"""Shared crop primitive for the region/crop pipeline (#114). One model- and transport-agnostic function sits at the trunk of both crop jobs: - CCIP characters: a face/figure detector proposes regions → crop → CCIP-embed. - SigLIP concepts: head-guided / saliency proposes regions → crop → SigLIP-embed. Only the PROPOSER (where to crop) and the EMBEDDER (what to run) differ; the crop itself — including the lower-bound size floor below which a region is too small to embed reliably — is identical, so it lives here and both jobs call it. The actual detector + embedders run in the GPU agent; this is pure Pillow so it's importable + testable anywhere (and the agent imports it for the crop step). """ from __future__ import annotations from PIL import Image # Size floor: a region must be at least this big on its SHORTER edge to be worth # embedding — a smaller crop is a blurry upscale carrying little real signal, and # unbounded tiny crops would explode the bag. Expressed as BOTH a fraction of the # image's short side and an absolute pixel floor; the larger of the two wins. MIN_CROP_FRACTION = 0.10 MIN_CROP_PX = 64 def _to_pixels(bbox: tuple[float, float, float, float], w: int, h: int): """Normalized (x, y, w, h) in [0,1] → pixel (x, y, w, h).""" x, y, bw, bh = bbox return x * w, y * h, bw * w, bh * h def crop_region( img: Image.Image, bbox: tuple[float, float, float, float], *, pad: float = 0.0, min_fraction: float = MIN_CROP_FRACTION, min_px: int = MIN_CROP_PX, out_size: int | None = None, ) -> Image.Image | None: """Crop a NORMALIZED bbox (x, y, w, h in [0,1]) from img. - pad: grow the box by this fraction on each side (e.g. 0.15 = +15% context), clamped to the image bounds. - Returns None when the resulting region is below the size floor (too small to embed reliably) — the caller skips embedding it. - out_size: if given, resize the crop to out_size×out_size; otherwise return the raw crop and let the embedder do its own preprocessing. """ iw, ih = img.size px, py, pw, ph = _to_pixels(bbox, iw, ih) if pad: px -= pw * pad / 2.0 py -= ph * pad / 2.0 pw *= (1.0 + pad) ph *= (1.0 + pad) left = max(0, int(round(px))) top = max(0, int(round(py))) right = min(iw, int(round(px + pw))) bottom = min(ih, int(round(py + ph))) if right <= left or bottom <= top: return None floor = max(min_px, int(min_fraction * min(iw, ih))) if min(right - left, bottom - top) < floor: return None crop = img.crop((left, top, right, bottom)).convert("RGB") if out_size: crop = crop.resize((out_size, out_size)) return crop