diff --git a/AGENTS.md b/AGENTS.md index bf61183..c2b97f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -314,9 +314,21 @@ 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_steps.py steps(): the shipped notebook's 12 steps, one at a time (PR 17a; pure + composition of the cards below); notebook_stepper.py the strip, Back/Next, + the overview, the compact page (one screen per step: the cards scroll in + their own box) and about()/field_rows(); notebook_step_states.py each + step's done/one-line state + notebook_compact.py the Images and Run steps arranged for one screen: one Folders card, the + collapsed panels, and Compare groups (the former statistics page) in the + run step (redesign of 10 November 2026) + notebook_progress.py Wait: one line per wait (done of total, item, elapsed, time left once + measurable); progress_html, time_left (pure parts) + 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 + axis_example.py which planes the axes step's example shows (pure): one tile per channel, + a few time points or slices; notebook_plots.example_png draws them + pixel_size_summary.py the pixel size in one line, which images differ and where (pure) 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_row_fold.py reopen: unchanged, answered rows fold; new or changed rows orange @@ -747,9 +759,12 @@ Technical details go to `logging` (shown in the notebook's "Technical details" p | Masks (Q32) | Optional masks folder outside the experiment, one subfolder per mask type mirroring the experiment's folders, or the experiment's folders with `_.tif` (both may be mixed; `ambiguous_mask` when a name could fit two images or both layouts), paired by leaf folder + `position_id`; OME-Zarr label images are masks too. Header-only errors (`masks_folder_not_found`, `masks_inside_experiment`, `mask_names_differ_in_case`, `duplicate_mask`, `ambiguous_mask`, `unreadable_mask`, `mask_not_2d`, `mask_size_mismatch`, `mask_pixel_type`) and warnings (`mask_missing`, `mask_without_image`, `mask_outside_type_folder`) in `check`/`run`; manifest key `masks` (optional, version 1). Optional mask step (`masks` command, `scan_masks`): kinds, relations with evidence, direct-parent hierarchy, `mask_relationships.json` (report version 1); default thresholds the user may change (`thresholds=` keyword, one CLI option each, checked by `mask_thresholds`, written in the report); suggestions only, the user names and confirms. SciPy core dependency. Confirmed by the owner: 2D masks at the image's YX, integer/bool pixels, masks folder outside the experiment. Next: notebook step and a file of the user's answers; tag v0.2.0 proposed after merge. Plane identity (issue #36): masks may have Z and/or T named in their own metadata (never guessed: unnamed axes are `mask_not_2d`); the masks of an image share their planes (`mask_planes_differ`) and `run` checks them against the image's confirmed roles (`mask_planes_mismatch`, `mask_plane_issues`), so a 2D mask is refused for a Z-stack or time-lapse; manifest mask entries gain `axes` and `shape` (optional keys, version 1); `read_mask(z=, t=)` reads one plane; `scan_masks` scans plane by plane | | Notebook style | Follow the lab's NucleiSky and LabChronicle notebooks (see §7) | | Widget widths | **99%**, never 100% | +| Waits | Every wait over about a second shows `notebook_progress.Wait`: done of total, the item, elapsed time, time left once measurable. Readers take a `progress` callback; no bare spinner (owner, 10 November 2026) | +| Disabled actions | Every disabled button has a visible `notebook_render.blocked_line` beside it saying what blocks it; a tooltip alone is a hidden refusal (owner, 10 November 2026) | +| One screen per step | The shipped notebook's steps fit 1366 x 768: one row of chips, explanations under *About this step*, details in collapsed panels, long lists scrolling in their own box (owner, 10 November 2026) | | Paths and filenames in widget HTML and notebook text | **`…` / `**…**`, never `` or inline code** | | Things the user must confirm | **Large buttons** in a highlighted question box (`ConfirmToggle`), never small checkboxes | -| Statistics step | "Step 4 · Calculate the statistics (optional)": clearly optional, writes nothing. A CSV chooser (Browse…) is prefilled with the table just computed, otherwise empty. Not calculated automatically | +| Statistics step | **Shipped notebook since v0.5.1: the collapsed *Compare groups* panel of the run step, for that run's table; no table chooser, no second intensity check (owner, 10 November 2026). The page below remains for `results_step()` without `steps()`.** "Step 4 · Calculate the statistics (optional)": clearly optional, writes nothing. A CSV chooser (Browse…) is prefilled with the table just computed, otherwise empty. Not calculated automatically | --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 142bbde..b719810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,47 @@ OME-XML description is parsed (which images are marked is unchanged; a malformed debug line without a traceback), and while the capture is attached the package's DEBUG records go to the "Technical details" panel only, with WARNING and above still reaching the root handlers. +### Notebook redesign 4: one column, less on screen; Compare groups in the run step + +Owner test of v0.5.0 (10 November 2026), items (a) and (b), and the owner's test of the first +redesign the same day, items (m) and (n): "simple and minimalistic"; no box that scrolls inside +the page; always one column. Layout only: no answer, check, refusal, draft, description or run +output changes. + +- **One column, and the page scrolls as a whole.** Nothing in a step scrolls on its own (no + fixed-height step body, no table or file list with its own scroll bar), and no cards, panels + or columns sit side by side. A step is short because it says less: its explanation is folded + under **About this step**, a card's help paragraph is one line that opens when pointed at, + details are collapsed panels, and cards, tiles, tables, buttons and pictures are denser. The + strip is one row; a step's title and purpose are one line. +- **Images:** one *Folders* card with three labelled rows (images, masks, output); **Scan the + folders**, what it found, then **Looks right: read all image headers**, one under the other. + The scan shows the conditions in a short table (every folder under *All N folders*). After + the check the scan makes way for the result, and the pictures per folder, the check report + and *Exclude images* are collapsed panels. +- **The quick scan shows every channel of its one image side by side** across the page, each + with its own contrast and its `c=` index, when that image's header names a channel axis + (`header_preview.header_channels`): up to six, and with seven or more it shows six and says + "6 of N channels shown". Display only, read from that one image; a header that names no + channel axis gets the one picture as before (no channel is guessed from the shape). +- **Axes:** one row per dimension (what the file has, its role, what the role does), then the + example, then the pixel size, one under the other. A refusal of the roles is one sentence + with *Files and what to do* on demand (`notebook_render.issues_brief_html`), e.g. "Error · + different channel counts: The images have different numbers of channels: 3 channels (10 + images), 4 channels (4 images)." +- **Run:** *Before you run* is shown only while something is missing; *Settings and mode* and + *What will happen* are collapsed; after a run the intensity checks and plots, **Compare + groups** and the report are collapsed panels. +- **Compare groups replaces the optional statistics page** in the shipped notebook: the + grouping, the percentiles, the pooled statistics and the distribution plot of the table this + run wrote. There is no table to choose and the intensity checks are not repeated. + `results_step()` after `steps()` returns an empty hidden box, so the shipped notebook's cell + needs no change; called without `steps()` it is the page it was. +- **`welcome()` is one line** (name, version, "the notebook is ready"); the *What next?* boxes + are not shown in the stepper. +- The stacked pages other bricks embed (`folder_step`, `axis_step`) get the one-column axes + layout (roles, example, then the named-axis copies card); nothing else changes for them. + ### Notebook redesign 3: the axes step shows the data as the roles read it; pixel size in one line Owner test of v0.5.0 (10 November 2026), items (e) and (f). The shipped notebook has 12 steps: diff --git a/docs/colab-release-check.md b/docs/colab-release-check.md index 6366678..c1b5a70 100644 --- a/docs/colab-release-check.md +++ b/docs/colab-release-check.md @@ -93,7 +93,8 @@ The notebook has three code cells, run in order: **1. Install MorphoBricks prepr Step 1 without running. The button is enabled, names the earlier run, and works. 14. - [ ] Move the masks folder on Drive and click: a red card says the masks folder does not exist, and nothing is written. -15. - [ ] The tables scroll inside the card, with no horizontal page scrollbar. +15. - [ ] The page scrolls as a whole: no box inside it has its own scroll bar, and there is no + horizontal page scrollbar. ## E. Make named-axis copies (PR #127) @@ -101,7 +102,7 @@ The notebook has three code cells, run in order: **1. Install MorphoBricks prepr press **Check folder**. 17. - [ ] In Step 2, swap the Y and X roles. The red axis check appears, with the **Make named-axis copies (optional)** card under it. Its button is disabled. -18. - [ ] Press **Show preview** and **Yes, the preview looks right**, then **Yes, write the +18. - [ ] Look at the example (drawn by itself) and press **Yes, the example looks right**, then **Yes, write the copies with these roles**. 19. - [ ] Press **Browse…**: the amber card names **Copy path**. Paste a Drive folder inside the experiment and check that it is refused. Then paste one next to the experiment. diff --git a/docs/user_guide.md b/docs/user_guide.md index 712f34d..5cebc62 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -265,6 +265,65 @@ notebook's step 3 (§4), also tells how they relate. ## 4. The notebook, step by step +### The app in short (v0.5.1) + +Cell 3 opens the app: a strip of twelve steps, one on screen at a time, with **Back** and +**Next** under it (**Next** is never blocked; a chip opens its step). The page is one column +and scrolls as a whole: nothing scrolls inside its own box and nothing sits side by side. +Explanations are folded under **About this step**, and details are in collapsed panels. +Every wait shows a bar with what is done of the total, the file being read, the time elapsed +and, once it can be measured, the time left. Every greyed-out button has a line beside it +saying what it waits for. + +1. **Images.** Give the folders (images; masks and output are optional). **Scan the folders** + lists the conditions and folders with their image counts without reading any image header, + and shows one image chosen at random, with its path, for looking only: it is not used for + anything. Every channel of it is shown side by side (`c=0`, `c=1`, …, each with its own + contrast; at most six, and then it says *6 of N channels shown*). + If this is your experiment, click **Looks right: read all image headers**; the + headers are then read and checked. Changing a folder asks for a new scan. The pictures of + each folder, the check's report and **Exclude images** are collapsed below. +2. **Axes.** One row per image dimension with its role, and under them an **example of one + image as these roles read it**: one tile per channel (`c=0`, `c=1`, …), a few time points + or slices, or the single plane, each with its height and width. It is drawn by itself and + redrawn whenever you change a role, so a wrong role looks wrong (a strip, stripes). Another + image or other planes are under **Another image, or other planes**. Nothing is confirmed + for you: click **Confirm these axes** (offered when the headers name every axis and + nothing is open), or **Yes, the example looks right** and then **Confirm axis roles**. + - **Pixel size** is one line here: for example *Pixel size: 0.108 × 0.108 µm (from the + headers, same in all 270 images)*. When the headers give none, type it once and click + **Use for all images** (or choose *pixels: deliberately uncalibrated*); leaving it empty + records the images as uncalibrated, nothing is guessed. When the headers do not agree, + the line says how many images have which size and in which folders, and **Set per + imaging unit…** opens: there you state a size for one folder (imaging unit) or image. + **Use for all images** is your statement for every image: it replaces sizes stated per + folder, and a size that differs from an image's header is still listed as a warning. +3. **Conditions**, 4. **Replicates**, 5. **Compared against**, 6. **Image type**, 7. **Stains**, + 8. **Tag channels**, 9. **Masks**: one kind of answer per step, saved as you give it. +10. **Check and save.** Your experiment in sentences, one question per replicate, then **Yes, + save the experiment description**. The line beside the button says what is still missing. +11. **Run.** **Compute histograms**; what is still to do is listed only while something is. + The settings and *What will happen* are collapsed. After the run: its result, and collapsed + panels for the **intensity checks and plots**, **Compare groups** and the report. + **Compare groups** replaces the former optional statistics page: choose how to group + (condition, biological repeat, technical repeat or image) and click **Calculate + statistics** for the pooled statistics and the distribution plot of the table this run + wrote. It writes nothing. +12. **Describe the masks** (optional), after a run that recorded masks. + +Images with different numbers of channels (for example three in most folders and four in +one) are refused in this version (`different_channel_counts`): the axes step names the error in +one sentence, with the files and what to do one click away, and **Confirm axis roles** says +that this is what it waits for. + +The rest of this section describes each card in detail. It is written for the same cards +shown as stacked pages (as other bricks embed them); where the app differs, the list above +is what you see. In particular: **Show preview** and **Yes, the preview looks right** are the +example and **Yes, the example looks right**; **Check folder** is **Looks right: read all +image headers**; and step 4 below is the **Compare groups** panel. + +### The cards in detail + The notebook's code cells are hidden: run them from top to bottom (click a cell and press **Shift + Enter**). Each step is a page of boxes. A blue box at the top says what the step is for, every control has a grey help line, and one coloured message says what happened diff --git a/notebooks/MorphoBricks_Preprocessing.ipynb b/notebooks/MorphoBricks_Preprocessing.ipynb index f27261b..0e8d130 100644 --- a/notebooks/MorphoBricks_Preprocessing.ipynb +++ b/notebooks/MorphoBricks_Preprocessing.ipynb @@ -14,7 +14,7 @@ "- **Confirms the experimental design**: folder labels, biological-repeat pairing and controls.\n", "- **Confirms image interpretation**: axis roles and fluorescence/RGB image type. Scientific metadata are never silently guessed.\n", "- **Computes per-plane intensity histograms** used by downstream MorphoBricks.\n", - "- **Runs intensity QC** and provides optional pooled statistics after preprocessing.\n", + "- **Runs intensity QC** and, on request, compares groups after preprocessing.\n", "\n", "### Before you start\n", "\n", @@ -372,12 +372,12 @@ "\n", "In the app:\n", "\n", - "1. Give the **experiment folder** and optional **output/masks folders**, then click **Check folder**.\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", + "1. Give the **experiment folder** (masks and output folders are optional) and click **Scan the folders**. Look at the folders and the example image, then click **Looks right: read all image headers**.\n", + "2. Go through the steps one at a time with **Next** (or click a step in the strip at the top): axes and pixel size, 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", + "4. After the run, the intensity checks and **Compare groups** are collapsed panels of the run step.\n", "\n", - "Detailed explanations appear inside the app only where a decision is required." + "Every wait shows its progress, and a greyed-out button says beside it what it waits for." ] }, { diff --git a/pyproject.toml b/pyproject.toml index 1deac54..081960a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,7 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.run_labels" = "io" "morphobricks_preprocessing.notebook_axes" = "ui" "morphobricks_preprocessing.notebook_progress" = "ui" +"morphobricks_preprocessing.notebook_compact" = "ui" "morphobricks_preprocessing.notebook_quick_scan" = "ui" "morphobricks_preprocessing.notebook_axes_quick" = "ui" "morphobricks_preprocessing.notebook_channel_thumbs" = "ui" diff --git a/src/morphobricks_preprocessing/header_preview.py b/src/morphobricks_preprocessing/header_preview.py index 8d86616..c9f1791 100644 --- a/src/morphobricks_preprocessing/header_preview.py +++ b/src/morphobricks_preprocessing/header_preview.py @@ -47,12 +47,15 @@ check_unchanged, checked_fingerprint, ) -from .preview_crop import TilePictures, tile_pictures +from .preview_crop import TilePictures, downscale_factor, tile_pictures +from .preview_render import display_panel, png_bytes PLANE_CAP = 8 """Planes read for one picture, at most.""" PIXEL_CAP = 4096 * 4096 """Pixels read for one picture (all its planes and colours), and the largest plane, at most.""" +CHANNELS_SHOWN = 6 +"""Channels drawn for one image when its header names a channel axis, at most.""" NO_YX = "preview after Axes (step 2)" HEADER_NOTE = "from the file header, axes not confirmed yet" _SLOTS = ("Z", "T", "C") @@ -193,6 +196,98 @@ def header_planes(path: str | os.PathLike, meta: TiffMetadata) -> HeaderPlanes: return HeaderPlanes(tuple(stacks), s is not None, text) +@dataclass(frozen=True) +class ChannelTile: + """One channel of an image as its header reads it: its index, picture and what to say.""" + + c: int + png: bytes + note: str = "" + + +@dataclass(frozen=True) +class HeaderChannels: + """The channels of one image drawn side by side from its header, for looking only. + + ``total`` is the length of the channel axis the header names; ``tiles`` holds the first + :data:`CHANNELS_SHOWN` of them, each with its own contrast. ``text`` says what was projected. + """ + + image: str + tiles: tuple[ChannelTile, ...] + total: int + text: str + + +def _channel_layout(meta: TiffMetadata) -> tuple[AxisMapping, list[int], int]: + """Return the reading layout of a header that names Y, X and a channel axis. + + Raises + ------ + PreviewUnavailable + When the header names no channel axis longer than one, says colour, or cannot be + stepped through (the single picture of :func:`header_preview` says why). + """ + named = named_axes(meta) + if "C" not in named or _colour_axis(meta) is not None: + raise PreviewUnavailable("the header names no channel axis") + c = named.index("C") + if "Y" not in named or "X" not in named or meta.shape[c] < 2: + raise PreviewUnavailable("the header names no channel axis") + y, x = named.index("Y"), named.index("X") + roles: list[str | None] = [None] * len(meta.axes) + roles[y], roles[x], roles[c] = "Y", "X", "C" + projected = [d for d, n in enumerate(meta.shape) if d not in (y, x, c) and n > 1] + if len(projected) > 2: + raise PreviewUnavailable("the header has more axes than a preview here projects") + for d, role in zip(projected, ("Z", "T"), strict=False): + roles[d] = role + return AxisMapping(meta.axes, tuple(roles)), projected, c + + +def header_channels( + path: str | os.PathLike, meta: TiffMetadata, side: int, shown: int = CHANNELS_SHOWN +) -> HeaderChannels: + """Draw the first ``shown`` channels of an image by its header's axes, each on its own. + + Only for a header that names Y, X and a channel axis (nothing is guessed from the shape); + every other axis is max-projected within the caps of this module, shared by the channels + drawn. Each channel gets its own contrast (:func:`.preview_render.display_panel`). + + Raises + ------ + PreviewUnavailable + Without a named channel axis, or over the caps. + PlaneReadError, OSError, ValueError, IndexError, MemoryError + When the file cannot be read. + """ + if not meta.readable: + raise PreviewUnavailable(f"the header cannot be read: {meta.error or 'unknown reason'}") + mapping, projected, c_axis = _channel_layout(meta) + plane = meta.shape[mapping.index_of("Y")] * meta.shape[mapping.index_of("X")] + if plane > PIXEL_CAP: + raise PreviewUnavailable("planes over the pixel cap of a preview here") + total = meta.shape[c_axis] + channels = range(min(total, shown)) + lengths = [meta.shape[d] for d in projected] + planes = math.prod(lengths) + chosen = _spread(planes, max(1, min(PLANE_CAP, PIXEL_CAP // (plane * len(channels))))) + tiles = [] + for c in channels: + stack: np.ndarray | None = None + for number in chosen: + where = dict(zip(projected, np.unravel_index(number, lengths), strict=True)) + coords = {mapping.roles[d].lower(): int(i) for d, i in where.items()} + pixels = _read(path, mapping, {**coords, "c": c}) + stack = pixels if stack is None else np.maximum(stack, pixels) + factor = downscale_factor(stack.shape, side) + panel = display_panel(np.ascontiguousarray(stack[::factor, ::factor])) + tiles.append(ChannelTile(c, png_bytes(panel.pixels), panel.note)) + return HeaderChannels( + meta.relative_path, tuple(tiles), total, _caption(meta, projected, len(chosen), planes) + ) + + def header_preview( root: Path, meta: TiffMetadata | None, @@ -231,13 +326,17 @@ def header_preview( __all__ = [ + "CHANNELS_SHOWN", "HEADER_NOTE", "NO_YX", "PIXEL_CAP", "PLANE_CAP", + "ChannelTile", + "HeaderChannels", "HeaderPlanes", "HeaderPreview", "PreviewUnavailable", + "header_channels", "header_planes", "header_preview", ] diff --git a/src/morphobricks_preprocessing/notebook_axes_quick.py b/src/morphobricks_preprocessing/notebook_axes_quick.py index 90aa5fd..76a3c46 100644 --- a/src/morphobricks_preprocessing/notebook_axes_quick.py +++ b/src/morphobricks_preprocessing/notebook_axes_quick.py @@ -111,6 +111,8 @@ def _refresh_quick_axes(self, drawn: bool = False) -> None: for widget in (self.preview_checked.widget, self.preview_blocker, self.confirm_button, self.confirm_blocker): # fmt: skip ui.set_visible(widget, not offered) + # The offer says the roles are the headers' and nothing is open: the line is not repeated. + ui.set_visible(self.axis_status, not offered) if offer is None: return if offer.roles != self.mapping.roles: diff --git a/src/morphobricks_preprocessing/notebook_axis_preview.py b/src/morphobricks_preprocessing/notebook_axis_preview.py index 0dfa088..47c441d 100644 --- a/src/morphobricks_preprocessing/notebook_axis_preview.py +++ b/src/morphobricks_preprocessing/notebook_axis_preview.py @@ -200,10 +200,8 @@ def _preview_label(self) -> str: return ui.status("ok", "Preview with the confirmed axis roles.") return ui.status( "info", - "Preview of the roles above, not confirmed yet.", - [ - "Nothing is written: the run uses only the roles you confirm below.", - ], + "Preview of the roles above, not confirmed yet. Nothing is written: the run uses " + "only the roles you confirm.", ) def _preview_header(self): # noqa: ANN202 diff --git a/src/morphobricks_preprocessing/notebook_axis_step.py b/src/morphobricks_preprocessing/notebook_axis_step.py index 5bfe5f9..1eece36 100644 --- a/src/morphobricks_preprocessing/notebook_axis_step.py +++ b/src/morphobricks_preprocessing/notebook_axis_step.py @@ -14,10 +14,20 @@ from .image_type import FLUORESCENCE from .notebook_axes import axis_check_html, header_marks from .notebook_options import IMAGE_TYPE_OPTIONS, ROLE_OPTIONS -from .notebook_render import blocked_line, issues_html, plane_summary_html, role_effect +from .notebook_render import ( + blocked_line, + issues_brief_html, + plane_summary_html, + role_effect, +) from .run import Preflight from .validation import plural +ROLES_HELP = ( + "One box per dimension, in the order stored in the files. Exactly one Y and one X; a " + "dimension with more than one plane needs Z, C or T." +) + class _AxisStep: """Step 2's axis roles: the role dropdowns, the axis check and the missing-axes card.""" @@ -131,6 +141,7 @@ def _build_preview_card(self) -> None: ], ) self.axis_sizes_slot = widgets.VBox() # the pixel size card, in the shipped notebook + self.roles_help = ui.help_text(ROLES_HELP) # folded into About this step there def _build_image_type_card(self) -> None: """Build the image type card, hidden until the axis roles are confirmed.""" @@ -182,27 +193,16 @@ def _refresh_axis_step(self) -> None: self._set_locked(False) roles = ui.card( "Axis roles", - [ - ui.help_text( - "One box per dimension, in the order stored in the files. Exactly one Y " - "and one X; a dimension with more than one plane needs Z, C or T." - ), - grid, - self.plane_summary, - ], + [self.roles_help, grid, self.plane_summary], ) - left = widgets.VBox( - [roles, self.missing_card, self.axis_status, self.axis_sizes_slot], - layout=widgets.Layout(flex="1 1 0", min_width="0"), - ) - right = widgets.VBox( - [self.preview_card], layout=widgets.Layout(flex="1 1 0", min_width="0") - ) - columns = widgets.HBox([left, right], layout=widgets.Layout(width="99%")) - columns.add_class("mb-columns") + # One column (owner, 10 November 2026): the roles, the example they give, the pixel size. self.axis_box.children = [ - columns, + roles, + self.missing_card, + self.axis_status, + self.preview_card, self.convert_card, + self.axis_sizes_slot, self.confirm_button, self.confirm_blocker, self.confirmed_status, @@ -263,23 +263,22 @@ def _role_grid(self) -> widgets.GridBox: self.role_dropdowns.append(dropdown) self.role_notes.append(note) length = f"{low}" if low == high else f"{low} to {high}" - box = widgets.VBox( - [ - widgets.HTML( - f"{html.escape(axis_name(axis))} in the file" - f"
Length {length} · suggested: " - f"{html.escape(role or 'no role')}
" - ), - dropdown, - note, - ] - ) - box.add_class("mb-card") - axis_cards.append(box) + axis_cards += [ + widgets.HTML( + f"{html.escape(axis_name(axis))} in the file" + f"
Length {length} · suggested: " + f"{html.escape(role or 'no role')}
" + ), + dropdown, + note, + ] + # One row per dimension: what the file has, its role, what the role does. return widgets.GridBox( axis_cards, layout=widgets.Layout( - grid_template_columns="repeat(auto-fill, minmax(240px, 1fr))", grid_gap="10px" + grid_template_columns="minmax(150px, 1fr) minmax(170px, 1fr) minmax(0, 3fr)", + grid_gap="2px 12px", + align_items="center", ), ) @@ -368,7 +367,7 @@ def _mapping_changed(self) -> None: if review.issues: others = [i for i in review.issues if i.code not in AXIS_CHECK_CODES] self.axis_status.value = axis_check_html(review) + ( - issues_html(others) if others else "" + issues_brief_html(others) if others else "" ) self.axis_next.value = "" else: diff --git a/src/morphobricks_preprocessing/notebook_compact.py b/src/morphobricks_preprocessing/notebook_compact.py new file mode 100644 index 0000000..4f2322a --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_compact.py @@ -0,0 +1,168 @@ +"""The compact pages of the shipped notebook: what a step shows at once, and what on demand. + +Owner's test of v0.5.0 (10 November 2026, items (a) and (b)): each step fits a laptop screen, +with fewer cards, short labels and the details collapsed. This module only arranges widgets the +other modules own; no answer, check or refusal is decided here. + +- **Images** has one *Folders* card (three labelled rows); the pictures per folder and the + check's report are collapsed once the headers are read. +- **Run** shows what is still missing only while something is, with the settings and *What + will happen* collapsed; after a run, the intensity checks, **Compare groups** (the former + optional statistics page: pooled statistics and the distribution plot of this run's table) + and the report are collapsed panels. Its technical log is text in the page, not a box that + scrolls by itself (owner's test of the redesign: the page scrolls, nothing inside it). +""" + +from __future__ import annotations + +import html + +import ipywidgets as widgets + +from . import notebook_style as ui +from .notebook_stepper import about, field_rows + +FOLDER_INTRO = ( + "What happens here: first the folders are listed (labels from the folder names: " + "condition / biological repeat / technical repeat) and one image is shown, so you can see " + "that this is the right experiment. Then every image header is read and checked; problems " + "are listed with what to do. Nothing in the folder is changed." +) +FOLDERS_HELP = ( + "
The folders: paste a path or use Browse… (on Google Colab: Files panel, " + "right-click, Copy path). Masks are optional: a folder outside the images, with one " + "subfolder per mask type laid out like the images (masks/cells/control/rep1/" + "image1.tif), or the images' folders with the mask type after the image name " + "(image1_cells.tif); no mask pixel is read by the check. The output folder is where " + "the results go; leave it empty for the default next to the images." +) +COMPARE_HELP = ( + "Optional; nothing is written. Statistics are pooled from the histograms of all " + "planes of a group and channel: a group's median is the median of all its pixels." +) +CHIPS = { + "Images found": "Images", + "Compared against": "Compared to", + "Tag channels": "Tags", + "Check and save": "Save", + "Describe the masks": "Describe masks", +} +"""The shorter names on the chips, so that the strip is one row on a laptop screen.""" + + +class _CompactPages: + """The cards of the *Images* and *Run* steps, arranged for one screen.""" + + def _images_page(self) -> list[widgets.Widget]: + """Return step 1's widgets: the folders, the scan, the check and what it found.""" + rows = field_rows( + [ + ("Images", [self.folder, self.folder_browse]), + ("Masks (optional)", [self.masks_folder, self.masks_browse]), + ("Output (optional)", [self.output, self.output_browse]), + ] + ) + self.folders_card = ui.card( + "Folders", [rows, self.output_hint, self.masks_recorded, self.masks_drop] + ) + self.found_fold = ui.accordion("Folders found, with a picture of each", [self.labels_card]) + self.report_fold = ui.accordion("Check report", [self.check_report.widget]) + return [ + about(FOLDER_INTRO + FOLDERS_HELP), + self.folders_card, + self.scan_button, + self.scan_status, + self.scan_box, + self.check_button, + self.check_blocker, + self.folder_status, # the check's progress, right under the button clicked + self.folder_lock.card, + self.folder_tiles, + self.masks_card, + self.issues_card, + self.found_fold, + self.report_fold, + self.exclude_box, + ] + + def _run_page(self) -> list[widgets.Widget]: + """Return the run's widgets (the mask step has its own step after it).""" + self.settings_fold = ui.accordion("Settings and mode", [self.settings_card]) + self.plan_fold = ui.accordion("What will happen", [self.plan_card]) + self.checks_fold = ui.accordion("Intensity checks and plots", [self.run_checks.card]) + self.compare_fold = ui.accordion("Compare groups", [self._compare_box()]) + self.run_report_fold = ui.accordion("Run report", [self.run_report.widget]) + self.log_text = widgets.HTML() + self.details.observe(lambda _: self._write_log_text(), names="value") + self.details_box.children = (self.log_text,) + self._write_log_text() + return [ + self.readiness_card, + self.settings_fold, + self.plan_fold, + self.run_button, + self.run_blocker, + self.progress, + self.run_status, + self.run_lock.card, + self.run_tiles, + self.run_issues, + self.checks_fold, + self.compare_fold, + self.run_report_fold, + self.details_box, + ] + + def _write_log_text(self) -> None: + """Write the technical log as wrapped text, so the page scrolls and not the log.""" + text = self.details.value + self.log_text.value = ( + "
{html.escape(text)}
" + if text + else "" + ) + + def _compare_box(self) -> widgets.Widget: + """Return *Compare groups*: the grouping, the pooled statistics and the plot. + + The table is the one this run wrote: there is no table to choose, and the intensity + checks are not repeated here (they are in their own panel above). + """ + self._compare_only = True + percentiles = widgets.HBox( + [widgets.HTML("Percentiles "), self.percentiles], + layout=widgets.Layout(align_items="center"), + ) + return widgets.VBox( + [ + ui.help_text(COMPARE_HELP), + self.level, + percentiles, + self.statistics_button, + self.stats_status, + self.stats_tiles, + self.statistics_card, + self.distribution_card, + ] + ) + + def _finish_run(self, result: object) -> None: + super()._finish_run(result) + self._refresh_folds() # the checks, Compare groups and the report now have content + + def _refresh_folds(self) -> None: + """Show each collapsed panel only while it has something in it.""" + if getattr(self, "compare_fold", None) is None: + return + written = self.result is not None and self.result.written + ui.set_visible(self.found_fold, ui.is_visible(self.labels_card)) + ui.set_visible(self.report_fold, bool(self.check_report.where.value)) + ui.set_visible(self.readiness_card, self.run_button.disabled) # only what is missing + ui.set_visible(self.plan_fold, ui.is_visible(self.plan_card)) + ui.set_visible(self.checks_fold, written and ui.is_visible(self.run_checks.card)) + ui.set_visible(self.compare_fold, written) + ui.set_visible(self.run_report_fold, bool(self.run_report.where.value)) + + +__all__ = ["CHIPS", "COMPARE_HELP", "FOLDERS_HELP", "FOLDER_INTRO", "_CompactPages"] diff --git a/src/morphobricks_preprocessing/notebook_folder_previews.py b/src/morphobricks_preprocessing/notebook_folder_previews.py index 4e17d25..48337ef 100644 --- a/src/morphobricks_preprocessing/notebook_folder_previews.py +++ b/src/morphobricks_preprocessing/notebook_folder_previews.py @@ -76,6 +76,7 @@ class LabelPreviews: def __init__(self) -> None: self.rows = widgets.VBox(layout=widgets.Layout(max_height="960px", overflow="auto")) + self.rows.add_class("mb-label-rows") # the stepper lets the page scroll instead self.detail = widgets.VBox() self.more = ui.button(" Draw the other pictures", icon="image") self.more.on_click(lambda _: self.draw_all()) diff --git a/src/morphobricks_preprocessing/notebook_plots.py b/src/morphobricks_preprocessing/notebook_plots.py index 962b1b7..b5e9af9 100644 --- a/src/morphobricks_preprocessing/notebook_plots.py +++ b/src/morphobricks_preprocessing/notebook_plots.py @@ -468,7 +468,7 @@ def plane_png(plane: Plane, size: float = 4.2) -> bytes: return buffer.getvalue() -TILE_INCHES = 1.9 +TILE_INCHES = 1.6 DRAWN_SIDE = 384 """The most pixels along a side handed to the drawing (a larger plane is strided).""" DPI = 100 diff --git a/src/morphobricks_preprocessing/notebook_quick_scan.py b/src/morphobricks_preprocessing/notebook_quick_scan.py index 82fd236..10872b1 100644 --- a/src/morphobricks_preprocessing/notebook_quick_scan.py +++ b/src/morphobricks_preprocessing/notebook_quick_scan.py @@ -8,9 +8,15 @@ The random image is for looking only. Its path and the seed that chose it are shown; it is drawn from its own header by the rules of the folder pictures (:mod:`.header_preview`: only when the -header names Y and X, bound to the file as read). Nothing of the quick scan is kept: no label, +header names Y and X, bound to the file as read). When that header names a channel axis, every +channel is drawn side by side across the page, each with its own contrast and its ``c=`` index, +up to :data:`.header_preview.CHANNELS_SHOWN`; with more, the line under them says "6 of N +channels shown" (owner, 10 November 2026). Nothing of the quick scan is kept: no label, axis, header or picture of it feeds the check, the draft or the run. Changing a folder makes the scan stale, and the check is not offered until the folders are scanned again. + +The page is one column (owner, same day): the picture row, then the conditions found in a short +table, with every folder one click away. """ from __future__ import annotations @@ -25,7 +31,15 @@ from . import notebook_style as ui from .discovery import DiscoveryResult, discover_experiment from .fingerprint import current_fingerprint -from .header_preview import HEADER_NOTE, HeaderPreview, header_preview +from .header_preview import ( + HEADER_NOTE, + READ_FAILURES, + HeaderChannels, + HeaderPreview, + PreviewUnavailable, + header_channels, + header_preview, +) from .metadata import inspect_image from .notebook_progress import Wait from .notebook_render import blocked_line, issues_html @@ -35,7 +49,7 @@ SCAN_LABEL, DEEP_LABEL = " Scan the folders", " Looks right: read all image headers" SEED_RANGE = 1_000_000 -EXAMPLE_SIDE = 320 +EXAMPLE_SIDE = 190 """The largest side of the quick scan's picture, in pixels.""" @@ -66,16 +80,35 @@ def scan_summary(discovery: DiscoveryResult, excluded: frozenset[str]) -> str: return " · ".join(parts) +def conditions_html(discovery: DiscoveryResult, excluded: frozenset[str]) -> str: + """Return the folders found in short: one row per condition, every folder on demand (HTML).""" + rows = [("Condition", "Biological repeats", "Folders", "Images")] + by_condition: dict[str, list] = {} + for leaf in discovery.leaves: + by_condition.setdefault(leaf.condition, []).append(leaf) + for condition, leaves in by_condition.items(): + repeats = list(dict.fromkeys(leaf.biological_repeat for leaf in leaves)) + count = sum(image not in excluded for leaf in leaves for image in leaf.images) + rows.append((condition, ", ".join(repeats), f"{len(leaves):,}", f"{count:,}")) + every = ( + f"
All {plural(len(discovery.leaves), 'folder')}" + f"{tree_html(discovery, excluded)}
" + ) + return ui.table(rows, numeric_from=2) + every + + def random_image(images: list[str], seed: int) -> str | None: """Return the image the seed chooses among ``images`` (sorted first), None without one.""" return random.Random(seed).choice(sorted(images)) if images else None -def example_preview(root: Path, image: str) -> HeaderPreview: - """Return the picture of one image from its own header, or why there is none. +def example_preview(root: Path, image: str) -> HeaderChannels | HeaderPreview: + """Return the pictures of one image from its own header, or why there is none. - The header is read here, for this picture only, and the file is fingerprinted before and - after, so the picture is of the file as that header describes it. + The header is read here, for these pictures only, and the file is fingerprinted before and + after, so they are of the file as that header describes it. A header that names a channel + axis gives one picture per channel (:func:`.header_preview.header_channels`); any other + gives the one picture of the folder check (:func:`.header_preview.header_preview`). """ path = root / image try: @@ -84,8 +117,43 @@ def example_preview(root: Path, image: str) -> HeaderPreview: after = current_fingerprint(path) except (OSError, ValueError) as exc: return HeaderPreview(image, problem=f"cannot be read for a preview: {exc}") - bound = {image: before if before is not None and before == after else None} - return header_preview(root, meta, EXAMPLE_SIDE, bound=bound) + same = before is not None and before == after + if same: + try: + channels = header_channels(path, meta, EXAMPLE_SIDE) + except PreviewUnavailable: + channels = None # no channel axis named, or over the caps: the one picture says why + except READ_FAILURES as exc: + return HeaderPreview(image, problem=f"cannot be read for a preview: {exc}") + if channels is not None and current_fingerprint(path) == before: + return channels + return header_preview(root, meta, EXAMPLE_SIDE, bound={image: before if same else None}) + + +def channel_tiles(found: HeaderChannels) -> list[widgets.Widget]: + """Return the row of channel pictures (``c=`` labels) and the line under it.""" + tiles = [] + for tile in found.tiles: + label = f"c={tile.c}" + (f" · {html.escape(tile.note)}" if tile.note else "") + image = widgets.Image( + value=tile.png, format="png", layout=widgets.Layout(border="1px solid #CBD5E1") + ) + image.add_class("mb-grey") + box = widgets.VBox( + [widgets.HTML(f"
{label}
"), image], + layout=widgets.Layout(flex="1 1 0", min_width="0", max_width=f"{EXAMPLE_SIDE}px"), + ) + box.add_class("mb-channel") + tiles.append(box) + shown = ( + f"all {found.total} channels" + if len(found.tiles) == found.total + else f"{len(found.tiles)} of {found.total} channels shown" + ) + row = widgets.HBox(tiles, layout=widgets.Layout(width="99%")) + row.add_class("mb-channel-row") + note = f"{shown}, each with its own contrast; {html.escape(found.text)}; {HEADER_NOTE}" + return [row, widgets.HTML(f"
{note}
")] class _QuickScan: @@ -110,14 +178,8 @@ def _build_quick_scan(self) -> None: self.scan_another = ui.button( " Show another image", icon="random", tooltip="Choose another image at random" ) - self.scan_box = widgets.HBox( - [ - widgets.VBox([self.scan_tree], layout=widgets.Layout(flex="1 1 0", min_width="0")), - widgets.VBox( - [self.scan_example, self.scan_another], - layout=widgets.Layout(flex="0 0 auto", margin="0 0 0 16px"), - ), - ], + self.scan_box = widgets.VBox( # one column: the pictures, then the folders + [self.scan_example, self.scan_another, self.scan_tree], layout=widgets.Layout(width="99%"), ) self.scan_box.add_class("mb-scan") @@ -198,12 +260,12 @@ def listed(path: str) -> None: wait.phase("Opening one image") self._show_scan_example(None, status=False) excluded = frozenset(normalise_relative_path(path) for path in self._excluded()) - self.scan_tree.value = tree_html(found, excluded) + self.scan_tree.value = conditions_html(found, excluded) errors = [issue for issue in found.issues if issue.is_error] self.scan_status.value = ui.status( "error" if errors else "ok" if found.images else "warn", - f"Found {scan_summary(found, excluded)} (listed in {wait.took()}).", - ["No image header was read. If this is your experiment, read the headers below."], + f"Found {scan_summary(found, excluded)} (listed in {wait.took()}). No image header " + "was read yet: if this is your experiment, read the headers below.", ) + (issues_html(errors) if errors else "") ui.set_visible(self.scan_box, bool(found.leaves)) self._refresh_scan_gate() @@ -230,6 +292,13 @@ def _show_scan_example(self, seed: int | None, *, status: bool = True) -> None: finally: if status: self._idle(self.scan_another, " Show another image", "random") + chosen = widgets.HTML( + f"
One image chosen at random (seed {self.scan_seed}), for " + f"looking only: {html.escape(self.scan_image)}
" + ) + if isinstance(preview, HeaderChannels): + self.scan_example.children = [chosen, *channel_tiles(preview)] + return tile = ThumbTile( preview.pictures, caption=f"{preview.text}; {HEADER_NOTE}" if preview.pictures is not None else "", @@ -237,17 +306,15 @@ def _show_scan_example(self, seed: int | None, *, status: bool = True) -> None: colour=preview.colour, width=f"{EXAMPLE_SIDE}px", ) - chosen = ( - "
One image chosen at random (seed " - f"{self.scan_seed}), for looking only:
{html.escape(self.scan_image)}
" - ) - self.scan_example.children = [widgets.HTML(chosen), tile.widget] + self.scan_example.children = [chosen, tile.widget] __all__ = [ "DEEP_LABEL", "SCAN_LABEL", "_QuickScan", + "channel_tiles", + "conditions_html", "example_preview", "random_image", "scan_summary", diff --git a/src/morphobricks_preprocessing/notebook_render.py b/src/morphobricks_preprocessing/notebook_render.py index 99beb1e..9d5657d 100644 --- a/src/morphobricks_preprocessing/notebook_render.py +++ b/src/morphobricks_preprocessing/notebook_render.py @@ -41,6 +41,36 @@ def issues_html(issues: Iterable[ValidationIssue]) -> str: return "".join(parts) +def issues_brief_html(issues: Iterable[ValidationIssue]) -> str: + """Render issues as short cards: what is wrong in one sentence, the rest on demand. + + Each card says the issue's first sentence; the whole message, the files and what to do + are under *Files and what to do*. Nothing is left out: a refusal is still shown, shorter. + """ + parts = [] + for issue in sorted(issues, key=lambda issue: not issue.is_error): + first, stop, rest = issue.message.partition(". ") + more = [html.escape(rest)] if rest else [] + if issue.paths: + more.append(ui.path_list(issue.paths, limit=5)) + if issue.action: + more.append(f"What to do: {html.escape(issue.action)}") + label = "Error" if issue.is_error else "Warning" + details = ( + "
Files and what to do" + + "".join(f"
{line}
" for line in more) + + "
" + if more + else "" + ) + parts.append( + f"
" + f"{label} · {html.escape(issue.code.replace('_', ' '))}: " + f"{html.escape(first + stop.strip())}{details}
" + ) + return "".join(parts) + + def blocked_line(reasons: Sequence[str]) -> str: """Return the visible line beside a disabled action saying what blocks it (HTML), or "". diff --git a/src/morphobricks_preprocessing/notebook_results.py b/src/morphobricks_preprocessing/notebook_results.py index 9af4c27..e8233fc 100644 --- a/src/morphobricks_preprocessing/notebook_results.py +++ b/src/morphobricks_preprocessing/notebook_results.py @@ -28,6 +28,7 @@ class _ResultsStep: def _build_results_step(self) -> None: """Build step 4: the table, the grouping, the statistics, plots and checks.""" + self._compare_only = False # the shipped notebook's Compare groups repeats no checks self._build_csv_card() self._build_grouping_card() self._build_statistics_cards() @@ -243,8 +244,9 @@ def _calculate_statistics(self, path: Path, percentiles: Sequence[float]) -> Non png = distribution_png(groups) self.distribution.value = png or b"" ui.set_visible(self.distribution_card, bool(png)) - wait.phase("Checking the intensities of every image", STAGES, 3) - self.table_checks.show(records) + if not self._compare_only: + wait.phase("Checking the intensities of every image", STAGES, 3) + self.table_checks.show(records) self.stats_status.value = ui.status( "ok", f"Statistics of {plural(len(groups), LEVEL_NAMES[level])} calculated.", diff --git a/src/morphobricks_preprocessing/notebook_stepper.py b/src/morphobricks_preprocessing/notebook_stepper.py index ddf96c4..8451bc7 100644 --- a/src/morphobricks_preprocessing/notebook_stepper.py +++ b/src/morphobricks_preprocessing/notebook_stepper.py @@ -11,6 +11,10 @@ What a step says about itself (done or not, a one-line summary) comes from a callable the owner gives, which reads the cards' own state; the stepper decides nothing. The same lines make the overview (:meth:`Stepper.overview`), each with a *change* button that opens its step. + +The page is one column and scrolls as a whole (owner's test of 10 November 2026): no box inside +it scrolls on its own, and nothing is laid out in side-by-side cards. A step is short because it +says less, folds its details and draws smaller pictures. """ from __future__ import annotations @@ -32,15 +36,57 @@ ) STEPPER_CSS = """ @@ -90,6 +136,8 @@ class Step: Returns the step's :class:`StepState` from the cards' current state. waiting Returns why the step cannot be answered yet (HTML), or an empty string. + chip + The name on the chip when it is shorter than the title (the strip is one row). """ title: str @@ -97,6 +145,7 @@ class Step: children: Sequence[widgets.Widget] state: Callable[[], StepState] waiting: Callable[[], str] = _no_wait + chip: str = "" class Stepper: @@ -111,10 +160,7 @@ def __init__( count = len(self.steps) self.chips = [self._chip(i) for i in range(count)] self.waits = [widgets.HTML() for _ in self.steps] - self.pages = [ - ui.page(f"Step {i + 1} of {count} · {step.title}", step.purpose, [wait, *step.children]) - for i, (step, wait) in enumerate(zip(self.steps, self.waits, strict=True)) - ] + self.pages = [self._page(i, count) for i in range(count)] self.back = ui.button("Back", icon="arrow-left") self.next = ui.button("Next", style="primary", icon="arrow-right") self.back.on_click(lambda _: self.go(self.current - 1)) @@ -128,8 +174,28 @@ def __init__( [widgets.HTML(STEPPER_CSS), self.strip, *self.pages, nav], layout=widgets.Layout(width="99%"), ) + self.widget.add_class("mb-compact") self.go(0) + def _page(self, index: int, count: int) -> widgets.VBox: + """Return a step's page: its title and purpose on one line, then its cards.""" + step = self.steps[index] + head = widgets.HTML( + f"
Step {index + 1} of {count} · {html.escape(step.title)}" + f" {step.purpose}
" + ) + root = widgets.VBox([head, self._body(index)]) + root.add_class("mb-root") + outer = widgets.VBox([widgets.HTML(ui.STYLE_CSS), root], layout=widgets.Layout(width="99%")) + outer.add_class("mb-page") + return outer + + def _body(self, index: int) -> widgets.VBox: + """Return a step's cards, one under the other (the page scrolls, never a box in it).""" + body = widgets.VBox([self.waits[index], *self.steps[index].children]) + body.add_class("mb-step-body") + return body + def _chip(self, index: int) -> widgets.Button: chip = widgets.Button(layout=widgets.Layout(width="auto")) chip.add_class("mb-chip") @@ -156,7 +222,8 @@ def refresh(self) -> None: chip.remove_class(name) chip.add_class(f"mb-chip-{chip_kind(state, i == self.current)}") noted = f" · {state.note}" if state.done and state.note else "" - chip.description = f"{'✓ ' if state.done else ''}{i + 1} {step.title}{noted}" + name = step.chip or step.title + chip.description = f"{'✓ ' if state.done else ''}{i + 1} {name}{noted}" chip.tooltip = state.text self.waits[self.current].value = self.steps[self.current].waiting() last = len(self.steps) - 1 @@ -207,12 +274,36 @@ def overview_line(title: str, state: StepState) -> str: ) +def about(text: str) -> widgets.HTML: + """Return a step's explanation folded behind *About this step* (``text`` is HTML).""" + return widgets.HTML( + "
About this step" + f"
{text}
" + ) + + +def field_rows(rows: Sequence[tuple[str, Sequence[widgets.Widget]]]) -> widgets.GridBox: + """Return labelled input rows: a short label, then the row's widgets side by side.""" + cells: list[widgets.Widget] = [] + for label, parts in rows: + name = widgets.HTML(f"{html.escape(label)}") + cells += [name, widgets.HBox(list(parts), layout=widgets.Layout(width="99%"))] + box = widgets.GridBox( + cells, + layout=widgets.Layout(grid_template_columns="max-content 1fr", grid_gap="4px 10px"), + ) + box.add_class("mb-fields") + return box + + __all__ = [ "CHIP_CLASSES", "STEPPER_CSS", "Step", "StepState", "Stepper", + "about", "chip_kind", + "field_rows", "overview_line", ] diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py index c37ac84..cb3f047 100644 --- a/src/morphobricks_preprocessing/notebook_steps.py +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -22,25 +22,24 @@ from . import notebook_style as ui from .dataset import CONSISTENCY_CODES from .notebook_axes_quick import _QuickAxes +from .notebook_axis_step import ROLES_HELP from .notebook_channels_type import ImageTypeRows from .notebook_check_save import INTRO as CHECK_INTRO +from .notebook_compact import CHIPS, _CompactPages from .notebook_masks_screen import INTRO as MASKS_INTRO from .notebook_quick_scan import _QuickScan from .notebook_step_states import _StepStates -from .notebook_stepper import Step, Stepper +from .notebook_stepper import Step, Stepper, about from .run import Preflight -FOLDER_INTRO = ( - "What happens here: first the folders are listed (labels from the folder names: " - "condition / biological repeat / technical repeat) and one image is shown, so you can see " - "that this is the right experiment. Then every image header is read and checked; problems " - "are listed with what to do. Nothing in the folder is changed." -) AXES_INTRO = ( "Why this matters: every image is cut into flat 2D planes, one per depth slice, " "channel and time point. The roles below are suggested from the image metadata, which is " "not always right, so check them with the example before confirming. The pictures of the " - "later steps are read only through the roles you confirm." + "later steps are read only through the roles you confirm.
" + + ROLES_HELP + + " The pixel size is the size of one pixel along X and Y, from the image headers or stated " + "by you; nothing is guessed, and an image without one is uncalibrated." ) CONDITIONS_HELP = ( "Each condition gets a short name (its ID), which later bricks use and which never changes " @@ -58,7 +57,7 @@ """The steps (0-based) that show a card the saved reopening folds.""" -class _StepsFlow(_QuickScan, _QuickAxes, _StepStates): +class _StepsFlow(_CompactPages, _QuickScan, _QuickAxes, _StepStates): """``steps()``: the stepper of the shipped notebook, and the hooks that keep it current.""" def steps(self) -> widgets.Widget: @@ -83,8 +82,10 @@ def steps(self) -> widgets.Widget: self.type_rows = ImageTypeRows(self.channel_types, lambda: self._channel_thumbs) self.type_rows.reopen = self.channel_grid.reopen = self._reopen_units self.overview_box = widgets.VBox() + self.overview_fold = ui.accordion("Your answers, step by step", [self.overview_box]) self._build_quick_axes() self.axis_sizes_slot.children = [self.calibration_card] # with the axes (owner) + ui.set_visible(self.roles_help, False) # said under About this step self._build_quick_scan() self.consistency_slot = widgets.VBox([self.consistency_card]) self._stepper = Stepper(self._step_list(), self._step_shown) @@ -101,7 +102,7 @@ def _step_list(self) -> list[Step]: ("Images found", "Choose the folder with your images and check it.", self._images_page()), ("Axes", "Say which image dimension is width, height, channel and time.", - [ui.intro(AXES_INTRO), self.axis_box, self.quick_card]), + [about(AXES_INTRO), self.axis_box, self.quick_card]), ("Conditions", "A short name for each condition.", [self._conditions_card()]), ("Replicates", "Which folders are one replicate, and their names.", [self.design_card, self._replicates_card(), self.consistency_slot]), @@ -112,10 +113,10 @@ def _step_list(self) -> list[Step]: [self.channel_intro, self.channel_labels.widget]), ("Tag channels", "Say which stain each channel is, looking at its picture.", [self.channel_grid.widget]), - ("Masks", "Say what each mask type is.", [ui.intro(MASKS_INTRO), self.masks_box]), + ("Masks", "Say what each mask type is.", [about(MASKS_INTRO), self.masks_box]), ("Check and save", "Check your experiment, confirm each replicate, then save.", - [self.overview_box, ui.intro(CHECK_INTRO), self.fold_bar, self.check_waiting, - self.check_card]), + [about(CHECK_INTRO), self.fold_bar, self.check_waiting, self.check_card, + self.overview_fold]), ("Run", "Read the images one plane at a time and write the histogram table.", self._run_page()), ("Describe the masks", "Optional: how the mask types relate.", @@ -123,50 +124,10 @@ def _step_list(self) -> list[Step]: ] # fmt: skip waits = self._step_waits() return [ - Step(title, purpose, children, state, waits.get(i, _nothing)) + Step(title, purpose, children, state, waits.get(i, _nothing), CHIPS.get(title, "")) for i, ((title, purpose, children), state) in enumerate(zip(pages, states, strict=True)) ] - def _images_page(self) -> list[widgets.Widget]: - """Return step 1's cards: the folders, the check and what it found.""" - return [ - ui.intro(FOLDER_INTRO), - self.folder_card, - self.output_card, - self.masks_input_card, - self.exclude_box, - self.scan_button, - self.scan_status, - self.scan_box, - self.check_button, - self.check_blocker, - self.folder_status, - self.folder_lock.card, - self.check_report.widget, - self.folder_tiles, - self.labels_card, - self.masks_card, - self.issues_card, - ] - - def _run_page(self) -> list[widgets.Widget]: - """Return the run's cards (the mask step has its own step after it).""" - return [ - self.readiness_card, - self.settings_card, - self.plan_card, - self.run_button, - self.run_blocker, - self.progress, - self.run_status, - self.run_lock.card, - self.run_report.widget, - self.run_tiles, - self.run_issues, - self.run_checks.card, - self.details_box, - ] - def _conditions_card(self) -> widgets.Widget: return ui.card("Conditions", [ui.help_text(CONDITIONS_HELP), self.condition_rows]) @@ -241,6 +202,7 @@ def _refresh_steps(self) -> None: i.code in CONSISTENCY_CODES for i in self._step1_issues() ) ui.set_visible(self.consistency_slot, warns) + self._refresh_folds() stepper.refresh() def _refresh_run_step(self) -> None: @@ -251,7 +213,11 @@ def _refresh_run_step(self) -> None: def check_folder(self) -> Preflight | None: """Check the folder; a reopened experiment opens on its first step still to do.""" pre = super().check_folder() - if pre is not None: + if pre is not None and getattr(self, "_stepper", None) is not None: + # The headers are read: the quick look made way for what the check found. + ui.set_visible(self.scan_box, False) + self.scan_status.value = "" + self._refresh_folds() self._go_first_open() return pre diff --git a/src/morphobricks_preprocessing/notebook_ui.py b/src/morphobricks_preprocessing/notebook_ui.py index 01f4017..76721bd 100644 --- a/src/morphobricks_preprocessing/notebook_ui.py +++ b/src/morphobricks_preprocessing/notebook_ui.py @@ -220,24 +220,12 @@ def welcome(self) -> widgets.Widget: """Confirm that the notebook started, with the package version and what to do next.""" from . import __version__ - return ui.page( - "MorphoBricks preprocessing", - "Per-plane intensity histograms for an experiment folder of TIFF and OME-Zarr images.", - [ - widgets.HTML( - ui.status( - "ok", - f"The notebook is ready (morphobricks-preprocessing {__version__}).", - ) - ), - widgets.HTML( - ui.next_steps( - "Go to Step 1 and check your experiment folder.", - "Run its cell, choose your image folder, then click Check folder.", - ) - ), - ], + line = ( + "
" + f"MorphoBricks preprocessing {__version__} · " + "the notebook is ready
" ) + return widgets.VBox([widgets.HTML(ui.STYLE_CSS + line)], layout=widgets.Layout(width="99%")) def folder_step( self, @@ -407,7 +395,13 @@ def run_step(self) -> widgets.Widget: ) def results_step(self) -> widgets.Widget: - """Step 4: pooled statistics and intensity distributions from the table.""" + """Step 4: pooled statistics and intensity distributions from the table. + + In the shipped notebook (after :meth:`steps`) this is an empty, hidden box: the + statistics are the **Compare groups** panel of the run step (owner, 10 November 2026). + """ + if getattr(self, "_stepper", None) is not None: + return widgets.VBox(layout=widgets.Layout(display="none")) return ui.page( "Calculate the statistics (optional)", # the stepper numbers the steps "Compare conditions and repeats using the histograms computed by the run.", diff --git a/tests/test_notebook_compact.py b/tests/test_notebook_compact.py new file mode 100644 index 0000000..f19968a --- /dev/null +++ b/tests/test_notebook_compact.py @@ -0,0 +1,280 @@ +"""The compact layout of the shipped notebook, and Compare groups in the run step. + +Owner's test of v0.5.0 (10 November 2026): (a) each step fits a laptop screen: one row of +chips, the step's cards in one column (the page scrolls, never a box inside it: owner's test of +the redesign, same day), explanations folded; (b) the optional +statistics page is gone: after a run, the run step has a collapsed *Compare groups* panel for +that run's table, without a table chooser and without the intensity checks a second time. +The pixel heights themselves are looked at in screenshots, not here. +""" + +import re + +import pytest + +from test_notebook_check_save import answer_all, exp # noqa: F401 + +pytest.importorskip("ipywidgets") +pytest.importorskip("matplotlib") + +import ipywidgets as widgets # noqa: E402 + +from morphobricks_preprocessing import notebook_style as ui # noqa: E402 +from morphobricks_preprocessing.notebook_compact import CHIPS # noqa: E402 +from morphobricks_preprocessing.notebook_render import issues_brief_html # noqa: E402 +from morphobricks_preprocessing.notebook_stepper import STEPPER_CSS, about, field_rows # noqa: E402 +from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 +from morphobricks_preprocessing.validation import error, warning # noqa: E402 +from test_notebook_axes_quick import stepper # noqa: E402 + + +def _text(html): + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html)).strip() + + +def _holds(widget, target): + if widget is target: + return True + return any(_holds(child, target) for child in getattr(widget, "children", ())) + + +def _ran(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + answer_all(nb) + assert nb.save_experiment() + assert nb.run().written + return nb + + +# --- (a) one screen ------------------------------------------------------------------------------ + + +def test_the_strip_is_short_names_and_the_page_keeps_the_full_title(exp): # noqa: F811 + nb = stepper(*exp) + st = nb._stepper + assert "mb-compact" in st.widget._dom_classes + names = [re.sub(r"^(✓ )?\d+ ", "", chip.description).split(" · ")[0] for chip in st.chips] + assert names == [CHIPS.get(step.title, step.title) for step in st.steps] + assert names[0] == "Images" and names[9] == "Save" and names[11] == "Describe masks" + assert sum(len(chip.description) for chip in st.chips) < 150 # one row at 1366 px + head = st.pages[9].children[1].children[0].value + assert "Step 10 of 12 · Check and save" in head and "mb-purpose" in head + + +def test_each_page_is_a_title_line_and_one_column_that_never_scrolls_by_itself(exp): # noqa: F811 + nb = stepper(*exp) + for page in nb._stepper.pages: + head, body = page.children[1].children + assert isinstance(head, widgets.HTML) and "mb-step-body" in body._dom_classes + # Owner's test of the redesign (10 November 2026): the page scrolls, never a box inside it. + compact = [line for line in STEPPER_CSS.splitlines() if ".mb-compact" in line] + assert not any("overflow-y: auto" in line or "calc(" in line for line in compact) + assert ".mb-compact .mb-table-wrap, .mb-compact .mb-paths { max-height: none;" in STEPPER_CSS + assert ".mb-compact .mb-label-rows { max-height: none !important;" in STEPPER_CSS + assert "mb-label-rows" in nb.label_previews.rows._dom_classes + + +def _panel(widget): + """Return True when ``widget`` is, or holds anywhere inside it, a card or a fold.""" + if "mb-card" in getattr(widget, "_dom_classes", ()) or isinstance(widget, widgets.Accordion): + return True + return any(_panel(child) for child in getattr(widget, "children", ())) + + +def _side_by_side(widget, found=None): + """Return the boxes that lay cards or panels out in a row (pictures and inputs may be). + + A row is an ``HBox`` or a ``GridBox`` (the earlier axes step put its cards in a grid), and + a card wrapped in another box is still a card in that row. + """ + found = [] if found is None else found + if isinstance(widget, (widgets.HBox, widgets.GridBox)): + if len(widget.children) > 1 and any(_panel(child) for child in widget.children): + found.append(widget) + for child in getattr(widget, "children", ()): + _side_by_side(child, found) + return found + + +def test_a_row_of_cards_is_found_in_a_grid_and_inside_another_box(): + # Review of #275: the walk looked only at HBox rows holding a card directly, so the cards + # side by side in a GridBox (main's axes step) or wrapped in a VBox were not found. + card = ui.card("A", [widgets.HTML("a")]) + other = ui.card("B", [widgets.HTML("b")]) + assert _side_by_side(widgets.GridBox([card, other])) != [] + assert _side_by_side(widgets.HBox([widgets.VBox([card]), widgets.VBox([other])])) != [] + assert _side_by_side(widgets.HBox([widgets.HTML("a"), widgets.Dropdown()])) == [] + assert _side_by_side(widgets.VBox([card, other])) == [] + assert _side_by_side(widgets.HBox([card])) == [] + + +def test_every_page_is_a_single_column_of_cards(exp): # noqa: F811 + nb = _ran(exp) + for page in nb._stepper.pages: + assert _side_by_side(page) == [] + body = page.children[1].children[1] + assert isinstance(body, widgets.VBox) and not isinstance(body, widgets.HBox) + # The axes step: roles, then the example, then the pixel size, one under the other. + order = list(nb.axis_box.children) + assert order.index(nb.preview_card) < order.index(nb.axis_sizes_slot) + assert isinstance(nb.scan_box, widgets.VBox) + assert list(nb.scan_box.children) == [nb.scan_example, nb.scan_another, nb.scan_tree] + + +def _boxes(widget): + yield widget + for child in getattr(widget, "children", ()): + yield from _boxes(child) + + +def test_the_run_log_is_text_in_the_page_not_a_box_that_scrolls(exp): # noqa: F811 + # Review of #275: the run step's technical log was a 14em read-only Textarea, a box with + # its own scroll bar. It is now wrapped text in its fold; the log is the same. + nb = _ran(exp) + for page in nb._stepper.pages: + assert not [w for w in _boxes(page) if isinstance(w, widgets.Textarea) and w.disabled] + assert nb.details.value and "run" in nb.details.value.lower() + assert _holds(nb.details_box, nb.log_text) + assert nb.details.value.splitlines()[-1].replace("&", "&")[:20] in nb.log_text.value + + +def test_explanations_are_folded_behind_about_this_step(exp): # noqa: F811 + nb = stepper(*exp) + for index in (0, 1, 8, 9): + first = nb._stepper.pages[index].children[1].children[1].children[1] + assert first.value.startswith( + "
About this step" + ) + html = about("Why").value + assert "Why" in html and " open" not in html + assert not ui.is_visible(nb.roles_help) # said once, under About this step + assert ( + "Exactly one Y and one X" in nb._stepper.pages[1].children[1].children[1].children[1].value + ) + + +def test_the_folders_are_one_card_of_three_labelled_rows(exp): # noqa: F811 + nb = stepper(*exp) + page = nb._stepper.pages[0] + assert _holds(page, nb.folders_card) + for box in ( + nb.folder, + nb.masks_folder, + nb.output, + nb.output_hint, + nb.scan_button, + nb.exclude_box, + ): + assert _holds(page, box) + for card in (nb.folder_card, nb.output_card, nb.masks_input_card): + assert not _holds(page, card) # their help paragraphs are under About this step + labels = [w.value for w in field_rows([("Images", []), ("Masks (optional)", [])]).children[::2]] + assert [_text(v) for v in labels] == ["Images", "Masks (optional)"] + + +def test_after_the_check_the_scan_makes_way_and_the_details_are_folded(exp): # noqa: F811 + nb = PreprocessingNotebook(str(exp[0]), output_folder=str(exp[2])) + nb.steps() + nb.masks_folder.value = str(exp[1]) + assert not ui.is_visible(nb.found_fold) and not ui.is_visible(nb.report_fold) + nb.scan_button.click() + assert ui.is_visible(nb.scan_box) + nb.check_button.click() + assert not ui.is_visible(nb.scan_box) and nb.scan_status.value == "" + assert ui.is_visible(nb.found_fold) and nb.found_fold.selected_index is None + assert ui.is_visible(nb.report_fold) and nb.report_fold.selected_index is None + assert _holds(nb.found_fold, nb.labels_card) and _holds(nb.report_fold, nb.check_report.widget) + nb.folder.value = str(exp[0]) + "/" + assert not ui.is_visible(nb.found_fold) # another folder: nothing found is shown + + +def test_the_welcome_and_the_statistics_page_take_no_room(exp): # noqa: F811 + nb = PreprocessingNotebook(str(exp[0]), output_folder=str(exp[2])) + alone = nb.results_step() # without the stepper: the page other notebooks may show + assert "Calculate the statistics (optional)" in alone.children[1].children[0].value + nb.steps() + hidden = nb.results_step() + assert hidden.children == () and hidden.layout.display == "none" + assert len(nb.welcome().children) == 1 + + +def test_a_refusal_is_one_sentence_with_the_files_and_the_fix_on_demand(): + paths = [f"ctrl/rep2/{i}.tif" for i in range(4)] + issue = error( + "different_channel_counts", + "The images have different numbers of channels: 3 channels (10 images), 4 channels (4 " + "images). Each row of the histogram table holds every channel.", + paths, + action="Split the images.", + ) + html = issues_brief_html([warning("w", "Odd.", ["a.tif"]), issue]) + assert html.index("different channel counts") < html.index("Warning · w") # errors first + shown = html.split("
")[0] + assert _text(shown) == ( + "Error · different channel counts: The images have different numbers of channels: 3 " + "channels (10 images), 4 channels (4 images)." + ) + assert "Files and what to do" in html and "Each row of the histogram table" in html + for path in paths: + assert path in html + assert "What to do: Split the images." in html and "mb-error" in html + assert issues_brief_html([]) == "" + + +# --- (b) Compare groups in the run step ---------------------------------------------------------- + + +def test_the_run_step_shows_only_what_is_missing_and_folds_the_rest(exp): # noqa: F811 + nb = stepper(*exp) + page = nb._stepper.pages[10] + assert ui.is_visible(nb.readiness_card) and nb.run_button.disabled + for fold, inner in ( + (nb.settings_fold, nb.settings_card), + (nb.plan_fold, nb.plan_card), + (nb.checks_fold, nb.run_checks.card), + (nb.run_report_fold, nb.run_report.widget), + ): + assert _holds(page, fold) and _holds(fold, inner) and fold.selected_index is None + assert not ui.is_visible(nb.compare_fold) and not ui.is_visible(nb.checks_fold) + nb.quick_confirm.click() + answer_all(nb) + assert nb.save_experiment() + assert not nb.run_button.disabled and not ui.is_visible(nb.readiness_card) # nothing missing + + +def test_compare_groups_appears_collapsed_after_a_run_for_that_run_s_table(exp): # noqa: F811 + nb = _ran(exp) + page = nb._stepper.pages[10] + assert ui.is_visible(nb.compare_fold) and nb.compare_fold.selected_index is None + assert ui.is_visible(nb.checks_fold) and ui.is_visible(nb.run_report_fold) + assert not ui.is_visible( + nb.plan_fold + ) # it plans a next run; shown again when a setting changes + assert _holds(page, nb.compare_fold) and _holds(nb.compare_fold, nb.statistics_button) + assert not _holds(page, nb.csv_card) and not _holds(page, nb.csv_file) # no table to choose + assert not _holds(page, nb.table_checks.card) and not _holds(page, nb.stats_next) + assert nb.statistics.value == "" # nothing is calculated before the click + nb.statistics_button.click() + assert ( + "calculated." in nb.stats_status.value and str(nb.result.csv_path) in nb.stats_status.value + ) + assert ui.is_visible(nb.statistics_card) and ui.is_visible(nb.distribution_card) + assert nb.distribution.value[:4] == b"\x89PNG" + assert nb.table_checks.report is None # the intensity checks are not run a second time + before = nb.statistics.value + nb.level.value = "biological_repeat" # another grouping recalculates at once + assert nb.statistics.value != before + + +def test_compare_groups_gives_the_same_statistics_as_the_old_page(exp): # noqa: F811 + nb = _ran(exp) + nb.statistics_button.click() + root, masks, out = exp + old = PreprocessingNotebook(str(root), output_folder=str(out)) + old.results_step() + old.csv_file.value = str(nb.result.csv_path) + old.statistics_button.click() + assert old.statistics.value == nb.statistics.value + assert old.distribution.value == nb.distribution.value + assert old.table_checks.report is not None # the old page still checks any table diff --git a/tests/test_notebook_convert.py b/tests/test_notebook_convert.py index 4e2b0ad..7ae958a 100644 --- a/tests/test_notebook_convert.py +++ b/tests/test_notebook_convert.py @@ -105,7 +105,8 @@ def test_the_card_is_offered_under_the_axis_check_for_swapped_axes(tmp_path): assert "swap the Y and X" in text(nb.axis_status) assert ui.is_visible(nb.convert_card) children = list(nb.axis_box.children) - assert children.index(nb.convert_card) == 1 # right under the roles, their check and example + # One column: right under the roles, their check and the example. + assert children.index(nb.convert_card) == children.index(nb.preview_card) + 1 nb.role_dropdowns[1].value, nb.role_dropdowns[2].value = "Y", "X" assert not ui.is_visible(nb.convert_card) diff --git a/tests/test_notebook_quick_scan.py b/tests/test_notebook_quick_scan.py index d35885f..4d89012 100644 --- a/tests/test_notebook_quick_scan.py +++ b/tests/test_notebook_quick_scan.py @@ -19,6 +19,7 @@ from morphobricks_preprocessing import notebook_style as ui # noqa: E402 from morphobricks_preprocessing.notebook_quick_scan import ( # noqa: E402 DEEP_LABEL, + conditions_html, random_image, scan_summary, tree_html, @@ -72,9 +73,11 @@ def no_check(*args, **kwargs): assert nb.check_blocker.value == "" said = _text(nb.scan_status.value) assert ( - f"Found {scan_summary(found, frozenset())}" in said and "No image header was read" in said + f"Found {scan_summary(found, frozenset())}" in said + and "No image header was read yet" in said ) - assert nb.scan_tree.value == tree_html(found, frozenset()) + assert nb.scan_tree.value == conditions_html(found, frozenset()) + assert tree_html(found, frozenset()) in nb.scan_tree.value # every folder, one click away def test_the_tree_lists_every_folder_with_its_labels_and_image_count(exp): # noqa: F811 @@ -101,7 +104,7 @@ def test_the_random_image_is_named_with_its_seed_and_is_reproducible(exp): # no assert ( f"seed {nb.scan_seed}" in shown and nb.scan_image in shown and "for looking only" in shown ) - assert "from the file header" in _text(nb.scan_example.children[1].children[1].value) + assert "from the file header" in _text(nb.scan_example.children[-1].value) nb._show_scan_example(7) assert nb.scan_seed == 7 and nb.scan_image == random_image(images, 7) assert random_image(images, 7) == random_image(list(reversed(images)), 7) # order-free @@ -190,3 +193,99 @@ def test_a_new_folder_appears_only_after_scanning_again(exp): # noqa: F811 assert nb.scan_tree.value == before nb.scan_button.click() assert "rep9" in nb.scan_tree.value and "rep9" not in before + + +# --- every channel of the one image, side by side (owner, 10 November 2026, item (m)) ----------- + + +def _channel_image(folder, channels, *, axes="CYX", shape=(40, 48)): + import numpy as np + import tifffile + + folder.mkdir(parents=True) + rng = np.random.default_rng(channels) + data = rng.integers(0, 4000, (channels, *shape), dtype=np.uint16) + data += (np.arange(channels, dtype=np.uint16) * 5000)[:, None, None] + tifffile.imwrite( + folder / "a.tif", data, ome=True, photometric="minisblack", metadata={"axes": axes} + ) + return data + + +def _scanned(tmp_path, channels, **how): + data = _channel_image(tmp_path / "exp" / "ctrl" / "rep1", channels, **how) + nb = PreprocessingNotebook(str(tmp_path / "exp"), output_folder=str(tmp_path / "out")) + nb.steps() + nb.scan_button.click() + return nb, data + + +def test_the_scan_shows_every_channel_side_by_side_with_its_index(exp): # noqa: F811 + nb = fresh(*exp) + nb.scan_button.click() + chosen, row, note = nb.scan_example.children + assert "for looking only" in _text(chosen.value) + assert "mb-channel-row" in row._dom_classes and len(row.children) == 3 + assert [_text(tile.children[0].value) for tile in row.children] == ["c=0", "c=1", "c=2"] + for tile in row.children: + assert tile.children[1].value[:4] == b"\x89PNG" + said = _text(note.value) + assert said.startswith("all 3 channels, each with its own contrast") + assert "from the file header, axes not confirmed yet" in said + + +def test_seven_or_more_channels_show_six_and_say_so(tmp_path): + nb, _ = _scanned(tmp_path, 9) + _, row, note = nb.scan_example.children + assert [_text(tile.children[0].value) for tile in row.children] == [f"c={c}" for c in range(6)] + assert "6 of 9 channels shown" in _text(note.value) + six, _ = _scanned(tmp_path / "six", 6) + assert len(six.scan_example.children[1].children) == 6 + assert "all 6 channels" in _text(six.scan_example.children[2].value) + + +def test_each_channel_has_its_own_contrast(tmp_path): + from test_notebook_check_save import decode + + nb, data = _scanned(tmp_path, 3) + for tile in nb.scan_example.children[1].children: + shown = decode(bytes(tile.children[1].value)) + # A channel 5000 counts brighter than the one before still spans black to white. + assert shown.min() == 0 and shown.max() == 255 + assert data[2].min() > data[0].max() + + +def test_a_header_without_a_named_channel_axis_gets_the_one_picture_as_before(tmp_path): + import numpy as np + import tifffile + + folder = tmp_path / "exp" / "ctrl" / "rep1" + folder.mkdir(parents=True) + plain = np.zeros((4, 40, 48), np.uint16) # a plain stack of pages: QYX, no axis named + tifffile.imwrite(folder / "a.tif", plain, photometric="minisblack") + nb = PreprocessingNotebook(str(tmp_path / "exp"), output_folder=str(tmp_path / "out")) + nb.steps() + nb.scan_button.click() + assert len(nb.scan_example.children) == 2 # no channel is guessed from the shape + assert "preview after Axes" in _text(nb.scan_example.children[1].value) + + +def test_the_channel_pictures_read_only_that_one_image(tmp_path, monkeypatch): + from morphobricks_preprocessing import header_preview + + _channel_image(tmp_path / "exp" / "ctrl" / "rep1", 3) + _channel_image(tmp_path / "exp" / "ctrl" / "rep2", 3) + read = [] + real = header_preview.read_plane + + def spy(path, mapping, **coords): + read.append((str(path), coords.get("c"))) + return real(path, mapping, **coords) + + monkeypatch.setattr(header_preview, "read_plane", spy) + nb = PreprocessingNotebook(str(tmp_path / "exp"), output_folder=str(tmp_path / "out")) + nb.steps() + nb.scan_button.click() + assert {path for path, _ in read} == {str(tmp_path / "exp" / nb.scan_image)} + assert [c for _, c in read] == [0, 1, 2] + assert nb.preflight is None and nb.mapping is None # display only: nothing is kept diff --git a/tests/test_notebook_ui.py b/tests/test_notebook_ui.py index 2105974..7326115 100644 --- a/tests/test_notebook_ui.py +++ b/tests/test_notebook_ui.py @@ -67,13 +67,13 @@ def test_pages_build_without_a_folder(): assert "Enter the experiment folder first" in nb.folder_status.value -def test_welcome_shows_the_version(): - page = PreprocessingNotebook().welcome() - root = page.children[1] - assert "The notebook is ready" in root.children[-2].value - assert "What next?" in root.children[-1].value - assert "Go to Step 1" in root.children[-1].value - assert "
  • " not in root.children[-1].value +def test_welcome_is_one_line_with_the_version(): + from morphobricks_preprocessing import __version__ + + (line,) = PreprocessingNotebook().welcome().children + assert f"MorphoBricks preprocessing {__version__}" in line.value + assert "the notebook is ready" in line.value + assert "What next?" not in line.value # the steps say where to go def test_check_shows_status_tiles_labels_and_roles(experiment):