Source code for spacr.qt.widgets.channel_mapping
"""Three-field editor for ``png_channel_mapping``: which source channel is
red, which is green, which is blue.
Replaces a text box holding ``{'r': 2, 'g': 1, 'b': 0}``. The dict form is
still what the setting *is* -- this only stops the user having to type a
Python literal to say something as simple as "555 is my red".
Why three labelled fields rather than one ordered list: the list form
(``png_dims``) never said which colour it meant. Position 0 was blue because
of how cv2 interprets an array it is handed, which is not something the
settings panel could show and not something a user could infer. It got read
backwards for eleven days and every crop written in that window has its
nuclear stain in the red channel. A field labelled "R" cannot be read
backwards.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QSpinBox,
QWidget,
)
#: Highest source channel index the spin boxes offer. Well past any real
#: stack; the cost of a too-high ceiling is nothing, and the cost of a too-low
#: one is a user who cannot enter their own data.
MAX_SOURCE_CHANNEL = 31
#: The value that means "leave this colour empty". QSpinBox needs a real
#: number for its `specialValueText` slot, so the empty plane lives one below
#: the first legal channel rather than in a separate control.
_EMPTY = -1
#: Label and tooltip per colour slot, in file order.
_SLOTS = (
("r", "R", "Source channel shown as RED (conventionally 555 / 647)"),
("g", "G", "Source channel shown as GREEN (conventionally 488)"),
("b", "B", "Source channel shown as BLUE (conventionally 405 / DAPI)"),
)
[docs]
class ChannelMappingWidget(QWidget):
"""Editor for a ``{'r': int, 'g': int, 'b': int}`` channel mapping.
:param value: the mapping already saved. ``None`` opens with nothing
assigned, and a colour left empty STAYS empty -- the run must not put
a plane back into a slot the user cleared.
:param parent: parent widget.
"""
valueChanged = Signal(dict)
def __init__(self, value: Any = None, parent: Optional[QWidget] = None):
"""Build the row of colour-slot spin boxes.
The whole help lives on each slot's name rather than on the box beside
it: hovering the field the user is about to type in used to cover it
with a tooltip they had already read on the label.
The container paints nothing and registers no stylesheet of its own, so
there is no new rule to forget to add to the theme's module list -- the
children are styled by the existing spin-box and label rules. A theme
that cannot be reached leaves the field working on the window colour,
because decoration must never be load-bearing.
:param value: the mapping to start with.
:param parent: parent widget, or ``None``.
"""
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self._boxes: Dict[str, QSpinBox] = {}
for key, label_text, tip in _SLOTS:
label = QLabel(label_text, self)
label.setObjectName(f"ChannelMappingLabel{label_text}")
label.setToolTip(tip + ". “—” leaves this colour empty.")
layout.addWidget(label)
box = QSpinBox(self)
box.setObjectName(f"ChannelMappingSpin{label_text}")
box.setRange(_EMPTY, MAX_SOURCE_CHANNEL)
box.setSpecialValueText("—")
box.valueChanged.connect(self._emit)
layout.addWidget(box)
self._boxes[key] = box
layout.addStretch(1)
try:
from ..theme import make_transparent
make_transparent(self)
except Exception:
pass
self.set_value(value)
from ..screens.settings_model import retarget_field_tooltips
retarget_field_tooltips(self)
[docs]
def get_value(self) -> Dict[str, Optional[int]]:
"""Return the mapping, with ``None`` for any colour left empty."""
out: Dict[str, Optional[int]] = {}
for key, box in self._boxes.items():
raw = box.value()
out[key] = None if raw == _EMPTY else int(raw)
return out
[docs]
def set_value(self, value: Any) -> None:
"""Load a mapping dict, a legacy ``png_dims`` list, or nothing.
The list form is accepted because a settings CSV written by an older
build holds one, and the panel has to be able to show what that file
actually asked for. It is translated the same way the pipeline
translates it -- entry 0 is blue -- so the fields show the colours the
run will produce, not a rearrangement of them.
:param value: ``None`` (the default mapping), a colour-slot mapping, a
legacy ``png_dims`` list, or the string form of either; an
unparseable string falls back to the default mapping.
"""
mapping = self._coerce(value)
for key, box in self._boxes.items():
idx = mapping.get(key)
box.blockSignals(True)
box.setValue(_EMPTY if idx is None else int(idx))
box.blockSignals(False)
self._emit()
@staticmethod
def _coerce(value: Any) -> Dict[str, Optional[int]]:
"""Normalise a stored value into a colour-slot mapping.
A string is parsed as a Python literal, and anything that will not parse
falls back to the default mapping rather than raising -- a settings file
with a mangled value should open the module, not refuse to build it.
:param value: ``None``, a mapping, a ``png_dims`` sequence, or the
string form of either.
:returns: the mapping, one entry per colour slot.
"""
from ...crops import (
DEFAULT_PNG_CHANNEL_MAPPING,
png_dims_to_channel_mapping,
resolve_png_channel_mapping,
)
if value is None:
return dict(DEFAULT_PNG_CHANNEL_MAPPING)
if isinstance(value, str):
import ast
try:
value = ast.literal_eval(value)
except (ValueError, SyntaxError):
return dict(DEFAULT_PNG_CHANNEL_MAPPING)
try:
if isinstance(value, (list, tuple)):
return png_dims_to_channel_mapping(value)
if isinstance(value, dict):
return resolve_png_channel_mapping(
{"png_channel_mapping": value})
except Exception:
pass
return dict(DEFAULT_PNG_CHANNEL_MAPPING)
def _emit(self, *_args) -> None:
"""Announce the current mapping.
:param _args: whatever the emitting spin box passes; ignored.
"""
self.valueChanged.emit(self.get_value())