Source code for phenotypic.detect._filamentous_fungi_detector

from __future__ import annotations
from typing import TYPE_CHECKING, Annotated, ClassVar, Optional, Union
import numpy as np
import gc

from pydantic import model_validator
from typing_extensions import Self

if TYPE_CHECKING:
    from phenotypic._core._image import Image
    from phenotypic._core._grid_image import GridImage
    from phenotypic.enhance._focus_edge_phase import _PhaseCong3Result

from scipy.ndimage import center_of_mass, label as ndi_label
from skimage.filters import threshold_otsu
from skimage.measure import label
from skimage.morphology import disk, dilation

from phenotypic.abc_ import GridObjectDetector
from phenotypic import ImagePipeline
from phenotypic.enhance import (
    SubtractGaussian,
    ContrastStretching,
    FocusEdgePhase,
)
from phenotypic.sdk_.typing_ import OperationField, TuneSpec

from phenotypic.detect import HysteresisDetector
from phenotypic.detect._inoculum_detector import InoculumDetector
from phenotypic.refine import KeepSectionLargest

from phenotypic.sdk_.branch_pathfinding import (
    _apply_distance_gap_penalty_inplace,
    _apply_border_penalty_inplace,
    _apply_structure_mask_inplace,
    _compute_screening_envelope,
    compute_anisotropy,
    compute_orientation_coherence,
    compute_local_mad_map,
    assemble_composite_cost,
    calibrate_screening_threshold,
    prescreen_fragments,
    run_multisource_dijkstra,
    assign_fragments_to_colonies,
    extract_fragment_paths,
    extract_calibration_branches,
    calibrate_thresholds,
    apply_filter_cascade,
    euclidean_voronoi_assign,
    connectivity_correct_labels
)


[docs] class FilamentousFungiDetector(GridObjectDetector): """Detect and individually label filamentous fungal colonies by two-stage inoculum-plus-hyphae detection with Euclidean Voronoi partition. First detect compact inoculation centres with ``inoculum_detector``, then capture the full hyphal network using phase-congruency edge detection combined with Gaussian background subtraction. Disconnected branch fragments are reconnected to their parent colonies via quality-filtered Dijkstra pathfinding on a composite cost surface derived from phase congruency energy, local texture, and orientation coherence. Inoculum centroids seed a Euclidean Voronoi partition that assigns every fungal pixel to its nearest colony, with connectivity-based correction enforcing uniform labelling within each connected component. For an algorithm overview and a comparison with other detection strategies, see :doc:`/explanation/detection_strategies_compared` and :doc:`/explanation/filamentous_fungi_algorithm`. Best For: - Filamentous fungal colonies (*Aspergillus*, *Neurospora*, *Trichoderma*) with irregular, spreading hyphal morphologies. - Dense plates where neighbouring fungal colonies touch or overlap and must be individually labelled. - Time-course experiments tracking hyphal extension radially outward from compact inoculation sites. - Grid-based fungal culture plates where one colony per well must be quantified separately. - High-throughput fungal phenotyping screens requiring consistent separation quality across hundreds of plates. Consider Also: - :class:`WatershedDetector` when colonies are compact and roughly circular (yeast or bacterial morphology). - :class:`OtsuDetector` when fungi are well-separated and a single binary mask suffices without individual colony labelling. - :class:`CompositeDetector` when combining multiple detection strategies is preferred over the two-stage centre-plus-hyphae approach. - :class:`InoculumDetector` when only the compact inoculation centres are needed and full hyphal reconstruction is not required. Args: inoculum_detector: ObjectDetector or ImagePipeline used to locate compact fungal centres. Should produce small, tight regions at inoculation points; centroids from this detector seed the final Voronoi partition. When ``None`` (default), an internal ``InoculumDetector`` + ``KeepSectionLargest`` pipeline is used. Default: None. # Scene-scale parameters — set these first; derived params follow max_colony_radius_px: Expected maximum colony radius in pixels. Acts as the master scene knob: proportionally scales ``gauss_sigma``, ``tile_size``, and ``tile_overlap`` when those are left at ``None``. A reasonable starting point is the radius of the largest colony in pixels at your imaging resolution (e.g. measure colony extent in your image viewer before setting this). Reduce for short-incubation plates or high-well-count formats; increase for slow-growing species with extensive radial growth. Typical range: 50--400. Default: 250.0. min_branch_width_px: Expected narrowest hyphal branch width in pixels. Scales signal-detection parameters (``pct_min_wavelength``, ``mad_window``, ``path_dilation_radius``, ``snr_margin``, ``coherence_window_radius``) when those are left at ``None``. Set to the thinnest hyphae visible at your imaging resolution; the derived ``pct_min_wavelength`` (``2 × min_branch_width_px``) is clamped at the Nyquist floor of 2 px. Typical range: 2--8. Default: 3. # Detection control ignore_borders: Drop objects touching the image border during hysteresis-threshold branch detection. Enable (default) to avoid partial colonies at plate edges; disable when genuine peripheral hyphal growth must be retained. Default: True. edge_noise_threshold: Noise-suppression multiplier ``k`` for the phase congruency detector. Only features whose phase energy exceeds the estimated noise mean plus ``k`` standard deviations of the noise energy are accepted as real edges. Higher values suppress agar texture artefacts at the cost of rejecting weak peripheral hyphae; lower values recover fine structure but may pass background noise on textured media. Typical range: 2.0--10.0. Default: 6.0. # Reconnection quality reconnection_tolerance: IQR multiplier for calibrating reconnection path quality thresholds from confirmed calibration branches. Thresholds are set at median ± ``reconnection_tolerance`` × IQR across five quality metrics. Higher values accept more candidate paths (permissive); lower values require paths to closely resemble calibration branches (conservative). Typical range: 1.5--4.0. Default: 2.5. max_gap_length: Maximum contiguous stretch of high-cost pixels tolerated along a reconnection path, in pixels. Paths containing a window worse than the calibrated threshold are rejected as routing through bare agar. Increase to bridge longer hyphal gaps; decrease to reject longer detours through background. Typical range: 10--100. Default: 30. border_margin_px: Width of the border penalty ramp applied to image-edge pixels in the Dijkstra cost surface. Prevents reconnection paths from routing along plate borders instead of through genuine hyphal corridors. Set to 0 to disable. Typical range: 0--150. Default: 50. frag_reach_px: Pre-screening radius in pixels. Fragments whose nearest routable pixel exceeds this distance from the colony boundary are discarded before Dijkstra, saving computation. Fragments within this radius are forwarded for full quality-filtered reconnection. Typical range: 5--40. Default: 10. gap_crossing_penalty: Scaling factor for the distance-weighted gap penalty applied to Dijkstra path costs. Higher values strongly penalise traversal of bare agar far from the colony, keeping paths near established structure; lower values allow longer background detours. Typical range: 1.0--10.0. Default: 4.0. # Scene-derivation overrides (leave at None to auto-derive) gauss_sigma: Gaussian sigma for background subtraction, in pixels. When ``None``, set to ``1.2 × max_colony_radius_px`` (300 px at the default radius). Must exceed the largest colony radius so the Gaussian estimates only the illumination gradient, not colony signal. Typical range: 50--600. Default: None. tile_size: Side length of square processing tiles in pixels. When ``None``, set to ``int(round(4.8 × max_colony_radius_px))`` (1200 px at the default radius). Must be large enough to contain an entire colony and its satellite fragments within one tile. Typical range: 200--3000. Default: None. tile_overlap: Overlap between adjacent tiles in pixels. When ``None``, set to ``int(round(2.4 × max_colony_radius_px))`` (600 px at the default radius). Larger overlap ensures fragments near tile boundaries are co-located with their parent colony in at least one tile. Typical range: 50--1500. Default: None. pct_min_wavelength: Minimum log-Gabor filter wavelength in pixels for phase congruency detection. When ``None``, set to ``2.0 × min_branch_width_px`` (6 px at the default width). Must be ≥ 2 (Nyquist). Match to the thinnest hyphae width at your imaging resolution. Typical range: 2--20. Default: None. mad_window: Side length of the square median-filter kernel for local MAD texture computation (must be odd). When ``None``, set to ``2 × min_branch_width_px + 1`` forced odd (7 at the default width). Should span approximately one branch diameter plus background buffer on each side. Typical range: 3--21. Default: None. path_dilation_radius: Disk radius for dilating accepted reconnection paths before painting colony labels. When ``None``, set to ``max(1, round(0.5 × min_branch_width_px))`` (2 at the default width). Also sets the inner band radius for path quality metrics. Match to half the expected hyphal width. Typical range: 1--10. Default: None. snr_margin: Extra pixel margin beyond ``path_dilation_radius`` that forms the background annular ring for local SNR estimation. When ``None``, set to ``max(2, round(0.5 × min_branch_width_px))`` (2 at the default width). Keep narrow on dense hyphal networks to avoid sampling adjacent hyphae as background. Typical range: 1--8. Default: None. coherence_window_radius: Radius of the square averaging kernel for orientation coherence computation. When ``None``, set to ``round(5.0 × min_branch_width_px)`` (15 at the default width). Larger radius captures long-range directional consistency; reduce for highly curved or heavily branching networks. Typical range: 5--50. Default: None. Returns: Image: Input image with ``objmask`` set to a binary mask of all detected fungal pixels and ``objmap`` set to a labelled colony map where each fungal colony receives a unique consecutive integer label via Voronoi assignment. Raises: TypeError: If ``inoculum_detector`` is not an ObjectDetector or ImagePipeline instance. ValueError: If no inoculum centres are detected, or no detected centres overlap with the branch structure after filtering. References: [1] P. Kovesi, "Phase congruency: A low-level image invariant," *Psychol. Res.*, vol. 64, no. 2, pp. 136--148, 2000. [2] E. W. Dijkstra, "A note on two problems in connexion with graphs," *Numer. Math.*, vol. 1, no. 1, pp. 269--271, 1959. See Also: :doc:`/tutorials/notebooks/10_detecting_filamentous_fungi` Dedicated tutorial for filamentous fungi detection workflows. :doc:`/how_to/notebooks/choose_detection_algorithm` Guide for selecting the right detector for your plate images. :doc:`/explanation/filamentous_fungi_algorithm` Algorithm details for the two-stage detection and Voronoi partition approach. :doc:`/explanation/detection_strategies_compared` Comparison of all detection strategies and their failure modes. """ @staticmethod def __build_center_pipe() -> "ImagePipeline": """Build the default inoculum-center detection pipeline. Constructed lazily (rather than as a class-body attribute) so importing this module does not instantiate operations — the pydantic v2 migration makes leaf operations uninstantiable until their own migration phase completes. """ return ImagePipeline( ops=[InoculumDetector(), KeepSectionLargest()] ) # Scene-derivation multipliers (private; override in subclass to tune). # Raw param = multiplier * scene knob (rounded to int where required). # Declared ``ClassVar`` so they stay class-level constants (not pydantic # fields) while remaining subclass-overridable, exactly as before. _GAUSS_SIGMA_PER_R: ClassVar[float] = 1.2 _TILE_SIZE_PER_R: ClassVar[float] = 4.8 _TILE_OVERLAP_PER_R: ClassVar[float] = 2.4 _WAVELENGTH_PER_W: ClassVar[float] = 2.0 _MAD_WINDOW_PER_W: ClassVar[float] = 2.0 _PATH_DILATION_PER_W: ClassVar[float] = 0.5 _SNR_MARGIN_PER_W: ClassVar[float] = 0.5 _COHERENCE_RADIUS_PER_W: ClassVar[float] = 5.0 # Algorithm internals (hidden from the constructor; override in subclass # to tune). ``ClassVar` keeps them out of ``model_fields`` so they are # not constructor parameters, matching the pre-migration behaviour. beta: ClassVar[float] = 2.0 # anisotropy exponent in composite cost gamma: ClassVar[float] = 1.2 # MAD penalty weight in composite cost numerator gauss_n_iter: ClassVar[int] = 2 # SubtractGaussian iterations delta: ClassVar[float] = 1.0 # Dijkstra radial retreat penalty pct_n_orient: ClassVar[int] = 8 # phase congruency angular resolution # ── Inoculum detector (None → default pipeline, filled by validator) ── # ``OperationField`` preserves the concrete detector/pipeline class # across a JSON round-trip; ``| None`` keeps the unset sentinel that # ``_derive_scene_params`` replaces with the default pipeline. inoculum_detector: Union[OperationField, None] = None # ── Scene parameters ── # TODO: review bound (unverified vs literature) max_colony_radius_px: Annotated[float, TuneSpec(50.0, 500.0, log=True)] = 250.0 # TODO: review bound (unverified vs literature) min_branch_width_px: Annotated[int, TuneSpec(2, 10)] = 3 # ── Explicit user knobs ── # Docstrings document each default + its qualitative direction but not an # explicit range; the windows below are derived from the default + domain # knowledge (search hints only, never validity bounds). ignore_borders: bool = True # TODO: review bound (unverified vs literature) edge_noise_threshold: Annotated[float, TuneSpec(2.0, 12.0)] = 6.0 # TODO: review bound (unverified vs literature) reconnection_tolerance: Annotated[float, TuneSpec(1.0, 5.0)] = 2.5 # TODO: review bound (unverified vs literature) max_gap_length: Annotated[int, TuneSpec(10, 60)] = 30 # TODO: review bound (unverified vs literature) border_margin_px: Annotated[int, TuneSpec(20, 100)] = 50 # TODO: review bound (unverified vs literature) frag_reach_px: Annotated[int, TuneSpec(5, 30)] = 10 # TODO: review bound (unverified vs literature) gap_crossing_penalty: Annotated[float, TuneSpec(1.0, 10.0)] = 4.0 # ── Scene-derivation overrides (None → auto-derived by the validator) ── # Auto-derived from max_colony_radius_px / min_branch_width_px when left at # None, so they are never independent search targets (decision: tunable=False). gauss_sigma: Annotated[Optional[float], TuneSpec(tunable=False)] = None tile_size: Annotated[Optional[int], TuneSpec(tunable=False)] = None tile_overlap: Annotated[Optional[int], TuneSpec(tunable=False)] = None pct_min_wavelength: Annotated[Optional[float], TuneSpec(tunable=False)] = None mad_window: Annotated[Optional[int], TuneSpec(tunable=False)] = None path_dilation_radius: Annotated[Optional[int], TuneSpec(tunable=False)] = None snr_margin: Annotated[Optional[int], TuneSpec(tunable=False)] = None coherence_window_radius: Annotated[Optional[int], TuneSpec(tunable=False)] = None @model_validator(mode="after") def _derive_scene_params(self) -> Self: """Fill the default pipeline and scene-derived parameters. Reproduces the body of the pre-migration ``__init__``: * a ``None`` ``inoculum_detector`` is replaced with the lazily built default ``InoculumDetector`` + ``KeepSectionLargest`` pipeline (the field default cannot be a live pipeline because operations are uninstantiable at class-definition time); * each scene-derivation override left at ``None`` is computed from ``max_colony_radius_px`` / ``min_branch_width_px`` using the ``_*_PER_*`` multipliers, with ``mad_window`` forced odd. Bad ``inoculum_detector`` types are rejected by the field's ``ObjectDetector | ImagePipeline | None`` annotation before this validator runs. """ if self.inoculum_detector is None: self.inoculum_detector = self.__build_center_pipe() R = self.max_colony_radius_px w = self.min_branch_width_px if self.gauss_sigma is None: self.gauss_sigma = self._GAUSS_SIGMA_PER_R * R if self.tile_size is None: self.tile_size = int(round(self._TILE_SIZE_PER_R * R)) if self.tile_overlap is None: self.tile_overlap = int(round(self._TILE_OVERLAP_PER_R * R)) if self.tile_size <= self.tile_overlap: raise ValueError( "tile_size must be greater than tile_overlap so " "sliding-window tiles advance by at least one pixel" ) if self.pct_min_wavelength is None: self.pct_min_wavelength = self._WAVELENGTH_PER_W * w if self.mad_window is None: # mad_window must be odd; +1 on an even 2w keeps it odd. _mad_default = int(round(self._MAD_WINDOW_PER_W * w)) + 1 if _mad_default % 2 == 0: _mad_default += 1 self.mad_window = _mad_default if self.path_dilation_radius is None: self.path_dilation_radius = max( 1, int(round(self._PATH_DILATION_PER_W * w)) ) if self.snr_margin is None: self.snr_margin = max(2, int(round(self._SNR_MARGIN_PER_W * w))) if self.coherence_window_radius is None: self.coherence_window_radius = int( round(self._COHERENCE_RADIUS_PER_W * w) ) return self def _operate(self, image: 'GridImage') -> 'GridImage': """Detect and separate filamentous fungi using grid-based Voronoi partition. Algorithm: 1. Run inoculum_detector to find fungal centers (full labeled regions) 2. Detect branches via dual-mask pipeline (Gaussian + phase congruency) 3. Filter centers, create grid markers, Voronoi assign with grid seeds 4. Identify pseudo-fragments (per-label CCs not overlapping inoculum) 5. Dijkstra reconnection of pseudo-fragments 6. Final Voronoi partition with grid markers 7. Set objmap with assignment results """ from phenotypic import ImagePipeline # Validate that detectors are set before operation if self.inoculum_detector is None: raise ValueError( "inoculum_detector is required but not set. " "Provide a detector when creating FilamentousFungiDetector." ) # ── PHASE 1: INOCULUM DETECTION ───────────────────────────── if isinstance(self.inoculum_detector, ImagePipeline): inoculum_img = self.inoculum_detector.apply(image, inplace=False, reset=False) else: inoculum_img = self.inoculum_detector.apply(image, inplace=False) inoculum_objmask = inoculum_img.objmask[:] if inoculum_img.objmap[:].max() == 0: raise ValueError( "No centers detected by inoculum_detector. Cannot perform " "separation. Try adjusting inoculum_detector parameters or using a " "different detection strategy." ) self._log_memory_usage("after center detection") # ── PHASE 2: BRANCH DETECTION ─────────────────────────────── # ContrastStretching-enhanced copy for dual-mask detection enhanced_work = image.copy() ContrastStretching().apply(enhanced_work, inplace=True) enhanced_arr = enhanced_work.detect_mat[:] enhanced_gray = enhanced_work.gray[:] # capture before destructive call # Mask A: Gauss branches (destructive: modifies enhanced_work in place) bg_removed_arr = self._subtract_background(enhanced_work) del enhanced_work # no longer valid after destructive call # Mask B: PCT branches pct_result = FocusEdgePhase( n_orient=self.pct_n_orient, min_wavelength=self.pct_min_wavelength, k=self.edge_noise_threshold, )._phasecong3(enhanced_arr) # Overlap filter: keep Gauss labels with any PCT overlap fragmented_overall_detect_mat = self._combine_bg_removed_with_pct( bg_removed_arr=bg_removed_arr, pct_sum=pct_result.pc_sum, ) _fragmented_detect_img = image.copy() _fragmented_detect_img.detect_mat[:] = fragmented_overall_detect_mat HysteresisDetector( low="triangle", high="otsu", ignore_zeros=False, ignore_borders=self.ignore_borders ).apply(_fragmented_detect_img, inplace=True) overall_objmask = _fragmented_detect_img.objmask[:] del _fragmented_detect_img self._log_memory_usage("after dual-mask branch detection") # ── PHASE 3: CENTER FILTERING + GRID VORONOI ───────────────── # The filtered structure that overlaps with the inoculum centers inoculum_structure_mask = self._filter_mask_by_overlap( mask=overall_objmask, reference_mask=inoculum_objmask, ) overlap_objmap = label(inoculum_structure_mask) if overlap_objmap.max() == 0: raise ValueError( "No centers overlap with detected branch structure after " "filtering. Check that inoculum_detector picks up the same " "objects captured by the dual-mask branch detection." ) self._log_memory_usage("after overlap filtering") centroid_markers = self._create_markers_from_centroids(inoculum_img.objmap[:]) inoculum_structure_map = self._separate_colonies(centroid_markers, inoculum_structure_mask) if inoculum_structure_map.max() == 0: raise RuntimeError( "Voronoi assignment produced empty result. " "Centroid markers may not overlap any foreground mask pixels." ) self._log_memory_usage( "after Voronoi assignment", include_process=True, include_tracemalloc=True, ) # ── PHASE 4: DIJKSTRA RECONNECTION ────────────────────────── colony_labels = inoculum_structure_map central_mask, fragment_labels = self._identify_pseudo_fragments( colony_labels=colony_labels, center_objmask=inoculum_objmask, ) unmasked_cost, cost_surface = self._build_cost_surface( pct_result=pct_result, enhanced_arr=enhanced_arr, colony_labels=colony_labels, central_mask=central_mask, ) colony_labels = self._reconnect_fragments_tiled( colony_labels=colony_labels, fragment_labels=fragment_labels, cost_surface=cost_surface, unmasked_cost=unmasked_cost, pct_energy=pct_result.pc_sum.astype(np.float32), grayscale=enhanced_gray, ) self._log_memory_usage( "after Dijkstra reconnection", include_process=True, include_tracemalloc=True, ) # ── PHASE 5: FINAL VORONOI ──────────────────────────────────── final_mask = (colony_labels > 0) | inoculum_structure_mask colony_labels = self._separate_colonies(centroid_markers, final_mask) # ── PHASE 6: WRITE RESULT ─────────────────────────────────── if colony_labels.dtype != image._OBJMAP_DTYPE: colony_labels = colony_labels.astype(image._OBJMAP_DTYPE) image.objmap[:] = colony_labels gc.collect() self._log_memory_usage( "final cleanup", include_process=True, include_tracemalloc=True, ) return image # ── Phase 2 helpers ───────────────────────────────────────────── def _subtract_background(self, enhanced_work: 'Image') -> np.ndarray: """Subtracts the background of the input Image class. This potentially deletes branches so is combined downstream with the PCT response""" return SubtractGaussian( sigma=self.gauss_sigma, n_iter=self.gauss_n_iter ).apply(enhanced_work, inplace=False).detect_mat[:] @staticmethod def _combine_bg_removed_with_pct( bg_removed_arr: np.ndarray, pct_sum: np.ndarray, ): return np.maximum( bg_removed_arr, pct_sum, ).clip(min=0, max=1) # ── Phase 4 helpers ───────────────────────────────────────────── @staticmethod def _identify_pseudo_fragments( colony_labels: np.ndarray, center_objmask: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: """Identify pseudo-fragments: per-label CCs that don't overlap inoculum. After grid Voronoi, every mask pixel has a label. CCs that overlap with the inoculum detection are "central" (main colony mass). CCs that don't are pseudo-fragments — blobs assigned to a section by proximity but not physically connected to the section's colony body. Args: colony_labels: Grid Voronoi label map. center_objmask: Inoculum detection binary mask. Returns: (central_mask, fragment_labels) where central_mask is the main colony mass and fragment_labels is a labeled map of pseudo-fragments. """ foreground = colony_labels > 0 cc_map, n_cc = ndi_label(foreground) if n_cc == 0: return (np.zeros_like(foreground), np.zeros(foreground.shape, dtype=np.int32)) # For each global CC: does it overlap inoculum? seeded_ccs = np.unique(cc_map[center_objmask & foreground]) is_central = np.zeros(n_cc + 1, dtype=bool) is_central[seeded_ccs] = True central_mask = is_central[cc_map] fragment_mask = foreground & ~central_mask if fragment_mask.any(): fragment_labels = label(fragment_mask).astype(np.int32) else: fragment_labels = np.zeros(foreground.shape, dtype=np.int32) return central_mask, fragment_labels def _apply_penalties_inplace( self, cost: np.ndarray, pct_energy: np.ndarray, colony_labels: np.ndarray, ) -> None: """Apply distance-gap and border penalties in place. Args: cost: 2D cost array to penalize (modified in place). pct_energy: 2D PCT energy map for gap penalty gating. colony_labels: Labeled colony assignment from watershed. """ _apply_distance_gap_penalty_inplace( cost, pct_energy, colony_labels, self.gap_crossing_penalty, ) _apply_border_penalty_inplace(cost, self.border_margin_px) def _build_cost_surface( self, pct_result: '_PhaseCong3Result', enhanced_arr: np.ndarray, colony_labels: np.ndarray, central_mask: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: """Build composite cost surface from PCT features. Reuses base_cost allocation: copies once for unmasked, then mutates the original for the masked surface. Args: pct_result: Phase congruency result containing M, m, orientation, and pc_sum fields. enhanced_arr: 2D contrast-stretched detection matrix for MAD computation. colony_labels: Labeled colony assignment from watershed. central_mask: Boolean mask of branch pixels overlapping colonies. Returns: Tuple of (unmasked_cost, cost_surface) where unmasked_cost is the composite cost before colony masking and cost_surface has colony/central pixels set to near-zero traversal cost. """ # Anisotropy gives pixel level directional dependence anisotropy = compute_anisotropy(pct_result.M, pct_result.m) # Coherence is a measure of the length of the structures orientation coherence = compute_orientation_coherence( pct_result.orientation, self.coherence_window_radius ) # For identifying noisy regions away from inoculum center mad = compute_local_mad_map(enhanced_arr, self.mad_window) base_cost = assemble_composite_cost( pct_result.pc_sum, anisotropy, coherence, mad, self.beta, self.gamma, ) # Copy once for unmasked cost, then mutate original for masked unmasked_cost = base_cost.copy() self._apply_penalties_inplace( unmasked_cost, pct_result.pc_sum, colony_labels ) colony_mask = (colony_labels > 0) | central_mask _apply_structure_mask_inplace(base_cost, colony_mask.astype(np.int32)) self._apply_penalties_inplace( base_cost, pct_result.pc_sum, colony_labels ) return unmasked_cost, base_cost def _reconnect_fragments_tiled( self, colony_labels: np.ndarray, fragment_labels: np.ndarray, cost_surface: np.ndarray, unmasked_cost: np.ndarray, pct_energy: np.ndarray, grayscale: np.ndarray, ) -> np.ndarray: """Generate tiles, process each, merge results into output mask. Args: colony_labels: Labeled colony assignment from watershed. fragment_labels: Labeled array of disconnected branch fragments. cost_surface: Masked composite cost surface for Dijkstra. unmasked_cost: Unmasked composite cost for quality calibration. pct_energy: Float32 (H, W) PCT energy map for quality filtering. grayscale: Float32 (H, W) enhanced grayscale for SNR filtering. Returns: Updated colony labels with reconnected fragments painted in. """ if fragment_labels.max() == 0: return colony_labels # Prescreen fragments: compute envelope once, share across calibration + screening colony_branch_mask = (colony_labels > 0).astype(np.int32) min_cost_envelope, _ = _compute_screening_envelope( cost_surface, colony_branch_mask, self.frag_reach_px ) tau_screen, _ = calibrate_screening_threshold( cost_surface, colony_branch_mask, r_screen=self.frag_reach_px, min_cost_envelope=min_cost_envelope, ) screen_result = prescreen_fragments( cost_surface, fragment_labels, r_screen=self.frag_reach_px, tau_screen=tau_screen, colony_branch_mask=colony_branch_mask, min_cost_envelope=min_cost_envelope, ) screened_frags = screen_result.screened_fragment_labels if screened_frags.max() == 0: return colony_labels # Compute PCT noise ceiling for F5 background masking pct_noise_ceil = float(threshold_otsu(pct_energy)) # Generate tiles tiles = self._generate_tiles( colony_labels.shape, self.tile_size, self.tile_overlap ) output = colony_labels.copy() for row_start, row_end, col_start, col_end in tiles: tile_cost = cost_surface[row_start:row_end, col_start:col_end] tile_raw = unmasked_cost[row_start:row_end, col_start:col_end] tile_colony = output[row_start:row_end, col_start:col_end] tile_frags = screened_frags[row_start:row_end, col_start:col_end] tile_pct = pct_energy[row_start:row_end, col_start:col_end] tile_gray = grayscale[row_start:row_end, col_start:col_end] tile_result = self._process_tile( tile_cost, tile_raw, tile_colony, tile_frags, tile_pct, tile_gray, pct_noise_ceil, ) self._merge_tile_into_output( output, tile_result, row_start, col_start ) return output @staticmethod def _generate_tiles( image_shape: tuple[int, int], tile_size: int, overlap: int, ) -> list[tuple[int, int, int, int]]: """Generate overlapping tile coordinates covering the full image. Args: image_shape: (height, width) of the image. tile_size: Side length of square tiles. overlap: Overlap in pixels between adjacent tiles. Returns: List of (row_start, row_end, col_start, col_end) tuples. """ H, W = image_shape step = tile_size - overlap tiles: list[tuple[int, int, int, int]] = [] row = 0 while row < H: row_end = min(row + tile_size, H) col = 0 while col < W: col_end = min(col + tile_size, W) tiles.append((row, row_end, col, col_end)) if col_end == W: break col += step if row_end == H: break row += step return tiles def _process_tile( self, tile_cost: np.ndarray, tile_raw: np.ndarray, tile_colony: np.ndarray, tile_frags: np.ndarray, tile_pct: np.ndarray, tile_gray: np.ndarray, pct_noise_ceil: float, ) -> np.ndarray: """Process a single tile: Dijkstra, assign, paths, quality filter, assemble. Args: tile_cost: Masked cost surface for this tile. tile_raw: Unmasked cost surface for quality calibration. tile_colony: Colony labels for this tile. tile_frags: Fragment labels for this tile. tile_pct: PCT energy map for this tile. tile_gray: Grayscale image for this tile. pct_noise_ceil: PCT energy threshold for F5 background masking. Returns: Updated tile colony labels with reconnected fragments. """ if tile_frags.max() == 0: return tile_colony if tile_colony.max() == 0: return tile_colony # Run Dijkstra from colony boundaries dijkstra = run_multisource_dijkstra( tile_cost, tile_colony, self.delta ) # Assign fragments to colonies by majority vote assignments = assign_fragments_to_colonies( tile_frags, dijkstra.colony_id, dijkstra.cost_distance ) # Extract minimum-cost paths from fragments to colonies paths, _unconnected = extract_fragment_paths( tile_frags, assignments, dijkstra, tile_cost ) if not paths: return tile_colony # Quality filter: calibrate from colony skeleton branches calibration = extract_calibration_branches( tile_colony, tile_raw, window_cost=self.max_gap_length, dilation_radius=self.path_dilation_radius, pct_energy=tile_pct, grayscale=tile_gray, snr_margin=self.snr_margin, pct_noise_ceil=pct_noise_ceil, ) # Only apply quality filters if we have calibration data if calibration.median_cost_values.size > 0: thresholds = calibrate_thresholds( calibration, k=self.reconnection_tolerance ) filter_result = apply_filter_cascade( paths, tile_raw, thresholds, window_cost=self.max_gap_length, dilation_radius=self.path_dilation_radius, pct_energy=tile_pct, grayscale=tile_gray, snr_margin=self.snr_margin, pct_noise_ceil=pct_noise_ceil, ) passed_ids = filter_result.passed_ids else: # No calibration data: accept all paths passed_ids = set(paths.keys()) # Build result: paint fragment + dilated path with colony ID result = tile_colony.copy() selem = disk(self.path_dilation_radius) # Group path coords by colony for batched dilation colony_coords: dict[int, list[np.ndarray]] = {} for fid in passed_ids: if fid not in paths or fid not in assignments: continue path = paths[fid] cid = assignments[fid].colony_id if cid < 0: continue # Paint fragment pixels frag_mask = tile_frags == fid result[frag_mask] = cid # Collect path coords for batched dilation rows = path.coords[:, 0] cols = path.coords[:, 1] valid = ( (rows >= 0) & (rows < result.shape[0]) & (cols >= 0) & (cols < result.shape[1]) ) colony_coords.setdefault(cid, []).append( path.coords[valid] ) # Single dilation per colony for cid, coord_list in colony_coords.items(): all_coords = np.vstack(coord_list) path_mask = np.zeros(result.shape, dtype=np.bool_) path_mask[all_coords[:, 0], all_coords[:, 1]] = True dilated = dilation(path_mask, selem) result[dilated] = cid return result @staticmethod def _merge_tile_into_output( output: np.ndarray, tile_labels: np.ndarray, row_start: int, col_start: int, ) -> None: """Write tile results into global output array. Only overwrites pixels that are currently unlabeled (0) in the output, preserving existing colony labels from earlier tiles or the watershed. Args: output: Global output label array (modified in place). tile_labels: Processed tile label array. row_start: Row offset of this tile in the global image. col_start: Column offset of this tile in the global image. """ tile_h, tile_w = tile_labels.shape out_slice = output[row_start:row_start + tile_h, col_start:col_start + tile_w] new_pixels = (tile_labels > 0) & (out_slice == 0) out_slice[new_pixels] = tile_labels[new_pixels] # ── Existing static methods (unchanged) ───────────────────────── @staticmethod def _filter_mask_by_overlap(mask, reference_mask): """ Retain only objects in mask_to_clean that overlap with reference_mask. Args: mask (np.ndarray): Binary mask to filter (2D boolean or uint8) reference_mask (np.ndarray): Binary mask defining valid regions (2D boolean or uint8) Returns: np.ndarray: Filtered binary mask with same shape as mask_to_clean Raises: ValueError: If masks don't have compatible spatial overlap """ # Label connected components in mask to clean labeled = label(mask) # Handle potential size mismatch by finding overlapping region min_h = min(mask.shape[0], reference_mask.shape[0]) min_w = min(mask.shape[1], reference_mask.shape[1]) # Compute intersection in overlapping region intersection = labeled[:min_h, :min_w] * reference_mask[:min_h, :min_w] # Find which labels have overlap overlapping_labels = np.unique(intersection[intersection > 0]) # Create output mask retaining only overlapping objects max_label = int(labeled.max()) keep = np.zeros(max_label + 1, dtype=labeled.dtype) keep[overlapping_labels] = overlapping_labels return keep[labeled].astype(mask.dtype, copy=False) @staticmethod def _create_markers_from_centroids(objmap: np.ndarray) -> np.ndarray: """Create Voronoi seed markers at detected inoculum centroids. Args: objmap: Labeled integer array where each detected inoculum has a unique positive ID (from ``inoculum_img.objmap[:]``). Returns: 2D int32 marker array with one seed per inoculum centroid. """ labels = np.unique(objmap) labels = labels[labels > 0] markers = np.zeros(objmap.shape, dtype=np.int32) for marker_id, lbl in enumerate(labels, start=1): com = center_of_mass(objmap == lbl) r = min(int(round(com[0])), objmap.shape[0] - 1) c = min(int(round(com[1])), objmap.shape[1] - 1) markers[r, c] = marker_id return markers @staticmethod def _separate_colonies( markers: np.ndarray, mask: np.ndarray, ) -> np.ndarray: """Voronoi-partition mask pixels and correct fragment connectivity.""" voronoi_map = euclidean_voronoi_assign( markers=markers, mask=mask, restrict_to_seeded_cc=False, ) return connectivity_correct_labels( voronoi_labels=voronoi_map, mask=mask, markers=markers, )