From 7b955ff5554bc4fd3bde2c31c4f97d83f3312787 Mon Sep 17 00:00:00 2001 From: Craig Myles Date: Fri, 17 Jul 2026 09:48:14 +0100 Subject: [PATCH 1/2] Prepare raw2features v0.1.1 --- .github/workflows/ci.yml | 61 +++- .github/workflows/release.yml | 21 +- CHANGELOG.md | 70 ++++ CITATION.cff | 2 +- CONTRIBUTING.md | 44 ++- README.md | 12 +- assets/raw2features-diagram.png | Bin 0 -> 129372 bytes docs/INTEROP.md | 11 +- docs/usage.md | 118 ++++-- notebooks/01_quickstart.ipynb | 2 +- notebooks/02_visual_walkthrough.ipynb | 2 +- notebooks/03_the_embeddings_store.ipynb | 2 +- notebooks/04_spatialdata.ipynb | 4 +- notebooks/05_quality_control.ipynb | 2 +- notebooks/06_cohort_quality_control.ipynb | 2 +- pyproject.toml | 3 +- slurm/README.md | 21 +- slurm/embed_array.sbatch | 34 +- slurm/embed_cohort.sbatch | 43 ++- slurm/preflight.sh | 18 +- src/raw2features/__init__.py | 24 +- src/raw2features/cli/_validation.py | 55 +++ src/raw2features/cli/benchmark.py | 22 +- src/raw2features/cli/embed.py | 27 +- src/raw2features/cli/embed_many.py | 105 +++++- src/raw2features/cli/slide_embed.py | 41 +-- src/raw2features/cli/verify.py | 48 ++- src/raw2features/core/config.py | 40 +- src/raw2features/core/provenance.py | 54 ++- src/raw2features/embedders/model_registry.py | 155 ++++++-- src/raw2features/export/h5.py | 59 ++- src/raw2features/pipeline/runner.py | 345 ++++++++++++++---- src/raw2features/readers/omezarr.py | 197 +++++++--- src/raw2features/sinks/zarr_sink.py | 121 +++++- src/raw2features/slide_embedders/encoding.py | 46 +++ .../slide_embedders/model_registry.py | 5 +- src/raw2features/spec.py | 37 +- tests/test_additive_embed.py | 176 ++++++++- tests/test_cli_commands.py | 168 ++++++++- tests/test_config.py | 22 ++ tests/test_config_integrity.py | 17 + tests/test_coordinate_frame.py | 4 +- tests/test_embed_many.py | 46 +++ tests/test_export_h5.py | 84 ++++- tests/test_multi_gpu.py | 15 + tests/test_output_fingerprints.py | 11 +- tests/test_plugins.py | 59 +++ tests/test_provenance.py | 47 ++- tests/test_public_api.py | 9 +- tests/test_reader_omezarr.py | 242 +++++++++++- tests/test_release_metadata.py | 22 ++ tests/test_resolve_geometry.py | 70 +++- tests/test_resolve_mpp.py | 28 +- tests/test_runner_mock.py | 40 ++ tests/test_slide_embedders.py | 135 +++++++ tests/test_spec_conformance.py | 134 +++++-- uv.lock | 312 ++++++++++++---- 57 files changed, 2938 insertions(+), 556 deletions(-) create mode 100644 assets/raw2features-diagram.png create mode 100644 src/raw2features/cli/_validation.py create mode 100644 tests/test_plugins.py create mode 100644 tests/test_release_metadata.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bfeeb0..149169b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,16 +6,33 @@ on: pull_request: jobs: + core-smoke: + # A real lean install: no default dev group and therefore no zarr, image, or + # torch stack. This protects the documented `pip install raw2features` path. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.12" + - name: Install (core only) + run: uv sync --locked --no-default-groups + - name: Smoke import and CLI + run: | + uv run --no-sync python -c "import raw2features, sys; assert 'torch' not in sys.modules; assert 'zarr' not in sys.modules" + uv run --no-sync raw2features --help + lint-and-test: - # Core-only: lint + run the suite with no heavy extras installed. Proves the - # package imports and the core paths work without torch/zarr (heavy-dep tests - # self-skip), so an end user who installs just the core isn't broken. Run across - # every supported Python (matches the requires-python / classifiers contract). + # Default developer environment: lint + the offline suite across every + # supported Python. The dev group intentionally includes the light Zarr/image + # test stack; the separate core-smoke job protects the dependency-lean install. runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v5 with: @@ -23,8 +40,8 @@ jobs: - uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} - - name: Install (core only) - run: uv sync + - name: Install (developer test environment) + run: uv sync --locked - name: Lint run: uv run ruff check . - name: Test (core) @@ -45,6 +62,34 @@ jobs: with: python-version: "3.12" - name: Install (full stack, CPU torch) - run: uv sync --extra zarr --extra image --extra torch --extra models --extra stain + run: uv sync --locked --extra zarr --extra image --extra torch --extra models --extra stain - name: Test (with torch) run: uv run pytest -m "not slow and not network" -q + + package-smoke: + # Validate exactly what a release would upload, including clean installs from + # both artifacts rather than importing from the checkout. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.12" + - name: Build and check artifacts + run: | + uv build + uvx --from twine twine check dist/* + - name: Smoke wheel + run: | + uv venv .venv-wheel + uv pip install --python .venv-wheel/bin/python dist/*.whl + .venv-wheel/bin/python -c "import importlib.metadata, raw2features; assert raw2features.__version__ == importlib.metadata.version('raw2features')" + .venv-wheel/bin/raw2features --help + - name: Smoke source distribution + run: | + uv venv .venv-sdist + uv pip install --python .venv-sdist/bin/python dist/*.tar.gz + .venv-sdist/bin/python -c "import importlib.metadata, raw2features; assert raw2features.__version__ == importlib.metadata.version('raw2features')" + .venv-sdist/bin/raw2features --help diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f51ade..8b85530 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ # Workflow: release.yml Environment: pypi # (A *pending* publisher lets the very first release create the project on PyPI.) # 2. Cut a release whose tag matches the version in pyproject.toml, e.g.: -# gh release create v0.1.0 --generate-notes +# gh release create v0.1.1 --generate-notes # This workflow then builds the sdist + wheel and uploads them automatically. name: Release @@ -28,8 +28,27 @@ jobs: contents: read # actions/checkout needs read access (required while the repo is private) steps: - uses: actions/checkout@v5 + with: + persist-credentials: false - uses: astral-sh/setup-uv@v7 + with: + python-version: "3.12" + - name: Verify tag matches package version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: >- + python -c "import os, pathlib, tomllib; + version = tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']; + expected = f'v{version}'; actual = os.environ['RELEASE_TAG']; + assert actual == expected, f'release tag {actual!r} does not match {expected!r}'" - name: Build sdist + wheel run: uv build + - name: Check and smoke wheel + run: | + uvx --from twine twine check dist/* + uv venv .venv-release-smoke + uv pip install --python .venv-release-smoke/bin/python dist/*.whl + .venv-release-smoke/bin/python -c "import importlib.metadata, raw2features; assert raw2features.__version__ == importlib.metadata.version('raw2features')" + .venv-release-smoke/bin/raw2features --help - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6f6cf..76e240b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,75 @@ Notable changes to raw2features, newest first. This project follows [Semantic Versioning](https://semver.org). +## [0.1.1] - 2026-07-16 + +This is a focused correctness, reproducibility, and interoperability release. It keeps +the v0.1 store layout and geometry identity while making resume decisions stricter and +multi-grid workflows fully additive. + +### Correctness and reproducibility + +- Redact command-line secrets and persist credential-free canonical source URIs while + still supporting query-authenticated HTTP Zarr stores in memory. +- Bind stores and receipts to their source and requested output, reject duplicate cohort + output IDs before dispatch, make receipt writes atomic, and propagate worker-startup + failures to the command exit status. +- Enforce immutable model revisions at load time. Patch and slide outputs now carry a + per-model fingerprint covering their effective weights, preprocessing, pooling, + dimension, resolved AMP, and loader contract; a stale or legacy-unfingerprinted output + is recomputed without changing `grid_hash` or unrelated model arrays. +- Verify composite checkpoints before deserialisation, validate expected embedding + dimensions during completion checks, and mark SEAL experimental until its upstream + base-model fetch can be pinned end to end. +- Make colliding human-readable grid labels unique, produce explicitly requested + thumbnails/QC/GeoJSON/slide embeddings when missing, and validate receipts against the + requested grid rather than unioning same-named models across grids. +- Harden partial-store recovery and conformance validation, preserve per-axis scale in + slide-relative STAMP coordinates, place clipped border pixels at their true offsets, + reject invalid AMP/batch/geometry settings before model loading, and avoid attributing + an enclosing unrelated Git checkout to an installed wheel. + +### Models and interoperability + +- Add pinned, forward-validated H0-mini, KEEP, OpenMidnight, and OpenPath patch encoders, + with their licenses, access requirements, preprocessing, and weight hashes recorded. +- Correct cached OME-Zarr reads for noncanonical spatial axis order. The reader accepts + x/y in either order (with an optional channel axis) and warns whenever another + non-singleton axis is reduced to index zero. +- Refresh the development lock to current `ngff-zarr`/`ome-zarr` releases while retaining + the existing declared compatibility floor. +- Choose the float-pixel `[0,1]` versus `[0,255]` convention once per opened slide instead + of independently for every patch. + +### API, documentation, and release safety + +- Bless `embed_slide` as the high-level Python entry point while retaining `run_slide` as + the explicit single-grid primitive, resolve injected embedders from their own model + specifications, and make inline `-s/--slide-encoder` requests produce missing outputs + on otherwise-complete runs. +- Correct the multi-grid, additive-append, readback, and SLURM documentation, and use the + compact 4x PNG project banner so GitHub and PyPI render it consistently. +- Add a genuine dependency-lean import/CLI check, built-wheel and source-distribution + smoke installs, package metadata validation, and a release tag/version gate. + +### Compatibility and migration + +- Existing v0.1 stores remain readable; the embeddings schema and geometry-only + `grid_hash` are unchanged. +- The first request for a model output created before fingerprints were introduced will + recompute that model in place. The old header is deliberately not treated as proof that + pre-v0.1.1 revision pins were enforced. +- Plain local output IDs remain basename-based for v0.1 compatibility. Source comparison + prevents accidental reuse, and manifest preflight rejects duplicate derived IDs. +- Credentials already persisted by v0.1.0 are not rewritten by this release and should be + rotated separately if the store was shared. + +Tracked by [#6](https://github.com/CraigMyles/raw2features/issues/6); this release resolves +[#2](https://github.com/CraigMyles/raw2features/issues/2), +[#3](https://github.com/CraigMyles/raw2features/issues/3), +[#4](https://github.com/CraigMyles/raw2features/issues/4), and +[#5](https://github.com/CraigMyles/raw2features/issues/5). + ## [0.1.0] - 2026-06-30 First public release. @@ -33,3 +102,4 @@ What 0.1.0 provides: harness, and six runnable tutorial notebooks. [0.1.0]: https://github.com/CraigMyles/raw2features/releases/tag/v0.1.0 +[0.1.1]: https://github.com/CraigMyles/raw2features/compare/v0.1.0...v0.1.1 diff --git a/CITATION.cff b/CITATION.cff index 70fa82c..7e5ec8e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -20,4 +20,4 @@ keywords: - foundation models - feature extraction license: MIT -version: "0.1.0" +version: "0.1.1" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15ef054..0200dc7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,22 @@ # Contributing to raw2features -raw2features is meant to be built *with* the community. The five seams - readers, -segmenters, patchers, embedders, sinks - are plugin points: you can add a model or -a storage backend by shipping a small package, no fork required. +raw2features is meant to be built *with* the community. Its six implementation seams - +readers, segmenters, patchers, embedders, sinks, and slide embedders - are Python +entry-point plugin groups. A third-party package can add an implementation or backend +without forking raw2features. + +The patch-model names accepted by the CLI are deliberately narrower: they come from the +provenance-complete registry bundled with raw2features. An external +`raw2features.embedders` entry point adds a loader family, not a new `--model` row by +itself. Python callers can pass external `Embedder` instances explicitly through +`embed_slide(..., embedders=[...])`; proposing a built-in CLI model still means adding a +reviewed registry row here. ## Dev setup ```bash uv sync --extra zarr --extra image --extra torch --extra models -uv run pytest -m "not slow" +uv run pytest -m "not slow and not network" uv run ruff check ``` @@ -18,28 +26,36 @@ Every model entry must be **provenance-complete** - its preprocessing is transcribed from the model's authoritative card/repo, and should never be guessed. If the preprocessing is uncertain, the model is not added. -1. Add a row to `models/registry.yaml` with **all** required fields: +1. Add a row to `src/raw2features/embedders/registry.yaml` with **all** required fields: - `source` (HF repo id / `torchvision://…` / local path) - - `weights_sha256` (verified at load; load fails loudly on mismatch) + - `weights_sha256`, `weights_revision`, and `weights_filename` (immutable artifact + identity; verified at load where raw2features handles the bytes) - `transform_source_url` (the model card / paper / repo the transform came from) - `input_size`, `mean`, `std`, `resize`, `interpolation` - - `embedding_dim`, `pooling` (`cls` / `mean` / `cls_mean_cat` / `pooled`) - - `license`, `gated` (HuggingFace gating?) + - `embedding_dim`, `pooling`, model family/constructor parameters, and any + recommended extraction MPP/patch size + - `license`, `gated` (Hugging Face access gate?), paper/source metadata, and DOI 2. If the model loads via timm/torchvision with a standard transform, that's all - the generic `TimmEmbedder` handles it. Only write a new `Embedder` subclass for bespoke loaders (e.g. CTransPath's modified Swin). -3. Register it: add an entry-point under `[project.entry-points."raw2features.embedders"]` - (in this repo) or ship it from your own package's entry-points. +3. If it needs a new loader family, register that implementation under + `[project.entry-points."raw2features.embedders"]`. Models using an existing family + need only the registry row and tests. 4. Add a test (a `MockEmbedder`-style shape/contract test is fine; a real-weights test should be marked `slow`). See `docs/adding_a_model.md` for a worked example. -## Adding a reader / segmenter / sink +## Adding another implementation plugin -Same shape: subclass the seam's ABC in `src/raw2features//base.py`, register -via entry-points. A missing optional dependency must degrade gracefully (the -plugin simply does not appear) - never break discovery. +Subclass the relevant ABC and register it through the matching +`raw2features.` entry-point group. The guaranteed discovery contract is small: +an importable entry point appears in `available()` and is returned by `get()`; an entry +point that cannot import (for example because its own optional dependency is absent) is +skipped without breaking discovery. The seam ABC defines the runtime methods and return +types. raw2features does not claim that an arbitrary third-party object conforms merely +because its entry point imports, so plugin packages should add their own end-to-end +contract test. ## Conventions diff --git a/README.md b/README.md index 156da9a..08fb023 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # raw2features

- raw2features: OME-Zarr whole-slide image in, patch-level and slide-level foundation-model embeddings out + raw2features: OME-Zarr whole-slide image in, patch-level and slide-level foundation-model embeddings out

Read a whole-slide image in **OME-Zarr / OME-NGFF** and emit **patch- and slide-level @@ -42,9 +42,11 @@ store too. (e.g. default 0.5 µm/px @ 224 px) by downsampling from the nearest finer pyramid level such that embeddings are comparable across slides and datasets. -- **Modular implementation.** `Reader`, `segmenter`, `patcher`, `embedder` and `sink` are - plugin seams exposed via Python entry-points: add a model or backend by - shipping a package. +- **Modular implementation.** Reader, segmenter, patcher, patch-embedder, + slide-embedder, and sink implementations are plugin seams exposed through Python + entry points. Entry points add implementations/families; the CLI's patch-model names + come from the bundled provenance registry, while Python callers can inject external + embedder instances through `embed_slide(..., embedders=[...])`. - **FAIR & provenance-first.** Stable models' weights are pinned to an **immutable HuggingFace revision** (or a sha256-pinned URL), with preprocessing sourced from each model's card. Every output records that provenance plus a 1:1 @@ -83,7 +85,7 @@ and the matching extra's comment in `pyproject.toml`. **Development** (from a clone, with [uv](https://docs.astral.sh/uv/)): ```bash -uv sync # core +uv sync --no-default-groups # dependency-lean core only uv sync --extra zarr --extra image --extra torch --extra models # full stack ``` diff --git a/assets/raw2features-diagram.png b/assets/raw2features-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..a758a43c8e105ef4567bfb39e62b707217b2a9e8 GIT binary patch literal 129372 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 + provenance | List what's installed for any component: `raw2features list embedders` (or `readers`, -`segmenters`, `patchers`, `sinks`). +`segmenters`, `patchers`, `sinks`, `slide_embedders`). --- @@ -75,7 +76,7 @@ on another (e.g. an x86 cluster). Verify with `raw2features version`. | `raw2features export-h5 ` | export features to HDF5 (one `.h5` per model); needs the `[h5]` extra | | `raw2features validate-store ` | check a store conforms to the embeddings-store spec | | `raw2features models` | list the model registry (name, dim, gated, source) | -| `raw2features list ` | list installed plugins for a component type (readers, segmenters, …) | +| `raw2features list ` | list installed plugins for a component type (readers, segmenters, patchers, embedders, sinks, slide_embedders) | | `raw2features version` | print the version | Every option below is also visible via `raw2features --help`. @@ -135,8 +136,9 @@ Any token passed on the command line is redacted from stored command provenance. | Option | Default | Meaning | |--------|---------|---------| | `-m, --model` | `resnet50` | model name; **repeatable** for multi-model (`-f`/`--feature-extractor` are aliases) | -| `--mpp` | per-model | target µm/px; default = each model's **recommended** MPP (0.5 for most pathology FMs, 1.0 for scale-agnostic). Pass a value to override (collapses all models to one grid) | -| `--patch-size` | per-model | patch side in px; default = each model's recommended size (224 / 448 CONCH / 512 conch_v1_5). Pass to force one size for all | +| `--mpp` | per-model | target µm/px; default = each model's **recommended** MPP (0.5 for most pathology FMs, 1.0 for scale-agnostic). A value overrides the scale while retaining each model's recommended patch size; combine it with `--patch-size` to force one shared grid | +| `--source-mpp` | source metadata | level-0 source µm/px override for an uncalibrated OME-Zarr whose spatial axes declare no physical unit; normally leave unset | +| `--patch-size` | per-model | patch side in px; default = each model's recommended size (224 / 448 CONCH / 512 conch_v1_5). A value overrides the size; combine it with `--mpp` to force one shared grid | | `--step` | = patch | stride in output px; `< patch` gives overlap | | `--no-seg` | off | tile the whole slide (skip tissue masking) | | `--segmenter` | `otsu` | tissue segmenter to use | @@ -211,6 +213,18 @@ Otherwise the patch is resampled and `achieved_mpp == target`. `--snap-to-level` forces a native level read (faster, MPP = that level); `--allow-upsample` permits going finer than level 0. `raw2features info` prints the exact plan. +### Float source pixels + +OME-NGFF float arrays do not necessarily declare whether RGB values use a normalized +`[0, 1]` range or a byte-like `[0, 255]` range. The OME-Zarr reader therefore chooses +one convention when the slide is opened and reuses it for every patch and pyramid level; +it never rescales one patch differently because that patch happens to be locally dark. +The decision uses the array fill value and a deterministic 3×3 lattice of level-0 +chunks (up to the first three channels): any finite observed value above 1 selects the +byte-like convention, otherwise the slide is treated as normalized. This bounded sample +avoids scanning an entire WSI at open. `read_region_channels` remains native-dtype and +unscaled for multiplex models. + ### Segmentation The default `otsu` segmenter runs Otsu-on-saturation + morphology on a low-res @@ -222,8 +236,9 @@ are pluggable via the `segmenters` seam - see ### Multi-model fan-out (decode-once) -With several `-f` flags, each patch is **read and decoded once** and reused across -every model in the batch - the slow part (IO + decode) is not repeated per model. +With several `-m` flags (`-f` is an alias), each patch is **read and decoded once** +and reused across every model in the batch - the slow part (IO + decode) is not +repeated per model. Each model applies **its own** preprocessing (mean/std/input size, sourced from its model card), and writes into its own `features/` array. @@ -269,18 +284,23 @@ re-runs are **additive and idempotent - no `--receipts-dir` required**: - A requested model already present and output-validated against its current model fingerprint is **skipped**. - A **missing** model is embedded and **appended in place**: `features/` is - added next to the existing arrays, decoded once at the store's existing patch - coordinates. Existing models and `coords` are left untouched. -- If **all** requested models are already present, the run does nothing - (`status: skipped`). -- If a store exists but its **patch geometry differs** (different `--mpp`, - `--patch-size`, segmentation, …), `embed` **errors** rather than mixing - incompatible patches. Use `--force` (rebuild from scratch) or a new output dir. + added next to the existing arrays when it shares that grid's geometry. Existing + models and `coords` are left untouched. +- A requested geometry not already in the store is added as another + `grids//` group. A difference in `--mpp`, `--patch-size`, segmentation, or + another geometry field is **not** an error and does not wipe existing grids or + mix incompatible rows in one grid. +- If **all** requested models and auxiliary outputs are already present, no patch + features are recomputed (a model-only run returns `status: skipped`). + +Use `--force` only when you intentionally want to rebuild the whole store from +scratch. ```bash raw2features embed slide.zarr out/ -m uni # writes features/uni raw2features embed slide.zarr out/ -m virchow2 # ADDS features/virchow2; uni untouched raw2features embed slide.zarr out/ -m uni -m virchow2 # both present -> nothing to do +raw2features embed slide.zarr out/ -m conch_v1_5 # ADDS its 0.5/512 grid; other grids untouched ``` The shared extraction/storage grid is identified by a **`grid_hash`**, recorded in @@ -311,13 +331,15 @@ model set: ``` reader, models (order-independent), segmenter / --no-seg, --mpp, --patch-size, ---step, --tissue-threshold, --features-dtype, --snap-to-level, --mpp-tolerance, ---allow-upsample, --amp +--source-mpp (when set), --step, --tissue-threshold, --features-dtype, +--stain-norm, --snap-to-level, --mpp-tolerance, --allow-upsample, --amp ``` Runtime-only knobs do **not** change the hash: -`--device`, `--batch-size`, `--emit-geojson`, `--emit-thumbnail`, -`--thumbnail-mpp`, `--max-px`, `--output-zarr-format`, `--force`. +`--device`, `--devices`, `--batch-size`, `--read-workers`, `--read-block`, +`--compile`, `--emit-geojson`, `--emit-thumbnail`, `--thumbnail-mpp`, `--max-px`, +`--slide-encoder`, `--qc`, `--qc-stain-norm`, `--qc-artifact-mpp`, +`--output-zarr-format`, `--force`. `--force` is the exception for control flow: it deliberately bypasses skipping and rebuilds the target without changing the content identity. @@ -341,15 +363,20 @@ Pass `--force` to ignore an existing store and rebuild it from scratch (all requested models recomputed, the store overwritten). It also bypasses a valid receipt's fast path. Deleting the output store (and any receipt) has the same effect. -### Important: side-products are skipped too +### Auxiliary outputs are produce-if-missing -Thumbnails and geojson are written only on a **fresh build**, not when a model is -appended to an existing store (the patches and mask are unchanged, so there is -nothing new to draw). And a fully-complete slide is skipped before any work, so -re-running `embed --emit-thumbnail` (or `--emit-geojson`) on it produces -**nothing new**. To add a thumbnail to an already-embedded slide, use the -standalone [`thumbnail`](#8-thumbnail--previews--qc-overlays) command (geojson -emits only during a fresh `embed`). +An explicit `--emit-thumbnail`, `--emit-geojson`, `--qc`, or `-s/--slide-encoder` +request is checked against the matching grid even when its patch features are already +complete. Missing outputs are produced without re-embedding patches; existing valid +outputs are left alone. These runtime products do not change the patch-grid identity or +receipt content hash. The standalone [`thumbnail`](#8-thumbnail--previews--qc-overlays) +and `slide-embed` commands remain convenient when you only want to add those products. + +In a multi-grid invocation, a slide encoder runs on the grid containing its required +patch encoder (for example, TITAN uses the `conch_v1_5` grid). Model-agnostic pooling +encoders run on each requested grid. Standalone `slide-embed` can infer an unambiguous +grid from the required patch model; otherwise pass `--grid` (and `--patch-model` when +needed). --- @@ -420,6 +447,7 @@ rows are also rejected; a per-output lock across independent invocations is not ```bash cd raw2features && uv sync --extra zarr --extra image --extra torch --extra models +# Add any model-specific extras / pinned git packages from docs/MODELS.md. export SLIDE_DIR=/path/to/raw # dir of *.zarr stores (top-level only) export OUT_DIR=/path/to/embeddings export MODELS="uni resnet50" # space-separated; each becomes a -m flag @@ -427,7 +455,11 @@ export HF_TOKEN=hf_... # only if MODELS includes a gated mode bash slurm/preflight.sh # validates venv/arch, models/HF, slide count mkdir -p logs # SLURM opens --output here before the job runs -N=$(ls -d "$SLIDE_DIR"/*.zarr | wc -l) +shopt -s nullglob +SLIDES=("$SLIDE_DIR"/*.zarr) +shopt -u nullglob +N=${#SLIDES[@]} +(( N > 0 )) || { echo "no *.zarr slides found" >&2; exit 1; } sbatch --array=0-$((N-1))%64 --export=ALL,SLIDE_DIR,OUT_DIR,MODELS,HF_TOKEN slurm/embed_array.sbatch ``` @@ -464,12 +496,16 @@ Notes: .patches.geojson # optional (--emit-geojson): per-patch polygons for QuPath .thumbnail.png # optional (--emit-thumbnail / thumbnail cmd) .thumbnail.overlay.png # optional QC overlay +..patches.geojson # corresponding sidecar for each additional grid +..thumbnail.overlay.png # corresponding overlay for each additional grid ``` A store nests its patch sets under `grids//` (one child per extraction geometry - `mpp0.5_px224`, etc.); a one-geometry run is just a single grid. Open one grid with the reader helper (`raw2features.core.store.open_grid(store)` returns the sole grid, or pass a -key), and `export`/`info` default to the sole grid (a multi-grid store wants `--grid`). +key), and `export`/`info` default to the sole grid (a multi-grid store wants `--grid`). The +first-created grid keeps the backward-compatible unsuffixed GeoJSON/overlay names; later +grids use their key as shown above. The plain thumbnail is shared by the slide. **Spatial-provenance contract (within a grid).** Row `i` refers to the same patch across **every** array of that grid: `coords[i]`, `grid_index[i]`, and `features/[i]` are @@ -479,7 +515,8 @@ pixel extent), any embedding is invertible to the exact slide region **Each grid's `.zattrs["raw2features"]`** records the full, self-describing header: `schema_version`, `provenance` (version, CLI, git SHA, host, GPU), `thumbnail` (if emitted), -`source` (uri, ngff_version, reader, mpp_level0, level_dimensions, level_downsamples), +`source` (uri, ngff_version, reader, mpp_level0, dimensions/downsamples, source axes, +per-axis physical scale, and source translation/origin), `patching` (target/achieved MPP, patch_px, read_level, read_px, resample, level0_patch, level0_step, n_patches, grid_shape, `coords_convention: level0_xy`), `segmentation`, and per-model `models.` (source, embedding_dim, input_size, pooling, mean, std, @@ -490,8 +527,10 @@ models, grid_hash}`) for discovery without opening each grid. Read it back: ```python -import zarr -g = zarr.open_group("out/.embeddings.zarr", mode="r") +from raw2features.core.store import open_grid + +# Omit the key only when the store contains exactly one grid. +g = open_grid("out/.embeddings.zarr", key="mpp0.5_px224") print(g["features/uni"].shape) # (N, 1024) print(dict(g.attrs)["raw2features"]["patching"]["achieved_mpp"]) ``` @@ -500,17 +539,20 @@ print(dict(g.attrs)["raw2features"]["patching"]["achieved_mpp"]) ## 11. FAQ / gotchas -**`embed --emit-thumbnail` produced no thumbnail.** The slide was already complete -(receipt + matching config) so the run was skipped before any work. Use the -standalone `raw2features thumbnail … --overlay` to add thumbnails to -already-embedded slides, or force a re-run (§7). +**`embed --emit-thumbnail` produced no thumbnail.** A missing thumbnail should be +produced even when the patch arrays are complete, while an existing valid thumbnail is +left unchanged. Check the command's error and output path; in a multi-grid store, +non-primary overlays have the grid key in their filename. The standalone +`raw2features thumbnail … --overlay` command is also available when only a preview is +needed. -**Adding a model didn't append - it recomputed everything.** Correct: the model -set is part of the config hash, so a different set is a different run and the store -is rewritten. Run all the models you want in a single `embed` invocation. +**What happens when I add a model?** A model with the same extraction geometry is +appended to that grid. A model with a different geometry gets a new grid. Existing +valid arrays are preserved; only a missing, damaged, legacy-unfingerprinted, or +stale-fingerprint model output is recomputed. **`verify` always says incomplete.** It must be given the **same content-affecting -flags** as the `embed` that produced the store (same `-f` models, `--mpp`, +flags** as the `embed` that produced the store (same `-m` models, `--mpp`, `--patch-size`, etc.) so the config hash matches. Pass the same output directory as `--out-dir` so the receipt is also bound to the intended target store. diff --git a/notebooks/01_quickstart.ipynb b/notebooks/01_quickstart.ipynb index 5a06f58..cc4a766 100644 --- a/notebooks/01_quickstart.ipynb +++ b/notebooks/01_quickstart.ipynb @@ -43,7 +43,7 @@ "IN_COLAB = \"google.colab\" in sys.modules\n", "HAVE_PKG = importlib.util.find_spec(\"raw2features\") is not None\n", "EXTRAS = \"zarr,image,torch,models\"\n", - "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.0) for reproducibility\n", + "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.1) for reproducibility\n", "if IN_COLAB or not HAVE_PKG:\n", " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", SPEC])\n", "\n", diff --git a/notebooks/02_visual_walkthrough.ipynb b/notebooks/02_visual_walkthrough.ipynb index 8e37a76..bff7f6e 100644 --- a/notebooks/02_visual_walkthrough.ipynb +++ b/notebooks/02_visual_walkthrough.ipynb @@ -52,7 +52,7 @@ "HAVE_PKG = importlib.util.find_spec(\"raw2features\") is not None\n", "\n", "EXTRAS = \"zarr,image,torch,models\"\n", - "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.0) for reproducibility\n", + "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.1) for reproducibility\n", "if IN_COLAB or not HAVE_PKG:\n", " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", SPEC])\n", "\n", diff --git a/notebooks/03_the_embeddings_store.ipynb b/notebooks/03_the_embeddings_store.ipynb index a85892e..d58d89d 100644 --- a/notebooks/03_the_embeddings_store.ipynb +++ b/notebooks/03_the_embeddings_store.ipynb @@ -44,7 +44,7 @@ "IN_COLAB = \"google.colab\" in sys.modules\n", "HAVE_PKG = importlib.util.find_spec(\"raw2features\") is not None\n", "EXTRAS = \"zarr,image,torch,models\"\n", - "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.0) for reproducibility\n", + "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.1) for reproducibility\n", "if IN_COLAB or not HAVE_PKG:\n", " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", SPEC])\n", "\n", diff --git a/notebooks/04_spatialdata.ipynb b/notebooks/04_spatialdata.ipynb index bd18a3d..00684d2 100644 --- a/notebooks/04_spatialdata.ipynb +++ b/notebooks/04_spatialdata.ipynb @@ -53,9 +53,9 @@ "HAVE_PKG = importlib.util.find_spec(\"raw2features\") is not None\n", "\n", "# SpatialData export needs the [spatialdata] extra (spatialdata + geopandas) on top of\n", - "# the resnet50 stack. After a PyPI release prefer a hard pin: raw2features[...]==0.1.0\n", + "# the resnet50 stack. After a PyPI release prefer a hard pin: raw2features[...]==0.1.1\n", "EXTRAS = \"zarr,image,torch,models,spatialdata\"\n", - "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.0) for reproducibility\n", + "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.1) for reproducibility\n", "\n", "if IN_COLAB or not HAVE_PKG:\n", " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", SPEC])\n", diff --git a/notebooks/05_quality_control.ipynb b/notebooks/05_quality_control.ipynb index a237d2d..38b91e2 100644 --- a/notebooks/05_quality_control.ipynb +++ b/notebooks/05_quality_control.ipynb @@ -51,7 +51,7 @@ "IN_COLAB = \"google.colab\" in sys.modules\n", "HAVE_PKG = importlib.util.find_spec(\"raw2features\") is not None\n", "EXTRAS = \"zarr,image,torch,models,grandqc\"\n", - "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.0) for reproducibility\n", + "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.1) for reproducibility\n", "if IN_COLAB or not HAVE_PKG:\n", " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", SPEC])\n", "\n", diff --git a/notebooks/06_cohort_quality_control.ipynb b/notebooks/06_cohort_quality_control.ipynb index bbab502..059fd2e 100644 --- a/notebooks/06_cohort_quality_control.ipynb +++ b/notebooks/06_cohort_quality_control.ipynb @@ -46,7 +46,7 @@ "IN_COLAB = \"google.colab\" in sys.modules\n", "HAVE_PKG = importlib.util.find_spec(\"raw2features\") is not None\n", "EXTRAS = \"zarr,image,torch,models,grandqc\"\n", - "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.0) for reproducibility\n", + "SPEC = f\"raw2features[{EXTRAS}]\" # PyPI; pin a version (==0.1.1) for reproducibility\n", "if IN_COLAB or not HAVE_PKG:\n", " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", SPEC])\n", "\n", diff --git a/pyproject.toml b/pyproject.toml index afbf0ea..cb23d46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "raw2features" -version = "0.1.0" +version = "0.1.1" description = "Read OME-Zarr whole-slide images and emit patch- and slide-level foundation-model embeddings - storage backend and model independently swappable." readme = "README.md" authors = [{ name = "Craig Myles", email = "me@craig.im" }] @@ -25,6 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Bio-Informatics", "Topic :: Scientific/Engineering :: Image Processing", "Topic :: Scientific/Engineering :: Medical Science Apps.", diff --git a/slurm/README.md b/slurm/README.md index f7cc526..dc6b283 100644 --- a/slurm/README.md +++ b/slurm/README.md @@ -8,12 +8,16 @@ per slide (or per batch of slides) - picking up where it left off if anything is You don't need to be a SLURM expert. You set a few environment variables (where the slides are, where output goes, which models), adjust the `#SBATCH` lines at the top of a script to match your cluster (partition, GPU, time limit), and submit it. Everything else is handled. +Install any model-specific extras or pinned git packages listed in +[`docs/MODELS.md`](../docs/MODELS.md) before preflight; the check validates model names and +HuggingFace authentication, but deliberately does not download every model on the login +node. ## Which script do I use? | Script | What it does | Use it when | |--------|--------------|-------------| -| **`preflight.sh`** | A read-only sanity check, run on the **login node**: is the venv working, do the slides exist, is HuggingFace access OK? | **Always run this first** - it catches a misconfiguration before you spend GPU hours discovering it. | +| **`preflight.sh`** | A read-only sanity check, run on the **login node**: is the venv working, do the slides exist, are model names known, and is HuggingFace authentication present when needed? | **Always run this first** - it catches a misconfiguration before you spend GPU hours discovering it. It cannot confirm that every model gate has been accepted. | | **`embed_array.sbatch`** | One cluster job **per slide**. Simplest mapping; one log file per slide. | The default. Small-to-medium cohorts. | | **`embed_cohort.sbatch`** | One cluster job **per batch ("shard") of slides**, loading the models **once** per job instead of once per slide. | Large cohorts, where re-loading the model for every slide would waste time. | @@ -36,7 +40,11 @@ bash slurm/preflight.sh # 3. Submit one job per slide (array size = number of slides). mkdir -p logs -N=$(ls -d "$SLIDE_DIR"/*.zarr | wc -l) +shopt -s nullglob +SLIDES=("$SLIDE_DIR"/*.zarr) +shopt -u nullglob +N=${#SLIDES[@]} +(( N > 0 )) || { echo "no *.zarr slides found" >&2; exit 1; } sbatch --array=0-$((N-1))%64 \ --export=ALL,SLIDE_DIR,OUT_DIR,MODELS,HF_TOKEN \ slurm/embed_array.sbatch @@ -52,7 +60,14 @@ top of each script. The ones you're most likely to touch: - `MODELS` - which encoders to run (space-separated). - `MPP` - extraction scale; leave blank to use each model's recommended scale. -- `AMP` - precision (`bf16` is a good default on modern GPUs; `auto` uses each model's card). +- `SOURCE_MPP` - level-0 source calibration for OME-Zarr inputs whose spatial axes + declare no physical unit; leave blank for normally calibrated sources. The array + script passes it identically to `verify` and `embed`. +- `PATCH` - patch side; leave blank to use each model's recommended size. Set it only + when you intentionally want one size for every model. +- `STEP` - stride; leave blank for one patch-width (no overlap). +- `AMP` - precision; keep `auto` to use each model's declared inference precision. Set + `bf16` explicitly only when the selected models and GPUs support it. - `BATCH_SIZE` - lower it if a GPU runs out of memory. - `READ_BLOCK` - read patches in NxN blocks; bigger means fewer, larger reads, which helps on a slow shared filesystem (try `16`) at the cost of host RAM. diff --git a/slurm/embed_array.sbatch b/slurm/embed_array.sbatch index bc55787..9c8df13 100755 --- a/slurm/embed_array.sbatch +++ b/slurm/embed_array.sbatch @@ -23,7 +23,11 @@ # export MODELS="uni resnet50" # gated models need HF_TOKEN exported # export HF_TOKEN=hf_... # only if MODELS includes a gated one # mkdir -p logs -# N=$(ls -d "$SLIDE_DIR"/*.zarr 2>/dev/null | wc -l) +# shopt -s nullglob +# SLIDES=("$SLIDE_DIR"/*.zarr) +# shopt -u nullglob +# N=${#SLIDES[@]} +# (( N > 0 )) || { echo "no *.zarr slides found" >&2; exit 1; } # sbatch --array=0-$((N-1))%64 \ # --export=ALL,SLIDE_DIR,OUT_DIR,MODELS,HF_TOKEN \ # slurm/embed_array.sbatch @@ -52,12 +56,13 @@ export LC_COLLATE=C : "${RECEIPTS_DIR:=$OUT_DIR/receipts}" : "${REPO_DIR:=$HOME/raw2features}" : "${VENV:=$REPO_DIR/.venv}" -: "${MODELS:=uni resnet50}" # space-separated; each becomes a -f flag +: "${MODELS:=uni resnet50}" # space-separated; each becomes a -m flag : "${MPP:=}" # empty => auto (each model's recommended MPP) -: "${PATCH:=224}" +: "${SOURCE_MPP:=}" # level-0 MPP override for uncalibrated sources +: "${PATCH:=}" # empty => each model's recommended patch size : "${STEP:=}" # empty => no overlap (step == patch) : "${TISSUE_THRESHOLD:=0.1}" -: "${AMP:=bf16}" +: "${AMP:=auto}" # each model's declared inference precision : "${BATCH_SIZE:=256}" : "${READ_BLOCK:=8}" # read patches in NxN blocks (1=off); bump to 16 on a latency-bound shared FS : "${EMIT_GEOJSON:=0}" # 1 => also write .patches.geojson @@ -91,28 +96,29 @@ set +u source "$VENV/bin/activate" set -u -# ---- repeated -f flags + the content-affecting flags shared by verify+embed +# ---- repeated -m flags + the content-affecting flags shared by verify+embed # These MUST be identical for `verify` and `embed` so the config hash matches. MODEL_FLAGS=() -for m in $MODELS; do MODEL_FLAGS+=(-f "$m"); done - -# Only pin --mpp when MPP is set; empty => let the CLI auto-resolve the model's -# recommended MPP (identical for verify + embed, so the config hash still matches). -MPP_FLAG=() -[ -n "$MPP" ] && MPP_FLAG=(--mpp "$MPP") +for m in $MODELS; do MODEL_FLAGS+=(-m "$m"); done COMMON=( "${MODEL_FLAGS[@]}" --reader omezarr - "${MPP_FLAG[@]}" - --patch-size "$PATCH" --tissue-threshold "$TISSUE_THRESHOLD" --amp "$AMP" ) +# Add optional geometry overrides directly to COMMON. Avoid expanding empty arrays: +# Bash 3.2 treats those as unbound under `set -u` (still common on login nodes). +[[ -n "$MPP" ]] && COMMON+=(--mpp "$MPP") +[[ -n "$SOURCE_MPP" ]] && COMMON+=(--source-mpp "$SOURCE_MPP") +[[ -n "$PATCH" ]] && COMMON+=(--patch-size "$PATCH") [[ -n "$STEP" ]] && COMMON+=(--step "$STEP") # ---- skip-if-complete (validates the actual output, not just the receipt) - -if raw2features verify "$SLIDE" --receipts-dir "$RECEIPTS_DIR" --out-dir "$OUT_DIR" "${COMMON[@]}" --quiet; then +# An explicit auxiliary-output request must reach `embed`: its own completion path +# produces a missing GeoJSON without re-embedding otherwise-complete patch arrays. +if [[ "$EMIT_GEOJSON" != "1" ]] && \ + raw2features verify "$SLIDE" --receipts-dir "$RECEIPTS_DIR" --out-dir "$OUT_DIR" "${COMMON[@]}" --quiet; then echo "[$(date -u +%FT%TZ)] already complete; skipping ${SLIDE}" exit 0 fi diff --git a/slurm/embed_cohort.sbatch b/slurm/embed_cohort.sbatch index c4c82b3..cf03357 100644 --- a/slurm/embed_cohort.sbatch +++ b/slurm/embed_cohort.sbatch @@ -31,16 +31,18 @@ set -euo pipefail : "${SLIDE_DIR:?set SLIDE_DIR to the directory holding the slide stores}" : "${OUT_DIR:?set OUT_DIR for the *.embeddings.zarr outputs}" -: "${NUM_SHARDS:?set NUM_SHARDS = the array size (0-$((NUM_SHARDS-1)))}" +: "${NUM_SHARDS:?set NUM_SHARDS to the submitted array size}" : "${GLOB:=*.zarr}" : "${RECEIPTS_DIR:=$OUT_DIR/receipts}" : "${REPO_DIR:=$HOME/raw2features}" : "${VENV:=$REPO_DIR/.venv}" : "${MODELS:=uni resnet50}" : "${MPP:=}" # empty => auto (each model's recommended MPP) -: "${PATCH:=224}" +: "${SOURCE_MPP:=}" # level-0 MPP override for uncalibrated sources +: "${PATCH:=}" # empty => each model's recommended patch size +: "${STEP:=}" # empty => no overlap (step == patch) : "${TISSUE_THRESHOLD:=0.1}" -: "${AMP:=auto}" # auto = each model's card precision (fp32 for current models) +: "${AMP:=auto}" # each model's declared inference precision : "${READ_WORKERS:=16}" # 16 suits a latency-bound shared FS; 8 on local NVMe : "${READ_BLOCK:=8}" # read patches in NxN blocks (1=off); 16 helps on a latency-bound shared FS, costs host RAM : "${BATCH_SIZE:=256}" @@ -53,24 +55,25 @@ source "$VENV/bin/activate" set -u MODEL_FLAGS=() -for m in $MODELS; do MODEL_FLAGS+=(-f "$m"); done - -# Only pin --mpp when MPP is set; empty => auto-resolve each model's recommended MPP. -MPP_FLAG=() -[ -n "$MPP" ] && MPP_FLAG=(--mpp "$MPP") +for m in $MODELS; do MODEL_FLAGS+=(-m "$m"); done echo "[$(date -u +%FT%TZ)] shard ${SLURM_ARRAY_TASK_ID:-0}/${NUM_SHARDS} on $(hostname)" -srun raw2features embed-many "$SLIDE_DIR" "$OUT_DIR" \ - --glob "$GLOB" \ - "${MODEL_FLAGS[@]}" \ - "${MPP_FLAG[@]}" \ - --patch-size "$PATCH" \ - --tissue-threshold "$TISSUE_THRESHOLD" \ - --amp "$AMP" \ - --read-workers "$READ_WORKERS" \ - --read-block "$READ_BLOCK" \ - --batch-size "$BATCH_SIZE" \ - --receipts-dir "$RECEIPTS_DIR" \ - --shard-index "${SLURM_ARRAY_TASK_ID:-0}" \ +CMD=( + raw2features embed-many "$SLIDE_DIR" "$OUT_DIR" + --glob "$GLOB" + "${MODEL_FLAGS[@]}" + --tissue-threshold "$TISSUE_THRESHOLD" + --amp "$AMP" + --read-workers "$READ_WORKERS" + --read-block "$READ_BLOCK" + --batch-size "$BATCH_SIZE" + --receipts-dir "$RECEIPTS_DIR" + --shard-index "${SLURM_ARRAY_TASK_ID:-0}" --num-shards "$NUM_SHARDS" +) +[[ -n "$MPP" ]] && CMD+=(--mpp "$MPP") +[[ -n "$SOURCE_MPP" ]] && CMD+=(--source-mpp "$SOURCE_MPP") +[[ -n "$PATCH" ]] && CMD+=(--patch-size "$PATCH") +[[ -n "$STEP" ]] && CMD+=(--step "$STEP") +srun "${CMD[@]}" echo "[$(date -u +%FT%TZ)] shard ${SLURM_ARRAY_TASK_ID:-0} finished" diff --git a/slurm/preflight.sh b/slurm/preflight.sh index 3c02caa..69d350b 100755 --- a/slurm/preflight.sh +++ b/slurm/preflight.sh @@ -1,7 +1,7 @@ #!/bin/bash # raw2features SLURM pre-flight: run this on your cluster's LOGIN node before # submitting embed_array.sbatch. It validates the environment so you don't burn -# a whole array on a misconfiguration (wrong-arch venv, missing HF access, empty +# a whole array on a misconfiguration (wrong-arch venv, missing HF login, empty # slide dir, ...). Read-only except for `mkdir -p` of OUT_DIR/logs. # # New here? slurm/README.md explains the cluster workflow + which script to use. @@ -56,27 +56,31 @@ else fi echo "[3] models ($MODELS) + HuggingFace access" -if command -v python >/dev/null 2>&1; then - GATED=$(python - </dev/null +if ! command -v python >/dev/null 2>&1; then + bad "python is unavailable after venv activation" +elif GATED=$(R2F_PREFLIGHT_MODELS="$MODELS" python - <<'PY' 2>/dev/null +import os from raw2features.embedders.model_registry import load_registry -reg = load_registry(); req = "$MODELS".split() +reg = load_registry(); req = os.environ["R2F_PREFLIGHT_MODELS"].split() unknown = [m for m in req if m not in reg] gated = [m for m in req if m in reg and reg[m].gated] print("UNKNOWN " + " ".join(unknown)) print("GATED " + " ".join(gated)) PY -) +); then UNK=$(sed -n 's/^UNKNOWN //p' <<<"$GATED"); GAT=$(sed -n 's/^GATED //p' <<<"$GATED") [[ -n "${UNK// }" ]] && bad "unknown model(s): $UNK (see 'raw2features models')" || ok "all models known" if [[ -n "${GAT// }" ]]; then if WHO=$(python -c "from huggingface_hub import whoami; print(whoami()['name'])" 2>/dev/null); then - ok "gated models [$GAT] -> HF authenticated as '$WHO' (ensure access is granted on the model pages)" + ok "gated models [$GAT] -> HF authenticated as '$WHO' (authentication only; ensure each model gate is accepted)" else - bad "gated models [$GAT] but no HF login -- export HF_TOKEN (and pass it via --export) or 'huggingface-cli login'" + bad "gated models [$GAT] but no HF login -- export HF_TOKEN (and pass it via --export) or run 'hf auth login'" fi else ok "no gated models requested (no HF token needed)" fi +else + bad "could not load the raw2features model registry in this environment" fi echo "[4] output + logs" diff --git a/src/raw2features/__init__.py b/src/raw2features/__init__.py index deea934..29e2df4 100644 --- a/src/raw2features/__init__.py +++ b/src/raw2features/__init__.py @@ -1,15 +1,18 @@ """raw2features - read OME-Zarr WSIs and emit patch-level FM embeddings. -The storage backend (reader) and the model (embedder) are independently -swappable via six plugin seams: readers, segmenters, patchers, embedders, sinks, -slide_embedders (see ``raw2features.core.plugins.SEAMS``). Third parties add a -backend or model by shipping a package that registers an entry-point in the -matching ``raw2features.`` group - no fork required. Those group names are -the public plugin contract. +The storage backend (reader) and model implementation are independently swappable +via six plugin seams: readers, segmenters, patchers, embedders, sinks, and +slide_embedders (see ``raw2features.core.plugins.SEAMS``). Third parties add an +implementation through the matching ``raw2features.`` entry-point group. +For embedders, that entry point is a loader family used by the packaged model +registry; it does not add a new CLI ``--model`` name. Programmatic callers can pass +external patch-model instances through ``embed_slide(..., embedders=[...])``. +Those group names and the explicit ``embedders=`` hook are the public plugin contract. Public API (stable import surface - couple to these, not to deep module paths): -* ``run_slide(slide_path, out_dir, cfg)`` / ``RunConfig`` - embed one slide. +* ``embed_slide(slide_path, out_dir, cfg)`` - high-level per-model geometry runner. +* ``run_slide(slide_path, out_dir, cfg)`` / ``RunConfig`` - single-grid primitive. * ``register`` / ``available`` / ``get`` - the plugin registry. * ``write_patches_geojson`` - patch grid -> QuPath-readable GeoJSON. * ``validate_store`` - conformance-check an embeddings store against docs/SPEC.md. @@ -22,10 +25,11 @@ from typing import TYPE_CHECKING -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "__version__", + "embed_slide", "run_slide", "RunConfig", "register", @@ -37,14 +41,14 @@ if TYPE_CHECKING: # import-time only for type checkers; never imported at runtime from raw2features.core.plugins import available, get, register - from raw2features.pipeline.runner import RunConfig, run_slide + from raw2features.pipeline.runner import RunConfig, embed_slide, run_slide from raw2features.sinks.zarr_sink import write_patches_geojson from raw2features.spec import validate_store def __getattr__(name: str): """Resolve a blessed public name on first access (PEP 562 lazy re-export).""" - if name in ("run_slide", "RunConfig"): + if name in ("embed_slide", "run_slide", "RunConfig"): from raw2features.pipeline import runner return getattr(runner, name) diff --git a/src/raw2features/cli/_validation.py b/src/raw2features/cli/_validation.py new file mode 100644 index 0000000..72921b3 --- /dev/null +++ b/src/raw2features/cli/_validation.py @@ -0,0 +1,55 @@ +"""Shared validation for content/runtime options exposed by several commands.""" + +from __future__ import annotations + +import math + +import typer + +AMP_CHOICES = ("auto", "fp32", "bf16", "fp16") + + +def validate_amp(value: str) -> None: + """Reject an unknown precision before model discovery or output mutation.""" + + if value not in AMP_CHOICES: + choices = ", ".join(AMP_CHOICES) + raise typer.BadParameter(f"must be one of: {choices}", param_hint="--amp") + + +def validate_batch_size(value: int) -> None: + """Reject empty/negative batches before they reach the embedding loop.""" + + if value <= 0: + raise typer.BadParameter("must be greater than zero", param_hint="--batch-size") + + +def validate_positive_float(value: float | None, param_hint: str) -> None: + """Reject zero, negative, NaN, and infinite physical scales.""" + + if value is not None and (not math.isfinite(value) or value <= 0): + raise typer.BadParameter( + "must be finite and greater than zero", param_hint=param_hint + ) + + +def validate_positive_int(value: int | None, param_hint: str) -> None: + """Reject zero or negative pixel sizes/strides.""" + + if value is not None and value <= 0: + raise typer.BadParameter("must be greater than zero", param_hint=param_hint) + + +def validate_geometry( + *, + mpp: float | None = None, + patch_size: int | None = None, + step: int | None = None, + source_mpp: float | None = None, +) -> None: + """Validate shared CLI geometry before discovery, loading, or output mutation.""" + + validate_positive_float(mpp, "--mpp") + validate_positive_int(patch_size, "--patch-size") + validate_positive_int(step, "--step") + validate_positive_float(source_mpp, "--source-mpp") diff --git a/src/raw2features/cli/benchmark.py b/src/raw2features/cli/benchmark.py index 18ae059..9992424 100644 --- a/src/raw2features/cli/benchmark.py +++ b/src/raw2features/cli/benchmark.py @@ -26,6 +26,8 @@ slide_id_from_path, ) +from ._validation import validate_amp, validate_batch_size, validate_geometry + def _print_run(slide_id: str, rep: dict, idx: int) -> None: typer.echo( @@ -43,11 +45,16 @@ def _print_run(slide_id: str, rep: dict, idx: int) -> None: def benchmark( slides: list[str] = typer.Argument(..., help="OME-Zarr slide path(s)."), feature_extractor: list[str] = typer.Option( - ["resnet50"], "--model", "-m", "--feature-extractor", "-f", - help="Model(s); repeatable. (--feature-extractor/-f are aliases.)" + ["resnet50"], + "--model", + "-m", + "--feature-extractor", + "-f", + help="Model(s); repeatable. (--feature-extractor/-f are aliases.)", ), mpp: float | None = typer.Option( - None, "--mpp", + None, + "--mpp", help="Target µm/px; default = the model's recommended scale (like embed), so " "the benchmark profiles at the same scale production runs at.", ), @@ -76,8 +83,10 @@ def benchmark( 8, "--read-workers", help="Concurrent patch-decode threads (read parallelism)." ), read_block: int = typer.Option( - 1, "--read-block", help="Read patches in NxN blocks (1=off). Bigger helps " - "remote/slow storage, costs RAM; bit-identical. Try 8 local / 16 remote." + 1, + "--read-block", + help="Read patches in NxN blocks (1=off). Bigger helps " + "remote/slow storage, costs RAM; bit-identical. Try 8 local / 16 remote.", ), compile: bool = typer.Option( False, @@ -97,6 +106,9 @@ def benchmark( hf_token: str | None = typer.Option(None, "--hf-token", envvar="HF_TOKEN"), ) -> None: """Profile the embed pipeline and report per-stage timing + throughput.""" + validate_amp(amp) + validate_batch_size(batch_size) + validate_geometry(mpp=mpp, patch_size=patch_size) if hf_token: os.environ["HF_TOKEN"] = hf_token os.environ["HUGGING_FACE_HUB_TOKEN"] = hf_token diff --git a/src/raw2features/cli/embed.py b/src/raw2features/cli/embed.py index 5cba14a..ae5a444 100644 --- a/src/raw2features/cli/embed.py +++ b/src/raw2features/cli/embed.py @@ -11,6 +11,8 @@ from raw2features.pipeline.runner import RunConfig, embed_slide from raw2features.viz import DEFAULT_THUMBNAIL_MPP +from ._validation import validate_amp, validate_batch_size, validate_geometry + def embed( slide: str = typer.Argument(..., help="Path to an OME-Zarr store."), @@ -18,8 +20,12 @@ def embed( ..., help="Output directory for the embeddings zarr." ), feature_extractor: list[str] = typer.Option( - ["resnet50"], "--model", "-m", "--feature-extractor", "-f", - help="Model(s); repeatable. (--feature-extractor/-f are aliases.)" + ["resnet50"], + "--model", + "-m", + "--feature-extractor", + "-f", + help="Model(s); repeatable. (--feature-extractor/-f are aliases.)", ), mpp: float | None = typer.Option( None, @@ -122,7 +128,7 @@ def embed( [], "--qc", help="Per-patch QC producer(s) writing grids//qc// (e.g. grandqc). " - "Needs the producer's extra, e.g. pip install \"raw2features[grandqc]\".", + 'Needs the producer\'s extra, e.g. pip install "raw2features[grandqc]".', ), qc_stain_norm: str | None = typer.Option( None, @@ -157,6 +163,11 @@ def embed( store in place and skips models already computed (no WSI re-read for them). Use ``--force`` to overwrite the store instead. """ + validate_amp(amp) + validate_batch_size(batch_size) + validate_geometry( + mpp=mpp, patch_size=patch_size, step=step, source_mpp=source_mpp + ) if hf_token: os.environ["HF_TOKEN"] = hf_token os.environ["HUGGING_FACE_HUB_TOKEN"] = hf_token @@ -175,6 +186,8 @@ def embed( # Resolve and preview the per-model extraction geometry (one grid per group). groups = resolve_geometry(models, mpp, patch_size, geometry_config) + if not groups: + raise typer.BadParameter("at least one model/extraction is required") if len(groups) > 1: plan = " · ".join( f"{','.join(g.models)} -> {g.mpp:g}/{g.patch_px}" for g in groups @@ -186,14 +199,18 @@ def embed( f"geometry: {','.join(g0.models)} -> {g0.mpp:g}/{g0.patch_px} ({g0.source})" ) + # RunConfig carries one concrete grid geometry for the run_slide primitive. + # embed_slide replaces it for every resolved group, so seed it with a real group + # rather than treating an otherwise meaningful 1.0/224 geometry as a sentinel. + representative = groups[0] cfg = RunConfig( models=models, reader=reader, segmenter=segmenter, no_seg=no_seg, - target_mpp=mpp if mpp is not None else 1.0, # per-group geometry overrides this + target_mpp=representative.mpp, source_mpp=source_mpp, - patch_px=patch_size if patch_size is not None else 224, + patch_px=representative.patch_px, step_px=step, tissue_threshold=tissue_threshold, features_dtype=features_dtype, diff --git a/src/raw2features/cli/embed_many.py b/src/raw2features/cli/embed_many.py index 4eacb01..43c5937 100644 --- a/src/raw2features/cli/embed_many.py +++ b/src/raw2features/cli/embed_many.py @@ -15,6 +15,7 @@ from __future__ import annotations import glob as globmod +import math import os import sys import time @@ -35,6 +36,8 @@ slide_id_from_path, ) +from ._validation import validate_amp, validate_batch_size, validate_geometry + class _WorkerStartupError(RuntimeError): """One or more slide-parallel workers could not load their models.""" @@ -54,8 +57,12 @@ def embed_many( "*.zarr", "--glob", help="Top-level glob for slides (never recurses)." ), feature_extractor: list[str] = typer.Option( - ["resnet50"], "--model", "-m", "--feature-extractor", "-f", - help="Model(s); repeatable. (--feature-extractor/-f are aliases.)" + ["resnet50"], + "--model", + "-m", + "--feature-extractor", + "-f", + help="Model(s); repeatable. (--feature-extractor/-f are aliases.)", ), mpp: float | None = typer.Option( None, @@ -81,22 +88,27 @@ def embed_many( tissue_threshold: float = typer.Option(0.1, "--tissue-threshold"), features_dtype: str = typer.Option("float16", "--features-dtype"), stain_norm: str | None = typer.Option( - None, "--stain-norm", + None, + "--stain-norm", help="Stain-normalize each patch before embedding (macenko|reinhard|vahadane).", ), slide_encoder: list[str] = typer.Option( - [], "--slide-encoder", "-s", + [], + "--slide-encoder", + "-s", help="Slide-level encoder(s) to run after patch embedding (e.g. titan).", ), qc: list[str] = typer.Option( [], "--qc", help="Per-patch QC producer(s) writing qc// (e.g. grandqc)." ), qc_stain_norm: str | None = typer.Option( - None, "--qc-stain-norm", + None, + "--qc-stain-norm", help="Normalize the QC input first (macenko|reinhard|vahadane).", ), qc_artifact_mpp: str = typer.Option( - "1.5", "--qc-artifact-mpp", + "1.5", + "--qc-artifact-mpp", help="GrandQC artifact µm/px (1.0|1.5|2.0); coarser = less focus-sensitive.", ), device: str = typer.Option( @@ -122,7 +134,8 @@ def embed_many( help="auto | fp32 | bf16 | fp16 (auto = each model's card precision)", ), read_workers: int = typer.Option( - 8, "--read-workers", + 8, + "--read-workers", help="Concurrent patch-decode threads (16 suits a shared/parallel FS).", ), read_block: int = typer.Option( @@ -151,17 +164,24 @@ def embed_many( receipts_dir: str | None = typer.Option(None, "--receipts-dir"), force: bool = typer.Option(False, "--force"), config: str | None = typer.Option( - None, "--config", + None, + "--config", help="YAML extraction plan ('extractions:' list of {model, mpp?, patch_px?}).", ), manifest: str | None = typer.Option( - None, "--manifest", + None, + "--manifest", help="CSV of slides (path[,source_mpp]) instead of globbing slide_dir; " "relative paths resolve against slide_dir.", ), hf_token: str | None = typer.Option(None, "--hf-token", envvar="HF_TOKEN"), ) -> None: """Warm worker: build the models once and embed this shard's slides.""" + validate_amp(amp) + validate_batch_size(batch_size) + validate_geometry( + mpp=mpp, patch_size=patch_size, step=step, source_mpp=source_mpp + ) from raw2features.slide_embedders.model_registry import ( validate_slide_encoder_names, ) @@ -186,7 +206,13 @@ def embed_many( if manifest: from raw2features.core.config import load_manifest - rows = load_manifest(manifest) + try: + rows = load_manifest(manifest) + except (OSError, ValueError) as exc: + typer.echo( + redact_uri_credentials(f"Error: invalid manifest: {exc}"), err=True + ) + raise typer.Exit(1) from exc try: all_rows = _resolve_manifest_sources(rows, slide_dir) except Exception as exc: # noqa: BLE001 - do not echo malformed credentials @@ -203,6 +229,11 @@ def embed_many( where = manifest or os.path.join(slide_dir, glob) typer.echo(f"Error: no slides found ({where})", err=True) raise typer.Exit(1) + try: + _validate_source_mpps(all_rows) + except ValueError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc try: _validate_unique_output_ids(all_rows) except ValueError as exc: @@ -226,14 +257,21 @@ def embed_many( else: models = list(feature_extractor) + from raw2features.embedders.model_registry import resolve_geometry + + groups = resolve_geometry(models, mpp, patch_size, geometry_config) + if not groups: + raise typer.BadParameter("at least one model/extraction is required") + representative = groups[0] + cfg = RunConfig( models=models, reader=reader, segmenter=segmenter, no_seg=no_seg, - target_mpp=mpp if mpp is not None else 1.0, # per-group geometry overrides this + target_mpp=representative.mpp, source_mpp=source_mpp, - patch_px=patch_size if patch_size is not None else 224, + patch_px=representative.patch_px, step_px=step, tissue_threshold=tissue_threshold, features_dtype=features_dtype, @@ -260,13 +298,28 @@ def embed_many( try: if len(device_list) > 1: done, skipped, failed = _embed_shard_parallel( - shard, out_dir, cfg, device_list, receipts_dir, cli, force, - mpp, patch_size, geometry_config, + shard, + out_dir, + cfg, + device_list, + receipts_dir, + cli, + force, + mpp, + patch_size, + geometry_config, ) else: done, skipped, failed = _embed_shard_serial( - shard, out_dir, cfg, receipts_dir, cli, force, - mpp, patch_size, geometry_config, + shard, + out_dir, + cfg, + receipts_dir, + cli, + force, + mpp, + patch_size, + geometry_config, ) except _WorkerStartupError as exc: typer.echo(f"Error: {exc}", err=True) @@ -338,6 +391,26 @@ def _validate_unique_output_ids(rows: list[dict]) -> None: raise ValueError("\n".join(lines)) +def _validate_source_mpps(rows: list[dict]) -> None: + """Validate every per-slide calibration before sharding or loading models.""" + + for index, row in enumerate(rows, 1): + if "source_mpp" not in row: + continue + try: + value = float(row["source_mpp"]) + except (TypeError, ValueError) as exc: + raise ValueError( + f"manifest row {index} source_mpp must be a finite number greater " + "than zero" + ) from exc + if not math.isfinite(value) or value <= 0: + raise ValueError( + f"manifest row {index} source_mpp must be a finite number greater " + "than zero" + ) + + def _with_source_mpp(cfg: RunConfig, row: dict) -> RunConfig: """Apply a manifest row's per-slide ``source_mpp`` override (else cfg unchanged).""" if "source_mpp" in row: diff --git a/src/raw2features/cli/slide_embed.py b/src/raw2features/cli/slide_embed.py index d070012..256ecfc 100644 --- a/src/raw2features/cli/slide_embed.py +++ b/src/raw2features/cli/slide_embed.py @@ -67,15 +67,13 @@ def slide_embed( import zarr from raw2features.core.device import resolve_device - from raw2features.core.store import grid_for_model, grid_keys, open_grid from raw2features.slide_embedders.encoding import ( encode_slide_embedding, - resolve_slide_patch_model, + resolve_slide_grid, slide_embedding_is_complete, write_slide_embedding, ) from raw2features.slide_embedders.model_registry import ( - get_slide_spec, validate_slide_encoder_names, ) @@ -103,44 +101,13 @@ def slide_embed( # consolidated metadata predates any slide/ group we add here. Reading the # live metadata avoids stale-key KeyErrors; we re-consolidate at the end. root = zarr.open_group(path, mode="r+", use_consolidated=False) - keys = grid_keys(root) - if not keys: - typer.echo( - "Error: store has no grids/ group; expected a v0.1 embeddings store.", - err=True, - ) - raise typer.Exit(1) for slide_model_name in slide_encoder: try: - if grid is not None: - if grid not in keys: - raise ValueError( - f"Unknown grid {grid!r}. Available grid keys: {keys}" - ) - selected_grid = grid - elif len(keys) == 1: - selected_grid = keys[0] - elif patch_model is not None: - selected_grid = grid_for_model(root, patch_model) - else: - required = get_slide_spec(slide_model_name).patch_encoder - if required == "any": - raise ValueError( - f"Slide encoder {slide_model_name!r} is model-agnostic and " - f"the store has multiple grids {keys}; pass --grid or " - "--patch-model to choose one." - ) - selected_grid = grid_for_model(root, required) - group = open_grid(root, selected_grid) - except (KeyError, ValueError) as exc: - typer.echo(f"Error: {exc}", err=True) - raise typer.Exit(1) from exc - - try: - selected_patch_model = resolve_slide_patch_model( - group, + selected_grid, group, selected_patch_model = resolve_slide_grid( + root, slide_model_name, + grid=grid, patch_model=patch_model, ) except (KeyError, ValueError) as exc: diff --git a/src/raw2features/cli/verify.py b/src/raw2features/cli/verify.py index 7b8453a..83cd409 100644 --- a/src/raw2features/cli/verify.py +++ b/src/raw2features/cli/verify.py @@ -20,6 +20,8 @@ slide_id_from_path, ) +from ._validation import validate_amp, validate_geometry + def verify( slide: str = typer.Argument(...), @@ -35,6 +37,11 @@ def verify( mpp: float | None = typer.Option( None, "--mpp", help="Target µm/px (default: model recommended; matches embed)." ), + source_mpp: float | None = typer.Option( + None, + "--source-mpp", + help="Source level-0 µm/px override (must match the embed run).", + ), patch_size: int | None = typer.Option( None, "--patch-size", help="Patch size in px (default: model recommended)." ), @@ -62,7 +69,12 @@ def verify( quiet: bool = typer.Option(False, "--quiet"), ) -> None: """Exit 0 if the slide is already complete & output-validated, else exit 1.""" + validate_amp(amp) + validate_geometry( + mpp=mpp, patch_size=patch_size, step=step, source_mpp=source_mpp + ) from raw2features.core.device import resolve_device + from raw2features.embedders.model_registry import resolve_geometry from raw2features.pipeline.runner import resolve_run geometry_config = None @@ -73,13 +85,33 @@ def verify( models = list(dict.fromkeys(e["model"] for e in geometry_config)) else: models = list(feature_extractor) + # Verification has no programmatic external-embedder instance from which to + # derive a current contract or geometry. Fail closed with the established clean + # diagnostic before the geometry resolver rejects the unknown name. + from raw2features.embedders.model_registry import load_registry + + registered = load_registry() + unknown = [model for model in models if model not in registered] + if unknown: + if not quiet: + typer.echo( + "cannot derive the current output contract for unregistered " + f"model(s) {', '.join(unknown)}; incomplete", + err=True, + ) + raise typer.Exit(code=1) + groups = resolve_geometry(models, mpp, patch_size, geometry_config) + if not groups: + raise typer.BadParameter("at least one model/extraction is required") + representative = groups[0] cfg = RunConfig( models=models, reader=reader, segmenter=segmenter, no_seg=no_seg, - target_mpp=mpp if mpp is not None else 1.0, # per-group geometry overrides this - patch_px=patch_size if patch_size is not None else 224, + target_mpp=representative.mpp, + source_mpp=source_mpp, + patch_px=representative.patch_px, step_px=step, tissue_threshold=tissue_threshold, features_dtype=features_dtype, @@ -113,18 +145,6 @@ def verify( if expected_path is not None else None ) - from raw2features.embedders.model_registry import load_registry - - registered = load_registry() - unknown = [model for model in models if model not in registered] - if unknown: - if not quiet: - typer.echo( - "cannot derive the current output contract for unregistered " - f"model(s) {', '.join(unknown)}; incomplete", - err=True, - ) - raise typer.Exit(code=1) contracts = expected_model_contracts(cfg) expected_grid_models = { group_cfg.grid_hash(): list(group_cfg.models) for group_cfg in group_cfgs diff --git a/src/raw2features/core/config.py b/src/raw2features/core/config.py index cd5af2e..a59cb84 100644 --- a/src/raw2features/core/config.py +++ b/src/raw2features/core/config.py @@ -16,6 +16,30 @@ from __future__ import annotations +import math + + +def _positive_float(value, *, field: str) -> float: + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field} must be a finite number greater than zero") from exc + if not math.isfinite(number) or number <= 0: + raise ValueError(f"{field} must be a finite number greater than zero") + return number + + +def _positive_int(value, *, field: str) -> int: + if isinstance(value, bool): + raise ValueError(f"{field} must be an integer greater than zero") + try: + integer = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field} must be an integer greater than zero") from exc + if integer != value or integer <= 0: + raise ValueError(f"{field} must be an integer greater than zero") + return integer + def load_extractions(path: str) -> list[dict]: """Parse an extraction-plan config; return the validated ``extractions`` list. @@ -42,9 +66,13 @@ def load_extractions(path: str) -> list[dict]: ) entry: dict = {"model": str(e["model"])} if e.get("mpp") is not None: - entry["mpp"] = float(e["mpp"]) + entry["mpp"] = _positive_float( + e["mpp"], field=f"{path}: extractions[{i}].mpp" + ) if e.get("patch_px") is not None: - entry["patch_px"] = int(e["patch_px"]) + entry["patch_px"] = _positive_int( + e["patch_px"], field=f"{path}: extractions[{i}].patch_px" + ) out.append(entry) return out @@ -75,14 +103,18 @@ def load_manifest(path: str) -> list[dict]: row: dict = {"path": p} sm = (r.get("source_mpp") or "").strip() if sm: - row["source_mpp"] = float(sm) + row["source_mpp"] = _positive_float( + sm, field=f"{path}: source_mpp" + ) out.append(row) else: for ln in lines: parts = [c.strip() for c in ln.split(",")] row = {"path": parts[0]} if len(parts) > 1 and parts[1]: - row["source_mpp"] = float(parts[1]) + row["source_mpp"] = _positive_float( + parts[1], field=f"{path}: source_mpp" + ) out.append(row) if not out: raise ValueError(f"{path}: manifest has no slide paths") diff --git a/src/raw2features/core/provenance.py b/src/raw2features/core/provenance.py index c046a75..5628eb6 100644 --- a/src/raw2features/core/provenance.py +++ b/src/raw2features/core/provenance.py @@ -7,15 +7,16 @@ from __future__ import annotations -import os import platform import re import shlex import subprocess import sys +import tomllib from collections.abc import Sequence from datetime import UTC, datetime from importlib.metadata import PackageNotFoundError, version +from pathlib import Path from raw2features.core.uris import ( is_qualified_uri, @@ -44,16 +45,51 @@ } +def _is_raw2features_checkout(root: Path, module_file: Path) -> bool: + """True only when *root* is the repository containing this source package. + + ``git`` walks through parent directories. Without this boundary, a wheel in a + virtual environment nested under an unrelated checkout inherits that checkout's + commit. A real raw2features source checkout has both its own project metadata and + this module under the repository's ``src/raw2features`` (or a supported flat + ``raw2features`` package layout). + """ + + package_roots = (root / "src" / "raw2features", root / "raw2features") + if not any(module_file.is_relative_to(path) for path in package_roots): + return False + try: + with (root / "pyproject.toml").open("rb") as fh: + project_name = tomllib.load(fh).get("project", {}).get("name") + except (OSError, tomllib.TOMLDecodeError): + return False + normalized = str(project_name or "").lower().replace("_", "-") + return normalized == "raw2features" + + def _git_sha() -> str | None: try: - out = subprocess.run( + module_file = Path(__file__).resolve() + common = { + "capture_output": True, + "text": True, + "timeout": 5, + "cwd": str(module_file.parent), + } + top = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + **common, + ) + if top.returncode != 0 or not top.stdout.strip(): + return None + root = Path(top.stdout.strip()).resolve() + if not _is_raw2features_checkout(root, module_file): + return None + head = subprocess.run( ["git", "rev-parse", "HEAD"], - capture_output=True, - text=True, - timeout=5, - cwd=os.path.dirname(os.path.abspath(__file__)), + **common, ) - return out.stdout.strip() or None if out.returncode == 0 else None + return head.stdout.strip() or None if head.returncode == 0 else None except Exception: # noqa: BLE001 return None @@ -91,9 +127,7 @@ def sanitize_cli(cli: str) -> str: """Redact secret option values and credentials embedded in source URIs.""" redacted = _SECRET_OPTION_RE.sub( - lambda match: ( - f"{match.group('flag')}{match.group('separator')}" - ), + lambda match: f"{match.group('flag')}{match.group('separator')}", str(cli), ) return redact_uri_credentials(redacted) diff --git a/src/raw2features/embedders/model_registry.py b/src/raw2features/embedders/model_registry.py index f2e8765..17b979e 100644 --- a/src/raw2features/embedders/model_registry.py +++ b/src/raw2features/embedders/model_registry.py @@ -3,6 +3,8 @@ from __future__ import annotations import importlib.resources +import math +from collections.abc import Mapping from dataclasses import dataclass import yaml @@ -62,12 +64,51 @@ def get_spec(name: str) -> ModelSpec: # Scale-agnostic fallback when no requested model declares a recommended MPP. DEFAULT_TARGET_MPP = 1.0 -# Extraction patch size (px) for models that declare none (scale-agnostic / unknown). +# Extraction patch size (px) for registered scale-agnostic models that declare none. DEFAULT_PATCH_PX = 224 +def _spec_catalog(specs: Mapping[str, ModelSpec] | None = None) -> dict[str, ModelSpec]: + """Packaged registry plus any explicitly injected model specifications.""" + + catalog = load_registry() + catalog.update(specs or {}) + return catalog + + +def _require_known_models( + models: list[str], catalog: Mapping[str, ModelSpec] +) -> None: + unknown = sorted(set(models) - set(catalog)) + if unknown: + available = sorted(catalog) + raise ValueError( + f"model(s) {', '.join(unknown)} not in the registry or supplied model " + f"specifications; available: {available}" + ) + + +def _positive_mpp(value: float, *, field: str = "mpp") -> float: + value = float(value) + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{field} must be finite and greater than zero") + return value + + +def _positive_patch_px(value: int, *, field: str = "patch_px") -> int: + if isinstance(value, bool): + raise ValueError(f"{field} must be an integer greater than zero") + integer = int(value) + if integer != value or integer <= 0: + raise ValueError(f"{field} must be an integer greater than zero") + return integer + + def resolve_target_mpp( - models: list[str], requested: float | None + models: list[str], + requested: float | None, + *, + specs: Mapping[str, ModelSpec] | None = None, ) -> tuple[float, str]: """Resolve the patch-extraction MPP for a run, returning ``(mpp, source)``. @@ -77,19 +118,21 @@ def resolve_target_mpp( if no requested model declares one (scale-agnostic, e.g. resnet50 / dinov2) it falls back to :data:`DEFAULT_TARGET_MPP` (``"auto-default"``). Models that *disagree* raise - one run extracts patches at one scale, so the caller must - pass ``--mpp`` to pick. Unknown names (e.g. test mocks) contribute nothing - rather than erroring, so they resolve to the default. + pass ``--mpp`` to pick. Programmatic external model names are accepted only + when their authoritative :class:`ModelSpec` is supplied through ``specs``; + unknown uninjected names fail instead of silently receiving generic geometry. - Pure in ``(models, requested)`` so every CLI (embed / embed-many / verify) - resolves identically and their config hashes line up. + Pure in its inputs so every CLI (embed / embed-many / verify) resolves + identically and their config hashes line up. """ + registry = _spec_catalog(specs) + _require_known_models(models, registry) if requested is not None: - return float(requested), "explicit" - registry = load_registry() + return _positive_mpp(requested), "explicit" recs = { name: float(registry[name].recommended_mpp) for name in models - if name in registry and registry[name].recommended_mpp + if registry[name].recommended_mpp } distinct = sorted(set(recs.values())) if len(distinct) == 1: @@ -110,10 +153,10 @@ class GeometryGroup: A "grid" in the store: every model in ``models`` is decoded once at this geometry (decode-once fan-out) and writes ``features/`` against the same coords. The field of view is ``patch_px * mpp`` µm. ``source`` records how the geometry was - chosen -- ``"explicit"`` (a global --mpp/--patch-size collapse), ``"config"`` (a - per-model config file), ``"recommended"`` (the models' card geometry), or - ``"default"`` (the scale-agnostic ``DEFAULT_TARGET_MPP`` / ``DEFAULT_PATCH_PX`` - fallback). + chosen -- ``"explicit"`` (a command-line mpp and/or patch-size override), + ``"config"`` (a per-model config file), ``"recommended"`` (the models' card + geometry), or ``"default"`` (the scale-agnostic ``DEFAULT_TARGET_MPP`` / + ``DEFAULT_PATCH_PX`` fallback). """ mpp: float @@ -121,6 +164,10 @@ class GeometryGroup: models: tuple[str, ...] source: str + def __post_init__(self) -> None: + _positive_mpp(self.mpp) + _positive_patch_px(self.patch_px) + def _dedup(names: list[str]) -> tuple[str, ...]: """First-seen-order de-duplication (a model can't appear twice in one grid).""" @@ -130,21 +177,33 @@ def _dedup(names: list[str]) -> tuple[str, ...]: return tuple(seen) -def model_geometry(name: str) -> tuple[float, int, str]: +def model_geometry( + name: str, *, specs: Mapping[str, ModelSpec] | None = None +) -> tuple[float, int, str]: """The default ``(mpp, patch_px, source)`` one model is extracted at. ``recommended_mpp`` (card-sourced) sets the scale; ``recommended_patch_px`` (or ``input_size``, via :attr:`ModelSpec.extract_px`) the size. Models with no ``recommended_mpp`` are scale-agnostic and fall back to :data:`DEFAULT_TARGET_MPP`. - Unknown names (e.g. test mocks) resolve to the scale-agnostic default rather than - raising, mirroring :func:`resolve_target_mpp`. + Programmatic external models must be provided through ``specs``; an unknown name + without a specification is rejected rather than silently receiving 1.0/224. """ - spec = load_registry().get(name) + catalog = _spec_catalog(specs) + _require_known_models([name], catalog) + spec = catalog.get(name) if spec is None: - return DEFAULT_TARGET_MPP, DEFAULT_PATCH_PX, "default" + raise AssertionError("known model unexpectedly missing from the catalog") if spec.recommended_mpp: - return float(spec.recommended_mpp), spec.extract_px, "recommended" - return DEFAULT_TARGET_MPP, spec.extract_px, "default" + return ( + _positive_mpp(spec.recommended_mpp, field=f"{name}.recommended_mpp"), + _positive_patch_px(spec.extract_px, field=f"{name}.extract_px"), + "recommended", + ) + return ( + DEFAULT_TARGET_MPP, + _positive_patch_px(spec.extract_px, field=f"{name}.extract_px"), + "default", + ) def resolve_geometry( @@ -152,6 +211,8 @@ def resolve_geometry( requested_mpp: float | None = None, requested_patch_px: int | None = None, config: list[dict] | None = None, + *, + specs: Mapping[str, ModelSpec] | None = None, ) -> list[GeometryGroup]: """Group requested models into extraction geometries -- one grid per group. @@ -160,26 +221,43 @@ def resolve_geometry( * ``config`` -- a list of ``{model, mpp?, patch_px?}`` entries: explicit per-model geometry, missing values falling back to the model's defaults. Supports the SAME model at several geometries (distinct groups -- the MPP-ablation case). - * an explicit ``requested_mpp`` and/or ``requested_patch_px`` -- a GLOBAL collapse: - every model onto one grid at that geometry (today's behaviour, and the "all at - one scale" switch). A bare ``requested_patch_px`` resolves the shared mpp via - :func:`resolve_target_mpp` (which raises if the cards disagree). + * both ``requested_mpp`` and ``requested_patch_px`` -- a GLOBAL collapse: every + model onto one grid at that geometry. + * a bare ``requested_mpp`` -- override scale while retaining each model's + recommended extraction size; models with different patch sizes get separate + grids at the requested scale. A bare ``requested_patch_px`` resolves the shared + mpp via :func:`resolve_target_mpp` (which raises if the cards disagree). * neither -- PER-MODEL geometry: each model at its own geometry ``(recommended_mpp or default, recommended_patch_px or input_size)``, grouped by identical geometry in request order. Models that disagree no longer raise; they get their own grids. - Pure in its arguments so every CLI resolves identically (config hashes line up). + ``specs`` supplies authoritative metadata for programmatic external embedders. + Unknown names without a registry entry or injected spec fail instead of silently + receiving the generic 1.0/224 geometry. Pure in its arguments so every CLI + resolves identically (config hashes line up). """ if config is not None: - return _groups_from_config(config) + return _groups_from_config(config, specs=specs) + catalog = _spec_catalog(specs) + _require_known_models(models, catalog) + if requested_mpp is not None and requested_patch_px is None: + mpp = _positive_mpp(requested_mpp) + groups: dict[int, list[str]] = {} + for model in models: + _default_mpp, patch_px, _source = model_geometry(model, specs=specs) + groups.setdefault(patch_px, []).append(model) + return [ + GeometryGroup(mpp, patch_px, _dedup(group_models), "explicit") + for patch_px, group_models in groups.items() + ] if requested_mpp is not None or requested_patch_px is not None: mpp = ( - float(requested_mpp) + _positive_mpp(requested_mpp) if requested_mpp is not None - else resolve_target_mpp(models, None)[0] + else resolve_target_mpp(models, None, specs=specs)[0] ) px = ( - int(requested_patch_px) + _positive_patch_px(requested_patch_px) if requested_patch_px is not None else DEFAULT_PATCH_PX ) @@ -187,7 +265,7 @@ def resolve_geometry( groups: dict[tuple[float, int], list[str]] = {} src: dict[tuple[float, int], str] = {} for m in models: - mpp, px, s = model_geometry(m) + mpp, px, s = model_geometry(m, specs=specs) groups.setdefault((mpp, px), []).append(m) src.setdefault((mpp, px), s) return [ @@ -196,7 +274,9 @@ def resolve_geometry( ] -def _groups_from_config(extractions: list[dict]) -> list[GeometryGroup]: +def _groups_from_config( + extractions: list[dict], *, specs: Mapping[str, ModelSpec] | None = None +) -> list[GeometryGroup]: """Build geometry groups from a parsed config: ``[{model, mpp?, patch_px?}, ...]``. Each entry resolves mpp/patch_px (explicit, else the model's defaults); entries with @@ -206,9 +286,9 @@ def _groups_from_config(extractions: list[dict]) -> list[GeometryGroup]: groups: dict[tuple[float, int], list[str]] = {} for e in extractions: name = e["model"] - d_mpp, d_px, _ = model_geometry(name) - mpp = float(e.get("mpp", d_mpp)) - px = int(e.get("patch_px", d_px)) + d_mpp, d_px, _ = model_geometry(name, specs=specs) + mpp = _positive_mpp(e.get("mpp", d_mpp)) + px = _positive_patch_px(e.get("patch_px", d_px)) groups.setdefault((mpp, px), []).append(name) return [ GeometryGroup(mpp, px, _dedup(ms), "config") @@ -217,7 +297,12 @@ def _groups_from_config(extractions: list[dict]) -> list[GeometryGroup]: def build_embedder(name: str) -> Embedder: - """Resolve a model name -> its family Embedder class -> a ready instance.""" + """Build a packaged-registry model with its family implementation plugin. + + An ``embedders`` entry point supplies a loader family; it does not by itself add + *name* to the CLI model catalogue. Programmatic callers may instead pass external + :class:`Embedder` instances through ``embed_slide(..., embedders=[...])``. + """ from raw2features.core import plugins spec = get_spec(name) diff --git a/src/raw2features/export/h5.py b/src/raw2features/export/h5.py index c7dd3c4..03c5fa7 100644 --- a/src/raw2features/export/h5.py +++ b/src/raw2features/export/h5.py @@ -118,18 +118,32 @@ def export_h5( raise FileExistsError(f"{path} exists; pass overwrite=True") with h5py.File(path, "w") as fh: if layout in ("trident", "clam"): - _write_trident(fh, feats, coords, level0_patch, achieved_mpp, - mpp_level0, model, - coord_dtype=np.int32 if layout == "clam" else np.int64) + _write_trident( + fh, + feats, + coords, + level0_patch, + achieved_mpp, + mpp_level0, + model, + coord_dtype=np.int32 if layout == "clam" else np.int64, + ) else: - _write_stamp(fh, feats, coords, patch_px, achieved_mpp, mpp_level0, - model, header) + _write_stamp(fh, feats, coords, patch_px, achieved_mpp, model, header) written.append(path) return written -def _write_trident(fh, feats, coords, level0_patch, achieved_mpp, mpp_level0, model, - coord_dtype=np.int64): +def _write_trident( + fh, + feats, + coords, + level0_patch, + achieved_mpp, + mpp_level0, + model, + coord_dtype=np.int64, +): """CLAM / TRIDENT / TITAN layout: features + level-0-pixel coords + attrs. ``coord_dtype`` is int64 for ``trident`` and int32 for the ``clam`` alias (CLAM's @@ -148,13 +162,34 @@ def _write_trident(fh, feats, coords, level0_patch, achieved_mpp, mpp_level0, mo fh["features"].attrs["encoder"] = model -def _write_stamp(fh, feats, coords, patch_px, achieved_mpp, mpp_level0, model, header): - """KatherLab STAMP layout: feats (fp16) + micron coords + physical-size attrs.""" - if not mpp_level0: +def _write_stamp(fh, feats, coords, patch_px, achieved_mpp, model, header): + """KatherLab STAMP layout: feats (fp16) + slide-relative micron coords.""" + source = dict(header.get("source", {})) + scale_um = source.get("scale_um") + if ( + isinstance(scale_um, dict) + and scale_um.get("x") is not None + and scale_um.get("y") is not None + ): + sx, sy = float(scale_um["x"]), float(scale_um["y"]) + else: + # Legacy stores carry only the isotropic scalar and imply a zero origin. + mpp_level0 = source.get("mpp_level0") + if not mpp_level0: + raise ValueError( + "STAMP layout needs source.scale_um or source.mpp_level0 to " + "convert px -> microns" + ) + sx = sy = float(mpp_level0) + + if sx <= 0 or sy <= 0: raise ValueError( - "STAMP layout needs source.mpp_level0 to convert px -> microns" + "STAMP layout needs positive source.scale_um/source.mpp_level0" ) - coords_um = coords.astype(np.float32) * float(mpp_level0) # level-0 px -> µm + # STAMP coordinates are measured from the top-left of the WSI scan. An NGFF + # translation describes the source's physical/stage origin, so adding it here + # would shift STAMP heatmaps relative to the pixels they are drawn over. + coords_um = coords.astype(np.float32) * np.asarray([sx, sy], dtype=np.float32) fh.create_dataset("feats", data=feats.astype(np.float16)) fh.create_dataset("coords", data=coords_um) fh.attrs["unit"] = "um" diff --git a/src/raw2features/pipeline/runner.py b/src/raw2features/pipeline/runner.py index f23705f..a063c00 100644 --- a/src/raw2features/pipeline/runner.py +++ b/src/raw2features/pipeline/runner.py @@ -6,6 +6,7 @@ from __future__ import annotations +import math import os import queue import threading @@ -31,7 +32,11 @@ ) from raw2features.embedders.model_registry import build_embedder, resolve_geometry from raw2features.patcher.grid import GridPatcher, resample_patch -from raw2features.sinks.zarr_sink import ZarrSink, write_patches_geojson +from raw2features.sinks.zarr_sink import ( + ZarrSink, + _grid_scaffold_is_usable, + write_patches_geojson, +) from .receipt import ( SCHEMA_VERSION, @@ -47,6 +52,28 @@ _AMP = {"bf16": "bfloat16", "fp16": "float16", "fp32": "float32", None: "float32"} +def _positive_float(value, *, field: str) -> float: + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field} must be a finite number greater than zero") from exc + if not math.isfinite(number) or number <= 0: + raise ValueError(f"{field} must be a finite number greater than zero") + return number + + +def _positive_int(value, *, field: str) -> int: + if isinstance(value, bool): + raise ValueError(f"{field} must be an integer greater than zero") + try: + integer = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field} must be an integer greater than zero") from exc + if integer != value or integer <= 0: + raise ValueError(f"{field} must be an integer greater than zero") + return integer + + @dataclass class RunConfig: """Everything needed to embed one slide. Content-affecting fields feed the @@ -118,6 +145,16 @@ class RunConfig: # focus-sensitive; raise it for a softer-scanned cohort that over-reads as blur. qc_artifact_mpp: str = "1.5" + def __post_init__(self) -> None: + """Reject invalid geometry before hashing, model loading, or store writes.""" + + self.target_mpp = _positive_float(self.target_mpp, field="target_mpp") + self.patch_px = _positive_int(self.patch_px, field="patch_px") + if self.step_px is not None: + self.step_px = _positive_int(self.step_px, field="step_px") + if self.source_mpp is not None: + self.source_mpp = _positive_float(self.source_mpp, field="source_mpp") + # Single source of truth for which fields affect the output (and thus the # config hash) vs. runtime-only knobs. ``test_config_integrity`` asserts these # partition every RunConfig field, so a new field must be classified. @@ -239,11 +276,7 @@ def device_list(self) -> list[str]: except Exception: # noqa: BLE001 - no torch/cuda visible -> single device n = 0 return [f"cuda:{i}" for i in range(n)] if n > 1 else [single] - out = [ - resolve_device(d.strip()) - for d in self.devices.split(",") - if d.strip() - ] + out = [resolve_device(d.strip()) for d in self.devices.split(",") if d.strip()] return out or [single] @@ -271,8 +304,13 @@ def run_slide( force: bool = False, profiler=None, allow_hashless_legacy: bool = True, + _expected_model_contracts: dict[str, dict] | None = None, ) -> dict: - """Embed one slide. Returns a summary dict. + """Embed one slide into exactly one configured grid. Returns a summary dict. + + This is the single-grid primitive. Library callers that want registry-recommended + per-model geometry (and therefore potentially several grids) should use the + top-level :func:`raw2features.embed_slide` entry point instead. Resume / additive behaviour: * A validated 'complete' receipt for this exact request short-circuits @@ -330,12 +368,19 @@ def run_slide( if store_exists: _assert_store_source(out_path, expected_source) - model_contracts = _expected_contracts_with_factory_probe( - cfg, - embedders, - embedder_factory if multi_device else None, - devices=devices, - ) + if _expected_model_contracts is None: + model_contracts = _expected_contracts_with_factory_probe( + cfg, + embedders, + embedder_factory if multi_device else None, + devices=devices, + ) + else: + model_contracts = dict(_expected_model_contracts) + if set(model_contracts) != set(cfg.models): + raise ValueError( + "Pre-resolved model contracts do not match this grid's models." + ) # Fast path: a validated 'complete' receipt for this exact config (incl. the # model set), source, and requested output. ``--force`` deliberately bypasses it. @@ -366,6 +411,7 @@ def run_slide( expected_model_contracts=model_contracts, compatible_grid_hashes=cfg.compatible_legacy_grid_hashes(), allow_hashless_legacy=allow_hashless_legacy, + require_mask=not cfg.no_seg, ) # append: a grid of THIS geometry already exists -> add the missing models to it. # Otherwise we create a fresh store (no store yet) or ADD a new grid to an existing @@ -728,9 +774,7 @@ def embedder_factory(device: str) -> list: thumbnail_tissue, _ = _segment( reader, cfg, - _stored_grid_segmenter( - out_path, grid_key_existing - ), + _stored_grid_segmenter(out_path, grid_key_existing), ) thumb_meta = _write_grid_thumbnail( reader, @@ -880,6 +924,8 @@ def resolve_run( requested_mpp: float | None = None, requested_patch_px: int | None = None, geometry_config: list[dict] | None = None, + *, + model_specs: dict | None = None, ): """``(groups, group_cfgs, run_hash)`` for a request. @@ -889,8 +935,14 @@ def resolve_run( identically to the embed that produced the store. """ groups = resolve_geometry( - cfg.models, requested_mpp, requested_patch_px, geometry_config + cfg.models, + requested_mpp, + requested_patch_px, + geometry_config, + specs=model_specs, ) + if not groups: + raise ValueError("at least one model/extraction is required") group_cfgs = [ replace(cfg, models=list(g.models), target_mpp=g.mpp, patch_px=g.patch_px) for g in groups @@ -915,16 +967,26 @@ def embed_slide( ) -> dict: """Resolve per-model geometry and embed each group as its own grid in one store. - The user-facing entry point. Without an mpp/patch override, models that recommend - different geometries are each extracted at their own ``(mpp, patch_px)`` into a - separate ``grids//``; an explicit override collapses them onto one grid (see + The high-level public entry point. Without an mpp/patch override, models that + recommend different geometries are each extracted at their own + ``(mpp, patch_px)`` into a separate ``grids//``. An mpp-only override + retains each model's extraction size; supplying both mpp and patch size + collapses them onto one grid (see :func:`raw2features.embedders.model_registry.resolve_geometry`). It drives the - single-grid :func:`run_slide` once per group: the first writes the store, each later - group *finds-or-creates* its grid (never a wipe). ``force`` wipes once, on the first - group. A single per-slide receipt records the whole request for fast skip. + single-grid :func:`run_slide` once per group: the first writes the store, each + later group *finds-or-creates* its grid (never a wipe). ``force`` wipes once, on + the first group. A single per-slide receipt records the whole request for fast + skip. ``embedders`` (a warm worker's pre-built set for all of ``cfg.models``) is passed - through to every group; ``run_slide`` selects each group's subset. + through to every group; ``run_slide`` selects each group's subset. Their + :class:`~raw2features.embedders.base.ModelSpec` objects also drive geometry for + models outside the packaged registry. When only ``embedder_factory`` is supplied, + it is probed once for the same specifications and output contracts. High-level + overrides belong in ``requested_mpp`` / ``requested_patch_px`` (or + ``geometry_config``); + ``RunConfig.target_mpp`` and ``RunConfig.patch_px`` remain the concrete geometry + consumed by the single-grid :func:`run_slide` primitive. """ from raw2features.slide_embedders.model_registry import ( validate_slide_encoder_names, @@ -946,13 +1008,26 @@ def embed_slide( raise ValueError( "Source URI is malformed and no safe output ID can be derived." ) from None + model_specs = {embedder.name: embedder.spec for embedder in (embedders or [])} + probed_contracts: dict[str, dict] | None = None + if not model_specs and embedder_factory is not None: + # A factory can be the only source for a programmatic external model. Probe it + # once before geometry resolution, then pass the resulting contracts into each + # run_slide call so the same loaded copy is not probed a second time. + model_specs, probed_contracts = _probe_factory_contracts( + cfg, embedder_factory, devices + ) groups, group_cfgs, run_hash = resolve_run( - cfg, requested_mpp, requested_patch_px, geometry_config + cfg, + requested_mpp, + requested_patch_px, + geometry_config, + model_specs=model_specs, ) out_path = os.path.join(out_dir, f"{slide_id}.embeddings.zarr") grids = {grid_key(g.mpp, g.patch_px): list(g.models) for g in groups} expected_output = f"file://{os.path.abspath(out_path)}" - model_contracts = _expected_contracts_with_factory_probe( + model_contracts = probed_contracts or _expected_contracts_with_factory_probe( cfg, embedders, embedder_factory, @@ -1010,23 +1085,29 @@ def embed_slide( # requested geometry, but cannot identify which of several grids it # represents. A multi-grid request therefore leaves it untouched. allow_hashless_legacy=(len(group_cfgs) == 1), + _expected_model_contracts={ + model: model_contracts[model] for model in gc.models + }, ) ) - elapsed = round(time.time() - started, 2) grids = { (result.get("grid") or grid_key(group.mpp, group.patch_px)): list(group.models) for group, result in zip(groups, results, strict=True) } status = "skipped" if all(r["status"] == "skipped" for r in results) else "complete" - # Coverage: each requested slide encoder must have run on some grid (the one holding - # its patch encoder). One that ran nowhere has no grid with its patch model -> clear - # error rather than a silent omission. - if cfg.slide_encoders and status != "skipped": + # Coverage: first use outputs produced on this request's grids. If a specific + # encoder's patch model lives only on another, already-existing grid, discover it + # with the same selection path as standalone `slide-embed` and produce it there. + # An explicit -s request never returns success without a complete output. + if cfg.slide_encoders: ran: set[str] = set() for r in results: ran |= set(r.get("slide_embeddings") or {}) missing = [s for s in cfg.slide_encoders if s not in ran] + if missing: + ran |= set(_run_slide_encoders_from_store(out_path, missing, cfg.device)) + missing = [s for s in cfg.slide_encoders if s not in ran] if missing: from raw2features.slide_embedders.model_registry import get_slide_spec @@ -1041,6 +1122,8 @@ def embed_slide( "their patch encoder -- embed that patch model (at its geometry) first." ) + elapsed = round(time.time() - started, 2) + # Record the explicit job knobs (extraction plan, stain norm) so the run replays # from one artifact and the store is self-describing about how it was made. if status != "skipped" and (geometry_config is not None or cfg.stain_norm): @@ -1165,11 +1248,25 @@ def _expected_contracts_with_factory_probe( factory = embedder_factory or ( lambda device: _build_embedders_on(cfg, cfg.models, device) ) + _specs, contracts = _probe_factory_contracts(cfg, factory, worker_devices) + return contracts + + +def _probe_factory_contracts( + cfg: RunConfig, + factory, + worker_devices: list[str], +) -> tuple[dict[str, object], dict[str, dict]]: + """Probe a factory once for external specs and the shared worker contract.""" + + if not worker_devices: + raise ValueError("At least one patch worker device is required.") probe = factory(worker_devices[0]) try: contracts = _expected_contracts_for_devices(cfg, probe, worker_devices) _assert_loaded_model_contracts(probe, contracts) - return contracts + specs = {embedder.name: embedder.spec for embedder in probe} + return specs, contracts finally: for embedder in probe: embedder.unload() @@ -1390,8 +1487,16 @@ def _warn_read_block_memory(reader, cfg, read_px, read_workers, multichannel) -> ) -def _decode_batch(reader, batch, read_level, read_px, patch_px, executor=None, - multichannel=False, read_block=1): +def _decode_batch( + reader, + batch, + read_level, + read_px, + patch_px, + executor=None, + multichannel=False, + read_block=1, +): """Decode one batch's patch list once at the store geometry (read + resample). Read read_px px then resample to exactly patch_px -> the stored patch is at the @@ -1413,7 +1518,13 @@ def _decode_batch(reader, batch, read_level, read_px, patch_px, executor=None, """ if read_block > 1: return _decode_batch_blocked( - reader, batch, read_level, read_px, patch_px, executor, multichannel, + reader, + batch, + read_level, + read_px, + patch_px, + executor, + multichannel, side=read_block, ) if executor is None: @@ -1510,8 +1621,18 @@ def _group_by_transform(embedders: list) -> list[list]: def _embed_patches( - reader, coords, read_level, read_px, patch_px, embedders, sink, cfg, prof=None, - multichannel=False, normalizer=None, device=None, + reader, + coords, + read_level, + read_px, + patch_px, + embedders, + sink, + cfg, + prof=None, + multichannel=False, + normalizer=None, + device=None, ): """Decode each patch once at the store geometry and embed it with every extractor in *embedders*, writing ``features/`` in batches. @@ -1622,8 +1743,14 @@ def _producer() -> None: batch = coords[start : start + cfg.batch_size] with prof.stage("read"): patches = _decode_batch( - reader, batch, read_level, read_px, patch_px, executor, - multichannel, read_block=cfg.read_block, + reader, + batch, + read_level, + read_px, + patch_px, + executor, + multichannel, + read_block=cfg.read_block, ) if normalizer is not None: # per-patch stain norm (brightfield) patches = [normalizer(p) for p in patches] @@ -1662,7 +1789,9 @@ def _producer() -> None: for emb in group: with prof.stage("gpu"): feats = ( - emb.embed_batch(batch_tensor).numpy().astype(cfg.features_dtype) + emb.embed_batch(batch_tensor) + .numpy() + .astype(cfg.features_dtype) ) with prof.stage("write"): write_block(emb.name, start, feats) @@ -1817,6 +1946,7 @@ def _inspect_store( expected_model_contracts: dict[str, dict] | None = None, compatible_grid_hashes: tuple[str, ...] = (), allow_hashless_legacy: bool = True, + require_mask: bool = False, ): """Find the grid matching ``expected_grid_hash``: ``(key | None, n, valid_models)``. @@ -1842,6 +1972,26 @@ def _inspect_store( def _result_for(k: str): g = root[GRIDS][k] try: + header = dict(g.attrs.get("raw2features", {})) + patching = header.get("patching", {}) + patching = patching if isinstance(patching, dict) else {} + declared_n = patching.get("n_patches") + expected_n = declared_n if isinstance(declared_n, int) else None + grid_shape = patching.get("grid_shape") + expected_mask_shape = ( + tuple(int(v) for v in grid_shape) + if require_mask + and isinstance(grid_shape, (list, tuple)) + and len(grid_shape) == 2 + else None + ) + if not _grid_scaffold_is_usable( + g, + expected_n=expected_n, + require_mask=require_mask, + expected_mask_shape=expected_mask_shape, + ): + return None n = int(g["coords"].shape[0]) except Exception: # noqa: BLE001 - skip an unreadable grid return None @@ -1957,9 +2107,7 @@ def _missing_qc_tools( group = open_grid(root, key) n_patches = int(group["coords"].shape[0]) present = { - tool - for tool in requested - if _qc_tool_complete(group, tool, n_patches) + tool for tool in requested if _qc_tool_complete(group, tool, n_patches) } except Exception: # noqa: BLE001 - unreadable auxiliary state is missing return list(requested) @@ -2030,9 +2178,7 @@ def _thumbnail_files_complete( ) -def _stored_thumbnail_metadata( - out_path: str, key: str | None = None -) -> dict | None: +def _stored_thumbnail_metadata(out_path: str, key: str | None = None) -> dict | None: """Read live root or per-grid thumbnail metadata, even when an asset is missing.""" try: @@ -2113,9 +2259,7 @@ def _stored_grid_segmenter(out_path: str, key: str | None) -> str | None: return None -def _grid_geojson_path( - out_dir: str, slide_id: str, key: str, primary_key: str -) -> str: +def _grid_geojson_path(out_dir: str, slide_id: str, key: str, primary_key: str) -> str: """Backward-compatible primary path; namespaced path for every other grid.""" filename = ( @@ -2126,9 +2270,7 @@ def _grid_geojson_path( return os.path.join(out_dir, filename) -def _grid_thumbnail_overlay_name( - slide_id: str, key: str, primary_key: str -) -> str: +def _grid_thumbnail_overlay_name(slide_id: str, key: str, primary_key: str) -> str: return ( f"{slide_id}.thumbnail.overlay.png" if key == primary_key @@ -2190,9 +2332,7 @@ def _stored_grid_summary( root = zarr.open_group(out_path, mode="r", use_consolidated=False) keys = grid_keys(root) stored = { - key: dict(root[GRIDS][key].attrs.get("raw2features", {})).get( - "grid_hash" - ) + key: dict(root[GRIDS][key].attrs.get("raw2features", {})).get("grid_hash") for key in keys } result: dict[str, list[str]] = {} @@ -2202,9 +2342,7 @@ def _stored_grid_summary( match = None for candidate in dict.fromkeys(candidates): hits = [ - key - for key in keys - if key not in used and stored[key] == candidate + key for key in keys if key not in used and stored[key] == candidate ] if len(hits) == 1: match = hits[0] @@ -2249,7 +2387,13 @@ def _slide_encoders_for(names: list[str], available: list[str]) -> list[str]: def _run_qc( - qc_tools, reader, sink, coords, level0_patch, device, stain_norm=None, + qc_tools, + reader, + sink, + coords, + level0_patch, + device, + stain_norm=None, artifact_mpp="1.5", ) -> None: """Run requested QC producers on the active grid -> write ``qc//`` scores. @@ -2338,6 +2482,76 @@ def _run_slide_encoders( return results +def _run_slide_encoders_from_store( + out_path: str, + slide_encoder_names: list[str], + device: str, +) -> dict[str, str]: + """Produce missing slide outputs by discovering compatible grids in a store. + + This is the high-level inline fallback for patch features written by an earlier + request. Grid and patch-model selection is shared with standalone ``slide-embed``; + no WSI is reopened and complete outputs are left untouched. + """ + import zarr + + from raw2features.slide_embedders.encoding import ( + encode_slide_embedding, + resolve_slide_grid, + slide_embedding_is_complete, + write_slide_embedding, + ) + + root = zarr.open_group(out_path, mode="r+", use_consolidated=False) + results: dict[str, str] = {} + wrote = False + for slide_model_name in slide_encoder_names: + try: + selected_grid, group, patch_model = resolve_slide_grid( + root, + slide_model_name, + ) + except (KeyError, ValueError): + # Coverage is checked by embed_slide after every requested grid and this + # fallback have been tried. Selection failure here means only that no + # compatible pre-existing grid was found; model-load/encode errors below + # still propagate rather than being mistaken for a missing grid. + continue + if slide_embedding_is_complete( + group, + slide_model_name, + patch_model=patch_model, + device=device, + ): + results[slide_model_name] = ( + f"grids/{selected_grid}/slide/{slide_model_name}" + ) + continue + encoding = encode_slide_embedding( + group, + slide_model_name, + device, + patch_model=patch_model, + ) + if encoding is None: + continue + write_slide_embedding( + group, + slide_model_name, + encoding.vector, + encoding.provenance, + ) + wrote = True + results[slide_model_name] = f"grids/{selected_grid}/slide/{slide_model_name}" + + if wrote: + try: + zarr.consolidate_metadata(root.store) + except Exception: # noqa: BLE001 - consolidation is rebuildable metadata + pass + return results + + def _models_header( embedders: list, model_contracts: dict[str, dict] | None = None, @@ -2378,8 +2592,17 @@ def _models_header( def _build_header( - reader, grid, seg_meta, embedders, slide_id, n, thumbnail, grid_hash, prov, - panel_meta=None, model_contracts=None, + reader, + grid, + seg_meta, + embedders, + slide_id, + n, + thumbnail, + grid_hash, + prov, + panel_meta=None, + model_contracts=None, ) -> dict: header = { "schema_version": SCHEMA_VERSION, diff --git a/src/raw2features/readers/omezarr.py b/src/raw2features/readers/omezarr.py index 58e5a67..ae0c0fe 100644 --- a/src/raw2features/readers/omezarr.py +++ b/src/raw2features/readers/omezarr.py @@ -62,11 +62,11 @@ class _ChunkCache: """Thread-safe LRU cache of decompressed 2D (y, x) chunk planes. Keyed by ``(level, c_index, chunk_y, chunk_x)``; the value is the decompressed - chunk plane (uint8/source dtype, edge-clipped to the array bounds) exactly as - a single chunk-aligned zarr slice returns it. Adjacent ~224 px patches read in - grid order overlap the same 512x512 chunks, so caching the decompressed plane - lets the second patch reuse it instead of re-decompressing - and an RGB read - touches three channel chunks, all cached. + chunk plane (uint8/source dtype, edge-clipped to the array bounds), normalised + from the source axis order to ``(y, x)``. Adjacent ~224 px patches read in grid + order overlap the same 512x512 chunks, so caching the decompressed plane lets + the second patch reuse it instead of re-decompressing - and an RGB read touches + three channel chunks, all cached. The reads run on the pipeline's parallel read-worker pool, so get/put are guarded by a lock. ``capacity`` bounds the cache to N planes (LRU eviction); @@ -171,6 +171,11 @@ def __init__(self, path: str) -> None: # the on-disk chunk grid for whatever layout the store actually uses. self._chunk_hw: list[tuple[int, int]] = [] self._chunk_cache = _ChunkCache(_chunk_cache_capacity()) + # Float pixels have no dtype range to distinguish normalized [0, 1] from + # byte-like [0, 255]. This multiplier is inferred once from level 0 at open + # (255 or 1), then reused for every region and pyramid level. ``None`` means + # the level-0 dtype is not floating and keeps the integer path untouched. + self._float_to_uint8_scale: float | None = None # Query-authenticated HTTP paths use a custom read-only store so the raw # query is attached after every metadata/chunk key. Cache one per child root. self._source_stores: dict[str, object] = {} @@ -258,6 +263,7 @@ def open(self) -> OmeZarrReader: (int(a.chunks[yi]), int(a.chunks[xi])) # type: ignore[attr-defined] for a in self._arrays ] + self._float_to_uint8_scale = self._infer_float_to_uint8_scale(self._arrays[0]) self._channel_names = self._read_omero_channels(ms_path) self._warn_plane_collapse(self._arrays[0].shape) # type: ignore[attr-defined] self._chunk_cache.clear() @@ -265,27 +271,28 @@ def open(self) -> OmeZarrReader: return self def _warn_plane_collapse(self, level0_shape: tuple[int, ...]) -> None: - """Warn if the source has a t or z extent > 1 (only plane 0 is read). + """Warn for every extra axis with extent > 1 (only index 0 is read). - The reader serves a single 2D (y, x[, c]) plane per patch, indexing any time - or depth axis at 0. A multi-timepoint or z-stack source is therefore silently - reduced to its first plane - fine if intended, a trap if not - so we surface it - once at open rather than embedding plane 0 without a word. + The reader serves a single 2D ``(y, x[, c])`` plane per patch, indexing every + axis other than x, y, and c at 0. Any non-singleton extra axis is therefore + reduced to its first plane - fine if intended, a trap if not - so we surface + it once at open rather than embedding plane 0 without a word. """ - for axis in ("t", "z"): - if axis in self._dims: - extent = int(level0_shape[self._dims.index(axis)]) - if extent > 1: - warnings.warn( - f"{source_uri(self.path)}: source has {axis}={extent}; only " - f"{axis}=0 is " - "read (the reader serves one 2D plane per patch).", - stacklevel=2, - ) + for pos, axis in enumerate(self._dims): + if axis in {"x", "y", "c"}: + continue + extent = int(level0_shape[pos]) + if extent > 1: + warnings.warn( + f"{source_uri(self.path)}: source has {axis}={extent}; only " + f"{axis}=0 is read (the reader serves one 2D plane per patch).", + stacklevel=2, + ) def close(self) -> None: self._arrays = [] self._chunk_hw = [] + self._float_to_uint8_scale = None self._chunk_cache.clear() for store in self._source_stores.values(): store.close() # type: ignore[attr-defined] @@ -430,8 +437,12 @@ def read_region_channels(self, region: Region) -> np.ndarray: nc = block.shape[2] if block.shape[0] != h or block.shape[1] != w: out = np.zeros((h, w, nc), dtype=block.dtype) - ph, pw = min(block.shape[0], h), min(block.shape[1], w) - out[:ph, :pw, :] = block[:ph, :pw, :] + dst_y = min(h, max(0, -y0)) + dst_x = min(w, max(0, -x0)) + ph = min(block.shape[0], h - dst_y) + pw = min(block.shape[1], w - dst_x) + if ph > 0 and pw > 0: + out[dst_y : dst_y + ph, dst_x : dst_x + pw, :] = block[:ph, :pw, :] block = out return np.ascontiguousarray(block) @@ -452,7 +463,7 @@ def _read_omero_channels(self, ms_path: str) -> list[str] | None: def _read_hwc( self, arr: object, level: int, x0: int, y0: int, w: int, h: int ) -> np.ndarray: - """Slice a 2-5D level array and normalise to (h, w, 3) uint8. + """Slice a multidimensional level array and normalise to (h, w, 3) uint8. The (y, x) window is assembled from a decompressed-chunk cache (see ``_read_block_cached``) so adjacent/overlapping patches reuse already- @@ -480,13 +491,20 @@ def _read_hwc( # Convert to 8-bit RGB by rescaling on the source dtype range -- never a # truncating ``astype(uint8)``, which would wrap uint16/float pixels # mod 256 and silently corrupt them (e.g. uint16 40000 -> 64). - block = self._to_uint8(block) + block = self._to_uint8( + block, + float_scale=self._float_to_uint8_scale, + ) # Pad to the requested size if the read was clipped at a border. if block.shape[0] != h or block.shape[1] != w: out = np.full((h, w, 3), 255, dtype=np.uint8) - ph, pw = min(block.shape[0], h), min(block.shape[1], w) - out[:ph, :pw, :] = block[:ph, :pw, :] + dst_y = min(h, max(0, -y0)) + dst_x = min(w, max(0, -x0)) + ph = min(block.shape[0], h - dst_y) + pw = min(block.shape[1], w - dst_x) + if ph > 0 and pw > 0: + out[dst_y : dst_y + ph, dst_x : dst_x + pw, :] = block[:ph, :pw, :] block = out return np.ascontiguousarray(block) @@ -496,8 +514,9 @@ def _read_block_cached( """Assemble the (y, x[, c]) window from cached decompressed chunk planes. Returns ``(block, sliced)`` where ``block`` is byte-identical to a direct - ``arr[t0, :, z0, y0:y0+h, x0:x0+w]`` slice (scalar t/z axes dropped, axes in - ``self._dims`` order) and ``sliced`` lists the surviving non-scalar dims. + source-order slice with every extra axis indexed at 0 (and the complete c, + y, and x ranges retained). ``sliced`` lists those surviving dimensions in + ``self._dims`` order. The window is built by copying from per-(channel, chunk_y, chunk_x) planes pulled from ``self._chunk_cache``; a miss decompresses exactly one chunk via @@ -515,8 +534,8 @@ def _read_block_cached( clip_h = max(0, y_hi - y_lo) clip_w = max(0, x_hi - x_lo) - # The surviving (non-scalar) dims in self._dims order; t/z/... collapse to a - # single plane just as the direct slice's scalar 0-index did. + # The surviving dimensions in self._dims order; every extra axis collapses + # to a single plane just as the direct slice's scalar 0-index did. ci = None sliced: list[str] = [] for pos, d in enumerate(self._dims): @@ -572,9 +591,10 @@ def _read_chunk_plane( ) -> np.ndarray: """Decompress one chunk-aligned (y, x) plane for a single channel. - Builds a full index over ``self._dims`` (scalar axes -> 0, the c axis -> the - single channel ``c_val``, y/x -> the chunk's bounds) so the result is a 2D - ``(y, x)`` array, exactly the chunk's pixels the multi-chunk slice would use. + Builds a full index over ``self._dims`` (extra axes -> 0, the c axis -> the + single channel ``c_val``, y/x -> the chunk's bounds), then explicitly + transposes the two surviving spatial dimensions from source order to + ``(y, x)``. This keeps the cache contract independent of source axis order. """ idx: list[object] = [] for d in self._dims: @@ -586,7 +606,13 @@ def _read_chunk_plane( idx.append(c_val) else: idx.append(0) - return np.asarray(arr[tuple(idx)]) # type: ignore[index] + plane = np.asarray(arr[tuple(idx)]) # type: ignore[index] + spatial_order = [d for d in self._dims if d in {"y", "x"}] + plane = np.transpose( + plane, + (spatial_order.index("y"), spatial_order.index("x")), + ) + return np.ascontiguousarray(plane) @staticmethod def _place( @@ -600,7 +626,9 @@ def _place( """Write a 2D (y, x) sub-window into ``block`` at the right axis positions. ``block`` axes follow ``nonscalar`` (dims order). The c axis (if present) is - indexed by the scalar ``c_pos``; y/x take the destination slices. + indexed by the scalar ``c_pos``; y/x take the destination slices. The cached + ``sub`` plane is always ``(y, x)``, so it is transposed back to the block's + source spatial order for assignment when necessary. """ sel: list[object] = [] for d in nonscalar: @@ -610,11 +638,90 @@ def _place( sel.append(dst_y) else: # "x" sel.append(dst_x) - block[tuple(sel)] = sub + spatial_order = [d for d in nonscalar if d in {"y", "x"}] + source_order = ("y", "x") + sub_in_block_order = np.transpose( + sub, + tuple(source_order.index(d) for d in spatial_order), + ) + block[tuple(sel)] = sub_in_block_order + + def _infer_float_to_uint8_scale(self, level0: object) -> float | None: + """Choose one float convention for the opened slide from level zero. + + OME-NGFF does not require float arrays to declare whether pixels use + normalized ``[0, 1]`` or byte-like ``[0, 255]`` values. Scanning a whole + level-zero WSI at open would be prohibitively expensive, so inspect a + deterministic 3x3 lattice of complete source chunks (up to the first three + channels), plus the array fill value. Any finite value above 1 selects the + byte-like convention; otherwise the slide is treated as normalized. + + The important invariant is that this bounded, slide-level decision is made + once. It is never revised from a later patch or lower pyramid level, so two + regions from one float slide cannot receive different scaling merely because + their local value ranges differ. + """ + dtype = np.dtype(level0.dtype) # type: ignore[attr-defined] + if not np.issubdtype(dtype, np.floating): + return None + + fill_value = getattr(level0, "fill_value", None) + try: + fill = float(fill_value) + except (TypeError, ValueError): + fill = float("nan") + if np.isfinite(fill) and fill > 1.0: + return 1.0 + + height = self._level_dims[0].height + width = self._level_dims[0].width + if height <= 0 or width <= 0: + return 255.0 + chunk_h, chunk_w = self._chunk_hw[0] + chunk_rows = (height + chunk_h - 1) // chunk_h + chunk_cols = (width + chunk_w - 1) // chunk_w + sample_rows = sorted({0, (chunk_rows - 1) // 2, chunk_rows - 1}) + sample_cols = sorted({0, (chunk_cols - 1) // 2, chunk_cols - 1}) + + if "c" in self._dims: + ci = self._dims.index("c") + channels: tuple[int | None, ...] = tuple( + range(min(3, int(level0.shape[ci]))) # type: ignore[attr-defined] + ) + else: + channels = (None,) + + for chunk_y in sample_rows: + y0 = chunk_y * chunk_h + y1 = min(y0 + chunk_h, height) + for chunk_x in sample_cols: + x0 = chunk_x * chunk_w + x1 = min(x0 + chunk_w, width) + for channel in channels: + sample = self._read_chunk_plane( + level0, + channel, + y0, + y1, + x0, + x1, + ) + finite = sample[np.isfinite(sample)] + if finite.size and float(finite.max()) > 1.0: + return 1.0 + return 255.0 @staticmethod - def _to_uint8(block: np.ndarray) -> np.ndarray: - """Rescale an image to 8-bit by its dtype range (never wrap mod 256).""" + def _to_uint8( + block: np.ndarray, + *, + float_scale: float | None = None, + ) -> np.ndarray: + """Rescale an image to 8-bit by its dtype range (never wrap mod 256). + + ``float_scale`` is the slide-level convention selected at open. Leaving it + unset preserves the standalone helper's historical local-range behaviour. + """ if block.dtype == np.uint8: return block if np.issubdtype(block.dtype, np.unsignedinteger): @@ -627,11 +734,17 @@ def _to_uint8(block: np.ndarray) -> np.ndarray: scaled = np.rint(np.clip(block.astype(np.float32), 0, None) * scale) return np.clip(scaled, 0, 255).astype(np.uint8) if np.issubdtype(block.dtype, np.floating): - # Float images are usually [0, 1] (normalized) or already [0, 255]; pick the - # convention from the data range (NaNs treated as 0). + # Float images are usually [0, 1] (normalized) or already [0, 255]. The + # reader supplies one level-zero decision; the fallback is retained for + # backwards-compatible direct calls to this internal helper. b = np.nan_to_num(block.astype(np.float32)) - if b.size and float(b.max()) <= 1.0: - b = b * 255.0 + scale = float_scale + if scale is None: + scale = 255.0 if b.size and float(b.max()) <= 1.0 else 1.0 + if scale != 1.0: + # Clip before multiplying so +inf (converted to float32 max by + # nan_to_num) saturates without an overflow warning. + b = np.clip(b, 0, 255.0 / scale) * scale return np.clip(np.rint(b), 0, 255).astype(np.uint8) raise NotImplementedError( f"unsupported OME-Zarr pixel dtype {block.dtype!r}; expected uint8, " diff --git a/src/raw2features/sinks/zarr_sink.py b/src/raw2features/sinks/zarr_sink.py index b221bb3..bd6d9f5 100644 --- a/src/raw2features/sinks/zarr_sink.py +++ b/src/raw2features/sinks/zarr_sink.py @@ -90,6 +90,104 @@ def stored_hash(key: str): ) +def _grid_scaffold_is_usable( + group, + *, + expected_n: int | None = None, + require_mask: bool = False, + expected_mask_shape: tuple[int, ...] | None = None, +) -> bool: + """Whether a grid has the structural scaffold needed for safe model append. + + Missing model arrays are deliberately allowed: adding those arrays is the normal + resume path. A malformed coordinate scaffold or an existing feature column with a + different row count cannot be repaired model-by-model because it breaks the grid's + 1:1 row identity, so the matching grid must instead be rebuilt as a unit. + """ + + try: + if "coords" not in group: + return False + coords = group["coords"] + if ( + getattr(coords, "ndim", None) != 2 + or tuple(coords.shape[1:]) != (2,) + or not np.issubdtype(coords.dtype, np.integer) + ): + return False + n = int(coords.shape[0]) + if expected_n is not None and n != int(expected_n): + return False + + # grid_index is optional in the public format, but when present it must retain + # the same row identity as coords. + if "grid_index" in group: + grid_index = group["grid_index"] + if ( + getattr(grid_index, "ndim", None) != 2 + or tuple(grid_index.shape) != (n, 2) + or not np.issubdtype(grid_index.dtype, np.integer) + ): + return False + + if "features" not in group or not hasattr(group["features"], "keys"): + return False + features = group["features"] + for model in features.keys(): + array = features[model] + if getattr(array, "ndim", None) != 2 or int(array.shape[0]) != n: + return False + + # A mask is required only for a segmented extraction. Unsegmented and legacy + # stores may validly omit it. + if require_mask: + if "mask" not in group: + return False + mask = group["mask"] + if getattr(mask, "ndim", None) != 2: + return False + if expected_mask_shape is not None and tuple(mask.shape) != tuple( + expected_mask_shape + ): + return False + except Exception: # noqa: BLE001 - an unreadable scaffold is not appendable + return False + return True + + +def _unappendable_grid_to_rebuild( + root, + base: str, + grid_hash: str | None, + *, + expected_n: int, + required_mask_shape: tuple[int, ...] | None, +) -> str | None: + """Return the matching target when its grid scaffold cannot be appended safely. + + A same-hash grid is authoritative even when a label collision gave it a suffix. + The sole hashless legacy grid remains the one explicitly-supported compatibility + case. Other hashless/mismatched grids are unrelated and must be preserved. + """ + + if GRIDS not in root: + return None + keys = list(root[GRIDS].keys()) + for key in keys: + g = root[GRIDS][key] + stored_hash = dict(g.attrs.get("raw2features", {})).get("grid_hash") + is_target = stored_hash == grid_hash and grid_hash is not None + is_sole_legacy_target = len(keys) == 1 and key == base and stored_hash is None + if (is_target or is_sole_legacy_target) and not _grid_scaffold_is_usable( + g, + expected_n=expected_n, + require_mask=required_mask_shape is not None, + expected_mask_shape=required_mask_shape, + ): + return key + return None + + @register("sinks", "zarr") class ZarrSink(Sink): """One ``grids//`` per geometry: coords + grid_index + mask + features.""" @@ -138,7 +236,24 @@ def create( else: # use_consolidated=False: read live metadata (a prior run consolidated it). root = zarr.open_group(path, mode="r+", use_consolidated=False) - actual_grid = _select_grid_label(root, grid, header.get("grid_hash")) + rebuild = _unappendable_grid_to_rebuild( + root, + grid, + header.get("grid_hash"), + expected_n=n_patches, + required_mask_shape=( + tuple(np.asarray(grid_tissue).shape) + if grid_tissue is not None + else None + ), + ) + if rebuild is not None: + # Delete only the unusable target. The complete replacement inputs + # are already in hand, and all unrelated grids remain untouched. + del root[GRIDS][rebuild] + actual_grid = rebuild + else: + actual_grid = _select_grid_label(root, grid, header.get("grid_hash")) rh = dict(root.attrs.get("raw2features", {})) rh.setdefault("schema_version", header.get("schema_version")) for k in ("source", "provenance", "segmentation", "thumbnail"): @@ -505,9 +620,7 @@ def write_patches_geojson( try: # Honour the process umask/default ACL, matching a normal output # file rather than mkstemp's fixed 0600 permissions. - fd = os.open( - temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666 - ) + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666) break except FileExistsError: temporary = None diff --git a/src/raw2features/slide_embedders/encoding.py b/src/raw2features/slide_embedders/encoding.py index 2106d7d..b4a55d3 100644 --- a/src/raw2features/slide_embedders/encoding.py +++ b/src/raw2features/slide_embedders/encoding.py @@ -165,6 +165,52 @@ def slide_embedding_is_complete( return bool(vector.size and np.isfinite(vector).all() and (vector != 0).any()) +def resolve_slide_grid( + root, + slide_model: str, + *, + grid: str | None = None, + patch_model: str | None = None, +): + """Select the store grid and patch model for one slide encoder request. + + This is the shared selection contract for standalone ``slide-embed`` and inline + ``embed -s`` fallback. A specific slide model is discovered by its required patch + encoder across all grids. Model-agnostic encoders require an explicit choice when + the store has several grids. + """ + from raw2features.core.store import grid_for_model, grid_keys, open_grid + from raw2features.slide_embedders.model_registry import get_slide_spec + + keys = grid_keys(root) + if not keys: + raise ValueError("store has no grids/ group; expected a v0.1 embeddings store") + if grid is not None: + if grid not in keys: + raise ValueError(f"Unknown grid {grid!r}. Available grid keys: {keys}") + selected_grid = grid + elif len(keys) == 1: + selected_grid = keys[0] + elif patch_model is not None: + selected_grid = grid_for_model(root, patch_model) + else: + required = get_slide_spec(slide_model).patch_encoder + if required == "any": + raise ValueError( + f"Slide encoder {slide_model!r} is model-agnostic and the store has " + f"multiple grids {keys}; pass --grid or --patch-model to choose one." + ) + selected_grid = grid_for_model(root, required) + + group = open_grid(root, selected_grid) + selected_patch_model = resolve_slide_patch_model( + group, + slide_model, + patch_model=patch_model, + ) + return selected_grid, group, selected_patch_model + + def resolve_slide_patch_model( group, slide_model: str, diff --git a/src/raw2features/slide_embedders/model_registry.py b/src/raw2features/slide_embedders/model_registry.py index 85c3f42..3493604 100644 --- a/src/raw2features/slide_embedders/model_registry.py +++ b/src/raw2features/slide_embedders/model_registry.py @@ -2,8 +2,9 @@ Built-in slide encoders live in the ``slide_encoders`` section of ``embedders/registry.yaml`` (the same file as patch encoders, to keep all -model provenance in one place). Third-party slide encoders can be added -via the ``raw2features.slide_embedders`` entry-point group. +model provenance in one place). The ``raw2features.slide_embedders`` entry-point +group supplies implementations for names in that packaged registry; an entry point +alone does not extend the CLI slide-encoder catalogue. """ from __future__ import annotations diff --git a/src/raw2features/spec.py b/src/raw2features/spec.py index 55a92bb..eb26510 100644 --- a/src/raw2features/spec.py +++ b/src/raw2features/spec.py @@ -18,10 +18,17 @@ SPEC_VERSION = "0.1" _INT_DTYPES = ( - "int8", "int16", "int32", "int64", - "uint8", "uint16", "uint32", "uint64", + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", ) + def _load_header_schema(version: str | None) -> dict | None: """Load the packaged JSON Schema for a store's ``schema_version`` (or ``None``).""" if not version: @@ -87,7 +94,11 @@ def validate_store(path: str) -> list[str]: from raw2features.core.store import GRIDS, grid_keys try: - root = zarr.open_group(path, mode="r") + # Validation is a correctness decision, so inspect the authoritative live + # hierarchy. Consolidated metadata is only a rebuildable listing cache and + # can describe arrays that a crashed replacement has since removed (or omit + # arrays added after the last consolidation). + root = zarr.open_group(path, mode="r", use_consolidated=False) except Exception as exc: # noqa: BLE001 - any open failure is one clear violation return [f"cannot open {path!r} as a zarr group: {exc}"] @@ -130,8 +141,19 @@ def _validate_grid(g, header: dict) -> list[str]: out.append("coords must carry attr units='level0_px'") n = int(c.shape[0]) + patching = header.get("patching") + patching = patching if isinstance(patching, dict) else {} + declared_n = patching.get("n_patches") + if n is not None and isinstance(declared_n, int) and declared_n != n: + out.append(f"header.patching.n_patches {declared_n} != coords length {n}") + + hdr_models = header.get("models") + hdr_models = hdr_models if isinstance(hdr_models, dict) else {} + if "features" not in g: out.append("required group 'features' is missing") + for m in hdr_models: + out.append(f"header.models.{m} has no features/{m} array") else: from raw2features.embedders.fingerprint import ( output_fingerprints_equal, @@ -139,11 +161,12 @@ def _validate_grid(g, header: dict) -> list[str]: ) feats = g["features"] - hdr_models = header.get("models") - hdr_models = hdr_models if isinstance(hdr_models, dict) else {} models = list(feats.keys()) if not models: out.append("'features' group has no model arrays") + for m in hdr_models: + if m not in feats: + out.append(f"header.models.{m} has no features/{m} array") for m in models: a = feats[m] if a.ndim != 2: @@ -175,9 +198,7 @@ def _validate_grid(g, header: dict) -> list[str]: # valid and agree; resume separately requires them for completion. if array_fp is not None or header_fp is not None: if not valid_output_fingerprint(array_fp): - out.append( - f"features/{m} has no valid output_fingerprint attr" - ) + out.append(f"features/{m} has no valid output_fingerprint attr") if not valid_output_fingerprint(header_fp): out.append( f"header.models.{m}.output_fingerprint is missing " diff --git a/tests/test_additive_embed.py b/tests/test_additive_embed.py index 71cfc27..66b8286 100644 --- a/tests/test_additive_embed.py +++ b/tests/test_additive_embed.py @@ -69,7 +69,7 @@ def _make_store( g.attrs["raw2features"] = header root.attrs["raw2features"] = { "source": {"uri": source}, - "grids": {key: {"models": [m for m, _ in models], "grid_hash": grid_hash}} + "grids": {key: {"models": [m for m, _ in models], "grid_hash": grid_hash}}, } return path @@ -189,6 +189,36 @@ def test_inspect_store_absent_is_not_appendable(tmp_path): assert key is None and n == 0 and valid == [] +def test_inspect_store_missing_features_group_is_not_appendable(tmp_path): + path = _make_store(str(tmp_path / "out"), grid_hash="GH") + del open_grid(path, mode="r+")["features"] + + assert _inspect_store(path, "GH", ["a"]) == (None, 0, []) + + +@pytest.mark.parametrize("damage", ["header_only", "grid_index", "feature_rows"]) +def test_inspect_store_rejects_structurally_incomplete_grid(tmp_path, damage): + path = _make_store(str(tmp_path / "out"), grid_hash="GH") + g = open_grid(path, mode="r+") + if damage == "header_only": + del g["coords"] + del g["features"] + elif damage == "grid_index": + g.create_array("grid_index", shape=(3, 2), dtype="int32") + else: + del g["features"]["a"] + g["features"].create_array("a", shape=(3, 4), dtype="float16") + + assert _inspect_store(path, "GH", ["a"]) == (None, 0, []) + + +def test_inspect_store_requires_mask_only_for_segmented_config(tmp_path): + path = _make_store(str(tmp_path / "out"), grid_hash="GH") + + assert _inspect_store(path, "GH", ["a"])[0] == "mpp1_px64" + assert _inspect_store(path, "GH", ["a"], require_mask=True) == (None, 0, []) + + def test_inspect_store_rejects_ambiguous_hashless_multigrid(tmp_path): path = _make_store(str(tmp_path / "out"), grid_hash=None) root = zarr.open_group(path, mode="r+") @@ -266,9 +296,7 @@ def test_store_source_binding_checks_root_and_every_grid(tmp_path): root = zarr.open_group(p, mode="r+") other = root[GRIDS].require_group("mpp2_px64") - other.attrs["raw2features"] = { - "source": {"uri": "file:///source/B.zarr"} - } + other.attrs["raw2features"] = {"source": {"uri": "file:///source/B.zarr"}} with pytest.raises(ValueError, match="Refusing to reuse existing store"): _assert_store_source(p, "file:///source/A.zarr") @@ -347,6 +375,72 @@ def test_open_append_never_clobbers_present_model(tmp_path): assert (np.asarray(g["features"]["a"][:]) == 5.0).all() +@pytest.mark.parametrize( + "damage", + [ + "missing_features", + "header_only", + "grid_index", + "feature_rows", + "missing_required_mask", + ], +) +def test_create_rebuilds_unappendable_target_and_preserves_other_grids( + tmp_path, damage +): + out = str(tmp_path / "out") + path = _make_store(out, "s", n=4, models=(("a", 4),), grid_hash="GH") + root = zarr.open_group(path, mode="r+", use_consolidated=False) + target = open_grid(root, "mpp1_px64") + if damage in {"missing_features", "header_only"}: + del target["features"] + if damage == "header_only": + del target["coords"] + if damage == "grid_index": + target.create_array("grid_index", shape=(3, 2), dtype="int32") + if damage == "feature_rows": + del target["features"]["a"] + target["features"].create_array("a", shape=(3, 4), dtype="float16") + other = root[GRIDS].create_group("unrelated") + sentinel = other.create_array("sentinel", shape=(1,), dtype="int8") + sentinel[:] = 7 + other.attrs["raw2features"] = {"grid_hash": "OTHER"} + + coords = np.arange(8, dtype="int32").reshape(4, 2) + header = { + "grid_hash": "GH", + "source": {"uri": "file:///source/s.zarr"}, + "patching": {"target_mpp": 1.0, "patch_px": 64, "n_patches": 4}, + } + sink = ZarrSink() + key = sink.create( + out, + "s", + grid="mpp1_px64", + fresh=False, + n_patches=4, + coords=coords, + grid_index=coords, + grid_tissue=( + np.ones((2, 2), dtype="float32") + if damage == "missing_required_mask" + else None + ), + model_dims={"a": 4}, + header=header, + ) + sink.write_block("a", 0, np.ones((4, 4), dtype="float16")) + sink.close() + + root = zarr.open_group(path, mode="r", use_consolidated=False) + assert key == "mpp1_px64" + assert set(root[GRIDS].keys()) == {"mpp1_px64", "unrelated"} + np.testing.assert_array_equal(root[GRIDS]["unrelated"]["sentinel"][:], [7]) + assert np.isfinite(open_grid(root, "mpp1_px64")["features"]["a"][:]).all() + if damage == "missing_required_mask": + assert open_grid(root, "mpp1_px64")["mask"].shape == (2, 2) + + # -- end-to-end (needs torch) -------------------------------------------------- @@ -356,7 +450,8 @@ def test_embed_then_add_model_is_additive(synthetic_ngff, tmp_path): common = dict(no_seg=True, target_mpp=0.5, patch_px=64, device="cpu", amp="fp32") sa = run_slide( - synthetic_ngff, out, + synthetic_ngff, + out, RunConfig(models=["mockA"], **common), embedders=[MockEmbedder(dim=8, name="mockA", bias=1.0)], ) @@ -368,7 +463,8 @@ def test_embed_then_add_model_is_additive(synthetic_ngff, tmp_path): assert a_before.shape[1] == 8 sb = run_slide( - synthetic_ngff, out, + synthetic_ngff, + out, RunConfig(models=["mockB"], **common), embedders=[MockEmbedder(dim=5, name="mockB", bias=2.0)], ) @@ -384,6 +480,47 @@ def test_embed_then_add_model_is_additive(synthetic_ngff, tmp_path): assert np.isfinite(b).all() +@pytest.mark.skipif(not _TORCH, reason="torch not installed") +def test_missing_features_group_rebuilds_only_the_target_grid(synthetic_ngff, tmp_path): + out = str(tmp_path / "out") + common = dict(no_seg=True, patch_px=64, device="cpu", amp="fp32") + target_cfg = RunConfig(models=["mockA"], target_mpp=0.5, **common) + other_cfg = RunConfig(models=["mockB"], target_mpp=1.0, **common) + first = run_slide( + synthetic_ngff, + out, + target_cfg, + embedders=[MockEmbedder(dim=8, name="mockA", bias=1.0)], + ) + run_slide( + synthetic_ngff, + out, + other_cfg, + embedders=[MockEmbedder(dim=5, name="mockB", bias=2.0)], + ) + path = first["output_uri"].removeprefix("file://") + root = zarr.open_group(path, mode="r+", use_consolidated=False) + other_before = np.asarray(open_grid(root, "mpp1_px64")["features"]["mockB"][:]) + del open_grid(root, "mpp0.5_px64")["features"] + + rebuilt = run_slide( + synthetic_ngff, + out, + target_cfg, + embedders=[MockEmbedder(dim=8, name="mockA", bias=3.0)], + ) + + assert rebuilt["status"] == "complete" + root = zarr.open_group(path, mode="r", use_consolidated=False) + assert set(root[GRIDS].keys()) == {"mpp0.5_px64", "mpp1_px64"} + assert np.isfinite( + np.asarray(open_grid(root, "mpp0.5_px64")["features"]["mockA"][:]) + ).all() + np.testing.assert_array_equal( + open_grid(root, "mpp1_px64")["features"]["mockB"][:], other_before + ) + + @pytest.mark.skipif(not _TORCH, reason="torch not installed") def test_embed_rerun_same_model_skips(synthetic_ngff, tmp_path): out = str(tmp_path / "out") @@ -597,8 +734,12 @@ def test_embed_slide_writes_multiple_grids_in_one_run(synthetic_ngff, tmp_path): ] embedders = [MockEmbedder(dim=8, name="mockA"), MockEmbedder(dim=5, name="mockB")] summary = embed_slide( - synthetic_ngff, out, cfg, geometry_config=geom, - embedders=embedders, receipts_dir=rec, + synthetic_ngff, + out, + cfg, + geometry_config=geom, + embedders=embedders, + receipts_dir=rec, ) assert summary["status"] == "complete" assert set(summary["grids"]) == {"mpp0.5_px64", "mpp1_px64"} @@ -609,8 +750,12 @@ def test_embed_slide_writes_multiple_grids_in_one_run(synthetic_ngff, tmp_path): # whole-request receipt -> an identical re-run skips both grids again = embed_slide( - synthetic_ngff, out, cfg, geometry_config=geom, - embedders=embedders, receipts_dir=rec, + synthetic_ngff, + out, + cfg, + geometry_config=geom, + embedders=embedders, + receipts_dir=rec, ) assert again["status"] == "skipped" @@ -674,7 +819,10 @@ def test_embed_slide_runs_pooling_encoder_on_each_grid(synthetic_ngff, tmp_path) encoder is whatever that grid holds) -> slide/mean under both grids.""" out = str(tmp_path / "out") cfg = RunConfig( - models=["mockA", "mockB"], no_seg=True, device="cpu", amp="fp32", + models=["mockA", "mockB"], + no_seg=True, + device="cpu", + amp="fp32", slide_encoders=["mean"], ) geom = [ @@ -683,7 +831,11 @@ def test_embed_slide_runs_pooling_encoder_on_each_grid(synthetic_ngff, tmp_path) ] embs = [MockEmbedder(dim=8, name="mockA"), MockEmbedder(dim=5, name="mockB")] summary = embed_slide( - synthetic_ngff, out, cfg, geometry_config=geom, embedders=embs, + synthetic_ngff, + out, + cfg, + geometry_config=geom, + embedders=embs, ) assert summary["status"] == "complete" root = zarr.open_group(summary["output_uri"].removeprefix("file://"), mode="r") diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 95073b8..ed76393 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +from importlib import import_module import pytest from typer.testing import CliRunner @@ -22,15 +23,157 @@ _TORCH = False +def test_embed_cli_seeds_runconfig_from_resolved_geometry(tmp_path, monkeypatch): + """The high-level CLI must not encode "unset" as the real 1.0/224 geometry.""" + embed_module = import_module("raw2features.cli.embed") + captured = {} + + def fake_embed_slide(slide, out_dir, cfg, **kwargs): + captured.update(cfg=cfg, kwargs=kwargs) + return {"status": "complete"} + + monkeypatch.setattr(embed_module, "embed_slide", fake_embed_slide) + result = CliRunner().invoke( + app, + [ + "embed", + str(tmp_path / "unused.zarr"), + str(tmp_path / "out"), + "-m", + "conch_v1_5", + "--mpp", + "1.0", + ], + ) + + assert result.exit_code == 0, result.output + assert (captured["cfg"].target_mpp, captured["cfg"].patch_px) == (1.0, 512) + assert captured["kwargs"]["requested_mpp"] == 1.0 + assert captured["kwargs"]["requested_patch_px"] is None + + +def test_verify_cli_hashes_the_source_mpp_override(tmp_path, monkeypatch): + verify_module = import_module("raw2features.cli.verify") + captured = {} + + def fake_contracts(cfg): + captured["source_mpp"] = cfg.source_mpp + return {} + + monkeypatch.setattr(verify_module, "expected_model_contracts", fake_contracts) + monkeypatch.setattr(verify_module, "is_complete", lambda *args, **kwargs: True) + result = CliRunner().invoke( + app, + [ + "verify", + str(tmp_path / "slide.zarr"), + "--receipts-dir", + str(tmp_path / "receipts"), + "--source-mpp", + "0.37", + "--device", + "cpu", + "--quiet", + ], + ) + + assert result.exit_code == 0, result.output + assert captured["source_mpp"] == pytest.approx(0.37) + + +@pytest.mark.parametrize( + "args", + [ + ["embed", "slide.zarr", "out"], + ["embed-many", "slides", "out"], + ["benchmark", "slide.zarr"], + ["verify", "slide.zarr", "--receipts-dir", "receipts"], + ], +) +def test_commands_reject_unknown_amp_before_doing_work(args): + result = CliRunner().invoke(app, [*args, "--amp", "fast16"]) + + assert result.exit_code == 2 + assert "--amp" in result.output + assert "auto, fp32, bf16, fp16" in result.output + + +@pytest.mark.parametrize( + "args", + [ + ["embed", "slide.zarr", "out"], + ["embed-many", "slides", "out"], + ["benchmark", "slide.zarr"], + ], +) +@pytest.mark.parametrize("batch_size", ["0", "-3"]) +def test_embedding_commands_reject_nonpositive_batch_size(args, batch_size): + result = CliRunner().invoke(app, [*args, "--batch-size", batch_size]) + + assert result.exit_code == 2 + assert "--batch-size" in result.output + assert "greater than zero" in result.output + + +@pytest.mark.parametrize( + ("command", "option"), + [ + ("embed", "--mpp"), + ("embed", "--source-mpp"), + ("embed", "--patch-size"), + ("embed", "--step"), + ("embed-many", "--mpp"), + ("embed-many", "--source-mpp"), + ("embed-many", "--patch-size"), + ("embed-many", "--step"), + ("verify", "--mpp"), + ("verify", "--source-mpp"), + ("verify", "--patch-size"), + ("verify", "--step"), + ("benchmark", "--mpp"), + ("benchmark", "--patch-size"), + ], +) +def test_geometry_options_reject_zero_before_work(command, option): + positional = { + "embed": ["slide.zarr", "out"], + "embed-many": ["slides", "out"], + "verify": ["slide.zarr", "--receipts-dir", "receipts"], + "benchmark": ["slide.zarr"], + }[command] + result = CliRunner().invoke(app, [command, *positional, option, "0"]) + + assert result.exit_code == 2 + assert option in result.output + assert "greater than zero" in result.output + + +def test_mpp_option_rejects_nonfinite_value_before_work(): + result = CliRunner().invoke( + app, ["embed", "slide.zarr", "out", "--mpp", "nan"] + ) + + assert result.exit_code == 2 + assert "--mpp" in result.output + assert "finite and greater than zero" in result.output + + def _build_store(tmp_path) -> str: from raw2features.pipeline.runner import RunConfig, embed_slide slide = build_ngff_v04(str(tmp_path / "S.zarr")) out = str(tmp_path / "out") - cfg = RunConfig(models=["mock"], no_seg=True, target_mpp=0.5, patch_px=64, - device="cpu", amp="fp32") - embed_slide(slide, out, cfg, - embedders=[MockEmbedder(dim=8, input_size=64, name="mock")]) + cfg = RunConfig( + models=["mock"], + no_seg=True, + target_mpp=0.5, + patch_px=64, + device="cpu", + amp="fp32", + ) + embed_slide( + slide, out, cfg, embedders=[MockEmbedder(dim=8, input_size=64, name="mock")] + ) return os.path.join(out, "S.embeddings.zarr") @@ -58,8 +201,21 @@ def test_verify_cli_exits_1_when_not_complete(tmp_path): receipts = str(tmp_path / "receipts") # empty -> nothing is complete os.makedirs(receipts, exist_ok=True) r = CliRunner().invoke( - app, ["verify", slide, "--receipts-dir", receipts, "-f", "mock", - "--no-seg", "--mpp", "0.5", "--patch-size", "64", "--quiet"], + app, + [ + "verify", + slide, + "--receipts-dir", + receipts, + "-f", + "mock", + "--no-seg", + "--mpp", + "0.5", + "--patch-size", + "64", + "--quiet", + ], ) assert r.exit_code == 1 diff --git a/tests/test_config.py b/tests/test_config.py index 9ade7e2..2f81b1f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -38,6 +38,18 @@ def test_load_extractions_rejects_entry_without_model(tmp_path): load_extractions(str(p)) +@pytest.mark.parametrize( + "geometry", ["mpp: 0", "mpp: .nan", "patch_px: 0", "patch_px: -2"] +) +def test_load_extractions_rejects_nonpositive_or_nonfinite_geometry( + tmp_path, geometry +): + p = tmp_path / "bad.yaml" + p.write_text(f"extractions:\n - {{model: uni, {geometry}}}\n") + with pytest.raises(ValueError, match="greater than zero"): + load_extractions(str(p)) + + # -- slide manifest ------------------------------------------------------------ @@ -90,3 +102,13 @@ def test_load_manifest_rejects_non_numeric_source_mpp(tmp_path): p.write_text("path,source_mpp\n/a/x.zarr,notanumber\n") with pytest.raises(ValueError): # float("notanumber") fails loudly load_manifest(str(p)) + + +@pytest.mark.parametrize("source_mpp", ["0", "-0.5", "nan", "inf"]) +def test_load_manifest_rejects_nonpositive_or_nonfinite_source_mpp( + tmp_path, source_mpp +): + p = tmp_path / "m.csv" + p.write_text(f"path,source_mpp\n/a/x.zarr,{source_mpp}\n") + with pytest.raises(ValueError, match="source_mpp.*greater than zero"): + load_manifest(str(p)) diff --git a/tests/test_config_integrity.py b/tests/test_config_integrity.py index 50634cb..50f333f 100644 --- a/tests/test_config_integrity.py +++ b/tests/test_config_integrity.py @@ -12,6 +12,8 @@ import inspect from dataclasses import fields +import pytest + from raw2features.cli.embed import embed from raw2features.cli.embed_many import embed_many from raw2features.cli.verify import verify @@ -47,6 +49,21 @@ def test_content_hash_is_pinned(): ) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("target_mpp", 0), + ("target_mpp", float("nan")), + ("source_mpp", -0.5), + ("patch_px", 0), + ("step_px", -1), + ], +) +def test_runconfig_rejects_invalid_geometry_at_construction(field, value): + with pytest.raises(ValueError, match=field): + RunConfig(models=["resnet50"], **{field: value}) + + def test_content_hash_is_model_order_independent(): a = RunConfig(models=["uni", "resnet50"]).content_hash() b = RunConfig(models=["resnet50", "uni"]).content_hash() diff --git a/tests/test_coordinate_frame.py b/tests/test_coordinate_frame.py index 0d9da17..84fa639 100644 --- a/tests/test_coordinate_frame.py +++ b/tests/test_coordinate_frame.py @@ -157,8 +157,8 @@ def test_apply_source_mpp_lets_an_uncalibrated_source_proceed(tmp_path): r.apply_source_mpp(0.0) -def test_plane_collapse_warns_on_t_or_z_stack(synthetic_ngff): - # dims are (t, c, z, y, x); a >1 extent on t or z means only plane 0 is read. +def test_plane_collapse_warns_on_non_singleton_fixture_extra_axes(synthetic_ngff): + # The fixture's extra axes are t and z; every extra axis is indexed at zero. with OmeZarrReader(synthetic_ngff) as r: for shape, token in ( ((2, 3, 1, 300, 200), "t=2"), diff --git a/tests/test_embed_many.py b/tests/test_embed_many.py index 9586e75..4198f64 100644 --- a/tests/test_embed_many.py +++ b/tests/test_embed_many.py @@ -21,6 +21,21 @@ _TORCH = False +@pytest.fixture(autouse=True) +def _register_cli_mock_model(monkeypatch): + """CLI tests need an explicit model spec; unknown names now fail closed.""" + + from raw2features.embedders import model_registry + + registry = model_registry.load_registry() + mock_spec = MockEmbedder(dim=8, input_size=64, name="mock").spec + monkeypatch.setattr( + model_registry, + "load_registry", + lambda: {**registry, "mock": mock_spec}, + ) + + # -- sharding (torch-free) ----------------------------------------------------- @@ -103,6 +118,37 @@ def test_embed_many_rejects_duplicate_output_ids_before_model_load( assert "models must not be loaded" not in result.output +@pytest.mark.parametrize("source_mpp", ["0", "-0.5", "nan", "inf"]) +def test_embed_many_rejects_invalid_manifest_source_mpp_before_model_load( + tmp_path, monkeypatch, source_mpp +): + import raw2features.cli.embed_many as em + + manifest = tmp_path / "manifest.csv" + manifest.write_text(f"path,source_mpp\nS.zarr,{source_mpp}\n") + monkeypatch.setattr( + em, + "load_embedders", + lambda *args, **kwargs: pytest.fail("models must not be loaded"), + ) + + result = CliRunner().invoke( + app, + [ + "embed-many", + str(tmp_path), + str(tmp_path / "out"), + "--manifest", + str(manifest), + ], + ) + + assert result.exit_code == 1 + assert "source_mpp" in result.output + assert "greater than zero" in result.output + assert "models must not be loaded" not in result.output + + def test_duplicate_preflight_never_prints_remote_credentials(tmp_path, monkeypatch): import raw2features.cli.embed_many as em diff --git a/tests/test_export_h5.py b/tests/test_export_h5.py index 42f77bd..eb34640 100644 --- a/tests/test_export_h5.py +++ b/tests/test_export_h5.py @@ -24,18 +24,28 @@ def _make_store(tmp_path, slide_id="slideA", models=("mock",)): header = { "schema_version": 1, "patching": { - "level0_patch": _LEVEL0_PATCH, "patch_px": _PATCH_PX, - "achieved_mpp": _ACHIEVED_MPP, "target_mpp": _ACHIEVED_MPP, - "read_level": 0, "coords_convention": "level0_xy", "n_patches": 3, + "level0_patch": _LEVEL0_PATCH, + "patch_px": _PATCH_PX, + "achieved_mpp": _ACHIEVED_MPP, + "target_mpp": _ACHIEVED_MPP, + "read_level": 0, + "coords_convention": "level0_xy", + "n_patches": 3, }, "source": {"uri": "file:///x", "slide_id": slide_id, "mpp_level0": _MPP_LEVEL0}, "models": {m: {"embedding_dim": 4} for m in models}, } sink = ZarrSink() sink.create( - str(tmp_path), slide_id, grid="mpp0.5_px224", n_patches=3, coords=_COORDS, - grid_index=_COORDS, grid_tissue=np.ones(3, dtype=np.float32), - model_dims={m: 4 for m in models}, header=header, + str(tmp_path), + slide_id, + grid="mpp0.5_px224", + n_patches=3, + coords=_COORDS, + grid_index=_COORDS, + grid_tissue=np.ones(3, dtype=np.float32), + model_dims={m: 4 for m in models}, + header=header, ) for m in models: sink.write_block(m, 0, _FEATS.astype(np.float16)) @@ -90,6 +100,29 @@ def test_stamp_layout(tmp_path): assert fh.attrs["extractor"] == "mock" +def test_stamp_honours_per_axis_scale_but_remains_slide_relative(tmp_path): + import h5py + import zarr + + from raw2features.core.store import open_grid + + store = _make_store(tmp_path) + g = open_grid(store, mode="r+") + header = dict(g.attrs["raw2features"]) + header["source"]["mpp_level0"] = 0.3 # scalar mean must not mask anisotropy + header["source"]["scale_um"] = {"x": 0.2, "y": 0.4} + header["source"]["level0_translation_um"] = {"x": 10.0, "y": 20.0} + g.attrs["raw2features"] = header + zarr.consolidate_metadata(store) + + (path,) = export_h5(store, str(tmp_path / "out"), layout="stamp") + with h5py.File(path, "r") as fh: + # STAMP defines coords from the scan's top-left, so the NGFF physical/stage + # origin must not be added even though it remains in native store metadata. + expected = _COORDS.astype(np.float32) * np.array([0.2, 0.4]) + np.testing.assert_allclose(fh["coords"][:], expected, atol=1e-5) + + def test_multi_model_writes_one_file_each(tmp_path): store = _make_store(tmp_path, models=("uni", "resnet50")) paths = export_h5(store, str(tmp_path / "out"), layout="trident") @@ -108,8 +141,13 @@ def _hdr(slide_id, mpp, px, l0): return { "schema_version": "0.1", "patching": { - "achieved_mpp": mpp, "target_mpp": mpp, "patch_px": px, "level0_patch": l0, - "read_level": 0, "coords_convention": "level0_xy", "n_patches": 3, + "achieved_mpp": mpp, + "target_mpp": mpp, + "patch_px": px, + "level0_patch": l0, + "read_level": 0, + "coords_convention": "level0_xy", + "n_patches": 3, }, "source": {"uri": "file:///x", "slide_id": slide_id, "mpp_level0": _MPP_LEVEL0}, "models": {"mock": {"embedding_dim": 4}}, @@ -123,15 +161,33 @@ def test_grid_selector_required_on_multigrid(tmp_path): slide = "s" # Two grids in one store: gridA (0.5/224), gridB (1.0/256), both holding 'mock'. a = ZarrSink() - a.create(str(tmp_path), slide, grid="mpp0.5_px224", fresh=True, n_patches=3, - coords=_COORDS, grid_index=_COORDS, grid_tissue=np.ones(3, np.float32), - model_dims={"mock": 4}, header=_hdr(slide, 0.5, 224, 448)) + a.create( + str(tmp_path), + slide, + grid="mpp0.5_px224", + fresh=True, + n_patches=3, + coords=_COORDS, + grid_index=_COORDS, + grid_tissue=np.ones(3, np.float32), + model_dims={"mock": 4}, + header=_hdr(slide, 0.5, 224, 448), + ) a.write_block("mock", 0, _FEATS.astype(np.float16)) a.close() b = ZarrSink() - b.create(str(tmp_path), slide, grid="mpp1_px256", fresh=False, n_patches=3, - coords=_COORDS, grid_index=_COORDS, grid_tissue=np.ones(3, np.float32), - model_dims={"mock": 4}, header=_hdr(slide, 1.0, 256, 256)) + b.create( + str(tmp_path), + slide, + grid="mpp1_px256", + fresh=False, + n_patches=3, + coords=_COORDS, + grid_index=_COORDS, + grid_tissue=np.ones(3, np.float32), + model_dims={"mock": 4}, + header=_hdr(slide, 1.0, 256, 256), + ) b.write_block("mock", 0, _FEATS.astype(np.float16)) b.close() store = str(tmp_path / f"{slide}.embeddings.zarr") diff --git a/tests/test_multi_gpu.py b/tests/test_multi_gpu.py index 2e9ee4e..ca026e0 100644 --- a/tests/test_multi_gpu.py +++ b/tests/test_multi_gpu.py @@ -52,6 +52,21 @@ def embed_batch(self, batch): return (v.unsqueeze(1).repeat(1, self.spec.embedding_dim) + self._bias).cpu() +@pytest.fixture(autouse=True) +def _register_cli_mock_model(monkeypatch): + """CLI tests declare their mock model spec explicitly; unknown names fail closed.""" + + from raw2features.embedders import model_registry + + registry = model_registry.load_registry() + mock_spec = PosMockEmbedder(dim=8, input_size=64, name="mock").spec + monkeypatch.setattr( + model_registry, + "load_registry", + lambda: {**registry, "mock": mock_spec}, + ) + + def _ramp2d_store(path: str, *, h: int = 256, w: int = 192, mpp0: float = 0.5) -> str: """An OME-NGFF v0.4 store whose pixels encode a 2-D ramp ``(x + y)``. diff --git a/tests/test_output_fingerprints.py b/tests/test_output_fingerprints.py index 4f556f5..3271c54 100644 --- a/tests/test_output_fingerprints.py +++ b/tests/test_output_fingerprints.py @@ -387,13 +387,18 @@ def factory(device): amp="fp32", batch_size=2, ), - requested_mpp=0.5, - requested_patch_px=64, embedder_factory=factory, ) group = open_grid(result["output_uri"]) assert group["features"]["custom"].shape[1] == 8 - assert len(calls) >= 4 # high-level probe, header copy, and two workers + patching = dict(group.attrs["raw2features"])["patching"] + # The external spec is scale-agnostic (1.0 default) with a 64-pixel input. The + # high-level resolver must use that spec; RunConfig's 1.0/224 remains run_slide's + # concrete single-grid geometry rather than an implicit high-level override. + assert (patching["target_mpp"], patching["patch_px"]) == (1.0, 64) + # One shared high-level spec/contract probe, one header copy, and two workers. + # In particular, run_slide must not repeat the contract probe. + assert len(calls) == 4 @pytest.mark.skipif(not _TORCH, reason="torch not installed") diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..17bb4da --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,59 @@ +"""The supported implementation-plugin discovery contract.""" + +from __future__ import annotations + +import pytest + +from raw2features.core import plugins + + +class _EntryPoint: + def __init__(self, name, value=None, error: Exception | None = None): + self.name = name + self._value = value + self._error = error + + def load(self): + if self._error is not None: + raise self._error + return self._value + + +@pytest.fixture +def isolated_plugins(monkeypatch): + registry = {seam: dict(values) for seam, values in plugins._REGISTRY.items()} + monkeypatch.setattr(plugins, "_REGISTRY", registry) + + def install(*entry_points): + monkeypatch.setattr( + plugins, + "entry_points", + lambda *, group: list(entry_points), + ) + + return install + + +def test_importable_entry_point_is_available_and_resolved(isolated_plugins): + implementation = object() + isolated_plugins(_EntryPoint("third_party_reader", implementation)) + + assert "third_party_reader" in plugins.available("readers") + assert plugins.get("readers", "third_party_reader") is implementation + + +def test_entry_point_load_failure_is_skipped(isolated_plugins): + isolated_plugins(_EntryPoint("broken_reader", error=ImportError("optional dep"))) + + assert "broken_reader" not in plugins.available("readers") + with pytest.raises(KeyError, match="broken_reader.*not found"): + plugins.get("readers", "broken_reader") + + +def test_in_process_registration_precedes_same_named_entry_point(isolated_plugins): + local = object() + external = object() + plugins.register("readers", "precedence_probe")(local) + isolated_plugins(_EntryPoint("precedence_probe", external)) + + assert plugins.get("readers", "precedence_probe") is local diff --git a/tests/test_provenance.py b/tests/test_provenance.py index efa0a37..57d3c99 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -4,12 +4,56 @@ import json import shlex +from types import SimpleNamespace import pytest from raw2features.core import provenance +def test_git_sha_accepts_the_raw2features_source_checkout(tmp_path, monkeypatch): + root = tmp_path / "raw2features" + module_file = root / "src/raw2features/core/provenance.py" + module_file.parent.mkdir(parents=True) + module_file.touch() + (root / "pyproject.toml").write_text( + '[project]\nname = "raw2features"\n', encoding="utf-8" + ) + calls = [] + + def fake_run(command, **kwargs): + calls.append((command, kwargs)) + value = f"{root}\n" if "--show-toplevel" in command else "abc123\n" + return SimpleNamespace(returncode=0, stdout=value) + + monkeypatch.setattr(provenance, "__file__", str(module_file)) + monkeypatch.setattr(provenance.subprocess, "run", fake_run) + + assert provenance._git_sha() == "abc123" + assert [call[0][-1] for call in calls] == ["--show-toplevel", "HEAD"] + + +def test_git_sha_rejects_an_unrelated_enclosing_repository(tmp_path, monkeypatch): + outer = tmp_path / "unrelated" + module_file = outer / ".venv/lib/site-packages/raw2features/core/provenance.py" + module_file.parent.mkdir(parents=True) + module_file.touch() + (outer / "pyproject.toml").write_text( + '[project]\nname = "some-other-project"\n', encoding="utf-8" + ) + calls = [] + + def fake_run(command, **kwargs): + calls.append(command) + return SimpleNamespace(returncode=0, stdout=f"{outer}\n") + + monkeypatch.setattr(provenance, "__file__", str(module_file)) + monkeypatch.setattr(provenance.subprocess, "run", fake_run) + + assert provenance._git_sha() is None + assert calls == [["git", "rev-parse", "--show-toplevel"]] + + @pytest.mark.parametrize( "cli", [ @@ -34,8 +78,7 @@ def test_sanitize_cli_redacts_authenticated_uri_and_keeps_semantic_selector(): ) got = provenance.sanitize_cli(cli) assert got == ( - "raw2features embed https://example.org/image.zarr?series=2 " - "out --model uni" + "raw2features embed https://example.org/image.zarr?series=2 out --model uni" ) assert "password" not in got and "R2F_SECRET" not in got diff --git a/tests/test_public_api.py b/tests/test_public_api.py index ff89d58..3cec80f 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -14,7 +14,7 @@ import pytest PUBLIC = ( - "run_slide", "RunConfig", "register", "available", "get", + "embed_slide", "run_slide", "RunConfig", "register", "available", "get", "write_patches_geojson", "validate_store", ) @@ -27,6 +27,13 @@ def test_public_names_resolve(): assert set(PUBLIC).issubset(set(raw2features.__all__)) +def test_slide_entry_points_state_their_supported_abstraction(): + import raw2features + + assert "high-level public entry point" in raw2features.embed_slide.__doc__ + assert "single-grid primitive" in raw2features.run_slide.__doc__ + + def test_unknown_attr_raises(): import raw2features diff --git a/tests/test_reader_omezarr.py b/tests/test_reader_omezarr.py index 8c743fd..709d9a4 100644 --- a/tests/test_reader_omezarr.py +++ b/tests/test_reader_omezarr.py @@ -9,6 +9,7 @@ from urllib.parse import urlsplit import numpy as np +import pytest import zarr from conftest import build_ngff_v04 @@ -20,9 +21,7 @@ def test_query_auth_is_attached_to_every_http_zarr_request(tmp_path): root = tmp_path / "remote.zarr" zarr.open_group(str(root), mode="w", zarr_format=2) build_ngff_v04(str(root / "0"), sizes=((64, 64), (32, 32))) - expected_query = ( - "token=R2F%2FSECRET%2BVALUE%3D&series=1&series=2&empty=" - ) + expected_query = "token=R2F%2FSECRET%2BVALUE%3D&series=1&series=2&empty=" seen = [] class AuthenticatedHandler(http.server.SimpleHTTPRequestHandler): @@ -65,14 +64,13 @@ def log_message(self, *args): assert any("/remote.zarr/0/0/" in urlsplit(path).path for path in seen) assert any( (request_path := urlsplit(path).path).startswith("/remote.zarr/0/0/") - and re.fullmatch(r"\d+(?:\.\d+)+", request_path.rsplit("/", 1)[-1]) - is not None + and re.fullmatch(r"\d+(?:\.\d+)+", request_path.rsplit("/", 1)[-1]) is not None for path in seen ) def _direct_slice_block(r: OmeZarrReader, level: int, x0: int, y0: int, w: int, h: int): - """Reference: the pre-cache direct multi-chunk zarr slice (scalar t/z dropped).""" + """Reference direct slice, with every extra axis indexed at zero.""" arr = r._arrays[level] idx: list[object] = [] for d in r._dims: @@ -87,6 +85,74 @@ def _direct_slice_block(r: OmeZarrReader, level: int, x0: int, y0: int, w: int, return np.asarray(arr[tuple(idx)]) +def _write_single_level_ngff( + path, + *, + data: np.ndarray, + chunks: tuple[int, ...], + axes: list[dict[str, str]], + scale: list[float], +) -> str: + """Write a minimal offline NGFF v0.4 store with an explicit axis layout.""" + g = zarr.open_group(str(path), mode="w", zarr_format=2) + arr = g.create_array("0", shape=data.shape, chunks=chunks, dtype=data.dtype) + arr[...] = data + g.attrs["multiscales"] = [ + { + "version": "0.4", + "name": "axis-order-regression", + "axes": axes, + "datasets": [ + { + "path": "0", + "coordinateTransformations": [{"type": "scale", "scale": scale}], + } + ], + } + ] + return str(path) + + +def _write_float_pyramid_ngff( + path, + *, + level0: np.ndarray, + level1: np.ndarray, +) -> str: + """Write a two-level c,y,x float pyramid for scaling regressions.""" + g = zarr.open_group(str(path), mode="w", zarr_format=2) + for key, data in (("0", level0), ("1", level1)): + chunks = (1, min(4, data.shape[1]), min(4, data.shape[2])) + arr = g.create_array(key, shape=data.shape, chunks=chunks, dtype=data.dtype) + arr[...] = data + g.attrs["multiscales"] = [ + { + "version": "0.4", + "name": "float-scaling-regression", + "axes": [ + {"name": "c", "type": "channel"}, + {"name": "y", "type": "space", "unit": "micrometer"}, + {"name": "x", "type": "space", "unit": "micrometer"}, + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + {"type": "scale", "scale": [1.0, 0.5, 0.5]} + ], + }, + { + "path": "1", + "coordinateTransformations": [ + {"type": "scale", "scale": [1.0, 1.0, 1.0]} + ], + }, + ], + } + ] + return str(path) + + def test_metadata_mpp_dims_downsamples(synthetic_ngff): with OmeZarrReader(synthetic_ngff) as r: assert r.mpp == 0.5 @@ -122,6 +188,37 @@ def test_border_read_is_padded_to_requested_size(synthetic_ngff): assert np.all(img[:, 20:, :] == 255) +def test_negative_translated_border_padding_keeps_source_offset(tmp_path): + """A translated level's clipped pixels land after the top/left padding.""" + store = build_ngff_v04( + str(tmp_path / "translated-border.zarr"), sizes=((8, 10), (4, 5)) + ) + root = zarr.open_group(store, mode="r+") + attrs = dict(root.attrs) + datasets = attrs["multiscales"][0]["datasets"] + # Level 1 is 1 µm/px. A (+2,+1) µm source translation maps level-0 (0,0) + # to level-1 index (-2,-1), so the returned 4x4 window needs two white/zero + # columns and one white/zero row before the clipped source data. + datasets[1]["coordinateTransformations"].append( + {"type": "translation", "translation": [0.0, 0.0, 0.0, 1.0, 2.0]} + ) + root.attrs.update(attrs) + + region = Region(level=1, location=Point(0, 0), size=Size(4, 4)) + with OmeZarrReader(store) as reader: + assert reader.read_level_mapping(1)[2:] == (-2.0, -1.0) + native = reader.read_region_channels(region) + rgb = reader.read_region(region) + + source = np.asarray(root["1"][0, :, 0, :3, :2]).transpose(1, 2, 0) + expected_native = np.zeros((4, 4, 3), dtype=np.uint8) + expected_native[1:, 2:, :] = source + expected_rgb = np.full((4, 4, 3), 255, dtype=np.uint8) + expected_rgb[1:, 2:, :] = source + np.testing.assert_array_equal(native, expected_native) + np.testing.assert_array_equal(rgb, expected_rgb) + + def test_exact_mpp_plan_via_reader(synthetic_ngff): with OmeZarrReader(synthetic_ngff) as r: plan = r.level_for_mpp(1.0, 224) # mpp0=0.5 -> level 1 is exactly 1.0 @@ -169,6 +266,71 @@ def test_cached_read_region_equals_uncached(synthetic_ngff): assert np.array_equal(cached_again, uncached) +def test_cached_read_normalises_noncanonical_x_c_y_axis_order(tmp_path): + """An x,c,y source is read with correct orientation across unequal chunks.""" + height, width = 9, 13 + yy, xx = np.indices((height, width)) + expected = np.stack( + [xx + 10 * yy, 80 + xx + 10 * yy, 160 + xx + 10 * yy], axis=-1 + ).astype(np.uint8) + # Source order is deliberately x,c,y rather than a conventional c,y,x. Unequal + # dimensions and chunks make an accidental x/y swap observable. + source = np.transpose(expected, (1, 2, 0)) + store = _write_single_level_ngff( + tmp_path / "x-c-y.zarr", + data=source, + chunks=(5, 1, 3), + axes=[ + {"name": "x", "type": "space", "unit": "micrometer"}, + {"name": "c", "type": "channel"}, + {"name": "y", "type": "space", "unit": "micrometer"}, + ], + scale=[0.5, 1.0, 0.5], + ) + region = Region(level=0, location=Point(3, 2), size=Size(8, 6)) + + with OmeZarrReader(store) as reader: + assert reader.axes == ("x", "c", "y") + native = reader.read_region_channels(region) + rgb = reader.read_region(region) + assert reader._chunk_cache.misses > 1 + + wanted = expected[2:8, 3:11, :] + np.testing.assert_array_equal(native, wanted) + np.testing.assert_array_equal(rgb, wanted) + + +def test_non_singleton_custom_axis_warns_and_reads_index_zero(tmp_path): + """Every extent-bearing extra axis warns; its index-zero plane is selected.""" + height, width = 7, 11 + yy, xx = np.indices((height, width)) + phase0 = np.stack( + [xx + 10 * yy, 70 + xx + 10 * yy, 140 + xx + 10 * yy], axis=0 + ).astype(np.uint8) + phase1 = np.full_like(phase0, 255) + store = _write_single_level_ngff( + tmp_path / "custom-axis.zarr", + data=np.stack([phase0, phase1], axis=0), + chunks=(1, 1, 3, 5), + axes=[ + {"name": "phase", "type": "time", "unit": "second"}, + {"name": "c", "type": "channel"}, + {"name": "y", "type": "space", "unit": "micrometer"}, + {"name": "x", "type": "space", "unit": "micrometer"}, + ], + scale=[1.0, 1.0, 0.5, 0.5], + ) + + with pytest.warns(UserWarning, match=r"phase=2; only phase=0 is read"): + with OmeZarrReader(store) as reader: + got = reader.read_region_channels( + Region(level=0, location=Point(2, 1), size=Size(7, 5)) + ) + + wanted = np.transpose(phase0[:, 1:6, 2:9], (1, 2, 0)) + np.testing.assert_array_equal(got, wanted) + + def test_overlapping_reads_hit_the_cache(synthetic_ngff): """Adjacent/overlapping reads reuse decompressed chunks (cache hits > 0).""" with OmeZarrReader(synthetic_ngff) as r: @@ -218,9 +380,71 @@ def test_to_uint8_handles_unsigned_signed_and_float(): assert (f(np.full((1, 1, 3), 65535, np.uint16)) == 255).all() # uint16 rescaled # signed int: negatives clamp to 0, positive rescales by dtype max np.testing.assert_array_equal( - f(np.array([[[-5, 32767, 0]]], np.int16))[0, 0], [0, 255, 0]) + f(np.array([[[-5, 32767, 0]]], np.int16))[0, 0], [0, 255, 0] + ) # float in [0, 1] -> *255; float already in [0, 255] -> clip passthrough np.testing.assert_array_equal( - f(np.array([[[0.0, 1.0, 0.5]]], np.float32))[0, 0], [0, 255, 128]) + f(np.array([[[0.0, 1.0, 0.5]]], np.float32))[0, 0], [0, 255, 128] + ) np.testing.assert_array_equal( - f(np.array([[[0.0, 255.0, 300.0]]], np.float32))[0, 0], [0, 255, 255]) + f(np.array([[[0.0, 255.0, 300.0]]], np.float32))[0, 0], [0, 255, 255] + ) + + +def test_float_byte_range_is_chosen_once_from_level_zero(tmp_path): + """A locally dark patch must not be mistaken for normalized float pixels.""" + level0 = np.full((3, 12, 12), 0.75, dtype=np.float32) + # A byte-range value in a distant level-zero chunk establishes the convention. + level0[:, -2:, -2:] = 200.0 + level1 = np.full((3, 6, 6), 0.75, dtype=np.float32) + store = _write_float_pyramid_ngff( + tmp_path / "float-byte.zarr", + level0=level0, + level1=level1, + ) + + with OmeZarrReader(store) as reader: + assert reader._float_to_uint8_scale == 1.0 + dark_level0 = reader.read_region( + Region(level=0, location=Point(0, 0), size=Size(3, 3)) + ) + dark_level1 = reader.read_region( + Region(level=1, location=Point(0, 0), size=Size(3, 3)) + ) + native = reader.read_region_channels( + Region(level=0, location=Point(0, 0), size=Size(3, 3)) + ) + + # Per-region inference used to turn these local 0.75 values into 191. The + # level-zero byte-range decision now applies to both levels, while the native + # multiplex-style read remains untouched. + assert np.all(dark_level0 == 1) + assert np.all(dark_level1 == 1) + assert native.dtype == np.float32 + assert np.all(native == np.float32(0.75)) + + +def test_float_unit_range_decision_is_reused_by_lower_levels(tmp_path): + """Lower-level local extrema cannot revise a normalized slide's convention.""" + level0 = np.full((3, 12, 12), 0.5, dtype=np.float32) + level0[:, -2:, -2:] = 1.0 + # Deliberate out-of-contract sentinel: if level 1 reclassified itself from its + # local max, this would become 2 instead of saturating under level zero's choice. + level1 = np.full((3, 6, 6), 2.0, dtype=np.float32) + store = _write_float_pyramid_ngff( + tmp_path / "float-unit.zarr", + level0=level0, + level1=level1, + ) + + with OmeZarrReader(store) as reader: + assert reader._float_to_uint8_scale == 255.0 + normalized = reader.read_region( + Region(level=0, location=Point(0, 0), size=Size(3, 3)) + ) + lower_level = reader.read_region( + Region(level=1, location=Point(0, 0), size=Size(3, 3)) + ) + + assert np.all(normalized == 128) + assert np.all(lower_level == 255) diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py new file mode 100644 index 0000000..2c38105 --- /dev/null +++ b/tests/test_release_metadata.py @@ -0,0 +1,22 @@ +"""Release metadata stays synchronized across the package and citation files.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import yaml + +import raw2features + +ROOT = Path(__file__).resolve().parents[1] + + +def test_release_versions_match() -> None: + project = tomllib.loads((ROOT / "pyproject.toml").read_text()) + citation = yaml.safe_load((ROOT / "CITATION.cff").read_text()) + version = project["project"]["version"] + + assert raw2features.__version__ == version + assert citation["version"] == version + assert f"## [{version}]" in (ROOT / "CHANGELOG.md").read_text() diff --git a/tests/test_resolve_geometry.py b/tests/test_resolve_geometry.py index f5982ac..f8fbbfa 100644 --- a/tests/test_resolve_geometry.py +++ b/tests/test_resolve_geometry.py @@ -2,15 +2,18 @@ With no `--mpp`/`--patch-size`, each model is extracted at its own card geometry (`recommended_mpp`, `recommended_patch_px` or `input_size`); models that disagree -get SEPARATE grids instead of raising. An explicit `--mpp`/`--patch-size` collapses -everything onto one grid; a config controls geometry per model (incl. repeats). +get SEPARATE grids instead of raising. A bare `--mpp` preserves model patch sizes; +supplying both `--mpp` and `--patch-size` collapses everything onto one grid. A config +controls geometry per model (incl. repeats). Pure in (models, mpp, patch_px, config) so every CLI resolves identically. """ from __future__ import annotations +import pytest + +from raw2features.embedders.base import ModelSpec from raw2features.embedders.model_registry import ( - DEFAULT_PATCH_PX, DEFAULT_TARGET_MPP, get_spec, model_geometry, @@ -41,14 +44,44 @@ def test_scale_agnostic_baseline_defaults(): assert model_geometry("resnet50") == (DEFAULT_TARGET_MPP, 224, "default") -def test_unknown_model_falls_back_to_default_geometry(): - assert model_geometry("not_a_real_model") == ( - DEFAULT_TARGET_MPP, - DEFAULT_PATCH_PX, - "default", +def _external_spec( + *, name="external", recommended_mpp=0.75, recommended_patch_px=96 +): + return ModelSpec( + name=name, + family="external", + source="external://weights", + embedding_dim=8, + input_size=64, + pooling="cls", + mean=(0.5, 0.5, 0.5), + std=(0.5, 0.5, 0.5), + transform_source_url="https://example.org/external", + license="MIT", + gated=False, + recommended_mpp=recommended_mpp, + recommended_patch_px=recommended_patch_px, ) +def test_unknown_model_requires_an_injected_specification(): + with pytest.raises(ValueError, match="not in the registry or supplied"): + model_geometry("not_a_real_model") + + +def test_external_model_geometry_comes_from_its_injected_specification(): + spec = _external_spec() + assert model_geometry("external", specs={"external": spec}) == ( + 0.75, + 96, + "recommended", + ) + groups = resolve_geometry(["external"], specs={"external": spec}) + assert [(g.mpp, g.patch_px, g.models) for g in groups] == [ + (0.75, 96, ("external",)) + ] + + # --- per-model grouping (no override) -------------------------------------- @@ -87,15 +120,24 @@ def test_duplicate_model_names_dedup_within_a_grid(): assert len(groups) == 1 and groups[0].models == ("uni",) -# --- global collapse (explicit override) ----------------------------------- +def test_high_level_run_resolution_rejects_an_empty_request(): + from raw2features.pipeline.runner import RunConfig, resolve_run + + with pytest.raises(ValueError, match="at least one model/extraction is required"): + resolve_run(RunConfig(models=[])) + +# --- explicit geometry overrides ------------------------------------------ -def test_explicit_mpp_collapses_all_to_one_grid(): + +def test_explicit_mpp_preserves_each_models_recommended_patch_size(): groups = resolve_geometry(["uni", "conch", "retccl"], requested_mpp=0.5) - assert len(groups) == 1 - assert (groups[0].mpp, groups[0].patch_px) == (0.5, DEFAULT_PATCH_PX) - assert set(groups[0].models) == {"uni", "conch", "retccl"} - assert groups[0].source == "explicit" + assert _geom_by_models(groups) == { + frozenset({"uni"}): (0.5, 224), + frozenset({"conch"}): (0.5, 448), + frozenset({"retccl"}): (0.5, 256), + } + assert all(group.source == "explicit" for group in groups) def test_explicit_mpp_and_patch_size_collapse(): diff --git a/tests/test_resolve_mpp.py b/tests/test_resolve_mpp.py index 28f9ed8..204aa67 100644 --- a/tests/test_resolve_mpp.py +++ b/tests/test_resolve_mpp.py @@ -1,9 +1,10 @@ """`resolve_target_mpp` - auto-select the patch-extraction MPP from the model cards. -Explicit always wins; a single shared `recommended_mpp` is used; scale-agnostic / -unknown models fall back to the default; models that disagree raise (one run = one -scale). The resolver is pure in (models, requested) so embed/embed-many/verify all -resolve identically and their config hashes line up. +Explicit always wins for known models; a single shared `recommended_mpp` is used; +scale-agnostic models fall back to the default; unknown models require an injected +specification; models that disagree raise (one run = one scale). The resolver is pure +in its inputs so embed/embed-many/verify all resolve identically and their config +hashes line up. """ from __future__ import annotations @@ -44,11 +45,22 @@ def test_foundation_plus_scale_agnostic_uses_the_foundation_mpp(): assert resolve_target_mpp(["uni", "resnet50"], None) == (0.5, "auto") -def test_unknown_model_is_ignored_not_an_error(): - assert resolve_target_mpp(["mock_not_in_registry"], None) == ( - DEFAULT_TARGET_MPP, - "auto-default", +def test_unknown_model_requires_an_injected_spec_even_with_explicit_mpp(): + with pytest.raises(ValueError, match="not in the registry or supplied"): + resolve_target_mpp(["mock_not_in_registry"], None) + with pytest.raises(ValueError, match="not in the registry or supplied"): + resolve_target_mpp(["mock_not_in_registry"], 0.5) + + +def test_external_model_spec_participates_in_mpp_resolution(): + spec = ModelSpec( + name="external", family="x", source="x", embedding_dim=1, input_size=32, + pooling="cls", mean=(0.0,), std=(1.0,), transform_source_url="x", + license="MIT", gated=False, recommended_mpp=0.75, ) + assert resolve_target_mpp( + ["external"], None, specs={"external": spec} + ) == (0.75, "auto") def test_conflicting_recommendations_raise(monkeypatch): diff --git a/tests/test_runner_mock.py b/tests/test_runner_mock.py index 32b5c02..3964a4a 100644 --- a/tests/test_runner_mock.py +++ b/tests/test_runner_mock.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import replace + import numpy as np import pytest @@ -11,6 +13,44 @@ from raw2features.pipeline.runner import RunConfig, run_slide +def test_high_level_external_embedder_uses_its_spec_geometry(tmp_path, monkeypatch): + """The documented external-instance hook must not silently use 1.0/224.""" + + from raw2features.pipeline import runner + + external = MockEmbedder(dim=8, input_size=64, name="external") + external.spec = replace( + external.spec, + recommended_mpp=0.75, + recommended_patch_px=96, + ) + seen = [] + + def fake_run_slide(slide_path, out_dir, cfg, **kwargs): + seen.append((cfg.target_mpp, cfg.patch_px, tuple(cfg.models))) + return {"status": "complete", "grid": "external-grid"} + + monkeypatch.setattr(runner, "run_slide", fake_run_slide) + # These fields are the concrete geometry for run_slide. The high-level API uses + # model specs unless requested_mpp/requested_patch_px are passed explicitly. + cfg = RunConfig( + models=["external"], + target_mpp=0.5, + patch_px=64, + no_seg=True, + device="cpu", + amp="fp32", + ) + runner.embed_slide( + str(tmp_path / "source.zarr"), + str(tmp_path / "out"), + cfg, + embedders=[external], + ) + + assert seen == [(0.75, 96, ("external",))] + + def test_runner_e2e_mock_no_seg(synthetic_ngff, tmp_path): cfg = RunConfig( models=["mock"], diff --git a/tests/test_slide_embedders.py b/tests/test_slide_embedders.py index 4a3e8d9..acdf926 100644 --- a/tests/test_slide_embedders.py +++ b/tests/test_slide_embedders.py @@ -280,6 +280,33 @@ def test_build_slide_embedder_resolves_pool(): # -- inline runner integration ------------------------------------------------- +def test_store_fallback_discovers_specific_patch_model( + tmp_path, recording_slide_embedder +): + from raw2features.pipeline.runner import _run_slide_encoders_from_store + + path = _write_slide_store( + tmp_path, + { + "mpp0.5_px64": ( + 128, + {"conch_v1_5": np.full((3, 8), 2.0, np.float32)}, + ), + "mpp1_px64": ( + 64, + {"resnet50": np.full((3, 5), 3.0, np.float32)}, + ), + }, + ) + + result = _run_slide_encoders_from_store(path, ["titan"], "cpu") + + assert result == {"titan": "grids/mpp0.5_px64/slide/titan"} + assert "titan" in open_grid(path, "mpp0.5_px64")["slide"] + assert "slide" not in open_grid(path, "mpp1_px64") + assert recording_slide_embedder["loads"] == 1 + + @pytest.mark.skipif(not _TORCH, reason="torch not installed") def test_embed_with_inline_slide_encoder(synthetic_ngff, tmp_path): cfg = RunConfig( @@ -384,6 +411,114 @@ def test_inline_slide_encoder_is_produced_after_complete_receipt( assert recording_slide_embedder["loads"] == 1 +@pytest.mark.skipif(not _TORCH, reason="torch not installed") +def test_embed_slide_cannot_succeed_when_explicit_encoder_produces_nothing( + synthetic_ngff, tmp_path, monkeypatch +): + """An explicit inline ``-s`` request is success only if an output is present.""" + import raw2features.pipeline.runner as runner + + monkeypatch.setattr( + runner, + "run_slide", + lambda *args, **kwargs: { + "status": "complete", + "grid": "mpp0.5_px64", + "slide_embeddings": {}, + }, + ) + monkeypatch.setattr( + runner, + "_run_slide_encoders_from_store", + lambda *args, **kwargs: {}, + ) + cfg = RunConfig( + models=["mock"], + no_seg=True, + device="cpu", + amp="fp32", + slide_encoders=["mean"], + ) + + with pytest.raises(ValueError, match="slide encoder.*mean.*found no grid"): + runner.embed_slide( + synthetic_ngff, + str(tmp_path / "out"), + cfg, + geometry_config=[{"model": "mock", "mpp": 0.5, "patch_px": 64}], + embedders=[MockEmbedder(dim=8, input_size=64, name="mock")], + ) + + +@pytest.mark.skipif(not _TORCH, reason="torch not installed") +def test_inline_specific_encoder_missing_patch_grid_fails_clearly( + synthetic_ngff, tmp_path +): + """A missing required patch model is a final coverage error, not a raw lookup.""" + from raw2features.pipeline.runner import embed_slide + + with pytest.raises( + ValueError, + match=r"slide encoder\(s\) titan \(needs conch_v1_5\) found no grid", + ): + embed_slide( + synthetic_ngff, + str(tmp_path / "out"), + RunConfig( + models=["resnet50"], + no_seg=True, + device="cpu", + amp="fp32", + slide_encoders=["titan"], + ), + geometry_config=[{"model": "resnet50", "mpp": 1.0, "patch_px": 64}], + embedders=[MockEmbedder(dim=5, input_size=64, name="resnet50")], + ) + + +@pytest.mark.skipif(not _TORCH, reason="torch not installed") +def test_inline_specific_encoder_discovers_compatible_existing_grid( + synthetic_ngff, tmp_path, recording_slide_embedder +): + """``embed -s`` can consume a compatible grid from an earlier patch run.""" + from raw2features.core.store import grid_for_model + from raw2features.pipeline.runner import embed_slide + + out = str(tmp_path / "out") + first = embed_slide( + synthetic_ngff, + out, + RunConfig(models=["conch_v1_5"], no_seg=True, device="cpu", amp="fp32"), + geometry_config=[ + {"model": "conch_v1_5", "mpp": 0.5, "patch_px": 64} + ], + embedders=[ + MockEmbedder(dim=8, input_size=64, name="conch_v1_5") + ], + ) + produced = embed_slide( + synthetic_ngff, + out, + RunConfig( + models=["resnet50"], + no_seg=True, + device="cpu", + amp="fp32", + slide_encoders=["titan"], + ), + geometry_config=[{"model": "resnet50", "mpp": 1.0, "patch_px": 64}], + embedders=[MockEmbedder(dim=5, input_size=64, name="resnet50")], + ) + + root = zarr.open_group(first["output_uri"].removeprefix("file://"), mode="r") + conch_grid = open_grid(root, grid_for_model(root, "conch_v1_5")) + resnet_grid = open_grid(root, grid_for_model(root, "resnet50")) + assert produced["status"] == "complete" + assert "titan" in conch_grid["slide"] + assert "slide" not in resnet_grid or "titan" not in resnet_grid["slide"] + assert recording_slide_embedder["loads"] == 1 + + @pytest.mark.skipif(not _TORCH, reason="torch not installed") def test_runner_threads_patch_size_lv0(synthetic_ngff, tmp_path, monkeypatch): """Position-aware encoders (TITAN) need patch_size_lv0; the runner must pass the diff --git a/tests/test_spec_conformance.py b/tests/test_spec_conformance.py index 52a3152..64172c5 100644 --- a/tests/test_spec_conformance.py +++ b/tests/test_spec_conformance.py @@ -17,16 +17,33 @@ def _model_meta(feat_dim): """A fully-populated per-model provenance block (every SPEC-required key).""" return { - "source": "hf-hub:org/model", "embedding_dim": feat_dim, "input_size": 224, - "pooling": "cls", "mean": [0.5, 0.5, 0.5], "std": [0.5, 0.5, 0.5], - "interpolation": "bilinear", "transform_source_url": "https://example/card", - "license": "MIT", "gated": False, "weights_sha256": None, - "weights_revision": "deadbeef", "doi": None, + "source": "hf-hub:org/model", + "embedding_dim": feat_dim, + "input_size": 224, + "pooling": "cls", + "mean": [0.5, 0.5, 0.5], + "std": [0.5, 0.5, 0.5], + "interpolation": "bilinear", + "transform_source_url": "https://example/card", + "license": "MIT", + "gated": False, + "weights_sha256": None, + "weights_revision": "deadbeef", + "doi": None, } -def _store(tmp_path, *, name="s", header="default", coords_n=2, feat_n=2, feat_dim=4, - model="mock", model_in_header=True): +def _store( + tmp_path, + *, + name="s", + header="default", + coords_n=2, + feat_n=2, + feat_dim=4, + model="mock", + model_in_header=True, +): """Build a store by hand so each test can break exactly one rule.""" path = str(tmp_path / f"{name}.embeddings.zarr") key = "mpp0.5_px224" @@ -35,22 +52,37 @@ def _store(tmp_path, *, name="s", header="default", coords_n=2, feat_n=2, feat_d header = { "schema_version": SPEC_VERSION, "source": { - "uri": "file:///x", "slide_id": name, "mpp_level0": 0.25, - "ngff_version": "0.4", "reader": "omezarr", - "level_dimensions": [[1024, 1024]], "level_downsamples": [1.0], + "uri": "file:///x", + "slide_id": name, + "mpp_level0": 0.25, + "ngff_version": "0.4", + "reader": "omezarr", + "level_dimensions": [[1024, 1024]], + "level_downsamples": [1.0], }, "patching": { - "target_mpp": 0.5, "achieved_mpp": 0.5, "patch_px": 224, - "level0_patch": 448, "level0_step": 448, "read_level": 0, - "step_out_px": 224, "n_patches": coords_n, "grid_shape": [1, coords_n], + "target_mpp": 0.5, + "achieved_mpp": 0.5, + "patch_px": 224, + "level0_patch": 448, + "level0_step": 448, + "read_level": 0, + "step_out_px": 224, + "n_patches": coords_n, + "grid_shape": [1, coords_n], "coords_convention": "level0_xy", }, "models": ({model: _model_meta(feat_dim)} if model_in_header else {}), "grid_hash": "abc123", "provenance": { - "raw2features_version": "0", "created_utc": "1970-01-01T00:00:00Z", - "cli": "raw2features embed", "git_sha": None, "host": "h", - "arch": "x86_64", "platform": "test", "python": "3.12", + "raw2features_version": "0", + "created_utc": "1970-01-01T00:00:00Z", + "cli": "raw2features embed", + "git_sha": None, + "host": "h", + "arch": "x86_64", + "platform": "test", + "python": "3.12", }, } # Root discovery header + grids index; the authoritative header is per grid. @@ -58,8 +90,11 @@ def _store(tmp_path, *, name="s", header="default", coords_n=2, feat_n=2, feat_d "schema_version": SPEC_VERSION, "grids": { key: { - "target_mpp": 0.5, "patch_px": 224, "n_patches": coords_n, - "models": [model] if model_in_header else [], "grid_hash": "abc123", + "target_mpp": 0.5, + "patch_px": 224, + "n_patches": coords_n, + "models": [model] if model_in_header else [], + "grid_hash": "abc123", } }, } @@ -80,6 +115,17 @@ def test_valid_store_conforms(tmp_path): assert validate_store(_store(tmp_path)) == [] +def test_validation_reads_live_metadata_not_stale_consolidated_view(tmp_path): + path = _store(tmp_path) + zarr.consolidate_metadata(path) + root = zarr.open_group(path, mode="r+", use_consolidated=False) + del root[GRIDS]["mpp0.5_px224"]["coords"] + + violations = validate_store(path) + + assert any("required array 'coords' is missing" in item for item in violations) + + def test_missing_header(tmp_path): v = validate_store(_store(tmp_path, header=None)) assert any("raw2features" in x for x in v) @@ -90,6 +136,30 @@ def test_length_mismatch_breaks_1to1(tmp_path): assert any("1:1 invariant" in x for x in v) +def test_header_patch_count_must_match_coords(tmp_path): + path = _store(tmp_path, coords_n=2) + g = open_grid(path, mode="r+") + h = dict(g.attrs)["raw2features"] + h["patching"]["n_patches"] = 3 + g.attrs["raw2features"] = h + + v = validate_store(path) + assert any("header.patching.n_patches 3 != coords length 2" in item for item in v) + + +def test_every_header_model_must_have_a_feature_array(tmp_path): + path = _store(tmp_path) + g = open_grid(path, mode="r+") + h = dict(g.attrs)["raw2features"] + h["models"]["missing"] = _model_meta(7) + g.attrs["raw2features"] = h + + v = validate_store(path) + assert any( + "header.models.missing has no features/missing array" in item for item in v + ) + + def test_model_without_header_entry(tmp_path): v = validate_store(_store(tmp_path, model_in_header=False)) assert any("no header.models" in x for x in v) @@ -227,8 +297,15 @@ def test_built_header_aligns_with_schema(synthetic_ngff): with OmeZarrReader(synthetic_ngff) as r: grid = GridPatcher(target_mpp=0.5, patch_px=64).build_grid(r) header = _build_header( - r, grid, {"segmenter": "none"}, [MockEmbedder().load()], - "s", 0, None, "gridhash", provenance.capture("test"), + r, + grid, + {"segmenter": "none"}, + [MockEmbedder().load()], + "s", + 0, + None, + "gridhash", + provenance.capture("test"), ) schema = _load_header_schema(SPEC_VERSION) jsonschema.Draft202012Validator(schema).validate(header) # raises on any mismatch @@ -240,9 +317,18 @@ def test_real_pipeline_output_conforms(synthetic_ngff, tmp_path): from conftest import MockEmbedder from raw2features.pipeline.runner import RunConfig, run_slide - cfg = RunConfig(models=["mock"], segmenter="otsu", target_mpp=0.5, patch_px=64, - tissue_threshold=0.0, device="cpu", amp="fp32", batch_size=8) - summary = run_slide(synthetic_ngff, str(tmp_path / "out"), cfg, - embedders=[MockEmbedder()]) + cfg = RunConfig( + models=["mock"], + segmenter="otsu", + target_mpp=0.5, + patch_px=64, + tissue_threshold=0.0, + device="cpu", + amp="fp32", + batch_size=8, + ) + summary = run_slide( + synthetic_ngff, str(tmp_path / "out"), cfg, embedders=[MockEmbedder()] + ) store = summary["output_uri"].removeprefix("file://") assert validate_store(store) == [], validate_store(store) diff --git a/uv.lock b/uv.lock index 6e606a1..c5b8e65 100644 --- a/uv.lock +++ b/uv.lock @@ -625,7 +625,7 @@ wheels = [ [[package]] name = "dask" -version = "2026.1.1" +version = "2026.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -637,9 +637,9 @@ dependencies = [ { name = "pyyaml" }, { name = "toolz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/46/61ecde57bac647ca7eb6ffef8dcd90af6c1c649020874cd7fd8738003d62/dask-2026.1.1.tar.gz", hash = "sha256:12b1dbb0d6e92f287feb4076871600b2fba3a843d35ff214776ada5e9e7a1529", size = 10994732, upload-time = "2026-01-16T12:35:30.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/39/cbd21c9133d02b4e60899ed466ad5e876553ea68ebffee6209e7dcafc8d4/dask-2026.7.1.tar.gz", hash = "sha256:5727484427665f051e86bf87d021a64d6411141cdc8a20bfe3c1ad2968cc06b7", size = 11548794, upload-time = "2026-07-14T01:06:22.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/4b/9cc373120658a2516aa5f6dcdde631c95d714b876d29ad8f8e009d793f3f/dask-2026.1.1-py3-none-any.whl", hash = "sha256:146b0ef2918eb581e06139183a88801b4a8c52d7c37758a91f8c3b75c54b0e15", size = 1481492, upload-time = "2026-01-16T12:35:22.602Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5f/7c22733da92b3a6cc4dddcaa8731089d213c2790bbc997e51c429a4e8f8b/dask-2026.7.1-py3-none-any.whl", hash = "sha256:985ffd6c5e9d7979ede515e84ae8d39b647d6aa64f77600f15714ff65f578fe6", size = 1496882, upload-time = "2026-07-14T01:06:20.341Z" }, ] [package.optional-dependencies] @@ -702,28 +702,27 @@ wheels = [ [[package]] name = "distributed" -version = "2026.1.1" +version = "2026.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "cloudpickle" }, - { name = "dask" }, - { name = "jinja2" }, - { name = "locket" }, - { name = "msgpack" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyyaml" }, - { name = "sortedcontainers" }, - { name = "tblib" }, - { name = "toolz" }, - { name = "tornado" }, - { name = "urllib3" }, - { name = "zict" }, + { name = "click", marker = "python_full_version >= '3.12'" }, + { name = "cloudpickle", marker = "python_full_version >= '3.12'" }, + { name = "dask", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "locket", marker = "python_full_version >= '3.12'" }, + { name = "msgpack", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "psutil", marker = "python_full_version >= '3.12'" }, + { name = "pyyaml", marker = "python_full_version >= '3.12'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.12'" }, + { name = "tblib", marker = "python_full_version >= '3.12'" }, + { name = "toolz", marker = "python_full_version >= '3.12'" }, + { name = "tornado", marker = "python_full_version >= '3.12'" }, + { name = "zict", marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/19/0c13efdffc55cb311594f66c1c8d36a3c4711e427c820155fb9c59138b5e/distributed-2026.1.1.tar.gz", hash = "sha256:3d2709a43912797df3c345af3bb333bbf1a386ec1e9e6a134e5f050521373dbd", size = 2102870, upload-time = "2026-01-16T12:34:58.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/e4/567125e6f2f504a6b1e37e3aecd936cf61b662c09e6e79270bd37c93f703/distributed-2026.7.1.tar.gz", hash = "sha256:f7c48ab6961734874521574d75898466703bce93f22f0cff5872c7e4ac6425d4", size = 2461209, upload-time = "2026-07-14T01:20:55.349Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/dc/6d709bcf8fed7611d8a510aeed23b0436cf6db5b61e63c8eb8451eb0d4d8/distributed-2026.1.1-py3-none-any.whl", hash = "sha256:506759b1ed88e45e12ba65e2a429de9911862db55d27dd8bb293c6268430374e", size = 1008417, upload-time = "2026-01-16T12:34:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4c/369f0b5665afa8ad6fb1862da979dbf61a650ea2b99b5dccda8953aa327c/distributed-2026.7.1-py3-none-any.whl", hash = "sha256:64eaf4406a04ad26acdc5b8e32fad6bac522d07539225a530bab49610b0114a9", size = 1012001, upload-time = "2026-07-14T01:20:53.684Z" }, ] [[package]] @@ -1958,7 +1957,7 @@ wheels = [ [[package]] name = "ngff-zarr" -version = "0.20.3" +version = "0.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dask", extra = ["array"] }, @@ -1974,9 +1973,9 @@ dependencies = [ { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "zarr", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/2d/11634237253a2234046173627811a98f2d1b4bd21444e4aaba39a83053c3/ngff_zarr-0.20.3.tar.gz", hash = "sha256:c072f1b92c95acf928407d7886b85afbf81b2728782d75a422d70323bee66582", size = 220546, upload-time = "2026-01-26T18:31:00.905Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/3a/ea45a8fb52516f35eb86fab997145c0babdc0751edd8bc1686422a3956f2/ngff_zarr-0.38.0.tar.gz", hash = "sha256:ca719ad7b4da194986de8360c193e4f5068c06caa349c18c302873830a582104", size = 566671, upload-time = "2026-07-09T20:27:29.169Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/0a/baa6733a2cbc0c08495aad756772d0c3b6d74b317518d0f17c2dccae07ef/ngff_zarr-0.20.3-py3-none-any.whl", hash = "sha256:9a74fc5dec9fb60a617ade98f28f9b03348afa5c5b93f48d744ae7e9e2a60482", size = 106186, upload-time = "2026-01-26T18:30:59.614Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ba/659fb680390f648e28e256659640e44fbe02b03e12cccdd20b14827e16f3/ngff_zarr-0.38.0-py3-none-any.whl", hash = "sha256:2f07ca50ad3b912938e4777ce2de9b8f3350f370bf73702143018933f73a0e4a", size = 195036, upload-time = "2026-07-09T20:27:27.59Z" }, ] [[package]] @@ -2285,24 +2284,78 @@ wheels = [ [[package]] name = "ome-zarr" -version = "0.15.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version <= '3.11' and sys_platform == 'win32'", + "python_full_version <= '3.11' and sys_platform == 'emscripten'", + "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] dependencies = [ - { name = "aiohttp" }, - { name = "dask" }, - { name = "deprecated" }, - { name = "fsspec", extra = ["s3"] }, - { name = "numpy" }, - { name = "rangehttpserver" }, - { name = "requests" }, - { name = "scikit-image" }, - { name = "toolz" }, - { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "aiohttp", marker = "python_full_version <= '3.11'" }, + { name = "dask", marker = "python_full_version <= '3.11'" }, + { name = "deprecated", marker = "python_full_version <= '3.11'" }, + { name = "fsspec", extra = ["s3"], marker = "python_full_version <= '3.11'" }, + { name = "numpy", marker = "python_full_version <= '3.11'" }, + { name = "rangehttpserver", marker = "python_full_version <= '3.11'" }, + { name = "requests", marker = "python_full_version <= '3.11'" }, + { name = "scikit-image", marker = "python_full_version <= '3.11'" }, + { name = "toolz", marker = "python_full_version <= '3.11'" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/45/b5b9933ec62028453dc841d25ea66d65405d77d2eb5bb34043621b0c294c/ome_zarr-0.16.0.tar.gz", hash = "sha256:0d31f3409c0f4ad67f04b9a4e9e8855e6e6b040e84d5c9be1095638f90d6eeb9", size = 86252, upload-time = "2026-04-14T09:43:32.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/81/4c41331f59e64b2a5eaec1c03819d5791e4dde321694fbda661f873184da/ome_zarr-0.16.0-py3-none-any.whl", hash = "sha256:a34a5558905af526afd343105804b6d291e97f949cb71f6b3d269a6a7112b599", size = 46410, upload-time = "2026-04-14T09:43:31.092Z" }, +] + +[[package]] +name = "ome-zarr" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "aiohttp", marker = "python_full_version > '3.11'" }, + { name = "dask", marker = "python_full_version > '3.11'" }, + { name = "deprecated", marker = "python_full_version > '3.11'" }, + { name = "fsspec", extra = ["s3"], marker = "python_full_version > '3.11'" }, + { name = "numpy", marker = "python_full_version > '3.11'" }, + { name = "ome-zarr-models", marker = "python_full_version > '3.11'" }, + { name = "rangehttpserver", marker = "python_full_version > '3.11'" }, + { name = "requests", marker = "python_full_version > '3.11'" }, + { name = "scikit-image", marker = "python_full_version > '3.11'" }, + { name = "toolz", marker = "python_full_version > '3.11'" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "zarr", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/98/f39cf0ec00ad64996ed2d2d6059d15a30c7b8f559cd2a216589885001a6a/ome_zarr-0.18.0.tar.gz", hash = "sha256:5cc0fc682b0c177fa32e54f4479b2436f79850a171e83ad4c6b08fec32e66cd0", size = 106039, upload-time = "2026-06-17T16:22:07.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/be/2cf9389c9a7b80ceefae88bd713afa6a4b488cb47e524e7dadae44260efd/ome_zarr-0.18.0-py3-none-any.whl", hash = "sha256:aaf6ea6ac5790623290210efc566ad49f92da170b50b07e9113b6b06a0870cc5", size = 57947, upload-time = "2026-06-17T16:22:06.732Z" }, +] + +[[package]] +name = "ome-zarr-models" +version = "1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "python_full_version > '3.11'" }, + { name = "pydantic-zarr", version = "0.9.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "pydantic-zarr", version = "0.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, { name = "zarr", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/ee/0e99c62dd3d453c9fb84d8c8cc9f64e255c2d7638546836516282fae2be5/ome_zarr-0.15.0.tar.gz", hash = "sha256:e9479d3a9849c194a616355290f6ae574a7d4402559c5f7005be20cf81945435", size = 81497, upload-time = "2026-04-08T11:57:25.475Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e7/c37b8896a24eabc76d6a5e7855f722792de5bdf3f5ccc562001738be4f7e/ome_zarr_models-1.6.tar.gz", hash = "sha256:00735ad939d2b5f6a4f583cc19af1e8a5247fa1e93f1f08191143cd913b9633a", size = 106913, upload-time = "2026-03-10T10:14:28.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/71/159de9929fe98326ada41393d2133fda3f9869ea10bf94996c48846ff68f/ome_zarr-0.15.0-py3-none-any.whl", hash = "sha256:99789b0c4c27fbd197ab1a8bb3ae58b1c7b4e72d0d6b95e9dda6e6416623d3eb", size = 45200, upload-time = "2026-04-08T11:57:24.399Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fc/7650df09dce0edfbaa1f2898c5eb0e0523ae5964dadb83855beb52166505/ome_zarr_models-1.6-py3-none-any.whl", hash = "sha256:d855a6fe885aa2613f64cd124ff8453d75854531bb3282eeaa916e976c7bcf66", size = 64251, upload-time = "2026-03-10T10:14:27.177Z" }, ] [[package]] @@ -2996,6 +3049,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pydantic-zarr" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "pydantic", marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/19/ad4f22b7a395408c90cc0f213d70cf5b9ed5f3b0db2b319cacff93b66850/pydantic_zarr-0.9.2.tar.gz", hash = "sha256:59f536cbc456ff4dc64d887d651476e2a6a2ca9f3ea13228dce4e3aeb1cf00b9", size = 80379, upload-time = "2026-03-18T14:38:30.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/f4/93a623ab5d2e1c20de0b43e60d6bd8df7704b39e88f5424b4191391a40d9/pydantic_zarr-0.9.2-py3-none-any.whl", hash = "sha256:8009b661cdce3e8283c26d281975432841a39fc8af8309f4ff66722e43850c6f", size = 52952, upload-time = "2026-03-18T14:38:29.684Z" }, +] + +[[package]] +name = "pydantic-zarr" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/57/aac9e500abe15719b7406d55871fc8787e7d0ebf83de5f98d44471139f70/pydantic_zarr-0.10.0.tar.gz", hash = "sha256:996b5b4225fb05757e7fff831ee7a75c61f54514a4c2493c7b79635bade7b415", size = 80374, upload-time = "2026-04-23T14:23:12.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/05/04af0ca9c5747c95591a549509ebbb0a986155015de59f1e4c984e3af9ab/pydantic_zarr-0.10.0-py3-none-any.whl", hash = "sha256:d15769dab43346b070051fdf6e83509d7f55259dd00c6d12be2400935117e230", size = 52809, upload-time = "2026-04-23T14:23:11.011Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -3277,7 +3371,7 @@ wheels = [ [[package]] name = "raw2features" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "jsonschema" }, @@ -3296,7 +3390,8 @@ all = [ { name = "huggingface-hub" }, { name = "ngff-zarr" }, { name = "numcodecs" }, - { name = "ome-zarr" }, + { name = "ome-zarr", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.11'" }, + { name = "ome-zarr", version = "0.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11'" }, { name = "opencv-python-headless" }, { name = "pillow" }, { name = "safetensors" }, @@ -3446,7 +3541,8 @@ seal = [ ] spatialdata = [ { name = "geopandas" }, - { name = "spatialdata" }, + { name = "spatialdata", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "spatialdata", version = "0.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] stain = [ { name = "scikit-learn" }, @@ -3475,7 +3571,8 @@ zarr = [ { name = "fsspec", extra = ["http"] }, { name = "ngff-zarr" }, { name = "numcodecs" }, - { name = "ome-zarr" }, + { name = "ome-zarr", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.11'" }, + { name = "ome-zarr", version = "0.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11'" }, { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "zarr", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] @@ -4421,45 +4518,101 @@ wheels = [ [[package]] name = "spatialdata" -version = "0.7.3" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version <= '3.11' and sys_platform == 'win32'", + "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version <= '3.11' and sys_platform == 'emscripten'", + "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version <= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] dependencies = [ - { name = "anndata", version = "0.12.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version <= '3.11' and sys_platform == 'win32')" }, - { name = "anndata", version = "0.12.16", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.14' and sys_platform != 'win32') or (python_full_version > '3.11' and sys_platform == 'win32')" }, - { name = "annsel" }, - { name = "click" }, - { name = "dask" }, - { name = "dask-image" }, - { name = "datashader" }, - { name = "distributed" }, - { name = "fsspec", extra = ["http", "s3"] }, - { name = "geopandas" }, - { name = "multiscale-spatial-image" }, - { name = "networkx" }, - { name = "numba" }, - { name = "numpy" }, - { name = "ome-zarr" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.14' and sys_platform != 'win32') or (python_full_version > '3.11' and sys_platform == 'win32')" }, - { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version <= '3.11' and sys_platform == 'win32')" }, - { name = "pooch" }, - { name = "pyarrow" }, - { name = "rich" }, - { name = "scikit-image" }, - { name = "scipy" }, - { name = "setuptools" }, - { name = "shapely" }, - { name = "spatial-image" }, - { name = "typing-extensions" }, - { name = "universal-pathlib" }, - { name = "xarray" }, + { name = "anndata", version = "0.12.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform != 'win32') or (python_full_version <= '3.11' and sys_platform == 'win32')" }, + { name = "anndata", version = "0.12.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "annsel", marker = "python_full_version < '3.12'" }, + { name = "click", marker = "python_full_version < '3.12'" }, + { name = "dask", marker = "python_full_version < '3.12'" }, + { name = "dask-image", marker = "python_full_version < '3.12'" }, + { name = "datashader", marker = "python_full_version < '3.12'" }, + { name = "fsspec", extra = ["http", "s3"], marker = "python_full_version < '3.12'" }, + { name = "geopandas", marker = "python_full_version < '3.12'" }, + { name = "multiscale-spatial-image", marker = "python_full_version < '3.12'" }, + { name = "networkx", marker = "python_full_version < '3.12'" }, + { name = "numba", marker = "python_full_version < '3.12'" }, + { name = "numpy", marker = "python_full_version < '3.12'" }, + { name = "ome-zarr", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.11'" }, + { name = "ome-zarr", version = "0.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.11' and python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform != 'win32') or (python_full_version <= '3.11' and sys_platform == 'win32')" }, + { name = "pooch", marker = "python_full_version < '3.12'" }, + { name = "pyarrow", marker = "python_full_version < '3.12'" }, + { name = "rich", marker = "python_full_version < '3.12'" }, + { name = "scikit-image", marker = "python_full_version < '3.12'" }, + { name = "scipy", marker = "python_full_version < '3.12'" }, + { name = "setuptools", marker = "python_full_version < '3.12'" }, + { name = "shapely", marker = "python_full_version < '3.12'" }, + { name = "spatial-image", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "universal-pathlib", marker = "python_full_version < '3.12'" }, + { name = "xarray", marker = "python_full_version < '3.12'" }, + { name = "xarray-schema", marker = "python_full_version < '3.12'" }, { name = "xarray-spatial", version = "0.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "xarray-spatial", version = "0.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/ae/0330a4bb68945fee2ae485a7318f51cc76911073189db4136effba023426/spatialdata-0.7.0.tar.gz", hash = "sha256:5a0a12d33e9682fe5193abe3430d8bfa106db62440423ae2d508d8a73ad264cc", size = 347530, upload-time = "2026-01-30T12:46:41.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/0e/4ed0def70624873f8a4feedd4183c1d090aab55e5d82f36fb3ddef4886cb/spatialdata-0.7.0-py3-none-any.whl", hash = "sha256:6df869089462994330e7b8d9b29dd2e3dbc02c09ce8d91b83a234911012ef3b0", size = 192054, upload-time = "2026-01-30T12:46:40.169Z" }, +] + +[[package]] +name = "spatialdata" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "anndata", version = "0.12.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'" }, + { name = "anndata", version = "0.12.16", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.14' and sys_platform != 'win32') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "annsel", marker = "python_full_version >= '3.12'" }, + { name = "click", marker = "python_full_version >= '3.12'" }, + { name = "dask", marker = "python_full_version >= '3.12'" }, + { name = "dask-image", marker = "python_full_version >= '3.12'" }, + { name = "datashader", marker = "python_full_version >= '3.12'" }, + { name = "distributed", marker = "python_full_version >= '3.12'" }, + { name = "fsspec", extra = ["http", "s3"], marker = "python_full_version >= '3.12'" }, + { name = "geopandas", marker = "python_full_version >= '3.12'" }, + { name = "multiscale-spatial-image", marker = "python_full_version >= '3.12'" }, + { name = "networkx", marker = "python_full_version >= '3.12'" }, + { name = "numba", marker = "python_full_version >= '3.12'" }, + { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "ome-zarr", version = "0.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.14' and sys_platform != 'win32') or (python_full_version >= '3.12' and sys_platform == 'win32')" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'" }, + { name = "pooch", marker = "python_full_version >= '3.12'" }, + { name = "pyarrow", marker = "python_full_version >= '3.12'" }, + { name = "rich", marker = "python_full_version >= '3.12'" }, + { name = "scikit-image", marker = "python_full_version >= '3.12'" }, + { name = "scipy", marker = "python_full_version >= '3.12'" }, + { name = "setuptools", marker = "python_full_version >= '3.12'" }, + { name = "shapely", marker = "python_full_version >= '3.12'" }, + { name = "spatial-image", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "universal-pathlib", marker = "python_full_version >= '3.12'" }, + { name = "xarray", marker = "python_full_version >= '3.12'" }, + { name = "xarray-spatial", version = "0.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "zarr", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3c/50/248fae79a62f3d9dffbe70e9b66be6e3f8aaf77caa20a8c931a79256b689/spatialdata-0.7.3.tar.gz", hash = "sha256:6054d66d00e19af96368a897faa0b369f3a25dbf9e0371f091129f8522ee6986", size = 359317, upload-time = "2026-05-06T16:02:42.446Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/70/e575bc26b0410cf1349f5115b55d8d119edd6b2cf18e32ab84e222ee17e3/spatialdata-0.8.0.tar.gz", hash = "sha256:14143788a1de83302f8e83a29dd723b2d4e82f5b130d840409822fbf3a778e25", size = 369880, upload-time = "2026-07-02T20:42:20.597Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/bd/76cd29c982ed8f06db839411f1f44c521bf36b8714074652368353856b44/spatialdata-0.7.3-py3-none-any.whl", hash = "sha256:15e0df647f149a8be70cef6b866dcf033c71890440583bca8bb1c20c37566e6c", size = 199244, upload-time = "2026-05-06T16:02:40.994Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/f5cac3e58d1bb092bfb6015104e6cce925bbe10a2cae44428c790d4e5d06/spatialdata-0.8.0-py3-none-any.whl", hash = "sha256:75139b0df0e27c4573141a8dbe6b3301b47231571505926ad6a633c046ec1010", size = 202596, upload-time = "2026-07-02T20:42:19.268Z" }, ] [[package]] @@ -4992,6 +5145,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/ea/bc1de04d06b7c59fc3ff647a11fa248bf80af5a6227647a31c6250c32ce6/xarray_dataclass-3.0.0-py3-none-any.whl", hash = "sha256:7956f29005671b2acaa0da700d8f359105dce84ef84f76ce2dd05b76dba3e826", size = 16862, upload-time = "2025-07-30T20:26:30.696Z" }, ] +[[package]] +name = "xarray-schema" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.12'" }, + { name = "xarray", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/3d/e0652fad05696e34897f085d4c3115dc4bedb1fb8c1827d6c79ce74095a6/xarray-schema-0.0.3.tar.gz", hash = "sha256:9c6c760489c0690a70394b2ad1368b32f8fa1333911c361b4adf249384212920", size = 15010, upload-time = "2022-04-05T23:20:58.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/6d/f585a27b380ee987619b5617c0ca672a71a4345b67cfedbb6299750ce845/xarray_schema-0.0.3-py3-none-any.whl", hash = "sha256:aa6f856626b2e100213ba290407797464608b2555bb8e0b26093a97fe1ba38ce", size = 10019, upload-time = "2022-04-05T23:20:57.301Z" }, +] + [[package]] name = "xarray-spatial" version = "0.5.3" From 520d39044dee03ae5a9454438a1b020f2101f5cd Mon Sep 17 00:00:00 2001 From: Craig Myles Date: Fri, 17 Jul 2026 16:58:23 +0100 Subject: [PATCH 2/2] Make CLI output assertions ANSI-safe --- tests/test_cli_commands.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index ed76393..24de8a5 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -10,6 +10,7 @@ from importlib import import_module import pytest +from click.utils import strip_ansi from typer.testing import CliRunner from conftest import MockEmbedder, build_ngff_v04 @@ -92,10 +93,11 @@ def fake_contracts(cfg): ) def test_commands_reject_unknown_amp_before_doing_work(args): result = CliRunner().invoke(app, [*args, "--amp", "fast16"]) + output = strip_ansi(result.output) assert result.exit_code == 2 - assert "--amp" in result.output - assert "auto, fp32, bf16, fp16" in result.output + assert "--amp" in output + assert "auto, fp32, bf16, fp16" in output @pytest.mark.parametrize( @@ -109,10 +111,11 @@ def test_commands_reject_unknown_amp_before_doing_work(args): @pytest.mark.parametrize("batch_size", ["0", "-3"]) def test_embedding_commands_reject_nonpositive_batch_size(args, batch_size): result = CliRunner().invoke(app, [*args, "--batch-size", batch_size]) + output = strip_ansi(result.output) assert result.exit_code == 2 - assert "--batch-size" in result.output - assert "greater than zero" in result.output + assert "--batch-size" in output + assert "greater than zero" in output @pytest.mark.parametrize( @@ -142,20 +145,22 @@ def test_geometry_options_reject_zero_before_work(command, option): "benchmark": ["slide.zarr"], }[command] result = CliRunner().invoke(app, [command, *positional, option, "0"]) + output = strip_ansi(result.output) assert result.exit_code == 2 - assert option in result.output - assert "greater than zero" in result.output + assert option in output + assert "greater than zero" in output def test_mpp_option_rejects_nonfinite_value_before_work(): result = CliRunner().invoke( app, ["embed", "slide.zarr", "out", "--mpp", "nan"] ) + output = strip_ansi(result.output) assert result.exit_code == 2 - assert "--mpp" in result.output - assert "finite and greater than zero" in result.output + assert "--mpp" in output + assert "finite and greater than zero" in output def _build_store(tmp_path) -> str: