pypindou.image.preprocess 源代码

"""
Image loading and preprocessing helpers.

This module keeps image preparation independent from palette quantization.  The
high-level :func:`pypindou.pattern.generate_pattern` API uses these helpers to
load user images, remove transparent background noise, resize to a bead grid,
and optionally apply lightweight photographic adjustments before color
matching.

Example::

    >>> from PIL import Image
    >>> from pypindou.image.preprocess import enhance_image, resize_image
    >>> image = Image.new("RGBA", (16, 16), (200, 120, 80, 255))
    >>> resize_image(enhance_image(image, contrast=1.1), (8, 8)).size
    (8, 8)
"""

from __future__ import annotations

from pathlib import Path
from typing import Literal, Tuple, Union

import numpy as np
from PIL import Image, ImageEnhance, ImageFilter, ImageOps

FitMode = Literal["contain", "cover", "stretch"]
BackgroundMode = Literal["keep", "white", "transparent"]
ResampleMode = Literal["nearest", "box", "bilinear", "bicubic", "lanczos"]
PreprocessMode = Literal["none", "smooth", "median", "edge"]


_RESAMPLE = {
    "nearest": Image.Resampling.NEAREST,
    "box": Image.Resampling.BOX,
    "bilinear": Image.Resampling.BILINEAR,
    "bicubic": Image.Resampling.BICUBIC,
    "lanczos": Image.Resampling.LANCZOS,
}


[文档] def load_image(image: Union[str, Path, Image.Image]) -> Image.Image: """ Load an image as RGBA. :param image: Source image, image path, or :class:`PIL.Image.Image`. :type image: Union[str, pathlib.Path, PIL.Image.Image] :return: Loaded image converted to RGBA mode. :rtype: PIL.Image.Image :raises FileNotFoundError: If ``image`` is a path and does not exist. Example:: >>> from PIL import Image >>> load_image(Image.new("RGB", (1, 1))).mode 'RGBA' """ if isinstance(image, Image.Image): return image.convert("RGBA") return Image.open(image).convert("RGBA")
[文档] def resize_image( image: Image.Image, size: Tuple[int, int], *, fit: FitMode = "contain", background: Tuple[int, int, int, int] = (255, 255, 255, 0), resample: ResampleMode = "lanczos", ) -> Image.Image: """ Resize an image to a bead-grid size. ``contain`` preserves the full image and pads the remaining grid with ``background``. ``cover`` fills the grid and crops overflow from the center. ``stretch`` ignores the source aspect ratio. :param image: Source image. :type image: PIL.Image.Image :param size: Target size as ``(width, height)``. :type size: Tuple[int, int] :param fit: Aspect-ratio strategy, defaults to ``"contain"``. :type fit: FitMode, optional :param background: RGBA background used by ``contain``, defaults to ``(255, 255, 255, 0)``. :type background: Tuple[int, int, int, int], optional :param resample: Pillow resampling strategy, defaults to ``"lanczos"``. :type resample: ResampleMode, optional :return: Resized RGBA image. :rtype: PIL.Image.Image :raises ValueError: If ``size`` is not positive or ``fit``/``resample`` is unsupported. Example:: >>> from PIL import Image >>> resize_image(Image.new("RGBA", (10, 20)), (5, 5)).size (5, 5) """ width, height = size if width <= 0 or height <= 0: raise ValueError("Target size should be positive.") if resample not in _RESAMPLE: raise ValueError(f"Unsupported resample mode: {resample!r}.") src = image.convert("RGBA") if fit == "stretch": return src.resize((width, height), _RESAMPLE[resample]) sx = width / src.width sy = height / src.height if fit == "contain": scale = min(sx, sy) elif fit == "cover": scale = max(sx, sy) else: raise ValueError(f"Unsupported fit mode: {fit!r}.") resized = src.resize( (max(1, int(round(src.width * scale))), max(1, int(round(src.height * scale)))), _RESAMPLE[resample], ) if fit == "cover": left = max(0, (resized.width - width) // 2) top = max(0, (resized.height - height) // 2) return resized.crop((left, top, left + width, top + height)) canvas = Image.new("RGBA", (width, height), background) left = (width - resized.width) // 2 top = (height - resized.height) // 2 canvas.alpha_composite(resized, (left, top)) return canvas
[文档] def enhance_image( image: Image.Image, *, brightness: float = 1.0, contrast: float = 1.0, saturation: float = 1.0, sharpness: float = 1.0, grayscale: float = 0.0, ) -> Image.Image: """ Apply lightweight photographic adjustments while preserving alpha. Adjustment factors follow Pillow's :mod:`PIL.ImageEnhance` convention: ``1.0`` leaves the channel unchanged, values below ``1.0`` reduce the effect, and values above ``1.0`` increase it. ``grayscale`` is a blend ratio from the adjusted RGB image to its grayscale version. :param image: Source image. :type image: PIL.Image.Image :param brightness: Brightness factor, defaults to ``1.0``. :type brightness: float, optional :param contrast: Contrast factor, defaults to ``1.0``. :type contrast: float, optional :param saturation: Color saturation factor, defaults to ``1.0``. :type saturation: float, optional :param sharpness: Sharpness factor, defaults to ``1.0``. :type sharpness: float, optional :param grayscale: Grayscale blend ratio in ``[0.0, 1.0]``, defaults to ``0.0``. :type grayscale: float, optional :return: Adjusted RGBA image. :rtype: PIL.Image.Image :raises ValueError: If a factor is negative or ``grayscale`` is outside ``[0.0, 1.0]``. Example:: >>> from PIL import Image >>> enhance_image(Image.new("RGBA", (2, 2)), contrast=1.2).mode 'RGBA' """ for name, value in { "brightness": brightness, "contrast": contrast, "saturation": saturation, "sharpness": sharpness, }.items(): if value < 0: raise ValueError(f"{name} should be non-negative.") if not 0.0 <= grayscale <= 1.0: raise ValueError("grayscale should be in [0.0, 1.0].") src = image.convert("RGBA") alpha = src.getchannel("A") rgb = src.convert("RGB") if brightness != 1.0: rgb = ImageEnhance.Brightness(rgb).enhance(brightness) if contrast != 1.0: rgb = ImageEnhance.Contrast(rgb).enhance(contrast) if saturation != 1.0: rgb = ImageEnhance.Color(rgb).enhance(saturation) if sharpness != 1.0: rgb = ImageEnhance.Sharpness(rgb).enhance(sharpness) if grayscale: gray = ImageOps.grayscale(rgb).convert("RGB") rgb = Image.blend(rgb, gray, grayscale) result = rgb.convert("RGBA") result.putalpha(alpha) return result
[文档] def prefilter_image( image: Image.Image, *, mode: PreprocessMode = "none", radius: float = 1.0, ) -> Image.Image: """ Denoise or sharpen an image before it is resized to the bead grid. ``smooth`` applies a mild Gaussian blur, ``median`` removes isolated sensor/compression speckles, and ``edge`` combines median filtering with a light unsharp mask. The alpha channel is preserved unchanged. :param image: Source image. :type image: PIL.Image.Image :param mode: Pre-filtering strategy, defaults to ``"none"``. :type mode: PreprocessMode, optional :param radius: Filter radius or median radius, defaults to ``1.0``. :type radius: float, optional :return: Filtered RGBA image. :rtype: PIL.Image.Image :raises ValueError: If ``radius`` is negative or ``mode`` is unsupported. Example:: >>> from PIL import Image >>> prefilter_image(Image.new("RGBA", (4, 4)), mode="median").size (4, 4) """ if radius < 0: raise ValueError("radius should be non-negative.") src = image.convert("RGBA") if mode == "none" or radius == 0: return src alpha = src.getchannel("A") rgb = src.convert("RGB") if mode == "smooth": filtered = rgb.filter(ImageFilter.GaussianBlur(radius=radius)) elif mode == "median": size = max(3, int(round(radius)) * 2 + 1) if size % 2 == 0: size += 1 filtered = rgb.filter(ImageFilter.MedianFilter(size=size)) elif mode == "edge": filtered = rgb.filter(ImageFilter.MedianFilter(size=3)) filtered = filtered.filter(ImageFilter.UnsharpMask(radius=max(1.0, radius), percent=90, threshold=4)) else: raise ValueError(f"Unsupported prefilter mode: {mode!r}.") result = filtered.convert("RGBA") result.putalpha(alpha) return result
[文档] def remove_background_by_alpha(image: Image.Image, *, alpha_threshold: int = 16) -> Image.Image: """ Set low-alpha pixels to fully transparent. :param image: Source image. :type image: PIL.Image.Image :param alpha_threshold: Pixels with alpha at or below this value become fully transparent, defaults to ``16``. :type alpha_threshold: int, optional :return: RGBA image with low-alpha pixels cleared. :rtype: PIL.Image.Image :raises ValueError: If ``alpha_threshold`` is outside ``[0, 255]``. Example:: >>> from PIL import Image >>> img = Image.new("RGBA", (1, 1), (1, 2, 3, 0)) >>> remove_background_by_alpha(img).getpixel((0, 0)) (0, 0, 0, 0) """ if not 0 <= alpha_threshold <= 255: raise ValueError("alpha_threshold should be in [0, 255].") arr = np.asarray(image.convert("RGBA")).copy() mask = arr[:, :, 3] <= alpha_threshold arr[mask] = (0, 0, 0, 0) return Image.fromarray(arr, mode="RGBA")
[文档] def rgba_to_rgb_array( image: Image.Image, *, background: BackgroundMode = "white", alpha_threshold: int = 16, ) -> Tuple[np.ndarray, np.ndarray]: """ Convert an RGBA image to an RGB array and an active-pixel mask. :param image: Source image. :type image: PIL.Image.Image :param background: Alpha handling strategy, defaults to ``"white"``. :type background: BackgroundMode, optional :param alpha_threshold: Alpha threshold used by transparent mode, defaults to ``16``. :type alpha_threshold: int, optional :return: RGB ``uint8`` array and boolean active-pixel mask. :rtype: Tuple[numpy.ndarray, numpy.ndarray] :raises ValueError: If ``background`` is unsupported. Example:: >>> from PIL import Image >>> rgb, active = rgba_to_rgb_array(Image.new("RGBA", (1, 1))) >>> rgb.shape, active.shape ((1, 1, 3), (1, 1)) """ rgba = np.asarray(image.convert("RGBA"), dtype=np.uint8) alpha = rgba[:, :, 3] active = alpha > alpha_threshold if background == "transparent": rgb = rgba[:, :, :3].copy() elif background == "white": rgb = rgba[:, :, :3].astype(np.float64) alpha_f = (alpha.astype(np.float64) / 255.0)[:, :, None] rgb = rgb * alpha_f + 255.0 * (1.0 - alpha_f) rgb = np.clip(np.rint(rgb), 0, 255).astype(np.uint8) active = np.ones(alpha.shape, dtype=bool) elif background == "keep": rgb = rgba[:, :, :3].copy() active = np.ones(alpha.shape, dtype=bool) else: raise ValueError(f"Unsupported background mode: {background!r}.") return rgb, active