"""The supervised (ground-truth) scoring objective — Phase 4 chunk B (4.4).
``SupervisedScorer`` scores a candidate segmentation **against ground-truth
annotations** resolved through a path-configured :class:`GroundTruthMasks`
loader. It is **modality-tiered** (supervised-scorers §3): the GT source's
modality decides which tier runs, and a tier emits exactly the term(s) it can
honestly compute, so the scorer degrades gracefully as annotations get cheaper:
* **mask tier** (``gt.modality() == "mask"``): resolve the per-image GT mask,
turn it into an **instance-label** array per the loader's ``gt_format``
(``"instance"`` GT is used as-is; ``"binary"`` foreground GT is split into
connected components within each **geometric, detection-independent** grid
cell, so spatially-separated colonies in one cell become distinct GT instances
while the full GT extent is preserved), match predicted objects to those GT
instances (``"binary"`` always via global IoU-greedy so missed/over-extended
GT is scored honestly; ``"instance"`` via the configured strategy — per-grid-
cell on a ``GridImage`` or IoU-greedy), score each matched pair with the chosen
*single* region metric (Dice **xor** IoU — never both, §1 composition note),
and **macro-average per image** into the ``"Region"`` term.
* **count tier** (``gt.modality() == "count"``): **reuse**
:class:`phenotypic.analysis.ExpectedVsDetectedCount` (the master's count QC
check — do *not* re-implement counting, §1 "reuse, don't duplicate") to get
each group's ``|detected − expected| / expected`` divergence, fold it to a
natural-goodness ``[0, 1]`` value anchored on the check's ``fail_threshold``
(``_TERM_SENSE = HIGHER_BETTER``; the base complements it to cost), and emit
the ``"CountMAE"`` term.
* **none tier** (abstain): no resolvable GT → :meth:`availability` is ``False``
and the engine degrades to the configured fallback objective.
The two region metrics are **monotonically related** (``Dice = 2·IoU/(1+IoU)``)
so they rank candidates identically; the scorer therefore carries exactly one
(``region_metric``), guarded by a ``field_validator``.
# TODO(DEFERRED-WORK §1): validate against real annotated plates. v1 ships the
# modality-tiered term machinery (mask region-overlap + count-MAE reuse) and the
# availability gate; the numeric correctness of the region term vs. a real
# annotated calibration set — and the choice of region metric / matching τ that
# best tracks the visual optimum — is deferred until such a set exists. No
# numeric-vs-real-GT test runs here (tests pin construction, term shape,
# availability tiers, count-check reuse, and round-trip only).
"""
from __future__ import annotations
from typing import Any, ClassVar, Literal, Optional, TypeAlias
import numpy as np
import pandas as pd
from pydantic import ConfigDict, field_validator
from skimage import measure
from phenotypic.analysis import ExpectedVsDetectedCount
from ._gt_loader import GroundTruthMasks
from ._matching import MatchPair, match_iou_greedy, match_per_grid_cell
from ._metrics import dice, iou
from ._orient import Sense
from ._qc_scorer import fold_expected_vs_detected_count
from ._scorer import Scorer
#: The single region-overlap metric — Dice **xor** IoU (never both: they rank
#: candidates identically, so a panel carrying both adds no information,
#: supervised-scorers §1 composition note). A type-only closed set.
RegionMetric: TypeAlias = Literal["dice", "iou"]
#: The object-matching strategy — per-grid-cell (the arrayed-plate default) or
#: IoU-greedy (the non-gridded fallback). A type-only closed set.
MatchStrategy: TypeAlias = Literal["grid_cell", "iou_greedy"]
#: Sentinel cell id for pixels **outside the grid** in the geometric cell map
#: (margins beyond the first/last grid edge). Excluded when deriving per-cell
#: binary-GT instances so off-grid foreground is never split into a GT object.
#: Grid cells themselves use the row-major id ``row * ncols + col`` (``>= 0``).
_OUTSIDE_GRID = -1
[docs]
class SupervisedScorer(Scorer):
"""Ground-truth segmentation/count objective, tiered by GT modality.
The :attr:`gt` loader's :meth:`GroundTruthMasks.modality` selects the tier
that runs: a directory of per-image masks → the mask (region-overlap) tier;
a ``.csv``/``.parquet`` count table (paired with a configured
:attr:`count_check`) → the count tier; nothing resolvable → abstain. Each
runnable tier contributes exactly one term — ``"Region"`` (mask) or
``"CountMAE"`` (count) — so :meth:`score_image` returns a tier-appropriate
mapping (empty when no tier can score a given image).
Args:
gt: The path-configured ground-truth loader. Its ``gt_masks_source``
is the serializable handle and its :meth:`GroundTruthMasks.modality`
drives tier selection and :meth:`availability`.
region_metric: The single region-overlap metric for the mask tier —
``"dice"`` (default) or ``"iou"``. The two rank identically; carry
exactly one (Dice **xor** IoU, supervised-scorers §1).
match_strategy: How predicted objects are paired with GT objects on the
mask tier — ``"grid_cell"`` (default; the arrayed plate's grid is the
spatial prior, no τ) or ``"iou_greedy"`` (the non-gridded fallback,
accepting pairs with IoU > :attr:`iou_tau`).
iou_tau: The IoU acceptance threshold for ``"iou_greedy"`` matching
(ignored for ``"grid_cell"``). Default ``0.5`` gives a provably
one-to-one assignment.
count_check: The path-configured :class:`ExpectedVsDetectedCount` reused
for the count tier (do not re-implement counting). Required for the
count tier to run; ``None`` makes the count tier abstain.
Raises:
pydantic.ValidationError: If ``region_metric`` is not exactly one of
``"dice"`` / ``"iou"`` (the Dice-xor-IoU guard).
Examples:
Construct against a count-table GT source and inspect availability — the
synthetic plate is the runnable doctest target (construction + the
modality-tiered availability gate only; numeric GT scoring is deferred):
>>> import tempfile
>>> from pathlib import Path
>>> import pandas as pd
>>> from phenotypic.analysis import ExpectedVsDetectedCount
>>> from phenotypic.tune.score import GroundTruthMasks, SupervisedScorer
>>> tmp = Path(tempfile.mkdtemp())
>>> counts = tmp / "counts.csv"
>>> _ = pd.DataFrame(
... {"MetadataImage_ImageName": ["Synthetic96PlateWithObjects"] * 96,
... "Object_Label": list(range(96))}
... ).to_csv(counts, index=False)
>>> scorer = SupervisedScorer(
... gt=GroundTruthMasks(gt_masks_source=counts),
... count_check=ExpectedVsDetectedCount(
... metadata=str(counts), groupby=["MetadataImage_ImageName"]
... ),
... )
>>> scorer.gt.modality()
'count'
>>> scorer.availability() # count tier runnable (check configured)
True
A sourceless loader abstains, so the engine degrades to the fallback:
>>> SupervisedScorer(gt=GroundTruthMasks(gt_masks_source=None)).availability()
False
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
#: The mask-tier region-overlap term name.
region_term_name: ClassVar[str] = "Region"
#: The count-tier folded-divergence term name.
count_term_name: ClassVar[str] = "CountMAE"
#: Both tier terms (Region = Dice/IoU; CountMAE = folded count goodness) are
#: bounded [0,1] goodness; the base complements them into cost.
_TERM_SENSE = Sense.HIGHER_BETTER
gt: GroundTruthMasks
region_metric: RegionMetric = "dice"
match_strategy: MatchStrategy = "grid_cell"
iou_tau: float = 0.5
count_check: Optional[ExpectedVsDetectedCount] = None
@field_validator("region_metric", mode="before")
@classmethod
def _exactly_one_region_metric(cls, value: object) -> RegionMetric:
"""Guard the single-region-metric contract (Dice **xor** IoU).
The two region metrics rank candidates identically, so the panel carries
exactly one. This rejects anything outside the closed
:data:`RegionMetric` set (e.g. a ``"both"`` sentinel) at construction.
Args:
value: The raw ``region_metric`` input.
Returns:
The validated metric name (``"dice"`` or ``"iou"``).
Raises:
ValueError: If ``value`` is not exactly ``"dice"`` or ``"iou"``.
"""
if value == "dice":
return "dice"
if value == "iou":
return "iou"
raise ValueError(
"region_metric must be exactly one of 'dice' or 'iou' "
"(Dice xor IoU — they rank identically, so carry only one); "
f"got {value!r}"
)
[docs]
def availability(self) -> bool:
"""Whether some GT tier can run as configured (modality-tiered).
Reads :meth:`GroundTruthMasks.modality`: the **mask** tier is available
whenever a mask source resolves; the **count** tier is available only
when a :attr:`count_check` is also configured (the tier reuses it); the
**none** modality abstains. When this returns ``False`` the engine
degrades to the configured fallback objective.
Returns:
``True`` if the mask tier (mask modality) or the count tier (count
modality **and** a configured ``count_check``) can run; ``False``
otherwise.
"""
modality = self.gt.modality()
if modality == "mask":
return True
if modality == "count":
return self.count_check is not None
return False
def _score_terms(
self, image: Any, measurements: pd.DataFrame
) -> dict[str, float]:
"""Natural goodness terms by GT modality tier (the base complements to cost).
Dispatches on :meth:`GroundTruthMasks.modality`:
* **mask** — resolve the per-image GT mask via
:meth:`GroundTruthMasks.masks_for` (keyed by ``image.name``); when it
resolves, match predicted vs. GT objects (:mod:`._matching`), score
each matched pair with the chosen single region metric
(:mod:`._metrics`), and **macro-average per image** into ``"Region"``.
An unresolved name yields no term (nothing to score).
* **count** — reuse the configured :attr:`count_check`
(:class:`ExpectedVsDetectedCount`) to get each group's normalized
count divergence, fold it to a natural goodness ``[0, 1]`` value
anchored on ``fail_threshold``, and emit ``"CountMAE"``. The base
:meth:`score_image` complements each term to cost.
* **none** — abstain (empty mapping).
Args:
image: The processed image — a ``GridImage`` (duck-typed: ``name``,
``objmap``, ``grid.get_section_map``) on the mask tier; unused on
the count tier.
measurements: The candidate pipeline's measurement frame.
Returns:
A mapping with the runnable tier's natural goodness term —
``{"Region": ...}`` (mask), ``{"CountMAE": ...}`` (count) — or
``{}`` when no tier can score this image. Values are natural goodness
in ``[0, 1]``; the base ``score_image`` orients them to cost.
"""
modality = self.gt.modality()
if modality == "mask":
return self._score_mask_tier(image, measurements)
if modality == "count":
return self._score_count_tier(measurements)
return {}
# ------------------------------------------------------------------ #
# mask tier — match → per-pair region metric → macro-average
# ------------------------------------------------------------------ #
def _score_mask_tier(
self, image: Any, measurements: pd.DataFrame
) -> dict[str, float]:
"""Macro-average the per-pair region metric into the ``"Region"`` term.
The loaded GT is first turned into an **instance-label** array per the
loader's :attr:`GroundTruthMasks.gt_format` (:meth:`_gt_instances`) —
``"instance"`` GT is used as-is; ``"binary"`` GT is split into per-cell
connected components on a **geometric** (detection-independent) cell map.
That instance array then drives matching and per-pair scoring.
Matching is dispatched by format:
* ``"binary"`` always uses :func:`match_iou_greedy` (pure global IoU, no
section-map dependency) so a GT instance in a cell the prediction
missed stays **unmatched** (scored ``0.0`` = correct miss penalty) and
an over-extended GT is **not clipped** to the prediction's pixels.
* ``"instance"`` honours the configured :attr:`match_strategy` (the
arrayed-plate ``"grid_cell"`` default, or ``"iou_greedy"``) — the GT
already carries authoritative per-object labels, so the grid prior is
safe to use.
Args:
image: The ``GridImage`` whose ``name`` keys the GT mask and whose
``objmap`` / ``grid`` drive matching.
measurements: Unused (the mask tier reads the object map, not the
frame) — accepted for signature parity.
Returns:
``{"Region": <macro-averaged metric in [0, 1]>}`` when the image's GT
mask resolves; ``{}`` when no GT mask exists for ``image.name``.
"""
del measurements # mask tier reads image.objmap, not the frame
gt_mask = self.gt.masks_for(getattr(image, "name", ""))
if gt_mask is None:
return {}
gt_instances = self._gt_instances(image, gt_mask)
if self.gt.gt_format == "binary":
# Pure global IoU: detection-independent, so misses/over-extension
# are scored honestly (no clipping to the prediction's footprint).
pairs = match_iou_greedy(
image.objmap[:], gt_instances, tau=self.iou_tau
)
else:
pairs = self._match(image, gt_instances)
score = self._macro_average_region(image, gt_instances, pairs)
return {self.region_term_name: score}
def _gt_instances(self, image: Any, gt_mask: Any) -> np.ndarray:
"""Resolve the loaded GT into an integer **instance-label** array.
Dispatches on the loader's :attr:`GroundTruthMasks.gt_format`:
* ``"instance"`` — the GT is already an integer instance-label array
(each colony a distinct positive label); it is used **as-is** (the
loader now preserves on-disk integer labels, so the existing matcher
path is correct).
* ``"binary"`` — the GT is a foreground mask; per-cell instances are
derived from a **geometric, detection-independent** cell map
(:meth:`_geometric_cell_map_or_none`, built from the grid *edges* — not
from ``get_section_map()``, which is prediction-derived and would clip
the GT to the prediction's pixels and erase entirely-missed cells).
With ``foreground = gt > 0``, each grid cell is connected-component
labelled independently on ``foreground & (cell_map == cell_id)``, with
a running label offset so every component is globally unique. This
**splits spatially-separated colonies in one cell into distinct
instances automatically** (only truly-touching clumps stay merged —
those need ``"instance"`` GT) and **preserves the full GT extent** so
the downstream IoU reflects under-segmentation honestly. An image with
no grid (not a ``GridImage``) falls back to a single global
:func:`skimage.measure.label` of the foreground.
Args:
image: The processed image (a ``GridImage`` exposes
``grid.get_row_edges()`` / ``get_col_edges()``; a plain image
does not).
gt_mask: The loaded GT array (integer labels or a binary foreground,
dtype preserved by :meth:`GroundTruthMasks.masks_for`).
Returns:
An integer instance-label array the same shape as ``gt_mask``
(``0`` = background, positive = distinct GT colony instances).
"""
gt = np.asarray(gt_mask)
if self.gt.gt_format == "instance":
return gt.astype(np.int64, copy=False)
# gt_format == "binary": derive per-cell connected-component instances on
# a GEOMETRIC (detection-independent) cell map.
foreground = gt > 0
cell_map = self._geometric_cell_map_or_none(image, foreground.shape)
if cell_map is None:
# No grid → a single global connected-components labelling.
return measure.label(foreground).astype(np.int64, copy=False)
gt_instances = np.zeros(foreground.shape, dtype=np.int64)
running_max = 0
# Off-grid margin (_OUTSIDE_GRID = -1) is excluded so foreground beyond
# the grid edges is never promoted into a spurious instance.
cell_ids = [
int(c) for c in np.unique(cell_map) if int(c) != _OUTSIDE_GRID
]
for cell_id in cell_ids:
cell_fg = foreground & (cell_map == cell_id)
if not cell_fg.any():
continue
cell_labels = measure.label(cell_fg)
cell_max = int(cell_labels.max())
if cell_max == 0:
continue
# Offset so this cell's components are globally unique.
gt_instances[cell_labels > 0] = (
cell_labels[cell_labels > 0] + running_max
)
running_max += cell_max
return gt_instances
@staticmethod
def _geometric_cell_map_or_none(
image: Any, shape: tuple[int, ...]
) -> Optional[np.ndarray]:
"""A per-pixel, **detection-independent** grid-cell-id map from grid edges.
Builds the cell map purely from the grid line positions
(``grid.get_row_edges()`` / ``get_col_edges()``, each strictly increasing
with length ``n+1``) — never from ``get_section_map()`` (which is derived
from the predicted ``objmap`` and so only labels detected-colony pixels).
Each image row/column is digitized into its grid row/col; pixels inside
the grid get the row-major cell id ``row * ncols + col`` (``>= 0``) and
pixels in the margins beyond the first/last edge get
:data:`_OUTSIDE_GRID` (``-1``).
Args:
image: The processed image; only a ``GridImage`` exposes the grid
edge accessors and ``grid.nrows`` / ``grid.ncols``.
shape: The GT mask's ``(H, W[, ...])`` shape; the first two entries
are the image height and width the map is built over.
Returns:
An ``(H, W)`` integer cell-id map (cells ``>= 0`` row-major, margin
``-1``), or ``None`` when the image carries no usable grid (so the
binary-GT derivation falls back to a single global labelling).
"""
grid = getattr(image, "grid", None)
if grid is None:
return None
row_getter = getattr(grid, "get_row_edges", None)
col_getter = getattr(grid, "get_col_edges", None)
nrows = getattr(grid, "nrows", None)
ncols = getattr(grid, "ncols", None)
if (
row_getter is None
or col_getter is None
or nrows is None
or ncols is None
):
return None
height, width = int(shape[0]), int(shape[1])
row_edges = np.asarray(row_getter())
col_edges = np.asarray(col_getter())
# Digitize each pixel index into its grid row/col; subtract 1 so the
# first interior bin is 0. Pixels before edge[0] -> -1, pixels at/after
# edge[-1] -> n (both filtered by the valid mask below).
row_bin = np.digitize(np.arange(height), row_edges) - 1
col_bin = np.digitize(np.arange(width), col_edges) - 1
valid_row = (row_bin >= 0) & (row_bin < int(nrows))
valid_col = (col_bin >= 0) & (col_bin < int(ncols))
valid = valid_row[:, None] & valid_col[None, :]
cell_id = row_bin[:, None] * int(ncols) + col_bin[None, :]
return np.where(valid, cell_id, _OUTSIDE_GRID).astype(np.int64)
def _match(self, image: Any, gt_instances: Any) -> list[MatchPair]:
"""Pair predicted vs. GT objects by the configured strategy.
Args:
image: The ``GridImage`` carrying the predicted ``objmap`` (and, for
``"grid_cell"``, the ``grid.get_section_map``).
gt_instances: The per-image ground-truth **instance-label** array
(from :meth:`_gt_instances`).
Returns:
The ``(pred_label, gt_label)`` pairs from :mod:`._matching`.
"""
if self.match_strategy == "grid_cell":
return match_per_grid_cell(image, gt_instances)
return match_iou_greedy(image.objmap[:], gt_instances, tau=self.iou_tau)
def _macro_average_region(
self, image: Any, gt_instances: Any, pairs: list[MatchPair]
) -> float:
"""Macro-average the single region metric over every match pair.
Each pair contributes the chosen metric between the predicted object's
mask and the GT object's mask; an unmatched object (``None`` on a side)
is scored against an empty mask, so a missed or spurious object scores
``0.0`` (a non-empty vs. empty overlap, §A.5). With no objects on either
side the image is perfectly (vacuously) scored ``1.0``.
Args:
image: The ``GridImage`` carrying the predicted ``objmap``.
gt_instances: The per-image ground-truth **instance-label** array
(from :meth:`_gt_instances`); must share the predicted objmap's
shape.
pairs: The match pairs from :meth:`_match`.
Returns:
The macro-averaged region metric in ``[0, 1]`` (higher = better).
Raises:
ValueError: If the predicted objmap and ``gt_instances`` differ in
shape (they must be pixel-aligned for per-object overlap).
"""
if not pairs:
return 1.0 # nothing predicted, nothing annotated → vacuous match
pred = np.asarray(image.objmap[:])
gt = np.asarray(gt_instances)
if pred.shape != gt.shape:
raise ValueError(
"predicted objmap and GT instances must share a shape for "
f"per-object overlap; got pred {pred.shape} vs. GT {gt.shape}"
)
metric = dice if self.region_metric == "dice" else iou
# Build the empty fallback from the *pred* shape (pred and gt are
# shape-checked equal above, so either works; pred is the candidate).
empty = np.zeros(pred.shape, dtype=bool)
scores: list[float] = []
for pred_label, gt_label in pairs:
pred_obj = (pred == pred_label) if pred_label is not None else empty
gt_obj = (gt == gt_label) if gt_label is not None else empty
scores.append(metric(pred_obj, gt_obj))
return float(sum(scores) / len(scores))
# ------------------------------------------------------------------ #
# count tier — reuse ExpectedVsDetectedCount (do NOT re-implement)
# ------------------------------------------------------------------ #
def _score_count_tier(self, measurements: pd.DataFrame) -> dict[str, float]:
"""Fold the reused count divergence into the ``"CountMAE"`` term.
Reuses the configured :attr:`count_check`
(:class:`ExpectedVsDetectedCount`) exactly as :class:`QCScorer` does —
runs the check, anchors each group's ``QC_Count_Metric`` on the check's
``fail_threshold`` via :func:`_threshold_anchored`, and averages across
groups — turning the lower-is-better divergence into a higher-is-better
``[0, 1]`` score. An empty frame, or a count tier with no configured
check, scores ``0.0``.
Args:
measurements: The candidate pipeline's measurement frame.
Returns:
``{"CountMAE": <score in [0, 1]>}`` (higher = better).
"""
if self.count_check is None:
return {self.count_term_name: 0.0}
return {
self.count_term_name: fold_expected_vs_detected_count(
self.count_check, measurements
)
}