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