caly-camera-engine / utils /image_utils.py
Mohamed
initial deploy
1928c42
Raw
History Blame Contribute Delete
4.03 kB
"""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)