"""Quality metrics for dense and post-processed contours.""" from __future__ import annotations import math import numpy as np from .geometry import rasterize_contour, strip_duplicate_endpoint, xy_to_physical from .types import ContourQualityMetrics, PixelSpacing def symmetric_boundary_distances(reference: np.ndarray, candidate: np.ndarray) -> np.ndarray: reference = strip_duplicate_endpoint(reference) candidate = strip_duplicate_endpoint(candidate) if len(reference) == 0 or len(candidate) == 0: return np.asarray([math.inf], dtype=np.float64) try: from scipy.spatial import cKDTree forward = cKDTree(candidate).query(reference)[0] reverse = cKDTree(reference).query(candidate)[0] except Exception: forward = np.linalg.norm( reference[:, None, :] - candidate[None, :, :], axis=2 ).min(axis=1) reverse = np.linalg.norm( candidate[:, None, :] - reference[None, :, :], axis=2 ).min(axis=1) return np.concatenate([forward, reverse]).astype(np.float64) def contour_roughness(points: np.ndarray) -> float: points = strip_duplicate_endpoint(points) if len(points) < 4: return 0.0 previous = np.roll(points, 1, axis=0) following = np.roll(points, -1, axis=0) second_difference = following - 2.0 * points + previous perimeter = float(np.linalg.norm(following - points, axis=1).sum()) if perimeter <= 1e-12: return 0.0 return float(np.linalg.norm(second_difference, axis=1).sum() / perimeter) def contour_quality_metrics( dense_xy: np.ndarray, smooth_xy: np.ndarray, shape: tuple[int, int], pixel_spacing: PixelSpacing, control_point_count: int, ) -> tuple[ContourQualityMetrics, np.ndarray]: dense_mask = rasterize_contour(dense_xy, shape) rendered_mask = rasterize_contour(smooth_xy, shape) intersection = int(np.logical_and(dense_mask, rendered_mask).sum()) union = int(np.logical_or(dense_mask, rendered_mask).sum()) source_area = int(dense_mask.sum()) rendered_area = int(rendered_mask.sum()) distances = symmetric_boundary_distances( xy_to_physical(dense_xy, pixel_spacing), xy_to_physical(smooth_xy, pixel_spacing), ) source_roughness = contour_roughness(xy_to_physical(dense_xy, pixel_spacing)) rendered_roughness = contour_roughness(xy_to_physical(smooth_xy, pixel_spacing)) metrics = ContourQualityMetrics( control_point_count=int(control_point_count), boundary_mean_mm=float(np.mean(distances)), boundary_p95_mm=float(np.percentile(distances, 95)), boundary_max_mm=float(np.max(distances)), mask_iou=float(intersection / union) if union else 0.0, source_area_px=source_area, rendered_area_px=rendered_area, area_change_pct=( float(100.0 * (rendered_area - source_area) / source_area) if source_area else math.inf ), roughness_ratio=( float(rendered_roughness / source_roughness) if source_roughness > 0 else 0.0 ), ) return metrics, rendered_mask