From 0afca0a9f157b4ec88dde5cf6c15d64f39270727 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 16:45:34 +0000 Subject: [PATCH 1/2] Notebook: the axes step shows an example of the data as the roles read it; pixel size in one line Owner test of v0.5.0 (10 November 2026), items (e) and (f); the per-unit pixel size menu. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- CHANGELOG.md | 33 ++ pyproject.toml | 2 + .../axis_example.py | 98 ++++++ .../notebook_axes_quick.py | 72 ++-- .../notebook_axis_preview.py | 45 ++- .../notebook_axis_step.py | 101 +++--- .../notebook_calibration.py | 134 +++++-- .../notebook_channels_grid.py | 4 +- .../notebook_channels_labels.py | 4 +- .../notebook_check_save.py | 12 +- .../notebook_check_view.py | 4 +- .../notebook_convert.py | 4 +- .../notebook_mask_question.py | 2 +- .../notebook_mask_step.py | 2 +- .../notebook_masks_screen.py | 2 +- .../notebook_plots.py | 52 +++ .../notebook_step_states.py | 48 +-- .../notebook_steps.py | 41 ++- .../notebook_style.py | 1 + .../pixel_size_summary.py | 167 +++++++++ tests/test_notebook_axes_example.py | 328 ++++++++++++++++++ tests/test_notebook_axes_quick.py | 31 +- tests/test_notebook_check_save.py | 4 +- tests/test_notebook_convert.py | 2 +- tests/test_notebook_mask_step.py | 2 +- tests/test_notebook_owner_fixes.py | 18 +- tests/test_notebook_prerelease.py | 12 +- tests/test_notebook_row_fold.py | 2 +- tests/test_notebook_stains_na.py | 6 +- tests/test_notebook_stepper.py | 64 ++-- tests/test_notebook_tag_blocks.py | 4 +- tests/test_notebook_ui.py | 8 +- tools/code_rules_baseline.json | 2 +- 33 files changed, 1054 insertions(+), 257 deletions(-) create mode 100644 src/morphobricks_preprocessing/axis_example.py create mode 100644 src/morphobricks_preprocessing/pixel_size_summary.py create mode 100644 tests/test_notebook_axes_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5982f910..9454ee78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,39 @@ v0.5.1: no run before v0.5.1 is read.) ## Unreleased (v0.5.1) +### Notebook redesign 3: the axes step shows the data as the roles read it; pixel size in one line + +Owner test of v0.5.0 (10 November 2026), items (e) and (f). The shipped notebook has 12 steps: +*Pixel sizes* is part of *Axes*. + +- **An example of one image as the chosen roles read it**, drawn by the folder check itself and + redrawn whenever a role changes (`axis_example.example_rows`, `notebook_plots.example_png`): + one tile per channel labelled `c=…` (six evenly spread beyond six), a few time points + `t=…` or depth slices `z=…`, or the single plane, each with its height and width and its + true proportions, so a wrong role looks wrong. The image and the other planes are under + **Another image, or other planes**. **Show preview** is gone (its button is hidden and still + works for the bricks that click it). The planes are read as before: with the roles being + edited, only the image as the folder check read it, labelled *not confirmed yet*. +- **The confirmation is unchanged and explicit.** The roles are confirmed by **Yes, the example + looks right** and **Confirm axis roles**, or by the one-click **Confirm these axes** under the + same conditions as before (owner decision Q3); nothing is confirmed without the click. The + role boxes and the example are always shown; the *Look closer, or change the roles* fold and + the separate *Suggested axes* card are gone. +- **Pixel size in one line, with the axes** (`pixel_size_summary`): "Pixel size: 0.108 × 0.108 + µm (from the headers, same in all 270 images)". When nothing gives a size, one input states + it for the whole experiment (**Use for all images**: the user's explicit statement, written + to `calibration.csv` for `.` as the `calibrate` command does, replacing other X and Y + statements; Z statements are kept). When the images do not agree the line says so with the + counts and folders ("Headers disagree: 0.108 × 0.108 µm in 180 images, 0.216 × 0.216 µm in + 90 (Condition_2/Biological_repeat_2)") and **Set per imaging unit…** opens: the size of each + folder and the form to state one for a folder or an image. Nothing is chosen silently: each + image is recorded with its header's size until the user states another, and a stated size + that differs from a header is still warned about (`calibration_differs_from_header`). The + rules of `calibration.csv` and what the manifest records per image are unchanged; the + experiment description holds no pixel size (Spec experiment-v1). +- Texts of the notebook no longer carry step numbers after step 3 ("the run can start", "on + Image type"), so they stay right when steps merge. + ### Notebook redesign 2: progress for every wait, and a quick scan before the headers Owner test of v0.5.0 (10 November 2026), items (c) and (d). diff --git a/pyproject.toml b/pyproject.toml index 4d6a5f2e..e1987048 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,8 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.axes" = "pure" "morphobricks_preprocessing.axis_check" = "pure" "morphobricks_preprocessing.axis_names" = "pure" +"morphobricks_preprocessing.axis_example" = "pure" +"morphobricks_preprocessing.pixel_size_summary" = "pure" "morphobricks_preprocessing.image_names" = "pure" "morphobricks_preprocessing.mask_names" = "pure" "morphobricks_preprocessing.name_issues" = "pure" diff --git a/src/morphobricks_preprocessing/axis_example.py b/src/morphobricks_preprocessing/axis_example.py new file mode 100644 index 00000000..5c648aee --- /dev/null +++ b/src/morphobricks_preprocessing/axis_example.py @@ -0,0 +1,98 @@ +"""Which planes show the axis roles being edited: the example of the notebook's axes step. + +The owner's test of v0.5.0 (10 November 2026, item (e)): the role boxes only listed lengths. +The step now shows an example of one image *as the chosen roles read it*, redrawn whenever a +role changes: one tile per channel, a few time points, a few depth slices, or the single plane. +A wrong role then looks wrong (a length-3 axis chosen as Y gives a strip three pixels high). + +This module only chooses the planes and labels them; it reads nothing and guesses nothing: +the roles are the ones the user is editing, and the example is display only. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass + +CHANNEL_TILES = 6 +"""The most channels shown side by side (evenly spread over the channel axis beyond that).""" +STEP_TILES = 4 +"""The most time points, or depth slices, shown side by side.""" +ROW_NAMES = {"C": "Channels (C)", "T": "Time points (T)", "Z": "Depth slices (Z)"} + + +@dataclass(frozen=True) +class Tile: + """One plane of the example: its label and the coordinates it is read at.""" + + label: str + coords: tuple[tuple[str, int], ...] + + def at(self) -> dict[str, int]: + """Return the coordinates as ``{"z": ..., "c": ..., "t": ...}`` (only the axes there).""" + return dict(self.coords) + + +@dataclass(frozen=True) +class ExampleRow: + """One row of tiles along one role (``""`` for the single plane of an image without one).""" + + role: str + title: str + tiles: tuple[Tile, ...] + + +def spread(length: int, count: int) -> list[int]: + """Return at most ``count`` positions of ``range(length)``, evenly spread, ends included.""" + if length <= count: + return list(range(length)) + if count == 1: + return [0] + return sorted({round(i * (length - 1) / (count - 1)) for i in range(count)}) + + +def example_rows(lengths: Mapping[str, int], at: Mapping[str, int]) -> list[ExampleRow]: + """Return the rows of the example for an image with these role lengths. + + Parameters + ---------- + lengths + ``{role: length}`` of the image's Z, C and T axes under the roles being edited; a role + the image has no axis for is left out. + at + The position along each of those roles at which the *other* rows are read (the + preview's sliders), e.g. the time point of the row of channels. + + Returns + ------- + list of ExampleRow + A row of channels (C), then a row of time points (T), then one of depth slices (Z), + each only for an axis longer than one; a single row with one tile when there is none. + """ + base = {role.lower(): min(at.get(role, 0), length - 1) for role, length in lengths.items()} + rows = [] + for role, limit in (("C", CHANNEL_TILES), ("T", STEP_TILES), ("Z", STEP_TILES)): + length = lengths.get(role, 1) + if length <= 1: + continue + shown = spread(length, limit) + name = role.lower() + tiles = tuple(Tile(f"{name}={i}", tuple(sorted({**base, name: i}.items()))) for i in shown) + title = ROW_NAMES[role] + if len(shown) < length: + title += f": {len(shown)} of {length}" + rows.append(ExampleRow(role, title, tiles)) + if not rows: + rows.append(ExampleRow("", "The plane", (Tile("", tuple(sorted(base.items()))),))) + return rows + + +__all__ = [ + "CHANNEL_TILES", + "ROW_NAMES", + "STEP_TILES", + "ExampleRow", + "Tile", + "example_rows", + "spread", +] diff --git a/src/morphobricks_preprocessing/notebook_axes_quick.py b/src/morphobricks_preprocessing/notebook_axes_quick.py index ea431f10..90aa5fd9 100644 --- a/src/morphobricks_preprocessing/notebook_axes_quick.py +++ b/src/morphobricks_preprocessing/notebook_axes_quick.py @@ -1,8 +1,9 @@ """*Confirm these axes*: the axes step in one click, when nothing about the axes is open. Owner decision Q3 (UX proposal 2026-11-09): when the suggested roles are complete and nothing -in them is uncertain, the axes step shows them with a preview and one **Confirm these axes** -button; the role boxes are folded under *Look closer, or change the roles*. On a reopened +in them is uncertain, the axes step offers one **Confirm these axes** button under the role +boxes and the example (redesign of 10 November 2026: the boxes and the example are always +shown; the button replaces the tick and *Confirm axis roles* while it is offered). On a reopened experiment whose folders are unchanged, the roles the earlier run recorded are the ones offered, so a reopening no longer stops on the axes. @@ -27,50 +28,31 @@ from .axis_review import review_axes from .description_reconcile import CHANNELS_CHANGED from .fingerprint import unchanged_confirmation - -DETAILS_OPEN, DETAILS_CLOSED = " Hide the role boxes", " Look closer, or change the roles" +from .notebook_render import blocked_line class _QuickAxes: """The one-click confirmation card of the stepper's axes step.""" def _build_quick_axes(self) -> None: - """Build the card (hidden until roles can be offered) and the fold of the role boxes.""" + """Build the one-click confirmation (hidden until roles can be offered).""" self._quick_offer: AxisMapping | None = None self._quick_filling = False # The check whose recorded roles were offered: once offered, a role the user changes is # theirs, and the recorded roles are not put back into the boxes. self._quick_seeded: object | None = None self.quick_text = widgets.HTML() - self.quick_image = widgets.Image(format="png", layout=widgets.Layout(max_width="420px")) - ui.set_visible(self.quick_image, False) self.quick_caption = widgets.HTML() self.quick_confirm = ui.button( " Confirm these axes", style="success", icon="check", - tooltip="The preview looks right: use these roles for every image", + 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 = ui.card( - "Suggested axes", - [ - self.quick_text, - widgets.HBox([self.quick_image]), - self.quick_caption, - self.quick_confirm, - ], - visible=False, - ) - self.axes_details = ui.button(DETAILS_CLOSED, style="info", icon="sliders") - self.axes_details.on_click(lambda _: self._toggle_axes_details()) - ui.set_visible(self.axes_details, False) - - def _toggle_axes_details(self) -> None: - shown = not ui.is_visible(self.axis_box) - ui.set_visible(self.axis_box, shown) - self.axes_details.description = DETAILS_OPEN if shown else DETAILS_CLOSED + self.quick_card = widgets.VBox([self.quick_text, self.quick_confirm, self.quick_caption]) + ui.set_visible(self.quick_card, False) # What can be offered ------------------------------------------------------------------- @@ -111,18 +93,24 @@ def _quick_mapping(self) -> AxisMapping | None: # Showing ----------------------------------------------------------------------------- - def _refresh_quick_axes(self) -> None: - """Offer the roles for one click, or show the role boxes open.""" + def _refresh_quick_axes(self, drawn: bool = False) -> None: + """Offer the roles for one click, or the tick and *Confirm axis roles*. + + The example is read again here (the image as the folder check read it), unless + ``drawn`` says the caller has just drawn it. + """ if getattr(self, "quick_card", None) is None or self._quick_filling: return offer = None if self.confirmed_mapping is not None else self._quick_mapping() self._quick_offer = offer if offer is not None and self._saved_axes() is not None: self._quick_seeded = self.preflight # the recorded roles go into the boxes once - ui.set_visible(self.quick_card, offer is not None) - ui.set_visible(self.axes_details, offer is not None) - ui.set_visible(self.axis_box, offer is None) - self.axes_details.description = DETAILS_CLOSED + offered = offer is not None + ui.set_visible(self.quick_card, offered) + # One click when nothing is open; else the tick and Confirm axis roles (also to change). + for widget in (self.preview_checked.widget, self.preview_blocker, self.confirm_button, + self.confirm_blocker): # fmt: skip + ui.set_visible(widget, not offered) if offer is None: return if offer.roles != self.mapping.roles: @@ -133,19 +121,12 @@ def _refresh_quick_axes(self) -> None: finally: self._quick_filling = False self.quick_text.value = self._quick_text(offer) - self.show_preview() + if not drawn: + self.show_preview() shown = self._quick_preview_shown(offer) - self.quick_image.value = self.preview_image.value if shown else b"" - ui.set_visible(self.quick_image, shown) self.quick_confirm.disabled = not shown - self.quick_caption.value = self.preview_caption.value + ( - "" - if shown - else ui.status( - "warn", - "Confirm these axes is not offered: no preview of these roles is shown.", - ["Open the role boxes below to look closer or change the roles."], - ) + self.quick_caption.value = blocked_line( + [] if shown else ["no example of these roles is drawn: see the message under it"] ) def _quick_preview_shown(self, offer: AxisMapping) -> bool: @@ -169,8 +150,7 @@ def _quick_text(self, offer: AxisMapping) -> str: f"Suggested: {roles}.", [ *self._headers_changed_line(), - f"These are {source}; the axis check finds nothing to ask about them.", - "Look at the preview: if it looks like a normal image of your sample, confirm. " + f"These are {source}. If the example shows your sample as you expect, confirm. " "Nothing is confirmed until you click.", ], raw=True, @@ -210,4 +190,4 @@ def _confirm_quick_axes(self) -> bool: return self.confirmed_mapping is not None -__all__ = ["DETAILS_CLOSED", "DETAILS_OPEN", "_QuickAxes"] +__all__ = ["_QuickAxes"] diff --git a/src/morphobricks_preprocessing/notebook_axis_preview.py b/src/morphobricks_preprocessing/notebook_axis_preview.py index c8596f82..0dfa0884 100644 --- a/src/morphobricks_preprocessing/notebook_axis_preview.py +++ b/src/morphobricks_preprocessing/notebook_axis_preview.py @@ -6,10 +6,12 @@ from . import notebook_style as ui from .axes import AxisMapping, axis_name, file_mapping_for +from .axis_example import example_rows from .axis_review import review_axes from .image_type import review_image_type from .notebook_options import IMAGE_TYPE_NAMES -from .notebook_plots import plane_png +from .notebook_plots import example_png, tile_label +from .notebook_progress import Wait from .notebook_render import blocked_line, preview_caption from .planes import Plane, PlaneReadError, read_plane from .preview import PreviewRefused, check_unchanged, checked_fingerprint @@ -104,7 +106,7 @@ def _gate_preview_tick(self) -> None: self.preview_blocker.value = ( "" if locked or shown - else blocked_line(["no preview of these roles is drawn: click Show preview"]) + else blocked_line(["no example of these roles is drawn: see the message above"]) ) if not locked: self._update_confirm_button() @@ -119,13 +121,14 @@ def _draw_preview(self) -> None: self.preview_caption.value = ui.status("warn", "No image fits these axis roles.") return file_mapping = file_mapping_for(header, self.mapping) - coords = { - role.lower(): self.preview_sliders[role].value + lengths = { + role: header.shape[dim] for role in ("Z", "C", "T") - if file_mapping.index_of(role) is not None + if (dim := file_mapping.index_of(role)) is not None } + at = {role: self.preview_sliders[role].value for role in lengths} try: - plane = self._bound_plane(header.relative_path, file_mapping, coords) + rows, shown = self._read_example(header.relative_path, file_mapping, lengths, at) except (PreviewRefused, PlaneReadError, IndexError, ValueError, OSError) as exc: self.preview_image.value = b"" ui.set_visible(self.preview_image, False) @@ -133,14 +136,40 @@ def _draw_preview(self) -> None: "warn", "This plane cannot be shown with these roles.", [str(exc)] ) return - self.preview_image.value = plane_png(plane) + self.preview_image.value = example_png(rows) ui.set_visible(self.preview_image, True) self._preview_drawn = self.mapping self.preview_caption.value = self._preview_label() + preview_caption( - header.relative_path, plane + header.relative_path, shown ) self._show_log() + def _read_example( + self, rel: str, file_mapping: AxisMapping, lengths: dict[str, int], at: dict[str, int] + ) -> tuple[list, Plane]: + """Read the planes of the example (:func:`.axis_example.example_rows`), with a wait line. + + Returns the rows as :func:`.notebook_plots.example_png` draws them, and the plane + at the sliders' position (the first tile when no tile is there) for the caption. + """ + rows = example_rows(lengths, at) + total = sum(len(row.tiles) for row in rows) + wait = Wait(self.preview_caption) + wait.start("Drawing the example", total) + wanted = {role.lower(): value for role, value in at.items()} + drawn, shown, done = [], None, 0 + for row in rows: + tiles = [] + for tile in row.tiles: + wait.step(done, total, tile.label) + plane = self._bound_plane(rel, file_mapping, tile.at()) + done += 1 + tiles.append((tile_label(tile.label, plane.shape), plane.pixels)) + if shown is None or tile.at() == wanted: + shown = plane + drawn.append((row.title, tiles)) + return drawn, shown + def _bound_plane(self, rel: str, file_mapping: AxisMapping, coords: dict) -> Plane: """Read the preview plane of the image only as the folder check read its header. diff --git a/src/morphobricks_preprocessing/notebook_axis_step.py b/src/morphobricks_preprocessing/notebook_axis_step.py index 797855f2..5bfe5f98 100644 --- a/src/morphobricks_preprocessing/notebook_axis_step.py +++ b/src/morphobricks_preprocessing/notebook_axis_step.py @@ -90,36 +90,47 @@ def _build_preview_card(self) -> None: icon="eye", tooltip="Read one plane with the roles above and show it", ) - self.preview_image = widgets.Image(format="png", layout=widgets.Layout(max_width="420px")) + self.preview_image = widgets.Image(format="png", layout=widgets.Layout(max_width="99%")) ui.set_visible(self.preview_image, False) # no broken-picture icon before a preview self.preview_caption = widgets.HTML() self.preview_checked = ui.ConfirmToggle( - "Does the preview look like a normal image of your sample?", - "Look at a few planes with the sliders. Stripes, repeating patterns or a smeared " - "picture mean that X or Y is on the wrong dimension: change the roles above. " - "The axis roles can only be confirmed once the preview looks right.", - "Yes, the preview looks right", - "Preview checked. Now click Confirm axis roles below.", - "Undo: the preview does not look right", + "Does the example show your sample as you expect?", + "Each channel, time point or slice should be a normal picture of the right size. " + "Stripes or a thin strip mean a role is on the wrong dimension: change it.", + "Yes, the example looks right", + "Example checked. Now click Confirm axis roles below.", + "Undo: the example does not look right", ) self.preview_checked.disabled = True self.preview_blocker = widgets.HTML() + ui.set_visible(self.preview_button, False) # the example is drawn by itself + self.preview_more = ui.accordion( + "Another image, or other planes", + [ + widgets.VBox( + [ + self.preview_file, + *self.preview_sliders.values(), + ui.help_text( + "The sliders choose the time point or slice of the row of channels, " + "and the channel of the other rows." + ), + ] + ) + ], + ) self.preview_card = ui.card( - "Preview", + "Example: one image as these roles read it", [ - ui.help_text( - "Pick an image and move the sliders to look at different planes. " - "Brightness is stretched for display only; the images are not changed." - ), - self.preview_file, - self.preview_button, - *self.preview_sliders.values(), widgets.HBox([self.preview_image]), self.preview_caption, + self.preview_more, + self.preview_button, self.preview_checked.widget, self.preview_blocker, ], ) + self.axis_sizes_slot = widgets.VBox() # the pixel size card, in the shipped notebook def _build_image_type_card(self) -> None: """Build the image type card, hidden until the axis roles are confirmed.""" @@ -169,23 +180,29 @@ def _refresh_axis_step(self) -> None: self._refresh_missing_card(pre) self._reset_preview() self._set_locked(False) + roles = ui.card( + "Axis roles", + [ + ui.help_text( + "One box per dimension, in the order stored in the files. Exactly one Y " + "and one X; a dimension with more than one plane needs Z, C or T." + ), + grid, + self.plane_summary, + ], + ) + left = widgets.VBox( + [roles, self.missing_card, self.axis_status, self.axis_sizes_slot], + layout=widgets.Layout(flex="1 1 0", min_width="0"), + ) + right = widgets.VBox( + [self.preview_card], layout=widgets.Layout(flex="1 1 0", min_width="0") + ) + columns = widgets.HBox([left, right], layout=widgets.Layout(width="99%")) + columns.add_class("mb-columns") self.axis_box.children = [ - ui.card( - "Axis roles", - [ - ui.help_text( - "One box per dimension of the images, in the order stored in the " - "files. Dimensions with more than one plane need Z, C or T; the image " - "itself needs exactly one Y and one X." - ), - grid, - self.plane_summary, - ], - ), - self.missing_card, - self.axis_status, + columns, self.convert_card, - self.preview_card, self.confirm_button, self.confirm_blocker, self.confirmed_status, @@ -327,7 +344,12 @@ def _mapping_changed(self) -> None: self.axis_status.value = ui.status("error", str(exc)) self.preview_checked.disabled = True # the preview shown is not of these roles self._review_ok, self._review_errors = False, () - self._update_confirm_button() + self.preview_image.value, self._preview_drawn = b"", None + ui.set_visible(self.preview_image, False) + self.preview_caption.value = ui.status( + "warn", "No example: these roles cannot be used together." + ) + self._gate_preview_tick() return ranges = axis_length_ranges(self.mapping, self.preflight.metadata.files) marks = header_marks(self.mapping, self.preflight.metadata.files) @@ -356,17 +378,18 @@ def _mapping_changed(self) -> None: "metadata names.", ) self.axis_next.value = ui.next_steps( - "Check the preview in Step 2, then confirm the axis roles.", - "Click Show preview and inspect a few planes before confirming.", + "Look at the example in Step 2, then confirm the axis roles.", + "It is redrawn whenever a role changes; other planes are one click away.", ) self._review_ok = review.ok self._review_errors = tuple( dict.fromkeys(i.code.replace("_", " ") for i in review.issues if i.is_error) ) self._refresh_convert_card(review) - if self._preview_shown: - self._configure_sliders(reset=False) - self._render_preview() + # The example is drawn by itself and redrawn for every change of the roles. + self._configure_sliders(reset=not self._preview_shown) + self._preview_shown = True + self._render_preview() self._update_confirm_button() def _update_confirm_button(self) -> None: @@ -392,7 +415,7 @@ def _confirm_blockers(self) -> list[str]: named = f" ({', '.join(codes)})" if codes else "" blockers.append(f"these roles have an error{named}: see the red message above") if not self._preview_of(self.mapping): - blockers.append("no preview of these roles is drawn: click Show preview") + blockers.append("no example of these roles is drawn: see the message under it") elif not self.preview_checked.value: - blockers.append("the preview is not ticked: click Yes, the preview looks right") + blockers.append("the example is not ticked: click Yes, the example looks right") return blockers diff --git a/src/morphobricks_preprocessing/notebook_calibration.py b/src/morphobricks_preprocessing/notebook_calibration.py index a665e64f..71f7e4a1 100644 --- a/src/morphobricks_preprocessing/notebook_calibration.py +++ b/src/morphobricks_preprocessing/notebook_calibration.py @@ -1,6 +1,11 @@ -"""Step 1 of the notebook: the calibration card (design v0.4 §3, PP-10). +"""The notebook's pixel size card (design v0.4 §3, PP-10; one line since the redesign). -The card shows the pixel sizes each image is recorded with, and their source: the image +By default the card is one line: the one pixel size of the whole experiment and where it +comes from (:mod:`.pixel_size_summary`), with one input when nothing gives a size, or behind +**Change the pixel size…** when the images have one (owner, 10 November 2026). When the images +do not agree the line says how many images have which size and where, and **Set per imaging +unit…** opens; nothing is ever chosen silently. The per-unit part shows the sizes each image +is recorded with, and their source: the image metadata (``header``), a size stated in ``calibration.csv`` (``stated``), ``pixels`` (deliberately uncalibrated), or none (uncalibrated). The user states sizes per target (the whole experiment, a folder or an image), axis and unit through the same API as the @@ -25,6 +30,7 @@ from .notebook_options import _WIDE from .notebook_render import issues_html from .notebook_values import size_rows +from .pixel_size_summary import summary_line, unit_rows, xy_groups from .recorded_sizes import PIXELS, SIZE_ROLES from .run import OutputLockedError, Preflight from .run.calibrate import update_calibration @@ -78,28 +84,17 @@ def _build_calibration_card(self) -> None: grid_template_columns="2fr 1fr 1fr 2fr", grid_gap="4px 12px", **_WIDE ), ) + self._build_pixel_line() + self._build_pixel_units(form) self.calibration_card = ui.card( - "Pixel sizes (calibration)", + "Pixel size", [ - ui.help_text( - "The size of one pixel along each axis, and where it comes from: the " - "image metadata (header), a size you state here (stated), " - "or pixels (deliberately uncalibrated). Nothing is guessed: an image " - "without either is uncalibrated. A size you state is saved in " - f"{CALIBRATION_FILENAME} in the output folder, used instead of the " - "metadata, and recorded by the run. No histogram depends on it." - ), - self.calibration_table, - self.calibration_issues, - widgets.HTML("State a size"), - ui.help_text( - "For . (the whole experiment), a folder or an image, relative to the " - "experiment folder. The most specific statement applies. Y and X are " - "always stated by name and never exchanged." - ), - form, - widgets.HBox([self.calibration_state, self.calibration_remove]), + self.pixel_line, + self.pixel_change, + self.pixel_form, self.calibration_message, + self.calibration_issues, + self.pixel_units, ], visible=False, ) @@ -107,6 +102,85 @@ def _build_calibration_card(self) -> None: self.calibration_state.on_click(lambda _: self._state_size()) self.calibration_remove.on_click(lambda _: self._remove_size()) + def _build_pixel_units(self, form: widgets.Widget) -> None: + """Build the collapsed part: the sizes per imaging unit and the form to state one.""" + self.pixel_units = ui.accordion( + "Set per imaging unit…", + [ + widgets.VBox( + [ + ui.help_text( + "An imaging unit is a folder of images. Choose it under For " + "(. is the whole experiment; an image can be chosen too); " + "the most specific statement applies. A stated size is saved in " + f"{CALIBRATION_FILENAME} in the output folder and recorded " + "by the run; no histogram depends on it." + ), + self.unit_table, + form, + widgets.HBox([self.calibration_state, self.calibration_remove]), + self.calibration_table, + ] + ) + ], + ) + + def _build_pixel_line(self) -> None: + """Build the one line about the pixel size and the one input for the whole experiment.""" + self.pixel_line = widgets.HTML() + self.unit_table = widgets.HTML() + self.pixel_size = widgets.Text( + placeholder="e.g. 0.325", layout=widgets.Layout(width="120px") + ) + self.pixel_unit = widgets.Dropdown( + options=UNIT_OPTIONS, value="micrometer", layout=widgets.Layout(width="260px") + ) + self.pixel_apply = ui.button( + " Use for all images", + style="primary", + icon="check", + tooltip="State this size of one pixel (X and Y) for every image of the experiment", + ) + self.pixel_form = widgets.HBox( + [ + widgets.HTML("One pixel is "), + self.pixel_size, + self.pixel_unit, + self.pixel_apply, + ], + layout=widgets.Layout(align_items="center", flex_flow="row wrap"), + ) + self.pixel_change = ui.button( + " Change the pixel size…", icon="pencil", tooltip="State one size for all images" + ) + self.pixel_change.on_click(lambda _: self._toggle_pixel_form()) + self.pixel_apply.on_click(lambda _: self._state_for_all()) + self.pixel_unit.observe(lambda _: self._pixel_unit_changed(), names="value") + + def _pixel_unit_changed(self) -> None: + """Disable the size for ``pixels``, which states no value.""" + self.pixel_size.disabled = self.pixel_unit.value == PIXELS + + def _toggle_pixel_form(self) -> None: + ui.set_visible(self.pixel_form, not ui.is_visible(self.pixel_form)) + + def _state_for_all(self) -> None: + """State one X and Y size for the whole experiment, replacing every other X/Y statement. + + The user's explicit statement for all images: statements for single folders or images + would otherwise still apply to them (the most specific wins), so they are removed + along X and Y; Z statements are kept. Header sizes that differ are then named by the + ``calibration_differs_from_header`` warning, as for any statement. + """ + unit = self.pixel_unit.value + size = None if unit == PIXELS else self.pixel_size.value.strip() + + def change(calibration: Calibration) -> Calibration: + kept = Calibration(tuple(e for e in calibration.entries if e.axis not in ("X", "Y"))) + return kept.stating(calibration_entry(".", axis, size, unit) for axis in ("X", "Y")) + + self._change_calibration(change) + def _calibration_unit_changed(self) -> None: """Disable the size for ``pixels``, which states no value.""" self.calibration_size.disabled = self.calibration_unit.value == PIXELS @@ -132,15 +206,27 @@ def _shown_calibration(self, output: Path | None = None) -> tuple[Preflight, dic return fit_calibration(pre, review)[0], roles def _show_calibration(self) -> None: - """Show every image's recorded sizes and the calibration's messages.""" + """Show the pixel size in one line, the per-unit sizes and the calibration's messages.""" pre = self.preflight ui.set_visible(self.calibration_card, pre is not None and bool(pre.metadata.files)) if pre is None: return self.calibration_target.options = self._calibration_targets() shown, roles = self._shown_calibration() + sizes = shown.calibration.sizes + groups = xy_groups(sizes) + kind, line = summary_line(groups) + self.pixel_line.value = ui.status(kind, line) + one_size = len(groups) == 1 and groups[0].calibrated + # One input when nothing gives a size; behind "Change…" when the images have one. + ui.set_visible(self.pixel_change, one_size) + ui.set_visible(self.pixel_form, not one_size) + if len(groups) > 1: + self.pixel_units.selected_index = 0 # they differ: the units are shown, not hidden + rows = [("Imaging unit (folder)", "Pixel size (X × Y)", "From")] + self.unit_table.value = ui.table([*rows, *unit_rows(sizes)]) rows = [("Images", *SIZE_ROLES)] - for texts, paths in size_rows(shown.calibration.sizes, roles): + for texts, paths in size_rows(sizes, roles): first = paths[0] if len(paths) == 1 else f"{len(paths):,} images, e.g. {paths[0]}" rows.append((first, *texts)) note = ( @@ -149,7 +235,7 @@ def _show_calibration(self) -> None: if roles is None else "With the confirmed axis roles, as the run records them." ) - self.calibration_table.value = ui.table(rows) + ui.help_text(note).value + self.calibration_table.value = ui.help_text(note).value + ui.table(rows) issues = [i for i in shown.metadata.issues if i.code in CALIBRATION_ISSUE_CODES] self.calibration_issues.value = issues_html(issues) if issues else "" diff --git a/src/morphobricks_preprocessing/notebook_channels_grid.py b/src/morphobricks_preprocessing/notebook_channels_grid.py index c6e66ca2..3b0bb42d 100644 --- a/src/morphobricks_preprocessing/notebook_channels_grid.py +++ b/src/morphobricks_preprocessing/notebook_channels_grid.py @@ -408,11 +408,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 " (Image type, step 7)" + where = "" if self.place_types else " (Image type)" 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 Image type (step 7)." + return "Colour is pre-ticked from the header: confirm it on Image type." 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_labels.py b/src/morphobricks_preprocessing/notebook_channels_labels.py index 336dde7b..506039af 100644 --- a/src/morphobricks_preprocessing/notebook_channels_labels.py +++ b/src/morphobricks_preprocessing/notebook_channels_labels.py @@ -149,8 +149,8 @@ def set_not_applicable(self, on: bool) -> None: "Not applicable: every folder is colour (RGB).", [ "R, G and B are the fixed names of a colour image's channels, so no label is " - "needed. If some folder is separate channels, change its image type " - "(step 7) and the labels come back." + "needed. If some folder is separate channels, change its image type and " + "the labels come back." + (f" The labels already declared ({kept}) stay saved, unused." if kept else "") ], ) diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py index 9828cb96..98b20e5c 100644 --- a/src/morphobricks_preprocessing/notebook_check_save.py +++ b/src/morphobricks_preprocessing/notebook_check_save.py @@ -99,7 +99,7 @@ def _build_check_save(self) -> None: self.same_way: dict[str, ui.ConfirmToggle] = {} self.saved_line = widgets.HTML() self.run_now = ui.button( - " Run", style="success", icon="play", tooltip="Compute the histograms (Run, step 12)" + " Run", style="success", icon="play", tooltip="Compute the histograms (Run)" ) self.change_saved = ui.button(" Change…", style="warning", icon="pencil") self.fold_bar = widgets.HBox([self.saved_line, self.run_now, self.change_saved]) @@ -270,7 +270,7 @@ def _todo(self, applied: object, answers: object, problem: str) -> tuple[list, l questions = list(missing_confirmations(applied, answers, channel_counts=counts)) extra = [problem] if problem else [] left = masks_unanswered(answers, self._mask_types()) - extra += [f"mask {m}: separate objects or regions? (Masks, step 10)" for m in left] + extra += [f"mask {m}: separate objects or regions? (Masks)" for m in left] if len(applied.conditions) > 1: extra += [ f"what {folder} is compared against (choose, or no comparison)" @@ -433,17 +433,17 @@ def _saved_status(self, when: str) -> str: start, only that the axes come first. """ if self._run_allowed(): - return ui.status("ok", f"Saved {when}. The run (step 12) can start.") + return ui.status("ok", f"Saved {when}. The run can start.") if self.confirmed_mapping is None: return ui.status( "info", f"Saved {when}.", [ "Confirm the axis roles (step 2) first: the run's checks of the images " - "need them, and the run (step 12) waits for those checks." + "need them, and the run waits for those checks." ], ) - return ui.status("ok", f"Saved {when}.", ["See Run (step 12) for what is still to do."]) + return ui.status("ok", f"Saved {when}.", ["See Run for what is still to do."]) # Answers ----------------------------------------------------------------------------- @@ -482,7 +482,7 @@ def _run_from_fold(self) -> None: self.saved_line.value += ui.status( "warn", "Not run yet.", - ["See Run (step 12): its checklist says what is still to do."], + ["See Run: its checklist says what is still to do."], ) # Saving ------------------------------------------------------------------------------ diff --git a/src/morphobricks_preprocessing/notebook_check_view.py b/src/morphobricks_preprocessing/notebook_check_view.py index b397e5b1..11fdbe75 100644 --- a/src/morphobricks_preprocessing/notebook_check_view.py +++ b/src/morphobricks_preprocessing/notebook_check_view.py @@ -152,7 +152,7 @@ def run_stop_html(check: CandidateCheck | None) -> str: ) return ui.status( "warn", - "You can save, but the run (step 12) will stop.", + "You can save, but the run will stop.", [f"Refused: {named}.", "Each reason is listed above, with the folder it names."], ) @@ -197,7 +197,7 @@ def blocked_html( "the axis roles are confirmed, step 2)" ) ) - return ui.status("error", "The run (step 12) is blocked by both:", lines, raw=True) + return ui.status("error", "The run is blocked by both:", lines, raw=True) def checks_html(check: CandidateCheck | None) -> str: diff --git a/src/morphobricks_preprocessing/notebook_convert.py b/src/morphobricks_preprocessing/notebook_convert.py index cd8aeb46..c641048a 100644 --- a/src/morphobricks_preprocessing/notebook_convert.py +++ b/src/morphobricks_preprocessing/notebook_convert.py @@ -179,7 +179,7 @@ def _convert_roles_changed(self) -> None: self.convert_tick_blocker.value = blocked_line( [] if confirmed or self.preview_checked.value - else ["the preview is not ticked: click Yes, the preview looks right first"] + else ["the example is not ticked: click Yes, the example looks right first"] ) ui.set_visible( self.convert_roles_checked.widget, not confirmed and self.mapping is not None @@ -375,7 +375,7 @@ def _roles_html(mapping: AxisMapping | None, roles: AxisMapping | None, confirme "The roles for the copies are not confirmed yet.", [ *lines, - "Check them with Show preview and press Yes, the preview looks right; then " + "Check them with the example and press Yes, the example looks right; then " "answer the question below.", ], raw=True, diff --git a/src/morphobricks_preprocessing/notebook_mask_question.py b/src/morphobricks_preprocessing/notebook_mask_question.py index 4d6a4a39..f69d775c 100644 --- a/src/morphobricks_preprocessing/notebook_mask_question.py +++ b/src/morphobricks_preprocessing/notebook_mask_question.py @@ -144,7 +144,7 @@ def _build_made_from(self, labels: list[str], chosen: tuple[str, ...]) -> None: ) box.observe(lambda _: self._made_from_changed(), names="value") self.made_from[label] = box - empty = "no channel labels yet: declare them on Stains (step 8)" + empty = "no channel labels yet: declare them on Stains" hint = "answer the question first" if labels and self.saved is None else "" tail = [ widgets.HTML(f"{hint or (empty if not labels else '')}") diff --git a/src/morphobricks_preprocessing/notebook_mask_step.py b/src/morphobricks_preprocessing/notebook_mask_step.py index 853f91e7..c86f2cbf 100644 --- a/src/morphobricks_preprocessing/notebook_mask_step.py +++ b/src/morphobricks_preprocessing/notebook_mask_step.py @@ -49,7 +49,7 @@ READING_MASKS = "Reading the masks, one image at a time" _SUGGESTIONS = ( "These are suggestions from the masks' pixels: check the relationships before using them. " - "Each mask keeps the ID it was given on Masks (step 10)." + "Each mask keeps the ID it was given on Masks." ) diff --git a/src/morphobricks_preprocessing/notebook_masks_screen.py b/src/morphobricks_preprocessing/notebook_masks_screen.py index 467de41a..558c2a83 100644 --- a/src/morphobricks_preprocessing/notebook_masks_screen.py +++ b/src/morphobricks_preprocessing/notebook_masks_screen.py @@ -45,7 +45,7 @@ "has its own number, as nuclei or cells from a segmentation) or regions (inside or " "outside, as a tissue area). Nothing is chosen for you: the pixel type and the number of " "values are shown only to help you answer. The mask ID is the folder name when it can be " - "one; it is used everywhere after, also by Describe the masks (step 13)." + "one; it is used everywhere after, also by Describe the masks." ) diff --git a/src/morphobricks_preprocessing/notebook_plots.py b/src/morphobricks_preprocessing/notebook_plots.py index fe5d00f2..962b1b72 100644 --- a/src/morphobricks_preprocessing/notebook_plots.py +++ b/src/morphobricks_preprocessing/notebook_plots.py @@ -468,6 +468,58 @@ def plane_png(plane: Plane, size: float = 4.2) -> bytes: return buffer.getvalue() +TILE_INCHES = 1.9 +DRAWN_SIDE = 384 +"""The most pixels along a side handed to the drawing (a larger plane is strided).""" +DPI = 100 + + +def tile_label(label: str, shape: tuple[int, int]) -> str: + """Return a tile's caption: its coordinate and the plane's height and width in pixels.""" + size = f"{shape[0]} × {shape[1]} px" + return f"{label} · {size}" if label else f"{size} (height × width)" + + +def example_png(rows: Sequence[tuple[str, Sequence[tuple[str, np.ndarray]]]]) -> bytes: + """Draw the example: one titled row of tiles per role, each tile with its label. + + Parameters + ---------- + rows + ``(row title, [(tile label, pixels), ...])`` per row; ``pixels`` is a (Y, X) array. + + Returns + ------- + bytes + A PNG picture. + """ + columns = max(len(tiles) for _, tiles in rows) + row_height = TILE_INCHES + 0.5 + figure = Figure(figsize=(max(columns, 2) * TILE_INCHES, len(rows) * row_height), dpi=DPI) + for r, (title, tiles) in enumerate(rows): + top = 1 - r / len(rows) + figure.text(0.01, top - 0.02 / len(rows), title, fontsize=9, fontweight="bold", va="top") + for c, (label, pixels) in enumerate(tiles): + width = 1 / max(columns, 2) + box = ( + c * width + 0.01, + top - 1 / len(rows) + 0.02 / len(rows), + width - 0.02, + TILE_INCHES / row_height / len(rows) - 0.02 / len(rows), + ) + axes = figure.add_axes(box) + step = max(1, max(pixels.shape) // DRAWN_SIDE) + shown = pixels[::step, ::step] + low, high = display_range(shown) + axes.imshow(shown, cmap="gray", vmin=low, vmax=high, interpolation="nearest") + axes.set_anchor("N") + axes.set_axis_off() + axes.set_title(label, fontsize=8, pad=2) + buffer = io.BytesIO() + figure.savefig(buffer, format="png", dpi=DPI) + return buffer.getvalue() + + def distribution_png(groups: dict[tuple[str, ...], list]) -> bytes | None: """Plot the cumulative intensity distribution of each group, one panel per channel. diff --git a/src/morphobricks_preprocessing/notebook_step_states.py b/src/morphobricks_preprocessing/notebook_step_states.py index 593af2de..c9639224 100644 --- a/src/morphobricks_preprocessing/notebook_step_states.py +++ b/src/morphobricks_preprocessing/notebook_step_states.py @@ -17,7 +17,7 @@ UNCALIBRATED = ("uncalibrated", "pixels (stated)") """The X/Y size texts (:func:`.notebook_values.size_text`) that give no physical size.""" -ANSWER_STEPS = range(3, 11) +ANSWER_STEPS = range(2, 10) """The steps (0-based) whose answers the published file holds: conditions to check and save.""" @@ -31,11 +31,10 @@ class _StepStates: """The :class:`.notebook_stepper.StepState` of each step, from the cards' state.""" def _step_states(self) -> list: - """Return the state callables of the 13 steps, in order.""" + """Return the state callables of the 12 steps, in order.""" return [ self._images_state, self._axes_state, - self._sizes_state, self._conditions_state, self._replicates_state, self._references_state, @@ -77,15 +76,18 @@ def _images_state(self) -> StepState: return StepState(pre.ok, text) def _axes_state(self) -> StepState: + """Return step 2: the confirmed roles, with the pixel size (its note when uncalibrated).""" mapping = self.confirmed_mapping + sizes, note = self._sizes_words() if mapping is None: return StepState(False, "not confirmed") - return StepState(True, ", ".join(role for role in mapping.roles if role) + " confirmed") + roles = ", ".join(role for role in mapping.roles if role) + " confirmed" + return StepState(True, f"{roles} · {sizes}" if sizes else roles, note=note) - def _sizes_state(self) -> StepState: - """Return step 3: the pixel sizes as the calibration card shows them.""" + def _sizes_words(self) -> tuple[str, str]: + """Return the pixel sizes as the calibration card shows them, and the chip's note.""" if self.preflight is None or not self.preflight.metadata.files: - return StepState(False, "after the folder check") + return "", "" key = (id(self.preflight), self.confirmed_mapping, self.missing_as_singleton.value) if getattr(self, "_sizes_key", None) != key: # the card's own sizes, read once shown, roles = self._shown_calibration() @@ -95,21 +97,21 @@ def _sizes_state(self) -> StepState: if len(bare) == len(rows): stated = all(t == "pixels (stated)" for r in rows for t in r[0][:2]) why = "stated as pixels" if stated else "no pixel size in the headers or stated" - return StepState(True, f"uncalibrated (pixels): {why}", note="uncalibrated") + return f"uncalibrated (pixels): {why}", "uncalibrated" note = "partly uncalibrated" if bare else "" if len(rows) == 1: x, y = rows[0][0][:2] - return StepState(True, f"X {x} · Y {y}") + return f"X {x} · Y {y}", note text = f"{len(rows)} different sets of sizes" if bare: text += f", {len(bare)} of them uncalibrated" - return StepState(True, text, note=note) + return text, note def _changed(self, index: int, done: bool) -> bool: return not done and index in ANSWER_STEPS and self._reopen_changed() def _conditions_state(self) -> StepState: - """Return step 4: the condition IDs as shown, with their shown-as names.""" + """Return step 3: the condition IDs as shown, with their shown-as names.""" if self.draft is None: return StepState(False, "after the folder check") wanted = self._wanted_ids() @@ -121,10 +123,10 @@ def _conditions_state(self) -> StepState: } text = ", ".join(f"{i} ({shown[i]})" if i in shown else i or "?" for i in ids) done = all(ids) - return StepState(done, text or "none", self._changed(3, done)) + return StepState(done, text or "none", self._changed(2, done)) def _replicates_state(self) -> StepState: - """Return step 5: the replicate IDs, the pairing and the technical-repeat IDs.""" + """Return step 4: the replicate IDs, the pairing and the technical-repeat IDs.""" if self.draft is None: return StepState(False, "after the folder check") wanted = self._wanted_ids() @@ -140,7 +142,7 @@ def _replicates_state(self) -> StepState: if named: text += " · technical: " + ", ".join(named) done = all(repeats) and all(technical) - return StepState(done, text or "none", self._changed(4, done)) + return StepState(done, text or "none", self._changed(3, done)) def _references_state(self) -> StepState: if self.draft is None: @@ -151,10 +153,10 @@ def _references_state(self) -> StepState: chosen = {c: self._chosen_reference(c) for c in conditions} text = " · ".join(f"{c} → {'?' if t is None else t or 'none'}" for c, t in chosen.items()) done = None not in chosen.values() - return StepState(done, text, self._changed(5, done)) + return StepState(done, text, self._changed(4, done)) def _image_type_state(self) -> StepState: - """Return step 7: the image types answered, by kind, and how many are left.""" + """Return step 6: the image types answered, by kind, and how many are left.""" if self._channels_key is None: return StepState(False, "after the axes") types = self.channel_types @@ -168,10 +170,10 @@ def _image_type_state(self) -> StepState: done = None not in saved if not done: parts.append(f"{saved.count(None)} to choose") - return StepState(done, ", ".join(parts), self._changed(6, done)) + return StepState(done, ", ".join(parts), self._changed(5, done)) def _stains_state(self) -> StepState: - """Return step 8: the confirmed labels, or not applicable when every folder is colour.""" + """Return step 7: the confirmed labels, or not applicable when every folder is colour.""" if self._channels_key is None: return StepState(False, "after the axes") if self._stains_not_applicable: @@ -180,10 +182,10 @@ def _stains_state(self) -> StepState: ) done = self.channel_labels.confirmed text = ", ".join(self.channel_labels.labels) if done else "not confirmed" - return StepState(done, text, self._changed(7, done)) + return StepState(done, text, self._changed(6, done)) def _tags_state(self) -> StepState: - """Return step 9: whether every channel of every folder is tagged.""" + """Return step 8: whether every channel of every folder is tagged.""" if self._channels_key is None or not ( self.channel_labels.confirmed or self._stains_not_applicable ): @@ -196,7 +198,7 @@ def _tags_state(self) -> StepState: if done else f"{left.cells} channel(s) still to tag" ) - return StepState(done, text, self._changed(8, done)) + return StepState(done, text, self._changed(7, done)) def _masks_state(self) -> StepState: types = self._mask_types() @@ -207,7 +209,7 @@ def _masks_state(self) -> StepState: kinds = mask_kinds(self.draft_answers) text = " · ".join(f"{t} = {kinds.get(t, '?')}" for t in types) done = not masks_unanswered(self.draft_answers, types) - return StepState(done, text, self._changed(9, done)) + return StepState(done, text, self._changed(8, done)) def _saved_state(self) -> StepState: if self.draft is None: @@ -215,7 +217,7 @@ def _saved_state(self) -> StepState: if self._saved_current(): state = self._published() return StepState(True, f"saved {self._saved_on or state.saved_on}") - return StepState(False, "not saved yet", self._changed(10, False)) + return StepState(False, "not saved yet", self._changed(9, False)) def _run_state(self) -> StepState: result = self.result diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py index 66d68b72..c37ac849 100644 --- a/src/morphobricks_preprocessing/notebook_steps.py +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -1,8 +1,9 @@ -"""The shipped notebook's 13 steps, one on screen at a time (owner decision 2026-11-09). +"""The shipped notebook's 12 steps, one on screen at a time (owner decision 2026-11-09). Pure composition of the cards the notebook already has (UX proposal 2026-11-09, PR 17a): the -order is images found, axes, pixel sizes, conditions, replicates, compared against, image type, -stains, tag channels, masks, check and save (with the overview of steps 1-10), run, and describe +order is images found, axes (with the pixel size, owner 2026-11-10), conditions, replicates, +compared against, image type, +stains, tag channels, masks, check and save (with the overview of steps 1-9), run, and describe the masks. Each answer is still saved, refused and checked by the card that asks it; the stepper (:mod:`.notebook_stepper`) only shows one page and says, from each card's state (:mod:`.notebook_step_states`), which steps are done. @@ -38,7 +39,7 @@ AXES_INTRO = ( "Why this matters: every image is cut into flat 2D planes, one per depth slice, " "channel and time point. The roles below are suggested from the image metadata, which is " - "not always right, so check them with the preview before confirming. The pictures of the " + "not always right, so check them with the example before confirming. The pictures of the " "later steps are read only through the roles you confirm." ) CONDITIONS_HELP = ( @@ -51,9 +52,9 @@ "which never changes once the experiment description is saved. A replicate pairs one folder " "per condition (above); its name is proposed from the folder name when it can be used." ) -OVERVIEW_STEPS = 10 -"""The overview on *Check and save* restates steps 1 to 10.""" -UNFOLDING = {4: "design_card", 5: "references_card", 9: "masks_box"} +OVERVIEW_STEPS = 9 +"""The overview on *Check and save* restates steps 1 to 9.""" +UNFOLDING = {3: "design_card", 4: "references_card", 8: "masks_box"} """The steps (0-based) that show a card the saved reopening folds.""" @@ -61,7 +62,7 @@ class _StepsFlow(_QuickScan, _QuickAxes, _StepStates): """``steps()``: the stepper of the shipped notebook, and the hooks that keep it current.""" def steps(self) -> widgets.Widget: - """Return the notebook's 13 steps, one shown at a time, with a strip and Back/Next. + """Return the notebook's 12 steps, one shown at a time, with a strip and Back/Next. The shipped notebook shows this instead of the stacked pages. Every answer is given and saved on the card that asks it, as on the pages; @@ -83,6 +84,7 @@ def steps(self) -> widgets.Widget: self.type_rows.reopen = self.channel_grid.reopen = self._reopen_units self.overview_box = widgets.VBox() self._build_quick_axes() + self.axis_sizes_slot.children = [self.calibration_card] # with the axes (owner) self._build_quick_scan() self.consistency_slot = widgets.VBox([self.consistency_card]) self._stepper = Stepper(self._step_list(), self._step_shown) @@ -93,15 +95,13 @@ def steps(self) -> widgets.Widget: return self._stepper.widget def _step_list(self) -> list[Step]: - """Return the 13 steps: titles, purposes, the existing cards, and their states.""" + """Return the 12 steps: titles, purposes, the existing cards, and their states.""" states = self._step_states() pages = [ ("Images found", "Choose the folder with your images and check it.", self._images_page()), ("Axes", "Say which image dimension is width, height, channel and time.", - [ui.intro(AXES_INTRO), self.quick_card, self.axes_details, self.axis_box]), - ("Pixel sizes", "The size of one pixel, and where it comes from.", - [self.calibration_card]), + [ui.intro(AXES_INTRO), self.axis_box, self.quick_card]), ("Conditions", "A short name for each condition.", [self._conditions_card()]), ("Replicates", "Which folders are one replicate, and their names.", [self.design_card, self._replicates_card(), self.consistency_slot]), @@ -182,14 +182,13 @@ def _step_waits(self) -> dict: after_check = self._wait_for_check return { 0: self._consistency_note, - 2: lambda: "" if self.preflight is not None else after_check(), + 2: after_check, 3: after_check, - 4: after_check, - 5: self._wait_for_references, + 4: self._wait_for_references, + 5: self._wait_for_channels, 6: self._wait_for_channels, - 7: self._wait_for_channels, - 8: self._wait_for_tags, - 12: self._wait_for_mask_step, + 7: self._wait_for_tags, + 11: self._wait_for_mask_step, } def _consistency_note(self) -> str: @@ -198,7 +197,7 @@ def _consistency_note(self) -> str: return "" return ui.status( "info", - "The consistency warnings are on Replicates (step 5).", + "The consistency warnings are on Replicates.", ["They depend on which folders are one replicate: pair the folders there first."], ) @@ -219,7 +218,7 @@ def _wait_for_tags(self) -> str: waiting = self._wait_for_channels() if waiting or self.channel_labels.confirmed or self._stains_not_applicable: return waiting - return ui.status("info", "Waiting for the stains.", ["Confirm the stains in step 8 first."]) + return ui.status("info", "Waiting for the stains.", ["Confirm the stains first (Stains)."]) def _wait_for_mask_step(self) -> str: if ui.is_visible(self.mask_step_card): @@ -277,7 +276,7 @@ def _confirm_quick_axes(self) -> bool: def _mapping_changed(self) -> None: super()._mapping_changed() - self._refresh_quick_axes() + self._refresh_quick_axes(drawn=True) # the example was just redrawn def _toggle_confirmation(self) -> None: super()._toggle_confirmation() diff --git a/src/morphobricks_preprocessing/notebook_style.py b/src/morphobricks_preprocessing/notebook_style.py index 24a97e67..7898d2fe 100644 --- a/src/morphobricks_preprocessing/notebook_style.py +++ b/src/morphobricks_preprocessing/notebook_style.py @@ -43,6 +43,7 @@ .mb-status details { margin-top: 4px; } .mb-paths { margin: 2px 0 2px 4px; max-height: 190px; overflow-y: auto; } .mb-paths div { padding: 1px 0; overflow-wrap: anywhere; } +.mb-columns { gap: 14px; align-items: flex-start; } .mb-blocker { color: #92400E; font-size: 13px; line-height: 1.4; margin: 2px 0 6px 0; overflow-wrap: anywhere; } .mb-next { background: #EFF6FF; border: 2px solid #93C5FD; border-radius: 8px; diff --git a/src/morphobricks_preprocessing/pixel_size_summary.py b/src/morphobricks_preprocessing/pixel_size_summary.py new file mode 100644 index 00000000..4f24f92d --- /dev/null +++ b/src/morphobricks_preprocessing/pixel_size_summary.py @@ -0,0 +1,167 @@ +"""One line about the pixel size of an experiment, and which imaging units differ. + +Owner requirement (10 November 2026, item (f)): by default one pixel size for the whole +experiment, said in one line; when the images do not agree, a line that says how many images +have which size and where, never a silent choice. The sizes are the ones each image is +recorded with (:attr:`.calibration.CalibrationReview.sizes`): its header's, or a size stated in +``calibration.csv``. Nothing is computed, converted or guessed here; the texts only group what +is recorded. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from .recorded_sizes import HEADER, PIXELS, STATED, RecordedSize +from .validation import plural + +UNIT_SYMBOLS = {"micrometer": "µm", "nanometer": "nm", "millimeter": "mm", "meter": "m"} +NAMED_FOLDERS = 2 +"""Folders named for a group of images that differs; the others are counted.""" + + +@dataclass(frozen=True) +class SizeGroup: + """The images recorded with one X and Y size. + + Attributes + ---------- + x, y + The recorded sizes (value, unit and source). + images + The images with exactly these, sorted. + """ + + x: RecordedSize + y: RecordedSize + images: tuple[str, ...] + + @property + def calibrated(self) -> bool: + """True when both X and Y have a value.""" + return self.x.value is not None and self.y.value is not None + + @property + def sources(self) -> frozenset[str | None]: + """The sources of the X and Y sizes.""" + return frozenset((self.x.source, self.y.source)) + + +def xy_groups(sizes: Mapping[str, Sequence[RecordedSize]]) -> list[SizeGroup]: + """Group the images by the X and Y size they are recorded with, the largest group first.""" + groups: dict[tuple, list[str]] = {} + found: dict[tuple, tuple[RecordedSize, RecordedSize]] = {} + for path in sorted(sizes): + by_role = {size.role: size for size in sizes[path]} + x, y = by_role.get("X", RecordedSize("X")), by_role.get("Y", RecordedSize("Y")) + key = (x.value, x.unit, x.source, y.value, y.unit, y.source) + groups.setdefault(key, []).append(path) + found[key] = (x, y) + ordered = sorted(groups.items(), key=lambda item: (-len(item[1]), item[1][0])) + return [SizeGroup(*found[key], tuple(paths)) for key, paths in ordered] + + +def _one(size: RecordedSize) -> str: + """Return one size as written: ``0.325 µm``, ``pixels`` or ``none``.""" + if size.source == PIXELS: + return "pixels" + if size.value is None: + return "none" + unit = UNIT_SYMBOLS.get(size.unit or "", size.unit or "no unit") + return f"{size.value:g} {unit}" + + +def size_words(group: SizeGroup) -> str: + """Return a group's X and Y size: ``0.325 × 0.325 µm``, ``pixels`` or ``none``.""" + x, y = _one(group.x), _one(group.y) + if x == y and not group.calibrated: + return x + if group.calibrated and group.x.unit == group.y.unit: + return f"{group.x.value:g} × {y}" # X × Y, the unit once + return f"X {x}, Y {y}" + + +def source_words(group: SizeGroup) -> str: + """Return where a group's sizes come from, in words.""" + sources = group.sources + if sources == {HEADER}: + return "from the headers" + if sources == {STATED}: + return "stated by you" + if sources == {PIXELS}: + return "stated by you as deliberately uncalibrated" + if sources == {None}: + return "no size in the headers and none stated: uncalibrated" + names = {HEADER: "the header", STATED: "stated", PIXELS: "stated as pixels", None: "none"} + return f"X: {names[group.x.source]}, Y: {names[group.y.source]}" + + +def folders_of(images: Sequence[str]) -> list[str]: + """Return the folders holding ``images``, sorted (``.`` for the experiment folder).""" + return sorted({image.rpartition("/")[0] or "." for image in images}) + + +def where_words(group: SizeGroup) -> str: + """Return the folders (imaging units) of a group, the first ones named.""" + folders = folders_of(group.images) + named = ", ".join(folders[:NAMED_FOLDERS]) + more = len(folders) - NAMED_FOLDERS + return named + (f" and {plural(more, 'more folder')}" if more > 0 else "") + + +def summary_line(groups: Sequence[SizeGroup]) -> tuple[str, str]: + """Return the kind (``ok``, ``info`` or ``warn``) and the one line about the pixel size. + + One group is the whole experiment with one size; several are named with their image + counts and, for all but the largest, their folders. Plain text. + """ + total = sum(len(group.images) for group in groups) + if not groups: + return "info", "Pixel size: no image to read a size from." + if len(groups) == 1: + group = groups[0] + count = plural(total, "image") + if group.calibrated: + return "ok", ( + f"Pixel size: {size_words(group)} ({source_words(group)}, same in all {count})." + ) + return "info", f"Pixel size: {size_words(group)} ({source_words(group)}; {count})." + parts = [f"{size_words(groups[0])} in {plural(len(groups[0].images), 'image')}"] + parts += [ + f"{size_words(group)} in {len(group.images):,} ({where_words(group)})" + for group in groups[1:] + ] + headers = all(group.sources <= {HEADER, None} for group in groups) + title = "Headers disagree" if headers else "Pixel sizes differ" + return "warn", f"{title}: {', '.join(parts)}." + + +def unit_rows(sizes: Mapping[str, Sequence[RecordedSize]]) -> list[tuple[str, str, str]]: + """Return ``(folder, size, source)`` per imaging unit (folder), for the per-unit table. + + A folder whose images are recorded with different sizes says so instead of naming one. + """ + by_folder: dict[str, dict[str, list[RecordedSize]]] = {} + for path, recorded in sizes.items(): + by_folder.setdefault(path.rpartition("/")[0] or ".", {})[path] = list(recorded) + rows = [] + for folder in sorted(by_folder): + groups = xy_groups(by_folder[folder]) + if len(groups) == 1: + rows.append((folder, size_words(groups[0]), source_words(groups[0]))) + else: + rows.append((folder, "differs between its images", summary_line(groups)[1])) + return rows + + +__all__ = [ + "SizeGroup", + "folders_of", + "size_words", + "source_words", + "summary_line", + "unit_rows", + "where_words", + "xy_groups", +] diff --git a/tests/test_notebook_axes_example.py b/tests/test_notebook_axes_example.py new file mode 100644 index 00000000..2ba4b0a0 --- /dev/null +++ b/tests/test_notebook_axes_example.py @@ -0,0 +1,328 @@ +"""The axes step shows the data as the roles read it, and the pixel size in one line. + +Owner's test of v0.5.0 (10 November 2026): (e) an example of one image as interpreted by the +chosen roles, drawn by itself and redrawn on every role change, with the explicit confirmation +kept; (f) the pixel size handled with the axes: one line for the whole experiment, one input +when the headers give none, a per-unit menu, and never a silent choice when the headers differ. +""" + +import re + +import numpy as np +import pytest +import tifffile + +from morphobricks_preprocessing.axis_example import ( + CHANNEL_TILES, + STEP_TILES, + example_rows, + spread, +) +from morphobricks_preprocessing.pixel_size_summary import ( + size_words, + source_words, + summary_line, + unit_rows, + xy_groups, +) +from morphobricks_preprocessing.recorded_sizes import RecordedSize + +pytest.importorskip("ipywidgets") +pytest.importorskip("matplotlib") + +from morphobricks_preprocessing import notebook_style as ui # noqa: E402 +from morphobricks_preprocessing.notebook_plots import example_png, tile_label # noqa: E402 +from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 + + +def _text(html): + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html)).strip() + + +# --- which planes the example shows -------------------------------------------------------------- + + +def test_spread_keeps_the_ends_and_never_more_than_asked(): + assert spread(3, 6) == [0, 1, 2] + assert spread(9, 4) == [0, 3, 5, 8] + assert spread(100, 4) == [0, 33, 66, 99] + assert spread(5, 1) == [0] and spread(1, 4) == [0] + + +def test_channels_get_one_tile_each_labelled_by_their_number(): + (row,) = example_rows({"C": 3}, {"C": 0}) + assert row.role == "C" and row.title == "Channels (C)" + assert [tile.label for tile in row.tiles] == ["c=0", "c=1", "c=2"] + assert [tile.at() for tile in row.tiles] == [{"c": 0}, {"c": 1}, {"c": 2}] + + +def test_time_points_and_slices_get_a_few_labelled_tiles(): + (row,) = example_rows({"T": 30}, {"T": 0}) + assert row.title == f"Time points (T): {STEP_TILES} of 30" + assert [tile.label for tile in row.tiles] == ["t=0", "t=10", "t=19", "t=29"] + (row,) = example_rows({"Z": 3}, {"Z": 1}) + assert row.title == "Depth slices (Z)" and [t.label for t in row.tiles] == ["z=0", "z=1", "z=2"] + + +def test_each_row_is_read_at_the_sliders_position_of_the_other_axes(): + channels, times = example_rows({"C": 2, "T": 5}, {"C": 1, "T": 4}) + assert [tile.at() for tile in channels.tiles] == [{"c": 0, "t": 4}, {"c": 1, "t": 4}] + assert [tile.at() for tile in times.tiles] == [{"c": 1, "t": t} for t in (0, 1, 3, 4)] + many = example_rows({"C": 20}, {"C": 0})[0] + assert len(many.tiles) == CHANNEL_TILES and many.title == f"Channels (C): {CHANNEL_TILES} of 20" + assert example_rows({"C": 2}, {"C": 9})[0].tiles[0].at() == {"c": 0} # a slider is clamped + + +def test_an_image_without_channels_time_or_depth_is_one_plane(): + (row,) = example_rows({}, {}) + assert row.role == "" and len(row.tiles) == 1 and row.tiles[0].at() == {} + (row,) = example_rows({"C": 1}, {"C": 0}) # a length-1 axis is no row of its own + assert row.role == "" and row.tiles[0].at() == {"c": 0} + + +def test_the_picture_labels_each_tile_with_its_size(): + assert tile_label("c=1", (2048, 1024)) == "c=1 · 2048 × 1024 px" + assert tile_label("", (3, 2048)) == "3 × 2048 px (height × width)" + rng = np.random.default_rng(0) + plane = rng.integers(0, 4000, (64, 64), dtype=np.uint16) + strip = rng.integers(0, 4000, (3, 64), dtype=np.uint16) # a length-3 axis taken as Y + png = example_png( + [("Channels (C)", [("c=0", plane), ("c=1", plane)]), ("The plane", [("", strip)])] + ) + assert png[:4] == b"\x89PNG" + + +# --- the pixel size in words --------------------------------------------------------------------- + + +def _sizes(value, unit="micrometer", source="header"): + return (RecordedSize("X", value, unit, source), RecordedSize("Y", value, unit, source)) + + +NONE = (RecordedSize("X"), RecordedSize("Y")) + + +def test_one_size_for_every_image_is_one_line(): + sizes = {f"ctrl/rep{i}/a.tif": _sizes(0.108) for i in range(4)} + groups = xy_groups(sizes) + assert summary_line(groups) == ( + "ok", + "Pixel size: 0.108 × 0.108 µm (from the headers, same in all 4 images).", + ) + stated = xy_groups({k: _sizes(0.5, source="stated") for k in sizes}) + assert summary_line(stated)[1] == ( + "Pixel size: 0.5 × 0.5 µm (stated by you, same in all 4 images)." + ) + + +def test_no_size_and_stated_pixels_are_said_as_uncalibrated_not_guessed(): + kind, line = summary_line(xy_groups({"a.tif": NONE, "b.tif": NONE})) + assert kind == "info" + assert line == ( + "Pixel size: none (no size in the headers and none stated: uncalibrated; 2 images)." + ) + pixels = (RecordedSize("X", source="pixels"), RecordedSize("Y", source="pixels")) + assert ( + "pixels (stated by you as deliberately uncalibrated; 1 image)" + in summary_line(xy_groups({"a.tif": pixels}))[1] + ) + + +def test_headers_that_disagree_are_named_with_counts_and_folders_never_one_chosen(): + sizes = {f"c1/rep1/w{i}/a.tif": _sizes(0.108) for i in range(3)} + sizes |= {f"c2/rep2/w1/{name}.tif": _sizes(0.216) for name in "ab"} + kind, line = summary_line(xy_groups(sizes)) + assert kind == "warn" + assert line == ( + "Headers disagree: 0.108 × 0.108 µm in 3 images, 0.216 × 0.216 µm in 2 (c2/rep2/w1)." + ) + sizes["c3/rep1/a.tif"] = NONE + line = summary_line(xy_groups(sizes))[1] + assert line.startswith("Headers disagree:") and "none in 1 (c3/rep1)" in line + sizes["c2/rep2/w1/a.tif"] = _sizes(0.3, source="stated") + assert summary_line(xy_groups(sizes))[1].startswith("Pixel sizes differ:") + + +def test_size_and_source_words_keep_x_and_y_apart_when_they_differ(): + group = xy_groups( + {"a.tif": (RecordedSize("X", 0.2, "micrometer", "header"), RecordedSize("Y"))} + )[0] + assert size_words(group) == "X 0.2 µm, Y none" + assert source_words(group) == "X: the header, Y: none" + other = xy_groups( + { + "a.tif": ( + RecordedSize("X", 0.2, "micrometer", "header"), + RecordedSize("Y", 0.3, "micrometer", "header"), + ) + } + )[0] + assert size_words(other) == "0.2 × 0.3 µm" + + +def test_unit_rows_give_each_folder_its_size_and_say_when_a_folder_is_mixed(): + sizes = {"c1/r1/a.tif": _sizes(0.1), "c1/r1/b.tif": _sizes(0.1), "c2/r1/a.tif": NONE} + sizes |= {"c3/r1/a.tif": _sizes(0.1), "c3/r1/b.tif": _sizes(0.2)} + rows = dict((folder, (size, source)) for folder, size, source in unit_rows(sizes)) + assert rows["c1/r1"] == ("0.1 × 0.1 µm", "from the headers") + assert rows["c2/r1"][0] == "none" + assert rows["c3/r1"][0] == "differs between its images" + + +# --- the notebook -------------------------------------------------------------------------------- + + +def experiment(tmp_path, sizes=None, shape=(2, 3, 16, 16), axes="TCYX"): + """Write two folders of one image each; ``sizes`` maps a folder to its header pixel size.""" + root = tmp_path / "exp" + rng = np.random.default_rng(1) + for folder in ("ctrl/rep1", "drug/rep1"): + path = root / folder / "a.tif" + path.parent.mkdir(parents=True) + metadata = {"axes": axes} + size = (sizes or {}).get(folder) + if size is not None: + metadata |= {"PhysicalSizeX": size, "PhysicalSizeY": size} + data = rng.integers(0, 4000, shape, dtype=np.uint16) + tifffile.imwrite(path, data, ome=True, photometric="minisblack", metadata=metadata) + nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out")) + nb.steps() + nb.check_button.click() + return nb + + +def test_the_example_is_drawn_by_the_check_and_nothing_is_confirmed(tmp_path): + nb = experiment(tmp_path) + assert nb.preview_image.value[:4] == b"\x89PNG" and ui.is_visible(nb.preview_image) + assert nb._preview_drawn == nb.mapping + assert not ui.is_visible(nb.preview_button) # no "Show preview" to find first + assert nb.confirmed_mapping is None and not nb.preview_checked.value # never by itself + assert "not confirmed yet" in nb.preview_caption.value + assert "ctrl/rep1/a.tif" in nb.preview_caption.value + + +def test_the_example_reads_one_plane_per_channel_and_a_few_time_points(tmp_path, monkeypatch): + nb = experiment(tmp_path, shape=(5, 3, 16, 16)) + read = [] + real = nb._bound_plane + + def spy(rel, mapping, coords): + read.append(dict(coords)) + return real(rel, mapping, coords) + + monkeypatch.setattr(nb, "_bound_plane", spy) + nb._render_preview() + assert read == [{"c": c, "t": 0} for c in range(3)] + [{"c": 0, "t": t} for t in (0, 1, 3, 4)] + + +def test_the_example_is_redrawn_for_every_role_change_and_the_tick_taken_back(tmp_path): + nb = experiment(tmp_path) + nb.preview_checked.button.click() + before = nb.preview_image.value + nb.role_dropdowns[0].value = "Z" # time read as depth: other tiles, other labels + assert nb.preview_image.value not in (b"", before) + assert nb._preview_drawn == nb.mapping and nb.mapping.roles[0] == "Z" + assert not nb.preview_checked.value # the tick was for the other roles + nb.role_dropdowns[0].value = "T" + assert nb._preview_drawn.roles[0] == "T" + nb.role_dropdowns[1].value = "-" # the channel axis without a role: still drawn, refused + assert nb.confirm_button.disabled and nb.confirmed_mapping is None + + +def test_another_image_or_plane_is_one_fold_away(tmp_path): + nb = experiment(tmp_path) + assert nb.preview_more.selected_index is None # collapsed + nb.preview_file.value = "drug/rep1/a.tif" + assert "drug/rep1/a.tif" in nb.preview_caption.value + nb.preview_sliders["T"].value = 1 + assert "t = 1" in nb.preview_caption.value + + +def test_the_pixel_size_of_the_headers_is_one_line_with_the_form_folded(tmp_path): + nb = experiment(tmp_path, {"ctrl/rep1": 0.325, "drug/rep1": 0.325}) + assert _text(nb.pixel_line.value) == ( + "Pixel size: 0.325 × 0.325 µm (from the headers, same in all 2 images)." + ) + assert ui.is_visible(nb.pixel_change) and not ui.is_visible(nb.pixel_form) + assert nb.pixel_units.selected_index is None + nb.pixel_change.click() + assert ui.is_visible(nb.pixel_form) + + +def test_without_a_size_in_the_headers_there_is_one_input_for_the_whole_experiment(tmp_path): + nb = experiment(tmp_path) + assert "none (no size in the headers and none stated: uncalibrated" in _text( + nb.pixel_line.value + ) + assert ui.is_visible(nb.pixel_form) and not ui.is_visible(nb.pixel_change) + out = tmp_path / "out" + assert not (out / "calibration.csv").exists() # nothing is stated for the user + nb.pixel_size.value = "0.108" + nb.pixel_apply.click() + text = (out / "calibration.csv").read_text(encoding="utf-8") + assert ".,X,0.108,micrometer" in text and ".,Y,0.108,micrometer" in text + assert _text(nb.pixel_line.value) == ( + "Pixel size: 0.108 × 0.108 µm (stated by you, same in all 2 images)." + ) + sizes = nb.preflight.calibration.sizes + assert {(s.role, s.value, s.source) for s in sizes["ctrl/rep1/a.tif"] if s.role in "XY"} == { + ("X", 0.108, "stated"), + ("Y", 0.108, "stated"), + } + + +def test_a_wrong_size_changes_nothing_and_says_why(tmp_path): + nb = experiment(tmp_path) + for wrong in ("", "abc", "-1", "0"): + nb.pixel_size.value = wrong + nb.pixel_apply.click() + assert "Nothing was changed" in nb.calibration_message.value, wrong + assert not (tmp_path / "out" / "calibration.csv").exists() + nb.pixel_unit.value = "pixels" + assert nb.pixel_size.disabled + nb.pixel_apply.click() + assert "pixels (stated by you as deliberately uncalibrated" in _text(nb.pixel_line.value) + + +def test_headers_that_disagree_open_the_units_and_nothing_is_chosen(tmp_path): + nb = experiment(tmp_path, {"ctrl/rep1": 0.108, "drug/rep1": 0.216}) + line = _text(nb.pixel_line.value) + assert ( + line == "Headers disagree: 0.108 × 0.108 µm in 1 image, 0.216 × 0.216 µm in 1 (drug/rep1)." + ) + assert "mb-warn" in nb.pixel_line.value + assert nb.pixel_units.selected_index == 0 # the per-unit part is shown, not hidden + table = _text(nb.unit_table.value) + assert "ctrl/rep1 0.108 × 0.108 µm from the headers" in table + assert "drug/rep1 0.216 × 0.216 µm from the headers" in table + assert not (tmp_path / "out" / "calibration.csv").exists() + recorded = { + path: {s.role: (s.value, s.source) for s in sizes} + for path, sizes in nb.preflight.calibration.sizes.items() + } + assert recorded["ctrl/rep1/a.tif"]["X"] == (0.108, "header") + assert recorded["drug/rep1/a.tif"]["X"] == (0.216, "header") # each as its header says + + +def test_a_size_per_unit_and_then_one_for_all_are_each_the_users_statement(tmp_path): + nb = experiment(tmp_path, {"ctrl/rep1": 0.108, "drug/rep1": 0.216}) + out = tmp_path / "out" + nb.calibration_target.value = "drug/rep1" + nb.calibration_size.value = "0.108" + nb.calibration_state.click() + assert "drug/rep1,X,0.108,micrometer" in (out / "calibration.csv").read_text(encoding="utf-8") + assert "Pixel sizes differ" in _text(nb.pixel_line.value) # header and stated: still two + assert "calibration differs from header" in _text(nb.calibration_issues.value) + nb.calibration_axes.value = "Z" + nb.calibration_target.value = "." + nb.calibration_size.value = "2" + nb.calibration_state.click() + nb.pixel_size.value = "0.5" + nb.pixel_apply.click() # one value for all: explicit, and it replaces the per-unit X and Y + text = (out / "calibration.csv").read_text(encoding="utf-8") + assert ".,X,0.5,micrometer" in text and ".,Y,0.5,micrometer" in text + assert "drug/rep1" not in text and ".,Z,2.0,micrometer" in text + assert _text(nb.pixel_line.value) == ( + "Pixel size: 0.5 × 0.5 µm (stated by you, same in all 2 images)." + ) + assert "calibration differs from header" in _text(nb.calibration_issues.value) # still said diff --git a/tests/test_notebook_axes_quick.py b/tests/test_notebook_axes_quick.py index d183980a..463e8e68 100644 --- a/tests/test_notebook_axes_quick.py +++ b/tests/test_notebook_axes_quick.py @@ -30,12 +30,12 @@ def stepper(root, masks, out): def test_complete_header_roles_are_offered_with_a_preview_and_one_button(exp): # noqa: F811 nb = stepper(*exp) - assert ui.is_visible(nb.quick_card) and not ui.is_visible(nb.axis_box) - assert ui.is_visible(nb.quick_image) and nb.quick_image.value[:4] == b"\x89PNG" + assert ui.is_visible(nb.quick_card) and ui.is_visible(nb.axis_box) # roles and example + assert ui.is_visible(nb.preview_image) and nb.preview_image.value[:4] == b"\x89PNG" assert "Nothing is confirmed until you click" in nb.quick_text.value assert nb.confirmed_mapping is None and not nb.preview_checked.value # never automatic - nb.axes_details.click() # the role boxes stay one click away - assert ui.is_visible(nb.axis_box) + assert not ui.is_visible(nb.confirm_button) # one button while the roles are offered + assert not ui.is_visible(nb.preview_checked.widget) def test_the_one_click_records_what_the_two_clicks_record(exp): # noqa: F811 @@ -43,7 +43,6 @@ def test_the_one_click_records_what_the_two_clicks_record(exp): # noqa: F811 quick.quick_confirm.click() root, masks, out = exp manual = stepper(root, masks, out.parent / "manual") - manual.axes_details.click() manual.preview_checked.set(True) manual.confirm_button.click() assert quick.confirmed_mapping == manual.confirmed_mapping is not None @@ -64,7 +63,7 @@ def test_roles_the_header_does_not_name_are_not_offered(tmp_path): nb.steps() nb.check_button.click() assert not ui.is_visible(nb.quick_card) and ui.is_visible(nb.axis_box) - assert not ui.is_visible(nb.axes_details) + assert ui.is_visible(nb.confirm_button) and ui.is_visible(nb.preview_checked.widget) def test_an_unchanged_reopen_offers_the_run_s_roles_and_moves_on(exp): # noqa: F811 @@ -78,13 +77,12 @@ def test_an_unchanged_reopen_offers_the_run_s_roles_and_moves_on(exp): # noqa: assert "the earlier run of this experiment recorded" in nb.quick_text.value nb.quick_confirm.click() assert nb.confirmed_mapping == first.confirmed_mapping - assert nb._stepper.current == 11 # every answer is saved: the run is next + assert nb._stepper.current == 10 # every answer is saved: the run is next def test_a_role_edited_after_the_offer_is_not_confirmed_by_the_old_offer(exp): # noqa: F811 nb = stepper(*exp) offer = nb._quick_offer - nb.axes_details.click() nb.role_dropdowns[0].value = "Z" # C becomes Z: the axis check now has something to say assert offer is not None and nb._quick_offer is None # the header names C: no offer now assert not ui.is_visible(nb.quick_card) and ui.is_visible(nb.axis_box) @@ -100,7 +98,6 @@ def test_a_role_edited_on_a_reopen_is_kept_not_put_back_to_the_run_s_roles(exp): assert first.run().written nb = stepper(*exp) assert nb._quick_offer is not None - nb.axes_details.click() nb.role_dropdowns[0].value = "Z" assert nb.role_dropdowns[0].value == "Z" # the user's edit stands assert nb._quick_offer is None and not ui.is_visible(nb.quick_card) @@ -121,14 +118,14 @@ def broken(*_args, **_kwargs): monkeypatch.setattr(notebook_axis_preview, "read_plane", broken) nb = stepper(*exp) assert nb._quick_offer is not None and ui.is_visible(nb.quick_card) - assert nb.preview_image.value == b"" and nb.quick_image.value == b"" - assert not ui.is_visible(nb.quick_image) # no image is shown - assert "the pixels cannot be decoded" in nb.quick_caption.value # the failure stays visible - assert "This plane cannot be shown with these roles." in nb.quick_caption.value + assert nb.preview_image.value == b"" and not ui.is_visible(nb.preview_image) + assert "the pixels cannot be decoded" in nb.preview_caption.value # the failure stays visible + assert "This plane cannot be shown with these roles." in nb.preview_caption.value + assert "no example of these roles is drawn" in nb.quick_caption.value assert nb.quick_confirm.disabled assert not nb._confirm_quick_axes() # even a click that reaches it is refused assert nb.confirmed_mapping is None and not nb.preview_checked.value - assert "the pixels cannot be decoded" in nb.quick_caption.value + assert "the pixels cannot be decoded" in nb.preview_caption.value def test_a_preview_that_fails_later_withdraws_the_click(exp, monkeypatch): # noqa: F811 @@ -185,8 +182,8 @@ def test_an_image_changed_since_the_folder_check_is_not_previewed_or_confirmed(e stat = path.stat() os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10**10)) nb._refresh_quick_axes() - assert nb.preview_image.value == b"" and not ui.is_visible(nb.quick_image) - assert "changed since the folder check read it" in nb.quick_caption.value + assert nb.preview_image.value == b"" and not ui.is_visible(nb.preview_image) + assert "changed since the folder check read it" in nb.preview_caption.value assert nb.quick_confirm.disabled assert not nb._confirm_quick_axes() assert nb.confirmed_mapping is None and not nb.preview_checked.value @@ -216,6 +213,6 @@ def read_then_rewrite(*args, **kwargs): monkeypatch.setattr(notebook_axis_preview, "read_plane", read_then_rewrite) nb._refresh_quick_axes() assert nb.preview_image.value == b"" and nb._preview_drawn is None - assert "changed since the folder check read it" in nb.quick_caption.value + assert "changed since the folder check read it" in nb.preview_caption.value assert nb.quick_confirm.disabled and not nb._confirm_quick_axes() assert nb.confirmed_mapping is None and not nb.preview_checked.value diff --git a/tests/test_notebook_check_save.py b/tests/test_notebook_check_save.py index 58e3bdf8..cff9c679 100644 --- a/tests/test_notebook_check_save.py +++ b/tests/test_notebook_check_save.py @@ -191,9 +191,7 @@ def test_the_experiment_is_restated_in_words_with_one_question_per_replicate(exp def test_save_says_saved_and_enables_run(exp): nb = saved_nb(exp) - assert ( - "Saved " in nb.save_status.value and "The run (step 12) can start" in nb.save_status.value - ) + assert "Saved " in nb.save_status.value and "The run can start" in nb.save_status.value assert not nb.run_button.disabled assert nb.confirmed_image_type == "fluorescence" _, _, out = exp diff --git a/tests/test_notebook_convert.py b/tests/test_notebook_convert.py index 9f132d12..4e2b0ad1 100644 --- a/tests/test_notebook_convert.py +++ b/tests/test_notebook_convert.py @@ -105,7 +105,7 @@ def test_the_card_is_offered_under_the_axis_check_for_swapped_axes(tmp_path): assert "swap the Y and X" in text(nb.axis_status) assert ui.is_visible(nb.convert_card) children = list(nb.axis_box.children) - assert children.index(nb.convert_card) == children.index(nb.axis_status) + 1 + assert children.index(nb.convert_card) == 1 # right under the roles, their check and example nb.role_dropdowns[1].value, nb.role_dropdowns[2].value = "Y", "X" assert not ui.is_visible(nb.convert_card) diff --git a/tests/test_notebook_mask_step.py b/tests/test_notebook_mask_step.py index 54ce8360..488a122e 100644 --- a/tests/test_notebook_mask_step.py +++ b/tests/test_notebook_mask_step.py @@ -141,7 +141,7 @@ def test_describe_the_masks_reuses_the_ids_of_your_masks_and_asks_no_name(tmp_pa nb.mask_step_button.click() status, shown = nb.mask_step_status.value, nb.mask_step_result.value assert "name each mask" not in status - assert "keeps the ID it was given on Masks (step 10)" in status + assert "keeps the ID it was given on Masks" in status assert "MaskDeclared kindKind (suggested)" in shown assert "Nuclei_seg (nuclei)objectsone object per label" in shown assert "cellsobjects" in shown # declared when it was described diff --git a/tests/test_notebook_owner_fixes.py b/tests/test_notebook_owner_fixes.py index 5c2f68e5..04101a22 100644 --- a/tests/test_notebook_owner_fixes.py +++ b/tests/test_notebook_owner_fixes.py @@ -151,11 +151,11 @@ def test_confirm_axis_roles_says_that_different_channel_counts_block_it(tmp_path assert nb.confirm_button.disabled said = _text(nb.confirm_blocker.value) assert "Not available yet" in said and "different channel counts" in said - assert "red message above" in said and "preview is not ticked" in said + assert "red message above" in said and "example is not ticked" in said nb.preview_checked.button.click() assert nb.confirm_button.disabled # the refusal stays: ticking the preview does not lift it said = _text(nb.confirm_blocker.value) - assert "different channel counts" in said and "preview" not in said + assert "different channel counts" in said and "example" not in said assert nb.confirmed_mapping is None @@ -170,12 +170,14 @@ def test_confirm_axis_roles_names_the_missing_preview_then_the_missing_tick(tmp_ nb.folder_step() nb.axis_step() nb.check_button.click() - assert nb.confirm_button.disabled and nb.preview_checked.disabled - assert "click Show preview" in _text(nb.confirm_blocker.value) - assert "click Show preview" in _text(nb.preview_blocker.value) - nb.show_preview() + assert nb.confirm_button.disabled and not nb.preview_checked.disabled # drawn by itself + assert nb.preview_blocker.value == "" + assert "click Yes, the example looks right" in _text(nb.confirm_blocker.value) + nb.role_dropdowns[1].value = "Z" # Y on no axis: no example can be drawn of these roles + assert nb.preview_checked.disabled and nb.confirm_button.disabled + assert "no example of these roles is drawn" in _text(nb.preview_blocker.value) + nb.role_dropdowns[1].value = "Y" assert nb.preview_blocker.value == "" and not nb.preview_checked.disabled - assert "click Yes, the preview looks right" in _text(nb.confirm_blocker.value) nb.preview_checked.button.click() assert not nb.confirm_button.disabled and nb.confirm_blocker.value == "" nb.confirm_button.click() @@ -246,7 +248,7 @@ def test_the_named_axis_copies_button_says_what_it_waits_for(tmp_path): nb.role_dropdowns[0].value = "C" assert ui.is_visible(nb.convert_card) and nb.convert_button.disabled assert "roles of the copies are not confirmed" in _text(nb.convert_blocker.value) - assert "preview is not ticked" in _text(nb.convert_tick_blocker.value) + assert "example is not ticked" in _text(nb.convert_tick_blocker.value) nb.show_preview() nb.preview_checked.button.click() assert nb.convert_tick_blocker.value == "" diff --git a/tests/test_notebook_prerelease.py b/tests/test_notebook_prerelease.py index ff160230..414e74f9 100644 --- a/tests/test_notebook_prerelease.py +++ b/tests/test_notebook_prerelease.py @@ -9,7 +9,7 @@ beside the button says that the run will stop, and why. - The step-by-step "Yes, the preview looks right" is offered only while a preview of the current roles is shown, as the one-click *Confirm these axes* is (#255). -- "The run (step 12) can start" is said only once the run's checks have run and passed. +- "The run can start" is said only once the run's checks have run and passed. """ import re @@ -129,7 +129,7 @@ def test_a_a_rename_resolved_through_header_names_keeps_the_dates_saves_and_runs assert not nb.save_button.disabled assert nb.save_experiment(), _text(nb.save_status.value) assert _published_dates(exp) == [SAVED_ON, SAVED_ON] - assert "The run (step 12) can start." in nb.save_status.value + assert "The run can start." in nb.save_status.value assert nb.run().written @@ -266,7 +266,7 @@ def test_save_stays_allowed_beside_a_line_saying_the_run_will_stop_and_why(exp): codes = sorted({i.code for i in check.run_refusals}) assert codes and not nb.save_button.disabled # owner decision: saving stays allowed note = _text(nb.save_note.value) - assert "the run (step 12) will stop" in note and "Refused:" in note + assert "the run will stop" in note and "Refused:" in note assert all(code in note for code in codes) assert nb.save_note in nb.save_row.children and nb.save_button in nb.save_row.children assert ui.is_visible(nb.save_note) @@ -296,7 +296,6 @@ def test_the_preview_tick_is_offered_only_while_a_preview_of_these_roles_is_show from morphobricks_preprocessing.planes import PlaneReadError nb = stepper(*exp) - nb.axes_details.click() nb.show_preview() assert nb._preview_drawn is not None and not nb.preview_checked.disabled # the control real = notebook_axis_preview.read_plane @@ -321,7 +320,6 @@ def test_a_ticked_preview_that_then_fails_is_unticked(exp, monkeypatch): # noqa from morphobricks_preprocessing.planes import PlaneReadError nb = stepper(*exp) - nb.axes_details.click() nb.show_preview() nb.preview_checked.button.click() assert nb.preview_checked.value and not nb.confirm_button.disabled @@ -339,7 +337,7 @@ def test_a_ticked_preview_that_then_fails_is_unticked(exp, monkeypatch): # noqa def test_a_reopen_before_the_axes_are_confirmed_does_not_say_the_run_can_start(exp): # noqa: F811 - # Regression: an unchanged reopen said "Saved ..., The run (step 12) can start." while the + # Regression: an unchanged reopen said "Saved ..., The run can start." while the # run's checks (which need the axis roles) had not run. first = stepper(*exp) first.quick_confirm.click() @@ -354,4 +352,4 @@ def test_a_reopen_before_the_axes_are_confirmed_does_not_say_the_run_can_start(e assert "nothing changed" in nb.saved_line.value # true: the folders are as saved nb.quick_confirm.click() # the user confirms the roles: the checks run and pass assert nb._published_check().can_run - assert "The run (step 12) can start." in nb.save_status.value + assert "The run can start." in nb.save_status.value diff --git a/tests/test_notebook_row_fold.py b/tests/test_notebook_row_fold.py index 25fb123a..ae189a12 100644 --- a/tests/test_notebook_row_fold.py +++ b/tests/test_notebook_row_fold.py @@ -255,7 +255,7 @@ def test_a_header_change_before_the_axes_are_confirmed_is_shown_as_blocking(tmp_ assert nb._published().reconciliation.reopened == {"rep1": ("channels_changed",)} assert nb.same_way["rep1"].value # the declared list did not change: the date stays shown = _text(nb.checks) - assert "The run (step 12) is blocked by both" in shown + assert "The run is blocked by both" in shown assert "header contradiction: the image headers of ctrl/rep1" in shown assert "“same way?” of rep1 is asked again" not in shown assert nb.save_button.disabled and nb.run_button.disabled diff --git a/tests/test_notebook_stains_na.py b/tests/test_notebook_stains_na.py index 523e5c41..fe775176 100644 --- a/tests/test_notebook_stains_na.py +++ b/tests/test_notebook_stains_na.py @@ -25,8 +25,8 @@ from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 FOLDERS = ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/rep2"] -STAINS = 7 # step 8, 0-based -TAGS = 8 +STAINS = 6 # step 7, 0-based +TAGS = 7 def _open(tmp_path, *, channel_folders=()): @@ -78,7 +78,7 @@ def test_an_all_rgb_experiment_completes_stains_and_check_and_save_without_a_lab assert not ui.is_visible(nb.channel_intro) # "first name what you stained" would contradict assert nb.todo.value == "" and not nb.save_button.disabled assert nb.save_experiment() - assert _state(nb, 10).done + assert _state(nb, 9).done def test_the_saved_file_declares_exactly_r_g_b_and_the_rgb_image_type(tmp_path): diff --git a/tests/test_notebook_stepper.py b/tests/test_notebook_stepper.py index 4366239c..01f9041d 100644 --- a/tests/test_notebook_stepper.py +++ b/tests/test_notebook_stepper.py @@ -27,7 +27,6 @@ ORDER = [ "Images found", "Axes", - "Pixel sizes", "Conditions", "Replicates", "Compared against", @@ -139,26 +138,27 @@ def test_the_steps_are_in_the_decided_order_with_one_page_on_screen(exp): # noq assert [step.title for step in nb._stepper.steps] == ORDER assert nb.steps() is nb._stepper.widget # built once assert shown(nb) == 0 and nb._stepper.current == 0 # a first check stays on its results - assert kinds(nb)[:3] == ["current", "todo", "noted"] # axes to confirm; sizes: uncalibrated + assert kinds(nb)[:2] == ["current", "todo"] # the axes are still to confirm nb._stepper.chips[1].click() assert shown(nb) == 1 and kinds(nb)[0] == "done" page = nb._stepper.pages[1] - assert "Step 2 of 13 · Axes" in page.children[1].children[0].value + assert "Step 2 of 12 · Axes" in page.children[1].children[0].value 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.masks_card) - assert contains(1, nb.axis_box) and contains(2, nb.calibration_card) - assert contains(3, nb.condition_rows) and not contains(3, nb.replicate_rows) - assert contains(4, nb.design_card) and contains(4, nb.replicate_rows) - assert contains(5, nb.references_card) and contains(6, nb.type_rows.widget) - assert contains(7, nb.channel_labels.widget) and contains(8, nb.channel_grid.widget) - assert contains(9, nb.masks_box) and contains(10, nb.check_card) - assert contains(10, nb.overview_box) and contains(11, nb.run_button) - assert contains(12, nb.mask_step_card) and not contains(11, nb.mask_step_card) - # The names rows are split between steps 4 and 5, and still the names card's grid. + assert contains(1, nb.axis_box) and contains(1, nb.calibration_card) # one step (owner) + assert _holds(nb.axis_box, nb.calibration_card) and contains(1, nb.quick_card) + assert contains(2, nb.condition_rows) and not contains(2, nb.replicate_rows) + assert contains(3, nb.design_card) and contains(3, nb.replicate_rows) + assert contains(4, nb.references_card) and contains(5, nb.type_rows.widget) + assert contains(6, nb.channel_labels.widget) and contains(7, nb.channel_grid.widget) + assert contains(8, nb.masks_box) and contains(9, nb.check_card) + assert contains(9, nb.overview_box) and contains(10, nb.run_button) + assert contains(11, nb.mask_step_card) and not contains(10, nb.mask_step_card) + # The names rows are split between steps 3 and 4, and still the names card's grid. (conditions,) = nb.condition_rows.children assert "Condition" in conditions.children[0].children[0].value assert list(nb.names_grid.children) == [conditions, *nb.replicate_rows.children] @@ -199,10 +199,10 @@ def test_moving_through_every_page_keeps_every_answer(exp): # noqa: F811 chip.click() assert snapshot(nb) == before assert nb.draft_answers.conditions["drug"].display_name == "Drug A" - assert all(k in ("done", "noted") for k in kinds(nb)[1:10]) and kinds(nb)[0] == "current" - assert kinds(nb)[10] == "todo" # not saved yet + assert all(k in ("done", "noted") for k in kinds(nb)[1:9]) and kinds(nb)[0] == "current" + assert kinds(nb)[9] == "todo" # not saved yet assert nb.save_experiment() - assert kinds(nb)[10] == "done" + assert kinds(nb)[9] == "done" def test_a_reopened_experiment_opens_on_the_axes_its_only_open_step(exp): # noqa: F811 @@ -214,11 +214,11 @@ def test_a_reopened_experiment_opens_on_the_axes_its_only_open_step(exp): # noq nb.preview_checked.set(True) nb.show_preview() nb.confirm_button.click() - assert all(k in ("done", "noted") for k in kinds(nb)[:11] if k != "current") - assert "saved " in nb._stepper.chips[10].tooltip + assert all(k in ("done", "noted") for k in kinds(nb)[:10] if k != "current") + assert "saved " in nb._stepper.chips[9].tooltip # The saved reopening folds the cards; a page that shows one unfolds it. assert nb._folded and not ui.is_visible(nb.design_card) - nb._stepper.chips[4].click() + nb._stepper.chips[3].click() assert not nb._folded and ui.is_visible(nb.design_card) @@ -233,9 +233,9 @@ def test_a_changed_reopen_turns_the_affected_steps_orange(tmp_path): nb = stepped(root, masks, out) states = nb._stepper.states() assert "since saved: 1 new, 0 gone" in states[0].text - assert states[8].changed and states[10].changed # the new folder's tags; save again - assert not states[3].changed and states[3].done # the conditions did not change - assert kinds(nb)[8] == "changed" and kinds(nb)[10] == "changed" + assert states[7].changed and states[9].changed # the new folder's tags; save again + assert not states[2].changed and states[2].done # the conditions did not change + assert kinds(nb)[7] == "changed" and kinds(nb)[9] == "changed" assert nb._stepper.current == 1 # landed on the axes: each session confirms them again @@ -244,10 +244,10 @@ def test_consistency_follows_the_pairing_and_shows_only_when_it_warns(tmp_path): for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/day2"]): add_folder(root, masks, folder, i + 1) nb = stepped(root, masks, out) - assert _holds(nb._stepper.pages[4], nb.consistency_card) + assert _holds(nb._stepper.pages[3], nb.consistency_card) assert not _holds(nb._stepper.pages[0], nb.consistency_card) assert ui.is_visible(nb.consistency_slot) # rep2 and day2 are each in one condition only - assert "on Replicates (step 5)" in nb._stepper.waits[0].value # step 1 says where + assert "on Replicates" in nb._stepper.waits[0].value # step 1 says where (dropdown,) = [ row.children[2] for row in nb.pairing_grid.children[1:] @@ -256,7 +256,7 @@ def test_consistency_follows_the_pairing_and_shows_only_when_it_warns(tmp_path): dropdown.value = "rep2" assert not ui.is_visible(nb.consistency_slot) assert nb._stepper.waits[0].value == "" - assert "drug/day2 = rep2" in nb._stepper.states()[4].text + assert "drug/day2 = rep2" in nb._stepper.states()[3].text def test_the_axes_page_has_no_stale_image_type_sentence_and_no_broken_picture(exp): # noqa: F811 @@ -277,11 +277,12 @@ 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()[2] + state = nb._stepper.states()[1] # the pixel size is part of the axes step assert state.done and state.note == "uncalibrated" - assert state.text.startswith("uncalibrated (pixels)") - assert nb._stepper.chips[2].description == "✓ 3 Pixel sizes · uncalibrated" - assert kinds(nb)[2] == "noted" + assert "confirmed · uncalibrated (pixels)" in state.text + nb._stepper.go(0) + assert nb._stepper.chips[1].description == "✓ 2 Axes · uncalibrated" + assert kinds(nb)[1] == "noted" def test_pixel_sizes_from_the_headers_are_a_plain_done(tmp_path): @@ -295,6 +296,9 @@ def test_pixel_sizes_from_the_headers_are_a_plain_done(tmp_path): nb = PreprocessingNotebook(str(root), output_folder=str(out)) nb.steps() nb.check_button.click() - state = nb._stepper.states()[2] + assert not nb._stepper.states()[1].done # the axes are not confirmed yet + nb.quick_confirm.click() + nb._stepper.go(0) + state = nb._stepper.states()[1] assert state.done and not state.note and "0.65" in state.text - assert kinds(nb)[2] == "done" + assert kinds(nb)[1] == "done" diff --git a/tests/test_notebook_tag_blocks.py b/tests/test_notebook_tag_blocks.py index e1aa9376..8108c2ca 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 Image type (step 7)" in text + assert "confirm it on Image type" 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) (Image type, step 7)" in repr(grid.blocks.children) + assert "Answered colour (RGB) (Image type)" in repr(grid.blocks.children) assert not grid.cells and grid.complete diff --git a/tests/test_notebook_ui.py b/tests/test_notebook_ui.py index ef252e22..21059744 100644 --- a/tests/test_notebook_ui.py +++ b/tests/test_notebook_ui.py @@ -270,9 +270,8 @@ def test_role_notes_and_plane_summary(experiment): def test_confirm_needs_valid_roles_and_a_checked_preview(experiment): nb = checked(experiment) assert nb.confirm_button.disabled - assert nb.preview_checked.disabled # the preview must be shown first - nb.preview_button.click() - assert not nb.preview_checked.disabled + assert not nb.preview_checked.disabled # the example is drawn by the check itself + assert nb.preview_image.value[:4] == b"\x89PNG" and not nb.preview_checked.value nb.preview_checked.button.click() assert not nb.confirm_button.disabled nb.role_dropdowns[1].value = "-" @@ -311,8 +310,7 @@ def test_confirmation_locks_and_unlocks(experiment): def test_preview_sliders_browse_planes(experiment): nb = checked(experiment) - assert not any(ui.is_visible(s) for s in nb.preview_sliders.values()) - nb.preview_button.click() + assert not ui.is_visible(nb.preview_button) # the example needs no click assert bytes(nb.preview_image.value).startswith(b"\x89PNG") assert "ctrl/rep1/a.tif" in nb.preview_caption.value assert "c = 0, t = 0" in nb.preview_caption.value # the first time point first diff --git a/tools/code_rules_baseline.json b/tools/code_rules_baseline.json index 22a9196f..5e46ff9f 100644 --- a/tools/code_rules_baseline.json +++ b/tools/code_rules_baseline.json @@ -35,7 +35,7 @@ "tests/test_discovery.py: # lines (max #)": 826, "tests/test_masks.py: # lines (max #)": 612, "tests/test_metadata.py: # lines (max #)": 629, - "tests/test_notebook_ui.py: # lines (max #)": 1133, + "tests/test_notebook_ui.py: # lines (max #)": 1131, "tests/test_planes.py: # lines (max #)": 818, "tests/test_run.py: # lines (max #)": 860, "tests/test_zarr_input.py: # lines (max #)": 850, From 003e826b77533842cb8b4f7f860a23afdf828d6b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:29:17 +0000 Subject: [PATCH 2/2] Notebook: Use for all images shows what it replaces before it writes The one-size-for-all statement removed the X and Y statements of single folders and images (and overrode the header sizes) at the first click, without naming them. It now lists the statements it removes or replaces and the sizes the images have now, and writes only after Replace them; under the lock it refuses when calibration.csv changed since they were shown. With nothing to replace it writes at once, as before. Regression tests in tests/test_notebook_axes_example.py. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- CHANGELOG.md | 4 +- .../notebook_calibration.py | 154 ++++++++++++++++-- tests/test_notebook_axes_example.py | 49 ++++++ 3 files changed, 193 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f09b1fa..e916bc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,9 @@ Owner test of v0.5.0 (10 November 2026), items (e) and (f). The shipped notebook µm (from the headers, same in all 270 images)". When nothing gives a size, one input states it for the whole experiment (**Use for all images**: the user's explicit statement, written to `calibration.csv` for `.` as the `calibrate` command does, replacing other X and Y - statements; Z statements are kept). When the images do not agree the line says so with the + statements; Z statements are kept). When it would remove or replace a statement, or a size + the images have from their headers or an earlier statement, it first lists them and writes + only after **Replace them**; it refuses if `calibration.csv` changed since. When the images do not agree the line says so with the counts and folders ("Headers disagree: 0.108 × 0.108 µm in 180 images, 0.216 × 0.216 µm in 90 (Condition_2/Biological_repeat_2)") and **Set per imaging unit…** opens: the size of each folder and the form to state one for a folder or an image. Nothing is chosen silently: each diff --git a/src/morphobricks_preprocessing/notebook_calibration.py b/src/morphobricks_preprocessing/notebook_calibration.py index 71f7e4a1..71a1e4c1 100644 --- a/src/morphobricks_preprocessing/notebook_calibration.py +++ b/src/morphobricks_preprocessing/notebook_calibration.py @@ -25,8 +25,14 @@ from . import notebook_style as ui from .axis_review import review_axes -from .calibration import CALIBRATION_FILENAME, Calibration, calibration_entry -from .calibration_io import CalibrationFormatError +from .calibration import ( + CALIBRATION_FILENAME, + EXPERIMENT, + Calibration, + CalibrationEntry, + calibration_entry, +) +from .calibration_io import CalibrationFormatError, read_calibration from .notebook_options import _WIDE from .notebook_render import issues_html from .notebook_values import size_rows @@ -53,6 +59,28 @@ AXES_OPTIONS = [("X and Y", "XY"), ("X", "X"), ("Y", "Y"), ("Z", "Z"), ("X, Y and Z", "XYZ")] +def replaced_by_one_size( + calibration: Calibration, new: tuple[CalibrationEntry, ...] +) -> tuple[CalibrationEntry, ...]: + """Return the X and Y statements that stating ``new`` for the whole experiment replaces. + + Every X or Y statement of a single folder or image is removed (it would otherwise still + win), and a whole-experiment X or Y statement is replaced when it says something else. + """ + wanted = {(entry.axis, entry.size, entry.unit) for entry in new} + return tuple( + entry + for entry in calibration.entries + if entry.axis in ("X", "Y") + and (entry.target != EXPERIMENT or (entry.axis, entry.size, entry.unit) not in wanted) + ) + + +def _statement_words(entry: CalibrationEntry) -> str: + """Return one statement as the confirmation lists it: ``drug/rep1: X 0.108 micrometer``.""" + return f"{entry.target}: {entry.axis} {entry.describe()}" + + class _CalibrationCard: """Step 1's calibration card: the recorded pixel sizes and the statements.""" @@ -92,6 +120,7 @@ def _build_calibration_card(self) -> None: self.pixel_line, self.pixel_change, self.pixel_form, + self.pixel_replace_box, self.calibration_message, self.calibration_issues, self.pixel_units, @@ -156,6 +185,25 @@ def _build_pixel_line(self) -> None: self.pixel_change.on_click(lambda _: self._toggle_pixel_form()) self.pixel_apply.on_click(lambda _: self._state_for_all()) self.pixel_unit.observe(lambda _: self._pixel_unit_changed(), names="value") + self._build_pixel_replace() + + def _build_pixel_replace(self) -> None: + """Build the confirmation shown before one size replaces what gives the sizes now.""" + self._pending_for_all: tuple | None = None + self.pixel_replace_text = widgets.HTML() + self.pixel_replace_yes = ui.button(" Replace them", style="warning", icon="check") + self.pixel_replace_no = ui.button(" Cancel", icon="times") + self.pixel_replace_box = widgets.VBox( + [ + self.pixel_replace_text, + widgets.HBox([self.pixel_replace_yes, self.pixel_replace_no]), + ] + ) + ui.set_visible(self.pixel_replace_box, False) + self.pixel_replace_yes.on_click(lambda _: self._confirm_for_all()) + self.pixel_replace_no.on_click(lambda _: self._cancel_for_all()) + for box in (self.pixel_size, self.pixel_unit): + box.observe(lambda _: self._cancel_for_all(), names="value") def _pixel_unit_changed(self) -> None: """Disable the size for ``pixels``, which states no value.""" @@ -165,21 +213,91 @@ def _toggle_pixel_form(self) -> None: ui.set_visible(self.pixel_form, not ui.is_visible(self.pixel_form)) def _state_for_all(self) -> None: - """State one X and Y size for the whole experiment, replacing every other X/Y statement. + """State one X and Y size for the whole experiment, once the user saw what it replaces. The user's explicit statement for all images: statements for single folders or images would otherwise still apply to them (the most specific wins), so they are removed - along X and Y; Z statements are kept. Header sizes that differ are then named by the - ``calibration_differs_from_header`` warning, as for any statement. + along X and Y; Z statements are kept. Before anything is written, the statements it + removes or replaces and the sizes the images have now are shown, and only **Replace + them** writes; with nothing to replace it is written at once. Header sizes that differ + are then named by the ``calibration_differs_from_header`` warning, as for any statement. """ + self._cancel_for_all() unit = self.pixel_unit.value size = None if unit == PIXELS else self.pixel_size.value.strip() + try: + new = tuple(calibration_entry(EXPERIMENT, axis, size, unit) for axis in ("X", "Y")) + current = self._calibration_statements() + except (CalibrationFormatError, OSError, ValueError) as exc: + self.calibration_message.value = ui.status("error", "Nothing was changed.", [str(exc)]) + return + replaced = replaced_by_one_size(current, new) + overridden = self._overridden_sizes(new) + if not replaced and not overridden: + self._write_for_all(new, replaced) + return + self._pending_for_all = (new, replaced) + lines = [f"Statements removed or replaced: {_statement_words(e)}" for e in replaced] + lines += [f"The images have now: {line}" for line in overridden] + self.pixel_replace_text.value = ui.status( + "warn", + f"Use {new[0].describe()} (X and Y) for all images? This replaces what gives " + "their size now; nothing is written until you click Replace them.", + lines, + ) + ui.set_visible(self.pixel_replace_box, True) + + def _calibration_statements(self) -> Calibration: + """Return the statements ``calibration.csv`` holds now (read only, without the lock).""" + output = self._design_folder() + path = None if output is None else output / CALIBRATION_FILENAME + return read_calibration(path)[0] if path is not None and path.is_file() else Calibration() + + def _overridden_sizes(self, new: tuple[CalibrationEntry, ...]) -> list[str]: + """Return the line about the X and Y sizes the images have now, when one differs.""" + if self.preflight is None: + return [] + groups = xy_groups(self._shown_calibration()[0].calibration.sizes) + stated = (new[0].size, None if new[0].in_pixels else new[0].unit) + differs = any( + (size.value, size.unit) != stated + for group in groups + if group.calibrated + for size in (group.x, group.y) + ) + return [summary_line(groups)[1]] if differs else [] + + def _confirm_for_all(self) -> None: + """Write the one size shown, unless the statements changed since they were shown.""" + pending = self._pending_for_all + self._cancel_for_all() + if pending is not None: + self._write_for_all(*pending) + + def _cancel_for_all(self) -> None: + """Take back the confirmation: nothing is written.""" + self._pending_for_all = None + if getattr(self, "pixel_replace_box", None) is not None: + ui.set_visible(self.pixel_replace_box, False) + + def _write_for_all( + self, new: tuple[CalibrationEntry, ...], replaced: tuple[CalibrationEntry, ...] + ) -> None: + """Write ``new`` for the whole experiment, removing exactly the ``replaced`` shown.""" def change(calibration: Calibration) -> Calibration: + if replaced_by_one_size(calibration, new) != replaced: + raise ValueError( + f"{CALIBRATION_FILENAME} changed since its statements were shown: click " + "Use for all images again to see them" + ) kept = Calibration(tuple(e for e in calibration.entries if e.axis not in ("X", "Y"))) - return kept.stating(calibration_entry(".", axis, size, unit) for axis in ("X", "Y")) + return kept.stating(new) - self._change_calibration(change) + if self._change_calibration(change, validate=False) and replaced: + self.calibration_message.value += ui.status( + "info", "Replaced: " + "; ".join(_statement_words(e) for e in replaced) + ) def _calibration_unit_changed(self) -> None: """Disable the size for ``pixels``, which states no value.""" @@ -208,6 +326,7 @@ def _shown_calibration(self, output: Path | None = None) -> tuple[Preflight, dic def _show_calibration(self) -> None: """Show the pixel size in one line, the per-unit sizes and the calibration's messages.""" pre = self.preflight + self._cancel_for_all() # a confirmation shown is of the sizes before this change ui.set_visible(self.calibration_card, pre is not None and bool(pre.metadata.files)) if pre is None: return @@ -265,28 +384,37 @@ def _remove_size(self) -> None: target, axes = self.calibration_target.value.strip(), tuple(self.calibration_axes.value) self._change_calibration(lambda calibration: calibration.without(target, axes)) - def _change_calibration(self, change: Callable[[Calibration], Calibration]) -> None: - """Write a change to ``calibration.csv`` under the lock, then show the new sizes.""" + def _change_calibration( + self, change: Callable[[Calibration], Calibration], *, validate: bool = True + ) -> bool: + """Write a change to ``calibration.csv`` under the lock, then show the new sizes. + + With ``validate``, the change is first tried on no statements, so that a wrong + statement is refused before the lock is taken. Returns False when nothing was written + because the change was refused (the message says why). + """ output = self._design_folder() if output is None or self.preflight is None: self.calibration_message.value = ui.status( "warn", "Check the experiment folder with an output folder outside it first." ) - return + return False try: - change(Calibration()) # refuse a wrong statement before taking the lock + if validate: + change(Calibration()) # refuse a wrong statement before taking the lock with self.log.attached(): _, written = update_calibration(output, change) except OutputLockedError: self.folder_lock.show(output) self.calibration_message.value = ui.status("error", "Nothing was changed.") - return + return False except (CalibrationFormatError, OSError, ValueError) as exc: self.calibration_message.value = ui.status("error", "Nothing was changed.", [str(exc)]) - return + return False finally: self._show_log() self._calibration_written(output, written) + return True def _calibration_written(self, output: Path, written: Path | None) -> None: """Read the calibration again and update everything that shows the sizes.""" diff --git a/tests/test_notebook_axes_example.py b/tests/test_notebook_axes_example.py index 2ba4b0a0..b230e830 100644 --- a/tests/test_notebook_axes_example.py +++ b/tests/test_notebook_axes_example.py @@ -319,6 +319,8 @@ def test_a_size_per_unit_and_then_one_for_all_are_each_the_users_statement(tmp_p nb.calibration_state.click() nb.pixel_size.value = "0.5" nb.pixel_apply.click() # one value for all: explicit, and it replaces the per-unit X and Y + assert "drug/rep1: X 0.108 micrometer" in _text(nb.pixel_replace_text.value) # shown first + nb.pixel_replace_yes.click() text = (out / "calibration.csv").read_text(encoding="utf-8") assert ".,X,0.5,micrometer" in text and ".,Y,0.5,micrometer" in text assert "drug/rep1" not in text and ".,Z,2.0,micrometer" in text @@ -326,3 +328,50 @@ def test_a_size_per_unit_and_then_one_for_all_are_each_the_users_statement(tmp_p "Pixel size: 0.5 × 0.5 µm (stated by you, same in all 2 images)." ) assert "calibration differs from header" in _text(nb.calibration_issues.value) # still said + + +def test_one_size_for_all_shows_what_it_replaces_before_it_takes_effect(tmp_path): + nb = experiment(tmp_path, {"ctrl/rep1": 0.108, "drug/rep1": 0.216}) + out = tmp_path / "out" + nb.calibration_target.value = "drug/rep1" + nb.calibration_size.value = "0.108" + nb.calibration_state.click() + before = (out / "calibration.csv").read_bytes() + nb.pixel_size.value = "0.5" + nb.pixel_apply.click() + assert (out / "calibration.csv").read_bytes() == before # nothing yet: the user must see + assert ui.is_visible(nb.pixel_replace_box) + shown = _text(nb.pixel_replace_text.value) + assert "drug/rep1: X 0.108 micrometer" in shown and "drug/rep1: Y 0.108 micrometer" in shown + assert "The images have now: Pixel sizes differ: 0.108 × 0.108 µm in 1 image" in shown + nb.pixel_replace_no.click() + assert not ui.is_visible(nb.pixel_replace_box) + assert (out / "calibration.csv").read_bytes() == before + nb.pixel_apply.click() + nb.pixel_replace_yes.click() + text = (out / "calibration.csv").read_text(encoding="utf-8") + assert ".,X,0.5,micrometer" in text and "drug/rep1" not in text + assert "Replaced: drug/rep1: X 0.108 micrometer" in _text(nb.calibration_message.value) + + +def test_one_size_for_all_refuses_when_the_statements_changed_after_they_were_shown(tmp_path): + nb = experiment(tmp_path, {"ctrl/rep1": 0.108, "drug/rep1": 0.216}) + out = tmp_path / "out" + nb.pixel_size.value = "0.5" + nb.pixel_apply.click() # replaces the header sizes: shown first + assert ui.is_visible(nb.pixel_replace_box) + out.mkdir(exist_ok=True) + (out / "calibration.csv").write_text( + "target,axis,size,unit\nctrl/rep1,X,0.3,micrometer\n", encoding="utf-8" + ) + nb.pixel_replace_yes.click() + assert "Nothing was changed" in nb.calibration_message.value + assert "ctrl/rep1,X,0.3" in (out / "calibration.csv").read_text(encoding="utf-8") + + +def test_one_size_for_all_with_nothing_to_replace_takes_effect_at_once(tmp_path): + nb = experiment(tmp_path) # no size in the headers, nothing stated + nb.pixel_size.value = "0.2" + nb.pixel_apply.click() + assert not ui.is_visible(nb.pixel_replace_box) + assert ".,X,0.2,micrometer" in (tmp_path / "out" / "calibration.csv").read_text("utf-8")