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