Source code for alexandria.analyzers.detailed_uniformity

"""
Detailed Uniformity Analyzer

Samples pixel values along concentric circular profiles and records
angle/value pairs for each radius.
"""

from typing import Any, Dict, List, Optional, Tuple, Callable

import numpy as np
from scipy.ndimage import map_coordinates


[docs] class DetailedUniformityAnalyzer: """ Analyzer that samples concentric circular profiles for uniformity checks. """ def __init__( self, image: Optional[np.ndarray] = None, center: Optional[Tuple[float, float]] = None, pixel_spacing: Optional[float] = None, spacing: Optional[float] = None, dicom_set: Optional[List[Any]] = None, slice_index: Optional[int] = None, radii_mm: Optional[List[float]] = None, sample_step_mm: float = 1.0, n_samples: int = 360, center_finder: Optional[Callable[..., Tuple]] = None, center_finder_kwargs: Optional[Dict[str, Any]] = None, center_threshold: float = 400.0, center_threshold_fallback: float = -900.0, ): self.dicom_mode = dicom_set is not None and slice_index is not None self.dicom_set = dicom_set self.slice_index = slice_index self.averaged_image = None self.image = np.array(image, dtype=float) if image is not None else None self.center = center if pixel_spacing is not None: self.pixel_spacing = float(pixel_spacing) elif spacing is not None: self.pixel_spacing = float(spacing) else: self.pixel_spacing = None self.radii_mm = radii_mm if radii_mm is not None else [5.0, 10.0, 20.0, 30.0, 40.0, 50.0, 55.0, 60.0, 65.0, 70.0] self.sample_step_mm = float(sample_step_mm) self.n_samples = int(n_samples) self.center_finder = center_finder self.center_finder_kwargs = center_finder_kwargs or {} self.center_threshold = center_threshold self.center_threshold_fallback = center_threshold_fallback self.profile_data: List[Dict[str, Any]] = [] self.results: Dict[str, Any] = {}
[docs] def prepare_image(self) -> np.ndarray: """ Prepare image for analysis. """ if self.dicom_mode: idx = self.slice_index if idx is None: raise ValueError("slice_index must be provided for DICOM mode") if idx <= 0: im1 = self.dicom_set[idx].pixel_array im2 = self.dicom_set[idx + 1].pixel_array im3 = self.dicom_set[idx + 1].pixel_array elif idx >= len(self.dicom_set) - 1: im1 = self.dicom_set[idx].pixel_array im2 = self.dicom_set[idx - 1].pixel_array im3 = self.dicom_set[idx - 1].pixel_array else: im1 = self.dicom_set[idx].pixel_array im2 = self.dicom_set[idx + 1].pixel_array im3 = self.dicom_set[idx - 1].pixel_array self.averaged_image = (im1 + im2 + im3) / 3 self.image = self.averaged_image if self.pixel_spacing is None: self.pixel_spacing = float(self.dicom_set[idx].PixelSpacing[0]) return self.averaged_image if self.image is None: raise ValueError("Image must be provided in single-image mode") return self.image
def _compute_center(self) -> Tuple[float, float, Optional[float], Optional[float]]: if self.center is not None: return float(self.center[0]), float(self.center[1]), None, None from alexandria.utils import find_center_edge_detection def _unpack(value: Any) -> Tuple[int, int, Optional[float], Optional[float]]: if isinstance(value, (tuple, list)): if len(value) >= 4: return int(value[0]), int(value[1]), value[2], value[3] if len(value) == 2: return int(value[0]), int(value[1]), None, None raise ValueError( "center_finder must return (row, col) or (row, col, diameter_y_px, diameter_x_px)" ) if self.center_finder is not None: result = self.center_finder(self.image, **self.center_finder_kwargs) row, col, diameter_y, diameter_x = _unpack(result) else: row, col, diameter_y, diameter_x = find_center_edge_detection( self.image, threshold=self.center_threshold, fallback_threshold=self.center_threshold_fallback, return_diameters=True ) self.center = (float(col), float(row)) return float(col), float(row), diameter_y, diameter_x def _resolve_radii_mm(self) -> List[float]: max_radius = float(max(self.radii_mm)) if self.radii_mm else 0.0 if max_radius <= 0.0: return [] count = int(round(max_radius / self.sample_step_mm)) return [self.sample_step_mm * idx for idx in range(1, count + 1)] def _plot_radii_mm(self) -> List[float]: return [float(r) for r in self.radii_mm] def _sample_circle(self, center_x: float, center_y: float, radius_px: float) -> Tuple[np.ndarray, np.ndarray]: angles_deg = np.linspace(0.0, 360.0, self.n_samples, endpoint=False) angles_rad = np.deg2rad(angles_deg) xs = center_x + radius_px * np.cos(angles_rad) ys = center_y + radius_px * np.sin(angles_rad) coords = np.vstack([ys, xs]) values = map_coordinates(self.image, coords, order=1, mode='nearest') return angles_deg, values
[docs] def analyze(self) -> Dict[str, Any]: if self.image is None: self.prepare_image() if self.pixel_spacing is None: raise ValueError("Pixel spacing must be provided") center_x, center_y, _, _ = self._compute_center() radii_mm = self._resolve_radii_mm() self.profile_data = [] profile_results = [] for radius_mm in radii_mm: radius_px = radius_mm / self.pixel_spacing angles_deg, values = self._sample_circle(center_x, center_y, radius_px) profile = { "radius_mm": float(radius_mm), "radius_px": float(radius_px), "angles_deg": angles_deg, "values": values, "mean": float(np.mean(values)), "std": float(np.std(values)), "min": float(np.min(values)), "max": float(np.max(values)), } self.profile_data.append(profile) profile_results.append({ "radius_mm": profile["radius_mm"], "radius_px": profile["radius_px"], "angles_deg": profile["angles_deg"].tolist(), "values": profile["values"].tolist(), "mean": profile["mean"], "std": profile["std"], "min": profile["min"], "max": profile["max"], }) self.results = { "center": [center_x, center_y], "pixel_spacing": float(self.pixel_spacing), "n_samples": self.n_samples, "profiles": profile_results, "plot_radii_mm": self._plot_radii_mm(), "sample_step_mm": float(self.sample_step_mm), } return self.results