Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
98 changes: 98 additions & 0 deletions src/morphobricks_preprocessing/axis_example.py
Original file line number Diff line number Diff line change
@@ -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",
]
72 changes: 26 additions & 46 deletions src/morphobricks_preprocessing/notebook_axes_quick.py
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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 -------------------------------------------------------------------

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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"]
45 changes: 37 additions & 8 deletions src/morphobricks_preprocessing/notebook_axis_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -119,28 +121,55 @@ 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)
self.preview_caption.value = ui.status(
"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.

Expand Down
Loading
Loading