diff --git a/CHANGELOG.md b/CHANGELOG.md
index d06c390..46f0c93 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,32 @@ v0.5.1: no run before v0.5.1 is read.)
## Unreleased
+**Owner feedback on the v0.5.2 Colab test (12 November 2026)**
+
+- **Axes: the confirmation is seen at once.** The instant **Confirm these axes** or
+ **Confirm axis roles** (or **Change axis roles**) is clicked, the button is greyed out and
+ says *Working…*, and the progress line of the folder check appears under it before any of
+ the work: *Working…*, then the 5 parts of the confirmation with a bar (n of 5, what it does
+ now). Both clear when it is done.
+ If something fails part-way, the button is given back and the error is said in red where
+ it is seen (under **Confirm axis roles** once the one-click card has gone), with the full
+ report in *Technical details*; nothing stays on *Working…*.
+- **Axes: the confirmation is faster.** It read every channel of every folder's first image for
+ the per-folder image-type rows, which are folded under *Set per folder…* (28 folders of
+ 3 × 2048² images: 93 plane reads, 84 of them for those rows), and rebuilt the pairing editor
+ of Design for pictures the stepper never shows. The rows now read their pictures only once
+ opened, and the editor is not rebuilt there: 9 plane reads instead of 93 (about 72 MiB read
+ instead of 744 MiB) and 1,037 widgets built instead of 1,439 on the same data. Nothing
+ validated changed.
+- **The image type moved from Axes to Channels.** It is proposed at the quick scan, display
+ only, from the headers (the shown image's before the folder check, every header after it;
+ nothing is recorded there), and confirmed on Channels, first on the page, since it decides
+ whether the channels are R, G and B or the stains. The rules are unchanged: recorded only by
+ a click, pre-filled only when every header agrees, per folder when they differ, **Use for all
+ folders**, and folders of different types refused (`R_HETEROGENEOUS_LAYOUT_NOT_ENABLED`). The
+ Axes step is done once the roles are confirmed; the Channels step needs the image type. The
+ published files are unchanged for the same answers.
+
**Parent-label (grouped) masks** (MorphoBricks-Spec PR #28, merge `69a58b3`: ADR 0001 PL1,
experiment-v1 §4.5, §4.5a-§4.5c, §7, §8; ADR 0002 resolver contract version 2)
diff --git a/docs/colab-release-check.md b/docs/colab-release-check.md
index eabb28d..e8c18d4 100644
--- a/docs/colab-release-check.md
+++ b/docs/colab-release-check.md
@@ -40,11 +40,18 @@ release PR moves both together and records the pair in `RELEASE_CALLS` of
7. - [ ] **Images:** paste the Drive paths (experiment, masks, output); **Browse…** says it
cannot open here and offers **Copy path**. **Scan the folders** shows the folders and an
example picture with a progress line; **Looks right: read all image headers** shows its
- progress (done of total, time left) and the result.
+ progress (done of total, time left) and the result. The image type (separate channels or
+ colour RGB) is shown from the headers, display only: "nothing is recorded here".
8. - [ ] **Axes:** one row per dimension with its role, the example redrawn when a role changes,
- the pixel size in one line, the image type in one line (**Use for all folders**). The roles
- are confirmed only by the click.
-9. - [ ] **Channels:** name the stains, then tag each folder's channels.
+ the pixel size in one line; no image type. The roles are confirmed only by the click. The
+ instant **Confirm these axes** (or **Confirm axis roles**) is clicked, the button is greyed
+ out and says **Working…**, and a progress bar under it counts the parts done (n of 5, what
+ it does now); both clear when the roles are confirmed.
+9. - [ ] **Channels:** first the image type in one line, pre-filled only when every header
+ agrees and recorded only by **Use for all folders** (or each folder under **Set per
+ folder…**; folders of different types are refused, `R_HETEROGENEOUS_LAYOUT_NOT_ENABLED`);
+ then name the stains (none for colour RGB: its channels are R, G and B) and tag each
+ folder's channels.
10. - [ ] **Masks:** **Check the masks** runs with progress; each mask type can be described.
**Describe the masks** is greyed out, with its reason, until a run exists.
11. - [ ] **Design:** the grid of conditions by biological repeats; **Looks right: confirm the
diff --git a/docs/contract.md b/docs/contract.md
index 6d66520..c1d7861 100644
--- a/docs/contract.md
+++ b/docs/contract.md
@@ -590,7 +590,7 @@ plan N34); this brick suggests it, asks for it after the axis roles, and records
| Types | `fluorescence` (any number of channels, every pixel type) and `rgb` (exactly three channels of an integer pixel type, the rule of the normalization brick's `check_rgb`). |
| RGB samples | A TIFF image is stored as RGB colour when its first series has the photometric interpretation RGB with three samples per pixel (`tifffile` axis `S`; `TiffMetadata.rgb_samples`). RGB with an alpha sample, several grey samples and OME-Zarr images are not. |
| Suggestion | `rgb` when every analysed image is stored as RGB colour, the confirmed C role is on its samples axis, and `rgb` is possible; `fluorescence` otherwise. `review_image_type` gives the suggestion, why, and why `rgb` is not possible (from the headers and the confirmed axis roles; no pixels are read). |
-| Confirmation | Never applied without the user's confirmation. The notebook asks in step 2 once the axis roles are confirmed, with the suggestion preselected and a **Yes, this is the right image type** button; `rgb` cannot be chosen, with the reason shown, when it is not possible. Changing the axis roles or the folder asks again. Step 3 runs only once the type is confirmed. The command line takes `--image-type`, or uses the suggestion and prints it. |
+| Confirmation | Never applied without the user's confirmation. The notebook proposes it at the quick scan (display only, from the headers; nothing is recorded there) and asks on the Channels step once the axis roles are confirmed (owner, 12 November 2026), pre-filled only when every header agrees and recorded only by a click (**Use for all folders**, or each folder); folders of different types are refused (`R_HETEROGENEOUS_LAYOUT_NOT_ENABLED`). Changing the axis roles or the folder asks again. The run takes the type the saved description states. The command line takes `--image-type`, or uses the suggestion and prints it. |
| Run | `run_experiment(..., image_type=...)` records the type and `channels_from_rgb_samples` in the manifest (§9). `rgb` for images that cannot be RGB is the error `rgb_not_possible`; any other value raises `ValueError`. Without a type the manifest records null. |
| Outputs | `plane_histograms.csv` and `experiment_design.csv` do not depend on the type: a plane of an RGB image is one row with channels C0, C1 and C2, as for any image. Changing the type on a later run reads no image again. |
diff --git a/notebooks/MorphoBricks_Preprocessing.ipynb b/notebooks/MorphoBricks_Preprocessing.ipynb
index a125d9a..08b2d57 100644
--- a/notebooks/MorphoBricks_Preprocessing.ipynb
+++ b/notebooks/MorphoBricks_Preprocessing.ipynb
@@ -395,8 +395,8 @@
"The app has seven steps, one on screen at a time; move with **Next** or click a step in the strip at the top:\n",
"\n",
"1. **Images**: give the experiment folder (masks and output are optional), click **Scan the folders**, look at the example image, then **Looks right: read all image headers**.\n",
- "2. **Axes**: what each image dimension is, the pixel size, and the image type (separate stains or colour).\n",
- "3. **Channels**: name what you stained, then tag each folder's channels.\n",
+ "2. **Axes**: what each image dimension is, and the pixel size.\n",
+ "3. **Channels**: confirm the image type (separate stains or colour), name what you stained, then tag each folder's channels.\n",
"4. **Masks**: say what each mask type is (nothing to do without masks). After the run, come back here for **Describe the masks** if later bricks use the masks.\n",
"5. **Design**: conditions, biological repeats and comparisons in one grid; **Looks right: confirm the design**.\n",
"6. **Check and save**: read your experiment in words and save it.\n",
diff --git a/src/morphobricks_preprocessing/notebook_axes_quick.py b/src/morphobricks_preprocessing/notebook_axes_quick.py
index d84fccd..ae622dc 100644
--- a/src/morphobricks_preprocessing/notebook_axes_quick.py
+++ b/src/morphobricks_preprocessing/notebook_axes_quick.py
@@ -50,8 +50,11 @@ def _build_quick_axes(self) -> None:
tooltip="The example looks right: use these roles for every image",
main=True,
)
- self.quick_confirm.on_click(lambda _: self._confirm_quick_axes())
- self.quick_card = widgets.VBox([self.quick_text, self.quick_confirm, self.quick_caption])
+ self.quick_confirm.on_click(lambda _: self._quick_clicked())
+ self.quick_progress = widgets.HTML() # Working…, then the parts done (owner, 12 Nov)
+ self.quick_card = widgets.VBox(
+ [self.quick_text, self.quick_confirm, self.quick_progress, self.quick_caption]
+ )
ui.set_visible(self.quick_card, False)
# What can be offered -------------------------------------------------------------------
@@ -178,6 +181,21 @@ def _headers_changed_line(self) -> list[str]:
# The click ---------------------------------------------------------------------------
+ def _quick_clicked(self) -> None:
+ """*Confirm these axes*: working at once, then the parts of the confirmation."""
+ self._working(self.quick_confirm, self.quick_progress, self._confirm_quick_axes)
+
+ def _after_working(self, button: widgets.Button) -> None:
+ super()._after_working(button)
+ if button is getattr(self, "quick_confirm", None):
+ self._refresh_quick_axes(drawn=True) # its state, from the roles and the example
+
+ def _failure_line(self, progress: widgets.HTML) -> widgets.HTML:
+ """Say a failure of *Confirm these axes* under *Confirm axis roles* once its card hides."""
+ if progress is getattr(self, "quick_progress", None) and not ui.is_visible(self.quick_card):
+ return self.confirm_progress
+ return super()._failure_line(progress)
+
def _confirm_quick_axes(self) -> bool:
"""Confirm the offered roles: the preview checked, then the roles, as the role boxes do.
diff --git a/src/morphobricks_preprocessing/notebook_axis_preview.py b/src/morphobricks_preprocessing/notebook_axis_preview.py
index 47c441d..406559c 100644
--- a/src/morphobricks_preprocessing/notebook_axis_preview.py
+++ b/src/morphobricks_preprocessing/notebook_axis_preview.py
@@ -3,6 +3,10 @@
from __future__ import annotations
import html
+import traceback
+from collections.abc import Callable
+
+import ipywidgets as widgets
from . import notebook_style as ui
from .axes import AxisMapping, axis_name, file_mapping_for
@@ -17,10 +21,90 @@
from .preview import PreviewRefused, check_unchanged, checked_fingerprint
from .validation import plural
+WORKING = "Working…"
+"""What a confirmation button says, and its progress line first, the instant it is clicked."""
+CONFIRM_STEPS = (
+ "Checking the roles against every image",
+ "Redrawing the example with these roles",
+ "Reading the pixel sizes",
+ "Preparing the run",
+ "Preparing Channels, Masks and Check and save",
+)
+"""The parts of *Confirm axis roles* (or of taking it back), in order, for its progress bar."""
+
class _AxisPreview:
"""Step 2's preview, the confirmation of the axis roles and the image type."""
+ # The click, shown at once ------------------------------------------------------------
+
+ def _confirm_clicked(self) -> None:
+ """*Confirm axis roles* (or *Change axis roles*): working at once, then the parts."""
+ self._working(self.confirm_button, self.confirm_progress, self._toggle_confirmation)
+
+ def _working(
+ self, button: widgets.Button, progress: widgets.HTML, action: Callable[[], object]
+ ) -> object:
+ """Run ``action`` behind a disabled button and a progress line drawn before it starts.
+
+ Owner (12 November 2026): the click must show at once that something happens. The
+ button is disabled and says *Working…*, and the line under it shows *Working…* before
+ any of the work; the parts of the work then advance its bar
+ (:meth:`_confirm_step`). The widgets' state is sent to the page as it is set, so the
+ line is seen while the work runs. Afterwards the line is cleared and the button given
+ back the state the answers give it (:meth:`_after_working`). A disabled button does
+ nothing. An unexpected failure part-way gives the button back too, and says what
+ failed where it is seen (:meth:`_failure_line`), the full report in Technical details.
+ """
+ if button.disabled:
+ return None
+ saved = (button.description, button.icon)
+ self._busy(button, WORKING)
+ self._working_on = button # it stays disabled, saying Working…, until the end
+ wait = self._confirm_wait = Wait(progress)
+ wait.start(WORKING, len(CONFIRM_STEPS))
+ failed: str = ""
+ try:
+ return action()
+ except Exception as exc: # unexpected failures still get a readable message
+ self.log.lines.append(traceback.format_exc())
+ failed = ui.status(
+ "error",
+ "The confirmation stopped because of an unexpected problem.",
+ [
+ f"{type(exc).__name__}: {exc}",
+ "Open Technical details for the full report. Click the button again; "
+ "once the roles are confirmed, Change axis roles takes them back.",
+ ],
+ )
+ return None
+ finally:
+ self._confirm_wait = self._working_on = None
+ progress.value = ""
+ button.description, button.icon = saved
+ button.disabled = False
+ try:
+ self._after_working(button)
+ finally:
+ if failed:
+ self._failure_line(progress).value = failed
+ self._show_log()
+
+ def _failure_line(self, progress: widgets.HTML) -> widgets.HTML:
+ """Return the line that says a failure: the click's own, where it is seen."""
+ return progress
+
+ def _after_working(self, button: widgets.Button) -> None:
+ """Give the confirmation buttons back the state the step's answers give them."""
+ self._confirm_words(self.confirmed_mapping is not None)
+ self._update_confirm_button()
+
+ def _confirm_step(self, index: int) -> None:
+ """Advance the progress of a confirmation to part ``index`` (0-based), when one shows."""
+ wait = getattr(self, "_confirm_wait", None)
+ if wait is not None:
+ wait.phase(CONFIRM_STEPS[index], len(CONFIRM_STEPS), index)
+
def _fill_preview_files(self) -> None:
"""List the images that fit the roles in the preview dropdown."""
files = [
@@ -211,13 +295,14 @@ def _preview_header(self): # noqa: ANN202
def _toggle_confirmation(self) -> None:
"""Confirm the roles shown (or take the confirmation back) and update what follows."""
if self.confirmed_mapping is None:
- if self.confirm_button.disabled:
+ if self._confirm_blockers(): # what disables the button (it shows Working… now)
return
self.confirmed_mapping = self._current_mapping()
else:
self.confirmed_mapping = None
locked = self.confirmed_mapping is not None
self._set_locked(locked)
+ self._confirm_step(0)
if locked:
roles = [
f"{axis_name(a)} → {r}"
@@ -256,11 +341,17 @@ def _toggle_confirmation(self) -> None:
self.confirmed_status.value = ""
# The image type depends on the channels the roles give.
self._close_image_type()
+ self._confirm_step(1)
self._mapping_changed()
self._update_confirm_button()
+ if locked:
+ self._confirm_step(1)
self._render_preview_if_shown()
+ self._confirm_step(2)
self._show_calibration()
+ self._confirm_step(3)
self._refresh_run_step()
+ self._confirm_step(4)
self._refresh_channels()
def _open_image_type(self) -> None:
@@ -350,6 +441,12 @@ def _set_locked(self, locked: bool) -> None:
dropdown.disabled = locked
self.missing_as_singleton.disabled = locked
self._gate_preview_tick()
+ self._confirm_words(locked)
+
+ def _confirm_words(self, locked: bool) -> None:
+ """Say *Confirm axis roles*, or *Change axis roles* once they are confirmed."""
+ if getattr(self, "_working_on", None) is self.confirm_button:
+ return # it says Working… until the work is done (:meth:`_working`)
self.confirm_button.description = " Change axis roles" if locked else " Confirm axis roles"
self.confirm_button.icon = "pencil" if locked else "check"
self.confirm_button.button_style = "warning" if locked else "success"
diff --git a/src/morphobricks_preprocessing/notebook_axis_step.py b/src/morphobricks_preprocessing/notebook_axis_step.py
index 04a0c35..175764a 100644
--- a/src/morphobricks_preprocessing/notebook_axis_step.py
+++ b/src/morphobricks_preprocessing/notebook_axis_step.py
@@ -50,6 +50,7 @@ def _build_axis_step(self) -> None:
tooltip="Use these roles for every image of the experiment",
main=True,
)
+ self.confirm_progress = widgets.HTML() # Working…, then the parts done (owner, 12 Nov)
self.confirm_blocker = widgets.HTML()
self.confirmed_status = widgets.HTML()
self.axis_next = widgets.HTML()
@@ -59,7 +60,7 @@ def _build_axis_step(self) -> None:
for slider in self.preview_sliders.values():
slider.observe(lambda _: self._render_preview_if_shown(), names="value")
self.preview_checked.observe(lambda _: self._update_confirm_button())
- self.confirm_button.on_click(lambda _: self._toggle_confirmation())
+ self.confirm_button.on_click(lambda _: self._confirm_clicked())
self._build_image_type_card()
def _build_missing_card(self) -> None:
@@ -204,9 +205,10 @@ def _refresh_axis_step(self) -> None:
self.convert_card,
self.axis_sizes_slot,
self.confirm_button,
+ self.confirm_progress,
self.confirm_blocker,
self.confirmed_status,
- self.image_type_card,
+ # No image type here (owner, 12 November 2026): it is confirmed on Channels.
self.axis_next,
]
self._fill_preview_files()
@@ -393,6 +395,8 @@ def _mapping_changed(self) -> None:
def _update_confirm_button(self) -> None:
"""Enable *Confirm axis roles* when nothing blocks it; say beside it what does."""
+ if getattr(self, "_working_on", None) is self.confirm_button:
+ return # disabled, saying Working…, until the work is done
if self.confirmed_mapping is not None:
self.confirm_button.disabled = False
self.confirm_blocker.value = ""
diff --git a/src/morphobricks_preprocessing/notebook_channels_grid.py b/src/morphobricks_preprocessing/notebook_channels_grid.py
index 4150e19..ddcb8de 100644
--- a/src/morphobricks_preprocessing/notebook_channels_grid.py
+++ b/src/morphobricks_preprocessing/notebook_channels_grid.py
@@ -416,11 +416,11 @@ def _block_text(self, units: Sequence[UnitLayout], colour: bool, apart: bool) ->
def _type_text(self, layout: UnitLayout) -> str:
"""Say the image-type answer of a colour block, as the image-type page gave it."""
if self.types.saved(layout.key) == "rgb":
- where = "" if self.place_types else " (Axes)"
+ where = "" if self.place_types else " (Image type, above)"
return f"Answered colour (RGB){where}: its channels are R, G and B, nothing to tag."
if self.place_types:
return "Colour is pre-ticked from the header: confirm it with the button below."
- return "Colour is pre-ticked from the header: confirm it on Axes (Image type)."
+ return "Colour is pre-ticked from the header: confirm it under Image type, above."
def _pending(self, layout: UnitLayout) -> bool:
"""Return True when the unit shows a proposal not saved yet.
diff --git a/src/morphobricks_preprocessing/notebook_channels_type.py b/src/morphobricks_preprocessing/notebook_channels_type.py
index 26a0abe..f6e3561 100644
--- a/src/morphobricks_preprocessing/notebook_channels_type.py
+++ b/src/morphobricks_preprocessing/notebook_channels_type.py
@@ -148,6 +148,11 @@ def __init__(
self.types, self._thumbs = types, thumbs
# The status of each unit against a saved experiment (None without one): rows fold.
self.reopen: Callable[[], Mapping[UnitKey, str] | None] = lambda: None
+ # True while the rows are folded away: their pictures are then read only once shown
+ # (owner, 12 November 2026: confirming the axes read every channel of every folder's
+ # image for rows nobody had opened).
+ self.folded: Callable[[], bool] = lambda: False
+ self.pictured = False
self.fold = RowFold("folder")
self.rows = widgets.VBox()
self.status = widgets.HTML()
@@ -184,8 +189,26 @@ def answered(self) -> int:
return sum(1 for u in self.types.layouts if u.usable and self.types.saved(u.key))
def refresh(self) -> None:
- """Rebuild the rows from the saved answers."""
+ """Rebuild the rows from the saved answers (without pictures while they are folded)."""
self.types.open(self.types.layouts) # a fresh set of cells, built from the answers
+ self.pictured = not self.folded()
+ self._build_rows()
+ self.problem.value = self.types.message
+ usable = sum(1 for u in self.types.layouts if u.usable)
+ done = self.answered
+ self.status.value = (
+ ui.status("ok", f"The image type of every one of the {usable} folders is answered.")
+ if done == usable
+ else ui.status("warn", f"Not finished: {usable - done} image type(s) to choose.")
+ )
+
+ def unfolded(self) -> None:
+ """Rebuild the rows with their pictures now that they are shown, unless they have them."""
+ if not self.pictured and not self.folded() and ui.is_visible(self.widget):
+ self.refresh()
+
+ def _build_rows(self) -> None:
+ """Build one row per folder (its picture once shown) and the header's confirmation."""
ordered = sorted(self.types.layouts, key=lambda u: tuple(map(natural_key, u.key)))
units = self.reopen()
rows = self.fold.arrange(
@@ -201,14 +224,6 @@ def refresh(self) -> None:
button.on_click(lambda _, keys=tuple(proposed): self._confirm(keys))
rows.append(button)
self.rows.children = rows
- self.problem.value = self.types.message
- usable = sum(1 for u in self.types.layouts if u.usable)
- done = self.answered
- self.status.value = (
- ui.status("ok", f"The image type of every one of the {usable} folders is answered.")
- if done == usable
- else ui.status("warn", f"Not finished: {usable - done} image type(s) to choose.")
- )
def _settled(self, layout: UnitLayout) -> bool:
"""Return True when a row may fold: answered, with no warning under its choice."""
@@ -233,7 +248,7 @@ def _row(self, layout: UnitLayout) -> widgets.Widget:
+ " · ".join(quoted(layout.name(i)) for i in range(layout.channels))
)
info += f"
{said}
"
- thumbs = self._thumbs()
+ thumbs = self._thumbs() if self.pictured else None
colour = self.types.shown(layout) == "rgb"
thumb = None if thumbs is None else thumbs.picture(layout, colour)
if thumb is not None:
diff --git a/src/morphobricks_preprocessing/notebook_check_view.py b/src/morphobricks_preprocessing/notebook_check_view.py
index 778ca31..4511315 100644
--- a/src/morphobricks_preprocessing/notebook_check_view.py
+++ b/src/morphobricks_preprocessing/notebook_check_view.py
@@ -32,7 +32,7 @@
"unit_channels_missing": "the channels of {subject} are not tagged (Channels)",
"unit_channel_untagged": "{subject} is not tagged (Channels)",
"channel_undeclared": "the label {subject} is used but not declared (Channels)",
- "unit_image_type_unanswered": "the image type of {subject} is not answered (Axes)",
+ "unit_image_type_unanswered": "the image type of {subject} is not answered (Channels)",
"unit_channels_stale": (
"the tags of {subject} were saved for another image: tag its channels again (Channels)"
),
diff --git a/src/morphobricks_preprocessing/notebook_compact.py b/src/morphobricks_preprocessing/notebook_compact.py
index adb5343..c07a0fb 100644
--- a/src/morphobricks_preprocessing/notebook_compact.py
+++ b/src/morphobricks_preprocessing/notebook_compact.py
@@ -80,6 +80,7 @@ def _images_page(self) -> list[widgets.Widget]:
self.folder_status, # the check's progress, right under the button clicked
self.copy_note_images,
self.scan_axes_line,
+ self.scan_type_line,
self.folder_lock.card,
self.folder_tiles,
self.issues_card,
diff --git a/src/morphobricks_preprocessing/notebook_described.py b/src/morphobricks_preprocessing/notebook_described.py
index b2d412a..3c0382a 100644
--- a/src/morphobricks_preprocessing/notebook_described.py
+++ b/src/morphobricks_preprocessing/notebook_described.py
@@ -177,10 +177,20 @@ def _refresh_channels(self) -> None:
"""Refresh *Your channels*; the pairing card gets its pictures once they can be read."""
super()._refresh_channels()
thumbs = self._channel_thumbs
- if self.pairing is not None and thumbs is not self._pictures_for:
+ # Rebuilt only where the editor shows the pictures (not in the stepper, whose viewer
+ # shows them): rebuilding it on every confirmation of the axes was most of the wait.
+ if (
+ self.pairing is not None
+ and thumbs is not self._pictures_for
+ and self._cards_show_pictures()
+ ):
self._pictures_for = thumbs
self._build_design_editor()
+ def _cards_show_pictures(self) -> bool:
+ """Return True when the folder cards show each folder's picture (``_member_picture``)."""
+ return True
+
# Step 2 ------------------------------------------------------------------------------
def _open_image_type(self) -> None:
diff --git a/src/morphobricks_preprocessing/notebook_image_type_line.py b/src/morphobricks_preprocessing/notebook_image_type_line.py
index dd34a24..e1c2215 100644
--- a/src/morphobricks_preprocessing/notebook_image_type_line.py
+++ b/src/morphobricks_preprocessing/notebook_image_type_line.py
@@ -1,14 +1,18 @@
-"""The image type in one line of the axes step: what the channel dimension holds.
+"""The image type in one line of the Channels step: what the channel dimension holds.
Owner decision (10 November 2026, item (p)): the image type says what the C dimension means
-(one stain or marker per channel, or the red, green and blue of a colour picture), so it is
-asked with the axes, in the pattern of the pixel size:
+(one stain or marker per channel, or the red, green and blue of a colour picture). Owner
+decision (12 November 2026): it is **proposed** at the quick scan (display only, from the
+headers: :meth:`.notebook_scan_axes._ScanAxes._show_scan_type`, nothing recorded there) and
+**confirmed** here, on the Channels step, since it decides whether the channels are R, G and B
+or the user's stains; it is no longer on the Axes step. Asked in the pattern of the pixel size:
- **one line for all folders**, its choice pre-filled from what the image headers say (colour
only when every folder's header says colour, separate channels only when none does). The
pre-fill is a proposal: nothing is saved until the user clicks **Use for all folders**;
- **Set per folder…**, collapsed, holds the row of each folder
- (:class:`.notebook_channels_type.ImageTypeRows`: the same cells, answers and refusals);
+ (:class:`.notebook_channels_type.ImageTypeRows`: the same cells, answers and refusals); the
+ rows, and the picture of each folder, are built only once it is opened;
- when the headers **differ**, nothing is pre-filled, one line says how many folders say what,
and the per-folder part opens.
@@ -85,6 +89,9 @@ def __init__(self, types: ImageTypeColumn, rows: ImageTypeRows) -> None:
ui.set_visible(self.replace_box, False)
self.message = widgets.HTML()
self.per_folder = ui.accordion("Set per folder…", [rows.widget])
+ # The rows of each folder are built, and their pictures read, only once opened.
+ rows.folded = lambda: self.per_folder.selected_index is None
+ self.per_folder.observe(lambda _: rows.unfolded(), names="selected_index")
self.widget = ui.card(
"Image type",
[self.line, self.form, self.blocker, self.replace_box, self.message, self.per_folder],
diff --git a/src/morphobricks_preprocessing/notebook_scan_axes.py b/src/morphobricks_preprocessing/notebook_scan_axes.py
index 1386eb3..ea060f9 100644
--- a/src/morphobricks_preprocessing/notebook_scan_axes.py
+++ b/src/morphobricks_preprocessing/notebook_scan_axes.py
@@ -7,6 +7,11 @@
image (:func:`.axis_review.review_axes`, as always), and the Axes step confirms them for the
whole dataset, where they can still be changed.
+The image type (separate channels or colour RGB) is **proposed** here too, display only
+(owner, 12 November 2026): from the header of the image shown before the folder check, and from
+every header after it. Nothing of it is recorded here; it is confirmed on the Channels step
+(:mod:`.notebook_image_type_line`), where the same headers pre-fill it.
+
Nothing is confirmed here. The roles of this module are a proposal, held per arrangement of
axes (a header's letters) and only for an arrangement the user changed; an arrangement left
alone keeps the header's roles, and a header that does not name its axes gets no picture until
@@ -24,6 +29,7 @@
from .axes import AxisMapping, axis_name, file_mapping_for, prefill_mapping
from .axis_review import check_mapping, review_axes
from .metadata import TiffMetadata
+from .notebook_image_type_line import MIXED
from .notebook_options import ROLE_OPTIONS
from .notebook_viewers import CONFIRMED_NOTE, header_roles
from .validation import plural
@@ -42,6 +48,46 @@
)
+TYPE_CONFIRMED_ON = "Display only: nothing is recorded here. It is confirmed on Channels (step 3)."
+
+
+def header_type(meta: TiffMetadata) -> str:
+ """Return what a header says the channels are: ``colour (RGB)`` or ``separate channels``."""
+ return "colour (RGB)" if meta.rgb_samples else "separate channels"
+
+
+def scan_type_html(metas: list[TiffMetadata], *, one: bool = False) -> str:
+ """Return the image type the readable headers propose, display only (HTML).
+
+ ``one`` says it of the single image shown before the folder check.
+ """
+ said = [header_type(meta) for meta in metas if meta.readable]
+ if not said:
+ return ""
+ if one:
+ return (
+ f"Image type, from this image's header: "
+ f"{html.escape(said[0])}. {TYPE_CONFIRMED_ON}
"
+ )
+ kinds = sorted(set(said))
+ if len(kinds) == 1:
+ return ui.status(
+ "info",
+ f"Image type proposed from the headers: {kinds[0]} in all "
+ f"{plural(len(said), 'image')}.",
+ [TYPE_CONFIRMED_ON],
+ )
+ counts = ", ".join(f"{kind} in {plural(said.count(kind), 'image')}" for kind in kinds)
+ return ui.status(
+ "warn",
+ f"The image headers differ: {counts}.",
+ [
+ "Nothing is proposed: the type of each folder is confirmed on Channels (step 3). "
+ + MIXED
+ ],
+ )
+
+
def roles_words(mapping: AxisMapping) -> str:
"""Return the roles in the order of the file: ``C, Y, X`` (``-`` for none)."""
return ", ".join(role or "-" for role in mapping.roles)
@@ -60,18 +106,21 @@ def _build_scan_axes(self) -> None:
self.scan_roles_row = widgets.HBox(
layout=widgets.Layout(flex_flow="row wrap", align_items="flex-end", width="99%")
)
+ self.scan_type_note = widgets.HTML() # the shown image's type, display only
self.scan_roles_card = widgets.VBox(
- [ui.help_text(ROLES_HELP), self.scan_roles_row], layout=widgets.Layout(width="99%")
+ [ui.help_text(ROLES_HELP), self.scan_roles_row, self.scan_type_note],
+ layout=widgets.Layout(width="99%"),
)
ui.set_visible(self.scan_roles_card, False)
self.scan_axes_line = widgets.HTML()
+ self.scan_type_line = widgets.HTML() # every header's type, display only
self.axes_change_note = ui.help_text(CHANGE_NOTE)
self.scan_viewer.on_draw(self._scan_roles_follow)
def _reset_scan_axes(self) -> None:
"""Forget the roles chosen at an earlier scan: a new scan proposes afresh."""
self._scan_roles, self._scan_row_axes = {}, None
- self.scan_axes_line.value = ""
+ self.scan_axes_line.value = self.scan_type_line.value = ""
# The row -------------------------------------------------------------------------------
@@ -87,6 +136,7 @@ def _scan_roles_follow(self) -> None:
return
if meta.axes != self._scan_row_axes:
self._build_scan_row(meta)
+ self.scan_type_note.value = scan_type_html([meta], one=True)
ui.set_visible(self.scan_roles_card, True)
def _build_scan_row(self, meta: TiffMetadata) -> None:
@@ -162,8 +212,15 @@ def _refresh_axis_step(self) -> None:
self._quick_seeded = self.preflight # the user's roles, not an earlier run's
super()._refresh_axis_step()
+ def _show_scan_type(self) -> None:
+ """Say, on Images, the image type every header the check read proposes (display only)."""
+ pre = self.preflight
+ files = list(pre.metadata.files) if pre is not None else []
+ self.scan_type_line.value = scan_type_html(files)
+
def _show_scan_axes(self) -> None:
"""Say, on Images, whether the proposed roles fit every image the check read."""
+ self._show_scan_type()
pre, mapping = self.preflight, self.mapping
if pre is None or not pre.metadata.files:
self.scan_axes_line.value = ""
@@ -203,4 +260,14 @@ def _show_scan_axes(self) -> None:
)
-__all__ = ["AXES_NOTE", "CHANGE_NOTE", "CHOSEN_NOTE", "ROLES_HELP", "_ScanAxes", "roles_words"]
+__all__ = [
+ "AXES_NOTE",
+ "CHANGE_NOTE",
+ "CHOSEN_NOTE",
+ "ROLES_HELP",
+ "TYPE_CONFIRMED_ON",
+ "_ScanAxes",
+ "header_type",
+ "roles_words",
+ "scan_type_html",
+]
diff --git a/src/morphobricks_preprocessing/notebook_step_states.py b/src/morphobricks_preprocessing/notebook_step_states.py
index c48c491..cba8528 100644
--- a/src/morphobricks_preprocessing/notebook_step_states.py
+++ b/src/morphobricks_preprocessing/notebook_step_states.py
@@ -18,7 +18,7 @@
UNCALIBRATED = ("uncalibrated", "pixels (stated)")
"""The X/Y size texts (:func:`.notebook_values.size_text`) that give no physical size."""
ANSWER_STEPS = range(1, 6)
-"""The steps (0-based) whose answers the published file holds: axes (the image type) to save."""
+"""The steps (0-based) whose answers the published file holds: channels (the image type) to save."""
TYPE_WORDS = (("separate channels", "channels"), ("colour", "rgb"))
@@ -72,19 +72,18 @@ def _images_state(self) -> StepState:
return StepState(pre.ok, text)
def _axes_state(self) -> StepState:
- """Return step 2: the confirmed roles, the pixel size and the image type.
+ """Return step 2: the confirmed roles and the pixel size.
- Done once the roles are confirmed and every folder's image type is answered; the chip
- notes an uncalibrated experiment.
+ Done once the roles are confirmed (the image type is on Channels since 12 November
+ 2026); the chip notes an uncalibrated experiment.
"""
mapping = self.confirmed_mapping
sizes, note = self._sizes_words()
if mapping is None:
return StepState(False, "not confirmed")
roles = ", ".join(role for role in mapping.roles if role) + " confirmed"
- types, typed = self._image_type_words()
- text = " · ".join(part for part in (roles, sizes, types) if part)
- return StepState(typed, text, self._changed(1, typed), note=note)
+ text = " · ".join(part for part in (roles, sizes) if part)
+ return StepState(True, text, note=note)
def _image_type_words(self) -> tuple[str, bool]:
"""Return the image types answered, by kind, and whether none is left to answer."""
@@ -158,16 +157,19 @@ def _design_state(self) -> StepState:
return StepState(done, text, self._changed(4, done))
def _channels_state(self) -> StepState:
- """Return step 3: the stains named and every channel of every folder tagged."""
+ """Return step 3: the image type, the stains named and every channel tagged."""
if self._channels_key is None:
return StepState(False, "after the axes")
+ types, typed = self._image_type_words()
+ if not typed:
+ return StepState(False, types, self._changed(2, False))
layouts = self._channel_layouts
if self._stains_not_applicable:
stains = "no stains to name: every folder is colour (RGB)"
elif self.channel_labels.confirmed:
stains = ", ".join(self.channel_labels.labels)
else:
- return StepState(False, "stains not confirmed", self._changed(2, False))
+ return StepState(False, f"{types} · stains not confirmed", self._changed(2, False))
left = remaining(layouts, self.draft_answers.units, self.channel_labels.labels)
done = not left.cells and not left.blocked
tags = (
@@ -175,7 +177,7 @@ def _channels_state(self) -> StepState:
if done
else f"{left.cells} channel(s) still to tag"
)
- return StepState(done, f"{stains} · {tags}", self._changed(2, done))
+ return StepState(done, f"{types} · {stains} · {tags}", self._changed(2, done))
def _masks_state(self) -> StepState:
"""Return step 4: what each mask type is, and whether the relationships are described."""
diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py
index 5bd4eae..3fc4c70 100644
--- a/src/morphobricks_preprocessing/notebook_steps.py
+++ b/src/morphobricks_preprocessing/notebook_steps.py
@@ -4,9 +4,11 @@
10 November 2026, what the files hold first and what it means after:
1. **Images**: the folders, the quick scan, then every header read on an explicit click;
-2. **Axes**: the role of each dimension with the example, the pixel size, and the image type
- (what the channel dimension holds: :mod:`.notebook_image_type_line`);
-3. **Channels**: name the stains, then tag each folder's channels;
+2. **Axes**: the role of each dimension with the example, and the pixel size;
+3. **Channels**: confirm the image type (what the channel dimension holds:
+ :mod:`.notebook_image_type_line`; owner, 12 November 2026: proposed at the quick scan,
+ confirmed here, since it decides whether the channels are R, G and B or the stains), then
+ name the stains and tag each folder's channels;
4. **Masks**: the masks paired with the images (and their axes), what each mask type is and,
once a run recorded them, the mask step that reads every mask and describes how the types
relate (it reads the masks the run recorded, so it cannot come earlier; bricks that use
@@ -52,15 +54,15 @@
"later steps are read only through the roles you confirm.
"
+ ROLES_HELP
+ " The pixel size is the size of one pixel along X and Y, from the image headers or stated "
- "by you; nothing is guessed, and an image without one is uncalibrated.
The image type "
- "says what the channel dimension holds: one stain or marker per channel, or the red, green "
- "and blue of a colour picture. It is proposed from the image headers and recorded only "
- "when you click."
+ "by you; nothing is guessed, and an image without one is uncalibrated."
)
CHANNELS_INTRO = (
- "What happens here: first name what you stained, then say, for every folder, which "
- "stain each channel is, looking at its picture. Nothing is chosen for you: names found in "
- "the image headers are offered only as suggestions, and each one needs your click."
+ "What happens here: first confirm the image type: what the channel dimension holds, "
+ "one stain or marker per channel, or the red, green and blue of a colour picture. It is "
+ "proposed from the image headers and recorded only when you click. Colour pictures have "
+ "the channels R, G and B; otherwise name what you stained, then say, for every folder, "
+ "which stain each channel is, looking at its picture. Nothing is chosen for you: names "
+ "found in the image headers are offered only as suggestions, and each one needs your click."
)
DESIGN_INTRO = (
"What happens here: you say which folders are which condition and biological "
@@ -113,7 +115,7 @@ def steps(self) -> widgets.Widget:
"""
if getattr(self, "_stepper", None) is not None:
return self._stepper.widget
- self.channel_grid.place_types = False # the image type is asked with the axes
+ self.channel_grid.place_types = False # the image type has its own card on Channels
if ui.is_visible(self.channel_grid.widget):
self.channel_grid.refresh()
for box in (self.folder_next, self.axis_next):
@@ -150,12 +152,12 @@ def _step_list(self) -> list[Step]:
pages = [
("Images", "Choose the folders, look at one image, then read every header.",
self._images_page()),
- ("Axes", "What each image dimension is, the pixel size and the image type.",
+ ("Axes", "What each image dimension is, and the pixel size.",
[about(AXES_INTRO), self.copy_note, self.axes_change_note, self.axis_box,
- self.projection_card, self.quick_card, self.zarr_card,
- self.type_line.widget]),
- ("Channels", "Name what you stained, then tag each folder's channels.",
- [about(CHANNELS_INTRO), self.channel_labels.widget, self.channels_look,
+ self.projection_card, self.quick_card, self.zarr_card]),
+ ("Channels", "Confirm the image type, name what you stained, then tag the channels.",
+ [about(CHANNELS_INTRO), self.type_line.widget, self.channel_labels.widget,
+ self.channels_look,
self.channel_grid.widget, self.moves_box, self.names_box]),
("Masks", "What each mask type is; after a run, how the types relate.",
[about(MASKS_INTRO), self.masks_card, self.masks_box, self.mask_step_card]),
@@ -215,7 +217,7 @@ def _wait_for_channels(self) -> str:
return ui.status(
"info",
"Waiting for the image type.",
- ["Say on Axes (step 2) what the channels hold: a folder is tagged after that."],
+ ["Confirm it under Image type below: a folder is tagged after that."],
)
return ""
diff --git a/src/morphobricks_preprocessing/notebook_ui.py b/src/morphobricks_preprocessing/notebook_ui.py
index 82681f8..9eff9b1 100644
--- a/src/morphobricks_preprocessing/notebook_ui.py
+++ b/src/morphobricks_preprocessing/notebook_ui.py
@@ -331,12 +331,12 @@ def axis_step(
intro: str | None = None,
show_next: bool = True,
) -> widgets.Widget:
- """Step 2: axis roles, preview and confirmation, then the image type.
+ """Step 2: axis roles, preview and confirmation.
The defaults give this notebook's step; another brick embedding it can give
its own words, since this notebook's explain the roles by the planes its
- histograms are computed on. The image type question follows the confirmed
- axis roles either way.
+ histograms are computed on. The image type is not asked here: it is confirmed
+ with the channels (owner, 12 November 2026).
Parameters
----------
diff --git a/src/morphobricks_preprocessing/notebook_viewers.py b/src/morphobricks_preprocessing/notebook_viewers.py
index d72c867..af49b2c 100644
--- a/src/morphobricks_preprocessing/notebook_viewers.py
+++ b/src/morphobricks_preprocessing/notebook_viewers.py
@@ -290,9 +290,13 @@ def _preview_of(self, mapping: AxisMapping | None) -> bool:
and viewer.view is not None
)
+ def _cards_show_pictures(self) -> bool:
+ """Return False where the step's viewer shows the images instead of the folder cards."""
+ return getattr(self, "check_viewer", None) is None
+
def _member_picture(self, key: tuple[str, str], detail: widgets.VBox) -> widgets.Widget:
"""Return no picture of a folder where the step's viewer shows any of its images."""
- if getattr(self, "check_viewer", None) is None:
+ if self._cards_show_pictures():
return super()._member_picture(key, detail)
return widgets.HTML("")
diff --git a/tests/test_notebook_axes_working.py b/tests/test_notebook_axes_working.py
new file mode 100644
index 0000000..d03be30
--- /dev/null
+++ b/tests/test_notebook_axes_working.py
@@ -0,0 +1,217 @@
+"""Owner feedback on v0.5.2 (12 November 2026): the axes click shows at once, the image type moves.
+
+1. *Confirm axis roles* (and *Confirm these axes*) was slow and "it feels like nothing is
+ happening": the button is now disabled and says *Working…*, and a progress line under it
+ says *Working…*, before any of the work starts; the parts of the work then advance its bar
+ (n of N, with what it does now). The click no longer reads every folder's picture for the
+ folded per-folder image-type rows.
+2. The image type is **proposed** at the quick scan (display only, from the headers; nothing is
+ recorded there) and **confirmed** on the Channels step; it is no longer on Axes.
+"""
+
+import re
+
+import pytest
+
+from channel_images import write_channels, write_rgb
+from test_notebook_check_save import exp # noqa: F401
+
+pytest.importorskip("ipywidgets")
+pytest.importorskip("matplotlib")
+
+from morphobricks_preprocessing import notebook_axis_preview # noqa: E402
+from morphobricks_preprocessing import notebook_style as ui # noqa: E402
+from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402
+from test_notebook_axes_quick import stepper # noqa: E402
+
+FOLDERS = ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/rep2"]
+WORKING = "Working…"
+AXES, CHANNELS = 1, 2
+
+
+def _text(html):
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html)).strip()
+
+
+def _holds(widget, target):
+ if widget is target:
+ return True
+ return any(_holds(child, target) for child in getattr(widget, "children", ()))
+
+
+def _types(nb):
+ answers = nb.draft_answers
+ return {} if answers is None else {k: u.image_type for k, u in answers.units.items()}
+
+
+def _spy_first_work(monkeypatch, button, progress):
+ """Record the button and its progress line when the first work of the click starts."""
+ seen = []
+ real = notebook_axis_preview.review_axes
+
+ def spy(*args, **kwargs):
+ if not seen:
+ seen.append((button.disabled, button.description, progress.value))
+ return real(*args, **kwargs)
+
+ monkeypatch.setattr(notebook_axis_preview, "review_axes", spy)
+ return seen
+
+
+# --- 1. the click is seen at once ----------------------------------------------------------------
+
+
+@pytest.mark.parametrize("path", ["quick", "roles"])
+def test_the_working_state_is_set_before_the_work_starts(exp, monkeypatch, path): # noqa: F811
+ nb = stepper(*exp)
+ if path == "quick":
+ button, progress = nb.quick_confirm, nb.quick_progress
+ else:
+ nb.preview_checked.set(True)
+ button, progress = nb.confirm_button, nb.confirm_progress
+ assert not button.disabled
+ seen = _spy_first_work(monkeypatch, button, progress)
+ button.click()
+ ((disabled, words, line),) = seen
+ assert disabled and WORKING in words # the button, before any work
+ assert "mb-bar" in line and "0 of" in _text(line) # the folder check's progress line
+ assert nb.confirmed_mapping is not None
+ assert progress.value == "" # cleared once done; the confirmation says the rest
+ if path == "roles":
+ assert not button.disabled and "Change axis roles" in button.description
+
+
+def test_the_progress_bar_advances_through_every_part_of_the_click(exp): # noqa: F811
+ nb = stepper(*exp)
+ lines = []
+ nb.quick_progress.observe(lambda change: lines.append(change["new"]), names="value")
+ nb.quick_confirm.click()
+ steps = notebook_axis_preview.CONFIRM_STEPS
+ shown = [_text(line) for line in lines if line]
+ assert len(steps) > 1 and shown
+ assert shown[0].startswith(WORKING) and f"0 of {len(steps)}" in shown[0]
+ for index, label in enumerate(steps):
+ assert any(label in s and f"{index} of {len(steps)}" in s for s in shown), label
+ widths = [float(w) for w in re.findall(r"width:([\d.]+)%", "".join(lines))]
+ assert widths == sorted(widths) and widths[-1] > widths[0] # the bar only moves on
+ assert lines[-1] == ""
+
+
+def test_taking_the_confirmation_back_shows_working_too(exp, monkeypatch): # noqa: F811
+ nb = stepper(*exp)
+ nb.quick_confirm.click()
+ lines = []
+ nb.confirm_progress.observe(lambda change: lines.append(change["new"]), names="value")
+ nb.confirm_button.click() # Change axis roles
+ assert nb.confirmed_mapping is None
+ assert WORKING in _text(lines[0]) and lines[-1] == ""
+
+
+@pytest.mark.parametrize("path", ["quick", "roles"])
+def test_a_failure_part_way_gives_the_button_back_and_shows_the_error(exp, path): # noqa: F811
+ nb = stepper(*exp)
+
+ def boom():
+ raise RuntimeError("the disk went away")
+
+ nb._refresh_run_step = boom # part 4 of 5: after the roles are confirmed
+ if path == "quick":
+ button = nb.quick_confirm
+ else:
+ nb.preview_checked.set(True)
+ button = nb.confirm_button
+ button.click()
+ assert not button.disabled and WORKING not in button.description # never stuck
+ assert WORKING not in nb.quick_confirm.description + nb.confirm_button.description
+ shown = [p for p in (nb.quick_progress, nb.confirm_progress) if p.value]
+ assert len(shown) == 1
+ (said,) = shown
+ assert "mb-error" in said.value
+ assert "RuntimeError: the disk went away" in _text(said.value)
+ card = nb.quick_card if said is nb.quick_progress else nb.axis_box
+ assert ui.is_visible(card) # said where it is seen, not under a hidden card
+ assert "the disk went away" in nb.details.value # the full report in Technical details
+
+
+def test_a_disabled_confirmation_does_nothing_and_shows_no_progress(exp): # noqa: F811
+ nb = stepper(*exp)
+ assert nb.confirm_button.disabled # the example is not ticked
+ nb.confirm_button.click()
+ assert nb.confirmed_mapping is None and nb.confirm_progress.value == ""
+ assert "Confirm axis roles" in nb.confirm_button.description
+
+
+def test_the_click_reads_no_picture_for_the_folded_per_folder_rows(exp, monkeypatch): # noqa: F811
+ nb = stepper(*exp)
+ nb.quick_confirm.click()
+ thumbs = nb._channel_thumbs
+ assert thumbs is not None and not thumbs._strips # no folder's channels were read
+ nb.type_line.per_folder.selected_index = 0 # Set per folder… opened: now they are
+ assert thumbs._strips and "no picture" not in repr(nb.type_rows.rows.children)
+
+
+# --- 2. the image type: proposed at the scan, confirmed on Channels ------------------------------
+
+
+def _colour_exp(tmp_path):
+ root = tmp_path / "exp"
+ for i, folder in enumerate(FOLDERS):
+ write_rgb(root / folder / "a.tif", seed=i + 1)
+ return root, tmp_path / "out"
+
+
+def test_the_image_type_is_not_on_axes_and_is_confirmed_on_channels(exp): # noqa: F811
+ nb = stepper(*exp)
+ pages = nb._stepper.pages
+ assert not _holds(pages[AXES], nb.type_line.widget)
+ assert not _holds(pages[AXES], nb.image_type_card) # nor the old card
+ assert _holds(pages[CHANNELS], nb.type_line.widget)
+ page = pages[CHANNELS].children[1].children[1].children
+ assert list(page).index(nb.type_line.widget) < list(page).index(nb.channel_labels.widget)
+ nb.quick_confirm.click()
+ assert nb._stepper.states()[AXES].done # Axes asks nothing about the type
+ assert not nb._stepper.states()[CHANNELS].done
+ assert set(_types(nb).values()) <= {None} # pre-filled, not recorded
+ nb.type_line.apply.click() # the explicit click, on Channels
+ assert set(_types(nb).values()) == {"channels"}
+
+
+def test_the_image_type_is_proposed_at_the_scan_and_nothing_is_recorded(tmp_path):
+ root, out = _colour_exp(tmp_path)
+ nb = PreprocessingNotebook(str(root), output_folder=str(out))
+ nb.steps()
+ nb.scan_button.click()
+ # Before the folder check: the shown image's header, display only.
+ note = _text(nb.scan_type_note.value)
+ assert "Image type , from this image's header: colour (RGB)" in note
+ assert "nothing is recorded here" in note and "Channels (step 3)" in note
+ assert nb.preflight is None and nb.draft is None
+ assert not out.exists() or not any(out.iterdir())
+ nb.check_button.click()
+ # After it: every header, display only; nothing recorded until the click on Channels.
+ line = _text(nb.scan_type_line.value)
+ assert "Image type proposed from the headers: colour (RGB) in all 4 images" in line
+ assert _holds(nb._stepper.pages[0], nb.scan_type_line)
+ assert set(_types(nb).values()) <= {None}
+ nb.quick_confirm.click()
+ assert nb.type_line.choice.value == "rgb" # the same headers pre-fill the Channels line
+ assert set(_types(nb).values()) <= {None}
+ nb.type_line.apply.click()
+ assert set(_types(nb).values()) == {"rgb"}
+
+
+def test_headers_that_differ_are_said_at_the_scan_with_the_refusal(tmp_path):
+ root = tmp_path / "exp"
+ for i, folder in enumerate(FOLDERS):
+ if folder == "drug/rep2":
+ write_rgb(root / folder / "a.tif", seed=i + 1)
+ else:
+ write_channels(root / folder / "a.tif", ["DAPI", "Actin", "Ch3"], ["blobs"] * 3, seed=i)
+ nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"))
+ nb.steps()
+ nb.scan_button.click()
+ nb.check_button.click()
+ line = _text(nb.scan_type_line.value)
+ assert "The image headers differ: colour (RGB) in 1 image, separate channels in 3" in line
+ assert "R_HETEROGENEOUS_LAYOUT_NOT_ENABLED" in line and ui.is_visible(nb.scan_box)
+ assert set(_types(nb).values()) <= {None}
diff --git a/tests/test_notebook_colour.py b/tests/test_notebook_colour.py
index 380c2aa..4b4615c 100644
--- a/tests/test_notebook_colour.py
+++ b/tests/test_notebook_colour.py
@@ -101,6 +101,8 @@ def test_every_colour_folder_is_one_colour_picture_in_the_viewer_and_on_image_ty
assert len(shown) == 1 and viewer.view.colour # one picture, not three grey planes
assert "mb-colour" in shown[0]._dom_classes and _colour(shown[0].value)
assert "one colour picture of the 3 channels" in viewer.caption.value
+ assert not list(_images(rgb_nb.type_rows.widget)) # folded: no picture read (12 Nov)
+ rgb_nb.type_line.per_folder.selected_index = 0 # Set per folder… opened
types = [w for w in _images(rgb_nb.type_rows.widget)]
assert len(types) == len(FOLDERS) and all(_colour(w.value) for w in types)
assert "channel 1 red, 2 green, 3 blue" in repr(rgb_nb.type_rows.rows.children)
diff --git a/tests/test_notebook_seven_steps.py b/tests/test_notebook_seven_steps.py
index c3423a2..f5754e5 100644
--- a/tests/test_notebook_seven_steps.py
+++ b/tests/test_notebook_seven_steps.py
@@ -1,7 +1,8 @@
"""The 7-step flow of the shipped notebook (owner, 10 November 2026, items (o), (p) and (q)).
-- the image type is asked with the axes, in one line pre-filled from the image headers and
- recorded only by a click; each folder on demand; folders that differ are said at once;
+- the image type is asked in one line pre-filled from the image headers and recorded only by
+ a click; each folder on demand; folders that differ are said at once (on Channels since the
+ owner's decision of 12 November 2026);
- the conditions, the replicates and "compared against" are one grid with one confirmation;
- the mask step (*Describe the masks*) is on the Masks step and waits, visibly, for a run.
@@ -89,13 +90,14 @@ def test_the_header_s_type_is_a_proposal_saved_only_by_the_click(exp): # noqa:
assert "Proposed from the image headers: separate channels in all 4 folders" in said
assert "Not saved until you click Use for all folders" in said
assert set(_types(nb).values()) <= {None} and not line.complete # nothing was applied
- assert not nb._stepper.states()[1].done
+ assert nb._stepper.states()[1].done # Axes is done: the type is on Channels (12 Nov)
+ assert not nb._stepper.states()[2].done and "image type" in nb._stepper.states()[2].text
assert ui.is_visible(line.apply) and not line.apply.disabled
line.apply.click()
assert set(_types(nb).values()) == {"channels"} and len(_types(nb)) == 4 and line.complete
assert "Image type: separate channels in all 4 folders." in _text(line.line.value)
assert not ui.is_visible(line.apply) # nothing left to record
- assert nb._stepper.states()[1].done
+ assert "separate channels (4 folders)" in nb._stepper.states()[2].text
def test_one_click_for_all_folders_records_what_each_folder_s_choice_records(exp, tmp_path): # noqa: F811
@@ -234,7 +236,7 @@ def test_the_channels_step_waits_for_the_image_type_and_says_where(exp): # noqa
nb.quick_confirm.click()
nb._stepper.go(2)
assert "Waiting for the image type" in nb._stepper.waits[2].value
- assert "Axes (step 2)" in nb._stepper.waits[2].value
+ assert "under Image type below" in nb._stepper.waits[2].value # on this page (12 Nov)
nb.type_line.apply.click()
nb._stepper.go(2)
assert nb._stepper.waits[2].value == ""
diff --git a/tests/test_notebook_stains_na.py b/tests/test_notebook_stains_na.py
index 7777fcf..b506622 100644
--- a/tests/test_notebook_stains_na.py
+++ b/tests/test_notebook_stains_na.py
@@ -70,7 +70,7 @@ def test_an_all_rgb_experiment_completes_stains_and_check_and_save_without_a_lab
_answer(nb)
stains = _state(nb, STAINS)
assert stains.done
- assert stains.text.startswith("no stains to name: every folder is colour (RGB) · every channel")
+ assert stains.text.startswith("colour (4 folders) · no stains to name: every folder is colour")
assert "mb-chip-done" 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
@@ -103,7 +103,7 @@ 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 == "stains not confirmed"
+ assert not stains.done and stains.text == "image type: 4 to choose" # asked first here
assert ui.is_visible(nb.channel_labels.body[0])
assert "Not applicable" not in nb.channel_labels.na_note.value
diff --git a/tests/test_notebook_stepper.py b/tests/test_notebook_stepper.py
index 9fe9498..06c9554 100644
--- a/tests/test_notebook_stepper.py
+++ b/tests/test_notebook_stepper.py
@@ -138,14 +138,16 @@ def test_the_pages_hold_the_existing_cards_in_the_new_places(exp): # noqa: F811
nb = stepped(*exp)
contains = lambda i, w: _holds(nb._stepper.pages[i], w) # noqa: E731
assert contains(0, nb.check_button) and contains(0, nb.scan_button)
- # Axes: the roles, the pixel size and the image type (what the channel dimension holds).
+ # Axes: the roles and the pixel size; no image type there (owner, 12 November 2026).
assert contains(AXES, nb.axis_box) and contains(AXES, nb.calibration_card)
assert _holds(nb.axis_box, nb.calibration_card) and contains(AXES, nb.quick_card)
- assert contains(AXES, nb.type_line.widget) and contains(AXES, nb.type_rows.widget)
- # Channels: the stains, the tags, then the saved channels that moved (#258) or lost a
- # header name (#281).
+ assert not contains(AXES, nb.type_line.widget) and not contains(AXES, nb.type_rows.widget)
+ # Channels: the image type first (it decides R, G, B or the stains), the stains, the tags,
+ # then the saved channels that moved (#258) or lost a header name (#281).
+ assert contains(CHANNELS, nb.type_line.widget) and contains(CHANNELS, nb.type_rows.widget)
page = nb._stepper.pages[CHANNELS].children[1].children[1].children
- assert list(page[-5:]) == [
+ assert list(page[-6:]) == [
+ nb.type_line.widget,
nb.channel_labels.widget,
nb.channels_look, # the one viewer, between the stains and the tags
nb.channel_grid.widget,
@@ -176,7 +178,7 @@ def _holds(widget, target):
return any(_holds(child, target) for child in getattr(widget, "children", ()))
-def test_the_image_type_is_asked_with_the_axes_and_the_grid_has_no_type_column(exp): # noqa: F811
+def test_the_image_type_is_asked_on_channels_and_the_grid_has_no_type_column(exp): # noqa: F811
nb = stepped(*exp)
for row, text in zip(nb.channel_labels.rows, TAGS, strict=True):
row.id_box.value = text
@@ -241,7 +243,8 @@ def test_a_changed_reopen_turns_the_affected_steps_orange(tmp_path):
states = nb._stepper.states()
assert "since saved: 1 new, 0 gone" in states[0].text
assert states[CHANNELS].changed and states[SAVE].changed # the new folder's tags; save again
- assert states[AXES].changed and "image type: 1 to choose" in states[AXES].text
+ assert "image type: 1 to choose" in states[CHANNELS].text # the type is on Channels
+ assert "image type" not in states[AXES].text
assert not states[DESIGN].changed and states[DESIGN].done # the names did not change
assert kinds(nb)[CHANNELS] == "changed" and kinds(nb)[SAVE] == "changed"
assert nb._stepper.current == AXES # landed on the axes: each session confirms them again
@@ -286,12 +289,13 @@ def test_the_axes_page_has_no_stale_image_type_sentence_and_no_broken_picture(ex
def test_pixel_sizes_without_a_physical_size_say_uncalibrated(exp): # noqa: F811
nb = stepped(*exp)
state = nb._stepper.states()[AXES] # the pixel size is part of the axes step
- assert not state.done and "image type: 4 to choose" in state.text # and the image type
+ assert state.done and "image type" not in state.text # the type is on Channels (12 Nov)
+ assert "image type: 4 to choose" in nb._stepper.states()[CHANNELS].text
nb.type_line.apply.click()
state = nb._stepper.states()[AXES]
assert state.done and state.note == "uncalibrated"
- assert "confirmed · uncalibrated (pixels)" in state.text
- assert state.text.endswith("separate channels (4 folders)")
+ assert state.text.endswith("uncalibrated (pixels): no pixel size in the headers or stated")
+ assert "separate channels (4 folders)" in nb._stepper.states()[CHANNELS].text
nb._stepper.go(0)
assert nb._stepper.chips[1].description == "✓ 2 Axes · uncalibrated"
assert kinds(nb)[1] == "noted"
@@ -310,7 +314,8 @@ def test_pixel_sizes_from_the_headers_are_a_plain_done(tmp_path):
nb.check_button.click()
assert not nb._stepper.states()[1].done # the axes are not confirmed yet
nb.quick_confirm.click()
- assert not nb._stepper.states()[1].done # the image type is still a proposal
+ assert nb._stepper.states()[1].done # the image type is asked on Channels now
+ assert not nb._stepper.states()[2].done # where it is still a proposal
nb.type_line.apply.click()
nb._stepper.go(0)
state = nb._stepper.states()[1]
diff --git a/tests/test_notebook_tag_blocks.py b/tests/test_notebook_tag_blocks.py
index 9a1e3ae..f334167 100644
--- a/tests/test_notebook_tag_blocks.py
+++ b/tests/test_notebook_tag_blocks.py
@@ -136,12 +136,12 @@ def test_colour_is_confirmed_on_image_type_only_and_the_tag_page_restates_it(tmp
grid = nb.channel_grid
assert not grid.confirm_buttons # no second confirmation of the colour pre-tick
text = repr(grid.blocks.children)
- assert "confirm it on Axes (Image type)" in text
+ assert "confirm it under Image type, above" in text
buttons = [w for w in nb.type_rows.rows.children if isinstance(w, widgets.Button)]
(button,) = [w for w in buttons if "colour (RGB) is right" in w.description]
button.click()
assert all(nb.draft_answers.units[k].image_type == "rgb" for k in KEYS)
- assert "Answered colour (RGB) (Axes)" in repr(grid.blocks.children)
+ assert "Answered colour (RGB) (Image type, above)" in repr(grid.blocks.children)
assert not grid.cells and grid.complete