From 3d80bd230a20c996a123a8e66075defce22207a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 10:03:27 +0000 Subject: [PATCH 1/2] Notebook: axes proposed and changed at the quick scan, validated on every image; differing pixel sizes in one line Owner's test of v0.5.1, items (s) and (x). The quick scan shows 'Axes of this image' (one list per dimension, from the header); a changed role redraws the pictures and becomes the proposal the Axes step starts from after the folder check validated it against every image. Three or more different pixel sizes are one line with their range and spread, a folded table and the two explicit choices. Run messages are short cards. Nothing is confirmed or chosen without a click; no published file changes. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- AGENTS.md | 2 + CHANGELOG.md | 18 ++ docs/user_guide.md | 23 +- pyproject.toml | 1 + .../notebook_axes_quick.py | 5 +- .../notebook_calibration.py | 29 ++- .../notebook_compact.py | 5 + .../notebook_quick_scan.py | 3 +- .../notebook_render.py | 6 + .../notebook_scan_axes.py | 206 ++++++++++++++++ .../notebook_steps.py | 15 +- .../pixel_size_summary.py | 48 ++++ tests/test_notebook_axes_example.py | 7 +- tests/test_notebook_compact.py | 7 +- tests/test_notebook_scan_axes.py | 233 ++++++++++++++++++ 15 files changed, 595 insertions(+), 13 deletions(-) create mode 100644 src/morphobricks_preprocessing/notebook_scan_axes.py create mode 100644 tests/test_notebook_scan_axes.py diff --git a/AGENTS.md b/AGENTS.md index 47728e1..08a417a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -328,6 +328,8 @@ src/morphobricks_preprocessing/ dropdowns from image_tree.py, t and z sliders, every channel of one plane, at most 6); image_view.py read_view, the one reader behind it; notebook_viewers.py which roles each step reads an image with + notebook_scan_axes.py the quick scan's "Axes of this image": roles proposed from the header, + changed right there, the proposal the Axes step starts from report_view.py report_html: the check's or run's report as headings and tables (pure) notebook_quick_scan.py the first step's quick scan: the folder walk only, one random image for looking only, then the explicit click that reads every header diff --git a/CHANGELOG.md b/CHANGELOG.md index fe8272c..7871c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,24 @@ v0.5.1: no run before v0.5.1 is read.) - **Pixel size: two labelled inputs, X and Y**, with **same for X and Y** (ticked: Y follows X), instead of "One pixel is …". What is stated is unchanged (`calibration.csv`, X and Y for `.`). +- **New axes flow.** The quick scan proposes the axes of the image it shows from that image's + header (**Axes of this image**: one list per dimension); a role changed there redraws the + pictures at once, and a header that names no axes gets its picture once the user says what + its dimensions are. The roles left there are the proposal the Axes step starts from: the + folder check reads every header, the proposal is checked against every image + (`review_axes`, unchanged) and one line on Images says whether it fits them all; the Axes + step confirms for the whole experiment, as before (one click when every header agrees, else + the tick and **Confirm axis roles**), and says that the axes can be changed. Nothing is + confirmed at the scan; a new scan forgets the roles of the one before. +- **Pixel sizes that differ are one line.** With three or more different sizes in one unit: + *Pixel sizes differ slightly: 0.10047–0.10054 µm (spread 0.07 %) across 138 images, 25 + combinations* (`pixel_size_summary.spread_line`), the sizes in a folded table (size, images, + folders, source) and the two explicit choices: one size for all images (shown before it + replaces anything, recorded as stated) or sizes per imaging unit. The per-unit tables no + longer open by themselves. Nothing is chosen silently: until a choice, each image is + recorded with its own header's size, as before. +- **Run messages are short cards**: a message whose first sentence lists every image shows + its first 200 characters, with the whole message, the files and what to do one click away. - **Notebook file, cell 3:** on Google Colab it calls `google.colab.output.no_vertical_scroll()`, so the app is not shown in an output frame that scrolls on its own. Nothing changes elsewhere. diff --git a/docs/user_guide.md b/docs/user_guide.md index f0da3f1..5ef28b3 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -291,11 +291,19 @@ editing, or the confirmed ones. An image that cannot be drawn gets a sentence sa 1. **Images.** Give the folders (images; masks and output are optional) and click **Scan the folders**. The scan lists the conditions and folders without reading any image header and shows one image chosen at random in the viewer, for looking only; any other image can be - chosen there. If this is your experiment, click **Looks right: read all image headers**. - The viewer then shows the images as the check read them; the folders found, the check's + chosen there. Under the pictures, **Axes of this image** proposes a role for each of its + dimensions from its header. If a picture looks wrong (a strip, stripes, channels that are + really slices), change a role right there: the pictures are redrawn, and a header that + names no axes gets its picture once you say what its dimensions are. What you leave there + is the proposal for every image; nothing is confirmed yet. If this is your experiment, + click **Looks right: read all image headers**: every header is read and the proposal is + checked against every image (one line says whether it fits them all). The viewer then + shows the images as the check read them; the folders found, the check's report (formatted: a summary, then tables with the details folded) and **Exclude images** are collapsed below. -2. **Axes.** What the files hold, in three parts. +2. **Axes.** What the files hold, in three parts. The roles start from the proposal of step + 1 (the header's, or what you chose on the example) and can be changed here at any time; + once confirmed, **Change axis roles** takes the confirmation back. - **Axis roles:** one row per image dimension with its role, and under them the viewer with one image as these roles read it, redrawn whenever you change a role (a wrong role looks wrong: a strip, stripes); choose any other image, time point or slice there. Click **Confirm these axes** (offered when the headers name @@ -304,8 +312,13 @@ editing, or the confirmed ones. An image that cannot be drawn gets a sentence sa same in all 270 images)*. When the headers give none, type it once under **Pixel size X** (**Y** follows while **same for X and Y** is ticked; untick it to type another Y) and click **Use for all images**, or leave the images uncalibrated; nothing is guessed. When the headers - disagree, the line says how many images have which size, and **Set per imaging unit…** - opens. + disagree, the line says how many images have which size; with three or more sizes it + gives their range instead (*Pixel sizes differ slightly: 0.10047–0.10054 µm (spread + 0.07 %) across 138 images, 25 combinations*), with the sizes in a folded table. Nothing + is chosen for you: until you choose, each image is recorded with the size of its own + header. Either state one size for all images (it is shown what it replaces before it is + written, and it is recorded as stated by you), or state sizes per folder under **Set per + imaging unit…**. - **Image type:** what the channel dimension holds, *separate stains or markers* or *colour (RGB)*. It is proposed from the image headers and recorded when you click **Use for all folders**. **Set per folder…** holds one row per folder; it opens when the diff --git a/pyproject.toml b/pyproject.toml index 9565f22..b574740 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,6 +147,7 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.notebook_image_type_line" = "ui" "morphobricks_preprocessing.notebook_quick_scan" = "ui" "morphobricks_preprocessing.notebook_viewer" = "ui" +"morphobricks_preprocessing.notebook_scan_axes" = "ui" "morphobricks_preprocessing.notebook_viewers" = "ui" "morphobricks_preprocessing.image_view" = "io" "morphobricks_preprocessing.image_tree" = "pure" diff --git a/src/morphobricks_preprocessing/notebook_axes_quick.py b/src/morphobricks_preprocessing/notebook_axes_quick.py index fcffa64..d84fccd 100644 --- a/src/morphobricks_preprocessing/notebook_axes_quick.py +++ b/src/morphobricks_preprocessing/notebook_axes_quick.py @@ -146,9 +146,12 @@ def _quick_text(self, offer: AxisMapping) -> str: for axis, role in zip(offer.axes, offer.roles, strict=True) if role is not None ) + chosen = getattr(self, "_scan_roles", {}) source = ( "the roles the earlier run of this experiment recorded" if self._saved_axes() is not None + else "the roles you chose on the example image (Images), which every header agrees with" + if offer.axes in chosen and chosen[offer.axes].roles == offer.roles else "the roles every image header names" ) return ui.status( @@ -157,7 +160,7 @@ def _quick_text(self, offer: AxisMapping) -> str: [ *self._headers_changed_line(), f"These are {source}. If the example shows your sample as you expect, confirm. " - "Nothing is confirmed until you click.", + "Nothing is confirmed until you click. They can be changed above.", ], raw=True, ) diff --git a/src/morphobricks_preprocessing/notebook_calibration.py b/src/morphobricks_preprocessing/notebook_calibration.py index 3ecf8ac..7159544 100644 --- a/src/morphobricks_preprocessing/notebook_calibration.py +++ b/src/morphobricks_preprocessing/notebook_calibration.py @@ -36,7 +36,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 .pixel_size_summary import combination_rows, summary_line, unit_rows, xy_groups from .recorded_sizes import PIXELS, SIZE_ROLES from .run import OutputLockedError, Preflight from .run.calibrate import update_calibration @@ -124,6 +124,7 @@ def _build_calibration_card(self) -> None: "Pixel size", [ self.pixel_line, + self.pixel_differs, self.pixel_change, self.pixel_form, self.pixel_replace_box, @@ -163,6 +164,7 @@ def _build_pixel_units(self, form: widgets.Widget) -> None: 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.pixel_differs = widgets.HTML() self.unit_table = widgets.HTML() narrow = widgets.Layout(width="110px") self.pixel_x = widgets.Text(placeholder="e.g. 0.325", layout=narrow) @@ -369,11 +371,12 @@ def _show_calibration(self) -> None: groups = xy_groups(sizes) kind, line = summary_line(groups) self.pixel_line.value = ui.status(kind, line) + self.pixel_differs.value = self._differing_sizes(groups) 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: + if len(groups) > 1 and not self._one_line_sizes: 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)]) @@ -391,6 +394,28 @@ def _show_calibration(self) -> None: 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 "" + _one_line_sizes = False + """True in the shipped notebook: sizes that differ are one line, a folded table and the + two explicit choices, and the per-unit part stays folded until it is opened.""" + + def _differing_sizes(self, groups: list) -> str: + """Return, for sizes that differ, the folded table and the choices (HTML), else "".""" + if len(groups) < 2 or not self._one_line_sizes: + return "" + rows = [("Pixel size (X × Y)", "Images", "Folders", "From"), *combination_rows(groups)] + table = ( + f"
The {len(groups)} different sizes" + f"{ui.table(rows)}
" + ) + choices = ( + "
Nothing is chosen " + "for you. Until you choose, each image is " + "recorded with the size of its own header. Either state one size for all " + "images below (recorded as stated by you; the headers stay as they are), or " + "state sizes per folder under Set per imaging unit….
" + ) + return table + choices + def _calibration_targets(self) -> list[str]: """Return the targets offered: the whole experiment, every folder and every image.""" images = self.preflight.discovery.images diff --git a/src/morphobricks_preprocessing/notebook_compact.py b/src/morphobricks_preprocessing/notebook_compact.py index bce2a20..02561c5 100644 --- a/src/morphobricks_preprocessing/notebook_compact.py +++ b/src/morphobricks_preprocessing/notebook_compact.py @@ -20,6 +20,7 @@ import ipywidgets as widgets from . import notebook_style as ui +from .notebook_render import issues_brief_html from .notebook_stepper import about, field_rows FOLDER_INTRO = ( @@ -76,6 +77,7 @@ def _images_page(self) -> list[widgets.Widget]: self.check_button, self.check_blocker, self.folder_status, # the check's progress, right under the button clicked + self.scan_axes_line, self.folder_lock.card, self.folder_tiles, self.issues_card, @@ -150,6 +152,9 @@ def _compare_box(self) -> widgets.Widget: def _finish_run(self, result: object) -> None: super()._finish_run(result) + issues = getattr(result, "issues", ()) + # Short cards: a message that lists every image is one line, the rest one click away. + self.run_issues.value = issues_brief_html(issues) if issues else "" self._refresh_folds() # the checks, Compare groups and the report now have content def _refresh_folds(self) -> None: diff --git a/src/morphobricks_preprocessing/notebook_quick_scan.py b/src/morphobricks_preprocessing/notebook_quick_scan.py index 86032f9..961671e 100644 --- a/src/morphobricks_preprocessing/notebook_quick_scan.py +++ b/src/morphobricks_preprocessing/notebook_quick_scan.py @@ -113,7 +113,7 @@ def _build_quick_scan(self) -> None: " Show another image", icon="random", tooltip="Choose another image at random" ) self.scan_box = widgets.VBox( # one column: the pictures, then the folders - [self.scan_example, self.scan_another, self.scan_tree], + [self.scan_example, self.scan_roles_card, self.scan_another, self.scan_tree], layout=widgets.Layout(width="99%"), ) self.scan_box.add_class("mb-scan") @@ -192,6 +192,7 @@ def listed(path: str) -> None: self._idle(self.scan_button, SCAN_LABEL, "search") self._scan, self._scanned = found, self._scan_inputs() self._scan_headers = {} # a new scan reads the headers of its pictures again + self._reset_scan_axes() wait.phase("Opening one image") self._show_scan_example(None, status=False) excluded = frozenset(normalise_relative_path(path) for path in self._excluded()) diff --git a/src/morphobricks_preprocessing/notebook_render.py b/src/morphobricks_preprocessing/notebook_render.py index 9d5657d..6992fcb 100644 --- a/src/morphobricks_preprocessing/notebook_render.py +++ b/src/morphobricks_preprocessing/notebook_render.py @@ -41,6 +41,10 @@ def issues_html(issues: Iterable[ValidationIssue]) -> str: return "".join(parts) +BRIEF_CHARS = 200 +"""Characters of an issue's first sentence shown before the rest is folded.""" + + def issues_brief_html(issues: Iterable[ValidationIssue]) -> str: """Render issues as short cards: what is wrong in one sentence, the rest on demand. @@ -50,6 +54,8 @@ def issues_brief_html(issues: Iterable[ValidationIssue]) -> str: parts = [] for issue in sorted(issues, key=lambda issue: not issue.is_error): first, stop, rest = issue.message.partition(". ") + if len(first) > BRIEF_CHARS: # a sentence that lists every file: its start, the rest folded + first, stop, rest = first[:BRIEF_CHARS] + "…", "", issue.message more = [html.escape(rest)] if rest else [] if issue.paths: more.append(ui.path_list(issue.paths, limit=5)) diff --git a/src/morphobricks_preprocessing/notebook_scan_axes.py b/src/morphobricks_preprocessing/notebook_scan_axes.py new file mode 100644 index 0000000..6b330f6 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_scan_axes.py @@ -0,0 +1,206 @@ +"""The axes of the quick scan's image: proposed from its header, changed right there. + +Owner (11 November 2026, the new axes flow): the quick scan opens one image and proposes its +axes from its header; when the picture looks wrong the user changes a role right there and +sees it redrawn. What the user leaves there becomes the proposal for the whole experiment: +the folder check (the "deep scan") reads every header, the roles are checked against every +image (:func:`.axis_review.review_axes`, as always), and the Axes step confirms them for the +whole dataset, where they can still be changed. + +Nothing is confirmed here. The roles of this module are a proposal, held per arrangement of +axes (a header's letters) and only for an arrangement the user changed; an arrangement left +alone keeps the header's roles, and a header that does not name its axes gets no picture until +the user says what its dimensions are. The Axes step, its example, its tick and its +confirmation are unchanged: they start from this proposal instead of the header's. +""" + +from __future__ import annotations + +import html + +import ipywidgets as widgets + +from . import notebook_style as ui +from .axes import AxisMapping, axis_name, file_mapping_for, prefill_mapping +from .axis_review import check_mapping, review_axes +from .metadata import TiffMetadata +from .notebook_options import ROLE_OPTIONS +from .notebook_viewers import CONFIRMED_NOTE, header_roles +from .validation import plural + +CHOSEN_NOTE = "read with the roles chosen above, not confirmed yet" +AXES_NOTE = "read with the roles on Axes (step 2), not confirmed yet" +ROLES_HELP = ( + "Axes of this image, proposed from its header. If a picture looks wrong (a strip, " + "stripes, channels that are slices), change a role here: the pictures are redrawn. What " + "you leave here is the proposal for every image; it is checked against all of them when " + "the headers are read, and confirmed on Axes (step 2), where it can still be changed." +) +CHANGE_NOTE = ( + "The axes can be changed: choose another role in a dimension's list (the pictures are " + "redrawn). Once confirmed, Change axis roles takes the confirmation back." +) + + +def roles_words(mapping: AxisMapping) -> str: + """Return the roles in the order of the file: ``C, Y, X`` (``-`` for none).""" + return ", ".join(role or "-" for role in mapping.roles) + + +class _ScanAxes: + """The role row of the quick scan, and the proposal it leaves for the Axes step.""" + + def _build_scan_axes(self) -> None: + """Build the role row (empty until an image is shown) and the line after the check.""" + self._scan_roles: dict[str, AxisMapping] = {} + self._scan_boxes: list[widgets.Dropdown] = [] + self._scan_row_axes: str | None = None + self._scan_applied: object | None = None + self._scan_filling = False + self.scan_roles_row = widgets.HBox( + layout=widgets.Layout(flex_flow="row wrap", align_items="flex-end", width="99%") + ) + self.scan_roles_card = widgets.VBox( + [ui.help_text(ROLES_HELP), self.scan_roles_row], layout=widgets.Layout(width="99%") + ) + ui.set_visible(self.scan_roles_card, False) + self.scan_axes_line = widgets.HTML() + self.axes_change_note = ui.help_text(CHANGE_NOTE) + self.scan_viewer.on_draw(self._scan_roles_follow) + + def _reset_scan_axes(self) -> None: + """Forget the roles chosen at an earlier scan: a new scan proposes afresh.""" + self._scan_roles, self._scan_row_axes = {}, None + self.scan_axes_line.value = "" + + # The row ------------------------------------------------------------------------------- + + def _scan_roles_follow(self) -> None: + """Show the role row of the image now in the viewer (before the headers are read).""" + image = self.scan_viewer.image + if image is None or self._checked_current(): + ui.set_visible(self.scan_roles_card, False) + return + _, meta, _ = self._header_of(image) + if meta is None or not meta.readable: + ui.set_visible(self.scan_roles_card, False) + return + if meta.axes != self._scan_row_axes: + self._build_scan_row(meta) + ui.set_visible(self.scan_roles_card, True) + + def _build_scan_row(self, meta: TiffMetadata) -> None: + """Build one list per dimension of the image, holding the role proposed or chosen.""" + mapping = self._scan_roles.get(meta.axes) or prefill_mapping(meta.axes) + self._scan_row_axes = meta.axes + self._scan_boxes, cells = [], [] + for axis, role, length in zip(meta.axes, mapping.roles, meta.shape, strict=True): + box = widgets.Dropdown( + options=ROLE_OPTIONS, value=role or "-", layout=widgets.Layout(width="210px") + ) + box.observe(lambda _: self._scan_role_changed(), names="value") + self._scan_boxes.append(box) + label = widgets.HTML( + f"
{html.escape(axis_name(axis))} in the file · " + f"length {length}
" + ) + cells.append(widgets.VBox([label, box], layout=widgets.Layout(margin="0 12px 0 0"))) + self.scan_roles_row.children = cells + + def _scan_role_changed(self) -> None: + """Keep the roles the user chose for this arrangement and redraw the image with them.""" + if self._scan_filling or self._scan_row_axes is None: + return + roles = tuple(box.value for box in self._scan_boxes) + self._scan_roles[self._scan_row_axes] = AxisMapping(self._scan_row_axes, roles) + self.scan_viewer.refresh() + + # What the viewer reads with ------------------------------------------------------------- + + def _scan_roles_for(self, meta: TiffMetadata) -> tuple[AxisMapping | None, str]: + """Return the roles the Images viewer reads an image with, or why it draws nothing. + + After the folder check: the roles on Axes, when the image fits them. Before: the + roles the user chose for the image's arrangement, else the header's. + """ + if self._checked_current() and self.mapping is not None and meta.readable: + fitted = file_mapping_for(meta, self.mapping) + if fitted is not None: + return fitted, "" + chosen = self._scan_roles.get(meta.axes) if meta.readable else None + if chosen is None: + return header_roles(meta) + errors = [issue for issue in check_mapping(chosen, [meta]) if issue.is_error] + if errors: + return None, errors[0].message + return chosen, "" + + def _scan_note(self, meta: TiffMetadata) -> str: + if self._checked_current() and self.mapping is not None: + return CONFIRMED_NOTE if self.confirmed_mapping is not None else AXES_NOTE + if meta.axes in self._scan_roles: + return CHOSEN_NOTE + return super()._scan_note(meta) + + # The proposal for the whole experiment -------------------------------------------------- + + def _scan_proposal(self) -> AxisMapping | None: + """Return the roles chosen at the scan for the experiment's arrangement, if any.""" + if self.mapping is None: + return None + chosen = self._scan_roles.get(self.mapping.axes) + return None if chosen is None or chosen.roles == self.mapping.roles else chosen + + def _refresh_axis_step(self) -> None: + """Start the Axes step of a new check from the roles chosen at the scan, once.""" + boxes = getattr(self, "_scan_roles", None) + if boxes and self.preflight is not None and self._scan_applied is not self.preflight: + self._scan_applied = self.preflight + chosen = self._scan_proposal() + if chosen is not None: + self.mapping = chosen + self._quick_seeded = self.preflight # the user's roles, not an earlier run's + super()._refresh_axis_step() + + def _show_scan_axes(self) -> None: + """Say, on Images, whether the proposed roles fit every image the check read.""" + pre, mapping = self.preflight, self.mapping + if pre is None or not pre.metadata.files: + self.scan_axes_line.value = "" + return + if mapping is None: + self.scan_axes_line.value = ui.status( + "warn", "Axes: no roles can be proposed for all images. See Axes (step 2)." + ) + return + review = review_axes( + pre.metadata, mapping, missing_as_singleton=self.missing_as_singleton.value + ) + chosen = self._scan_roles.get(mapping.axes) + suggested = pre.suggested_mapping + source = ( + "chosen by you on the example" + if chosen is not None and chosen.roles == mapping.roles + else "proposed from the image headers" + if suggested is not None and suggested.roles == mapping.roles + else "as edited on Axes" + ) + words = f"Axes {roles_words(mapping)} ({source})" + count = plural(len([f for f in pre.metadata.files if f.readable]), "image") + if not review.issues: + self.scan_axes_line.value = ui.status( + "ok", + f"{words}: checked against all {count}, they fit every one. Confirm them on " + "Axes (step 2); they can still be changed there.", + ) + return + errors = list(dict.fromkeys(i.code.replace("_", " ") for i in review.issues if i.is_error)) + what = f"they do not fit ({', '.join(errors)})" if errors else "there is a warning" + self.scan_axes_line.value = ui.status( + "warn", + f"{words}: checked against all {count}, {what}. See Axes (step 2), where they can " + "be changed.", + ) + + +__all__ = ["AXES_NOTE", "CHANGE_NOTE", "CHOSEN_NOTE", "ROLES_HELP", "_ScanAxes", "roles_words"] diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py index 20b4eb7..7d7e057 100644 --- a/src/morphobricks_preprocessing/notebook_steps.py +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -37,6 +37,7 @@ from .notebook_image_type_line import ImageTypeLine from .notebook_masks_screen import INTRO as MASKS_INTRO from .notebook_quick_scan import _QuickScan +from .notebook_scan_axes import _ScanAxes from .notebook_step_states import _StepStates from .notebook_stepper import Step, Stepper, about from .notebook_viewers import VIEW_HELP, _Viewers @@ -83,7 +84,9 @@ """The steps (0-based) that show a card the saved reopening folds.""" -class _StepsFlow(_CompactPages, _DesignStep, _QuickScan, _QuickAxes, _Viewers, _StepStates): +class _StepsFlow( + _CompactPages, _DesignStep, _QuickScan, _QuickAxes, _ScanAxes, _Viewers, _StepStates +): """``steps()``: the stepper of the shipped notebook, and the hooks that keep it current.""" def steps(self) -> widgets.Widget: @@ -113,8 +116,10 @@ def steps(self) -> widgets.Widget: self._build_quick_axes() self.axis_sizes_slot.children = [self.calibration_card] # with the axes (owner) ui.set_visible(self.roles_help, False) # said under About this step + self._one_line_sizes = True self._build_viewers() self._use_viewers() + self._build_scan_axes() self._build_quick_scan() self.consistency_slot = widgets.VBox([self.consistency_card]) self._build_design_step() @@ -134,7 +139,8 @@ def _step_list(self) -> list[Step]: ("Images", "Choose the folders, look at one image, then read every header.", self._images_page()), ("Axes", "What each image dimension is, the pixel size and the image type.", - [about(AXES_INTRO), self.axis_box, self.quick_card, self.type_line.widget]), + [about(AXES_INTRO), self.axes_change_note, self.axis_box, self.quick_card, + self.type_line.widget]), ("Channels", "Name what you stained, then tag each folder's channels.", [about(CHANNELS_INTRO), self.channel_labels.widget, self.channels_look, self.channel_grid.widget, self.moves_box, self.names_box]), @@ -206,6 +212,8 @@ def _step_shown(self, index: int) -> None: """Unfold the saved reopening when a step shows one of the cards it folds.""" if index in UNFOLDING and getattr(self, "_folded", False): self._unfold() + if index == 0 and self.preflight is not None and self._checked_current(): + self.scan_viewer.refresh() # the roles may have changed on Axes def _refresh_steps(self) -> None: """Update the strip and the consistency card's place (shown only when it warns).""" @@ -230,6 +238,7 @@ def check_folder(self) -> Preflight | None: if pre is not None and getattr(self, "_stepper", None) is not None: # The headers are read: the viewer now shows the images as the check read them. self._show_checked_viewer() + self._show_scan_axes() self.scan_status.value = "" self._refresh_folds() self._go_first_open() @@ -260,6 +269,8 @@ def _mapping_changed(self) -> None: if viewer is not None and self._preview_drawn is None and viewer.view is not None: self.axes_viewer.draw() # roles that cannot be used together: no picture of them self._refresh_quick_axes(drawn=True) # the example was just redrawn + if getattr(self, "scan_axes_line", None) is not None: + self._show_scan_axes() def _toggle_confirmation(self) -> None: super()._toggle_confirmation() diff --git a/src/morphobricks_preprocessing/pixel_size_summary.py b/src/morphobricks_preprocessing/pixel_size_summary.py index 4f24f92..0859c17 100644 --- a/src/morphobricks_preprocessing/pixel_size_summary.py +++ b/src/morphobricks_preprocessing/pixel_size_summary.py @@ -127,6 +127,9 @@ def summary_line(groups: Sequence[SizeGroup]) -> tuple[str, str]: 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})." + spread = spread_line(groups) + if spread is not None: + return "warn", spread 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)})" @@ -137,6 +140,49 @@ def summary_line(groups: Sequence[SizeGroup]) -> tuple[str, str]: return "warn", f"{title}: {', '.join(parts)}." +MANY_GROUPS = 3 +"""From this many different sizes on, the line gives their range instead of naming each.""" +SLIGHT_SPREAD = 0.01 +"""A spread under 1 % of the mean size reads "differ slightly".""" + + +def spread_line(groups: Sequence[SizeGroup]) -> str | None: + """Return the one line for many sizes in one unit: their range, spread and counts. + + ``Pixel sizes differ slightly: 0.10047–0.10054 µm (spread 0.07 %) across 138 images, 25 + combinations``: the smallest and largest recorded value over X and Y, the spread as + (largest - smallest) / mean of the two, the images, and the different (X, Y) pairs. None + when there are fewer than :data:`MANY_GROUPS` sizes, an image without a size, or more than + one unit: those are named one by one (:func:`summary_line`). Nothing is rounded away: the + values are the recorded ones, written with ``:g``. + """ + if len(groups) < MANY_GROUPS or not all(group.calibrated for group in groups): + return None + units = {size.unit for group in groups for size in (group.x, group.y)} + if len(units) != 1: + return None + values = [size.value for group in groups for size in (group.x, group.y)] + low, high = min(values), max(values) + spread = (high - low) / ((high + low) / 2) + unit = next(iter(units)) + symbol = UNIT_SYMBOLS.get(unit or "", unit or "no unit") + how = "differ slightly" if spread < SLIGHT_SPREAD else "differ" + percent = f"{spread * 100:.0f}" if spread >= 0.1 else f"{spread * 100:.2g}" + total = sum(len(group.images) for group in groups) + return ( + f"Pixel sizes {how}: {low:g}–{high:g} {symbol} (spread {percent} %) across " + f"{plural(total, 'image')}, {len(groups)} combinations." + ) + + +def combination_rows(groups: Sequence[SizeGroup]) -> list[tuple[str, str, str, str]]: + """Return ``(size, images, folders, source)`` per different (X, Y) size, largest first.""" + return [ + (size_words(group), f"{len(group.images):,}", where_words(group), source_words(group)) + for group in groups + ] + + 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. @@ -157,9 +203,11 @@ def unit_rows(sizes: Mapping[str, Sequence[RecordedSize]]) -> list[tuple[str, st __all__ = [ "SizeGroup", + "combination_rows", "folders_of", "size_words", "source_words", + "spread_line", "summary_line", "unit_rows", "where_words", diff --git a/tests/test_notebook_axes_example.py b/tests/test_notebook_axes_example.py index 2519d07..a013a6b 100644 --- a/tests/test_notebook_axes_example.py +++ b/tests/test_notebook_axes_example.py @@ -332,7 +332,12 @@ def test_headers_that_disagree_open_the_units_and_nothing_is_chosen(tmp_path): 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 + # One line, the sizes in a folded table, the two explicit choices; nothing opens by itself. + assert nb.pixel_units.selected_index is None and ui.is_visible(nb.pixel_form) + differs = _text(nb.pixel_differs.value) + assert "The 2 different sizes" in differs and "]+>", " ", text))).strip() + + +def scanned(tmp_path, shape=(4, 3, 16, 16), axes="ZCYX", *, named=True, sizes=None): + """Write two folders of two images each and scan them (no header check yet).""" + root = tmp_path / "exp" + rng = np.random.default_rng(3) + for number, folder in enumerate(("ctrl/rep1", "drug/rep1")): + for name in ("a.tif", "b.tif"): + path = root / folder / name + path.parent.mkdir(parents=True, exist_ok=True) + data = rng.integers(0, 4000, shape, dtype=np.uint16) + if not named: + tifffile.imwrite(path, data, photometric="minisblack") + continue + meta = {"axes": axes} + if sizes is not None: + x, y = sizes[2 * number + (name == "b.tif")] + meta |= {"PhysicalSizeX": x, "PhysicalSizeY": y} + tifffile.imwrite(path, data, ome=True, photometric="minisblack", metadata=meta) + nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out")) + nb.steps() + nb.scan_button.click() + return nb + + +def test_the_scan_proposes_the_axes_of_its_image_from_the_header(tmp_path): + nb = scanned(tmp_path) + assert ui.is_visible(nb.scan_roles_card) + assert [box.value for box in nb._scan_boxes] == ["Z", "C", "Y", "X"] + labels = _text(" ".join(cell.children[0].value for cell in nb.scan_roles_row.children)) + assert "Z (depth) in the file · length 4" in labels and "C (channel)" in labels + assert "proposed from its header" in _text(nb.scan_roles_card.children[0].value) + assert nb._scan_roles == {} # a proposal left alone is the header's: nothing is held + assert "from the file header, axes not confirmed yet" in nb.scan_viewer.caption.value + assert nb.preflight is None and nb.mapping is None and nb.confirmed_mapping is None + + +def test_a_role_changed_at_the_scan_redraws_the_pictures_right_there(tmp_path): + nb = scanned(tmp_path) + viewer = nb.scan_viewer + assert len(viewer.view.tiles) == 3 and ui.is_visible(viewer.sliders["Z"]) + nb._scan_boxes[0].value = "C" # the stack is really four channels ... + assert "More than one axis has the role C" in viewer.caption.value and viewer.view is None + nb._scan_boxes[1].value = "Z" # ... of three slices + assert len(viewer.view.tiles) == 4 and viewer.sliders["Z"].max == 2 + assert "read with the roles chosen above, not confirmed yet" in viewer.caption.value + assert nb._scan_roles["ZCYX"].roles == ("C", "Z", "Y", "X") + assert nb.confirmed_mapping is None and nb.preflight is None # still only a proposal + + +def test_a_plain_stack_gets_its_picture_once_the_user_says_what_its_dimensions_are(tmp_path): + nb = scanned(tmp_path, shape=(3, 16, 16), named=False) + viewer = nb.scan_viewer + assert viewer.view is None and "preview after Axes" in viewer.caption.value + assert [box.value for box in nb._scan_boxes] == ["-", "Y", "X"] # nothing guessed + nb._scan_boxes[0].value = "C" + assert viewer.view is not None and len(viewer.view.tiles) == 3 + + +def test_the_roles_left_at_the_scan_are_the_proposal_the_check_validates_on_all_images(tmp_path): + nb = scanned(tmp_path, shape=(3, 16, 16), named=False) + nb._scan_boxes[0].value = "C" + nb.check_button.click() + assert nb.mapping.roles == ("C", "Y", "X") + assert [box.value for box in nb.role_dropdowns] == ["C", "Y", "X"] # the Axes step starts here + line = _text(nb.scan_axes_line.value) + assert line.startswith("Axes C, Y, X (chosen by you on the example): checked against all 4") + assert "can still be changed there" in line or "can be changed" in line + assert nb.confirmed_mapping is None # the check validates; only Axes confirms + assert not ui.is_visible(nb.scan_roles_card) # after the check the roles are on Axes + assert nb.axes_viewer.view is not None and len(nb.axes_viewer.view.tiles) == 3 + # The plain stack names no axis: the tick and Confirm axis roles, as before. + assert not ui.is_visible(nb.quick_card) + nb.preview_checked.button.click() + nb.confirm_button.click() + assert nb.confirmed_mapping.roles == ("C", "Y", "X") + + +def test_roles_that_contradict_the_headers_are_named_after_the_check_and_not_offered(tmp_path): + nb = scanned(tmp_path) + nb._scan_boxes[0].value = "T" # the header says depth; the user says time + nb.check_button.click() + assert nb.mapping.roles == ("T", "C", "Y", "X") + line = _text(nb.scan_axes_line.value) + assert "checked against all 4 images, they do not fit (axes contradict header)" in line + assert not ui.is_visible(nb.quick_card) and nb.confirm_button.disabled + nb.role_dropdowns[0].value = "Z" # changed on Axes: the line on Images follows + assert "Axes Z, C, Y, X (proposed from the image headers)" in _text(nb.scan_axes_line.value) + + +def test_header_roles_left_alone_are_offered_for_one_click_and_say_they_can_be_changed(tmp_path): + nb = scanned(tmp_path, shape=(3, 16, 16), axes="CYX") + nb.check_button.click() + line = _text(nb.scan_axes_line.value) + assert line == ( + "Axes C, Y, X (proposed from the image headers): checked against all 4 images, they " + "fit every one. Confirm them on Axes (step 2); they can still be changed there." + ) + assert ui.is_visible(nb.quick_card) and "They can be changed above" in nb.quick_text.value + page = nb._stepper.pages[1].children[1].children[1].children + assert nb.axes_change_note in page and "The axes can be changed" in nb.axes_change_note.value + nb.quick_confirm.click() + assert nb.confirmed_mapping.roles == ("C", "Y", "X") + nb._stepper.go(0) # Images now reads with the confirmed roles + assert "read with the confirmed axis roles" in nb.scan_viewer.caption.value + + +def test_a_new_scan_forgets_the_roles_of_the_one_before(tmp_path): + nb = scanned(tmp_path) + nb._scan_boxes[0].value = "T" + nb.scan_button.click() + assert nb._scan_roles == {} and [box.value for box in nb._scan_boxes][0] == "Z" + + +# --- pixel sizes that differ --------------------------------------------------------------------- + + +def _sizes(x, y): + return ( + RecordedSize("X", x, "micrometer", "header"), + RecordedSize("Y", y, "micrometer", "header"), + ) + + +def test_many_slightly_different_sizes_are_one_line_with_their_range_and_spread(): + values = [0.10047, 0.10049, 0.1005, 0.10052, 0.10054] + sizes = { + f"c/r{i}/img{j}.tif": _sizes(values[i], values[(i + j) % 5]) + for i in range(5) + for j in range(5) + } + groups = xy_groups(sizes) + kind, line = summary_line(groups) + assert kind == "warn" and line == ( + "Pixel sizes differ slightly: 0.10047–0.10054 µm (spread 0.07 %) across 25 images, " + "25 combinations." + ) + rows = combination_rows(groups) + assert len(rows) == 25 and rows[0][1] == "1" and rows[0][3] == "from the headers" + assert all(len(row) == 4 for row in rows) + + +def test_the_range_line_is_only_for_three_or_more_calibrated_sizes_in_one_unit(): + two = xy_groups({"a/1.tif": _sizes(0.1, 0.1), "b/1.tif": _sizes(0.2, 0.2)}) + assert spread_line(two) is None and summary_line(two)[1].startswith("Headers disagree") + wide = xy_groups({f"f{i}/1.tif": _sizes(v, v) for i, v in enumerate((0.1, 0.2, 0.4))}) + assert spread_line(wide).startswith("Pixel sizes differ: 0.1–0.4 µm (spread 120 %)") + none = dict({f"f{i}/1.tif": _sizes(v, v) for i, v in enumerate((0.1, 0.2))}) + none["g/1.tif"] = (RecordedSize("X"), RecordedSize("Y")) + assert spread_line(xy_groups(none)) is None # an image without a size is named, not ranged + mixed = dict(none) + mixed["g/1.tif"] = ( + RecordedSize("X", 100.0, "nanometer", "header"), + RecordedSize("Y", 100.0, "nanometer", "header"), + ) + assert spread_line(xy_groups(mixed)) is None # two units: nothing is converted here + + +def test_the_notebook_says_differing_sizes_in_one_line_with_the_table_folded(tmp_path): + sizes = [(0.10047, 0.10049), (0.1005, 0.10052), (0.10054, 0.10047), (0.10049, 0.1005)] + nb = scanned(tmp_path, shape=(3, 16, 16), axes="CYX", sizes=sizes) + nb.check_button.click() + assert _text(nb.pixel_line.value) == ( + "Pixel sizes differ slightly: 0.10047–0.10054 µm (spread 0.07 %) across 4 images, " + "4 combinations." + ) + assert nb.pixel_units.selected_index is None # the per-unit tables do not open by themselves + folded = nb.pixel_differs.value + assert folded.startswith("")[0] + assert len(_text(shown)) < 260 and "…" in shown + assert "image_39.tif" in page and "Bricks refuse." in page # nothing is left out From d83917254f84db6a525b4d97f57ac73ebf65ed93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 10:28:23 +0000 Subject: [PATCH 2/2] Images: roles equal to the headers' proposal are named as the headers' Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- src/morphobricks_preprocessing/notebook_scan_axes.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/morphobricks_preprocessing/notebook_scan_axes.py b/src/morphobricks_preprocessing/notebook_scan_axes.py index 6b330f6..1386eb3 100644 --- a/src/morphobricks_preprocessing/notebook_scan_axes.py +++ b/src/morphobricks_preprocessing/notebook_scan_axes.py @@ -179,10 +179,10 @@ def _show_scan_axes(self) -> None: chosen = self._scan_roles.get(mapping.axes) suggested = pre.suggested_mapping source = ( - "chosen by you on the example" - if chosen is not None and chosen.roles == mapping.roles - else "proposed from the image headers" + "proposed from the image headers" if suggested is not None and suggested.roles == mapping.roles + else "chosen by you on the example" + if chosen is not None and chosen.roles == mapping.roles else "as edited on Axes" ) words = f"Axes {roles_words(mapping)} ({source})"