"""OpenCV and image conversion helpers used by the CALY pipeline.""" from __future__ import annotations from pathlib import Path from typing import Iterable, Sequence import cv2 import numpy as np from PIL import Image def read_image_bgr(image_path: str | Path) -> np.ndarray: """Read an image from disk as BGR and raise a useful error on failure.""" image = cv2.imread(str(image_path), cv2.IMREAD_COLOR) if image is None: raise ValueError(f"Could not read image: {image_path}") return image def decode_image_bytes(content: bytes) -> np.ndarray: """Decode uploaded image bytes into a BGR OpenCV array.""" if not content: raise ValueError("Uploaded image is empty") data = np.frombuffer(content, dtype=np.uint8) image = cv2.imdecode(data, cv2.IMREAD_COLOR) if image is None: raise ValueError("Uploaded file is not a valid image") return image def bgr_to_rgb(image: np.ndarray) -> np.ndarray: """Convert a BGR OpenCV image to RGB.""" return cv2.cvtColor(image, cv2.COLOR_BGR2RGB) def bgr_to_pil(image: np.ndarray) -> Image.Image: """Convert a BGR OpenCV image to a PIL RGB image.""" return Image.fromarray(bgr_to_rgb(image)) def polygon_to_mask( polygon: np.ndarray | None, shape: tuple[int, int], fallback_bbox: Sequence[float] | None = None, ) -> np.ndarray: """Rasterize a polygon to a binary mask, optionally using a bbox fallback.""" height, width = shape mask = np.zeros((height, width), dtype=np.uint8) if polygon is not None and len(polygon) >= 3: cv2.fillPoly(mask, [np.asarray(polygon, dtype=np.int32)], 1) return mask if fallback_bbox is not None: x1, y1, x2, y2 = [int(round(v)) for v in fallback_bbox] x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(width, x2), min(height, y2) if x2 > x1 and y2 > y1: mask[y1:y2, x1:x2] = 1 return mask def resize_mask(mask: np.ndarray, shape: tuple[int, int], threshold: float = 0.5) -> np.ndarray: """Resize a mask to ``(height, width)`` and return a boolean mask.""" height, width = shape resized = cv2.resize(mask.astype(np.float32), (width, height), interpolation=cv2.INTER_LINEAR) return resized > threshold def draw_mask_overlay( image_rgb: np.ndarray, mask: np.ndarray, color: tuple[int, int, int], alpha: float = 0.45, ) -> np.ndarray: """Return a copy of ``image_rgb`` with a colored transparent mask overlay.""" output = image_rgb.copy() mask_bool = mask.astype(bool) overlay = np.zeros_like(output) overlay[:, :] = np.asarray(color, dtype=np.uint8) output[mask_bool] = ( output[mask_bool].astype(np.float32) * (1.0 - alpha) + overlay[mask_bool].astype(np.float32) * alpha ).astype(np.uint8) return output def estimate_plate_diameter_pixels( gray_image: np.ndarray, food_boxes: Iterable[Sequence[float]], image_width: int, image_height: int, ) -> float: """Estimate plate diameter from Hough circles, falling back to food extent.""" blurred = cv2.GaussianBlur(gray_image, (9, 9), 1.5) min_dim = min(image_width, image_height) circles = cv2.HoughCircles( blurred, cv2.HOUGH_GRADIENT, dp=1.2, minDist=max(40, min_dim // 3), param1=90, param2=28, minRadius=max(20, int(min_dim * 0.18)), maxRadius=int(min_dim * 0.55), ) if circles is not None and len(circles[0]) > 0: circles = np.round(circles[0]).astype(int) center = np.array([image_width / 2.0, image_height / 2.0]) best = min(circles, key=lambda c: np.linalg.norm(np.array([c[0], c[1]]) - center)) return float(best[2] * 2) boxes = list(food_boxes) if boxes: arr = np.asarray(boxes, dtype=np.float32) width = float(arr[:, 2].max() - arr[:, 0].min()) height = float(arr[:, 3].max() - arr[:, 1].min()) return max(width, height) * 1.35 return float(min_dim * 0.80)