From 34c105742ee9f26778697397af60f724fa7c7a9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 10:44:49 +0000 Subject: [PATCH 1/2] Notebook: Stains is not applicable when every folder is declared colour (RGB) Experiment-v1 section 4.4b fixes R, G and B as the channels of an rgb unit, so an experiment whose every folder the user declared rgb needs no channel label. Stains then reads "not applicable: every folder is colour (RGB)" (chip done, noted), Tag channels opens without labels, and Check and save completes. Mixed or unanswered image types, and the header's unconfirmed colour pre-tick, keep the labels required. Fixes #240 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- .../channel_answers.py | 22 +++- .../notebook_channels.py | 25 +++- .../notebook_channels_labels.py | 62 ++++++--- .../notebook_step_states.py | 9 +- .../notebook_steps.py | 2 +- tests/test_notebook_stains_na.py | 119 ++++++++++++++++++ 6 files changed, 213 insertions(+), 26 deletions(-) create mode 100644 tests/test_notebook_stains_na.py diff --git a/src/morphobricks_preprocessing/channel_answers.py b/src/morphobricks_preprocessing/channel_answers.py index 4409fdcb..96b1ac77 100644 --- a/src/morphobricks_preprocessing/channel_answers.py +++ b/src/morphobricks_preprocessing/channel_answers.py @@ -27,6 +27,18 @@ 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( + layout.usable and getattr(units.get(layout.key), "image_type", None) == "rgb" + for layout in layouts + ) + + 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 +136,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..ae46b30d 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 @@ -136,9 +137,24 @@ 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) + 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 +164,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..e15a0e61 100644 --- a/src/morphobricks_preprocessing/notebook_steps.py +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -210,7 +210,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..18e9a3e1 --- /dev/null +++ b/tests/test_notebook_stains_na.py @@ -0,0 +1,119 @@ +"""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 + +import pytest + +from channel_images import write_channels, write_rgb +from morphobricks_preprocessing.description_file import DESCRIPTION_FILENAME + +pytest.importorskip("ipywidgets") +pytest.importorskip("matplotlib") + +from morphobricks_preprocessing import notebook_style as ui # 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): + """Answer the image types (when asked), the reference and each replicate; no label.""" + 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)]) + 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 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_mixed_experiment_still_requires_the_label(tmp_path): + nb = _open(tmp_path, channel_folders=("drug/rep2",)) + _answer(nb) + 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]) + 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]) + assert nb.channel_labels.na_note.value == "" From d40887b529267a3ea9be44de3d4878f4bc410459 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 11:33:12 +0000 Subject: [PATCH 2/2] Notebook: hide the Stains intro when not applicable; harden all_colour; tighten tests - The "first name what you stained" intro contradicts the not-applicable card, so it is hidden while every folder is declared colour. - all_colour requires the saved rgb answer to still fit the image (3 channels). - The mixed-experiment test was vacuous (no axis roles confirmed, so no layouts): replaced by a folder left on the header pre-tick. Added the stale-labels case (valid; W_UNUSED_CHANNEL per the vendored refval) and an all_colour unit test. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- .../channel_answers.py | 13 +++- .../notebook_channels.py | 5 +- .../notebook_steps.py | 3 +- tests/test_notebook_stains_na.py | 61 ++++++++++++++++--- 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/morphobricks_preprocessing/channel_answers.py b/src/morphobricks_preprocessing/channel_answers.py index 96b1ac77..c9257094 100644 --- a/src/morphobricks_preprocessing/channel_answers.py +++ b/src/morphobricks_preprocessing/channel_answers.py @@ -33,9 +33,16 @@ def all_colour(layouts: Sequence[UnitLayout], units: Mapping[UnitKey, UnitAnswer 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( - layout.usable and getattr(units.get(layout.key), "image_type", None) == "rgb" - for layout in layouts + 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 ) diff --git a/src/morphobricks_preprocessing/notebook_channels.py b/src/morphobricks_preprocessing/notebook_channels.py index ae46b30d..bb3b7531 100644 --- a/src/morphobricks_preprocessing/notebook_channels.py +++ b/src/morphobricks_preprocessing/notebook_channels.py @@ -71,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] @@ -83,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 ----------------------------------------------------------------- @@ -93,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() @@ -154,6 +156,7 @@ def _channel_labels_changed(self) -> None: """ 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 diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py index e15a0e61..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]), diff --git a/tests/test_notebook_stains_na.py b/tests/test_notebook_stains_na.py index 18e9a3e1..c29632f8 100644 --- a/tests/test_notebook_stains_na.py +++ b/tests/test_notebook_stains_na.py @@ -7,16 +7,21 @@ """ 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"] @@ -38,13 +43,16 @@ def _open(tmp_path, *, channel_folders=()): return nb -def _answer(nb): - """Answer the image types (when asked), the reference and each replicate; no label.""" +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)]) + 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()): @@ -67,6 +75,7 @@ def test_an_all_rgb_experiment_completes_stains_and_check_and_save_without_a_lab 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 @@ -99,12 +108,14 @@ def test_the_header_pre_tick_alone_does_not_make__stains_not_applicable(tmp_path assert "Not applicable" not in nb.channel_labels.na_note.value -def test_a_mixed_experiment_still_requires_the_label(tmp_path): - nb = _open(tmp_path, channel_folders=("drug/rep2",)) - _answer(nb) +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]) + 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() @@ -115,5 +126,39 @@ def test_one_unanswered_folder_keeps_the_label_required(tmp_path): 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]) + 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([], {})