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