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