""" Image decoding. In the new architecture, each detector does its own tensor preprocessing (CLIP needs CLIP's specific resize/normalise; frequency detectors don't resize at all). This module just turns raw bytes into a validated PIL Image that all detectors can consume. """ from __future__ import annotations from io import BytesIO from PIL import Image, ImageOps, UnidentifiedImageError # Pillow's default decompression-bomb guard is ~89 MP. Tighten it: a 25 MP # image is more than enough for any consumer photo, and rejecting larger # inputs caps memory usage on the inference server. Image.MAX_IMAGE_PIXELS = 25_000_000 class InvalidImageError(ValueError): """Raised when the bytes do not decode as a usable image.""" def decode(image_bytes: bytes) -> Image.Image: """Decode raw bytes into an RGB PIL image. Raises InvalidImageError on failure.""" try: image = Image.open(BytesIO(image_bytes)) image.load() # Force decode now so we catch errors here, not later. except (UnidentifiedImageError, OSError) as exc: raise InvalidImageError(f"Could not decode image: {exc}") from exc except Image.DecompressionBombError as exc: raise InvalidImageError(f"Image too large: {exc}") from exc # Phone cameras almost always store the sensor readout unrotated and record # the intended rotation in the EXIF Orientation tag — 9 of the first 13 real # test photos (Galaxy S23 Ultra) came through as orientation 6, i.e. stored # sideways. Every viewer applies that tag, so a user who uploads a portrait # photo sees it upright and assumes we scanned it upright. Without this call # the detector was seeing it rotated 90°, which is both wrong for users and # would have silently corrupted the P0 false-positive measurement. # exif_transpose is a no-op when the tag is absent or already upright. image = ImageOps.exif_transpose(image) if image.mode != "RGB": image = image.convert("RGB") return image