"""OpenCV-based preprocessing for uploaded room photos. Validates decodability, applies EXIF orientation, resizes so the longest edge is <= MAX_EDGE_PX (aspect preserved), and writes a normalized copy to disk. """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path import cv2 import numpy as np from PIL import Image, ImageOps from ..config import settings @dataclass class PreprocessResult: path: Path # normalized image path on disk rel_path: str # path relative to the server root (e.g. "static/uploads/x.jpg") width: int height: int def _load_oriented(raw_path: Path) -> Image.Image: img = Image.open(raw_path) img = ImageOps.exif_transpose(img) # honor EXIF orientation return img.convert("RGB") def preprocess_image( raw_path: Path, out_path: Path, max_edge: int | None = None ) -> PreprocessResult: """Validate, auto-orient, resize and save a normalized image. Raises ValueError if the file cannot be decoded as an image. """ max_edge = max_edge or settings.MAX_EDGE_PX try: pil = _load_oriented(raw_path) except Exception as exc: # noqa: BLE001 raise ValueError(f"Could not decode image: {raw_path.name}") from exc arr = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR) if arr is None or arr.size == 0: raise ValueError(f"Empty/invalid image: {raw_path.name}") h, w = arr.shape[:2] scale = min(1.0, max_edge / float(max(h, w))) if scale < 1.0: w, h = int(round(w * scale)), int(round(h * scale)) arr = cv2.resize(arr, (w, h), interpolation=cv2.INTER_AREA) out_path.parent.mkdir(parents=True, exist_ok=True) cv2.imwrite(str(out_path), arr) rel = out_path.relative_to(settings.STATIC_DIR).as_posix() return PreprocessResult(path=out_path, rel_path=f"static/{rel}", width=w, height=h)