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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ src/morphobricks_preprocessing/
dropdowns from image_tree.py, t and z sliders, every channel of one
plane, at most 6); image_view.py read_view, the one reader behind it;
notebook_viewers.py which roles each step reads an image with
notebook_scan_axes.py the quick scan's "Axes of this image": roles proposed from the header,
changed right there, the proposal the Axes step starts from
report_view.py report_html: the check's or run's report as headings and tables (pure)
notebook_quick_scan.py the first step's quick scan: the folder walk only, one random image for
looking only, then the explicit click that reads every header
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ experiment-v1 §4.5, §4.5a-§4.5c, §7, §8; ADR 0002 resolver contract version
- **Pixel size: two labelled inputs, X and Y**, with **same for X and Y** (ticked: Y follows
X), instead of "One pixel is …". What is stated is unchanged (`calibration.csv`, X and Y for
`.`).
- **New axes flow.** The quick scan proposes the axes of the image it shows from that image's
header (**Axes of this image**: one list per dimension); a role changed there redraws the
pictures at once, and a header that names no axes gets its picture once the user says what
its dimensions are. The roles left there are the proposal the Axes step starts from: the
folder check reads every header, the proposal is checked against every image
(`review_axes`, unchanged) and one line on Images says whether it fits them all; the Axes
step confirms for the whole experiment, as before (one click when every header agrees, else
the tick and **Confirm axis roles**), and says that the axes can be changed. Nothing is
confirmed at the scan; a new scan forgets the roles of the one before.
- **Pixel sizes that differ are one line.** With three or more different sizes in one unit:
*Pixel sizes differ slightly: 0.10047–0.10054 µm (spread 0.07 %) across 138 images, 25
combinations* (`pixel_size_summary.spread_line`), the sizes in a folded table (size, images,
folders, source) and the two explicit choices: one size for all images (shown before it
replaces anything, recorded as stated) or sizes per imaging unit. The per-unit tables no
longer open by themselves. Nothing is chosen silently: until a choice, each image is
recorded with its own header's size, as before.
- **Run messages are short cards**: a message whose first sentence lists every image shows
its first 200 characters, with the whole message, the files and what to do one click away.
- **Notebook file, cell 3:** on Google Colab it calls
`google.colab.output.no_vertical_scroll()`, so the app is not shown in an output frame that
scrolls on its own. Nothing changes elsewhere.
Expand Down
23 changes: 18 additions & 5 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,19 @@ editing, or the confirmed ones. An image that cannot be drawn gets a sentence sa
1. **Images.** Give the folders (images; masks and output are optional) and click **Scan the
folders**. The scan lists the conditions and folders without reading any image header and
shows one image chosen at random in the viewer, for looking only; any other image can be
chosen there. If this is your experiment, click **Looks right: read all image headers**.
The viewer then shows the images as the check read them; the folders found, the check's
chosen there. Under the pictures, **Axes of this image** proposes a role for each of its
dimensions from its header. If a picture looks wrong (a strip, stripes, channels that are
really slices), change a role right there: the pictures are redrawn, and a header that
names no axes gets its picture once you say what its dimensions are. What you leave there
is the proposal for every image; nothing is confirmed yet. If this is your experiment,
click **Looks right: read all image headers**: every header is read and the proposal is
checked against every image (one line says whether it fits them all). The viewer then
shows the images as the check read them; the folders found, the check's
report (formatted: a summary, then tables with the details folded) and **Exclude images**
are collapsed below.
2. **Axes.** What the files hold, in three parts.
2. **Axes.** What the files hold, in three parts. The roles start from the proposal of step
1 (the header's, or what you chose on the example) and can be changed here at any time;
once confirmed, **Change axis roles** takes the confirmation back.
- **Axis roles:** one row per image dimension with its role, and under them the viewer
with one image as these roles read it, redrawn whenever you change a role (a wrong role
looks wrong: a strip, stripes); choose any other image, time point or slice there. Click **Confirm these axes** (offered when the headers name
Expand All @@ -304,8 +312,13 @@ editing, or the confirmed ones. An image that cannot be drawn gets a sentence sa
same in all 270 images)*. When the headers give none, type it once under **Pixel size X**
(**Y** follows while **same for X and Y** is ticked; untick it to type another Y) and
click **Use for all images**, or leave the images uncalibrated; nothing is guessed. When the headers
disagree, the line says how many images have which size, and **Set per imaging unit…**
opens.
disagree, the line says how many images have which size; with three or more sizes it
gives their range instead (*Pixel sizes differ slightly: 0.10047–0.10054 µm (spread
0.07 %) across 138 images, 25 combinations*), with the sizes in a folded table. Nothing
is chosen for you: until you choose, each image is recorded with the size of its own
header. Either state one size for all images (it is shown what it replaces before it is
written, and it is recorded as stated by you), or state sizes per folder under **Set per
imaging unit…**.
- **Image type:** what the channel dimension holds, *separate stains or markers* or
*colour (RGB)*. It is proposed from the image headers and recorded when you click **Use
for all folders**. **Set per folder…** holds one row per folder; it opens when the
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.notebook_image_type_line" = "ui"
"morphobricks_preprocessing.notebook_quick_scan" = "ui"
"morphobricks_preprocessing.notebook_viewer" = "ui"
"morphobricks_preprocessing.notebook_scan_axes" = "ui"
"morphobricks_preprocessing.notebook_viewers" = "ui"
"morphobricks_preprocessing.image_view" = "io"
"morphobricks_preprocessing.image_tree" = "pure"
Expand Down
5 changes: 4 additions & 1 deletion src/morphobricks_preprocessing/notebook_axes_quick.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,12 @@ def _quick_text(self, offer: AxisMapping) -> str:
for axis, role in zip(offer.axes, offer.roles, strict=True)
if role is not None
)
chosen = getattr(self, "_scan_roles", {})
source = (
"the roles the earlier run of this experiment recorded"
if self._saved_axes() is not None
else "the roles you chose on the example image (Images), which every header agrees with"
if offer.axes in chosen and chosen[offer.axes].roles == offer.roles
else "the roles every image header names"
)
return ui.status(
Expand All @@ -157,7 +160,7 @@ def _quick_text(self, offer: AxisMapping) -> str:
[
*self._headers_changed_line(),
f"These are {source}. If the example shows your sample as you expect, confirm. "
"Nothing is confirmed until you click.",
"Nothing is confirmed until you click. They can be changed above.",
],
raw=True,
)
Expand Down
29 changes: 27 additions & 2 deletions src/morphobricks_preprocessing/notebook_calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from .notebook_options import _WIDE
from .notebook_render import issues_html
from .notebook_values import size_rows
from .pixel_size_summary import summary_line, unit_rows, xy_groups
from .pixel_size_summary import combination_rows, summary_line, unit_rows, xy_groups
from .recorded_sizes import PIXELS, SIZE_ROLES
from .run import OutputLockedError, Preflight
from .run.calibrate import update_calibration
Expand Down Expand Up @@ -124,6 +124,7 @@ def _build_calibration_card(self) -> None:
"Pixel size",
[
self.pixel_line,
self.pixel_differs,
self.pixel_change,
self.pixel_form,
self.pixel_replace_box,
Expand Down Expand Up @@ -163,6 +164,7 @@ def _build_pixel_units(self, form: widgets.Widget) -> None:
def _build_pixel_line(self) -> None:
"""Build the one line about the pixel size and the one input for the whole experiment."""
self.pixel_line = widgets.HTML()
self.pixel_differs = widgets.HTML()
self.unit_table = widgets.HTML()
narrow = widgets.Layout(width="110px")
self.pixel_x = widgets.Text(placeholder="e.g. 0.325", layout=narrow)
Expand Down Expand Up @@ -369,11 +371,12 @@ def _show_calibration(self) -> None:
groups = xy_groups(sizes)
kind, line = summary_line(groups)
self.pixel_line.value = ui.status(kind, line)
self.pixel_differs.value = self._differing_sizes(groups)
one_size = len(groups) == 1 and groups[0].calibrated
# One input when nothing gives a size; behind "Change…" when the images have one.
ui.set_visible(self.pixel_change, one_size)
ui.set_visible(self.pixel_form, not one_size)
if len(groups) > 1:
if len(groups) > 1 and not self._one_line_sizes:
self.pixel_units.selected_index = 0 # they differ: the units are shown, not hidden
rows = [("Imaging unit (folder)", "Pixel size (X × Y)", "From")]
self.unit_table.value = ui.table([*rows, *unit_rows(sizes)])
Expand All @@ -391,6 +394,28 @@ def _show_calibration(self) -> None:
issues = [i for i in shown.metadata.issues if i.code in CALIBRATION_ISSUE_CODES]
self.calibration_issues.value = issues_html(issues) if issues else ""

_one_line_sizes = False
"""True in the shipped notebook: sizes that differ are one line, a folded table and the
two explicit choices, and the per-unit part stays folded until it is opened."""

def _differing_sizes(self, groups: list) -> str:
"""Return, for sizes that differ, the folded table and the choices (HTML), else ""."""
if len(groups) < 2 or not self._one_line_sizes:
return ""
rows = [("Pixel size (X × Y)", "Images", "Folders", "From"), *combination_rows(groups)]
table = (
f"<details class='mb-about'><summary>The {len(groups)} different sizes</summary>"
f"{ui.table(rows)}</details>"
)
choices = (
"<div style='color:#475569;font-size:12.5px;margin:2px 0 6px 0'>Nothing is chosen "
"for you. Until you choose, each image is "
"recorded with the size of its own header. Either state <b>one size for all "
"images</b> below (recorded as stated by you; the headers stay as they are), or "
"state sizes per folder under <b>Set per imaging unit…</b>.</div>"
)
return table + choices

def _calibration_targets(self) -> list[str]:
"""Return the targets offered: the whole experiment, every folder and every image."""
images = self.preflight.discovery.images
Expand Down
5 changes: 5 additions & 0 deletions src/morphobricks_preprocessing/notebook_compact.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import ipywidgets as widgets

from . import notebook_style as ui
from .notebook_render import issues_brief_html
from .notebook_stepper import about, field_rows

FOLDER_INTRO = (
Expand Down Expand Up @@ -76,6 +77,7 @@ def _images_page(self) -> list[widgets.Widget]:
self.check_button,
self.check_blocker,
self.folder_status, # the check's progress, right under the button clicked
self.scan_axes_line,
self.folder_lock.card,
self.folder_tiles,
self.issues_card,
Expand Down Expand Up @@ -151,6 +153,9 @@ def _compare_box(self) -> widgets.Widget:

def _finish_run(self, result: object) -> None:
super()._finish_run(result)
issues = getattr(result, "issues", ())
# Short cards: a message that lists every image is one line, the rest one click away.
self.run_issues.value = issues_brief_html(issues) if issues else ""
self._refresh_folds() # the checks, Compare groups and the report now have content

def _refresh_folds(self) -> None:
Expand Down
3 changes: 2 additions & 1 deletion src/morphobricks_preprocessing/notebook_quick_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def _build_quick_scan(self) -> None:
" Show another image", icon="random", tooltip="Choose another image at random"
)
self.scan_box = widgets.VBox( # one column: the pictures, then the folders
[self.scan_example, self.scan_another, self.scan_tree],
[self.scan_example, self.scan_roles_card, self.scan_another, self.scan_tree],
layout=widgets.Layout(width="99%"),
)
self.scan_box.add_class("mb-scan")
Expand Down Expand Up @@ -192,6 +192,7 @@ def listed(path: str) -> None:
self._idle(self.scan_button, SCAN_LABEL, "search")
self._scan, self._scanned = found, self._scan_inputs()
self._scan_headers = {} # a new scan reads the headers of its pictures again
self._reset_scan_axes()
wait.phase("Opening one image")
self._show_scan_example(None, status=False)
excluded = frozenset(normalise_relative_path(path) for path in self._excluded())
Expand Down
6 changes: 6 additions & 0 deletions src/morphobricks_preprocessing/notebook_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ def issues_html(issues: Iterable[ValidationIssue]) -> str:
return "".join(parts)


BRIEF_CHARS = 200
"""Characters of an issue's first sentence shown before the rest is folded."""


def issues_brief_html(issues: Iterable[ValidationIssue]) -> str:
"""Render issues as short cards: what is wrong in one sentence, the rest on demand.

Expand All @@ -50,6 +54,8 @@ def issues_brief_html(issues: Iterable[ValidationIssue]) -> str:
parts = []
for issue in sorted(issues, key=lambda issue: not issue.is_error):
first, stop, rest = issue.message.partition(". ")
if len(first) > BRIEF_CHARS: # a sentence that lists every file: its start, the rest folded
first, stop, rest = first[:BRIEF_CHARS] + "…", "", issue.message
more = [html.escape(rest)] if rest else []
if issue.paths:
more.append(ui.path_list(issue.paths, limit=5))
Expand Down
Loading
Loading