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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ src/morphobricks_preprocessing/
the confirmed AxisReview; no channel axis guessed), mask_thumbnail, PreviewCache;
preview_render.py display_panel (float32 after the reduction, percentile contrast
of the finite values; flat / no-finite / non-finite shown as such), stdlib PNG,
strip_png, mask_overlay_png (declared representation only)
strip_png, mask_overlay_png (declared representation only),
colour_composite (an RGB image as one colour picture, display only)
planes.py plane-by-plane reader (iter_planes, read_plane, read_sample_plane), table rows as
XYC planes whose channels are read one at a time (iter_xyc_planes, XycPlane),
memory estimate,
Expand Down Expand Up @@ -309,6 +310,8 @@ src/morphobricks_preprocessing/
composition of the cards below); notebook_stepper.py the strip, Back/Next
and the overview; notebook_step_states.py each step's done/one-line state
notebook_names.py the names card's rows (conditions on step 4, replicates on step 5)
notebook_axes_quick.py the stepper's one-click Confirm these axes (Q3, PR 17b)
notebook_channels_rows.py the tag grid's folder rows: pictures (colour for RGB), Enlarge
notebook_folder.py step 1: folder, check, issues, labels (_FolderStep)
notebook_design.py step 1: the experiment design card (_DesignCard)
notebook_axis_step.py step 2: role dropdowns, axis check, missing-axes card (_AxisStep)
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the

## Unreleased

- **Notebook: tags per header block, colour pictures, one-click axes (PR 17b-1).** *Tag channels*
shows folders whose image headers are identical, exactly (channel count, every raw header name
present and equal, colour flag), as one block tagged once for all its folders (owner decision Q5),
with every folder's pictures below for checking; a header lacking a channel name, other names or
other saved tags keep folders apart, and **Tag this folder on its own** takes one out. A block
answer writes the same per-folder answers into the draft as tagging one folder at a time (tested on
the draft bytes). The colour pre-tick is confirmed only on *Image type*; *Tag channels* restates
it. A colour (RGB) folder is drawn as one colour picture (channel 1 red, 2 green, 3 blue, each with
its own percentile contrast; display only) on *Tag channels*, *Image type* (which now has a picture
per folder), the pairing and *Check and save*: `preview_render.colour_composite`,
`Thumbnails.colour`. The axes step offers complete, unambiguous roles (no axis message, every image
naming its axes, nothing missing to state) with a preview and one **Confirm these axes** click,
which records what the two clicks of the role boxes record (owner decision Q3); an unchanged reopen
offers the earlier run's roles and then opens its next open step. Pixel sizes say *uncalibrated*
on the chip and in the summary when no physical size is found. Replicate IDs are shown in natural
order (`rep2` before `rep10`, the folder breaking a tie) in the run plots and legends, the
replicate blocks, the names rows and the short names: display only. Texts no longer name the old
steps 1-4 (the results page, the design card, the mask step, the design messages).
- **Described runs with several layouts report `R_HETEROGENEOUS_LAYOUT_NOT_ENABLED` first (#245).**
The run lists it before the axis review's issues, and the arrangement issues
(`inconsistent_mapping`, `missing_axes`, `ambiguous_missing_axes`) no longer advise exporting the
Expand Down
5 changes: 5 additions & 0 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,11 @@ results or comparing another grouping.
**Axes checked: they are correct for every image**. Changing a role afterwards asks
you to check the preview again.
The confirmed roles are locked until you click **Change axis roles**.
- In the shipped notebook (one step at a time), when the suggested roles leave nothing
to ask (no axis message, every image names its axes, no missing axis to state), the
*Axes* step shows them with a preview and one **Confirm these axes** button; it records
the same two answers. A reopened experiment whose folders did not change offers the
roles of its earlier run. The role boxes stay under **Look closer, or change the roles**.
- If some images lack a dimension the others have (often a single channel), a yellow box
lists them; press **Yes, they have a single plane there** if that is the case.
Once you confirm the axis roles, this resolves the missing-axis error for step 3.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,11 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.mask_record" = "io"
"morphobricks_preprocessing.run_labels" = "io"
"morphobricks_preprocessing.notebook_axes" = "ui"
"morphobricks_preprocessing.notebook_axes_quick" = "ui"
"morphobricks_preprocessing.notebook_channel_thumbs" = "ui"
"morphobricks_preprocessing.notebook_channels" = "ui"
"morphobricks_preprocessing.notebook_channels_grid" = "ui"
"morphobricks_preprocessing.notebook_channels_rows" = "ui"
"morphobricks_preprocessing.notebook_channels_labels" = "ui"
"morphobricks_preprocessing.notebook_channels_type" = "ui"
"morphobricks_preprocessing.notebook_channels_view" = "ui"
Expand Down
2 changes: 1 addition & 1 deletion src/morphobricks_preprocessing/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ def _missing_issues(balance: BalanceTable) -> list[ValidationIssue]:
],
"No action needed if a repeat was not acquired for every condition. If the "
"same repeat has a different folder name in another condition, pair the "
"folders in the experiment design (step 1 of the notebook, or the "
"folders in the experiment design (Replicates in the notebook, or the "
"repeat_group column of experiment_design.csv).",
)
]
Expand Down
14 changes: 11 additions & 3 deletions src/morphobricks_preprocessing/description_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .design import DesignRow, ExperimentDesign
from .draft import Draft
from .draft_answers import ConditionAnswer, DraftAnswers, RepeatAnswer, UnitKey
from .notebook_values import natural_key

SINGLE = "single"

Expand Down Expand Up @@ -96,10 +97,17 @@ def _members(applied: Draft, label: str, answers: DraftAnswers) -> tuple[MemberV


def replicates(applied: Draft, answers: DraftAnswers) -> tuple[ReplicateView, ...]:
"""Return every replicate with its folders, their channels and the labels some lack."""
"""Return every replicate with its folders, their channels and the labels some lack.

In natural order of the replicate ID (``rep2`` before ``rep10``), the folder label breaking
a tie: for display only.
"""
every = sorted({c for unit in answers.units.values() for c in unit.channels if c is not None})
out = []
for label, repeat in sorted(applied.repeats.items()):
shown = sorted(
applied.repeats.items(), key=lambda i: (natural_key(i[1].id.confirmed or i[0]), i[0])
)
for label, repeat in shown:
members = _members(applied, label, answers)
units = [u for m in members for u in m.units]
present = sorted({c for u in units for c in u.channels if c is not None})
Expand Down Expand Up @@ -135,7 +143,7 @@ def short_names(applied: Draft, answers: DraftAnswers) -> list[tuple[str, tuple[
("Channels", set(answers.channels)),
("Masks", set(answers.masks)),
]
return [(title, tuple(sorted(ids))) for title, ids in kinds if ids]
return [(title, tuple(sorted(ids, key=natural_key))) for title, ids in kinds if ids]


def unit_text(key: UnitKey) -> str:
Expand Down
10 changes: 5 additions & 5 deletions src/morphobricks_preprocessing/design.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,8 @@ def design_issues(design: ExperimentDesign) -> list[ValidationIssue]:
"A repeat group is one biological repeat across the conditions, so it can "
f"hold only one folder of each condition: {described}.",
[_folder(row) for g in design.conflicts for row in design.members(g)],
"Move one of the folders into another repeat group in the experiment design "
"(step 1, or the repeat_group column of experiment_design.csv).",
"Move one of the folders into another repeat group (Replicates in the "
"notebook, or the repeat_group column of experiment_design.csv).",
)
)
unconfirmed = [g for g in design.unconfirmed_groups if g not in design.conflicts]
Expand All @@ -484,7 +484,7 @@ def design_issues(design: ExperimentDesign) -> list[ValidationIssue]:
"that the biological repeats were prepared and acquired under the same "
"circumstances, so that they are comparable.",
[_folder(row) for g in unconfirmed for row in design.members(g)],
"Confirm the biological repeats in step 1 of the notebook, with the design "
"Confirm the biological repeats in the notebook, with the design "
"command (--confirm), or by setting confirmed to true in experiment_design.csv.",
)
)
Expand Down Expand Up @@ -592,7 +592,7 @@ def read_confirmed_design(
"""
path = Path(path)
rerun = (
"Run step 1 of the preprocessing brick on this experiment (it pairs and confirms the "
"Check this experiment in the preprocessing notebook (it pairs and confirms the "
"biological repeats), then run the preprocessing brick again."
)
if not path.is_file():
Expand Down Expand Up @@ -720,7 +720,7 @@ def load_design(
exc,
"Correct the file where the message says (a spreadsheet program may have "
f"rewritten a date such as 2026-10-26 as 26/10/2026; dates are {DATE_FORMAT}). "
"Answering the questions again in step 1 of the notebook also replaces it; "
"Answering the questions again in the notebook also replaces it; "
"nothing replaces it without an answer.",
) from exc
return reconcile_design(previous, repeats)
Expand Down
184 changes: 184 additions & 0 deletions src/morphobricks_preprocessing/notebook_axes_quick.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""*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
experiment whose folders are unchanged, the roles the earlier run recorded are the ones offered,
so a reopening no longer stops on the axes.

Nothing is confirmed without the click. The click records exactly what the two clicks of the
role boxes record (the preview checked, then the roles confirmed), through the same code. The
roles are offered only when the axis review finds no message at all, every readable image fits
them with no missing axis to state, and every image names its axes in its header (a plain
stack only when the earlier run's confirmation still covers it unchanged); otherwise the role
boxes are shown open, as before.
"""

from __future__ import annotations

import html
from pathlib import Path

import ipywidgets as widgets

from . import notebook_style as ui
from .axes import AxisMapping, axis_name
from .axis_check import ConfirmedAxes
from .axis_review import review_axes
from .fingerprint import unchanged_confirmation

DETAILS_OPEN, DETAILS_CLOSED = " Hide the role boxes", " Look closer, or change the roles"


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."""
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",
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

# What can be offered -------------------------------------------------------------------

def _saved_axes(self) -> AxisMapping | ConfirmedAxes | None:
"""Return the roles the earlier run recorded, on a reopening with unchanged folders."""
state = self._published() if self.preflight is not None else None
recorded = getattr(self, "_recorded", None)
if state is None or state.changed or recorded is None or recorded.mapping is None:
return None
root = Path(self.preflight.discovery.root)
saved = unchanged_confirmation(recorded.mapping, root)
roles = saved.mapping if isinstance(saved, ConfirmedAxes) else saved
if self.mapping is None or roles.axes != self.mapping.axes:
return None
edited = roles.roles != self.mapping.roles and self._quick_seeded is self.preflight
return None if edited else saved

def _quick_mapping(self) -> AxisMapping | None:
"""Return the roles to offer for one click, or None when the role boxes are needed."""
pre = self.preflight
if pre is None or self.mapping is None or not self.role_dropdowns:
return None
saved = self._saved_axes()
covered: set[str] = set()
if isinstance(saved, ConfirmedAxes):
covered, saved = set(saved.files), saved.mapping
mapping = saved or self.mapping
review = review_axes(pre.metadata, mapping, missing_as_singleton=False)
readable = [f for f in pre.metadata.files if f.readable]
if (
review.issues
or len(review.file_mappings) != len(readable)
or any(m.singleton_roles for m in review.file_mappings.values())
or not set(review.confirmed_files) <= covered
):
return None
return mapping

# Showing -----------------------------------------------------------------------------

def _refresh_quick_axes(self) -> None:
"""Offer the roles for one click, or show the role boxes open."""
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
if offer is None:
return
if offer.roles != self.mapping.roles:
self._quick_filling = True # the recorded roles fill the boxes, as other inputs
try:
for box, role in zip(self.role_dropdowns, offer.roles, strict=True):
box.value = role or "-"
finally:
self._quick_filling = False
self.quick_text.value = self._quick_text(offer)
self.show_preview()
self.quick_image.value = self.preview_image.value
ui.set_visible(self.quick_image, bool(self.preview_image.value))
self.quick_caption.value = self.preview_caption.value

def _quick_text(self, offer: AxisMapping) -> str:
"""Return what the card says: the roles, where they come from, what the click does."""
roles = ", ".join(
f"{html.escape(axis_name(axis))} → {role}"
for axis, role in zip(offer.axes, offer.roles, strict=True)
if role is not None
)
source = (
"the roles the earlier run of this experiment recorded"
if self._saved_axes() is not None
else "the roles every image header names"
)
return ui.status(
"info",
f"Suggested: {roles}.",
[
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. "
"Nothing is confirmed until you click.",
],
raw=True,
)

# The click ---------------------------------------------------------------------------

def _confirm_quick_axes(self) -> bool:
"""Confirm the offered roles: the preview checked, then the roles, as the role boxes do.

Returns
-------
bool
True when the roles are now confirmed.
"""
offer = self._quick_offer
if offer is None or self.confirmed_mapping is not None:
return False
if self._current_mapping() != offer:
self._refresh_quick_axes()
return False
self.preview_checked.set(True) # the user's click says the preview looks right
self._toggle_confirmation()
return self.confirmed_mapping is not None


__all__ = ["DETAILS_CLOSED", "DETAILS_OPEN", "_QuickAxes"]
Loading