"""Step-specific metric collectors for pipeline stages.
Each ``collect_*`` function records the relevant metrics for a single pipeline
step, saves the JSON file, and returns the populated :class:`PipelineMetrics`.
"""
from pathlib import Path
from typing import Any
import numpy as np
from linumpy.metrics.core import PipelineMetrics
[docs]
def collect_normalization_metrics(
vol_normalized: np.ndarray,
agarose_mask: np.ndarray,
otsu_threshold: float,
background_thresholds: np.ndarray,
output_path: Path,
input_path: Path | None = None,
params: dict | None = None,
) -> PipelineMetrics:
"""
Collect metrics for intensity normalization step.
Parameters
----------
vol_normalized : np.ndarray
The normalized volume.
agarose_mask : np.ndarray
The agarose mask used.
otsu_threshold : float
Otsu threshold computed.
background_thresholds : np.ndarray
Background thresholds per slice.
output_path : str or Path
Path to the output file.
input_path : str, optional
Path to the input image.
params : dict, optional
Dictionary of parameters used.
Returns
-------
PipelineMetrics
Metrics object (already saved).
"""
output_path = Path(output_path)
metrics = PipelineMetrics("normalize_intensities", str(output_path.parent))
if input_path:
metrics.add_info("input_volume", str(input_path), "Input volume path")
metrics.add_info("output_volume", str(output_path), "Output volume path")
metrics.add_info("volume_shape", list(vol_normalized.shape), "Volume shape")
metrics.add_params(params)
# Agarose mask metrics
agarose_coverage = float(np.sum(agarose_mask)) / agarose_mask.size
metrics.add_metric(
"agarose_coverage",
agarose_coverage,
description="Fraction of image classified as agarose",
threshold_name="agarose_coverage",
)
metrics.add_metric("otsu_threshold", float(otsu_threshold), description="Otsu threshold used for agarose detection")
# Background normalization metrics
metrics.add_metric(
"mean_background", float(np.mean(background_thresholds)), description="Mean background threshold across slices"
)
metrics.add_metric(
"std_background",
float(np.std(background_thresholds)),
description="Std dev of background thresholds",
threshold_name="std_background",
)
return metrics.finalize(f"{output_path.stem}_metrics.json")
[docs]
def collect_pairwise_registration_metrics(
registration_error: float,
tx: float,
ty: float,
rotation_deg: float,
best_z_index: int,
expected_z_index: int,
output_path: Path,
fixed_path: Path | None = None,
moving_path: Path | None = None,
params: dict | None = None,
z_correlation: float = 0.0,
) -> PipelineMetrics:
"""
Collect metrics for pairwise registration step.
Parameters
----------
registration_error : float
Registration error value.
tx, ty : float
Translation in X and Y.
rotation_deg : float
Rotation in degrees.
best_z_index : int
Best matching z-index.
expected_z_index : int
Expected z-index based on slice interval.
output_path : str or Path
Path to the output directory.
fixed_path, moving_path : str, optional
Paths to fixed and moving volumes.
params : dict, optional
Dictionary of parameters used.
z_correlation : float, optional
Normalized cross-correlation score from Z-matching (0-1). Higher values
indicate a reliable Z-match between the two slices.
Returns
-------
PipelineMetrics
Metrics object (already saved).
"""
output_path = Path(output_path)
metrics = PipelineMetrics("pairwise_registration", str(output_path))
if fixed_path:
metrics.add_info("fixed_volume", str(fixed_path), "Path to fixed volume")
if moving_path:
metrics.add_info("moving_volume", str(moving_path), "Path to moving volume")
metrics.add_info("best_z_offset", int(best_z_index), "Best matching z-index in fixed volume")
metrics.add_params(params)
translation_magnitude = float(np.sqrt(tx**2 + ty**2))
metrics.add_metric(
"registration_error",
float(registration_error),
description="Registration error (lower is better)",
threshold_name="registration_error",
)
metrics.add_metric("translation_x", float(tx), unit="pixels", description="Translation in X direction")
metrics.add_metric("translation_y", float(ty), unit="pixels", description="Translation in Y direction")
metrics.add_metric(
"translation_magnitude",
translation_magnitude,
unit="pixels",
description="Total translation magnitude",
threshold_name="translation_magnitude",
)
metrics.add_metric(
"rotation", float(rotation_deg), unit="degrees", description="Rotation angle", threshold_name="rotation_degrees"
)
metrics.add_metric(
"z_drift", int(abs(best_z_index - expected_z_index)), unit="voxels", description="Deviation from expected z-index"
)
metrics.add_metric(
"z_correlation",
float(max(0.0, z_correlation)),
unit="",
description="Z-matching cross-correlation score (0-1; higher = more reliable)",
threshold_name="correlation",
)
# Composite confidence score (0-1): combines Z-correlation, normalized translation
# and normalized rotation. Used downstream by adaptive transform degradation
# in linum_stack_slices_motor.py to decide whether to apply the full transform,
# rotation-only, or skip entirely.
max_translation = float(params.get("max_translation_px", 50.0)) if params else 50.0
max_rotation = float(params.get("max_rotation_deg", 5.0)) if params else 5.0
norm_translation = min(translation_magnitude / max(max_translation, 1.0), 1.0)
norm_rotation = min(abs(rotation_deg) / max(max_rotation, 1.0), 1.0)
z_corr_score = float(max(0.0, z_correlation))
confidence = float(np.clip(0.5 * z_corr_score + 0.3 * (1.0 - norm_translation) + 0.2 * (1.0 - norm_rotation), 0.0, 1.0))
metrics.add_metric(
"registration_confidence",
confidence,
unit="",
description="Overall transform reliability score (0=unreliable, 1=reliable)",
custom_thresholds={"warning": 0.4, "error": 0.3, "higher_is_better": True},
)
return metrics.finalize()
[docs]
def collect_interface_crop_metrics(
detected_interface: int,
crop_depth_px: int,
start_idx: int,
end_idx: int,
input_shape: tuple[int, ...],
output_shape: tuple[int, ...],
resolution_um: float,
output_path: Path,
input_path: Path | None = None,
padding_needed: bool = False,
) -> PipelineMetrics:
"""
Collect metrics for interface cropping step.
Parameters
----------
detected_interface : int
Detected interface depth in voxels.
crop_depth_px : int
Cropping depth in voxels.
start_idx, end_idx : int
Start and end indices for cropping.
input_shape, output_shape : tuple
Input and output volume shapes.
resolution_um : float
Resolution in microns.
output_path : str or Path
Path to the output file.
input_path : str, optional
Path to the input file.
padding_needed : bool
Whether padding was required.
Returns
-------
PipelineMetrics
Metrics object (already saved).
"""
output_path = Path(output_path)
metrics = PipelineMetrics("crop_interface", str(output_path.parent))
if input_path:
metrics.add_info("input_volume", str(input_path), "Input volume path")
metrics.add_info("output_volume", str(output_path), "Output volume path")
metrics.add_info("input_shape", list(input_shape), "Input volume shape")
metrics.add_info("output_shape", list(output_shape), "Output volume shape")
metrics.add_info("resolution_um", float(resolution_um), "Resolution in microns")
metrics.add_metric(
"detected_interface_depth", int(detected_interface), unit="voxels", description="Detected interface depth in voxels"
)
metrics.add_metric(
"detected_interface_depth_um",
float(detected_interface * resolution_um),
unit="um",
description="Detected interface depth in microns",
)
metrics.add_metric("crop_depth", int(crop_depth_px), unit="voxels", description="Cropping depth in voxels")
metrics.add_metric("start_index", int(start_idx), unit="voxels", description="Start index for cropping")
metrics.add_metric("end_index", int(end_idx), unit="voxels", description="End index for cropping")
# Quality checks
min_depth = PipelineMetrics.DEFAULT_THRESHOLDS["interface_min_depth_px"]["error"]
max_fraction = PipelineMetrics.DEFAULT_THRESHOLDS["interface_max_depth_fraction"]["error"]
if detected_interface < min_depth:
metrics.add_metric(
"interface_quality", "warning", description="Interface detected very close to start - may be incorrect"
)
elif detected_interface > input_shape[0] * max_fraction:
metrics.add_metric("interface_quality", "warning", description="Interface detected past halfway - check detection")
else:
metrics.add_metric("interface_quality", "ok", description="Interface detection appears reasonable")
metrics.add_info("padding_needed", padding_needed, "Whether padding was required")
return metrics.finalize(f"{output_path.stem}_metrics.json")
[docs]
def collect_psf_compensation_metrics(
psf: np.ndarray,
agarose_coverage: float,
output_path: Path,
input_path: Path | None = None,
fit_gaussian: bool = False,
) -> PipelineMetrics:
"""
Collect metrics for PSF compensation step.
Parameters
----------
psf : np.ndarray
The estimated PSF profile.
agarose_coverage : float
Fraction of image classified as agarose.
output_path : str or Path
Path to the output file.
input_path : str, optional
Path to the input file.
fit_gaussian : bool
Whether Gaussian fit was used.
Returns
-------
PipelineMetrics
Metrics object (already saved).
"""
output_path = Path(output_path)
metrics = PipelineMetrics("psf_compensation", str(output_path.parent))
if input_path:
metrics.add_info("input_volume", str(input_path), "Input volume path")
metrics.add_info("output_volume", str(output_path), "Output volume path")
metrics.add_info("fit_gaussian", fit_gaussian, "Whether Gaussian fit was used")
# PSF profile metrics
psf_max = float(np.max(psf))
psf_peak_index = int(np.argmax(psf))
metrics.add_metric(
"psf_max",
psf_max,
description="Maximum PSF value",
custom_thresholds={"warning": 0.1, "error": 0.05, "higher_is_better": True},
)
metrics.add_metric("psf_peak_depth", psf_peak_index, unit="voxels", description="Depth of PSF peak")
metrics.add_metric(
"agarose_coverage",
agarose_coverage,
description="Fraction of image classified as agarose",
threshold_name="agarose_coverage",
)
# Profile quality assessment
if psf_max < 0.05:
metrics.add_metric("profile_quality", "poor", description="PSF profile quality assessment - very low signal")
elif psf_peak_index < 5 or psf_peak_index > len(psf) * 0.8:
metrics.add_metric("profile_quality", "warning", description="PSF peak at unexpected depth")
else:
metrics.add_metric("profile_quality", "good", description="PSF profile appears reasonable")
return metrics.finalize(f"{output_path.stem}_metrics.json")
[docs]
def collect_stack_metrics(
output_shape: tuple[int, ...],
z_offsets: np.ndarray,
num_slices: int,
resolution: list[float],
output_path: Path,
blend_enabled: bool = False,
normalize_enabled: bool = False,
z_matches_df: Any = None,
decisions_df: Any = None,
) -> PipelineMetrics:
"""
Collect metrics for slice stacking step.
Parameters
----------
output_shape : tuple
Final output shape.
z_offsets : np.ndarray
Z-offsets between consecutive slices.
num_slices : int
Number of slices stacked.
resolution : list
Output resolution.
output_path : str or Path
Path to the output file.
blend_enabled : bool
Whether blending was enabled.
normalize_enabled : bool
Whether normalization was enabled.
z_matches_df : pandas.DataFrame, optional
Per-pair z-match diagnostics with at least a ``correlation`` column.
decisions_df : pandas.DataFrame, optional
Per-slice stacking decisions with optional columns
``transform_loaded``, ``manual_override``, ``overlap_source``.
Returns
-------
PipelineMetrics
Metrics object (already saved).
"""
output_path = Path(output_path)
metrics = PipelineMetrics("stack_slices", str(output_path.parent))
metrics.add_info("output_volume", str(output_path), "Output stacked volume path")
metrics.add_info("num_slices", num_slices, "Number of slices stacked")
metrics.add_info("output_shape", list(output_shape), "Final output shape")
metrics.add_info("resolution", list(resolution), "Output resolution")
metrics.add_info("blending_enabled", blend_enabled, "Whether blending was enabled")
metrics.add_info("normalization_enabled", normalize_enabled, "Whether normalization was enabled")
z_offsets = np.asarray(z_offsets)
metrics.add_info("z_offsets", z_offsets.tolist(), "Z-offsets between consecutive slices")
metrics.add_metric("total_z_depth", int(output_shape[0]), unit="voxels", description="Total Z depth of stacked volume")
metrics.add_metric("mean_z_offset", float(np.mean(z_offsets)), unit="voxels", description="Mean Z-offset between slices")
metrics.add_metric(
"std_z_offset",
float(np.std(z_offsets)),
unit="voxels",
description="Std dev of Z-offsets",
threshold_name="z_offset_std",
)
z_offset_range = float(np.max(z_offsets) - np.min(z_offsets))
metrics.add_metric(
"z_offset_range",
z_offset_range,
unit="voxels",
description="Range of Z-offsets (max - min)",
threshold_name="z_offset_range",
)
if z_matches_df is not None and len(z_matches_df) > 0 and "correlation" in z_matches_df.columns:
corr = np.asarray(z_matches_df["correlation"], dtype=float)
corr = corr[np.isfinite(corr)]
if corr.size > 0:
metrics.add_info("num_z_match_pairs", int(corr.size), "Number of evaluated z-match pairs")
metrics.add_metric(
"mean_z_correlation",
float(np.mean(corr)),
description="Mean correlation across z-match pairs",
threshold_name="correlation",
)
metrics.add_metric(
"min_z_correlation",
float(np.min(corr)),
description="Minimum correlation across z-match pairs",
threshold_name="correlation",
)
metrics.add_info("max_z_correlation", float(np.max(corr)), "Maximum correlation across z-match pairs")
if decisions_df is not None and len(decisions_df) > 0:
if "transform_loaded" in decisions_df.columns:
loaded = decisions_df["transform_loaded"].astype(bool)
metrics.add_info("n_transform_loaded", int(loaded.sum()), "Slices where pairwise transform was loaded")
metrics.add_info("n_transform_missing", int((~loaded).sum()), "Slices where pairwise transform was unavailable")
if "manual_override" in decisions_df.columns:
metrics.add_info(
"n_manual_override",
int(decisions_df["manual_override"].astype(bool).sum()),
"Slices with a manual stacking override",
)
if "overlap_source" in decisions_df.columns:
counts = decisions_df["overlap_source"].astype(str).value_counts().to_dict()
metrics.add_info(
"overlap_source_counts",
{str(k): int(v) for k, v in counts.items()},
"Histogram of overlap source decisions",
)
return metrics.finalize(f"{output_path.stem}_metrics.json")
[docs]
def collect_quality_assessment_metrics(
output_path: Path,
quality_results: dict[int, dict[str, Any]],
excluded_ids: list[int],
min_quality: float,
) -> PipelineMetrics:
"""Collect aggregate metrics for the slice-quality assessment step.
Parameters
----------
output_path : Path
Path to the slice_config CSV that was written.
quality_results : dict
Mapping ``slice_id -> per-slice quality dict`` (with at least an
``overall`` field).
excluded_ids : list of int
Slices marked as excluded by the quality assessment.
min_quality : float
Threshold used to flag low-quality slices.
"""
output_path = Path(output_path)
metrics = PipelineMetrics("slice_quality_assessment", str(output_path.parent))
metrics.add_info("output_file", str(output_path), "Slice config CSV with quality stamps")
metrics.add_info("min_quality_threshold", float(min_quality), "Quality cutoff used")
metrics.add_info("num_slices", len(quality_results), "Slices evaluated")
metrics.add_info("num_excluded", len(excluded_ids), "Slices excluded by this stage")
metrics.add_info("excluded_slice_ids", sorted(int(s) for s in excluded_ids), "IDs of excluded slices")
overalls = np.array(
[float(q.get("overall", 0.0)) for q in quality_results.values() if q.get("has_data", True)],
dtype=float,
)
if overalls.size > 0:
metrics.add_metric("mean_quality", float(np.mean(overalls)), description="Mean per-slice quality score")
metrics.add_metric("min_quality", float(np.min(overalls)), description="Minimum per-slice quality score")
metrics.add_info("max_quality", float(np.max(overalls)), "Maximum per-slice quality score")
metrics.add_info(
"n_below_threshold",
int(np.sum(overalls < min_quality)) if min_quality > 0 else 0,
"Slices below the quality threshold",
)
return metrics.finalize("slice_quality_assessment_metrics.json")
[docs]
def collect_rehoming_metrics(
output_path: Path,
n_total_transitions: int,
tile_corrected_indices: list[int],
spike_corrected_indices: list[int],
n_unreliable: int,
max_correction_mm: float | None = None,
) -> PipelineMetrics:
"""Collect aggregate metrics for the rehoming-detection step."""
output_path = Path(output_path)
metrics = PipelineMetrics("rehoming_detection", str(output_path.parent))
metrics.add_info("output_shifts", str(output_path), "Corrected shifts CSV")
metrics.add_info("n_total_transitions", int(n_total_transitions), "Total pairwise transitions evaluated")
metrics.add_info("n_tile_corrected", len(tile_corrected_indices), "Pass 1 tile-FOV multiple corrections applied")
metrics.add_info("n_spike_corrected", len(spike_corrected_indices), "Pass 2 self-cancelling spike corrections applied")
metrics.add_info("n_unreliable", int(n_unreliable), "Transitions still flagged reliable=0 after correction")
if max_correction_mm is not None:
metrics.add_info("max_correction_mm", float(max_correction_mm), "Largest absolute correction applied (mm)")
return metrics.finalize("rehoming_detection_metrics.json")
[docs]
def collect_auto_exclude_metrics(
output_path: Path,
num_total_slices: int,
excluded_ids: list[int],
cluster_count: int,
z_corr_threshold: float,
consecutive_threshold: int,
) -> PipelineMetrics:
"""Collect aggregate metrics for the auto-exclude step."""
output_path = Path(output_path)
metrics = PipelineMetrics("auto_exclude", str(output_path.parent))
metrics.add_info("output_slice_config", str(output_path), "Slice config CSV stamped with auto_excluded")
metrics.add_info("num_total_slices", int(num_total_slices), "Total slices considered")
metrics.add_info("num_auto_excluded", len(excluded_ids), "Slices auto-excluded by this stage")
metrics.add_info("auto_excluded_slice_ids", sorted(int(s) for s in excluded_ids), "IDs of auto-excluded slices")
metrics.add_info("num_clusters", int(cluster_count), "Number of consecutive low-z_corr clusters detected")
metrics.add_info("z_corr_threshold", float(z_corr_threshold), "Z-correlation threshold used")
metrics.add_info("consecutive_threshold", int(consecutive_threshold), "Minimum cluster length")
return metrics.finalize("auto_exclude_metrics.json")
[docs]
def collect_common_space_metrics(
output_dir: Path,
n_selected_slices: int,
n_excluded_slices: int,
n_unreliable: int,
n_refined_image_based: int,
n_refined_rejected: int,
refine_discrepancies_px: list[float] | None = None,
) -> PipelineMetrics:
"""Collect aggregate metrics for the common-space alignment step."""
output_dir = Path(output_dir)
metrics = PipelineMetrics("common_space_alignment", str(output_dir))
metrics.add_info("output_dir", str(output_dir), "Aligned mosaics directory")
metrics.add_info("n_selected_slices", int(n_selected_slices), "Slices retained for alignment")
metrics.add_info("n_excluded_slices", int(n_excluded_slices), "Slices excluded by slice config")
metrics.add_info("n_unreliable", int(n_unreliable), "Transitions flagged reliable=0 in shifts file")
metrics.add_info("n_refined_image_based", int(n_refined_image_based), "Unreliable transitions refined via registration")
metrics.add_info("n_refined_rejected", int(n_refined_rejected), "Image-based refinements rejected (NCC/discrepancy)")
if refine_discrepancies_px:
arr = np.asarray(refine_discrepancies_px, dtype=float)
arr = arr[np.isfinite(arr)]
if arr.size > 0:
metrics.add_metric(
"mean_refine_discrepancy_px",
float(np.mean(arr)),
unit="px",
description="Mean discrepancy between motor and image-based estimates",
)
metrics.add_info("max_refine_discrepancy_px", float(np.max(arr)), "Maximum motor-vs-image discrepancy (px)")
return metrics.finalize("common_space_alignment_metrics.json")
[docs]
def collect_slice_interpolation_metrics(
output_path: Path,
n_fragments: int,
interpolated_ids: list[str],
failed_ids: list[str],
fallback_reasons: dict[str, int] | None = None,
method_counts: dict[str, int] | None = None,
) -> PipelineMetrics:
"""Collect aggregate metrics for the slice-interpolation finalise step."""
output_path = Path(output_path)
metrics = PipelineMetrics("slice_interpolation", str(output_path.parent))
metrics.add_info("output_slice_config", str(output_path), "Final slice config CSV")
metrics.add_info("n_fragments", int(n_fragments), "Number of per-slice fragments merged")
metrics.add_info("n_interpolated", len(interpolated_ids), "Slices successfully reconstructed")
metrics.add_info("n_failed", len(failed_ids), "Slices where interpolation failed")
metrics.add_info("interpolated_slice_ids", sorted(interpolated_ids), "IDs of interpolated slices")
metrics.add_info("failed_slice_ids", sorted(failed_ids), "IDs of slices that could not be interpolated")
if fallback_reasons:
metrics.add_info("fallback_reasons", dict(fallback_reasons), "Histogram of interpolation fallback reasons")
if method_counts:
metrics.add_info("method_counts", dict(method_counts), "Histogram of interpolation methods used")
return metrics.finalize("slice_interpolation_metrics.json")
[docs]
def collect_stitch_3d_metrics(
input_shape: tuple[int, ...],
output_shape: tuple[int, ...],
num_tiles: int,
resolution: list[float],
output_path: Path,
input_path: Path | None = None,
blending_method: str = "diffusion",
) -> PipelineMetrics:
"""
Collect metrics for 3D tile stitching step.
Parameters
----------
input_shape : tuple
Input mosaic grid shape.
output_shape : tuple
Output stitched volume shape.
num_tiles : int
Number of tiles stitched.
resolution : list
Output resolution.
output_path : str or Path
Path to the output file.
input_path : str, optional
Path to the input file.
blending_method : str
Blending method used.
Returns
-------
PipelineMetrics
Metrics object (already saved).
"""
output_path = Path(output_path)
metrics = PipelineMetrics("stitch_3d", str(output_path.parent))
if input_path:
metrics.add_info("input_volume", str(input_path), "Input mosaic grid path")
metrics.add_info("output_volume", str(output_path), "Output stitched volume path")
metrics.add_info("input_shape", list(input_shape), "Input mosaic shape")
metrics.add_info("output_shape", list(output_shape), "Output stitched shape")
metrics.add_info("num_tiles", num_tiles, "Number of tiles stitched")
metrics.add_info("resolution", list(resolution), "Output resolution")
metrics.add_info("blending_method", blending_method, "Blending method used")
# Compute compression ratio (how much the stitching reduced overlap)
input_pixels = np.prod(input_shape)
output_pixels = np.prod(output_shape)
overlap_reduction = 1.0 - (output_pixels / input_pixels) if input_pixels > 0 else 0.0
metrics.add_metric(
"overlap_reduction", float(overlap_reduction), description="Fraction of pixels removed by stitching (overlap)"
)
return metrics.finalize(f"{output_path.stem}_metrics.json")