"""The CPU detectors: a histogram's worth of thresholds, and propagation.
Make Masks' plain-threshold mode was Otsu and only Otsu. Otsu asks one
question of a histogram -- where is the cut that separates it into two
groups with the least variance inside each -- and it is a good question
for a field with two clear populations and a poor one for everything else.
A field that is mostly background, or whose objects are a thin bright
tail, has been thresholded better by Li's minimum cross entropy or by the
triangle method since before spaCR existed, and both are one call away in
scikit-image.
So the threshold algorithms here are NAMES OF A LEVEL AND NOTHING ELSE.
Each is one line in :data:`spacr.qt.mask_engine.GLOBAL_THRESHOLDS` or
:data:`spacr.qt.mask_engine.LOCAL_THRESHOLDS`, and everything around the
level -- the smoothing, the threshold correction, the bright or dark side,
filling holes, the watershed split, the border rule, the minimum area --
is the code Otsu already went through. Choosing Li instead of Otsu changes
where one number comes from and nothing else, which is exactly what it
should change.
Maxima + propagate finds bright centres in a blurred image and uses them
as markers for a watershed on inverted intensity. A per-seed intensity
cut then trims each basin, or a common intensity mask limits the watershed.
This can separate touching objects when they have distinct detected centres;
it does not implement CellProfiler's distance/intensity propagation cost.
The seed count is measured before hole filling and area filtering, so it
can exceed the number of surviving labels. See
:func:`spacr.qt.mask_engine.maxima_propagate_instances` for all four rules.
WHAT IS NOT HERE, AND WHY. Local MEAN and local GAUSSIAN thresholding are
not offered again: they are the Adaptive threshold mode, which runs the
organelle engine's ``adaptive`` branch with a block size and an offset.
Local OTSU is not offered again either: it is the "Local threshold (uneven
illumination)" switch in plain Otsu mode, used by whole-image detection.
Other named thresholds ignore saved Local Otsu and class-count settings;
Multi-Otsu alone uses the selected class count and band in both scopes.
Plain Otsu retains its legacy magnifier preprocessing, while the other
thresholds use the whole-image engine on the requested crop. Niblack uses
``T = m - k*s``; Sauvola uses
``T = m*(1 + k*(s/R - 1))``, where ``m`` and ``s`` are the local mean and
standard deviation. The engine supplies float32 values without rescaling
their range, so scikit-image's default Sauvola ``R`` is 1. These formulas
are different, and changing the intensity scale can change Sauvola's result.
"""
from __future__ import annotations
from typing import Dict, NamedTuple, Optional, Tuple
import numpy as np
#: The global threshold algorithms, as ``mode -> the caption the Mode box
#: shows``, in the order it offers them. ``otsu`` is not here: it is the
#: screen's own first row and predates this module.
THRESHOLD_LABELS: Dict[str, str] = {
"li": "Li (minimum cross entropy)",
"yen": "Yen",
"triangle": "Triangle",
"isodata": "IsoData (Ridler-Calvard)",
"mean": "Mean",
"minimum": "Minimum",
"multiotsu": "Multi-Otsu",
"sauvola": "Sauvola (local)",
"niblack": "Niblack (local)",
}
#: The mode that grows objects out of local maxima.
PROPAGATE = "propagate"
SECONDARY = "secondary"
PUNCTA = "puncta_centers"
#: Every mode this module adds, as ``mode -> caption``.
MODE_LABELS: Dict[str, str] = {
**THRESHOLD_LABELS,
PROPAGATE: "Maxima + propagate",
SECONDARY: "Secondary objects from primary masks",
PUNCTA: "Centre-pixel puncta within parent masks",
}
#: Multi-Otsu is a global algorithm in the box but is asked for by a CLASS
#: COUNT in the engine, since that is the parameter it really has. The
#: screen forces the count to at least three while this mode is chosen.
MULTIOTSU = "multiotsu"
#: What each mode suits, in the sentence pattern the organelle methods use
#: (:func:`spacr.organelle_types.method_guidance`). English; a caller
#: showing one passes it through ``tr``.
GUIDANCE: Dict[str, str] = {
PUNCTA: "Detect small, faint puncta inside a separate cyst or cell mask. "
"Uses original channel intensities and ignores display normalization, inversion and enhancement. "
"Choose the parent-mask folder first. LoG peaks are measured in pixel-noise units. "
"Each object contains its nearest centre pixels, not a grown watershed region. "
"Use whole-image Replace for final masks and centre measurements. "
"Parent masks are never modified.",
SECONDARY: "Grow secondary objects from a separate labelled primary mask, "
"keeping each primary object's ID. Choose a global threshold "
"when nuclei are dark in the cell channel. Primary masks are "
"read-only; missing, orphaned and incomplete relationships are reported. "
"Click to accept objects; dragging does not merge primary identities. "
"Post-detection morphology and splitting are bypassed to retain IDs. "
"Try distance growth when bright structures pull intensity growth across cells.",
"otsu": "Suits a field with two clear populations -- objects and "
"background, with a valley between them in the histogram. It "
"is the right first try and the wrong one for a field that is "
"mostly background, where Li or Triangle cut better.",
"cellpose": "Suits cells and nuclei of the kind Cellpose was trained "
"on, and is the only method here that knows what an "
"object looks like rather than only how bright it is. It "
"is slow without a GPU.",
"li": "Suits a field whose objects are a small, bright minority -- Li "
"minimises the cross entropy between the field and its "
"thresholded self rather than assuming two equal populations, so "
"it holds up where Otsu drifts into the background.",
"yen": "Suits a field with a long bright tail and little else; Yen "
"maximises an entropy criterion and tends to cut higher than "
"Otsu, keeping only the confident pixels.",
"triangle": "Suits a field with ONE dominant peak and a shoulder -- "
"mostly background with faint objects on it. The level is "
"geometric: the point on the histogram furthest from the "
"line between the peak and the far end.",
"isodata": "Suits much what Otsu suits and is the classic "
"Ridler-Calvard iteration: the level settles where it is "
"the mean of the two means either side of it. A reasonable "
"second opinion when Otsu looks wrong.",
"mean": "Suits a field split near half and half. The level is the "
"mean intensity, which is as crude as it sounds and is here "
"as a baseline to judge the others against.",
"minimum": "Suits a histogram with two clear humps and a real valley "
"between them; the level is the bottom of the valley. It "
"refuses a histogram it cannot smooth into two humps, and "
"says so rather than guessing.",
"multiotsu": "Suits a field with THREE or more populations -- "
"background, a dim halo and bright objects -- where one "
"level between all of them is a compromise that fits "
"none. Set the class count and which class is the "
"foreground.",
"sauvola": "Suits uneven illumination when local contrast separates "
"objects: the mean is "
"weighted by local contrast. Check the image intensity "
"scale: this detector uses R=1 without rescaling its float "
"input. A constant positive window can become foreground "
"with bright objects and positive k; blank areas are not "
"automatically rejected.",
"niblack": "Suits uneven illumination when local intensity statistics "
"separate objects, using the "
"window's mean minus k times its standard deviation. "
"Increasing k lowers the threshold and keeps more bright "
"pixels, including background; negative k raises it. "
"For dark objects the direction of pixel inclusion reverses.",
PROPAGATE: "Suits touching round objects with a bright centre -- "
"nuclei, vacuoles, colonies. Each centre becomes one "
"object, and two that touch come apart at the ridge "
"between them rather than at a level.",
}
[docs]
class CpuParams(NamedTuple):
"""Everything the CPU modes read, as one hashable value.
One tuple for the same reason :class:`spacr.qt.organelle_modes.MethodParams`
is one: it rides on the magnifier's request key, and a
mode that falls back to another must still find its own settings in it.
:param local_k: dimensionless local contrast weight; default 0.2 for
both modes. Niblack uses ``T = m - k*s``: increasing k lowers the
threshold, admitting more bright pixels and fewer dark pixels.
Sauvola uses ``T = m*(1 + k*(s/R - 1))``. The engine passes floats
without range rescaling, giving scikit-image's default ``R = 1``.
Its response to k depends on the local mean m and deviation s;
positive k does not guarantee rejection of a flat background.
:param propagate_sigma: the Gaussian blur before the maxima are found,
in pixels. SEPARATE FROM THE IMAGE ENHANCEMENT CHAIN'S DENOISE,
which has already run by the time a detector sees the image: this
one exists because the blur that makes one object have one centre
is usually far stronger than the blur anyone wants the object's
EDGE measured through, and the propagation measures the edge on the
same blurred image. Leave the chain's denoise off unless the field
is genuinely noisy, or the two blurs compound.
:param propagate_min_distance: the smallest gap between two seeds, in
pixels; about one object radius.
:param propagate_seed_level: how bright a maximum must be to be a seed.
:param propagate_seed_percentile: read the seed level as a percentile
of the blurred image rather than as an absolute intensity.
:param propagate_exclude_border: drop seeds near the edge.
:param propagate_stop: a key of
:data:`spacr.qt.mask_engine.PROPAGATE_STOPS`.
:param propagate_stop_value: the fraction, intensity or percentile the
stop rule reads.
:param propagate_stop_algorithm: which global threshold provides the
floor under the ``threshold`` stop rule.
:param secondary_growth: ``intensity`` or ``distance`` watershed for
secondary objects; maxima detection ignores this setting.
:param puncta_sigmas: scale-normalised LoG widths in image pixels.
:param puncta_k: candidate response threshold in propagated pixel-noise units.
:param puncta_center_pixels: number of pixels nearest each subpixel centre,
from 1 to 81 in the nine-by-nine measurement window.
:param puncta_min_corrected: minimum centre mean minus local annulus median,
in native intensity units. Equality is retained.
:param puncta_min_distance: peak-local-maximum neighbourhood in pixels;
scale-dependent nonmaximum suppression follows it.
:param puncta_edge_margin: minimum distance from the parent boundary in pixels.
"""
local_k: float = 0.2
propagate_sigma: float = 2.0
propagate_min_distance: int = 10
propagate_seed_level: float = 90.0
propagate_seed_percentile: bool = True
propagate_exclude_border: bool = False
propagate_stop: str = "seed_fraction"
propagate_stop_value: float = 0.4
propagate_stop_algorithm: str = "otsu"
secondary_growth: str = "intensity"
puncta_sigmas: tuple = (1.5, 2.0, 3.0, 4.0, 6.0)
puncta_k: float = 2.5
puncta_center_pixels: int = 20
puncta_min_corrected: float = 3.0
puncta_min_distance: int = 2
puncta_edge_margin: float = 3.0
#: The defaults, as a value to compare a request against.
DEFAULT_PARAMS = CpuParams()
#: ``mode -> the fields of :class:`CpuParams` it reads``, in the order a
#: form should show them. Read by the panel and by :func:`provenance`, so a
#: control that is on screen, a value that is recorded and a number the
#: engine is given are one list.
PARAMETERS_FOR: Dict[str, Tuple[str, ...]] = {
PUNCTA: ("puncta_sigmas", "puncta_k", "puncta_center_pixels",
"puncta_min_corrected", "puncta_min_distance", "puncta_edge_margin"),
SECONDARY: ("propagate_sigma", "propagate_stop", "propagate_stop_value",
"propagate_stop_algorithm", "secondary_growth"),
"sauvola": ("local_k",),
"niblack": ("local_k",),
PROPAGATE: ("propagate_sigma", "propagate_min_distance",
"propagate_seed_level", "propagate_seed_percentile",
"propagate_exclude_border", "propagate_stop",
"propagate_stop_value", "propagate_stop_algorithm"),
}
[docs]
def modes() -> Tuple[str, ...]:
"""Every mode this module adds, in the box's order."""
return tuple(MODE_LABELS)
[docs]
def threshold_modes() -> Tuple[str, ...]:
"""The modes that are a threshold algorithm, Multi-Otsu included."""
return tuple(THRESHOLD_LABELS)
[docs]
def guidance(mode: str) -> str:
"""What ``mode`` suits, as one sentence a picker can show.
:param mode: the detector mode key, such as ``otsu`` or ``li``.
"""
return GUIDANCE.get(str(mode), "")
[docs]
def engine_algorithm(mode: str) -> str:
"""The name :mod:`spacr.qt.mask_engine` knows ``mode`` by.
Multi-Otsu is asked for by a class count rather than by name, so it
maps back to ``otsu``; the engine's ``classes`` parameter is what makes
it multi-level. Everything else is its own name.
:param mode: the detector mode key to map to an engine algorithm.
"""
return "otsu" if str(mode) == MULTIOTSU else str(mode)
[docs]
def provenance(mode: str, params: CpuParams) -> Dict[str, object]:
"""The parameters this mode actually read, for a mask's ledger entry.
:param mode: the selected detector mode key.
:param params: the CPU-mode settings carried by the detection request.
:returns: ``{field: value}``, JSON-safe; empty for a mode that reads
none of them -- a global threshold reads only the Otsu category's
own settings, which the entry already carries.
"""
return {field: getattr(params, field)
for field in PARAMETERS_FOR.get(str(mode), ())}
[docs]
def propagate(image: np.ndarray, params: CpuParams, *, min_area: int = 0,
fill_holes: bool = True):
"""Grow objects out of the local maxima of ``image``.
A thin wrapper on :func:`spacr.qt.mask_engine.maxima_propagate_instances`,
so the screen has one place to turn its
controls into that call's keywords.
:param image: the 2-D field or region, already through the chain.
:param params: the settings as the panel holds them.
:param min_area: the smallest object to keep, in pixels.
:param fill_holes: close the holes inside each grown object.
:returns: a :class:`spacr.qt.mask_engine.PropagateResult`.
"""
from . import mask_engine as engine
return engine.maxima_propagate_instances(
image,
sigma=float(params.propagate_sigma),
min_distance=int(params.propagate_min_distance),
seed_level=float(params.propagate_seed_level),
seed_level_is_percentile=bool(params.propagate_seed_percentile),
exclude_border=bool(params.propagate_exclude_border),
stop=str(params.propagate_stop),
stop_value=float(params.propagate_stop_value),
stop_algorithm=str(params.propagate_stop_algorithm),
min_area=int(min_area), fill_holes=bool(fill_holes))
[docs]
def secondary(image: np.ndarray, primary: np.ndarray, params: CpuParams, *,
min_area: int = 0, fill_holes: bool = True):
"""Grow secondary labels from the existing primary-mask identities.
:param image: prepared 2-D field or magnifier crop.
:param primary: matching integer primary labels; no seeds are detected.
:param params: shared growth settings; centre-finding settings are ignored.
:param min_area: minimum grown area, including the primary footprint.
:param fill_holes: fill holes per label before the area filter.
:returns: mask_engine.SecondaryResult with exact IDs and relationship diagnostics.
"""
from . import mask_engine as engine
return engine.secondary_object_instances(
image, primary, sigma=float(params.propagate_sigma),
growth=str(params.secondary_growth),
stop=str(params.propagate_stop), stop_value=float(params.propagate_stop_value),
stop_algorithm=str(params.propagate_stop_algorithm),
min_area=int(min_area), fill_holes=bool(fill_holes))
[docs]
def puncta(image, parent_labels, params=DEFAULT_PARAMS, *, measurements=False):
"""Find noise-standardised centre-pixel puncta within unchanged parents.
:param image: original finite two-dimensional channel intensities.
:param parent_labels: matching nonnegative integer parent labels.
:param params: candidate, centre-pixel and native-intensity inclusion settings.
:param measurements: return ``(labels, candidates)`` instead of labels alone;
candidate rows include rejected centres and exact overlapping centre means.
:returns: int32 labels, optionally with the candidate measurement DataFrame.
"""
from .mask_engine import _center_puncta_instances
labels, candidates = _center_puncta_instances(image, parent_labels, params)
return (labels, candidates) if measurements else labels