feat(platform): images port — decode, resolution bar (Q-3), WebP renditions (SD-0002 §6.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
"""platform/images — pure image processing (SD-0002 §6.2).
|
||||
|
||||
Model-free, deterministic, no I/O of its own: decode bytes, enforce the
|
||||
resolution bar (INV-18 / Q-3), and emit downscale-only WebP renditions. The
|
||||
caller (the image-fetch phase) owns fetching, storage, and status.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
# Q-3 (SD-0002 §13): the minimum shorter-side dimension; below it an image is
|
||||
# rejected_low_res. Rejects icons/thumbnails, accepts normal product photos.
|
||||
MIN_IMAGE_SHORT_SIDE = 500
|
||||
|
||||
# Rendition longest-side caps (px); downscale-only — a smaller source is kept
|
||||
# at its own size, never upscaled. Encoded WebP.
|
||||
RENDITIONS = {"thumb": 160, "card": 480, "detail": 1600}
|
||||
|
||||
# Source formats we accept (Pillow format names).
|
||||
_ACCEPTED = {"JPEG", "PNG", "WEBP"}
|
||||
|
||||
_WEBP_QUALITY = 82
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Processed:
|
||||
source_format: str
|
||||
width: int
|
||||
height: int
|
||||
renditions: dict[str, bytes] # name -> WebP bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rejected:
|
||||
reason: str # "rejected_low_res" | "rejected_not_image"
|
||||
|
||||
|
||||
def process(data: bytes) -> Processed | Rejected:
|
||||
"""Decode + bar-check + renditions, or a typed rejection. Never raises on
|
||||
bad input — undecodable/unsupported bytes are Rejected('rejected_not_image')."""
|
||||
try:
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
im.load()
|
||||
source_format = im.format or ""
|
||||
if source_format not in _ACCEPTED:
|
||||
return Rejected(reason="rejected_not_image")
|
||||
rgb = im.convert("RGB")
|
||||
width, height = rgb.size
|
||||
if min(width, height) < MIN_IMAGE_SHORT_SIDE:
|
||||
return Rejected(reason="rejected_low_res")
|
||||
renditions = {
|
||||
name: _rendition(rgb, cap) for name, cap in RENDITIONS.items()
|
||||
}
|
||||
return Processed(
|
||||
source_format=source_format,
|
||||
width=width,
|
||||
height=height,
|
||||
renditions=renditions,
|
||||
)
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
return Rejected(reason="rejected_not_image")
|
||||
|
||||
|
||||
def _rendition(rgb: Image.Image, cap: int) -> bytes:
|
||||
longest = max(rgb.size)
|
||||
if longest > cap:
|
||||
scale = cap / longest
|
||||
size = (max(1, round(rgb.width * scale)), max(1, round(rgb.height * scale)))
|
||||
resized = rgb.resize(size, Image.LANCZOS)
|
||||
else:
|
||||
resized = rgb
|
||||
buf = io.BytesIO()
|
||||
resized.save(buf, format="WEBP", quality=_WEBP_QUALITY, method=6)
|
||||
return buf.getvalue()
|
||||
Reference in New Issue
Block a user