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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,10 @@ src/morphobricks_preprocessing/
(plan §12 Q26, Q31). Since PP-9 it holds the page methods, __init__ and
the shared helpers; the step methods live on mixin classes in the modules
below, and it re-exports every public name it had (`__all__`)
notebook_steps.py steps(): the shipped notebook's 13 steps, one at a time (PR 17a; pure
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_folder.py step 1: folder, check, issues, labels (_FolderStep)
notebook_design.py step 1: the experiment design card (_DesignCard)
notebook_axis_step.py step 2: role dropdowns, axis check, missing-axes card (_AxisStep)
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the

## Unreleased

- **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
never blocked: the save and the run still refuse what is missing). The order is the owner's: images
found, axes, pixel sizes, conditions, replicates (pairing with the replicate and technical-repeat
names, then the consistency card only when it warns), compared against, image type (its own page;
the tag grid has no type column there), stains, tag channels, masks, check and save (starting with a
one-line overview of steps 1-10, each with *change*), run, describe the masks. Pure composition: the
cards, answers, draft, save and run are unchanged; a reopened experiment opens on its first step
still to do (the axes, confirmed once per session). The notebook file shows `notebook.steps()` and
`results_step()`; `folder_step()` and `axis_step()` render as before for embedders. Text: no RGB
sentence on a described run's axes page, no broken picture before the preview, "same way?" *above*,
the reader and CSV lines of *Check and save* behind *Details*, "folder" instead of "unit", and the
steps named by their new numbers. The run page's box plots and curves order the replicates of a
condition by replicate ID (`rep1` before `rep2`), not by folder name.
- **Re-pin the vendored Spec to 1bed024 (MorphoBricks-Spec PR 16, owner decisions RF1 to RF6);
`read_records` follows section 4.6.** The Spec now fixes the bytes and values of
`morphobricks.records.jsonl` and names one refusal, `R_RECORDS_FILE_INVALID`, with the reasons
Expand Down
15 changes: 3 additions & 12 deletions notebooks/MorphoBricks_Preprocessing.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
"In the app:\n",
"\n",
"1. Give the **experiment folder** and optional **output/masks folders**, then click **Check folder**.\n",
"2. Confirm the labels/design and review the axis roles and image type.\n",
"3. Click **Compute histograms**.\n",
"2. Go through the steps one at a time with **Next** (or click a step in the strip at the top): axes, pixel sizes, conditions, replicates, comparisons, image type, stains, channel tags and masks.\n",
"3. On **Check and save**, read your experiment in words and save it; then **Run** computes the histograms.\n",
"4. Review the QC results; pooled statistics are optional.\n",
"\n",
"Detailed explanations appear inside the app only where a decision is required.\n"
Expand Down Expand Up @@ -313,16 +313,7 @@
"from IPython.display import display\n",
"\n",
"app = widgets.VBox(\n",
" [\n",
" notebook.welcome(),\n",
" notebook.folder_step(),\n",
" notebook.axis_step(),\n",
" notebook.channels_step(),\n",
" notebook.masks_step(),\n",
" notebook.check_save_step(),\n",
" notebook.run_step(),\n",
" notebook.results_step(),\n",
" ],\n",
" [notebook.welcome(), notebook.steps(), notebook.results_step()],\n",
" layout=widgets.Layout(width=\"99%\"),\n",
")\n",
"display(app)"
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.notebook_description" = "ui"
"morphobricks_preprocessing.notebook_description_saved" = "ui"
"morphobricks_preprocessing.notebook_description_view" = "ui"
"morphobricks_preprocessing.notebook_names" = "ui"
"morphobricks_preprocessing.notebook_step_states" = "ui"
"morphobricks_preprocessing.notebook_stepper" = "ui"
"morphobricks_preprocessing.notebook_steps" = "ui"
"morphobricks_preprocessing.notebook_design" = "ui"
"morphobricks_preprocessing.notebook_masks" = "ui"
"morphobricks_preprocessing.notebook_masks_screen" = "ui"
Expand Down
2 changes: 2 additions & 0 deletions src/morphobricks_preprocessing/notebook_axis_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,13 @@ def _render_preview(self) -> None:
)
except (PlaneReadError, IndexError, ValueError) as exc:
self.preview_image.value = b""
ui.set_visible(self.preview_image, False)
self.preview_caption.value = ui.status(
"warn", "This plane cannot be shown with these roles.", [str(exc)]
)
return
self.preview_image.value = plane_png(plane)
ui.set_visible(self.preview_image, True)
self.preview_caption.value = self._preview_label() + preview_caption(
header.relative_path, plane
)
Expand Down
2 changes: 2 additions & 0 deletions src/morphobricks_preprocessing/notebook_axis_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def _build_preview_card(self) -> None:
tooltip="Read one plane with the roles above and show it",
)
self.preview_image = widgets.Image(format="png", layout=widgets.Layout(max_width="420px"))
ui.set_visible(self.preview_image, False) # no broken-picture icon before a preview
self.preview_caption = widgets.HTML()
self.preview_checked = ui.ConfirmToggle(
"Does the preview look like a normal image of your sample?",
Expand Down Expand Up @@ -246,6 +247,7 @@ def _reset_preview(self) -> None:
for slider in self.preview_sliders.values():
ui.set_visible(slider, False)
self.preview_image.value = b""
ui.set_visible(self.preview_image, False)
self.preview_caption.value = ""
self.confirmed_status.value = ""

Expand Down
2 changes: 1 addition & 1 deletion src/morphobricks_preprocessing/notebook_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

INTRO = (
"<b>What happens here:</b> first name what you stained (the labels), then say, for every "
"unit, which label each channel is, looking at its picture. Nothing is chosen for you: "
"folder, which label each channel is, looking at its picture. Nothing is chosen for you: "
"names found in the image headers are offered only as suggestions, and each one needs your "
"click."
)
Expand Down
41 changes: 24 additions & 17 deletions src/morphobricks_preprocessing/notebook_channels_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@
def partial_text(label: str, used: int, total: int) -> str:
"""Return the sentence for a label used in only some units, with its consequence."""
return (
f"used in {used} of {total} units: an analysis of {html.escape(label)} will be "
"refused for the other units"
f"used in {used} of {total} folders: an analysis of {html.escape(label)} will be "
"refused for the other folders"
)


Expand All @@ -66,6 +66,8 @@ def __init__(
on_change: Callable[[], None] = lambda: None,
) -> None:
self.store, self.types, self._on_change = store, types, on_change
# False when the image type has a page of its own (the stepper): no type column here.
self.place_types = True
self.layouts: Sequence[UnitLayout] = ()
self.labels: list[str] = []
self.thumbs: Thumbnails | None = None
Expand All @@ -84,9 +86,9 @@ def __init__(
"B. Tag each channel",
[
ui.help_text(
"For every unit, say which label each channel is, looking at its picture. "
"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 units is shown in orange. Every channel and every image type "
"only some folders is shown in orange. Every channel and every image type "
"must be answered."
),
self.contrast,
Expand All @@ -105,7 +107,8 @@ def __init__(
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
self.types.open(layouts)
if self.place_types:
self.types.open(layouts)
self.detail.children = []
self.refresh()
ui.set_visible(self.widget, True)
Expand Down Expand Up @@ -193,17 +196,18 @@ def refresh(self) -> None:
self._building = True
try:
self.cells, self.confirm_buttons = {}, []
self.types.open(self.layouts)
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("<b>unit</b>")]
[widgets.HTML("<b>folder</b>")]
+ [widgets.HTML(f"<b>channel {i + 1}</b> (C{i})") for i in range(width)]
+ [widgets.HTML("<b>image type</b>")],
+ [widgets.HTML("<b>image type</b>" if self.place_types else "")],
layout=columns(width),
)
blocks.append(header)
Expand All @@ -221,7 +225,7 @@ def _group(self, units: Sequence[UnitLayout], width: int) -> 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} &nbsp;·&nbsp; {len(units)} unit(s) with this layout."
text = f"{what} &nbsp;·&nbsp; {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:
Expand All @@ -233,10 +237,10 @@ def _group(self, units: Sequence[UnitLayout], width: int) -> widgets.Widget:
out.append(widgets.HTML(f"<div class='mb-help' style='margin-top:10px'>{text}</div>"))
if pending:
button = ui.button(
f" Yes, these are right for these {len(pending)} unit(s)",
f" Yes, these are right for these {len(pending)} folder(s)",
style="success",
icon="check",
tooltip="Save the pre-ticked answers of these units",
tooltip="Save the pre-ticked answers of these folders",
)
button.on_click(lambda _, p=pending: self._confirm_layout(p))
self.confirm_buttons.append(button)
Expand Down Expand Up @@ -269,7 +273,8 @@ def _row(self, layout: UnitLayout, width: int) -> widgets.Widget:
cells.append(widgets.HTML(f"<span class='mb-help'>{text}</span>"))
else:
cells.append(self._cell(layout, index, tags[index], index in proposed, shown))
cells.append(self.types.cell(layout) if layout.usable else widgets.HTML(""))
typed = layout.usable and self.place_types
cells.append(self.types.cell(layout) if typed else widgets.HTML(""))
return widgets.GridBox(cells, layout=columns(width))

def _picture(self, layout: UnitLayout, index: int) -> widgets.Widget:
Expand Down Expand Up @@ -352,7 +357,7 @@ def _summary(self) -> None:
for label, places in positions(self.layouts, answers.units, self.labels).items():
if len(places) > 1:
where = ", ".join(
f"channel {i + 1} in {n} unit(s)" for i, n in sorted(places.items())
f"channel {i + 1} in {n} folder(s)" for i, n in sorted(places.items())
)
lines.append(
f"<div class='mb-help'>{html.escape(label)} is not always in the same "
Expand All @@ -373,9 +378,11 @@ def _usage_line(self, label: str, used: int, total: int) -> str:
"""Return the usage line of a label; one used in only some units is orange."""
mark = chip(label, self.labels, self._shown_as(label))
if used == total:
return f"<div>{mark} used in all {total} units</div>"
return f"<div>{mark} used in all {total} folders</div>"
if used == 0:
return f"<div>{mark} <span style='color:#92400E'>not used in any unit yet</span></div>"
return (
f"<div>{mark} <span style='color:#92400E'>not used in any folder yet</span></div>"
)
return (
"<div style='background:#FFFBEB;border-left:3px solid #D97706;padding:2px 8px;"
f"margin:2px 0'>{mark} <span style='color:#92400E'>"
Expand All @@ -387,14 +394,14 @@ def _progress(self, total: int) -> str:
answers = self.store.answers()
left = remaining(self.layouts, answers.units, self.labels)
if left.done:
return ui.status("ok", f"Every channel of every one of the {total} units is tagged.")
return ui.status("ok", f"Every channel of every one of the {total} folders is tagged.")
parts = []
if left.cells:
parts.append(f"{left.cells} channel(s) still to tag")
if left.types:
parts.append(f"{left.types} image type(s) still to choose")
if left.blocked:
parts.append(f"{left.blocked} unit(s) cannot be tagged")
parts.append(f"{left.blocked} folder(s) cannot be tagged")
return ui.status("warn", "Not finished: " + ", ".join(parts) + ".")


Expand Down
4 changes: 2 additions & 2 deletions src/morphobricks_preprocessing/notebook_channels_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
EXPLANATION = (
"A label is the short name of <b>what was stained</b>, for example <i>DNA</i> or "
"<i>ACTIN</i>, not the dye (<i>AF488</i>) or the microscope's slot (<i>Ch1</i>). The same "
"label must be used for the same stain in every unit, and different stains must never share "
"label must be used for the same stain in every folder, and different stains must never share "
"a label. Letters and digits, no space at the start or end; capital letters count as the "
"same label."
)
Expand Down Expand Up @@ -112,7 +112,7 @@ def __init__(self, store: AnswerStore, on_change: Callable[[], None]) -> None:
"number of channels found in any image; <b>Add label</b> adds more. Header "
"names are offered only when they are valid labels as they are and not "
"generic. "
"Every channel position of every unit needs a label, so an unused slot "
"Every channel position of every folder needs a label, so an unused slot "
"needs one too: declare it here (for example <i>unused</i>)."
),
self.note,
Expand Down
Loading
Loading