"""Mathematical helpers for masks, calibration, and volume estimation.""" from __future__ import annotations import math from typing import Sequence import numpy as np def mask_iou(a: np.ndarray, b: np.ndarray) -> float: """Return intersection-over-union for two binary masks.""" a_bool = np.asarray(a).astype(bool) b_bool = np.asarray(b).astype(bool) intersection = np.logical_and(a_bool, b_bool).sum() union = np.logical_or(a_bool, b_bool).sum() return float(intersection) / float(union) if union else 0.0 def bbox_iou(a: Sequence[float], b: Sequence[float]) -> float: """Return intersection-over-union for two ``[x1, y1, x2, y2]`` boxes.""" ax1, ay1, ax2, ay2 = map(float, a) bx1, by1, bx2, by2 = map(float, b) ix1, iy1 = max(ax1, bx1), max(ay1, by1) ix2, iy2 = min(ax2, bx2), min(ay2, by2) iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1) inter = iw * ih area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1) area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1) union = area_a + area_b - inter return inter / union if union else 0.0 def polygon_area(points: np.ndarray) -> float: """Return polygon area in pixel units using the shoelace formula.""" if points is None or len(points) < 3: return 0.0 pts = np.asarray(points, dtype=np.float64) x = pts[:, 0] y = pts[:, 1] return float(abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) / 2.0) def cm_per_pixel_from_real_width(real_width_cm: float, width_pixels: float) -> float: """Convert a known real-world width and pixel width into cm-per-pixel.""" if width_pixels <= 0: raise ValueError("width_pixels must be positive") return float(real_width_cm) / float(width_pixels) def area_cm2_per_pixel(cm_per_pixel: float) -> float: """Return square centimeters represented by one image pixel.""" if cm_per_pixel <= 0: raise ValueError("cm_per_pixel must be positive") return float(cm_per_pixel) ** 2 def cylinder_volume_cm3(radius_cm: float, height_cm: float) -> float: """Return cylinder volume in cubic centimeters.""" return math.pi * max(radius_cm, 0.0) ** 2 * max(height_cm, 0.0) def solid_volume_cm3(mask_area_cm2: float, height_cm: float) -> float: """Return prism-like solid volume in cubic centimeters.""" return max(mask_area_cm2, 0.0) * max(height_cm, 0.0) def soft_clamp(value: float, minimum: float, maximum: float) -> float: """Clamp low values hard, reduce high outliers smoothly. Examples: soft_clamp(100, 50, 200) -> 100 (within range, unchanged) soft_clamp(10, 50, 200) -> 50 (below min, hard floor) soft_clamp(500, 50, 200) -> ~280 (above max, smoothly reduced) soft_clamp(900, 50, 200) -> ~320 (far above max, asymptotes to 320) """ value = float(value) minimum = float(minimum) maximum = float(maximum) if maximum < minimum: minimum, maximum = maximum, minimum if value < minimum: return minimum if value <= maximum: return value soft_top = maximum * 1.6 excess = value - maximum squashed = maximum + (soft_top - maximum) * (1.0 - math.exp(-excess / max(maximum, 1.0))) return float(min(squashed, soft_top)) def robust_percentile(values: np.ndarray, percentile: float, default: float = 0.0) -> float: """Return a percentile while tolerating empty and non-finite arrays.""" arr = np.asarray(values, dtype=np.float32) arr = arr[np.isfinite(arr)] if arr.size == 0: return float(default) return float(np.percentile(arr, percentile))