diff --git a/AGENTS.md b/AGENTS.md
index ffb56a6c..a0635600 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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,
@@ -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)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd6299b3..514d68ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/user_guide.md b/docs/user_guide.md
index 65bb4685..4440c554 100644
--- a/docs/user_guide.md
+++ b/docs/user_guide.md
@@ -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.
diff --git a/pyproject.toml b/pyproject.toml
index c75ad4c9..876c5cfb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"
diff --git a/src/morphobricks_preprocessing/dataset.py b/src/morphobricks_preprocessing/dataset.py
index aa33faa4..cade60d7 100644
--- a/src/morphobricks_preprocessing/dataset.py
+++ b/src/morphobricks_preprocessing/dataset.py
@@ -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).",
)
]
diff --git a/src/morphobricks_preprocessing/description_review.py b/src/morphobricks_preprocessing/description_review.py
index e00570e8..7b095df1 100644
--- a/src/morphobricks_preprocessing/description_review.py
+++ b/src/morphobricks_preprocessing/description_review.py
@@ -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"
@@ -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})
@@ -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:
diff --git a/src/morphobricks_preprocessing/design.py b/src/morphobricks_preprocessing/design.py
index ddcd7a0b..3a25e259 100644
--- a/src/morphobricks_preprocessing/design.py
+++ b/src/morphobricks_preprocessing/design.py
@@ -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]
@@ -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.",
)
)
@@ -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():
@@ -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)
diff --git a/src/morphobricks_preprocessing/notebook_axes_quick.py b/src/morphobricks_preprocessing/notebook_axes_quick.py
new file mode 100644
index 00000000..ae2f95ad
--- /dev/null
+++ b/src/morphobricks_preprocessing/notebook_axes_quick.py
@@ -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"]
diff --git a/src/morphobricks_preprocessing/notebook_channel_thumbs.py b/src/morphobricks_preprocessing/notebook_channel_thumbs.py
index 7bbf2592..4f674f2b 100644
--- a/src/morphobricks_preprocessing/notebook_channel_thumbs.py
+++ b/src/morphobricks_preprocessing/notebook_channel_thumbs.py
@@ -4,6 +4,10 @@
then float32, then its own percentile contrast, flat images grey and non-finite pixels magenta,
and only through the axis roles the user confirmed. A picture never names a channel. A picture
that cannot be shown is replaced by the reason, never by a guess.
+
+A unit shown as colour (RGB) gets one colour picture instead of three grey ones
+(:meth:`Thumbnails.colour`, :func:`.preview_render.colour_composite`): display only, from the same
+panels, each channel with its own contrast.
"""
from __future__ import annotations
@@ -21,7 +25,7 @@
StripOptions,
channel_strip,
)
-from .preview_render import png_bytes
+from .preview_render import Panel, colour_composite, png_bytes
CONTRAST_TEXT = (
"Each picture is stretched on its own, from the 0.5th to the 99.5th percentile of its own "
@@ -29,6 +33,10 @@
"pictures. A flat picture is grey and a non-finite pixel is magenta. The pictures only help "
"you check the labels you choose: nothing is guessed from them."
)
+COLOUR_TEXT = (
+ "in colour: channel 1 red, 2 green, 3 blue, each stretched on its own from the 0.5th "
+ "to the 99.5th percentile (display only)"
+)
@dataclass(frozen=True)
@@ -45,10 +53,10 @@ class Thumbnails:
def __init__(self, root: Path, review: AxisReview, memory_limit: int) -> None:
self.root, self.review, self.memory_limit = root, review, memory_limit
self._cache = PreviewCache()
- self._strips: dict[tuple[str, int], tuple[Thumb, ...] | Thumb] = {}
+ self._strips: dict[tuple[str, int], tuple[Panel, ...] | Thumb] = {}
self.where: dict[str, str] = {}
- def _read(self, layout: UnitLayout, side: int) -> tuple[Thumb, ...] | Thumb:
+ def _read(self, layout: UnitLayout, side: int) -> tuple[Panel, ...] | Thumb:
"""Read every channel of the unit's image once per size; a refusal becomes one note."""
key = (layout.image or "", side)
if key not in self._strips:
@@ -61,9 +69,7 @@ def _read(self, layout: UnitLayout, side: int) -> tuple[Thumb, ...] | Thumb:
z = f"depth slice {strip.z}" if strip.z is not None else "no depth axis"
t = f"time point {strip.t}" if strip.t is not None else "no time axis"
self.where[key[0]] = f"{z}, {t}"
- self._strips[key] = tuple(
- Thumb(png_bytes(p.panel.pixels), p.panel.note) for p in strip.panels
- )
+ self._strips[key] = tuple(p.panel for p in strip.panels)
return self._strips[key]
def get(self, layout: UnitLayout, c: int, *, detail: bool = False) -> Thumb:
@@ -73,7 +79,31 @@ def get(self, layout: UnitLayout, c: int, *, detail: bool = False) -> Thumb:
found = self._read(layout, DETAIL_SIDE if detail else THUMBNAIL_SIDE)
if isinstance(found, Thumb):
return found
- return found[c] if c < len(found) else Thumb(None, f"the image has no channel {c}")
+ if c >= len(found):
+ return Thumb(None, f"the image has no channel {c}")
+ return Thumb(png_bytes(found[c].pixels), found[c].note)
+
+ def colour(self, layout: UnitLayout, *, detail: bool = False) -> Thumb:
+ """Return the unit's three channels as one colour picture (display only).
+
+ The note says how it is drawn (:data:`COLOUR_TEXT`); a unit whose image cannot be drawn
+ in colour gets no picture and the reason.
+ """
+ if not layout.image or not layout.usable:
+ return Thumb(None, layout.problem)
+ found = self._read(layout, DETAIL_SIDE if detail else THUMBNAIL_SIDE)
+ if isinstance(found, Thumb):
+ return found
+ try:
+ panel = colour_composite(found)
+ except ValueError as exc:
+ return Thumb(None, str(exc))
+ note = f"{COLOUR_TEXT}; {panel.note}" if panel.note else COLOUR_TEXT
+ return Thumb(png_bytes(panel.pixels), note)
+
+ def picture(self, layout: UnitLayout, colour: bool) -> Thumb:
+ """Return the unit's picture: the colour picture when shown as colour, else channel 1."""
+ return self.colour(layout) if colour else self.get(layout, 0)
def plane(self, layout: UnitLayout) -> str:
"""Return which plane the pictures of this unit show ("depth slice 3, time point 0")."""
@@ -81,4 +111,4 @@ def plane(self, layout: UnitLayout) -> str:
return self.where.get(layout.image or "", "")
-__all__ = ["CONTRAST_TEXT", "Thumb", "Thumbnails"]
+__all__ = ["COLOUR_TEXT", "CONTRAST_TEXT", "Thumb", "Thumbnails"]
diff --git a/src/morphobricks_preprocessing/notebook_channels_grid.py b/src/morphobricks_preprocessing/notebook_channels_grid.py
index 472781ce..c6b69e39 100644
--- a/src/morphobricks_preprocessing/notebook_channels_grid.py
+++ b/src/morphobricks_preprocessing/notebook_channels_grid.py
@@ -1,18 +1,24 @@
-"""Step B of *Your channels*: "Tag each channel" (a self-contained card).
-
-An image-first grid: one row per imaging unit (condition / biological repeat / technical repeat),
-one column per C index. Each cell is a safe thumbnail of the unit's representative image (about
-160 px, **Enlarge** for the 1024 px view), the C index, the header name quoted exactly as read and
-a dropdown limited to the labels of step A (an unused slot needs a label too, for example
-*unused*); there is no free text. A
-unit whose header names equal the labels exactly is pre-tagged, but the pre-tag is only a
-suggestion until the user confirms it once per layout (units with the same header names), beside
-the thumbnails. Step B is complete only when every channel position of every unit is tagged and
-every image type answered. Labels used in only some units say so, with the consequence.
-
-The card reads and saves through an :class:`.notebook_channels_view.AnswerStore`; the image-type
-column is a separate piece (:class:`.notebook_channels_type.ImageTypeColumn`) the card places in
-each row.
+"""*Tag channels*: which label each channel is, one answer per block of identical headers.
+
+The folders (imaging units: condition / biological repeat / technical repeat) are shown in
+**blocks**. Folders whose image headers are identical, exactly (the same channel count, the same
+raw header names at every C index, all present, and the same colour flag), form one block, and
+the user tags its channels once, for every folder of the block (owner decision Q5): one dropdown
+per C index, limited to the confirmed stains (an unused slot needs a label too, for example
+*unused*); there is no free text. Under the dropdowns, every folder of the block has its own
+pictures (:mod:`.notebook_channels_rows`) for checking. Nothing is grouped by guessing: a folder
+whose header lacks a channel name, or whose saved tags differ, is a block of its own, and a
+folder can be taken out of its block (**Tag this folder on its own**). A block answer writes,
+for each of its folders, the same tags into the draft that tagging the folders one by one would.
+
+A block whose header names equal the labels exactly is pre-tagged, but the pre-tag is only a
+suggestion until the user confirms it, once for the block. The page is complete only when every
+channel position of every unit is tagged and every image type answered. Labels used in only some
+folders say so, with the consequence.
+
+The card reads and saves through an :class:`.notebook_channels_view.AnswerStore`. The image
+type is answered by :class:`.notebook_channels_type.ImageTypeColumn`: placed in each folder row,
+or, with ``place_types`` False (the stepper), on its own page, and only restated here.
"""
from __future__ import annotations
@@ -33,7 +39,8 @@
usage,
)
from .draft_answers import UnitKey
-from .notebook_channel_thumbs import CONTRAST_TEXT, Thumbnails
+from .notebook_channel_thumbs import COLOUR_TEXT, CONTRAST_TEXT, Thumbnails
+from .notebook_channels_rows import FolderRows
from .notebook_channels_type import ImageTypeColumn
from .notebook_channels_view import (
CHOOSE,
@@ -42,10 +49,8 @@
chip,
columns,
quoted,
- unit_name,
)
-
-THUMB_PX = "160px"
+from .notebook_values import natural_key
def partial_text(label: str, used: int, total: int) -> str:
@@ -56,8 +61,18 @@ def partial_text(label: str, used: int, total: int) -> str:
)
+def folder_order(layout: UnitLayout) -> tuple:
+ """Return the display order of a folder: its labels, numbers as numbers (rep2, rep10)."""
+ return tuple(natural_key(part) for part in layout.key)
+
+
+def named(layout: UnitLayout) -> bool:
+ """Return True when the header names every channel of a usable unit (a block can hold it)."""
+ return layout.usable and all(layout.name(i) for i in range(layout.channels))
+
+
class TagGrid:
- """The tag grid: rows of units, a dropdown per channel, one confirm per layout."""
+ """The tag grid: blocks of folders with identical headers, one answer per block."""
def __init__(
self,
@@ -70,9 +85,11 @@ def __init__(
self.place_types = True
self.layouts: Sequence[UnitLayout] = ()
self.labels: list[str] = []
- self.thumbs: Thumbnails | None = None
self.cells: dict[tuple[UnitKey, int], widgets.Dropdown] = {}
self.confirm_buttons: list[widgets.Button] = []
+ self.split_buttons: dict[UnitKey, widgets.Button] = {}
+ # Folders the user took out of their block (this session; never saved).
+ self.alone: set[UnitKey] = set()
self.message = ""
self._building = False
self.blocks = widgets.VBox()
@@ -80,16 +97,18 @@ def __init__(
self.status = widgets.HTML()
self.problem = widgets.HTML()
self.detail = widgets.VBox()
+ self.rows = FolderRows(self.detail)
self.contrast = widgets.HTML(f"
{CONTRAST_TEXT}
")
self.plane = widgets.HTML()
self.widget = ui.card(
- "B. Tag each channel",
+ "Tag each channel",
[
ui.help_text(
- "For every folder, say which label each channel is, looking at its picture. "
- "The same label should be in the same column in every row; a label used in "
- "only some folders is shown in orange. Every channel and every image type "
- "must be answered."
+ "Folders whose image headers are identical are one block: say once which "
+ "label each channel is, looking at the pictures of every folder of the "
+ "block. The same label should be in the same column in every block; a label "
+ "used in only some folders is shown in orange. Every channel and every "
+ "image type must be answered."
),
self.contrast,
self.plane,
@@ -102,11 +121,18 @@ def __init__(
visible=False,
)
+ @property
+ def thumbs(self) -> Thumbnails | None:
+ """The pictures of the folders' first images."""
+ return self.rows.thumbs
+
# Opening -----------------------------------------------------------------------------
def open(self, layouts: Sequence[UnitLayout], labels: list[str], thumbs: Thumbnails) -> None:
"""Show the grid for these units and labels (the confirmed label list of step A)."""
- self.layouts, self.labels, self.thumbs = layouts, labels, thumbs
+ if layouts is not self.layouts:
+ self.alone = set()
+ self.layouts, self.labels, self.rows.thumbs = layouts, labels, thumbs
if self.place_types:
self.types.open(layouts)
self.detail.children = []
@@ -122,6 +148,30 @@ def complete(self) -> bool:
"""True when every position of every unit is tagged and every type answered."""
return remaining(self.layouts, self.store.answers().units, self.labels).done
+ def split(self, key: UnitKey) -> None:
+ """Take one folder out of its block, to tag it on its own (nothing is saved)."""
+ self.alone.add(key)
+ self.refresh()
+
+ # Blocks ------------------------------------------------------------------------------
+
+ def _colour(self, layout: UnitLayout) -> bool:
+ return layout.usable and self.types.shown(layout) == "rgb"
+
+ def _block_key(self, layout: UnitLayout) -> tuple:
+ """Return what the folders of one block share exactly; a folder alone has its own key."""
+ colour = self._colour(layout)
+ tags = tags_of(layout, self.store.answers().units.get(layout.key))
+ together = (colour or named(layout)) and layout.key not in self.alone
+ return (layout.signature, colour, tags, None if together else layout.key)
+
+ def block_list(self) -> list[list[UnitLayout]]:
+ """Return the blocks of folders, each sorted, in the order of their first folder."""
+ groups: dict[tuple, list[UnitLayout]] = {}
+ for layout in sorted(self.layouts, key=folder_order):
+ groups.setdefault(self._block_key(layout), []).append(layout)
+ return list(groups.values())
+
# Tags --------------------------------------------------------------------------------
def _proposal(self, layout: UnitLayout) -> tuple[str, ...] | None:
@@ -143,38 +193,45 @@ def effective(self, layout: UnitLayout) -> tuple[tuple[str | None, ...], set[int
proposed.add(index)
return tuple(saved), proposed
- def _tagged(self, layout: UnitLayout, index: int, value: str | None) -> None:
- """Save one cell's tag; a label already tagged elsewhere in the unit is refused."""
+ def _tagged(self, units: Sequence[UnitLayout], index: int, value: str | None) -> None:
+ """Save one channel's tag for every folder of a block; a label used twice is refused."""
if self._building:
return
answers = self.store.answers()
- tags = list(tags_of(layout, answers.units.get(layout.key)))
+ first = units[0]
+ tags = list(tags_of(first, answers.units.get(first.key)))
if value and value in [t for i, t in enumerate(tags) if i != index]:
- other = tags.index(value) + 1
- self.message = ui.status(
- "error",
- f"{value} was not tagged here.",
- [
- f"{html.escape(value)} is already channel {other} of "
- f"{html.escape(unit_name(layout.key))}: one label cannot stand at two "
- "positions of a unit. Change that one first."
- ],
- raw=True,
- )
- self.refresh()
+ self._refuse_twice(value, tags.index(value) + 1)
return
tags[index] = value
- if self.store.update(synced(with_tags(answers, layout, tags), self.layouts)):
+ for layout in units: # every folder of the block holds these tags
+ answers = with_tags(answers, layout, tags)
+ if self.store.update(synced(answers, self.layouts)):
self.message = ""
else:
self.message = ui.status("error", "Not saved.", [self.store.refusal()])
self.refresh()
self._on_change()
+ def _refuse_twice(self, value: str, other: int) -> None:
+ self.message = ui.status(
+ "error",
+ f"{value} was not tagged here.",
+ [
+ f"{html.escape(value)} is already channel {other} of these folders: one label "
+ "cannot stand at two positions of a folder. Change that one first."
+ ],
+ raw=True,
+ )
+ self.refresh()
+
def _confirm_layout(self, units: Sequence[UnitLayout]) -> None:
- """Save the header's proposal (tags and colour pre-tick) of these units as the answer."""
+ """Save the header's proposal of these units as the answer.
+
+ The colour pre-tick is saved too only when the image type is answered here.
+ """
answers = self.store.answers()
- rgb = [u.key for u in units if self.types.proposed(u)]
+ rgb = [u.key for u in units if self.place_types and self.types.proposed(u)]
for layout in units:
proposal = self._proposal(layout)
if proposal is None:
@@ -192,136 +249,137 @@ def _confirm_layout(self, units: Sequence[UnitLayout]) -> None:
# Building ----------------------------------------------------------------------------
def refresh(self) -> None:
- """Rebuild the rows from the saved answers (pictures are kept, not read again)."""
+ """Rebuild the blocks from the saved answers (pictures are kept, not read again)."""
self._building = True
try:
- self.cells, self.confirm_buttons = {}, []
+ self.cells, self.confirm_buttons, self.split_buttons = {}, [], {}
if self.place_types:
self.types.open(self.layouts)
width = max((u.channels for u in self.layouts if not u.rgb), default=3)
width = max(width, 3 if any(u.rgb for u in self.layouts) else 1)
- groups: dict[tuple, list[UnitLayout]] = {}
- for layout in sorted(self.layouts, key=lambda u: u.key):
- groups.setdefault(layout.signature if layout.usable else (0,), []).append(layout)
- blocks: list[widgets.Widget] = []
header = widgets.GridBox(
[widgets.HTML("folder")]
+ [widgets.HTML(f"channel {i + 1} (C{i})") for i in range(width)]
+ [widgets.HTML("image type" if self.place_types else "")],
layout=columns(width),
)
- blocks.append(header)
- for units in groups.values():
- blocks.append(self._group(units, width))
- self.blocks.children = blocks
+ blocks = self.block_list()
+ shared = [b[0].signature for b in blocks if named(b[0])]
+ self.blocks.children = [header] + [
+ self._block(units, width, shared.count(units[0].signature) > 1) for units in blocks
+ ]
finally:
self._building = False
self._summary()
- def _group(self, units: Sequence[UnitLayout], width: int) -> widgets.Widget:
- """Build the rows of units with one layout, and their one confirm button."""
+ def _block(self, units: Sequence[UnitLayout], width: int, apart: bool) -> widgets.Widget:
+ """Build one block: what its header says, its answer, and the rows of its folders."""
first = units[0]
- out: list[widgets.Widget] = []
- if first.usable:
- names = " · ".join(quoted(first.name(i)) for i in range(first.channels))
- what = f"Header names: {names}" if not first.rgb else "Header says: colour (RGB)"
- text = f"{what} · {len(units)} folder(s) with this layout."
- proposal = self._proposal(first)
- pending = [u for u in units if self._pending(u)]
- if proposal is not None:
- text += (
- " They equal your labels exactly: pre-tagged below, not confirmed yet."
- if pending
- else " They equal your labels exactly."
- )
- out.append(widgets.HTML(f"{text}
"))
- if pending:
- button = ui.button(
- f" Yes, these are right for these {len(pending)} folder(s)",
- style="success",
- icon="check",
- tooltip="Save the pre-ticked answers of these folders",
- )
- button.on_click(lambda _, p=pending: self._confirm_layout(p))
- self.confirm_buttons.append(button)
- out.append(button)
- out += [self._row(u, width) for u in units]
- return widgets.VBox(out)
+ colour = self._colour(first)
+ out: list[widgets.Widget] = [widgets.HTML(self._block_text(units, colour, apart))]
+ if first.usable and not colour:
+ out.append(self._answer_row(units, width))
+ pending = [u for u in units if u.usable and self._pending(u)]
+ if pending:
+ button = ui.button(
+ f" Yes, these are right for these {len(pending)} folder(s)",
+ style="success",
+ icon="check",
+ tooltip="Save the pre-ticked answers of these folders",
+ )
+ button.on_click(lambda _, p=pending: self._confirm_layout(p))
+ self.confirm_buttons.append(button)
+ out.append(button)
+ out += [self._folder_row(u, units, width, colour) for u in units]
+ box = widgets.VBox(out)
+ box.add_class("mb-tag-block")
+ return box
+
+ def _block_text(self, units: Sequence[UnitLayout], colour: bool, apart: bool) -> str:
+ """Return the line above a block: its header, its size and what is still to do."""
+ first = units[0]
+ if not first.usable:
+ return ""
+ if first.rgb:
+ what = "Header says: colour (RGB)"
+ else:
+ what = "Header names: " + " · ".join(
+ quoted(first.name(i)) for i in range(first.channels)
+ )
+ text = f"{what} · {len(units)} folder(s)"
+ if colour:
+ text += f". {self._type_text(first)} Pictures: {COLOUR_TEXT}."
+ elif len(units) > 1:
+ text += " with this header: tagged once, for all of them."
+ elif not named(first):
+ text += ": a header without every channel name is tagged folder by folder."
+ if apart:
+ text += (
+ " Other folders with this header are in another block (other tags or taken out)."
+ )
+ if not colour and self._proposal(first) is not None:
+ text += (
+ " They equal your labels exactly: pre-tagged below, not confirmed yet."
+ if any(self._pending(u) for u in units)
+ else " They equal your labels exactly."
+ )
+ return f"{text}
"
+
+ 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)"
+ 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)."
def _pending(self, layout: UnitLayout) -> bool:
- """Return True when the unit shows a proposal (tags or colour) not saved yet."""
+ """Return True when the unit shows a proposal not saved yet.
+
+ The colour pre-tick counts only when the image type is answered here.
+ """
_, proposed = self.effective(layout)
- return bool(proposed) or self.types.proposed(layout)
-
- def _row(self, layout: UnitLayout, width: int) -> widgets.Widget:
- """Build the row of one unit: its name, a cell per C index and its image type."""
- info = f"{html.escape(unit_name(layout.key))}"
- if layout.usable and self.thumbs is not None:
- info += (
- f"first image by path: {html.escape(layout.image or '')}"
- f"
1 of {layout.images} image(s); {html.escape(self.thumbs.plane(layout))}"
- "
"
- )
- if not layout.usable:
- info += ui.status("warn", "Cannot be tagged.", [html.escape(layout.problem)], raw=True)
- shown = self.types.shown(layout)
- tags, proposed = self.effective(layout)
- cells: list[widgets.Widget] = [widgets.HTML(info)]
+ return bool(proposed) or (self.place_types and self.types.proposed(layout))
+
+ def _answer_row(self, units: Sequence[UnitLayout], width: int) -> widgets.Widget:
+ """Build the block's answer: one dropdown per C index, for every folder of the block."""
+ first = units[0]
+ tags, proposed = self.effective(first)
+ what = "these folders" if len(units) > 1 else "this folder"
+ cells: list[widgets.Widget] = [widgets.HTML(f"Tags of {what}")]
for index in range(width):
- if not layout.usable or index >= layout.channels:
- text = "" if not layout.usable else f"no channel {index + 1} in this image"
- cells.append(widgets.HTML(f"{text}"))
- else:
- cells.append(self._cell(layout, index, tags[index], index in proposed, shown))
- typed = layout.usable and self.place_types
- cells.append(self.types.cell(layout) if typed else widgets.HTML(""))
+ if index >= first.channels:
+ cells.append(widgets.HTML(""))
+ continue
+ cells.append(self._dropdown(units, index, tags[index], index in proposed))
+ cells.append(widgets.HTML(""))
return widgets.GridBox(cells, layout=columns(width))
- def _picture(self, layout: UnitLayout, index: int) -> widgets.Widget:
- """Build the thumbnail of one channel, or the reason there is none."""
- thumb = self.thumbs.get(layout, index) if self.thumbs is not None else None
- if thumb is None or thumb.png is None:
- note = "" if thumb is None else html.escape(thumb.note)
- return widgets.HTML(
- f""
- f"no picture: {note}
"
- )
- box = widgets.Image(
- value=thumb.png,
- format="png",
- layout=widgets.Layout(width="99%", max_width=THUMB_PX, border="1px solid #CBD5E1"),
- )
- return box
-
- def _cell(
- self, layout: UnitLayout, index: int, tag: str | None, proposed: bool, shown: str | None
+ def _folder_row(
+ self, layout: UnitLayout, units: Sequence[UnitLayout], width: int, colour: bool
) -> widgets.Widget:
- """Build one cell: picture, C index and header name, the tag and a zoom button."""
- header = f"C{index} {quoted(layout.name(index))}"
- children: list[widgets.Widget] = [self._picture(layout, index)]
- thumb = self.thumbs.get(layout, index) if self.thumbs is not None else None
- if thumb is not None and thumb.note:
- header += f"{html.escape(thumb.note)}
"
- children.append(widgets.HTML(header))
- if shown == "rgb":
- children.append(widgets.HTML(chip("RGB"[index], list("RGB"))))
- else:
- children.append(self._dropdown(layout, index, tag, proposed))
- zoom = ui.button(" Enlarge", icon="search-plus", tooltip="Show the 1024 px detail view")
- zoom.layout = widgets.Layout(width="99%")
- zoom.on_click(lambda _, u=layout, i=index: self._enlarge(u, i))
- children.append(zoom)
- return widgets.VBox(children)
+ """Build one folder's row of pictures, with its split button and image type."""
+ extra: list[widgets.Widget] = []
+ if len(units) > 1 and not colour:
+ button = ui.button(" Tag this folder on its own", icon="scissors")
+ button.on_click(lambda _, k=layout.key: self.split(k))
+ self.split_buttons[layout.key] = button
+ extra.append(button)
+ typed = layout.usable and self.place_types
+ last = self.types.cell(layout) if typed else widgets.HTML("")
+ return self.rows.row(layout, width, colour, extra, last)
def _dropdown(
- self, layout: UnitLayout, index: int, tag: str | None, proposed: bool
+ self, units: Sequence[UnitLayout], index: int, tag: str | None, proposed: bool
) -> widgets.Widget:
- """Build the dropdown of one cell: the labels and what is shown now."""
+ """Build the dropdown of one C index: the labels and what is shown now."""
options = [(CHOOSE, None), *((label, label) for label in self.labels)]
value = tag if tag in {o[1] for o in options} else None
drop = widgets.Dropdown(options=options, value=value, layout=widgets.Layout(width="99%"))
- drop.observe(lambda change, u=layout, i=index: self._tagged(u, i, change["new"]), "value")
- self.cells[(layout.key, index)] = drop
+ drop.observe(lambda change, u=units, i=index: self._tagged(u, i, change["new"]), "value")
+ for layout in units:
+ self.cells[(layout.key, index)] = drop
if tag is None:
mark = blank_chip("not tagged")
else:
@@ -330,22 +388,6 @@ def _dropdown(
mark += " proposed, not confirmed"
return widgets.VBox([drop, widgets.HTML(mark)])
- def _enlarge(self, layout: UnitLayout, index: int) -> None:
- """Show the 1024 px picture of one channel under the grid."""
- thumb = self.thumbs.get(layout, index, detail=True)
- close = ui.button(" Close", icon="times")
- close.on_click(lambda _: setattr(self.detail, "children", []))
- title = widgets.HTML(
- f"{html.escape(unit_name(layout.key))} · C{index} "
- f"{quoted(layout.name(index))} · {html.escape(layout.image or '')}"
- )
- picture: widgets.Widget = (
- widgets.Image(value=thumb.png, format="png", layout=widgets.Layout(max_width="1024px"))
- if thumb.png
- else widgets.HTML(ui.status("warn", "No picture.", [html.escape(thumb.note)], raw=True))
- )
- self.detail.children = [title, picture, close]
-
# Summary -----------------------------------------------------------------------------
def _summary(self) -> None:
@@ -405,4 +447,4 @@ def _progress(self, total: int) -> str:
return ui.status("warn", "Not finished: " + ", ".join(parts) + ".")
-__all__ = ["TagGrid", "partial_text"]
+__all__ = ["TagGrid", "folder_order", "named", "partial_text"]
diff --git a/src/morphobricks_preprocessing/notebook_channels_labels.py b/src/morphobricks_preprocessing/notebook_channels_labels.py
index abe020ea..3b3ebe14 100644
--- a/src/morphobricks_preprocessing/notebook_channels_labels.py
+++ b/src/morphobricks_preprocessing/notebook_channels_labels.py
@@ -105,7 +105,7 @@ def __init__(self, store: AnswerStore, on_change: Callable[[], None]) -> None:
self.status = widgets.HTML()
self.problem = widgets.HTML()
self.widget = ui.card(
- "A. Which labels did you image?",
+ "Which labels did you image?",
[
ui.help_text(
"Name what was stained, once per stain. The number of slots is the largest "
diff --git a/src/morphobricks_preprocessing/notebook_channels_rows.py b/src/morphobricks_preprocessing/notebook_channels_rows.py
new file mode 100644
index 00000000..22da3683
--- /dev/null
+++ b/src/morphobricks_preprocessing/notebook_channels_rows.py
@@ -0,0 +1,141 @@
+"""The folder rows of the tag grid: each folder's pictures, its C indices and header names.
+
+A row shows one folder of a block (:mod:`.notebook_channels_grid`): its name, the first image by
+path and the plane shown, then one picture per channel (about 160 px, **Enlarge** for the
+1024 px view) with the C index and the header name quoted exactly as read. A folder shown as
+colour (RGB) has one colour picture instead (:meth:`.notebook_channel_thumbs.Thumbnails.colour`)
+and the fixed channels R, G and B. The rows hold no answer: the block above them does.
+"""
+
+from __future__ import annotations
+
+import html
+from collections.abc import Callable
+
+import ipywidgets as widgets
+
+from . import notebook_style as ui
+from .channel_labels import RGB_IDS, UnitLayout
+from .notebook_channel_thumbs import COLOUR_TEXT, Thumb, Thumbnails
+from .notebook_channels_view import columns, quoted, rgb_chip, unit_name
+
+THUMB_PX = "160px"
+
+
+def picture(thumb: Thumb | None, colour: bool = False) -> widgets.Widget:
+ """Return a thumbnail, or the reason there is none (a dashed box)."""
+ if thumb is None or thumb.png is None:
+ note = "" if thumb is None else html.escape(thumb.note)
+ return widgets.HTML(
+ f""
+ f"no picture: {note}
"
+ )
+ image = widgets.Image(
+ value=thumb.png,
+ format="png",
+ layout=widgets.Layout(width="99%", max_width=THUMB_PX, border="1px solid #CBD5E1"),
+ )
+ image.add_class("mb-colour" if colour else "mb-grey")
+ return image
+
+
+def folder_info(layout: UnitLayout, thumbs: Thumbnails | None) -> str:
+ """Return the first cell of a folder row: its name, first image and plane (HTML)."""
+ info = f"{html.escape(unit_name(layout.key))}"
+ if layout.usable and thumbs is not None:
+ info += (
+ f"first image by path: {html.escape(layout.image or '')}"
+ f"
1 of {layout.images} image(s); {html.escape(thumbs.plane(layout))}
"
+ )
+ if not layout.usable:
+ info += ui.status("warn", "Cannot be tagged.", [html.escape(layout.problem)], raw=True)
+ return info
+
+
+class FolderRows:
+ """Builds the folder rows of the grid and the enlarged view under it."""
+
+ def __init__(self, detail: widgets.VBox) -> None:
+ self.detail = detail
+ self.thumbs: Thumbnails | None = None
+
+ def row(
+ self,
+ layout: UnitLayout,
+ width: int,
+ colour: bool,
+ extra: list[widgets.Widget],
+ last: widgets.Widget,
+ ) -> widgets.Widget:
+ """Build one folder's row: its name (and ``extra`` under it), its pictures, ``last``."""
+ info = widgets.HTML(folder_info(layout, self.thumbs))
+ cells: list[widgets.Widget] = [widgets.VBox([info, *extra])]
+ for index in range(width):
+ if not layout.usable or (colour and index > 0):
+ cells.append(widgets.HTML(""))
+ elif colour:
+ cells.append(self._colour_cell(layout))
+ elif index >= layout.channels:
+ cells.append(
+ widgets.HTML(
+ f"no channel {index + 1} in this image"
+ )
+ )
+ else:
+ cells.append(self._cell(layout, index))
+ cells.append(last)
+ return widgets.GridBox(cells, layout=columns(width))
+
+ def _cell(self, layout: UnitLayout, index: int) -> widgets.Widget:
+ """Build one channel's picture, its C index and header name, and Enlarge."""
+ thumb = self.thumbs.get(layout, index) if self.thumbs is not None else None
+ header = f"C{index} {quoted(layout.name(index))}"
+ if thumb is not None and thumb.note:
+ header += f"{html.escape(thumb.note)}
"
+ return widgets.VBox(
+ [picture(thumb), widgets.HTML(header), self._zoom(lambda: self._enlarge(layout, index))]
+ )
+
+ def _colour_cell(self, layout: UnitLayout) -> widgets.Widget:
+ """Build a colour folder's one picture and its fixed channels R, G, B."""
+ thumb = self.thumbs.colour(layout) if self.thumbs is not None else None
+ chips = " ".join(rgb_chip(t) for t in RGB_IDS)
+ extra = "" if thumb is None else thumb.note.removeprefix(COLOUR_TEXT).lstrip("; ")
+ note = f"{html.escape(extra)}
" if extra else ""
+ return widgets.VBox(
+ [
+ picture(thumb, colour=True),
+ widgets.HTML(f"{chips}{note}"),
+ self._zoom(lambda: self._enlarge(layout, None)),
+ ]
+ )
+
+ @staticmethod
+ def _zoom(action: Callable[[], None]) -> widgets.Button:
+ zoom = ui.button(" Enlarge", icon="search-plus", tooltip="Show the 1024 px detail view")
+ zoom.layout = widgets.Layout(width="99%")
+ zoom.on_click(lambda _: action())
+ return zoom
+
+ def _enlarge(self, layout: UnitLayout, index: int | None) -> None:
+ """Show the 1024 px picture of one channel (or the colour picture) under the grid."""
+ if index is None:
+ thumb, what = self.thumbs.colour(layout, detail=True), "colour picture"
+ else:
+ thumb = self.thumbs.get(layout, index, detail=True)
+ what = f"C{index} {quoted(layout.name(index))}"
+ close = ui.button(" Close", icon="times")
+ close.on_click(lambda _: setattr(self.detail, "children", []))
+ title = widgets.HTML(
+ f"{html.escape(unit_name(layout.key))} · {what} · "
+ f"{html.escape(layout.image or '')}"
+ )
+ shown: widgets.Widget = (
+ widgets.Image(value=thumb.png, format="png", layout=widgets.Layout(max_width="1024px"))
+ if thumb.png
+ else widgets.HTML(ui.status("warn", "No picture.", [html.escape(thumb.note)], raw=True))
+ )
+ self.detail.children = [title, shown, close]
+
+
+__all__ = ["THUMB_PX", "FolderRows", "folder_info", "picture"]
diff --git a/src/morphobricks_preprocessing/notebook_channels_type.py b/src/morphobricks_preprocessing/notebook_channels_type.py
index 5fa649f8..2ebc5ea5 100644
--- a/src/morphobricks_preprocessing/notebook_channels_type.py
+++ b/src/morphobricks_preprocessing/notebook_channels_type.py
@@ -20,7 +20,10 @@
from .channel_answers import synced, with_type
from .channel_labels import UnitLayout
from .draft_answers import UnitKey
+from .notebook_channel_thumbs import Thumbnails
+from .notebook_channels_rows import picture
from .notebook_channels_view import AnswerStore, blank_chip, quoted, unit_name
+from .notebook_values import natural_key
OPTIONS = [("separate channels", "channels"), ("colour (RGB)", "rgb")]
@@ -132,11 +135,16 @@ class ImageTypeRows:
"""The image type on a page of its own: one row per folder, its header and its choice.
The cells are the :class:`ImageTypeColumn`'s own, so the answers, notes and refusals are
- exactly those of the column in the tag grid; only the place differs.
+ exactly those of the column in the tag grid; only the place differs. Each row has the
+ folder's picture: in colour when the folder is shown as colour (RGB), else channel 1.
+ This page holds the only confirmation of the header's colour pre-tick; *Tag channels*
+ restates the answer.
"""
- def __init__(self, types: ImageTypeColumn) -> None:
- self.types = types
+ def __init__(
+ self, types: ImageTypeColumn, thumbs: Callable[[], Thumbnails | None] = lambda: None
+ ) -> None:
+ self.types, self._thumbs = types, thumbs
self.rows = widgets.VBox()
self.status = widgets.HTML()
self.problem = widgets.HTML()
@@ -174,7 +182,8 @@ def answered(self) -> int:
def refresh(self) -> None:
"""Rebuild the rows from the saved answers."""
self.types.open(self.types.layouts) # a fresh set of cells, built from the answers
- rows = [self._row(u) for u in sorted(self.types.layouts, key=lambda u: u.key)]
+ ordered = sorted(self.types.layouts, key=lambda u: tuple(map(natural_key, u.key)))
+ rows = [self._row(u) for u in ordered]
proposed = [u.key for u in self.types.layouts if self.types.proposed(u)]
if proposed:
button = ui.button(
@@ -212,10 +221,16 @@ def _row(self, layout: UnitLayout) -> widgets.Widget:
+ " · ".join(quoted(layout.name(i)) for i in range(layout.channels))
)
info += f"{said}
"
+ thumbs = self._thumbs()
+ colour = self.types.shown(layout) == "rgb"
+ thumb = None if thumbs is None else thumbs.picture(layout, colour)
+ if thumb is not None:
+ what = thumb.note if colour else f"channel 1{'; ' if thumb.note else ''}{thumb.note}"
+ info += f"picture: {html.escape(what)}
"
return widgets.GridBox(
- [widgets.HTML(info), self.types.cell(layout)],
+ [picture(thumb, colour), widgets.HTML(info), self.types.cell(layout)],
layout=widgets.Layout(
- grid_template_columns="minmax(200px, 2fr) minmax(200px, 3fr)",
+ grid_template_columns="120px minmax(200px, 2fr) minmax(200px, 3fr)",
grid_gap="4px 12px",
width="99%",
margin="0 0 8px 0",
diff --git a/src/morphobricks_preprocessing/notebook_channels_view.py b/src/morphobricks_preprocessing/notebook_channels_view.py
index cb2bda85..23602159 100644
--- a/src/morphobricks_preprocessing/notebook_channels_view.py
+++ b/src/morphobricks_preprocessing/notebook_channels_view.py
@@ -73,6 +73,18 @@ def chip(label: str, labels: Sequence[str], display: str | None = None) -> str:
)
+RGB_COLOURS = {"R": "#DC2626", "G": "#16A34A", "B": "#2563EB"}
+"""The chips of a colour image's fixed channels (never labels) are drawn in their colour."""
+
+
+def rgb_chip(token: str) -> str:
+ """Return the chip of a colour channel (``R``, ``G``, ``B``) in red, green or blue."""
+ return (
+ "{html.escape(token)}"
+ )
+
+
def blank_chip(text: str) -> str:
"""Return an orange outlined chip for something not answered."""
return (
@@ -111,10 +123,12 @@ def unit_name(key: tuple[str, str, str]) -> str:
"ORANGE",
"ORANGE_FILL",
"PALETTE",
+ "RGB_COLOURS",
"blank_chip",
"chip",
"colour",
"columns",
"quoted",
+ "rgb_chip",
"unit_name",
]
diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py
index ff2f3938..13f3a5c1 100644
--- a/src/morphobricks_preprocessing/notebook_check_save.py
+++ b/src/morphobricks_preprocessing/notebook_check_save.py
@@ -273,7 +273,7 @@ def _show_replicates(self, applied: object, answers: object, labels: list[str])
self.replicates_box.children = boxes
def _member_picture(self, key: tuple[str, str]) -> widgets.Widget:
- """Return the picture of a folder's first image, channel 1, or why there is none."""
+ """Return the picture of a folder's first image (channel 1, or in colour when RGB)."""
thumbs, layouts = self._channel_thumbs, self._channel_layouts
layout = next((u for u in layouts if u.key[:2] == key), None)
if thumbs is None or layout is None:
@@ -281,12 +281,15 @@ def _member_picture(self, key: tuple[str, str]) -> widgets.Widget:
"picture once the axis roles are "
"confirmed
"
)
- thumb = thumbs.get(layout, 0)
+ colour = self.channel_types.shown(layout) == "rgb"
+ thumb = thumbs.picture(layout, colour)
if not thumb.png:
return widgets.HTML(
f"{html.escape(thumb.note)}
"
)
- return widgets.Image(value=thumb.png, format="png", layout=PICTURE)
+ image = widgets.Image(value=thumb.png, format="png", layout=PICTURE)
+ image.add_class("mb-colour" if colour else "mb-grey")
+ return image
def _folder_cell(self, key: tuple[str, str], text: str) -> widgets.Widget:
"""Return a folder of the pairing card, with its picture in a described run."""
diff --git a/src/morphobricks_preprocessing/notebook_check_view.py b/src/morphobricks_preprocessing/notebook_check_view.py
index 37c7d0d2..0fd5a9c0 100644
--- a/src/morphobricks_preprocessing/notebook_check_view.py
+++ b/src/morphobricks_preprocessing/notebook_check_view.py
@@ -15,7 +15,7 @@
from .description_removal import RemovalQuestion
from .description_review import MemberView, ReplicateView, unit_text
from .draft import OpenQuestion
-from .notebook_channels_view import ORANGE, blank_chip, chip
+from .notebook_channels_view import ORANGE, RGB_COLOURS, blank_chip, chip, rgb_chip
from .validation import ValidationIssue, plural
SAME_WAY = "Were these prepared and imaged the same way, so they count as one replicate?"
@@ -43,7 +43,10 @@ def comparisons_html(sentences: Sequence[str]) -> str:
def _channels(channels: Sequence[str | None], labels: Sequence[str]) -> str:
- return " ".join(chip(c, labels) if c else blank_chip("not tagged") for c in channels)
+ return " ".join(
+ (rgb_chip(c) if c in RGB_COLOURS else chip(c, labels)) if c else blank_chip("not tagged")
+ for c in channels
+ )
def member_html(member: MemberView, labels: Sequence[str]) -> str:
diff --git a/src/morphobricks_preprocessing/notebook_convert.py b/src/morphobricks_preprocessing/notebook_convert.py
index 6a09b076..490e2f90 100644
--- a/src/morphobricks_preprocessing/notebook_convert.py
+++ b/src/morphobricks_preprocessing/notebook_convert.py
@@ -435,8 +435,7 @@ def _next_html(result: ConversionResult, output: Path, missing_as_singleton: boo
"header gave, because the axis was relabelled: its size is unknown in the copy "
"(a swapped Y and X keep theirs only when the header gives X and Y equal). If you "
"know them, state them "
- "under Pixel sizes in step 1 after checking the copies."
- + ui.path_list(sizes, limit=5)
+ "under Pixel sizes after checking the copies." + ui.path_list(sizes, limit=5)
)
return ui.status(
"info", "What next: check the copies in step 1, then run on them.", lines, raw=True
diff --git a/src/morphobricks_preprocessing/notebook_described.py b/src/morphobricks_preprocessing/notebook_described.py
index ad477369..a1819d9a 100644
--- a/src/morphobricks_preprocessing/notebook_described.py
+++ b/src/morphobricks_preprocessing/notebook_described.py
@@ -272,8 +272,8 @@ def _refresh_axis_next(self) -> None:
super()._refresh_axis_next()
return
self.axis_next.value = ui.next_steps(
- "Go to Your channels.",
- "Name what you stained and tag every channel; then Your masks and Check and save.",
+ "Go to Stains.",
+ "Name what you stained, then Tag channels, Masks and Check and save.",
)
# The run -----------------------------------------------------------------------------
diff --git a/src/morphobricks_preprocessing/notebook_design.py b/src/morphobricks_preprocessing/notebook_design.py
index 10ae7e9d..681c3b2a 100644
--- a/src/morphobricks_preprocessing/notebook_design.py
+++ b/src/morphobricks_preprocessing/notebook_design.py
@@ -436,7 +436,7 @@ def _refresh_design_status(self) -> None:
lines.append(
f"Still to confirm: {plural(len(waiting), 'repeat group')} ("
+ ", ".join(html.escape(g) for g in waiting)
- + "). Step 3 runs once every group is confirmed."
+ + "). The run waits until every group is confirmed."
)
lines.append(where)
status = ui.status("warn", "Please answer the questions below.", lines, raw=True)
diff --git a/src/morphobricks_preprocessing/notebook_mask_step.py b/src/morphobricks_preprocessing/notebook_mask_step.py
index 8b0032a9..3ed5a8c2 100644
--- a/src/morphobricks_preprocessing/notebook_mask_step.py
+++ b/src/morphobricks_preprocessing/notebook_mask_step.py
@@ -186,7 +186,7 @@ def _describe_recorded_masks(self) -> MaskStepResult | None:
except ValueError as exc: # masks whose pixels cannot be used (planes, labels)
self._mask_step_failed(
"The masks cannot be used.",
- [str(exc), "Correct or replace the mask named, run step 3 again, then retry."],
+ [str(exc), "Correct or replace the mask named, run again, then retry."],
)
return None
if result.refusal is not None:
diff --git a/src/morphobricks_preprocessing/notebook_names.py b/src/morphobricks_preprocessing/notebook_names.py
index c4f7cec6..92e5e858 100644
--- a/src/morphobricks_preprocessing/notebook_names.py
+++ b/src/morphobricks_preprocessing/notebook_names.py
@@ -27,6 +27,12 @@
text_box,
why_no_proposal,
)
+from .notebook_values import natural_key
+
+
+def _natural(item: tuple[str, object]) -> tuple:
+ """Return the display order of a replicate's row: its folder label, numbers as numbers."""
+ return natural_key(item[0])
class _NameRows:
@@ -53,11 +59,11 @@ def _entries(self) -> list[Entry]:
f"{html.escape(label)}
{members_text(repeat.members)}",
answers.repeats.get(label, RepeatAnswer()).display_name,
)
- for label, repeat in sorted(draft.repeats.items())
+ for label, repeat in sorted(draft.repeats.items(), key=_natural)
]
entries += [
Entry("technical_repeats", folder, slot, f"{html.escape(folder)}")
- for folder, slot in sorted(draft.technical_repeats.items())
+ for folder, slot in sorted(draft.technical_repeats.items(), key=_natural)
]
return entries
diff --git a/src/morphobricks_preprocessing/notebook_results.py b/src/morphobricks_preprocessing/notebook_results.py
index bc1b3706..c73825ac 100644
--- a/src/morphobricks_preprocessing/notebook_results.py
+++ b/src/morphobricks_preprocessing/notebook_results.py
@@ -29,7 +29,7 @@ def _build_results_step(self) -> None:
self._build_statistics_cards()
self.table_checks = IntensityPanel(
"Intensity checks",
- "The same checks as after step 3, for the chosen table. Each image is compared only "
+ "The same checks as after the run, for the chosen table. Each image is compared only "
"with the others of its condition, so differences between conditions are never "
"reported. Nothing is written.",
)
@@ -41,7 +41,7 @@ def _build_results_step(self) -> None:
def _build_csv_card(self) -> None:
"""Build the card that chooses the histogram table."""
self.csv_file = widgets.Text(
- placeholder="filled in after step 3, or choose a plane_histograms.csv",
+ placeholder="filled in after the run, or choose a plane_histograms.csv",
layout=widgets.Layout(flex="1 1 auto", width="auto"),
)
self.csv_browse = ui.button(
@@ -52,8 +52,8 @@ def _build_csv_card(self) -> None:
[
widgets.HBox([self.csv_file, self.csv_browse], layout=widgets.Layout(**_WIDE)),
ui.help_text(
- "The table to calculate the statistics from. After a successful run in "
- "step 3 this is the table just written. To look at another experiment, "
+ "The table to calculate the statistics from. After a successful run "
+ "this is the table just written. To look at another experiment, "
"paste the path of its plane_histograms.csv or use "
"Browse…."
),
@@ -161,7 +161,10 @@ def show_statistics(self) -> None:
self.stats_status.value = ui.status(
"info",
"Choose a histogram table first.",
- ["Run step 3, or click Browse… above to pick a plane_histograms.csv."],
+ [
+ "Run the images first (Run), or click Browse… above to pick a "
+ "plane_histograms.csv."
+ ],
)
else:
self.stats_status.value = ui.status(
diff --git a/src/morphobricks_preprocessing/notebook_run.py b/src/morphobricks_preprocessing/notebook_run.py
index ab2afc21..1ea4688c 100644
--- a/src/morphobricks_preprocessing/notebook_run.py
+++ b/src/morphobricks_preprocessing/notebook_run.py
@@ -371,7 +371,7 @@ def _finish_run(self, result: RunResult) -> None:
self.csv_file.value = str(result.csv_path)
self.stats_status.value = ui.status(
"info",
- "The table from step 3 is selected.",
+ "The table the run just wrote is selected.",
["Click Calculate statistics to compare the groups (optional)."],
)
diff --git a/src/morphobricks_preprocessing/notebook_step_states.py b/src/morphobricks_preprocessing/notebook_step_states.py
index 80db79fe..0c5e1ee6 100644
--- a/src/morphobricks_preprocessing/notebook_step_states.py
+++ b/src/morphobricks_preprocessing/notebook_step_states.py
@@ -11,15 +11,19 @@
from .channel_labels import remaining
from .mask_answers import mask_kinds, masks_unanswered
from .notebook_stepper import StepState
-from .notebook_values import size_rows
+from .notebook_values import natural_key, size_rows
from .validation import plural
+UNCALIBRATED = ("uncalibrated", "pixels (stated)")
+"""The X/Y size texts (:func:`.notebook_values.size_text`) that give no physical size."""
ANSWER_STEPS = range(3, 11)
"""The steps (0-based) whose answers the published file holds: conditions to check and save."""
def _ids(wanted: dict[tuple[str, str], str], kind: str) -> list[str]:
- return [text for (k, _), text in sorted(wanted.items()) if k == kind]
+ """Return the IDs of one kind in natural order of their folder (``rep2`` before ``rep10``)."""
+ found = sorted(((natural_key(f), t) for (k, f), t in wanted.items() if k == kind))
+ return [text for _, text in found]
class _StepStates:
@@ -83,10 +87,19 @@ def _sizes_state(self) -> StepState:
shown, roles = self._shown_calibration()
self._sizes_key, self._sizes_rows = key, size_rows(shown.calibration.sizes, roles)
rows = self._sizes_rows
+ bare = [r for r in rows if all(t in UNCALIBRATED for t in r[0][:2])]
+ 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")
+ 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 StepState(True, f"{len(rows)} different sets of sizes")
+ text = f"{len(rows)} different sets of sizes"
+ if bare:
+ text += f", {len(bare)} of them uncalibrated"
+ return StepState(True, text, note=note)
def _changed(self, index: int, done: bool) -> bool:
return not done and index in ANSWER_STEPS and self._reopen_changed()
@@ -210,4 +223,4 @@ def _describe_state(self) -> StepState:
return StepState(False, "optional")
-__all__ = ["ANSWER_STEPS", "_StepStates"]
+__all__ = ["ANSWER_STEPS", "UNCALIBRATED", "_StepStates"]
diff --git a/src/morphobricks_preprocessing/notebook_stepper.py b/src/morphobricks_preprocessing/notebook_stepper.py
index b2a077e3..ddf96c4e 100644
--- a/src/morphobricks_preprocessing/notebook_stepper.py
+++ b/src/morphobricks_preprocessing/notebook_stepper.py
@@ -23,13 +23,20 @@
from . import notebook_style as ui
-CHIP_CLASSES = ("mb-chip-done", "mb-chip-current", "mb-chip-todo", "mb-chip-changed")
+CHIP_CLASSES = (
+ "mb-chip-done",
+ "mb-chip-noted",
+ "mb-chip-current",
+ "mb-chip-todo",
+ "mb-chip-changed",
+)
STEPPER_CSS = """