From 94b8214beb683f1a514ab4735c6fdf7695bd4f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Hidalgo?= <47150974+IvanHCenalmor@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:06:53 +0200 Subject: [PATCH] Add the image type, fluorescence or RGB, to step 2 and the manifest (P4) Step 2 asks for the image type once the axis roles are confirmed, with RGB suggested for TIFF images stored as RGB colour on the channel axis and confirmed with a yellow question; RGB needs three integer channels (the normalization brick's check_rgb). The manifest records image_type and channels_from_rgb_samples as optional keys (version 1); changing the type recomputes nothing. The command line takes --image-type or prints the suggestion. Plan question 29. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 24 +- README.md | 8 +- docs/contract.md | 20 +- docs/plan.md | 28 +++ docs/user_guide.md | 48 +++- notebooks/MorphoBricks_Preprocessing.ipynb | 20 +- src/morphobricks_preprocessing/__init__.py | 16 ++ src/morphobricks_preprocessing/axes.py | 9 +- src/morphobricks_preprocessing/cli.py | 38 ++- src/morphobricks_preprocessing/image_type.py | 150 ++++++++++++ src/morphobricks_preprocessing/manifest.py | 22 +- src/morphobricks_preprocessing/metadata.py | 17 ++ .../notebook_style.py | 12 + src/morphobricks_preprocessing/notebook_ui.py | 132 ++++++++++- src/morphobricks_preprocessing/report.py | 2 + src/morphobricks_preprocessing/run.py | 27 ++- tests/test_cli.py | 55 +++++ tests/test_image_type.py | 218 ++++++++++++++++++ tests/test_manifest.py | 13 ++ tests/test_notebook_ui.py | 103 +++++++++ 20 files changed, 937 insertions(+), 25 deletions(-) create mode 100644 src/morphobricks_preprocessing/image_type.py create mode 100644 tests/test_image_type.py diff --git a/AGENTS.md b/AGENTS.md index 14b9a4e5..d137f545 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,9 @@ src/morphobricks_preprocessing/ table, unbalanced_images, repeat_missing_conditions, CONSISTENCY_CODES (P2) design.py experiment design (P3): ExperimentDesign/DesignRow, default pairing, reconcile with the folders on reruns, design_issues, read/write experiment_design.csv, load_design + image_type.py image type (P4): FLUORESCENCE/RGB, review_image_type (suggestion from + TiffMetadata.rgb_samples and the confirmed roles, whether RGB is possible via the + brick's check_rgb), image_type_issues (rgb_not_possible) validation.py ValidationIssue (severity, code, message, paths, action), error()/warning() builders axes.py AxisMapping (role per dimension), prefill, check_mapping, review_axes (one mapping applied to every file, missing axes as singletons only when confirmed) @@ -368,6 +371,21 @@ functions the tests use. reconciled template and exits 1. **API** `run_experiment(design=None)` is optional; with a design it is written with the table and `experiment_design_sha256` goes in the manifest. +### Image type (contract §13, follow-up P4) + +- **Types:** `fluorescence` and `rgb`; RGB needs three channels of an integer type + (the normalization brick's `check_rgb`), else `rgb_not_possible` in a run. +- **Suggestion:** `rgb` only when every analysed image is a TIFF stored as RGB colour + (`TiffMetadata.rgb_samples`: photometric RGB, three samples per pixel, axis `S`), the + confirmed C role is on `S`, and RGB is possible. Never applied without confirmation. +- **Notebook:** step 2's "Image type" card appears once the axis roles are confirmed + (also when embedded); radio buttons with the suggestion preselected, disabled at + fluorescence when RGB is impossible; `ConfirmToggle` **Yes, this is the right image + type**; changing roles or folder resets it; step 3 gated on `confirmed_image_type`. +- **CLI:** `--image-type`, else the suggestion, printed with its reason. +- **Manifest:** optional top-level `image_type` (null without one) and + `channels_from_rgb_samples`; changing the type recomputes nothing. + ### Validation messages (contract §8) Every expected problem is a `ValidationIssue` with: @@ -407,6 +425,7 @@ Technical details go to `logging` (shown in the notebook's "Technical details" p | Normalization brick (P1) | The modules `dtypes`, `histogram`, `records`, `csv_io`, `pooling` and `normalization` moved unchanged to `morphobricks_normalization` (normalization plan N1, N2, N6) and are imported from there. Their public names stay importable from `morphobricks_preprocessing` and from the old submodules for one version: module `__getattr__` stand-ins (`_moved.py`), one `DeprecationWarning` per old path naming `morphobricks_normalization..`, same objects. The package's own code never uses the old paths. Outputs unchanged (CSV schema 2); the manifest's `versions` adds `morphobricks-normalization`; Step 0 checks for it; contract §3–§7 text moved to the normalization contract (§1.2, now §6.2 for the CSV) | | Dataset consistency (Q21, P2) | Step 1 "Dataset consistency" panel: balance table (images and technical-repeat folders per condition x biological repeat), `unbalanced_images` (< 0.5 or > 2 x median), `repeat_missing_conditions`, `different_pixel_sizes`, `missing_pixel_size` (OME-Zarr scale included), `different_plane_counts`; `different_sizes` narrowed to width/height; consistency issues shown only in that panel, not in Messages. Warnings only; outputs unchanged | | Experiment design (Q22, P3) | Step 1 after the labels: pairing dropdowns, one yellow "Yes, they are comparable" question per repeat group (single whole-experiment question without biological-repeat folders), control checkboxes; `experiment_design.csv` written after every answer; reruns load it and fold the questions behind a Change button; step 3 gated. CLI `run` reads it or `--design`, else writes a template and exits 1. `run_experiment(design=...)` optional; manifest `experiment_design_sha256` (optional key, version 1). Two folders of one condition in a group block that group | +| Image type (Q29, P4) | Step 2 card after the axis roles: Fluorescence or RGB, suggestion preselected, confirmed with **Yes, this is the right image type**; RGB suggested for TIFF photometric RGB with three samples on the C axis; RGB needs three integer channels (`check_rgb`), else disabled with the reason and `rgb_not_possible` in a run. Manifest keys `image_type`, `channels_from_rgb_samples` (optional, version 1); type changes recompute nothing. CLI `--image-type`, default the printed suggestion | | Position identity (Q20, D02) | `position_id` = image name without extension, in every CSV row and manifest file entry; FOV key = labels + `position_id`; duplicates in one leaf folder are the error `duplicate_position_id`. CSV columns and schema 2 unchanged; manifest version 1 with an optional key | | Notebook style | Follow the lab's NucleiSky and LabChronicle notebooks (see §7) | | Widget widths | **99%**, never 100% | @@ -695,8 +714,9 @@ jupyter lab notebooks/MorphoBricks_Preprocessing.ipynb - **Released as v0.1.0** (27 October 2026), with everything above; the normalization brick is pinned to its v0.2.0 (question 28). The next tag, v0.2.0, follows follow-ups P4 and P5. -- **Next (not started):** follow-ups P4–P5 of the normalization plan (image type, - intensity checks and plots). +- **Since v0.1.0:** follow-up P4 of the normalization plan, the image type + (question 29). +- **Next:** follow-up P5 of the normalization plan (intensity checks and plots). - **Not yet verified:** - the exact look of the notebook in a real JupyterLab; - the optional `jl-hidecode` JupyterLab extension, which keeps code cells collapsed. It diff --git a/README.md b/README.md index 0bebd06d..37ad4649 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Pre-alpha. Implemented and tested: blocks with identical results (`planes`) - The experiment design: pairing biological repeats across conditions, confirming they are comparable, and marking controls, in `experiment_design.csv` (`design`) +- The image type, fluorescence or RGB: suggested from the metadata (RGB colour samples), + confirmed by the user and recorded in the run manifest (`image_type`) - Running an experiment end to end and updating earlier output incrementally, with a run manifest (`run`, `manifest`) - A step-by-step notebook ([notebooks/MorphoBricks_Preprocessing.ipynb](notebooks/MorphoBricks_Preprocessing.ipynb)) @@ -70,7 +72,7 @@ From a terminal: ```bash morphobricks-preprocessing check /data/exp1 -morphobricks-preprocessing run /data/exp1 --axes ZCYX +morphobricks-preprocessing run /data/exp1 --axes ZCYX --image-type fluorescence ``` ## Running an experiment @@ -92,7 +94,9 @@ recomputes everything, and `exclude=[...]` leaves out damaged files. The noteboo command line also ask for the experiment design (which biological repeats pair across conditions, and that they are comparable) and write `experiment_design.csv`; in Python, pass it with `design=` (see the [user guide](docs/user_guide.md) §9). Later bricks read it -with `read_experiment_design`. +with `read_experiment_design`. They also ask for the image type, `fluorescence` or `rgb` +(suggested by `review_image_type`), and record it in the manifest; in Python, pass it with +`image_type=`. ## Example diff --git a/docs/contract.md b/docs/contract.md index 0a469a1d..b85d83f1 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -107,7 +107,7 @@ method is an offset and a scale, `(pixels − offset) / scale`. | Severity | Situations | Effect | |---|---|---| -| Error | Experiment design not confirmed (`design_not_confirmed`) or a repeat group holding two folders of one condition (`repeat_group_conflict`), when a design is used (§12); mixed dtypes; images with different numbers of channels (`different_channel_counts`, §2); unreadable TIFF (header or pixel data) or OME-Zarr folder in a leaf folder that is not excluded; OME-Zarr images, or converting TIFF images to OME-Zarr, without the `zarr` extra; OME-Zarr images whose declared axes do not fit the experiment's roles; every image excluded; more than one image series in a file; unsupported sample format or axis; missing/duplicate X or Y, duplicate roles, unassigned non-singleton axis, inconsistent mapping; plane that cannot be read within the memory limit, not even in blocks (for example a plane stored compressed in one piece); folder named `single` in another case, or beside other folders (§1); sibling folders differing only in case; images in one leaf folder with the same position identifier (§1); folder below the technical-repeat level; output folder inside the experiment; no usable planes | Processing is blocked | +| Error | Experiment design not confirmed (`design_not_confirmed`) or a repeat group holding two folders of one condition (`repeat_group_conflict`), when a design is used (§12); image type RGB for images that are not three channels of an integer type (`rgb_not_possible`, §13); mixed dtypes; images with different numbers of channels (`different_channel_counts`, §2); unreadable TIFF (header or pixel data) or OME-Zarr folder in a leaf folder that is not excluded; OME-Zarr images, or converting TIFF images to OME-Zarr, without the `zarr` extra; OME-Zarr images whose declared axes do not fit the experiment's roles; every image excluded; more than one image series in a file; unsupported sample format or axis; missing/duplicate X or Y, duplicate roles, unassigned non-singleton axis, inconsistent mapping; plane that cannot be read within the memory limit, not even in blocks (for example a plane stored compressed in one piece); folder named `single` in another case, or beside other folders (§1); sibling folders differing only in case; images in one leaf folder with the same position identifier (§1); folder below the technical-repeat level; output folder inside the experiment; no usable planes | Processing is blocked | | Warning | Files that are not images; empty folders; images outside leaf folders; images excluded by the user, or excluded paths that match no image; channel names differing between files; non-finite pixels; different image widths or heights; different numbers of depth slices, time points or other planes; physical pixel sizes that differ or are known for only some images; unbalanced numbers of images; biological repeats missing from some conditions (§11); planes without finite pixels while others remain; planes over the memory limit that are read in blocks | Processing continues; the item is reported | Every message states what happened in plain language, lists the affected paths, and @@ -150,6 +150,8 @@ file's schema or statistical interpretation. | `files` | One entry per analysed file, sorted by path: `relative_path`, `size` (bytes), `mtime_ns`, the three labels, `axes`, `singleton_roles`, `plane_count` (its rows in the table: XYC planes), `channel_names` (list, null for unnamed channels; empty if none in metadata), `significant_bits` (null if not in metadata), `ome_zarr_path` (for a TIFF converted to OME-Zarr, the copy relative to the output folder; null otherwise or when the key is absent), `read_in_blocks` (true when the image's planes are over the memory limit and are read in blocks; information only, false when absent), `position_id` (the image's position identifier, §1; null when absent, in manifests written before it). For an OME-Zarr image, `size` is the total size of its files and `mtime_ns` the time of its newest file. | | `csv_sha256` | SHA-256 of the table written with this manifest. | | `experiment_design_sha256` | SHA-256 of the `experiment_design.csv` written with this run (§12), so later bricks can check that the design they read is the one used; null when the run had no design, or absent in manifests written before it. Optional key: the manifest stays version 1. | +| `image_type` | `fluorescence` or `rgb`, the image type the user confirmed (§13); null when the run was given none, or absent in manifests written before it. Optional key: the manifest stays version 1. Changing it recomputes nothing. | +| `channels_from_rgb_samples` | `true` when the C role is the RGB samples axis of images stored as RGB colour (§13), `false` otherwise; absent in manifests written before it. Optional key. | | `versions` | Versions of `morphobricks-preprocessing`, `morphobricks-normalization` (which computes the histograms and writes the CSV; absent in manifests written before it), `numpy`, `tifffile` and Python. Information only: a different version never causes a rebuild. | Channel names and significant bits come from OME metadata and are for information only. @@ -239,3 +241,19 @@ folder. | Notebook | Step 1 asks once the labels are confirmed and writes the file after every answer (unconfirmed groups with `confirmed=false`). A design confirmed for the same folders is loaded with the questions folded away behind **Change the experiment design**. Step 3 runs only once every group is confirmed. | | Command line | `run` reads the file from the output folder, or `--design FILE`. Without a fully confirmed design it writes the design brought up to date with the folders (a template with `confirmed=false` for the groups still to confirm, keeping the answers already given) to the output folder, explains the columns, and stops with status 1. Confirmed rows without a date get the date of the run. | | Run | `run_experiment(..., design=...)` brings the design up to date with the folders and stops with `design_not_confirmed` or `repeat_group_conflict` if it is not fully confirmed. Otherwise the file is written with the table and published before the manifest, which records its SHA-256 (§9). Without a design the run is unchanged: no file is written, an existing one is left as it is, and `experiment_design_sha256` is null. `plane_histograms.csv` does not depend on the design. | + +## 13. Image type + +Later bricks normalize `fluorescence` images channel by channel and `rgb` images with +StarDist's percentile method over the three colour channels together ([normalization +contract][normalization contract] §3.3 and §5). The type is the user's choice (normalization +plan N34); this brick suggests it, asks for it after the axis roles, and records it. + +| Rule | Detail | +|---|---| +| Types | `fluorescence` (any number of channels, every pixel type) and `rgb` (exactly three channels of an integer pixel type, the rule of the normalization brick's `check_rgb`). | +| RGB samples | A TIFF image is stored as RGB colour when its first series has the photometric interpretation RGB with three samples per pixel (`tifffile` axis `S`; `TiffMetadata.rgb_samples`). RGB with an alpha sample, several grey samples and OME-Zarr images are not. | +| Suggestion | `rgb` when every analysed image is stored as RGB colour, the confirmed C role is on its samples axis, and `rgb` is possible; `fluorescence` otherwise. `review_image_type` gives the suggestion, why, and why `rgb` is not possible (from the headers and the confirmed axis roles; no pixels are read). | +| Confirmation | Never applied without the user's confirmation. The notebook asks in step 2 once the axis roles are confirmed, with the suggestion preselected and a **Yes, this is the right image type** button; `rgb` cannot be chosen, with the reason shown, when it is not possible. Changing the axis roles or the folder asks again. Step 3 runs only once the type is confirmed. The command line takes `--image-type`, or uses the suggestion and prints it. | +| Run | `run_experiment(..., image_type=...)` records the type and `channels_from_rgb_samples` in the manifest (§9). `rgb` for images that cannot be RGB is the error `rgb_not_possible`; any other value raises `ValueError`. Without a type the manifest records null. | +| Outputs | `plane_histograms.csv` and `experiment_design.csv` do not depend on the type: a plane of an RGB image is one row with channels C0, C1 and C2, as for any image. Changing the type on a later run reads no image again. | diff --git a/docs/plan.md b/docs/plan.md index f9e283ef..0046ad45 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -340,6 +340,12 @@ API, CLI). The notebook and CLI only call the API. columns; its channels read one at a time (`planes.iter_xyc_planes`); images with different numbers of channels are an error before any image is read; earlier tables are recomputed on the next run. *Implemented, see question 27.* +- **Image type (follow-up P4 of the normalization plan):** after the axis roles, step 2 + asks whether the images are fluorescence or RGB, suggesting RGB for TIFF images stored as + RGB colour on the channel axis (`image_type`, `metadata.TiffMetadata.rgb_samples`); RGB + needs three channels of an integer type. The manifest records the type; the command line + takes `--image-type` or prints the suggestion. Outputs are otherwise unchanged. + *Implemented, see question 29.* ## 11. Tests and acceptance criteria @@ -691,3 +697,25 @@ Raised during review; to be decided before the milestones that need them: depends on the normalization brick's release v0.2.0 (tag, not commit) instead of the Milestone 2b merge commit `0e53e67`; v0.2.0 adds only RGB and intensity checks, which this brick does not call yet. The next tag, v0.2.0, follows follow-ups P4 and P5.* +29. How is the image type, fluorescence or RGB, chosen and recorded (normalization plan + N33, N34, its contract §5, follow-up P4)? + *Decided (27 October 2026): after the axis roles are confirmed, step 2 shows an + "Image type" card with the two types, the suggestion preselected and the yellow question + "Is this the right image type for your images?" answered with **Yes, this is the right + image type** (Undo to change it); nothing is used until it is confirmed, and step 3 stays + locked until then. RGB is suggested when every analysed image is a TIFF stored as RGB + colour (photometric RGB, three samples per pixel, read from the header as + `TiffMetadata.rgb_samples`), the confirmed C role is on its samples axis, and RGB is + possible; fluorescence otherwise (OME-Zarr images, grey samples, RGB with alpha). RGB + needs exactly three channels of an integer pixel type, the rule of the normalization + brick's `check_rgb`; otherwise the choice stays at fluorescence with the reason shown + (ipywidgets cannot disable one radio button, so the whole choice is disabled), and + `run_experiment(image_type="rgb")` is the error `rgb_not_possible`. Changing the axis + roles or the folder asks again; the card is the same when step 2 is embedded by another + brick (question 26). Agreed with the owner: the manifest gains two optional top-level + keys, `image_type` (null when the run was given none, as `run_experiment` takes it + optionally like the design of question 22) and `channels_from_rgb_samples`; it stays + version 1. Changing only the type recomputes nothing. The command line takes + `--image-type fluorescence|rgb`, or uses the suggestion and prints it with the reason; + `check` prints the suggestion. `plane_histograms.csv` and `experiment_design.csv` do not + change.* diff --git a/docs/user_guide.md b/docs/user_guide.md index 360d5f91..f3315569 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -192,10 +192,25 @@ results or comparing another grouping. 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. - - **What will happen** names the output folder and files, and says whether an earlier - run was found and what will be read. Once the axis roles are confirmed it also names + - **What will happen** names the output folder and files and the image type, and says + whether an earlier run was found and what will be read. 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). @@ -273,6 +288,12 @@ morphobricks-preprocessing run /data/exp1 --axes ZCYX `T`, or `-` for a length-1 dimension without a role. Roles are never taken from metadata without being stated. +`--image-type fluorescence` or `--image-type rgb` gives the image type (§4, step 2). +Without it, `run` uses the type suggested from the image headers and prints it, with why it +was suggested and how to choose the other one; `check` prints the suggestion too. `rgb` +for images that are not three channels of a whole-number pixel type stops the run +(`rgb_not_possible`). + `run` also needs the experiment design (§4, step 1). It reads `experiment_design.csv` from the output folder (or the file given with `--design`). The first time, or when folders were added, it writes that file and stops, so you can fill it in with a spreadsheet program or a @@ -288,6 +309,7 @@ the design is confirmed. Other options: | `--bins N` | Bins per plane | | `--memory-limit SIZE` | Working-memory limit per plane, e.g. `2GiB` | | `--missing-as-singleton` | Confirm that images lacking some dimensions have one plane along them | +| `--image-type fluorescence\|rgb` | The image type (default: the suggested one, printed) | | `--design FILE` | The confirmed experiment design (default: `experiment_design.csv` in the output folder); it is copied to the output folder | | `--force-rebuild` | Recompute every row | | `--convert-tiff` | Convert the TIFF images to OME-Zarr first and process the copies (needs the `zarr` extra); prints the disk space needed | @@ -322,8 +344,9 @@ The default output folder is next to the experiment folder: Later bricks read the pairing from it (`read_experiment_design`). Format in [contract.md](contract.md) §12. - **`run_manifest.json`:** what the run read and with which settings: labels, axis roles, - bin count, memory limit, excluded images, package versions, the fingerprint of the - experiment design, and each image's position, size and modification time. Channel names and significant bits are recorded when the metadata + the image type (and whether the channels are RGB colour samples), bin count, memory + limit, excluded images, package versions, the fingerprint of the experiment design, and + each image's position, size and modification time. Channel names and significant bits are recorded when the metadata provides them, and whether the image's planes were too large to read whole and are read in blocks. Format in [contract.md](contract.md) §9. - **`ome_zarr/`** (only when TIFF images are converted): one `.ome.zarr` folder per TIFF @@ -356,7 +379,9 @@ Run the notebook or `run` again after adding, changing or removing images: added); - everything is recomputed when the bin count, the axis roles or the pixel type changed, when the table was edited by hand or written by an earlier version of the brick, or when - you ask for it. + you ask for it; +- changing only the image type reads nothing again: the histograms are the same for both + types, and only the manifest records the new type. To leave out images, for example a damaged file, list them under *Exclude images* (one per line) or pass `--exclude`. Excluded images are not read, are listed in the messages and the @@ -405,6 +430,7 @@ continues. | `unbalanced_images` | Warning | A condition and biological repeat has fewer than half or more than twice the median number of images. Check for missing, misplaced or duplicated images. | | `repeat_missing_conditions` | Warning | A biological repeat is not found in every condition. It is still used. If it has another folder name in some condition, pair it in the experiment design (step 1). | | `design_not_confirmed` | Error | Some repeat groups are not confirmed as comparable. Confirm them in step 1, or set `confirmed` to `true` in `experiment_design.csv`. | +| `rgb_not_possible` | Error | The image type RGB was chosen, but the images are not three channels of a whole-number pixel type. Choose fluorescence, or check the axis roles: the RGB samples must have the role C. | | `repeat_group_conflict` | Error | A repeat group holds two folders of the same condition. Move one into another group. | | `channel_names_differ` | Warning | Channel names differ between images at the same position. Check the channel order. | | `non_finite_pixels` | Warning | NaN or infinite pixels are left out of the histograms and counted. | @@ -443,6 +469,18 @@ pairing = design.repeat_groups() # {(condition, biological_repeat): repeat_grou Later bricks read the file with `read_experiment_design(path)`. +The image type is optional too: `run_experiment(..., image_type="rgb")` records it in the +manifest, and without it the manifest records none. `review_image_type` gives the +suggestion and whether RGB is possible, from the headers and the axis roles: + +```python +from morphobricks_preprocessing import review_axes, review_image_type + +suggestion = review_image_type(check.metadata, review_axes(check.metadata, mapping)) +print(suggestion.suggested, suggestion.reason, suggestion.rgb_problem) +result = run_experiment("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/data/exp1", mapping, image_type=suggestion.suggested) +``` + Reading the table, group statistics and normalisation parameters come from the normalization brick (`morphobricks_normalization`, installed with this package): `read_plane_histograms`, `PooledHistogram`, `group_records`, `min_max`, diff --git a/notebooks/MorphoBricks_Preprocessing.ipynb b/notebooks/MorphoBricks_Preprocessing.ipynb index 98b21eae..2369e3c2 100644 --- a/notebooks/MorphoBricks_Preprocessing.ipynb +++ b/notebooks/MorphoBricks_Preprocessing.ipynb @@ -21,7 +21,8 @@ "- **Asks you about the experiment design:** which biological repeats belong together across\n", " conditions, whether they are comparable, and which conditions are controls.\n", "- **Asks you to confirm how the images are organised:** which dimension is depth, channel or\n", - " time. It shows a preview so you can check.\n", + " time. It shows a preview so you can check. Then it asks whether they are fluorescence or RGB\n", + " colour images.\n", "- **Computes the histograms** and writes them, with a record of what was done, to a separate\n", " output folder.\n", "- **Shows pooled statistics** per condition, repeat or image.\n", @@ -33,7 +34,8 @@ " messages and, if the labels are right, press **Yes, the labels are correct**. Then confirm the\n", " biological repeats with **Yes, they are comparable**.\n", "3. **Step 2:** check the role of each axis, press **Show preview**, press\n", - " **Yes, the preview looks right** and then **Confirm axis roles**.\n", + " **Yes, the preview looks right** and then **Confirm axis roles**. Check the suggested image\n", + " type and press **Yes, this is the right image type**.\n", "4. **Step 3:** press **Compute histograms**. This writes the table the next bricks need.\n", "5. **Step 4 (optional):** press **Calculate statistics** to compare your groups.\n", "\n", @@ -289,9 +291,18 @@ "3. If the preview looks right, press **Yes, the preview looks right** in the yellow box under the\n", " picture. Changing a role afterwards asks you to check the preview again.\n", "4. Press **Confirm axis roles**. The roles are locked until you press **Change axis roles**.\n", + "5. Under **Image type**, check the suggestion: **Fluorescence** (each channel is a separate stain\n", + " or marker) or **RGB** (colour images, such as brightfield or histology stains). RGB is suggested\n", + " when the images are stored as RGB colour. If the suggestion is right, press\n", + " **Yes, this is the right image type**; otherwise choose the other type first. Later bricks\n", + " normalise the two types differently; the histograms are the same. Step 3 only runs once the\n", + " type is confirmed.\n", "\n", "> 💡 **Tip:** if some images lack a dimension the others have (often a single channel), a yellow box\n", - "> lists them. Press **Yes, they have a single plane there** if that is the case." + "> lists them. Press **Yes, they have a single plane there** if that is the case.\n", + ">\n", + "> ℹ️ RGB can only be chosen for images with exactly three channels of a whole-number pixel type\n", + "> (8- or 16-bit colour); otherwise the card says why." ] }, { @@ -449,6 +460,9 @@ "**\"The physical pixel sizes in the image metadata differ\"**: the images may come from different\n", "objectives. Nothing to do if that is intended; otherwise check the microscope settings.\n", "\n", + "**\"RGB images need exactly three colour channels of a whole-number pixel type\"**: choose\n", + "**Fluorescence** in step 2, or check the axis roles: the RGB samples must have the role C.\n", + "\n", "**The preview looks like stripes**: X or Y is on the wrong dimension; change the roles in step 2.\n", "\n", "More help: the [user guide](../docs/user_guide.md)." diff --git a/src/morphobricks_preprocessing/__init__.py b/src/morphobricks_preprocessing/__init__.py index 5fe27b65..f536eb4f 100644 --- a/src/morphobricks_preprocessing/__init__.py +++ b/src/morphobricks_preprocessing/__init__.py @@ -57,6 +57,15 @@ discover_experiment, position_id, ) +from .image_type import ( + FLUORESCENCE, + IMAGE_TYPES, + RGB, + ImageTypeReview, + check_image_type, + image_type_issues, + review_image_type, +) from .manifest import ( MANIFEST_FILENAME, FileEntry, @@ -140,8 +149,11 @@ def __dir__() -> list[str]: "DESIGN_COLUMNS", "DESIGN_FILENAME", "DESIGN_SCHEMA_VERSION", + "FLUORESCENCE", + "IMAGE_TYPES", "MANIFEST_FILENAME", "OUTPUT_FILENAME", + "RGB", "SINGLE", "AxisMapping", "AxisReview", @@ -161,6 +173,7 @@ def __dir__() -> list[str]: "FileEntry", "GroupSummary", "ImageMetadata", + "ImageTypeReview", "LeafFolder", "ManifestFormatError", "MetadataReport", @@ -193,6 +206,7 @@ def __dir__() -> list[str]: "balance_table", "biological_repeats", "check_dataset", + "check_image_type", "check_mapping", "check_metadata", "check_output_folder", @@ -211,6 +225,7 @@ def __dir__() -> list[str]: "group_records", "group_summaries", "has_errors", + "image_type_issues", "inspect_experiment", "inspect_image", "inspect_images", @@ -242,6 +257,7 @@ def __dir__() -> list[str]: "reconcile_design", "reference_axes", "review_axes", + "review_image_type", "robust_z_score", "run_experiment", "sample_planes", diff --git a/src/morphobricks_preprocessing/axes.py b/src/morphobricks_preprocessing/axes.py index 61bd028f..20584f46 100644 --- a/src/morphobricks_preprocessing/axes.py +++ b/src/morphobricks_preprocessing/axes.py @@ -574,7 +574,7 @@ def review_axes( issues.extend( channel_count_issues( { - f.relative_path: _channel_count(f, file_mappings[f.relative_path]) + f.relative_path: channel_count(f, file_mappings[f.relative_path]) for f in readable if f.relative_path in file_mappings } @@ -583,8 +583,11 @@ def review_axes( return AxisReview(mapping=mapping, file_mappings=file_mappings, issues=tuple(issues)) -def _channel_count(f: TiffMetadata, mapping: AxisMapping) -> int: - """Return the number of channels of ``f`` under its own mapping; 1 without a C axis.""" +def channel_count(f: TiffMetadata, mapping: AxisMapping) -> int: + """Return the number of channels of ``f`` under its own mapping; 1 without a C axis. + + ``mapping`` is the file's own mapping (:attr:`AxisReview.file_mappings`). + """ c = mapping.index_of("C") return 1 if c is None else f.shape[c] diff --git a/src/morphobricks_preprocessing/cli.py b/src/morphobricks_preprocessing/cli.py index 4f940f31..f3ff9a66 100644 --- a/src/morphobricks_preprocessing/cli.py +++ b/src/morphobricks_preprocessing/cli.py @@ -6,6 +6,8 @@ are never taken from metadata without being stated. It also needs the experiment design confirmed in ``experiment_design.csv``: without it, ``run`` writes a template to the output folder for the user to fill in, and stops. +The image type (fluorescence or RGB) is given with ``--image-type``, or the +suggestion from the image headers is used and printed. Both exit with status 1 when an error blocks processing. """ @@ -19,7 +21,7 @@ from datetime import date from pathlib import Path -from .axes import AxisMapping, reference_axes +from .axes import AxisMapping, reference_axes, review_axes from .design import ( DESIGN_FILENAME, DesignFormatError, @@ -31,6 +33,7 @@ write_experiment_design, ) from .discovery import check_output_folder, default_output_folder +from .image_type import IMAGE_TYPES, RGB, ImageTypeReview, review_image_type from .planes import DEFAULT_MEMORY_LIMIT, format_bytes from .report import ( balance_summary, @@ -132,6 +135,12 @@ def build_parser() -> argparse.ArgumentParser: help="the confirmed experiment design (default: experiment_design.csv in the output " "folder); without a confirmed design, run writes a template there and stops", ) + run.add_argument( + "--image-type", + choices=IMAGE_TYPES, + help="fluorescence (each channel a separate stain) or rgb (colour images with " + "three channels); default: the type suggested from the image headers, printed", + ) run.add_argument("--bins", type=int, help="bins per plane (default: depends on pixel type)") run.add_argument( "--memory-limit", @@ -252,6 +261,13 @@ def _check(args: argparse.Namespace) -> int: f"Suggested option for 'run': --axes {pre.suggested_mapping.roles_string} " f"(axes in the files: {pre.suggested_mapping.axes})" ) + type_review = review_image_type( + pre.metadata, review_axes(pre.metadata, pre.suggested_mapping) + ) + print( + f"Suggested image type: {type_review.suggested}, because {type_review.reason}. " + "'run' uses it unless you give --image-type." + ) return 0 if pre.ok else 1 @@ -291,6 +307,12 @@ def _run(args: argparse.Namespace) -> int: print("Axis roles:") print(mapping_summary(mapping, pre.metadata.files)) print() + review = review_axes(pre.metadata, mapping, missing_as_singleton=args.missing_as_singleton) + type_review = review_image_type(pre.metadata, review) + image_type = args.image_type or type_review.suggested + print(f"Image type: {image_type}") + print(_image_type_note(type_review, args.image_type)) + print() if design is not None: print("Experiment design:") print(design_summary(design)) @@ -316,6 +338,7 @@ def progress(done: int, total: int, path: str) -> None: convert_tiffs=args.convert_tiff, conversion=conversion, design=design, + image_type=image_type, ) if result.issues: print(format_issues(result.issues)) @@ -371,6 +394,19 @@ def _design_for_run(pre, args: argparse.Namespace) -> tuple[ExperimentDesign | N return None, "\n".join(lines) +def _image_type_note(review: ImageTypeReview, given: str | None) -> str: + """Say where the image type comes from and how to choose the other one.""" + if given is not None: + return " given with --image-type" + other = "fluorescence" if review.suggested == RGB else RGB + lines = [f" suggested, because {review.reason}"] + if other == RGB and not review.rgb_allowed: + lines.append(f" rgb is not possible: {review.rgb_problem}") + else: + lines.append(f" Give --image-type {other} if these are {other} images.") + return "\n".join(lines) + + def _conversion_note(pre, conversion: ConversionSettings, output) -> str: estimate = estimate_conversion(pre.discovery.root, pre.metadata.files, conversion) if not estimate.image_count: diff --git a/src/morphobricks_preprocessing/image_type.py b/src/morphobricks_preprocessing/image_type.py new file mode 100644 index 00000000..26bdab03 --- /dev/null +++ b/src/morphobricks_preprocessing/image_type.py @@ -0,0 +1,150 @@ +"""The image type: fluorescence or RGB (``docs/contract.md`` §13, follow-up P4). + +Later bricks normalize the two types differently (normalization contract §5): +``fluorescence`` images channel by channel, ``rgb`` images with StarDist's +percentile method over the red, green and blue channels together. The type is +the user's choice. This module suggests it from the image headers (normalization +plan N34): ``rgb`` when every image is stored as RGB colour (TIFF photometric +RGB, three samples per pixel) and the confirmed axis roles make those samples the +channel axis, ``fluorescence`` otherwise. RGB needs exactly three channels of an +integer pixel type; the rule is the normalization brick's +:func:`~morphobricks_normalization.rgb.check_rgb`. + +The histograms are the same for both types, so the type changes no row of +``plane_histograms.csv``; it is recorded in the run manifest (contract §9). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from morphobricks_normalization.rgb import check_rgb + +from .axes import AxisReview, channel_count +from .metadata import MetadataReport +from .validation import ValidationIssue, error + +FLUORESCENCE = "fluorescence" +RGB = "rgb" +IMAGE_TYPES = (FLUORESCENCE, RGB) + + +@dataclass(frozen=True) +class ImageTypeReview: + """What the headers and the confirmed axis roles say about the image type. + + Attributes + ---------- + suggested + ``"rgb"`` or ``"fluorescence"``, for the user to confirm. + reason + Why it is suggested, in plain language. + channels_from_rgb_samples + True when every analysed image is stored as RGB colour and the C role is + on its samples axis ``S``. + channel_count + The number of channels of every analysed image; None when they differ or + no image fits the axis roles. + rgb_problem + Why the type ``rgb`` cannot be chosen, in plain language; None when it can. + """ + + suggested: str + reason: str + channels_from_rgb_samples: bool = False + channel_count: int | None = None + rgb_problem: str | None = None + + @property + def rgb_allowed(self) -> bool: + """True if the images can be of type ``rgb``.""" + return self.rgb_problem is None + + +def check_image_type(image_type: str) -> str: + """Return ``image_type`` if it is ``"fluorescence"`` or ``"rgb"``. + + Raises + ------ + ValueError + For any other value. + """ + if image_type not in IMAGE_TYPES: + raise ValueError( + f"Image type {image_type!r} does not exist. Choose 'fluorescence' or 'rgb'." + ) + return image_type + + +def review_image_type(metadata: MetadataReport, review: AxisReview) -> ImageTypeReview: + """Suggest the image type and say whether RGB is possible, from the headers alone. + + Parameters + ---------- + metadata + The image headers. + review + The confirmed axis roles, resolved for every image. + + Returns + ------- + ImageTypeReview + ``rgb`` is suggested only when the images are RGB colour on the channel + axis and can be of type ``rgb``. + """ + headers = {f.relative_path: f for f in metadata.files} + files = [(headers[rel], mapping) for rel, mapping in sorted(review.file_mappings.items())] + from_samples = bool(files) and all( + f.rgb_samples and mapping.roles[mapping.axes.index("S")] == "C" for f, mapping in files + ) + counts = {channel_count(f, mapping) for f, mapping in files} + count = counts.pop() if len(counts) == 1 else None + problem = _rgb_problem(count, {f.dtype for f, _ in files}) + if from_samples and problem is None: + return ImageTypeReview( + RGB, + "the images are stored as RGB colour (three colour samples per pixel), and these " + "samples are their channels", + True, + count, + ) + if from_samples: + reason = "the images are stored as RGB colour, but cannot be of type RGB" + elif any(f.rgb_samples for f, _ in files): + reason = "not every image is stored as RGB colour on its channel axis" + else: + reason = "the images are not stored as RGB colour" + return ImageTypeReview(FLUORESCENCE, reason, from_samples, count, problem) + + +def image_type_issues(image_type: str | None, review: ImageTypeReview) -> list[ValidationIssue]: + """Return the error ``rgb_not_possible`` when ``rgb`` was chosen for images that cannot be. + + Images with different numbers of channels are reported by the axis review + (``different_channel_counts``), so they give no issue here. + """ + if image_type != RGB or review.rgb_allowed or review.channel_count is None: + return [] + return [ + error( + "rgb_not_possible", + review.rgb_problem, + action="Choose the image type fluorescence, or check the axis roles: the RGB " + "samples axis must have the role C (channel).", + ) + ] + + +def _rgb_problem(count: int | None, dtypes: set) -> str | None: + if count is None or None in dtypes: + return "The images do not have one number of channels and one pixel type." + try: + check_rgb(count, dtypes) + except ValueError: + names = " and ".join(sorted(dtype.name for dtype in dtypes)) + return ( + "RGB images need exactly three colour channels of a whole-number pixel type " + f"(for example 8- or 16-bit), but these images have {count} " + f"{'channel' if count == 1 else 'channels'} of type {names}." + ) + return None diff --git a/src/morphobricks_preprocessing/manifest.py b/src/morphobricks_preprocessing/manifest.py index 35141d23..f90ee47a 100644 --- a/src/morphobricks_preprocessing/manifest.py +++ b/src/morphobricks_preprocessing/manifest.py @@ -8,8 +8,9 @@ ``docs/contract.md`` §9. The manifest also records, for information only, each file's channel names and -significant bits when its metadata provides them, and the SHA-256 of the -experiment design used, so later bricks can check that it was not changed. +significant bits when its metadata provides them, the SHA-256 of the +experiment design used, so later bricks can check that it was not changed, and +the image type the user confirmed, which later bricks normalize by. """ from __future__ import annotations @@ -165,6 +166,13 @@ class RunManifest: SHA-256 of the ``experiment_design.csv`` written with this run, or None when the run had no experiment design (or in manifests written before it was recorded). + image_type + ``"fluorescence"`` or ``"rgb"``, as the user confirmed it + (:mod:`.image_type`); None when the run was not given one (or in + manifests written before it was recorded). + channels_from_rgb_samples + True when the channel axis is the RGB samples axis of images stored as + RGB colour; None in manifests written before it was recorded. schema_version Version of the CSV format. manifest_version @@ -180,6 +188,8 @@ class RunManifest: versions: dict[str, str] = field(default_factory=dict) written_at: str = "" experiment_design_sha256: str | None = None + image_type: str | None = None + channels_from_rgb_samples: bool | None = None schema_version: str = SCHEMA_VERSION manifest_version: str = MANIFEST_VERSION @@ -224,6 +234,8 @@ def to_dict(self) -> dict: "files": [_file_dict(f) for f in self.files], "csv_sha256": self.csv_sha256, "experiment_design_sha256": self.experiment_design_sha256, + "image_type": self.image_type, + "channels_from_rgb_samples": self.channels_from_rgb_samples, "versions": dict(self.versions), } @@ -265,6 +277,12 @@ def from_dict(cls, data: dict) -> RunManifest: if data.get("experiment_design_sha256") is None else str(data["experiment_design_sha256"]) ), + image_type=None if data.get("image_type") is None else str(data["image_type"]), + channels_from_rgb_samples=( + None + if data.get("channels_from_rgb_samples") is None + else bool(data["channels_from_rgb_samples"]) + ), schema_version=str(data["schema_version"]), ) except ManifestFormatError: diff --git a/src/morphobricks_preprocessing/metadata.py b/src/morphobricks_preprocessing/metadata.py index f1d2d0d7..e6cfc84a 100644 --- a/src/morphobricks_preprocessing/metadata.py +++ b/src/morphobricks_preprocessing/metadata.py @@ -78,6 +78,11 @@ class TiffMetadata: metadata (TIFF) or the OME-NGFF scale (OME-Zarr), as :func:`.physical_sizes.read_physical_sizes` gives them; all None when not given or the image does not open. For information only. + rgb_samples + True for a TIFF file stored as RGB colour: photometric interpretation + RGB with three samples per pixel, which ``tifffile`` labels ``S``. It is + the metadata the image type suggestion uses (:mod:`.image_type`). False + for other TIFF files (also RGB with an alpha sample) and OME-Zarr images. """ relative_path: str @@ -92,6 +97,7 @@ class TiffMetadata: significant_bits: int | None = None kind: str = TIFF physical_sizes: PhysicalSizes = PhysicalSizes() + rgb_samples: bool = False def length(self, axis: str) -> int: """Return the length of the dimension labelled ``axis``, 1 if there is none.""" @@ -184,6 +190,7 @@ def inspect_tiff(path: str | os.PathLike, relative_path: str | None = None) -> T page_count=len(tif.pages), **_ome_details(tif), physical_sizes=_tiff_sizes(tif), + rgb_samples=_rgb_samples(first), ) except Exception as exc: # any failure to parse means the file cannot be used logger.debug("Cannot read the TIFF header of %s", path, exc_info=True) @@ -426,6 +433,16 @@ def _ome_details(tif: tifffile.TiffFile) -> dict: } +def _rgb_samples(series: tifffile.TiffPageSeries) -> bool: + """Return True if the series stores RGB colour: photometric RGB, three samples per pixel.""" + keyframe = series.keyframe + return ( + "S" in series.axes + and keyframe.photometric == tifffile.PHOTOMETRIC.RGB + and keyframe.samplesperpixel == 3 + ) + + def _local_name(tag: str) -> str: return tag.rsplit("}", 1)[-1] diff --git a/src/morphobricks_preprocessing/notebook_style.py b/src/morphobricks_preprocessing/notebook_style.py index a7c8d1fe..1ecab568 100644 --- a/src/morphobricks_preprocessing/notebook_style.py +++ b/src/morphobricks_preprocessing/notebook_style.py @@ -309,6 +309,18 @@ def value(self) -> bool: """True once the user confirmed.""" return self._value + @property + def confirmed_text(self) -> str: + """Message shown once confirmed.""" + return self._confirmed_text + + @confirmed_text.setter + def confirmed_text(self, text: str) -> None: + # The message may name the answer, e.g. the image type chosen. + self._confirmed_text = text + if self._value: + self.message.value = status("ok", text) + @property def disabled(self) -> bool: """True while the question cannot be answered yet.""" diff --git a/src/morphobricks_preprocessing/notebook_ui.py b/src/morphobricks_preprocessing/notebook_ui.py index 937a9c92..27db62bc 100644 --- a/src/morphobricks_preprocessing/notebook_ui.py +++ b/src/morphobricks_preprocessing/notebook_ui.py @@ -8,7 +8,7 @@ biological repeats belong together across conditions, that they are comparable, and which conditions are controls. 2. **Axes** - review the role of every image axis, look at planes with the - preview, and confirm. + preview, and confirm; then confirm the image type (fluorescence or RGB). 3. **Run** - check that everything is ready, see what will happen, and compute or update the histogram table. 4. **Results** - pooled statistics and intensity distributions per group. @@ -73,6 +73,7 @@ write_experiment_design, ) from .discovery import check_output_folder, default_output_folder +from .image_type import FLUORESCENCE, RGB, ImageTypeReview, review_image_type from .manifest import MANIFEST_FILENAME, ManifestFormatError, read_manifest from .planes import ( DEFAULT_MEMORY_LIMIT, @@ -114,6 +115,12 @@ ("No role (only for length 1)", "-"), ] +IMAGE_TYPE_OPTIONS = [ + ("Fluorescence - each channel is a separate stain or marker", FLUORESCENCE), + ("RGB - colour images (red, green, blue), e.g. brightfield or histology stains", RGB), +] +IMAGE_TYPE_NAMES = {FLUORESCENCE: "Fluorescence", RGB: "RGB"} + LEVEL_OPTIONS = [ ("Condition", "condition"), ("Biological repeat", "biological_repeat"), @@ -167,6 +174,9 @@ class PreprocessingNotebook: The axis roles as currently edited in step 2. confirmed_mapping The axis roles the user confirmed, or None. + confirmed_image_type + ``"fluorescence"`` or ``"rgb"`` once the user confirmed the image type + in step 2, or None. design The experiment design as answered in step 1, or None until the labels are confirmed. @@ -183,6 +193,8 @@ def __init__(self, experiment_folder: str = "", output_folder: str = ""): self.dataset = DatasetReport() self.mapping: AxisMapping | None = None self.confirmed_mapping: AxisMapping | None = None + self.confirmed_image_type: str | None = None + self._image_type_review: ImageTypeReview | None = None self.result: RunResult | None = None self.log = _LogCapture() self._checked_folder = "" @@ -301,11 +313,12 @@ def axis_step( intro: str | None = None, show_next: bool = True, ) -> widgets.Widget: - """Step 2: axis roles, preview and confirmation. + """Step 2: axis roles, preview and confirmation, then the image type. The defaults give this notebook's step; another brick embedding it can give its own words, since this notebook's explain the roles by the planes its - histograms are computed on. + histograms are computed on. The image type question follows the confirmed + axis roles either way. Parameters ---------- @@ -332,7 +345,8 @@ def axis_step( "Why this matters: every image is cut into flat 2D planes, one per " "depth slice, channel and time point. The roles below are suggested from " "the image metadata, which is not always right, so check them with the " - "preview before confirming." + "preview before confirming. Then confirm whether the images are fluorescence " + "or RGB colour images." ) return ui.page( "Step 2 · Review the axis roles" if title is None else title, @@ -1108,11 +1122,45 @@ def _build_axis_step(self) -> None: slider.observe(lambda _: self._render_preview_if_shown(), names="value") self.preview_checked.observe(lambda _: self._update_confirm_button()) self.confirm_button.on_click(lambda _: self._toggle_confirmation()) + self._build_image_type_card() + + def _build_image_type_card(self) -> None: + self.image_type = widgets.RadioButtons( + options=IMAGE_TYPE_OPTIONS, value=FLUORESCENCE, layout=widgets.Layout(width="99%") + ) + self.image_type_note = widgets.HTML() + self.image_type_checked = ui.ConfirmToggle( + "Is this the right image type for your images?", + "Fluorescence images have one stain or marker per channel, and later bricks " + "normalise each channel on its own. RGB images are colour pictures whose red, " + "green and blue channels are normalised together, so their colours are kept. " + "The type does not change the histograms.", + "Yes, this is the right image type", + "Image type confirmed.", + "Undo: choose another image type", + ) + self.image_type_card = ui.card( + "Image type", + [ + ui.help_text( + "Suggested from the image metadata once the axis roles are confirmed: RGB " + "when the images are stored as RGB colour, fluorescence otherwise. It is " + "recorded in run_manifest.json for later bricks." + ), + self.image_type, + self.image_type_note, + self.image_type_checked.widget, + ], + visible=False, + ) + self.image_type.observe(lambda _: self._show_image_type_note(), names="value") + self.image_type_checked.observe(self._image_type_answered) def _refresh_axis_step(self) -> None: pre = self.preflight self.axis_next.value = "" self._preview_shown = False + self._close_image_type() if pre is None: self.axis_box.children = [ widgets.HTML( @@ -1194,6 +1242,7 @@ def _refresh_axis_step(self) -> None: self.preview_card, self.confirm_button, self.confirmed_status, + self.image_type_card, self.axis_next, ] self._fill_preview_files() @@ -1387,15 +1436,81 @@ def _toggle_confirmation(self) -> None: if r is not None ] self.confirmed_status.value = ui.status("ok", "Axis roles confirmed.", roles) + self._open_image_type() self._refresh_axis_next() else: self.confirmed_status.value = "" + # The image type depends on the channels the roles give. + self._close_image_type() self._mapping_changed() self._update_confirm_button() self._refresh_run_step() + def _open_image_type(self) -> None: + """Suggest the image type for the confirmed roles and ask the user to confirm it.""" + review = review_axes( + self.preflight.metadata, + self.confirmed_mapping, + missing_as_singleton=self.missing_as_singleton.value, + ) + self._image_type_review = review_image_type(self.preflight.metadata, review) + self.image_type_checked.set(False, notify=False) + self.confirmed_image_type = None + # The suggestion is only preselected; nothing is used until it is confirmed. + self.image_type.value = self._image_type_review.suggested + self.image_type.disabled = not self._image_type_review.rgb_allowed + self.image_type_checked.disabled = False + self._show_image_type_note() + ui.set_visible(self.image_type_card, True) + + def _close_image_type(self) -> None: + self._image_type_review = None + self.confirmed_image_type = None + self.image_type_checked.set(False, notify=False) + self.image_type_note.value = "" + ui.set_visible(self.image_type_card, False) + + def _show_image_type_note(self) -> None: + review = self._image_type_review + if review is None: + return + lines = [ + f"Suggested: {IMAGE_TYPE_NAMES[review.suggested]}, because " + f"{html.escape(review.reason)}." + ] + if not review.rgb_allowed: + lines.append(f"RGB cannot be chosen: {html.escape(review.rgb_problem)}") + kind = "info" + if self.image_type.value != review.suggested: + kind = "warn" + lines.append( + f"You chose {IMAGE_TYPE_NAMES[self.image_type.value]}, not the suggested type. " + "Confirm only if you are sure." + ) + self.image_type_note.value = ui.status( + kind, f"Image type: {IMAGE_TYPE_NAMES[self.image_type.value]}", lines, raw=True + ) + + def _image_type_answered(self, confirmed: bool) -> None: + self.confirmed_image_type = self.image_type.value if confirmed else None + review = self._image_type_review + self.image_type.disabled = confirmed or review is None or not review.rgb_allowed + if confirmed: + self.image_type_checked.confirmed_text = ( + f"Image type confirmed: {IMAGE_TYPE_NAMES[self.confirmed_image_type]}." + ) + if self.confirmed_mapping is not None: + self._refresh_axis_next() + self._refresh_run_step() + def _refresh_axis_next(self) -> None: - if not self.labels_confirmed.value: + if self.confirmed_image_type is None: + self.axis_next.value = ui.next_steps( + "Confirm the image type in Step 2.", + "Check the suggested type under Image type, then click Yes, this is the right " + "image type.", + ) + elif not self.labels_confirmed.value: self.axis_next.value = ui.next_steps( "Go to Step 1 and confirm the folder labels.", "Review the labels and click Yes, the labels are correct before running Step 3.", @@ -1762,6 +1877,7 @@ def _refresh_run_step(self) -> None: (pre is not None and self.labels_confirmed.value, "Confirm the labels (step 1)"), (self._design_confirmed(), "Confirm the biological repeats (step 1)"), (self.confirmed_mapping is not None, "Confirm the axis roles (step 2)"), + (self.confirmed_image_type is not None, "Confirm the image type (step 2)"), ] missing = [text for done, text in items if not done] if missing: @@ -1862,6 +1978,11 @@ def _plan_html(self, review: AxisReview | None = None) -> str: "columns) and " f"{MANIFEST_FILENAME} (what was read, with which settings).", ] + if self.confirmed_image_type is not None: + lines.append( + f"Image type: {IMAGE_TYPE_NAMES[self.confirmed_image_type]}, recorded in " + "the manifest for later bricks (the histograms are the same for either type)." + ) files = self.preflight.metadata.files tiffs = sum(not f.is_ome_zarr for f in files) zarrs = len(files) - tiffs @@ -1928,6 +2049,7 @@ def run(self) -> RunResult | None: convert_tiffs=self._convert_tiffs(), conversion=self._conversion_settings(), design=self.design, + image_type=self.confirmed_image_type, ) except Exception as exc: # unexpected failures still get a readable message self.log.lines.append(traceback.format_exc()) diff --git a/src/morphobricks_preprocessing/report.py b/src/morphobricks_preprocessing/report.py index 1c342e83..8905f93d 100644 --- a/src/morphobricks_preprocessing/report.py +++ b/src/morphobricks_preprocessing/report.py @@ -191,6 +191,8 @@ def run_summary(result: RunResult) -> str: f"Wrote {plural(result.plane_count, 'plane')} to {result.csv_path}", f"Run manifest: {result.manifest_path}", ] + if result.image_type is not None: + lines.append(f"Image type: {result.image_type}") if result.rebuild_reason: lines.append(f"All rows were computed because {result.rebuild_reason}.") for label, files in ( diff --git a/src/morphobricks_preprocessing/run.py b/src/morphobricks_preprocessing/run.py index 1d5ada94..0e36de44 100644 --- a/src/morphobricks_preprocessing/run.py +++ b/src/morphobricks_preprocessing/run.py @@ -6,7 +6,8 @@ everything again, reads only the images that are new or changed since the last run, and writes ``plane_histograms.csv`` and ``run_manifest.json`` to the output folder (``docs/plan.md`` §5–§7). Given the confirmed experiment design, it also -writes ``experiment_design.csv`` and records its hash in the manifest. +writes ``experiment_design.csv`` and records its hash in the manifest; given the +confirmed image type, it records it there too. Each row depends only on its own plane, so rows of unchanged files are kept as they are. Everything is recomputed when there is no usable earlier output, when @@ -47,6 +48,7 @@ write_experiment_design, ) from .discovery import DiscoveryResult, default_output_folder, discover_experiment +from .image_type import check_image_type, image_type_issues, review_image_type from .manifest import ( MANIFEST_FILENAME, FileEntry, @@ -159,6 +161,9 @@ class RunResult: design The experiment design written with the table, or None when the run was given none. + image_type + The image type recorded in the manifest, or None when the run was given + none. """ output_folder: Path @@ -174,6 +179,7 @@ class RunResult: plane_count: int = 0 converted: tuple[str, ...] = () design: ExperimentDesign | None = None + image_type: str | None = None @property def ok(self) -> bool: @@ -305,6 +311,7 @@ def run_experiment( convert_tiffs: bool = False, conversion: ConversionSettings | None = None, design: ExperimentDesign | None = None, + image_type: str | None = None, ) -> RunResult: """Pass 2: write or update the histogram table and the run manifest. @@ -350,16 +357,30 @@ def run_experiment( with the table, and its SHA-256 recorded in the manifest. Without a design nothing is asked, the manifest records none, and an existing ``experiment_design.csv`` is left as it is. + image_type + The image type the user confirmed, ``"fluorescence"`` or ``"rgb"`` + (:mod:`.image_type`), recorded in the manifest. ``"rgb"`` for images that + are not three channels of an integer type is the error + ``rgb_not_possible``. It changes no row, so changing it recomputes + nothing. Without it the manifest records none. Returns ------- RunResult If any error is found, nothing is written and ``written`` is False. + + Raises + ------ + ValueError + For an image type other than ``"fluorescence"`` and ``"rgb"``. """ + if image_type is not None: + check_image_type(image_type) root = Path(experiment_root).resolve() output = default_output_folder(root) if output_folder is None else Path(output_folder).resolve() pre = preflight(root, output, exclude=exclude) review = review_axes(pre.metadata, mapping, missing_as_singleton=missing_as_singleton) + type_review = review_image_type(pre.metadata, review) dataset = pre.dataset design_problems: list[ValidationIssue] = [] if design is not None: @@ -371,6 +392,7 @@ def run_experiment( *pre.discovery.issues, *pre.exclusion_issues, *review.combined_issues(pre.metadata), + *image_type_issues(image_type, type_review), *design_problems, *dataset.issues, ] @@ -557,6 +579,8 @@ def stop(**counts) -> RunResult: versions=package_versions(), written_at=datetime.now(UTC).isoformat(timespec="seconds"), experiment_design_sha256=design_sha256, + image_type=image_type, + channels_from_rgb_samples=type_review.channels_from_rgb_samples, ) write_manifest(manifest, staged / MANIFEST_FILENAME) logger.info( @@ -575,6 +599,7 @@ def stop(**counts) -> RunResult: plane_count=len(records), converted=tuple(to_convert), design=design, + image_type=image_type, **counts, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index c5bf555b..aba3de5d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -77,6 +77,61 @@ def test_run_writes_the_table(experiment, capsys): assert {(r.z, r.channel_count) for r in records} == {(0, 3), (1, 3)} +def rgb_experiment(root): + rng = np.random.default_rng(0) + for rel in ["ctrl/rep1/a.tif", "drug/rep1/a.tif"]: + data = rng.integers(0, 200, (2, 8, 8, 3), dtype=np.uint8) + write_data(root / rel, data, axes="ZYXS", photometric="rgb") + return root + + +def test_check_suggests_the_image_type(experiment, tmp_path, capsys): + assert main(["check", str(experiment)]) == 0 + out = capsys.readouterr().out + assert "Suggested image type: fluorescence, because the images are not stored as RGB" in out + assert main(["check", str(rgb_experiment(tmp_path / "rgb"))]) == 0 + assert "Suggested image type: rgb, because the images are stored as RGB colour" in ( + capsys.readouterr().out + ) + + +def test_run_uses_and_prints_the_suggested_image_type(tmp_path, capsys): + root = rgb_experiment(tmp_path / "exp") + confirm_design(root) + assert main(["run", str(root), "--axes", "ZYXC"]) == 0 + out = capsys.readouterr().out + assert "Image type: rgb\n suggested, because the images are stored as RGB colour" in out + assert "Give --image-type fluorescence if these are fluorescence images." in out + manifest = read_manifest(default_output_folder(root) / "run_manifest.json") + assert manifest.image_type == "rgb" and manifest.channels_from_rgb_samples + assert main(["run", str(root), "--axes", "ZYXC", "--image-type", "fluorescence"]) == 0 + out = capsys.readouterr().out + assert "Image type: fluorescence\n given with --image-type" in out + assert "Read: " not in out # changing the type reads nothing again + manifest = read_manifest(default_output_folder(root) / "run_manifest.json") + assert manifest.image_type == "fluorescence" + + +def test_rgb_is_refused_for_images_that_cannot_be_rgb(experiment, capsys): + confirm_design(experiment) + assert main(["run", str(experiment), "--axes", "ZCYX"]) == 0 + out = capsys.readouterr().out + assert "Image type: fluorescence" in out + assert "Give --image-type rgb if these are rgb images." in out + assert main(["run", str(experiment), "--axes", "TCYX", "--image-type", "rgb"]) == 0 + capsys.readouterr() + assert main(["run", str(experiment), "--axes", "ZYCX", "--image-type", "rgb"]) == 1 + out = capsys.readouterr().out + assert "RGB images need exactly three colour channels" in out + assert "8 channels of type uint8" in out and "Nothing was written" in out + + +def test_image_type_must_be_known(experiment): + with pytest.raises(SystemExit) as excinfo: + main(["run", str(experiment), "--axes", "ZCYX", "--image-type", "brightfield"]) + assert excinfo.value.code == 2 + + def test_run_options_reach_the_run(experiment, tmp_path, capsys): out_dir = tmp_path / "results" log = tmp_path / "run.log" diff --git a/tests/test_image_type.py b/tests/test_image_type.py new file mode 100644 index 00000000..21cad303 --- /dev/null +++ b/tests/test_image_type.py @@ -0,0 +1,218 @@ +from pathlib import Path + +import numpy as np +import pytest + +from morphobricks_preprocessing import ( + FLUORESCENCE, + RGB, + check_image_type, + inspect_tiff, + prefill_mapping, + preflight, + read_manifest, + review_axes, + review_image_type, + run_experiment, +) +from morphobricks_preprocessing import run as run_module +from tiff_tree import write_data + + +def rgb_image(path, shape=(2, 8, 8, 3), dtype=np.uint8, **kwargs): + """An RGB colour image: photometric RGB, three samples per pixel (ZYXS).""" + rng = np.random.default_rng(len(str(path))) + data = rng.integers(0, 200, shape).astype(dtype) + kwargs.setdefault("photometric", "rgb") + return write_data(path, data, axes="ZYXS" if len(shape) == 4 else None, **kwargs) + + +def rgb_experiment(root): + for rel in ("ctrl/rep1/a.tif", "ctrl/rep1/b.tif", "drug/rep1/a.tif"): + rgb_image(root / rel) + return root + + +def fluorescence_experiment(root, channels=2, dtype=np.uint8): + rng = np.random.default_rng(0) + for rel in ("ctrl/rep1/a.tif", "drug/rep1/a.tif"): + data = rng.integers(0, 200, (2, channels, 8, 8)).astype(dtype) + write_data(root / rel, data, axes="ZCYX") + return root + + +def type_review(root, roles=None, **kwargs): + pre = preflight(root) + mapping = pre.suggested_mapping if roles is None else roles + return review_image_type(pre.metadata, review_axes(pre.metadata, mapping, **kwargs)) + + +def codes(issues): + return [issue.code for issue in issues] + + +# Header ----------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("data_shape", "kwargs", "expected"), + [ + pytest.param((8, 8, 3), {"photometric": "rgb"}, True, id="rgb"), + pytest.param( + (3, 8, 8), {"photometric": "rgb", "planarconfig": "separate"}, True, id="planar" + ), + pytest.param((8, 8, 3), {"planarconfig": "contig"}, False, id="three-grey-samples"), + pytest.param( + (8, 8, 4), {"photometric": "rgb", "extrasamples": ["unassalpha"]}, False, id="rgba" + ), + pytest.param((3, 8, 8), {}, False, id="grey-stack"), + ], +) +def test_rgb_samples_come_from_the_photometric_interpretation( + tmp_path, data_shape, kwargs, expected +): + path = write_data(tmp_path / "a.tif", np.zeros(data_shape, np.uint8), **kwargs) + assert inspect_tiff(path).rgb_samples is expected + + +def test_ome_zarr_images_have_no_rgb_samples(tmp_path): + pytest.importorskip("zarr") + import zarr_tree + + path = tmp_path / "a.ome.zarr" + zarr_tree.write_ngff(path, np.zeros((3, 8, 8), np.uint8), "cyx") + from morphobricks_preprocessing import inspect_ome_zarr + + assert inspect_ome_zarr(path).rgb_samples is False + + +# Suggestion ------------------------------------------------------------------------------- + + +def test_rgb_is_suggested_for_rgb_samples_on_the_channel_axis(tmp_path): + review = type_review(rgb_experiment(tmp_path / "exp")) + assert review.suggested == RGB and review.channels_from_rgb_samples + assert review.rgb_allowed and review.channel_count == 3 + assert "stored as RGB colour" in review.reason + + +def test_planar_rgb_is_suggested_too(tmp_path): + root = tmp_path / "exp" + for rel in ("a.tif", "b.tif"): + rgb_image(root / rel, shape=(3, 8, 8), planarconfig="separate") + review = type_review(root) + assert review.suggested == RGB and review.channels_from_rgb_samples + + +def test_fluorescence_is_suggested_without_rgb_samples(tmp_path): + # Three integer channels could be RGB, but nothing in the metadata says so. + review = type_review(fluorescence_experiment(tmp_path / "exp", channels=3)) + assert review.suggested == FLUORESCENCE and not review.channels_from_rgb_samples + assert review.rgb_allowed + assert review.reason == "the images are not stored as RGB colour" + + +@pytest.mark.parametrize( + ("channels", "dtype", "text"), + [ + (2, np.uint8, "these images have 2 channels of type uint8"), + (1, np.uint16, "these images have 1 channel of type uint16"), + (3, np.float32, "these images have 3 channels of type float32"), + ], +) +def test_rgb_needs_three_integer_channels(tmp_path, channels, dtype, text): + review = type_review(fluorescence_experiment(tmp_path / "exp", channels, dtype)) + assert review.suggested == FLUORESCENCE and not review.rgb_allowed + assert text in review.rgb_problem + + +def test_float_rgb_images_are_not_suggested_rgb(tmp_path): + root = tmp_path / "exp" + for rel in ("a.tif", "b.tif"): + rgb_image(root / rel, dtype=np.float32) + review = type_review(root) + assert review.channels_from_rgb_samples and not review.rgb_allowed + assert review.suggested == FLUORESCENCE + assert review.reason == "the images are stored as RGB colour, but cannot be of type RGB" + assert "3 channels of type float32" in review.rgb_problem + + +def test_mixed_rgb_and_grey_images_are_not_suggested_rgb(tmp_path): + root = tmp_path / "exp" + rgb_image(root / "a.tif", shape=(8, 8, 3)) + write_data(root / "b.tif", np.zeros((8, 8, 3), np.uint8), planarconfig="contig") + review = type_review(root) + assert review.suggested == FLUORESCENCE and review.rgb_allowed + assert review.reason == "not every image is stored as RGB colour on its channel axis" + + +def test_check_image_type(): + assert check_image_type("rgb") == "rgb" + with pytest.raises(ValueError, match="Choose 'fluorescence' or 'rgb'"): + check_image_type("brightfield") + + +# Run and manifest ------------------------------------------------------------------------- + + +@pytest.fixture +def reads(monkeypatch): + paths = [] + original = run_module.iter_xyc_planes + + def spy(path, *args, **kwargs): + paths.append(Path(path).name) + return original(path, *args, **kwargs) + + monkeypatch.setattr(run_module, "iter_xyc_planes", spy) + return paths + + +def test_the_manifest_records_the_image_type(tmp_path): + root = rgb_experiment(tmp_path / "exp") + result = run_experiment(root, prefill_mapping("ZYXS"), image_type="rgb") + assert result.written and result.image_type == "rgb" + manifest = read_manifest(result.manifest_path) + assert manifest.image_type == "rgb" and manifest.channels_from_rgb_samples is True + + +def test_without_an_image_type_none_is_recorded(tmp_path): + # The suggestion is never applied without the user's confirmation. + root = rgb_experiment(tmp_path / "exp") + result = run_experiment(root, prefill_mapping("ZYXS")) + manifest = read_manifest(result.manifest_path) + assert manifest.image_type is None and manifest.channels_from_rgb_samples is True + + +def test_changing_the_image_type_recomputes_nothing(tmp_path, reads): + root = rgb_experiment(tmp_path / "exp") + first = run_experiment(root, prefill_mapping("ZYXS"), image_type="rgb") + table = first.csv_path.read_bytes() + reads.clear() + second = run_experiment(root, prefill_mapping("ZYXS"), image_type="fluorescence") + assert second.written and reads == [] and second.rebuild_reason is None + assert second.csv_path.read_bytes() == table + assert read_manifest(second.manifest_path).image_type == "fluorescence" + + +def test_rgb_is_refused_for_images_that_are_not_rgb(tmp_path): + root = fluorescence_experiment(tmp_path / "exp", channels=2) + result = run_experiment(root, prefill_mapping("ZCYX"), image_type="rgb") + assert not result.written and "rgb_not_possible" in codes(result.issues) + (issue,) = [i for i in result.issues if i.code == "rgb_not_possible"] + assert "2 channels of type uint8" in issue.message and "fluorescence" in issue.action + assert not result.output_folder.exists() + + +def test_rgb_for_three_grey_channels_is_allowed(tmp_path): + root = fluorescence_experiment(tmp_path / "exp", channels=3) + result = run_experiment(root, prefill_mapping("ZCYX"), image_type="rgb") + assert result.written + manifest = read_manifest(result.manifest_path) + assert manifest.image_type == "rgb" and manifest.channels_from_rgb_samples is False + + +def test_unknown_image_type_is_a_programming_error(tmp_path): + root = fluorescence_experiment(tmp_path / "exp") + with pytest.raises(ValueError, match="does not exist"): + run_experiment(root, prefill_mapping("ZCYX"), image_type="RGB") diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 690f96b6..7ac957c5 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -143,6 +143,19 @@ def test_position_id_round_trips_and_is_none_when_absent(tmp_path): assert read_manifest(path).files[0].position_id is None +def test_image_type_round_trips_and_is_none_when_absent(tmp_path): + original = manifest(image_type="rgb", channels_from_rgb_samples=True) + path = write_manifest(original, tmp_path / "m.json") + assert read_manifest(path) == original + data = json.loads(path.read_bytes()) + assert (data["image_type"], data["channels_from_rgb_samples"]) == ("rgb", True) + assert data["manifest_version"] == "1" + del data["image_type"], data["channels_from_rgb_samples"] # written before P4 + path.write_text(json.dumps(data), encoding="utf-8") + read = read_manifest(path) + assert read.image_type is None and read.channels_from_rgb_samples is None + + def test_file_entry_for_reads_size_and_time(tmp_path): path = write_tiff(tmp_path / "a.tif") found = file_entry_for(tmp_path, "a.tif", ("single",) * 3, prefill_mapping("YX")) diff --git a/tests/test_notebook_ui.py b/tests/test_notebook_ui.py index a52d949f..f2498121 100644 --- a/tests/test_notebook_ui.py +++ b/tests/test_notebook_ui.py @@ -63,12 +63,14 @@ def confirm_repeats(nb): def confirmed(experiment, **kwargs): + """Check the folder and confirm everything step 3 needs: labels, design, roles, type.""" nb = checked(experiment, **kwargs) nb.labels_confirmed.button.click() confirm_repeats(nb) nb.preview_button.click() nb.preview_checked.button.click() nb.confirm_button.click() + nb.image_type_checked.button.click() return nb @@ -227,6 +229,8 @@ def test_next_steps_follow_confirmation_and_folder_changes(experiment): nb.preview_button.click() nb.preview_checked.button.click() nb.confirm_button.click() + assert "Confirm the image type in Step 2" in nb.axis_next.value + nb.image_type_checked.button.click() assert "Go to Step 3" in nb.axis_next.value assert "Continue" not in nb.confirmed_status.value nb.labels_confirmed.button.click() @@ -253,6 +257,7 @@ def test_unreadable_image_can_be_excluded(experiment): nb.preview_button.click() nb.preview_checked.button.click() nb.confirm_button.click() + nb.image_type_checked.button.click() assert nb.run().excluded == ("ctrl/rep1/bad.tif",) @@ -594,6 +599,7 @@ def test_confirmed_missing_axes_allow_running_unless_other_errors_remain(tmp_pat assert nb.run_button.disabled nb.missing_as_singleton.button.click() nb.confirm_button.click() + nb.image_type_checked.button.click() assert nb.confirmed_mapping is not None assert nb.run_button.disabled == blocking_file if blocking_file: @@ -617,6 +623,7 @@ def test_undoing_missing_axes_confirmation_blocks_running_again(tmp_path): nb.preview_button.click() nb.preview_checked.button.click() nb.confirm_button.click() + nb.image_type_checked.button.click() assert not nb.run_button.disabled nb.confirm_button.click() # unlock the roles before undoing singleton confirmation nb.missing_as_singleton.button.click() @@ -624,6 +631,93 @@ def test_undoing_missing_axes_confirmation_blocks_running_again(tmp_path): assert nb.run() is None +# Image type (follow-up P4) ---------------------------------------------------------------- + + +def roles_confirmed(root, **kwargs): + """Check the folder and confirm the labels, the design and the axis roles, not the type.""" + nb = checked(root, **kwargs) + nb.labels_confirmed.button.click() + confirm_repeats(nb) + nb.preview_button.click() + nb.preview_checked.button.click() + nb.confirm_button.click() + return nb + + +def rgb_tree(root, dtype=np.uint8): + rng = np.random.default_rng(0) + for rel in ["ctrl/rep1/a.tif", "drug/rep1/a.tif"]: + data = rng.integers(0, 200, (2, 8, 8, 3)).astype(dtype) + write_data(root / rel, data, axes="ZYXS", photometric="rgb") + return root + + +def test_image_type_is_asked_after_the_axis_roles(experiment): + nb = checked(experiment) + assert not ui.is_visible(nb.image_type_card) + nb.labels_confirmed.button.click() + confirm_repeats(nb) + nb.preview_button.click() + nb.preview_checked.button.click() + nb.confirm_button.click() + assert ui.is_visible(nb.image_type_card) + assert nb.image_type.value == "fluorescence" and nb.confirmed_image_type is None + assert "Suggested: Fluorescence" in nb.image_type_note.value + # Two channels cannot be RGB: the choice is fixed and the reason shown. + assert nb.image_type.disabled + assert "RGB cannot be chosen" in nb.image_type_note.value + assert "2 channels of type uint8" in nb.image_type_note.value + assert nb.run_button.disabled and "Confirm the image type in Step 2" in nb.axis_next.value + nb.image_type_checked.button.click() + assert nb.confirmed_image_type == "fluorescence" + assert "Image type confirmed: Fluorescence." in nb.image_type_checked.message.value + assert not nb.run_button.disabled and "Go to Step 3" in nb.axis_next.value + assert "Image type: Fluorescence" in nb.plan.value + assert read_manifest(nb.run().manifest_path).image_type == "fluorescence" + + +def test_rgb_is_suggested_but_only_used_once_confirmed(tmp_path): + nb = roles_confirmed(rgb_tree(tmp_path / "exp")) + assert nb.image_type.value == "rgb" and not nb.image_type.disabled + assert "Suggested: RGB" in nb.image_type_note.value + assert nb.confirmed_image_type is None and nb.run_button.disabled + assert nb.run() is None + # Choosing the other type is possible, with a reminder of the suggestion. + nb.image_type.value = "fluorescence" + assert "mb-warn" in nb.image_type_note.value and "not the suggested type" in ( + nb.image_type_note.value + ) + nb.image_type.value = "rgb" + nb.image_type_checked.button.click() + assert nb.confirmed_image_type == "rgb" and nb.image_type.disabled + manifest = read_manifest(nb.run().manifest_path) + assert manifest.image_type == "rgb" and manifest.channels_from_rgb_samples + nb.image_type_checked.button.click() # undo + assert nb.confirmed_image_type is None and not nb.image_type.disabled + assert nb.run_button.disabled + + +def test_float_rgb_images_cannot_be_rgb(tmp_path): + nb = roles_confirmed(rgb_tree(tmp_path / "exp", np.float32)) + assert nb.image_type.value == "fluorescence" and nb.image_type.disabled + assert "stored as RGB colour, but cannot be of type RGB" in nb.image_type_note.value + assert "3 channels of type float32" in nb.image_type_note.value + + +def test_changing_the_axis_roles_asks_for_the_image_type_again(experiment): + nb = confirmed(experiment) + assert nb.confirmed_image_type == "fluorescence" + nb.confirm_button.click() # change the axis roles + assert nb.confirmed_image_type is None and not ui.is_visible(nb.image_type_card) + assert not nb.image_type_checked.value and nb.run_button.disabled + nb.confirm_button.click() + assert ui.is_visible(nb.image_type_card) and nb.confirmed_image_type is None + nb.image_type_checked.button.click() + nb.folder.value = str(experiment) + "_other" + assert nb.confirmed_image_type is None and not ui.is_visible(nb.image_type_card) + + def test_conflicting_axes_explain_why_no_roles_are_offered(tmp_path): write_data(tmp_path / "exp/a.tif", np.zeros((2, 8, 8), np.uint8), axes="ZYX") write_data(tmp_path / "exp/b.tif", np.zeros((2, 8, 8), np.uint8), axes="CYX") @@ -731,6 +825,10 @@ def test_axis_step_with_its_own_words(experiment): nb.preview_checked.button.click() nb.confirm_button.click() assert nb.confirmed_mapping is not None + # The embedded step asks for the image type too, with the same gating (P4). + assert ui.is_visible(nb.image_type_card) and nb.run_button.disabled + nb.image_type_checked.button.click() + assert nb.confirmed_image_type == "fluorescence" and not nb.run_button.disabled assert "compute the histograms" in nb.axis_next.value and not ui.is_visible(nb.axis_next) # Building the default page again shows the box again. nb.axis_step() @@ -751,6 +849,8 @@ def test_readiness_checklist(experiment): nb.preview_button.click() nb.preview_checked.button.click() nb.confirm_button.click() + assert "Still to do: confirm the image type" in nb.readiness.value and nb.run_button.disabled + nb.image_type_checked.button.click() assert "Ready to run" in nb.readiness.value and not nb.run_button.disabled @@ -817,6 +917,7 @@ def fail(*args, **kwargs): for cls in (tifffile.TiffFile, tifffile.TiffPage, tifffile.TiffFrame, tifffile.TiffPageSeries): monkeypatch.setattr(cls, "asarray", fail) nb.confirm_button.click() + nb.image_type_checked.button.click() plan = nb.plan.value assert "1 image has planes too large to read whole" in plan assert "will be read in blocks" in plan and "exactly the same" in plan @@ -1182,6 +1283,7 @@ def test_statistics_need_a_table_and_valid_percentiles(experiment): nb.preview_button.click() nb.preview_checked.button.click() nb.confirm_button.click() + nb.image_type_checked.button.click() nb.run() nb.percentiles.value = "1, 120" nb.show_statistics() @@ -1362,6 +1464,7 @@ def test_notebook_names_buttons_that_exist(): nb.preview_checked.button, nb.missing_as_singleton.button, nb.confirm_button, + nb.image_type_checked.button, nb.run_button, nb.statistics_button, ):