Source code for phenotypic.detect._filamentous_fungi_detector

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

if TYPE_CHECKING:
    from phenotypic._core._image import Image
    from phenotypic._core._grid_image import GridImage
    from phenotypic._core._image_pipeline import ImagePipeline  # type: ignore
    from phenotypic.enhance._phase_congruency 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, ObjectDetector
from phenotypic import ImagePipeline
from phenotypic.enhance import (
    SubtractGaussian,
    ContrastStretching,
    PhaseCongruencyEnhancer,
)

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

from phenotypic.tools_.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 separate filamentous fungal colonies by two-stage detection with Euclidean Voronoi partition. Segment filamentous fungi in two stages: (1) detect compact inoculation centres with an ``inoculum_detector``, (2) capture the full hyphal structure via phase-congruency-based branch detection and Dijkstra reconnection. Filtered centre centroids seed a Euclidean Voronoi partition that assigns every fungal pixel to its nearest colony, with connectivity-based correction ensuring uniform labelling within connected components. For a full comparison see :doc:`/explanation/detection_strategies_compared`. Args: inoculum_detector: ObjectDetector or ImagePipeline that identifies compact fungal centres/nuclei. Should produce small, tight regions at inoculation points. Default uses an internal InoculumDetector + GridSectionLargest pipeline. max_colony_radius_px: Largest colony radius (in pixels) the detector should handle. Sizes scene-derived spatial parameters (``gauss_sigma``, ``tile_size``, ``tile_overlap``) for this worst case. Default 250. min_branch_width_px: Narrowest hyphal branch width (in pixels) to detect. Sizes signal-scale parameters (``pct_min_wavelength``, ``mad_window``, ``path_dilation_radius``, ``snr_margin``, ``coherence_window_radius``). Default 3. ignore_borders: If True, drops objects touching the image border during hysteresis-threshold branch detection. Default True. edge_noise_threshold: Noise threshold scaling factor for phase congruency edge detection. Higher values are stricter (reject more pixels as noise; preserve fewer thin hyphae). Default 6.0. reconnection_tolerance: IQR multiplier for path quality threshold calibration. Higher values accept more reconnection paths (may bridge genuinely-missing hyphae but risks over-merging). Default 2.5. max_gap_length: Maximum acceptable length (pixels) of a suspicious cost stretch along a reconnection path. Paths with longer bad stretches are rejected. Default 30. border_margin_px: Border penalty buffer width in pixels. Prevents reconnection paths from routing along image borders. Default 50. frag_reach_px: Maximum 2D distance (pixels) from a fragment's boundary to the nearest routable (low-cost) pixel. Fragments more isolated than this are dropped before Dijkstra routing, since no plausible path could connect them. Default 10. gap_crossing_penalty: Distance-gap penalty strength during Dijkstra routing. Higher values make paths route around low-PCT-energy gaps more aggressively. Default 4.0. gauss_sigma: Override for SubtractGaussian sigma. If None (default), derived from ``max_colony_radius_px``. tile_size: Override for tile side length. If None (default), derived from ``max_colony_radius_px``. tile_overlap: Override for tile overlap. If None (default), derived from ``max_colony_radius_px``. pct_min_wavelength: Override for log-Gabor minimum wavelength. If None (default), derived from ``min_branch_width_px``. mad_window: Override for local MAD window size (must be odd). If None (default), derived from ``min_branch_width_px``. path_dilation_radius: Override for dilating reconnection paths. If None (default), derived from ``min_branch_width_px``. snr_margin: Override for SNR background ring radius beyond ``path_dilation_radius``. If None (default), derived from ``min_branch_width_px``. coherence_window_radius: Override for orientation coherence computation radius. If None (default), derived from ``min_branch_width_px``. Returns: Image: Input image with ``objmask`` set to a binary fungal mask and ``objmap`` set to a labelled colony map where each fungal colony receives a unique integer label via Voronoi assignment. Raises: TypeError: If *inoculum_detector* is not an ObjectDetector or ImagePipeline instance. ValueError: If no centres are detected, no branch structure is detected, or no centres overlap with the branch structure after filtering. Best For: * Filamentous fungal colonies (e.g., *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 from compact inoculation sites. * Grid-based fungal culture plates (GridImage) where one colony per well must be quantified. * 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-like morphology). * :class:`OtsuDetector` when fungi are well-separated and a simple binary mask suffices without individual labelling. * :class:`CompositeDetector` when combining multiple detection strategies without the two-stage centre-plus-body approach. References: [1] P. Kovesi, "Image features from phase congruency," *Videre: J. Comput. Vis. Res.*, vol. 1, no. 3, pp. 1--26, 1999. 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/detection_strategies_compared` In-depth comparison of all detection strategies. """ __center_pipe = ImagePipeline( ops=[InoculumDetector(), GridSectionLargest()] ) # Scene-derivation multipliers (private; override in subclass to tune). # Raw param = multiplier * scene knob (rounded to int where required). _GAUSS_SIGMA_PER_R: float = 1.2 _TILE_SIZE_PER_R: float = 4.8 _TILE_OVERLAP_PER_R: float = 2.4 _WAVELENGTH_PER_W: float = 2.0 _MAD_WINDOW_PER_W: float = 2.0 _PATH_DILATION_PER_W: float = 0.5 _SNR_MARGIN_PER_W: float = 0.5 _COHERENCE_RADIUS_PER_W: float = 5.0 # Algorithm internals (hidden from __init__; override in subclass to tune). beta: float = 2.0 # anisotropy exponent in composite cost gamma: float = 1.2 # MAD penalty weight in composite cost numerator gauss_n_iter: int = 2 # SubtractGaussian iterations delta: float = 1.0 # Dijkstra radial retreat penalty pct_n_orient: int = 8 # phase congruency angular resolution def __init__( self, inoculum_detector: Union[ObjectDetector, 'ImagePipeline', None] = None, # ── Scene parameters ── max_colony_radius_px: float = 250.0, min_branch_width_px: int = 3, # ── Explicit user knobs ── ignore_borders: bool = True, edge_noise_threshold: float = 6.0, reconnection_tolerance: float = 2.5, max_gap_length: int = 30, border_margin_px: int = 50, frag_reach_px: int = 10, gap_crossing_penalty: float = 4.0, *, # ── Scene-derivation overrides (None → auto-derived) ── gauss_sigma: Optional[float] = None, tile_size: Optional[int] = None, tile_overlap: Optional[int] = None, pct_min_wavelength: Optional[float] = None, mad_window: Optional[int] = None, path_dilation_radius: Optional[int] = None, snr_margin: Optional[int] = None, coherence_window_radius: Optional[int] = None, ): super().__init__() # Type validation (allow None for serialization/deserialization) from phenotypic import ImagePipeline if inoculum_detector is not None and not isinstance( inoculum_detector, (ObjectDetector, ImagePipeline) ): raise TypeError( "inoculum_detector must be an ObjectDetector or " "ImagePipeline instance, " f"got {type(inoculum_detector).__name__}" ) self.inoculum_detector = inoculum_detector if inoculum_detector \ else self.__center_pipe # ── Scene knobs ── self.max_colony_radius_px = float(max_colony_radius_px) self.min_branch_width_px = int(min_branch_width_px) # ── Explicit user knobs ── self.ignore_borders = ignore_borders self.edge_noise_threshold = edge_noise_threshold self.reconnection_tolerance = reconnection_tolerance self.max_gap_length = max_gap_length self.border_margin_px = border_margin_px self.frag_reach_px = frag_reach_px self.gap_crossing_penalty = gap_crossing_penalty # ── Scene-derived params (apply overrides if supplied) ── R = self.max_colony_radius_px w = self.min_branch_width_px self.gauss_sigma = ( float(gauss_sigma) if gauss_sigma is not None else self._GAUSS_SIGMA_PER_R * R ) self.tile_size = ( int(tile_size) if tile_size is not None else int(round(self._TILE_SIZE_PER_R * R)) ) self.tile_overlap = ( int(tile_overlap) if tile_overlap is not None else int(round(self._TILE_OVERLAP_PER_R * R)) ) self.pct_min_wavelength = ( float(pct_min_wavelength) if pct_min_wavelength is not None else self._WAVELENGTH_PER_W * w ) # 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 = ( int(mad_window) if mad_window is not None else _mad_default ) self.path_dilation_radius = ( int(path_dilation_radius) if path_dilation_radius is not None else max(1, int(round(self._PATH_DILATION_PER_W * w))) ) self.snr_margin = ( int(snr_margin) if snr_margin is not None else max(2, int(round(self._SNR_MARGIN_PER_W * w))) ) self.coherence_window_radius = ( int(coherence_window_radius) if coherence_window_radius is not None else int(round(self._COHERENCE_RADIUS_PER_W * w)) ) 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 = PhaseCongruencyEnhancer( 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, )