diff --git a/AGENTS.md b/AGENTS.md
index 154ea34..85f3fe0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -333,6 +333,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 1740907..b1aed5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -84,6 +84,24 @@ experiment-v1 §4.5, §4.5a-§4.5c, §7, §8; ADR 0002 resolver contract version
- **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 e1e698d..bcb40c2 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 561dc26..51f8ba7 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 0ca03d4..1a6aa4b 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,
@@ -151,6 +153,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..1386eb3
--- /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 = (
+ "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})"
+ 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