Source code for phenotypic.measure._measure_symzones

"""Mask-based radial expansion and symmetry measurement operator.

Implements :class:`MeasureSymZones`, a branch-free operator that answers
two colony-level questions directly from the binary object mask: how far has
growth progressed past the inoculum, and out to what radius does that growth
remain angularly uniform?
"""

from __future__ import annotations

import weakref
from dataclasses import fields, replace
from typing import Any, ClassVar, Literal, TYPE_CHECKING, TypeAlias

if TYPE_CHECKING:
    from phenotypic._core._image import Image

    # The pydantic mypy plugin resolves the inherited runtime OperationField
    # annotation in each concrete model's module. Runtime validation remains
    # on CanonicalZoneMeasure; the checker needs only the erased carrier type.
    OperationField: TypeAlias = Any

import numpy as np
import pandas as pd
from pydantic import PrivateAttr
from skimage.measure import approximate_polygon, find_contours, regionprops

from phenotypic.abc_.plotting import Control, PlotImage, figure
from phenotypic.measure._canonical_zone_measure import CanonicalZoneMeasure
from phenotypic.measure._zone_segmentation import (
    ZoneSegmentation,
)
from phenotypic.schema import OBJECT
from phenotypic.schema import SYMMETRIC_ZONES

# Back-compat alias so the rest of this module (inspect/_operate/overlay
# helpers) keeps referring to the relocated dataclass by its old name.
_SymmetryIntermediates: TypeAlias = ZoneSegmentation


def _own_intermediate_arrays(
        intermediates: _SymmetryIntermediates,
) -> _SymmetryIntermediates:
    """Return compact intermediates whose arrays own their memory.

    A cropped NumPy view can retain the complete plate-sized backing array.
    Plot caches therefore copy every derived array before retaining the
    per-object record. Scalar measurement results are unchanged.
    """
    owned = {
        item.name: value.copy()
        for item in fields(intermediates)
        if isinstance((value := getattr(intermediates, item.name)), np.ndarray)
    }
    return replace(intermediates, **owned)


_NEIGHBOR_KERNEL = np.array([[1, 1, 1], [1, 0, 1], [1, 1, 1]], dtype=np.int32)

# Zone-segmentation constants (retained for the overlay helpers below).
_N_ANGULAR_SECTORS = 360

# Diagnostic-overlay constants
_ZONE_POLYGON_STRIDE = 5  # 360 / 5 = 72 vertices per zone polygon
_OBJMAP_POLYGON_TOLERANCE = 0.5  # Douglas-Peucker pixel tolerance

# Okabe-Ito palette constants for diagnostic plots
_OI_NAVY = "#003660"
_OI_ORANGE = "#E69F00"
_OI_SKY = "#56B4E9"
_OI_GREEN = "#009E73"
_OI_BLUE = "#0072B2"
_OI_PURPLE = "#CC79A7"
_OI_VERMILION = "#D55E00"
_OI_GREY = "#BBBBBB"

# Which image array backs the plotly overlay; a select Control bound (by identity)
# to the inspect() figure's ``base_layer`` kwarg for the interactive dashboard.
BASE_LAYER = Control(
        label="Base layer",
        kind="select",
        default="gray",
        options=("rgb", "gray", "detect_mat"),
        help="Image array rendered behind the symmetric-radius overlay.",
)


[docs] class MeasureSymZones(CanonicalZoneMeasure, PlotImage): """Measure colony radial expansion, symmetry, and canonical radial zones. Quantifies each colony by four mask-derived expansion and symmetry scalars plus a shared CoreZone / DenseZone / SparseZone partition. The headline is ``SymmetricRadius``, the first radius past the inoculum core at which the smoothed fraction of occupied angular sectors drops below a tunable symmetry threshold. ``CoreRadius`` (PELT changepoint on the radial density profile) anchors the measurement; ``MeanExpansion`` and ``MaxExpansion`` summarise how far growth reached past that core. Canonical Method B resolves those zones from center-origin Sholl-style rings combining target-mask occupancy, ``detect_mat`` signal and edge statistics, structure-tensor coherence, radial-tilt concentration, and literal skeleton-crossing support. CoreZone is operational: it contains the inoculum and any inner region where branch orientation is not sufficiently resolved. ``legacy_mode=True`` restores the historical intensity-based colony-ness thresholds without changing the independent mask-derived expansion and symmetry measurements. Args: center_detector: ObjectDetector or ImagePipeline that produces compact center regions. The default pipeline resets ``detect_mat`` to grayscale and applies ``InoculumDetector(min_diameter=20, max_diameter=140, thresh_method="otsu")``. In canonical mode, each detected component is associated with the final colony it overlaps and its overlap centroid becomes the shared radial origin. Set to ``None`` to use the final-mask distance-transform estimator. Ignored in legacy mode. legacy_mode: Use the historical colony-ness zone partition instead of canonical Method B. Defaults to ``False``. outer_zone_percentile: Target-mask radial percentile used as the canonical SparseZone outer boundary. ``100`` uses full extent. sigma_d: Gaussian-derivative scale in pixels, approximately hypha width. sigma_i: Structure-tensor integration scale in pixels. radial_ring_width: Width in pixels of each center-origin Sholl-style annular band. zone_minimum_segment: Minimum ring count in every change-point segment. zone_min_crossings: Minimum literal crossings for ring support. zone_min_resultant: Minimum ring-level axial resultant for support. zone_min_ring_coherence: Minimum reliable-pixel mean coherence for ring support. zone_support_weight: Weight of the Boolean orientation-support feature. zone_outer_support_margin: Minimum outer-minus-inner support fraction accepted at the first Method B boundary. zone_maximum_gap: Largest interior unsupported ring run bridged before change-point fitting. n_annuli: Number of equal-area annuli used for the radial density profile and angular analysis. Defaults to 100. pelt_penalty: PELT penalty controlling changepoint sensitivity for core detection. Defaults to 5.0. symmetry_threshold: Minimum angular coverage (fraction of angular bins occupied) for growth to be considered symmetric. Defaults to 4/6 (~0.667); with 6 angular bins this means at least 4 of 6 60-degree sectors must contain mask pixels. n_angular_bins: Number of angular bins used to compute the per-annulus angular coverage diagnostic. Defaults to 36 (10 degree resolution). smoothing_window: Moving-average window (in annuli) applied to the angular R̄ profile before the threshold test. Defaults to 3. method: Inoculum centre estimator --- ``"distance"`` uses the peak of the Euclidean distance transform, ``"intensity"`` uses the intensity-weighted centroid. Defaults to ``"distance"``. extent_margin: Fractional expansion of the ring accumulator past the farthest mask pixel, so the outer annuli sample a small agar tail for the ``I_agar`` reference. Defaults to 0.05 (5%) — deliberately small because tight plates risk touching neighbouring colonies. min_samples_per_ring: Minimum pixel count required to compute a mean for a given ring; below this the ring's mean is filled by linear interpolation from its neighbours before normalisation. Defaults to 5. tau_core: Colony-ness threshold that marks the core/dense boundary. The core zone extends out to the last ring where ``c(r) ≥ tau_core``. Defaults to 0.9 (last "≥90% colony" ring). tau_dense: Colony-ness threshold that marks the dense/sparse boundary. The dense zone extends to the last ring where ``c(r) ≥ tau_dense``. Defaults to 0.5 (last "majority colony" ring). tau_sparse: Colony-ness threshold that marks the sparse/outside boundary. The sparse zone extends to the last ring where ``c(r) ≥ tau_sparse``, capped at the mask envelope. Defaults to 0.1. intensity_source: Image array used for the mean-intensity calculation -- ``"gray"`` uses the grayscale (dark = colony), ``"detect_mat"`` uses the detection matrix (bright = colony). Direction is auto-detected. This applies only in legacy mode; canonical Method B always uses ``detect_mat``. Returns: pd.DataFrame: Object-level radial symmetry measurements with columns: - Object_Label: unique object identifier. - SymZones_CoreRadius: inoculum core radius (pixels). - SymZones_SymmetricRadius: first radius past the core where smoothed angular-sector coverage drops below the symmetry threshold (pixels). - SymZones_MeanExpansion: mean boundary-pixel distance beyond the core (pixels, clamped at 0). - SymZones_MaxExpansion: maximum mask-pixel distance beyond the core (pixels, clamped at 0). - SymZones_CoreEndRadius: canonical operational CoreZone boundary from Method B (pixels). - SymZones_DenseEndRadius: canonical DenseZone outer boundary from Method B (pixels). - SymZones_SparseEndRadius: configured target-mask radial percentile used as the canonical outer boundary (pixels). - SymZones_CoreArea: pixel^2 area of the concentric operational CoreZone. - SymZones_DenseArea: pixel^2 area of the dense branching zone (annular region between core and dense boundaries). - SymZones_SparseArea: pixel^2 area of the sparse branching zone (annular region between dense and outer boundaries). Best For: - Summarising colony-level radial growth with a single symmetry figure of merit. - Distinguishing uniformly-expanding colonies from those with sectors, lopsided growth, or directional bias. - Comparing wild-type versus mutant expansion phenotypes when the biological question is about the colony envelope, not individual hyphae. - Time-course assays where runner counts are noisy but colony extent is informative. Consider Also: - :class:`MeasureShape` for general morphological descriptors (circularity, eccentricity) that do not require radial analysis. - :class:`MeasureBounds` for lightweight bounding-box data without any radial pipeline. See Also: :doc:`/tutorials/notebooks/07_measuring_and_exporting` for a walkthrough of measuring and exporting colony data. """ _measurement_infoclass: ClassVar[type] = SYMMETRIC_ZONES intensity_source: Literal["gray", "detect_mat"] = "gray" # Diagnostic cache populated by ``measure()`` so ``inspect()`` / # ``napari()`` can reuse the per-object intermediates. Pure runtime # state — never a constructor parameter — so modeled as private attrs. __cache_image_ref: "weakref.ReferenceType[Image] | None" = PrivateAttr( default=None ) __cache_intermediates: "dict[int, _SymmetryIntermediates]" = PrivateAttr( default_factory=dict) __cache_signature: str | None = PrivateAttr(default=None) # ── shared pipeline for one object ─────────────────────────────── def _compute_intermediates( self, image: Image, object_label: int | None = None, prop=None, ) -> ZoneSegmentation: """Run the shared symmetric-radius pipeline for a single object. Thin delegator to :func:`phenotypic.measure._zone_segmentation.compute_zone_segmentation`. Every caller in this module supplies ``prop`` explicitly; the shared helper resolves the largest object by area when ``prop`` is *None*. Args: image: Detected Image with objmap/objmask. object_label: Retained for signature back-compat; unused because callers always pass ``prop``. prop: Pre-computed RegionProperties object for the target object. Returns: ZoneSegmentation with all computed fields populated. """ return self._resolve_object_zones(image, prop).segmentation # ── MeasureFeatures interface ──────────────────────────────────── def _operate(self, image: Image) -> pd.DataFrame: """Populate the symmetric-radius measurement DataFrame. Args: image: Detected Image with objmap/objmask. Returns: pd.DataFrame with one row per detected object and a leading ``OBJECT.LABEL`` column followed by the four SYMMETRIC_ZONES columns. """ measurements = { str(feature): np.full(image.num_objects, np.nan) for feature in SYMMETRIC_ZONES if feature != SYMMETRIC_ZONES.CATEGORY } props = regionprops( image.objmap[:], intensity_image=image.gray[:].astype(np.float64, copy=False), ) # Refresh cache so inspect() can reuse these results self.__cache_image_ref = weakref.ref(image) self.__cache_intermediates = {} # Legacy zone columns retain their historical 0.0 tiny-object values. # Canonical mode routes tiny objects through the shared resolver, which # emits missing morphology and zone geometry consistently. _zero_for_tiny = ( SYMMETRIC_ZONES.CORE_END_RADIUS, SYMMETRIC_ZONES.DENSE_END_RADIUS, SYMMETRIC_ZONES.SPARSE_END_RADIUS, SYMMETRIC_ZONES.CORE_AREA, SYMMETRIC_ZONES.DENSE_AREA, SYMMETRIC_ZONES.SPARSE_AREA, ) for idx, prop in enumerate(props): if prop.area < 10 and self.legacy_mode: for feat in _zero_for_tiny: measurements[str(feat)][idx] = 0.0 continue try: inter = self._compute_intermediates(image, prop.label, prop=prop) except Exception: import logging logging.getLogger(__name__).debug( "Skipping object label %d", prop.label, exc_info=True, ) continue # leave NaN self.__cache_intermediates[prop.label] = _own_intermediate_arrays(inter) measurements[str(SYMMETRIC_ZONES.CORE_RADIUS)][idx] = inter.core_radius measurements[str(SYMMETRIC_ZONES.SYMMETRIC_RADIUS)][ idx] = inter.symmetric_radius measurements[str(SYMMETRIC_ZONES.MEAN_EXPANSION)][ idx] = inter.mean_expansion measurements[str(SYMMETRIC_ZONES.MAX_EXPANSION)][idx] = inter.max_expansion measurements[str(SYMMETRIC_ZONES.CORE_AREA)][idx] = inter.core_area measurements[str(SYMMETRIC_ZONES.DENSE_AREA)][idx] = inter.dense_area measurements[str(SYMMETRIC_ZONES.SPARSE_AREA)][idx] = inter.sparse_area measurements[str(SYMMETRIC_ZONES.CORE_END_RADIUS)][ idx] = inter.core_end_radius measurements[str(SYMMETRIC_ZONES.DENSE_END_RADIUS)][ idx] = inter.dense_end_radius measurements[str(SYMMETRIC_ZONES.SPARSE_END_RADIUS)][ idx] = inter.sparse_end_radius df = pd.DataFrame(measurements) df.insert(0, OBJECT.LABEL, image.objects.labels2series()) self.__cache_signature = self.model_dump_json() return df # ── cache helpers ──────────────────────────────────────────────── def _require_cache_image(self) -> Image: """Return the cached image or raise if :meth:`measure` has not run.""" image = self.__cached_image() if image is None: raise RuntimeError( "MeasureSymZones: no live image subject is available. " "Pass an image to .inspect(), or keep the measured image " "alive for no-argument rendering." ) return image def __cached_image(self) -> Image | None: """Return the weakly held image without extending its lifetime.""" return ( self.__cache_image_ref() if self.__cache_image_ref is not None else None ) def _get_local_obj_mask(self, label: int) -> np.ndarray: """Local (bbox-cropped) boolean mask for the object with ``label``.""" inter = self.__cache_intermediates[label] return inter.obj_mask def _get_local_dist_map(self, label: int) -> np.ndarray: """Local distance-from-centroid map for the object with ``label``.""" inter = self.__cache_intermediates[label] return inter.dist_map def _get_global_offset(self, label: int) -> tuple[int, int]: """Top-left (row, col) offset from the local bbox to plate coordinates.""" inter = self.__cache_intermediates[label] slc = inter.bbox_slice return int(slc[0].start), int(slc[1].start) # ── overlay polygon helpers ────────────────────────────────────── @staticmethod def _object_polygon_xy( local_mask: np.ndarray, slc: tuple[slice, slice], tolerance: float, ) -> tuple[np.ndarray | None, np.ndarray | None]: """Largest simplified mask contour as plate-coordinate (x, y) arrays. The mask is padded by one zero pixel before contour extraction so objects that touch the local bbox edge still produce closed contours. Without padding, ``find_contours`` returns an open curve for edge-touching masks and plotly's ``fill="toself"`` closes it with a straight last→first segment that slices across the object. Args: local_mask: Boolean mask in local bbox coordinates. slc: ``(row_slice, col_slice)`` mapping the local bbox to plate coordinates. tolerance: Douglas-Peucker tolerance in pixels. Returns: ``(xs, ys)`` float ndarrays for the simplified polygon in plate coordinates, or ``(None, None)`` if no usable contour exists (mask empty or contour collapses below 3 vertices). """ if not local_mask.any(): return None, None padded = np.pad(local_mask, 1, mode="constant", constant_values=False) contours = find_contours(padded.astype(np.float64), 0.5) if not contours: return None, None contour = max(contours, key=len) if contour.shape[0] < 3: return None, None simplified = approximate_polygon(contour, tolerance=tolerance) if simplified.shape[0] < 3: return None, None # Subtract 1 to undo the padding offset before mapping to plate coords. r0 = float(slc[0].start) c0 = float(slc[1].start) ys = simplified[:, 0] - 1.0 + r0 xs = simplified[:, 1] - 1.0 + c0 return xs, ys @staticmethod def _polar_polygon_xy( centroid_xy: tuple[float, float], r_per_angle: np.ndarray, stride: int, ) -> tuple[np.ndarray, np.ndarray]: """Closed polar polygon as plate-coordinate (x, y) arrays. ``r_per_angle`` is the per-degree radius array (length 360); ``stride`` downsamples it (``stride=5`` → 72 vertices). The polygon is closed by repeating the first vertex. Args: centroid_xy: ``(cx, cy)`` centroid in plate coordinates. r_per_angle: (360,) per-degree radii. stride: Subsampling stride applied to the angle axis. Returns: Tuple ``(xs, ys)`` of float64 arrays, both length ``ceil(360 / stride) + 1`` (closed). """ cx, cy = centroid_xy angles_deg: np.ndarray = np.arange( 0, _N_ANGULAR_SECTORS, stride, dtype=np.int32 ) theta = np.deg2rad(angles_deg) radii = r_per_angle[angles_deg].astype(np.float64) xs = cx + radii * np.cos(theta) ys = cy + radii * np.sin(theta) xs = np.concatenate([xs, xs[:1]]) ys = np.concatenate([ys, ys[:1]]) return xs, ys @staticmethod def _polar_annulus_xy( centroid_xy: tuple[float, float], r_inner_per_angle: np.ndarray, r_outer_per_angle: np.ndarray, stride: int, ) -> tuple[np.ndarray, np.ndarray]: """Closed polar annulus as plate-coordinate (x, y) arrays. Traces the outer ring forward then the inner ring in reverse, producing a ring polygon that plotly's ``fill="toself"`` renders correctly (the reversed inner winding subtracts the inner disk from the outer disk without self-intersection). Args: centroid_xy: ``(cx, cy)`` centroid in plate coordinates. r_inner_per_angle: (360,) per-degree inner radii. r_outer_per_angle: (360,) per-degree outer radii. stride: Subsampling stride applied to the angle axis. Returns: Tuple ``(xs, ys)`` of float64 arrays, closed. """ if stride <= 0: raise ValueError(f"stride must be positive, got {stride}") cx, cy = centroid_xy angles_deg: np.ndarray = np.arange( 0, _N_ANGULAR_SECTORS, stride, dtype=np.int32 ) theta = np.deg2rad(angles_deg) r_out = r_outer_per_angle[angles_deg].astype(np.float64) r_in = r_inner_per_angle[angles_deg].astype(np.float64) cos_t = np.cos(theta) sin_t = np.sin(theta) outer_x = cx + r_out * cos_t outer_y = cy + r_out * sin_t inner_x = cx + r_in * cos_t inner_y = cy + r_in * sin_t # Outer ring forward, inner ring reversed, close back to the first # outer vertex. The opposite windings let plotly's non-zero-fill # "toself" render the ring (the inner traversal subtracts the hole). xs = np.concatenate([outer_x, inner_x[::-1], outer_x[:1]]) ys = np.concatenate([outer_y, inner_y[::-1], outer_y[:1]]) return xs, ys # ── diagnostics ──────────────────────────────────────────────────
[docs] @figure( title="Symmetric-radius overlay", primary=True, controls={"base_layer": BASE_LAYER}, ) def inspect( self, image: Image | None = None, base_layer: Literal["rgb", "gray", "detect_mat"] = "gray", *, for_save: bool = False, ): """Plate-level diagnostic overlay for symmetric-radius measurement. Args: image: Detected Image with objmap/objmask. If *None*, the image cached by the most recent :meth:`measure` call is reused. base_layer: Which image array to use as the plotly background. for_save: When *True*, every overlay trace is force-shown (no ``visible="legendonly"``) so the figure renders meaningfully as a static raster. The CLI's PlotImage publication passes this. Defaults to *False* (interactive Jupyter use, with overlay layers toggleable from the legend). Returns: plotly.graph_objects.Figure with toggleable overlay layers. Renders natively in Jupyter via the plotly mime bundle. For scroll-to-zoom, call ``fig.show(config={"scrollZoom": True})``. """ from phenotypic.sdk_._plotly_helpers import _require_plotly _require_plotly() import plotly.graph_objects as go valid_base_layers = BASE_LAYER.options or () if base_layer not in valid_base_layers: allowed = ", ".join(repr(value) for value in valid_base_layers) raise ValueError( f"base_layer must be one of {allowed}; got {base_layer!r}" ) if image is None: image = self._require_cache_image() props = regionprops( image.objmap[:], intensity_image=image.gray[:].astype(np.float64, copy=False), ) intermediates_cache: dict[int, _SymmetryIntermediates] = {} cache_is_current = ( self.__cached_image() is image and self.__cache_signature == self.model_dump_json() ) for prop in props: if ( cache_is_current and prop.label in self.__cache_intermediates ): intermediates_cache[prop.label] = self.__cache_intermediates[prop.label] continue try: inter = self._compute_intermediates(image, prop.label, prop=prop) intermediates_cache[prop.label] = _own_intermediate_arrays(inter) except Exception: continue self.__cache_image_ref = weakref.ref(image) self.__cache_intermediates = intermediates_cache self.__cache_signature = self.model_dump_json() if not intermediates_cache: fig = go.Figure() fig.add_annotation( text="No objects found for symmetric-radius analysis.", xref="paper", yref="paper", x=0.5, y=0.5, showarrow=False, font=dict(color=_OI_NAVY), # family from the phenotypic template ) return fig fig = self._build_plate_overview( image, intermediates_cache, base_layer=base_layer, ) h, w = image.gray[:].shape[:2] overview_h = int(900 * h / w) fig.update_layout( title=dict( text=( "Symmetric radius and radial zones " f"(P{self.outer_zone_percentile:g}) -- " f"{len(intermediates_cache)} objects" ), font=dict(color=_OI_NAVY), # family from the phenotypic template ), height=overview_h, ) if for_save: for trace in fig.data: if getattr(trace, "visible", True) == "legendonly": trace.visible = True return fig
@staticmethod def _build_plate_overview( image: Image, intermediates_cache: dict[int, _SymmetryIntermediates], base_layer: str = "gray", ): """Build a zoomable plotly plate overview with toggleable overlays. Args: image: Detected Image with objmap/objmask. intermediates_cache: Pre-computed intermediates keyed by label. base_layer: Which image array to use as background. Returns: plotly.graph_objects.Figure. """ from phenotypic.sdk_._plotly_helpers import ( plotly_imshow, ) if base_layer == "rgb": arr = image.rgb[:] elif base_layer == "detect_mat": arr = image.detect_mat[:] else: arr = image.gray[:] h, w = arr.shape[:2] display_w = 900 display_h = int(display_w * h / w) fig = plotly_imshow( arr, title="Plate Overview", figsize=(display_w // 100, display_h // 100), ) fig.update_coloraxes(showscale=False) MeasureSymZones._add_overlay_traces( fig, image, intermediates_cache, ) return fig @staticmethod def _add_overlay_traces( fig, image: Image, intermediates_cache: dict[int, _SymmetryIntermediates], ) -> None: """Add toggleable trace layers to the plate overview. Layers (all legend-toggleable): - **Objmap** — simplified mask contour per object, filled with a label-keyed overlay palette. - **Zones** — three legend entries grouped under the "Zones" heading: **Sparse zone** (sky, annulus r_dense_end..r_outer), **Dense zone** (navy, annulus r_core..r_dense_end), and **CoreZone** (vermilion, disk 0..r_core). In canonical mode, ``r_outer`` is the configured target-mask radial percentile. The legend group is set to toggle as a group, so clicking any entry hides/shows all three; the entries can also be toggled individually. - **Centroids** — inoculum centre markers for every object. - **Core radius** — dashed vermilion circles. - **Symmetric radius** — purple dashed circles (all objects). - **Outer envelope** — solid green per-angle polygon tracing the uncapped mask reach per object. Drawn for every object with any mask pixels, including those whose symmetry thresholding failed (``zones_computed=False``); those objects intentionally have no Zones traces. Args: fig: Plotly figure to modify in-place. image: Detected Image (used for objmap overlay). intermediates_cache: Per-object intermediates keyed by label. """ import plotly.graph_objects as go _N_CIRCLE_PTS = 72 _theta = np.linspace(0, 2 * np.pi, _N_CIRCLE_PTS, endpoint=True) def _circle_xy(cx: float, cy: float, r: float): return cx + r * np.cos(_theta), cy + r * np.sin(_theta) def _hex_to_rgba(hex_str: str, alpha: float) -> str: h = hex_str.lstrip("#") r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) return f"rgba({r}, {g}, {b}, {alpha:.3f})" # Make grouped legend entries toggle every trace in the group. fig.update_layout(legend=dict(groupclick="togglegroup")) _FILL_ALPHA = 0.30 # ── Objmap polygons (label-keyed palette, NaN-separated buckets) ── # Use the same overlay palette as skimage-based objmap overlays so # the Objmap layer here matches colors elsewhere in phenotypic, and # key it on ``label`` (not enumeration index) so neighbouring objects # cycle through the whole palette rather than colliding. from phenotypic._core._image_parts.accessor_abstracts._image_accessor_base_parents._accessor_mpl_handler import ( # noqa: E501 AccessorMplHandler, ) _palette_rgb = (AccessorMplHandler._OVERLAY_COLORS * 255).astype(np.int32) n_palette = len(_palette_rgb) bucket_xs: list[list[float]] = [[] for _ in range(n_palette)] bucket_ys: list[list[float]] = [[] for _ in range(n_palette)] sorted_inters = sorted( intermediates_cache.values(), key=lambda x: x.label, ) for inter in sorted_inters: xs, ys = MeasureSymZones._object_polygon_xy( inter.obj_mask, inter.bbox_slice, _OBJMAP_POLYGON_TOLERANCE, ) if xs is None or ys is None: continue # Labels start at 1; subtract before mod so label 1 → palette 0. bi = (int(inter.label) - 1) % n_palette bucket_xs[bi].extend(xs.tolist()) bucket_xs[bi].append(float("nan")) bucket_ys[bi].extend(ys.tolist()) bucket_ys[bi].append(float("nan")) first_objmap_drawn = False for bi, (bxs, bys) in enumerate(zip(bucket_xs, bucket_ys)): if not bxs: continue r, g, b = _palette_rgb[bi] fillcolor = f"rgba({int(r)}, {int(g)}, {int(b)}, {_FILL_ALPHA:.3f})" fig.add_trace(go.Scatter( x=bxs, y=bys, mode="lines", line=dict(width=0), fill="toself", fillcolor=fillcolor, legendgroup="objmap", name="Objmap", showlegend=not first_objmap_drawn, visible="legendonly", hoverinfo="skip", )) first_objmap_drawn = True # ── Zone polygons — nested annuli + core disk ─────────────── # Core: solid disk 0..r_core. Dense: annulus r_core..r_dense_end. # Sparse: annulus r_dense_end..r_outer. Canonical r_outer is the # configured target-mask radial percentile; legacy retains its cap. # Drawn sparse → dense → core so narrower zones render on top of # wider ones and the legend-group toggle handles all three at once. sparse_xs: list[float] = [] sparse_ys: list[float] = [] dense_xs: list[float] = [] dense_ys: list[float] = [] zcore_xs: list[float] = [] zcore_ys: list[float] = [] for inter in intermediates_cache.values(): if not inter.zones_computed: continue slc = inter.bbox_slice r0, c0 = int(slc[0].start), int(slc[1].start) cxy = (inter.centroid_rc[1] + c0, inter.centroid_rc[0] + r0) # Zone radii are scalars; broadcast to per-angle arrays so the # existing polar-polygon helpers draw concentric circles. r_core_arr: np.ndarray = np.full( _N_ANGULAR_SECTORS, inter.core_end_radius, dtype=np.float64, ) r_dense_arr: np.ndarray = np.full( _N_ANGULAR_SECTORS, inter.dense_end_radius, dtype=np.float64, ) r_outer_arr: np.ndarray = np.full( _N_ANGULAR_SECTORS, inter.sparse_end_radius, dtype=np.float64, ) sxs, sys = MeasureSymZones._polar_annulus_xy( cxy, r_dense_arr, r_outer_arr, _ZONE_POLYGON_STRIDE, ) sparse_xs.extend(sxs.tolist()) sparse_xs.append(float("nan")) sparse_ys.extend(sys.tolist()) sparse_ys.append(float("nan")) dxs, dys = MeasureSymZones._polar_annulus_xy( cxy, r_core_arr, r_dense_arr, _ZONE_POLYGON_STRIDE, ) dense_xs.extend(dxs.tolist()) dense_xs.append(float("nan")) dense_ys.extend(dys.tolist()) dense_ys.append(float("nan")) cxs, cys = MeasureSymZones._polar_polygon_xy( cxy, r_core_arr, _ZONE_POLYGON_STRIDE, ) zcore_xs.extend(cxs.tolist()) zcore_xs.append(float("nan")) zcore_ys.extend(cys.tolist()) zcore_ys.append(float("nan")) # Three legend entries share legendgroup="zones"; groupclick is set # to "togglegroup" above so clicking any of them toggles all three at # once, while individual toggles are still available. zone_layers = [ (sparse_xs, sparse_ys, _OI_SKY, "Sparse zone"), (dense_xs, dense_ys, _OI_NAVY, "Dense zone"), (zcore_xs, zcore_ys, _OI_VERMILION, "Core zone"), ] for zxs, zys, zcolor, zname in zone_layers: if not zxs: continue fig.add_trace(go.Scatter( x=zxs, y=zys, mode="lines", line=dict(width=0), fill="toself", fillcolor=_hex_to_rgba(zcolor, _FILL_ALPHA), legendgroup="zones", legendgrouptitle_text="Zones", name=zname, visible="legendonly", hoverinfo="skip", )) # ── Centroids ─────────────────────────────────────────────── cent_x, cent_y = [], [] for inter in intermediates_cache.values(): slc = inter.bbox_slice r0, c0 = int(slc[0].start), int(slc[1].start) cent_x.append(inter.centroid_rc[1] + c0) cent_y.append(inter.centroid_rc[0] + r0) if cent_x: fig.add_trace(go.Scatter( x=cent_x, y=cent_y, mode="markers", marker=dict( color=_OI_ORANGE, size=8, symbol="circle", line=dict(color=_OI_NAVY, width=1), ), name="Centroids", hoverinfo="skip", )) # ── Core radius circles (all objects) ─────────────────────── core_x: list[float] = [] core_y: list[float] = [] for inter in intermediates_cache.values(): if inter.core_radius <= 0: continue slc = inter.bbox_slice r0, c0 = int(slc[0].start), int(slc[1].start) cx = inter.centroid_rc[1] + c0 cy = inter.centroid_rc[0] + r0 xs, ys = _circle_xy(cx, cy, inter.core_radius) core_x.extend(xs.tolist()) core_y.extend(ys.tolist()) core_x.append(float("nan")) core_y.append(float("nan")) if core_x: fig.add_trace(go.Scattergl( x=core_x, y=core_y, mode="lines", line=dict(color=_OI_VERMILION, width=1.5, dash="dash"), name="Core radius", hoverinfo="skip", )) # ── Symmetric radius circles (all objects) ────────────────── sym_x: list[float] = [] sym_y: list[float] = [] for inter in intermediates_cache.values(): sr = inter.symmetric_radius if sr <= 0 or not np.isfinite(sr): continue slc = inter.bbox_slice r0, c0 = int(slc[0].start), int(slc[1].start) cx = inter.centroid_rc[1] + c0 cy = inter.centroid_rc[0] + r0 xs, ys = _circle_xy(cx, cy, sr) sym_x.extend(xs.tolist()) sym_y.extend(ys.tolist()) sym_x.append(float("nan")) sym_y.append(float("nan")) if sym_x: fig.add_trace(go.Scattergl( x=sym_x, y=sym_y, mode="lines", line=dict(color=_OI_PURPLE, width=2.5, dash="dash"), name="Symmetric radius", hoverinfo="skip", )) # ── Outer envelope polygons (per-angle, uncapped mask reach) ── env_x: list[float] = [] env_y: list[float] = [] for inter in intermediates_cache.values(): r_env = inter.r_outer_full_per_angle if float(r_env.max()) <= 0: continue slc = inter.bbox_slice r0, c0 = int(slc[0].start), int(slc[1].start) cxy = (inter.centroid_rc[1] + c0, inter.centroid_rc[0] + r0) xs, ys = MeasureSymZones._polar_polygon_xy( cxy, r_env, _ZONE_POLYGON_STRIDE, ) env_x.extend(xs.tolist()) env_y.extend(ys.tolist()) env_x.append(float("nan")) env_y.append(float("nan")) if env_x: fig.add_trace(go.Scattergl( x=env_x, y=env_y, mode="lines", line=dict(color=_OI_GREEN, width=1.25), name="Outer envelope", hoverinfo="skip", ))
MeasureSymZones.__doc__ = SYMMETRIC_ZONES.append_rst_to_doc( MeasureSymZones )