From bd2f7d89785bc8f32ffa9312d4a5419992e6d85c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 20:34:15 +0000 Subject: [PATCH] Notebook: seven steps, what the files hold first (image type with the axes; Channels; Masks; Design) Owner's test of the first redesign (10 November 2026), items (o), (p), (q). The stepper is Images, Axes, Channels, Masks, Design, Check and save, Run: - Axes asks the image type in one line for all folders, pre-filled from the image headers and recorded only by 'Use for all folders'; 'Set per folder' holds each folder's row; headers that differ pre-fill nothing and open it; replacing answers asks first. - Channels is the stains and the tags on one page. - Masks holds the paired masks, what each type is and the mask step, which waits visibly for a run that recorded masks (A_MASK_UNCHECKED unchanged). - Design is one grid (conditions x biological repeats, cells = folders, compared against as the last column) of proposals, adopted by one click 'Looks right: confirm the design' (the all-or-none ID adoption, and 'no comparison' where nothing else was chosen); Check and save waits for it and says so. - Check and save folds a long list of missing answers; every line is still there. For the same answers the published description, design, histograms, calibration, mask files and manifest (minus time and paths) are byte-identical to main; the stacked pages and the public methods other bricks embed are unchanged. Notebook Markdown and user guide section 4 describe the seven steps. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- CHANGELOG.md | 48 ++ docs/user_guide.md | 441 +++--------------- notebooks/MorphoBricks_Preprocessing.ipynb | 15 +- pyproject.toml | 2 + .../notebook_axes_quick.py | 8 +- .../notebook_channels_grid.py | 4 +- .../notebook_check_save.py | 2 +- .../notebook_check_view.py | 27 +- .../notebook_compact.py | 20 +- .../notebook_described.py | 4 +- .../notebook_description.py | 8 +- .../notebook_design_step.py | 298 ++++++++++++ .../notebook_image_type_line.py | 239 ++++++++++ .../notebook_mask_question.py | 3 +- .../notebook_mask_step.py | 19 +- .../notebook_masks.py | 4 +- .../notebook_step_states.py | 149 +++--- .../notebook_stepper.py | 10 +- .../notebook_steps.py | 147 +++--- tests/test_notebook_axes_quick.py | 2 +- tests/test_notebook_check_save.py | 7 +- tests/test_notebook_compact.py | 13 +- tests/test_notebook_mask_step.py | 6 +- tests/test_notebook_masks.py | 6 +- tests/test_notebook_owner_fixes.py | 8 +- tests/test_notebook_seven_steps.py | 407 ++++++++++++++++ tests/test_notebook_stains_na.py | 20 +- tests/test_notebook_stepper.py | 96 ++-- tests/test_notebook_tag_blocks.py | 4 +- 29 files changed, 1359 insertions(+), 658 deletions(-) create mode 100644 src/morphobricks_preprocessing/notebook_design_step.py create mode 100644 src/morphobricks_preprocessing/notebook_image_type_line.py create mode 100644 tests/test_notebook_seven_steps.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3825a7ef..8bd142a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,54 @@ 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 5: seven steps, what the files hold first + +Owner's test of the first redesign (10 November 2026), items (o), (p) and (q). The shipped +notebook has 7 steps instead of 12, in the owner's order: **Images → Axes → Channels → Masks → +Design → Check and save → Run**. For the same answers the published `morphobricks.toml`, +`experiment_design.csv`, `plane_histograms.csv`, `calibration.csv`, the mask files and the +manifest (but for its time and paths) are byte-identical to before; the Spec behaviour is +unchanged. + +- **Axes holds the image type** (what the channel dimension holds: separate stains or markers, + or colour), in the pattern of the pixel size (`notebook_image_type_line.ImageTypeLine`): one + line for all folders, pre-filled from what the image headers say and **recorded only by the + click Use for all folders**; **Set per folder…** holds each folder's row as before; when the + headers differ nothing is pre-filled, the line says how many folders say what, and the + per-folder part opens. Folders answered differently are said at once with the refusal that + follows (`R_HETEROGENEOUS_LAYOUT_NOT_ENABLED`); colour pictures beside separate-channel + images are still refused at the axes. Replacing answers already given asks first. The step is + done once the roles are confirmed and every folder's image type is answered. +- **Channels** is *Stains* and *Tag channels* on one page: name the stains, then tag. +- **Masks** holds the masks the check paired (with **Check the masks**, moved from step 1), + what each mask type is, and **Describe the masks**. The mask step reads the masks a run + recorded, so it stays greyed out, with its reason, until a run exists; after such a run the + run step says to come back for it. `A_MASK_UNCHECKED` is unchanged. +- **Design** is *Conditions*, *Replicates* and *Compared against* in one grid + (`notebook_design_step`): conditions down, biological repeats across, each cell the folder + of that repeat, and what each condition is compared against in the last column. The names + are the folder names, folders of the same name are one repeat, and "no comparison" is shown + for every condition: all proposals. **Looks right: confirm the design** adopts every ID as + shown (all or none, each refusal listed, as *Yes, use these IDs*) and records "no + comparison" where nothing else was chosen. A name that cannot be proposed blocks the click + with its reason and opens **Edit names or pairing…**, which holds the earlier rows. + *Check and save* waits for the click and says so. The grid is updated in place. +- **Check and save** lists at most eight missing answers at once; the others are one click + away (`notebook_check_view.TODO_SHOWN`). Every one is still listed. +- Smaller: the tiles of counts are one line of text in the stepper; mask pictures are + smaller; once the axes are confirmed the tick box and the "roles are valid" line make way + for the confirmation. Messages name the new steps (*Channels*, *Design*, *Axes*). +- The notebook's Markdown cell and the user guide §4 describe the seven steps (the stale + card-by-card text of §4 is removed). `RELEASE_REF` and the code cells are unchanged. +- **For bricks that embed the pages:** `welcome`, `folder_step`, `axis_step`, `channels_step`, + `masks_step`, `check_save_step`, `run_step`, `results_step` and the public methods are + unchanged (`tests/test_embedding_api.py` is untouched). Texts that changed on the stacked + pages too: the masks' "Before you run" items end in "(Masks)" instead of "(step 1)" / + "(step 1, Masks)"; the mask step's hint reads "Available after a run that recorded + masks: …"; its help paragraph starts with what it is needed for; a mask question without + labels says "declare them on Channels"; the missing-answer lines end in "(Channels)", + "(Design)" and "(Axes)". + ### 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 diff --git a/docs/user_guide.md b/docs/user_guide.md index d5e52e2f..9e656357 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -265,388 +265,69 @@ 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. +Run the notebook's three cells from top to bottom (install, mount Google Drive, run +preprocessing). Cell 3 opens the app: a strip of seven steps, one on screen at a time, with +**Back** and **Next** under it. **Next** is never blocked, and a chip of the strip opens its +step; a green chip with a check mark is done. + +The page is one column and scrolls as a whole. Each step shows what you must answer; +explanations are 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 and the time. Every greyed-out +button has a line beside it saying what it waits for. Nothing is decided for you: what the app +proposes is recorded only when you click. + +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, for looking only: every channel 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 + pictures of each folder, the check's report and **Exclude images** are collapsed below. +2. **Axes.** What the files hold, in three parts. + - **Axis roles:** one row per image dimension with its role, and under them an example of + one image as these roles read it, redrawn whenever you change a role (a wrong role looks + wrong: a strip, stripes). Click **Confirm these axes** (offered when the headers name + every axis), or **Yes, the example looks right** and then **Confirm axis roles**. + - **Pixel size:** one line, 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 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. + - **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 + headers differ. An experiment that mixes colour and separate-channel folders is refused + in this version. +3. **Channels.** Name what you stained and confirm the list; then tag each folder's channels + with those names, looking at their pictures. +4. **Masks.** The masks the check paired with the images (and, for masks whose files do not + name their axes, the axes to state), then for each mask type: *separate objects* or + *regions*. **Describe the masks** reads every mask and reports how the types relate. It + reads the masks a run recorded, so it is greyed out before the run and says so; come back + to it after **Run**. The histogram table does not need it, but bricks that use masks accept + only masks it has read. Without masks, the step says so. +5. **Design.** One grid: conditions down, biological repeats across, each cell the folder of + that repeat, and what each condition is compared against in the last column. The names are + the folder names, folders with the same name are the same repeat, and nothing is compared. + If that is right, click **Looks right: confirm the design**; otherwise change a comparison + in the grid, or a name or the pairing under **Edit names or pairing…**, first. +6. **Check and save.** Your experiment in sentences and one question per replicate (*Were + these prepared and imaged the same way?*), then **Yes, save the experiment description**. + The line beside the button says what is still missing. +7. **Run.** **Compute histograms**; what is still to do is listed only while something is. + After the run: its result, and collapsed panels for the **intensity checks and plots**, + **Compare groups** (pooled statistics and a distribution plot per condition, repeat or + image, for the table this run wrote; it writes nothing) and the report. 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 -and what to do next. Green means done, amber means "please check", and red means -something must be fixed first. Where you need to confirm something, a yellow box asks the -question and you answer with a large green **Yes, …** button. The box turns green, and an -**Undo** button takes the answer back. Later steps stay locked until the earlier ones are -confirmed. If you change the experiment folder, everything after it is reset. - -After a successful action, a separate **What next?** box gives the next action in larger -text. In step 1 it first asks you to check the labels, then points to step 2 once they are -confirmed. Step 3 offers the optional statistics step, and step 4 suggests reviewing the -results or comparing another grouping. - -0. **Start the notebook.** The setup cell checks the environment and installs missing - requirements if needed. A green box then confirms it is ready and shows the package - version. -1. **Check the experiment folder.** - - Paste the folder path, or click **Browse…** to choose it in a dialog. Below the output - folder, a line shows where the results will go. An output folder that is a file, or - inside one (for example `somefile/out`), cannot be created: the line says so in red, - nothing is written there, and step 3 waits until you choose another folder. - - If you have masks (§2), give their folder under **Masks folder (optional)** the same way - (on Colab, paste its path: **Browse…** explains how). Leave it empty for no masks. - **Check folder** pairs them with the images exactly as `check --masks` does. - - Click **Check folder**. Only folder names and image headers are read. Like the command - line's `check`, it writes its report, **discovery_report.txt** and - **discovery_report.json** (§6), to the output folder; **Show the report** under the - status box shows it. When the output folder holds no **experiment_design.csv**, it also - writes one prefilled from the folders, with nothing confirmed and no control, for the - **Experiment design** questions below. (When another brick embeds this step without - the output folder, the check writes nothing.) - - If another run holds the output folder's lock, **The output folder is locked** names it - (computer, process ID, start time). Wait for it to finish. If it was stopped (on Colab, - the runtime disconnected) and no other session uses the folder, press **Remove the lock - of a stopped run**: the same rule as `--remove-stale-lock`, so the lock of a process - still running on this computer is kept. On Google Drive the lock cannot guard a folder - across computers or sessions: use one session per output folder (§1). - - Tiles summarise the result: images to analyse, image folders, conditions, pixel type, - skipped files, and errors and warnings. - - The **Labels from the folder names** table shows every image folder with its condition, - biological repeat and technical repeat (*single* where a level is missing). If they - are right, press **Yes, the labels are correct** in the yellow box under the table. - In the shipped notebook (*Images found*), each folder also gets a picture of its first - image, so a wrong folder shows at once. It is drawn from the file header only, before the - axes are confirmed, and is for looking only: only when the header names Y and X (otherwise - "preview after Axes (step 2)"), every other axis max-projected and named in the caption, - colour when the header says RGB. At most 8 planes and 4096 x 4096 pixels are read for one - picture; a larger image says so instead. The first 12 folders are drawn by the check; - **Draw the pictures of the other N folders** draws the rest. A large image also shows a - square of 128 pixels at full resolution (where the picture varies most, with its x and y - range), here and on *Replicates* and *Check and save*; **Enlarge** shows both larger. - - Once the labels are confirmed, **Experiment design** asks about the biological - repeats. Later bricks normalise each biological repeat over all conditions together, - so they need to know which folders are the same repeat: - - **Pair the biological repeats across conditions.** Folders with the same name in - different conditions are one *repeat group*. If a repeat has another folder name in - some condition (acquired a day later, for example), choose its group in the - **Repeat group** dropdown. A group can hold only one folder of each condition. - - **Confirm that they are comparable.** For each repeat group, a yellow box asks - whether its images were prepared and acquired under the same circumstances. Press - **Yes, they are comparable** if they were. Without biological-repeat folders, one - question covers the whole experiment. Step 3 runs only once every group is - confirmed; if images are not comparable, process them as separate experiments. - - **Controls (optional).** Tick the control conditions. This is recorded for later - bricks; nothing uses it yet. - - The answers are saved in the draft (`morphobricks.draft.json`) after every answer and - published with the description on *Check and save*; `experiment_design.csv` is - derived from the published description: a repeat group is confirmed only when its - folders pass the run's checks, and a refused answer is shown with the reason and - changes nothing. Nothing - is confirmed for you. Next time, if the folders are the same, they are loaded and the - questions are folded away; **Change the experiment design** shows them again. A new - biological-repeat folder asks again only for the group it joins. - - If the saved **experiment_design.csv** cannot be read (for example a spreadsheet - program rewrote a date such as 2026-10-26 as 26/10/2026), a red box shows why, - naming the line and column, and the file is left as it is. Correct the file and - undo and confirm the labels again to read it, or answer the questions again: the - first answer replaces the file. - - The design belongs to its output folder. If you change the output folder, the design - is read from the new folder (its own answers and controls, a blue box says so), or, - when it holds none, the questions are asked again: answers given for the previous - output folder are never carried over, and nothing is written in the new folder until - you answer. Changing back reads the first folder's design again. - - **Pixel sizes (calibration)** lists the size of one pixel along X, Y, Z and T for every - image (images with the same sizes share a row), with its source: **header** (the image - metadata), **stated** (in **calibration.csv**), **pixels** (stated as deliberately - uncalibrated) or **uncalibrated** (nothing is guessed). To state a size, fill in **For** - (`.` for the whole experiment, or a folder or image relative to the experiment folder), - **Axes**, **Size** and **Unit** (or *pixels*) and press **State this size**; **Remove the - statement** goes back to the metadata. This writes **calibration.csv** in the output - folder with the `calibrate` command's functions, under the lock (§5), and the most - specific statement applies. The messages `calibration_target_not_found` (an error), - `calibration_differs_from_header` and `calibration_axis_absent` (warnings, §8) are - shown on the card. Until the axis roles are confirmed in step 2, Z and T show the - metadata's sizes; once they are, an image without a Z or T axis shows **no such axis**, - as the run records no size for it. No histogram depends on the sizes. (When another - brick embeds this step without the output folder, the card is left out.) - - **Dataset consistency** shows how many images each condition (columns) and - biological repeat (rows; the repeat groups once paired) has, with the number of technical-repeat folders. Excluded images - are not counted, nor is a technical-repeat folder whose images are all excluded; the - line under the table says how many images were excluded and names those folders. A - technical-repeat folder named **single** is the level's value single, so it counts as - no technical-repeat folder, like images directly in the biological-repeat folder. A cell is - amber when it has fewer than half or more than twice the median number of images, or - when a condition does not have that biological repeat. Expand **Show consistency - warnings** to see the checks that compare the images with each other: pixel type, - dimensions, width and height, numbers of channels, slices and time points, channel - names and physical pixel sizes (see §8). Warnings never stop you; they say what to - check. Images with different numbers of channels, pixel types or dimension orders are - errors: fix them before going on. - - **Masks** (with a masks folder, or OME-Zarr images with label images) lists every kind - of mask found, where from (the masks folder or label images), its axes, how many masks - it has and the images without one (named below the table), and the masks' messages - (§8): the same checks as `check --masks` and, once the axis roles are confirmed in - step 2, as `run`. A mask error blocks step 3; the step-1 status and tiles count the - masks' errors and warnings too. - - **Mask axes** appears only for kinds of mask that need their axes stated, as the - command line's `--mask-axes TYPE=ROLES`: masks whose files do not name their axes (a - plain stack, `mask_not_2d`), or whose statement does not fit. A 2D mask needs no - statement and is never shared by several planes, so a 2D mask of a time-lapse is not - asked about: it is refused (`mask_planes_mismatch`) until it is saved with the - image's time points. The dropdown lists only the statements that fit every - mask of that kind (one role per axis as stored, never X before Y), starts at **Not - stated**, and nothing is applied until you press **Check the masks**. If no statement - fits (masks of one kind stored differently), it says so: save them alike. - - **Check the masks** pairs the masks again with the masks folder and the mask axes - chosen, keeping your other answers (labels, design, axis roles). Step 3 asks you to - press it after changing the masks folder or a mask axis; masks that were not checked - are never used. A new masks folder starts its mask axes over. - - The run records the masks folder and the mask axes in **run_manifest.json**; a later - run in the same output folder reuses them (§7). With the box empty, the masks folder of - the earlier run is used again, and the card says so; **Drop the recorded masks** (then - **Check the masks**) runs without masks, as `--no-masks`, and drops the mask axes - stated for that folder's masks. A masks folder other than the recorded one, or dropped, - is named under **What will happen** in step 3 and in the run's report. - - When another brick embeds this step without the output folder, neither the masks - folder nor the card is shown, and the check pairs no masks folder. - - **Messages** lists the other problems (folders, files that are not images, unreadable - images), with the warning count visible in orange and the error count in red. - Expand **Show warnings and errors** to see each problem, the affected files and what - to do. The details start collapsed after each check. If some images cannot be read, - **Exclude these images and check again** inside the dropdown leaves them out (see §7). -2. **Review the axis roles.** - - Each dimension of the images has its own box. It shows the dimension's length and the - role suggested from the metadata, with a dropdown to change it. - - A line under each dropdown says what the choice means, for example *3 depth slices, - numbered z = 0 to 2*. A blue line says how many planes each image will be split into. - - Click **Show preview** and move the sliders to look at different slices, channels and - time points of any image. Until you confirm the roles, the preview is labelled - **Preview of the roles above, not confirmed yet**: it is drawn with the roles as you - edit them, nothing is written, and the run uses only the roles you confirm. If the roles you choose give the images different numbers of - channels, a message says so and step 3 stays locked. Each picture should be a normal image of your sample; - stripes or a smear mean X or Y is on the wrong dimension. Brightness is stretched for - display only. - - The roles are checked against the axes each image's metadata names, in one **Axis - check** card. A role that disagrees with the header is marked in amber under its - dropdown; the card gives the error (`axes_yx_swapped` or `axes_contradict_header`, - §8) naming the images with their header axes, your roles and the likely cause. Such - images are never read: correct the axes, or save named-axis copies with your roles and - run preprocessing on the copies (the **Make named-axis copies (optional)** card under - the axis check, §5 "Named-axis copies"). An axis the metadata does not name (a plain stack of - pages) takes the role you confirm: the card warns (`axes_not_named`) and the run - records the roles as confirmed by you. - - If the preview looks right, press **Yes, the preview looks right** (offered only while a - preview of the roles shown is drawn), then click - **Confirm axis roles**. When the roles agree with every header, a green card says - **Axes checked: they are correct for every image**. Changing a role afterwards asks - you to check the preview again. - The confirmed roles are locked until you click **Change axis roles**. - - In the shipped notebook (one step at a time), when the suggested roles leave nothing - to ask (no axis message, every image names its axes, no missing axis to state), the - *Axes* step shows them with a preview and one **Confirm these axes** button; it records - the same two answers. A reopened experiment whose folders did not change offers the - roles of its earlier run. The role boxes stay under **Look closer, or change the roles**. - - On a reopened experiment, new folders whose header is exactly that of a saved, tagged - group form their own block on *Tag channels* with **Tag like the saved group (…)**. One - click copies those tags, exactly as choosing them by hand; nothing is copied without it. - - If some images lack a dimension the others have (often a single channel), a yellow box - lists them; press **Yes, they have a single plane there** if that is the case. - Once you confirm the axis roles, this resolves the missing-axis error for step 3. - Any other errors still need to be fixed before running. - - Once the axis roles are confirmed, **Image type** asks whether the images are - **Fluorescence** (each channel is a separate stain or marker) or **RGB** (colour - images, such as brightfield or histology stains). Later bricks normalise the two types - differently: fluorescence channel by channel, RGB with the red, green and blue channels - together (StarDist's method), so the colours are kept. The histograms are the same for - both types. - - RGB is suggested when the images are stored as RGB colour (three colour samples per - pixel) and those samples are the channels; fluorescence otherwise. The suggestion is - only preselected: press **Yes, this is the right image type** to confirm it, or choose - the other type first. An amber line reminds you when you choose against the - suggestion. Step 3 runs only once the type is confirmed. - - RGB needs exactly three channels of a whole-number pixel type (8- or 16-bit colour). - For other images the choice is greyed out at Fluorescence, with the reason. - - Changing the axis roles asks for the image type again. The type is recorded in - **run_manifest.json** (§6); changing it on a later run reads no image again. -3. **Compute the histograms.** - - **Before you run** lists anything still to do. - - **The settings start from the earlier run** in the output folder, as the command - line's `run` keeps them: the excluded images (filled in step 1 when you choose the - folders), the bins (when recorded for the same pixel type), the histogram range and - its quantiles, the intensity limits, the image type (preselected in step 2) and the - answer about missing axes; the masks folder and mask axes are kept by leaving the - masks folder empty. Leave a setting as it is to keep it. A setting you change is - listed under **What will happen** and in the run's report. Choosing another output - folder fills them again from that folder's run; a folder without a run sets back the - values filled from the other folder that you did not edit. - - **What will happen** names the output folder and files, the image type and the masks - recorded (or that the recorded masks are dropped), and says - whether an earlier run was found and what will be read. It lists the inputs that differ - from those the earlier run recorded (for example an image excluded since, or another - bin count), each with its effect (*every row is recomputed*, *no row is affected*, - …), from the run's own rules, and the run's report names them again. Once the axis roles are confirmed it also names - the images whose planes are too large for the memory limit and will be read in - blocks, and, in a red box, any image that cannot be read within the limit at all - (the run would stop; raise the limit or exclude the image). - - The recommended settings suit most experiments. Untick **Use the recommended - settings** to change them: - - *Bins per plane:* one bin per possible value for 8- and 16-bit images (exact - histograms); 65,536 bins for wider integers and 10,000 for floats. - - *Histogram range:* **Minimum to maximum of each plane** (recommended) or - **Robust**, which bins between two quantiles of each plane (0.01 % and 99.99 % by - default) and counts the pixels outside separately. It matters only for histograms - that are not exact (float images, and integer images with more values than bins). - A few extreme pixels, such as hot pixels, can squeeze every other pixel into a few - bins of the minimum-to-maximum range; the run then warns - (`narrow_histogram_range`), names the planes, and says how many bins the robust - range would give. Run again with the robust range to fix it. Changing the range - reads every image again. - - *Memory limit:* the most working memory one plane may need (default 1 GiB). A - plane that needs more (roughly above 5,000 x 5,000 pixels) is read in blocks - instead, and the run reports this as a warning: the histograms are exactly the - same, only reading takes longer. Raise the limit only if the computer has enough - memory. - - **Mode:** **Restart** (recommended) reuses the rows of unchanged images and reads new - or changed ones; **Full** reads every image again and recomputes every row and check. - Both keep your confirmations (labels, design, axis roles, image type, stated pixel - sizes) and write the table only when the whole run succeeds. - - **Verify each image's content (SHA-256) before reusing its rows** (off by default): an - image counts as unchanged when its size and modification time are, so a file rewritten - with the same size and its time set back would keep its earlier rows. With the box - ticked, the run records each image's SHA-256 and reuses rows only for the same content; - every image is read once more. The choice is recorded and kept by later runs; turning - it on or off reads every image again. - - Click **Compute histograms**. A progress bar shows which image is being read. Tiles - then summarise planes written, images read, kept, new, changed, removed and excluded, - and the status names the mode, the files replaced and the inputs that changed. The - table and manifest are written only when the whole run succeeds; the report - (**discovery_report.txt**, **Show the report**) is written either way. A run that stops - says *The run stopped: no table or manifest was written.*, and that its report was - written; a run refused before it starts (the lock) says *Nothing was written.* and - shows **The output folder is locked**. **Technical details (log)** holds the log for - troubleshooting. - - **Intensity checks** then compares the images, using only the table just written (no - image is read again, and nothing else is written). Each image is compared with the - other images of its condition and biological repeat, and each biological repeat with - the other repeats of its condition, so differences between conditions (treatment - effects) are never reported: - - Tiles count the images checked and each kind of warning; a table lists every - warning with the check, the channel (**C0**, **C1**, …), the image or biological - repeat, and what was found (the values compared and the limit), followed by what to - do about each kind. The four checks find images far from the others of their - biological repeat, biological repeats shifted from the others of their condition, - saturated images, and images without intensity spread; a fifth kind names each - image channel without finite pixels, which the checks cannot see (see §8). - - **Check settings** (collapsed) holds the limits: how many robust deviations count as - far (default 3), the share of saturated pixels (default 1 %), and the fewest images a - comparison needs (default 5). Changing them updates the warnings and plots at once; - the run records the limits in **run_manifest.json**, so the next run records the - new ones. **Restore the defaults** sets them back. - - Plots, one channel at a time (choose it above the plots): box plots of the image - medians and high percentiles for each condition and biological repeat, one point per - image, with images that have a warning in that channel as red diamonds and shifted - repeats as amber boxes; and one panel per condition with the intensity distribution - of each biological repeat (comparable repeats lie close together; a shifted one is - dashed). The plots are only shown here, never saved. - - **Describe the masks (optional)**, after a run that recorded masks: the notebook's - counterpart of the `masks` command (§5), with the same functions and the inputs the - run recorded (experiment folder, masks folder, mask axes, excluded images). The button - works for the run in step 1's output folder: after a run in this session, and also for - an earlier run when you reopen the notebook (choose the same experiment or output - folder in step 1). The line above it names the run it describes (output folder, when - it was written, experiment and masks folders, mask types); it is greyed out, saying - why, when that folder holds no run that recorded masks. Click it to read every mask, one image at a - time (a progress bar names the image); the card then shows each mask type (kind, - images, objects, median pieces per label), the relationships found (which type is - inside or grouped by which, how many objects are inside, how many objects of the - other type hold 0, 1, 2 or 3 or more, and how many are outside or split), the - objects given no parent because they lie equally in two or more objects of the other - type, masks of the same objects, and the hierarchy of direct parents. These are suggestions to - check, not decisions. The description is written to **mask_relationships.json** in - the output folder (its path is shown). The output folder's lock is held from before the - first mask is read until the file is written: when another run holds it, **The output folder is locked** is shown with **Remove the lock of a - stopped run**, no mask is read and nothing is written. Errors found since the run - (the experiment or masks folder moved, a mask changed or removed) and masks that - cannot be used (a label above 2,147,483,647) are shown as red cards, and nothing is - written. The notebook uses the default thresholds - (contract §15.1); the `masks` command can change them. A new run clears the card; - an embedding brick that hides the output folder does not show it. -4. **Calculate the statistics (optional).** The table from step 3 is all that later bricks - need; this step only reads it to compare groups and writes no files. - - **Histogram table** is filled in with the table from step 3. To look at another - experiment, paste the path of its `plane_histograms.csv` or click **Browse…**. - - Choose **Condition**, **Biological repeat**, **Technical repeat** or **Image**, and the - percentiles to report, then click **Calculate statistics**. - - A spinning wheel shows that work is in progress while the table is read, statistics - are calculated and the plot is drawn. The button says **Calculating…** and stays - disabled until the calculation finishes or an error is shown; you can then try again. - - The table lists pooled statistics per group **and channel**: minimum, maximum, mean, - standard deviation, median and percentiles. Each channel (**C0**, **C1**, …) has its - own row, since different fluorescence markers (or the red, green and blue of an RGB - image) are different measurements and are never mixed. **XYC planes** counts the - planes (an image at one time point) and **channel histograms** the histograms pooled - in that row. - - The plot compares the groups' intensity distributions, one panel per channel: curves - further right are brighter. - - **Intensity checks** shows the same warnings, settings and plots as step 3 for the - chosen table, so tables of other experiments can be checked too. It needs only the - table, not the images. - -In JupyterLab, the optional `jl-hidecode` extension keeps the code cells collapsed and adds -a run button to each; without it, click the "…" of a cell to see its code. +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. + +When you open the notebook again on an experiment that is already saved and whose folders +did not change, it opens on the first step still to do (usually **Run**). + +Other MorphoBricks notebooks embed the first cards of this app as stacked pages +(`folder_step`, `axis_step`, `run_step`): the cards and their messages are the same, laid out +one page under the other. ## 5. Command line diff --git a/notebooks/MorphoBricks_Preprocessing.ipynb b/notebooks/MorphoBricks_Preprocessing.ipynb index 0e8d1301..eedc65ba 100644 --- a/notebooks/MorphoBricks_Preprocessing.ipynb +++ b/notebooks/MorphoBricks_Preprocessing.ipynb @@ -370,14 +370,17 @@ "\n", "Opens the preprocessing app. Run cells 1 and 2 first: without them this cell stops and says which cell to run.\n", "\n", - "In the app:\n", + "The app has seven steps, one on screen at a time; move with **Next** or click a step in the strip at the top:\n", "\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. After the run, the intensity checks and **Compare groups** are collapsed panels of the run step.\n", + "1. **Images**: give the experiment folder (masks and output are optional), click **Scan the folders**, look at the example image, then **Looks right: read all image headers**.\n", + "2. **Axes**: what each image dimension is, the pixel size, and the image type (separate stains or colour).\n", + "3. **Channels**: name what you stained, then tag each folder's channels.\n", + "4. **Masks**: say what each mask type is (nothing to do without masks). After the run, come back here for **Describe the masks** if later bricks use the masks.\n", + "5. **Design**: conditions, biological repeats and comparisons in one grid; **Looks right: confirm the design**.\n", + "6. **Check and save**: read your experiment in words and save it.\n", + "7. **Run**: computes the histograms; the intensity checks and **Compare groups** are collapsed panels after the run.\n", "\n", - "Every wait shows its progress, and a greyed-out button says beside it what it waits for." + "What the app proposes is recorded only when you click. 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 081960a8..c6b5f148 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,6 +143,8 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.notebook_axes" = "ui" "morphobricks_preprocessing.notebook_progress" = "ui" "morphobricks_preprocessing.notebook_compact" = "ui" +"morphobricks_preprocessing.notebook_design_step" = "ui" +"morphobricks_preprocessing.notebook_image_type_line" = "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/notebook_axes_quick.py b/src/morphobricks_preprocessing/notebook_axes_quick.py index 76a3c46f..fcffa648 100644 --- a/src/morphobricks_preprocessing/notebook_axes_quick.py +++ b/src/morphobricks_preprocessing/notebook_axes_quick.py @@ -111,8 +111,12 @@ 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) + # The offer says the roles are the headers' and nothing is open: the line is not repeated; + # once confirmed, the confirmation says it, and Change axis roles takes it back. + confirmed = self.confirmed_mapping is not None + ui.set_visible(self.axis_status, not offered and not confirmed) + if confirmed: + ui.set_visible(self.preview_checked.widget, False) if offer is None: return if offer.roles != self.mapping.roles: diff --git a/src/morphobricks_preprocessing/notebook_channels_grid.py b/src/morphobricks_preprocessing/notebook_channels_grid.py index 3b0bb42d..afdb89e9 100644 --- a/src/morphobricks_preprocessing/notebook_channels_grid.py +++ b/src/morphobricks_preprocessing/notebook_channels_grid.py @@ -408,11 +408,11 @@ def _block_text(self, units: Sequence[UnitLayout], colour: bool, apart: bool) -> def _type_text(self, layout: UnitLayout) -> str: """Say the image-type answer of a colour block, as the image-type page gave it.""" if self.types.saved(layout.key) == "rgb": - where = "" if self.place_types else " (Image type)" + where = "" if self.place_types else " (Axes)" return f"Answered colour (RGB){where}: its channels are R, G and B, nothing to tag." if self.place_types: return "Colour is pre-ticked from the header: confirm it with the button below." - return "Colour is pre-ticked from the header: confirm it on Image type." + return "Colour is pre-ticked from the header: confirm it on Axes (Image type)." def _pending(self, layout: UnitLayout) -> bool: """Return True when the unit shows a proposal not saved yet. diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py index 98b20e5c..43d68da1 100644 --- a/src/morphobricks_preprocessing/notebook_check_save.py +++ b/src/morphobricks_preprocessing/notebook_check_save.py @@ -502,7 +502,7 @@ def save_experiment(self) -> bool: self.save_status.value = ui.status( "error", "Not saved: a short name was refused.", - ["See Conditions and Replicates (steps 4 and 5)."], + ["See Design: the names are under Edit names or pairing."], ) return False if not self._save_description(): diff --git a/src/morphobricks_preprocessing/notebook_check_view.py b/src/morphobricks_preprocessing/notebook_check_view.py index 11fdbe75..849a0672 100644 --- a/src/morphobricks_preprocessing/notebook_check_view.py +++ b/src/morphobricks_preprocessing/notebook_check_view.py @@ -26,15 +26,15 @@ "settings). They are then one biological replicate, compared with each other." ) QUESTION_WORDS = { - "id_pending": "a short name is missing for {subject} (Conditions or Replicates)", + "id_pending": "a short name is missing for {subject} (Design)", "repeat_not_confirmed": "{subject}: answer “same way?” above", "confirmed_on_missing": "{subject}: answer “same way?” again above", - "unit_channels_missing": "the channels of {subject} are not tagged (Tag channels)", - "unit_channel_untagged": "{subject} is not tagged (Tag channels)", - "channel_undeclared": "the label {subject} is used but not declared (Stains)", - "unit_image_type_unanswered": "the image type of {subject} is not answered (Image type)", + "unit_channels_missing": "the channels of {subject} are not tagged (Channels)", + "unit_channel_untagged": "{subject} is not tagged (Channels)", + "channel_undeclared": "the label {subject} is used but not declared (Channels)", + "unit_image_type_unanswered": "the image type of {subject} is not answered (Axes)", "unit_channels_stale": ( - "the tags of {subject} were saved for another image: tag its channels again (Tag channels)" + "the tags of {subject} were saved for another image: tag its channels again (Channels)" ), "unit_channel_count_unknown": ( "the channels of {subject} are not known from its images: confirm the axis roles " @@ -117,6 +117,10 @@ def names_html(names: Sequence[tuple[str, Sequence[str]]]) -> str: ) +TODO_SHOWN = 8 +"""The most missing answers listed at once on *Check and save*; the others are folded.""" + + def todo_html(questions: Sequence[OpenQuestion], extra: Sequence[str]) -> str: """Return what is still to answer before saving, or nothing.""" lines = [ @@ -128,7 +132,16 @@ def todo_html(questions: Sequence[OpenQuestion], extra: Sequence[str]) -> str: lines += list(extra) if not lines: return "" - return ui.status("warn", f"Still to answer before saving: {len(lines)}.", lines) + title = f"Still to answer before saving: {len(lines)}." + if len(lines) <= TODO_SHOWN: + return ui.status("warn", title, lines) + # Every line is still there: the first ones shown, the others one click away. + first = [html.escape(line) for line in lines[: TODO_SHOWN - 2]] + rest = "".join(f"
  • {html.escape(line)}
  • " for line in lines[TODO_SHOWN - 2 :]) + more = ( + f"
    and {len(lines) - len(first)} more
      {rest}
    " + ) + return ui.status("warn", title, [*first, more], raw=True) def _issue_lines(issues: Sequence[ValidationIssue]) -> list[str]: diff --git a/src/morphobricks_preprocessing/notebook_compact.py b/src/morphobricks_preprocessing/notebook_compact.py index 5a6b174a..37dad393 100644 --- a/src/morphobricks_preprocessing/notebook_compact.py +++ b/src/morphobricks_preprocessing/notebook_compact.py @@ -37,13 +37,11 @@ "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", -} +MASKS_HINT = ( + "This run recorded masks. Bricks that use masks accept only masks that were read once: " + "Describe the masks on Masks (step 4) reads them and reports how the types relate." +) +CHIPS = {"Check and save": "Save"} """The shorter names on the chips, so that the strip is one row on a laptop screen.""" @@ -75,7 +73,6 @@ def _images_page(self) -> list[widgets.Widget]: 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, @@ -83,12 +80,13 @@ def _images_page(self) -> list[widgets.Widget]: ] def _run_page(self) -> list[widgets.Widget]: - """Return the run's widgets (the mask step has its own step after it).""" + """Return the run's widgets (the optional mask description is on the Masks step).""" 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.masks_hint = ui.help_text(MASKS_HINT) # after a run that recorded masks return [ self.readiness_card, self.settings_fold, @@ -100,6 +98,7 @@ def _run_page(self) -> list[widgets.Widget]: self.run_lock.card, self.run_tiles, self.run_issues, + self.masks_hint, self.checks_fold, self.compare_fold, self.run_report_fold, @@ -146,6 +145,7 @@ def _refresh_folds(self) -> None: 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)) + ui.set_visible(self.masks_hint, written and not self.mask_step_button.disabled) -__all__ = ["CHIPS", "COMPARE_HELP", "FOLDERS_HELP", "FOLDER_INTRO", "_CompactPages"] +__all__ = ["CHIPS", "COMPARE_HELP", "FOLDERS_HELP", "FOLDER_INTRO", "MASKS_HINT", "_CompactPages"] diff --git a/src/morphobricks_preprocessing/notebook_described.py b/src/morphobricks_preprocessing/notebook_described.py index f9715e86..b2d412a7 100644 --- a/src/morphobricks_preprocessing/notebook_described.py +++ b/src/morphobricks_preprocessing/notebook_described.py @@ -202,8 +202,8 @@ def _described_image_type(self) -> str | None: def _refresh_axis_next(self) -> None: self.axis_next.value = ui.next_steps( - "Go to Stains.", - "Name what you stained, then Tag channels, Masks and Check and save.", + "Go to Channels.", + "Name what you stained and tag the channels, then Masks, Design and Check and save.", ) # The run ----------------------------------------------------------------------------- diff --git a/src/morphobricks_preprocessing/notebook_description.py b/src/morphobricks_preprocessing/notebook_description.py index 2a40380d..26b0ef3f 100644 --- a/src/morphobricks_preprocessing/notebook_description.py +++ b/src/morphobricks_preprocessing/notebook_description.py @@ -342,7 +342,7 @@ def _build_references(self) -> None: references = self.draft.references for condition in conditions: box = self.reference_boxes[condition] - options, chosen = self._reference_options(condition), self._chosen_reference(condition) + options, chosen = self._reference_options(condition), self._shown_reference(condition) if list(box.options) != options: box.options = options if box.value != chosen: @@ -361,7 +361,7 @@ def _new_reference_rows(self, conditions: list[str]) -> None: for condition in conditions: box = widgets.Dropdown( options=self._reference_options(condition), - value=self._chosen_reference(condition), + value=self._shown_reference(condition), layout=widgets.Layout(width="99%"), ) box.observe( @@ -386,6 +386,10 @@ def _chosen_reference(self, condition: str) -> str | None: answer = self.draft_answers.conditions.get(condition, ConditionAnswer()).reference return self.draft.references.get(condition) if answer is None else answer + def _shown_reference(self, condition: str) -> str | None: + """Return what the dropdown shows: the saved choice (the stepper shows a proposal).""" + return self._chosen_reference(condition) + def _references_answered(self) -> bool: """Return True once any condition was answered (including "no comparison").""" answers = self.draft_answers diff --git a/src/morphobricks_preprocessing/notebook_design_step.py b/src/morphobricks_preprocessing/notebook_design_step.py new file mode 100644 index 00000000..7f0234b8 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_design_step.py @@ -0,0 +1,298 @@ +"""The *Design* step of the shipped notebook: conditions, repeats and comparisons in one grid. + +Owner decision (10 November 2026, item (q)): the conditions, the replicates and "compared +against" are one step with an easy default. The step shows one grid, conditions down and +biological repeats across, each cell the folder that is that repeat of that condition, with +what each condition is compared against in the last column. Everything in it is a proposal: + +- the IDs are the folder names (only where the reader accepts a folder name as an ID), +- folders with the same name are the same biological repeat, +- no condition is compared against another. + +Nothing of it is taken until the user clicks **Looks right: confirm the design**. That one +click adopts every ID as shown (the same all-or-none adoption as *Yes, use these IDs*, +:meth:`.notebook_description._DescriptionCard._confirm_ids`, with the reader's refusals listed) +and records "no comparison" for each condition still showing that proposal. A name that cannot +be proposed, a different pairing or a shown-as name is edited under **Edit names or +pairing…**, which holds the rows of the earlier steps unchanged. *Check and save* waits for the +click, and says so. + +The grid is built once for the conditions and repeats there are and then updated in place, so +a choice never replaces the control the user is using. +""" + +from __future__ import annotations + +import html + +import ipywidgets as widgets + +from . import notebook_style as ui +from .notebook_description import _updated +from .notebook_description_view import pill +from .notebook_render import blocked_line +from .notebook_values import natural_key +from .validation import plural + +CONFIRM_LABEL = " Looks right: confirm the design" +HELP = ( + "Rows are conditions, columns are biological repeats, and a cell is the folder of that " + "repeat. The names are the folder names and nothing is compared, until you say otherwise." +) +NOT_CONFIRMED = "the design is not confirmed: click Looks right: confirm the design (Design)" + + +class _DesignStep: + """The grid of the *Design* step, its one confirmation and the fold that edits it.""" + + def _build_design_step(self) -> None: + """Build the grid, the confirmation and the fold of the names and the pairing.""" + self._design_shape: tuple | None = None + self._design_cells: dict[tuple, widgets.HTML] = {} + self._design_problem = "" + self.design_table = widgets.GridBox() + self.design_technical = widgets.HTML() + self.design_confirm = ui.button( + CONFIRM_LABEL, + style="success", + icon="check", + tooltip="Use these names, this pairing and these comparisons", + main=True, + ) + self.design_blocker = widgets.HTML() + self.design_result = widgets.HTML() + self.design_change = ui.button( + " Change the design", icon="pencil", tooltip="Take the names back to edit them" + ) + self.design_edit = ui.accordion( + "Edit names or pairing…", + [widgets.VBox([self._conditions_card(), self.design_card, self._replicates_card()])], + ) + self.design_step_card = ui.card( + "Your design", + [ + ui.help_text(HELP), + self.design_table, + self.design_technical, + self.design_result, + self.design_confirm, + self.design_blocker, + self.design_change, + ], + visible=False, + ) + self.design_confirm.on_click(lambda _: self._confirm_design()) + self.design_change.on_click(lambda _: self._change_design()) + + # State ------------------------------------------------------------------------------- + + def _design_confirmed(self) -> bool: + """Return True when every ID is adopted and every comparison answered.""" + draft = self.draft + if draft is None or self.draft_answers is None or not self._ids_all_confirmed(): + return False + if len(draft.conditions) < 2: + return True + return all(self._chosen_reference(c) is not None for c in draft.conditions) + + def _shown_reference(self, condition: str) -> str | None: + """Show "no comparison" as the proposal of a condition nobody answered for.""" + chosen = self._chosen_reference(condition) + return "" if chosen is None and self._in_steps() else chosen + + def _reference_options(self, condition: str) -> list[tuple[str, str | None]]: + """Leave out "choose" in the stepper: the proposal there is "no comparison".""" + options = super()._reference_options(condition) + return options[1:] if self._in_steps() else options + + def _in_steps(self) -> bool: + return getattr(self, "design_table", None) is not None + + # The click --------------------------------------------------------------------------- + + def _confirm_design(self) -> bool: + """Adopt the design as shown: every ID, and "no comparison" where nothing was chosen. + + Returns + ------- + bool + True when the design is now confirmed; False when an ID was refused (the step + lists each refusal and nothing was adopted) or the answers could not be saved. + """ + if self.draft is None or not self._in_steps(): + return False + self._names_refusal = self._draft_note = self._design_problem = "" + if not self._confirm_ids(): + self._design_problem = self._names_refusal + self.design_edit.selected_index = 0 + self._refresh_design_step() + return False + answers = self.draft_answers + if len(self.draft.conditions) > 1: + for condition in sorted(self.draft.conditions): + if self._chosen_reference(condition) is None: + answers = _updated(answers, "conditions", condition, reference="") + if answers != self.draft_answers and not self._answer(answers): + self._design_problem = ui.status("error", "Not taken.", [str(self._refusal)]) + self._refresh_design_step() + return False + self._refresh_description() + self._refresh_design_status() + return self._design_confirmed() + + def _change_design(self) -> None: + """Take the IDs back so the names can be edited (as *Change the IDs* does).""" + if self.draft is None: + return + self._design_problem = "" + self.ids_confirmed.set(False) + self.design_edit.selected_index = 0 + self._refresh_description() + + # Showing ----------------------------------------------------------------------------- + + def _refresh_design_step(self) -> None: + """Show the grid for the draft as it is answered now.""" + if not self._in_steps(): + return + draft = self.draft + ui.set_visible(self.design_step_card, draft is not None) + ui.set_visible(self.design_edit, draft is not None) + if draft is None: + self._design_shape = None + return + conditions = sorted(draft.conditions) + repeats = sorted(draft.repeats, key=natural_key) + compared = len(conditions) > 1 + boxes = tuple(id(self.reference_boxes.get(c)) for c in conditions) if compared else () + shape = (tuple(conditions), tuple(repeats), boxes) + if shape != self._design_shape: + self._design_shape = shape + self._build_design_table(conditions, repeats, compared) + self._fill_design_table(conditions, repeats) + confirmed = self._design_confirmed() + missing = [key for (_kind, key), text in self._wanted_ids().items() if not text] + ui.set_visible(self.design_confirm, not confirmed) + ui.set_visible(self.design_change, confirmed) + self.design_confirm.disabled = bool(missing) + self.design_blocker.value = blocked_line( + [f"{plural(len(missing), 'name')} missing ({', '.join(missing[:3])}): to type " + "under Edit names or pairing"] + if missing and not confirmed + else [] + ) # fmt: skip + if missing and not confirmed: + self.design_edit.selected_index = 0 + self.design_result.value = ( + ui.status( + "ok", + f"Design confirmed: {plural(len(conditions), 'condition')} × " + f"{plural(len(repeats), 'biological repeat')}.", + ) + if confirmed + else self._design_problem + ) + + def _build_design_table( + self, conditions: list[str], repeats: list[str], compared: bool + ) -> None: + """Build the grid's cells once: a header row, then one row per condition.""" + cells: dict[tuple, widgets.HTML] = {} + children: list[widgets.Widget] = [] + + def cell(key: tuple) -> widgets.HTML: + cells[key] = widgets.HTML() + return cells[key] + + children.append(widgets.HTML("Condition")) + children += [cell(("repeat", label)) for label in repeats] + if compared: + children.append(widgets.HTML("Compared against")) + for condition in conditions: + children.append(cell(("condition", condition))) + children += [cell((condition, label)) for label in repeats] + if compared: + children.append(self.reference_boxes[condition]) + self._design_cells = cells + columns = f"minmax(120px, 1.3fr) repeat({len(repeats)}, minmax(90px, 1fr))" + if compared: + columns += " minmax(170px, 1.4fr)" + self.design_table.layout = widgets.Layout( + grid_template_columns=columns, grid_gap="4px 12px", width="99%", align_items="center" + ) + self.design_table.children = children + + def _fill_design_table(self, conditions: list[str], repeats: list[str]) -> None: + """Write the names and the folders into the grid's cells, in place.""" + draft, wanted, cells = self.draft, self._wanted_ids(), self._design_cells + for condition in conditions: + slot = draft.conditions[condition] + cells[("condition", condition)].value = _named( + wanted.get(("conditions", condition), ""), condition, slot.confirmed is not None + ) + for label in repeats: + repeat = draft.repeats[label] + cells[("repeat", label)].value = _named( + wanted.get(("repeats", label), ""), label, repeat.id.confirmed is not None + ) + by_condition = {m.condition: m for m in repeat.members} + for condition in conditions: + cells[(condition, label)].value = _folder(by_condition.get(condition)) + technical = [ + wanted.get(("technical_repeats", folder), "") or f"{folder} (no name)" + for folder in sorted(draft.technical_repeats, key=natural_key) + if folder != "single" + ] + self.design_technical.value = ( + f"
    Technical repeats (folders inside a repeat): " + f"{html.escape(', '.join(technical))}
    " + if technical + else "" + ) + + # Hooks ------------------------------------------------------------------------------- + + def _refresh_description(self) -> None: + super()._refresh_description() + self._refresh_design_step() + + def _hide_description(self) -> None: + super()._hide_description() + self._refresh_design_step() + + def _id_typed(self, entry: object, state: widgets.HTML, text: str) -> None: + super()._id_typed(entry, state, text) + if not self._refreshing: + self._refresh_design_step() # the grid shows the names as they are typed + + def _todo(self, applied: object, answers: object, problem: str) -> tuple[list, list[str]]: + """Ask for the design's one click instead of each comparison it would record.""" + questions, extra = super()._todo(applied, answers, problem) + if not self._in_steps() or self._design_confirmed(): + return questions, extra + kept = [line for line in extra if " is compared against (choose" not in line] + return questions, [NOT_CONFIRMED, *kept] + + +def _named(shown: str, folder: str, confirmed: bool) -> str: + """Return a row or column title: the ID as shown, its folder when another, and its state.""" + if not shown: + return f"{html.escape(folder)} {pill('warn', 'needs a name')}" + other = "" if shown == folder else f" folder {html.escape(folder)}" + state = "" if confirmed else f" {pill('info', 'proposed')}" + return f"{html.escape(shown)}{other}{state}" + + +def _folder(member: object) -> str: + """Return a cell: the folder of one condition in one repeat, with its images, or a dash.""" + if member is None: + return "no folder" + inside = len(member.units) + folders = f" · {inside} folders" if inside > 1 else "" + return ( + f"{html.escape(member.source_repeat)}" + f"
    {plural(member.image_count, 'image')}{folders}
    " + ) + + +__all__ = ["CONFIRM_LABEL", "NOT_CONFIRMED", "_DesignStep"] diff --git a/src/morphobricks_preprocessing/notebook_image_type_line.py b/src/morphobricks_preprocessing/notebook_image_type_line.py new file mode 100644 index 00000000..dd34a245 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_image_type_line.py @@ -0,0 +1,239 @@ +"""The image type in one line of the axes step: what the channel dimension holds. + +Owner decision (10 November 2026, item (p)): the image type says what the C dimension means +(one stain or marker per channel, or the red, green and blue of a colour picture), so it is +asked with the axes, in the pattern of the pixel size: + +- **one line for all folders**, its choice pre-filled from what the image headers say (colour + only when every folder's header says colour, separate channels only when none does). The + pre-fill is a proposal: nothing is saved until the user clicks **Use for all folders**; +- **Set per folder…**, collapsed, holds the row of each folder + (:class:`.notebook_channels_type.ImageTypeRows`: the same cells, answers and refusals); +- when the headers **differ**, nothing is pre-filled, one line says how many folders say what, + and the per-folder part opens. + +The answers are the per-unit ``image_type`` of the draft, saved through +:func:`.channel_answers.with_type` exactly as the per-folder radio buttons save them: clicking +**Use for all folders** records what choosing that type in every row records. A choice that +contradicts a header is still allowed and said in words (the run refuses it, +``R_IMAGE_TYPE_CONTRADICTS_HEADER``); folders answered differently stay refused by the save and +the run (``R_HETEROGENEOUS_LAYOUT_NOT_ENABLED``, Spec Stages 1-2), and the line says so at once. +Replacing answers already given asks first, as the pixel size does. +""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Sequence + +import ipywidgets as widgets + +from . import notebook_style as ui +from .channel_answers import synced, with_type +from .channel_labels import UnitLayout +from .notebook_channels_type import ImageTypeColumn, ImageTypeRows +from .notebook_render import blocked_line +from .validation import plural + +OPTIONS = [("separate stains or markers, one per channel", "channels"), ("colour (RGB)", "rgb")] +WORDS = {"channels": "separate channels", "rgb": "colour (RGB)"} +WAITING = ( + "Asked once the axis roles are confirmed: the channels of each folder are read through them." +) +MIXED = ( + "This version runs one image type per experiment: Check and save refuses folders of " + "different types (R_HETEROGENEOUS_LAYOUT_NOT_ENABLED)." +) + + +def counted(values: Sequence[str]) -> str: + """Return ``colour (RGB) in 2 folders, separate channels in 10`` for these types.""" + found = Counter(values) + return ", ".join( + f"{WORDS[value]} in {plural(found[value], 'folder')}" for value in WORDS if found[value] + ) + + +class ImageTypeLine: + """One image type for all folders, confirmed by a click; each folder on demand.""" + + def __init__(self, types: ImageTypeColumn, rows: ImageTypeRows) -> None: + self.types, self.rows = types, rows + self._pending: str | None = None + self._shown_for: object = None + self.line = widgets.HTML() + self.choice = widgets.Dropdown( + options=OPTIONS, value=None, layout=widgets.Layout(width="330px") + ) + self.apply = ui.button( + " Use for all folders", + style="success", + icon="check", + tooltip="Record this image type for every folder of the experiment", + ) + self.form = widgets.HBox( + [widgets.HTML("The channels are "), self.choice, self.apply], + layout=widgets.Layout(align_items="center", flex_flow="row wrap"), + ) + self.blocker = widgets.HTML() + self.replace_text = widgets.HTML() + self.replace_yes = ui.button(" Replace them", style="warning", icon="check") + self.replace_no = ui.button(" Cancel", icon="times") + self.replace_box = widgets.VBox( + [self.replace_text, widgets.HBox([self.replace_yes, self.replace_no])] + ) + ui.set_visible(self.replace_box, False) + self.message = widgets.HTML() + self.per_folder = ui.accordion("Set per folder…", [rows.widget]) + self.widget = ui.card( + "Image type", + [self.line, self.form, self.blocker, self.replace_box, self.message, self.per_folder], + ) + self.apply.on_click(lambda _: self._use_for_all()) + self.replace_yes.on_click(lambda _: self._replace()) + self.replace_no.on_click(lambda _: self._cancel()) + self.choice.observe(lambda _: self._choice_changed(), names="value") + self.refresh() + + # What there is ----------------------------------------------------------------------- + + def _usable(self) -> list[UnitLayout]: + return [u for u in self.types.layouts if u.usable] + + @property + def opened(self) -> bool: + """True while the folders' channels are known (the axis roles are confirmed).""" + return ui.is_visible(self.rows.widget) + + def suggestion(self) -> str | None: + """Return the type every header suggests, None when they differ or there is none.""" + said = {"rgb" if u.rgb else "channels" for u in self._usable()} + return said.pop() if len(said) == 1 else None + + def saved(self) -> list[str | None]: + """Return the type the user answered for each usable folder (None where not yet).""" + return [self.types.saved(u.key) for u in self._usable()] + + @property + def complete(self) -> bool: + """True when every usable folder has an answer (the folders may still differ).""" + saved = self.saved() + return bool(saved) and None not in saved + + # Showing ----------------------------------------------------------------------------- + + def refresh(self) -> None: + """Show the line for the folders as they are answered now.""" + self._cancel() + usable = self._usable() + shown = self.opened and bool(usable) + for widget in (self.form, self.per_folder): + ui.set_visible(widget, shown) + if not shown: + self.line.value = ui.status("info", WAITING) if not self.opened else "" + self.blocker.value = self.message.value = "" + self._shown_for = None + return + saved, suggestion = self.saved(), self.suggestion() + answered = [value for value in saved if value is not None] + one = answered[0] if len(set(answered)) == 1 and len(answered) == len(saved) else None + key = (tuple(u.key for u in usable), tuple(saved), suggestion) + if key != self._shown_for: # the user's own choice in the box is kept otherwise + self._shown_for = key + self.choice.value = one or (suggestion if not answered else None) + self.line.value = self._line(saved, answered, one, suggestion) + if suggestion is None or len(set(answered)) > 1 or self.types.message: + self.per_folder.selected_index = 0 # they differ: each folder is shown + self._choice_changed() + + def _line( + self, saved: list, answered: list[str], one: str | None, suggestion: str | None + ) -> str: + """Return the line: the answer, the proposal from the headers, or how they differ.""" + folders = plural(len(saved), "folder") + said = [("rgb" if u.rgb else "channels") for u in self._usable()] + if one is not None: + lines = [] if one == suggestion else [f"The image headers say: {counted(said)}."] + return ui.status("ok", f"Image type: {WORDS[one]} in all {folders}.", lines) + if len(set(answered)) > 1: + left = len(saved) - len(answered) + more = f", {left} not answered" if left else "" + return ui.status("warn", f"Image types differ: {counted(answered)}{more}.", [MIXED]) + if answered: + return ui.status( + "warn", + f"Not finished: {counted(answered)}, {len(saved) - len(answered)} not answered.", + ) + if suggestion is None: + return ui.status( + "warn", + f"The image headers differ: {counted(said)}.", + ["Nothing is proposed: choose the type of each folder below. " + MIXED], + ) + return ui.status( + "info", + f"Proposed from the image headers: {WORDS[suggestion]} in all {folders}.", + ["Not saved until you click Use for all folders."], + ) + + def _choice_changed(self) -> None: + """Offer the click for a type that is not already every folder's answer.""" + self._cancel() + value, saved = self.choice.value, self.saved() + everywhere = value is not None and all(s == value for s in saved) + ui.set_visible(self.apply, not everywhere) + self.apply.disabled = value is None + self.blocker.value = blocked_line( + ["choose a type in the box, or set each folder below"] if value is None else [] + ) + + # The click --------------------------------------------------------------------------- + + def _use_for_all(self) -> None: + """Record the chosen type for every folder, asking first when it replaces answers.""" + value = self.choice.value + if value is None: + return + others = [u for u in self._usable() if self.types.saved(u.key) not in (None, value)] + if not others: + self._write(value) + return + self._pending = value + self.replace_text.value = ui.status( + "warn", + f"Use {WORDS[value]} for all folders? {plural(len(others), 'folder')} answered " + "otherwise; nothing is written until you click Replace them.", + ["Their channel tags are cleared: a channel is tagged again after its type changes."], + ) + ui.set_visible(self.replace_box, True) + + def _replace(self) -> None: + pending = self._pending + self._cancel() + if pending is not None: + self._write(pending) + + def _cancel(self) -> None: + self._pending = None + ui.set_visible(self.replace_box, False) + + def _write(self, value: str) -> bool: + """Save ``value`` as the type of every usable folder, all or none; say a refusal.""" + store = self.types.store + answers = store.answers() + try: + for layout in self._usable(): + answers = with_type(answers, layout, value) + except ValueError as exc: + self.message.value = ui.status("error", "Nothing was changed.", [str(exc)]) + return False + if not store.update(synced(answers, self.types.layouts)): + self.message.value = ui.status("error", "Not saved.", [store.refusal()]) + return False + self.message.value = "" + self.types.message = "" + self.types._on_change() + return True + + +__all__ = ["MIXED", "OPTIONS", "WAITING", "ImageTypeLine", "counted"] diff --git a/src/morphobricks_preprocessing/notebook_mask_question.py b/src/morphobricks_preprocessing/notebook_mask_question.py index f69d775c..b21c95e9 100644 --- a/src/morphobricks_preprocessing/notebook_mask_question.py +++ b/src/morphobricks_preprocessing/notebook_mask_question.py @@ -85,6 +85,7 @@ def __init__( self.message = widgets.HTML() self.facts = widgets.HTML() self.pictures = widgets.HBox() + self.pictures.add_class("mb-mask-pictures") # drawn smaller in the stepper self.id_box.observe(lambda _: self._id_typed(), names="value") self.choice.observe(lambda change: self._chosen(change["new"]), names="value") self.widget = widgets.VBox( @@ -144,7 +145,7 @@ def _build_made_from(self, labels: list[str], chosen: tuple[str, ...]) -> None: ) box.observe(lambda _: self._made_from_changed(), names="value") self.made_from[label] = box - empty = "no channel labels yet: declare them on Stains" + empty = "no channel labels yet: declare them on Channels" hint = "answer the question first" if labels and self.saved is None else "" tail = [ widgets.HTML(f"{hint or (empty if not labels else '')}") diff --git a/src/morphobricks_preprocessing/notebook_mask_step.py b/src/morphobricks_preprocessing/notebook_mask_step.py index c86f2cbf..5a64f6dc 100644 --- a/src/morphobricks_preprocessing/notebook_mask_step.py +++ b/src/morphobricks_preprocessing/notebook_mask_step.py @@ -84,13 +84,14 @@ def _build_mask_step_card(self) -> None: "Describe the masks (optional)", [ ui.help_text( - "Reads every mask the run recorded and describes each mask type (one " - "object per label, or labels in several pieces), how the types relate " - "(inside, grouped by, the same objects) and their hierarchy, with the " - f"counts behind each. Writes {MASK_REPORT_FILENAME} to the output " - "folder, as the command line's masks step does. The relationship " - "thresholds are the defaults; the command line's masks step can change " - "them. Optional: the histogram table does not need it." + "Reads every mask the run recorded. Optional for the histogram table; " + "bricks that use masks accept only masks this step has read. It describes " + "each mask type (one object per label, or labels in several pieces), how " + "the types relate (inside, grouped by, the same objects) and their " + "hierarchy, with the counts behind each, and writes " + f"{MASK_REPORT_FILENAME} to the output folder, as the command " + "line's masks step does. The relationship thresholds are the defaults; " + "the command line's masks step can change them." ), self.mask_step_hint, self.mask_step_button, @@ -289,8 +290,8 @@ def _recorded_hint(recorded: RecordedMasks | None) -> str: """Name the run the step describes, or say why the button is disabled.""" if recorded is None: return ( - "Available when the output folder holds a run that recorded masks " - "(step 1's masks folder)." + "Available after a run that recorded masks: it reads the masks that run recorded " + "(the masks folder is given in step 1)." ) when = f", written {html.escape(recorded.written_at)}" if recorded.written_at else "" run = ( diff --git a/src/morphobricks_preprocessing/notebook_masks.py b/src/morphobricks_preprocessing/notebook_masks.py index 7779e60f..f83de7a4 100644 --- a/src/morphobricks_preprocessing/notebook_masks.py +++ b/src/morphobricks_preprocessing/notebook_masks.py @@ -226,10 +226,10 @@ def _masks_ready_item(self, review: AxisReview | None) -> tuple[bool, str] | Non """Return the "Before you run" item of the masks, or None without masks.""" plan = self._planned_masks() if self._show_output else None if self._masks_stale(plan): - return False, "Check the masks again with the folder and mask axes chosen (step 1)" + return False, "Check the masks again with the folder and mask axes chosen (Masks)" if self.preflight is None or self.preflight.masks is None: return None - return not has_errors(self._mask_issues_now(review)), "Fix any mask errors (step 1, Masks)" + return not has_errors(self._mask_issues_now(review)), "Fix any mask errors (Masks)" def _refresh_masks(self, review: AxisReview | None) -> None: """Show the recorded masks folder, the masks found, their axes and their messages.""" diff --git a/src/morphobricks_preprocessing/notebook_step_states.py b/src/morphobricks_preprocessing/notebook_step_states.py index c9639224..c48c491b 100644 --- a/src/morphobricks_preprocessing/notebook_step_states.py +++ b/src/morphobricks_preprocessing/notebook_step_states.py @@ -17,8 +17,9 @@ UNCALIBRATED = ("uncalibrated", "pixels (stated)") """The X/Y size texts (:func:`.notebook_values.size_text`) that give no physical size.""" -ANSWER_STEPS = range(2, 10) -"""The steps (0-based) whose answers the published file holds: conditions to check and save.""" +ANSWER_STEPS = range(1, 6) +"""The steps (0-based) whose answers the published file holds: axes (the image type) to save.""" +TYPE_WORDS = (("separate channels", "channels"), ("colour", "rgb")) def _ids(wanted: dict[tuple[str, str], str], kind: str) -> list[str]: @@ -31,20 +32,15 @@ class _StepStates: """The :class:`.notebook_stepper.StepState` of each step, from the cards' state.""" def _step_states(self) -> list: - """Return the state callables of the 12 steps, in order.""" + """Return the state callables of the 7 steps, in order.""" return [ self._images_state, self._axes_state, - self._conditions_state, - self._replicates_state, - self._references_state, - self._image_type_state, - self._stains_state, - self._tags_state, + self._channels_state, self._masks_state, + self._design_state, self._saved_state, self._run_state, - self._describe_state, ] def _reopen_changed(self) -> bool: @@ -76,13 +72,35 @@ def _images_state(self) -> StepState: return StepState(pre.ok, text) def _axes_state(self) -> StepState: - """Return step 2: the confirmed roles, with the pixel size (its note when uncalibrated).""" + """Return step 2: the confirmed roles, the pixel size and the image type. + + Done once the roles are confirmed and every folder's image type is answered; the chip + notes an uncalibrated experiment. + """ mapping = self.confirmed_mapping sizes, note = self._sizes_words() if mapping is None: return StepState(False, "not confirmed") roles = ", ".join(role for role in mapping.roles if role) + " confirmed" - return StepState(True, f"{roles} · {sizes}" if sizes else roles, note=note) + types, typed = self._image_type_words() + text = " · ".join(part for part in (roles, sizes, types) if part) + return StepState(typed, text, self._changed(1, typed), note=note) + + def _image_type_words(self) -> tuple[str, bool]: + """Return the image types answered, by kind, and whether none is left to answer.""" + if self._channels_key is None: + return "image type: after the folder check", False + types = self.channel_types + saved = [types.saved(u.key) for u in self._channel_layouts if u.usable] + parts = [ + f"{word} ({plural(saved.count(value), 'folder')})" + for word, value in TYPE_WORDS + if value in saved + ] + done = None not in saved + if not done: + parts.append(f"image type: {saved.count(None)} to choose") + return ", ".join(parts), done def _sizes_words(self) -> tuple[str, str]: """Return the pixel sizes as the calibration card shows them, and the chip's note.""" @@ -110,97 +128,57 @@ def _sizes_words(self) -> tuple[str, str]: def _changed(self, index: int, done: bool) -> bool: return not done and index in ANSWER_STEPS and self._reopen_changed() - def _conditions_state(self) -> StepState: - """Return step 3: the condition IDs as shown, with their shown-as names.""" - if self.draft is None: - return StepState(False, "after the folder check") - wanted = self._wanted_ids() - ids = _ids(wanted, "conditions") - shown = { - wanted.get(("conditions", folder), ""): answer.display_name - for folder, answer in self.draft_answers.conditions.items() - if answer.display_name - } - text = ", ".join(f"{i} ({shown[i]})" if i in shown else i or "?" for i in ids) - done = all(ids) - return StepState(done, text or "none", self._changed(2, done)) - - def _replicates_state(self) -> StepState: - """Return step 4: the replicate IDs, the pairing and the technical-repeat IDs.""" + def _design_state(self) -> StepState: + """Return step 5: the conditions, the replicates and the comparisons, once confirmed.""" if self.draft is None: return StepState(False, "after the folder check") wanted = self._wanted_ids() + conditions = sorted(self.draft.conditions) + chosen = {c: self._chosen_reference(c) for c in conditions} + named = [] + for folder in conditions: + text = wanted.get(("conditions", folder)) or "?" + target = chosen[folder] + named.append(f"{text} → {wanted.get(('conditions', target)) or target}" + if target else text) # fmt: skip repeats, technical = _ids(wanted, "repeats"), _ids(wanted, "technical_repeats") - text = ", ".join(i or "?" for i in repeats) + text = f"{', '.join(named)} × {', '.join(i or '?' for i in repeats)}" paired = [ f"{condition}/{folder} = {wanted.get(('repeats', group)) or group}" for (condition, folder), group in sorted(self.draft_answers.pairing.items()) ] if paired: text += f" ({'; '.join(paired)})" - named = [i or "?" for i in technical if i != "single"] - if named: - text += " · technical: " + ", ".join(named) - done = all(repeats) and all(technical) - return StepState(done, text or "none", self._changed(3, done)) - - def _references_state(self) -> StepState: - if self.draft is None: - return StepState(False, "after the folder check") - conditions = sorted(self.draft.conditions) - if len(conditions) < 2: - return StepState(True, "one condition: nothing to compare") - chosen = {c: self._chosen_reference(c) for c in conditions} - text = " · ".join(f"{c} → {'?' if t is None else t or 'none'}" for c, t in chosen.items()) - done = None not in chosen.values() - return StepState(done, text, self._changed(4, done)) - - def _image_type_state(self) -> StepState: - """Return step 6: the image types answered, by kind, and how many are left.""" - if self._channels_key is None: - return StepState(False, "after the axes") - types = self.channel_types - usable = [u for u in self._channel_layouts if u.usable] - saved = [types.saved(u.key) for u in usable] - parts = [ - f"{word} ({plural(saved.count(value), 'folder')})" - for word, value in (("separate channels", "channels"), ("colour", "rgb")) - if value in saved - ] - done = None not in saved + named_folders = [i or "?" for i in technical if i != "single"] + if named_folders: + text += " · technical: " + ", ".join(named_folders) + done = self._design_confirmed() if not done: - parts.append(f"{saved.count(None)} to choose") - return StepState(done, ", ".join(parts), self._changed(5, done)) + text += " · not confirmed" + return StepState(done, text, self._changed(4, done)) - def _stains_state(self) -> StepState: - """Return step 7: the confirmed labels, or not applicable when every folder is colour.""" + def _channels_state(self) -> StepState: + """Return step 3: the stains named and every channel of every folder tagged.""" if self._channels_key is None: return StepState(False, "after the axes") - if self._stains_not_applicable: - return StepState( - True, "not applicable: every folder is colour (RGB)", note="not applicable" - ) - done = self.channel_labels.confirmed - text = ", ".join(self.channel_labels.labels) if done else "not confirmed" - return StepState(done, text, self._changed(6, done)) - - def _tags_state(self) -> StepState: - """Return step 8: whether every channel of every folder is tagged.""" - if self._channels_key is None or not ( - self.channel_labels.confirmed or self._stains_not_applicable - ): - return StepState(False, "after the stains") layouts = self._channel_layouts + if self._stains_not_applicable: + stains = "no stains to name: every folder is colour (RGB)" + elif self.channel_labels.confirmed: + stains = ", ".join(self.channel_labels.labels) + else: + return StepState(False, "stains not confirmed", self._changed(2, False)) left = remaining(layouts, self.draft_answers.units, self.channel_labels.labels) done = not left.cells and not left.blocked - text = ( + tags = ( f"every channel of {plural(len(layouts), 'folder')} tagged" if done else f"{left.cells} channel(s) still to tag" ) - return StepState(done, text, self._changed(7, done)) + return StepState(done, f"{stains} · {tags}", self._changed(2, done)) def _masks_state(self) -> StepState: + """Return step 4: what each mask type is, and whether the relationships are described.""" types = self._mask_types() if not types: return StepState(self.preflight is not None, "no masks") @@ -208,8 +186,10 @@ def _masks_state(self) -> StepState: return StepState(False, "after the folder check") kinds = mask_kinds(self.draft_answers) text = " · ".join(f"{t} = {kinds.get(t, '?')}" for t in types) + if self.mask_step_result.value: + text += " · relationships described" done = not masks_unanswered(self.draft_answers, types) - return StepState(done, text, self._changed(8, done)) + return StepState(done, text, self._changed(3, done)) def _saved_state(self) -> StepState: if self.draft is None: @@ -217,7 +197,7 @@ def _saved_state(self) -> StepState: if self._saved_current(): state = self._published() return StepState(True, f"saved {self._saved_on or state.saved_on}") - return StepState(False, "not saved yet", self._changed(9, False)) + return StepState(False, "not saved yet", self._changed(5, False)) def _run_state(self) -> StepState: result = self.result @@ -230,10 +210,5 @@ def _run_state(self) -> StepState: True, f"{plural(result.plane_count, 'plane')} · {plural(len(warnings), 'warning')}" ) - def _describe_state(self) -> StepState: - if self.mask_step_result.value: - return StepState(True, "masks described") - return StepState(False, "optional") - __all__ = ["ANSWER_STEPS", "UNCALIBRATED", "_StepStates"] diff --git a/src/morphobricks_preprocessing/notebook_stepper.py b/src/morphobricks_preprocessing/notebook_stepper.py index 8451bc77..1ae07219 100644 --- a/src/morphobricks_preprocessing/notebook_stepper.py +++ b/src/morphobricks_preprocessing/notebook_stepper.py @@ -55,10 +55,11 @@ .mb-compact .mb-header { font-size: 11px; padding-bottom: 3px; margin-bottom: 5px; } .mb-compact .mb-help { font-size: 12px; margin: 1px 0 4px 0; line-height: 1.35; } .mb-compact .mb-status { padding: 5px 10px; margin: 4px 0; line-height: 1.4; } -.mb-compact .mb-tiles { gap: 6px; margin: 4px 0 2px 0; } -.mb-compact .mb-tile { padding: 3px 10px; min-width: 96px; flex: 1 1 96px; } -.mb-compact .mb-tile .mb-tile-value { font-size: 15px; } -.mb-compact .mb-tile .mb-tile-label { font-size: 11px; } +.mb-compact .mb-tiles { gap: 2px 18px; margin: 4px 0 4px 2px; } +.mb-compact .mb-tile { display: flex; align-items: baseline; gap: 5px; padding: 0; min-width: 0; + flex: 0 0 auto; border: none; background: transparent; box-shadow: none; } +.mb-compact .mb-tile .mb-tile-value { font-size: 13px; } +.mb-compact .mb-tile .mb-tile-label { font-size: 12px; } .mb-compact .mb-confirm { padding: 6px 10px; margin: 6px 0 2px 0; } .mb-compact .mb-question { margin-bottom: 4px; } .mb-compact .mb-table td { padding: 3px 10px; } @@ -81,6 +82,7 @@ .mb-compact .mb-scan img.mb-grey, .mb-compact .mb-scan img.mb-colour, .mb-compact .mb-scan img.mb-crop { width: 180px !important; } .mb-channel-row { gap: 8px; } +.mb-compact .mb-mask-pictures img { width: 170px !important; height: auto; } .mb-compact .mb-channel-row .mb-channel img.mb-grey { width: 99% !important; } .mb-about { margin: 0 0 6px 0; font-size: 12.5px; color: #475569; } .mb-about summary { cursor: pointer; color: #2563EB; } diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py index cb3f047e..2fec802f 100644 --- a/src/morphobricks_preprocessing/notebook_steps.py +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -1,18 +1,25 @@ -"""The shipped notebook's 12 steps, one on screen at a time (owner decision 2026-11-09). - -Pure composition of the cards the notebook already has (UX proposal 2026-11-09, PR 17a): the -order is images found, axes (with the pixel size, owner 2026-11-10), conditions, replicates, -compared against, image type, -stains, tag channels, masks, check and save (with the overview of steps 1-9), run, and describe -the masks. Each answer is still saved, refused and checked by the card that asks it; the stepper +"""The shipped notebook's 7 steps, one on screen at a time (owner decisions 2026-11-09/10). + +Pure composition of the cards the notebook already has. The order is the owner's of +10 November 2026, what the files hold first and what it means after: + +1. **Images**: the folders, the quick scan, then every header read on an explicit click; +2. **Axes**: the role of each dimension with the example, the pixel size, and the image type + (what the channel dimension holds: :mod:`.notebook_image_type_line`); +3. **Channels**: name the stains, then tag each folder's channels; +4. **Masks**: the masks paired with the images (and their axes), what each mask type is and, + once a run recorded them, the mask step that reads every mask and describes how the types + relate (it reads the masks the run recorded, so it cannot come earlier; bricks that use + masks still refuse a mask it has not read, ``A_MASK_UNCHECKED``); +5. **Design**: conditions, biological repeats and comparisons in one grid, confirmed by one + click (:mod:`.notebook_design_step`); +6. **Check and save**; 7. **Run**, with the collapsed *Compare groups*. + +Each answer is still saved, refused and checked by the card that asks it; the stepper (:mod:`.notebook_stepper`) only shows one page and says, from each card's state -(:mod:`.notebook_step_states`), which steps are done. - -Three things follow the layout and nothing else: the tag grid leaves the image type to its own -page (:class:`.notebook_channels_type.ImageTypeRows`, the same answers), the condition names and -the replicate names sit on their own steps (:mod:`.notebook_names`), and the consistency card -follows the pairing, shown only when it warns. A page holding a card the saved reopening folded -unfolds it when it is opened. ``folder_step`` and ``axis_step`` are unchanged for embedders. +(:mod:`.notebook_step_states`), which steps are done. A page holding a card the saved +reopening folded unfolds it when it is opened. ``folder_step`` and ``axis_step`` are unchanged +for embedders. """ from __future__ import annotations @@ -26,6 +33,8 @@ from .notebook_channels_type import ImageTypeRows from .notebook_check_save import INTRO as CHECK_INTRO from .notebook_compact import CHIPS, _CompactPages +from .notebook_design_step import _DesignStep +from .notebook_image_type_line import ImageTypeLine from .notebook_masks_screen import INTRO as MASKS_INTRO from .notebook_quick_scan import _QuickScan from .notebook_step_states import _StepStates @@ -39,7 +48,22 @@ "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." + "by you; nothing is guessed, and an image without one is uncalibrated.
    The image type " + "says what the channel dimension holds: one stain or marker per channel, or the red, green " + "and blue of a colour picture. It is proposed from the image headers and recorded only " + "when you click." +) +CHANNELS_INTRO = ( + "What happens here: first name what you stained, then say, for every folder, which " + "stain each channel is, looking at its picture. Nothing is chosen for you: names found in " + "the image headers are offered only as suggestions, and each one needs your click." +) +DESIGN_INTRO = ( + "What happens here: you say which folders are which condition and biological " + "repeat, and what each condition is compared against. A short name (ID) is what later " + "bricks use; it never changes once the experiment description is saved. Later bricks " + "normalise each biological repeat over all its conditions together, so folders of the same " + "preparation must be the same repeat." ) CONDITIONS_HELP = ( "Each condition gets a short name (its ID), which later bricks use and which never changes " @@ -51,17 +75,18 @@ "which never changes once the experiment description is saved. A replicate pairs one folder " "per condition (above); its name is proposed from the folder name when it can be used." ) -OVERVIEW_STEPS = 9 -"""The overview on *Check and save* restates steps 1 to 9.""" -UNFOLDING = {3: "design_card", 4: "references_card", 8: "masks_box"} +OVERVIEW_STEPS = 5 +"""The overview on *Check and save* restates steps 1 to 5.""" +MASKS_STEP, DESIGN_STEP = 3, 4 +UNFOLDING = (MASKS_STEP, DESIGN_STEP) """The steps (0-based) that show a card the saved reopening folds.""" -class _StepsFlow(_CompactPages, _QuickScan, _QuickAxes, _StepStates): +class _StepsFlow(_CompactPages, _DesignStep, _QuickScan, _QuickAxes, _StepStates): """``steps()``: the stepper of the shipped notebook, and the hooks that keep it current.""" def steps(self) -> widgets.Widget: - """Return the notebook's 12 steps, one shown at a time, with a strip and Back/Next. + """Return the notebook's 7 steps, one shown at a time, with a strip and Back/Next. The shipped notebook shows this instead of the stacked pages. Every answer is given and saved on the card that asks it, as on the pages; @@ -74,13 +99,14 @@ def steps(self) -> widgets.Widget: """ if getattr(self, "_stepper", None) is not None: return self._stepper.widget - self.channel_grid.place_types = False # the image type has its own step + self.channel_grid.place_types = False # the image type is asked with the axes if ui.is_visible(self.channel_grid.widget): self.channel_grid.refresh() for box in (self.folder_next, self.axis_next): ui.set_visible(box, False) # Next and the strip say where to go self.type_rows = ImageTypeRows(self.channel_types, lambda: self._channel_thumbs) self.type_rows.reopen = self.channel_grid.reopen = self._reopen_units + self.type_line = ImageTypeLine(self.channel_types, self.type_rows) self.overview_box = widgets.VBox() self.overview_fold = ui.accordion("Your answers, step by step", [self.overview_box]) self._build_quick_axes() @@ -88,39 +114,36 @@ def steps(self) -> widgets.Widget: 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._build_design_step() self._stepper = Stepper(self._step_list(), self._step_shown) self.overview_box.children = [self._stepper.overview(OVERVIEW_STEPS)] + if self.draft is not None: + self._refresh_description() # the comparisons show their proposal self._refresh_type_rows() self._refresh_quick_axes() self._refresh_steps() return self._stepper.widget def _step_list(self) -> list[Step]: - """Return the 12 steps: titles, purposes, the existing cards, and their states.""" + """Return the 7 steps: titles, purposes, the existing cards, and their states.""" states = self._step_states() pages = [ - ("Images found", "Choose the folder with your images and check it.", + ("Images", "Choose the folders, look at one image, then read every header.", self._images_page()), - ("Axes", "Say which image dimension is width, height, channel and time.", - [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]), - ("Compared against", "One question per condition.", [self.references_card]), - ("Image type", "Separate channels or a colour (RGB) picture, per folder.", - [self.type_rows.widget]), - ("Stains", "Name what you stained.", - [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.", [about(MASKS_INTRO), self.masks_box]), + ("Axes", "What each image dimension is, the pixel size and the image type.", + [about(AXES_INTRO), self.axis_box, self.quick_card, self.type_line.widget]), + ("Channels", "Name what you stained, then tag each folder's channels.", + [about(CHANNELS_INTRO), self.channel_labels.widget, self.channel_grid.widget]), + ("Masks", "What each mask type is; after a run, how the types relate.", + [about(MASKS_INTRO), self.masks_card, self.masks_box, self.mask_step_card]), + ("Design", "Conditions, biological repeats and comparisons.", + [about(DESIGN_INTRO), self.design_step_card, self.design_edit, + self.consistency_slot]), ("Check and save", "Check your experiment, confirm each replicate, then save.", [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.", + ("Run", "Compute the histogram table; then check and compare the groups.", self._run_page()), - ("Describe the masks", "Optional: how the mask types relate.", - [self.mask_step_card]), ] # fmt: skip waits = self._step_waits() return [ @@ -140,16 +163,10 @@ def _replicates_card(self) -> widgets.Widget: def _step_waits(self) -> dict: """Return, by step (0-based), why it cannot be answered yet (HTML, empty when it can).""" - after_check = self._wait_for_check return { 0: self._consistency_note, - 2: after_check, - 3: after_check, - 4: self._wait_for_references, - 5: self._wait_for_channels, - 6: self._wait_for_channels, - 7: self._wait_for_tags, - 11: self._wait_for_mask_step, + 2: self._wait_for_channels, + DESIGN_STEP: self._wait_for_check, } def _consistency_note(self) -> str: @@ -158,33 +175,26 @@ def _consistency_note(self) -> str: return "" return ui.status( "info", - "The consistency warnings are on Replicates.", - ["They depend on which folders are one replicate: pair the folders there first."], + "The consistency warnings are on Design.", + ["They depend on which folders are one replicate: see the pairing there first."], ) def _wait_for_check(self) -> str: if self.draft is not None: return "" - return ui.status("info", "Waiting for step 1.", ["Check the experiment folder first."]) - - def _wait_for_references(self) -> str: - if self.draft is not None and len(self.draft.conditions) < 2: - return ui.status("ok", "There is one condition: nothing to compare.") - return self._wait_for_check() + return ui.status("info", "Waiting for step 1.", ["Read the image headers first (Images)."]) def _wait_for_channels(self) -> str: - return "" if self._channels_key is not None else self.channels_waiting.value - - def _wait_for_tags(self) -> str: - waiting = self._wait_for_channels() - if waiting or self.channel_labels.confirmed or self._stains_not_applicable: - return waiting - return ui.status("info", "Waiting for the stains.", ["Confirm the stains first (Stains)."]) - - def _wait_for_mask_step(self) -> str: - if ui.is_visible(self.mask_step_card): - return "" - return ui.status("info", "Not offered.", ["The output folder is not shown."]) + """Say what the channels wait for: the folder check, the axis roles, the image type.""" + if self._channels_key is None: + return self.channels_waiting.value + if not self.type_line.complete: + return ui.status( + "info", + "Waiting for the image type.", + ["Say on Axes (step 2) what the channels hold: a folder is tagged after that."], + ) + return "" # Hooks ------------------------------------------------------------------------------- @@ -249,7 +259,7 @@ def _toggle_confirmation(self) -> None: self._refresh_quick_axes() def _refresh_type_rows(self) -> None: - """Open the image-type step for the folders the channel step opened, or close it.""" + """Open the image type for the folders the channel step opened, or close it.""" rows = getattr(self, "type_rows", None) if rows is None: return @@ -259,6 +269,7 @@ def _refresh_type_rows(self) -> None: rows.open(self._channel_layouts) else: rows.refresh() + self.type_line.refresh() def _refresh_channels(self) -> None: super()._refresh_channels() diff --git a/tests/test_notebook_axes_quick.py b/tests/test_notebook_axes_quick.py index 463e8e68..fc64d0d9 100644 --- a/tests/test_notebook_axes_quick.py +++ b/tests/test_notebook_axes_quick.py @@ -77,7 +77,7 @@ def test_an_unchanged_reopen_offers_the_run_s_roles_and_moves_on(exp): # noqa: assert "the earlier run of this experiment recorded" in nb.quick_text.value nb.quick_confirm.click() assert nb.confirmed_mapping == first.confirmed_mapping - assert nb._stepper.current == 10 # every answer is saved: the run is next + assert nb._stepper.current == 6 # every answer is saved: the run is next def test_a_role_edited_after_the_offer_is_not_confirmed_by_the_old_offer(exp): # noqa: F811 diff --git a/tests/test_notebook_check_save.py b/tests/test_notebook_check_save.py index cff9c679..6d31ab1d 100644 --- a/tests/test_notebook_check_save.py +++ b/tests/test_notebook_check_save.py @@ -108,10 +108,13 @@ def answer_all(nb): question = nb.mask_card.questions.get("Nuclei") if question is not None and question.choice.value is None: question.choice.value = "instances" - if nb.reference_boxes["drug"].value is None: + if nb._chosen_reference("drug") is None: nb.reference_boxes["drug"].value = "ctrl" - if nb.reference_boxes["ctrl"].value is None: + if nb._chosen_reference("ctrl") is None and nb.reference_boxes["ctrl"].value is None: nb.reference_boxes["ctrl"].value = "" + if getattr(nb, "_stepper", None) is not None and not nb._design_confirmed(): + # The stepper's Design step: the names and "no comparison" as shown, in one click. + nb.design_confirm.click() for toggle in list(nb.same_way.values()): if not toggle.value: toggle.button.click() diff --git a/tests/test_notebook_compact.py b/tests/test_notebook_compact.py index 4711afb6..abd577f5 100644 --- a/tests/test_notebook_compact.py +++ b/tests/test_notebook_compact.py @@ -56,10 +56,10 @@ def test_the_strip_is_short_names_and_the_page_keeps_the_full_title(exp): # noq 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 names == ["Images", "Axes", "Channels", "Masks", "Design", "Save", "Run"] 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 + head = st.pages[5].children[1].children[0].value + assert "Step 6 of 7 · 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 @@ -103,7 +103,7 @@ def test_every_page_is_a_single_column_of_cards(exp): # noqa: F811 def test_explanations_are_folded_behind_about_this_step(exp): # noqa: F811 nb = stepper(*exp) - for index in (0, 1, 8, 9): + for index in range(6): first = nb._stepper.pages[index].children[1].children[1].children[1] assert first.value.startswith( "
    About this step" @@ -131,6 +131,7 @@ def test_the_folders_are_one_card_of_three_labelled_rows(exp): # noqa: F811 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 + assert not _holds(page, nb.masks_card) # what the check paired is on Masks labels = [w.value for w in field_rows([("Images", []), ("Masks (optional)", [])]).children[::2]] assert [_text(v) for v in labels] == ["Images", "Masks (optional)"] @@ -189,7 +190,7 @@ def test_a_refusal_is_one_sentence_with_the_files_and_the_fix_on_demand(): 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] + page = nb._stepper.pages[6] assert ui.is_visible(nb.readiness_card) and nb.run_button.disabled for fold, inner in ( (nb.settings_fold, nb.settings_card), @@ -207,7 +208,7 @@ def test_the_run_step_shows_only_what_is_missing_and_folds_the_rest(exp): # noq 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] + page = nb._stepper.pages[6] 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( diff --git a/tests/test_notebook_mask_step.py b/tests/test_notebook_mask_step.py index 488a122e..6f9ace95 100644 --- a/tests/test_notebook_mask_step.py +++ b/tests/test_notebook_mask_step.py @@ -78,9 +78,7 @@ def test_the_button_is_on_step_3_and_waits_for_a_run_that_recorded_masks(tmp_pat assert nb.mask_step_card in page.children[1].children assert ui.is_visible(nb.mask_step_card) assert nb.mask_step_button.disabled - assert "Available when the output folder holds a run that recorded masks" in ( - nb.mask_step_hint.value - ) + assert "Available after a run that recorded masks" in (nb.mask_step_hint.value) assert nb._on_describe_masks() is None assert nb.run().written assert not nb.mask_step_button.disabled @@ -289,7 +287,7 @@ def test_a_reopened_notebook_follows_the_output_folder_of_step_1(tmp_path): assert not nb.mask_step_button.disabled nb.output.value = str(tmp_path / "elsewhere") assert nb.mask_step_button.disabled - assert "Available when the output folder holds" in nb.mask_step_hint.value + assert "Available after a run that recorded masks" in nb.mask_step_hint.value nb.output.value = str(output_of(root)) assert not nb.mask_step_button.disabled diff --git a/tests/test_notebook_masks.py b/tests/test_notebook_masks.py index 4b777b56..eba32ee9 100644 --- a/tests/test_notebook_masks.py +++ b/tests/test_notebook_masks.py @@ -113,7 +113,7 @@ def test_mask_errors_are_shown_on_the_card_and_block_the_run(tmp_path): assert "some masks cannot be used" in nb.masks_status.value assert "Action required" in nb.folder_status.value confirm_everything(nb) - assert still_to_do(nb) == "fix any mask errors (step 1, Masks)" + assert still_to_do(nb) == "fix any mask errors (Masks)" assert nb.run() is None @@ -151,7 +151,7 @@ def test_plain_stack_masks_get_their_axes_stated_never_guessed(tmp_path): assert offered[:3] == ["", "TYX", "ZYX"] and "YX" not in offered assert "Cells (axes in the files: IYX)" in nb.mask_axes_box.children[1].value confirm_everything(nb) - assert still_to_do(nb) == "fix any mask errors (step 1, Masks)" + assert still_to_do(nb) == "fix any mask errors (Masks)" choice.value = "TYX" # chosen, not yet applied assert nb.preflight.masks.files[0].axes == "" assert still_to_do(nb).startswith("check the masks again") @@ -182,7 +182,7 @@ def test_a_2d_mask_of_a_time_lapse_is_refused_without_a_question(tmp_path, named assert "mask planes mismatch" in nb.masks_issues.value assert "same T as their image" in nb.masks_issues.value assert "Cells" not in nb._mask_choices and nb.mask_axes_box.children == () - assert still_to_do(nb) == "fix any mask errors (step 1, Masks)" + assert still_to_do(nb) == "fix any mask errors (Masks)" assert nb.run_button.disabled diff --git a/tests/test_notebook_owner_fixes.py b/tests/test_notebook_owner_fixes.py index 04101a22..ae5f12a3 100644 --- a/tests/test_notebook_owner_fixes.py +++ b/tests/test_notebook_owner_fixes.py @@ -78,9 +78,12 @@ def test_choosing_a_reference_keeps_the_dropdown_and_shows_the_choice_at_once(ex assert boxes["drug"].value == "ctrl" assert nb.draft_answers.conditions["drug"].reference == "ctrl" # saved as before assert "the reference of drug" in nb.reference_notes["ctrl"].value - nb.reference_boxes["ctrl"].value = "" + # "no comparison" is the proposal the Design step shows: saved by its one click, not before. + assert boxes["ctrl"].value == "" and "ctrl" not in nb.draft_answers.conditions + nb.design_confirm.click() assert nb.reference_boxes["ctrl"] is boxes["ctrl"] and boxes["ctrl"].value == "" assert nb.draft_answers.conditions["ctrl"].reference == "" + assert nb.draft_answers.conditions["drug"].reference == "ctrl" # the choice is kept def test_a_refused_reference_is_shown_and_the_dropdown_goes_back_in_place(exp): # noqa: F811 @@ -91,7 +94,8 @@ def test_a_refused_reference_is_shown_and_the_dropdown_goes_back_in_place(exp): nb.reference_boxes["ctrl"].value = "drug" # a loop: refused by the draft's own rule assert "This answer was not taken" in nb.references_status.value assert nb.reference_boxes["ctrl"] is boxes["ctrl"] - assert boxes["ctrl"].value is None and boxes["drug"].value == "ctrl" + # Back to the proposal the Design step shows ("no comparison"), which is not saved. + assert boxes["ctrl"].value == "" and boxes["drug"].value == "ctrl" assert nb.draft_answers.conditions.get("ctrl") is None or ( nb.draft_answers.conditions["ctrl"].reference is None ) diff --git a/tests/test_notebook_seven_steps.py b/tests/test_notebook_seven_steps.py new file mode 100644 index 00000000..c3423a23 --- /dev/null +++ b/tests/test_notebook_seven_steps.py @@ -0,0 +1,407 @@ +"""The 7-step flow of the shipped notebook (owner, 10 November 2026, items (o), (p) and (q)). + +- the image type is asked with the axes, in one line pre-filled from the image headers and + recorded only by a click; each folder on demand; folders that differ are said at once; +- the conditions, the replicates and "compared against" are one grid with one confirmation; +- the mask step (*Describe the masks*) is on the Masks step and waits, visibly, for a run. + +The proposals are never answers: nothing is saved before the click. For the same answers the +stepper publishes the bytes the stacked pages publish. +""" + +import re + +import numpy as np +import pytest +import tifffile + +from channel_images import write_channels, write_rgb +from morphobricks_preprocessing.description_file import DESCRIPTION_FILENAME +from morphobricks_preprocessing.design import DESIGN_FILENAME +from test_notebook_check_save import add_folder, answer_all, exp, opened, tag # noqa: F401 + +pytest.importorskip("ipywidgets") +pytest.importorskip("matplotlib") + +from morphobricks_preprocessing import notebook_style as ui # noqa: E402 +from morphobricks_preprocessing.channel_labels import UnitLayout # noqa: E402 +from morphobricks_preprocessing.draft_answers import DraftAnswers # noqa: E402 +from morphobricks_preprocessing.notebook_channels_type import ( # noqa: E402 + ImageTypeColumn, + ImageTypeRows, +) +from morphobricks_preprocessing.notebook_check_view import TODO_SHOWN, todo_html # noqa: E402 +from morphobricks_preprocessing.notebook_design_step import NOT_CONFIRMED # noqa: E402 +from morphobricks_preprocessing.notebook_image_type_line import ( # noqa: E402 + MIXED, + WAITING, + ImageTypeLine, +) +from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 +from test_notebook_axes_quick import stepper # noqa: E402 + +FOLDERS = ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/rep2"] + + +def _text(html): + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html)).strip() + + +def _types(nb): + return {key: unit.image_type for key, unit in nb.draft_answers.units.items()} + + +def _colour(tmp_path, colour=FOLDERS, *, confirm=True): + """Return a stepper on four folders, those of ``colour`` holding colour (RGB) pictures.""" + root = tmp_path / "exp" + for i, folder in enumerate(FOLDERS): + if folder in colour: + write_rgb(root / folder / "a.tif", seed=i + 1) + else: + write_channels(root / folder / "a.tif", ["DAPI", "Actin", "Ch3"], ["blobs"] * 3, seed=i) + nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out")) + nb.steps() + nb.check_button.click() + if confirm: + nb.quick_confirm.click() + return nb + + +# --- (p) the image type, with the axes ---------------------------------------------------------- + + +def test_the_image_type_waits_for_the_axis_roles_and_says_so(exp): # noqa: F811 + nb = stepper(*exp) + line = nb.type_line + assert _text(line.line.value) == WAITING + assert not ui.is_visible(line.form) and not ui.is_visible(line.per_folder) + nb.quick_confirm.click() + assert ui.is_visible(line.form) and ui.is_visible(line.per_folder) + assert line.per_folder.selected_index is None # each folder on demand + + +def test_the_header_s_type_is_a_proposal_saved_only_by_the_click(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + line = nb.type_line + assert line.choice.value == "channels" and line.suggestion() == "channels" + said = _text(line.line.value) + assert "Proposed from the image headers: separate channels in all 4 folders" in said + assert "Not saved until you click Use for all folders" in said + assert set(_types(nb).values()) <= {None} and not line.complete # nothing was applied + assert not nb._stepper.states()[1].done + assert ui.is_visible(line.apply) and not line.apply.disabled + line.apply.click() + assert set(_types(nb).values()) == {"channels"} and len(_types(nb)) == 4 and line.complete + assert "Image type: separate channels in all 4 folders." in _text(line.line.value) + assert not ui.is_visible(line.apply) # nothing left to record + assert nb._stepper.states()[1].done + + +def test_one_click_for_all_folders_records_what_each_folder_s_choice_records(exp, tmp_path): # noqa: F811 + root, masks, out = exp + one = stepper(root, masks, out) + one.quick_confirm.click() + one.type_line.apply.click() + each = stepper(root, masks, tmp_path / "each") + each.quick_confirm.click() + for radio in list(each.channel_types.radios.values()): + radio.value = "channels" + assert one.draft_answers.units == each.draft_answers.units + assert one.draft_answers.channels == each.draft_answers.channels + + +def test_headers_that_all_say_colour_propose_colour_and_the_click_confirms_it(tmp_path): + nb = _colour(tmp_path) + line = nb.type_line + assert line.choice.value == "rgb" and "colour (RGB) in all 4 folders" in _text(line.line.value) + assert not nb._stains_not_applicable and set(_types(nb).values()) <= {None} + line.apply.click() + assert set(_types(nb).values()) == {"rgb"} and nb._stains_not_applicable + assert all(unit.channels == ("R", "G", "B") for unit in nb.draft_answers.units.values()) + + +class _Store: + """The answers of a card, kept in memory (the cards' :class:`AnswerStore`).""" + + def __init__(self): + self.saved = DraftAnswers("0" * 64, "spec") + + def answers(self): + return self.saved + + def update(self, new): + self.saved = new + return True + + def refusal(self): + return "" + + def used_channels(self): + return () + + +def _line(colour): + """Return the image-type line over four units, those of ``colour`` with a colour header.""" + layouts = tuple( + UnitLayout((c, r, "single"), "a.tif", 3, (None,) * 3, rgb=(c, r) in colour) + for c in ("ctrl", "drug") + for r in ("rep1", "rep2") + ) + types = ImageTypeColumn(_Store(), lambda: line.rows.refresh() or line.refresh()) + line = ImageTypeLine(types, ImageTypeRows(types)) + line.rows.open(layouts) + line.refresh() + return line + + +def test_headers_that_differ_propose_nothing_and_open_each_folder(): + line = _line({("drug", "rep2")}) + assert line.suggestion() is None and line.choice.value is None + said = _text(line.line.value) + assert "The image headers differ: separate channels in 3 folders, colour (RGB) in 1" in said + assert "Nothing is proposed" in said and "R_HETEROGENEOUS_LAYOUT_NOT_ENABLED" in said + assert line.per_folder.selected_index == 0 # the menu is open + assert line.apply.disabled and "choose a type in the box" in _text(line.blocker.value) + assert not line.types.store.saved.units # nothing was applied + + +def test_folders_answered_differently_are_said_at_once_with_the_refusal_to_come(): + line = _line({("drug", "rep2")}) + for layout in line.types.layouts: + if not layout.rgb: + line.types.radios[layout.key].value = "channels" + line.types.confirm_header([u.key for u in line.types.layouts if u.rgb]) # the pre-tick + said = _text(line.line.value) + assert "Image types differ: separate channels in 3 folders, colour (RGB) in 1" in said + assert MIXED in said and line.complete + one = _line(set()) + key = one.types.layouts[0].key + one.types.radios[key].value = "channels" + assert "Not finished: separate channels in 1 folder, 3 not answered" in _text(one.line.value) + + +def test_colour_pictures_beside_separate_channels_stay_refused_at_the_axes(tmp_path): + nb = _colour(tmp_path, colour=["drug/rep2"], confirm=False) + assert nb.mapping is None and nb.confirmed_mapping is None # as before: no roles to confirm + assert "No axis roles can be suggested" in nb.axis_box.children[0].value + assert _text(nb.type_line.line.value) == WAITING and not ui.is_visible(nb.type_line.form) + assert nb.run_button.disabled and not nb._stepper.states()[1].done + + +def test_replacing_answers_already_given_asks_first(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + line = nb.type_line + line.apply.click() + before = nb.draft_answers + line.choice.value = "rgb" # three channels: colour can be declared (the run checks headers) + assert ui.is_visible(line.apply) + line.apply.click() + assert ui.is_visible(line.replace_box) and nb.draft_answers == before # nothing written yet + assert "4 folders answered otherwise" in _text(line.replace_text.value) + line.replace_no.click() + assert not ui.is_visible(line.replace_box) and nb.draft_answers == before + line.apply.click() + line.replace_yes.click() + assert set(_types(nb).values()) == {"rgb"} + # The per-folder rows say what the run will do with a type the header contradicts. + assert "The header does not say colour" in repr(nb.type_rows.rows.children) + + +def test_a_type_the_images_cannot_have_changes_nothing(tmp_path): + root = tmp_path / "exp" + for folder in ("ctrl/rep1", "drug/rep1"): + (root / folder).mkdir(parents=True) + data = np.zeros((2, 16, 16), np.uint8) + tifffile.imwrite(root / folder / "a.tif", data, metadata={"axes": "CYX"}) + nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out")) + nb.steps() + nb.check_button.click() + nb.quick_confirm.click() + line = nb.type_line + line.choice.value = "rgb" + line.apply.click() + assert "Nothing was changed" in line.message.value + assert "a colour image has 3 channels" in line.message.value + assert set(_types(nb).values()) <= {None} + + +def test_the_channels_step_waits_for_the_image_type_and_says_where(exp): # noqa: F811 + nb = stepper(*exp) + nb._stepper.go(2) + assert "Waiting for the axis roles" in nb._stepper.waits[2].value + nb.quick_confirm.click() + nb._stepper.go(2) + assert "Waiting for the image type" in nb._stepper.waits[2].value + assert "Axes (step 2)" in nb._stepper.waits[2].value + nb.type_line.apply.click() + nb._stepper.go(2) + assert nb._stepper.waits[2].value == "" + + +# --- (q) the design: one grid, one click -------------------------------------------------------- + + +def test_the_design_is_proposed_and_nothing_is_saved_before_the_click(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + assert ui.is_visible(nb.design_step_card) and ui.is_visible(nb.design_confirm) + cells = nb._design_cells + assert "ctrl" in cells[("condition", "ctrl")].value + assert "proposed" in cells[("condition", "ctrl")].value + assert "rep1" in cells[("repeat", "rep1")].value + assert _text(cells[("drug", "rep2")].value) == "rep2 1 image" + assert [box.value for box in nb.reference_boxes.values()] == ["", ""] # no comparison + assert all(None not in [v for _, v in box.options] for box in nb.reference_boxes.values()) + # A proposal is not an answer: no ID is adopted and no comparison is saved. + assert not nb.draft_answers.conditions and not nb._ids_all_confirmed() + assert not nb._design_confirmed() and not nb._stepper.states()[4].done + assert "not confirmed" in nb._stepper.states()[4].text + + +def test_the_save_waits_for_the_design_s_click_and_says_so(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + tag(nb) + nb.mask_card.questions["Nuclei"].choice.value = "instances" + for toggle in list(nb.same_way.values()): + toggle.button.click() + assert nb.save_button.disabled and not nb.save_experiment() + assert NOT_CONFIRMED in _text(nb.todo.value) + assert "is compared against (choose" not in nb.todo.value # the one click answers them + assert "1 answer still missing" in _text(nb.save_blocker.value) + nb.design_confirm.click() + assert nb.todo.value == "" and not nb.save_button.disabled + assert nb.save_experiment() + + +def test_the_click_adopts_every_name_and_no_comparison_as_shown(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + nb.reference_boxes["drug"].value = "ctrl" # a choice in the grid is saved at once + assert nb.draft_answers.conditions["drug"].reference == "ctrl" + table, boxes = nb.design_table.children, dict(nb.reference_boxes) + nb.design_confirm.click() + answers = nb.draft_answers + assert answers.conditions["ctrl"].reference == "" and answers.conditions["ctrl"].id == "ctrl" + assert ( + answers.conditions["drug"].reference == "ctrl" and answers.conditions["drug"].id == "drug" + ) + assert {k: v.id for k, v in answers.repeats.items()} == {"rep1": "rep1", "rep2": "rep2"} + assert nb._design_confirmed() and nb._stepper.states()[4].done + assert not ui.is_visible(nb.design_confirm) and ui.is_visible(nb.design_change) + assert "Design confirmed: 2 conditions × 2 biological repeats" in _text(nb.design_result.value) + assert "proposed" not in nb._design_cells[("condition", "ctrl")].value + # The grid is updated in place: the same cells and the same dropdowns. + assert nb.design_table.children == table and dict(nb.reference_boxes) == boxes + assert nb._stepper.states()[4].text.startswith("ctrl, drug → ctrl × rep1, rep2") + nb.design_change.click() # the names can be taken back to edit them + assert not nb._design_confirmed() and ui.is_visible(nb.design_confirm) + assert not nb.id_inputs[("conditions", "ctrl")].disabled + + +def test_a_folder_that_cannot_be_a_name_blocks_the_click_with_a_visible_reason(tmp_path): + root, masks = tmp_path / "exp", tmp_path / "masks" + for i, folder in enumerate(["NA/rep1", "NA/rep2", "drug/rep1", "drug/rep2"]): + add_folder(root, masks, folder, i + 1) + nb = stepper(root, masks, tmp_path / "out") + nb.quick_confirm.click() + assert nb.design_confirm.disabled + assert "1 name missing (NA)" in _text(nb.design_blocker.value) + assert "Edit names or pairing" in _text(nb.design_blocker.value) + assert nb.design_edit.selected_index == 0 # opened: that is where the name is typed + assert "needs a name" in nb._design_cells[("condition", "NA")].value + nb.id_inputs[("conditions", "NA")].value = "untreated" + assert not nb.design_confirm.disabled and nb.design_blocker.value == "" + assert "untreated" in nb._design_cells[("condition", "NA")].value + assert "folder NA" in nb._design_cells[("condition", "NA")].value + nb.design_confirm.click() + assert nb.draft_answers.conditions["NA"].id == "untreated" and nb._design_confirmed() + + +def test_a_refused_name_confirms_nothing_and_lists_the_refusal(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + nb.id_inputs[("conditions", "drug")].value = "ctrl" # the ID of the other condition + nb.design_confirm.click() + assert "nothing was confirmed" in _text(nb.design_result.value) + assert not nb._design_confirmed() and not nb.draft_answers.conditions + assert nb.design_edit.selected_index == 0 + + +def test_re_pairing_shows_in_the_grid_and_asks_for_the_click_again(tmp_path): + root, masks = tmp_path / "exp", tmp_path / "masks" + for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/day2"]): + add_folder(root, masks, folder, i + 1) + nb = stepper(root, masks, tmp_path / "out") + nb.quick_confirm.click() + assert "no folder" in nb._design_cells[("drug", "rep2")].value + nb.design_confirm.click() + assert nb._design_confirmed() + (dropdown,) = [ + row.children[2] + for row in nb.pairing_grid.children[1:] + if "day2" in row.children[1].children[0].value + ] + dropdown.value = "rep2" + assert ("drug", "day2") not in nb._design_cells + assert _text(nb._design_cells[("drug", "rep2")].value) == "day2 1 image" + assert nb.draft_answers.pairing == {("drug", "day2"): "rep2"} + + +def test_one_condition_has_no_comparison_column(tmp_path): + root, masks = tmp_path / "exp", tmp_path / "masks" + for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2"]): + add_folder(root, masks, folder, i + 1) + nb = stepper(root, masks, tmp_path / "out") + nb.quick_confirm.click() + assert "Compared against" not in repr(nb.design_table.children) + nb.design_confirm.click() + assert nb._design_confirmed() and not nb.draft_answers.conditions.get("ctrl").reference + + +def test_the_same_answers_publish_the_bytes_the_stacked_pages_publish(exp, tmp_path): # noqa: F811 + root, masks, out = exp + steps = stepper(root, masks, out) + steps.quick_confirm.click() + steps.type_line.apply.click() # separate channels, for all folders + answer_all(steps) # drug against ctrl; the Design click records the rest as shown + assert steps.save_experiment() + pages = opened(root, masks, tmp_path / "pages") # the pages other bricks embed + answer_all(pages) # the same answers, each given by hand + assert pages.save_experiment() + for name in (DESCRIPTION_FILENAME, DESIGN_FILENAME): + assert (out / name).read_bytes() == (tmp_path / "pages" / name).read_bytes(), name + + +# --- (o) masks: what each is, then the mask step once a run recorded them ---------------------- + + +def test_the_mask_step_is_on_masks_and_waits_visibly_for_a_run(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + assert nb.mask_step_button.disabled + assert "Available after a run that recorded masks" in _text(nb.mask_step_hint.value) + assert not ui.is_visible(nb.masks_hint) + answer_all(nb) + assert nb.save_experiment() and nb.run().written + assert not nb.mask_step_button.disabled # offered once the run recorded the masks + assert ui.is_visible(nb.masks_hint) and "Masks (step 4)" in nb.masks_hint.value + assert "relationships described" not in nb._stepper.states()[3].text + assert nb._on_describe_masks() is not None + assert "relationships described" in nb._stepper.states()[3].text + + +# --- shorter, never hidden ---------------------------------------------------------------------- + + +def test_a_long_list_of_missing_answers_is_folded_and_complete(): + lines = [f"answer {i} " for i in range(TODO_SHOWN + 5)] + html = todo_html([], lines) + shown, folded = html.split("
    ") + assert f"Still to answer before saving: {len(lines)}." in shown + assert shown.count("
  • answer ") == TODO_SHOWN - 2 and "and 7 more" in folded + assert all(f"answer {i} <b>" in html for i in range(len(lines))) # each one is there + short = todo_html([], lines[:TODO_SHOWN]) + assert "
    " not in short and short.count("
  • answer ") == TODO_SHOWN diff --git a/tests/test_notebook_stains_na.py b/tests/test_notebook_stains_na.py index fe775176..7777fcf5 100644 --- a/tests/test_notebook_stains_na.py +++ b/tests/test_notebook_stains_na.py @@ -1,7 +1,8 @@ """An all-RGB experiment needs no channel label (issue 240, experiment-v1 section 4.4b). R, G and B are the fixed channel IDs of a unit the user declared ``rgb``; no label applies. So -the Stains step is not applicable when every folder's saved image type is ``rgb``, and Check and +naming the stains (on the Channels step) is not applicable when every folder's saved image type +is ``rgb``, and Check and save completes and saves without one. Only the user's answer counts: a header's colour pre-tick, a mixed experiment or an unanswered image type keeps the labels required. """ @@ -25,8 +26,8 @@ from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 FOLDERS = ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/rep2"] -STAINS = 6 # step 7, 0-based -TAGS = 7 +CHANNELS = 2 # step 3, 0-based: the stains and the tags +STAINS = TAGS = CHANNELS def _open(tmp_path, *, channel_folders=()): @@ -54,7 +55,7 @@ def _answer(nb, *, skip=()): types.radios[layout.key].value = "channels" types.confirm_header([u.key for u in types.layouts if types.proposed(u) and u.key not in skip]) nb.reference_boxes["drug"].value = "ctrl" - nb.reference_boxes["ctrl"].value = "" + nb.design_confirm.click() # the names, and "no comparison" for ctrl, as the Design step shows for toggle in list(nb.same_way.values()): if not toggle.value: toggle.button.click() @@ -68,17 +69,16 @@ def test_an_all_rgb_experiment_completes_stains_and_check_and_save_without_a_lab nb = _open(tmp_path) _answer(nb) stains = _state(nb, STAINS) - assert stains.done and stains.note == "not applicable" - assert stains.text == "not applicable: every folder is colour (RGB)" - assert "not applicable" in nb._stepper.chips[STAINS].description - assert "mb-chip-noted" in nb._stepper.chips[STAINS]._dom_classes + assert stains.done + assert stains.text.startswith("no stains to name: every folder is colour (RGB) · every channel") + assert "mb-chip-done" in nb._stepper.chips[STAINS]._dom_classes assert _state(nb, TAGS).done and not nb.channel_labels.confirmed assert "Not applicable" in nb.channel_labels.na_note.value assert not ui.is_visible(nb.channel_labels.body[0]) assert not ui.is_visible(nb.channel_intro) # "first name what you stained" would contradict assert nb.todo.value == "" and not nb.save_button.disabled assert nb.save_experiment() - assert _state(nb, 9).done + assert _state(nb, 5).done def test_the_saved_file_declares_exactly_r_g_b_and_the_rgb_image_type(tmp_path): @@ -103,7 +103,7 @@ def test_the_header_pre_tick_alone_does_not_make__stains_not_applicable(tmp_path nb = _open(tmp_path) # the headers say colour; nothing is answered assert not nb._stains_not_applicable stains = _state(nb, STAINS) - assert not stains.done and stains.text == "not confirmed" + assert not stains.done and stains.text == "stains not confirmed" assert ui.is_visible(nb.channel_labels.body[0]) assert "Not applicable" not in nb.channel_labels.na_note.value diff --git a/tests/test_notebook_stepper.py b/tests/test_notebook_stepper.py index 01f9041d..2366213c 100644 --- a/tests/test_notebook_stepper.py +++ b/tests/test_notebook_stepper.py @@ -1,4 +1,4 @@ -"""The shipped notebook's stepper: 13 steps, one on screen at a time (PR 17a, 2026-11-09). +"""The shipped notebook's stepper: 7 steps, one on screen at a time (owner, 2026-11-10). Widgets are driven headless, as the user would: the strip's chips, Back and Next, and the existing cards on each page. The stepper is pure composition, so the answers are those of the @@ -24,20 +24,9 @@ ) from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 -ORDER = [ - "Images found", - "Axes", - "Conditions", - "Replicates", - "Compared against", - "Image type", - "Stains", - "Tag channels", - "Masks", - "Check and save", - "Run", - "Describe the masks", -] +ORDER = ["Images", "Axes", "Channels", "Masks", "Design", "Check and save", "Run"] +"""What the files hold first (images, axes, channels, masks), what it means after (design).""" +AXES, CHANNELS, MASKS, DESIGN, SAVE, RUN = range(1, 7) def stepped(root, masks, out, *, axes=True): @@ -130,7 +119,7 @@ def test_the_overview_restates_each_step_with_a_change_button(): assert stepper.current == 2 -# --- the notebook's 13 steps -------------------------------------------------------------------- +# --- the notebook's 7 steps --------------------------------------------------------------------- def test_the_steps_are_in_the_decided_order_with_one_page_on_screen(exp): # noqa: F811 @@ -142,23 +131,32 @@ def test_the_steps_are_in_the_decided_order_with_one_page_on_screen(exp): # noq nb._stepper.chips[1].click() assert shown(nb) == 1 and kinds(nb)[0] == "done" page = nb._stepper.pages[1] - assert "Step 2 of 12 · Axes" in page.children[1].children[0].value + assert "Step 2 of 7 · Axes" in page.children[1].children[0].value def test_the_pages_hold_the_existing_cards_in_the_new_places(exp): # noqa: F811 nb = stepped(*exp) contains = lambda i, w: _holds(nb._stepper.pages[i], w) # noqa: E731 - assert contains(0, nb.check_button) and contains(0, nb.masks_card) - assert contains(1, nb.axis_box) and contains(1, nb.calibration_card) # one step (owner) - assert _holds(nb.axis_box, nb.calibration_card) and contains(1, nb.quick_card) - assert contains(2, nb.condition_rows) and not contains(2, nb.replicate_rows) - assert contains(3, nb.design_card) and contains(3, nb.replicate_rows) - assert contains(4, nb.references_card) and contains(5, nb.type_rows.widget) - assert contains(6, nb.channel_labels.widget) and contains(7, nb.channel_grid.widget) - assert contains(8, nb.masks_box) and contains(9, nb.check_card) - assert contains(9, nb.overview_box) and contains(10, nb.run_button) - assert contains(11, nb.mask_step_card) and not contains(10, nb.mask_step_card) - # The names rows are split between steps 3 and 4, and still the names card's grid. + assert contains(0, nb.check_button) and contains(0, nb.scan_button) + # Axes: the roles, the pixel size and the image type (what the channel dimension holds). + assert contains(AXES, nb.axis_box) and contains(AXES, nb.calibration_card) + assert _holds(nb.axis_box, nb.calibration_card) and contains(AXES, nb.quick_card) + assert contains(AXES, nb.type_line.widget) and contains(AXES, nb.type_rows.widget) + # Channels: the stains, then the tags. + page = nb._stepper.pages[CHANNELS].children[1].children[1].children + assert list(page[-2:]) == [nb.channel_labels.widget, nb.channel_grid.widget] + # Masks: the masks paired, what each is, then the mask step (it needs the run's record). + page = nb._stepper.pages[MASKS].children[1].children[1].children + assert list(page[-3:]) == [nb.masks_card, nb.masks_box, nb.mask_step_card] + assert not contains(0, nb.masks_card) and not contains(RUN, nb.mask_step_card) + # Design: one grid, then the names and the pairing to edit, and the consistency warnings. + assert contains(DESIGN, nb.design_table) and contains(DESIGN, nb.design_confirm) + for card in (nb.condition_rows, nb.replicate_rows, nb.design_card): + assert _holds(nb.design_edit, card) and contains(DESIGN, card) + assert all(_holds(nb.design_table, box) for box in nb.reference_boxes.values()) + assert contains(SAVE, nb.check_card) and contains(SAVE, nb.overview_box) + assert contains(RUN, nb.run_button) and contains(RUN, nb.compare_fold) + # The names rows are still the names card's grid (the pages other bricks embed). (conditions,) = nb.condition_rows.children assert "Condition" in conditions.children[0].children[0].value assert list(nb.names_grid.children) == [conditions, *nb.replicate_rows.children] @@ -170,12 +168,12 @@ def _holds(widget, target): return any(_holds(child, target) for child in getattr(widget, "children", ())) -def test_the_image_type_has_its_own_step_and_the_grid_no_type_column(exp): # noqa: F811 +def test_the_image_type_is_asked_with_the_axes_and_the_grid_has_no_type_column(exp): # noqa: F811 nb = stepped(*exp) for row, text in zip(nb.channel_labels.rows, TAGS, strict=True): row.id_box.value = text nb.channel_labels.confirm.button.click() - assert len(nb.channel_types.radios) == 4 # one per folder, on the image-type step + assert len(nb.channel_types.radios) == 4 # one per folder, under Set per folder… assert not any(_holds(nb.channel_grid.widget, r) for r in nb.channel_types.radios.values()) assert "Not finished: 4 image type(s)" in nb.type_rows.status.value key = next(iter(nb.channel_types.radios)) @@ -199,10 +197,10 @@ def test_moving_through_every_page_keeps_every_answer(exp): # noqa: F811 chip.click() assert snapshot(nb) == before assert nb.draft_answers.conditions["drug"].display_name == "Drug A" - assert all(k in ("done", "noted") for k in kinds(nb)[1:9]) and kinds(nb)[0] == "current" - assert kinds(nb)[9] == "todo" # not saved yet + assert all(k in ("done", "noted") for k in kinds(nb)[1:SAVE]) and kinds(nb)[0] == "current" + assert kinds(nb)[SAVE] == "todo" # not saved yet assert nb.save_experiment() - assert kinds(nb)[9] == "done" + assert kinds(nb)[SAVE] == "done" def test_a_reopened_experiment_opens_on_the_axes_its_only_open_step(exp): # noqa: F811 @@ -210,15 +208,16 @@ def test_a_reopened_experiment_opens_on_the_axes_its_only_open_step(exp): # noq answer_all(first) assert first.save_experiment() nb = stepped(*exp, axes=False) - assert nb._stepper.current == 1 and shown(nb) == 1 + assert nb._stepper.current == AXES and shown(nb) == AXES nb.preview_checked.set(True) nb.show_preview() nb.confirm_button.click() - assert all(k in ("done", "noted") for k in kinds(nb)[:10] if k != "current") - assert "saved " in nb._stepper.chips[9].tooltip + assert all(k in ("done", "noted") for k in kinds(nb)[:RUN] if k != "current") + assert "saved " in nb._stepper.chips[SAVE].tooltip + assert nb._design_confirmed() and not ui.is_visible(nb.design_confirm) # as saved # The saved reopening folds the cards; a page that shows one unfolds it. assert nb._folded and not ui.is_visible(nb.design_card) - nb._stepper.chips[3].click() + nb._stepper.chips[DESIGN].click() assert not nb._folded and ui.is_visible(nb.design_card) @@ -233,10 +232,11 @@ def test_a_changed_reopen_turns_the_affected_steps_orange(tmp_path): nb = stepped(root, masks, out) states = nb._stepper.states() assert "since saved: 1 new, 0 gone" in states[0].text - assert states[7].changed and states[9].changed # the new folder's tags; save again - assert not states[2].changed and states[2].done # the conditions did not change - assert kinds(nb)[7] == "changed" and kinds(nb)[9] == "changed" - assert nb._stepper.current == 1 # landed on the axes: each session confirms them again + assert states[CHANNELS].changed and states[SAVE].changed # the new folder's tags; save again + assert states[AXES].changed and "image type: 1 to choose" in states[AXES].text + assert not states[DESIGN].changed and states[DESIGN].done # the names did not change + assert kinds(nb)[CHANNELS] == "changed" and kinds(nb)[SAVE] == "changed" + assert nb._stepper.current == AXES # landed on the axes: each session confirms them again def test_consistency_follows_the_pairing_and_shows_only_when_it_warns(tmp_path): @@ -244,10 +244,10 @@ def test_consistency_follows_the_pairing_and_shows_only_when_it_warns(tmp_path): for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/day2"]): add_folder(root, masks, folder, i + 1) nb = stepped(root, masks, out) - assert _holds(nb._stepper.pages[3], nb.consistency_card) + assert _holds(nb._stepper.pages[DESIGN], nb.consistency_card) assert not _holds(nb._stepper.pages[0], nb.consistency_card) assert ui.is_visible(nb.consistency_slot) # rep2 and day2 are each in one condition only - assert "on Replicates" in nb._stepper.waits[0].value # step 1 says where + assert "on Design" in nb._stepper.waits[0].value # step 1 says where (dropdown,) = [ row.children[2] for row in nb.pairing_grid.children[1:] @@ -256,7 +256,7 @@ def test_consistency_follows_the_pairing_and_shows_only_when_it_warns(tmp_path): dropdown.value = "rep2" assert not ui.is_visible(nb.consistency_slot) assert nb._stepper.waits[0].value == "" - assert "drug/day2 = rep2" in nb._stepper.states()[3].text + assert "drug/day2 = rep2" in nb._stepper.states()[DESIGN].text def test_the_axes_page_has_no_stale_image_type_sentence_and_no_broken_picture(exp): # noqa: F811 @@ -277,9 +277,13 @@ def test_the_axes_page_has_no_stale_image_type_sentence_and_no_broken_picture(ex def test_pixel_sizes_without_a_physical_size_say_uncalibrated(exp): # noqa: F811 nb = stepped(*exp) - state = nb._stepper.states()[1] # the pixel size is part of the axes step + state = nb._stepper.states()[AXES] # the pixel size is part of the axes step + assert not state.done and "image type: 4 to choose" in state.text # and the image type + nb.type_line.apply.click() + state = nb._stepper.states()[AXES] assert state.done and state.note == "uncalibrated" assert "confirmed · uncalibrated (pixels)" in state.text + assert state.text.endswith("separate channels (4 folders)") nb._stepper.go(0) assert nb._stepper.chips[1].description == "✓ 2 Axes · uncalibrated" assert kinds(nb)[1] == "noted" @@ -298,6 +302,8 @@ def test_pixel_sizes_from_the_headers_are_a_plain_done(tmp_path): nb.check_button.click() assert not nb._stepper.states()[1].done # the axes are not confirmed yet nb.quick_confirm.click() + assert not nb._stepper.states()[1].done # the image type is still a proposal + nb.type_line.apply.click() nb._stepper.go(0) state = nb._stepper.states()[1] assert state.done and not state.note and "0.65" in state.text diff --git a/tests/test_notebook_tag_blocks.py b/tests/test_notebook_tag_blocks.py index 8108c2ca..9a1e3aeb 100644 --- a/tests/test_notebook_tag_blocks.py +++ b/tests/test_notebook_tag_blocks.py @@ -136,12 +136,12 @@ def test_colour_is_confirmed_on_image_type_only_and_the_tag_page_restates_it(tmp grid = nb.channel_grid assert not grid.confirm_buttons # no second confirmation of the colour pre-tick text = repr(grid.blocks.children) - assert "confirm it on Image type" in text + assert "confirm it on Axes (Image type)" in text buttons = [w for w in nb.type_rows.rows.children if isinstance(w, widgets.Button)] (button,) = [w for w in buttons if "colour (RGB) is right" in w.description] button.click() assert all(nb.draft_answers.units[k].image_type == "rgb" for k in KEYS) - assert "Answered colour (RGB) (Image type)" in repr(grid.blocks.children) + assert "Answered colour (RGB) (Axes)" in repr(grid.blocks.children) assert not grid.cells and grid.complete