| """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) |
|
|