Source code for spacr.object_settings_table

"""Convert per-object settings between flat keys and a table with one row per question.

Per-object settings repeat questions for each object type --
``cell_diameter``, ``nucleus_diameter``, ``pathogen_diameter`` and
``organelle_diameter``, for example. The table puts each question in one
row and each object type in one column.

The organelle count is variable, up to
``spacr.organelle_types.MAX_ORGANELLES``. Each added slot repeats
the primary organelle's settings. In the table an additional organelle adds
a column while the question rows stay the same.

This module is only the model: it reads a flat settings dict into a table
and writes a table back out, losslessly. It draws nothing.
A GUI renders :func:`to_table` and calls :func:`from_table` on edit, and the
headless path never has to know the table exists -- the stored keys are
unchanged, so no settings file, notebook or tutorial migrates.

  Storing a nested ``{question: {object: value}}`` structure instead would
  be a file-format change, and every saved settings file, every published
  tutorial, and the headless ``spacr-run`` path would have to change with
  it. Only the presentation changes.
"""
from __future__ import annotations

import functools
import re
from collections import OrderedDict
from typing import Dict, Iterable, List, Mapping, Optional, Tuple

from .object_roles import organelle_index, organelle_label
from .organelle_types import (ALL_ORGANELLE_ROLES, _background_switch_key,
                              _background_switch_role,
                              _legacy_background_switch_role)

__all__ = [
    "OBJECT_ORDER",
    "column_label",
    "families",
    "from_table",
    "questions",
    "to_table",
]

#: The objects a question can be asked about, in the order a table shows
#: them. The four segmented kinds first, then the organelle slots, then
#: ``cytoplasm`` -- which is DERIVED (cell minus the rest) and so answers far
#: fewer of the questions, and putting it last keeps the sparse column out of
#: the middle of the table.
OBJECT_ORDER: Tuple[str, ...] = (
    ("cell", "nucleus", "pathogen") + tuple(ALL_ORGANELLE_ROLES) + ("cytoplasm",)
)

#: Longest object prefix first, so ``organelleb_min_area`` is split at
#: ``organelleb`` and not at ``organelle``. Getting this backwards silently
#: files every organelle slot under the first one.
_PREFIXES = tuple(sorted(OBJECT_ORDER, key=len, reverse=True))


#: Questions spelled with the object LAST (``remove_background_cell``).
#:
#: spaCR names the same relationship both ways round; a table that only saw
#: ``<object>_<question>`` would leave these rows in the flat form.
_OBJECT_LAST_QUESTIONS: Tuple[str, ...] = ("remove_background",)

#: Settings that are a per-object question for ONE object and carry no
#: object in their name: ``{key: (object, question)}``.
_SINGLE_OBJECT_KEYS: Dict[str, Tuple[str, str]] = {
    "adjust_cells": ("cell", "adjust_cells"),
}

#: The questions of :data:`_SINGLE_OBJECT_KEYS`, which the table keeps as a
#: row although only one object asks them.
_SINGLE_OBJECT_QUESTIONS = frozenset(
    question for _obj, question in _SINGLE_OBJECT_KEYS.values())


@functools.lru_cache(maxsize=8192)
def _split(key: str) -> Optional[Tuple[str, str]]:
    """``('cell', 'min_area')`` for ``'cell_min_area'``, else ``None``.

    Also ``('cell', 'remove_background')`` for ``'remove_background_cell'``
    and ``('cell', 'adjust_cells')`` for ``'adjust_cells'``. An organelle
    slot's switch is numbered -- ``remove_background_organelle_2`` is
    ``('organelleb', 'remove_background')`` -- and the lettered spelling it
    had before 2026-09-30 is still read (item 76).
    """
    if key in _SINGLE_OBJECT_KEYS:
        return _SINGLE_OBJECT_KEYS[key]
    slot = _background_switch_role(key) or _legacy_background_switch_role(key)
    if slot is not None:
        return slot, "remove_background"
    for question in _OBJECT_LAST_QUESTIONS:
        head = question + "_"
        if key.startswith(head) and key[len(head):] in OBJECT_ORDER:
            return key[len(head):], question
    for prefix in _PREFIXES:
        if key.startswith(prefix + "_"):
            return prefix, key[len(prefix) + 1:]
    return None


def _settings_key(obj: str, question: str) -> str:
    """The flat settings key one table cell stands for.

    The inverse of the split: ``('cell', 'min_area')`` is ``cell_min_area``,
    ``('cell', 'remove_background')`` is ``remove_background_cell`` and
    ``('cell', 'adjust_cells')`` is ``adjust_cells``. An organelle slot's
    switch is numbered: ``('organelleb', 'remove_background')`` is
    ``remove_background_organelle_2`` (item 76, 2026-09-30).

    :param obj: the object, a column of the table.
    :param question: the row.
    """
    for key, pair in _SINGLE_OBJECT_KEYS.items():
        if pair == (obj, question):
            return key
    if question == "remove_background" and obj in ALL_ORGANELLE_ROLES:
        return _background_switch_key(obj)
    if question in _OBJECT_LAST_QUESTIONS:
        return f"{question}_{obj}"
    return f"{obj}_{question}"


[docs] def questions(keys: Iterable[str]) -> "List[str]": """The distinct questions in ``keys``, in first-seen order. One entry per question, however many objects ask it: ``cell_diameter`` and ``nucleus_diameter`` are one question, ``diameter``. :param keys: flat settings names such as ``"cell_diameter"``, ``"remove_background_cell"``, ``"remove_background_organelle_2"`` and ``"adjust_cells"``. Unsupported names are skipped. """ seen: "OrderedDict[str, None]" = OrderedDict() for key in keys: split = _split(key) if split is not None: seen.setdefault(split[1], None) return list(seen)
[docs] def families(keys: Iterable[str]) -> "Dict[str, List[str]]": """``{question: [objects that ask it]}``, objects in display order. :param keys: flat settings names such as ``"cell_min_area"``, ``"remove_background_cell"``, ``"remove_background_organelle_2"`` and ``"adjust_cells"``. Unsupported names are skipped. """ found: "Dict[str, set]" = {} for key in keys: split = _split(key) if split is None: continue obj, question = split found.setdefault(question, set()).add(obj) order = {name: index for index, name in enumerate(OBJECT_ORDER)} return {question: sorted(objects, key=lambda o: order.get(o, len(order))) for question, objects in found.items()}
#: What a filter row's question starts with: ``filter:area`` is the row of #: ``object_filters`` bounds on the ``area`` regionprop, one cell per object. _FILTER_PREFIX = "filter:" #: Between a filter cell's minimum and maximum. _FILTER_DASH = "\u2013" def _filter_text(entry) -> Optional[str]: """One ``object_filters`` row as a cell: ``"200 – 5000"``, ``"200 –"``. :param entry: a ``{'property', 'min', 'max'}`` mapping. :returns: the text, or ``None`` when neither side is set. """ low, high = entry.get("min"), entry.get("max") def _num(value): """One bound as compact text; blank for an unset bound.""" if value is None or (isinstance(value, str) and not value.strip()): return "" number = float(value) return f"{number:g}" left, right = _num(low), _num(high) if not left and not right: return None return f"{left} {_FILTER_DASH} {right}".strip() def _parse_filter_text(text) -> Tuple[Optional[float], Optional[float]]: """A filter cell's ``(min, max)``; blank or ``off`` is ``(None, None)``. Accepts ``"200 – 5000"``, ``"200-5000"``, ``"200, 5000"``, ``"200 to 5000"``, ``"200"`` (a minimum alone) and ``"– 5000"`` (a maximum alone). :param text: what the cell holds or the user typed. :raises ValueError: when a side is not a number, or the minimum is above the maximum. """ raw = str(text if text is not None else "").strip() if not raw or raw.lower() in ("off", "none", "auto"): return None, None for separator in (_FILTER_DASH, "\u2014", ",", " to ", ".."): if separator in raw: left, _sep, right = raw.partition(separator) break else: match = re.match(r"^\s*(-?[\d.eE+]*)\s*-\s*(-?[\d.eE+]*)\s*$", raw) if match and (match.group(1) or match.group(2)) and raw[0] != "-": left, right = match.group(1), match.group(2) elif raw.startswith("-") and raw[1:].strip() and not re.match( r"^-\s*\d", raw): left, right = "", raw[1:] else: left, right = raw, "" def _side(value): """One side of the range as a number; ``None`` when left blank.""" value = value.strip() return float(value) if value else None low, high = _side(left), _side(right) if low is not None and high is not None and low > high: raise ValueError( f"The minimum {low:g} is above the maximum {high:g}, which " f"would remove every object.") return low, high
[docs] def column_label(obj: str) -> str: """Return the column header text for ``obj``. ``organelleb`` is a storage spelling, not a thing a user recognises. The letter suffixes exist because object types are embedded in underscore-separated object keys and ``organelle2`` is ambiguous with label 2 -- an implementation constraint that has no business appearing in a column header. :param obj: an object name from :data:`OBJECT_ORDER`, e.g. ``"cell"`` or ``"organelleb"``. Organelle slots get their numbered label; any other name has underscores turned into spaces and is capitalised. """ if obj.startswith("organelle"): return organelle_label(obj) return obj.replace("_", " ").capitalize()
[docs] def to_table(settings: Mapping[str, object]) -> "Dict[str, Dict[str, object]]": """Read a flat settings dict as ``{question: {object: value}}``. Only the keys that ARE per-object questions are taken. Everything else -- ``src``, ``verbose``, ``n_jobs`` -- is not a table row and is left where it is; a caller wanting the whole settings dict back uses :func:`from_table` with the original. A question a given object does not ask is ABSENT from its row rather than present as ``None``. ``cytoplasm`` has no channel, no diameter and no detection method because it is derived rather than found in a channel, and a blank cell says that where a ``None`` would read as "not set yet". :param settings: a flat settings dict, e.g. Mask's settings; it is only read. """ order = {name: index for index, name in enumerate(OBJECT_ORDER)} table: "Dict[str, Dict[str, object]]" = OrderedDict() for key, value in settings.items(): split = _split(key) if split is None: continue obj, question = split table.setdefault(question, {})[obj] = value for question, row in table.items(): table[question] = { obj: row[obj] for obj in sorted(row, key=lambda o: order.get(o, len(order))) } return table
[docs] def from_table(table: Mapping[str, Mapping[str, object]], base: Optional[Mapping[str, object]] = None) -> "Dict[str, object]": """Flatten a table back to settings keys, over ``base`` if given. The inverse of :func:`to_table`, without loss: the table is the settings file rearranged, not transformed, so it can be used as an editing surface. A test round-trips every default settings dict. :param table: ``{question: {object: value}}``. :param base: the settings the table came from. Keys the table does not cover are carried through unchanged, so a caller can edit the per-object half without holding the rest. """ out: "Dict[str, object]" = dict(base or {}) for question, row in table.items(): for obj, value in row.items(): out[_settings_key(obj, question)] = value return out
[docs] def widen(table: Mapping[str, Mapping[str, object]], obj: str, *, like: Optional[str] = None) -> "Dict[str, Dict[str, object]]": """Return a copy of ``table`` with a column for ``obj``, copying ``like``'s answers where it has them. Adding an organelle is one call that adds one column; in the flat vocabulary it is twenty new settings that every consumer, tooltip table and translation catalog has to include. :param obj: the object to add, e.g. ``'organellec'``. :param like: an existing object whose answers to copy as the starting point. Defaults to the first organelle when ``obj`` is one, because a second mitochondrion should start where the first one is rather than at a global default nobody chose. """ if like is None and obj.startswith("organelle"): like = "organelle" widened: "Dict[str, Dict[str, object]]" = OrderedDict() order = {name: index for index, name in enumerate(OBJECT_ORDER)} for question, row in table.items(): new_row = dict(row) if like is not None and like in row and obj not in row: new_row[obj] = row[like] widened[question] = { o: new_row[o] for o in sorted(new_row, key=lambda o: order.get(o, len(order))) } return widened
_AUTO_DIAMETER = "auto" """The stored form of "let Cellpose estimate the diameter" (item 696). 0 and "auto", in any case, are the same answer: both are stored as this word and both reach Cellpose as "estimate it". A blank diameter is a different answer, "use the magnification default", and the two never disagree because blank is never read as auto nor auto as blank. """ _DIAMETER_OBJECTS: Tuple[str, ...] = ("cell", "nucleus", "pathogen") """The objects whose diameter defaults from the magnification.""" _OBJECT_DIAMETER_KEYS = frozenset(f"{obj}_diameter" for obj in _DIAMETER_OBJECTS) """The settings that take a positive number, 0 or "auto".""" def _diameter_from_magnification(mag, obj: str) -> int: """The expected diameter of ``obj`` in pixels at magnification ``mag``. The formula spaCR has always used, kept here so the GUI can show it without importing :mod:`spacr.utils`; :func:`spacr.utils._get_diam` answers with this function. :param mag: the objective magnification. :param obj: ``'cell'``, ``'cell_large'``, ``'nucleus'`` or ``'pathogen'``. :returns: ``2*mag+80``, ``2*mag+120``, ``0.75*mag+45`` or ``mag``, as an int. :raises ValueError: for any other object type. """ mag = float(mag) if obj == 'cell': diameter = 2 * mag + 80 elif obj == 'cell_large': diameter = 2 * mag + 120 elif obj == 'nucleus': diameter = 0.75 * mag + 45 elif obj == 'pathogen': diameter = mag else: raise ValueError( f"_get_diam: unsupported object type '{obj}'. " f"Expected one of: cell, cell_large, nucleus, pathogen." ) return int(diameter) def _diameter_setting(raw): """A diameter setting in its one stored form. :param raw: what a field, a settings file or the command line holds. :returns: ``None`` for blank (the magnification default), ``"auto"`` for 0 or "auto" in any case, or the positive number (an int when it is whole). :raises ValueError: for a negative or non-finite number, a bool, or text that is neither a number nor "auto". """ if raw is None: return None if isinstance(raw, bool): raise ValueError(f"{raw!r} is not a diameter") if isinstance(raw, str): text = raw.strip() if not text or text.lower() == "none": return None if text.lower() == _AUTO_DIAMETER: return _AUTO_DIAMETER try: number = float(text) except ValueError: raise ValueError(f"{raw!r} is not a diameter") from None else: try: number = float(raw) except (TypeError, ValueError): raise ValueError(f"{raw!r} is not a diameter") from None if number != number or number in (float("inf"), float("-inf")): raise ValueError(f"{raw!r} is not a diameter") if number < 0: raise ValueError(f"{raw!r} is below 0") if number == 0: return _AUTO_DIAMETER return int(number) if number.is_integer() else number def _canonical_diameter(raw): """``raw`` in its stored form, or ``raw`` itself when it is not a diameter. The settings panel's reader for the diameter settings: it puts 0 and "Auto" into the one form, and leaves text it cannot read alone for validation to report rather than turning it into blank. :param raw: the value a field holds. """ try: return _diameter_setting(raw) except ValueError: return raw def _canonical_diameters(settings): """Put every object diameter in ``settings`` into its stored form, in place. :param settings: a settings dict. :returns: the same dict. """ for key in _OBJECT_DIAMETER_KEYS: if key in settings: settings[key] = _canonical_diameter(settings[key]) return settings def _magnification_default(magnification, obj: str): """The magnification default for ``obj``, or ``None`` when there is none. :param magnification: the objective magnification, or ``None``. :param obj: the object. """ if magnification is None or isinstance(magnification, bool): return None try: return float(_diameter_from_magnification(magnification, obj)) except (TypeError, ValueError): return None def _resolved_diameter(raw, magnification, obj: str): """The diameter a run segments ``obj`` with: one rule for run and preview. :param raw: the object's diameter setting. :param magnification: the run's magnification. :param obj: the object. :returns: ``"auto"``; the user's number as a float; or, for blank or unreadable text, the magnification default -- ``None`` (native scale) only when there is no magnification or the object has no formula. """ try: value = _diameter_setting(raw) except ValueError: value = None if value is None: return _magnification_default(magnification, obj) if value == _AUTO_DIAMETER: return _AUTO_DIAMETER return float(value) def _diameter_for_eval(resolved, *, estimates: bool = False): """What Cellpose's ``eval`` is handed for a resolved diameter. :param resolved: what :func:`_resolved_diameter` returned. :param estimates: whether the model estimates a size at 0 (a Cellpose 3 model); Cellpose-SAM has no size model and takes ``None``. :returns: a positive float, ``0.0`` or ``None``. """ if resolved == _AUTO_DIAMETER: return 0.0 if estimates else None if resolved is None: return None return float(resolved) def _diameter_used_text(raw, magnification, obj: str, estimate=None) -> str: """How the run journal says which diameter ``obj`` was segmented with. :param raw: the object's diameter setting. :param magnification: the run's magnification. :param obj: the object. :param estimate: Cellpose's own estimate in pixels, when it gave one. :returns: e.g. ``"120 px (magnification 20x default)"``, ``"60 px"``, ``"auto (Cellpose estimated 57.3 px)"`` or ``"auto"``. """ resolved = _resolved_diameter(raw, magnification, obj) if resolved == _AUTO_DIAMETER: if estimate is not None: return f"auto (Cellpose estimated {float(estimate):.4g} px)" return _AUTO_DIAMETER if resolved is None: return "native scale (no magnification default)" try: user = _diameter_setting(raw) except ValueError: user = None if user is None: return f"{resolved:g} px (magnification {float(magnification):g}x default)" return f"{resolved:g} px" def _diameter_used_by(settings, obj: str, estimate=None) -> str: """:func:`_diameter_used_text` for ``obj`` as ``settings`` set it. :param settings: the run settings. :param obj: the object. :param estimate: Cellpose's own estimate in pixels, when it gave one. """ return _diameter_used_text(settings.get(f"{obj}_diameter"), settings.get("magnification"), obj, estimate) def _default_diameter_of(settings, obj: str): """The magnification default a blank ``obj`` diameter segments at. :param settings: the run settings. :param obj: the object. :returns: the diameter in pixels, or ``None`` without a magnification. """ return _magnification_default(settings.get("magnification"), obj)