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