diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40e39f6..a947c97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: run: | python -m pip install -U pip python -m pip install torch==2.9.0 --index-url https://download.pytorch.org/whl/cpu - python -m pip install --group dev . + python -m pip install --group dev ".[processing]" - name: 🧪 Run Tests env: diff --git a/.gitignore b/.gitignore index 570549d..3f94877 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,7 @@ celerybeat-schedule # virtualenv .venv +.uv-cache/ venv/ ENV/ @@ -120,7 +121,7 @@ src/cucim # Local audit notes, experiment reports, and diagnostic outputs /diagnostics/ /AUDIT_*.md -/docs/*_audit.md +/docs/**/*audit*.md # Temporary diagnostic runs and generated stitching reports /.tmp/ /stitching_*_diagnostic.json @@ -130,3 +131,7 @@ src/cucim /.agents/ uv.lock + +/.test-* +# Locally supplied reference papers +/tests/reference/papers/ diff --git a/README.md b/README.md index ca57c00..80d7365 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,19 @@ Read the [documentation](https://qi2lab.github.io/opm-processing-v2/), starting with [installation](https://qi2lab.github.io/opm-processing-v2/getting-started/installation/) and the [first workflow](https://qi2lab.github.io/opm-processing-v2/getting-started/quickstart/). +To select and reconstruct raw regions interactively, follow the +[ROI export guide](docs/guides/roi.md). + ## Quick start Requires Python 3.12 and [uv](https://docs.astral.sh/uv/). Windows and Linux installations include CUDA dependencies by default; Windows also requires the [cuCIM setup](docs/getting-started/installation.md#cucim-on-windows). +The base distribution provides the yaozarrs/TensorStore IO SDK. Processing, +visualization and their existing PyTorch dependency are in the `processing` +extra; the default checkout workflow still selects them. IO consumers install +the base distribution without extras. In a checkout, `uv sync --no-default-groups` +selects only IO dependencies. ```bash uv sync @@ -22,6 +30,10 @@ uv run fuse "/path/to/acquisition" uv run export-ome-tiff "/path/to/acquisition" ``` +Deconvolution reads camera and optical calibration from OME-Zarr. See the +[required metadata and solver parameters](docs/methods/deconvolution.md). +Missing read-noise calibration selects the no-read-noise model. + ## Commands Each command's reference describes its arguments, options, and an example. @@ -32,18 +44,16 @@ Append `--help` to any command for its installed options and defaults. | [inspect-opm](docs/reference/cli/inspect-opm.md) | Inspect acquisition metadata. | | [process](docs/reference/cli/process.md) | Calibrate, deconvolve, and deskew. | | [fuse](docs/reference/cli/fuse.md) | Register and blend processed tiles. | -| [display](docs/reference/cli/display.md) | View outputs and select an ROI. | -| [process-ROI](docs/reference/cli/process-roi.md) | Process and fuse selected raw regions. | | [export-ome-tiff](docs/reference/cli/export-ome-tiff.md) | Export fused images as compressed OME-BigTIFF. | +| [export-rois](docs/reference/cli/export-rois.md) | Select multiple ROIs and reconstruct raw regions into OME-Zarr and optional TIFF. | | [export-projections](docs/reference/cli/export-projections.md) | Export annotated projections and movies. | | [encode-projections](docs/reference/cli/encode-projections.md) | Encode existing projection TIFFs as movies. | -| [convert-timelapse](docs/reference/cli/convert-timelapse.md) | Export raw scan-plane timelapses. | ## Development See [contributing](docs/development/contributing.md), [running tests](docs/development/testing.md), and -[simulation examples](docs/examples/index.md). +[deconvolution usage](docs/examples/index.md). Preview the documentation in a separate environment: diff --git a/docs/development/contributing.md b/docs/development/contributing.md index 0d574eb..6db2c25 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -16,10 +16,9 @@ units, returned values, and meaningful algorithm conventions. Keep helpers and state limited to what the calculation needs. The controlled acquisition format is the input contract; support additional formats only when explicitly required. -Core processing belongs in `src/opm_processing`. Simulations, experiments, and -figure generators belong in `scripts/`, with a clear opening docstring and module -imports. Generated reports, figures, and temporary acquisitions belong under -ignored `diagnostics/` or pytest temporary directories. +Core processing belongs in `src/opm_processing`. Simulation setup, forward models, configurations, and comparison figure generators +belong under `tests/`. `scripts/` contains only a usage example. Generated outputs +belong under ignored `diagnostics/` or pytest temporary directories. ## Validate changes @@ -29,8 +28,9 @@ uv run pytest --strict-markers git diff --check ``` -Tests must be numerical unit tests or simulated disk-to-disk integration tests. -Use the [testing guide](testing.md) for CUDA selection and physical acceptance. +Use only unit and integration tests, with shared fixtures and installed storage +backends in isolated temporary directories. +Use the [testing guide](testing.md) for CPU and CUDA commands. Run performance measurements outside pytest and record the conditions and comparison baseline in ignored `diagnostics/`. Deskew, fusion, and deconvolution optimizations must retain their stated geometry and numerical behavior. diff --git a/docs/development/testing.md b/docs/development/testing.md index 459a5b7..4d787d6 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -1,78 +1,23 @@ -# Running and adding tests +# Running tests -Run these commands from the repository root. They use PowerShell syntax. - -## Set up and run - -Install the development dependencies and run tests that do not require CUDA: +Run the unit and integration suites from the repository root using an installed development environment: ```powershell -uv sync --group dev --no-default-groups uv run --no-sync python -m pytest tests/cpu --strict-markers -q -``` - -For a CUDA workstation, follow the [GPU installation instructions](../getting-started/installation.md), -install the GPU dependencies, and require working CUDA during testing: - -```powershell -uv sync --group dev --extra gpu -$env:OPM_REQUIRE_GPU = "1" uv run --no-sync python -m pytest tests/gpu --strict-markers -q -Remove-Item Env:OPM_REQUIRE_GPU ``` -Without `OPM_REQUIRE_GPU=1`, unavailable CUDA causes GPU tests to skip. Check -the summary for skipped tests before treating a run as GPU validation. +CPU tests run in GitHub Actions. GPU tests require CUDA; inspect the pytest summary for unavailable hardware. +The existing GPU environment does not need to be synchronized to run tests. + +Shared fixtures provide controlled metadata, file structures and pixel arrays. +Tests use production algorithms and installed packages. Synthetic acquisitions +share production optics through the forward model in `tests/simulation/`. +See [test contribution rules](https://github.com/qi2lab/opm-processing-v2/blob/perf/numba-deskew-corrections/tests/CONTRIBUTING.md) +for adding or changing cases. -## Run a smaller selection +Run repository checks before submitting: ```powershell -uv run --no-sync python -m pytest -q -m unit -uv run --no-sync python -m pytest -q -m integration -uv run --no-sync python -m pytest -q tests/cpu/test_depth_intensity_normalization.py +uv run --no-sync prek run --all-files ``` - -The CPU directory is the GitHub Actions suite and always uses the real CPU -registration backend. The GPU directory requires CUDA or NVENC. Running -`python -m pytest` collects both; `-m cpu` and `-m gpu` select the same groups. -Add `-x` to stop at the first failure, or `-vv` to see each test case as it runs. - -## Add a test - -Put tests in `test_*.py` files and choose `@pytest.mark.unit` for an isolated -calculation or state transition, or `@pytest.mark.integration` for a simulated -object processed from disk input to verified disk output. Use `tmp_path` for -generated files so tests do not depend on local acquisitions. - -Use the shared factories in `tests/fixtures/`; `conftest.py` loads them through -`pytest_plugins` and enforces test categories. Keep setup with the logic it models: - -| Fixture module | Responsibility | -| --- | --- | -| `acquisition` | Upstream scan layouts, camera metadata and processing options | -| `camera`, `illumination` | Detector transfer and known sensitivity fields | -| `optics`, `tiling` | Physical objects, PSFs, placement errors and acceptance settings | -| `tensorstore`, `storage`, `export` | Unit dataset mocks, saved NGFF images and OME provenance | -| `live`, `roi`, `state` | Acquisition lifecycle, physical regions and durable checkpoints | -| `fusion`, `hardware` | Isolated numerical operators and execution requirements | - -Independent optical models and measurements belong in `tests/reference/`. -Reuse a factory and override the settings that define the case. Keep expected -physical values and tolerances visible in the test. Integration tests must -reopen saved pixels; simulated return values and call counts are insufficient. -Unit tests use the shared TensorStore doubles with functional pixel reads and -writes. Integration tests retain real TensorStore datasets on disk. - -For a concrete integration example, see -[the depth-normalization test](https://github.com/qi2lab/opm-processing-v2/blob/perf/numba-deskew-corrections/tests/cpu/test_depth_intensity_normalization.py). It: - -1. Simulates an object with known geometry and brightness. -2. Writes its camera pixels and acquisition metadata to disk. -3. Runs processing and fusion using those files. -4. Reopens the outputs and compares the saved data with expected values. - -Choose tolerances from sampling, camera quantization, or another stated error -bound. Run the affected tests and `uv run --no-sync prek run --all-files` before -submitting a change. The review rules below also apply to numerical optimizations. - ---8<-- "tests/CONTRIBUTING.md" diff --git a/docs/examples/index.md b/docs/examples/index.md index b133904..8d79406 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -1,21 +1,30 @@ -# Simulation scripts +# Deconvolution usage -Repository simulations and figure generators live in `scripts/`. Run them as -modules from the repository root so package imports resolve. Their opening -docstrings describe inputs, calculations, and outputs. Generated data and figures -belong in ignored `diagnostics/` directories. +The example processes an OME-Zarr using its recorded acquisition calibration: ```bash -uv run python -m scripts.opm_simulation --output diagnostics/meridian_sphere -uv run --with matplotlib python -m scripts.plot_opm_simulation diagnostics/meridian_sphere/scan_0.2um +uv run python scripts/deconvolve.py "/path/to/acquisition.ome.zarr" --output "/path/to/processed" ``` -| Workflow | Description | -| --- | --- | -| [Meridian sphere](../meridian_sphere_simulation.md) | Known fluorescent object, optical forward model, and camera sampling | -| [Publication figure](../opm_publication_figure.md) | Acquisition and reconstruction panels at multiple scan spacings | -| [Fixed-plane series](../fixed_plane_sampling_series.md) | Object passage through a stationary oblique plane | +Its Python call is: -Each workflow page records the physical assumptions and commands used for its -figures. Experimental outcomes apply to those specified models and sampling; -they do not establish performance for every specimen or optical mismatch. +```python +from pathlib import Path +from opm_processing.process import process + +process( + root_path=Path("/path/to/acquisition.ome.zarr"), + deconvolve=True, + save_float32=True, + output=Path("/path/to/processed"), +) +``` + +The pipeline selects each channel, timepoint and position using the stored axes. +Camera calibration and scan geometry come from the OME-Zarr; missing read-noise +calibration selects the no-read-noise model. Theoretical PSFs require recorded +optical calibration and emission wavelengths. Measured PSFs can be supplied +instead with `decon_psf_paths=[Path(...), ...]`, in channel order. + +See [deconvolution parameters and metadata](../methods/deconvolution.md) for the +array API, units, required optical fields, illumination handling and FFT tiling. diff --git a/docs/fixed_plane_sampling_series.md b/docs/fixed_plane_sampling_series.md deleted file mode 100644 index 2c33ca0..0000000 --- a/docs/fixed_plane_sampling_series.md +++ /dev/null @@ -1,124 +0,0 @@ -# Fixed-plane acquisition and reconstruction at four scan spacings - -The figures are saved in `diagnostics/fixed_plane_sampling_series` as PDF, SVG -and 600-dpi PNG, with frame arrays in NPZ files and acquisition/reconstruction -provenance in `manifest.json`. - -## Acquisition figures - -`acquisition_0.4um`, `acquisition_0.8um`, `acquisition_1.2um`, and -`acquisition_1.6um` each show seven acquired frames across the sphere's passage -through a stationary oblique plane. Columns select the nearest acquired frame -to scan positions -10.4, -8, -4, 0, 4, 8, and 10.4 µm, with actual object displacements -explicitly labeled. Thus these widely separated snapshots are not consecutive -camera frames, and the selected positions vary slightly with acquisition step. -The camera display spans -8 to +8 µm in columns and -16 to +16 µm in rows; -the small margin outside the saved detector support is displayed black. - -The top row illustrates the sphere's lateral translation and the fixed blue -detection plane in laboratory YZ coordinates. The middle row independently -predicts each frame by translating the Cartesian blurred object past that -plane and integrating camera pixels. The bottom row shows the existing noisy -camera acquisition. A scan position s corresponds to object displacement -s -in the fixed-objective view. Camera images use a shared linear display range -of 0–0.5 fluorescence-density units; larger values display white. - -`consecutive_acquisitions` compares all four acquisitions at the same nine -consecutive 0.4 µm positions (-1.6 through +1.6 µm). Missing observations are -shown as exact zeros, without text overlays on the images. These zeros are display -placeholders and are not treated as measured zeros by deconvolution. - -## Reconstruction figures - -`reconstruction_0.4_to_0.4um`, `reconstruction_0.8_to_0.4um`, -`reconstruction_1.2_to_0.4um`, and `reconstruction_1.6_to_0.4um` use the same -seven physical scan positions (-10.4, -8, -4, 0, 4, 8, and 10.4 µm) for all acquisitions. -The four rows are: - -1. Sphere displacement relative to the stationary detector plane. -2. The GC-deconvolved Cartesian microscope reference, sampled onto that plane. -3. OPM GC reconstruction in instrument coordinates, labeled according to - whether a camera frame was acquired at that scan position. -4. The final orthogonally deskewed OPM reconstruction, re-sliced onto the same - oblique plane to permit a direct frame comparison. - -Rows 2 and 4 are trilinear oblique slices of Cartesian volumes, not raw camera -measurements or Cartesian projections. No extra optical convolution, camera -integration, or noise is added to these reconstructed object estimates. The -final re-slicing is a visualization operation; it does not change the saved -reconstruction. All reconstruction images share a 0–1 linear display range; -larger values display white. Native GC is used for 0.4 µm, and missing-plane -GC uses factors 2, 3, and 4 for the other acquisitions. Every reconstruction -has a 0.4 µm scan step before unchanged orthogonal deskew. - -`consecutive_reconstructions` shows the corresponding instrument-coordinate -GC estimates at the same nine positions as `consecutive_acquisitions`. -"Measured" identifies a location with an acquired camera frame, while -"inferred" identifies a location without one; all displayed values are -deconvolved estimates, including those at measured locations. - -## Simulation conditions and limits - -The stationary ground truth has 12 meridian arcs on a 10 µm centerline-diameter -sphere, 1 µm tube diameter and 50 nm Cartesian sampling. The model uses a -30° oblique plane, NA 1.35, wavelength 637 nm, 115 nm camera pixels and 3 × 3 -pixel-area quadrature. Camera noise is Poisson plus 1.5-electron RMS read -noise, with 2 background electrons per pixel and a calibration of 500 electrons -at the fine Cartesian blurred-object peak. Photon calibration per pixel is -fixed across acquisition spacings; total photons per volume are not fixed. -Camera seed 42 and Cartesian-reference seed 43 are reused, but differing -array shapes give different realizations. - -The first three acquisitions cover scan centers from -21.6 to +21.6 µm; -1.6 µm acquisition rounds the padded bounds outward to -22.4 to +22.4 µm. -This preserves a centered object and complete optical support, yielding -29 measured and 113 reconstructed planes for 1.6 µm. The other acquisitions -have 109, 55 and 37 measured planes and 109 reconstructed planes. The shifted -output-grid phase means the 1.6 µm Cartesian reference is sampled on its own -recorded Cartesian axes; frame comparisons use physical coordinates for -every dataset, without registration or a fitted intensity normalization. - -Input electron-data hashes are checked against the hashes recorded by each -reconstruction. All reconstructed arrays must be finite, selected planes must -land on the recorded 0.4 µm reconstruction grid, and physical settings must -agree across cases. The simulations share a spatially invariant optical PSF -assumption and do not establish the cause of artifacts in moving live data. -No production PSF, GC or orthogonal-deskew code is changed for these figures. - -## Acquisition-to-reconstruction Cartesian maximum projections - -`acquisition_reconstruction_mips` is a separate figure with one row per -acquisition spacing and paired acquisition/reconstruction columns for XY, -XZ and YZ. These are true Cartesian maximum-intensity projections through a -common object-containing box, with voxel centers restricted to ±7.8 µm in -each Cartesian direction, shown within ±8 µm axes. They are not individual -frames or projections over raw array indices. - -Each acquisition is first placed on a 0.4 µm scan grid, with exact zeros in -the unmeasured planes. For display only, its raw cells are assigned to 50 nm -Cartesian grid points by nearest neighbor using the inverse physical map -row = Z/sin(theta), scan = Y - Z*cot(theta), column = X. This coordinate -conversion does not interpolate fluorescence into missing planes, estimate -missing data, or call production deskew. The fine Cartesian display grid -does not imply finer acquired resolution. Maximum projections are then taken -along physical X, Y or Z. Because the missing planes are oblique and parallel -to X, their gaps remain diagonal in YZ; projection along Y or Z can cover -gaps with signal from other planes. - -The reconstruction panels use the saved final orthogonally deskewed GC -volumes at their recorded Cartesian sampling, cropped to the same physical -box. All panels use a common linear display range 0–1; values above 1 appear -white and remain unchanged in exported source data. The acquisition panels -retain optical blur and noise; reconstruction panels show deconvolved object -estimates. The input hashes, measured/zero-plane counts, physical display -grids and projection arrays are saved alongside the figure. - -```powershell -uv run --no-sync --with matplotlib python -m scripts.plot_acquisition_reconstruction_projections --acquisitions diagnostics/meridian_sphere_camera_0p4/scan_0.4um diagnostics/meridian_sphere_camera/scan_0.8um diagnostics/meridian_sphere_camera_1p2/scan_1.2um diagnostics/meridian_sphere_camera_1p6/scan_1.6um --output diagnostics/fixed_plane_sampling_series -``` - -## Reproduce the figures - -```powershell -uv run --no-sync --with matplotlib python -m scripts.publication_fixed_plane_series --acquisitions diagnostics/meridian_sphere_camera_0p4/scan_0.4um diagnostics/meridian_sphere_camera/scan_0.8um diagnostics/meridian_sphere_camera_1p2/scan_1.2um diagnostics/meridian_sphere_camera_1p6/scan_1.6um --output diagnostics/fixed_plane_sampling_series -``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 27e3630..bb4b4d3 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -2,6 +2,16 @@ Run these commands from a checkout of this repository. +The base distribution is the public yaozarrs/TensorStore IO and metadata SDK. +It does not require PyTorch, CuPy, napari or deconvolution packages. The +`processing` extra retains the full processing/visualization dependency stack; +the `gpu` extra includes processing and CUDA dependencies. The default checkout +groups select processing and, on Windows/Linux, GPU support as before. + +For IO only, use `uv sync --no-default-groups` in this checkout or add the base +`opm-processing-v2` dependency to a consumer project. Import the public reader +with `from opm_processing.dataio import read_processed_snapshot`. + Python 3.12 and [`uv`](https://docs.astral.sh/uv/) are required. ```bash diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 88fabfc..c376f2b 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -35,10 +35,10 @@ projection. See [fusion](../guides/fusion.md) for registration and depth gains. ## View or export -View the registered projection without creating an ROI: +Select multiple ROIs in napari and reconstruct them from raw data: ```bash -uv run display "/path/to/acquisition" --no-roi +uv run export-rois "/path/to/acquisition" --deconvolve ``` Export the highest-resolution fused volume and maximum-Z projection: diff --git a/docs/guides/exports.md b/docs/guides/exports.md index 9b781a0..776e9a3 100644 --- a/docs/guides/exports.md +++ b/docs/guides/exports.md @@ -22,6 +22,21 @@ acquisition and journal accessible to retain their metadata. `--metadata-only` repairs the XML encoding of existing exports without rewriting or recompressing pixels. See [export-ome-tiff](../reference/cli/export-ome-tiff.md). +## Reconstructed ROI exports + +```bash +uv run export-rois "/path/to/processed" --deconvolve --tiff +``` + +Draw multiple XY rectangles on an existing deskewed maximum-Z preview in +napari. Closing the viewer reconstructs their raw regions, retaining full Z, +all channels, and all timepoints. Multi-position ROIs are locally registered +and fused. `--deconvolve` enables RLGC; `--tiff` adds compressed OME-BigTIFF +alongside the final OME-Zarr. Outputs go to numbered subdirectories under +`roi_exports` and retain global coordinates. +Follow the [ROI export guide](roi.md) +or see the [command reference](../reference/cli/export-rois.md). + ## Annotated projection TIFFs ```bash @@ -74,10 +89,3 @@ Hardware encoding requires an NVENC-capable NVIDIA GPU and compatible driver. MP4s are lossy presentation copies. See [export-projections](../reference/cli/export-projections.md) and [encode-projections](../reference/cli/encode-projections.md) for options. - -## Raw timelapses - -[convert-timelapse](../reference/cli/convert-timelapse.md) exports the time series -at selected raw positions and scan planes. Raw timelapse TIFFs retain camera -counts; optional time-mean projections apply camera calibration. This command -does not export a registered fused volume. diff --git a/docs/guides/fusion.md b/docs/guides/fusion.md index 7061fba..17cb086 100644 --- a/docs/guides/fusion.md +++ b/docs/guides/fusion.md @@ -20,6 +20,54 @@ tile shapes, spacing, and scan geometry determine initial placement and automatic search limits. Registration downsampling adapts to each overlap. The similarity threshold defaults to 0.7. +Empty overlaps and one-sided constant backgrounds are rejected before histogram +matching, which could otherwise turn a blank patch into a bright constant image. +An optional directional edge image with zero energy is skipped; a valid +intensity-based registration is retained. +If real-space disambiguation is degenerate, its unresolved periodic shift is +discarded. Registration can use an independently resolved intensity or edge +candidate; the pair is rejected when neither provides a valid measurement. +Other backend warnings remain visible. + +Depth-face registration retains extra Z context beyond the nominal overlap. +After measuring the lateral displacement, it searches the contextual samples +for Z alignment using correlation of axial intensity differences on common +geometric support, then checks the original-intensity match with SSIM. +Shared XY background can produce high SSIM at many Z shifts; it does not +establish an axial registration. Initial automatic depth limits allow twenty percent +of the tile depth and require at least three actually matched planes, rather +than being capped by the nominal overlap width. +Explicit correction limits still apply; measured peaks beyond them are +rejected, with tile indices and corrections reported in the warning. A weak +lateral match in the nominal overlap is checked again using the wider context. +Changes to this method invalidate saved pairwise registration so +the next fusion run recomputes it. + +If an out-of-range peak meets the similarity threshold, automatic registration +increases only that pair's offending axis limits to the absolute measured +correction plus a small margin: five percent, with a minimum of two pixels or +one effective registration sampling step. For example, a 15-pixel Z correction +measured at full Z sampling expands a 13-pixel limit to 17; a 284-pixel Y +correction at five-pixel sampling expands a 198-pixel limit to 299. +Unaffected pairs and axes keep their existing limits. Every retry **remeasures +only pairs whose limits increased**, then refits the global solution and +outlier rejection across all timepoints using the complete set of raw pairwise +measurements. Unchanged measurements are retained, including links previously +rejected by global optimization, so the updated graph can reconsider them. +Offsets are applied only after the final pass. A peak +measured on the old search boundary is provisional and is measured again in +the wider search. The default is two retries. SSIM thresholds, geometric +support checks, and global outlier rejection still apply. Low-similarity +rejected peaks alone do not trigger expansion. Disconnected groups without +out-of-range peaks do not trigger it. + +Use `--registration-retries 0` to disable automatic retries, or choose another +nonnegative retry count. Final measurements and effective limits are cached; +changing the retry count or initial limits invalidates the cache. Existing +registration caches from before this retry policy are recomputed once. +Changing shared registration settings, such as the channel or downsampling, +starts a fresh full pairwise registration pass. + Override the automatic correction limits when the acquisition needs a specific search envelope, using processed-image pixels in ZYX order: @@ -27,6 +75,9 @@ search envelope, using processed-image pixels in ZYX order: uv run fuse "/path/to/processed" --registration-channel 2 --max-registration-shift-zyx 20 250 100 ``` +An explicit `--max-registration-shift-zyx` is a hard limit and is never expanded +automatically, regardless of the retry count. + Inspect connectivity warnings before accepting a mosaic. They describe links retained after global optimization. Unconnected groups lack a measured relative alignment. Review input choice, overlap signal, and registration limits when diff --git a/docs/guides/live.md b/docs/guides/live.md index 20fd1df..2e0f734 100644 --- a/docs/guides/live.md +++ b/docs/guides/live.md @@ -7,23 +7,50 @@ precomputed illumination OME-TIFF with CYX axes: uv run process "/path/to/acquisition-directory" --live "/path/to/illumination.ome.tif" --deconvolve ``` +## Wait for completion before processing + +When no illumination file is available, use `--live-wait` with +`--flatfield-correction`: + +```bash +uv run process "/path/to/acquisition-directory" --live-wait --flatfield-correction --deconvolve +``` + +The command waits for a successful `completed` lifecycle event and every +expected raw timepoint/position tile. No illumination estimation or image +processing starts while acquisition is running. After completion, it refreshes +the metadata and runs normal offline processing, estimating the shared +per-channel flatfield from the acquired data. An existing current flatfield +is reused through the usual lookup beside the outputs or source acquisition. + +`--live-wait` takes no filename and cannot be combined with `--live PATH`. +Pass the acquisition's containing directory. Without `--flatfield-correction`, +it waits and then processes without illumination correction. Offline options +such as `--time-range`, `--pos-range`, `--output`, and `--resume` remain +available. A canceled or errored experiment stops the command before processing +begins. Restart the command after an interruption to wait again; use `--resume` +to continue compatible processing checkpoints once acquisition has completed. + ## Required acquisition files The acquisition controller writes one `*.manifest.json`, the OME-Zarr hierarchy, and the corresponding `*.log.jsonl` lifecycle log in the directory. The manifest identifies the store, acquisition identity, expected dimensions, and calibration. -Processing waits for the manifest and required metadata, then checks raw chunk -keys for complete timepoint/position tiles. +Both live modes wait for the manifest and required metadata, then check raw +chunk keys for complete timepoint/position tiles. Polling occurs every 30 +seconds. Single-position root images and multi-position collections are +recognized. Live processing supports mirror and stage scans with the supported unsharded Zarr v3 chunk layout. The supplied illumination is used directly; it is never -estimated during the run. `--live` cannot be combined with +estimated during streaming. `--live PATH` cannot be combined with `--flatfield-correction`, time ranges, or position ranges. ## Completion and interruption -Complete tiles are processed and checkpointed as they become available. A -completed lifecycle event ends processing once all expected tiles are complete. +With `--live PATH`, complete tiles are processed and checkpointed as they +become available. A completed lifecycle event ends processing once all +expected tiles are complete. Canceled or errored acquisitions terminate with the available completion count. Existing compatible live checkpoints are reused on restart. diff --git a/docs/guides/processing.md b/docs/guides/processing.md index 80e57b8..8e1800f 100644 --- a/docs/guides/processing.md +++ b/docs/guides/processing.md @@ -12,14 +12,59 @@ uv run process "/path/to/acquisition" --deconvolve --flatfield-correction --outp Camera calibration runs before optional illumination correction and deconvolution. `--flatfield-correction` estimates or reuses illumination fields. +For each channel, the correction is the pixelwise maximum of the estimated +stage-Z fields and is shared across all depths and timepoints. This prevents +sample-free camera regions at one depth from imposing a separate correction +on that depth. Depths excluded by signal selection do not contribute; channels +without selected signal use a unity field. Saved flatfields contain one field +per channel. Cached fields from the older per-depth estimator are re-estimated. + +To launch processing before acquisition finishes without a precomputed +illumination file, add `--live-wait` alongside `--flatfield-correction` and +pass the acquisition directory. The command waits for completion before +estimating illumination or processing images. See the +[live acquisition guide](live.md#wait-for-completion-before-processing). + `--deconvolve` uses GPU RLGC; channel-ordered PSFs can be supplied with repeated `--decon-psf-paths` options. Otherwise theoretical PSFs are generated. +With deconvolution, camera-calibrated detector estimates retain their precision +during splitting, and flatfield and detector-gain responses enter the solver's +measurement model. Without deconvolution, correction still divides the +calibrated floating-point image by these responses before deskewing. The default saved pixels are clipped uint16 values. `--save-float32` retains fractional calibrated intensities. `--z-downsample-level` controls the integer reduction of deskewed Z, defaulting to 2. Calibration and interpolation conventions are described in [methods](../methods/deskew.md). +## Physical time axis + +Processing reads the actual hardware camera exposure from per-frame +`exposure_time` (seconds), or legacy `exposure_ms` (milliseconds), and rounds +each exposure to two decimal places in milliseconds. For this acquisition +system, the rounded exposure defines the hardware frame period. The volume +interval is the original acquired scan-plane count multiplied by the sum of +these periods across stored camera channels. Simultaneous wavelengths stored +in one image count as one camera channel. Discarded acquired planes are included; +deskewed Z size does not affect timing. Software timestamps (`delta_t`, +`runner_time_ms`, and `ElapsedTime-ms`) and file-writing delays are ignored. +The resulting OME-NGFF T scale has unit `second`. +The same scale is preserved in projections, mosaics, and every pyramid level. +Position collections also record the OME-XML time increment in seconds. + +For legacy acquisitions without recorded hardware exposures, supply the confirmed +period explicitly: + +```bash +uv run --locked process "/path/to/raw.ome.zarr" --plane-period-ms 5 --output "/path/to/processed" +``` + +For 17 acquired planes at 5.000 ms per plane, the output T scale is `0.085` +seconds per volume. The processed store records the acquired count, hardware +period, volume interval, and confirmation source under `opm_processing.timing`. +A supplied period must agree with any recorded hardware period. Unknown timing +remains a unitless index axis. Resume rejects a conflicting existing time scale. + ## Select or skip data `--time-range START STOP` and `--pos-range START STOP` select zero-based, @@ -35,9 +80,26 @@ uv run process "/path/to/acquisition" --deconvolve --output "/path/to/processed" ``` Resume uses the processing journal beside the outputs and requires compatible -settings. Durably completed tiles are reused. Without `--resume`, the selected -output run is overwritten. Keep the journal with its image stores; see the -[data reference](../reference/data.md). +settings. Durably completed tiles are reused. If you omit `--resume` and a +partial output is found, `process` offers **Resume / Overwrite / Cancel** before +starting processing or changing the outputs. Enter `resume` (or `r`) to keep +completed tiles, `overwrite` (or `o`) to restart the selected output run, or +`cancel` (or `c`) to stop. Cancel is the default: pressing Enter or closing the +input stream stops the command. Choices are case-insensitive. Pass `--resume` +explicitly for unattended restarts. + +The perpendicular-distance orthogonal interpolation update changes the saved +pixels. Runs produced with the earlier equal-plane weights must be regenerated; +their interpolation settings are incompatible with `--resume`. Use the existing +overwrite choice to restart a partial run with the new interpolation. + +The check uses the previous run's selected timepoints and positions, its +completion journal, and the presence of position metadata. It also prompts +when an existing output has no readable completion journal. Confirming resume +still requires compatible settings and readable image metadata; it does not +repair damaged output. Completed output runs retain the existing overwrite +behavior when `--resume` is omitted. Keep the journal with its image stores; +see the [data reference](../reference/data.md). ## Produce a registered mosaic @@ -48,4 +110,4 @@ to subsequent commands. The [process reference](../reference/cli/process.md) lists options. Experimental scan upsampling and its limitations are described in the -[deconvolution methods](../methods/deconvolution.md#sub-sampled-acquisitions). +[deconvolution methods](../methods/deconvolution.md#sparse-scans). diff --git a/docs/guides/roi.md b/docs/guides/roi.md index eff0403..bde7398 100644 --- a/docs/guides/roi.md +++ b/docs/guides/roi.md @@ -1,44 +1,75 @@ -# Selecting and processing an ROI +# Interactive ROI processing and export -First create a registered maximum-Z projection with [fuse](fusion.md). ROI -selection uses its calibrated world coordinates. +`export-rois` opens an existing deskewed maximum-Z preview in napari. Draw +multiple XY rectangles and close napari to reconstruct the corresponding raw +regions. Each ROI retains full Z, every channel, and every timepoint. -## Draw a rectangle +## Prepare a preview + +Create the preview first; the ROI command does not create one automatically: ```bash -uv run display "/path/to/processed" +uv run process "/path/to/acquisition" --no-deconvolve ``` -Draw one rectangle in the napari `processing ROI` layer and close the viewer -to save it. The default destination is `_roi.json` beside the data. -`--roi-output PATH` selects another destination. ROI mode requires -`fused-max-z`; use `--no-roi` for other display modes. +For multiple positions, processing creates a stage-placed maximum-Z mosaic. +If you have already run `fuse`, its registered maximum-Z projection is used +instead. A single-position timelapse can use its per-position maximum-Z store; +napari retains the time slider. + +Keep the raw acquisition, preview source collection, and processing journal accessible. The preview, +recorded camera calibration, scan geometry, and tile placements determine +which raw data correspond to each rectangle. -## Process the selected raw region +## Select and process ```bash -uv run process-ROI "/path/to/processed" +uv run export-rois "/path/to/processed" ``` -The command resolves the raw acquisition through the processing journal, -reads the saved ROI, processes its raw-data regions, and fuses the cropped -tiles. Keep the source acquisition accessible. Deconvolution and resume are -enabled by default; output goes to `_roi` in the input directory. - -Supply the raw acquisition, ROI, and destination explicitly when needed: +You can also pass the raw acquisition store or the exact preview path: ```bash -uv run process-ROI "/path/to/raw.ome.zarr" "/path/to/selection_roi.json" --output "/path/to/roi-output" +uv run export-rois "/path/to/sample_max_z_fused.ome.zarr" --deconvolve --registration-channel 1 +uv run export-rois "/path/to/sample_max_z_deskewed.ome.zarr" --deconvolve --tiff ``` -## Resume or restart +1. Inspect channels and, for timelapses, move the time slider. +2. In the `export ROIs` Shapes layer, draw any number of axis-aligned rectangles. + Move, resize, or delete rectangles using napari's Shapes tools. +3. Close napari. Selections are saved before processing starts. + +Selections refer to the full-resolution grid regardless of the displayed +pyramid level. Every rectangle applies to all timepoints; the displayed +channel and timepoint do not restrict the output. Closing without rectangles +creates no exports. + +Raw data are camera-calibrated, optionally deconvolved with RLGC, and +orthogonally deskewed. Multi-position ROIs retain overlap for a fresh local +registration and fusion, including when the preview was already registered. +The final image is cropped to the requested physical XY bounds. Single-position +ROIs are assembled across time without measuring pairwise registration links. +`--flatfield-correction` enables the same illumination handling as `process`. +Deconvolution is disabled unless `--deconvolve` is supplied. + +## Outputs and resume + +The default destination is `roi_exports` beside the preview. Use `--output` +to choose another directory. Each `roi_001`, `roi_002`, ... subdirectory contains +its saved ROI, processing checkpoints, processed tiles, final +`_fused.ome.zarr`, and maximum-Z projection. The final OME-Zarr uses NGFF +v0.5 and retains global spatial coordinates and recorded temporal calibration. +The default Z binning factor is one; `--z-downsample-level` changes it explicitly. + +`--tiff` additionally writes `_roi_001.ome.tiff` in each ROI directory. +These are lossless zlib-compressed OME-BigTIFF files with global origins, +per-plane positions, channel metadata, and processing provenance. OME-Zarr +outputs are retained. -Rerun the same command after interruption. Compatible completed tile/channel -checkpoints are reused; unfinished writes are processed again. Use -`--no-resume` to overwrite the ROI output. `--crop-after-deskew` is inapplicable -because this workflow already writes the selected physical crop. +Rerun the same command to reuse saved selections and compatible completed +checkpoints without reopening napari. Local registration checkpoints belong to +the ROI run; preview registration is never used as the final ROI solution. +Use `--no-resume` to draw new rectangles and restart processing. An incompatible +configuration is rejected during resume. -To restrict fusion of existing processed tiles without reprocessing the raw -data, use `fuse --roi PATH`. See the -[display](../reference/cli/display.md) and -[process-ROI](../reference/cli/process-roi.md) references. +See the [command reference](../reference/cli/export-rois.md) for options. diff --git a/docs/guides/viewing.md b/docs/guides/viewing.md index 4a1061e..fc668e0 100644 --- a/docs/guides/viewing.md +++ b/docs/guides/viewing.md @@ -2,15 +2,15 @@ ## napari +Use `export-rois` to select rectangles on a deskewed maximum-Z preview and +process their raw regions after closing napari: + ```bash -uv run display "/path/to/processed" --to-display fused-full --no-roi +uv run export-rois "/path/to/processed" --deconvolve ``` -Choose `full`, `max-z`, `fused-full`, or `fused-max-z`. Full and per-tile -maximum-Z modes display processed position collections. Fused modes display -the registered output. `--time-range` and `--pos-range` select visible ranges. -ROI drawing is enabled by default and requires the registered fused maximum-Z -image; see [ROI selection](roi.md). +For viewing alone, open OME-Zarr outputs through napari's installed OME-Zarr +reader. See [ROI processing](roi.md) for preview preparation and export options. ## Fiji @@ -47,6 +47,6 @@ viewer workflow from a standard ImageJ hyperstack. | Disconnected registration groups | Check overlap signal, channel selection, and correction limits. | | Visible depth seams | Review registered placement, depth gains, and geometric support. | | Resume rejects the run | Use the original settings and matching journal, or explicitly restart. | -| ROI cannot open | Run `fuse` to create the registered maximum-Z image first. | +| ROI cannot open | Create a maximum-Z preview with `process` and retain its raw source and journal. | | CUDA registration unavailable | Check GPU installation; `--require-gpu` reports failure instead of falling back. | | Movie encoding fails | Check NVENC support and the installed NVIDIA driver. | diff --git a/docs/index.md b/docs/index.md index 5378f6b..6b690ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,6 +18,7 @@ RLGC deconvolution, deskewing, tile registration, fusion, and image export. | Process while acquisition is running | [Live acquisition](guides/live.md) | | Register and combine stage tiles | [Registration and fusion](guides/fusion.md) | | Select a smaller raw-data region | [ROI guide](guides/roi.md) | +| Reconstruct and export multiple ROIs | [ROI export guide](guides/roi.md) | | Save OME-TIFFs, projections, or movies | [Export guide](guides/exports.md) | | Open outputs in napari or Fiji | [Viewing and troubleshooting](guides/viewing.md) | | Understand axes, output files, and metadata | [Data reference](reference/data.md) | diff --git a/docs/meridian_sphere_simulation.md b/docs/meridian_sphere_simulation.md deleted file mode 100644 index f560d4a..0000000 --- a/docs/meridian_sphere_simulation.md +++ /dev/null @@ -1,204 +0,0 @@ -# Meridian-sphere OPM simulation - -This is a separate, stationary-object forward simulation. Production orthogonal -deskew and PSF generation are unchanged. The forward check below does not use -deconvolution; the subsequent GC comparison uses the saved noisy camera data. - -## Object and optics - -The ground truth is a hollow sphere with **10 um meridian-centerline diameter** -and **1 um diameter fluorescent tubes**. Its outer diameter is therefore 11 um. -Twelve equally spaced pole-to-pole meridians form six full great circles through -the Z axis. Tubes join at the poles; overlapping fluorescence is a union, so -the poles are not artificially brighter because multiple tubes overlap. The -interior and gaps between meridians contain no fluorophores. - -The object is defined first on an isotropic **50 nm Cartesian grid**, finer than -the 115 nm camera pitch and its 57.5 nm projected Z spacing at 30 degrees. -The optical PSF is the vectorial 100x silicone model, with configurable NA and -wavelength (defaults NA 1.35, 637 nm; immersion RI 1.4, sample RI 1.38). Optical -support is +/-2.4 um in Z and +/-1.2 um in XY. Both simulation routes use this -same finite-support PSF. There is no empirical taper. - -## Independent forward routes - -**OPM route:** interpolate the fine Cartesian object and PSF into instrument -coordinates. The fine instrument grid has 0.1 um scan spacing and 57.5 nm -camera-row/column spacing. Convolve the object with the normalized skewed PSF -on that fine grid. Only then select the requested acquired scan planes and -sample/average camera pixels. The transformations are - -``` -X = camera_column * pixel_pitch -Y = scan_index * scan_step + camera_row * pixel_pitch * cos(angle) -Z = camera_row * pixel_pitch * sin(angle) -``` - -All grids have explicit centered coordinates in microns. Optical halos and -the complete object are retained before convolution, which uses zero exterior -fluorescence rather than FFT wraparound. - -**Cartesian microscope route:** independently convolve the ground truth and -Cartesian optical PSF on the fine Cartesian grid, then digitally sample it. -For the comparison, evaluate it at the physical coordinates of the deskewed -output. No registration, fitted gain, or alignment optimization is applied. - -There is also a direct check *before deskew*: sample the Cartesian convolution -on the raw OPM grid and compare it with the independently computed skewed -convolution. This distinguishes forward-model sampling error from later -deskew interpolation differences. - -Production deskew applies a constant-field gain of `2*pixel/scan_step`. Since -the simulator's clean arrays represent fluorescence density, multiply its -output by the reciprocal of that known factor for the comparison. This does -not change production deskew or fit image brightness. Deskew Z downsampling is -disabled for these comparisons; output voxels are 115 nm isotropic. - -## Camera sampling and noise - -Start with `--camera-samples 1`, which samples camera-pixel centers without -noise. After geometry validation, `--camera-samples 3` averages a 3x3 set of -uniform subpixel centers within each detector pixel. This approximates binning -the finer irradiance field over the camera pixel area. The OPM detector plane -is tilted; the Cartesian microscope detector averages in laboratory XY. Their -pixel-area responses need not be identical. - -Convert the averaged intensity to expected detected photoelectrons using -`--peak-electrons`, add `--background-electrons`, draw Poisson counts, and then -add Gaussian read noise with `--read-noise` in electrons RMS. Noise is applied -once per camera pixel after averaging, not once per interpolation subpixel. -Both microscopes use the same exposure gain and independent noise draws. The -gain is based on the peak of the fine Cartesian optical image, not fitted to -the sampled OPM image. Background-subtracted density is clipped at zero before -deskew, introducing a small positive background bias; original electron-valued -raw data are also saved. ADC quantization, saturation, motion, and rolling -shutter timing are not included. - -## Reproduce - -Run from the repository root: - -```powershell -uv run python -m scripts.opm_simulation --output diagnostics/meridian_sphere --scan-steps 0.2 0.8 -``` - -Then add camera integration and noise: - -```powershell -uv run python -m scripts.opm_simulation --output diagnostics/meridian_sphere_camera --scan-steps 0.2 0.8 --camera-samples 3 --peak-electrons 500 --background-electrons 2 --read-noise 1.5 -``` - -Geometry and optics can be changed with `--diameter`, `--tube-diameter`, -`--meridians`, `--angle`, `--pixel-size`, `--na`, `--wavelength`, -`--fine-spacing`, and `--fine-scan-step`. Wavelength and all spatial inputs are -in microns. `--seed` controls camera noise. Finer grids cost more memory/time. - -Each run exports fine ground truth and both PSFs. Each acquisition directory -contains raw skewed data, deskewed data, the Cartesian microscope reference, -the noise-free reference, and sampled truth. Cartesian comparison volumes -are OME-TIFFs with physical voxel sizes, as are the fine Cartesian truth and PSF. -Raw TIFF Z denotes scan index, not -Cartesian Z. `simulation.json` records the actual axes, physical origins, -parameters, and quantitative comparisons. `comparison.png` shows maximum -projections, `sections.png` shows the central planes through the hollow sphere, -and `raw_instrument.png` shows the oblique data before deskewing. - -Render the saved volumes without rerunning optics: - -```powershell -uv run --with matplotlib python -m scripts.plot_opm_simulation diagnostics/meridian_sphere/scan_0.2um -``` - -## Results and checks - -For the noiseless default object, the independent forward routes agree to -1.96% relative L2 error and 0.999913 correlation. After unchanged orthogonal -deskew: - -| Acquired scan step | Error vs Cartesian microscope | Correlation | Total signal ratio | -| --- | ---: | ---: | ---: | -| 0.2 um | 2.81% | 0.99944 | 1.00037 | -| 0.8 um | 8.84% | 0.99222 | 1.00028 | - -With 3x3 pixel integration, 500 peak electrons, 2 background electrons and -1.5-electron RMS read noise, errors against the noise-free Cartesian reference -are 4.88% and 9.66%, respectively. Comparisons against an independently noisy -Cartesian image have larger errors (8.47% and 11.87%). - -Errors/correlations use voxels above 1% of the reference peak, excluding the -large empty background; total-signal ratios use the complete compared volume. -Projection figures use shared intensity scales and include the projection of -the absolute volumetric error, rather than differences between independent -maximum projections. - -The original 23 numerical cases covered exact tube geometry and empty interior, -pixel-area quadrature, Poisson/read-noise moments, the two convolution routes, -physical sphere centering, deskew/reference agreement at two scan steps, and -fine-grid PSF sampling-convergence checks. Current in-memory simulation checks -are unit tests. Integration tests must exercise simulated data from disk input -to verified disk output, as described in the [testing guide](development/testing.md). -These checks validate the specified stationary forward model; they do not -explain motion or residual tilt in a live acquisition. - -## GC after the complete camera simulation - -The required order is **fine object -> optical convolution -> oblique camera -sampling and pixel integration -> photon/read noise -> GC -> orthogonal -deskew**. Noiseless geometry-check data are rejected by the GC comparison. -Earlier exploratory GC runs on those noiseless data were withdrawn and are -not evidence for changing production code. - -The comparison reads the existing `raw_electrons.tif`, subtracts the recorded -background, clips negative calibrated counts as in the simulation's camera -calibration, and runs the existing GC solver. It does not regenerate the image -or draw another noise realization. An SHA-256 of the input electron-data file -is saved with the results. Conversion back to density uses the original camera -gain, not a fitted normalization. For older exports without that gain recorded, -its exact definition is recalculated from the saved fine truth and PSF and -checked against the saved calibrated camera image. New exports record it. - -The deconvolution PSF is sampled from the same fine Cartesian optical PSF at -the reconstruction spacing and includes camera-pixel integration. Native GC -uses the acquired scan step. Optional missing-plane GC uses the finer requested -step, preserves the original endpoints, and passes that step to unchanged -orthogonal deskew. The Cartesian microscope reference is also deconvolved with -its corresponding pixel-integrated PSF. - -```powershell -uv run python -m scripts.deconvolve_opm_simulation diagnostics/meridian_sphere_camera/scan_0.2um -uv run python -m scripts.deconvolve_opm_simulation diagnostics/meridian_sphere_camera/scan_0.8um -uv run python -m scripts.deconvolve_opm_simulation diagnostics/meridian_sphere_camera/scan_0.8um --scan-upsample 2 -``` - -Outputs are in `decon_native` or `decon_upsample2` subdirectories and can be -rendered with the same plotting module. Unlike the optical-image comparisons -above, the following relative L2 errors include **all voxels** and compare with -the **unblurred ground truth**, including false fluorescence outside the tubes: - -| Acquisition / reconstruction | Before deconvolution | After deconvolution | -| --- | ---: | ---: | -| OPM 0.2 / 0.2 um | 72.20% | 38.68% | -| OPM 0.8 / 0.8 um | 73.44% | 42.70% | -| OPM 0.8 / 0.4 um | 73.44% | 39.31% | -| OPM 1.2 / 0.4 um | 74.78% | 38.54% | -| Cartesian microscope | 72.60% | 39.35% | - -The two 0.8 um runs use exactly the same input electron data. These are noisy, -finite-resolution reconstructions, not exact recovery of the binary tube -boundaries. No production deconvolution, PSF-generation or deskew changes were -needed for these comparisons. - -The 1.2 um acquisition uses the same optics, 3x3 camera integration, 500 peak -electrons, 2 background electrons, 1.5-electron read noise and seed 42. Its 37 -measured planes reconstruct to 109 planes at 0.4 um using factor 3. Reproduce it: - -```powershell -uv run python -m scripts.opm_simulation --output diagnostics/meridian_sphere_camera_1p2 --scan-steps 1.2 --camera-samples 3 --peak-electrons 500 --background-electrons 2 --read-noise 1.5 --seed 42 -uv run python -m scripts.deconvolve_opm_simulation diagnostics/meridian_sphere_camera_1p2/scan_1.2um --scan-upsample 3 -``` - -Against the deconvolved Cartesian reference, this reconstruction has 14.15% -relative error and 0.98699 correlation on the reference mask, compared with -11.07% and 0.99126 for the 0.8 / 0.4 um reconstruction. Its slightly lower error -against unblurred truth in this single noise realization does not establish an -advantage for coarser acquisition. diff --git a/docs/methods/deconvolution.md b/docs/methods/deconvolution.md index 00cdf34..d746db3 100644 --- a/docs/methods/deconvolution.md +++ b/docs/methods/deconvolution.md @@ -1,63 +1,180 @@ # PSFs and deconvolution -## Optical sampling - -Production PSF generation models the silicone-objective optics, then samples -the PSF in the same oblique coordinates as the acquisition. The emission -wavelength, camera pixel size, scan step, and angle determine the sampled kernel. -Supplied PSFs must match the reconstruction grid and channel order. Planar -processing uses the central plane of a 3D PSF. - -The solver normalizes the PSF to unit sum by default. Native OPM deconvolution -uses per-axis PSF halos, symmetric image padding, and FFT-friendly sizes with -small prime factors. Padded values participate in the loop; padding is removed -from the returned image. See the [PSF and solver reference](../reference/python/imageprocessing.md). - -## RLGC update - -RLGC repeatedly splits the measured counts into complementary halves. Whole -counts use a 50:50 binomial draw. Fractional calibrated counts use a weighted-count -approximation that assigns the remainder to either half with equal probability. -Their sum is always the measured image. - -Each half's observation/prediction ratio is backprojected. The update multiplier -is the mean of the two backprojected ratios. The product of their deviations -from unity is convolved with the forward/adjoint autocorrelation operator to -form the consensus map. The consensus sign controls which multiplicative -updates are applied. The core update and sign rule follow the designated local -`expansion-processing` reference. - -Initialization backprojects the measured image. Native stopping compares the -current and previous predictions against the same freshly drawn halves using -normalized observation-to-prediction KLD. In safe mode, worsening either half -restores the previous reconstruction. Other stopping criteria are the updated -pixel fraction, maximum relative change, and the iteration limit. - -KLD is a stopping statistic. Physical acceptance checks specimen recovery, -fluorescence, localization, and two-point profiles against known ground truth. -See [test requirements](../development/testing.md). - -The RLGC reference implementation is -`expansion-processing/src/expansion_processing/rlgc.py` at revision -`098b8155f27bfbebf84ebaefc0a1114a64f3c287`, with SHA-256 -`96d3f2ffef3b6f52cf46fcc2c9fe2ad70e29dddc8f60ed61066dfb96dd70a5ec`. -The core update is the reference requirement. Current initialization, fractional -sampling, stopping, and OPM padding adaptations are described above; the complete -loop is not claimed to be identical to that reference. - -## Sub-sampled acquisitions - -Experimental `--decon-scan-upsample N` reconstructs a finer scan grid using -convolution followed by selection of the acquired planes. The adjoint scatters -observations into that grid before backprojection; missing planes are not -zero-valued measurements. This operator has its own sensitivity correction and -finite-volume boundary condition. - -The experimental path requires a full GPU volume and currently supports neither -scan chunking nor ROI processing. For an acquired scan spacing of 0.8 micrometers, -`N=4` reconstructs at 0.2 micrometers; PSF generation and deskewing use that finer -spacing. An acquisition with M scan planes produces `(M - 1) * N + 1` -reconstruction planes, preserving the first and last acquired positions. -Supplied PSFs must be sampled at the finer spacing. Fully sampled and -sub-sampled data must be assessed against the same physical -object and resolution measurements. +RLGC combines Richardson–Lucy updates with a gradient-consensus gate to limit +noise amplification. It follows [Manton and collaborators' implementation](https://github.com/jdmanton/rlgc) +and the MATLAB algorithm accompanying +[Liu and Stallinga's work](https://doi.org/10.1364/BOE.597710), including its +`gc_deconv.m` read-noise correction. +The source header in `imageprocessing/rlgc.py` describes the optical and GPU adaptations. + +## Process an OME-Zarr + +```bash +uv run process "/path/to/acquisition.ome.zarr" --deconvolve --save-float32 +``` + +Processing extracts axes, camera pixel pitch, scan step, oblique angle, channels, +camera offset and intensity conversion from the acquisition. It calibrates raw +camera values, deconvolves in the oblique acquisition grid, then deskews and +rotates the result. `--save-float32` preserves fractional output values. +For 3D deconvolution, the recorded scan spacing automatically determines integer +refinement toward 0.4 micrometers: 0.8 micrometers uses factor 2 and 1.2 uses +factor 3. Acquisitions already at 0.4 micrometers or finer retain their recorded +spacing. Coarser steps must be integer multiples of 0.4 micrometers for the +acquired-plane solver. +Without deconvolution, processing uses the acquired scan spacing. +The [usage example](../examples/index.md) runs the same pipeline from Python. + +OME-NGFF does not specify the optical calibration needed to generate this PSF. +Record the following acquisition attributes under the root's +`opm_processing.deconvolution` object: + +| Field | Expected value | +| --- | --- | +| `read_noise` | Optional object with `rms` and `units`. Units are `ADU`, `electrons`, or `calibrated-detector-counts`. Missing calibration selects zero read noise. | +| `emission_wavelengths_nm` | One positive emission wavelength per channel, in storage order; required for theoretical PSFs. | +| `psf_parameters` | Optical and numerical PSF settings described below; required unless measured PSFs are supplied. | +| `detector_gain_x` | Optional measured positive response, one value per detector column. Applied only when recorded, independently of image width or acquisition mode. | + +Frame `Camera.read_noise_e` or `Camera.read_noise_adu` is also read when the root +has no read-noise calibration. Noise specified in ADU is multiplied by the recorded +camera intensity conversion. Offset subtraction does not change noise RMS. +Noise already in calibrated units is used directly, without applying the gain again. +No camera model or scan mode is inferred when calibration is missing. + +`psf_parameters.lens` contains `NA`, `ni0`, `ni`, `ns`, `ng0`, `ng`, `tg0`, `tg`, +and `ti0`: numerical aperture, design/actual immersion and specimen refractive +indices, design/actual coverslip refractive indices and thicknesses, and design +working distance. Lengths are in +micrometers. Supply `skewed_depth_um` and `projection_depth_um` as source depths; +`taper_sigma_px_3d` and `taper_sigma_px_2d` as Gaussian taper widths in pixels; +`support_fwhms`, `depth_width_scale`, and `grid_oversampling` as dimensionless +numerical controls; `depth_scale_um` in micrometers; and `projection_nz`, +`projection_nxy`, `projection_dz_um` as the planar PSF grid dimensions and axial +spacing. These values must describe the acquisition's optical calibration. + +The generated 3D PSF integrates over the tilted camera pixels using the recorded +camera pitch. `grid_oversampling` is a positive integer controlling optical-grid +resolution and midpoint integration samples along each pixel dimension. + +To use measured kernels instead, repeat `--decon-psf-paths` once per channel. +Files may be NumPy `.npy` or TIFF, in camera YX or scan-YX order, sampled on the +reconstruction grid. The library uses the OME-Zarr's camera and geometry metadata +for either PSF source. + +## Call the array solver + +```python +from opm_processing.dataio.deconvolution import read_noise_from_metadata +from opm_processing.imageprocessing.rlgc import rlgc + +result = rlgc( + image=calibrated_data, + psf=sampled_psf, + read_noise=read_noise_from_metadata(acquisition), +) +``` + +Here `acquisition` is returned by `inspect_acquisition`; `calibrated_data` and +`sampled_psf` are arrays prepared as follows: + +| Parameter | Expected value and default | +| --- | --- | +| `image` | Nonnegative calibrated floating-point YX or scan-YX data, after subtracting the recorded camera offset and multiplying by its recorded conversion. Exclude T, position and C axes; process each volume separately. | +| `psf` | Nonnegative finite YX or scan-YX kernel, centered and sampled on the reconstruction grid. Normalized to unit sum by default. A planar image uses the central plane of a 3D kernel. | +| `read_noise` | RMS in the same units as `image`; default `0` selects no read noise. | +| `detector_response` | Optional positive YX illumination/sensitivity response, broadcast over scan planes. Pass the image before dividing by this response; default `None`. | +| `gpu_id` | CUDA device index; default `0`. | +| `crop_scan` | FFT tile length in reconstruction scan planes; default `None` estimates it from free GPU memory. Tiling shares one reconstruction and stopping history. | +| `fallback_step_scan` | Tile reduction after allocation failure; default reduces the length by one quarter. | +| `rng_seed` | Seed for fresh half-image splits; default `42`. | +| `limit`, `max_delta` | Reference updated-fraction and relative-change thresholds; both default `0.001`. | +| `use_kl` | Reference two-half KL stopping; default `True`. | +| `scan_upsample_factor` | Integer scan-grid refinement; default `1`. For sparse acquisition use `boundary="zero"`. | +| `boundary` | Default `"reflect"` for native sampling; `"zero"` for the acquired-plane sparse operator. | +| `max_iterations` | Optional safety cap; default `None` for RLGC. Ordinary RL (`gradient_consensus=False`) requires a finite cap. | +| `storage` | `"gpu"` or `"host"` for reconstruction arrays; default selected from tile size. FFTs use CUDA in either case. | +| `callback` | Optional function receiving the iteration index and completed reconstruction. | + +The return value is a NumPy `float32` array with the input rank. With scan +refinement N, M acquired planes produce `(M-1)*N+1` output planes; YX dimensions +are retained. No camera calibration, deskew or file writing occurs inside `rlgc`. +See the [Python reference](../reference/python/imageprocessing.md) for all controls. + +## Photon gradient consensus + +Processing applies the recorded camera offset and conversion before deconvolution, +clipping negative calibrated estimates to zero and retaining floating-point precision. +RLGC initializes a constant estimate at the mean calibrated intensity. Each iteration +uses `do_binosplit.m`'s rounded counts: binomial sampling below 150 and a Gaussian +approximation at 150 or higher, with out-of-range Gaussian draws replaced by +half the rounded count. A uniform random fraction of the signed rounding +remainder is added to the first half. The second half is formed by subtraction. +For read-noise RMS σ in calibrated input units, split `J + σ²` instead of `J`, +subtract `σ²/2` from each half, then clip independently at zero, following +`gc_deconv.m`. Processing reads the camera RMS from the OME-Zarr calibration. +If read noise is absent, it uses zero and selects the no-read-noise model. + +The consensus map blurs `(u1-1)*(u2-1)`, where `u1` and `u2` are the two half-image +multiplicative updates. It has no intensity weighting. Negative consensus rejects +an update and zero permits it. The full RL update is computed directly from the +measured image, since independently clipped halves need not sum to that image. +The full ratio is `(J + σ²)/max(Ax + σ², 1e-12)`; each half ratio is +`(half + σ²)/(0.5*max(Ax + σ², 1e-12))`, exactly as in `gc_deconv.m`. + +KL stopping is enabled by default. Following `gc_deconv.m`, normalize the predicted +acquired pixels and each fresh half after adding its `1e-4` pseudocount. Stop when +both prediction-to-half KL scores exceed their preceding-iteration values, +keeping the current estimate as MATLAB does. This measurement-space convention +also applies to sparse scans; object voxels are not compared directly with +measured pixels. This selects MATLAB's `use_KL=1` branch. Its read-noise correction +is a shifted-count approximation. CuPy and MATLAB use different random generators, +so identical seed numbers do not imply identical random halves. + +The existing accepted-update fraction threshold of 0.001 and maximum absolute +change divided by `max(max(reconstruction), 1e-12)` threshold of 0.001 remain active. +Ordinary RL has no built-in stopping rule for noisy data. + +## PSFs, response and execution + +PSFs must match channel order and reconstruction sampling. Theoretical PSFs use +recorded optical calibration, emission wavelengths and oblique geometry. +Excitation labels such as `488nm` are not substituted for emission wavelengths. +Planar processing uses the central plane of a supplied 3D PSF. Kernels are +normalized to unit sum. Supplying measured `--decon-psf-paths` bypasses theoretical +generation. Missing required PSF calibration produces an error; the library does +not choose an objective, refractive index, wavelength or specimen depth. + +Flatfield and measured detector-X response enter the forward model +`A(x)=F*convolution(x)` and its sensitivity-normalized adjoint. They are not +divided into noisy measurements before deconvolution or applied again afterward. +This detector-response model approximates excitation flatfields, which physically +act before emission blur. Without deconvolution, calibration and flatfield division +retain their usual order. + +The single `rlgc` solver supports planar, native and undersampled reconstruction. +Native reconstruction uses symmetric PSF halos, efficient FFT dimensions and +cached workspaces. Scan chunks use overlap-save convolution within each global +iteration: initialization, random halves, consensus and stopping are shared +across the volume. Chunk boundaries are not independently reconstructed. +Allocation fallback can retain global state in host RAM while evaluating only +haloed FFT tiles on the GPU. Host RAM must accommodate the reconstruction state. +NumPy and CuPy random generators need not produce identical random halves. +Processing records the solver configuration; incompatible outputs cannot be +mixed by `--resume`. + +## Sparse scans + +With `scan_upsample_factor=N`, the array solver reconstructs a finer scan grid while +predicting only acquired planes. Its adjoint scatters observations onto that +grid; missing planes are not zero measurements. An acquisition with M planes +produces `(M-1)*N+1` reconstruction planes, preserving endpoints. + +The PSF and subsequent deskew use the finer spacing. This path uses the same +photon consensus and stopping rules, including the same FFT tiling. Both `process` +and `export-rois` infer this factor from acquisition metadata. ROI raw reads remain +on the acquired grid; deconvolution and subsequent deskew use the refined grid. +Both OPM paths follow `gc_deconv.m` for the noise shift. The MATLAB +`gc_deconv_up.m` file instead shifts the split by σ rather than σ²; that +inconsistent split formula is not used here. +See the [solver reference](../reference/python/imageprocessing.md) for Python parameters. diff --git a/docs/methods/deskew.md b/docs/methods/deskew.md index 32e2a20..f68c1ef 100644 --- a/docs/methods/deskew.md +++ b/docs/methods/deskew.md @@ -8,12 +8,21 @@ Camera calibration converts ADU to nonnegative float32 intensity: intensity = max((camera_counts - camera_offset) * camera_conversion, 0) ``` -The stage-scan path can additionally divide by the measured detector-X response. +A recorded detector-X response can additionally correct camera sensitivity. Cropping retains the original detector-column offset so that this correction uses the matching calibration columns. Optional illumination correction divides the calibrated data by a CYX illumination field. Empty-channel decisions occur after camera calibration and before illumination division. +Illumination fitting samples tiles separately at each stage-Z level. The final +field for each channel is the pixelwise maximum over those depth estimates, +shared by every stage position and timepoint. Each fitted depth field has unit +spatial mean; the maximum retains its resulting scale. Empty depth/channel groups are +excluded from the maximum; a channel with no selected signal uses unity. Fitting +uses distributed time-zero samples with recorded camera calibration. Saved fields +must be finite and positive; incompatible or invalid cached fields are estimated +again. + These calculations are separate from deconvolution and interpolation. See [camera_correct](../reference/python/imageprocessing.md#opm_processing.imageprocessing.camera.camera_correct) for parameter units and detector-gain behavior. @@ -30,7 +39,11 @@ Z = v * sin(theta) ``` The orthogonal interpolator projects each laboratory output point onto adjacent -camera planes and uses four source samples. X remains the detector-column axis. +camera planes and uses four source samples. Each perpendicular foot is +interpolated between two detector rows. If the output point has perpendicular +distances `d0` and `d1` from the two planes, those two interpolated values receive +weights `d1/(d0+d1)` and `d0/(d0+d1)`. The weights sum to one and reproduce a +linear laboratory intensity field. X remains the detector-column axis. Output shape, padding, origin, and scan orientation are determined from the acquisition geometry. See the [geometry diagram](../orthogonal_interpolation_diagram.md) for the interpolation construction. @@ -41,16 +54,38 @@ Before Z averaging, laboratory spacing is the camera pixel size along each spatial axis. `--z-downsample-level N` averages N adjacent laboratory Z planes, giving ZYX spacing `(N * pixel_size, pixel_size, pixel_size)`. -Interpolation includes the existing camera-pixel/scan-step intensity factor, -`pixel_size / scan_step`. This convention must be included when comparing -absolute intensities or integrated fluorescence with a simulated object. +The normalized interpolation is multiplied by the photon-volume Jacobian. At +the supported 30-degree angle this is `2 * pixel_size / scan_step`, because an +input voxel has volume `scan_step * pixel_size^2 * sin(30 degrees)` and an output +voxel has volume `pixel_size^3`. Perpendicular-distance weighting replaces the +former equal-plane average while preserving this gain for constant fluorescence. Unsupported regions and padding remain zero. A partly covered Z bin retains the -full averaging divisor; its geometric coverage is accounted for during fusion. +full averaging divisor; its geometric coverage is accounted for during fusion, +even when averaging leaves only one stored Z plane. Cropping retains its physical +Y offset, and output padding is applied after the crop. The default output casts clipped values to uint16. `--save-float32` preserves fractional intensity. Changing output dtype does not change the geometric model. +The interpolation revision is recorded in the processing configuration. Stores +made with earlier interpolation weights or truncated final Z bins require +reprocessing; `--resume` rejects incompatible configurations. + ## Execution CPU kernels parallelize independent detector or output rows. Coordinates retain float64 precision, with float32 interpolation weights and intensities. + +The array API requires explicit `theta`, `distance` and `pixel_size`; acquisition +processing extracts them from the OME-Zarr. Use `deskew_shape_estimator` with +`crop_after_deskew=False` and the same `downsample_factor` to allocate output for +`orthogonal_deskew`, including the final partial Z bin. + +For output tiling, pass `tile_shape=(z, y, x)` in averaged output voxels. Each tile +uses global coordinates and the same source quartets as whole-volume processing. +An optional writable float32 `out` array must have the estimated output shape; +NumPy views and memory-mapped arrays are supported. Supplied buffers are fully +written, including zero support and padding. A memory map can bound resident +output storage, while the full calibrated input volume remains available. +Z reversal changes the stored output orientation without restarting averaging at +tile boundaries. diff --git a/docs/methods/fusion.md b/docs/methods/fusion.md index 7dcec9a..31d0b67 100644 --- a/docs/methods/fusion.md +++ b/docs/methods/fusion.md @@ -4,14 +4,43 @@ Stage positions define nominal physical tile placement. Overlapping tile bounds, processed shapes, voxel spacing, scan angle, and effective registration sampling -define per-pair search envelopes. The automatic limits retain at least half -the overlap along active axes and account for oblique depth displacement. +define initial per-pair search envelopes. These account for oblique depth +displacement and retain at least half the nominal overlap outside the depth +search. Depth alignment uses wider context and requires at least three +actually matched planes. +Depth candidates are selected using normalized correlation of adjacent-Z +intensity differences, with support required on both adjacent planes. This +removes Z-invariant XY background from the depth evidence. Original-intensity +SSIM remains a separate acceptance check; high SSIM alone cannot resolve depth. Registration estimates image shifts and scores corresponding supported samples. +For supported overlaps it compares phase and directional-edge proposals with a +masked, nonperiodic normalized-correlation peak searched inside the physical +shift limits. The normalization uses only common acquisition support, so padded +crop edges do not provide registration evidence. Nominal stage placement is +also scored when a measurement is available. Every proposal is scored at the +full-resolution integer correction that fusion actually retains, including +when the registration images are downsampled; scoring a fractional correction +and rounding it afterward could otherwise select a different placement. Downsampling and the similarity window adapt to overlap size. Thin depth overlaps can use their common XY projection when a full 3D scoring window does not fit. Accepted links enter a global placement fit. +Out-of-range peaks meeting the similarity threshold trigger a bounded automatic +retry. Only the rejected pair's offending axis limits increase, to the absolute +measured correction plus five percent, with a minimum margin of two pixels or +one effective registration sampling bin. Unaffected limits stay unchanged. +Each retry remeasures only pairs with expanded limits and repeats the global +fit and outlier rejection across all timepoints from nominal stage placement. +Unchanged raw measurements are reused, including links previously rejected by +the global fit. Shared registration setting changes invalidate the cache and +start a fresh full pairwise pass. The default permits two retries; +explicit user-supplied limits stay fixed. Similarity and actual support checks +continue to gate every measurement. Registration caches record initial settings +and final effective limits so a later run can reuse the completed search. +The proposal and scoring revisions are part of the cache fingerprint, so +registrations measured with the preceding algorithm are recomputed. + Global optimization evaluates residuals in effective registration sampling units. Inconsistent redundant links are removed one at a time with a refit after each removal. Links that anchor connected tile groups are retained. Connectivity @@ -27,11 +56,8 @@ constrain multiplicative gains independently for each timepoint and channel. The first depth in each connected gain component is anchored to one; unmeasured depths retain unit gains. Every XY tile at a depth shares that depth's gain. -The production fitter estimates gains from observations. It does not require a -known tissue attenuation coefficient. Physical tests use a known Beer-Lambert -forward model, `I(d) = I(0) * exp(-mu * d)`, so expected correction gains follow -independently from `exp(mu * (d - d_anchor))`. The known coefficient belongs to -the simulated ground truth; it is not a value inferred from the test result. +The fitter estimates gains from observations and does not require a tissue +attenuation coefficient. Gains apply during fusion and are recorded beside the fused volume. Source arrays remain unchanged. XY-field brightness differences are preserved. @@ -46,13 +72,16 @@ weight; pixel intensity is not used to infer geometric coverage. The numerator already contains the deskew coverage factor, so fusion applies coverage to the denominator. This avoids fading partly supported depth bins. -Missing channels are excluded from that channel's accumulation. +Missing channels are excluded from that channel's accumulation. Camera-X padding +has zero feather weight. ## Execution Registration uses CuPy/cuCIM when available and otherwise NumPy/scikit-image. Fusion uses threaded blocks and Numba CPU kernels with bounded memory and queued -writes. Full-volume integer output clips and truncates; maximum-projection +writes. Depth and coverage corrections retain source precision before the final +output cast, including directly copied regions. Full-volume integer output clips +and truncates; maximum-projection fusion retains its rounding convention. Use the [fusion guide](../guides/fusion.md) for practical settings. diff --git a/docs/opm_publication_figure.md b/docs/opm_publication_figure.md deleted file mode 100644 index 1b1ab0f..0000000 --- a/docs/opm_publication_figure.md +++ /dev/null @@ -1,107 +0,0 @@ -# Oblique sampling and reconstruction of a fluorescent meridian sphere - -## Figure caption - -**Oblique-plane acquisition and reconstruction at three scan spacings.** -**a**, Maximum-intensity projections of the Cartesian ground truth, the -unblurred object sampled in instrument coordinates, and simulated camera -volumes acquired at scan spacings of 0.4, 0.8, and 1.2 µm. Ground truth comprises -12 fluorescent meridian arcs with 1 µm tube diameter on a hollow sphere of -10 µm centerline diameter (11 µm outer diameter), defined on a 50 nm Cartesian -grid. The object and a vectorial optical PSF (NA 1.35, wavelength 637 nm) were -sampled into the 30° oblique instrument frame and convolved before camera -sampling. Fine instrument sampling was 0.1 µm along the scan axis and 57.5 nm -along both camera axes. Detector pixels of 115 nm were integrated using 3 × 3 -subpixel quadrature. Detection included Poisson photon noise with a calibration -of 500 electrons at the peak of the fine Cartesian blurred object, 2 electrons -of background per pixel, and Gaussian read noise of 1.5 electrons RMS. -Camera data are shown after subtraction of the known background, clipping -negative values to zero, and conversion to the original fluorescence-density -units. The 0.8 and 1.2 µm acquisitions are displayed on a common 0.4 µm scan -grid, retaining the measured planes and inserting exact zeros in every -unmeasured plane. These zeros are display placeholders, not observations used -by deconvolution. The 0.4, 0.8 and 1.2 µm acquisitions contain 109, 55 and 37 -measured planes, respectively, across the same 43.2 µm scan-center span. -**b**, Maximum-intensity projections of the independently simulated and -GC-deconvolved Cartesian microscope reference and the three OPM reconstructions. -The 0.4 µm acquisition uses native GC; the 0.8 and 1.2 µm acquisitions use the -experimental missing-plane GC implementation with factors 2 and 3, -respectively. All OPM reconstructions have 0.4 µm scan sampling before unchanged -orthogonal deskew. Final comparison volumes have isotropic 115 nm voxels. -All panels use the same linear fluorescence-density display range of 0–1; -values above 1 are displayed as white and retained unchanged in source data. -There is no individual image normalization or display interpolation. Coordinates and aspect ratios -are physical; fields of view differ between instrument and Cartesian panels. - -Rows show XY, XZ and YZ maximum-intensity projections for Cartesian volumes. -Primed axes denote the corresponding raw-array views, with X′ = camera column, -Y′ = camera row, and Z′ = scan position. In physical Cartesian coordinates, -X = X′, Y = Z′ + Y′ cos(30°), and Z = Y′ sin(30°). Thus primed views are not -Cartesian projections of the raw data. Missing planes produce black gaps in -X′Z′ and Y′Z′; maximum projection along Z′ removes these gaps in X′Y′. - -## Reproducibility and interpretation - -The simulation uses the same per-pixel photon calibration at all scan steps; -it does not hold total photons per volume constant. Seed 42 is used for the -camera model and seed 43 for the Cartesian reference. Differently shaped -acquisitions have different noise realizations despite sharing the seed. -The reference volumes and their physical axes were checked to agree across -all three reconstruction exports. Each raw electron-data hash was checked -against the input recorded by its reconstruction. - -The binary ground-truth tubes are intentionally blurred and sampled by the -microscope; deconvolution does not exactly recover their hard boundaries. -This figure represents a stationary, matched-PSF simulation and does not -establish the cause of the tilt in live experimental data. - -Render the figure from the saved acquisitions: - -```powershell -uv run --no-sync --with matplotlib python -m scripts.publication_opm_simulation --acquisitions diagnostics/meridian_sphere_camera_0p4/scan_0.4um diagnostics/meridian_sphere_camera/scan_0.8um diagnostics/meridian_sphere_camera_1p2/scan_1.2um --output diagnostics/publication_opm_sampling -``` - -The output contains the combined figure and separate forward/reconstruction -panels as PDF, SVG, 600-dpi PNG and LZW-compressed TIFF. PDF/SVG preserve vector -labels with embedded raster data. `projection_source_data.npz` contains every -displayed full projection and its physical axes; plots crop only the view, -not the volume used in the projection. `manifest.json` records source paths, -input hashes, physical grids, sampling masks and reconstruction metrics. -`camera_*_zero_filled.tif` stores the exact display volumes in scan/row/column -order, as specified by the manifest, rather than Cartesian Z/Y/X. - -## Verification of camera-plane geometry - -`plane_geometry.png` (also PDF/SVG) displays the same saved camera data twice: -in row/scan coordinates and with raw pixel cells placed at their Cartesian YZ -coordinates. No image intensities are interpolated and production deskew is -not involved in this diagnostic. Each frame at scan position s obeys -Z = tan(30°) (Y - s), so missing planes appear as diagonal gaps in the Cartesian -view. Horizontal gaps in the instrument view describe the same missing data. -The actual sampling function was also checked using the analytic Cartesian -field Y - Z cot(30°), which must equal s at every pixel in a camera frame. -Maximum errors were below 1e-15 µm for all three scan steps; results are saved -in `plane_geometry_check.json`. This checks the coordinate mapping, not every -optical assumption in the simulation. - -```powershell -uv run --no-sync --with matplotlib python -m scripts.plot_opm_plane_geometry diagnostics/publication_opm_sampling -``` - -`fixed_objective_frames.png` (also PDF/SVG) makes a different check: the -Cartesian object is blurred first, its laboratory Y coordinates are shifted, -and it is sampled at a stationary oblique detector plane. Detector points are -held fixed for all five displayed frames. This route is compared with the -original convolution in the fine instrument frame, with camera integration -applied to both. Relative full-frame L2 differences are 1.74%, 1.96%, 2.13%, -1.96%, and 1.74% at scan positions -8, -4, 0, 4, and 8 µm. These calculations -share the same spatially invariant optical PSF assumption; they check two -discretizations of translation and sampling, not independent optical models. -The figure shows a sphere-outline schematic with a fixed blue detector plane, -the predicted individual frames, and the corresponding saved noisy frames. -Both image rows share display limits 0–0.5, with higher values displayed white. -No camera-frame panel is a projection through the stack. - -```powershell -uv run --no-sync --with matplotlib python -m scripts.plot_fixed_objective_simulation diagnostics/meridian_sphere_camera_0p4/scan_0.4um --output diagnostics/publication_opm_sampling -``` diff --git a/docs/orthogonal_interpolation_diagram.md b/docs/orthogonal_interpolation_diagram.md index 0e0b5d2..fe2698e 100644 --- a/docs/orthogonal_interpolation_diagram.md +++ b/docs/orthogonal_interpolation_diagram.md @@ -31,31 +31,17 @@ k_j = floor(v_j / p) and β_j = v_j / p − k_j. Interpolate within that plane: L_j = (1 − β_j) I_j[k_j] + β_j I_j[k_j + 1]. -The existing production core combines the two values as +The production core combines the two values as - I_out = (p / Δs) (L_i + L_(i+1)). + w_i = d_(i+1) / (d_i + d_(i+1)) + w_(i+1) = d_i / (d_i + d_(i+1)) + I_out = (2p / Δs) (w_i L_i + w_(i+1) L_(i+1)). -This expression deliberately preserves the implementation's intensity scale; -the sum is not replaced with distance-weighted interpolation between planes. -For constant input, the core's gain is 2p/Δs before optional Z averaging. +Here d_i and d_(i+1) are the perpendicular distances from the output point to +the two camera planes. Their weights sum to one and linearly interpolate between +the perpendicular projections. The supported 30-degree photon-volume gain +remains 2p/Δs before optional Z averaging. All four samples retain the same camera-column index u = X. Only the YZ geometry requires interpolation. Origins are omitted for clarity; the diagram uses 30° and illustrative spacing, not a particular acquisition's parameters. Boundary handling, padding and optional output-Z averaging are omitted. - -The drawing script checks that both Q points lie on their camera planes and -that P−Q is perpendicular to the camera-row direction. A numerical four-sample -example is evaluated by the actual `orthogonal_deskew` function with Z -downsampling disabled. The expected value is 19.5213548685; the float32 result -is 19.5213546753. The two untouched camera columns remain exactly zero. -These are diagram-consistency checks, not changes to the production algorithm. - -Exports are PDF, editable-text SVG and 600-dpi PNG under -`diagnostics/orthogonal_interpolation_labeled`. The numerical check is saved -as `geometry_check.json`. - -```powershell -uv run --no-sync --with matplotlib python -m scripts.plot_orthogonal_interpolation --output diagnostics/orthogonal_interpolation_labeled -``` - -Spatial axes are labeled in ?m; the numerical example uses illustrative p = 1 ?m and ?s = 3 ?m. The bottom key identifies camera planes and their pixels, orthogonal projection paths, other lab-grid points, and target P. Scan displacement s is explicitly labeled along lab Y. diff --git a/docs/reference/cli/convert-timelapse.md b/docs/reference/cli/convert-timelapse.md deleted file mode 100644 index 652bf6f..0000000 --- a/docs/reference/cli/convert-timelapse.md +++ /dev/null @@ -1,24 +0,0 @@ -# convert-timelapse - -Export raw timelapses at selected positions and scan planes as OME-TIFF, RAW/YAML, -or calibrated time-mean TIFFs. Accepts an acquisition store or its containing -directory. Output defaults to `converted_files` beside the store. - -| Option | Description | -| --- | --- | -| `--output-dir PATH` | Set the destination directory. | -| `--time-range START STOP` | Select timepoints. | -| `--stage-range START STOP` | Select positions. | -| `--scan-range START STOP` | Select scan planes. | -| `--fov-x-range START STOP` | Select camera columns. | -| `--create-raw` | Write RAW data with YAML shape sidecars. | -| `--create-time-projection` | Write calibrated means over time per channel. | -| `--no-create-tiff` | Disable raw timelapse OME-TIFF output. | -| `--camera-offset VALUE` | Override electronic background in ADU. | -| `--camera-conversion VALUE` | Override calibrated intensity per ADU. | - -```bash -uv run convert-timelapse "/path/to/acquisition" --stage-range 0 1 --scan-range 0 1 --create-time-projection -``` - -Append `--help` to list all options and defaults. diff --git a/docs/reference/cli/display.md b/docs/reference/cli/display.md deleted file mode 100644 index ca59ffc..0000000 --- a/docs/reference/cli/display.md +++ /dev/null @@ -1,18 +0,0 @@ -# display - -Open processed data in napari and save a rectangular ROI. Defaults to the -registered fused maximum-Z image and `_roi.json` beside the data. - -| Option | Description | -| --- | --- | -| `--to-display VALUE` | Select `full`, `max-z`, `fused-full`, or `fused-max-z`. | -| `--no-roi` | View without creating an ROI; required for other display modes. | -| `--roi-output PATH` | Set the ROI JSON destination. | -| `--time-range START STOP` | Select visible timepoints. | -| `--pos-range START STOP` | Select visible positions. | - -```bash -uv run display "/path/to/acquisition" --roi-output "/path/to/selection_roi.json" -``` - -Append `--help` to list all options and defaults. diff --git a/docs/reference/cli/export-projections.md b/docs/reference/cli/export-projections.md index 4e0953e..548883a 100644 --- a/docs/reference/cli/export-projections.md +++ b/docs/reference/cli/export-projections.md @@ -1,8 +1,10 @@ # export-projections Export annotated XY/XZ/YZ projection TIFFs per timepoint, channel, and position. -Accepts the directory containing one full deconvolved, deskewed store. Saves -8-bit display images under `projection_frames` beside the store. +Accepts the directory containing one full deskewed store; the deconvolved version +is preferred when both versions of that acquisition exist. Saves +8-bit display images under `projection_frames` beside the store. Timestamps require +the processed NGFF time scale or recorded hardware volume timing. | Option | Description | | --- | --- | diff --git a/docs/reference/cli/export-rois.md b/docs/reference/cli/export-rois.md new file mode 100644 index 0000000..7e9f9ba --- /dev/null +++ b/docs/reference/cli/export-rois.md @@ -0,0 +1,40 @@ +# export-rois + +Select multiple XY rectangles on a deskewed maximum-Z preview, then reconstruct +raw regions and export each as OME-Zarr. Full Z, all channels, and all timepoints +are retained. Multiple positions are locally registered and fused. With +deconvolution enabled, scan-grid refinement is inferred from the recorded scan +step toward 0.4 micrometers, using the same rule as `process`. + +| Argument | Description | +| --- | --- | +| `ROOT_PATH` | Raw acquisition store, processed directory, or explicit maximum-Z preview. | + +Preview lookup prefers `*_max_z_fused.ome.zarr`, then +`*_max_z_decon_deskewed.ome.zarr`, then `*_max_z_deskewed.ome.zarr`. +Unfused previews must contain exactly one position. Multiple matches require +an explicit preview path. A processing journal linking to accessible raw data +is required. Missing previews report a `uv run process` command to create one. + +| Option | Description | +| --- | --- | +| `--output PATH` | Destination; default `roi_exports` beside the preview. | +| `--deconvolve / --no-deconvolve` | Enable RLGC before deskewing; default disabled. | +| `--tiff` | Also export compressed OME-BigTIFF; retain final OME-Zarr. | +| `--flatfield-correction` | Reuse or estimate illumination correction. | +| `--save-float32` | Preserve fractional intensities; default uint16. | +| `--z-downsample-level INTEGER` | Output Z binning factor; default 1. | +| `--registration-channel INTEGER` | Zero-based local registration channel; default 0. | +| `--decon-psf-paths PATH` | Channel-ordered measured PSFs; repeat per channel. Otherwise use recorded optical calibration. | +| `--decon-crop-scan INTEGER` | Retained reconstruction scan planes per chunk; default automatic. | +| `--decon-gpu-id INTEGER` | CUDA device; default 0. | +| `--decon-verbose INTEGER` | Reconstruction verbosity; default 1. | +| `--resume / --no-resume` | Default: reuse saved selections and compatible checkpoints. Disable to redraw and restart. | +| `--workers INTEGER` | TIFF compression workers; default 4. | + +```bash +uv run export-rois "/path/to/processed" --deconvolve --tiff +``` + +See the [ROI guide](../../guides/roi.md) for selection, output layout, metadata, +and resume behavior. diff --git a/docs/reference/cli/fuse.md b/docs/reference/cli/fuse.md index 13e5ba1..4e0fa9f 100644 --- a/docs/reference/cli/fuse.md +++ b/docs/reference/cli/fuse.md @@ -8,6 +8,7 @@ prefers deconvolved, deskewed data. | --- | --- | | `--registration-channel VALUE` | Select the zero-based registration channel; default 0. | | `--max-registration-shift-zyx Z Y X` | Override correction limits inferred from stage positions and scan geometry. | +| `--registration-retries VALUE` | Remeasure pairs with expanded automatic limits and refit the entire graph; default 2, 0 disables. Explicit shift limits stay fixed. | | `--registration-threshold VALUE` | Minimum overlap similarity; default 0.7. | | `--blend-pixels Z Y X` | Set edge blending widths; default 20 600 400. | | `--downsample-factors Z Y X` | Set registration sampling factors; default 3 5 5. | diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 6d28dc3..04f83a6 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -13,9 +13,7 @@ stop. Supply tuple options as separate values, such as | [inspect-opm](inspect-opm.md) | Print acquisition metadata. | | [process](process.md) | Calibrate, deconvolve, and deskew an acquisition. | | [fuse](fuse.md) | Register and blend processed tiles. | -| [display](display.md) | View processed images and select an ROI. | -| [process-ROI](process-roi.md) | Process and fuse a selected raw-data region. | | [export-ome-tiff](export-ome-tiff.md) | Write fused images as compressed OME-BigTIFF. | +| [export-rois](export-rois.md) | Select multiple ROIs and reconstruct raw regions into OME-Zarr and optional TIFF. | | [export-projections](export-projections.md) | Write annotated projections and optional movies. | | [encode-projections](encode-projections.md) | Encode existing projection TIFF sequences. | -| [convert-timelapse](convert-timelapse.md) | Export raw scan-plane timelapses. | diff --git a/docs/reference/cli/process-roi.md b/docs/reference/cli/process-roi.md deleted file mode 100644 index 0a9d0a7..0000000 --- a/docs/reference/cli/process-roi.md +++ /dev/null @@ -1,23 +0,0 @@ -# process-ROI - -Process the raw region selected by `display`, then fuse its tiles. Accepts a raw -acquisition or processed-output directory, followed by an optional ROI JSON path. -Defaults to `_roi.json`, deconvolution enabled, and `_roi` output. - -| Option | Description | -| --- | --- | -| `--no-deconvolve` | Disable deconvolution. | -| `--no-resume` | Overwrite ROI output; completed tiles/channels are resumed by default. | -| `--output PATH` | Set the ROI output directory. | -| `--registration-channel VALUE` | Select the zero-based registration channel; default 0. | -| `--crop-after-deskew` | Unsupported for ROI processing, which already writes a physical crop. | - -Also accepts the [process](process.md) options `--flatfield-correction`, `--save-float32`, -`--z-downsample-level`, `--decon-crop-scan`, `--decon-gpu-id`, `--decon-verbose`, -and `--decon-psf-paths`. - -```bash -uv run process-ROI "/path/to/acquisition" "/path/to/selection_roi.json" --output "/path/to/roi-output" -``` - -Append `--help` to list all options and defaults. diff --git a/docs/reference/cli/process.md b/docs/reference/cli/process.md index 4f9c5f3..be15039 100644 --- a/docs/reference/cli/process.md +++ b/docs/reference/cli/process.md @@ -3,16 +3,23 @@ Calibrate and deskew an acquisition, optionally deconvolving it. Saves uint16 output beside the acquisition by default. +With `--deconvolve`, 3D scan refinement is inferred from recorded spacing toward +0.4 micrometers. For example, a 1.2 micrometer acquisition uses factor 3. Already +finer acquisitions retain their spacing; coarser steps must be integer multiples +of 0.4 micrometers. +Measured PSFs must be sampled at the inferred reconstruction spacing. + | Option | Description | | --- | --- | | `--deconvolve` | Apply RLGC deconvolution before deskewing. | -| `--flatfield-correction` | Correct uneven illumination. | +| `--flatfield-correction` | Include illumination in the deconvolution measurement model, or divide calibrated data by it when deconvolution is disabled. | | `--save-float32` | Preserve fractional intensities in float32 output. | | `--skip-empty-below VALUE` | Zero channel tiles with insufficient signal above this intensity. | | `--skip-empty-min-signal-fraction VALUE` | Required fraction above the cutoff; default 0.01. | -| `--resume` | Continue compatible completed tiles; otherwise overwrite output. | +| `--resume` | Continue compatible completed tiles. If omitted, partial outputs offer Resume / Overwrite / Cancel (default: Cancel); completed outputs are overwritten. | | `--output PATH` | Set the output directory. | | `--live PATH` | Process during acquisition using a supplied CYX illumination TIFF; pass the acquisition directory. | +| `--live-wait` | Wait for successful live completion and all raw tiles, then process offline. Combine with `--flatfield-correction` to estimate missing illumination. Mutually exclusive with `--live`; pass the acquisition directory. | | `--time-range START STOP` | Select timepoints. | | `--pos-range START STOP` | Select positions. | | `--z-downsample-level VALUE` | Deskewed Z reduction factor; default 2. | @@ -25,11 +32,25 @@ output beside the acquisition by default. | `--decon-verbose VALUE` | Set solver verbosity; 0 suppresses iteration reports. | | `--decon-fallback-step-scan VALUE` | Set chunk reduction after GPU allocation failure. | | `--decon-psf-paths PATH` | Supply channel-ordered PSFs; repeat for each channel. | -| `--decon-scan-upsample VALUE` | Experimental 3D scan upsampling; requires deconvolution and full-volume GPU memory. | -| `--eager-mode` | Enable eager stopping for planar deconvolution. | +| `--eager-mode` | Legacy planar KL-stopping option; has no effect with MATLAB-default GC stopping. | ```bash uv run process "/path/to/acquisition" --deconvolve --flatfield-correction ``` +Start before acquisition finishes, without a precomputed illumination file: + +```bash +uv run process "/path/to/acquisition-directory" --live-wait --flatfield-correction --deconvolve +``` + +See the [live acquisition guide](../../guides/live.md) for required controller +sidecars and completion behavior. + Append `--help` to list all options and defaults. + +The photon-GC solver revision is recorded with the processing configuration. +Resume rejects incompatible solver configurations; start a fresh output or +intentionally overwrite instead. +Processing without deconvolution retains its existing calibration and flatfield +behavior. diff --git a/docs/reference/python/imageprocessing.md b/docs/reference/python/imageprocessing.md index 95aaba8..5082bb5 100644 --- a/docs/reference/python/imageprocessing.md +++ b/docs/reference/python/imageprocessing.md @@ -11,6 +11,8 @@ source-documented functions. ::: opm_processing.imageprocessing.camera.illumination_correct +::: opm_processing.imageprocessing.flatfield.estimate_illuminations + ## Geometry and interpolation ::: opm_processing.imageprocessing.coordinates.stage_positions_to_image_coordinates @@ -21,6 +23,8 @@ source-documented functions. ## PSFs and RLGC +::: opm_processing.imageprocessing.opmpsf.integrate_camera_pixels + ::: opm_processing.imageprocessing.opmpsf.generate_skewed_psf ::: opm_processing.imageprocessing.opmpsf.generate_proj_psf diff --git a/docs/reference/python/workflows.md b/docs/reference/python/workflows.md index 8ff1cd0..841c8ed 100644 --- a/docs/reference/python/workflows.md +++ b/docs/reference/python/workflows.md @@ -5,6 +5,8 @@ document the corresponding Python calculations and workflow parameters. ::: opm_processing.process.process +::: opm_processing.export_rois.export_rois + ::: opm_processing.process_roi.process_roi ::: opm_processing.fuse.register_and_fuse diff --git a/mkdocs.yml b/mkdocs.yml index 0c2e044..c41a9d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -90,12 +90,10 @@ nav: - inspect-opm: reference/cli/inspect-opm.md - process: reference/cli/process.md - fuse: reference/cli/fuse.md - - display: reference/cli/display.md - - process-ROI: reference/cli/process-roi.md - export-ome-tiff: reference/cli/export-ome-tiff.md + - export-rois: reference/cli/export-rois.md - export-projections: reference/cli/export-projections.md - encode-projections: reference/cli/encode-projections.md - - convert-timelapse: reference/cli/convert-timelapse.md - Dataset layout and metadata: reference/data.md - Python: - Acquisition and state: reference/python/dataio.md @@ -108,9 +106,6 @@ nav: - Orthogonal interpolation geometry: orthogonal_interpolation_diagram.md - Examples: - Simulation scripts: examples/index.md - - Meridian sphere: meridian_sphere_simulation.md - - Publication figure: opm_publication_figure.md - - Fixed-plane sampling: fixed_plane_sampling_series.md - Development: - Contributing: development/contributing.md - Running tests: development/testing.md diff --git a/pyproject.toml b/pyproject.toml index 57b9fc6..0766d0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ # https://peps.python.org/pep-0517/ [build-system] -requires = ["hatchling"] +requires = ["hatchling==1.27.0"] build-backend = "hatchling.build" # read more about configuring hatch at: @@ -33,15 +33,25 @@ classifiers = [ ] # add your package dependencies here dependencies = [ + "numpy", + "ome-types[lxml]", + "tensorstore", + "zarr>=3", + "typer>=0.15,<1", + "yaozarrs[io,write-tensorstore] @ git+https://github.com/dpshepherd/yaozarrs.git@7eebe750654f71fbb0e722aeabdff8b47d12c1b5", +] + +[project.optional-dependencies] +# The base distribution is a lightweight public IO/metadata SDK. Processing +# keeps its existing scientific/GUI stack, selected by the ordinary uv workflow. +processing = [ "basicpy @ git+https://github.com/QI2lab/BaSiCPy.git@main", "cmap", "imagecodecs", - "napari[all]", + "napari[pyqt]", "napari-ome-zarr>=0.9.0", "napari-plugin-engine", "numba", - "numpy", - "ome-types[lxml]", "psutil", "psfmodels", "pyyaml", @@ -49,21 +59,17 @@ dependencies = [ "ryomen", "scikit-image", "scipy", - "tensorstore", "tifffile", "torch==2.9.0; sys_platform == 'win32'", "torch; sys_platform != 'win32'", "tqdm", - "typer", - "yaozarrs[io,write-tensorstore] @ git+https://github.com/dpshepherd/yaozarrs.git@fix-v3-extra-attributes-location", - "zarr>3.0.8", "napari-animation>=0.0.10", "pynvvideocodec>=2.2.3 ; sys_platform == 'linux' or sys_platform == 'win32'", "imageio-ffmpeg>=0.6", ] -[project.optional-dependencies] gpu = [ + "opm-processing-v2[processing]", "cucim-cu12==26.6.*; sys_platform == 'linux'", "cupy-cuda12x[ctk]>=14.1.1", ] @@ -82,9 +88,10 @@ name = "nvidia" url = "https://pypi.nvidia.com" [tool.uv] -default-groups = ["gpu"] +default-groups = ["processing", "gpu"] [dependency-groups] +processing = ["opm-processing-v2[processing]"] docs = [ "mkdocs>=1.6,<2", "mkdocs-material>=9.6,<10", @@ -101,6 +108,7 @@ dev = [ "prek==0.5.5", "rich", # https://github.com/Textualize/rich "ruff==0.16.6", + "pyfakefs==6.2.0", ] [project.urls] @@ -113,13 +121,11 @@ documentation = "https://qi2lab.github.io/opm-processing-v2/" # same as console_scripts entry point [project.scripts] process = "opm_processing.process:main" -process-ROI = "opm_processing.process_roi:main" inspect-opm = "opm_processing.dataio.acquisition:main" -convert-timelapse = "opm_processing.dataio.convert_timelapse_data:app" -display = "opm_processing.display:main" export-projections = "opm_processing.export_projections:main" encode-projections = "opm_processing.encode_projections:main" export-ome-tiff = "opm_processing.export_ome_tiff:main" +export-rois = "opm_processing.export_rois:main" fuse = "opm_processing.fuse:main" # [project.entry-points."some.group"] @@ -133,7 +139,8 @@ src = ["src"] [tool.ruff.lint] allowed-confusables = ["µ", "α", "−", "×", "–", "′"] select = ["E", "W", "F", "D", "I", "UP", "C4", "B", "A001", "ANN", "RUF", "TC", "TID"] -ignore = ["ANN401", "D401", "E402", "E501"] +extend-select = ["D417"] +ignore = ["ANN401", "E402", "E501"] [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" @@ -142,7 +149,7 @@ ban-relative-imports = "all" runtime-evaluated-decorators = ["app.command"] [tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = ["D", "ANN"] +"tests/**/*.py" = ["ANN"] [tool.ruff.lint.pydocstyle] convention = "numpy" @@ -155,16 +162,18 @@ docstring-code-format = true [tool.pytest.ini_options] minversion = "7.0" testpaths = ["tests"] -addopts = "-p no:zarr" +addopts = "--strict-markers -p no:cacheprovider -p fakefs" markers = [ "unit: isolated numerical or decision behavior with an independent expected result", - "integration: simulated image objects processed from real disk inputs to verified disk outputs", + "integration: interacting processing and storage components with independently checked outputs", "cpu: correctness tests using CPU execution, suitable for GitHub-hosted runners", "gpu: correctness tests that execute CUDA code on a real NVIDIA GPU", ] filterwarnings = [ "error", "ignore:CUDA path could not be detected.*:UserWarning:cupy._environment", + # cuCIM's vendored padding still assigns ndarray.shape under NumPy 2.5. + "ignore:Setting the shape on a NumPy array has been deprecated.*:DeprecationWarning:cucim.skimage._vendored.pad", "ignore::pydantic.warnings.PydanticDeprecatedSince20", ] diff --git a/scripts/__init__.py b/scripts/__init__.py deleted file mode 100644 index eb6df82..0000000 --- a/scripts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Repository simulation experiments and figure-generation scripts.""" diff --git a/scripts/audit_deconvolution.py b/scripts/audit_deconvolution.py deleted file mode 100644 index 9942f0c..0000000 --- a/scripts/audit_deconvolution.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Compare native RLGC, lookup-table count splitting, and finite-PSF edge tapering. - -Read an NPZ containing image, psf, truth, and sampling_um arrays from an -independent optical simulation. Run warmed, alternating reconstruction trials -and write input/reconstruction OME-Zarr stores, candidate PSFs, and a JSON report -of timings, FFT counts, memory, absolute reconstruction error, flux, and measured -prediction error. An optional rlgc.py snapshot with the same OPM geometry -padding supplies a measured baseline. Use --require-pixel-identity only for -changes intended to preserve arithmetic and random draws. -The lookup sampler and taper are experiments; production defaults are unchanged. -Run from the repository root with python -m scripts.audit_deconvolution. -""" - -from __future__ import annotations - -import argparse -import importlib.util -import json -import logging -import sys -import time -from functools import partial -from pathlib import Path - -import numpy as np -from scipy import signal -from scipy.special import bdtr - -from opm_processing.dataio.position_collection import ( - create_position_collection, - open_position_collection, -) -from opm_processing.imageprocessing import rlgc as solver -from opm_processing.imageprocessing.rlgc import _split_observed_counts - -cp = solver.cp - -_binomial_lookup = cp.ElementwiseKernel( - "float32 observed, float64 uniform, float32 residual_uniform, raw float64 cdf, int32 stride", - "float32 split", - """ - const long long n = (long long)observed; - if (n >= stride) { - split = 0; - } else { - int lo = 0, hi = (int)n; - while (lo < hi) { - const int mid = (lo + hi) / 2; - if (cdf[n * stride + mid] > uniform) hi = mid; - else lo = mid + 1; - } - split = (float)lo + (residual_uniform < 0.5f ? observed - (float)n : 0.0f); - } - """, - "audit_binomial_lookup", -) - - -def binomial_split_cdf(max_count: int = 256) -> cp.ndarray: - """Build a bounded float64 binomial CDF on the current CUDA device. - - Parameters - ---------- - max_count : int - Largest integer photon count represented, defaulting to 256. - - Returns - ------- - cupy.ndarray - Square table whose row n contains P(Binomial(n, 0.5) <= k). - Entries beyond n equal one. The table occupies 8*(max_count+1)**2 bytes. - """ - counts = np.arange(max_count + 1) - probabilities = bdtr( - np.minimum(counts[None], counts[:, None]), counts[:, None], 0.5 - ) - return cp.asarray(probabilities, dtype=cp.float64) - - -def split_counts_lookup( - observed: cp.ndarray, - rng: cp.random.Generator, - cdf: cp.ndarray, - *, - assign_fractional_remainder: bool = True, -) -> cp.ndarray: - """Split counts with fresh inverse-CDF draws and optional fractional assignment. - - Parameters - ---------- - observed : cupy.ndarray - Nonnegative float32 calibrated photon counts. - rng : cupy.random.Generator - Generator for independent float64 quantiles and fractional assignments. - cdf : cupy.ndarray - Float64 table from binomial_split_cdf on the same CUDA device. - assign_fractional_remainder : bool, default=True - Use weighted residual assignment for the undersampled model. False - leaves every remainder in the complementary half, as in the reference. - - Returns - ------- - cupy.ndarray - One float32 half; subtracting it from observed gives the complementary - half. Counts exceeding the table use the original binomial sampler. - """ - split = _binomial_lookup( - observed, - rng.random(observed.shape, dtype=cp.float64), - rng.random(observed.shape, dtype=cp.float32) - if assign_fractional_remainder - else cp.asarray(1, dtype=cp.float32), - cdf, - cdf.shape[0], - ) - outside = observed >= cdf.shape[0] - if bool(cp.any(outside)): - split[outside] = _split_observed_counts( - observed[outside], - rng, - assign_fractional_remainder=assign_fractional_remainder, - ) - return split - - -def taper_psf( - psf: np.ndarray, - sampling_um: tuple[float, float, float], - edge_widths_um: tuple[float, float, float], -) -> tuple[np.ndarray, float]: - """Apply raised-cosine edge windows to the centered acquisition-grid PSF. - - Parameters - ---------- - psf : numpy.ndarray - Nonnegative centered SYX PSF, before FFT padding or shifting. - sampling_um : tuple of float - Scan, camera-row, and camera-column spacings in micrometers. - edge_widths_um : tuple of float - Taper widths from each face along SYX, in micrometers. Zero disables - tapering along that axis; singleton axes retain unit weight. - - Returns - ------- - tuple[numpy.ndarray, float] - Unit-sum float32 PSF and fraction of input energy removed by the window. - The source is unchanged. Widths describe acquisition axes, not lab ZYX. - """ - tapered = np.asarray(psf, dtype=np.float64).copy() - original_energy = tapered.sum() - for axis, (size, pitch, width) in enumerate( - zip(psf.shape, sampling_um, edge_widths_um, strict=True) - ): - if size == 1 or width == 0: - continue - distance = np.minimum(np.arange(size), np.arange(size)[::-1]) * pitch - profile = 0.5 * (1 - np.cos(np.pi * np.minimum(distance / width, 1))) - shape = [1] * psf.ndim - shape[axis] = size - tapered *= profile.reshape(shape) - retained_energy = tapered.sum() - return (tapered / retained_energy).astype(np.float32), float( - 1 - retained_energy / original_energy - ) - - -def main() -> None: - """Persist reconstruction comparisons from independently simulated input data.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "input", type=Path, help="NPZ with image, psf, truth, sampling_um" - ) - parser.add_argument("output", type=Path) - parser.add_argument( - "--baseline", type=Path, help="rlgc.py snapshot using the same OPM padding" - ) - parser.add_argument("--trials", type=int, default=5) - parser.add_argument("--lookup-count", type=int, default=256) - parser.add_argument("--taper-um", type=float, nargs=3, default=(0.4, 0.46, 0.23)) - parser.add_argument("--seed", type=int, default=42) - parser.add_argument("--solver-logging", action="store_true") - parser.add_argument("--require-pixel-identity", action="store_true") - args = parser.parse_args() - with np.load(args.input) as data: - image, psf, truth = (data[name] for name in ("image", "psf", "truth")) - sampling = tuple(float(value) for value in data["sampling_um"]) - image = image.astype(np.float32, copy=False) - args.output.mkdir(parents=True, exist_ok=True) - setup_start = time.perf_counter() - lookup = binomial_split_cdf(args.lookup_count) - cp.cuda.Stream.null.synchronize() - lookup_setup_seconds = time.perf_counter() - setup_start - tapered, removed_energy = taper_psf(psf, sampling, tuple(args.taper_um)) - variants = [ - ("current", solver, psf, False), - ("lookup", solver, psf, True), - ("taper", solver, tapered, False), - ] - if args.baseline is not None: - spec = importlib.util.spec_from_file_location( - "audit_rlgc_baseline", args.baseline - ) - baseline = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = baseline - spec.loader.exec_module(baseline) - variants.insert(0, ("baseline", baseline, psf, False)) - logger = logging.getLogger("deconvolution-audit") if args.solver_logging else None - if logger is not None: - logger.setLevel(logging.INFO) - logger.addHandler(logging.StreamHandler()) - inputs = create_position_collection( - args.output / "input.ome.zarr", - (1, 1, 1, *image.shape), - sampling, - chunks=(1, 1, 8, 64, 64), - dtype=np.float32, - ) - inputs.arrays[0][0, 0].write(image.astype(np.float32)).result() - original_split = solver._split_observed_counts - fft_functions = {module: module.fft_conv for _, module, *_ in variants} - counts = [0] - - def counted_fft( - image: cp.ndarray, transfer: cp.ndarray, shape: tuple[int, ...] - ) -> cp.ndarray: - """Count optical convolutions during one reconstruction. - - Parameters - ---------- - image - Device input volume. - transfer - Forward or adjoint OTF. - shape - FFT transform dimensions. - - Returns - ------- - cupy.ndarray - Original solver convolution result. - """ - counts[0] += 1 - return fft_function(image, transfer, shape) - - measurements = {name: [] for name, *_ in variants} - metrics = {} - try: - for trial in range(args.trials + 1): - for name, module, kernel, use_lookup in ( - variants if trial % 2 == 0 else variants[::-1] - ): - solver._split_observed_counts = ( - partial(split_counts_lookup, cdf=lookup) - if use_lookup - else original_split - ) - fft_function = fft_functions[module] - module.fft_conv = counted_fft - counts[0] = 0 - cp.cuda.Stream.null.synchronize() - started = time.perf_counter() - result = module.rlgc( - image, - kernel, - rng_seed=args.seed, - limit=0.01, - max_delta=0.001, - release_memory=False, - logger=logger, - ) - cp.cuda.Stream.null.synchronize() - elapsed = time.perf_counter() - started - if trial: - measurements[name].append(elapsed) - prediction = signal.fftconvolve( - result.astype(np.float64), psf, mode="same" - ) - metrics[name] = { - "seconds_median": float(np.median(measurements[name])) - if trial - else None, - "seconds_trials": measurements[name], - "fft_convolutions": counts[0], - "cumulative_pool_reserved_bytes": cp.get_default_memory_pool().total_bytes(), - "truth_nrmse": float( - np.linalg.norm(result - truth) / np.linalg.norm(truth) - ), - "flux_ratio": float(result.sum() / truth.sum()), - "prediction_nrmse": float( - np.linalg.norm(prediction - image) / np.linalg.norm(image) - ), - } - if trial == args.trials: - output = create_position_collection( - args.output / f"{name}.ome.zarr", - (1, 1, 1, *result.shape), - sampling, - chunks=(1, 1, 8, 64, 64), - dtype=np.float32, - ) - output.arrays[0][0, 0].write(result).result() - np.save(args.output / f"{name}-psf.npy", kernel) - print(name, "trial", trial, "seconds", elapsed, flush=True) - finally: - solver._split_observed_counts = original_split - for module, function in fft_functions.items(): - module.fft_conv = function - module.clear_rlgc_caches(clear_memory_pool=True) - report = { - "shape_syx": list(image.shape), - "sampling_um": sampling, - "lookup_max_count": args.lookup_count, - "lookup_table_bytes": lookup.nbytes, - "lookup_setup_seconds": lookup_setup_seconds, - "taper_widths_um": args.taper_um, - "taper_removed_energy": removed_energy, - "rng_seed": args.seed, - "limit": 0.01, - "max_delta": 0.001, - "solver_logging": args.solver_logging, - "memory_note": "Pool reservation accumulates across variants; this is not per-variant peak memory.", - "variants": metrics, - } - if args.baseline is not None: - previous = open_position_collection(args.output / "baseline.ome.zarr") - current = open_position_collection(args.output / "current.ome.zarr") - previous_pixels = previous.arrays[0][0, 0].read().result() - current_pixels = current.arrays[0][0, 0].read().result() - report["baseline_pixels_identical"] = bool( - np.array_equal(previous_pixels, current_pixels) - ) - if args.require_pixel_identity: - np.testing.assert_array_equal(current_pixels, previous_pixels) - (args.output / "report.json").write_text( - json.dumps(report, indent=2), encoding="utf-8" - ) - - -if __name__ == "__main__": - main() diff --git a/scripts/audit_deconvolution_execution.py b/scripts/audit_deconvolution_execution.py deleted file mode 100644 index a19e0cf..0000000 --- a/scripts/audit_deconvolution_execution.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Measure deconvolution execution costs with identical reconstruction rules. - -Read independent simulated image and PSF arrays from an NPZ. Compare native -and scan-subsampled RLGC using the current FFT execution against the previous -extra frequency copy, per-call garbage collection, and repeated old-prediction -FFT. Run alternating warmed trials, require identical reconstructed pixels, -and write timings, transform counts and pool reservations to JSON. Optionally -tile the input for a larger fixed-iteration timing workload; that workload is -not a new physical validation. No acquisition stores are modified. -""" - -from __future__ import annotations - -import argparse -import gc -import importlib.util -import json -import sys -import time -from pathlib import Path - -import numpy as np - -from opm_processing.imageprocessing import rlgc as native -from opm_processing.imageprocessing import rlgc_undersampled as sparse - - -def main() -> None: - """Compare execution strategies on one supplied simulation and persist results.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("input", type=Path) - parser.add_argument("output", type=Path) - parser.add_argument("--trials", type=int, default=5) - parser.add_argument("--iterations", type=int, default=100) - parser.add_argument("--tile", type=int, nargs=3, default=(1, 1, 1)) - args = parser.parse_args() - with np.load(args.input) as data: - image = np.tile(data["image"], args.tile).astype(np.float32) - psf = data["psf"] - cp = native.cp - frequency_buffers = {} - calls = [0] - original_fft = native.fft_conv - - def previous_fft( - values: cp.ndarray, transfer: cp.ndarray, shape: tuple[int, ...] - ) -> cp.ndarray: - """Reproduce the former extra frequency allocation and transform copy. - - Parameters - ---------- - values : cupy.ndarray - Object-space input. - transfer : cupy.ndarray - Optical transfer function. - shape : tuple of int - Inverse transform dimensions. - - Returns - ------- - cupy.ndarray - Unclipped float32 convolution. - """ - calls[0] += 1 - if shape not in frequency_buffers: - frequency_buffers[shape] = cp.empty( - (*shape[:2], shape[2] // 2 + 1), cp.complex64 - ) - workspace = frequency_buffers[shape] - workspace[...] = cp.fft.rfftn(values) - workspace *= transfer - return cp.fft.irfftn(workspace, s=shape).astype(cp.float32, copy=False) - - def current_fft( - values: cp.ndarray, transfer: cp.ndarray, shape: tuple[int, ...] - ) -> cp.ndarray: - """Count current convolution calls without changing execution. - - Parameters - ---------- - values : cupy.ndarray - Object-space input. - transfer : cupy.ndarray - Optical transfer function. - shape : tuple of int - Inverse transform dimensions. - - Returns - ------- - cupy.ndarray - Current unclipped convolution result. - """ - calls[0] += 1 - return original_fft(values, transfer, shape) - - # Load a separate copy for the old redundant forward transform. The source - # and scientific rules are otherwise identical to the current solver. - spec = importlib.util.spec_from_file_location( - "audit_sparse_previous", sparse.__file__ - ) - previous_sparse = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = previous_sparse - source = ( - Path(sparse.__file__) - .read_text() - .replace( - "predicted, previous_prediction, split1, split2, scratch", - "predicted, cp.maximum(forward(previous), 1e-6), split1, split2, scratch", - ) - ) - exec(compile(source, str(sparse.__file__), "exec"), previous_sparse.__dict__) - memory_pool = cp.cuda.MemoryPool() - peak_live = [0] - - def measured_allocator(size: int) -> cp.cuda.MemoryPointer: - """Allocate a device block and track peak live bytes in an isolated pool. - - Parameters - ---------- - size : int - Requested allocation in bytes. - - Returns - ------- - cupy.cuda.MemoryPointer - Allocation owned by the measurement pool. - """ - allocation = memory_pool.malloc(size) - peak_live[0] = max(peak_live[0], memory_pool.used_bytes()) - return allocation - - report = { - "shape_syx": image.shape, - "iterations_cap": args.iterations, - "tiled_runtime_workload": tuple(args.tile) != (1, 1, 1), - "variants": {}, - } - try: - for factor in (None, 3): - measurements = {name: [] for name in ("previous", "current")} - outputs = {} - counts = {} - memory = {} - for trial in range(args.trials + 1): - for name in ( - ("previous", "current") - if trial % 2 == 0 - else ("current", "previous") - ): - module = ( - native - if factor is None - else (previous_sparse if name == "previous" else sparse) - ) - module.fft_conv = ( - previous_fft if name == "previous" else current_fft - ) - calls[0] = 0 - cp.cuda.Stream.null.synchronize() - started = time.perf_counter() - kwargs = { - "max_iterations": args.iterations, - "limit": 0, - "max_delta": 0, - "rng_seed": 42, - "release_memory": False, - } - if factor is None: - result = module.rlgc(image, psf, **kwargs) - else: - result = module.rlgc_undersampled( - image[::factor], psf, factor, **kwargs - ) - if name == "previous" and factor is None: - gc.collect() - cp.cuda.Stream.null.synchronize() - elapsed = time.perf_counter() - started - if trial: - measurements[name].append(elapsed) - outputs[name] = result - counts[name] = calls[0] - memory[name] = cp.get_default_memory_pool().total_bytes() - print(factor, name, trial, elapsed, flush=True) - np.testing.assert_array_equal(outputs["previous"], outputs["current"]) - peaks = {} - for name in ("previous", "current"): - frequency_buffers.clear() - native.clear_rlgc_caches(clear_memory_pool=True) - memory_pool = cp.cuda.MemoryPool() - peak_live[0] = 0 - module = ( - native - if factor is None - else (previous_sparse if name == "previous" else sparse) - ) - module.fft_conv = previous_fft if name == "previous" else current_fft - with cp.cuda.using_allocator(measured_allocator): - if factor is None: - module.rlgc(image, psf, **kwargs) - else: - module.rlgc_undersampled(image[::factor], psf, factor, **kwargs) - cp.cuda.Stream.null.synchronize() - peaks[name] = peak_live[0] - frequency_buffers.clear() - native.clear_rlgc_caches(clear_memory_pool=True) - memory_pool.free_all_blocks() - report["variants"]["native" if factor is None else "sparse3"] = { - name: { - "seconds_trials": timings, - "seconds_median": float(np.median(timings)), - "fft_convolutions": counts[name], - "pool_reserved_bytes": memory[name], - "peak_live_pool_bytes": peaks[name], - } - for name, timings in measurements.items() - } - report["pixels_identical"] = True - report["memory_note"] = ( - "Reservations include all warmed variants. Peak live bytes are measured separately " - "using an isolated CuPy pool; allocations made directly by the driver are excluded." - ) - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(report, indent=2)) - finally: - native.fft_conv = sparse.fft_conv = original_fft - frequency_buffers.clear() - native.clear_rlgc_caches(clear_memory_pool=True) - - -if __name__ == "__main__": - main() diff --git a/scripts/deconvolve.py b/scripts/deconvolve.py new file mode 100644 index 0000000..f052d43 --- /dev/null +++ b/scripts/deconvolve.py @@ -0,0 +1,29 @@ +"""Process an OME-Zarr using its recorded camera and microscope calibration. + +Run: uv run python scripts/deconvolve.py /path/to/acquisition.ome.zarr +""" + +import argparse +from pathlib import Path + +from opm_processing.process import process + + +def main() -> None: + """Run metadata-driven acquisition processing with RLGC enabled.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("acquisition", type=Path) + parser.add_argument("--output", type=Path) + parser.add_argument("--gpu-id", type=int, default=0) + args = parser.parse_args() + process( + root_path=args.acquisition, + deconvolve=True, + save_float32=True, + decon_gpu_id=args.gpu_id, + output=args.output, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/deconvolve_opm_simulation.py b/scripts/deconvolve_opm_simulation.py deleted file mode 100644 index 2365426..0000000 --- a/scripts/deconvolve_opm_simulation.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Deconvolve a saved camera-integrated, noisy sphere acquisition. - -Read simulation.json, raw_electrons.tif, microscope comparison volumes, and the -fine Cartesian object/PSF from a saved simulation. Reconstruct the oblique and -Cartesian acquisitions with the production RLGC solvers, optionally inserting -scan planes with --scan-upsample, then deskew the oblique reconstruction. -Write reconstructed TIFFs, the sampled deconvolution PSF, and simulation.json -with truth-comparison metrics into decon_native or decon_upsample below the -input directory. Acquisition is not rerun and no additional noise is added. - -Run from the repository root with -``uv run python -m scripts.deconvolve_opm_simulation ACQUISITION``. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -from pathlib import Path -from typing import Any - -import numpy as np -from scipy.signal import fftconvolve -from tifffile import imread, imwrite - -from opm_processing.imageprocessing.opmtools import orthogonal_deskew -from scripts.opm_simulation import centered_axis, comparison_metrics, pixel_average -from scripts.psf_sampling_experiment import sample_skewed - - -def full_volume_error(image: np.ndarray, truth: np.ndarray) -> float: - """Include both missing fluorescence and false fluorescence outside the tubes. - - Parameters - ---------- - image - Nonnegative image samples on the operator input grid. - truth - Known simulated fluorescence volume used as the reference. - - Returns - ------- - float - Full-volume relative L2 error against the known simulated fluorescence. - """ - return float(np.linalg.norm(image - truth) / np.linalg.norm(truth)) - - -def deconvolve_saved_simulation( - directory: Path, scan_upsample: int | None = None -) -> dict[str, Any]: - """Keep exported data fixed and compare native or upsampled RLGC with truth. - - Require completed optical convolution, camera integration and detection - noise. Use the saved detected electrons, never scale noiseless density into - pseudo-counts. No new data or noise realization is generated here. - - Parameters - ---------- - directory - Saved simulation directory containing TIFFs and simulation.json. - scan_upsample - Integer scan-plane insertion factor, or None for native sampling. - - Returns - ------- - dict - Reconstruction truth-comparison metrics saved beside the output TIFFs. - """ - directory = Path(directory) - meta = json.loads((directory / "simulation.json").read_text(encoding="utf-8")) - if ( - meta["acquisition"]["peak_electrons"] is None - or meta["acquisition"]["camera_samples"] < 2 - ): - raise ValueError( - "GC requires the fully convolved, camera-integrated, noisy acquisition export" - ) - if scan_upsample is not None and ( - not isinstance(scan_upsample, int) or scan_upsample < 1 - ): - raise ValueError("scan_upsample must be a positive integer") - from opm_processing.imageprocessing.rlgc import rlgc - - p, acquisition = meta["parameters"], meta["acquisition"] - pixel = p["pixel_size_um"] - theta = np.deg2rad(p["angle_deg"]) - step = acquisition["scan_step_um"] / (scan_upsample or 1) - raw_path = directory / "raw_electrons.tif" - counts = np.maximum(imread(raw_path) - acquisition["background_electrons"], 0) - raw = imread(directory / "raw_skewed.tif") - truth = imread(directory / "truth_sampled.ome.tif") - normal = imread(directory / "normal.ome.tif") - original_deskewed = imread(directory / "deskewed.ome.tif") - fine_psf = imread(directory.parent / "psf_cartesian_fine.tif") - counts_per_density = acquisition.get("counts_per_density") - if counts_per_density is None: - # Older exports did not persist their calibration gain. Recalculate - # its exact definition from the saved fine truth and PSF, without - # changing the acquired data or fitting gain to a reconstruction. - fine_truth = imread(directory.parent / "ground_truth_fine.tif") - peak_density = float(fftconvolve(fine_truth, fine_psf, mode="same").max()) - counts_per_density = acquisition["peak_electrons"] / peak_density - del fine_truth - np.testing.assert_allclose(counts / counts_per_density, raw, rtol=1e-6, atol=1e-7) - psf_axes = tuple( - a["origin_um"] + np.arange(a["size"]) * a["step_um"] - for a in meta["cartesian_psf_axes_zyx"] - ) - hz, hy, hx = (max(abs(a[0]), abs(a[-1])) for a in psf_axes) - skewed_shape = tuple( - len(centered_axis(e, d)) - for e, d in zip( - (hy + hz / np.tan(theta), hz / np.sin(theta), hx), - (step, pixel, pixel), - strict=False, - ) - ) - # Same fine Cartesian optical field as the forward simulation. Only the - # requested reconstruction-grid sampling changes; the measured raw data do not. - skewed_psf = np.zeros(skewed_shape, dtype=np.float32) - samples = acquisition["camera_samples"] - offsets = ((np.arange(samples) + 0.5) / samples - 0.5) * pixel - for dy in offsets: - for dx in offsets: - skewed_psf += sample_skewed( - fine_psf, - psf_axes, - skewed_shape, - pixel_size_um=pixel, - scan_step_um=step, - angle_deg=p["angle_deg"], - center_xyz_um=(dx, dy * np.cos(theta), dy * np.sin(theta)), - ) - skewed_psf /= skewed_psf.sum() - print( - f"Deconvolving fixed raw volume {raw.shape}; reconstruction scan step {step:g} um", - flush=True, - ) - if scan_upsample is None: - reconstructed = rlgc(counts, skewed_psf) - else: - from opm_processing.imageprocessing.rlgc_undersampled import rlgc_undersampled - - reconstructed = rlgc_undersampled( - counts, skewed_psf, scan_upsample_factor=scan_upsample - ) - if not np.isfinite(reconstructed).all(): - raise FloatingPointError( - "GC returned non-finite values; no deconvolution output was exported" - ) - reconstructed /= counts_per_density - deskewed = orthogonal_deskew( - reconstructed, - theta=p["angle_deg"], - distance=step, - pixel_size=pixel, - downsample_factor=1, - divisible_by=1, - ) * (step / (2 * pixel)) - - # Endpoint-preserving scan upsampling leaves the physical volume origin - # unchanged. Select the exact pre-existing comparison grid, without shifts. - raw_axes = meta["raw_axes_syx"] - first_scan, first_row, first_col = (a["origin_um"] for a in raw_axes) - origin = ( - first_row * np.sin(theta), - first_scan + first_row * np.cos(theta), - first_col, - ) - offsets = [ - (a["origin_um"] - o) / pixel - for a, o in zip(meta["output_axes_zyx"], origin, strict=False) - ] - if not np.allclose(offsets, np.round(offsets), atol=1e-6): - raise ValueError( - "Reconstructed grid does not coincide with the recorded physical comparison grid" - ) - crop = tuple( - slice(round(o), round(o) + n) - for o, n in zip(offsets, truth.shape, strict=False) - ) - deskewed = deskewed[crop].copy() - if deskewed.shape != truth.shape: - raise ValueError("Reconstruction does not cover the full comparison grid") - - normal_axes = tuple(centered_axis(e, pixel) for e in (hz, hy, hx)) - normal_psf = pixel_average(fine_psf, psf_axes, normal_axes, pixel, samples) - normal_psf /= normal_psf.sum() - print("Deconvolving fixed Cartesian microscope reference", flush=True) - normal_reconstructed = ( - rlgc(normal * counts_per_density, normal_psf) / counts_per_density - ) - if not np.isfinite(normal_reconstructed).all(): - raise FloatingPointError( - "Cartesian GC returned non-finite values; no output was exported" - ) - metrics = { - "opm_before_vs_truth": full_volume_error(original_deskewed, truth), - "opm_after_vs_truth": full_volume_error(deskewed, truth), - "normal_before_vs_truth": full_volume_error(normal, truth), - "normal_after_vs_truth": full_volume_error(normal_reconstructed, truth), - "deskew_vs_normal": comparison_metrics(deskewed, normal_reconstructed), - } - print(json.dumps(metrics), flush=True) - output = directory / ( - "decon_native" if scan_upsample is None else f"decon_upsample{scan_upsample}" - ) - output.mkdir(exist_ok=True) - image_metadata = {"axes": "ZYX"} - for axis in "XYZ": - image_metadata[f"PhysicalSize{axis}"] = pixel - image_metadata[f"PhysicalSize{axis}Unit"] = "µm" - for name, image in ( - ("truth_sampled", truth), - ("normal", normal_reconstructed), - ("deskewed", deskewed), - ): - imwrite( - output / f"{name}.ome.tif", - image, - metadata=image_metadata, - compression="zlib", - ) - imwrite(output / "deconvolved_skewed.tif", reconstructed, compression="zlib") - imwrite(output / "deconvolution_psf_skewed.tif", skewed_psf, compression="zlib") - meta.update( - metrics=metrics, - comparison_stage="deconvolution", - deconvolution={ - "raw_source": str(raw_path.resolve()), - "raw_file_sha256": hashlib.sha256(raw_path.read_bytes()).hexdigest(), - "scan_upsample": scan_upsample, - "reconstruction_step_um": step, - "reconstruction_shape_syx": list(reconstructed.shape), - "counts_per_density": counts_per_density, - "added_noise": False, - "solver": "rlgc" if scan_upsample is None else "rlgc_undersampled", - }, - ) - (output / "simulation.json").write_text( - json.dumps(meta, indent=2), encoding="utf-8" - ) - return metrics - - -def main() -> None: - """Run reconstruction on existing exported data without rerunning acquisition.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("directory", type=Path) - parser.add_argument("--scan-upsample", type=int) - args = parser.parse_args() - deconvolve_saved_simulation(args.directory, args.scan_upsample) - - -if __name__ == "__main__": - main() diff --git a/scripts/opm_simulation.py b/scripts/opm_simulation.py deleted file mode 100644 index f3722da..0000000 --- a/scripts/opm_simulation.py +++ /dev/null @@ -1,680 +0,0 @@ -"""Simulate fluorescence imaging of a hollow sphere made of meridian tubes. - -Construct the object and vectorial PSF on a fine Cartesian grid, form Cartesian -and oblique camera acquisitions at the requested scan spacings, optionally -integrate camera pixels and add shot/read noise, then deskew the oblique data. -Write the fine object and PSFs to --output and save raw acquisitions, sampled -truth, microscope comparisons, and simulation.json in each scan_um folder. -All physical coordinates and spacings are in micrometers. - -Run from the repository root with -``uv run python -m scripts.opm_simulation --output DIR``. -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import numpy as np -from scipy.ndimage import map_coordinates -from scipy.signal import fftconvolve -from tifffile import imwrite - -from opm_processing.imageprocessing.opmtools import orthogonal_deskew -from scripts.psf_sampling_experiment import cartesian_psf, sample_skewed - -if TYPE_CHECKING: - from collections.abc import Sequence - - -def centered_axis(half_extent: float, spacing: float) -> np.ndarray: - """Cover a physical half extent with an odd grid centered on zero. - - Parameters - ---------- - half_extent - Physical distance from the centered grid origin to its edge. - spacing - Physical voxel spacing in ZYX order, in micrometers. - - Returns - ------- - np.ndarray - Odd-length physical coordinate axis centered at zero. - """ - radius = int(np.ceil(half_extent / spacing)) - return np.arange(-radius, radius + 1) * spacing - - -def meridian_sphere( - axes_zyx: Sequence[np.ndarray], - diameter_um: float = 10.0, - tube_diameter_um: float = 1.0, - meridians: int = 12, -) -> np.ndarray: - """Construct uniform fluorescent tubes around meridian great-circle arcs. - - Diameter refers to the sphere traced by the tube centerlines; the outer - diameter includes one tube diameter. An even number of pole-to-pole arcs - forms meridians/2 full circles. Overlapping tubes form a union, not a sum. - - Parameters - ---------- - axes_zyx - Cartesian ZYX physical coordinate arrays, in micrometers. - diameter_um - Diameter of the meridian sphere, in micrometers. - tube_diameter_um - Diameter of each fluorescent meridian tube, in micrometers. - meridians - Number of equally spaced fluorescent meridian tubes. - - Returns - ------- - np.ndarray - Float32 fluorescence density for the union of the meridian tubes. - """ - if not 0 < tube_diameter_um < diameter_um: - raise ValueError("Require 0 < tube diameter < sphere diameter") - if not isinstance(meridians, int) or meridians < 2 or meridians % 2: - raise ValueError("meridians must be a positive even integer of at least 2") - z, y, x = np.meshgrid( - *[a.astype(np.float32) for a in axes_zyx], indexing="ij", sparse=True - ) - fluorescent = np.zeros(tuple(len(a) for a in axes_zyx), dtype=bool) - for phi in np.arange(meridians // 2) * 2 * np.pi / meridians: - perpendicular = -x * np.sin(phi) + y * np.cos(phi) - in_plane = x * np.cos(phi) + y * np.sin(phi) - distance_squared = ( - perpendicular**2 + (np.sqrt(in_plane**2 + z**2) - diameter_um / 2) ** 2 - ) - fluorescent |= distance_squared <= (tube_diameter_um / 2) ** 2 - return fluorescent.astype(np.float32) - - -def resample_grid( - field: np.ndarray, - source_axes: Sequence[np.ndarray], - target_axes: Sequence[np.ndarray], -) -> np.ndarray: - """Sample a uniform rectilinear field without allocating full XYZ meshes. - - Parameters - ---------- - field - Cartesian fluorescence or optical field sampled by the experiment. - source_axes - Physical coordinate arrays associated with the source field. - target_axes - Physical coordinate arrays requested for the output field. - - Returns - ------- - np.ndarray - Interpolated field on the requested physical target grid. - """ - coords = [ - (target - source[0]) / (source[1] - source[0]) - for source, target in zip(source_axes, target_axes, strict=False) - ] - result = np.empty(tuple(len(a) for a in target_axes), dtype=np.float32) - yy, xx = np.meshgrid(coords[1], coords[2], indexing="ij") - for i, zz in enumerate(coords[0]): - result[i] = map_coordinates( - field, - np.stack((np.full_like(yy, zz), yy, xx)), - order=1, - mode="constant", - cval=0.0, - prefilter=False, - ) - return result - - -def pixel_average( - field: np.ndarray, - source_axes: Sequence[np.ndarray], - target_axes: Sequence[np.ndarray], - pitch_um: float, - samples: int = 1, -) -> np.ndarray: - """Average uniformly spaced subpixel centers over a camera pixel's area. - - The first axis is plane index; only the two camera axes are integrated. - samples=1 gives point sampling for the initial geometry validation. Photon - conversion follows averaging, so fine-grid subdivisions do not add photons. - - Parameters - ---------- - field - Cartesian fluorescence or optical field sampled by the experiment. - source_axes - Physical coordinate arrays associated with the source field. - target_axes - Physical coordinate arrays requested for the output field. - pitch_um - Detector pixel pitch used for subpixel area integration, in micrometers. - samples - Quadrature samples per detector pixel along each camera axis. - - Returns - ------- - np.ndarray - Pixel-integrated field values at the target camera sample centers. - """ - if not isinstance(samples, int) or samples < 1: - raise ValueError("samples must be a positive integer") - result = np.zeros(tuple(len(a) for a in target_axes), dtype=np.float32) - offsets = ((np.arange(samples) + 0.5) / samples - 0.5) * pitch_um - for dy in offsets: - for dx in offsets: - result += resample_grid( - field, - source_axes, - (target_axes[0], target_axes[1] + dy, target_axes[2] + dx), - ) - return result / samples**2 - - -def camera_noise( - expected_electrons: np.ndarray, read_noise_e: float = 0.0, seed: int = 42 -) -> np.ndarray: - """Apply Poisson detection then independent Gaussian read noise, in electrons. - - Parameters - ---------- - expected_electrons - Expected detected photon/electron counts before noise. - read_noise_e - Gaussian read-noise standard deviation in electrons. - seed - Random seed for the simulated detection noise. - - Returns - ------- - np.ndarray - Poisson detection samples plus Gaussian read noise. - """ - if read_noise_e < 0 or not np.isfinite(read_noise_e): - raise ValueError("read_noise_e must be nonnegative and finite") - rng = np.random.default_rng(seed) - return ( - rng.poisson(expected_electrons) - + rng.normal(0.0, read_noise_e, np.shape(expected_electrons)) - ).astype(np.float32) - - -def prepare_sphere( - *, - diameter_um: float = 10.0, - tube_diameter_um: float = 1.0, - meridians: int = 12, - fine_spacing_um: float = 0.05, - pixel_size_um: float = 0.115, - fine_scan_step_um: float = 0.1, - angle_deg: float = 30.0, - numerical_aperture: float = 1.35, - wavelength_um: float = 0.637, -) -> dict[str, Any]: - """Construct independent Cartesian and fine-skewed optical convolutions. - - Parameters - ---------- - diameter_um - Diameter of the meridian sphere, in micrometers. - tube_diameter_um - Diameter of each fluorescent meridian tube, in micrometers. - meridians - Number of equally spaced fluorescent meridian tubes. - fine_spacing_um - Isotropic fine Cartesian grid spacing, in micrometers. - pixel_size_um - Camera pixel spacing in micrometers. - fine_scan_step_um - Fine instrument scan spacing for the forward optical model, in micrometers. - angle_deg - Oblique detector-plane angle relative to the coverslip, in degrees. - numerical_aperture - Detection objective numerical aperture. - wavelength_um - Emission wavelength in micrometers. - - Returns - ------- - dict - Ground truth, Cartesian and skew optical fields, PSFs, axes, and simulation parameters. - """ - if not 0 < angle_deg < 90: - raise ValueError("angle_deg must lie between 0 and 90") - if min(fine_spacing_um, pixel_size_um, fine_scan_step_um) <= 0: - raise ValueError("Grid spacings must be positive") - theta = np.deg2rad(angle_deg) - if fine_spacing_um >= pixel_size_um * min(1.0, np.sin(theta)): - raise ValueError( - "Cartesian fine spacing must be below the camera's projected Z sampling" - ) - params = { - "diameter_um": diameter_um, - "tube_diameter_um": tube_diameter_um, - "meridians": meridians, - "fine_spacing_um": fine_spacing_um, - "pixel_size_um": pixel_size_um, - "fine_scan_step_um": fine_scan_step_um, - "angle_deg": angle_deg, - "numerical_aperture": numerical_aperture, - "wavelength_um": wavelength_um, - } - print("Generating fine Cartesian object and vectorial PSF", flush=True) - psf_axes, psf = cartesian_psf( - fine_spacing_um, - (2.4, 1.2, 1.2), - wavelength_um=wavelength_um, - numerical_aperture=numerical_aperture, - ) - psf = psf.astype(np.float32) - half_extent = diameter_um / 2 + tube_diameter_um / 2 + 2.4 - axes = tuple(centered_axis(half_extent, fine_spacing_um) for _ in range(3)) - truth = meridian_sphere(axes, diameter_um, tube_diameter_um, meridians) - print(f"Cartesian convolution: {truth.shape}, {fine_spacing_um:g} um", flush=True) - cartesian_blurred = np.maximum(fftconvolve(truth, psf, mode="same"), 0) - - # The inverse acquisition map is r=Z/sin(theta), s=Y-Z*cot(theta). - # Cover the entire Cartesian box, including optical halo, without cropping - # corners that might be mistaken for a geometric failure. - h = axes[0][-1] - instrument_extents = (h + h / np.tan(theta), h / np.sin(theta), h) - fine_camera_step = pixel_size_um / 2 - fine_axes = tuple( - centered_axis(e, d) - for e, d in zip( - instrument_extents, - (fine_scan_step_um, fine_camera_step, fine_camera_step), - strict=False, - ) - ) - fine_shape = tuple(len(a) for a in fine_axes) - print(f"Sampling object into fine instrument grid: {fine_shape}", flush=True) - skewed_truth = sample_skewed( - truth, - axes, - fine_shape, - pixel_size_um=fine_camera_step, - scan_step_um=fine_scan_step_um, - angle_deg=angle_deg, - ) - pz, py, px = (a[-1] for a in psf_axes) - psf_extents = (py + pz / np.tan(theta), pz / np.sin(theta), px) - skewed_psf_shape = tuple( - len(centered_axis(e, d)) - for e, d in zip( - psf_extents, - (fine_scan_step_um, fine_camera_step, fine_camera_step), - strict=False, - ) - ) - skewed_psf = sample_skewed( - psf, - psf_axes, - skewed_psf_shape, - pixel_size_um=fine_camera_step, - scan_step_um=fine_scan_step_um, - angle_deg=angle_deg, - ) - skewed_psf /= skewed_psf.sum() - print(f"Fine instrument convolution with skewed PSF {skewed_psf.shape}", flush=True) - skewed_blurred = np.maximum(fftconvolve(skewed_truth, skewed_psf, mode="same"), 0) - return { - "params": params, - "axes": axes, - "truth": truth, - "cartesian_blurred": cartesian_blurred, - "psf_axes": psf_axes, - "psf_cartesian": psf, - "fine_axes": fine_axes, - "skewed_psf": skewed_psf, - "skewed_blurred": skewed_blurred, - "instrument_extents": instrument_extents, - } - - -def comparison_metrics(actual: np.ndarray, reference: np.ndarray) -> dict[str, float]: - """Compare absolute density, without fitting a gain or registering images. - - Parameters - ---------- - actual - Image being compared with the known physical reference. - reference - Independent optical image or ground truth on the same physical grid. - - Returns - ------- - dict[str, float] - Image agreement metrics evaluated without fitted alignment or intensity gain. - """ - # Exclude the vast empty background from correlation. - mask = reference > reference.max() * 0.01 - a, b = actual[mask].astype(np.float64), reference[mask].astype(np.float64) - return { - "relative_l2": float(np.linalg.norm(a - b) / np.linalg.norm(b)), - "correlation": float(np.corrcoef(a, b)[0, 1]), - "total_signal_ratio": float( - actual.sum(dtype=np.float64) / reference.sum(dtype=np.float64) - ), - } - - -def acquire_sphere( - prepared: dict[str, Any], - scan_step_um: float = 0.2, - camera_samples: int = 1, - peak_electrons: float | None = None, - background_electrons: float = 0.0, - read_noise_e: float = 0.0, - seed: int = 42, -) -> dict[str, Any]: - """Acquire, optionally integrate/noise, and run unchanged production deskew. - - Parameters - ---------- - prepared - Fine-grid sphere, PSFs, coordinate arrays, and optical fields from prepare_sphere. - scan_step_um - Acquisition displacement between scan planes, in micrometers. - camera_samples - Subpixel integration samples per detector pixel axis. - peak_electrons - Peak expected signal electrons, or None to omit detection noise. - background_electrons - Uniform background electron count added before shot noise. - read_noise_e - Gaussian read-noise standard deviation in electrons. - seed - Random seed for the simulated detection noise. - - Returns - ------- - dict - Raw camera samples, sampled truth, microscope references, deskewed image, and metrics. - """ - p = prepared["params"] - if scan_step_um < p["fine_scan_step_um"]: - raise ValueError("Scan step must be at least the fine instrument scan step") - if peak_electrons is not None and peak_electrons <= 0: - raise ValueError("peak_electrons must be positive") - if background_electrons < 0: - raise ValueError("background_electrons must be nonnegative") - pixel = p["pixel_size_um"] - theta = np.deg2rad(p["angle_deg"]) - raw_axes = tuple( - centered_axis(e, d) - for e, d in zip( - prepared["instrument_extents"], (scan_step_um, pixel, pixel), strict=False - ) - ) - print( - f"Acquiring {scan_step_um:g} um scan, camera samples={camera_samples}", - flush=True, - ) - raw = pixel_average( - prepared["skewed_blurred"], - prepared["fine_axes"], - raw_axes, - pixel, - camera_samples, - ) - # An independent forward route: convolve on the Cartesian grid first, - # then sample at exactly the same raw instrument pixel centers. - raw_reference = np.zeros_like(raw) - offsets = ((np.arange(camera_samples) + 0.5) / camera_samples - 0.5) * pixel - for dy in offsets: - for dx in offsets: - raw_reference += sample_skewed( - prepared["cartesian_blurred"], - prepared["axes"], - raw.shape, - pixel_size_um=pixel, - scan_step_um=scan_step_um, - angle_deg=p["angle_deg"], - center_xyz_um=(dx, dy * np.cos(theta), dy * np.sin(theta)), - ) - raw_reference /= camera_samples**2 - forward_metrics = comparison_metrics(raw, raw_reference) - raw_clean = raw.copy() - raw_electrons = None - gain = None - if peak_electrons is not None: - gain = peak_electrons / float(prepared["cartesian_blurred"].max()) - raw_electrons = camera_noise( - raw * gain + background_electrons, read_noise_e, seed - ) - raw = np.maximum((raw_electrons - background_electrons) / gain, 0) - print("Orthogonal deskew (unchanged production implementation)", flush=True) - deskewed = orthogonal_deskew( - raw, - theta=p["angle_deg"], - distance=scan_step_um, - pixel_size=pixel, - downsample_factor=1, - divisible_by=1, - ) - # The simulator uses density, while deskew has a known 2*pixel/scan gain. - # Undo that scalar for comparison; do not fit brightness to the reference. - deskewed *= scan_step_um / (2 * pixel) - output_axes = ( - np.arange(deskewed.shape[0]) * pixel - raw_axes[1][-1] * np.sin(theta), - np.arange(deskewed.shape[1]) * pixel - - raw_axes[0][-1] - - raw_axes[1][-1] * np.cos(theta), - np.arange(deskewed.shape[2]) * pixel - raw_axes[2][-1], - ) - bounds = [ - np.flatnonzero((a >= prepared["axes"][i][0]) & (a <= prepared["axes"][i][-1])) - for i, a in enumerate(output_axes) - ] - crop = tuple(slice(int(b[0]), int(b[-1]) + 1) for b in bounds) - output_axes = tuple(a[s] for a, s in zip(output_axes, crop, strict=False)) - deskewed = deskewed[crop].copy() - reference = pixel_average( - prepared["cartesian_blurred"], - prepared["axes"], - output_axes, - pixel, - camera_samples, - ) - reference_clean = reference.copy() - if gain is not None: - reference_e = camera_noise( - reference * gain + background_electrons, read_noise_e, seed + 1 - ) - reference = np.maximum((reference_e - background_electrons) / gain, 0) - truth_sampled = resample_grid(prepared["truth"], prepared["axes"], output_axes) - metrics = { - "forward_routes": forward_metrics, - "deskew_vs_normal": comparison_metrics(deskewed, reference), - "deskew_vs_noiseless_normal": comparison_metrics(deskewed, reference_clean), - } - print(json.dumps(metrics), flush=True) - return { - "raw": raw, - "raw_clean": raw_clean, - "raw_electrons": raw_electrons, - "raw_axes": raw_axes, - "output_axes": output_axes, - "deskewed": deskewed, - "normal": reference, - "normal_clean": reference_clean, - "truth_sampled": truth_sampled, - "metrics": metrics, - "settings": { - "scan_step_um": scan_step_um, - "camera_samples": camera_samples, - "peak_electrons": peak_electrons, - "counts_per_density": gain, - "background_electrons": background_electrons, - "read_noise_e": read_noise_e, - "seed": seed, - }, - } - - -def save_simulation( - prepared: dict[str, Any], acquired: dict[str, Any], output: Path -) -> None: - """Write viewable volumes and complete physical axes/parameters, without plots. - - Parameters - ---------- - prepared - Fine-grid sphere, PSFs, coordinate arrays, and optical fields from prepare_sphere. - acquired - Simulated raw frames, optical comparison images, axes, and acquisition settings. - output - Directory or file receiving the generated images and figures. - """ - output = Path(output) - output.mkdir(parents=True, exist_ok=True) - pixel = prepared["params"]["pixel_size_um"] - for name in ("deskewed", "normal", "normal_clean", "truth_sampled"): - imwrite( - output / f"{name}.ome.tif", - acquired[name], - metadata={ - "axes": "ZYX", - "PhysicalSizeX": pixel, - "PhysicalSizeXUnit": "µm", - "PhysicalSizeY": pixel, - "PhysicalSizeYUnit": "µm", - "PhysicalSizeZ": pixel, - "PhysicalSizeZUnit": "µm", - }, - compression="zlib", - ) - # Raw Z is the scan axis, not Cartesian Z; the explicit sidecar records SYX. - imwrite( - output / "raw_skewed.tif", - acquired["raw"], - metadata={"axes": "ZYX"}, - compression="zlib", - ) - if acquired["raw_electrons"] is not None: - imwrite( - output / "raw_electrons.tif", acquired["raw_electrons"], compression="zlib" - ) - - def axes(values: Sequence[np.ndarray]) -> list[dict[str, float | int]]: - """Serialize physical coordinate arrays as axis origin, step, and size records. - - Parameters - ---------- - values - Physical coordinate arrays serialized into origin, step, and size records. - - Returns - ------- - list[dict[str, float | int]] - Axis records describing each physical coordinate array. - """ - return [ - {"origin_um": float(a[0]), "step_um": float(a[1] - a[0]), "size": len(a)} - for a in values - ] - - metadata = { - "parameters": prepared["params"], - "acquisition": acquired["settings"], - "metrics": acquired["metrics"], - "raw_axes_syx": axes(acquired["raw_axes"]), - "output_axes_zyx": axes(acquired["output_axes"]), - "fine_cartesian_axes_zyx": axes(prepared["axes"]), - "fine_instrument_axes_syx": axes(prepared["fine_axes"]), - "cartesian_psf_axes_zyx": axes(prepared["psf_axes"]), - "skewed_psf_axes_syx": [ - {"origin_um": -(n - 1) / 2 * d, "step_um": d, "size": n} - for n, d in zip( - prepared["skewed_psf"].shape, - ( - prepared["params"]["fine_scan_step_um"], - pixel / 2, - pixel / 2, - ), - strict=False, - ) - ], - "fluorescence": "union of tubes, diameter measured at meridian centerlines", - "comparison_gain": "deskew multiplied by scan_step/(2*pixel), no fitted gain", - } - (output / "simulation.json").write_text(json.dumps(metadata, indent=2)) - - -def main() -> None: - """Run the reproducible sphere experiment, exporting each requested scan step.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--scan-steps", type=float, nargs="+", default=[0.2, 0.8]) - parser.add_argument("--fine-spacing", type=float, default=0.05) - parser.add_argument("--fine-scan-step", type=float, default=0.1) - parser.add_argument("--pixel-size", type=float, default=0.115) - parser.add_argument("--angle", type=float, default=30.0) - parser.add_argument("--na", type=float, default=1.35) - parser.add_argument("--wavelength", type=float, default=0.637) - parser.add_argument("--diameter", type=float, default=10.0) - parser.add_argument("--tube-diameter", type=float, default=1.0) - parser.add_argument("--meridians", type=int, default=12) - parser.add_argument("--camera-samples", type=int, default=1) - parser.add_argument("--peak-electrons", type=float) - parser.add_argument("--background-electrons", type=float, default=0.0) - parser.add_argument("--read-noise", type=float, default=0.0) - parser.add_argument("--seed", type=int, default=42) - args = parser.parse_args() - prepared = prepare_sphere( - diameter_um=args.diameter, - tube_diameter_um=args.tube_diameter, - meridians=args.meridians, - fine_spacing_um=args.fine_spacing, - pixel_size_um=args.pixel_size, - fine_scan_step_um=args.fine_scan_step, - angle_deg=args.angle, - numerical_aperture=args.na, - wavelength_um=args.wavelength, - ) - args.output.mkdir(parents=True, exist_ok=True) - fine_metadata = {"axes": "ZYX"} - for axis in "XYZ": - fine_metadata[f"PhysicalSize{axis}"] = args.fine_spacing - fine_metadata[f"PhysicalSize{axis}Unit"] = "µm" - imwrite( - args.output / "ground_truth_fine.tif", - prepared["truth"], - compression="zlib", - ome=True, - metadata=fine_metadata, - ) - imwrite( - args.output / "psf_cartesian_fine.tif", - prepared["psf_cartesian"], - compression="zlib", - ome=True, - metadata=fine_metadata, - ) - imwrite( - args.output / "psf_skewed_fine.tif", prepared["skewed_psf"], compression="zlib" - ) - for step in args.scan_steps: - acquired = acquire_sphere( - prepared, - step, - args.camera_samples, - args.peak_electrons, - args.background_electrons, - args.read_noise, - args.seed, - ) - save_simulation(prepared, acquired, args.output / f"scan_{step:g}um") - - -if __name__ == "__main__": - main() diff --git a/scripts/plot_acquisition_reconstruction_projections.py b/scripts/plot_acquisition_reconstruction_projections.py deleted file mode 100644 index a6eba10..0000000 --- a/scripts/plot_acquisition_reconstruction_projections.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Compare acquisition and reconstructed Cartesian projections at four scan spacings. - -Read the four saved sphere acquisitions supplied by --acquisitions, their -simulation metadata, and native or scan-upsampled deconvolution outputs. -Place zero-filled camera samples on the Cartesian grid and compare their -maximum projections with the final deskewed reconstructions on physical axes. -Write acquisition_reconstruction_mips figures in PNG, SVG, and PDF, an NPZ -of plotted source arrays, and a JSON provenance record to --output. -Requires matplotlib; run as -``uv run --with matplotlib python -m scripts.plot_acquisition_reconstruction_projections --acquisitions DIR1 DIR2 DIR3 DIR4 --output DIR`` -from the repository root. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -from pathlib import Path - -import matplotlib - -matplotlib.use("Agg") -from typing import TYPE_CHECKING - -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.cm import ScalarMappable -from matplotlib.colors import Normalize -from scipy.ndimage import map_coordinates -from tifffile import imread - -from scripts.publication_fixed_plane_series import physical_axes - -if TYPE_CHECKING: - from collections.abc import Sequence - - -def acquisition_projections( - raw: np.ndarray, - axes: Sequence[np.ndarray], - factor: int, - theta: float, - display_axis: np.ndarray, -) -> tuple[tuple[np.ndarray, np.ndarray, np.ndarray], int, int]: - """Map raw cells into Cartesian space by nearest neighbor, preserving zero gaps. - - Parameters - ---------- - raw - Saved oblique camera image in scan, row, column order. - axes - Physical coordinate arrays for the source image, in storage-axis order. - factor - Integer spacing between measured scan planes on the fine reconstruction grid. - theta - Detector plane angle in radians. - display_axis - Physical Cartesian coordinates used for projection voxelization. - - Returns - ------- - tuple - Cartesian maximum projections and counts of measured and zero-filled scan planes. - """ - scans, rows, columns = axes - scan_step = (scans[1] - scans[0]) / factor - assert np.isclose(scan_step, 0.4) - filled = np.zeros(((len(scans) - 1) * factor + 1, *raw.shape[1:]), dtype=raw.dtype) - filled[::factor] = raw - measured = np.arange(filled.shape[0]) % factor == 0 - np.testing.assert_array_equal(filled[measured], raw) - assert not np.any(filled[~measured]) - # Inverse physical map: row = Z/sin(theta), scan = Y-Z*cot(theta), column = X. - # Order zero assigns each Cartesian display sample the value of its raw - # instrument cell. No linear interpolation can fill a missing plane here. - n = len(display_axis) - xy = np.zeros((n, n), dtype=raw.dtype) - xz = np.zeros((n, n), dtype=raw.dtype) - yz = np.zeros((n, n), dtype=raw.dtype) - x_index = (display_axis - columns[0]) / (columns[1] - columns[0]) - for zi, z in enumerate(display_axis): - scan_index = (display_axis - z / np.tan(theta) - scans[0]) / scan_step - row_index = (z / np.sin(theta) - rows[0]) / (rows[1] - rows[0]) - ss, xx = np.meshgrid(scan_index, x_index, indexing="ij") - section = map_coordinates( - filled, - np.stack((ss, np.full_like(ss, row_index), xx)), - order=0, - mode="constant", - cval=0, - prefilter=False, - ) - np.maximum(xy, section, out=xy) - xz[zi] = section.max(axis=0) - yz[zi] = section.max(axis=1) - return (xy, xz, yz), int(measured.sum()), int((~measured).sum()) - - -def main() -> None: - """Render paired acquisition and reconstruction projections for all four steps.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--acquisitions", type=Path, nargs=4, required=True) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - args.output.mkdir(parents=True, exist_ok=True) - plt.rcParams.update({"font.size": 8, "pdf.fonttype": 42, "svg.fonttype": "none"}) - # The saved Cartesian outputs cover approximately +/-7.8 um. Use the same - # complete common physical box for projections of both acquisition and reconstruction. - display_axis = np.arange(-156, 157) * 0.05 - source = {"acquisition_display_axis_um": display_axis} - report = { - "display_method": "Nearest-neighbor physical voxelization, no intensity interpolation", - "acquisition_display_grid_um": 0.05, - "zero_filled_scan_step_um": 0.4, - "physical_projection_box_um": [-7.8, 7.8], - "intensity_limits": [0, 1], - "cases": [], - } - fig, axs = plt.subplots(4, 6, figsize=(15, 10.8), layout="constrained") - orientations = ((0, 2, 1, "XY"), (1, 2, 0, "XZ"), (2, 1, 0, "YZ")) - for row, directory in enumerate(args.acquisitions): - factor = row + 1 - step = 0.4 * factor - meta = json.loads((directory / "simulation.json").read_text()) - assert np.isclose(meta["acquisition"]["scan_step_um"], step) - theta = np.deg2rad(meta["parameters"]["angle_deg"]) - raw = imread(directory / "raw_skewed.tif") - print(f"Cartesian acquisition MIPs for {step:.1f} um", flush=True) - projections, measured, zeros = acquisition_projections( - raw, physical_axes(meta["raw_axes_syx"]), factor, theta, display_axis - ) - decon = directory / ( - "decon_native" if factor == 1 else f"decon_upsample{factor}" - ) - dm = json.loads((decon / "simulation.json").read_text()) - raw_hash = hashlib.sha256( - (directory / "raw_electrons.tif").read_bytes() - ).hexdigest() - assert raw_hash == dm["deconvolution"]["raw_file_sha256"] - assert np.isclose(dm["deconvolution"]["reconstruction_step_um"], 0.4) - recon = imread(decon / "deskewed.ome.tif") - assert np.isfinite(recon).all() - rec_axes = physical_axes(dm["output_axes_zyx"]) - crop = [] - for axis in rec_axes: - keep = np.flatnonzero((axis >= -7.8) & (axis <= 7.8)) - crop.append(slice(int(keep[0]), int(keep[-1]) + 1)) - recon = recon[tuple(crop)] - rec_axes = tuple(a[s] for a, s in zip(rec_axes, crop, strict=False)) - for plane, (collapsed, horizontal, vertical, name) in enumerate(orientations): - before = projections[plane] - after = recon.max(axis=collapsed) - source[f"acquisition_{step:.1f}um_{name}"] = before - source[f"reconstruction_{step:.1f}um_{name}"] = after - for stage, (data, axes) in enumerate( - ((before, (display_axis,) * 3), (after, rec_axes)) - ): - ax = axs[row, plane * 2 + stage] - h, v = axes[horizontal], axes[vertical] - dh, dv = h[1] - h[0], v[1] - v[0] - extent = (h[0] - dh / 2, h[-1] + dh / 2, v[0] - dv / 2, v[-1] + dv / 2) - ax.imshow( - data, - origin="lower", - extent=extent, - cmap="gray", - vmin=0, - vmax=1, - interpolation="nearest", - aspect="equal", - ) - ax.set( - xlim=(-8, 8), - ylim=(-8, 8), - xlabel=f"{'ZYX'[horizontal]} (µm)", - ylabel=f"{'ZYX'[vertical]} (µm)", - ) - ax.set_facecolor("black") - ax.set_xticks([-8, 0, 8]) - ax.set_yticks([-8, 0, 8]) - if row == 0: - ax.set_title( - f"{name}: " - + ("acquisition" if stage == 0 else "reconstruction"), - fontsize=10, - ) - if plane == 0 and stage == 0: - ax.set_ylabel(f"{step:.1f} µm acquisition\nY (µm)", fontsize=9) - for index, axis in enumerate(rec_axes): - source[f"reconstruction_{step:.1f}um_axis{index}_um"] = axis - report["cases"].append( - { - "acquisition": str(directory.resolve()), - "reconstruction": str(decon.resolve()), - "raw_electrons_sha256": raw_hash, - "measured_planes": measured, - "zero_planes": zeros, - } - ) - fig.suptitle( - "Acquisition → reconstruction: Cartesian maximum-intensity projections\n" - "0.4, 0.8, 1.2 and 1.6 µm acquisitions; all GC reconstructions at 0.4 µm, then orthogonal deskew", - fontsize=13, - ) - fig.colorbar( - ScalarMappable(norm=Normalize(0, 1), cmap="gray"), - ax=axs, - shrink=0.55, - label="Fluorescence density (a.u.)", - ticks=[0, 0.5, 1], - ) - for suffix in ("pdf", "svg", "png"): - fig.savefig(args.output / f"acquisition_reconstruction_mips.{suffix}", dpi=600) - plt.close(fig) - np.savez_compressed( - args.output / "acquisition_reconstruction_mips_source.npz", **source - ) - (args.output / "acquisition_reconstruction_mips.json").write_text( - json.dumps(report, indent=2) - ) - print("Finished paired Cartesian MIP figure", flush=True) - - -if __name__ == "__main__": - main() diff --git a/scripts/plot_fixed_objective_simulation.py b/scripts/plot_fixed_objective_simulation.py deleted file mode 100644 index 4237454..0000000 --- a/scripts/plot_fixed_objective_simulation.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Compare saved camera frames with an independently translated sphere model. - -Read an acquisition's simulation.json and raw camera TIFF, regenerate its -Cartesian optical field, and translate that field past a stationary detection -plane. Compare selected pixel-integrated predictions with the saved frames. -Write fixed_objective_frames figures in PNG, SVG, and PDF and a -fixed_objective_check.json error report to --output. Requires matplotlib. -Run from the repository root with -``uv run --with matplotlib python -m scripts.plot_fixed_objective_simulation ACQUISITION --output DIR``. -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.patches import Circle -from scipy.interpolate import RegularGridInterpolator -from tifffile import imread - -from scripts.opm_simulation import pixel_average, prepare_sphere - - -def main() -> None: - """Translate the Cartesian optical field past a stationary detection plane.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("acquisition", type=Path) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - meta = json.loads((args.acquisition / "simulation.json").read_text()) - prepared = prepare_sphere(**meta["parameters"]) - p = prepared["params"] - theta = np.deg2rad(p["angle_deg"]) - pitch = p["pixel_size_um"] - scans, rows, columns = tuple( - a["origin_um"] + np.arange(a["size"]) * a["step_um"] - for a in meta["raw_axes_syx"] - ) - indices = [int(np.argmin(abs(scans - s))) for s in (-8, -4, 0, 4, 8)] - selected = scans[indices] - # Independent route: shift the Cartesian blurred object in laboratory Y. - # Detector points stay fixed for every frame, including all subpixels. - samples = meta["acquisition"]["camera_samples"] - offsets = ((np.arange(samples) + 0.5) / samples - 0.5) * pitch - frames = [] - for s in selected: - shifted_axes = ( - prepared["axes"][0], - prepared["axes"][1] - s, - prepared["axes"][2], - ) - moving_object = RegularGridInterpolator( - shifted_axes, - prepared["cartesian_blurred"], - bounds_error=False, - fill_value=0, - ) - frame = np.zeros((len(rows), len(columns)), dtype=np.float32) - for dr in offsets: - for dx in offsets: - rr, xx = np.meshgrid(rows + dr, columns + dx, indexing="ij") - fixed_detector = np.stack( - (rr * np.sin(theta), rr * np.cos(theta), xx), axis=-1 - ) - frame += moving_object(fixed_detector) - frames.append(frame / samples**2) - frames = np.asarray(frames) - skew_route = pixel_average( - prepared["skewed_blurred"], - prepared["fine_axes"], - (selected, rows, columns), - pitch, - samples, - ) - errors = [] - for s, direct, skew in zip(selected, frames, skew_route, strict=False): - error = float(np.linalg.norm(direct - skew) / np.linalg.norm(direct)) - errors.append({"scan_position_um": float(s), "relative_l2": error}) - raw = imread(args.acquisition / "raw_skewed.tif")[indices] - plt.rcParams.update({"font.size": 8, "pdf.fonttype": 42, "svg.fonttype": "none"}) - fig, axs = plt.subplots(3, 5, figsize=(12, 9), layout="constrained") - rr = np.linspace(-25, 25, 100) - extent = ( - columns[0] - pitch / 2, - columns[-1] + pitch / 2, - rows[0] - pitch / 2, - rows[-1] + pitch / 2, - ) - for col, s in enumerate(selected): - ax = axs[0, col] - ax.add_patch( - Circle((-s, 0), p["diameter_um"] / 2, facecolor="0.9", edgecolor="0.3") - ) - ax.plot(rr * np.cos(theta), rr * np.sin(theta), color="#0072B2", lw=2) - ax.plot(-s, 0, "+", color="0.3") - ax.set( - title=f"Object translation: {-s:.1f} µm", - xlabel="Laboratory Y (µm)", - ylabel="Laboratory Z (µm)", - xlim=(-15, 15), - ylim=(-7, 7), - aspect="equal", - ) - for row, data in ((1, frames[col]), (2, raw[col])): - axs[row, col].imshow( - data, - origin="lower", - extent=extent, - cmap="gray", - vmin=0, - vmax=0.5, - interpolation="nearest", - aspect="equal", - ) - axs[row, col].set( - xlim=(-6, 6), - ylim=(-12, 12), - xlabel="Camera column (µm)", - ylabel="Camera row (µm)", - ) - axs[1, col].set_title("Fixed-plane prediction") - axs[2, col].set_title("Saved noisy camera frame") - fig.suptitle( - "Fixed oblique detection plane; object translates laterally\n" - "Top: sphere outline and stationary plane | Middle/bottom: individual frames, not projections", - fontsize=12, - ) - args.output.mkdir(parents=True, exist_ok=True) - for suffix in ("png", "pdf", "svg"): - fig.savefig(args.output / f"fixed_objective_frames.{suffix}", dpi=300) - plt.close(fig) - report = { - "assumptions": "Stationary oblique plane; lateral Y translation; spatially invariant Cartesian PSF", - "comparison": "Noiseless fixed-plane prediction vs fine-skewed convolution, both pixel-integrated", - "frame_errors": errors, - "display_limits": [0, 0.5], - "scan_positions_um": selected.tolist(), - } - (args.output / "fixed_objective_check.json").write_text( - json.dumps(report, indent=2) - ) - print(json.dumps(report, indent=2), flush=True) - - -if __name__ == "__main__": - main() diff --git a/scripts/plot_opm_plane_geometry.py b/scripts/plot_opm_plane_geometry.py deleted file mode 100644 index 79fc680..0000000 --- a/scripts/plot_opm_plane_geometry.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Show saved zero-filled camera planes in instrument and Cartesian YZ views. - -Read manifest.json and camera_*um_zero_filled.tif from the publication figure -directory. Verify the skew sampler against an analytic camera-plane equation -and draw the same samples in both coordinate systems. Write plane_geometry -figures in PNG, SVG, and PDF and plane_geometry_check.json beside the inputs. -Requires matplotlib; run from the repository root with -``uv run --with matplotlib python -m scripts.plot_opm_plane_geometry DIRECTORY``. -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np -from tifffile import imread - -from scripts.psf_sampling_experiment import sample_skewed - - -def main() -> None: - """Check the sampler's plane equation and plot raw cells at physical positions.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("directory", type=Path) - args = parser.parse_args() - directory = args.directory - manifest = json.loads((directory / "manifest.json").read_text()) - params = manifest["parameters"] - theta = np.deg2rad(params["angle_deg"]) - cotangent = 1 / np.tan(theta) - # A Cartesian field equal to Y-Z*cot(theta) must be constant within each - # camera frame and equal that frame's scan position. Exercise the actual - # sampler, not just the plotting transform, on this analytic field. - axes = tuple(np.linspace(-6, 6, 61) for _ in range(3)) - zz, yy, _xx = np.meshgrid(*axes, indexing="ij", sparse=True) - field = np.broadcast_to(yy - zz * cotangent, (61, 61, 61)).copy() - checks = [] - for step in (0.4, 0.8, 1.2): - values = sample_skewed( - field, - axes, - (5, 9, 7), - pixel_size_um=0.115, - scan_step_um=step, - angle_deg=params["angle_deg"], - ) - expected = np.broadcast_to( - (np.arange(5) - 2)[:, None, None] * step, values.shape - ) - error = float(np.max(np.abs(values - expected))) - np.testing.assert_allclose(values, expected, atol=1e-12, rtol=0) - checks.append({"scan_step_um": step, "maximum_plane_equation_error_um": error}) - source_axes = manifest["camera_display_axes_syx"] - edges = [] - for a in source_axes[:2]: - edges.append(a["origin_um"] + (np.arange(a["size"] + 1) - 0.5) * a["step_um"]) - ss, rr = np.meshgrid(*edges, indexing="ij") - cartesian_y = ss + rr * np.cos(theta) - cartesian_z = rr * np.sin(theta) - np.testing.assert_allclose(cartesian_y - cartesian_z * cotangent, ss, atol=1e-12) - plt.rcParams.update({"font.size": 9, "pdf.fonttype": 42, "svg.fonttype": "none"}) - fig, axs = plt.subplots(2, 3, figsize=(9, 6.8), layout="constrained") - for col, step in enumerate((0.4, 0.8, 1.2)): - raw = imread(directory / f"camera_{step:.1f}um_zero_filled.tif") - side = raw.max(axis=2) - axs[0, col].pcolormesh( - rr, ss, side, cmap="gray", vmin=0, vmax=1, shading="flat", rasterized=True - ) - im = axs[1, col].pcolormesh( - cartesian_y, - cartesian_z, - side, - cmap="gray", - vmin=0, - vmax=1, - shading="flat", - rasterized=True, - ) - axs[0, col].set( - title=f"{step:g} µm acquisition", - xlabel="Camera row r (µm)", - ylabel="Scan position s (µm)", - xlim=(-12, 12), - ylim=(-12, 12), - ) - axs[1, col].set( - xlabel="Cartesian Y (µm)", - ylabel="Cartesian Z (µm)", - xlim=(-6, 6), - ylim=(-6, 6), - ) - for ax in axs[:, col]: - ax.set_aspect("equal") - ax.set_facecolor("black") - fig.suptitle( - "Same camera samples, two coordinate displays\n" - "Top: instrument row–scan | Bottom: Cartesian YZ", - fontsize=12, - ) - fig.colorbar(im, ax=axs, shrink=0.65, label="Fluorescence density (a.u.)") - for suffix in ("png", "pdf", "svg"): - fig.savefig(directory / f"plane_geometry.{suffix}", dpi=400) - plt.close(fig) - report = { - "plane_equation": "Z = tan(theta) * (Y - s)", - "angle_deg": params["angle_deg"], - "side_view_slope": float(np.tan(theta)), - "sampler_checks": checks, - "display": "Affine placement of raw pixel cells; no intensity interpolation", - } - (directory / "plane_geometry_check.json").write_text(json.dumps(report, indent=2)) - print(json.dumps(report, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/scripts/plot_opm_simulation.py b/scripts/plot_opm_simulation.py deleted file mode 100644 index 950f066..0000000 --- a/scripts/plot_opm_simulation.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Plot saved sphere acquisitions or deconvolved microscope comparisons. - -Read simulation.json and truth_sampled, normal, and deskewed OME-TIFFs from the -input directory. Render physical maximum projections and central sections on -common intensity scales, plus a view of the raw oblique camera acquisition. -Write comparison.png, sections.png, and raw_instrument.png beside the inputs. -Requires matplotlib; run from the repository root with -``uv run --with matplotlib python -m scripts.plot_opm_simulation DIRECTORY``. -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np -from tifffile import imread - - -def main() -> None: - """Export common-scale physical projections and an instrument-frame view.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("directory", type=Path) - args = parser.parse_args() - path = args.directory - meta = json.loads((path / "simulation.json").read_text()) - if meta.get("valid_for_gc_comparison") is False: - raise ValueError( - "This GC comparison was withdrawn; use the camera-integrated noisy acquisition" - ) - fields = [ - imread(path / f"{name}.ome.tif") - for name in ("truth_sampled", "normal", "deskewed") - ] - labels = [ - "Ground truth (sampled)", - "Cartesian microscope", - "OPM + orthogonal deskew", - ] - if meta.get("comparison_stage") == "deconvolution": - labels[1] = "Cartesian deconvolution" - labels[2] = "OPM deconvolution + deskew" - axes = meta["output_axes_zyx"] - a = meta["acquisition"] - p = meta["parameters"] - m = meta["metrics"]["deskew_vs_normal"] - plot_radius = (p["diameter_um"] + p["tube_diameter_um"]) / 2 + 0.5 - for sections in (False, True): - fig, axs = plt.subplots(3, 4, figsize=(12, 9), layout="constrained") - for row, (projection_axis, horizontal, vertical, name) in enumerate( - [ - (0, 2, 1, "XY"), - (1, 2, 0, "XZ"), - (2, 1, 0, "YZ"), - ] - ): - section_index = int( - np.argmin( - np.abs( - axes[projection_axis]["origin_um"] - + np.arange(axes[projection_axis]["size"]) - * axes[projection_axis]["step_um"] - ) - ) - ) - projections = [ - np.take(a, section_index, axis=projection_axis) - if sections - else a.max(axis=projection_axis) - for a in fields - ] - h, v = axes[horizontal], axes[vertical] - extent = ( - h["origin_um"] - h["step_um"] / 2, - h["origin_um"] + (h["size"] - 0.5) * h["step_um"], - v["origin_um"] - v["step_um"] / 2, - v["origin_um"] + (v["size"] - 0.5) * v["step_um"], - ) - for col, projection in enumerate(projections): - axs[row, col].imshow( - projection, - origin="lower", - extent=extent, - cmap="gray", - vmin=0, - vmax=1, - ) - axs[row, col].set_title(f"{labels[col]}: {name}", fontsize=10) - # Projection of the absolute volumetric difference; this does not hide - # opposing errors by subtracting two independently maximized images. - difference = np.abs(fields[2] - fields[1]) - error = ( - np.take(difference, section_index, axis=projection_axis) - if sections - else difference.max(axis=projection_axis) - ) - im = axs[row, 3].imshow( - error, origin="lower", extent=extent, cmap="magma", vmin=0, vmax=0.2 - ) - axs[row, 3].set_title( - f"{'Slice' if sections else 'Max'} |OPM − reference|: {name}", - fontsize=10, - ) - for ax in axs[row]: - ax.set( - xlim=(-plot_radius, plot_radius), - ylim=(-plot_radius, plot_radius), - xlabel=f"{'ZYX'[horizontal]} (µm)", - ylabel=f"{'ZYX'[vertical]} (µm)", - ) - fig.colorbar(im, ax=axs[:, 3], shrink=0.7, label="Density difference (0–0.2)") - detection = ( - "noiseless" - if a["peak_electrons"] is None - else f"{a['peak_electrons']:g} peak electrons" - ) - reconstruction = meta.get("deconvolution") - stage_label = ( - f" | GC, reconstruction step {reconstruction['reconstruction_step_um']:g} µm" - if reconstruction - else "" - ) - fig.suptitle( - ("Central sections | " if sections else "Maximum projections | ") - + f"{p['diameter_um']:g} µm meridian sphere, {p['tube_diameter_um']:g} µm tubes | {p['angle_deg']:g}° OPM, {a['scan_step_um']:g} µm scan, " - f"{p['pixel_size_um'] * 1000:g} nm camera\n" - f"NA {p['numerical_aperture']:g}, {p['wavelength_um'] * 1000:g} nm | " - f"camera quadrature {a['camera_samples']}×{a['camera_samples']}, {detection}{stage_label} | " - f"relative error {m['relative_l2']:.1%}, correlation {m['correlation']:.4f}", - fontsize=12, - ) - fig.savefig(path / ("sections.png" if sections else "comparison.png"), dpi=150) - plt.close(fig) - - if not (path / "raw_skewed.tif").exists(): - print(path / "comparison.png") - return - raw = imread(path / "raw_skewed.tif") - scan, camera_y, _ = meta["raw_axes_syx"] - fig, ax = plt.subplots(figsize=(9, 5), layout="constrained") - ax.imshow( - raw.max(axis=2).T, - origin="lower", - cmap="gray", - vmin=0, - vmax=1, - extent=( - scan["origin_um"], - scan["origin_um"] + (scan["size"] - 1) * scan["step_um"], - camera_y["origin_um"], - camera_y["origin_um"] + (camera_y["size"] - 1) * camera_y["step_um"], - ), - ) - ax.set( - xlabel="Scan position (µm)", - ylabel="Camera row position (µm)", - title="Raw OPM: projection over camera columns (instrument coordinates)", - ) - fig.savefig(path / "raw_instrument.png", dpi=150) - print(path / "comparison.png") - - -if __name__ == "__main__": - main() diff --git a/scripts/plot_orthogonal_interpolation.py b/scripts/plot_orthogonal_interpolation.py deleted file mode 100644 index 5e5c2ba..0000000 --- a/scripts/plot_orthogonal_interpolation.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Illustrate the production orthogonal deskew interpolation in vector figures. - -Construct labeled oblique scan and Cartesian voxel diagrams from the deskew -geometry, then verify the illustrated coordinates with orthogonal_deskew on -a synthetic four-sample stencil. Write orthogonal_interpolation figures in PNG, SVG, and PDF -and geometry_check.json to --output. No acquisition files are required. -Requires matplotlib; run from the repository root with -``uv run --with matplotlib python -m scripts.plot_orthogonal_interpolation --output DIR``. -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.lines import Line2D -from matplotlib.patches import Arc, Polygon - -from opm_processing.imageprocessing.opmtools import orthogonal_deskew - -if TYPE_CHECKING: - from collections.abc import Sequence - - from matplotlib.axes import Axes - -BLUE = "#0072B2" -ORANGE = "#D55E00" -GREEN = "#00856A" - - -def arrow( - ax: Axes, - start: Sequence[float], - end: Sequence[float], - color: str = "black", - style: str = "-|>", - **kwargs: Any, -) -> None: - """Draw a vector arrow in diagram coordinates. - - Parameters - ---------- - ax - Matplotlib axes receiving the drawing. - start - Starting physical or diagram coordinates of the arrow. - end - Ending physical or diagram coordinates of the arrow. - color - Matplotlib color for the arrow. - style - Matplotlib arrow style. - """ - ax.annotate( - "", - xy=end, - xytext=start, - arrowprops=dict(arrowstyle=style, color=color, lw=1.5, **kwargs), - ) - - -def project(x: float, y: float, z: float) -> np.ndarray: - """Use an explicit axonometric view preserving the YZ geometry. - - Parameters - ---------- - x - Diagram X coordinate. - y - Diagram Y coordinate. - z - Diagram Z coordinate. - - Returns - ------- - np.ndarray - Two-dimensional diagram coordinates of the projected 3D point. - """ - return np.array([y - 0.75 * x, z + 0.30 * x]) - - -def main() -> None: - """Render plane geometry, the four-sample stencil, and exact implementation rules.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - args.output.mkdir(parents=True, exist_ok=True) - theta = np.deg2rad(30) - tangent = np.array([np.cos(theta), np.sin(theta)]) - normal = np.array([-np.sin(theta), np.cos(theta)]) - spacing, pitch = 3.0, 1.0 # Schematic dimensions, not acquisition settings. - point = np.array([5.0, 2.0]) # (Y,Z), one exact output grid point. - virtual_scan = point[0] - point[1] / np.tan(theta) - feet, fractions, lower = [], [], [] - for si in (0.0, spacing): - vi = np.dot(point - [si, 0], tangent) - qi = np.array([si, 0]) + vi * tangent - np.testing.assert_allclose(np.dot(point - qi, tangent), 0, atol=1e-12) - np.testing.assert_allclose(qi[0] - qi[1] / np.tan(theta), si, atol=1e-12) - feet.append(qi) - lower.append(int(np.floor(vi / pitch))) - fractions.append(vi / pitch - lower[-1]) - assert 0 < virtual_scan < spacing - # Check the shown stencil against the real implementation, including the - # existing scale factor and unchanged camera-column index. - data = np.zeros((4, 16, 3), dtype=np.float32) - neighbor_values = ((10.0, 30.0), (20.0, 50.0)) - for plane, (k, values) in enumerate(zip(lower, neighbor_values, strict=False)): - data[plane, k : k + 2, 1] = values - interpolated = [ - (1 - w) * a + w * b - for w, (a, b) in zip(fractions, neighbor_values, strict=False) - ] - expected = pitch / spacing * sum(interpolated) - result = orthogonal_deskew( - data, - theta=30, - distance=spacing, - pixel_size=pitch, - downsample_factor=1, - divisible_by=1, - ) - np.testing.assert_allclose(result[2, 5, 1], expected, rtol=1e-6) - np.testing.assert_array_equal(result[:, :, [0, 2]], 0) - plt.rcParams.update( - { - "font.family": "DejaVu Sans", - "font.size": 10, - "pdf.fonttype": 42, - "svg.fonttype": "none", - } - ) - fig = plt.figure(figsize=(12.8, 6.0), facecolor="white") - gs = fig.add_gridspec( - 1, - 2, - left=0.04, - right=0.98, - bottom=0.20, - top=0.91, - wspace=0.10, - hspace=0.12, - ) - ax = fig.add_subplot(gs[0, 0]) - for si, color, label in ( - (spacing, ORANGE, r"$\Pi_{i+1}$"), - (0.0, BLUE, r"$\Pi_i$"), - ): - - def location(u: float, v: float) -> np.ndarray: - """Map plane-basis coordinates to the illustrated detector plane. - - Coordinates are evaluated before the scan-position loop advances. - - Parameters - ---------- - u - First coordinate along the illustrated plane basis. - v - Second coordinate along the illustrated plane basis. - - Returns - ------- - numpy.ndarray - Physical diagram coordinates of the point on the plane. - """ - return project(u, si + v * np.cos(theta), v * np.sin(theta)) # noqa: B023 - - corners = [location(u, v) for u, v in ((0, 0), (3.4, 0), (3.4, 7.2), (0, 7.2))] - ax.add_patch( - Polygon(corners, facecolor=color, alpha=0.17, edgecolor=color, lw=1.8) - ) - for u in np.linspace(0, 3.4, 6): - line = np.asarray([location(u, v) for v in (0, 7.2)]) - ax.plot(*line.T, color=color, lw=0.55, alpha=0.55) - for v in np.arange(0, 7.3, 0.9): - line = np.asarray([location(u, v) for u in (0, 3.4)]) - ax.plot(*line.T, color=color, lw=0.55, alpha=0.55) - edge = np.asarray([location(0, 0), location(0, 7.2)]) - ax.plot(*edge.T, color=color, lw=2) - top = location(0, 7.2) - ax.text(top[0] - 0.8, top[1] + 0.25, label, color=color, fontsize=15) - origin = project(0, 0, 0) - arrow(ax, origin, project(4.2, 0, 0)) - ax.text(-3.0, 1.7, "$X=u$\n(µm)", ha="center", fontsize=12) - arrow(ax, origin, project(0, 0, 4.8)) - ax.text(-0.2, 4.8, "$Z$ (µm)", ha="right", fontsize=12) - arrow(ax, (0, -1.0), (9.0, -1.0)) - ax.text( - 7.0, - -1.45, - "Lab $Y$ (µm)\nScan displacement $s$ (µm)", - ha="center", - va="top", - fontsize=10, - ) - arrow(ax, (0, -0.45), (spacing, -0.45), style="<->") - ax.text(spacing / 2, -0.25, r"$\Delta s$", ha="center", fontsize=12) - ax.text(0, -1.5, r"$s_i$", ha="center") - ax.text(spacing, -1.5, r"$s_{i+1}$", ha="center") - arrow(ax, 0.8 * tangent, 4.8 * tangent, BLUE) - ax.text(2.7, 1.8, "$v$ (µm)", rotation=30, color=BLUE, fontsize=11) - ax.add_patch(Arc((0, 0), 2.2, 2.2, theta1=0, theta2=30, lw=1)) - ax.text(1.18, 0.35, r"$\theta$") - ax.set(xlim=(-4.0, 10), ylim=(-2.8, 5.4), aspect="equal") - ax.set_anchor("N") - ax.axis("off") - ax.set_title("A", loc="left", fontsize=16, weight="bold", pad=12) - ax = fig.add_subplot(gs[0, 1]) - lab_y, lab_z = np.meshgrid(np.arange(2, 8.1), np.arange(0, 4.5)) - other_points = (lab_y != point[0]) | (lab_z != point[1]) - ax.scatter( - lab_y[other_points], - lab_z[other_points], - s=16, - marker="o", - color="0.77", - edgecolors="none", - zorder=0, - ) - selected_pairs = [ - np.array([si, 0]) + np.arange(k, k + 2)[:, None] * pitch * tangent - for si, k in zip((0, spacing), lower, strict=False) - ] - # Outline the actual four contributing samples. The connector edges need - # not be perpendicular: orthogonality refers to P's projections onto planes. - corners = np.array( - [ - selected_pairs[0][0], - selected_pairs[0][1], - selected_pairs[1][1], - selected_pairs[1][0], - ] - ) - ax.add_patch( - Polygon( - corners, - closed=True, - facecolor=GREEN, - alpha=0.10, - edgecolor="none", - zorder=1, - ) - ) - outline = np.vstack((corners, corners[0])) - ax.plot(*outline.T, color="0.40", lw=1.2, zorder=2) - for pixel in corners: - ax.plot( - *np.array([pixel, point]).T, color="0.60", lw=0.9, linestyle=":", zorder=2 - ) - for j, (si, color) in enumerate(((0, BLUE), (spacing, ORANGE))): - t = np.linspace(0, 9, 2) - line = np.array([si, 0])[:, None] + tangent[:, None] * t - ax.plot(*line, color=color, lw=2) - v = np.arange(0, 10) * pitch - points = np.array([si, 0])[:, None] + tangent[:, None] * v - ax.scatter(*points, s=20, color=color, zorder=3) - selected = points[:, lower[j] : lower[j] + 2] - ax.plot(*selected, color=color, lw=3.0, zorder=4) - ax.scatter(*selected, s=85, color=color, edgecolor="white", zorder=5) - qi = feet[j] - ax.scatter(*qi, s=65, facecolor="white", edgecolor=color, lw=1.7, zorder=6) - toward = (point - qi) / np.linalg.norm(point - qi) - square = np.array( - [qi + 0.16 * tangent, qi + 0.16 * (tangent + toward), qi + 0.16 * toward] - ) - ax.plot(*square.T, color=GREEN, lw=1.8, zorder=6) - ax.annotate( - "", - xy=point, - xytext=qi, - arrowprops={"arrowstyle": "-|>", "color": GREEN, "lw": 2.1}, - zorder=5, - ) - ax.scatter(*point, marker="*", s=165, color="black", zorder=7) - ax.text(5.35, 2.08, r"$P=(X,Y,Z)$", fontsize=12) - ax.annotate( - r"$Q_i$", - xy=feet[0], - xytext=(3.65, 3.3), - color=BLUE, - arrowprops={"arrowstyle": "-", "color": BLUE}, - ) - ax.annotate( - r"$Q_{i+1}$", - xy=feet[1], - xytext=(6.15, 0.85), - color=ORANGE, - arrowprops={"arrowstyle": "-", "color": ORANGE}, - ) - for text, xy, at, color in ( - (r"$I_i[k_i]$", 5 * tangent, (3.0, 2.25), BLUE), - (r"$I_i[k_i+1]$", 6 * tangent, (5.35, 3.4), BLUE), - ( - r"$I_{i+1}[k_{i+1}]$", - np.array([spacing, 0]) + 2 * tangent, - (3.05, 0.55), - ORANGE, - ), - ( - r"$I_{i+1}[k_{i+1}+1]$", - np.array([spacing, 0]) + 3 * tangent, - (6.15, 1.65), - ORANGE, - ), - ): - ax.annotate( - text, - xy=xy, - xytext=at, - color=color, - fontsize=10, - arrowprops={"arrowstyle": "-", "color": color, "lw": 0.8}, - ) - ax.text(2.2, 4.05, "$X=u$ (µm)", fontsize=11) - ax.text(7.35, 2.2, r"$\Pi_{i+1}$", color=ORANGE, fontsize=13) - ax.text(6.3, 4.0, r"$\Pi_i$", color=BLUE, fontsize=13) - ax.set( - xlim=(2, 8), - ylim=(0, 4.4), - xlabel="Lab $Y$ (µm)", - ylabel="Lab $Z$ (µm)", - aspect="equal", - ) - ax.set_xticks([]) - ax.set_yticks([]) - ax.spines[["top", "right"]].set_visible(False) - ax.set_title("B", loc="left", fontsize=16, weight="bold", pad=12) - fig.legend( - handles=[ - Line2D([], [], color=BLUE, marker="o", label=r"Plane $\Pi_i$ and pixels"), - Line2D( - [], [], color=ORANGE, marker="o", label=r"Plane $\Pi_{i+1}$ and pixels" - ), - Line2D([], [], color=GREEN, lw=2, label="Orthogonal projection"), - Line2D( - [], - [], - color="0.77", - marker="o", - linestyle="none", - label="Other lab-grid points", - ), - Line2D( - [], - [], - color="black", - marker="*", - markersize=11, - linestyle="none", - label=r"Target $P$", - ), - ], - loc="lower center", - bbox_to_anchor=(0.5, 0.045), - ncol=5, - frameon=False, - fontsize=9, - ) - for suffix in ("pdf", "svg", "png"): - fig.savefig(args.output / f"orthogonal_interpolation.{suffix}", dpi=600) - plt.close(fig) - report = { - "plane_indices": [0, 1], - "target_yz": point.tolist(), - "virtual_scan": virtual_scan, - "projected_points_yz": np.asarray(feet).tolist(), - "lower_row_indices": lower, - "row_fractions": fractions, - "interpolation_values": interpolated, - "expected_output": expected, - "production_output": float(result[2, 5, 1]), - "untouched_columns_zero": True, - "schematic_pitch": pitch, - "schematic_scan_step": spacing, - "plane_normal_yz": normal.tolist(), - "production_scale": "p / delta_s times the sum of the two within-plane interpolants", - } - (args.output / "geometry_check.json").write_text(json.dumps(report, indent=2)) - print(json.dumps(report, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/scripts/psf_sampling_experiment.py b/scripts/psf_sampling_experiment.py deleted file mode 100644 index 326cb4f..0000000 --- a/scripts/psf_sampling_experiment.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Provide Cartesian optical fields and skew sampling for simulation scripts. - -cartesian_psf builds a finite-support vectorial PSF on a Cartesian grid using -the silicone objective model. sample_skewed interpolates a Cartesian field at -oblique camera-pixel centers for the supplied scan spacing and plane angle. -Coordinates are in micrometers. These helpers perform no file I/O and exclude -illumination, pixel-area integration, noise, motion, and empirical apodization; -the sphere simulation adds camera integration and noise separately. -This module is imported by the experiment scripts and has no CLI. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy as np -import psfmodels -from scipy.interpolate import RegularGridInterpolator - -if TYPE_CHECKING: - from collections.abc import Sequence - - -def cartesian_psf( - spacing_um: float, - half_extent_zyx_um: Sequence[float] = (1.5, 0.9, 0.9), - *, - wavelength_um: float = 0.637, - numerical_aperture: float = 1.35, - depth_um: float = 0.0, -) -> tuple[tuple[np.ndarray, np.ndarray, np.ndarray], np.ndarray]: - """Return centered physical ZYX axes and a normalized fine optical PSF. - - The grid covers the requested physical extent at the supplied isotropic - spacing, with an odd number of samples on each axis. XY extents must match - because the optical library evaluates a square detector field. Do not - change the physical extent when testing convergence with finer spacing. - - Parameters - ---------- - spacing_um : float - Isotropic Cartesian PSF grid spacing, in micrometers. - half_extent_zyx_um - PSF half-extents in ZYX order, in micrometers. - wavelength_um : float - Emission wavelength in micrometers. - numerical_aperture : float - Detection objective numerical aperture. - depth_um : float - Emitter height above the coverslip, in micrometers. - - Returns - ------- - tuple - Centered ZYX physical coordinate axes and normalized vectorial PSF samples. - """ - extent = np.asarray(half_extent_zyx_um, dtype=float) - if ( - not np.isfinite(spacing_um) - or spacing_um <= 0 - or extent.shape != (3,) - or not np.isfinite(extent).all() - or np.any(extent <= 0) - or extent[1] != extent[2] - ): - raise ValueError("Require positive spacing and ZYX extents with equal XY") - if not np.isfinite(wavelength_um) or wavelength_um <= 0: - raise ValueError("wavelength_um must be positive and finite") - if not np.isfinite(depth_um) or depth_um < 0: - raise ValueError("depth_um must be nonnegative and finite") - if not np.isfinite(numerical_aperture) or not 0 < numerical_aperture <= 1.4: - raise ValueError( - "numerical_aperture must be positive and at most the immersion RI 1.4" - ) - radii = np.ceil(extent / spacing_um).astype(int) - axes = tuple(np.arange(-r, r + 1) * spacing_um for r in radii) - psf = psfmodels.vectorial_psf( - zv=axes[0] + depth_um, - nx=axes[2].size, - dxy=spacing_um, - pz=depth_um, - wvl=wavelength_um, - params={ - "NA": numerical_aperture, - "ni0": 1.4, - "ni": 1.4, - "ns": 1.38, - "tg0": 170, - "tg": 170, - "ti0": 300, - }, - ) - psf = np.asarray(psf, dtype=np.float64) - psf /= psf.sum() - return axes, psf - - -def sample_skewed( - field: np.ndarray, - axes_zyx_um: Sequence[np.ndarray], - shape_syx: Sequence[int], - *, - pixel_size_um: float = 0.115, - scan_step_um: float = 0.8, - angle_deg: float = 30.0, - center_xyz_um: Sequence[float] = (0.0, 0.0, 0.0), -) -> np.ndarray: - """Trilinearly sample a Cartesian image at physical raw pixel centers. - - Output order is scan, camera-row, camera-column, centered at the given XYZ - coordinate. Both odd and even shapes retain their physical half-pixel - centers. Samples outside the provided Cartesian field are explicitly zero. - No intensity normalization or voxel-volume conversion is applied: the - returned values have the same units as the input field. - - Parameters - ---------- - field : np.ndarray - Cartesian fluorescence or optical field sampled by the experiment. - axes_zyx_um - Cartesian ZYX coordinate arrays for the source field, in micrometers. - shape_syx - Requested scan, camera-row, camera-column sampling dimensions. - pixel_size_um : float - Camera pixel spacing in micrometers. - scan_step_um : float - Acquisition displacement between scan planes, in micrometers. - angle_deg : float - Oblique detector-plane angle relative to the coverslip, in degrees. - center_xyz_um - Physical XYZ center of the requested camera sampling grid. - - Returns - ------- - np.ndarray - Trilinear field samples at physical scan/row/column camera centers. - """ - shape = tuple(shape_syx) - if len(shape) != 3 or any( - isinstance(n, bool) or not isinstance(n, (int, np.integer)) or n < 1 - for n in shape - ): - raise ValueError("shape_syx must contain three positive integers") - if ( - not np.isfinite(pixel_size_um) - or pixel_size_um <= 0 - or not np.isfinite(scan_step_um) - or scan_step_um <= 0 - or not np.isfinite(angle_deg) - or not 0 < angle_deg < 90 - ): - raise ValueError("Require positive sampling and an angle between 0 and 90") - center = np.asarray(center_xyz_um, dtype=float) - if center.shape != (3,) or not np.isfinite(center).all(): - raise ValueError("center_xyz_um must contain three finite coordinates") - theta = np.deg2rad(angle_deg) - scan = (np.arange(shape[0]) - (shape[0] - 1) / 2) * scan_step_um - row = (np.arange(shape[1]) - (shape[1] - 1) / 2) * pixel_size_um - col = (np.arange(shape[2]) - (shape[2] - 1) / 2) * pixel_size_um - interpolate = RegularGridInterpolator( - axes_zyx_um, - field, - method="linear", - bounds_error=False, - fill_value=0.0, - ) - result = np.empty( - shape, dtype=np.float32 if field.dtype == np.float32 else np.float64 - ) - # A fine sphere can contain millions of camera samples. Bound temporary - # coordinate arrays while keeping the same trilinear interpolation. - for start in range(0, shape[0], 8): - end = min(start + 8, shape[0]) - z, y, x = np.broadcast_arrays( - center[2] + row[None, :, None] * np.sin(theta), - center[1] - + scan[start:end, None, None] - + row[None, :, None] * np.cos(theta), - center[0] + col[None, None, :], - ) - result[start:end] = interpolate(np.stack((z, y, x), axis=-1)) - return result diff --git a/scripts/publication_fixed_plane_series.py b/scripts/publication_fixed_plane_series.py deleted file mode 100644 index a3b0531..0000000 --- a/scripts/publication_fixed_plane_series.py +++ /dev/null @@ -1,455 +0,0 @@ -"""Generate fixed-objective frame figures for four sphere scan spacings. - -Read saved 0.4, 0.8, 1.2, and 1.6 micrometer acquisitions and their deconvolution -outputs, plus the fine Cartesian truth and PSF from the acquisition parent. -Independently convolve the object and sample a fixed detection plane to compare -acquired frames, reconstructions on a 0.4 micrometer scan grid, and missing-plane -patterns. Write PNG, SVG, and PDF figures, per-spacing NPZ source arrays, and -manifest.json provenance to --output. Requires matplotlib; run as -``uv run --with matplotlib python -m scripts.publication_fixed_plane_series --acquisitions DIR1 DIR2 DIR3 DIR4 --output DIR`` -from the repository root. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -from pathlib import Path - -import matplotlib - -matplotlib.use("Agg") -from typing import TYPE_CHECKING - -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.patches import Circle -from scipy.interpolate import RegularGridInterpolator -from scipy.signal import fftconvolve -from tifffile import imread - -if TYPE_CHECKING: - from collections.abc import Sequence - - from matplotlib.axes import Axes - from matplotlib.figure import Figure - from matplotlib.image import AxesImage - - -def physical_axes(metadata: Sequence[dict[str, float]]) -> tuple[np.ndarray, ...]: - """Expand recorded coordinates to physical voxel centers. - - Parameters - ---------- - metadata - Saved axis records containing coordinate origin, step, and sample count. - - Returns - ------- - tuple[np.ndarray, ...] - Physical coordinate arrays reconstructed from the saved axis records. - """ - return tuple(a["origin_um"] + np.arange(a["size"]) * a["step_um"] for a in metadata) - - -def fixed_plane_frames( - field: np.ndarray, - axes: Sequence[np.ndarray], - scans: np.ndarray, - rows: np.ndarray, - columns: np.ndarray, - theta: float, - pitch: float, - samples: int = 1, -) -> np.ndarray: - """Translate a Cartesian field past a fixed detector, optionally averaging pixels. - - Parameters - ---------- - field - Cartesian fluorescence or optical field sampled by the experiment. - axes - Physical coordinate arrays for the source image, in storage-axis order. - scans - Selected scan-plane positions in micrometers. - rows - Detector-row center coordinates in micrometers. - columns - Detector-column center coordinates in micrometers. - theta - Detector plane angle in radians. - pitch - Detector pixel pitch in micrometers. - samples - Quadrature samples per detector pixel along each camera axis. - - Returns - ------- - np.ndarray - Pixel-integrated detector frames for the selected fixed-plane translations. - """ - frames = [] - offsets = ((np.arange(samples) + 0.5) / samples - 0.5) * pitch - for scan in scans: - interpolate = RegularGridInterpolator( - (axes[0], axes[1] - scan, axes[2]), field, bounds_error=False, fill_value=0 - ) - frame = np.zeros((len(rows), len(columns)), dtype=np.float32) - for dr in offsets: - for dx in offsets: - rr, xx = np.meshgrid(rows + dr, columns + dx, indexing="ij") - frame += interpolate( - np.stack((rr * np.sin(theta), rr * np.cos(theta), xx), axis=-1) - ) - frames.append(frame / samples**2) - return np.asarray(frames) - - -def diagram(ax: Axes, scan: float, theta: float, radius: float = 5) -> None: - """Show a stationary plane while the object translates in laboratory Y. - - Parameters - ---------- - ax - Matplotlib axes receiving the drawing. - scan - Object translation along the acquisition scan axis, in micrometers. - theta - Detector plane angle in radians. - radius - Sphere radius displayed in the geometry sketch, in micrometers. - """ - displacement = 0.0 if abs(scan) < 1e-8 else -scan - ax.add_patch(Circle((displacement, 0), radius, facecolor="0.9", edgecolor="0.35")) - rr = np.array([-25, 25]) - ax.plot(rr * np.cos(theta), rr * np.sin(theta), color="#0072B2", lw=1.5) - ax.plot(displacement, 0, "+", color="0.35") - ax.set( - title=f"Object Y = {displacement:.1f} µm", - xlim=(-18, 18), - ylim=(-9, 9), - xlabel="Laboratory Y (µm)", - ylabel="Z (µm)", - aspect="equal", - ) - - -def camera_panel( - ax: Axes, frame: np.ndarray, axes: Sequence[np.ndarray], title: str, vmax: float -) -> AxesImage: - """Render one frame with physical coordinates and unobstructed image pixels. - - Parameters - ---------- - ax - Matplotlib axes receiving the drawing. - frame - Camera frame record containing event indices and instrument metadata. - axes - Physical coordinate arrays for the source image, in storage-axis order. - title - Title displayed above the camera panel. - vmax - Upper image display intensity; the lower limit is zero. - - Returns - ------- - matplotlib.image.AxesImage - Displayed camera image artist with physical row and column coordinates. - """ - rows, columns = axes - pitch = columns[1] - columns[0] - extent = ( - columns[0] - pitch / 2, - columns[-1] + pitch / 2, - rows[0] - pitch / 2, - rows[-1] + pitch / 2, - ) - im = ax.imshow( - frame, - origin="lower", - extent=extent, - cmap="gray", - vmin=0, - vmax=vmax, - interpolation="nearest", - aspect="equal", - ) - ax.set( - xlim=(-8, 8), - ylim=(-16, 16), - xlabel="Column (µm)", - ylabel="Row (µm)", - title=title, - ) - ax.set_facecolor("black") - return im - - -def save(fig: Figure, directory: Path, stem: str) -> None: - """Export the same layout with vector labels and publication-resolution pixels. - - Parameters - ---------- - fig - Matplotlib figure to write and close. - directory - Output directory for the figure files. - stem - Output filename stem without the figure extension. - """ - for extension in ("pdf", "svg", "png"): - fig.savefig(directory / f"{stem}.{extension}", dpi=600) - plt.close(fig) - - -def main() -> None: - """Prepare matching acquisition, reconstruction and missing-plane comparisons.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--acquisitions", type=Path, nargs=4, required=True) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - args.output.mkdir(parents=True, exist_ok=True) - plt.rcParams.update( - { - "font.size": 8, - "axes.titlesize": 9, - "pdf.fonttype": 42, - "svg.fonttype": "none", - } - ) - metas = [json.loads((d / "simulation.json").read_text()) for d in args.acquisitions] - p = metas[0]["parameters"] - theta = np.deg2rad(p["angle_deg"]) - pitch = p["pixel_size_um"] - truth = imread(args.acquisitions[0].parent / "ground_truth_fine.tif") - psf = imread(args.acquisitions[0].parent / "psf_cartesian_fine.tif") - print( - "Independent Cartesian optical convolution for fixed-plane predictions", - flush=True, - ) - blurred = np.maximum(fftconvolve(truth, psf, mode="same"), 0) - fine_axes = physical_axes(metas[0]["fine_cartesian_axes_zyx"]) - common_scans = np.array([-10.4, -8, -4, 0, 4, 8, 10.4], dtype=float) - consecutive_scans = np.arange(-4, 5) * 0.4 - manifest = { - "parameters": p, - "reconstruction_step_um": 0.4, - "cases": [], - "reconstruction_display_scan_positions_um": common_scans.tolist(), - "consecutive_scan_positions_um": consecutive_scans.tolist(), - "camera_display_limits": [0, 0.5], - "reconstruction_display_limits": [0, 1], - } - overviews = [] - for factor, (directory, meta) in enumerate( - zip(args.acquisitions, metas, strict=False), 1 - ): - step = 0.4 * factor - assert meta["parameters"] == p - assert np.isclose(meta["acquisition"]["scan_step_um"], step) - for key in ( - "camera_samples", - "peak_electrons", - "background_electrons", - "read_noise_e", - "seed", - ): - assert meta["acquisition"][key] == metas[0]["acquisition"][key] - scans, rows, columns = physical_axes(meta["raw_axes_syx"]) - raw = imread(directory / "raw_skewed.tif") - indices = np.asarray([np.argmin(abs(scans - s)) for s in common_scans]) - selected_scans = scans[indices] - predictions = fixed_plane_frames( - blurred, - fine_axes, - selected_scans, - rows, - columns, - theta, - pitch, - meta["acquisition"]["camera_samples"], - ) - print(f"Rendering {step:.1f} µm acquisition and reconstruction", flush=True) - fig, axs = plt.subplots( - 3, len(common_scans), figsize=(16, 9), layout="constrained" - ) - for col, s in enumerate(selected_scans): - diagram(axs[0, col], s, theta) - camera_panel( - axs[1, col], - predictions[col], - (rows, columns), - "Fixed-plane prediction", - 0.5, - ) - im = camera_panel( - axs[2, col], - raw[indices[col]], - (rows, columns), - "Measured noisy frame", - 0.5, - ) - fig.colorbar(im, ax=axs[1:, :], shrink=0.5, label="Fluorescence density (a.u.)") - fig.suptitle( - f"{step:.1f} µm acquisition: fixed oblique plane, translating object\n" - "Seven acquired frames across the sphere; actual positions labeled", - fontsize=12, - ) - save(fig, args.output, f"acquisition_{step:.1f}um") - decon_dir = directory / ( - "decon_native" if factor == 1 else f"decon_upsample{factor}" - ) - dm = json.loads((decon_dir / "simulation.json").read_text()) - assert np.isclose(dm["deconvolution"]["reconstruction_step_um"], 0.4) - raw_hash = hashlib.sha256( - (directory / "raw_electrons.tif").read_bytes() - ).hexdigest() - assert raw_hash == dm["deconvolution"]["raw_file_sha256"] - rec = imread(decon_dir / "deconvolved_skewed.tif") - assert np.isfinite(rec).all() - rec_indices = np.rint((common_scans - scans[0]) / 0.4).astype(int) - np.testing.assert_allclose( - scans[0] + rec_indices * 0.4, common_scans, atol=1e-9 - ) - assert rec.shape[0] == (len(scans) - 1) * factor + 1 - assert rec.shape[1:] == raw.shape[1:] - reference = imread(decon_dir / "normal.ome.tif") - deskewed = imread(decon_dir / "deskewed.ome.tif") - cart_axes = physical_axes(dm["output_axes_zyx"]) - reference_frames = fixed_plane_frames( - reference, cart_axes, common_scans, rows, columns, theta, pitch - ) - deskewed_frames = fixed_plane_frames( - deskewed, cart_axes, common_scans, rows, columns, theta, pitch - ) - acquired = np.array([np.min(abs(scans - s)) < 1e-8 for s in common_scans]) - fig, axs = plt.subplots( - 4, len(common_scans), figsize=(16, 12), layout="constrained" - ) - for col, s in enumerate(common_scans): - diagram(axs[0, col], s, theta) - camera_panel( - axs[1, col], - reference_frames[col], - (rows, columns), - "Cartesian GC reference", - 1, - ) - status = "acquired position" if acquired[col] else "unmeasured position" - camera_panel( - axs[2, col], - rec[rec_indices[col]], - (rows, columns), - f"OPM GC: {status}", - 1, - ) - im = camera_panel( - axs[3, col], - deskewed_frames[col], - (rows, columns), - "After orthogonal deskew*", - 1, - ) - fig.colorbar(im, ax=axs[1:, :], shrink=0.5, label="Fluorescence density (a.u.)") - fig.suptitle( - f"{step:.1f} → 0.4 µm reconstruction: same fixed-plane positions\n" - "*Cartesian result re-sliced onto the oblique plane for frame comparison", - fontsize=12, - ) - save(fig, args.output, f"reconstruction_{step:.1f}_to_0.4um") - consecutive_raw = np.zeros( - (len(consecutive_scans), *raw.shape[1:]), dtype=np.float32 - ) - measured = np.zeros(len(consecutive_scans), dtype=bool) - for j, s in enumerate(consecutive_scans): - ix = int(np.argmin(abs(scans - s))) - if abs(scans[ix] - s) < 1e-8: - consecutive_raw[j] = raw[ix] - measured[j] = True - ix = np.rint((consecutive_scans - scans[0]) / 0.4).astype(int) - np.testing.assert_allclose(scans[0] + ix * 0.4, consecutive_scans, atol=1e-9) - consecutive_ref = fixed_plane_frames( - reference, cart_axes, consecutive_scans, rows, columns, theta, pitch - ) - overviews.append( - { - "step": step, - "raw": consecutive_raw, - "measured": measured, - "rec": rec[ix], - "reference": consecutive_ref, - "axes": (rows, columns), - } - ) - np.savez_compressed( - args.output / f"frame_source_{step:.1f}um.npz", - acquired_scan_positions_um=selected_scans, - fixed_plane_predictions=predictions, - acquired_frames=raw[indices], - reconstruction_scan_positions_um=common_scans, - reference_frames=reference_frames, - reconstructed_frames=rec[rec_indices], - deskewed_resliced_frames=deskewed_frames, - acquired_position_mask=acquired, - camera_rows_um=rows, - camera_columns_um=columns, - consecutive_scan_positions_um=consecutive_scans, - consecutive_raw=consecutive_raw, - consecutive_acquired_mask=measured, - consecutive_reconstruction=rec[ix], - consecutive_reference=consecutive_ref, - ) - manifest["cases"].append( - { - "acquisition": str(directory.resolve()), - "reconstruction": str(decon_dir.resolve()), - "scan_step_um": step, - "acquired_planes": len(scans), - "reconstructed_planes": rec.shape[0], - "acquired_scan_positions_um": selected_scans.tolist(), - "raw_electrons_sha256": raw_hash, - "metrics": dm["metrics"], - } - ) - for stage in ("raw", "rec"): - fig, axs = plt.subplots(5, 9, figsize=(18, 12), layout="constrained") - for col, s in enumerate(consecutive_scans): - diagram(axs[0, col], s, theta) - for row, case in enumerate(overviews, 1): - for col, _scan in enumerate(consecutive_scans): - missing = not case["measured"][col] - status = "measured" if not missing else "inferred" - im = camera_panel( - axs[row, col], - case[stage][col], - case["axes"], - f"{case['step']:.1f} µm" - if stage == "raw" - else f"{case['step']:.1f} µm: {status}", - 0.5 if stage == "raw" else 1, - ) - fig.colorbar(im, ax=axs[1:, :], shrink=0.5, label="Fluorescence density (a.u.)") - fig.suptitle( - ( - "Acquisition: unmeasured positions are shown as zeros" - if stage == "raw" - else "GC reconstruction: measured and inferred scan positions" - ) - + "\nConsecutive 0.4 µm scan positions; fixed oblique plane, translating sphere", - fontsize=13, - ) - save( - fig, - args.output, - "consecutive_acquisitions" - if stage == "raw" - else "consecutive_reconstructions", - ) - (args.output / "manifest.json").write_text(json.dumps(manifest, indent=2)) - print(f"Figures and source data written to {args.output.resolve()}", flush=True) - - -if __name__ == "__main__": - main() diff --git a/scripts/publication_opm_simulation.py b/scripts/publication_opm_simulation.py deleted file mode 100644 index ff791d8..0000000 --- a/scripts/publication_opm_simulation.py +++ /dev/null @@ -1,413 +0,0 @@ -"""Generate publication figures for sphere imaging at three scan spacings. - -Read saved 0.4, 0.8, and 1.2 micrometer acquisitions and their reconstruction -outputs, along with the fine Cartesian object in the acquisition parent folder. -Compare object sampling, optical blur, camera integration/noise, zero-filled -missing scan planes, and deconvolution using common physical/intensity scales. -Write PNG, SVG, PDF, and TIFF figures, zero-filled camera TIFFs, plotted source arrays -in projection_source_data.npz, and manifest.json provenance to --output. -Requires matplotlib; run as -``uv run --with matplotlib python -m scripts.publication_opm_simulation --acquisitions DIR1 DIR2 DIR3 --output DIR`` -from the repository root. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.cm import ScalarMappable -from matplotlib.colors import Normalize -from tifffile import imread, imwrite - -from scripts.psf_sampling_experiment import sample_skewed - -if TYPE_CHECKING: - from collections.abc import Sequence - - from matplotlib.axes import Axes - from matplotlib.figure import Figure - from matplotlib.gridspec import SubplotSpec - -PLANES = ((0, 2, 1, "XY"), (1, 2, 0, "XZ"), (2, 1, 0, "YZ")) - - -def axes_from_metadata(items: Sequence[dict[str, float]]) -> tuple[np.ndarray, ...]: - """Recover physical voxel-center coordinates from exported metadata. - - Parameters - ---------- - items - Saved physical axis records containing origin, step, and sample count. - - Returns - ------- - tuple[np.ndarray, ...] - Physical coordinate arrays reconstructed from saved axis metadata. - """ - return tuple(a["origin_um"] + np.arange(a["size"]) * a["step_um"] for a in items) - - -def record( - volume: np.ndarray, axes: Sequence[np.ndarray], instrument: bool = False -) -> dict[str, Any]: - """Compute the three full-volume maximum-intensity projections. - - Parameters - ---------- - volume - ZYX image volume to project. - axes - Physical coordinate arrays for the source image, in storage-axis order. - instrument - Interpret axes as scan/row/column instead of laboratory ZYX. - - Returns - ------- - dict - Image samples and physical axes packaged for the publication projection panels. - """ - return { - "projections": [volume.max(axis=a) for a, _, _, _ in PLANES], - "axes": axes, - "instrument": instrument, - } - - -def insert_missing_planes( - raw: np.ndarray, raw_axes: Sequence[np.ndarray], display_axes: Sequence[np.ndarray] -) -> tuple[np.ndarray, np.ndarray]: - """Place measured planes by physical position; unmeasured planes remain zero. - - Parameters - ---------- - raw - Saved oblique camera image in scan, row, column order. - raw_axes - Physical coordinates of the measured camera sample centers. - display_axes - Fine instrument grid coordinates including unmeasured scan planes. - - Returns - ------- - np.ndarray - Camera samples inserted into the display grid with unmeasured planes left zero. - """ - for source, target in zip(raw_axes[1:], display_axes[1:], strict=False): - np.testing.assert_allclose(source, target, atol=1e-10) - coordinates = (raw_axes[0] - display_axes[0][0]) / 0.4 - indices = np.rint(coordinates).astype(int) - np.testing.assert_allclose(coordinates, indices, atol=1e-9) - if indices.min() < 0 or indices.max() >= len(display_axes[0]): - raise ValueError("Camera planes exceed the common display grid") - result = np.zeros(tuple(map(len, display_axes)), dtype=raw.dtype) - result[indices] = raw - mask = np.zeros(len(display_axes[0]), dtype=bool) - mask[indices] = True - np.testing.assert_array_equal(result[indices], raw) - assert not np.any(result[~mask]) - return result, mask - - -def image_panel(ax: Axes, item: dict[str, Any], row: int) -> None: - """Display physical coordinates without smoothing or independent rescaling. - - Parameters - ---------- - ax - Matplotlib axes receiving the drawing. - item - Saved image record with data and physical coordinate axes. - row - Projection orientation index for the figure row. - """ - _, horizontal, vertical, name = PLANES[row] - axes = item["axes"] - h, v = axes[horizontal], axes[vertical] - dh, dv = h[1] - h[0], v[1] - v[0] - extent = (h[0] - dh / 2, h[-1] + dh / 2, v[0] - dv / 2, v[-1] + dv / 2) - ax.imshow( - item["projections"][row], - origin="lower", - extent=extent, - cmap="gray", - vmin=0, - vmax=1, - interpolation="nearest", - aspect="equal", - ) - if item["instrument"]: - labels = ("Z′ (scan)", "Y′ (row)", "X′ (column)") - ranges = (12, 12, 6) - else: - labels = ("Z", "Y", "X") - ranges = (6, 6, 6) - ax.set( - xlim=(-ranges[horizontal], ranges[horizontal]), - ylim=(-ranges[vertical], ranges[vertical]), - xlabel=f"{labels[horizontal]} (µm)", - ylabel=f"{labels[vertical]} (µm)", - ) - ax.set_xticks([-ranges[horizontal], 0, ranges[horizontal]]) - ax.set_yticks([-ranges[vertical], 0, ranges[vertical]]) - ax.tick_params(length=2, width=0.5, pad=2) - for spine in ax.spines.values(): - spine.set_linewidth(0.5) - ax.text( - 0.04, - 0.96, - "".join(letter + "′" for letter in name) if item["instrument"] else name, - transform=ax.transAxes, - ha="left", - va="top", - color="white", - fontsize=7, - ) - - -def draw_block( - fig: Figure, - spec: SubplotSpec, - records: Sequence[dict[str, Any]], - titles: Sequence[str], - heading: str, -) -> None: - """Lay out a labeled group of three projections for each volume. - - Parameters - ---------- - fig - Matplotlib figure to write and close. - spec - GridSpec region in which the figure block is drawn. - records - Image/axis records drawn in the comparison block. - titles - Column titles for the comparison images. - heading - Heading displayed above the comparison block. - """ - sub = spec.subgridspec( - 4, len(records), height_ratios=[0.21, 1, 1, 1], hspace=0.43, wspace=0.58 - ) - header = fig.add_subplot(sub[0, :]) - header.axis("off") - header.text(0, 1.05, heading, fontsize=10, weight="bold", va="top") - for col, item in enumerate(records): - for row in range(3): - ax = fig.add_subplot(sub[row + 1, col]) - image_panel(ax, item, row) - if row == 0: - ax.set_title(titles[col], fontsize=8, pad=8) - - -def save_figure(fig: Figure, output: Path, stem: str) -> None: - """Export vector text and high-resolution raster images for publication. - - Parameters - ---------- - fig - Matplotlib figure to write and close. - output - Directory or file receiving the generated images and figures. - stem - Output filename stem without the figure extension. - """ - for suffix in ("pdf", "svg", "png", "tiff"): - options = ( - {"pil_kwargs": {"compression": "tiff_lzw"}} if suffix == "tiff" else {} - ) - fig.savefig(output / f"{stem}.{suffix}", dpi=600, facecolor="white", **options) - plt.close(fig) - - -def main() -> None: - """Validate saved acquisitions and render the forward and reconstruction panels.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--acquisitions", - type=Path, - nargs=3, - required=True, - help="Saved 0.4, 0.8 and 1.2 µm acquisitions, in that order", - ) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - output = args.output - output.mkdir(parents=True, exist_ok=True) - plt.rcParams.update( - { - "font.family": "DejaVu Sans", - "font.size": 7, - "axes.labelsize": 7, - "xtick.labelsize": 6, - "ytick.labelsize": 6, - "pdf.fonttype": 42, - "ps.fonttype": 42, - "svg.fonttype": "none", - } - ) - metas = [json.loads((d / "simulation.json").read_text()) for d in args.acquisitions] - p = metas[0]["parameters"] - for meta, step in zip(metas, (0.4, 0.8, 1.2), strict=False): - assert meta["parameters"] == p - assert np.isclose(meta["acquisition"]["scan_step_um"], step) - for key in ( - "camera_samples", - "peak_electrons", - "background_electrons", - "read_noise_e", - "seed", - ): - assert meta["acquisition"][key] == metas[0]["acquisition"][key] - fine_axes = axes_from_metadata(metas[0]["fine_cartesian_axes_zyx"]) - truth = imread(args.acquisitions[0].parent / "ground_truth_fine.tif") - forward = [record(truth, fine_axes)] - fine_raw_axes = axes_from_metadata(metas[0]["fine_instrument_axes_syx"]) - print("Sampling unblurred fine object in instrument coordinates", flush=True) - skewed = sample_skewed( - truth, - fine_axes, - tuple(map(len, fine_raw_axes)), - pixel_size_um=p["pixel_size_um"] / 2, - scan_step_um=p["fine_scan_step_um"], - angle_deg=p["angle_deg"], - ) - forward.append(record(skewed, fine_raw_axes, instrument=True)) - del truth, skewed - display_axes = axes_from_metadata(metas[0]["raw_axes_syx"]) - manifest = { - "display_scan_step_um": 0.4, - "projection": "maximum intensity", - "intensity_limits": [0, 1], - "parameters": p, - "cases": [], - } - reconstructions = [] - for directory, meta, factor in zip( - args.acquisitions, metas, (1, 2, 3), strict=False - ): - raw_path = directory / "raw_skewed.tif" - raw = imread(raw_path) - raw_axes = axes_from_metadata(meta["raw_axes_syx"]) - zero_filled, mask = insert_missing_planes(raw, raw_axes, display_axes) - forward.append(record(zero_filled, display_axes, instrument=True)) - imwrite( - output / f"camera_{factor * 0.4:.1f}um_zero_filled.tif", - zero_filled, - compression="zlib", - metadata={ - "axes": "ZYX", - "description": "Stored ZYX denotes scan,row,column, not Cartesian; see manifest.json.", - }, - ) - decon_dir = directory / ( - "decon_native" if factor == 1 else f"decon_upsample{factor}" - ) - dm = json.loads((decon_dir / "simulation.json").read_text()) - assert np.isclose(dm["deconvolution"]["reconstruction_step_um"], 0.4) - assert dm.get("valid_for_gc_comparison", True) - electron_hash = hashlib.sha256( - (directory / "raw_electrons.tif").read_bytes() - ).hexdigest() - assert electron_hash == dm["deconvolution"]["raw_file_sha256"] - output_axes = axes_from_metadata(dm["output_axes_zyx"]) - reference = imread(decon_dir / "normal.ome.tif") - if factor == 1: - reference_fixed = reference - reference_axes = output_axes - reconstructions.append(record(reference, output_axes)) - else: - np.testing.assert_allclose(reference, reference_fixed, rtol=1e-6, atol=1e-7) - for a, b in zip(output_axes, reference_axes, strict=False): - np.testing.assert_allclose(a, b, atol=1e-9) - reconstructed = imread(decon_dir / "deskewed.ome.tif") - assert np.isfinite(reconstructed).all() - reconstructions.append(record(reconstructed, output_axes)) - manifest["cases"].append( - { - "acquisition": str(directory.resolve()), - "reconstruction": str(decon_dir.resolve()), - "acquisition_settings": meta["acquisition"], - "raw_electrons_sha256": electron_hash, - "measured_planes": int(mask.sum()), - "zero_planes": int((~mask).sum()), - "measured_scan_indices": np.flatnonzero(mask).tolist(), - "metrics": dm["metrics"], - } - ) - manifest["camera_display_axes_syx"] = metas[0]["raw_axes_syx"] - manifest["fine_instrument_axes_syx"] = metas[0]["fine_instrument_axes_syx"] - manifest["fine_cartesian_axes_zyx"] = metas[0]["fine_cartesian_axes_zyx"] - manifest["reconstruction_axes_zyx"] = metas[0]["output_axes_zyx"] - (output / "manifest.json").write_text( - json.dumps(manifest, indent=2), encoding="utf-8" - ) - source = {} - for group, records in (("forward", forward), ("reconstruction", reconstructions)): - for col, item in enumerate(records): - for row, (_, _, _, plane) in enumerate(PLANES): - source[f"{group}_{col}_{plane}"] = item["projections"][row] - for axis, values in enumerate(item["axes"]): - source[f"{group}_{col}_axis{axis}_um"] = values - np.savez_compressed(output / "projection_source_data.npz", **source) - forward_titles = [ - "Ground truth", - "→ Skewed object\n(before optical blur)", - "→ Camera: 0.4 µm\nall planes measured", - "Camera: 0.8 µm\n1 of 2 planes measured", - "Camera: 1.2 µm\n1 of 3 planes measured", - ] - reconstruction_titles = [ - "Cartesian reference\nGC deconvolution", - "OPM: 0.4 → 0.4 µm\nGC + deskew", - "OPM: 0.8 → 0.4 µm\nGC + deskew", - "OPM: 1.2 → 0.4 µm\nGC + deskew", - ] - headings = [ - "a Forward model and sampled camera data", - "b Deconvolution and orthogonal deskew", - ] - for stem, records, titles, heading in ( - ("forward_model", forward, forward_titles, headings[0]), - ("reconstructions", reconstructions, reconstruction_titles, headings[1]), - ): - fig = plt.figure(figsize=(10, 7.7)) - spec = fig.add_gridspec(1, 1, left=0.07, right=0.91, bottom=0.08, top=0.96) - draw_block(fig, spec[0], records, titles, heading) - cax = fig.add_axes((0.94, 0.37, 0.01, 0.22)) - fig.colorbar( - ScalarMappable(norm=Normalize(0, 1), cmap="gray"), - cax=cax, - label="Fluorescence density (a.u.)", - ticks=[0, 0.5, 1], - ) - save_figure(fig, output, stem) - fig = plt.figure(figsize=(10, 15.0)) - spec = fig.add_gridspec( - 2, 1, left=0.07, right=0.91, bottom=0.04, top=0.98, hspace=0.12 - ) - draw_block(fig, spec[0], forward, forward_titles, headings[0]) - draw_block(fig, spec[1], reconstructions, reconstruction_titles, headings[1]) - for y in (0.70, 0.20): - cax = fig.add_axes((0.94, y, 0.01, 0.12)) - fig.colorbar( - ScalarMappable(norm=Normalize(0, 1), cmap="gray"), - cax=cax, - label="Fluorescence density (a.u.)", - ticks=[0, 0.5, 1], - ) - save_figure(fig, output, "opm_sampling_reconstruction") - print(f"Publication figures and source data: {output.resolve()}", flush=True) - - -if __name__ == "__main__": - main() diff --git a/src/opm_processing/dataio/__init__.py b/src/opm_processing/dataio/__init__.py index fd5dec0..11cedf3 100644 --- a/src/opm_processing/dataio/__init__.py +++ b/src/opm_processing/dataio/__init__.py @@ -9,13 +9,16 @@ inspect_acquisition, open_acquisition_datastore, ) +from opm_processing.dataio.ptv import ProcessedSnapshot, read_processed_snapshot __all__ = [ "AcquisitionMetadata", "ChannelMetadata", + "ProcessedSnapshot", "acquisition", "acquisition_stem", "inspect_acquisition", "open_acquisition_datastore", "position_collection", + "read_processed_snapshot", ] diff --git a/src/opm_processing/dataio/acquisition.py b/src/opm_processing/dataio/acquisition.py index 007d54b..fbe6747 100644 --- a/src/opm_processing/dataio/acquisition.py +++ b/src/opm_processing/dataio/acquisition.py @@ -7,18 +7,25 @@ from __future__ import annotations +import codecs import json +import math import re -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, replace from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Annotated, Any +import typer import yaozarrs from yaozarrs import ZarrGroup, v05 +from opm_processing.dataio.timing import hardware_volume_interval + if TYPE_CHECKING: import tensorstore as ts +app = typer.Typer(pretty_exceptions_enable=False) + @dataclass(frozen=True) class ChannelMetadata: @@ -57,6 +64,104 @@ class AcquisitionMetadata: excess_scan_end_positions: int orientations: tuple[tuple[str, str], ...] sidecar_paths: tuple[str, ...] + acquired_plane_count: int | None = None + plane_period_s: float | None = None + timing_source: str | None = None + deconvolution: dict[str, Any] | None = None + + def validate_processing_calibration(self) -> None: + """Reject missing or invalid recorded camera and scan calibration.""" + required = ["pixel_size_um", "angle_deg", "camera_offset", "camera_conversion"] + if not self.is_2d: + required.append("scan_axis_step_um") + positive = {"pixel_size_um", "camera_conversion", "scan_axis_step_um"} + for name in required: + value = getattr(self, name) + if ( + value is None + or not math.isfinite(value) + or (name in positive and value <= 0) + ): + raise ValueError(f"OME-Zarr has missing or invalid {name} calibration") + + def with_plane_period(self, plane_period_s: float | None) -> AcquisitionMetadata: + """Supply an operator-confirmed hardware period for a legacy acquisition. + + Parameters + ---------- + plane_period_s : float or None + Confirmed period in seconds; None preserves recorded timing. + + Returns + ------- + AcquisitionMetadata + Timing with the original raw plane count and its source recorded. + + Raises + ------ + ValueError + The supplied period is invalid or contradicts recorded timing. + """ + if plane_period_s is None: + _ = self.volume_interval_s + return self + planes = ( + self.scan_position_count + if self.acquired_plane_count is None + else self.acquired_plane_count + ) + hardware_volume_interval(planes, plane_period_s) + if self.plane_period_s is not None and not math.isclose( + self.plane_period_s, plane_period_s, rel_tol=1e-9, abs_tol=1e-12 + ): + raise ValueError( + "Supplied hardware plane period contradicts acquisition metadata" + ) + return replace( + self, + acquired_plane_count=planes, + plane_period_s=plane_period_s, + timing_source=self.timing_source + or ( + "recorded acquisition timing" + if self.plane_period_s is not None + else "operator-confirmed hardware plane period" + ), + ) + + def timing_attributes(self) -> dict: + """Return self-contained hardware timing provenance for a processed store. + + Returns + ------- + dict + Producer timing metadata, or an empty dictionary when timing is unknown. + """ + interval = self.volume_interval_s + if interval is None: + return {} + return { + "opm_processing": { + "timing": { + "schema_version": 1, + "n_acquired_planes": self.acquired_plane_count, + "dt_plane_s": self.plane_period_s, + "dt_volume_s": interval, + "source": self.timing_source or "recorded acquisition timing", + } + } + } + + @property + def volume_interval_s(self) -> float | None: + """Return recorded hardware cadence in seconds, excluding exposure guesses. + + Returns + ------- + float or None + Original acquired-plane count times the hardware plane period. + """ + return hardware_volume_interval(self.acquired_plane_count, self.plane_period_s) @property def index_sizes(self) -> dict[str, int]: @@ -358,23 +463,6 @@ def _integer(value: Any, default: int = 0) -> int: return default if number is None else int(number) -def _wavelength(name: str) -> float | None: - """Infer the emission wavelength from a named acquisition channel. - - Parameters - ---------- - name : str - Stored channel name used to infer an emission wavelength in nanometers. - - Returns - ------- - float | None - Emission wavelength in nanometers, or None when absent from the name. - """ - match = re.search(r"(? tuple[dict[str, Any], dict[str, Any]]: """Return normalized event index and event metadata for both schemas. @@ -411,62 +499,50 @@ def _initial_frame_metadata(path: Path, count: int) -> list[dict[str, Any]]: """ if count < 1: return [] + import tensorstore as ts + + path = Path(path).resolve() + store = ts.KvStore.open({"driver": "file", "path": str(path.parent) + "/"}).result() + size = path.stat().st_size + offset = 0 + utf8 = codecs.getincrementaldecoder("utf-8")() marker = '"frame_metadata"' decoder = json.JSONDecoder() buffer = "" cursor: int | None = None frames: list[dict[str, Any]] = [] - with Path(path).open("r", encoding="utf-8") as stream: - while len(frames) < count: - if cursor is None: - marker_index = buffer.find(marker) - if marker_index >= 0: - array_start = buffer.find("[", marker_index + len(marker)) - if array_start >= 0: - cursor = array_start + 1 - if cursor is not None: - while len(frames) < count: - while cursor < len(buffer) and buffer[cursor] in " \t\r\n,": - cursor += 1 - if cursor < len(buffer) and buffer[cursor] == "]": - return frames - try: - value, end = decoder.raw_decode(buffer, cursor) - except json.JSONDecodeError: - break - frames.append(value) - cursor = end - chunk = stream.read(64 * 1024) - if not chunk: - if cursor is None: - return [] - if frames: + while len(frames) < count: + if cursor is None: + marker_index = buffer.find(marker) + if marker_index >= 0: + array_start = buffer.find("[", marker_index + len(marker)) + if array_start >= 0: + cursor = array_start + 1 + if cursor is not None: + while len(frames) < count: + while cursor < len(buffer) and buffer[cursor] in " \t\r\n,": + cursor += 1 + if cursor < len(buffer) and buffer[cursor] == "]": return frames - raise ValueError(f"Invalid frame_metadata array: {path}") - buffer += chunk + try: + value, end = decoder.raw_decode(buffer, cursor) + except json.JSONDecodeError: + break + frames.append(value) + cursor = end + if offset >= size: + if cursor is None: + return [] + if frames: + return frames + raise ValueError(f"Invalid frame_metadata array: {path}") + end = min(offset + 64 * 1024, size) + chunk = store.read(path.name, byte_range=slice(offset, end)).result().value + offset = end + buffer += utf8.decode(chunk, final=offset == size) return frames -def _array_layout(path: Path) -> tuple[tuple[int, ...], tuple[str, ...]]: - """Read the small Zarr-v3 array document without opening its parent group. - - Parameters - ---------- - path : Path - Zarr array directory whose zarr.json supplies shape and dimension names. - - Returns - ------- - tuple[tuple[int, ...], tuple[str, ...]] - Stored array shape and lowercase dimension names. - """ - metadata_path = Path(path) / "zarr.json" - document = json.loads(metadata_path.read_text(encoding="utf-8")) - shape = tuple(int(value) for value in document["shape"]) - names = tuple(str(value).lower() for value in document["dimension_names"]) - return shape, names - - def _first_by_channel(frames: list[dict[str, Any]]) -> dict[int, dict[str, Any]]: """Select the first initial frame recorded for each channel. @@ -530,11 +606,16 @@ def _channel_metadata( exposure = _number(configured_exposures[channel_index]) powers = configured_powers or daq.get("laser_powers", []) power = _number(powers[channel_index]) if channel_index < len(powers) else None + wavelength_match = re.search( + r"(? tuple[str, ...]: ) +def _camera_timing( + frame_sets: list[list[dict[str, Any]]], + channel_count: int, +) -> tuple[float | None, str | None]: + """Read hardware scan cadence from camera exposures rounded to 0.01 ms. + + Parameters + ---------- + frame_sets : list[list[dict[str, Any]]] + Leading records for each position, containing actual camera + ``exposure_time`` in seconds or legacy ``exposure_ms`` in milliseconds. + Requested event settings and frame arrival timestamps are not used. + channel_count : int + Stored camera channels acquired at each scan position. Simultaneously + illuminated wavelengths recorded in one image count as one channel. + + Returns + ------- + period_s : float or None + Sum of rounded hardware exposures across acquired channels, in seconds. + None means that hardware exposure metadata is incomplete. + source : str or None + Hardware exposure fields used to calculate the period. + + Raises + ------ + ValueError + Recorded exposures are invalid or change across frames or positions. + """ + exposures = {} + sources = set() + for frames in frame_sets: + present = set() + for frame in frames: + field = "exposure_time" if "exposure_time" in frame else "exposure_ms" + if frame.get(field) is None: + continue + value = _number(frame[field]) + if value is None or not math.isfinite(value) or value <= 0: + raise ValueError(f"Invalid camera exposure: {field}={frame[field]!r}") + milliseconds = round(value * (1000 if field == "exposure_time" else 1), 2) + if milliseconds <= 0: + raise ValueError("Camera exposure rounds to zero at 0.01 ms precision") + index, _ = _event_parts(frame) + channel = _integer(index.get("c")) + if channel in exposures and exposures[channel] != milliseconds: + raise ValueError( + "Recorded hardware exposures change across frames or positions" + ) + exposures[channel] = milliseconds + present.add(channel) + sources.add(field) + if present != set(range(channel_count)): + return None, None + if not exposures: + return None, None + return ( + math.fsum(exposures.values()) / 1000, + f"recorded camera {'/'.join(sorted(sources))}; rounded to 0.01 ms", + ) + + def _inspect_ome_zarr(path: Path, root: ZarrGroup) -> AcquisitionMetadata: """Normalize the current OPM OME-Zarr layout without parsing every journal. @@ -688,14 +831,23 @@ def _inspect_ome_zarr(path: Path, root: ZarrGroup) -> AcquisitionMetadata: dimension_names: tuple[str, ...] = () for series_name in series_names: relative_path = f"{series_name}/{dataset_path}" if series_name else dataset_path - current_shape, current_names = _array_layout(path / relative_path) + array_metadata = root[relative_path].metadata + current_shape = tuple(array_metadata.shape) + current_names = tuple( + str(name).lower() + for name in (getattr(array_metadata, "dimension_names", None) or ()) + ) array_paths.append(relative_path) array_shapes.append(current_shape) if current_names: dimension_names = current_names if not dimension_names: - dimension_names = tuple("tczyx"[-len(array_shapes[0]) :]) + dimension_names = tuple( + axis.name for axis in image_metadata.multiscales[0].axes + ) + if dimension_names not in (("t", "c", "z", "y", "x"), ("t", "c", "y", "x")): + raise ValueError(f"Unsupported acquisition axis order: {dimension_names!r}") axes = (dimension_names[0], "p", *dimension_names[1:]) shape = (array_shapes[0][0], len(series_names), *array_shapes[0][1:]) @@ -709,7 +861,22 @@ def _inspect_ome_zarr(path: Path, root: ZarrGroup) -> AcquisitionMetadata: axes_model = [axis.name for axis in multiscale.axes] scale = multiscale.datasets[0].scale_transform.scale if "x" in axes_model: - pixel_size_um = _number(scale[axes_model.index("x")]) + index = axes_model.index("x") + unit = multiscale.axes[index].unit + units_to_um = {"micrometer": 1.0, "nanometer": 0.001, "millimeter": 1000.0} + if unit is not None: + if unit not in units_to_um: + raise ValueError(f"Unsupported camera pixel unit: {unit!r}") + pixel_size_um = float(scale[index]) * units_to_um[unit] + y_index = axes_model.index("y") + y_unit = multiscale.axes[y_index].unit + if y_unit not in units_to_um: + raise ValueError(f"Unsupported camera Y pixel unit: {y_unit!r}") + y_size_um = float(scale[y_index]) * units_to_um[y_unit] + if not abs(pixel_size_um - y_size_um) <= 1e-9 * abs(pixel_size_um): + raise ValueError( + "OPM processing requires equal recorded camera X/Y pixel pitches" + ) channel_count = shape[axes.index("c")] scan_position_count = shape[axes.index("z")] if "z" in axes else 1 @@ -803,6 +970,13 @@ def enabled_values(key: str) -> list[Any]: if key.endswith("_orientation") ) mode = str(daq.get("mode") or config.get("opm_mode") or "unknown") + deconvolution = dict(attributes.get("opm_processing", {}).get("deconvolution", {})) + if "read_noise" not in deconvolution: + for key, units in (("read_noise_e", "electrons"), ("read_noise_adu", "ADU")): + if key in camera: + deconvolution["read_noise"] = {"rms": camera[key], "units": units} + break + plane_period_s, timing_source = _camera_timing(frame_sets, channel_count) return AcquisitionMetadata( path=path, storage_format="opm-v2-ome-zarr-v3", @@ -821,6 +995,7 @@ def enabled_values(key: str) -> list[Any]: angle_deg=_number(opm_frame.get("angle_deg")), camera_offset=_number(camera.get("offset")), camera_conversion=_number(camera.get("e_to_ADU")), + deconvolution=deconvolution, excess_scan_positions=_integer(opm_frame.get("excess_scan_positions")), excess_scan_start_positions=_integer( opm_frame.get("excess_scan_start_positions") @@ -828,6 +1003,11 @@ def enabled_values(key: str) -> list[Any]: excess_scan_end_positions=_integer(opm_frame.get("excess_scan_end_positions")), orientations=orientations, sidecar_paths=_sidecars(path), + acquired_plane_count=scan_position_count + if plane_period_s is not None + else None, + plane_period_s=plane_period_s, + timing_source=timing_source, ) @@ -876,31 +1056,28 @@ def open_acquisition_datastore( if isinstance(acquisition, AcquisitionMetadata) else inspect_acquisition(acquisition) ) - arrays = [] - for array_path in metadata.array_paths: - arrays.append( - ts.open( - { - "driver": "zarr3", - "kvstore": { - "driver": "file", - "path": str(metadata.path / array_path), - }, - } - ).result() - ) + root = yaozarrs.open_group(metadata.path) + arrays = [root[array_path].to_tensorstore() for array_path in metadata.array_paths] return ts.stack(arrays, axis=1) +@app.command(help="Print a metadata-only acquisition manifest as JSON.") +def inspect_command( + path: Annotated[Path, typer.Argument(help="Zarr store or containing directory")], +) -> None: + """Print a metadata-only acquisition manifest as JSON. + + Parameters + ---------- + path : pathlib.Path + Acquisition OME-Zarr store or its containing directory. + """ + typer.echo(json.dumps(inspect_acquisition(path).to_dict(), indent=2)) + + def main() -> None: - """Print a metadata-only acquisition manifest as JSON.""" - import argparse - import json - - parser = argparse.ArgumentParser(description=main.__doc__) - parser.add_argument("path", type=Path, help="Zarr store or containing directory") - arguments = parser.parse_args() - print(json.dumps(inspect_acquisition(arguments.path).to_dict(), indent=2)) + """Run the metadata inspector with standard Python exception tracebacks.""" + app() if __name__ == "__main__": diff --git a/src/opm_processing/dataio/convert_timelapse_data.py b/src/opm_processing/dataio/convert_timelapse_data.py deleted file mode 100644 index c3bc698..0000000 --- a/src/opm_processing/dataio/convert_timelapse_data.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Convert an acquisition timelapse without embedding experiment paths. - -All acquisition selection and camera calibration values are supplied by the -caller or read from acquisition metadata. Importing this module performs no I/O. -""" - -from __future__ import annotations - -from pathlib import Path - -import numpy as np -import typer -import yaml -from tifffile import TiffWriter -from tqdm import tqdm - -from opm_processing.dataio.acquisition import ( - inspect_acquisition, - open_acquisition_datastore, -) -from opm_processing.imageprocessing.camera import camera_correct - -app = typer.Typer() - - -def save_raw_with_yaml(data_array: np.ndarray, output_path: Path) -> None: - """Write a uint16 RAW array and its shape/byte-order sidecar. - - Parameters - ---------- - data_array : np.ndarray - Uint16 data in the axis order required by the selected export. - output_path : Path - Destination file; the writer applies the RAW or TIFF suffix. - - Returns - ------- - None - No value is returned. - """ - output_path = Path(output_path).with_suffix(".raw") - yml_path = output_path.with_suffix(".yaml") - np.asarray(data_array, dtype=np.uint16).tofile(output_path) - meta = { - "Frames": data_array.shape[0], - "Data Type": str(np.dtype("uint16")), - "Height": data_array.shape[-2], - "Width": data_array.shape[-1], - "Byte Order": "<", - } - with yml_path.open("w") as stream: - yaml.safe_dump(meta, stream, sort_keys=False) - - -def save_time_projection( - data_array: np.ndarray, - pixel_size_um: float, - output_path: Path, - *, - camera_offset: float, - camera_conversion: float, -) -> None: - """Save a mean time projection after explicit camera calibration. - - Parameters - ---------- - data_array : np.ndarray - Uint16 data in the axis order required by the selected export. - pixel_size_um : float - Detector pixel spacing in micrometers. - output_path : Path - Destination file; the writer applies the RAW or TIFF suffix. - camera_offset : float - Electronic camera background in ADU. - camera_conversion : float - Calibrated intensity per ADU. - - Returns - ------- - None - No value is returned. - """ - output_path = Path(output_path).with_suffix(".tiff") - calibrated = camera_correct(data_array, camera_offset, camera_conversion) - projection = np.clip(calibrated, 0, np.iinfo(np.uint16).max).mean( - axis=0, dtype=np.float32 - ) - save_as_tiff(projection.astype(np.uint16), pixel_size_um, output_path, axes="YX") - - -def save_as_tiff( - data_array: np.ndarray, - pixel_size_um: float, - output_path: Path, - *, - axes: str = "TYX", -) -> None: - """Save selected timelapse data as OME-TIFF. - - Parameters - ---------- - data_array : np.ndarray - Uint16 data in the axis order required by the selected export. - pixel_size_um : float - Detector pixel spacing in micrometers. - output_path : Path - Destination file; the writer applies the RAW or TIFF suffix. - axes : str - TIFF axis labels matching the data dimensions. - - Returns - ------- - None - No value is returned. - """ - output_path = Path(output_path).with_suffix(".tiff") - resolution = 1e4 / pixel_size_um - with TiffWriter(output_path, bigtiff=True) as tif: - tif.write( - data_array, - resolution=(resolution, resolution), - compression="zlib", - predictor=True, - photometric="minisblack", - resolutionunit="CENTIMETER", - metadata={ - "axes": axes, - "SignificantBits": np.iinfo(np.uint16).bits, - "PhysicalSizeX": pixel_size_um, - "PhysicalSizeXUnit": "µm", - "PhysicalSizeY": pixel_size_um, - "PhysicalSizeYUnit": "µm", - }, - ) - - -def selection_bounds( - requested: tuple[int, int] | None, - length: int, - name: str, -) -> tuple[int, int]: - """Validate and normalize a half-open selection range. - - Parameters - ---------- - requested : tuple[int, int] | None - Half-open index range, or None for the entire axis. - length : int - Available samples along the selected axis. - name : str - Option name used in an invalid-range error. - - Returns - ------- - tuple[int, int] - Validated start and exclusive stop indices. - """ - if requested is None: - return 0, length - start, stop = requested - if not 0 <= start < stop <= length: - raise ValueError(f"{name} must satisfy 0 <= start < stop <= {length}") - return int(start), int(stop) - - -def convert_timelapse( - zarr_dir: Path, - *, - output_dir: Path | None = None, - time_range: tuple[int, int] | None = None, - stage_range: tuple[int, int] | None = None, - scan_range: tuple[int, int] | None = None, - fov_x_range: tuple[int, int] | None = None, - create_raw: bool = False, - create_time_projection: bool = False, - create_tiff: bool = True, - camera_offset: float | None = None, - camera_conversion: float | None = None, -) -> list[Path]: - """Convert selected TPCZYX positions from one acquisition datastore. - - Parameters - ---------- - zarr_dir : Path - Acquisition store or its containing directory. - output_dir : Path | None - Destination directory, defaulting to converted_files beside the store. - time_range : tuple[int, int] | None - Half-open timepoint range, or all timepoints. - stage_range : tuple[int, int] | None - Half-open position range, or all positions. - scan_range : tuple[int, int] | None - Half-open scan-plane range, or all planes. - fov_x_range : tuple[int, int] | None - Half-open camera-column range, or the full detector width. - create_raw : bool - Write each selected timelapse as RAW with a YAML shape sidecar. - create_time_projection : bool - Write the calibrated mean over time for each selected channel. - create_tiff : bool - Write selected uncalibrated timelapses as OME-TIFF. - camera_offset : float | None - Electronic camera background in ADU. - camera_conversion : float | None - Calibrated intensity per ADU. - - Returns - ------- - list[Path] - Written RAW, YAML, and TIFF paths in export order. - """ - zarr_dir = Path(zarr_dir) - acquisition = inspect_acquisition(zarr_dir) - zarr_dir = acquisition.path - datastore = open_acquisition_datastore(acquisition) - - pixel_size_um = acquisition.pixel_size_um - if camera_offset is None: - camera_offset = acquisition.camera_offset - if camera_conversion is None: - camera_conversion = acquisition.camera_conversion - - t0, t1 = selection_bounds(time_range, datastore.shape[0], "time_range") - p0, p1 = selection_bounds(stage_range, datastore.shape[1], "stage_range") - z0, z1 = selection_bounds(scan_range, datastore.shape[3], "scan_range") - x0, x1 = selection_bounds(fov_x_range, datastore.shape[5], "fov_x_range") - destination = ( - Path(output_dir) if output_dir else zarr_dir.parent / "converted_files" - ) - destination.mkdir(parents=True, exist_ok=True) - - if create_time_projection and (camera_offset is None or camera_conversion is None): - raise ValueError( - "Time projection requires camera offset and conversion in metadata " - "or as explicit arguments." - ) - - written: list[Path] = [] - planes = ((position, scan) for position in range(p0, p1) for scan in range(z0, z1)) - for position, scan in tqdm( - planes, - total=(p1 - p0) * (z1 - z0), - desc="planes", - unit="plane", - ): - selected = np.asarray( - datastore[t0:t1, position, :, scan, :, x0:x1].read().result(), - dtype=np.uint16, - ) - channel_count = selected.shape[1] - axes = "TYX" if channel_count == 1 else "TCYX" - stem = f"pos_{position}_scan_{scan}" - if create_raw: - path = destination / f"{stem}.raw" - save_raw_with_yaml(selected, path) - written.extend((path, path.with_suffix(".yaml"))) - if create_time_projection: - for channel in range(channel_count): - path = destination / f"{stem}_c{channel}_time_mean.tiff" - save_time_projection( - selected[:, channel], - pixel_size_um, - path, - camera_offset=float(camera_offset), - camera_conversion=float(camera_conversion), - ) - written.append(path) - if create_tiff: - path = destination / f"{stem}.tiff" - save_as_tiff( - np.squeeze(selected, axis=1) if channel_count == 1 else selected, - pixel_size_um, - path, - axes=axes, - ) - written.append(path) - return written - - -@app.command() -def main( - zarr_dir: Path, - output_dir: Path | None = None, - time_range: tuple[int, int] | None = None, - stage_range: tuple[int, int] | None = None, - scan_range: tuple[int, int] | None = None, - fov_x_range: tuple[int, int] | None = None, - create_raw: bool = False, - create_time_projection: bool = False, - create_tiff: bool = True, - camera_offset: float | None = None, - camera_conversion: float | None = None, -) -> None: - """Convert a selected acquisition; no paths or calibration are implicit. - - Parameters - ---------- - zarr_dir : Path - Acquisition store or its containing directory. - output_dir : Path | None - Destination directory, defaulting to converted_files beside the store. - time_range : tuple[int, int] | None - Half-open timepoint range, or all timepoints. - stage_range : tuple[int, int] | None - Half-open position range, or all positions. - scan_range : tuple[int, int] | None - Half-open scan-plane range, or all planes. - fov_x_range : tuple[int, int] | None - Half-open camera-column range, or the full detector width. - create_raw : bool - Write each selected timelapse as RAW with a YAML shape sidecar. - create_time_projection : bool - Write the calibrated mean over time for each selected channel. - create_tiff : bool - Write selected uncalibrated timelapses as OME-TIFF. - camera_offset : float | None - Electronic camera background in ADU. - camera_conversion : float | None - Calibrated intensity per ADU. - - Returns - ------- - None - No value is returned. - """ - convert_timelapse( - zarr_dir, - output_dir=output_dir, - time_range=time_range, - stage_range=stage_range, - scan_range=scan_range, - fov_x_range=fov_x_range, - create_raw=create_raw, - create_time_projection=create_time_projection, - create_tiff=create_tiff, - camera_offset=camera_offset, - camera_conversion=camera_conversion, - ) - - -if __name__ == "__main__": - app() diff --git a/src/opm_processing/dataio/deconvolution.py b/src/opm_processing/dataio/deconvolution.py new file mode 100644 index 0000000..4a6b645 --- /dev/null +++ b/src/opm_processing/dataio/deconvolution.py @@ -0,0 +1,260 @@ +"""Deconvolution calibration recorded in an acquisition's OME-Zarr attributes.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from collections.abc import Sequence + + from opm_processing.dataio.acquisition import AcquisitionMetadata + + +TARGET_SCAN_STEP_UM = 0.4 + + +def scan_upsample_from_metadata(acquisition: AcquisitionMetadata) -> int: + """Infer integer scan refinement toward the 0.4 micrometer target. + + Parameters + ---------- + acquisition : AcquisitionMetadata + Acquisition with recorded physical scan spacing. + + Returns + ------- + int + One for planar or already finer acquisitions, otherwise the acquired + scan spacing divided by the target spacing. + + Raises + ------ + ValueError + Scan spacing is invalid or a coarser scan is not an integer multiple + of the target. The acquired-plane solver requires an integer factor. + """ + if acquisition.is_2d: + return 1 + step = acquisition.scan_axis_step_um + if step is None or not np.isfinite(step) or step <= 0: + raise ValueError( + "Automatic scan refinement requires a positive recorded scan_axis_step_um" + ) + if step <= TARGET_SCAN_STEP_UM: + return 1 + ratio = step / TARGET_SCAN_STEP_UM + factor = round(ratio) + if not np.isclose(ratio, factor, rtol=1e-6, atol=0): + raise ValueError( + f"Recorded scan step {step:g} um is not an integer multiple of the {TARGET_SCAN_STEP_UM:g} um reconstruction target" + ) + return factor + + +def read_noise_from_metadata(acquisition: AcquisitionMetadata) -> float: + """Return RMS noise in calibrated detector units; absent calibration means zero. + + ``opm_processing.deconvolution.read_noise`` contains ``rms`` and ``units``. + ADU noise is multiplied by the same recorded conversion as camera data. + Electron noise already has the units of the camera-corrected image. + + Parameters + ---------- + acquisition : AcquisitionMetadata + Inspected OME-Zarr acquisition and its recorded calibration attributes. + + Returns + ------- + float + Read-noise RMS in calibrated detector units, or zero when absent. + + Raises + ------ + ValueError + Recorded noise has invalid units or an unrepresentable RMS. + """ + calibration = (acquisition.deconvolution or {}).get("read_noise") + if calibration is None: + return 0.0 + try: + value = float(calibration["rms"]) + units = calibration["units"] + except (KeyError, TypeError, ValueError) as exc: + raise ValueError( + "Recorded read noise requires numeric rms and explicit units" + ) from exc + if units == "ADU": + conversion = acquisition.camera_conversion + if conversion is None or not np.isfinite(conversion) or conversion <= 0: + raise ValueError( + "ADU read noise requires a positive recorded camera conversion" + ) + value *= conversion + elif units not in ("electrons", "calibrated-detector-counts"): + raise ValueError(f"Unsupported read-noise units: {units!r}") + if not np.isfinite(value) or not 0 <= value <= np.sqrt(np.finfo(np.float32).max): + raise ValueError( + "Recorded read-noise RMS must be finite, nonnegative and float32-representable" + ) + return value + + +def psf_parameters_from_metadata(acquisition: AcquisitionMetadata) -> dict: + """Read theoretical PSF settings without supplying microscope assumptions. + + Parameters + ---------- + acquisition : AcquisitionMetadata + Inspected OME-Zarr acquisition and its recorded calibration attributes. + + Returns + ------- + dict + Recorded lens, specimen depth, support and numerical PSF settings. + + Raises + ------ + ValueError + The acquisition has no theoretical PSF calibration. + """ + parameters = (acquisition.deconvolution or {}).get("psf_parameters") + if not parameters: + raise ValueError( + "The OME-Zarr has no opm_processing.deconvolution.psf_parameters. " + "Record the optical calibration or supply measured --decon-psf-paths." + ) + return parameters + + +def detector_gain_from_metadata( + acquisition: AcquisitionMetadata, detector_width: int +) -> np.ndarray | None: + """Read an optional measured detector-X response; never infer it from width. + + Parameters + ---------- + acquisition : AcquisitionMetadata + Inspected OME-Zarr acquisition and its recorded calibration attributes. + detector_width : int + Number of columns in the stored camera image. + + Returns + ------- + numpy.ndarray or None + Positive float32 detector-X response, or None when absent. + + Raises + ------ + ValueError + The recorded response has invalid values or a different width. + """ + values = (acquisition.deconvolution or {}).get("detector_gain_x") + if values is None: + return None + gain = np.asarray(values, dtype=np.float32) + if gain.shape != (detector_width,) or not np.all(np.isfinite(gain) & (gain > 0)): + raise ValueError( + "Recorded detector_gain_x must contain one positive finite value per column" + ) + return gain + + +def emission_wavelengths_from_metadata( + acquisition: AcquisitionMetadata, +) -> tuple[float, ...]: + """Read channel-ordered emission wavelengths in micrometers, never from labels. + + Parameters + ---------- + acquisition : AcquisitionMetadata + Inspected OME-Zarr acquisition and its recorded calibration attributes. + + Returns + ------- + tuple of float + Positive emission wavelengths in micrometers, in channel order. + + Raises + ------ + ValueError + A channel wavelength is absent, nonfinite or nonpositive. + """ + values = (acquisition.deconvolution or {}).get("emission_wavelengths_nm", ()) + if len(values) != len(acquisition.channels): + raise ValueError( + "The OME-Zarr must record one emission_wavelengths_nm value per channel " + "in opm_processing.deconvolution to generate PSFs." + ) + wavelengths = np.asarray(values, dtype=float) + if not np.all(np.isfinite(wavelengths) & (wavelengths > 0)): + raise ValueError("Recorded emission wavelengths must be finite and positive") + return tuple(float(value) / 1000 for value in wavelengths) + + +def load_deconvolution_psfs( + acquisition: AcquisitionMetadata, + measured_paths: Sequence[str | Path] | None = None, + *, + projection: bool = False, + reconstruction_step_um: float | None = None, +) -> tuple[list[np.ndarray], dict | None]: + """Load measured kernels or generate them from the recorded optical calibration. + + Parameters + ---------- + acquisition : AcquisitionMetadata + Inspected acquisition with channel order and physical camera calibration. + measured_paths : sequence of str or pathlib.Path or None + One NumPy or TIFF PSF per channel. None selects theoretical production PSFs. + projection : bool + Generate planar kernels when True, oblique scan kernels otherwise. + reconstruction_step_um : float or None + Reconstruction scan spacing. None uses the recorded acquisition step. + + Returns + ------- + tuple[list[numpy.ndarray], dict or None] + Channel-ordered kernels and the optical parameters used to generate them. + Measured kernels return None for the theoretical parameter dictionary. + + Raises + ------ + ValueError + Measured PSF count differs from channel count or optical calibration is absent. + """ + from tqdm import tqdm + + if measured_paths is not None: + if len(measured_paths) != len(acquisition.channels): + raise ValueError("decon_psf_paths must contain one PSF per channel") + from tifffile import imread + + return [ + np.load(path) if Path(path).suffix == ".npy" else np.asarray(imread(path)) + for path in tqdm(measured_paths, desc="PSFs", unit="PSF") + ], None + from opm_processing.imageprocessing.opmpsf import ( + generate_proj_psf, + generate_skewed_psf, + ) + + parameters = psf_parameters_from_metadata(acquisition) + settings = {"pixel_size_um": acquisition.pixel_size_um, "parameters": parameters} + generate = generate_proj_psf if projection else generate_skewed_psf + if not projection: + settings.update( + scan_axis_step_um=acquisition.scan_axis_step_um + if reconstruction_step_um is None + else reconstruction_step_um, + theta_deg=acquisition.angle_deg, + ) + kernels = [ + generate(em_wvl=wavelength, **settings) + for wavelength in tqdm( + emission_wavelengths_from_metadata(acquisition), desc="PSFs", unit="PSF" + ) + ] + return kernels, parameters diff --git a/src/opm_processing/dataio/live.py b/src/opm_processing/dataio/live.py index bbfff4d..3443b2a 100644 --- a/src/opm_processing/dataio/live.py +++ b/src/opm_processing/dataio/live.py @@ -8,7 +8,9 @@ from dataclasses import dataclass, replace from itertools import product from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any + +from yaozarrs import open_group from opm_processing.dataio.acquisition import ( AcquisitionMetadata, @@ -228,8 +230,12 @@ def __init__(self, acquisition: AcquisitionMetadata) -> None: Inspected filesystem-backed TCZYX acquisition series. """ self.acquisition = acquisition + root = open_group(acquisition.path) self._arrays = tuple( - LiveArrayChunks.read(acquisition.path / relative_path) + LiveArrayChunks.from_metadata( + acquisition.path / relative_path, + root[relative_path].metadata, + ) for relative_path in acquisition.array_paths ) @@ -281,45 +287,36 @@ class LiveArrayChunks: separator: str @classmethod - def read( - cls, - path: Path, - *, - require_frame_chunks: bool = True, - ) -> LiveArrayChunks: - """Read a position array's dimensions and raw chunk-key layout. + def from_metadata(cls, path: Path, metadata: Any) -> LiveArrayChunks: + """Use yaozarrs metadata to determine the raw chunk-key layout. Parameters ---------- path : Path Position array directory containing zarr.json. - require_frame_chunks : bool - Require separate T/C/Z frame chunks for live raw-data reads. + metadata : Any + Array metadata opened through yaozarrs at the acquisition root, + avoiding reads of large per-position frame journals. Returns ------- LiveArrayChunks Layout used to enumerate every chunk needed for a complete tile. """ - metadata_path = path / "zarr.json" - with metadata_path.open(encoding="utf-8") as stream: - metadata = json.load(stream) - shape = tuple(int(value) for value in metadata["shape"]) + shape = tuple(int(value) for value in metadata.shape) chunk_shape = tuple( - int(value) - for value in metadata["chunk_grid"]["configuration"]["chunk_shape"] + int(value) for value in metadata.chunk_grid["configuration"]["chunk_shape"] ) - if require_frame_chunks and chunk_shape[:3] != (1, 1, 1): + if chunk_shape[:3] != (1, 1, 1): raise ValueError( "Live processing requires one-frame chunks along T, C, and Z; " f"got {chunk_shape} at {path}" ) if any( - codec.get("name") == "sharding_indexed" - for codec in metadata.get("codecs", ()) + codec.get("name") == "sharding_indexed" for codec in metadata.codecs or () ): raise ValueError(f"Live processing does not support sharded arrays: {path}") - encoding_document = metadata.get("chunk_key_encoding", {}) + encoding_document = metadata.chunk_key_encoding encoding = str(encoding_document.get("name", "default")) default_separator = "/" if encoding == "default" else "." separator = str( @@ -353,31 +350,14 @@ def tile_is_ready(self, time_index: int) -> bool: range(chunk_counts[3]), range(chunk_counts[4]), ) + prefix = ("c",) if self.encoding == "default" else () return all( - self._chunk_path(coordinates).is_file() + ( + self.path / self.separator.join(map(str, (*prefix, *coordinates))) + ).is_file() for coordinates in product(*coordinate_ranges) ) - def _chunk_path(self, coordinates: tuple[int, ...]) -> Path: - """Encode one TCZYX chunk coordinate as its on-disk path. - - Parameters - ---------- - coordinates : tuple[int, ...] - Chunk-grid indices in TCZYX order. - - Returns - ------- - Path - File path for the array's Zarr chunk-key encoding. - """ - components = [str(value) for value in coordinates] - if self.encoding == "default": - components.insert(0, "c") - if self.separator == "/": - return self.path.joinpath(*components) - return self.path / self.separator.join(components) - def iter_live_tiles( readiness: ZarrTileReadiness, @@ -436,7 +416,7 @@ def iter_live_tiles( f"{len(expected)} complete tiles" ) print( - "Live processing is caught up; waiting " - f"{poll_interval:g} seconds for another complete tile..." + f"Waiting {poll_interval:g} seconds for acquisition completion " + "or another complete tile..." ) sleeper(poll_interval) diff --git a/src/opm_processing/dataio/ngff.py b/src/opm_processing/dataio/ngff.py index cefefc2..2208f9a 100644 --- a/src/opm_processing/dataio/ngff.py +++ b/src/opm_processing/dataio/ngff.py @@ -9,6 +9,8 @@ if TYPE_CHECKING: from collections.abc import Sequence + from yaozarrs import v05 + SPATIAL_DECIMALS = 3 @@ -107,3 +109,69 @@ def downsample_yx( if np.issubdtype(array.dtype, np.integer): reduced = np.rint(reduced) return reduced.astype(array.dtype, copy=False) + + +def pyramid_yx_sampling(multiscale: v05.Multiscale) -> tuple[tuple[int, ...], str]: + """Recover YX reduction factors and sampling from recorded grid coordinates. + + Parameters + ---------- + multiscale : yaozarrs.v05.Multiscale + Pyramid with equal integer reductions of the final YX axes. + + Returns + ------- + factors : tuple of int + Reduction at each level relative to level zero. + method : str + ``stride`` for aligned sample origins or ``block_mean`` for block centers. + + Raises + ------ + ValueError + Scale factors or origins disagree with either supported sampling method. + """ + datasets = multiscale.datasets + base_scale = np.asarray(datasets[0].scale_transform.scale[-2:], dtype=float) + base_origin = np.asarray( + datasets[0].translation_transform.translation[-2:] + if datasets[0].translation_transform is not None + else (0, 0), + dtype=float, + ) + tolerance = 10**-SPATIAL_DECIMALS + factors, methods = [], set() + for dataset in datasets: + scale = np.asarray(dataset.scale_transform.scale[-2:], dtype=float) + factor = np.rint(scale / base_scale).astype(int) + if ( + np.any(factor < 1) + or factor[0] != factor[1] + or not np.allclose(scale, factor * base_scale, atol=tolerance, rtol=0) + ): + raise ValueError("Pyramids require equal integer YX scale factors") + factor = int(factor[0]) + factors.append(factor) + origin = np.asarray( + dataset.translation_transform.translation[-2:] + if dataset.translation_transform is not None + else (0, 0), + dtype=float, + ) + if np.allclose(origin, base_origin, atol=tolerance, rtol=0): + if factor != 1: + methods.add("stride") + elif np.allclose( + origin, + base_origin + 0.5 * (factor - 1) * base_scale, + atol=tolerance, + rtol=0, + ): + methods.add("block_mean") + else: + raise ValueError( + "Pyramid origins do not match stride or block-mean sampling" + ) + if len(methods) > 1: + raise ValueError("Pyramid levels disagree on YX sampling") + return tuple(factors), next(iter(methods), "stride") diff --git a/src/opm_processing/dataio/position_collection.py b/src/opm_processing/dataio/position_collection.py index 856688b..282599e 100644 --- a/src/opm_processing/dataio/position_collection.py +++ b/src/opm_processing/dataio/position_collection.py @@ -1,10 +1,10 @@ """Read and write multi-position OME-Zarr collections with yaozarrs.""" -from __future__ import annotations - +import json +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any import numpy as np from ome_types import from_xml, to_xml @@ -22,35 +22,42 @@ from opm_processing.dataio.acquisition import inspect_acquisition from opm_processing.dataio.ngff import ( + pyramid_yx_sampling, round_spatial, round_spatial_values, round_tczyx_transform, ) - -if TYPE_CHECKING: - from collections.abc import Sequence +from opm_processing.dataio.processing_state import convert_metadata +from opm_processing.dataio.timing import read_time_interval, time_axis -def convert_metadata(obj: Any) -> Any: - """Convert NumPy arrays in collection attributes into JSON-compatible lists. +def update_group_attributes(path: str | Path, attributes: dict[str, Any]) -> None: + """Update group attributes with Zarr's public attribute API. Parameters ---------- - obj : Any - Attribute value, including nested dictionaries and lists. - - Returns - ------- - Any - Value with NumPy arrays converted to lists; other values are preserved. + path : str or pathlib.Path + Existing Zarr v3 group; missing groups are never created. + attributes : dict + Top-level attribute values to replace, preserving all other metadata + and pixels. NumPy arrays are converted using the shared metadata encoder. + + Raises + ------ + ValueError + The group is not Zarr v3 or attribute values are not JSON serializable. """ - if isinstance(obj, np.ndarray): - return obj.tolist() - if isinstance(obj, dict): - return {key: convert_metadata(value) for key, value in obj.items()} - if isinstance(obj, list): - return [convert_metadata(value) for value in obj] - return obj + import zarr + + values = convert_metadata(attributes) + json.dumps(values, allow_nan=False) + group = zarr.open_group( + str(Path(path).expanduser().resolve()), + mode="r+", + zarr_format=3, + use_consolidated=False, + ) + group.attrs.update(values) @dataclass(frozen=True) @@ -67,6 +74,7 @@ class PositionCollection: stage_positions_zxy: tuple[tuple[float, float, float], ...] = () channel_names: tuple[str, ...] = () spatial_origins_zyx_um: tuple[tuple[float, float, float], ...] = () + time_interval_s: float | None = None @property def shape(self) -> tuple[int, ...]: @@ -95,6 +103,7 @@ def create_position_collection( multiscale_factors_yx: Sequence[int] = (), multiscale_downsample: str = "stride", spatial_offset_um: Sequence[float] = (0.0, 0.0, 0.0), + time_interval_s: float | None = None, ) -> PositionCollection: """Create a Bf2Raw collection with one TCZYX image per position. @@ -124,12 +133,15 @@ def create_position_collection( Pyramid sampling method: stride or block_mean. spatial_offset_um : Sequence[float] Image origin in ZYX order, in micrometers. + time_interval_s : float or None + Physical snapshot interval in seconds; None leaves time unitless. Returns ------- PositionCollection Ordered array handles and physical metadata for the collection. """ + t_axis, t_scale = time_axis(time_interval_s) if multiscale_downsample not in ("stride", "block_mean"): raise ValueError('multiscale_downsample must be "stride" or "block_mean"') @@ -150,11 +162,13 @@ def create_position_collection( root_attributes = convert_metadata(attributes or {}) ome_xml = _build_ome_xml( - shape=(t, positions, c, z, y, x), + shapes_tczyx=[(t, c, z, y, x)] * positions, dtype=np.dtype(dtype), voxel_size_um=voxel_size_um, - stage_positions=rounded_stage_positions, + origins_zyx_um=rounded_stage_positions, channels=channels, + time_interval_s=time_interval_s, + name_prefix="Position", ) builder = Bf2RawBuilder( output_path, @@ -165,7 +179,7 @@ def create_position_collection( extra_attributes=root_attributes, ) axes = [ - {"name": "t", "type": "time"}, + t_axis, {"name": "c", "type": "channel"}, {"name": "z", "type": "space", "unit": "micrometer"}, {"name": "y", "type": "space", "unit": "micrometer"}, @@ -176,7 +190,7 @@ def create_position_collection( for level, factor in enumerate(factors): scale = round_tczyx_transform( ( - 1, + t_scale, 1, voxel_size_um[0], voxel_size_um[1] * factor, @@ -241,6 +255,7 @@ def create_position_collection( stage_positions_zxy=rounded_stage_positions or (), channel_names=tuple(channels or (f"channel-{index}" for index in range(c))), spatial_origins_zyx_um=origins, + time_interval_s=time_interval_s, ) @@ -255,6 +270,7 @@ def create_variable_position_collection( attributes: dict[str, Any] | None = None, chunks: tuple[int, ...] | str | None = "auto", overwrite: bool = True, + time_interval_s: float | None = None, ) -> PositionCollection: """Create a Bf2Raw collection whose cropped series have different shapes. @@ -281,24 +297,29 @@ def create_variable_position_collection( TCZYX chunk dimensions or the automatic chunk policy. overwrite : bool Whether an existing destination may be replaced. + time_interval_s : float or None + Physical snapshot interval in seconds; None leaves time unitless. Returns ------- PositionCollection Ordered cropped tile handles and their physical origins. """ + t_axis, t_scale = time_axis(time_interval_s) shapes = tuple(tuple(int(value) for value in shape) for shape in shapes_tczyx) origins = tuple(round_spatial_values(origin) for origin in spatial_origins_zyx_um) voxel_size = round_spatial_values(voxel_size_um) output_dtype = np.dtype(dtype) root_attributes = convert_metadata(attributes or {}) - ome_xml = _build_variable_ome_xml( + ome_xml = _build_ome_xml( shapes, output_dtype, voxel_size, origins, channels, + time_interval_s, + name_prefix="ROI tile", ) builder = Bf2RawBuilder( output_path, @@ -309,13 +330,13 @@ def create_variable_position_collection( extra_attributes=root_attributes, ) axes = [ - {"name": "t", "type": "time"}, + t_axis, {"name": "c", "type": "channel"}, {"name": "z", "type": "space", "unit": "micrometer"}, {"name": "y", "type": "space", "unit": "micrometer"}, {"name": "x", "type": "space", "unit": "micrometer"}, ] - scale = round_tczyx_transform((1, 1, *voxel_size)) + scale = round_tczyx_transform((t_scale, 1, *voxel_size)) for series, (shape, origin) in enumerate(zip(shapes, origins, strict=False)): image = v05.Image( multiscales=[ @@ -350,17 +371,21 @@ def create_variable_position_collection( channels or (f"channel-{index}" for index in range(int(shapes[0][1]))) ), spatial_origins_zyx_um=origins, + time_interval_s=time_interval_s, ) -def _build_variable_ome_xml( +def _build_ome_xml( shapes_tczyx: Sequence[Sequence[int]], dtype: np.dtype, voxel_size_um: Sequence[float], - origins_zyx_um: Sequence[Sequence[float]], + origins_zyx_um: Sequence[Sequence[float]] | None, channels: Sequence[str] | None, + time_interval_s: float | None = None, + *, + name_prefix: str, ) -> str: - """Build OME-XML for independently shaped and positioned ROI tiles. + """Build validated OME-XML for uniform collections or independently shaped ROIs. Parameters ---------- @@ -370,15 +395,20 @@ def _build_variable_ome_xml( Pixel dtype shared by the series. voxel_size_um : Sequence[float] ZYX voxel spacing in micrometers. - origins_zyx_um : Sequence[Sequence[float]] - Per-series ZYX physical origins in micrometers. + origins_zyx_um : Sequence[Sequence[float]] or None + Per-series ZYX physical origins, or None when stage positions are unknown. channels : Sequence[str] | None Ordered channel names, or generated channel-N labels. + time_interval_s : float or None + Recorded interval between volumes in seconds; None leaves timing unknown. + + name_prefix : str + Image and stage-label prefix, such as Position or ROI tile. Returns ------- str - OME-XML with one image and stage label per cropped tile. + OME-XML describing every series with its optional physical stage label. """ channel_count = int(shapes_tczyx[0][1]) channel_names = list( @@ -391,115 +421,30 @@ def _build_variable_ome_xml( "complex128": "double-complex", }.get(dtype.name, dtype.name) images = [] - for series, (shape, origin) in enumerate( - zip(shapes_tczyx, origins_zyx_um, strict=False) - ): + for series, shape in enumerate(shapes_tczyx): t, c, z, y, x = (int(value) for value in shape) - origin_z, origin_y, origin_x = (round_spatial(value) for value in origin) - images.append( - Image( - id=f"Image:{series}", - name=f"ROI tile {series}", - stage_label=StageLabel( - name=f"ROI tile {series}", - x=origin_x, - x_unit=UnitsLength.MICROMETER, - y=origin_y, - y_unit=UnitsLength.MICROMETER, - z=origin_z, - z_unit=UnitsLength.MICROMETER, - ), - pixels=Pixels( - id=f"Pixels:{series}", - dimension_order="XYZCT", - type=ome_pixel_type, - size_x=x, - size_y=y, - size_z=z, - size_c=c, - size_t=t, - physical_size_x=round_spatial(voxel_size_um[2]), - physical_size_y=round_spatial(voxel_size_um[1]), - physical_size_z=round_spatial(voxel_size_um[0]), - channels=[ - Channel( - id=f"Channel:{series}:{channel}", - name=name, - samples_per_pixel=1, - ) - for channel, name in enumerate(channel_names) - ], - metadata_only=MetadataOnly(), - ), - ) - ) - ome_xml = to_xml( - OME(images=images, creator="opm-processing-v2"), - include_namespace=True, - validate=True, - ) - return ome_xml.replace(UnitsLength.MICROMETER.value, "µm") - - -def _build_ome_xml( - shape: tuple[int, int, int, int, int, int], - dtype: np.dtype, - voxel_size_um: Sequence[float], - stage_positions: Sequence[Sequence[float]] | None, - channels: Sequence[str] | None, -) -> str: - """Build a validated OME-XML companion for a Bf2Raw collection. - - Parameters - ---------- - shape : tuple[int, int, int, int, int, int] - Logical collection dimensions in TPCZYX order. - dtype : np.dtype - Pixel dtype stored in each series. - voxel_size_um : Sequence[float] - Physical voxel spacing in ZYX order, in micrometers. - stage_positions : Sequence[Sequence[float]] | None - Per-position stage coordinates in the acquisition ordering, or no stage labels. - channels : Sequence[str] | None - Ordered channel names; omitted names become channel-N. - - Returns - ------- - str - Serialized OME-XML describing every position. - """ - t, positions, c, z, y, x = shape - channel_names = list(channels or (f"channel-{index}" for index in range(c))) - ome_pixel_type = { - "float32": "float", - "float64": "double", - "complex64": "complex", - "complex128": "double-complex", - }.get(dtype.name, dtype.name) - - images = [] - for position in range(positions): + name = f"{name_prefix} {series}" stage_label = None - if stage_positions is not None: - stage_z, stage_y, stage_x = ( - round_spatial(value) for value in stage_positions[position] + if origins_zyx_um is not None: + origin_z, origin_y, origin_x = ( + round_spatial(value) for value in origins_zyx_um[series] ) stage_label = StageLabel( - name=f"Position {position}", - x=stage_x, + name=name, + x=origin_x, x_unit=UnitsLength.MICROMETER, - y=stage_y, + y=origin_y, y_unit=UnitsLength.MICROMETER, - z=stage_z, + z=origin_z, z_unit=UnitsLength.MICROMETER, ) images.append( Image( - id=f"Image:{position}", - name=f"Position {position}", + id=f"Image:{series}", + name=name, stage_label=stage_label, pixels=Pixels( - id=f"Pixels:{position}", + id=f"Pixels:{series}", dimension_order="XYZCT", type=ome_pixel_type, size_x=x, @@ -507,12 +452,14 @@ def _build_ome_xml( size_z=z, size_c=c, size_t=t, + time_increment=time_interval_s, + time_increment_unit="s", physical_size_x=round_spatial(voxel_size_um[2]), physical_size_y=round_spatial(voxel_size_um[1]), physical_size_z=round_spatial(voxel_size_um[0]), channels=[ Channel( - id=f"Channel:{position}:{channel}", + id=f"Channel:{series}:{channel}", name=name, samples_per_pixel=1, ) @@ -553,37 +500,59 @@ def open_position_collection(path: str | Path) -> PositionCollection: ) arrays = [] series_levels = [] + time_intervals = [] spatial_origins = [] + voxel_sizes = [] factors: tuple[int, ...] | None = None + downsample = None for name in series_metadata.series: image_group = root[name] image_metadata = image_group.ome_metadata() + time_intervals.append(read_time_interval(image_metadata.multiscales[0])) datasets = image_metadata.multiscales[0].datasets level_arrays = tuple( image_group[dataset.path].to_tensorstore() for dataset in datasets ) - if factors is None: - factors = tuple( - round( - dataset.scale_transform.scale[-1] - / datasets[0].scale_transform.scale[-1] + sampling_factors, sampling_method = pyramid_yx_sampling( + image_metadata.multiscales[0] + ) + if factors is not None and ( + factors != sampling_factors or downsample != sampling_method + ): + raise ValueError("Position series disagree on pyramid sampling") + factors, downsample = sampling_factors, sampling_method + spatial_axes = image_metadata.multiscales[0].axes[-3:] + units_to_um = {"micrometer": 1.0, "nanometer": 0.001, "millimeter": 1000.0} + if [axis.name for axis in spatial_axes] != list("zyx") or any( + axis.unit not in units_to_um for axis in spatial_axes + ): + raise ValueError("Position series require calibrated NGFF ZYX spatial axes") + voxel_sizes.append( + tuple( + float(value) * units_to_um[axis.unit] + for value, axis in zip( + datasets[0].scale_transform.scale[-3:], spatial_axes, strict=True ) - for dataset in datasets ) + ) arrays.append(level_arrays[0]) series_levels.append(level_arrays) translation = datasets[0].translation_transform spatial_origins.append( tuple( - float(value) - for value in ( + float(value) * units_to_um[axis.unit] + for value, axis in zip( (0.0, 0.0, 0.0) if translation is None - else translation.translation[-3:] + else translation.translation[-3:], + spatial_axes, + strict=True, ) ) ) arrays = tuple(arrays) + if any(value != time_intervals[0] for value in time_intervals): + raise ValueError("Position series disagree on physical time scale") attributes = {key: value for key, value in root.attrs.items() if key != "ome"} if "opm_v2" in root.attrs: @@ -598,11 +567,9 @@ def open_position_collection(path: str | Path) -> PositionCollection: for level in range(len(series_levels[0])) ) first_pixels = ome.images[0].pixels - voxel_size_um = ( - float(first_pixels.physical_size_z or 1.0), - float(first_pixels.physical_size_y or 1.0), - float(first_pixels.physical_size_x or 1.0), - ) + voxel_size_um = voxel_sizes[0] + if any(value != voxel_size_um for value in voxel_sizes): + raise ValueError("Position series disagree on physical voxel spacing") channel_names = tuple( channel.name or f"channel-{index}" for index, channel in enumerate(first_pixels.channels) @@ -622,11 +589,12 @@ def open_position_collection(path: str | Path) -> PositionCollection: attributes=attributes, multiscale_arrays=multiscale_arrays, multiscale_factors_yx=factors or (1,), - multiscale_downsample="stride", + multiscale_downsample=downsample, voxel_size_um=voxel_size_um, stage_positions_zxy=stage_positions, channel_names=channel_names, spatial_origins_zyx_um=tuple(spatial_origins), + time_interval_s=time_intervals[0], ) diff --git a/src/opm_processing/dataio/processing_state.py b/src/opm_processing/dataio/processing_state.py index 093a15c..13f9728 100644 --- a/src/opm_processing/dataio/processing_state.py +++ b/src/opm_processing/dataio/processing_state.py @@ -42,7 +42,7 @@ def processing_state_path(output_root: Path, acquisition_stem: str) -> Path: return root / f"{acquisition_stem}.processing.json" -def _json_value(value: Any) -> Any: +def convert_metadata(value: Any) -> Any: """Return a deterministic JSON-compatible representation. Parameters @@ -53,16 +53,18 @@ def _json_value(value: Any) -> Any: Returns ------- Any - Deterministic JSON-compatible values used in the journal and resume fingerprint. + Deterministic JSON-compatible attributes, journal values and resume settings. """ if isinstance(value, Path): return str(value.expanduser().resolve()) if isinstance(value, dict): - return {str(key): _json_value(item) for key, item in value.items()} + return {str(key): convert_metadata(item) for key, item in value.items()} if isinstance(value, (list, tuple)): - return [_json_value(item) for item in value] + return [convert_metadata(item) for item in value] if isinstance(value, set): - return [_json_value(item) for item in sorted(value)] + return [convert_metadata(item) for item in sorted(value)] + if hasattr(value, "tolist"): + return value.tolist() if hasattr(value, "item"): return value.item() return value @@ -98,7 +100,7 @@ def _configuration_fingerprint(configuration: dict[str, Any]) -> str: SHA-256 digest of the canonical output-affecting configuration. """ encoded = json.dumps( - _json_value(configuration), + convert_metadata(configuration), sort_keys=True, separators=(",", ":"), ).encode("utf-8") @@ -245,7 +247,7 @@ def initialize_run( "path": key, "configuration_sha256": _configuration_fingerprint(configuration), } - series = [_json_value(item) for item in roi_series] + series = [convert_metadata(item) for item in roi_series] outputs = self.document["outputs"] existing = outputs.get(key) if existing is not None and not overwrite: @@ -254,18 +256,26 @@ def initialize_run( raise ValueError( f"Existing processing run is incompatible with {output_path}" ) + # The fingerprint establishes exact compatibility before backfilling + # self-contained export evidence for an older resumable journal. + if "configuration" not in existing or "dataset_id" not in existing: + existing["configuration"] = convert_metadata(configuration) + existing.setdefault("dataset_id", str(uuid4())) + self.save() return record = { **expected, "completed_tiles": [], "zero_channels": [], + "configuration": convert_metadata(configuration), + "dataset_id": str(uuid4()), } if series: record["roi_series"] = series if "reconstruction" in configuration: # Fusion needs the effective deskew input geometry when a solver # reconstructs additional scan planes. Keep raw metadata intact. - record["reconstruction"] = _json_value(configuration["reconstruction"]) + record["reconstruction"] = convert_metadata(configuration["reconstruction"]) outputs[key] = record self.document["registration"].pop(key, None) self.save() @@ -456,7 +466,7 @@ def save_registration( self.run(output_path) self.document["registration"][key] = { "configuration_sha256": _configuration_fingerprint(configuration), - "pairwise_metrics": _json_value(pairwise_metrics), + "pairwise_metrics": convert_metadata(pairwise_metrics), } self.save() @@ -520,7 +530,7 @@ def complete_registration( if previous is not record: previous.pop("fused_path") record["fused_path"] = fused_key - record["tiles"] = [_json_value(tile) for tile in tiles] + record["tiles"] = [convert_metadata(tile) for tile in tiles] self.save() def set_registered_max_projection( @@ -601,7 +611,7 @@ def save(self) -> None: try: with temporary.open("w", encoding="utf-8", newline="\n") as stream: json.dump( - _json_value(self.document), + convert_metadata(self.document), stream, indent=2, sort_keys=True, diff --git a/src/opm_processing/dataio/ptv.py b/src/opm_processing/dataio/ptv.py new file mode 100644 index 0000000..0dd0ac4 --- /dev/null +++ b/src/opm_processing/dataio/ptv.py @@ -0,0 +1,496 @@ +"""Self-contained processed snapshot metadata and lightweight TensorStore access. + +This module imports metadata/IO dependencies only. It reads observed yaozarrs +axes/transforms before opening array handles; legacy reader defaults never enter +the PTV contract. Publishing metadata does not process or rewrite pixel arrays. +""" + +import copy +import math +from dataclasses import dataclass +from itertools import pairwise +from pathlib import Path +from typing import Any + +from ome_types import from_xml +from yaozarrs import open_group + +from opm_processing.dataio.position_collection import update_group_attributes +from opm_processing.dataio.timing import hardware_volume_interval, read_time_interval + +_LENGTH_TO_UM = { + "meter": 1e6, + "centimeter": 1e4, + "millimeter": 1e3, + "micrometer": 1.0, + "nanometer": 1e-3, + "picometer": 1e-6, +} +_STATUSES = {"valid", "acquired_empty", "invalid", "incomplete", "missing"} +_KINDS = {"deskewed", "deconvolved_deskewed", "fused_volume"} + + +@dataclass(frozen=True) +class ProcessedSnapshot: + """Selected image handle and copied authoritative acquisition provenance.""" + + array: Any + metadata: dict[str, Any] + + +def _image_paths(root: Any) -> list[str]: + """Resolve explicit image paths from the declared OME layout. + + Parameters + ---------- + root : ZarrGroup + Opened root image or position collection. + + Returns + ------- + list of str + Image group paths in stored series order. + """ + metadata = root.ome_metadata() + if hasattr(metadata, "multiscales"): + return [""] + return list(root["OME"].ome_metadata().series) + + +def _contract(root: Any, manifest: dict[str, Any]) -> dict[str, Any]: + """Validate self-contained PTV provenance and hardware timing. + + Parameters + ---------- + root : ZarrGroup + Opened processed OME-Zarr root. + manifest : dict + Recorded PTV acquisition and processing contract. + + Returns + ------- + dict + Validated producer hardware timing. + """ + if not isinstance(manifest, dict) or manifest.get("schema_version") != 1: + raise ValueError("Missing supported self-contained opm_processing.ptv metadata") + for name in ("dataset_id", "producer_version"): + if not isinstance(manifest.get(name), str) or not manifest[name]: + raise ValueError(f"PTV metadata requires {name}") + if manifest.get("product_kind") not in _KINDS: + raise ValueError( + "PTV requires a full processed volume; raw/projection products rejected" + ) + history = manifest.get("processing_history") + if ( + not isinstance(history, list) + or not history + or any(not isinstance(row, dict) or not row for row in history) + ): + raise ValueError("PTV requires explicit processing history") + if ( + not isinstance(manifest.get("registration"), dict) + or type(manifest["registration"].get("applied")) is not bool + ): + raise ValueError("PTV requires explicit applied-registration provenance") + if not isinstance(manifest.get("calibration"), dict) or not manifest["calibration"]: + raise ValueError("PTV requires explicit calibration provenance") + images = manifest.get("images") + if ( + not isinstance(images, list) + or any(not isinstance(image, dict) for image in images) + or [image.get("path") for image in images] != _image_paths(root) + ): + raise ValueError("PTV image mapping must match the stored series order") + timing = root.attrs.get("opm_processing", {}).get("timing", {}) + if timing.get("schema_version") != 1 or not timing.get("source"): + raise ValueError("PTV requires recorded original hardware timing") + interval = hardware_volume_interval( + timing.get("n_acquired_planes"), timing.get("dt_plane_s") + ) + if interval is None or not math.isclose( + timing.get("dt_volume_s", float("nan")), interval, rel_tol=1e-10, abs_tol=1e-12 + ): + raise ValueError("Producer hardware timing is missing or contradictory") + return timing + + +def _snapshot( + path: Path, root: Any, manifest: dict[str, Any], series: int, channel: int +) -> ProcessedSnapshot: + """Open a selected processed image using its authoritative metadata. + + Parameters + ---------- + path : pathlib.Path + Processed OME-Zarr root path. + root : ZarrGroup + Opened root group. + manifest : dict + Validated self-contained PTV manifest. + series : int + Stored series index. + channel : int + Stored channel index. + + Returns + ------- + ProcessedSnapshot + Selected array handle and copied physical provenance. + """ + timing = _contract(root, manifest) + if ( + type(series) is not int + or series < 0 + or series >= len(manifest["images"]) + or type(channel) is not int + or channel < 0 + ): + raise ValueError("Selected series/channel is outside the stored images") + image_record = manifest["images"][series] + image_path = image_record["path"] + image_group = root if not image_path else root[image_path] + scales = image_group.ome_metadata().multiscales + if len(scales) != 1: + raise ValueError("PTV requires one unambiguous multiscale image per series") + multiscale = scales[0] + if [axis.name for axis in multiscale.axes] != ["t", "c", "z", "y", "x"]: + raise ValueError("PTV requires named TCZYX processed snapshot axes") + if [axis.type for axis in multiscale.axes] != [ + "time", + "channel", + "space", + "space", + "space", + ]: + raise ValueError("Stored axis types disagree with processed TCZYX") + factors = [] + for axis in multiscale.axes[2:]: + if axis.unit not in _LENGTH_TO_UM: + raise ValueError("Missing or unsupported observed spatial units") + factors.append(_LENGTH_TO_UM[axis.unit]) + dt = hardware_volume_interval(timing["n_acquired_planes"], timing["dt_plane_s"]) + stored_dt = read_time_interval(multiscale) + if stored_dt is None or not math.isclose( + stored_dt, dt, rel_tol=1e-10, abs_tol=1e-12 + ): + raise ValueError("Observed NGFF T scale/units contradict hardware cadence") + datasets = multiscale.datasets + spacings, origins = [], [] + for dataset in datasets: + scale = dataset.scale_transform + translation = dataset.translation_transform + if ( + scale is None + or translation is None + or len(scale.scale) != 5 + or len(translation.translation) != 5 + ): + raise ValueError( + "Explicit observed TCZYX scale and translation are required" + ) + if any(not math.isfinite(value) or value <= 0 for value in scale.scale) or any( + not math.isfinite(value) for value in translation.translation + ): + raise ValueError("Stored transforms must be finite with positive scales") + spacings.append( + tuple( + value * factor + for value, factor in zip(scale.scale[2:], factors, strict=True) + ) + ) + origins.append( + tuple( + value * factor + for value, factor in zip( + translation.translation[2:], factors, strict=True + ) + ) + ) + finest = [ + i + for i, spacing in enumerate(spacings) + if all( + all(a <= b for a, b in zip(spacing, other, strict=True)) + for other in spacings + ) + ] + if len(finest) != 1: + raise ValueError("Highest physical resolution must be unique and unambiguous") + level = finest[0] + array = image_group[datasets[level].path].to_tensorstore() + if len(array.shape) != 5 or array.shape[2] <= 1 or channel >= array.shape[1]: + raise ValueError("PTV requires full 3D TCZYX with an existing channel") + indices = image_record.get("source_indices") + statuses = image_record.get("volume_status_by_channel") + if ( + not isinstance(indices, list) + or len(indices) != array.shape[0] + or any(type(k) is not int or k < 0 for k in indices) + or any(b <= a for a, b in pairwise(indices)) + ): + raise ValueError( + "One increasing original source index is required per stored snapshot" + ) + if ( + not isinstance(statuses, list) + or len(statuses) != array.shape[1] + or any( + not isinstance(rows, list) + or len(rows) != len(indices) + or any(status not in _STATUSES for status in rows) + for rows in statuses + ) + ): + raise ValueError( + "Explicit valid/empty/invalid/incomplete/missing status is required per channel/snapshot" + ) + channel_name = None + xml_path = path / "OME/METADATA.ome.xml" + if xml_path.is_file(): + ome = from_xml(xml_path.read_text(encoding="utf-8")) + pixels = ome.images[series].pixels + channel_name = pixels.channels[channel].name + if pixels.time_increment is not None and ( + str(pixels.time_increment_unit.value) != "s" + or not math.isclose(pixels.time_increment, dt, rel_tol=1e-10, abs_tol=1e-12) + ): + raise ValueError( + "OME TimeIncrement contradicts observed hardware/NGFF timing" + ) + normalized_timing = { + name: timing[name] for name in ("n_acquired_planes", "dt_plane_s", "source") + } + for name in ("t_reference_s", "k_reference", "clock_precision_s"): + if name in timing: + normalized_timing[name] = timing[name] + stored_times = image_record.get("stored_timestamps_s") + if stored_times is not None: + precision = timing.get("clock_precision_s") + if ( + not isinstance(stored_times, list) + or len(stored_times) != len(indices) + or any( + type(t) not in (int, float) or not math.isfinite(t) + for t in stored_times + ) + or any(b <= a for a, b in pairwise(stored_times)) + or type(precision) not in (int, float) + or not math.isfinite(precision) + or precision < 0 + ): + raise ValueError( + "Explicit snapshot timestamps require increasing finite values and clock precision" + ) + reference_t = timing.get("t_reference_s", stored_times[0]) + reference_k = ( + timing.get("k_reference", indices[0]) + if "t_reference_s" in timing + else indices[0] + ) + if any( + abs(t - (reference_t + (k - reference_k) * dt)) > precision + for k, t in zip(indices, stored_times, strict=True) + ): + raise ValueError("Stored timestamps contradict original hardware cadence") + metadata = { + "schema_version": 1, + "dataset_id": manifest["dataset_id"], + "producer_version": manifest["producer_version"], + "series": series, + "channel": channel, + "array_path": "/".join( + part for part in (image_path, datasets[level].path) if part + ), + "shape_zyx": tuple(int(value) for value in array.shape[2:]), + "spacing_zyx_um": spacings[level], + "origin_zyx_um": origins[level], + "timing": normalized_timing, + "source_indices": indices, + "volume_status": statuses[channel], + "processing_history": manifest["processing_history"], + "registration": manifest["registration"], + "calibration": manifest["calibration"], + "stored_ngff": { + "time_unit": multiscale.axes[0].unit, + "time_scale": datasets[level].scale_transform.scale[0], + "channel_name": channel_name, + "multiscale": multiscale.model_dump(mode="json"), + "product_kind": manifest["product_kind"], + }, + } + if "stored_timestamps_s" in image_record: + metadata["stored_timestamps_s"] = image_record["stored_timestamps_s"] + return ProcessedSnapshot(array, copy.deepcopy(metadata)) + + +def read_processed_snapshot( + path: str | Path, *, series: int = 0, channel: int = 0 +) -> ProcessedSnapshot: + """Read authoritative metadata and open the highest-resolution processed array. + + Parameters + ---------- + path : str or Path + Self-contained processed OME-Zarr 0.5 image or position collection. + series, channel : int + Existing series and channel indices, without acquisition overrides. + + Returns + ------- + ProcessedSnapshot + Lazy TensorStore array and normalized copied evidence. No pixels are read. + """ + path = Path(path).expanduser().resolve() + root = open_group(path) + manifest = root.attrs.get("opm_processing", {}).get("ptv") + return _snapshot(path, root, manifest, series, channel) + + +def publish_ptv_metadata(path: str | Path, manifest: dict[str, Any]) -> None: + """Validate and atomically embed explicit processing provenance without pixels. + + Parameters + ---------- + path : str or Path + Existing filesystem-backed processed OME-Zarr 0.5 output. + manifest : dict + Producer-owned version-one PTV history, calibration, registration and + original source-index/status mapping. Timing must already be recorded. + """ + path = Path(path).expanduser().resolve() + root = open_group(path) + _contract(root, manifest) + for series, image in enumerate(manifest["images"]): + for channel in range(max(1, len(image["volume_status_by_channel"]))): + _snapshot(path, root, manifest, series, channel) + processing = copy.deepcopy(root.attrs.get("opm_processing", {})) + processing["ptv"] = manifest + update_group_attributes(path, {"opm_processing": processing}) + + +def publish_processing_state_ptv(path: str | Path, state: Any) -> None: + """Embed current processing evidence and durable completion from its journal. + + Parameters + ---------- + path : str or Path + Processed collection recorded in the journal; timing must be known. + state : ProcessingState + Producer journal holding exact configuration and durable T/P/C writes. + Intentionally zeroed processing channels are invalid, not acquired-empty. + """ + from opm_processing import __version__ + + path = Path(path).resolve() + run = state.run(path) + configuration = run.get("configuration") + if not configuration or configuration.get("kind") not in _KINDS: + raise ValueError("PTV export needs exact full-volume processing configuration") + calibration = configuration.get("calibration") + if not calibration: + raise ValueError( + "PTV export needs the actual applied camera/illumination calibration" + ) + completed_tiles = state.completed_tiles(path) + completed_channels = state.completed_channels(path) + zero_channels = state.zero_channels(path) + roi_series = state.roi_series(path) + root = open_group(path) + images = [] + for series, name in enumerate(_image_paths(root)): + group = root if not name else root[name] + dataset = group.ome_metadata().multiscales[0].datasets[0] + shape = group[dataset.path].to_tensorstore().shape + position = ( + series if not roi_series else int(roi_series[series]["position_index"]) + ) + indices = ( + list(range(shape[0])) + if not roi_series + else [int(roi_series[series]["time_index"])] + ) + statuses = [] + for channel in range(shape[1]): + rows = [] + for index in indices: + key = (index, position, channel) + complete = ( + index, + position, + ) in completed_tiles or key in completed_channels + rows.append( + "incomplete" + if not complete + else "invalid" + if key in zero_channels + else "valid" + ) + statuses.append(rows) + images.append( + { + "path": name, + "source_position_index": position, + "source_indices": indices, + "volume_status_by_channel": statuses, + } + ) + manifest = { + "schema_version": 1, + "dataset_id": run["dataset_id"], + "producer_version": __version__, + "product_kind": configuration["kind"], + "processing_history": [ + { + "operation": configuration["kind"], + "configuration": configuration, + "configuration_sha256": run["configuration_sha256"], + } + ], + "registration": { + "applied": False, + "coordinate_frame": "stored_processed", + "deskew_applied": True, + }, + "calibration": calibration, + "images": images, + } + publish_ptv_metadata(path, manifest) + + +def mark_ptv_tile( + path: str | Path, + time_index: int, + position_index: int, + *, + zero_channels: tuple[int, ...] = (), +) -> None: + """Checkpoint self-contained status after every channel write is durable. + + Parameters + ---------- + path : str or Path + Processed collection with an already validated PTV header. + time_index, position_index : int + Original acquisition indices of the completed tile. + zero_channels : tuple of int + Deliberately zeroed processing channels, recorded as invalid. + """ + processing = copy.deepcopy(open_group(path).attrs["opm_processing"]) + manifest = processing["ptv"] + matched = False + for image in manifest["images"]: + if ( + image["source_position_index"] != position_index + or time_index not in image["source_indices"] + ): + continue + matched = True + row = image["source_indices"].index(time_index) + for channel, statuses in enumerate(image["volume_status_by_channel"]): + statuses[row] = "invalid" if channel in zero_channels else "valid" + if not matched: + raise ValueError( + "Completed tile does not correspond to the processed PTV mapping" + ) + update_group_attributes(path, {"opm_processing": processing}) diff --git a/src/opm_processing/dataio/roi.py b/src/opm_processing/dataio/roi.py index a3caf0b..68dae28 100644 --- a/src/opm_processing/dataio/roi.py +++ b/src/opm_processing/dataio/roi.py @@ -9,8 +9,9 @@ from typing import Any import numpy as np -from yaozarrs import open_group +from yaozarrs import open_group, v05 +from opm_processing.dataio.acquisition import inspect_acquisition from opm_processing.dataio.ngff import round_spatial, round_spatial_values from opm_processing.dataio.position_collection import open_position_collection from opm_processing.dataio.processing_state import ( @@ -19,6 +20,11 @@ ) ROI_SCHEMA = "opm-processing-roi-v1" +ROI_PREVIEW_SUFFIXES = ( + "_max_z_fused.ome.zarr", + "_max_z_decon_deskewed.ome.zarr", + "_max_z_deskewed.ome.zarr", +) def _registered_projection_context( @@ -93,22 +99,116 @@ def _registered_tile_footprints(path: Path) -> tuple[dict[str, Any], ...]: return tuple(footprints) -def validate_registered_max_projection(path: str | Path) -> Path: - """Require an ROI canvas projected from registered full-volume fusion. +def projection_tile_footprints(path: str | Path) -> tuple[dict[str, Any], ...]: + """Read the raw-tile placement represented by a maximum-Z preview. Parameters ---------- path : str | Path - Registered maximum-Z fusion image selected as the processing ROI canvas. + Registered or stage-placed fused preview, or single-position projection. Returns ------- - Path - Absolute registered maximum-Z image path after checking its processing association. + tuple of dict + Per-timepoint source footprints and uncropped raw-volume origins. """ source = Path(path).expanduser().resolve() - _registered_tile_footprints(source) - return source + suffixes = ROI_PREVIEW_SUFFIXES + suffix = next((item for item in suffixes if source.name.endswith(item)), None) + if suffix is None: + raise ValueError("ROI selection requires a deskewed maximum-Z preview") + stem = source.name.removesuffix(suffix) + state = ProcessingState.read(processing_state_path(source.parent, stem)) + preview = open_group(source) + projection = preview.attrs.get("opm_processing_projection", {}) + registrations = [ + record + for record in state.document["registration"].values() + if record.get("max_projection_path") == source.name + ] + if registrations and projection.get("placement") != "stage": + footprints = _registered_tile_footprints(source) + processed = state.registered_output_for_max_projection(source) + collection = open_position_collection(processed) + return tuple( + { + **tile, + "raw_origin_zyx_um": list( + round_spatial_values( + np.asarray(tile["origin_zyx_um"]) + - np.asarray( + collection.spatial_origins_zyx_um[ + int(tile["position_index"]) + ] + ) + ) + ), + } + for tile in footprints + ) + + from opm_processing.imageprocessing.coordinates import ( + stage_positions_to_image_coordinates, + ) + + acquisition = inspect_acquisition(Path(state.document["source"]["path"])) + if suffix == suffixes[0]: + candidates = ( + [Path(projection["source_collection"])] + if projection.get("source_collection") + else [source.parent / f"{stem}{label}" for label in suffixes[1:]] + ) + candidates = [candidate for candidate in candidates if candidate.is_dir()] + if len(candidates) != 1: + raise ValueError( + "Stage-placed preview has ambiguous source collections. Recreate it with process so its source is recorded." + ) + collection_path = candidates[0] + else: + collection_path = source + collection = open_position_collection(collection_path) + if suffix != suffixes[0] and len(collection.arrays) != 1: + raise ValueError( + "Multi-position ROI selection requires a fused maximum-Z preview" + ) + full_path = collection_path.with_name( + collection_path.name.replace("_max_z_", "_", 1) + ) + full = open_position_collection(full_path) + stage_origins = stage_positions_to_image_coordinates( + collection.stage_positions_zxy, + reverse_z=not acquisition.stage_axis_flips_xyz[2], + ) + fused = isinstance(preview.ome_metadata(), v05.Image) + footprints = [] + for position, (array, stage, offset) in enumerate( + zip(collection.arrays, stage_origins, full.spatial_origins_zyx_um, strict=True) + ): + # Stage-fused projections place the cropped image at the stage origin; + # an ordinary collection displays its NGFF local image coordinates. + placed = np.asarray(stage) if fused else np.asarray(stage) + offset + raw_origin = placed - np.asarray(offset) + dy, dx = collection.voxel_size_um[-2:] + for time in range(int(array.shape[0])): + footprints.append( + { + "time_index": time, + "position_index": position, + "origin_zyx_um": list(round_spatial_values(placed)), + "raw_origin_zyx_um": list(round_spatial_values(raw_origin)), + "bounds_yx_um": list( + round_spatial_values( + ( + placed[1], + placed[1] + array.shape[-2] * dy, + placed[2], + placed[2] + array.shape[-1] * dx, + ) + ) + ), + } + ) + return tuple(footprints) @dataclass(frozen=True) @@ -130,7 +230,7 @@ def __post_init__(self) -> None: @classmethod def read(cls, path: str | Path) -> PhysicalRoi: - """Read the physical ROI exported by display. + """Read a saved physical ROI selection. Parameters ---------- @@ -367,6 +467,9 @@ def _level_zero_grid(path: Path) -> tuple[tuple[float, float], tuple[float, floa """ root = open_group(path) metadata = root.ome_metadata() + collection = isinstance(metadata, v05.Bf2Raw) + if collection: + metadata = root[root["OME"].ome_metadata().series[0]].ome_metadata() dataset = metadata.multiscales[0].datasets[0] scale = dataset.scale_transform.scale translation = ( @@ -375,7 +478,13 @@ def _level_zero_grid(path: Path) -> tuple[tuple[float, float], tuple[float, floa else dataset.translation_transform.translation ) return ( - round_spatial_values(translation[-2:]), + ( + round_spatial_values( + projection_tile_footprints(path)[0]["origin_zyx_um"][-2:] + ) + if collection + else round_spatial_values(translation[-2:]) + ), round_spatial_values(scale[-2:]), ) @@ -419,7 +528,11 @@ def snap_grid_index(value: float) -> float: Grid index with near-integer floating-point noise removed. """ nearest = round(value) - return float(nearest) if math.isclose(value, nearest, abs_tol=1e-7) else value + return ( + float(nearest) + if math.isclose(value, nearest, rel_tol=0, abs_tol=1e-7) + else value + ) iy0 = math.floor(snap_grid_index((y0 - oy) / sy)) iy1 = math.ceil(snap_grid_index((y1 - oy) / sy)) @@ -484,7 +597,7 @@ def roi_from_world_rectangle( PhysicalRoi Pixel-aligned physical ROI and source registered tile footprints. """ - source = validate_registered_max_projection(source_path) + source = Path(source_path).expanduser().resolve() vertices = np.asarray(vertices_world, dtype=np.float64) if vertices.ndim != 2 or vertices.shape[0] < 2 or vertices.shape[1] < 2: raise ValueError("ROI rectangle vertices must have shape (n, ndim>=2)") @@ -501,7 +614,7 @@ def roi_from_world_rectangle( origin, spacing, ) - footprints = list(_registered_tile_footprints(source)) + footprints = list(projection_tile_footprints(source)) selected_footprints = intersecting_tile_footprints(aligned_bounds, footprints) selected = tuple( sorted({int(item["position_index"]) for item in selected_footprints}) diff --git a/src/opm_processing/dataio/timing.py b/src/opm_processing/dataio/timing.py new file mode 100644 index 0000000..dcc09b0 --- /dev/null +++ b/src/opm_processing/dataio/timing.py @@ -0,0 +1,100 @@ +"""Physical snapshot cadence shared by processed OME-Zarr writers.""" + +from math import isfinite + +from yaozarrs import v05 + + +def time_axis(interval_s: float | None) -> tuple[dict, float]: + """Return an NGFF time axis and scale without inventing a physical interval. + + Parameters + ---------- + interval_s : float or None + Positive finite snapshot interval in seconds; None means unknown timing. + + Returns + ------- + axis : dict + Time-axis descriptor, with unit second only when timing is known. + scale : float + Physical interval or a unitless index scale of one. + """ + if interval_s is None: + return {"name": "t", "type": "time"}, 1.0 + if isinstance(interval_s, bool) or not isfinite(interval_s) or interval_s <= 0: + raise ValueError("time_interval_s must be finite and positive") + return {"name": "t", "type": "time", "unit": "second"}, float(interval_s) + + +def hardware_volume_interval( + acquired_planes: int | None, plane_period_s: float | None +) -> float | None: + """Validate recorded hardware timing and return N_acquired_planes * dt_plane_s. + + Parameters + ---------- + acquired_planes : int or None + Original acquired count, including discarded planes; never processed Z size. + plane_period_s : float or None + Hardware period in seconds per scan position, including acquired channels. + + Returns + ------- + float or None + Physical volume interval, or None when both timing fields are absent. + """ + if acquired_planes is None and plane_period_s is None: + return None + if ( + type(acquired_planes) is not int + or acquired_planes <= 0 + or plane_period_s is None + ): + raise ValueError( + "Hardware timing requires a positive integer acquired-plane count and plane period" + ) + time_axis(plane_period_s) + interval = acquired_planes * plane_period_s + time_axis(interval) + return interval + + +def read_time_interval(multiscale: v05.Multiscale) -> float | None: + """Read a physical NGFF T scale in seconds, preserving unknown time units. + + Parameters + ---------- + multiscale : yaozarrs.v05.Multiscale + Producer image metadata; all datasets must have the same physical T scale. + + Returns + ------- + float or None + Seconds per snapshot, or None for an axis without physical units. + """ + index = next( + (i for i, axis in enumerate(multiscale.axes) if axis.name == "t"), None + ) + if index is None or multiscale.axes[index].unit is None: + return None + factors = { + "second": 1.0, + "millisecond": 0.001, + "microsecond": 1e-6, + "nanosecond": 1e-9, + "minute": 60.0, + "hour": 3600.0, + } + unit = multiscale.axes[index].unit + if unit not in factors: + raise ValueError(f"Unsupported physical time unit {unit}") + values = [ + dataset.scale_transform.scale[index] * factors[unit] + for dataset in multiscale.datasets + ] + for value in values: + time_axis(value) + if any(value != values[0] for value in values): + raise ValueError("Pyramid levels disagree on physical time scale") + return values[0] diff --git a/src/opm_processing/display.py b/src/opm_processing/display.py deleted file mode 100644 index 82ab592..0000000 --- a/src/opm_processing/display.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Display processed OPM data through napari-ome-zarr.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Annotated, Any - -import napari -import numpy as np -import typer -from napari.experimental import link_layers -from yaozarrs import open_group, v05 - -from opm_processing.dataio.acquisition import ( - acquisition_stem, - resolve_acquisition_path, -) -from opm_processing.dataio.position_collection import ( - PositionCollection, - open_position_collection, -) -from opm_processing.dataio.roi import ( - roi_from_image_pixel_rectangle, - validate_registered_max_projection, -) - -app = typer.Typer(pretty_exceptions_enable=False) - -_DISPLAY_OUTPUT_SUFFIXES = { - "max-z": ( - "_max_z_decon_deskewed.ome.zarr", - "_max_z_deskewed.ome.zarr", - ), - "full": ( - "_decon_deskewed.ome.zarr", - "_deskewed.ome.zarr", - ), - "fused-max-z": ("_max_z_fused.ome.zarr",), - "fused-full": ("_fused.ome.zarr",), -} - - -def _processed_output_stem(path: Path) -> str | None: - """Return the acquisition stem encoded by a recognized display output. - - Parameters - ---------- - path : Path - Processed output path inspected for a supported display filename suffix. - - Returns - ------- - str | None - Acquisition name encoded in the recognized processed filename, or None. - """ - for suffixes in _DISPLAY_OUTPUT_SUFFIXES.values(): - for suffix in suffixes: - if path.name.endswith(suffix): - return path.name[: -len(suffix)] - return None - - -def _resolve_display_context(root_path: Path) -> tuple[Path, str]: - """Resolve the output directory and acquisition stem for display lookup. - - Parameters - ---------- - root_path : Path - Acquisition or processed-output directory selected by the caller. - - Returns - ------- - tuple[Path, str] - Processed-output directory and the acquisition name used for display lookup. - """ - candidate = Path(root_path).expanduser().resolve() - direct_stem = _processed_output_stem(candidate) - if direct_stem is not None: - return candidate.parent, direct_stem - - try: - acquisition_path = resolve_acquisition_path(candidate) - except ValueError as acquisition_error: - if not candidate.is_dir(): - raise - processed_stems = { - stem - for item in candidate.iterdir() - if item.is_dir() - for stem in (_processed_output_stem(item),) - if stem is not None - } - if len(processed_stems) == 1: - return candidate, processed_stems.pop() - if len(processed_stems) > 1: - found = ", ".join(sorted(processed_stems)) - raise ValueError( - f"Expected processed data for one acquisition in {candidate}, " - f"found: {found}" - ) from acquisition_error - raise - return acquisition_path.parent, acquisition_stem(acquisition_path) - - -def _resolve_data_path(root_path: Path, to_display: str) -> Path: - """Resolve a display mode to the first existing processed dataset. - - Parameters - ---------- - root_path - Original acquisition path used to derive output names. - to_display - Requested processed-data display mode. - - Returns - ------- - pathlib.Path - Existing processed dataset path. - - Raises - ------ - ValueError - If the display mode is unsupported. - FileNotFoundError - If no output exists for the requested mode. - """ - if to_display not in _DISPLAY_OUTPUT_SUFFIXES: - choices = ", ".join(_DISPLAY_OUTPUT_SUFFIXES) - raise ValueError(f"to_display must be one of: {choices}") - base, stem = _resolve_display_context(root_path) - candidates = tuple( - base / f"{stem}{suffix}" for suffix in _DISPLAY_OUTPUT_SUFFIXES[to_display] - ) - for candidate in candidates: - if candidate.exists(): - return candidate - raise FileNotFoundError( - f"No {to_display} output found. Checked: " - + ", ".join(str(path) for path in candidates) - ) - - -def _configure_collection_layers( - data_path: Path, - layers: list[Any], - pos_range: tuple[int, int] | None, - time_range: tuple[int, int] | None, -) -> PositionCollection | None: - """Apply collection selections to plugin-created lazy layers. - - Parameters - ---------- - data_path : Path - Processed OME-Zarr store opened by the napari plugin. - layers : list[Any] - Lazy image layers produced by the OME-Zarr reader plugin. - pos_range : tuple[int, int] | None - Half-open position range to make visible, or None for all positions. - time_range : tuple[int, int] | None - Half-open timepoint range to display, or None for all timepoints. - - Returns - ------- - PositionCollection or None - The opened multi-position collection, or ``None`` when ``data_path`` - is a single OME-NGFF Image such as a fused output. - """ - if time_range is not None: - time_start, time_stop = time_range - if time_start < 0 or time_start >= time_stop: - raise ValueError("time_range must satisfy 0 <= start < stop") - for layer in layers: - if layer.multiscale: - layer.data = [level[time_start:time_stop] for level in layer.data] - else: - layer.data = layer.data[time_start:time_stop] - - root = open_group(data_path) - if not isinstance(root.ome_metadata(), v05.Bf2Raw): - return None - - collection = open_position_collection(data_path) - positions = len(collection.arrays) - channels = len(collection.channel_names) or max(1, len(layers) // positions) - start, stop = pos_range or (0, positions) - if start < 0 or stop > positions or start >= stop: - raise ValueError(f"pos_range must satisfy 0 <= start < stop <= {positions}") - - for position in range(positions): - position_layers = layers[position * channels : (position + 1) * channels] - for layer in position_layers: - layer.visible = start <= position < stop - if collection.stage_positions_zxy: - z, y, x = collection.stage_positions_zxy[position] - layer.translate = (0.0, z, y, x) - return collection - - -@app.command() -def display( - root_path: Path, - to_display: str = "fused-max-z", - time_range: tuple[int, int] | None = None, - pos_range: tuple[int, int] | None = None, - roi_output: Path | None = None, - roi: Annotated[ - bool, - typer.Option( - "--roi/--no-roi", - help="Create and save one processing ROI rectangle.", - ), - ] = True, -) -> None: - """Display processed OPM data using the napari-ome-zarr reader. - - Parameters - ---------- - root_path : Path - Acquisition or processed-output directory selected by the caller. - to_display : str - Processed output kind: full, max-z, fused-full, or fused-max-z. - time_range : tuple[int, int] | None - Half-open timepoint range to display, or None for all timepoints. - pos_range : tuple[int, int] | None - Half-open position range to make visible, or None for all positions. - roi_output : pathlib.Path or None - Output path for the physical-coordinate ROI JSON. When omitted in ROI - mode, defaults to ``_roi.json`` beside the displayed data. - roi : bool - Create and save one napari rectangle. Enabled by default; use - ``--no-roi`` for display-only use. - - Returns - ------- - None - No value is returned. - """ - data_path = _resolve_data_path(root_path, to_display) - save_roi = roi or roi_output is not None - if save_roi: - if to_display != "fused-max-z": - raise ValueError( - "ROI mode requires --to-display fused-max-z. Use --no-roi " - "to open another display mode." - ) - try: - validate_registered_max_projection(data_path) - except (FileNotFoundError, ValueError): - command_path = Path(root_path).expanduser().resolve() - raise typer.BadParameter( - "ROI mode requires a registered maximum-Z projection from fuse.\n" - f'Run: uv run fuse "{command_path}"\n' - "Then rerun display. For display-only use --no-roi.", - param_hint="root_path", - ) from None - if save_roi and roi_output is None: - stem = _processed_output_stem(data_path) - if stem is None: - raise RuntimeError(f"Cannot derive acquisition stem from {data_path}") - roi_output = data_path.parent / f"{stem}_roi.json" - - viewer = napari.Viewer() - layers = list(viewer.open(str(data_path), plugin="napari-ome-zarr")) - collection = _configure_collection_layers(data_path, layers, pos_range, time_range) - - if collection is not None: - channels = len(collection.channel_names) - for channel in range(channels): - channel_layers = layers[channel::channels] - if len(channel_layers) > 1: - link_layers(channel_layers, ("contrast_limits", "gamma")) - roi_layer = None - if save_roi: - roi_layer = viewer.add_shapes( - name="processing ROI", - ndim=viewer.dims.ndim, - shape_type="rectangle", - edge_color="yellow", - face_color=[1.0, 1.0, 0.0, 0.15], - ) - print( - "Draw exactly one rectangle in the 'processing ROI' layer, then " - "close napari to save it. All Z values will be retained." - ) - napari.run() - if save_roi: - if roi_layer is None or len(roi_layer.data) != 1: - raise ValueError("Draw exactly one rectangle before closing napari") - shape_types = np.atleast_1d(roi_layer.shape_type).tolist() - if shape_types != ["rectangle"]: - raise ValueError("The processing ROI layer must contain one rectangle") - vertices_canvas_world = np.asarray( - roi_layer.data_to_world(np.asarray(roi_layer.data[0])), - dtype=np.float64, - ) - reference_layer = layers[0] - vertices_image_data = np.asarray( - [reference_layer.world_to_data(vertex) for vertex in vertices_canvas_world], - dtype=np.float64, - ) - roi = roi_from_image_pixel_rectangle(data_path, vertices_image_data) - if roi_output is None: - raise RuntimeError("ROI output path was not resolved") - written = roi.write(roi_output) - selected = ( - "determined during processing" - if roi.position_indices is None - else f"{len(roi.position_indices)} source positions" - ) - print(f"Saved physical ROI selecting {selected}: {written}") - - -def main() -> None: - """Run the display command-line application. - - Returns - ------- - None - No value is returned. - """ - app() - - -if __name__ == "__main__": - main() diff --git a/src/opm_processing/encode_projections.py b/src/opm_processing/encode_projections.py index bfb88bc..f19ffa3 100644 --- a/src/opm_processing/encode_projections.py +++ b/src/opm_processing/encode_projections.py @@ -53,16 +53,21 @@ def find_sequences(root: Path) -> list[list[Path]]: return sequences -def image_nv12(frame: np.ndarray) -> tuple[np.ndarray, int, int]: +def image_nv12( + frame: np.ndarray, *, minimum_shape: tuple[int, int] = (2, 2) +) -> tuple[np.ndarray, int, int]: """Map grayscale or RGB display pixels to BT.709 limited-range NV12. Neutral chroma preserves grayscale. Black padding gives even dimensions - and a minimum 128-pixel extent for small test or cropped canvases. + and satisfies the selected encoder's reported minimum dimensions. Parameters ---------- frame : np.ndarray Uint8 grayscale or RGB projection image. + minimum_shape : tuple[int, int] + Minimum height and width, normally read from the encoder capabilities. + The default is one 2-by-2 NV12 chroma block. Returns ------- @@ -74,7 +79,10 @@ def image_nv12(frame: np.ndarray) -> tuple[np.ndarray, int, int]: ): raise ValueError("Video input must be uint8 grayscale or RGB projection TIFFs.") h, w = frame.shape[:2] - height, width = max(128, h + h % 2), max(128, w + w % 2) + height, width = ( + max(size, minimum) + max(size, minimum) % 2 + for size, minimum in zip((h, w), minimum_shape, strict=True) + ) nv12 = np.full((height * 3 // 2, width), 128, dtype=np.uint8) nv12[:height] = 16 if frame.ndim == 2: @@ -107,7 +115,7 @@ def encode_sequence( gop_seconds: float = 2, encoder_options: dict[str, Any] | None = None, ) -> Path: - """Encode one ordered sequence and atomically publish a fast-start H.264 MP4. + """Encode one ordered sequence and atomically publish a fast-start MP4. Parameters ---------- @@ -135,8 +143,6 @@ def encode_sequence( Path MP4 destination after successful encoding and atomic publication. """ - import PyNvVideoCodec as nvc - if not frames: raise ValueError("No frames to encode.") if codec not in ("h264", "hevc") or rate_control not in ("cbr", "vbr"): @@ -158,7 +164,12 @@ def encode_sequence( output = Path(output) output.parent.mkdir(parents=True, exist_ok=True) first = tifffile.imread(frames[0]) - _, width, height = image_nv12(first) + import PyNvVideoCodec as nvc + + capabilities = nvc.GetEncoderCaps(gpu, codec) + minimum_shape = (capabilities["height_min"], capabilities["width_min"]) + _, width, height = image_nv12(first, minimum_shape=minimum_shape) + # No B frames: raw elementary packets can be remuxed with monotonically # increasing timestamps without inventing presentation/decode reordering. encoder = nvc.CreateEncoder( @@ -193,7 +204,7 @@ def encode_sequence( frame = first if index == 0 else tifffile.imread(path) if frame.shape != first.shape: raise ValueError(f"Frame dimensions changed at {path}") - nv12, _, _ = image_nv12(frame) + nv12, _, _ = image_nv12(frame, minimum_shape=minimum_shape) for packet in encoder.Encode(nv12): stream.write(packet["data"]) for packet in encoder.EndEncode(): @@ -268,7 +279,7 @@ def encode_projections( str, typer.Option(help="cbr or vbr; both retain the configured bitrate target.") ] = "cbr", ) -> None: - """Write one high-quality H.264 MP4 beside each projection TIFF sequence. + """Write one MP4 beside each projection TIFF sequence. Parameters ---------- diff --git a/src/opm_processing/export_ome_tiff.py b/src/opm_processing/export_ome_tiff.py index ed64603..55016f9 100644 --- a/src/opm_processing/export_ome_tiff.py +++ b/src/opm_processing/export_ome_tiff.py @@ -45,6 +45,69 @@ app = typer.Typer(pretty_exceptions_enable=False) +def export_source_stem(source: Path) -> str: + """Return the acquisition basename of a fused or processed image store. + + Parameters + ---------- + source : pathlib.Path + Processed OME-Zarr source path. + + Returns + ------- + str + Acquisition basename with its processing suffix removed. + """ + for suffix in ( + "_decon_deskewed.ome.zarr", + "_deskewed.ome.zarr", + "_fused.ome.zarr", + ".ome.zarr", + ): + if source.name.endswith(suffix): + return source.name.removesuffix(suffix) + return source.name.removesuffix(".zarr") + + +def resolve_export_image(source: Path) -> Path: + """Resolve a root image or the sole image in a position collection. + + Parameters + ---------- + source : pathlib.Path + Root image or single-position collection. + + Returns + ------- + pathlib.Path + Resolved TCZYX image group. + + Raises + ------ + ValueError + The collection has multiple positions or unsupported image axes. + """ + root = open_group(source) + if isinstance(root.ome_metadata(), v05.Bf2Raw): + series = root["OME"].ome_metadata().series + if len(series) != 1: + raise ValueError( + f"ROI export requires a single-position collection; {source} has " + f"{len(series)} positions. Fuse multi-position data first." + ) + image_path = source / series[0] + root = open_group(image_path) + else: + image_path = source + metadata = root.ome_metadata() + if not isinstance(metadata, v05.Image): + raise ValueError(f"Expected an OME-Zarr image in {image_path}") + axes = [axis.name for axis in metadata.multiscales[0].axes] + if axes != list("tczyx"): + raise ValueError(f"Expected TCZYX image axes in {image_path}; found {axes}") + return image_path + + def image_tiles( array: ts.TensorStore, tile_shape: tuple[int, int] ) -> Iterator[np.ndarray]: @@ -93,34 +156,69 @@ def export_image( provenance: dict[str, str], *, workers: int = 4, + bounds_yx: tuple[int, int, int, int] | None = None, ) -> None: - """Write one fused image as tiled OME-BigTIFF without changing its pixels. + """Write one image as tiled OME-BigTIFF without changing its pixels. Parameters ---------- source : Path - Fused volume or fused maximum-Z OME-Zarr Image group. + OME-Zarr Image group or single-position processed collection. output : Path Destination OME-TIFF file, replaced after a successful export. template : OME Source OME metadata containing available channels, instrument details, acquisition date, timing, and annotations. Its image geometry is - replaced with the fused level-zero NGFF geometry. + replaced with the exported level-zero NGFF geometry. provenance : dict[str, str] JSON-encoded acquisition and processing records for OME annotations. workers : int Number of TIFF compression workers; reads and buffering remain bounded. + bounds_yx : tuple[int, int, int, int] | None + Optional level-zero (y start, y stop, x start, x stop) crop bounds. """ - root = open_group(source) + image_path = resolve_export_image(source) + root = open_group(image_path) metadata = root.ome_metadata() multiscale = metadata.multiscales[0] dataset = multiscale.datasets[0] array = root[dataset.path].to_tensorstore() + if bounds_yx is not None: + y0, y1, x0, x1 = bounds_yx + if not (0 <= y0 < y1 <= array.shape[-2] and 0 <= x0 < x1 <= array.shape[-1]): + raise ValueError( + "ROI bounds must be nonempty and inside the level-zero image" + ) + array = array[:, :, :, y0:y1, x0:x1].translate_to[0, 0, 0, 0, 0] nt, nc, nz, ny, nx = array.shape dtype = np.dtype(array.dtype.numpy_dtype) scale = dataset.scale_transform.scale translation = dataset.translation_transform - origin = translation.translation if translation is not None else [0] * 5 + origin = list(translation.translation) if translation is not None else [0] * 5 + if image_path != source: + # Ordinary processed collections use a local zero NGFF origin and + # store the global position in their OME StageLabel. ROI collections + # already have a nonzero global NGFF origin, which takes precedence. + companion = source / "OME" / "METADATA.ome.xml" + if companion.is_file() and not any(origin[-3:]): + stage = ( + from_xml(companion.read_text(encoding="utf-8")).images[0].stage_label + ) + if stage is not None: + origin[2:] = [ + float(stage.z or 0), + float(stage.y or 0), + float(stage.x or 0), + ] + provenance = {**provenance, "SourceImageGroup": str(image_path)} + if bounds_yx is not None: + origin[3] += bounds_yx[0] * scale[3] + origin[4] += bounds_yx[2] * scale[4] + provenance = { + **provenance, + "ROIBoundsLevelZeroYX": json.dumps(bounds_yx), + "ROIOriginTCZYX": json.dumps(origin), + } ome = template.model_copy(deep=True) ome.uuid = None @@ -148,7 +246,7 @@ def export_image( if source.name.endswith("_max_z_fused.ome.zarr"): image.description = "Maximum-Z projection of the registered fused volume; Z position is the projected volume center." image.stage_label = StageLabel( - name="Fused origin", x=origin[4], y=origin[3], z=origin[2] + name="Image origin", x=origin[4], y=origin[3], z=origin[2] ) pixels.dimension_order = "XYZCT" pixels.type = {"float32": "float", "float64": "double"}.get(dtype.name, dtype.name) @@ -343,13 +441,43 @@ def export_fused( typer.echo(f"Repaired OME metadata: {target}") return + template, provenance = load_export_metadata(fused) + for source, target in zip(sources, targets, strict=False): + export_image( + source, + target, + template, + provenance, + workers=workers, + ) + typer.echo(f"Created {target}") + + +def load_export_metadata(image_store: Path) -> tuple[OME, dict[str, str]]: + """Load the shared acquisition template and processing provenance. + + Parameters + ---------- + image_store : pathlib.Path + Processed OME-Zarr store with optional acquisition and journal companions. + + Returns + ------- + tuple[OME, dict[str, str]] + OME template and serialized source and processing provenance. + """ + stem = export_source_stem(image_store) template = OME(creator="opm-processing-v2") provenance = {} - state_path = processing_state_path(fused.parent, stem) + state_path = processing_state_path(image_store.parent, stem) if state_path.is_file(): state = ProcessingState.read(state_path) provenance["ProcessingState"] = json.dumps(state.document, ensure_ascii=False) - processed = state.registered_output_for_fused(fused) + processed = ( + state.registered_output_for_fused(image_store) + if image_store.name.endswith("_fused.ome.zarr") + else image_store + ) source = Path(state.document["source"]["path"]) if not source.is_absolute(): source = state_path.parent / source @@ -432,15 +560,13 @@ def export_fused( 0 ].scale_transform.scale[0] pixels.time_increment_unit = "s" - for source, target in zip(sources, targets, strict=False): - export_image( - source, - target, - template, - provenance, - workers=workers, - ) - typer.echo(f"Created {target}") + if not template.images: + companion = image_store / "OME" / "METADATA.ome.xml" + if companion.is_file(): + source_xml = companion.read_text(encoding="utf-8") + template = from_xml(source_xml) + provenance["SourceOME"] = source_xml + return template, provenance def main() -> None: diff --git a/src/opm_processing/export_projections.py b/src/opm_processing/export_projections.py index 3908744..e56a9d5 100644 --- a/src/opm_processing/export_projections.py +++ b/src/opm_processing/export_projections.py @@ -23,11 +23,11 @@ app = typer.Typer(pretty_exceptions_enable=False) -SUFFIX = "_decon_deskewed.ome.zarr" +SUFFIX = "_deskewed.ome.zarr" def find_datasets(path: Path) -> list[Path]: - """Resolve the full deconvolved/deskewed store directly within an acquisition root. + """Resolve full deskewed data, preferring deconvolution when available. Parameters ---------- @@ -37,7 +37,7 @@ def find_datasets(path: Path) -> list[Path]: Returns ------- list[Path] - Single full deconvolved/deskewed store selected for projection export. + Single full deskewed store selected for projection export. """ path = path.expanduser().resolve() @@ -49,8 +49,20 @@ def find_datasets(path: Path) -> list[Path]: datasets = sorted( p for p in path.glob(f"*{SUFFIX}") - if p.is_dir() and not p.name.endswith("_max_z" + SUFFIX) + if p.is_dir() + and not p.name.endswith(("_max_z" + SUFFIX, "_max_z_decon" + SUFFIX)) ) + deconvolved = { + p.name.removesuffix("_decon" + SUFFIX) + for p in datasets + if p.name.endswith("_decon" + SUFFIX) + } + datasets = [ + p + for p in datasets + if p.name.endswith("_decon" + SUFFIX) + or p.name.removesuffix(SUFFIX) not in deconvolved + ] if not datasets: raise ValueError(f"No full deconvolved/deskewed OME-Zarr stores in {path}") @@ -104,29 +116,29 @@ def acquisition_for(path: Path, override: Path | None = None) -> AcquisitionMeta def volume_interval_ms(acquisition: AcquisitionMetadata) -> float: - """Compute scan planes times exposure times channels (sum unequal exposures). + """Read the recorded hardware volume interval in milliseconds. Parameters ---------- - acquisition - Inspected acquisition dimensions, stage geometry, and camera calibration. + acquisition : AcquisitionMetadata + Acquisition carrying the original plane count and hardware plane period. Returns ------- float - Acquired scan-plane count times the sum of channel exposures, in milliseconds. - """ - exposures = [c.exposure_ms for c in acquisition.channels] + Original acquired-plane count times its hardware period, in milliseconds. - if not exposures or any( - e is None or not np.isfinite(e) or e <= 0 for e in exposures - ): + Raises + ------ + ValueError + Hardware timing is missing. Exposure times cannot determine scan cadence. + """ + interval = acquisition.volume_interval_s + if interval is None: raise ValueError( - "Acquisition metadata must contain a positive exposure for every channel." + "Projection timestamps require recorded hardware volume timing" ) - - planes = acquisition.scan_position_count - return planes * sum(exposures) + return 1000 * interval def timestamp(milliseconds: float) -> str: @@ -621,7 +633,24 @@ def export_dataset( if depth_color: depth_palette(1, depth_colormap) # Validate before writing any frames. - interval = volume_interval_ms(acquisition) + interval_s = collection.time_interval_s + acquired_interval_s = acquisition.volume_interval_s + if ( + interval_s is not None + and acquired_interval_s is not None + and not np.isclose( + interval_s, + acquired_interval_s, + rtol=1e-12, + atol=0, + ) + ): + raise ValueError( + "Processed NGFF time scale disagrees with acquisition hardware timing" + ) + interval = ( + 1000 * interval_s if interval_s is not None else volume_interval_ms(acquisition) + ) stem = dataset.name.removesuffix(".ome.zarr") diff --git a/src/opm_processing/export_rois.py b/src/opm_processing/export_rois.py new file mode 100644 index 0000000..c185b46 --- /dev/null +++ b/src/opm_processing/export_rois.py @@ -0,0 +1,370 @@ +"""Select maximum-Z ROIs, reconstruct raw regions, and export OME-Zarr/TIFF.""" + +import json +from pathlib import Path +from typing import TYPE_CHECKING, Annotated + +import numpy as np +import typer +from yaozarrs import open_group, v05 + +from opm_processing.export_ome_tiff import ( + export_image, + load_export_metadata, + resolve_export_image, +) + +if TYPE_CHECKING: + from napari.components import ViewerModel + from napari.layers import Image + +from opm_processing.dataio.acquisition import acquisition_stem, inspect_acquisition +from opm_processing.dataio.processing_state import ( + ProcessingState, + processing_state_path, +) +from opm_processing.dataio.roi import ( + ROI_PREVIEW_SUFFIXES, + projection_tile_footprints, + roi_from_image_pixel_rectangle, +) +from opm_processing.process_roi import process_roi + +app = typer.Typer(pretty_exceptions_enable=False) + + +def resolve_roi_source(root_path: Path) -> Path: + """Find an existing maximum-Z preview for one acquisition. + + Parameters + ---------- + root_path : pathlib.Path + Raw acquisition, processed directory, or explicit maximum-Z store. + + Returns + ------- + pathlib.Path + Fused preview or single-position maximum-Z collection. + + Raises + ------ + ValueError + Input is ambiguous or no supported preview exists. + """ + root_path = root_path.expanduser().resolve() + suffixes = ROI_PREVIEW_SUFFIXES + if any(root_path.name.endswith(suffix) for suffix in suffixes): + if root_path.is_dir(): + return root_path + store = (root_path / "zarr.json").is_file() or (root_path / ".zgroup").is_file() + directory = root_path.parent if store else root_path + stem = acquisition_stem(root_path) if store else None + for suffix in suffixes: + candidates = sorted(directory.glob(f"{stem or '*'}{suffix}")) + candidates = [candidate for candidate in candidates if candidate.is_dir()] + if len(candidates) > 1: + raise ValueError( + "Multiple previews found; pass the intended maximum-Z OME-Zarr path" + ) + if candidates: + return candidates[0] + try: + raw = inspect_acquisition(root_path).path + except (ValueError, FileNotFoundError): + raw = root_path + raise ValueError( + f"No deskewed maximum-Z preview found in {directory}. Create one first:\n" + f'uv run process "{raw}" --no-deconvolve' + ) + + +def configure_roi_projection(viewer: "ViewerModel", layers: list["Image"]) -> None: + """Show a full-depth maximum projection using napari's Z slice margins. + + Parameters + ---------- + viewer : napari.components.ViewerModel + Viewer containing the acquired time, channel and spatial dimensions. + layers : list of napari.layers.Image + Image layers whose full Z extent will be projected. + """ + viewer.dims.ndisplay = 2 + viewer.dims.order = tuple(range(viewer.dims.ndim)) + z_axis = viewer.dims.ndim - 3 + start, stop, step = viewer.dims.range[z_axis] + for layer in layers: + layer.projection_mode = "max" + viewer.dims.set_point(z_axis, (start + stop) / 2) + thickness = [0.0] * viewer.dims.ndim + # Dims ranges are inclusive world coordinates. Pad by one sample on each + # side so napari's rounded slice bounds include both boundary planes at + # every pyramid level, including translated stacks with odd Z sizes. + thickness[z_axis] = stop - start + 2 * step + viewer.dims.thickness = tuple(thickness) + + +def rectangle_bounds( + vertices: np.ndarray, shape: tuple[int, ...] +) -> tuple[int, int, int, int]: + """Round outward on the level-zero grid and clip to the image extent. + + Parameters + ---------- + vertices : numpy.ndarray + Four axis-aligned rectangle vertices in level-zero data coordinates. + shape : tuple of int + Full image dimensions, ending in YX. + + Returns + ------- + tuple[int, int, int, int] + Clipped start-inclusive, stop-exclusive bounds y0, y1, x0, x1. + + Raises + ------ + ValueError + The rectangle is invalid, rotated, nonfinite or outside the image. + """ + vertices = np.asarray(vertices, dtype=float) + if vertices.ndim != 2 or vertices.shape[0] != 4 or vertices.shape[1] < 2: + raise ValueError("Expected four rectangle vertices") + if not np.isfinite(vertices).all(): + raise ValueError("ROI coordinates must be finite") + if not np.allclose(vertices[:, :-2], vertices[0, :-2], rtol=0, atol=1e-7): + raise ValueError("Draw rectangles in the YX view") + points = vertices[:, -2:] + if len(np.unique(points[:, 0])) != 2 or len(np.unique(points[:, 1])) != 2: + raise ValueError("ROIs must be axis-aligned YX rectangles") + low = np.maximum(0, np.floor(points.min(axis=0) + 1e-7)).astype(int) + high = np.minimum(shape[-2:], np.ceil(points.max(axis=0) - 1e-7)).astype(int) + if np.any(high <= low): + raise ValueError("ROI does not intersect the image") + return int(low[0]), int(high[0]), int(low[1]), int(high[1]) + + +def select_rois( + source: Path, shape: tuple[int, ...] +) -> list[tuple[int, int, int, int]]: + """Use napari's image transform to obtain level-zero data coordinates. + + Image world_to_data uses the base image transform, regardless of the + pyramid level currently rendered. Never multiply by the active level's + downsampling factor. + + Parameters + ---------- + source : pathlib.Path + Maximum-Z image or single-position collection to open in napari. + shape : tuple of int + Level-zero image dimensions, ending in YX. + + Returns + ------- + list of tuple[int, int, int, int] + Selected rectangles as level-zero y0, y1, x0, x1 bounds. + """ + import napari + + root = open_group(source) + if isinstance(root.ome_metadata(), v05.Bf2Raw): + series = root["OME"].ome_metadata().series + if len(series) != 1: + raise ValueError("ROI viewing requires a fused or single-position preview") + viewer = napari.Viewer() + layers = viewer.open(str(source), plugin="napari-ome-zarr") + reference = layers[0] + if isinstance(root.ome_metadata(), v05.Bf2Raw): + placement = projection_tile_footprints(source)[0]["origin_zyx_um"] + for layer in layers: + layer.translate = (*layer.translate[:-3], *placement) + configure_roi_projection(viewer, layers) + # Each ROI spans all Z and timepoints. A 2D overlay also avoids napari's + # integer shape-slice keys hiding rectangles at fractional world Z values. + rois = viewer.add_shapes( + name="export ROIs", + ndim=2, + # Shapes keeps float32 vertices. Store local pixel coordinates rather + # than large stage coordinates, preserving subpixel drawing precision. + scale=reference.scale[-2:], + translate=reference.translate[-2:], + shape_type="rectangle", + edge_color="yellow", + face_color=[1, 1, 0, 0.15], + ) + rois.mode = "add_rectangle" + typer.echo( + "Viewing a full-Z maximum projection. Right-click the Z slider to adjust " + "slice thickness. Draw YX rectangles, then close napari to export all Z, " + "channels, and timepoints." + ) + napari.run() + bounds = [] + for vertices, kind in zip(rois.data, rois.shape_type, strict=True): + if kind != "rectangle": + raise ValueError("Only rectangle ROIs are supported") + world = [rois.data_to_world(vertex) for vertex in vertices] + pixels = np.asarray([reference.world_to_data(vertex) for vertex in world]) + bounds.append(rectangle_bounds(pixels, shape)) + return bounds + + +@app.command( + help="Draw multiple XY ROIs on an existing maximum-Z preview. Close napari to process their raw data and export full-depth OME-Zarr images, optionally with OME-TIFF." +) +def export_rois( + root_path: Annotated[ + Path, + typer.Argument( + help="Raw acquisition, processed directory, or maximum-Z preview." + ), + ], + output: Annotated[ + Path | None, + typer.Option(help="Destination; defaults to roi_exports beside the preview."), + ] = None, + deconvolve: Annotated[ + bool, + typer.Option( + "--deconvolve/--no-deconvolve", + help="Run RLGC on the selected raw regions before deskewing.", + ), + ] = False, + tiff: Annotated[ + bool, typer.Option(help="Also write compressed OME-BigTIFF; retain OME-Zarr.") + ] = False, + flatfield_correction: bool = False, + save_float32: bool = False, + z_downsample_level: Annotated[int, typer.Option(min=1)] = 1, + decon_crop_scan: Annotated[int | None, typer.Option(min=1)] = None, + decon_gpu_id: Annotated[int, typer.Option(min=0)] = 0, + decon_verbose: int = 1, + decon_psf_paths: list[Path] | None = None, + registration_channel: Annotated[int, typer.Option(min=0)] = 0, + resume: Annotated[ + bool, + typer.Option( + "--resume/--no-resume", + help="Reuse saved selections and compatible checkpoints. --no-resume redraws and restarts.", + ), + ] = True, + workers: Annotated[int, typer.Option(min=1, help="TIFF compression workers.")] = 4, +) -> None: + """Draw multiple XY ROIs, process their raw volumes, and export each result. + + Parameters + ---------- + root_path : pathlib.Path + Raw acquisition, processed directory, or existing maximum-Z preview. + output : pathlib.Path or None + Destination directory; None selects roi_exports beside the preview. + deconvolve : bool + Apply RLGC before orthogonal deskewing; disabled by default. + tiff : bool + Export OME-TIFF in addition to the final OME-Zarr image. + flatfield_correction : bool + Reuse or estimate a detector-response flatfield. + save_float32 : bool + Preserve float32 intensities instead of writing uint16. + z_downsample_level : int + Output Z binning factor; one retains native orthogonal sampling. + decon_crop_scan : int or None + Retained scan planes per reconstruction chunk; None selects automatically. + decon_gpu_id : int + CUDA device index for RLGC. + decon_verbose : int + Reconstruction diagnostic verbosity. + decon_psf_paths : list[pathlib.Path] or None + Channel-ordered measured PSFs; None uses recorded theoretical calibration. + registration_channel : int + Channel used for fresh local registration of multi-position ROIs. + resume : bool + Reuse saved selections and compatible processing checkpoints. + workers : int + TIFF compression workers. + """ + source = resolve_roi_source(root_path) + footprints = projection_tile_footprints(source) + if not footprints: + raise ValueError("The preview has no source tile footprints") + suffix = next( + suffix for suffix in ROI_PREVIEW_SUFFIXES if source.name.endswith(suffix) + ) + stem = source.name.removesuffix(suffix) + state = ProcessingState.read(processing_state_path(source.parent, stem)) + acquisition = inspect_acquisition(Path(state.document["source"]["path"])) + acquisition.validate_processing_calibration() + if acquisition.is_2d: + raise ValueError("ROI reconstruction requires a raw oblique 3D acquisition") + if registration_channel >= len(acquisition.channels): + raise ValueError("Registration channel is outside the acquired channel range") + destination = ( + output.expanduser().resolve() if output else source.parent / "roi_exports" + ) + manifest_path = destination / "selections.json" + if resume and manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest["source_path"] != str(acquisition.path) or manifest[ + "preview_path" + ] != str(source): + raise ValueError( + "Saved selections belong to a different acquisition or preview; choose another --output" + ) + selections = [destination / name for name in manifest["rois"]] + else: + image_path = resolve_export_image(source) + root = open_group(image_path) + dataset = root.ome_metadata().multiscales[0].datasets[0] + shape = tuple(root[dataset.path].to_tensorstore().shape) + bounds = select_rois(source, shape) + if not bounds: + typer.echo("No ROIs selected; no files exported.") + return + rois = [ + roi_from_image_pixel_rectangle( + source, np.asarray([[y0, x0], [y0, x1], [y1, x1], [y1, x0]]) + ) + for y0, y1, x0, x1 in bounds + ] + if any(not roi.position_indices for roi in rois): + raise ValueError("An ROI does not intersect any acquired tiles") + selections = [ + destination / f"roi_{index:03d}" / "roi.json" + for index in range(1, len(rois) + 1) + ] + for roi, path in zip(rois, selections, strict=True): + roi.write(path) + manifest = { + "source_path": str(acquisition.path), + "preview_path": str(source), + "rois": [path.relative_to(destination).as_posix() for path in selections], + } + temporary = manifest_path.with_suffix(".tmp") + temporary.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + temporary.replace(manifest_path) + for index, selection in enumerate(selections, 1): + result = process_roi( + acquisition.path, + selection, + output=selection.parent, + deconvolve=deconvolve, + flatfield_correction=flatfield_correction, + save_float32=save_float32, + z_downsample_level=z_downsample_level, + decon_crop_scan=decon_crop_scan, + decon_gpu_id=decon_gpu_id, + decon_verbose=decon_verbose, + decon_psf_paths=decon_psf_paths, + registration_channel=registration_channel, + resume=resume, + ) + typer.echo(f"Created {result}") + if tiff: + target = selection.parent / f"{stem}_roi_{index:03d}.ome.tiff" + template, provenance = load_export_metadata(result) + export_image(result, target, template, provenance, workers=workers) + typer.echo(f"Created {target}") + + +def main() -> None: + """Run the interactive ROI processing and export CLI.""" + app() diff --git a/src/opm_processing/fuse.py b/src/opm_processing/fuse.py index 413a74e..47c53c5 100644 --- a/src/opm_processing/fuse.py +++ b/src/opm_processing/fuse.py @@ -24,7 +24,7 @@ resolve_fusion_input, ) -app = typer.Typer() +app = typer.Typer(pretty_exceptions_enable=False) app.pretty_exceptions_enable = False @@ -59,6 +59,17 @@ def register_and_fuse( ), ), ] = None, + registration_retries: Annotated[ + int, + typer.Option( + min=0, + help=( + "Retry only pairs with expanded limits, then refit the full " + "global solution. Explicit shift limits " + "remain fixed. Set 0 to disable retries." + ), + ), + ] = 2, normalize_depth_intensity: Annotated[ bool, typer.Option( @@ -84,7 +95,7 @@ def register_and_fuse( Path | None, typer.Option( "--roi", - help="Physical ROI JSON created by display --roi-output.", + help="Physical ROI JSON saved by export-rois.", ), ] = None, ) -> None: @@ -122,13 +133,17 @@ def register_and_fuse( normalize_depth_intensity : bool Match registered depth layers using channel gains shared across XY tiles at each depth and timepoint. Save the gains alongside the fused image. + registration_retries : int + Maximum automatic search expansions; each remeasures only expanded + pairs and refits the full graph. Zero disables retries; explicit limits + stay fixed. require_gpu : bool Require CUDA registration instead of allowing the CPU backend. regenerate_max_z : bool Regenerate all maximum-Z pyramid levels from the existing fused image without registering or fusing tiles again. roi : pathlib.Path or None - Display-exported ROI restricting positions and fused YX bounds while + Saved ROI restricting positions and fused YX bounds while retaining every Z plane; None fuses the full acquisition. Returns @@ -172,6 +187,7 @@ def register_and_fuse( optimization_rel_threshold=optimization_rel_threshold, optimization_abs_threshold=optimization_abs_threshold, max_registration_shift_zyx=max_registration_shift_zyx, + registration_retries=registration_retries, roi_selection=None if roi is None else PhysicalRoi.read(roi), normalize_depth_intensity=normalize_depth_intensity, ) diff --git a/src/opm_processing/imageprocessing/camera.py b/src/opm_processing/imageprocessing/camera.py index 7aa328f..cc534a2 100644 --- a/src/opm_processing/imageprocessing/camera.py +++ b/src/opm_processing/imageprocessing/camera.py @@ -3,86 +3,6 @@ import numpy as np from numba import njit, prange -QI2LAB_STAGE_SCAN_DETECTOR_WIDTH = 1900 -QI2LAB_STAGE_SCAN_GAIN_START_X = 1046 -QI2LAB_STAGE_SCAN_GAIN_VALUES = np.asarray( - [ - 0.998620, - 0.991085, - 0.987864, - 0.980038, - 0.972417, - 0.963074, - 0.952404, - 0.946026, - 0.936700, - 0.923635, - 0.912184, - 0.901147, - 0.886930, - 0.872415, - 0.864196, - 0.850803, - 0.839574, - 0.825066, - 0.812804, - 0.801067, - 0.790662, - 0.780457, - 0.769833, - 0.761351, - 0.753541, - 0.744542, - 0.738491, - 0.730549, - 0.729804, - 0.723986, - 0.720666, - 0.717408, - 0.718568, - 0.717388, - 0.720602, - 0.721850, - 0.725791, - 0.729051, - 0.732097, - 0.739335, - 0.748341, - 0.752445, - 0.762228, - 0.770891, - 0.781373, - 0.794978, - 0.811252, - 0.826110, - 0.844273, - 0.860149, - 0.879094, - 0.896383, - 0.912213, - 0.929968, - 0.947805, - 0.965706, - 0.978314, - 0.992173, - ], - dtype=np.float32, -) - - -def qi2lab_stage_scan_camera_gain() -> np.ndarray: - """Return the fixed detector-X gain measured for the qi2lab stage camera. - - Returns - ------- - np.ndarray - Float32 detector-X response with unity outside the measured gain interval. - """ - gain = np.ones(QI2LAB_STAGE_SCAN_DETECTOR_WIDTH, dtype=np.float32) - stop = QI2LAB_STAGE_SCAN_GAIN_START_X + QI2LAB_STAGE_SCAN_GAIN_VALUES.size - gain[QI2LAB_STAGE_SCAN_GAIN_START_X:stop] = QI2LAB_STAGE_SCAN_GAIN_VALUES - return gain - @njit(inline="always") def calibrated_pixel( @@ -148,7 +68,7 @@ def camera_correct( camera_conversion: float, *, detector_x_offset: int = 0, - apply_stage_scan_gain: bool = False, + detector_gain_x: np.ndarray | None = None, ) -> np.ndarray: """Convert raw camera counts to nonnegative float32 intensities. @@ -162,8 +82,8 @@ def camera_correct( Calibrated intensity per ADU, including QE when required by the caller. detector_x_offset : int First detector column of a cropped acquisition. - apply_stage_scan_gain : bool - Divide by the measured qi2lab detector-X response after clipping. + detector_gain_x : numpy.ndarray or None + Recorded detector-X response to divide out after clipping. None disables it. Returns ------- @@ -172,12 +92,15 @@ def camera_correct( """ raw = np.asarray(raw) gain = np.empty(0, np.float32) - if apply_stage_scan_gain: + if detector_gain_x is not None: + profile = np.asarray(detector_gain_x, dtype=np.float32) start = int(detector_x_offset) stop = start + raw.shape[-1] - if start < 0 or stop > QI2LAB_STAGE_SCAN_DETECTOR_WIDTH: - raise ValueError("Detector-X crop is outside the calibrated detector") - gain = qi2lab_stage_scan_camera_gain()[start:stop] + if profile.ndim != 1 or start < 0 or stop > profile.size: + raise ValueError("Detector-X crop is outside the recorded gain calibration") + if not np.all(np.isfinite(profile) & (profile > 0)): + raise ValueError("Recorded detector gain must be finite and positive") + gain = profile[start:stop] offset, conversion = np.float32(camera_offset), np.float32(camera_conversion) if raw.ndim == 3: return camera_correct_rows(raw, offset, conversion, gain) diff --git a/src/opm_processing/imageprocessing/flatfield.py b/src/opm_processing/imageprocessing/flatfield.py index 4ccc6cf..8f933f4 100644 --- a/src/opm_processing/imageprocessing/flatfield.py +++ b/src/opm_processing/imageprocessing/flatfield.py @@ -28,33 +28,37 @@ def _flatfield_sample_indices( - n_positions: int, + positions: tuple[int, ...], n_scan_planes: int, *, + max_tiles_per_level: int = 32, planes_per_position: int = 10, ) -> list[tuple[int, list[int]]]: - """Select reproducible scan planes distributed across the acquisition. - - BaSiCPy needs independent images containing varied specimen content. In a - tiled stage scan, sampling only adjacent tiles can make specimen structure - common to the fit and therefore indistinguishable from illumination. + """Choose distributed tiles and reproducible scan samples within one depth. Parameters ---------- - n_positions : int - Number of independently acquired stage positions. + positions : tuple of int + Eligible acquisition position indices for this channel and depth. n_scan_planes : int - Number of acquired scan planes per tile. + Acquired scan planes per tile. + max_tiles_per_level : int + Maximum spatially distributed tiles sampled per depth. planes_per_position : int - Maximum raw planes sampled from each position for illumination fitting. + Maximum distinct scan planes sampled from each selected tile. Returns ------- - list[tuple[int, list[int]]] - Positions and reproducible scan-plane samples used for the illumination fit. + list of tuple[int, list[int]] + Acquisition positions paired with sorted, unique scan-plane indices. """ - samples_per_position = min(n_scan_planes, planes_per_position) - + if min(n_scan_planes, max_tiles_per_level, planes_per_position) < 1: + raise ValueError("Illumination sampling counts must be positive") + if len(positions) > max_tiles_per_level: + relative = np.linspace( + 0, len(positions) - 1, max_tiles_per_level, dtype=np.int64 + ) + positions = tuple(positions[int(index)] for index in relative) rng = np.random.default_rng(0) return [ ( @@ -63,113 +67,15 @@ def _flatfield_sample_indices( int(index) for index in rng.choice( n_scan_planes, - size=samples_per_position, + size=min(n_scan_planes, planes_per_position), replace=False, ) ), ) - for position in range(n_positions) + for position in positions ] -def _stage_z_groups( - n_positions: int, - stage_positions_zxy: np.ndarray | None, -) -> tuple[tuple[float, ...], tuple[tuple[int, ...], ...]]: - """Group tiles by repeated acquisition depth at the same stage XY. - - Parameters - ---------- - n_positions : int - Number of independently acquired stage positions. - stage_positions_zxy : np.ndarray | None - Acquisition-ordered stage coordinates in micrometers, or None for one depth group. - - Returns - ------- - tuple - Repeated-depth level labels and ordered source tile groups. - """ - if stage_positions_zxy is None: - return (0.0,), (tuple(range(n_positions)),) - positions = np.asarray(stage_positions_zxy, dtype=float) - indices = stage_z_level_indices(positions) - groups = tuple( - tuple(int(value) for value in np.flatnonzero(indices == level)) - for level in range(int(indices.max()) + 1) - ) - return tuple(float(level) for level in range(len(groups))), groups - - -def _flatfield_tile_indices( - positions: tuple[int, ...], - *, - max_tiles_per_level: int = 32, -) -> tuple[int, ...]: - """Select evenly distributed tiles independently within one depth level. - - Parameters - ---------- - positions : tuple[int, ...] - Source tile indices within one acquisition depth. - max_tiles_per_level : int - Maximum spatially distributed tiles sampled from one depth. - - Returns - ------- - tuple[int, ...] - Spatially distributed source positions selected within the depth level. - """ - if max_tiles_per_level < 1: - raise ValueError("max_tiles_per_level must be positive") - if len(positions) <= max_tiles_per_level: - return positions - relative = np.unique( - np.linspace(0, len(positions) - 1, max_tiles_per_level, dtype=np.int64) - ) - return tuple(positions[int(index)] for index in relative) - - -def _camera_corrected_images( - selection: np.ndarray | ts.TensorStore, - *, - camera_offset: float, - camera_conversion: float, - apply_stage_scan_gain: bool, -) -> np.ndarray: - """Load raw images and apply the shared camera calibration. - - Parameters - ---------- - selection : array-like or TensorStore - Uint16 camera images or a readable selection of the acquisition store. - camera_offset : float - Electronic background in ADU. - camera_conversion : float - Calibrated intensity per ADU. - apply_stage_scan_gain : bool - Divide by the measured detector-X response after dark subtraction. - - Returns - ------- - np.ndarray - Float32 calibrated images with a leading image axis for 2D selections. - """ - try: - images = selection.read().result() - except (AttributeError, TypeError): - images = np.asarray(selection) - images = np.asarray(images) - if images.ndim == 2: - images = images[np.newaxis, ...] - return camera_correct( - images, - camera_offset, - camera_conversion, - apply_stage_scan_gain=apply_stage_scan_gain, - ) - - def _resize_image_stack( images: np.ndarray, output_shape: tuple[int, int], @@ -198,22 +104,6 @@ def _resize_image_stack( return resized[:, 0].cpu().numpy() -def _flatfield_working_shape(image_shape: tuple[int, int]) -> tuple[int, int]: - """Use a rectangular working field downsampled twofold on each axis. - - Parameters - ---------- - image_shape : tuple[int, int] - Input image dimensions in scan, camera-Y, camera-X order. - - Returns - ------- - tuple[int, int] - Detector YX working dimensions reduced twofold, with each dimension at least one. - """ - return tuple(max(1, int(size) // 2) for size in image_shape) - - def _separable_residual_calibration( flatfield: np.ndarray, observations: np.ndarray, @@ -232,9 +122,18 @@ def _separable_residual_calibration( np.ndarray Mean-normalized float32 illumination with residual detector profiles applied. """ + if flatfield.shape != observations.shape[-2:] or not np.all( + np.isfinite(flatfield) & (flatfield > 0) + ): + raise ValueError("BaSiC returned an invalid illumination field") corrected = observations / flatfield[np.newaxis, :, :] residual = np.median(corrected, axis=0) - residual /= np.median(residual) + scale = np.median(residual) + if not np.isfinite(scale) or scale <= 0: + raise ValueError( + "Illumination samples do not constrain a positive detector response" + ) + residual /= scale sigma = max(4.0, observations.shape[-1] / 120.0) x_profile = np.median(residual, axis=0) @@ -243,6 +142,8 @@ def _separable_residual_calibration( sigma=sigma, mode="nearest", ) + if not np.all(x_profile > 0): + raise ValueError("Illumination samples do not constrain every detector column") x_profile /= np.mean(x_profile) y_profile = np.median( @@ -254,6 +155,8 @@ def _separable_residual_calibration( sigma=sigma, mode="nearest", ) + if not np.all(y_profile > 0): + raise ValueError("Illumination samples do not constrain every detector row") y_profile /= np.mean(y_profile) calibrated = flatfield * y_profile[:, np.newaxis] * x_profile[np.newaxis, :] @@ -262,46 +165,76 @@ def _separable_residual_calibration( def estimate_illuminations( - datastore: ts.TensorStore, + datastore: ts.TensorStore | np.ndarray, camera_offset: float, camera_conversion: float, stage_positions_zxy: np.ndarray | None = None, *, - apply_stage_scan_gain: bool = False, + detector_gain_x: np.ndarray | None = None, signal_mask: np.ndarray | None = None, ) -> np.ndarray: - """Estimate per-channel illumination fields from sampled images. + """Estimate shared per-channel illumination as the maximum over stage Z. Parameters ---------- - datastore - Array-like TPCZYX acquisition datastore. - camera_offset - Camera offset subtracted from each sampled image. - camera_conversion - Multiplicative conversion from camera units to intensity units. - stage_positions_zxy - Stage ZYX coordinates used to group repeated XY visits by depth. - apply_stage_scan_gain - Apply the fixed detector-X gain correction before fitting. - signal_mask - Optional boolean TPC mask computed before processing. At time zero, - false entries are excluded before tile subsampling for each channel. + datastore : tensorstore.TensorStore or numpy.ndarray + Nonempty TPCZYX acquisition, in raw camera ADU. Only time zero is fitted. + camera_offset : float + Recorded camera baseline in ADU, subtracted before calibration. + camera_conversion : float + Recorded calibrated intensity per ADU; must be finite and positive. + stage_positions_zxy : numpy.ndarray or None + Acquisition-ordered stage Z, X and Y coordinates in micrometers. + Repeated XY visits define depth groups. None uses one group. + detector_gain_x : numpy.ndarray or None + Recorded detector-X response divided out before illumination fitting. + signal_mask : numpy.ndarray or None + TPC decisions: 1 selects a tile, zero excludes it and -1 is + unchecked and excluded. Boolean masks are also accepted. Returns ------- numpy.ndarray - Illumination fields in CYX order for a global estimate or ZCYX order - when stage positions are supplied. + Dimensionless fields in CYX order without stage positions, or 1CYX order + when stage positions are supplied. Each channel is the pixelwise maximum + of the fitted stage-depth fields and is applied at every depth. + + Raises + ------ + ValueError + Calibration or dimensions are invalid, fewer than two nonempty images + constrain a selected fit, or the fitted response is unusable. """ - stage_z_levels, position_groups = _stage_z_groups( - int(datastore.shape[1]), - stage_positions_zxy, + if len(datastore.shape) != 6 or min(datastore.shape) < 1: + raise ValueError( + "Illumination estimation requires a nonempty TPCZYX acquisition" + ) + if ( + not np.isfinite(camera_offset) + or not np.isfinite(camera_conversion) + or camera_conversion <= 0 + ): + raise ValueError( + "Camera offset must be finite and conversion finite and positive" + ) + n_positions = int(datastore.shape[1]) + if stage_positions_zxy is None: + depth_indices = np.zeros(n_positions, dtype=int) + else: + positions = np.asarray(stage_positions_zxy, dtype=float) + if positions.shape != (n_positions, 3) or not np.isfinite(positions).all(): + raise ValueError( + "Stage positions must contain one finite ZXY coordinate per tile" + ) + depth_indices = stage_z_level_indices(positions) + position_groups = tuple( + tuple(int(value) for value in np.flatnonzero(depth_indices == level)) + for level in range(int(depth_indices.max()) + 1) ) - # flatfields shape: physical stage z, c, y, x + # Retain one shared camera correction per channel, irrespective of depth. flatfields = np.zeros( ( - len(stage_z_levels), + 1, datastore.shape[2], datastore.shape[-2], datastore.shape[-1], @@ -309,111 +242,145 @@ def estimate_illuminations( dtype=np.float32, ) camera_shape = (datastore.shape[-2], datastore.shape[-1]) - working_shape = _flatfield_working_shape(camera_shape) + working_shape = tuple(max(1, int(size) // 2) for size in camera_shape) n_channels = int(datastore.shape[2]) + fitted_channels = np.zeros(n_channels, dtype=bool) if signal_mask is not None: - signal_mask = np.asarray(signal_mask, dtype=bool) + signal_mask = np.asarray(signal_mask) + if ( + signal_mask.shape != tuple(datastore.shape[:3]) + or not np.isin(signal_mask, (-1, 0, 1)).all() + ): + raise ValueError( + "Illumination signal mask must have TPC shape and contain -1, 0 or 1" + ) + signal_mask = signal_mask > 0 - progress = tqdm( + with tqdm( total=len(position_groups) * n_channels, desc="illumination fitting", unit="channel", leave=False, - ) - for stage_level_index, positions in enumerate(position_groups): - for chan_idx in range(n_channels): - progress.set_postfix( - stage_level=stage_level_index, - channel=chan_idx, - ) - eligible_positions = ( - positions - if signal_mask is None - else tuple( - position - for position in positions - if signal_mask[0, position, chan_idx] + ) as progress: + for stage_level_index, positions in enumerate(position_groups): + for chan_idx in range(n_channels): + progress.set_postfix( + stage_level=stage_level_index, + channel=chan_idx, ) - ) - selected_positions = _flatfield_tile_indices(eligible_positions) - if not selected_positions: - flatfields[stage_level_index, chan_idx] = 1.0 - progress.update() - continue - samples_per_position = _flatfield_sample_indices( - len(selected_positions), - datastore.shape[-3], - ) - sample_indices = [ - (selected_positions[group_position], scan_indices) - for group_position, scan_indices in samples_per_position - ] - # One tile per depth is common for Z-only acquisitions. Collapsing - # its scan planes to one median leaves BaSiC without independent - # observations (and its baseline becomes a scalar). Keep the scan - # planes as samples in this case; retain tile summaries for mosaics. - single_tile = len(sample_indices) == 1 - n_fit_images = ( - len(sample_indices[0][1]) if single_tile else len(sample_indices) - ) - if n_fit_images < 2: - raise ValueError( - "Flatfield estimation requires at least two sampled images " - f"at depth {stage_level_index}, channel {chan_idx}; " - "only one tile with one scan plane is available." + eligible_positions = ( + positions + if signal_mask is None + else tuple( + position + for position in positions + if signal_mask[0, position, chan_idx] + ) ) - fit_images = np.empty( - (n_fit_images, *working_shape), - dtype=np.float32, - ) - calibration_images = np.empty( - (len(sample_indices), *working_shape), dtype=np.float32 - ) - for image_index, (pos_idx, scan_indices) in enumerate(sample_indices): - temp_images = _camera_corrected_images( - datastore[0, pos_idx, chan_idx, scan_indices, :, :], - camera_offset=camera_offset, - camera_conversion=camera_conversion, - apply_stage_scan_gain=apply_stage_scan_gain, + sample_indices = _flatfield_sample_indices( + eligible_positions, datastore.shape[-3] ) - resized = _resize_image_stack(temp_images, working_shape) - if single_tile: - fit_images[:] = resized - else: - fit_images[image_index] = np.median(resized, axis=0) - calibration_images[image_index] = np.percentile( - resized, - 75, - axis=0, + if not sample_indices: + # An excluded depth must not floor valid estimates from other depths. + progress.update() + continue + # One tile per depth is common for Z-only acquisitions. Collapsing + # its scan planes to one median leaves BaSiC without independent + # observations (and its baseline becomes a scalar). Keep the scan + # planes as samples in this case; retain tile summaries for mosaics. + single_tile = len(sample_indices) == 1 + n_fit_images = ( + len(sample_indices[0][1]) if single_tile else len(sample_indices) + ) + fit_images = np.empty( + (n_fit_images, *working_shape), + dtype=np.float32, + ) + calibration_images = np.empty( + (len(sample_indices), *working_shape), dtype=np.float32 + ) + for image_index, (pos_idx, scan_indices) in enumerate(sample_indices): + selection = datastore[0, pos_idx, chan_idx, scan_indices, :, :] + raw = ( + selection.read().result() + if hasattr(selection, "read") + else np.asarray(selection) + ) + temp_images = camera_correct( + raw, + camera_offset, + camera_conversion, + detector_gain_x=detector_gain_x, + ) + if not np.isfinite(temp_images).all(): + raise ValueError( + "Illumination samples must contain finite calibrated intensities" + ) + resized = _resize_image_stack(temp_images, working_shape) + if single_tile: + fit_images[:] = resized + else: + fit_images[image_index] = np.median(resized, axis=0) + calibration_images[image_index] = np.percentile( + resized, + 75, + axis=0, + ) + + fit_images = fit_images[np.any(fit_images > 0, axis=(1, 2))] + calibration_images = calibration_images[ + np.any(calibration_images > 0, axis=(1, 2)) + ] + if not len(fit_images): + progress.update() + continue + if len(fit_images) < 2: + raise ValueError( + "Flatfield estimation requires at least two nonempty sampled images" + ) + + basic = BaSiC( + get_darkfield=False, + sort_intensity=True, + working_size=list(working_shape), ) - basic = BaSiC( - get_darkfield=False, - sort_intensity=True, - working_size=list(working_shape), - ) - - with redirect_stdout(io.StringIO()): - basic.autotune(fit_images) - basic.smoothness_flatfield = 2.0 - basic.fit(fit_images) - fitted_flatfield = _separable_residual_calibration( - np.asarray(basic.flatfield, dtype=np.float32), - calibration_images, - ) - flatfields[stage_level_index, chan_idx, :] = _resize_image_stack( - fitted_flatfield[np.newaxis, :, :], - camera_shape, - )[0] - - del basic, fitted_flatfield, fit_images, calibration_images - - gc.collect() - if torch.cuda.is_available(): - torch.cuda.empty_cache() - gc.collect() - progress.update() + with redirect_stdout(io.StringIO()): + basic.autotune(fit_images) + basic.smoothness_flatfield = 2.0 + basic.fit(fit_images) + fitted_flatfield = _separable_residual_calibration( + np.asarray(basic.flatfield, dtype=np.float32), + calibration_images, + ) + depth_flatfield = _resize_image_stack( + fitted_flatfield[np.newaxis, :, :], + camera_shape, + )[0] + np.maximum( + flatfields[0, chan_idx], + depth_flatfield, + out=flatfields[0, chan_idx], + ) + fitted_channels[chan_idx] = True + + del ( + basic, + fitted_flatfield, + depth_flatfield, + fit_images, + calibration_images, + ) - progress.close() + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + progress.update() + # Channels without signal at any depth receive no illumination correction. + flatfields[0, ~fitted_channels] = 1.0 + if not np.all(np.isfinite(flatfields) & (flatfields > 0)): + raise ValueError( + "Illumination could not be estimated over the complete detector" + ) return flatfields[0] if stage_positions_zxy is None else flatfields diff --git a/src/opm_processing/imageprocessing/maxtilefusion.py b/src/opm_processing/imageprocessing/maxtilefusion.py index 49a41c5..2635186 100644 --- a/src/opm_processing/imageprocessing/maxtilefusion.py +++ b/src/opm_processing/imageprocessing/maxtilefusion.py @@ -16,6 +16,7 @@ from opm_processing.dataio.ngff import ( downsample_yx, + pyramid_yx_sampling, round_spatial, round_spatial_values, round_tczyx_transform, @@ -24,6 +25,7 @@ ProcessingState, processing_state_path, ) +from opm_processing.dataio.timing import time_axis from opm_processing.imageprocessing.coordinates import ( stage_positions_to_image_coordinates, ) @@ -66,10 +68,23 @@ def regenerate_fused_max_projection( source_root = open_group(fused_path) source_metadata = source_root.ome_metadata() source_multiscale = source_metadata.multiscales[0] + base_dataset = source_multiscale.datasets[0] + base = source_root[base_dataset.path].to_tensorstore() + base_scale = np.asarray(base_dataset.scale_transform.scale, dtype=np.float64) + base_origin = np.asarray( + base_dataset.translation_transform.translation + if base_dataset.translation_transform is not None + else [0] * 5, + dtype=np.float64, + ) + projection_z = base_origin[2] + 0.5 * (base.shape[2] - 1) * base_scale[2] + reduction_factors, downsample = pyramid_yx_sampling(source_multiscale) projection_datasets = [] projection_specs = [] levels = [] - for source_dataset in source_multiscale.datasets: + for source_dataset, factor in zip( + source_multiscale.datasets, reduction_factors, strict=True + ): source = source_root[source_dataset.path].to_tensorstore() time_dim, channel_dim, z_dim, y_dim, x_dim = ( int(value) for value in source.shape @@ -77,7 +92,9 @@ def regenerate_fused_max_projection( scale = np.asarray(source_dataset.scale_transform.scale, dtype=np.float64) translation = np.asarray( - source_dataset.translation_transform.translation, + source_dataset.translation_transform.translation + if source_dataset.translation_transform is not None + else [0] * 5, dtype=np.float64, ) output_scale = np.asarray(round_tczyx_transform(scale), dtype=np.float64) @@ -85,7 +102,7 @@ def regenerate_fused_max_projection( round_tczyx_transform(translation), dtype=np.float64, ) - projection_translation[2] += 0.5 * (z_dim - 1) * output_scale[2] + projection_translation[2] = projection_z projection_datasets.append( v05.Dataset( path=source_dataset.path, @@ -115,6 +132,8 @@ def regenerate_fused_max_projection( "chunk_y": chunk_y, "chunk_x": chunk_x, "z_step": min(z_dim, max(16, source_chunks[-3])), + "factor": factor, + "downsample": downsample, } ) @@ -191,6 +210,28 @@ def project_chunk( level = levels[level_index] source = level["source"] output = arrays[level["path"]] + if level_index: + # Reduce the full-depth projection, never a Z-subsampled volume. + factor = level["factor"] + projection = ( + arrays[base_dataset.path][ + time_index, + channel_index, + 0, + y_start * factor : min(y_stop * factor, base.shape[-2]), + x_start * factor : min(x_stop * factor, base.shape[-1]), + ] + .read() + .result() + ) + output[ + time_index, + channel_index, + 0, + y_start:y_stop, + x_start:x_stop, + ].write(downsample_yx(projection, factor, level["downsample"])).result() + return z_dim = int(level["shape"][2]) z_step = int(level["z_step"]) projection = None @@ -269,12 +310,13 @@ def collect(done: set[Future[None]]) -> None: return_when=FIRST_COMPLETED, ) collect(done) - while pending: - done, pending = wait( - pending, - return_when=FIRST_COMPLETED, - ) - collect(done) + # Finish the full-resolution projection before its pyramid reads it. + while pending: + done, pending = wait( + pending, + return_when=FIRST_COMPLETED, + ) + collect(done) progress.close() processing_state.set_registered_max_projection( processed_path, @@ -284,34 +326,12 @@ def collect(done: set[Future[None]]) -> None: class MaxTileFusion: - """ - A class for fusing multiple overlapping image tiles stored in a TensorStore dataset. - - Parameters - ---------- - tensorstore_path : str or Path - Path to the input TensorStore dataset. - tile_positions : list of tuple of float - List of physical YX or ZYX stage coordinates representing tile positions. - output_path : Path - Path to the output TensorStore dataset. - pixel_size : tuple of float - Pixel size in physical units, supplied as either (y, x) or (z, y, x). - reverse_stage_y : bool, default=True - Reverse the stage-derived Y placement coordinate without flipping tile - pixels. OPM stage motion is opposite image Y. - reverse_stage_z : bool, default=True - Convert physical stage Z into the opposite laboratory-Z placement - coordinate. This does not affect the projection's XY placement. - pad_yx : list of int, default = [0, 0]. - Padding in y and x dimensions already applied to the dataset - time_range: list of int, default = None - """ + """Blend stage-placed projection tiles into a bounded-memory NGFF pyramid.""" def __init__( self, - ts_dataset: str | Path, - tile_positions: list[float, float], + ts_dataset: Sequence[ts.TensorStore], + tile_positions: Sequence[Sequence[float]], output_path: str | Path, pixel_size: Sequence[float], spatial_offset_z_um: float = 0.0, @@ -324,42 +344,49 @@ def __init__( reverse_stage_y: bool = True, reverse_stage_z: bool = True, opm_angle_deg: float | None = None, + time_interval_s: float | None = None, + source_collection: str | Path | None = None, ) -> None: """Initialize maximum-projection fusion with a bounded source chunk cache. Parameters ---------- - ts_dataset + ts_dataset : sequence of tensorstore.TensorStore Per-position TensorStore arrays to fuse. - tile_positions + tile_positions : sequence of sequence of float Physical YX or ZYX position of each tile. Z does not change XY placement. - output_path + output_path : str or pathlib.Path Destination for the fused image. - pixel_size + pixel_size : sequence of float Physical YX or ZYX pixel spacing. - spatial_offset_z_um + spatial_offset_z_um : float Physical Z coordinate represented by the singleton projection plane. - source_position_indices + source_position_indices : sequence of int or None Original acquisition position index for each input tile. - pad_yx + pad_yx : sequence of int Existing YX padding to remove from each tile. - time_range + time_range : tuple[int, int] or None Optional half-open time range to fuse. - blend_pixels + blend_pixels : tuple[int, int] Feathering width along Y and X. - chunk_size + chunk_size : int Spatial output chunk size. - padding_multiple + padding_multiple : int Multiple to which the fused shape is padded. - reverse_stage_y + reverse_stage_y : bool Whether to reverse the stage-derived image-Y placement coordinate. Tile pixel arrays are not modified. - reverse_stage_z + reverse_stage_z : bool Whether to reverse physical stage Z for laboratory-coordinate placement. Tile pixels and the acquired scan axis are not modified. - opm_angle_deg + opm_angle_deg : float or None Retained for compatibility. Deskewed tile placement is independent of the OPM angle. + time_interval_s : float or None + Physical snapshot interval in seconds, preserved in the fused output. + source_collection : str or pathlib.Path or None + Source projection collection, recorded to identify the preview's + tile geometry when multiple processed collections exist. Returns ------- @@ -367,6 +394,8 @@ def __init__( No value is returned. """ self.pad_y = pad_yx[0] + time_axis(time_interval_s) + self.time_interval_s = time_interval_s self.pad_x = pad_yx[1] source_context = ts.Context( @@ -404,6 +433,11 @@ def __init__( int(value) for value in source_position_indices ) self.output_path = Path(output_path) + self.source_collection = ( + None + if source_collection is None + else str(Path(source_collection).resolve()) + ) pixel_size = tuple(float(value) for value in pixel_size) if len(pixel_size) == 2: self.z_pixel_size = 1.0 @@ -539,8 +573,15 @@ def create_fused_image(self) -> ts.TensorStore: factor for factor in (1, 2, 4, 8, 16, 32) if factor <= min(self.fused_shape) ) self.multiscale_factors_yx = factors + interval_s = getattr(self, "time_interval_s", None) + t_axis, t_scale = time_axis(interval_s) + t_origin = ( + 0 + if self.time_range is None or interval_s is None + else self.time_range[0] * t_scale + ) axes = [ - {"name": "t", "type": "time"}, + t_axis, {"name": "c", "type": "channel"}, {"name": "z", "type": "space", "unit": "micrometer"}, {"name": "y", "type": "space", "unit": "micrometer"}, @@ -556,7 +597,7 @@ def create_fused_image(self) -> ts.TensorStore: v05.ScaleTransformation( scale=round_tczyx_transform( ( - 1, + t_scale, 1, self.z_pixel_size, self.pixel_size[0] * factor, @@ -567,7 +608,7 @@ def create_fused_image(self) -> ts.TensorStore: v05.TranslationTransformation( translation=round_tczyx_transform( ( - 0, + t_origin, 0, self.spatial_offset_z_um, self.offset[0], @@ -603,7 +644,13 @@ def create_fused_image(self) -> ts.TensorStore: self.output_path, image, specs, - extra_attributes={}, + extra_attributes={ + "opm_processing_projection": { + "placement": "stage", + "source_collection": self.source_collection, + "source_position_indices": list(self.source_position_indices), + } + }, chunks=(1, 1, 1, self.chunk_size, self.chunk_size), writer="tensorstore", overwrite=True, diff --git a/src/opm_processing/imageprocessing/opmpsf.py b/src/opm_processing/imageprocessing/opmpsf.py index 6fd4ffd..c43e944 100644 --- a/src/opm_processing/imageprocessing/opmpsf.py +++ b/src/opm_processing/imageprocessing/opmpsf.py @@ -2,6 +2,10 @@ Sample the vectorial optical model in physical coordinates and interpolate its Cartesian planes onto the acquisition scan grid used by production deconvolution. +The oblique kernel integrates the optical field over each physical camera pixel +using midpoint quadrature. Numerical oversampling controls quadrature and optical +grid resolution, while the camera pitch sets pixel area. Radial tapers share the +optical kernel center. """ from __future__ import annotations @@ -16,43 +20,121 @@ from collections.abc import Sequence -def _interpolate_psf_plane( - x_grid: np.ndarray, - y_grid: np.ndarray, - values: np.ndarray, - x_coords: np.ndarray, - y_coords: np.ndarray, +def _lens_parameters(parameters: dict) -> dict: + """Require recorded physical lens parameters before evaluating the optics. + + Parameters + ---------- + parameters : dict + PSF settings containing the recorded ``lens`` calibration. + + Returns + ------- + dict + Lens calibration, with no implicit instrument parameters supplied. + + Raises + ------ + ValueError + A required physical lens parameter is missing. + """ + lens = parameters.get("lens", {}) + missing = {"NA", "ni0", "ni", "ns", "ng0", "ng", "tg0", "tg", "ti0"} - lens.keys() + if missing: + raise ValueError(f"Missing PSF lens calibration: {', '.join(sorted(missing))}") + return lens + + +def integrate_camera_pixels( + optical_field: np.ndarray, + axes: Sequence[np.ndarray], + sensor_axes: Sequence[np.ndarray], + *, + pixel_size_um: float, + theta_deg: float, + samples: int, + geometry: str = "oblique", + area_integral: bool = True, ) -> np.ndarray: - """Interpolate one regular-grid PSF plane onto an output XY grid. + """Integrate a Cartesian optical field over physical camera pixels. Parameters ---------- - x_grid : object - Cartesian X coordinates of the sampled optical PSF plane. - y_grid : object - Cartesian Y coordinates of the sampled optical PSF plane. - values : object - Optical intensities in Cartesian Y, X order. - x_coords : object - Requested skewed X sampling coordinates. - y_coords : object - Requested skewed Y sampling coordinates. + optical_field : numpy.ndarray + Optical intensity in Cartesian ZYX order. + axes : sequence of numpy.ndarray + Cartesian ZYX coordinates in micrometers. + sensor_axes : sequence of numpy.ndarray + Pixel-center coordinates: scan, camera row and column for oblique + acquisitions, or Cartesian ZYX for orthogonal acquisitions. + pixel_size_um : float + Physical camera-pixel width in object space. + theta_deg : float + Oblique camera angle to the coverslip, in degrees. + samples : int + Positive midpoint-quadrature count along each pixel dimension. + geometry : {"oblique", "cartesian"} + Camera orientation relative to the optical field. + area_integral : bool + Include physical pixel area; False returns the mean sampled intensity. Returns ------- - object - Optical PSF samples interpolated at the skewed camera coordinates. + numpy.ndarray + Pixel-integrated field on the requested sensor grid, with zero extension. + + Raises + ------ + ValueError + Pixel calibration, quadrature or coordinate arrays are invalid. """ - interpolator = RegularGridInterpolator( - (y_grid, x_grid), - values, - method="linear", - bounds_error=False, - fill_value=0.0, + if ( + isinstance(samples, bool) + or not isinstance(samples, (int, np.integer)) + or samples < 1 + ): + raise ValueError("Pixel quadrature samples must be positive integers") + if ( + not np.isfinite(pixel_size_um) + or pixel_size_um <= 0 + or not np.isfinite(theta_deg) + ): + raise ValueError("Require positive pixel width and a finite camera angle") + if geometry not in ("oblique", "cartesian"): + raise ValueError("Require oblique or cartesian sensor geometry") + if len(sensor_axes) != 3 or any( + np.ndim(a) != 1 or not len(a) or not np.isfinite(a).all() for a in sensor_axes + ): + raise ValueError("Sensor axes must be three nonempty finite coordinate arrays") + integrate = RegularGridInterpolator( + axes, optical_field, bounds_error=False, fill_value=0 ) - target_y, target_x = np.meshgrid(y_coords, x_coords, indexing="ij") - points = np.column_stack((target_y.ravel(), target_x.ravel())) - return interpolator(points).reshape(target_y.shape) + offsets = ((np.arange(samples) + 0.5) / samples - 0.5) * pixel_size_um + theta = np.deg2rad(theta_deg) + shape = tuple(map(len, sensor_axes)) + result = np.zeros(shape, dtype=np.result_type(optical_field.dtype, np.float32)) + a, b, c = sensor_axes + for start in range(0, shape[0], 8): + for dy in offsets: + for dx in offsets: + if geometry == "oblique": + z, y, x = np.broadcast_arrays( + (b[None, :, None] + dy) * np.sin(theta), + a[start : start + 8, None, None] + + (b[None, :, None] + dy) * np.cos(theta), + c[None, None, :] + dx, + ) + else: + z, y, x = np.broadcast_arrays( + a[start : start + 8, None, None], + b[None, :, None] + dy, + c[None, None, :] + dx, + ) + result[start : start + 8] += integrate(np.stack((z, y, x), axis=-1)) + result /= samples**2 + if area_integral: + result *= pixel_size_um**2 + return result # ROI tools @@ -90,7 +172,7 @@ def get_skewed_roi_size( n1 = int(np.ceil(sizes[0] / dc / np.sin(theta))) # set so that @ top and bottom z-points, ROI includes the full y-size - n0 = int(np.ceil((0.5 * (n1 + 1)) * dc * np.cos(theta) + sizes[1]) / dstep) + n0 = int(np.ceil(((0.5 * (n1 + 1)) * dc * np.cos(theta) + sizes[1]) / dstep)) if ensure_odd: if np.mod(n2, 2) == 0: @@ -178,11 +260,18 @@ def get_skewed_coords( def create_psf_silicone_100x( - dxy: float, dz: float, nxy: int, nz: int, em_wvl: float, pz: float + dxy: float, + dz: float, + nxy: int, + nz: int, + em_wvl: float, + pz: float, + *, + parameters: dict, ) -> np.ndarray: """Evaluate the vectorial emission model with the existing lateral mask. - The Gaussian mask has sigma 10 Cartesian grid pixels. This preserves the + The Gaussian mask width is supplied in Cartesian grid pixels. This preserves the established model; it does not introduce a measured light-sheet profile. The masked result is normalized by the deconvolution solver. @@ -200,34 +289,24 @@ def create_psf_silicone_100x( Emission wavelength in microns. pz : float Microns above coverslip. + parameters : dict + Lens calibration and the supplied three-dimensional taper width. Returns ------- tot_psf : ndarray Theoretical PSF in coverslip coordinates. """ - silicone_lens = { - "ni0": 1.4, # immersion medium RI design value - "ni": 1.4, # immersion medium RI experimental value - "tg0": 170, # microns, coverslip thickness design value - "tg": 170, # microns, coverslip thickness - "ns": 1.38, # specimen refractive index - "ti0": 300, - } - em_lens = {**silicone_lens, "NA": 1.35} + em_lens = _lens_parameters(parameters) lim = (nz - 1) * dz / 2 zv = np.linspace(-lim + pz, lim + pz, nz) psf = psfm.vectorial_psf(zv=zv, nx=nxy, dxy=dxy, pz=pz, wvl=em_wvl, params=em_lens) psf = psf / np.sum(psf) - x = np.linspace(1, psf.shape[1], psf.shape[1]) - x = x - psf.shape[1] / 2 - y = np.linspace(1, psf.shape[2], psf.shape[2]) - y = y - psf.shape[2] / 2 - x, y = np.meshgrid(x, y) - r = np.sqrt(np.power(x, 2) + np.power(y, 2)) - sigma = 10 + y, x = np.ogrid[: psf.shape[1], : psf.shape[2]] + r = np.hypot(y - (psf.shape[1] - 1) / 2, x - (psf.shape[2] - 1) / 2) + sigma = parameters["taper_sigma_px_3d"] radial_taper = np.exp(-np.power(r, 2) / (2 * sigma**2)) radial_taper = radial_taper / np.max(radial_taper) @@ -237,7 +316,7 @@ def create_psf_silicone_100x( def generate_proj_psf( - em_wvl: float, pixel_size_um: float = 0.115, pz: float = 15.0, plot: bool = False + em_wvl: float, pixel_size_um: float, *, parameters: dict ) -> np.ndarray: """Generate a central optical-plane kernel with the existing lateral mask. @@ -249,28 +328,24 @@ def generate_proj_psf( Emission wavelength in micrometers. pixel_size_um Camera-plane pixel size in micrometers. - pz - Emitter depth above the coverslip in micrometers. - plot - Retained for API compatibility; plotting is not performed. + parameters + Explicit lens calibration, emitter depth, grid dimensions and taper widths. Returns ------- numpy.ndarray Masked central PSF plane in ZYX order with a singleton Z dimension. """ - silicone_lens = { - "ni0": 1.4, # immersion medium RI design value - "ni": 1.4, # immersion medium RI experimental value - "tg0": 170, # microns, coverslip thickness design value - "tg": 170, # microns, coverslip thickness - "ns": 1.38, # specimen refractive index - "ti0": 300, - } - em_lens = {**silicone_lens, "NA": 1.35} - + em_lens = _lens_parameters(parameters) + pz = parameters["projection_depth_um"] psf = psfm.vectorial_psf_centered( - nz=20, dz=0.115, nx=71, dxy=pixel_size_um, pz=pz, wvl=em_wvl, params=em_lens + nz=parameters["projection_nz"], + dz=parameters["projection_dz_um"], + nx=parameters["projection_nxy"], + dxy=pixel_size_um, + pz=pz, + wvl=em_wvl, + params=em_lens, ) psf = psf / np.sum(psf) # A projection acquisition contains one observed YX plane. Use the central @@ -280,13 +355,9 @@ def generate_proj_psf( psf = psf / np.sum(psf) if psf.ndim == 2: psf = psf[np.newaxis, :, :] - x = np.linspace(1, psf.shape[1], psf.shape[1]) - x = x - psf.shape[1] / 2 - y = np.linspace(1, psf.shape[2], psf.shape[2]) - y = y - psf.shape[2] / 2 - x, y = np.meshgrid(x, y) - r = np.sqrt(np.power(x, 2) + np.power(y, 2)) - sigma = 15 + y, x = np.ogrid[: psf.shape[1], : psf.shape[2]] + r = np.hypot(y - (psf.shape[1] - 1) / 2, x - (psf.shape[2] - 1) / 2) + sigma = parameters["taper_sigma_px_2d"] radial_taper = np.exp(-np.power(r, 2) / (2 * sigma**2)) radial_taper = radial_taper / np.max(radial_taper) @@ -297,13 +368,13 @@ def generate_proj_psf( def generate_skewed_psf( em_wvl: float, - pixel_size_um: float = 0.115, - scan_axis_step_um: float = 0.4, - theta_deg: float = 30.0, - pz: float = 0.0, - plot: bool = False, + pixel_size_um: float, + scan_axis_step_um: float, + theta_deg: float, + *, + parameters: dict, ) -> np.ndarray: - """Create OPM PSF in skewed coordinates. + """Integrate the optical PSF over pixels in the oblique camera plane. Parameters ---------- @@ -315,10 +386,10 @@ def generate_skewed_psf( step size in microns theta_deg: float OPM tilt angle in degrees. - pz: float - distance above coverslip in microns - plot: bool - whether to plot the PSF + parameters: dict + Lens calibration, source depth, support and taper. ``grid_oversampling`` + is a positive integer controlling optical-grid spacing and midpoint + quadrature samples along each camera-pixel dimension. Returns ------- @@ -326,60 +397,68 @@ def generate_skewed_psf( OPM PSF in skewed coordinates """ dc = pixel_size_um - na = 1.35 - ni = 1.4 + lens = _lens_parameters(parameters) + na = lens["NA"] + ni = lens["ni"] + pz = parameters["skewed_depth_um"] dstage = scan_axis_step_um theta = np.deg2rad(theta_deg) xy_res = 1.6163399561827614 / np.pi * em_wvl / na z_res = 2.355 * (np.sqrt(6) / np.pi * ni * em_wvl / na**2) - z_res_mod = z_res + np.divide(pz + 1e-12, 15) / 5 + z_res_mod = ( + z_res + + np.divide(pz + 1e-12, parameters["depth_scale_um"]) + / parameters["depth_width_scale"] + ) roi_skewed_size = get_skewed_roi_size( - [z_res_mod * 12, xy_res * 12, xy_res * 12], theta, dc, dstage, ensure_odd=True + [ + z_res_mod * parameters["support_fwhms"], + xy_res * parameters["support_fwhms"], + xy_res * parameters["support_fwhms"], + ], + theta, + dc, + dstage, + ensure_odd=True, ) # make square roi_skewed_size[2] = roi_skewed_size[1] - # get tilted coordinates - x, y, z = get_skewed_coords(roi_skewed_size, dc, dstage, theta) - dx = x[0, 0, 1] - x[0, 0, 0] - dy = y[0, 1, 0] - y[0, 0, 0] - dz = z[0, 1, 0] - z[0, 0, 0] - - z -= z.mean() - x -= x.mean() - y -= y.mean() - - # get on grid of coordinates - dxy = 0.5 * np.min([dx, dy]) - dz = 0.5 * dz - - # Only the target coordinate extent is needed for interpolation. - nxy = np.max( - [ - 2 * int(np.ceil(np.max(np.abs(x)) / dxy)) + 1, - 2 * int(np.ceil(np.max(np.abs(y)) / dxy)) + 1, - ] + samples = parameters["grid_oversampling"] + if isinstance(samples, bool) or int(samples) != samples or samples < 1: + raise ValueError("grid_oversampling must be a positive integer") + samples = int(samples) + x, y, _ = get_skewed_coords(roi_skewed_size, dc, dstage, theta) + x, y = x - x.mean(), y - y.mean() + dxy = dc * np.cos(theta) / samples + dz = dc * np.sin(theta) / samples + # Cover the complete pixel footprints, with one Z guard plane on each side. + half_xy = max( + np.max(np.abs(x)) + dc / 2, np.max(np.abs(y)) + dc * np.cos(theta) / 2 + ) + nxy = 2 * int(np.ceil(half_xy / dxy)) + 1 + nz = roi_skewed_size[1] * samples + 2 + axis_xy = (np.arange(nxy) - nxy // 2) * dxy + # psfmodels otherwise integrates Cartesian XY cells of width dxy. Evaluate + # point intensities here; the camera pixel is tilted and has width dc. + optical_parameters = dict( + parameters, lens={**parameters["lens"], "sf": 1, "mode": 1} + ) + psf_grid = create_psf_silicone_100x( + dxy, dz, nxy, nz, em_wvl, pz, parameters=optical_parameters + ) + sensor_axes = tuple( + (np.arange(n) - (n - 1) / 2) * spacing + for n, spacing in zip(roi_skewed_size, (dstage, dc, dc), strict=True) + ) + axis_z = (np.arange(nz) - (nz - 1) / 2) * dz + return integrate_camera_pixels( + psf_grid, + (axis_z, axis_xy, axis_xy), + sensor_axes, + pixel_size_um=dc, + theta_deg=theta_deg, + samples=samples, ) - nz = z.size - - xg = np.arange(nxy) * dxy - xg -= xg.mean() - yg = np.arange(nxy) * dxy - yg -= yg.mean() - - psf_grid = create_psf_silicone_100x(dxy, dz, nxy, nz, em_wvl, pz) - - # get value from interpolation - skewed_psf = np.zeros(roi_skewed_size) - for ii in range(nz): - skewed_psf[:, ii, :] = _interpolate_psf_plane( - xg, - yg, - psf_grid[ii], - x.ravel(), - y[:, ii].ravel(), - ) - - return skewed_psf diff --git a/src/opm_processing/imageprocessing/opmtools.py b/src/opm_processing/imageprocessing/opmtools.py index 6f8b61d..5bb4b4a 100644 --- a/src/opm_processing/imageprocessing/opmtools.py +++ b/src/opm_processing/imageprocessing/opmtools.py @@ -1,4 +1,39 @@ -"""Estimate deskew dimensions and interpolate calibrated oblique scans to ZYX grids.""" +"""Orthogonal interpolation of oblique camera scans onto laboratory ZYX grids. + +References +---------- +Vincent Albert Maioli, "High-speed 3-D fluorescence imaging by oblique plane +microscopy: multi-well plate-reader development, biological applications and +image analysis", PhD thesis, Imperial College London (awarded 2017): +https://doi.org/10.25560/68022 . This is the orthogonal-interpolation reference +cited for the original qi2lab stage-scanning implementation in Sapoznik et al. +(2020), https://doi.org/10.7554/eLife.57681 . + +Notes +----- +Interpolation projects each output point perpendicularly onto its two adjacent +acquired planes, interpolates between two detector rows on each plane, then +combines those four source samples. It does not apply an affine image warp. + +Compared with the earlier OPM code's equal-plane contribution, plane values +are now weighted by d1/(d0+d1) and d0/(d0+d1), where d0,d1 are perpendicular +distances. These normalized weights preserve constants and linear laboratory +fields. The supported 30-degree photon-volume gain is 2*pixel_size/scan_step; +it is applied separately from the normalized interpolation. Optional Z averaging +retains the full bin divisor for partial bins, with coverage handled in fusion. + +Output tiling evaluates this same kernel at global ZYX coordinates, without +rebasing the oblique geometry or resetting Z-averaging bins at tile boundaries. +The complete final Z bin is retained, with unsupported samples contributing zero. + +Geometry uses float64 coordinates; weights and output intensities use float32. +Numba parallelizes independent output rows and reuses their quartet geometry +across detector X. Unsupported samples and padding are zero. Singleton/paired +Z bins use direct writes; larger bins accumulate without intermediate image +allocations. Float output is retained here; final output casting belongs to the +processing workflow. Output shape, orientation and padding conventions remain +explicit in the public functions below. +""" from __future__ import annotations @@ -16,11 +51,12 @@ @njit def deskew_shape_estimator( input_shape: Sequence[int], - theta: float = 30.0, - distance: float = 0.4, - pixel_size: float = 0.115, + theta: float, + distance: float, + pixel_size: float, crop_after_deskew: bool = True, divisible_by: int = 4, + downsample_factor: int = 1, ) -> tuple[list[int], int, int, int]: """Generate shape of orthogonal interpolation output array. @@ -29,19 +65,21 @@ def deskew_shape_estimator( Parameters ---------- - input_shape: Sequence[int] - shape of oblique array - theta: float - angle relative to coverslip - distance: float - step between image planes along coverslip - pixel_size: float - in-plane camera pixel size in OPM coordinates + input_shape : Sequence[int] + Oblique scan, camera-Y and camera-X dimensions. + theta : float + Recorded angle relative to the coverslip, in degrees. + distance : float + Recorded scan spacing along the coverslip, in micrometers. + pixel_size : float + Recorded in-plane camera pixel pitch, in micrometers. crop_after_deskew : bool Remove the lateral triangular deskew margins from the output shape. divisible_by : int Pad output YX dimensions to multiples of this integer. + downsample_factor : int + Number of laboratory Z planes per output bin, including a partial last bin. Returns ------- @@ -54,7 +92,25 @@ def deskew_shape_estimator( crop_y : int Rows removed from each end of laboratory Y before padding. """ - # change step size from physical space (nm) to camera space (pixels) + if len(input_shape) != 3 or min(input_shape) < 1: + raise ValueError("Deskew requires a nonempty scan-YX volume") + if ( + not np.isfinite(theta) + or not np.isfinite(distance) + or not np.isfinite(pixel_size) + or not 0 < theta < 90 + or distance <= 0 + or pixel_size <= 0 + ): + raise ValueError("Deskew requires finite positive sampling and 0 < theta < 90") + if ( + divisible_by < 1 + or int(divisible_by) != divisible_by + or downsample_factor < 1 + or int(downsample_factor) != downsample_factor + ): + raise ValueError("Deskew padding and Z averaging must be positive integers") + # Convert recorded micrometer spacing to camera-pixel units. pixel_step = distance / pixel_size # (pixels) # calculate the number of pixels scanned during stage scan @@ -75,13 +131,17 @@ def deskew_shape_estimator( else: crop_y = 0 + if final_ny < 1: + raise ValueError("Deskew cropping leaves no laboratory-Y extent") + # Pad YX dimensions to the configured multiple. pad_y = (divisible_by - (final_ny % divisible_by)) % divisible_by pad_x = (divisible_by - (final_nx % divisible_by)) % divisible_by padded_final_ny = final_ny + pad_y padded_final_nx = final_nx + pad_x - return [final_nz, padded_final_ny, padded_final_nx], pad_y, pad_x, crop_y + averaged_nz = (final_nz + int(downsample_factor) - 1) // int(downsample_factor) + return [averaged_nz, padded_final_ny, padded_final_nx], pad_y, pad_x, crop_y @njit(inline="always") @@ -115,7 +175,9 @@ def deskew_row( ------- tuple Validity, preceding scan, preceding/following detector rows, and four - float32 interpolation weights. Invalid rows have zero weights. + float32 interpolation weights combining detector-row interpolation with + perpendicular-distance weighting between planes. Valid weights sum to + one; invalid rows have zero weights. """ virtual_plane = y - z / tangent before = int(np.floor(virtual_plane * (1 / step))) @@ -131,20 +193,25 @@ def deskew_row( np.float32(0), ) za = z / sine - position_before = za + (virtual_plane - before * step) * cosine - position_after = za - (step - (virtual_plane - before * step)) * cosine + displacement = virtual_plane - before * step + position_before = za + displacement * cosine + position_after = za - (step - displacement) * cosine rb, ra = int(np.floor(position_before)), int(np.floor(position_after)) valid = rb >= 0 and ra >= 0 and rb + 1 < ny and ra + 1 < ny db, da = position_before - rb, position_after - ra + # Perpendicular distances are displacement*sin(theta) and + # (step-displacement)*sin(theta). Their common sine cancels in d_other/sum(d). + after_weight = min(1.0, max(0.0, displacement / step)) + before_weight = 1.0 - after_weight return ( valid, before, rb, ra, - np.float32(da), - np.float32(1 - da), - np.float32(db), - np.float32(1 - db), + np.float32(after_weight * da), + np.float32(after_weight * (1 - da)), + np.float32(before_weight * db), + np.float32(before_weight * (1 - db)), ) @@ -163,7 +230,7 @@ def deskew_sample( geometry : tuple Source indices and weights returned by ``deskew_row``. gain : np.float32 - Reciprocal scan spacing in camera-pixel units. + Photon-volume gain, ``2 / step`` at the supported 30-degree angle. Returns ------- @@ -188,11 +255,12 @@ def interpolate_rows( output: np.ndarray, final_ny: int, final_nz: int, - theta: float = 30.0, - distance: float = 0.4, - pixel_size: float = 0.115, + theta: float, + distance: float, + pixel_size: float, downsample_factor: int = 2, zero_initialized: bool = True, + output_origin: tuple[int, int, int] = (0, 0, 0), ) -> np.ndarray: """Parallelize orthogonal interpolation over independent output rows. @@ -215,6 +283,9 @@ def interpolate_rows( zero_initialized : bool Whether output was allocated with zeros. Skip unsupported rows in that case; otherwise write every row, including padding and empty support. + output_origin : tuple[int, int, int] + Global averaged-Z, Y and X indices of the output window's first voxel. + Source data and unpadded dimensions always describe the full input volume. Returns ------- @@ -229,17 +300,20 @@ def interpolate_rows( """ planes, ny, nx = data.shape nz, padded_y, padded_x = output.shape + z_origin, y_origin, x_origin = output_origin + valid_x = min(padded_x, max(0, nx - x_origin)) angle = np.radians(theta) tangent, sine, cosine = np.tan(angle), np.sin(angle), np.cos(angle) step = distance / pixel_size - gain = np.float32(1 / step) + gain = np.float32(2 / step) for task in prange(nz * padded_y): row = np.int64(task) - z_ds, y = row // padded_y, row % padded_y + z_local, y_local = row // padded_y, row % padded_y + z_ds, y = z_local + z_origin, y_local + y_origin if y >= final_ny: if not zero_initialized: for x in range(padded_x): - output[z_ds, y, x] = np.float32(0) + output[z_local, y_local, x] = np.float32(0) continue g0 = deskew_row( z_ds * downsample_factor, @@ -255,22 +329,25 @@ def interpolate_rows( if downsample_factor == 1: if zero_initialized and not g0[0]: continue - for x in range(nx): - output[z_ds, y, x] = deskew_sample(data, x, g0, gain) + for x in range(valid_x): + output[z_local, y_local, x] = deskew_sample( + data, x + x_origin, g0, gain + ) elif downsample_factor == 2: g1 = deskew_row( z_ds * 2 + 1, y, planes, ny, final_nz, step, tangent, sine, cosine ) if zero_initialized and not g0[0] and not g1[0]: continue - for x in range(nx): + for x in range(valid_x): total = np.float32( - deskew_sample(data, x, g0, gain) + deskew_sample(data, x, g1, gain) + deskew_sample(data, x + x_origin, g0, gain) + + deskew_sample(data, x + x_origin, g1, gain) ) - output[z_ds, y, x] = total / np.float32(2) + output[z_local, y_local, x] = total / np.float32(2) else: - for x in range(nx): - output[z_ds, y, x] = np.float32(0) + for x in range(valid_x): + output[z_local, y_local, x] = np.float32(0) for z in range( z_ds * downsample_factor, min((z_ds + 1) * downsample_factor, final_nz) ): @@ -278,70 +355,123 @@ def interpolate_rows( z, y, planes, ny, final_nz, step, tangent, sine, cosine ) if geometry[0]: - for x in range(nx): - output[z_ds, y, x] += deskew_sample(data, x, geometry, gain) - for x in range(nx): - output[z_ds, y, x] /= np.float32(downsample_factor) + for x in range(valid_x): + output[z_local, y_local, x] += deskew_sample( + data, x + x_origin, geometry, gain + ) + for x in range(valid_x): + output[z_local, y_local, x] /= np.float32(downsample_factor) if not zero_initialized: - for x in range(nx, padded_x): - output[z_ds, y, x] = np.float32(0) + for x in range(valid_x, padded_x): + output[z_local, y_local, x] = np.float32(0) return output def orthogonal_deskew( data: ArrayLike, - theta: float = 30.0, - distance: float = 0.4, - pixel_size: float = 0.115, + theta: float, + distance: float, + pixel_size: float, reverse_deskewed_z: bool = False, divisible_by: int = 4, downsample_factor: int = 2, *, zero_initialized: bool = True, + tile_shape: tuple[int, int, int] | None = None, + out: np.ndarray | None = None, ) -> np.ndarray: - """Deskew oblique data into a float32 laboratory volume. + """Deskew oblique data on one global grid, optionally writing output tiles. Parameters ---------- data : ArrayLike - Image stack in scan, detector-Y, detector-X order. + Full image stack in scan, detector-Y, detector-X order. theta : float - Acquisition angle relative to the coverslip, in degrees. + Recorded acquisition angle relative to the coverslip, in degrees. distance : float - Distance between scan planes in micrometers. + Recorded distance between scan planes in micrometers. pixel_size : float - In-plane camera pixel size in micrometers. + Recorded in-plane camera pixel size in micrometers. reverse_deskewed_z : bool Reverse the laboratory Z axis after interpolation. divisible_by : int - Pad Y and X to multiples of this integer. + Pad Y and X to multiples of this positive integer. downsample_factor : int - Average this many laboratory Z planes; the default remains two. + Average this many laboratory Z planes. A partial final bin uses the full + divisor, with unsupported source samples contributing zero. zero_initialized : bool - Use a zero-filled allocation; False writes every voxel explicitly for chunks. + Use a zero-filled internal allocation; False explicitly writes every voxel. + Supplied output buffers always receive explicit writes. + tile_shape : tuple[int, int, int] or None + Maximum averaged-Z, Y and X output tile dimensions. None evaluates the + full output at once. All tiles use the same global interpolation grid. + out : numpy.ndarray or None + Optional writable float32 output array with the exact deskewed shape. + NumPy views and memory-mapped arrays are supported. A memory map together + with tile_shape bounds resident output storage; input data stays available + as a full volume. The buffer must not overlap the source data. Returns ------- - np.ndarray - Float32 ZYX volume with sampling + numpy.ndarray + Float32 ZYX volume, or the supplied output buffer, with spacing ``(pixel_size * downsample_factor, pixel_size, pixel_size)``. + + Raises + ------ + ValueError + Input dimensions, physical calibration, averaging, tile dimensions or + output buffer are invalid. """ data = np.asarray(data) + if data.ndim != 3: + raise ValueError("Deskew requires a nonempty scan-YX volume") shape, pad_y, _, _ = deskew_shape_estimator( - data.shape, theta, distance, pixel_size, False, divisible_by - ) - final_nz, final_ny = shape[0], shape[1] - pad_y - shape[0] = max(1, final_nz // downsample_factor) - output = (np.zeros if zero_initialized else np.empty)(tuple(shape), np.float32) - interpolate_rows( - data, - output, - final_ny, - final_nz, - theta, - distance, - pixel_size, - downsample_factor, - zero_initialized, + data.shape, theta, distance, pixel_size, False, divisible_by, downsample_factor ) - return output[::-1] if reverse_deskewed_z else output + downsample_factor = int(downsample_factor) + final_nz = int(np.ceil(data.shape[1] * np.sin(np.radians(theta)))) + final_ny = shape[1] - pad_y + shape = tuple(shape) + if tile_shape is None: + tile_shape = shape + if len(tile_shape) != 3 or any( + value < 1 or int(value) != value for value in tile_shape + ): + raise ValueError("Deskew tile_shape must contain three positive integers") + tile_shape = tuple(int(value) for value in tile_shape) + if out is not None: + if ( + not isinstance(out, np.ndarray) + or out.shape != shape + or out.dtype != np.float32 + or not out.flags.writeable + or np.may_share_memory(out, data) + ): + raise ValueError( + "Deskew out must be writable float32, match the output shape and not overlap data" + ) + zero_initialized = False + else: + out = (np.zeros if zero_initialized else np.empty)(shape, np.float32) + target = out[::-1] if reverse_deskewed_z else out + for z in range(0, shape[0], tile_shape[0]): + for y in range(0, shape[1], tile_shape[1]): + for x in range(0, shape[2], tile_shape[2]): + interpolate_rows( + data, + target[ + z : z + tile_shape[0], + y : y + tile_shape[1], + x : x + tile_shape[2], + ], + final_ny, + final_nz, + theta, + distance, + pixel_size, + downsample_factor, + zero_initialized, + (z, y, x), + ) + return out diff --git a/src/opm_processing/imageprocessing/rlgc.py b/src/opm_processing/imageprocessing/rlgc.py index d95689e..aa01142 100644 --- a/src/opm_processing/imageprocessing/rlgc.py +++ b/src/opm_processing/imageprocessing/rlgc.py @@ -1,24 +1,72 @@ -""" -Richardson-Lucy Gradient Consensus (RLGC) deconvolution (Manton-style core). - -Original idea for Gradient Consensus deconvolution: -James Manton and Andrew York, https://zenodo.org/records/10278919 - -The multiplicative update and gradient consensus follow the local -expansion-processing implementation. OPM uses geometry-specific padding, -data-derived initialization and same-split stopping. Validation is recorded -in docs/deconvolution_optimization_audit.md. That local implementation cites: -https://colab.research.google.com/drive/1mfVNSCaYHz1g56g92xBkIoa8190XNJpJ +"""Richardson–Lucy deconvolution with gradient consensus (RLGC). + +References +---------- +James Manton's original RLGC implementation (2023), developed with Andrew York, +Jan Becker and Craig Russell: https://github.com/jdmanton/rlgc . +Andrew York, Gradient Consensus (2023): https://doi.org/10.5281/zenodo.10278919 . +The later shared RLGC notebook is available at +https://colab.research.google.com/drive/1mfVNSCaYHz1g56g92xBkIoa8190XNJpJ . + +Yiming Liu and Sjoerd Stallinga (2026), "Application of gradient-consensus +Richardson–Lucy deconvolution to noisy undersampled brightfield microscopy +data", Biomedical Optics Express 17, 2999: https://doi.org/10.1364/BOE.597710 . +Associated MATLAB code: https://gitlab.tudelft.nl/imphys/ci/upsample-gc-rl , +gc_deconv.m at revision 0340af0d7a4c5d92a97a1b443dfedb367dfb9f3d. + +OPM adaptations +--------------- +The algorithm follows gc_deconv.m, including its read-noise compensation. +Initialize with constant mean(J). Split J+sigma^2 using do_binosplit.m's rounding, +binomial/Gaussian split and uniform remainder; subtract sigma^2/2 from each half +and clip independently at zero. The full and half ratios use sigma^2 in both +numerators and the predicted-image denominator, exactly as in gc_deconv.m. +Blur (u1-1)*(u2-1), with no intensity weighting. Negative consensus rejects an +update and zero permits it. Compute the full RL ratio directly from J, as in +MATLAB; clipped halves need not sum to J and must not define that full ratio. + +Default KL stopping follows gc_deconv.m with use_KL=1: compute normalized +D(prediction || split) for each fresh half, and stop when BOTH scores exceed +their preceding-iteration scores. As in MATLAB, retain the current estimate; +do not roll back an iteration. Score Ax on the acquired-pixel grid, including +detector response, rather than compare differently sized object and data grids. +Native reflected padding is excluded from KL scores because it is not acquired. +This is MATLAB's measurement-space convention; the paper's equations (6)-(7) +write the object estimate instead. Its 1e-4 pseudocount is retained. The existing +accepted-fraction and maximum-relative-change thresholds remain 0.001; callers +can disable KL stopping with use_kl=False. No PSNR-derived threshold is used. + +Camera gain and offset are calibrated before reconstruction. read_noise is RMS +in those calibrated input units; zero selects the photon-only reference branch. +Processing supplies the explicitly configured camera RMS read noise. This is the +reference's shifted-count approximation, not a new camera-likelihood algorithm. +The split implementation keeps +MATLAB's signed rounding remainder and Gaussian approximation at rounded counts +of 150 or higher, including its out-of-range fallback. CuPy and MATLAB random +generators differ, so the same numeric seed does not imply identical halves. + +OPM-specific differences are the 3D oblique PSF, conjugate-OTF adjoint, optional +detector response with sensitivity normalization, and symmetric image halos. +Convergence metrics include the padded native volume, while KL uses acquired +pixels only. GPU execution uses float32, efficient FFT dimensions and cached +workspaces; negative FFT roundoff in the nonnegative full RL multiplier is +clipped at zero. Scan chunking has allocation fallback. The acquired-plane +operator uses zero extension and scan-plane selection, +rather than MATLAB's 2D pixel averaging. These geometric/execution adaptations +are not a claim of identical boundary conditions or bitwise MATLAB results. + +FFT tiles use overlap-save with the PSF support at every convolution. They share +one object, initialization, count split and stopping decision. GPU-memory fallback +can keep this global state in host RAM and transfer only haloed FFT tiles. NumPy +and CuPy random generators follow the same split distribution but differ bitwise. """ import gc import logging -import timeit from collections.abc import Callable from dataclasses import dataclass import numpy as np -from tqdm import tqdm from opm_processing.cuda import preload_cuda_libraries @@ -41,6 +89,60 @@ ) +Array = np.ndarray | cp.ndarray + +_fft_cache_3d: dict[tuple[int, int, int, int], cp.ndarray] = {} + +GC_UPDATED_FRACTION_LIMIT = 0.001 +GC_RELATIVE_CHANGE_LIMIT = 0.001 + + +def _gc_stopping_metrics( + previous: cp.ndarray, + recon: cp.ndarray, + consensus: cp.ndarray, + limit: float, + max_delta: float, +) -> tuple[float, float, str | None]: + """Evaluate gc_deconv.m's stopping conditions for native and sparse solvers. + + Reference: gc_deconv.m, revision 0340af0d7a4c5d92a97a1b443dfedb367dfb9f3d, + stopping metrics and final OR condition. Siemens_rdnoise_plot.m supplies + limit1=limit2=0.001. Negative consensus values reject updates; zero accepts. + The strict inequalities and 1e-12 denominator floor are from that code. + + Parameters + ---------- + previous : array-like + Object estimate before the current iteration. + recon : array-like + Current nonnegative object estimate. + consensus : array-like + Consensus field; nonnegative entries permit an update. + limit : float + Minimum accepted-update fraction. + max_delta : float + Minimum maximum-absolute change relative to the current peak. + + Returns + ------- + tuple[float, float, str or None] + Accepted fraction, relative change and selected stopping reason. + """ + xp = cp.get_array_module(recon) + fraction = float(recon.size - xp.count_nonzero(consensus < 0)) / recon.size + denominator = xp.maximum(xp.max(recon), 1e-12) + delta = float(xp.max(xp.abs(recon - previous)) / denominator) + reason = ( + "consensus_fraction" + if fraction < limit + else "relative_change" + if delta < max_delta + else None + ) + return fraction, delta, reason + + def clear_rlgc_caches(clear_memory_pool: bool = False) -> None: """Clear cached FFT resources used by RLGC helper functions. @@ -54,6 +156,7 @@ def clear_rlgc_caches(clear_memory_pool: bool = False) -> None: ------- None """ + _fft_cache_3d.clear() try: cp.fft.config.get_plan_cache().clear() except Exception: @@ -72,7 +175,8 @@ def clear_rlgc_caches(clear_memory_pool: bool = False) -> None: def next_gpu_fft_size(x: int) -> int: - """Return the smallest cuFFT-friendly size at least as large as ``x``. + """ + Return the smallest FFT-friendly size >= ``x`` with prime factors in {2, 3}. Parameters ---------- @@ -82,16 +186,17 @@ def next_gpu_fft_size(x: int) -> int: Returns ------- int - Next length whose prime factors are in 2, 3, 5, and 7. + Next 2-3-smooth length >= ``x``. """ if x <= 1: return 1 n = x while True: m = n - for factor in (2, 3, 5, 7): - while (m % factor) == 0: - m //= factor + while (m % 2) == 0: + m //= 2 + while (m % 3) == 0: + m //= 3 if m == 1: return n n += 1 @@ -161,32 +266,6 @@ def _linear_fft_pad_width( return pad_scan, pad_y, pad_x -def remove_padding_zyx( - padded_image: cp.ndarray | np.ndarray, - pad_width: tuple[tuple[int, int], tuple[int, int], tuple[int, int]], -) -> cp.ndarray | np.ndarray: - """Remove reflected boundary and FFT padding from a reconstructed volume. - - Parameters - ---------- - padded_image : cupy.ndarray or numpy.ndarray - Padded image in Z, Y, X order. - pad_width : tuple[tuple[int, int], tuple[int, int], tuple[int, int]] - Reflected boundary and FFT padding widths for each axis. - - Returns - ------- - cupy.ndarray or numpy.ndarray - View of the input with padding removed. - """ - slices = [] - for axis, (pad_before, pad_after) in enumerate(pad_width): - start = pad_before - stop = padded_image.shape[axis] - pad_after if pad_after > 0 else None - slices.append(slice(start, stop)) - return padded_image[tuple(slices)] - - def pad_psf( psf_temp: cp.ndarray, image_shape: tuple[int, int, int], @@ -231,10 +310,12 @@ def pad_psf( def fft_conv( image: cp.ndarray, H: cp.ndarray, shape: tuple[int, int, int] ) -> cp.ndarray: - """Convolve using the transform output as the frequency workspace. + """ + Linear convolution via FFT with cached buffers (no clipping). - This computes ``irfftn(rfftn(image) * H, s=shape)`` without an additional - frequency buffer or copy. CuPy reuses FFT plans. No clipping is applied. + This computes ``irfftn(rfftn(image) * H, s=shape)`` with preallocated + work buffers. No clipping is applied here-this matches the reference + implementation used to compute predictions, ratios and consensus. Parameters ---------- @@ -250,9 +331,17 @@ def fft_conv( cupy.ndarray Convolved array in object space (float32). """ - spectrum = cp.fft.rfftn(image) - spectrum *= H - return cp.fft.irfftn(spectrum, s=shape).astype(cp.float32, copy=False) + device_id = int(cp.cuda.Device().id) + cache_key = (device_id, *shape) + if cache_key not in _fft_cache_3d: + z, y, x = shape + freq_shape = (z, y, x // 2 + 1) + _fft_cache_3d[cache_key] = cp.empty(freq_shape, dtype=cp.complex64) + + fft_buf = _fft_cache_3d[cache_key] + fft_buf[...] = cp.fft.rfftn(image) + fft_buf[...] *= H + return cp.fft.irfftn(fft_buf, s=shape).astype(cp.float32, copy=False) def _observed_region_slices( @@ -302,113 +391,48 @@ def _kl_div_into( Float32 sum of directed prediction-to-observation KLD terms, represented as a Python float. NaN terms are zeroed as in the reference. """ + xp = cp.get_array_module(p) probability_p = p + 1e-4 probability_q = q + 1e-4 - probability_p = probability_p / cp.sum(probability_p) - probability_q = probability_q / cp.sum(probability_q) - scratch[...] = probability_p * (cp.log(probability_p) - cp.log(probability_q)) - scratch[cp.isnan(scratch)] = 0 - return float(cp.sum(scratch)) + probability_p = probability_p / xp.sum(probability_p) + probability_q = probability_q / xp.sum(probability_q) + scratch[...] = probability_p * (xp.log(probability_p) - xp.log(probability_q)) + scratch[xp.isnan(scratch)] = 0 + return float(xp.sum(scratch)) -def split_kl_changes( +def split_kl_scores( predicted: cp.ndarray, - previous_prediction: cp.ndarray, split1: cp.ndarray, split2: cp.ndarray, scratch: cp.ndarray, ) -> tuple[float, float]: - """Compare two predictions against the same complementary observations. + """MATLAB's normalized D(Ax || half), evaluated on acquired pixels. - Compute D(split || predicted) - D(split || previous_prediction) for each - half. The split entropy cancels, so shared prediction logs are evaluated - once. Positive changes indicate a worse estimate. Normalizations and - weighted reductions use float64; the logarithm workspace remains float32. - Negative FFT roundoff is clipped only for this stopping statistic. + Store these two scores and compare with the preceding iteration's scores, + as gc_deconv.m does. The new halves are fresh draws, not held-out data. + Clip negative FFT roundoff only for this statistic; predictions used in + the multiplicative update are unchanged. Parameters ---------- - predicted : cupy.ndarray - Current nonnegative prediction on the measured grid. - previous_prediction : cupy.ndarray - Previous prediction on the same grid. - split1 : cupy.ndarray - First fresh photon-count half. - split2 : cupy.ndarray - Complementary fresh photon-count half. - scratch : cupy.ndarray - Float32 workspace with the same shape as the predictions. + predicted : array-like + Predicted intensities at acquired pixels. + split1 : array-like + First measured half image in the same geometry. + split2 : array-like + Second measured half image in the same geometry. + scratch : array-like + Reusable workspace matching the acquired-pixel shape. Returns ------- tuple[float, float] - Changes in normalized observation-to-prediction KLD for both halves. - """ - current = cp.maximum(predicted, 0) + 1e-4 - previous = cp.maximum(previous_prediction, 0) + 1e-4 - new_mass = cp.sum(current, dtype=cp.float64) - old_mass = cp.sum(previous, dtype=cp.float64) - scratch[...] = cp.log(previous / current) + cp.log(new_mass / old_mass) - return tuple( - float( - cp.sum((split + 1e-4) * scratch, dtype=cp.float64) - / cp.sum(split + 1e-4, dtype=cp.float64) - ) - for split in (split1, split2) - ) - - -def _child_log_prefix(base_prefix: str, suffix: str) -> str: - """Append one structured suffix to an RLGC log prefix. - - Parameters - ---------- - base_prefix : str - Existing structured log prefix, or an empty string. - suffix : str - Suffix to append as a separate token. - - Returns - ------- - str - Combined log prefix. If ``base_prefix`` is empty, returns ``suffix``. - """ - return suffix if not base_prefix else f"{base_prefix} {suffix}" - - -def _resolve_tiled_axis_geometry( - requested_crop: int, - image_size: int, - psf_support: int, - axis_name: str, -) -> tuple[int, int]: - """Resolve retained crop size and discarded processing halo for one axis. - - Parameters - ---------- - requested_crop : int - Requested retained crop size along the axis. - image_size : int - Full image size along the axis. - psf_support : int - PSF support length along the axis. - axis_name : str - Name used in validation error messages. - - Returns - ------- - tuple[int, int] - Retained tile size and hidden processing halo for the axis. + Normalized prediction-to-half KL divergences. """ - if requested_crop <= 0: - raise ValueError(f"{axis_name} must be greater than 0 for tiled 3D RLGC.") - - retained_size = min(int(requested_crop), int(image_size)) - if retained_size >= image_size: - return retained_size, 0 - - tile_pad = int(psf_support) - return retained_size, tile_pad + xp = cp.get_array_module(predicted) + predicted = xp.maximum(predicted, 0) + return tuple(_kl_div_into(predicted, half, scratch) for half in (split1, split2)) def determine_rlgc_crop_scan( @@ -460,10 +484,8 @@ def determine_rlgc_crop_scan( free_bytes, _ = cp.cuda.runtime.memGetInfo() memory_budget = int(free_bytes * memory_fraction) - # An isolated-pool measurement on the 219x243x490 workload peaked at - # 24.2 float32-equivalent buffers per padded voxel. Use 26 before applying - # memory_fraction to cover object state, OTFs, FFT workspaces, retained - # predictions, normalization and binomial/reduction temporaries. + # Reserve optical state, OTFs, FFT workspaces, split/reduction temporaries + # and optional detector-response buffers, before memory_fraction is applied. estimated_bytes_per_voxel = 26 * np.dtype(np.float32).itemsize scan_size, camera_y, camera_x = (int(size) for size in image_shape) pad_y = _axis_linear_fft_padding(camera_y, psf_shape[1]) @@ -523,574 +545,665 @@ def determine_once( ) return self.crop_scan - def remember_successful_crop(self, crop_scan: int) -> None: - """Retain a successful fallback crop for all later solver calls. - - Parameters - ---------- - crop_scan : int - Retained scan-plane count used for chunked deconvolution. - """ - self.crop_scan = int(crop_scan) +def _split_observed_counts( + observed: cp.ndarray, rng: cp.random.Generator +) -> cp.ndarray: + """Return do_binosplit.m's first, unclipped half for psplit=0.5. -def _axis_retained_bounds(retained_size: int, image_size: int) -> list[tuple[int, int]]: - """Build non-overlapping retained tile bounds that exactly cover one axis. + MATLAB rounds halfway away from zero. Below rounded count 150 it uses a + binomial draw; otherwise it uses a Gaussian with mean n/2 and variance n/4, + replacing out-of-range draws by n/2. Every pixel also receives U*(J-n), + U uniform on [0,1), including negative rounding remainders. Both dummy + distributions and the uniform draw are generated every call, as in MATLAB. + The caller forms J-first BEFORE independently clipping the two halves. Parameters ---------- - retained_size : int - Number of output samples retained from each tile along the axis. - image_size : int - Full image size along the axis. + observed : array-like + Nonnegative counts including the read-noise variance shift, when used. + rng : numpy.random.Generator or cupy.random.Generator + Random generator on the same backend as observed. Returns ------- - list[tuple[int, int]] - Inclusive-exclusive retained bounds covering ``[0, image_size)``. + array-like + Unclipped first half, including the signed fractional remainder. """ - if retained_size <= 0: - raise ValueError("retained_size must be greater than 0.") - bounds = [] - start = 0 - while start < image_size: - stop = min(start + retained_size, image_size) - bounds.append((start, stop)) - start = stop - return bounds + xp = cp.get_array_module(observed) + whole = xp.floor(observed) + counts = (whole + (observed - whole >= 0.5)).astype(xp.int64) + high_counts = counts >= 150 + low_n = xp.where(high_counts, 1, counts) + first_low = rng.binomial(low_n, p=0.5).astype(xp.float32) + high_n = xp.where(high_counts, counts, 1).astype(xp.float32) + first_high = high_n * 0.5 + xp.sqrt(high_n * 0.25) * rng.standard_normal( + observed.shape, dtype=xp.float32 + ) + first_high = xp.where( + (first_high >= high_n) | (first_high < 0), high_n * 0.5, first_high + ) + first = xp.where(high_counts, first_high, first_low) + return first + rng.random(observed.shape, dtype=xp.float32) * ( + observed - counts.astype(xp.float32) + ) -def central_psf_plane(psf: np.ndarray) -> np.ndarray: - """Return a normalized 2D central plane from a 2D or skewed 3D PSF. +def _read_noise_variance(read_noise: float) -> np.float32: + """Convert calibrated RMS noise to the variance used by gc_deconv.m. Parameters ---------- - psf : numpy.ndarray - A YX PSF or a skewed ZYX PSF. For an even Z extent, index ``Z // 2`` - is used consistently with the PSF-centering convention in this module. + read_noise : float + RMS read noise in calibrated image units. Returns ------- - numpy.ndarray - Nonnegative, unit-sum float32 PSF in YX order. + float + Variance used in the MATLAB shifted-count approximation. + + Raises + ------ + ValueError + RMS noise is negative, nonfinite or not representable. """ - psf_array = np.asarray(psf, dtype=np.float32) - if psf_array.ndim == 3: - psf_array = psf_array[psf_array.shape[0] // 2] - elif psf_array.ndim != 2: - raise ValueError(f"Expected a 2D or 3D PSF, got shape {psf_array.shape}") - if not np.all(np.isfinite(psf_array)): - raise ValueError("PSF values must be finite") - psf_array = np.maximum(psf_array, 0) - psf_sum = float(np.sum(psf_array, dtype=np.float64)) - if not psf_sum > 0: - raise ValueError("The central PSF plane must contain positive signal") - return (psf_array / psf_sum).astype(np.float32, copy=False) + sigma = float(read_noise) + if not np.isfinite(sigma) or sigma < 0 or sigma > np.sqrt(np.finfo(np.float32).max): + raise ValueError("read_noise must be finite, nonnegative and representable") + return np.float32(sigma * sigma) -def _split_observed_counts( - observed: cp.ndarray, - rng: cp.random.Generator, - *, - assign_fractional_remainder: bool = True, -) -> cp.ndarray: - """Draw a binomial half, optionally assigning fractional weighted counts. - - Whole counts follow the usual binomial split. Assign each fractional - remainder entirely to either half with equal probability, instead of - always putting it in the second half. The complementary half is - ``observed - result``, preserving the measured data. For fractional data - this is a weighted-count approximation, not an exact Poisson noise model. - Integer-only inputs retain the original binomial samples and RNG sequence. - Both native and undersampled RLGC use weighted residual assignment. +def _split_calibrated_counts( + observed: cp.ndarray, rng: cp.random.Generator, variance: np.float32 +) -> tuple[cp.ndarray, cp.ndarray]: + """Apply gc_deconv.m's shifted split, half-variance subtraction and clipping. Parameters ---------- - observed : cp.ndarray - Nonnegative measured photon counts split into independent observations. - rng : cp.random.Generator - Random generator used for reproducible count splitting. - assign_fractional_remainder : bool, default=True - Randomly assign fractional weighted counts to either half. If False, - return only integer binomial draws; the complementary half contains - every fractional remainder, matching the local reference. + observed : array-like + Nonnegative calibrated detector estimates. + rng : numpy.random.Generator or cupy.random.Generator + Backend-compatible split generator. + variance : float + Read-noise variance in squared calibrated units. Returns ------- - cupy.ndarray - One random float32 count split; subtract it from observed to obtain the other split. + tuple of array-like + Two independently clipped, variance-corrected half images. """ - counts = observed.astype(cp.int64) - split = rng.binomial(counts, p=0.5).astype(cp.float32) - if not assign_fractional_remainder: - return split - remainder = observed - counts.astype(cp.float32) - if bool(cp.any(remainder)): - remainder *= rng.random(observed.shape, dtype=cp.float32) < 0.5 - split += remainder - return split + xp = cp.get_array_module(observed) + shifted = observed if variance == 0 else observed + variance + first = _split_observed_counts(shifted, rng) + second = shifted - first + return xp.maximum(first - 0.5 * variance, 0), xp.maximum(second - 0.5 * variance, 0) -def rlgc( - image: np.ndarray, - psf: np.ndarray, - gpu_id: int = 0, - safe_mode: bool = True, - limit: float = 0.001, - max_delta: float = 0.001, - pad_yx: bool = True, - rng_seed: int | None = 42, - normalize_psf: bool = True, - release_memory: bool = True, - logger: logging.Logger | None = None, - log_prefix: str = "", - max_iterations: int = 100, -) -> np.ndarray: +def _calibrated_observations(image: np.ndarray) -> np.ndarray: + """Retain finite nonnegative calibrated estimates without quantization loss. + + Parameters + ---------- + image : array-like + Calibrated detector values before solver preparation. + + Returns + ------- + numpy.ndarray + Finite, nonnegative float32 observations. """ - Richardson-Lucy Gradient Consensus deconvolution. + observed = np.asarray(image, dtype=np.float32) + if not np.isfinite(observed).all(): + raise ValueError("Camera-calibrated counts must be finite") + return np.maximum(observed, 0) - The multiplicative update and consensus sign rule follow the local - expansion-processing reference. Initialization backprojects the measured - image. Stopping compares successive predictions against the same fresh - split, preventing changes in the split itself from triggering rollback. - Fractional counts are split without favoring either half. - OPM retains per-axis PSF halos and 2/3/5/7-smooth FFT padding. Symmetric - input padding is applied once; padded values participate in the loop. +def _detector_response( + response: np.ndarray | None, image_shape: tuple[int, ...] +) -> np.ndarray | None: + """Validate a positive detector-YX response, retaining compact storage. Parameters ---------- - image : numpy.ndarray - 2D or 3D image to be deconvolved. 2D input is treated as a single-z - stack internally. - psf : numpy.ndarray - 2D or 3D point-spread function. This PSF is padded and transformed on - the GPU to form the forward and adjoint OTFs internally. - gpu_id : int, default=0 - Which GPU to use. - safe_mode : bool, default=True - Compare observation-to-prediction KLD against the same fresh halves. - If True, stop when either half worsens; otherwise require both halves. - limit : float, default=0.001 - Minimum fraction of pixels that must be updated per iteration before - early stopping is triggered. - max_delta : float, default=0.001 - Maximum allowed relative update magnitude before early stopping is - triggered. - pad_yx : bool, default=True - If True, pad Y/X by the PSF support and expand them to FFT-friendly - sizes. Z is always padded by the PSF support. Padding is removed before - returning the result. - rng_seed : int or None, default=42 - Seed for the per-iteration 50:50 data split. Set to None for - nondeterministic splits. - normalize_psf : bool, default=True - If True, normalize the PSF to unit sum before deconvolution. Set False - only for diagnostics that intentionally preserve PSF scale. - release_memory : bool, default=True - If True, release GPU memory pools after each call. Set False when - calling in tight loops to avoid allocator thrashing. - logger : logging.Logger or None, default=None - Optional logger for per-iteration RLGC diagnostics. - log_prefix : str, default="" - Structured prefix prepended to every emitted log line. - max_iterations : int, default=100 - Upper bound on consensus updates when stochastic stopping has not fired. + response : array-like or None + Positive detector-YX response; None selects uniform sensitivity. + image_shape : tuple of int + Acquired image dimensions ending in YX. Returns ------- - numpy.ndarray - Deconvolved 3D image (float32). A 2D input is returned as a single-z - stack. + numpy.ndarray or None + Validated compact detector response, or None. + """ + if response is None: + return None + field = np.asarray(response, dtype=np.float32) + if field.ndim == 3 and field.shape[0] == 1: + field = field[0] + field = np.broadcast_to(field, image_shape[-2:]) + if not np.isfinite(field).all() or np.any(field <= 0): + raise ValueError("Detector response must be finite and strictly positive") + return None if np.all(field == 1) else field + + +class _Convolution: + """One periodic convolution, optionally evaluated by exact overlap-save tiles. + + Only FFT workspace is tiled. The object, random split, consensus and stopping + decision remain global, so a chunk boundary is never an optical boundary. """ - cp.cuda.Device(gpu_id).use() - logging_enabled = logger is not None and logger.isEnabledFor(logging.INFO) - log_tag = f"{log_prefix} " if log_prefix else "" - solver_start_time = timeit.default_timer() if logging_enabled else None - cleanup_memory_pool = bool(release_memory) - - try: - rng = cp.random.default_rng(rng_seed) - if psf.ndim == 2: - psf = np.expand_dims(psf, axis=0) - if image.ndim == 2: - image = np.expand_dims(image, axis=0) + def __init__( + self, + psf: np.ndarray, + shape: tuple[int, int, int], + crop_scan: int | None = None, + normalize: bool = True, + ) -> None: + """Prepare the optical transfer function and optional FFT tiles. - pad_width = _linear_fft_pad_width( - tuple(int(v) for v in image.shape), - tuple(int(v) for v in psf.shape), - pad_yx=pad_yx, + Parameters + ---------- + psf : numpy.ndarray + Centered optical kernel in scan-YX order. + shape : tuple of int + Periodic computational grid dimensions. + crop_scan : int or None + Retained scan planes per FFT tile; None uses the full grid. + normalize : bool + Normalize the kernel to unit mass before transforming. + """ + self.shape = tuple(shape) + self.crop_scan = shape[0] if crop_scan is None else int(crop_scan) + if self.crop_scan < 1: + raise ValueError("crop_scan must be positive") + self.radius = psf.shape[0] // 2 + self.tile_shape = ( + ( + next_gpu_fft_size(min(self.crop_scan, shape[0]) + 2 * self.radius), + *shape[1:], + ) + if self.crop_scan < shape[0] + else self.shape ) - image_gpu_np = np.pad(image, pad_width, mode="symmetric") - image_gpu = cp.asarray(image_gpu_np, dtype=cp.float32) - del image_gpu_np - psf_gpu = pad_psf( - cp.asarray(psf, dtype=cp.float32), image_gpu.shape, normalize=normalize_psf + self.otf = cp.fft.rfftn( + pad_psf(cp.asarray(psf), self.tile_shape, normalize=normalize) ) - otf = cp.fft.rfftn(psf_gpu) - otfT = cp.conjugate(otf) - otfotfT = otf * otfT - del psf_gpu - - num_z = image_gpu.shape[0] - num_y = image_gpu.shape[1] - num_x = image_gpu.shape[2] - num_pixels = num_z * num_y * num_x - num_iters = 0 - recon = cp.maximum(fft_conv(image_gpu, otfT, image_gpu.shape), 0) - recon = cp.maximum(recon, cp.max(recon) * cp.float32(1e-7)) - previous_recon = recon - previous_prediction = None - kld_scratch = cp.empty_like(image_gpu) - - if logging_enabled: - logger.info( - "%ssolver_started image_shape=%s padded_shape=%s psf_shape=%s initialization=backprojection stopping=same_split safe_mode=%s pad_yx=%s", - log_tag, - tuple(int(v) for v in image.shape), - tuple(int(v) for v in image_gpu.shape), - tuple(int(v) for v in psf.shape), - safe_mode, - pad_yx, - ) + def __call__(self, values: Array, *, adjoint: bool = False) -> Array: + """Convolve a global estimate using shared GPU FFT workspace. - while num_iters < max_iterations: - iter_start_time = timeit.default_timer() if logging_enabled else None + Parameters + ---------- + values : array-like + Object-grid values on the host or GPU. + adjoint : bool + Conjugate the transfer function to apply the adjoint. - split1 = _split_observed_counts(image_gpu, rng) - split2 = image_gpu - split1 + Returns + ------- + array-like + Convolution on the same backend and grid as values. + """ + xp = cp.get_array_module(values) + otf = self.otf.conj() if adjoint else self.otf + if self.tile_shape == self.shape: + result = fft_conv(cp.asarray(values), otf, self.shape) + return result if xp is cp else cp.asnumpy(result) + result = xp.empty_like(values) + for start in range(0, self.shape[0], self.crop_scan): + stop = min(start + self.crop_scan, self.shape[0]) + indices = xp.arange(start - self.radius, stop + self.radius) % self.shape[0] + padded = cp.zeros(self.tile_shape, dtype=cp.float32) + padded[: indices.size] = cp.asarray(values[indices]) + blurred = fft_conv(padded, otf, self.tile_shape)[ + self.radius : self.radius + stop - start + ] + result[start:stop] = blurred if xp is cp else cp.asnumpy(blurred) + return result + + +def _iterate_rlgc( + observed: Array, + shape: tuple[int, int, int], + forward: Callable, + adjoint: Callable, + smooth: Callable, + region: tuple[slice, slice, slice], + *, + gradient_consensus: bool, + max_iterations: int | None, + limit: float, + max_delta: float, + rng_seed: int | None, + use_kl: bool, + read_noise: float, + initial_estimate: np.ndarray | None, + logger: logging.Logger | None, + log_prefix: str, + callback: Callable | None, +) -> Array: + """Iterate RL/RLGC independently of geometry and FFT tiling. - Hu = fft_conv(recon, otf, image_gpu.shape) + Parameters + ---------- + observed : array-like + Calibrated measurements on the acquired grid. + shape : tuple of int + Full reconstruction dimensions in scan-YX order. + forward : callable + Map object estimates to acquired detector intensities. + adjoint : callable + Apply the exact adjoint of the supplied forward operator. + smooth : callable + Apply the optical smoothing used by the consensus gate. + region : tuple of slice + Unpadded acquired-pixel region used for KL and stopping metrics. + gradient_consensus : bool + Use the reference GC gate and split-image stopping when True. + max_iterations : int or None + Optional iteration safety cap; None leaves GC stopping uncapped. + limit : float + Minimum accepted-update fraction. + max_delta : float + Minimum maximum-absolute relative object change. + rng_seed : int or None + Seed for fresh half-image splits. + use_kl : bool + Enable the reference two-half KL stopping comparison. + read_noise : float + RMS read noise in calibrated observation units. + initial_estimate : numpy.ndarray or None + Initial object; None selects the constant mean observation. + logger : logging.Logger or None + Destination for solver diagnostics. + log_prefix : str + Context prepended to diagnostic messages. + callback : callable or None + Observer receiving the iteration index and completed estimate. - if logging_enabled: - kldim = _kl_div_into(Hu, image_gpu, kld_scratch) - kld1 = kld2 = -np.inf - if previous_prediction is not None: - kld1, kld2 = split_kl_changes( - Hu, previous_prediction, split1, split2, kld_scratch + Returns + ------- + array-like + Final nonnegative reconstruction on the selected backend. + """ + xp = cp.get_array_module(observed) + variance = _read_noise_variance(read_noise) + sensitivity = xp.maximum(adjoint(xp.ones_like(observed)), xp.float32(1e-12)) + recon = ( + xp.full(shape, xp.mean(observed), dtype=xp.float32) + if initial_estimate is None + else xp.asarray(initial_estimate).copy() + ) + rng = xp.random.default_rng(rng_seed) if gradient_consensus else None + use_kl = bool(use_kl and gradient_consensus) + scratch = xp.empty(observed[region].shape, dtype=xp.float32) if use_kl else None + previous_kl = (np.inf, np.inf) + completed = 0 + reason = "max_iterations" + while max_iterations is None or completed < max_iterations: + predicted = forward(recon) + if gradient_consensus: + split1, split2 = _split_calibrated_counts(observed, rng, variance) + if use_kl: + scores = split_kl_scores( + predicted[region], split1[region], split2[region], scratch ) - should_restore = ( - (kld1 > 0 or kld2 > 0) if safe_mode else (kld1 > 0 and kld2 > 0) - ) - if should_restore: - recon[...] = previous_recon - if logging_enabled: - logger.info( - "%sstop=restore_previous_recon best_iteration=%d elapsed_s=%.2f safe_mode=%s kld_image=%.6f kld_split1_change=%.6f kld_split2_change=%.6f", - log_tag, - max(num_iters - 1, 0), - timeit.default_timer() - solver_start_time, - safe_mode, - float(kldim), - float(kld1), - float(kld2), - ) - break - - HTratio1 = fft_conv( - cp.divide(split1, 0.5 * (Hu + 1e-12), dtype=cp.float32), - otfT, - image_gpu.shape, + if scores[0] > previous_kl[0] and scores[1] > previous_kl[1]: + reason = "split_kl" + break + previous_kl = scores + denominator = xp.maximum(predicted + variance, xp.float32(1e-12)) + half1 = adjoint(2 * (split1 + variance) / denominator) / sensitivity + half2 = adjoint(2 * (split2 + variance) / denominator) / sensitivity + consensus = smooth((half1 - 1) * (half2 - 1), sensitivity) + ratio = adjoint((observed + variance) / denominator) / sensitivity + xp.maximum(ratio, 0, out=ratio) + updated = ( + filter_update(recon, ratio, consensus) + if xp is cp + else np.where(consensus < 0, recon, recon * ratio) ) - del split1 - HTratio2 = fft_conv( - cp.divide(split2, 0.5 * (Hu + 1e-12), dtype=cp.float32), - otfT, - image_gpu.shape, + fraction, delta, reason = _gc_stopping_metrics( + recon, updated, consensus, limit, max_delta ) - del split2 - HTratio = cp.float32(0.5) * (HTratio1 + HTratio2) - previous_prediction = Hu - - consensus_map = fft_conv( - (HTratio1 - 1) * (HTratio2 - 1), otfotfT, recon.shape + else: + ratio = ( + adjoint(observed / xp.maximum(predicted, xp.float32(1e-12))) + / sensitivity ) - - previous_recon = recon - recon = filter_update(recon, HTratio, consensus_map) - - num_updated = num_pixels - cp.sum(consensus_map < 0) - recon_max = cp.maximum(cp.max(recon), cp.float32(1e-12)) - updated_fraction = float(num_updated) / float(num_pixels) - max_relative_delta = float( - cp.max(cp.abs(recon - previous_recon) / recon_max) + xp.maximum(ratio, 0, out=ratio) + updated = recon * ratio + fraction = 1.0 + delta = float( + xp.max(xp.abs(updated - recon)) / xp.maximum(xp.max(updated), 1e-12) ) - num_iters += 1 - if logging_enabled: - min_HTratio = cp.min(HTratio) - max_HTratio = cp.max(HTratio) - logger.info( - "%siteration=%03d elapsed_s=%.2f kld_image=%.6f kld_split1_change=%.6f kld_split2_change=%.6f update_min=%.3f update_max=%.3f updated_fraction=%.5f max_relative_delta=%.5f", - log_tag, - num_iters, - timeit.default_timer() - iter_start_time, - float(kldim), - float(kld1), - float(kld2), - float(min_HTratio), - float(max_HTratio), - updated_fraction, - max_relative_delta, - ) - - del HTratio, HTratio1, HTratio2, consensus_map - - if updated_fraction < limit: - if logging_enabled: - logger.info( - "%sstop=limit iteration=%03d updated_fraction=%.5f limit=%.5f", - log_tag, - num_iters, - updated_fraction, - limit, - ) - break - - if max_relative_delta < max_delta: - if logging_enabled: - logger.info( - "%sstop=max_delta iteration=%03d max_relative_delta=%.5f threshold=%.5f", - log_tag, - num_iters, - max_relative_delta, - max_delta, - ) - break - - else: - if logging_enabled: - logger.info("%sstop=max_iterations iterations=%d", log_tag, num_iters) - - recon = remove_padding_zyx(recon, pad_width) - recon_cpu = cp.asnumpy(recon).astype(np.float32) - - if logging_enabled: + reason = "relative_change" if delta < max_delta else None + recon = updated + completed += 1 + if callback is not None: + callback(completed, recon) + if logger is not None: logger.info( - "%ssolver_completed iterations=%d elapsed_s=%.2f output_shape=%s", - log_tag, - num_iters, - timeit.default_timer() - solver_start_time, - tuple(int(v) for v in recon_cpu.shape), + "%siteration=%d updated_fraction=%.6g max_relative_delta=%.6g", + log_prefix, + completed, + fraction, + delta, ) - return recon_cpu - except Exception: - cleanup_memory_pool = True - raise - finally: - if cleanup_memory_pool: - cp.cuda.Stream.null.synchronize() - clear_rlgc_caches(clear_memory_pool=True) - - -def _is_gpu_memory_error(exc: BaseException) -> bool: - """Identify CUDA allocation failures that should trigger chunk fallback. - - Parameters - ---------- - exc : BaseException - Exception raised during an RLGC attempt. - - Returns - ------- - bool - True if the exception appears to be a GPU or host memory allocation - failure; otherwise False. - """ - if isinstance(exc, MemoryError): - return True - if isinstance(exc, cp.cuda.memory.OutOfMemoryError): - return True - message = str(exc).lower() - return "out of memory" in message or "oom" in message + if reason is not None: + break + else: + reason = "max_iterations" + if logger is not None: + label = {"consensus_fraction": "limit", "relative_change": "max_delta"}.get( + reason, reason + ) + logger.info("%sstop=%s iterations=%d", log_prefix, label, completed) + return recon -def _chunked_rlgc_once( +def _rlgc_once( image: np.ndarray, psf: np.ndarray, gpu_id: int = 0, - crop_scan: int = 128, safe_mode: bool = True, - limit: float = 0.001, - max_delta: float = 0.001, + limit: float = GC_UPDATED_FRACTION_LIMIT, + max_delta: float = GC_RELATIVE_CHANGE_LIMIT, + pad_yx: bool = True, rng_seed: int | None = 42, normalize_psf: bool = True, - verbose: int = 1, release_memory: bool = True, logger: logging.Logger | None = None, log_prefix: str = "", + max_iterations: int | None = None, + detector_response: np.ndarray | None = None, + use_kl: bool = True, + read_noise: float = 0.0, + *, + scan_upsample_factor: int = 1, + boundary: str = "reflect", + crop_scan: int | None = None, + gradient_consensus: bool = True, + initial_estimate: np.ndarray | None = None, + callback: Callable | None = None, + storage: str = "gpu", ) -> np.ndarray: - """Run one chunked RLGC attempt without GPU-memory fallback retries. - - This function performs either full-frame RLGC or scan-axis tiling for the - requested crop size. Every tile keeps the full camera Y and X extents. + """Deconvolve calibrated data with the MATLAB RLGC update and stopping rules. Parameters ---------- - image : numpy.ndarray - 2D or 3D image to deconvolve. - psf : numpy.ndarray - 2D or 3D point-spread function. - gpu_id : int, default=0 - CUDA device ID to use. - crop_scan : int, default=128 - Retained tile size along the scan axis (axis 0 of normalized ZYX). - safe_mode : bool, default=True - Compare observation-to-prediction KLD against the same fresh halves. - If True, stop when either half worsens; otherwise require both halves. - limit : float, default=0.001 - Minimum fraction of pixels updated per iteration before early stopping. - max_delta : float, default=0.001 - Stop when the largest relative update falls below this threshold. - rng_seed : int or None, default=42 - Seed for the per-iteration 50:50 data split. - normalize_psf : bool, default=True - If True, normalize the padded PSF to unit sum before deconvolution. - verbose : int, default=1 - If at least 1, show a progress bar over scan-axis tiles. - release_memory : bool, default=True - If True, release CuPy memory pools and FFT caches before returning. - logger : logging.Logger or None, default=None - Optional logger for route and per-iteration diagnostics. - log_prefix : str, default="" - Structured prefix prepended to emitted log lines. + image, psf : numpy.ndarray + Nonnegative calibrated 2D/3D measurements and a centered, odd-sized PSF. + The PSF must already be sampled on the requested reconstruction grid. + read_noise : float + RMS noise in the calibrated measurement units; zero is ideal photon data. + scan_upsample_factor : int + Acquired scan spacing divided by reconstructed scan spacing. Missing + planes are unobserved, not zero-valued measurements. Requires zero + boundary extension when greater than one. + boundary : {"reflect", "zero"} + Symmetric acquisition padding, or a zero-extended object with exact + convolution transpose and acquired-plane sensitivity normalization. + crop_scan : int or None + Maximum retained scan planes per FFT tile. Overlap-save evaluates the + same global operator; splits and stopping never run independently by tile. + limit, max_delta : float + Reference accepted-voxel fraction and maximum-relative-change thresholds. + max_iterations : int or None + Optional safety cap. None uses reference GC stopping without a cap. + Ordinary RL requires an explicit cap or a positive max_delta. + rng_seed : int or None + Seed for the reference random halves, shared across all FFT tiles. + detector_response : numpy.ndarray or None + Positive YX sensitivity in the measurement operator, not input division. + use_kl : bool + Enable the reference both-halves prediction-to-split KL stopping rule. + gradient_consensus : bool + False selects ordinary RL for controlled comparisons using the same optics. + initial_estimate : numpy.ndarray or None + Positive reconstruction-grid initialization. None uses mean observations. + callback : callable or None + Receives each completed iteration number and its device array. Copy any + state that must outlive the callback; it does not control convergence. + storage : str + Backend for reconstruction state, gpu or host; FFTs always execute on CUDA. + gpu_id, safe_mode, pad_yx, normalize_psf, release_memory, logger, log_prefix + Device, compatibility, padding, normalization and execution diagnostics. + safe_mode is retained for compatibility; KL always requires both halves. Returns ------- numpy.ndarray - Deconvolved image as float32. + Float32 reconstruction. A 2D input retains the legacy singleton-Z output. """ - cp.cuda.Device(gpu_id).use() - if crop_scan <= 0: - raise ValueError("crop_scan must be greater than 0.") - - image_arr = np.asarray(image) - original_ndim = image_arr.ndim - if original_ndim == 2: - image_work = image_arr[np.newaxis, ...] - elif original_ndim == 3: - image_work = image_arr - else: - raise ValueError(f"Expected a 2D or 3D image, got shape {image_arr.shape}") - - psf_arr = np.asarray(psf) - if psf_arr.ndim not in (2, 3): - raise ValueError(f"Expected a 2D or 3D PSF, got shape {psf_arr.shape}") - psf_shape = psf_arr.shape if psf_arr.ndim == 3 else (1, *psf_arr.shape) - - # Full-frame path if tiling not needed - if crop_scan >= image_work.shape[0]: - if logger is not None and logger.isEnabledFor(logging.INFO): - logger.info( - "%spath=3d_fullframe image_shape=%s psf_shape=%s", - f"{log_prefix} " if log_prefix else "", - tuple(int(v) for v in image_arr.shape), - tuple(int(v) for v in psf_shape), - ) - output = rlgc( - image_arr, - psf, - gpu_id, - safe_mode=safe_mode, - limit=limit, - max_delta=max_delta, - pad_yx=True, - rng_seed=rng_seed, - normalize_psf=normalize_psf, - release_memory=False, - logger=logger, - log_prefix=_child_log_prefix(log_prefix, "path=3d_fullframe"), - ) - if original_ndim == 2 and output.ndim == 3 and output.shape[0] == 1: - output = np.squeeze(output, axis=0) - - # Scan-axis tiled deconvolution with discarded processing halos. The scan - # axis is axis 0 in the normalized ZYX volume; camera Y and X remain intact. - # The halo is wider than a single convolution radius because RLGC is - # iterative, so boundary influence can propagate farther than one PSF - # half-width. - else: - full_shape = image_work.shape - retained_scan, tile_pad_scan = _resolve_tiled_axis_geometry( - crop_scan, - full_shape[0], - int(psf_shape[-3]), - "crop_scan", - ) - output = np.zeros_like(image_work, dtype=np.float32) - - retained_bounds_scan = _axis_retained_bounds(retained_scan, full_shape[0]) - num_tiles = len(retained_bounds_scan) - - if logger is not None and logger.isEnabledFor(logging.INFO): - logger.info( - "%spath=3d_tiled image_shape=%s psf_shape=%s retained_shape=%s processing_halo=%s num_tiles=%d", - f"{log_prefix} " if log_prefix else "", - tuple(int(v) for v in image_work.shape), - tuple(int(v) for v in psf_shape), - (retained_scan, full_shape[1], full_shape[2]), - (tile_pad_scan, 0, 0), - num_tiles, - ) - - if verbose >= 1 and num_tiles > 1: - iterator = tqdm( - enumerate(retained_bounds_scan), - desc="Decon chunks", - total=num_tiles, - leave=False, - unit="chunk", + image = np.asarray(image, dtype=np.float32) + psf = np.asarray(psf, dtype=np.float32) + if image.ndim == 2: + image = image[None] + if psf.ndim == 2: + psf = psf[None] + for name, array in (("image", image), ("psf", psf)): + if array.ndim != 3 or not array.size or not np.isfinite(array).all(): + raise ValueError(f"{name} must be a finite nonempty 2D or 3D array") + if ( + np.any(psf < 0) + or psf.sum(dtype=np.float64) <= 0 + or any(n % 2 == 0 for n in psf.shape) + ): + raise ValueError("psf must be nonnegative with positive sum and odd dimensions") + for name, value in ( + ("scan_upsample_factor", scan_upsample_factor), + ("max_iterations", max_iterations), + ("crop_scan", crop_scan), + ): + if value is not None and ( + isinstance(value, bool) + or not isinstance(value, (int, np.integer)) + or value < 1 + ): + raise ValueError(f"{name} must be a positive integer") + if boundary not in ("reflect", "zero") or ( + boundary == "reflect" and scan_upsample_factor != 1 + ): + raise ValueError("scan upsampling requires boundary='zero'") + if ( + not np.isfinite(limit) + or limit < 0 + or not np.isfinite(max_delta) + or max_delta < 0 + ): + raise ValueError("Convergence thresholds must be finite and nonnegative") + if not gradient_consensus and max_iterations is None and max_delta == 0: + raise ValueError("Ordinary RL requires a bound or relative-change threshold") + if storage not in ("gpu", "host"): + raise ValueError("storage must be gpu or host") + xp = cp if storage == "gpu" else np + response = _detector_response(detector_response, image.shape) + image = _calibrated_observations(image) + factor = int(scan_upsample_factor) + fine_shape = ((image.shape[0] - 1) * factor + 1, *image.shape[1:]) + try: + with cp.cuda.Device(gpu_id): + pads = _linear_fft_pad_width(fine_shape, psf.shape, pad_yx=pad_yx) + padded_shape = tuple( + n + sum(p) for n, p in zip(fine_shape, pads, strict=True) ) - else: - iterator = enumerate(retained_bounds_scan) - - for tile_idx, (scan_dest_start, scan_dest_stop) in iterator: - scan_crop_start = max(scan_dest_start - tile_pad_scan, 0) - scan_crop_stop = min(scan_dest_stop + tile_pad_scan, full_shape[0]) - - crop = image_work[scan_crop_start:scan_crop_stop, :, :] - crop_array = rlgc( - crop, - psf, - gpu_id, - safe_mode=safe_mode, + core = _observed_region_slices(padded_shape, pads) + convolve = _Convolution(psf, padded_shape, crop_scan, normalize_psf) + field = None if response is None else xp.asarray(response) + reflected = boundary == "reflect" + if reflected: + observed = xp.asarray(np.pad(image, pads, mode="symmetric")) + if response is not None: + field = xp.asarray(np.pad(response, pads[-2:], mode="symmetric")) + shape = padded_shape + region = core + else: + observed = xp.asarray(image) + shape = fine_shape + region = np.s_[:, :, :] + + def blur(values: Array, adjoint: bool = False) -> Array: + """Convolve on the selected reflected or zero-extended object grid. + + Parameters + ---------- + values : array-like + Object-grid values before acquired-plane selection. + adjoint : bool + Apply the conjugate optical transfer function when True. + + Returns + ------- + array-like + Convolution on the selected reconstruction grid. + """ + if reflected: + return convolve(values, adjoint=adjoint) + padded = xp.zeros(padded_shape, dtype=xp.float32) + padded[core] = values + return convolve(padded, adjoint=adjoint)[core].copy() + + def forward(estimate: Array) -> Array: + """Predict acquired detector intensities from the object estimate. + + Parameters + ---------- + estimate : array-like + Nonnegative reconstruction on the object grid. + + Returns + ------- + array-like + Blurred, sampled and response-weighted detector prediction. + """ + result = blur(estimate) + if not reflected: + result = result[::factor].copy() + return result if field is None else result * field + + def adjoint(values: Array) -> Array: + """Backproject acquired values through the exact optical adjoint. + + Parameters + ---------- + values : array-like + Values on the acquired detector grid. + + Returns + ------- + array-like + Sensitivity-weighted backprojection on the object grid. + """ + weighted = values if field is None else values * field + if reflected: + return blur(weighted, adjoint=True) + scattered = xp.zeros(fine_shape, dtype=xp.float32) + scattered[::factor] = weighted + return blur(scattered, adjoint=True) + + def smooth(product: Array, sensitivity: Array) -> Array: + """Apply optical consensus smoothing for the selected geometry. + + Parameters + ---------- + product : array-like + Product of the two relative half-image update directions. + sensitivity : array-like + Adjoint image of unit detector values. + + Returns + ------- + array-like + Smoothed consensus field on the object grid. + """ + if field is None: + if reflected: + if xp is cp and convolve.tile_shape == convolve.shape: + return fft_conv( + product, + convolve.otf * convolve.otf.conj(), + padded_shape, + ) + else: + # Retain padded H*H smoothing: cropping between these + # convolutions would change the operator at the boundary. + padded = xp.zeros(padded_shape, dtype=xp.float32) + padded[core] = product + if xp is cp and convolve.tile_shape == convolve.shape: + return fft_conv( + padded, convolve.otf * convolve.otf.conj(), padded_shape + )[core].copy() + return convolve(convolve(padded), adjoint=True)[core].copy() + return adjoint(forward(product)) / sensitivity + + if initial_estimate is not None: + initial_estimate = np.asarray(initial_estimate, dtype=np.float32) + if ( + initial_estimate.shape != shape + or not np.isfinite(initial_estimate).all() + or np.any(initial_estimate <= 0) + ): + raise ValueError( + "initial_estimate must be finite, positive and match the reconstruction grid" + ) + recon = _iterate_rlgc( + observed, + shape, + forward, + adjoint, + smooth, + region, + gradient_consensus=gradient_consensus, + max_iterations=max_iterations, limit=limit, max_delta=max_delta, - rng_seed=None if rng_seed is None else rng_seed + tile_idx, - normalize_psf=normalize_psf, - release_memory=False, + rng_seed=rng_seed, + use_kl=use_kl, + read_noise=read_noise, + initial_estimate=initial_estimate, logger=logger, - log_prefix=_child_log_prefix( - log_prefix, f"path=3d_tiled tile={tile_idx:04d}" - ), + log_prefix=log_prefix, + callback=callback, ) + result = recon[core] if boundary == "reflect" else recon + return cp.asnumpy(result) if xp is cp else result.copy() + finally: + if release_memory: + clear_rlgc_caches(clear_memory_pool=True) - scan_source_start = scan_dest_start - scan_crop_start - scan_source_stop = scan_source_start + (scan_dest_stop - scan_dest_start) - crop_sub = crop_array[scan_source_start:scan_source_stop, :, :] - output[scan_dest_start:scan_dest_stop, :, :] = crop_sub - - del crop_sub - gc.collect() - if original_ndim == 2: - output = np.squeeze(output, axis=0) +def _is_gpu_memory_error(exc: BaseException) -> bool: + """Identify CUDA allocation failures that should trigger chunk fallback. - if release_memory: - clear_rlgc_caches(clear_memory_pool=True) + Parameters + ---------- + exc : BaseException + Exception raised during an RLGC attempt. - return output + Returns + ------- + bool + True if the exception appears to be a GPU or host memory allocation + failure; otherwise False. + """ + if isinstance(exc, MemoryError): + return True + if isinstance(exc, cp.cuda.memory.OutOfMemoryError): + return True + message = str(exc).lower() + return "out of memory" in message or "oom" in message -def chunked_rlgc( +def rlgc( image: np.ndarray, psf: np.ndarray, gpu_id: int = 0, crop_scan: int | None = None, safe_mode: bool = True, - limit: float = 0.001, - max_delta: float = 0.001, + limit: float = GC_UPDATED_FRACTION_LIMIT, + max_delta: float = GC_RELATIVE_CHANGE_LIMIT, rng_seed: int | None = 42, normalize_psf: bool = True, verbose: int = 1, @@ -1099,8 +1212,11 @@ def chunked_rlgc( log_prefix: str = "", on_successful_crop_scan: Callable[[int], None] | None = None, fallback_step_scan: int | None = None, + detector_response: np.ndarray | None = None, + read_noise: float = 0.0, + **solver_options: object, ) -> np.ndarray: - """Scan-axis chunked RLGC deconvolution with automatic fallback. + """Deconvolve calibrated data with the single RLGC engine and FFT tiling. The solver first attempts the requested ``crop_scan``. If CUDA memory allocation fails, it retries with smaller scan-axis chunks. Every solver @@ -1121,15 +1237,15 @@ def chunked_rlgc( current free GPU memory and this PSF. Values at least as large as the scan-axis length select full-frame processing. safe_mode : bool, default=True - Compare observation-to-prediction KLD against the same fresh halves. - If True, stop when either half worsens; otherwise require both halves. + Retained for compatibility. The reference KL rule always requires both + halves to worsen; this wrapper option has no effect. limit : float, default=0.001 Minimum fraction of pixels updated per iteration before early stopping. max_delta : float, default=0.001 Stop when the largest relative update falls below this threshold. rng_seed : int or None, default=42 - Seed for the per-iteration 50:50 data split. Tiled calls offset this - seed by tile index. + Seed for the per-iteration 50:50 data split. Every FFT tile shares the global + split and convergence history. normalize_psf : bool, default=True If True, normalize the padded PSF to unit sum before deconvolution. verbose : int, default=1 @@ -1145,6 +1261,22 @@ def chunked_rlgc( fallback_step_scan : int or None, default=None Number of retained scan planes removed after each allocation failure. If None, reduce the current crop by one quarter. + detector_response : numpy.ndarray or None + Positive detector-YX illumination and gain response retained for every + scan chunk. Supply camera-calibrated counts without dividing by it. + + read_noise : float, default=0 + RMS read noise in the same calibrated units as image. Zero selects + the photon-only branch; processing supplies the camera calibration. + + solver_options : keyword arguments + Numerical parameters forwarded to the single engine: ``use_kl`` enables + reference KL stopping; ``max_iterations`` is an optional safety cap; + ``scan_upsample_factor`` and ``boundary="zero"`` select acquired-plane + reconstruction; ``initial_estimate`` overrides mean-data initialization; + ``gradient_consensus=False`` selects ordinary RL. ``callback`` observes + completed iterates. ``storage`` chooses GPU or host state; FFTs use CUDA + in both cases. ``pad_yx`` controls symmetric camera-axis padding. Returns ------- @@ -1159,12 +1291,22 @@ def chunked_rlgc( else: raise ValueError(f"Expected a 2D or 3D image, got shape {image_arr.shape}") + if (image_arr.ndim == 2 or image_arr.shape[0] == 1) and solver_options.get( + "boundary", "reflect" + ) == "reflect": + psf = np.asarray(psf) + if psf.ndim == 3: + psf = psf[psf.shape[0] // 2] + original_ndim = image_arr.ndim + image_scan = (image_scan - 1) * int( + solver_options.get("scan_upsample_factor", 1) + ) + 1 if crop_scan is None: if image_arr.ndim == 2: crop_scan = 1 else: crop_scan = determine_rlgc_crop_scan( - tuple(int(size) for size in image_arr.shape), + (image_scan, *image_arr.shape[-2:]), [tuple(int(size) for size in np.asarray(psf).shape)], gpu_id=gpu_id, ) @@ -1172,6 +1314,9 @@ def chunked_rlgc( raise ValueError("fallback_step_scan must be at least 1") min_crop_scan = 1 attempted_crop_scan = min(int(crop_scan), int(image_scan)) + solver_options.setdefault( + "storage", "host" if attempted_crop_scan < image_scan else "gpu" + ) while True: try: @@ -1182,20 +1327,24 @@ def chunked_rlgc( attempted_crop_scan, crop_scan, ) - output = _chunked_rlgc_once( + output = _rlgc_once( image=image_arr, psf=psf, gpu_id=gpu_id, - crop_scan=attempted_crop_scan, + crop_scan=None + if attempted_crop_scan >= image_scan + else attempted_crop_scan, safe_mode=safe_mode, limit=limit, max_delta=max_delta, rng_seed=rng_seed, normalize_psf=normalize_psf, - verbose=verbose, release_memory=release_memory, logger=logger, log_prefix=log_prefix, + detector_response=detector_response, + read_noise=read_noise, + **solver_options, ) if on_successful_crop_scan is not None: on_successful_crop_scan(attempted_crop_scan) @@ -1206,13 +1355,17 @@ def chunked_rlgc( attempted_crop_scan, crop_scan, ) - return output + return output[0] if original_ndim == 2 else output except Exception as exc: if not _is_gpu_memory_error(exc): raise clear_rlgc_caches(clear_memory_pool=True) + already_on_host = solver_options["storage"] == "host" + solver_options["storage"] = "host" if attempted_crop_scan == min_crop_scan: + if not already_on_host: + continue raise RuntimeError( "RLGC failed due to GPU memory constraints even at the " f"minimum scan-axis crop size {attempted_crop_scan}." @@ -1232,84 +1385,3 @@ def chunked_rlgc( next_crop_scan, ) attempted_crop_scan = next_crop_scan - - -def rlgc_2d( - image: np.ndarray, - skewed_psf: np.ndarray, - gpu_id: int = 0, - safe_mode: bool = True, - limit: float = 0.001, - max_delta: float = 0.001, - rng_seed: int | None = 42, - verbose: int = 1, - release_memory: bool = True, - logger: logging.Logger | None = None, - log_prefix: str = "", -) -> np.ndarray: - """Deconvolve one acquired YX image using the central plane of its PSF. - - Non-spatial dimensions such as time, stage position, and channel must be - iterated by the caller. A singleton Z dimension is accepted and restored - on return so storage code can retain explicit TCZYX axes. - - Parameters - ---------- - image : numpy.ndarray - A YX image or singleton-Z ZYX image. - skewed_psf : numpy.ndarray - A YX PSF or skewed ZYX PSF. Only its central Z plane is used. - gpu_id : int - CUDA device index used for reconstruction or memory estimation. - safe_mode : bool - Roll back consensus iterations when either split likelihood worsens. - limit : float, default=0.001 - Stop when the fraction of voxels receiving consensus updates falls below this value. - max_delta : float, default=0.001 - Stop when the maximum relative reconstruction change falls below this value. - rng_seed : int | None - Seed for independent count splitting; None uses an unseeded generator. - verbose : int - Deconvolution diagnostic verbosity. - release_memory : bool - Clear shared FFT caches and unused GPU allocations on completion. - logger : logging.Logger | None - Optional logger for iteration diagnostics. - log_prefix : str - Text prepended to deconvolution diagnostics for this tile or channel. - - Returns - ------- - numpy.ndarray - Float32 deconvolution with the same rank as ``image``. - """ - image_array = np.asarray(image) - restore_z = image_array.ndim == 3 and image_array.shape[0] == 1 - if restore_z: - image_yx = image_array[0] - elif image_array.ndim == 2: - image_yx = image_array - else: - raise ValueError( - "2D RLGC expects a YX image or singleton-Z ZYX image; " - f"got shape {image_array.shape}" - ) - - psf_yx = central_psf_plane(skewed_psf) - result = chunked_rlgc( - image=image_yx, - psf=psf_yx, - gpu_id=gpu_id, - crop_scan=1, - safe_mode=safe_mode, - limit=limit, - max_delta=max_delta, - rng_seed=rng_seed, - normalize_psf=False, - verbose=verbose, - release_memory=release_memory, - logger=logger, - log_prefix=_child_log_prefix(log_prefix, "path=2d_central_psf"), - ) - result = np.asarray(result, dtype=np.float32) - return result[np.newaxis] if restore_z else result diff --git a/src/opm_processing/imageprocessing/rlgc_undersampled.py b/src/opm_processing/imageprocessing/rlgc_undersampled.py deleted file mode 100644 index 359a935..0000000 --- a/src/opm_processing/imageprocessing/rlgc_undersampled.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Experimental RL/gradient-consensus reconstruction of missing scan planes. - -This separate module is opt-in through ``process --deconvolve ---decon-scan-upsample N``. Arrays are in (scan, camera_y, camera_x), not -laboratory ZYX. Supply the existing PSF -generator with ``acquired_scan_step_um / scan_upsample_factor``; its output is -the PSF required here. No PSF resampling or new PSF model is introduced. - -The forward model is H = S C: convolve on the reconstruction grid, then select -acquired planes. Its adjoint is C^T S^T: insert zeros, then convolve with the -adjoint PSF. Missing planes are unobserved, not measured zeros. The RL update -includes H^T(1) normalization, which is essential for this sampling operator. - -Unlike the production solver's reflected input padding, this experiment -assumes zero fluorescence outside the reconstruction volume. This gives an -explicit linear forward model and its exact transpose, including at edges. -""" - -from __future__ import annotations - -from numbers import Integral -from typing import TYPE_CHECKING - -import numpy as np - -from opm_processing.imageprocessing.rlgc import ( - _linear_fft_pad_width, - _observed_region_slices, - _split_observed_counts, - clear_rlgc_caches, - cp, - fft_conv, - filter_update, - pad_psf, - split_kl_changes, -) - -if TYPE_CHECKING: - import logging - - -def _convolve_core( - image: cp.ndarray, - otf: cp.ndarray, - padded_shape: tuple[int, int, int], - core: tuple[slice, slice, slice], -) -> cp.ndarray: - """Convolve a zero-extended object and crop to its original field of view. - - Parameters - ---------- - image - Nonnegative image samples on the operator input grid. - otf - Frequency-domain optical transfer function on the padded convolution grid. - padded_shape - FFT grid dimensions including zero padding. - core - Slices selecting the original image support from the FFT grid. - - Returns - ------- - cupy.ndarray - Convolved image on the original support after zero-padded FFT convolution. - """ - padded = cp.zeros(padded_shape, dtype=cp.float32) - padded[core] = image - return fft_conv(padded, otf, padded_shape)[core].copy() - - -def _forward( - image: cp.ndarray, - otf: cp.ndarray, - padded_shape: tuple[int, int, int], - core: tuple[slice, slice, slice], - factor: int, -) -> cp.ndarray: - """Blur on the fine grid before selecting measured scan planes. - - Parameters - ---------- - image - Nonnegative image samples on the operator input grid. - otf - Frequency-domain optical transfer function on the padded convolution grid. - padded_shape - FFT grid dimensions including zero padding. - core - Slices selecting the original image support from the FFT grid. - factor - Integer spacing between measured scan planes on the fine reconstruction grid. - - Returns - ------- - cupy.ndarray - Predicted camera planes at the measured fine-grid scan indices. - """ - return _convolve_core(image, otf, padded_shape, core)[::factor].copy() - - -def _adjoint( - image: cp.ndarray, - otf_adjoint: cp.ndarray, - padded_shape: tuple[int, int, int], - core: tuple[slice, slice, slice], - fine_shape: tuple[int, int, int], - factor: int, -) -> cp.ndarray: - """Scatter measurements into a zero-filled fine grid and apply C transpose. - - Parameters - ---------- - image - Nonnegative image samples on the operator input grid. - otf_adjoint - Conjugate optical transfer function for adjoint convolution. - padded_shape - FFT grid dimensions including zero padding. - core - Slices selecting the original image support from the FFT grid. - fine_shape - Endpoint-preserving fine reconstruction dimensions in scan, Y, X order. - factor - Integer spacing between measured scan planes on the fine reconstruction grid. - - Returns - ------- - cupy.ndarray - Adjoint optical backprojection on the endpoint-preserving fine scan grid. - """ - scattered = cp.zeros(fine_shape, dtype=cp.float32) - scattered[::factor] = image - return _convolve_core(scattered, otf_adjoint, padded_shape, core) - - -def rlgc_undersampled( - image: np.ndarray, - psf: np.ndarray, - scan_upsample_factor: int = 1, - *, - gradient_consensus: bool = True, - max_iterations: int = 100, - gpu_id: int = 0, - safe_mode: bool = True, - limit: float = 0.001, - max_delta: float = 0.001, - rng_seed: int | None = 42, - release_memory: bool = True, - logger: logging.Logger | None = None, -) -> np.ndarray: - """Reconstruct a fine scan grid using the existing RLGC numerical helpers. - - Parameters - ---------- - image : numpy.ndarray - Nonnegative measured photon counts, shaped (scan, camera_y, camera_x). - Measurements are taken at fine-grid indices 0, k, 2k, ... . - psf : numpy.ndarray - Nonnegative, centered, odd-sized 3D PSF sampled on the desired fine - grid. It is normalized to unit sum. The caller controls physical - sampling using the existing PSF generator. - scan_upsample_factor : int, default=1 - Integer k relating acquired and reconstructed scan steps. Output has - ``(image.shape[0] - 1) * k + 1`` planes, preserving endpoint positions. - gradient_consensus : bool, default=True - Use the existing count splitting, consensus gate and split-KLD - rollback. False runs ordinary RL for controlled comparisons. - max_iterations : int, default=100 - Maximum multiplicative updates after normalized-backprojection - initialization. Also bounds the experimental GC run. - gpu_id : int, default=0 - CUDA device. - safe_mode : bool, default=True - In GC mode, compare normalized observation-to-prediction KLD against - the same fresh split for both estimates. Roll back if either half - worsens; otherwise require both to worsen. Only acquired planes enter - this statistic; the previous prediction is reused without another FFT. - limit : float, default=0.001 - In GC mode, stop below this fraction of fine-grid voxels updated. - max_delta : float, default=0.001 - Stop when the maximum change relative to the reconstruction peak is - below this value. Use zero for fixed-iteration ordinary RL tests. - rng_seed : int or None, default=42 - Seed for GC count splitting. - release_memory : bool, default=True - Clear shared FFT caches and free unused GPU pool allocations on exit. - logger : logging.Logger or None, default=None - Optional iteration diagnostics. - - Returns - ------- - numpy.ndarray - Float32 fluorescence estimate at the fine scan step. No deskewing, - interpolation of measurements, chunking, or metadata changes occur. - """ - for name, value in ( - ("scan_upsample_factor", scan_upsample_factor), - ("max_iterations", max_iterations), - ): - if isinstance(value, bool) or not isinstance(value, Integral) or value < 1: - raise ValueError(f"{name} must be a positive integer") - image = np.asarray(image, dtype=np.float32) - psf = np.asarray(psf, dtype=np.float32) - for name, array in (("image", image), ("psf", psf)): - if array.ndim != 3 or any(size == 0 for size in array.shape): - raise ValueError(f"{name} must be a nonempty 3D array") - if not np.isfinite(array).all() or np.any(array < 0): - raise ValueError(f"{name} must contain finite nonnegative values") - if not np.sum(psf, dtype=np.float64) > 0 or any(s % 2 == 0 for s in psf.shape): - raise ValueError("psf must have positive sum and odd dimensions") - if not np.isfinite(limit) or not 0 <= limit <= 1: - raise ValueError("limit must be between zero and one") - if not np.isfinite(max_delta) or max_delta < 0: - raise ValueError("max_delta must be finite and nonnegative") - - factor = int(scan_upsample_factor) - fine_shape = ((image.shape[0] - 1) * factor + 1, *image.shape[1:]) - with cp.cuda.Device(gpu_id): - try: - pads = _linear_fft_pad_width(fine_shape, psf.shape) - padded_shape = tuple( - n + sum(p) for n, p in zip(fine_shape, pads, strict=False) - ) - core = _observed_region_slices(padded_shape, pads) - otf = cp.fft.rfftn(pad_psf(cp.asarray(psf), padded_shape)) - otf_adjoint = cp.conjugate(otf) - observed = cp.asarray(image) - - def forward(estimate: cp.ndarray) -> cp.ndarray: - """Blur the fine estimate and select only acquired scan planes. - - Parameters - ---------- - estimate - Current fluorescence reconstruction on the fine scan grid. - - Returns - ------- - array or scalar - Predicted measured camera planes from the fine estimate. - """ - return _forward(estimate, otf, padded_shape, core, factor) - - def adjoint(values: cp.ndarray) -> cp.ndarray: - """Scatter acquired-plane values and apply the adjoint optical convolution. - - Parameters - ---------- - values - Values on acquired camera planes to backproject. - - Returns - ------- - array or scalar - Backprojected measured-plane values on the fine reconstruction grid. - """ - return _adjoint( - values, otf_adjoint, padded_shape, core, fine_shape, factor - ) - - sensitivity = cp.maximum(adjoint(cp.ones_like(observed)), 1e-6) - recon = cp.maximum(adjoint(observed) / sensitivity, 0) - # A positive seed avoids locking unmeasured voxels at zero. - recon = cp.maximum(recon, cp.max(recon) * cp.float32(1e-7)) - previous = None - previous_prediction = None - rng = cp.random.default_rng(rng_seed) if gradient_consensus else None - scratch = cp.empty_like(observed) - for iteration in range(max_iterations): - predicted = cp.maximum(forward(recon), cp.float32(1e-6)) - if gradient_consensus: - split1 = _split_observed_counts(observed, rng) - split2 = observed - split1 - if previous is not None: - changes = split_kl_changes( - predicted, previous_prediction, split1, split2, scratch - ) - worse = [change > 0 for change in changes] - if any(worse) if safe_mode else all(worse): - recon = previous - break - ratio1 = adjoint(2 * split1 / predicted) / sensitivity - ratio2 = adjoint(2 * split2 / predicted) / sensitivity - consensus = _convolve_core( - (ratio1 - 1) * (ratio2 - 1), - otf * otf_adjoint, - padded_shape, - core, - ) - ratio = cp.maximum((ratio1 + ratio2) * 0.5, 0) - updated = cp.empty_like(recon) - filter_update(recon, ratio, consensus, updated) - updated_fraction = float(cp.mean(consensus >= 0)) - else: - ratio = cp.maximum(adjoint(observed / predicted) / sensitivity, 0) - updated = recon * ratio - updated_fraction = 1.0 - delta = float( - cp.max(cp.abs(updated - recon)) / cp.maximum(cp.max(updated), 1e-6) - ) - previous_prediction = predicted - previous, recon = recon, updated - if logger is not None: - logger.info( - "undersampled_rl iteration=%d factor=%d gc=%s delta=%.6g updated_fraction=%.6g", - iteration + 1, - factor, - gradient_consensus, - delta, - updated_fraction, - ) - if delta < max_delta or ( - gradient_consensus and updated_fraction < limit - ): - break - return cp.asnumpy(recon) - finally: - if release_memory: - clear_rlgc_caches(clear_memory_pool=True) diff --git a/src/opm_processing/imageprocessing/tilefusion.py b/src/opm_processing/imageprocessing/tilefusion.py index 8ddb322..f737ffa 100644 --- a/src/opm_processing/imageprocessing/tilefusion.py +++ b/src/opm_processing/imageprocessing/tilefusion.py @@ -9,7 +9,7 @@ 1) For each timepoint independently, register overlapping tile pairs using phase cross-correlation + SSIM scoring. 2) For each timepoint independently, solve a robust global least-squares system - (two-round iterative outlier rejection) with tile 0 anchored to zero offset. + (two-round iterative outlier rejection) with one anchor per connected component. 3) Build a *global* fused coordinate space spanning all timepoints. 4) Fuse each timepoint into the shared global space using weighted accumulation. 5) Build and stream NGFF multiscales through yaozarrs. @@ -27,6 +27,7 @@ import itertools import json import math +import warnings from collections import deque from concurrent.futures import ( FIRST_COMPLETED, @@ -60,6 +61,7 @@ ProcessingState, processing_state_path, ) +from opm_processing.dataio.timing import time_axis from opm_processing.imageprocessing.coordinates import ( stage_positions_to_image_coordinates, stage_z_level_indices, @@ -67,7 +69,7 @@ from opm_processing.imageprocessing.opmtools import orthogonal_deskew if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Iterable, Sequence from opm_processing.dataio.roi import PhysicalRoi @@ -101,6 +103,9 @@ from cucim.skimage.registration import ( phase_cross_correlation as _pcc, # type: ignore ) + from cucim.skimage.registration._masked_phase_cross_correlation import ( + cross_correlate_masked as masked_cross_correlation, + ) from cupyx.scipy.ndimage import minimum_filter as _minimum_filter # type: ignore from cupyx.scipy.ndimage import shift as _shift # type: ignore from cupyx.scipy.ndimage import sobel as _sobel # type: ignore @@ -125,6 +130,9 @@ from skimage.measure import block_reduce as _br # type: ignore from skimage.metrics import structural_similarity as _cpu_ssim # type: ignore from skimage.registration import phase_cross_correlation as _pcc # type: ignore + from skimage.registration._masked_phase_cross_correlation import ( + cross_correlate_masked as masked_cross_correlation, + ) match_histograms = _mh block_reduce = _br @@ -393,6 +401,117 @@ def _aligned_registration_views( return fixed[tuple(fixed_slices)], moving[tuple(moving_slices)] +def _registration_similarity( + arr1: Any, + arr2: Any, + candidate: Any, + win_size: int, + masks: tuple[Any, Any] | None = None, + registration_axis: int | None = None, +) -> float: + """Evaluate overlap similarity for a candidate registration correction. + + Parameters + ---------- + arr1 : array-like + Reference intensity patch. + arr2 : array-like + Moving intensity patch on the same grid. + candidate : sequence of float + Candidate displacement in patch pixels. + win_size : int + Requested odd SSIM window width. + masks : tuple of array-like or None + Reference and moving interpolation-support masks. + registration_axis : int or None + Axis used to identify directional registration geometry. + + Returns + ------- + float + Structural similarity of the translated overlapping patches. + """ + try: + fixed_view, moving_view = _aligned_registration_views( + arr1, + arr2, + candidate, + ) + valid_mask = None + if masks is not None: + fixed_mask, moving_mask = _aligned_registration_views(*masks, candidate) + tolerance = 4 * xp.finfo(xp.float32).eps + valid_mask = (fixed_mask >= 1 - tolerance) & (moving_mask >= 1 - tolerance) + # Remove completely unsupported edge planes before selecting + # the SSIM window; thin depth overlaps often have one. + bounds = [] + for axis in range(valid_mask.ndim): + other_axes = tuple( + index for index in range(valid_mask.ndim) if index != axis + ) + supported = xp.flatnonzero(xp.any(valid_mask, axis=other_axes)) + if supported.size == 0: + return -math.inf + bounds.append(slice(int(supported[0]), int(supported[-1]) + 1)) + bounds = tuple(bounds) + fixed_view = fixed_view[bounds] + moving_view = moving_view[bounds] + valid_mask = valid_mask[bounds] + # Stage-depth overlaps can be thinner than the configured + # window even without downsampling. Score the valid aligned + # support with the largest odd window that fits it. + effective_window = min(win_size, min(fixed_view.shape)) + effective_window -= 1 - effective_window % 2 + score = -math.inf + if effective_window >= 3: + score = _ssim( + fixed_view, + moving_view, + win_size=effective_window, + valid_mask=valid_mask, + ) + # A thin depth overlap can have many common voxels but no + # complete 3D SSIM window. Compare XY means of the same valid + # planes instead, preserving geometric support in both views. + if ( + not math.isfinite(score) + and fixed_view.ndim == 3 + and registration_axis == 0 + and fixed_view.shape[0] <= win_size + 2 + ): + weights = xp.ones_like(fixed_view) if valid_mask is None else valid_mask + count = xp.sum(weights, axis=0) + fixed_view = xp.sum(fixed_view * weights, axis=0) / xp.maximum(count, 1) + moving_view = xp.sum(moving_view * weights, axis=0) / xp.maximum(count, 1) + window = min(win_size, min(fixed_view.shape)) + window -= 1 - window % 2 + if window >= 3: + score = _ssim( + fixed_view, + moving_view, + win_size=window, + valid_mask=count > 0, + ) + valid_mask = count > 0 + effective_window = window + # A cropped oblique footprint can have a large bounding box but no + # complete SSIM window of that size. Retain the same score and gate, + # using the largest odd window supported by actual acquired samples. + while ( + not math.isfinite(score) and valid_mask is not None and effective_window > 3 + ): + effective_window -= 2 + score = _ssim( + fixed_view, + moving_view, + win_size=effective_window, + valid_mask=valid_mask, + ) + return score + except ValueError: + return -math.inf + + def _bounded_phase_correlation_peak( fixed: Any, moving: Any, @@ -448,6 +567,141 @@ def _bounded_phase_correlation_peak( ) +def _masked_registration_peak( + fixed: Any, + moving: Any, + masks: tuple[Any, Any], + max_shift: Sequence[float] | None, +) -> Any: + """Find the nonperiodic normalized-correlation peak within physical limits. + + The masks describe acquisition support. Padfield normalization subtracts + means and computes variance on each translation's common supported samples. + Search the allowed lags rather than discard an out-of-range global maximum. + + Parameters + ---------- + fixed : array-like + Reference intensity patch. + moving : array-like + Moving patch on the same grid. + masks : tuple of array-like + Reference and moving support masks. + max_shift : sequence of float or None + Maximum permitted correction on each patch axis. + + Returns + ------- + array-like or None + Nonperiodic displacement, or None when no informative peak exists. + """ + correlation = masked_cross_correlation( + fixed, + moving, + *masks, + mode="full", + axes=tuple(range(fixed.ndim)), + overlap_ratio=0.5, + ) + center = np.asarray(moving.shape) - 1 + extent = ( + center + if max_shift is None + else np.minimum(center, np.floor(max_shift).astype(int)) + ) + start = center - extent + slices = tuple( + slice(int(lo), int(hi + 1)) + for lo, hi in zip(start, center + extent, strict=True) + ) + allowed = correlation[slices] + peak = xp.max(allowed) + if not bool(xp.isfinite(peak)) or float(peak) <= 0: + return None + maxima = xp.stack(xp.nonzero(allowed == peak), axis=1) + return xp.mean(maxima, axis=0).astype(xp.float32) - xp.asarray( + extent, dtype=xp.float32 + ) + + +def _refine_depth_candidate( + fixed: Any, + moving: Any, + masks: tuple[Any, Any], + nominal_z: int, + lateral_shift: Sequence[float], + z_limit: int, + win_size: int, +) -> tuple[Any, float]: + """Search contextual depth samples at the measured lateral displacement. + + Pearson correlation of axial differences measures Z-varying specimen + agreement, excluding shared XY background that cannot constrain depth. + Adjacent samples must both have full geometric support. SSIM independently + gates the selected match on the original intensities. + + Parameters + ---------- + fixed : array-like + Reference volume with depth context. + moving : array-like + Moving volume with depth context. + masks : tuple of array-like + Reference and moving interpolation support. + nominal_z : int + Nominal depth displacement in pixels. + lateral_shift : sequence of float + Measured lateral displacement in pixels. + z_limit : int + Maximum depth search correction. + win_size : int + Requested SSIM window width. + + Returns + ------- + tuple[array-like, float] + Selected ZYX shift and its structural similarity. + """ + lateral = tuple(round(value) for value in lateral_shift) + best = None + best_score = -math.inf + fixed_axial = xp.diff(fixed, axis=0) + moving_axial = xp.diff(moving, axis=0) + axial_masks = tuple((mask[:-1] > 0.999) & (mask[1:] > 0.999) for mask in masks) + # Measure a small guard band as well: a peak beyond the requested limit + # must be rejected by the caller rather than clamped onto the limit. + for correction in range(-z_limit - 2, z_limit + 3): + shift = (nominal_z + correction, *lateral) + try: + a, b = _aligned_registration_views(fixed_axial, moving_axial, shift) + ma, mb = _aligned_registration_views(*axial_masks, shift) + except ValueError: + continue + common = ma & mb + # Two supported adjacent differences require three original planes. + if int(xp.count_nonzero(xp.any(common, axis=(1, 2)))) < 2: + continue + a, b = a[common], b[common] + if a.size < 3: + continue + a = a - xp.mean(a) + b = b - xp.mean(b) + denominator = xp.sqrt(xp.sum(a * a) * xp.sum(b * b)) + if float(denominator) <= 0: + continue + correlation = float(xp.sum(a * b) / denominator) + if correlation > best_score: + best_score, best = correlation, shift + # High intensity SSIM or a shared XY illumination pattern does not provide + # evidence for axial alignment. Require agreement of Z-varying features. + if best is None or best_score < 0.3: + return xp.asarray((nominal_z, *lateral_shift)), -math.inf + # Preserve the fractional lateral measurement when checking similarity. + best = xp.asarray((best[0], *lateral_shift), dtype=xp.float32) + matched = match_histograms(moving, fixed) + return best, _registration_similarity(fixed, matched, best, win_size, masks, 0) + + class _RegistrationDeviceBuffers: """Grow-only reusable CuPy staging buffers for a registration pair.""" @@ -494,23 +748,6 @@ def _stage_one(self, index: int, host: np.ndarray) -> Any: view.set(contiguous) return view - def stage(self, fixed: np.ndarray, moving: np.ndarray) -> tuple[Any, Any]: - """Copy a host pair into reusable device allocations. - - Parameters - ---------- - fixed : np.ndarray - Reference overlap patch used for pairwise registration. - moving : np.ndarray - Neighbor overlap patch to align with the reference. - - Returns - ------- - tuple - Reusable device arrays populated with the fixed and moving overlap patches. - """ - return self._stage_one(0, fixed), self._stage_one(1, moving) - @njit(nogil=True) def _accumulate_tile_block( @@ -691,33 +928,13 @@ def _partition_fusion_block( Spatial sub-blocks paired with the tiles contributing to each sub-block. """ z0, z1, y0, y1, x0, x1 = bounds - common_shape = ( - tuple(int(value) for value in tile_shapes) - if len(tile_shapes) == 3 - and all(isinstance(value, (int, np.integer)) for value in tile_shapes) - else None - ) - - def shape_for(tile_index: int) -> tuple[int, int, int]: - """Read the source spatial dimensions for one contributing tile. - - Parameters - ---------- - tile_index : int - Index into the selected processed tile collection. - - Returns - ------- - tuple[int, int, int] - Tile dimensions in ZYX order. - """ - if common_shape is not None: - return common_shape - return tuple(int(value) for value in tile_shapes[tile_index]) + shapes = _registration_tile_shapes( + tile_shapes, max((index for index, _ in contributors), default=-1) + 1 + ).astype(np.int64) cuts = [{z0, z1}, {y0, y1}, {x0, x1}] for tile_index, (oz, oy, ox) in contributors: - tile_z, tile_y, tile_x = shape_for(tile_index) + tile_z, tile_y, tile_x = shapes[tile_index] for axis_cuts, lo, hi, tile_lo, tile_length in ( (cuts[0], z0, z1, oz, tile_z), (cuts[1], y0, y1, oy, tile_y), @@ -740,11 +957,11 @@ def shape_for(tile_index: int) -> tuple[int, int, int]: (p, offset) for p, offset in contributors if offset[0] <= rz0 - and offset[0] + shape_for(p)[0] >= rz1 + and offset[0] + shapes[p][0] >= rz1 and offset[1] <= ry0 - and offset[1] + shape_for(p)[1] >= ry1 + and offset[1] + shapes[p][1] >= ry1 and offset[2] <= rx0 - and offset[2] + shape_for(p)[2] >= rx1 + and offset[2] + shapes[p][2] >= rx1 ] if not region_contributors: continue @@ -789,6 +1006,46 @@ def _registration_tile_shapes( return shapes +def _connected_tile_components( + tile_indices: Sequence[int], pairs: Iterable[tuple[int, int]] +) -> list[list[int]]: + """Group tile indices by accepted links, including isolated tiles. + + Parameters + ---------- + tile_indices : sequence of int + Tile indices belonging to one graph or timepoint. + pairs : iterable of tuple[int, int] + Undirected links. Links outside the selected indices are ignored. + + Returns + ------- + list of list of int + Components in first-tile order, each with its first tile as anchor. + """ + adjacency = {index: set() for index in tile_indices} + for left, right in pairs: + if left in adjacency and right in adjacency: + adjacency[left].add(right) + adjacency[right].add(left) + components = [] + visited = set() + for start in adjacency: + if start in visited: + continue + component = [] + frontier = [start] + visited.add(start) + while frontier: + current = frontier.pop() + component.append(current) + unseen = adjacency[current] - visited + visited.update(unseen) + frontier.extend(unseen) + components.append(component) + return components + + def _select_nearest_registration_pairs( positions_zyx: Sequence[Sequence[float]], tile_shape_zyx: Sequence[int] | Sequence[Sequence[int]], @@ -825,84 +1082,24 @@ def _select_nearest_registration_pairs( tile_shapes = _registration_tile_shapes(tile_shape_zyx, len(positions)) physical_shapes = tile_shapes * pixel_size - edges: list[tuple[float, int, int]] = [] - for i in range(len(positions)): - for j in range(i + 1, len(positions)): - delta = positions[j] - positions[i] - overlap = np.logical_and( - positions[i] < positions[j] + physical_shapes[j], - positions[j] < positions[i] + physical_shapes[i], - ) - if is_2d: - overlap[0] = True - delta = delta.copy() - delta[0] = 0.0 - if np.all(overlap): - pair_scale = np.maximum(physical_shapes[i], physical_shapes[j]) - distance = float(np.linalg.norm(delta / pair_scale)) - edges.append((distance, i, j)) - - if not edges: - return [] - - parent = list(range(len(positions))) - - def find(index: int) -> int: - """Find a tile component representative with path compression. - - Parameters - ---------- - index : int - Tile or registration-link index used by this calculation. - - Returns - ------- - int - Representative tile index of the connected component. - """ - while parent[index] != index: - parent[index] = parent[parent[index]] - index = parent[index] - return index - - def union(left: int, right: int) -> bool: - """Merge the connected components containing two tiles. - - Parameters - ---------- - left : int - First tile index whose connected component is merged. - right : int - Second tile index whose connected component is merged. - - Returns - ------- - bool - Updates the component parents in place. - """ - left_root = find(left) - right_root = find(right) - if left_root == right_root: - return False - parent[right_root] = left_root - return True - - for _, i, j in edges: - union(i, j) - component_by_node = [find(index) for index in range(len(positions))] - edges_by_component: dict[int, list[tuple[float, int, int]]] = {} - nodes_by_component: dict[int, set[int]] = {} - for edge in edges: - _, i, j = edge - component = component_by_node[i] - edges_by_component.setdefault(component, []).append(edge) - nodes_by_component.setdefault(component, set()).update((i, j)) + pairs = _select_registration_pairs(positions, tile_shapes, pixel_size, is_2d=is_2d) + components = _connected_tile_components(range(len(positions)), pairs) + component_by_node = { + node: index for index, component in enumerate(components) for node in component + } + for i, j in pairs: + delta = positions[j] - positions[i] + if is_2d: + delta[0] = 0.0 + pair_scale = np.maximum(physical_shapes[i], physical_shapes[j]) + distance = float(np.linalg.norm(delta / pair_scale)) + edges_by_component.setdefault(component_by_node[i], []).append((distance, i, j)) selected: list[tuple[int, int]] = [] for component, component_edges in edges_by_component.items(): component_edges.sort() - component_nodes = nodes_by_component[component] + component_nodes = components[component] local_parent = {node: node for node in component_nodes} def local_find(index: int) -> int: @@ -982,22 +1179,10 @@ def _fit_depth_gains( } for (left, right), values in grouped.items() ] - adjacency = [set() for _ in range(level_count)] - for left, right in grouped: - adjacency[left].add(right) - adjacency[right].add(left) - anchors = [] - visited = set() - for level in range(level_count): - if level in visited: - continue - anchors.append(level) - frontier = [level] - visited.add(level) - while frontier: - unseen = adjacency[frontier.pop()] - visited - visited.update(unseen) - frontier.extend(unseen) + anchors = [ + component[0] + for component in _connected_tile_components(range(level_count), grouped) + ] shifts = TileFusion._solve_global(links, level_count, anchors) return np.exp(shifts[:, 0]) @@ -1051,7 +1236,9 @@ def _infer_registration_limits( a floor. Untiled axes use ten percent of their tile extent. Depth pairs additionally allow the oblique-plane footprint displacement dz*cot(theta) along image Y. This is a search allowance, not a shear of tile origins. - Each pair retains at least half its overlap along every active axis. + Depth-face pairs allow up to twenty percent of their depth extent while + requiring at least three actually matched planes; other axes retain at + least half their overlap. Parameters ---------- @@ -1135,7 +1322,24 @@ def _infer_registration_limits( budget[1] += ( abs(positions[right, 0] - positions[left, 0]) * cotangent / spacing[1] ) - budget = np.minimum(np.ceil(budget), np.floor(overlap / 2)) + cap = np.floor(overlap / 2) + separation = np.abs(positions[right] - positions[left]) / spacing + depth_pair = ( + not is_2d + and int(np.argmax(separation / np.minimum(shapes[left], shapes[right]))) + == 0 + ) + if depth_pair: + # Narrow depth intersections must not cap corrections at half their + # width. Context reads below permit matching outside that crop. + budget[0] = max( + budget[0], math.ceil(0.2 * min(shapes[left, 0], shapes[right, 0])) + ) + # A correction towards the other tile increases the overlap. Do + # not limit that correction by the small nominal intersection. + # The contextual search checks actual common geometric support. + cap[0] = min(shapes[left, 0], shapes[right, 0]) - 3 + budget = np.minimum(np.ceil(budget), cap) if is_2d: budget[0] = 0 limits[pair] = tuple(int(value) for value in budget) @@ -1187,54 +1391,11 @@ def _select_registration_pairs( class TileFusion: - """ - Register and fuse multi-tile OPM acquisitions into a global OME-NGFF Zarr v3 store. - - This implementation: - - registers tiles independently per timepoint, - - performs robust global shift optimization per timepoint (tile 0 anchored), - - builds a global fused coordinate system spanning all timepoints, - - fuses each timepoint into that common coordinate system, - - writes a Zarr v3 OME-NGFF (t, c, z, y, x) store including multiscales. + """Register and fuse processed OPM tiles onto a common physical ZYX grid. - Parameters - ---------- - root_path : str or pathlib.Path - Source acquisition, processed collection, or processing output - directory. The code discovers deskewed or projection data there; - deconvolved products are fallbacks when a non-deconvolved product is - unavailable. - blend_pixels : tuple[int, int, int], default=(20, 600, 400) - Feather ramp widths (bz, by, bx) used to build 1D weight profiles. - downsample_factors : tuple[int, int, int], default=(3, 5, 5) - Block-reduce factors (z, y, x) for registration patches. - ssim_window : int, default=15 - SSIM window size for registration scoring. - threshold : float, default=0.7 - Minimum SSIM score for accepting a pairwise link. Use 0.0 to accept all. - multiscale_factors : sequence[int], default=(2, 4, 8, 16, 32) - Downsampling factors for creating multiscale pyramid levels. - resolution_multiples : sequence[int | sequence[int]], default=((1,1,1), ..., (32,32,32)) - Spatial scale multipliers recorded into NGFF metadata for each pyramid level. - max_workers : int or None, default=None - Maximum number of fusion blocks rendered concurrently. By default, - uses up to eight physical CPU cores so blocks can retain enough Z depth - for efficient source-chunk reads. - debug : bool, default=False - If True, emits debug logs. - channel_to_use : int, default=0 - Channel index used for registration. - reverse_stage_y : bool, default=True - Reverse stage-derived Y for tile placement without flipping tile pixels. - reverse_stage_z : bool or None, default=None - Reverse physical stage Z into laboratory Z for tile placement. ``None`` - applies the transform unless processed coordinates were already flipped. - multiscale_downsample : {"stride", "block_mean"}, default="stride" - Method for multiscale downsampling. - fusion_ram_fraction : float, default=0.4 - Fraction of currently available host RAM available to fusion buffers. - max_in_flight_writes : int, default=2 - Maximum number of TensorStore writes retained before applying backpressure. + Registration and robust global placement are solved independently for each + timepoint, with one fixed tile per connected component. Fusion writes a + shared TCZYX OME-NGFF volume and its multiscale pyramid. """ def __init__( @@ -1267,62 +1428,68 @@ def __init__( reverse_stage_z: bool | None = None, roi_selection: PhysicalRoi | None = None, normalize_depth_intensity: bool = True, + registration_retries: int = 2, ) -> None: """Initialize registration and fusion for a processed acquisition. Parameters ---------- - root_path - Source acquisition, processed collection, or processing output - directory used to discover processed data. - blend_pixels + root_path : str or pathlib.Path + Source acquisition, processed collection, or output directory. Directory + discovery prefers deskewed volumes and deconvolved data; an explicit + processed-store path selects that store directly. + blend_pixels : tuple[int, int, int] Feathering width in ZYX voxels. - downsample_factors + downsample_factors : tuple[int, int, int] Registration downsampling factors in ZYX order. - ssim_window + ssim_window : int Structural-similarity window width. - threshold + threshold : float Minimum accepted pairwise registration score. - multiscale_factors + multiscale_factors : sequence of int Downsampling factors for pyramid levels. - resolution_multiples + resolution_multiples : sequence of int or sequence of int NGFF spatial scale multiples for pyramid levels. - max_workers + max_workers : int or None Maximum number of fusion blocks rendered concurrently. - debug + debug : bool Whether to emit diagnostic messages. - channel_to_use + channel_to_use : int Channel index used for registration. - multiscale_downsample + multiscale_downsample : str Pyramid downsampling method. - fusion_ram_fraction + fusion_ram_fraction : float Fraction of available host RAM allocated to fusion buffers. - max_in_flight_writes + max_in_flight_writes : int Maximum number of pending TensorStore writes. - chunk_shape_yx + chunk_shape_yx : tuple[int, int] Spatial output chunk shape. - optimization_rel_threshold + optimization_rel_threshold : float Relative residual threshold for registration outliers. - optimization_abs_threshold + optimization_abs_threshold : float Absolute residual threshold in effective registration sampling bins. - max_registration_shift_zyx + max_registration_shift_zyx : tuple[int, int, int] or None Maximum accepted registration correction in ZYX voxels. None infers a separate limit for each pair from stage spacing, tile overlap, voxel size, scan angle, and registration sampling. - reverse_stage_y + reverse_stage_y : bool Whether to reverse stage-derived image-Y placement. Tile pixel arrays are not modified. - reverse_stage_z + reverse_stage_z : bool or None Whether to convert physical stage Z into the opposite laboratory-Z placement coordinate. ``None`` detects whether processing already transformed the stored positions. Tile pixels and scan ordering are never modified. - roi_selection + roi_selection : PhysicalRoi or None Optional physical YX crop. Tile selection ignores Z so every stage-Z level intersecting the rectangle is retained. - normalize_depth_intensity + normalize_depth_intensity : bool Match depth-layer intensities using image-registered overlaps at the same stage XY. All XY tiles at one depth share the same gain. + registration_retries : int + Maximum automatic search expansions. Each expansion remeasures only + affected pairs and refits all global offsets. Explicit shift limits + are never expanded. Returns ------- @@ -1386,6 +1553,7 @@ def __init__( reverse_stage_z = not self.acquisition.stage_axis_flips_xyz[2] self.reverse_stage_z = bool(reverse_stage_z) self._pixel_size = tuple(float(value) for value in collection.voxel_size_um) + self.time_interval_s = collection.time_interval_s self.roi_selection = roi_selection if self._variable_roi_tiles: records = roi_series @@ -1434,14 +1602,17 @@ def __init__( for _ in range(collection_shape[0]) for position in collection.stage_positions_zxy ] + stage_origins = stage_positions_to_image_coordinates( + stage_positions, + reverse_y=self.reverse_stage_y, + reverse_z=self.reverse_stage_z, + opm_angle_deg=float(self.acquisition.angle_deg), + ) + image_origins = np.tile( + np.asarray(collection.spatial_origins_zyx_um), (collection_shape[0], 1) + ) self._tile_positions = [ - tuple(position) - for position in stage_positions_to_image_coordinates( - stage_positions, - reverse_y=self.reverse_stage_y, - reverse_z=self.reverse_stage_z, - opm_angle_deg=float(self.acquisition.angle_deg), - ) + tuple(position) for position in stage_origins + image_origins ] selected_positions: tuple[int, ...] = tuple(range(collection_shape[1])) if roi_selection is not None: @@ -1533,6 +1704,10 @@ def __init__( self.optimization_rel_threshold = float(optimization_rel_threshold) self.optimization_abs_threshold = float(optimization_abs_threshold) self._automatic_registration_limits = max_registration_shift_zyx is None + if registration_retries < 0: + raise ValueError("registration_retries must be nonnegative") + self.registration_retries = int(registration_retries) + self._registration_retry_count = 0 self._pair_registration_limits = {} self._is_2d = all(shape[0] == 1 for shape in self._tile_shapes) if self._automatic_registration_limits: @@ -1574,8 +1749,13 @@ def __init__( self.max_registration_shift_zyx = tuple( int(value) for value in max_registration_shift_zyx ) + self._initial_registration_shift_zyx = self.max_registration_shift_zyx + self._initial_pair_registration_limits = self._pair_registration_limits.copy() if self._variable_roi_tiles: - self._tile_support_zy = self._build_variable_roi_support_masks() + self._tile_support_zy = [ + self._build_deskew_support_mask(record) + for record in self._roi_tile_records + ] else: self._deskew_support_zy = self._build_deskew_support_mask() self._tile_support_zy = [self._deskew_support_zy] * len(self._tile_shapes) @@ -1585,7 +1765,6 @@ def __init__( f"{int(self.channels) - 1}; got {self.channel_to_use}" ) - self._update_profiles() self._tile_profiles = [ ( self._make_1d_profile(shape[0], self._blend_pixels[0]), @@ -1594,6 +1773,19 @@ def __init__( ) for shape in self._tile_shapes ] + # Camera-X padding is absent detector support, not a dark measurement. + for index, (_z_profile, _y_profile, x_profile) in enumerate( + self._tile_profiles + ): + if self._variable_roi_tiles: + record = self._roi_tile_records[index] + valid_x = int(record["skewed_shape_syx"][2]) - int( + record["deskew_crop_yx"][2] + ) + else: + valid_x = int(self.acquisition.index_sizes["x"]) + x_profile[max(0, valid_x) :] = 0 + self.z_profile, self.y_profile, self.x_profile = self._tile_profiles[0] self._tile_channel_nonzero: dict[tuple[int, int], bool] = {} self._load_zero_channel_metadata() @@ -1633,8 +1825,16 @@ def debug(self, flag: bool) -> None: """ self._debug = bool(flag) - def _build_deskew_support_mask(self) -> np.ndarray: - """Generate deskew interpolation coverage without reading image pixels. + def _build_deskew_support_mask( + self, roi_record: dict[str, Any] | None = None + ) -> np.ndarray: + """Generate full-tile or cropped-ROI coverage without reading image pixels. + + Parameters + ---------- + roi_record : dict[str, Any] or None + Recorded source dimensions and output crop bounds for one ROI tile. + None uses the full processed acquisition geometry. Returns ------- @@ -1642,7 +1842,7 @@ def _build_deskew_support_mask(self) -> np.ndarray: Float32 ZY fractions of valid samples in each averaged Z bin. Zero denotes padding; one denotes complete interpolation coverage. """ - if self._is_2d: + if self.acquisition.is_2d: # Projection processing does not deskew or pad its image plane. return np.ones((1, int(self.y_dim)), dtype=np.float32) raw_sizes = self.acquisition.index_sizes @@ -1660,10 +1860,19 @@ def _build_deskew_support_mask(self) -> np.ndarray: angle_deg = float(self.acquisition.angle_deg) scan_step_um = float(self.acquisition.scan_axis_step_um) raw_pixel_size_um = float(self.acquisition.pixel_size_um) - reconstruction = self.processing_state.run(self.data).get("reconstruction") + run = self.processing_state.run(self.data) + reconstruction = run.get("reconstruction") if reconstruction is not None: scan_count, raw_camera_y, _ = map(int, reconstruction["shape_syx"]) scan_step_um = float(reconstruction["scan_axis_step_um"]) + if roi_record is not None: + scan_count, raw_camera_y, _ = map(int, roi_record["skewed_shape_syx"]) + scan_step_um = float(self.acquisition.scan_axis_step_um) + if "reconstruction_shape_syx" in roi_record: + scan_count, raw_camera_y, _ = map( + int, roi_record["reconstruction_shape_syx"] + ) + scan_step_um = float(reconstruction["scan_axis_step_um"]) z_downsample = round(float(self._pixel_size[0]) / raw_pixel_size_um) support = orthogonal_deskew( @@ -1675,13 +1884,22 @@ def _build_deskew_support_mask(self) -> np.ndarray: theta=angle_deg, distance=scan_step_um, pixel_size=raw_pixel_size_um, - divisible_by=1, + divisible_by=1 if roi_record is None else 4, downsample_factor=z_downsample, )[..., 0] + configuration = run.get("configuration", {}) + if roi_record is not None: + crop_y0, crop_y1 = map(int, roi_record["deskew_crop_yx"][:2]) + support = support[:, crop_y0:crop_y1] + elif configuration.get("crop_after_deskew", False): + crop_y = int(np.ceil(raw_camera_y * np.cos(np.deg2rad(angle_deg)))) + support = support[:, crop_y:-crop_y] full_response = 2048 * raw_pixel_size_um / scan_step_um support *= np.float32(z_downsample / full_response) np.rint(support, out=support) support /= np.float32(z_downsample) + if roi_record is not None: + return np.ascontiguousarray(support) padded_support = np.zeros( (int(self.z_dim), int(self.y_dim)), dtype=np.float32, @@ -1689,57 +1907,6 @@ def _build_deskew_support_mask(self) -> np.ndarray: padded_support[:, : support.shape[1]] = support return padded_support - def _build_variable_roi_support_masks(self) -> list[np.ndarray]: - """Build cropped interpolation coverage for every variable ROI tile. - - Returns - ------- - list[np.ndarray] - Float32 ZY fractions of valid samples in each averaged Z bin, - ordered by cropped tile series. - """ - angle_deg = float(self.acquisition.angle_deg) - scan_step_um = float(self.acquisition.scan_axis_step_um) - raw_pixel_size_um = float(self.acquisition.pixel_size_um) - z_downsample = round(float(self._pixel_size[0]) / raw_pixel_size_um) - - masks: list[np.ndarray] = [] - for record in self._roi_tile_records: - scan_count, camera_y, _camera_x = map(int, record["skewed_shape_syx"]) - crop_y0, crop_y1, _crop_x0, _crop_x1 = map(int, record["deskew_crop_yx"]) - support = orthogonal_deskew( - np.full( - (scan_count, camera_y, 1), - 1024.0, - dtype=np.float32, - ), - theta=angle_deg, - distance=scan_step_um, - pixel_size=raw_pixel_size_um, - divisible_by=4, - downsample_factor=z_downsample, - )[..., 0] - full_response = 2048 * raw_pixel_size_um / scan_step_um - support *= np.float32(z_downsample / full_response) - np.rint(support, out=support) - support /= np.float32(z_downsample) - support = support[:, crop_y0:crop_y1] - masks.append(np.ascontiguousarray(support)) - return masks - - def _update_profiles(self) -> None: - """Recompute 1D feather profiles from blend_pixels and current data shape. - - Returns - ------- - None - Populates `z_profile`, `y_profile`, and `x_profile` attributes. - """ - bz, by, bx = self._blend_pixels - self.z_profile = self._make_1d_profile(int(self.z_dim), int(bz)) - self.y_profile = self._make_1d_profile(int(self.y_dim), int(by)) - self.x_profile = self._make_1d_profile(int(self.x_dim), int(bx)) - def _load_zero_channel_metadata(self) -> None: """Load completed and zero-channel decisions from processing state.""" completed = self.processing_state.completed_tiles(self.data) @@ -1775,23 +1942,6 @@ def _load_zero_channel_metadata(self) -> None: f"Excluding {zero_count} zero tile/channel volumes from fusion weights." ) - def _channel_has_signal(self, tile_index: int, channel_index: int) -> bool: - """Return processing's durable tile/channel signal decision. - - Parameters - ---------- - tile_index : int - Index into the selected processed tile collection. - channel_index : int - Acquisition channel index. - - Returns - ------- - bool - True unless this tile/channel was checkpointed as empty during processing. - """ - return self._tile_channel_nonzero[(tile_index, channel_index)] - @staticmethod def _make_1d_profile(length: int, blend: int) -> np.ndarray: """Create a 1D feather profile with linear ramps at both ends. @@ -1940,6 +2090,7 @@ def register_and_score( allow_bounded_peak: bool = True, registration_axis: int | None = None, support_masks: tuple[Any, Any] | None = None, + shift_sampling: Sequence[int] | None = None, ) -> tuple[tuple[float, float, float], float]: """Register `g2` to `g1` and compute an SSIM score. @@ -1975,6 +2126,10 @@ def register_and_score( Fixed and moving geometric support masks at registration sampling. Exclude padding and partially supported downsampling blocks from SSIM windows. None treats both patches as fully supported. + shift_sampling : sequence of int or None, default=None + Full-resolution pixels per sampled axis. When provided, compare + candidates on the full-resolution integer grid actually retained + by fusion, rather than scoring a fractional shift and later rounding. Returns ------- @@ -2002,6 +2157,17 @@ def register_and_score( if masks is not None: masks = tuple(mask[0] for mask in masks) + # Histogram matching can turn a blank patch into the reference's + # brightest constant value. Reject it before changing its intensities. + if arr1.size == 0 or arr2.size == 0: + return (0.0, 0.0, 0.0), -math.inf + ranges = [(float(xp.min(arr)), float(xp.max(arr))) for arr in (arr1, arr2)] + # Constant patches contain no displacement information, including when + # both have the same nonzero background. An SSIM of one is not a link. + if any(low == high for low, high in ranges): + return (0.0, 0.0, 0.0), -math.inf + + unmodified_moving = arr2 arr2 = match_histograms(arr2, arr1) disambiguate = True if max_shift is not None: @@ -2011,6 +2177,33 @@ def register_and_score( for limit, length in zip(max_shift, arr1.shape, strict=False) ) + def measured_phase_shift( + fixed: Any, moving: Any, *, resolve_alias: bool + ) -> Any: + """Discard the unresolved periodic shift returned after degeneracy.""" + try: + with warnings.catch_warnings(): + warnings.filterwarnings( + "error", + message="Could not determine real-space shift for periodic shift", + category=UserWarning, + ) + shift, _, _ = phase_cross_correlation( + fixed, + moving, + disambiguate=resolve_alias, + normalization="phase", + upsample_factor=10, + overlap_ratio=0.5, + ) + except UserWarning as error: + if str(error).startswith( + "Could not determine real-space shift for periodic shift" + ) and "disambiguation is degenerate" in str(error): + return None + raise + return xp.asarray(shift, dtype=xp.float32) + def correlate(fixed: Any, moving: Any) -> Any: """Calculate phase correlation with the selected CPU or CUDA backend. @@ -2024,128 +2217,71 @@ def correlate(fixed: Any, moving: Any) -> Any: Returns ------- Any - Measured phase-correlation shift in registration pixels. + Measured shift, or None for zero energy or unresolved aliases. """ + # An optional directional derivative may be all zero even when + # the intensity images contain useful features in other directions. + if not bool(xp.any(fixed)) or not bool(xp.any(moving)): + return None # CPU disambiguation can move identical, textureless crops to an - # equivalent correlation peak. Their measured correction is zero. - if not USING_GPU and np.array_equal(fixed, moving): - return np.zeros(fixed.ndim, dtype=np.float32) - shift, _, _ = phase_cross_correlation( - fixed, - moving, - disambiguate=disambiguate, - normalization="phase", - upsample_factor=10, - overlap_ratio=0.5, - ) - result = xp.asarray(shift, dtype=xp.float32) + # equivalent peak. Preserve zero correction on both backends. + if bool(xp.array_equal(fixed, moving)): + return xp.zeros(fixed.ndim, dtype=xp.float32) + result = measured_phase_shift(fixed, moving, resolve_alias=disambiguate) + if result is None: + return None if ( not disambiguate and max_shift is not None and bool(xp.any(xp.abs(result) >= limits * 0.5)) ): - shift, _, _ = phase_cross_correlation( - fixed, - moving, - disambiguate=True, - normalization="phase", - upsample_factor=10, - overlap_ratio=0.5, - ) - result = xp.asarray(shift, dtype=xp.float32) + result = measured_phase_shift(fixed, moving, resolve_alias=True) return result + def retained_shift(candidate: Any) -> Any: + """Round candidate corrections onto the retained acquisition sampling. + + Parameters + ---------- + candidate : array-like + Registration displacement in reduced patch pixels. + + Returns + ------- + array-like + Correction snapped to the requested physical sampling. + """ + if shift_sampling is not None: + sampling = xp.asarray(shift_sampling, dtype=xp.float32) + return xp.rint(candidate * sampling) / sampling + return candidate + def score_shift(candidate: Any) -> float: - """Evaluate overlap similarity for a candidate registration correction. + """Score a sampled candidate on its valid overlapping detector support. Parameters ---------- - candidate : Any - Candidate ZYX registration correction in pixels. + candidate : array-like + Registration displacement in reduced patch pixels. Returns ------- float - Structural similarity of the translated overlapping patches. + Structural similarity at the retained correction. """ - try: - fixed_view, moving_view = _aligned_registration_views( - arr1, - arr2, - candidate, - ) - valid_mask = None - if masks is not None: - fixed_mask, moving_mask = _aligned_registration_views( - *masks, candidate - ) - tolerance = 4 * xp.finfo(xp.float32).eps - valid_mask = (fixed_mask >= 1 - tolerance) & ( - moving_mask >= 1 - tolerance - ) - # Remove completely unsupported edge planes before selecting - # the SSIM window; thin depth overlaps often have one. - bounds = [] - for axis in range(valid_mask.ndim): - other_axes = tuple( - index for index in range(valid_mask.ndim) if index != axis - ) - supported = xp.flatnonzero(xp.any(valid_mask, axis=other_axes)) - if supported.size == 0: - return -math.inf - bounds.append(slice(int(supported[0]), int(supported[-1]) + 1)) - bounds = tuple(bounds) - fixed_view = fixed_view[bounds] - moving_view = moving_view[bounds] - valid_mask = valid_mask[bounds] - # Stage-depth overlaps can be thinner than the configured - # window even without downsampling. Score the valid aligned - # support with the largest odd window that fits it. - effective_window = min(win_size, min(fixed_view.shape)) - effective_window -= 1 - effective_window % 2 - score = -math.inf - if effective_window >= 3: - score = _ssim( - fixed_view, - moving_view, - win_size=effective_window, - valid_mask=valid_mask, - ) - # A thin depth overlap can have many common voxels but no - # complete 3D SSIM window. Compare XY means of the same valid - # planes instead, preserving geometric support in both views. - if ( - not math.isfinite(score) - and fixed_view.ndim == 3 - and registration_axis == 0 - and fixed_view.shape[0] <= win_size + 2 - ): - weights = ( - xp.ones_like(fixed_view) if valid_mask is None else valid_mask - ) - count = xp.sum(weights, axis=0) - fixed_view = xp.sum(fixed_view * weights, axis=0) / xp.maximum( - count, 1 - ) - moving_view = xp.sum(moving_view * weights, axis=0) / xp.maximum( - count, 1 - ) - window = min(win_size, min(fixed_view.shape)) - window -= 1 - window % 2 - if window >= 3: - score = _ssim( - fixed_view, - moving_view, - win_size=window, - valid_mask=count > 0, - ) - return score - except ValueError: - return -math.inf + return _registration_similarity( + arr1, + arr2, + retained_shift(candidate), + win_size, + masks, + registration_axis, + ) shift_apply = correlate(arr1, arr2) if ( - max_shift is not None + shift_apply is not None + and max_shift is not None and allow_bounded_peak and bool(xp.any(xp.abs(shift_apply) > limits)) ): @@ -2155,7 +2291,7 @@ def score_shift(candidate: Any) -> float: max_shift, ) - score = score_shift(shift_apply) + score = -math.inf if shift_apply is None else score_shift(shift_apply) if registration_axis is not None: axis = int(registration_axis) if not 0 <= axis < arr1.ndim: @@ -2164,17 +2300,64 @@ def score_shift(candidate: Any) -> float: sobel_filter(arr1, axis=axis), sobel_filter(arr2, axis=axis), ) - edge_is_allowed = max_shift is None or not bool( - xp.any(xp.abs(edge_shift) > limits) + edge_is_allowed = edge_shift is not None and ( + max_shift is None or not bool(xp.any(xp.abs(edge_shift) > limits)) ) if edge_is_allowed and not bool( - xp.all(xp.rint(edge_shift) == xp.rint(shift_apply)) + shift_apply is not None + and xp.all(retained_shift(edge_shift) == retained_shift(shift_apply)) ): edge_score = score_shift(edge_shift) if edge_score > score: shift_apply = edge_shift score = edge_score + if shift_apply is None: + score = -math.inf + + if masks is not None and registration_axis is not None: + # Periodic phase peaks can align crop edges instead of anatomy. + # Padfield's nonperiodic normalized correlation uses the known + # acquisition support for both centering and normalization. Keep + # original intensities for that calculation, and use the same SSIM + # gate as the other proposals. Thin/ambiguous Z still goes through + # the contextual axial-contrast checks in the caller. + tolerance = 4 * xp.finfo(xp.float32).eps + valid_masks = tuple(mask >= 1 - tolerance for mask in masks) + if all(bool(xp.any(mask)) for mask in valid_masks): + masked_shift = _masked_registration_peak( + arr1, + unmodified_moving, + valid_masks, + max_shift, + ) + if ( + masked_shift is not None + and bool(xp.all(xp.isfinite(masked_shift))) + and ( + max_shift is None + or bool(xp.all(xp.abs(masked_shift) <= limits)) + ) + ): + masked_score = score_shift(masked_shift) + if masked_score > score: + shift_apply, score = masked_shift, masked_score + + if shift_apply is not None: + # Nominal stage placement is also a physically valid candidate. + # Do not manufacture a correction when a measured proposal + # reduces agreement between already aligned supported samples. + nominal = xp.zeros(arr1.ndim, dtype=xp.float32) + nominal_score = score_shift(nominal) + if nominal_score > score: + shift_apply, score = nominal, nominal_score + + if shift_apply is None: + return (0.0, 0.0, 0.0), -math.inf + + if shift_sampling is not None: + shift_apply = retained_shift(shift_apply) + if arr1.ndim == 2 and len(shift_apply) == 2: shift_ret = xp.concatenate( ( @@ -2198,8 +2381,10 @@ def refine_tile_positions_with_cross_correlation( ssim_window: int | None = None, ch_idx: int = 0, threshold: float | None = None, + *, + pairs_to_register: set[tuple[int, int]] | None = None, ) -> None: - """Register and score all overlapping tile pairs independently per timepoint. + """Register overlapping pairs, optionally replacing only selected measurements. Parameters ---------- @@ -2212,6 +2397,10 @@ def refine_tile_positions_with_cross_correlation( Channel index used to extract patches. threshold : float or None, optional SSIM acceptance threshold. If None, uses `self.threshold`. + pairs_to_register : set of tuple of int or None + Global tile-index pairs to remeasure. None starts a fresh full pass; + a subset replaces only those pairs, preserving all other raw links, + including links previously rejected by global optimization. Returns ------- @@ -2232,7 +2421,30 @@ def refine_tile_positions_with_cross_correlation( sw = self.ssim_window if ssim_window is None else int(ssim_window) th = self.threshold if threshold is None else float(threshold) - self.pairwise_metrics.clear() + if pairs_to_register is None: + self.pairwise_metrics.clear() + self._rejected_registration_links = {} + self._registration_pair_sampling = {} + else: + if not pairs_to_register: + return + # Remove old measurements before retrying: a failed retry must not + # leave stale accepted or rejected data for the selected pair. + for pair in pairs_to_register: + self.pairwise_metrics.pop(pair, None) + getattr(self, "_rejected_registration_links", {}).pop(pair, None) + getattr(self, "_registration_pair_sampling", {}).pop(pair, None) + self._rejected_registration_links = getattr( + self, "_rejected_registration_links", {} + ) + self._registration_pair_sampling = getattr( + self, "_registration_pair_sampling", {} + ) + selected_tiles = ( + None + if pairs_to_register is None + else {index for pair in pairs_to_register for index in pair} + ) def overlap_bounds_1d( offset: int, @@ -2291,6 +2503,10 @@ def overlap_bounds_1d( with ThreadPoolExecutor(max_workers=read_workers) as executor: for t, tile_indices in enumerate(tile_groups): + if selected_tiles is not None and selected_tiles.isdisjoint( + tile_indices + ): + continue positions = [self._tile_positions[index] for index in tile_indices] tile_shapes = [all_tile_shapes[index] for index in tile_indices] nearest_registration_pairs = set( @@ -2313,6 +2529,7 @@ def overlap_bounds_1d( ] = {} candidate_scores: dict[tuple[int, int], float] = {} rejected_shifts: list[tuple[int, int, int]] = [] + rejected_links = [] pair_specs = [] input_itemsize = int( np.dtype(self.position_arrays[0].dtype.numpy_dtype).itemsize @@ -2320,9 +2537,15 @@ def overlap_bounds_1d( for i_pos, j_pos in registration_pairs: i = tile_indices[i_pos] j = tile_indices[j_pos] - if not self._channel_has_signal( - i, ch_idx - ) or not self._channel_has_signal(j, ch_idx): + if ( + pairs_to_register is not None + and (i, j) not in pairs_to_register + ): + continue + if ( + not self._tile_channel_nonzero[(i, ch_idx)] + or not self._tile_channel_nonzero[(j, ch_idx)] + ): continue phys = np.array(self._tile_positions[j]) - np.array( @@ -2389,6 +2612,35 @@ def overlap_bounds_1d( if self._is_2d else boundary_axis ) + context_z = None + if not self._is_2d and boundary_axis == 0: + max_z = getattr(self, "_pair_registration_limits", {}).get( + (i, j), + getattr( + self, + "_initial_registration_shift_zyx", + self.max_registration_shift_zyx, + ), + )[0] + if max_z > 0: + max_z += 2 + bounds_i[0] = ( + max(0, bounds_i[0][0] - max_z), + min(shape_i[0], bounds_i[0][1] + max_z), + ) + bounds_j[0] = ( + max(0, bounds_j[0][0] - max_z), + min(shape_j[0], bounds_j[0][1] + max_z), + ) + # Keep a common array extent, including variable ROI tiles. + length = min( + bounds_i[0][1] - bounds_i[0][0], + bounds_j[0][1] - bounds_j[0][0], + ) + bounds_i[0] = (bounds_i[0][0], bounds_i[0][0] + length) + bounds_j[0] = (bounds_j[0][0], bounds_j[0][0] + length) + context_z = dz + bounds_j[0][0] - bounds_i[0][0] + df_zyx_eff[0] = 1 df_zyx_eff_tuple = tuple(df_zyx_eff) pair_specs.append( ( @@ -2401,7 +2653,12 @@ def overlap_bounds_1d( df_zyx_eff_tuple, thin_z_overlap, registration_axis, - (2 * math.prod(overlap_shape) * input_itemsize), + ( + 2 + * math.prod(hi - lo for lo, hi in bounds_i) + * input_itemsize + ), + context_z, ) ) @@ -2436,7 +2693,7 @@ def fill_read_ahead() -> None: spec = next(spec_iterator) # noqa: B023 except StopIteration: return - _, _, i, j, bounds_i, bounds_j, _, _, _, pair_bytes = spec + _, _, i, j, bounds_i, bounds_j, _, _, _, pair_bytes, _ = spec if ( pending_reads # noqa: B023 and pending_read_bytes + pair_bytes > read_ahead_budget # noqa: B023 @@ -2486,6 +2743,7 @@ def fill_read_ahead() -> None: thin_z_overlap, registration_axis, pair_bytes, + context_z, ) = spec patch_i = future_i.result() patch_j = future_j.result() @@ -2497,10 +2755,8 @@ def fill_read_ahead() -> None: else: reduce_block = df_zyx_eff if device_buffers is not None: - staged_i, staged_j = device_buffers.stage( - np.asarray(patch_i), - np.asarray(patch_j), - ) + staged_i = device_buffers._stage_one(0, np.asarray(patch_i)) + staged_j = device_buffers._stage_one(1, np.asarray(patch_j)) else: staged_i = xp.asarray(patch_i) staged_j = xp.asarray(patch_j) @@ -2535,8 +2791,14 @@ def fill_read_ahead() -> None: support_masks.append(support) max_shift = getattr(self, "_pair_registration_limits", {}).get( - (i, j), self.max_registration_shift_zyx + (i, j), + getattr( + self, + "_initial_registration_shift_zyx", + self.max_registration_shift_zyx, + ), ) + self._registration_pair_sampling[i, j] = df_zyx_eff if self._is_2d: max_shift_ds = ( max_shift[1] / df_zyx_eff[1], @@ -2549,15 +2811,82 @@ def fill_read_ahead() -> None: max_shift, df_zyx_eff, strict=False ) ) + legacy_g1, legacy_g2 = g1, g2 + legacy_masks = tuple(support_masks) + if context_z is not None: + legacy_g1, legacy_g2 = _aligned_registration_views( + g1, g2, (context_z, 0, 0) + ) + legacy_masks = _aligned_registration_views( + *legacy_masks, (context_z, 0, 0) + ) shift_ds, score = self.register_and_score( - g1, - g2, + legacy_g1, + legacy_g2, win_size=sw, max_shift=max_shift_ds, allow_bounded_peak=not thin_z_overlap, registration_axis=registration_axis, - support_masks=tuple(support_masks), + support_masks=legacy_masks, + shift_sampling=(df_zyx_eff[1:] if self._is_2d else df_zyx_eff), ) + if context_z is not None: + lateral_allowed = all( + abs(value) <= limit + for value, limit in zip( + shift_ds[1:], max_shift_ds[1:], strict=True + ) + ) + refined = xp.asarray((context_z + shift_ds[0], *shift_ds[1:])) + if lateral_allowed: + refined, score = _refine_depth_candidate( + g1, + g2, + tuple(support_masks), + context_z, + shift_ds[1:], + int(max_shift_ds[0]), + sw, + ) + if not lateral_allowed or score < th: + # A large depth error can leave too little matching + # anatomy in the nominal crop to seed XY reliably. + # Try the same phase estimator on the wider context. + context_seed, _ = self.register_and_score( + g1, + g2, + win_size=sw, + allow_bounded_peak=False, + registration_axis=0, + support_masks=tuple(support_masks), + shift_sampling=df_zyx_eff, + ) + if all( + abs(value) <= limit + for value, limit in zip( + context_seed[1:], max_shift_ds[1:], strict=True + ) + ): + alternative, alternative_score = ( + _refine_depth_candidate( + g1, + g2, + tuple(support_masks), + context_z, + context_seed[1:], + int(max_shift_ds[0]), + sw, + ) + ) + if math.isfinite(alternative_score) and ( + not lateral_allowed or alternative_score > score + ): + refined, score = alternative, alternative_score + if USING_GPU and cp is not None: + refined = cp.asnumpy(refined) + refined = np.asarray(refined, dtype=np.float64) + refined[0] -= context_z + shift_ds = refined progress.update() shift_ds_array = np.asarray(shift_ds, dtype=np.float64) if not math.isfinite(score) or not np.all( @@ -2579,6 +2908,14 @@ def fill_read_ahead() -> None: or abs(dx_s) > max_shift[2] ): rejected_shifts.append((dz_s, dy_s, dx_s)) + self._rejected_registration_links[i, j] = ( + (dz_s, dy_s, dx_s), + max_shift, + score, + ) + rejected_links.append( + (i, j, (dz_s, dy_s, dx_s), max_shift, score) + ) if self._debug: print( "Dropping link (%d, %d) shift=%s exceeds max=%s", @@ -2612,6 +2949,11 @@ def fill_read_ahead() -> None: f"measured absolute corrections were {largest}. " "Check --max-registration-shift-zyx if tiles remain misaligned." ) + for i, j, correction, limits, similarity in rejected_links: + print( + f" Rejected link ({i}, {j}): ZYX correction {correction}, " + f"limits {limits}, SSIM {similarity:.3f}." + ) if self._debug: print( "Registration source cache: TensorStore native LRU, " @@ -2842,7 +3184,7 @@ def optimize_shifts( Notes ----- - - Every tile must have at least one accepted image-registration link. + - Isolated tiles retain their nominal stage placement. - Each connected component is anchored at one zero correction, preserving the stage-derived placement between components. """ @@ -2880,9 +3222,18 @@ def pair_sampling(i: int, j: int) -> tuple[int, int, int]: offset, self._tile_shapes[i], self._tile_shapes[j], strict=False ) ) - return _registration_sampling( + sampling = _registration_sampling( overlap, self.downsample_factors, self.ssim_window, self._is_2d ) + separation = np.abs(offset) / np.minimum( + self._tile_shapes[i], self._tile_shapes[j] + ) + limit = getattr(self, "_pair_registration_limits", {}).get( + (i, j), getattr(self, "max_registration_shift_zyx", (0, 0, 0)) + )[0] + if not self._is_2d and int(np.argmax(separation)) == 0 and limit > 0: + return (1, *sampling[1:]) + return sampling links_all: list[dict[str, Any]] = [ { @@ -2928,25 +3279,12 @@ def pair_sampling(i: int, j: int) -> tuple[int, int, int]: if not local_links: continue - adjacency = [set() for _ in range(n_pos)] - for link in local_links: - left = int(link["i"]) - right = int(link["j"]) - adjacency[left].add(right) - adjacency[right].add(left) - fixed = [] - visited: set[int] = set() - for start in range(n_pos): - if start in visited: - continue - fixed.append(start) - visited.add(start) - frontier = [start] - while frontier: - current = frontier.pop() - unseen = adjacency[current] - visited - visited.update(unseen) - frontier.extend(unseen) + fixed = [ + component[0] + for component in _connected_tile_components( + range(n_pos), ((link["i"], link["j"]) for link in local_links) + ) + ] if mode == "ONE_ROUND": d_opt = self._solve_global(local_links, n_pos, fixed) retained_links = local_links @@ -3060,9 +3398,10 @@ def estimate_depth_intensity_gains(self) -> None: ).mean(axis=(3, 5)) support = support.reshape(depth, height // 4, 4).all(axis=2)[:, :, None] for channel in range(int(self.channels)): - if not self._channel_has_signal( - i, channel - ) or not self._channel_has_signal(j, channel): + if ( + not self._tile_channel_nonzero[(i, channel)] + or not self._tile_channel_nonzero[(j, channel)] + ): continue a, b = fixed[channel], moving[channel] valid = ( @@ -3185,33 +3524,18 @@ def report_registration_connectivity(self) -> None: Connectivity diagnostics are printed without changing placements. """ for time_index, tile_indices in enumerate(self._tiles_by_time): - parent = {index: index for index in tile_indices} - - def find(index: int) -> int: - """Find a tile component representative with path compression. - - This disjoint-set helper is consumed before the component loop advances. - - Parameters - ---------- - index : int - Tile or registration-link index used by this calculation. - - Returns - ------- - int - Representative tile index of the connected component. - """ - while parent[index] != index: # noqa: B023 - parent[index] = parent[parent[index]] # noqa: B023 - index = parent[index] # noqa: B023 - return index - - for left, right in getattr( - self, "optimized_pairwise_metrics", self.pairwise_metrics - ): - if left in parent and right in parent: - parent[find(right)] = find(left) + component_by_tile = { + tile: index + for index, component in enumerate( + _connected_tile_components( + tile_indices, + getattr( + self, "optimized_pairwise_metrics", self.pairwise_metrics + ), + ) + ) + for tile in component + } overlapping = _select_registration_pairs( [self._tile_positions[index] for index in tile_indices], [self._tile_shapes[index] for index in tile_indices], @@ -3221,11 +3545,16 @@ def find(index: int) -> int: disconnected = [ (tile_indices[left], tile_indices[right]) for left, right in overlapping - if find(tile_indices[left]) != find(tile_indices[right]) + if component_by_tile[tile_indices[left]] + != component_by_tile[tile_indices[right]] ] if disconnected: components = len( - {find(index) for pair in disconnected for index in pair} + { + component_by_tile[index] + for pair in disconnected + for index in pair + } ) print( f"Registration warning: overlapping tiles at time index " @@ -3235,6 +3564,98 @@ def find(index: int) -> int: "channel, threshold, and --max-registration-shift-zyx." ) + def _expand_registration_limits(self) -> set[tuple[int, int]]: + """Expand rejected pair limits just past otherwise acceptable peaks. + + Only offending pair/axis limits grow, to the absolute measured correction + plus five percent, with at least two pixels or one effective sampling bin + of margin. A peak on the depth search guard band remains provisional: + that pair must be remeasured before refitting the entire graph. + + Returns + ------- + set of tuple of int + Pairs whose limits increased and whose measurements must be replaced. + """ + if not getattr(self, "_automatic_registration_limits", False): + return set() + expanded = set() + for pair, (correction, limits, score) in getattr( + self, "_rejected_registration_links", {} + ).items(): + if not math.isfinite(score) or score < self.threshold: + continue + sampling = getattr(self, "_registration_pair_sampling", {}).get( + pair, self.downsample_factors + ) + updated = list(self._pair_registration_limits.get(pair, limits)) + for axis in range(3): + if limits[axis] > 0 and abs(correction[axis]) > limits[axis]: + peak = abs(correction[axis]) + margin = max(2, sampling[axis], math.ceil(0.05 * peak)) + updated[axis] = max(updated[axis], int(peak + margin)) + if tuple(updated) != self._pair_registration_limits.get(pair, limits): + self._pair_registration_limits[pair] = tuple(updated) + expanded.add(pair) + if not expanded: + return expanded + self.max_registration_shift_zyx = tuple( + max( + self.max_registration_shift_zyx[axis], + *(limits[axis] for limits in self._pair_registration_limits.values()), + ) + for axis in range(3) + ) + return expanded + + def register_with_retries(self) -> None: + """Remeasure expanded pairs and refit the complete graph on every retry. + + Compatible cached final measurements retain their effective search + envelopes. Fresh passes use the unchanged nominal stage positions; + global offsets are applied only after registration has finished. + """ + recomputed = False + pairs_to_register = None + self._registration_retry_count = 0 + try: + self.load_pairwise_metrics() + except ValueError: + recomputed = True + while True: + if recomputed: + self.refine_tile_positions_with_cross_correlation( + downsample_factors=self.downsample_factors, + ch_idx=self.channel_to_use, + threshold=self.threshold, + pairs_to_register=pairs_to_register, + ) + self.optimize_shifts( + method="TWO_ROUND_ITERATIVE", + rel_thresh=self.optimization_rel_threshold, + abs_thresh=self.optimization_abs_threshold, + ) + if ( + not recomputed + or self._registration_retry_count >= self.registration_retries + ): + break + pairs_to_register = self._expand_registration_limits() + if not pairs_to_register: + break + self._registration_retry_count += 1 + print( + f"Registration retry {self._registration_retry_count}/" + f"{self.registration_retries}: automatic maximum ZYX corrections " + f"increased to {self.max_registration_shift_zyx} pixels. " + f"Remeasuring {len(pairs_to_register)} expanded pairs and " + "refitting the global solution using all raw pairwise links " + "from nominal stage placement." + ) + if recomputed: + self.save_pairwise_metrics() + self.report_registration_connectivity() + def save_pairwise_metrics(self) -> None: """Persist pairwise links in the acquisition processing state.""" self.processing_state.save_registration( @@ -3245,6 +3666,16 @@ def save_pairwise_metrics(self) -> None: for (i, j), values in self.pairwise_metrics.items() }, ) + record = self.processing_state.registration(self.data) + record["effective_registration_limits"] = { + "maximum_zyx": list(self.max_registration_shift_zyx), + "pairs": { + f"{i},{j}": list(limits) + for (i, j), limits in self._pair_registration_limits.items() + }, + "retries": self._registration_retry_count, + } + self.processing_state.save() def load_pairwise_metrics(self) -> None: """Load compatible pairwise links from processing state.""" @@ -3253,6 +3684,28 @@ def load_pairwise_metrics(self) -> None: configuration=self._registration_cache_settings(), ) raw_metrics = data["pairwise_metrics"] + effective = data.get("effective_registration_limits") + if not isinstance(effective, dict): + raise ValueError("Saved registration lacks effective retry limits") + try: + maximum = tuple(int(value) for value in effective["maximum_zyx"]) + pairs = { + tuple(map(int, key.split(","))): tuple(int(value) for value in limits) + for key, limits in effective["pairs"].items() + } + retries = int(effective["retries"]) + if ( + len(maximum) != 3 + or any(value < 0 for value in maximum) + or retries < 0 + or any( + len(pair) != 2 or len(limits) != 3 or min(limits) < 0 + for pair, limits in pairs.items() + ) + ): + raise ValueError("Invalid effective registration limits") + except (KeyError, TypeError, AttributeError) as error: + raise ValueError("Invalid effective registration limits") from error metrics = {} invalid_count = self._count_invalid_pairwise_metrics(raw_metrics) @@ -3277,6 +3730,9 @@ def load_pairwise_metrics(self) -> None: f"Processing state contains {invalid_count} invalid registration metrics" ) self.pairwise_metrics = metrics + self.max_registration_shift_zyx = maximum + self._pair_registration_limits = pairs + self._registration_retry_count = retries def _registration_cache_settings(self) -> dict[str, Any]: """Return every setting that can change pairwise registration. @@ -3293,25 +3749,33 @@ def _registration_cache_settings(self) -> dict[str, Any]: "stage_z_to_image_y": "none-orthogonal-stage-placement-v3", "deskew_coverage": "normalize-valid-z-bin-samples-v1", "pair_selection": "all-thick-overlaps-nearest-thin-z-overlaps-v2", - "acceptance": "threshold-only-no-forced-bridges-v2", - "overlap_roi": "full-physical-overlap-main-v1", + "acceptance": "reject-constant-threshold-only-no-forced-bridges-v3", + "overlap_roi": "depth-context-lateral-fallback-axial-contrast-refinement-v4", "downsample": ("upstream-block-reduce-with-adaptive-zyx-ssim-support-v2"), - "phase_registration": ( - "thin-z-full-reject-out-of-range-thick-bounded-peak-v7" - ), - "score": ("fractional-alignment-cucim-ssim-geometric-support-windows-v6"), + "phase_registration": ("phase-edge-and-masked-normalized-correlation-v10"), + "score": ("retained-full-resolution-grid-ssim-supported-window-v8"), "downsample_factors_zyx": list(self.downsample_factors), "ssim_window": self.ssim_window, - "max_registration_shift_zyx": list(self.max_registration_shift_zyx), + "max_registration_shift_zyx": list( + getattr( + self, + "_initial_registration_shift_zyx", + self.max_registration_shift_zyx, + ) + ), + "registration_retries": getattr(self, "registration_retries", 2), + "retry_policy": "selective-pairs-and-global-measured-peak-5pct-2px-bin-v3", "shift_limits_policy": ( - "stage-overlap-oblique-footprint-per-pair-v1" + "stage-overlap-contextual-depth-actual-support-per-pair-v3" if getattr(self, "_automatic_registration_limits", False) else "explicit" ), "pair_shift_limits": { f"{left},{right}": list(limits) for (left, right), limits in getattr( - self, "_pair_registration_limits", {} + self, + "_initial_pair_registration_limits", + getattr(self, "_pair_registration_limits", {}), ).items() }, "threshold": self.threshold, @@ -3324,10 +3788,7 @@ def _registration_cache_settings(self) -> dict[str, Any]: "zero_registration_tiles": [ tile_index for tile_index in range(len(self._tile_positions)) - if not self._channel_has_signal( - tile_index, - self.channel_to_use, - ) + if not self._tile_channel_nonzero[(tile_index, self.channel_to_use)] ], } @@ -3403,9 +3864,14 @@ def _compute_fused_image_space(self) -> None: min_y, max_y = float(roi_y0), float(roi_y1) min_x, max_x = float(roi_x0), float(roi_x1) - sz = int(np.ceil((max_z - min_z) / dz_um)) - sy = int(np.ceil((max_y - min_y) / dy_um)) - sx = int(np.ceil((max_x - min_x) / dx_um)) + extents = np.asarray( + (max_z - min_z, max_y - min_y, max_x - min_x) + ) / np.asarray((dz_um, dy_um, dx_um)) + nearest = np.rint(extents) + # Physical origins can be much larger than a pixel. Subtraction must + # not add a spurious output row at an otherwise exact grid boundary. + extents = np.where(np.abs(extents - nearest) <= 1e-7, nearest, extents) + sz, sy, sx = (int(value) for value in np.ceil(extents)) self.unpadded_shape = (sz, sy, sx) self.offset_um = (float(min_z), float(min_y), float(min_x)) @@ -3452,8 +3918,9 @@ def _prepare_fused_image( factors = (1, *self.multiscale_factors) dz, dy, dx = round_spatial_values(self._pixel_size) offset_z, offset_y, offset_x = round_spatial_values(self.offset_um) + t_axis, t_scale = time_axis(getattr(self, "time_interval_s", None)) axes = [ - {"name": "t", "type": "time"}, + t_axis, {"name": "c", "type": "channel"}, {"name": "z", "type": "space", "unit": "micrometer"}, {"name": "y", "type": "space", "unit": "micrometer"}, @@ -3481,7 +3948,7 @@ def _prepare_fused_image( { "scale": round_tczyx_transform( ( - 1, + t_scale, 1, dz * z_factor, dy * factor, @@ -3678,7 +4145,7 @@ def _render_fusion_block( slice(rz0 - oz, rz1 - oz), slice(ry0 - oy, ry1 - oy), slice(rx0 - ox, rx1 - ox), - dtype=getattr(self, "output_dtype", np.dtype(np.uint16)), + dtype=None, ) direct = self._cast_fusion_values( self._scale_depth_source( @@ -3761,7 +4228,7 @@ def _render_fusion_block( channel_flags = [ np.asarray( [ - self._channel_has_signal(p, channel_index) + self._tile_channel_nonzero[(p, channel_index)] for channel_index in range(int(self.channels)) ], dtype=np.bool_, @@ -3985,10 +4452,9 @@ def collect_rendered(done: set[Future[Any]]) -> None: (tile_index, offset) for tile_index, offset in offsets_t if any( - self._channel_has_signal( - tile_index, - channel_index, - ) + self._tile_channel_nonzero[ + (tile_index, channel_index) + ] for channel_index in range(int(self.channels)) ) and offset[0] + tile_shapes[tile_index][0] > z0 @@ -4260,26 +4726,7 @@ def run(self) -> None: dtype=np.float64, ) else: - recomputed_metrics = False - try: - self.load_pairwise_metrics() - except ValueError: - self.refine_tile_positions_with_cross_correlation( - downsample_factors=self.downsample_factors, - ch_idx=self.channel_to_use, - threshold=self.threshold, - ) - recomputed_metrics = True - - if recomputed_metrics: - self.save_pairwise_metrics() - - self.optimize_shifts( - method="TWO_ROUND_ITERATIVE", - rel_thresh=self.optimization_rel_threshold, - abs_thresh=self.optimization_abs_threshold, - ) - self.report_registration_connectivity() + self.register_with_retries() if self.normalize_depth_intensity: self.estimate_depth_intensity_gains() diff --git a/src/opm_processing/process.py b/src/opm_processing/process.py index 2b56373..a6b6b1f 100644 --- a/src/opm_processing/process.py +++ b/src/opm_processing/process.py @@ -13,6 +13,8 @@ import time import traceback from contextlib import ExitStack +from functools import partial +from multiprocessing.connection import wait as wait_for_worker from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any @@ -20,6 +22,7 @@ import typer from tifffile import TiffFile, TiffWriter, imread from tqdm import tqdm +from yaozarrs import open_group, v05 from opm_processing.dataio.acquisition import ( AcquisitionMetadata, @@ -27,11 +30,18 @@ inspect_acquisition, open_acquisition_datastore, ) +from opm_processing.dataio.deconvolution import ( + detector_gain_from_metadata, + load_deconvolution_psfs, + read_noise_from_metadata, + scan_upsample_from_metadata, +) from opm_processing.dataio.live import ( LIVE_POLL_INTERVAL_SECONDS, LiveManifest, ZarrTileReadiness, iter_live_tiles, + read_lifecycle_event, ) from opm_processing.dataio.ngff import downsample_yx, round_spatial from opm_processing.dataio.position_collection import ( @@ -45,12 +55,12 @@ ProcessingState, processing_state_path, ) +from opm_processing.dataio.ptv import mark_ptv_tile, publish_processing_state_ptv from opm_processing.dataio.roi import ( PhysicalRoi, world_roi_to_skewed_bounds, ) from opm_processing.imageprocessing.camera import ( - QI2LAB_STAGE_SCAN_DETECTOR_WIDTH, camera_correct, illumination_correct, ) @@ -58,7 +68,6 @@ stage_z_level_indices, ) from opm_processing.imageprocessing.maxtilefusion import MaxTileFusion -from opm_processing.imageprocessing.opmpsf import generate_proj_psf, generate_skewed_psf from opm_processing.imageprocessing.opmtools import ( deskew_shape_estimator, orthogonal_deskew, @@ -70,11 +79,11 @@ import tensorstore as ts -app = typer.Typer() +app = typer.Typer(pretty_exceptions_enable=False) app.pretty_exceptions_enable = False -# Reject cached fields made with a different camera-calibration contract. -FLATFIELD_SOFTWARE = "opm-processing/basicpy-stage-depth-camera-contract-v10" +# Reject cached fields made before depth estimates shared one maximum field. +FLATFIELD_SOFTWARE = "opm-processing/basicpy-stage-z-max-camera-contract-v12" def progress_tile_groups( @@ -478,7 +487,7 @@ def build_illumination_signal_decisions( threshold: float | None, min_signal_fraction: float, *, - apply_stage_scan_gain: bool, + detector_gain_x: np.ndarray | None, ) -> np.ndarray | None: """Find nonempty illumination candidates without pre-scanning every tile. @@ -497,8 +506,8 @@ def build_illumination_signal_decisions( detection. min_signal_fraction : float Required fraction of volume pixels at or above the signal threshold. - apply_stage_scan_gain : bool - Apply the fixed qi2lab detector-X gain during camera calibration. + detector_gain_x : numpy.ndarray or None + Apply the recorded detector-X response during camera calibration. Returns ------- @@ -548,7 +557,7 @@ def build_illumination_signal_decisions( raw, camera_offset, camera_conversion, - apply_stage_scan_gain=apply_stage_scan_gain, + detector_gain_x=detector_gain_x, ) is_empty = is_empty_tile( calibrated, @@ -621,12 +630,12 @@ def read_current_flatfield( path : pathlib.Path Filesystem path of the artifact to read or write. expected_shape : tuple[int, int, int, int] - Required depth, channel, Y, X dimensions. + Required singleton depth, channel, Y, X dimensions. Returns ------- numpy.ndarray or None - Depth-CYX fields, or None when the estimator version or acquisition + Shared 1CYX fields, or None when the estimator version or acquisition dimensions differ from this run. """ with TiffFile(path) as tif: @@ -636,7 +645,11 @@ def read_current_flatfield( flatfields = tif.asarray().astype(np.float32) if expected_shape[0] == 1 and flatfields.shape == expected_shape[1:]: flatfields = flatfields[np.newaxis, ...] - if flatfields.shape != expected_shape: + elif expected_shape[:2] == (1, 1) and flatfields.shape == expected_shape[2:]: + flatfields = flatfields[np.newaxis, np.newaxis, ...] + if flatfields.shape != expected_shape or not np.all( + np.isfinite(flatfields) & (flatfields > 0) + ): return None return flatfields @@ -646,14 +659,14 @@ def write_flatfield( flatfields: np.ndarray, pixel_size_um: float, ) -> None: - """Write reusable physical-stage-Z illumination fields. + """Write one reusable illumination field per channel, shared across depth. Parameters ---------- path : pathlib.Path Filesystem path of the artifact to read or write. flatfields : numpy.ndarray - Positive float32 illumination fields in depth, channel, Y, X order. + Positive float32 illumination fields in singleton-depth, channel, Y, X order. pixel_size_um : float Camera pixel pitch in micrometers. @@ -663,6 +676,11 @@ def write_flatfield( Fields are written with spatial metadata and the current estimator identifier. """ + flatfields = np.asarray(flatfields, dtype=np.float32) + if flatfields.ndim != 4 or flatfields.shape[0] != 1 or min(flatfields.shape) < 1: + raise ValueError("Shared flatfields must have shape (1, channels, Y, X)") + if not np.all(np.isfinite(flatfields) & (flatfields > 0)): + raise ValueError("Shared flatfields must be finite and positive") with TiffWriter(path, bigtiff=True) as tif: metadata = { "axes": "ZCYX", @@ -690,7 +708,7 @@ def load_or_estimate_flatfield( pixel_size_um: float, stage_positions_zxy: np.ndarray, *, - apply_stage_scan_gain: bool, + detector_gain_x: np.ndarray | None, signal_threshold: float | None, minimum_signal_fraction: float, signal_mask: np.ndarray | None, @@ -711,8 +729,8 @@ def load_or_estimate_flatfield( Camera pixel pitch in micrometers. stage_positions_zxy : numpy.ndarray Physical stage Z, scan-axis X, and lateral Y coordinates in micrometers. - apply_stage_scan_gain : bool - Apply the fixed qi2lab detector-X gain during camera calibration. + detector_gain_x : numpy.ndarray or None + Apply the recorded detector-X response during camera calibration. signal_threshold : float or None Calibrated signal cutoff for selecting illumination samples; None disables it. @@ -725,11 +743,11 @@ def load_or_estimate_flatfield( Returns ------- numpy.ndarray - Depth-CYX illumination fields reused from disk or estimated and saved. + Shared 1CYX illumination fields reused from disk or estimated and saved. """ stage_z_indices = stage_z_level_indices(stage_positions_zxy) expected_shape = ( - int(stage_z_indices.max()) + 1, + 1, datastore.shape[2], datastore.shape[-2], datastore.shape[-1], @@ -752,7 +770,7 @@ def load_or_estimate_flatfield( camera_conversion, signal_threshold, minimum_signal_fraction, - apply_stage_scan_gain=apply_stage_scan_gain, + detector_gain_x=detector_gain_x, ) flatfields = call_estimate_illuminations( @@ -760,7 +778,7 @@ def load_or_estimate_flatfield( camera_offset, camera_conversion, np.asarray(stage_positions_zxy, dtype=float), - apply_stage_scan_gain, + detector_gain_x, None if signal_mask is None else signal_mask == 1, ) write_flatfield( @@ -799,7 +817,7 @@ def open_live_acquisition( Parameters ---------- root_path : pathlib.Path - Raw acquisition store, or its containing directory for live processing. + Containing acquisition directory with one live manifest and lifecycle log. Returns ------- @@ -815,11 +833,13 @@ def open_live_acquisition( requested_path = Path(root_path).expanduser().resolve() if requested_path.name.endswith((".zarr", ".ome.zarr")): raise ValueError( - "--live requires the containing acquisition directory, not the " + "--live and --live-wait require the containing acquisition directory, not the " "OME-Zarr path" ) if not requested_path.is_dir(): - raise ValueError("--live requires the containing acquisition directory") + raise ValueError( + "--live and --live-wait require the containing acquisition directory" + ) while True: manifests = sorted(requested_path.glob("*.manifest.json")) if len(manifests) == 1: @@ -839,16 +859,28 @@ def open_live_acquisition( log_path = manifest_path.with_name(f"{manifest_stem}.log.jsonl") manifest = LiveManifest.read(manifest_path) root_path = manifest.data_path - required_metadata = [root_path / "zarr.json", root_path / "OME" / "zarr.json"] - required_metadata.extend( - metadata_path - for position in range(manifest.index_sizes["p"]) - for metadata_path in ( - root_path / str(position) / "zarr.json", - root_path / str(position) / "0" / "zarr.json", - ) - ) - while not all(path.is_file() for path in required_metadata): + while True: + event = read_lifecycle_event(log_path, manifest.acquisition_id) + if event in {"canceled", "errored"}: + raise RuntimeError(f"Live acquisition {event}; processing was not started") + if (root_path / "zarr.json").is_file(): + layout = open_group(root_path).ome_metadata() + if isinstance(layout, v05.Image): + required_metadata = [ + root_path / layout.multiscales[0].datasets[0].path / "zarr.json" + ] + else: + required_metadata = [root_path / "OME" / "zarr.json"] + required_metadata.extend( + metadata_path + for position in range(manifest.index_sizes["p"]) + for metadata_path in ( + root_path / str(position) / "zarr.json", + root_path / str(position) / "0" / "zarr.json", + ) + ) + if all(path.is_file() for path in required_metadata): + break print( f"Waiting {LIVE_POLL_INTERVAL_SECONDS:g} seconds for OME-Zarr metadata..." ) @@ -870,6 +902,7 @@ def plan_roi_tiles( z_downsample_level: int, halo_scan: int, halo_x: int, + scan_upsample_factor: int = 1, ) -> tuple[dict[str, Any], ...]: """Plan variable-size cropped ROI tiles without full-tile zero padding. @@ -897,6 +930,8 @@ def plan_roi_tiles( Extra scan planes retained around each ROI for deconvolution support. halo_x : int Extra camera-X pixels retained around each ROI for deconvolution support. + scan_upsample_factor : int + Integer refinement applied after reading raw scan planes. Returns ------- @@ -905,13 +940,23 @@ def plan_roi_tiles( origins. """ plans: list[dict[str, Any]] = [] - roi_y0, roi_y1, roi_x0, roi_x1 = roi.bounds_yx_um for time_index in time_indices: for position_index in position_indices: registered_origin = roi.registered_tile_origin_zyx_um( time_index, position_index, ) + if registered_origin is None: + continue + footprint = next( + item + for item in roi.tile_footprints + if int(item["time_index"]) == time_index + and int(item["position_index"]) == position_index + ) + registered_origin = tuple( + footprint.get("raw_origin_zyx_um", registered_origin) + ) skewed = world_roi_to_skewed_bounds( roi.bounds_yx_um, registered_origin[-2:], @@ -929,14 +974,19 @@ def plan_roi_tiles( int(deskew_input_shape[1]), skewed.x_stop - skewed.x_start, ) + local_reconstruction_shape = ( + (local_input_shape[0] - 1) * scan_upsample_factor + 1, + *local_input_shape[1:], + ) local_deskew_shape, _pad_y, _pad_x, _crop_y = deskew_shape_estimator( - local_input_shape, + local_reconstruction_shape, theta=angle_deg, - distance=scan_step_um, + distance=scan_step_um / scan_upsample_factor, pixel_size=pixel_size_um, crop_after_deskew=False, + downsample_factor=z_downsample_level, ) - local_z = int(local_deskew_shape[0]) // int(z_downsample_level) + local_z = int(local_deskew_shape[0]) local_y = int(local_deskew_shape[1]) local_x = int(local_deskew_shape[2]) local_origin_y = float(registered_origin[1]) + int( @@ -945,22 +995,19 @@ def plan_roi_tiles( local_origin_x = float(registered_origin[2]) + int(skewed.x_start) * float( pixel_size_um ) - crop_y0 = max( - 0, - math.floor((roi_y0 - local_origin_y) / pixel_size_um), - ) - crop_y1 = min( - local_y, - math.ceil((roi_y1 - local_origin_y) / pixel_size_um), - ) - crop_x0 = max( - 0, - math.floor((roi_x0 - local_origin_x) / pixel_size_um), - ) - crop_x1 = min( - local_x, - math.ceil((roi_x1 - local_origin_x) / pixel_size_um), + grid_edges = ( + np.asarray(roi.bounds_yx_um) + - np.repeat((local_origin_y, local_origin_x), 2) + ) / pixel_size_um + nearest = np.rint(grid_edges) + # Suppress roundoff at exact pixel edges without moving fractional ROIs. + grid_edges = np.where( + np.abs(grid_edges - nearest) <= 1e-7, nearest, grid_edges ) + crop_y0 = max(0, math.floor(grid_edges[0])) + crop_y1 = min(local_y, math.ceil(grid_edges[1])) + crop_x0 = max(0, math.floor(grid_edges[2])) + crop_x1 = min(local_x, math.ceil(grid_edges[3])) if crop_y0 >= crop_y1 or crop_x0 >= crop_x1: continue output_origin = ( @@ -988,6 +1035,11 @@ def plan_roi_tiles( int(skewed.x_stop), ], "skewed_shape_syx": list(local_input_shape), + **( + {"reconstruction_shape_syx": list(local_reconstruction_shape)} + if scan_upsample_factor > 1 + else {} + ), "deskew_crop_yx": [crop_y0, crop_y1, crop_x0, crop_x1], } ) @@ -1023,6 +1075,126 @@ def apply_stage_axis_flips( return transformed +def confirm_partial_output_resume( + acquisition: AcquisitionMetadata, + output_dir: Path | None, + *, + deconvolve: bool, + max_projection: bool, + resume: bool, +) -> bool: + """Ask before overwriting an incomplete offline processing run. + + Parameters + ---------- + acquisition : AcquisitionMetadata + Inspected source supplying expected time and position dimensions. + output_dir : Path or None + Processed output directory, or None for the source directory. + deconvolve : bool + Select the deconvolved output family. + max_projection : bool + Include the per-position maximum-Z output for volume processing. + resume : bool + Explicit resume skips the prompt and preserves existing validation. + + Returns + ------- + bool + True after explicit or confirmed resume; False for an intentional + overwrite, new run, or completed run. Cancel or unavailable input aborts + before writes. + """ + if resume: + return True + output_dir = ( + acquisition.path.parent + if output_dir is None + else Path(output_dir).expanduser().resolve() + ) + stem = acquisition_stem(acquisition.path) + mode = acquisition.mode.casefold() + planar = acquisition.is_2d or "projection" in mode + if planar: + label = "projection" if "projection" in mode else "2d" + labels = [f"decon_{label}" if deconvolve else label] + else: + label = "decon_deskewed" if deconvolve else "deskewed" + labels = [label, f"max_z_{label}"] if max_projection else [label] + existing = [ + output_dir / f"{stem}_{label}.ome.zarr" + for label in labels + if (output_dir / f"{stem}_{label}.ome.zarr").exists() + ] + if not existing: + return False + + try: + state = ProcessingState.read(processing_state_path(output_dir, stem)) + if ( + Path(state.document["source"]["path"]).resolve() + != acquisition.path.resolve() + ): + raise ValueError("Completion journal belongs to a different acquisition") + except (OSError, ValueError, KeyError, TypeError): + state = None + + partial = [] + for path in existing: + try: + if state is None: + raise ValueError("No readable completion journal") + run = state.run(path) + selection = run["configuration"]["selection"] + times = range( + *(selection.get("time") or (0, acquisition.index_sizes.get("t", 1))) + ) + positions = range( + *(selection.get("position") or (0, acquisition.tile_count)) + ) + expected = len(times) * len(positions) + completed = sum( + time_index in times and position_index in positions + for time_index, position_index in state.completed_tiles(path) + ) + metadata_present = all( + file.is_file() + for file in ( + path / "zarr.json", + path / "OME" / "zarr.json", + path / "OME" / "METADATA.ome.xml", + ) + ) and all( + (path / str(position) / "zarr.json").is_file() + and (path / str(position) / "0" / "zarr.json").is_file() + for position in positions + ) + if completed == expected and metadata_present: + continue + detail = f"{completed} of {expected} selected tiles complete" + if not metadata_present: + detail += "; position metadata is missing" + except (OSError, ValueError, KeyError, TypeError): + detail = "completion cannot be verified from the processing journal" + partial.append(f"{path} ({detail})") + + if not partial: + return False + typer.echo("Partial processed output found:\n" + "\n".join(partial)) + typer.echo("Overwrite restarts the selected outputs from scratch.") + while True: + action = typer.prompt("Resume / Overwrite / Cancel", default="cancel") + match action.strip().casefold(): + case "resume" | "r": + return True + case "overwrite" | "o": + return False + case "cancel" | "c": + raise typer.Abort() + case _: + typer.echo("Enter resume, overwrite, or cancel (r/o/c).") + + @app.command() def process( root_path: Path, @@ -1072,26 +1244,14 @@ def process( decon_verbose: int = 1, decon_fallback_step_scan: int | None = None, decon_psf_paths: list[Path] | None = None, - decon_scan_upsample: Annotated[ - int | None, - typer.Option( - "--decon-scan-upsample", - min=1, - help=( - "Opt into experimental undersampled RLGC with this integer scan " - "upsampling factor. Requires --deconvolve and a 3D scan. " - "Uses a full volume on the GPU; supplied PSFs must use the finer " - "scan spacing." - ), - ), - ] = None, resume: Annotated[ bool, typer.Option( "--resume", help=( "Reuse compatible processed outputs and continue after the last " - "durably completed tile. Without this flag, outputs are overwritten." + "durably completed tile. Without this flag, partial outputs prompt " + "to resume, overwrite, or cancel; completed outputs are overwritten." ), ), ] = False, @@ -1115,6 +1275,24 @@ def process( ), ), ] = None, + live_wait: Annotated[ + bool, + typer.Option( + "--live-wait", + help=( + "Wait for live acquisition completion and all raw tiles before " + "processing. Supports --flatfield-correction without a supplied " + "illumination file. Pass the acquisition directory." + ), + ), + ] = False, + plane_period_ms: Annotated[ + float | None, + typer.Option( + "--plane-period-ms", + help="Confirmed hardware plane period in milliseconds for legacy acquisitions.", + ), + ] = None, ) -> None: """Process an OPM acquisition using its recorded scan and camera calibration. @@ -1122,6 +1300,9 @@ def process( ---------- root_path : pathlib.Path Raw acquisition store, or its containing directory for live processing. + plane_period_ms : float or None + Confirmed hardware trigger period for missing legacy timing metadata. + Recorded periods cannot be overridden. Exposure is not used as a period. deconvolve : bool Apply Richardson–Lucy gradient-consensus deconvolution before saving. save_float32 : bool @@ -1149,7 +1330,7 @@ def process( Start-inclusive, stop-exclusive position selection; None processes all positions. eager_mode : bool - Use eager stopping for planar deconvolution; unused for skewed volumes. + Legacy compatibility option; reference KL stopping requires both halves. decon_crop_scan : int or None Retained scan-plane chunk size for 3D deconvolution; None calibrates it automatically. @@ -1163,16 +1344,18 @@ def process( decon_psf_paths : list[Path] or None Channel-ordered PSF files; None generates theoretical PSFs. Planar processing uses a central Z slice of 3D PSFs. - decon_scan_upsample : int or None - Experimental integer scan upsampling factor; None uses the standard solver. - Requires 3D deconvolution and a full GPU volume. resume : bool - Reuse compatible output checkpoints; False overwrites this output run. + Reuse compatible output checkpoints; False asks before overwriting partial + outputs and overwrites completed output runs. output : Path or None Output directory to create; None uses the acquisition directory. live : Path or None Supplied CYX illumination TIFF enabling live processing; None processes offline. + live_wait : bool + Wait for successful live acquisition completion and all expected raw + tiles, then process offline, estimating illumination when requested. + Returns ------- @@ -1185,16 +1368,8 @@ def process( skip_empty_min_signal_fraction, ) - if decon_scan_upsample is not None: - if not deconvolve: - raise typer.BadParameter("--decon-scan-upsample requires --deconvolve") - if decon_scan_upsample < 1 or int(decon_scan_upsample) != decon_scan_upsample: - raise typer.BadParameter("--decon-scan-upsample must be a positive integer") - if decon_crop_scan is not None or decon_fallback_step_scan is not None: - raise typer.BadParameter( - "Experimental undersampled RLGC uses a full GPU volume; " - "--decon-crop-scan and --decon-fallback-step-scan are unsupported" - ) + if live_wait and live is not None: + raise typer.BadParameter("--live-wait cannot be combined with --live") if live is not None: if flatfield_correction: @@ -1205,6 +1380,9 @@ def process( if time_range is not None or pos_range is not None: raise ValueError("--live does not support time or position subranges") acquisition, live_manifest, live_log_path = open_live_acquisition(root_path) + acquisition = acquisition.with_plane_period( + None if plane_period_ms is None else plane_period_ms / 1000 + ) root_path = acquisition.path opm_mode = acquisition.mode.casefold() if "mirror" not in opm_mode and "stage" not in opm_mode: @@ -1230,7 +1408,6 @@ def process( decon_verbose=decon_verbose, decon_fallback_step_scan=decon_fallback_step_scan, decon_psf_paths=decon_psf_paths, - decon_scan_upsample=decon_scan_upsample, illumination_path=live, live_manifest=live_manifest, live_log_path=live_log_path, @@ -1238,7 +1415,24 @@ def process( ) return - acquisition = inspect_acquisition(root_path) + if live_wait: + acquisition, manifest, log_path = open_live_acquisition(root_path) + print( + "Waiting for live acquisition completion before calibration and processing..." + ) + for _tile in iter_live_tiles( + ZarrTileReadiness(acquisition), manifest, log_path, sleeper=time.sleep + ): + pass + # Refresh metadata after the controller has finished writing, then keep + # its manifest geometry and calibration for the ordinary offline path. + acquisition = manifest.apply(inspect_acquisition(acquisition.path)) + print("Live acquisition completed; starting offline processing.") + else: + acquisition = inspect_acquisition(root_path) + acquisition = acquisition.with_plane_period( + None if plane_period_ms is None else plane_period_ms / 1000 + ) root_path = acquisition.path opm_mode = acquisition.mode.casefold() sizes = acquisition.index_sizes @@ -1249,6 +1443,13 @@ def process( f"channels={list(acquisition.channel_names)}" ) print(f"Processing OPM mode: {opm_mode}") + resume = confirm_partial_output_resume( + acquisition, + output, + deconvolve=deconvolve, + max_projection=max_projection, + resume=resume, + ) common = { "root_path": root_path, "output_dir": output, @@ -1267,10 +1468,6 @@ def process( "resume": resume, } if acquisition.is_2d or "projection" in opm_mode: - if decon_scan_upsample is not None: - raise typer.BadParameter( - "--decon-scan-upsample requires a 3D mirror or stage scan" - ) process_projection( **common, eager_deconvolution=eager_mode, @@ -1280,7 +1477,6 @@ def process( **common, decon_crop_scan=decon_crop_scan, decon_fallback_step_scan=decon_fallback_step_scan, - decon_scan_upsample=decon_scan_upsample, max_projection=max_projection, create_fused_max_projection=create_fused_max_projection, z_downsample_level=z_downsample_level, @@ -1316,7 +1512,6 @@ def process_skewed( live_log_path: Path | None = None, roi_selection: PhysicalRoi | None = None, resume: bool = False, - decon_scan_upsample: int | None = None, ) -> None: """Calibrate, optionally deconvolve, and deskew mirror or stage scan tiles. @@ -1378,9 +1573,7 @@ def process_skewed( full tiles. resume : bool Reuse compatible output checkpoints; False overwrites this output run. - decon_scan_upsample : int or None - Experimental integer scan upsampling factor; None uses the standard solver. - Requires 3D deconvolution and a full GPU volume. + Returns ------- @@ -1388,6 +1581,9 @@ def process_skewed( Deskewed tiles, optional maximum projections, and checkpoints are written to disk. """ + acquisition.validate_processing_calibration() + decon_scan_upsample = scan_upsample_from_metadata(acquisition) if deconvolve else 1 + decon_read_noise = read_noise_from_metadata(acquisition) if deconvolve else None # Fusion consumes the per-position maximum-projection datastore. Treat # --no-max-projection as disabling that dependent output as well instead of # trying to open a datastore that was intentionally never created. @@ -1396,15 +1592,15 @@ def process_skewed( skip_empty_below, skip_empty_min_signal_fraction, ) - if deconvolve and decon_scan_upsample is None: + if deconvolve: from opm_processing.imageprocessing.rlgc import ( + GC_RELATIVE_CHANGE_LIMIT, + GC_UPDATED_FRACTION_LIMIT, RlgcChunkState, - chunked_rlgc, + rlgc, ) decon_chunk_state = RlgcChunkState(decon_crop_scan) - elif deconvolve: - from opm_processing.imageprocessing.rlgc_undersampled import rlgc_undersampled max_z_factors_yx = (2, 4, 8, 16, 32) output_dir = ( @@ -1415,14 +1611,13 @@ def process_skewed( output_dir.mkdir(parents=True, exist_ok=True) datastore = open_acquisition_datastore(acquisition) - opm_mode = acquisition.mode scan_axis_step_um = acquisition.scan_axis_step_um - reconstruction_step_um = scan_axis_step_um / (decon_scan_upsample or 1) - if decon_scan_upsample is not None: + reconstruction_step_um = scan_axis_step_um / decon_scan_upsample + if decon_scan_upsample > 1: print( - f"Experimental undersampled RLGC: factor={decon_scan_upsample}, " + f"Automatic RLGC scan refinement: factor={decon_scan_upsample}, " f"acquired step={scan_axis_step_um:g} um, " - f"reconstruction step={reconstruction_step_um:g} um (full GPU volume)" + f"reconstruction step={reconstruction_step_um:g} um" ) excess_scan_positions = ( acquisition.excess_scan_start_positions or acquisition.excess_scan_positions @@ -1434,46 +1629,19 @@ def process_skewed( opm_tilt_deg = acquisition.angle_deg camera_offset = acquisition.camera_offset camera_conversion = acquisition.camera_conversion + detector_gain_x = detector_gain_from_metadata(acquisition, int(datastore.shape[-1])) channels = list(acquisition.channel_names) stage_positions_raw = np.asarray(acquisition.stage_positions_zxy, dtype=float) pixel_size_um = float(pixel_size_um) opm_tilt_deg = float(opm_tilt_deg) camera_offset = float(camera_offset) camera_conversion = float(camera_conversion) - apply_qi2lab_stage_scan_gain = ( - "stage" in str(opm_mode).casefold() - and int(datastore.shape[-1]) == QI2LAB_STAGE_SCAN_DETECTOR_WIDTH - ) output_dtype = np.dtype(np.float32 if save_float32 else np.uint16) psfs: list[np.ndarray] | None = None if deconvolve: - if decon_psf_paths is not None: - if len(decon_psf_paths) != len(channels): - raise ValueError("decon_psf_paths must contain one PSF per channel") - psfs = [ - ( - np.load(path) - if Path(path).suffix == ".npy" - else np.asarray(imread(path)) - ) - for path in tqdm(decon_psf_paths, desc="PSFs", unit="PSF") - ] - else: - psfs = [ - generate_skewed_psf( - em_wvl=max( - float(wavelength.strip().lower().removesuffix("nm")) - for wavelength in channel.split("+") - ) - / 1000, - pixel_size_um=pixel_size_um, - scan_axis_step_um=reconstruction_step_um, - theta_deg=opm_tilt_deg, - pz=0.0, - plot=False, - ) - for channel in tqdm(channels, desc="PSFs", unit="PSF") - ] + psfs, psf_parameters = load_deconvolution_psfs( + acquisition, decon_psf_paths, reconstruction_step_um=reconstruction_step_um + ) stage_positions = apply_stage_axis_flips(stage_positions_raw, stage_axis_flips) stage_z_indices = stage_z_level_indices(stage_positions_raw) stage_z_flipped = stage_axis_flips[2] @@ -1486,7 +1654,8 @@ def process_skewed( datastore.shape[-2], datastore.shape[-1], ) - if decon_scan_upsample is not None: + raw_input_shape = deskew_input_shape + if decon_scan_upsample > 1: deskew_input_shape = ( (deskew_input_shape[0] - 1) * decon_scan_upsample + 1, *deskew_input_shape[1:], @@ -1497,12 +1666,19 @@ def process_skewed( distance=reconstruction_step_um, pixel_size=pixel_size_um, crop_after_deskew=crop_after_deskew, + downsample_factor=z_downsample_level, ) roi_position_indices = ( None if roi_selection is None else roi_selection.position_indices ) - roi_halo_scan = 0 if psfs is None else max(int(psf.shape[0]) // 2 for psf in psfs) + roi_halo_scan = ( + 0 + if psfs is None + else max( + math.ceil((int(psf.shape[0]) // 2) / decon_scan_upsample) for psf in psfs + ) + ) roi_halo_x = 0 if psfs is None else max(int(psf.shape[-1]) // 2 for psf in psfs) if time_range is not None: @@ -1536,7 +1712,7 @@ def process_skewed( roi_selection, plan_times, plan_positions, - tuple(int(value) for value in deskew_input_shape), + tuple(int(value) for value in raw_input_shape), channel_count=int(datastore.shape[2]), pixel_size_um=pixel_size_um, scan_step_um=scan_axis_step_um, @@ -1544,6 +1720,7 @@ def process_skewed( z_downsample_level=z_downsample_level, halo_scan=roi_halo_scan, halo_x=roi_halo_x, + scan_upsample_factor=decon_scan_upsample, ) roi_plan_lookup = { (int(plan["time_index"]), int(plan["position_index"])): (index, plan) @@ -1554,7 +1731,7 @@ def process_skewed( time_shape, pos_shape, datastore.shape[2], - deskewed_shape[0] // z_downsample_level, + deskewed_shape[0], deskewed_shape[1], deskewed_shape[2], ] @@ -1589,7 +1766,7 @@ def process_skewed( camera_conversion, skip_empty_below, skip_empty_min_signal_fraction, - apply_stage_scan_gain=apply_qi2lab_stage_scan_gain, + detector_gain_x=detector_gain_x, ) ) output_kind = "deconvolved_deskewed" if deconvolve else "deskewed" @@ -1600,7 +1777,25 @@ def process_skewed( ) processing_configuration = { "kind": output_kind, + "deskew_interpolation": "orthogonal-perpendicular-distance-weights-v2-complete-z-bins", + "calibration": { + "gain_offset_applied": True, + "camera_offset_adu": camera_offset, + "camera_conversion": camera_conversion, + "camera_correction": "opm_processing.imageprocessing.camera.camera_correct", + "detector_gain_x_applied": detector_gain_x is not None, + "illumination_correction_applied": bool(flatfield_correction), + "localization_uncertainty": { + "coverage_validated": False, + "noise_model": "unvalidated", + "source": None, + }, + }, + "timing": acquisition.timing_attributes(), "dtype": output_dtype.name, + "detector_gain_x": None + if detector_gain_x is None + else detector_gain_x.tolist(), "flatfield": bool(flatfield_correction), "selection": { "time": time_range, @@ -1620,9 +1815,42 @@ def process_skewed( None if not deconvolve else { - "model_revision": "opm-physical-rlgc-20261008", + "model_revision": "gradient-consensus-v12-recorded-calibration", + "observation_model": "poisson", + "gc_stopping": { + "reference": "gc_deconv.m@0340af0d7a4c5d92a97a1b443dfedb367dfb9f3d", + "kl_enabled": True, + "kl_domain": "acquired-pixel-prediction", + "kl_direction": "prediction-to-split", + "kl_comparison": "preceding-iteration-fresh-halves", + "kl_require_both": True, + "consensus_product": "relative-directions-before-blur", + "split": "matlab-do_binosplit", + "read_noise": decon_read_noise, + "read_noise_units": "calibrated-detector-counts-rms", + "read_noise_reference": "gc_deconv.m", + "accepted_fraction_limit": GC_UPDATED_FRACTION_LIMIT, + "relative_change_limit": GC_RELATIVE_CHANGE_LIMIT, + "peak_floor": 1e-12, + "iteration_limit": None, + }, + "observations": "calibrated-detector-counts", + "detector_response": "forward-adjoint-sensitivity", "crop_scan": decon_crop_scan, "fallback_step_scan": decon_fallback_step_scan, + "psf_parameters": None + if decon_psf_paths is not None + else psf_parameters, + **( + {} + if decon_psf_paths is not None + else { + "psf_sampling": "oblique-camera-pixel-integral-centered-taper-v3", + } + ), + "emission_wavelengths_nm": (acquisition.deconvolution or {}).get( + "emission_wavelengths_nm" + ), "psf_sha256": ( None if decon_psf_paths is None @@ -1631,9 +1859,9 @@ def process_skewed( } ), } - if decon_scan_upsample is not None: + if decon_scan_upsample > 1: processing_configuration["deconvolution"].update( - backend="rlgc_undersampled", + backend="rlgc", scan_upsample_factor=int(decon_scan_upsample), ) processing_configuration["reconstruction"] = { @@ -1650,7 +1878,9 @@ def process_skewed( "skewed_bounds_sx", "skewed_shape_syx", "deskew_crop_yx", + "reconstruction_shape_syx", ) + if key in plan } for plan in roi_plans ) @@ -1681,6 +1911,8 @@ def process_skewed( roi_output_shapes, deskewed_voxel_size_um, spatial_origins_zyx_um=[plan["origin_zyx_um"] for plan in roi_plans], + time_interval_s=acquisition.volume_interval_s, + attributes=acquisition.timing_attributes(), channels=channels, dtype=output_dtype, overwrite=True, @@ -1696,11 +1928,18 @@ def process_skewed( output_path, output_shape, deskewed_voxel_size_um, + time_interval_s=acquisition.volume_interval_s, + attributes=acquisition.timing_attributes(), stage_positions=stage_positions[:pos_shape], + spatial_offset_um=(0, crop_y * pixel_size_um, 0), channels=channels, dtype=output_dtype, overwrite=True, ) + if output_collection.time_interval_s != acquisition.volume_interval_s: + raise ValueError( + "Existing output time scale disagrees with hardware volume cadence" + ) ts_store = output_collection.arrays if max_projection: @@ -1730,7 +1969,7 @@ def process_skewed( max_z_voxel_size_um = deskewed_voxel_size_um max_z_offset_um = ( round_spatial(0.5 * (datastore_shape[3] - 1) * max_z_voxel_size_um[0]), - 0.0, + round_spatial(crop_y * pixel_size_um), 0.0, ) max_z_output_shape = tuple(int(value) for value in max_z_datastore_shape) @@ -1750,6 +1989,8 @@ def process_skewed( max_z_output_path, max_z_output_shape, max_z_voxel_size_um, + time_interval_s=acquisition.volume_interval_s, + attributes=acquisition.timing_attributes(), stage_positions=stage_positions[:pos_shape], channels=channels, dtype=output_dtype, @@ -1757,6 +1998,10 @@ def process_skewed( spatial_offset_um=max_z_offset_um, overwrite=True, ) + if max_z_collection.time_interval_s != acquisition.volume_interval_s: + raise ValueError( + "Existing projection time scale disagrees with hardware volume cadence" + ) max_z_ts_store = max_z_collection.arrays if flatfield_correction: @@ -1770,7 +2015,7 @@ def process_skewed( camera_conversion, pixel_size_um, stage_positions_raw, - apply_stage_scan_gain=apply_qi2lab_stage_scan_gain, + detector_gain_x=detector_gain_x, signal_threshold=skip_empty_below, minimum_signal_fraction=skip_empty_min_signal_fraction, signal_mask=signal_mask, @@ -1778,7 +2023,7 @@ def process_skewed( else: flatfields = np.ones( ( - int(stage_z_indices.max()) + 1, + 1, datastore.shape[2], datastore.shape[-2], datastore.shape[-1], @@ -1798,6 +2043,9 @@ def process_skewed( resume=state_resume, output_preexisting=output_preexisting, ) + ptv_export_enabled = acquisition.volume_interval_s is not None + if ptv_export_enabled: + publish_processing_state_ptv(output_path, processing_state) if max_projection: if state_resume and max_z_output_preexisting: try: @@ -1962,7 +2210,7 @@ def process_skewed( continue if skewed_roi is None: raw_selection = datastore[t_idx, pos_idx, chan_idx, :] - illumination = flatfields[stage_z_indices[pos_idx], chan_idx, :] + illumination = flatfields[0, chan_idx, :] detector_x_offset = 0 else: useful_start = int(skewed_roi.scan_start) @@ -1986,7 +2234,7 @@ def process_skewed( skewed_roi.x_start : skewed_roi.x_stop, ] illumination = flatfields[ - stage_z_indices[pos_idx], + 0, chan_idx, :, skewed_roi.x_start : skewed_roi.x_stop, @@ -2006,7 +2254,7 @@ def process_skewed( camera_offset, camera_conversion, detector_x_offset=detector_x_offset, - apply_stage_scan_gain=apply_qi2lab_stage_scan_gain, + detector_gain_x=None if deconvolve else detector_gain_x, ) if skewed_roi is not None and scan_axis_reversed: camera_calibrated_data = np.flip(camera_calibrated_data, axis=0) @@ -2045,10 +2293,19 @@ def process_skewed( ) ) continue - camera_corrected_data = illumination_correct( - camera_calibrated_data, - illumination, - ) + if deconvolve: + camera_corrected_data = camera_calibrated_data + decon_response = np.asarray(illumination, dtype=np.float32) + if detector_gain_x is not None: + gain = detector_gain_x[ + detector_x_offset : detector_x_offset + raw_data.shape[-1] + ] + decon_response = decon_response * gain + else: + camera_corrected_data = illumination_correct( + camera_calibrated_data, + illumination, + ) if skewed_roi is None and scan_axis_reversed: camera_corrected_data = np.flip(camera_corrected_data, axis=0) @@ -2063,64 +2320,44 @@ def process_skewed( decon_input = camera_corrected_data[ excess_scan_positions:, :, : ] - if decon_scan_upsample is not None: - deconvolved_data = rlgc_undersampled( - decon_input, - np.asarray(psfs[chan_idx]), - scan_upsample_factor=decon_scan_upsample, - gpu_id=decon_gpu_id, - ) - else: - effective_crop_scan = decon_chunk_state.determine_once( - tuple(int(size) for size in decon_input.shape), - [tuple(int(size) for size in psf.shape) for psf in psfs], - gpu_id=decon_gpu_id, - ) - deconvolved_data = chunked_rlgc( - decon_input, - np.asarray(psfs[chan_idx]), - crop_scan=effective_crop_scan, - gpu_id=decon_gpu_id, - verbose=decon_verbose, - fallback_step_scan=decon_fallback_step_scan, - on_successful_crop_scan=( - decon_chunk_state.remember_successful_crop - ), - ) - deskewed = orthogonal_deskew( - deconvolved_data, - theta=opm_tilt_deg, - distance=reconstruction_step_um, - pixel_size=pixel_size_um, - downsample_factor=z_downsample_level, + factor = decon_scan_upsample + fine_shape = ( + (decon_input.shape[0] - 1) * factor + 1, + *decon_input.shape[1:], + ) + effective_crop_scan = decon_chunk_state.determine_once( + fine_shape, + [tuple(int(size) for size in psf.shape) for psf in psfs], + gpu_id=decon_gpu_id, + ) + deconvolved_data = rlgc( + decon_input, + np.asarray(psfs[chan_idx]), + crop_scan=effective_crop_scan, + gpu_id=decon_gpu_id, + verbose=decon_verbose, + fallback_step_scan=decon_fallback_step_scan, + on_successful_crop_scan=partial( + setattr, decon_chunk_state, "crop_scan" + ), + detector_response=decon_response, + read_noise=decon_read_noise, + scan_upsample_factor=factor, + boundary="reflect" if decon_scan_upsample == 1 else "zero", ) + deskew_input = deconvolved_data + elif skewed_roi is not None: + deskew_input = camera_corrected_data else: - if skewed_roi is not None: - deskewed = orthogonal_deskew( - camera_corrected_data, - theta=opm_tilt_deg, - distance=scan_axis_step_um, - pixel_size=pixel_size_um, - downsample_factor=z_downsample_level, - ) - elif flyback_crop is not None: - deskewed = orthogonal_deskew( - camera_corrected_data[ - excess_scan_positions:-flyback_crop, :, : - ], - theta=opm_tilt_deg, - distance=scan_axis_step_um, - pixel_size=pixel_size_um, - downsample_factor=z_downsample_level, - ) - else: - deskewed = orthogonal_deskew( - camera_corrected_data[excess_scan_positions:, :, :], - theta=opm_tilt_deg, - distance=scan_axis_step_um, - pixel_size=pixel_size_um, - downsample_factor=z_downsample_level, - ) + stop = -flyback_crop if flyback_crop is not None else None + deskew_input = camera_corrected_data[excess_scan_positions:stop] + deskewed = orthogonal_deskew( + deskew_input, + theta=opm_tilt_deg, + distance=reconstruction_step_um, + pixel_size=pixel_size_um, + downsample_factor=z_downsample_level, + ) if roi_plan is not None: crop_y0, crop_y1, crop_x0, crop_x1 = ( @@ -2132,7 +2369,10 @@ def process_skewed( crop_x0:crop_x1, ] elif crop_after_deskew: - deskewed = deskewed[:, crop_y:-crop_y, :] + cropped_ny = deskewed_shape[1] - _pad_y + deskewed = deskewed[:, crop_y : crop_y + cropped_ny, :] + if _pad_y: + deskewed = np.pad(deskewed, ((0, 0), (0, _pad_y), (0, 0))) if max_projection: max_z_deskewed = np.max(deskewed, axis=0, keepdims=True) @@ -2172,7 +2412,7 @@ def process_skewed( if max_projection: for ts_max_write in ts_max_writes: ts_max_write.result() - tile_zero_channels = ( + tile_zero_channels = tuple( channel_index for time_index, position_index, channel_index in zero_channels if time_index == int(t_idx) and position_index == int(pos_idx) @@ -2183,6 +2423,13 @@ def process_skewed( pos_idx, zero_channels=tile_zero_channels, ) + if ptv_export_enabled: + mark_ptv_tile( + output_path, + int(t_idx), + int(pos_idx), + zero_channels=tile_zero_channels, + ) if max_projection: processing_state.complete_tile(max_z_output_path, t_idx, pos_idx) @@ -2223,6 +2470,8 @@ def process_skewed( dtype=np.float64, ), spatial_offset_z_um=float(max_z_collection.spatial_origins_zyx_um[0][0]), + source_collection=max_z_output_path, + time_interval_s=max_z_collection.time_interval_s, reverse_stage_z=not stage_z_flipped, opm_angle_deg=opm_tilt_deg, ) @@ -2315,7 +2564,7 @@ def process_projection( Start-inclusive, stop-exclusive position selection; None processes all positions. eager_deconvolution : bool - Use eager stopping in the planar gradient-consensus solver. + Legacy compatibility option; reference KL stopping requires both halves. resume : bool Reuse compatible output checkpoints; False overwrites this output run. decon_gpu_id : int @@ -2328,15 +2577,22 @@ def process_projection( output_dir : Path or None Directory for processed images and state; None uses the source directory. + Returns ------- None Processed planar tiles, stage-placed mosaics, and checkpoints are written to disk. """ + acquisition.validate_processing_calibration() + decon_read_noise = read_noise_from_metadata(acquisition) if deconvolve else None overwrite = not resume if deconvolve: - from opm_processing.imageprocessing.rlgc import rlgc_2d + from opm_processing.imageprocessing.rlgc import ( + GC_RELATIVE_CHANGE_LIMIT, + GC_UPDATED_FRACTION_LIMIT, + rlgc, + ) skip_empty_below, skip_empty_min_signal_fraction = validate_empty_tile_options( skip_empty_below, @@ -2356,6 +2612,7 @@ def process_projection( opm_tilt_deg = acquisition.angle_deg camera_offset = acquisition.camera_offset camera_conversion = acquisition.camera_conversion + detector_gain_x = detector_gain_from_metadata(acquisition, int(datastore.shape[-1])) channels = list(acquisition.channel_names) stage_positions_raw = np.asarray(acquisition.stage_positions_zxy, dtype=float) stage_axis_flips = acquisition.stage_axis_flips_xyz @@ -2366,29 +2623,9 @@ def process_projection( output_dtype = np.dtype(np.float32 if save_float32 else np.uint16) psfs: list[np.ndarray] | None = None if deconvolve: - if decon_psf_paths is not None: - if len(decon_psf_paths) != len(channels): - raise ValueError("decon_psf_paths must contain one PSF per channel") - psfs = [ - ( - np.load(path) - if Path(path).suffix == ".npy" - else np.asarray(imread(path)) - ) - for path in tqdm(decon_psf_paths, desc="PSFs", unit="PSF") - ] - else: - psfs = [ - generate_proj_psf( - em_wvl=max( - float(wavelength.strip().lower().removesuffix("nm")) - for wavelength in channel.split("+") - ) - / 1000, - pixel_size_um=pixel_size_um, - ) - for channel in tqdm(channels, desc="PSFs", unit="PSF") - ] + psfs, psf_parameters = load_deconvolution_psfs( + acquisition, decon_psf_paths, projection=True + ) stage_positions = apply_stage_axis_flips(stage_positions_raw, stage_axis_flips) stage_z_indices = stage_z_level_indices(stage_positions_raw) stage_z_flipped = stage_axis_flips[2] @@ -2431,13 +2668,17 @@ def process_projection( camera_conversion, skip_empty_below, skip_empty_min_signal_fraction, - apply_stage_scan_gain=False, + detector_gain_x=detector_gain_x, ) ) voxel_size_um = (1.0, pixel_size_um, pixel_size_um) processing_configuration = { "kind": output_kind, + "timing": acquisition.timing_attributes(), "dtype": output_dtype.name, + "detector_gain_x": None + if detector_gain_x is None + else detector_gain_x.tolist(), "flatfield": bool(flatfield_correction), "selection": {"time": time_range, "position": pos_range}, "empty": ( @@ -2449,8 +2690,40 @@ def process_projection( None if not deconvolve else { - "model_revision": "opm-physical-rlgc-20261008", + "model_revision": "gradient-consensus-v12-recorded-calibration", + "observation_model": "poisson", + "gc_stopping": { + "reference": "gc_deconv.m@0340af0d7a4c5d92a97a1b443dfedb367dfb9f3d", + "kl_enabled": True, + "kl_domain": "acquired-pixel-prediction", + "kl_direction": "prediction-to-split", + "kl_comparison": "preceding-iteration-fresh-halves", + "kl_require_both": True, + "consensus_product": "relative-directions-before-blur", + "split": "matlab-do_binosplit", + "read_noise": decon_read_noise, + "read_noise_units": "calibrated-detector-counts-rms", + "read_noise_reference": "gc_deconv.m", + "accepted_fraction_limit": GC_UPDATED_FRACTION_LIMIT, + "relative_change_limit": GC_RELATIVE_CHANGE_LIMIT, + "peak_floor": 1e-12, + }, + "observations": "calibrated-detector-counts", + "detector_response": "forward-adjoint-sensitivity", "eager": bool(eager_deconvolution), + "psf_parameters": None + if decon_psf_paths is not None + else psf_parameters, + **( + {} + if decon_psf_paths is not None + else { + "psf_sampling": "oblique-camera-pixel-integral-centered-taper-v3", + } + ), + "emission_wavelengths_nm": (acquisition.deconvolution or {}).get( + "emission_wavelengths_nm" + ), "psf_sha256": ( None if decon_psf_paths is None @@ -2470,11 +2743,17 @@ def process_projection( output_path, datastore.shape, voxel_size_um, + time_interval_s=acquisition.volume_interval_s, + attributes=acquisition.timing_attributes(), stage_positions=stage_positions, channels=channels, overwrite=True, dtype=output_dtype, ) + if output_collection.time_interval_s != acquisition.volume_interval_s: + raise ValueError( + "Existing output time scale disagrees with hardware volume cadence" + ) ts_store = output_collection.arrays if flatfield_correction: @@ -2485,7 +2764,7 @@ def process_projection( camera_conversion, pixel_size_um, stage_positions_raw, - apply_stage_scan_gain=False, + detector_gain_x=detector_gain_x, signal_threshold=skip_empty_below, minimum_signal_fraction=skip_empty_min_signal_fraction, signal_mask=signal_mask, @@ -2493,7 +2772,7 @@ def process_projection( else: flatfields = np.ones( ( - int(stage_z_indices.max()) + 1, + 1, datastore.shape[2], datastore.shape[-2], datastore.shape[-1], @@ -2559,6 +2838,7 @@ def process_projection( raw_data, camera_offset, camera_conversion, + detector_gain_x=None if deconvolve else detector_gain_x, ) if tile_is_empty( @@ -2575,21 +2855,22 @@ def process_projection( ts_store[pos_idx][t_idx, chan_idx].write(output_dtype.type(0)) ) continue - camera_corrected_data = illumination_correct( - camera_calibrated_data, - flatfields[stage_z_indices[pos_idx], chan_idx, :], - ) - if deconvolve: - deconvolved_data = rlgc_2d( - image=camera_corrected_data, - skewed_psf=np.asarray(psfs[chan_idx]), + deconvolved_data = rlgc( + image=camera_calibrated_data, + psf=np.asarray(psfs[chan_idx]), gpu_id=decon_gpu_id, verbose=decon_verbose, safe_mode=not eager_deconvolution, + detector_response=flatfields[0, chan_idx, :] + * (1 if detector_gain_x is None else detector_gain_x), + read_noise=decon_read_noise, ) else: - deconvolved_data = camera_corrected_data + deconvolved_data = illumination_correct( + camera_calibrated_data, + flatfields[0, chan_idx, :], + ) formatted = format_processed_output( deconvolved_data, @@ -2628,6 +2909,7 @@ def process_projection( pixel_size=np.asarray((pixel_size_um, pixel_size_um), dtype=np.float32), time_range=time_range, reverse_stage_z=not stage_z_flipped, + time_interval_s=output_collection.time_interval_s, opm_angle_deg=opm_tilt_deg, ) tile_fusion.run() @@ -2688,7 +2970,7 @@ def run_estimate_illuminations( camera_offset: float, camera_conversion: float, stage_positions_zxy: np.ndarray | None, - apply_stage_scan_gain: bool, + detector_gain_x: np.ndarray | None, signal_mask: np.ndarray | None, conn: Connection, ) -> None: @@ -2704,8 +2986,8 @@ def run_estimate_illuminations( Calibrated intensity units per ADU after baseline subtraction. stage_positions_zxy : numpy.ndarray Physical stage Z, scan-axis X, and lateral Y coordinates in micrometers. - apply_stage_scan_gain : bool - Apply the fixed qi2lab detector-X gain during camera calibration. + detector_gain_x : numpy.ndarray or None + Apply the recorded detector-X response during camera calibration. signal_mask : numpy.ndarray or None TPC mask selecting nonempty estimation tiles; None allows automatic selection. @@ -2736,7 +3018,7 @@ def run_estimate_illuminations( camera_offset, camera_conversion, stage_positions_zxy, - apply_stage_scan_gain=apply_stage_scan_gain, + detector_gain_x=detector_gain_x, signal_mask=signal_mask, ) conn.send(flatfields) @@ -2752,7 +3034,7 @@ def call_estimate_illuminations( camera_offset: float, camera_conversion: float, stage_positions_zxy: np.ndarray | None, - apply_stage_scan_gain: bool, + detector_gain_x: np.ndarray | None, signal_mask: np.ndarray | None, ) -> np.ndarray: """Call ``estimate_illuminations`` in an isolated subprocess. @@ -2767,8 +3049,8 @@ def call_estimate_illuminations( Calibrated intensity units per ADU after baseline subtraction. stage_positions_zxy : numpy.ndarray Physical stage Z, scan-axis X, and lateral Y coordinates in micrometers. - apply_stage_scan_gain : bool - Apply the fixed qi2lab detector-X gain during camera calibration. + detector_gain_x : numpy.ndarray or None + Apply the recorded detector-X response during camera calibration. signal_mask : numpy.ndarray or None TPC mask selecting nonempty estimation tiles; None allows automatic selection. @@ -2776,7 +3058,7 @@ def call_estimate_illuminations( Returns ------- numpy.ndarray - Estimated depth-CYX fields after the isolated worker exits successfully. + Shared per-channel fields after the isolated worker exits successfully. Notes ----- @@ -2794,7 +3076,7 @@ def call_estimate_illuminations( camera_offset, camera_conversion, stage_positions_zxy, - apply_stage_scan_gain, + detector_gain_x, signal_mask, child_conn, ), @@ -2803,6 +3085,9 @@ def call_estimate_illuminations( child_conn.close() try: try: + ready = wait_for_worker((parent_conn, p.sentinel)) + if parent_conn not in ready: + raise EOFError("Worker exited before returning a result") result = parent_conn.recv() except EOFError as error: p.join() diff --git a/src/opm_processing/process_roi.py b/src/opm_processing/process_roi.py index 785d4b2..a1bde83 100644 --- a/src/opm_processing/process_roi.py +++ b/src/opm_processing/process_roi.py @@ -2,11 +2,9 @@ from __future__ import annotations +import math from dataclasses import replace from pathlib import Path -from typing import Annotated - -import typer from opm_processing.dataio.acquisition import ( acquisition_stem, @@ -23,33 +21,11 @@ from opm_processing.imageprocessing.tilefusion import TileFusion from opm_processing.process import process_skewed -app = typer.Typer(pretty_exceptions_enable=False) - -@app.command() def process_roi( - root_path: Annotated[ - Path, - typer.Argument( - help=( - "Raw acquisition directory, OME-Zarr path, or processed output " - "directory containing one .processing.json." - ) - ), - ], - roi_json: Annotated[ - Path | None, - typer.Argument( - help=( - "ROI JSON written by display; defaults to " - "_roi.json in the input directory." - ) - ), - ] = None, - deconvolve: Annotated[ - bool, - typer.Option("--deconvolve/--no-deconvolve"), - ] = True, + root_path: Path, + roi_json: Path, + deconvolve: bool = False, flatfield_correction: bool = False, save_float32: bool = False, z_downsample_level: int = 2, @@ -59,35 +35,17 @@ def process_roi( decon_verbose: int = 1, decon_psf_paths: list[Path] | None = None, registration_channel: int = 0, - resume: Annotated[ - bool, - typer.Option( - "--resume/--no-resume", - help=( - "Resume from completed tiles/channels by default. Use " - "--no-resume to overwrite the processed ROI and start again." - ), - ), - ] = True, - output: Annotated[ - Path | None, - typer.Option( - "--output", - help=( - "Output directory; defaults to _roi in the input " - "directory (beside the source when a Zarr path is supplied)." - ), - ), - ] = None, -) -> None: + resume: bool = True, + output: Path | None = None, +) -> Path: """Process a napari-selected world-space ROI and fuse its cropped tiles. Parameters ---------- root_path : pathlib.Path Raw acquisition path or directory containing its processing state. - roi_json : pathlib.Path or None - ROI exported by display; None uses the input directory's default ROI. + roi_json : pathlib.Path + Saved physical ROI selection. deconvolve : bool Deconvolve the selected raw regions before deskewing. flatfield_correction : bool @@ -113,11 +71,11 @@ def process_roi( output : pathlib.Path or None Output directory; None uses _roi beside the input. + Returns ------- - None - Cropped tiles and checkpoints are saved, with fused outputs when the ROI - spans multiple positions. + pathlib.Path + Final TCZYX OME-Zarr image; cropped tiles and checkpoints are retained. """ requested_path = Path(root_path).expanduser().resolve() state_paths = tuple(requested_path.glob("*.processing.json")) @@ -135,12 +93,12 @@ def process_roi( context_dir = acquisition.path.parent if acquisition.is_2d: raise ValueError( - "process-ROI currently targets skewed 3D acquisitions; use process " + "ROI processing currently targets skewed 3D acquisitions; use process " "for native 2D data" ) if crop_after_deskew: raise ValueError( - "process-ROI already writes exact physical ROI crops; " + "ROI processing already writes exact physical ROI crops; " "--crop-after-deskew is not applicable" ) stem = acquisition_stem(acquisition.path) @@ -157,6 +115,43 @@ def process_roi( ) output_dir.mkdir(parents=True, exist_ok=True) + # Preserve geometric overlap outside the requested rectangle for local + # registration. Crop to the exact requested bounds only during final fusion. + margins = [0.0, 0.0] + footprints = roi.tile_footprints + for index, left in enumerate(footprints): + for right in footprints[index + 1 :]: + if ( + left["time_index"] != right["time_index"] + or left["position_index"] == right["position_index"] + ): + continue + a, b = left["bounds_yx_um"], right["bounds_yx_um"] + overlap = [ + min(a[1], b[1]) - max(a[0], b[0]), + min(a[3], b[3]) - max(a[2], b[2]), + ] + if min(overlap) <= 0: + continue + # Depth-neighbor alignment can move the common oblique-plane + # footprint along Y by dz*cot(theta); retain that image context. + depth = abs(left["origin_zyx_um"][0] - right["origin_zyx_um"][0]) + margins[0] = max( + margins[0], depth / math.tan(math.radians(acquisition.angle_deg)) + ) + for axis in range(2): + if left["origin_zyx_um"][axis + 1] != right["origin_zyx_um"][axis + 1]: + margins[axis] = max(margins[axis], overlap[axis]) + y0, y1, x0, x1 = roi.bounds_yx_um + processing_roi = replace( + roi, + bounds_yx_um=( + y0 - margins[0], + y1 + margins[0], + x0 - margins[1], + x1 + margins[1], + ), + ) process_skewed( root_path=acquisition.path, acquisition=acquisition, @@ -172,7 +167,7 @@ def process_roi( decon_gpu_id=decon_gpu_id, decon_verbose=decon_verbose, decon_psf_paths=decon_psf_paths, - roi_selection=roi, + roi_selection=processing_roi, resume=resume, ) @@ -183,37 +178,19 @@ def process_roi( selected_positions = tuple( dict.fromkeys(int(record["position_index"]) for record in tile_records) ) - if len(selected_positions) <= 1: - print( - "ROI processing complete; fusion skipped because the ROI intersects " - "one position." - ) - return - - print(f"Fusing {len(tile_records)} cropped ROI tiles...") + # The shared fusion writer also assembles independently stored ROI timepoints + # into TCZYX for one position; there are no pair measurements in that case. fusion_roi = replace(roi, position_indices=selected_positions) fusion = TileFusion( root_path=processed_path, channel_to_use=registration_channel, roi_selection=fusion_roi, + multiscale_factors=(), + resolution_multiples=((1, 1, 1),), ) fusion.run() fused_path = output_dir / f"{stem}_fused.ome.zarr" max_z_path = output_dir / f"{stem}_max_z_fused.ome.zarr" regenerate_fused_max_projection(fused_path, max_z_path) print(f"ROI processing and fusion complete: {output_dir}") - - -def main() -> None: - """Run the ROI-processing command-line application. - - Returns - ------- - None - The Typer application parses arguments and processes the requested ROI. - """ - app() - - -if __name__ == "__main__": - main() + return fused_path diff --git a/tests/CONTRIBUTING.md b/tests/CONTRIBUTING.md index 6643449..01fa987 100644 --- a/tests/CONTRIBUTING.md +++ b/tests/CONTRIBUTING.md @@ -1,67 +1,71 @@ -# Test review rules +# Test conventions -Every test must have exactly one `unit` or `integration` marker. Put CPU tests -in `tests/cpu/` and CUDA/NVENC tests in `tests/gpu/`; collection adds the matching -hardware marker. Hardware requirements do not replace either category. +Only unit and integration tests are permitted. Do not add regression checks, +snapshot tests, or saved outputs from previous runs as expected results. This +rule applies to test behavior as well as names and markers. -- Unit tests check an isolated numerical operator, geometry calculation, or - state transition against independent expected values. Storage-component - tests may use temporary files but remain unit tests. -- Unit tests use the shared TensorStore dataset mocks, including sliced reads, - writes, dtype, chunks and completed futures. Real TensorStore datasets remain - required for integration tests. Do not mock processing algorithms or replace - pixel assertions with mock call counts. -- Integration tests define a simulated object with known intensity or physical - geometry, write its input data to disk, execute the workflow with real image - reads and writes, reopen its output, and compare the saved data with truth. - File existence, mock calls, and successful return codes are insufficient. -- Simulated brightness differences between acquisition depths must use - Beer–Lambert attenuation with an explicit optical path and known attenuation - coefficients in the forward model. Derive expected correction gains from - those coefficients independently of the fitted result. -- Smoke tests, API/argument-forwarding tests, regression tests, and benchmark-only tests are not - permitted. A CLI may launch a disk-to-disk integration workflow, but the - assertions must verify resulting data rather than its interface. -- Keep acquisition formats, camera calibration, optical objects, storage and - workflow settings in the corresponding `tests/fixtures/` modules. Reuse - factory fixtures for differing shapes, channels and scan modes. Do not import - helpers from `conftest.py` or another test module. -- Keep independent forward models and measurements in `tests/reference/`. - Expected values must describe known physics or state transitions, rather than - snapshots of production output or historical bugs. -- Integration tests run real metadata inspection, reconstruction, registration, - fusion and storage for the workflow being tested. Supply simulated PSFs on - disk instead of replacing PSF generation or solvers. Fault injection may - interrupt genuine writes or checkpoints to test recovery; it must not replace - successful image calculations. -- Use function scope for mutable datasets and journals. Cache expensive optical - fixtures at session scope when their inputs remain immutable. Seed each - simulated noise realization explicitly, and parameterize independent factors - separately. Small analytical examples and acceptance bounds belong beside - their assertions; fixtures own repeated setup and configuration. +Classify every case as either unit or integration. Unit tests isolate numerical +operators or decisions; integration tests check interacting processing and storage +components. Put CPU cases in `tests/cpu` and CUDA cases in `tests/gpu`; collection +supplies the hardware marker. Do not add smoke, benchmark or argument-forwarding +cases under another label. -Collection checks enforce the categories and require temporary disk storage -for integration tests. Review must also verify simulated ground truth and the -complete read/process/write path; fixtures alone cannot prove those properties. +Use shared fixtures in `tests/fixtures` for setup, input file structures, +acquisition metadata, optical objects, calibration and real package-backed datasets. +Keep mutable data function-scoped; cache expensive immutable optical models at +session scope. Parameterize independent settings rather than copying +tests. Do not import helpers from another test module or `conftest.py`. +Integration tests must consume shared forward-model ground truth, then process +the simulated observations through production code. CLI integrations invoke the +registered applications and their actual parsers; numerical processing, storage, +and encoders must execute. Collection requires a shared simulation fixture. +Reuse an existing builder before adding a fixture. Avoid tiny wrappers that only +rename a call. Use NumPy-style docstrings; Ruff checks test functions and fixtures +as well as library code. -Use the deconvolution reference implementation: -the local `expansion-processing/src/expansion_processing/rlgc.py`, pinned in -[the processing methods](https://github.com/qi2lab/opm-processing-v2/blob/perf/numba-deskew-corrections/docs/methods/deconvolution.md). -Keep the RLGC multiplicative update and gradient-consensus rule numerically -consistent with that reference, accounting for documented OPM adaptations. -Changes must pass the same physical validation for fully sampled -and sub-sampled acquisitions; seeded pixel identity is required only for changes -intended to preserve arithmetic and random draws. -Use identical prescribed splits to check arithmetic and stopping independently; -also check recovery of the known ground object, absolute fluorescence, physical -localization and two-point profiles at known separations. Measure peak positions, -separation and valley depth in laboratory units. Establish resolution tolerances -from the pinned reference and stated measurement criterion. -Account explicitly for known camera and deskew intensity conventions and voxel -volumes; fitting a gain to the reconstructed result is not physical validation. -Normalized KLD is a stopping statistic, not an absolute Poisson likelihood. -Do not weaken tolerances or replace the optical truth to obtain a passing -optimization. Report reference/physics differences separately. Lookup sampling -and PSF tapering require distribution, object-recovery and two-point validation -before production acceptance. Measure runtime outside pytest; performance -scripts belong in scripts/ and generated reports in ignored diagnostics/. +Use library entry points and installed packages. Persist OME-Zarr fixtures with +yaozarrs and its TensorStore writer; use real TensorStore arrays for in-memory +numerical inputs. Native storage uses pytest temporary directories, since Python +filesystem mocks cannot intercept TensorStore's native file I/O. Do not replace +package APIs with custom arrays, futures, codecs, storage adapters or optimizers. +Read image/group metadata and open on-disk arrays through yaozarrs. TensorStore +provides native block writes. Zarr-python is permitted for attribute edits and +through napari-ome-zarr for viewing; pixel processing and storage must use +yaozarrs or TensorStore. Reuse the production metadata updater when constructing +opm-v2-shaped acquisition fixtures; tests must not require an experimental +dataset or an external data drive. +Observers must execute the real operation. Fault injection belongs only in +explicit error-handling tests; every successful numerical path must execute the +real implementation. Integration tests must retain production storage and worker +processes. Assert pixels, coordinates or state transitions against independent +expectations. Keep performance measurements outside pytest. + +Simulation and filesystem mocking are the only test-specific implementations. +Every numerical test must use a known analytical object or a known blurred +ground-truth scene. Decision and metadata units use explicitly prescribed state, +dimensions and physical units. Do not fabricate package results, futures or +warnings; exercise actual installed-package behavior. A tiled/untiled comparison +alone cannot establish accuracy. Verify numerical operators against analytical +truth or installed independent operators, and reconstruction against the known +camera means. Scenes must cross multiple actual tile borders, including partial +last tiles, with foreground on both sides of internal borders. +Use production algorithms and installed packages for processing and numerical +assertions; do not maintain alternative deskew, PSF or reconstruction algorithms. +The single camera forward model belongs in `tests/simulation`. Simulation and +deconvolution share the production optical PSF and pixel integration. Render fine +objects, multiply excitation before emission blur, and digitize Poisson +photoelectrons plus calibrated read noise into unsigned ADC codes. Use declared +exposures and seeded noise, with no fitted reconstruction gain. + +Reconstruction and registration accuracy use absolute photon/camera likelihood, +with independent camera realizations and fixed physical source/background +regions. Likelihood is an accuracy metric, not a held-out stopping rule. +Resolution uses direction-specific PSF first minima (Rayleigh). Numerical +operator tolerances must follow precision, sampling or a stated error bound. +Geometry and photon-volume checks use the supported 30-degree OPM convention. +Do not weaken physics assertions, substitute production output for truth, or +hide an accuracy failure with skip/xfail. Only unavailable hardware may skip. + +Local investigation notes and generated reports belong in ignored `.tmp/` or +`diagnostics/`, not published documentation. Keep commands and general contributor +instructions concise; report the validation performed with the change itself. diff --git a/tests/README.md b/tests/README.md index 23ac694..95d880f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,12 +1,21 @@ # Running tests -See the [testing guide](../docs/development/testing.md) for environment setup, -CPU and CUDA commands, and simulated disk-to-disk examples. -[Test review rules](CONTRIBUTING.md) define permitted test categories and physics -acceptance requirements; the documentation site includes that same file. +```powershell +uv run --no-sync python -m pytest tests/cpu --strict-markers -q +uv run --no-sync python -m pytest tests/gpu --strict-markers -q +``` -From an installed development environment: +Use the existing development environment. CUDA is required for GPU cases. +See [contribution rules](CONTRIBUTING.md) and the +[testing guide](../docs/development/testing.md). + +Simulation setup and the single forward model live in `tests/simulation`. +Simulation and deconvolution share production PSF generation and pixel integration. +Shared configurations live in `tests/fixtures/configs`. +To render the saved synthetic comparison outputs without reconstructing: ```powershell -uv run --no-sync python -m pytest tests/cpu --strict-markers -q +uv run --no-sync python -m tests.deconvolution_figures --config tests/fixtures/configs/deconvolution_figures.json --output diagnostics/rlgc_final --render-only ``` + +Omit `--render-only` to generate a new comparison series in a new output directory. diff --git a/tests/conftest.py b/tests/conftest.py index a74f5d3..7ece67a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,6 @@ pytest_plugins = ( "tests.fixtures.hardware", - "tests.fixtures.tensorstore", "tests.fixtures.acquisition", "tests.fixtures.tiling", "tests.fixtures.roi", @@ -18,22 +17,24 @@ "tests.fixtures.state", "tests.fixtures.camera", "tests.fixtures.illumination", + "tests.fixtures.pipeline", + "tests.fixtures.cli", ) def pytest_collection_modifyitems(items): - """Require unit or disk-based integration tests during collection. + """Require unit or integration tests and consistent execution hardware. Parameters ---------- items : list of pytest.Item - Collected correctness tests to validate. + Collected tests to validate. Raises ------ pytest.UsageError - If categories or hardware placement are inconsistent, a forbidden - category is used, or an integration test has no temporary disk storage. + If a test lacks an allowed classification, uses a forbidden category, or + requires CUDA from the CPU suite. """ for item in items: execution = item.path.relative_to(Path(__file__).parent).parts[0] @@ -54,20 +55,20 @@ def pytest_collection_modifyitems(items): for name in ("smoke", "api", "regression", "benchmark") ): raise pytest.UsageError( - f"{item.nodeid}: smoke, API, regression, and benchmark tests are not permitted" + f"{item.nodeid}: only unit and integration tests are permitted" ) categories = [ name for name in ("unit", "integration") if item.get_closest_marker(name) ] if len(categories) != 1: raise pytest.UsageError( - f"{item.nodeid} must have exactly one of unit or integration" + f"{item.nodeid}: select exactly one of unit or integration" ) if categories == ["integration"] and not { - "tmp_path", - "tmp_path_factory", + "simulated_camera", + "pipeline_optical_data", + "independent_photon_scene_factory", }.intersection(item.fixturenames): raise pytest.UsageError( - f"{item.nodeid}: integration tests require simulated input and " - "verified output on disk" + f"{item.nodeid}: integration tests require shared forward-model ground truth" ) diff --git a/tests/cpu/__init__.py b/tests/cpu/__init__.py index 25d19b7..6c65bed 100644 --- a/tests/cpu/__init__.py +++ b/tests/cpu/__init__.py @@ -1 +1 @@ -"""CPU unit and disk-to-disk integration tests.""" +"""CPU numerical and storage-boundary unit and integration tests.""" diff --git a/tests/cpu/conftest.py b/tests/cpu/conftest.py index 3c7a723..eb20ed6 100644 --- a/tests/cpu/conftest.py +++ b/tests/cpu/conftest.py @@ -7,6 +7,7 @@ from skimage.measure import block_reduce from skimage.metrics import structural_similarity from skimage.registration import phase_cross_correlation +from skimage.registration._masked_phase_cross_correlation import cross_correlate_masked from opm_processing.imageprocessing import tilefusion @@ -35,6 +36,7 @@ def cpu_registration_backend(monkeypatch): "match_histograms": match_histograms, "block_reduce": block_reduce, "phase_cross_correlation": phase_cross_correlation, + "masked_cross_correlation": cross_correlate_masked, "sobel_filter": ndimage.sobel, "shift_filter": ndimage.shift, "minimum_filter": ndimage.minimum_filter, diff --git a/tests/cpu/test_acquisition_metadata.py b/tests/cpu/test_acquisition_metadata.py index 5e54598..216c54d 100644 --- a/tests/cpu/test_acquisition_metadata.py +++ b/tests/cpu/test_acquisition_metadata.py @@ -1,4 +1,4 @@ -"""Metadata-only coverage for the current opm-v2 OME-Zarr writer layout.""" +"""Acquisition metadata units and native storage/processing integration.""" from __future__ import annotations @@ -7,19 +7,17 @@ import numpy as np import pytest -from tifffile import imread from yaozarrs import open_group, v05 from opm_processing.dataio.acquisition import ( inspect_acquisition, open_acquisition_datastore, ) -from opm_processing.dataio.convert_timelapse_data import convert_timelapse from opm_processing.dataio.position_collection import ( create_position_collection, open_position_collection, + update_group_attributes, ) -from opm_processing.imageprocessing.opmtools import orthogonal_deskew from opm_processing.process import process if TYPE_CHECKING: @@ -27,8 +25,60 @@ @pytest.mark.unit +@pytest.mark.parametrize( + ("attributes", "expected"), + [ + ({"known_values": np.arange(3)}, {"known_values": [0, 1, 2]}), + ( + {"gain": np.float32(0.25), "indices": (1, 2), "selected": {2, 1}}, + {"gain": 0.25, "indices": [1, 2], "selected": [1, 2]}, + ), + (float("nan"), None), + ], +) +def test_native_attribute_updates_preserve_known_pixels_and_ngff( + acquisition_factory, attributes, expected +): + """Update attributes without changing analytical pixel values or NGFF geometry.""" + values = np.arange(3 * 4 * 5, dtype=np.uint16).reshape(1, 1, 1, 3, 4, 5) + dataset = acquisition_factory(values) + original = dict(open_group(dataset.path).attrs) + if expected is not None: + update_group_attributes(dataset.path, {"specimen": attributes}) + assert open_group(dataset.path).attrs["specimen"] == expected + else: + with pytest.raises(ValueError, match="JSON"): + update_group_attributes(dataset.path, {"specimen": attributes}) + root = open_group(dataset.path) + for key, value in original.items(): + assert root.attrs[key] == value + np.testing.assert_array_equal( + open_acquisition_datastore(dataset.path).read().result(), values + ) + + +@pytest.mark.unit +def test_scan_metadata_retains_all_physical_scan_planes( + scan_camera_acquisition, +): + """Retain every physical scan plane after metadata-directed crop and reversal.""" + fixture = scan_camera_acquisition + acquisition = inspect_acquisition(fixture.path) + start = acquisition.excess_scan_start_positions or acquisition.excess_scan_positions + end = acquisition.excess_scan_end_positions + assert fixture.raw_data.shape[-3] - start - end == 16 + raw = open_acquisition_datastore(acquisition)[0, 0, 0].read().result() + ordered = raw[::-1] if acquisition.scan_axis_reversed else raw + useful = ordered[start : ordered.shape[0] - end] + assert useful.shape == (16, 32, 32) + np.testing.assert_array_equal(useful, fixture.expected) + + +@pytest.mark.unit +@pytest.mark.parametrize("downsample", ["stride", "block_mean"]) def test_position_collection_multiscales_round_spatial_metadata( tmp_path: Path, + downsample, ) -> None: """Round NGFF spacing/origins and preserve block-center coordinates.""" path = tmp_path / "rounded_projection.ome.zarr" @@ -39,13 +89,13 @@ def test_position_collection_multiscales_round_spatial_metadata( stage_positions=((1.23456, 2.34567, 3.45678),), spatial_offset_um=(4.56789, 5.67891, 6.78912), multiscale_factors_yx=(2, 4), + multiscale_downsample=downsample, ) assert collection.multiscale_factors_yx == (1, 2, 4) + reduction = np.ceil if downsample == "stride" else np.floor assert [tuple(level[0].shape) for level in collection.multiscale_arrays] == [ - (1, 1, 1, 17, 21), - (1, 1, 1, 9, 11), - (1, 1, 1, 5, 6), + (1, 1, 1, int(reduction(17 / f)), int(reduction(21 / f))) for f in (1, 2, 4) ] reopened = open_group(path) metadata = reopened["0"].ome_metadata() @@ -66,7 +116,18 @@ def test_position_collection_multiscales_round_spatial_metadata( ): assert dataset.scale_transform.scale == scale assert dataset.translation_transform is not None - assert dataset.translation_transform.translation == translation + expected_origin = np.asarray(translation) + if downsample == "block_mean": + factor = scale[-1] / expected_scales[0][-1] + expected_origin[-2:] += ( + 0.5 * (factor - 1) * np.array(expected_scales[0][-2:]) + ) + np.testing.assert_allclose( + dataset.translation_transform.translation, + expected_origin, + rtol=0, + atol=0.0005 + 1e-12, + ) assert "stage_positions" not in reopened.attrs assert "opm_multiscale_downsample" not in reopened.attrs @@ -76,6 +137,7 @@ def test_position_collection_multiscales_round_spatial_metadata( assert reopened_collection.spatial_origins_zyx_um == ((4.568, 5.679, 6.789),) opened_collection = open_position_collection(path) assert opened_collection.multiscale_factors_yx == (1, 2, 4) + assert opened_collection.multiscale_downsample == downsample @pytest.mark.unit @@ -171,82 +233,20 @@ def test_single_position_root_image_opens_as_virtual_tpczyx( @pytest.mark.integration -def test_single_position_mirror_timelapse_processes_every_timepoint( - current_single_position_mirror_timelapse: Path, -) -> None: - """Deskew every timepoint from a one-position root Image acquisition.""" - path = current_single_position_mirror_timelapse - process( - root_path=path, - max_projection=False, - z_downsample_level=1, +@pytest.mark.parametrize("save_float32", [False, True]) +def test_single_position_mirror_timelapse_preserves_calibrated_fluorescence( + calibrated_mirror_timelapse, processing_options, save_float32 +): + """Reopen all timepoints and compare with the analytical 30-degree Jacobian.""" + run = calibrated_mirror_timelapse + process(root_path=run.path, save_float32=save_float32, **processing_options) + output = open_position_collection( + run.path.parent / "single_mirror_deskewed.ome.zarr" ) - - output = open_position_collection(path.parent / "single_mirror_deskewed.ome.zarr") assert output.shape[:3] == (3, 1, 1) - for time_index in range(3): - raw = ( - np.arange(3 * 4 * 5 * 6, dtype=np.uint16).reshape(3, 4, 5, 6)[time_index] - + 200 - ) - expected = orthogonal_deskew( - (raw.astype(np.float32) - 100) * np.float32(0.25), - distance=0.4, - pixel_size=0.115, - downsample_factor=1, - ).astype(np.uint16) - np.testing.assert_array_equal( - output.arrays[0][time_index, 0].read().result(), expected - ) - assert not (path.parent / "single_mirror_max_z_deskewed.ome.zarr").exists() - assert not (path.parent / "single_mirror_max_z_fused.ome.zarr").exists() - - -@pytest.mark.integration -def test_single_position_mirror_timelapse_can_save_float32( - current_single_position_mirror_timelapse: Path, -) -> None: - """Preserve fractional calibrated values for the reported acquisition layout.""" - path = current_single_position_mirror_timelapse - process( - root_path=path, - save_float32=True, - max_projection=False, - create_fused_max_projection=False, - z_downsample_level=1, - ) - - output = open_position_collection(path.parent / "single_mirror_deskewed.ome.zarr") - values = np.asarray(output.arrays[0][0, 0].read().result()) - assert values.dtype == np.float32 - raw = np.arange(4 * 5 * 6, dtype=np.uint16).reshape(4, 5, 6) + 200 - expected = orthogonal_deskew( - (raw.astype(np.float32) - 100) * np.float32(0.25), - distance=0.4, - pixel_size=0.115, - downsample_factor=1, - ) - np.testing.assert_array_equal(values, expected) + dtype = np.float32 if save_float32 else np.uint16 + for time, expected in enumerate(run.expected): + actual = output.arrays[0][time, 0].read().result() + assert actual.dtype == dtype + np.testing.assert_allclose(actual, expected.astype(dtype), rtol=2e-6, atol=1e-5) assert not any(key.startswith("opm_") for key in output.attributes) - - -@pytest.mark.integration -def test_timelapse_converter_accepts_current_collection( - current_opm_v2_stage_scan: Path, -) -> None: - """Converted TIFF pixels must exactly match the requested source data.""" - output_dir = current_opm_v2_stage_scan.parent / "converted" - written = convert_timelapse( - current_opm_v2_stage_scan.parent, - output_dir=output_dir, - time_range=(0, 1), - stage_range=(0, 1), - scan_range=(0, 1), - create_tiff=True, - ) - - assert written == [output_dir / "pos_0_scan_0.tiff"] - converted = imread(written[0]) - yy, xx = np.mgrid[:4, :5] - expected = np.stack((2 * yy + xx, 100 + 2 * yy + xx))[np.newaxis, ...] - np.testing.assert_array_equal(converted, expected) diff --git a/tests/cpu/test_camera.py b/tests/cpu/test_camera.py index 8d989b4..72520c7 100644 --- a/tests/cpu/test_camera.py +++ b/tests/cpu/test_camera.py @@ -2,23 +2,51 @@ import numpy as np import pytest +from scipy.stats import norm from opm_processing.imageprocessing.camera import ( camera_correct, illumination_correct, ) +pytestmark = pytest.mark.unit -@pytest.mark.unit -@pytest.mark.parametrize("conversion", [0.24, 0.24 / 0.9]) -def test_camera_transfer_dark_saturation_and_rounding(camera_codes, conversion): - """Cover dark subtraction, saturation and rounding of every uint16 code. - Parameters - ---------- - conversion : float - Camera conversion per ADU, with or without quantum-efficiency scaling. +@pytest.mark.parametrize("offset", [100, 1000]) +@pytest.mark.parametrize("qe", [1.0, 0.9]) +def test_camera_calibration_transforms_read_noise_units(camera_codes, offset, qe): + """Gain scales noise RMS; subtracting offset does not, including ADC rounding. + + Isolate Gaussian read noise around 96 fixed photoelectrons so clipping and + shot noise cannot conceal a units error. Integrate exact normal ADC-bin + probabilities rather than approximate quantization noise as uniform. """ + electrons_per_adu, sigma_e, signal_e = 0.24, 1.6, 96.0 + sigma_adu = sigma_e / electrons_per_adu + relative_code = ( + camera_codes.astype(np.float64) - offset - signal_e / electrons_per_adu + ) + probability = norm.cdf((relative_code + 0.5) / sigma_adu) - norm.cdf( + (relative_code - 0.5) / sigma_adu + ) + np.testing.assert_allclose(probability.sum(), 1, atol=2e-15, rtol=0) + observed = camera_correct(camera_codes, offset, electrons_per_adu / qe).astype( + np.float64 + ) + mean = np.sum(observed * probability) + variance = np.sum((observed - mean) ** 2 * probability) + adc_variance = np.sum(relative_code**2 * probability) + np.testing.assert_allclose(mean, signal_e / qe, rtol=2 * np.finfo(np.float32).eps) + np.testing.assert_allclose( + variance, + adc_variance * (electrons_per_adu / qe) ** 2, + rtol=32 * np.finfo(np.float32).eps, + ) + + +@pytest.mark.parametrize("conversion", [0.24, 0.24 / 0.9]) +def test_camera_transfer_dark_saturation_and_rounding(camera_codes, conversion): + """Cover dark subtraction, saturation and rounding of every uint16 code.""" raw = camera_codes expected = np.maximum( (raw.astype(np.float32) - np.float32(100.25)) * np.float32(conversion), @@ -31,22 +59,13 @@ def test_camera_transfer_dark_saturation_and_rounding(camera_codes, conversion): assert not np.shares_memory(actual, raw) -@pytest.mark.unit @pytest.mark.parametrize("stage_gain", (False, True)) @pytest.mark.parametrize("strided", (False, True)) -def test_optical_illumination_and_camera_transfer( - camera_optical_sample, stage_gain, strided +def test_detector_response_and_camera_transfer( + detector_response_sample, stage_gain, strided ): - """Restore a diffraction emitter with known illumination and detector gain. - - Parameters - ---------- - stage_gain : bool - Include the measured detector response in acquisition and correction. - strided : bool - Process a noncontiguous scan and detector-Y crop. - """ - case = camera_optical_sample + """Invert known detector response and gain within the ADC rounding interval.""" + case = detector_response_sample original = case.raw.copy() calibrated = camera_correct(case.raw, case.offset, case.conversion, **case.options) reference = calibrated.copy() @@ -69,7 +88,6 @@ def test_optical_illumination_and_camera_transfer( assert not np.shares_memory(corrected, calibrated) -@pytest.mark.unit def test_uniform_illumination_preserves_calibrated_intensities(): """Uniform unit illumination preserves the calibrated photon values.""" photons = np.linspace(0, 1000, 120, dtype=np.float32).reshape(3, 5, 8) @@ -78,16 +96,9 @@ def test_uniform_illumination_preserves_calibrated_intensities(): ) -@pytest.mark.unit @pytest.mark.parametrize("shape", [(5, 8), (2, 3, 5, 8)]) def test_camera_images_and_broadcast_illumination(shape): - """Broadcast known ADC transfer and illumination over leading image axes. - - Parameters - ---------- - shape : tuple of int - Image dimensions, including optional leading acquisition axes. - """ + """Broadcast known ADC transfer and illumination over leading image axes.""" raw = np.full(shape, 110, np.uint16) actual = camera_correct(raw, 100, 0.25) np.testing.assert_array_equal(actual, np.full(shape, 2.5, np.float32)) diff --git a/tests/cpu/test_camera_likelihood.py b/tests/cpu/test_camera_likelihood.py new file mode 100644 index 0000000..6b1b52c --- /dev/null +++ b/tests/cpu/test_camera_likelihood.py @@ -0,0 +1,143 @@ +"""Exact photon likelihood, Gaussian readout and quantized camera probabilities.""" + +from dataclasses import replace + +import numpy as np +import pytest +from scipy.special import j1 +from scipy.stats import norm, poisson + +from tests.simulation.camera_likelihood import camera_log_likelihood +from tests.simulation.metrics import ( + airy_rayleigh, + camera_score_difference, + first_minimum, + poisson_log_ratio_moments, + predictive_deviance, +) + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize("sigma", [0, 0.7, 1.6]) +@pytest.mark.parametrize("rate", [0, 0.2, 2, 100]) +def test_adc_likelihood_normalizes_and_predicts_photon_statistics( + sigma, rate, camera_calibration +): + # A 10-bit camera retains negligible saturation here; the likelihood still + # integrates both censored endpoint codes, rather than discarding them. + """Integrate quantized camera probabilities, including censored ADC endpoints.""" + camera = replace(camera_calibration, read_noise_e=sigma, bit_depth=10) + codes = np.arange(2**camera.bit_depth) + probability = np.exp(camera_log_likelihood(codes, rate, camera)) + np.testing.assert_allclose(probability.sum(), 1, rtol=0, atol=2e-13) + if sigma == 0: + n = np.arange(300) + adc = np.clip(np.rint(100 + n / 0.24), 0, 1023).astype(int) + expected = np.bincount(adc, weights=poisson.pmf(n, rate), minlength=1024) + np.testing.assert_allclose(probability, expected, rtol=1e-11, atol=2e-13) + if rate == 0 and sigma > 0: + expected = norm.cdf((100.5 - 100) * 0.24 / sigma) - norm.cdf( + (99.5 - 100) * 0.24 / sigma + ) + assert probability[100] == pytest.approx(expected, rel=1e-13) + + +@pytest.mark.parametrize("rate", [0.2, 2, 100], scope="module") +@pytest.mark.parametrize("rate_factor", [0.5, 2]) +def test_held_out_camera_likelihood_favors_true_rate_without_brightness_fit( + rate, rate_factor, camera_calibration, camera_likelihood_sample +): + """Prefer the true photon rate over fixed alternative rates on independent data.""" + raw, truth_score = camera_likelihood_sample + difference = truth_score - camera_log_likelihood( + raw, rate * rate_factor, camera_calibration + ) + # Five standard errors test the likelihood ordering on independent observations. + assert difference.mean() > 5 * difference.std(ddof=1) / np.sqrt(raw.size) + + +@pytest.mark.parametrize("kernel_shape", [(1, 1, 1), (3, 3, 3), (4, 2, 3)]) +def test_blurred_object_likelihood_uses_exact_reflected_optical_model(kernel_shape): + """Match the analytic Poisson likelihood of twofold exposure on reflected edges.""" + from scipy import ndimage + + z, y, x = np.indices((7, 9, 11), dtype=float) + density = 1 + z + 2 * y + 3 * x + psf = np.arange(1, np.prod(kernel_shape) + 1, dtype=float).reshape(kernel_shape) + psf /= psf.sum() + blurred = ndimage.convolve(density, psf, mode="reflect") + expected = 2 * (2 * np.log(2) - 1) * blurred.sum() + assert predictive_deviance( + density, 2 * blurred, psf, boundary="reflect" + ) == pytest.approx(expected, rel=1e-13) + + +def test_integer_camera_score_ratio_matches_independent_adc_sum(camera_calibration): + """Recover the Poisson score ratio for an ideal integer-count camera.""" + camera = replace( + camera_calibration, read_noise_e=0, electrons_per_adu=1, offset_adu=0 + ) + observed = np.array([0, 1, 30, 3000, 1000], dtype=np.uint16) + prediction = np.array([0, 0.1, 30, 3000, 0.1]) + baseline = np.array([1, 0.2, 25, 3100, 0.2]) + np.testing.assert_allclose( + camera_log_likelihood(observed, prediction, camera), + poisson.logpmf(observed, prediction), + rtol=3e-13, + atol=3e-13, + ) + expected = camera_log_likelihood( + observed, prediction, camera + ) - camera_log_likelihood(observed, baseline, camera) + np.testing.assert_allclose( + camera_score_difference(observed, prediction, baseline, camera), + expected, + rtol=3e-11, + atol=3e-11, + ) + + +def test_registration_likelihood_preserves_dark_support(): + """Retain exact deterministic dark support and impossible-event limits.""" + assert poisson_log_ratio_moments([0, 2], [0, 2]) == (0, 0) + assert poisson_log_ratio_moments([0, 0], [0, 3]) == (3, 0) + assert poisson_log_ratio_moments([0, 2], [3, 0]) == (np.inf, np.inf) + + +@pytest.mark.parametrize("exposure", [0.01, 1, 100]) +def test_registration_likelihood_moments_predict_independent_photon_draws(exposure): + """Match analytic log-likelihood moments to independent photon realizations.""" + truth = exposure * np.array([0.2, 2, 20]) + prediction = exposure * np.array([0.1, 3, 16]) + loss, variance = poisson_log_ratio_moments(truth, prediction) + base_loss, base_variance = poisson_log_ratio_moments( + truth / exposure, prediction / exposure + ) + # KL and variance scale with exposure; distinguishability scales as sqrt(T). + np.testing.assert_allclose( + [loss, variance], exposure * np.array([base_loss, base_variance]), rtol=1e-13 + ) + counts = np.random.default_rng(31).poisson(truth, size=(50000, 3)) + measured = (poisson.logpmf(counts, truth) - poisson.logpmf(counts, prediction)).sum( + axis=1 + ) + n = measured.size + assert abs(measured.mean() - loss) <= 5 * np.sqrt(variance / n) + # Fourth cumulant of a weighted Poisson sum is sum(lambda*weight^4). + fourth = 3 * variance**2 + np.sum(truth * np.log(truth / prediction) ** 4) + variance_se = np.sqrt((fourth - (n - 3) / (n - 1) * variance**2) / n) + assert abs(measured.var(ddof=1) - variance) <= 5 * variance_se + + +def test_directional_rayleigh_measurement_matches_airy_first_minimum(): + """Recover the circular-pupil Rayleigh separation and pair contrast.""" + separation, dip = airy_rayleigh(0.637, 1.35) + radius = np.linspace(0, 2 * separation, 20001) + argument = 2 * np.pi * 1.35 * radius[1:] / 0.637 + profile = np.r_[1, (2 * j1(argument) / argument) ** 2] + assert first_minimum(radius, profile) == pytest.approx(separation, abs=radius[1]) + assert separation == pytest.approx(0.6098349456 * 0.637 / 1.35, rel=1e-9) + assert dip == pytest.approx(0.26497, abs=5e-5) + with pytest.raises(ValueError, match="first minimum"): + first_minimum(radius, np.exp(-(radius**2))) diff --git a/tests/cpu/test_cli_helpers.py b/tests/cpu/test_cli_helpers.py new file mode 100644 index 0000000..a60f06d --- /dev/null +++ b/tests/cpu/test_cli_helpers.py @@ -0,0 +1,397 @@ +"""CLI discovery, calibration, selection and export-format function units.""" + +from pathlib import Path + +import numpy as np +import pytest +import tensorstore as ts + +from opm_processing import encode_projections, export_ome_tiff, export_projections +from opm_processing.process import apply_stage_axis_flips, validate_empty_tile_options + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize( + "cli_file_layout,expected", + [ + (("sample_deskewed.ome.zarr",), "sample_deskewed.ome.zarr"), + ( + ("sample_max_z_tissue_deskewed.ome.zarr",), + "sample_max_z_tissue_deskewed.ome.zarr", + ), + (("sample_decon_deskewed.ome.zarr",), "sample_decon_deskewed.ome.zarr"), + ( + ( + "sample_deskewed.ome.zarr", + "sample_decon_deskewed.ome.zarr", + "sample_max_z_decon_deskewed.ome.zarr", + ), + "sample_decon_deskewed.ome.zarr", + ), + ((), None), + (("sample_max_z_decon_deskewed.ome.zarr",), None), + (("first_decon_deskewed.ome.zarr", "second_deskewed.ome.zarr"), None), + ], + indirect=["cli_file_layout"], +) +def test_projection_discovery_selects_full_processed_data(cli_file_layout, expected): + """Choose one full store without confusing previews or distinct acquisitions.""" + if expected is None: + with pytest.raises(ValueError): + export_projections.find_datasets(cli_file_layout) + else: + assert export_projections.find_datasets(cli_file_layout) == [ + cli_file_layout / expected + ] + with pytest.raises(ValueError, match="acquisition root"): + export_projections.find_datasets(cli_file_layout / expected) + + +@pytest.mark.parametrize( + "cli_file_layout,indices", + [ + (("p0/c0/sample_t9.tiff", "p0/c0/sample_t10.tiff", "notes.txt"), (9, 10)), + (("sample_t1.tiff", "sample_t3.tiff"), None), + (("sample_t1.tiff", "sample_t01.tif"), None), + (("notes.txt",), None), + ], + indirect=["cli_file_layout"], +) +def test_video_sequences_require_unique_contiguous_timepoints(cli_file_layout, indices): + """Sort acquired indices numerically and reject missing or duplicate frames.""" + if indices is None: + with pytest.raises(ValueError): + encode_projections.find_sequences(cli_file_layout) + else: + sequences = encode_projections.find_sequences(cli_file_layout) + assert len(sequences) == 1 + assert [int(p.stem.rsplit("t", 1)[1]) for p in sequences[0]] == list(indices) + + +@pytest.mark.parametrize( + "suffix", + [ + "_decon_deskewed.ome.zarr", + "_deskewed.ome.zarr", + "_fused.ome.zarr", + ".ome.zarr", + ".zarr", + ], +) +def test_export_basename_retains_specimen_identifier(suffix): + """Strip a processing suffix without truncating the acquisition identifier.""" + source = Path("sample_name" + suffix) + assert export_ome_tiff.export_source_stem(source) == "sample_name" + + +@pytest.mark.parametrize( + "values,expected", + [ + ([0, 1, 2, np.nan], (0.00002, 1.99998)), + ([7, 7, np.inf], (7, 8)), + ([np.nan, np.inf], None), + ], +) +def test_projection_contrast_uses_finite_first_exposure(values, expected): + """Keep fixed intensity scaling with explicit constant and invalid-input cases.""" + if expected is None: + with pytest.raises(ValueError, match="no finite"): + export_projections.contrast_limits(np.asarray(values)) + else: + np.testing.assert_allclose( + export_projections.contrast_limits(np.asarray(values)), expected + ) + + +@pytest.mark.parametrize("flips", list(np.ndindex(2, 2, 2))) +def test_stage_reflections_preserve_zxy_axis_order(flips): + """Reflect each declared stage axis about its maximum and retain source data.""" + source = np.asarray(((2, -7, 11), (5, -3, 19)), dtype=float) + original = source.copy() + transformed = apply_stage_axis_flips(source, flips) + for column, flip in enumerate(flips[::-1]): + expected = ( + source[:, column].max() - source[:, column] if flip else source[:, column] + ) + np.testing.assert_array_equal(transformed[:, column], expected) + np.testing.assert_array_equal(source, original) + + +@pytest.mark.parametrize( + "threshold,fraction,valid", + [ + (None, 0.01, True), + (0, 1, True), + (10, 0.1, True), + (-1, 0.1, False), + (np.nan, 0.1, False), + (1, 0, False), + (1, 1.1, False), + (1, np.inf, False), + ], +) +def test_empty_channel_options_preserve_physical_thresholds(threshold, fraction, valid): + """Accept nonnegative count thresholds and finite occupancy fractions.""" + if valid: + assert validate_empty_tile_options(threshold, fraction) == (threshold, fraction) + else: + with pytest.raises(ValueError): + validate_empty_tile_options(threshold, fraction) + + +@pytest.mark.parametrize("tile_shape", [(16, 16), (32, 32)]) +@pytest.mark.parametrize("dtype", [np.uint16, np.float32]) +def test_tiff_tiles_preserve_page_order_partial_edges_and_counts(tile_shape, dtype): + """Read genuine TensorStore tiles without dropping padded-edge photon values.""" + source = np.arange(2 * 2 * 3 * 17 * 19, dtype=dtype).reshape(2, 2, 3, 17, 19) + tiles = list(export_ome_tiff.image_tiles(ts.array(source), tile_shape)) + spatial = int(np.prod(np.ceil(np.asarray(source.shape[-2:]) / tile_shape))) + assert len(tiles) == np.prod(source.shape[:3]) * spatial + assert sum(tile.sum(dtype=np.float64) for tile in tiles) == source.sum( + dtype=np.float64 + ) + np.testing.assert_array_equal( + tiles[0], source[0, 0, 0, : tile_shape[0], : tile_shape[1]] + ) + np.testing.assert_array_equal( + tiles[spatial], source[0, 0, 1, : tile_shape[0], : tile_shape[1]] + ) + assert all(tile.dtype == dtype for tile in tiles) + + +@pytest.mark.parametrize( + "options", + [ + {"fps": 0}, + {"fps": 121}, + {"bitrate": 0}, + {"gpu": -1}, + {"codec": "invalid"}, + {"rate_control": "invalid"}, + ], +) +def test_encoder_rejects_invalid_options_before_gpu_allocation( + encoder_frames, tmp_path, options +): + """Reject invalid playback and encoder settings without loading CUDA.""" + with pytest.raises(ValueError): + encode_projections.encode_sequence( + encoder_frames, tmp_path / "movie.mp4", **options + ) + assert not (tmp_path / "movie.mp4").exists() + + +@pytest.mark.parametrize( + "export_image_input", ["image", "collection", "multiple"], indirect=True +) +@pytest.mark.parametrize("bounds", [None, (1, 4, 2, 5), (2, 2, 0, 3)]) +def test_tiff_image_functions_resolve_metadata_and_crop_pixels( + export_image_input, bounds, tmp_path +): + """Resolve real image layouts and retain camera values through bounded export.""" + import tifffile + from ome_types import from_xml + + case = export_image_input + if case.kind == "multiple": + with pytest.raises(ValueError, match="single-position"): + export_ome_tiff.resolve_export_image(case.path) + return + assert export_ome_tiff.resolve_export_image(case.path) == case.image + template, provenance = export_ome_tiff.load_export_metadata(case.path) + output = tmp_path / "image.ome.tif" + if bounds is not None and bounds[0] == bounds[1]: + with pytest.raises(ValueError, match="ROI bounds"): + export_ome_tiff.export_image( + case.path, output, template, provenance, bounds_yx=bounds + ) + assert not output.exists() + else: + export_ome_tiff.export_image( + case.path, output, template, provenance, bounds_yx=bounds, workers=1 + ) + expected = ( + case.pixels + if bounds is None + else case.pixels[..., bounds[0] : bounds[1], bounds[2] : bounds[3]] + ) + with tifffile.TiffFile(output) as tif: + np.testing.assert_array_equal( + tif.asarray().reshape(expected.shape), expected + ) + pixels = from_xml(tif.ome_metadata, validate=True).images[0].pixels + assert (pixels.size_z, pixels.size_y, pixels.size_x) == expected.shape[-3:] + + +@pytest.mark.parametrize("export_image_input", ["collection"], indirect=True) +@pytest.mark.parametrize( + "variable,damage", + [ + (False, None), + (False, "shape"), + (False, "dtype"), + (False, "pyramid"), + (True, None), + (True, "shape"), + (True, "dtype"), + ], +) +def test_resume_readers_require_matching_shape_dtype_and_pyramid( + export_image_input, variable, damage +): + """Reject incompatible saved data while accepting the exact recorded geometry.""" + from opm_processing.process import ( + open_resume_collection, + open_variable_resume_collection, + ) + + case = export_image_input + shape = list(case.pixels.shape) + if damage == "shape": + shape[-3] += 1 + dtype = np.dtype(np.float32 if damage == "dtype" else case.pixels.dtype) + if variable: + reader, args = open_variable_resume_collection, ([tuple(shape)], dtype) + else: + shape.insert(1, 1) + reader, args = ( + open_resume_collection, + (tuple(shape), dtype, (1, 2) if damage == "pyramid" else (1,)), + ) + if damage: + with pytest.raises(ValueError, match="incompatible"): + reader(case.path, *args) + else: + output = reader(case.path, *args) + np.testing.assert_array_equal(output.arrays[0].read().result(), case.pixels) + + +@pytest.mark.parametrize("is_zero", [False, True]) +@pytest.mark.parametrize("fail_write", [False, True]) +def test_channel_checkpoint_requires_a_successful_write( + processing_state, tmp_path, is_zero, fail_write +): + """Retain channel pixels and record completion only after the write succeeds.""" + from opm_processing.process import write_checkpointed_roi_channel + + path = tmp_path / "output.ome.zarr" + processing_state.initialize_run(path, configuration={}, overwrite=True) + target = ts.array(np.zeros((2, 3, 4), np.uint16)) + if fail_write: + readonly = ts.open(target.spec(), read=True, write=False).result() + with pytest.raises(ValueError, match="does not support writing"): + write_checkpointed_roi_channel( + readonly, + 0 if is_zero else 17, + processing_state, + path, + (0, 0, 0), + is_zero=is_zero, + ) + assert processing_state.completed_channels(path) == set() + else: + write_checkpointed_roi_channel( + target, + 0 if is_zero else 17, + processing_state, + path, + (0, 0, 0), + is_zero=is_zero, + ) + np.testing.assert_array_equal(target.read().result(), 0 if is_zero else 17) + assert processing_state.completed_channels(path) == {(0, 0, 0)} + assert processing_state.zero_channels(path) == ( + {(0, 0, 0)} if is_zero else set() + ) + + +@pytest.mark.parametrize("order", [("t", "p"), ("p", "t"), ("c", "z")]) +def test_progress_groups_preserve_every_pending_volume(order): + """Yield each pending tile once while accounting for completed group members.""" + from opm_processing.process import progress_tile_groups + + requested = [(0, 0), (0, 1), (1, 0), (1, 1)] + pending = [(0, 1), (1, 0), (1, 1)] + actual = list(progress_tile_groups(pending, requested, {(0, 0)}, order)) + assert actual == [(0, (1,)), (1, (0,)), (1, (1,))] + + +@pytest.mark.parametrize("zero", [False, True]) +def test_position_pyramid_queue_writes_all_levels(position_store_factory, zero): + """Retain intensity or an explicit zero across every queued image pyramid.""" + from opm_processing.process import queue_position_pyramid_writes + + source = np.arange(3 * 8 * 10, dtype=np.float32).reshape(3, 8, 10) + collection = position_store_factory( + np.zeros((1, 1, 1, *source.shape), np.float32), + multiscale_factors_yx=(2, 4), + ) + writes = queue_position_pyramid_writes( + collection, 0, 0, 0, np.float32(0) if zero else source + ) + for future in writes: + future.result() + assert len(writes) == 3 + for factor, arrays in zip( + collection.multiscale_factors_yx, collection.multiscale_arrays, strict=True + ): + np.testing.assert_array_equal( + arrays[0][0, 0].read().result(), + 0 if zero else source[..., ::factor, ::factor], + ) + + +@pytest.mark.parametrize("times", [1, 3]) +def test_acquisition_manifest_is_json_serializable(acquisition_metadata, times): + """Retain recorded calibration and derived index sizes in the inspector payload.""" + import json + from dataclasses import replace + + shape = (times, *acquisition_metadata.shape[1:]) + acquisition = replace(acquisition_metadata, shape=shape) + manifest = json.loads(json.dumps(acquisition.to_dict())) + assert manifest["path"] == str(acquisition.path) + assert manifest["shape"] == list(shape) + assert manifest["index_sizes"] == dict(zip(acquisition.axes, shape, strict=True)) + assert manifest["camera_offset"] == acquisition.camera_offset + assert manifest["camera_conversion"] == acquisition.camera_conversion + + +@pytest.mark.parametrize("export_image_input", ["collection"], indirect=True) +@pytest.mark.parametrize( + "acquisition_link", + ["override", "journal", "relative", "missing", "ambiguous"], + indirect=True, +) +def test_projection_metadata_requires_an_unambiguous_source(acquisition_link): + """Read recorded acquisition calibration from explicit or journal-linked paths.""" + link = acquisition_link + if link.kind in ("missing", "ambiguous"): + with pytest.raises(ValueError, match="one processing sidecar"): + export_projections.acquisition_for(link.processed) + else: + metadata = export_projections.acquisition_for( + link.processed, link.source if link.kind == "override" else None + ) + assert metadata.path == link.source.resolve() + assert metadata.shape == (1, 1, 1, 3, 4, 5) + assert metadata.camera_offset == 100 + assert metadata.camera_conversion == 0.25 + + +def test_estimation_worker_returns_annotated_errors_and_closes_pipe(): + """Return the original input error and its traceback through a real pipe.""" + import multiprocessing as mp + from contextlib import closing + + from opm_processing.process import run_estimate_illuminations + + receiving, sending = mp.Pipe(duplex=False) + with closing(receiving), closing(sending): + invalid = ts.array(np.zeros((1, 1, 1, 4, 5), np.uint16)) + run_estimate_illuminations(invalid, 100, 0.25, None, None, None, sending) + error = receiving.recv() + assert isinstance(error, ValueError) + assert any("Flatfield worker traceback" in note for note in error.__notes__) + assert sending.closed diff --git a/tests/cpu/test_cli_workflows.py b/tests/cpu/test_cli_workflows.py new file mode 100644 index 0000000..27b9add --- /dev/null +++ b/tests/cpu/test_cli_workflows.py @@ -0,0 +1,86 @@ +"""Registered commands process the same simulated ground-truth acquisition.""" + +import json + +import numpy as np +import pytest +import tifffile +from ome_types import from_xml + +from opm_processing.dataio.position_collection import open_image_array + +pytestmark = pytest.mark.integration + + +@pytest.mark.parametrize("deconvolve", [False]) +@pytest.mark.parametrize( + "pipeline_case", + [("mirror", False, 0.4, None, False), ("stage", False, 1.2, None, False)], + indirect=True, +) +@pytest.mark.parametrize( + "cli_export_options", ["all-gray", "selected-color"], indirect=True +) +def test_inspection_and_exports_process_simulated_acquisition( + pipeline_case, cli_export_options, invoke_cli, tmp_path +): + """Parse real commands and reopen pixels, physical coordinates and timestamps.""" + case = pipeline_case + options = cli_export_options + metadata = case.dataset.metadata + source = case.dataset.path.parent + manifest = json.loads( + invoke_cli( + "inspect-opm", source if options["depth_color"] else case.dataset.path + ).output + ) + assert manifest["shape"] == list(case.dataset.raw_data.shape) + assert manifest["scan_axis_step_um"] == metadata.scan_axis_step_um + assert manifest["camera_offset"] == metadata.camera_offset + assert manifest["camera_conversion"] == metadata.camera_conversion + + destination = tmp_path / "ome-tiff" + invoke_cli( + "export-ome-tiff", case.output, output=destination, workers=options["workers"] + ) + for suffix in ("fused", "max_z_fused"): + image = source / f"pipeline_{suffix}.ome.zarr" + expected = open_image_array(image).read().result() + with tifffile.TiffFile(destination / f"pipeline_{suffix}.ome.tif") as tif: + np.testing.assert_array_equal( + tif.asarray().reshape(expected.shape), expected + ) + pixels = from_xml(tif.ome_metadata, validate=True).images[0].pixels + assert (pixels.size_t, pixels.size_c, pixels.size_z) == expected.shape[:3] + assert pixels.time_increment == pytest.approx( + case.collection.time_interval_s + ) + + invoke_cli( + "export-projections", + source, + acquisition=case.dataset.path if options["depth_color"] else None, + **options, + ) + files = sorted((source / "projection_frames").rglob("*.tiff")) + times = range(*(options["timepoints"] or (0, metadata.shape[0]))) + assert len(files) == len(times) * metadata.shape[1] * metadata.shape[2] + for path in files: + with tifffile.TiffFile(path) as tif: + saved = tif.shaped_metadata[0] + position, channel = saved["position"], saved["channel"] + reference = case.collection.arrays[position][0, channel].read().result() + np.testing.assert_allclose( + saved["contrast_limits"], np.percentile(reference, (0.001, 99.999)) + ) + assert saved["timepoint"] in times + assert saved["elapsed_ms"] == pytest.approx( + saved["timepoint"] * metadata.volume_interval_s * 1000 + ) + assert saved["source_voxel_size_zyx_um"] == list( + case.collection.voxel_size_um + ) + assert tif.pages[0].photometric.name == ( + "RGB" if options["depth_color"] else "MINISBLACK" + ) + assert np.any(tif.asarray() > 0) diff --git a/tests/cpu/test_deconvolution_metadata.py b/tests/cpu/test_deconvolution_metadata.py new file mode 100644 index 0000000..d8b7087 --- /dev/null +++ b/tests/cpu/test_deconvolution_metadata.py @@ -0,0 +1,246 @@ +"""Stored calibration determines deconvolution units without instrument defaults.""" + +from dataclasses import replace + +import numpy as np +import pytest +from yaozarrs import open_group + +from opm_processing.dataio.acquisition import inspect_acquisition +from opm_processing.dataio.deconvolution import ( + detector_gain_from_metadata, + emission_wavelengths_from_metadata, + load_deconvolution_psfs, + psf_parameters_from_metadata, + read_noise_from_metadata, + scan_upsample_from_metadata, +) +from opm_processing.dataio.position_collection import ( + open_position_collection, + update_group_attributes, +) + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize( + "step,factor", + [(0.2, 1), (0.4, 1), (0.8, 2), (1.2, 3), (2.4, 6), (1.200000000000001, 3)], +) +def test_scan_refinement_uses_recorded_spacing(acquisition_metadata, step, factor): + """Keep fine acquisition sampling and refine coarse steps to 0.4 micrometers.""" + acquisition = replace(acquisition_metadata, scan_axis_step_um=step) + assert scan_upsample_from_metadata(acquisition) == factor + assert step / factor == pytest.approx(min(step, 0.4)) + + +@pytest.mark.parametrize("step", [None, 0, -1, np.nan, np.inf, 0.6]) +def test_scan_refinement_rejects_unsupported_spacing(acquisition_metadata, step): + """Require valid spacing that the integer acquired-plane operator can represent.""" + with pytest.raises(ValueError, match=r"scan.*step|scan_axis_step_um"): + scan_upsample_from_metadata( + replace(acquisition_metadata, scan_axis_step_um=step) + ) + + +def test_planar_data_does_not_require_scan_spacing(acquisition_metadata): + """A single acquired plane needs no scan-grid refinement.""" + shape = (*acquisition_metadata.shape[:3], 1, *acquisition_metadata.shape[-2:]) + assert ( + scan_upsample_from_metadata( + replace(acquisition_metadata, shape=shape, scan_axis_step_um=None) + ) + == 1 + ) + + +@pytest.mark.parametrize("suffix", [".npy", ".ome.tif"]) +def test_measured_psfs_preserve_pixels_without_theoretical_calibration( + tmp_path, acquisition_metadata, suffix +): + """Load measured optical samples unchanged using NumPy and tifffile readers.""" + from tifffile import imwrite + + kernel = np.arange(27, dtype=np.float32).reshape(3, 3, 3) + kernel /= kernel.sum() + path = tmp_path / f"measured_psf{suffix}" + if suffix == ".npy": + np.save(path, kernel) + else: + imwrite(path, kernel, photometric="minisblack", metadata={"axes": "ZYX"}) + kernels, parameters = load_deconvolution_psfs(acquisition_metadata, [path]) + np.testing.assert_array_equal(kernels[0], kernel) + assert parameters is None + + +@pytest.mark.parametrize("count", [0, 2]) +def test_measured_psf_count_must_match_recorded_channels( + tmp_path, acquisition_metadata, count +): + """Reject missing or extra channel kernels before opening their files.""" + with pytest.raises(ValueError, match="one PSF per channel"): + load_deconvolution_psfs( + acquisition_metadata, + [tmp_path / f"channel_{index}.npy" for index in range(count)], + ) + + +@pytest.mark.parametrize( + "location, calibration, expected", + [ + ("root", None, 0), + ("root", {"rms": 0, "units": "electrons"}, 0), + ("root", {"rms": 1.6, "units": "electrons"}, 1.6), + ("root", {"rms": 6.4, "units": "ADU"}, 1.6), + ("root", {"rms": 0.7, "units": "calibrated-detector-counts"}, 0.7), + ("read_noise_e", 1.6, 1.6), + ("read_noise_adu", 6.4, 1.6), + ], +) +def test_noise_is_extracted_and_calibrated_once( + deconvolution_acquisition, location, calibration, expected +): + """Read RMS noise from stored calibration and convert its units once.""" + case = deconvolution_acquisition + root = open_group(case.path) + if location == "root": + if calibration is not None: + update_group_attributes( + case.path, + {"opm_processing": {"deconvolution": {"read_noise": calibration}}}, + ) + else: + writer = root["0"].attrs["ome_writers"] + writer["frame_metadata"][0]["mda_event"]["metadata"]["Camera"][location] = ( + calibration + ) + update_group_attributes(case.path / "0", {"ome_writers": writer}) + acquisition = inspect_acquisition(case.path) + assert read_noise_from_metadata(acquisition) == pytest.approx(expected) + assert acquisition.pixel_size_um == case.metadata.pixel_size_um + assert acquisition.scan_axis_step_um == case.metadata.scan_axis_step_um + + +@pytest.mark.parametrize( + "calibration", + [ + {"rms": -1, "units": "electrons"}, + {"rms": np.nan, "units": "electrons"}, + {"rms": np.inf, "units": "electrons"}, + {"rms": 1e30, "units": "electrons"}, + {"rms": 1.6, "units": "unknown"}, + ], +) +def test_invalid_recorded_noise_is_not_silently_replaced( + acquisition_metadata, calibration +): + """Reject invalid RMS noise instead of selecting a different noise model.""" + acquisition = replace( + acquisition_metadata, deconvolution={"read_noise": calibration} + ) + with pytest.raises(ValueError, match=r"[Rr]ead.noise"): + read_noise_from_metadata(acquisition) + + +@pytest.mark.parametrize("field", ["psf_parameters", "emission_wavelengths_nm"]) +def test_missing_optics_are_not_inferred_from_channel_labels( + deconvolution_acquisition, field +): + """Require recorded optical calibration independently of excitation labels.""" + acquisition = inspect_acquisition(deconvolution_acquisition.path) + read = ( + psf_parameters_from_metadata + if field == "psf_parameters" + else emission_wavelengths_from_metadata + ) + with pytest.raises(ValueError, match=field): + read(acquisition) + + +@pytest.mark.parametrize("profile", [None, [0.7, 0.8, 0.9, 1, 1.1]]) +def test_optical_calibration_round_trips_without_instrument_defaults( + deconvolution_acquisition, simulation_parameters, profile +): + """Preserve recorded optics, emission wavelengths and optional detector gain.""" + case = deconvolution_acquisition + parameters = {"lens": dict(simulation_parameters["microscope"]["psf_parameters"])} + settings = {"psf_parameters": parameters, "emission_wavelengths_nm": [615]} + if profile is not None: + settings["detector_gain_x"] = profile + update_group_attributes(case.path, {"opm_processing": {"deconvolution": settings}}) + acquisition = inspect_acquisition(case.path) + assert psf_parameters_from_metadata(acquisition) == parameters + assert emission_wavelengths_from_metadata(acquisition) == (0.615,) + gain = detector_gain_from_metadata(acquisition, case.raw_data.shape[-1]) + if profile is None: + assert gain is None + else: + np.testing.assert_array_equal(gain, np.asarray(profile, np.float32)) + + +@pytest.mark.parametrize("profile", [[1, 1], [1, 1, 0, 1, 1], [1, 1, np.nan, 1, 1]]) +def test_invalid_detector_calibration_is_rejected(acquisition_metadata, profile): + """Reject incomplete, nonpositive or nonfinite detector gain profiles.""" + acquisition = replace( + acquisition_metadata, deconvolution={"detector_gain_x": profile} + ) + with pytest.raises(ValueError, match="detector_gain_x"): + detector_gain_from_metadata(acquisition, 5) + + +@pytest.mark.parametrize( + "field", + [ + "pixel_size_um", + "angle_deg", + "camera_offset", + "camera_conversion", + "scan_axis_step_um", + ], +) +def test_missing_required_calibration_is_an_explicit_error(acquisition_metadata, field): + """Name the missing physical calibration in the processing error.""" + acquisition = replace(acquisition_metadata, **{field: None}) + with pytest.raises(ValueError, match=field): + acquisition.validate_processing_calibration() + + +@pytest.mark.parametrize("unit, multiplier", [("micrometer", 1), ("nanometer", 1000)]) +def test_camera_pitch_uses_recorded_ngff_units( + deconvolution_acquisition, unit, multiplier +): + """Convert the recorded NGFF camera pitch into micrometers.""" + case = deconvolution_acquisition + image = open_group(case.path)["0"] + ome = image.attrs["ome"] + multiscale = ome["multiscales"][0] + scales = multiscale["datasets"][0]["coordinateTransformations"][0]["scale"] + for index, axis in enumerate(multiscale["axes"]): + if axis["name"] in ("x", "y"): + axis["unit"] = unit + scales[index] = case.metadata.pixel_size_um * multiplier + update_group_attributes(case.path / "0", {"ome": ome}) + assert inspect_acquisition(case.path).pixel_size_um == pytest.approx( + case.metadata.pixel_size_um + ) + + +@pytest.mark.parametrize("unit, multiplier", [("micrometer", 1), ("nanometer", 1000)]) +def test_collection_reads_ngff_spacing_and_origin_in_physical_units( + deconvolution_acquisition, unit, multiplier +): + """NGFF calibration is authoritative when the XML companion is stale.""" + path = deconvolution_acquisition.path + group = open_group(path)["0"] + metadata = group.ome_metadata().model_dump(mode="json", exclude_none=True) + multiscale = metadata["multiscales"][0] + transforms = multiscale["datasets"][0]["coordinateTransformations"] + spacing, origin = (0.31, 0.12, 0.12), (-2.3, 4.5, 6.7) + for axis in multiscale["axes"][-3:]: + axis["unit"] = unit + transforms[0]["scale"][-3:] = [value * multiplier for value in spacing] + transforms[1]["translation"][-3:] = [value * multiplier for value in origin] + update_group_attributes(path / "0", {"ome": metadata}) + collection = open_position_collection(path) + np.testing.assert_allclose(collection.voxel_size_um, spacing, atol=1e-14) + np.testing.assert_allclose(collection.spatial_origins_zyx_um, [origin], atol=1e-14) diff --git a/tests/cpu/test_depth_color.py b/tests/cpu/test_depth_color.py index 09d92d3..a948881 100644 --- a/tests/cpu/test_depth_color.py +++ b/tests/cpu/test_depth_color.py @@ -1,6 +1,6 @@ -"""Depth-color units and simulated point-object disk-to-disk export.""" +"""Depth colors and export pixels with isolated input and output files.""" -from types import SimpleNamespace +from dataclasses import replace import numpy as np import pytest @@ -10,7 +10,6 @@ from opm_processing import export_projections as exporter from opm_processing.imageprocessing.depth_color import ( depth_legends, - depth_palette, depth_projection, ) @@ -29,7 +28,15 @@ def test_anisotropic_point_source_depth_and_brightness(): for axis in range(3): projection = depth_projection(volume, axis, (0, 100)) pixel = tuple(center[a] for a in range(3) if a != axis) - expected = (depth_palette(shape[axis])[center[axis]] * 0.8).astype(np.uint8) + expected = ( + np.rint( + Colormap("turbo").lut(256)[ + round(255 * (center[axis] + 1) / shape[axis]), :3 + ] + * 255 + ).astype(np.uint8) + * 0.8 + ).astype(np.uint8) np.testing.assert_array_equal(projection[pixel], expected) legends = depth_legends(shape, spacing) assert [entry["axis"] for entry in legends] == ["Z", "Y", "X"] @@ -41,16 +48,20 @@ def test_raw_maximum_precedes_display_clipping(): """Select the physical intensity maximum before applying display contrast.""" volume = np.array([[[100]], [[200]]], dtype=float) np.testing.assert_array_equal( - depth_projection(volume, 0, (0, 50))[0, 0], depth_palette(2)[1] + depth_projection(volume, 0, (0, 50))[0, 0], + np.rint(Colormap("turbo").lut(256)[255, :3] * 255).astype(np.uint8), ) np.testing.assert_array_equal(depth_projection(np.zeros((2, 2, 2)), 0, (0, 1)), 0) np.testing.assert_array_equal( - depth_projection(np.ones((2, 1, 1)), 0, (0, 1))[0, 0], depth_palette(2)[0] + depth_projection(np.ones((2, 1, 1)), 0, (0, 1))[0, 0], + np.rint(Colormap("turbo").lut(256)[128, :3] * 255).astype(np.uint8), ) -@pytest.mark.integration -def test_color_tiff_export_from_disk(tmp_path, position_store_factory): +@pytest.mark.unit +def test_color_tiff_export_pixels_and_metadata( + tmp_path, position_store_factory, acquisition_metadata +): """Export simulated point objects from OME-Zarr to verified RGB TIFF pixels.""" # Emitters lie outside the upper-left panel annotation. Equal XY pitch # lets us check saved colors at exact detector coordinates without resizing. @@ -64,8 +75,10 @@ def test_color_tiff_export_from_disk(tmp_path, position_store_factory): channels=("488",), ) source_path = collection.path - acquisition = SimpleNamespace( - scan_position_count=21, channels=[SimpleNamespace(exposure_ms=5)] + acquisition = replace( + acquisition_metadata, + acquired_plane_count=21, + plane_period_s=0.005, ) exporter.export_dataset(source_path, tmp_path, acquisition, depth_color=True) files = list(tmp_path.rglob("*.tiff")) @@ -106,7 +119,7 @@ def test_color_tiff_export_from_disk(tmp_path, position_store_factory): @pytest.mark.unit -def test_timepoint_workers_overlap_and_propagate_failure(): +def test_timepoint_workers_overlap_and_propagate_failure(tmp_path): """Verify concurrent work and propagation of the original worker exception.""" from threading import Barrier, Lock @@ -116,6 +129,7 @@ def test_timepoint_workers_overlap_and_propagate_failure(): maximum = 0 def work(t): + """Track concurrent rendering while returning a prescribed frame.""" nonlocal active, maximum with lock: active += 1 @@ -127,8 +141,9 @@ def work(t): assert sorted(exporter.run_timepoints(work, range(6), 3)) == list(range(6)) assert maximum == 3 - def fail(t): - raise RuntimeError("read failed") - - with pytest.raises(RuntimeError, match="read failed"): - list(exporter.run_timepoints(fail, range(100), 2)) + with pytest.raises(FileNotFoundError): + list( + exporter.run_timepoints( + lambda _: (tmp_path / "missing-frame.tiff").read_bytes(), range(100), 2 + ) + ) diff --git a/tests/cpu/test_depth_intensity_normalization.py b/tests/cpu/test_depth_intensity_normalization.py index 61676e4..45a9539 100644 --- a/tests/cpu/test_depth_intensity_normalization.py +++ b/tests/cpu/test_depth_intensity_normalization.py @@ -4,261 +4,13 @@ import numpy as np import pytest -from scipy.ndimage import gaussian_filter1d -from typer.testing import CliRunner +import tensorstore as ts -from opm_processing.fuse import app as fuse_app from opm_processing.imageprocessing.tilefusion import TileFusion, _fit_depth_gains +pytestmark = pytest.mark.unit -@pytest.mark.integration -def test_directory_fusion_uses_deconvolved_tiles_and_preserves_partial_z_bins(tmp_path): - """Fuse known depth overlaps through disk while replacing a plain-data fusion. - Parameters - ---------- - tmp_path - Directory for the simulated camera acquisition, both processed inputs, - processing journal, fused volume and maximum projection. - - Notes - ----- - A narrow fluorescent line on a constant background has independently known - intensity. The deeper acquisition has Beer–Lambert attenuation of one half. - Simulated deconvolved tiles retain the line; plain tiles contain its Gaussian - optical blur. Exact stage placements are persisted as measured registration - state to isolate fusion and input selection from feature registration. - Partially filled Z bins must retain the same calibrated intensity as full - bins across the overlap. Both fusion passes use real reads and writes. - """ - from dataclasses import replace - - from opm_processing.dataio.position_collection import ( - create_position_collection, - open_image_array, - open_position_collection, - ) - from opm_processing.dataio.processing_state import ProcessingState - from opm_processing.process import process - from tests.fixtures.acquisition import ( - simulated_acquisition_metadata, - write_simulated_acquisition, - ) - - shape = (1, 2, 1, 64, 64, 32) - depth_step_um = 24.0 - attenuation_per_um = np.log(2) / depth_step_um - specimen = np.full(shape[-3:], 100.0) - specimen[..., 10] += 500 - raw = np.stack( - [ - np.rint(specimen * np.exp(-attenuation_per_um * path)).astype(np.uint16) - for path in (0, depth_step_um) - ] - )[None, :, None] - metadata = replace( - simulated_acquisition_metadata(tmp_path / "coverage.ome.zarr", "mirror", shape), - array_paths=("0/0", "1/0"), - stage_positions_zxy=((0, 0, 0), (-depth_step_um, 0, 0)), - scan_axis_step_um=1.0, - pixel_size_um=1.0, - camera_offset=0.0, - camera_conversion=1.0, - ) - write_simulated_acquisition(metadata, raw) - process( - metadata.path, - flatfield_correction=False, - max_projection=False, - create_fused_max_projection=False, - z_downsample_level=2, - save_float32=True, - ) - plain_path = tmp_path / "coverage_deskewed.ome.zarr" - decon_path = tmp_path / "coverage_decon_deskewed.ome.zarr" - plain = open_position_collection(plain_path) - decon = create_position_collection( - decon_path, - plain.shape, - plain.voxel_size_um, - dtype=np.float32, - stage_positions=plain.stage_positions_zxy, - channels=plain.channel_names, - chunks=(1, 1, 4, 32, 32), - ) - state = ProcessingState.read(tmp_path / "coverage.processing.json") - state.initialize_run( - decon_path, configuration={"simulated_deconvolution": True}, overwrite=True - ) - for position, (source, destination) in enumerate( - zip(plain.arrays, decon.arrays, strict=False) - ): - photons = source.read().result() - destination.write(photons).result() - source.write(gaussian_filter1d(photons, 1, axis=-1)).result() - state.complete_tile(decon_path, 0, position) - - for path in (plain_path, decon_path): - fusion = TileFusion(path, max_registration_shift_zyx=(1, 1, 1), max_workers=1) - fusion.processing_state.save_registration( - path, - configuration=fusion._registration_cache_settings(), - pairwise_metrics={"0,1": [0, 0, 0, 1.0]}, - ) - if path == plain_path: - fusion.run() - written = open_image_array(tmp_path / "coverage_fused.ome.zarr") - expected = 2 * gaussian_filter1d(specimen[0, 0], 1) - np.testing.assert_allclose( - written[0, 0, 12:16, 56:60, :32].read().result(), - np.broadcast_to(expected, (4, 4, 32)), - rtol=1e-6, - ) - - result = CliRunner().invoke( - fuse_app, - [ - str(tmp_path), - "--max-registration-shift-zyx", - "1", - "1", - "1", - "--max-workers", - "1", - ], - ) - assert result.exit_code == 0, result.output - written = open_image_array(tmp_path / "coverage_fused.ome.zarr") - np.testing.assert_allclose( - written[0, 0, 12:16, 56:60, :32].read().result(), - np.broadcast_to(2 * specimen[0, 0], (4, 4, 32)), - rtol=1e-6, - ) - maximum = open_image_array(tmp_path / "coverage_max_z_fused.ome.zarr") - np.testing.assert_allclose( - maximum[0, 0, 0, 56:60, :32].read().result(), - np.broadcast_to(2 * specimen[0, 0], (4, 32)), - rtol=1e-6, - ) - state = ProcessingState.read(state.path) - assert ( - state.registered_output_for_fused(tmp_path / "coverage_fused.ome.zarr") - == decon_path - ) - assert state.registration(plain_path)["pairwise_metrics"] == {"0,1": [0, 0, 0, 1.0]} - - -@pytest.mark.integration -@pytest.mark.parametrize("normalize", [False, True]) -def test_depth_normalization_fuses_simulated_object_from_disk( - processing_options, tmp_path, normalize -): - """Fuse a known oblique intensity phantom while preserving its XY contrast.""" - import json - from dataclasses import replace - - from opm_processing.dataio.acquisition import ChannelMetadata - from opm_processing.dataio.position_collection import open_image_array - from opm_processing.process import process - from tests.fixtures.acquisition import ( - simulated_acquisition_metadata, - write_simulated_acquisition, - ) - - shape = (1, 4, 2, 64, 64, 32) - scan, row, col = np.indices(shape[-3:], dtype=float) - raw = np.empty(shape, dtype=np.uint16) - depth_step_um = 8.0 - # Known effective excitation/emission attenuation, in inverse micrometers. - attenuation_per_um = np.asarray((0.08, 0.16)) - rng = np.random.default_rng(637) - bead_centers_um = rng.uniform((2, 10, 4), (38, 100, 28), (40, 3)) - for tile, (depth, xy_gain) in enumerate(((0, 1), (0, 3), (1, 1), (1, 3))): - optical_path_um = depth * depth_step_um - z = row * 0.5 + optical_path_um - y = scan + row * np.cos(np.pi / 6) - # Resolve a fixed bead object in laboratory coordinates with an - # independent continuous Gaussian optical response (sigma = 2 um). - specimen = np.full(shape[-3:], 10.0) - for bead_z, bead_y, bead_x in bead_centers_um: - squared_distance = ( - (z - bead_z) ** 2 + (y - bead_y) ** 2 + (col - bead_x) ** 2 - ) - specimen += 1000 * np.exp(-squared_distance / (2 * 2.0**2)) - for channel in range(2): - raw[0, tile, channel] = np.rint( - specimen - * xy_gain - * np.exp(-attenuation_per_um[channel] * optical_path_um) - ).astype(np.uint16) - metadata = replace( - simulated_acquisition_metadata(tmp_path / "depth.ome.zarr", "mirror", shape), - channels=( - ChannelMetadata(0, "488nm", 488, 10, None), - ChannelMetadata(1, "637nm", 637, 10, None), - ), - array_paths=tuple(f"{tile}/0" for tile in range(4)), - stage_positions_zxy=((0, 0, 0), (0, 0, 100), (-8, 0, 0), (-8, 0, 100)), - scan_axis_step_um=1.0, - pixel_size_um=1.0, - camera_offset=0.0, - camera_conversion=1.0, - ) - write_simulated_acquisition(metadata, raw) - process( - root_path=metadata.path, - save_float32=True, - **processing_options, - ) - fusion = TileFusion( - metadata.path, - normalize_depth_intensity=normalize, - max_registration_shift_zyx=(1, 1, 1), - downsample_factors=(1, 1, 1), - ssim_window=3, - threshold=0.3, - multiscale_factors=(2,), - chunk_shape_yx=(32, 32), - max_workers=1, - ) - fusion.run() - written = open_image_array(tmp_path / "depth_fused.ome.zarr") - actual = written[0].read().result() - assert actual[..., :32].max() > 500 # Recover the known 1000-ADU bead signal. - pyramid = open_image_array(tmp_path / "depth_fused.ome.zarr", level="1") - np.testing.assert_array_equal(pyramid[0].read().result(), actual[:, ::2, ::2, ::2]) - # The two separated XY fields differ only by the imposed threefold gain. - # Quantization contributes at most half an ADU per field before the - # threefold XY scale and inverse attenuation. Bound it independently - # using the known Beer gain, including the 2% gain accuracy checked below. - expected_depth_gains = np.exp(attenuation_per_um * depth_step_um) - quantization_tolerance = 0.5 * (3 + 1) * expected_depth_gains.max() * 1.02 - np.testing.assert_allclose( - actual[..., 100:132], - actual[..., :32] * 3, - rtol=0.002, - atol=quantization_tolerance, - ) - report = json.loads((tmp_path / "depth_depth_intensity_gains.json").read_text()) - assert report["enabled"] == normalize - if normalize: - np.testing.assert_allclose( - np.asarray([channel["depth_gains"] for channel in report["channels"]]).T, - np.exp(np.asarray((0, depth_step_um))[:, None] * attenuation_per_um), - rtol=0.02, - ) - # Both channels sample the same object and must agree after matching. - np.testing.assert_allclose( - actual[0], - actual[1], - rtol=0.03, - atol=0.5 * 3 * expected_depth_gains.sum() * 1.02, - ) - else: - assert np.max(np.abs(actual[0] - actual[1])) > 500 - - -@pytest.mark.unit def test_depth_gain_fit_is_robust_and_anchors_unobserved_depths(): """Recover known gains despite an outlier and retain isolated depth anchors.""" gains = _fit_depth_gains( @@ -273,9 +25,7 @@ def test_depth_gain_fit_is_robust_and_anchors_unobserved_depths(): np.testing.assert_allclose(gains, (1, 2, 1, 1)) -@pytest.mark.unit def test_normalization_matches_depths_and_preserves_xy_brightness( - tensorstore_dataset, fusion_operator, ): """Invert known Beer attenuation while preserving threefold XY contrast.""" @@ -308,7 +58,7 @@ def test_normalization_matches_depths_and_preserves_xy_brightness( position_dim=4, time_dim=1, channels=3, - position_arrays=tuple(tensorstore_dataset(a[None]) for a in arrays), + position_arrays=tuple(ts.array(a[None]) for a in arrays), _tile_support_zy=[np.ones((16, 64), dtype=bool)] * 4, _tile_channel_nonzero={(i, c): c < 2 for i in range(4) for c in range(3)}, optimized_pairwise_metrics={(0, 2): (0, 0, 0, 1), (1, 3): (0, 0, 0, 1)}, @@ -324,7 +74,6 @@ def test_normalization_matches_depths_and_preserves_xy_brightness( np.testing.assert_allclose(corrected[1], corrected[0] * 3, rtol=1e-6) -@pytest.mark.unit def test_scaled_uint16_output_clips_and_float32_preserves_range( fusion_operator, ): @@ -357,3 +106,23 @@ def test_scaled_uint16_output_clips_and_float32_preserves_range( corrected = fusion._scale_depth_source(0, source, coverage_zy=coverage) np.testing.assert_array_equal(corrected.ravel(), (160000, 1000)) np.testing.assert_array_equal(source.ravel(), (40000, 1000)) + + +@pytest.mark.parametrize("direct", [False, True]) +def test_fusion_source_prefers_deconvolution_unless_explicit( + processed_directory_factory, direct +): + """Directory discovery prefers deconvolution; an explicit plain store stays selected.""" + from opm_processing.imageprocessing.tilefusion import resolve_fusion_input + + directory = processed_directory_factory( + "sample", labels=("deskewed", "decon_deskewed", "projection") + ) + plain = directory / "sample_deskewed.ome.zarr" + output, source, stem, raw = resolve_fusion_input(plain if direct else directory) + assert (output, source, stem, raw) == ( + directory, + plain if direct else directory / "sample_decon_deskewed.ome.zarr", + "sample", + None, + ) diff --git a/tests/cpu/test_encode_projections.py b/tests/cpu/test_encode_projections.py index caecf92..b083c78 100644 --- a/tests/cpu/test_encode_projections.py +++ b/tests/cpu/test_encode_projections.py @@ -7,19 +7,22 @@ image_nv12, ) +pytestmark = pytest.mark.unit -@pytest.mark.unit -def test_nv12_preserves_gray_calibration_and_padding(): + +@pytest.mark.parametrize("minimum_shape", [(2, 2), (49, 145)]) +def test_nv12_preserves_gray_calibration_and_padding(minimum_shape): """Black, midpoint and white map to video-range luma with neutral chroma.""" frame = np.array([[0, 128, 255]], dtype=np.uint8) - buffer, width, height = image_nv12(frame) + buffer, width, height = image_nv12(frame, minimum_shape=minimum_shape) planes = buffer.reshape(height * 3 // 2, width) np.testing.assert_array_equal(planes[0, :3], [16, 126, 235]) assert np.all(planes[1:height] == 16) assert np.all(planes[height:] == 128) + assert height >= minimum_shape[0] and width >= minimum_shape[1] + assert height % 2 == width % 2 == 0 -@pytest.mark.unit def test_rgb_nv12_known_bt709_primaries(): # Published BT.709 limited-range primary values, each on a full chroma block. """Compare color conversion with published BT.709 primary calibration values.""" diff --git a/tests/cpu/test_export_ome_tiff.py b/tests/cpu/test_export_ome_tiff.py index 4cf19d9..fd25b25 100644 --- a/tests/cpu/test_export_ome_tiff.py +++ b/tests/cpu/test_export_ome_tiff.py @@ -1,4 +1,4 @@ -"""Simulated disk-to-disk OME-BigTIFF exports with pixel and metadata truth.""" +"""OME-BigTIFF export units with isolated temporary files and known pixel metadata.""" import json @@ -11,23 +11,14 @@ from opm_processing.export_ome_tiff import app +pytestmark = pytest.mark.integration + -@pytest.mark.integration @pytest.mark.parametrize("source_xml,workers", [(True, 1), (False, 3)]) def test_fused_pair_roundtrip_pixels_and_metadata( tmp_path, fused_export, source_xml, workers ): - """Preserve a calibrated fluorescent object, channel settings, and provenance. - - Parameters - ---------- - tmp_path : pathlib.Path - Isolated on-disk acquisition and export directory. - source_xml : bool - Include a rich source OME companion, or use acquisition NGFF alone. - workers : int - Serial or threaded TIFF compression worker count. - """ + """Preserve a calibrated fluorescent object, channel settings, and provenance.""" destination = tmp_path / "exports" runner = CliRunner() result = runner.invoke( @@ -88,7 +79,11 @@ def test_fused_pair_roundtrip_pixels_and_metadata( image.acquisition_date == fused_export.source_ome.images[0].acquisition_date ) - assert pixels.channels[0].emission_wavelength == 525 + assert pixels.channels[0].emission_wavelength == ( + fused_export.source_ome.images[0] + .pixels.channels[0] + .emission_wavelength + ) assert pixels.time_increment == 8.5 assert ( exported.structured_annotations.comment_annotations[0].value @@ -97,7 +92,7 @@ def test_fused_pair_roundtrip_pixels_and_metadata( z_origin = ( fused_export.origin[0] if expected.shape[2] == 3 - else fused_export.origin[0] + 0.23 + else fused_export.origin[0] + fused_export.spacing[0] ) assert image.stage_label.z == z_origin for index, plane in enumerate(pixels.planes): @@ -192,25 +187,32 @@ def test_fused_pair_roundtrip_pixels_and_metadata( assert not list(destination.glob("*.tmp")) -@pytest.mark.integration -def test_standalone_planar_fused_pair(tmp_path, fused_store_factory): +def test_standalone_planar_fused_pair(tmp_path, fused_store_factory, simulated_camera): """A portable singleton TCZ image retains NGFF channels and calibrated time.""" - y, x = np.mgrid[:17, :19] - truth = (100 * ((y - 8) ** 2 + (x - 9) ** 2 < 16)).astype(np.uint16)[ - None, None, None - ] + from opm_processing.imageprocessing.camera import camera_correct + + observed = simulated_camera((1, 17, 19), geometry="cartesian") + microscope = observed.microscope + spacing = ( + microscope.scan_step_um, + microscope.pixel_size_um, + microscope.pixel_size_um, + ) + truth = camera_correct( + observed.raw, microscope.camera.offset_adu, microscope.camera.electrons_per_adu + ).astype(np.uint16)[None, None] for name in ("sample_fused", "sample_max_z_fused"): fused_store_factory( truth, name=name, - spacing=(0.3, 0.2, 0.2), + spacing=spacing, time_step_s=3.5, time_origin_s=9.75, chunks=(1, 1, 1, 17, 19), omero=v05.Omero( channels=[ v05.OmeroChannel( - label="fluorescent disk", + label="simulated points", color="00FF00", window=v05.OmeroWindow(start=0, end=100, min=0, max=100), ) @@ -225,7 +227,12 @@ def test_standalone_planar_fused_pair(tmp_path, fused_store_factory): assert tif.is_bigtiff and tif.is_ome pixels = from_xml(tif.ome_metadata, validate=True).images[0].pixels assert pixels.size_t == pixels.size_c == pixels.size_z == 1 - assert pixels.channels[0].name == "fluorescent disk" + assert pixels.channels[0].name == "simulated points" + assert ( + pixels.physical_size_z, + pixels.physical_size_y, + pixels.physical_size_x, + ) == spacing assert pixels.time_increment == 3.5 assert pixels.planes[0].position_z == 0 assert pixels.planes[0].delta_t == 9.75 diff --git a/tests/cpu/test_export_projections.py b/tests/cpu/test_export_projections.py index 4266dde..a4d3474 100644 --- a/tests/cpu/test_export_projections.py +++ b/tests/cpu/test_export_projections.py @@ -1,6 +1,6 @@ -"""Physical projection units and simulated disk-to-disk export tests.""" +"""Physical projection and export units with isolated file structures.""" -from types import SimpleNamespace +from dataclasses import replace import numpy as np import pytest @@ -42,18 +42,28 @@ def test_physical_sphere_projections_and_scale(): @pytest.mark.unit -def test_acquired_volume_timing(): - """Use acquired scan counts and all channel exposures, with millisecond labels.""" - acquisition = SimpleNamespace( - scan_position_count=25, channels=[SimpleNamespace(exposure_ms=10)] * 3 +@pytest.mark.parametrize("planes, period", [(25, 0.03), (21, 0.005), (None, None)]) +def test_acquired_volume_timing(acquisition_metadata, planes, period): + """Hardware cadence is independent of exposure, channels and processed Z size.""" + acquisition = replace( + acquisition_metadata, + acquired_plane_count=planes, + plane_period_s=period, ) - assert exporter.volume_interval_ms(acquisition) == 750 + if planes is None: + with pytest.raises(ValueError, match="hardware volume timing"): + exporter.volume_interval_ms(acquisition) + else: + assert exporter.volume_interval_ms(acquisition) == pytest.approx( + 1000 * planes * period + ) assert exporter.timestamp(80 * 750 + 123) == "01:00:123" -@pytest.mark.integration +@pytest.mark.unit +@pytest.mark.parametrize("stored_timing", [False, True]) def test_disk_export_preserves_brightness_and_separates_series( - tmp_path, position_store_factory + tmp_path, position_store_factory, acquisition_metadata, stored_timing ): """Known linear intensities retain the same mapping over time in each series.""" first = np.linspace(0, 100, 10 * 48 * 64).reshape(10, 48, 64) @@ -63,10 +73,13 @@ def test_disk_export_preserves_brightness_and_separates_series( name="sample_decon_deskewed", voxel_size_um=(0.1, 0.1, 0.1), channels=("488", "637"), + time_interval_s=0.5 if stored_timing else None, ) source_path = collection.path - acquisition = SimpleNamespace( - scan_position_count=25, channels=[SimpleNamespace(exposure_ms=10)] * 2 + acquisition = replace( + acquisition_metadata, + acquired_plane_count=None if stored_timing else 25, + plane_period_s=None if stored_timing else 0.02, ) exporter.export_dataset(source_path, tmp_path, acquisition, 1) files = sorted(tmp_path.rglob("*.tiff")) diff --git a/tests/cpu/test_export_rois.py b/tests/cpu/test_export_rois.py new file mode 100644 index 0000000..495eb6f --- /dev/null +++ b/tests/cpu/test_export_rois.py @@ -0,0 +1,342 @@ +"""Physical GUI selections and raw-to-OME ROI processing integration.""" + +import json + +import numpy as np +import pytest +import tifffile +from ome_types import from_xml +from typer.testing import CliRunner +from yaozarrs import open_group, v05 + +from opm_processing import export_rois +from opm_processing.dataio.position_collection import open_image_array +from opm_processing.dataio.processing_state import ProcessingState + + +@pytest.mark.integration +@pytest.mark.parametrize( + "roi_preview", + [ + ("mirror", 1, False), + ("stage", 1, False), + ("mirror", 2, False), + ("stage", 2, True), + ], + indirect=True, +) +@pytest.mark.parametrize("tiff", [False, True]) +def test_raw_roi_exports_and_resume(roi_preview, tiff, monkeypatch): + """Reconstruct multiple rectangles with all times/channels and global origins.""" + case = roi_preview + selections = [(3, 12, 2, 10), (5, 15, 4, 12)] + calls = [] + + def choose(source, shape): + """Supply user interaction while exercising actual processing and storage.""" + calls.append(source) + assert shape[:3] == (2, 2, 1) + return selections + + monkeypatch.setattr(export_rois, "select_rois", choose) + args = [str(case.preview), "--save-float32"] + (["--tiff"] if tiff else []) + for _attempt in range(2): + result = CliRunner().invoke(export_rois.app, args) + assert result.exit_code == 0, result.exception + assert len(calls) == 1 + for index, (y0, y1, x0, x1) in enumerate(selections, 1): + directory = case.preview.parent / "roi_exports" / f"roi_{index:03d}" + image = directory / "sample_fused.ome.zarr" + root = open_group(image) + assert isinstance(root.ome_metadata(), v05.Image) + data = open_image_array(image).read().result() + assert data.shape[:2] == (2, 2) + assert data.shape[-2:] == (y1 - y0, x1 - x0) + # The selected preimage must retain the same measured photons as + # full processing of this simulated specimen, at every time/channel. + expected = case.collection.arrays[0].read().result()[..., y0:y1, x0:x1] + np.testing.assert_allclose(data, expected, rtol=2e-6, atol=2e-3) + assert np.all(data.max(axis=(2, 3, 4)) > 0) + dataset = root.ome_metadata().multiscales[0].datasets[0] + origin = dataset.translation_transform.translation + pixel = case.dataset.metadata.pixel_size_um + assert origin[-2:] == pytest.approx( + (case.origin[1] + y0 * pixel, case.origin[2] + x0 * pixel) + ) + state = ProcessingState.read(directory / "sample.processing.json") + tiles = directory / "sample_deskewed.ome.zarr" + assert len(state.completed_channels(tiles)) == 2 * 2 * case.positions + if case.positions > 1: + assert state.registration(tiles)["pairwise_metrics"] + target = directory / f"sample_roi_{index:03d}.ome.tiff" + assert target.is_file() == tiff + if tiff: + with tifffile.TiffFile(target) as tif: + np.testing.assert_array_equal( + tif.asarray().reshape(data.shape), data + ) + assert tif.is_ome and tif.is_bigtiff + assert ( + tif.pages[0].compression == tifffile.COMPRESSION.ADOBE_DEFLATE + ) + ome = from_xml(tif.ome_metadata, validate=True).images[0] + assert ( + ome.pixels.time_increment + == case.dataset.metadata.volume_interval_s + ) + assert ( + ome.stage_label.z, + ome.stage_label.y, + ome.stage_label.x, + ) == pytest.approx(origin[-3:]) + manifest = json.loads( + (case.preview.parent / "roi_exports" / "selections.json").read_text( + encoding="utf-8" + ) + ) + assert len(manifest["rois"]) == 2 + + +@pytest.mark.unit +def test_missing_preview_explains_creation(tmp_path): + """Missing previews fail before GUI creation with a complete CLI command.""" + with pytest.raises(ValueError, match=r"uv run process .*--no-deconvolve"): + export_rois.resolve_roi_source(tmp_path) + + +@pytest.mark.integration +@pytest.mark.parametrize( + "mosaic_acquisition", [("mirror", 0.4), ("stage", 1.2)], indirect=True +) +def test_roi_maps_extended_specimen_to_raw_tiles(mosaic_acquisition, monkeypatch): + """Keep the oblique preimage and measure fresh links across a real tile seam.""" + from opm_processing.dataio.position_collection import open_position_collection + from opm_processing.imageprocessing.tilefusion import TileFusion + from opm_processing.process import process_skewed + + case = mosaic_acquisition + process_skewed( + case.dataset.path, + acquisition=case.dataset.metadata, + max_projection=True, + create_fused_max_projection=True, + save_float32=True, + z_downsample_level=1, + ) + preview = case.dataset.path.parent / "mosaic_max_z_fused.ome.zarr" + projection = open_image_array(preview).read().result()[0, 0, 0] + cy = int(np.argmax(projection.max(axis=1))) + offset = case.parameters["tile_offset_x_pixels"] + bounds = (cy - 16, cy + 16, offset - 8, case.parameters["camera_shape_yx"][1] + 8) + monkeypatch.setattr(export_rois, "select_rois", lambda source, shape: [bounds]) + measurements = [] + register = TileFusion.register_and_score + + def measure(*args, **kwargs): + """Observe fresh local image measurements without changing their scores.""" + result = register(*args, **kwargs) + measurements.append(result) + return result + + monkeypatch.setattr(TileFusion, "register_and_score", staticmethod(measure)) + result = CliRunner().invoke(export_rois.app, [str(preview), "--save-float32"]) + assert result.exit_code == 0, result.exception + directory = preview.parent / "roi_exports" / "roi_001" + state = ProcessingState.read(directory / "mosaic.processing.json") + tiles = directory / "mosaic_deskewed.ome.zarr" + records = state.roi_series(tiles) + assert len(records) == 2 + assert all(record["skewed_bounds_sx"][0] > 0 for record in records) + assert measurements and all(np.isfinite(score) for _, score in measurements) + # This sparse ROI may not meet the unchanged production SSIM gate. The + # interaction contract requires new local measurements, not forced links. + assert state.registration(tiles)["fused_path"] == "mosaic_fused.ome.zarr" + saved = open_position_collection(tiles) + assert all(np.any(array.read().result() > 0) for array in saved.arrays) + final = open_image_array(directory / "mosaic_fused.ome.zarr").read().result() + assert final.shape[-2:] == (bounds[1] - bounds[0], bounds[3] - bounds[2]) + assert np.isfinite(final).all() and np.any(final > 0) + + +@pytest.mark.integration +@pytest.mark.parametrize( + "roi_preview", [("mirror", 1, False), ("stage", 1, False)], indirect=True +) +def test_single_position_gui_keeps_time_and_global_coordinates( + roi_preview, napari_model, monkeypatch +): + """Draw rectangles on plugin-loaded timelapse layers in stage space.""" + import napari + + viewer = napari_model + monkeypatch.setattr(napari, "Viewer", lambda: viewer) + + def draw(): + """Add a rectangle after inspecting the second acquired timepoint.""" + viewer.dims.set_current_step(0, 1) + layer = viewer.layers[0] + np.testing.assert_allclose(layer.translate[-2:], roi_preview.origin[-2:]) + vertices = np.asarray(((3, 2), (3, 10), (12, 10), (12, 2))) + viewer.layers.selection.active.add(vertices, shape_type="rectangle") + assert viewer.dims.current_step[0] == 1 + + monkeypatch.setattr(napari, "run", draw) + assert export_rois.select_rois(roi_preview.preview, (2, 2, 1, 32, 16)) == [ + (3, 12, 2, 10) + ] + + +@pytest.mark.integration +@pytest.mark.parametrize("z_origin", [0.0, -2441.811]) +def test_draw_rois_on_thick_projection( + monkeypatch, z_origin, napari_model, fused_store_factory, simulated_camera +): + """Draw level-zero rectangles on a physical full-depth maximum projection.""" + from types import SimpleNamespace + + import napari + from napari.utils._proxies import ReadOnlyWrapper + from napari.utils.interactions import ( + mouse_move_callbacks, + mouse_press_callbacks, + mouse_release_callbacks, + ) + + viewer = napari_model + observed = simulated_camera((226, 32, 48), step=0.23, geometry="cartesian").raw + data = np.stack((observed, observed)) + source = fused_store_factory( + data[:, None], + spacing=(0.23, 0.115, 0.115), + origin=(z_origin, -5330.254, -91.32), + factors=(1, 2), + ) + monkeypatch.setattr(napari, "Viewer", lambda: viewer) + + def draw_rectangles(): + """Deliver physical mouse events to the real rectangle-drawing callbacks.""" + image = viewer.layers[0] + rois = viewer.layers.selection.active + rois.scale_factor = 0.001 + for time, level, corners in ( + (0, 1, ((3.2, 4.1), (9.8, 12.6))), + (1, 0, ((12.2, 20.1), (19.8, 28.6))), + ): + image.locked_data_level = level + viewer.dims.set_current_step(0, time) + if time: + viewer.dims.set_point(1, viewer.dims.point[1] + 0.17) + viewer.dims.thickness = (0, 2.3, 0, 0) + rois._slice_dims(viewer.dims) + world = [ + (*viewer.dims.point[:2], *image.data_to_world((0, 0, *xy))[-2:]) + for xy in corners + ] + for kind, position, callback in ( + ("mouse_press", world[0], mouse_press_callbacks), + ("mouse_move", world[1], mouse_move_callbacks), + ("mouse_release", world[1], mouse_release_callbacks), + ): + event = ReadOnlyWrapper( + SimpleNamespace( + type=kind, + position=position, + is_dragging=kind == "mouse_move", + button=1, + modifiers=(), + pos=np.zeros(2), + dims_displayed=(2, 3), + ) + ) + callback(rois, event) + viewer.dims.set_current_step(0, 0) + viewer.dims.set_current_step(1, 0) + rois._slice_dims(viewer.dims) + assert rois._data_view._displayed.all() + + monkeypatch.setattr(napari, "run", draw_rectangles) + assert export_rois.select_rois(source, data.shape) == [ + (3, 10, 4, 13), + (12, 20, 20, 29), + ] + + +@pytest.mark.unit +def test_multiscale_transform_uses_level_zero(napari_model): + """Map world coordinates to level zero independently of the displayed scale.""" + from napari.layers import Image + + layer = Image( + [np.zeros((32, 48)), np.zeros((8, 12))], + multiscale=True, + scale=(0.115, 0.119), + translate=(20.25, -11.75), + ) + pixels = np.array([[3.2, 4.1], [3.2, 12.6], [9.8, 12.6], [9.8, 4.1]]) + world = [layer.data_to_world(vertex) for vertex in pixels] + for level in (0, 1): + layer.data_level = level + recovered = np.array([layer.world_to_data(vertex) for vertex in world]) + np.testing.assert_allclose(recovered, pixels) + assert export_rois.rectangle_bounds(recovered, (32, 48)) == (3, 10, 4, 13) + + +@pytest.mark.unit +@pytest.mark.parametrize("z_size", [5, 6]) +def test_thick_projection_preserves_time_and_multiscale_coordinates( + z_size, napari_model +): + """Retain time selection and physical coordinates across projected scales.""" + rng = np.random.default_rng(42) + data = rng.integers(0, 100, size=(2, z_size, 16, 24), dtype=np.uint16) + data[0, 0, 1, 1] = 200 + data[0, -1, 2, 2] = 300 + data[1] += 1000 + pyramid = [data, data[:, ::2, ::2, ::2]] + viewer = napari_model + layers = [ + viewer.add_image( + [level + channel * 2000 for level in pyramid], + multiscale=True, + scale=(1, 0.23, 0.115, 0.119), + translate=(0, -2447.283, -5330.16, -91.32), + ) + for channel in range(3) + ] + export_rois.configure_roi_projection(viewer, layers) + pixels = np.array([[3.2, 4.1], [3.2, 12.6], [9.8, 12.6], [9.8, 4.1]]) + for time in range(2): + viewer.dims.set_current_step(0, time) + for layer in layers: + for level, array in enumerate(layer.data): + layer.locked_data_level = level + layer.corner_pixels = np.array( + [np.zeros(4, dtype=int), np.array(array.shape) - 1] + ) + layer._slice_dims(viewer.dims) + np.testing.assert_array_equal( + layer._slice.image.raw, np.max(array[time], axis=0) + ) + vertices = np.column_stack( + [np.full(4, time), np.full(4, z_size // 2), pixels] + ) + world = [layer.data_to_world(vertex) for vertex in vertices] + recovered = np.array([layer.world_to_data(v) for v in world]) + assert export_rois.rectangle_bounds(recovered, data.shape) == ( + 3, + 10, + 4, + 13, + ) + + +@pytest.mark.unit +def test_bounds_clip_and_reject_empty(): + """Clip intersecting rectangles and reject selections outside the image.""" + assert export_rois.rectangle_bounds( + np.array([[-2, -3], [-2, 8], [10, 8], [10, -3]]), (6, 7) + ) == (0, 6, 0, 7) + with pytest.raises(ValueError): + export_rois.rectangle_bounds( + np.array([[10, 10], [10, 12], [12, 12], [12, 10]]), (6, 7) + ) diff --git a/tests/cpu/test_flatfield.py b/tests/cpu/test_flatfield.py index 6e27829..76777b3 100644 --- a/tests/cpu/test_flatfield.py +++ b/tests/cpu/test_flatfield.py @@ -1,93 +1,26 @@ -"""Ground-truth integration test for illumination correction.""" - -from pathlib import Path +"""Illumination correction units against known detector response.""" import numpy as np import pytest +import tensorstore as ts from opm_processing.imageprocessing.camera import ( camera_correct, illumination_correct, - qi2lab_stage_scan_camera_gain, ) from opm_processing.imageprocessing.flatfield import ( - _flatfield_tile_indices, - _stage_z_groups, + _flatfield_sample_indices, + _separable_residual_calibration, estimate_illuminations, ) -@pytest.mark.integration -@pytest.mark.parametrize("output_flatfield", (False, True)) -def test_processing_uses_existing_flatfield_with_separate_output( - opm_v2_projection_zarr, - tmp_path: Path, - output_flatfield: bool, -) -> None: - """Verify source reuse and output precedence through saved pixel values. - - Parameters - ---------- - opm_v2_projection_zarr : OpmV2ProjectionFixture - Simulated acquisition with camera calibration and raw images on disk. - tmp_path : pathlib.Path - Temporary directory containing the acquisition and processed output. - output_flatfield : bool - Write an output-side field that must take precedence over the source field. - - Returns - ------- - None - Reopened processed intensities match the selected calibration field. - """ - from opm_processing.dataio.acquisition import acquisition_stem - from opm_processing.dataio.position_collection import open_position_collection - from opm_processing.process import process, write_flatfield - - acquisition = opm_v2_projection_zarr - output_dir = tmp_path / "processed" - output_dir.mkdir() - filename = f"{acquisition_stem(acquisition.path)}_flatfield.ome.tif" - field_shape = (1, acquisition.raw_data.shape[2], *acquisition.raw_data.shape[-2:]) - source_field = np.full(field_shape, 2.0, dtype=np.float32) - selected_field = source_field - write_flatfield( - acquisition.path.parent / filename, source_field, acquisition.pixel_size_um - ) - if output_flatfield: - selected_field = np.full(field_shape, 1.25, dtype=np.float32) - write_flatfield( - output_dir / filename, selected_field, acquisition.pixel_size_um - ) - - process( - root_path=acquisition.path, - output=output_dir, - flatfield_correction=True, - deconvolve=False, - save_float32=True, - write_fused_max_projection_tiff=False, - ) - saved = ( - open_position_collection( - output_dir / f"{acquisition_stem(acquisition.path)}_projection.ome.zarr" - ) - .arrays[0] - .read() - .result() - ) - calibrated = ( - acquisition.raw_data[:, 0].astype(np.float32) - acquisition.camera_offset - ) * acquisition.camera_conversion - expected = (calibrated / selected_field[0])[:, :, np.newaxis] - np.testing.assert_allclose(saved, expected, rtol=1e-6) - - @pytest.mark.unit def test_stage_z_groups_use_repeated_xy_depth_not_absolute_tilted_z(): """Repeated XY visits define depth despite coverslip-dependent absolute Z.""" - levels, groups = _stage_z_groups( - 6, + from opm_processing.imageprocessing.coordinates import stage_z_level_indices + + indices = stage_z_level_indices( np.asarray( [ [10.0, 0.0, 0.0], @@ -100,33 +33,37 @@ def test_stage_z_groups_use_repeated_xy_depth_not_absolute_tilted_z(): ), ) - assert levels == (0.0, 1.0, 2.0) - assert groups == ((0, 1), (2, 3), (4, 5)) + np.testing.assert_array_equal(indices, (0, 0, 1, 1, 2, 2)) @pytest.mark.unit def test_flatfield_tiles_are_evenly_subsampled_within_each_depth(): """Large depth levels use a bounded, deterministic span of tiles.""" - selected = _flatfield_tile_indices(tuple(range(100)), max_tiles_per_level=8) + samples = _flatfield_sample_indices(tuple(range(100)), 12, max_tiles_per_level=8) + selected = [position for position, _planes in samples] assert len(selected) == 8 assert selected[0] == 0 assert selected[-1] == 99 assert len(set(selected)) == 8 assert set(np.diff(selected)) <= {14, 15} + assert samples == _flatfield_sample_indices( + tuple(range(100)), 12, max_tiles_per_level=8 + ) + for _position, planes in samples: + assert len(planes) == len(set(planes)) == 10 + assert 0 <= min(planes) <= max(planes) < 12 @pytest.mark.unit -def test_signal_selection_excludes_empty_channels_and_positions( - tensorstore_dataset, -): +def test_signal_selection_excludes_empty_channels_and_positions(): """The full-volume TPC mask limits each channel before tile subsampling.""" from opm_processing import process as process_module raw = np.full((1, 4, 2, 3, 4, 8), 100, dtype=np.uint16) raw[0, 0, 0, :, :, :4] = 130 raw[0, 2, 0, :, :, :4] = 130 - datastore = tensorstore_dataset(raw) + datastore = ts.array(raw) signal_decisions = process_module.build_illumination_signal_decisions( datastore, np.zeros(4, dtype=np.int64), @@ -134,7 +71,7 @@ def test_signal_selection_excludes_empty_channels_and_positions( camera_conversion=1.0, threshold=20.0, min_signal_fraction=0.01, - apply_stage_scan_gain=False, + detector_gain_x=None, ) assert signal_decisions is not None @@ -145,21 +82,19 @@ def test_signal_selection_excludes_empty_channels_and_positions( @pytest.mark.unit -def test_illumination_signal_check_stops_after_32_nonempty_candidates( - tensorstore_dataset, -): +def test_illumination_signal_check_stops_after_32_nonempty_candidates(): """Dense acquisitions avoid a full-position signal pre-scan.""" from opm_processing import process as process_module raw = np.full((1, 40, 1, 2, 3, 4), 130, dtype=np.uint16) decisions = process_module.build_illumination_signal_decisions( - tensorstore_dataset(raw), + ts.array(raw), np.zeros(40, dtype=np.int64), camera_offset=100.0, camera_conversion=1.0, threshold=20.0, min_signal_fraction=0.01, - apply_stage_scan_gain=False, + detector_gain_x=None, ) assert decisions is not None @@ -179,19 +114,9 @@ def test_illumination_signal_check_stops_after_32_nonempty_candidates( @pytest.mark.unit -def test_qi2lab_stage_camera_gain_has_fixed_unity_baseline(): - """The saved camera line profile changes only the measured detector trough.""" - gain = qi2lab_stage_scan_camera_gain() - - assert gain.shape == (1900,) - np.testing.assert_array_equal(gain[:1046], 1.0) - np.testing.assert_array_equal(gain[1104:], 1.0) - assert np.argmin(gain) == 1079 - assert gain[1079] == np.float32(0.717388) - - -@pytest.mark.unit -def test_processing_contract_uses_uint16_raw_float32_intermediates_and_final_cast(): +def test_processing_contract_uses_uint16_raw_float32_intermediates_and_final_cast( + camera_gain_profile, +): """Camera, nonlinear gain, illumination, and output boundaries are explicit.""" from opm_processing.process import format_processed_output @@ -200,13 +125,15 @@ def test_processing_contract_uses_uint16_raw_float32_intermediates_and_final_cas raw, 100.0, 1.0, - apply_stage_scan_gain=True, + detector_gain_x=camera_gain_profile, ) assert camera_corrected.dtype == np.float32 - np.testing.assert_allclose(camera_corrected[..., 100], 10.0) + np.testing.assert_allclose( + camera_corrected[..., 100], 10.0 / camera_gain_profile[100] + ) np.testing.assert_allclose( camera_corrected[..., 1079], - 10.0 / qi2lab_stage_scan_camera_gain()[1079], + 10.0 / camera_gain_profile[1079], ) illumination = np.full((3, 1900), 2.0, dtype=np.float32) @@ -223,142 +150,85 @@ def test_processing_contract_uses_uint16_raw_float32_intermediates_and_final_cas @pytest.mark.unit -def test_stage_z_flatfield_round_trips_exact_values( - tmp_path: Path, +@pytest.mark.parametrize("decision", (None, -1), ids=("all", "unchecked-excluded")) +@pytest.mark.parametrize("topology", ("single", "tiles", "depths")) +@pytest.mark.parametrize("storage", (False, True), ids=("array", "store")) +def test_fit_samples_preserve_calibration_and_depth_groups( + illumination_fit_case, topology, decision ): - """The reusable illumination retains every stage/channel field exactly.""" - from opm_processing.process import ( - read_current_flatfield, - write_flatfield, + """Keep time-zero photon units and odd detector dimensions through fit preparation.""" + case = illumination_fit_case + mask = None + selected = np.arange(len(case.levels)) + if decision is not None: + mask = np.ones(case.raw.shape[:3], np.int8) + if len(selected) > 1: + mask[0, 1] = decision + selected = selected[selected != 1] + else: + mask[1] = decision # Later times are not used for estimation. + actual = estimate_illuminations( + case.datastore, + 100, + 0.5, + case.coordinates, + detector_gain_x=case.gain, + signal_mask=mask, ) - - for stage_level_count in (1, 2): - path = tmp_path / f"flatfield-{stage_level_count}.ome.tif" - flatfields = np.ones( - (stage_level_count, 3, 8, 12), - dtype=np.float32, + expected_levels = [ + np.median(case.levels[selected, channel], axis=1) + if topology == "tiles" + else case.levels[position, channel] + for position in (selected if topology == "depths" else (0,)) + for channel in range(2) + ] + for images, levels in zip(case.calls, expected_levels, strict=True): + np.testing.assert_allclose( + images, + np.broadcast_to(levels[:, None, None], images.shape), + rtol=4 * np.finfo(np.float32).eps, + atol=0, ) - if stage_level_count > 1: - flatfields[1] *= 1.25 - write_flatfield(path, flatfields, 0.115) - - actual = read_current_flatfield(path, flatfields.shape) - assert actual is not None - np.testing.assert_array_equal(actual, flatfields) + assert actual.shape == ((2, 5, 7) if case.coordinates is None else (1, 2, 5, 7)) + np.testing.assert_allclose(actual, 1, rtol=4 * np.finfo(np.float32).eps, atol=0) @pytest.mark.unit -def test_flatfield_sample_loading_applies_detector_calibration( - tensorstore_dataset, -): - """The estimator's loader must preserve each channel's calibrated pixels.""" - from opm_processing.imageprocessing.flatfield import _camera_corrected_images - - for value in (110, 120): - raw = np.full((3, 4, 1900), value, dtype=np.uint16) - raw[:, 0, 0] = 90 - actual = _camera_corrected_images( - tensorstore_dataset(raw), - camera_offset=100.0, - camera_conversion=0.5, - apply_stage_scan_gain=True, +@pytest.mark.parametrize("level", (100, 110), ids=("empty", "one-image")) +def test_empty_or_insufficient_samples_are_handled_before_fitting(level): + """Empty channels use unity; a single informative image cannot constrain a fit.""" + raw = np.full((1, 1, 1, 1, 5, 7), level, np.uint16) + if level == 100: + np.testing.assert_array_equal( + estimate_illuminations(raw, 100, 1), np.ones((1, 5, 7)) ) - assert actual.dtype == np.float32 - np.testing.assert_array_equal(actual[:, 0, 0], 0) - np.testing.assert_array_equal(actual[:, 1:, :1046], (value - 100) * 0.5) - np.testing.assert_allclose(actual[..., 1079], (value - 100) * 0.5 / 0.717388) - np.testing.assert_array_equal(actual[..., 1104:], (value - 100) * 0.5) + else: + with pytest.raises(ValueError, match="at least two nonempty"): + estimate_illuminations(raw, 100, 1) @pytest.mark.unit -def test_single_tile_depth_fields_recover_known_illumination( - tensorstore_dataset, depth_illumination -): - """Fit separate depth fields from scan planes with the real BaSiC solver.""" - case = depth_illumination - estimated = estimate_illuminations( - tensorstore_dataset(case.raw[None, :, None]), - camera_offset=100.0, - camera_conversion=0.25, - stage_positions_zxy=np.asarray(((10, 20, 30), (0, 20, 30))), - ) - assert estimated.shape == (2, 1, case.height, case.width) - assert np.all(np.isfinite(estimated)) - assert np.all(estimated > 0) - for depth in range(2): - assert ( - np.corrcoef(estimated[depth, 0].ravel(), case.fields[depth].ravel())[0, 1] - > 0.95 - ) - calibrated = (case.raw[depth].astype(float) - 100) * 0.25 - corrected = calibrated / estimated[depth, 0] - before = np.mean(np.abs(calibrated - case.specimen[depth])) - after = np.mean(np.abs(corrected - case.specimen[depth])) - assert after < 0.35 * before +@pytest.mark.parametrize( + "option,value", + [ + ("camera_conversion", 0), + ("camera_offset", np.nan), + ("stage_positions_zxy", np.zeros((2, 3))), + ("signal_mask", np.ones((1, 1, 2))), + ], +) +def test_invalid_illumination_calibration_is_rejected(option, value): + """Reject inconsistent acquisition metadata before loading samples or fitting.""" + options = {"camera_offset": 100, "camera_conversion": 1, option: value} + with pytest.raises(ValueError): + estimate_illuminations(np.full((1, 1, 1, 3, 5, 7), 120, np.uint16), **options) @pytest.mark.unit -def test_flatfield_correction_recovers_multitile_multichannel_truth(tiled_illumination): - """Recover known rectangular illumination fields from a tiled scan.""" - case = tiled_illumination - estimated = estimate_illuminations( - case.datastore, - case.camera_offset, - case.camera_conversion, - stage_positions_zxy=case.stage_positions, - apply_stage_scan_gain=False, - signal_mask=None, - )[0] - - camera_corrected = ( - case.raw.astype(np.float32) - case.camera_offset - ) * case.camera_conversion - corrected = camera_corrected / estimated[np.newaxis, :, np.newaxis, :, :] - for channel in range(case.channels): - truth = case.specimen[:, channel] - before = camera_corrected[:, channel] - after = corrected[:, channel] - scale = np.median(truth, axis=(-2, -1)) / np.median( - after, - axis=(-2, -1), - ) - after = after * scale[..., np.newaxis, np.newaxis] - before_error = np.mean(np.abs(before - truth)) / np.mean(truth) - after_error = np.mean(np.abs(after - truth)) / np.mean(truth) - - assert after_error < 0.35 * before_error - assert ( - np.corrcoef( - estimated[channel].ravel(), - case.illuminations[channel].ravel(), - )[0, 1] - > 0.95 - ) - before_gain = np.median(before / truth, axis=(0, 1, 2)) - after_gain = np.median(after / truth, axis=(0, 1, 2)) - before_stripe_error = abs( - before_gain[case.detector_x] - - np.median( - np.concatenate( - ( - before_gain[case.detector_x - 12 : case.detector_x - 7], - before_gain[case.detector_x + 8 : case.detector_x + 13], - ) - ) - ) - ) - after_stripe_error = abs( - after_gain[case.detector_x] - - np.median( - np.concatenate( - ( - after_gain[case.detector_x - 12 : case.detector_x - 7], - after_gain[case.detector_x + 8 : case.detector_x + 13], - ) - ) - ) - ) - assert after_stripe_error < 0.4 * before_stripe_error, ( - f"channel {channel}: stripe error changed from " - f"{before_stripe_error:.6f} to {after_stripe_error:.6f}" +@pytest.mark.parametrize("level", (0, -1, np.nan)) +def test_nonpositive_or_nonfinite_fits_are_rejected(level): + """Prevent unusable fitted fields from entering residual calibration.""" + with pytest.raises(ValueError, match="invalid illumination field"): + _separable_residual_calibration( + np.full((5, 7), level, np.float32), np.ones((3, 5, 7), np.float32) ) diff --git a/tests/cpu/test_fusion_storage.py b/tests/cpu/test_fusion_storage.py new file mode 100644 index 0000000..bd2e623 --- /dev/null +++ b/tests/cpu/test_fusion_storage.py @@ -0,0 +1,370 @@ +"""Fusion writer units and registered maximum-projection CLI integration.""" + +import numpy as np +import pytest +from skimage.measure import block_reduce as block_reduce_cpu +from typer.testing import CliRunner +from yaozarrs import open_group, v05 + +from opm_processing.dataio.position_collection import ( + open_image_array, + open_position_collection, +) +from opm_processing.dataio.processing_state import ProcessingState +from opm_processing.fuse import app as fuse_app +from opm_processing.imageprocessing.maxtilefusion import MaxTileFusion +from opm_processing.imageprocessing.tilefusion import TileFusion + + +@pytest.mark.unit +def test_max_projection_depth_tiles_share_xy_footprint( + tmp_path, position_store_factory +) -> None: + """Depth tiles with fixed stage XY must fuse into a single tile footprint.""" + source = np.arange(64, dtype=np.uint16).reshape(1, 1, 1, 8, 8) + collection = position_store_factory( + np.stack((source, source), axis=1), + name="depth_phantom", + voxel_size_um=(0.23, 0.115, 0.115), + ) + fusion = MaxTileFusion( + ts_dataset=open_position_collection(collection.path).arrays, + tile_positions=((3939.54, -7456.06, 4207.16), (3800.09, -7456.06, 4207.16)), + output_path=tmp_path / "depth_max_z_fused.ome.zarr", + pixel_size=(0.23, 0.115, 0.115), + opm_angle_deg=30.0, + blend_pixels=(0, 0), + chunk_size=4, + ) + fusion.run() + + np.testing.assert_array_equal(fusion.tile_positions[0], fusion.tile_positions[1]) + written = open_image_array(tmp_path / "depth_max_z_fused.ome.zarr") + np.testing.assert_array_equal(written.read().result(), source) + + +@pytest.mark.unit +def test_max_projection_fusion_writes_centered_multiscales_and_offsets( + tmp_path, + position_store_factory, +) -> None: + """Persist every fused max level with rounded physical coordinates.""" + source = np.arange(64, dtype=np.uint16).reshape(1, 1, 1, 8, 8) + output_path = tmp_path / "max_z_fused.ome.zarr" + collection = position_store_factory( + source[:, None], + name="ramp_phantom", + voxel_size_um=(0.34567, 0.1164, 0.1184), + ) + fusion = MaxTileFusion( + ts_dataset=open_position_collection(collection.path).arrays, + tile_positions=((1.23456, 2.34567, 3.45678),), + output_path=output_path, + pixel_size=(0.34567, 0.1164, 0.1184), + spatial_offset_z_um=4.56789, + blend_pixels=(0, 0), + chunk_size=4, + reverse_stage_y=True, + reverse_stage_z=False, + ) + fusion.run() + + reopened = open_group(output_path) + metadata = reopened.ome_metadata() + assert isinstance(metadata, v05.Image) + datasets = metadata.multiscales[0].datasets + assert [dataset.path for dataset in datasets] == ["0", "1", "2", "3"] + base_scale = np.asarray(datasets[0].scale_transform.scale[-3:]) + assert datasets[0].translation_transform is not None + base_translation = np.asarray(datasets[0].translation_transform.translation[-3:]) + expected = source + for factor, dataset in zip((1, 2, 4, 8), datasets, strict=False): + assert dataset.scale_transform.scale == [ + 1.0, + 1.0, + 0.346, + round(0.116 * factor, 3), + round(0.118 * factor, 3), + ] + assert dataset.translation_transform is not None + assert dataset.translation_transform.translation == [ + 0.0, + 0.0, + 4.568, + -2.346, + 3.457, + ] + output_index = np.asarray([0, min(2, 7 // factor), min(3, 7 // factor)]) + output_coordinate = np.asarray( + dataset.translation_transform.translation[-3:] + ) + output_index * np.asarray(dataset.scale_transform.scale[-3:]) + source_index = output_index * np.asarray([1, factor, factor]) + np.testing.assert_allclose( + output_coordinate, + base_translation + source_index * base_scale, + ) + if factor > 1: + expected = source[..., ::factor, ::factor] + np.testing.assert_array_equal( + reopened[dataset.path].to_tensorstore().read().result(), + expected, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("downsample_method", "is_2d", "padded_shape", "dtype"), + ( + ("stride", False, (4, 8, 8), np.uint16), + ("block_mean", False, (4, 8, 8), np.uint16), + ("stride", True, (1, 8, 8), np.uint16), + ("stride", False, (2, 4, 4), np.float32), + ), + ids=( + "volume-stride", + "volume-block-mean", + "projection-stride", + "fractional-photons", + ), +) +def test_multiscale_storage_round_trip_matches_reference( + fusion_operator, + tmp_path, + downsample_method, + is_2d, + padded_shape, + dtype, +): + """Reopen exact pyramid data and verify its common physical-space center.""" + fusion = fusion_operator( + TileFusion, + padded_shape=padded_shape, + offset_um=(4.51234, -3.01234, 12.25123), + _pixel_size=(0.81234, 0.51234, 0.25123), + time_dim=1, + channels=1, + multiscale_factors=(2, 4), + _is_2d=is_2d, + chunk_y=4, + chunk_x=4, + multiscale_downsample=downsample_method, + _max_workers=2, + fusion_ram_fraction=0.4, + max_in_flight_writes=2, + _input_chunk_zyx=(2, 4, 4), + output_dtype=np.dtype(dtype), + ) + + path = tmp_path / "multiscale.ome.zarr" + scale0, fusion.write_block_shape = fusion._prepare_fused_image(path) + source_shape = (1, 1, *padded_shape) + source = np.arange(np.prod(source_shape), dtype=dtype).reshape(source_shape) + if dtype == np.float32: + source = source / 4 + np.float32(0.25) + scale0.write(source).result() + fusion._write_multiscales() + + reopened = open_group(path) + metadata = reopened.ome_metadata() + assert metadata is not None + datasets = metadata.multiscales[0].datasets + assert [dataset.path for dataset in datasets] == ["0", "1", "2"] + + expected = source + rounded_offset = np.asarray([round(float(value), 3) for value in fusion.offset_um]) + rounded_pixel_size = np.asarray( + [round(float(value), 3) for value in fusion._pixel_size] + ) + base_center = ( + rounded_offset + + (np.asarray(padded_shape, dtype=np.float64) - 1.0) * rounded_pixel_size / 2.0 + ) + for absolute_factor, dataset in zip((1, 2, 4), datasets, strict=False): + z_factor = 1 if is_2d else absolute_factor + expected_scale = np.asarray( + [ + round(float(value), 3) + for value in ( + 1.0, + 1.0, + rounded_pixel_size[0] * z_factor, + rounded_pixel_size[1] * absolute_factor, + rounded_pixel_size[2] * absolute_factor, + ) + ] + ) + center_shift = 0.5 if downsample_method == "block_mean" else 0.0 + expected_translation = np.asarray( + [ + round(float(value), 3) + for value in ( + 0.0, + 0.0, + rounded_offset[0] + + center_shift * (z_factor - 1) * rounded_pixel_size[0], + rounded_offset[1] + + center_shift * (absolute_factor - 1) * rounded_pixel_size[1], + rounded_offset[2] + + center_shift * (absolute_factor - 1) * rounded_pixel_size[2], + ) + ] + ) + np.testing.assert_allclose( + dataset.scale_transform.scale, + expected_scale, + ) + assert dataset.translation_transform is not None + np.testing.assert_allclose( + dataset.translation_transform.translation, + expected_translation, + ) + reopened_array = reopened[dataset.path].to_tensorstore() + assert np.dtype(reopened_array.dtype.numpy_dtype) == dtype + + if downsample_method == "stride": + # A pyramid index must have exactly the same physical coordinate + # as the source voxel selected at index * absolute_factor. + output_index = np.asarray( + [ + min(1, int(reopened_array.shape[-3]) - 1), + min(2, int(reopened_array.shape[-2]) - 1), + min(3, int(reopened_array.shape[-1]) - 1), + ], + dtype=np.float64, + ) + source_index = output_index * np.asarray( + [z_factor, absolute_factor, absolute_factor], + dtype=np.float64, + ) + output_coordinate = np.asarray( + dataset.translation_transform.translation[-3:] + ) + output_index * np.asarray(dataset.scale_transform.scale[-3:]) + source_coordinate = rounded_offset + source_index * rounded_pixel_size + np.testing.assert_allclose( + output_coordinate, + source_coordinate, + ) + + spatial_shape = np.asarray(reopened_array.shape[-3:], dtype=np.float64) + physical_center = ( + expected_translation[-3:] + + (spatial_shape - 1.0) * expected_scale[-3:] / 2.0 + ) + if downsample_method == "block_mean": + np.testing.assert_allclose(physical_center, base_center, atol=0.002) + + if dataset.path == "0": + np.testing.assert_array_equal( + reopened_array.read().result(), + expected, + ) + continue + + z_relative_factor = 1 if is_2d else 2 + if downsample_method == "stride": + expected = expected[..., ::z_relative_factor, ::2, ::2] + else: + expected = block_reduce_cpu( + expected, + block_size=(1, 1, z_relative_factor, 2, 2), + func=np.mean, + ).astype(dtype) + np.testing.assert_array_equal( + reopened_array.read().result(), + expected, + ) + + assert not any(key.startswith("opm_") for key in reopened.attrs) + + +@pytest.mark.integration +def test_regenerate_max_z_flag_preserves_multiscale_pyramid_round_trip( + registered_projection_case, +) -> None: + """All pyramid levels include photons from every full-resolution Z plane.""" + case = registered_projection_case + result = CliRunner().invoke( + fuse_app, + [str(case.raw_path), "--regenerate-max-z", "--max-workers", "2"], + ) + assert result.exit_code == 0, result.output + + reopened = open_group(case.output_path) + metadata = reopened.ome_metadata() + assert isinstance(metadata, v05.Image) + datasets = metadata.multiscales[0].datasets + assert [dataset.path for dataset in datasets] == ["0", "1"] + projection = case.truth.max(axis=2, keepdims=True) + for level, dataset in enumerate(datasets): + actual = reopened[dataset.path].to_tensorstore().read().result() + factor = 2**level + expected = projection[..., ::factor, ::factor] + if case.downsample == "block_mean": + expected = np.rint( + block_reduce_cpu( + projection, + block_size=(1, 1, 1, factor, factor), + func=np.mean, + ) + ).astype(case.truth.dtype) + np.testing.assert_array_equal(actual, expected) + expected_scale = [ + case.scales[level][0], + case.scales[level][1], + *(round(value, 3) for value in case.scales[level][2:]), + ] + np.testing.assert_allclose(dataset.scale_transform.scale, expected_scale) + assert dataset.translation_transform is not None + expected_translation = [ + case.translations[level][0], + case.translations[level][1], + *(round(value, 3) for value in case.translations[level][2:]), + ] + expected_translation[2] = ( + case.translations[0][2] + + 0.5 * (case.truth.shape[2] - 1) * case.scales[0][2] + ) + expected_translation[2:] = [ + round(value, 3) for value in expected_translation[2:] + ] + np.testing.assert_allclose( + dataset.translation_transform.translation, + expected_translation, + ) + assert not any(key.startswith("opm_") for key in reopened.attrs) + state = ProcessingState.read(case.state_path) + assert ( + state.registered_output_for_max_projection(case.output_path) + == case.processed_path + ) + + +@pytest.mark.unit +def test_expanded_cache_uses_initial_settings_and_restores_final_limits( + tmp_path, processing_state, retry_fusion +): + """Restore expanded limits from a cache keyed by the original settings.""" + fusion = retry_fusion + fusion.processing_state = processing_state + fusion.data = tmp_path / "sample_deskewed.ome.zarr" + processing_state.initialize_run(fusion.data, configuration={}, overwrite=True) + initial = fusion._registration_cache_settings() + fusion._rejected_registration_links = {(0, 1): ((15, -284, 0), (13, 198, 33), 0.9)} + assert fusion._expand_registration_limits() + fusion._registration_retry_count = 1 + fusion.pairwise_metrics = {(0, 1): (15, -284, 0, 0.9)} + assert fusion._registration_cache_settings() == initial + fusion.save_pairwise_metrics() + saved = processing_state.path.read_bytes() + final_pairs = fusion._pair_registration_limits.copy() + fusion.max_registration_shift_zyx = fusion._initial_registration_shift_zyx + fusion._pair_registration_limits = fusion._initial_pair_registration_limits.copy() + fusion.pairwise_metrics = {} + fusion.register_with_retries() + assert fusion.max_registration_shift_zyx == (17, 299, 33) + assert fusion._pair_registration_limits == final_pairs + assert fusion._registration_retry_count == 1 + assert processing_state.path.read_bytes() == saved + fusion.registration_retries = 0 + with pytest.raises(ValueError, match="settings do not match"): + fusion.load_pairwise_metrics() diff --git a/tests/cpu/test_illumination_processing.py b/tests/cpu/test_illumination_processing.py new file mode 100644 index 0000000..91d196c --- /dev/null +++ b/tests/cpu/test_illumination_processing.py @@ -0,0 +1,328 @@ +"""Illumination function units and simulated processing CLI integration.""" + +import importlib +from pathlib import Path + +import numpy as np +import pytest +import tensorstore as ts +from scipy.special import kl_div +from typer.testing import CliRunner + +from opm_processing.dataio.live import read_lifecycle_event +from opm_processing.dataio.position_collection import open_position_collection +from opm_processing.imageprocessing.flatfield import estimate_illuminations +from opm_processing.process import app + + +@pytest.mark.integration +@pytest.mark.parametrize("output_flatfield", (False, True)) +def test_processing_uses_existing_flatfield_with_separate_output( + opm_v2_projection_zarr, + tmp_path: Path, + output_flatfield: bool, +) -> None: + """Verify source reuse and output precedence through saved pixel values.""" + from opm_processing.dataio.acquisition import acquisition_stem + from opm_processing.dataio.position_collection import open_position_collection + from opm_processing.process import process, write_flatfield + + acquisition = opm_v2_projection_zarr + output_dir = tmp_path / "processed" + output_dir.mkdir() + filename = f"{acquisition_stem(acquisition.path)}_flatfield.ome.tif" + field_shape = (1, acquisition.raw_data.shape[2], *acquisition.raw_data.shape[-2:]) + source_field = np.full(field_shape, 2.0, dtype=np.float32) + selected_field = source_field + write_flatfield( + acquisition.path.parent / filename, source_field, acquisition.pixel_size_um + ) + if output_flatfield: + selected_field = np.full(field_shape, 1.25, dtype=np.float32) + write_flatfield( + output_dir / filename, selected_field, acquisition.pixel_size_um + ) + + process( + root_path=acquisition.path, + output=output_dir, + flatfield_correction=True, + deconvolve=False, + save_float32=True, + write_fused_max_projection_tiff=False, + ) + saved = ( + open_position_collection( + output_dir / f"{acquisition_stem(acquisition.path)}_projection.ome.zarr" + ) + .arrays[0] + .read() + .result() + ) + calibrated = ( + acquisition.raw_data[:, 0].astype(np.float32) - acquisition.camera_offset + ) * acquisition.camera_conversion + expected = (calibrated / selected_field[0])[:, :, np.newaxis] + np.testing.assert_allclose(saved, expected, rtol=1e-6) + + +@pytest.mark.unit +@pytest.mark.parametrize("channels", [1, 3]) +def test_shared_flatfield_round_trips_and_is_reused_at_multiple_depths( + tmp_path: Path, channels +): + """The saved maximum field is reused unchanged for every stage depth.""" + from opm_processing.process import ( + load_or_estimate_flatfield, + read_current_flatfield, + write_flatfield, + ) + + path = tmp_path / "flatfield.ome.tif" + flatfields = np.linspace(0.5, 1.5, channels * 8 * 12, dtype=np.float32).reshape( + 1, channels, 8, 12 + ) + write_flatfield(path, flatfields, 0.115) + actual = read_current_flatfield(path, flatfields.shape) + assert actual is not None + np.testing.assert_array_equal(actual, flatfields) + actual = load_or_estimate_flatfield( + path, + ts.array(np.zeros((1, 2, channels, 4, 8, 12), dtype=np.uint16)), + 100, + 0.25, + 0.115, + np.asarray(((10, 20, 30), (0, 20, 30))), + detector_gain_x=None, + signal_threshold=None, + minimum_signal_fraction=0.01, + signal_mask=None, + ) + np.testing.assert_array_equal(actual, flatfields) + + +@pytest.mark.unit +def test_old_per_depth_flatfield_is_not_reused(tmp_path): + """Replace obsolete depth-specific fields with the shared-depth estimator.""" + from tifffile import imwrite + + from opm_processing.process import read_current_flatfield + + path = tmp_path / "old-flatfield.ome.tif" + imwrite( + path, + np.ones((2, 1, 8, 12), dtype=np.float32), + photometric="minisblack", + metadata={"axes": "ZCYX"}, + software="opm-processing/basicpy-stage-depth-camera-contract-v10", + ) + assert read_current_flatfield(path, (1, 1, 8, 12)) is None + + +@pytest.mark.unit +@pytest.mark.parametrize("scan_planes", [1, 4]) +def test_processing_applies_the_same_field_at_every_depth( + tmp_path, constant_illumination_acquisition, scan_planes +): + """Calibrate every channel and depth against analytically known photons.""" + from opm_processing.imageprocessing.opmtools import orthogonal_deskew + from opm_processing.process import process, write_flatfield + + case = constant_illumination_acquisition + acquisition = case.dataset + write_flatfield( + tmp_path / "sample_flatfield.ome.tif", + case.fields, + acquisition.metadata.pixel_size_um, + ) + process( + root_path=acquisition.path, + output=tmp_path / "corrected", + flatfield_correction=True, + deconvolve=False, + save_float32=True, + z_downsample_level=1, + max_projection=False, + create_fused_max_projection=False, + write_fused_max_projection_tiff=False, + ) + suffix = "2d" if scan_planes == 1 else "deskewed" + corrected = open_position_collection( + tmp_path / "corrected" / f"sample_{suffix}.ome.zarr" + ) + for position, array in enumerate(corrected.arrays): + for time, channel in np.ndindex(2, 2): + value = ( + case.photons[time, position, channel] / case.fields[0, channel, 0, 0] + ) + expected = ( + np.full((1, 8, 12), value, np.float32) + if scan_planes == 1 + else orthogonal_deskew( + np.full((scan_planes, 8, 12), value, dtype=np.float32), + distance=acquisition.metadata.scan_axis_step_um, + pixel_size=acquisition.metadata.pixel_size_um, + theta=acquisition.metadata.angle_deg, + downsample_factor=1, + ) + ) + np.testing.assert_allclose( + array[time, channel].read().result(), + expected, + rtol=4 * np.finfo(np.float32).eps, + atol=0, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "illumination_observations", ("maximum", "masked", "tiles"), indirect=True +) +def test_real_estimator_predicts_known_detector_illumination(illumination_observations): + """Known illumination predicts calibration photons better than unit sensitivity.""" + case = illumination_observations + actual = estimate_illuminations( + case.datastore, + case.offset, + case.conversion, + case.coordinates, + signal_mask=case.mask, + )[0] + assert actual.shape == case.expected.shape + assert np.all(np.isfinite(actual) & (actual > 0)) + for expected, estimated in zip(case.expected, actual, strict=True): + if np.all(expected == 1): + np.testing.assert_array_equal(estimated, 1) + continue + if case.mask is not None: + # An excluded depth must not floor known dim detector regions to unity. + assert np.any(estimated[expected < 1] < 1) + truth = case.source * expected + prediction = case.source * estimated + assert (2 * kl_div(truth, prediction)).sum() < ( + 2 * kl_div(truth, case.source) + ).sum() + if case.detector_x is not None: + stripe = np.s_[:, case.detector_x - 3 : case.detector_x + 4] + assert (2 * kl_div(truth[stripe], prediction[stripe])).sum() < ( + 2 * kl_div(truth[stripe], case.source) + ).sum() + + +@pytest.mark.unit +@pytest.mark.parametrize("level", (0, -1, np.nan)) +def test_invalid_cached_fields_are_not_reused(tmp_path, level): + """Reject invalid fields at write time and refresh invalid existing caches.""" + from tifffile import imwrite + + from opm_processing.process import ( + FLATFIELD_SOFTWARE, + read_current_flatfield, + write_flatfield, + ) + + path = tmp_path / "invalid.ome.tif" + field = np.full((1, 1, 5, 7), level, np.float32) + with pytest.raises(ValueError, match="finite and positive"): + write_flatfield(path, field, 0.115) + assert not path.exists() + imwrite( + path, + field, + photometric="minisblack", + metadata={"axes": "ZCYX"}, + software=FLATFIELD_SOFTWARE, + ) + assert read_current_flatfield(path, field.shape) is None + + +@pytest.mark.integration +def test_live_wait_defers_processing_and_selects_flatfield(live_wait_case, monkeypatch): + """Wait for both completion and chunks, then reuse or estimate illumination.""" + processing = importlib.import_module("opm_processing.process") + + case = live_wait_case + estimates = [] + estimate_in_worker = processing.call_estimate_illuminations + + def estimate_after_completion(store, offset, conversion, positions, gain, mask): + """Estimate illumination only after the recorded acquisition completes.""" + assert case.kind == "estimate" + assert ( + read_lifecycle_event(case.dataset.log, case.dataset.manifest.acquisition_id) + == "completed" + ) + np.testing.assert_array_equal(store.read().result(), case.raw) + estimates.append(True) + return estimate_in_worker( + store, + offset, + conversion, + positions, + gain, + mask, + ) + + monkeypatch.setattr( + processing, "call_estimate_illuminations", estimate_after_completion + ) + args = [ + str(case.dataset.path.parent), + "--live-wait", + "--save-float32", + "--output", + str(case.destination), + "--no-max-projection", + "--no-create-fused-max-projection", + "--z-downsample-level", + "1", + ] + if case.kind != "pending-chunks": + args.append("--flatfield-correction") + result = CliRunner().invoke(app, args) + assert result.exit_code == 0, result.exception + assert ( + case.sleeps + == [30.0] * {"estimate": 2, "pending-chunks": 1, "cached": 0}[case.kind] + ) + assert estimates == ([True] if case.kind == "estimate" else []) + if case.kind == "pending-chunks": + assert not case.field_path.exists() + else: + field = processing.read_current_flatfield( + case.field_path, (1, 1, *case.raw.shape[-2:]) + ) + assert field is not None + assert np.all(np.isfinite(field) & (field > 0)) + if case.kind == "cached": + np.testing.assert_array_equal(field, 2) + assert ( + "starting offline processing" + if case.kind == "estimate" + else "Using existing flatfield" + ) in result.output + actual = ( + open_position_collection(case.destination / "sample_deskewed.ome.zarr") + .arrays[0][0, 0] + .read() + .result() + ) + expected = case.expected + if case.kind == "estimate": + from opm_processing.imageprocessing.opmtools import orthogonal_deskew + + expected = orthogonal_deskew( + np.maximum(case.raw[0, 0, 0, ::-1].astype(np.float32) - 100, 0) + * 0.24 + / field[0, 0], + theta=30, + distance=0.4, + pixel_size=0.115, + downsample_factor=1, + ) + np.testing.assert_allclose( + actual, + expected, + rtol=4 * np.finfo(np.float32).eps, + atol=1e-5 if case.kind == "estimate" else 0, + ) diff --git a/tests/cpu/test_live_processing.py b/tests/cpu/test_live_processing.py index c5ff514..815feed 100644 --- a/tests/cpu/test_live_processing.py +++ b/tests/cpu/test_live_processing.py @@ -13,7 +13,6 @@ inspect_acquisition, ) from opm_processing.dataio.live import ( - LiveManifest, ZarrTileReadiness, iter_live_tiles, read_lifecycle_event, @@ -29,10 +28,58 @@ from opm_processing.process import ( app, is_empty_tile, + open_live_acquisition, process, ) -from tests.fixtures.live import manifest_document -from tests.reference.deskew import constant_deskew +from tests.fixtures.live import manifest_document, write_manifest + + +@pytest.mark.unit +@pytest.mark.parametrize("event", ["canceled", "errored"]) +def test_live_wait_stops_before_processing_on_acquisition_failure( + tmp_path, live_acquisition_factory, event +): + """Abort live waiting before processing a canceled or failed acquisition.""" + dataset = live_acquisition_factory(completed=False) + dataset.illumination_path.unlink() + dataset.log.write_text( + json.dumps({"event": event, "acquisition_id": dataset.manifest.acquisition_id}) + + "\n", + encoding="utf-8", + ) + destination = tmp_path / "processed" + result = CliRunner().invoke( + app, + [ + str(tmp_path), + "--live-wait", + "--flatfield-correction", + "--output", + str(destination), + ], + ) + assert isinstance(result.exception, RuntimeError) + assert event in str(result.exception) + assert not destination.exists() + + +@pytest.mark.unit +def test_live_wait_accepts_single_position_root_image( + tmp_path, current_single_position_mirror_timelapse +): + """Discover completed live data stored directly as a root NGFF image.""" + path = current_single_position_mirror_timelapse + original = inspect_acquisition(path) + document = manifest_document(path, timepoints=3) + document["index_sizes"] = {"t": 3, "p": 1, "c": 1, "z": 4} + document["channels"] = document["channels"][:1] + document["mode"] = "mirror" + manifest = write_manifest(path, document=document) + acquisition, actual_manifest, _log = open_live_acquisition(tmp_path) + assert acquisition.path == path + assert acquisition.shape == original.shape + assert actual_manifest == manifest + assert ZarrTileReadiness(acquisition).ready_tiles() == {(0, 0), (1, 0), (2, 0)} @pytest.mark.unit @@ -79,77 +126,41 @@ def test_chunk_presence_marks_only_a_fully_written_tile_ready( @pytest.mark.unit -def test_live_iterator_polls_then_stops_at_completed_log(tmp_path) -> None: - """Poll only while caught up and stop after all completed tiles are yielded.""" - data_path = tmp_path / "iterator.ome.zarr" - manifest_path = tmp_path / "iterator.manifest.json" - manifest_path.write_text( - json.dumps(manifest_document(data_path, timepoints=1)), encoding="utf-8" +@pytest.mark.parametrize("resume", [False, True], ids=["poll", "resume"]) +def test_live_iterator_waits_for_unprocessed_tiles_then_stops( + live_acquisition_factory, resume +): + """Observe real chunk arrivals, skip durable tiles and stop on completion.""" + dataset = live_acquisition_factory( + shape=(1, 2 if resume else 1, 2, 3, 4, 5), + completed=True, ) - manifest = LiveManifest.read(manifest_path) - log_path = tmp_path / "iterator.log.jsonl" - log_path.write_text( - json.dumps({"event": "completed", "acquisition_id": manifest.acquisition_id}) - + "\n", - encoding="utf-8", + readiness = ZarrTileReadiness( + dataset.manifest.apply(inspect_acquisition(dataset.path)) ) - - class Readiness: - calls = 0 - - def ready_tiles(self): - self.calls += 1 - return set() if self.calls == 1 else {(0, 0)} - sleeps = [] - tiles = list( - iter_live_tiles( - Readiness(), - manifest, - log_path, - poll_interval=30.0, - sleeper=sleeps.append, - ) - ) - - assert tiles == [(0, 0)] - assert sleeps == [30.0] - - -@pytest.mark.unit -def test_live_iterator_skips_tiles_completed_before_restart(tmp_path) -> None: - """Seed iterator state with processed tiles discovered from existing output.""" - data_path = tmp_path / "resume.ome.zarr" - manifest_path = tmp_path / "resume.manifest.json" - document = manifest_document(data_path, timepoints=1) - document["index_sizes"]["p"] = 2 - document["stage_positions_zxy"] = [ - [30.0, 100.0, 200.0], - [30.0, 100.0, 202.0], - ] - manifest_path.write_text(json.dumps(document), encoding="utf-8") - manifest = LiveManifest.read(manifest_path) - log_path = tmp_path / "resume.log.jsonl" - log_path.write_text( - json.dumps({"event": "completed", "acquisition_id": manifest.acquisition_id}) - + "\n", - encoding="utf-8", - ) - class Readiness: - def ready_tiles(self): - return {(0, 0), (0, 1)} + def acquire(seconds=None): + """Publish native TensorStore chunks when the iterator polls acquisition.""" + if seconds is not None: + sleeps.append(seconds) + for array in dataset.collection.arrays: + array.write(np.ones(array.shape, np.uint16)).result() + if resume: + acquire() tiles = list( iter_live_tiles( - Readiness(), - manifest, - log_path, - completed_tiles={(0, 0)}, + readiness, + dataset.manifest, + dataset.log, + completed_tiles={(0, 0)} if resume else set(), + poll_interval=30.0, + sleeper=acquire, ) ) - - assert tiles == [(0, 1)] + assert tiles == [(0, 1)] if resume else tiles == [(0, 0)] + assert sleeps == ([] if resume else [30.0]) @pytest.mark.unit @@ -206,15 +217,22 @@ def test_empty_tile_detection_uses_global_occupancy() -> None: ((False, np.dtype(np.uint16)), (True, np.dtype(np.float32))), ) def test_live_process_matches_direct_deskew_without_estimating( - tmp_path, live_acquisition_factory, save_float32, expected_dtype + tmp_path, live_acquisition_factory, save_float32, expected_dtype, simulated_camera ) -> None: """Process a complete live acquisition using only the supplied illumination.""" - raw = np.full((1, 1, 1, 4, 8, 5), 137, np.uint16) + observed = simulated_camera((4, 8, 5), conversion=0.24).raw + raw = observed[None, None, None, ::-1].copy() dataset = live_acquisition_factory( raw, name="live", illumination=np.full((1, 8, 5), 2.0, np.float32) ) illumination_path = dataset.illumination_path - expected_float = constant_deskew((4, 8, 5), 37 * 0.24 / 2, downsample_factor=1) + expected_float = orthogonal_deskew( + np.maximum(observed.astype(np.float32) - 100, 0) * 0.24 / 2, + theta=30.0, + distance=0.4, + pixel_size=0.115, + downsample_factor=1, + ) args = [ str(tmp_path), "--live", @@ -247,7 +265,7 @@ def test_live_process_matches_direct_deskew_without_estimating( assert not any(key.startswith("opm_") for key in output.attributes) -@pytest.mark.integration +@pytest.mark.unit def test_live_occupancy_filter_preserves_sample_signal_and_writes_zero( tmp_path, live_acquisition_factory, @@ -256,7 +274,7 @@ def test_live_occupancy_filter_preserves_sample_signal_and_writes_zero( raw = np.full((2, 4, 8, 5), 100, dtype=np.uint16) raw[0, :, 0, 0] = 200 # One persistent detector hot pixel. raw[0, 0, 0, 1:5] = 104 # Sub-threshold signal before illumination correction. - raw[1, 2] = 200 # A sample-like occupied detector frame. + raw[1] = 200 # A uniformly occupied fluorescent volume. illumination = np.ones((2, 8, 5), dtype=np.float32) illumination[0] = ( 0.1 # Amplifying detector artifacts must not rescue an empty channel. @@ -284,7 +302,8 @@ def test_live_occupancy_filter_preserves_sample_signal_and_writes_zero( assert np.count_nonzero(max_output.arrays[0][0, 0].read().result()) == 0 assert np.count_nonzero(output.arrays[0][0, 1].read().result()) > 0 expected = orthogonal_deskew( - (np.flip(raw[1], axis=0).astype(np.float32) - 100) * np.float32(0.24), + np.full((4, 8, 5), 100 * 0.24, dtype=np.float32), + theta=30.0, distance=0.4, pixel_size=0.115, downsample_factor=1, @@ -304,13 +323,15 @@ def test_live_occupancy_filter_preserves_sample_signal_and_writes_zero( assert not any(key.startswith("opm_") for key in output.attributes) -@pytest.mark.integration +@pytest.mark.unit +@pytest.mark.parametrize("action", ["resume", "overwrite"]) def test_offline_resume_skips_durably_completed_tile_and_default_overwrites( tmp_path, monkeypatch, acquisition_factory, + action, ) -> None: - """Checkpoint after writes, resume the next tile, and overwrite by default.""" + """Resume preserves completed pixels; explicit overwrite recomputes them.""" process_module = importlib.import_module("opm_processing.process") pixels = np.empty((1, 2, 1, 4, 8, 5), np.uint16) pixels[:, 0] = 110 @@ -327,6 +348,7 @@ def test_offline_resume_skips_durably_completed_tile_and_default_overwrites( completion_count = 0 def interrupt_after_first(self, *args, **kwargs): + """Interrupt after a durable write to exercise restart behavior.""" nonlocal completion_count real_complete_tile(self, *args, **kwargs) completion_count += 1 @@ -353,9 +375,9 @@ def interrupt_after_first(self, *args, **kwargs): completed_tile = interrupted.arrays[0][0, 0].read().result() np.testing.assert_array_equal( completed_tile, - constant_deskew( - (4, 8, 5), - 10, + orthogonal_deskew( + np.full((4, 8, 5), 10, dtype=np.float32), + theta=30.0, distance=0.4, pixel_size=0.115, downsample_factor=1, @@ -368,25 +390,39 @@ def interrupt_after_first(self, *args, **kwargs): "complete_tile", real_complete_tile, ) - process_module.process_skewed( - data_path, - acquisition=metadata, - output_dir=tmp_path, - max_projection=False, - create_fused_max_projection=False, - z_downsample_level=1, - resume=True, + result = CliRunner().invoke( + app, + [ + str(data_path), + "--output", + str(tmp_path), + "--no-max-projection", + "--no-create-fused-max-projection", + "--z-downsample-level", + "1", + ], + input=f"{action}\n", ) + assert result.exit_code == 0, result.exception + assert "Resume / Overwrite / Cancel" in result.output resumed = open_position_collection(output_path) np.testing.assert_array_equal( resumed.arrays[0][0, 0].read().result(), - completed_tile, + completed_tile + if action == "resume" + else orthogonal_deskew( + np.full((4, 8, 5), 401, dtype=np.float32), + theta=30.0, + distance=0.4, + pixel_size=0.115, + downsample_factor=1, + ).astype(np.uint16), ) np.testing.assert_array_equal( resumed.arrays[1][0, 0].read().result(), - constant_deskew( - (4, 8, 5), - 20, + orthogonal_deskew( + np.full((4, 8, 5), 20, dtype=np.float32), + theta=30.0, distance=0.4, pixel_size=0.115, downsample_factor=1, @@ -404,9 +440,9 @@ def interrupt_after_first(self, *args, **kwargs): overwritten = open_position_collection(output_path) np.testing.assert_array_equal( overwritten.arrays[0][0, 0].read().result(), - constant_deskew( - (4, 8, 5), - 401, + orthogonal_deskew( + np.full((4, 8, 5), 401, dtype=np.float32), + theta=30.0, distance=0.4, pixel_size=0.115, downsample_factor=1, @@ -414,7 +450,7 @@ def interrupt_after_first(self, *args, **kwargs): ) -@pytest.mark.integration +@pytest.mark.unit def test_live_processing_resumes_completed_output_tiles( tmp_path, monkeypatch, live_acquisition_factory ) -> None: @@ -430,6 +466,7 @@ def test_live_processing_resumes_completed_output_tiles( real_complete = ProcessingState.complete_tile def interrupt_after_first(self, *args, **kwargs): + """Interrupt after a durable write to exercise restart behavior.""" real_complete(self, *args, **kwargs) # A live tile is durable only after both the volume and projection # checkpoints are committed. Interrupt after the second output. @@ -448,8 +485,9 @@ def interrupt_after_first(self, *args, **kwargs): first_output = open_position_collection(output_path) first_max_output = open_position_collection(max_output_path) - completed_before_restart = first_output.arrays[0].read().result() - completed_max_before_restart = first_max_output.arrays[0].read().result() + # The first tile is exactly the camera offset, hence zero calibrated photons. + np.testing.assert_array_equal(first_output.arrays[0].read().result(), 0) + np.testing.assert_array_equal(first_max_output.arrays[0].read().result(), 0) first_output.arrays[1][0, 0, 0, 0, 0].write(np.uint16(999)).result() dataset.collection.arrays[0].write(np.uint16(901)).result() @@ -466,16 +504,16 @@ def interrupt_after_first(self, *args, **kwargs): resumed_max_output = open_position_collection(max_output_path) np.testing.assert_array_equal( resumed_output.arrays[0].read().result(), - completed_before_restart, + 0, ) np.testing.assert_array_equal( resumed_max_output.arrays[0].read().result(), - completed_max_before_restart, + 0, ) assert np.all(resumed_output.arrays[1].read().result() != 999) - expected_tile = constant_deskew( - (4, 8, 5), - 11 * 0.24, + expected_tile = orthogonal_deskew( + np.full((4, 8, 5), 11 * 0.24, dtype=np.float32), + theta=30.0, distance=0.4, pixel_size=0.115, downsample_factor=1, diff --git a/tests/cpu/test_opm_simulation.py b/tests/cpu/test_opm_simulation.py index 485cbce..be65a11 100644 --- a/tests/cpu/test_opm_simulation.py +++ b/tests/cpu/test_opm_simulation.py @@ -1,16 +1,37 @@ -"""Unit and integration tests of physical fluorescence and OPM image formation.""" +"""Unit tests of physical fluorescence and OPM image formation.""" import numpy as np import pytest -from scripts.opm_simulation import ( - acquire_sphere, - camera_noise, - meridian_sphere, - pixel_average, + +from tests.simulation.objects import meridian_sphere + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize( + "radius,phase,z,expected", + [ + (0, 0, 0, 0.5), + (4, 0, 0, 1), + (4, np.pi / 100, 0, 0), + (4, np.pi / 200, 0, 0.5), + (4, 0, 0.05, 0), + ], ) +def test_siemens_target_matches_matlab_angular_pattern( + radius, phase, z, expected, simulation_parameters +): + """Check the flat core, angular extrema and finite sheet thickness analytically.""" + from tests.simulation.objects import siemens_star + + axes = tuple( + np.array([v]) for v in (z, radius * np.sin(phase), radius * np.cos(phase)) + ) + assert siemens_star( + axes, **simulation_parameters["specimens"]["siemens-star"]["features"] + ).item() == pytest.approx(expected, abs=2e-7) -@pytest.mark.unit @pytest.mark.parametrize( "xyz, expected", [ @@ -30,54 +51,3 @@ def test_tubes_have_defined_diameter_and_empty_meridian_gaps(xyz, expected): axes = tuple(np.array([v]) for v in xyz[::-1]) actual = meridian_sphere(axes, diameter_um=10, tube_diameter_um=1, meridians=12) assert actual.item() == expected - - -@pytest.mark.unit -def test_camera_pixel_averaging_has_known_area_response(): - """A quadratic irradiance field has an exact subpixel-quadrature average.""" - axes = tuple(np.arange(-10, 11) * 0.01 for _ in range(3)) - z, y, x = np.meshgrid(*axes, indexing="ij", sparse=True) - field = (10 + 0 * z + y**2 + 2 * x**2).astype(np.float32) - target = tuple(np.array([0.0]) for _ in range(3)) - point = pixel_average(field, axes, target, pitch_um=0.12, samples=1).item() - integrated = pixel_average(field, axes, target, pitch_um=0.12, samples=3).item() - expected = 10 + 3 * 0.12**2 * (1 - 1 / 3**2) / 12 - assert point == pytest.approx(10) - assert integrated == pytest.approx(expected, abs=2e-6) - - -@pytest.mark.unit -def test_camera_shot_and_read_noise_have_physical_moments(): - """Poisson variance is the expected count; read-noise variances add.""" - expectation = np.full(200_000, 102.0) # 100 signal + 2 background electrons. - observed = camera_noise(expectation, read_noise_e=1.5, seed=637) - assert observed.mean() == pytest.approx(102, abs=0.1) - assert observed.var() == pytest.approx(102 + 1.5**2, rel=0.015) - - -@pytest.mark.unit -@pytest.mark.parametrize("scan_step, max_error", [(0.2, 0.05), (0.8, 0.12)]) -def test_skewed_sphere_deskews_to_cartesian_microscope( - physical_sphere, scan_step, max_error -): - """Compare independent convolutions and absolute physical coordinates.""" - acquired = acquire_sphere(physical_sphere, scan_step_um=scan_step) - metrics = acquired["metrics"] - assert metrics["forward_routes"]["relative_l2"] < 0.03 - assert metrics["forward_routes"]["correlation"] > 0.999 - assert metrics["deskew_vs_normal"]["relative_l2"] < max_error - assert metrics["deskew_vs_normal"]["correlation"] > 0.99 - assert metrics["deskew_vs_normal"]["total_signal_ratio"] == pytest.approx( - 1, abs=0.01 - ) - axes = acquired["output_axes"] - center = tuple(np.argmin(np.abs(a)) for a in axes) - assert acquired["deskewed"][center] < 0.01 * acquired["deskewed"].max() - # No image alignment is allowed. The absolute physical centroid must stay - # at the center of the synthetic sphere, within one native camera pixel. - mass = acquired["deskewed"] - centroid = [ - np.dot(mass.sum(axis=tuple(j for j in range(3) if j != i)), a) / mass.sum() - for i, a in enumerate(axes) - ] - assert np.linalg.norm(centroid) < physical_sphere["params"]["pixel_size_um"] diff --git a/tests/cpu/test_opm_v2_dataset.py b/tests/cpu/test_opm_v2_dataset.py index 1195f8b..0acec6f 100644 --- a/tests/cpu/test_opm_v2_dataset.py +++ b/tests/cpu/test_opm_v2_dataset.py @@ -1,9 +1,8 @@ -"""Integration coverage for acquisitions produced by QI2lab/opm-v2.""" +"""Processing-component checks with controlled opm-v2 acquisition fixtures.""" import numpy as np import pytest from typer.testing import CliRunner -from yaozarrs import open_group from opm_processing.dataio.position_collection import ( open_position_collection, @@ -12,18 +11,24 @@ ProcessingState, processing_state_path, ) -from opm_processing.fuse import app as fuse_app from opm_processing.process import app as process_app from opm_processing.process import process +pytestmark = pytest.mark.integration + -@pytest.mark.integration def test_singleton_z_stage_mode_writes_2d_name_without_stage_fusion( tmp_path, acquisition_factory, + simulated_camera, ) -> None: """Keep dimensionality and acquisition mode distinct in output names.""" - raw = np.arange(40, dtype=np.uint16).reshape(2, 1, 1, 1, 4, 5) + 200 + raw = np.stack( + [ + simulated_camera((1, 4, 5), geometry="cartesian", exposure=1 + t / 4).raw + for t in range(2) + ] + )[:, None, None] dataset = acquisition_factory(raw, name="single_stage", mode="stage") path = dataset.path process( @@ -45,139 +50,50 @@ def test_singleton_z_stage_mode_writes_2d_name_without_stage_fusion( assert not any(key.startswith("opm_") for key in output.attributes) -@pytest.mark.integration -def test_process_runs_end_to_end_on_opm_v2_projection_zarr( - opm_v2_projection_zarr, +@pytest.mark.parametrize("save_float32", [False, True], ids=["uint16", "float32"]) +@pytest.mark.parametrize( + "separate_output", [False, True], ids=["source-dir", "output-dir"] +) +def test_projection_output_preserves_calibration_geometry_and_provenance( + opm_v2_projection_zarr, save_float32, separate_output ): - """Verify projection acquisitions process and fuse end to end.""" + """Output dtype and destination preserve calibrated pixels and physical metadata.""" fixture = opm_v2_projection_zarr - - process( - root_path=fixture.path, - deconvolve=False, - flatfield_correction=False, - write_fused_max_projection_tiff=False, - ) - - collection_path = fixture.path.parent / f"{fixture.path.stem}_projection.ome.zarr" - collection = open_position_collection(collection_path) + parent = fixture.path.parent + output_dir = parent / "nested/processed" if separate_output else parent + args = [str(fixture.path)] + if save_float32: + args.append("--save-float32") + if separate_output: + args.extend(["--output", str(output_dir)]) + result = CliRunner().invoke(process_app, args) + assert result.exit_code == 0, result.output + output_path = output_dir / f"{fixture.path.stem}_projection.ome.zarr" + collection = open_position_collection(output_path) assert collection.shape == (2, 1, 2, 1, 16, 18) assert collection.channel_names == fixture.channel_names + assert collection.voxel_size_um == (1.0, 0.115, 0.115) np.testing.assert_allclose( collection.stage_positions_zxy, fixture.stage_positions_zxy ) - - processed = collection.arrays[0].read().result() + state = ProcessingState.read(processing_state_path(output_dir, fixture.path.stem)) + assert state.document["source"]["path"] == str(fixture.path.resolve()) expected = np.clip( (fixture.raw_data[:, 0].astype(np.float32) - fixture.camera_offset) * fixture.camera_conversion, 0, np.iinfo(np.uint16).max, - ).astype(np.uint16) - np.testing.assert_array_equal(processed[:, :, 0], expected) - - assert not ( - fixture.path.parent / f"{fixture.path.stem}_stagefused.ome.zarr" - ).exists() - - -@pytest.mark.integration -def test_float32_output_is_preserved_for_single_position_projection( - opm_v2_projection_zarr, -) -> None: - """Keep calibrated fractions without creating redundant stage fusion.""" - fixture = opm_v2_projection_zarr - - process( - root_path=fixture.path, - deconvolve=False, - save_float32=True, - flatfield_correction=False, - write_fused_max_projection_tiff=False, ) + if not save_float32: + expected = expected.astype(np.uint16) + actual = collection.arrays[0].read().result() + assert actual.dtype == (np.float32 if save_float32 else np.uint16) + np.testing.assert_array_equal(actual[:, :, 0], expected) + assert not (parent / f"{fixture.path.stem}_stagefused.ome.zarr").exists() + if separate_output: + assert not (parent / output_path.name).exists() - collection_path = fixture.path.parent / f"{fixture.path.stem}_projection.ome.zarr" - processed = open_position_collection(collection_path).arrays[0].read().result() - - assert processed.dtype == np.float32 - expected = np.maximum( - (fixture.raw_data[:, 0].astype(np.float32) - fixture.camera_offset) - * fixture.camera_conversion, - 0, - ) - np.testing.assert_array_equal(processed[:, :, 0], expected) - assert not ( - fixture.path.parent / f"{fixture.path.stem}_stagefused.ome.zarr" - ).exists() - -@pytest.mark.integration -def test_output_directory_is_created_and_can_be_passed_to_fuse( - opm_v2_projection_zarr, -) -> None: - """Keep self-contained processing and fusion artifacts outside the source.""" - fixture = opm_v2_projection_zarr - output_dir = fixture.path.parent / "nested" / "processed" - - process_result = CliRunner().invoke( - process_app, - [str(fixture.path), "--output", str(output_dir)], - ) - assert process_result.exit_code == 0, process_result.output - assert output_dir.is_dir() - - processed_path = output_dir / f"{fixture.path.stem}_projection.ome.zarr" - assert processed_path.is_dir() - assert not ( - fixture.path.parent / f"{fixture.path.stem}_projection.ome.zarr" - ).exists() - - collection = open_position_collection(processed_path) - assert collection.channel_names == fixture.channel_names - np.testing.assert_allclose( - collection.stage_positions_zxy, - fixture.stage_positions_zxy, - ) - assert collection.voxel_size_um == (1.0, 0.115, 0.115) - state_path = processing_state_path(output_dir, fixture.path.stem) - state = ProcessingState.read(state_path) - assert state.document["source"]["path"] == str(fixture.path.resolve()) - - fuse_result = CliRunner().invoke( - fuse_app, - [str(output_dir), "--max-workers", "1"], - ) - assert fuse_result.exit_code == 0, fuse_result.output - assert (output_dir / f"{fixture.path.stem}_fused.ome.zarr").is_dir() - max_z_path = output_dir / f"{fixture.path.stem}_max_z_fused.ome.zarr" - assert max_z_path.is_dir() - assert not any(key.startswith("opm_") for key in open_group(max_z_path).attrs) - state = ProcessingState.read(state_path) - registration = state.registration(processed_path) - assert registration["fused_path"] == f"{fixture.path.stem}_fused.ome.zarr" - assert registration["max_projection_path"] == max_z_path.name - assert registration["tiles"] - assert not (output_dir / "stitching_metrics.json").exists() - processed_pixels = collection.arrays[0].read().result() - expected = np.maximum( - (fixture.raw_data[:, 0].astype(np.float32) - fixture.camera_offset) - * fixture.camera_conversion, - 0, - ).astype(np.uint16) - np.testing.assert_array_equal(processed_pixels[:, :, 0], expected) - fused = ( - open_group(output_dir / f"{fixture.path.stem}_fused.ome.zarr")["0"] - .to_tensorstore() - .read() - .result() - ) - projected = open_group(max_z_path)["0"].to_tensorstore().read().result() - # Single-position projection fusion must copy every source pixel. - np.testing.assert_array_equal(fused[..., :16, :18], processed_pixels) - np.testing.assert_array_equal(projected, fused.max(axis=2, keepdims=True)) - - -@pytest.mark.integration @pytest.mark.parametrize("resume", [False, True]) def test_projection_cli_resume_preserves_completed_pixels( opm_v2_projection_zarr, resume @@ -188,7 +104,15 @@ def test_projection_cli_resume_preserves_completed_pixels( first = CliRunner().invoke(process_app, args) assert first.exit_code == 0, first.output output_path = fixture.path.parent / f"{fixture.path.stem}_projection.ome.zarr" - before = open_position_collection(output_path).arrays[0].read().result() + expected = np.maximum( + (fixture.raw_data[:, 0].astype(np.float32) - fixture.camera_offset) + * fixture.camera_conversion, + 0, + ).astype(np.uint16)[:, :, None] + np.testing.assert_array_equal( + open_position_collection(output_path).arrays[0].read().result(), + expected, + ) raw = open_position_collection(fixture.path) for array in raw.arrays: array.write(np.uint16(900)).result() @@ -197,8 +121,8 @@ def test_projection_cli_resume_preserves_completed_pixels( second = CliRunner().invoke(process_app, args) assert second.exit_code == 0, second.output after = open_position_collection(output_path).arrays[0].read().result() - if resume: - np.testing.assert_array_equal(after, before) - else: - expected = np.uint16((900 - fixture.camera_offset) * fixture.camera_conversion) - np.testing.assert_array_equal(after, np.full_like(after, expected)) + if not resume: + expected.fill( + np.uint16((900 - fixture.camera_offset) * fixture.camera_conversion) + ) + np.testing.assert_array_equal(after, expected) diff --git a/tests/cpu/test_opmpsf.py b/tests/cpu/test_opmpsf.py new file mode 100644 index 0000000..8f47f41 --- /dev/null +++ b/tests/cpu/test_opmpsf.py @@ -0,0 +1,143 @@ +"""Generated optical kernels must retain their declared physical coordinates.""" + +from dataclasses import replace + +import numpy as np +import pytest + +from opm_processing.imageprocessing.opmpsf import ( + create_psf_silicone_100x, + generate_proj_psf, + generate_skewed_psf, + get_skewed_roi_size, +) + +pytestmark = pytest.mark.unit + + +def test_oblique_psf_refinement_against_independent_acquisition( + production_point_acquisition, +): + """Pixel integration converges towards a separately refined physical acquisition. + + Compare conditional photon probabilities, keeping the known total detected + dose fixed. Expected log-likelihood loss measures optical error in units of + distinguishability under shot noise; it is not a reconstruction error fit. + """ + from tests.simulation.metrics import poisson_log_ratio_moments + + case = production_point_acquisition + coarse, expectation = case.expectations + total = expectation.sum() + refinement = poisson_log_ratio_moments(expectation, coarse / coarse.sum() * total) + metrics = { + name: poisson_log_ratio_moments(expectation, kernel * total) + for name, kernel in case.kernels.items() + } + print({"scan_step_um": case.microscope.scan_step_um, "loss_variance": metrics}) + # At this declared exposure, reference refinement and the finest tested + # production quadrature are each smaller than one shot-noise standard deviation. + assert refinement[0] < np.sqrt(refinement[1]), refinement + assert metrics[3][0] < np.sqrt(metrics[3][1]), metrics + assert metrics[3][0] < metrics[2][0] < metrics[1][0], metrics + + +@pytest.mark.parametrize("oversampling", [1, 2, 3]) +@pytest.mark.parametrize("step", [0.4, 1.2]) +def test_oblique_psf_matches_physical_pixel_integration( + microscope_parameters, + production_psf_parameters, + production_psf_grid, + oversampling, + step, +): + """Integrate the same optical field using the shared physical camera model. + + The reference maps finite pixels through X=c+u, Y=s+(r+v)cos(theta), + Z=(r+v)sin(theta). This checks all axial positions and both camera dimensions + without replacing the optical model or constructing another forward model. + """ + from tests.simulation.forward_model import sensor_integral + + microscope = replace( + microscope_parameters, scan_step_um=step, pixel_samples=oversampling + ) + parameters = dict(production_psf_parameters, grid_oversampling=oversampling) + kernel = generate_skewed_psf( + microscope.wavelength_um, + microscope.pixel_size_um, + step, + microscope.angle_deg, + parameters=parameters, + ) + field, (dxy, dz, nxy, nz) = production_psf_grid[0] + axes = tuple( + (np.arange(n) - (n - 1) / 2) * spacing + for n, spacing in ((nz, dz), (nxy, dxy), (nxy, dxy)) + ) + expected = sensor_integral(field, axes, kernel.shape, microscope) + np.testing.assert_allclose( + kernel, + expected, + rtol=2e-11, + atol=1e-15, + ) + + +@pytest.mark.parametrize("geometry", ["cartesian", "projection"]) +def test_lateral_taper_preserves_optical_reflection_symmetry( + microscope_parameters, production_psf_parameters, geometry +): + """A centered isotropic pupil and radial taper have no preferred lateral side.""" + microscope = microscope_parameters + if geometry == "cartesian": + kernel = create_psf_silicone_100x( + microscope.pixel_size_um, + microscope.fine_spacing_um, + 15, + 3, + microscope.wavelength_um, + 0, + parameters=production_psf_parameters, + ) + else: + kernel = generate_proj_psf( + microscope.wavelength_um, + microscope.pixel_size_um, + parameters=production_psf_parameters, + ) + np.testing.assert_allclose(kernel, kernel[..., ::-1], rtol=2e-12, atol=1e-15) + np.testing.assert_allclose(kernel, kernel[..., ::-1, :], rtol=2e-12, atol=1e-15) + + +@pytest.mark.parametrize("step", [0.115, 0.4, 1.2]) +def test_skewed_support_rounds_a_dimensionless_scan_count(microscope_parameters, step): + """The minimum scan count is a ceiling of physical extent divided by spacing.""" + theta = np.deg2rad(microscope_parameters.angle_deg) + pitch = microscope_parameters.pixel_size_um + extent = (0.8, 0.7, 0.6) + scans, rows, columns = get_skewed_roi_size(extent, theta, pitch, step, False) + scan_extent = 0.5 * (rows + 1) * pitch * np.cos(theta) + extent[1] + assert (scans - 1) * step < scan_extent <= scans * step + assert rows * pitch * np.sin(theta) >= extent[0] + assert columns * pitch >= extent[2] + + +@pytest.mark.parametrize("missing", ["NA", "ng", "ni0"]) +def test_missing_lens_calibration_cannot_select_package_defaults( + production_psf_parameters, microscope_parameters, missing +): + """Theoretical optics require the acquisition's lens, sample and glass data.""" + parameters = dict( + production_psf_parameters, lens=dict(production_psf_parameters["lens"]) + ) + parameters["lens"].pop(missing) + microscope = microscope_parameters + with pytest.raises(ValueError, match=missing): + generate_skewed_psf( + microscope.wavelength_um, + microscope.pixel_size_um, + microscope.scan_step_um, + microscope.angle_deg, + parameters=parameters, + ) diff --git a/tests/cpu/test_opmtools.py b/tests/cpu/test_opmtools.py index a295e04..be19f25 100644 --- a/tests/cpu/test_opmtools.py +++ b/tests/cpu/test_opmtools.py @@ -1,59 +1,227 @@ -"""Test production deskew coordinates, photon scaling, and Z averaging.""" +"""Orthogonal interpolation units and tiled processing/fusion integration.""" import numpy as np import pytest +import tensorstore as ts -from opm_processing.imageprocessing import opmtools +from opm_processing.imageprocessing.opmtools import deskew_row, orthogonal_deskew + +pytestmark = pytest.mark.usefixtures("small_cpu_kernels") @pytest.mark.unit -@pytest.mark.parametrize("scan_step", [0.23, 0.4, 0.8]) -def test_deskew_preserves_fractional_photon_density(scan_step) -> None: - """An interior constant field scales with the raw-to-output voxel volume.""" - calibrated = np.full((4, 8, 5), 0.24, dtype=np.float32) +@pytest.mark.parametrize( + "deskew_field", + [(kind, step) for kind in ("constant", "y", "z") for step in (0.23, 0.4, 1.2)], + indirect=True, +) +def test_deskew_recovers_analytic_fluorescence(deskew_field, deskew_analytic_truth): + """Preserve linear laboratory fields with the 30-degree photon-volume Jacobian.""" + case = deskew_field + actual = orthogonal_deskew(case.data, **case.geometry, downsample_factor=1) + expected, interior = deskew_analytic_truth(actual.shape) + assert interior.any() + assert actual.dtype == np.float32 + np.testing.assert_allclose(actual[interior], expected[interior], rtol=8e-7, atol=0) + np.testing.assert_array_equal(actual[..., case.data.shape[2] :], 0) + - output = opmtools.orthogonal_deskew( - calibrated, - distance=scan_step, - pixel_size=0.115, - downsample_factor=1, +@pytest.mark.unit +@pytest.mark.parametrize("factor", [1, 2, 3, 20]) +@pytest.mark.parametrize("deskew_field", [("x", 0.4)], indirect=True) +def test_deskew_x_ramp_retains_partial_z_bins(deskew_field, factor, deskew_roundoff): + """Keep every Z bin and its full divisor, including a partly supported last bin.""" + case = deskew_field + actual = orthogonal_deskew(case.data, **case.geometry, downsample_factor=factor) + response = orthogonal_deskew( + np.full(case.data.shape, 1, dtype=np.float32), + **case.geometry, + downsample_factor=factor, ) + ramp = np.pad(case.data[0, 0], (0, response.shape[-1] - case.data.shape[-1])) + np.testing.assert_allclose(actual, response * ramp, **deskew_roundoff) + if factor in (3, 20): + assert np.any(actual[-1] > 0) + np.testing.assert_array_equal(actual[:, 0], 0) - assert output.dtype == np.float32 - # At 30 degrees, V_raw = scan_step * pixel_size**2 * sin(30), - # while V_output = pixel_size**3. No integer cast or arbitrary gain. - expected = 0.24 * 0.115 / (scan_step * 0.5) - np.testing.assert_allclose(output[1, 7, :5], expected, rtol=1e-6) - np.testing.assert_array_equal(output[..., 5:], 0) + +@pytest.mark.unit +@pytest.mark.parametrize("step_um", [0.23, 0.4, 1.2]) +@pytest.mark.parametrize("point", [(4, 20), (8, 30), (12, 40)]) +def test_source_rows_are_perpendicular_projections( + step_um, point, simulation_parameters +): + """Recover both feet and their distance weights from independent dot products.""" + microscope = simulation_parameters["microscope"] + theta = np.deg2rad(microscope["angle_deg"]) + step = step_um / microscope["pixel_size_um"] + tangent, normal = ( + np.array([np.sin(theta), np.cos(theta)]), + np.array([np.cos(theta), -np.sin(theta)]), + ) + valid, before, row0, row1, a, b, c, d = deskew_row( + *point, 48, 64, 32, step, np.tan(theta), np.sin(theta), np.cos(theta) + ) + assert valid + origins = np.column_stack((np.zeros(2), np.array([before, before + 1]) * step)) + projected = (point - origins) @ tangent + feet = origins + projected[:, None] * tangent + np.testing.assert_allclose((point - feet) @ tangent, 0, atol=2e-14) + weights = 1 - np.abs((point - feet) @ normal) / (step * np.sin(theta)) + rows = np.floor(projected).astype(int) + np.testing.assert_array_equal([row0, row1], rows) + fraction = projected - rows + expected = weights[:, None] * np.column_stack((fraction, 1 - fraction)) + np.testing.assert_allclose([[c, d], [a, b]], expected, atol=5e-8, rtol=0) + np.testing.assert_allclose(a + b + c + d, 1, rtol=1e-7) @pytest.mark.unit @pytest.mark.parametrize("factor", [1, 2, 3, 20]) -def test_deskew_x_ramp_and_z_averaging(factor): - """A uniform YZ field retains detector X and the existing averaging gain. - - Parameters - ---------- - factor : int - Laboratory Z bin size, including a bin larger than the volume height. - """ - ramp = np.arange(38, dtype=np.float32)[::2] + 10 - data = np.broadcast_to(ramp, (40, 33, 19)) - actual = opmtools.orthogonal_deskew(data, downsample_factor=factor) - fine = opmtools.orthogonal_deskew(data, downsample_factor=1) - nz = fine.shape[0] - expected = np.zeros_like(actual) - for z in range(actual.shape[0]): - expected[z] = fine[z * factor : min((z + 1) * factor, nz)].sum(axis=0) / factor - np.testing.assert_allclose(actual, expected, rtol=3e-7, atol=1e-6) - np.testing.assert_allclose( - fine[6, 45, :19], ramp * np.float32(2 * 0.115 / 0.4), rtol=3e-7 +@pytest.mark.parametrize( + "reverse, tile_shape", [(False, (2, 17, 5)), (True, (3, 23, 7))] +) +@pytest.mark.parametrize( + "deskew_field", + [("constant", 0.4, (48, 97, 41)), ("affine", 1.2, (48, 97, 41))], + indirect=True, +) +def test_tiled_deskew_matches_global_grid( + deskew_field, + deskew_output, + deskew_analytic_truth, + deskew_roundoff, + factor, + reverse, + tile_shape, +): + """Match whole-volume output exactly across Z, Y and X tile boundaries.""" + case = deskew_field + storage, output = deskew_output + options = dict( + **case.geometry, downsample_factor=factor, reverse_deskewed_z=reverse + ) + whole = orthogonal_deskew(case.data, **options) + tiled = orthogonal_deskew(case.data, **options, tile_shape=tile_shape, out=output) + assert tiled is output + np.testing.assert_array_equal(tiled, whole) + expected, interior = deskew_analytic_truth( + tiled.shape, factor=factor, reverse=reverse + ) + assert interior.any() + np.testing.assert_allclose(tiled[interior], expected[interior], **deskew_roundoff) + # Assert that the analytical comparison includes foreground on both sides + # of actual interior tile borders, rather than only a tile's central region. + for axis in (1, 2): + borders = np.arange(tile_shape[axis], tiled.shape[axis], tile_shape[axis]) + assert len(borders) >= 2 + assert np.any( + np.take(interior, borders - 1, axis=axis) + & np.take(interior, borders, axis=axis) + ) + assert np.isfinite(output).all() + assert np.isnan(storage[1::2]).all() + assert np.isnan(storage[:, (0, -1)]).all() + assert np.isnan(storage[:, :, (0, -1)]).all() + explicit = orthogonal_deskew( + case.data, **options, tile_shape=tile_shape, zero_initialized=False ) - np.testing.assert_array_equal(actual[..., 19:], 0) - np.testing.assert_array_equal(actual[:, 0, :], 0) - np.testing.assert_array_equal( - opmtools.orthogonal_deskew( - data, downsample_factor=factor, reverse_deskewed_z=True - ), - actual[::-1], + np.testing.assert_array_equal(explicit, whole) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "option,value", + [ + ("downsample_factor", 0), + ("downsample_factor", 1.5), + ("tile_shape", (1, 0, 2)), + ("tile_shape", (1, 2)), + ("pixel_size", 0), + ], +) +@pytest.mark.parametrize("deskew_field", [("constant", 0.4)], indirect=True) +def test_deskew_rejects_invalid_sampling_or_tiles(deskew_field, option, value): + """Reject nonphysical sampling or invalid tile extents before interpolation.""" + options = {**deskew_field.geometry, option: value} + with pytest.raises(ValueError): + orthogonal_deskew(deskew_field.data, **options) + + +@pytest.mark.unit +@pytest.mark.parametrize("factor", [1, 3]) +@pytest.mark.parametrize("deskew_field", [("constant", 0.4)], indirect=True) +def test_deskewed_stage_tiles_blend_without_boundary_seams( + deskew_mosaic, factor, block_fusion +): + """Preserve uniform fluorescence through real deskew wedges and block fusion.""" + case = deskew_mosaic + deskewed = orthogonal_deskew( + case.data, **case.geometry, downsample_factor=factor, tile_shape=(2, 17, 5) + ) + fusion = block_fusion( + [deskewed[None], deskewed[None]], + case.positions, + case.shape, + support=case.support, + x_profile=case.x_profile, + block_shape=[1, 1, 2, 13, 5], + fused_ts=ts.array(np.zeros((1, 1, *case.shape), np.float32)), + output_dtype=np.dtype(np.float32), + ) + fusion._fuse_by_blocks() + actual = fusion.fused_ts.read().result()[0, 0] + np.testing.assert_allclose(actual, case.expected, rtol=8e-7, atol=0) + + +@pytest.mark.unit +@pytest.mark.parametrize("factor", [3, 20]) +@pytest.mark.parametrize("mode", ["full", "roi", "cropped"]) +@pytest.mark.parametrize("deskew_field", [("x", 0.4)], indirect=True) +def test_saved_deskew_retains_complete_z_extent( + deskew_processing_case, factor, deskew_roundoff +): + """Save every supported Z bin and the correct physical full-volume or ROI extent.""" + from opm_processing.dataio.position_collection import open_position_collection + from opm_processing.imageprocessing.tilefusion import TileFusion + from opm_processing.process import process_skewed + + case = deskew_processing_case + process_skewed( + case.dataset.path, + case.acquisition, + save_float32=True, + z_downsample_level=factor, + max_projection=False, + create_fused_max_projection=False, + roi_selection=case.roi, + crop_after_deskew=case.crop, + ) + saved = open_position_collection(case.path) + actual = saved.arrays[0][0, 0].read().result() + np.testing.assert_allclose(actual, case.expected, **deskew_roundoff) + assert np.any(actual[-1] > 0) + assert saved.voxel_size_um[0] == pytest.approx( + round(factor * case.acquisition.pixel_size_um, 3) + ) + assert saved.spatial_origins_zyx_um[0] == case.origin + fusion = TileFusion( + root_path=case.path, + downsample_factors=(1, 1, 1), + ssim_window=3, + threshold=0.1, + max_registration_shift_zyx=(2, 2, 2), + multiscale_factors=(), + resolution_multiples=((1, 1, 1),), + max_workers=1, + ) + support = fusion._tile_support_zy[0] + first_value = 14 if case.roi is not None else 10 + gain = 2 * case.acquisition.pixel_size_um / case.acquisition.scan_axis_step_um + np.testing.assert_allclose( + support, case.expected[..., 0] / (first_value * gain), atol=1e-7, rtol=1e-6 ) + np.testing.assert_allclose(fusion._tile_positions[0], case.origin, atol=1e-12) + if case.roi is None: + width = case.acquisition.index_sizes["x"] + np.testing.assert_array_equal(fusion._tile_profiles[0][2][width:], 0) diff --git a/tests/cpu/test_physical_forward_model.py b/tests/cpu/test_physical_forward_model.py new file mode 100644 index 0000000..cf73439 --- /dev/null +++ b/tests/cpu/test_physical_forward_model.py @@ -0,0 +1,359 @@ +"""Camera statistics and independent optical source-to-sensor image formation.""" + +from dataclasses import replace + +import numpy as np +import pytest + +from tests.simulation.camera import digitize +from tests.simulation.forward_model import ( + Acquisition, + camera_psf, + excitation_field, + forward_model, + sensor_integral, +) +from tests.simulation.scenes import acquisition_shape + +pytestmark = pytest.mark.unit + + +def test_line_pairs_have_declared_spacing_and_equal_emitted_photons( + independent_photon_scene_factory, +): + """Preserve declared line-pair gaps and equal emitted photon totals.""" + scene = independent_photon_scene_factory("line-pairs") + positions = np.asarray(scene.features["positions_x_um"]).reshape(-1, 2) + np.testing.assert_allclose( + positions[:, 1] - positions[:, 0], + [0.15, 0.23, 0.35, 0.53, 0.8, 1.2], + rtol=0, + atol=2e-15, + ) + np.testing.assert_allclose(positions[1:, 0] - positions[:-1, 1], 1.8, atol=2e-15) + np.testing.assert_allclose( + positions.mean(axis=1), scene.features["pair_centers_x_um"] + ) + boundaries = (positions[:-1, 1] + positions[1:, 0]) / 2 + profile = scene.density.sum(axis=(0, 1), dtype=np.float64) + pair_mass = [ + part.sum() * scene.microscope.fine_spacing_um**3 + for part in np.split(profile, np.searchsorted(scene.axes[2], boundaries)) + ] + np.testing.assert_allclose(pair_mass, 2 * scene.features["length_um"], rtol=1e-6) + + +@pytest.mark.parametrize( + "mean,noise,full_model", [(0, 0.7, False), (2, 1.6, False), (30, 1.6, True)] +) +def test_camera_adc_moments( + independent_photon_scene_factory, mean, noise, full_model, camera_calibration +): + """Detection/read variances add before gain; unsigned ADC retains its offset.""" + camera = replace(camera_calibration, read_noise_e=noise) + if full_model: + scene = independent_photon_scene_factory() + # A closed exposure has only the specified photon background. + raw = forward_model( + scene.density, + Acquisition( + scene.axes, + (1, 400, 1000), + exposure=0, + background_electrons=mean, + seed=637, + ), + replace(scene.microscope, camera=camera), + ) + else: + raw = digitize( + np.full(400_000, float(mean)), camera, seed=637 if mean == 0 else 31 + ) + variance = (mean + noise**2) / camera.electrons_per_adu**2 + 1 / 12 + tolerance = ( + (0.02 if mean == 0 else 0.05) + if not full_model + else 5 * np.sqrt(variance / raw.size) + ) + assert raw.dtype == np.uint16 + assert raw.mean() == pytest.approx( + camera.offset_adu + mean / camera.electrons_per_adu, abs=tolerance + ) + assert raw.var() == pytest.approx(variance, rel=0.02) + if mean == 0: + assert 0.42 < np.mean(raw < 100) < 0.46 + assert np.mean(raw == 0) == 0 + + +@pytest.mark.parametrize("profile", ("uniform", "edge-taper")) +def test_512_by_1900_excitation_varies_only_along_camera_x( + profile, microscope_parameters +): + """The taper is positive at both 1900-axis ends and identical on all 512 rows.""" + x = (np.arange(1900) - 949.5) * 0.115 + field = np.broadcast_to( + excitation_field( + x, + x[-1], + profile, + edge_fraction=microscope_parameters.illumination_edge_fraction, + ), + (512, 1900), + ) + np.testing.assert_array_equal(field[0], field[-1]) + np.testing.assert_array_equal(field[:, 0], field[:, -1]) + if profile == "edge-taper": + assert field[0, 0] == pytest.approx(0.25) + assert field[0, 949] == pytest.approx(1, abs=1e-6) + assert np.all(np.diff(field[0, :950]) > 0) + else: + np.testing.assert_array_equal(field, 1) + + +@pytest.mark.parametrize("geometry", ("oblique", "cartesian")) +def test_tilted_sensor_integrates_area_instead_of_point_sampling( + geometry, microscope_parameters +): + """A quadratic irradiance has an independently known finite-pixel response.""" + p = replace(microscope_parameters, pixel_samples=3) + axis = np.arange(-20, 21) * 0.005 + z, y, x = np.meshgrid(axis, axis, axis, indexing="ij", sparse=True) + field = (10 + 2 * x**2 + y**2 + z**2).astype(np.float32) + actual = sensor_integral( + field, (axis, axis, axis), (1, 1, 1), p, geometry=geometry + ).item() + # Camera row offsets rotate in YZ, preserving y²+z². Midpoint quadrature + # variance per pixel dimension is pitch²*(1 - 1/n²)/12. + expected_average = 10 + 3 * p.pixel_size_um**2 * (1 - 1 / 3**2) / 12 + assert actual == pytest.approx(expected_average * p.pixel_size_um**2, abs=2e-7) + + +@pytest.mark.parametrize("profile", ("uniform", "edge-taper")) +def test_point_illumination_precedes_emission_blur( + independent_photon_scene_factory, profile +): + """Known emitter brightness scales its entire PSF, rather than detected pixels.""" + scene = independent_photon_scene_factory("points", profile) + p = scene.microscope + shape = acquisition_shape(scene) + actual = ( + forward_model( + scene.density, + Acquisition( + scene.axes, shape, illumination_profile=profile, output="expectation" + ), + p, + ) + / p.quantum_efficiency + ) + expected = np.zeros(shape, np.float32) + # The convolved scene continues into zero-valued voxels beyond the finite + # PSF support. Include that zero halo in the direct point-source route so + # trilinear interpolation has the same physical boundary on both routes. + optical_field = np.pad(scene.psf_irradiance, 1) + axes_with_halo = tuple( + np.r_[a[0] - p.fine_spacing_um, a, a[-1] + p.fine_spacing_um] + for a in scene.psf_axes + ) + for center in scene.features["centers_xyz_um"]: + shifted_axes = tuple( + a + c for a, c in zip(axes_with_halo, center[::-1], strict=True) + ) + expected += sensor_integral( + optical_field, shifted_axes, shape, p + ) * excitation_field( + np.asarray(center[0]), + scene.illumination_half_width_um, + profile, + edge_fraction=p.illumination_edge_fraction, + ) + np.testing.assert_allclose(actual, expected, rtol=5e-4, atol=1e-8) + # Zero padding of GT is a representation choice; it cannot change the + # emitted light collected beyond the stored object's boundaries. + crop = tuple( + slice(int(indices.min()) - 1, int(indices.max()) + 2) + for indices in np.nonzero(scene.density) + ) + cropped_axes = tuple(a[s] for a, s in zip(scene.axes, crop, strict=True)) + cropped = ( + forward_model( + scene.density[crop], + Acquisition( + cropped_axes, shape, illumination_profile=profile, output="expectation" + ), + p, + ) + / p.quantum_efficiency + ) + np.testing.assert_allclose(cropped, actual, rtol=5e-4, atol=1e-8) + # A stage translation changes global coordinates, not the local image's + # excitation profile or collected photons. + shift_xyz = np.array((1.15, 2.0, 10.0)) + shifted_axes = tuple( + a + shift for a, shift in zip(scene.axes, shift_xyz[::-1], strict=True) + ) + angle = np.deg2rad(p.angle_deg) + shift_syx = ( + shift_xyz[1] - shift_xyz[2] / np.tan(angle), + shift_xyz[2] / np.sin(angle), + shift_xyz[0], + ) + sensor_axes = tuple( + (np.arange(n) - (n - 1) / 2) * step + shift + for n, step, shift in zip( + shape, + (p.scan_step_um, p.pixel_size_um, p.pixel_size_um), + shift_syx, + strict=True, + ) + ) + translated = ( + forward_model( + scene.density, + Acquisition( + shifted_axes, + sensor_axes=sensor_axes, + illumination_profile=profile, + output="expectation", + ), + p, + ) + / p.quantum_efficiency + ) + np.testing.assert_allclose(translated, actual, rtol=1e-5, atol=1e-9) + # Each fine-grid point has one emitted photon per exposure, independent of + # grid spacing. No fitted gain or reconstruction participates in this check. + assert scene.density.sum(dtype=np.float64) * p.fine_spacing_um**3 == pytest.approx( + 2, rel=1e-6 + ) + + +@pytest.mark.parametrize("step", (0.4, 1.2)) +def test_inverse_psf_matches_full_camera_response( + independent_photon_scene_factory, step +): + """A common lattice emitter must have the same response in both operators.""" + scene = independent_photon_scene_factory() + p = replace(scene.microscope, scan_step_um=step) + shape = acquisition_shape(replace(scene, microscope=p)) + density = np.zeros_like(scene.density) + density[tuple(n // 2 for n in density.shape)] = 1 / p.fine_spacing_um**3 + actual = forward_model( + density, Acquisition(scene.axes, shape, output="expectation"), p + ) + kernel, scale = camera_psf(p) + # Kernel normalization has a physical Jacobian, not a fitted image gain. + probability = scale / (step * p.pixel_size_um**2 * np.sin(np.deg2rad(p.angle_deg))) + expected = np.zeros(shape) + support = tuple( + slice(n // 2 - k // 2, n // 2 + k // 2 + 1) + for n, k in zip(shape, kernel.shape, strict=True) + ) + expected[support] = kernel * probability + # The full route uses float32 FFT convolution; allow its arithmetic error. + np.testing.assert_allclose( + actual, expected, rtol=0, atol=64 * np.finfo(np.float32).eps * actual.max() + ) + + +@pytest.mark.parametrize("factor", (3, 4)) +def test_subsampling_selects_the_same_physical_exposures( + independent_photon_scene_factory, factor +): + """Coarse scans retain dense positions, sensor pixels, illumination and exposure.""" + scene = independent_photon_scene_factory() + p = scene.microscope + shape = acquisition_shape(scene) + dense = forward_model( + scene.density, Acquisition(scene.axes, shape, output="expectation"), p + ) + coarse = forward_model( + scene.density, + Acquisition( + scene.axes, ((shape[0] - 1) // factor + 1, *shape[1:]), output="expectation" + ), + replace(p, scan_step_um=p.scan_step_um * factor), + ) + np.testing.assert_allclose(coarse, dense[::factor], rtol=1e-6, atol=1e-10) + + +def test_pixel_quadrature_convergence(independent_photon_scene_factory): + """Refine tilted sensor integration while holding the physical scene fixed.""" + fine = independent_photon_scene_factory(spacing=0.025) + metadata = Acquisition(fine.axes, acquisition_shape(fine), output="expectation") + images = [ + forward_model(fine.density, metadata, replace(fine.microscope, pixel_samples=n)) + for n in (3, 5) + ] + assert np.linalg.norm(images[0] - images[1]) / np.linalg.norm(images[1]) < 0.005 + + +@pytest.mark.parametrize("angle", [30, 38]) +@pytest.mark.parametrize("step", [0.2, 0.4, 0.8]) +def test_oblique_pixel_coordinates_match_linear_field( + linear_camera_field, angle, step, microscope_parameters +): + """A known concentration ramp gives exact values at tilted pixel centers.""" + case = linear_camera_field + p = replace( + microscope_parameters, + pixel_size_um=0.13, + angle_deg=angle, + scan_step_um=step, + pixel_samples=1, + ) + theta = np.deg2rad(angle) + shifts = ( + case.center[1] - case.center[2] / np.tan(theta), + case.center[2] / np.sin(theta), + case.center[0], + ) + sensor_axes = tuple( + (np.arange(n) - (n - 1) / 2) * d + offset + for n, d, offset in zip(case.shape, (step, 0.13, 0.13), shifts, strict=True) + ) + actual = sensor_integral( + case.field, + case.axes, + case.shape, + p, + sensor_axes=sensor_axes, + area_integral=False, + ) + expected = np.empty(case.shape) + for s, r, c in np.ndindex(case.shape): + row = (r - 5.5) * 0.13 + xyz = ( + (c - 4) * 0.13 + case.center[0], + (s - 3) * step + row * np.cos(theta) + case.center[1], + row * np.sin(theta) + case.center[2], + ) + expected[s, r, c] = 100 + np.dot((2, 3, 5), xyz) + np.testing.assert_allclose(actual, expected, rtol=1e-12, atol=1e-12) + + +@pytest.mark.parametrize("step", [0.2, 0.4, 0.8]) +def test_optical_grid_convergence(fine_optical_images, step, microscope_parameters): + """Refine PSF and finite-object grids at the same physical scan positions.""" + p = replace(microscope_parameters, scan_step_um=step, pixel_samples=1) + sampled_psfs, sampled_images = [], [] + for axes, psf, image in fine_optical_images: + sampled = sensor_integral(psf, axes, (25, 33, 17), p, area_integral=False) + sampled_psfs.append(sampled / sampled.sum()) + sampled_images.append( + sensor_integral(image, axes, (25, 33, 17), p, area_integral=False) + ) + for samples in (sampled_psfs, sampled_images): + reference = samples[-1] + errors = [ + np.linalg.norm(a - reference) / np.linalg.norm(reference) + for a in samples[:-1] + ] + assert errors[1] < errors[0] + assert errors[1] < 0.02 + if step == 0.8: + dense = sensor_integral( + image, axes, (33, 33, 17), replace(p, scan_step_um=0.2), area_integral=False + ) + coarse = sensor_integral(image, axes, (9, 33, 17), p, area_integral=False) + np.testing.assert_allclose(coarse, dense[::4], rtol=1e-12, atol=1e-12) diff --git a/tests/cpu/test_point_resolution.py b/tests/cpu/test_point_resolution.py index c96b056..ffa7ead 100644 --- a/tests/cpu/test_point_resolution.py +++ b/tests/cpu/test_point_resolution.py @@ -1,67 +1,47 @@ -"""CPU checks of two-point profile measurements against known emitter geometry.""" +"""Two-point profile checks against prescribed physical emitter geometry.""" import numpy as np import pytest -from tests.reference.point_sources import two_point_profile +from tests.simulation.metrics import two_point_profile + +pytestmark = pytest.mark.unit -@pytest.mark.unit @pytest.mark.parametrize( - "offset, separation", (((0, 0, 3), 0.69), ((3, 0, 0), 1.2), ((0, 3, 0), 0.69)) + "point_profile_case", + [ + ("pair", (0, 0, 3), 0.69), + ("pair", (3, 0, 0), 1.2), + ("pair", (0, 3, 0), 0.69), + ("single", (0, 0, 3), 0.69), + ("flat", (0, 0, 3), 0.69), + ("adjacent", (0, 0, 0.5), 0.115), + ], + indirect=True, + ids=["camera-x", "scan", "camera-y", "single-point", "flat-field", "adjacent"], ) -def test_two_point_measurements_match_known_positions(offset, separation): - """Recover exact separation and an empty valley for two discrete emitters. - - Parameters - ---------- - offset : tuple of int - Known displacement from the midpoint in acquisition indices. - separation : float - Independently known laboratory separation in micrometers. - """ - volume = np.zeros((17, 17, 17), np.float64) - center = np.array((8, 8, 8)) - volume[tuple(center - offset)] = 100 - volume[tuple(center + offset)] = 200 - measured = two_point_profile(volume, center, offset) - assert measured["true_separation_um"] == pytest.approx(separation) - assert measured["peak_separation_um"] == pytest.approx(separation) - np.testing.assert_allclose( - measured["peak_positions_um"], (-separation / 2, separation / 2), atol=1e-12 - ) - assert measured["valley_depth"] == 1 - - -@pytest.mark.unit -@pytest.mark.parametrize("constant", (False, True), ids=("single-point", "flat-field")) -def test_two_point_measurement_does_not_split_one_peak(constant): - """Return no pair measurement for a single emitter or a uniform profile. - - Parameters - ---------- - constant : bool - Use a uniform field instead of a single central emitter. - """ - volume = np.full((17, 17, 17), float(constant), np.float64) - volume[8, 8, 8] = 1 - measured = two_point_profile(volume, (8, 8, 8), (0, 0, 3)) - assert measured["peak_positions_um"] is None - assert measured["peak_separation_um"] is None - assert measured["valley_depth"] is None - - -@pytest.mark.unit -def test_adjacent_points_use_their_half_pixel_midpoint(): - """Sample adjacent emitters about their true midpoint without inventing a dip.""" - volume = np.zeros((17, 17, 17), np.float64) - volume[8, 8, 8:10] = 100 - measured = two_point_profile(volume, (8, 8, 8.5), (0, 0, 0.5)) - assert measured["true_separation_um"] == pytest.approx(0.115) - np.testing.assert_allclose( - measured["distance_um"], np.linspace(-0.115, 0.115, 9), atol=1e-12 - ) - np.testing.assert_allclose( - measured["profile"], (50, 75, 100, 100, 100, 100, 100, 75, 50) - ) - assert measured["peak_positions_um"] is None +def test_point_profile_resolves_only_distinct_emitters(point_profile_case): + """Measure physical pair spacing without inventing a dip in unresolved data.""" + case = point_profile_case + measured = two_point_profile(case.volume, case.center, case.offset, case.microscope) + assert measured["true_separation_um"] == pytest.approx(case.separation) + if case.pattern == "pair": + assert measured["peak_separation_um"] == pytest.approx(case.separation) + np.testing.assert_allclose( + measured["peak_positions_um"], + (-case.separation / 2, case.separation / 2), + atol=1e-12, + ) + assert measured["valley_depth"] == 1 + else: + assert measured["peak_positions_um"] is None + assert measured["peak_separation_um"] is None + assert measured["valley_depth"] is None + if case.pattern == "adjacent": + np.testing.assert_allclose( + measured["distance_um"], np.linspace(-0.115, 0.115, 9), atol=1e-12 + ) + np.testing.assert_allclose( + measured["profile"], (50, 75, 100, 100, 100, 100, 100, 75, 50) + ) diff --git a/tests/cpu/test_process_resume_prompt.py b/tests/cpu/test_process_resume_prompt.py new file mode 100644 index 0000000..6fad88a --- /dev/null +++ b/tests/cpu/test_process_resume_prompt.py @@ -0,0 +1,139 @@ +"""Read-only resume decisions protect saved photons and their completion journal. + +Terminal decisions use controlled input; shared fixtures provide isolated temporary files +and journals containing known completion and pixel state. +""" + +from contextlib import nullcontext +from unittest.mock import Mock + +import pytest +import typer + +from opm_processing.dataio.position_collection import create_position_collection +from opm_processing.process import confirm_partial_output_resume + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize( + ("answer", "expected", "damage", "planar"), + [ + (answer, expected, None, planar) + for answer, expected in ( + ("resume", True), + ("overwrite", False), + ("cancel", None), + ) + for planar in (False, True) + ] + + [ + ("cancel", None, damage, False) + for damage in ("missing-journal", "invalid-journal", "metadata") + ], +) +def test_partial_output_decision_preserves_pixels_and_journal( + partial_processed_run, + stored_file_bytes, + monkeypatch, + planar, + answer, + expected, + damage, +): + """Apply the resume decision without prematurely changing durable output.""" + run = partial_processed_run( + planar=planar, + completed=[(t, p) for t in range(2) for p in range(3)] if damage else None, + ) + if damage == "missing-journal": + run.state.path.unlink() + elif damage == "invalid-journal": + run.state.path.write_text("{invalid json") + elif damage == "metadata": + (run.output / "0" / "zarr.json").unlink() + before = stored_file_bytes(run.output_dir) + monkeypatch.setattr(typer, "prompt", Mock(return_value=answer)) + with pytest.raises(typer.Abort) if expected is None else nullcontext(): + assert ( + confirm_partial_output_resume( + run.acquisition, + run.output_dir, + deconvolve=False, + max_projection=False, + resume=False, + ) + is expected + ) + assert stored_file_bytes(run.output_dir) == before + + +@pytest.mark.parametrize( + "selection,completed", + [ + ( + {"time": None, "position": None}, + [(t, p) for t in range(2) for p in range(3)], + ), + ({"time": [1, 2], "position": [1, 3]}, [(1, 1), (1, 2)]), + ], +) +def test_complete_selected_output_allows_overwrite( + partial_processed_run, selection, completed +): + """Allow a completed selected dataset to be intentionally overwritten.""" + run = partial_processed_run(selection=selection, completed=completed) + assert not confirm_partial_output_resume( + run.acquisition, + run.output_dir, + deconvolve=False, + max_projection=False, + resume=False, + ) + + +@pytest.mark.parametrize("deconvolve", [False, True]) +def test_resume_and_distinct_output_families(partial_processed_run, deconvolve): + """Keep resume decisions independent across processed output families.""" + run = partial_processed_run(deconvolve=deconvolve) + assert confirm_partial_output_resume( + run.acquisition, + run.output_dir, + deconvolve=deconvolve, + max_projection=False, + resume=True, + ) + assert not confirm_partial_output_resume( + run.acquisition, + run.output_dir, + deconvolve=not deconvolve, + max_projection=False, + resume=False, + ) + + +@pytest.mark.parametrize("requested", [False, True]) +def test_projection_completion_only_matters_when_requested( + partial_processed_run, monkeypatch, requested +): + """Check projection completeness only when that output is requested.""" + run = partial_processed_run(completed=[(t, p) for t in range(2) for p in range(3)]) + maximum = run.output.with_name("sample_max_z_deskewed.ome.zarr") + create_position_collection(maximum, (2, 3, 1, 1, 4, 5), (1, 0.115, 0.115)) + run.state.initialize_run( + maximum, + configuration={"selection": {"time": None, "position": None}}, + overwrite=True, + ) + run.state.complete_tile(maximum, 0, 0) + monkeypatch.setattr(typer, "prompt", lambda *a, **k: "resume") + assert ( + confirm_partial_output_resume( + run.acquisition, + run.output_dir, + deconvolve=False, + max_projection=requested, + resume=False, + ) + is requested + ) diff --git a/tests/cpu/test_process_roi_input.py b/tests/cpu/test_process_roi_input.py index f713576..3ef9467 100644 --- a/tests/cpu/test_process_roi_input.py +++ b/tests/cpu/test_process_roi_input.py @@ -3,27 +3,17 @@ from pathlib import Path import pytest -import zarr -from typer.testing import CliRunner from opm_processing import process_roi as process_roi_module from opm_processing.dataio.acquisition import acquisition_stem from opm_processing.dataio.processing_state import ProcessingState +pytestmark = pytest.mark.integration -def _processed_directory(tmp_path: Path, stem: str) -> Path: - """Create four derived stores without raw acquisition metadata.""" - output_dir = tmp_path / "processed" - output_dir.mkdir() - for label in ("deskewed", "fused", "max_z_deskewed", "max_z_fused"): - zarr.open_group(output_dir / f"{stem}_{label}.ome.zarr", mode="w") - return output_dir - -@pytest.mark.integration @pytest.mark.parametrize("explicit_paths", (False, True)) def test_process_roi_uses_raw_source_and_processed_directory_defaults( - tmp_path: Path, roi_run, explicit_paths: bool + tmp_path: Path, roi_run, processed_directory_factory, explicit_paths: bool ) -> None: """Resolve CLI inputs and persist the calibrated cropped raw pixels.""" from dataclasses import replace @@ -35,7 +25,7 @@ def test_process_roi_uses_raw_source_and_processed_directory_defaults( run = roi_run source = run.source.resolve() stem = acquisition_stem(source) - processed = _processed_directory(tmp_path, stem) + processed = processed_directory_factory(stem) ProcessingState.create(processed / f"{stem}.processing.json", source) roi = replace(run.roi, source_path=processed / f"{stem}_max_z_fused.ome.zarr") roi_path = ( @@ -47,12 +37,9 @@ def test_process_roi_uses_raw_source_and_processed_directory_defaults( expected_output = ( tmp_path / "custom_output" if explicit_paths else processed / f"{stem}_roi" ) - args = [str(processed), "--no-deconvolve"] - if explicit_paths: - args.extend([str(roi_path), "--output", str(expected_output)]) - result = CliRunner().invoke(process_roi_module.app, args) - - assert result.exit_code == 0, result.exception + process_roi_module.process_roi( + processed, roi_path, deconvolve=False, output=expected_output + ) output = open_position_collection(expected_output / f"{stem}_deskewed.ome.zarr") for array, expected in zip(output.arrays, run.expected_tiles, strict=False): np.testing.assert_array_equal(array.read().result(), expected) diff --git a/tests/cpu/test_processing_pipeline.py b/tests/cpu/test_processing_pipeline.py new file mode 100644 index 0000000..78635be --- /dev/null +++ b/tests/cpu/test_processing_pipeline.py @@ -0,0 +1,28 @@ +"""CPU parameterization of the shared qi2lab-to-NGFF integration scenario.""" + +import pytest + +from tests.pipeline import ( + test_qi2lab_acquisition_to_fused_ngff_v05 as test_qi2lab_acquisition_to_fused_ngff_v05, +) + +pytestmark = [ + pytest.mark.integration, + pytest.mark.parametrize("deconvolve", [False]), + pytest.mark.parametrize( + "pipeline_case", + [ + (mode, illumination, step, None, False) + for mode in ("stage", "mirror") + for illumination in (False, True) + for step in (0.4, 1.2) + ], + indirect=True, + ids=[ + f"{mode}-{profile}-{step}um" + for mode in ("stage", "mirror") + for profile in ("uniform", "estimated-taper") + for step in (0.4, 1.2) + ], + ), +] diff --git a/tests/cpu/test_processing_state.py b/tests/cpu/test_processing_state.py index 5fcefad..0b780d2 100644 --- a/tests/cpu/test_processing_state.py +++ b/tests/cpu/test_processing_state.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pathlib import Path +from unittest.mock import Mock import pytest @@ -11,8 +11,9 @@ processing_state_path, ) +pytestmark = pytest.mark.unit + -@pytest.mark.unit @pytest.mark.parametrize( ("save_float32", "dtype"), ((False, "uint16"), (True, "float32")), @@ -70,7 +71,6 @@ def test_processing_state_round_trip_has_exact_durable_ground_truth( assert reopened.registered_output_for_max_projection(maximum) == output -@pytest.mark.unit def test_processing_state_resume_requires_exact_run_configuration( tmp_path, processing_state ) -> None: @@ -98,7 +98,6 @@ def test_processing_state_resume_requires_exact_run_configuration( assert path.read_bytes() == saved -@pytest.mark.unit def test_overwriting_one_run_preserves_siblings_and_invalidates_only_its_registration( tmp_path, processing_state, @@ -136,7 +135,6 @@ def test_overwriting_one_run_preserves_siblings_and_invalidates_only_its_registr state.registration(deconvolved) -@pytest.mark.unit def test_failed_channel_checkpoint_keeps_previous_durable_state( tmp_path, processing_state, monkeypatch ): @@ -146,16 +144,17 @@ def test_failed_channel_checkpoint_keeps_previous_durable_state( state.initialize_run(output, configuration={}, overwrite=True) assert state.completed_channels(output) == set() state.complete_channel(output, 0, 3, 0, is_zero=True) - snapshot = state.path.read_bytes() - - def fail_replace(self, target): - raise OSError("interrupted state replacement") + before = state.path.read_bytes() - monkeypatch.setattr(Path, "replace", fail_replace) + monkeypatch.setattr( + type(state.path), + "replace", + Mock(side_effect=OSError("interrupted state replacement")), + ) with pytest.raises(OSError, match="interrupted state replacement"): state.complete_channel(output, 0, 3, 1) - assert state.path.read_bytes() == snapshot + assert state.path.read_bytes() == before reopened = ProcessingState.read(state.path) assert reopened.completed_channels(output) == {(0, 3, 0)} assert reopened.zero_channels(output) == {(0, 3, 0)} diff --git a/tests/cpu/test_psf_sampling_experiment.py b/tests/cpu/test_psf_sampling_experiment.py deleted file mode 100644 index 3434b7e..0000000 --- a/tests/cpu/test_psf_sampling_experiment.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Physical sampling tests without changes to production deskew or PSFs.""" - -import numpy as np -import pytest -from scripts.psf_sampling_experiment import ( - sample_skewed, -) - - -@pytest.mark.unit -@pytest.mark.parametrize("angle", [30, 38]) -@pytest.mark.parametrize("step", [0.2, 0.4, 0.8]) -def test_physical_concentration_sampled_at_camera_pixel_centers(angle, step): - """An affine concentration field has exact values at every camera pixel.""" - axes = tuple(np.linspace(-5, 5, 101) for _ in range(3)) - z, y, x = np.meshgrid(*axes, indexing="ij", sparse=True) - field = 100 + 2 * x + 3 * y + 5 * z - center = (0.03, -0.12, 0.21) - shape = (7, 12, 9) # Include an even camera dimension and a shifted origin. - actual = sample_skewed( - field, - axes, - shape, - angle_deg=angle, - scan_step_um=step, - pixel_size_um=0.13, - center_xyz_um=center, - ) - expected = np.empty(shape) - for s, r, c in np.ndindex(shape): - row_um = (r - 5.5) * 0.13 - xyz = ( - (c - 4) * 0.13 + center[0], - (s - 3) * step + row_um * np.cos(np.deg2rad(angle)) + center[1], - row_um * np.sin(np.deg2rad(angle)) + center[2], - ) - expected[s, r, c] = 100 + np.dot((2, 3, 5), xyz) - np.testing.assert_allclose(actual, expected, rtol=1e-12, atol=1e-12) - - -@pytest.mark.unit -@pytest.mark.parametrize("step", [0.2, 0.4, 0.8]) -def test_fine_cartesian_forward_model_converges_at_raw_sampling( - fine_optical_images, step -): - """Refine the optical/object grid while holding raw sampling fixed.""" - sampled_psfs = [] - sampled_images = [] - for axes, psf, image in fine_optical_images: - sampled = sample_skewed(psf, axes, (25, 33, 17), scan_step_um=step) - sampled_psfs.append(sampled / sampled.sum()) - sampled_images.append( - sample_skewed( - image, - axes, - (25, 33, 17), - scan_step_um=step, - ) - ) - for label, samples in (("psf", sampled_psfs), ("blurred_object", sampled_images)): - reference = samples[-1] - errors = [ - np.linalg.norm(a - reference) / np.linalg.norm(reference) - for a in samples[:-1] - ] - print({"scan_step_um": step, "field": label, "relative_errors": errors}) - assert errors[1] < errors[0] - assert errors[1] < 0.02 - - -@pytest.mark.unit -def test_integer_scan_sampling_preserves_physical_plane_locations(fine_optical_images): - """0.8 um measurements are every fourth plane of a 0.2 um scan.""" - axes, _, image = fine_optical_images[-1] - dense = sample_skewed(image, axes, (33, 33, 17), scan_step_um=0.2) - coarse = sample_skewed(image, axes, (9, 33, 17), scan_step_um=0.8) - np.testing.assert_allclose(coarse, dense[::4], rtol=1e-12, atol=1e-12) diff --git a/tests/cpu/test_ptv_metadata.py b/tests/cpu/test_ptv_metadata.py new file mode 100644 index 0000000..d37cef9 --- /dev/null +++ b/tests/cpu/test_ptv_metadata.py @@ -0,0 +1,198 @@ +"""Self-contained processed PTV metadata through genuine producer writers.""" + +import copy + +import pytest +from yaozarrs import open_group + +from opm_processing.dataio.position_collection import update_group_attributes +from opm_processing.dataio.ptv import publish_ptv_metadata, read_processed_snapshot +from tests.fixtures.storage import ptv_manifest + +pytestmark = pytest.mark.unit + + +def test_actual_writer_roundtrip_retains_geometry_ids_status_and_hardware( + ptv_collection, +): + """Retain geometry, identifiers, volume validity and hardware timing.""" + path = ptv_collection + publish_ptv_metadata(path, ptv_manifest()) + snapshot = read_processed_snapshot(path) + metadata = snapshot.metadata + assert snapshot.array.shape == (3, 1, 7, 12, 14) + assert metadata["shape_zyx"] == (7, 12, 14) + assert metadata["spacing_zyx_um"] == (0.23, 0.115, 0.15) + assert metadata["origin_zyx_um"] == (-2.1, 3.2, -4.3) + assert metadata["timing"]["n_acquired_planes"] == 17 + assert metadata["source_indices"] == [0, 1, 3] + assert metadata["volume_status"] == ["valid", "invalid", "incomplete"] + assert metadata["processing_history"] == ptv_manifest()["processing_history"] + assert metadata["stored_ngff"]["channel_name"] == "beads" + assert "opm_processing" in open_group(path).attrs + + +def test_highest_resolution_resolved_without_literal_array_path(ptv_collection): + """Resolve the declared highest-resolution dataset without a path convention.""" + path = ptv_collection + values = dict(open_group(path / "0").attrs) + values["ome"]["multiscales"][0]["datasets"][0]["path"] = "native-resolution" + (path / "0/0").rename(path / "0/native-resolution") + update_group_attributes(path / "0", values) + publish_ptv_metadata(path, ptv_manifest()) + assert read_processed_snapshot(path).metadata["array_path"] == "0/native-resolution" + + +@pytest.mark.parametrize( + "problem", + [ + "missing_unit", + "missing_translation", + "unitless_time", + "wrong_cadence", + "wrong_status_count", + "projection_kind", + "raw_kind", + ], +) +def test_metadata_traps_are_rejected_before_reader_defaults(ptv_collection, problem): + """Reject contradictory metadata before any reader fallback can conceal it.""" + path = ptv_collection + contract = ptv_manifest() + values = dict(open_group(path / "0").attrs) + scale = values["ome"]["multiscales"][0] + if problem == "missing_unit": + scale["axes"][2].pop("unit") + elif problem == "missing_translation": + for dataset in scale["datasets"]: + dataset["coordinateTransformations"] = [ + t + for t in dataset["coordinateTransformations"] + if t["type"] != "translation" + ] + elif problem == "unitless_time": + scale["axes"][0].pop("unit") + elif problem == "wrong_cadence": + for dataset in scale["datasets"]: + dataset["coordinateTransformations"][0]["scale"][0] = 1 + elif problem == "wrong_status_count": + contract["images"][0]["volume_status_by_channel"][0].pop() + elif problem == "projection_kind": + contract["product_kind"] = "max_z_deskewed" + else: + contract["product_kind"] = "raw_scan" + update_group_attributes(path / "0", values) + with pytest.raises(ValueError): + publish_ptv_metadata(path, contract) + assert "ptv" not in open_group(path).attrs["opm_processing"] + + +def test_physical_unit_conversion_and_self_contained_fused_layout( + tmp_path, fusion_operator, ptv_collection +): + """Convert physical units and read self-contained fused image metadata.""" + from opm_processing.imageprocessing.tilefusion import TileFusion + + path = tmp_path / "fused.ome.zarr" + operator = fusion_operator( + TileFusion, + time_interval_s=0.085, + multiscale_factors=(2,), + multiscale_downsample="stride", + _pixel_size=(0.23, 0.115, 0.15), + offset_um=(-2.1, 3.2, -4.3), + padded_shape=(7, 12, 14), + _is_2d=False, + time_dim=3, + channels=1, + chunk_y=4, + chunk_x=4, + ) + operator._prepare_fused_image(path, z_slices_per_write=2) + values = dict(open_group(path).attrs) + timing = open_group(ptv_collection).attrs["opm_processing"]["timing"] + values["opm_processing"] = {"timing": timing} + scale = values["ome"]["multiscales"][0] + scale["axes"][0]["unit"] = "millisecond" + for axis in scale["axes"][2:]: + axis["unit"] = "nanometer" + for dataset in scale["datasets"]: + dataset["coordinateTransformations"][0]["scale"] = [ + v * 1000 if i != 1 else v + for i, v in enumerate(dataset["coordinateTransformations"][0]["scale"]) + ] + dataset["coordinateTransformations"][1]["translation"][2:] = [ + v * 1000 for v in dataset["coordinateTransformations"][1]["translation"][2:] + ] + update_group_attributes(path, values) + contract = copy.deepcopy(ptv_manifest(("",))) + contract["product_kind"] = "fused_volume" + publish_ptv_metadata(path, contract) + result = read_processed_snapshot(path) + assert result.metadata["array_path"] == "0" + assert result.metadata["spacing_zyx_um"] == pytest.approx((0.23, 0.115, 0.15)) + assert result.metadata["origin_zyx_um"] == pytest.approx((-2.1, 3.2, -4.3)) + assert result.metadata["stored_ngff"]["time_unit"] == "millisecond" + + +def test_durable_journal_export_distinguishes_unwritten_zeroed_and_valid( + tmp_path, ptv_collection +): + """Distinguish unwritten, deliberately zeroed and valid processed volumes.""" + from opm_processing.dataio.processing_state import ProcessingState + from opm_processing.dataio.ptv import mark_ptv_tile, publish_processing_state_ptv + + path = ptv_collection + state = ProcessingState.create( + tmp_path / "processing.json", tmp_path / "raw.ome.zarr" + ) + configuration = { + "kind": "deskewed", + "calibration": { + "gain_offset_applied": True, + "camera_offset_adu": 100, + "camera_conversion": 0.5, + }, + } + state.initialize_run(path, configuration=configuration, overwrite=True) + publish_processing_state_ptv(path, state) + initial = read_processed_snapshot(path) + assert initial.metadata["volume_status"] == ["incomplete"] * 3 + state.complete_tile(path, 0, 0) + mark_ptv_tile(path, 0, 0) + state.complete_tile(path, 1, 0, zero_channels=(0,)) + mark_ptv_tile(path, 1, 0, zero_channels=(0,)) + snapshot = read_processed_snapshot(path) + assert snapshot.metadata["volume_status"] == ["valid", "invalid", "incomplete"] + assert snapshot.metadata["processing_history"][0]["configuration"] == configuration + assert snapshot.metadata["calibration"] == configuration["calibration"] + # Re-export from durable state after restart preserves dataset identity/status. + publish_processing_state_ptv(path, ProcessingState.read(state.path)) + assert read_processed_snapshot(path).metadata == snapshot.metadata + + +@pytest.mark.parametrize("contradict", [False, True]) +def test_explicit_timestamps_use_hardware_cadence_with_source_gaps( + ptv_collection, contradict +): + """Preserve hardware timestamps across missing source volume indices.""" + path = ptv_collection + processing = copy.deepcopy(open_group(path).attrs["opm_processing"]) + processing["timing"]["clock_precision_s"] = 1e-6 + update_group_attributes(path, {"opm_processing": processing}) + contract = ptv_manifest() + contract["images"][0]["stored_timestamps_s"] = [ + 4.0, + 4.085, + 4.085 if contradict else 4.255, + ] + if contradict: + with pytest.raises(ValueError, match="timestamp"): + publish_ptv_metadata(path, contract) + else: + publish_ptv_metadata(path, contract) + assert read_processed_snapshot(path).metadata["stored_timestamps_s"] == [ + 4.0, + 4.085, + 4.255, + ] diff --git a/tests/cpu/test_registration_geometry.py b/tests/cpu/test_registration_geometry.py index b6204c4..4c62a84 100644 --- a/tests/cpu/test_registration_geometry.py +++ b/tests/cpu/test_registration_geometry.py @@ -8,11 +8,91 @@ TileFusion, _aligned_registration_views, _infer_registration_limits, + _refine_depth_candidate, _ssim, ) +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize("sampling", [(1, 1, 1), (1, 2, 2)]) +def test_orthogonal_camera_pairs_recover_known_stage_errors( + orthogonal_registration_pair, + sampling, +): + """Recover prescribed stage errors from independently formed oblique images.""" + from skimage.measure import block_reduce + + pair = orthogonal_registration_pair + fixed, moving = ( + block_reduce(image, sampling, np.mean) for image in (pair.fixed, pair.moving) + ) + masks = tuple(block_reduce(mask, sampling, np.min) for mask in pair.masks) + shift, score = TileFusion.register_and_score( + fixed, + moving, + 3, + max_shift=np.array((2, 4, 4)) / sampling, + registration_axis=pair.axis, + shift_sampling=sampling, + support_masks=masks, + ) + assert np.isfinite(score) + np.testing.assert_array_equal(np.rint(np.asarray(shift) * sampling), pair.expected) + + +@pytest.mark.parametrize("shape", [(17, 19), (7, 17, 19)]) +@pytest.mark.parametrize( + ("blank_side", "level"), + [ + ("fixed", 0), + ("moving", 0), + ("both", 0), + ("fixed", 100), + ("moving", 100), + ("both", 100), + ], +) +def test_empty_or_uninformative_overlap_is_rejected_before_histogram_matching( + shape, blank_side, level +): + """Reject empty or featureless overlaps before histogram matching.""" + rng = np.random.default_rng(292) + fixed = rng.uniform(100, 1000, shape).astype(np.float32) + moving = fixed.copy() + if blank_side in ("fixed", "both"): + fixed.fill(level) + if blank_side in ("moving", "both"): + moving.fill(level) + + shift, score = TileFusion.register_and_score(fixed, moving, 3) + np.testing.assert_array_equal(shift, (0, 0, 0)) + assert score == -np.inf + + +@pytest.mark.parametrize("ramp_in_fixed", [False, True]) +def test_zero_energy_directional_edges_keep_valid_intensity_registration( + ramp_in_fixed, +): + """Retain valid intensity registration when directional edges have zero energy.""" + rng = np.random.default_rng(293) + fixed = np.broadcast_to(rng.uniform(100, 1000, (25, 31)), (7, 25, 31)).copy() + moving = np.roll(fixed, 3, axis=2).astype(np.float32) + if ramp_in_fixed: + fixed += 2 * np.arange(7)[:, None, None] + fixed = fixed.astype(np.float32) + shift, score = TileFusion.register_and_score( + fixed, moving, 3, max_shift=(2, 4, 8), registration_axis=0 + ) + # The moving image is invariant in Z, so the ramp case cannot constrain a + # unique Z displacement. Its textured XY plane still has a known shift. + np.testing.assert_allclose(shift[1:], (0, -3), atol=0.1) + assert abs(shift[0]) <= 2 + if not ramp_in_fixed: + assert shift[0] == pytest.approx(0, abs=0.1) + assert score > 0.99 + -@pytest.mark.unit def test_stage_grid_allows_depth_footprint_drift_and_preserves_overlap(): # Independent dimensions and spacing from the failed four-depth stage scan. """Check geometric drift bounds against independent stage-grid dimensions.""" @@ -28,7 +108,7 @@ def test_stage_grid_allows_depth_footprint_drift_and_preserves_overlap(): 30, ) depth_limits = limits[0, 4] - assert depth_limits[0] == 6 # Preserve half of the 13-voxel Z overlap. + assert depth_limits[0] == 13 # Actual matched support gates depth corrections. assert 240 <= depth_limits[1] <= 260 assert depth_limits[2] == 31 assert all( @@ -47,7 +127,6 @@ def test_stage_grid_allows_depth_footprint_drift_and_preserves_overlap(): assert reordered[3, 7] == depth_limits -@pytest.mark.unit def test_projection_limits_ignore_depth_and_disjoint_tiles(): """Exclude inactive Z corrections and nonoverlapping projection pairs.""" limits = _infer_registration_limits( @@ -61,7 +140,6 @@ def test_projection_limits_ignore_depth_and_disjoint_tiles(): assert limits == {(0, 1): (0, 10, 10)} -@pytest.mark.unit def test_variable_roi_limits_use_intersection_not_common_shape(): """Bound corrections using the physical intersection of differently sized tiles.""" limits = _infer_registration_limits( @@ -75,7 +153,6 @@ def test_variable_roi_limits_use_intersection_not_common_shape(): assert limits[0, 1] == (6, 5, 10) -@pytest.mark.unit def test_geometry_is_invariant_to_physical_unit_conversion(): """Preserve pixel correction limits under a micrometer-to-nanometer conversion.""" positions = np.asarray([(0, 0, 0), (10, 60, 5)]) @@ -89,7 +166,6 @@ def test_geometry_is_invariant_to_physical_unit_conversion(): assert actual == scaled -@pytest.mark.unit def test_no_registration_limits_for_missing_or_disjoint_tiles(): """Return no search bounds when there are no physically overlapping tiles.""" assert _infer_registration_limits([], [], (1, 1, 1), (1, 1, 1), 30) == {} @@ -105,49 +181,15 @@ def test_no_registration_limits_for_missing_or_disjoint_tiles(): ) -@pytest.mark.unit @pytest.mark.parametrize("drift", (180, 270)) def test_inferred_depth_limits_recover_or_reject_measured_drift( - tensorstore_dataset, fusion_operator, drift + registration_pair, shifted_slab_pair, drift ): # A fixed random specimen sampled by two slabs with an independently # imposed Y displacement, including one outside the geometric allowance. """Recover a known slab displacement and reject drift beyond geometric bounds.""" - rng = np.random.default_rng(51) - truth = gaussian_filter(rng.uniform(0, 1000, (115, 512 + drift, 64)), (1, 2, 2)) - truth = np.rint(truth).astype(np.uint16) - volumes = (truth[:64, drift:], truth[51:, :512]) - fusion = fusion_operator( - TileFusion, - _tile_support_zy=[np.ones(volume.shape[:2], np.float32) for volume in volumes], - downsample_factors=(3, 2, 2), - ssim_window=15, - threshold=0.7, - pairwise_metrics={}, - position_dim=2, - time_dim=1, - ) - fusion.z_dim, fusion.y_dim, fusion.x_dim = volumes[0].shape - fusion._pixel_size = (0.23, 0.115, 0.115) - fusion._tile_positions = [(0, 0, 0), (51 * 0.23, 0, 0)] - fusion._tile_shapes = [volumes[0].shape] * 2 - fusion._tiles_by_time = ((0, 1),) - fusion._tile_time_indices = [0, 0] - fusion._is_2d = False - fusion._max_workers = 1 - fusion._pair_registration_limits = _infer_registration_limits( - fusion._tile_positions, - fusion._tile_shapes, - fusion._pixel_size, - fusion.downsample_factors, - 30, - ) - fusion.max_registration_shift_zyx = fusion._pair_registration_limits[0, 1] - fusion._debug = False - fusion._tile_channel_nonzero = {(0, 0): True, (1, 0): True} - fusion.position_arrays = tuple( - tensorstore_dataset(volume[None, None]) for volume in volumes - ) + pair = shifted_slab_pair((64, 512, 64), 51, (0, -drift, 0)) + fusion = registration_pair(pair.volumes, pair.positions) fusion.refine_tile_positions_with_cross_correlation() if drift == 180: assert set(fusion.pairwise_metrics) == {(0, 1)} @@ -158,16 +200,39 @@ def test_inferred_depth_limits_recover_or_reject_measured_drift( assert fusion.pairwise_metrics == {} -@pytest.mark.unit -@pytest.mark.parametrize("offsets", ((0.5, -1.3), (0.5, -1.3, 2.4), (1, -2, 3))) -def test_aligned_registration_views_preserve_fractional_translation(offsets): - """Recover an analytic intensity ramp without rounding shifts or scoring padding. +@pytest.mark.parametrize("reverse", (False, True)) +@pytest.mark.parametrize("z_limit", (None, 6)) +@pytest.mark.parametrize("depth_error", (7, 11)) +def test_context_recovers_depth_error_beyond_half_overlap( + registration_pair, shifted_slab_pair, reverse, z_limit, depth_error +): + """Recover Z errors from a thirteen-plane nominal intersection. - Parameters - ---------- - offsets : tuple of float - Applied moving-image correction in two or three array dimensions. + Both signs preserve the specimen's Z direction. An explicit six-pixel + limit rejects the measured correction instead of clamping it. """ + pair = shifted_slab_pair((64, 200, 152), 51, (-depth_error, -40, 8), seed=20261009) + volumes, positions = list(pair.volumes), pair.positions + if reverse: + volumes.reverse() + positions.reverse() + fusion = registration_pair(volumes, positions) + if z_limit is not None: + fusion._pair_registration_limits[0, 1] = (z_limit, 100, 100) + fusion.max_registration_shift_zyx = fusion._pair_registration_limits[0, 1] + fusion.refine_tile_positions_with_cross_correlation() + if z_limit is not None: + assert fusion.pairwise_metrics == {} + else: + expected = np.array((-depth_error, -40, 8)) * (-1 if reverse else 1) + np.testing.assert_allclose(fusion.pairwise_metrics[0, 1][:3], expected, atol=1) + fusion.optimize_shifts() + np.testing.assert_allclose(fusion.global_offsets[1], expected, atol=1) + + +@pytest.mark.parametrize("offsets", ((0.5, -1.3), (0.5, -1.3, 2.4), (1, -2, 3))) +def test_aligned_registration_views_preserve_fractional_translation(offsets): + """Recover an analytic intensity ramp without rounding shifts or scoring padding.""" shape = (11, 13, 17)[-len(offsets) :] coordinates = np.indices(shape, dtype=np.float32) slopes = np.arange(1, len(offsets) + 1, dtype=np.float32) @@ -184,19 +249,39 @@ def test_aligned_registration_views_preserve_fractional_translation(offsets): assert bool(np.allclose(reference, aligned, atol=2e-5, rtol=0)) -@pytest.mark.unit -@pytest.mark.parametrize("stripe", (False, True)) -def test_identical_low_texture_crops_preserve_zero_displacement(stripe): - """Identical uniform or line objects have zero measured displacement. +@pytest.mark.parametrize("uniform", (True, False)) +def test_context_rejects_background_without_matching_specimen(uniform): + """Similar background levels must not manufacture a depth match.""" + rng = np.random.default_rng(913) + fields = [np.full((25, 48, 40), 1000, np.float32) for _ in range(2)] + if not uniform: + for field in fields: + field += gaussian_filter(rng.normal(0, 5, field.shape), 1) + masks = tuple(np.ones_like(field) for field in fields) + _, score = _refine_depth_candidate(*fields, masks, 12, (0, 0), 9, 7) + assert not np.isfinite(score) - Parameters - ---------- - stripe : bool - Add an X-localized fluorescent line with constant Z and Y intensity. - """ + +@pytest.mark.parametrize("noise", [0.0, 0.3]) +def test_high_ssim_shared_xy_background_cannot_establish_a_z_registration(noise): + """Shared shading can have almost perfect SSIM without shared axial features.""" + rng = np.random.default_rng(297) + background = gaussian_filter(rng.uniform(100, 1000, (48, 40)), 2) + fixed = np.broadcast_to(background, (48, 48, 40)).copy() + moving = fixed.copy() + fixed += rng.normal(0, noise, fixed.shape) + moving += rng.normal(0, noise, moving.shape) + fixed, moving = fixed.astype(np.float32), moving.astype(np.float32) + assert _ssim(fixed, moving, 7) > 0.99 + masks = (np.ones_like(fixed), np.ones_like(moving)) + _shift, score = _refine_depth_candidate(fixed, moving, masks, -20, (0, 0), 13, 7) + assert score == -np.inf + + +def test_identical_line_crops_preserve_zero_displacement(): + """Identical line objects have a valid zero correction along their contrast.""" specimen = np.full((6, 4, 16), 100, dtype=np.float32) - if stripe: - specimen[..., 6] += 500 + specimen[..., 6] += 500 shift, score = TileFusion.register_and_score( specimen, specimen.copy(), 3, max_shift=(3, 2, 8) ) @@ -204,7 +289,6 @@ def test_identical_low_texture_crops_preserve_zero_displacement(stripe): assert score == pytest.approx(1, abs=1e-6) -@pytest.mark.unit def test_similarity_scores_common_geometric_support_and_rejects_unrelated_signal(): """Exclude opposing wedge padding while retaining an independent mismatch check.""" rng = np.random.default_rng(38) @@ -222,17 +306,9 @@ def test_similarity_scores_common_geometric_support_and_rejects_unrelated_signal assert _ssim(fixed, unrelated, 7, valid_mask=common) < 0.7 -@pytest.mark.unit @pytest.mark.parametrize("unrelated", (False, True)) def test_thin_depth_registration_scores_only_common_planes(unrelated): - """Use matched XY samples when geometric gaps prevent a 3D SSIM window. - - Parameters - ---------- - unrelated : bool - Replace the second observed field with independent signal to check - that projecting common planes does not manufacture a good match. - """ + """Use matched XY samples when geometric gaps prevent a 3D SSIM window.""" rng = np.random.default_rng(83) truth = gaussian_filter(rng.uniform(0, 1000, (5, 45, 37)), (0, 1, 1)) support = np.zeros(truth.shape, bool) diff --git a/tests/cpu/test_registration_optimization.py b/tests/cpu/test_registration_optimization.py index f777b61..7ae0d1f 100644 --- a/tests/cpu/test_registration_optimization.py +++ b/tests/cpu/test_registration_optimization.py @@ -5,8 +5,9 @@ from opm_processing.imageprocessing.tilefusion import TileFusion +pytestmark = pytest.mark.unit + -@pytest.mark.unit @pytest.mark.parametrize("method", ["TWO_ROUND", "TWO_ROUND_ITERATIVE"]) def test_cycle_rejection_preserves_depth_links_and_component_anchors( fusion_operator, method @@ -32,7 +33,6 @@ def test_cycle_rejection_preserves_depth_links_and_component_anchors( assert (0, 1) in fusion.pairwise_metrics # Keep raw measurements for cache reuse. -@pytest.mark.unit def test_depth_measurement_weights_reflect_registration_sampling(): """Match the analytic weighted solution for unequal registration resolutions.""" links = [ diff --git a/tests/cpu/test_registration_retries.py b/tests/cpu/test_registration_retries.py new file mode 100644 index 0000000..bbb1b82 --- /dev/null +++ b/tests/cpu/test_registration_retries.py @@ -0,0 +1,162 @@ +"""Adaptive registration must recompute a consistent complete link graph.""" + +import numpy as np +import pytest +import tensorstore as ts + +from opm_processing.imageprocessing.tilefusion import ( + TileFusion, +) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "correction,expected,automatic,score", + [ + ((15, -284, 0), (17, 299, 33), True, 0.9), + ((-15, 46, 0), (17, 198, 33), True, 0.9), + ((0, 0, 34), (13, 198, 39), True, 0.9), + ((0, 420, 0), (13, 441, 33), True, 0.9), + ((15, -284, 0), None, False, 0.9), + ((15, -284, 0), None, True, 0.69), + ((15, -284, 0), None, True, float("nan")), + ((15, -284, 0), (17, 299, 33), True, 0.7), + ], + ids=( + "zy", + "negative-z", + "x", + "large-y", + "explicit", + "low-score", + "invalid-score", + "threshold", + ), +) +def test_limits_follow_rejected_peak_and_sampling_without_widening_other_pairs( + retry_fusion, correction, expected, automatic, score +): + """Use measured peaks only when automatic limits and similarity permit it.""" + fusion = retry_fusion + fusion._automatic_registration_limits = automatic + original = fusion._pair_registration_limits.copy() + fusion._rejected_registration_links = { + (1, 2): (correction, original[1, 2], score), + (3, 4): ((9999, 9999, 9999), original[3, 4], 0.69), + } + assert bool(fusion._expand_registration_limits()) == (expected is not None) + assert fusion._pair_registration_limits == ( + original if expected is None else {**original, (1, 2): expected} + ) + assert not fusion._expand_registration_limits() + + +@pytest.mark.unit +@pytest.mark.parametrize("failed_retry", [False, True]) +def test_selective_registration_reads_only_target_pair_and_preserves_raw_outliers( + fusion_operator, monkeypatch, failed_retry +): + """Read selected pairs while preserving raw links outside the selection.""" + rng = np.random.default_rng(481) + specimen = rng.uniform(0, 1000, (1, 1, 16, 16)).astype(np.float32) + source_tiles = [specimen.copy() for _ in range(6)] + if failed_retry: + source_tiles[5] = rng.uniform(0, 1000, specimen.shape).astype(np.float32) + raw = { + (0, 1): (0, 2, 3, 0.99), + (3, 4): (0, 1, 2, 0.99), + (3, 5): (0, 7, 1, 0.8), # A prior global outlier remains available. + (4, 5): (0, 8, 8, 0.99), # This stale target must be replaced or removed. + } + fusion = fusion_operator( + TileFusion, + source_tiles=source_tiles[:3], + position_arrays=tuple( + ts.array(np.stack((source_tiles[position], source_tiles[position + 3]))) + for position in range(3) + ), + time_dim=2, + position_dim=3, + _tile_positions=[(0, 0, 0)] * 6, + _tile_shapes=[(1, 16, 16)] * 6, + _tiles_by_time=((0, 1, 2), (3, 4, 5)), + _pixel_size=(1, 1, 1), + _tile_support_zy=[np.ones((1, 16), np.float32)] * 6, + _is_2d=True, + _max_workers=1, + _debug=False, + _tile_channel_nonzero={(index, 0): True for index in range(6)}, + downsample_factors=(1, 1, 1), + ssim_window=3, + threshold=0.7, + max_registration_shift_zyx=(0, 8, 8), + pairwise_metrics=raw.copy(), + optimized_pairwise_metrics={(0, 1): raw[0, 1], (3, 4): raw[3, 4]}, + _rejected_registration_links={(0, 2): ((0, 10, 0), (0, 8, 8), 0.6)}, + _registration_pair_sampling={(0, 1): (1, 2, 2)}, + ) + reads = [] + read_patch = fusion._read_registration_patch + + def read(tile, *args): + """Record which source tiles are read for a selective retry.""" + reads.append(tile) + assert tile in (4, 5) + return read_patch(tile, *args) + + monkeypatch.setattr(fusion, "_read_registration_patch", read) + fusion.refine_tile_positions_with_cross_correlation(pairs_to_register={(4, 5)}) + assert sorted(reads) == [4, 5] + assert { + pair: value for pair, value in fusion.pairwise_metrics.items() if pair != (4, 5) + } == {pair: value for pair, value in raw.items() if pair != (4, 5)} + assert fusion._registration_pair_sampling[0, 1] == (1, 2, 2) + assert fusion._rejected_registration_links[0, 2][2] == 0.6 + if failed_retry: + assert (4, 5) not in fusion.pairwise_metrics + else: + np.testing.assert_allclose( + fusion.pairwise_metrics[4, 5][:3], (0, 0, 0), atol=1e-6 + ) + assert fusion.pairwise_metrics[4, 5][3] == pytest.approx(1) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "depth_error,retries,automatic", + [(15, 2, True), (16, 2, True), (15, 0, True), (15, 2, False)], + ids=("15px", "16px", "no-retries", "explicit-limits"), +) +def test_real_depth_peak_outside_guard_band_is_remeasured_and_recovered( + registration_pair, + shifted_slab_pair, + registration_cache, + depth_error, + retries, + automatic, +): + """Recover a known physical drift only when retries and automatic limits permit it.""" + pair = shifted_slab_pair((64, 160, 88), 51, (-depth_error, -40, 8), seed=20261017) + fusion = registration_pair( + pair.volumes, + pair.positions, + ssim_window=7, + _automatic_registration_limits=automatic, + registration_retries=retries, + optimization_rel_threshold=0.5, + optimization_abs_threshold=1.5, + channel_to_use=0, + ) + registration_cache(fusion) + assert fusion.max_registration_shift_zyx[0] == 13 + fusion.register_with_retries() + if automatic and retries: + assert fusion._registration_retry_count >= 1 + np.testing.assert_allclose( + fusion.pairwise_metrics[0, 1][:3], pair.expected, atol=1 + ) + np.testing.assert_allclose(fusion.global_offsets[1], pair.expected, atol=1) + else: + assert fusion._registration_retry_count == 0 + assert (0, 1) not in fusion.pairwise_metrics + np.testing.assert_array_equal(fusion.global_offsets, np.zeros((2, 3))) diff --git a/tests/cpu/test_roi.py b/tests/cpu/test_roi.py index fccb762..3c0a544 100644 --- a/tests/cpu/test_roi.py +++ b/tests/cpu/test_roi.py @@ -6,8 +6,7 @@ import numpy as np import pytest -from yaozarrs import open_group, v05 -from yaozarrs.write.v05 import prepare_image +from yaozarrs import open_group from opm_processing.dataio.position_collection import ( create_position_collection, @@ -16,6 +15,7 @@ ) from opm_processing.dataio.roi import ( PhysicalRoi, + align_bounds_to_grid, roi_from_image_pixel_rectangle, roi_from_world_rectangle, world_roi_to_skewed_bounds, @@ -25,44 +25,16 @@ @pytest.mark.unit def test_napari_world_rectangle_round_trips_grid_and_all_z( - record_registration, tmp_path: Path + record_registration, fused_store_factory, tmp_path: Path ) -> None: """Save a pyramid-independent ROI plus every overlapping stage-Z level.""" source = tmp_path / "sample_max_z_fused.ome.zarr" - image = v05.Image( - multiscales=[ - v05.Multiscale( - name="preview", - axes=[ - {"name": "t", "type": "time"}, - {"name": "c", "type": "channel"}, - {"name": "z", "type": "space", "unit": "micrometer"}, - {"name": "y", "type": "space", "unit": "micrometer"}, - {"name": "x", "type": "space", "unit": "micrometer"}, - ], - datasets=[ - v05.Dataset( - path="0", - coordinateTransformations=[ - v05.ScaleTransformation(scale=[1, 1, 1, 0.5, 0.25]), - v05.TranslationTransformation( - translation=[0, 0, 3, 10, 20] - ), - ], - ) - ], - ) - ] - ) - _, arrays = prepare_image( - source, - image, - ((1, 1, 1, 8, 8), np.uint16), - extra_attributes={}, - writer="tensorstore", - overwrite=True, + fused_store_factory( + np.zeros((1, 1, 1, 8, 8), np.uint16), + name="sample_max_z_fused", + spacing=(1, 0.5, 0.25), + origin=(3, 10, 20), ) - arrays["0"].write(np.zeros((1, 1, 1, 8, 8), dtype=np.uint16)).result() tiles = [ {"time_index": 0, "position_index": 2, "origin_zyx_um": [0, 10, 20]}, {"time_index": 0, "position_index": 5, "origin_zyx_um": [4, 10, 20]}, @@ -98,44 +70,17 @@ def test_napari_world_rectangle_round_trips_grid_and_all_z( @pytest.mark.unit def test_napari_image_pixels_are_converted_to_ngff_physical_coordinates( record_registration, + fused_store_factory, tmp_path: Path, ) -> None: """Apply NGFF translation after mapping the Shapes layer to Image data.""" source = tmp_path / "sample_max_z_fused.ome.zarr" - image = v05.Image( - multiscales=[ - v05.Multiscale( - name="preview", - axes=[ - {"name": "t", "type": "time"}, - {"name": "c", "type": "channel"}, - {"name": "z", "type": "space", "unit": "micrometer"}, - {"name": "y", "type": "space", "unit": "micrometer"}, - {"name": "x", "type": "space", "unit": "micrometer"}, - ], - datasets=[ - v05.Dataset( - path="0", - coordinateTransformations=[ - v05.ScaleTransformation(scale=[1, 1, 1, 0.115, 0.115]), - v05.TranslationTransformation( - translation=[0, 0, 0, -8192.189, 213.58] - ), - ], - ) - ], - ) - ] - ) - _, arrays = prepare_image( - source, - image, - ((1, 1, 1, 5000, 5000), np.uint16), - extra_attributes={}, - writer="tensorstore", - overwrite=True, + fused_store_factory( + np.zeros((1, 1, 1, 5000, 5000), np.uint16), + name="sample_max_z_fused", + spacing=(1, 0.115, 0.115), + origin=(0, -8192.189, 213.58), ) - arrays["0"][0, 0, 0, 0, 0].write(0).result() processed_path = tmp_path / "sample_projection.ome.zarr" create_position_collection( processed_path, @@ -216,7 +161,7 @@ def test_registered_tile_origin_is_selected_per_timepoint() -> None: assert roi.tile_origin_yx_um(1, 4, (99.0, 99.0)) == (11.0, 32.0) -@pytest.mark.integration +@pytest.mark.unit def test_roi_fusion_keeps_multiple_stage_z_levels_and_crops_only_yx( record_registration, tmp_path: Path, @@ -295,7 +240,7 @@ def test_roi_fusion_keeps_multiple_stage_z_levels_and_crops_only_yx( np.testing.assert_array_equal(written.read().result()[..., :6, :5, :4], expected) -@pytest.mark.integration +@pytest.mark.unit def test_variable_roi_tiles_are_stored_cropped_and_reopened( record_registration, tmp_path: Path, @@ -399,3 +344,16 @@ def test_variable_roi_tiles_are_stored_cropped_and_reopened( expected[..., 2:4, 2:4] = 20 expected[:, :, 0] = 0 np.testing.assert_array_equal(fused, expected) + + +@pytest.mark.unit +@pytest.mark.parametrize("index", [0, 100_000]) +@pytest.mark.parametrize("epsilon", [1e-10, 1e-5]) +def test_outward_roi_rounding_has_an_absolute_pixel_tolerance(index, epsilon): + """A large image index must not erase a real fractional-pixel intersection.""" + origin, pitch = -8000.0, 0.115 + bounds = origin + pitch * np.array([index + 10 - epsilon, index + 12 + epsilon]) + expected = (10, 12) if epsilon < 1e-7 else (9, 13) + aligned = align_bounds_to_grid((*bounds, *bounds), (origin, origin), (pitch, pitch)) + expected = origin + pitch * (index + np.tile(expected, 2)) + np.testing.assert_allclose(aligned, expected, atol=1e-10, rtol=0) diff --git a/tests/cpu/test_roi_resume.py b/tests/cpu/test_roi_resume.py index a5b7d9d..9224eda 100644 --- a/tests/cpu/test_roi_resume.py +++ b/tests/cpu/test_roi_resume.py @@ -1,11 +1,10 @@ """Interrupted ROI processing must retain only durably completed channels.""" import importlib -from dataclasses import replace import numpy as np import pytest -from typer.testing import CliRunner +import tensorstore as ts from opm_processing import process_roi as roi_command from opm_processing.dataio.position_collection import ( @@ -24,17 +23,13 @@ def test_roi_resumes_at_last_durable_channel(roi_run, monkeypatch, interruption) run = roi_run output = run.output_dir / "sample_deskewed.ome.zarr" state_path = run.output_dir / "sample.processing.json" - reference_dir = run.output_dir.parent / "reference" - roi_command.process_roi( - run.source, run.roi_path, output=reference_dir, deconvolve=False - ) - reference = open_position_collection(reference_dir / "sample_deskewed.ome.zarr") - expected = [array.read().result() for array in reference.arrays] + expected = run.expected_tiles real_complete = ProcessingState.complete_channel real_write = run.process.write_checkpointed_roi_channel stop_key = (0, 2, 2) if interruption == "after_final_channel" else (0, 2, 0) def checkpoint(self, path, time, position, channel, **kwargs): + """Record a channel checkpoint only after the associated pixels are durable.""" key = (time, position, channel) if key == stop_key and interruption == "before_checkpoint": raise KeyboardInterrupt @@ -43,25 +38,19 @@ def checkpoint(self, path, time, position, channel, **kwargs): raise KeyboardInterrupt def failing_write(target, value, state, path, key, **kwargs): + """Inject a partial write failure before the channel checkpoint.""" if key == stop_key: - - class FailedTarget: - def write(self, value): - # Leave a partially written channel, then fail its write future. - target[0].write(value[0]).result() - return self - - def result(self): - raise OSError("interrupted channel write") - - return real_write(FailedTarget(), value, state, path, key, **kwargs) + # Leave a partial channel and fail the write future before checkpointing. + target[0].write(value[0]).result() + readonly = ts.open(target.spec(), read=True, write=False).result() + return real_write(readonly, value, state, path, key, **kwargs) return real_write(target, value, state, path, key, **kwargs) if interruption == "during_write": monkeypatch.setattr( run.process, "write_checkpointed_roi_channel", failing_write ) - expected_error = OSError + expected_error = ValueError else: monkeypatch.setattr(ProcessingState, "complete_channel", checkpoint) expected_error = KeyboardInterrupt @@ -102,6 +91,7 @@ def test_empty_roi_channel_is_checkpointed_and_skipped(roi_run, monkeypatch): real_complete = ProcessingState.complete_channel def stop_after_empty(self, *args, **kwargs): + """Interrupt after recording a deliberately empty channel.""" real_complete(self, *args, **kwargs) raise KeyboardInterrupt @@ -137,38 +127,25 @@ def stop_after_empty(self, *args, **kwargs): assert ProcessingState.read(state_path).zero_channels(output) == {(0, 1, 0)} -@pytest.mark.integration -def test_roi_no_resume_overwrites_completed_channels(roi_run): - """Explicit restart recomputes data instead of using previous checkpoints.""" - run = roi_run - roi_command.process_roi(run.source, run.roi_path, deconvolve=False) - run.raw.arrays[1].write(np.uint16(901)).result() - roi_command.process_roi(run.source, run.roi_path, resume=False, deconvolve=False) - output = open_position_collection(run.output_dir / "sample_deskewed.ome.zarr") - np.testing.assert_array_equal( - output.arrays[1].read().result(), run.expected_tiles[1] - ) - changed = output.arrays[0].read().result() - np.testing.assert_array_equal(changed, run.overwritten_tile) - - @pytest.mark.integration @pytest.mark.parametrize( "flag, expected", ((None, True), ("--resume", True), ("--no-resume", False)) ) -def test_roi_cli_resumes_by_default(roi_run, flag, expected): +def test_roi_processing_resumes_by_default(roi_run, flag, expected): """CLI resume preserves saved pixels; explicit restart replaces them.""" run = roi_run - args = [str(run.source), "--no-deconvolve"] + ([] if flag is None else [flag]) - first = CliRunner().invoke(roi_command.app, args) - assert first.exit_code == 0, first.output + options = {} if flag is None else {"resume": expected} + roi_command.process_roi(run.source, run.roi_path, deconvolve=False, **options) output_path = run.output_dir / "sample_deskewed.ome.zarr" before = open_position_collection(output_path).arrays[0].read().result() np.testing.assert_array_equal(before, run.expected_tiles[0]) run.raw.arrays[1].write(np.uint16(901)).result() - second = CliRunner().invoke(roi_command.app, args) - assert second.exit_code == 0, second.output + roi_command.process_roi(run.source, run.roi_path, deconvolve=False, **options) after = open_position_collection(output_path).arrays[0].read().result() + np.testing.assert_array_equal( + open_position_collection(output_path).arrays[1].read().result(), + run.expected_tiles[1], + ) if expected: np.testing.assert_array_equal(after, before) else: @@ -200,15 +177,12 @@ def test_recreated_output_does_not_reuse_stale_checkpoints(tmp_path): assert reopened.completed_tiles(output) == set() -@pytest.mark.integration -def test_roi_command_fuses_selected_tiles_and_projects_pixels(tmp_path): +@pytest.mark.unit +def test_roi_command_fuses_selected_tiles_and_projects_pixels( + tmp_path, acquisition_factory +): """Recover a known line through real ROI processing, fusion and projection. - Parameters - ---------- - tmp_path - Directory for the simulated acquisition, physical ROI and saved images. - Notes ----- Two colocated views contain the same fluorescent line on a constant @@ -216,13 +190,8 @@ def test_roi_command_fuses_selected_tiles_and_projects_pixels(tmp_path): known to be two. Fusion must preserve that gain even at half-filled Z bins. No metadata, reconstruction, registration or storage boundary is mocked. """ - from opm_processing.dataio.acquisition import ChannelMetadata from opm_processing.dataio.position_collection import open_image_array from opm_processing.dataio.roi import PhysicalRoi - from tests.fixtures.acquisition import ( - simulated_acquisition_metadata, - write_simulated_acquisition, - ) shape = (1, 2, 3, 64, 64, 32) specimen = np.full(shape[-3:], 100.0) @@ -232,20 +201,14 @@ def test_roi_command_fuses_selected_tiles_and_projects_pixels(tmp_path): specimen[None, None, None] * spectral_gains[None, None, :, None, None, None], shape, ).astype(np.uint16) - metadata = replace( - simulated_acquisition_metadata(tmp_path / "sample.ome.zarr", "mirror", shape), - channels=tuple( - ChannelMetadata(index, f"{wavelength}nm", wavelength, 10, None) - for index, wavelength in enumerate((488, 561, 637)) - ), - array_paths=("0/0", "1/0"), + dataset = acquisition_factory( + raw, stage_positions_zxy=((0, 0, 0), (0, 0, 0)), scan_axis_step_um=1.0, pixel_size_um=1.0, camera_offset=0.0, camera_conversion=1.0, ) - write_simulated_acquisition(metadata, raw) roi = PhysicalRoi( bounds_yx_um=(56.0, 60.0, 4.0, 20.0), source_path=tmp_path / "sample_max_z_fused.ome.zarr", @@ -264,7 +227,7 @@ def test_roi_command_fuses_selected_tiles_and_projects_pixels(tmp_path): ) roi_path = roi.write(tmp_path / "sample_roi.json") roi_command.process_roi( - metadata.path, + dataset.path, roi_path, deconvolve=False, flatfield_correction=False, diff --git a/tests/cpu/test_stage_placement_geometry.py b/tests/cpu/test_stage_placement_geometry.py index 628edf7..92fa91c 100644 --- a/tests/cpu/test_stage_placement_geometry.py +++ b/tests/cpu/test_stage_placement_geometry.py @@ -1,4 +1,4 @@ -"""Independent optical forward model for stage-Z tile placement.""" +"""Stage placement across acquisition metadata, deskew and fusion.""" import numpy as np import pytest @@ -9,14 +9,14 @@ ) from opm_processing.imageprocessing.tilefusion import TileFusion from opm_processing.process import process -from tests.fixtures.acquisition import opm_v2_frame_metadata, write_opm_v2_zarr + +pytestmark = pytest.mark.integration -@pytest.mark.integration @pytest.mark.parametrize("angle", (30.0, 45.0)) @pytest.mark.parametrize("mode", ("stage", "mirror")) def test_physical_stage_z_translation_preserves_registered_xy( - processing_options, tmp_path, angle, mode + processing_options, tmp_path, stage_placement_acquisition ): """Sample a fixed bead through tilted planes without using placement code. @@ -25,84 +25,8 @@ def test_physical_stage_z_translation_preserves_registered_xy( For mirror scanning the plane translates while the stage remains fixed. Both experiments move the stage only in Z between volumes. """ - pixel_um = 0.115 - step_um = 0.23 - scans, camera_height, camera_width = 40, 64, 48 - camera_u, camera_v = np.indices((camera_height, camera_width), dtype=float) - theta = np.deg2rad(angle) - plane_z = camera_u * pixel_um * np.sin(theta) - plane_y = camera_u * pixel_um * np.cos(theta) - plane_x = camera_v * pixel_um - bead_zyx_um = np.asarray((20, 64, 24)) * pixel_um - sigma_um = 1.5 * pixel_um - stage_z_values_um = -np.asarray((0, 6, 12)) * pixel_um - raw = np.zeros((1, 3, 1, scans, camera_height, camera_width), dtype=np.uint16) - frames = [] - for position, stage_z_um in enumerate(stage_z_values_um): - for scan in range(scans): - if mode == "stage": - # Forward acquisition: the stationary plane sees the specimen - # move from negative scan-axis displacement back to zero. - stage_scan_um = (scan - (scans - 1)) * step_um - plane_scan_um = 0.0 - else: - stage_scan_um = 0.0 - plane_scan_um = scan * step_um - specimen_z = plane_z - stage_z_um - specimen_y = plane_y + plane_scan_um - stage_scan_um - specimen_x = plane_x - radius_squared = ( - (specimen_z - bead_zyx_um[0]) ** 2 - + (specimen_y - bead_zyx_um[1]) ** 2 - + (specimen_x - bead_zyx_um[2]) ** 2 - ) - raw[0, position, 0, scan] = np.rint( - 20_000 * np.exp(-radius_squared / (2 * sigma_um**2)) - ).astype(np.uint16) - frames.append( - opm_v2_frame_metadata( - index={"t": 0, "p": position, "c": 0, "z": scan}, - daq_metadata={ - "mode": mode, - "channel_states": [True], - "exposure_channels_ms": [10.0], - "laser_powers": [10.0], - "interleaved": True, - "blanking": True, - "current_channel": "488nm", - "scan_axis_step_um": step_um, - "image_mirror_position": float(scan), - "image_mirror_step_um": step_um, - "image_mirror_range_um": scans * step_um, - }, - opm_metadata={ - "angle_deg": angle, - "camera_Zstage_orientation": "normal", - "camera_XYstage_orientation": "normal", - "camera_mirror_orientation": "normal", - }, - stage_metadata={ - "x_pos": stage_scan_um, - "y_pos": 0.0, - "z_pos": stage_z_um, - "excess_image": False, - }, - camera_shape_yx=(camera_height, camera_width), - pixel_size_um=pixel_um, - camera_offset=0.0, - camera_conversion=1.0, - runner_time_ms=float(position * scans + scan), - hardware_triggered=True, - ) - ) - path = tmp_path / "physical_stage.zarr" - write_opm_v2_zarr( - path=path, - raw_data=raw, - labels=("t", "p", "c", "z", "y", "x"), - chunks=(1, 1, 1, 1, camera_height, camera_width), - frame_metadatas=frames, - ) + path = stage_placement_acquisition.path + pixel_um = stage_placement_acquisition.pixel_um process( root_path=path, deconvolve=False, diff --git a/tests/cpu/test_tiled_pipeline.py b/tests/cpu/test_tiled_pipeline.py new file mode 100644 index 0000000..2e973c7 --- /dev/null +++ b/tests/cpu/test_tiled_pipeline.py @@ -0,0 +1,22 @@ +"""Extended mosaics with identical full and tiled orthogonal processing.""" + +import pytest + +from tests.pipeline import ( + test_extended_mosaic_full_and_tiled_agree as test_extended_mosaic_full_and_tiled_agree, +) + +pytestmark = [ + pytest.mark.integration, + pytest.mark.parametrize("deconvolve", [False]), + pytest.mark.parametrize( + "mosaic_acquisition", + [ + pytest.param((mode, step), id=f"{mode}-{step}um") + for mode, step in [ + (mode, step) for mode in ("stage", "mirror") for step in (0.4, 1.2) + ] + ], + indirect=True, + ), +] diff --git a/tests/cpu/test_tilefusion.py b/tests/cpu/test_tilefusion.py index a68cbd6..5bb051c 100644 --- a/tests/cpu/test_tilefusion.py +++ b/tests/cpu/test_tilefusion.py @@ -1,23 +1,9 @@ -"""Numerical unit and storage integration tests for tile fusion.""" - -from types import SimpleNamespace +"""Numerical and storage-boundary unit tests for tile fusion.""" import numpy as np import pytest -from scipy.ndimage import gaussian_filter -from skimage.measure import block_reduce as block_reduce_cpu -from typer.testing import CliRunner -from yaozarrs import open_group, v05 -from yaozarrs.write.v05 import prepare_image +import tensorstore as ts -from opm_processing.dataio.position_collection import ( - create_position_collection, - open_image_array, - open_position_collection, -) -from opm_processing.dataio.processing_state import ProcessingState -from opm_processing.fuse import app as fuse_app -from opm_processing.imageprocessing import maxtilefusion as maxtilefusion_module from opm_processing.imageprocessing import tilefusion as tilefusion_module from opm_processing.imageprocessing.coordinates import ( stage_positions_to_image_coordinates, @@ -25,108 +11,88 @@ from opm_processing.imageprocessing.maxtilefusion import MaxTileFusion from opm_processing.imageprocessing.tilefusion import TileFusion +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize("half_width", [1, 3, 5]) +def test_registration_similarity_uses_acquired_window_support(half_width): + """Identical oblique samples have unit similarity only with a valid window.""" + rng = np.random.default_rng(321) + image = rng.uniform(10, 100, size=(17, 19, 21)).astype(np.float32) + z, y, _ = np.indices(image.shape) + mask = np.abs(z - y) < half_width + score = tilefusion_module._registration_similarity( + image, image, (0, 0, 0), 15, masks=(mask, mask), registration_axis=1 + ) + if half_width == 1: + assert score == -np.inf + else: + assert score == pytest.approx(1.0) + -@pytest.mark.unit @pytest.mark.parametrize("dtype", (np.uint16, np.float32)) @pytest.mark.parametrize("strided", (False, True)) @pytest.mark.parametrize("shared", (False, True)) @pytest.mark.parametrize("partial", (False, True)) def test_weighted_fusion_rows_preserve_masks_offsets_and_output_range( - dtype, strided, shared, partial + weighted_fusion_case, dtype, strided, shared ): - """Compare strided weighted pixels with independent separable-weight truth. - - Parameters - ---------- - dtype : numpy.dtype - Destination type, retaining float32 values or clipping uint16 values. - strided : bool - Use cropped strided views, or contiguous blocks with explicit crop offsets. - shared : bool - Share one denominator across complete channels, or handle missing channels. - partial : bool - Include a half-filled interpolation bin with known geometric coverage. - """ - first = np.arange(3 * 4 * 5 * 10, dtype=np.float32).reshape(3, 4, 5, 10) - first[0, 1, 1, 1] = 0 - first[0, 1, 1, 3] = -20 - first[1, 1, 1, 1] = 90000 - second = first * np.float32(0.5) + np.float32(10) - attenuation_per_um = np.asarray((0.025, 0.05, 0.075), np.float32) - optical_path_um = np.float32(8) - second *= np.exp(-attenuation_per_um * optical_path_um)[:, None, None, None] - depth_gains = (None, np.exp(attenuation_per_um * optical_path_um)) - crop = np.s_[:, 1:3, 1:4, 1:9:2] if strided else np.s_[:, 1:3, 1:4, 1:5] - source_views = (first[crop], second[crop]) - present = ( - (np.ones(3, bool), np.ones(3, bool)) - if shared - else (np.asarray((True, True, False)), np.asarray((True, False, True))) - ) - support = np.asarray(((True, False, True), (False, True, True))) - if partial: - support = support.astype(np.float32) - support[0, 0] = 0.5 - for pixels in source_views: - pixels[:, 0, 0] *= np.float32(0.5) - z_weights = np.asarray((0.5, 1), np.float32) - y_weights = np.asarray((1, 0.25, 0.5), np.float32) - x_weights = np.asarray((1, 0.5, 0.25, 1), np.float32) - spatial_weights = (z_weights[:, None, None] * y_weights[None, :, None]) * x_weights[ - None, None, : - ] - spatial_weights *= (support > 0)[..., None] - accumulated = np.zeros((3, 4, 7, 10), np.float32) - weights = np.zeros((1 if shared else 3, *accumulated.shape[1:]), np.float32) - expected_sum = np.zeros_like(accumulated) - expected_weight = np.zeros_like(accumulated) - selection = np.s_[:, 1:3, 2:5, 3:7] - source_origin = (0, 0, 0) if strided else (1, 1, 1) + """Compare strided weighted pixels with independent separable-weight truth.""" + case = weighted_fusion_case for source, pixels, channels, gains in zip( - (first, second), source_views, present, depth_gains, strict=True + (case.first, case.second), + case.source_views, + case.present, + case.depth_gains, + strict=True, ): tilefusion_module._accumulate_tile_block( - accumulated, - weights, + case.accumulated, + case.weights, pixels if strided else source, channels, - support, - z_weights, - y_weights, - x_weights, + case.support, + case.z_weights, + case.y_weights, + case.x_weights, 1, 2, 3, - *source_origin, + *case.source_origin, gains, ) - contribution = spatial_weights[None] * channels[:, None, None, None] + contribution = case.spatial_weights[None] * channels[:, None, None, None] corrected = pixels if gains is None else pixels * gains[:, None, None, None] - expected_sum[selection] += corrected * contribution - expected_weight[selection] += contribution * support[None, ..., None] - np.testing.assert_array_equal(accumulated, expected_sum) + case.expected_sum[case.selection] += corrected * contribution + case.expected_weight[case.selection] += ( + contribution * case.support[None, ..., None] + ) + np.testing.assert_array_equal(case.accumulated, case.expected_sum) np.testing.assert_array_equal( - weights, expected_weight[:1] if shared else expected_weight + case.weights, case.expected_weight[:1] if shared else case.expected_weight ) expected = np.divide( - expected_sum, - expected_weight, - out=np.zeros_like(expected_sum), - where=expected_weight > 0, + case.expected_sum, + case.expected_weight, + out=np.zeros_like(case.expected_sum), + where=case.expected_weight > 0, ) if dtype == np.uint16: expected = np.clip(expected, 0, 65535).astype(np.uint16) destination = np.full((3, 4, 7, 12), 173, dtype=dtype) if strided: - tilefusion_module._normalize_block(accumulated, weights, destination[..., 1:11]) + tilefusion_module._normalize_block( + case.accumulated, case.weights, destination[..., 1:11] + ) else: - tilefusion_module._normalize_block(accumulated, weights, destination, 0, 0, 1) + tilefusion_module._normalize_block( + case.accumulated, case.weights, destination, 0, 0, 1 + ) np.testing.assert_array_equal(destination[..., 1:11], expected) np.testing.assert_array_equal(destination[..., (0, 11)], 173) - np.testing.assert_array_equal(accumulated, expected_sum) + np.testing.assert_array_equal(case.accumulated, case.expected_sum) -@pytest.mark.unit def test_uint16_weighted_fusion_preserves_integer_boundary_rounding() -> None: """Retain mixed-precision accumulation before truncating camera pixels.""" profile = np.linspace(0.1, 1, 128, dtype=np.float32) @@ -164,9 +130,7 @@ def test_uint16_weighted_fusion_preserves_integer_boundary_rounding() -> None: np.testing.assert_array_equal(output, 778) -@pytest.mark.unit def test_max_projection_pyramid_clamps_partial_source_edge_chunks( - tensorstore_dataset, fusion_operator, ) -> None: """Never request factor-rounded source bounds beyond level-zero shape.""" @@ -176,9 +140,9 @@ def test_max_projection_pyramid_clamps_partial_source_edge_chunks( MaxTileFusion, chunk_size=512, multiscale_factors_yx=(1, 16), - fused_ts=tensorstore_dataset(source), + fused_ts=ts.array(source), ) - fusion.multiscale_arrays = (fusion.fused_ts, tensorstore_dataset(target)) + fusion.multiscale_arrays = (fusion.fused_ts, ts.array(target)) fusion.write_multiscales() @@ -188,7 +152,6 @@ def test_max_projection_pyramid_clamps_partial_source_edge_chunks( ) -@pytest.mark.unit @pytest.mark.parametrize("angle", (None, 30.0, 45.0)) @pytest.mark.parametrize("reverse_z", (False, True)) def test_stage_z_is_reversed_only_for_lab_placement(angle, reverse_z) -> None: @@ -213,33 +176,6 @@ def test_stage_z_is_reversed_only_for_lab_placement(angle, reverse_z) -> None: np.testing.assert_array_equal(stage_positions, original) -@pytest.mark.integration -def test_max_projection_depth_tiles_share_xy_footprint(tmp_path) -> None: - """Depth tiles with fixed stage XY must fuse into a single tile footprint.""" - source = np.arange(64, dtype=np.uint16).reshape(1, 1, 1, 8, 8) - source_path = tmp_path / "depth_phantom.ome.zarr" - collection = create_position_collection( - source_path, (1, 2, 1, 1, 8, 8), (0.23, 0.115, 0.115) - ) - for array in collection.arrays: - array.write(source).result() - fusion = MaxTileFusion( - ts_dataset=open_position_collection(source_path).arrays, - tile_positions=((3939.54, -7456.06, 4207.16), (3800.09, -7456.06, 4207.16)), - output_path=tmp_path / "depth_max_z_fused.ome.zarr", - pixel_size=(0.23, 0.115, 0.115), - opm_angle_deg=30.0, - blend_pixels=(0, 0), - chunk_size=4, - ) - fusion.run() - - np.testing.assert_array_equal(fusion.tile_positions[0], fusion.tile_positions[1]) - written = open_image_array(tmp_path / "depth_max_z_fused.ome.zarr") - np.testing.assert_array_equal(written.read().result(), source) - - -@pytest.mark.unit @pytest.mark.parametrize( ("dtype", "values"), ( @@ -247,44 +183,15 @@ def test_max_projection_depth_tiles_share_xy_footprint(tmp_path) -> None: (np.float32, (0.25, 0.75, 1.25)), ), ) -def test_max_projection_fusion_is_chunked_and_sparse( - tensorstore_dataset, memory_store, fusion_operator, monkeypatch, dtype, values -): +def test_max_projection_fusion_is_chunked_and_sparse(projection_fusion, dtype, values): """Fuse distant tiles without allocating arrays the size of the full canvas.""" tile_shape = (4, 4) tile_positions = np.asarray(((0, 0), (0, 2), (0, 10_000)), dtype=float) fused_shape = (4, 10_004) tiles = tuple( - tensorstore_dataset(np.full((1, 1, 1, *tile_shape), value, dtype=dtype)) - for value in values - ) - fusion = fusion_operator( - MaxTileFusion, - pad_y=0, - pad_x=0, - ts_dataset=tiles, - tile_positions=tile_positions, - pixel_size=np.asarray((1.0, 1.0)), - offset=(0.0, 0.0), - time_dim=1, - channels=1, - tile_shape=tile_shape, - fused_shape=fused_shape, - time_range=None, - chunk_size=3, - weight_mask=np.ones(tile_shape, dtype=np.float32), - output_dtype=np.dtype(dtype), - fused_ts=memory_store((1, 1, 1, *fused_shape), dtype=dtype), + np.full((1, 1, 1, *tile_shape), value, dtype=dtype) for value in values ) - - real_zeros = maxtilefusion_module.np.zeros - - def reject_full_canvas(shape, *args, **kwargs): - if tuple(shape) == (fusion.channels, 1, *fusion.fused_shape): - raise AssertionError("fusion allocated the full mosaic canvas") - return real_zeros(shape, *args, **kwargs) - - monkeypatch.setattr(maxtilefusion_module.np, "zeros", reject_full_canvas) + fusion = projection_fusion(tiles, tile_positions, fused_shape) fusion.fuse_tiles() @@ -296,34 +203,19 @@ def reject_full_canvas(shape, *args, **kwargs): np.testing.assert_allclose(fusion.fused_ts.read().result(), expected) -@pytest.mark.unit def test_zero_max_projection_tile_does_not_reduce_weighted_fusion( - tensorstore_dataset, - memory_store, - fusion_operator, + projection_fusion, ) -> None: """Exclude a globally zero projection channel from its neighbor's weights.""" tile_shape = (3, 4) - fusion = fusion_operator( - MaxTileFusion, - pad_y=0, - pad_x=0, - ts_dataset=( - tensorstore_dataset(np.full((1, 1, 1, *tile_shape), 100, dtype=np.uint16)), - tensorstore_dataset(np.zeros((1, 1, 1, *tile_shape), dtype=np.uint16)), - ), - tile_positions=np.asarray(((0, 0), (0, 0)), dtype=float), - pixel_size=np.asarray((1.0, 1.0)), - offset=(0.0, 0.0), - time_dim=1, - channels=1, - tile_shape=tile_shape, - fused_shape=tile_shape, - time_range=None, + fusion = projection_fusion( + [ + np.full((1, 1, 1, *tile_shape), 100, np.uint16), + np.zeros((1, 1, 1, *tile_shape), np.uint16), + ], + ((0, 0), (0, 0)), + tile_shape, chunk_size=4, - weight_mask=np.ones(tile_shape, dtype=np.float32), - output_dtype=np.dtype(np.uint16), - fused_ts=memory_store((1, 1, 1, *tile_shape), dtype=np.uint16), ) fusion.fuse_tiles() @@ -334,76 +226,8 @@ def test_zero_max_projection_tile_does_not_reduce_weighted_fusion( ) -@pytest.mark.integration -def test_max_projection_fusion_writes_centered_multiscales_and_offsets( - tmp_path, -) -> None: - """Persist every fused max level with rounded physical coordinates.""" - source = np.arange(64, dtype=np.uint16).reshape(1, 1, 1, 8, 8) - output_path = tmp_path / "max_z_fused.ome.zarr" - source_path = tmp_path / "ramp_phantom.ome.zarr" - collection = create_position_collection( - source_path, (1, 1, 1, 1, 8, 8), (0.34567, 0.1164, 0.1184) - ) - collection.arrays[0].write(source).result() - fusion = MaxTileFusion( - ts_dataset=open_position_collection(source_path).arrays, - tile_positions=((1.23456, 2.34567, 3.45678),), - output_path=output_path, - pixel_size=(0.34567, 0.1164, 0.1184), - spatial_offset_z_um=4.56789, - blend_pixels=(0, 0), - chunk_size=4, - reverse_stage_y=True, - reverse_stage_z=False, - ) - fusion.run() - - reopened = open_group(output_path) - metadata = reopened.ome_metadata() - assert isinstance(metadata, v05.Image) - datasets = metadata.multiscales[0].datasets - assert [dataset.path for dataset in datasets] == ["0", "1", "2", "3"] - base_scale = np.asarray(datasets[0].scale_transform.scale[-3:]) - assert datasets[0].translation_transform is not None - base_translation = np.asarray(datasets[0].translation_transform.translation[-3:]) - expected = source - for factor, dataset in zip((1, 2, 4, 8), datasets, strict=False): - assert dataset.scale_transform.scale == [ - 1.0, - 1.0, - 0.346, - round(0.116 * factor, 3), - round(0.118 * factor, 3), - ] - assert dataset.translation_transform is not None - assert dataset.translation_transform.translation == [ - 0.0, - 0.0, - 4.568, - -2.346, - 3.457, - ] - output_index = np.asarray([0, min(2, 7 // factor), min(3, 7 // factor)]) - output_coordinate = np.asarray( - dataset.translation_transform.translation[-3:] - ) + output_index * np.asarray(dataset.scale_transform.scale[-3:]) - source_index = output_index * np.asarray([1, factor, factor]) - np.testing.assert_allclose( - output_coordinate, - base_translation + source_index * base_scale, - ) - if factor > 1: - expected = source[..., ::factor, ::factor] - np.testing.assert_array_equal( - reopened[dataset.path].to_tensorstore().read().result(), - expected, - ) - - -@pytest.mark.unit def test_registration_pairs_include_every_physical_overlap() -> None: - """Match main by retaining face, edge, and corner overlaps.""" + """Select every physical face, edge and corner overlap.""" positions = [(z, y, x) for z in (0.0, 8.0) for y in (0.0, 8.0) for x in (0.0, 8.0)] pairs = tilefusion_module._select_registration_pairs( @@ -421,7 +245,6 @@ def test_registration_pairs_include_every_physical_overlap() -> None: assert set(pairs) == expected -@pytest.mark.unit def test_registration_pairs_use_each_cropped_tile_shape() -> None: """Detect ROI overlap from crop-adjusted origins and variable extents.""" positions = [(0.0, 0.0, 0.0), (0.0, 0.0, 6.0), (0.0, 0.0, 10.0)] @@ -437,7 +260,6 @@ def test_registration_pairs_use_each_cropped_tile_shape() -> None: assert pairs == [(0, 1)] -@pytest.mark.unit def test_shift_optimization_supports_variable_tiles_per_timepoint( fusion_operator, ) -> None: @@ -458,10 +280,7 @@ def test_shift_optimization_supports_variable_tiles_per_timepoint( np.testing.assert_allclose(fusion.global_offsets[4], (0, 3, 0), atol=1e-12) -@pytest.mark.unit -def test_block_fusion_does_not_infer_support_from_pixel_values( - memory_store, fusion_operator, monkeypatch -): +def test_block_fusion_does_not_infer_support_from_pixel_values(block_fusion): """Include legitimate zero-valued pixels when geometry marks them valid.""" tiles = [ np.full((1, 2, 3, 4), 10.0, dtype=np.float32), @@ -471,30 +290,16 @@ def test_block_fusion_does_not_infer_support_from_pixel_values( tiles[1][:, 0, :2, :2] = 0 tiles[1][:, 1, :1, :2] = 0 - fusion = fusion_operator( - TileFusion, - source_tiles=tiles, - fused_ts=memory_store((1, 1, 2, 3, 6)), - write_block_shape=[1, 1, 1, 2, 2], - offset_um=(0.0, 0.0, 0.0), - padded_shape=(2, 3, 6), - _pixel_size=(1.0, 1.0, 1.0), - _tile_positions=[(0.0, 0.0, 0.0), (0.0, 0.0, 2.0)], + fusion = block_fusion( + tiles, + [(0, 0, 0), (0, 0, 2)], + (2, 3, 6), + block_shape=[1, 1, 1, 2, 2], chunk_y=2, chunk_x=2, - fusion_ram_fraction=1.0, max_in_flight_writes=2, _max_workers=2, - _debug=False, - _deskew_support_zy=np.ones((2, 3), dtype=bool), - _tile_channel_nonzero={(0, 0): True, (1, 0): True}, - ) - monkeypatch.setattr( - tilefusion_module.psutil, - "virtual_memory", - lambda: SimpleNamespace(available=256), ) - fusion._fuse_by_blocks() expected = np.full((1, 1, 2, 3, 6), 10, dtype=np.uint16) @@ -505,11 +310,8 @@ def test_block_fusion_does_not_infer_support_from_pixel_values( np.testing.assert_array_equal(fusion.fused_ts.read().result(), expected) -@pytest.mark.unit def test_block_fusion_has_no_zero_weight_line_at_masked_wedge_boundary( - memory_store, - fusion_operator, - monkeypatch, + block_fusion, ): """Keep constant signal continuous where a deskew wedge meets a tile edge.""" tiles = [ @@ -520,32 +322,12 @@ def test_block_fusion_has_no_zero_weight_line_at_masked_wedge_boundary( tile[:, 0, :2, :] = 0 tile[:, 1, :1, :] = 0 - fusion = fusion_operator( - TileFusion, - source_tiles=tiles, - fused_ts=memory_store((1, 1, 2, 6, 3)), - write_block_shape=[1, 1, 2, 6, 3], - offset_um=(0.0, 0.0, 0.0), - padded_shape=(2, 6, 3), - _pixel_size=(1.0, 1.0, 1.0), - _tile_positions=[(0.0, 0.0, 0.0), (0.0, 2.0, 0.0)], + fusion = block_fusion( + tiles, + [(0, 0, 0), (0, 2, 0)], + (2, 6, 3), + support=np.array([[False, False, True, True], [False, True, True, True]]), y_profile=TileFusion._make_1d_profile(4, 2), - x_profile=np.ones(3, dtype=np.float32), - chunk_y=6, - chunk_x=3, - fusion_ram_fraction=1.0, - max_in_flight_writes=1, - _max_workers=1, - _debug=False, - _deskew_support_zy=np.asarray( - [[False, False, True, True], [False, True, True, True]], dtype=bool - ), - _tile_channel_nonzero={(0, 0): True, (1, 0): True}, - ) - monkeypatch.setattr( - tilefusion_module.psutil, - "virtual_memory", - lambda: SimpleNamespace(available=4096), ) fusion._fuse_by_blocks() @@ -556,104 +338,33 @@ def test_block_fusion_has_no_zero_weight_line_at_masked_wedge_boundary( np.testing.assert_array_equal(fusion.fused_ts.read().result(), expected) -@pytest.mark.unit -def test_zero_tile_channel_contributes_neither_signal_nor_fusion_weight( - memory_store, - fusion_operator, - monkeypatch, +@pytest.mark.parametrize("channels", [1, 2], ids=["zero-tile", "zero-channel"]) +def test_zero_channels_keep_single_contributors_and_blend_valid_overlap( + block_fusion, channels ): - """An explicitly zeroed tile must not attenuate an overlapping valid tile.""" + """Exclude absent channels while retaining independent weights for valid ones.""" tiles = [ - np.full((1, 1, 2, 4), 100.0, dtype=np.float32), - np.zeros((1, 1, 2, 4), dtype=np.float32), + np.full((channels, 1, 2, 4), 100, np.float32), + np.zeros((channels, 1, 2, 4), np.float32), ] - - fusion = fusion_operator( - TileFusion, - source_tiles=tiles, - fused_ts=memory_store((1, 1, 1, 2, 4)), - write_block_shape=[1, 1, 1, 2, 4], - offset_um=(0.0, 0.0, 0.0), - padded_shape=(1, 2, 4), - _pixel_size=(1.0, 1.0, 1.0), - _tile_positions=[(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)], - chunk_y=2, - chunk_x=4, - fusion_ram_fraction=1.0, - max_in_flight_writes=1, - _max_workers=1, - _debug=False, - _deskew_support_zy=np.ones((1, 2), dtype=bool), - _tile_channel_nonzero={(0, 0): True, (1, 0): False}, - ) - monkeypatch.setattr( - tilefusion_module.psutil, - "virtual_memory", - lambda: SimpleNamespace(available=4096), + if channels == 2: + tiles[1][1] = 200 + fusion = block_fusion( + tiles, + [(0, 0, 0)] * 2, + (1, 2, 4), + present={(0, c): True for c in range(channels)} + | {(1, c): c > 0 for c in range(channels)}, ) - fusion._fuse_by_blocks() - - np.testing.assert_array_equal( - fusion.fused_ts.read().result(), - np.full((1, 1, 1, 2, 4), 100, dtype=np.uint16), - ) - - -@pytest.mark.unit -def test_single_nonzero_channel_contributor_is_copied_in_overlap( - memory_store, fusion_operator, monkeypatch -): - """Copy one valid channel unchanged while blending another channel.""" - tiles = [ - np.full((2, 1, 2, 4), 100.0, dtype=np.float32), - np.stack( - ( - np.zeros((1, 2, 4), dtype=np.float32), - np.full((1, 2, 4), 200.0, dtype=np.float32), - ) - ), - ] - - fusion = fusion_operator( - TileFusion, - source_tiles=tiles, - fused_ts=memory_store((1, 2, 1, 2, 4)), - write_block_shape=[1, 1, 1, 2, 4], - offset_um=(0.0, 0.0, 0.0), - padded_shape=(1, 2, 4), - _pixel_size=(1.0, 1.0, 1.0), - _tile_positions=[(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)], - chunk_y=2, - chunk_x=4, - fusion_ram_fraction=1.0, - max_in_flight_writes=1, - _max_workers=1, - _debug=False, - _deskew_support_zy=np.ones((1, 2), dtype=bool), - _tile_channel_nonzero={(0, 0): True, (0, 1): True, (1, 0): False, (1, 1): True}, - ) - monkeypatch.setattr( - tilefusion_module.psutil, - "virtual_memory", - lambda: SimpleNamespace(available=4096), - ) - - fusion._fuse_by_blocks() - - np.testing.assert_array_equal( - fusion.fused_ts.read().result()[:, 0], - np.full_like(fusion.fused_ts.read().result()[:, 0], 100), - ) - np.testing.assert_array_equal( - fusion.fused_ts.read().result()[:, 1], - np.full_like(fusion.fused_ts.read().result()[:, 1], 150), + expected = np.broadcast_to( + np.array([100, 150][:channels])[None, :, None, None, None], + (1, channels, 1, 2, 4), ) + np.testing.assert_array_equal(fusion.fused_ts.read().result(), expected) -@pytest.mark.unit def test_zero_registration_channel_is_excluded_before_patch_reads( - tensorstore_dataset, fusion_operator, ) -> None: """Do not schedule registration reads for a pair containing a zero tile.""" @@ -676,8 +387,8 @@ def test_zero_registration_channel_is_excluded_before_patch_reads( _debug=False, _tile_channel_nonzero={(0, 0): True, (1, 0): False}, position_arrays=( - tensorstore_dataset(np.ones((1, 1, 4, 4, 4), np.uint16)), - tensorstore_dataset(np.zeros((1, 1, 4, 4, 4), np.uint16)), + ts.array(np.ones((1, 1, 4, 4, 4), np.uint16)), + ts.array(np.zeros((1, 1, 4, 4, 4), np.uint16)), ), ) @@ -686,46 +397,23 @@ def test_zero_registration_channel_is_excluded_before_patch_reads( assert fusion.pairwise_metrics == {} -@pytest.mark.unit @pytest.mark.parametrize("limit_y", (None, 50)) @pytest.mark.parametrize("depth_overlap", (20, 13)) def test_depth_registration_handles_large_xy_drift( - tensorstore_dataset, fusion_operator, limit_y, depth_overlap, capsys + registration_pair, shifted_slab_pair, block_fusion, limit_y, depth_overlap, capsys ): """Recover a thin-Z overlap with 60-pixel Y drift, or explain rejection.""" - rng = np.random.default_rng(71) - depth_step = 32 - depth_overlap - truth = gaussian_filter( - rng.uniform(0, 1000, (32 + depth_step, 240, 270)), (1, 2, 2) + pair = shifted_slab_pair( + (32, 180, 200), 32 - depth_overlap, (0, -60, 70), seed=71, spacing=(1, 1, 1) ) - truth = np.rint(truth).astype(np.uint16) - volumes = (truth[:32, 60:240, :200], truth[depth_step:, :180, 70:270]) - fusion = fusion_operator( - TileFusion, - _tile_support_zy=[np.ones(volume.shape[:2], np.float32) for volume in volumes], - downsample_factors=(3, 5, 5), - ssim_window=15, - threshold=0.7, - pairwise_metrics={}, - position_dim=2, - time_dim=1, - ) - fusion.z_dim, fusion.y_dim, fusion.x_dim = volumes[0].shape - fusion._pixel_size = (1.0, 1.0, 1.0) - fusion._tile_positions = [(0.0, 0.0, 0.0), (float(depth_step), 0.0, 0.0)] - fusion._tile_shapes = [volumes[0].shape] * 2 - fusion._tiles_by_time = ((0, 1),) - fusion._tile_time_indices = [0, 0] - fusion._is_2d = False - fusion._max_workers = 1 - fusion.max_registration_shift_zyx = (20, 100, 100) - if limit_y is not None: - fusion.max_registration_shift_zyx = (20, limit_y, 100) - fusion._debug = False - fusion._tile_channel_nonzero = {(0, 0): True, (1, 0): True} - fusion.position_arrays = tuple( - tensorstore_dataset(volume[None, None]) for volume in volumes + fusion = registration_pair( + pair.volumes, + pair.positions, + spacing=pair.spacing, + sampling=(3, 5, 5), + max_registration_shift_zyx=(20, 100 if limit_y is None else limit_y, 100), ) + del fusion._pair_registration_limits # Exercise explicit, shared bounds. fusion.refine_tile_positions_with_cross_correlation() fusion.optimize_shifts() @@ -736,6 +424,31 @@ def test_depth_registration_handles_large_xy_drift( assert set(fusion.pairwise_metrics) == {(0, 1)} np.testing.assert_allclose(fusion.global_offsets[1], (0, -60, 70), atol=1) assert "Registration warning" not in output + origins = ( + np.asarray(pair.positions) / pair.spacing + + fusion.global_offsets + + pair.starts[0] + ) + renderer = block_fusion( + [volume[None] for volume in pair.volumes], + origins, + pair.truth.shape, + block_shape=[1, 1, 7, 31, 37], + fused_ts=ts.array(np.zeros((1, 1, *pair.truth.shape), np.float32)), + output_dtype=np.dtype(np.float32), + ) + renderer._fuse_by_blocks() + covered = np.zeros(pair.truth.shape, bool) + for start, volume in zip(pair.starts, pair.volumes, strict=True): + covered[ + tuple(slice(a, a + n) for a, n in zip(start, volume.shape, strict=True)) + ] = True + np.testing.assert_allclose( + renderer.fused_ts.read().result()[0, 0], + np.where(covered, pair.truth, 0), + rtol=2e-6, + atol=0, + ) else: assert fusion.pairwise_metrics == {} assert "exceeded the ZYX shift limits" in output @@ -743,347 +456,42 @@ def test_depth_registration_handles_large_xy_drift( assert "not mutually registered" in output -@pytest.mark.unit -@pytest.mark.parametrize( - ("downsample_method", "is_2d", "padded_shape"), - ( - ("stride", False, (4, 8, 8)), - ("block_mean", False, (4, 8, 8)), - ("stride", True, (1, 8, 8)), - ), - ids=("volume-stride", "volume-block-mean", "projection-stride"), -) -def test_multiscale_storage_round_trip_matches_reference( - fusion_operator, - tmp_path, - downsample_method, - is_2d, - padded_shape, +@pytest.mark.parametrize("dtype", (np.float32, np.uint16)) +@pytest.mark.parametrize("block_shape,workers", [(None, 1), ((5, 11, 13), 2)]) +def test_fusion_is_independent_of_blocks_and_workers( + fusion_mosaic, block_fusion, dtype, block_shape, workers ): - """Reopen exact pyramid data and verify its common physical-space center.""" - fusion = fusion_operator( - TileFusion, - padded_shape=padded_shape, - offset_um=(4.51234, -3.01234, 12.25123), - _pixel_size=(0.81234, 0.51234, 0.25123), - time_dim=1, - channels=1, - multiscale_factors=(2, 4), - _is_2d=is_2d, - chunk_y=4, - chunk_x=4, - multiscale_downsample=downsample_method, - _max_workers=2, - fusion_ram_fraction=0.4, + """Recover one physical field across crop, coverage, channel and time boundaries.""" + case = fusion_mosaic + block_shape = case.shape if block_shape is None else block_shape + fusion = block_fusion( + case.tiles, + case.origins, + case.shape, + support=case.supports[0], + present=case.present, + block_shape=[1, 1, *block_shape], + fused_ts=ts.array(np.zeros(case.expected.shape, dtype)), + output_dtype=np.dtype(dtype), + _variable_roi_tiles=True, + _tile_shapes=case.shapes, + _tile_support_zy=case.supports, + _tile_profiles=case.profiles, + _depth_intensity_gains=case.gains, + _tiles_by_time=((0, 1, 2), (3, 4, 5)), + time_dim=2, + chunk_y=block_shape[1], + chunk_x=block_shape[2], + _max_workers=workers, max_in_flight_writes=2, - _input_chunk_zyx=(2, 4, 4), - output_dtype=np.dtype(np.uint16), - ) - - path = tmp_path / "multiscale.ome.zarr" - scale0, fusion.write_block_shape = fusion._prepare_fused_image(path) - source_shape = (1, 1, *padded_shape) - source = np.arange(np.prod(source_shape), dtype=np.uint16).reshape(source_shape) - scale0.write(source).result() - fusion._write_multiscales() - - reopened = open_group(path) - metadata = reopened.ome_metadata() - assert metadata is not None - datasets = metadata.multiscales[0].datasets - assert [dataset.path for dataset in datasets] == ["0", "1", "2"] - - expected = source - rounded_offset = np.asarray([round(float(value), 3) for value in fusion.offset_um]) - rounded_pixel_size = np.asarray( - [round(float(value), 3) for value in fusion._pixel_size] + fusion_ram_fraction=0.1 if workers == 2 else 1, ) - base_center = ( - rounded_offset - + (np.asarray(padded_shape, dtype=np.float64) - 1.0) * rounded_pixel_size / 2.0 - ) - for absolute_factor, dataset in zip((1, 2, 4), datasets, strict=False): - z_factor = 1 if is_2d else absolute_factor - expected_scale = np.asarray( - [ - round(float(value), 3) - for value in ( - 1.0, - 1.0, - rounded_pixel_size[0] * z_factor, - rounded_pixel_size[1] * absolute_factor, - rounded_pixel_size[2] * absolute_factor, - ) - ] - ) - center_shift = 0.5 if downsample_method == "block_mean" else 0.0 - expected_translation = np.asarray( - [ - round(float(value), 3) - for value in ( - 0.0, - 0.0, - rounded_offset[0] - + center_shift * (z_factor - 1) * rounded_pixel_size[0], - rounded_offset[1] - + center_shift * (absolute_factor - 1) * rounded_pixel_size[1], - rounded_offset[2] - + center_shift * (absolute_factor - 1) * rounded_pixel_size[2], - ) - ] - ) - np.testing.assert_allclose( - dataset.scale_transform.scale, - expected_scale, - ) - assert dataset.translation_transform is not None - np.testing.assert_allclose( - dataset.translation_transform.translation, - expected_translation, - ) - reopened_array = reopened[dataset.path].to_tensorstore() - - if downsample_method == "stride": - # A pyramid index must have exactly the same physical coordinate - # as the source voxel selected at index * absolute_factor. - output_index = np.asarray( - [ - min(1, int(reopened_array.shape[-3]) - 1), - min(2, int(reopened_array.shape[-2]) - 1), - min(3, int(reopened_array.shape[-1]) - 1), - ], - dtype=np.float64, - ) - source_index = output_index * np.asarray( - [z_factor, absolute_factor, absolute_factor], - dtype=np.float64, - ) - output_coordinate = np.asarray( - dataset.translation_transform.translation[-3:] - ) + output_index * np.asarray(dataset.scale_transform.scale[-3:]) - source_coordinate = rounded_offset + source_index * rounded_pixel_size - np.testing.assert_allclose( - output_coordinate, - source_coordinate, - ) - - spatial_shape = np.asarray(reopened_array.shape[-3:], dtype=np.float64) - physical_center = ( - expected_translation[-3:] - + (spatial_shape - 1.0) * expected_scale[-3:] / 2.0 - ) - if downsample_method == "block_mean": - np.testing.assert_allclose(physical_center, base_center, atol=0.002) - - if dataset.path == "0": - np.testing.assert_array_equal( - reopened_array.read().result(), - expected, - ) - continue - - z_relative_factor = 1 if is_2d else 2 - if downsample_method == "stride": - expected = expected[..., ::z_relative_factor, ::2, ::2] - else: - expected = block_reduce_cpu( - expected, - block_size=(1, 1, z_relative_factor, 2, 2), - func=np.mean, - ).astype(np.uint16) - np.testing.assert_array_equal( - reopened_array.read().result(), - expected, - ) - - assert not any(key.startswith("opm_") for key in reopened.attrs) - - -@pytest.mark.unit -def test_fused_multiscale_storage_preserves_float32_source_contract( - fusion_operator, tmp_path -): - """Float32 processed tiles create float32 fused output at every level.""" - fusion = fusion_operator( - TileFusion, - padded_shape=(2, 4, 4), - offset_um=(0.0, 0.0, 0.0), - _pixel_size=(1.0, 1.0, 1.0), - time_dim=1, - channels=1, - multiscale_factors=(2,), - _is_2d=False, - chunk_y=2, - chunk_x=2, - multiscale_downsample="stride", - _max_workers=1, - fusion_ram_fraction=0.4, - max_in_flight_writes=1, - _input_chunk_zyx=(1, 2, 2), - output_dtype=np.dtype(np.float32), - ) - - path = tmp_path / "float32-fused.ome.zarr" - scale0, fusion.write_block_shape = fusion._prepare_fused_image(path) - source = np.full((1, 1, 2, 4, 4), 0.25, dtype=np.float32) - scale0.write(source).result() - fusion._write_multiscales() - - reopened = open_group(path) - for level in ("0", "1"): - result = reopened[level].to_tensorstore().read().result() - assert result.dtype == np.float32 - factor = 2 ** int(level) - np.testing.assert_array_equal( - result, - source[..., ::factor, ::factor, ::factor], - ) - - -@pytest.mark.integration -def test_regenerate_max_z_flag_preserves_multiscale_pyramid_round_trip( - tmp_path, -) -> None: - """Project every fused scale and retain its exact spatial coordinates.""" - raw_path = tmp_path / "sample.zarr" - fused_path = tmp_path / "sample_fused.ome.zarr" - output_path = tmp_path / "sample_max_z_fused.ome.zarr" - shape = (2, 2, 5, 7, 9) - scales = ( - [1.0, 1.0, 0.81234, 0.51234, 0.25123], - [1.0, 1.0, 1.62468, 1.02468, 0.50246], - ) - translations = ( - [0.0, 0.0, 4.51234, -3.01234, 12.25123], - [0.0, 0.0, 4.91876, -2.75678, 12.37987], - ) - image = v05.Image( - multiscales=[ - v05.Multiscale( - name="registered-fused", - axes=[ - {"name": "t", "type": "time"}, - {"name": "c", "type": "channel"}, - {"name": "z", "type": "space", "unit": "micrometer"}, - {"name": "y", "type": "space", "unit": "micrometer"}, - {"name": "x", "type": "space", "unit": "micrometer"}, - ], - datasets=[ - v05.Dataset( - path=str(level), - coordinateTransformations=[ - v05.ScaleTransformation(scale=scales[level]), - v05.TranslationTransformation( - translation=translations[level] - ), - ], - ) - for level in range(2) - ], - ) - ] - ) - - level_shapes = (shape, (shape[0], shape[1], 3, 4, 5)) - level_specs = [(level_shape, np.uint16) for level_shape in level_shapes] - - _, raw_arrays = prepare_image( - raw_path, - image, - level_specs, - chunks=(1, 1, 2, 4, 5), - writer="tensorstore", - overwrite=True, - ) - for level, level_shape in enumerate(level_shapes): - raw_arrays[str(level)].write(np.zeros(level_shape, dtype=np.uint16)).result() - - rng = np.random.default_rng(281) - registered = rng.integers(0, 60_000, size=shape, dtype=np.uint16) - registered_levels = (registered, registered[..., ::2, ::2, ::2]) - _, fused_arrays = prepare_image( - fused_path, - image, - level_specs, - extra_attributes={}, - chunks=(1, 1, 2, 4, 5), - writer="tensorstore", - overwrite=True, - ) - for level, level_data in enumerate(registered_levels): - fused_arrays[str(level)].write(level_data).result() - - sentinel_specs = [ - ((level_shape[0], level_shape[1], 1, level_shape[3], level_shape[4]), np.uint16) - for level_shape in level_shapes - ] - _, sentinel_arrays = prepare_image( - output_path, - image, - sentinel_specs, - chunks=(1, 1, 1, 4, 5), - writer="tensorstore", - overwrite=True, - ) - for level, (sentinel_shape, _dtype) in enumerate(sentinel_specs): - sentinel_arrays[str(level)].write( - np.zeros(sentinel_shape, dtype=np.uint16) - ).result() - del raw_arrays, fused_arrays, sentinel_arrays - - processed_path = tmp_path / "sample_projection.ome.zarr" - state = ProcessingState.create(tmp_path / "sample.processing.json", raw_path) - state.initialize_run(processed_path, configuration={}, overwrite=True) - state.save_registration( - processed_path, - configuration={}, - pairwise_metrics={}, - ) - state.complete_registration( - processed_path, - fused_path=fused_path, - tiles=(), - ) - - result = CliRunner().invoke( - fuse_app, - [str(raw_path), "--regenerate-max-z", "--max-workers", "2"], - ) - assert result.exit_code == 0, result.output - - reopened = open_group(output_path) - metadata = reopened.ome_metadata() - assert isinstance(metadata, v05.Image) - datasets = metadata.multiscales[0].datasets - assert [dataset.path for dataset in datasets] == ["0", "1"] - for level, (dataset, level_data) in enumerate( - zip(datasets, registered_levels, strict=False) - ): - actual = reopened[dataset.path].to_tensorstore().read().result() - np.testing.assert_array_equal( - actual, - level_data.max(axis=2, keepdims=True), - ) - expected_scale = [ - scales[level][0], - scales[level][1], - *(round(value, 3) for value in scales[level][2:]), - ] - np.testing.assert_allclose(dataset.scale_transform.scale, expected_scale) - assert dataset.translation_transform is not None - expected_translation = [ - translations[level][0], - translations[level][1], - *(round(value, 3) for value in translations[level][2:]), - ] - expected_translation[2] += 0.5 * (level_data.shape[2] - 1) * expected_scale[2] - expected_translation[2:] = [ - round(value, 3) for value in expected_translation[2:] - ] + fusion._fuse_by_blocks() + actual = fusion.fused_ts.read().result() + expected = case.expected.astype(dtype) + if dtype == np.uint16: + np.testing.assert_array_equal(actual, expected) + else: np.testing.assert_allclose( - dataset.translation_transform.translation, - expected_translation, + actual, expected, rtol=4 * np.finfo(dtype).eps, atol=0 ) - assert not any(key.startswith("opm_") for key in reopened.attrs) - state = ProcessingState.read(tmp_path / "sample.processing.json") - assert state.registered_output_for_max_projection(output_path) == processed_path diff --git a/tests/cpu/test_time_axis_metadata.py b/tests/cpu/test_time_axis_metadata.py new file mode 100644 index 0000000..a167a58 --- /dev/null +++ b/tests/cpu/test_time_axis_metadata.py @@ -0,0 +1,299 @@ +"""Physical time-axis units and hardware cadence through the actual producer writer.""" + +from dataclasses import replace + +import pytest +from yaozarrs import open_group + +from opm_processing.dataio.acquisition import _camera_timing, inspect_acquisition +from opm_processing.dataio.position_collection import ( + create_position_collection, + create_variable_position_collection, + open_position_collection, + update_group_attributes, +) + + +@pytest.mark.unit +@pytest.mark.parametrize("cropped", [False, True]) +def test_written_time_axis_interval_and_units(tmp_path, cropped): + """Write the measured temporal interval with explicit NGFF units.""" + path = tmp_path / "timed.ome.zarr" + if cropped: + create_variable_position_collection( + path, + [(3, 1, 4, 8, 8)], + (0.23, 0.115, 0.115), + spatial_origins_zyx_um=[(0, 0, 0)], + time_interval_s=0.085, + ) + else: + create_position_collection( + path, + (3, 1, 1, 128, 8, 8), + (0.23, 0.115, 0.115), + multiscale_factors_yx=(2,), + time_interval_s=0.085, + ) + image = open_group(path)["0"].ome_metadata() + assert image.multiscales[0].axes[0].unit == "second" + assert all( + dataset.scale_transform.scale[0] == 0.085 + for dataset in image.multiscales[0].datasets + ) + assert open_position_collection(path).time_interval_s == 0.085 + + +@pytest.mark.unit +def test_hardware_cadence_uses_acquired_count(current_single_position_mirror_timelapse): + """Compute volume cadence from the acquired plane count and trigger period.""" + source = inspect_acquisition(current_single_position_mirror_timelapse) + acquired = replace(source, acquired_plane_count=17, plane_period_s=0.005) + assert acquired.volume_interval_s == pytest.approx(0.085) + assert acquired.scan_position_count != 17 + assert ( + replace( + source, acquired_plane_count=None, plane_period_s=None + ).volume_interval_s + is None + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("interval", [0, -1, float("nan"), float("inf")]) +def test_invalid_time_scale_rejected(tmp_path, interval): + """Reject nonphysical recorded time scales.""" + with pytest.raises(ValueError, match="time_interval_s"): + create_position_collection( + tmp_path / "invalid.ome.zarr", + (2, 1, 1, 4, 8, 8), + (0.23, 0.115, 0.115), + time_interval_s=interval, + ) + + +@pytest.mark.unit +def test_unknown_time_does_not_become_one_second(tmp_path): + """Preserve unknown timing without inventing a one-second interval.""" + path = tmp_path / "unknown.ome.zarr" + create_position_collection(path, (2, 1, 1, 4, 8, 8), (0.23, 0.115, 0.115)) + assert open_group(path)["0"].ome_metadata().multiscales[0].axes[0].unit is None + assert open_position_collection(path).time_interval_s is None + + +@pytest.mark.integration +@pytest.mark.parametrize("recorded", [False, True]) +def test_processing_propagates_hardware_cadence( + calibrated_mirror_timelapse, invoke_cli, recorded +): + """Preserve hardware cadence through processing and projection output.""" + raw = calibrated_mirror_timelapse.path + metadata = dict(open_group(raw).attrs) + if recorded: + for frame in metadata["ome_writers"]["frame_metadata"]: + frame["exposure_time"] = 0.0050021029411764706 + frame["delta_t"] = -1 # Software arrival time has no role in cadence. + update_group_attributes(raw, metadata) + invoke_cli( + "process", + raw, + max_projection=True, + create_fused_max_projection=False, + z_downsample_level=1, + plane_period_ms=None if recorded else 5.0, + ) + for name in ( + "single_mirror_deskewed.ome.zarr", + "single_mirror_max_z_deskewed.ome.zarr", + ): + result = open_position_collection(raw.parent / name) + import numpy as np + + for time, expected in enumerate(calibrated_mirror_timelapse.expected): + expected = expected.astype(np.uint16) + if "max_z" in name: + expected = expected.max(axis=0, keepdims=True) + np.testing.assert_array_equal( + result.arrays[0][time, 0].read().result(), expected + ) + assert result.time_interval_s == pytest.approx(0.020) + timing = result.attributes["opm_processing"]["timing"] + assert timing["n_acquired_planes"] == 4 + assert timing["dt_plane_s"] == 0.005 + assert timing["dt_volume_s"] == 0.020 + assert timing["source"] == ( + "recorded camera exposure_time; rounded to 0.01 ms" + if recorded + else "operator-confirmed hardware plane period" + ) + + +@pytest.mark.unit +def test_confirmed_period_cannot_override_recorded_timing( + current_single_position_mirror_timelapse, +): + """Reject a supplied period that contradicts recorded hardware timing.""" + acquired = inspect_acquisition(current_single_position_mirror_timelapse) + confirmed = acquired.with_plane_period(0.005) + assert confirmed.acquired_plane_count == acquired.scan_position_count + assert confirmed.with_plane_period(None) is confirmed + assert ( + confirmed.with_plane_period(0.005).volume_interval_s + == confirmed.volume_interval_s + ) + with pytest.raises(ValueError, match="contradicts"): + confirmed.with_plane_period(0.006) + with pytest.raises(ValueError, match="time_interval_s"): + acquired.with_plane_period(float("nan")) + + +@pytest.mark.unit +@pytest.mark.parametrize("channels", [1, 3]) +@pytest.mark.parametrize("planes", [1, 5]) +@pytest.mark.parametrize("field", ["exposure_time", "exposure_ms"]) +def test_hardware_exposures_define_cadence( + acquisition_factory, channels, planes, field +): + """Use actual camera exposures and retain raw discarded scan planes.""" + import numpy as np + + dataset = acquisition_factory( + np.zeros((2, 2, channels, planes, 4, 6), np.uint16), + acquired_plane_count=planes, + plane_period_s=0.03, + excess_scan_start_positions=int(planes > 1), + excess_scan_end_positions=int(planes > 1), + ) + root = open_group(dataset.path) + assert "timing" not in root.attrs["opm_v2"] + for position in range(2): + writer = dict(root[str(position)].attrs["ome_writers"]) + for frame in writer["frame_metadata"]: + if field == "exposure_time": + frame[field] = frame.pop("exposure_ms") / 1000 + frame["delta_t"] = -123.456 + frame["camera_metadata"] = {"ElapsedTime-ms": -1} + update_group_attributes(dataset.path / str(position), {"ome_writers": writer}) + acquired = inspect_acquisition(dataset.path) + assert acquired.acquired_plane_count == planes + assert acquired.plane_period_s == pytest.approx(0.03) + assert acquired.volume_interval_s == pytest.approx(planes * 0.03) + assert acquired.with_plane_period(0.03).volume_interval_s == pytest.approx( + planes * 0.03 + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "exposures, message", + [ + ([0], "Invalid camera exposure"), + ([float("nan")], "Invalid camera exposure"), + ([float("inf")], "Invalid camera exposure"), + ([-1], "Invalid camera exposure"), + ([True], "Invalid camera exposure"), + (["invalid"], "Invalid camera exposure"), + ([0.001], "rounds to zero"), + ([5, 10], "hardware exposures change"), + ], +) +def test_invalid_hardware_exposures_rejected(exposures, message): + """Reject nonphysical hardware exposures and changes in hardware cadence.""" + frames = [ + { + "event_index": {"t": 0, "p": 0, "c": 0, "z": scan}, + "exposure_ms": exposure, + } + for scan, exposure in enumerate(exposures) + ] + with pytest.raises(ValueError, match=message): + _camera_timing([frames], 1) + + +@pytest.mark.unit +def test_hardware_timing_ignores_software_timestamps(): + """Use rounded actual hardware exposure rather than arrival or requested times.""" + frames = [ + { + "event_index": {"z": scan}, + "runner_time_ms": scan * 5, + "delta_t": scan * 0.116, + "camera_metadata": {"ElapsedTime-ms": -scan}, + "event_metadata": {"Camera": {"exposure_ms": 150}}, + } + for scan in range(2) + ] + assert _camera_timing([frames], 1) == (None, None) + for frame in frames: + frame["exposure_time"] = 0.0050021029411764706 + frame["exposure_ms"] = 150 # Prefer the current serialized camera value. + assert _camera_timing([frames], 1) == ( + 0.005, + "recorded camera exposure_time; rounded to 0.01 ms", + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "exposures, period", [([5.0021], 0.005), ([5.0021, 7.018], 0.01202)] +) +def test_hardware_channel_periods_sum_once(exposures, period): + """Count each stored camera channel once, rounding before summing exposures.""" + frames = [ + {"event_index": {"c": channel}, "exposure_ms": exposure} + for channel, exposure in enumerate(exposures) + ] + assert _camera_timing([frames], len(exposures))[0] == pytest.approx(period) + assert _camera_timing([frames], len(exposures) + 1) == (None, None) + + +@pytest.mark.unit +@pytest.mark.parametrize("projection", [False, True]) +def test_fused_writer_preserves_time_axis( + tmp_path, fusion_operator, position_store_factory, projection +): + """Retain the source temporal axis in fused output.""" + import numpy as np + + from opm_processing.imageprocessing.maxtilefusion import MaxTileFusion + + path = tmp_path / "fused.ome.zarr" + if projection: + collection = position_store_factory( + np.zeros((3, 1, 1, 1, 8, 8), np.uint16), + name="projection", + voxel_size_um=(0.23, 0.115, 0.115), + time_interval_s=0.085, + ) + operator = MaxTileFusion( + collection.arrays, + ((0, 0, 0),), + path, + collection.voxel_size_um, + time_interval_s=collection.time_interval_s, + chunk_size=8, + source_collection=collection.path, + ) + provenance = open_group(path).attrs["opm_processing_projection"] + assert provenance["source_collection"] == str(collection.path) + assert provenance["source_position_indices"] == [0] + else: + operator = fusion_operator( + time_interval_s=0.085, + multiscale_factors=(2,), + multiscale_downsample="stride", + _pixel_size=(0.23, 0.115, 0.115), + offset_um=(0, 0, 0), + padded_shape=(4, 8, 8), + _is_2d=False, + time_dim=3, + channels=1, + chunk_y=4, + chunk_x=4, + ) + operator._prepare_fused_image(path, z_slices_per_write=2) + image = open_group(path).ome_metadata() + assert image.multiscales[0].axes[0].unit == "second" + assert all( + d.scale_transform.scale[0] == 0.085 for d in image.multiscales[0].datasets + ) diff --git a/tests/deconvolution_figures.py b/tests/deconvolution_figures.py new file mode 100644 index 0000000..dbb8bdb --- /dev/null +++ b/tests/deconvolution_figures.py @@ -0,0 +1,817 @@ +"""Generate calibrated OPM camera comparisons and their labeled PSNR figures. + +All acquisition, specimen, noise and reconstruction settings come from --config. +One physical forward_model produces each raw camera realization shared by RL +and RLGC. --render-only reuses final numerical panels without reconstruction. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import logging +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.patheffects as pe +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.backends.backend_pdf import PdfPages +from scipy.sparse import csr_matrix +from skimage.metrics import peak_signal_noise_ratio + +from opm_processing.imageprocessing.camera import camera_correct +from opm_processing.imageprocessing.opmtools import orthogonal_deskew +from tests.simulation.camera import Camera, digitize +from tests.simulation.forward_model import ( + Acquisition, + Microscope, + camera_psf, + forward_model, +) +from tests.simulation.scenes import acquisition_shape, prepare_scene + +Array = np.ndarray + +METHODS = ("gt", "rl_best", "rl_end", "gc") +NAMES = {"gt": "GT", "rl_best": "RL best recorded", "rl_end": "RL end", "gc": "RLGC"} +COLORS = {"gt": "#111111", "rl_best": "#0072B2", "rl_end": "#E69F00", "gc": "#009E73"} +STROKE = [pe.withStroke(linewidth=1.7, foreground="black")] +VIEWS = {"XY": (0, ("y", "x")), "XZ": (1, ("z", "x")), "YZ": (2, ("z", "y"))} +LABELS = [] + + +def laboratory(volume: np.ndarray) -> tuple[np.ndarray, tuple[np.ndarray, ...]]: + """Apply production orthogonal deskew and rotation at camera-pixel pitch.""" + p = P.pixel_size_um + result = orthogonal_deskew( + volume, + theta=P.angle_deg, + distance=P.scan_step_um, + pixel_size=p, + downsample_factor=1, + divisible_by=1, + ) + half = (np.asarray(volume.shape) - 1) / 2 + theta = np.deg2rad(P.angle_deg) + origin = ( + -half[1] * p * np.sin(theta), + -half[0] * P.scan_step_um - half[1] * p * np.cos(theta), + -half[2] * p, + ) + axes = tuple( + o + np.arange(n) * p for o, n in zip(origin, result.shape, strict=True) + ) + return result, axes + + +def project(volume: np.ndarray, masks: dict[str, np.ndarray]) -> dict[str, np.ndarray]: + """Crop physically aligned maximum-intensity projections.""" + return { + v: volume.max(axis=axis)[np.ix_(*(masks[n] for n in names))] + for v, (axis, names) in VIEWS.items() + } + + +def generate_case( + raw: np.ndarray, + gt: dict[str, np.ndarray], + axes: tuple[np.ndarray, ...], + masks: dict[str, np.ndarray], + psf: np.ndarray, + factor: int, + folder: Path, + kind: str, +) -> None: + """Run the sole deconvolver and retain final figure panels, not iterates.""" + import cupy as cp + + from opm_processing.imageprocessing.rlgc import rlgc + + observed = camera_correct( + raw[::factor], P.camera.offset_adu, P.camera.electrons_per_adu + ) + shape = ((observed.shape[0] - 1) * factor + 1, *observed.shape[1:]) + chosen = CONFIG["rendering"]["best_view"][kind] + best = dict.fromkeys(VIEWS, (-np.inf, None, None)) + selected = None + endpoint = None + + def record(step: int, device: Array) -> None: + """Retain the requested best checkpoint and final reconstruction.""" + nonlocal selected, endpoint + if step not in CONFIG["rl"]["checkpoints"]: + return + images = project(laboratory(cp.asnumpy(device))[0], masks) + for view in VIEWS: + score = peak_signal_noise_ratio( + gt[view], images[view], data_range=float(np.max(gt[view])) + ) + if score > best[view][0]: + best[view] = (score, step, images[view]) + if view == chosen: + selected = images + if step == RL_ITERATIONS: + endpoint = images + + rlgc( + observed, + psf, + scan_upsample_factor=factor, + boundary="zero", + gradient_consensus=False, + max_iterations=RL_ITERATIONS, + max_delta=0, + initial_estimate=np.ones(shape, np.float32), + callback=record, + release_memory=False, + ) + + class StopLog(logging.Logger): + def info(self, message: str, *args: object) -> None: + """Capture the reference stopping reason and iteration count.""" + if "stop=" in message: + self.reason, self.iterations = args[-2:] + + log = StopLog("figure-deconvolution", level=logging.INFO) + result = rlgc( + observed, + psf, + scan_upsample_factor=factor, + boundary="zero", + logger=log, + release_memory=False, + **CONFIG["rlgc"], + ) + gc = project(laboratory(result)[0], masks) + arrays = {n: a[masks[n]] for n, a in zip(("z", "y", "x"), axes, strict=True)} + arrays.update({"gt_" + v: a for v, a in gt.items()}) + for method, images in (("rl_best", selected), ("rl_end", endpoint), ("gc", gc)): + arrays.update({method + "_" + v: a for v, a in images.items()}) + folder.mkdir(parents=True, exist_ok=True) + np.savez_compressed(folder / "views.npz", **arrays) + info = { + "best_iteration": best[chosen][1], + "gc_iteration": log.iterations, + "gc_stop": log.reason, + "best_view": chosen, + } + (folder / "views.json").write_text(json.dumps(info, indent=2)) + if kind == "line-pairs": + profiles = {} + metadata = [] + for view in ("XY", "XZ"): + vertical = "y" if view == "XY" else "z" + cut = int(np.argmin(abs(arrays[vertical]))) + profiles[view + "_x"] = arrays["x"] + images = { + "gt": gt[view], + "rl_best": best[view][2], + "rl_end": endpoint[view], + "gc": gc[view], + } + for method, image in images.items(): + profiles[view + "_" + method] = image[cut] + metadata.append( + { + "view": view, + "best_iteration": best[view][1], + "gc_iteration": log.iterations, + "cut_coordinate_um": float(arrays[vertical][cut]), + "scores": { + m: peak_signal_noise_ratio( + gt[view], a, data_range=float(np.max(gt[view])) + ) + for m, a in images.items() + if m != "gt" + }, + } + ) + np.savez_compressed(folder / "profiles.npz", **profiles) + (folder / "profiles.json").write_text(json.dumps(metadata, indent=2)) + + +def simulate() -> None: + """Use the shared fine-grid optical model; no alternate image simulator.""" + psf, scale = camera_psf(P) + for profile in CONFIG["illumination_profiles"]: + for kind in CONFIG["kinds"]: + scene = None + for photons, background in CONFIG["photon_budgets"]: + folders = [ + ROOT / f"photons_{photons}/{profile}_{kind}_f{factor}" + for factor in CONFIG["scan_factors"] + ] + marker = "profiles.npz" if kind == "line-pairs" else "views.npz" + if all((folder / marker).exists() for folder in folders): + continue + if scene is None: + scene = prepare_scene( + kind, + P, + parameters=CONFIG["specimens"][kind], + illumination_profile=profile, + ) + shape = acquisition_shape(scene) + _, axes = laboratory(np.zeros(shape, np.float32)) + masks = { + n: (a >= fine[0]) & (a <= fine[-1]) + for n, a, fine in zip( + ("z", "y", "x"), axes, scene.axes, strict=True + ) + } + emission = forward_model( + scene.density, + Acquisition( + scene.axes, + shape, + illumination_profile=profile, + camera_photon_budget=(photons, background), + output="object", + ), + P, + ) + truth = emission + for axis, (fine, coarse) in enumerate( + zip(scene.axes, axes, strict=True) + ): + overlap = np.maximum( + np.minimum( + coarse[:, None] + P.pixel_size_um / 2, + fine[None, :] + P.fine_spacing_um / 2, + ) + - np.maximum( + coarse[:, None] - P.pixel_size_um / 2, + fine[None, :] - P.fine_spacing_um / 2, + ), + 0, + ) + moved = np.moveaxis(truth, axis, 0) + reduced = csr_matrix(overlap) @ moved.reshape(moved.shape[0], -1) + truth = np.moveaxis( + reduced.reshape((len(coarse), *moved.shape[1:])), 0, axis + ) + truth *= scale / ( + P.scan_step_um + * P.pixel_size_um**2 + * np.sin(np.deg2rad(P.angle_deg)) + ) + gt = project(truth, masks) + expectation = forward_model( + emission, Acquisition(scene.axes, shape, output="expectation"), P + ) + raw = digitize(expectation, P.camera, seed=CONFIG["camera_seed"]) + inputs = ROOT / "inputs" + inputs.mkdir(exist_ok=True) + np.savez_compressed( + inputs / f"{profile}_{kind}_{photons}.npz", + raw=raw, + expectation=expectation, + ) + for factor, folder in zip(CONFIG["scan_factors"], folders, strict=True): + if not (folder / marker).exists(): + generate_case(raw, gt, axes, masks, psf, factor, folder, kind) + print( + "RECONSTRUCTED", photons, profile, kind, factor, flush=True + ) + + +def load(path: Path) -> dict[str, np.ndarray]: + """Load final numerical panels without retaining an open archive.""" + with np.load(path) as data: + return dict(data) + + +def condition(profile: str, factor: int) -> str: + """Describe illumination and scan spacing from configuration.""" + return ( + "Uniform" if profile == "uniform" else "Edge-tapered" + ) + f" illumination · {factor * P.scan_step_um:g} µm scan" + + +def line_figure(profile: str, factor: int, view: str) -> plt.Figure: + """Plot physical line cuts for every configured photon budget.""" + fig, axes = plt.subplots(1, len(LEVELS), figsize=(15, 6.4), squeeze=False) + axes = axes[0] + fig.subplots_adjust(left=0.065, right=0.985, bottom=0.38, top=0.80, wspace=0.25) + fig.suptitle( + "Paired-line intensity cuts · " + condition(profile, factor), + fontsize=16, + fontweight="bold", + y=0.97, + ) + fig.text( + 0.065, + 0.885, + f"{view} maximum-intensity projection · camera pitch {P.pixel_size_um:g} µm · {P.angle_deg:g}° OPM", + fontsize=12, + ) + for ax, photons in zip(axes, LEVELS, strict=False): + folder = ROOT / f"photons_{photons}/{profile}_line-pairs_f{factor}" + data = load(folder / "profiles.npz") + info = next( + r + for r in json.loads((folder / "profiles.json").read_text()) + if r["view"] == view + ) + x = data[view + "_x"] + for method in METHODS: + iteration = { + "rl_best": info["best_iteration"], + "rl_end": RL_ITERATIONS, + "gc": info["gc_iteration"], + }.get(method) + label = NAMES[method] + if method != "gt": + score = info["scores"][method] + label += f" · I={iteration} · PSNR {score:.2f} dB" + LABELS.append( + { + "figure": f"line_{profile}_f{factor}_{view}", + "photons": photons, + "view": view, + "method": method, + "iteration": iteration, + "psnr_db": score, + } + ) + if method == "gt": + ax.step( + x, + data[view + "_" + method], + where="mid", + color=COLORS[method], + linewidth=1.5, + label=label, + ) + else: + ax.plot( + x, + data[view + "_" + method], + color=COLORS[method], + linewidth=1.25, + linestyle="--" if method == "rl_end" else "-", + label=label, + marker="." if method == "rl_end" else None, + markersize=2, + markevery=3, + ) + ax.set( + title=f"{photons:,} foreground / {BACKGROUND[photons]} background photons", + xlabel="X (µm)", + xlim=(x[0], x[-1]), + ylim=(0, None), + ) + ax.set_ylabel("Intensity (calibrated count units)") + ax.ticklabel_format(axis="y", style="sci", scilimits=(0, 0)) + ax.grid(alpha=0.15) + ax.legend( + loc="upper left", + bbox_to_anchor=(0, -0.19), + fontsize=8.5, + frameon=False, + borderaxespad=0, + ) + if info["best_iteration"] == RL_ITERATIONS: + ax.text( + 0.98, + 0.94, + "Best recorded RL = endpoint", + ha="right", + va="top", + transform=ax.transAxes, + fontsize=9, + ) + cut = json.loads( + ( + ROOT / f"photons_{LEVELS[0]}/{profile}_line-pairs_f{factor}/profiles.json" + ).read_text() + ) + cut = next(r for r in cut if r["view"] == view)["cut_coordinate_um"] + fig.text( + 0.065, + 0.04, + f"Fixed cut at {'Y' if view == 'XY' else 'Z'} = {cut:.3f} µm. PSNR uses the full displayed GT projection, not the 1D cut.\n" + f"Best recorded RL: highest PSNR among the saved iteration checkpoints through I={RL_ITERATIONS:,}. RLGC: MATLAB read-noise correction ({READ_NOISE:g} RMS), KL enabled.\n" + f"Incident photon levels are before QE. Intensities are unscaled. Pair spacings: {GAPS} µm.", + fontsize=9, + linespacing=1.45, + ) + return fig + + +def image_panel( + ax: plt.Axes, + image: np.ndarray, + reference: np.ndarray, + extent: tuple[float, ...], + method: str, + iteration: int | None, + tag: dict, +) -> object: + """Display an unscaled reconstruction and its GT-referenced PSNR.""" + maximum = float(reference.max()) + artist = ax.imshow( + image, + origin="lower", + extent=extent, + cmap="gray", + vmin=0, + vmax=maximum, + interpolation="none", + aspect="equal", + ) + ax.set_axis_off() + ax.text( + 0.025, + 0.975, + NAMES[method], + color="#169dd2" if method == "gc" else "white", + ha="left", + va="top", + fontsize=10.5, + fontweight="bold", + transform=ax.transAxes, + path_effects=STROKE, + ) + if method != "gt": + score = peak_signal_noise_ratio( + reference, image, data_range=float(np.max(reference)) + ) + ax.text( + 0.975, + 0.90, + f"PSNR {score:.2f} dB", + color="white", + ha="right", + va="top", + fontsize=9, + transform=ax.transAxes, + path_effects=STROKE, + ) + ax.text( + 0.025, + 0.035, + f"I = {iteration}" + (" · automatic stop" if method == "gc" else ""), + color="white", + ha="left", + va="bottom", + fontsize=9, + transform=ax.transAxes, + path_effects=STROKE, + ) + LABELS.append(dict(**tag, method=method, iteration=iteration, psnr_db=score)) + horizontal = extent[1] - extent[0] + vertical = extent[3] - extent[2] + scale = 5 if horizontal > 20 else 2 + right = extent[1] - 0.06 * horizontal + bottom = extent[2] + 0.09 * vertical + ax.plot( + [right - scale, right], + [bottom, bottom], + color="white", + linewidth=3, + solid_capstyle="butt", + ) + ax.text( + right - scale / 2, + bottom + 0.035 * vertical, + f"{scale} µm", + color="white", + ha="center", + va="bottom", + fontsize=9, + path_effects=STROKE, + ) + return artist + + +def physical_extent(data: dict[str, np.ndarray], view: str) -> tuple[float, ...]: + """Return image bounds from recorded camera-grid coordinates.""" + vertical, horizontal = {"XY": ("y", "x"), "XZ": ("z", "x"), "YZ": ("z", "y")}[view] + v, h = data[vertical], data[horizontal] + p = P.pixel_size_um + return h[0] - p / 2, h[-1] + p / 2, v[0] - p / 2, v[-1] + p / 2 + + +def sphere_figure(photons: int, profile: str, factor: int) -> plt.Figure: + """Compare the same sphere volumes in three orthogonal views.""" + folder = ROOT / f"photons_{photons}/{profile}_sphere_f{factor}" + data = load(folder / "views.npz") + info = json.loads((folder / "views.json").read_text()) + fig, axes = plt.subplots(3, 4, figsize=(12.5, 11.2)) + fig.subplots_adjust( + left=0.065, right=0.985, bottom=0.095, top=0.89, wspace=0.035, hspace=0.10 + ) + fig.suptitle( + "Hollow sphere · " + condition(profile, factor), + fontsize=16, + fontweight="bold", + y=0.98, + ) + fig.text( + 0.065, + 0.93, + f"{photons:,} foreground / {BACKGROUND[photons]} background photons per camera pixel before QE · {P.pixel_size_um:g} µm pixels", + fontsize=12, + ) + for row, view in enumerate(("XY", "XZ", "YZ")): + for column, method in enumerate(METHODS): + iteration = { + "rl_best": info["best_iteration"], + "rl_end": RL_ITERATIONS, + "gc": info["gc_iteration"], + }.get(method) + image_panel( + axes[row, column], + data[method + "_" + view], + data["gt_" + view], + physical_extent(data, view), + method, + iteration, + { + "figure": f"sphere_{profile}_f{factor}_{photons}", + "photons": photons, + "view": view, + }, + ) + axes[row, 0].text( + -0.07, + 0.5, + view, + rotation=90, + ha="right", + va="center", + fontsize=13, + fontweight="bold", + transform=axes[row, 0].transAxes, + ) + fig.text( + 0.065, + 0.025, + "XY, XZ and YZ maximum-intensity projections. Each row shares its GT intensity scale; brighter values saturate white.\n" + f"RL best recorded: highest saved XZ PSNR at I={info['best_iteration']}; the same 3D reconstruction is shown in all views.\n" + f"RL endpoint I={RL_ITERATIONS:,}. RLGC uses MATLAB read-noise correction ({READ_NOISE:g} RMS) and reference stopping with KL enabled. No intensity fitting.", + fontsize=9, + linespacing=1.4, + ) + return fig + + +def star_figure(profile: str, factor: int) -> plt.Figure: + """Compare the configured Siemens target across photon budgets.""" + fig, axes = plt.subplots(len(LEVELS), 4, figsize=(12.5, 11.4), squeeze=False) + fig.subplots_adjust( + left=0.065, right=0.985, bottom=0.105, top=0.89, wspace=0.035, hspace=0.10 + ) + fig.suptitle( + "MATLAB Siemens target · " + condition(profile, factor), + fontsize=16, + fontweight="bold", + y=0.98, + ) + fig.text( + 0.065, + 0.935, + f"{STAR['cycles']} sinusoidal spokes · half-bright center · {STAR_SAMPLES}×{STAR_SAMPLES} fine GT samples · {P.pixel_size_um:g} µm camera pixels", + fontsize=12, + ) + for row, photons in enumerate(LEVELS): + folder = ROOT / f"photons_{photons}/{profile}_siemens-star_f{factor}" + data = load(folder / "views.npz") + info = json.loads((folder / "views.json").read_text()) + for column, method in enumerate(METHODS): + iteration = { + "rl_best": info["best_iteration"], + "rl_end": RL_ITERATIONS, + "gc": info["gc_iteration"], + }.get(method) + image_panel( + axes[row, column], + data[method + "_XY"], + data["gt_XY"], + physical_extent(data, "XY"), + method, + iteration, + { + "figure": f"star_{profile}_f{factor}", + "photons": photons, + "view": "XY", + }, + ) + axes[row, 0].text( + -0.07, + 0.5, + f"{photons:,}/{BACKGROUND[photons]} photons", + rotation=90, + ha="right", + va="center", + fontsize=11, + transform=axes[row, 0].transAxes, + ) + fig.text( + 0.065, + 0.025, + "XY maximum-intensity projections. Rows: source/background incident photons per camera pixel, before QE. Shared GT scale within each row.\n" + f"MATLAB angular target in a {STAR['thickness_um'] * 1000:g} nm fluorescent XY sheet, propagated through the shared {P.angle_deg:g}° OPM fluorescence/camera model.\n" + f"Fine GT pitch {P.fine_spacing_um:g} µm; camera pitch {P.pixel_size_um:g} µm. RL best: highest recorded XY PSNR through I={RL_ITERATIONS:,}. RLGC: {READ_NOISE:g} RMS, KL enabled.\n" + "The MATLAB brightfield OTF is not used as an OPM forward model. No reconstruction intensity fitting.", + fontsize=9, + linespacing=1.4, + ) + return fig + + +def save(fig: plt.Figure, stem: str, pdf: PdfPages) -> None: + """Export a labeled figure and append it to its combined PDF.""" + fig.savefig(OUT / f"{stem}.png", dpi=CONFIG["rendering"]["dpi"]) + fig.savefig(OUT / f"{stem}.pdf") + pdf.savefig(fig) + plt.close(fig) + print("FIGURE", stem, flush=True) + + +def points_figure(profile: str, factor: int) -> plt.Figure: + """Compare point reconstructions in the laboratory XZ view.""" + fig, axes = plt.subplots(len(LEVELS), 4, figsize=(12.5, 11.4), squeeze=False) + fig.subplots_adjust( + left=0.065, right=0.985, bottom=0.105, top=0.89, wspace=0.035, hspace=0.10 + ) + fig.suptitle( + "Points · " + condition(profile, factor), fontsize=16, fontweight="bold", y=0.98 + ) + fig.text( + 0.065, + 0.935, + f"XZ maximum-intensity projections · {P.pixel_size_um:g} µm camera pixels · {P.angle_deg:g}° OPM", + fontsize=12, + ) + for row, photons in enumerate(LEVELS): + folder = ROOT / f"photons_{photons}/{profile}_points_f{factor}" + data = load(folder / "views.npz") + info = json.loads((folder / "views.json").read_text()) + for column, method in enumerate(METHODS): + iteration = { + "rl_best": info["best_iteration"], + "rl_end": RL_ITERATIONS, + "gc": info["gc_iteration"], + }.get(method) + image_panel( + axes[row, column], + data[method + "_XZ"], + data["gt_XZ"], + physical_extent(data, "XZ"), + method, + iteration, + { + "figure": f"points_{profile}_f{factor}", + "photons": photons, + "view": "XZ", + }, + ) + axes[row, 0].text( + -0.07, + 0.5, + f"{photons:,}/{BACKGROUND[photons]} photons", + rotation=90, + ha="right", + va="center", + fontsize=11, + transform=axes[row, 0].transAxes, + ) + fig.text( + 0.065, + 0.025, + "Rows: source/background incident photons per camera pixel before QE. Shared GT display scale within each row.\n" + "Zero-background conditions retain source shot noise and camera read noise.\n" + f"RL best: highest recorded XZ PSNR through I={RL_ITERATIONS:,}. RLGC: reference read-noise correction ({READ_NOISE:g} RMS), KL enabled.\n" + "No reconstruction intensity fitting.", + fontsize=9, + linespacing=1.4, + ) + return fig + + +def main() -> None: + """Generate or render the explicitly configured comparison series.""" + global \ + CONFIG, \ + ROOT, \ + OUT, \ + P, \ + LEVELS, \ + BACKGROUND, \ + CONDITIONS, \ + RL_ITERATIONS, \ + READ_NOISE, \ + STAR, \ + STAR_SAMPLES, \ + GAPS + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--render-only", action="store_true") + args = parser.parse_args() + CONFIG = json.loads(args.config.read_text()) + ROOT = args.output + ROOT.mkdir(parents=True, exist_ok=True) + configuration_path = ROOT / "configuration.json" + if configuration_path.exists(): + saved = json.loads(configuration_path.read_text()) + if {k: v for k, v in saved.items() if k != "rendering"} != { + k: v for k, v in CONFIG.items() if k != "rendering" + }: + raise ValueError( + "The output contains a different physical or reconstruction configuration; choose a new output directory" + ) + OUT = ROOT / "figures" + OUT.mkdir(exist_ok=True) + settings = dict(CONFIG["microscope"]) + settings["camera"] = Camera(**settings["camera"]) + settings["psf_half_extent_zyx_um"] = tuple(settings["psf_half_extent_zyx_um"]) + P = Microscope(**settings) + LEVELS = tuple(row[0] for row in CONFIG["photon_budgets"]) + BACKGROUND = dict(CONFIG["photon_budgets"]) + CONDITIONS = [ + (profile, factor) + for profile in CONFIG["illumination_profiles"] + for factor in CONFIG["scan_factors"] + ] + RL_ITERATIONS = CONFIG["rl"]["iterations"] + READ_NOISE = CONFIG["rlgc"]["read_noise"] + if RL_ITERATIONS not in CONFIG["rl"]["checkpoints"]: + raise ValueError("RL endpoint must be included in the recorded checkpoints") + STAR = CONFIG["specimens"]["siemens-star"]["features"] + STAR_SAMPLES = round(2 * STAR["radius_um"] / P.fine_spacing_um) + 1 + GAPS = ", ".join(map(str, CONFIG["specimens"]["line-pairs"]["features"]["gaps_um"])) + plt.rcParams.update( + { + "font.family": CONFIG["rendering"]["font_family"], + "font.size": 10, + "pdf.fonttype": 42, + "axes.spines.top": False, + "axes.spines.right": False, + "savefig.facecolor": "white", + } + ) + if not args.render_only: + simulate() + LABELS.clear() + for name, kind in ( + ("line_cuts", "line-pairs"), + ("sphere_orthogonal", "sphere"), + ("siemens_star", "siemens-star"), + ("points", "points"), + ): + if kind not in CONFIG["kinds"]: + continue + with PdfPages(OUT / f"{name}.pdf") as pdf: + for profile, factor in CONDITIONS: + if kind == "line-pairs": + for view in ("XY", "XZ"): + save( + line_figure(profile, factor, view), + f"line_{profile}_f{factor}_{view}", + pdf, + ) + elif kind == "sphere": + for photons in LEVELS: + save( + sphere_figure(photons, profile, factor), + f"sphere_{profile}_f{factor}_{photons}", + pdf, + ) + elif kind == "siemens-star": + save(star_figure(profile, factor), f"star_{profile}_f{factor}", pdf) + else: + save( + points_figure(profile, factor), + f"points_{profile}_f{factor}", + pdf, + ) + with (OUT / "psnr_labels.csv").open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=list(LABELS[0])) + writer.writeheader() + writer.writerows(LABELS) + (ROOT / "configuration.json").write_text(json.dumps(CONFIG, indent=2) + "\n") + (OUT / "index.html").write_text( + 'Deconvolution figures' + "

Deconvolution comparisons

" + + "".join( + f'

{label}

' + for name, label in [ + ("line_cuts", "Line cuts"), + ("sphere_orthogonal", "Sphere XY/XZ/YZ"), + ("siemens_star", "Siemens star"), + ("points", "Points"), + ] + ) + ) + print("COMPLETE", len(LABELS), "PSNR labels", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/acquisition.py b/tests/fixtures/acquisition.py index b89f46f..dfe3812 100644 --- a/tests/fixtures/acquisition.py +++ b/tests/fixtures/acquisition.py @@ -5,23 +5,20 @@ from dataclasses import dataclass, replace from pathlib import Path from types import SimpleNamespace -from typing import TYPE_CHECKING +from typing import Literal import numpy as np import pytest -import zarr -from yaozarrs import DimSpec, v05 +from yaozarrs import DimSpec, open_group, v05 from yaozarrs.write.v05 import prepare_image from opm_processing.dataio.acquisition import AcquisitionMetadata, ChannelMetadata from opm_processing.dataio.position_collection import ( create_position_collection, open_position_collection, + update_group_attributes, ) -if TYPE_CHECKING: - from pathlib import Path - @dataclass(frozen=True) class OpmV2ProjectionFixture: @@ -64,38 +61,15 @@ def opm_v2_frame_metadata( camera_conversion: float, runner_time_ms: float, hardware_triggered: bool | None = None, + exposure_ms: float | None = 10.0, additional_event_metadata: dict | None = None, + frame_format: Literal["mda_event", "event_index"] = "mda_event", ) -> dict: - """Build one upstream-compatible ``FrameMetaV1`` dictionary. - - Parameters - ---------- - index : dict - Logical time, position, channel and optional scan indices. - daq_metadata, opm_metadata, stage_metadata : dict - Acquisition controller settings, optical geometry and physical stage pose. - camera_shape_yx : tuple of int - Detector row and column counts. - pixel_size_um : float - Detector pitch in micrometers. - camera_offset, camera_conversion : float - Offset in ADU and calibrated photon conversion. - runner_time_ms : float - Acquisition runner timestamp in milliseconds. - hardware_triggered : bool or None - Recorded trigger mode; None omits the optional field. - additional_event_metadata : dict or None - Additional upstream controller metadata attached to this frame. - - Returns - ------- - dict - Physical frame metadata containing its logical acquisition index. - """ + """Build an upstream frame record in either supported metadata format.""" event_metadata = { "DAQ": daq_metadata, "Camera": { - "exposure_ms": 10.0, + "exposure_ms": exposure_ms, "camera_center_x": 128, "camera_center_y": 128, "camera_crop_x": camera_shape_yx[1], @@ -107,16 +81,26 @@ def opm_v2_frame_metadata( "Stage": stage_metadata, **(additional_event_metadata or {}), } + if frame_format == "event_index": + frame = { + "event_index": index, + "event_metadata": event_metadata, + "storage_index": [index.get(axis, 0) for axis in ("t", "c", "z")], + } + if exposure_ms is not None: + frame["exposure_time"] = exposure_ms / 1000 + return frame frame = { "format": "frame-dict", "version": "1.0", "pixel_size_um": pixel_size_um, "camera_device": "SyntheticCamera", - "exposure_ms": 10.0, "property_values": [], "runner_time_ms": runner_time_ms, "mda_event": {"index": index, "metadata": event_metadata}, } + if exposure_ms is not None: + frame["exposure_ms"] = exposure_ms if hardware_triggered is not None: frame["hardware_triggered"] = hardware_triggered return frame @@ -130,26 +114,7 @@ def write_opm_v2_zarr( chunks: tuple[int, ...], frame_metadatas: list[dict], ) -> None: - """Write a current group-based OME-Zarr acquisition fixture. - - Parameters - ---------- - path : pathlib.Path - Destination acquisition directory. - raw_data : numpy.ndarray - Known camera pixels in TPCZYX or TPCYX order. - labels : tuple of str - Logical labels matching raw_data dimensions. - chunks : tuple of int - Storage chunks in the same dimension order. - frame_metadatas : list of dict - Upstream frame records giving channel labels and physical calibration. - - Returns - ------- - None - Persist the real per-position image arrays and their frame records. - """ + """Write a current group-based OME-Zarr acquisition fixture.""" normalized = raw_data if "z" in labels else raw_data[:, :, :, None, :, :] normalized_chunks = chunks if "z" in labels else (*chunks[:3], 1, *chunks[-2:]) time_count, position_count, channel_count, z_count, _y_count, _x_count = ( @@ -203,38 +168,32 @@ def write_opm_v2_zarr( ) for position, array in enumerate(collection.arrays): array.write(normalized[:, position]).result() - root = zarr.open_group(path, mode="r+") for position, frames in enumerate(frames_by_position): - root[str(position)].attrs["ome_writers"] = {"frame_metadata": frames} + update_group_attributes( + path / str(position), {"ome_writers": {"frame_metadata": frames}} + ) @pytest.fixture -def opm_v2_projection_zarr(tmp_path) -> OpmV2ProjectionFixture: +def opm_v2_projection_zarr(tmp_path, simulated_camera) -> OpmV2ProjectionFixture: """Create a small, faithful OPMMirrorHandler projection acquisition. Schema provenance (QI2lab/opm-v2 main, commit ee354a1421732218d1128332c42a93aef106aa33): - ``handlers/opm_mirror_handler.py`` defines shape/chunks/labels and .zattrs. - ``engine/setup_events_v2.py`` defines projection event metadata. - pymmcore-plus ``FrameMetaV1`` defines the enclosing frame metadata. - - Parameters - ---------- - tmp_path : pathlib.Path - Isolated directory receiving the acquisition. - - Returns - ------- - OpmV2ProjectionFixture - Saved multi-timepoint, multi-channel camera ramp and its calibration. """ path = tmp_path / "opm_v2_projection.zarr" shape = (2, 1, 2, 16, 18) # T, P, C, Y, X; projection has no Z index. chunks = (1, 1, 1, shape[-2], shape[-1]) - yy, xx = np.mgrid[: shape[-2], : shape[-1]] raw_data = np.empty(shape, dtype=np.uint16) for time in range(shape[0]): for channel in range(shape[2]): - raw_data[time, 0, channel] = 200 + 50 * time + 25 * channel + 2 * yy + xx + raw_data[time, 0, channel] = simulated_camera( + (1, *shape[-2:]), + geometry="cartesian", + exposure=1 + time / 4 + channel / 2, + ).raw[0] camera_offset = 100.0 camera_conversion = 0.25 @@ -302,22 +261,7 @@ def opm_v2_projection_zarr(tmp_path) -> OpmV2ProjectionFixture: def simulated_acquisition_metadata( path: Path, mode: str, shape: tuple[int, ...] ) -> AcquisitionMetadata: - """Describe a calibrated 0.8 um combined-laser scan. - - Parameters - ---------- - path : pathlib.Path - On-disk acquisition store for the simulated specimen. - mode : str - Mirror or stage scanning mode. - shape : tuple of int - Acquisition dimensions in TPCZYX order. - - Returns - ------- - AcquisitionMetadata - Physical scan and camera calibration for the simulated acquisition. - """ + """Describe a calibrated 0.8 um combined-laser scan.""" return AcquisitionMetadata( path=path, storage_format="opm-v2-ome-zarr-v3", @@ -345,21 +289,7 @@ def simulated_acquisition_metadata( def write_simulated_acquisition(metadata: AcquisitionMetadata, raw: np.ndarray) -> None: - """Persist simulated camera pixels and their physical acquisition metadata. - - Parameters - ---------- - metadata : AcquisitionMetadata - Scan geometry and camera calibration for the specimen. - raw : numpy.ndarray - Simulated uint16 camera data in TPCZYX order. - - Returns - ------- - None - Pixels and scan metadata are persisted at the metadata path; no image, - metadata inspection, or processing-state boundary is mocked. - """ + """Persist simulated camera pixels and their physical acquisition metadata.""" frames = [] for position, (stage_z, stage_x, stage_y) in enumerate( metadata.stage_positions_zxy @@ -407,6 +337,11 @@ def write_simulated_acquisition(metadata: AcquisitionMetadata, raw: np.ndarray) camera_offset=metadata.camera_offset, camera_conversion=metadata.camera_conversion, runner_time_ms=float(len(frames)), + exposure_ms=( + 1000 * metadata.plane_period_s / len(metadata.channels) + if metadata.plane_period_s is not None + else None + ), hardware_triggered=True, ) ) @@ -418,21 +353,15 @@ def write_simulated_acquisition(metadata: AcquisitionMetadata, raw: np.ndarray) frame_metadatas=frames, ) + if metadata.deconvolution is not None: + update_group_attributes( + metadata.path, {"opm_processing": {"deconvolution": metadata.deconvolution}} + ) + @pytest.fixture def current_opm_v2_stage_scan(tmp_path: Path) -> Path: - """Create a small Bio-Formats2Raw collection matching current opm-v2. - - Parameters - ---------- - tmp_path : pathlib.Path - Isolated directory receiving the stage acquisition. - - Returns - ------- - pathlib.Path - Collection containing known pixels, event-indexed metadata and stage poses. - """ + """Create a small Bio-Formats2Raw collection matching current opm-v2.""" path = tmp_path / "current_stage.ome.zarr" shape = (1, 2, 2, 3, 4, 5) # T, P, C, Z, Y, X stage_positions_zxy = ((30.0, 100.0, 200.0), (30.0, 100.0, 220.0)) @@ -461,75 +390,58 @@ def current_opm_v2_stage_scan(tmp_path: Path) -> Path: chunks=(1, 1, 1, 4, 5), ) - root = zarr.open_group(path, mode="a") for position in range(shape[1]): frames = [] for scan in range(shape[3]): for channel, name in enumerate(("488nm", "561nm")): frames.append( - { - "event_index": { - "t": 0, - "p": position, - "c": channel, - "z": scan, + opm_v2_frame_metadata( + frame_format="event_index", + index={"t": 0, "p": position, "c": channel, "z": scan}, + daq_metadata={ + "mode": "stage", + "scan_axis_step_um": 0.4, + "laser_powers": [12.0, 18.0, 0.0], + "current_channel": name, }, - "exposure_time": (10.0, 15.0)[channel] / 1000.0, - "event_metadata": { - "DAQ": { - "mode": "stage", - "scan_axis_step_um": 0.4, - "laser_powers": [12.0, 18.0, 0.0], - "current_channel": name, - }, - "Camera": { - "exposure_ms": (10.0, 15.0)[channel], - "offset": 100.0, - "e_to_ADU": 0.24, - }, - "OPM": { - "angle_deg": 30.0, - "camera_Zstage_orientation": "negative", - "camera_XYstage_orientation": "positive", - "camera_mirror_orientation": "positive", - "excess_scan_positions": 0, - "excess_scan_start_positions": 0, - "excess_scan_end_positions": 0, - }, - "Stage": { - "x_pos": 100.0 + 0.4 * scan, - "y_pos": stage_positions_zxy[position][2], - "z_pos": stage_positions_zxy[position][0], - "excess_image": False, - }, + opm_metadata={ + "angle_deg": 30.0, + "camera_Zstage_orientation": "negative", + "camera_XYstage_orientation": "positive", + "camera_mirror_orientation": "positive", + "excess_scan_positions": 0, + "excess_scan_start_positions": 0, + "excess_scan_end_positions": 0, }, - "storage_index": [0, channel, scan], - } + stage_metadata={ + "x_pos": 100.0 + 0.4 * scan, + "y_pos": stage_positions_zxy[position][2], + "z_pos": stage_positions_zxy[position][0], + "excess_image": False, + }, + camera_shape_yx=shape[-2:], + pixel_size_um=0.115, + camera_offset=100.0, + camera_conversion=0.24, + runner_time_ms=float(len(frames)), + exposure_ms=(10.0, 15.0)[channel], + ) ) - root[str(position)].attrs["ome_writers"] = {"frame_metadata": frames} + update_group_attributes( + path / str(position), {"ome_writers": {"frame_metadata": frames}} + ) data = np.empty((1, shape[2], shape[3], shape[4], shape[5]), dtype=np.uint16) zz, yy, xx = np.mgrid[: shape[3], : shape[4], : shape[5]] for channel in range(shape[2]): data[0, channel] = 1000 * position + 100 * channel + 10 * zz + 2 * yy + xx - root[str(position)]["0"][:] = data + open_group(path)[f"{position}/0"].to_tensorstore().write(data).result() return path @pytest.fixture def current_single_position_mirror_timelapse(tmp_path: Path) -> Path: - """Create the root-Image layout ome-writers uses for one position. - - Parameters - ---------- - tmp_path : pathlib.Path - Isolated directory receiving the mirror acquisition. - - Returns - ------- - pathlib.Path - Root image containing three camera timepoints and upstream scan metadata. - """ + """Create the root-Image layout ome-writers uses for one position.""" path = tmp_path / "single_mirror.ome.zarr" shape = (3, 1, 4, 5, 6) # T, C, Z, Y, X dims = [ @@ -573,37 +485,35 @@ def current_single_position_mirror_timelapse(tmp_path: Path) -> Path: for time in range(shape[0]): for scan in range(shape[2]): frames.append( - { - "event_index": {"t": time, "p": 0, "c": 0, "z": scan}, - "event_metadata": { - "DAQ": { - "mode": "mirror", - "image_mirror_step_um": 0.4, - "current_channel": "488nm", - "laser_powers": [10.0], - }, - "Camera": { - "exposure_ms": 2.0, - "offset": 100.0, - "e_to_ADU": 0.25, - }, - "OPM": { - "angle_deg": 30.0, - "camera_mirror_orientation": "positive", - "camera_XYstage_orientation": "positive", - "camera_Zstage_orientation": "positive", - }, - "Stage": { - "x_pos": 100.0, - "y_pos": 200.0, - "z_pos": 30.0, - }, + opm_v2_frame_metadata( + frame_format="event_index", + index={"t": time, "p": 0, "c": 0, "z": scan}, + daq_metadata={ + "mode": "mirror", + "image_mirror_step_um": 0.4, + "current_channel": "488nm", + "laser_powers": [10.0], + }, + opm_metadata={ + "angle_deg": 30.0, + "camera_mirror_orientation": "positive", + "camera_XYstage_orientation": "positive", + "camera_Zstage_orientation": "positive", }, - "storage_index": [time, 0, scan], - } + stage_metadata={ + "x_pos": 100.0, + "y_pos": 200.0, + "z_pos": 30.0, + }, + camera_shape_yx=shape[-2:], + pixel_size_um=0.115, + camera_offset=100.0, + camera_conversion=0.25, + runner_time_ms=float(len(frames)), + exposure_ms=None, + ) ) - root = zarr.open_group(path, mode="a") - root.attrs["ome_writers"] = {"frame_metadata": frames} + update_group_attributes(path, {"ome_writers": {"frame_metadata": frames}}) return path @@ -615,37 +525,10 @@ def acquisition_factory(tmp_path): overrides of AcquisitionMetadata fields. Defaults describe a 30 degree OPM with 115 nm detector pixels, 400 nm scans, 100 ADU offset and 0.25 photons/ADU. It returns the path, metadata, original pixels and reopened disk collection. - - Parameters - ---------- - tmp_path : pathlib.Path - Isolated directory receiving each independently named acquisition. - - Returns - ------- - callable - Dataset writer accepting camera pixels and physical metadata overrides. """ def create(raw_data, *, name="sample", mode="mirror", **overrides): - """Write one independent dataset with overrides of its physical calibration. - - Parameters - ---------- - raw_data : numpy.ndarray - Simulated camera values in time, position, channel, scan, Y, X order. - name : str - Dataset basename inside the test's temporary directory. - mode : str - Acquisition mode recorded in upstream frame metadata. - **overrides - AcquisitionMetadata fields replacing the common OPM calibration. - - Returns - ------- - types.SimpleNamespace - Dataset path, metadata, raw_data and reopened position collection. - """ + """Write one independent dataset with overrides of its physical calibration.""" metadata = replace( simulated_acquisition_metadata( tmp_path / f"{name}.ome.zarr", mode, raw_data.shape @@ -669,20 +552,82 @@ def create(raw_data, *, name="sample", mode="mirror", **overrides): return create +@pytest.fixture(scope="session") +def acquisition_metadata(): + """Provide frozen physical calibration for checks that do not read pixels.""" + return simulated_acquisition_metadata( + Path("calibration.ome.zarr"), "mirror", (1, 1, 1, 3, 4, 5) + ) + + +@pytest.fixture +def deconvolution_acquisition(acquisition_factory): + """Provide a tiny raw store with independently variable calibration attributes.""" + return acquisition_factory(np.zeros((1, 1, 1, 3, 4, 5), dtype=np.uint16)) + + +@pytest.fixture(params=("mirror", "stage")) +def scan_camera_acquisition(request, acquisition_factory): + """Known numbered planes with an explicit excess-plane and scan convention.""" + expected = np.broadcast_to( + np.arange(1, 17, dtype=np.uint16)[:, None, None], (16, 32, 32) + ).copy() + stage = request.param == "stage" + ordered = ( + np.concatenate((np.zeros((1, 32, 32), np.uint16), expected)) + if stage + else expected + ) + raw = ordered[::-1] if stage else ordered + dataset = acquisition_factory( + raw[None, None, None], + mode=request.param, + camera_offset=0, + camera_conversion=1, + excess_scan_positions=int(stage), + excess_scan_start_positions=int(stage), + ) + return SimpleNamespace( + path=dataset.path, raw_data=dataset.raw_data, expected=expected + ) + + @pytest.fixture def processing_options(): - """Return common settings for a full-resolution isolated volume workflow. - - Returns - ------- - dict - Disable illumination fitting, tile projections and automatic fusion; - preserve native laboratory Z sampling. Tests override other options - explicitly and validate their written fluorescence. - """ + """Return common settings for a full-resolution isolated volume workflow.""" return { "flatfield_correction": False, + # These shared disk fixtures have ideal photon counting with no read noise. "max_projection": False, "create_fused_max_projection": False, "z_downsample_level": 1, } + + +@pytest.fixture +def calibrated_mirror_timelapse( + current_single_position_mirror_timelapse, simulated_camera +): + """Simulate photon observations at three times in a root NGFF Image.""" + from types import SimpleNamespace + + from opm_processing.dataio.acquisition import ( + inspect_acquisition, + open_acquisition_datastore, + ) + from opm_processing.imageprocessing.opmtools import orthogonal_deskew + + path = current_single_position_mirror_timelapse + source = open_acquisition_datastore(inspect_acquisition(path)) + shape = tuple(source.shape[-3:]) + expected = [] + for time in range(source.shape[0]): + raw = simulated_camera(shape, exposure=1 + time / 4).raw + source[time, 0, 0].write(raw).result() + photons = np.maximum(raw.astype(np.float32) - 100, 0) * 0.25 + expected.append( + orthogonal_deskew( + photons, theta=30.0, distance=0.4, pixel_size=0.115, downsample_factor=1 + ) + ) + return SimpleNamespace(path=path, expected=expected) diff --git a/tests/fixtures/camera.py b/tests/fixtures/camera.py index e160651..98a96bc 100644 --- a/tests/fixtures/camera.py +++ b/tests/fixtures/camera.py @@ -1,55 +1,82 @@ -"""Calibrated camera codes and physical illumination forward models.""" +"""Calibrated camera codes and post-blur detector response operator fixtures.""" from types import SimpleNamespace import numpy as np import pytest -from opm_processing.imageprocessing.camera import qi2lab_stage_scan_camera_gain -from tests.reference.point_sources import sample_point_source + +@pytest.fixture(scope="session") +def camera_gain_profile(): + """Provide a prescribed nonuniform detector response for calibration tests.""" + return np.linspace(0.7, 1.3, 1900, dtype=np.float32) + + +@pytest.fixture(scope="session") +def camera_exposure(): + """Set exposure by peak expected detected photons per camera pixel. + + Each detected photon generates one photoelectron. The supplied expectation + already includes QE, optical throughput and sensor integration. Scaling it + changes the source exposure before the Poisson camera draw, not noisy data. + """ + + def at_peak(expectation, peak_detected_photons): + """Set the declared mean detector exposure before the Poisson draw.""" + expectation = np.asarray(expectation, dtype=np.float64) + maximum = float(expectation.max()) + if ( + not np.isfinite(expectation).all() + or np.any(expectation < 0) + or maximum <= 0 + or not np.isfinite(peak_detected_photons) + or peak_detected_photons <= 0 + ): + raise ValueError("Require positive exposure and finite nonnegative rates") + return expectation * (peak_detected_photons / maximum) + + return at_peak @pytest.fixture(scope="session") def camera_codes(): - """Return every uint16 detector code to check offset, gain and saturation.""" + """All recorded uint16 values for physical gain, offset and saturation checks.""" return np.arange(65536, dtype=np.uint16).reshape(16, 16, 256) @pytest.fixture -def camera_optical_sample(stage_gain, strided): - """Forward simulate a diffraction emitter with shot noise and detector sensitivity. - - Parameters - ---------- - stage_gain : bool - Include the measured detector response in the forward image formation. - strided : bool - Return a noncontiguous scan and detector row crop. - - Returns - ------- - types.SimpleNamespace - Known illumination and gain, measured photons, uint16 camera data and - calibration. Only independent camera quantization contributes extra error. - """ - photons = sample_point_source(shape=(31, 65, 73), scan_step=0.4) - photons *= np.float32(1000 / photons.max()) - photons += np.float32(20) +def photon_splitter(cupy_gpu): + """Run MATLAB's unclipped first-half split with a reproducible GPU generator.""" + from opm_processing.imageprocessing import rlgc + + def split(observed, seed=19): + """Split prescribed observations with a reproducible GPU random generator.""" + counts = cupy_gpu.asarray(observed, dtype=cupy_gpu.float32) + rng = cupy_gpu.random.default_rng(seed) + first = rlgc._split_observed_counts(counts, rng) + return cupy_gpu.asnumpy(first) + + return SimpleNamespace(split=split) + + +@pytest.fixture +def detector_response_sample(stage_gain, strided, camera_gain_profile): + """Apply detector sensitivity and ADC conversion to prescribed detected counts.""" + photons = np.random.default_rng(47).integers(20, 1021, (31, 65, 73)) y = np.linspace(-1, 1, 65, dtype=np.float32)[:, None] x = np.linspace(-1, 1, 73, dtype=np.float32)[None, :] illumination = np.asarray(0.5 + 0.5 * np.exp(-x * x - y * y), dtype=np.float32) - gain = ( - qi2lab_stage_scan_camera_gain()[1040:1113] - if stage_gain - else np.ones(73, np.float32) - ) - measured = np.random.default_rng(47).poisson(photons * illumination * gain) + gain = camera_gain_profile[1040:1113] if stage_gain else np.ones(73, np.float32) + measured = np.rint(photons * illumination * gain) offset, conversion = 100.0, 0.24 / 0.9 raw = np.rint(measured / conversion + offset).astype(np.uint16) if strided: raw, illumination = raw[::2, ::2, :], illumination[::2, :] measured = measured[::2, ::2, :] - options = {"detector_x_offset": 1040, "apply_stage_scan_gain": stage_gain} + options = { + "detector_x_offset": 1040, + "detector_gain_x": camera_gain_profile if stage_gain else None, + } return SimpleNamespace( raw=raw, illumination=illumination, @@ -59,3 +86,13 @@ def camera_optical_sample(stage_gain, strided): conversion=conversion, options=options, ) + + +@pytest.fixture(scope="module") +def camera_likelihood_sample(camera_calibration, rate): + """Share independent camera data and its true-rate score across hypotheses.""" + from tests.simulation.camera import digitize + from tests.simulation.camera_likelihood import camera_log_likelihood + + raw = digitize(np.full(20000, rate), camera_calibration, seed=31) + return raw, camera_log_likelihood(raw, rate, camera_calibration) diff --git a/tests/fixtures/cli.py b/tests/fixtures/cli.py new file mode 100644 index 0000000..6320737 --- /dev/null +++ b/tests/fixtures/cli.py @@ -0,0 +1,167 @@ +"""Real CLI invocation shared by simulated acquisition integration scenarios.""" + +import importlib +import tomllib +from pathlib import Path + +import pytest +from typer.main import get_command +from typer.testing import CliRunner + + +@pytest.fixture +def cli_file_layout(request, fs): + """Create mocked discovery inputs without replacing any package APIs. + + Returns + ------- + pathlib.Path + Acquisition directory containing the requested stores or frame names. + """ + root = Path.cwd() / "cli-files" + fs.create_dir(root) + for name in request.param: + path = root / name + if name.endswith(".zarr"): + fs.create_dir(path) + else: + fs.create_file(path) + return root + + +@pytest.fixture +def encoder_frames(tmp_path): + """Write calibrated display pixels for encoder input-validation units. + + Returns + ------- + list of pathlib.Path + One TIFF with a known acquired volume interval. + """ + import numpy as np + import tifffile + + path = tmp_path / "projection_t0.tiff" + tifffile.imwrite( + path, np.zeros((8, 10), np.uint8), metadata={"volume_interval_ms": 500} + ) + return [path] + + +@pytest.fixture +def export_image_input(request, acquisition_factory, fused_store_factory): + """Persist small real OME-Zarr inputs for individual TIFF writer units. + + Returns + ------- + types.SimpleNamespace + Source path, independently known pixels, and expected image-group path. + """ + from types import SimpleNamespace + + import numpy as np + + kind = request.param + pixels = np.arange(3 * 4 * 5, dtype=np.uint16).reshape(1, 1, 3, 4, 5) + if kind == "image": + path = fused_store_factory(pixels, name="sample_fused") + image = path + else: + positions = 2 if kind == "multiple" else 1 + dataset = acquisition_factory( + np.repeat(pixels[:, None], positions, axis=1), name="sample_deskewed" + ) + path, image = dataset.path, dataset.path / "0" + return SimpleNamespace(path=path, image=image, pixels=pixels, kind=kind) + + +@pytest.fixture +def acquisition_link(request, export_image_input, tmp_path): + """Persist source-link variants using the production processing journal. + + Returns + ------- + types.SimpleNamespace + Processed path, source store and optional explicit acquisition override. + """ + from types import SimpleNamespace + + from opm_processing.dataio.processing_state import ProcessingState + + kind = request.param + source = export_image_input.path + processed = tmp_path / "linked_deskewed.ome.zarr" + if kind not in ("missing", "override"): + count = 2 if kind == "ambiguous" else 1 + for index in range(count): + state = ProcessingState.create( + tmp_path / f"linked_{index}.processing.json", source + ) + state.initialize_run(processed, configuration={}, overwrite=True) + if kind == "relative": + state.document["source"]["path"] = source.name + state.save() + return SimpleNamespace(processed=processed, source=source, kind=kind) + + +@pytest.fixture +def cli_export_options(request): + """Pair display, scheduling, time-selection and annotation options. + + Returns + ------- + dict + Real projection CLI values; selection retains acquisition timestamps. + """ + selected = request.param == "selected-color" + return { + "depth_color": selected, + "depth_colormap": "viridis" if selected else "turbo", + "workers": 2 if selected else 1, + "timepoints": (1, 2) if selected else None, + "scale_bar_um": 1.0 if selected else None, + } + + +@pytest.fixture +def invoke_cli(): + """Invoke a registered CLI with its real parser and processing implementation. + + Returns + ------- + callable + Accept a command name, positional arguments and named option values. + Tuple options retain their arity and list options repeat their flag. + Successful execution returns the captured Click result; failures retain + their original traceback. + """ + registry = tomllib.loads( + (Path(__file__).resolve().parents[2] / "pyproject.toml").read_text("utf-8") + )["project"]["scripts"] + + def invoke(name, *arguments, **options): + """Parse actual CLI arguments and execute every numerical/storage boundary.""" + application = importlib.import_module(registry[name].split(":")[0]).app + parameters = {p.name: p for p in get_command(application).params} + args = [str(value) for value in arguments] + for key, value in options.items(): + if value is None: + continue + parameter = parameters[key] + if isinstance(value, bool): + flags = parameter.opts if value else parameter.secondary_opts + if flags: + args.append(flags[0]) + elif value != parameter.default: + raise ValueError(f"{name}: {key} has no flag for {value}") + elif isinstance(value, list): + for item in value: + args.extend((parameter.opts[0], str(item))) + else: + values = value if isinstance(value, tuple) else (value,) + args.extend((parameter.opts[0], *(str(v) for v in values))) + result = CliRunner().invoke(application, args, catch_exceptions=False) + assert result.exit_code == 0, result.output + return result + + return invoke diff --git a/tests/fixtures/configs/deconvolution_figures.json b/tests/fixtures/configs/deconvolution_figures.json new file mode 100644 index 0000000..54cf34c --- /dev/null +++ b/tests/fixtures/configs/deconvolution_figures.json @@ -0,0 +1,236 @@ +{ + "microscope": { + "fine_spacing_um": 0.05, + "pixel_size_um": 0.115, + "scan_step_um": 0.4, + "angle_deg": 30.0, + "wavelength_um": 0.637, + "numerical_aperture": 1.35, + "detection_efficiency": 0.1, + "quantum_efficiency": 0.8, + "pixel_samples": 3, + "psf_half_extent_zyx_um": [ + 2.4, + 1.2, + 1.2 + ], + "camera": { + "read_noise_e": 1.6, + "electrons_per_adu": 0.24, + "offset_adu": 100.0, + "bit_depth": 16 + }, + "illumination_edge_fraction": 0.25, + "scan_endpoint_divisor": 12, + "psf_parameters": [ + [ + "ni0", + 1.4 + ], + [ + "ni", + 1.4 + ], + [ + "ns", + 1.38 + ], + [ + "tg0", + 170 + ], + [ + "tg", + 170 + ], + [ + "ti0", + 300 + ], + [ + "ng0", + 1.515 + ], + [ + "ng", + 1.515 + ] + ], + "psf_depth_um": 0.0 + }, + "specimens": { + "points": { + "half_extent_zyx_um": [ + 3.2, + 2.5, + 2.5 + ], + "features": { + "centers_xyz_um": [ + [ + -0.65, + -0.6, + -0.35 + ], + [ + 0.7, + 0.65, + 0.4 + ] + ] + } + }, + "line-pairs": { + "half_extent_zyx_um": [ + 2.6, + 2.7, + 7.4 + ], + "features": { + "gaps_um": [ + 0.15, + 0.23, + 0.35, + 0.53, + 0.8, + 1.2 + ], + "pair_gap_um": 1.8, + "length_um": 2.4, + "diameter_um": 0.1, + "voxel_samples": 3 + } + }, + "sphere": { + "half_extent_zyx_um": [ + 7.9, + 6.7, + 6.7 + ], + "features": { + "diameter_um": 10.0, + "tube_diameter_um": 1.0, + "meridians": 12 + } + }, + "siemens-star": { + "half_extent_zyx_um": [ + 2.6, + 12.8, + 12.8 + ], + "features": { + "radius_um": 12.8, + "thickness_um": 0.05, + "cycles": 100, + "center_radius_fraction": 0.16572815184059708 + } + } + }, + "kinds": [ + "points", + "line-pairs", + "sphere", + "siemens-star" + ], + "illumination_profiles": [ + "uniform", + "edge-taper" + ], + "scan_factors": [ + 1, + 3 + ], + "photon_budgets": [ + [ + 1000, + 30 + ], + [ + 100, + 3 + ], + [ + 10, + 0 + ] + ], + "camera_seed": 31, + "rl": { + "iterations": 2000, + "checkpoints": [ + 1, + 2, + 3, + 5, + 10, + 20, + 30, + 50, + 75, + 100, + 150, + 200, + 300, + 500, + 750, + 1000, + 1500, + 2000 + ] + }, + "rlgc": { + "read_noise": 1.6, + "limit": 0.001, + "max_delta": 0.001, + "rng_seed": 42, + "max_iterations": null, + "use_kl": true + }, + "rendering": { + "dpi": 190, + "font_family": "Arial", + "best_view": { + "points": "XZ", + "line-pairs": "XZ", + "sphere": "XZ", + "siemens-star": "XY" + } + }, + "pipeline_mosaic": { + "half_extent_zyx_um": [ + 0.8, + 7.0, + 7.4 + ], + "line_length_um": 12.0, + "scan_extent_um": 38.4, + "camera_shape_yx": [ + 32, + 80 + ], + "tile_offset_x_pixels": 48, + "deskew_tile_shape_zyx": [ + 8, + 64, + 32 + ], + "fusion_chunk_shape_yx": [ + 8, + 8 + ], + "blend_pixels_zyx": [ + 0, + 0, + 8 + ] + }, + "pipeline": { + "plane_period_s": 0.02, + "scan_extent_um": 7.2, + "camera_shape_yx": [ + 32, + 36 + ] + } +} diff --git a/tests/fixtures/export.py b/tests/fixtures/export.py index eff068e..02e9155 100644 --- a/tests/fixtures/export.py +++ b/tests/fixtures/export.py @@ -5,52 +5,49 @@ import numpy as np import pytest -import zarr from ome_types import from_xml, to_xml from ome_types.model import AnnotationRef, CommentAnnotation from yaozarrs import open_group, v05 -from opm_processing.dataio.position_collection import create_position_collection +from opm_processing.dataio.position_collection import ( + create_position_collection, + update_group_attributes, +) from opm_processing.dataio.processing_state import ProcessingState @pytest.fixture -def fused_export(tmp_path, source_xml, fused_store_factory): - """Persist a two-channel object, depth projection and calibrated provenance. - - Parameters - ---------- - tmp_path : pathlib.Path - Temporary acquisition and processed image directory. - source_xml : bool - Test parameter selecting rich OME metadata or NGFF-only acquisition. - fused_store_factory - Shared writer for calibrated root images and their pyramids. - - Returns - ------- - types.SimpleNamespace - Known pixels, image paths, calibration, source OME and processing state. - """ +def fused_export(tmp_path, source_xml, fused_store_factory, simulated_camera): + """Persist a two-channel object, depth projection and calibrated provenance.""" dtype = np.uint16 - z, y, x = np.mgrid[:3, :515, :529] - object_pixels = 900 * np.exp( - -((z - 1) ** 2 + ((y - 251) / 45) ** 2 + ((x - 287) / 39) ** 2) - ) - # Add known emitters at TIFF tile boundaries and at both partial-tile edges. - object_pixels[:, 511:514, 510:516] += 233 - object_pixels[:, -1, -1] = (7, 11, 17) - truth = np.stack( + from opm_processing.imageprocessing.camera import camera_correct + from tests.simulation.camera import digitize + + observed = simulated_camera((3, 515, 529), geometry="cartesian") + camera = observed.microscope.camera + raw_frames = np.stack( [ - np.stack([object_pixels, object_pixels * 2.3]), - np.stack([object_pixels * 0.7, object_pixels * 1.4]), + np.stack( + [ + digitize( + observed.expectation * exposure, + camera, + seed=10 * time + channel, + ) + for channel, exposure in enumerate(exposures) + ] + ) + for time, exposures in enumerate(((1.0, 2.3), (0.7, 1.4))) ] + ) + truth = camera_correct( + raw_frames, camera.offset_adu, camera.electrons_per_adu ).astype(dtype) projection = truth.max(axis=2, keepdims=True) raw_path = tmp_path / "sample.ome.zarr" raw = create_position_collection( raw_path, - (2, 1, 2, 3, 5, 7), + (2, 1, 2, 3, 515, 529), (0.4, 0.115, 0.115), channels=("488nm", "637nm"), dtype=dtype, @@ -71,8 +68,7 @@ def fused_export(tmp_path, source_xml, fused_store_factory): } }, ) - raw.arrays[0].write(truth[..., :5, :7]).result() - raw_group = zarr.open_group(raw_path, mode="a")["0"] + raw.arrays[0].write(raw_frames).result() image_metadata = ( open_group(raw_path)["0"] .ome_metadata() @@ -91,7 +87,10 @@ def fused_export(tmp_path, source_xml, fused_store_factory): } ) ) - raw_group.attrs["ome"] = image_metadata.model_dump(mode="json", exclude_none=True) + update_group_attributes( + raw_path / "0", + {"ome": image_metadata.model_dump(mode="json", exclude_none=True)}, + ) companion = raw_path / "OME/METADATA.ome.xml" source_ome = from_xml(companion.read_text(encoding="utf-8")) if source_xml: @@ -99,7 +98,9 @@ def fused_export(tmp_path, source_xml, fused_store_factory): 2026, 10, 5, 19, 12, 32, tzinfo=UTC ) source_ome.images[0].pixels.time_increment = 8.5 - source_ome.images[0].pixels.channels[0].emission_wavelength = 525 + source_ome.images[0].pixels.channels[0].emission_wavelength = ( + observed.microscope.wavelength_um * 1000 + ) source_ome.structured_annotations.comment_annotations.append( CommentAnnotation(id="Annotation:sample", value="Simulated specimen μ") ) @@ -112,11 +113,11 @@ def fused_export(tmp_path, source_xml, fused_store_factory): fused_path = tmp_path / "sample_fused.ome.zarr" projection_path = tmp_path / "sample_max_z_fused.ome.zarr" - spacing = (0.23, 0.115, 0.119) + spacing = (0.4, 0.115, 0.115) origin = (-40.5, 20.25, -11.75) for path, data, z_origin in ( (fused_path, truth, origin[0]), - (projection_path, projection, origin[0] + 0.23), + (projection_path, projection, origin[0] + spacing[0]), ): fused_store_factory( data, @@ -127,7 +128,7 @@ def fused_export(tmp_path, source_xml, fused_store_factory): attributes={"specimen": "Simulated α"}, ) # Coarse pixels deliberately differ so exporting the wrong scale fails. - zarr.open_group(path, mode="r+")["1"][:] = 0 + open_group(path)["1"].to_tensorstore().write(0).result() processed = tmp_path / "sample_decon_deskewed.ome.zarr" state = ProcessingState.create(tmp_path / "sample.processing.json", raw_path) state.initialize_run(processed, configuration={"deconvolve": True}, overwrite=True) diff --git a/tests/fixtures/fusion.py b/tests/fixtures/fusion.py index 9f7edae..7d06bc7 100644 --- a/tests/fixtures/fusion.py +++ b/tests/fixtures/fusion.py @@ -1,47 +1,26 @@ """Minimal numerical fusion operators with explicitly supplied geometry and data.""" +from types import SimpleNamespace + import numpy as np import pytest +import tensorstore as ts -from opm_processing.imageprocessing.tilefusion import TileFusion +from opm_processing.imageprocessing.tilefusion import ( + TileFusion, + _infer_registration_limits, +) @pytest.fixture -def fusion_operator(tensorstore_dataset): +def fusion_operator(): """Return a factory for isolated fusion calculations without file discovery. - Numerical unit tests supply only the state consumed by their calculation. - Integration tests construct TileFusion normally from a persisted dataset. - - Parameters - ---------- - tensorstore_dataset : callable - Shared dataset double retaining known source pixels and storage behavior. - - Returns - ------- - callable - Factory populating the state consumed by one numerical fusion calculation. + Tests supply only the state consumed by their calculation. """ def create(operator=TileFusion, *, source_tiles=None, **attributes): - """Construct an operator from a complete, visible unit-test configuration. - - Parameters - ---------- - operator : type - Fusion class whose numerical method is under test. - source_tiles : sequence of numpy.ndarray or None - CZYX tile objects for the mocked TensorStore reader. These supply - array dimensions, unity blending profiles and per-position sources. - **attributes - Geometry, source arrays and execution settings required by that method. - - Returns - ------- - object - Uninitialized operator populated with the supplied attributes. - """ + """Construct an operator from a complete, visible unit-test configuration.""" if source_tiles is not None: channels, z_dim, y_dim, x_dim = source_tiles[0].shape attributes = { @@ -55,9 +34,7 @@ def create(operator=TileFusion, *, source_tiles=None, **attributes): "z_profile": np.ones(z_dim, np.float32), "y_profile": np.ones(y_dim, np.float32), "x_profile": np.ones(x_dim, np.float32), - "position_arrays": tuple( - tensorstore_dataset(tile[None]) for tile in source_tiles - ), + "position_arrays": tuple(ts.array(tile[None]) for tile in source_tiles), **attributes, } instance = operator.__new__(operator) @@ -65,3 +42,349 @@ def create(operator=TileFusion, *, source_tiles=None, **attributes): return instance return create + + +@pytest.fixture +def registration_pair(fusion_operator): + """Share pair geometry and registration state while keeping pixels explicit.""" + + def create( + volumes, + positions, + *, + spacing=(0.23, 0.115, 0.115), + sampling=(3, 2, 2), + **settings, + ): + """Build a fusion operator from prescribed pixels, support and geometry.""" + shapes = [volume.shape for volume in volumes] + limits = _infer_registration_limits(positions, shapes, spacing, sampling, 30) + attributes = { + "_tile_positions": positions, + "_tile_shapes": shapes, + "_tiles_by_time": ((0, 1),), + "_tile_time_indices": [0, 0], + "_tile_support_zy": [np.ones(shape[:2], np.float32) for shape in shapes], + "_pixel_size": spacing, + "downsample_factors": sampling, + "ssim_window": 15, + "threshold": 0.7, + "pairwise_metrics": {}, + "_pair_registration_limits": limits, + "max_registration_shift_zyx": limits[0, 1], + "_max_workers": 1, + "_debug": False, + "_tile_channel_nonzero": {(0, 0): True, (1, 0): True}, + } + attributes.update(settings) + return fusion_operator( + source_tiles=[volume[None] for volume in volumes], **attributes + ) + + return create + + +@pytest.fixture +def block_fusion(fusion_operator): + """Configure bounded block writes shared by mask and channel-weight tests.""" + + def create( + tiles, + positions, + shape, + *, + support=None, + present=None, + block_shape=None, + **settings, + ): + """Build a fusion operator from prescribed pixels, support and geometry.""" + channels, z_dim, y_dim, _ = tiles[0].shape + attributes = { + "fused_ts": ts.array(np.zeros((1, channels, *shape), dtype=np.uint16)), + "write_block_shape": block_shape or [1, 1, *shape], + "offset_um": (0, 0, 0), + "padded_shape": shape, + "_pixel_size": (1, 1, 1), + "_tile_positions": positions, + "chunk_y": shape[1], + "chunk_x": shape[2], + "fusion_ram_fraction": 1.0, + "max_in_flight_writes": 1, + "_max_workers": 1, + "_debug": False, + "_deskew_support_zy": np.ones((z_dim, y_dim), bool) + if support is None + else support, + "_tile_channel_nonzero": present + or { + (tile, channel): True + for tile in range(len(tiles)) + for channel in range(channels) + }, + } + attributes.update(settings) + return fusion_operator(source_tiles=tiles, **attributes) + + return create + + +@pytest.fixture +def registration_cache(tmp_path, processing_state): + """Attach the production durable registration journal to unit operators.""" + + def attach(fusion): + """Supply persistence paths and retain the real save/load implementations.""" + fusion.data = tmp_path / "sample_deskewed.ome.zarr" + fusion.processing_state = processing_state + processing_state.initialize_run(fusion.data, configuration={}, overwrite=True) + fusion.__dict__.setdefault("reverse_stage_y", True) + fusion.__dict__.setdefault("reverse_stage_z", True) + return processing_state + + return attach + + +@pytest.fixture +def retry_fusion(fusion_operator): + """Provide a controlled registration graph with independently specified links.""" + from opm_processing.imageprocessing.tilefusion import TileFusion + + limits = { + (0, 1): (13, 198, 33), + (1, 2): (13, 198, 33), + (3, 4): (9, 202, 31), + (4, 5): (9, 202, 31), + } + return fusion_operator( + TileFusion, + _tile_positions=[(0, 0, 0)] * 6, + _tile_shapes=[(1, 1, 1)] * 6, + _pixel_size=(1, 1, 1), + _is_2d=False, + _tiles_by_time=((0, 1, 2), (3, 4, 5)), + _automatic_registration_limits=True, + _pair_registration_limits=limits.copy(), + _initial_pair_registration_limits=limits.copy(), + _registration_pair_sampling=dict.fromkeys(limits, (1, 5, 5)), + max_registration_shift_zyx=(13, 202, 33), + _initial_registration_shift_zyx=(13, 202, 33), + registration_retries=2, + downsample_factors=(3, 5, 5), + ssim_window=15, + channel_to_use=1, + threshold=0.7, + optimization_rel_threshold=0.5, + optimization_abs_threshold=1.5, + pairwise_metrics={}, + reverse_stage_y=True, + reverse_stage_z=True, + _tile_channel_nonzero={(index, 1): True for index in range(6)}, + ) + + +@pytest.fixture +def shifted_slab_pair(): + """Build known translated slabs from one prescribed blurred random field.""" + from scipy.ndimage import gaussian_filter + + def create( + shape, nominal_step, correction, *, seed=51, spacing=(0.23, 0.115, 0.115) + ): + """Return paired pixels, nominal placement and independent exact shifts.""" + shift = np.asarray(correction, dtype=int) + delta = shift + np.array((nominal_step, 0, 0)) + truth = np.rint( + gaussian_filter( + np.random.default_rng(seed).uniform( + 0, 1000, np.asarray(shape) + abs(delta) + ), + (1, 2, 2), + ) + ).astype(np.uint16) + starts = (np.maximum(0, -delta), np.maximum(0, delta)) + volumes = tuple( + truth[ + tuple( + slice(start, start + length) + for start, length in zip(origin, shape, strict=True) + ) + ] + for origin in starts + ) + return SimpleNamespace( + volumes=volumes, + positions=[(0, 0, 0), (nominal_step * spacing[0], 0, 0)], + spacing=spacing, + expected=shift, + truth=truth, + starts=starts, + ) + + return create + + +@pytest.fixture +def projection_fusion(fusion_operator): + """Share projection geometry and native TensorStore arrays for chunk tests.""" + from opm_processing.imageprocessing.maxtilefusion import MaxTileFusion + + def create(tiles, positions, shape, *, chunk_size=3, **settings): + """Build a bounded projection fuser from explicit TCZYX camera data.""" + channels, height, width = tiles[0].shape[1], *tiles[0].shape[-2:] + return fusion_operator( + MaxTileFusion, + pad_y=0, + pad_x=0, + ts_dataset=tuple(ts.array(tile) for tile in tiles), + tile_positions=np.asarray(positions, float), + pixel_size=np.ones(2), + offset=(0, 0), + time_dim=tiles[0].shape[0], + channels=channels, + tile_shape=(height, width), + fused_shape=shape, + time_range=None, + chunk_size=chunk_size, + weight_mask=np.ones((height, width), np.float32), + output_dtype=tiles[0].dtype, + fused_ts=ts.array( + np.zeros((tiles[0].shape[0], channels, 1, *shape), tiles[0].dtype) + ), + **settings, + ) + + return create + + +@pytest.fixture +def fusion_mosaic(): + """Prescribe one laboratory field seen by cropped, attenuated, partial tiles.""" + shape = (28, 46, 69) + z, y, x = np.indices(shape) + field = 100.375 + 3 * z + 2 * y + x + field[:, 2, 4] = 0 # Real darkness inside valid support. + tiles, origins, shapes, supports, profiles, gains, present = ( + [], + [], + [], + [], + [], + [], + {}, + ) + expected = np.zeros((2, 2, *shape), np.float32) + for time in range(2): + for position, (origin, extent) in enumerate( + zip( + ((0, 0, 0), (0, 11, 23), (9, 23, 7)), + ((21, 35, 47), (21, 29, 41), (17, 21, 51)), + strict=True, + ) + ): + tile_index = len(tiles) + selection = tuple( + slice(start, start + length) + for start, length in zip(origin, extent, strict=True) + ) + coverage = np.ones(extent[:2], np.float32) + coverage[0, :2] = 0 + coverage[-1, 1:] = 0.5 + x_profile = np.linspace(0.25, 1, extent[2], dtype=np.float32) + x_profile[-1] = 0 # No detector support in a padded column. + gain = np.array((1.25, 2), np.float32) + position * 0.25 + truth = np.stack( + [field[selection] * (1 + time + channel) for channel in range(2)] + ) + pixels = truth / gain[:, None, None, None] * coverage[None, ..., None] + pixels[..., -1] = 0 + for channel in range(2): + active = not (position == 1 and channel == time) + present[tile_index, channel] = active + if not active: + pixels[channel] = 0 + continue + mask = (coverage[..., None] > 0) & (x_profile[None, None] > 0) + target = expected[(time, channel, *selection)] + target[mask] = truth[channel][mask] + tiles.append(pixels.astype(np.float32)) + origins.append(origin) + shapes.append(extent) + supports.append(coverage) + profiles.append( + ( + np.ones(extent[0], np.float32), + np.linspace(0.5, 1, extent[1], dtype=np.float32), + x_profile, + ) + ) + gains.append(gain) + return SimpleNamespace( + tiles=tiles, + origins=origins, + shape=shape, + shapes=shapes, + supports=supports, + profiles=profiles, + gains=np.asarray(gains), + present=present, + expected=expected, + ) + + +@pytest.fixture +def weighted_fusion_case(strided, shared, partial): + """Provide strided photon fields, partial coverage and independent feather weights.""" + first = np.arange(3 * 4 * 5 * 10, dtype=np.float32).reshape(3, 4, 5, 10) + first[0, 1, 1, 1] = 0 + first[0, 1, 1, 3] = -20 + first[1, 1, 1, 1] = 90000 + second = first * np.float32(0.5) + np.float32(10) + attenuation_per_um = np.asarray((0.025, 0.05, 0.075), np.float32) + optical_path_um = np.float32(8) + second *= np.exp(-attenuation_per_um * optical_path_um)[:, None, None, None] + depth_gains = (None, np.exp(attenuation_per_um * optical_path_um)) + crop = np.s_[:, 1:3, 1:4, 1:9:2] if strided else np.s_[:, 1:3, 1:4, 1:5] + source_views = (first[crop], second[crop]) + present = ( + (np.ones(3, bool), np.ones(3, bool)) + if shared + else (np.asarray((True, True, False)), np.asarray((True, False, True))) + ) + support = np.asarray(((True, False, True), (False, True, True))) + if partial: + support = support.astype(np.float32) + support[0, 0] = 0.5 + for pixels in source_views: + pixels[:, 0, 0] *= np.float32(0.5) + z_weights = np.asarray((0.5, 1), np.float32) + y_weights = np.asarray((1, 0.25, 0.5), np.float32) + x_weights = np.asarray((1, 0.5, 0.25, 1), np.float32) + spatial_weights = (z_weights[:, None, None] * y_weights[None, :, None]) * x_weights[ + None, None, : + ] + spatial_weights *= (support > 0)[..., None] + accumulated = np.zeros((3, 4, 7, 10), np.float32) + weights = np.zeros((1 if shared else 3, *accumulated.shape[1:]), np.float32) + expected_sum = np.zeros_like(accumulated) + expected_weight = np.zeros_like(accumulated) + selection = np.s_[:, 1:3, 2:5, 3:7] + source_origin = (0, 0, 0) if strided else (1, 1, 1) + return SimpleNamespace( + first=first, + second=second, + source_views=source_views, + present=present, + depth_gains=depth_gains, + support=support, + z_weights=z_weights, + y_weights=y_weights, + x_weights=x_weights, + spatial_weights=spatial_weights, + accumulated=accumulated, + weights=weights, + expected_sum=expected_sum, + expected_weight=expected_weight, + selection=selection, + source_origin=source_origin, + ) diff --git a/tests/fixtures/hardware.py b/tests/fixtures/hardware.py index 2cc4440..dc771d1 100644 --- a/tests/fixtures/hardware.py +++ b/tests/fixtures/hardware.py @@ -1,4 +1,4 @@ -"""CUDA availability and resource cleanup for numerical GPU tests.""" +"""CPU thread limits, CUDA availability and numerical resource cleanup.""" from __future__ import annotations @@ -30,6 +30,9 @@ def unavailable(message: str) -> None: device.use() probe = cp.arange(32, dtype=cp.float32) probe = probe * probe + cp.float32(3) + # Verify the installed FFT and linear algebra runtimes as well as CUDA. + cp.fft.rfft(probe) + cp.tensordot(probe.reshape(4, 8), probe.reshape(8, 4), axes=1) device.synchronize() np.testing.assert_array_equal( cp.asnumpy(probe), @@ -47,20 +50,55 @@ def unavailable(message: str) -> None: cp.get_default_pinned_memory_pool().free_all_blocks() +@pytest.fixture +def reconstruct(cupy_gpu): + """Reconstruct full-grid measurements with the native or acquired-plane solver.""" + import importlib + + def run(observed, psf, factor=None, **options): + """Apply the shared solver to native or selected acquired scan planes.""" + return importlib.import_module("opm_processing.imageprocessing.rlgc").rlgc( + observed if factor is None else observed[::factor], + psf, + scan_upsample_factor=1 if factor is None else factor, + boundary="reflect" if factor is None else "zero", + **options, + ) + + return run + + @pytest.fixture(scope="module") -def experiment(cupy_gpu): - """Load the experimental operators after requiring actual CUDA execution. +def small_cpu_kernels(): + """Bound worker overhead for small parallel numerical unit-test arrays.""" + from numba import get_num_threads, set_num_threads - Parameters - ---------- - cupy_gpu - Fixture proving CUDA execution before importing the audit kernels. + previous = get_num_threads() + set_num_threads(min(2, previous)) + yield + set_num_threads(previous) + + +@pytest.fixture(scope="module") +def nvenc_gpu(cupy_gpu): + """Require a real hardware encoder, without substituting an encoded movie. Returns ------- - module - Audit script containing the experimental count sampler and PSF window. + None + CUDA and NVENC are available for the real video integration tests. """ - from scripts import audit_deconvolution + import PyNvVideoCodec as nvc - return audit_deconvolution + try: + capabilities = nvc.GetEncoderCaps(0, "h264") + except RuntimeError as error: + if not any( + word in str(error).lower() + for word in ("no encode device", "no nvenc capable", "unsupported device") + ): + raise + if os.environ.get("OPM_REQUIRE_GPU") == "1": + pytest.fail(str(error), pytrace=False) + pytest.skip(str(error)) + assert capabilities["width_min"] > 0 and capabilities["height_min"] > 0 diff --git a/tests/fixtures/illumination.py b/tests/fixtures/illumination.py index 4826d33..f54191e 100644 --- a/tests/fixtures/illumination.py +++ b/tests/fixtures/illumination.py @@ -1,94 +1,129 @@ -"""Known depth-dependent illumination and detector stripes for BaSiC recovery.""" +"""Known detector fields and shared camera/storage boundaries for illumination tests.""" from types import SimpleNamespace import numpy as np import pytest +import tensorstore as ts @pytest.fixture -def depth_illumination(): - """Simulate known fluorescence multiplied by physical illumination and camera gain. - - Returns - ------- - types.SimpleNamespace - Ground fluorescence, detector sensitivity and quantized camera data. - Geometry, spectral channels and camera calibration are fixed together. - """ - rng = np.random.default_rng(37) - height, width = 32, 64 - yy, xx = np.meshgrid( - np.linspace(-1, 1, height), np.linspace(-1, 1, width), indexing="ij" +def illumination_observations(request): + """Prescribe post-integration intensities, illumination and camera quantization.""" + kind = request.param + tiled = kind == "tiles" + positions, channels = (6, 2) if tiled else (2, 2 if kind == "masked" else 1) + height, width = (64, 128) if tiled else (32, 64) + y = np.linspace(-1, 1, height, dtype=np.float32)[:, None] + x = np.linspace(-1, 1, width, dtype=np.float32)[None, :] + if tiled: + fields = np.stack( + ( + 1 + 0.28 * y + 0.12 * x + 0.08 * y * x, + 1 - 0.22 * y + 0.16 * x - 0.06 * y * x, + ) + ) + detector_x = 78 + stripe = 1 - 0.12 * np.exp(-0.5 * ((np.arange(width) - detector_x) / 3) ** 2) + fields *= stripe[None, None] + fields /= fields.mean(axis=(1, 2), keepdims=True) + fields = np.broadcast_to(fields[None], (positions, channels, height, width)) + else: + fields = np.stack((1 + 0.25 * x + 0.1 * y, 1 - 0.2 * x + 0.15 * y)) + fields = np.repeat(fields[:, None], channels, axis=1) + detector_x = None + offset, conversion, source = 100.0, 0.25, 1000.0 + specimen = np.random.default_rng(7 if tiled else 37).uniform( + 0.75 * source, 1.25 * source, (positions, channels, 12, height, width) ) - fields = np.stack((1 + 0.25 * xx + 0.1 * yy, 1 - 0.2 * xx + 0.15 * yy)) - specimen = rng.uniform(750, 1250, (2, 12, height, width)) - raw = np.rint(specimen * fields[:, None] / 0.25 + 100).astype(np.uint16) + from tests.simulation.camera import Camera, digitize + + raw = digitize( + specimen * fields[:, :, None], + Camera( + read_noise_e=1.6, + electrons_per_adu=conversion, + offset_adu=offset, + bit_depth=16, + ), + seed=71, + )[None] + coordinates = np.zeros((positions, 3)) + coordinates[:, 1] = np.arange(positions) * 100 if tiled else 20 + coordinates[:, 0] = np.arange(positions) * 10 + mask = np.asarray([[[True, False], [False, False]]]) if kind == "masked" else None + expected = np.ones((channels, height, width), np.float32) + for channel in range(channels): + eligible = np.ones(positions, bool) if mask is None else mask[0, :, channel] + if eligible.any(): + expected[channel] = fields[eligible, channel].max(axis=0) return SimpleNamespace( - fields=fields, height=height, raw=raw, specimen=specimen, width=width + datastore=ts.array(raw), + offset=offset, + conversion=conversion, + coordinates=coordinates, + mask=mask, + expected=expected, + source=source, + detector_x=detector_x, ) @pytest.fixture -def tiled_illumination( - tensorstore_dataset, -): - """Simulate known fluorescence multiplied by physical illumination and camera gain. +def illumination_fit_case(monkeypatch, topology, storage): + """Observe calibrated samples while the installed BaSiCPy performs the fit.""" + from opm_processing.imageprocessing import flatfield - Parameters - ---------- - tensorstore_dataset : callable - Shared dataset double holding simulated raw camera pixels. - - Returns - ------- - types.SimpleNamespace - Ground fluorescence, detector sensitivity and quantized camera data. - Geometry, spectral channels and camera calibration are fixed together. - """ - rng = np.random.default_rng(7) - positions, channels, scan_planes = 6, 2, 12 - height, width = 64, 128 - camera_offset = 100.0 - camera_conversion = 0.25 - - yy = np.linspace(-1.0, 1.0, height, dtype=np.float32)[:, np.newaxis] - xx = np.linspace(-1.0, 1.0, width, dtype=np.float32)[np.newaxis, :] - illuminations = np.stack( - ( - 1.0 + 0.28 * yy + 0.12 * xx + 0.08 * yy * xx, - 1.0 - 0.22 * yy + 0.16 * xx - 0.06 * yy * xx, - ) + positions = 1 if topology == "single" else 3 + gain = np.linspace(0.5, 2, 7, dtype=np.float32) + levels = ( + 8 + + 16 * np.arange(positions)[:, None, None] + + 32 * np.arange(2)[None, :, None] + + 8 * np.arange(3)[None, None] ) - detector_x = 78 - detector_distance = (np.arange(width, dtype=np.float32) - detector_x) / 3.0 - detector_stripe = 1.0 - 0.12 * np.exp(-0.5 * detector_distance**2) - illuminations *= detector_stripe[np.newaxis, np.newaxis, :] - illuminations /= illuminations.mean(axis=(1, 2), keepdims=True) - - specimen = rng.uniform( - 750, - 1250, - size=(positions, channels, scan_planes, height, width), - ).astype(np.float32) - raw = np.rint( - specimen * illuminations[np.newaxis, :, np.newaxis, :, :] / camera_conversion - + camera_offset + raw = np.broadcast_to( + 100 + levels[..., None, None] * gain / 0.5, (positions, 2, 3, 5, 7) ).astype(np.uint16) - datastore = tensorstore_dataset(raw[np.newaxis, ...]) - stage_positions = np.zeros((positions, 3)) - stage_positions[:, 1] = np.arange(positions) * 100.0 + raw = np.stack((raw, raw + 1000)) + coordinates = None if topology == "single" else np.zeros((positions, 3)) + if topology == "tiles": + coordinates[:, 1] = np.arange(positions) + calls = [] + fit = flatfield.BaSiC.fit + def observe_fit(solver, images, *args, **kwargs): + """Record the calibrated images and execute the installed optimizer.""" + if isinstance(images, np.ndarray): + calls.append(images.copy()) + return fit(solver, images, *args, **kwargs) + + monkeypatch.setattr(flatfield.BaSiC, "fit", observe_fit) return SimpleNamespace( - camera_conversion=camera_conversion, - camera_offset=camera_offset, - channels=channels, - datastore=datastore, - detector_x=detector_x, - height=height, - illuminations=illuminations, + datastore=ts.array(raw) if storage else raw, + coordinates=coordinates, + levels=levels, + gain=gain, + calls=calls, raw=raw, - specimen=specimen, - stage_positions=stage_positions, - width=width, ) + + +@pytest.fixture +def constant_illumination_acquisition(acquisition_factory, scan_planes): + """Persist known constant photon fields over time, position and channel.""" + photons = 20 + 4 * np.arange(8).reshape(2, 2, 2) + shape = (*photons.shape, scan_planes, 8, 12) + raw = np.broadcast_to(100 + photons[..., None, None, None] / 0.25, shape).astype( + np.uint16 + ) + dataset = acquisition_factory( + raw, + stage_positions_zxy=((10, 20, 30), (0, 20, 30)), + camera_offset=100, + camera_conversion=0.25, + ) + fields = np.broadcast_to( + np.array([2, 1.25], np.float32)[None, :, None, None], (1, 2, 8, 12) + ).copy() + return SimpleNamespace(dataset=dataset, photons=photons, fields=fields) diff --git a/tests/fixtures/live.py b/tests/fixtures/live.py index 87aca77..c058533 100644 --- a/tests/fixtures/live.py +++ b/tests/fixtures/live.py @@ -15,26 +15,14 @@ LiveManifest, ) from opm_processing.dataio.position_collection import create_position_collection +from opm_processing.imageprocessing.opmtools import orthogonal_deskew if TYPE_CHECKING: from pathlib import Path def manifest_document(data_path: Path, *, timepoints: int = 2) -> dict: - """Describe a calibrated two-channel stage acquisition before any frames arrive. - - Parameters - ---------- - data_path : pathlib.Path - Planned OME-Zarr location recorded relative to its sidecar. - timepoints : int - Planned acquisition time count. - - Returns - ------- - dict - Controller plan with known stage geometry, channels and camera calibration. - """ + """Describe a calibrated two-channel stage acquisition before any frames arrive.""" return { "schema": LIVE_MANIFEST_SCHEMA, "schema_version": "1.0", @@ -78,22 +66,7 @@ def manifest_document(data_path: Path, *, timepoints: int = 2) -> dict: def write_manifest( data_path: Path, *, timepoints: int = 2, document: dict | None = None ) -> LiveManifest: - """Persist a live acquisition plan and reopen it through the real reader. - - Parameters - ---------- - data_path : pathlib.Path - Planned OME-Zarr acquisition path. - timepoints : int - Time count when using the standard two-channel stage plan. - document : dict or None - Explicit physical plan; otherwise use the common manifest configuration. - - Returns - ------- - LiveManifest - Acquisition plan read from its persisted sidecar. - """ + """Persist a live acquisition plan and reopen it through the real reader.""" sidecars = SimpleNamespace( manifest=data_path.with_name( data_path.name.removesuffix(".ome.zarr") + ".manifest.json" @@ -115,18 +88,7 @@ def write_manifest( @pytest.fixture def live_acquisition_factory(tmp_path): - """Return a writer for complete or partially acquired live camera objects. - - Parameters - ---------- - tmp_path : pathlib.Path - Isolated directory for the camera store and controller sidecars. - - Returns - ------- - callable - Writer accepting a known camera object or an incomplete acquisition plan. - """ + """Return a writer for complete or partially acquired live camera objects.""" def create( raw_data=None, @@ -138,30 +100,7 @@ def create( completed=True, illumination=None, ): - """Persist a stage scan, lifecycle plan and measured illumination field. - - Parameters - ---------- - raw_data : numpy.ndarray or None - TPCZYX camera object; None leaves chunks unwritten for readiness tests. - name : str - Acquisition basename in the temporary directory. - shape : tuple of int - Planned TPCZYX dimensions when pixels are not yet available. - stage_positions : sequence or None - Recorded ZXY stage coordinates; defaults to separated lateral fields. - chunks : tuple of int or None - TCZYX storage chunk shape; defaults to one complete detector frame. - completed : bool - Append the controller's completed acquisition event. - illumination : numpy.ndarray or None - Known CYX sensitivity field; None writes uniform unity sensitivity. - - Returns - ------- - types.SimpleNamespace - Acquisition, collection, manifest, lifecycle log and illumination paths. - """ + """Persist a stage scan, lifecycle plan and measured illumination field.""" if raw_data is not None: shape = raw_data.shape data_path = tmp_path / f"{name}.ome.zarr" @@ -217,3 +156,75 @@ def create( ) return create + + +@pytest.fixture(params=["estimate", "pending-chunks", "cached"]) +def live_wait_case( + request, tmp_path, live_acquisition_factory, monkeypatch, simulated_camera +): + """Share camera input and controlled completion for offline live-wait decisions.""" + from opm_processing import process as processing + + kind = request.param + shape = ( + 1, + 1, + 1, + 12 if kind == "estimate" else 4, + 8, + 16 if kind == "estimate" else 5, + ) + raw = ( + simulated_camera(shape[-3:], conversion=0.24).raw[None, None, None, ::-1].copy() + ) + dataset = live_acquisition_factory( + raw if kind == "cached" else None, shape=shape, completed=kind != "estimate" + ) + dataset.illumination_path.unlink() + destination = tmp_path / "processed" + field_path = tmp_path / "sample_flatfield.ome.tif" + if kind == "cached": + processing.write_flatfield( + field_path, np.full((1, 1, *shape[-2:]), 2, np.float32), 0.115 + ) + sleeps = [] + + def finish(seconds): + """Write the acquisition completion event during the wait callback.""" + assert not destination.exists() + assert not (destination / field_path.name).exists() + sleeps.append(seconds) + if len(sleeps) == 1: + dataset.collection.arrays[0].write(raw[:, 0]).result() + assert kind != "cached" + elif len(sleeps) == 2 and kind == "estimate": + dataset.log.write_text( + json.dumps( + { + "event": "completed", + "acquisition_id": dataset.manifest.acquisition_id, + } + ) + + "\n", + encoding="utf-8", + ) + else: + pytest.fail("Processing kept waiting after acquisition completion") + + monkeypatch.setattr(processing.time, "sleep", finish) + calibrated = np.maximum(raw[0, 0, 0, ::-1].astype(np.float32) - 100, 0) * 0.24 + return SimpleNamespace( + kind=kind, + dataset=dataset, + raw=raw, + destination=destination, + sleeps=sleeps, + expected=orthogonal_deskew( + calibrated / (2 if kind == "cached" else 1), + theta=30.0, + distance=0.4, + pixel_size=0.115, + downsample_factor=1, + ), + field_path=field_path if kind == "cached" else destination / field_path.name, + ) diff --git a/tests/fixtures/optics.py b/tests/fixtures/optics.py index 1964acf..f420f83 100644 --- a/tests/fixtures/optics.py +++ b/tests/fixtures/optics.py @@ -1,210 +1,356 @@ -"""Physical specimens and independent optical image formation for reconstruction.""" +"""Physical specimens and shared production optics for reconstruction.""" +import json +from dataclasses import replace from functools import lru_cache +from pathlib import Path +from types import SimpleNamespace import numpy as np import pytest -from scipy import signal -from scipy.signal import fftconvolve -from scripts.opm_simulation import ( - prepare_sphere, -) -from scripts.psf_sampling_experiment import ( - cartesian_psf, -) - -from tests.reference.point_sources import ( - fluorescent_specimen, - optical_field, - skewed_coordinates, +from scipy.interpolate import RegularGridInterpolator + +from tests.simulation.forward_model import ( + Acquisition, + Microscope, + camera_psf, + emission_psf, + forward_model, ) +from tests.simulation.objects import centered_axis, deposit_point @pytest.fixture(scope="session") -def planar_point_model(): - """Return two known emitters and the normalized central YX PSF used to blur them. - - Returns - ------- - tuple - Float32 YX truth, normalized YX PSF and asymmetric SYX PSF whose central - plane defines native 2D reconstruction. Other planes must not contribute. - """ - y, x = np.mgrid[-3:4, -3:4] - central = np.exp(-(y**2 + x**2) / 8).astype(np.float32) - central /= central.sum() - psf = np.stack((np.ones_like(central), central * 7, np.ones_like(central) * 100)) - truth = np.zeros((41, 43), np.float32) - truth[12, 13] = 2000 - truth[28, 30] = 3000 - return truth, central, psf +def simulation_parameters(): + """Load the shared physical acquisition and specimen configuration.""" + return json.loads( + ( + Path(__file__).resolve().parent / "configs/deconvolution_figures.json" + ).read_text() + ) -@pytest.fixture -def planar_camera_acquisition(tmp_path, acquisition_factory, planar_point_model): - """Persist independently blurred emitters across two times, positions and channels. - - Parameters - ---------- - tmp_path : pathlib.Path - Isolated directory receiving the reconstruction PSF. - acquisition_factory : callable - Writer for calibrated upstream camera measurements and metadata. - planar_point_model : tuple - Known YX emitters, central blur kernel and asymmetric volume PSF. +@pytest.fixture(scope="session") +def camera_calibration(simulation_parameters): + """Provide the camera calibration declared in the shared configuration.""" + from tests.simulation.camera import Camera - Returns - ------- - types.SimpleNamespace - Calibrated camera acquisition, unblurred TPCZYX object, photon observations - and on-disk PSF. Poisson measurements use a fixed seed and unity conversion. - """ - from types import SimpleNamespace + return Camera(**simulation_parameters["microscope"]["camera"]) - from scipy import ndimage - truth, central, psf = planar_point_model - gains = ( - np.asarray((1.0, 1.2))[:, None, None] - * np.asarray((1.0, 1.1))[None, :, None] - * np.asarray((1.0, 0.8))[None, None, :] - ) - object_pixels = gains[..., None, None, None] * truth[None, None, None, None] - blurred = ndimage.convolve( - object_pixels, central[None, None, None, None], mode="reflect" - ) - photons = np.random.default_rng(31).poisson(blurred + 0.25).astype(np.float32) - dataset = acquisition_factory( - (photons + 100).astype(np.uint16), - name="planar_points", - mode="projection", - camera_conversion=1.0, - ) - psf_path = tmp_path / "planar_psf.npy" - np.save(psf_path, psf) - return SimpleNamespace( - dataset=dataset, truth=object_pixels, photons=photons, psf_path=psf_path - ) +@pytest.fixture(scope="session") +def microscope_parameters(simulation_parameters, camera_calibration): + """Provide immutable microscope settings for the compact optical fixtures.""" + options = dict(simulation_parameters["microscope"], camera=camera_calibration) + options["psf_half_extent_zyx_um"] = tuple(options["psf_half_extent_zyx_um"]) + # This small physical unit-test grid predates the figure acquisition grid. + return replace(Microscope(**options), scan_step_um=0.2) @pytest.fixture(scope="session") -def sparse_optical_volume(): - """Return a fixed bead object, 637 nm optical PSF and every second photon plane. +def independent_photon_scene_factory(microscope_parameters, simulation_parameters): + """Cache independent fine-grid scenes without retaining an entire parameter sweep.""" + from tests.simulation.scenes import prepare_scene + + @lru_cache(maxsize=2) + def build(kind="points", profile="uniform", spacing=0.05): + """Reuse cached physical scenes at the requested illumination and grid spacing.""" + return prepare_scene( + kind, + replace(microscope_parameters, fine_spacing_um=spacing), + parameters=simulation_parameters["specimens"][kind], + illumination_profile=profile, + ) - Returns - ------- - tuple - Laboratory bead truth and centers, unit-sum optical PSF, independent - noiseless measurements and seeded Poisson data acquired every 800 nm. - Reconstruction spacing is 400 nm, with 115 nm camera pixels. - """ - truth, beads = fluorescent_specimen((49, 65, 49), 0.4) - psf = compact_optical_psf(0.637, 0.4) - expectation = np.maximum( - signal.fftconvolve(truth.astype(np.float64), psf, mode="same"), 0 - ) - photons = np.random.default_rng(637).poisson(expectation)[::2] - return truth, beads, psf, expectation, photons + return build @pytest.fixture(scope="session") -def physical_volume(): - """Return a voxel-integrated specimen and independent detector-integrated PSF. +def simulated_camera( + microscope_parameters, simulation_parameters, independent_photon_scene_factory +): + """Cache camera observations of the shared physical ground-truth objects. Returns ------- - tuple - Float32 specimen, bead XYZ centers in micrometers, unit-sum cropped - reconstruction PSF, and noiseless measurements generated with the - larger optical PSF. Cropping omits at most 0.15 percent of PSF energy. + callable + Render an SYX acquisition using the single forward model. The result + contains the object, optical calibration, camera means and unsigned ADU. + Geometry, exposure and detector calibration are explicit inputs. """ - psf = np.zeros((121, 121, 61), np.float64) - for row_offset in (-1 / 3, 0, 1 / 3): - for col_offset in (-1 / 3, 0, 1 / 3): - x, y, z = skewed_coordinates(psf.shape, offset=(0, row_offset, col_offset)) - coordinates = np.stack(np.broadcast_arrays(z, y, x), axis=-1) - psf += optical_field()(coordinates) / 9 - psf /= psf.sum() - selection = [] - for axis, size in enumerate(psf.shape): - marginal = psf.sum(axis=tuple(i for i in range(3) if i != axis)) - center = size // 2 - radius = next( - r - for r in range(center + 1) - if marginal[center - r : center + r + 1].sum() >= 0.9995 + from tests.simulation.camera import digitize + + @lru_cache(maxsize=24) + def render( + shape, + *, + step=0.4, + offset=100.0, + conversion=0.25, + geometry="oblique", + exposure=1.0, + seed=42, + kind="points", + profile="uniform", + ): + """Simulate optical formation before camera sampling and digitization.""" + scene = independent_photon_scene_factory(kind) + microscope = replace( + microscope_parameters, + scan_step_um=step, + camera=replace( + microscope_parameters.camera, + offset_adu=offset, + electrons_per_adu=conversion, + ), + ) + expectation = forward_model( + scene.density, + Acquisition( + scene.axes, + shape, + geometry=geometry, + exposure=exposure, + illumination_profile=profile, + camera_photon_budget=tuple(simulation_parameters["photon_budgets"][0]), + output="expectation", + ), + microscope, + ) + raw = digitize(expectation, microscope.camera, seed=seed) + return SimpleNamespace( + ground_truth=scene, + microscope=microscope, + expectation=expectation, + raw=raw, ) - selection.append(slice(center - radius, center + radius + 1)) - cropped = psf[tuple(selection)] - assert cropped.sum() >= 0.9985 - truth, beads = fluorescent_specimen((73, 81, 49), 0.2) - expectation = np.maximum(signal.fftconvolve(truth, psf, mode="same"), 0) - return truth, beads, (cropped / cropped.sum()).astype(np.float32), expectation + return render -@pytest.fixture(scope="session") -def optical_psf(): - """Generate the production vectorial PSF at the reconstruction spacing.""" - return compact_optical_psf(0.610, 0.2) +@pytest.fixture(scope="session") +def optical_setup(microscope_parameters): + """Share one calibrated PSF between profiles, operators and optical specimens.""" + microscope = replace( + microscope_parameters, + fine_spacing_um=0.04, + psf_half_extent_zyx_um=(3.0, 2.0, 2.0), + ) + axes, field = emission_psf(microscope, microscope.fine_spacing_um) + psf, scale = camera_psf(microscope) + return SimpleNamespace( + microscope=microscope, + psf=psf.astype(np.float32), + density_scale=scale, + field=RegularGridInterpolator(axes, field, bounds_error=False, fill_value=0), + ) -@lru_cache(maxsize=4) -def compact_optical_psf(wavelength, scan_step): - """Evaluate the optical model, caching only identical physical parameters. - Parameters - ---------- - wavelength : float - Emission wavelength in micrometers. - scan_step : float - Scan-plane spacing in micrometers. +@pytest.fixture(scope="session", params=["x", "y", "z"]) +def rayleigh_pair(request, optical_setup): + """Direction-specific Rayleigh pair using production optical sampling. - Returns - ------- - numpy.ndarray - Unit-sum float32 skewed PSF at 115 nm detector pitch and 30 degree tilt. - Cropping retains at least 99.85 percent of the full model's energy. + Fine optical samples locate the diffraction PSF's first minimum. The target + contrast is measured from the noise-free forward acquisition on the same + grid and interpolated profile as the reconstruction. A continuous optical + profile is not a contrast threshold for discrete camera samples. + The two sources are placed at that physical separation, without snapping + their positions to acquisition voxels or creating reconstruction-grid delta PSFs. + The common detector field contains the full PSF response of every pair; + its scan endpoints also coincide for the tested sparse acquisitions. """ - from opm_processing.imageprocessing.opmpsf import generate_skewed_psf - - psf = generate_skewed_psf( - em_wvl=wavelength, - pixel_size_um=0.115, - scan_axis_step_um=scan_step, - theta_deg=30, + from tests.simulation.metrics import first_minimum, two_point_profile + + axis = request.param + index = "xyz".index(axis) + directions = np.eye(3) + direction = directions[index] + radius = np.arange(0, 1.8001, 0.005) + separations = np.array( + [ + first_minimum(radius, optical_setup.field((radius[:, None] * d)[:, ::-1])) + for d in directions + ] ) - # Compact only negligible tails for GPU test runtime. Bound the total - # omitted energy explicitly; retain the full skew orientation and center. - psf /= psf.sum() - slices = [] - for axis, size in enumerate(psf.shape): - marginal = psf.sum(axis=tuple(i for i in range(3) if i != axis)) - center = size // 2 - radius = next( - r - for r in range(center + 1) - if marginal[center - r : center + r + 1].sum() >= 0.9995 + separation = separations[index] + microscope = optical_setup.microscope + theta = np.deg2rad(microscope.angle_deg) + xyz_offsets = directions * separations[:, None] / 2 + offsets = np.column_stack( + ( + (xyz_offsets[:, 1] - xyz_offsets[:, 2] / np.tan(theta)) + / microscope.scan_step_um, + xyz_offsets[:, 2] / (microscope.pixel_size_um * np.sin(theta)), + xyz_offsets[:, 0] / microscope.pixel_size_um, ) - slices.append(slice(center - radius, center + radius + 1)) - cropped = psf[tuple(slices)] - assert cropped.sum() >= 0.9985 - return (cropped / cropped.sum()).astype(np.float32) + ) + # Detector half-width >= PSF half-width + maximum source displacement. + shape = ( + 2 + * np.ceil( + (np.array(optical_setup.psf.shape) - 1) / 2 + + np.max(np.abs(offsets), axis=0) + ).astype(int) + + 1 + ) + scan_multiple = np.lcm(3, 4) + shape[0] = ((shape[0] - 1 + scan_multiple - 1) // scan_multiple) * scan_multiple + 1 + offset = offsets[index] + axes = tuple(centered_axis(e, 0.04) for e in (4.0, 3.0, 3.0)) + density = np.zeros(tuple(map(len, axes)), np.float32) + for sign in (-1, 1): + deposit_point(density, axes, direction * sign * separation / 2, 100000) + expectation = forward_model( + density, Acquisition(axes, tuple(shape), output="expectation"), microscope + ) + return SimpleNamespace( + axis=axis, + separation=separation, + microscope=microscope, + acquisition_dip=two_point_profile( + expectation, (np.array(expectation.shape) - 1) / 2, offset, microscope + )["valley_depth"], + offset=offset, + expectation=expectation, + psf=optical_setup.psf, + ) @pytest.fixture(scope="session") -def specimen(): - """Integrate bead volumes and a tilted filament over skew-grid voxels.""" - return fluorescent_specimen((73, 65, 49), 0.2) +def planar_point_model(): + """Return two known emitters and the normalized central YX PSF used to blur them.""" + y, x = np.mgrid[-3:4, -3:4] + central = np.exp(-(y**2 + x**2) / 8).astype(np.float32) + central /= central.sum() + psf = np.stack((np.ones_like(central), central * 7, np.ones_like(central) * 100)) + truth = np.zeros((41, 43), np.float32) + truth[12, 13] = 2000 + truth[28, 30] = 3000 + return truth, central, psf -@pytest.fixture(scope="session") -def physical_sphere(): - """Use the requested 10 um object on a 50 nm Cartesian grid, all in memory.""" - return prepare_sphere() +@pytest.fixture +def uniform_detector_acquisition( + request, + acquisition_factory, + tmp_path, + roi_mode, + camera_gain_profile, + production_psf_parameters, + microscope_parameters, +): + """Persist uniform fluorescence with a known nonuniform camera response.""" + from opm_processing.imageprocessing.opmtools import orthogonal_deskew + from opm_processing.process import write_flatfield + + generated = isinstance(request.param, tuple) + mode = request.param[0] if generated else request.param + deconvolution = {} + if generated: + deconvolution.update( + psf_parameters=production_psf_parameters, + emission_wavelengths_nm=[microscope_parameters.wavelength_um * 1000], + ) + if mode == "stage": + deconvolution["detector_gain_x"] = camera_gain_profile.tolist() + shape = (1 if mode == "projection" else 8, 12, 1900) + field = np.broadcast_to( + np.repeat(np.array((0.25, 0.5, 1.0, 2.0), np.float32), 475), shape[-2:] + ).copy() + response = field.copy() + if mode == "stage": + response *= camera_gain_profile + truth = np.full(shape, 1200, dtype=np.float32) + measured = truth * response + raw = np.clip(np.rint(100 + measured / 0.5), 0, 65535).astype(np.uint16) + dataset = acquisition_factory( + raw[None, None, None], + name="uniform_detector", + mode=mode, + camera_conversion=0.5, + deconvolution=deconvolution or None, + ) + write_flatfield( + tmp_path / "uniform_detector_flatfield.ome.tif", field[None, None], 0.115 + ) + psf_path = tmp_path / "uniform_detector_psf.npy" + psf = np.ones((3, 3, 3), np.float32) + np.save(psf_path, psf / psf.sum()) + expected = ( + truth + if mode == "projection" + else orthogonal_deskew( + np.full(shape, 1200, dtype=np.float32), + theta=30.0, + distance=0.4, + pixel_size=0.115, + downsample_factor=1, + ) + ) + options = { + "deconvolve": True, + "flatfield_correction": True, + "decon_psf_paths": [psf_path], + "save_float32": True, + "z_downsample_level": 1, + "max_projection": False, + "create_fused_max_projection": False, + "write_fused_max_projection_tiff": False, + } + if generated: + options.pop("decon_psf_paths") + if roi_mode: + from opm_processing.dataio.roi import PhysicalRoi + + y_stop, x_stop = expected.shape[-2] - 3, expected.shape[-1] - 50 + roi_path = PhysicalRoi( + # Bounds lie inside edge voxels: intersecting-voxel selection is + # unambiguous despite binary rounding of physical coordinates. + bounds_yx_um=( + 3.25 * 0.115, + (y_stop - 0.25) * 0.115, + 50.25 * 0.115, + (x_stop - 0.25) * 0.115, + ), + source_path=tmp_path / "uniform_detector_fused.ome.zarr", + grid_origin_yx_um=(0, 0), + pixel_size_yx_um=(0.115, 0.115), + position_indices=(0,), + tile_footprints=( + { + "time_index": 0, + "position_index": 0, + "origin_zyx_um": [0, 0, 0], + "bounds_yx_um": [ + 0, + expected.shape[-2] * 0.115, + 0, + expected.shape[-1] * 0.115, + ], + }, + ), + ).write(tmp_path / "uniform_detector_roi.json") + expected = expected[:, 3:y_stop, 50:x_stop] + for key in ( + "max_projection", + "create_fused_max_projection", + "write_fused_max_projection_tiff", + ): + options.pop(key) + options.update(roi_json=roi_path, output=tmp_path) + suffix = "projection" if mode == "projection" else "deskewed" + return SimpleNamespace( + dataset=dataset, + psf_path=psf_path, + expected=expected, + options=options, + roi_mode=roi_mode, + output=tmp_path / f"uniform_detector_decon_{suffix}.ome.zarr", + ) @pytest.fixture(scope="session") -def fine_optical_images(): +def fine_optical_images(microscope_parameters): """Blur a finite fluorescent object in physical space before sampling. A Gaussian fluorophore density describes a smooth, finite-size bead, not @@ -215,7 +361,12 @@ def fine_optical_images(): for spacing in (0.0575, 0.02875, 0.014375): # Shared physical boundaries must also coincide across refinements; # otherwise changes in zero-filled support masquerade as sampling error. - axes, psf = cartesian_psf(spacing, (24 * 0.0575, 16 * 0.0575, 16 * 0.0575)) + microscope = replace( + microscope_parameters, + fine_spacing_um=spacing, + psf_half_extent_zyx_um=(24 * 0.0575, 16 * 0.0575, 16 * 0.0575), + ) + axes, psf = emission_psf(microscope, spacing) z, y, x = np.meshgrid(*axes, indexing="ij", sparse=True) center = (0.07, 0.11, -0.04) sigma = 0.12 @@ -226,6 +377,181 @@ def fine_optical_images(): ) / (2 * sigma**2) ) / ((2 * np.pi) ** 1.5 * sigma**3) - image = fftconvolve(density, psf, mode="same") + image = forward_model( + density, + Acquisition(axes, output="irradiance"), + microscope, + ) result.append((axes, psf, image)) return result + + +@pytest.fixture(scope="session") +def linear_camera_field(): + """Known concentration ramp, with an even row count and shifted XYZ origin.""" + axes = tuple(np.linspace(-5, 5, 101) for _ in range(3)) + z, y, x = np.meshgrid(*axes, indexing="ij", sparse=True) + return SimpleNamespace( + axes=axes, + field=100 + 2 * x + 3 * y + 5 * z, + shape=(7, 12, 9), + center=(0.03, -0.12, 0.21), + ) + + +@pytest.fixture +def discrete_optical_case(factor, nonuniform): + """Blur a prescribed positive 3D object for full and tiled operator units.""" + from scipy import ndimage + + scan, row, column = np.indices((31, 24, 26), dtype=float) + truth = ( + 100 + 50 * np.cos(scan / 3) ** 2 + 30 * np.sin((row + column) / 4) ** 2 + ).astype(np.float32) + psf = np.arange(1, 28, dtype=np.float32).reshape(3, 3, 3) + psf /= psf.sum() + response = ( + np.broadcast_to(np.linspace(0.3, 2, truth.shape[-1]), truth.shape[-2:]) + if nonuniform + else np.ones(truth.shape[-2:]) + ) + boundary = "reflect" if factor is None else "constant" + expectation = ndimage.convolve(truth, psf, mode=boundary) * response + measured = expectation if factor is None else expectation[::factor] + return SimpleNamespace( + measured=measured.astype(np.float32), + psf=psf, + response=response, + truth=truth, + expectation=expectation, + boundary=boundary, + ) + + +@pytest.fixture +def point_profile_case(request, microscope_parameters): + """Provide prescribed emitters and independent expectations on a shared grid.""" + pattern, offset, separation = request.param + center = np.array((8.0, 8.0, 8.0)) + offset = np.asarray(offset) + volume = np.full((17, 17, 17), float(pattern == "flat")) + if pattern == "pair": + volume[tuple((center - offset).astype(int))] = 100 + volume[tuple((center + offset).astype(int))] = 200 + elif pattern == "adjacent": + center[2] += 0.5 + volume[8, 8, 8:10] = 100 + else: + volume[8, 8, 8] = 1 + return SimpleNamespace( + volume=volume, + center=center, + offset=offset, + separation=separation, + microscope=microscope_parameters, + pattern=pattern, + ) + + +@pytest.fixture(scope="session") +def production_psf_parameters(microscope_parameters): + """Provide explicit optical support and taper calibration for generated kernels.""" + microscope = microscope_parameters + return { + "lens": dict(microscope.psf_parameters, NA=microscope.numerical_aperture), + "skewed_depth_um": 0.0, + "projection_depth_um": 0.0, + "projection_nz": 3, + "projection_dz_um": microscope.fine_spacing_um, + "projection_nxy": 15, + "taper_sigma_px_3d": 4.0, + "taper_sigma_px_2d": 4.0, + "depth_scale_um": 100.0, + "depth_width_scale": 4.0, + "support_fwhms": 4.0, + "grid_oversampling": 2, + } + + +@pytest.fixture +def production_psf_grid(monkeypatch): + """Observe production optical fields and their declared sample coordinates.""" + from opm_processing.imageprocessing import opmpsf + + grids = [] + evaluate = opmpsf.create_psf_silicone_100x + + def capture(*args, **kwargs): + """Retain the actual optical field and its declared Cartesian spacing.""" + field = evaluate(*args, **kwargs) + grids.append((field, args[:4])) + return field + + monkeypatch.setattr(opmpsf, "create_psf_silicone_100x", capture) + return grids + + +@pytest.fixture(scope="module", params=[(0.4, (5, 17, 17)), (1.2, (3, 17, 17))]) +def production_point_acquisition( + request, microscope_parameters, production_psf_parameters +): + """Share production optics between fine-grid simulation and deconvolution. + + Kernel probabilities are conditioned on the finite camera ROI. Unit-mass + normalization is fixed before sampling camera data and does not fit brightness. + An infinite taper width selects the untapered PSF used by this simulation. + """ + from opm_processing.imageprocessing.opmpsf import generate_skewed_psf + + step, shape = request.param + microscope = replace( + microscope_parameters, + scan_step_um=step, + pixel_samples=12, + psf_half_extent_zyx_um=(0.6, 2.2, 2.2), + ) + expectations = [] + for spacing in (0.02, 0.01): + axis = np.arange(-1, 2) * spacing + density = np.zeros((3, 3, 3)) + deposit_point(density, (axis,) * 3, (0, 0, 0), 1) + expectations.append( + forward_model( + density, + Acquisition( + (axis,) * 3, + shape, + output="expectation", + camera_photon_budget=(1000, 0), + ), + replace(microscope, fine_spacing_um=spacing), + ) + ) + parameters = dict(production_psf_parameters, taper_sigma_px_3d=np.inf) + kernels = {} + for samples in (1, 2, 3): + kernel = generate_skewed_psf( + microscope.wavelength_um, + microscope.pixel_size_um, + step, + microscope.angle_deg, + parameters=dict(parameters, grid_oversampling=samples), + ) + region = tuple( + slice((n - size) // 2, (n + size) // 2) + for n, size in zip(kernel.shape, shape, strict=True) + ) + kernel = kernel[region] + kernels[samples] = kernel / kernel.sum() + kernel, _ = camera_psf(replace(microscope, fine_spacing_um=spacing)) + region = tuple( + slice((n - size) // 2, (n + size) // 2) + for n, size in zip(kernel.shape, shape, strict=True) + ) + kernel = kernel[region] + return SimpleNamespace( + microscope=microscope, + expectations=expectations, + kernels=kernels, + kernel=kernel / kernel.sum(), + ) diff --git a/tests/fixtures/pipeline.py b/tests/fixtures/pipeline.py new file mode 100644 index 0000000..968e015 --- /dev/null +++ b/tests/fixtures/pipeline.py @@ -0,0 +1,359 @@ +"""Shared qi2lab acquisitions for complete processing and fusion integration.""" + +import importlib +from dataclasses import replace +from functools import lru_cache, partial +from types import SimpleNamespace + +import numpy as np +import pytest + +from opm_processing.dataio.deconvolution import scan_upsample_from_metadata +from opm_processing.imageprocessing.tilefusion import TileFusion +from tests.simulation.camera import digitize +from tests.simulation.forward_model import Acquisition, camera_psf, forward_model +from tests.simulation.scenes import prepare_scene + + +@pytest.fixture +def pipeline_runner(monkeypatch, processing_options, invoke_cli): + """Run real processing and fusion with shared storage and worker boundaries. + + Returns + ------- + callable + Process a raw acquisition into an independently selected output directory, + optionally configuring deskew output tiles, RLGC and fusion block sizes. + """ + from opm_processing.dataio.acquisition import acquisition_stem + from opm_processing.dataio.position_collection import open_position_collection + from opm_processing.imageprocessing import opmtools + from opm_processing.imageprocessing.opmtools import orthogonal_deskew + + module = importlib.import_module("opm_processing.process") + fuse = importlib.import_module("opm_processing.fuse") + + def run( + dataset, + *, + deconvolve=False, + illumination=False, + crop_scan=None, + psf_path=None, + output=None, + deskew_tile=None, + decon_storage=None, + fusion_chunk=(16, 16), + blend=(0, 0, 0), + ): + """Persist calibrated tiles and registered multiscales using real algorithms.""" + options = dict( + processing_options, + deconvolve=deconvolve, + flatfield_correction=illumination, + save_float32=True, + output=output, + ) + if deconvolve: + options.update( + decon_psf_paths=[psf_path] * dataset.raw_data.shape[2], + decon_crop_scan=crop_scan, + decon_verbose=0, + ) + deskew_tiles = set() + reconstructions = [] + with monkeypatch.context() as execution: + if deconvolve: + solver = importlib.import_module("opm_processing.imageprocessing.rlgc") + reconstruct = solver.rlgc + + def observe_reconstruction(*args, **kwargs): + """Retain actual calibrated reconstructions for physical truth checks.""" + if decon_storage is not None: + kwargs["storage"] = decon_storage + result = reconstruct(*args, **kwargs) + reconstructions.append(result.copy()) + return result + + execution.setattr(solver, "rlgc", observe_reconstruction) + if deskew_tile is not None: + interpolate = opmtools.interpolate_rows + + def record_tile(*args): + """Record executed global output tiles and run the real kernel.""" + deskew_tiles.add((args[-1], args[1].shape)) + return interpolate(*args) + + execution.setattr(opmtools, "interpolate_rows", record_tile) + execution.setattr( + module, + "orthogonal_deskew", + partial(orthogonal_deskew, tile_shape=deskew_tile), + ) + invoke_cli("process", dataset.path, **options) + directory = dataset.path.parent if output is None else output + stem = acquisition_stem(dataset.path) + processed = ( + directory / f"{stem}_{'decon_' if deconvolve else ''}deskewed.ome.zarr" + ) + field = ( + module.read_current_flatfield( + directory / f"{stem}_flatfield.ome.tif", + (1, dataset.raw_data.shape[2], *dataset.raw_data.shape[-2:]), + ) + if illumination + else None + ) + created = [] + + def observe_fusion(*args, **kwargs): + """Keep the actual CLI-created solver and its nominal stage positions.""" + fusion = TileFusion(*args, **kwargs) + created.append((fusion, np.asarray(fusion._tile_positions).copy())) + return fusion + + with monkeypatch.context() as execution: + execution.setattr(fuse, "TileFusion", observe_fusion) + invoke_cli( + "fuse", + processed, + blend_pixels=blend, + downsample_factors=(1, 1, 1), + registration_channel=dataset.raw_data.shape[2] - 1, + ssim_window=3, + registration_threshold=0.1, + max_registration_shift_zyx=(2, 2, 2), + chunk_shape_yx=fusion_chunk, + max_workers=1, + normalize_depth_intensity=False, + ) + ((fusion, nominal),) = created + return SimpleNamespace( + options=options, + processed=processed, + field=field, + fusion=fusion, + nominal=nominal, + collection=open_position_collection(processed), + output=directory / f"{stem}_fused.ome.zarr", + deskew_tiles=deskew_tiles, + reconstructions=reconstructions, + ) + + return run + + +@pytest.fixture(scope="session") +def pipeline_optical_data( + microscope_parameters, simulation_parameters, acquisition_metadata +): + """Cache production optics and camera means for repeated fields and mosaics. + + Returns + ------- + callable + Return microscope, noiseless camera means and the matching production PSF + for a scan step, illumination selection and extended-mosaic flag. + """ + + @lru_cache(maxsize=6) + def create(step, illumination=False, *, mosaic=False): + """Render one calibrated physical specimen before independent camera draws.""" + microscope = replace(microscope_parameters, scan_step_um=step) + settings = simulation_parameters["pipeline_mosaic" if mosaic else "pipeline"] + kind = "line-pairs" if mosaic else "points" + specimen = simulation_parameters["specimens"][kind] + if mosaic: + specimen = { + "half_extent_zyx_um": settings["half_extent_zyx_um"], + "features": dict( + specimen["features"], length_um=settings["line_length_um"] + ), + } + scene = prepare_scene(kind, microscope, parameters=specimen) + height, width = settings["camera_shape_yx"] + shape = ( + round(settings["scan_extent_um"] / step) + 1, + height, + width + settings.get("tile_offset_x_pixels", 0), + ) + expected = forward_model( + scene.density, + Acquisition( + scene.axes, + shape, + illumination_profile="edge-taper" if illumination else "uniform", + camera_photon_budget=tuple(simulation_parameters["photon_budgets"][0]), + output="expectation", + ), + microscope, + ) + factor = scan_upsample_from_metadata( + replace(acquisition_metadata, scan_axis_step_um=step) + ) + psf, _ = camera_psf(replace(microscope, scan_step_um=step / factor)) + return microscope, expected, psf.astype(np.float32) + + return create + + +@pytest.fixture +def pipeline_case( + request, + deconvolve, + pipeline_optical_data, + simulation_parameters, + acquisition_factory, + tmp_path, + pipeline_runner, + small_cpu_kernels, +): + """Process independent repeat exposures with a known stage-placement error. + + Indirect parameters select scan mode, illumination, scan step, FFT scan + chunk and whether read-noise calibration is recorded. + + Returns + ------- + types.SimpleNamespace + Raw acquisition, reopened processed collection, fitted illumination, + fusion object and the independent expected support. + + Notes + ----- + Both positions observe the same physical field with independent camera + noise. Position 1 reports an erroneous one-pixel lateral stage shift. + BaSiC runs in its production worker process. Camera calibration, RLGC, + orthogonal interpolation, registration and fusion execute normally, with + native TensorStore storage in pytest temporary directories. + """ + from opm_processing.imageprocessing.opmtools import orthogonal_deskew + + mode, illumination, step, crop, record_noise = request.param + microscope, expectation, psf = pipeline_optical_data(step, illumination) + raw = np.empty((2, 2, 2, *expectation.shape), dtype=np.uint16) + for time, position, channel in np.ndindex(raw.shape[:3]): + raw[time, position, channel] = digitize( + expectation * (1 + time / 4 + channel / 2), + microscope.camera, + seed=100 * time + 10 * position + channel, + ) + if mode == "stage": + raw = raw[:, :, :, ::-1].copy() + calibration = ( + {"read_noise": {"rms": microscope.camera.read_noise_e, "units": "electrons"}} + if record_noise + else None + ) + pixel = microscope.pixel_size_um + dataset = acquisition_factory( + raw, + name="pipeline", + mode=mode, + camera_offset=microscope.camera.offset_adu, + camera_conversion=microscope.camera.electrons_per_adu, + pixel_size_um=pixel, + scan_axis_step_um=step, + angle_deg=microscope.angle_deg, + stage_positions_zxy=((2.3, -4.6, 1.15), (2.3, -4.6, 1.15 + pixel)), + deconvolution=calibration, + acquired_plane_count=raw.shape[-3], + plane_period_s=simulation_parameters["pipeline"]["plane_period_s"], + ) + psf_path = None + if deconvolve: + psf_path = tmp_path / "measured_psf.npy" + np.save(psf_path, psf) + result = pipeline_runner( + dataset, + deconvolve=deconvolve, + illumination=illumination, + crop_scan=crop, + psf_path=psf_path, + ) + factor = scan_upsample_from_metadata(dataset.metadata) if deconvolve else 1 + reconstruction_shape = ( + (expectation.shape[0] - 1) * factor + 1, + *expectation.shape[1:], + ) + support = ( + orthogonal_deskew( + np.full(reconstruction_shape, 1, dtype=np.float32), + distance=step / factor, + pixel_size=pixel, + theta=microscope.angle_deg, + downsample_factor=1, + ) + > 0 + ) + return SimpleNamespace( + **vars(result), + dataset=dataset, + microscope=microscope, + support=support, + ) + + +@pytest.fixture +def mosaic_acquisition( + request, + pipeline_optical_data, + simulation_parameters, + acquisition_factory, + tmp_path, + small_cpu_kernels, +): + """Persist independent adjacent views of the same extended fluorescent object. + + Returns + ------- + types.SimpleNamespace + Raw qi2lab acquisition, physical camera expectation, PSF and the shared + dimensions used to check scan chunks, deskew tiles and mosaic seams. + """ + mode, step = request.param + microscope, expectation, psf = pipeline_optical_data(step, mosaic=True) + parameters = simulation_parameters["pipeline_mosaic"] + height, width = parameters["camera_shape_yx"] + offset = parameters["tile_offset_x_pixels"] + raw = np.empty((1, 2, 1, expectation.shape[0], height, width), np.uint16) + for position in range(2): + start = position * offset + raw[0, position, 0] = digitize( + expectation[..., start : start + width], + microscope.camera, + seed=simulation_parameters["camera_seed"] + position, + ) + if mode == "stage": + raw = raw[:, :, :, ::-1].copy() + pixel = microscope.pixel_size_um + dataset = acquisition_factory( + raw, + name="mosaic", + mode=mode, + pixel_size_um=pixel, + scan_axis_step_um=step, + angle_deg=microscope.angle_deg, + camera_offset=microscope.camera.offset_adu, + camera_conversion=microscope.camera.electrons_per_adu, + stage_positions_zxy=( + (2.3, -4.6, 1.15), + (2.3, -4.6, 1.15 + (offset + 1) * pixel), + ), + deconvolution={ + "read_noise": {"rms": microscope.camera.read_noise_e, "units": "electrons"} + }, + ) + factor = scan_upsample_from_metadata(dataset.metadata) + fine_expectation = pipeline_optical_data(step / factor, mosaic=True)[1] + psf_path = tmp_path / "mosaic_psf.npy" + np.save(psf_path, psf) + return SimpleNamespace( + dataset=dataset, + microscope=microscope, + expectation=expectation, + psf=psf, + psf_path=psf_path, + parameters=parameters, + reconstruction_expectation=fine_expectation, + ) diff --git a/tests/fixtures/roi.py b/tests/fixtures/roi.py index fe6f4a0..4b39fed 100644 --- a/tests/fixtures/roi.py +++ b/tests/fixtures/roi.py @@ -12,34 +12,130 @@ @pytest.fixture -def roi_run(tmp_path: Path, acquisition_factory): - """Build a three-channel acquisition with two selected nonzero positions. +def roi_preview( + request, + acquisition_factory, + small_cpu_kernels, + simulated_camera, + simulation_parameters, +): + """Create a calibrated raw timelapse and a real processed max-Z preview.""" + from types import SimpleNamespace + + from opm_processing.dataio.position_collection import open_position_collection + from opm_processing.dataio.roi import projection_tile_footprints + from opm_processing.imageprocessing.maxtilefusion import ( + regenerate_fused_max_projection, + ) + from opm_processing.imageprocessing.tilefusion import TileFusion + from opm_processing.process import process_skewed + + mode, positions, registered = request.param + pixels = np.empty((2, positions, 2, 8, 12, 14), np.uint16) + for time, position, channel in np.ndindex(pixels.shape[:3]): + pixels[time, position, channel] = simulated_camera( + pixels.shape[-3:], + conversion=1.0, + exposure=1 + time / 4 + channel / 2, + ).raw + if mode == "stage": + pixels = pixels[:, :, :, ::-1].copy() + dataset = acquisition_factory( + pixels, + mode=mode, + camera_conversion=1.0, + stage_positions_zxy=((2.3, -4.6, 1.15),) * positions, + acquired_plane_count=pixels.shape[-3], + plane_period_s=simulation_parameters["pipeline"]["plane_period_s"], + ) + process_skewed( + dataset.path, + acquisition=dataset.metadata, + deconvolve=False, + max_projection=True, + create_fused_max_projection=positions > 1, + save_float32=True, + z_downsample_level=1, + ) + preview = dataset.path.parent / ( + "sample_max_z_fused.ome.zarr" + if positions > 1 + else "sample_max_z_deskewed.ome.zarr" + ) + if registered: + fusion = TileFusion( + dataset.path.parent / "sample_deskewed.ome.zarr", + multiscale_factors=(), + resolution_multiples=((1, 1, 1),), + ) + fusion.run() + regenerate_fused_max_projection( + dataset.path.parent / "sample_fused.ome.zarr", preview + ) + footprints = projection_tile_footprints(preview) + return SimpleNamespace( + dataset=dataset, + preview=preview, + positions=positions, + origin=footprints[0]["origin_zyx_um"], + collection=open_position_collection( + dataset.path.parent / "sample_deskewed.ome.zarr" + ), + ) - Parameters - ---------- - tmp_path : pathlib.Path - Isolated directory for the source, ROI and processed images. - acquisition_factory : callable - Shared writer for calibrated upstream camera data. + +@pytest.fixture +def napari_model(monkeypatch, tmp_path_factory): + """Share headless viewer setup with isolated runtime caches and settings.""" + from napari.resources import _icons + from napari.utils import _platformdirs + + directory = tmp_path_factory.mktemp("napari-runtime") + monkeypatch.setattr(_icons, "user_cache_dir", lambda: str(directory)) + monkeypatch.setattr(_platformdirs, "user_config_dir", lambda: str(directory)) + from napari.components import ViewerModel + from napari.plugins import _initialize_plugins + + _initialize_plugins() + model = ViewerModel() + yield model + model.layers.clear() + + +@pytest.fixture +def roi_psf(roi_preview, pipeline_optical_data, tmp_path): + """Store the same physical PSF used by the shared forward simulator. Returns ------- - types.SimpleNamespace - Known camera volumes, physical ROI and independently derived cropped - fluorescence for original and overwritten data. Numerical processing, - metadata inspection and fusion remain unmodified. + pathlib.Path + Measured-kernel input sampled at the acquisition scan spacing. """ + step = roi_preview.dataset.metadata.scan_axis_step_um + psf = pipeline_optical_data(step)[2] + path = tmp_path / "roi_psf.npy" + np.save(path, psf) + return path + + +@pytest.fixture +def roi_run(tmp_path: Path, acquisition_factory, simulated_camera): + """Build a three-channel acquisition with two selected nonzero positions.""" import importlib from types import SimpleNamespace from opm_processing.dataio.roi import PhysicalRoi - from tests.reference.deskew import constant_deskew + from opm_processing.imageprocessing.opmtools import orthogonal_deskew process = importlib.import_module("opm_processing.process") pixels = np.empty((1, 3, 3, 8, 12, 14), np.uint16) for position in range(3): for channel in range(3): - pixels[0, position, channel] = 110 + 20 * position + channel + pixels[0, position, channel] = simulated_camera( + pixels.shape[-3:], + conversion=1.0, + exposure=1 + position / 4 + channel / 2, + ).raw dataset = acquisition_factory( pixels, camera_conversion=1.0, stage_positions_zxy=((0.0, 0.0, 0.0),) * 3 ) @@ -73,9 +169,11 @@ def roi_run(tmp_path: Path, acquisition_factory): expected_tiles=tuple( np.stack( [ - constant_deskew( - (8, 12, 14), - 10 + 20 * position + channel, + orthogonal_deskew( + np.maximum( + pixels[0, position, channel].astype(np.float32) - 100, 0 + ), + theta=30.0, distance=0.4, pixel_size=0.115, downsample_factor=2, @@ -86,9 +184,9 @@ def roi_run(tmp_path: Path, acquisition_factory): for position in (1, 2) ), overwritten_tile=np.broadcast_to( - constant_deskew( - (8, 12, 14), - 801, + orthogonal_deskew( + np.full((8, 12, 14), 801, dtype=np.float32), + theta=30.0, distance=0.4, pixel_size=0.115, downsample_factor=2, diff --git a/tests/fixtures/state.py b/tests/fixtures/state.py index b3d22c9..cc7a548 100644 --- a/tests/fixtures/state.py +++ b/tests/fixtures/state.py @@ -2,28 +2,65 @@ from __future__ import annotations +from types import SimpleNamespace from typing import TYPE_CHECKING +import numpy as np import pytest +from opm_processing.dataio.acquisition import inspect_acquisition +from opm_processing.dataio.position_collection import create_position_collection from opm_processing.dataio.processing_state import ProcessingState +@pytest.fixture +def partial_processed_run(tmp_path, live_acquisition_factory): + """Persist camera input, sentinel output pixels and a partial completion journal.""" + + def create(*, planar=False, deconvolve=False, completed=None, selection=None): + """Persist a controlled partial run and its durable completion journal.""" + shape = (2, 3, 1, 1 if planar else 3, 4, 5) + dataset = live_acquisition_factory(shape=shape) + destination = tmp_path / "processed" + label = "2d" if planar else "deskewed" + if deconvolve: + label = f"decon_{label}" + output = destination / f"sample_{label}.ome.zarr" + collection = create_position_collection(output, shape, (1, 0.115, 0.115)) + collection.arrays[0][0, 0].write( + np.full(collection.arrays[0].shape[-3:], 42, np.uint16) + ).result() + state = ProcessingState.create( + destination / "sample.processing.json", dataset.path + ) + state.initialize_run( + output, + configuration={"selection": selection or {"time": None, "position": None}}, + overwrite=True, + ) + for time, position in completed if completed is not None else [(0, 0), (0, 1)]: + state.complete_tile(output, time, position) + return SimpleNamespace( + acquisition=inspect_acquisition(dataset.path), + output_dir=destination, + output=output, + state=state, + ) + + return create + + +@pytest.fixture +def stored_file_bytes(): + """Capture current file bytes to verify a read-only state decision cannot modify saved data.""" + return lambda root: { + p.relative_to(root): p.read_bytes() for p in root.rglob("*") if p.is_file() + } + + @pytest.fixture def processing_state(tmp_path): - """Create an empty durable journal for isolated processing state transitions. - - Parameters - ---------- - tmp_path : pathlib.Path - Temporary directory for the source identity and durable journal. - - Returns - ------- - ProcessingState - Journal bound to a temporary sample source directory. Image workflow - integration fixtures persist their camera data separately. - """ + """Create an empty durable journal for isolated processing state transitions.""" source = tmp_path / "sample.ome.zarr" source.mkdir(exist_ok=True) return ProcessingState.create(tmp_path / "sample.processing.json", source) @@ -35,18 +72,7 @@ def processing_state(tmp_path): @pytest.fixture def record_registration(tmp_path): - """Return a writer for measured tile origins and completed processing state. - - Parameters - ---------- - tmp_path : pathlib.Path - Directory containing the acquisition, journal and derived image paths. - - Returns - ------- - callable - Writer persisting physical tile origins and completed ROI series. - """ + """Return a writer for measured tile origins and completed processing state.""" def record( processed_path: Path, @@ -54,22 +80,7 @@ def record( *, roi_series: tuple[dict[str, object], ...] = (), ) -> tuple[Path, ProcessingState]: - """Persist measured placement and completed series for fusion and ROI selection. - - Parameters - ---------- - processed_path : pathlib.Path - On-disk processed position collection. - tiles : list of dict - Known time/position indices and laboratory voxel origins in micrometers. - roi_series : tuple of dict - Optional camera geometry and per-series crop records. - - Returns - ------- - tuple - Registered projection path and durable processing state. - """ + """Persist measured placement and completed series for fusion and ROI selection.""" stem = processed_path.name.split("_", 1)[0] source = tmp_path / f"{stem}.ome.zarr" source.mkdir(exist_ok=True) diff --git a/tests/fixtures/storage.py b/tests/fixtures/storage.py index 938da90..0ccdf14 100644 --- a/tests/fixtures/storage.py +++ b/tests/fixtures/storage.py @@ -1,84 +1,24 @@ """Persist known objects as processed position collections or fused NGFF images.""" +from types import SimpleNamespace + import numpy as np import pytest +from skimage.measure import block_reduce from yaozarrs import v05 from yaozarrs.write.v05 import prepare_image from opm_processing.dataio.position_collection import create_position_collection -@pytest.fixture -def memory_store(tensorstore_dataset): - """Return zero-filled dataset mocks for numerical output buffers. - - Parameters - ---------- - tensorstore_dataset : callable - Shared TensorStore double factory with functional reads and writes. - - Returns - ------- - callable - Allocator for a mocked output buffer with the requested shape and dtype. - """ - - def create(shape, dtype=np.uint16): - """Allocate a zero-filled TensorStore dataset double. - - Parameters - ---------- - shape : tuple of int - Required image or output buffer dimensions. - dtype : numpy dtype - Element type of the numerical buffer. - - Returns - ------- - unittest.mock.MagicMock - Spec-constrained writable dataset with known backing pixels. - """ - return tensorstore_dataset(np.zeros(shape, dtype=dtype)) - - return create - - @pytest.fixture def position_store_factory(tmp_path): - """Return a writer for calibrated processed objects in TPCZYX order. - - Parameters - ---------- - tmp_path : pathlib.Path - Temporary directory containing each independent processed dataset. - - Returns - ------- - callable - Writer accepting known pixels, physical spacing and position metadata. - """ + """Return a writer for calibrated processed objects in TPCZYX order.""" def create( data, *, name="sample_deskewed", voxel_size_um=(1.0, 1.0, 1.0), **settings ): - """Persist each position with common spacing and optional NGFF settings. - - Parameters - ---------- - data : numpy.ndarray - Known processed fluorescence in TPCZYX order. - name : str - Dataset basename in the temporary test directory. - voxel_size_um : tuple of float - Laboratory voxel spacing in ZYX order. - **settings - Position collection settings, including channels and stage positions. - - Returns - ------- - PositionCollection - Written collection and its per-position arrays. - """ + """Persist each position with common spacing and optional NGFF settings.""" collection = create_position_collection( tmp_path / f"{name}.ome.zarr", data.shape, @@ -95,18 +35,7 @@ def create( @pytest.fixture def fused_store_factory(tmp_path): - """Return a writer for a known TCZYX object and its calibrated NGFF pyramid. - - Parameters - ---------- - tmp_path : pathlib.Path - Temporary directory containing saved fused images. - - Returns - ------- - callable - Writer accepting pixels, pyramid factors and calibrated voxel coordinates. - """ + """Return a writer for a known TCZYX object and its calibrated NGFF pyramid.""" def create( data, @@ -114,6 +43,8 @@ def create( name="sample_fused", spacing=(1.0, 1.0, 1.0), origin=(0.0, 0.0, 0.0), + level_origins=None, + downsample="stride", time_step_s=None, time_origin_s=0.0, factors=(1,), @@ -121,37 +52,18 @@ def create( omero=None, chunks=(1, 1, 1, 128, 128), ): - """Write a root image with explicit physical coordinates at each scale. - - Parameters - ---------- - data : numpy.ndarray - Known fluorescence in TCZYX order. - name : str - Dataset basename in the temporary test directory. - spacing, origin : tuple of float - Base voxel spacing and voxel-center origin in ZYX micrometers. - time_step_s : float or None - Timepoint interval in seconds; None leaves time units unspecified - so acquisition metadata provides the timing. - time_origin_s : float - First timepoint offset in seconds when time_step_s is provided. - factors : tuple of int - Absolute isotropic stride factors; one denotes the original pixels. - attributes : dict or None - Additional root NGFF attributes. - omero : yaozarrs.v05.Omero or None - Channel labels, colors and display limits stored with the image. - chunks : tuple of int - TCZYX chunk dimensions for the persisted image. - - Returns - ------- - pathlib.Path - Saved image path; callers reopen it through the real workflow. - """ + """Write a root image with explicit physical coordinates at each scale.""" path = tmp_path / f"{name}.ome.zarr" - levels = [data[..., ::factor, ::factor, ::factor] for factor in factors] + levels = [ + data[..., ::factor, ::factor, ::factor] + if downsample == "stride" + else block_reduce( + data, + block_size=(1, 1, factor, factor, factor), + func=np.mean, + ).astype(data.dtype) + for factor in factors + ] datasets = [ v05.Dataset( path=str(index), @@ -164,7 +76,15 @@ def create( ] ), v05.TranslationTransformation( - translation=[time_origin_s, 0, *origin] + translation=[ + time_origin_s, + 0, + *( + origin + if level_origins is None + else level_origins[index] + ), + ] ), ], ) @@ -202,3 +122,124 @@ def create( return path return create + + +def ptv_manifest(image_paths=("0",)): + """Return explicit synthetic processing provenance, without implied calibration.""" + return { + "schema_version": 1, + "dataset_id": "synthetic-processed", + "producer_version": "0.3.0", + "product_kind": "deconvolved_deskewed", + "processing_history": [{"operation": "synthetic_processed_fixture"}], + "registration": {"applied": False, "coordinate_frame": "stored_processed"}, + "calibration": {"gain_offset_applied": False, "uncertainty_validated": False}, + "images": [ + { + "path": name, + "source_indices": [0, 1, 3], + "volume_status_by_channel": [["valid", "invalid", "incomplete"]], + } + for name in image_paths + ], + } + + +@pytest.fixture +def ptv_collection(tmp_path): + """Allocate a tiny actual processed writer output; no numerical processing runs.""" + path = tmp_path / "processed.ome.zarr" + create_position_collection( + path, + (3, 1, 1, 7, 12, 14), + (0.23, 0.115, 0.15), + spatial_offset_um=(-2.1, 3.2, -4.3), + time_interval_s=0.085, + multiscale_factors_yx=(2,), + channels=["beads"], + attributes={ + "opm_processing": { + "timing": { + "schema_version": 1, + "n_acquired_planes": 17, + "dt_plane_s": 0.005, + "dt_volume_s": 0.085, + "source": "synthetic_trigger", + } + } + }, + ) + return path + + +@pytest.fixture +def processed_directory_factory(tmp_path): + """Create derived-store identities for source/output path resolution tests.""" + + def create(stem, *, labels=("deskewed", "fused", "max_z_deskewed", "max_z_fused")): + """Create only the requested processed-store directory structure.""" + output_dir = tmp_path / "processed" + output_dir.mkdir() + for label in labels: + create_position_collection( + output_dir / f"{stem}_{label}.ome.zarr", (1, 1, 1, 1, 1, 1), (1, 1, 1) + ) + return output_dir + + return create + + +@pytest.fixture(params=["stride", "block_mean"]) +def registered_projection_case( + request, tmp_path, fused_store_factory, simulated_camera +): + """Persist raw, registered and stale projection pyramids with distinct origins.""" + downsample = request.param + shape = (2, 2, 5, 7, 9) if downsample == "stride" else (2, 2, 6, 8, 10) + spacing = (0.81234, 0.51234, 0.25123) + origin = (4.51234, -3.01234, 12.25123) + origins = ( + origin, + tuple( + np.asarray(origin) + + (0.5 * np.asarray(spacing) if downsample == "block_mean" else 0) + ), + ) + registered = np.broadcast_to( + simulated_camera(shape[-3:], geometry="cartesian").raw, shape + ).copy() + paths = [ + fused_store_factory( + data, + name=name, + spacing=spacing, + level_origins=origins, + downsample=downsample, + factors=(1, 2), + chunks=(1, 1, 2, 4, 5), + ) + for name, data in ( + ("sample", np.zeros(shape, np.uint16)), + ("sample_fused", registered), + ("sample_max_z_fused", np.zeros((*shape[:2], 1, *shape[-2:]), np.uint16)), + ) + ] + from opm_processing.dataio.processing_state import ProcessingState + + processed_path = tmp_path / "sample_projection.ome.zarr" + state = ProcessingState.create(tmp_path / "sample.processing.json", paths[0]) + state.initialize_run(processed_path, configuration={}, overwrite=True) + state.save_registration(processed_path, configuration={}, pairwise_metrics={}) + state.complete_registration(processed_path, fused_path=paths[1], tiles=()) + return SimpleNamespace( + raw_path=paths[0], + output_path=paths[2], + processed_path=processed_path, + truth=registered, + downsample=downsample, + scales=tuple( + [1.0, 1.0, *(factor * value for value in spacing)] for factor in (1, 2) + ), + translations=tuple([0.0, 0.0, *origin] for origin in origins), + state_path=state.path, + ) diff --git a/tests/fixtures/tensorstore.py b/tests/fixtures/tensorstore.py deleted file mode 100644 index 3e689d7..0000000 --- a/tests/fixtures/tensorstore.py +++ /dev/null @@ -1,201 +0,0 @@ -"""TensorStore unit-test doubles backed by known NumPy or Zarr pixels.""" - -from concurrent.futures import Future -from types import SimpleNamespace -from unittest.mock import MagicMock - -import numpy as np -import pytest -import tensorstore as ts -import zarr - - -def completed_operation(operation): - """Execute an operation and return its value or error through a future. - - Parameters - ---------- - operation : callable - Read or write operation whose result must follow Future.result semantics. - - Returns - ------- - concurrent.futures.Future - Completed future retaining an operation's value or original exception. - """ - future = Future() - try: - future.set_result(operation()) - except Exception as error: - future.set_exception(error) - return future - - -@pytest.fixture -def tensorstore_dataset(): - """Return a spec-constrained TensorStore mock factory for known pixel arrays. - - Returns - ------- - callable - Factory providing shape, dtype, domain labels, chunks, sliced reads and - writes with completed futures. Writes update the backing pixels; reads - return independent arrays. Processing algorithms are never replaced. - """ - - def create(data, *, chunks=None, labels=None): - """Wrap known pixels with the TensorStore operations used by unit tests. - - Parameters - ---------- - data : numpy.ndarray or zarr.Array - Backing pixels; writes update this array. - chunks : tuple of int or None - Read chunk dimensions; omitted chunks span the array. - labels : sequence of str or None - Dimension labels; omitted dimensions are unlabeled. - - Returns - ------- - unittest.mock.MagicMock - TensorStore double constrained to the installed TensorStore interface. - """ - store = MagicMock(spec_set=ts.TensorStore) - store.shape = data.shape - store.rank = data.ndim - store.dtype = ts.dtype(data.dtype) - store.domain = SimpleNamespace(labels=tuple(labels or ("",) * data.ndim)) - store.chunk_layout = SimpleNamespace( - read_chunk=SimpleNamespace(shape=tuple(chunks or data.shape)) - ) - store.read.side_effect = lambda: completed_operation(lambda: np.array(data)) - store.write.side_effect = lambda value: completed_operation( - lambda: data.__setitem__(Ellipsis, value) - ) - - def select(selection): - """Return a readable selection whose writes update the parent pixels. - - Parameters - ---------- - selection : tuple, slice or int - NumPy-style dimensions selecting known pixels. - - Returns - ------- - unittest.mock.MagicMock - Selected TensorStore double sharing the parent write destination. - """ - # NumPy silently clips slice bounds; TensorStore rejects oversized - # requests. Keep that behavior so edge-chunk tests remain meaningful. - indices = selection if isinstance(selection, tuple) else (selection,) - axis = 0 - for index in indices: - if index is Ellipsis: - axis += data.ndim - len(indices) + 1 - elif index is not None: - if isinstance(index, slice) and ( - (index.start is not None and index.start > data.shape[axis]) - or (index.stop is not None and index.stop > data.shape[axis]) - ): - raise IndexError("Selection exceeds the TensorStore domain") - axis += 1 - selected = create(np.asarray(data[selection])) - selected.read.side_effect = lambda: completed_operation( - lambda: np.array(data[selection]) - ) - selected.write.side_effect = lambda value: completed_operation( - lambda: data.__setitem__(selection, value) - ) - return selected - - store.__getitem__.side_effect = select - return store - - return create - - -@pytest.fixture(autouse=True) -def mock_tensorstore_datasets(request, monkeypatch, tensorstore_dataset): - """Replace all TensorStore dataset entry points for isolated unit tests. - - Parameters - ---------- - request : pytest.FixtureRequest - Test category determining whether storage is mocked. - monkeypatch : pytest.MonkeyPatch - Restore real TensorStore entry points after the test. - tensorstore_dataset : callable - Shared pixel-backed, spec-constrained dataset double factory. - - Returns - ------- - None - Unit tests use NumPy/Zarr-backed mocks. Integration tests retain real - TensorStore reads and writes from acquisition files to saved outputs. - """ - if request.node.get_closest_marker("integration"): - return - - def open_dataset(spec): - """Open a controlled file-backed Zarr specification without TensorStore. - - Parameters - ---------- - spec : dict - File kvstore specification, optionally including creation schema. - - Returns - ------- - concurrent.futures.Future - Dataset double or the original Zarr opening error. - """ - - def open_array(): - """Create or reopen the fixture's Zarr pixels and wrap their metadata.""" - path = spec["kvstore"]["path"] - if spec.get("create"): - schema = spec["schema"] - layout = schema["chunk_layout"] - chunk_shape = layout.get("chunk", layout.get("write_chunk"))["shape"] - array = zarr.create_array( - path, - shape=tuple(schema["domain"]["shape"]), - dtype=schema["dtype"], - chunks=tuple(chunk_shape), - dimension_names=schema["domain"].get("labels"), - overwrite=spec["delete_existing"], - ) - else: - array = zarr.open_array(path, mode="r+") - return tensorstore_dataset( - array, chunks=array.chunks, labels=array.metadata.dimension_names - ) - - return completed_operation(open_array) - - def stack_datasets(arrays, *, axis): - """Stack known unit-test pixels and preserve their dimension labels. - - Parameters - ---------- - arrays : sequence - TensorStore doubles describing the per-position images. - axis : int - New unlabeled position dimension. - - Returns - ------- - unittest.mock.MagicMock - TPCZYX dataset double with the known stacked pixel values. - """ - labels = list(arrays[0].domain.labels) - labels.insert(axis, "") - return tensorstore_dataset( - np.stack([array.read().result() for array in arrays], axis=axis), - labels=labels, - ) - - monkeypatch.setattr(ts, "array", tensorstore_dataset) - monkeypatch.setattr(ts, "open", open_dataset) - monkeypatch.setattr(ts, "stack", stack_datasets) diff --git a/tests/fixtures/tiling.py b/tests/fixtures/tiling.py index 4663b68..1385a5b 100644 --- a/tests/fixtures/tiling.py +++ b/tests/fixtures/tiling.py @@ -2,522 +2,386 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING +from types import SimpleNamespace import numpy as np import pytest -from scipy import ndimage -from opm_processing.imageprocessing.opmtools import deskew_shape_estimator -from tests.fixtures.acquisition import opm_v2_frame_metadata, write_opm_v2_zarr -if TYPE_CHECKING: - from pathlib import Path +@pytest.fixture(params=[(0, 14), (8, 0), (8, 14)], ids=["x", "y", "yx"]) +def orthogonal_registration_pair(request, simulation_parameters): + """Known blurred beads observed through two overlapping oblique camera stacks.""" + from opm_processing.imageprocessing.opmtools import orthogonal_deskew + calibration = simulation_parameters["microscope"] + geometry = { + "theta": calibration["angle_deg"], + "distance": calibration["scan_step_um"], + "pixel_size": calibration["pixel_size_um"], + } + theta = np.deg2rad(geometry["theta"]) + shape = (20, 48, 48) + scan, row, x = np.indices(shape, dtype=float) + z = row * np.sin(theta) + y = scan * (geometry["distance"] / geometry["pixel_size"]) + row * np.cos(theta) + delta_y, delta_x = request.param + error = np.array([0, 2 * bool(delta_y), 2 * bool(delta_x)]) + origins = [np.zeros(3), np.array([0, delta_y, delta_x])] + images = [] + for offset in origins: + image = np.zeros(shape, dtype=float) + # Prescribed Gaussian optical bead images isolate registration from a + # diffraction-PSF accuracy claim. All positions are laboratory pixels. + for center, amplitude in [ + ((8, 40, 18), 1000), + ((15, 65, 32), 1800), + ((19, 53, 27), 1300), + ]: + radius = sum( + ((coordinate + shift - position) / width) ** 2 + for coordinate, shift, position, width in zip( + (z, y, x), offset, center, (2, 4, 3), strict=True + ) + ) + image += amplitude * np.exp(-radius / 2) + images.append( + orthogonal_deskew(image.astype(np.float32), **geometry, downsample_factor=1) + ) + support = ( + orthogonal_deskew(np.ones(shape, np.float32), **geometry, downsample_factor=1) + > 0 + ) + nominal = origins[1].astype(int) + error + left = tuple( + slice(max(0, d), min(n, n + d)) + for n, d in zip(images[0].shape, nominal, strict=True) + ) + right = tuple( + slice(max(0, -d), min(n, n - d)) + for n, d in zip(images[0].shape, nominal, strict=True) + ) + return SimpleNamespace( + fixed=images[0][left], + moving=images[1][right], + masks=(support[left], support[right]), + expected=-error, + axis=int(np.argmax(nominal / np.array(images[0].shape))), + ) -@dataclass(frozen=True) -class OpmV2TiledGroundTruthFixture: - """A blurred multi-tile acquisition rendered from a known 3D object. - - Attributes - ---------- - path : pathlib.Path - Persisted upstream acquisition. - configuration, mode : str - Spatial scenario and mirror or stage scan mode. - ground_truth : numpy.ndarray - Unblurred laboratory ZYX fluorescence. - raw_data : numpy.ndarray - Quantized TPCZYX photon measurements, including stage excess planes. - true_stage_positions_zxy, recorded_stage_positions_zxy : numpy.ndarray - Physical and deliberately perturbed stage positions in micrometers. - tile_offsets_zyx_px : tuple - True laboratory origins in pixels. - recorded_position_errors_zyx_px : numpy.ndarray - Known placement errors for registration to recover. - ellipsoids_zyx_radii : tuple - Shell centers and radii in laboratory pixels. - pixel_size_um, scan_axis_step_um : float - Detector pitch and physical scan increment. - psf_path : pathlib.Path - Unit-sum skewed PSF saved for reconstruction. - """ - - path: Path - configuration: str - mode: str - ground_truth: np.ndarray - raw_data: np.ndarray - true_stage_positions_zxy: np.ndarray - recorded_stage_positions_zxy: np.ndarray - tile_offsets_zyx_px: tuple[tuple[int, int, int], ...] - recorded_position_errors_zyx_px: np.ndarray - ellipsoids_zyx_radii: tuple[tuple[float, float, float, float, float, float], ...] - pixel_size_um: float - scan_axis_step_um: float - psf_path: Path - - -@dataclass(frozen=True) -class TiledAcquisitionConfig: - """Immutable spatial and acquisition configuration for a synthetic run. - - Parameters - ---------- - name : str - Spatial scenario used in dataset names. - mode : str - Mirror or stage scanning. - tile_offsets_zyx_px, recorded_position_errors_zyx_px : tuple - True laboratory origins and added recording errors in pixels. - camera_shape_zyx : tuple of int - Scan planes, detector rows and detector columns before excess planes. - pixel_size_um, scan_axis_step_um : float - Detector pitch and scan increment in micrometers. - theta_deg : float - Camera-plane tilt relative to the laboratory frame. - rng_seed : int - Seed for shell geometry, brightness and photon noise. - """ - - name: str - mode: str - tile_offsets_zyx_px: tuple[tuple[int, int, int], ...] - recorded_position_errors_zyx_px: tuple[tuple[int, int, int], ...] - camera_shape_zyx: tuple[int, int, int] = (16, 32, 32) - pixel_size_um: float = 0.115 - scan_axis_step_um: float = 0.4 - theta_deg: float = 30.0 - rng_seed: int = 2468 +@pytest.fixture +def deskew_field(request, simulation_parameters): + """Provide calibrated analytic fields and explicit oblique acquisition geometry.""" + kind, step_um, *shape = request.param + microscope = simulation_parameters["microscope"] + geometry = { + "theta": microscope["angle_deg"], + "distance": step_um, + "pixel_size": microscope["pixel_size_um"], + } + angle = np.deg2rad(geometry["theta"]) + step = step_um / geometry["pixel_size"] + scan, row, x = np.indices(shape[0] if shape else (24, 33, 9), dtype=float) + if kind == "constant": + source = np.full(scan.shape, 0.24) + elif kind == "x": + source = 10 + 2 * x + elif kind == "y": + source = 100 + scan * step + row * np.cos(angle) + elif kind == "z": + source = 100 + row * np.sin(angle) + elif kind == "affine": + source = 100 + 2 * x + scan * step + row * (np.cos(angle) + np.sin(angle)) + else: + source = np.random.default_rng(31).uniform(0.1, 500, scan.shape) + # A strided source also exercises camera-X addressing across output tiles. + storage = np.zeros((*source.shape[:2], 2 * source.shape[-1]), np.float32) + storage[..., ::2] = source + return SimpleNamespace( + data=storage[..., ::2], kind=kind, geometry=geometry, step=step + ) -def tiled_acquisition_config( - name: str, *, mode: str = "mirror" -) -> TiledAcquisitionConfig: - """Build a known spatial scenario with prescribed recording errors. - Parameters - ---------- - name : str - ``x_overlap``, ``yx_grid``, ``z_staggered``, ``thin_z_staggered`` or - ``yx_grid_z_staggered``. - mode : str - Mirror or stage scanning. +@pytest.fixture +def deskew_analytic_truth(deskew_field): + """Evaluate affine fluorescence and fully sampled Z-bin interiors analytically. Returns ------- - TiledAcquisitionConfig - Geometry and repeatable acquisition settings for the named scenario. + callable + Compute known laboratory fluorescence on an output grid, including the + 30-degree photon-volume Jacobian and the mean position of each Z bin. """ - configurations = { - "x_overlap": ( - ((0, 0, 0), (0, 0, 14)), - ((0, 0, 0), (0, 0, 2)), - ), - "yx_grid": ( - ((0, 0, 0), (0, 0, 14), (0, 8, 0), (0, 8, 14)), - ((0, 0, 0), (0, 0, 2), (0, 2, 0), (0, 2, 2)), - ), - "z_staggered": ( - ((0, 0, 0), (4, 0, 0), (8, 0, 0)), - ((0, 0, 0), (1, 0, 0), (1, 0, 0)), - ), - "thin_z_staggered": ( - # Explicit Y moves keep the thin overlap's oblique support aligned. - # These lab origins previously came from the erroneous Z-to-Y shear. - ((0, 0, 0), (11, 19, 0), (22, 38, 0)), - ((0, 0, 0), (0, 0, 2), (0, 0, 2)), - ), - "yx_grid_z_staggered": ( - ((0, 0, 0), (2, 0, 14), (4, 8, 0), (6, 8, 14)), - ((0, 0, 0), (0, 0, 0), (0, 0, 0), (0, 0, 0)), - ), - } - offsets, errors = configurations[name] - return TiledAcquisitionConfig( - name=name, - mode=mode, - tile_offsets_zyx_px=offsets, - recorded_position_errors_zyx_px=errors, - ) - + case = deskew_field + + def evaluate(shape, *, factor=1, reverse=False): + """Return analytic values and the region whose interpolation stencil is supported.""" + z, y, x = np.indices(shape, dtype=float) + first = (shape[0] - 1 - z if reverse else z) * factor + last = first + factor - 1 + mean_z = (first + last) / 2 + theta = np.deg2rad(case.geometry["theta"]) + guard = case.step * np.cos(theta) + 2 + interior = ( + ((y - last / np.tan(theta)) / case.step > 2) + & ((y - first / np.tan(theta)) / case.step < case.data.shape[0] - 3) + & (first / np.sin(theta) > guard) + & (last / np.sin(theta) < case.data.shape[1] - guard - 1) + & (x < case.data.shape[2]) + ) + fields = { + "constant": 0.24, + "x": 10 + 2 * x, + "y": 100 + y, + "z": 100 + mean_z, + "affine": 100 + 2 * x + y + mean_z, + } + expected = np.broadcast_to( + np.asarray(fields[case.kind]) * (2 / case.step), shape + ) + return expected, interior -def create_opm_v2_tiled_ground_truth_zarr( - tmp_path: Path, - *, - config: TiledAcquisitionConfig, -) -> OpmV2TiledGroundTruthFixture: - """Render an overlapping tiled OPM-v2 acquisition from a known object. + return evaluate - The forward model samples a laboratory-frame ground truth on the tilted - camera planes used by OPM and convolves each skewed stack with the same - compact PSF supplied to processing. - Parameters - ---------- - tmp_path : pathlib.Path - Isolated directory receiving the acquisition and PSF. - config : TiledAcquisitionConfig - Physical sampling, true tile origins and known placement errors. +@pytest.fixture +def deskew_roundoff(factor): + """Bound two float32 weighted sums and their final intensity multiplication. - Returns - ------- - OpmV2TiledGroundTruthFixture - Persisted camera measurements and independent laboratory truth. + Each Z bin has at most four positive interpolation terms per source plane, + followed by division. Comparing two evaluations gives 2e/(1-2e), where + e=(4*factor+2)*eps; unsupported zeros remain exact. """ - mode = config.mode - path = tmp_path / f"opm_v2_{mode}_{config.name}.zarr" - pixel_size_um = config.pixel_size_um - scan_axis_step_um = config.scan_axis_step_um - theta_deg = config.theta_deg - camera_shape = config.camera_shape_zyx - true_stage_offsets = np.asarray(config.tile_offsets_zyx_px, dtype=np.float64) - # Stage translations are orthogonal; the camera-plane tilt is modeled - # below when sampling each tile, not by shearing its laboratory origin. - true_image_offsets = np.rint(true_stage_offsets).astype(np.int64) - excess_scan_positions = 1 if mode == "stage" else 0 - stored_scan_count = camera_shape[0] + excess_scan_positions + error = (4 * factor + 2) * np.finfo(np.float32).eps + return {"rtol": 2 * error / (1 - 2 * error), "atol": 0} + + +@pytest.fixture +def deskew_output(deskew_field, factor): + """Provide a dirty strided output view with independently calculated dimensions.""" + data, geometry = deskew_field.data, deskew_field.geometry + angle = np.deg2rad(geometry["theta"]) + nz = int(np.ceil(data.shape[1] * np.sin(angle))) + ny = int(np.ceil(data.shape[0] * deskew_field.step + data.shape[1] * np.cos(angle))) shape = ( - 1, - len(config.tile_offsets_zyx_px), - 1, - stored_scan_count, - camera_shape[1], - camera_shape[2], + (nz + factor - 1) // factor, + ((ny + 3) // 4) * 4, + ((data.shape[2] + 3) // 4) * 4, ) - tile_zyx, _, _, _ = deskew_shape_estimator( - camera_shape, - theta=theta_deg, - distance=scan_axis_step_um, - pixel_size=pixel_size_um, - crop_after_deskew=False, + storage = np.full((2 * shape[0], shape[1] + 2, shape[2] + 2), np.nan, np.float32) + return storage, storage[::2, 1:-1, 1:-1] + + +@pytest.fixture +def deskew_mosaic(deskew_field, factor): + """Provide independent coverage and fluorescence for overlapping oblique tiles.""" + from opm_processing.imageprocessing.opmtools import orthogonal_deskew + + data = deskew_field.data + gain = 2 / deskew_field.step + response = orthogonal_deskew( + np.full(data.shape, 1, dtype=np.float32), + **deskew_field.geometry, + downsample_factor=factor, ) - max_offsets = np.max(true_image_offsets, axis=0) - ground_truth_shape = tuple( - int(tile_size + offset) - for tile_size, offset in zip(tile_zyx, max_offsets, strict=False) + support = response[..., 0] / gain + positions = ((0, 0, 0), (0, 13, 3)) + shape = (response.shape[0], response.shape[1] + 13, response.shape[2] + 3) + covered = np.zeros(shape, bool) + for _z, y, x in positions: + covered[:, y : y + response.shape[1], x : x + data.shape[2]] |= ( + support[..., None] > 0 + ) + return SimpleNamespace( + data=data, + geometry=deskew_field.geometry, + support=support, + positions=positions, + shape=shape, + x_profile=np.r_[ + np.ones(data.shape[2], np.float32), + np.zeros(response.shape[2] - data.shape[2], np.float32), + ], + expected=covered.astype(np.float32) * (0.24 * gain), ) - rng = np.random.default_rng(config.rng_seed) - ground_truth = np.zeros(ground_truth_shape, dtype=np.float32) - ellipsoids = [] - ellipsoid_amplitudes = [] - for z_index, center_z in enumerate( - np.arange(4.0, ground_truth_shape[0] - 2.0, 5.0) - ): - for y_index, center_y in enumerate( - np.arange(12.0, ground_truth_shape[1] - 5.0, 18.0) - ): - for x_index, center_x in enumerate( - np.arange(7.0, ground_truth_shape[2] - 4.0, 11.0) - ): - if (z_index + y_index + x_index) % 2 == 0: - ellipsoids.append( - ( - center_z + rng.uniform(-0.6, 0.6), - center_y + rng.uniform(-1.2, 1.2), - center_x + rng.uniform(-1.0, 1.0), - 2.2 + rng.uniform(0.0, 0.8), - 4.8 + rng.uniform(0.0, 1.8), - 3.4 + rng.uniform(0.0, 1.4), - ) - ) - ellipsoid_amplitudes.append(rng.uniform(4500.0, 8500.0)) - ellipsoids_zyx_radii = tuple(ellipsoids) - zz, yy, xx = np.indices(ground_truth_shape, dtype=np.float32) - for amplitude, ( - center_z, - center_y, - center_x, - radius_z, - radius_y, - radius_x, - ) in zip(ellipsoid_amplitudes, ellipsoids_zyx_radii, strict=False): - elliptical_radius = np.sqrt( - ((zz - center_z) / radius_z) ** 2 - + ((yy - center_y) / radius_y) ** 2 - + ((xx - center_x) / radius_x) ** 2 + +@pytest.fixture +def deskew_processing_case(deskew_field, factor, mode, acquisition_factory): + """Provide raw camera data, physical ROI bounds and independent saved-pixel truth.""" + from opm_processing.dataio.acquisition import inspect_acquisition + from opm_processing.dataio.roi import PhysicalRoi + from opm_processing.imageprocessing.opmtools import orthogonal_deskew + + case = deskew_field + dataset = acquisition_factory( + (100 + case.data).astype(np.uint16)[None, None, None], + name="deskew_tail", + camera_conversion=1, + scan_axis_step_um=case.geometry["distance"], + pixel_size_um=case.geometry["pixel_size"], + angle_deg=case.geometry["theta"], + stage_positions_zxy=((0, 0, 0),), + ) + response = orthogonal_deskew( + np.full(case.data.shape, 1, dtype=np.float32), + **case.geometry, + downsample_factor=factor, + ) + ramp = np.pad(case.data[0, 0], (0, response.shape[-1] - case.data.shape[-1])) + expected = response * ramp + pixel = case.geometry["pixel_size"] + roi = None + origin = (0, 0, 0) + if mode == "roi": + y_end, x_end = response.shape[1] * pixel, case.data.shape[-1] * pixel + roi = PhysicalRoi( + bounds_yx_um=(2 * pixel, y_end, 2 * pixel, x_end), + source_path=dataset.path, + grid_origin_yx_um=(0, 0), + pixel_size_yx_um=(pixel, pixel), + position_indices=(0,), + tile_footprints=( + { + "time_index": 0, + "position_index": 0, + "origin_zyx_um": [0, 0, 0], + "bounds_yx_um": [0, y_end, 0, x_end], + }, + ), ) - ground_truth += amplitude * np.exp( - -0.5 * ((elliptical_radius - 1.0) / 0.13) ** 2 + expected = expected[:, 2:, 2 : case.data.shape[-1]] + origin = (0, round(2 * pixel, 3), round(2 * pixel, 3)) + if mode == "cropped": + angle = np.deg2rad(case.geometry["theta"]) + crop_y = int(np.ceil(case.data.shape[1] * np.cos(angle))) + raw_y = int( + np.ceil(case.data.shape[0] * case.step + case.data.shape[1] * np.cos(angle)) ) - ground_truth = np.clip(ground_truth, 0.0, 40_000.0) - - scan, camera_y, camera_x = np.indices(camera_shape, dtype=np.float32) - theta_rad = np.deg2rad(theta_deg) - sample_z = camera_y * np.sin(theta_rad) - sample_y = scan * (scan_axis_step_um / pixel_size_um) + camera_y * np.cos(theta_rad) - psf_z, psf_y, psf_x = np.mgrid[-2:3, -3:4, -3:4] - psf = np.exp( - -(psf_z**2 / 1.0**2 + psf_y**2 / 1.6**2 + psf_x**2 / 1.6**2) / 2 - ).astype(np.float32) - psf /= psf.sum() - psf_path = tmp_path / f"{mode}_synthetic_psf.npy" - np.save(psf_path, psf) + core = expected[:, crop_y : raw_y - crop_y] + expected = np.pad(core, ((0, 0), (0, (-core.shape[1]) % 4), (0, 0))) + origin = (0, round(crop_y * pixel, 3), 0) + return SimpleNamespace( + dataset=dataset, + acquisition=inspect_acquisition(dataset.path), + roi=roi, + crop=mode == "cropped", + expected=expected, + origin=origin, + path=dataset.path.parent / "deskew_tail_deskewed.ome.zarr", + ) - raw_data = np.zeros(shape, dtype=np.uint16) - for position, (tile_z_offset, tile_y_offset, tile_x_offset) in enumerate( - true_image_offsets - ): - tile_sample_z = sample_z + tile_z_offset - tile_sample_y = sample_y + tile_y_offset - sample_x = camera_x + tile_x_offset - ideal_skewed = ndimage.map_coordinates( - ground_truth, - (tile_sample_z, tile_sample_y, sample_x), - order=1, - mode="constant", - cval=0.0, - prefilter=False, - ) - blurred = ndimage.convolve(ideal_skewed, psf, mode="constant", cval=0.0) - blurred = rng.poisson(np.clip(blurred, 0.0, None)).astype(np.uint16) - if mode == "stage": - # process_skewed reverses stage scans and then removes the leading - # excess plane. Arrange stored data so that operation recovers the - # same physical scan order as the mirror acquisition. - post_flip = np.concatenate((np.zeros_like(blurred[:1]), blurred), axis=0) - blurred = post_flip[::-1] - raw_data[0, position, 0] = blurred - recorded_errors = np.asarray( - config.recorded_position_errors_zyx_px, dtype=np.float64 +@pytest.fixture +def stage_placement_acquisition( + tmp_path, + angle, + mode, + simulation_parameters, + microscope_parameters, + independent_photon_scene_factory, +): + """Image one fixed point through the shared physical oblique camera model.""" + from dataclasses import replace + + from tests.fixtures.acquisition import opm_v2_frame_metadata, write_opm_v2_zarr + from tests.simulation.forward_model import Acquisition, forward_model + from tests.simulation.objects import deposit_point + + pixel_um = simulation_parameters["microscope"]["pixel_size_um"] + step_um = 0.23 + scans, camera_height, camera_width = 40, 64, 48 + theta = np.deg2rad(angle) + bead_zyx_um = np.asarray((20, 64, 24)) * pixel_um + stage_z_values_um = -np.asarray((0, 6, 12)) * pixel_um + microscope = replace(microscope_parameters, scan_step_um=step_um, angle_deg=angle) + scene = independent_photon_scene_factory() + density = np.zeros_like(scene.density) + deposit_point(density, scene.axes, (0, 0, 0), 1.0) + scan_axis = np.arange(scans) * step_um + if mode == "stage": + scan_axis = scan_axis[::-1].copy() + column_axis = np.arange(camera_width) * pixel_um - bead_zyx_um[2] + # Sensor coordinates refer to the same fixed object for every stage depth. + nominal_axes = ( + scan_axis - bead_zyx_um[1] + bead_zyx_um[0] / np.tan(theta), + np.arange(camera_height) * pixel_um - bead_zyx_um[0] / np.sin(theta), + column_axis, ) - true_stage_positions_zxy = true_stage_offsets * pixel_size_um - recorded_stage_positions_zxy = ( - true_stage_offsets + recorded_errors - ) * pixel_size_um - # Physical specimen-stage Z motion moves the sampled laboratory plane in - # the opposite direction. Fusion performs this stage-to-lab transform. - true_stage_positions_zxy[:, 0] *= -1 - recorded_stage_positions_zxy[:, 0] *= -1 - # OPM stage Y and image-placement Y point in opposite directions. - true_stage_positions_zxy[:, 1] *= -1 - recorded_stage_positions_zxy[:, 1] *= -1 - frame_metadatas = [] - for position in range(shape[1]): - for stored_scan in range(shape[3]): - daq_metadata = { - "mode": mode, - "channel_states": [True], - "exposure_channels_ms": [10.0], - "laser_powers": [10.0], - "interleaved": True, - "blanking": True, - "current_channel": "488nm", - } - opm_metadata = { - "angle_deg": theta_deg, - "camera_Zstage_orientation": "normal", - "camera_XYstage_orientation": "normal", - "camera_mirror_orientation": "normal", - } - stage_x = recorded_stage_positions_zxy[position, 1] - if mode == "mirror": - daq_metadata.update( - { - "image_mirror_position": float(stored_scan), - "image_mirror_range_um": (camera_shape[0] * scan_axis_step_um), - "image_mirror_step_um": scan_axis_step_um, - } - ) + density = forward_model( + density, + Acquisition( + scene.axes, + sensor_axes=nominal_axes, + camera_photon_budget=tuple(simulation_parameters["photon_budgets"][0]), + output="object", + ), + microscope, + ) + raw = np.zeros((1, 3, 1, scans, camera_height, camera_width), dtype=np.uint16) + frames = [] + for position, stage_z_um in enumerate(stage_z_values_um): + sensor_axes = ( + nominal_axes[0] + stage_z_um / np.tan(theta), + nominal_axes[1] - stage_z_um / np.sin(theta), + column_axis, + ) + raw[0, position, 0] = forward_model( + density, + Acquisition(scene.axes, sensor_axes=sensor_axes, seed=position), + microscope, + ) + for scan in range(scans): + if mode == "stage": + # Forward acquisition: the stationary plane sees the specimen + # move from negative scan-axis displacement back to zero. + stage_scan_um = (scan - (scans - 1)) * step_um else: - daq_metadata["scan_axis_step_um"] = scan_axis_step_um - opm_metadata.update( - { - "excess_scan_positions": excess_scan_positions, - "excess_scan_start_positions": excess_scan_positions, - "excess_scan_end_positions": excess_scan_positions, - } - ) - stage_x += stored_scan * scan_axis_step_um - - frame_metadatas.append( + stage_scan_um = 0.0 + frames.append( opm_v2_frame_metadata( - index={ - "t": 0, - "p": position, - "c": 0, - "z": stored_scan, + index={"t": 0, "p": position, "c": 0, "z": scan}, + daq_metadata={ + "mode": mode, + "channel_states": [True], + "exposure_channels_ms": [10.0], + "laser_powers": [10.0], + "interleaved": True, + "blanking": True, + "current_channel": "488nm", + "scan_axis_step_um": step_um, + "image_mirror_position": float(scan), + "image_mirror_step_um": step_um, + "image_mirror_range_um": scans * step_um, + }, + opm_metadata={ + "angle_deg": angle, + "camera_Zstage_orientation": "normal", + "camera_XYstage_orientation": "normal", + "camera_mirror_orientation": "normal", }, - daq_metadata=daq_metadata, - opm_metadata=opm_metadata, stage_metadata={ - "x_pos": stage_x, - "y_pos": recorded_stage_positions_zxy[position, 2], - "z_pos": recorded_stage_positions_zxy[position, 0], - "excess_image": ( - mode == "stage" and stored_scan < excess_scan_positions - ), + "x_pos": stage_scan_um, + "y_pos": 0.0, + "z_pos": stage_z_um, + "excess_image": False, }, - camera_shape_yx=camera_shape[1:], - pixel_size_um=pixel_size_um, - camera_offset=0.0, - camera_conversion=1.0, - runner_time_ms=float(position * shape[3] + stored_scan), + camera_shape_yx=(camera_height, camera_width), + pixel_size_um=pixel_um, + camera_offset=microscope.camera.offset_adu, + camera_conversion=microscope.camera.electrons_per_adu, + runner_time_ms=float(position * scans + scan) + * simulation_parameters["pipeline"]["plane_period_s"] + * 1000, hardware_triggered=True, ) ) - + path = tmp_path / "physical_stage.zarr" write_opm_v2_zarr( path=path, - raw_data=raw_data, + raw_data=raw, labels=("t", "p", "c", "z", "y", "x"), - chunks=(1, 1, 1, 1, camera_shape[1], camera_shape[2]), - frame_metadatas=frame_metadatas, - ) - return OpmV2TiledGroundTruthFixture( - path=path, - configuration=config.name, - mode=mode, - ground_truth=ground_truth, - raw_data=raw_data, - true_stage_positions_zxy=true_stage_positions_zxy, - recorded_stage_positions_zxy=recorded_stage_positions_zxy, - tile_offsets_zyx_px=tuple( - tuple(int(value) for value in offset) for offset in true_image_offsets - ), - recorded_position_errors_zyx_px=recorded_errors, - ellipsoids_zyx_radii=ellipsoids_zyx_radii, - pixel_size_um=pixel_size_um, - scan_axis_step_um=scan_axis_step_um, - psf_path=psf_path, + chunks=(1, 1, 1, 1, camera_height, camera_width), + frame_metadatas=frames, ) - - -@pytest.fixture(params=("mirror", "stage"), ids=("mirror-tiled", "stage-tiled")) -def opm_v2_tiled_ground_truth_zarr(request, tmp_path) -> OpmV2TiledGroundTruthFixture: - """Create two physically overlapping tiles in each scan mode. - - Parameters - ---------- - request : pytest.FixtureRequest - Parametrized mirror or stage scan mode. - tmp_path : pathlib.Path - Isolated acquisition directory. - - Returns - ------- - OpmV2TiledGroundTruthFixture - Known object, camera measurements and perturbed stage coordinates. - """ - return create_opm_v2_tiled_ground_truth_zarr( - tmp_path, - config=tiled_acquisition_config("x_overlap", mode=str(request.param)), - ) - - -@pytest.fixture( - params=( - ("yx_grid", "mirror"), - ("z_staggered", "mirror"), - ("thin_z_staggered", "mirror"), - ("yx_grid_z_staggered", "mirror"), - ("yx_grid", "stage"), - ("z_staggered", "stage"), - ("thin_z_staggered", "stage"), - ("yx_grid_z_staggered", "stage"), - ), - ids=( - "yx-grid", - "z-staggered", - "thin-z-staggered", - "yx-grid-z-staggered", - "stage-yx-grid", - "stage-z-staggered", - "stage-thin-z-staggered", - "stage-yx-grid-z-staggered", - ), -) -def opm_v2_spatial_tiling_ground_truth_zarr( - request, - tmp_path, -) -> OpmV2TiledGroundTruthFixture: - """Create each multi-axis spatial scenario in both scan modes. - - Parameters - ---------- - request : pytest.FixtureRequest - Spatial scenario and scan mode selected by parametrization. - tmp_path : pathlib.Path - Isolated acquisition directory. - - Returns - ------- - OpmV2TiledGroundTruthFixture - Known object, camera measurements and perturbed stage coordinates. - """ - configuration, mode = request.param - return create_opm_v2_tiled_ground_truth_zarr( - tmp_path, - config=tiled_acquisition_config(configuration, mode=mode), - ) - - -@dataclass(frozen=True) -class ReconstructionTestConfig: - """Processing options and quantitative acceptance criteria. - - Parameters - ---------- - correlation_percentile : float - Truth intensity percentile defining meaningful fluorescence support. - minimum_correlation_samples : int - Required support samples for a correlation measurement. - minimum_tile_correlation, minimum_deskewed_correlation : float - Minimum truth correlation before and after deconvolution. - minimum_fused_correlation, minimum_overlap_correlation : float - Minimum truth correlation in the fused volume and tile overlaps. - minimum_line_width_samples : int - Required resolved shell profiles for width comparisons. - line_profile_half_window : int - Pixel radius of each shell profile. - blend_pixels_zyx : tuple of int - Fusion taper widths in laboratory pixels. - registration_downsample_zyx : tuple of int - Sampling factors used for pair registration. - maximum_registration_shift_zyx : tuple of int - Allowed residual shifts after stage placement, in laboratory pixels. - decon_scan_chunk_size : int - Scan-plane block size for chunked deconvolution. - """ - - correlation_percentile: float = 35.0 - minimum_correlation_samples: int = 100 - minimum_tile_correlation: float = 0.55 - minimum_deskewed_correlation: float = 0.45 - minimum_fused_correlation: float = 0.50 - minimum_overlap_correlation: float = 0.50 - minimum_line_width_samples: int = 4 - line_profile_half_window: int = 5 - blend_pixels_zyx: tuple[int, int, int] = (1, 4, 4) - # Preserve the resolved Z structure of these tiny deconvolved overlaps. - registration_downsample_zyx: tuple[int, int, int] = (1, 1, 1) - maximum_registration_shift_zyx: tuple[int, int, int] = (2, 2, 4) - decon_scan_chunk_size: int = 6 - - def fusion_options(self) -> dict[str, object]: - """Return registration, blending and storage settings for synthetic tiles. - - Returns - ------- - dict - Keyword arguments shared by full and deskew-only fusion runs. - """ - return { - "blend_pixels": self.blend_pixels_zyx, - "downsample_factors": self.registration_downsample_zyx, - "ssim_window": 3, - "threshold": 0.1, - "multiscale_factors": (2,), - "resolution_multiples": ((1, 1, 1), (2, 2, 2)), - "chunk_shape_yx": (16, 16), - "max_registration_shift_zyx": self.maximum_registration_shift_zyx, - } - - -@pytest.fixture -def reconstruction_config() -> ReconstructionTestConfig: - """Return one immutable configuration shared by all tiled cases.""" - return ReconstructionTestConfig() + return SimpleNamespace(path=path, pixel_um=pixel_um) diff --git a/tests/gpu/__init__.py b/tests/gpu/__init__.py index 06516e8..eb24202 100644 --- a/tests/gpu/__init__.py +++ b/tests/gpu/__init__.py @@ -1 +1 @@ -"""GPU unit and disk-to-disk integration tests.""" +"""GPU numerical and storage-boundary unit tests.""" diff --git a/tests/gpu/test_cli_video.py b/tests/gpu/test_cli_video.py new file mode 100644 index 0000000..d80b7d8 --- /dev/null +++ b/tests/gpu/test_cli_video.py @@ -0,0 +1,62 @@ +"""Real NVENC CLI round trips of projections from simulated camera acquisitions.""" + +import imageio_ffmpeg +import numpy as np +import pytest +import tifffile + +from opm_processing.encode_projections import find_sequences + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("nvenc_gpu")] + + +@pytest.mark.parametrize("roi_preview", [("mirror", 1, False)], indirect=True) +@pytest.mark.parametrize("codec", ["h264", "hevc"]) +@pytest.mark.parametrize("rate_control", ["cbr", "vbr"]) +def test_video_cli_preserves_simulated_timepoints_and_display_pixels( + roi_preview, codec, rate_control, invoke_cli +): + """Encode real TIFF frames and decode MP4s with the installed FFmpeg package.""" + root = roi_preview.dataset.path.parent + color = codec == "hevc" + fps = 7.5 if rate_control == "vbr" else None + invoke_cli( + "export-projections", + root, + depth_color=color, + workers=2, + video=codec == "h264" and rate_control == "cbr", + fps=fps, + ) + invoke_cli( + "encode-projections", + root, + codec=codec, + rate_control=rate_control, + fps=fps, + bitrate=8_000_000, + gpu=0, + ) + sequences = find_sequences(root / "projection_frames") + assert len(sequences) == roi_preview.dataset.metadata.shape[2] + for paths in sequences: + movie = paths[0].parent / (paths[0].stem.rsplit("_t", 1)[0] + ".mp4") + decoded = imageio_ffmpeg.read_frames(str(movie), pix_fmt="rgb24") + information = next(decoded) + with tifffile.TiffFile(paths[0]) as tif: + interval = tif.shaped_metadata[0]["volume_interval_ms"] + assert information["codec"] == codec + assert information["fps"] == pytest.approx(fps or 1000 / interval) + width, height = information["size"] + frames = list(decoded) + assert len(frames) == len(paths) == roi_preview.dataset.metadata.shape[0] + for frame, path in zip(frames, paths, strict=True): + expected = tifffile.imread(path) + if expected.ndim == 2: + expected = np.repeat(expected[..., None], 3, axis=-1) + image = np.frombuffer(frame, np.uint8).reshape(height, width, 3) + actual = image[: expected.shape[0], : expected.shape[1]] + # Video is a presentation copy. This bound concerns 8-bit display + # conversion/compression, not recovered photon counts or PSNR. + assert np.abs(actual.astype(float) - expected).mean() < 3 + assert not list(movie.parent.glob(".nvenc-*")) diff --git a/tests/gpu/test_deconvolution_experiments.py b/tests/gpu/test_deconvolution_experiments.py deleted file mode 100644 index f799ac0..0000000 --- a/tests/gpu/test_deconvolution_experiments.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Distribution and optical-kernel tests for deconvolution audit candidates.""" - -import math - -import numpy as np -import pytest - - -@pytest.mark.unit -def test_lookup_cdf_matches_exact_binomial_probabilities(experiment, cupy_gpu): - """Bound every CDF entry by independently summed integer binomial coefficients. - - Parameters - ---------- - experiment - Fixture providing the experimental operators. - cupy_gpu - Fixture providing CuPy on a working CUDA device. - """ - actual = cupy_gpu.asnumpy(experiment.binomial_split_cdf(256)) - for n in range(257): - cumulative = 0 - expected = [] - for k in range(n + 1): - cumulative += math.comb(n, k) - expected.append(cumulative / 2**n) - # A nonzero absolute tolerance would conceal lost low-probability tails. - np.testing.assert_allclose(actual[n, : n + 1], expected, rtol=2e-12, atol=0) - np.testing.assert_array_equal(actual[n, n:], 1) - assert np.all(np.diff(actual, axis=1) >= 0) - - -@pytest.mark.unit -@pytest.mark.parametrize("fractional", (False, True)) -@pytest.mark.parametrize("assign_fractional_remainder", (False, True)) -def test_lookup_splits_preserve_distribution_and_fallback( - experiment, cupy_gpu, fractional, assign_fractional_remainder -): - """Check counts, small-count probabilities, unbiased moments, and independence. - - Parameters - ---------- - experiment - Fixture providing the experimental operators. - cupy_gpu - Fixture providing CuPy on a working CUDA device. - fractional : bool - Add a known fractional count to exercise weighted residual assignment. - assign_fractional_remainder : bool - Reference residual placement or the undersampled weighted-count model. - """ - cp = cupy_gpu - levels = np.asarray((0, 1, 4, 15, 64, 128, 256, 257, 2048), np.float32) - if fractional: - levels += np.float32(0.24) - observed = cp.asarray(np.broadcast_to(levels[:, None], (len(levels), 200_000))) - table = experiment.binomial_split_cdf() - first = experiment.split_counts_lookup( - observed, - cp.random.default_rng(19), - table, - assign_fractional_remainder=assign_fractional_remainder, - ) - repeated = experiment.split_counts_lookup( - observed, - cp.random.default_rng(19), - table, - assign_fractional_remainder=assign_fractional_remainder, - ) - np.testing.assert_array_equal(cp.asnumpy(first), cp.asnumpy(repeated)) - second = observed - first - np.testing.assert_array_equal(cp.asnumpy(first + second), cp.asnumpy(observed)) - assert bool(cp.all(first >= 0)) and bool(cp.all(second >= 0)) - expected_variance = ( - np.floor(levels) + ((levels % 1) ** 2 if assign_fractional_remainder else 0) - ) / 4 - means = ( - (levels / 2, levels / 2) - if assign_fractional_remainder - else (np.floor(levels) / 2, levels - np.floor(levels) / 2) - ) - for half, mean in zip((first, second), means, strict=False): - # Six standard errors, plus float32 rounding at the largest count. - mean_error = np.abs(cp.asnumpy(half.mean(axis=1)) - mean) - assert np.all(mean_error <= 6 * np.sqrt(expected_variance / 200_000) + 2e-5) - np.testing.assert_allclose( - cp.asnumpy(half.var(axis=1)), expected_variance, rtol=0.02, atol=0.002 - ) - integer_split = np.rint(cp.asnumpy(first[2])).astype(int) - np.testing.assert_allclose( - np.bincount(integer_split, minlength=5) / integer_split.size, - np.asarray((1, 4, 6, 4, 1)) / 16, - atol=0.004, - ) - pixels = cp.asnumpy(first[3]) - assert abs(np.corrcoef(pixels[:-1], pixels[1:])[0, 1]) < 0.01 - rng = cp.random.default_rng(31) - a = cp.asnumpy(experiment.split_counts_lookup(observed[3], rng, table)) - b = cp.asnumpy(experiment.split_counts_lookup(observed[3], rng, table)) - assert abs(np.corrcoef(a, b)[0, 1]) < 0.01 - - -@pytest.mark.unit -def test_lookup_poisson_thinning_has_independent_halves(experiment, cupy_gpu): - """A Poisson count split must retain two independent half-rate Poisson processes. - - Parameters - ---------- - experiment - Fixture providing the experimental operators. - cupy_gpu - Fixture providing CuPy on a working CUDA device. - """ - cp = cupy_gpu - observed = cp.asarray( - np.random.default_rng(73).poisson(20, 400_000).astype(np.float32) - ) - first = cp.asnumpy( - experiment.split_counts_lookup( - observed, cp.random.default_rng(73), experiment.binomial_split_cdf(64) - ) - ) - second = cp.asnumpy(observed) - first - for half in (first, second): - assert abs(half.mean() - 10) < 0.03 - assert abs(half.var() - 10) < 0.12 - assert abs(np.corrcoef(first, second)[0, 1]) < 0.01 - - -@pytest.mark.unit -@pytest.mark.parametrize("shape", ((1, 7, 9), (5, 7, 9))) -def test_taper_retains_center_origin_and_normalized_optical_gain(experiment, shape): - """Check a known separable edge window, energy accounting, and unit DC response. - - Parameters - ---------- - experiment - Fixture providing the experimental operators. - shape : tuple of int - Planar or volumetric acquisition-grid PSF dimensions. - """ - psf = np.ones(shape, np.float32) - original = psf.copy() - sampling = (0.2, 0.115, 0.115) - widths = (0.2, 0.23, 0.115) - tapered, removed = experiment.taper_psf(psf, sampling, widths) - expected = np.ones(shape, np.float64) - for axis, (size, pitch, width) in enumerate( - zip(shape, sampling, widths, strict=True) - ): - if size == 1: - continue - profile = np.ones(size) - profile[0] = profile[-1] = 0 - if width == 2 * pitch: - profile[1] = profile[-2] = 0.5 - window_shape = [1, 1, 1] - window_shape[axis] = size - expected *= profile.reshape(window_shape) - assert removed == pytest.approx(1 - expected.sum() / psf.sum()) - np.testing.assert_allclose(tapered, expected / expected.sum(), rtol=1e-6) - np.testing.assert_array_equal(psf, original) - np.testing.assert_allclose(np.fft.fftn(tapered)[0, 0, 0], 1, atol=1e-7) - for axis in range(3): - coordinates = np.arange(shape[axis]) - (shape[axis] - 1) / 2 - marginal = tapered.sum(axis=tuple(i for i in range(3) if i != axis)) - assert abs(np.sum(coordinates * marginal)) < 1e-7 diff --git a/tests/gpu/test_encode_projections.py b/tests/gpu/test_encode_projections.py deleted file mode 100644 index 15d4cc2..0000000 --- a/tests/gpu/test_encode_projections.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Physical intensity and frame-preservation checks for NVENC projection movies.""" - -import imageio_ffmpeg -import numpy as np -import pytest -import tifffile - -from opm_processing.encode_projections import ( - encode_sequence, - find_sequences, -) - - -@pytest.mark.integration -def test_nvenc_round_trip_moving_gaussian(tmp_path): - """A translating Gaussian PSF and calibrated ramp retain order, geometry and intensity.""" - pytest.importorskip("PyNvVideoCodec") - y, x = np.mgrid[:255, :321] - originals, paths = [], [] - for t in range(8): - frame = np.rint( - 255 * np.exp(-((x - 80 - t * 15) ** 2 + (y - 100) ** 2) / (2 * 20**2)) - ).astype(np.uint8) - frame[210:230, 30:290] = np.linspace(0, 255, 260).astype(np.uint8) - path = tmp_path / f"psf_t{t:04d}.tiff" - tifffile.imwrite( - path, - frame, - metadata={"volume_interval_ms": 85.0, "display_pixel_size_um": 0.1}, - ) - originals.append(frame) - paths.append(path) - paths = find_sequences(tmp_path)[0] - output = encode_sequence(paths, tmp_path / "psf.mp4") - decoded = imageio_ffmpeg.read_frames( - str(output), pix_fmt="rgb24", output_params=["-vsync", "0"] - ) - metadata = next(decoded) - assert metadata["codec"] == "h264" - assert metadata["pix_fmt"].startswith("yuv420p") - assert metadata["fps"] == pytest.approx(200 / 17, abs=0.01) - import PyNvVideoCodec as nvc - - assert nvc.CreateDemuxer(filename=str(output)).FrameRate() == pytest.approx( - 200 / 17 - ) - assert metadata["size"] == (322, 256) - frames = list(decoded) - assert len(frames) == len(originals) - for raw, reference in zip(frames, originals, strict=False): - rgb = np.frombuffer(raw, np.uint8).reshape(256, 322, 3) - restored = rgb[:255, :321, 0].astype(float) - error = restored - reference - psnr = 10 * np.log10(255**2 / max(np.mean(error**2), 1e-12)) - assert psnr > 45 - assert abs(error.mean()) < 1 - contents = output.read_bytes() - assert contents.index(b"moov") < contents.index(b"mdat") - - -@pytest.mark.integration -def test_nvenc_rgb_calibration_round_trip(tmp_path): - """Encode on-disk color calibration objects and verify decoded output colors.""" - pytest.importorskip("PyNvVideoCodec") - rgb = np.zeros((256, 384, 3), dtype=np.uint8) - colors = [[200, 30, 40], [20, 180, 40], [40, 30, 210]] - paths = [] - for index, color in enumerate(colors): - rgb[:, index * 128 : (index + 1) * 128] = color - for t in range(3): - path = tmp_path / f"colors_t{t:04d}.tiff" - tifffile.imwrite( - path, rgb, photometric="rgb", metadata={"volume_interval_ms": 85} - ) - paths.append(path) - output = encode_sequence(paths, tmp_path / "colors.mp4") - decoded = imageio_ffmpeg.read_frames(str(output), pix_fmt="rgb24") - next(decoded) - frames = list(decoded) - assert len(frames) == 3 - for raw in frames: - frame = np.frombuffer(raw, np.uint8).reshape(256, 384, 3) - for index, color in enumerate(colors): - np.testing.assert_allclose(frame[128, index * 128 + 64], color, atol=4) diff --git a/tests/gpu/test_gpu_processing.py b/tests/gpu/test_gpu_processing.py index 99aa441..302dc10 100644 --- a/tests/gpu/test_gpu_processing.py +++ b/tests/gpu/test_gpu_processing.py @@ -1,7 +1,7 @@ """Correctness tests for the CUDA image-processing paths. -These tests use small synthetic microscopy volumes, but they are not smoke -tests: every test compares GPU output with an independent result or checks a +These unit tests use small synthetic microscopy volumes. Every test compares +GPU output with an independent result or checks a quantitative reconstruction/registration property. Set ``OPM_REQUIRE_GPU=1`` in a GPU CI job so an unavailable GPU stack fails the @@ -16,21 +16,44 @@ import pytest from scipy import ndimage +from tests.simulation.metrics import ( + predictive_deviance, +) -def _direct_circular_convolution(image: np.ndarray, kernel: np.ndarray) -> np.ndarray: - """Small, deliberately direct reference for FFT circular convolution.""" - result = np.zeros_like(image, dtype=np.float64) - for kernel_index in np.argwhere(kernel != 0): - index = tuple(int(value) for value in kernel_index) - result += float(kernel[index]) * np.roll( - image, - shift=index, - axis=(0, 1, 2), - ) - return result +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize("sampling", [(1, 1, 1), (1, 2, 2)]) +def test_orthogonal_camera_pairs_recover_known_stage_errors( + cupy_gpu, orthogonal_registration_pair, sampling +): + """Recover prescribed stage errors from independently formed oblique images.""" + from cucim.skimage.measure import block_reduce + + from opm_processing.imageprocessing.tilefusion import TileFusion + + pair = orthogonal_registration_pair + fixed, moving = ( + block_reduce(cupy_gpu.asarray(image), sampling, cupy_gpu.mean) + for image in (pair.fixed, pair.moving) + ) + masks = tuple( + block_reduce(cupy_gpu.asarray(mask), sampling, cupy_gpu.min) + for mask in pair.masks + ) + shift, score = TileFusion.register_and_score( + fixed, + moving, + 3, + max_shift=np.array((2, 4, 4)) / sampling, + registration_axis=pair.axis, + shift_sampling=sampling, + support_masks=masks, + ) + assert np.isfinite(score) + np.testing.assert_array_equal(np.rint(np.asarray(shift) * sampling), pair.expected) -@pytest.mark.unit def test_fft_convolution_matches_direct_reference(cupy_gpu): """Exercise cuFFT and validate all voxels against direct convolution.""" cp = cupy_gpu @@ -55,12 +78,16 @@ def test_fft_convolution_matches_direct_reference(cupy_gpu): expected_kernel[-1, 0, 0] = 2 / 8 expected_kernel[0, 1, 0] = 1 / 8 np.testing.assert_allclose(cp.asnumpy(padded_psf_gpu), expected_kernel) - expected = _direct_circular_convolution(image, expected_kernel) + expected = ndimage.convolve( + image.astype(np.float64), + expected_kernel, + mode="wrap", + origin=tuple(-(np.array(image.shape) // 2)), + ) np.testing.assert_allclose(cp.asnumpy(actual_gpu), expected, rtol=2e-5, atol=2e-5) rlgc.clear_rlgc_caches() -@pytest.mark.unit @pytest.mark.parametrize( "p_values,q_values", [([0, 1, 3, 20], [4, 0, 2, 1]), ([0, 0, 0], [0, 0, 0]), ([2, 2, 2], [9, 9, 9])], @@ -84,18 +111,9 @@ def test_kld_matches_independent_probability_definition(cupy_gpu, p_values, q_va np.testing.assert_array_equal(cp.asnumpy(q), q_values) -@pytest.mark.unit @pytest.mark.parametrize("applied_shift", ((1.0, -2.0, 3.0), (0.6, -2.3, 3.4))) def test_gpu_registration_recovers_known_3d_translation(cupy_gpu, applied_shift): - """Recover integer and fractional translations using cuCIM and CUDA SSIM. - - Parameters - ---------- - cupy_gpu - CuPy module verified against the available CUDA device. - applied_shift : tuple of float - Known moving-image translation in ZYX pixels. - """ + """Recover integer and fractional translations using cuCIM and CUDA SSIM.""" cp = cupy_gpu try: import cucim # noqa: F401 @@ -139,256 +157,6 @@ def test_gpu_registration_recovers_known_3d_translation(cupy_gpu, applied_shift) assert score > unregistered_score -@pytest.mark.unit -def test_rlgc_gpu_deconvolution_improves_synthetic_point_sample(cupy_gpu): - """End-to-end GPU deconvolution must improve recovery of point emitters.""" - del cupy_gpu # The fixture has already proved that device 0 executes CUDA. - rlgc = importlib.import_module("opm_processing.imageprocessing.rlgc") - rng = np.random.default_rng(31) - - zz, yy, xx = np.mgrid[-2:3, -3:4, -3:4] - psf = np.exp(-(zz**2 / 1.1**2 + yy**2 / 2.0**2 + xx**2 / 2.0**2) / 2) - psf = (psf / psf.sum()).astype(np.float32) - truth = np.zeros((9, 35, 37), dtype=np.float32) - truth[2, 9, 10] = 1800 - truth[4, 18, 27] = 2400 - truth[6, 26, 17] = 2100 - noiseless = ndimage.convolve(truth, psf, mode="reflect") - observed = rng.poisson(noiseless + 0.25).astype(np.float32) - - restored = rlgc.rlgc( - observed, - psf, - gpu_id=0, - rng_seed=17, - limit=0.02, - max_delta=0.005, - release_memory=False, - ) - - assert restored.shape == truth.shape - assert restored.dtype == np.float32 - assert np.isfinite(restored).all() - assert restored.min() >= -1e-4 - - def scale_invariant_rmse(candidate): - """Measure RMSE after fitting a scalar intensity correction.""" - scale = float(np.vdot(candidate, truth) / np.vdot(candidate, candidate)) - return float(np.sqrt(np.mean((candidate * scale - truth) ** 2))) - - assert scale_invariant_rmse(restored) < 0.85 * scale_invariant_rmse(observed) - # Shape recovery alone can conceal a factor-of-two photon gain. - np.testing.assert_allclose(restored.sum(), observed.sum(), rtol=0.1) - assert np.mean((restored - truth) ** 2) < np.mean((observed - truth) ** 2) - rlgc.clear_rlgc_caches(clear_memory_pool=True) - - -@pytest.mark.unit -def test_rlgc_preserves_stationary_intensity(cupy_gpu, monkeypatch): - """A balanced split of an exact constant prediction must have unit update.""" - rlgc = importlib.import_module("opm_processing.imageprocessing.rlgc") - - class BalancedSplit: - """Provide the exact half-count observations for this fixed-point case.""" - - def binomial(self, counts, p): - """Split even counts equally so both gradients are exactly zero.""" - return counts // 2 - - monkeypatch.setattr(rlgc.cp.random, "default_rng", lambda seed: BalancedSplit()) - observed = np.full((1, 8, 8), 32, dtype=np.float32) - restored = rlgc.rlgc(observed, np.ones((1, 1, 1), dtype=np.float32)) - - np.testing.assert_allclose(restored, observed, rtol=1e-6) - - -@pytest.mark.unit -@pytest.mark.parametrize("scan_planes", [1, 5]) -def test_rlgc_preserves_smooth_low_count_background(cupy_gpu, scan_planes): - """Local stopping must preserve resolved background instead of flat patches.""" - rlgc = importlib.import_module("opm_processing.imageprocessing.rlgc") - yy, xx = np.mgrid[:64, :96] - background = 1.5 + 0.3 * np.sin(2 * np.pi * xx / 96) - background += 0.15 * np.cos(2 * np.pi * yy / 64) - truth = np.broadcast_to(background, (scan_planes, 64, 96)).astype(np.float32) - zz, py, px = np.mgrid[-1:2, -5:6, -5:6] - psf = np.exp(-(zz**2 + (py**2 + px**2) / 2.5**2) / 2) - if scan_planes == 1: - psf = psf[1:2] - psf = (psf / psf.sum()).astype(np.float32) - observed = ndimage.convolve(truth, psf, mode="reflect") - - restored = rlgc.rlgc(observed, psf, max_delta=0.1) - - assert np.isfinite(restored).all() - assert restored.min() >= 0 - assert float(np.sqrt(np.mean((restored - truth) ** 2))) < 0.04 - assert np.mean(np.abs(restored - observed.mean()) < 1e-5) < 0.001 - np.testing.assert_allclose(restored.mean(), truth.mean(), rtol=0.01) - - -@pytest.mark.unit -def test_rlgc_fractional_split_is_unbiased_and_preserves_counts(cupy_gpu): - """Both halves must have equal expected signal, including sub-count pixels.""" - cp = cupy_gpu - rlgc = importlib.import_module("opm_processing.imageprocessing.rlgc") - levels = np.array([0, 0.24, 0.96, 1, 1.44, 2.88, 12.24], dtype=np.float32) - observed = cp.asarray(np.broadcast_to(levels[:, None], (len(levels), 200_000))) - split1 = rlgc._split_observed_counts(observed, cp.random.default_rng(73)) - split2 = observed - split1 - - assert bool(cp.all(split1 >= 0)) and bool(cp.all(split2 >= 0)) - np.testing.assert_allclose( - cp.asnumpy(split1 + split2), cp.asnumpy(observed), atol=1e-6 - ) - for split in (split1, split2): - np.testing.assert_allclose( - cp.asnumpy(split.mean(axis=1)), levels / 2, atol=0.01 - ) - # Independently assigned unit counts and the residual weighted count - # have variance sum(weight**2)/4. Deterministic halving must fail. - expected_variance = (np.floor(levels) + (levels % 1) ** 2) / 4 - np.testing.assert_allclose( - cp.asnumpy(split.var(axis=1)), expected_variance, rtol=0.02, atol=1e-5 - ) - - -@pytest.mark.unit -def test_reference_fractional_split_retains_remainders_in_complement(cupy_gpu): - """Verify the local reference's fractional-count mean and complementary data. - - Parameters - ---------- - cupy_gpu - Fixture providing CuPy on a working CUDA device. - """ - cp = cupy_gpu - solver = importlib.import_module("opm_processing.imageprocessing.rlgc") - levels = np.asarray((0.24, 0.96, 1.44, 2.88, 12.24), np.float32) - observed = cp.asarray(np.broadcast_to(levels[:, None], (len(levels), 200_000))) - first = solver._split_observed_counts( - observed, cp.random.default_rng(73), assign_fractional_remainder=False - ) - second = observed - first - np.testing.assert_array_equal(cp.asnumpy(first + second), cp.asnumpy(observed)) - np.testing.assert_array_equal(cp.asnumpy(first), cp.asnumpy(cp.floor(first))) - np.testing.assert_allclose( - cp.asnumpy(first.mean(axis=1)), np.floor(levels) / 2, atol=0.01 - ) - np.testing.assert_allclose( - cp.asnumpy(second.mean(axis=1)), levels - np.floor(levels) / 2, atol=0.01 - ) - - -@pytest.mark.unit -def test_rlgc_integer_split_has_binomial_distribution(cupy_gpu): - """Four photons split into 0..4 counts with binomial probabilities.""" - cp = cupy_gpu - rlgc = importlib.import_module("opm_processing.imageprocessing.rlgc") - observed = cp.full(200_000, 4, dtype=cp.float32) - actual = cp.asnumpy( - rlgc._split_observed_counts(observed, cp.random.default_rng(19)) - ) - np.testing.assert_array_equal(actual, actual.astype(np.int64)) - np.testing.assert_allclose( - np.bincount(actual.astype(np.int64), minlength=5) / actual.size, - np.array([1, 4, 6, 4, 1]) / 16, - atol=0.004, - ) - - -@pytest.mark.unit -@pytest.mark.parametrize( - "read_noise_e", [0.7, 1.0, 1.6], ids=["ultra-quiet", "standard", "fast"] -) -def test_rlgc_recovers_dim_emitter_with_camera_noise(cupy_gpu, read_noise_e): - """Recover a known emitter across calibrated camera-noise realizations. - - Parameters - ---------- - cupy_gpu - Fixture requiring a working CUDA device. - read_noise_e : float - RMS electron read noise before ADC conversion. Three independent shot - and read-noise draws check individual object error, concentration and - absolute fluorescence, then the mean differential fluorescence. - """ - rlgc = importlib.import_module("opm_processing.imageprocessing.rlgc") - flux_ratios = [] - for seed in (7, 31, 83): - rng = np.random.default_rng(seed) - zz, yy, xx = np.mgrid[-3:4, -6:7, -6:7] - psf = np.exp( - -0.5 * ((zz / 1.2) ** 2 + ((yy + 2 * zz) / 2) ** 2 + (xx / 2) ** 2) - ) - psf = (psf / psf.sum()).astype(np.float32) - truth = np.zeros((9, 48, 64), dtype=np.float32) - truth[4, 24, 32] = 100 - # Hamamatsu C15440-20UP: 0.24 electrons/ADU, 100 ADU offset, - # 0.7/1.0/1.6 electrons RMS read noise across the three scan modes. - # See the manufacturer's - # ORCA-Fusion/ORCA-Fusion BT technical note, specifications, page 15: - # https://www.hamamatsu.com/content/dam/hamamatsu-photonics/sites/documents/99_SALES_LIBRARY/sys/SCAS0138E_C14440-20UP_tec.pdf - # Shot noise acts on electrons before ADC conversion. Multiplying a - # Poisson draw by 0.24 instead would incorrectly reduce its variance. - background_electrons = rng.poisson(1.5, truth.shape) - background_electrons = background_electrons + rng.normal( - 0, read_noise_e, truth.shape - ) - injected = rng.poisson(ndimage.convolve(truth, psf, mode="reflect")).astype( - np.float32 - ) - - def calibrate_camera(electrons): - """Digitize electron charge, then apply the pipeline's camera calibration.""" - adu = np.clip(np.rint(100 + electrons / 0.24), 0, 65535).astype(np.uint16) - return np.maximum((adu.astype(np.float32) - 100) * 0.24, 0) - - background = calibrate_camera(background_electrons) - observed = calibrate_camera(background_electrons + injected) - restored_background = rlgc.rlgc(background, psf, rng_seed=seed) - restored = rlgc.rlgc(observed, psf, rng_seed=seed) - response = restored - restored_background - - # Score the complete estimate against the expected object, not a sampled - # noise realization. Paired subtraction below cancels retained background - # grain and cannot establish whole-image reconstruction accuracy by itself. - expected_object = truth.astype(np.float64) + 1.5 - observed_mse = np.mean((observed - expected_object) ** 2) - restored_mse = np.mean((restored - expected_object) ** 2) - assert restored_mse < observed_mse, { - "restored_mse": restored_mse, - "observed_mse": observed_mse, - } - assert np.mean((restored_background.astype(np.float64) - 1.5) ** 2) < np.mean( - (background.astype(np.float64) - 1.5) ** 2 - ) - - # Paired background subtraction isolates the response to the known emitter. - # The core contains the emitter's true location, so useful deconvolution - # must move signal into it. A no-op cannot pass this strict improvement. - core = (slice(3, 6), slice(22, 27), slice(30, 35)) - # Compare concentration with the actually measured blurred signal. Keep - # the flux bound against the injected electron count, including any signal - # lost during camera calibration's clipping of negative measurements. - measured_signal = observed - background - core_gain = float(response[core].sum() / measured_signal[core].sum()) - flux_ratio = float(response.sum() / injected.sum()) - assert core_gain > 1.0, {"core_gain": core_gain, "flux_ratio": flux_ratio} - flux_ratios.append(flux_ratio) - np.testing.assert_allclose(restored.sum(), observed.sum(), rtol=0.01) - np.testing.assert_allclose( - restored_background.sum(), background.sum(), rtol=0.01 - ) - assert np.isfinite(restored).all() - assert restored.min() >= 0 - # Independently stopped nonlinear reconstructions need not conserve the - # flux of their difference for each draw. Check its expected response - # across realizations, without fitting a gain to any reconstruction. - np.testing.assert_allclose(np.mean(flux_ratios), 1.0, rtol=0.2) - - -@pytest.mark.unit def test_rlgc_empty_image_remains_empty(cupy_gpu): """Data-derived initialization must not introduce signal into an empty image.""" rlgc = importlib.import_module("opm_processing.imageprocessing.rlgc") @@ -398,7 +166,6 @@ def test_rlgc_empty_image_remains_empty(cupy_gpu): np.testing.assert_array_equal(restored, observed) -@pytest.mark.unit @pytest.mark.parametrize("singleton_z", [False, True]) def test_rlgc_2d_recovers_points_using_central_psf( cupy_gpu, planar_point_model, singleton_z @@ -414,49 +181,37 @@ def test_rlgc_2d_recovers_points_using_central_psf( if singleton_z: observed = observed[None] truth = truth[None] - restored = rlgc.rlgc_2d(observed, skewed_psf, limit=0.02, max_delta=0.005) + restored = rlgc.rlgc(observed, skewed_psf, limit=0.02, max_delta=0.005) assert restored.shape == truth.shape assert restored.dtype == np.float32 - assert np.mean((restored - truth) ** 2) < np.mean((observed - truth) ** 2) - np.testing.assert_allclose(restored.sum(), observed.sum(), rtol=0.1) + psf = central[None] if singleton_z else central + expectation = ndimage.convolve(truth, psf, mode="reflect") + 0.25 + assert predictive_deviance( + restored, expectation, psf, boundary="reflect" + ) < predictive_deviance(observed, expectation, psf, boundary="reflect") np.testing.assert_array_equal( np.unravel_index(np.argmax(restored), restored.shape), np.unravel_index(np.argmax(truth), truth.shape), ) -@pytest.mark.unit @pytest.mark.parametrize("scale", (0.01, 1, 1000)) -def test_same_split_kl_change_matches_direct_scores(cupy_gpu, scale): - """Check cancellation against independently normalized CPU KL scores. - - Parameters - ---------- - cupy_gpu - Fixture requiring a working CUDA device. - scale : float - Photon-rate scale covering fractional, ordinary and bright measurements. - """ +def test_split_kl_scores_match_direct_probability_definition(cupy_gpu, scale): + """Lock MATLAB's prediction-to-split direction, normalization and pseudocount.""" solver = importlib.import_module("opm_processing.imageprocessing.rlgc") rng = np.random.default_rng(51) - current = (rng.uniform(0, 30, (3, 5, 7)) * scale).astype(np.float32) - previous = (rng.uniform(0, 30, current.shape) * scale).astype(np.float32) - current[0] = 0 - previous[1] = 0 - first = rng.poisson(3, current.shape).astype(np.float32) - second = rng.poisson(3, current.shape).astype(np.float32) + prediction = (rng.uniform(0, 30, (3, 5, 7)) * scale).astype(np.float32) + prediction[0] = 0 + halves = [rng.poisson(3, prediction.shape).astype(np.float32) for _ in range(2)] + probability = prediction.astype(np.float64) + 1e-4 + probability /= probability.sum() expected = [] - for split in (first, second): - q = split.astype(np.float64) + 1e-4 - q /= q.sum() - scores = [] - for prediction in (current, previous): - p = prediction.astype(np.float64) + 1e-4 - p /= p.sum() - scores.append(np.sum(q * np.log(q / p))) - expected.append(scores[0] - scores[1]) - actual = solver.split_kl_changes( - *(cupy_gpu.asarray(value) for value in (current, previous, first, second)), - cupy_gpu.empty_like(cupy_gpu.asarray(current)), + for half in halves: + target = half.astype(np.float64) + 1e-4 + target /= target.sum() + expected.append(np.sum(probability * np.log(probability / target))) + actual = solver.split_kl_scores( + *(cupy_gpu.asarray(value) for value in (prediction, *halves)), + cupy_gpu.empty_like(cupy_gpu.asarray(prediction)), ) np.testing.assert_allclose(actual, expected, rtol=2e-6, atol=2e-7) diff --git a/tests/gpu/test_opm_v2_dataset.py b/tests/gpu/test_opm_v2_dataset.py deleted file mode 100644 index b335e14..0000000 --- a/tests/gpu/test_opm_v2_dataset.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Recover known planar emitters through disk-to-disk CUDA deconvolution.""" - -import numpy as np -import pytest - -from opm_processing.dataio.position_collection import ( - open_position_collection, -) -from opm_processing.process import process - - -@pytest.mark.integration -def test_planar_deconvolution_recovers_saved_emitters_in_every_series( - cupy_gpu, planar_camera_acquisition, tmp_path -): - """Recover photon flux and emitter positions through real multi-series processing.""" - case = planar_camera_acquisition - process( - case.dataset.path, - deconvolve=True, - decon_psf_paths=[case.psf_path, case.psf_path], - flatfield_correction=False, - save_float32=True, - create_fused_max_projection=False, - write_fused_max_projection_tiff=False, - ) - output = open_position_collection( - tmp_path / "planar_points_decon_projection.ome.zarr" - ) - for position, array in enumerate(output.arrays): - saved = array.read().result() - expected = case.truth[:, position] - measured = case.photons[:, position] - for time in range(2): - for channel in range(2): - recovered = saved[time, channel] - truth = expected[time, channel] - observed = measured[time, channel] - assert np.mean((recovered - truth) ** 2) < np.mean( - (observed - truth) ** 2 - ) - np.testing.assert_allclose(recovered.sum(), observed.sum(), rtol=0.1) - for row, column in ((12, 13), (28, 30)): - region = recovered[0, row - 2 : row + 3, column - 2 : column + 3] - assert np.unravel_index(np.argmax(region), region.shape) == (2, 2) diff --git a/tests/gpu/test_opm_v2_tiled_reconstruction.py b/tests/gpu/test_opm_v2_tiled_reconstruction.py deleted file mode 100644 index 0ca4e4f..0000000 --- a/tests/gpu/test_opm_v2_tiled_reconstruction.py +++ /dev/null @@ -1,432 +0,0 @@ -"""Ground-truth reconstruction test for tiled OPM-v2 acquisitions.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy as np -import pytest - -from opm_processing.dataio.position_collection import ( - open_image_array, - open_position_collection, -) -from opm_processing.dataio.processing_state import ( - ProcessingState, - processing_state_path, -) -from opm_processing.imageprocessing.tilefusion import TileFusion -from opm_processing.process import process -from tests.reference.metrics import masked_correlation, shell_line_width_x - -if TYPE_CHECKING: - from tests.fixtures.tiling import ReconstructionTestConfig - - -def _measure_correlation( - candidate: np.ndarray, - truth: np.ndarray, - config: ReconstructionTestConfig, - observation_mask=None, -) -> float: - """Measure masked correlation and enforce sufficient support.""" - measurement = masked_correlation( - candidate, - truth, - truth_percentile=config.correlation_percentile, - observation_mask=observation_mask, - ) - assert measurement.sample_count > config.minimum_correlation_samples - return measurement.value - - -def _measure_shell_width( - volume: np.ndarray, - center_zyx: tuple[float, float, float], - wall_x: float, - config: ReconstructionTestConfig, -) -> float: - """Measure the reconstructed hollow-ellipsoid shell width.""" - return shell_line_width_x( - volume, - center_zyx=center_zyx, - wall_x=wall_x, - half_window=config.line_profile_half_window, - ) - - -def _align_fused_to_ground_truth( - fused: np.ndarray, - truth: np.ndarray, - *, - offset_um: tuple[float, float, float], - pixel_size_um: tuple[float, float, float], -) -> np.ndarray: - """Place a translated fused volume in the synthetic world-coordinate grid. - - Parameters - ---------- - fused : np.ndarray - Fused volume whose voxel zero is at ``offset_um``. - truth : np.ndarray - Ground-truth volume whose voxel zero is the world-coordinate origin. - offset_um : tuple[float, float, float] - Fused image origin in ZYX physical coordinates. - pixel_size_um : tuple[float, float, float] - Fused voxel spacing in ZYX order. - - Returns - ------- - np.ndarray - Fused samples aligned to the ground-truth array coordinates. - """ - offset_pixels = np.rint( - np.asarray(offset_um, dtype=np.float64) - / np.asarray(pixel_size_um, dtype=np.float64) - ).astype(np.int64) - fused_start = np.maximum(-offset_pixels, 0) - truth_start = np.maximum(offset_pixels, 0) - common_shape = np.minimum( - np.asarray(fused.shape, dtype=np.int64) - fused_start, - np.asarray(truth.shape, dtype=np.int64) - truth_start, - ) - if np.any(common_shape <= 0): - raise AssertionError( - "Fused output does not overlap the synthetic world-coordinate volume" - ) - - fused_slices = tuple( - slice(int(start), int(start + size)) - for start, size in zip(fused_start, common_shape, strict=False) - ) - truth_slices = tuple( - slice(int(start), int(start + size)) - for start, size in zip(truth_start, common_shape, strict=False) - ) - aligned = np.zeros_like(truth, dtype=fused.dtype) - aligned[truth_slices] = fused[fused_slices] - return aligned - - -def _tile_observation_mask(fixture, tile_shape): - """Select an observable interior using the physical inverse OPM transform.""" - z, y, x = np.indices(tile_shape, dtype=np.float64) - camera_y = z / np.sin(np.deg2rad(30)) - step = fixture.scan_axis_step_um / fixture.pixel_size_um - scan = (y - z / np.tan(np.deg2rad(30))) / step - scans, height, width = fixture.raw_data.shape[-3:] - scans -= int(fixture.mode == "stage") # Fixture's excess leading scan. - # Keep the interpolation neighborhood inside the acquisition; do not - # infer support from reconstructed intensity or discard lost features. - return ( - (scan >= 1) - & (scan < scans - 2) - & (camera_y >= step) - & (camera_y < height - 1 - step) - & (x < width) - ) - - -def _tile_observation_coverage( - world_shape: tuple[int, int, int], - tile_shape: tuple[int, int, int], - tile_offsets_zyx_px, - tile_support, -) -> np.ndarray: - """Count geometrically observable tiles at each ground-truth voxel.""" - coverage = np.zeros(world_shape, dtype=np.uint8) - for offset in tile_offsets_zyx_px: - slices = tuple( - slice(int(start), int(start) + int(length)) - for start, length in zip(offset, tile_shape, strict=False) - ) - coverage[slices] += tile_support - return coverage - - -def _assert_fused_overlap_matches_ground_truth( - fixture, - config: ReconstructionTestConfig, - fusion: TileFusion, -) -> tuple[np.ndarray, float]: - """Validate the registered fused pixels specifically inside tile overlaps.""" - fused_path = fixture.path.parent / f"{fixture.path.stem}_fused.ome.zarr" - fused = open_image_array(fused_path).read().result()[0, 0] - assert fusion.offset_um is not None - reconstructed = _align_fused_to_ground_truth( - fused, - fixture.ground_truth, - offset_um=fusion.offset_um, - pixel_size_um=fusion._pixel_size, - ) - tile_shape = tuple(int(value) for value in fusion.position_arrays[0].shape[-3:]) - overlap = ( - _tile_observation_coverage( - fixture.ground_truth.shape, - tile_shape, - fixture.tile_offsets_zyx_px, - _tile_observation_mask(fixture, tile_shape), - ) - > 1 - ) - overlap_correlation = _measure_correlation( - reconstructed[overlap], - fixture.ground_truth[overlap], - config, - ) - assert overlap_correlation > config.minimum_overlap_correlation, { - "overlap_correlation": overlap_correlation, - "pairwise_metrics": fusion.pairwise_metrics, - "global_offsets": fusion.global_offsets, - } - return reconstructed, overlap_correlation - - -def _assert_tiled_reconstruction( - fixture, - config: ReconstructionTestConfig, - cupy_gpu, - processing_options, -): - """Run and validate deskew, deconvolution, registration, and fusion.""" - del cupy_gpu - - process(root_path=fixture.path, deconvolve=False, **processing_options) - process( - root_path=fixture.path, - deconvolve=True, - decon_crop_scan=config.decon_scan_chunk_size, - decon_gpu_id=0, - decon_psf_paths=[fixture.psf_path], - **processing_options, - ) - - deskewed_path = fixture.path.parent / f"{fixture.path.stem}_deskewed.ome.zarr" - deconvolved_path = ( - fixture.path.parent / f"{fixture.path.stem}_decon_deskewed.ome.zarr" - ) - deskewed_collection = open_position_collection(deskewed_path) - deconvolved_collection = open_position_collection(deconvolved_path) - assert deskewed_collection.shape[:3] == ( - 1, - len(fixture.tile_offsets_zyx_px), - 1, - ) - assert deconvolved_collection.shape == deskewed_collection.shape - np.testing.assert_allclose( - deconvolved_collection.stage_positions_zxy, - fixture.recorded_stage_positions_zxy, - ) - - deskewed_scores = [] - deconvolved_scores = [] - truth_line_widths = [] - deskewed_line_widths = [] - deconvolved_line_widths = [] - tile_shape = deskewed_collection.shape[-3:] - tile_support = _tile_observation_mask(fixture, tile_shape) - for position, (z_offset, y_offset, x_offset) in enumerate( - fixture.tile_offsets_zyx_px - ): - truth_tile = fixture.ground_truth[ - z_offset : z_offset + tile_shape[0], - y_offset : y_offset + tile_shape[1], - x_offset : x_offset + tile_shape[2], - ] - deskewed = deskewed_collection.arrays[position][0, 0].read().result() - deconvolved = deconvolved_collection.arrays[position][0, 0].read().result() - deskewed_scores.append( - _measure_correlation(deskewed, truth_tile, config, tile_support) - ) - deconvolved_scores.append( - _measure_correlation(deconvolved, truth_tile, config, tile_support) - ) - for ( - center_z, - center_y, - center_x, - _, - _, - radius_x, - ) in fixture.ellipsoids_zyx_radii: - for wall_x in (center_x - radius_x, center_x + radius_x): - local_center_z = center_z - z_offset - local_center_y = center_y - y_offset - local_center_x = center_x - x_offset - local_wall_x = wall_x - x_offset - if ( - 0 <= round(local_center_z) < truth_tile.shape[0] - and 0 <= round(local_center_y) < truth_tile.shape[1] - and 5 <= local_wall_x < truth_tile.shape[2] - 5 - ): - center = (local_center_z, local_center_y, local_center_x) - widths = ( - _measure_shell_width(truth_tile, center, local_wall_x, config), - _measure_shell_width(deskewed, center, local_wall_x, config), - _measure_shell_width(deconvolved, center, local_wall_x, config), - ) - if np.all(np.isfinite(widths)): - truth_width, deskewed_width, deconvolved_width = widths - truth_line_widths.append(truth_width) - deskewed_line_widths.append(deskewed_width) - deconvolved_line_widths.append(deconvolved_width) - - assert min(deconvolved_scores) > config.minimum_tile_correlation - assert min(deskewed_scores) > config.minimum_deskewed_correlation - assert len(truth_line_widths) >= config.minimum_line_width_samples - truth_width = np.mean(truth_line_widths) - deskewed_width = np.mean(deskewed_line_widths) - deconvolved_width = np.mean(deconvolved_line_widths) - assert deconvolved_width < deskewed_width - assert abs(deconvolved_width - truth_width) < abs(deskewed_width - truth_width) - - fusion = TileFusion( - root_path=fixture.path, - **config.fusion_options(), - ) - fusion.run() - if fixture.configuration == "thin_z_staggered": - expected_x_corrections = -fixture.recorded_position_errors_zyx_px[:, 2] - np.testing.assert_allclose( - np.asarray(fusion.global_offsets)[:, 2], - expected_x_corrections, - atol=0.5, - ) - adjacency = { - tile_index: set() for tile_index in range(len(fixture.tile_offsets_zyx_px)) - } - for left, right in fusion.pairwise_metrics: - adjacency[left].add(right) - adjacency[right].add(left) - connected = {0} - frontier = [0] - while frontier: - current = frontier.pop() - unseen_neighbors = adjacency[current] - connected - connected.update(unseen_neighbors) - frontier.extend(unseen_neighbors) - assert connected == set(range(len(fixture.tile_offsets_zyx_px))), { - "connected_tiles": connected, - "pairwise_metrics": fusion.pairwise_metrics, - } - - reconstructed, overlap_correlation = _assert_fused_overlap_matches_ground_truth( - fixture, - config, - fusion, - ) - fused_correlation = _measure_correlation( - reconstructed, - fixture.ground_truth, - config, - _tile_observation_coverage( - fixture.ground_truth.shape, - tile_shape, - fixture.tile_offsets_zyx_px, - tile_support, - ) - > 0, - ) - assert fused_correlation > config.minimum_fused_correlation, { - "fused_correlation": fused_correlation, - "deskewed_correlations": deskewed_scores, - "deconvolved_correlations": deconvolved_scores, - "pairwise_metrics": fusion.pairwise_metrics, - "global_offsets": fusion.global_offsets, - "offset_um": fusion.offset_um, - "overlap_correlation": overlap_correlation, - } - fused_line_widths = [] - for center_z, center_y, center_x, _, _, radius_x in fixture.ellipsoids_zyx_radii: - for wall_x in (center_x - radius_x, center_x + radius_x): - if 5 <= wall_x < reconstructed.shape[2] - 5: - width = _measure_shell_width( - reconstructed, - (center_z, center_y, center_x), - wall_x, - config, - ) - if np.isfinite(width): - fused_line_widths.append(width) - assert len(fused_line_widths) >= config.minimum_line_width_samples - fused_width = np.mean(fused_line_widths) - # Fusion now consumes the deconvolved tiles; preserve their resolution. - assert abs(fused_width - deconvolved_width) <= 1.0 - - -@pytest.mark.integration -def test_tiled_opm_v2_reconstructs_registered_ground_truth( - opm_v2_tiled_ground_truth_zarr, - reconstruction_config, - cupy_gpu, - processing_options, -): - """Recover the original X-overlap acquisition in mirror and stage modes.""" - _assert_tiled_reconstruction( - opm_v2_tiled_ground_truth_zarr, - reconstruction_config, - cupy_gpu, - processing_options, - ) - - -@pytest.mark.integration -def test_spatial_tiling_reconstructs_registered_ground_truth( - opm_v2_spatial_tiling_ground_truth_zarr, - reconstruction_config, - cupy_gpu, - processing_options, -): - """Validate YX-grid, Z-staggered, and combined configurations.""" - _assert_tiled_reconstruction( - opm_v2_spatial_tiling_ground_truth_zarr, - reconstruction_config, - cupy_gpu, - processing_options, - ) - - -@pytest.mark.integration -def test_reprocessing_recomputes_registration_before_fusing( - opm_v2_tiled_ground_truth_zarr, - reconstruction_config, - cupy_gpu, - processing_options, -): - """Reprocess a simulated specimen and verify registered physical overlap.""" - del cupy_gpu - fixture = opm_v2_tiled_ground_truth_zarr - options = processing_options - state_path = processing_state_path(fixture.path.parent, fixture.path.stem) - processed_path = fixture.path.parent / f"{fixture.path.stem}_deskewed.ome.zarr" - - process(root_path=fixture.path, deconvolve=False, **options) - first_fusion = TileFusion( - root_path=fixture.path, - **reconstruction_config.fusion_options(), - ) - first_fusion.run() - first_registration = ProcessingState.read(state_path).registration(processed_path) - - process(root_path=fixture.path, deconvolve=False, **options) - assert ProcessingState.read(state_path).document["registration"] == {} - second_fusion = TileFusion( - root_path=fixture.path, - **reconstruction_config.fusion_options(), - ) - with pytest.raises(ValueError, match="no registration"): - second_fusion.load_pairwise_metrics() - - second_fusion.run() - second_registration = ProcessingState.read(state_path).registration(processed_path) - assert ( - second_registration["pairwise_metrics"] - == first_registration["pairwise_metrics"] - ) - assert second_registration["tiles"] == first_registration["tiles"] - assert not (fixture.path.parent / "stitching_metrics.json").exists() - _assert_fused_overlap_matches_ground_truth( - fixture, - reconstruction_config, - second_fusion, - ) diff --git a/tests/gpu/test_photon_splitting.py b/tests/gpu/test_photon_splitting.py new file mode 100644 index 0000000..8895274 --- /dev/null +++ b/tests/gpu/test_photon_splitting.py @@ -0,0 +1,80 @@ +"""Photon-count distribution tests for production splitting.""" + +import numpy as np +import pytest + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize("fractional", [False, True]) +def test_matlab_split_matches_independent_distribution_moments( + photon_splitter, cupy_gpu, fractional +): + # Integer photons and calibrated fractional weights across the count range. + """Match the reference split mean, variance and fractional-count behavior.""" + levels = np.array([0, 1, 4, 15, 64, 128, 149, 150, 256, 257, 2048], np.float32) + if fractional: + levels = np.r_[ + levels + np.float32(0.24), + np.array( + [0.49, 0.5, 0.51, 0.96, 1.44, 2.88, 12.24, 149.49, 149.5], np.float32 + ), + [np.nextafter(np.float32(0.5), np.float32(0))], + ] + count = 200000 + observed = np.broadcast_to(levels[:, None], (levels.size, count)) + first = photon_splitter.split(observed) + np.testing.assert_array_equal(first, photon_splitter.split(observed)) + if not fractional: + reference = cupy_gpu.random.default_rng(19).binomial( + cupy_gpu.asarray(np.where(observed >= 150, 1, observed)).astype( + cupy_gpu.int64 + ), + 0.5, + ) + np.testing.assert_array_equal( + first[levels < 150], cupy_gpu.asnumpy(reference)[levels < 150] + ) + second = observed - first + np.testing.assert_allclose( + first + second, observed, rtol=2 * np.finfo(np.float32).eps, atol=0 + ) + whole = np.floor(levels.astype(np.float64) + 0.5) + residual = levels - whole + # Binomial n/4 below 150; Gaussian n/4 above it; uniform remainder r²/12. + variance = whole / 4 + residual**2 / 12 + fourth = 3 * variance**2 - np.where(whole < 150, whole / 8, 0) - residual**4 / 120 + variance_se = np.sqrt( + np.maximum(fourth - (count - 3) / (count - 1) * variance**2, 0) / count + ) + for half in (first, second): + # Six standard errors of the independently specified count distribution, + # rather than a fixed percentage or empirical probability tolerance. + np.testing.assert_array_less( + np.abs(half.astype(np.float64).mean(axis=1) - levels / 2), + 6 * np.sqrt(variance / count) + 1e-12, + ) + np.testing.assert_array_less( + np.abs(half.astype(np.float64).var(axis=1, ddof=1) - variance), + 6 * variance_se + 1e-12, + ) + probability = np.array([1, 4, 6, 4, 1]) / 16 + measured = np.bincount(np.floor(first[2]).astype(int), minlength=5) / count + np.testing.assert_array_less( + np.abs(measured - probability), + 6 * np.sqrt(probability * (1 - probability) / count), + ) + + +def test_poisson_thinning_produces_independent_half_rate_processes(photon_splitter): + """Recover independent half-rate Poisson samples through binomial thinning.""" + count = 400000 + observed = np.random.default_rng(73).poisson(20, count).astype(np.float32) + first = photon_splitter.split(observed, seed=131).astype(np.float64) + second = observed - first + for half in [first, second]: + assert abs(half.mean() - 10) < 6 * np.sqrt(10 / count) + # Poisson fourth central moment is lambda + 3 lambda^2. + variance_se = np.sqrt((10 + 300 - (count - 3) / (count - 1) * 100) / count) + assert abs(half.var(ddof=1) - 10) < 6 * variance_se + assert abs(np.corrcoef(first, second)[0, 1]) < 6 / np.sqrt(count) diff --git a/tests/gpu/test_processing_pipeline.py b/tests/gpu/test_processing_pipeline.py new file mode 100644 index 0000000..0433a47 --- /dev/null +++ b/tests/gpu/test_processing_pipeline.py @@ -0,0 +1,29 @@ +"""Run the shared full pipeline with real full-volume and tiled CUDA RLGC.""" + +import pytest + +from tests.pipeline import ( + test_qi2lab_acquisition_to_fused_ngff_v05 as test_qi2lab_acquisition_to_fused_ngff_v05, +) + +pytestmark = [ + pytest.mark.integration, + pytest.mark.usefixtures("cupy_gpu"), + pytest.mark.parametrize("deconvolve", [True]), + pytest.mark.parametrize( + "pipeline_case", + [ + (mode, illumination, step, crop, noise) + for mode in ("stage", "mirror") + for step in (0.4, 1.2) + for illumination, crop, noise in ((False, None, False), (True, 4, True)) + ], + indirect=True, + ids=[ + f"{mode}-{step}um-{method}" + for mode in ("stage", "mirror") + for step in (0.4, 1.2) + for method in ("full-no-calibration", "tiled-estimated-taper-read-noise") + ], + ), +] diff --git a/tests/gpu/test_rlgc_detector_response.py b/tests/gpu/test_rlgc_detector_response.py new file mode 100644 index 0000000..2ddbd74 --- /dev/null +++ b/tests/gpu/test_rlgc_detector_response.py @@ -0,0 +1,328 @@ +"""Detector operator units and calibrated acquisition processing integration.""" + +import importlib + +import numpy as np +import pytest +from scipy import ndimage + +from tests.simulation.metrics import predictive_deviance + + +@pytest.mark.integration +@pytest.mark.parametrize( + "mosaic_acquisition", [("mirror", 0.4), ("stage", 1.2)], indirect=True +) +def test_roi_deconvolves_recorded_sampling( + cupy_gpu, mosaic_acquisition, monkeypatch, invoke_cli +): + """Read bounded raw planes and reconstruct the inferred fine ROI geometry.""" + from typer.testing import CliRunner + + from opm_processing import export_rois + from opm_processing.dataio.position_collection import open_image_array + from opm_processing.dataio.processing_state import ProcessingState + + case = mosaic_acquisition + invoke_cli( + "process", + case.dataset.path, + max_projection=True, + create_fused_max_projection=True, + z_downsample_level=1, + ) + preview = case.dataset.path.parent / "mosaic_max_z_fused.ome.zarr" + projection = open_image_array(preview).read().result()[0, 0, 0] + center = int(np.argmax(projection.max(axis=1))) + bounds = (center - 16, center + 16, 40, 88) + monkeypatch.setattr(export_rois, "select_rois", lambda source, shape: [bounds]) + result = CliRunner().invoke( + export_rois.app, + [ + str(preview), + "--deconvolve", + "--save-float32", + "--decon-verbose", + "0", + "--decon-psf-paths", + str(case.psf_path), + ], + ) + if result.exception: + raise result.exception + assert result.exit_code == 0 + directory = preview.parent / "roi_exports" / "roi_001" + state = ProcessingState.read(directory / "mosaic.processing.json") + tiles = directory / "mosaic_decon_deskewed.ome.zarr" + configuration = state.run(tiles) + factor = round(case.microscope.scan_step_um / 0.4) + assert ( + configuration["configuration"]["deconvolution"].get("scan_upsample_factor", 1) + == factor + ) + if factor > 1: + assert configuration["reconstruction"]["scan_axis_step_um"] == pytest.approx( + 0.4 + ) + for tile in state.roi_series(tiles): + start, stop = tile["skewed_bounds_sx"][:2] + assert 0 <= start < stop <= case.dataset.raw_data.shape[-3] + assert tile["skewed_shape_syx"][0] == stop - start + shape = tile.get("reconstruction_shape_syx", tile["skewed_shape_syx"]) + assert shape[0] == (stop - start - 1) * factor + 1 + image = open_image_array(directory / "mosaic_fused.ome.zarr").read().result() + assert image.shape[-2:] == (bounds[1] - bounds[0], bounds[3] - bounds[2]) + assert np.isfinite(image).all() and np.any(image > 0) + + +@pytest.mark.integration +@pytest.mark.parametrize( + "roi_preview", [("mirror", 1, False), ("stage", 2, False)], indirect=True +) +def test_roi_cli_deconvolves_every_timepoint_and_channel( + cupy_gpu, roi_preview, roi_psf, invoke_cli, monkeypatch, tmp_path +): + """Retain all measured spectral/time samples through physical-PSF ROI RLGC.""" + from opm_processing import export_rois + from opm_processing.dataio.position_collection import open_image_array + from opm_processing.dataio.processing_state import ProcessingState + + case = roi_preview + bounds = (3, 12, 2, 10) + monkeypatch.setattr(export_rois, "select_rois", lambda source, shape: [bounds]) + destination = tmp_path / "selected" + invoke_cli( + "export-rois", + case.preview, + output=destination, + deconvolve=True, + save_float32=True, + decon_verbose=0, + decon_crop_scan=4 if case.positions > 1 else None, + decon_psf_paths=[roi_psf] * case.dataset.metadata.shape[2], + registration_channel=1, + ) + directory = destination / "roi_001" + image = open_image_array(directory / "sample_fused.ome.zarr").read().result() + assert image.shape[:2] == case.dataset.metadata.shape[:3:2] + assert image.shape[-2:] == (bounds[1] - bounds[0], bounds[3] - bounds[2]) + assert image.dtype == np.float32 and np.isfinite(image).all() + assert np.all(image.max(axis=(2, 3, 4)) > 0) + state = ProcessingState.read(directory / "sample.processing.json") + tiles = directory / "sample_decon_deskewed.ome.zarr" + assert state.completed_channels(tiles) == set(np.ndindex(2, case.positions, 2)) + settings = state.run(tiles)["configuration"]["deconvolution"] + assert settings["gc_stopping"]["read_noise"] == 0 + assert settings["gc_stopping"]["kl_enabled"] is True + + +@pytest.mark.unit +@pytest.mark.parametrize( + "kl_history", [False, True], ids=["iteration-cap", "both-halves-KL"] +) +@pytest.mark.parametrize("read_noise", [0, 1.6], ids=["photons", "read-noise"]) +@pytest.mark.parametrize( + "crop_scan,storage", [(2, "gpu"), (2, "host")], ids=["tiled-fft", "host-tiled-fft"] +) +@pytest.mark.parametrize("factor", [None, 1, 3], ids=["native", "sparse1", "sparse3"]) +@pytest.mark.parametrize( + "nonuniform", [False, True], ids=["unity", "detector-response"] +) +def test_tiled_consensus_recovers_known_blurred_object( + cupy_gpu, + factor, + nonuniform, + kl_history, + read_noise, + crop_scan, + storage, + discrete_optical_case, +): + """Full and tiled GC improve the likelihood of a known blurred 3D object.""" + solver = importlib.import_module("opm_processing.imageprocessing.rlgc") + case = discrete_optical_case + options = { + "use_kl": kl_history, + "max_iterations": 3, + "limit": 0, + "max_delta": 0, + "detector_response": case.response, + "read_noise": read_noise, + "rng_seed": 42, + "scan_upsample_factor": factor or 1, + "boundary": "reflect" if factor is None else "zero", + } + whole = solver.rlgc(case.measured, case.psf, storage=storage, **options) + tiled = solver.rlgc( + case.measured, case.psf, crop_scan=crop_scan, storage=storage, **options + ) + assert tiled.shape == case.truth.shape + baseline = case.measured / case.response + if factor is not None: + from scipy.interpolate import interp1d + + baseline = interp1d(np.arange(baseline.shape[0]) * factor, baseline, axis=0)( + np.arange(case.truth.shape[0]) + ) + baseline_loss = predictive_deviance( + baseline, + case.expectation, + case.psf, + boundary=case.boundary, + response=case.response, + ) + # The GC gate is discontinuous at zero: single-precision FFT roundoff can + # change an accepted update. Verify physical accuracy against truth for each + # layout; forward/adjoint units separately bound convolution seam error. + for recovered in (whole, tiled): + assert ( + predictive_deviance( + recovered, + case.expectation, + case.psf, + boundary=case.boundary, + response=case.response, + ) + < baseline_loss + ) + + +@pytest.mark.unit +@pytest.mark.parametrize("scan_planes", (1, 7)) +@pytest.mark.parametrize("crop_scan", (3, 20)) +def test_nonuniform_response_preserves_uniform_fluorescence( + cupy_gpu, scan_planes, crop_scan +): + """A uniform object remains uniform through blur and nonuniform detection.""" + solver = importlib.import_module("opm_processing.imageprocessing.rlgc") + + response = np.broadcast_to(np.array((0.25, 0.5, 1, 2), np.float32), (16, 4)) + truth = np.full((scan_planes, *response.shape), 1200, dtype=np.float32) + psf = np.ones((1 if scan_planes == 1 else 3, 3, 3), dtype=np.float32) + psf /= psf.sum() + measured = ndimage.convolve(truth, psf, mode="reflect") * response + restored = solver.rlgc( + measured, psf, crop_scan=crop_scan, detector_response=response, verbose=0 + ) + np.testing.assert_allclose(restored, truth, rtol=2e-6, atol=2e-3) + + +@pytest.mark.unit +def test_calibrated_input_preserves_subcount_intensity(cupy_gpu): + """An identity optical model retains calibrated precision without uint16 overflow.""" + solver = importlib.import_module("opm_processing.imageprocessing.rlgc") + + observed = np.broadcast_to( + np.array((-0.4, 31.9, 64.1, 80000.2), np.float32), (8, 4) + ) + expected = np.broadcast_to(np.array((0, 31.9, 64.1, 80000.2), np.float32), (8, 4)) + actual = solver.rlgc( + observed, + np.ones((1, 1), np.float32), + gradient_consensus=False, + max_iterations=1, + ) + np.testing.assert_allclose(actual, expected, rtol=2e-6, atol=0.02) + + +@pytest.mark.unit +@pytest.mark.parametrize("crop_scan", (4, 20)) +@pytest.mark.parametrize("seed", (31, 83)) +def test_detector_response_recovers_point_flux(cupy_gpu, crop_scan, seed): + """Recover known emitters from integer Poisson draws under a varying response.""" + solver = importlib.import_module("opm_processing.imageprocessing.rlgc") + z, y, x = np.mgrid[-2:3, -3:4, -3:4] + psf = np.exp(-0.5 * (z**2 + (y / 1.5) ** 2 + (x / 1.5) ** 2)).astype(np.float32) + psf /= psf.sum() + truth = np.zeros((9, 35, 37), np.float32) + truth[3, 10, 10], truth[6, 25, 27] = 20000, 30000 + response = np.broadcast_to(np.linspace(0.25, 2, 37, dtype=np.float32), (35, 37)) + expectation = response * (ndimage.convolve(truth, psf, mode="reflect") + 0.25) + measured = np.random.default_rng(seed).poisson(expectation).astype(np.float32) + actual = solver.rlgc( + measured, psf, crop_scan=crop_scan, detector_response=response, verbose=0 + ) + assert predictive_deviance( + actual, expectation, psf, boundary="reflect", response=response + ) < predictive_deviance( + measured / response, expectation, psf, boundary="reflect", response=response + ) + for point in ((3, 10, 10), (6, 25, 27)): + neighborhood = actual[tuple(slice(p - 1, p + 2) for p in point)] + assert np.unravel_index(np.argmax(neighborhood), neighborhood.shape) == ( + 1, + 1, + 1, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("uniform_detector_acquisition", "roi_mode"), + ( + ("projection", False), + ("mirror", False), + ("stage", False), + ("mirror", True), + ("stage", True), + *[ + pytest.param((mode, "generated-PSF"), False, id=f"generated-psf-{mode}") + for mode in ("mirror", "stage", "projection") + ], + ), + indirect=["uniform_detector_acquisition"], +) +def test_saved_fluorescence_with_detector_response( + cupy_gpu, uniform_detector_acquisition +): + """Check calibrated processing orchestration against known absolute fluorescence.""" + from opm_processing.dataio.position_collection import open_position_collection + from opm_processing.dataio.processing_state import ( + ProcessingState, + processing_state_path, + ) + from opm_processing.process import process + from opm_processing.process_roi import process_roi + + case = uniform_detector_acquisition + run = process_roi if case.roi_mode else process + run(case.dataset.path, **case.options) + saved = open_position_collection(case.output).arrays[0][0, 0].read().result() + np.testing.assert_allclose(saved, case.expected, rtol=0.005, atol=1) + state = ProcessingState.read( + processing_state_path( + case.output.parent, case.dataset.path.stem.removesuffix(".ome") + ) + ) + settings = state.run(case.output)["configuration"]["deconvolution"] + assert settings["gc_stopping"]["read_noise"] == 0 + assert settings["gc_stopping"]["read_noise_reference"] == "gc_deconv.m" + assert settings["observations"] == "calibrated-detector-counts" + assert settings["observation_model"] == "poisson" + assert settings["gc_stopping"]["accepted_fraction_limit"] == 0.001 + assert settings["gc_stopping"]["kl_enabled"] is True + assert settings["gc_stopping"]["kl_require_both"] is True + assert ( + settings["gc_stopping"]["consensus_product"] + == "relative-directions-before-blur" + ) + if "decon_psf_paths" in case.options: + assert settings["psf_parameters"] is None + np.save(case.psf_path, np.full((3, 3, 3), 2 / 27, np.float32)) + else: + from yaozarrs import open_group + + from opm_processing.dataio.position_collection import update_group_attributes + + root = open_group(case.dataset.path) + calibration = root.attrs["opm_processing"] + assert ( + settings["psf_parameters"] == calibration["deconvolution"]["psf_parameters"] + ) + calibration["deconvolution"]["psf_parameters"]["support_fwhms"] *= 1.2 + update_group_attributes(case.dataset.path, {"opm_processing": calibration}) + with pytest.raises(ValueError, match=r"incompatible|configuration"): + run(case.dataset.path, **case.options, resume=True) + np.testing.assert_array_equal( + open_position_collection(case.output).arrays[0][0, 0].read().result(), saved + ) diff --git a/tests/gpu/test_rlgc_physics.py b/tests/gpu/test_rlgc_physics.py index d2a1418..47107ee 100644 --- a/tests/gpu/test_rlgc_physics.py +++ b/tests/gpu/test_rlgc_physics.py @@ -1,427 +1,204 @@ -"""Independent optical and boundary checks for production gradient consensus. +"""Reconstruction physics using the shared fine-Cartesian camera forward model. -The optical field is evaluated on a 40 nm Cartesian grid at 637 nm and -integrated over detector pixels before sampling onto the 30 degree acquisition -grid. Fluorescent beads and a filament have known laboratory coordinates. -SciPy simulates image formation without production PSF or FFT helpers. +Numerical assertions use production operators, SciPy and the shared simulator. """ import importlib -import json -import logging -from dataclasses import replace import numpy as np import pytest -from scipy import ndimage, signal +from scipy.interpolate import interp1d +from scipy.signal import fftconvolve -from opm_processing.dataio.position_collection import open_position_collection -from opm_processing.process import process -from tests.fixtures.acquisition import ( - simulated_acquisition_metadata, - write_simulated_acquisition, -) -from tests.reference.point_sources import ( - skewed_coordinates, +from tests.simulation.camera import digitize +from tests.simulation.metrics import ( + camera_score_difference, + predictive_deviance, two_point_profile, ) +pytestmark = pytest.mark.unit -@pytest.mark.unit -@pytest.mark.parametrize("seed", (None, 31, 83)) -@pytest.mark.parametrize( - "factor", (None, 1, 3, 4), ids=("native", "dense", "sparse3", "sparse4") -) -def test_native_gc_recovers_independent_optical_specimen( - cupy_gpu, physical_volume, seed, tmp_path, factor -): - """Recover known object fluorescence and physical bead positions with native GC. - Parameters - ---------- - cupy_gpu - Fixture requiring a working CUDA device. - physical_volume - Known specimen, bead centers, finite reconstruction PSF, and full optical data. - seed : int | None - Poisson realization, or None for noiseless photon expectations. - tmp_path - Directory for the measured object arrays and physical error report. - factor : int | None - Measured-plane interval for the sensitivity-corrected solver, or None - for native reflected-boundary reconstruction. - """ - solver = importlib.import_module("opm_processing.imageprocessing.rlgc") - truth, beads, psf, expectation = physical_volume - observed = ( - expectation - if seed is None - else np.random.default_rng(seed).poisson(expectation) - ) - if factor is None: - recovered = solver.rlgc(observed.astype(np.float32), psf) - else: - from opm_processing.imageprocessing.rlgc_undersampled import rlgc_undersampled +def test_generated_psf_camera_reconstruction(cupy_gpu, production_point_acquisition): + """Apply production GC using the same PSF as the full camera simulation.""" + from opm_processing.imageprocessing.camera import camera_correct + from opm_processing.imageprocessing.rlgc import rlgc - recovered = rlgc_undersampled( - observed[::factor].astype(np.float32), - psf, - factor, - ) - reference_error = np.linalg.norm(observed - truth) - metrics = { - "seed": seed, - "factor": factor, - "object_nrmse": float( - np.linalg.norm(recovered - truth) / np.linalg.norm(truth) - ), - "measured_nrmse": float( - np.linalg.norm(observed - truth) / np.linalg.norm(truth) - ), - "recovery_error_ratio": float( - np.linalg.norm(recovered - truth) / reference_error - ), - "flux_ratio": float(recovered.sum() / truth.sum()), - } - xyz = skewed_coordinates(truth.shape) - localization_errors = [] - for center in beads: - region = ( - sum((coord - c) ** 2 for coord, c in zip(xyz, center, strict=False)) - < 0.6**2 - ) - mass = np.maximum(recovered, 0) * region - centroid = np.asarray([np.sum(mass * coord) / mass.sum() for coord in xyz]) - localization_errors.append(float(np.linalg.norm(centroid - center))) - metrics["localization_errors_um"] = localization_errors - print(json.dumps(metrics)) - np.savez( - tmp_path / "ground-object.npz", - truth=truth, - observed=observed, - recovered=recovered, - psf=psf, + case = production_point_acquisition + expected = case.expectations[-1] + camera = case.microscope.camera + calibrated = camera_correct( + digitize(expected, camera, seed=42), camera.offset_adu, camera.electrons_per_adu + ) + recovered = rlgc( + calibrated, + case.kernel, + read_noise=camera.read_noise_e, + boundary="zero", + rng_seed=42, ) - (tmp_path / "measurements.json").write_text(json.dumps(metrics), encoding="utf-8") - assert metrics["recovery_error_ratio"] < 0.9, metrics - assert abs(metrics["flux_ratio"] - 1) < 0.1, metrics - assert max(localization_errors) < 0.15, metrics assert np.isfinite(recovered).all() and recovered.min() >= 0 + assert predictive_deviance(recovered, expected, case.kernel) < predictive_deviance( + calibrated, expected, case.kernel + ) + score = camera_score_difference( + digitize(expected, camera, seed=43), + np.maximum(fftconvolve(recovered, case.kernel, mode="same"), 0), + np.maximum(fftconvolve(calibrated, case.kernel, mode="same"), 0), + camera, + ) + assert score.sum() > 0 -@pytest.mark.unit -@pytest.mark.parametrize("axis", ("x", "y", "z")) -@pytest.mark.parametrize("photons_per_point", (10_000, 100_000)) -@pytest.mark.parametrize("factor", (None, 3, 4), ids=("native", "sparse3", "sparse4")) -def test_native_gc_two_point_resolution( - cupy_gpu, physical_volume, axis, photons_per_point, tmp_path, factor -): - """Measure the same optical emitter pairs under full and sparse sampling. - - Parameters - ---------- - cupy_gpu - Fixture requiring a working CUDA device before reconstructing. - physical_volume - Independent detector-integrated optical PSF and acquisition-grid shape. - axis : str - Laboratory direction of the pair. Z pairs include compensating scan - displacement and report their actual physical separation. - photons_per_point : int - Expected photons from each emitter before finite acquisition cropping. - tmp_path - Directory for physical line profiles and pair measurements. - factor : int | None - Select every third or fourth measured plane, or use native full sampling. - - Notes - ----- - Measurements report raw and reconstructed peak positions, separation and - valley depth across acquisition-grid and wider spacings. Closely - spaced pairs may remain unresolved. Widest-pair measurements must retain - two localized peaks and absolute fluorescence. No gain or translation is - fitted, and no minimum resolution is assigned without a measured dip. - """ - solver = importlib.import_module("opm_processing.imageprocessing.rlgc") - specimen, _, psf, _ = physical_volume - origin = np.array(specimen.shape) // 2 - displacements = { - "x": ( - (0, 0, 1), - (0, 0, 2), - (0, 0, 3), - (0, 0, 4), - (0, 0, 6), - (0, 0, 8), - (0, 0, 12), - ), - "y": ((1, 0, 0), (2, 0, 0), (3, 0, 0), (4, 0, 0), (6, 0, 0), (8, 0, 0)), - "z": ((-2, 4, 0), (-4, 8, 0), (-6, 12, 0), (-8, 16, 0), (-12, 24, 0)), - }[axis] - for displacement in displacements: - displacement = np.array(displacement) - left = origin - displacement // 2 - right = left + displacement - center = (left + right) / 2 - offset = displacement / 2 - truth = np.zeros(specimen.shape, np.float32) - truth[tuple(left)] = photons_per_point - truth[tuple(right)] = photons_per_point - expectation = np.maximum(signal.fftconvolve(truth, psf, mode="same"), 0) - observed = np.random.default_rng(31).poisson(expectation).astype(np.float32) - if factor is None: - recovered = solver.rlgc(observed, psf) - else: - from opm_processing.imageprocessing.rlgc_undersampled import ( - rlgc_undersampled, - ) +@pytest.mark.parametrize( + "accepted,change,peak,reason", + [ + (0, 2, 1000, "consensus_fraction"), + (1, 2, 1000, None), + (2, 2, 1000, None), + (1000, 0.5, 1000, "relative_change"), + (1000, 1, 1000, None), + (1000, -2, 1000, None), + (1, 0.5, 1000, "relative_change"), + (1000, 1e-15, 1e-15, None), + (1000, 5e-16, 5e-16, "relative_change"), + ], +) +def test_gc_stopping_matches_matlab_reference(cupy_gpu, accepted, change, peak, reason): + """Lock MATLAB's strict bounds, absolute change, zero gate and denominator floor.""" + from opm_processing.imageprocessing.rlgc import ( + GC_RELATIVE_CHANGE_LIMIT, + GC_UPDATED_FRACTION_LIMIT, + _gc_stopping_metrics, + ) - recovered = rlgc_undersampled( - observed[::factor], - psf, - factor, - ) - profiles = { - name: two_point_profile(volume, center, offset) - for name, volume in (("raw", observed), ("reconstructed", recovered)) - } - measurements = { - "axis": axis, - "factor": factor, - "photons_per_point": photons_per_point, - "seed": 31, - "recovery_error_ratio": float( - np.linalg.norm(recovered - truth) / np.linalg.norm(observed - truth) - ), - "flux_ratio": float(recovered.sum() / truth.sum()), - "profiles": { - name: { - key: value - for key, value in measured.items() - if key not in ("distance_um", "profile") - } - for name, measured in profiles.items() - }, - } - print(json.dumps(measurements)) - np.savez( - tmp_path / f"{axis}-{max(map(abs, displacement))}.npz", - distance_um=profiles["raw"]["distance_um"], - raw_profile=profiles["raw"]["profile"], - reconstructed_profile=profiles["reconstructed"]["profile"], - recovered=recovered, - measurements=json.dumps(measurements), - ) - assert np.isfinite(recovered).all() and recovered.min() >= 0 - widest = np.array_equal(displacement, displacements[-1]) - if widest: - np.testing.assert_allclose(recovered.sum(), truth.sum(), rtol=0.1) - measured = profiles["reconstructed"] - separation = measured["true_separation_um"] - assert measured["peak_positions_um"] is not None, measurements - acquisition_interval = separation / (2 * max(map(abs, offset))) - np.testing.assert_allclose( - measured["peak_positions_um"], - (-separation / 2, separation / 2), - atol=acquisition_interval, - rtol=0, - err_msg=str(measurements), - ) + current = cupy_gpu.full(1000, peak, dtype=cupy_gpu.float64) + previous = current.copy() + previous[0] -= change + consensus = cupy_gpu.full(1000, -1.0) + consensus[:accepted] = 0 + fraction, delta, actual = _gc_stopping_metrics( + previous, + current, + consensus, + GC_UPDATED_FRACTION_LIMIT, + GC_RELATIVE_CHANGE_LIMIT, + ) + assert GC_UPDATED_FRACTION_LIMIT == GC_RELATIVE_CHANGE_LIMIT == 0.001 + assert fraction == accepted / 1000 + assert delta == pytest.approx(abs(change) / max(peak, 1e-12), rel=2e-15) + assert actual == reason -@pytest.mark.unit -@pytest.mark.parametrize("safe_mode", (True, False)) -@pytest.mark.parametrize("termination", ("rollback", "limit", "max_delta")) -def test_production_boundary_and_consensus_match_cpu_reference( - cupy_gpu, monkeypatch, caplog, safe_mode, termination +@pytest.mark.parametrize("factor", (None, 3, 4), ids=("native", "sparse3", "sparse4")) +@pytest.mark.parametrize("camera_mode", (False, True), ids=("ideal", "camera-3000")) +def test_native_gc_directional_rayleigh_pair( + cupy_gpu, + reconstruct, + rayleigh_pair, + camera_exposure, + factor, + camera_mode, + camera_calibration, ): - """Check the complete update and returned iterate against direct CPU convolution. - - Parameters - ---------- - cupy_gpu - Fixture requiring a working CUDA device. - monkeypatch - Fixture replacing random draws with independently prescribed splits. - caplog - Fixture capturing the solver's scientific stopping decisions. - safe_mode : bool - Roll back when either split worsens, or only when both worsen. - termination : str - Exercise rollback, updated-fraction stopping, or relative-delta stopping. - """ - solver = importlib.import_module("opm_processing.imageprocessing.rlgc") - observed = np.random.default_rng(7).integers(5, 80, (6, 8, 10)).astype(np.float32) - psf = np.arange(1, 28, dtype=np.float64).reshape(3, 3, 3) - psf /= psf.sum() - splits = [] - rng = np.random.default_rng(17) - - padded = np.pad(observed, 1, mode="symmetric") - - def prescribed_split(values, generator, *, assign_fractional_remainder=False): - """Supply known complementary binomial draws for both numerical operators. - - Parameters - ---------- - values - Measured device counts; only their independently known shape is used. - generator - Solver generator, superseded by the prescribed CPU draws. - assign_fractional_remainder : bool - Reference mode, unused because these prescribed measurements are integers. - - Returns - ------- - cupy.ndarray - Independent CPU binomial draw transferred to the device. - """ - if termination == "rollback" and splits: - delta = ndimage.convolve(padded, psf[::-1, ::-1, ::-1], mode="wrap") - split = np.where(delta > delta.mean(), padded, 0) - else: - split = rng.binomial(padded.astype(np.int64), 0.5) - splits.append(split) - return cupy_gpu.asarray(split, dtype=cupy_gpu.float32) - - monkeypatch.setattr(solver, "_split_observed_counts", prescribed_split) - limit = 1.01 if termination == "limit" else 0 - max_delta = 2 if termination == "max_delta" else 0 - logger = logging.getLogger("rlgc-rule-test") - with caplog.at_level(logging.INFO, logger=logger.name): - actual = solver.rlgc( - observed, - psf.astype(np.float32), - safe_mode=safe_mode, - limit=limit, - max_delta=max_delta, - logger=logger, - ) - core = np.s_[1:-1, 1:-1, 1:-1] - estimate = np.maximum( - ndimage.convolve(padded, psf[::-1, ::-1, ::-1], mode="wrap"), 0 - ) - estimate = np.maximum(estimate, estimate.max() * 1e-7).astype(np.float64) - previous = None - previous_prediction = None - for split in splits: - predicted = ndimage.convolve(estimate, psf, mode="wrap") - worsened = [] - for half in (split, padded - split): - target = half + 1e-4 - target /= target.sum() - probability = predicted + 1e-4 - probability /= probability.sum() - score = np.sum(target * np.log(target / probability)) - old_score = np.inf - if previous_prediction is not None: - old = previous_prediction + 1e-4 - old /= old.sum() - old_score = np.sum(target * np.log(target / old)) - worsened.append(score > old_score) - if any(worsened) if safe_mode else all(worsened): - estimate = previous - expected_stop = "restore_previous_recon" - break - previous_prediction = predicted - ratios = [] - for half in (split, padded - split): - ratio = half / (0.5 * (predicted + 1e-12)) - ratios.append(ndimage.convolve(ratio, psf[::-1, ::-1, ::-1], mode="wrap")) - consensus = ndimage.convolve( - ndimage.convolve((ratios[0] - 1) * (ratios[1] - 1), psf, mode="wrap"), - psf[::-1, ::-1, ::-1], - mode="wrap", + """Resolve noisy pairs placed at the directional optical first-minimum spacing.""" + case = rayleigh_pair + expectation = case.expectation + if camera_mode: + expectation = camera_exposure(expectation, 3000) + assert expectation.max() == pytest.approx(3000, rel=2e-15) + camera = camera_calibration + observed = digitize(expectation, camera, seed=31) + calibrated = np.maximum( + (observed.astype(np.float32) - camera.offset_adu) + * camera.electrons_per_adu, + 0, ) - updated = np.where( - consensus >= 0, estimate * (ratios[0] + ratios[1]) * 0.5, estimate - ) - previous, estimate = estimate, updated - if np.mean(consensus >= 0) < limit: - expected_stop = "limit" - break - if np.max(np.abs(estimate - previous) / max(estimate.max(), 1e-12)) < max_delta: - expected_stop = "max_delta" - break else: - expected_stop = "max_iterations" - np.testing.assert_allclose(actual, estimate[core], rtol=2e-4, atol=2e-4) - assert f"stop={expected_stop}" in caplog.text - assert expected_stop == ( - ("restore_previous_recon" if safe_mode else "max_iterations") - if termination == "rollback" - else termination + observed = np.random.default_rng(31).poisson(expectation).astype(np.float32) + calibrated = observed + recovered = reconstruct( + calibrated, + case.psf, + factor, + read_noise=camera.read_noise_e if camera_mode else 0, ) - assert len(splits) > 1 if termination == "rollback" else len(splits) == 1 + baseline = calibrated + if factor is not None: + acquired = calibrated[::factor] + positions = np.arange(acquired.shape[0]) * factor + baseline = interp1d(positions, acquired, axis=0)(np.arange(positions[-1] + 1)) + center = (np.array(observed.shape) - 1) / 2 + measured = two_point_profile(recovered, center, case.offset, case.microscope) + metrics = { + "axis": case.axis, + "scan_factor": factor, + "rayleigh_separation_um": case.separation, + "sampled_rayleigh_dip": case.acquisition_dip, + "measured_dip": measured["valley_depth"], + "measured_positions_um": measured["peak_positions_um"], + "predictive_deviance": predictive_deviance(recovered, expectation, case.psf), + "baseline_deviance": predictive_deviance(baseline, expectation, case.psf), + } + print(metrics) + assert np.isfinite(recovered).all() and recovered.min() >= 0 + assert metrics["predictive_deviance"] < metrics["baseline_deviance"], metrics + # Sparse scans need not preserve the information required by the Rayleigh + # pair. Report their measured dip rather than assigning a fictitious limit. + if factor is None: + assert measured["peak_positions_um"] is not None, metrics + # Rayleigh fixes source separation through the single-point first minimum. + # It does not require a noisy reconstruction's dip to exceed the noiseless + # acquisition's dip. Peak positions and likelihood test the recovered pair. + # Profile interpolation cannot establish positions more finely than the + # acquisition-grid projection along this direction. + interval = case.separation / (2 * max(abs(case.offset))) + np.testing.assert_allclose( + measured["peak_positions_um"], + [-case.separation / 2, case.separation / 2], + rtol=0, + atol=interval, + err_msg=str(metrics), + ) -@pytest.mark.integration -@pytest.mark.parametrize("mode", ("mirror", "stage")) -def test_native_optical_deconvolution_disk_round_trip( - processing_options, cupy_gpu, physical_volume, tmp_path, mode +@pytest.mark.parametrize("factor", [1, 3, 4]) +@pytest.mark.parametrize("crop_scan", [None, 5]) +def test_optical_forward_adjoint_matches_scipy( + cupy_gpu, optical_setup, factor, crop_scan ): - """Process camera stores and verify saved fluorescence and physical bead positions. - - Parameters - ---------- - cupy_gpu - Fixture requiring a working CUDA device. - physical_volume - Known specimen, bead coordinates, PSF, and photon expectations. - tmp_path - Temporary acquisition and processed stores. - mode : str - Mirror or reversed stage scan. - """ - truth, beads, psf, expectation = physical_volume - photons = np.random.default_rng(637).poisson(expectation) - raw = (photons * 2 + 100).astype(np.uint16) - if mode == "stage": - raw = raw[::-1].copy() - raw = raw[None, None, None] - metadata = replace( - simulated_acquisition_metadata(tmp_path / "physical.ome.zarr", mode, raw.shape), - scan_axis_step_um=0.2, + """Check the actual optical operator against independent CPU convolution.""" + cp = cupy_gpu + solver = importlib.import_module("opm_processing.imageprocessing.rlgc") + # Displace the calibrated optical origin by one scan sample to ensure the + # adjoint test also detects using the unflipped, asymmetric PSF. + psf = np.pad(optical_setup.psf, ((2, 0), (0, 0), (0, 0))) + psf /= psf.sum() + shape = (31, 17, 19) + selection = (slice(None, None, factor), slice(None), slice(None)) + rng = np.random.default_rng(34) + obj = rng.uniform(1, 10, shape).astype(np.float32) + measured = rng.uniform(1, 10, obj[selection].shape).astype(np.float32) + pads = solver._linear_fft_pad_width(shape, psf.shape) + padded_shape = tuple(n + sum(p) for n, p in zip(shape, pads, strict=False)) + core = solver._observed_region_slices(padded_shape, pads) + convolve = solver._Convolution(psf, padded_shape, crop_scan=crop_scan) + padded = cp.zeros(padded_shape, dtype=cp.float32) + padded[core] = cp.asarray(obj) + forward = cp.asnumpy(convolve(padded)[core])[selection] + scattered_gpu = cp.zeros(shape, dtype=cp.float32) + scattered_gpu[selection] = cp.asarray(measured) + padded[core] = scattered_gpu + adjoint = cp.asnumpy(convolve(padded, adjoint=True)[core]) + np.testing.assert_allclose( + forward, fftconvolve(obj, psf, mode="same")[selection], rtol=2e-5, atol=2e-6 ) - write_simulated_acquisition(metadata, raw) - psf_path = tmp_path / "independent-637nm-psf.npy" - np.save(psf_path, psf) - process( - root_path=metadata.path, - deconvolve=True, - decon_psf_paths=[psf_path], - decon_crop_scan=truth.shape[0], - save_float32=True, - **processing_options, + scattered = np.zeros(shape) + scattered[selection] = measured + np.testing.assert_allclose( + adjoint, fftconvolve(scattered, np.flip(psf), mode="same"), rtol=2e-5, atol=2e-6 ) - collection = open_position_collection(tmp_path / "physical_decon_deskewed.ome.zarr") - actual = collection.arrays[0][0, 0].read().result() - assert np.isfinite(actual).all() and actual.min() >= 0 - assert collection.voxel_size_um == (0.115, 0.115, 0.115) - # The existing deskew sums two interpolated planes and divides by scan - # spacing in pixels, giving constant-field gain 2 * pixel / scan_step. - # Account for that known intensity convention, without fitting a scale. - # The acquisition Jacobian is scan_step * pixel**2 * sin(angle). - deskew_gain = 2 * 0.115 / 0.2 - output_fluorescence = actual.sum(dtype=np.float64) * 0.115**3 / deskew_gain - known_fluorescence = truth.sum(dtype=np.float64) * 0.2 * 0.115**2 * 0.5 - assert abs(output_fluorescence / known_fluorescence - 1) < 0.1 - z, y, x = np.ogrid[: actual.shape[0], : actual.shape[1], : actual.shape[2]] - xyz = ( - x * 0.115 - (truth.shape[2] - 1) * 0.115 / 2, - y * 0.115 - - ( - (truth.shape[0] - 1) * 0.2 - + (truth.shape[1] - 1) * 0.115 * np.cos(np.pi / 6) - ) - / 2, - z * 0.115 - (truth.shape[1] - 1) * 0.115 * np.sin(np.pi / 6) / 2, + np.testing.assert_allclose( + np.sum(forward * measured), np.sum(obj * adjoint), rtol=2e-6 ) - for center in beads: - region = ( - sum((coord - c) ** 2 for coord, c in zip(xyz, center, strict=False)) - < 0.6**2 - ) - mass = actual * region - centroid = np.asarray([np.sum(mass * coord) / mass.sum() for coord in xyz]) - assert np.linalg.norm(centroid - center) < 0.15 diff --git a/tests/gpu/test_rlgc_undersampled.py b/tests/gpu/test_rlgc_undersampled.py deleted file mode 100644 index f9d2bdb..0000000 --- a/tests/gpu/test_rlgc_undersampled.py +++ /dev/null @@ -1,307 +0,0 @@ -"""Physical image-formation tests for the separate undersampled RL experiment. - -The microscope is the existing 100x silicone model: NA 1.35, immersion RI 1.40, -sample RI 1.38, emission 610 nm, camera pixels 115 nm, and OPM angle 30 degrees. -The PSF comes from the vectorial diffraction model, never a Gaussian kernel. -Fluorescent spheres and a finite filament are defined in laboratory microns -and voxel-integrated in the skew acquisition coordinates. Independent SciPy -linear convolution produces photon expectations before scan-plane selection. - -Full sampling is 0.2 um; integer undersampling is 0.6 or 0.8 um with unchanged -exposure per plane. Thus undersampling also reduces total collected photons. -We do not blur a decimated specimen: that would delete real fluorophores. -These are tests of the stated shift-invariant, zero-background optical model, -not a claim to model scattering, illumination variations or camera read noise. -""" - -from __future__ import annotations - -import importlib -import json - -import numpy as np -import pytest -from scipy import signal -from typer.testing import CliRunner - -from opm_processing.dataio.position_collection import open_position_collection -from opm_processing.dataio.processing_state import ProcessingState -from tests.fixtures.acquisition import ( - simulated_acquisition_metadata, - write_simulated_acquisition, -) -from tests.reference.point_sources import skewed_coordinates - - -def _blur(truth, psf): - """Independent zero-exterior incoherent fluorescence image formation.""" - return np.maximum(signal.fftconvolve(truth.astype(np.float64), psf, mode="same"), 0) - - -def _interpolate_scan(image, factor): - """Linear interpolation baseline with exactly the acquired endpoints.""" - positions = np.arange((image.shape[0] - 1) * factor + 1) / factor - lower = np.floor(positions).astype(int) - upper = np.minimum(lower + 1, image.shape[0] - 1) - alpha = (positions - lower)[:, None, None] - return image[lower] * (1 - alpha) + image[upper] * alpha - - -def _nrmse(actual, expected): - """Measure absolute fluorescence error without fitting a gain or shift.""" - return float(np.linalg.norm(actual - expected) / np.linalg.norm(expected)) - - -@pytest.mark.unit -@pytest.mark.parametrize("factor", [3, 4]) -@pytest.mark.parametrize("poisson", [False, True], ids=["noise_free", "photon_noise"]) -def test_physical_full_and_undersampled_rl( - cupy_gpu, optical_psf, specimen, factor, poisson -): - """Recover physical features and missing planes relative to full-data RL.""" - solver = importlib.import_module("opm_processing.imageprocessing.rlgc_undersampled") - truth, beads = specimen - expectation = _blur(truth, optical_psf) - rng = np.random.default_rng(610) - full_data = rng.poisson(expectation) if poisson else expectation - # The same specimen, PSF, exposure and scan origin; no coarse-grid reblur. - sparse_data = full_data[::factor].copy() - kwargs = {"gradient_consensus": False, "max_iterations": 60, "max_delta": 0} - dense = solver.rlgc_undersampled(full_data, optical_psf, **kwargs) - recovered = solver.rlgc_undersampled(sparse_data, optical_psf, factor, **kwargs) - # Sample the same optical PSF about its center at the coarse spacing. - # This baseline performs conventional RL directly on the acquired grid. - center = optical_psf.shape[0] // 2 - coarse_psf = optical_psf[center % factor :: factor].copy() - coarse_psf /= coarse_psf.sum() - native = solver.rlgc_undersampled(sparse_data, coarse_psf, **kwargs) - native_interpolated = _interpolate_scan(native, factor) - baseline = _interpolate_scan(sparse_data, factor) - assert recovered.shape == truth.shape - assert recovered.dtype == np.float32 - assert np.isfinite(recovered).all() and np.min(recovered) >= 0 - missing = np.arange(truth.shape[0]) % factor != 0 - reblurred = _blur(recovered, optical_psf) - metrics = { - "scan_step_um": 0.2 * factor, - "dense_nrmse": _nrmse(dense, truth), - "undersampled_nrmse": _nrmse(recovered, truth), - "native_coarse_rl_nrmse": _nrmse(native_interpolated, truth), - "interpolation_nrmse": _nrmse(baseline, truth), - "difference_from_dense_rl": _nrmse(recovered, dense), - "missing_planes_nrmse": _nrmse(recovered[missing], truth[missing]), - "missing_planes_interpolation_nrmse": _nrmse(baseline[missing], truth[missing]), - "missing_planes_reblur_nrmse": _nrmse(reblurred[missing], expectation[missing]), - "dense_reblur_nrmse": _nrmse(_blur(dense, optical_psf), expectation), - "measured_planes_reblur_nrmse": _nrmse( - reblurred[::factor], expectation[::factor] - ), - "flux_ratio": float(recovered.sum() / truth.sum()), - } - # Feature localization is checked in physical coordinates, so swapping - # scan/camera Y, changing origin, or a wrong shear cannot pass on MSE alone. - xyz = skewed_coordinates(truth.shape) - errors = [] - for bead in beads: - region = ( - sum((coord - c) ** 2 for coord, c in zip(xyz, bead, strict=False)) < 0.6**2 - ) - mass = recovered * region - centroid = np.array([np.sum(mass * coord) / mass.sum() for coord in xyz]) - truth_mass = truth * region - truth_centroid = np.array( - [np.sum(truth_mass * coord) / truth_mass.sum() for coord in xyz] - ) - errors.append(float(np.linalg.norm(centroid - truth_centroid))) - metrics["bead_centroid_errors_um"] = errors - print(json.dumps(metrics)) - # These tolerances allow the expected loss of information, not a claim - # that arbitrary missing high spatial frequencies can be recovered. - assert metrics["dense_nrmse"] < metrics["interpolation_nrmse"] * 0.8 - assert metrics["undersampled_nrmse"] < metrics["interpolation_nrmse"] * 0.9 - assert metrics["undersampled_nrmse"] < metrics["native_coarse_rl_nrmse"] * 0.95 - assert metrics["undersampled_nrmse"] < metrics["dense_nrmse"] * 1.6 - assert metrics["difference_from_dense_rl"] < 0.15 - assert ( - metrics["missing_planes_nrmse"] - < metrics["missing_planes_interpolation_nrmse"] * 0.9 - ) - # A finite-iteration full-data RL reconstruction also has residual blur. - # Limit absolute error and additional error from withholding observations. - assert metrics["missing_planes_reblur_nrmse"] < 0.1 - assert metrics["measured_planes_reblur_nrmse"] < 0.1 - assert ( - metrics["measured_planes_reblur_nrmse"] < 1.25 * metrics["dense_reblur_nrmse"] - ) - assert 0.95 < metrics["flux_ratio"] < 1.05 - assert max(errors) < 0.12 - - -@pytest.mark.unit -def test_physical_gradient_consensus(cupy_gpu, optical_psf, specimen): - """Exercise the reused GC loop with photon noise and unmeasured planes.""" - solver = importlib.import_module("opm_processing.imageprocessing.rlgc_undersampled") - truth, _ = specimen - data = np.random.default_rng(32).poisson(_blur(truth, optical_psf)) - results = [] - for factor in (1, 3): - reconstructed = solver.rlgc_undersampled( - data[::factor], optical_psf, factor, max_iterations=60 - ) - assert reconstructed.shape == truth.shape - assert np.isfinite(reconstructed).all() and reconstructed.min() >= 0 - assert _nrmse(reconstructed, truth) < 0.9 * _nrmse( - _interpolate_scan(data[::factor], factor), truth - ) - results.append(reconstructed) - assert _nrmse(results[1], truth) < 1.6 * _nrmse(results[0], truth) - - -@pytest.mark.unit -@pytest.mark.parametrize("factor", [1, 3, 4]) -def test_optical_forward_adjoint_and_cpu_rl(cupy_gpu, optical_psf, factor): - """Check the actual optical operator against independent CPU convolution.""" - cp = cupy_gpu - solver = importlib.import_module("opm_processing.imageprocessing.rlgc_undersampled") - # Displace the calibrated optical origin by one scan sample to ensure the - # adjoint test also detects using the unflipped, asymmetric PSF. - psf = np.pad(optical_psf, ((2, 0), (0, 0), (0, 0))) - psf /= psf.sum() - shape = (13, 11, 9) - rng = np.random.default_rng(34) - obj = rng.uniform(1, 10, shape).astype(np.float32) - measured = rng.uniform(1, 10, obj[::factor].shape).astype(np.float32) - pads = solver._linear_fft_pad_width(shape, psf.shape) - padded_shape = tuple(n + sum(p) for n, p in zip(shape, pads, strict=False)) - core = solver._observed_region_slices(padded_shape, pads) - otf = cp.fft.rfftn(solver.pad_psf(cp.asarray(psf), padded_shape)) - forward = cp.asnumpy( - solver._forward(cp.asarray(obj), otf, padded_shape, core, factor) - ) - adjoint = cp.asnumpy( - solver._adjoint( - cp.asarray(measured), otf.conj(), padded_shape, core, shape, factor - ) - ) - np.testing.assert_allclose(forward, _blur(obj, psf)[::factor], rtol=2e-5, atol=2e-6) - scattered = np.zeros(shape) - scattered[::factor] = measured - np.testing.assert_allclose( - adjoint, _blur(scattered, psf[::-1, ::-1, ::-1]), rtol=2e-5, atol=2e-6 - ) - np.testing.assert_allclose( - np.sum(forward * measured), np.sum(obj * adjoint), rtol=2e-6 - ) - - mask = np.zeros(shape) - mask[::factor] = 1 - sensitivity = _blur(mask, psf[::-1, ::-1, ::-1]) - expected = _blur(scattered, psf[::-1, ::-1, ::-1]) / sensitivity - for _ in range(3): - ratio = np.zeros(shape) - ratio[::factor] = measured / _blur(expected, psf)[::factor] - expected *= _blur(ratio, psf[::-1, ::-1, ::-1]) / sensitivity - actual = solver.rlgc_undersampled( - measured, psf, factor, gradient_consensus=False, max_iterations=3, max_delta=0 - ) - np.testing.assert_allclose(actual, expected, rtol=2e-4, atol=2e-4) - - -@pytest.mark.integration -@pytest.mark.parametrize("mode", ["mirror", "stage"]) -def test_combined_channel_cli_reconstructs_physical_specimen( - cupy_gpu, sparse_optical_volume, mode, tmp_path -): - """Reconstruct physical beads from camera data on disk to deskewed data on disk.""" - process = importlib.import_module("opm_processing.process") - truth, beads, known_psf, _expectation, photons = sparse_optical_volume - # An ideal calibrated camera: 100 ADU offset, 0.5 photons/ADU. - raw = (photons * 2 + 100).astype(np.uint16) - if mode == "stage": - raw = raw[::-1].copy() - raw = raw[None, None, None] - metadata = simulated_acquisition_metadata( - tmp_path / "both_lasers.ome.zarr", mode, raw.shape - ) - write_simulated_acquisition(metadata, raw) - output = tmp_path - psf_path = tmp_path / "independent_637nm_psf.npy" - np.save(psf_path, known_psf) - result = CliRunner().invoke( - process.app, - [ - str(metadata.path), - "--deconvolve", - "--decon-psf-paths", - str(psf_path), - "--decon-scan-upsample", - "2", - "--save-float32", - "--no-max-projection", - "--z-downsample-level", - "1", - ], - ) - assert result.exit_code == 0, (result.output, result.exception) - processed_path = output / "both_lasers_decon_deskewed.ome.zarr" - collection = open_position_collection(processed_path) - state = ProcessingState.read(output / "both_lasers.processing.json") - actual = collection.arrays[0][0, 0].read().result() - assert np.isfinite(actual).all() and actual.min() >= 0 - assert collection.voxel_size_um == (0.115, 0.115, 0.115) - np.testing.assert_array_equal( - collection.stage_positions_zxy, metadata.stage_positions_zxy - ) - assert state.completed_tiles(processed_path) == {(0, 0)} - assert state.run(processed_path)["reconstruction"] == { - "shape_syx": list(truth.shape), - "scan_axis_step_um": 0.4, - } - # Independent Cartesian coordinates: each output voxel is 115 nm. Compare - # bead centers in microns, not against a second call to the deskew routine. - deskew_gain = 2 * 0.115 / 0.4 - saved_fluorescence = actual.sum(dtype=np.float64) * 0.115**3 / deskew_gain - object_fluorescence = truth.sum(dtype=np.float64) * 0.4 * 0.115**2 * 0.5 - assert abs(saved_fluorescence / object_fluorescence - 1) < 0.1 - z, y, x = np.ogrid[: actual.shape[0], : actual.shape[1], : actual.shape[2]] - xyz = ( - x * 0.115 - (truth.shape[2] - 1) / 2 * 0.115, - y * 0.115 - - ( - (truth.shape[0] - 1) / 2 * 0.4 - + (truth.shape[1] - 1) / 2 * 0.115 * np.cos(np.pi / 6) - ), - z * 0.115 - (truth.shape[1] - 1) / 2 * 0.115 * np.sin(np.pi / 6), - ) - for bead in beads: - region = ( - sum((coord - c) ** 2 for coord, c in zip(xyz, bead, strict=False)) < 0.6**2 - ) - mass = actual * region - assert mass.sum() > 0 - centroid = np.array([np.sum(mass * coord) / mass.sum() for coord in xyz]) - assert np.linalg.norm(centroid - bead) < 0.2 - - -@pytest.mark.unit -def test_sparse_optical_beads_recover_flux_and_predict_withheld_planes( - cupy_gpu, sparse_optical_volume -): - """Check acquisition-grid accuracy independently of disk writing and deskewing.""" - solver = importlib.import_module("opm_processing.imageprocessing.rlgc_undersampled") - truth, _, known_psf, expectation, photons = sparse_optical_volume - recovered = solver.rlgc_undersampled(photons.astype(np.float32), known_psf, 2) - assert recovered.shape == truth.shape - baseline = _interpolate_scan(photons, 2) - metrics = { - "truth_nrmse": _nrmse(recovered, truth), - "interpolation_nrmse": _nrmse(baseline, truth), - "withheld_prediction_nrmse": _nrmse( - _blur(recovered, known_psf)[1::2], expectation[1::2] - ), - "flux_ratio": float(recovered.sum() / truth.sum()), - } - print(json.dumps(metrics)) - assert metrics["truth_nrmse"] < 0.9 * metrics["interpolation_nrmse"] - assert metrics["withheld_prediction_nrmse"] < 0.2 - assert 0.9 < metrics["flux_ratio"] < 1.1 diff --git a/tests/gpu/test_tiled_pipeline.py b/tests/gpu/test_tiled_pipeline.py new file mode 100644 index 0000000..2e3c0dc --- /dev/null +++ b/tests/gpu/test_tiled_pipeline.py @@ -0,0 +1,21 @@ +"""Automatic memory-limited RLGC compared with full processing of identical mosaics.""" + +import pytest + +from tests.pipeline import ( + test_extended_mosaic_full_and_tiled_agree as test_extended_mosaic_full_and_tiled_agree, +) + +pytestmark = [ + pytest.mark.integration, + pytest.mark.usefixtures("cupy_gpu"), + pytest.mark.parametrize("deconvolve", [True]), + pytest.mark.parametrize( + "mosaic_acquisition", + [ + pytest.param((mode, step), id=f"{mode}-{step}um") + for mode, step in [("stage", 0.4), ("mirror", 1.2)] + ], + indirect=True, + ), +] diff --git a/tests/pipeline.py b/tests/pipeline.py new file mode 100644 index 0000000..b900f07 --- /dev/null +++ b/tests/pipeline.py @@ -0,0 +1,401 @@ +"""Shared complete processing scenarios collected by the CPU and CUDA suites.""" + +import importlib +from functools import partial + +import numpy as np +import tensorstore as ts +from yaozarrs import open_group, v05 + +from opm_processing.dataio.acquisition import inspect_acquisition +from opm_processing.dataio.deconvolution import scan_upsample_from_metadata +from opm_processing.dataio.processing_state import ProcessingState +from opm_processing.imageprocessing.camera import camera_correct, illumination_correct +from opm_processing.imageprocessing.opmtools import orthogonal_deskew +from tests.simulation.metrics import predictive_deviance + + +def test_qi2lab_acquisition_to_fused_ngff_v05(pipeline_case, deconvolve): + """Retain calibrated pixels, registered coordinates and pyramids across stages.""" + case = pipeline_case + raw = open_group(case.dataset.path) + assert "opm_v2" in raw.attrs + assert raw["0"].attrs["ome_writers"]["frame_metadata"] + metadata = inspect_acquisition(case.dataset.path) + assert metadata.shape == case.dataset.raw_data.shape + assert metadata.camera_conversion == case.microscope.camera.electrons_per_adu + assert metadata.scan_axis_step_um == case.microscope.scan_step_um + assert case.collection.shape[:3] == (2, 2, 2) + np.testing.assert_allclose( + case.collection.voxel_size_um, case.microscope.pixel_size_um + ) + if case.options["flatfield_correction"]: + assert case.field is not None and case.field.shape == ( + 1, + 2, + *metadata.shape[-2:], + ) + assert np.all(np.isfinite(case.field) & (case.field > 0)) + for position, array in enumerate(case.collection.arrays): + assert isinstance( + open_group(case.processed / str(position)).ome_metadata(), v05.Image + ) + for time, channel in np.ndindex(2, 2): + saved = array[time, channel].read().result() + assert saved.dtype == np.float32 + assert np.isfinite(saved).all() and np.all(saved >= 0) + assert np.any(saved[case.support] > 0) + np.testing.assert_array_equal(saved[~case.support], 0) + if not deconvolve: + calibrated = camera_correct( + case.dataset.raw_data[time, position, channel], + metadata.camera_offset, + metadata.camera_conversion, + ) + if case.field is not None: + calibrated = illumination_correct( + calibrated, case.field[0, channel] + ) + if metadata.mode == "stage": + calibrated = calibrated[::-1].copy() + expected = orthogonal_deskew( + calibrated, + theta=metadata.angle_deg, + distance=metadata.scan_axis_step_um, + pixel_size=metadata.pixel_size_um, + downsample_factor=1, + ) + np.testing.assert_array_equal(saved, expected) + + fusion = case.fusion + # Stage Z is converted to laboratory Z; the detector-X stage coordinate + # keeps its physical value. Registration must retain the anchored origin. + stage_z, _scan, stage_x = metadata.stage_positions_zxy[0] + np.testing.assert_allclose( + np.asarray(fusion._tile_positions)[0, (0, 2)], + (-stage_z, stage_x), + atol=1e-8, + ) + # Repeated fields must coincide after removing the deliberately wrong + # one-pixel stage displacement, for both independent timepoints. + for time in range(2): + indices = np.flatnonzero(np.asarray(fusion._tile_time_indices) == time) + np.testing.assert_allclose( + np.diff(np.asarray(fusion._tile_positions)[indices], axis=0), + 0, + atol=1e-8, + ) + np.testing.assert_allclose( + np.diff(case.nominal[indices], axis=0), + ((0, 0, metadata.pixel_size_um),), + atol=1e-8, + ) + assert tuple(indices) in fusion.optimized_pairwise_metrics + state = ProcessingState.read(fusion.processing_state.path) + assert state.completed_tiles(case.processed) == set(np.ndindex(2, 2)) + registration = state.registration(case.processed) + assert len(registration["tiles"]) == 4 + assert registration["fused_path"].endswith(case.output.name) + if deconvolve: + settings = state.run(case.processed)["configuration"]["deconvolution"] + expected_noise = ( + case.microscope.camera.read_noise_e if metadata.deconvolution else 0 + ) + assert settings["gc_stopping"]["read_noise"] == expected_noise + assert settings["gc_stopping"]["kl_enabled"] is True + + base = assert_fused_ngff(case) + # Coincident independently exposed tiles have equal weights. This checks + # every fused pixel, including block boundaries, unsupported edges and + # pyramid padding, without calling the production fusion renderer. + expected_fused = np.zeros_like(base) + for time in range(2): + indices = np.flatnonzero(np.asarray(fusion._tile_time_indices) == time) + origin = np.asarray(fusion._tile_positions)[indices[0]] + start = np.rint( + (origin - fusion.offset_um) / case.collection.voxel_size_um + ).astype(int) + slices = tuple( + slice(a, a + n) for a, n in zip(start, case.support.shape, strict=True) + ) + exposures = np.stack( + [array[time].read().result() for array in case.collection.arrays] + ) + expected_fused[(time, slice(None), *slices)] = exposures.mean(axis=0) + np.testing.assert_allclose(base, expected_fused, rtol=5e-6, atol=5e-6) + + +def assert_fused_ngff(case): + """Validate persisted v0.5 axes, global coordinates, pixels and every scale. + + Returns + ------- + numpy.ndarray + Reopened full-resolution TCZYX pixels. + """ + fusion = case.fusion + reopened = open_group(case.output) + assert reopened.metadata.zarr_format == 3 + assert reopened.attrs["ome"]["version"] == "0.5" + image = reopened.ome_metadata() + assert isinstance(image, v05.Image) + multiscale = image.multiscales[0] + assert [axis.name for axis in multiscale.axes] == list("tczyx") + assert [axis.unit for axis in multiscale.axes[-3:]] == ["micrometer"] * 3 + factors = (1, *fusion.multiscale_factors) + assert [dataset.path for dataset in multiscale.datasets] == [ + str(i) for i in range(len(factors)) + ] + store = reopened["0"].to_tensorstore() + assert isinstance(store, ts.TensorStore) + specification = store.spec().to_json() + assert specification["driver"] == "zarr3" + assert specification["kvstore"]["driver"] == "file" + base = store.read().result() + assert base.shape[:2] == (case.collection.shape[0], case.collection.shape[2]) + assert base.dtype == np.float32 + assert np.isfinite(base).all() and base.max() > 0 + for factor, dataset in zip(factors, multiscale.datasets, strict=True): + np.testing.assert_allclose( + dataset.scale_transform.scale[-3:], + factor * np.asarray(case.collection.voxel_size_um), + ) + np.testing.assert_allclose( + dataset.translation_transform.translation[-3:], fusion.offset_um + ) + np.testing.assert_array_equal( + reopened[dataset.path].to_tensorstore().read().result(), + base[:, :, ::factor, ::factor, ::factor], + ) + return base + + +def test_extended_mosaic_full_and_tiled_agree( + mosaic_acquisition, + deconvolve, + pipeline_runner, + monkeypatch, + tmp_path, +): + """Check automatic chunks and foreground seams against identical full processing.""" + case = mosaic_acquisition + parameters = case.parameters + shape = case.dataset.raw_data.shape[-3:] + factor = scan_upsample_from_metadata(case.dataset.metadata) if deconvolve else 1 + shape = ((shape[0] - 1) * factor + 1, *shape[1:]) + expectation = case.reconstruction_expectation if deconvolve else case.expectation + scan_step = case.microscope.scan_step_um / factor + offset = parameters["tile_offset_x_pixels"] + tile_limits = np.asarray(parameters["deskew_tile_shape_zyx"]) + # The noiseless specimen image crosses Y and X output tile borders, so the + # numerical comparison exercises foreground seams as well as dark regions. + signal = orthogonal_deskew( + expectation, + theta=case.microscope.angle_deg, + distance=scan_step, + pixel_size=case.microscope.pixel_size_um, + downsample_factor=1, + ) + bright = signal[..., : shape[-1]] > signal.max() / 2 + for axis in (0, 1, 2): + boundaries = np.arange(tile_limits[axis], bright.shape[axis], tile_limits[axis]) + assert np.any( + np.take(bright, boundaries - 1, axis=axis) + & np.take(bright, boundaries, axis=axis) + ) + layouts = [] + if deconvolve: + solver = importlib.import_module("opm_processing.imageprocessing.rlgc") + create_convolution = solver._Convolution + + def observe_layout(*args, **kwargs): + """Record actual FFT plans while returning the real numerical operator.""" + operator = create_convolution(*args, **kwargs) + layouts.append((operator.shape, operator.tile_shape, operator.crop_scan)) + return operator + + monkeypatch.setattr(solver, "_Convolution", observe_layout) + pads = solver._linear_fft_pad_width(shape, case.psf.shape) + padded_shape = tuple(n + sum(p) for n, p in zip(shape, pads, strict=True)) + # Configure the public planner's memory fraction for a sub-volume budget. + # Query actual free memory; do not replace the CUDA runtime or allocator. + memory_budget = int( + np.prod(padded_shape) * 26 * np.dtype(np.float32).itemsize * 0.76 + ) + + options = { + "deconvolve": deconvolve, + "psf_path": case.psf_path, + "blend": tuple(parameters["blend_pixels_zyx"]), + # Keep the same real RNG and arithmetic backend for the two layouts. + # The public host-storage mode still performs every convolution on CUDA. + # Backend-specific random streams differ even with the same seed. + "decon_storage": "host" if deconvolve else None, + } + full = pipeline_runner( + case.dataset, + **options, + crop_scan=shape[0], + output=tmp_path / "full", + fusion_chunk=(256, 256), + ) + if deconvolve: + assert layouts and all( + full_shape == tile_shape for full_shape, tile_shape, _ in layouts + ) + layouts.clear() + with monkeypatch.context() as resources: + if deconvolve: + free_bytes, _ = solver.cp.cuda.runtime.memGetInfo() + resources.setattr( + solver, + "determine_rlgc_crop_scan", + partial( + solver.determine_rlgc_crop_scan, + memory_fraction=min(0.8, memory_budget / free_bytes), + ), + ) + tiled = pipeline_runner( + case.dataset, + **options, + output=tmp_path / "tiled", + deskew_tile=tuple(parameters["deskew_tile_shape_zyx"]), + fusion_chunk=tuple(parameters["fusion_chunk_shape_yx"]), + ) + if deconvolve: + assert layouts and all( + tile_shape[0] < full_shape[0] for full_shape, tile_shape, _ in layouts + ) + crops = {crop for _, _, crop in layouts} + assert len(crops) == 1 + crop = crops.pop() + assert 1 < crop < shape[0] + foreground_scan = expectation[..., : shape[-1]].max(axis=(1, 2)) + foreground_scan = foreground_scan > foreground_scan.max() / 2 + boundaries = np.arange(crop, padded_shape[0], crop) - pads[0][0] + boundaries = boundaries[(boundaries > 0) & (boundaries < shape[0])] + assert ( + np.count_nonzero( + foreground_scan[boundaries - 1] & foreground_scan[boundaries] + ) + >= 2 + ) + assert tiled.options["decon_crop_scan"] is None + # Compare actual reconstructions with known optical camera means, rather + # than treating a different FFT layout as physical ground truth. + from scipy.interpolate import interp1d + + for result in (full, tiled): + assert len(result.reconstructions) == case.dataset.raw_data.shape[1] + for position, recovered in enumerate(result.reconstructions): + start = position * offset + expected = case.reconstruction_expectation[ + ..., start : start + shape[-1] + ] + raw = case.dataset.raw_data[0, position, 0] + if case.dataset.metadata.scan_axis_reversed: + raw = raw[::-1] + baseline = camera_correct( + raw, + case.microscope.camera.offset_adu, + case.microscope.camera.electrons_per_adu, + ) + if factor > 1: + baseline = interp1d( + np.arange(baseline.shape[0]) * factor, baseline, axis=0 + )(np.arange(shape[0])) + boundary = "constant" if factor > 1 else "reflect" + assert predictive_deviance( + recovered, + expected, + case.psf, + boundary=boundary, + ) < predictive_deviance(baseline, expected, case.psf, boundary=boundary) + + # A numerical layout comparison uses the same unsigned camera pixels, + # recorded noise, measured PSF, random seed and unmodified GC stopping rules. + # Tolerance covers accumulated single-precision FFT roundoff, not a change + # in exposure, normalization, likelihood or reconstruction parameters. + tolerance = {"rtol": 2e-4, "atol": 2e-3} if deconvolve else {"rtol": 0, "atol": 0} + for reference, blocked in zip( + full.collection.arrays, tiled.collection.arrays, strict=True + ): + np.testing.assert_allclose( + blocked.read().result(), reference.read().result(), **tolerance + ) + + support = ( + orthogonal_deskew( + np.full(shape, 1, dtype=np.float32), + distance=scan_step, + pixel_size=case.microscope.pixel_size_um, + theta=case.microscope.angle_deg, + downsample_factor=1, + ) + > 0 + ) + assert len(tiled.deskew_tiles) > 1 + assert all( + np.all(np.asarray(tile_shape) <= tile_limits) + for _, tile_shape in tiled.deskew_tiles + ) + assert any( + np.any(np.asarray(tile_shape) < tile_limits) + for _, tile_shape in tiled.deskew_tiles + ) + fused = [] + for result in (full, tiled): + actual = assert_fused_ngff(result) + fused.append(actual) + fusion = result.fusion + origins = np.asarray(fusion._tile_positions) + np.testing.assert_allclose( + origins[1] - origins[0], + (0, 0, offset * case.microscope.pixel_size_um), + atol=1e-8, + ) + assert (0, 1) in fusion.optimized_pairwise_metrics + # Independent linear feathering: retain both exclusive camera regions + # and normalize overlapping measurements of the same physical specimen. + numerator = np.zeros_like(actual, dtype=np.float64) + denominator = np.zeros_like(actual, dtype=np.float64) + x = np.arange(shape[-1]) + blend = parameters["blend_pixels_zyx"][-1] + weights = np.minimum(1, np.minimum(x + 1, shape[-1] - x) / blend) + for position, array in enumerate(result.collection.arrays): + start = np.rint( + (origins[position] - fusion.offset_um) / result.collection.voxel_size_um + ).astype(int) + selection = tuple( + slice(a, a + n) for a, n in zip(start, support.shape, strict=True) + ) + numerator[(slice(None), slice(None), *selection)] += ( + array.read().result() * weights + ) + denominator[(slice(None), slice(None), *selection)] += support * weights + expected = np.divide( + numerator, denominator, out=np.zeros_like(numerator), where=denominator > 0 + ) + np.testing.assert_allclose(actual, expected, rtol=5e-6, atol=5e-6) + # Bright lines occur in the left-only, shared and right-only regions. + for region in ( + slice(0, offset), + slice(offset, shape[-1]), + slice(shape[-1], None), + ): + field = case.expectation[..., region] + assert field.max() > case.expectation.max() / 2 + if result is tiled: + block_y, block_x = fusion._fusion_block_shape( + fusion.write_block_shape[2], + *fusion.padded_shape[-2:], + ) + assert block_y < actual.shape[-2] and block_x < actual.shape[-1] + # Locate foreground from the known optical specimen, independently + # of whether deconvolution narrows a line onto one camera column. + foreground = signal > signal.max() / 2 + x_borders = np.arange(block_x, actual.shape[-1], block_x) + y_borders = np.arange(block_y, actual.shape[-2], block_y) + assert np.any(foreground[..., x_borders - 1] & foreground[..., x_borders]) + assert np.any(foreground[:, y_borders - 1] & foreground[:, y_borders]) + np.testing.assert_allclose(fused[1], fused[0], **tolerance) diff --git a/tests/reference/__init__.py b/tests/reference/__init__.py deleted file mode 100644 index 2018795..0000000 --- a/tests/reference/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Independent physical objects and numerical acceptance measurements.""" diff --git a/tests/reference/deskew.py b/tests/reference/deskew.py deleted file mode 100644 index 5c9fdc1..0000000 --- a/tests/reference/deskew.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Analytical deskew intensity and support for a uniform fluorescent volume.""" - -import numpy as np - - -def constant_deskew( - shape, value, *, distance=0.4, pixel_size=0.115, theta=30.0, downsample_factor=2 -): - """Evaluate constant fluorescence without production deskew kernels. - - Parameters - ---------- - shape : tuple of int - Scan, detector row and detector column counts. - value : float - Calibrated constant fluorescence in the acquired volume. - distance, pixel_size : float - Scan spacing and detector pixel pitch in micrometers. - theta : float - Camera plane angle to the coverslip in degrees. - downsample_factor : int - Number of laboratory Z planes averaged per stored output plane. - - Returns - ------- - numpy.ndarray - Float32 ZYX fluorescence including unsupported voxels and YX padding. - - Notes - ----- - Two interpolated scan planes give gain ``2 * pixel_size / distance``. - Both bracketing scans and both detector row pairs must lie inside the - acquisition. Missing support contributes zero to a complete Z-bin divisor. - """ - planes, rows, columns = shape - angle = np.deg2rad(theta) - step = distance / pixel_size - nz = int(np.ceil(rows * np.sin(angle))) - ny = int(np.ceil(planes * step + rows * np.cos(angle))) - z, y = np.indices((nz, ny), dtype=np.float64) - scan_position = y - z / np.tan(angle) - before = np.floor(scan_position / step) - displacement = scan_position - before * step - row_before = z / np.sin(angle) + displacement * np.cos(angle) - row_after = z / np.sin(angle) - (step - displacement) * np.cos(angle) - supported = ( - (before >= 0) - & (before + 1 < planes) - & (row_before >= 0) - & (row_before < rows - 1) - & (row_after >= 0) - & (row_after < rows - 1) - ) - padded_z = ((nz + downsample_factor - 1) // downsample_factor) * downsample_factor - intensity = np.zeros( - (padded_z, ((ny + 3) // 4) * 4, ((columns + 3) // 4) * 4), np.float32 - ) - intensity[:nz, :ny, :columns] = supported[..., None] * (2 * value / step) - return intensity.reshape(-1, downsample_factor, *intensity.shape[1:]).mean(axis=1) diff --git a/tests/reference/metrics.py b/tests/reference/metrics.py deleted file mode 100644 index 14eb731..0000000 --- a/tests/reference/metrics.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Reusable numerical assertions and measurements for synthetic image tests.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - - -@dataclass(frozen=True) -class CorrelationMeasurement: - """A masked Pearson correlation and its sample count.""" - - value: float - sample_count: int - - -def masked_correlation( - candidate: np.ndarray, - truth: np.ndarray, - *, - truth_percentile: float, - observation_mask: np.ndarray | None = None, -) -> CorrelationMeasurement: - """Measure correlation on truth-selected pixels, including lost signal.""" - candidate_array = np.asarray(candidate) - truth_array = np.asarray(truth) - if candidate_array.shape != truth_array.shape: - raise ValueError("candidate and truth must have identical shapes") - supported = truth_array > np.percentile(truth_array, truth_percentile) - if observation_mask is not None: - supported &= observation_mask - sample_count = int(np.count_nonzero(supported)) - if sample_count < 2: - return CorrelationMeasurement(float("nan"), sample_count) - value = float(np.corrcoef(candidate_array[supported], truth_array[supported])[0, 1]) - return CorrelationMeasurement(value, sample_count) - - -def shell_line_width_x( - volume: np.ndarray, - *, - center_zyx: tuple[float, float, float], - wall_x: float, - half_window: int, -) -> float: - """Measure discrete FWHM of an ellipsoidal shell along its X normal.""" - z_index = round(center_zyx[0]) - y_index = round(center_zyx[1]) - x_index = round(wall_x) - start = max(0, x_index - half_window) - stop = min(volume.shape[2], x_index + half_window + 1) - profile = np.asarray(volume[z_index, y_index, start:stop], dtype=np.float64) - if profile.size < 3 or not np.any(profile > profile.min()): - return float("nan") - peak = int(np.argmax(profile)) - half_max = profile.min() + 0.5 * (profile[peak] - profile.min()) - left = peak - while left > 0 and profile[left - 1] >= half_max: - left -= 1 - right = peak - while right + 1 < profile.size and profile[right + 1] >= half_max: - right += 1 - return float(right - left + 1) diff --git a/tests/reference/point_sources.py b/tests/reference/point_sources.py deleted file mode 100644 index 1f8b37c..0000000 --- a/tests/reference/point_sources.py +++ /dev/null @@ -1,260 +0,0 @@ -"""Independent vectorial-diffraction point emitters in laboratory microns.""" - -from functools import lru_cache - -import numpy as np -import psfmodels -from scipy.interpolate import RegularGridInterpolator -from scipy.ndimage import map_coordinates -from scipy.signal import find_peaks - - -@lru_cache(maxsize=1) -def optical_field(): - """Evaluate a 637 nm, NA 1.35 silicone objective on a Cartesian grid.""" - spacing = 0.04 - xy = (np.arange(101) - 50) * spacing - z = (np.arange(151) - 75) * spacing - field = psfmodels.vectorial_psf( - zv=z, - nx=xy.size, - dxy=spacing, - pz=0, - wvl=0.637, - params={ - "NA": 1.35, - "ni0": 1.4, - "ni": 1.4, - "ns": 1.38, - "tg0": 170, - "tg": 170, - "ti0": 300, - }, - ) - return RegularGridInterpolator((z, xy, xy), field, bounds_error=False, fill_value=0) - - -def sample_point_source( - shape=(81, 97, 49), - scan_step=0.2, - pixel=0.115, - angle=30, - center_xyz=(0, 0, 0), - velocity_xyz=(0, 0, 0), - scan_speed=0.8 / 0.003, - exposure=0.003, -): - """Integrate a stationary/moving emitter over the exposure of each plane. - - Coordinates and time are evaluated independently of production deskew/PSF - helpers. Motion is constant velocity in um/s; exposure is in seconds. - The camera plane is held at its nominal position during exposure, isolating - specimen motion from any unmodeled motion of the scanning actuator. - """ - scan, row, col = np.meshgrid( - (np.arange(shape[0]) - (shape[0] - 1) / 2) * scan_step, - (np.arange(shape[1]) - (shape[1] - 1) / 2) * pixel, - (np.arange(shape[2]) - (shape[2] - 1) / 2) * pixel, - indexing="ij", - sparse=True, - ) - theta = np.deg2rad(angle) - x, y, z = col, scan + row * np.cos(theta), row * np.sin(theta) - times = scan / scan_speed - nodes, weights = np.polynomial.legendre.leggauss(5) - result = np.zeros(shape) - for node, weight in zip(nodes, weights / 2, strict=False): - time = times + node * exposure / 2 - coords = np.broadcast_arrays( - z - center_xyz[2] - velocity_xyz[2] * time, - y - center_xyz[1] - velocity_xyz[1] * time, - x - center_xyz[0] - velocity_xyz[0] * time, - ) - points = np.stack(coords, axis=-1) - result += weight * optical_field()(points) - return result.astype(np.float32) - - -def point_moments(volume, voxel_size, center, radius=1.3): - """Measure XYZ covariance and YZ principal-axis tilt in a physical sphere.""" - z, y, x = np.ogrid[: volume.shape[0], : volume.shape[1], : volume.shape[2]] - xyz = np.broadcast_arrays(x * voxel_size[2], y * voxel_size[1], z * voxel_size[0]) - region = ( - sum((coord - c) ** 2 for coord, c in zip(xyz, center, strict=False)) - <= radius**2 - ) - mass = np.maximum(volume, 0).astype(np.float64) * region - mass /= mass.sum() - centroid = np.array([np.sum(mass * coord) for coord in xyz]) - covariance = np.array( - [ - [ - np.sum(mass * (a - ca) * (b - cb)) - for b, cb in zip(xyz, centroid, strict=False) - ] - for a, ca in zip(xyz, centroid, strict=False) - ] - ) - yz_tilt = np.rad2deg( - 0.5 * np.arctan2(2 * covariance[1, 2], covariance[2, 2] - covariance[1, 1]) - ) - return { - "centroid_xyz": centroid.tolist(), - "covariance_xyz": covariance.tolist(), - "yz_tilt_deg": float(yz_tilt), - "axis_misalignment_deg": float(min(abs(yz_tilt), 90 - abs(yz_tilt))), - "yz_correlation": float( - covariance[1, 2] / np.sqrt(covariance[1, 1] * covariance[2, 2]) - ), - } - - -def skewed_coordinates(shape, scan_step=0.2, offset=(0, 0, 0)): - """Map centered acquisition indices to laboratory XYZ in microns. - - Parameters - ---------- - shape : tuple of int - Scan, detector-row, and detector-column dimensions. - scan_step : float - Scan displacement per plane in micrometers, defaulting to 0.2. - offset : tuple of float - Subvoxel offsets along the three acquisition axes, defaulting to zero. - - Returns - ------- - tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] - Broadcastable laboratory X, Y, Z coordinates for 115 nm camera pixels - and a 30 degree OPM angle. - """ - scan, row, col = np.meshgrid( - *[np.arange(n) - (n - 1) / 2 + d for n, d in zip(shape, offset, strict=False)], - indexing="ij", - sparse=True, - ) - angle = np.deg2rad(30) - return ( - col * 0.115, - scan * scan_step + row * 0.115 * np.cos(angle), - row * 0.115 * np.sin(angle), - ) - - -def fluorescent_specimen(shape, scan_step): - """Integrate beads and a finite filament on the requested oblique grid. - - Parameters - ---------- - shape : tuple of int - Scan, detector-row, and detector-column dimensions. - scan_step : float - Scan displacement per plane in micrometers. - - Returns - ------- - tuple[numpy.ndarray, list[tuple[float, float, float]]] - Float32 fluorescence sampled with 27 subvoxel quadrature points and - bead centers in laboratory XYZ micrometers. Beads have 380 nm diameter - and 10,000 fluorescence units; the 220 nm filament has 5,000 units. - """ - truth = np.zeros(shape, dtype=np.float64) - # (X,Y,Z) centers in um; different Y phases include missing scan planes. - beads = [(-1.25, -3.1, -0.55), (1.0, 2.7, 0.65), (0.6, -0.65, -0.65)] - start = np.array([-0.9, -1.6, 0.45]) - end = np.array([0.65, 1.7, -0.15]) - direction = end - start - offsets = (-1 / 3, 0.0, 1 / 3) - for ds in offsets: - for dy in offsets: - for dx in offsets: - xyz = skewed_coordinates( - shape, scan_step=scan_step, offset=(ds, dy, dx) - ) - for center in beads: - distance2 = sum( - (coord - c) ** 2 for coord, c in zip(xyz, center, strict=False) - ) - truth += 10000 * (distance2 <= 0.19**2) / 27 - projection = sum( - (coord - s) * d - for coord, s, d in zip(xyz, start, direction, strict=False) - ) - projection = np.clip(projection / np.dot(direction, direction), 0, 1) - distance2 = sum( - (coord - (s + projection * d)) ** 2 - for coord, s, d in zip(xyz, start, direction, strict=False) - ) - truth += 5000 * (distance2 <= 0.11**2) / 27 - return truth.astype(np.float32), beads - - -def two_point_profile(volume, center_syx, offset_syx, scan_step=0.2): - """Measure the line profile through two known acquisition-grid point positions. - - Parameters - ---------- - volume : numpy.ndarray - Fluorescence in scan, camera-row, camera-column order. - center_syx : array_like - Midpoint of the two emitters in acquisition indices. - offset_syx : array_like - Displacement from the midpoint to the positive emitter in index units. - The other emitter is at the opposite displacement. - scan_step : float, default=0.2 - Scan displacement in micrometers. Camera pixels are 115 nm and the - OPM angle is 30 degrees, matching the shared specimen model. - - Returns - ------- - dict - Distances and linear-interpolated profile samples, known separation, - measured peak positions and separation, and fractional valley depth - relative to the weaker peak. Distances are laboratory micrometers - relative to the pair midpoint. Peak measurements are None when two - local maxima are absent from the respective emitter neighborhoods. - Positive valley depth alone does not define a resolution criterion. - - Notes - ----- - Search within half a pair separation around each known emitter and select - the strongest local maximum in each neighborhood. Four profile samples - per acquisition-grid interval interpolate the measured image; they do - not provide additional optical information. Intensities are not fitted. - """ - offset = np.asarray(offset_syx, dtype=np.float64) - displacement_xyz = np.array( - ( - offset[2] * 0.115, - offset[0] * scan_step + offset[1] * 0.115 * np.cos(np.pi / 6), - offset[1] * 0.115 * np.sin(np.pi / 6), - ) - ) - separation = 2 * np.linalg.norm(displacement_xyz) - positions = np.linspace(-2, 2, int(np.ceil(16 * np.max(np.abs(offset)))) + 1) - distance = positions * separation / 2 - coordinates = np.asarray(center_syx)[:, None] + offset[:, None] * positions - profile = map_coordinates(volume, coordinates, order=1, mode="constant", cval=0) - peaks, _ = find_peaks(profile) - selected = [] - for expected in (-separation / 2, separation / 2): - candidates = peaks[np.abs(distance[peaks] - expected) < separation / 4] - if candidates.size: - selected.append(int(candidates[np.argmax(profile[candidates])])) - result = { - "distance_um": distance, - "profile": profile, - "true_separation_um": float(separation), - "peak_positions_um": None, - "peak_separation_um": None, - "valley_depth": None, - } - if len(selected) == 2: - left, right = selected - weaker_peak = min(profile[left], profile[right]) - if weaker_peak > 0: - result.update( - peak_positions_um=distance[selected].tolist(), - peak_separation_um=float(distance[right] - distance[left]), - valley_depth=float(1 - profile[left : right + 1].min() / weaker_peak), - ) - return result diff --git a/tests/simulation/__init__.py b/tests/simulation/__init__.py new file mode 100644 index 0000000..517a97e --- /dev/null +++ b/tests/simulation/__init__.py @@ -0,0 +1 @@ +"""One physical acquisition simulator and its test specimen setup.""" diff --git a/tests/simulation/camera.py b/tests/simulation/camera.py new file mode 100644 index 0000000..1d92a14 --- /dev/null +++ b/tests/simulation/camera.py @@ -0,0 +1,57 @@ +"""Unsigned camera digitization after photon detection and Gaussian read noise.""" + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class Camera: + """Electron-domain read noise and explicit digitization calibration.""" + + read_noise_e: float + electrons_per_adu: float + offset_adu: float + bit_depth: int + + +def digitize( + expected_electrons: np.ndarray, + camera: Camera, + *, + seed: int = 42, +) -> np.ndarray: + """Apply photon statistics, electron read noise, gain/offset and integer ADC. + + Parameters + ---------- + expected_electrons + Photoelectron expectation after sensor integration and QE. + camera + Camera calibration and read-noise parameters. + seed + Seed for one complete camera-noise realization. + + Returns + ------- + numpy.ndarray + Unsigned integer ADC pixels. The camera offset remains in the image; + read noise may produce values below it. No signed image is exported. + """ + if ( + not np.isfinite(expected_electrons).all() + or np.any(expected_electrons < 0) + or camera.electrons_per_adu <= 0 + or camera.read_noise_e < 0 + or not 1 <= camera.bit_depth <= 16 + ): + raise ValueError("Require nonnegative means/noise and positive camera gain") + rng = np.random.default_rng(seed) + detected = rng.poisson(expected_electrons) + charge = detected + rng.normal(0, camera.read_noise_e, detected.shape) + raw = np.clip( + np.rint(camera.offset_adu + charge / camera.electrons_per_adu), + 0, + 2**camera.bit_depth - 1, + ).astype(np.uint16) + return raw diff --git a/tests/simulation/camera_likelihood.py b/tests/simulation/camera_likelihood.py new file mode 100644 index 0000000..e8adc3f --- /dev/null +++ b/tests/simulation/camera_likelihood.py @@ -0,0 +1,128 @@ +"""Independent camera probabilities for physical validation, in recorded ADU. + +Photon arrival is Poisson; electron read noise is Gaussian; the ADC rounds and +saturates. Likelihoods retain the offset and include both censored edge codes. +They are never evaluated on clipped, dark-subtracted pseudo-photon counts. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from scipy.special import gammaln, log_ndtr, logsumexp, ndtri, xlogy +from scipy.stats import poisson + +if TYPE_CHECKING: + from numpy.typing import ArrayLike + + from tests.simulation.camera import Camera + + +def camera_log_likelihood( + raw: ArrayLike, + expectation: ArrayLike, + camera: Camera, + *, + tail_probability: float = 1e-13, +) -> np.ndarray: + """Evaluate log P(ADC=raw | expected photoelectrons), pixel by pixel. + + For code a, sum Pois(n|lambda) times the Gaussian probability in its + quantization interval [(a-.5-offset)g, (a+.5-offset)g]. Code zero has an + infinite lower interval; the maximum code has an infinite upper interval. + The only approximation is a Poisson tail of total probability <= the + specified bound. Summation in log space preserves rare-event likelihoods. + + Parameters + ---------- + raw : array-like + Unsigned digitized ADC values. + expectation : array-like + Expected detected photoelectrons before read noise and digitization. + camera : Camera + Gain, offset, read-noise RMS and ADC depth. + tail_probability : float + Maximum omitted Poisson probability in the latent-count sum. + + Returns + ------- + numpy.ndarray + Pixelwise log probabilities of the observed ADC values. + """ + codes, rates = np.broadcast_arrays( + np.asarray(raw), np.asarray(expectation, np.float64) + ) + maximum = 2**camera.bit_depth - 1 + if ( + np.any(codes != np.rint(codes)) + or np.any(codes < 0) + or np.any(codes > maximum) + or not np.isfinite(rates).all() + or np.any(rates < 0) + or camera.read_noise_e < 0 + or camera.electrons_per_adu <= 0 + or not 0 < tail_probability < 1 + ): + raise ValueError( + "Require recorded ADC codes, nonnegative rates and valid camera parameters" + ) + result = np.empty(codes.size, dtype=np.float64) + # Detector intervals depend on the recorded code, not the predicted rate. + # Group similar rate/code scales to reuse probabilities. Each group's + # Poisson upper limit uses its largest rate, so the SAME absolute tail bound + # holds for every pixel by Poisson stochastic ordering. Every sum still + # starts at zero, retaining rare low-code events even under a bright model. + # Include the recorded interval even under a dim prediction: a rare bright + # code can require n far beyond that prediction's Poisson upper quantile. + adc_upper = np.maximum( + (codes.ravel().astype(np.float64) + 0.5 - camera.offset_adu) + * camera.electrons_per_adu, + 0, + ) + exponents = np.frexp(np.maximum(rates.ravel(), adc_upper))[1] + order = np.lexsort((codes.ravel(), exponents)) + boundaries = np.flatnonzero(np.diff(exponents[order])) + 1 + for group in np.split(order, boundaries): + stop = ( + int(poisson.isf(tail_probability, float(np.max(rates.ravel()[group])))) + 1 + ) + stop = max( + stop, + int( + np.ceil( + np.max(adc_upper[group]) + - camera.read_noise_e * ndtri(tail_probability) + ) + ), + ) + electrons = np.arange(stop + 1, dtype=np.float64) + log_factorial = gammaln(electrons + 1) + batch = max(1, 262144 // electrons.size) + for start in range(0, group.size, batch): + indices = group[start : start + batch] + adc, inverse = np.unique(codes.ravel()[indices], return_inverse=True) + adc = adc[:, None] + rate = rates.ravel()[indices, None] + if camera.read_noise_e == 0: + digitized = np.clip( + np.rint(camera.offset_adu + electrons / camera.electrons_per_adu), + 0, + maximum, + ) + log_interval = np.where(adc == digitized, 0, -np.inf) + else: + lower = (adc - 0.5 - camera.offset_adu) * camera.electrons_per_adu + upper = (adc + 0.5 - camera.offset_adu) * camera.electrons_per_adu + lower = np.where(adc == 0, -np.inf, lower) + upper = np.where(adc == maximum, np.inf, upper) + lo = (lower - electrons) / camera.read_noise_e + hi = (upper - electrons) / camera.read_noise_e + # Reflect positive intervals to avoid subtracting CDFs near one. + log_hi = log_ndtr(np.where(lo > 0, -lo, hi)) + log_lo = log_ndtr(np.where(lo > 0, -hi, lo)) + with np.errstate(divide="ignore", invalid="ignore"): + log_interval = log_hi + np.log(-np.expm1(log_lo - log_hi)) + log_poisson = xlogy(electrons, rate) - rate - log_factorial + result[indices] = logsumexp(log_poisson + log_interval[inverse], axis=1) + return result.reshape(codes.shape) diff --git a/tests/simulation/forward_model.py b/tests/simulation/forward_model.py new file mode 100644 index 0000000..102fa09 --- /dev/null +++ b/tests/simulation/forward_model.py @@ -0,0 +1,509 @@ +"""One fluorescence-to-camera model for test fixtures and synthetic comparisons. + +For fine-grid emitted-photon density f, excitation L and detection PSF h, +lambda = QE * exposure * integral_pixel[(f L) * h] + background. +Convolution includes the fine voxel volume; pixel integration includes sensor +area. Detection is Poisson(lambda), followed by electron read noise, gain, +offset and unsigned ADC quantization. The PSF has unit focal-plane integral +times the declared collection efficiency, never a gain fitted to an image. +An optional camera-plane photon budget chooses source and background emission +doses from deterministic unit-dose responses before QE and camera sampling. +It never rescales a camera realization or a reconstruction. + +Oblique coordinates are X=c, Y=s+r*cos(theta), Z=r*sin(theta). Cartesian +comparison stacks use ZYX coordinates and XY sensor pixels. Neither route +resamples the object before optical convolution. This shift-invariant model +assumes static excitation during a scan; it does not simulate a moving sheet. +Optical generation and pixel quadrature use production opmpsf functions. The +inverse kernel samples the same cached optical field used to blur ground truth. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from typing import TYPE_CHECKING, Literal + +import numpy as np +from scipy.signal import fftconvolve + +from opm_processing.imageprocessing.opmpsf import ( + create_psf_silicone_100x, + integrate_camera_pixels, +) +from tests.simulation.camera import Camera, digitize +from tests.simulation.objects import centered_axis + +if TYPE_CHECKING: + from collections.abc import Sequence + + +@dataclass(frozen=True) +class Microscope: + """Optics, detector geometry and camera calibration; lengths are micrometers.""" + + fine_spacing_um: float + pixel_size_um: float + scan_step_um: float + angle_deg: float + wavelength_um: float + numerical_aperture: float + detection_efficiency: float + quantum_efficiency: float + pixel_samples: int + psf_half_extent_zyx_um: tuple[float, float, float] + camera: Camera + illumination_edge_fraction: float + scan_endpoint_divisor: int + psf_parameters: tuple[tuple[str, float], ...] + psf_depth_um: float + + def __post_init__(self) -> None: + """Keep explicitly supplied PSF calibration immutable for optical caching.""" + object.__setattr__( + self, + "psf_parameters", + tuple((str(k), float(v)) for k, v in self.psf_parameters), + ) + + +@dataclass(frozen=True) +class Acquisition: + """Physical GT axes and exposure metadata for one acquisition. + + GT is emitted photons/exposure/um^3 on uniform isotropic Cartesian ZYX + axes. Sensor axes are SYX for oblique scans and ZYX for Cartesian stacks; + explicit axes allow sparse scans, tiles and shifted origins. With only a + shape, centered axes use the microscope's scan step and pixel pitch. + ``expectation`` returns electron means; ``irradiance`` returns the fine + pre-sensor optical field for plotting. ``object`` returns the illuminated + emission density after choosing the source/background doses and exposure, + before optical blur or QE. Default output is unsigned raw ADU. + + Optional ``camera_photon_budget=(source_peak, background_center)`` specifies + incident photons/pixel/exposure, before QE. In this mode GT specifies source + geometry. Its emission dose and a uniform fluorescent-background density + are chosen using their noiseless optical responses, before detection. The + background traverses the same illumination, PSF and sensor integration. + No noisy image is rescaled. Exposure multiplies both nominal photon doses. + """ + + gt_axes: tuple[np.ndarray, ...] + shape: tuple[int, int, int] | None = None + sensor_axes: tuple[np.ndarray, ...] | None = None + geometry: Literal["oblique", "cartesian"] = "oblique" + illumination_profile: str = "uniform" + illumination_half_width_um: float | None = None + exposure: float = 1.0 + background_electrons: float = 0.0 + seed: int = 42 + output: Literal["camera", "expectation", "irradiance", "object"] = "camera" + camera_photon_budget: tuple[float, float] | None = None + + +def excitation_field( + x_um: np.ndarray, + half_width_um: float, + profile: str = "uniform", + *, + edge_fraction: float, +) -> np.ndarray: + """Positive excitation with the configured taper at both camera-X ends. + + Parameters + ---------- + x_um : numpy.ndarray + Camera-X coordinates in micrometers. + half_width_um : float + Physical half-width of the excitation field. + profile : str + Uniform excitation or the configured edge-taper profile. + edge_fraction : float + Positive excitation fraction at either long-axis endpoint. + + Returns + ------- + numpy.ndarray + Dimensionless float32 illumination at the supplied coordinates. + """ + if profile == "uniform": + return np.ones_like(x_um, dtype=np.float32) + if profile != "edge-taper" or half_width_um <= 0: + raise ValueError("Require uniform or edge-taper and a positive image width") + coordinate = np.clip(x_um / half_width_um, -1, 1) + if not 0 < edge_fraction <= 1: + raise ValueError("Illumination edge fraction must be in (0,1]") + return ( + edge_fraction + (1 - edge_fraction) * np.cos(np.pi * coordinate / 2) ** 2 + ).astype(np.float32) + + +@lru_cache(maxsize=4) +def emission_psf( + microscope: Microscope, spacing: float +) -> tuple[tuple[np.ndarray, ...], np.ndarray]: + """Finite vectorial detection density, normalized by its focal-plane area. + + Parameters + ---------- + microscope : Microscope + Explicit optical calibration and collection efficiency. + spacing : float + Cartesian integration-grid spacing in micrometers. + + Returns + ------- + tuple[tuple of numpy.ndarray, numpy.ndarray] + Physical ZYX axes and vectorial detection density. + """ + extent = np.asarray(microscope.psf_half_extent_zyx_um, dtype=float) + optics = dict(microscope.psf_parameters) + if not {"ni0", "ni", "ns", "tg0", "tg", "ti0"} <= optics.keys(): + raise ValueError( + "Supply immersion/sample indices and coverglass/objective calibration" + ) + if ( + not np.isfinite(spacing) + or spacing <= 0 + or extent.shape != (3,) + or not np.isfinite(extent).all() + or np.any(extent <= 0) + or extent[1] != extent[2] + ): + raise ValueError("Require positive spacing and ZYX extents with equal XY") + if ( + not np.isfinite(microscope.wavelength_um) + or microscope.wavelength_um <= 0 + or not 0 < microscope.numerical_aperture <= optics["ni0"] + ): + raise ValueError( + "Require a positive wavelength and NA at most the supplied immersion RI" + ) + axes = tuple(centered_axis(e, spacing) for e in extent) + psf = np.asarray( + create_psf_silicone_100x( + spacing, + spacing, + axes[2].size, + axes[0].size, + microscope.wavelength_um, + microscope.psf_depth_um, + parameters={ + "lens": { + **optics, + "NA": microscope.numerical_aperture, + "sf": 1, + "mode": 1, + }, + "taper_sigma_px_3d": np.inf, + }, + ), + dtype=np.float64, + ) + psf /= psf.sum() + psf *= microscope.detection_efficiency / (psf[len(axes[0]) // 2].sum() * spacing**2) + psf = psf.astype(np.float32) + psf.setflags(write=False) + return axes, psf + + +def sensor_integral( + optical_field: np.ndarray, + axes: Sequence[np.ndarray], + shape: Sequence[int] | None, + microscope: Microscope, + *, + area_integral: bool = True, + sensor_axes: Sequence[np.ndarray] | None = None, + geometry: str = "oblique", +) -> np.ndarray: + """Pixel quadrature primitive, also used for analytic sensor unit checks. + + Parameters + ---------- + optical_field : numpy.ndarray + Blurred Cartesian emission density. + axes : sequence of numpy.ndarray + Physical Cartesian ZYX grid axes in micrometers. + shape : sequence of int or None + Requested detector-grid dimensions; None uses sensor_axes. + microscope : Microscope + Pixel pitch, scan geometry and quadrature settings. + area_integral : bool + Integrate pixel area when True; otherwise evaluate sampled intensity. + sensor_axes : sequence of numpy.ndarray or None + Explicit detector coordinates for analytic sampling checks. + geometry : str + Oblique acquisition or Cartesian detector sampling. + + Returns + ------- + numpy.ndarray + Sensor-integrated intensities on the declared acquisition grid. + """ + p = microscope + if not isinstance(p.pixel_samples, int) or p.pixel_samples < 1: + raise ValueError("Pixel quadrature samples must be positive integers") + if geometry not in ("oblique", "cartesian"): + raise ValueError("Require oblique or cartesian sensor geometry") + if sensor_axes is None: + if ( + shape is None + or len(shape) != 3 + or any( + isinstance(n, bool) or not isinstance(n, (int, np.integer)) or n < 1 + for n in shape + ) + ): + raise ValueError( + "Require three positive sensor dimensions or explicit axes" + ) + sensor_axes = tuple( + (np.arange(n) - (n - 1) / 2) * step + for n, step in zip( + shape, (p.scan_step_um, p.pixel_size_um, p.pixel_size_um), strict=True + ) + ) + if len(sensor_axes) != 3 or any( + np.ndim(a) != 1 or not len(a) or not np.isfinite(a).all() for a in sensor_axes + ): + raise ValueError("Sensor axes must be three nonempty finite coordinate arrays") + if shape is not None and tuple(shape) != tuple(map(len, sensor_axes)): + raise ValueError("Sensor shape and coordinate axes disagree") + return integrate_camera_pixels( + optical_field, + axes, + sensor_axes, + pixel_size_um=p.pixel_size_um, + theta_deg=p.angle_deg, + samples=p.pixel_samples, + geometry=geometry, + area_integral=area_integral, + ) + + +def camera_psf(microscope: Microscope) -> tuple[np.ndarray, float]: + """Integrate the simulation's production optical PSF onto the camera grid. + + Parameters + ---------- + microscope : Microscope + Explicit optics, camera pixel pitch and scan sampling. + + Returns + ------- + tuple[numpy.ndarray, float] + Unit-mass oblique kernel and its physical integrated detection factor. + """ + p = microscope + axes, psf = emission_psf(p, p.fine_spacing_um) + # Full optical convolution interpolates the final PSF sample towards the + # next, zero-valued fine-grid sample. Extend the kernel identically before + # pixel quadrature; filling out-of-bounds queries with zero would instead + # cut off that interpolation interval and change the camera response. + psf = np.pad(psf, 1) + axes = tuple( + np.r_[a[0] - p.fine_spacing_um, a, a[-1] + p.fine_spacing_um] for a in axes + ) + theta = np.deg2rad(p.angle_deg) + hz, hy, hx = (a[-1] for a in axes) + steps = (p.scan_step_um, p.pixel_size_um, p.pixel_size_um) + shape = tuple( + len(centered_axis(e, d)) + for e, d in zip( + ( + hy + hz / np.tan(theta), + hz / np.sin(theta) + p.pixel_size_um / 2, + hx + p.pixel_size_um / 2, + ), + steps, + strict=True, + ) + ) + sensor_axes = tuple( + (np.arange(n) - (n - 1) / 2) * step + for n, step in zip(shape, steps, strict=True) + ) + probabilities = ( + sensor_integral(psf, axes, shape, p, sensor_axes=sensor_axes) + * p.quantum_efficiency + ) + total = probabilities.sum(dtype=np.float64) + volume = np.prod(steps) * np.sin(theta) + return probabilities / total, float(total * volume) + + +def forward_model( + ground_truth: np.ndarray, metadata: Acquisition, microscope: Microscope +) -> np.ndarray: + """Return simulated data from GT, acquisition metadata and microscope setup. + + Parameters + ---------- + ground_truth : numpy.ndarray + Nonnegative emitted-photon density on the fine Cartesian ZYX grid. + metadata : Acquisition + GT axes, detector sampling, exposure, background and requested output. + microscope : Microscope + Explicit optics, oblique geometry, illumination, QE and ADC calibration. + + Returns + ------- + numpy.ndarray + Unsigned digitized camera data, or the requested noiseless intermediate. + + Raises + ------ + ValueError + Grid, density, exposure or detector calibration is nonphysical. + """ + axes = tuple(np.asarray(a, dtype=float) for a in metadata.gt_axes) + if len(axes) != 3 or ground_truth.shape != tuple(map(len, axes)): + raise ValueError("GT must match three Cartesian ZYX coordinate axes") + if any(a.ndim != 1 or len(a) < 2 or not np.isfinite(a).all() for a in axes): + raise ValueError("GT axes must contain at least two finite coordinates") + # Use the declared grid pitch for both the simulated optical field and its + # inverse kernel. Cropping or translating GT must not alter PSF support + # through floating-point cancellation in inferred coordinate differences. + spacing = microscope.fine_spacing_um + if spacing <= 0 or any( + not np.allclose(np.diff(a), spacing, rtol=1e-8, atol=1e-12) for a in axes + ): + raise ValueError("GT requires a uniform isotropic fine Cartesian grid") + if not np.isfinite(ground_truth).all() or np.any(ground_truth < 0): + raise ValueError("GT photon density must be finite and nonnegative") + values = (metadata.exposure, metadata.background_electrons) + if not np.isfinite(values).all() or min(values) < 0: + raise ValueError("Exposure and background must be finite and nonnegative") + if ( + not 0 <= microscope.quantum_efficiency <= 1 + or not 0 < microscope.detection_efficiency <= 1 + ): + raise ValueError("QE and collection efficiency must be probabilities") + if ( + not np.isfinite( + (microscope.angle_deg, microscope.pixel_size_um, microscope.scan_step_um) + ).all() + or not 0 < microscope.angle_deg < 90 + or min(microscope.pixel_size_um, microscope.scan_step_um) <= 0 + or spacing >= microscope.pixel_size_um + ): + raise ValueError( + "Require positive sampling, a finer GT grid and an angle between 0 and 90" + ) + if metadata.output not in ("camera", "expectation", "irradiance", "object"): + raise ValueError("Require camera, expectation, irradiance or object output") + if metadata.output == "object" and metadata.background_electrons: + raise ValueError( + "An additive detector background has no emission-density truth" + ) + budget = metadata.camera_photon_budget + if budget is not None and ( + len(budget) != 2 + or not np.isfinite(budget).all() + or min(budget) < 0 + or metadata.output == "irradiance" + ): + raise ValueError( + "Require two nonnegative camera photon doses and sensor output" + ) + # Excitation belongs to this individual camera image, including its stage + # origin; a translated tile must not inherit the laboratory-zero taper. + center_x = 0.0 + half_width = metadata.illumination_half_width_um + if metadata.sensor_axes is not None: + columns = np.asarray(metadata.sensor_axes[2]) + center_x = float((columns[0] + columns[-1]) / 2) + if half_width is None: + half_width = max( + float(np.max(np.abs(columns - center_x))), microscope.pixel_size_um / 2 + ) + elif metadata.shape is not None and half_width is None: + half_width = max( + (metadata.shape[2] - 1) * microscope.pixel_size_um / 2, + microscope.pixel_size_um / 2, + ) + if half_width is None: + half_width = ( + np.ceil(max(abs(axes[2] - center_x)) / microscope.pixel_size_um) + * microscope.pixel_size_um + ) + illumination = excitation_field( + axes[2][None, None, :] - center_x, + half_width, + metadata.illumination_profile, + edge_fraction=microscope.illumination_edge_fraction, + ) + psf_axes, psf = emission_psf(microscope, spacing) + # The object domain is not the camera field of view: light from a source + # near a GT boundary still reaches pixels outside that boundary. Retain the + # full convolution halo for acquisitions. Fine-field plotting explicitly + # requests the original GT grid instead. + incident = None + object_density = ( + np.zeros_like( + ground_truth, dtype=np.result_type(ground_truth.dtype, np.float32) + ) + if metadata.output == "object" + else None + ) + components = ( + (ground_truth,) + if budget is None + else (ground_truth, np.ones_like(ground_truth)) + ) + for index, density in enumerate(components): + irradiance = np.maximum( + fftconvolve( + density * illumination, + psf, + mode="same" if metadata.output == "irradiance" else "full", + ) + * spacing**3, + 0, + ) + if metadata.output == "irradiance": + return irradiance * metadata.exposure + optical_axes = tuple( + source[0] + kernel[0] + np.arange(size) * spacing + for source, kernel, size in zip( + axes, psf_axes, irradiance.shape, strict=True + ) + ) + response = sensor_integral( + irradiance, + optical_axes, + metadata.shape, + microscope, + sensor_axes=metadata.sensor_axes, + geometry=metadata.geometry, + ) + dose = 1.0 + if budget is not None: + # Select physical emission densities from the known optical model: + # a = source_peak / max(H[L f]); b = background_center / H[L 1]_center. + # These are input doses, shared by every reconstruction method. + reference = ( + float(response.max()) + if index == 0 + else float(response[tuple(n // 2 for n in response.shape)]) + ) + if reference <= 0 and budget[index] > 0: + raise ValueError( + "Camera photon budget requires a nonzero optical response" + ) + dose = budget[index] / reference if reference > 0 else 0 + response *= dose + if object_density is not None: + object_density += density * illumination * (dose * metadata.exposure) + if incident is None: + incident = response + else: + incident += response + if object_density is not None: + return object_density + expected = incident * (microscope.quantum_efficiency * metadata.exposure) + expected += metadata.background_electrons + if metadata.output == "expectation": + return expected + return digitize(expected, microscope.camera, seed=metadata.seed) diff --git a/tests/simulation/metrics.py b/tests/simulation/metrics.py new file mode 100644 index 0000000..bd0a7d6 --- /dev/null +++ b/tests/simulation/metrics.py @@ -0,0 +1,243 @@ +"""Likelihood and resolution measurements for simulated camera acquisitions.""" + +import numpy as np +from scipy import ndimage, signal +from scipy.ndimage import map_coordinates +from scipy.signal import find_peaks +from scipy.special import j1, jn_zeros, kl_div, xlogy + + +def poisson_log_ratio_moments(expectation, prediction): + """Exact mean and variance of log p(N|lambda)/p(N|mu), N~Pois(lambda). + + Pixels are independent. Preserve impossible hypotheses and deterministic + dark pixels without introducing a photon floor. SciPy evaluates the KL loss. + + Parameters + ---------- + expectation : array-like + True nonnegative photon rates for independent pixels. + prediction : array-like + Alternative rates on the same detector grid. + + Returns + ------- + tuple[float, float] + Expected total log-likelihood loss and its shot-noise variance. + """ + truth, predicted = np.broadcast_arrays( + np.asarray(expectation, dtype=np.float64), + np.asarray(prediction, dtype=np.float64), + ) + if ( + not np.isfinite(truth).all() + or not np.isfinite(predicted).all() + or np.any(truth < 0) + or np.any(predicted < 0) + ): + raise ValueError("Photon rates must be finite and nonnegative") + if np.any((truth > 0) & (predicted == 0)): + return float("inf"), float("inf") + positive = truth > 0 + rate, alternative = truth[positive], predicted[positive] + log_ratio = np.log(rate) - np.log(alternative) + return float(kl_div(truth, predicted).sum()), float(np.sum(rate * log_ratio**2)) + + +def camera_score_difference(raw, prediction, baseline, camera): + """Score independent camera observations without a brightness-dependent grid. + + Ideal integer observations use the exact Poisson log ratio; its factorial + cancels. Gaussian/ADC observations use an independent CPU summation of + the physical detector probabilities. This is a validation metric only. + + Parameters + ---------- + raw : array-like + Independent digitized camera observations. + prediction : array-like + Predicted detected photoelectrons before digitization. + baseline : array-like + Reference prediction in the same units. + camera : Camera + Explicit physical camera calibration. + + Returns + ------- + numpy.ndarray + Pixelwise log-likelihood advantage over the baseline. + """ + if ( + camera.read_noise_e == 0 + and camera.electrons_per_adu == 1 + and camera.offset_adu == 0 + and np.all(np.asarray(raw) < 2**camera.bit_depth - 1) + ): + return xlogy(raw, prediction) - xlogy(raw, baseline) - prediction + baseline + from tests.simulation.camera_likelihood import camera_log_likelihood + + return camera_log_likelihood(raw, prediction, camera) - camera_log_likelihood( + raw, baseline, camera + ) + + +def predictive_deviance(density, expectation, psf, *, boundary="constant", response=1): + """Score absolute expected camera counts, including background, without a fit. + + Finite float32 kernels and FFT roundoff may create numerically zero tails. + The lower bound is float64's smallest positive *normal*, not a fitted floor. + A positive ground-truth rate predicted as zero receives its full log penalty. + + Parameters + ---------- + density : array-like + Reconstructed object-grid intensities. + expectation : array-like + Independently simulated expected detector counts. + psf : numpy.ndarray + Centered optical kernel on the object grid. + boundary : str + SciPy boundary mode; constant uses zero-extended FFT convolution. + response : array-like or float + Detector response applied after blur. + + Returns + ------- + float + Summed absolute Poisson deviance of the detector prediction. + """ + if boundary == "constant": + blurred = signal.fftconvolve(density, psf, mode="same") + elif boundary == "reflect": + # Symmetric extension is SciPy ndimage's reflect boundary. Including + # the full kernel halo makes valid FFT convolution exactly equivalent, + # without the direct convolution's per-voxel full-PSF work. + pads = tuple(((size - 1) // 2, size // 2) for size in psf.shape) + blurred = signal.fftconvolve( + np.pad(density, pads, mode="symmetric"), psf, mode="valid" + ) + else: + blurred = ndimage.convolve(density, psf, mode=boundary) + prediction = np.maximum(blurred * response, np.finfo(float).tiny) + return float(2 * kl_div(expectation, prediction).sum(dtype=np.float64)) + + +def first_minimum(distance, intensity): + """Return the first sampled minimum after a centered PSF's principal maximum. + + A profile without a minimum inside its modeled support has no measurable + Rayleigh separation; it must not receive the lateral Airy value by default. + Samples must be ordered physical distances from the central maximum. + + Parameters + ---------- + distance : array-like + Increasing physical distances from the PSF center. + intensity : array-like + Single-emitter optical intensity along that direction. + + Returns + ------- + float + Distance to the first sampled local minimum. + """ + distance, intensity = np.asarray(distance), np.asarray(intensity) + if ( + distance.ndim != 1 + or intensity.shape != distance.shape + or np.any(np.diff(distance) <= 0) + ): + raise ValueError("Require a sampled PSF along increasing physical distance") + peaks, _ = find_peaks(-intensity) + candidates = peaks[distance[peaks] > 0] + if not candidates.size: + raise ValueError("PSF support does not contain a directional first minimum") + index = int(candidates[0]) + return float(distance[index]) + + +def airy_rayleigh(wavelength_um, numerical_aperture): + """Scalar circular-pupil reference: exact Bessel first zero and pair contrast. + + Parameters + ---------- + wavelength_um : float + Emission wavelength in micrometers. + numerical_aperture : float + Circular-pupil numerical aperture. + + Returns + ------- + tuple[float, float] + Rayleigh separation in micrometers and equal-pair valley contrast. + """ + root = float(jn_zeros(1, 1)[0]) + separation = root * wavelength_um / (2 * np.pi * numerical_aperture) + # Two equal incoherent points at separation r: midpoint 2 h(r/2), peak 1. + dip = 1 - 2 * (2 * j1(root / 2) / (root / 2)) ** 2 + return separation, float(dip) + + +def two_point_profile(volume, center_syx, offset_syx, microscope): + """Measure peaks and valley along known laboratory-coordinate emitter positions. + + Search within half a pair separation of each source. Four interpolated + samples per grid interval add no optical information; intensities are + never fitted. The profile alone does not define a resolution criterion. + + Parameters + ---------- + volume : numpy.ndarray + Intensity image in oblique scan-YX coordinates. + center_syx : sequence of float + Pair midpoint in array-index coordinates. + offset_syx : sequence of float + Index displacement from the midpoint to either emitter. + microscope : Microscope + Recorded pixel pitch, scan step and oblique angle. + + Returns + ------- + dict + Physical profile coordinates, source positions, peak spacing and valley dip. + """ + offset = np.asarray(offset_syx, dtype=np.float64) + theta = np.deg2rad(microscope.angle_deg) + pixel_size = microscope.pixel_size_um + displacement_xyz = np.array( + ( + offset[2] * pixel_size, + offset[0] * microscope.scan_step_um + + offset[1] * pixel_size * np.cos(theta), + offset[1] * pixel_size * np.sin(theta), + ) + ) + separation = 2 * np.linalg.norm(displacement_xyz) + positions = np.linspace(-2, 2, int(np.ceil(16 * np.max(np.abs(offset)))) + 1) + distance = positions * separation / 2 + coordinates = np.asarray(center_syx)[:, None] + offset[:, None] * positions + profile = map_coordinates(volume, coordinates, order=1, mode="constant", cval=0) + peaks, _ = find_peaks(profile) + selected = [] + for expected in (-separation / 2, separation / 2): + candidates = peaks[np.abs(distance[peaks] - expected) < separation / 4] + if candidates.size: + selected.append(int(candidates[np.argmax(profile[candidates])])) + result = { + "distance_um": distance, + "profile": profile, + "true_separation_um": float(separation), + "peak_positions_um": None, + "peak_separation_um": None, + "valley_depth": None, + } + if len(selected) == 2: + left, right = selected + weaker_peak = min(profile[left], profile[right]) + if weaker_peak > 0: + result.update( + peak_positions_um=distance[selected].tolist(), + peak_separation_um=float(distance[right] - distance[left]), + valley_depth=float(1 - profile[left : right + 1].min() / weaker_peak), + ) + return result diff --git a/tests/simulation/objects.py b/tests/simulation/objects.py new file mode 100644 index 0000000..2de7847 --- /dev/null +++ b/tests/simulation/objects.py @@ -0,0 +1,148 @@ +"""Ground-truth construction only; no optics, detector sampling or noise.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def centered_axis(half_extent: float, spacing: float) -> np.ndarray: + """Cover a physical half extent with an odd grid centered on zero. + + Parameters + ---------- + half_extent + Physical distance from the centered grid origin to its edge. + spacing + Physical voxel spacing in ZYX order, in micrometers. + + Returns + ------- + np.ndarray + Odd-length physical coordinate axis centered at zero. + """ + radius = int(np.ceil(half_extent / spacing)) + return np.arange(-radius, radius + 1) * spacing + + +def meridian_sphere( + axes_zyx: Sequence[np.ndarray], + diameter_um: float, + tube_diameter_um: float, + meridians: int, +) -> np.ndarray: + """Construct uniform fluorescent tubes around meridian great-circle arcs. + + Diameter refers to the sphere traced by the tube centerlines; the outer + diameter includes one tube diameter. An even number of pole-to-pole arcs + forms meridians/2 full circles. Overlapping tubes form a union, not a sum. + + Parameters + ---------- + axes_zyx + Cartesian ZYX physical coordinate arrays, in micrometers. + diameter_um + Diameter of the meridian sphere, in micrometers. + tube_diameter_um + Diameter of each fluorescent meridian tube, in micrometers. + meridians + Number of equally spaced fluorescent meridian tubes. + + Returns + ------- + np.ndarray + Float32 fluorescence density for the union of the meridian tubes. + """ + if not 0 < tube_diameter_um < diameter_um: + raise ValueError("Require 0 < tube diameter < sphere diameter") + if not isinstance(meridians, int) or meridians < 2 or meridians % 2: + raise ValueError("meridians must be a positive even integer of at least 2") + z, y, x = np.meshgrid( + *[a.astype(np.float32) for a in axes_zyx], indexing="ij", sparse=True + ) + fluorescent = np.zeros(tuple(len(a) for a in axes_zyx), dtype=bool) + for phi in np.arange(meridians // 2) * 2 * np.pi / meridians: + perpendicular = -x * np.sin(phi) + y * np.cos(phi) + in_plane = x * np.cos(phi) + y * np.sin(phi) + distance_squared = ( + perpendicular**2 + (np.sqrt(in_plane**2 + z**2) - diameter_um / 2) ** 2 + ) + fluorescent |= distance_squared <= (tube_diameter_um / 2) ** 2 + return fluorescent.astype(np.float32) + + +def deposit_point( + density: np.ndarray, + axes: Sequence[np.ndarray], + center_xyz: Sequence[float], + photons: float, +) -> None: + """Deposit a point conservatively with its specified mass and centroid. + + Parameters + ---------- + density : numpy.ndarray + Mutable emitted-photon density on a uniform Cartesian ZYX grid. + axes : sequence of numpy.ndarray + Physical ZYX grid axes in micrometers. + center_xyz : sequence of float + Emitter position in physical XYZ micrometers. + photons : float + Total emitted photon mass deposited into neighboring voxels. + """ + spacing = float(axes[0][1] - axes[0][0]) + location = [ + (c - a[0]) / spacing for c, a in zip(center_xyz[::-1], axes, strict=True) + ] + lower = np.floor(location).astype(int) + fraction = np.asarray(location) - lower + for offset in np.ndindex(2, 2, 2): + weight = np.prod(np.where(offset, fraction, 1 - fraction)) + density[tuple(lower + offset)] += photons * weight / spacing**3 + + +def siemens_star( + axes_zyx: Sequence[np.ndarray], + radius_um: float, + thickness_um: float, + cycles: int, + center_radius_fraction: float, +) -> np.ndarray: + """MATLAB's sinusoidal Siemens target as a thin fluorescent XY sheet. + + Siemens_rdnoise_plot.m uses r=256, cos(100*atan2(Y,X)), and a constant + half-bright center of radius 30*sqrt(2) pixels. The pattern fills its square + field, including the corners. Here r=256 fine samples at 0.05 um gives + radius_um=12.8; the center radius retains the MATLAB ratio. Optical image + formation and camera noise belong to the shared forward_model, not here. + + Parameters + ---------- + axes_zyx : sequence of numpy.ndarray + Cartesian ZYX grid axes in micrometers. + radius_um : float + Physical half-width used to define the target center. + thickness_um : float + Fluorescent sheet thickness in micrometers. + cycles : int + Number of angular sinusoidal cycles. + center_radius_fraction : float + Half-bright central disk radius divided by radius_um. + + Returns + ------- + numpy.ndarray + Nonnegative fluorescent-sheet density on the supplied grid. + """ + if radius_um <= 0 or thickness_um <= 0 or cycles < 1 or int(cycles) != cycles: + raise ValueError("Require positive radius/thickness and integer cycles") + z, y, x = np.meshgrid(*axes_zyx, indexing="ij", sparse=True) + pattern = 0.5 * (1 + np.cos(cycles * np.arctan2(y, x))) + pattern = np.where( + np.hypot(y, x) <= radius_um * center_radius_fraction, 0.5, pattern + ) + return (pattern * (np.abs(z) < thickness_um / 2)).astype(np.float32) diff --git a/tests/simulation/scenes.py b/tests/simulation/scenes.py new file mode 100644 index 0000000..4eee87f --- /dev/null +++ b/tests/simulation/scenes.py @@ -0,0 +1,147 @@ +"""Shared physical specimens, acquisition grids and fixed analysis apertures.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from tests.simulation.forward_model import ( + Microscope, + emission_psf, +) +from tests.simulation.objects import ( + centered_axis, + deposit_point, + meridian_sphere, + siemens_star, +) + + +@dataclass +class OpticalScene: + """Known fine object and optical calibration for reconstruction comparisons.""" + + kind: str + microscope: Microscope + axes: tuple[np.ndarray, ...] + density: np.ndarray + psf_axes: tuple[np.ndarray, ...] + psf_irradiance: np.ndarray + illumination_profile: str + illumination_half_width_um: float + features: dict + + +def prepare_scene( + kind: str, + microscope: Microscope, + *, + parameters: dict, + illumination_profile: str = "uniform", +) -> OpticalScene: + """Build fine-grid points, paired lines, a hollow sphere or the MATLAB Siemens target. + + Parameters + ---------- + kind : str + Points, paired lines, meridian sphere or Siemens star. + microscope : Microscope + Explicit optical and acquisition calibration. + parameters : dict + Specimen extent, sampling and feature dimensions. + illumination_profile : str + Uniform illumination or a positive long-axis edge taper. + + Returns + ------- + OpticalScene + Fine-grid density, physical axes and shared optical calibration. + """ + spacing = microscope.fine_spacing_um + if spacing <= 0 or spacing >= microscope.pixel_size_um: + raise ValueError("Fine grid must be positive and finer than sensor pixels") + half_extent = parameters["half_extent_zyx_um"] + features = dict(parameters["features"]) + if kind == "line-pairs": + gaps = np.asarray(features["gaps_um"]) + starts = np.r_[0, np.cumsum(gaps[:-1] + features.pop("pair_gap_um"))] + positions = np.column_stack((starts, starts + gaps)).ravel() + positions -= (positions[0] + positions[-1]) / 2 + features["positions_x_um"] = positions.tolist() + if kind == "line-pairs": + features["pair_centers_x_um"] = ( + positions.reshape(-1, 2).mean(axis=1).tolist() + ) + elif kind not in ("points", "sphere", "siemens-star"): + raise ValueError("Unknown specimen kind") + axes = tuple(centered_axis(extent, spacing) for extent in half_extent) + density = np.zeros(tuple(len(a) for a in axes), np.float32) + z, y, x = np.meshgrid(*axes, indexing="ij", sparse=True) + if kind == "points": + for center in features["centers_xyz_um"]: + deposit_point(density, axes, center, 1.0) + elif kind == "line-pairs": + # Volume-average the finite cylinders before fixing their known mass. + for center in features["positions_x_um"]: + line = np.zeros_like(density) + samples = features["voxel_samples"] + if not isinstance(samples, int) or samples < 1: + raise ValueError("Specimen voxel_samples must be a positive integer") + for dz, dy, dx in np.ndindex(samples, samples, samples): + line += ( + (z + (dz - (samples - 1) / 2) * spacing / samples) ** 2 + + (x + (dx - (samples - 1) / 2) * spacing / samples - center) ** 2 + <= (features["diameter_um"] / 2) ** 2 + ) & ( + np.abs(y + (dy - (samples - 1) / 2) * spacing / samples) + <= features["length_um"] / 2 + ) + line *= features["length_um"] / (line.sum(dtype=np.float64) * spacing**3) + density += line + elif kind == "siemens-star": + density = siemens_star(axes, **features) + else: + density = meridian_sphere(axes, **features) + psf_axes, psf = emission_psf(microscope, spacing) + half_width = ( + np.ceil(axes[2][-1] / microscope.pixel_size_um) * microscope.pixel_size_um + ) + return OpticalScene( + kind, + microscope, + axes, + density, + psf_axes, + psf, + illumination_profile, + half_width, + features, + ) + + +def acquisition_shape(scene: OpticalScene) -> tuple[int, int, int]: + """Cover the full optical field with shared endpoints for factors 1, 3 and 4. + + Parameters + ---------- + scene : OpticalScene + Specimen support and declared acquisition sampling. + + Returns + ------- + tuple[int, int, int] + Scan-YX dimensions covering the scene with aligned scan endpoints. + """ + p = scene.microscope + theta = np.deg2rad(p.angle_deg) + hz, hy, hx = (axis[-1] for axis in scene.axes) + if p.scan_endpoint_divisor < 2 or p.scan_endpoint_divisor % 2: + raise ValueError("Scan endpoint divisor must be a positive even integer") + half_divisor = p.scan_endpoint_divisor // 2 + scan_radius = half_divisor * int( + np.ceil((hy + hz / np.tan(theta)) / p.scan_step_um / half_divisor) + ) + row_radius = int(np.ceil(hz / (p.pixel_size_um * np.sin(theta)))) + col_radius = int(np.ceil(hx / p.pixel_size_um)) + return 2 * scan_radius + 1, 2 * row_radius + 1, 2 * col_radius + 1 diff --git a/typos.toml b/typos.toml index 463f478..4343dcb 100644 --- a/typos.toml +++ b/typos.toml @@ -4,3 +4,7 @@ ome = "ome" OT = "OT" Ba = "Ba" iy = "iy" + +[default.extend-identifiers] +# NumPy's public array.flags attribute uses this spelling. +writeable = "writeable"