"""Which picture settings apply to which mode, and why the others do not.
THE KEYS ARE THE ANNOTATOR'S OWN, and that is the point of this module rather
than a second list. `spacr.settings.set_annotate_default_settings` already
names every one of them; a Cells tab with its own vocabulary for the same
picture would be two panels that disagree about what "normalize" means.
WHY A TABLE AND NOT A BRANCH IN THE WIDGET. The greying rule has to hold
wherever the settings are read -- a panel, a settings CSV, a macro -- and a
rule that lives only in the widget that greys it is a rule with one entry
point unguarded. That is what `ml._require_backend` says about backends and
what the volcano's adjusted axis needed at the API as well as in its menu.
"""
from __future__ import annotations
from typing import Dict, Optional, Tuple
from .crops import (DEFAULT_PERCENTILES, DEFAULT_PNG_CHANNEL_MAPPING,
LOAD_IMAGES,
LOAD_IMAGES_LABEL, STREAM_FROM_DB,
STREAM_FROM_DB_LABEL, STREAM_IMAGES,
STREAM_IMAGES_LABEL, STREAMING_SOURCES,
percentile_pair)
#: Settings that shape the picture AFTER it has been obtained, so they mean
#: the same thing whichever route produced it.
BOTH_MODES: Tuple[str, ...] = (
"crop_shape",
"crop_size",
"normalize_channels",
"percentiles",
"outline",
"outline_threshold_factor",
"outline_sigma",
"edge_thickness",
"edge_transparency",
"edge_image",
"object_size",
"channels",
"show_all_in_well",
"crop_source",
"half_widths",
"baseline",
"score_column",
"cap",
"cell_picking",
"picking_threshold",
)
#: Defaults for the keys that are this panel's own rather than the
#: annotator's. Everything else comes from `set_annotate_default_settings`.
OWN_DEFAULTS: Dict[str, object] = {
"show_all_in_well": True,
"object_type": "cell",
"crop_source": LOAD_IMAGES,
"object_array": "",
"red_channel": DEFAULT_PNG_CHANNEL_MAPPING.get("r", 2),
"green_channel": DEFAULT_PNG_CHANNEL_MAPPING.get("g", 1),
"blue_channel": DEFAULT_PNG_CHANNEL_MAPPING.get("b", 0),
"half_widths": 1.0,
"baseline": "screen_median",
"score_column": "pred",
"cap": 2000,
"cell_picking": "rank",
"picking_threshold": 0.55,
"crop_shape": "object",
"normalize_channels": "",
"outline": "",
"edge_image": False,
}
#: Settings that only mean something when the crops are read off disk.
LOAD_ONLY: Dict[str, str] = {
"image_type": (
"names which exported crop folder to read (cell_png, nucleus_png, "
"...), and nothing is being read off disk when the images are "
"streamed"),
}
#: Settings that only mean something for one way of picking cells.
PICKING_ONLY: Dict[str, Tuple[str, str]] = {
"picking_threshold": (
"attributed",
"is the probability a cell must reach to be called, and the other "
"pickers do not compute one"),
}
#: Settings that only mean something when the crops are cut on demand.
STREAM_ONLY: Dict[str, str] = {
"object_array": (
"names the labelled plane the object number is read from, and only "
"the array route reads one"),
"red_channel": (
"chooses the array plane drawn in red, and a crop already on disk "
"was made from a choice taken when it was written"),
"green_channel": (
"chooses the array plane drawn in green, and a crop already on "
"disk was made from a choice taken when it was written"),
"blue_channel": (
"chooses the array plane drawn in blue, and a crop already on "
"disk was made from a choice taken when it was written"),
}
#: Why a setting is silent in a mode that is not simply "the other mode".
#:
#: `crop_shape` applies in two of the three modes, so neither LOAD_ONLY nor
#: STREAM_ONLY can carry its reason, and a greyed control with no sentence
#: is the thing INVARIANTS 6 exists to prevent.
DERIVED_REASON: Dict[str, str] = {
"crop_shape": (
"chooses between an object-shaped cut and a bounding box, and the "
"database route locates by coordinates -- there is no outline in a "
"table to follow"),
}
#: Settings only the database route uses.
#:
#: It is the one route that has to be TOLD which object it is cutting: the
#: disk route is given it by the folder it reads, and the array route by
#: the labelled plane it reads the labels out of. Here the object type is
#: what names the coordinate columns, through
#: `spacr.stream_dataset.coordinate_column` -- which is also why the panel
#: does not ask for the columns as well.
DATABASE_ONLY: Dict[str, str] = {
"object_type": (
"names the object-table columns the database route reads its "
"coordinates from, and the other two routes are given the object "
"directly -- by the folder they read, or by the labelled plane"),
}
#: Every key this module has an opinion about, in the order a panel shows them.
ALL_KEYS: Tuple[str, ...] = (
("image_type",) + BOTH_MODES + tuple(DATABASE_ONLY) + tuple(STREAM_ONLY)
)
#: The tabs the picture-settings window is divided into, and which keys sit
#: on each: ``(title, keys)``, in the order they are shown.
#:
#: ONE LONG FORM IS NOT A PANEL. Twenty-eight controls in a single column ask
#: the reader to scroll past every question they are not asking to reach the
#: one they are, and the module screens already answer that with categories
#: (`spacr.qt.screens.settings_model._APP_CATEGORY_SPECS`) -- the same shape
#: is used here so the two panels are read the same way.
#:
#: THE GROUPING IS BY THE QUESTION EACH SETTING ANSWERS, not by which mode
#: uses it: a tab that appeared and vanished with the crop source would hide
#: the very controls whose greyed reason explains the mode, and greyed-never-
#: hidden is this panel's rule.
CATEGORY_SPEC: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
("Source", ("crop_source", "image_type", "object_type", "object_array",
"crop_shape")),
("Channels", ("channels", "red_channel", "green_channel",
"blue_channel")),
("Picture", ("crop_size", "object_size", "normalize_channels",
"percentiles")),
("Outline", ("outline", "outline_threshold_factor", "outline_sigma",
"edge_thickness", "edge_transparency", "edge_image")),
("Which cells", ("cell_picking", "picking_threshold", "show_all_in_well",
"score_column", "baseline", "half_widths", "cap")),
)
#: Where a key lands when :data:`CATEGORY_SPEC` has not been told about it.
#:
#: A SETTING ADDED LATER MUST NOT VANISH. A panel built strictly from the
#: table would silently drop a key that reached `ALL_KEYS` without reaching
#: here, and a control that is not on any tab is a control the user cannot
#: reach -- the same failure as hiding one, arrived at by omission.
UNGROUPED_TITLE = "Other"
[docs]
def categories() -> Tuple[Tuple[str, Tuple[str, ...]], ...]:
"""``(title, keys)`` for each tab, covering every key in :data:`ALL_KEYS`.
Keys that :data:`CATEGORY_SPEC` names but :data:`ALL_KEYS` does not are
dropped -- a retired setting must not leave an empty row behind -- and
keys `ALL_KEYS` has that the spec does not are gathered onto a trailing
:data:`UNGROUPED_TITLE` tab, which exists only when it has something on
it.
"""
known = set(ALL_KEYS)
placed = []
out = []
for title, keys in CATEGORY_SPEC:
kept = tuple(key for key in keys if key in known and key not in placed)
placed.extend(kept)
if kept:
out.append((title, kept))
left = tuple(key for key in ALL_KEYS if key not in placed)
if left:
out.append((UNGROUPED_TITLE, left))
return tuple(out)
[docs]
def category_of(key: str) -> str:
"""The tab ``key`` is shown on, or ``""`` when it is not offered here.
:param key: picture-setting key to locate in the category specification.
"""
name = str(key or "").strip()
for title, keys in categories():
if name in keys:
return title
return ""
[docs]
def modes() -> Tuple[Tuple[str, str], ...]:
"""``(value, label)`` for the three modes, default first.
Two of them stream from ``merged/*.npy`` and differ only in how they
find the object -- by its label in a mask plane, or by its row in the
measurement database. That difference decides whether the cut can
follow an outline, so it is a choice the user makes here rather than
something inferred from which other settings happen to be filled.
"""
return ((LOAD_IMAGES, LOAD_IMAGES_LABEL),
(STREAM_IMAGES, STREAM_IMAGES_LABEL),
(STREAM_FROM_DB, STREAM_FROM_DB_LABEL))
[docs]
def applies_to_picking(key: str, picking: str) -> bool:
"""Whether ``key`` means anything for the chosen way of picking cells.
:param key: picture-setting key whose dependency is being checked.
:param picking: selected cell-picking strategy.
"""
entry = PICKING_ONLY.get(str(key or "").strip())
if entry is None:
return True
return str(picking or "rank").strip().lower() == entry[0]
[docs]
def applies_to(key: str, mode: str) -> bool:
"""Whether ``key`` means anything in ``mode``.
:param key: picture-setting key whose source-mode applicability is tested.
:param mode: selected crop-source mode.
A key this module has never heard of applies: it is not this module's job
to grey out a setting it does not know, and a panel that hid the unknown
would hide new settings by default.
"""
name = str(key or "").strip()
chosen = str(mode or LOAD_IMAGES).strip().lower()
streaming = chosen in STREAMING_SOURCES
if name in LOAD_ONLY:
return not streaming
if name == "object_array":
return chosen == STREAM_IMAGES
if name in DATABASE_ONLY:
return chosen == STREAM_FROM_DB
if name == "crop_shape":
return chosen != STREAM_FROM_DB
if name in STREAM_ONLY:
return streaming
return True
[docs]
def why_not(key: str, mode: str) -> str:
"""The sentence a greyed control carries, or ``""`` when it applies.
:param key: picture-setting key whose inapplicability is explained.
:param mode: selected crop-source mode.
GREYED, NEVER HIDDEN (INVARIANTS 6). A control that vanishes cannot tell
the user why their mode does not offer it.
"""
if applies_to(key, mode):
return ""
name = str(key or "").strip()
chosen = str(mode or LOAD_IMAGES).strip().lower()
label = LOAD_IMAGES_LABEL if chosen != STREAM_IMAGES else STREAM_IMAGES_LABEL
reason = (LOAD_ONLY.get(name) or STREAM_ONLY.get(name)
or DATABASE_ONLY.get(name) or DERIVED_REASON.get(name) or "")
return f"not used by '{label}': it {reason}" if reason else (
f"not used by '{label}'")
[docs]
def greyed_in(mode: str) -> Tuple[str, ...]:
"""The keys a panel must grey for ``mode``, in a stable order.
:param mode: selected crop-source mode.
"""
return tuple(key for key in ALL_KEYS if not applies_to(key, mode))
[docs]
def bounding_box_only(settings) -> bool:
"""Whether the chosen cut can only be a bounding box.
:param settings: picture settings containing the selected crop source.
Coordinate-only sources have no object outline, so a panel should disable
object-shaped crops before the cut rather than silently return a rectangle.
"""
try:
chosen = str(settings.get("crop_source") or LOAD_IMAGES).lower()
except AttributeError:
return False
return chosen == STREAM_FROM_DB
#: The annotator's name for a setting -> the crop layer's name for it.
#: ONLY the settings that change how the crop is CUT. The rest of the
#: annotator's controls -- outline, edge_*, normalize_channels, percentiles,
#: object_size -- change how an obtained crop is DRAWN, and belong to the
#: renderer rather than the crop spec. They are not in this table because a
#: mapping that pretended to apply them would be worse than an absent one.
CUT_SETTINGS: Dict[str, str] = {
"crop_size": "png_size",
"channels": "png_dims",
}
def _as_channel_mapping(value, picture) -> Optional[Dict[str, object]]:
"""Colour letters as an explicit ``{r, g, b}`` source-channel mapping.
``None`` means "these are not colour letters" -- an index list, most
often -- and the caller falls through to the index path.
:param value: what the user chose, e.g. ``"r,g,b"`` or ``["r", "b"]``.
:param picture: the whole settings dict, consulted for this screen's own
``png_channel_mapping`` before the default is used.
"""
from .crops import DEFAULT_PNG_CHANNEL_MAPPING, PNG_COLOR_KEYS
if isinstance(value, str):
parts = [p.strip().lower() for p in value.replace(";", ",").split(",")]
elif isinstance(value, (list, tuple)):
parts = [str(p).strip().lower() for p in value]
else:
return None
parts = [p for p in parts if p]
if not parts or not all(p in PNG_COLOR_KEYS for p in parts):
return None
known = (picture or {}).get("png_channel_mapping")
base = dict(known) if isinstance(known, dict) else dict(
DEFAULT_PNG_CHANNEL_MAPPING)
return {key: (base.get(key) if key in parts else None)
for key in PNG_COLOR_KEYS}
def _as_indices(value) -> Optional[list]:
"""``value`` as a list of source-channel indices, or ``None``.
``None`` means "these are not indices" -- colour letters, most often --
and the caller leaves the setting to the renderer rather than guessing a
number for it. See :func:`to_crop_settings`.
"""
if isinstance(value, str):
parts = [p.strip() for p in value.replace(";", ",").split(",")]
elif isinstance(value, (list, tuple)):
parts = [str(p).strip() for p in value]
elif isinstance(value, int) and not isinstance(value, bool):
return [int(value)]
else:
return None
parts = [p for p in parts if p]
if not parts:
return None
out = []
for part in parts:
try:
out.append(int(part))
except (TypeError, ValueError):
return None
return out
[docs]
def to_crop_settings(picture) -> Dict[str, object]:
"""The subset of a picture-settings dict the crop layer understands.
:param picture: the annotator-named settings, as
:class:`spacr.qt.widgets.picture_settings_dialog.PictureSettingsDialog`
returns them.
:returns: a mapping in the crop layer's own vocabulary, carrying only what
this mode actually uses and only what is set.
"""
try:
items = dict(picture or {})
except (TypeError, ValueError):
return {}
mode = str(items.get("crop_source") or LOAD_IMAGES).strip().lower()
out: Dict[str, object] = {}
for mine, theirs in CUT_SETTINGS.items():
if not applies_to(mine, mode):
continue
value = items.get(mine)
if value in (None, "", [], ()):
continue
if theirs == "png_dims":
mapping = _as_channel_mapping(value, items)
if mapping is not None:
out["png_channel_mapping"] = mapping
continue
indices = _as_indices(value)
if indices is None:
continue
value = indices
if theirs == "png_size" and isinstance(value, (int, float)) \
and not isinstance(value, bool):
value = [int(value), int(value)]
out[theirs] = value
if applies_to("crop_shape", mode):
shape = str(items.get("crop_shape") or "").strip().lower()
if shape in ("bbox", "bounding_box", "box"):
out["use_bounding_box"] = True
elif shape == "object":
out["use_bounding_box"] = False
if mode in STREAMING_SOURCES:
if mode == STREAM_FROM_DB:
object_type = str(items.get("object_type") or "").strip().lower()
if object_type:
out["object_array"] = object_type
chosen = {}
for colour, key in (("r", "red_channel"), ("g", "green_channel"),
("b", "blue_channel")):
raw = items.get(key, "")
text = str(raw).strip()
if text in ("", "None", "-1"):
chosen[colour] = None
continue
try:
chosen[colour] = int(float(text))
except (TypeError, ValueError):
chosen[colour] = None
if any(v is not None for v in chosen.values()):
out["png_channel_mapping"] = chosen
if mode == STREAM_FROM_DB:
out["stream_method"] = "column"
else:
out["stream_method"] = "array"
plane = str(items.get("object_array", "")).strip()
if plane not in ("", "None"):
out["object_array"] = plane
if bounding_box_only(items):
out["use_bounding_box"] = True
return out
#: Settings that change how an obtained crop is DRAWN rather than how it is
#: cut. They are applied here, by the annotator's own functions, so a crop in
#: the Cells tab and the same crop in the annotation app look the same.
DRAW_SETTINGS: Tuple[str, ...] = (
"normalize_channels", "percentiles", "channels", "outline",
"outline_threshold_factor", "outline_sigma", "edge_thickness",
"edge_transparency", "edge_image", "object_size",
)
#: A displayed image's channel position -> the colour the annotator's own
#: helpers name it by. `normalize_pil` and `filter_channels_pil` both key on
#: 'r'/'g'/'b' and silently skip anything else, so a user who typed 0,1,2 --
#: which is what every other channel setting in spaCR takes -- got a control
#: that accepted their input and did nothing.
#:
#: NOT the source-channel mapping. These are positions in the RGB picture
#: being drawn, which is why 0 is red here while `png_channel_mapping` may
#: put source channel 2 in red.
_POSITION_TO_COLOUR = {"0": "r", "1": "g", "2": "b"}
def _as_channel_list(value):
"""``['r','g']`` from whatever a settings field holds.
Accepts the annotator's letters AND the index form the rest of spaCR
uses, because a setting that quietly ignores half the spellings offered
to it is worse than one that refuses them.
"""
if value is None or value is False:
return None
if isinstance(value, str):
text = value.strip()
if not text:
return None
if text.startswith("[") and text.endswith("]"):
text = text[1:-1]
parts = [p.strip().strip("'\"").lower() for p in text.split(",")
if p.strip().strip("'\"")]
else:
try:
parts = [str(v).strip().lower() for v in value if str(v).strip()]
except TypeError:
return None
return [_POSITION_TO_COLOUR.get(p, p) for p in parts] or None
def _as_pair(value, default=DEFAULT_PERCENTILES):
"""Resolve one percentile setting through the shared pair parser.
:param value: Percentile pair in any form accepted by
:func:`percentile_pair`.
:param default: Pair returned when ``value`` does not provide one.
:returns: Normalized low/high percentile pair.
"""
return percentile_pair(value, default)
[docs]
def draw_crop(array, picture):
"""``array`` drawn as the annotation application would draw it.
:param array: an ``(H, W, 3)`` uint8 crop.
:param picture: the annotator-named settings.
:returns: an array of the same shape, or the input unchanged when nothing
was asked for or the pipeline is unavailable.
THE ANNOTATOR'S OWN FUNCTIONS DO THE WORK -- `normalize_pil`,
`filter_channels_pil`, `outline_image` from
:mod:`spacr.qt.annotate_engine`. A second implementation of "normalise a
crop" is a second answer to what normalise means. Reusing the annotator's
functions keeps identically configured images visually consistent.
NEVER RAISES. A picture is the last thing this produces and the least
important: losing a montage to an outline is the worst trade available.
"""
items = dict(picture or {})
if not any(items.get(key) for key in DRAW_SETTINGS):
return array
try:
import numpy as np
from PIL import Image
from .qt.annotate_engine import (filter_channels_pil, normalize_pil,
outline_image)
except Exception: # noqa: BLE001
return array
try:
data = np.ascontiguousarray(np.asarray(array, dtype="uint8"))
if data.ndim == 2:
data = np.repeat(data[:, :, None], 3, axis=2)
image = Image.fromarray(data[:, :, :3])
full = image.copy()
normalise = _as_channel_list(items.get("normalize_channels"))
if normalise:
image = normalize_pil(image, _as_pair(items.get("percentiles")),
normalise)
outline = _as_channel_list(items.get("outline"))
if outline:
size = items.get("object_size") or 0
try:
bounds = (int(size), int(size)) if not isinstance(
size, (list, tuple)) else (int(size[0]), int(size[1]))
except (TypeError, ValueError):
bounds = (0, 0)
image = outline_image(
image, full, outline_channels=outline,
edge_sigma=float(items.get("outline_sigma") or 1.0),
edge_thickness=float(items.get("edge_thickness") or 1.0),
edge_transparency=float(items.get("edge_transparency") or 100.0),
edge_image=bool(items.get("edge_image")),
outline_threshold_factor=float(
items.get("outline_threshold_factor") or 1.0),
object_size=bounds)
shown = _as_channel_list(items.get("channels"))
if shown and all(c in ("r", "g", "b") for c in shown):
image = filter_channels_pil(image, shown)
return np.asarray(image.convert("RGB"), dtype="uint8")
except Exception: # noqa: BLE001
return array
[docs]
def available_arrays(source) -> Tuple[str, ...]:
"""The mask planes this screen's merged arrays actually record.
`object_array` chooses which mask the intensity channels are cut by, and
offering it as free text asks the user to remember what their own screen
contains -- and to spell it the way `measure` did. Every other chooser in
spaCR is built from the data; this one was not.
:param source: a :class:`spacr.crops.CropSource`, a
:class:`CropSourceChoice`, or anything with a ``spec.mask_dims``.
:returns: the plane names, in a stable order. Empty when the screen
records none -- which is the answer, not a failure: a run whose
merged arrays carry no mask planes cannot cut by one.
"""
spec = getattr(getattr(source, "source", source), "spec", None)
dims = dict(getattr(spec, "mask_dims", None) or {})
return tuple(sorted(str(name) for name in dims))
[docs]
def available_coordinate_columns(frame) -> Tuple[str, ...]:
"""The object-table columns a bounding box could be cut from.
:param frame: measurement table whose coordinate columns are inspected.
All four corners or none: three of them describe no box, so a chooser
that offered them singly would let a user assemble a request that cannot
be met.
"""
names = getattr(frame, "columns", None)
if names is None:
return ()
columns = {str(c) for c in names}
if not columns:
return ()
out = []
for spelling in (("bbox-0", "bbox-1", "bbox-2", "bbox-3"),
("bbox_0", "bbox_1", "bbox_2", "bbox_3"),
("min_row", "min_col", "max_row", "max_col")):
if set(spelling) <= columns:
out.append(", ".join(spelling))
return tuple(out)
#: Explain how each annotation method selects cells: its inputs,
#: calculation, inclusion and exclusion rules, and principal limitation.
#:
#: The dropdown, documentation, and contextual help share this mapping so the
#: selection contract remains consistent across user-facing surfaces.
PICKING_HELP: Dict[str, str] = {
"rank": (
"TOP BY SCORE.\n"
"Given: the guide's fraction in the well, and each cell's "
"classification score.\n"
"Computes: n = round(cells x fraction), then sorts by score -- "
"descending for a positive coefficient, ascending for a negative "
"one, because an inhibitory guide's cells are the LEAST consistent "
"ones.\n"
"Annotated: the top n. Not annotated: everyone below the cut, and "
"any cell with no score at all.\n"
"Wrong when: the fraction is wrong. It sets HOW MANY cells are "
"taken, so a share inflated by normalisation reaches down into "
"cells the score does not support. No probability is computed and "
"none is reported."),
"attributed": (
"ATTRIBUTED.\n"
"Given: every guide's fraction in the well and its regression "
"effect, plus each cell's score.\n"
"Computes: a posterior per cell per guide -- a likelihood from the "
"score against each guide's expected effect, a prior from the "
"fractions -- then iterative proportional fitting until every cell "
"sums to 1 AND every guide holds the number of cells its reads "
"imply.\n"
"Annotated: the argmax, if it clears 0.55. Not annotated: anything "
"below that, marked ambiguous, carrying the highest probability any "
"guide reached.\n"
"Wrong when: the effects are wrong, or a guide's effect is too "
"small against the spread of scores to reach the threshold in any "
"well -- then it selects nothing and the montage is empty."),
"assigned": (
"ASSIGNED.\n"
"Given: the same as attributed.\n"
"Computes: slots per guide = round(N x fraction), adjusted by "
"largest remainders to sum to N exactly, then a Hungarian "
"assignment minimising total cost.\n"
"Annotated: EVERY cell -- each gets exactly one guide by "
"construction. Not annotated: none.\n"
"Wrong when: you need to know which cells are uncertain. It cannot "
"abstain, so a well of pure noise is partitioned as confidently as "
"a well of clear signal."),
"multivariate": (
"MULTIVARIATE.\n"
"Given: attributed's inputs, plus one effect per MEASUREMENT per "
"guide from the gene x measurement sweep.\n"
"Computes: the same posterior over a vector of measurements rather "
"than one score.\n"
"Annotated: cells clearing 0.55. Not annotated: the rest.\n"
"Wrong when: there is no sweep -- it falls back to attributed and "
"SAYS so rather than substituting silently -- or when the swept "
"measurements are correlated, which makes the effective dimension "
"smaller than the count of columns suggests."),
"sudoku": (
"SUDOKU.\n"
"Given: the cells' measurements across every well the guide "
"appears in, plus the fractions.\n"
"Computes: anchors from wells where a guide dominates, a "
"nearest-neighbour graph over cells, label propagation from those "
"anchors, then the same per-well constraint as attributed.\n"
"Annotated: cells whose constrained posterior clears the decision "
"bar. Not annotated: cells far from every anchor, and cells whose "
"top two guides are too close to call.\n"
"Wrong when: the anchors are wrong. They are chosen BY SCORE, so "
"the score is deliberately left out of the graph -- with it in, "
"every high-scoring cell would sit beside every guide's anchors "
"and affirm all of them."),
}
[docs]
def offered_values(key: str, source=None, frame=None) -> Tuple[str, ...]:
"""What a chooser for ``key`` should list, or ``()`` for free text.
:param key: picture-setting key whose current choices are requested.
ONE PLACE, so the Cells tab and the annotation app cannot offer different
answers for the same screen.
"""
name = str(key or "").strip()
if name == "object_array":
return available_arrays(source)
if name == "crop_shape":
return ("object", "bbox")
if name == "crop_source":
return modes()
if name == "channels":
return (
("r,g,b", "all three"),
("r", "red only"),
("g", "green only"),
("b", "blue only"),
("r,g", "red and green"),
("r,b", "red and blue"),
("g,b", "green and blue"),
)
if name in ("normalize_channels", "outline"):
what = ("normalised" if name == "normalize_channels" else "outlined")
return (
("", f"none — nothing is {what}"),
("r", f"red only"),
("g", f"green only"),
("b", f"blue only"),
("r,g", "red and green"),
("r,b", "red and blue"),
("g,b", "green and blue"),
("r,g,b", f"every channel"),
)
if name == "object_type":
return ("cell", "nucleus", "pathogen", "cytoplasm",
"organelle", "organelleb", "organellec", "organelled")
if name == "baseline":
return ("screen_median", "control_median", "zero")
if name == "cell_picking":
return (
("rank", "top by score — the count the fraction implies"),
("attributed", "attributed — each cell's probability, above the "
"threshold"),
("assigned", "assigned — every cell in the well gets one guide"),
("multivariate", "multivariate — every measurement, not just the "
"score (needs a sweep)"),
("sudoku", "sudoku — learns each guide's look from every well "
"it is in, then decides this one"),
)
return ()
#: How many crops fit on one page of a well tab, measured across the viewport
#: sizes the Cells tab is really used at.
#:
#: The page is the container's, not a setting: the column count is the user's
#: preference and the ROWS are whatever the viewport has room for. At six
#: columns and 96 px thumbnails that is 24 crops in the narrowest panel the
#: splitter allows (780x420), 36 in a comfortable one (780x700 and 1200x700
#: both), and 60 on a full 1920x1080 screen.
MEASURED_PAGE_SIZES: Tuple[int, int, int] = (24, 36, 60)
#: Bytes one crop occupies while its tab is open.
#:
#: THE TAB HOLDS EVERY CROP, not one page of them: the page decides how many
#: thumbnails are drawn, and the arrays behind them all stay so that turning
#: a page does not re-cut. 224x224x3 uint8 is the crop size the merged route
#: cuts by default.
MEASURED_CROP_BYTES = 224 * 224 * 3
#: Milliseconds to CUT one crop, by route: ``(best, worst)``.
#:
#: The merged route is priced by how many FIELDS a montage touches rather
#: than by how many crops it cuts, which is why its two numbers are four-fold
#: apart: 2.58 ms/crop over 6 fields against 11.43 ms/crop over 30. The
#: exported-PNG route is ~10x cheaper and flat in both.
MEASURED_MS_PER_CROP: Dict[str, Tuple[float, float]] = {
"png": (0.49, 0.64),
"merged": (2.58, 11.43),
}
[docs]
def montage_cap_cost(cap) -> str:
"""Summarize the estimated cost of a montage containing ``cap`` objects.
Estimates use the measured page capacities, crop memory, and per-crop
timings in :data:`MEASURED_PAGE_SIZES`, :data:`MEASURED_CROP_BYTES`, and
:data:`MEASURED_MS_PER_CROP`.
:param cap: Maximum number of objects in one montage.
:returns: Estimated page count, memory use, and crop-extraction time, or
``""`` when ``cap`` is not a positive integer.
"""
try:
count = int(cap)
except (TypeError, ValueError):
return ""
if count <= 0:
return ""
widest, typical, narrowest = (max(MEASURED_PAGE_SIZES),
MEASURED_PAGE_SIZES[1],
min(MEASURED_PAGE_SIZES))
fewest = -(-count // widest)
most = -(-count // narrowest)
memory = count * MEASURED_CROP_BYTES / float(1 << 20)
quick = count * MEASURED_MS_PER_CROP["png"][0] / 1000.0
slow = count * MEASURED_MS_PER_CROP["merged"][1] / 1000.0
pages = (f"{fewest} pages" if fewest == most
else f"{fewest}-{most} pages")
return (f"{count:,} objects is {pages} "
f"({typical} to a page on a typical panel), about "
f"{memory:,.0f} MB of crops held while the tab is open, and "
f"{_seconds(quick)}-{_seconds(slow)} s to cut -- the low end "
f"reading exported PNGs, the high end cutting from merged arrays "
f"across many fields.")
def _seconds(value: float) -> str:
"""A duration rounded to something a reader can act on.
Under ten seconds a whole number rounds a real wait down to zero, which
reads as free; over it the decimal is noise.
"""
return f"{value:,.1f}" if value < 10 else f"{value:,.0f}"
#: Removed picture settings and the migration note reported for each one.
RETIRED: dict = {
"cells_per_page": (
"removed: the page size is now a consequence of the container size "
"and the image size, so a configured count could only contradict "
"the geometry -- producing a half-empty page or a clipped row, with "
"no way for the user to tell which"),
}
[docs]
def drop_retired(picture) -> tuple:
"""``(settings, [note])`` with the retired keys taken out.
:param picture: saved picture-settings mapping to migrate.
Called wherever a saved picture-settings blob is read. The notes are
returned rather than printed, so the caller decides whether this is
worth a line -- it is worth one the first time and noise every time
after.
A RENAMED key is moved rather than dropped: ``img_size`` became
``crop_size`` on 2026-09-19, and a saved run that chose a size keeps it.
The rename is read from :func:`spacr.settings.surviving_setting_name`,
the resolver the settings files use, and only onto a key this panel
offers. The new name wins when a blob carries both.
"""
out = dict(picture or {})
notes = []
for key, why in RETIRED.items():
if key in out:
out.pop(key)
notes.append(f"{key}: {why}")
offered = set(ALL_KEYS)
try:
from .settings import surviving_setting_name
except Exception: # noqa: BLE001
return out, notes
for key in [k for k in out if isinstance(k, str) and k not in offered]:
try:
survivors = surviving_setting_name(key)
except Exception: # noqa: BLE001
survivors = ()
if len(survivors) != 1 or survivors[0] not in offered:
continue
new = survivors[0]
value = out.pop(key)
if new in out:
notes.append(f"{key}: renamed to {new}, which this blob also "
f"sets, so {key}={value!r} was not used")
continue
out[new] = value
notes.append(f"{key}: renamed to {new}, and {value!r} moved across")
return out, notes