diff --git a/src/morphobricks_preprocessing/channel_answers.py b/src/morphobricks_preprocessing/channel_answers.py
index 4409fdcb..c9257094 100644
--- a/src/morphobricks_preprocessing/channel_answers.py
+++ b/src/morphobricks_preprocessing/channel_answers.py
@@ -27,6 +27,25 @@ def label_ids(answers: DraftAnswers) -> list[str]:
return sorted(k for k in answers.channels if k not in RGB_IDS)
+def all_colour(layouts: Sequence[UnitLayout], units: Mapping[UnitKey, UnitAnswer]) -> bool:
+ """Return True when the user declared every unit colour (``rgb``): no label is needed then.
+
+ Only a saved answer counts (experiment-v1 section 4.4b): the header's pre-tick, a missing
+ answer or a folder that cannot be read keeps this False, so the labels stay required.
+ """
+ return bool(layouts) and all(_declared_colour(lay, units.get(lay.key)) for lay in layouts)
+
+
+def _declared_colour(layout: UnitLayout, answer: UnitAnswer | None) -> bool:
+ """Return True for a readable unit with a saved ``rgb`` answer that still fits its image."""
+ return (
+ layout.usable
+ and answer is not None
+ and answer.image_type == "rgb"
+ and layout.channels == len(RGB_IDS) == len(answer.channels) # a stale answer never counts
+ )
+
+
def _unit(answers: DraftAnswers, layout: UnitLayout) -> UnitAnswer:
saved = answers.units.get(layout.key)
if saved is not None and len(saved.channels) == layout.channels:
@@ -124,4 +143,12 @@ def synced(answers: DraftAnswers, layouts: Sequence[UnitLayout]) -> DraftAnswers
return replace(answers, channels=channels)
-__all__ = ["label_ids", "synced", "untagged_by", "with_labels", "with_tags", "with_type"]
+__all__ = [
+ "all_colour",
+ "label_ids",
+ "synced",
+ "untagged_by",
+ "with_labels",
+ "with_tags",
+ "with_type",
+]
diff --git a/src/morphobricks_preprocessing/notebook_channels.py b/src/morphobricks_preprocessing/notebook_channels.py
index fdb8b2a6..bb3b7531 100644
--- a/src/morphobricks_preprocessing/notebook_channels.py
+++ b/src/morphobricks_preprocessing/notebook_channels.py
@@ -18,6 +18,7 @@
from . import notebook_style as ui
from .axis_review import review_axes
+from .channel_answers import all_colour
from .channel_labels import UnitLayout
from .channel_units import header_names, slot_count, unit_layouts
from .draft_answers import DraftAnswers
@@ -70,6 +71,7 @@ def _build_channels_step(self) -> None:
self.channel_types = ImageTypeColumn(store, self._types_changed)
self.channel_grid = TagGrid(store, self.channel_types, self._channels_changed)
self.channel_labels = LabelsCard(store, self._channel_labels_changed)
+ self.channel_intro = ui.intro(INTRO) # hidden while the stains are not applicable
self.channels_waiting = widgets.HTML()
self.channels_box = widgets.VBox(
[self.channels_waiting, self.channel_labels.widget, self.channel_grid.widget]
@@ -82,7 +84,7 @@ def channels_step(self, title: str | None = None, purpose: str | None = None) ->
"Name what you stained, then tag every channel of every unit."
if purpose is None
else purpose,
- [ui.intro(INTRO), self.channels_box],
+ [self.channel_intro, self.channels_box],
)
# Opening and closing -----------------------------------------------------------------
@@ -92,6 +94,7 @@ def _hide_channels(self, why: str = "") -> None:
self._channel_thumbs = None
self.channel_labels.close()
self.channel_grid.close()
+ ui.set_visible(self.channel_intro, True)
self.channels_waiting.value = why
self._refresh_masks_screen()
@@ -136,9 +139,25 @@ def _refresh_channels(self) -> None:
self.channel_labels.open(slots, names, layouts)
self._channel_labels_changed()
+ @property
+ def _stains_not_applicable(self) -> bool:
+ """True when the user declared every folder colour (RGB): R, G, B need no label."""
+ answers = self.draft_answers
+ return (
+ self._channels_key is not None
+ and answers is not None
+ and all_colour(self._channel_layouts, answers.units)
+ )
+
def _channel_labels_changed(self) -> None:
- """Open the tag grid for the confirmed labels, or close it while they are being changed."""
- if self.channel_labels.confirmed and self._channels_key is not None:
+ """Open the tag grid for the confirmed labels, or close it while they are being changed.
+
+ When every folder is colour the labels are not applicable and the grid opens without them.
+ """
+ na = self._stains_not_applicable
+ self.channel_labels.set_not_applicable(na)
+ ui.set_visible(self.channel_intro, not na) # it says to name the stains first
+ if (self.channel_labels.confirmed or na) and self._channels_key is not None:
self.channel_grid.open(
self._channel_layouts, self.channel_labels.labels, self._channel_thumbs
)
@@ -148,7 +167,10 @@ def _channel_labels_changed(self) -> None:
def _types_changed(self) -> None:
"""Redraw the grid after an image type (its rows hold the type cells)."""
- self.channel_grid.refresh()
+ if self._stains_not_applicable != self.channel_labels.not_applicable:
+ self._channel_labels_changed() # every folder became colour, or no longer is
+ else:
+ self.channel_grid.refresh()
self._channels_changed()
def _channels_changed(self) -> None:
diff --git a/src/morphobricks_preprocessing/notebook_channels_labels.py b/src/morphobricks_preprocessing/notebook_channels_labels.py
index 3b3ebe14..d4f59da3 100644
--- a/src/morphobricks_preprocessing/notebook_channels_labels.py
+++ b/src/morphobricks_preprocessing/notebook_channels_labels.py
@@ -104,27 +104,28 @@ def __init__(self, store: AnswerStore, on_change: Callable[[], None]) -> None:
self.confirm.observe(self._toggled)
self.status = widgets.HTML()
self.problem = widgets.HTML()
+ self.not_applicable = False
+ self.na_note = widgets.HTML()
+ self.body: list[widgets.Widget] = [
+ ui.help_text(
+ "Name what was stained, once per stain. The number of slots is the largest "
+ "number of channels found in any image; Add label adds more. Header "
+ "names are offered only when they are valid labels as they are and not "
+ "generic. "
+ "Every channel position of every folder needs a label, so an unused slot "
+ "needs one too: declare it here (for example unused)."
+ ),
+ self.note,
+ self.groups_box,
+ self.rows_box,
+ self.add_button,
+ self.skipped,
+ self.problem,
+ self.status,
+ self.confirm.widget,
+ ]
self.widget = ui.card(
- "Which labels did you image?",
- [
- ui.help_text(
- "Name what was stained, once per stain. The number of slots is the largest "
- "number of channels found in any image; Add label adds more. Header "
- "names are offered only when they are valid labels as they are and not "
- "generic. "
- "Every channel position of every folder needs a label, so an unused slot "
- "needs one too: declare it here (for example unused)."
- ),
- self.note,
- self.groups_box,
- self.rows_box,
- self.add_button,
- self.skipped,
- self.problem,
- self.status,
- self.confirm.widget,
- ],
- visible=False,
+ "Which labels did you image?", [*self.body, self.na_note], visible=False
)
@property
@@ -137,6 +138,27 @@ def confirmed(self) -> bool:
"""True while the list is confirmed (saved and not being changed)."""
return self.confirm.value
+ def set_not_applicable(self, on: bool) -> None:
+ """Say the card is not applicable (every folder is colour, RGB) and hide its questions."""
+ self.not_applicable = on
+ kept = ", ".join(self.labels)
+ self.na_note.value = (
+ ui.status(
+ "ok",
+ "Not applicable: every folder is colour (RGB).",
+ [
+ "R, G and B are the fixed names of a colour image's channels, so no label is "
+ "needed. If some folder is separate channels, change its image type "
+ "(step 7) and the labels come back."
+ + (f" The labels already declared ({kept}) stay saved, unused." if kept else "")
+ ],
+ )
+ if on
+ else ""
+ )
+ for widget in self.body:
+ ui.set_visible(widget, not on)
+
def display_names(self) -> dict[str, str | None]:
"""Return the shown-as name of each saved label."""
return {k: v.display_name for k, v in self.store.answers().channels.items()}
diff --git a/src/morphobricks_preprocessing/notebook_step_states.py b/src/morphobricks_preprocessing/notebook_step_states.py
index 0c5e1ee6..a635151c 100644
--- a/src/morphobricks_preprocessing/notebook_step_states.py
+++ b/src/morphobricks_preprocessing/notebook_step_states.py
@@ -167,15 +167,22 @@ def _image_type_state(self) -> StepState:
return StepState(done, ", ".join(parts), self._changed(6, done))
def _stains_state(self) -> StepState:
+ """Return step 8: the confirmed labels, or not applicable when every folder is colour."""
if self._channels_key is None:
return StepState(False, "after the axes")
+ if self._stains_not_applicable:
+ return StepState(
+ True, "not applicable: every folder is colour (RGB)", note="not applicable"
+ )
done = self.channel_labels.confirmed
text = ", ".join(self.channel_labels.labels) if done else "not confirmed"
return StepState(done, text, self._changed(7, done))
def _tags_state(self) -> StepState:
"""Return step 9: whether every channel of every folder is tagged."""
- if self._channels_key is None or not self.channel_labels.confirmed:
+ if self._channels_key is None or not (
+ self.channel_labels.confirmed or self._stains_not_applicable
+ ):
return StepState(False, "after the stains")
layouts = self._channel_layouts
left = remaining(layouts, self.draft_answers.units, self.channel_labels.labels)
diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py
index e14e3252..d4bc0e1b 100644
--- a/src/morphobricks_preprocessing/notebook_steps.py
+++ b/src/morphobricks_preprocessing/notebook_steps.py
@@ -21,7 +21,6 @@
from . import notebook_style as ui
from .dataset import CONSISTENCY_CODES
from .notebook_axes_quick import _QuickAxes
-from .notebook_channels import INTRO as CHANNELS_INTRO
from .notebook_channels_type import ImageTypeRows
from .notebook_check_save import INTRO as CHECK_INTRO
from .notebook_masks_screen import INTRO as MASKS_INTRO
@@ -107,7 +106,7 @@ def _step_list(self) -> list[Step]:
("Image type", "Separate channels or a colour (RGB) picture, per folder.",
[self.type_rows.widget]),
("Stains", "Name what you stained.",
- [ui.intro(CHANNELS_INTRO), self.channel_labels.widget]),
+ [self.channel_intro, self.channel_labels.widget]),
("Tag channels", "Say which stain each channel is, looking at its picture.",
[self.channel_grid.widget]),
("Masks", "Say what each mask type is.", [ui.intro(MASKS_INTRO), self.masks_box]),
@@ -210,7 +209,7 @@ def _wait_for_channels(self) -> str:
def _wait_for_tags(self) -> str:
waiting = self._wait_for_channels()
- if waiting or self.channel_labels.confirmed:
+ if waiting or self.channel_labels.confirmed or self._stains_not_applicable:
return waiting
return ui.status("info", "Waiting for the stains.", ["Confirm the stains in step 8 first."])
diff --git a/tests/test_notebook_stains_na.py b/tests/test_notebook_stains_na.py
new file mode 100644
index 00000000..c29632f8
--- /dev/null
+++ b/tests/test_notebook_stains_na.py
@@ -0,0 +1,164 @@
+"""An all-RGB experiment needs no channel label (issue 240, experiment-v1 section 4.4b).
+
+R, G and B are the fixed channel IDs of a unit the user declared ``rgb``; no label applies. So
+the Stains step is not applicable when every folder's saved image type is ``rgb``, and Check and
+save completes and saves without one. Only the user's answer counts: a header's colour pre-tick,
+a mixed experiment or an unanswered image type keeps the labels required.
+"""
+
+import tomllib
+from dataclasses import replace
+
+import pytest
+
+from channel_images import write_channels, write_rgb
+from morphobricks_preprocessing.description_file import DESCRIPTION_FILENAME
+from spec_oracle import refval
+
+pytest.importorskip("ipywidgets")
+pytest.importorskip("matplotlib")
+
+from morphobricks_preprocessing import notebook_style as ui # noqa: E402
+from morphobricks_preprocessing.channel_answers import all_colour # noqa: E402
+from morphobricks_preprocessing.channel_labels import UnitLayout # noqa: E402
+from morphobricks_preprocessing.draft_answers import UnitAnswer # noqa: E402
+from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402
+
+FOLDERS = ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/rep2"]
+STAINS = 7 # step 8, 0-based
+TAGS = 8
+
+
+def _open(tmp_path, *, channel_folders=()):
+ root = tmp_path / "exp"
+ for i, folder in enumerate(FOLDERS):
+ if folder in channel_folders:
+ write_channels(root / folder / "a.tif", ["DAPI", "Actin", "Ch3"], ["blobs"] * 3, seed=i)
+ else:
+ write_rgb(root / folder / "a.tif", seed=i + 1)
+ nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"), describe=True)
+ nb.steps()
+ nb.check_button.click()
+ nb.quick_confirm.click()
+ return nb
+
+
+def _answer(nb, *, skip=()):
+ """Answer the image types (when asked), the reference and each replicate; no label.
+
+ The colour folders of ``skip`` are left on the header's unsaved pre-tick.
+ """
+ types = nb.channel_types
+ for layout in types.layouts:
+ if not layout.rgb and types.saved(layout.key) is None:
+ types.radios[layout.key].value = "channels"
+ types.confirm_header([u.key for u in types.layouts if types.proposed(u) and u.key not in skip])
+ nb.reference_boxes["drug"].value = "ctrl"
+ nb.reference_boxes["ctrl"].value = ""
+ for toggle in list(nb.same_way.values()):
+ if not toggle.value:
+ toggle.button.click()
+
+
+def _state(nb, index):
+ return nb._stepper.states()[index]
+
+
+def test_an_all_rgb_experiment_completes_stains_and_check_and_save_without_a_label(tmp_path):
+ nb = _open(tmp_path)
+ _answer(nb)
+ stains = _state(nb, STAINS)
+ assert stains.done and stains.note == "not applicable"
+ assert stains.text == "not applicable: every folder is colour (RGB)"
+ assert "not applicable" in nb._stepper.chips[STAINS].description
+ assert "mb-chip-noted" in nb._stepper.chips[STAINS]._dom_classes
+ assert _state(nb, TAGS).done and not nb.channel_labels.confirmed
+ assert "Not applicable" in nb.channel_labels.na_note.value
+ assert not ui.is_visible(nb.channel_labels.body[0])
+ assert not ui.is_visible(nb.channel_intro) # "first name what you stained" would contradict
+ assert nb.todo.value == "" and not nb.save_button.disabled
+ assert nb.save_experiment()
+ assert _state(nb, 10).done
+
+
+def test_the_saved_file_declares_exactly_r_g_b_and_the_rgb_image_type(tmp_path):
+ nb = _open(tmp_path)
+ _answer(nb)
+ assert nb.save_experiment()
+ text = (tmp_path / "out" / DESCRIPTION_FILENAME).read_text(encoding="utf-8")
+ data = tomllib.loads(text)
+ assert sorted(data["channels"]) == ["B", "G", "R"]
+ assert all(not v.get("header_names") for v in data["channels"].values())
+ units = [
+ unit
+ for repeat in data["repeats"].values()
+ for member in repeat["members"].values()
+ for unit in member["technical_repeats"].values()
+ ]
+ assert len(units) == len(FOLDERS)
+ assert all(u["image_type"] == "rgb" and u["channels"] == ["R", "G", "B"] for u in units)
+
+
+def test_the_header_pre_tick_alone_does_not_make__stains_not_applicable(tmp_path):
+ nb = _open(tmp_path) # the headers say colour; nothing is answered
+ assert not nb._stains_not_applicable
+ stains = _state(nb, STAINS)
+ assert not stains.done and stains.text == "not confirmed"
+ assert ui.is_visible(nb.channel_labels.body[0])
+ assert "Not applicable" not in nb.channel_labels.na_note.value
+
+
+def test_a_folder_left_on_the_header_pre_tick_keeps_the_label_required(tmp_path):
+ nb = _open(tmp_path)
+ key = nb.channel_types.layouts[0].key
+ _answer(nb, skip=(key,)) # three folders answered colour, one only pre-ticked
+ assert nb.channel_types.layouts and nb.channel_types.saved(key) is None
+ assert not nb._stains_not_applicable
+ assert not _state(nb, STAINS).done and not _state(nb, TAGS).done
+ assert ui.is_visible(nb.channel_labels.body[0]) and ui.is_visible(nb.channel_intro)
+ assert nb.save_button.disabled and not nb.save_experiment()
+
+
+def test_one_unanswered_folder_keeps_the_label_required(tmp_path):
+ nb = _open(tmp_path)
+ _answer(nb)
+ assert _state(nb, STAINS).done
+ layout = nb.channel_types.layouts[0]
+ nb.channel_types.radios[layout.key].value = "channels" # now mixed
+ assert not nb._stains_not_applicable and not _state(nb, STAINS).done
+ assert ui.is_visible(nb.channel_labels.body[0]) and ui.is_visible(nb.channel_intro)
+ assert nb.channel_labels.na_note.value == ""
+
+
+def test_labels_declared_before_every_folder_became_colour_stay_saved_and_unused(tmp_path):
+ nb = _open(tmp_path)
+ _answer(nb)
+ key = nb.channel_types.layouts[0].key
+ nb.channel_types.radios[key].value = "channels"
+ card = nb.channel_labels
+ while len(card.rows) < 3:
+ card.add_button.click()
+ for row, text in zip(card.rows, ["DNA", "ACTIN", "PHASE"], strict=True):
+ row.id_box.value = text
+ card.confirm.button.click()
+ for (_k, index), cell in list(nb.channel_grid.cells.items()):
+ cell.value = card.labels[index] if cell.value is None else cell.value
+ nb.channel_types.radios[key].value = "rgb" # every folder is colour again
+ assert nb._stains_not_applicable and card.labels == ["ACTIN", "DNA", "PHASE"]
+ assert "stay saved, unused" in card.na_note.value
+ assert nb.save_experiment()
+ text = (tmp_path / "out" / DESCRIPTION_FILENAME).read_text(encoding="utf-8")
+ result = refval().validate(text.encode("utf-8"))[0]
+ assert not result.errors # valid; the reader's warning is W_UNUSED_CHANNEL (section 4.3)
+ assert {w[0] for w in result.warnings} == {"W_UNUSED_CHANNEL"}
+
+
+def test_a_stale_colour_answer_that_no_longer_fits_the_image_does_not_count():
+ key = ("c", "r", "single")
+ answer = UnitAnswer(("R", "G", "B"), "rgb")
+ fits = UnitLayout(key, "a.tif", 3, (None, None, None), rgb=True)
+ assert all_colour([fits], {key: answer})
+ assert not all_colour([replace(fits, channels=4)], {key: answer})
+ assert not all_colour([replace(fits, problem="unreadable")], {key: answer})
+ assert not all_colour([fits], {})
+ assert not all_colour([], {})