From 52a7248922f9de023c589508e67dddc113c0f268 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 08:45:56 +0000 Subject: [PATCH 1/4] Notebook: tags per header block, colour pictures, one-click axes (PR 17b-1) - Tag channels: folders with identical headers (exact: channel count, every raw name present and equal, colour flag) are one block tagged once; a block answer writes the same per-folder draft answers as one folder at a time. A folder can be taken out of its block. - The colour pre-tick is confirmed only on Image type; Tag channels restates it. - RGB folders are drawn as one colour picture (colour_composite, Thumbnails.colour): tags, image type, pairing, check and save. - Axes: one-click Confirm these axes when the roles leave nothing to ask; an unchanged reopen offers the earlier run's roles and moves on. - Pixel sizes say uncalibrated on the chip and summary. - Replicate IDs in natural order (rep2 before rep10), display only. - Texts no longer name the old steps 1-4. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- AGENTS.md | 5 +- CHANGELOG.md | 18 + docs/user_guide.md | 5 + pyproject.toml | 2 + src/morphobricks_preprocessing/dataset.py | 2 +- .../description_review.py | 14 +- src/morphobricks_preprocessing/design.py | 10 +- .../notebook_axes_quick.py | 176 +++++++++ .../notebook_channel_thumbs.py | 46 ++- .../notebook_channels_grid.py | 366 ++++++++++-------- .../notebook_channels_labels.py | 2 +- .../notebook_channels_rows.py | 151 ++++++++ .../notebook_channels_type.py | 27 +- .../notebook_check_save.py | 9 +- .../notebook_convert.py | 3 +- .../notebook_described.py | 4 +- .../notebook_design.py | 2 +- .../notebook_mask_step.py | 2 +- .../notebook_names.py | 10 +- .../notebook_results.py | 13 +- .../notebook_run.py | 2 +- .../notebook_step_states.py | 21 +- .../notebook_stepper.py | 48 ++- .../notebook_steps.py | 46 ++- src/morphobricks_preprocessing/notebook_ui.py | 7 +- .../notebook_values.py | 13 + .../preview_render.py | 35 ++ src/morphobricks_preprocessing/run_labels.py | 10 +- tests/test_notebook_axes_quick.py | 91 +++++ tests/test_notebook_channels.py | 2 + tests/test_notebook_check_save.py | 15 + tests/test_notebook_colour.py | 124 ++++++ tests/test_notebook_convert.py | 2 +- tests/test_notebook_stepper.py | 41 +- tests/test_notebook_tag_blocks.py | 154 ++++++++ tests/test_notebook_ui.py | 4 +- tests/test_run_page_labels.py | 26 ++ 37 files changed, 1262 insertions(+), 246 deletions(-) create mode 100644 src/morphobricks_preprocessing/notebook_axes_quick.py create mode 100644 src/morphobricks_preprocessing/notebook_channels_rows.py create mode 100644 tests/test_notebook_axes_quick.py create mode 100644 tests/test_notebook_colour.py create mode 100644 tests/test_notebook_tag_blocks.py 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 ca2f3c88..59267d41 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). - **Notebook: one step at a time (UX proposal 2026-11-09, PR 17a).** `PreprocessingNotebook.steps()` shows the shipped notebook's 13 steps one at a time, with a strip of chips (done green with a tick, current blue, to do grey, changed by a reopen orange; a chip opens its step) and Back/Next (Next is 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..1eb102cd --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_axes_quick.py @@ -0,0 +1,176 @@ +"""*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 + 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 + return saved if self.mapping is not None and roles.axes == self.mapping.axes else None + + 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 + 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..70ba5e94 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_channels_rows.py @@ -0,0 +1,151 @@ +"""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, unit_name + +THUMB_PX = "160px" +RGB_COLOURS = {"R": "#DC2626", "G": "#16A34A", "B": "#2563EB"} +"""The chips of a colour image's fixed channels are drawn in their own 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 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__ = ["RGB_COLOURS", "THUMB_PX", "FolderRows", "folder_info", "picture", "rgb_chip"] 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_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_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 = """