"""Organelle detection's methods, offered to Make Masks as magnifier modes.
spaCR already implements eight ways of finding an object -- ``otsu``,
``adaptive``, ``log``, ``dog``, ``ridge``, ``hysteresis``, ``cellpose`` and
``unet`` -- in :func:`spacr.object.generate_organelle_masks_sam`. Make Masks
offered two of them. A curator correcting a mask of tubules had Otsu and
Cellpose; the ridge filter that would have found the tubules was three
screens away, in a batch pipeline, and could not be tried on the field in
front of them.
THIS MODULE IS A BRIDGE AND NOT A SECOND IMPLEMENTATION. Every classical
method runs through :func:`spacr.object._segment_single_image`, the one the
organelle pipeline's workers call, and ``unet`` through
:func:`spacr.object._segment_unet`. So "adaptive" means in Make Masks
exactly what it means in a mask run, and a curator who tunes a block size
on one field is tuning the setting the pipeline will read. The whole of
this module's own work is naming the parameters, turning them into the
``organelle_*`` keys that engine reads, and saying what each method suits.
WHICH MORPHOLOGY A MODE RUNS UNDER. The organelle engine dispatches on
``organelle_morphology`` FIRST and the method second, because the same word
means different code for different shapes. Make Masks has no morphology
box -- a curator picks a detector, not a cell-biology category -- so each
mode names the morphology whose branch implements that method in the form
a curator of whole objects wants, and :data:`MODE_MORPHOLOGY` is that
choice written down:
* ``adaptive`` runs under ``irregular``: the branch that smooths, closes,
opens, fills holes and watershed-splits, which is what a solid object
wants. The ``spots`` branch's adaptive is a top-hat filter first and
erases anything wider than its disk.
* ``log`` and ``dog`` run under ``spots``, the only branch that has them.
* ``ridge``, ``hysteresis`` and ``unet`` run under ``network``, likewise.
:data:`spacr.organelle_types.LEGAL_METHODS` remains the statement of which
method is legal for which shape, and :func:`guidance` reads the sentence
each mode shows straight out of it rather than restating it here.
"""
from __future__ import annotations
from typing import Dict, NamedTuple, Optional, Tuple
import numpy as np
#: ``mode -> the organelle morphology whose branch runs it``. The module
#: docstring says why each is what it is.
MODE_MORPHOLOGY: Dict[str, str] = {
"adaptive": "irregular",
"log": "spots",
"dog": "spots",
"ridge": "network",
"hysteresis": "network",
"unet": "network",
}
#: ``mode -> the caption the Mode box shows``, in the order it offers them.
MODE_LABELS: Dict[str, str] = {
"adaptive": "Adaptive threshold",
"log": "LoG blobs",
"dog": "DoG blobs",
"ridge": "Ridge filter",
"hysteresis": "Hysteresis",
"unet": "U-Net",
}
#: The modes that are slow enough to say so before they run, and what to
#: say. ``unet`` loads a torch checkpoint and runs a network.
HEAVY_MODES: Dict[str, str] = {
"unet": "loads a torch model and runs a network",
}
[docs]
class MethodParams(NamedTuple):
"""Every parameter the organelle methods read, as one hashable value.
One tuple rather than one per method, because it goes into the
magnifier's request key: a request carries every setting a detector
could read, so a mode that falls back to another still finds its own
settings in it. The defaults are
:func:`spacr.settings._set_organelle_defaults`', so a method means the
same thing here as it does in a mask run until somebody moves a box.
:param adaptive_block: the local threshold's window, in pixels, forced
odd by the engine. Read by ``adaptive`` and by ``ridge`` when its
threshold is adaptive.
:param adaptive_offset: subtracted from the Gaussian-weighted local
mean before the bright-foreground comparison. Increasing it lowers
the threshold and admits more pixels before cleanup; a negative
offset raises the threshold. Units are those of the processed
detector image: smoothed image intensity for ``adaptive``, ridge
response for ``ridge`` with an adaptive threshold. Default 5.0;
an offset suitable for raw intensities can overwhelm a response
whose values lie between 0 and 1.
:param morph_radius: the cleanup disk, in pixels. ``adaptive`` also
pre-smooths with half of it; the network branches close with half.
:param fill_holes: holes up to this area, in square pixels, are filled.
``adaptive`` only.
:param watershed_spots: whether ``log`` and ``dog`` grow a watershed
from each blob centre rather than stamping a disk.
:param log_min_sigma: smallest Gaussian scale LoG searches, in pixels;
a blob's radius is about sigma times root two.
:param log_max_sigma: the largest.
:param log_num_sigma: how many scales between the two.
:param log_threshold: the blob-response cut-off, read by ``log`` AND by
``dog``, which has no threshold of its own.
:param dog_sigma_low: DoG's smallest scale, in pixels.
:param dog_sigma_high: DoG's largest.
:param ridge_filter: ``frangi``, ``sato`` or ``meijering``.
:param ridge_sigmas: the filament half-widths to look for, in pixels.
:param ridge_threshold: ``otsu`` or ``adaptive``, how the ridge
response is cut.
:param skeletonize: reduce a network to a one-pixel skeleton and label
that, so area measures length rather than thickness.
:param hysteresis_low: the weak level; under 1.0 it is read as a
fraction and becomes that percentile of the smoothed image.
:param hysteresis_high: the seeding level, read the same way.
:param unet_model_path: the ``.pt``/``.pth`` file to load.
:param unet_threshold: the probability the sigmoid output is cut at.
"""
adaptive_block: int = 51
adaptive_offset: float = 5.0
morph_radius: int = 3
fill_holes: int = 64
watershed_spots: bool = True
log_min_sigma: float = 1.0
log_max_sigma: float = 10.0
log_num_sigma: int = 10
log_threshold: float = 0.01
dog_sigma_low: float = 1.0
dog_sigma_high: float = 3.0
ridge_filter: str = "frangi"
ridge_sigmas: Tuple[float, ...] = (1.0, 2.0, 3.0)
ridge_threshold: str = "otsu"
skeletonize: bool = False
hysteresis_low: float = 0.2
hysteresis_high: float = 0.6
unet_model_path: str = ""
unet_threshold: float = 0.5
#: The defaults, as a value to compare a request against.
DEFAULT_PARAMS = MethodParams()
#: ``mode -> the fields of :class:`MethodParams` it actually reads``, in
#: the order a form should show them. A screen shows a mode's own
#: parameters and no others by asking here, so a control that is being
#: ignored is never on screen looking as though it is not.
PARAMETERS_FOR: Dict[str, Tuple[str, ...]] = {
"adaptive": ("adaptive_block", "adaptive_offset", "morph_radius",
"fill_holes"),
"log": ("log_min_sigma", "log_max_sigma", "log_num_sigma",
"log_threshold", "watershed_spots"),
"dog": ("dog_sigma_low", "dog_sigma_high", "log_threshold",
"watershed_spots"),
"ridge": ("ridge_filter", "ridge_sigmas", "ridge_threshold",
"adaptive_block", "adaptive_offset", "morph_radius",
"skeletonize"),
"hysteresis": ("hysteresis_low", "hysteresis_high", "morph_radius",
"skeletonize"),
"unet": ("unet_model_path", "unet_threshold", "skeletonize"),
}
[docs]
def modes() -> Tuple[str, ...]:
"""Every mode this module adds to Make Masks, in the box's order."""
return tuple(MODE_LABELS)
[docs]
def guidance(mode: str) -> str:
"""What this method suits, from :mod:`spacr.organelle_types`.
The sentence is built from
:data:`spacr.organelle_types.LEGAL_METHODS`, which is where spaCR
already records which method belongs to which shape, so a method that
gains or loses a shape there gains or loses it here too rather than
drifting into a second opinion.
:param mode: one of :func:`modes`, or any organelle method name.
:returns: an English sentence, not yet translated: a caller showing it
passes it through ``tr``.
"""
from ..organelle_types import method_guidance
return method_guidance(mode)
[docs]
def organelle_settings(mode: str, params: MethodParams,
min_area: int = 0) -> Dict[str, object]:
"""``params`` as the ``organelle_*`` keys the engine reads.
:param mode: one of :func:`modes`.
:param params: the parameters as the screen holds them.
:param min_area: the smallest object to keep, in pixels -- Make Masks'
own Min area, so one number governs the magnifier, the detect
buttons and the Remove-small button.
:returns: a dict for :func:`spacr.object._segment_single_image`.
:raises KeyError: for a mode this module does not add.
"""
morphology = MODE_MORPHOLOGY[mode]
return {
"organelle_morphology": morphology,
"organelle_method": mode,
"organelle_min_area": int(min_area),
"organelle_adaptive_block_size": int(params.adaptive_block),
"organelle_adaptive_offset": float(params.adaptive_offset),
"organelle_morph_radius": int(params.morph_radius),
"organelle_fill_holes": int(params.fill_holes),
"organelle_tophat_radius": 5,
"organelle_watershed_spots": bool(params.watershed_spots),
"organelle_log_min_sigma": float(params.log_min_sigma),
"organelle_log_max_sigma": float(params.log_max_sigma),
"organelle_log_num_sigma": int(params.log_num_sigma),
"organelle_log_threshold": float(params.log_threshold),
"organelle_dog_sigma_low": float(params.dog_sigma_low),
"organelle_dog_sigma_high": float(params.dog_sigma_high),
"organelle_ridge_filter": str(params.ridge_filter),
"organelle_ridge_sigmas": [float(s) for s in params.ridge_sigmas],
"organelle_network_threshold": str(params.ridge_threshold),
"organelle_skeletonize": bool(params.skeletonize),
"organelle_hysteresis_low": float(params.hysteresis_low),
"organelle_hysteresis_high": float(params.hysteresis_high),
"organelle_unet_model_path": str(params.unet_model_path) or None,
"organelle_unet_threshold": float(params.unet_threshold),
}
[docs]
def provenance(mode: str, params: MethodParams) -> Dict[str, object]:
"""The parameters this mode actually read, for a mask's ledger entry.
A mode's own parameters and no others, so an entry says what the
detection was told rather than carrying nineteen numbers eighteen of
which no branch looked at.
:param mode: one of :func:`modes`, or any other magnifier mode, which
reads nothing here and records nothing.
:param params: the parameters the detection ran with.
:returns: ``{field: value}``, JSON-safe; empty for a mode that is not
one of this module's.
"""
out: Dict[str, object] = {}
for field in PARAMETERS_FOR.get(mode, ()):
value = getattr(params, field)
out[field] = list(value) if isinstance(value, tuple) else value
return out
[docs]
def segment(image: np.ndarray, mode: str, params: MethodParams,
min_area: int = 0, model=None) -> np.ndarray:
"""Detect objects in ``image`` the way organelle detection would.
:param image: a 2-D field or region. Read, never modified.
:param mode: one of :func:`modes`.
:param params: the parameters the method reads.
:param min_area: the smallest object to keep, in pixels.
:param model: a loaded U-Net, for ``unet``; loaded from
:attr:`MethodParams.unet_model_path` when not given.
:returns: a 2-D label image the shape of ``image``.
:raises KeyError: for a mode this module does not add.
:raises ValueError: from the engine, for a parameter it refuses -- an
unknown ridge filter, a U-Net path that is not a file.
"""
from ..object import _load_unet_model, _segment_single_image, _segment_unet
settings = organelle_settings(mode, params, min_area)
field = np.asarray(image, dtype=np.float32)
if mode == "unet":
net = model if model is not None else _load_unet_model(settings)
masks = _segment_unet(field[None, ...], net, settings)
return np.asarray(masks[0])
return np.asarray(_segment_single_image(field, settings))