From 05c0b4f2471677767aa376351cf196f990e39eed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 10:07:23 +0000 Subject: [PATCH 1/2] Axes: show the confirmation at once; image type proposed at the scan, confirmed on Channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner feedback on the v0.5.2 Colab test (12 November 2026): - Confirm these axes / Confirm axis roles: the button is disabled and says Working…, and the folder check's progress line appears under it before any work, then advances through the 5 parts of the confirmation (n of 5). - The click read every channel of every folder's first image for the folded per-folder image-type rows, and rebuilt the Design pairing editor for pictures the stepper never shows. The rows now read their pictures only once opened, and the editor is not rebuilt there (28 folders of 3 x 2048^2: 93 plane reads -> 9, 1,439 widgets built -> 1,037). Nothing validated changed. - The image type is no longer on Axes: proposed at the quick scan (display only, from the headers; nothing recorded there) and confirmed first on the Channels step. The rules are unchanged; published files are unchanged for the same answers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- CHANGELOG.md | 23 +++ docs/colab-release-check.md | 15 +- docs/contract.md | 2 +- notebooks/MorphoBricks_Preprocessing.ipynb | 4 +- .../notebook_axes_quick.py | 16 +- .../notebook_axis_preview.py | 75 ++++++- .../notebook_axis_step.py | 8 +- .../notebook_channels_grid.py | 4 +- .../notebook_channels_type.py | 35 +++- .../notebook_check_view.py | 2 +- .../notebook_compact.py | 1 + .../notebook_described.py | 12 +- .../notebook_image_type_line.py | 15 +- .../notebook_scan_axes.py | 73 ++++++- .../notebook_step_states.py | 22 +- .../notebook_steps.py | 36 ++-- src/morphobricks_preprocessing/notebook_ui.py | 6 +- .../notebook_viewers.py | 6 +- tests/test_notebook_axes_working.py | 191 ++++++++++++++++++ tests/test_notebook_colour.py | 2 + tests/test_notebook_seven_steps.py | 12 +- tests/test_notebook_stains_na.py | 4 +- tests/test_notebook_stepper.py | 27 ++- tests/test_notebook_tag_blocks.py | 4 +- 24 files changed, 511 insertions(+), 84 deletions(-) create mode 100644 tests/test_notebook_axes_working.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d06c3907..c8cd1284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ 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. +- **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 eabb28db..e8c18d43 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 6d665209..c1d78610 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 a125d9aa..08b2d574 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 d84fccd1..8f769203 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,15 @@ 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 _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 47c441d9..936fd57c 100644 --- a/src/morphobricks_preprocessing/notebook_axis_preview.py +++ b/src/morphobricks_preprocessing/notebook_axis_preview.py @@ -3,6 +3,9 @@ from __future__ import annotations import html +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 +20,67 @@ 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. + """ + 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)) + try: + return action() + finally: + self._confirm_wait = self._working_on = None + progress.value = "" + button.description, button.icon = saved + button.disabled = False + self._after_working(button) + + 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 +271,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 +317,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 +417,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 04a0c35c..175764a4 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 4150e190..ddcb8de4 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 26a0abec..f6e35616 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 778ca319..45113154 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 adb53430..c07a0fbe 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 b2d412a7..3c0382a3 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 dd34a245..e1c22157 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 1386eb31..ea060f96 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 c48c491b..cba85288 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 5bd4eae6..3fc4c701 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 82681f89..9eff9b12 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 d72c8670..af49b2c1 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 00000000..b932ebd0 --- /dev/null +++ b/tests/test_notebook_axes_working.py @@ -0,0 +1,191 @@ +"""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] == "" + + +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 380c2aad..4b4615cf 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 c3423a23..f5754e58 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 7777fcf5..b5066220 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 9fe94989..06c9554b 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 9a1e3aeb..f3341672 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 From ec04490a2058c029f4de7baff50fbd8328c31938 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 10:43:07 +0000 Subject: [PATCH 2/2] Axes: a confirmation that fails part-way says so, where it is seen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Working… state of #292 cleared its progress line and gave the button back when the confirmation raised part-way, but said nothing: the line went blank, the confirmation above it still said the axes were checked, and after Confirm these axes the one-click card had already hidden. The failure is now caught: the button is given back as before, the error is said in red under the button clicked (under Confirm axis roles once the one-click card has gone), and the traceback goes to Technical details, as the run and the mask step do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- CHANGELOG.md | 3 ++ .../notebook_axes_quick.py | 6 ++++ .../notebook_axis_preview.py | 28 +++++++++++++++++-- tests/test_notebook_axes_working.py | 26 +++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8cd1284..46f0c931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ v0.5.1: no run before v0.5.1 is read.) 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 diff --git a/src/morphobricks_preprocessing/notebook_axes_quick.py b/src/morphobricks_preprocessing/notebook_axes_quick.py index 8f769203..ae622dcd 100644 --- a/src/morphobricks_preprocessing/notebook_axes_quick.py +++ b/src/morphobricks_preprocessing/notebook_axes_quick.py @@ -190,6 +190,12 @@ def _after_working(self, button: widgets.Button) -> None: 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 936fd57c..406559c1 100644 --- a/src/morphobricks_preprocessing/notebook_axis_preview.py +++ b/src/morphobricks_preprocessing/notebook_axis_preview.py @@ -3,6 +3,7 @@ from __future__ import annotations import html +import traceback from collections.abc import Callable import ipywidgets as widgets @@ -52,7 +53,8 @@ def _working( (: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. + 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 @@ -61,14 +63,36 @@ def _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 - self._after_working(button) + 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.""" diff --git a/tests/test_notebook_axes_working.py b/tests/test_notebook_axes_working.py index b932ebd0..d03be307 100644 --- a/tests/test_notebook_axes_working.py +++ b/tests/test_notebook_axes_working.py @@ -107,6 +107,32 @@ def test_taking_the_confirmation_back_shows_working_too(exp, monkeypatch): # no 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