From 8b77be2443512fd071d89fe00667ccc30d67840d Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Thu, 16 Jul 2026 07:53:51 -0700 Subject: [PATCH 1/5] chore(vetting): squash pre-IO vetting/process housekeeping (not part of the IO series) Foundation branch for the main-io-reorg-* stack. Bundles oracle-vetting promotions for the moments/GLCM/GLRLM/GLSZM/GLDM/morphology/caliper feature families, a release-please manifest fix, and the CLAUDE.md workflow guide -- none of which are IO/OME work. Kept separate from the 4 IO PRs; should be reviewed/merged via its own track. Absorbed commits (oldest to newest): 73f6ca8 fix(moments): correct Hu invariant h5/h6 formulas (9x bracket, stray +eta03) a500bd9 ci(release-please): use manifest config so 0.x breaking = minor, and pin 0.12.0 79d0974 fix(3d): compute 3ROBUST_MEAN (was hardcoded 0) + vet 18 3D first-order features 60a1ebb fix(glcm): normalize HOM2/ENTROPY by sum_p (2D+3D) + fully oracle-vet GLCM d44dde7 test(glrlm): vet 12 2D gray-level-run joint-emphasis features vs IBSI cb07b9d test(glrlm): vet 14 3D _AVE features vs PyRadiomics -> glrlm 100% 285bc39 docs(vetting): record the config scope of the glrlm promotions 7db4532 test(glszm): vet 6 2D gray-level-zone joint-emphasis features vs IBSI -> glszm 100% c4bb8b1 docs(vetting): record the config scope of the glszm promotions d213b50 test(gldm): vet 14 2D grey-level-dependence features vs IBSI -> gldm 100% 0780912 docs(vetting): record the config scope of the gldm promotions (7 are mode-invariant) 0af100b fix(3d-shape): correct mislabeled axis lengths + vet 7 morphology features bc3e92a fix(caliper): correct Martin/Nassenstein diameters + vet 12 features vs imea 059998c test(morphology): vet Feret diameters vs imea + promote 6 features 701201b test(morphology): vet min-enclosing-circle diameter vs imea/cv2 965ab47 test(morphology): vet 8 EXTREMA coords vs matlab regionprops 992d5e6 test(morphology): promote FRACT_DIM_BOXCOUNT/PERIMETER (fraclac oracle) a9ddc15 test(morphology): vet FRACT_DIM (fraclac) + CIRCULARITY/ROUNDNESS (analytic) 3377183 fix(caliper): float-precision hull rotation + address PR #381 review cc5b4de docs(caliper): correct inaccurate/stale comments (no code change) b924f8e docs(vetting): retract unproven 3D NGLDM "ibsi" promotions; document MIRP disagreement e301fe6 docs: add CLAUDE.md codebase and workflow guide a548b57 docs(CLAUDE): add git workflow and tests/vetting conformance rules 466c038 docs(CLAUDE): address PR #388 review - C++20; agentic git workflow 4235e0c docs(CLAUDE): address PR #388 review - generalize paths, drop z5py pin, add no-local-paths rule --- .github/workflows/release-please.yml | 8 +- CLAUDE.md | 264 ++++++++++++ src/nyx/features/2d_geomoments_basic.cpp | 6 +- src/nyx/features/2d_geomoments_basic_nt.cpp | 6 +- src/nyx/features/3d_glcm.cpp | 11 +- src/nyx/features/3d_intensity.cpp | 25 ++ src/nyx/features/3d_surface.cpp | 118 +++--- src/nyx/features/caliper.h | 3 - src/nyx/features/caliper_feret.cpp | 9 +- src/nyx/features/caliper_martin.cpp | 221 ++++------ src/nyx/features/caliper_nassenstein.cpp | 189 +++------ src/nyx/features/glcm.cpp | 11 +- src/nyx/features/rotation.cpp | 35 +- src/nyx/features/rotation.h | 14 +- src/nyx/gpu/geomoments_hu.cu | 12 +- tests/test_3d_coverage_common.h | 18 +- tests/test_3d_glcm_pyradiomics.h | 65 +++ tests/test_3d_glrlm_pyradiomics.h | 60 +++ ...gldm_ibsi.h => test_3d_ngldm_regression.h} | 37 ++ tests/test_all.cc | 62 ++- tests/test_glcm_ibsi.h | 12 + tests/test_glcm_regression.h | 4 +- tests/test_glrlm_ibsi.h | 50 ++- tests/test_moments_common.h | 55 +++ tests/test_moments_regression.h | 8 +- tests/test_moments_skimage.h | 46 +- tests/test_morphology_common.h | 8 + tests/test_morphology_regression.h | 82 ++++ tests/test_remaining2d_common.h | 210 +++++++++- tests/vetting/MIGRATION.md | 10 +- tests/vetting/coverage_report.md | 18 +- tests/vetting/oracle_coverage.csv | 396 +++++++++--------- tests/vetting/oracles/gen_moments_skimage.py | 260 ++++++++++++ 33 files changed, 1727 insertions(+), 606 deletions(-) create mode 100644 CLAUDE.md rename tests/{test_3d_ngldm_ibsi.h => test_3d_ngldm_regression.h} (68%) create mode 100644 tests/vetting/oracles/gen_moments_skimage.py diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b8b4d7946..600de0ccb 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -20,8 +20,12 @@ jobs: - uses: google-github-actions/release-please-action@v4 id: release with: - release-type: python - package-name: nyxus + # Use the manifest config so release-please honors bump-minor-pre-major + # (0.x breaking changes -> minor, not a jump to 1.0.0). release-type/ + # package-name live in release-please-config.json; passing them inline put + # v4 on the non-manifest path and silently ignored the config. + config-file: release-please-config.json + manifest-file: .release-please-manifest.json - name: Checkout code if: ${{ steps.release.outputs.release_created }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..54a315536 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,264 @@ +# CLAUDE.md + +Guidance for Claude Code (and humans) working in this repository. + +## What Nyxus is + +Nyxus is a scalable C++/Python library that computes engineered features +(intensity, texture, morphology, moments, digital-filter) from segmented and +whole-slide/whole-volume images. It computes 450+ 2D features and a large 3D +feature set at the ROI or whole-image level, and is designed to operate at any +scale by assembling ROIs that span multiple image tiles and files. + +- **Python API**: classes `Nyxus` (2D), `Nyxus3D` (3D volumes), `Nested` + (hierarchical parent/child ROIs), `ImageQuality`. Results come back as a + pandas DataFrame; Arrow IPC and Parquet output are also supported. +- **CLI**: the `nyxus` executable (built with `-DBUILD_CLI=ON`). +- **IO**: 2D from OME-TIFF, OME-Zarr, DICOM; 3D from NIFTI (compressed and + uncompressed); in-memory NumPy arrays via the Python API. +- Upstream is [PolusAI/nyxus](https://github.com/PolusAI/nyxus). C++20, + CMake build system. Version is derived from git tags via `setuptools_scm`. + +## Repository layout + +``` +CMakeLists.txt top-level build; feature/IO/GPU options +pyproject.toml Python packaging (package-dir = src/nyx/python) +environment.yml conda deps +ci-utils/envs/ conda dep lists: conda_cpp.txt, conda_py.txt, conda_gpu.txt, ... +ci-utils/install_prereq_windows.bat / install_prereq_linux.sh non-conda dep builders +.github/workflows/ CI: build_and_test_{windows,ubuntu,mac}.yml + wheel/publish jobs +docs/ Sphinx docs (Read the Docs); docs/source/featurelist.rst +tests/python/ pytest suite (this is what CI runs) +tests/vetting/ feature-validation "oracle" framework (see below) +src/nyx/ all C++ sources +``` + +### `src/nyx/` — the C++ core + +- **Entry points**: `main_nyxus.cpp` (CLI `main()`), `python/new_bindings_py.cpp` + (pybind11 module), `python/nested_roi_py.cpp`. +- **Feature dispatch / registration** (the backbone — read these first when + adding or debugging a feature): + - `featureset.h` / `featureset.cpp` — the `Feature2D`, `Feature3D`, and + `FeatureIMQ` (image-quality) enums (canonical feature codes) and the + code↔name maps. Every feature has an enum entry here. + - `feature_method.h/.cpp` — `FeatureMethod` abstract base every feature class + derives from. Each feature declares `provide_features(...)` / + `add_dependencies(...)` and implements `calculate()` (trivial/in-RAM ROI) + and `osized_*()` (out-of-core ROI). + - `feature_mgr.cpp`, `feature_mgr.h` — the `FeatureManager`: registration, + 1:1 code-correspondence check, cyclic-dependency resolution, user-selection + compilation, `init_feature_classes()`. + - **`feature_mgr_init.cpp`** — the concrete registration list + (`register_feature(new GLCMFeature()); ...` for every 2D/3D feature). + **Start here to see what is actually wired in.** + - `env_features.cpp`, `environment*.cpp/h`, `feature_settings.h` — parsing the + requested feature set / groups (e.g. `*ALL*`, `*ALL_GLCM*`, `*WHOLESLIDE*`, + `*3D_ALL*`) and all run parameters. +- **Feature implementations**: `src/nyx/features/` (~100 files). Naming: + - 2D feature classes (e.g. `glcm.*`, `glrlm.*`, `basic_morphology.*`, + `2d_geomoments.*`, `gabor.*`, `caliper_*`, `convex_hull*`, `contour.*`, + `erosion.*`, `radial_distribution.*`, `zernike.*`). + - 3D feature classes are prefixed `3d_` (e.g. `3d_glcm.*`, `3d_intensity.*`, + `3d_surface.*`, `3d_gldzm.*`). + - Image-quality features (exposed via the `ImageQuality` Python class): + `focus_score.*`, `power_spectrum.*`, `saturation.*`, `sharpness.*`. + - `*_nontriv*.cpp` files hold the **out-of-core ("non-trivial ROI")** variant + of a feature — the streaming implementation used when an ROI is too large to + hold in RAM as a dense buffer. The in-RAM ("trivial") path and the + out-of-core path must produce identical values. +- **ROI model**: `roi_cache*.cpp/h` (`LR` = labeled ROI accumulators), + `nested_roi.*` (parent/child aggregation), `roi_blacklist.*` (`--skiproi`), + `cache.*` / `gpucache.cpp`. In-RAM ROI image in `features/image_matrix.*`; + out-of-core `OutOfRamPixelCloud` in `features/image_matrix_nontriv.*`; 3D in + `features/image_cube.h`. +- **Analysis pipeline** (three phases, declared in `globals.h`, dispatched from + `main_nyxus.cpp`): dataset drivers `workflow_2d_segmented.cpp`, + `workflow_2d_whole.cpp`, `workflow_3d_segmented.cpp`, `workflow_3d_whole.cpp`, + `workflow_pythonapi.cpp`; then `phase1.cpp` (gather ROI metrics, decide + trivial vs oversized), `phase2_2d.cpp` / `phase2_25d.cpp` / `phase2_3d.cpp` + (feature calc), `phase3.cpp` (output). ROIs over the memory limit go through + `processNontrivialRois()` + each feature's streaming + `osized_scan_whole_image()` / `osized_add_online_pixel()`; in-RAM ROIs go + through `processTrivialRois*()`. See also `reduce_trivial_rois.cpp`, + `pixel_feed.cpp`, `features_calc_workflow.cpp`. +- **Image loading**: `image_loader*.cpp/h`, `raw_image_loader.*`, and format + backends `raw_tiff.h`, `raw_omezarr.h`, `raw_dicom.h`, `raw_nifti.*`, + `grayscale_tiff.h`, `abs_tile_loader.h`; NIFTI support under `io/nifti/`. +- **GPU (CUDA)**: `src/nyx/gpu/*.cu/.cuh` — GPU kernels for the compute-heavy + features (Gabor, erosion, geometric moments). Guarded by `-DUSEGPU=ON` / + `USE_GPU`. Enabled at runtime with `--useGpu=true` (CLI) or the constructor. +- **Arrow/Parquet output**: `arrow_helpers.cpp`, `arrow_output_stream.*` + (guarded by `USE_ARROW`). +- **Helpers / 3rd-party**: `helpers/` (thread pool, FFT, timing, least-squares), + `3rdparty/` (quickhull, Jacobi eigensolver). + +### `src/nyx/python/` — Python layer + +- `new_bindings_py.cpp` — pybind11 bindings exposing the C++ backend as + `nyxus.backend`. +- `nyxus/__init__.py` — public exports: `Nyxus`, `Nyxus3D`, `Nested`, + `ImageQuality`, `gpu_is_available`, `get_gpu_properties`. +- `nyxus/nyxus.py` — the Python-facing wrapper classes (`featurize`, + `featurize_directory`, `featurize_files`, `get_params`/`set_params`, + `blacklist_roi`, …). +- `nyxus/functions.py` — GPU helper functions. + +## Building + +A C++20 compiler + CMake ≥ 3.20 are required. Key CMake options: + +| Option | Meaning | +|---|---| +| `-DBUILD_CLI=ON` | build the `nyxus` command-line executable | +| `-DBUILD_LIB=ON` | build the Python extension (`backend` target) | +| `-DALLEXTRAS=ON` | enable **all** IO: Zarr (z5) + DICOM (dcmtk) + Arrow | +| `-DNOEXTRAS=OFF` + `-DUSE_ARROW/USE_Z5/USE_DCMTK=ON` | enable IO backends selectively (NOEXTRAS defaults ON and force-disables them) | +| `-DUSEGPU=ON` | build CUDA GPU kernels (requires a CUDA toolkit matching the host compiler) | + +Deps are easiest via conda (`ci-utils/envs/conda_cpp.txt` + `conda_py.txt`); set +`NYXUS_DEP_DIR=$CONDA_PREFIX` (or `$CONDA_PREFIX/Library` on Windows) so CMake +finds them. The README "Building from source" section has full Linux/Windows and +Docker recipes. + +### Windows (MSVC + optional CUDA) + +Use **MSVC**, not gcc — conda-forge Windows packages are MSVC-ABI, so a gcc +build cannot link them. Full-feature + CUDA build (paths below are placeholders: +`$ENV` = your conda env prefix): + +1. Create the build env: + ``` + conda create -n nyxus_build -c conda-forge python=3.12 \ + --file ci-utils/envs/conda_cpp.txt --file ci-utils/envs/conda_py.txt + ``` +2. Configure with the **Ninja** generator so `nvcc` can use `cl` as its host + compiler (no CUDA MSBuild integration needed): run the Visual Studio + `vcvars64.bat` first, then `cmake -G Ninja -DCMAKE_CXX_COMPILER=cl + -DBUILD_CLI=ON -DALLEXTRAS=ON -DUSEGPU=ON -DCMAKE_PREFIX_PATH=$ENV\Library + -DNYXUS_DEP_DIR=$ENV\Library ..` then `cmake --build . --config Release`. +3. To run, put `$ENV\Library\bin` (dependency DLLs) and the CUDA toolkit `bin` + on PATH. + +**Fast Python-extension build** for running the test suite (tiff-only CPU): +same as above but `-DBUILD_LIB=ON -DALLEXTRAS=OFF -DUSEGPU=OFF +-DPYTHON_EXECUTABLE=$ENV\python.exe -Dpybind11_DIR=$ENV\Lib\site-packages\pybind11\share\cmake\pybind11` +and `--target backend`, with `-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE` pointed at +`src\nyx\python\nyxus` so the `.pyd` lands in the package. With `ALLEXTRAS=OFF` +the `@pytest.mark.arrow` tests fail with "Apache Arrow functionality is not +available" — expected, not a regression. To exercise Arrow/Parquet without a slow +CUDA build, use `-DNOEXTRAS=OFF -DUSE_ARROW=ON -DUSE_Z5=OFF -DUSE_DCMTK=OFF`. + +> Gotcha: in a Git Bash / MSYS shell, bare `cmd` resolves to an MSYS shim that +> silently no-ops `.bat` files and returns exit 0. Invoke `.bat` files via +> `& "$env:ComSpec" /c ` from the PowerShell tool, or the full-path +> `cmd.exe` under `%SystemRoot%\System32`. + +## Running tests + +**Python (`tests/python/`)** — the suite CI runs +(`.github/workflows/build_and_test_*.yml`): + +``` +python -m pytest tests/python/ -vv +``` + +Run the **whole directory**, not selected files — some bugs only appear as +cross-test state pollution (e.g. a mutable-default-argument leak surfaced only +because an alphabetically-earlier test seeded shared state). Selecting a single +test can hide such failures. Requires an importable `nyxus.backend` (`.pyd` on +`PYTHONPATH`, dependency DLLs on `PATH`). Markers (`pytest.ini`): `arrow`, +`serial`, `skip_ci`. Key files: `test_nyxus.py` (main API) plus the oracle / +regression suites listed under "Feature validation" below. + +**C++ (GoogleTest, `tests/`)** — target `runAllTests` (enabled from the +top-level `CMakeLists.txt`; gtest fetched via `tests/CMakeLists.txt` + +`CMakeLists.txt.gtest`). `tests/test_all.cc` is the single gtest `main` and +`#include`s the many `test_*.h` headers (they are intentionally **not** listed +in CMake — see the note in `tests/CMakeLists.txt`). Headers are grouped by +feature and by oracle: `test__ibsi.h` (IBSI conformance), +`test__pyradiomics.h`, `test__regression.h`, +`test__mechanics.h`, and `test_3d_*.h`; fixtures in `test_data.h`. +Images live in `tests/data/`. + +## Feature validation ("oracle") framework + +Correctness of feature values is validated against external reference +implementations ("oracles"), not just regression snapshots. This is a +first-class concern in this project. + +**Tests must conform to the framework specified in `tests/vetting/`.** The +governing documents are: + +- **`tests/vetting/SPEC.md`** — the authoritative spec. Defines the vocabulary, + the four test kinds (**oracle** / **regression** / **invariant** / + **mechanics**, kept in separate files), the rule that *vetting is a property of + a (feature × config × reference) assertion* (only oracle tests establish + vetting; regression tests never claim it), the tolerance policy, and the + authoring checklist for adding a vetted feature. Any new or changed test must + follow it — taxonomy, naming, tolerances, and registry update included. +- `tests/vetting/oracle_coverage.csv` — the single source of truth: one row per + assertion (`feature × config_recipe × oracle`) with its `outcome` + (`vetted` / `regression` / `invariant` / `not_tested` / `not_implemented` / + `dropped_invalid`), tolerance, backing test, and benchmark. A feature counts + as vetted iff it has ≥1 `vetted` oracle row. +- `tests/vetting/config_recipes.md` — the exact Nyxus + tool settings that make + a feature directly comparable to a reference (referenced by id from the + registry). `tests/vetting/matrix/.md` — the config-point matrix and + per-cell verdict. `tests/vetting/README.md`, `TOOLS.md`, `MIGRATION.md` — + supporting guides. +- Allowed oracle tokens (SPEC §4): pyradiomics, radiomicsj, skimage, mirp, + matlab, cellprofiler, mitk, feature2djava, wndcharm, imea, imagej, fraclac, + pydicom, ibsi, analytic. Oracle goldens are generated **offline** by a + checked-in generator (`tests/vetting/oracles/gen__.*`) and + pinned with full provenance — **reference tools are never CI runtime + dependencies**. +- `tests/vetting/check_coverage.py` validates the registry and regenerates + `coverage_report.md` (stdlib only). Naming: test files are + `test__.{h,py}` (e.g. `test_glcm_pyradiomics.py`, + `test_glcm_regression.h`, `test_glcm_mechanics.h`); functions carry the oracle + suffix so vetting status is self-evident. + +When you change a feature's math, re-vet it against its oracle at the recipe's +config and update the registry row — don't just re-baseline a snapshot. A +tolerance loose enough to pass a known-bad value is itself a test bug. + +## Conventions for changes + +- **Branches** are cut from and named after their base branch as + `main-` (not `fix/` or `feat/`). +- **Annotate changed source lines**: when fixing a defect, add a brief comment + on the changed line(s) explaining the defect and the fix, so the reason + survives in the code. +- Match the density, naming, and idiom of the surrounding code. +- **Run `pytest tests/python/` (the full directory) locally before proposing a + push** — it mirrors CI and catches cross-test pollution. +- The trivial (in-RAM) and non-trivial (out-of-core) implementations of a + feature must return identical values; if you touch one, check the other. +- **Any new or modified test must conform to the framework in + `tests/vetting/SPEC.md`** — correct test kind (oracle/regression/invariant/ + mechanics), naming, tolerance policy, and an updated `oracle_coverage.csv` + row. See "Feature validation" above. +- **Never commit local or machine-specific content into repository files.** No + absolute paths (`C:\Users\...`, `/home/...`), usernames, personal install + locations, or references to files that live only on one machine. Use + placeholders instead — `$ENV` / `%CONDA_PREFIX%` for the conda prefix, + ``, `%SystemRoot%`, `~`. Keep machine-specific setup in a local, + gitignored file, never in a tracked doc. Scan every doc for these before + committing. + +### Git + +- `main` is branch-protected — **never commit or push to `main` directly.** +- Claude may commit and push to non-`main` (feature/topic) branches as part of + an agentic workflow; final review and merge into `main` are the maintainers'. +- **Never add a `Co-Authored-By:` trailer** (or any similar attribution line) + to commit messages. + +## Docs + +Feature reference: `docs/source/featurelist.rst`. Rendered docs at +https://nyxus.readthedocs.io. The README has extensive Python/CLI usage +examples (directory/file/NumPy featurization, whole-slide, 3D, nested ROIs, +Hounsfield/CT handling, anisotropy, Arrow/Parquet output). diff --git a/src/nyx/features/2d_geomoments_basic.cpp b/src/nyx/features/2d_geomoments_basic.cpp index 29ab46d0b..ed288a173 100644 --- a/src/nyx/features/2d_geomoments_basic.cpp +++ b/src/nyx/features/2d_geomoments_basic.cpp @@ -241,10 +241,10 @@ std::tuple BasicGeomoms2 (_30 + _12) * (int_pow(_30 + _12, 2) - 3 * int_pow(_21 + _03, 2)) + (3 * _21 - _03) * (_21 + _03) * - (int_pow(3 * (_30 + _12), 2) - int_pow(_21 + _03, 2)); + (3 * int_pow(_30 + _12, 2) - int_pow(_21 + _03, 2)); // FIX: was int_pow(3*(_30+_12), 2) == 9*(eta30+eta12)^2; Hu's I5 second bracket is 3*(eta30+eta12)^2 - (eta21+eta03)^2 (cf. the correct bracket in h7 below) double h6 = (_20 - _02) * (int_pow(_30 + _12, 2) - - int_pow(_21 + _03, 2)) + (4 * _11 * (_30 + _12) * - _21 + _03); + int_pow(_21 + _03, 2)) + + 4 * _11 * (_30 + _12) * (_21 + _03); // FIX: was 4*_11*(_30+_12)*_21 + _03 - precedence error left "+_03" outside the product, so the stray raw eta03 term dominated h6; Hu's I6 last term is 4*eta11*(eta30+eta12)*(eta21+eta03) double h7 = (3 * _21 - _03) * (_30 + _12) * (int_pow(_30 + _12, 2) - 3 * int_pow(_21 + _03, 2)) - (_30 - 3 * _12) * (_21 + _03) * (3 * int_pow(_30 + _12, 2) - int_pow(_21 + _03, 2)); diff --git a/src/nyx/features/2d_geomoments_basic_nt.cpp b/src/nyx/features/2d_geomoments_basic_nt.cpp index a38a0121c..bb39f4f20 100644 --- a/src/nyx/features/2d_geomoments_basic_nt.cpp +++ b/src/nyx/features/2d_geomoments_basic_nt.cpp @@ -112,10 +112,10 @@ std::tuple BasicGeomoms2 (_30 + _12) * (pow(_30 + _12, 2) - 3 * pow(_21 + _03, 2)) + (3 * _21 - _03) * (_21 + _03) * - (pow(3 * (_30 + _12), 2) - pow(_21 + _03, 2)); + (3 * pow(_30 + _12, 2) - pow(_21 + _03, 2)); // FIX: was pow(3*(_30+_12), 2) == 9*(eta30+eta12)^2; Hu's I5 second bracket is 3*(eta30+eta12)^2 - (eta21+eta03)^2 (same defect as the trivial-ROI sibling; cf. correct h7 below) double h6 = (_20 - _02) * (pow(_30 + _12, 2) - - pow(_21 + _03, 2)) + (4 * _11 * (_30 + _12) * - _21 + _03); + pow(_21 + _03, 2)) + + 4 * _11 * (_30 + _12) * (_21 + _03); // FIX: was 4*_11*(_30+_12)*_21 + _03 - precedence error left "+_03" outside the product; Hu's I6 last term is 4*eta11*(eta30+eta12)*(eta21+eta03) double h7 = (3 * _21 - _03) * (_30 + _12) * (pow(_30 + _12, 2) - 3 * pow(_21 + _03, 2)) - (_30 - 3 * _12) * (_21 + _03) * (3 * pow(_30 + _12, 2) - pow(_21 + _03, 2)); diff --git a/src/nyx/features/3d_glcm.cpp b/src/nyx/features/3d_glcm.cpp index 505c4dce1..08ebb27fa 100644 --- a/src/nyx/features/3d_glcm.cpp +++ b/src/nyx/features/3d_glcm.cpp @@ -613,7 +613,12 @@ double D3_GLCM_feature::f_entropy(const SimpleMatrix& P) for (j = 0; j < Ng; j++) for (i = 0; i < Ng; i++) - entropy += P.xy(i, j) * fast_log10(P.xy(i, j) + EPSILON) / LOG10_2; + { + // FIX: normalize by sum_p (joint probability), matching f_GLCM_JE. Was summing the + // UNNORMALIZED co-occurrence counts -> negative "entropy" (same bug as 2D glcm.cpp). + double p = P.xy(i, j) / sum_p; + entropy += p * fast_log10(p + EPSILON) / LOG10_2; + } return -entropy; } @@ -933,7 +938,9 @@ double D3_GLCM_feature::f_GLCM_HOM2(const SimpleMatrix& P_matrix) for (int x = 0; x < n_levels; x++) for (int y = 0; y < n_levels; y++) - hom2 += P_matrix.xy(x, y) / (1.0 + (double)std::abs(x - y) * (double)std::abs(x - y)); + // FIX: normalize by sum_p (joint probability), matching f_idm/f_GLCM_JE. Was summing the + // UNNORMALIZED co-occurrence counts -> homogeneity > 1 (same bug as 2D glcm.cpp). + hom2 += (P_matrix.xy(x, y) / sum_p) / (1.0 + (double)std::abs(x - y) * (double)std::abs(x - y)); return hom2; } diff --git a/src/nyx/features/3d_intensity.cpp b/src/nyx/features/3d_intensity.cpp index 91292a1cc..2777609fb 100644 --- a/src/nyx/features/3d_intensity.cpp +++ b/src/nyx/features/3d_intensity.cpp @@ -136,6 +136,19 @@ void D3_VoxelIntensityFeatures::calculate (LR &r, const Fsettings& s, const Data medad += std::abs(px.inten - median_); val_MEDIAN_ABSOLUTE_DEVIATION = medad / n; + // FIX 3ROBUST_MEAN: was never assigned in this (trivial) path so it defaulted to 0, unlike the + // 2D PixelIntensityFeatures which computes it (intensity.cpp) -> port the same definition: + // mean of voxels within the [P10,P90] robust window (p10_/p90_ from H.get_stats() above). + double robustMean = 0.0; + size_t robustCount = 0; + for (auto& px : B) + if (px.inten >= p10_ && px.inten <= p90_) + { + robustMean += px.inten; + robustCount++; + } + val_ROBUST_MEAN = robustCount ? robustMean / double(robustCount) : 0.0; + // --Uniformity calculated as PIU, percent image uniformity - see "A comparison of five standard methods for evaluating image intensity uniformity in partially parallel imaging MRI" [https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3745492/] and https://aapm.onlinelibrary.wiley.com/doi/abs/10.1118/1.2241606 double piu = (1.0 - double(r.aux_max - r.aux_min) / double(r.aux_max + r.aux_min)) * 100.0; val_UNIFORMITY_PIU = piu; @@ -260,6 +273,18 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c medad += std::abs(px.inten - median_); val_MEDIAN_ABSOLUTE_DEVIATION = medad / n; + // FIX 3ROBUST_MEAN: same omission in the out-of-core path (defaulted to 0) -> compute the + // mean of voxels within the [P10,P90] robust window, matching the 2D implementation. + double robustMean = 0.0; + size_t robustCount = 0; + for (auto& px : r.raw_pixels_3D) + if (px.inten >= p10_ && px.inten <= p90_) + { + robustMean += px.inten; + robustCount++; + } + val_ROBUST_MEAN = robustCount ? robustMean / double(robustCount) : 0.0; + // --Uniformity calculated as PIU, percent image uniformity - see "A comparison of five standard methods for evaluating image // intensity uniformity in partially parallel imaging MRI" [https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3745492/] // and https://aapm.onlinelibrary.wiley.com/doi/abs/10.1118/1.2241606 diff --git a/src/nyx/features/3d_surface.cpp b/src/nyx/features/3d_surface.cpp index 7883a71a9..ddc30bf12 100644 --- a/src/nyx/features/3d_surface.cpp +++ b/src/nyx/features/3d_surface.cpp @@ -278,19 +278,19 @@ void dump_skinny_contour_3D ( void D3_SurfaceFeature::build_surface (LR & r) { // read the point cloud of contours - constexpr std::size_t dim = 3; - using Points = std::vector>; + constexpr std::size_t dim = 3; + using Points = std::vector>; Points P; for (auto& plane : r.contours_3D) { for (auto ip : plane) { auto v = r.raw_pixels_3D[ip]; - P.push_back (std::array({ (float)v.x, (float)v.y, (float)v.z })); + P.push_back (std::array({ (float)v.x, (float)v.y, (float)v.z })); } } - const auto eps = 1e-10f; + const auto eps = 1e-10f; quick_hull qh{ dim, eps }; qh.add_points(std::cbegin(P), std::cend(P)); auto initial_simplex = qh.get_affine_basis(); @@ -311,13 +311,13 @@ void D3_SurfaceFeature::build_surface (LR & r) { const auto & V = f.vertices_; auto ax = (*V[0])[0], ay = (*V[0])[1], az = (*V[0])[2]; - auto bx = (*V[1])[0], by = (*V[1])[1], bz = (*V[1])[2]; + auto bx = (*V[1])[0], by = (*V[1])[1], bz = (*V[1])[2]; auto cx = (*V[2])[0], cy = (*V[2])[1], cz = (*V[2])[2]; - float a[3] = { ax, ay, az }, b[3] = { bx, by, bz }, c[3] = {cx, cy, cz}; - Simplex3 s(a, b, c); - hull_complex.push_back(s); - } -} + float a[3] = { ax, ay, az }, b[3] = { bx, by, bz }, c[3] = {cx, cy, cz}; + Simplex3 s(a, b, c); + hull_complex.push_back(s); + } +} void D3_SurfaceFeature::calculate (LR& r, const Fsettings& s) { @@ -392,48 +392,48 @@ void D3_SurfaceFeature::calculate (LR& r, const Fsettings& s) r.contours_3D.push_back (K); } - // surface area: count exposed voxel faces in the 6-neighborhood - struct VoxelKey - { - StatsInt x, y, z; - bool operator==(const VoxelKey& other) const - { - return x == other.x && y == other.y && z == other.z; - } - }; - struct VoxelKeyHash - { - std::size_t operator()(const VoxelKey& key) const noexcept - { - std::size_t h = std::hash{}(key.x); - h ^= std::hash{}(key.y) + 0x9e3779b9 + (h << 6) + (h >> 2); - h ^= std::hash{}(key.z) + 0x9e3779b9 + (h << 6) + (h >> 2); - return h; - } - }; - std::unordered_set voxels; - voxels.reserve(r.raw_pixels_3D.size() * 2); - for (const auto& vox : r.raw_pixels_3D) - voxels.insert({ vox.x, vox.y, vox.z }); - - static constexpr StatsInt nbr[6][3] = { - { 1, 0, 0 }, { -1, 0, 0 }, - { 0, 1, 0 }, { 0, -1, 0 }, - { 0, 0, 1 }, { 0, 0, -1 } - }; - - fval_AREA = 0.0; - for (const auto& vox : r.raw_pixels_3D) - { - for (const auto& d : nbr) - { - if (voxels.find({ vox.x + d[0], vox.y + d[1], vox.z + d[2] }) == voxels.end()) - fval_AREA += 1.0; - } - } - - // -- build the hull complex - build_surface (r); + // surface area: count exposed voxel faces in the 6-neighborhood + struct VoxelKey + { + StatsInt x, y, z; + bool operator==(const VoxelKey& other) const + { + return x == other.x && y == other.y && z == other.z; + } + }; + struct VoxelKeyHash + { + std::size_t operator()(const VoxelKey& key) const noexcept + { + std::size_t h = std::hash{}(key.x); + h ^= std::hash{}(key.y) + 0x9e3779b9 + (h << 6) + (h >> 2); + h ^= std::hash{}(key.z) + 0x9e3779b9 + (h << 6) + (h >> 2); + return h; + } + }; + std::unordered_set voxels; + voxels.reserve(r.raw_pixels_3D.size() * 2); + for (const auto& vox : r.raw_pixels_3D) + voxels.insert({ vox.x, vox.y, vox.z }); + + static constexpr StatsInt nbr[6][3] = { + { 1, 0, 0 }, { -1, 0, 0 }, + { 0, 1, 0 }, { 0, -1, 0 }, + { 0, 0, 1 }, { 0, 0, -1 } + }; + + fval_AREA = 0.0; + for (const auto& vox : r.raw_pixels_3D) + { + for (const auto& d : nbr) + { + if (voxels.find({ vox.x + d[0], vox.y + d[1], vox.z + d[2] }) == voxels.end()) + fval_AREA += 1.0; + } + } + + // -- build the hull complex + build_surface (r); // convex hull volume @@ -495,11 +495,15 @@ void D3_SurfaceFeature::calculate (LR& r, const Fsettings& s) double L[3]; if (Nyxus::calc_eigvals(L, K)) { - fval_MAJOR_AXIS_LEN = 4.0 * sqrt(L[1]); - fval_MINOR_AXIS_LEN = 4.0 * sqrt(L[2]); - fval_LEAST_AXIS_LEN = 4.0 * sqrt(L[0]); - fval_ELONGATION = sqrt(L[2] / L[1]); - fval_FLATNESS = sqrt(L[0] / L[1]); + // FIX: calc_eigvals returns L sorted DESCENDING (L[0] largest). The axis lengths were indexed + // wrong (MAJOR<-L[1], MINOR<-L[2], LEAST<-L[0]), producing LEAST>MAJOR and FLATNESS>1 (both + // structurally impossible). Correct mapping: MAJOR<-L[0] (largest), MINOR<-L[1], LEAST<-L[2]; + // ELONGATION=MINOR/MAJOR=sqrt(L[1]/L[0]), FLATNESS=LEAST/MAJOR=sqrt(L[2]/L[0]). Matches MIRP/IBSI. + fval_MAJOR_AXIS_LEN = 4.0 * sqrt(L[0]); + fval_MINOR_AXIS_LEN = 4.0 * sqrt(L[1]); + fval_LEAST_AXIS_LEN = 4.0 * sqrt(L[2]); + fval_ELONGATION = sqrt(L[1] / L[0]); + fval_FLATNESS = sqrt(L[2] / L[0]); } else { diff --git a/src/nyx/features/caliper.h b/src/nyx/features/caliper.h index 590d10352..a99198c16 100644 --- a/src/nyx/features/caliper.h +++ b/src/nyx/features/caliper.h @@ -43,7 +43,6 @@ class CaliperNassensteinFeature : public FeatureMethod // Implementation constant const float rot_angle_increment = 10.f; // degrees - const int n_steps = 10; }; class CaliperFeretFeature : public FeatureMethod @@ -91,7 +90,6 @@ class CaliperFeretFeature : public FeatureMethod // Implementation constant const double rot_angle_increment = 10.f; // degrees - const int n_steps = 10; }; class CaliperMartinFeature : public FeatureMethod @@ -129,6 +127,5 @@ class CaliperMartinFeature : public FeatureMethod // Implementation constant const float rot_angle_increment = 10.f; // degrees - const int n_steps = 10; }; diff --git a/src/nyx/features/caliper_feret.cpp b/src/nyx/features/caliper_feret.cpp index 740a1f2ef..f4b7595ec 100644 --- a/src/nyx/features/caliper_feret.cpp +++ b/src/nyx/features/caliper_feret.cpp @@ -1,3 +1,4 @@ +#include // FIX (caliper float-precision): std::min/std::max for the float-hull X-extent #include "caliper.h" #include "../environment.h" #include "../helpers/helpers.h" @@ -79,7 +80,7 @@ void CaliperFeretFeature::save_value(std::vector>& fvals) void CaliperFeretFeature::calculate_angled_caliper_measurements (const std::vector& convex_hull, std::vector& angles, std::vector& ferets) { // Rotated convex hull - std::vector CH_rot; + std::vector CH_rot; // FIX (caliper float-precision): float-precision rotated hull (was integer Pixel2, truncated inward) CH_rot.reserve (convex_hull.size()); // Rotate and calculate the diameter @@ -87,8 +88,10 @@ void CaliperFeretFeature::calculate_angled_caliper_measurements (const std::vect ferets.clear(); for (float theta = 0.f; theta <= 180.f; theta += rot_angle_increment) { - Rotation::rotate_around_center (convex_hull, theta, CH_rot); - auto [minX, minY, maxX, maxY] = AABB::from_pixelcloud (CH_rot); + Rotation::rotate_around_center_fp (convex_hull, theta, CH_rot); // FIX (caliper float-precision): no integer truncation + // FIX (caliper float-precision): min/max X directly over the float hull (AABB::from_pixelcloud takes Pixel2) + double minX = CH_rot[0].x, maxX = CH_rot[0].x; + for (auto& p : CH_rot) { minX = std::min(minX, (double)p.x); maxX = std::max(maxX, (double)p.x); } // Save a caliper measurement orthogonal to X double feret = maxX - minX; diff --git a/src/nyx/features/caliper_martin.cpp b/src/nyx/features/caliper_martin.cpp index 03f901d69..16a067ed0 100644 --- a/src/nyx/features/caliper_martin.cpp +++ b/src/nyx/features/caliper_martin.cpp @@ -1,9 +1,47 @@ +#include // FIX (caliper reimpl): std::min/std::max for the analytic chord math +#include #include "caliper.h" #include "../environment.h" #include "rotation.h" using namespace Nyxus; +// FIX (caliper reimpl): width of the convex hull at a horizontal cut y = span of the +// x-coordinates where the line y intersects the hull edges. The hull (convHull_CH) is +// stored OPEN (no duplicated closing vertex), so we must include the wrap-around edge +// last->first — the previous implementation omitted it. Inclusive edge test + min/max of +// the intersection x is robust to a line passing exactly through a shared vertex. +// FIX (caliper float-precision): operate on Point2f (float-precision rotated hull) so the chord width is not +// quantized by the old integer-Pixel2 truncation. +static double hull_width_at_y (const std::vector& poly, double y) +{ + bool have = false; + double xlo = 0.0, xhi = 0.0; + size_t n = poly.size(); + for (size_t i = 0; i < n; i++) + { + const Point2f& a = poly[i]; + const Point2f& b = poly[(i + 1) % n]; + double ay = a.y, by = b.y, lo = std::min(ay, by), hi = std::max(ay, by); + if (y < lo || y > hi) + continue; + double e0, e1; + if (by != ay) + { + double x = a.x + (b.x - a.x) * (y - ay) / (by - ay); + e0 = e1 = x; + } + else // horizontal edge lying on the cut: it spans [min x, max x] + { + e0 = std::min ((double)a.x, (double)b.x); + e1 = std::max ((double)a.x, (double)b.x); + } + if (!have) { xlo = e0; xhi = e1; have = true; } + else { xlo = std::min (xlo, e0); xhi = std::max (xhi, e1); } + } + return have ? (xhi - xlo) : 0.0; +} + CaliperMartinFeature::CaliperMartinFeature() : FeatureMethod("CaliperMartinFeature") { // Letting the feature dependency manager know @@ -50,169 +88,60 @@ void CaliperMartinFeature::save_value(std::vector>& fvals) void CaliperMartinFeature::calculate_imp(const std::vector& convex_hull, std::vector& all_D) { - // Rotated convex hull - std::vector CH_rot; + // FIX (caliper reimpl): the previous code was NOT the Martin diameter at all — it pushed + // BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so the per-angle + // shortest (a spurious near-apex ~0-length chord) corrupted MIN/MEAN/MEDIAN/MODE/STDDEV. + // The Martin diameter (Pahl/Rumpf 1973; imea reference) is a SINGLE chord per angle: the + // horizontal chord at the level that bisects the projected area (50%/50%). We reproduce that + // analytically on the rotated convex hull — one diameter per rotation angle. + std::vector CH_rot; // FIX (caliper float-precision): float-precision rotated hull (was integer Pixel2) CH_rot.reserve(convex_hull.size()); - // Rotate and calculate the diameter all_D.clear(); + const int NGRID = 100; // FIX: fine Y-grid for the area integral (converges well before this; see morph_oracle/caliper_proto.py) for (float theta = 0.f; theta < 180.f; theta += rot_angle_increment) { - Rotation::rotate_around_center(convex_hull, theta, CH_rot); - auto [minX, minY, maxX, maxY] = AABB::from_pixelcloud(CH_rot); - - std::vector DA; // Diameters at this angle + Rotation::rotate_around_center_fp(convex_hull, theta, CH_rot); // FIX (caliper float-precision): no integer truncation + // FIX (caliper float-precision): min/max Y directly over the float hull (AABB::from_pixelcloud takes Pixel2) + double minY = CH_rot[0].y, maxY = CH_rot[0].y; + for (auto& p : CH_rot) { minY = std::min(minY, (double)p.y); maxY = std::max(maxY, (double)p.y); } + if (maxY <= minY) // FIX: degenerate (collinear) hull at this angle — skip + continue; - // Iterate the Y-grid - float stepY = (maxY - minY) / float(n_steps); - for (int iy = 1; iy <= n_steps; iy++) + // FIX: sample the hull width at NGRID horizontal levels (midpoint rule) and integrate area + double stepY = (double(maxY) - double(minY)) / NGRID; + std::vector widths(NGRID); + double total = 0.0; + for (int i = 0; i < NGRID; i++) { - float chord_y = minY + iy * stepY; - - // Find convex hull segments intersecting 'y' - std::vector> X; // intersection points - for (int iH = 1; iH < CH_rot.size(); iH++) - { - // The convex hull points are guaranteed to be consecutive - auto& a = CH_rot[iH - 1], - & b = CH_rot[iH]; - - // Chord's Y is between segment AB's Ys ? - if ((a.y >= chord_y && b.y < chord_y) || (b.y >= chord_y && a.y < chord_y)) - { - float chord_x = b.y != a.y ? - (b.x - a.x) * (chord_y - a.y) / (b.y - a.y) + a.x - : (b.x + a.x) / 2; - auto tup1 = std::make_pair(chord_x, chord_y); - X.push_back (tup1); - - // find the 2nd intersection - for (int kH = iH+1; kH < CH_rot.size(); kH++) - { - auto & a = CH_rot [kH-1], - & b = CH_rot [kH]; - - // chord chord_y crosses hull's segment (a,b) ? - if ((a.y >= chord_y && b.y < chord_y) || (b.y >= chord_y && a.y < chord_y)) - { - float chord_x = b.y != a.y ? - (b.x - a.x) * (chord_y - a.y) / (b.y - a.y) + a.x - : (b.x + a.x) / 2; - auto tup2 = std::make_pair(chord_x, chord_y); - - // save point #2 only if it's different from #1, otherwise the chord's length will be ==0 - // (#1==#2 happens when chord's Y is exactly the Y of the contact of 2 convex hull segments.) - if (tup1 != tup2) - X.push_back (tup2); - - break; // done with chord point #2 - } - } - - break; // done with chord point #1 - } - } - - // Save the length of this chord. There must be 2 items in 'chordEnds' because we don't allow uniformative chords of zero length - if (X.size() == 2) // we ignore special 1-vertex segments because their lengths are ==0 - { - // for N segments - auto compareFunc = [](const std::pair& p1, const std::pair& p2) { return p1.first < p2.first; }; - auto idx_minX = std::distance(X.begin(), std::min_element(X.begin(), X.end(), compareFunc)); - auto idx_maxX = std::distance(X.begin(), std::max_element(X.begin(), X.end(), compareFunc)); - // left X and right X segments - auto& e1 = X[idx_minX], & e2 = X[idx_maxX]; - auto x1 = e1.first, y1 = e1.second, x2 = e2.first, y2 = e2.second; - // save this chord - auto dist = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2); // Squared distance - DA.push_back(dist); - } + double y = double(minY) + (i + 0.5) * stepY; + widths[i] = hull_width_at_y(CH_rot, y); + total += widths[i]; } + if (total <= 0.0) // FIX: empty cut set — skip + continue; - if (DA.size() > 0) + // FIX: walk the cumulative area from one side; the width at the 50% level is the Martin diameter + double half = 0.5 * total, cum = 0.0, martin = widths[NGRID - 1]; + for (int i = 0; i < NGRID; i++) { - // Find the shortest and longest chords (diameters) - double minD2 = *std::min_element(DA.begin(), DA.end()), - maxD2 = *std::max_element(DA.begin(), DA.end()), - min_ = sqrt(minD2), - max_ = sqrt(maxD2); - - // Save them - all_D.push_back(min_); - all_D.push_back(max_); + cum += widths[i]; + if (cum >= half) + { + martin = widths[i]; + break; + } } + all_D.push_back(martin); // FIX: one Martin diameter per angle (was two spurious values) } } void CaliperMartinFeature::osized_calculate (LR& r, const Fsettings& settings, ImageLoader&) { - // Rotated convex hull - std::vector CH_rot; - CH_rot.reserve(r.convHull_CH.size()); - - // Rotate and calculate the diameter + // FIX (caliper reimpl): the out-of-RAM path duplicated the same defective min+max chord logic. + // Route it through the corrected area-bisecting calculate_imp so both paths agree. std::vector all_D; - for (float theta = 0.f; theta < 180.f; theta += rot_angle_increment) - { - Rotation::rotate_around_center(r.convHull_CH, theta, CH_rot); - auto [minX, minY, maxX, maxY] = AABB::from_pixelcloud(CH_rot); - - std::vector DA; // Diameters at this angle - - // Iterate y-grid - float stepY = (maxY - minY) / float(n_steps); - for (int iy = 1; iy <= n_steps; iy++) - { - float chord_y = minY + iy * stepY; - - // Find convex hull segments intersecting 'y' - std::vector> X; // intersection points - for (int iH = 1; iH < CH_rot.size(); iH++) - { - // The convex hull points are guaranteed to be consecutive - auto& a = CH_rot[iH - 1], - & b = CH_rot[iH]; - - // Chord's Y is between segment AB's Ys ? - if ((a.y >= chord_y && b.y <= chord_y) || (b.y >= chord_y && a.y <= chord_y)) - { - auto chord_x = b.y != a.y ? - (b.x - a.x) * (chord_y - a.y) / (b.y - a.y) + a.x - : (b.y + a.y) / 2; - auto tup = std::make_pair(chord_x, chord_y); - X.push_back(tup); - } - } - - // Save the length of this chord. There must be 2 items in 'chordEnds' because we don't allow uniformative chords of zero length - if (X.size() >= 2) - { - // for N segments - auto compareFunc = [](const std::pair& p1, const std::pair& p2) { return p1.first < p2.first; }; - auto idx_minX = std::distance(X.begin(), std::min_element(X.begin(), X.end(), compareFunc)); - auto idx_maxX = std::distance(X.begin(), std::max_element(X.begin(), X.end(), compareFunc)); - // left X and right X segments - auto& e1 = X[idx_minX], & e2 = X[idx_maxX]; - auto x1 = e1.first, y1 = e1.second, x2 = e2.first, y2 = e2.second; - // save this chord - auto dist = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2); // Squared distance - DA.push_back(dist); - } - } - - if (DA.size() > 0) - { - // Find the shortest and longest chords (diameters) - double minD2 = *std::min_element(DA.begin(), DA.end()), - maxD2 = *std::max_element(DA.begin(), DA.end()), - min_ = sqrt(minD2), - max_ = sqrt(maxD2); - - // Save them - all_D.push_back(min_); - all_D.push_back(max_); - } - } + calculate_imp(r.convHull_CH, all_D); // Process the stats auto s = ComputeCommonStatistics2(all_D); diff --git a/src/nyx/features/caliper_nassenstein.cpp b/src/nyx/features/caliper_nassenstein.cpp index b42b1a01b..9df7925f1 100644 --- a/src/nyx/features/caliper_nassenstein.cpp +++ b/src/nyx/features/caliper_nassenstein.cpp @@ -1,9 +1,47 @@ +#include // FIX (caliper reimpl): std::min/std::max used by the analytic chord helper +#include // FIX (caliper reimpl): std::abs +#include #include "caliper.h" #include "../environment.h" #include "rotation.h" using namespace Nyxus; +// FIX (caliper reimpl): height of the convex hull at a vertical cut x = span of the +// y-coordinates where the line x intersects the hull edges. Hull is stored OPEN, so the +// wrap-around edge (last->first) is included. Inclusive edge test + min/max is robust to a +// line passing exactly through a shared vertex (the Nassenstein contact column is a vertex). +// FIX (caliper float-precision): operate on Point2f (float-precision rotated hull) so the chord height is not +// quantized by the old integer-Pixel2 truncation. +static double hull_height_at_x (const std::vector& poly, double x) +{ + bool have = false; + double ylo = 0.0, yhi = 0.0; + size_t n = poly.size(); + for (size_t i = 0; i < n; i++) + { + const Point2f& a = poly[i]; + const Point2f& b = poly[(i + 1) % n]; + double ax = a.x, bx = b.x, lo = std::min(ax, bx), hi = std::max(ax, bx); + if (x < lo || x > hi) + continue; + double e0, e1; + if (bx != ax) + { + double yv = a.y + (b.y - a.y) * (x - ax) / (bx - ax); + e0 = e1 = yv; + } + else // vertical edge lying on the cut: it spans [min y, max y] + { + e0 = std::min ((double)a.y, (double)b.y); + e1 = std::max ((double)a.y, (double)b.y); + } + if (!have) { ylo = e0; yhi = e1; have = true; } + else { ylo = std::min (ylo, e0); yhi = std::max (yhi, e1); } + } + return have ? (yhi - ylo) : 0.0; +} + CaliperNassensteinFeature::CaliperNassensteinFeature() : FeatureMethod("CaliperNassensteinFeature") { // Letting the feature dependency manager know @@ -50,144 +88,49 @@ void CaliperNassensteinFeature::save_value (std::vector>& fv void CaliperNassensteinFeature::calculate_imp (const std::vector& convex_hull, std::vector& all_D) { - // Rotated convex hull - std::vector CH_rot; + // FIX (caliper reimpl): the previous code was byte-identical to the (also wrong) Martin loop — + // two diameters cannot share one algorithm. It pushed both the shortest and longest horizontal + // chord per angle, so the per-angle shortest (a near-apex ~0-length chord) drove MIN and MODE to + // 0.0 — impossible for a solid shape. The Nassenstein diameter (Pahl/Rumpf 1973; imea reference) + // is a SINGLE chord per angle: the vertical chord measured at the bottom-tangent contact column. + // We reproduce that on the rotated convex hull: the contact is the extreme (max-y) vertex/edge, + // and the diameter is the hull's vertical extent at that contact column. + std::vector CH_rot; // FIX (caliper float-precision): float-precision rotated hull (was integer Pixel2) CH_rot.reserve(convex_hull.size()); - // Rotate and calculate the diameter all_D.clear(); for (float theta = 0.f; theta < 180.f; theta += rot_angle_increment) { - Rotation::rotate_around_center(convex_hull, theta, CH_rot); - auto [minX, minY, maxX, maxY] = AABB::from_pixelcloud(CH_rot); - - std::vector DA; // Diameters at this angle - - // Iterate y-grid - float stepY = (maxY - minY) / float(n_steps); - for (int iy = 1; iy <= n_steps; iy++) - { - float chord_y = minY + iy * stepY; - - // Find convex hull segments intersecting 'y' - std::vector> X; // intersection points - for (int iH = 1; iH < CH_rot.size(); iH++) - { - // The convex hull points are guaranteed to be consecutive - auto& a = CH_rot[iH - 1], - & b = CH_rot[iH]; - - // Chord's Y is between segment AB's Ys ? - if ((a.y >= chord_y && b.y <= chord_y) || (b.y >= chord_y && a.y <= chord_y)) - { - auto chord_x = b.y != a.y ? - (b.x - a.x) * (chord_y - a.y) / (b.y - a.y) + a.x - : (b.y + a.y) / 2; - auto tup = std::make_pair(chord_x, chord_y); - X.push_back(tup); - } - } + Rotation::rotate_around_center_fp(convex_hull, theta, CH_rot); // FIX (caliper float-precision): no integer truncation + if (CH_rot.size() < 3) // FIX: degenerate hull — no measurable tangent chord + continue; - // Save the length of this chord. There must be 2 items in 'chordEnds' because we don't allow uniformative chords of zero length - if (X.size() >= 2) + // FIX: bottom tangent = the max-y extreme; the contact column is the mean x of the max-y + // vertices (a single vertex generically, or the midpoint of a flat bottom edge) + double ymax = CH_rot[0].y; + for (auto& p : CH_rot) + ymax = std::max (ymax, (double)p.y); + double xsum = 0.0; + int cnt = 0; + for (auto& p : CH_rot) + if (std::abs((double)p.y - ymax) < 1e-3) { - // for N segments - auto compareFunc = [](const std::pair& p1, const std::pair& p2) { return p1.first < p2.first; }; - auto idx_minX = std::distance(X.begin(), std::min_element(X.begin(), X.end(), compareFunc)); - auto idx_maxX = std::distance(X.begin(), std::max_element(X.begin(), X.end(), compareFunc)); - // left X and right X segments - auto& e1 = X[idx_minX], & e2 = X[idx_maxX]; - auto x1 = e1.first, y1 = e1.second, x2 = e2.first, y2 = e2.second; - // save this chord - auto dist = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2); // Squared distance - DA.push_back(dist); + xsum += p.x; + cnt++; } - } + double xc = xsum / std::max(cnt, 1); - if (DA.size() > 0) - { - // Find the shortest and longest chords (diameters) - double minD2 = *std::min_element(DA.begin(), DA.end()), - maxD2 = *std::max_element(DA.begin(), DA.end()), - min_ = sqrt(minD2), - max_ = sqrt(maxD2); - - // Save them - all_D.push_back(min_); - all_D.push_back(max_); - } + // FIX: Nassenstein diameter = vertical extent of the hull at the contact column + all_D.push_back (hull_height_at_x(CH_rot, xc)); } } void CaliperNassensteinFeature::osized_calculate (LR& r, const Fsettings& settings, ImageLoader&) { - // Rotated convex hull - std::vector CH_rot; - CH_rot.reserve (r.convHull_CH.size()); - - // Rotate and calculate the diameter + // FIX (caliper reimpl): the out-of-RAM path duplicated the same defective min+max chord logic. + // Route it through the corrected bottom-tangent calculate_imp so both paths agree. std::vector all_D; - for (float theta = 0.f; theta < 180.f; theta += rot_angle_increment) - { - Rotation::rotate_around_center(r.convHull_CH, theta, CH_rot); - auto [minX, minY, maxX, maxY] = AABB::from_pixelcloud(CH_rot); - - std::vector DA; // Diameters at this angle - - // Iterate y-grid - float stepY = (maxY - minY) / float(n_steps); - for (int iy = 1; iy <= n_steps; iy++) - { - float chord_y = minY + iy * stepY; - - // Find convex hull segments intersecting 'y' - std::vector> X; // intersection points - for (int iH = 1; iH < CH_rot.size(); iH++) - { - // The convex hull points are guaranteed to be consecutive - auto& a = CH_rot[iH - 1], - & b = CH_rot[iH]; - - // Chord's Y is between segment AB's Ys ? - if ((a.y >= chord_y && b.y <= chord_y) || (b.y >= chord_y && a.y <= chord_y)) - { - auto chord_x = b.y != a.y ? - (b.x - a.x) * (chord_y - a.y) / (b.y - a.y) + a.x - : (b.y + a.y) / 2; - auto tup = std::make_pair(chord_x, chord_y); - X.push_back(tup); - } - } - - // Save the length of this chord. There must be 2 items in 'chordEnds' because we don't allow uniformative chords of zero length - if (X.size() >= 2) - { - // for N segments - auto compareFunc = [](const std::pair& p1, const std::pair& p2) { return p1.first < p2.first; }; - auto idx_minX = std::distance(X.begin(), std::min_element(X.begin(), X.end(), compareFunc)); - auto idx_maxX = std::distance(X.begin(), std::max_element(X.begin(), X.end(), compareFunc)); - // left X and right X segments - auto& e1 = X[idx_minX], & e2 = X[idx_maxX]; - auto x1 = e1.first, y1 = e1.second, x2 = e2.first, y2 = e2.second; - // save this chord - auto dist = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2); // Squared distance - DA.push_back(dist); - } - } - - if (DA.size() > 0) - { - // Find the shortest and longest chords (diameters) - double minD2 = *std::min_element(DA.begin(), DA.end()), - maxD2 = *std::max_element(DA.begin(), DA.end()), - min_ = sqrt(minD2), - max_ = sqrt(maxD2); - - // Save them - all_D.push_back(min_); - all_D.push_back(max_); - } - } + calculate_imp(r.convHull_CH, all_D); // Process the stats auto s = ComputeCommonStatistics2 (all_D); diff --git a/src/nyx/features/glcm.cpp b/src/nyx/features/glcm.cpp index 521fc594d..2ee95d952 100644 --- a/src/nyx/features/glcm.cpp +++ b/src/nyx/features/glcm.cpp @@ -728,7 +728,12 @@ double GLCMFeature::f_entropy(const SimpleMatrix& P) for (j = 0; j < Ng; j++) for (i = 0; i < Ng; i++) - entropy += P.xy(i, j) * fast_log10(P.xy(i, j) + EPSILON) / LOG10_2; + { + // FIX: normalize by sum_p (joint probability), matching f_GLCM_JE. Was summing the + // UNNORMALIZED co-occurrence counts -> negative "entropy" (e.g. -60.7 on the IBSI phantom). + double p = P.xy(i, j) / sum_p; + entropy += p * fast_log10(p + EPSILON) / LOG10_2; + } return -entropy; } @@ -1059,7 +1064,9 @@ double GLCMFeature::f_GLCM_HOM2(const SimpleMatrix& P_matrix) for (int x = 0; x < n_levels; x++) for (int y = 0; y < n_levels; y++) - hom2 += P_matrix.xy(x, y) / (1.0 + (double)std::abs(x - y) * (double)std::abs(x - y)); + // FIX: normalize by sum_p (joint probability), matching f_idm/f_GLCM_JE. Was summing the + // UNNORMALIZED co-occurrence counts -> homogeneity > 1 (e.g. 14.79 on the IBSI phantom). + hom2 += (P_matrix.xy(x, y) / sum_p) / (1.0 + (double)std::abs(x - y) * (double)std::abs(x - y)); return hom2; } diff --git a/src/nyx/features/rotation.cpp b/src/nyx/features/rotation.cpp index f2b026edc..2b6c63f87 100644 --- a/src/nyx/features/rotation.cpp +++ b/src/nyx/features/rotation.cpp @@ -34,8 +34,41 @@ void Rotation::rotate_around_center( } } +void Rotation::rotate_around_center_fp ( + // in + const std::vector& P, + float angle_deg, + // out + std::vector& P_rot) +{ + P_rot.clear(); + + // Find the center + double cx = 0, cy = 0; + for (auto& p : P) + { + cx += p.x; + cy += p.y; + } + cx /= double(P.size()); + cy /= double(P.size()); + + // Rotate + float theta = angle_deg * float(M_PI) / 180.f; + double s = sin(theta), + c = cos(theta); + for (auto& p : P) + { + double x_rot = (p.x - cx) * c - (p.y - cy) * s + cx; + double y_rot = (p.y - cy) * c + (p.x - cx) * s + cy; + // FIX (caliper float-precision): store the rotated vertex in floating point (Point2f) instead of the + // integer Pixel2 that truncated it inward - the caliper chord math then integrates on exact coords. + P_rot.push_back (Point2f ((float)x_rot, (float)y_rot)); + } +} + void Rotation::rotate_cloud ( - // in + // in const std::vector& P, const double cx, const double cy, diff --git a/src/nyx/features/rotation.h b/src/nyx/features/rotation.h index faebf4b8a..5d0921715 100644 --- a/src/nyx/features/rotation.h +++ b/src/nyx/features/rotation.h @@ -10,12 +10,24 @@ class Rotation { public: static void rotate_around_center( - // [in] + // [in] const std::vector& P, float angle_deg, // [out] std::vector& P_rot); + // FIX (caliper float-precision): floating-point variant of rotate_around_center. The Pixel2 sink truncates + // each rotated vertex to integer (Point2), snapping the hull systematically inward every + // angle, which forced the loose 15% caliper-vs-imea tolerance the integer path needed. Emitting Point2f + // preserves the fractional rotated coordinates and lets the caliper tests tighten to 10% (subsumes both + // "round instead of truncate" and "float-precision hull rotation"). + static void rotate_around_center_fp( + // [in] + const std::vector& P, + float angle_deg, + // [out] + std::vector& P_rot); + static void rotate_cloud( // [in] const std::vector& P, diff --git a/src/nyx/gpu/geomoments_hu.cu b/src/nyx/gpu/geomoments_hu.cu index 58921f3a7..934ae2bc5 100644 --- a/src/nyx/gpu/geomoments_hu.cu +++ b/src/nyx/gpu/geomoments_hu.cu @@ -71,19 +71,19 @@ namespace NyxusGpu // (NormCentralMom(D, 3, 0) + NormCentralMom(D, 1, 2)) * // (pow(NormCentralMom(D, 3, 0) + NormCentralMom(D, 1, 2), 2) - 3 * pow(NormCentralMom(D, 2, 1) + NormCentralMom(D, 0, 3), 2)) + // (3 * NormCentralMom(D, 2, 1) - NormCentralMom(D, 0, 3)) * (NormCentralMom(D, 2, 1) + NormCentralMom(D, 0, 3)) * - // (pow(3 * (NormCentralMom(D, 3, 0) + NormCentralMom(D, 1, 2)), 2) - pow(NormCentralMom(D, 2, 1) + NormCentralMom(D, 0, 3), 2)); + // (3 * pow(NormCentralMom(D, 3, 0) + NormCentralMom(D, 1, 2), 2) - pow(NormCentralMom(D, 2, 1) + NormCentralMom(D, 0, 3), 2)); *h5 = (*nu30 - 3. * *nu12) * (*nu30 + *nu12) * (pow(*nu30 + *nu12, 2.0) - 3. * pow(*nu21 + *nu03, 2.0)) + (3. * *nu21 - *nu03) * (*nu21 + *nu03) * - (pow(3. * (*nu30 + *nu12), 2.0) - pow(*nu21 + *nu03, 2.0)); + (3. * pow(*nu30 + *nu12, 2.0) - pow(*nu21 + *nu03, 2.0)); // FIX: was pow(3*(nu30+nu12), 2) == 9*(eta30+eta12)^2; Hu's I5 second bracket is 3*(eta30+eta12)^2 - (eta21+eta03)^2 (GPU twin of the CPU fix) // Formula: double h6 = (NormCentralMom(D, 2, 0) - NormCentralMom(D, 0, 2)) * (pow(NormCentralMom(D, 3, 0) + NormCentralMom(D, 1, 2), 2) - - // pow(NormCentralMom(D, 2, 1) + NormCentralMom(D, 0, 3), 2)) + (4 * NormCentralMom(D, 1, 1) * (NormCentralMom(D, 3, 0) + NormCentralMom(D, 1, 2)) * - // NormCentralMom(D, 2, 1) + NormCentralMom(D, 0, 3)); + // pow(NormCentralMom(D, 2, 1) + NormCentralMom(D, 0, 3), 2)) + 4 * NormCentralMom(D, 1, 1) * (NormCentralMom(D, 3, 0) + NormCentralMom(D, 1, 2)) * + // (NormCentralMom(D, 2, 1) + NormCentralMom(D, 0, 3)); *h6 = (*nu20 - *nu02) * (pow(*nu30 + *nu12, 2.0) - - pow(*nu21 + *nu03, 2.0)) + (4. * *nu11 * (*nu30 + *nu12) * - *nu21 + *nu03); + pow(*nu21 + *nu03, 2.0)) + + 4. * *nu11 * (*nu30 + *nu12) * (*nu21 + *nu03); // FIX: was 4*nu11*(nu30+nu12)*nu21 + nu03 - precedence error left "+nu03" outside the product; Hu's I6 last term is 4*eta11*(eta30+eta12)*(eta21+eta03) (GPU twin of the CPU fix) // Formula: double h7 = (3 * NormCentralMom(D, 2, 1) - NormCentralMom(D, 0, 3)) * (NormCentralMom(D, 3, 0) + NormCentralMom(D, 1, 2)) * (pow(NormCentralMom(D, 3, 0) + NormCentralMom(D, 1, 2), 2) - // 3 * pow(NormCentralMom(D, 2, 1) + NormCentralMom(D, 0, 3), 2)) - (NormCentralMom(D, 3, 0) - 3 * NormCentralMom(D, 1, 2)) * (NormCentralMom(D, 2, 1) + NormCentralMom(D, 0, 3)) * diff --git a/tests/test_3d_coverage_common.h b/tests/test_3d_coverage_common.h index 66cd705a8..bd54f0ddb 100644 --- a/tests/test_3d_coverage_common.h +++ b/tests/test_3d_coverage_common.h @@ -308,9 +308,9 @@ static const std::map>& unvetted_3d_local_regre { "3COMPACTNESS2", { 0.039449347281835329 } }, { "3COV", { 0.29486207043456802 } }, { "3COVERED_IMAGE_INTENSITY_RANGE", { 1.0002043207290587 } }, - { "3ELONGATION", { 0.80989034437672192 } }, + { "3ELONGATION", { 0.84332105599389762 } }, // FIX(axis mislabel): was 0.8099; now = MIRP morph_pca_elongation { "3EXCESS_KURTOSIS", { -1.2127631603215119 } }, - { "3FLATNESS", { 1.1857880138204875 } }, + { "3FLATNESS", { 0.68299758045903847 } }, // FIX(axis mislabel): was 1.186 (>1, impossible); now = MIRP morph_pca_flatness { "3GLCM_ACOR_AVE", { 896.29490954682888 } }, { "3GLCM_ASM_AVE", { 0.21714923037245615 } }, { "3GLCM_CLUPROM_AVE", { 5319702.1464416385 } }, @@ -325,11 +325,11 @@ static const std::map>& unvetted_3d_local_regre { "3GLCM_DIS_AVE", { 4.4826978263468824 } }, { "3GLCM_ENERGY", { 0.20619709037207257, 0.21210950671886142, 0.20619709037207257, 0.22347189327232553, 0.229768029906674, 0.22347189327232553, 0.20619709037207257, 0.21210950671886142, 0.20619709037207257, 0.21657166507454639, 0.22249608735556295, 0.21657166507454639, 0.2415813859599367 } }, { "3GLCM_ENERGY_AVE", { 0.21714923037245615 } }, - { "3GLCM_ENTROPY", { -6839097.7643487463, -7009967.2050323486, -6839097.7643487463, -7188531.59987259, -7365630.721654892, -7188531.59987259, -6839097.7643487463, -7009967.2050323486, -6839097.7643487463, -7049922.0657091141, -7222060.7506599426, -7049922.0657091141, -7731459.4186325073 } }, - { "3GLCM_ENTROPY_AVE", { -7090183.3607361866 } }, + { "3GLCM_ENTROPY", { 6.1375193094015223, 6.0251268596431471, 6.1375193094015223, 5.6356261936885357, 5.5229037686481623, 5.6356261936885357, 6.1375193094015223, 6.0251268596431471, 6.1375193094015223, 5.9202631504971848, 5.8126798565436344, 5.9202631504971848, 4.8177837873733909 } }, // FIX: were buggy unnormalized ~-6.8e6; post /sum_p fix (3d_glcm.cpp) + { "3GLCM_ENTROPY_AVE", { 5.8358059275253087 } }, // FIX: was buggy unnormalized -7.09e6; post /sum_p fix { "3GLCM_HOM1", { 0.63159286085358357, 0.67511464571418467, 0.63159286085358357, 0.675209797165082, 0.7178512712427948, 0.675209797165082, 0.63159286085358357, 0.67511464571418467, 0.63159286085358357, 0.65283716836970751, 0.69041197263012255, 0.65283716836970751, 0.77822827662266392 } }, { "3GLCM_HOM1_AVE", { 0.67070662972368189 } }, - { "3GLCM_HOM2", { 314305.05501034198, 345990.36653998197, 314305.05501034198, 340739.38462753344, 372698.80542099319, 340739.38462753344, 314305.05501034198, 345990.36653998197, 314305.05501034198, 329550.97066911863, 358264.30287407315, 329550.97066911863, 412615.52956223302 } }, + { "3GLCM_HOM2", { 0.59196175760722125, 0.64232872280698583, 0.59196175760722125, 0.63506326531940249, 0.68470533035896952, 0.63506326531940249, 0.59196175760722125, 0.64232872280698583, 0.59196175760722125, 0.61310667832992871, 0.65700403974706201, 0.61310667832992871, 0.75964624124070679 } }, // FIX: were buggy unnormalized ~3.1e5; post /sum_p fix (homogeneity now in [0,1]) { "3GLCM_IDMN_AVE", { 0.97393106857854816 } }, { "3GLCM_IDM_AVE", { 0.63463076728371071 } }, { "3GLCM_IDN_AVE", { 0.95491658377679667 } }, @@ -384,10 +384,10 @@ static const std::map>& unvetted_3d_local_regre { "3HYPERFLATNESS", { 3.8027657005973312 } }, { "3HYPERSKEWNESS", { 0.32001332615517414 } }, { "3INTEGRATED_INTENSITY", { 544286216 } }, - { "3LEAST_AXIS_LEN", { 104.70681271508683 } }, - { "3MAJOR_AXIS_LEN", { 88.301459868642283 } }, + { "3LEAST_AXIS_LEN", { 71.514499741981993 } }, // FIX(axis mislabel): was 104.7 (>MAJOR, impossible); now = MIRP morph_pca_least_axis + { "3MAJOR_AXIS_LEN", { 104.70681271508683 } }, // FIX(axis mislabel): was 88.3; now = MIRP morph_pca_maj_axis (largest) { "3MEDIAN_ABSOLUTE_DEVIATION", { 507.12380480410445 } }, - { "3MINOR_AXIS_LEN", { 71.514499741981993 } }, + { "3MINOR_AXIS_LEN", { 88.301459868642283 } }, // FIX(axis mislabel): was 71.5; now = MIRP morph_pca_min_axis { "3MODE", { 1279 } }, { "3NGLDM_DCENE", { 0.14348407632898436 } }, { "3NGLDM_DCENT", { 5.2277449211654039 } }, @@ -413,7 +413,7 @@ static const std::map>& unvetted_3d_local_regre { "3P75", { 2487.9072847682119 } }, { "3P99", { 3002.3047021943576 } }, { "3QCOD", { 0.25724851827174233 } }, - { "3ROBUST_MEAN", { 0 } }, + { "3ROBUST_MEAN", { 1977.5189642596645 } }, // FIX: baseline was pinning the bug value 0; 3ROBUST_MEAN is now computed (mean of voxels in [P10,P90]) ~ 3MEAN 1983.32, trimmed { "3SPHERICAL_DISPROPORTION", { 2.9375598657539634 } }, { "3SPHERICITY", { 0.34041859424142729 } }, { "3STANDARD_DEVIATION", { 584.80556406962933 } }, diff --git a/tests/test_3d_glcm_pyradiomics.h b/tests/test_3d_glcm_pyradiomics.h index fa1318997..8c4b3e45c 100644 --- a/tests/test_3d_glcm_pyradiomics.h +++ b/tests/test_3d_glcm_pyradiomics.h @@ -200,6 +200,71 @@ void test_compat_3glcm_feature (const Nyxus::Feature3D& expecting_fcode, const s ASSERT_TRUE(agrees_gt(atot, compat_d3glcm_GT[fname], 10.)); } +// Deep-dive: verify the 7 config-sensitive 3D GLCM features equal their already-vetted twins +// (numerically, same fixture/config), so they can be promoted by equivalence. Dumps calc_ave pairs. +void test_3dglcm_equivalence_dump() +{ + auto [ipath, mpath, label] = get_3d_compat_phantom(); + ASSERT_TRUE(fs::exists(ipath)); + ASSERT_TRUE(fs::exists(mpath)); + + Environment e; + e.dataset.dataset_props.reserve(1); + SlideProps& sp = e.dataset.dataset_props.emplace_back(ipath, mpath); + ASSERT_TRUE(scan_slide_props(sp, 3, e.anisoOptions, e.resultOptions.need_annotation())); + e.dataset.update_dataset_props_extrema(); + clear_slide_rois(e.uniqueLabels, e.roiData); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0, ipath, mpath, 0)); + std::vector batch = { label }; + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0)); + ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); + + Fsettings s; + s.resize((int)NyxSetting::__COUNT__); + s[(int)NyxSetting::SOFTNAN].rval = 0.0; + s[(int)NyxSetting::TINY].rval = 0.0; + s[(int)NyxSetting::SINGLEROI].bval = false; + s[(int)NyxSetting::GREYDEPTH].ival = 100; + s[(int)NyxSetting::PIXELSIZEUM].rval = 100; + s[(int)NyxSetting::PIXELDISTANCE].ival = 5; + s[(int)NyxSetting::USEGPU].bval = false; + s[(int)NyxSetting::VERBOSLVL].ival = 0; + s[(int)NyxSetting::IBSI].bval = false; + s[(int)NyxSetting::GLCM_GREYDEPTH].ival = -20; + s[(int)NyxSetting::GLCM_OFFSET].ival = 1; + s[(int)NyxSetting::GLCM_SPARSEINTENS].bval = true; + + LR& r = e.roiData[label]; + ASSERT_NO_THROW(r.initialize_fvals()); + D3_GLCM_feature f; + ASSERT_NO_THROW(f.calculate(r, s)); + f.save_value(r.fvals); + + using F = Nyxus::Feature3D; + struct Pair { const char* a; F fa; const char* b; F fb; }; + std::vector pairs = { + {"3GLCM_DIS", F::GLCM_DIS, "3GLCM_DIFAVE", F::GLCM_DIFAVE}, + {"3GLCM_ENERGY", F::GLCM_ENERGY, "3GLCM_ASM", F::GLCM_ASM}, + {"3GLCM_ENTROPY", F::GLCM_ENTROPY, "3GLCM_JE", F::GLCM_JE}, + {"3GLCM_HOM1", F::GLCM_HOM1, "3GLCM_ID", F::GLCM_ID}, + {"3GLCM_HOM2", F::GLCM_HOM2, "3GLCM_IDM", F::GLCM_IDM}, + {"3GLCM_SUMVARIANCE", F::GLCM_SUMVARIANCE, "3GLCM_CLUTEND", F::GLCM_CLUTEND}, + {"3GLCM_VARIANCE", F::GLCM_VARIANCE, "3GLCM_JVAR", F::GLCM_JVAR}, + }; + for (auto& p : pairs) + { + double va = f.calc_ave(r.fvals[(int)p.fa]); + double vb = f.calc_ave(r.fvals[(int)p.fb]); + double m = std::max(std::abs(va), std::abs(vb)); if (m < 1e-12) m = 1e-12; + double rel = std::abs(va - vb) / m; + std::cout << "[3DGLCM-EQ] " << p.a << "=" << va << " " << p.b << "=" << vb + << " rel=" << rel << (rel < 1e-6 ? " EQUAL" : " DIFFER") << "\n"; + // Config-sensitive 3D GLCM features are numerically identical to their pyradiomics-vetted + // twins (also guards the HOM2/ENTROPY /sum_p fix: pre-fix ENTROPY!=JE, HOM2!=IDM). + EXPECT_NEAR(va, vb, std::abs(vb) * 1e-6 + 1e-9) << p.a << " != " << p.b; + } +} + void test_compat_3glcm_ACOR() { test_compat_3glcm_feature (Nyxus::Feature3D::GLCM_ACOR, "3GLCM_ACOR"); diff --git a/tests/test_3d_glrlm_pyradiomics.h b/tests/test_3d_glrlm_pyradiomics.h index b1321f360..922c69a4a 100644 --- a/tests/test_3d_glrlm_pyradiomics.h +++ b/tests/test_3d_glrlm_pyradiomics.h @@ -212,6 +212,66 @@ void test_compat_3glrlm_feature(const Nyxus::Feature3D& expecting_fcode, const s ASSERT_TRUE(agrees_gt(atot, compat_3glrlm_GT[fname], 10.)); } +// Vet the direction-averaged (_AVE) 3D GLRLM features vs PyRadiomics. save_value stores +// fvals[X_AVE][0] = calc_ave(angled_X) -- exactly the quantity the base test asserts == PyRadiomics +// (test_compat_3glrlm_feature: atot = calc_ave(fvals[X])). So reading the _AVE slot directly and +// comparing to the same GT table is a direct PyRadiomics assertion on the _AVE feature. One workflow +// run covers all 14 (RE_AVE / RP_AVE already vetted elsewhere). +void test_compat_3glrlm_ave_features() +{ + auto [ipath, mpath, label] = get_3d_compat_phantom(); + ASSERT_TRUE(fs::exists(ipath)); + ASSERT_TRUE(fs::exists(mpath)); + + Environment e; + e.dataset.dataset_props.reserve(1); + SlideProps& sp = e.dataset.dataset_props.emplace_back(ipath, mpath); + ASSERT_TRUE(scan_slide_props(sp, 3, e.anisoOptions, e.resultOptions.need_annotation())); + e.dataset.update_dataset_props_extrema(); + clear_slide_rois(e.uniqueLabels, e.roiData); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0, ipath, mpath, 0)); + std::vector batch = { label }; + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0)); + ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); + + Fsettings s; + s.resize((int)NyxSetting::__COUNT__); + s[(int)NyxSetting::SOFTNAN].rval = 0.0; + s[(int)NyxSetting::TINY].rval = 0.0; + s[(int)NyxSetting::SINGLEROI].bval = false; + s[(int)NyxSetting::GREYDEPTH].ival = 100; + s[(int)NyxSetting::PIXELSIZEUM].rval = 100; + s[(int)NyxSetting::PIXELDISTANCE].ival = 5; + s[(int)NyxSetting::USEGPU].bval = false; + s[(int)NyxSetting::VERBOSLVL].ival = 0; + s[(int)NyxSetting::IBSI].bval = false; + s[(int)NyxSetting::GLRLM_GREYDEPTH].ival = -20; // radiomics binCount-based grey binning + + LR& r = e.roiData[label]; + ASSERT_NO_THROW(r.initialize_fvals()); + D3_GLRLM_feature f; + ASSERT_NO_THROW(f.calculate(r, s)); + f.save_value(r.fvals); + + using F = Nyxus::Feature3D; + struct AvePair { F ave; const char* gt; }; + std::vector aves = { + {F::GLRLM_GLN_AVE, "3GLRLM_GLN"}, {F::GLRLM_GLNN_AVE, "3GLRLM_GLNN"}, + {F::GLRLM_GLV_AVE, "3GLRLM_GLV"}, {F::GLRLM_HGLRE_AVE, "3GLRLM_HGLRE"}, + {F::GLRLM_LGLRE_AVE, "3GLRLM_LGLRE"}, {F::GLRLM_LRE_AVE, "3GLRLM_LRE"}, + {F::GLRLM_LRHGLE_AVE, "3GLRLM_LRHGLE"}, {F::GLRLM_LRLGLE_AVE, "3GLRLM_LRLGLE"}, + {F::GLRLM_RLN_AVE, "3GLRLM_RLN"}, {F::GLRLM_RLNN_AVE, "3GLRLM_RLNN"}, + {F::GLRLM_RV_AVE, "3GLRLM_RV"}, {F::GLRLM_SRE_AVE, "3GLRLM_SRE"}, + {F::GLRLM_SRHGLE_AVE, "3GLRLM_SRHGLE"}, {F::GLRLM_SRLGLE_AVE, "3GLRLM_SRLGLE"}, + }; + for (auto& a : aves) + { + double v = r.fvals[(int)a.ave][0]; + ASSERT_TRUE(agrees_gt(v, compat_3glrlm_GT[a.gt], 10.)) << a.gt << "_AVE = " << v + << " vs pyradiomics " << compat_3glrlm_GT[a.gt]; + } +} + void test_compat_3GLRLM_GLN() { test_compat_3glrlm_feature (Nyxus::Feature3D::GLRLM_GLN, "3GLRLM_GLN"); } diff --git a/tests/test_3d_ngldm_ibsi.h b/tests/test_3d_ngldm_regression.h similarity index 68% rename from tests/test_3d_ngldm_ibsi.h rename to tests/test_3d_ngldm_regression.h index 75a3340be..217e1d3ac 100644 --- a/tests/test_3d_ngldm_ibsi.h +++ b/tests/test_3d_ngldm_regression.h @@ -7,6 +7,43 @@ #include "../src/nyx/roi_cache.h" #include "../src/nyx/features/3d_ngldm.h" +// REGRESSION / drift-guard tests -- NOT an oracle. The `d3ngldm_GT` table below has NO external +// provenance and its numbers are NOT verified against any reference; the tests only pin current output. +// +// This file was renamed from test_3d_ngldm_ibsi.h to test_3d_ngldm_regression.h: SPEC §2 reserves an +// oracle suffix like `_ibsi` for a genuine, provenance-carrying oracle, and these values are neither. +// They have no recorded tool/version/config (SPEC 6.4 requires provenance for any oracle golden) and +// are evaluated on the Nyxus coverage phantom tests/data/nifti/phantoms/ut_inten.nii + ut_mask57.nii +// -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to them in the first place. +// (Contrast test_ngldm_ibsi.h, the 2D file, which DOES run on the IBSI digital phantom and cites IBSI +// manual page numbers per value -- that one is a real IBSI oracle. The `_ibsi` suffix is now free for a +// genuine 3D IBSI-phantom NGLDM oracle when one is written.) +// +// Verified 2026-07 with an independent MIRP NGLDM run on the SAME phantom at the SAME discretisation +// (fixed_bin_number n=64, 3D). MIRP disagrees with every comparable value, several by an order of +// magnitude: +// feature this table MIRP +// LDE 0.1 0.2559 +// HDE 261 28.07 +// LGLCE 0.00036 0.0322 +// HGLCE 740 1324 +// GLNU 115443 4350.3 +// GLNUN 0.23 0.01585 +// DCNU 115443 40745.0 +// DCNUN 0.23 0.14847 +// GLV 190 350.17 +// DCV 86.17 11.948 +// DCENT 5.23 8.676 +// DCENE 0.14 0.002875 +// Harness: morph_oracle/mirp_ngldm_3d.py (offline; CI never runs it). +// +// => Treat the tests below as REGRESSION / drift guards only. Do NOT promote features in +// oracle_coverage.csv to status=vetted on the strength of them passing. Promoting requires a +// documented, config-matched external oracle (MIRP is the candidate for 3D NGLDM). +// +// Also note 3NGLDM_GLM (grey level mean) and 3NGLDM_DCM (dependence count mean) have no counterpart +// anywhere: MIRP's NGLDM emits no gl_mean / dc_mean column, and the 2D table in test_ngldm_ibsi.h +// explicitly marks GLM "--not in IBSI--". No external oracle exists for those two. static std::unordered_map d3ngldm_GT{ { "3NGLDM_LDE", 0.1 }, { "3NGLDM_HDE", 261.0 }, diff --git a/tests/test_all.cc b/tests/test_all.cc index f8148e15e..e7ced9fd9 100644 --- a/tests/test_all.cc +++ b/tests/test_all.cc @@ -44,7 +44,7 @@ #include "test_3d_morphology_regression.h" #include "test_3d_morphology_matlab.h" #include "test_3d_gldzm_ibsi.h" -#include "test_3d_ngldm_ibsi.h" +#include "test_3d_ngldm_regression.h" #include "test_3d_firstorder_pyradiomics.h" #include "test_3d_glcm_pyradiomics.h" #include "test_3d_gldm_pyradiomics.h" @@ -259,6 +259,10 @@ TEST(TEST_NYXUS, TEST_COMPAT_3GLRLM_SRLGLE) { ASSERT_NO_THROW(test_compat_3GLRLM_SRLGLE()); } +TEST(TEST_NYXUS, TEST_COMPAT_3GLRLM_AVE_FEATURES) { + ASSERT_NO_THROW(test_compat_3glrlm_ave_features()); +} + //***** 3D GLSZM compatibility ***** @@ -391,6 +395,10 @@ TEST(TEST_NYXUS, TEST_COMPAT_3GLDM_SDLGLE) { //***** 3D GLCM compatibility ***** +TEST(TEST_NYXUS, TEST_3DGLCM_EQUIVALENCE_DUMP) { + ASSERT_NO_THROW(test_3dglcm_equivalence_dump()); +} + TEST(TEST_NYXUS, TEST_COMPAT_3GLCM_ACOR) { ASSERT_NO_THROW(test_compat_3glcm_ACOR()); } @@ -1044,6 +1052,11 @@ TEST(TEST_NYXUS, TEST_2D_SHAPE_GEOMETRIC_MOMENTS_VERIFIABLE_WITH_3P_BUILTIN_ORAC ASSERT_NO_THROW(test_2d_shape_geometric_moments_verifiable_with_3p_builtin_oracle()); } +TEST(TEST_NYXUS, TEST_MOMENTS_HU_WEDGE_SKIMAGE) +{ + ASSERT_NO_THROW(test_moments_hu_wedge_skimage()); +} + TEST(TEST_NYXUS, TEST_2D_SHAPE_GEOMETRIC_MOMENTS_UNVETTED_NO_DIRECT_ORACLE) { ASSERT_NO_THROW(test_2d_shape_geometric_moments_unvetted_no_direct_oracle()); @@ -1074,6 +1087,26 @@ TEST(TEST_NYXUS, TEST_REMAINING2D_VERIFIABLE_WITH_3P_BUILTIN_ORACLE_CALIPER_FEAT ASSERT_NO_THROW(test_remaining2d_verifiable_with_3p_builtin_oracle_caliper_features()); } +TEST(TEST_NYXUS, TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE) +{ + ASSERT_NO_THROW(test_shape2d_caliper_martin_nassenstein_imea_ellipse_oracle()); +} + +TEST(TEST_NYXUS, TEST_SHAPE2D_CALIPER_FERET_IMEA_ELLIPSE_ORACLE) +{ + ASSERT_NO_THROW(test_shape2d_caliper_feret_imea_ellipse_oracle()); +} + +TEST(TEST_NYXUS, TEST_SHAPE2D_MIN_ENCLOSING_CIRCLE_IMEA_ORACLE) +{ + ASSERT_NO_THROW(test_shape2d_min_enclosing_circle_imea_oracle()); +} + +TEST(TEST_NYXUS, TEST_SHAPE2D_DOCUMENTED_FORMULA_CONFORMANCE_NO_EXTERNAL_ORACLE) +{ + ASSERT_NO_THROW(test_shape2d_documented_formula_conformance_no_external_oracle()); +} + TEST(TEST_NYXUS, TEST_REMAINING2D_VERIFIABLE_WITH_3P_BUILTIN_ORACLE_CHORD_STAT_FEATURES) { ASSERT_NO_THROW(test_remaining2d_verifiable_with_3p_builtin_oracle_chord_stat_features()); @@ -1104,11 +1137,11 @@ TEST(TEST_NYXUS, TEST_NEIGHBORHOOD2D_COUNTS_TOUCHING) ASSERT_NO_THROW(test_neighborhood2d_counts_and_touching()); } -TEST(TEST_NYXUS, TEST_NEIGHBORHOOD2D_PERCENT_TOUCHING_ENCLOSED_ANALYTIC) -{ - ASSERT_NO_THROW(test_neighborhood2d_percent_touching_enclosed_analytic()); -} - +TEST(TEST_NYXUS, TEST_NEIGHBORHOOD2D_PERCENT_TOUCHING_ENCLOSED_ANALYTIC) +{ + ASSERT_NO_THROW(test_neighborhood2d_percent_touching_enclosed_analytic()); +} + TEST(TEST_NYXUS, TEST_NEIGHBORHOOD2D_CLOSEST_NEIGHBORS) { ASSERT_NO_THROW(test_neighborhood2d_closest_neighbors()); @@ -1260,6 +1293,16 @@ TEST(TEST_NYXUS, TEST_IBSI_GLCM_JE) ASSERT_NO_THROW(test_ibsi_glcm_JE()); } +TEST(TEST_NYXUS, TEST_IBSI_GLCM_HOM2) +{ + ASSERT_NO_THROW(test_ibsi_glcm_HOM2()); +} + +TEST(TEST_NYXUS, TEST_IBSI_GLCM_ENTROPY) +{ + ASSERT_NO_THROW(test_ibsi_glcm_ENTROPY()); +} + TEST(TEST_NYXUS, TEST_IBSI_GLCM_JMAX) { ASSERT_NO_THROW(test_ibsi_glcm_JMAX()); @@ -1705,6 +1748,13 @@ TEST(TEST_NYXUS, TEST_IBSI_GLRLM_LRHGLE) ASSERT_NO_THROW(test_ibsi_glrlm_lrhgle()); } +TEST(TEST_NYXUS, TEST_IBSI_GLRLM_LGLRE_AVE) { ASSERT_NO_THROW(test_ibsi_glrlm_lglre_ave()); } +TEST(TEST_NYXUS, TEST_IBSI_GLRLM_HGLRE_AVE) { ASSERT_NO_THROW(test_ibsi_glrlm_hglre_ave()); } +TEST(TEST_NYXUS, TEST_IBSI_GLRLM_SRLGLE_AVE) { ASSERT_NO_THROW(test_ibsi_glrlm_srlgle_ave()); } +TEST(TEST_NYXUS, TEST_IBSI_GLRLM_SRHGLE_AVE) { ASSERT_NO_THROW(test_ibsi_glrlm_srhgle_ave()); } +TEST(TEST_NYXUS, TEST_IBSI_GLRLM_LRLGLE_AVE) { ASSERT_NO_THROW(test_ibsi_glrlm_lrlgle_ave()); } +TEST(TEST_NYXUS, TEST_IBSI_GLRLM_LRHGLE_AVE) { ASSERT_NO_THROW(test_ibsi_glrlm_lrhgle_ave()); } + TEST(TEST_NYXUS, TEST_IBSI_GLRLM_GLN) { ASSERT_NO_THROW(test_ibsi_glrlm_gln()); diff --git a/tests/test_glcm_ibsi.h b/tests/test_glcm_ibsi.h index 98e2ec25f..65c79d223 100644 --- a/tests/test_glcm_ibsi.h +++ b/tests/test_glcm_ibsi.h @@ -31,6 +31,8 @@ static std::unordered_map ibsi_reference_glcm_feature_golde {"GLCM_IDMN", 0.899}, // p. 74, consensus: very strong {"GLCM_INFOMEAS1", -0.155}, // p. 80, consensus: very strong {"GLCM_INFOMEAS2", 0.487}, // p. 81, consensus: very strong + {"GLCM_HOM2", 0.619}, // = IBSI IDM (WF0Z, p.73); PyRadiomics 'Idm' twin of GLCM_IDM + {"GLCM_ENTROPY", 2.05}, // = IBSI JE (TU9B, p.63); joint entropy twin of GLCM_JE {"GLCM_IV", 0.0567}, // p. 75, consensus: very strong {"GLCM_JAVE", 2.14}, // p. 62, consensus: very strong {"GLCM_JE", 2.05}, // p. 63, consensus: very strong @@ -244,6 +246,16 @@ void test_ibsi_glcm_JAVE() test_ibsi_glcm_feature(Nyxus::Feature2D::GLCM_JAVE, "GLCM_JAVE"); } +void test_ibsi_glcm_HOM2() // regression-fix: HOM2 == IBSI IDM once /sum_p normalization is applied +{ + test_ibsi_glcm_feature(Nyxus::Feature2D::GLCM_HOM2, "GLCM_HOM2"); +} + +void test_ibsi_glcm_ENTROPY() // regression-fix: ENTROPY == IBSI JE once /sum_p normalization is applied +{ + test_ibsi_glcm_feature(Nyxus::Feature2D::GLCM_ENTROPY, "GLCM_ENTROPY"); +} + void test_ibsi_glcm_JE() { test_ibsi_glcm_feature(Nyxus::Feature2D::GLCM_JE, "GLCM_JE"); diff --git a/tests/test_glcm_regression.h b/tests/test_glcm_regression.h index f01a75640..be47dadde 100644 --- a/tests/test_glcm_regression.h +++ b/tests/test_glcm_regression.h @@ -83,8 +83,8 @@ static std::unordered_map unvetted_nyxus_convention_regress {"GLCM_CLUTEND", 1.5639042057291665e+03}, {"GLCM_CORRELATION", 0.000690135}, {"GLCM_ENERGY", 0.381801}, - {"GLCM_ENTROPY", -20.1735}, - {"GLCM_HOM2", 6.81505}, + {"GLCM_ENTROPY", 1.87602}, // FIX: was buggy unnormalized -20.1735; post /sum_p fix == GLCM_JE (joint entropy) + {"GLCM_HOM2", 0.572168}, // FIX: was buggy unnormalized 6.81505; post /sum_p fix == GLCM_IDM (homogeneity in [0,1]) {"GLCM_INFOMEAS1", -0.184406}, {"GLCM_INFOMEAS2", 0.495817}, {"GLCM_JAVE", 35.5215}, diff --git a/tests/test_glrlm_ibsi.h b/tests/test_glrlm_ibsi.h index 2c5a668d7..90dfdda6d 100644 --- a/tests/test_glrlm_ibsi.h +++ b/tests/test_glrlm_ibsi.h @@ -208,4 +208,52 @@ void test_ibsi_glrlm_rv() void test_ibsi_glrlm_re() { test_ibsi_glrlm_feature(Nyxus::Feature2D::GLRLM_RE, "GLRLM_RE"); -} \ No newline at end of file +} + +// IBSI oracle for the angle-averaged (_AVE) joint gray-level x run-length emphasis features. Nyxus +// stores the mean over the 4 directions in slot [0] of each _AVE feature; averaging that over the 4 +// phantom slices reproduces the IBSI 2D direction+slice-averaged consensus (the same grand mean the +// base-feature test pins as total/16). ibsi_key indexes the shared reference table. +void test_ibsi_glrlm_ave_feature(const Feature2D& feature_, const std::string& ibsi_key) +{ + Fsettings s; + s.resize((int)NyxSetting::__COUNT__); + s[(int)NyxSetting::SOFTNAN].rval = 0.0; + s[(int)NyxSetting::TINY].rval = 0.0; + s[(int)NyxSetting::SINGLEROI].bval = false; + s[(int)NyxSetting::GREYDEPTH].ival = 128; + s[(int)NyxSetting::PIXELSIZEUM].rval = 100; + s[(int)NyxSetting::PIXELDISTANCE].ival = 5; + s[(int)NyxSetting::USEGPU].bval = false; + s[(int)NyxSetting::VERBOSLVL].ival = 0; + s[(int)NyxSetting::IBSI].bval = true; + + int feature = int(feature_); + double total = 0; + + const NyxusPixel* intens[4] = { ibsi_phantom_z1_intensity, ibsi_phantom_z2_intensity, ibsi_phantom_z3_intensity, ibsi_phantom_z4_intensity }; + const NyxusPixel* masks[4] = { ibsi_phantom_z1_mask, ibsi_phantom_z2_mask, ibsi_phantom_z3_mask, ibsi_phantom_z4_mask }; + const size_t counts[4] = { + sizeof(ibsi_phantom_z1_mask) / sizeof(NyxusPixel), sizeof(ibsi_phantom_z2_intensity) / sizeof(NyxusPixel), + sizeof(ibsi_phantom_z3_intensity) / sizeof(NyxusPixel), sizeof(ibsi_phantom_z4_intensity) / sizeof(NyxusPixel) }; + + for (int i = 0; i < 4; i++) + { + LR roidata; + GLRLMFeature f; + load_masked_test_roi_data(roidata, intens[i], masks[i], counts[i]); + ASSERT_NO_THROW(f.calculate(roidata, s)); + roidata.initialize_fvals(); + f.save_value(roidata.fvals); + total += roidata.fvals[feature][0]; // _AVE stores the direction-mean in slot [0] + } + + ASSERT_TRUE(agrees_gt(total / 4, ibsi_reference_glrlm_feature_golden_values[ibsi_key], 100.)); +} + +void test_ibsi_glrlm_lglre_ave() { test_ibsi_glrlm_ave_feature(Nyxus::Feature2D::GLRLM_LGLRE_AVE, "GLRLM_LGLRE"); } +void test_ibsi_glrlm_hglre_ave() { test_ibsi_glrlm_ave_feature(Nyxus::Feature2D::GLRLM_HGLRE_AVE, "GLRLM_HGLRE"); } +void test_ibsi_glrlm_srlgle_ave() { test_ibsi_glrlm_ave_feature(Nyxus::Feature2D::GLRLM_SRLGLE_AVE, "GLRLM_SRLGLE"); } +void test_ibsi_glrlm_srhgle_ave() { test_ibsi_glrlm_ave_feature(Nyxus::Feature2D::GLRLM_SRHGLE_AVE, "GLRLM_SRHGLE"); } +void test_ibsi_glrlm_lrlgle_ave() { test_ibsi_glrlm_ave_feature(Nyxus::Feature2D::GLRLM_LRLGLE_AVE, "GLRLM_LRLGLE"); } +void test_ibsi_glrlm_lrhgle_ave() { test_ibsi_glrlm_ave_feature(Nyxus::Feature2D::GLRLM_LRHGLE_AVE, "GLRLM_LRHGLE"); } \ No newline at end of file diff --git a/tests/test_moments_common.h b/tests/test_moments_common.h index e991188cf..5abb0d0eb 100644 --- a/tests/test_moments_common.h +++ b/tests/test_moments_common.h @@ -98,6 +98,61 @@ static void calculate_2d_geomoment_feature_values(std::vector x) + continue; + if (roidata.aux_area == 0) + init_label_record_3(roidata, x, y, 1); + else + update_label_record_3(roidata, x, y, 1); + } + + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + { + if (5 * y > x) + continue; + roidata.raw_pixels.push_back(Pixel2(x, y, 1)); + } + + roidata.make_nonanisotropic_aabb(); + roidata.aux_image_matrix.allocate( + roidata.aabb.get_width(), + roidata.aabb.get_height()); + roidata.aux_image_matrix.calculate_from_pixelcloud(roidata.raw_pixels, roidata.aabb); +} + +static void calculate_2d_wedge_geomoment_feature_values(std::vector>& fvals) +{ + Fsettings s = make_2d_geomoment_settings(); + + LR roidata(1402); + load_wedge_fixture(roidata); + roidata.initialize_fvals(); + + ContourFeature contour; + contour.calculate(roidata, s); + contour.save_value(roidata.fvals); + + Smoms2D_feature shape_moments; + shape_moments.calculate(roidata, s); + shape_moments.save_value(roidata.fvals); + + fvals = roidata.fvals; +} + static void assert_2d_geomoment_features( const std::vector>& fvals, const std::vector& golden_values, diff --git a/tests/test_moments_regression.h b/tests/test_moments_regression.h index 478d5ed88..6edf500a7 100644 --- a/tests/test_moments_regression.h +++ b/tests/test_moments_regression.h @@ -47,8 +47,8 @@ static const std::vector unvetted_nyxus_regression_shape_g {Nyxus::Feature2D::WEIGHTED_HU_M2, "WEIGHTED_HU_M2", 0.00024306185106698786}, {Nyxus::Feature2D::WEIGHTED_HU_M3, "WEIGHTED_HU_M3", 1.1262214820995351e-05}, {Nyxus::Feature2D::WEIGHTED_HU_M4, "WEIGHTED_HU_M4", 7.674925625409561e-05}, - {Nyxus::Feature2D::WEIGHTED_HU_M5, "WEIGHTED_HU_M5", -4.0924793325555725e-09}, - {Nyxus::Feature2D::WEIGHTED_HU_M6, "WEIGHTED_HU_M6", 0.004652261067232659}, + {Nyxus::Feature2D::WEIGHTED_HU_M5, "WEIGHTED_HU_M5", -1.8387946856938181e-09}, // FIX(h5): snapshot after the calcHu_imp h5 fix (skimage moments_hu on the pinned WT_NORM_CTR_MOM_*; gen_moments_skimage.py) + {Nyxus::Feature2D::WEIGHTED_HU_M6, "WEIGHTED_HU_M6", 2.1453724299933224e-07}, // FIX(h6): snapshot after the calcHu_imp h6 fix; old pin 0.004652... == WT_NORM_CTR_MOM_03, i.e. the stray "+eta03" of the precedence bug (gen_moments_skimage.py) {Nyxus::Feature2D::WEIGHTED_HU_M7, "WEIGHTED_HU_M7", -1.3078000499389243e-09}, }; @@ -97,8 +97,8 @@ static const std::vector unvetted_nyxus_regression_intensi {Nyxus::Feature2D::IMOM_WHU2, "IMOM_WHU2", 5.6161730111679807e-09}, {Nyxus::Feature2D::IMOM_WHU3, "IMOM_WHU3", 1.1923046864432925e-12}, {Nyxus::Feature2D::IMOM_WHU4, "IMOM_WHU4", 3.1287168309169019e-12}, - {Nyxus::Feature2D::IMOM_WHU5, "IMOM_WHU5", -5.2494915279842729e-24}, - {Nyxus::Feature2D::IMOM_WHU6, "IMOM_WHU6", 4.6265447915851515e-07}, + {Nyxus::Feature2D::IMOM_WHU5, "IMOM_WHU5", -3.578057915124686e-25}, // FIX(h5): snapshot after the calcHu_imp h5 fix (skimage moments_hu on the pinned IMOM_WNCM_*; gen_moments_skimage.py) + {Nyxus::Feature2D::IMOM_WHU6, "IMOM_WHU6", 3.297985694277869e-17}, // FIX(h6): snapshot after the calcHu_imp h6 fix; old pin 4.6265447915851515e-07 == IMOM_WNCM_03, i.e. the stray "+eta03" of the precedence bug (gen_moments_skimage.py) {Nyxus::Feature2D::IMOM_WHU7, "IMOM_WHU7", -5.6202519491182231e-24}, }; diff --git a/tests/test_moments_skimage.h b/tests/test_moments_skimage.h index 64b1cf22a..48d912b67 100644 --- a/tests/test_moments_skimage.h +++ b/tests/test_moments_skimage.h @@ -43,7 +43,7 @@ static const std::vector oracle_3p_shape_geomoment_feature {Nyxus::Feature2D::HU_M2, "HU_M2", 0.0009336419753086421}, {Nyxus::Feature2D::HU_M3, "HU_M3", 0}, {Nyxus::Feature2D::HU_M4, "HU_M4", 0}, - {Nyxus::Feature2D::HU_M5, "HU_M5", 4.598098572281346e-10}, + {Nyxus::Feature2D::HU_M5, "HU_M5", 0}, // FIX(h5): oracle-exact 0 on this symmetric fixture (odd etas vanish); the old pin 4.598e-10 was summation noise of the defective h5 formula (gen_moments_skimage.py) {Nyxus::Feature2D::HU_M6, "HU_M6", 0}, {Nyxus::Feature2D::HU_M7, "HU_M7", -2.3604559630908652e-10}, }; @@ -89,11 +89,51 @@ static const std::vector oracle_3p_intensity_geomoment_fea {Nyxus::Feature2D::IMOM_HU2, "IMOM_HU2", 3.7112807351259333e-08}, {Nyxus::Feature2D::IMOM_HU3, "IMOM_HU3", 2.6788774435431954e-11}, {Nyxus::Feature2D::IMOM_HU4, "IMOM_HU4", 2.8991603200922661e-12}, - {Nyxus::Feature2D::IMOM_HU5, "IMOM_HU5", -2.1393783155778043e-23}, - {Nyxus::Feature2D::IMOM_HU6, "IMOM_HU6", -2.3976723312959013e-06}, + {Nyxus::Feature2D::IMOM_HU5, "IMOM_HU5", -1.9898126265836865e-22}, // FIX(h5): skimage-oracle value after the calcHu_imp h5 fix; old pin encoded the 9x-bracket defect (gen_moments_skimage.py) + {Nyxus::Feature2D::IMOM_HU6, "IMOM_HU6", -6.66827346717698e-17}, // FIX(h6): skimage-oracle value after the calcHu_imp h6 fix; old pin -2.3976723312959013e-06 == IMOM_NCM_03, i.e. the stray "+eta03" of the precedence bug (gen_moments_skimage.py) {Nyxus::Feature2D::IMOM_HU7, "IMOM_HU7", 2.3835594243218353e-23}, }; +// Asymmetric wedge fixture (see load_wedge_fixture in test_moments_common.h): the ONLY fixture in +// this file whose odd-order etas are big enough to vet Hu h5/h6 against skimage above the assertion +// tolerance. Provenance: scikit-image 0.26.0 / numpy 2.4.6, generator +// tests/vetting/oracles/gen_moments_skimage.py (section C), 2026-07-16. +static const std::vector wedge_shape_hu_skimage_golden_values{ + {Nyxus::Feature2D::SPAT_MOMENT_00, "SPAT_MOMENT_00", 180}, + {Nyxus::Feature2D::SPAT_MOMENT_10, "SPAT_MOMENT_10", 4560}, + {Nyxus::Feature2D::SPAT_MOMENT_01, "SPAT_MOMENT_01", 420}, + {Nyxus::Feature2D::CENTRAL_MOMENT_20, "CENTRAL_MOMENT_20", 17860}, + {Nyxus::Feature2D::CENTRAL_MOMENT_02, "CENTRAL_MOMENT_02", 699.9999999999999}, + {Nyxus::Feature2D::CENTRAL_MOMENT_11, "CENTRAL_MOMENT_11", 1750}, + {Nyxus::Feature2D::CENTRAL_MOMENT_30, "CENTRAL_MOMENT_30", -99166.66666666698}, + {Nyxus::Feature2D::CENTRAL_MOMENT_03, "CENTRAL_MOMENT_03", 793.3333333333339}, + {Nyxus::Feature2D::CENTRAL_MOMENT_21, "CENTRAL_MOMENT_21", -9916.666666666686}, + {Nyxus::Feature2D::CENTRAL_MOMENT_12, "CENTRAL_MOMENT_12", 1983.3333333333285}, + {Nyxus::Feature2D::NORM_CENTRAL_MOMENT_20, "NORM_CENTRAL_MOMENT_20", 0.5512345679012346}, + {Nyxus::Feature2D::NORM_CENTRAL_MOMENT_02, "NORM_CENTRAL_MOMENT_02", 0.021604938271604934}, + {Nyxus::Feature2D::NORM_CENTRAL_MOMENT_11, "NORM_CENTRAL_MOMENT_11", 0.05401234567901234}, + {Nyxus::Feature2D::NORM_CENTRAL_MOMENT_30, "NORM_CENTRAL_MOMENT_30", -0.22813107795136814}, + {Nyxus::Feature2D::NORM_CENTRAL_MOMENT_03, "NORM_CENTRAL_MOMENT_03", 0.0018250486236109408}, + {Nyxus::Feature2D::NORM_CENTRAL_MOMENT_21, "NORM_CENTRAL_MOMENT_21", -0.022813107795136785}, + {Nyxus::Feature2D::NORM_CENTRAL_MOMENT_12, "NORM_CENTRAL_MOMENT_12", 0.004562621559027338}, + {Nyxus::Feature2D::HU_M1, "HU_M1", 0.5728395061728395}, + {Nyxus::Feature2D::HU_M2, "HU_M2", 0.2921768785246152}, + {Nyxus::Feature2D::HU_M3, "HU_M3", 0.06341348298776381}, + {Nyxus::Feature2D::HU_M4, "HU_M4", 0.05042335332144146}, + {Nyxus::Feature2D::HU_M5, "HU_M5", 0.002851264767850148}, // defective h5 gives 0.0032935269006466807 (442x tolerance away) + {Nyxus::Feature2D::HU_M6, "HU_M6", 0.027252861297278195}, // defective h6 gives 0.02916606310377036 (1913x tolerance away) + {Nyxus::Feature2D::HU_M7, "HU_M7", 5.616461607732461e-06}, +}; + +// Vets Hu h5/h6 (and the full moment chain) against scikit-image on an asymmetric fixture -- +// the historic calcHu_imp h5 9x-bracket / h6 "+eta03"-precedence defects fail this assertion. +void test_moments_hu_wedge_skimage() +{ + std::vector> fvals; + calculate_2d_wedge_geomoment_feature_values(fvals); + assert_2d_geomoment_features(fvals, wedge_shape_hu_skimage_golden_values, "VERIFIABLE_WITH_3P_BUILTIN_ORACLE__"); +} + void test_2d_shape_geometric_moments_verifiable_with_3p_builtin_oracle() { std::vector> fvals; diff --git a/tests/test_morphology_common.h b/tests/test_morphology_common.h index dc53edfba..a72212037 100644 --- a/tests/test_morphology_common.h +++ b/tests/test_morphology_common.h @@ -102,6 +102,14 @@ static std::unordered_map oracle_3p_shape2d_feature_golden_ {"GEODETIC_LENGTH", 10.0}, {"THICKNESS", 3.0}, {"EROSIONS_2_VANISH", 1.0}, + // EXTREMA P1..P8 (X,Y) match MATLAB/Octave regionprops('Extrema') EXACTLY under the documented + // coordinate convention: MATLAB returns 1-based sub-pixel *corner* coords, Nyxus returns 0-based + // pixel *centers*. The corner is direction-specific -> the offset is per-point: left/top coords + // map as (matlab - 0.5), right/bottom coords as (matlab - 1.5). Verified on this 8x8 fixture by + // octave/matlab_oracle_tests/extrema_8x8.m: raw Extrema P1(2.5,0.5) P2(4.5,0.5) P3(6.5,2.5) + // P4(6.5,4.5) P5(5.5,7.5) P6(3.5,7.5) P7(0.5,4.5) P8(0.5,2.5) -> after the per-point offset -> + // P1(2,0) P2(3,0) P3(5,2) P4(5,3) P5(4,6) P6(3,6) P7(0,3) P8(0,2), i.e. these goldens exactly. + // (The earlier "~1px off" on the right/bottom coords was a harness bug: it used a uniform -0.5.) {"EXTREMA_P1_X", 2.0}, {"EXTREMA_P1_Y", 0.0}, {"EXTREMA_P2_X", 3.0}, diff --git a/tests/test_morphology_regression.h b/tests/test_morphology_regression.h index 16e92f7a8..aaf9a4946 100644 --- a/tests/test_morphology_regression.h +++ b/tests/test_morphology_regression.h @@ -60,6 +60,33 @@ void test_shape2d_misc_shape_features() assert_unvetted_no_direct_oracle_shape2d_feature(fvals, Nyxus::Feature2D::DIAMETER_MIN_ENCLOSING_CIRCLE, "DIAMETER_MIN_ENCLOSING_CIRCLE"); } +// Tier-2 documented-formula conformance (NO external oracle). These features have a recognized closed +// form but their VALUE uses Nyxus' own conventions (pixel-count area, contour perimeter, moment-fit +// major axis), so no third-party tool reproduces the number. What we CAN pin is that the code applies +// the published formula to its own constituents without an implementation bug -- recompute the formula +// from AREA_PIXELS_COUNT / PERIMETER / MAJOR_AXIS_LENGTH and require an exact match. This is weaker than +// external-oracle vetting and is registered as such (oracle=formula) in oracle_coverage.csv. +void test_shape2d_documented_formula_conformance_no_external_oracle() +{ + std::vector> fvals; + calculate_shape2d_feature_values(fvals); + + const double PI = 3.14159265358979323846; + const double A = fvals[static_cast(Nyxus::Feature2D::AREA_PIXELS_COUNT)][0]; + const double P = fvals[static_cast(Nyxus::Feature2D::PERIMETER)][0]; + const double major = fvals[static_cast(Nyxus::Feature2D::MAJOR_AXIS_LENGTH)][0]; + + // CIRCULARITY = sqrt(4*pi*A) / P (convex_hull_nontriv.cpp) + const double circ_formula = std::sqrt(4.0 * PI * A) / P; + ASSERT_NEAR(fvals[static_cast(Nyxus::Feature2D::CIRCULARITY)][0], circ_formula, 1e-9) + << "CIRCULARITY does not match sqrt(4*pi*A)/P"; + + // ROUNDNESS = 4*A / (pi*major^2) (ellipse_fitting.cpp) + const double round_formula = 4.0 * A / (PI * major * major); + ASSERT_NEAR(fvals[static_cast(Nyxus::Feature2D::ROUNDNESS)][0], round_formula, 1e-9) + << "ROUNDNESS does not match 4A/(pi*major^2)"; +} + void test_shape2d_unvetted_no_direct_oracle_radius_features() { std::vector> fvals; @@ -140,6 +167,61 @@ void test_remaining2d_verifiable_with_3p_builtin_oracle_caliper_features() assert_verifiable_with_3p_builtin_oracle_remaining2d_feature(fvals, Nyxus::Feature2D::STAT_NASSENSTEIN_DIAM_MODE, "STAT_NASSENSTEIN_DIAM_MODE"); } +// Vets the reimplemented Martin (area-bisecting chord) and Nassenstein (bottom-tangent vertical +// chord) diameters against imea on a clean filled ellipse (a=20, b=10). See the oracle block in +// test_remaining2d_common.h. Robust stats (min/max/mean/median) agree with imea within the +// hull-vs-raster convention tolerance; the >0 lower bound pins that the old min+max-chord bug +// (0-length Nassenstein diameters) is gone. +void test_shape2d_caliper_martin_nassenstein_imea_ellipse_oracle() +{ + std::vector> fvals; + calculate_ellipse_caliper_values(fvals); + + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_MARTIN_DIAM_MIN, "STAT_MARTIN_DIAM_MIN"); + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_MARTIN_DIAM_MAX, "STAT_MARTIN_DIAM_MAX"); + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_MARTIN_DIAM_MEAN, "STAT_MARTIN_DIAM_MEAN"); + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_MARTIN_DIAM_MEDIAN, "STAT_MARTIN_DIAM_MEDIAN"); + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_NASSENSTEIN_DIAM_MIN, "STAT_NASSENSTEIN_DIAM_MIN"); + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_NASSENSTEIN_DIAM_MAX, "STAT_NASSENSTEIN_DIAM_MAX"); + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_NASSENSTEIN_DIAM_MEAN, "STAT_NASSENSTEIN_DIAM_MEAN"); + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_NASSENSTEIN_DIAM_MEDIAN, "STAT_NASSENSTEIN_DIAM_MEDIAN"); + + // Bug-gone invariant: a solid shape cannot have a 0-length diameter (the old code produced 0). + ASSERT_GT(fvals[static_cast(Nyxus::Feature2D::STAT_MARTIN_DIAM_MIN)][0], 2.0); + ASSERT_GT(fvals[static_cast(Nyxus::Feature2D::STAT_NASSENSTEIN_DIAM_MIN)][0], 2.0); +} + +// Vets the Feret diameter distribution against imea on the same filled ellipse. Feret is a correct +// rotating-calipers implementation; robust stats (min/max/mean/median) agree with imea within the +// hull-vs-raster convention tolerance. (MIN/MAX_FERET_ANGLE stay regression — they are a Nyxus-frame +// angle convention with no directly comparable imea output.) +void test_shape2d_caliper_feret_imea_ellipse_oracle() +{ + std::vector> fvals; + calculate_ellipse_caliper_values(fvals); + + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_FERET_DIAM_MIN, "STAT_FERET_DIAM_MIN"); + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_FERET_DIAM_MAX, "STAT_FERET_DIAM_MAX"); + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_FERET_DIAM_MEAN, "STAT_FERET_DIAM_MEAN"); + assert_caliper_close_to_imea(fvals, Nyxus::Feature2D::STAT_FERET_DIAM_MEDIAN, "STAT_FERET_DIAM_MEDIAN"); +} + +// Vets the minimum-enclosing-circle diameter (Welzl / cv2.minEnclosingCircle) against its exact +// geometric/imea value on two clean fixtures: ellipse a=20 -> 2a=40, circle r=15 -> 30. This is +// centroid-independent, so it matches to <0.1%. (DIAMETER_CIRCUMSCRIBING/INSCRIBING_CIRCLE are left +// regression: imea's centroid-to-contour-distance approximation, convention-sensitive.) +void test_shape2d_min_enclosing_circle_imea_oracle() +{ + std::vector> ell; + calculate_ellipse_caliper_values(ell); + assert_caliper_close_to_imea(ell, Nyxus::Feature2D::DIAMETER_MIN_ENCLOSING_CIRCLE, "DIAMETER_MIN_ENCLOSING_CIRCLE", 0.05); + + std::vector> cir; + calculate_circle_shape_values(cir); + const double d = cir[static_cast(Nyxus::Feature2D::DIAMETER_MIN_ENCLOSING_CIRCLE)][0]; + ASSERT_NEAR(d, 30.0, 30.0 * 0.05) << "circle min-enclosing nyxus=" << d << " expected~30"; +} + void test_remaining2d_verifiable_with_3p_builtin_oracle_chord_stat_features() { std::vector> fvals; diff --git a/tests/test_remaining2d_common.h b/tests/test_remaining2d_common.h index 130535095..91076b9ed 100644 --- a/tests/test_remaining2d_common.h +++ b/tests/test_remaining2d_common.h @@ -11,6 +11,7 @@ #include "../src/nyx/features/basic_morphology.h" #include "../src/nyx/features/caliper.h" #include "../src/nyx/features/chords.h" +#include "../src/nyx/features/circle.h" #include "../src/nyx/features/contour.h" #include "../src/nyx/features/convex_hull.h" #include "../src/nyx/features/erosion.h" @@ -24,25 +25,38 @@ static std::unordered_map oracle_3p_remaining2d_feature_golden_values{ {"EROSIONS_2_VANISH_COMPLEMENT", 0.0}, {"MIN_FERET_ANGLE", 40.0}, - {"MAX_FERET_ANGLE", 0.0}, - {"STAT_FERET_DIAM_MIN", 4.0}, - {"STAT_FERET_DIAM_MAX", 5.0}, - {"STAT_FERET_DIAM_MEAN", 4.9473684210526319}, - {"STAT_FERET_DIAM_MEDIAN", 5.0}, - {"STAT_FERET_DIAM_STDDEV", 0.22329687826943606}, + // FIX (caliper float-precision): re-pinned to the float-precision hull-rotation values (see rotation.cpp + // rotate_around_center_fp). The old integer-Pixel2 rotation truncated every rotated vertex inward, + // so these 8x8-fixture goldens shifted when the truncation was removed. MAX_FERET_ANGLE moved 0->110 + // because the per-angle Feret ties differently once the diameters are no longer integer-quantized + // (the Feret angle is a regression-only Nyxus-frame convention, not oracle-vetted). MODE values are + // unchanged. The diameters themselves are vetted vs imea (<=10%) on the ellipse oracle below. + {"MAX_FERET_ANGLE", 110.0}, + {"STAT_FERET_DIAM_MIN", 4.47301}, + {"STAT_FERET_DIAM_MAX", 6.3222}, + {"STAT_FERET_DIAM_MEAN", 5.40848}, + {"STAT_FERET_DIAM_MEDIAN", 5.19615}, + {"STAT_FERET_DIAM_STDDEV", 0.550668}, {"STAT_FERET_DIAM_MODE", 5.0}, - {"STAT_MARTIN_DIAM_MIN", 0.79999995380639899}, - {"STAT_MARTIN_DIAM_MAX", 5.0}, - {"STAT_MARTIN_DIAM_MEAN", 3.1314814727604796}, - {"STAT_MARTIN_DIAM_MEDIAN", 3.5}, - {"STAT_MARTIN_DIAM_STDDEV", 1.6662952583978845}, - {"STAT_MARTIN_DIAM_MODE", 1.0}, - {"STAT_NASSENSTEIN_DIAM_MIN", 0.0}, - {"STAT_NASSENSTEIN_DIAM_MAX", 5.0}, - {"STAT_NASSENSTEIN_DIAM_MEAN", 2.68842592930522}, - {"STAT_NASSENSTEIN_DIAM_MEDIAN", 3.5}, - {"STAT_NASSENSTEIN_DIAM_STDDEV", 2.1985188021518653}, - {"STAT_NASSENSTEIN_DIAM_MODE", 0.0}, + // FIXED (caliper reimpl): Martin is now the area-bisecting chord and Nassenstein the bottom-tangent + // vertical chord (one diameter per angle), not the old min+max of a Y-grid of horizontal chords. + // The old goldens pinned the bug (Martin min 0.8, Nassenstein min/mode 0.0 — impossible for a solid + // shape). These are the corrected values on the 8x8 fixture; the diameters are vetted vs imea on a + // clean ellipse in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. + // FIX (caliper float-precision): re-pinned again after the float-precision hull rotation removed the inward + // integer-truncation bias (MODE unchanged). + {"STAT_MARTIN_DIAM_MIN", 4.25885}, + {"STAT_MARTIN_DIAM_MAX", 6.12801}, + {"STAT_MARTIN_DIAM_MEAN", 5.01762}, + {"STAT_MARTIN_DIAM_MEDIAN", 4.97511}, + {"STAT_MARTIN_DIAM_STDDEV", 0.553162}, + {"STAT_MARTIN_DIAM_MODE", 4.0}, + {"STAT_NASSENSTEIN_DIAM_MIN", 1.67316}, + {"STAT_NASSENSTEIN_DIAM_MAX", 6.24165}, + {"STAT_NASSENSTEIN_DIAM_MEAN", 4.77746}, + {"STAT_NASSENSTEIN_DIAM_MEDIAN", 5.03857}, + {"STAT_NASSENSTEIN_DIAM_STDDEV", 1.09628}, + {"STAT_NASSENSTEIN_DIAM_MODE", 4.0}, {"MAXCHORDS_MAX", 6.0}, {"MAXCHORDS_MIN", 3.0}, {"MAXCHORDS_MEDIAN", 4.0}, @@ -70,7 +84,9 @@ static std::unordered_map unvetted_nyxus_regression_remaini // assertions below now value-compare against them (agrees_gt) so any future drift is caught. {"POLYGONALITY_AVE", 2.0833333333333357}, {"HEXAGONALITY_AVE", 6.8823312738837217}, - {"HEXAGONALITY_STDDEV", 0.18495557498763179}, + // FIX (caliper float-precision): HEXAGONALITY_STDDEV re-pinned (depends on STAT_FERET_DIAM_MIN/MAX, which + // shifted with the float-precision hull rotation); the AVE scores stayed within tolerance. + {"HEXAGONALITY_STDDEV", 0.188079}, // FIXED (chords.cpp idxmax used iteMin): max-angle now indexes the longest chord (angle 0), not the min {"MAXCHORDS_MAX_ANG", 0.0}, {"MAXCHORDS_MIN_ANG", 0.94247779607693793}, @@ -108,6 +124,43 @@ static std::unordered_map> oracle_3p_remaining2 }}, }; +// --------------------------------------------------------------------------------------------------- +// Martin / Nassenstein caliper vetting vs imea (external oracle). +// +// The 8x8 shape2d fixture above is too small/aliased to serve as a tight caliper oracle, so the +// corrected Martin (area-bisecting chord) and Nassenstein (bottom-tangent vertical chord) diameters +// are vetted on a clean, larger convex fixture: a filled ellipse a=20, b=10 (same rasterization as +// morph_oracle/caliper_proto.py). imea (imea.measure_2d.statistical_length, dalpha=10) is the +// reference. Nyxus rotates the convex hull and measures analytically while imea rotates the filled +// raster, so the two agree only up to a ~1-2px hull-vs-raster convention gap (same gap already +// accepted for Feret) — hence a 10% relative tolerance on the robust stats. The point that this pins +// is that the diameters are now the *correct* quantities (min > 0), not the old min+max-chord bug +// that produced physically-impossible 0-length Nassenstein diameters. +static std::unordered_map imea_ellipse_caliper_oracle{ + {"STAT_MARTIN_DIAM_MIN", 19.0}, + {"STAT_MARTIN_DIAM_MAX", 41.0}, + {"STAT_MARTIN_DIAM_MEAN", 27.61}, + {"STAT_MARTIN_DIAM_MEDIAN", 25.5}, + {"STAT_NASSENSTEIN_DIAM_MIN", 16.0}, + {"STAT_NASSENSTEIN_DIAM_MAX", 41.0}, + {"STAT_NASSENSTEIN_DIAM_MEAN", 25.17}, + {"STAT_NASSENSTEIN_DIAM_MEDIAN", 21.5}, + // Feret is a correct rotating-calipers implementation (unlike the Martin/Nassenstein bug); it + // agrees with imea within the same ~1-2px hull-vs-raster convention gap. Reference from imea + // (imea.measure_2d.statistical_length feret_diameters, dalpha=10) on the same ellipse. + {"STAT_FERET_DIAM_MIN", 21.0}, + {"STAT_FERET_DIAM_MAX", 41.0}, + {"STAT_FERET_DIAM_MEAN", 31.72}, + {"STAT_FERET_DIAM_MEDIAN", 32.5}, + // Minimum enclosing circle (Welzl / cv2.minEnclosingCircle) is centroid-independent and matches + // imea/OpenCV exactly: for the ellipse a=20 its diameter = the major axis = 2a = 40. (The circle + // fixture's value 30 is asserted inline.) NOTE: DIAMETER_CIRCUMSCRIBING_CIRCLE and + // DIAMETER_INSCRIBING_CIRCLE are NOT here — they are imea's crude max/min centroid-to-contour + // distance approximation (not a true geometric circle), sensitive to Nyxus's contour convention + + // the centroid-1 offset (a symmetric circle yields 35.6/23.3, not ~30/~30), so they stay regression. + {"DIAMETER_MIN_ENCLOSING_CIRCLE", 40.0}, +}; + static Fsettings make_remaining2d_settings() { Fsettings s; @@ -236,6 +289,125 @@ static void calculate_remaining2d_polygonality_feature_values(std::unordered_map } } +// Build a filled ellipse (a=20, b=10) ROI and compute its caliper features. Mirrors the +// rasterization in morph_oracle/caliper_proto.py so the imea reference values above line up. +static void calculate_ellipse_caliper_values(std::vector>& fvals) +{ + Fsettings s = make_remaining2d_settings(); + + LR roi(1); + const double a = 20.0, b = 10.0, cx = 26.0, cy = 16.0; // pad=6, matches the prototype fixture + bool first = true; + for (int y = 0; y <= 32; y++) + for (int x = 0; x <= 52; x++) + { + double dx = (x - cx) / a, dy = (y - cy) / b; + if (dx * dx + dy * dy <= 1.0) + { + if (first) + { + init_label_record_3(roi, x, y, 1); + first = false; + } + else + update_label_record_3(roi, x, y, 1); + roi.raw_pixels.push_back(Pixel2(static_cast(x), static_cast(y), static_cast(1))); + } + } + roi.make_nonanisotropic_aabb(); + roi.aux_image_matrix = ImageMatrix(roi.raw_pixels); + roi.initialize_fvals(); + + BasicMorphologyFeatures basic; // provides CENTROID_X/Y for the circle features + basic.calculate(roi, s); + basic.save_value(roi.fvals); + + ContourFeature contour; + contour.calculate(roi, s); + contour.save_value(roi.fvals); + + ConvexHullFeature hull; + hull.calculate(roi, s); + hull.save_value(roi.fvals); + + CaliperFeretFeature feret; + feret.calculate(roi, s); + feret.save_value(roi.fvals); + + CaliperMartinFeature martin; + martin.calculate(roi, s); + martin.save_value(roi.fvals); + + CaliperNassensteinFeature nassenstein; + nassenstein.calculate(roi, s); + nassenstein.save_value(roi.fvals); + + EnclosingInscribingCircumscribingCircleFeature circle; + circle.calculate(roi, s); + circle.save_value(roi.fvals); + + fvals = roi.fvals; +} + +// Build a filled circle (r=15) ROI and compute basic morphology + contour + the 3 circle diameters. +static void calculate_circle_shape_values(std::vector>& fvals) +{ + Fsettings s = make_remaining2d_settings(); + + LR roi(2); + const double r = 15.0, cx = 21.0, cy = 21.0; // pad=6 + bool first = true; + for (int y = 0; y <= 42; y++) + for (int x = 0; x <= 42; x++) + { + double dx = (x - cx) / r, dy = (y - cy) / r; + if (dx * dx + dy * dy <= 1.0) + { + if (first) { init_label_record_3(roi, x, y, 1); first = false; } + else update_label_record_3(roi, x, y, 1); + roi.raw_pixels.push_back(Pixel2(static_cast(x), static_cast(y), static_cast(1))); + } + } + roi.make_nonanisotropic_aabb(); + roi.aux_image_matrix = ImageMatrix(roi.raw_pixels); + roi.initialize_fvals(); + + BasicMorphologyFeatures basic; + basic.calculate(roi, s); + basic.save_value(roi.fvals); + + ContourFeature contour; + contour.calculate(roi, s); + contour.save_value(roi.fvals); + + EnclosingInscribingCircumscribingCircleFeature circle; + circle.calculate(roi, s); + circle.save_value(roi.fvals); + + fvals = roi.fvals; +} + +// Assert a caliper stat agrees with imea within a relative tolerance (hull-vs-raster convention gap). +static void assert_caliper_close_to_imea( + const std::vector>& fvals, + Nyxus::Feature2D feature, + const std::string& feature_name, + // FIX (caliper float-precision): tightened 0.15 -> 0.10 after the float-precision hull rotation removed the + // integer-truncation inward bias. Measured residuals on the a=20,b=10 ellipse: Martin 1.8-4.6%, + // Feret 1.4-4.8%, Nassenstein 2.4-3.7% except its bottom-tangent MIN (8.9%) and MEDIAN (6.0%). The + // floor is the Nassenstein MIN: a near-apex vertical tangent chord measured on the convex hull vs + // imea's raster - a definitional hull-vs-raster gap, not a precision loss, so 0.10 is the honest bound. + double reltol = 0.10) +{ + SCOPED_TRACE(std::string("CALIPER_VS_IMEA__") + feature_name); + ASSERT_TRUE(imea_ellipse_caliper_oracle.count(feature_name) > 0); + const double imea_ref = imea_ellipse_caliper_oracle[feature_name]; + const double actual = fvals[static_cast(feature)][0]; + const double denom = std::max(std::abs(imea_ref), 1e-9); + ASSERT_LE(std::abs(actual - imea_ref) / denom, reltol) + << feature_name << " nyxus=" << actual << " imea=" << imea_ref; +} + static void assert_unvetted_no_direct_oracle_remaining2d_feature( const std::vector>& fvals, Nyxus::Feature2D feature, diff --git a/tests/vetting/MIGRATION.md b/tests/vetting/MIGRATION.md index 52196a7cf..0ade8ba18 100644 --- a/tests/vetting/MIGRATION.md +++ b/tests/vetting/MIGRATION.md @@ -195,11 +195,15 @@ Patterns confirmed across the family: - **Systemic orphan finding:** `test_3d_.h` is **orphaned** (never `#include`d, tests never run) for **glcm, glrlm, glszm, gldm, ngtdm** (5 families, ~130 dead 3D-regression assertions). But **ngldm and gldzm have LIVE `test_3d_*` files** (they were `#include`d) → renamed - `test_3d_ngldm_ibsi.h` / `test_3d_gldzm_ibsi.h`. This inconsistency (some native 3D texture tests + `test_3d_ngldm_regression.h` (initially `_ibsi`; corrected in PR #385, see below) / + `test_3d_gldzm_ibsi.h`. This inconsistency (some native 3D texture tests wired, most not) is flagged for a coverage-gap triage; live 3D texture coverage otherwise comes from the `test_3d__pyradiomics.h` (ex-compat) files + parameterized `test_3d_feature_coverage.h`. -- The `test_3d_ngldm_ibsi.h` / `test_3d_gldzm_ibsi.h` kind labels are provisional (IBSI-phantom-based, - not fully confirmed); the rename preserved all tests regardless. +- `test_3d_gldzm_ibsi.h`'s kind label is provisional (IBSI-phantom-based, not fully confirmed). The + matching NGLDM file was CORRECTED to `test_3d_ngldm_regression.h` (PR #385): its `d3ngldm_GT` table + has no provenance, runs on the Nyxus coverage phantom (not the IBSI digital phantom), and disagrees + with MIRP by up to ~10x, so it is a drift guard, not an `_ibsi` oracle. The `_ibsi` suffix is left + free for a genuine 3D IBSI-phantom NGLDM oracle. The rename preserved all tests regardless. **Remaining families for later waves:** moments, morphology, firstorder, intensity_histogram, neighbor, imq, gabor, zernike, radial — plus the cross-cutting `test_3d_feature_coverage.h` split (§4) and the diff --git a/tests/vetting/coverage_report.md b/tests/vetting/coverage_report.md index c2ba00546..4e31cf53f 100644 --- a/tests/vetting/coverage_report.md +++ b/tests/vetting/coverage_report.md @@ -2,22 +2,22 @@ _Generated by check_coverage.py from oracle_coverage.csv. Do not edit by hand._ -Features vetted by >=1 oracle: 455/758 (60%) -regression: 303 untested: 0 +Features vetted by >=1 oracle: 604/758 (80%) +regression: 154 untested: 0 | family | total | vetted | regression | untested | |---|---|---|---|---| -| firstorder | 72 | 53 | 19 | 0 | +| firstorder | 72 | 71 | 1 | 0 | | gabor | 1 | 0 | 1 | 0 | -| glcm | 118 | 72 | 46 | 0 | -| gldm | 28 | 14 | 14 | 0 | +| glcm | 118 | 118 | 0 | 0 | +| gldm | 28 | 28 | 0 | 0 | | gldzm | 36 | 17 | 19 | 0 | -| glrlm | 64 | 38 | 26 | 0 | -| glszm | 32 | 26 | 6 | 0 | +| glrlm | 64 | 64 | 0 | 0 | +| glszm | 32 | 32 | 0 | 0 | | imq | 6 | 0 | 6 | 0 | | intensity_histogram | 47 | 47 | 0 | 0 | -| moments | 180 | 117 | 63 | 0 | -| morphology | 113 | 39 | 74 | 0 | +| moments | 180 | 118 | 62 | 0 | +| morphology | 113 | 77 | 36 | 0 | | neighbor | 9 | 2 | 7 | 0 | | ngldm | 38 | 20 | 18 | 0 | | ngtdm | 10 | 10 | 0 | 0 | diff --git a/tests/vetting/oracle_coverage.csv b/tests/vetting/oracle_coverage.csv index bc6c44269..8882b7448 100644 --- a/tests/vetting/oracle_coverage.csv +++ b/tests/vetting/oracle_coverage.csv @@ -55,7 +55,7 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,ELONGATION,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_matlab.h,,,tracker, 2D,ECCENTRICITY,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_matlab.h,,,tracker, 2D,ORIENTATION,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Clarify convention or verify with anoth...,promote-after-deepdive;convention-mismatch,tracker, -2D,ROUNDNESS,morphology,regression,,needs_audit,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,none (Nyxus-unique formula),no-mainstream-oracle,tracker, +2D,ROUNDNESS,morphology,vetted,analytic,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,no-external-oracle,tracker,"Tier-2 documented-formula conformance, NO external oracle. Nyxus computes ROUNDNESS = 4*A/(pi*major^2) (ellipse_fitting.cpp) with A = ROI pixel count and major = moment-fit MAJOR_AXIS_LENGTH. TEST_SHAPE2D_DOCUMENTED_FORMULA_CONFORMANCE_NO_EXTERNAL_ORACLE recomputes the formula and requires an exact match (1e-9). Implementation bug-free, but the value depends on Nyxus' pixel-area + moment-fit conventions; no third-party tool reproduces it. Formula-vetted only." 2D,PERIMETER,morphology,vetted,skimage,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_features.h;test_nyxus.py;test_morphology_regression.h,test_morphology_skimage.h,,,tracker, 2D,DIAMETER_EQUAL_PERIMETER,morphology,regression,,needs_audit,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_nyxus.py;test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive,tracker, 2D,EDGE_MEAN_INTENSITY,morphology,vetted,cellprofiler,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_nyxus.py;test_morphology_regression.h,test_morphology_cellprofiler.h,,,tracker, @@ -63,33 +63,33 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,EDGE_MAX_INTENSITY,morphology,vetted,cellprofiler,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_cellprofiler.h,,,tracker, 2D,EDGE_MIN_INTENSITY,morphology,vetted,cellprofiler,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_cellprofiler.h,,,tracker, 2D,EDGE_INTEGRATED_INTENSITY,morphology,vetted,cellprofiler,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_cellprofiler.h,,,tracker, -2D,CIRCULARITY,morphology,regression,,needs_audit,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_skimage.h,test_morphology_regression.h,pyradiomics:Sphericity(2D) (formula-identical),oracle-identified,tracker, -2D,CONVEX_HULL_AREA,morphology,regression,,sus,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h;test_convex_hull_invariants.py;test_morphology_skimage.h,test_morphology_regression.h,Document Nyxus point-hull convention or revise expected value.,promote-after-deepdive;suspected-bug,tracker, -2D,SOLIDITY,morphology,regression,,sus,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_convex_hull_invariants.py;test_morphology_skimage.h,test_morphology_regression.h,Document Nyxus point-hull convention or revise expected value.,promote-after-deepdive;suspected-bug,tracker, +2D,CIRCULARITY,morphology,vetted,analytic,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_skimage.h,test_morphology_regression.h,,no-external-oracle,tracker,"Tier-2 documented-formula conformance, NO external oracle. Nyxus computes CIRCULARITY = sqrt(4*pi*A)/P (convex_hull_nontriv.cpp) with A = ROI pixel count and P = contour perimeter. TEST_SHAPE2D_DOCUMENTED_FORMULA_CONFORMANCE_NO_EXTERNAL_ORACLE recomputes the formula from AREA_PIXELS_COUNT/PERIMETER and requires an exact match (1e-9), so the implementation is bug-free -- but the value is convention-specific (pixel-area + contour-perimeter) and no third-party tool reproduces it (pyradiomics/skimage use mesh area/perimeter). Formula-vetted only." +2D,CONVEX_HULL_AREA,morphology,vetted,skimage,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h;test_convex_hull_invariants.py;test_morphology_skimage.h,test_morphology_regression.h,Document Nyxus point-hull convention or revise expected value.,,tracker,"vetted vs scikit-image (convex_hull_image offset_coordinates=False, Pick's-theorem convex_area); test_morphology_skimage.h TEST_SHAPE2D_CONVEX_HULL_FEATURES passes. The prior matlab-based 'suspected-bug' (SOLIDITY 1.0 vs matlab 0.97) was a wrong-oracle artifact: Nyxus deliberately matches skimage, not matlab regionprops ConvexArea." +2D,SOLIDITY,morphology,vetted,skimage,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_convex_hull_invariants.py;test_morphology_skimage.h,test_morphology_regression.h,Document Nyxus point-hull convention or revise expected value.,,tracker,"vetted vs scikit-image (convex_hull_image offset_coordinates=False, Pick's-theorem convex_area); test_morphology_skimage.h TEST_SHAPE2D_CONVEX_HULL_FEATURES passes. The prior matlab-based 'suspected-bug' (SOLIDITY 1.0 vs matlab 0.97) was a wrong-oracle artifact: Nyxus deliberately matches skimage, not matlab regionprops ConvexArea." 2D,EROSIONS_2_VANISH,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, 2D,EROSIONS_2_VANISH_COMPLEMENT,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,FRACT_DIM_BOXCOUNT,morphology,regression,,needs_audit,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_fractal_dim_oracle.py;test_morphology_fraclac.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive,tracker, -2D,FRACT_DIM_PERIMETER,morphology,regression,,needs_audit,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_fractal_dim_oracle.py;test_morphology_fraclac.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive,tracker, +2D,FRACT_DIM_BOXCOUNT,morphology,vetted,fraclac,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_fractal_dim_oracle.py;test_morphology_fraclac.h,test_morphology_fraclac.h,,,tracker,"vetted vs ImageJ/FracLac box-count on the 512x512 blob fixture (tests/data/fractal_blob512_seg.ome.tif) = 1.8706, same-method direct match at 1% (test_morphology_fraclac.h). Also test_fractal_dim_oracle.py recovers analytic ground-truth dimensions: filled square -> 2.0, straight line -> 1.0, Sierpinski triangle -> 1.585." +2D,FRACT_DIM_PERIMETER,morphology,vetted,fraclac,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_fractal_dim_oracle.py;test_morphology_fraclac.h,test_morphology_fraclac.h,,,tracker,"vetted vs ImageJ/FracLac edge box-count on the blob512 fixture = 1.0493, cross-method vs Nyxus' Richardson divider (1.0572, 0.8% gap), asserted at 3% (test_morphology_fraclac.h). Also test_fractal_dim_oracle.py: Koch snowflake and smooth-disk perimeter dimensions match." 2D,MIN_FERET_ANGLE,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; do not mark agreed unless a tool matches within 5%.,promote-after-deepdive;convention-mismatch,tracker, 2D,MAX_FERET_ANGLE,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; do not mark agreed unless a tool matches within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_FERET_DIAM_MIN,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; do not mark agreed unless a tool matches within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_FERET_DIAM_MAX,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; do not mark agreed unless a tool matches within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_FERET_DIAM_MEAN,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_FERET_DIAM_MEDIAN,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_FERET_DIAM_STDDEV,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_FERET_DIAM_MODE,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_MARTIN_DIAM_MIN,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_MARTIN_DIAM_MAX,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_MARTIN_DIAM_MEAN,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_MARTIN_DIAM_MEDIAN,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_MARTIN_DIAM_STDDEV,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_MARTIN_DIAM_MODE,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_NASSENSTEIN_DIAM_MIN,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_NASSENSTEIN_DIAM_MAX,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_NASSENSTEIN_DIAM_MEAN,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_NASSENSTEIN_DIAM_MEDIAN,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_NASSENSTEIN_DIAM_STDDEV,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, -2D,STAT_NASSENSTEIN_DIAM_MODE,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, +2D,STAT_FERET_DIAM_MIN,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length feret_diameters, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_FERET_IMEA_ELLIPSE_ORACLE. Correct rotating-calipers impl (caliper_feret.cpp, width of the rotated convex-hull AABB per angle); no code change needed. MIN/MAX_FERET_ANGLE stay regression (Nyxus-frame angle convention, no directly comparable imea output)." +2D,STAT_FERET_DIAM_MAX,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length feret_diameters, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_FERET_IMEA_ELLIPSE_ORACLE. Correct rotating-calipers impl (caliper_feret.cpp, width of the rotated convex-hull AABB per angle); no code change needed. MIN/MAX_FERET_ANGLE stay regression (Nyxus-frame angle convention, no directly comparable imea output)." +2D,STAT_FERET_DIAM_MEAN,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length feret_diameters, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_FERET_IMEA_ELLIPSE_ORACLE. Correct rotating-calipers impl (caliper_feret.cpp, width of the rotated convex-hull AABB per angle); no code change needed. MIN/MAX_FERET_ANGLE stay regression (Nyxus-frame angle convention, no directly comparable imea output)." +2D,STAT_FERET_DIAM_MEDIAN,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length feret_diameters, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_FERET_IMEA_ELLIPSE_ORACLE. Correct rotating-calipers impl (caliper_feret.cpp, width of the rotated convex-hull AABB per angle); no code change needed. MIN/MAX_FERET_ANGLE stay regression (Nyxus-frame angle convention, no directly comparable imea output)." +2D,STAT_FERET_DIAM_STDDEV,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length feret_diameters, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_FERET_IMEA_ELLIPSE_ORACLE. Correct rotating-calipers impl (caliper_feret.cpp, width of the rotated convex-hull AABB per angle); no code change needed. MIN/MAX_FERET_ANGLE stay regression (Nyxus-frame angle convention, no directly comparable imea output)." +2D,STAT_FERET_DIAM_MODE,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length feret_diameters, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_FERET_IMEA_ELLIPSE_ORACLE. Correct rotating-calipers impl (caliper_feret.cpp, width of the rotated convex-hull AABB per angle); no code change needed. MIN/MAX_FERET_ANGLE stay regression (Nyxus-frame angle convention, no directly comparable imea output)." +2D,STAT_MARTIN_DIAM_MIN,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_MARTIN_DIAM_MAX,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_MARTIN_DIAM_MEAN,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_MARTIN_DIAM_MEDIAN,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_MARTIN_DIAM_STDDEV,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_MARTIN_DIAM_MODE,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_NASSENSTEIN_DIAM_MIN,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_NASSENSTEIN_DIAM_MAX,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_NASSENSTEIN_DIAM_MEAN,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_NASSENSTEIN_DIAM_MEDIAN,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_NASSENSTEIN_DIAM_STDDEV,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." +2D,STAT_NASSENSTEIN_DIAM_MODE,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs imea (imea.measure_2d.statistical_length, dalpha=10) on a filled ellipse a=20 b=10; robust stats (min/max/mean/median) agree within the ~1-2px hull-vs-raster convention gap (15% reltol) in TEST_SHAPE2D_CALIPER_MARTIN_NASSENSTEIN_IMEA_ELLIPSE_ORACLE. Fixed a bug: caliper_martin.cpp and caliper_nassenstein.cpp shared byte-identical code that pushed BOTH the shortest and longest of a Y-grid of horizontal chords per angle, so Nassenstein min/mode were 0.0 (impossible for a solid shape). Now Martin = area-bisecting horizontal chord and Nassenstein = bottom-tangent vertical chord, one diameter per rotation angle." 2D,MAXCHORDS_MAX,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h;test_feature_oracle.py,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, 2D,MAXCHORDS_MAX_ANG,morphology,regression,,na,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h;test_feature_oracle.py,test_morphology_regression.h,none (imea gives lengths not angles),no-mainstream-oracle,tracker, 2D,MAXCHORDS_MIN,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h;test_feature_oracle.py,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, @@ -109,26 +109,26 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,EULER_NUMBER,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_matlab.h,,,tracker, 2D,EXTREMA_P1_X,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker, 2D,EXTREMA_P1_Y,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker, -2D,EXTREMA_P2_X,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_regression.h,matlab,promote-after-deepdive;convention-mismatch,tracker, +2D,EXTREMA_P2_X,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker,"vetted vs MATLAB/Octave regionprops('Extrema'). These are the right/bottom-edge coordinates; the earlier '~1px off' was a harness bug (uniform -0.5). MATLAB returns 1-based sub-pixel corner coords with a direction-specific corner: right/bottom coords map to Nyxus 0-based centers as (matlab-1.5), left/top as (matlab-0.5). Under that convention Nyxus matches regionprops EXACTLY (verified octave/matlab_oracle_tests/extrema_8x8.m; goldens in test_morphology_common.h). test_morphology_matlab.h asserts all 16 EXTREMA components." 2D,EXTREMA_P2_Y,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker, -2D,EXTREMA_P3_X,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_regression.h,matlab,promote-after-deepdive;convention-mismatch,tracker, +2D,EXTREMA_P3_X,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker,"vetted vs MATLAB/Octave regionprops('Extrema'). These are the right/bottom-edge coordinates; the earlier '~1px off' was a harness bug (uniform -0.5). MATLAB returns 1-based sub-pixel corner coords with a direction-specific corner: right/bottom coords map to Nyxus 0-based centers as (matlab-1.5), left/top as (matlab-0.5). Under that convention Nyxus matches regionprops EXACTLY (verified octave/matlab_oracle_tests/extrema_8x8.m; goldens in test_morphology_common.h). test_morphology_matlab.h asserts all 16 EXTREMA components." 2D,EXTREMA_P3_Y,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker, -2D,EXTREMA_P4_X,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_regression.h,matlab,promote-after-deepdive;convention-mismatch,tracker, -2D,EXTREMA_P4_Y,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_regression.h,matlab,promote-after-deepdive;convention-mismatch,tracker, -2D,EXTREMA_P5_X,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_regression.h,matlab,promote-after-deepdive;convention-mismatch,tracker, -2D,EXTREMA_P5_Y,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_regression.h,matlab,promote-after-deepdive;convention-mismatch,tracker, +2D,EXTREMA_P4_X,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker,"vetted vs MATLAB/Octave regionprops('Extrema'). These are the right/bottom-edge coordinates; the earlier '~1px off' was a harness bug (uniform -0.5). MATLAB returns 1-based sub-pixel corner coords with a direction-specific corner: right/bottom coords map to Nyxus 0-based centers as (matlab-1.5), left/top as (matlab-0.5). Under that convention Nyxus matches regionprops EXACTLY (verified octave/matlab_oracle_tests/extrema_8x8.m; goldens in test_morphology_common.h). test_morphology_matlab.h asserts all 16 EXTREMA components." +2D,EXTREMA_P4_Y,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker,"vetted vs MATLAB/Octave regionprops('Extrema'). These are the right/bottom-edge coordinates; the earlier '~1px off' was a harness bug (uniform -0.5). MATLAB returns 1-based sub-pixel corner coords with a direction-specific corner: right/bottom coords map to Nyxus 0-based centers as (matlab-1.5), left/top as (matlab-0.5). Under that convention Nyxus matches regionprops EXACTLY (verified octave/matlab_oracle_tests/extrema_8x8.m; goldens in test_morphology_common.h). test_morphology_matlab.h asserts all 16 EXTREMA components." +2D,EXTREMA_P5_X,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker,"vetted vs MATLAB/Octave regionprops('Extrema'). These are the right/bottom-edge coordinates; the earlier '~1px off' was a harness bug (uniform -0.5). MATLAB returns 1-based sub-pixel corner coords with a direction-specific corner: right/bottom coords map to Nyxus 0-based centers as (matlab-1.5), left/top as (matlab-0.5). Under that convention Nyxus matches regionprops EXACTLY (verified octave/matlab_oracle_tests/extrema_8x8.m; goldens in test_morphology_common.h). test_morphology_matlab.h asserts all 16 EXTREMA components." +2D,EXTREMA_P5_Y,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker,"vetted vs MATLAB/Octave regionprops('Extrema'). These are the right/bottom-edge coordinates; the earlier '~1px off' was a harness bug (uniform -0.5). MATLAB returns 1-based sub-pixel corner coords with a direction-specific corner: right/bottom coords map to Nyxus 0-based centers as (matlab-1.5), left/top as (matlab-0.5). Under that convention Nyxus matches regionprops EXACTLY (verified octave/matlab_oracle_tests/extrema_8x8.m; goldens in test_morphology_common.h). test_morphology_matlab.h asserts all 16 EXTREMA components." 2D,EXTREMA_P6_X,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker, -2D,EXTREMA_P6_Y,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_regression.h,matlab,promote-after-deepdive;convention-mismatch,tracker, +2D,EXTREMA_P6_Y,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker,"vetted vs MATLAB/Octave regionprops('Extrema'). These are the right/bottom-edge coordinates; the earlier '~1px off' was a harness bug (uniform -0.5). MATLAB returns 1-based sub-pixel corner coords with a direction-specific corner: right/bottom coords map to Nyxus 0-based centers as (matlab-1.5), left/top as (matlab-0.5). Under that convention Nyxus matches regionprops EXACTLY (verified octave/matlab_oracle_tests/extrema_8x8.m; goldens in test_morphology_common.h). test_morphology_matlab.h asserts all 16 EXTREMA components." 2D,EXTREMA_P7_X,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker, -2D,EXTREMA_P7_Y,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_regression.h,matlab,promote-after-deepdive;convention-mismatch,tracker, +2D,EXTREMA_P7_Y,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker,"vetted vs MATLAB/Octave regionprops('Extrema'). These are the right/bottom-edge coordinates; the earlier '~1px off' was a harness bug (uniform -0.5). MATLAB returns 1-based sub-pixel corner coords with a direction-specific corner: right/bottom coords map to Nyxus 0-based centers as (matlab-1.5), left/top as (matlab-0.5). Under that convention Nyxus matches regionprops EXACTLY (verified octave/matlab_oracle_tests/extrema_8x8.m; goldens in test_morphology_common.h). test_morphology_matlab.h asserts all 16 EXTREMA components." 2D,EXTREMA_P8_X,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker, 2D,EXTREMA_P8_Y,morphology,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_matlab.h,test_morphology_matlab.h,,,tracker, 2D,POLYGONALITY_AVE,morphology,regression,,na,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,none (Nyxus/WIPP-unique),no-mainstream-oracle,tracker, 2D,HEXAGONALITY_AVE,morphology,regression,,na,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,none (Nyxus/WIPP-unique),no-mainstream-oracle,tracker, 2D,HEXAGONALITY_STDDEV,morphology,regression,,na,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,none (Nyxus/WIPP-unique),no-mainstream-oracle,tracker, -2D,DIAMETER_MIN_ENCLOSING_CIRCLE,morphology,regression,,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,imea:min_enclosing_circle (returns diameter),oracle-identified,tracker, -2D,DIAMETER_CIRCUMSCRIBING_CIRCLE,morphology,regression,,needs_audit,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive,tracker, -2D,DIAMETER_INSCRIBING_CIRCLE,morphology,regression,,needs_audit,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive,tracker, +2D,DIAMETER_MIN_ENCLOSING_CIRCLE,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,,tracker,"vetted vs OpenCV/imea minimum enclosing circle (Welzl; cv2.minEnclosingCircle, imea min_enclosing_circle) on clean fixtures: ellipse a=20 -> 2a=40, circle r=15 -> 30; centroid-independent, matches to <0.1% in TEST_SHAPE2D_MIN_ENCLOSING_CIRCLE_IMEA_ORACLE." +2D,DIAMETER_CIRCUMSCRIBING_CIRCLE,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,convention-mismatch,tracker,"Left regression: Nyxus (circle.cpp, ported from imea macro.py) computes this as imea's max/min centroid-to-contour distance approximation, NOT a true geometric circle. It is convention-sensitive (Nyxus contour tracing + the centroid-1 offset): a symmetric circle r=15 yields circ=35.6/insc=23.3 instead of ~30/~30, so no external numeric oracle agrees -> documented-convention, not promoted. (DIAMETER_MIN_ENCLOSING_CIRCLE IS vetted vs cv2/imea.)" +2D,DIAMETER_INSCRIBING_CIRCLE,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,,convention-mismatch,tracker,"Left regression: Nyxus (circle.cpp, ported from imea macro.py) computes this as imea's max/min centroid-to-contour distance approximation, NOT a true geometric circle. It is convention-sensitive (Nyxus contour tracing + the centroid-1 offset): a symmetric circle r=15 yields circ=35.6/insc=23.3 instead of ~30/~30, so no external numeric oracle agrees -> documented-convention, not promoted. (DIAMETER_MIN_ENCLOSING_CIRCLE IS vetted vs cv2/imea.)" 2D,GEODETIC_LENGTH,morphology,regression,,benign,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Definition review; keep as verifiable convention mismatch unless another oracle agrees within 5%.,promote-after-deepdive;convention-mismatch,tracker, 2D,THICKNESS,morphology,vetted,imea,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_imea.h,,,tracker, 2D,ROI_RADIUS_MEAN,morphology,regression,,needs_audit,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_morphology_regression.h,test_morphology_regression.h,Contour-convention audit,promote-after-deepdive,tracker, @@ -144,9 +144,9 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,ANG_BW_NEIGHBORS_STDDEV,neighbor,regression,,na,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_neighbor_regression.h,test_neighbor_regression.h,none (CP AngleBetweenNeighbors differs) -> scipy analytic,no-mainstream-oracle,tracker, 2D,ANG_BW_NEIGHBORS_MODE,neighbor,regression,,na,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_neighbor_regression.h,test_neighbor_regression.h,none (CP AngleBetweenNeighbors differs) -> scipy analytic,no-mainstream-oracle,tracker, 2D,GLCM_ASM,glcm,vetted,matlab,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h;test_nyxus.py,test_glcm_matlab.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -2D,GLCM_ACOR,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_pyradiomics.py;test_glcm_ibsi.h,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +2D,GLCM_ACOR,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_pyradiomics.py;test_glcm_ibsi.h,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" 2D,GLCM_CLUPROM,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -2D,GLCM_CLUSHADE,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +2D,GLCM_CLUSHADE,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" 2D,GLCM_CLUTEND,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_CONTRAST,glcm,vetted,matlab,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_pyradiomics.py;test_glcm_ibsi.h;test_nyxus.py,test_glcm_matlab.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_CORRELATION,glcm,vetted,matlab,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h;test_nyxus.py,test_glcm_matlab.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A @@ -155,28 +155,28 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,GLCM_DIFVAR,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h;test_nyxus.py,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_DIS,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_ENERGY,glcm,vetted,matlab,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_nyxus.py,test_glcm_matlab.h,,,tracker, -2D,GLCM_ENTROPY,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_nyxus.py,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, +2D,GLCM_ENTROPY,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_nyxus.py,test_glcm_regression.h,MIRP/IBSI check,,tracker,"FIXED (missing /sum_p normalization added to f_GLCM_HOM2/f_entropy) + vetted vs MIRP/IBSI: HOM2==IDM 0.619, ENTROPY==JE 2.05. New IBSI gtests TEST_IBSI_GLCM_HOM2/ENTROPY pin it; regression goldens updated" 2D,GLCM_HOM1,glcm,vetted,matlab,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_nyxus.py,test_glcm_matlab.h,,,tracker, -2D,GLCM_HOM2,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, +2D,GLCM_HOM2,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,,tracker,"FIXED (missing /sum_p normalization added to f_GLCM_HOM2/f_entropy) + vetted vs MIRP/IBSI: HOM2==IDM 0.619, ENTROPY==JE 2.05. New IBSI gtests TEST_IBSI_GLCM_HOM2/ENTROPY pin it; regression goldens updated" 2D,GLCM_ID,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_IDN,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_pyradiomics.py;test_glcm_ibsi.h,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_IDM,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h;test_nyxus.py,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_IDMN,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_pyradiomics.py;test_glcm_ibsi.h,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -2D,GLCM_INFOMEAS1,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h;test_nyxus.py,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +2D,GLCM_INFOMEAS1,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h;test_nyxus.py,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" 2D,GLCM_INFOMEAS2,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h;test_nyxus.py,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_IV,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -2D,GLCM_JAVE,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +2D,GLCM_JAVE,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" 2D,GLCM_JE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_JMAX,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -2D,GLCM_JVAR,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -2D,GLCM_SUMAVERAGE,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_pyradiomics.py;test_glcm_ibsi.h;test_nyxus.py,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +2D,GLCM_JVAR,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" +2D,GLCM_SUMAVERAGE,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_pyradiomics.py;test_glcm_ibsi.h;test_nyxus.py,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" 2D,GLCM_SUMENTROPY,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_glcm_regression.h;test_glcm_ibsi.h;test_nyxus.py,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_SUMVARIANCE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glcm.h;test_glcm_regression.h;test_glcm_ibsi.h;test_nyxus.py,test_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 2D,GLCM_VARIANCE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_nyxus.py,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_ASM_AVE,glcm,vetted,matlab,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_matlab.h,,,tracker, -2D,GLCM_ACOR_AVE,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_glcm_pyradiomics.py,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, +2D,GLCM_ACOR_AVE,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_glcm_pyradiomics.py,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" 2D,GLCM_CLUPROM_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, -2D,GLCM_CLUSHADE_AVE,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, +2D,GLCM_CLUSHADE_AVE,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" 2D,GLCM_CLUTEND_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_CONTRAST_AVE,glcm,vetted,matlab,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_glcm_pyradiomics.py,test_glcm_matlab.h,,,tracker, 2D,GLCM_CORRELATION_AVE,glcm,vetted,matlab,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_matlab.h,,,tracker, @@ -185,21 +185,21 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,GLCM_DIFVAR_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_DIS_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_ENERGY_AVE,glcm,vetted,matlab,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_matlab.h,,,tracker, -2D,GLCM_ENTROPY_AVE,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, +2D,GLCM_ENTROPY_AVE,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,,tracker,"FIXED (missing /sum_p normalization added to f_GLCM_HOM2/f_entropy) + vetted vs MIRP/IBSI: HOM2==IDM 0.619, ENTROPY==JE 2.05. New IBSI gtests TEST_IBSI_GLCM_HOM2/ENTROPY pin it; regression goldens updated" 2D,GLCM_HOM1_AVE,glcm,vetted,matlab,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_matlab.h,,,tracker, 2D,GLCM_ID_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_IDN_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_glcm_pyradiomics.py,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_IDM_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_IDMN_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_glcm_pyradiomics.py,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_IV_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, -2D,GLCM_JAVE_AVE,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, +2D,GLCM_JAVE_AVE,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" 2D,GLCM_JE_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, -2D,GLCM_INFOMEAS1_AVE,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, +2D,GLCM_INFOMEAS1_AVE,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" 2D,GLCM_INFOMEAS2_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_VARIANCE_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_JMAX_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, -2D,GLCM_JVAR_AVE,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, -2D,GLCM_SUMAVERAGE_AVE,glcm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_glcm_pyradiomics.py,test_glcm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, +2D,GLCM_JVAR_AVE,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" +2D,GLCM_SUMAVERAGE_AVE,glcm,vetted,mirp,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h;test_glcm_pyradiomics.py,test_glcm_regression.h,MIRP/IBSI check,,tracker,"vetted vs MIRP (IBSI digital phantom, glcm_oracle/); MIRP reproduces IBSI consensus exactly and Nyxus IBSI gtest passes (ACOR 5.09, CLUSHADE 7, INFOMEAS1 -0.155, JAVE 2.14, JVAR 2.69, SUMAVERAGE 4.28); _AVE = angle-average of the vetted base" 2D,GLCM_SUMENTROPY_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, 2D,GLCM_SUMVARIANCE_AVE,glcm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glcm_regression.h,test_glcm_pyradiomics.h,,,tracker, 2D,GLRLM_SRE,glrlm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A @@ -212,12 +212,12 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,GLRLM_GLV,glrlm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A 2D,GLRLM_RV,glrlm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A 2D,GLRLM_RE,glrlm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -2D,GLRLM_LGLRE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -2D,GLRLM_HGLRE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -2D,GLRLM_SRLGLE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -2D,GLRLM_SRHGLE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -2D,GLRLM_LRLGLE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -2D,GLRLM_LRHGLE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +2D,GLRLM_LGLRE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM LGLRE = 0.604) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions. IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 0.6044 (matches the IBSI reference); ibsi=false gives 0.1147 on the same fixture -- default-mode values are NOT vetted." +2D,GLRLM_HGLRE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM HGLRE = 9.82) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions. IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 9.824 (matches the IBSI reference); ibsi=false gives 4087.74 on the same fixture -- default-mode values are NOT vetted." +2D,GLRLM_SRLGLE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM SRLGLE = 0.294) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions. IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 0.2940 (matches the IBSI reference); ibsi=false gives 0.1036 on the same fixture -- default-mode values are NOT vetted." +2D,GLRLM_SRHGLE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM SRHGLE = 8.57) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions. IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 8.573 (matches the IBSI reference); ibsi=false gives 3545.21 on the same fixture -- default-mode values are NOT vetted." +2D,GLRLM_LRLGLE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM LRLGLE = 3.14) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions. IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 3.144 (matches the IBSI reference); ibsi=false gives 0.1614 on the same fixture -- default-mode values are NOT vetted." +2D,GLRLM_LRHGLE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_glrlm_regression.h;test_glrlm_ibsi.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM LRHGLE = 17.4) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions. IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 17.387 (matches the IBSI reference); ibsi=false gives 7358.78 on the same fixture -- default-mode values are NOT vetted." 2D,GLRLM_SRE_AVE,glrlm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_pyradiomics.h,,,tracker, 2D,GLRLM_LRE_AVE,glrlm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_pyradiomics.h,,,tracker, 2D,GLRLM_GLN_AVE,glrlm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_pyradiomics.h,,,tracker, @@ -228,12 +228,12 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,GLRLM_GLV_AVE,glrlm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_pyradiomics.h,,,tracker, 2D,GLRLM_RV_AVE,glrlm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_pyradiomics.h,,,tracker, 2D,GLRLM_RE_AVE,glrlm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_pyradiomics.h,,,tracker, -2D,GLRLM_LGLRE_AVE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, -2D,GLRLM_HGLRE_AVE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, -2D,GLRLM_SRLGLE_AVE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, -2D,GLRLM_SRHGLE_AVE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, -2D,GLRLM_LRLGLE_AVE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, -2D,GLRLM_LRHGLE_AVE,glrlm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_glrlm_regression.h,test_glrlm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker, +2D,GLRLM_LGLRE_AVE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_glrlm_regression.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM LGLRE = 0.604) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions (_AVE direction-mean, same grand mean). IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 0.6044 (matches the IBSI reference); ibsi=false gives 0.1147 on the same fixture -- default-mode values are NOT vetted." +2D,GLRLM_HGLRE_AVE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_glrlm_regression.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM HGLRE = 9.82) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions (_AVE direction-mean, same grand mean). IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 9.824 (matches the IBSI reference); ibsi=false gives 4087.74 on the same fixture -- default-mode values are NOT vetted." +2D,GLRLM_SRLGLE_AVE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_glrlm_regression.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM SRLGLE = 0.294) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions (_AVE direction-mean, same grand mean). IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 0.2940 (matches the IBSI reference); ibsi=false gives 0.1036 on the same fixture -- default-mode values are NOT vetted." +2D,GLRLM_SRHGLE_AVE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_glrlm_regression.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM SRHGLE = 8.57) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions (_AVE direction-mean, same grand mean). IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 8.573 (matches the IBSI reference); ibsi=false gives 3545.21 on the same fixture -- default-mode values are NOT vetted." +2D,GLRLM_LRLGLE_AVE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_glrlm_regression.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM LRLGLE = 3.14) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions (_AVE direction-mean, same grand mean). IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 3.144 (matches the IBSI reference); ibsi=false gives 0.1614 on the same fixture -- default-mode values are NOT vetted." +2D,GLRLM_LRHGLE_AVE,glrlm,vetted,ibsi,agreed,"glrlm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over 4 slices x 4 directions. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_glrlm_regression.h,test_glrlm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLRLM LRHGLE = 17.4) at 1% in test_glrlm_ibsi.h, ibsi=True Ng=128, averaged over the 4 phantom slices x 4 directions (_AVE direction-mean, same grand mean). IBSI is the reference table (§4); no tool run at CI time. CONFIG SCOPE (measured): ibsi=true gives 17.387 (matches the IBSI reference); ibsi=false gives 7358.78 on the same fixture -- default-mode values are NOT vetted." 2D,GLDZM_SDE,gldzm,vetted,mirp,agreed,Mode-specific 2D texture row. MIRP checks use IBSI phantom slices with no discretization and by_slice=True; agreement is for that mode/scope.,,test_3d_gldzm_ibsi.h;test_gldzm_ibsi.h,test_gldzm_mirp.h,,,tracker, 2D,GLDZM_LDE,gldzm,vetted,mirp,agreed,Mode-specific 2D texture row. MIRP checks use IBSI phantom slices with no discretization and by_slice=True; agreement is for that mode/scope.,,test_3d_gldzm_ibsi.h;test_gldzm_ibsi.h,test_gldzm_mirp.h,,,tracker, 2D,GLDZM_LGLZE,gldzm,vetted,mirp,agreed,Mode-specific 2D texture row. MIRP checks use IBSI phantom slices with no discretization and by_slice=True; agreement is for that mode/scope.,,test_3d_gldzm_ibsi.h;test_gldzm_ibsi.h,test_gldzm_mirp.h,,,tracker, @@ -259,48 +259,48 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,GLSZM_SZN,glszm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A 2D,GLSZM_SZNN,glszm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A 2D,GLSZM_ZP,glszm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -2D,GLSZM_GLV,glszm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +2D,GLSZM_GLV,glszm,vetted,ibsi,agreed,"glszm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLSZM GLV = 3.97) at 1% in test_glszm_ibsi.h (ibsi=True Ng=128, averaged over the 4 phantom slices). The wired test already passed; reconciled from regression. IBSI reference table (SPEC §4). CONFIG SCOPE (measured): ibsi=true gives 3.9695 (matches the IBSI reference); ibsi=false gives 2227.54 on the same fixture -- default-mode values are NOT vetted." 2D,GLSZM_ZV,glszm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A 2D,GLSZM_ZE,glszm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -2D,GLSZM_LGLZE,glszm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -2D,GLSZM_HGLZE,glszm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +2D,GLSZM_LGLZE,glszm,vetted,ibsi,agreed,"glszm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLSZM LGLZE = 0.371) at 1% in test_glszm_ibsi.h (ibsi=True Ng=128, averaged over the 4 phantom slices). The wired test already passed; reconciled from regression. IBSI reference table (SPEC §4). CONFIG SCOPE (measured): ibsi=true gives 0.3712 (matches the IBSI reference); ibsi=false gives 0.1944 on the same fixture -- default-mode values are NOT vetted." +2D,GLSZM_HGLZE,glszm,vetted,ibsi,agreed,"glszm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLSZM HGLZE = 16.4) at 1% in test_glszm_ibsi.h (ibsi=True Ng=128, averaged over the 4 phantom slices). The wired test already passed; reconciled from regression. IBSI reference table (SPEC §4). CONFIG SCOPE (measured): ibsi=true gives 16.441 (matches the IBSI reference); ibsi=false gives 5985.62 on the same fixture -- default-mode values are NOT vetted." 2D,GLSZM_SALGLE,glszm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -2D,GLSZM_SAHGLE,glszm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -2D,GLSZM_LALGLE,glszm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -2D,GLSZM_LAHGLE,glszm,regression,,needs_audit,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_SDE,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_mechanics.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_LDE,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_mechanics.h;test_gldm_pyradiomics.py;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_GLN,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_mechanics.h;test_gldm_pyradiomics.py;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_DN,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_mechanics.h;test_gldm_pyradiomics.py;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_DNN,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_mechanics.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_GLV,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_DV,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_DE,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_LGLE,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker, -2D,GLDM_HGLE,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_SDLGLE,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_SDHGLE,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_LDLGLE,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,GLDM_LDHGLE,gldm,regression,,sus,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_regression.h,MIRP/IBSI check,promote-after-deepdive;suspected-bug,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -2D,NGLDM_LDE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_HDE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_LGLCE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_HGLCE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_LDLGLE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_LDHGLE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_HDLGLE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_HDHGLE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_GLNU,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_GLNUN,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_DCNU,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_DCNUN,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_DCP,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_GLM,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Primitive-chain MIRP MatrixNGLDM checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_GLV,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_DCM,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Primitive-chain MIRP MatrixNGLDM checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_DCV,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_DCENT,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, -2D,NGLDM_DCENE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_ibsi.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,GLSZM_SAHGLE,glszm,vetted,ibsi,agreed,"glszm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLSZM SAHGLE = 10.3) at 1% in test_glszm_ibsi.h (ibsi=True Ng=128, averaged over the 4 phantom slices). The wired test already passed; reconciled from regression. IBSI reference table (SPEC §4). CONFIG SCOPE (measured): ibsi=true gives 10.278 (matches the IBSI reference); ibsi=false gives 3521.46 on the same fixture -- default-mode values are NOT vetted." +2D,GLSZM_LALGLE,glszm,vetted,ibsi,agreed,"glszm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLSZM LALGLE = 40.4) at 1% in test_glszm_ibsi.h (ibsi=True Ng=128, averaged over the 4 phantom slices). The wired test already passed; reconciled from regression. IBSI reference table (SPEC §4). CONFIG SCOPE (measured): ibsi=true gives 40.398 (matches the IBSI reference); ibsi=false gives 0.5908 on the same fixture -- default-mode values are NOT vetted." +2D,GLSZM_LAHGLE,glszm,vetted,ibsi,agreed,"glszm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights these grey-level-dependent features by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_glszm_regression.h;test_glszm_ibsi.h,test_glszm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom consensus (GLSZM LAHGLE = 113) at 1% in test_glszm_ibsi.h (ibsi=True Ng=128, averaged over the 4 phantom slices). The wired test already passed; reconciled from regression. IBSI reference table (SPEC §4). CONFIG SCOPE (measured): ibsi=true gives 112.52 (matches the IBSI reference); ibsi=false gives 44190.2 on the same fixture -- default-mode values are NOT vetted." +2D,GLDM_SDE,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 (MODE-INVARIANT) -- vetted vs IBSI consensus at ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4. MEASURED to be bit-identical at ibsi=false on the same fixture, so this feature is structure-only (no grey-level weighting) and the vetting holds in Nyxus' default mode too -- unconditional.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_mechanics.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_SDE = 0.158) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true and ibsi=false both give 0.15807 -- MODE-INVARIANT, so the vetting also covers default mode." +2D,GLDM_LDE,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 (MODE-INVARIANT) -- vetted vs IBSI consensus at ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4. MEASURED to be bit-identical at ibsi=false on the same fixture, so this feature is structure-only (no grey-level weighting) and the vetting holds in Nyxus' default mode too -- unconditional.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_mechanics.h;test_gldm_pyradiomics.py;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_LDE = 19.2) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true and ibsi=false both give 19.1738 -- MODE-INVARIANT, so the vetting also covers default mode." +2D,GLDM_GLN,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 (MODE-INVARIANT) -- vetted vs IBSI consensus at ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4. MEASURED to be bit-identical at ibsi=false on the same fixture, so this feature is structure-only (no grey-level weighting) and the vetting holds in Nyxus' default mode too -- unconditional.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_mechanics.h;test_gldm_pyradiomics.py;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_GLN = 10.2) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true and ibsi=false both give 10.2464 -- MODE-INVARIANT, so the vetting also covers default mode." +2D,GLDM_DN,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 (MODE-INVARIANT) -- vetted vs IBSI consensus at ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4. MEASURED to be bit-identical at ibsi=false on the same fixture, so this feature is structure-only (no grey-level weighting) and the vetting holds in Nyxus' default mode too -- unconditional.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_mechanics.h;test_gldm_pyradiomics.py;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_DN = 3.96) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true and ibsi=false both give 3.96465 -- MODE-INVARIANT, so the vetting also covers default mode." +2D,GLDM_DNN,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 (MODE-INVARIANT) -- vetted vs IBSI consensus at ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4. MEASURED to be bit-identical at ibsi=false on the same fixture, so this feature is structure-only (no grey-level weighting) and the vetting holds in Nyxus' default mode too -- unconditional.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_mechanics.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_DNN = 0.212) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true and ibsi=false both give 0.211772 -- MODE-INVARIANT, so the vetting also covers default mode." +2D,GLDM_GLV,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights this grey-level-dependent feature by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_GLV = 2.7) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true gives 2.7037 (matches the IBSI reference); ibsi=false gives 1221.07 on the same fixture -- default-mode values are NOT vetted." +2D,GLDM_DV,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 (MODE-INVARIANT) -- vetted vs IBSI consensus at ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4. MEASURED to be bit-identical at ibsi=false on the same fixture, so this feature is structure-only (no grey-level weighting) and the vetting holds in Nyxus' default mode too -- unconditional.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_DV = 2.73) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true and ibsi=false both give 2.7295 -- MODE-INVARIANT, so the vetting also covers default mode." +2D,GLDM_DE,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 (MODE-INVARIANT) -- vetted vs IBSI consensus at ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4. MEASURED to be bit-identical at ibsi=false on the same fixture, so this feature is structure-only (no grey-level weighting) and the vetting holds in Nyxus' default mode too -- unconditional.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_DE = 2.71) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true and ibsi=false both give 2.71215 -- MODE-INVARIANT, so the vetting also covers default mode." +2D,GLDM_LGLE,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights this grey-level-dependent feature by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_LGLE = 0.702) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true gives 0.70175 (matches the IBSI reference); ibsi=false gives 0.0014516 on the same fixture -- default-mode values are NOT vetted." +2D,GLDM_HGLE,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights this grey-level-dependent feature by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_HGLE = 7.49) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true gives 7.48695 (matches the IBSI reference); ibsi=false gives 3452.84 on the same fixture -- default-mode values are NOT vetted." +2D,GLDM_SDLGLE,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights this grey-level-dependent feature by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_SDLGLE = 0.0473) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true gives 0.0472905 (matches the IBSI reference); ibsi=false gives 9.8277e-05 on the same fixture -- default-mode values are NOT vetted." +2D,GLDM_SDHGLE,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights this grey-level-dependent feature by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_SDHGLE = 3.06) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true gives 3.06491 (matches the IBSI reference); ibsi=false gives 1402.44 on the same fixture -- default-mode values are NOT vetted." +2D,GLDM_LDLGLE,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights this grey-level-dependent feature by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_LDLGLE = 17.6) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true gives 17.5997 (matches the IBSI reference); ibsi=false gives 0.0363725 on the same fixture -- default-mode values are NOT vetted." +2D,GLDM_LDHGLE,gldm,vetted,ibsi,agreed,"gldm.ibsi_ng128 -- ibsi=true, GREYDEPTH=128, IBSI digital phantom z1..z4, mean over the 4 slices. VETTED ONLY ON THIS RECIPE: Nyxus' default mode (ibsi=false) weights this grey-level-dependent feature by RAW intensity instead of the grey-level index, and is NOT covered by this assertion.",,test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_gldm_regression.h;test_gldm_ibsi.h,test_gldm_ibsi.h,,,tracker,"vetted vs IBSI digital-phantom NGLDM consensus (GLDM_LDHGLE = 49.5) at 1% in test_gldm_ibsi.h (ibsi=True Ng=128, 4 phantom slices). Nyxus GLDM is the pyradiomics-lineage naming of the neighbouring grey-level-dependence matrix that IBSI calls NGLDM (same quantity; verified the gldm and ngldm IBSI reference tables are identical). The wired test already passed; reconciled from regression. CONFIG SCOPE (measured): ibsi=true gives 49.4777 (matches the IBSI reference); ibsi=false gives 23183.9 on the same fixture -- default-mode values are NOT vetted." +2D,NGLDM_LDE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_HDE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_LGLCE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_HGLCE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_LDLGLE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_LDHGLE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_HDLGLE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_HDHGLE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_GLNU,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_GLNUN,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_DCNU,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_DCNUN,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_DCP,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_GLM,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Primitive-chain MIRP MatrixNGLDM checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_GLV,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_DCM,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Primitive-chain MIRP MatrixNGLDM checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_DCV,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_DCENT,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, +2D,NGLDM_DCENE,ngldm,vetted,mirp,agreed,"Mode-specific 2D texture row. Direct MIRP checks use Nyxus IBSI=true/no-binning IBSI phantom slices with MIRP base_discretisation_method=""none"", by_slice=True; GLM/DCM remain no-direct-oracle.",,test_3d_ngldm_regression.h;test_ngldm_ibsi.h,test_ngldm_mirp.h,,,tracker, 2D,NGTDM_COARSENESS,ngtdm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_ngtdm_ibsi.h;test_ngtdm_regression.h,test_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A 2D,NGTDM_CONTRAST,ngtdm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_ngtdm_ibsi.h;test_ngtdm_regression.h,test_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A 2D,NGTDM_BUSYNESS,ngtdm,vetted,pyradiomics,agreed,"Mode-specific 2D texture row. PyRadiomics/MIRP comparison is tied to verifier settings such as force2D/by_slice, binWidth/binCount or no discretization, symmetry, and aggregation.",,test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_ngtdm_ibsi.h;test_ngtdm_regression.h,test_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A @@ -367,8 +367,8 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,HU_M2,moments,vetted,skimage,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_moments_skimage.h,test_moments_skimage.h,,,tracker, 2D,HU_M3,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix 2D,HU_M4,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix -2D,HU_M5,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix -2D,HU_M6,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix +2D,HU_M5,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix; h5 9x-bracket formula fixed and vetted on the asymmetric wedge (test_moments_hu_wedge_skimage; gen_moments_skimage.py) +2D,HU_M6,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix; h6 stray-eta03 precedence bug fixed and vetted on the asymmetric wedge (test_moments_hu_wedge_skimage; gen_moments_skimage.py) 2D,HU_M7,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix 2D,WEIGHTED_SPAT_MOMENT_00,moments,regression,,na,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_moments_regression.h,test_moments_regression.h,none (Nyxus-specific weighted moment),oracle-identified,tracker,"no mainstream oracle: skimage weighted moments correspond to Nyxus IMOM_ (non-W) family, already vetted; the W-variants are a distinct Nyxus/WIPP-specific weighting (IMOM_WRM_00=512893 vs sum-intensity=346635)" 2D,WEIGHTED_SPAT_MOMENT_01,moments,regression,,na,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_moments_regression.h,test_moments_regression.h,none (Nyxus-specific weighted moment),oracle-identified,tracker,"no mainstream oracle: skimage weighted moments correspond to Nyxus IMOM_ (non-W) family, already vetted; the W-variants are a distinct Nyxus/WIPP-specific weighting (IMOM_WRM_00=512893 vs sum-intensity=346635)" @@ -458,7 +458,7 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,IMOM_HU3,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix 2D,IMOM_HU4,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix 2D,IMOM_HU5,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix -2D,IMOM_HU6,moments,regression,,needs_audit,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_moments_regression.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,skimage flag rel=35956418749.65425 (residual near-zero) +2D,IMOM_HU6,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted after the calcHu_imp h6 precedence fix; the old skimage flag rel=3.6e10 WAS the bug (h6 emitted the raw eta03 == IMOM_NCM_03; gen_moments_skimage.py) 2D,IMOM_HU7,moments,vetted,skimage,exact (rel=0.0e+00),moments.skimage_regionprops,exact,test_moments_regression.h;test_moments_skimage.h,test_moments_regression.h,Document convention or find another direct built-in oracle.,promote-after-deepdive,tracker,PROMOTED regression->vetted vs skimage regionprops after centroid-truncation fix 2D,IMOM_WRM_00,moments,regression,,na,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_moments_regression.h,test_moments_regression.h,none (Nyxus-specific weighted moment),oracle-identified,tracker,"no mainstream oracle: skimage weighted moments correspond to Nyxus IMOM_ (non-W) family, already vetted; the W-variants are a distinct Nyxus/WIPP-specific weighting (IMOM_WRM_00=512893 vs sum-intensity=346635)" 2D,IMOM_WRM_01,moments,regression,,na,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_moments_regression.h,test_moments_regression.h,none (Nyxus-specific weighted moment),oracle-identified,tracker,"no mainstream oracle: skimage weighted moments correspond to Nyxus IMOM_ (non-W) family, already vetted; the W-variants are a distinct Nyxus/WIPP-specific weighting (IMOM_WRM_00=512893 vs sum-intensity=346635)" @@ -538,56 +538,56 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,IH_ROBUST_MEAN_IDX,intensity_histogram,vetted,analytic,,ih.ibsi_fbn,,test_intensity_histogram_ibsi.h,,mirp (IBSI Intensity-Histogram family); vet _IDX,not-in-tracker;oracle-identified,audit,"vetted by test_ih_dispersion_robust_analytic (hand-computed on the robust-window discriminating fixture; no clean IBSI anchor - robust-mean has no IBSI feature; QCoD_VAL/IQR_VAL are Nyxus continuous-percentile extensions of the IBSI discrete-percentile IQR/QCoD, so they have no IBSI counterpart by design)" 2D,IH_NUM_BINS,intensity_histogram,vetted,analytic,,,,test_intensity_histogram_regression.h;test_intensity_histogram.py,test_intensity_histogram_analytic.h,numpy/scipy analytic,not-in-tracker,audit, 2D,IH_BIN_SIZE,intensity_histogram,vetted,analytic,,,,test_intensity_histogram_regression.h;test_intensity_histogram.py,test_intensity_histogram_analytic.h,numpy/scipy analytic,not-in-tracker,audit, -3D,3COV,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,mirp:stat_cov (IBSI 7TET; no pyradiomics equiv),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3COVERED_IMAGE_INTENSITY_RANGE,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,none (Nyxus-specific: uses image dynamic range),no-mainstream-oracle,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3COV,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,mirp:stat_cov (IBSI 7TET; no pyradiomics equiv),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3COVERED_IMAGE_INTENSITY_RANGE,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,none (Nyxus-specific: (roi_max-roi_min)/(slide_max-slide_min)),bug-degenerate,tracker,BUG: 3D segmented workflow never sets roi.slide_idx (workflow_3d_segmented.cpp) so 3d_intensity.cpp takes the else branch and always emits degenerate 1; unvettable until slide_idx+min/max_preroi_inten are wired through the 3D pipeline (2D sets it via pixel_feed.cpp). Oracle (octave/oracle_3d) confirms intended value = (roi_range)/(slide_range) 3D,3ENERGY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 3D,3ENTROPY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3EXCESS_KURTOSIS,firstorder,regression,,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3HYPERFLATNESS,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,none (scipy.moment order 5/6 only),no-mainstream-oracle,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3HYPERSKEWNESS,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,none (scipy.moment order 5/6 only),no-mainstream-oracle,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3INTEGRATED_INTENSITY,firstorder,regression,,needs_audit,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),analytic-trivial,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3EXCESS_KURTOSIS,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3HYPERFLATNESS,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,none (scipy.moment order 5/6 only),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3HYPERSKEWNESS,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,none (scipy.moment order 5/6 only),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3INTEGRATED_INTENSITY,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) 3D,3INTERQUARTILE_RANGE,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 3D,3KURTOSIS,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 3D,3MAX,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 3D,3MEAN,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 3D,3MEAN_ABSOLUTE_DEVIATION,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 3D,3MEDIAN,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3MEDIAN_ABSOLUTE_DEVIATION,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,mirp:stat_medad (IBSI N72L),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3MEDIAN_ABSOLUTE_DEVIATION,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,mirp:stat_medad (IBSI N72L),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) 3D,3MIN,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3MODE,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,scipy.stats.mode on raw voxels (IBSI mode is on histogram),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3P01,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),analytic-trivial,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3MODE,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,scipy.stats.mode on raw voxels (IBSI mode is on histogram),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3P01,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) 3D,3P10,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3P25,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),analytic-trivial,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3P75,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),analytic-trivial,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3P25,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) +3D,3P75,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) 3D,3P90,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3P99,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),analytic-trivial,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3QCOD,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3P99,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) +3D,3QCOD,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) 3D,3RANGE,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3ROBUST_MEAN,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3ROBUST_MEAN,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,octave/MATLAB (mean of voxels in [P10,P90]),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; rel<1e-9) after fixing 3ROBUST_MEAN which was hardcoded 0 in both 3d_intensity.cpp paths 3D,3ROBUST_MEAN_ABSOLUTE_DEVIATION,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 3D,3ROOT_MEAN_SQUARED,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 3D,3SKEWNESS,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3STANDARD_DEVIATION,firstorder,regression,,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3STANDARD_DEVIATION_BIASED,firstorder,regression,,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3STANDARD_ERROR,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3STANDARD_DEVIATION,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3STANDARD_DEVIATION_BIASED,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3STANDARD_ERROR,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) 3D,3VARIANCE,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3VARIANCE_BIASED,firstorder,regression,,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3VARIANCE_BIASED,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) 3D,3UNIFORMITY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3UNIFORMITY_PIU,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),oracle-identified,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3AREA,morphology,regression,,needs_audit,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,matlab,promote-after-deepdive,tracker, -3D,3AREA_2_VOLUME,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:SurfaceVolumeRatio(3D),oracle-identified,tracker, -3D,3COMPACTNESS1,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:Compactness1/Compactness2(3D),oracle-identified,tracker, -3D,3COMPACTNESS2,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:Compactness1/Compactness2(3D),oracle-identified,tracker, +3D,3UNIFORMITY_PIU,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3AREA,morphology,regression,,needs_audit,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,matlab,convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." +3D,3AREA_2_VOLUME,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:SurfaceVolumeRatio(3D),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." +3D,3COMPACTNESS1,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:Compactness1/Compactness2(3D),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." +3D,3COMPACTNESS2,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:Compactness1/Compactness2(3D),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." 3D,3MESH_VOLUME,morphology,vetted,matlab,agreed,,,test_3d_morphology_coverage.h;test_3d_morphology_matlab.h,test_3d_morphology_matlab.h,,,tracker, -3D,3SPHERICAL_DISPROPORTION,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:SphericalDisproportion(3D shape),oracle-identified,tracker, -3D,3SPHERICITY,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:Sphericity(3D shape),oracle-identified,tracker, +3D,3SPHERICAL_DISPROPORTION,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:SphericalDisproportion(3D shape),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." +3D,3SPHERICITY,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:Sphericity(3D shape),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." 3D,3VOLUME_CONVEXHULL,morphology,vetted,matlab,agreed,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_matlab.h,,,tracker, 3D,3VOXEL_VOLUME,morphology,vetted,matlab,agreed,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_matlab.h,,,tracker, -3D,3MAJOR_AXIS_LEN,morphology,regression,,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:MajorAxisLength(3D shape),oracle-identified,tracker, -3D,3MINOR_AXIS_LEN,morphology,regression,,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:MinorAxisLength(3D shape),oracle-identified,tracker, -3D,3LEAST_AXIS_LEN,morphology,regression,,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:LeastAxisLength(3D shape),oracle-identified,tracker, -3D,3ELONGATION,morphology,regression,,benign,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:Elongation(3D shape),oracle-identified,tracker, -3D,3FLATNESS,morphology,regression,,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:Flatness(3D shape),oracle-identified,tracker, +3D,3MAJOR_AXIS_LEN,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:MajorAxisLength(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" +3D,3MINOR_AXIS_LEN,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:MinorAxisLength(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" +3D,3LEAST_AXIS_LEN,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:LeastAxisLength(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" +3D,3ELONGATION,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:Elongation(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" +3D,3FLATNESS,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:Flatness(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" 3D,3GLCM_ACOR,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 3D,3GLCM_ASM,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 3D,3GLCM_CLUPROM,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A @@ -598,11 +598,11 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 3D,3GLCM_DIFAVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 3D,3GLCM_DIFENTRO,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 3D,3GLCM_DIFVAR,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_DIS,glcm,regression,,na,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",oracle-identified-config-sensitive,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_ENERGY,glcm,regression,,na,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",oracle-identified-config-sensitive,tracker, -3D,3GLCM_ENTROPY,glcm,regression,,na,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",oracle-identified-config-sensitive,tracker, -3D,3GLCM_HOM1,glcm,regression,,na,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",oracle-identified-config-sensitive,tracker, -3D,3GLCM_HOM2,glcm,regression,,na,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",oracle-identified-config-sensitive,tracker, +3D,3GLCM_DIS,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_DIFAVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ENERGY,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ASM (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ENTROPY,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_HOM1,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ID (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_HOM2,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_IDM (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" 3D,3GLCM_ID,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 3D,3GLCM_IDN,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 3D,3GLCM_IDM,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A @@ -616,37 +616,37 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 3D,3GLCM_JVAR,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 3D,3GLCM_SUMAVERAGE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A 3D,3GLCM_SUMENTROPY,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_SUMVARIANCE,glcm,regression,,na,,,test_3d_glcm_coverage.h;test_3d_glcm.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",oracle-identified-config-sensitive,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_VARIANCE,glcm,regression,,na,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",oracle-identified-config-sensitive,tracker, -3D,3GLCM_ASM_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_ACOR_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_CLUPROM_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_CLUSHADE_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_CLUTEND_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_CONTRAST_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_CORRELATION_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLCM_SUMVARIANCE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_CLUTEND (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_VARIANCE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JVAR (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ASM_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_ACOR_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_CLUPROM_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_CLUSHADE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_CLUTEND_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_CONTRAST_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_CORRELATION_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" 3D,3GLCM_DIFAVE_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_mirp.h,,,tracker, -3D,3GLCM_DIFENTRO_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_DIFVAR_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLCM_DIFENTRO_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_DIFVAR_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" 3D,3GLCM_DIS_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_mirp.h,,,tracker, -3D,3GLCM_ENERGY_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_ENTROPY_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_HOM1_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_ID_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLCM_ENERGY_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ASM_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ENTROPY_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JE_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_HOM1_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ID_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ID_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" 3D,3GLCM_IDN_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_mirp.h,,,tracker, -3D,3GLCM_IDM_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLCM_IDM_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" 3D,3GLCM_IDMN_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_mirp.h,,,tracker, -3D,3GLCM_IV_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_JAVE_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_JE_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_INFOMEAS1_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLCM_IV_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_JAVE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_JE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_INFOMEAS1_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" 3D,3GLCM_INFOMEAS2_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_mirp.h,,,tracker, -3D,3GLCM_VARIANCE_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_JMAX_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_JVAR_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_SUMAVERAGE_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_SUMENTROPY_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLCM_SUMVARIANCE_AVE,glcm,regression,,needs_audit,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLCM_VARIANCE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JVAR_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_JMAX_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_JVAR_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_SUMAVERAGE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_SUMENTROPY_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_SUMVARIANCE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_CLUTEND_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" 3D,3GLDM_SDE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A 3D,3GLDM_LDE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A 3D,3GLDM_GLN,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A @@ -679,25 +679,25 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 3D,3GLDZM_ZDM,gldzm,regression,,na,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,none (intermediate mean; indirect via GLV/ZDV/DCV variances),no-mainstream-oracle,tracker, 3D,3GLDZM_ZDV,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, 3D,3GLDZM_ZDE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_LDE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_HDE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_LGLCE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_HGLCE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_LDLGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_LDHGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_HDLGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_HDHGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_GLNU,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_GLNUN,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_DCNU,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_DCNUN,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_DCP,ngldm,vetted,mirp,agreed,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_mirp.h,,,tracker, -3D,3NGLDM_GLM,ngldm,regression,,na,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,none (intermediate mean; indirect via GLV/ZDV/DCV variances),no-mainstream-oracle,tracker, -3D,3NGLDM_GLV,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_DCM,ngldm,regression,,na,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,none (intermediate mean; indirect via GLV/ZDV/DCV variances),no-mainstream-oracle,tracker, -3D,3NGLDM_DCV,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_DCENT,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_DCENE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_ibsi.h,test_3d_ngldm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3NGLDM_LDE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LDE: table=0.1 vs MIRP=0.2559." +3D,3NGLDM_HDE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HDE: table=261 vs MIRP=28.07." +3D,3NGLDM_LGLCE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LGLCE: table=0.00036 vs MIRP=0.0322." +3D,3NGLDM_HGLCE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HGLCE: table=740 vs MIRP=1324." +3D,3NGLDM_LDLGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LDLGLE: table=5.8e-05 vs MIRP=0.000685." +3D,3NGLDM_LDHGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LDHGLE: table=74 vs MIRP=474.8." +3D,3NGLDM_HDLGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HDLGLE: table=0.025 vs MIRP=8.714." +3D,3NGLDM_HDHGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HDHGLE: table=20030 vs MIRP=14942.8." +3D,3NGLDM_GLNU,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_GLNU: table=115443 vs MIRP=4350.3." +3D,3NGLDM_GLNUN,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_GLNUN: table=0.23 vs MIRP=0.01585." +3D,3NGLDM_DCNU,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCNU: table=115443 vs MIRP=40745.0." +3D,3NGLDM_DCNUN,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCNUN: table=0.23 vs MIRP=0.14847." +3D,3NGLDM_DCP,ngldm,vetted,mirp,agreed,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_mirp.h,,,tracker, +3D,3NGLDM_GLM,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,no-external-oracle,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. Additionally 3NGLDM_GLM has NO counterpart in IBSI/MIRP at all (MIRP's NGLDM emits no grey-level-mean or dependence-count-mean column; the 2D table in test_ngldm_ibsi.h likewise marks GLM '--not in IBSI--'), so no external oracle exists for it." +3D,3NGLDM_GLV,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_GLV: table=190 vs MIRP=350.17." +3D,3NGLDM_DCM,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,no-external-oracle,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. Additionally 3NGLDM_DCM has NO counterpart in IBSI/MIRP at all (MIRP's NGLDM emits no grey-level-mean or dependence-count-mean column; the 2D table in test_ngldm_ibsi.h likewise marks GLM '--not in IBSI--'), so no external oracle exists for it." +3D,3NGLDM_DCV,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCV: table=86.17 vs MIRP=11.948." +3D,3NGLDM_DCENT,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCENT: table=5.23 vs MIRP=8.676." +3D,3NGLDM_DCENE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCENE: table=0.14 vs MIRP=0.002875." 3D,3NGTDM_COARSENESS,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A 3D,3NGTDM_CONTRAST,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A 3D,3NGTDM_BUSYNESS,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A @@ -735,22 +735,22 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 3D,3GLRLM_SRHGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A 3D,3GLRLM_LRLGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A 3D,3GLRLM_LRHGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_SRE_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_LRE_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_GLN_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_GLNN_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_RLN_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_RLNN_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLRLM_SRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_LRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_GLN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_GLNN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_RLN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_RLNN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." 3D,3GLRLM_RP_AVE,glrlm,vetted,mirp,agreed,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_mirp.h,,,tracker, -3D,3GLRLM_GLV_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_RV_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLRLM_GLV_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_RV_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." 3D,3GLRLM_RE_AVE,glrlm,vetted,mirp,agreed,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_mirp.h,,,tracker, -3D,3GLRLM_LGLRE_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_HGLRE_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_SRLGLE_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_SRHGLE_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_LRLGLE_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLRLM_LRHGLE_AVE,glrlm,regression,,needs_audit,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLRLM_LGLRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_HGLRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_SRLGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_SRHGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_LRLGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_LRHGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." IMQ,FOCUS_SCORE,imq,regression,,,,,test_imq_regression.h,test_imq_regression.h,reference IQ library (e.g. skimage/BRISQUE) or analytic,not-in-tracker,audit, IMQ,LOCAL_FOCUS_SCORE,imq,regression,,,,,test_imq_regression.h,test_imq_regression.h,reference IQ library (e.g. skimage/BRISQUE) or analytic,not-in-tracker,audit, IMQ,POWER_SPECTRUM_SLOPE,imq,regression,,,,,test_imq_regression.h,test_imq_regression.h,reference IQ library (e.g. skimage/BRISQUE) or analytic,not-in-tracker,audit, diff --git a/tests/vetting/oracles/gen_moments_skimage.py b/tests/vetting/oracles/gen_moments_skimage.py new file mode 100644 index 000000000..b7083505f --- /dev/null +++ b/tests/vetting/oracles/gen_moments_skimage.py @@ -0,0 +1,260 @@ +"""OFFLINE golden generator for the Hu-invariant goldens in test_moments_skimage.h / +test_moments_regression.h (SPEC 6.4), refreshed for the calcHu_imp h5/h6 formula fix. + +Context. Nyxus's calcHu_imp had two formula defects (both also present in the _nt and CUDA +twins, fixed together): + - h5: the second bracket computed (3*(eta30+eta12))^2 == 9*(eta30+eta12)^2 instead of + Hu's 3*(eta30+eta12)^2. + - h6: a precedence error left "+eta03" outside the product -- 4*eta11*(eta30+eta12)*eta21 + eta03 + instead of 4*eta11*(eta30+eta12)*(eta21+eta03) -- so the stray raw eta03 dominated h6. + (Tell-tale in the old pinned goldens: IMOM_HU6 == IMOM_NCM_03, WEIGHTED_HU_M6 == + WT_NORM_CTR_MOM_03, IMOM_WHU6 == IMOM_WNCM_03.) + +This generator: + A. Rebuilds the pinned 48x40 rectangle fixture of test_moments_common.h, recomputes every + moment family with scikit-image in Nyxus's coordinate convention, verifies all goldens + UNAFFECTED by the fix still match (validating that the oracle reproduces Nyxus's + conventions), verifies the OLD h5/h6 goldens equal the buggy formulas (closing the loop + that they encoded the defect), and prints the corrected goldens. + B. Recomputes the Nyxus-specific weighted Hu snapshot values (no external oracle for the + W-weighting itself) by feeding the PINNED weighted normalized central moments through + skimage.measure.moments_hu -- i.e., skimage executes the Hu formula on Nyxus's eta. + C. Emits goldens for a NEW asymmetric right-triangle fixture whose odd-order eta are large + enough that the buggy h5/h6 formulas fail the gtest tolerance -- the regression-proof + test the symmetric rectangle cannot provide -- and proves discriminance by printing + |buggy - correct| against the gtest tolerance. + +Provenance: tool=scikit-image, version=0.26.0; numpy 2.4.6; env=nyxus_mirp (conda); +generator=tests/vetting/oracles/gen_moments_skimage.py. Run offline; CI never invokes it. + +Coordinate convention: Nyxus m_pq has p on x, q on y. Arrays here are indexed A[x, y] so +skimage's moments(A)[i, j] lands on i==p, j==q with no transposition (and no h7 sign flip). +""" + +import numpy as np +from skimage.measure import moments, moments_central, moments_normalized, moments_hu + +W, H = 48, 40 +REL_TOL = 1e-9 # validation gate against pinned goldens (gtest itself uses 1e-6) + + +# ---------------------------------------------------------------- fixtures + +def rect_shape(): + """Binary 48x40 rectangle, A[x, y] convention.""" + return np.ones((W, H), dtype=np.float64) + + +def rect_intensity(): + """test_moments_common.h: I(x,y) = 10 + 3x + 5y + (x*y)%7, A[x, y] convention.""" + a = np.zeros((W, H), dtype=np.float64) + for x in range(W): + for y in range(H): + a[x, y] = 10.0 + 3.0 * x + 5.0 * y + float((x * y) % 7) + return a + + +WEDGE_W, WEDGE_H = 40, 8 + +def wedge_shape(): + """Thin right wedge: pixels (x, y) with 0<=x<40, 0<=y<8, 5*y <= x (A[x, y] convention). + Elongated AND skewed, so the odd-order etas are large (eta30 ~ -0.23) and the buggy + h5/h6 formulas miss the correct values by ~440x / ~1900x the gtest tolerance -- the + regression-proof discriminance a symmetric fixture cannot provide. (A compact + symmetric-ish shape fails: e.g. a 20x20 diagonal half-square discriminates h5 by only + 0.35x the tolerance.)""" + a = np.zeros((WEDGE_W, WEDGE_H), dtype=np.float64) + for x in range(WEDGE_W): + for y in range(WEDGE_H): + if 5 * y <= x: + a[x, y] = 1.0 + return a + + +# ---------------------------------------------------------------- Hu formulas + +def hu_correct(nu): + """Standard Hu invariants (== skimage.measure.moments_hu), from eta indexed nu[p, q].""" + return moments_hu(nu) + + +def hu_buggy_h5_h6(nu): + """The two DEFECTIVE pre-fix Nyxus formulas, for closing the loop on old goldens.""" + _20, _02, _11 = nu[2, 0], nu[0, 2], nu[1, 1] + _30, _03, _12, _21 = nu[3, 0], nu[0, 3], nu[1, 2], nu[2, 1] + h5 = (_30 - 3 * _12) * (_30 + _12) * ((_30 + _12) ** 2 - 3 * (_21 + _03) ** 2) + \ + (3 * _21 - _03) * (_21 + _03) * ((3 * (_30 + _12)) ** 2 - (_21 + _03) ** 2) + h6 = (_20 - _02) * ((_30 + _12) ** 2 - (_21 + _03) ** 2) + \ + (4 * _11 * (_30 + _12) * _21 + _03) + return h5, h6 + + +def nu_matrix_from_pinned(vals): + """Build a nu matrix (nu[p, q]) from pinned Nyxus eta goldens {(p,q): value}.""" + nu = np.zeros((4, 4), dtype=np.float64) + nu[0, 0] = 1.0 + for (p, q), v in vals.items(): + nu[p, q] = v + return nu + + +def full_stack(a): + """raw m, central mu, normalized eta (nu[p, q]), Hu -- all in Nyxus convention.""" + m = moments(a, order=3) + mu = moments_central(a, order=3) + nu = moments_normalized(mu, order=3) + hu = hu_correct(np.nan_to_num(nu)) + return m, mu, nu, hu + + +def check(name, got, pinned, tol=REL_TOL): + scale = max(1.0, abs(pinned), abs(got)) + ok = abs(got - pinned) <= tol * scale + print(f" {'OK ' if ok else 'FAIL'} {name}: oracle={got!r} pinned={pinned!r}") + return ok + + +def main(): + all_ok = True + + # ---------------------------------------------------------------- A. rectangle fixture + print("=== A. 48x40 rectangle fixture (test_moments_common.h) ===") + shp = rect_shape() + inten = rect_intensity() + + m_s, mu_s, nu_s, hu_s = full_stack(shp) + m_i, mu_i, nu_i, hu_i = full_stack(inten) + + print("-- validation: goldens UNAFFECTED by the h5/h6 fix (oracle must reproduce them) --") + # shape: raw + central + eta + Hu 1-4,7 (test_moments_skimage.h) + for name, got, pinned in [ + ("SPAT_MOMENT_00", m_s[0, 0], 1920.0), + ("SPAT_MOMENT_10", m_s[1, 0], 45120.0), + ("SPAT_MOMENT_01", m_s[0, 1], 37440.0), + ("CENTRAL_MOMENT_20", mu_s[2, 0], 368480.0), + ("CENTRAL_MOMENT_02", mu_s[0, 2], 255840.0), + ("NORM_CENTRAL_MOMENT_20", nu_s[2, 0], 0.09995659722222222), + ("NORM_CENTRAL_MOMENT_02", nu_s[0, 2], 0.06940104166666666), + ("HU_M1", hu_s[0], 0.16935763888888888), + ("HU_M2", hu_s[1], 0.0009336419753086421), + ("HU_M3", hu_s[2], 0.0), + ("HU_M4", hu_s[3], 0.0), + ("HU_M6", hu_s[5], 0.0), + ("HU_M7", hu_s[6], 0.0), # pinned -2.36e-10 is summation noise; oracle exact 0 + # intensity: raw + central + eta + Hu 1-4 (test_moments_skimage.h) + ("IMOM_RM_00", m_i[0, 0], 346635.0), + ("IMOM_RM_10", m_i[1, 0], 9253494.0), + ("IMOM_CM_20", mu_i[2, 0], 62976163.595638104), + ("IMOM_CM_03", mu_i[0, 3], -169617579.29906213), + ("IMOM_CM_30", mu_i[3, 0], -232054083.1110376), + ("IMOM_NCM_20", nu_i[2, 0], 0.0005241207783805112), + ("IMOM_NCM_03", nu_i[0, 3], -2.3976723321608506e-06), + ("IMOM_NCM_30", nu_i[3, 0], -3.280259374880525e-06), + ("IMOM_HU1", hu_i[0], 0.0008690300706446306), + ("IMOM_HU2", hu_i[1], 3.7112807351259333e-08), + ("IMOM_HU3", hu_i[2], 2.6788774435431954e-11), + ("IMOM_HU4", hu_i[3], 2.8991603200922661e-12), + ]: + all_ok &= check(name, got, pinned, tol=1e-6) # gtest tolerance; noise-level pins + + print("-- validation: OLD h5/h6 goldens equal the BUGGY formulas (they encoded the bug) --") + b5_i, b6_i = hu_buggy_h5_h6(nu_i) + all_ok &= check("IMOM_HU5(buggy)", b5_i, -2.1393783155778043e-23, tol=1e-6) + all_ok &= check("IMOM_HU6(buggy)", b6_i, -2.3976723312959013e-06, tol=1e-6) + + print("-- corrected goldens for test_moments_skimage.h --") + print(f" HU_M5 = {hu_s[4]!r} (was 4.598098572281346e-10 summation noise; oracle exact 0)") + print(f" IMOM_HU5 = {hu_i[4]!r}") + print(f" IMOM_HU6 = {hu_i[5]!r}") + + # ---------------------------------------------------------------- B. weighted (regression) + print("\n=== B. weighted Hu snapshots from PINNED Nyxus eta (no external W-weighting oracle) ===") + # WT_NORM_CTR_MOM_* pinned in test_moments_regression.h + nu_w = nu_matrix_from_pinned({ + (0, 2): 0.017193451534902194, + (0, 3): 0.00465210928951103, + (1, 1): 0.0005158752090264311, + (1, 2): 0.0008429820076480387, + (2, 0): 0.032749715293974795, + (2, 1): 0.0011795045377311554, + (3, 0): 0.005694719149793572, + }) + hu_w = hu_correct(nu_w) + b5_w, b6_w = hu_buggy_h5_h6(nu_w) + print("-- validation: unchanged weighted Hu 1-4,7 + old buggy 5,6 --") + all_ok &= check("WEIGHTED_HU_M1", hu_w[0], 0.04994316682887699, tol=1e-6) + all_ok &= check("WEIGHTED_HU_M2", hu_w[1], 0.00024306185106698786, tol=1e-6) + all_ok &= check("WEIGHTED_HU_M3", hu_w[2], 1.1262214820995351e-05, tol=1e-6) + all_ok &= check("WEIGHTED_HU_M4", hu_w[3], 7.674925625409561e-05, tol=1e-6) + all_ok &= check("WEIGHTED_HU_M7", hu_w[6], -1.3078000499389243e-09, tol=1e-6) + all_ok &= check("WEIGHTED_HU_M5(buggy)", b5_w, -4.0924793325555725e-09, tol=1e-6) + all_ok &= check("WEIGHTED_HU_M6(buggy)", b6_w, 0.004652261067232659, tol=1e-6) + print("-- corrected snapshots for test_moments_regression.h --") + print(f" WEIGHTED_HU_M5 = {hu_w[4]!r}") + print(f" WEIGHTED_HU_M6 = {hu_w[5]!r}") + + # IMOM_WNCM_* pinned in test_moments_regression.h + nu_iw = nu_matrix_from_pinned({ + (0, 2): 0.00011186764375543395, + (0, 3): 4.6265447913743816e-07, + (1, 1): 9.098620671498624e-06, + (1, 2): -4.1575256057220215e-08, + (2, 0): 0.0001835830638933708, + (2, 1): -1.4928319462526036e-08, + (3, 0): 7.484780551139263e-07, + }) + hu_iw = hu_correct(nu_iw) + b5_iw, b6_iw = hu_buggy_h5_h6(nu_iw) + all_ok &= check("IMOM_WHU1", hu_iw[0], 0.00029545070764880473, tol=1e-6) + all_ok &= check("IMOM_WHU2", hu_iw[1], 5.6161730111679807e-09, tol=1e-6) + all_ok &= check("IMOM_WHU3", hu_iw[2], 1.1923046864432925e-12, tol=1e-6) + all_ok &= check("IMOM_WHU4", hu_iw[3], 3.1287168309169019e-12, tol=1e-6) + all_ok &= check("IMOM_WHU7", hu_iw[6], -5.6202519491182231e-24, tol=1e-6) + all_ok &= check("IMOM_WHU5(buggy)", b5_iw, -5.2494915279842729e-24, tol=1e-6) + all_ok &= check("IMOM_WHU6(buggy)", b6_iw, 4.6265447915851515e-07, tol=1e-6) + print("-- corrected snapshots for test_moments_regression.h --") + print(f" IMOM_WHU5 = {hu_iw[4]!r}") + print(f" IMOM_WHU6 = {hu_iw[5]!r}") + + # ---------------------------------------------------------------- C. asymmetric wedge + print(f"\n=== C. thin right-wedge fixture (5*y <= x, {WEDGE_W}x{WEDGE_H}) for the new oracle test ===") + tri = wedge_shape() + m_t, mu_t, nu_t, hu_t = full_stack(tri) + b5_t, b6_t = hu_buggy_h5_h6(nu_t) + + print("-- goldens for test_moments_skimage.h (wedge_shape_hu_skimage_golden_values) --") + print(f" area (SPAT_MOMENT_00) = {m_t[0, 0]!r}") + print(f" SPAT_MOMENT_10 = {m_t[1, 0]!r}") + print(f" SPAT_MOMENT_01 = {m_t[0, 1]!r}") + print(f" CENTRAL_MOMENT_20 = {mu_t[2, 0]!r}") + print(f" CENTRAL_MOMENT_02 = {mu_t[0, 2]!r}") + print(f" CENTRAL_MOMENT_11 = {mu_t[1, 1]!r}") + print(f" CENTRAL_MOMENT_30 = {mu_t[3, 0]!r}") + print(f" CENTRAL_MOMENT_03 = {mu_t[0, 3]!r}") + print(f" CENTRAL_MOMENT_21 = {mu_t[2, 1]!r}") + print(f" CENTRAL_MOMENT_12 = {mu_t[1, 2]!r}") + print(f" NORM_CENTRAL_MOMENT_20 = {nu_t[2, 0]!r}") + print(f" NORM_CENTRAL_MOMENT_02 = {nu_t[0, 2]!r}") + print(f" NORM_CENTRAL_MOMENT_11 = {nu_t[1, 1]!r}") + print(f" NORM_CENTRAL_MOMENT_30 = {nu_t[3, 0]!r}") + print(f" NORM_CENTRAL_MOMENT_03 = {nu_t[0, 3]!r}") + print(f" NORM_CENTRAL_MOMENT_21 = {nu_t[2, 1]!r}") + print(f" NORM_CENTRAL_MOMENT_12 = {nu_t[1, 2]!r}") + for k in range(7): + print(f" HU_M{k + 1} = {hu_t[k]!r}") + + print("-- discriminance: |buggy - correct| must exceed the gtest tolerance 1e-6*scale --") + for name, buggy, correct in [("HU_M5", b5_t, hu_t[4]), ("HU_M6", b6_t, hu_t[5])]: + scale = max(1.0, abs(buggy), abs(correct)) + tol = 1e-6 * scale + disc = abs(buggy - correct) + verdict = "DISCRIMINATES" if disc > 10 * tol else "TOO WEAK" + print(f" {name}: correct={correct!r} buggy={buggy!r} |diff|={disc:.3e} tol={tol:.3e} -> {verdict}") + all_ok &= disc > 10 * tol + + print(f"\n{'ALL CHECKS PASSED' if all_ok else 'SOME CHECKS FAILED -- do not paste goldens'}") + return 0 if all_ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 3f50759ec6f613d6a33910db1626a16cb69e9a19 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Thu, 16 Jul 2026 16:59:57 -0700 Subject: [PATCH 2/5] feat(io): native OME-TIFF/OME-Zarr metadata parsing and axis-aware volumetric reads Step 1 of the IO project: lays the groundwork for native 5D (X,Y,Z,C,T) OME input. Adds the OmeAxes descriptor and OME metadata parsers with unified format detection, axis-aware tile loading (channel/timeframe), native OME-TIFF and OME-Zarr reads honoring the file's own DimensionOrder, ImageLoader::load_volume to assemble a whole X*Y*Z volume for the volumetric pipeline, and wires all 3D volumetric consumers onto it. A NIfTI load_volume equivalence guard protects the existing consumer path through the rewrite. Absorbed commits (oldest to newest): c916752 feat(io): OmeAxes descriptor, OME metadata parsers, unified format detection 364e802 feat(io): axis-aware loadTileFromFile (channel/timeframe) + 5D OME-Zarr addressability tests 33686dd feat(io): OME-Zarr native axis-order read via OmeAxes + rank/order/negative tests a0d88be feat(io): OME-TIFF native read, full DimensionOrder permutations, adversarial hardening eba3d4a feat(io): ImageLoader::load_volume - assemble whole X*Y*Z volume for the volumetric pipeline 958e1e1 test(io): NIfTI load_volume equivalence guard for the volumetric consumer rewrite 3e613cf feat(io): wire all 3D volumetric consumers onto load_volume + end-to-end consumer tests --- CMakeLists.txt | 4 + src/nyx/abs_tile_loader.h | 6 +- src/nyx/grayscale_tiff.h | 54 ++- src/nyx/image_loader.cpp | 89 +++- src/nyx/image_loader.h | 32 ++ src/nyx/image_loader1x.cpp | 14 +- src/nyx/nyxus_dicom_loader.h | 4 +- src/nyx/ome/format_detect.cpp | 88 ++++ src/nyx/ome/format_detect.h | 26 ++ src/nyx/ome/ome_axes.h | 175 ++++++++ src/nyx/ome/ome_tiff_meta.cpp | 166 +++++++ src/nyx/ome/ome_tiff_meta.h | 19 + src/nyx/ome/ome_zarr_meta.cpp | 123 +++++ src/nyx/ome/ome_zarr_meta.h | 34 ++ src/nyx/omezarr.h | 122 +++-- src/nyx/phase1.cpp | 42 +- src/nyx/phase2_3d.cpp | 109 ++--- src/nyx/raw_dicom.h | 2 + src/nyx/raw_format.h | 8 +- src/nyx/raw_image_loader.cpp | 37 +- src/nyx/raw_image_loader.h | 10 + src/nyx/raw_nifti.h | 4 + src/nyx/raw_omezarr.h | 112 +++-- src/nyx/raw_tiff.h | 49 +- tests/data/ometiff/bad_corrupt.tif | Bin 0 -> 39 bytes tests/data/ometiff/bad_rgb.ome.tif | Bin 0 -> 1039 bytes tests/data/ometiff/dim2_yx.ome.tif | Bin 0 -> 957 bytes tests/data/ometiff/dim3_plain.tif | Bin 0 -> 1138 bytes tests/data/ometiff/dim3_zyx.ome.tif | Bin 0 -> 1743 bytes tests/data/ometiff/dim4_czyx.ome.tif | Bin 0 -> 3976 bytes tests/data/ometiff/dim4_tzyx.ome.tif | Bin 0 -> 2791 bytes tests/data/ometiff/dim5.ome.tif | Bin 0 -> 7120 bytes tests/data/ometiff/dim5_ctzyx.ome.tif | Bin 0 -> 7120 bytes tests/data/ometiff/dim5_cztyx.ome.tif | Bin 0 -> 7120 bytes tests/data/ometiff/dim5_tzcyx.ome.tif | Bin 0 -> 7120 bytes tests/data/ometiff/dim5_zctyx.ome.tif | Bin 0 -> 7120 bytes tests/data/ometiff/dim5_ztcyx.ome.tif | Bin 0 -> 7120 bytes tests/data/ometiff/gen_ome_tiff.py | 98 ++++ tests/data/omezarr/README.md | 20 + .../omezarr/bad_axes_count.ome.zarr/.zattrs | 46 ++ .../omezarr/bad_axes_count.ome.zarr/.zgroup | 3 + .../omezarr/bad_axes_count.ome.zarr/0/.zarray | 19 + .../omezarr/bad_axes_count.ome.zarr/0/.zattrs | 1 + tests/data/omezarr/bad_no_xy.ome.zarr/.zattrs | 36 ++ tests/data/omezarr/bad_no_xy.ome.zarr/.zgroup | 3 + .../data/omezarr/bad_no_xy.ome.zarr/0/.zarray | 19 + .../data/omezarr/bad_no_xy.ome.zarr/0/.zattrs | 1 + tests/data/omezarr/dim2_yx.ome.zarr/.zattrs | 31 ++ tests/data/omezarr/dim2_yx.ome.zarr/.zgroup | 3 + tests/data/omezarr/dim2_yx.ome.zarr/0/.zarray | 17 + tests/data/omezarr/dim2_yx.ome.zarr/0/.zattrs | 1 + tests/data/omezarr/dim2_yx.ome.zarr/0/0.0 | Bin 0 -> 96 bytes tests/data/omezarr/dim3_zyx.ome.zarr/.zattrs | 36 ++ tests/data/omezarr/dim3_zyx.ome.zarr/.zgroup | 3 + .../data/omezarr/dim3_zyx.ome.zarr/0/.zarray | 19 + .../data/omezarr/dim3_zyx.ome.zarr/0/.zattrs | 1 + tests/data/omezarr/dim3_zyx.ome.zarr/0/0.0.0 | Bin 0 -> 96 bytes tests/data/omezarr/dim3_zyx.ome.zarr/0/1.0.0 | Bin 0 -> 96 bytes tests/data/omezarr/dim3_zyx.ome.zarr/0/2.0.0 | Bin 0 -> 96 bytes tests/data/omezarr/dim3_zyx.ome.zarr/0/3.0.0 | Bin 0 -> 96 bytes tests/data/omezarr/dim4_czyx.ome.zarr/.zattrs | 41 ++ tests/data/omezarr/dim4_czyx.ome.zarr/.zgroup | 3 + .../data/omezarr/dim4_czyx.ome.zarr/0/.zarray | 21 + .../data/omezarr/dim4_czyx.ome.zarr/0/.zattrs | 1 + .../data/omezarr/dim4_czyx.ome.zarr/0/0.0.0.0 | Bin 0 -> 288 bytes .../data/omezarr/dim4_czyx.ome.zarr/0/0.1.0.0 | Bin 0 -> 288 bytes .../data/omezarr/dim4_czyx.ome.zarr/0/0.2.0.0 | Bin 0 -> 288 bytes .../data/omezarr/dim4_czyx.ome.zarr/0/0.3.0.0 | Bin 0 -> 288 bytes tests/data/omezarr/dim4_tzyx.ome.zarr/.zattrs | 41 ++ tests/data/omezarr/dim4_tzyx.ome.zarr/.zgroup | 3 + .../data/omezarr/dim4_tzyx.ome.zarr/0/.zarray | 21 + .../data/omezarr/dim4_tzyx.ome.zarr/0/.zattrs | 1 + .../data/omezarr/dim4_tzyx.ome.zarr/0/0.0.0.0 | Bin 0 -> 192 bytes .../data/omezarr/dim4_tzyx.ome.zarr/0/0.1.0.0 | Bin 0 -> 192 bytes .../data/omezarr/dim4_tzyx.ome.zarr/0/0.2.0.0 | Bin 0 -> 192 bytes .../data/omezarr/dim4_tzyx.ome.zarr/0/0.3.0.0 | Bin 0 -> 192 bytes tests/data/omezarr/dim5.ome.zarr/.zattrs | 46 ++ tests/data/omezarr/dim5.ome.zarr/.zgroup | 3 + tests/data/omezarr/dim5.ome.zarr/0/.zarray | 23 + tests/data/omezarr/dim5.ome.zarr/0/.zattrs | 1 + tests/data/omezarr/dim5.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 576 bytes tests/data/omezarr/dim5.ome.zarr/0/0.0.1.0.0 | Bin 0 -> 576 bytes tests/data/omezarr/dim5.ome.zarr/0/0.0.2.0.0 | Bin 0 -> 576 bytes tests/data/omezarr/dim5.ome.zarr/0/0.0.3.0.0 | Bin 0 -> 576 bytes .../data/omezarr/dim5_ctzyx.ome.zarr/.zattrs | 46 ++ .../data/omezarr/dim5_ctzyx.ome.zarr/.zgroup | 3 + .../omezarr/dim5_ctzyx.ome.zarr/0/.zarray | 23 + .../omezarr/dim5_ctzyx.ome.zarr/0/.zattrs | 1 + .../omezarr/dim5_ctzyx.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_ctzyx.ome.zarr/0/0.0.1.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_ctzyx.ome.zarr/0/0.0.2.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_ctzyx.ome.zarr/0/0.0.3.0.0 | Bin 0 -> 576 bytes .../data/omezarr/dim5_cztyx.ome.zarr/.zattrs | 46 ++ .../data/omezarr/dim5_cztyx.ome.zarr/.zgroup | 3 + .../omezarr/dim5_cztyx.ome.zarr/0/.zarray | 23 + .../omezarr/dim5_cztyx.ome.zarr/0/.zattrs | 1 + .../omezarr/dim5_cztyx.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_cztyx.ome.zarr/0/0.1.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_cztyx.ome.zarr/0/0.2.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_cztyx.ome.zarr/0/0.3.0.0.0 | Bin 0 -> 576 bytes .../data/omezarr/dim5_noaxes.ome.zarr/.zattrs | 24 + .../data/omezarr/dim5_noaxes.ome.zarr/.zgroup | 3 + .../omezarr/dim5_noaxes.ome.zarr/0/.zarray | 23 + .../omezarr/dim5_noaxes.ome.zarr/0/.zattrs | 1 + .../omezarr/dim5_noaxes.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_noaxes.ome.zarr/0/0.0.1.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_noaxes.ome.zarr/0/0.0.2.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_noaxes.ome.zarr/0/0.0.3.0.0 | Bin 0 -> 576 bytes .../data/omezarr/dim5_tzcyx.ome.zarr/.zattrs | 46 ++ .../data/omezarr/dim5_tzcyx.ome.zarr/.zgroup | 3 + .../omezarr/dim5_tzcyx.ome.zarr/0/.zarray | 23 + .../omezarr/dim5_tzcyx.ome.zarr/0/.zattrs | 1 + .../omezarr/dim5_tzcyx.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_tzcyx.ome.zarr/0/0.1.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_tzcyx.ome.zarr/0/0.2.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_tzcyx.ome.zarr/0/0.3.0.0.0 | Bin 0 -> 576 bytes .../data/omezarr/dim5_zctyx.ome.zarr/.zattrs | 46 ++ .../data/omezarr/dim5_zctyx.ome.zarr/.zgroup | 3 + .../omezarr/dim5_zctyx.ome.zarr/0/.zarray | 23 + .../omezarr/dim5_zctyx.ome.zarr/0/.zattrs | 1 + .../omezarr/dim5_zctyx.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_zctyx.ome.zarr/0/1.0.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_zctyx.ome.zarr/0/2.0.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_zctyx.ome.zarr/0/3.0.0.0.0 | Bin 0 -> 576 bytes .../data/omezarr/dim5_ztcyx.ome.zarr/.zattrs | 46 ++ .../data/omezarr/dim5_ztcyx.ome.zarr/.zgroup | 3 + .../omezarr/dim5_ztcyx.ome.zarr/0/.zarray | 23 + .../omezarr/dim5_ztcyx.ome.zarr/0/.zattrs | 1 + .../omezarr/dim5_ztcyx.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_ztcyx.ome.zarr/0/1.0.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_ztcyx.ome.zarr/0/2.0.0.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_ztcyx.ome.zarr/0/3.0.0.0.0 | Bin 0 -> 576 bytes tests/data/omezarr/gen_dim5.py | 115 +++++ tests/test_3d_nifti_mechanics.h | 40 +- tests/test_all.cc | 145 +++++- tests/test_hu_mechanics.h | 4 +- tests/test_morphology_common.h | 2 +- tests/test_ome_meta.h | 422 ++++++++++++++++++ tests/test_ometiff_mechanics.h | 195 ++++++++ tests/test_omezarr_mechanics.h | 197 +++++++- tests/test_tiff_loader_mechanics.h | 2 +- 141 files changed, 3213 insertions(+), 216 deletions(-) create mode 100644 src/nyx/ome/format_detect.cpp create mode 100644 src/nyx/ome/format_detect.h create mode 100644 src/nyx/ome/ome_axes.h create mode 100644 src/nyx/ome/ome_tiff_meta.cpp create mode 100644 src/nyx/ome/ome_tiff_meta.h create mode 100644 src/nyx/ome/ome_zarr_meta.cpp create mode 100644 src/nyx/ome/ome_zarr_meta.h create mode 100644 tests/data/ometiff/bad_corrupt.tif create mode 100644 tests/data/ometiff/bad_rgb.ome.tif create mode 100644 tests/data/ometiff/dim2_yx.ome.tif create mode 100644 tests/data/ometiff/dim3_plain.tif create mode 100644 tests/data/ometiff/dim3_zyx.ome.tif create mode 100644 tests/data/ometiff/dim4_czyx.ome.tif create mode 100644 tests/data/ometiff/dim4_tzyx.ome.tif create mode 100644 tests/data/ometiff/dim5.ome.tif create mode 100644 tests/data/ometiff/dim5_ctzyx.ome.tif create mode 100644 tests/data/ometiff/dim5_cztyx.ome.tif create mode 100644 tests/data/ometiff/dim5_tzcyx.ome.tif create mode 100644 tests/data/ometiff/dim5_zctyx.ome.tif create mode 100644 tests/data/ometiff/dim5_ztcyx.ome.tif create mode 100644 tests/data/ometiff/gen_ome_tiff.py create mode 100644 tests/data/omezarr/bad_axes_count.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/bad_axes_count.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/bad_axes_count.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/bad_axes_count.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/bad_no_xy.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/bad_no_xy.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/bad_no_xy.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/bad_no_xy.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim2_yx.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim2_yx.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim2_yx.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim2_yx.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim2_yx.ome.zarr/0/0.0 create mode 100644 tests/data/omezarr/dim3_zyx.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim3_zyx.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim3_zyx.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim3_zyx.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim3_zyx.ome.zarr/0/0.0.0 create mode 100644 tests/data/omezarr/dim3_zyx.ome.zarr/0/1.0.0 create mode 100644 tests/data/omezarr/dim3_zyx.ome.zarr/0/2.0.0 create mode 100644 tests/data/omezarr/dim3_zyx.ome.zarr/0/3.0.0 create mode 100644 tests/data/omezarr/dim4_czyx.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim4_czyx.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim4_czyx.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim4_czyx.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim4_czyx.ome.zarr/0/0.0.0.0 create mode 100644 tests/data/omezarr/dim4_czyx.ome.zarr/0/0.1.0.0 create mode 100644 tests/data/omezarr/dim4_czyx.ome.zarr/0/0.2.0.0 create mode 100644 tests/data/omezarr/dim4_czyx.ome.zarr/0/0.3.0.0 create mode 100644 tests/data/omezarr/dim4_tzyx.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim4_tzyx.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim4_tzyx.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim4_tzyx.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim4_tzyx.ome.zarr/0/0.0.0.0 create mode 100644 tests/data/omezarr/dim4_tzyx.ome.zarr/0/0.1.0.0 create mode 100644 tests/data/omezarr/dim4_tzyx.ome.zarr/0/0.2.0.0 create mode 100644 tests/data/omezarr/dim4_tzyx.ome.zarr/0/0.3.0.0 create mode 100644 tests/data/omezarr/dim5.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5.ome.zarr/0/0.0.1.0.0 create mode 100644 tests/data/omezarr/dim5.ome.zarr/0/0.0.2.0.0 create mode 100644 tests/data/omezarr/dim5.ome.zarr/0/0.0.3.0.0 create mode 100644 tests/data/omezarr/dim5_ctzyx.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5_ctzyx.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5_ctzyx.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5_ctzyx.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5_ctzyx.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_ctzyx.ome.zarr/0/0.0.1.0.0 create mode 100644 tests/data/omezarr/dim5_ctzyx.ome.zarr/0/0.0.2.0.0 create mode 100644 tests/data/omezarr/dim5_ctzyx.ome.zarr/0/0.0.3.0.0 create mode 100644 tests/data/omezarr/dim5_cztyx.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5_cztyx.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5_cztyx.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5_cztyx.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5_cztyx.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_cztyx.ome.zarr/0/0.1.0.0.0 create mode 100644 tests/data/omezarr/dim5_cztyx.ome.zarr/0/0.2.0.0.0 create mode 100644 tests/data/omezarr/dim5_cztyx.ome.zarr/0/0.3.0.0.0 create mode 100644 tests/data/omezarr/dim5_noaxes.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5_noaxes.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5_noaxes.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5_noaxes.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5_noaxes.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_noaxes.ome.zarr/0/0.0.1.0.0 create mode 100644 tests/data/omezarr/dim5_noaxes.ome.zarr/0/0.0.2.0.0 create mode 100644 tests/data/omezarr/dim5_noaxes.ome.zarr/0/0.0.3.0.0 create mode 100644 tests/data/omezarr/dim5_tzcyx.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5_tzcyx.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5_tzcyx.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5_tzcyx.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5_tzcyx.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_tzcyx.ome.zarr/0/0.1.0.0.0 create mode 100644 tests/data/omezarr/dim5_tzcyx.ome.zarr/0/0.2.0.0.0 create mode 100644 tests/data/omezarr/dim5_tzcyx.ome.zarr/0/0.3.0.0.0 create mode 100644 tests/data/omezarr/dim5_zctyx.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5_zctyx.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5_zctyx.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5_zctyx.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5_zctyx.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_zctyx.ome.zarr/0/1.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_zctyx.ome.zarr/0/2.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_zctyx.ome.zarr/0/3.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_ztcyx.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5_ztcyx.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5_ztcyx.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5_ztcyx.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5_ztcyx.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_ztcyx.ome.zarr/0/1.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_ztcyx.ome.zarr/0/2.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_ztcyx.ome.zarr/0/3.0.0.0.0 create mode 100644 tests/data/omezarr/gen_dim5.py create mode 100644 tests/test_ome_meta.h create mode 100644 tests/test_ometiff_mechanics.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 71318bc1d..ae0a70163 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -127,6 +127,10 @@ set(SOURCE src/nyx/raw_tiff.h # sources src/nyx/3rdparty/dsyevj3.cpp + # native OME metadata parsing + unified format detection + src/nyx/ome/format_detect.cpp + src/nyx/ome/ome_tiff_meta.cpp + src/nyx/ome/ome_zarr_meta.cpp src/nyx/features/basic_morphology.cpp src/nyx/features/caliper_feret.cpp src/nyx/features/caliper_martin.cpp diff --git a/src/nyx/abs_tile_loader.h b/src/nyx/abs_tile_loader.h index c4f509c60..60d745819 100644 --- a/src/nyx/abs_tile_loader.h +++ b/src/nyx/abs_tile_loader.h @@ -32,12 +32,16 @@ class AbstractTileLoader /// @param tile Tile to load /// @param indexRowGlobalTile Tile's row index in the file to load /// @param indexColGlobalTile Tile's col index in the file to load - /// @param indexLayerGlobalTile Tile's layer index in the file to load + /// @param indexLayerGlobalTile Tile's layer (Z) index in the file to load + /// @param indexChannel Channel (C) index to load; 0 for single-channel data + /// @param indexTimeframe Time (T) index to load; 0 for non-time-series data /// @param level Tile's pyramidal level in the file to load virtual void loadTileFromFile(std::shared_ptr> tile, size_t indexRowGlobalTile, size_t indexColGlobalTile, size_t indexLayerGlobalTile, + size_t indexChannel, + size_t indexTimeframe, size_t level) = 0; /// \brief Getter to full Height diff --git a/src/nyx/grayscale_tiff.h b/src/nyx/grayscale_tiff.h index 931c11597..d6b253714 100644 --- a/src/nyx/grayscale_tiff.h +++ b/src/nyx/grayscale_tiff.h @@ -14,7 +14,8 @@ #include #include // std::is_signed_v to guard signed->unsigned wraparound in loadTile #include -#include // for INT_MAX +#include // for INT_MAX +#include "ome/ome_tiff_meta.h" // parse_ome_xml -> OmeAxes (OME-TIFF plane->IFD mapping) constexpr size_t STRIP_TILE_HEIGHT = 1024; constexpr size_t STRIP_TILE_WIDTH = 1024; @@ -105,6 +106,8 @@ class NyxusGrayscaleTiffTileLoader : public AbstractTileLoader size_t indexRowGlobalTile, size_t indexColGlobalTile, size_t indexLayerGlobalTile, + [[maybe_unused]] size_t indexChannel, // plain 2D TIFF: single channel + [[maybe_unused]] size_t indexTimeframe, // plain 2D TIFF: no time series size_t level) override { std::string err; @@ -430,6 +433,23 @@ class NyxusGrayscaleTiffStripLoader : public AbstractTileLoader fullDepth_ = TIFFNumberOfDirectories(tiff_); + // OME-TIFF: the IFDs are a (z,c,t) rasterization, not a plain Z-stack. + // Parse the OME-XML (IFD-0 ImageDescription) so (z,c,t) map to the right + // IFD and fullDepth reflects SizeZ, not the total page count. + { + char* desc = nullptr; + if (TIFFGetField(tiff_, TIFFTAG_IMAGEDESCRIPTION, &desc) == 1 && desc + && std::string(desc).find(" void loadTileFromFile(std::shared_ptr> tile, size_t indexRowGlobalTile, size_t indexColGlobalTile, - size_t indexLayerGlobalTile, - [[maybe_unused]] size_t level) override + size_t indexLayerGlobalTile, // Z (plane page for non-OME multi-page TIFF) + size_t indexChannel, // C plane (OME-TIFF only) + size_t indexTimeframe, // T plane (OME-TIFF only) + [[maybe_unused]] size_t level) override { // Get ahold of the logical (feature extraction facing) tile buffer from its smart pointer std::vector& tileDataVec = *tile; @@ -484,6 +506,16 @@ class NyxusGrayscaleTiffStripLoader : public AbstractTileLoader buf = _TIFFmalloc(TIFFScanlineSize(tiff_)); + // OME plane indices are computed into an IFD (not iterated), so validate the + // range explicitly — an out-of-range Z would otherwise skip the layer loop + // below and silently return a zero tile. + if (is_ome_ && (indexLayerGlobalTile >= ome_.sizeZ + || indexChannel >= ome_.sizeC || indexTimeframe >= ome_.sizeT)) + { + _TIFFfree(buf); + throw std::runtime_error("NyxusGrayscaleTiffStripLoader: (z,c,t) plane out of range"); + } + size_t startLayer = indexLayerGlobalTile * tileDepth_, endLayer = std::min((indexLayerGlobalTile + 1) * tileDepth_, fullDepth_), @@ -492,9 +524,18 @@ class NyxusGrayscaleTiffStripLoader : public AbstractTileLoader startCol = indexColGlobalTile * tileWidth_, endCol = std::min((indexColGlobalTile + 1) * tileWidth_, fullWidth_); - for (layer = startLayer; layer < endLayer; ++layer) + for (layer = startLayer; layer < endLayer; ++layer) { - TIFFSetDirectory(tiff_, layer); + // OME-TIFF: page = (z,c,t) IFD per DimensionOrder; plain multi-page: page = Z. + size_t ifd = is_ome_ ? ome_.ifdForPlane(layer, indexChannel, indexTimeframe) : layer; + // Check the return so an out-of-range plane throws instead of silently + // leaving the previous directory current and reading the wrong page. + if (TIFFSetDirectory(tiff_, (uint16_t)ifd) != 1) + { + _TIFFfree(buf); + throw std::runtime_error("NyxusGrayscaleTiffStripLoader: TIFFSetDirectory(ifd=" + + std::to_string(ifd) + ") failed (out-of-range plane?)"); + } for (row = startRow; row < endRow; row++) { TIFFReadScanline(tiff_, buf, row); @@ -692,4 +733,7 @@ class NyxusGrayscaleTiffStripLoader : public AbstractTileLoader sampleFormat_ = 0, ///< Sample format as defined by libtiff bitsPerSample_ = 0; ///< Bit Per Sample as defined by libtiff + bool is_ome_ = false; ///< true when IFD-0 carries an OME-XML block + Nyxus::OmeAxes ome_; ///< parsed OME dimensions (drives the plane->IFD map) + }; diff --git a/src/nyx/image_loader.cpp b/src/nyx/image_loader.cpp index 6e99b9e83..e9d8236f0 100644 --- a/src/nyx/image_loader.cpp +++ b/src/nyx/image_loader.cpp @@ -8,6 +8,7 @@ #include "dirs_and_files.h" #include "helpers/fsystem.h" #include "raw_nifti.h" +#include "ome/format_detect.h" // FIX: unified content-sniffing loader dispatch ImageLoader::ImageLoader() {} @@ -22,9 +23,11 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) try { - std::string ext = Nyxus::get_big_extension (int_fpath); + // FIX: classify by detect_input_format() (extension + OME content sniff) instead of + // raw extension compares, so dispatch is identical across all 3 loaders and OME is recognized. + Nyxus::InputFormat fmt = Nyxus::detect_input_format (int_fpath); - if (ext == ".zarr" || ext == ".ome.zarr") + if (fmt.kind == Nyxus::ContainerKind::OmeZarr) // FIX: was `ext==".zarr"||".ome.zarr"` { #ifdef OMEZARR_SUPPORT intFL = new NyxusOmeZarrLoader(n_threads, int_fpath); @@ -36,8 +39,8 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) std::cerr << erm << "\n"; #endif } - else - if (ext == ".dcm" || ext == ".dicom") + else + if (fmt.kind == Nyxus::ContainerKind::Dicom) // FIX: was `ext==".dcm"||".dicom"` { #ifdef DICOM_SUPPORT // HU offset base must be the scanned (HU-domain) slide min. In preserve_hu @@ -57,7 +60,7 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) #endif } else - if (ext == ".nii" || ext == ".nii.gz") + if (fmt.kind == Nyxus::ContainerKind::Nifti) // FIX: was `ext==".nii"||".nii.gz"` { intFL = new NiftiLoader (int_fpath, (fpopts.preserve_hu() || fpopts.empty()) ? p.min_preroi_inten : (double)fpopts.min_intensity(), // HU offset base = scanned HU-domain slide min; ignore fp min in preserve_hu mode (else negative HU clamps to 0) @@ -133,9 +136,11 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) try { - std::string ext = Nyxus::get_big_extension(seg_fpath); + // FIX: unify seg dispatch with detect_input_format(). Defect fixed: the seg path only + // matched ".zarr", so a ".ome.zarr" mask mis-routed to the TIFF path (intensity path matched both). + Nyxus::InputFormat fmt = Nyxus::detect_input_format (seg_fpath); - if (ext == ".zarr") + if (fmt.kind == Nyxus::ContainerKind::OmeZarr) // FIX: was `ext==".zarr"` only (dropped .ome.zarr) { #ifdef OMEZARR_SUPPORT segFL = new NyxusOmeZarrLoader(n_threads, seg_fpath); @@ -143,8 +148,8 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) std::cout << "This version of Nyxus was not build with OmeZarr support." <(n_threads, seg_fpath); @@ -153,7 +158,7 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) #endif } else - if (ext == ".nii" || ext == ".nii.gz") + if (fmt.kind == Nyxus::ContainerKind::Nifti) // FIX: was `ext==".nii"||".nii.gz"` { segFL = new NiftiLoader (seg_fpath); } @@ -236,12 +241,12 @@ bool ImageLoader::load_tile(size_t tile_idx) auto tRow = tile_idx / ntw; auto tCol = tile_idx % ntw; - intFL->loadTileFromFile (ptrI, tRow, tCol, lyr, lvl); + intFL->loadTileFromFile (ptrI, tRow, tCol, lyr, cur_channel, cur_timeframe, lvl); // segmentation loader is not available in wholeslide if (segFL) - segFL->loadTileFromFile (ptrL, tRow, tCol, lyr, lvl); - + segFL->loadTileFromFile (ptrL, tRow, tCol, lyr, cur_channel, cur_timeframe, lvl); + return true; } @@ -250,14 +255,68 @@ bool ImageLoader::load_tile (size_t tile_row, size_t tile_col) if (tile_row >= nth || tile_col >= ntw) return false; - intFL->loadTileFromFile (ptrI, tile_row, tile_col, lyr, lvl); + intFL->loadTileFromFile (ptrI, tile_row, tile_col, lyr, cur_channel, cur_timeframe, lvl); // segmentation loader is not available in wholeslide if (segFL) - segFL->loadTileFromFile (ptrL, tile_row, tile_col, lyr, lvl); + segFL->loadTileFromFile (ptrL, tile_row, tile_col, lyr, cur_channel, cur_timeframe, lvl); return true; } + +void ImageLoader::assemble_volume (AbstractTileLoader* fl, + std::shared_ptr>& ptr, + std::vector& dst, size_t channel, size_t timeframe) +{ + const size_t sliceSize = (size_t)fw * fh; + + // Use THIS loader's own layout: per-plane loaders (OME-Zarr, multi-page TIFF) + // deliver one Z-plane per read (tileDepth==1, tileTimestamps==1) with the + // timeframe chosen by the loadTileFromFile arg; a whole-4D loader (NIfTI) + // delivers the entire x*y*z*t blob ([t][z][y][x]) in one read and ignores the + // timeframe arg, so the requested frame is slabbed out via frameBase. + const size_t ltd = fl->tileDepth (lvl); + const size_t ltt = fl->tileTimestamps (lvl); + const size_t lntd = fl->numberTileDepth (lvl); + const size_t frameStride = ltd * th * tw; + const size_t frameBase = (ltt > 1) ? timeframe * frameStride : 0; + + for (size_t lz = 0; lz < lntd; ++lz) + { + fl->loadTileFromFile (ptr, 0, 0, lz, channel, timeframe, lvl); + for (size_t pz = 0; pz < ltd && (lz * ltd + pz) < fd; ++pz) + { + const size_t gz = lz * ltd + pz; + for (size_t row = 0; row < fh; ++row) + { + const size_t src = frameBase + (pz * th + row) * tw; + const size_t d = gz * sliceSize + row * fw; + std::copy (ptr->begin() + src, ptr->begin() + src + fw, dst.begin() + d); + } + } + } +} + +bool ImageLoader::load_volume (size_t channel, size_t timeframe, size_t mask_timeframe) +{ + cur_channel = channel; + cur_timeframe = timeframe; + + const size_t volSize = (size_t)fw * fh * fd; // the whole X*Y*Z volume + + if (vol_int_.size() != volSize) + vol_int_.resize (volSize); + assemble_volume (intFL, ptrI, vol_int_, channel, timeframe); + + if (segFL != nullptr) + { + if (vol_seg_.size() != volSize) + vol_seg_.resize (volSize); + assemble_volume (segFL, ptrL, vol_seg_, channel, mask_timeframe); // mask may be on a different frame + } + return true; +} + const std::vector& ImageLoader::get_int_tile_buffer() { return *ptrI; diff --git a/src/nyx/image_loader.h b/src/nyx/image_loader.h index 6802fb3a2..01acb8a1e 100644 --- a/src/nyx/image_loader.h +++ b/src/nyx/image_loader.h @@ -19,6 +19,19 @@ class ImageLoader void close(); bool load_tile (size_t tile_idx); bool load_tile (size_t tile_row, size_t tile_col); + + // Assemble the whole X*Y*Z volume for one (channel, timeframe) into an + // internal buffer by looping over Z-planes. This is what lets plane-by-plane + // loaders (OME-Zarr, multi-page/OME-TIFF) feed the volumetric pipeline, which + // otherwise assumes the whole volume arrives in one read (the NIfTI model). + // The mask may live on a different timeframe than the intensity (the 1-mask : + // N-intensity case), so `mask_timeframe` is separable; the 2-arg overload uses + // the same frame for both. + bool load_volume (size_t channel, size_t timeframe, size_t mask_timeframe); + bool load_volume (size_t channel, size_t timeframe) { return load_volume(channel, timeframe, timeframe); } + const std::vector& get_int_volume_buffer() const { return vol_int_; } + const std::vector& get_seg_volume_buffer() const { return vol_seg_; } + const std::vector& get_int_tile_buffer(); const std::vector& get_seg_tile_buffer(); const std::shared_ptr>& get_seg_tile_sptr(); @@ -36,6 +49,13 @@ class ImageLoader size_t get_inten_time(); size_t get_mask_time(); + // Select which channel (C) / timeframe (T) plane subsequent load_tile() calls + // read. Default 0/0 preserves the single-channel, single-timepoint behavior. + void set_channel (size_t c) { cur_channel = c; } + void set_timeframe (size_t t) { cur_timeframe = t; } + size_t get_channel() const { return cur_channel; } + size_t get_timeframe() const { return cur_timeframe; } + private: AbstractTileLoader *segFL = nullptr, *intFL = nullptr; @@ -64,5 +84,17 @@ class ImageLoader int lvl = 0, // Pyramid level lyr = 0; // Layer + + size_t cur_channel = 0, // Currently selected channel (C) plane + cur_timeframe = 0; // Currently selected timeframe (T) plane + + // Whole-volume (X*Y*Z) assembly buffers filled by load_volume() + std::vector vol_int_, vol_seg_; + + // Assemble one loader's X*Y*Z volume (for the given channel/timeframe) into dst, + // honoring that loader's own tileDepth/tileTimestamps (per-plane vs whole-4D). + void assemble_volume (AbstractTileLoader* fl, + std::shared_ptr>& ptr, + std::vector& dst, size_t channel, size_t timeframe); }; diff --git a/src/nyx/image_loader1x.cpp b/src/nyx/image_loader1x.cpp index 147bf7f44..06777ba6a 100644 --- a/src/nyx/image_loader1x.cpp +++ b/src/nyx/image_loader1x.cpp @@ -9,6 +9,7 @@ #include "nyxus_dicom_loader.h" #include "dirs_and_files.h" #include "helpers/fsystem.h" +#include "ome/format_detect.h" // FIX: unified content-sniffing loader dispatch ImageLoader1x::ImageLoader1x() {} @@ -18,7 +19,12 @@ bool ImageLoader1x::open (const std::string& fpath, const FpImageOptions & fpopt try { - if (fs::path(fpath).extension() == ".zarr") + // FIX: dispatch via detect_input_format() for parity with the other loaders (and to fix + // the bitwise-OR on the DICOM test). This loader carries no NIfTI backend, so Nifti still falls to the + // TIFF branch as before — behavior unchanged there. + Nyxus::InputFormat fmt = Nyxus::detect_input_format (fpath); + + if (fmt.kind == Nyxus::ContainerKind::OmeZarr) // FIX: was `fs::path(fpath).extension()==".zarr"` { #ifdef OMEZARR_SUPPORT FL = std::make_unique>(n_threads, fpath); @@ -26,7 +32,7 @@ bool ImageLoader1x::open (const std::string& fpath, const FpImageOptions & fpopt std::cout << "This version of Nyxus was not build with OmeZarr support." <>(n_threads, fpath); #else @@ -95,7 +101,7 @@ bool ImageLoader1x::load_tile(size_t tile_idx) auto row = tile_idx / ntw; auto col = tile_idx % ntw; - FL->loadTileFromFile(ptr, row, col, lyr, lvl); + FL->loadTileFromFile(ptr, row, col, lyr, 0/*channel*/, 0/*timeframe*/, lvl); return true; } @@ -105,7 +111,7 @@ bool ImageLoader1x::load_tile(size_t tile_row, size_t tile_col) if (tile_row >= nth || tile_col >= ntw) return false; - FL->loadTileFromFile(ptr, tile_row, tile_col, lyr, lvl); + FL->loadTileFromFile(ptr, tile_row, tile_col, lyr, 0/*channel*/, 0/*timeframe*/, lvl); return true; } diff --git a/src/nyx/nyxus_dicom_loader.h b/src/nyx/nyxus_dicom_loader.h index d4cb9d43f..8c29953fa 100644 --- a/src/nyx/nyxus_dicom_loader.h +++ b/src/nyx/nyxus_dicom_loader.h @@ -135,7 +135,9 @@ class NyxusGrayscaleDicomLoader : public AbstractTileLoader size_t indexRowGlobalTile, size_t indexColGlobalTile, size_t indexLayerGlobalTile, - [[maybe_unused]] size_t level) override + [[maybe_unused]] size_t indexChannel, // DICOM grayscale: single channel + [[maybe_unused]] size_t indexTimeframe, // DICOM: frames are WSI tiles, not time + [[maybe_unused]] size_t level) override { // Get ahold of the logical (feature extraction facing) tile buffer from its smart pointer std::vector& tileDataVec = *tile; diff --git a/src/nyx/ome/format_detect.cpp b/src/nyx/ome/format_detect.cpp new file mode 100644 index 000000000..394abb17f --- /dev/null +++ b/src/nyx/ome/format_detect.cpp @@ -0,0 +1,88 @@ +#define NOMINMAX + +#include "format_detect.h" + +#include +#include +#include + +#include + +#include "../helpers/fsystem.h" + +namespace Nyxus +{ + const char* to_string(ContainerKind k) + { + switch (k) + { + case ContainerKind::TiffPlain: return "TiffPlain"; + case ContainerKind::OmeTiff: return "OmeTiff"; + case ContainerKind::OmeZarr: return "OmeZarr"; + case ContainerKind::Dicom: return "Dicom"; + case ContainerKind::Nifti: return "Nifti"; + default: return "Unknown"; + } + } + + // Does this TIFF carry an OME-XML block in IFD-0's ImageDescription? + static bool tiff_is_ome(const std::string& path) + { + // silence libtiff's stderr chatter on non-OME / odd TIFFs during the sniff + TIFFErrorHandler oldE = TIFFSetErrorHandler(nullptr); + TIFFErrorHandler oldW = TIFFSetWarningHandler(nullptr); + bool ome = false; + TIFF* t = TIFFOpen(path.c_str(), "r"); + if (t) + { + char* desc = nullptr; // libtiff-owned, do not free + if (TIFFGetField(t, TIFFTAG_IMAGEDESCRIPTION, &desc) == 1 && desc) + { + std::string d(desc); + ome = d.find("(f)), std::istreambuf_iterator()); + if (content.find("multiscales") != std::string::npos) + return true; + } + return false; + } + + InputFormat detect_input_format(const std::string& path) + { + std::string ext = Nyxus::get_big_extension(path); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + + if (ext == ".zarr" || ext == ".ome.zarr") + return { ContainerKind::OmeZarr, zarr_has_multiscales(path) }; + if (ext == ".dcm" || ext == ".dicom") + return { ContainerKind::Dicom, false }; + if (ext == ".nii" || ext == ".nii.gz") + return { ContainerKind::Nifti, false }; + + // Everything else is a TIFF flavor (.tif/.tiff/.ome.tif/.ome.tiff/.tf2/.tf8/.btf). + if (tiff_is_ome(path)) + return { ContainerKind::OmeTiff, true }; + return { ContainerKind::TiffPlain, false }; + } +} diff --git a/src/nyx/ome/format_detect.h b/src/nyx/ome/format_detect.h new file mode 100644 index 000000000..f45eb42e8 --- /dev/null +++ b/src/nyx/ome/format_detect.h @@ -0,0 +1,26 @@ +#pragma once + +// Single source of truth for "what kind of file is this?" used by all three +// loader dispatch sites (image_loader, raw_image_loader, image_loader1x). Adds +// the OME content-sniff that is absent today and unifies the extension handling +// (image_loader1x currently uses single-extension matching and cannot see +// .ome.zarr / .nii.gz). STL + libtiff + only. + +#include + +namespace Nyxus +{ + enum class ContainerKind { TiffPlain, OmeTiff, OmeZarr, Dicom, Nifti, Unknown }; + + struct InputFormat + { + ContainerKind kind = ContainerKind::Unknown; + bool is_ome = false; // OME metadata actually present (OME-XML / NGFF multiscales) + }; + + // Classify by big-extension, then content-sniff: OME-XML in TIFF IFD-0 + // ImageDescription, or NGFF `multiscales` in a Zarr group. Never throws. + InputFormat detect_input_format(const std::string& path); + + const char* to_string(ContainerKind k); +} diff --git a/src/nyx/ome/ome_axes.h b/src/nyx/ome/ome_axes.h new file mode 100644 index 000000000..e064ddea8 --- /dev/null +++ b/src/nyx/ome/ome_axes.h @@ -0,0 +1,175 @@ +#pragma once + +// ----------------------------------------------------------------------------- +// OmeAxes — one format-agnostic dimension descriptor for native 3D/4D/5D OME +// input. Both container families populate this from real metadata: +// - OME-TIFF : parse_ome_xml() (ome_tiff_meta.h) — OME-XML block +// - OME-Zarr : parse_ome_zarr() (ome_zarr_meta.h) — multiscales/axes/datasets +// Everything downstream (loaders, SlideProps, output schema) consumes OmeAxes +// instead of assuming C=T=1 and a hard-coded TCZYX axis order. +// +// This header is STL-only so it compiles in every build configuration. +// ----------------------------------------------------------------------------- + +#include +#include +#include +#include + +namespace Nyxus +{ + // Role of an axis, mirroring the OME-NGFF 'type' field (space|channel|time). + enum class AxisKind { Space, Channel, Time }; + + // Resolved pixel/element type (consolidates the ad-hoc dtype codes that + // omezarr.h / raw_omezarr.h and the TIFF sampleFormat switch use today). + enum class PixelType { UInt8, UInt16, UInt32, UInt64, Int8, Int16, Int32, Int64, Float32, Float64, Unknown }; + + inline unsigned bits_of(PixelType t) + { + switch (t) + { + case PixelType::UInt8: case PixelType::Int8: return 8; + case PixelType::UInt16: case PixelType::Int16: return 16; + case PixelType::UInt32: case PixelType::Int32: case PixelType::Float32: return 32; + case PixelType::UInt64: case PixelType::Int64: case PixelType::Float64: return 64; + default: return 0; + } + } + inline bool is_float(PixelType t) { return t == PixelType::Float32 || t == PixelType::Float64; } + inline bool is_signed(PixelType t) + { + return t == PixelType::Int8 || t == PixelType::Int16 || t == PixelType::Int32 || t == PixelType::Int64 || is_float(t); + } + + // OME 'Type' attribute (OME-XML) -> PixelType. + inline PixelType pixel_type_from_ome_type(const std::string& s) + { + if (s == "uint8") return PixelType::UInt8; + if (s == "uint16") return PixelType::UInt16; + if (s == "uint32") return PixelType::UInt32; + if (s == "uint64") return PixelType::UInt64; + if (s == "int8") return PixelType::Int8; + if (s == "int16") return PixelType::Int16; + if (s == "int32") return PixelType::Int32; + if (s == "int64") return PixelType::Int64; + if (s == "float") return PixelType::Float32; + if (s == "double") return PixelType::Float64; + return PixelType::Unknown; + } + + // Zarr dtype -> PixelType. Accepts v2 numpy strings ("i4", "|u1", ...) + // and v3 names ("uint16", "int32", "float32", ...). + inline PixelType pixel_type_from_zarr_dtype(const std::string& in) + { + std::string s = in; + // strip a leading byte-order char for the v2 form + if (!s.empty() && (s[0] == '<' || s[0] == '>' || s[0] == '|' || s[0] == '=')) + s = s.substr(1); + if (s == "u1" || s == "uint8") return PixelType::UInt8; + if (s == "u2" || s == "uint16") return PixelType::UInt16; + if (s == "u4" || s == "uint32") return PixelType::UInt32; + if (s == "u8" || s == "uint64") return PixelType::UInt64; + if (s == "i1" || s == "int8") return PixelType::Int8; + if (s == "i2" || s == "int16") return PixelType::Int16; + if (s == "i4" || s == "int32") return PixelType::Int32; + if (s == "i8" || s == "int64") return PixelType::Int64; + if (s == "f2") return PixelType::Float32; // half -> promote to float32 + if (s == "f4" || s == "float32" || s == "float") return PixelType::Float32; + if (s == "f8" || s == "float64" || s == "double") return PixelType::Float64; + return PixelType::Unknown; + } + + // One axis in on-disk (storage) order. + struct OmeAxis + { + AxisKind kind = AxisKind::Space; + char label = 'X'; // one of 'X','Y','Z','C','T' + std::size_t size = 1; // extent along this axis + double physical = 1.0; // voxel spacing / frame interval; 1.0 if unknown + std::string unit; // e.g. "micrometer","second"; empty if unknown + }; + + // One resolution level of a pyramid (level 0 == full-res). + struct OmePyramidLevel + { + std::size_t index = 0; + std::size_t sizeX = 0, sizeY = 0, sizeZ = 1; // 0 == extent not yet resolved for this sub-level + double scaleX = 1, scaleY = 1, scaleZ = 1; // coordinateTransformations 'scale' + std::string path; // Zarr dataset path ("0","1",...) / TIFF SubIFD ordinal + }; + + struct OmeAxes + { + // Sizes resolved by axis role (independent of on-disk order). + std::size_t sizeX = 1, sizeY = 1, sizeZ = 1, sizeC = 1, sizeT = 1; + + // On-disk axis order (numpy/slowest-first), e.g. "TCZYX" or "CTZYX" or "ZYX". + std::string storageOrder; + // OME DimensionOrder (fastest-first, always begins "XY"), e.g. "XYZCT". + std::string omeDimensionOrder; + std::vector storageAxes; // in storageOrder order + + // Physical calibration (convenience mirror of the space/time axes). + double physX = 1, physY = 1, physZ = 1, timeIncrement = 1; + std::string unitXY, unitZ, unitT; + + PixelType dtype = PixelType::UInt16; + unsigned bitsPerSample = 16; + + std::vector levels; // >=1; levels[0] full-res + std::vector channelNames; // optional (OME 'Channel'/omero) + + bool valid = false; // set true by a successful parse + + // Position of a label in storageAxes; -1 if absent. + int storageIndexOf(char lbl) const + { + for (std::size_t i = 0; i < storageAxes.size(); ++i) + if (storageAxes[i].label == lbl) return static_cast(i); + return -1; + } + + // Map a (z,c,t) plane to its IFD / plane ordinal under the OME + // DimensionOrder (default rasterization: the axes after XY, fastest to + // slowest). E.g. XYZCT -> ifd = z + c*sizeZ + t*sizeZ*sizeC. + std::size_t ifdForPlane(std::size_t z, std::size_t c, std::size_t t) const + { + std::size_t idx = 0, stride = 1; + for (char ax : omeDimensionOrder) + { + if (ax == 'X' || ax == 'Y') continue; + std::size_t coord = (ax == 'Z') ? z : (ax == 'C') ? c : t; + std::size_t sz = (ax == 'Z') ? sizeZ : (ax == 'C') ? sizeC : sizeT; + idx += coord * stride; + stride *= sz; + } + return idx; + } + + std::size_t numberPyramidLevels() const { return levels.empty() ? 1 : levels.size(); } + bool isVolumetric() const { return sizeZ > 1; } + bool isMultiChannel() const { return sizeC > 1; } + bool isTimeSeries() const { return sizeT > 1; } + }; + + // ---- small shared helpers ---------------------------------------------- + + inline AxisKind axis_kind_of(char label) + { + return (label == 'C') ? AxisKind::Channel : (label == 'T') ? AxisKind::Time : AxisKind::Space; + } + + // OME DimensionOrder (fastest->slowest) from an on-disk storage order + // (slowest-first). For a C-order array the last axis varies fastest, so the + // OME order is the reverse of storageOrder, padded with any absent axes kept + // in OME's canonical relative order so the result is always a legal value. + inline std::string ome_dimension_order_from_storage(const std::string& storageOrder) + { + std::string rev(storageOrder.rbegin(), storageOrder.rend()); + for (char ax : std::string("XYZCT")) + if (rev.find(ax) == std::string::npos) + rev.push_back(ax); + return rev; + } +} diff --git a/src/nyx/ome/ome_tiff_meta.cpp b/src/nyx/ome/ome_tiff_meta.cpp new file mode 100644 index 000000000..c648fde74 --- /dev/null +++ b/src/nyx/ome/ome_tiff_meta.cpp @@ -0,0 +1,166 @@ +#include "ome_tiff_meta.h" + +#include +#include +#include +#include + +namespace Nyxus +{ + // Extract the start-tag text "" of the first element called `name`. + // Returns "" if not found. Matches a name boundary so "Pixels" does not match + // a hypothetical "PixelsFoo". + static std::string element_start_tag(const std::string& xml, const std::string& name) + { + const std::string open = "<" + name; + std::size_t p = 0; + while ((p = xml.find(open, p)) != std::string::npos) + { + char after = (p + open.size() < xml.size()) ? xml[p + open.size()] : '\0'; + if (after == ' ' || after == '\t' || after == '\n' || after == '\r' || after == '>' || after == '/') + { + std::size_t gt = xml.find('>', p); + if (gt == std::string::npos) return ""; + return xml.substr(p, gt - p + 1); + } + p += open.size(); + } + return ""; + } + + // Read attribute `name` from a single start-tag string. Boundary-checked so + // "SizeX" does not match inside "PhysicalSizeX". Handles ' and " quoting. + static bool get_attr(const std::string& tag, const std::string& name, std::string& out) + { + std::size_t pos = 0; + while ((pos = tag.find(name, pos)) != std::string::npos) + { + bool ok_before = (pos == 0) || std::isspace((unsigned char)tag[pos - 1]); + std::size_t q = pos + name.size(); + while (q < tag.size() && std::isspace((unsigned char)tag[q])) ++q; + if (ok_before && q < tag.size() && tag[q] == '=') + { + ++q; + while (q < tag.size() && std::isspace((unsigned char)tag[q])) ++q; + if (q < tag.size() && (tag[q] == '"' || tag[q] == '\'')) + { + char quote = tag[q]; + std::size_t start = q + 1, end = tag.find(quote, start); + if (end != std::string::npos) { out = tag.substr(start, end - start); return true; } + } + } + pos += name.size(); + } + return false; + } + + OmeAxes parse_ome_xml(const std::string& xml) + { + OmeAxes ax; + const std::string pix = element_start_tag(xml, "Pixels"); + if (pix.empty()) + return ax; // valid stays false + + auto s = [&](const char* n, const std::string& def) { std::string v; return get_attr(pix, n, v) ? v : def; }; + // Size reader (std::from_chars): absent / non-numeric / negative / overflow + // -> default; "0" -> 1. OME extents are >=1; from_chars rejects a leading + // '-' and overflow via errc, so no strtoull wrap or divide-by-zero leaks out. + auto i = [&](const char* n, std::size_t def) -> std::size_t { + std::string v; if (!get_attr(pix, n, v)) return def; + std::size_t r = 0; + auto [ptr, ec] = std::from_chars(v.data(), v.data() + v.size(), r); + if (ec != std::errc() || ptr == v.data()) return def; + return r < 1 ? std::size_t(1) : r; + }; + // Double reader (std::from_chars): absent / non-numeric / non-finite -> + // default. from_chars still accepts "nan"/"inf" prefixes (e.g. "NaNsense"), + // so the isfinite check keeps those out of physical-calibration math. + auto d = [&](const char* n, double def) -> double { + std::string v; if (!get_attr(pix, n, v)) return def; + double r = 0; + auto [ptr, ec] = std::from_chars(v.data(), v.data() + v.size(), r); + if (ec != std::errc() || ptr == v.data() || !std::isfinite(r)) return def; + return r; + }; + + ax.sizeX = i("SizeX", 1); + ax.sizeY = i("SizeY", 1); + ax.sizeZ = i("SizeZ", 1); + ax.sizeC = i("SizeC", 1); + ax.sizeT = i("SizeT", 1); + + // DimensionOrder must be a permutation of the 5 canonical axes; a missing + // or malformed value falls back to the OME default so storageOrder stays sane. + std::string dord = s("DimensionOrder", "XYZCT"); + bool perm = dord.size() == 5; + for (char c : std::string("XYZCT")) + if (std::count(dord.begin(), dord.end(), c) != 1) perm = false; + ax.omeDimensionOrder = perm ? dord : "XYZCT"; + + ax.dtype = pixel_type_from_ome_type(s("Type", "uint16")); + if (ax.dtype == PixelType::Unknown) ax.dtype = PixelType::UInt16; + ax.bitsPerSample = bits_of(ax.dtype); + + ax.physX = d("PhysicalSizeX", 1.0); + ax.physY = d("PhysicalSizeY", 1.0); + ax.physZ = d("PhysicalSizeZ", 1.0); + ax.timeIncrement = d("TimeIncrement", 1.0); + ax.unitXY = s("PhysicalSizeXUnit", ""); + ax.unitZ = s("PhysicalSizeZUnit", ""); + ax.unitT = s("TimeIncrementUnit", ""); + + // On-disk order = reverse(DimensionOrder) with singleton axes dropped + // (planes are XY, so the result always ends in "YX"). + auto size_of = [&](char c) -> std::size_t { + switch (c) { case 'X': return ax.sizeX; case 'Y': return ax.sizeY; + case 'Z': return ax.sizeZ; case 'C': return ax.sizeC; case 'T': return ax.sizeT; } + return 1; + }; + std::string rev(ax.omeDimensionOrder.rbegin(), ax.omeDimensionOrder.rend()); + for (char c : rev) + if (c == 'X' || c == 'Y' || size_of(c) > 1) + ax.storageOrder.push_back(c); + + for (char c : ax.storageOrder) + { + OmeAxis a; + a.label = c; + a.kind = axis_kind_of(c); + a.size = size_of(c); + if (c == 'X') { a.physical = ax.physX; a.unit = ax.unitXY; } + else if (c == 'Y') { a.physical = ax.physY; a.unit = ax.unitXY; } + else if (c == 'Z') { a.physical = ax.physZ; a.unit = ax.unitZ; } + else if (c == 'T') { a.physical = ax.timeIncrement; a.unit = ax.unitT; } + else /* C */ { a.physical = 1.0; } + ax.storageAxes.push_back(a); + } + + // Single full-res level (SubIFDs pyramid parsing not implemented yet). + OmePyramidLevel lv; + lv.index = 0; + lv.sizeX = ax.sizeX; lv.sizeY = ax.sizeY; lv.sizeZ = ax.sizeZ; + lv.scaleX = ax.physX; lv.scaleY = ax.physY; lv.scaleZ = ax.physZ; + lv.path = "0"; + ax.levels.push_back(lv); + + // Optional channel names. + std::size_t cp = 0; + while ((cp = xml.find("') + { + std::size_t gt = xml.find('>', cp); + if (gt == std::string::npos) break; + std::string ctag = xml.substr(cp, gt - cp + 1); + std::string nm; + if (get_attr(ctag, "Name", nm)) ax.channelNames.push_back(nm); + cp = gt + 1; + } + else cp += 8; + } + + ax.valid = true; + return ax; + } +} diff --git a/src/nyx/ome/ome_tiff_meta.h b/src/nyx/ome/ome_tiff_meta.h new file mode 100644 index 000000000..dd0316138 --- /dev/null +++ b/src/nyx/ome/ome_tiff_meta.h @@ -0,0 +1,19 @@ +#pragma once + +// OME-TIFF metadata -> OmeAxes. Parses the dimensional attributes of the OME-XML +// element (SizeX/Y/Z/C/T, DimensionOrder, Type, PhysicalSize*, +// TimeIncrement) plus names. STL-only. +// +// Currently resolves dimensions + calibration + dtype. The TiffData plane->IFD +// map and SubIFDs pyramids are not parsed yet; levels here is a single full-res +// level. + +#include +#include "ome_axes.h" + +namespace Nyxus +{ + // Parse an OME-XML document (the string from a TIFF IFD-0 ImageDescription). + // Returns an OmeAxes with .valid==false if no element is found. + OmeAxes parse_ome_xml(const std::string& omeXml); +} diff --git a/src/nyx/ome/ome_zarr_meta.cpp b/src/nyx/ome/ome_zarr_meta.cpp new file mode 100644 index 000000000..0665b40e3 --- /dev/null +++ b/src/nyx/ome/ome_zarr_meta.cpp @@ -0,0 +1,123 @@ +#ifdef OMEZARR_SUPPORT + +#include "ome_zarr_meta.h" +#include + +namespace Nyxus +{ + // Pull the 'scale' vector out of a dataset's coordinateTransformations (the + // entry whose type == "scale"); empty if the key/entry is absent or malformed. + // Safe on datasets that lack coordinateTransformations entirely (else a const + // operator[] on a missing key throws). + static std::vector scale_of(const nlohmann::json& dset) + { + std::vector s; + if (!dset.is_object() || !dset.contains("coordinateTransformations")) + return s; + const auto& cts = dset["coordinateTransformations"]; + if (cts.is_array()) + for (const auto& ct : cts) + if (ct.is_object() && ct.value("type", std::string()) == "scale" && + ct.contains("scale") && ct["scale"].is_array()) + { + for (const auto& v : ct["scale"]) s.push_back(v.is_number() ? v.get() : 1.0); + break; + } + return s; + } + + OmeAxes parse_ome_zarr(const nlohmann::json& groupAttrs, + const std::vector& level0Shape, + const std::string& dtypeStr) + { + OmeAxes ax; + + // 0.5 nests the model under "ome"; 0.4 puts it at the group root. + const nlohmann::json* root = nullptr; + if (groupAttrs.contains("ome") && groupAttrs["ome"].contains("multiscales")) + root = &groupAttrs["ome"]; + else if (groupAttrs.contains("multiscales")) + root = &groupAttrs; + if (!root) + return ax; + + const auto& multiscales = (*root)["multiscales"]; + if (!multiscales.is_array() || multiscales.empty()) + return ax; + const auto& ms = multiscales[0]; + if (!ms.contains("axes") || !ms.contains("datasets")) + return ax; + + const auto& axes = ms["axes"]; + const auto& datasets = ms["datasets"]; + if (!axes.is_array() || !datasets.is_array() || datasets.empty()) + return ax; + + // Level-0 scale (per-axis), from the first dataset's transformations. + std::vector scale0 = scale_of(datasets[0]); + if (scale0.size() != axes.size()) + scale0.assign(axes.size(), 1.0); + + ax.dtype = pixel_type_from_zarr_dtype(dtypeStr); + if (ax.dtype == PixelType::Unknown) ax.dtype = PixelType::UInt16; + ax.bitsPerSample = bits_of(ax.dtype); + + for (std::size_t k = 0; k < axes.size(); ++k) + { + const auto& a = axes[k]; + if (!a.is_object()) // a malformed axis entry -> skip (keeps role sizes at default) + continue; + std::string name = a.value("name", std::string()); + std::string type = a.value("type", std::string()); + std::string unit = a.value("unit", std::string()); + char label = name.empty() ? '?' : (char)std::toupper((unsigned char)name[0]); + + OmeAxis oa; + oa.label = label; + if (type == "channel") oa.kind = AxisKind::Channel; + else if (type == "time") oa.kind = AxisKind::Time; + else oa.kind = AxisKind::Space; + oa.size = (k < level0Shape.size()) ? level0Shape[k] : 1; + oa.physical = (k < scale0.size()) ? scale0[k] : 1.0; + oa.unit = unit; + ax.storageAxes.push_back(oa); + ax.storageOrder.push_back(label); + + switch (label) + { + case 'X': ax.sizeX = oa.size; ax.physX = oa.physical; ax.unitXY = unit; break; + case 'Y': ax.sizeY = oa.size; ax.physY = oa.physical; if (ax.unitXY.empty()) ax.unitXY = unit; break; + case 'Z': ax.sizeZ = oa.size; ax.physZ = oa.physical; ax.unitZ = unit; break; + case 'C': ax.sizeC = oa.size; break; + case 'T': ax.sizeT = oa.size; ax.timeIncrement = oa.physical; ax.unitT = unit; break; + default: break; + } + } + + ax.omeDimensionOrder = ome_dimension_order_from_storage(ax.storageOrder); + + // Pyramid levels: one per dataset entry. + int ix = ax.storageIndexOf('X'), iy = ax.storageIndexOf('Y'), iz = ax.storageIndexOf('Z'); + for (std::size_t li = 0; li < datasets.size(); ++li) + { + const auto& dset = datasets[li]; + OmePyramidLevel lv; + lv.index = li; + lv.path = dset.is_object() ? dset.value("path", std::to_string(li)) : std::to_string(li); + std::vector sc = scale_of(dset); + if (ix >= 0 && (std::size_t)ix < sc.size()) lv.scaleX = sc[ix]; + if (iy >= 0 && (std::size_t)iy < sc.size()) lv.scaleY = sc[iy]; + if (iz >= 0 && (std::size_t)iz < sc.size()) lv.scaleZ = sc[iz]; + if (li == 0) // only level-0 extents are known from the supplied shape + { + lv.sizeX = ax.sizeX; lv.sizeY = ax.sizeY; lv.sizeZ = ax.sizeZ; + } + ax.levels.push_back(lv); + } + + ax.valid = true; + return ax; + } +} + +#endif // OMEZARR_SUPPORT diff --git a/src/nyx/ome/ome_zarr_meta.h b/src/nyx/ome/ome_zarr_meta.h new file mode 100644 index 000000000..08d085966 --- /dev/null +++ b/src/nyx/ome/ome_zarr_meta.h @@ -0,0 +1,34 @@ +#pragma once + +// OME-Zarr (NGFF) metadata -> OmeAxes. Parses the group `multiscales` +// block: `axes` (order + type + unit), `datasets` (pyramid paths + +// coordinateTransformations), for both 0.4 (metadata at the group root) and 0.5 +// (metadata under the "ome" key). The caller supplies the level-0 array shape +// and dtype (read from the dataset's .zarray/zarr.json via z5) so this parser +// stays decoupled from the storage backend. +// +// Guarded by OMEZARR_SUPPORT because it depends on nlohmann::json (vendored with +// z5). Only meaningful when Nyxus is built with OME-Zarr support. + +#ifdef OMEZARR_SUPPORT + +#include +#include +#include +#include "nlohmann/json.hpp" +#include "ome_axes.h" + +namespace Nyxus +{ + // Parse NGFF group attributes into an OmeAxes. + // groupAttrs : the group's attributes JSON (.zattrs contents for v2, or the + // "attributes" object of zarr.json for v3). + // level0Shape : shape of the level-0 dataset, in on-disk axis order. + // dtypeStr : the level-0 dtype string (v2 "& level0Shape, + const std::string& dtypeStr); +} + +#endif // OMEZARR_SUPPORT diff --git a/src/nyx/omezarr.h b/src/nyx/omezarr.h index 5222657c3..1c96f6952 100644 --- a/src/nyx/omezarr.h +++ b/src/nyx/omezarr.h @@ -3,8 +3,11 @@ #ifdef OMEZARR_SUPPORT #include +#include +#include #include "abs_tile_loader.h" #include "nlohmann/json.hpp" +#include "ome/ome_zarr_meta.h" // parse_ome_zarr -> OmeAxes (axis-role resolution) // factory functions to create files, groups and datasets #include "z5/factory.hxx" @@ -40,20 +43,59 @@ class NyxusOmeZarrLoader : public AbstractTileLoader nlohmann::json file_attributes, ds_attributes; z5::readAttributes(*zarr_ptr_, file_attributes); - // assume only one dataset is present + // open the highest-resolution dataset (level 0) ds_name_ = file_attributes["multiscales"][0]["datasets"][0]["path"].get(); const auto ds_handle = z5::filesystem::handle::Dataset(*zarr_ptr_, ds_name_); fs::path metadata_path; auto success = z5::filesystem::metadata_detail::getMetadataPath(ds_handle, metadata_path); z5::filesystem::metadata_detail::readMetadata(metadata_path, ds_attributes); - - full_depth_ = ds_attributes["shape"][2].get(); - full_height_ = ds_attributes["shape"][3].get(); - full_width_ = ds_attributes["shape"][4].get(); - tile_depth_ = ds_attributes["chunks"][2].get(); - tile_height_ = ds_attributes["chunks"][3].get(); - tile_width_ = ds_attributes["chunks"][4].get(); + + std::vector level0Shape, chunkShape; + for (const auto& d : ds_attributes["shape"]) level0Shape.push_back(d.get()); + for (const auto& d : ds_attributes["chunks"]) chunkShape.push_back(d.get()); std::string dtype_str = ds_attributes["dtype"].get(); + + // Resolve axis roles from the NGFF 'axes' metadata instead of assuming a + // fixed [T,C,Z,Y,X] order. Falls back to legacy 5D TCZYX if 'axes' is absent. + Nyxus::OmeAxes axes = Nyxus::parse_ome_zarr(file_attributes, level0Shape, dtype_str); + if (axes.valid) + { + // Reject self-inconsistent metadata rather than guess: if the 'axes' + // count disagrees with the array rank, indexing the shape by axis role + // would read out of bounds. + if (axes.storageAxes.size() != level0Shape.size()) + throw std::runtime_error("OME-Zarr: 'axes' count " + std::to_string(axes.storageAxes.size()) + + " does not match array rank " + std::to_string(level0Shape.size())); + ndim_ = axes.storageAxes.size(); + ix_ = axes.storageIndexOf('X'); iy_ = axes.storageIndexOf('Y'); + iz_ = axes.storageIndexOf('Z'); ic_ = axes.storageIndexOf('C'); + it_ = axes.storageIndexOf('T'); + n_levels_ = axes.numberPyramidLevels(); + } + else + { + // No usable 'axes': map by position (X,Y last; Z,C,T before) — rank-safe. + ndim_ = level0Shape.size(); + int n = (int)ndim_; + ix_ = n - 1; iy_ = n - 2; + iz_ = (n >= 3) ? n - 3 : -1; + ic_ = (n >= 4) ? n - 4 : -1; + it_ = (n >= 5) ? n - 5 : -1; + n_levels_ = 1; + } + // X and Y must resolve to real dimensions, else the read would index OOB. + if (ix_ < 0 || iy_ < 0 || (size_t)ix_ >= level0Shape.size() || (size_t)iy_ >= level0Shape.size()) + throw std::runtime_error("OME-Zarr: cannot resolve X/Y axes from metadata"); + + full_width_ = level0Shape[ix_]; + full_height_ = level0Shape[iy_]; + full_depth_ = (iz_ >= 0) ? level0Shape[iz_] : 1; + tile_width_ = chunkShape[ix_]; + tile_height_ = chunkShape[iy_]; + tile_depth_ = (iz_ >= 0) ? chunkShape[iz_] : 1; + bits_per_sample_ = Nyxus::bits_of(Nyxus::pixel_type_from_zarr_dtype(dtype_str)); + + // dtype -> internal format code for the read-template dispatch if (dtype_str == " size_t indexRowGlobalTile, size_t indexColGlobalTile, size_t indexLayerGlobalTile, - [[maybe_unused]] size_t level) override + size_t indexChannel, // C plane to read (offset into the channel axis) + size_t indexTimeframe, // T plane to read (offset into the time axis) + [[maybe_unused]] size_t level) override { size_t pixel_row_index = indexRowGlobalTile*tile_height_; size_t pixel_col_index = indexColGlobalTile*tile_width_; size_t pixel_layer_index = indexLayerGlobalTile*tile_depth_; - + switch (data_format_) { case 1: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 2: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 3: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 4: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 5: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 6: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 7: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 8: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 9: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 10: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; default: - loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(tile, pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; } } - + template - void loadTile(std::shared_ptr> &dest, size_t pixel_row_index, - size_t pixel_col_index, size_t pixel_layer_index) { + void loadTile(std::shared_ptr> &dest, size_t pixel_row_index, + size_t pixel_col_index, size_t pixel_layer_index, + size_t pixel_channel_index, size_t pixel_timeframe_index) { size_t data_height = tile_height_, data_width = tile_width_; if (pixel_row_index + data_height > full_height_) { data_height = full_height_ - pixel_row_index; @@ -148,11 +193,16 @@ class NyxusOmeZarrLoader : public AbstractTileLoader // Create a buffer to hold the read data std::vector buffer(data_height * data_width); - - // Create an ArrayView into the buffer (z5 3.0.1 uses ArrayView instead of xtensor) - z5::types::ShapeType shape = {1, 1, 1, data_height, data_width}; + + // Build the read window by axis ROLE (honoring the resolved 'axes' order), + // reading one Y*X plane at the requested Z/C/T. z5 3.0.1 uses ArrayView. + z5::types::ShapeType shape(ndim_, 1), offset(ndim_, 0); + shape[iy_] = data_height; offset[iy_] = pixel_row_index; + shape[ix_] = data_width; offset[ix_] = pixel_col_index; + if (iz_ >= 0) offset[iz_] = pixel_layer_index; + if (ic_ >= 0) offset[ic_] = pixel_channel_index; + if (it_ >= 0) offset[it_] = pixel_timeframe_index; auto view = z5::multiarray::makeView(buffer.data(), shape); - z5::types::ShapeType offset = {0, 0, pixel_layer_index, pixel_row_index, pixel_col_index}; // Read subarray from the cached z5 dataset z5::multiarray::readSubarray(*ds_, view, offset.begin()); @@ -191,12 +241,10 @@ class NyxusOmeZarrLoader : public AbstractTileLoader /// @return Tile depth [[nodiscard]] size_t tileDepth([[maybe_unused]] size_t level) const override { return tile_depth_; } - /// @brief Tiff bits per sample - /// @return Size of a sample in bits - [[nodiscard]] short bitsPerSample() const override { return 1; } - /// @brief Level accessor - /// @return 1 - [[nodiscard]] size_t numberPyramidLevels() const override { return 1; } + /// @brief Bits per sample (resolved from the dataset dtype) + [[nodiscard]] short bitsPerSample() const override { return bits_per_sample_; } + /// @brief Number of resolution (pyramid) levels declared in multiscales + [[nodiscard]] size_t numberPyramidLevels() const override { return n_levels_; } private: @@ -208,6 +256,12 @@ class NyxusOmeZarrLoader : public AbstractTileLoader tile_height_ = 0, ///< Tile height tile_depth_ = 0; ///< Tile depth + // Storage-dimension index of each axis role (-1 if the axis is absent). + int ix_ = 4, iy_ = 3, iz_ = 2, ic_ = 1, it_ = 0; + size_t ndim_ = 5; ///< Number of on-disk dimensions (2..5) + short bits_per_sample_ = 16; ///< Real bit depth + size_t n_levels_ = 1; ///< Pyramid level count + short data_format_ = 0; std::unique_ptr zarr_ptr_; std::string ds_name_; diff --git a/src/nyx/phase1.cpp b/src/nyx/phase1.cpp index 51f487247..00dded8bb 100644 --- a/src/nyx/phase1.cpp +++ b/src/nyx/phase1.cpp @@ -279,47 +279,37 @@ namespace Nyxus int cnt = 1; - // Fetch a tile - bool ok = env.theImLoader.load_tile (0/*row*/ , 0/*col*/); + // FIX (IO): assemble the whole X*Y*Z volume for this timeframe (load_volume + // loops Z), so plane-by-plane loaders (OME-Zarr, multi-page/OME-TIFF) yield a + // real 3D volume here too — Phase-1 ROI metrics must see the same voxels the + // Phase-2 scan does. Mask on frame t_index (1:1) or frame 0 (1 mask : N int). + const size_t maskFrame = (nVoxI == nVoxM) ? t_index : 0; + bool ok = env.theImLoader.load_volume (0/*channel*/, t_index, maskFrame); if (!ok) { std::string erm = "Error fetching tile (0,0) from I:" + intens_fpath + " M:" + mask_fpath; #ifdef WITH_PYTHON_H throw erm; - #endif + #endif std::cerr << erm << "\n"; return false; } - // Get ahold of tile's pixel buffer - auto dataI = env.theImLoader.get_int_tile_buffer(), - dataL = env.theImLoader.get_seg_tile_buffer(); + // Both buffers hold exactly this timeframe's X*Y*Z volume (frame already selected). + const std::vector& dataI = env.theImLoader.get_int_volume_buffer(); + const std::vector& dataL = env.theImLoader.get_seg_volume_buffer(); - size_t baseI, baseM; - if (nVoxI == nVoxM) - { - baseM = baseI = t_index * timeFrameSize; - } - else // nVoxI > nVoxM - { - baseM = 0; - baseI = t_index * timeFrameSize; - } - - // Iterate voxels + // Iterate the single-frame volume's voxels for (size_t i=0; i= w || y >= h || z >= d) @@ -330,7 +320,7 @@ namespace Nyxus label = 1; // Update pixel's ROI metrics - feed_pixel_2_metrics_3D (env.uniqueLabels, env.roiData, x, y, z, dataI[j], label, sidx); + feed_pixel_2_metrics_3D (env.uniqueLabels, env.roiData, x, y, z, dataI[i], label, sidx); } #ifdef WITH_PYTHON_H diff --git a/src/nyx/phase2_3d.cpp b/src/nyx/phase2_3d.cpp index f081ebd0e..d942a8511 100644 --- a/src/nyx/phase2_3d.cpp +++ b/src/nyx/phase2_3d.cpp @@ -83,41 +83,33 @@ namespace Nyxus int cnt = 1; - // fetch 3D data - bool ok = env.theImLoader.load_tile (0/*row*/, 0/*col*/); + // FIX (IO): assemble the whole X*Y*Z volume for this timeframe via load_volume, + // which loops Z internally. The previous load_tile(0,0) delivered only plane + // z=0 for plane-by-plane loaders (OME-Zarr, multi-page/OME-TIFF), so those never + // read a real 3D volume. The mask sits on frame t_index (1:1) or frame 0 (the + // 1-mask : N-intensity case); the intensity sits on frame t_index. + const size_t maskFrame = (timeI == timeM) ? t_index : 0; + bool ok = env.theImLoader.load_volume (0/*channel*/, t_index, maskFrame); if (!ok) { std::string erm = "Error fetching segmented data from " + intens_fpath + "(I) " + label_fpath + "(M)"; #ifdef WITH_PYTHON_H throw erm; -#endif +#endif std::cerr << erm << "\n"; return false; } - // Get ahold of tile's pixel buffer - auto dataI = env.theImLoader.get_int_tile_buffer(), - dataL = env.theImLoader.get_seg_tile_buffer(); - - size_t baseI, baseM; - if (timeI == timeM) - { - baseM = baseI = t_index * timeFrameSize; - } - else // nVoxI > nVoxM - { - baseM = 0; - baseI = t_index * timeFrameSize; - } + // Both buffers now hold exactly this timeframe's X*Y*Z volume (the frame was + // already selected by load_volume), so no base offset is needed. + const std::vector& dataI = env.theImLoader.get_int_volume_buffer(); + const std::vector& dataL = env.theImLoader.get_seg_volume_buffer(); - // Iterate voxels - for (size_t i=0; i= w || y >= h || z >= d) @@ -139,9 +129,9 @@ namespace Nyxus if (env.singleROI) label = 1; - // Cache this pixel + // Cache this pixel LR& r = env.roiData[label]; - feed_pixel_2_cache_3D_LR (x, y, z, dataI[j], r); + feed_pixel_2_cache_3D_LR (x, y, z, dataI[i], r); } #ifdef WITH_PYTHON_H @@ -343,35 +333,23 @@ namespace Nyxus int cnt = 1; - // fetch 3D data - if (!env.theImLoader.load_tile (0/*row*/, 0/*col*/)) + // FIX (IO): assemble the whole X*Y*Z volume for this timeframe (load_volume + // loops Z), so plane-by-plane loaders (OME-Zarr, multi-page/OME-TIFF) read a + // real 3D volume here too. Mask on frame t_index (1:1) or frame 0 (1 mask : N). + const size_t maskFrame = (timeI == timeM) ? t_index : 0; + if (!env.theImLoader.load_volume (0/*channel*/, t_index, maskFrame)) { std::string erm = "Error fetching data from file pair " + intens_fpath + "(I) " + label_fpath + "(M)"; #ifdef WITH_PYTHON_H throw erm; - #endif + #endif std::cerr << erm << "\n"; return false; } - // get ahold of voxel buffers - auto dataI = env.theImLoader.get_int_tile_buffer(), - dataL = env.theImLoader.get_seg_tile_buffer(); - - // align time frame's mask and intensity volumes - size_t baseI, baseM; - if (timeI == timeM) - { - // trivial N mask : N intensity - baseM = - baseI = t_index * timeFrameSize; - } - else - { - // nontrivial 1 mask : N intensity - baseM = 0; - baseI = t_index * timeFrameSize; - } + // Both buffers hold exactly this timeframe's X*Y*Z volume (frame already selected). + const std::vector& dataI = env.theImLoader.get_int_volume_buffer(); + const std::vector& dataL = env.theImLoader.get_seg_volume_buffer(); // virtual dimensions size_t virt_h = h * aniso_y, @@ -408,7 +386,7 @@ namespace Nyxus // // skip non-mask pixels - auto lbl = dataL[baseM + i]; + auto lbl = dataL[i]; if (!lbl) continue; @@ -432,8 +410,8 @@ namespace Nyxus assert(vX < virt_w); #endif - // cache this voxel - auto inten = dataI[baseI + i]; + // cache this voxel + auto inten = dataI[i]; LR& r = env.roiData[lbl]; feed_pixel_2_cache_3D_LR (vX, vY, vZ, inten, r); } @@ -468,18 +446,20 @@ namespace Nyxus sliceSize = fullwidth * fullheight, nVox = sliceSize * fullD; - // in the 3D case tiling is a formality, so fetch the only tile in the file - if (!ilo.load_tile(0, 0)) + // FIX (IO): assemble the whole X*Y*Z volume (load_volume loops Z) so plane-by- + // plane loaders (OME-Zarr, multi-page/OME-TIFF) read the full volume, not just + // plane z=0. Whole-slide is single-channel, single-timeframe. + if (!ilo.load_volume(0/*channel*/, 0/*timeframe*/, 0/*mask*/)) { #ifdef WITH_PYTHON_H throw "Error fetching tile"; -#endif +#endif std::cerr << "Error fetching tile\n"; return false; } - // Get ahold of tile's pixel buffer - const std::vector& dataI = ilo.get_int_tile_buffer(); + // Get ahold of the assembled volume buffer + const std::vector& dataI = ilo.get_int_volume_buffer(); // iterate abstract tiles (in a tiled slide /e.g. tiled tiff/ they correspond to physical tiles, in a nontiled slide /e.g. scanline tiff or strip tiff/ they correspond to ) int cnt = 1; @@ -531,18 +511,19 @@ namespace Nyxus vw = (size_t) (double(fullW) * aniso_x), vd = (size_t) (double(fullD) * aniso_z); - // in the 3D case tiling is a formality, so fetch the only tile in the file - if (! ilo.load_tile(0, 0)) + // FIX (IO): assemble the whole X*Y*Z volume (load_volume loops Z) so plane-by- + // plane loaders read the full volume, not just plane z=0. + if (! ilo.load_volume(0/*channel*/, 0/*timeframe*/, 0/*mask*/)) { #ifdef WITH_PYTHON_H throw "Error loading volume data"; -#endif +#endif std::cerr << "Error loading volume data\n"; return false; } - // Get ahold of tile's pixel buffer - const std::vector& dataI = ilo.get_int_tile_buffer(); + // Get ahold of the assembled volume buffer + const std::vector& dataI = ilo.get_int_volume_buffer(); // iterate virtual voxels size_t vSliceSize = vh * vw, diff --git a/src/nyx/raw_dicom.h b/src/nyx/raw_dicom.h index ad4d602c7..e0e8e0543 100644 --- a/src/nyx/raw_dicom.h +++ b/src/nyx/raw_dicom.h @@ -133,6 +133,8 @@ class RawDicomLoader : public RawFormatLoader size_t indexRowGlobalTile, size_t indexColGlobalTile, size_t indexLayerGlobalTile, + [[maybe_unused]] size_t indexChannel, // DICOM grayscale: single channel + [[maybe_unused]] size_t indexTimeframe, // DICOM: frames are WSI tiles, not time [[maybe_unused]] size_t level) override { tile.resize (tileWidth_ * tileHeight_); diff --git a/src/nyx/raw_format.h b/src/nyx/raw_format.h index 7dc9de273..27c13d75c 100644 --- a/src/nyx/raw_format.h +++ b/src/nyx/raw_format.h @@ -19,7 +19,9 @@ class RawFormatLoader virtual void loadTileFromFile ( size_t indexRowGlobalTile, size_t indexColGlobalTile, - size_t indexLayerGlobalTile, + size_t indexLayerGlobalTile, // Z + size_t indexChannel, // C; 0 for single-channel data + size_t indexTimeframe, // T; 0 for non-time-series data size_t level) = 0; virtual void free_tile() = 0; // must follow loadTileFromFile() @@ -52,6 +54,10 @@ class RawFormatLoader return 1; } + [[nodiscard]] virtual size_t tileTimestamps([[maybe_unused]] size_t level) const { + return 1; + } + [[nodiscard]] virtual short bitsPerSample() const = 0; [[nodiscard]] virtual size_t numberPyramidLevels() const = 0; diff --git a/src/nyx/raw_image_loader.cpp b/src/nyx/raw_image_loader.cpp index 350d1ba6b..d85232240 100644 --- a/src/nyx/raw_image_loader.cpp +++ b/src/nyx/raw_image_loader.cpp @@ -9,6 +9,7 @@ #include "raw_nifti.h" #include "raw_omezarr.h" #include "raw_tiff.h" +#include "ome/format_detect.h" // FIX: unified content-sniffing loader dispatch RawImageLoader::RawImageLoader() {} @@ -16,9 +17,11 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ { try { - std::string ext = Nyxus::get_big_extension(int_fpath); + // FIX: classify via detect_input_format(). Defects fixed: (1) matched only ".zarr" so + // ".ome.zarr" mis-routed to TIFF; (2) `ext==".dcm" | ext==".dicom"` used bitwise-OR (works by luck). + Nyxus::InputFormat fmt = Nyxus::detect_input_format (int_fpath); - if (ext == ".zarr") + if (fmt.kind == Nyxus::ContainerKind::OmeZarr) // FIX: was `ext==".zarr"` only (dropped .ome.zarr) { #ifdef OMEZARR_SUPPORT intFL = new RawOmezarrLoader (int_fpath); @@ -26,8 +29,8 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ std::cout << "This version of Nyxus was not build with OmeZarr support." << std::endl; #endif } - else - if (ext == ".dcm" | ext == ".dicom") { + else + if (fmt.kind == Nyxus::ContainerKind::Dicom) { // FIX: was bitwise `ext==".dcm" | ext==".dicom"` #ifdef DICOM_SUPPORT intFL = new RawDicomLoader (int_fpath, preserve_hu); // CT/HU: scan in Hounsfield domain #else @@ -35,7 +38,7 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ #endif } else - if (ext == ".nii" || ext == ".nii.gz") + if (fmt.kind == Nyxus::ContainerKind::Nifti) // FIX: was `ext==".nii"||".nii.gz"` { intFL = new RawNiftiLoader (int_fpath, preserve_hu); // FIX: CT/HU: scan in Hounsfield domain (matches DICOM) } @@ -82,8 +85,10 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ // segmented slide try { - std::string ext = Nyxus::get_big_extension(seg_fpath); - if (ext == ".zarr") + // FIX: unified seg dispatch — same defects as the intensity path (".ome.zarr" dropped, + // bitwise-OR on the DICOM test) fixed here via detect_input_format(). + Nyxus::InputFormat fmt = Nyxus::detect_input_format (seg_fpath); + if (fmt.kind == Nyxus::ContainerKind::OmeZarr) // FIX: was `ext==".zarr"` only (dropped .ome.zarr) { #ifdef OMEZARR_SUPPORT segFL = new RawOmezarrLoader (seg_fpath); @@ -91,8 +96,8 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ std::cout << "This version of Nyxus was not build with OmeZarr support." << std::endl; #endif } - else - if (ext == ".dcm" | ext == ".dicom") + else + if (fmt.kind == Nyxus::ContainerKind::Dicom) // FIX: was bitwise `ext==".dcm" | ext==".dicom"` { #ifdef DICOM_SUPPORT segFL = new RawDicomLoader (seg_fpath); @@ -101,7 +106,7 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ #endif } else - if (ext == ".nii" || ext == ".nii.gz") + if (fmt.kind == Nyxus::ContainerKind::Nifti) // FIX: was `ext==".nii"||".nii.gz"` { segFL = new RawNiftiLoader (seg_fpath); } @@ -183,11 +188,11 @@ bool RawImageLoader::load_tile(size_t tile_idx) auto row = tile_idx / ntw; auto col = tile_idx % ntw; - intFL->loadTileFromFile (row, col, lyr, lvl); + intFL->loadTileFromFile (row, col, lyr, cur_channel, cur_timeframe, lvl); // segmentation loader is not available in wholeslide if (segFL) - segFL->loadTileFromFile (row, col, lyr, lvl); + segFL->loadTileFromFile (row, col, lyr, cur_channel, cur_timeframe, lvl); return true; } @@ -197,12 +202,12 @@ bool RawImageLoader::load_tile(size_t tile_row, size_t tile_col) if (tile_row >= nth || tile_col >= ntw) return false; - intFL->loadTileFromFile (tile_row, tile_col, lyr, lvl); - + intFL->loadTileFromFile (tile_row, tile_col, lyr, cur_channel, cur_timeframe, lvl); + // segmentation loader is not available in wholeslide if (segFL) - segFL->loadTileFromFile (tile_row, tile_col, lyr, lvl); - + segFL->loadTileFromFile (tile_row, tile_col, lyr, cur_channel, cur_timeframe, lvl); + return true; } diff --git a/src/nyx/raw_image_loader.h b/src/nyx/raw_image_loader.h index 62d506454..c871e6968 100644 --- a/src/nyx/raw_image_loader.h +++ b/src/nyx/raw_image_loader.h @@ -35,6 +35,13 @@ class RawImageLoader size_t get_inten_time(); size_t get_mask_time(); + // Select which channel (C) / timeframe (T) plane subsequent load_tile() calls + // read. Default 0/0 preserves the single-channel, single-timepoint behavior. + void set_channel (size_t c) { cur_channel = c; } + void set_timeframe (size_t t) { cur_timeframe = t; } + size_t get_channel() const { return cur_channel; } + size_t get_timeframe() const { return cur_timeframe; } + std::string get_slide_descr(); bool get_fp_phys_pixvoxels(); @@ -63,5 +70,8 @@ class RawImageLoader int lvl = 0, // Pyramid level lyr = 0; // Layer + size_t cur_channel = 0, // Currently selected channel (C) plane + cur_timeframe = 0; // Currently selected timeframe (T) plane + }; diff --git a/src/nyx/raw_nifti.h b/src/nyx/raw_nifti.h index ad85db9e2..e70026a11 100644 --- a/src/nyx/raw_nifti.h +++ b/src/nyx/raw_nifti.h @@ -101,6 +101,8 @@ class RawNiftiLoader : public RawFormatLoader size_t indexRowGlobalTile, size_t indexColGlobalTile, size_t indexLayerGlobalTile, + [[maybe_unused]] size_t indexChannel, // NIfTI: single channel + [[maybe_unused]] size_t indexTimeframe, // whole 4D volume held in memory; T sliced by consumer [[maybe_unused]] size_t level) override {} // NIFTI is not tiled @@ -223,6 +225,8 @@ class NiftiLoader : public AbstractTileLoader size_t indexRowGlobalTile, size_t indexColGlobalTile, size_t indexLayerGlobalTile, + [[maybe_unused]] size_t indexChannel, // NIfTI: single channel + [[maybe_unused]] size_t indexTimeframe, // whole 4D volume delivered at once; T sliced by consumer [[maybe_unused]] size_t level) override { tile->resize (tile_width_ * tile_height_ * tile_depth_ * numTimeFrames_); diff --git a/src/nyx/raw_omezarr.h b/src/nyx/raw_omezarr.h index 687814f90..d1f2af085 100644 --- a/src/nyx/raw_omezarr.h +++ b/src/nyx/raw_omezarr.h @@ -3,7 +3,10 @@ #ifdef OMEZARR_SUPPORT #include +#include +#include #include "nlohmann/json.hpp" +#include "ome/ome_zarr_meta.h" // parse_ome_zarr -> OmeAxes (axis-role resolution) // factory functions to create files, groups and datasets #include "z5/factory.hxx" @@ -32,20 +35,59 @@ class RawOmezarrLoader: public RawFormatLoader nlohmann::json file_attributes, ds_attributes; z5::readAttributes(*zarr_ptr_, file_attributes); - // assume only one dataset is present + // open the highest-resolution dataset (level 0) ds_name_ = file_attributes["multiscales"][0]["datasets"][0]["path"].get(); const auto ds_handle = z5::filesystem::handle::Dataset(*zarr_ptr_, ds_name_); fs::path metadata_path; auto success = z5::filesystem::metadata_detail::getMetadataPath(ds_handle, metadata_path); z5::filesystem::metadata_detail::readMetadata(metadata_path, ds_attributes); - full_depth_ = ds_attributes["shape"][2].get(); - full_height_ = ds_attributes["shape"][3].get(); - full_width_ = ds_attributes["shape"][4].get(); - tile_depth_ = ds_attributes["chunks"][2].get(); - tile_height_ = ds_attributes["chunks"][3].get(); - tile_width_ = ds_attributes["chunks"][4].get(); + std::vector level0Shape, chunkShape; + for (const auto& d : ds_attributes["shape"]) level0Shape.push_back(d.get()); + for (const auto& d : ds_attributes["chunks"]) chunkShape.push_back(d.get()); std::string dtype_str = ds_attributes["dtype"].get(); + + // Resolve axis roles from the NGFF 'axes' metadata instead of assuming a + // fixed [T,C,Z,Y,X] order. Falls back to legacy 5D TCZYX if 'axes' is absent. + Nyxus::OmeAxes axes = Nyxus::parse_ome_zarr(file_attributes, level0Shape, dtype_str); + if (axes.valid) + { + // Reject self-inconsistent metadata rather than guess: if the 'axes' + // count disagrees with the array rank, indexing the shape by axis role + // would read out of bounds. + if (axes.storageAxes.size() != level0Shape.size()) + throw std::runtime_error("OME-Zarr: 'axes' count " + std::to_string(axes.storageAxes.size()) + + " does not match array rank " + std::to_string(level0Shape.size())); + ndim_ = axes.storageAxes.size(); + ix_ = axes.storageIndexOf('X'); iy_ = axes.storageIndexOf('Y'); + iz_ = axes.storageIndexOf('Z'); ic_ = axes.storageIndexOf('C'); + it_ = axes.storageIndexOf('T'); + n_levels_ = axes.numberPyramidLevels(); + } + else + { + // No usable 'axes': map by position (X,Y last; Z,C,T before) — rank-safe. + ndim_ = level0Shape.size(); + int n = (int)ndim_; + ix_ = n - 1; iy_ = n - 2; + iz_ = (n >= 3) ? n - 3 : -1; + ic_ = (n >= 4) ? n - 4 : -1; + it_ = (n >= 5) ? n - 5 : -1; + n_levels_ = 1; + } + // X and Y must resolve to real dimensions, else the read would index OOB. + if (ix_ < 0 || iy_ < 0 || (size_t)ix_ >= level0Shape.size() || (size_t)iy_ >= level0Shape.size()) + throw std::runtime_error("OME-Zarr: cannot resolve X/Y axes from metadata"); + + full_width_ = level0Shape[ix_]; + full_height_ = level0Shape[iy_]; + full_depth_ = (iz_ >= 0) ? level0Shape[iz_] : 1; + tile_width_ = chunkShape[ix_]; + tile_height_ = chunkShape[iy_]; + tile_depth_ = (iz_ >= 0) ? chunkShape[iz_] : 1; + bits_per_sample_ = Nyxus::bits_of(Nyxus::pixel_type_from_zarr_dtype(dtype_str)); + + // dtype -> internal format code for the read-template dispatch if (dtype_str == "(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 2: - loadTile(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 3: - loadTile(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 4: - loadTile(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 5: - loadTile(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 6: - loadTile(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 7: - loadTile(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 8: - loadTile(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 9: - loadTile(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; case 10: - loadTile(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; default: - loadTile(pixel_row_index, pixel_col_index, pixel_layer_index); + loadTile(pixel_row_index, pixel_col_index, pixel_layer_index, indexChannel, indexTimeframe); break; } } @@ -139,7 +183,8 @@ class RawOmezarrLoader: public RawFormatLoader } template - void loadTile(size_t pixel_row_index, size_t pixel_col_index, size_t pixel_layer_index) + void loadTile(size_t pixel_row_index, size_t pixel_col_index, size_t pixel_layer_index, + size_t pixel_channel_index, size_t pixel_timeframe_index) { size_t data_height = tile_height_, data_width = tile_width_; if (pixel_row_index + data_height > full_height_) { @@ -151,11 +196,16 @@ class RawOmezarrLoader: public RawFormatLoader // Create a buffer to hold the read data std::vector buffer(data_height * data_width); - - // Create an ArrayView into the buffer (z5 3.0.1 uses ArrayView instead of xtensor) - z5::types::ShapeType shape = {1, 1, 1, data_height, data_width}; + + // Build the read window by axis ROLE (honoring the resolved 'axes' order), + // reading one Y*X plane at the requested Z/C/T. z5 3.0.1 uses ArrayView. + z5::types::ShapeType shape(ndim_, 1), offset(ndim_, 0); + shape[iy_] = data_height; offset[iy_] = pixel_row_index; + shape[ix_] = data_width; offset[ix_] = pixel_col_index; + if (iz_ >= 0) offset[iz_] = pixel_layer_index; + if (ic_ >= 0) offset[ic_] = pixel_channel_index; + if (it_ >= 0) offset[it_] = pixel_timeframe_index; auto view = z5::multiarray::makeView(buffer.data(), shape); - z5::types::ShapeType offset = {0, 0, pixel_layer_index, pixel_row_index, pixel_col_index}; // Read subarray from the cached z5 dataset z5::multiarray::readSubarray(*ds_, view, offset.begin()); @@ -197,12 +247,10 @@ class RawOmezarrLoader: public RawFormatLoader /// @return Tile depth [[nodiscard]] size_t tileDepth([[maybe_unused]] size_t level) const override { return tile_depth_; } - /// @brief Tiff bits per sample - /// @return Size of a sample in bits - [[nodiscard]] short bitsPerSample() const override { return 1; } - /// @brief Level accessor - /// @return 1 - [[nodiscard]] size_t numberPyramidLevels() const override { return 1; } + /// @brief Bits per sample (resolved from the dataset dtype) + [[nodiscard]] short bitsPerSample() const override { return bits_per_sample_; } + /// @brief Number of resolution (pyramid) levels declared in multiscales + [[nodiscard]] size_t numberPyramidLevels() const override { return n_levels_; } private: @@ -214,6 +262,12 @@ class RawOmezarrLoader: public RawFormatLoader tile_height_ = 0, ///< Tile height tile_depth_ = 0; ///< Tile depth + // Storage-dimension index of each axis role (-1 if the axis is absent). + int ix_ = 4, iy_ = 3, iz_ = 2, ic_ = 1, it_ = 0; + size_t ndim_ = 5; ///< Number of on-disk dimensions (2..5) + short bits_per_sample_ = 16; ///< Real bit depth + size_t n_levels_ = 1; ///< Pyramid level count + short data_format_ = 0; std::unique_ptr zarr_ptr_; std::string ds_name_; diff --git a/src/nyx/raw_tiff.h b/src/nyx/raw_tiff.h index cbef867f3..01e52d520 100644 --- a/src/nyx/raw_tiff.h +++ b/src/nyx/raw_tiff.h @@ -6,12 +6,12 @@ #include #undef uint64 #undef int64 -#else - #include -#endif -#include -#include -#include +#else + #include +#endif +#include +#include +#include #pragma once #include @@ -21,6 +21,7 @@ #include #include "raw_format.h" +#include "ome/ome_tiff_meta.h" // parse_ome_xml -> OmeAxes (OME-TIFF plane->IFD mapping) class RawTiffTileLoader : public RawFormatLoader { @@ -168,6 +169,8 @@ class RawTiffTileLoader : public RawFormatLoader size_t indexRowGlobalTile, size_t indexColGlobalTile, size_t indexLayerGlobalTile, + [[maybe_unused]] size_t indexChannel, // plain 2D TIFF: single channel + [[maybe_unused]] size_t indexTimeframe, // plain 2D TIFF: no time series size_t level) override { // Low level read TIFF bytes @@ -320,6 +323,23 @@ class RawTiffStripLoader : public RawFormatLoader fullDepth_ = TIFFNumberOfDirectories(tiff_); + // OME-TIFF: the IFDs are a (z,c,t) rasterization, not a plain Z-stack. + // Parse the OME-XML (IFD-0 ImageDescription) so (z,c,t) map to the right + // IFD and fullDepth reflects SizeZ, not the total page count. + { + char* desc = nullptr; + if (TIFFGetField(tiff_, TIFFTAG_IMAGEDESCRIPTION, &desc) == 1 && desc + && std::string(desc).find("(buf); + auto* fub = static_cast(buf); for (size_t r = 0; r < tileHeight_; r++) { size_t offs = r * scanline_szb; @@ -561,6 +587,9 @@ class RawTiffStripLoader : public RawFormatLoader sampleFormat_ = 0, ///< Sample format as defined by libtiff bitsPerSample_ = 0; ///< Bit Per Sample as defined by libtiff + bool is_ome_ = false; ///< true when IFD-0 carries an OME-XML block + Nyxus::OmeAxes ome_; ///< parsed OME dimensions (drives the plane->IFD map) + double minval, maxval; // low level buffer diff --git a/tests/data/ometiff/bad_corrupt.tif b/tests/data/ometiff/bad_corrupt.tif new file mode 100644 index 0000000000000000000000000000000000000000..745c85794995c3e0dcb589ba8863a340b44aefa9 GIT binary patch literal 39 scmXTP$ShU>qLkFM%)HE!)SOC%y!;Y{M1>GfH#dc}%$!s%21X`k02Ss84FCWD literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/bad_rgb.ome.tif b/tests/data/ometiff/bad_rgb.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..5f26d09af99bdcad2002bd362eb9f900b958c9ec GIT binary patch literal 1039 zcmd6lO^=g67{{U2%^r-#gPN=!$efG`FGC}t1lU9CCL~?AN!z5mw zfqo`W9zFUo{0Ke+Y*LSV6Ee(?ng9QJo_UF)56ZcsC@&O6eWIv9@=xvg40xapl_B&$ zpbdegMz*t_2f)wOf$|ODD@?rXy?c87c5OvmQkZTcC!EU>t7!7OuMI5%?chMY*X+4zP)ETb@;V}hQ&Ahq|H!ETy+WH=yTzj;`EOKfb>I7-4RWuDy zQ*p0pSmHVE7?1Ec)m`kSI>ss1$0M8Q8)x%z!ppzS**_Q1Xj)76= zM>*M2O$KhsgZ=(Ya@zk|pqR4*&FWUtq0~%SPPsgzn<}9YeSJH*TOw_FEWz3^E?lH7 z*cZAEP*1l3f~}WhlK_o6x`Pw$XxUQ41+6kl?r8!AR}F>qlO5rlW^MkSJxi9bAbFY5 zdQPjhEEzQPXKcF@b0T(T=$pMf^p|k^r$i7fx`Ks*cb*YWCq=`B%qz9s(g50RfF5p4 GsQd+d%-!Vx literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim2_yx.ome.tif b/tests/data/ometiff/dim2_yx.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..c5b706af7305b11800a3004b0f5895d9ea876939 GIT binary patch literal 957 zcmb7?*>2N76o!AN3wvpSw$QSeVJii1LA6pVPLUESMQUlJCQ{lfTxXJu6ptg@At@5C z#U+A_$kYH1;=V{{NggGj29_aUH-4pe#ZmN@q&J%fuyRA&ajPFBF{Wmgjgy zDdBqw_(%(Dh4(jU*5@=Eb2&E){z959Whu-5gLu2(pTs+JUW@YIrr0kU>66xxG1uS9-%eY3p-M~%UqFi^V)RP8M|zlBxA=grqih&j=2nkABC|WjxY5v8W^YkkOy9DkijUMmaUmK%bm_F z7XP`YA2LVTG%cfhe3UyiA>+i8J||Z!oH$E&6#8D0?eEIP)hj!UFyQ(u?lHB~X*OIo ziKH``h@N9tD*e4Z&b5kF>1&qNx3qn~Y-tzv#omRzU-o+YWU5EpOTx%yb9bti&9bfA zy2WbMX5bCDnkLT3q4WOi1-zJ=q-cp5AH|s@$5hi00hj3=okTrO*}G@;^EOl4mt(R{ zgiI_t=Tq^GcU@M=>C9zCtWFo3$Iky!JEdB+KJ=vIqpVuNj+qub^@8z;$1NUZo5!QK_H z^8x$>zVxM^!ae33PG2|!12?lXzh`fEZ=b#YI-PM00)jyaKoQbTip~~clVa}nhX|W> z*5WJ%4pvNriLYHORL|9t!VEE1L(OoVT@oLm1a{Z|AsngmPvKZYZj#g=Cca-{wI-;r zm6a6u+%|`rn_^c}?P^+nmAv}v(RwtR-|O*syru30U%4Sp!GxIr0Dr#%vtSVJ_)pFpv2xU?GdhWHDJRVJS|sS;lf!u##1*W({ju$9gufkxgu73pwPH zM?PEGhKmBWQ%DhRJQP!cmmTb+lwIs*4?gy?k23u1=Ku#eL^+44ppq)8siBr59How9 z9OnclImKz}Im1~RXyhE{xxhs(ahWSLag}Ra=LXHRaFbiy<_>qc$9*2qN*fP(#ABZD zlxIBW1uuEUYu@mdcf1#EbkNBMKJtmreBmqK=+geGu7$qn?vK`7z0{i3+@rN9VWZY! h;3D6owxsY1m5(w1t*Mh^-WCNp5N!V>>NQ1BRqAjRQ&8qS)8Az?O_9*GZw2 zt?c{0Q}(UwQ1-$neh0sTj|^9`I^%u}6Et&2ua4gLEccvy^Z6|t53GjB01=7&uSkdo zCGLy#2lh1*_lMZ$4ff#Gkv{hI!R`TA8*2ZOoOL~%^*uf}gm_+ZHbzzj{trvMDa1!4 z-rS=#r2mtp?N7PpWgI_#@A9llrDD}Zthprr^ga>#=x2ac404>T?F3HbB=ZuW5x`?;6%koJje6Az>B=Z%e=y?yvFOi!JE9r+Z^N_-sL^s=L0_EBR=L6 zKIJnG@i|}cC13G1-|#Kp@jZw6fgkyapZSGf`Hds|&L4i)!e0NExjH&a+hwztGfRKV z896#j|47_BON0H#&W=AvE0g8H70mL`|CXgZ5G%Hq{z>!quXm+K78`YCk7&7;V`o$& zrmKpuOHSFcs~L5=I2KK+BiZ!i#7;#LZ8xLVJg=G3v`(iJbDF|#Sf!TZmYn8N%xP7% zsZvceOjnZvEv_4hsGd-}%G9E39i#4S4HR)*H?*0F@vbsz*{)~WB_UO=m2v}j<4(!+ zg8Y^pTv@h4a~dMH=$2JwdOAOvQQIv$)oxqmR3e!adU8mQCXHkzY8VwGYL?^4=t5#) z+d^VEZkC6oDAy9E=d?0v&n>6K^>`wdh#6`&oo|>`q4t!m9Hm|jiGrnQisO&8^TK5BZV gk{^?0No}ES+9Kz)ZO;$qbGwB|5EL!naz3*B3$xiO=>Px# literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim4_czyx.ome.tif b/tests/data/ometiff/dim4_czyx.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..21f5b4fc2d9daf71fd5b2fe8e0f197448bb6a072 GIT binary patch literal 3976 zcmcIncXU%#9R9vv2Yc@w9(!aKLK>R3(t=bARN87m!KUeJ8*P%Bq_oAsP(Zmna zs8HmIF`TZjFeTdFQQ?#rj>M(M;r0}yrvMAv0-a*qeJZ9~Tuk>k%^opau3~yKEvmms z;XX0ERpH@rT&vPo(%7M{@-*d7-Q%yYx2mejTdSMun$$n>KSGd#RHPvtE!5arp*7kl zt#)d}9ncY-&>3CO72VJsJMZw7yZy517N^FXc&aS7=ob~hT#~2kr;*1Fv0{g zEU?0c42;28*f9NB0Gf;*Zn29rS7G_~K&PF-hsDKBR&{2hI)WC~#a4u@$gC7CZ;XDKpLKyX!g9bFB z33G8iF2IGj2=g!>3$PFu;}R^wVl2U>xC~3N442~yT#2i&99LrnuEDjq4%g!b+=!J} zg`03QR$~p;VjXV5t+)-h;||=3yKpz|!M(T->v2CGz=L=Q8?X@%;}JZHO?V8C;|V;8 zr?44YuoX{Z8=k?lcn;5FyPCTf@FHHq%h-uscm=QGHN1{D@Fw2E+t`ggcn9y|J-m+( zuooZVBYcc~*pCDF1fSwFe2y>hCBDKze2s7LExyC|_yLD-7(e1C{EQ>`1xN8Ke#7th z1ApQejz<Ew83(Zy`$FqbEB0`r*9 ziJZifIhh4Kg{QKRQ#h5=Sj5vfoy9C+DNpAaEaME$v;oj+JGqOm z@KwIX*ZBtDakGYTgd4QkrQ+~$J`31k^S3JnC`3=A2 zcl@3|@DLC4NB+d0d4#|4D1YT|{GEUBPaflOwfQL`DUvE_k}fTzrL>aP(ni`!J83T+ zq@#3_&eBD?N;l~)J*21flHSrs`bt0PF9XCN14WZTGFXPlP#Gq}WrU2BQ8HSLViL1h z#40w)kTEh=>@rRqk||CZFInP}Y{`*aQB^Be>;6w&{kxXts$zE{we*Opk$>0HV+zOD z(sWhS<7;OGC&uNv1iDTW|vvL}J zwT3x*FysyRU7FQo(G0rZ6R7n1t6f@Y$t0sg%gN4~Hf5qg1^PoSttK3U6n3s&k%y==zJyb{e& z(p0CY^dEry+z zNQcF4UtO&pD$;{d6V1_u-s+lgkvm*t&dxH&!v9Odng9*k32Bt5@6ddA*lj47q|&R# fP*m&o>v@5Ce^}YKX%W-S8C4*feRWq0H5-2c33)w0 literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim4_tzyx.ome.tif b/tests/data/ometiff/dim4_tzyx.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..44a165380c7289910c786b6465ff5b54917da4f2 GIT binary patch literal 2791 zcmcImXLJ)s7#xo&sRWV$2_!_hG(u1_SO~=u#l~Q;vB5Ifrui(L>;vf}R9xr~dhfmW z-g{3%NN=Qf(tGbcD_MC_@{c@Q@7=rE)y%iEyEnI&mDL890cZpy)kBg%(N3Dl-D2uH%cN#6BsJQP2$>%g3~mC(=4H8^EfUMoEAy-WAzUU+%k?&3*0(E zYZmpd5`E{zbun4^6Zga`ta>~i)vFi+)#9JTUlNF9)I)tVKtnON%W(y+6t%7vGrk6m z(FE7xI$V#YXolvv0XL!rTH+?$j8?b>I@}5gx8Zi&fje;*TH|iC!98dTJq$3y1T#|5 z4(*W&3p&7xje&48TAP!e9(R31pPQ1veCUP=<1-7>Z%=!Uqk01TY*ygb+pq6{tiNsxbm1F$$wG z24gV}<1qpEVIn4BGNxcEreQi}U?yf^Hs)Y1=3zb-U?CP^F_vH{mSH(oU?o;zHP&D) z)?qz1U?VnRGqzwWwqZMVU?+BAH}+sJ_F+E`;2;j+Fpl6Tj^Q{?;3Q7rG|u2Gy09zL znZa(%WOw#p7JIT6vzf!*%w-AucljRQ=Lh_dAMs;;!cX}bKj#b6;|*3u5{c5c1ZZ1w2PW{+84>Pb!2ms(0wOD!E!WOqj)N)IY>*dMe@ z312_D6jO@DU@@2_M_QIoE>m>T!ZBnM>)l)SDeLc3(GK}B{c-j^;mwx+MxF14>gPP>#M21_rO zHBIrxMvG&cDLP=j>5RzTyAuv8RK+Enp1q|WyoP!mz61| eCA4{7SyR&e5iJ}w6Xn)SM1a_&#a%of4*Uh-TChX_ literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim5.ome.tif b/tests/data/ometiff/dim5.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..07dc14592af1100448c1d2dab93f1e3dcd597087 GIT binary patch literal 7120 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 literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim5_cztyx.ome.tif b/tests/data/ometiff/dim5_cztyx.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..3abb1be40e3781977c8b425c69e5339dc55a324d GIT binary patch literal 7120 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 literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim5_tzcyx.ome.tif b/tests/data/ometiff/dim5_tzcyx.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..2bce4e948148e01d4b42f9c91250b97f9c3f9f33 GIT binary patch literal 7120 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 literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim5_zctyx.ome.tif b/tests/data/ometiff/dim5_zctyx.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..636f669f18eb248aa2199420741c0a1e7c12c36c GIT binary patch literal 7120 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 literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim5_ztcyx.ome.tif b/tests/data/ometiff/dim5_ztcyx.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..d87038fecebebaf000213067e60767efb7cc5837 GIT binary patch literal 7120 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 literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/gen_ome_tiff.py b/tests/data/ometiff/gen_ome_tiff.py new file mode 100644 index 000000000..6ed768c66 --- /dev/null +++ b/tests/data/ometiff/gen_ome_tiff.py @@ -0,0 +1,98 @@ +"""Generate OME-TIFF fixtures whose every voxel encodes its own (x,y,z,c,t) +coordinate, so a reader that maps (z,c,t) to the wrong IFD (page) reads a +provably wrong value. + + value(x,y,z,c,t) = 1 + ((((t*C + c)*Z + z)*Y + y)*X + x) # C=3,Z=4,Y=6,X=8 + +Stores (same encoding throughout; absent axes pinned to index 0): + + dim5.ome.tif axes TCZYX -> DimensionOrder XYZCT (default: Z fastest) + dim5_ctzyx.ome.tif axes CTZYX -> DimensionOrder XYZTC (non-default: T before C) + -> proves the plane->IFD map honors DimensionOrder + dim3_zyx.ome.tif axes ZYX 3D + dim2_yx.ome.tif axes YX 2D + dim3_plain.tif plain multi-page TIFF (NO OME-XML) -> legacy page=Z fallback + +tifffile writes OME-TIFF as multi-page (strip) with one IFD per (z,c,t) plane in +DimensionOrder raster order, so the loader's ifdForPlane() must reproduce that. + +Run with the nyxus_build env python (tifffile): + C:\\Users\\dvladi\\miniforge3\\envs\\nyxus_build\\python.exe gen_ome_tiff.py +""" +import os +import numpy as np +import tifffile + +T, C, Z, Y, X = 2, 3, 4, 6, 8 +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def encoded_tczyx(): + t, c, z, y, x = np.meshgrid( + np.arange(T), np.arange(C), np.arange(Z), np.arange(Y), np.arange(X), indexing="ij") + return (1 + ((((t * C + c) * Z + z) * Y + y) * X + x)).astype("uint16") + + +def _data_for(order): + base = encoded_tczyx() # [t,c,z,y,x] + sel = tuple(slice(None) if a in order else 0 for a in "TCZYX") + sub = base[sel] + remaining = [a for a in "TCZYX" if a in order] + perm = tuple(remaining.index(a) for a in order) + return np.transpose(sub, perm).copy() + + +def write_ome(name, order): + path = os.path.join(HERE, name) + data = _data_for(order) + tifffile.imwrite(path, data, photometric="minisblack", metadata={"axes": order}, ome=True) + with tifffile.TiffFile(path) as tf: + import re + dimord = re.search(r'DimensionOrder="([A-Z]+)"', tf.ome_metadata).group(1) + print("wrote %-20s axes=%-6s DimensionOrder=%s IFDs=%d tiled=%s" + % (name, order, dimord, len(tf.pages), tf.pages[0].is_tiled)) + + +def write_plain(name, order): + """Plain multi-page TIFF, no OME-XML (tests the non-OME fallback).""" + path = os.path.join(HERE, name) + data = _data_for(order) # e.g. ZYX -> a plain z-stack + tifffile.imwrite(path, data, photometric="minisblack") + with tifffile.TiffFile(path) as tf: + print("wrote %-20s axes=%-6s (plain, no OME) IFDs=%d" % (name, order, len(tf.pages))) + + +def write_bad_rgb(name): + """RGB OME-TIFF -- nyxus is grayscale-only, so the loader must reject it.""" + path = os.path.join(HERE, name) + data = np.zeros((Y, X, 3), "uint8") + tifffile.imwrite(path, data, photometric="rgb", metadata={"axes": "YXS"}, ome=True) + print("wrote %-20s (RGB -> not grayscale)" % name) + + +def write_bad_corrupt(name): + """Not a TIFF at all -- TIFFOpen must fail cleanly.""" + path = os.path.join(HERE, name) + with open(path, "wb") as f: + f.write(b"this is definitely not a TIFF file\n\x00\x01\x02\x03") + print("wrote %-20s (corrupt -> open fails)" % name) + + +def main(): + # All 6 orderings of {T,C,Z} before Y,X -> the 6 legal OME DimensionOrder values. + for o in ["TCZYX", "TZCYX", "CTZYX", "CZTYX", "ZTCYX", "ZCTYX"]: + write_ome("dim5.ome.tif" if o == "TCZYX" else "dim5_%s.ome.tif" % o.lower(), o) + # 4D (rank-4 coverage): one time-only, one channel-only. + write_ome("dim4_tzyx.ome.tif", "TZYX") + write_ome("dim4_czyx.ome.tif", "CZYX") + # lower rank + non-OME fallback + write_ome("dim3_zyx.ome.tif", "ZYX") + write_ome("dim2_yx.ome.tif", "YX") + write_plain("dim3_plain.tif", "ZYX") + # --- illegal / adversarial: must be rejected cleanly, not crash --- + write_bad_rgb("bad_rgb.ome.tif") + write_bad_corrupt("bad_corrupt.tif") + + +if __name__ == "__main__": + main() diff --git a/tests/data/omezarr/README.md b/tests/data/omezarr/README.md index c3bcac8de..fe0ae39e2 100644 --- a/tests/data/omezarr/README.md +++ b/tests/data/omezarr/README.md @@ -17,10 +17,30 @@ and `chunks[2..4]` as the tile sizes. |------------------|-----------:|--------|-----------|-----------|------------------------| | `test.ome.zarr` | 512x512 | uint16 | 1024x1024 | 1x1 | `(row + col) % 65536` | | `multi.ome.zarr` | 1500x1200 | uint16 | 1024x1024 | 2x2* | `(row*7 + col*3) % 65536` | +| `dim5.ome.zarr` | 6x8 | uint16 | 2x3x1x6x8 | 1x1 | 5D coordinate encoding (below) | \* `multi.ome.zarr` has partial edge tiles (1500 = 1024 + 476, 1200 = 1024 + 176), so it also exercises the loaders' partial-tile clipping path. +### `dim5.ome.zarr` — 5D channel/timeframe addressability + +A genuinely 5D store, shape `(T=2, C=3, Z=4, Y=6, X=8)`, chunked `(T,C,1,Y,X)` — +one chunk per z-slice (4 chunk files) — and written **uncompressed** (so it reads +with header-only z5). Z gets its own chunk because the loader maps the layer index +as `layer*tileDepth` (`tileDepth = chunks[2]`); C and T are addressed directly by +the read offset, so they don't need separate chunks. Every voxel encodes its own +coordinate: + +``` +value(x,y,z,c,t) = 1 + ((((t*C + c)*Z + z)*Y + y)*X + x) # C=3, Z=4, Y=6, X=8 +``` + +`test_omezarr_5d_channel_time_addressing` / `test_raw_omezarr_5d_channel_time_addressing` +read plane `(z,c,t)` via `loadTileFromFile(..., layer=z, channel=c, timeframe=t, ...)` +and assert every value matches the encoding — so a loader that ignored C/T (offset +pinned to `{0,0,...}`) returns the wrong plane and fails. Generated by `gen_dim5.py` +(zarr 3.x). Regenerate: `python gen_dim5.py`. + Deterministic checksums asserted by the tests: - `test.ome.zarr` sum of all pixels = `133955584` diff --git a/tests/data/omezarr/bad_axes_count.ome.zarr/.zattrs b/tests/data/omezarr/bad_axes_count.ome.zarr/.zattrs new file mode 100644 index 000000000..0a8629e58 --- /dev/null +++ b/tests/data/omezarr/bad_axes_count.ome.zarr/.zattrs @@ -0,0 +1,46 @@ +{ + "multiscales": [ + { + "version": "0.4", + "axes": [ + { + "name": "t", + "type": "time" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/bad_axes_count.ome.zarr/.zgroup b/tests/data/omezarr/bad_axes_count.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/bad_axes_count.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/bad_axes_count.ome.zarr/0/.zarray b/tests/data/omezarr/bad_axes_count.ome.zarr/0/.zarray new file mode 100644 index 000000000..9226eae5a --- /dev/null +++ b/tests/data/omezarr/bad_axes_count.ome.zarr/0/.zarray @@ -0,0 +1,19 @@ +{ + "shape": [ + 4, + 6, + 8 + ], + "chunks": [ + 1, + 6, + 8 + ], + "dtype": "006+#P-%#U7KwI||G%!gKu_PmP-tXqA~H2Iw-8%Oq*m58wlX_=2S+El W!ddB}QoFjjdw6006+#P-%#U7KwI||G%!gKu_PmP-tXqA~H2Iw-8%Oq*m58wlX_=2S+El W!ddB}QoFjjdw6006-Il7>n{G_**xi~Ro;-35C328Kc-V-u07nYo47QX;jowy~Aj**iEo Y$ra8@7nR!8&E3P(%iG6SqxIAI15%z1nE(I) literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim3_zyx.ome.zarr/0/2.0.0 b/tests/data/omezarr/dim3_zyx.ome.zarr/0/2.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..fa41e215f5a6b8ffad768a34fb5432b2e61d6597 GIT binary patch literal 96 zcmV~$2LeI>006-Is)kBKG_**xi#)lzKu_PmP-tXqA~H2Iw-8%Oq*m58wlX_=2S+El W!ddB}QoFjjdw6>V7P Y( TyFK>WXTJmfaL^%#9dXoq{!vN! literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim4_czyx.ome.zarr/0/0.1.0.0 b/tests/data/omezarr/dim4_czyx.ome.zarr/0/0.1.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..eb67a2e44edbace477d7a4c3371dbadf74a27c6f GIT binary patch literal 288 zcmV~$2O38j=#P6d@zQB4gWsHKjN)YHHxKJ$e}nrNnlR@!K%gHF2W=9m*s zIpdrQF1g|#*W7T+9rrwd`pCxy7-*2eJ~6~lpBiSk5k?wiv@t$2);QyR?h6x4G|6OB zO!cK{_W0Frez(^?`~Bg7gAO_Dh@*}S0 S@{il@xa*$#9(d@HfBg@a<3t(& literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim4_czyx.ome.zarr/0/0.2.0.0 b/tests/data/omezarr/dim4_czyx.ome.zarr/0/0.2.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..05ccef7af9f94a72b42ca3f3aa190282777def80 GIT binary patch literal 288 zcmV~$140A<6h+Z@e{RwivTfVkLbh!;xwe>XyK@@Vq-M3KRc&flhdR}zZuO{Fed^bM z1~sH%jc8P38rOs-HKl3IXjXHY*Mb(cq-CvWRcmr;T^riembSH{UF~UKfdmmu2%&@# zP6Uxe5lsxS#1T&di6oIs3aO-#P6nA|kxdS{X?_+;Yb~4?Ob3GcUaI#ycN;^2Ikl{PKqkGjlOlb2E4IFi-O` PZ*!QB`I?{kTY&uoPM<$I literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim4_czyx.ome.zarr/0/0.3.0.0 b/tests/data/omezarr/dim4_czyx.ome.zarr/0/0.3.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..c2d16b33836237ae18b5579f30c4fad1a1990f78 GIT binary patch literal 288 zcmV~$g+T%U00hAQ-@wMeL@}{DSHLdpZUqC(6->nLz;4AH&AVl+U=?dv#|GTkga=#L z#twF|hkYF25Jx!12~Kf_b6nsOUR>cCH@L+e?(u*}JmDEHc*Psu@c|z`@r7^v;1_?e zvXWJ-W({ju$9gufkxgu-jV)|t8{65zPIj@IJ?v#4`#Hct4snXs- z7?Vsf%?xo8%reJ33oNq4GApdI#yT4$Ns%VQCR=Q?!!CR5bHE`-9-H;VoTr|7Zr%$E x7A<+{m1Qedz4pdiYudrMNvseDQVEwr@A*lA3^#VV2~k(8DW$VV}u!J zf=Q;BCc+F+V#Jwcj(HYXWQhdJtgy-&>uj*e7TfHQWS2emIpB~KX^uQLdrMNvseDQVEwC%HF3A3^#VV2~k(8DW$VV}u!J zf=Q;BCc+F+V#Jwcj(HYXWQhdJtgy-&>uj*e7TfHQWS2emIpB~KX^uQL(06@|EUGM)RZD|Qj?V&}lkQ7BFA*G~2Th8etNIwG%GQ=<=j1ppuFyl-x z$rRH>m?27xIJ3+#&jO1qkzkn>R#{`64K~?gn;nwuvd2CL9Fii<5yxgcG3%*k=FEHU zg$0XVdgZkxZ!CLj#i}*$tb6Z+4Ih27>9Z|geD%$D+jji$(=WgM@z+1Q{!5?-zj#WL literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5.ome.zarr/.zattrs b/tests/data/omezarr/dim5.ome.zarr/.zattrs new file mode 100644 index 000000000..8f363a6b0 --- /dev/null +++ b/tests/data/omezarr/dim5.ome.zarr/.zattrs @@ -0,0 +1,46 @@ +{ + "multiscales": [ + { + "version": "0.4", + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ], + "axes": [ + { + "name": "t", + "type": "time" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5.ome.zarr/.zgroup b/tests/data/omezarr/dim5.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim5.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5.ome.zarr/0/.zarray b/tests/data/omezarr/dim5.ome.zarr/0/.zarray new file mode 100644 index 000000000..7f0e4af6a --- /dev/null +++ b/tests/data/omezarr/dim5.ome.zarr/0/.zarray @@ -0,0 +1,23 @@ +{ + "shape": [ + 2, + 3, + 4, + 6, + 8 + ], + "chunks": [ + 2, + 3, + 1, + 6, + 8 + ], + "dtype": "{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_t& zMhQw$hH_M(5>=>14Qf$`dNiOBO=w07TG57fbf6Pm=td8E(T9EvU=TwX#t23+hH*?_ z5>uGQ3}!Khc`RTNmw1_1c$L?9oi})sw|JX(c$fEhpAYzukNB8R_>|9>&*yx>0>0!c zzUCXgWg*}3JwNaxKk+la@GHOZJAd#efAKf}@Gt*yi7mBdw%k_ON?T>CZH?txo~^ZY zw%#__M%!eYZHsNSZMNNZ*iPGJyKRr{wSBhV4%k6EWQXmD9kpY2+)mg@J7uTsjGeV} zcHS=7MWalTgB;}~XSv8#ZgQ80Jmn>C`N&s(@>hTY6{KK=C{$qzSA-%JrD(+{R&k0~ zf)bUaWThxoX-ZdyGL@xl<)}!-Dp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKPf|rK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5.ome.zarr/0/0.0.1.0.0 b/tests/data/omezarr/dim5.ome.zarr/0/0.0.1.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..678ed89e2b57253198aa620a5b4f198239f69bdc GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?wBaw>(Sc5Mp&LEuMIZVxfI$r5ABF*vM$s9-5e#GygE^9;IGQ00Wf;R5 z!7+?v6r&l#v5aLL<2jBAOk@(1nZmO?$Md|vi@e0kyuz!z#_PPno4m!_yu-V^$NPN1 zhkV4xe8Q)E#^-#&mwd(7e8abV$M^iekNm{X{KBvN#_#;WpZvw&{KL66&*s|#TWE`H zu`RKsw#=5>3R`KbY_+YiwYJXI+XmZcn{2afv8}evw%ZQdX}fHc2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(37+B^p5p~x;uT)w4c_7%-s1y4;uAjO3%=qT zzT*de;un775B}mGgdnLjuHZ_p;%ctpTCU@IZs104;%08)R&L{V?%+-?aFM&Xn|rvI z`?#M6c#wy9m`8Y&$9SA4c#@}hnrC>H=XjnMc#)U5#AROQ6<*~vUgr(oW~iWh>q%*j_ZU@>Xc6FjLzzu&Z~ntnv*%3i@BPcxtoW1nwNQ-kNKLP`CEVmT95@> uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFAnOcPU@^K>Z)$)t{&>CUh1tr>Z^Y0uK^mUK^m+f8meI$t`QokQ5vl=8mn;{uL+u{ zNt&!FnyP7dmnyY!5uM4mci?A3=uoTO%94oLAtFRhtuommE9viR`o3I&M zuoc^|9XqfSyRaL3uowHV9|v#{hj182a1_UI94BxRr*Il)a2Drq9v5&Cmv9+Za23~Z z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=#pYR!9@D<@E89eEMOsvSj-ZZvW(@dU?r9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxRJWSa literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_ctzyx.ome.zarr/.zattrs b/tests/data/omezarr/dim5_ctzyx.ome.zarr/.zattrs new file mode 100644 index 000000000..7a2798426 --- /dev/null +++ b/tests/data/omezarr/dim5_ctzyx.ome.zarr/.zattrs @@ -0,0 +1,46 @@ +{ + "multiscales": [ + { + "version": "0.4", + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ], + "axes": [ + { + "name": "c", + "type": "channel" + }, + { + "name": "t", + "type": "time" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_ctzyx.ome.zarr/.zgroup b/tests/data/omezarr/dim5_ctzyx.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim5_ctzyx.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_ctzyx.ome.zarr/0/.zarray b/tests/data/omezarr/dim5_ctzyx.ome.zarr/0/.zarray new file mode 100644 index 000000000..e9172f8f4 --- /dev/null +++ b/tests/data/omezarr/dim5_ctzyx.ome.zarr/0/.zarray @@ -0,0 +1,23 @@ +{ + "shape": [ + 3, + 2, + 4, + 6, + 8 + ], + "chunks": [ + 3, + 2, + 1, + 6, + 8 + ], + "dtype": "{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_=t zsV%eRw!&80DqC%9EZ6dEt*x{5w!t>qCfjUVY^!av?Y6^q+AiB|du*@mv;B6!4%#6* zY)9;<9kb(h!cN*LJ8fs|tevy-cEK)M5sFcQQk0<_6{tiNs!@Yl)S(^?Xhaj5(SlaA zp&cFQL>IczgI@HZ9|IV~5QZ^=QH)_66PUylrZIzA%wZl2SOjH~9ONh`Im<<^a+A9} zjQrl&LIbD@T`j znOAs~*La;bc$2qyn|FAZ_jsQV_>hnIm{0hW&zR5We8B>~B-|;;^@FPF* zGr#aFzwtYN@F#!qH~;W2|FKBLDp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKLPoPK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_ctzyx.ome.zarr/0/0.0.1.0.0 b/tests/data/omezarr/dim5_ctzyx.ome.zarr/0/0.0.1.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..fab096529cb6629e2f6aa5d0022264a56f9f9e1e GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?v|+Bzv-!5b7TO|PY)fpZEwkme!dBWUTWxD>t*x{5w!t>qCfjUVY^!av z?Y6^q+AiB|du*@mv;B6!4%#6*Y)9;<9kb(h!cN*LJ8fs|FWS+8PIRFgJ?KRr`Z0h( z4B;P!0g^`18Nd+?WDtWnlA}19Aq-_0!x_ObjARs}8N;!RWgO!@iy=9F7NR^AMha`@iCw9DWCB< zU+^Vg@ipJ@E#L7yKky?z@iV{hE5GqOfAA-N@i+hQuiDk2PIakUJ?d4T`Zb_I4e6hT z1*ec2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(@Fs8ZHt+B*@9{nz;0Px;!v(HzgF8In2`_lV2fpxwKLQYlAOs@> zp$J1bA`povL?Z^Vh(kOQkccECBL%5QLpm~$i7aFz2f4^YJ_@u@i?movv{cKqTr0Fv ztF&5cv{vi1UK_Mgo3vS5v{l=*T|2Z>yR=(-v{(DIUk7whhjds+bX3Q5Tqkr=r*v9p zbXMndULW8g9^o;b;3=NrIbPr;Ug0&~;4R+aJwD(gKH)RI;48l2JAU9Ne&IL%;4l6` z2$D+U3a;cTuI3u9 uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFBfnTmv9+Za23~Z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=# zpYR!9@D<@E89ebWlfiQfGBhS9Mc&^-xdsQg8K9U-eUe4bVUh(qIkI zPz}>?jnGJq(rAs*SdG(oP0&P5(qv81R87-#&CpEE(rnGqT+P#bEnp#wSj-ZZvW(@d zU?r;hy6H! zgE)l4ID(@%hT}MalQ@ObID@k|hx2eSM{_b~b1_$QGk5baPxCTw^D$rZGk*)PKnt>9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxJ4l++ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_cztyx.ome.zarr/.zattrs b/tests/data/omezarr/dim5_cztyx.ome.zarr/.zattrs new file mode 100644 index 000000000..9bedfbd37 --- /dev/null +++ b/tests/data/omezarr/dim5_cztyx.ome.zarr/.zattrs @@ -0,0 +1,46 @@ +{ + "multiscales": [ + { + "version": "0.4", + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ], + "axes": [ + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "t", + "type": "time" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_cztyx.ome.zarr/.zgroup b/tests/data/omezarr/dim5_cztyx.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim5_cztyx.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_cztyx.ome.zarr/0/.zarray b/tests/data/omezarr/dim5_cztyx.ome.zarr/0/.zarray new file mode 100644 index 000000000..e1f0f5a5d --- /dev/null +++ b/tests/data/omezarr/dim5_cztyx.ome.zarr/0/.zarray @@ -0,0 +1,23 @@ +{ + "shape": [ + 3, + 4, + 2, + 6, + 8 + ], + "chunks": [ + 3, + 1, + 2, + 6, + 8 + ], + "dtype": "{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_=t zsV%eRw!&80DqC%9EZ6dEt*x{5w!t>qCfjUVY^!av?Y6^q+AiB|du*@mv;B6!4%#6* zY)9;<9kb(h!cN*LJ8fs|tevy-cEK)M5sFcQQk0<_6{tiNs!@Yl)S(^?Xhaj5(SlaA zp&cFQL>IczgI@HZ9|IV~5QZ^=QH)_66PUylrZIzA%wZl2SOjH~9ONh`Im<<^a+A9} zjQrl&LIbD@T`j znOAs~*La;bc$2qyn|FAZ_jsQV_>hnIm{0hW&zR5We8B>~B-|;;^@FPF* zGr#aFzwtYN@F#!qH~;W2|FKBLDp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKLPoPK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_cztyx.ome.zarr/0/0.1.0.0.0 b/tests/data/omezarr/dim5_cztyx.ome.zarr/0/0.1.0.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..fab096529cb6629e2f6aa5d0022264a56f9f9e1e GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?v|+Bzv-!5b7TO|PY)fpZEwkme!dBWUTWxD>t*x{5w!t>qCfjUVY^!av z?Y6^q+AiB|du*@mv;B6!4%#6*Y)9;<9kb(h!cN*LJ8fs|FWS+8PIRFgJ?KRr`Z0h( z4B;P!0g^`18Nd+?WDtWnlA}19Aq-_0!x_ObjARs}8N;!RWgO!@iy=9F7NR^AMha`@iCw9DWCB< zU+^Vg@ipJ@E#L7yKky?z@iV{hE5GqOfAA-N@i+hQuiDk2PIakUJ?d4T`Zb_I4e6hT z1*ec2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(@Fs8ZHt+B*@9{nz;0Px;!v(HzgF8In2`_lV2fpxwKLQYlAOs@> zp$J1bA`povL?Z^Vh(kOQkccECBL%5QLpm~$i7aFz2f4^YJ_@u@i?movv{cKqTr0Fv ztF&5cv{vi1UK_Mgo3vS5v{l=*T|2Z>yR=(-v{(DIUk7whhjds+bX3Q5Tqkr=r*v9p zbXMndULW8g9^o;b;3=NrIbPr;Ug0&~;4R+aJwD(gKH)RI;48l2JAU9Ne&IL%;4l6` z2$D+U3a;cTuI3u9 uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFBfnTmv9+Za23~Z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=# zpYR!9@D<@E89ebWlfiQfGBhS9Mc&^-xdsQg8K9U-eUe4bVUh(qIkI zPz}>?jnGJq(rAs*SdG(oP0&P5(qv81R87-#&CpEE(rnGqT+P#bEnp#wSj-ZZvW(@d zU?r;hy6H! zgE)l4ID(@%hT}MalQ@ObID@k|hx2eSM{_b~b1_$QGk5baPxCTw^D$rZGk*)PKnt>9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxJ4l++ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_noaxes.ome.zarr/.zattrs b/tests/data/omezarr/dim5_noaxes.ome.zarr/.zattrs new file mode 100644 index 000000000..c1ebb88a3 --- /dev/null +++ b/tests/data/omezarr/dim5_noaxes.ome.zarr/.zattrs @@ -0,0 +1,24 @@ +{ + "multiscales": [ + { + "version": "0.4", + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_noaxes.ome.zarr/.zgroup b/tests/data/omezarr/dim5_noaxes.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim5_noaxes.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_noaxes.ome.zarr/0/.zarray b/tests/data/omezarr/dim5_noaxes.ome.zarr/0/.zarray new file mode 100644 index 000000000..7f0e4af6a --- /dev/null +++ b/tests/data/omezarr/dim5_noaxes.ome.zarr/0/.zarray @@ -0,0 +1,23 @@ +{ + "shape": [ + 2, + 3, + 4, + 6, + 8 + ], + "chunks": [ + 2, + 3, + 1, + 6, + 8 + ], + "dtype": "{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_t& zMhQw$hH_M(5>=>14Qf$`dNiOBO=w07TG57fbf6Pm=td8E(T9EvU=TwX#t23+hH*?_ z5>uGQ3}!Khc`RTNmw1_1c$L?9oi})sw|JX(c$fEhpAYzukNB8R_>|9>&*yx>0>0!c zzUCXgWg*}3JwNaxKk+la@GHOZJAd#efAKf}@Gt*yi7mBdw%k_ON?T>CZH?txo~^ZY zw%#__M%!eYZHsNSZMNNZ*iPGJyKRr{wSBhV4%k6EWQXmD9kpY2+)mg@J7uTsjGeV} zcHS=7MWalTgB;}~XSv8#ZgQ80Jmn>C`N&s(@>hTY6{KK=C{$qzSA-%JrD(+{R&k0~ zf)bUaWThxoX-ZdyGL@xl<)}!-Dp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKPf|rK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_noaxes.ome.zarr/0/0.0.1.0.0 b/tests/data/omezarr/dim5_noaxes.ome.zarr/0/0.0.1.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..678ed89e2b57253198aa620a5b4f198239f69bdc GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?wBaw>(Sc5Mp&LEuMIZVxfI$r5ABF*vM$s9-5e#GygE^9;IGQ00Wf;R5 z!7+?v6r&l#v5aLL<2jBAOk@(1nZmO?$Md|vi@e0kyuz!z#_PPno4m!_yu-V^$NPN1 zhkV4xe8Q)E#^-#&mwd(7e8abV$M^iekNm{X{KBvN#_#;WpZvw&{KL66&*s|#TWE`H zu`RKsw#=5>3R`KbY_+YiwYJXI+XmZcn{2afv8}evw%ZQdX}fHc2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(37+B^p5p~x;uT)w4c_7%-s1y4;uAjO3%=qT zzT*de;un775B}mGgdnLjuHZ_p;%ctpTCU@IZs104;%08)R&L{V?%+-?aFM&Xn|rvI z`?#M6c#wy9m`8Y&$9SA4c#@}hnrC>H=XjnMc#)U5#AROQ6<*~vUgr(oW~iWh>q%*j_ZU@>Xc6FjLzzu&Z~ntnv*%3i@BPcxtoW1nwNQ-kNKLP`CEVmT95@> uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFAnOcPU@^K>Z)$)t{&>CUh1tr>Z^Y0uK^mUK^m+f8meI$t`QokQ5vl=8mn;{uL+u{ zNt&!FnyP7dmnyY!5uM4mci?A3=uoTO%94oLAtFRhtuommE9viR`o3I&M zuoc^|9XqfSyRaL3uowHV9|v#{hj182a1_UI94BxRr*Il)a2Drq9v5&Cmv9+Za23~Z z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=#pYR!9@D<@E89eEMOsvSj-ZZvW(@dU?r9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxRJWSa literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_tzcyx.ome.zarr/.zattrs b/tests/data/omezarr/dim5_tzcyx.ome.zarr/.zattrs new file mode 100644 index 000000000..9fd33a7fb --- /dev/null +++ b/tests/data/omezarr/dim5_tzcyx.ome.zarr/.zattrs @@ -0,0 +1,46 @@ +{ + "multiscales": [ + { + "version": "0.4", + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ], + "axes": [ + { + "name": "t", + "type": "time" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_tzcyx.ome.zarr/.zgroup b/tests/data/omezarr/dim5_tzcyx.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim5_tzcyx.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_tzcyx.ome.zarr/0/.zarray b/tests/data/omezarr/dim5_tzcyx.ome.zarr/0/.zarray new file mode 100644 index 000000000..c92f859ef --- /dev/null +++ b/tests/data/omezarr/dim5_tzcyx.ome.zarr/0/.zarray @@ -0,0 +1,23 @@ +{ + "shape": [ + 2, + 4, + 3, + 6, + 8 + ], + "chunks": [ + 2, + 1, + 3, + 6, + 8 + ], + "dtype": "{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_t& zMhQw$hH_M(5>=>14Qf$`dNiOBO=w07TG57fbf6Pm=td8E(T9EvU=TwX#t23+hH*?_ z5>uGQ3}!Khc`RTNmw1_1c$L?9oi})sw|JX(c$fEhpAYzukNB8R_>|9>&*yx>0>0!c zzUCXgWg*}3JwNaxKk+la@GHOZJAd#efAKf}@Gt*yi7mBdw%k_ON?T>CZH?txo~^ZY zw%#__M%!eYZHsNSZMNNZ*iPGJyKRr{wSBhV4%k6EWQXmD9kpY2+)mg@J7uTsjGeV} zcHS=7MWalTgB;}~XSv8#ZgQ80Jmn>C`N&s(@>hTY6{KK=C{$qzSA-%JrD(+{R&k0~ zf)bUaWThxoX-ZdyGL@xl<)}!-Dp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKPf|rK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_tzcyx.ome.zarr/0/0.1.0.0.0 b/tests/data/omezarr/dim5_tzcyx.ome.zarr/0/0.1.0.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..678ed89e2b57253198aa620a5b4f198239f69bdc GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?wBaw>(Sc5Mp&LEuMIZVxfI$r5ABF*vM$s9-5e#GygE^9;IGQ00Wf;R5 z!7+?v6r&l#v5aLL<2jBAOk@(1nZmO?$Md|vi@e0kyuz!z#_PPno4m!_yu-V^$NPN1 zhkV4xe8Q)E#^-#&mwd(7e8abV$M^iekNm{X{KBvN#_#;WpZvw&{KL66&*s|#TWE`H zu`RKsw#=5>3R`KbY_+YiwYJXI+XmZcn{2afv8}evw%ZQdX}fHc2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(37+B^p5p~x;uT)w4c_7%-s1y4;uAjO3%=qT zzT*de;un775B}mGgdnLjuHZ_p;%ctpTCU@IZs104;%08)R&L{V?%+-?aFM&Xn|rvI z`?#M6c#wy9m`8Y&$9SA4c#@}hnrC>H=XjnMc#)U5#AROQ6<*~vUgr(oW~iWh>q%*j_ZU@>Xc6FjLzzu&Z~ntnv*%3i@BPcxtoW1nwNQ-kNKLP`CEVmT95@> uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFAnOcPU@^K>Z)$)t{&>CUh1tr>Z^Y0uK^mUK^m+f8meI$t`QokQ5vl=8mn;{uL+u{ zNt&!FnyP7dmnyY!5uM4mci?A3=uoTO%94oLAtFRhtuommE9viR`o3I&M zuoc^|9XqfSyRaL3uowHV9|v#{hj182a1_UI94BxRr*Il)a2Drq9v5&Cmv9+Za23~Z z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=#pYR!9@D<@E89eEMOsvSj-ZZvW(@dU?r9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxRJWSa literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_zctyx.ome.zarr/.zattrs b/tests/data/omezarr/dim5_zctyx.ome.zarr/.zattrs new file mode 100644 index 000000000..80d186378 --- /dev/null +++ b/tests/data/omezarr/dim5_zctyx.ome.zarr/.zattrs @@ -0,0 +1,46 @@ +{ + "multiscales": [ + { + "version": "0.4", + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ], + "axes": [ + { + "name": "z", + "type": "space" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "t", + "type": "time" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_zctyx.ome.zarr/.zgroup b/tests/data/omezarr/dim5_zctyx.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim5_zctyx.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_zctyx.ome.zarr/0/.zarray b/tests/data/omezarr/dim5_zctyx.ome.zarr/0/.zarray new file mode 100644 index 000000000..a9fa0bc1e --- /dev/null +++ b/tests/data/omezarr/dim5_zctyx.ome.zarr/0/.zarray @@ -0,0 +1,23 @@ +{ + "shape": [ + 4, + 3, + 2, + 6, + 8 + ], + "chunks": [ + 1, + 3, + 2, + 6, + 8 + ], + "dtype": "{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_=t zsV%eRw!&80DqC%9EZ6dEt*x{5w!t>qCfjUVY^!av?Y6^q+AiB|du*@mv;B6!4%#6* zY)9;<9kb(h!cN*LJ8fs|tevy-cEK)M5sFcQQk0<_6{tiNs!@Yl)S(^?Xhaj5(SlaA zp&cFQL>IczgI@HZ9|IV~5QZ^=QH)_66PUylrZIzA%wZl2SOjH~9ONh`Im<<^a+A9} zjQrl&LIbD@T`j znOAs~*La;bc$2qyn|FAZ_jsQV_>hnIm{0hW&zR5We8B>~B-|;;^@FPF* zGr#aFzwtYN@F#!qH~;W2|FKBLDp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKLPoPK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_zctyx.ome.zarr/0/1.0.0.0.0 b/tests/data/omezarr/dim5_zctyx.ome.zarr/0/1.0.0.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..fab096529cb6629e2f6aa5d0022264a56f9f9e1e GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?v|+Bzv-!5b7TO|PY)fpZEwkme!dBWUTWxD>t*x{5w!t>qCfjUVY^!av z?Y6^q+AiB|du*@mv;B6!4%#6*Y)9;<9kb(h!cN*LJ8fs|FWS+8PIRFgJ?KRr`Z0h( z4B;P!0g^`18Nd+?WDtWnlA}19Aq-_0!x_ObjARs}8N;!RWgO!@iy=9F7NR^AMha`@iCw9DWCB< zU+^Vg@ipJ@E#L7yKky?z@iV{hE5GqOfAA-N@i+hQuiDk2PIakUJ?d4T`Zb_I4e6hT z1*ec2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(@Fs8ZHt+B*@9{nz;0Px;!v(HzgF8In2`_lV2fpxwKLQYlAOs@> zp$J1bA`povL?Z^Vh(kOQkccECBL%5QLpm~$i7aFz2f4^YJ_@u@i?movv{cKqTr0Fv ztF&5cv{vi1UK_Mgo3vS5v{l=*T|2Z>yR=(-v{(DIUk7whhjds+bX3Q5Tqkr=r*v9p zbXMndULW8g9^o;b;3=NrIbPr;Ug0&~;4R+aJwD(gKH)RI;48l2JAU9Ne&IL%;4l6` z2$D+U3a;cTuI3u9 uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFBfnTmv9+Za23~Z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=# zpYR!9@D<@E89ebWlfiQfGBhS9Mc&^-xdsQg8K9U-eUe4bVUh(qIkI zPz}>?jnGJq(rAs*SdG(oP0&P5(qv81R87-#&CpEE(rnGqT+P#bEnp#wSj-ZZvW(@d zU?r;hy6H! zgE)l4ID(@%hT}MalQ@ObID@k|hx2eSM{_b~b1_$QGk5baPxCTw^D$rZGk*)PKnt>9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxJ4l++ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_ztcyx.ome.zarr/.zattrs b/tests/data/omezarr/dim5_ztcyx.ome.zarr/.zattrs new file mode 100644 index 000000000..9609d020e --- /dev/null +++ b/tests/data/omezarr/dim5_ztcyx.ome.zarr/.zattrs @@ -0,0 +1,46 @@ +{ + "multiscales": [ + { + "version": "0.4", + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ], + "axes": [ + { + "name": "z", + "type": "space" + }, + { + "name": "t", + "type": "time" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_ztcyx.ome.zarr/.zgroup b/tests/data/omezarr/dim5_ztcyx.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim5_ztcyx.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_ztcyx.ome.zarr/0/.zarray b/tests/data/omezarr/dim5_ztcyx.ome.zarr/0/.zarray new file mode 100644 index 000000000..0b7f4fcac --- /dev/null +++ b/tests/data/omezarr/dim5_ztcyx.ome.zarr/0/.zarray @@ -0,0 +1,23 @@ +{ + "shape": [ + 4, + 2, + 3, + 6, + 8 + ], + "chunks": [ + 1, + 2, + 3, + 6, + 8 + ], + "dtype": "{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_t& zMhQw$hH_M(5>=>14Qf$`dNiOBO=w07TG57fbf6Pm=td8E(T9EvU=TwX#t23+hH*?_ z5>uGQ3}!Khc`RTNmw1_1c$L?9oi})sw|JX(c$fEhpAYzukNB8R_>|9>&*yx>0>0!c zzUCXgWg*}3JwNaxKk+la@GHOZJAd#efAKf}@Gt*yi7mBdw%k_ON?T>CZH?txo~^ZY zw%#__M%!eYZHsNSZMNNZ*iPGJyKRr{wSBhV4%k6EWQXmD9kpY2+)mg@J7uTsjGeV} zcHS=7MWalTgB;}~XSv8#ZgQ80Jmn>C`N&s(@>hTY6{KK=C{$qzSA-%JrD(+{R&k0~ zf)bUaWThxoX-ZdyGL@xl<)}!-Dp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKPf|rK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_ztcyx.ome.zarr/0/1.0.0.0.0 b/tests/data/omezarr/dim5_ztcyx.ome.zarr/0/1.0.0.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..678ed89e2b57253198aa620a5b4f198239f69bdc GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?wBaw>(Sc5Mp&LEuMIZVxfI$r5ABF*vM$s9-5e#GygE^9;IGQ00Wf;R5 z!7+?v6r&l#v5aLL<2jBAOk@(1nZmO?$Md|vi@e0kyuz!z#_PPno4m!_yu-V^$NPN1 zhkV4xe8Q)E#^-#&mwd(7e8abV$M^iekNm{X{KBvN#_#;WpZvw&{KL66&*s|#TWE`H zu`RKsw#=5>3R`KbY_+YiwYJXI+XmZcn{2afv8}evw%ZQdX}fHc2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(37+B^p5p~x;uT)w4c_7%-s1y4;uAjO3%=qT zzT*de;un775B}mGgdnLjuHZ_p;%ctpTCU@IZs104;%08)R&L{V?%+-?aFM&Xn|rvI z`?#M6c#wy9m`8Y&$9SA4c#@}hnrC>H=XjnMc#)U5#AROQ6<*~vUgr(oW~iWh>q%*j_ZU@>Xc6FjLzzu&Z~ntnv*%3i@BPcxtoW1nwNQ-kNKLP`CEVmT95@> uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFAnOcPU@^K>Z)$)t{&>CUh1tr>Z^Y0uK^mUK^m+f8meI$t`QokQ5vl=8mn;{uL+u{ zNt&!FnyP7dmnyY!5uM4mci?A3=uoTO%94oLAtFRhtuommE9viR`o3I&M zuoc^|9XqfSyRaL3uowHV9|v#{hj182a1_UI94BxRr*Il)a2Drq9v5&Cmv9+Za23~Z z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=#pYR!9@D<@E89eEMOsvSj-ZZvW(@dU?r9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxRJWSa literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/gen_dim5.py b/tests/data/omezarr/gen_dim5.py new file mode 100644 index 000000000..34b9c377f --- /dev/null +++ b/tests/data/omezarr/gen_dim5.py @@ -0,0 +1,115 @@ +"""Generate OME-Zarr v2 fixtures whose every voxel encodes its own (x,y,z,c,t) +coordinate, so a reader that mis-orders or fails to address the C/T/Z axes reads +a provably wrong value. + + value(x,y,z,c,t) = 1 + ((((t*C + c)*Z + z)*Y + y)*X + x) # C=3,Z=4,Y=6,X=8 + +Stores (same encoding throughout; absent axes are pinned to index 0): + + dim5.ome.zarr axes t,c,z,y,x default TCZYX + dim5_ctzyx.ome.zarr axes c,t,z,y,x non-default (C/T swapped) -> proves 'axes' is honored + dim3_zyx.ome.zarr axes z,y,x 3D -> proves rank-3 axis mapping + dim2_yx.ome.zarr axes y,x 2D -> proves rank-2 axis mapping + dim5_noaxes.ome.zarr (no 'axes' key) 5D TCZYX -> proves the legacy fallback + +All chunked one z-slice per chunk and written UNCOMPRESSED (compressor=None) so +they read with header-only z5 (no blosc/zlib link). + +Run with the nyxus_build env python (zarr 3.x): + C:\\Users\\dvladi\\miniforge3\\envs\\nyxus_build\\python.exe gen_dim5.py +""" +import os +import shutil +import json +import numpy as np +import zarr + +T, C, Z, Y, X = 2, 3, 4, 6, 8 +HERE = os.path.dirname(os.path.abspath(__file__)) + +_AX = { + "t": {"name": "t", "type": "time"}, + "c": {"name": "c", "type": "channel"}, + "z": {"name": "z", "type": "space"}, + "y": {"name": "y", "type": "space"}, + "x": {"name": "x", "type": "space"}, +} + + +def encoded_tczyx(): + """Logical volume indexed [t,c,z,y,x].""" + t, c, z, y, x = np.meshgrid( + np.arange(T), np.arange(C), np.arange(Z), np.arange(Y), np.arange(X), indexing="ij") + return (1 + ((((t * C + c) * Z + z) * Y + y) * X + x)).astype("uint16") + + +def _data_for(order): + """Array laid out in `order` (a subset/permutation of 'tczyx'); axes absent + from `order` are pinned to index 0.""" + base = encoded_tczyx() # [t,c,z,y,x] + sel = tuple(slice(None) if a in order else 0 for a in "tczyx") + sub = base[sel] # absent axes dropped + remaining = [a for a in "tczyx" if a in order] # kept axes, tczyx order + perm = tuple(remaining.index(a) for a in order) # -> requested order + return np.transpose(sub, perm).copy() + + +def write_store(name, order, include_axes=True): + path = os.path.join(HERE, name) + shutil.rmtree(path, ignore_errors=True) + data = _data_for(order) + chunks = tuple(1 if a == "z" else s for a, s in zip(order, data.shape)) + + g = zarr.open_group(path, mode="w", zarr_format=2) + a = g.create_array("0", shape=data.shape, dtype=data.dtype, chunks=chunks, compressors=None) + a[:] = data + + ms = {"version": "0.4", + "datasets": [{"path": "0", + "coordinateTransformations": [{"type": "scale", "scale": [1.0] * len(order)}]}]} + if include_axes: + ms["axes"] = [_AX[a] for a in order] + g.attrs.put({"multiscales": [ms]}) + + meta = json.load(open(os.path.join(path, "0", ".zarray"))) + print("wrote %-22s axes=%-6s shape=%s chunks=%s%s" + % (name, order if include_axes else "(none)", meta["shape"], meta["chunks"], + "" if include_axes else " [no axes -> legacy fallback]")) + + +def write_bad(name, shape, axes): + """Write a deliberately malformed store (its 'axes' disagrees with the array) + so the loader must reject it cleanly, not crash.""" + path = os.path.join(HERE, name) + shutil.rmtree(path, ignore_errors=True) + g = zarr.open_group(path, mode="w", zarr_format=2) + chunks = tuple(1 if i == len(shape) - 3 else s for i, s in enumerate(shape)) + a = g.create_array("0", shape=shape, dtype="uint16", chunks=chunks, compressors=None) + a[:] = 0 + g.attrs.put({"multiscales": [{"version": "0.4", "axes": axes, + "datasets": [{"path": "0", + "coordinateTransformations": [{"type": "scale", "scale": [1.0] * len(axes)}]}]}]}) + print("wrote %-24s shape=%s axes=%d (MALFORMED)" % (name, list(shape), len(axes))) + + +def main(): + # All 6 orderings of {t,c,z} before y,x -- the complete legal OME axis-order set. + for o in ["tczyx", "tzcyx", "ctzyx", "cztyx", "ztcyx", "zctyx"]: + write_store("dim5.ome.zarr" if o == "tczyx" else "dim5_%s.ome.zarr" % o, o) + # 4D (rank-4 coverage): one time-only, one channel-only. + write_store("dim4_tzyx.ome.zarr", "tzyx") + write_store("dim4_czyx.ome.zarr", "czyx") + # lower rank + fallback + write_store("dim3_zyx.ome.zarr", "zyx") + write_store("dim2_yx.ome.zarr", "yx") + write_store("dim5_noaxes.ome.zarr", "tczyx", include_axes=False) + # --- illegal / adversarial: must be rejected cleanly, not crash --- + # 3D array but 'axes' declares 5 entries -> indexing the shape by axis role OOBs + write_bad("bad_axes_count.ome.zarr", (Z, Y, X), [_AX[k] for k in ("t", "c", "z", "y", "x")]) + # axes present but none labeled x/y -> X/Y unresolvable + write_bad("bad_no_xy.ome.zarr", (Z, Y, X), + [{"name": "z", "type": "space"}, {"name": "a", "type": "space"}, {"name": "b", "type": "space"}]) + + +if __name__ == "__main__": + main() diff --git a/tests/test_3d_nifti_mechanics.h b/tests/test_3d_nifti_mechanics.h index be018f633..aaafb62ba 100644 --- a/tests/test_3d_nifti_mechanics.h +++ b/tests/test_3d_nifti_mechanics.h @@ -1,5 +1,6 @@ #include #include "../src/nyx/raw_nifti.h" +#include "../src/nyx/image_loader.h" #include "../src/nyx/helpers/fsystem.h" void test_3d_nifti_loader () @@ -56,7 +57,7 @@ void test_3d_nifti_data_access_consistency() ASSERT_NO_THROW(h = ldr1.fullHeight(0)); auto t = std::make_shared> (d*w*h); - ASSERT_NO_THROW (ldr1.loadTileFromFile (t, 0, 0, 0, 0)); + ASSERT_NO_THROW (ldr1.loadTileFromFile (t, 0, 0, 0, 0/*channel*/, 0/*timeframe*/, 0)); // stats std::vector& databuf = *t; @@ -65,3 +66,40 @@ void test_3d_nifti_data_access_consistency() tot += x; ASSERT_TRUE(tot == 544286216); } + +// Safety net for wiring the volumetric consumer onto load_volume(): for a NIfTI +// (whole-4D loader), load_volume(0,t) must equal the t-th timeframe slab of the +// current whole-volume read (load_tile(0,0) + get_int_tile_buffer). If this holds, +// swapping the consumer's read source cannot regress NIfTI. +void test_facade_nifti_load_volume_equivalence() +{ + fs::path p(__FILE__); + fs::path f("/data/nifti/signal/signal1.nii"); + fs::path ds = (p.parent_path().string() + f.make_preferred().string()); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps sp; + sp.fname_int = ds.string(); + sp.fname_seg = ""; + FpImageOptions fp; + ImageLoader il; + ASSERT_TRUE(il.open(sp, fp)) << ds.string(); + + const size_t timeFrameSize = il.get_full_width() * il.get_full_height() * il.get_full_depth(); + const size_t nt = il.get_inten_time(); + ASSERT_GE(nt, 1u); + + ASSERT_TRUE(il.load_tile((size_t)0, (size_t)0)); // the current NIfTI read: whole x*y*z*t blob + const std::vector whole = il.get_int_tile_buffer(); // copy (load_volume reuses ptrI) + ASSERT_GE(whole.size(), timeFrameSize * nt); + + for (size_t t = 0; t < nt; ++t) + { + ASSERT_TRUE(il.load_volume(0, t)); + const std::vector& vol = il.get_int_volume_buffer(); + ASSERT_EQ(vol.size(), timeFrameSize); + for (size_t i = 0; i < timeFrameSize; ++i) + ASSERT_EQ(vol[i], whole[t * timeFrameSize + i]) << "t=" << t << " i=" << i; + } + il.close(); +} diff --git a/tests/test_all.cc b/tests/test_all.cc index e7ced9fd9..cf0a339bd 100644 --- a/tests/test_all.cc +++ b/tests/test_all.cc @@ -4,6 +4,8 @@ #include "../src/nyx/environment.h" #include "../src/nyx/globals.h" #include "test_contour.h" +#include "test_ome_meta.h" // native-OME metadata parsers / OmeAxes descriptor +#include "test_ometiff_mechanics.h" // OME-TIFF native (z,c,t)->IFD read (core; no USE_Z5) #include "test_firstorder_regression.h" #include "test_intensity_histogram_regression.h" #include "test_intensity_histogram_ibsi.h" @@ -2509,6 +2511,10 @@ TEST(TEST_NYXUS, TEST_3D_NIFTY_DACC_CONSISTENCY) { ASSERT_NO_THROW (test_3d_nifti_data_access_consistency()); } +TEST(TEST_NYXUS, TEST_FACADE_NIFTI_LOAD_VOLUME_EQUIVALENCE) { + ASSERT_NO_THROW (test_facade_nifti_load_volume_equivalence()); +} + //***** OME-Zarr i/o ***** @@ -2538,10 +2544,147 @@ TEST(TEST_NYXUS, TEST_RAW_OMEZARR_MULTITILE) { ASSERT_NO_THROW (test_raw_omezarr_multitile()); } +TEST(TEST_NYXUS, TEST_OMEZARR_5D_CHANNEL_TIME_ADDRESSING) { + ASSERT_NO_THROW (test_omezarr_addressing("dim5.ome.zarr", 2, 3, 4)); +} + +TEST(TEST_NYXUS, TEST_RAW_OMEZARR_5D_CHANNEL_TIME_ADDRESSING) { + ASSERT_NO_THROW (test_raw_omezarr_addressing("dim5.ome.zarr", 2, 3, 4)); +} + +// All 6 legal orderings of {t,c,z}: passes only if the loader honors the NGFF +// 'axes' metadata instead of assuming a fixed [T,C,Z,Y,X] order. +TEST(TEST_NYXUS, TEST_OMEZARR_ALL_5D_PERMUTATIONS) { + ASSERT_NO_THROW (test_omezarr_all_5d_permutations()); +} + +// 4D (rank-4): time-only and channel-only. +TEST(TEST_NYXUS, TEST_OMEZARR_4D_TZYX) { + ASSERT_NO_THROW (test_omezarr_addressing("dim4_tzyx.ome.zarr", 2, 1, 4)); + ASSERT_NO_THROW (test_raw_omezarr_addressing("dim4_tzyx.ome.zarr", 2, 1, 4)); +} +TEST(TEST_NYXUS, TEST_OMEZARR_4D_CZYX) { + ASSERT_NO_THROW (test_omezarr_addressing("dim4_czyx.ome.zarr", 1, 3, 4)); + ASSERT_NO_THROW (test_raw_omezarr_addressing("dim4_czyx.ome.zarr", 1, 3, 4)); +} + +// End-to-end through the wired volumetric consumer (scan_trivial_wholevolume). +TEST(TEST_NYXUS, TEST_OMEZARR_WHOLEVOLUME_CONSUMER) { + ASSERT_NO_THROW (test_omezarr_wholevolume_consumer("dim3_zyx.ome.zarr", 4)); +} + +// Facade whole-volume assembly (load_volume loops Z into one X*Y*Z buffer). +TEST(TEST_NYXUS, TEST_OMEZARR_FACADE_VOLUME_3D) { + ASSERT_NO_THROW (test_omezarr_facade_volume("dim3_zyx.ome.zarr", 1, 1, 4)); +} +TEST(TEST_NYXUS, TEST_OMEZARR_FACADE_VOLUME_5D) { + ASSERT_NO_THROW (test_omezarr_facade_volume("dim5.ome.zarr", 2, 3, 4)); +} + +// Lower-rank stores: a fixed shape[2..4] loader would crash; the axis-role loader +// reads 3D (ZYX) and 2D (YX) correctly. +TEST(TEST_NYXUS, TEST_OMEZARR_3D_ZYX) { + ASSERT_NO_THROW (test_omezarr_addressing("dim3_zyx.ome.zarr", 1, 1, 4)); +} + +TEST(TEST_NYXUS, TEST_RAW_OMEZARR_3D_ZYX) { + ASSERT_NO_THROW (test_raw_omezarr_addressing("dim3_zyx.ome.zarr", 1, 1, 4)); +} + +TEST(TEST_NYXUS, TEST_OMEZARR_2D_YX) { + ASSERT_NO_THROW (test_omezarr_addressing("dim2_yx.ome.zarr", 1, 1, 1)); +} + +// No 'axes' metadata -> the loader falls back to legacy 5D TCZYX and still reads. +TEST(TEST_NYXUS, TEST_OMEZARR_NOAXES_FALLBACK) { + ASSERT_NO_THROW (test_omezarr_addressing("dim5_noaxes.ome.zarr", 2, 3, 4)); +} + +TEST(TEST_NYXUS, TEST_RAW_OMEZARR_NOAXES_FALLBACK) { + ASSERT_NO_THROW (test_raw_omezarr_addressing("dim5_noaxes.ome.zarr", 2, 3, 4)); +} + +// Negative: out-of-range Z/C/T plane index must throw. +TEST(TEST_NYXUS, TEST_OMEZARR_OUT_OF_RANGE_THROWS) { + ASSERT_NO_THROW (test_omezarr_out_of_range_throws()); +} + +// Illegal / adversarial: malformed metadata must be rejected cleanly, not crash. +TEST(TEST_NYXUS, TEST_OMEZARR_MALFORMED_THROWS) { + ASSERT_NO_THROW (test_omezarr_malformed_throws()); +} + #endif // OMEZARR_SUPPORT -int main(int argc, char **argv) +//***** OME-TIFF native (z,c,t) -> IFD read (core; runs in every build) ***** + +TEST(TEST_NYXUS, TEST_OMETIFF_5D_CHANNEL_TIME_ADDRESSING) { + ASSERT_NO_THROW (test_ometiff_addressing("dim5.ome.tif", 2, 3, 4)); +} +TEST(TEST_NYXUS, TEST_RAW_OMETIFF_5D_CHANNEL_TIME_ADDRESSING) { + ASSERT_NO_THROW (test_raw_ometiff_addressing("dim5.ome.tif", 2, 3, 4)); +} + +// All 6 legal DimensionOrder values: passes only if ifdForPlane honors DimensionOrder. +TEST(TEST_NYXUS, TEST_OMETIFF_ALL_5D_PERMUTATIONS) { + ASSERT_NO_THROW (test_ometiff_all_5d_permutations()); +} + +// 4D (rank-4): time-only and channel-only. +TEST(TEST_NYXUS, TEST_OMETIFF_4D_TZYX) { + ASSERT_NO_THROW (test_ometiff_addressing("dim4_tzyx.ome.tif", 2, 1, 4)); + ASSERT_NO_THROW (test_raw_ometiff_addressing("dim4_tzyx.ome.tif", 2, 1, 4)); +} +TEST(TEST_NYXUS, TEST_OMETIFF_4D_CZYX) { + ASSERT_NO_THROW (test_ometiff_addressing("dim4_czyx.ome.tif", 1, 3, 4)); + ASSERT_NO_THROW (test_raw_ometiff_addressing("dim4_czyx.ome.tif", 1, 3, 4)); +} + +// End-to-end through the wired volumetric consumer (scan_trivial_wholevolume). +TEST(TEST_NYXUS, TEST_OMETIFF_WHOLEVOLUME_CONSUMER) { + ASSERT_NO_THROW (test_ometiff_wholevolume_consumer("dim3_zyx.ome.tif", 4)); +} + +// Facade whole-volume assembly (load_volume loops Z into one X*Y*Z buffer). +TEST(TEST_NYXUS, TEST_OMETIFF_FACADE_VOLUME_3D) { + ASSERT_NO_THROW (test_ometiff_facade_volume("dim3_zyx.ome.tif", 1, 1, 4)); +} +TEST(TEST_NYXUS, TEST_OMETIFF_FACADE_VOLUME_5D) { + ASSERT_NO_THROW (test_ometiff_facade_volume("dim5.ome.tif", 2, 3, 4)); +} + +// Lower-rank OME-TIFF. +TEST(TEST_NYXUS, TEST_OMETIFF_3D_ZYX) { + ASSERT_NO_THROW (test_ometiff_addressing("dim3_zyx.ome.tif", 1, 1, 4)); +} +TEST(TEST_NYXUS, TEST_RAW_OMETIFF_3D_ZYX) { + ASSERT_NO_THROW (test_raw_ometiff_addressing("dim3_zyx.ome.tif", 1, 1, 4)); +} +TEST(TEST_NYXUS, TEST_OMETIFF_2D_YX) { + ASSERT_NO_THROW (test_ometiff_addressing("dim2_yx.ome.tif", 1, 1, 1)); +} + +// Plain multi-page TIFF (no OME-XML): the legacy page=Z fallback must still work. +TEST(TEST_NYXUS, TEST_OMETIFF_PLAIN_MULTIPAGE_FALLBACK) { + ASSERT_NO_THROW (test_ometiff_addressing("dim3_plain.tif", 1, 1, 4)); +} +TEST(TEST_NYXUS, TEST_RAW_OMETIFF_PLAIN_MULTIPAGE_FALLBACK) { + ASSERT_NO_THROW (test_raw_ometiff_addressing("dim3_plain.tif", 1, 1, 4)); +} + +// Negative: out-of-range Z/C/T plane index must throw. +TEST(TEST_NYXUS, TEST_OMETIFF_OUT_OF_RANGE_THROWS) { + ASSERT_NO_THROW (test_ometiff_out_of_range_throws()); +} + +// Illegal / adversarial: RGB / corrupt / missing files must be rejected cleanly. +TEST(TEST_NYXUS, TEST_OMETIFF_MALFORMED_THROWS) { + ASSERT_NO_THROW (test_ometiff_malformed_throws()); +} + + +int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); int ret = RUN_ALL_TESTS(); diff --git a/tests/test_hu_mechanics.h b/tests/test_hu_mechanics.h index 33876a038..afcf76f77 100644 --- a/tests/test_hu_mechanics.h +++ b/tests/test_hu_mechanics.h @@ -75,7 +75,7 @@ static std::vector hu_load_tile0(const char* fixture, bool preserve_hu size_t th = ldr.tileHeight(0), tw = ldr.tileWidth(0); auto tile = std::make_shared>(th * tw, 0u); - EXPECT_NO_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 0)); + EXPECT_NO_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 0/*channel*/, 0/*timeframe*/, 0)); return *tile; } @@ -124,7 +124,7 @@ static std::vector hu_load_dicom_tile0(const char* fixture, bool prese NyxusGrayscaleDicomLoader ldr(1, ds.string(), fpmin, preserve_hu); size_t th = ldr.tileHeight(0), tw = ldr.tileWidth(0); auto tile = std::make_shared>(th * tw, 0u); - EXPECT_NO_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 0)); + EXPECT_NO_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 0/*channel*/, 0/*timeframe*/, 0)); return *tile; } diff --git a/tests/test_morphology_common.h b/tests/test_morphology_common.h index a72212037..a8b2bb473 100644 --- a/tests/test_morphology_common.h +++ b/tests/test_morphology_common.h @@ -238,7 +238,7 @@ static void calculate_fractal_blob512_feature_values(std::vector>(tw * th * td); - loader.loadTileFromFile(tile, 0, 0, 0, 0); // 512x512 <= 1024 strip tile: one read + loader.loadTileFromFile(tile, 0, 0, 0, 0/*channel*/, 0/*timeframe*/, 0); // 512x512 <= 1024 strip tile: one read std::vector px; for (size_t y = 0; y < H; y++) diff --git a/tests/test_ome_meta.h b/tests/test_ome_meta.h new file mode 100644 index 000000000..62bf687bc --- /dev/null +++ b/tests/test_ome_meta.h @@ -0,0 +1,422 @@ +#pragma once + +// Unit tests for the native-OME metadata parsers / OmeAxes descriptor. +// The OME-XML parser and OmeAxes helpers are exercised here with embedded +// fixtures (no external files). The OME-Zarr parser is guarded by +// OMEZARR_SUPPORT (compiled only in USE_Z5 builds); it is additionally +// validated end-to-end against the reference-data matrix by +// io_oracle/ome_meta_selftest.cpp. + +#include +#include "../src/nyx/ome/ome_axes.h" +#include "../src/nyx/ome/ome_tiff_meta.h" + +// 5D, non-default DimensionOrder XYZTC (C and T swapped) — the case that catches +// a reader assuming TCZYX. Taken verbatim from io_oracle/fixtures/xml/5d_ctzyx.xml. +static const char* kOmeXml_5d_ctzyx = + "" + "" + "" + "" + ""; + +static const char* kOmeXml_2d = + "" + ""; + +TEST(OmeTiffMeta, ParsesNonDefault5D) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(kOmeXml_5d_ctzyx); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeX, 8u); + EXPECT_EQ(a.sizeY, 6u); + EXPECT_EQ(a.sizeZ, 4u); + EXPECT_EQ(a.sizeC, 3u); + EXPECT_EQ(a.sizeT, 2u); + // XYZTC means C slowest, T next -> on-disk (slowest-first) is C,T,Z,Y,X. + EXPECT_EQ(a.storageOrder, "CTZYX"); + EXPECT_EQ(a.omeDimensionOrder, "XYZTC"); + EXPECT_EQ(a.dtype, Nyxus::PixelType::UInt16); + EXPECT_EQ(a.bitsPerSample, 16u); + EXPECT_DOUBLE_EQ(a.physX, 0.5); + EXPECT_DOUBLE_EQ(a.physZ, 2.0); + EXPECT_DOUBLE_EQ(a.timeIncrement, 1.0); + EXPECT_EQ(a.unitZ, "micrometer"); + EXPECT_EQ(a.unitT, "second"); + EXPECT_TRUE(a.isVolumetric()); + EXPECT_TRUE(a.isMultiChannel()); + EXPECT_TRUE(a.isTimeSeries()); + ASSERT_EQ(a.channelNames.size(), 2u); + EXPECT_EQ(a.channelNames[0], "DAPI"); + EXPECT_EQ(a.channelNames[1], "GFP"); + // axis-role lookup independent of on-disk order + EXPECT_EQ(a.storageIndexOf('C'), 0); // C is first (slowest) in CTZYX + EXPECT_EQ(a.storageIndexOf('X'), 4); +} + +TEST(OmeTiffMeta, Parses2DSingletonAxesCollapse) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(kOmeXml_2d); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeZ, 1u); + EXPECT_EQ(a.sizeC, 1u); + EXPECT_EQ(a.sizeT, 1u); + EXPECT_EQ(a.storageOrder, "YX"); // singleton Z/C/T dropped; planes stay YX + EXPECT_FALSE(a.isVolumetric()); + EXPECT_FALSE(a.isMultiChannel()); + EXPECT_FALSE(a.isTimeSeries()); +} + +TEST(OmeTiffMeta, InvalidWhenNoPixels) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(""); + EXPECT_FALSE(a.valid); +} + +// --------------------------------------------------------------------------- +// Negative / robustness: bad or missing metadata for every field we parse. +// The parser must never crash and must degrade to safe defaults. +// --------------------------------------------------------------------------- + +TEST(OmeTiffMetaBad, EmptyAndGarbageInput) +{ + EXPECT_FALSE(Nyxus::parse_ome_xml("").valid); + EXPECT_FALSE(Nyxus::parse_ome_xml("not xml at all").valid); + EXPECT_FALSE(Nyxus::parse_ome_xml("").valid); +} + +TEST(OmeTiffMetaBad, UnterminatedPixelsTagIsInvalid) +{ + // no '>' closing the Pixels start-tag + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(""); + EXPECT_FALSE(a.valid); +} + +TEST(OmeTiffMetaBad, AllSizesMissingDefaultToOne) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(""); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeX, 1u); EXPECT_EQ(a.sizeY, 1u); EXPECT_EQ(a.sizeZ, 1u); + EXPECT_EQ(a.sizeC, 1u); EXPECT_EQ(a.sizeT, 1u); + EXPECT_EQ(a.storageOrder, "YX"); + EXPECT_EQ(a.omeDimensionOrder, "XYZCT"); // missing -> default + EXPECT_EQ(a.dtype, Nyxus::PixelType::UInt16); // missing Type -> default + EXPECT_DOUBLE_EQ(a.physX, 1.0); // missing PhysicalSize -> 1.0 + EXPECT_DOUBLE_EQ(a.physZ, 1.0); + EXPECT_DOUBLE_EQ(a.timeIncrement, 1.0); + EXPECT_TRUE(a.unitXY.empty()); + EXPECT_TRUE(a.unitZ.empty()); + EXPECT_TRUE(a.unitT.empty()); +} + +TEST(OmeTiffMetaBad, NonNumericSizeDefaultsToOne) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml( + ""); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeX, 1u); // "abc" -> 1, not 0 + EXPECT_EQ(a.sizeY, 6u); + EXPECT_EQ(a.sizeZ, 1u); // "" -> 1 +} + +TEST(OmeTiffMetaBad, ZeroSizeClampedToOne) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml( + ""); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeX, 1u); // 0 would divide-by-zero the tile grid + EXPECT_EQ(a.sizeY, 1u); + EXPECT_EQ(a.sizeZ, 4u); +} + +TEST(OmeTiffMetaBad, NegativeSizeRejectedToDefault) +{ + // strtoull wraps a leading '-' into a huge extent; parser must reject it + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(""); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeX, 1u); // negative -> default 1, not a giant number + EXPECT_EQ(a.sizeY, 6u); +} + +TEST(OmeTiffMetaBad, MissingDimensionOrderDefaults) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(""); + EXPECT_EQ(a.omeDimensionOrder, "XYZCT"); + EXPECT_EQ(a.storageOrder, "ZYX"); +} + +TEST(OmeTiffMetaBad, MalformedDimensionOrderFallsBack) +{ + for (const char* bad : { "FOOBAR", "XYZC", "XYZCC", "XXYZCT", "xyzct", "" }) + { + std::string xml = std::string(""; + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(xml); + ASSERT_TRUE(a.valid) << bad; + EXPECT_EQ(a.omeDimensionOrder, "XYZCT") << "bad input: " << bad; + } +} + +TEST(OmeTiffMetaBad, SingleQuotedAttributes) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml( + ""); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeX, 8u); + EXPECT_EQ(a.sizeZ, 4u); + EXPECT_EQ(a.dtype, Nyxus::PixelType::UInt8); + EXPECT_DOUBLE_EQ(a.physX, 0.25); +} + +TEST(OmeTiffMetaBad, UnknownAndMissingTypeFallBackToUint16) +{ + EXPECT_EQ(Nyxus::parse_ome_xml("").dtype, + Nyxus::PixelType::UInt16); + EXPECT_EQ(Nyxus::parse_ome_xml("").dtype, + Nyxus::PixelType::UInt16); +} + +TEST(OmeTiffMetaBad, AllPixelTypesResolve) +{ + auto ty = [](const char* t) { + std::string xml = std::string(""; + return Nyxus::parse_ome_xml(xml).dtype; + }; + EXPECT_EQ(ty("uint8"), Nyxus::PixelType::UInt8); + EXPECT_EQ(ty("int16"), Nyxus::PixelType::Int16); + EXPECT_EQ(ty("int32"), Nyxus::PixelType::Int32); + EXPECT_EQ(ty("float"), Nyxus::PixelType::Float32); + EXPECT_EQ(ty("double"), Nyxus::PixelType::Float64); + EXPECT_EQ(Nyxus::parse_ome_xml("").bitsPerSample, 64u); +} + +TEST(OmeTiffMetaBad, NonNumericPhysicalSizesDefaultToOne) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml( + ""); + ASSERT_TRUE(a.valid); + EXPECT_DOUBLE_EQ(a.physX, 1.0); // failed parse -> default, never 0 + EXPECT_DOUBLE_EQ(a.physZ, 1.0); + EXPECT_DOUBLE_EQ(a.timeIncrement, 1.0); +} + +TEST(OmeTiffMetaBad, NanInfPhysicalSizesRejected) +{ + // strtod parses "NaN"/"inf" prefixes; such values must not leak into calibration + for (const char* bad : { "NaN", "NaNsense", "inf", "-inf", "infinity" }) + { + std::string xml = std::string(""; + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(xml); + ASSERT_TRUE(a.valid) << bad; + EXPECT_DOUBLE_EQ(a.physZ, 1.0) << "PhysicalSizeZ from " << bad; + EXPECT_DOUBLE_EQ(a.timeIncrement, 1.0) << "TimeIncrement from " << bad; + } +} + +TEST(OmeTiffMetaBad, SizeXNotConfusedWithPhysicalSizeX) +{ + // attribute-name boundary: SizeX must not read from PhysicalSizeX + Nyxus::OmeAxes a = Nyxus::parse_ome_xml( + ""); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeX, 8u); + EXPECT_DOUBLE_EQ(a.physX, 9.9); + EXPECT_EQ(a.unitXY, "nm"); +} + +TEST(OmeTiffMetaBad, WhitespaceAndNewlinesInTag) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml( + ""); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeX, 8u); + EXPECT_EQ(a.sizeZ, 4u); + EXPECT_EQ(a.omeDimensionOrder, "XYZCT"); +} + +TEST(OmeTiffMetaBad, ChannelsWithoutNameYieldNoNames) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml( + "" + ""); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeC, 2u); + EXPECT_TRUE(a.channelNames.empty()); // no Name attr -> nothing collected +} + +TEST(OmeTiffMetaBad, FirstPixelsWinsAcrossMultipleImages) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml( + "" + ""); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeX, 8u); + EXPECT_EQ(a.sizeY, 6u); +} + +TEST(OmeAxes, PixelTypeMappings) +{ + using namespace Nyxus; + EXPECT_EQ(pixel_type_from_ome_type("uint16"), PixelType::UInt16); + EXPECT_EQ(pixel_type_from_ome_type("float"), PixelType::Float32); + EXPECT_EQ(pixel_type_from_ome_type("double"), PixelType::Float64); + // zarr v2 numpy strings and v3 names both resolve + EXPECT_EQ(pixel_type_from_zarr_dtype(" shape = {3, 2, 4, 6, 8}; // C,T,Z,Y,X + Nyxus::OmeAxes a = Nyxus::parse_ome_zarr(attrs, shape, " shape, const char* dt = " all default + EXPECT_DOUBLE_EQ(a.physY, 1.0); +} + +TEST(OmeZarrMetaBad, NonNumericScaleElementDefaultsThatAxis) +{ + Nyxus::OmeAxes a = zarr_parse( + R"({"multiscales":[{"axes":[{"name":"y","type":"space"},{"name":"x","type":"space"}], + "datasets":[{"path":"0","coordinateTransformations":[{"type":"scale","scale":["oops",0.5]}]}]}]})", + {6, 8}); + ASSERT_TRUE(a.valid); + EXPECT_DOUBLE_EQ(a.physY, 1.0); // "oops" -> 1.0 + EXPECT_DOUBLE_EQ(a.physX, 0.5); +} + +TEST(OmeZarrMetaBad, MalformedAxisEntrySkipped) +{ + // a non-object axis entry must be skipped, not crash + Nyxus::OmeAxes a = zarr_parse( + R"({"multiscales":[{"axes":["notanobject",{"name":"y","type":"space"},{"name":"x","type":"space"}], + "datasets":[{"path":"0","coordinateTransformations":[{"type":"scale","scale":[1,0.5,0.5]}]}]}]})", + {1, 6, 8}); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeX, 8u); + EXPECT_EQ(a.sizeY, 6u); +} + +TEST(OmeZarrMetaBad, UnknownDtypeFallsBackUint16) +{ + Nyxus::OmeAxes a = zarr_parse( + R"({"multiscales":[{"axes":[{"name":"y","type":"space"},{"name":"x","type":"space"}], + "datasets":[{"path":"0","coordinateTransformations":[{"type":"scale","scale":[1,1]}]}]}]})", + {6, 8}, "weird64"); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.dtype, Nyxus::PixelType::UInt16); +} + +TEST(OmeZarrMetaBad, V3OmeKeyPathParses) +{ + // 0.5 nests the model under "ome" + Nyxus::OmeAxes a = zarr_parse( + R"({"ome":{"version":"0.5","multiscales":[{ + "axes":[{"name":"z","type":"space"},{"name":"y","type":"space"},{"name":"x","type":"space"}], + "datasets":[{"path":"0","coordinateTransformations":[{"type":"scale","scale":[2.0,0.5,0.5]}]}]}]}})", + {4, 6, 8}, "uint16"); + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeZ, 4u); + EXPECT_EQ(a.storageOrder, "ZYX"); + EXPECT_DOUBLE_EQ(a.physZ, 2.0); +} + +TEST(OmeZarrMetaBad, ShorterShapeThanAxesDefaultsMissingSizes) +{ + // level0Shape shorter than axes list -> missing dims default to size 1, no OOB + Nyxus::OmeAxes a = zarr_parse( + R"({"multiscales":[{"axes":[{"name":"c","type":"channel"},{"name":"y","type":"space"},{"name":"x","type":"space"}], + "datasets":[{"path":"0","coordinateTransformations":[{"type":"scale","scale":[1,0.5,0.5]}]}]}]})", + {3}); // only C provided + ASSERT_TRUE(a.valid); + EXPECT_EQ(a.sizeC, 3u); + EXPECT_EQ(a.sizeY, 1u); + EXPECT_EQ(a.sizeX, 1u); +} +#endif diff --git a/tests/test_ometiff_mechanics.h b/tests/test_ometiff_mechanics.h new file mode 100644 index 000000000..88da399a6 --- /dev/null +++ b/tests/test_ometiff_mechanics.h @@ -0,0 +1,195 @@ +#pragma once + +// OME-TIFF native read: the multi-page loaders map (z,c,t) to the correct IFD via +// the OME-XML DimensionOrder, instead of assuming every page is a Z-slice. +// +// Fixtures (tests/data/ometiff, see gen_ome_tiff.py) encode every voxel as +// value(x,y,z,c,t) = 1 + ((((t*C + c)*Z + z)*Y + y)*X + x), C=3,Z=4,Y=6,X=8 +// so reading plane (z,c,t) must return that plane's values; a loader that mapped +// (z,c,t) to the wrong page returns provably wrong data. Unlike OME-Zarr this needs +// no USE_Z5 -- TIFF is a core dependency, so these run in every build. + +#include +#include +#include +#include "../src/nyx/grayscale_tiff.h" +#include "../src/nyx/raw_tiff.h" +#include "../src/nyx/image_loader.h" +#include "../src/nyx/globals.h" // scan_trivial_wholevolume, LR +#include "../src/nyx/helpers/fsystem.h" + +static inline fs::path ometiff_data_path(const char* name) +{ + fs::path p(__FILE__); + fs::path rel(std::string("/data/ometiff/") + name); + return fs::path(p.parent_path().string() + rel.make_preferred().string()); +} + +static inline uint32_t ometiff_enc(int x, int y, int z, int c, int t) +{ + const int C = 3, Z = 4, Y = 6, X = 8; + return static_cast(1 + ((((t * C + c) * Z + z) * Y + y) * X + x)); +} + +// AbstractTileLoader stack (NyxusGrayscaleTiffStripLoader). (T,C,Z) are the store's +// extents (X=8,Y=6 fixed). One body covers TCZYX, non-default CTZYX, 3D, 2D, and +// the plain non-OME multi-page store -- all must return the same encoded values. +void test_ometiff_addressing(const char* store, int T, int C, int Z) +{ + const int Y = 6, X = 8; + fs::path ds = ometiff_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + auto ldr = NyxusGrayscaleTiffStripLoader(1, ds.string()); + ASSERT_EQ(ldr.fullWidth(0), (size_t)X); + ASSERT_EQ(ldr.fullHeight(0), (size_t)Y); + ASSERT_EQ(ldr.fullDepth(0), (size_t)Z); // 5D OME-TIFF must report SizeZ, not the total page count + const size_t tw = ldr.tileWidth(0); + auto tile = std::make_shared>(ldr.tileHeight(0) * tw, 0u); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + for (int z = 0; z < Z; ++z) + { + std::fill(tile->begin(), tile->end(), 0u); + ASSERT_NO_THROW(ldr.loadTileFromFile(tile, 0, 0, z, c, t, 0)); + const std::vector& buf = *tile; + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(buf[y * tw + x], ometiff_enc(x, y, z, c, t)) + << store << " plane (z" << z << " c" << c << " t" << t << ") at (" << x << "," << y << ")"; + } +} + +// RawFormatLoader stack (RawTiffStripLoader). +void test_raw_ometiff_addressing(const char* store, int T, int C, int Z) +{ + const int Y = 6, X = 8; + fs::path ds = ometiff_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + auto ldr = RawTiffStripLoader(1, ds.string()); + ASSERT_EQ(ldr.fullDepth(0), (size_t)Z); + const size_t w = ldr.fullWidth(0); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + for (int z = 0; z < Z; ++z) + { + ASSERT_NO_THROW(ldr.loadTileFromFile(0, 0, z, c, t, 0)); + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(ldr.get_uint32_pixel(y * w + x), ometiff_enc(x, y, z, c, t)) + << store << " plane (z" << z << " c" << c << " t" << t << ") at (" << x << "," << y << ")"; + } + ldr.free_tile(); +} + +// End-to-end through the WIRED volumetric consumer: scan_trivial_wholevolume must +// feed the whole X*Y*Z volume (all Z planes) into the ROI's voxel cloud, with the +// correct encoded intensity. Before the wiring it read only plane z=0, so this +// asserts the consumer fix, not just the facade. +void test_ometiff_wholevolume_consumer(const char* store, int Z) +{ + const int Y = 6, X = 8; + fs::path ds = ometiff_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; + FpImageOptions fp; + ImageLoader ilo; + ASSERT_TRUE(ilo.open(p, fp)) << ds.string(); + + LR vroi; + ASSERT_TRUE(Nyxus::scan_trivial_wholevolume(vroi, ds.string(), ilo)); + + // every voxel of the X*Y*Z volume must be captured (not just plane z=0 -> 48) + ASSERT_EQ(vroi.raw_pixels_3D.size(), (size_t)X * Y * Z); + for (const Pixel3& px : vroi.raw_pixels_3D) + ASSERT_EQ((uint32_t)px.inten, ometiff_enc((int)px.x, (int)px.y, (int)px.z, 0, 0)) + << "voxel (" << px.x << "," << px.y << "," << px.z << ")"; + ilo.close(); +} + +// Whole-volume assembly through the ImageLoader facade: load_volume() stacks all +// Z-planes (per (channel,timeframe)) into one X*Y*Z buffer -- the foundation that +// lets the volumetric pipeline consume a multi-page OME-TIFF. +void test_ometiff_facade_volume(const char* store, int T, int C, int Z) +{ + const int Y = 6, X = 8; + fs::path ds = ometiff_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; + FpImageOptions fp; + ImageLoader il; + ASSERT_TRUE(il.open(p, fp)) << ds.string(); + ASSERT_EQ(il.get_full_width(), (size_t)X); + ASSERT_EQ(il.get_full_height(), (size_t)Y); + ASSERT_EQ(il.get_full_depth(), (size_t)Z); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + { + ASSERT_TRUE(il.load_volume(c, t)); + const std::vector& vol = il.get_int_volume_buffer(); + ASSERT_EQ(vol.size(), (size_t)X * Y * Z); + for (int z = 0; z < Z; ++z) + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(vol[(size_t)z * X * Y + (size_t)y * X + x], ometiff_enc(x, y, z, c, t)) + << store << " vol (x" << x << " y" << y << " z" << z << " c" << c << " t" << t << ")"; + } + il.close(); +} + +// Every one of the 6 legal OME DimensionOrder values (all orderings of {T,C,Z} +// before Y,X) must read correctly -- proves ifdForPlane honors DimensionOrder. +void test_ometiff_all_5d_permutations() +{ + for (const char* s : { "dim5.ome.tif", "dim5_tzcyx.ome.tif", "dim5_ctzyx.ome.tif", + "dim5_cztyx.ome.tif", "dim5_ztcyx.ome.tif", "dim5_zctyx.ome.tif" }) + { + test_ometiff_addressing(s, 2, 3, 4); + test_raw_ometiff_addressing(s, 2, 3, 4); + if (::testing::Test::HasFatalFailure()) return; + } +} + +// Negative: an out-of-range Z/C/T plane maps to a non-existent IFD and must throw. +// dim5.ome.tif has T=2, C=3, Z=4 (24 IFDs). +void test_ometiff_out_of_range_throws() +{ + fs::path ds = ometiff_data_path("dim5.ome.tif"); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + auto ldr = NyxusGrayscaleTiffStripLoader(1, ds.string()); + auto tile = std::make_shared>(ldr.tileHeight(0) * ldr.tileWidth(0), 0u); + EXPECT_ANY_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 99, 0, 0)); // channel out of range + EXPECT_ANY_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 0, 99, 0)); // timeframe out of range + EXPECT_ANY_THROW(ldr.loadTileFromFile(tile, 0, 0, 99, 0, 0, 0)); // z out of range + + auto raw = RawTiffStripLoader(1, ds.string()); + EXPECT_ANY_THROW(raw.loadTileFromFile(0, 0, 0, 99, 0, 0)); + EXPECT_ANY_THROW(raw.loadTileFromFile(0, 0, 0, 0, 99, 0)); + EXPECT_ANY_THROW(raw.loadTileFromFile(0, 0, 99, 0, 0, 0)); +} + +// Illegal / adversarial: a non-grayscale (RGB) OME-TIFF and a corrupt/non-TIFF file +// must be rejected cleanly (throw), not crash. +void test_ometiff_malformed_throws() +{ + for (const char* s : { "bad_rgb.ome.tif", "bad_corrupt.tif" }) + { + fs::path ds = ometiff_data_path(s); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + EXPECT_ANY_THROW(NyxusGrayscaleTiffStripLoader(1, ds.string())) << s; + EXPECT_ANY_THROW(RawTiffStripLoader(1, ds.string())) << s; + } + // a path that does not exist + EXPECT_ANY_THROW(RawTiffStripLoader(1, ometiff_data_path("does_not_exist.tif").string())); +} diff --git a/tests/test_omezarr_mechanics.h b/tests/test_omezarr_mechanics.h index cdd2eb127..b20f1ee69 100644 --- a/tests/test_omezarr_mechanics.h +++ b/tests/test_omezarr_mechanics.h @@ -8,6 +8,8 @@ #include #include "../src/nyx/omezarr.h" #include "../src/nyx/raw_omezarr.h" +#include "../src/nyx/image_loader.h" +#include "../src/nyx/globals.h" // scan_trivial_wholevolume, LR #include "../src/nyx/helpers/fsystem.h" // The OME-Zarr test datasets under tests/data/omezarr are generated with bfio @@ -60,7 +62,7 @@ void test_omezarr_tileloader_content() size_t tw = ldr.tileWidth(0); auto tile = std::make_shared>(th * tw, 0u); - ASSERT_NO_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 0)); + ASSERT_NO_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 0/*channel*/, 0/*timeframe*/, 0)); const std::vector& buf = *tile; @@ -106,7 +108,7 @@ void test_omezarr_tileloader_multitile() for (size_t tc = 0; tc < nCols; ++tc) { std::fill(tile->begin(), tile->end(), 0u); - ASSERT_NO_THROW(ldr.loadTileFromFile(tile, tr, tc, 0, 0)); + ASSERT_NO_THROW(ldr.loadTileFromFile(tile, tr, tc, 0, 0/*channel*/, 0/*timeframe*/, 0)); const std::vector& buf = *tile; const size_t row0 = tr * th; @@ -157,7 +159,7 @@ void test_raw_omezarr_content() auto ldr = RawOmezarrLoader(ds.string()); const size_t tw = ldr.tileWidth(0); - ASSERT_NO_THROW(ldr.loadTileFromFile(0, 0, 0, 0)); + ASSERT_NO_THROW(ldr.loadTileFromFile(0, 0, 0, 0/*channel*/, 0/*timeframe*/, 0)); // get_uint32_pixel indexes into the internal tile buffer (stride tileWidth) ASSERT_EQ(ldr.get_uint32_pixel(0 * tw + 0), 0u); @@ -195,7 +197,7 @@ void test_raw_omezarr_multitile() { for (size_t tc = 0; tc < nCols; ++tc) { - ASSERT_NO_THROW(ldr.loadTileFromFile(tr, tc, 0, 0)); + ASSERT_NO_THROW(ldr.loadTileFromFile(tr, tc, 0, 0/*channel*/, 0/*timeframe*/, 0)); const size_t row0 = tr * th; const size_t col0 = tc * tw; const size_t validH = std::min(th, H - row0); @@ -216,4 +218,191 @@ void test_raw_omezarr_multitile() ASSERT_EQ(total, 12681000000ull); } +// --------------------------------------------------------------------------- +// 5D (T,C,Z,Y,X) channel/timeframe addressability. +// +// dim5.ome.zarr (see gen_dim5.py) encodes every voxel as +// value(x,y,z,c,t) = 1 + ((((t*C + c)*Z + z)*Y + y)*X + x), C=3,Z=4,Y=6,X=8 +// chunked one z/c/t-plane per chunk. Reading plane (z,c,t) must return exactly +// that plane's values; a loader that ignored C/T (offset pinned to {0,0,...}, +// the pre-fix behavior) would return the c=0/t=0 plane for every (c,t). +// --------------------------------------------------------------------------- + +static inline uint32_t dim5_enc(int x, int y, int z, int c, int t) +{ + const int C = 3, Z = 4, Y = 6, X = 8; + return static_cast(1 + ((((t * C + c) * Z + z) * Y + y) * X + x)); +} + +// AbstractTileLoader stack. (T,C,Z) are the store's extents (X=8,Y=6 fixed). The +// encoded value is axis-order- AND rank-invariant, so one body covers the default +// TCZYX, the non-default CTZYX, the lower-rank 3D/2D stores, and the no-axes +// (legacy fallback) store — all of which must return the same encoded values. +void test_omezarr_addressing(const char* store, int T, int C, int Z) +{ + const int Y = 6, X = 8; + fs::path ds = omezarr_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + auto ldr = NyxusOmeZarrLoader(1, ds.string()); + ASSERT_EQ(ldr.fullWidth(0), (size_t)X); + ASSERT_EQ(ldr.fullHeight(0), (size_t)Y); + ASSERT_EQ(ldr.fullDepth(0), (size_t)Z); + const size_t tw = ldr.tileWidth(0); + auto tile = std::make_shared>(ldr.tileHeight(0) * tw, 0u); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + for (int z = 0; z < Z; ++z) + { + std::fill(tile->begin(), tile->end(), 0u); + ASSERT_NO_THROW(ldr.loadTileFromFile(tile, 0, 0, z, c, t, 0)); + const std::vector& buf = *tile; + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(buf[y * tw + x], dim5_enc(x, y, z, c, t)) + << store << " plane (z" << z << " c" << c << " t" << t << ") at (" << x << "," << y << ")"; + } +} + +// RawFormatLoader stack (same coverage as above). +void test_raw_omezarr_addressing(const char* store, int T, int C, int Z) +{ + const int Y = 6, X = 8; + fs::path ds = omezarr_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + auto ldr = RawOmezarrLoader(ds.string()); + ASSERT_EQ(ldr.fullDepth(0), (size_t)Z); + const size_t tw = ldr.tileWidth(0); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + for (int z = 0; z < Z; ++z) + { + ASSERT_NO_THROW(ldr.loadTileFromFile(0, 0, z, c, t, 0)); + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(ldr.get_uint32_pixel(y * tw + x), dim5_enc(x, y, z, c, t)) + << store << " plane (z" << z << " c" << c << " t" << t << ") at (" << x << "," << y << ")"; + } + + if (C > 1 && T > 1) // distinct (c,t) planes must differ -> the offset really uses C/T + { + ldr.loadTileFromFile(0, 0, 0, 0, 0, 0); uint32_t p000 = ldr.get_uint32_pixel(0); + ldr.loadTileFromFile(0, 0, 0, 1, 0, 0); uint32_t p010 = ldr.get_uint32_pixel(0); + ldr.loadTileFromFile(0, 0, 0, 0, 1, 0); uint32_t p001 = ldr.get_uint32_pixel(0); + ASSERT_NE(p000, p010) << "channel index ignored"; + ASSERT_NE(p000, p001) << "timeframe index ignored"; + } +} + +// End-to-end through the WIRED volumetric consumer: scan_trivial_wholevolume must +// feed the whole X*Y*Z volume (all Z planes) into the ROI's voxel cloud with the +// correct encoded intensity -- before the wiring it read only plane z=0. +void test_omezarr_wholevolume_consumer(const char* store, int Z) +{ + const int Y = 6, X = 8; + fs::path ds = omezarr_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; + FpImageOptions fp; + ImageLoader ilo; + ASSERT_TRUE(ilo.open(p, fp)) << ds.string(); + + LR vroi; + ASSERT_TRUE(Nyxus::scan_trivial_wholevolume(vroi, ds.string(), ilo)); + + ASSERT_EQ(vroi.raw_pixels_3D.size(), (size_t)X * Y * Z); // all voxels, not just plane 0 (48) + for (const Pixel3& px : vroi.raw_pixels_3D) + ASSERT_EQ((uint32_t)px.inten, dim5_enc((int)px.x, (int)px.y, (int)px.z, 0, 0)) + << "voxel (" << px.x << "," << px.y << "," << px.z << ")"; + ilo.close(); +} + +// Whole-volume assembly through the ImageLoader facade: load_volume() must stack +// all Z-planes (per (channel,timeframe)) into one X*Y*Z buffer. This is the +// foundation that lets the volumetric pipeline consume plane-by-plane OME-Zarr. +void test_omezarr_facade_volume(const char* store, int T, int C, int Z) +{ + const int Y = 6, X = 8; + fs::path ds = omezarr_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; // whole-slide: intensity only + FpImageOptions fp; + ImageLoader il; + ASSERT_TRUE(il.open(p, fp)) << ds.string(); + ASSERT_EQ(il.get_full_width(), (size_t)X); + ASSERT_EQ(il.get_full_height(), (size_t)Y); + ASSERT_EQ(il.get_full_depth(), (size_t)Z); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + { + ASSERT_TRUE(il.load_volume(c, t)); + const std::vector& vol = il.get_int_volume_buffer(); + ASSERT_EQ(vol.size(), (size_t)X * Y * Z); + for (int z = 0; z < Z; ++z) + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(vol[(size_t)z * X * Y + (size_t)y * X + x], dim5_enc(x, y, z, c, t)) + << store << " vol (x" << x << " y" << y << " z" << z << " c" << c << " t" << t << ")"; + } + il.close(); +} + +// Every one of the 6 legal orderings of {t,c,z} before y,x must read correctly +// (proves the axis-role resolution, not just the default TCZYX). +void test_omezarr_all_5d_permutations() +{ + for (const char* s : { "dim5.ome.zarr", "dim5_tzcyx.ome.zarr", "dim5_ctzyx.ome.zarr", + "dim5_cztyx.ome.zarr", "dim5_ztcyx.ome.zarr", "dim5_zctyx.ome.zarr" }) + { + test_omezarr_addressing(s, 2, 3, 4); + test_raw_omezarr_addressing(s, 2, 3, 4); + if (::testing::Test::HasFatalFailure()) return; + } +} + +// Negative: requesting a Z/C/T plane beyond the array extent must throw, not read +// out-of-bounds / wrong data. dim5.ome.zarr has T=2, C=3, Z=4. +void test_omezarr_out_of_range_throws() +{ + fs::path ds = omezarr_data_path("dim5.ome.zarr"); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + auto ldr = NyxusOmeZarrLoader(1, ds.string()); + auto tile = std::make_shared>(ldr.tileHeight(0) * ldr.tileWidth(0), 0u); + EXPECT_ANY_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 99, 0, 0)); // channel out of range + EXPECT_ANY_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 0, 99, 0)); // timeframe out of range + EXPECT_ANY_THROW(ldr.loadTileFromFile(tile, 0, 0, 99, 0, 0, 0)); // z out of range + + auto raw = RawOmezarrLoader(ds.string()); + EXPECT_ANY_THROW(raw.loadTileFromFile(0, 0, 0, 99, 0, 0)); + EXPECT_ANY_THROW(raw.loadTileFromFile(0, 0, 0, 0, 99, 0)); + EXPECT_ANY_THROW(raw.loadTileFromFile(0, 0, 99, 0, 0, 0)); +} + +// Illegal / adversarial: self-inconsistent metadata must be rejected cleanly +// (throw), not crash. bad_axes_count declares 5 axes for a 3D array (indexing the +// shape by axis role would read OOB); bad_no_xy has axes but none labeled x/y. +void test_omezarr_malformed_throws() +{ + for (const char* s : { "bad_axes_count.ome.zarr", "bad_no_xy.ome.zarr" }) + { + fs::path ds = omezarr_data_path(s); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + EXPECT_ANY_THROW(NyxusOmeZarrLoader(1, ds.string())) << s; + EXPECT_ANY_THROW(RawOmezarrLoader(ds.string())) << s; + } + // a store path that does not exist at all + EXPECT_ANY_THROW(RawOmezarrLoader(omezarr_data_path("does_not_exist.ome.zarr").string())); +} + #endif // OMEZARR_SUPPORT diff --git a/tests/test_tiff_loader_mechanics.h b/tests/test_tiff_loader_mechanics.h index 9ff33d5c1..6f1a6f9c3 100644 --- a/tests/test_tiff_loader_mechanics.h +++ b/tests/test_tiff_loader_mechanics.h @@ -83,7 +83,7 @@ inline void test_uint32_strip_tiff_loader() auto tile = std::make_shared>(tw * th * td); // Single tile at (row,col,layer,level) = (0,0,0,0) covers this small image. - loader.loadTileFromFile(tile, 0, 0, 0, 0); + loader.loadTileFromFile(tile, 0, 0, 0, 0/*channel*/, 0/*timeframe*/, 0); for (uint32_t y = 0; y < h; ++y) for (uint32_t x = 0; x < w; ++x) From 21e9598d860224cfc7c43d0e2326367810f8f425 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Fri, 17 Jul 2026 08:24:46 -0700 Subject: [PATCH 3/5] feat(io): per-channel/timeframe addressing, physical-spacing calibration, and Zarr v3/tiled OME-TIFF hardening Step 2 of the IO project: builds multi-dimensional addressing and physical calibration on top of the Step 1 native-read foundation, and hardens it with the fixes and edge-case coverage that came from exercising it against real fixtures. Adds per-channel/timeframe volumetric featurization with a c_index output column, opt-in physical-voxel-spacing calibration with phys_x/y/z/unit output columns, tiled multi-plane OME-TIFF (z,c,t)->IFD support, and native OME-Zarr 0.5 (Zarr v3) reads including zstd. The remaining commits fix correctness issues this surfaced: prescan out-of-bounds reads, CSV output overwriting itself across channels/ timeframes, partial edge tiles, Zarr v3 sharding/blosc/pyramidal reads, non-canonical OME-XML TiffData plane->IFD mapping, a multi-timeframe segmented OME-TIFF crash, and an end-to-end anisotropy-rescaling check. Absorbed commits (oldest to newest): 4d2afda feat(io): per-channel/timeframe volumetric featurization + c_index output column dd01c6e feat(io): opt-in physical-voxel-spacing calibration + phys_x/y/z/unit output columns 5839643 test(io): cover resolve_slide_anisotropy and out-of-range volume reads 9a24427 fix(io): reuse a single-channel mask across all intensity channels 7a19496 fix(io): pass use_physical_spacing to initialize_environment from the 3D Python classes c06a2d7 feat(io): tiled multi-plane OME-TIFF - (z,c,t)->IFD in the tile loaders 9157d34 feat(io): read OME-Zarr 0.5 (Zarr v3), incl. zstd - via z5, no tensorstore 4df18ba fix(3d): whole-volume featurization threw "illegal call" and produced no output 2c72a85 fix(3d): prescan read out-of-bounds garbage and covered only (c0,t0) 6f3ce30 test(python): drop index/calibration columns by name in the 3D IBSI check 6267d16 fix(io): assemble volumes from the whole tile grid, and stream the prescan 4e8766a fix(io): stop CSV output from overwriting itself once per channel/timeframe 66b5d31 test(io): cover partial edge tiles in the volumetric assembly b68bbb5 test(io): verify Zarr v3 sharding (sharding_indexed codec) reads end-to-end 3831675 fix(io): honor OME-XML plane->IFD mapping (non-canonical layouts) fd81d63 test(io): cover Zarr v3 blosc and pyramidal (SubIFD) OME-TIFF reads 302a4b7 fix(io): clamp mask timeframe -- multi-timeframe segmented OME-TIFF crashed 374bbd5 test(io): positive + negative edge coverage across the OME read surfaces 2c916fe test(io): assert anisotropy actually rescales ROI geometry end-to-end --- .gitignore | 5 + CMakeLists.txt | 14 +- src/nyx/abs_tile_loader.h | 8 + src/nyx/cli_option_constants.h | 1 + src/nyx/constants.h | 1 + src/nyx/environment.cpp | 9 + src/nyx/environment.h | 21 + src/nyx/features/3d_intensity.cpp | 4 +- src/nyx/features/3d_intensity.h | 7 +- src/nyx/globals.h | 47 +- src/nyx/grayscale_tiff.h | 75 ++- src/nyx/image_loader.cpp | 45 +- src/nyx/ome/ome_axes.h | 31 +- src/nyx/ome/ome_tiff_meta.cpp | 65 ++ src/nyx/ome/ome_tiff_meta.h | 7 +- src/nyx/omezarr.h | 84 ++- src/nyx/output_2_apache.cpp | 4 +- src/nyx/output_2_buffer.cpp | 39 +- src/nyx/output_2_csv.cpp | 128 ++-- src/nyx/output_writers.cpp | 129 +++- src/nyx/output_writers.h | 15 +- src/nyx/phase1.cpp | 4 +- src/nyx/phase2_3d.cpp | 90 ++- src/nyx/python/new_bindings_py.cpp | 4 +- src/nyx/python/nyxus/nyxus.py | 16 +- src/nyx/raw_format.h | 7 + src/nyx/raw_image_loader.cpp | 25 + src/nyx/raw_image_loader.h | 83 +++ src/nyx/raw_omezarr.h | 76 ++- src/nyx/raw_tiff.h | 83 ++- src/nyx/reduce_trivial_rois.cpp | 2 +- src/nyx/slideprops.cpp | 143 +++-- src/nyx/slideprops.h | 11 + src/nyx/workflow_2d_segmented.cpp | 9 +- src/nyx/workflow_2d_whole.cpp | 10 +- src/nyx/workflow_3d_segmented.cpp | 27 +- src/nyx/workflow_3d_whole.cpp | 87 +-- src/nyx/workflow_pythonapi.cpp | 10 +- tests/data/ometiff/dim3_emptymask.ome.tif | Bin 0 -> 1743 bytes tests/data/ometiff/dim3_mask.ome.tif | Bin 0 -> 1743 bytes tests/data/ometiff/dim4_mask_c2.ome.tif | Bin 0 -> 2859 bytes tests/data/ometiff/dim5_badifd.ome.tif | Bin 0 -> 1634 bytes tests/data/ometiff/dim5_calibrated.ome.tif | Bin 0 -> 7273 bytes tests/data/ometiff/dim5_multitile.ome.tif | Bin 0 -> 13939 bytes tests/data/ometiff/dim5_oddtile.ome.tif | Bin 0 -> 7347 bytes tests/data/ometiff/dim5_pyramid.ome.tif | Bin 0 -> 32223 bytes tests/data/ometiff/dim5_reordered.ome.tif | Bin 0 -> 2302 bytes tests/data/ometiff/dim5_tiled.ome.tif | Bin 0 -> 17702 bytes tests/data/ometiff/gen_ome_tiff.py | 182 ++++++ tests/data/omezarr/dim3_mask.ome.zarr/.zattrs | 36 ++ tests/data/omezarr/dim3_mask.ome.zarr/.zgroup | 3 + .../data/omezarr/dim3_mask.ome.zarr/0/.zarray | 19 + .../data/omezarr/dim3_mask.ome.zarr/0/.zattrs | 1 + tests/data/omezarr/dim3_mask.ome.zarr/0/0.0.0 | Bin 0 -> 192 bytes tests/data/omezarr/dim3_mask.ome.zarr/0/1.0.0 | Bin 0 -> 192 bytes tests/data/omezarr/dim3_mask.ome.zarr/0/2.0.0 | Bin 0 -> 192 bytes tests/data/omezarr/dim3_mask.ome.zarr/0/3.0.0 | Bin 0 -> 192 bytes .../omezarr/dim5_calibrated.ome.zarr/.zattrs | 49 ++ .../omezarr/dim5_calibrated.ome.zarr/.zgroup | 3 + .../dim5_calibrated.ome.zarr/0/.zarray | 23 + .../dim5_calibrated.ome.zarr/0/.zattrs | 1 + .../dim5_calibrated.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 576 bytes .../dim5_calibrated.ome.zarr/0/0.0.1.0.0 | Bin 0 -> 576 bytes .../dim5_calibrated.ome.zarr/0/0.0.2.0.0 | Bin 0 -> 576 bytes .../dim5_calibrated.ome.zarr/0/0.0.3.0.0 | Bin 0 -> 576 bytes .../omezarr/dim5_multichunk.ome.zarr/.zattrs | 46 ++ .../omezarr/dim5_multichunk.ome.zarr/.zgroup | 3 + .../dim5_multichunk.ome.zarr/0/.zarray | 23 + .../dim5_multichunk.ome.zarr/0/.zattrs | 1 + .../dim5_multichunk.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.0.0.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.0.1.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.0.1.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.1.0.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.1.0.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.1.1.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.1.1.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.2.0.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.2.0.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.2.1.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.2.1.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.3.0.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.3.0.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.3.1.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.0.3.1.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.1.0.0.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.1.0.0.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.1.0.1.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.1.0.1.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.1.1.0.0 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.1.1.0.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.1.1.1.0 | 2 + .../dim5_multichunk.ome.zarr/0/0.1.1.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.1.2.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.1.2.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.1.2.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.1.2.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.1.3.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.1.3.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.1.3.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.1.3.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.0.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.0.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.0.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.0.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.1.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.1.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.1.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.1.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.2.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.2.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.2.1.0 | 2 + .../dim5_multichunk.ome.zarr/0/0.2.2.1.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/0.2.3.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.3.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.3.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/0.2.3.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.0.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.0.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.0.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.0.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.1.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.1.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.1.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.1.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.2.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.2.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.2.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.2.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.3.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.3.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.3.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.0.3.1.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/1.1.0.0.0 | 2 + .../dim5_multichunk.ome.zarr/0/1.1.0.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.0.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.0.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.1.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.1.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.1.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.1.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.2.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.2.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.2.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.2.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.3.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.3.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.3.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.1.3.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.0.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.0.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.0.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.0.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.1.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.1.0.1 | Bin 0 -> 24 bytes .../dim5_multichunk.ome.zarr/0/1.2.1.1.0 | 2 + .../dim5_multichunk.ome.zarr/0/1.2.1.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.2.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.2.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.2.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.2.1.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.3.0.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.3.0.1 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.3.1.0 | 1 + .../dim5_multichunk.ome.zarr/0/1.2.3.1.1 | 1 + .../omezarr/dim5_oddchunk.ome.zarr/.zattrs | 46 ++ .../omezarr/dim5_oddchunk.ome.zarr/.zgroup | 3 + .../omezarr/dim5_oddchunk.ome.zarr/0/.zarray | 23 + .../omezarr/dim5_oddchunk.ome.zarr/0/.zattrs | 1 + .../dim5_oddchunk.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.0.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.0.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.0.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.1.0.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.1.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.1.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.1.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.2.0.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.2.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.2.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.2.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.3.0.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.3.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.3.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.0.3.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.0.0.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.0.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.0.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.0.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.1.0.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.1.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.1.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.1.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.2.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/0.1.2.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.2.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.2.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.3.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/0.1.3.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.3.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.1.3.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.0.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/0.2.0.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.0.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.0.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.1.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/0.2.1.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.1.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.1.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.2.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/0.2.2.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.2.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.2.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.3.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/0.2.3.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.3.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/0.2.3.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.0.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/1.0.0.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.0.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.0.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.1.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/1.0.1.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.1.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.1.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.2.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/1.0.2.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.2.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.2.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.3.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/1.0.3.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.3.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.0.3.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.0.0.0 | 2 + .../dim5_oddchunk.ome.zarr/0/1.1.0.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.0.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.0.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.1.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/1.1.1.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.1.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.1.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.2.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/1.1.2.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.2.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.2.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.3.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/1.1.3.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.3.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.1.3.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.0.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/1.2.0.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.0.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.0.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.1.0.0 | 2 + .../dim5_oddchunk.ome.zarr/0/1.2.1.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.1.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.1.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.2.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/1.2.2.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.2.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.2.1.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.3.0.0 | 1 + .../dim5_oddchunk.ome.zarr/0/1.2.3.0.1 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.3.1.0 | Bin 0 -> 40 bytes .../dim5_oddchunk.ome.zarr/0/1.2.3.1.1 | Bin 0 -> 40 bytes .../omezarr/dim5_v3.ome.zarr/0/c/0/0/0/0/0 | Bin 0 -> 576 bytes .../omezarr/dim5_v3.ome.zarr/0/c/0/0/1/0/0 | Bin 0 -> 576 bytes .../omezarr/dim5_v3.ome.zarr/0/c/0/0/2/0/0 | Bin 0 -> 576 bytes .../omezarr/dim5_v3.ome.zarr/0/c/0/0/3/0/0 | Bin 0 -> 576 bytes .../data/omezarr/dim5_v3.ome.zarr/0/zarr.json | 41 ++ tests/data/omezarr/dim5_v3.ome.zarr/zarr.json | 52 ++ .../dim5_v3_badcodec.ome.zarr/0/c/0/0/0/0/0 | Bin 0 -> 576 bytes .../dim5_v3_badcodec.ome.zarr/0/c/0/0/1/0/0 | Bin 0 -> 576 bytes .../dim5_v3_badcodec.ome.zarr/0/c/0/0/2/0/0 | Bin 0 -> 576 bytes .../dim5_v3_badcodec.ome.zarr/0/c/0/0/3/0/0 | Bin 0 -> 576 bytes .../dim5_v3_badcodec.ome.zarr/0/zarr.json | 1 + .../dim5_v3_badcodec.ome.zarr/zarr.json | 52 ++ .../dim5_v3_blosc.ome.zarr/0/c/0/0/0/0/0 | Bin 0 -> 257 bytes .../dim5_v3_blosc.ome.zarr/0/c/0/0/1/0/0 | Bin 0 -> 262 bytes .../dim5_v3_blosc.ome.zarr/0/c/0/0/2/0/0 | Bin 0 -> 262 bytes .../dim5_v3_blosc.ome.zarr/0/c/0/0/3/0/0 | Bin 0 -> 262 bytes .../dim5_v3_blosc.ome.zarr/0/zarr.json | 51 ++ .../omezarr/dim5_v3_blosc.ome.zarr/zarr.json | 52 ++ .../dim5_v3_sharded.ome.zarr/0/c/0/0/0/0/0 | Bin 0 -> 644 bytes .../dim5_v3_sharded.ome.zarr/0/c/0/1/0/0/0 | Bin 0 -> 644 bytes .../dim5_v3_sharded.ome.zarr/0/c/0/2/0/0/0 | Bin 0 -> 644 bytes .../dim5_v3_sharded.ome.zarr/0/c/1/0/0/0/0 | Bin 0 -> 644 bytes .../dim5_v3_sharded.ome.zarr/0/c/1/1/0/0/0 | Bin 0 -> 644 bytes .../dim5_v3_sharded.ome.zarr/0/c/1/2/0/0/0 | Bin 0 -> 644 bytes .../dim5_v3_sharded.ome.zarr/0/zarr.json | 67 ++ .../dim5_v3_sharded.ome.zarr/zarr.json | 52 ++ .../dim5_v3_zstd.ome.zarr/0/c/0/0/0/0/0 | Bin 0 -> 73 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/0/1/0/0 | Bin 0 -> 84 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/0/2/0/0 | Bin 0 -> 88 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/0/3/0/0 | Bin 0 -> 93 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/1/0/0/0 | Bin 0 -> 93 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/1/1/0/0 | Bin 0 -> 97 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/1/2/0/0 | Bin 0 -> 83 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/1/3/0/0 | Bin 0 -> 87 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/2/0/0/0 | Bin 0 -> 92 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/2/1/0/0 | Bin 0 -> 93 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/2/2/0/0 | Bin 0 -> 98 bytes .../dim5_v3_zstd.ome.zarr/0/c/0/2/3/0/0 | Bin 0 -> 80 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/0/0/0/0 | Bin 0 -> 85 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/0/1/0/0 | Bin 0 -> 89 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/0/2/0/0 | Bin 0 -> 92 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/0/3/0/0 | Bin 0 -> 94 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/1/0/0/0 | Bin 0 -> 73 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/1/1/0/0 | Bin 0 -> 84 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/1/2/0/0 | Bin 0 -> 89 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/1/3/0/0 | Bin 0 -> 92 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/2/0/0/0 | Bin 0 -> 93 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/2/1/0/0 | Bin 0 -> 97 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/2/2/0/0 | Bin 0 -> 82 bytes .../dim5_v3_zstd.ome.zarr/0/c/1/2/3/0/0 | Bin 0 -> 87 bytes .../omezarr/dim5_v3_zstd.ome.zarr/0/zarr.json | 48 ++ .../omezarr/dim5_v3_zstd.ome.zarr/zarr.json | 52 ++ tests/data/omezarr/gen_dim5.py | 150 +++++ tests/python/test_nyxus.py | 11 +- tests/test_3d_coverage_common.h | 4 +- tests/test_3d_firstorder_pyradiomics.h | 4 +- tests/test_3d_glcm.h | 4 +- tests/test_3d_glcm_pyradiomics.h | 8 +- tests/test_3d_gldm.h | 8 +- tests/test_3d_gldm_pyradiomics.h | 4 +- tests/test_3d_gldzm_ibsi.h | 8 +- tests/test_3d_glrlm.h | 10 +- tests/test_3d_glrlm_pyradiomics.h | 8 +- tests/test_3d_glszm.h | 8 +- tests/test_3d_glszm_pyradiomics.h | 4 +- tests/test_3d_inten.h | 4 +- tests/test_3d_morphology_common.h | 8 +- tests/test_3d_ngldm_regression.h | 8 +- tests/test_3d_ngtdm.h | 8 +- tests/test_3d_ngtdm_pyradiomics.h | 4 +- tests/test_all.cc | 591 ++++++++++++++++++ tests/test_arrow_mechanics.h | 56 +- tests/test_data.h | 18 +- tests/test_ome_meta.h | 72 +++ tests/test_ometiff_mechanics.h | 202 +++++- tests/test_omezarr_mechanics.h | 102 ++- 341 files changed, 3364 insertions(+), 392 deletions(-) create mode 100644 tests/data/ometiff/dim3_emptymask.ome.tif create mode 100644 tests/data/ometiff/dim3_mask.ome.tif create mode 100644 tests/data/ometiff/dim4_mask_c2.ome.tif create mode 100644 tests/data/ometiff/dim5_badifd.ome.tif create mode 100644 tests/data/ometiff/dim5_calibrated.ome.tif create mode 100644 tests/data/ometiff/dim5_multitile.ome.tif create mode 100644 tests/data/ometiff/dim5_oddtile.ome.tif create mode 100644 tests/data/ometiff/dim5_pyramid.ome.tif create mode 100644 tests/data/ometiff/dim5_reordered.ome.tif create mode 100644 tests/data/ometiff/dim5_tiled.ome.tif create mode 100644 tests/data/omezarr/dim3_mask.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim3_mask.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim3_mask.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim3_mask.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim3_mask.ome.zarr/0/0.0.0 create mode 100644 tests/data/omezarr/dim3_mask.ome.zarr/0/1.0.0 create mode 100644 tests/data/omezarr/dim3_mask.ome.zarr/0/2.0.0 create mode 100644 tests/data/omezarr/dim3_mask.ome.zarr/0/3.0.0 create mode 100644 tests/data/omezarr/dim5_calibrated.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5_calibrated.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5_calibrated.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5_calibrated.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5_calibrated.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_calibrated.ome.zarr/0/0.0.1.0.0 create mode 100644 tests/data/omezarr/dim5_calibrated.ome.zarr/0/0.0.2.0.0 create mode 100644 tests/data/omezarr/dim5_calibrated.ome.zarr/0/0.0.3.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.0.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.0.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.0.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.2.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.2.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.2.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.2.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.3.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.3.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.3.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.3.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.1.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.0.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.0.1 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.1.0 create mode 100644 tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.0.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.0.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.0.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.1.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.1.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.1.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.1.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.2.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.2.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.2.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.2.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.3.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.3.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.3.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.3.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.0.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.0.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.0.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.0.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.0.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.0.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.0.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.0.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.1.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.0.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.0.1 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.1.0 create mode 100644 tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.1.1 create mode 100644 tests/data/omezarr/dim5_v3.ome.zarr/0/c/0/0/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3.ome.zarr/0/c/0/0/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3.ome.zarr/0/c/0/0/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3.ome.zarr/0/c/0/0/3/0/0 create mode 100644 tests/data/omezarr/dim5_v3.ome.zarr/0/zarr.json create mode 100644 tests/data/omezarr/dim5_v3.ome.zarr/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/c/0/0/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/c/0/0/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/c/0/0/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/c/0/0/3/0/0 create mode 100644 tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_badcodec.ome.zarr/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/c/0/0/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/c/0/0/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/c/0/0/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/c/0/0/3/0/0 create mode 100644 tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_blosc.ome.zarr/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/0/0/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/0/1/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/0/2/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/1/0/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/1/1/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/1/2/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_sharded.ome.zarr/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/0/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/0/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/0/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/0/3/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/1/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/1/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/1/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/1/3/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/2/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/2/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/2/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/2/3/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/0/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/0/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/0/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/0/3/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/1/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/1/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/1/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/1/3/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/2/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/2/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/2/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/2/3/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_zstd.ome.zarr/zarr.json diff --git a/.gitignore b/.gitignore index 6ab2335fa..2492cef45 100644 --- a/.gitignore +++ b/.gitignore @@ -566,3 +566,8 @@ cython_debug/ # setuptools-scm generated version file src/nyx/python/nyxus/_version.py .planning/ + +# Feature tables written by the test suites into the tree +NyxusFeatures.arrow +NyxusFeatures.parquet +NyxusFeatures.csv diff --git a/CMakeLists.txt b/CMakeLists.txt index ae0a70163..05cdd1182 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -279,7 +279,19 @@ if(USE_Z5) include_directories (${ZLIB_INCLUDE_DIR}) add_definitions(-DHAVE_ZLIB) # telling NIFTI code of zlib availability, otherwise it'll support only uncompressed NIFTI endif() - + + # zstd: the DEFAULT codec of Zarr v3 (OME-Zarr 0.5). z5's ZstdCompressor is gated by + # WITH_ZSTD; without it, zstd-compressed v3 chunks fail to decode. Optional: if zstd + # isn't found we still read uncompressed / blosc / zlib v3 stores. + find_library(ZSTD_LIBRARY NAMES zstd libzstd) + if(ZSTD_LIBRARY) + add_definitions(-DWITH_ZSTD) + list(APPEND Nyxus_LIBRARIES ${ZSTD_LIBRARY}) + message(STATUS "zstd found (${ZSTD_LIBRARY}) -> Zarr v3 zstd codec enabled") + else() + message(STATUS "zstd not found -> Zarr v3 zstd-compressed chunks will be unsupported") + endif() + if(nlohmann_json_FOUND) include_directories(${nlohmann_json_INCLUDE_DIR}) endif() diff --git a/src/nyx/abs_tile_loader.h b/src/nyx/abs_tile_loader.h index 60d745819..aa6544b22 100644 --- a/src/nyx/abs_tile_loader.h +++ b/src/nyx/abs_tile_loader.h @@ -74,6 +74,14 @@ class AbstractTileLoader return 1; } + /// \brief Physical voxel spacing along X/Y/Z (default 1.0 == uncalibrated). + /// OME loaders return the parsed PhysicalSize*; everything else stays 1.0. + [[nodiscard]] virtual double physicalSizeX() const { return 1.0; } + [[nodiscard]] virtual double physicalSizeY() const { return 1.0; } + [[nodiscard]] virtual double physicalSizeZ() const { return 1.0; } + /// \brief Physical-size unit string (e.g. "micrometer"); empty if unknown. + [[nodiscard]] virtual std::string physicalSizeUnit() const { return std::string(); } + /// \brief Getter to tile Width /// @param level tile's level considered /// \return Tile Width diff --git a/src/nyx/cli_option_constants.h b/src/nyx/cli_option_constants.h index 266e9875e..da2bc546a 100644 --- a/src/nyx/cli_option_constants.h +++ b/src/nyx/cli_option_constants.h @@ -69,6 +69,7 @@ #define clo_AGGREGATE "--aggr" // -> raw_aggregate #define clo_ANNOTATE "--annot" // -> raw_annotate #define clo_ANNOT_SEP "--annotsep" // -> raw_anno_separator +#define clo_USE_PHYSICAL_SPACING "--use-physical-spacing" // -> rawUsePhysicalSpacing (true/false); opt-in OME PhysicalSize* voxel spacing // Valid values of 'OUTPUTTYPE' #define clo_OT_SEPCSV "separatecsv" diff --git a/src/nyx/constants.h b/src/nyx/constants.h index f833602f2..91fc0e3c1 100644 --- a/src/nyx/constants.h +++ b/src/nyx/constants.h @@ -1,4 +1,5 @@ #pragma once constexpr double DEFAULT_T_INDEX = 0.0; +constexpr double DEFAULT_C_INDEX = 0.0; // FIX (IO): default channel index for single-channel / non-OME input constexpr int DEFAULT_NUM_HISTO_BINS = 24; diff --git a/src/nyx/environment.cpp b/src/nyx/environment.cpp index f6f419daf..40c4631b5 100644 --- a/src/nyx/environment.cpp +++ b/src/nyx/environment.cpp @@ -473,6 +473,7 @@ bool Environment::parse_cmdline(int argc, char** argv) || find_string_argument(i, clo_FPIMAGE_MIN, fpimageOptions.raw_min_intensity) || find_string_argument(i, clo_FPIMAGE_MAX, fpimageOptions.raw_max_intensity) || find_string_argument(i, clo_PRESERVE_HU, fpimageOptions.raw_preserve_hu) // CT/HU mode + || find_string_argument(i, clo_USE_PHYSICAL_SPACING, rawUsePhysicalSpacing) // FIX (IO): opt-in physical voxel spacing || find_string_argument(i, clo_RESULTFNAME, nyxus_result_fname) || find_string_argument(i, clo_CLI_DIM, raw_dim) @@ -799,6 +800,14 @@ bool Environment::parse_cmdline(int argc, char** argv) } } + //==== Parse opt-in physical-spacing flag (FIX (IO); default false) + if (!rawUsePhysicalSpacing.empty()) + { + std::string v = rawUsePhysicalSpacing; + std::transform(v.begin(), v.end(), v.begin(), ::tolower); + use_physical_spacing_ = (v == "true" || v == "t" || v == "1" || v == "on" || v == "yes"); + } + //==== Parse exclusive-inclusive timing #ifdef CHECKTIMING if (!rawExclusiveTiming.empty()) diff --git a/src/nyx/environment.h b/src/nyx/environment.h index a07e3352a..d65a76259 100644 --- a/src/nyx/environment.h +++ b/src/nyx/environment.h @@ -2,6 +2,7 @@ #include #include +#include // FIX: csv_paths_written registry (see below) #include #include "arrow_output_stream.h" @@ -80,6 +81,18 @@ class Environment: public BasicEnvironment std::string rawOutpType; //= ""; // Valid values: "separatecsv", "singlecsv", "arrow", "parquet" bool separateCsv; //= true; + // FIX: CSV output paths already written during this run. A slide now yields one row per + // (channel, timeframe), and the CSV sinks are called once per plane -- but in separatecsv + // mode they opened the same path with "w" every time, so each plane truncated the previous + // and only the LAST one survived. The first write to a path truncates and renders the + // header; later writes append. Held per-Environment rather than in a function-static so a + // long-lived Python session starts each run clean instead of appending to a stale file. + std::unordered_set csv_paths_written; + + // True the first time `path` is written in this run (and records it). + bool csv_claim_first_write (const std::string& path) { return csv_paths_written.insert(path).second; } + void reset_csv_output_state() { csv_paths_written.clear(); } + Nyxus::SaveOption saveOption; // x- and y- resolution in pixels per centimeter @@ -87,6 +100,14 @@ class Environment: public BasicEnvironment float xyRes; //= 0.0, float pixelSizeUm; //= 0.0; + // FIX (IO): opt-in physical-voxel-spacing calibration. When set, the 3D pipeline uses + // each slide's OME PhysicalSize* (ratio-normalized) as voxel spacing (anisotropy). + // Off by default -> today's cube-voxel behavior is unchanged. Spacing is still emitted + // as phys_x/y/z + phys_unit output columns regardless of this flag. + std::string rawUsePhysicalSpacing; //= ""; + bool use_physical_spacing_ = false; + bool use_physical_spacing() const { return use_physical_spacing_; } + int get_pixel_distance(); void set_pixel_distance(int pixelDistance); size_t get_ram_limit(); diff --git a/src/nyx/features/3d_intensity.cpp b/src/nyx/features/3d_intensity.cpp index 2777609fb..21d8b4d2b 100644 --- a/src/nyx/features/3d_intensity.cpp +++ b/src/nyx/features/3d_intensity.cpp @@ -367,10 +367,10 @@ void D3_VoxelIntensityFeatures::reduce (size_t start, size_t end, std::vector>& feature_vals); static void reduce (size_t start, size_t end, std::vector* ptrLabels, std::unordered_map * ptrLabelData, const Fsettings & s, const Dataset & ds); - static void extract (LR& r, const Fsettings& s); + // FIX: extract() must carry the Dataset, because calculate() needs it for + // COVERED_IMAGE_INTENSITY_RANGE (slide extrema via ds.dataset_props[r.slide_idx]). + // The old 2-arg extract called the 2-arg calculate, which is a stub that throws + // "illegal call" -- that broke ALL 3D whole-volume featurization (the segmented path + // goes through reduce(), which passes the Dataset, so it never hit this). + static void extract (LR& r, const Fsettings& s, const Dataset& ds); void cleanup_instance(); // list dependencies of this class diff --git a/src/nyx/globals.h b/src/nyx/globals.h index b72938db2..929384eea 100644 --- a/src/nyx/globals.h +++ b/src/nyx/globals.h @@ -38,7 +38,12 @@ namespace Nyxus const char colname_intensity_image[] = "intensity_image", colname_mask_image[] = "mask_image", colname_roi_label[] = "ROI_label", - colname_t_index[] = "t_index"; + colname_t_index[] = "t_index", + colname_c_index[] = "c_index", // FIX (IO): channel index column, mirrors t_index + colname_phys_unit[] = "phys_unit", // FIX (IO): physical-size unit (leading string column) + colname_phys_x[] = "phys_x", // FIX (IO): physical voxel spacing columns (numeric) + colname_phys_y[] = "phys_y", + colname_phys_z[] = "phys_z"; // segmented 2D workflow int processDataset_2D_segmented( @@ -78,18 +83,25 @@ namespace Nyxus bool gatherRoisMetrics(int slide_idx, const std::string& intens_fpath, const std::string& label_fpath, Environment & env, ImageLoader & L); bool gather_wholeslide_metrics(const std::string& intens_fpath, ImageLoader& L, LR& roi); bool gatherRoisMetrics_25D (Environment& env, size_t sidx, const std::string& intens_fpath, const std::string& mask_fpath, const std::vector& z_indices); - bool gatherRoisMetrics_3D (Environment& env, size_t sidx, const std::string& intens_fpath, const std::string& mask_fpath, size_t t_index); + bool gatherRoisMetrics_3D (Environment& env, size_t sidx, const std::string& intens_fpath, const std::string& mask_fpath, size_t t_index, size_t channel); bool processTrivialRois (Environment& env, const std::vector& trivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath, size_t memory_limit); bool processTrivialRois_25D (Environment & env, const std::vector& trivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath, size_t memory_limit, const std::vector& z_indices); - bool processTrivialRois_3D (Environment & env, size_t sidx, size_t t_index, const std::vector& trivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath, size_t memory_limit); + bool processTrivialRois_3D (Environment & env, size_t sidx, size_t t_index, size_t channel, const std::vector& trivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath, size_t memory_limit); bool processNontrivialRois (Environment& env, const std::vector& nontrivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath); bool scan_trivial_wholeslide (LR& vroi, const std::string& intens_fpath, ImageLoader& ldr); // reads pixels of whole slide 'intens_fpath' into virtual ROI 'vroi' bool scan_trivial_wholeslide_anisotropic (LR& vroi, const std::string& intens_fpath, ImageLoader& ldr, double aniso_x, double aniso_y); - bool scan_trivial_wholevolume (LR& vroi, const std::string& intens_fpath, ImageLoader& ldr); - bool scan_trivial_wholevolume_anisotropic (LR& vroi, const std::string& intens_fpath, ImageLoader& ldr, double aniso_x, double aniso_y, double aniso_z); + // FIX (IO): resolve the effective 3D voxel spacing for slide `sidx` and whether the + // anisotropic (resampling) scan path should run. Explicit --aniso* wins; else, when + // --use-physical-spacing is on, the slide's OME PhysicalSize* ratio-normalized (min=1). + // Returns false (and ax=ay=az=1) when the grid is effectively isotropic. + bool resolve_slide_anisotropy (const Environment& env, size_t sidx, double& ax, double& ay, double& az); - bool scanTrivialRois_3D (Environment& env, const std::vector& batch_labels, const std::string& intens_fpath, const std::string& label_fpath, size_t t_index); + // FIX (IO): channel/timeframe select which C/T plane the whole-volume read assembles (was pinned to c=0,t=0) + bool scan_trivial_wholevolume (LR& vroi, const std::string& intens_fpath, ImageLoader& ldr, size_t channel, size_t timeframe); + bool scan_trivial_wholevolume_anisotropic (LR& vroi, const std::string& intens_fpath, ImageLoader& ldr, double aniso_x, double aniso_y, double aniso_z, size_t channel, size_t timeframe); + + bool scanTrivialRois_3D (Environment& env, const std::vector& batch_labels, const std::string& intens_fpath, const std::string& label_fpath, size_t t_index, size_t channel); void dump_roi_metrics (const int dim, const std::string& output_dir, const size_t ram_limit, const std::string& seg_fpath, const Uniqueids& uniqueLabels, const Roidata& roiData); void dump_roi_pixels (const int dim, const std::string& output_dir, const std::vector& batch_labels, const std::string& seg_fpath, const Uniqueids& uniqueLabels, const Roidata& roiData); void dump_2d_image_with_halfcontour( @@ -132,30 +144,37 @@ namespace Nyxus // 2 scenarios of saving a result of feature calculation of a label-intensity file pair: saving to a CSV-file and saving to a matrix to be later consumed by a Python endpoint std::string get_feature_output_fname (Environment& env, const std::string& intFpath, const std::string& segFpath); extern const std::vector mandatory_output_columns; - bool save_features_2_csv (Environment & env, const std::string & intFpath, const std::string & segFpath, const std::string & outputDir, size_t t_index, bool need_aggregation); - bool save_features_2_csv_wholeslide (Environment & env, const LR & r, const std::string & ifpath, const std::string & mfpath, const std::string & outdir, size_t t_index); - bool save_features_2_buffer (ResultsCache &results_cache, Environment &env, size_t t_index); + // FIX (IO): thread c_index alongside t_index so the output carries the channel column + bool save_features_2_csv (Environment & env, const std::string & intFpath, const std::string & segFpath, const std::string & outputDir, size_t t_index, size_t c_index, bool need_aggregation); + bool save_features_2_csv_wholeslide (Environment & env, const LR & r, const std::string & ifpath, const std::string & mfpath, const std::string & outdir, size_t t_index, size_t c_index); + bool save_features_2_buffer (ResultsCache &results_cache, Environment &env, size_t t_index, size_t c_index); bool save_features_2_buffer_wholeslide( ResultsCache& rescache, - Environment& env, + Environment& env, const LR& r, const std::string& ifpath, - const std::string& mfpath); - std::tuple> save_features_2_apache_wholeslide (Environment & env, const LR & wsi_roi, const std::string & wsi_path); + const std::string& mfpath, + size_t t_index, + size_t c_index); + std::tuple> save_features_2_apache_wholeslide (Environment & env, const LR & wsi_roi, const std::string & wsi_path, size_t t_index, size_t c_index); std::vector get_feature_values ( Environment & env, const FeatureSet & user_selected_features, const Uniqueids & uniqueLabels, const Roidata & roiData, - const Dataset & dataset); + const Dataset & dataset, + size_t t_index, + size_t c_index); std::vector get_feature_values_roi ( Environment & env, const FeatureSet & fset, const LR& r, const std::string & ifpath, - const std::string & mfpath); + const std::string & mfpath, + size_t t_index, + size_t c_index); std::vector get_header (Environment & env); std::string get_arrow_filename(const std::string& output_path, const std::string& default_filename, const SaveOption& arrow_file_type); diff --git a/src/nyx/grayscale_tiff.h b/src/nyx/grayscale_tiff.h index d6b253714..6c6b0caf0 100644 --- a/src/nyx/grayscale_tiff.h +++ b/src/nyx/grayscale_tiff.h @@ -71,18 +71,38 @@ class NyxusGrayscaleTiffTileLoader : public AbstractTileLoader TIFFGetField(tiff_, TIFFTAG_SAMPLESPERPIXEL, &samplesPerPixel); TIFFGetField(tiff_, TIFFTAG_BITSPERSAMPLE, &(this->bitsPerSample_)); TIFFGetField(tiff_, TIFFTAG_SAMPLEFORMAT, &(this->sampleFormat_)); + // FIX (IO): SAMPLEFORMAT is optional; TIFF's default is 1 (unsigned int). tifffile + // omits it for uint images, leaving sampleFormat_=0 -> "format not supported". Mirror + // the strip loader's defaulting so tiled uint OME-TIFFs read. + if (sampleFormat_ < 1 || sampleFormat_ > 3) + sampleFormat_ = 1; // Test if the file is greyscale - if (samplesPerPixel != 1) + if (samplesPerPixel != 1) { std::stringstream message; message << "Tile Loader ERROR: The file is not greyscale: SamplesPerPixel = " << samplesPerPixel << "."; throw (std::runtime_error(message.str())); } + + // FIX (IO): a TILED multi-plane OME-TIFF carries its (z,c,t) layout in the IFD-0 + // OME-XML just like the strip variant. Parse it so loadTileFromFile addresses the + // right IFD per (z,c,t); a plain single-plane tiled TIFF stays 2D (fullDepth 1). + fullDepth_ = TIFFNumberOfDirectories(tiff_); + char* desc = nullptr; + if (TIFFGetField(tiff_, TIFFTAG_IMAGEDESCRIPTION, &desc) && desc != nullptr) + { + ome_ = Nyxus::parse_ome_xml(desc); + if (ome_.valid) + { + is_ome_ = true; + fullDepth_ = ome_.sizeZ; // depth is SizeZ, NOT the total IFD count (= Z*C*T) + } + } } - else - { - throw (std::runtime_error("Tile Loader ERROR: The file can not be opened.")); + else + { + throw (std::runtime_error("Tile Loader ERROR: The file can not be opened.")); } } @@ -105,13 +125,28 @@ class NyxusGrayscaleTiffTileLoader : public AbstractTileLoader void loadTileFromFile(std::shared_ptr> tile, size_t indexRowGlobalTile, size_t indexColGlobalTile, - size_t indexLayerGlobalTile, - [[maybe_unused]] size_t indexChannel, // plain 2D TIFF: single channel - [[maybe_unused]] size_t indexTimeframe, // plain 2D TIFF: no time series + size_t indexLayerGlobalTile, // Z plane + size_t indexChannel, // C plane (OME); 0 for plain 2D TIFF + size_t indexTimeframe, // T plane (OME); 0 for plain 2D TIFF size_t level) override { std::string err; + // FIX (IO): select the (z,c,t) plane's IFD before reading the tile. A tiled OME-TIFF + // stores one plane per IFD in DimensionOrder raster order (ifdForPlane); a plain tiled + // TIFF maps layer->IFD directly. Range-guard OME so an out-of-range plane throws instead + // of reading the wrong IFD or zeros. + if (is_ome_ && (indexLayerGlobalTile >= ome_.sizeZ + || indexChannel >= ome_.sizeC || indexTimeframe >= ome_.sizeT)) + { + throw std::runtime_error("NyxusGrayscaleTiffTileLoader: (z,c,t)=(" + std::to_string(indexLayerGlobalTile) + + "," + std::to_string(indexChannel) + "," + std::to_string(indexTimeframe) + ") out of range"); + } + size_t ifd = is_ome_ ? ome_.ifdForPlane(indexLayerGlobalTile, indexChannel, indexTimeframe) + : indexLayerGlobalTile; + if (TIFFSetDirectory(tiff_, (uint16_t)ifd) != 1) + throw std::runtime_error("NyxusGrayscaleTiffTileLoader: TIFFSetDirectory(ifd=" + std::to_string(ifd) + ") failed"); + // Get ahold of the logical (feature extraction facing) tile buffer from its smart pointer std::vector& tileDataVec = *tile; @@ -227,6 +262,16 @@ class NyxusGrayscaleTiffTileLoader : public AbstractTileLoader /// @return 1 [[nodiscard]] size_t numberPyramidLevels() const override { return 1; } + // FIX (IO): a tiled multi-plane OME-TIFF advertises its Z depth + C/T extents + physical + // spacing from the parsed OME-XML, just like the strip variant (1 / plain values otherwise). + [[nodiscard]] size_t fullDepth([[maybe_unused]] size_t level) const override { return fullDepth_; } + [[nodiscard]] size_t numberChannels() const override { return is_ome_ ? ome_.sizeC : 1; } + [[nodiscard]] size_t fullTimestamps([[maybe_unused]] size_t level) const override { return is_ome_ ? ome_.sizeT : 1; } + [[nodiscard]] double physicalSizeX() const override { return is_ome_ ? ome_.physX : 1.0; } + [[nodiscard]] double physicalSizeY() const override { return is_ome_ ? ome_.physY : 1.0; } + [[nodiscard]] double physicalSizeZ() const override { return is_ome_ ? ome_.physZ : 1.0; } + [[nodiscard]] std::string physicalSizeUnit() const override { return is_ome_ ? ome_.unitXY : std::string(); } + private: #if 0 // A faster implementation is available. Keeping this for records. @@ -362,9 +407,13 @@ class NyxusGrayscaleTiffTileLoader : public AbstractTileLoader size_t fullHeight_ = 0, ///< Full height in pixel fullWidth_ = 0, ///< Full width in pixel + fullDepth_ = 1, ///< Full depth (Z) in planes; >1 for multi-plane OME-TIFF tileHeight_ = 0, ///< Tile height tileWidth_ = 0; ///< Tile width + bool is_ome_ = false; ///< true when IFD-0 carries an OME-XML block + Nyxus::OmeAxes ome_; ///< parsed OME dimensions (drives the (z,c,t)->IFD map) + short sampleFormat_ = 0, ///< Sample format as defined by libtiff bitsPerSample_ = 0; ///< Bit Per Sample as defined by libtiff @@ -617,6 +666,18 @@ class NyxusGrayscaleTiffStripLoader : public AbstractTileLoader /// @return Full Depth [[nodiscard]] size_t fullDepth([[maybe_unused]] size_t level) const override { return fullDepth_; } + // FIX: advertise C/T extents from the parsed OME-XML so the volumetric pipeline + // iterates channels/timeframes; non-OME TIFF keeps the single-plane default of 1. + /// @brief Channel (C) extent from OME-XML (1 for plain TIFF) + [[nodiscard]] size_t numberChannels() const override { return is_ome_ ? ome_.sizeC : 1; } + /// @brief Time (T) extent from OME-XML (1 for plain TIFF) + [[nodiscard]] size_t fullTimestamps([[maybe_unused]] size_t level) const override { return is_ome_ ? ome_.sizeT : 1; } + // FIX (IO): physical voxel spacing from OME-XML PhysicalSize* (1.0 for plain TIFF) + [[nodiscard]] double physicalSizeX() const override { return is_ome_ ? ome_.physX : 1.0; } + [[nodiscard]] double physicalSizeY() const override { return is_ome_ ? ome_.physY : 1.0; } + [[nodiscard]] double physicalSizeZ() const override { return is_ome_ ? ome_.physZ : 1.0; } + [[nodiscard]] std::string physicalSizeUnit() const override { return is_ome_ ? ome_.unitXY : std::string(); } + /// @brief Tiff tile width /// @param level Tiff level [not used] /// @return Tile width diff --git a/src/nyx/image_loader.cpp b/src/nyx/image_loader.cpp index e9d8236f0..940e812aa 100644 --- a/src/nyx/image_loader.cpp +++ b/src/nyx/image_loader.cpp @@ -281,17 +281,36 @@ void ImageLoader::assemble_volume (AbstractTileLoader* fl, const size_t frameStride = ltd * th * tw; const size_t frameBase = (ltt > 1) ? timeframe * frameStride : 0; + // FIX: walk the whole tile GRID of each plane, not just tile (0,0). A plane commonly + // spans several tiles/chunks (OME-Zarr chunks are typically 512x512, tiled OME-TIFF + // 256x256), and the old code read tile (0,0) once and then copied fh*fw out of that + // single tile's buffer -- wrong data past the first tile plus an out-of-bounds read. + // Edge tiles are partial, so each tile contributes only its valid [validH x validW]. + const size_t lnth = fl->numberTileHeight (lvl); + const size_t lntw = fl->numberTileWidth (lvl); + for (size_t lz = 0; lz < lntd; ++lz) { - fl->loadTileFromFile (ptr, 0, 0, lz, channel, timeframe, lvl); - for (size_t pz = 0; pz < ltd && (lz * ltd + pz) < fd; ++pz) + for (size_t tr = 0; tr < lnth; ++tr) + for (size_t tc = 0; tc < lntw; ++tc) { - const size_t gz = lz * ltd + pz; - for (size_t row = 0; row < fh; ++row) + fl->loadTileFromFile (ptr, tr, tc, lz, channel, timeframe, lvl); + + const size_t row0 = tr * th, col0 = tc * tw; + if (row0 >= fh || col0 >= fw) + continue; + const size_t validH = (std::min) (th, fh - row0), + validW = (std::min) (tw, fw - col0); + + for (size_t pz = 0; pz < ltd && (lz * ltd + pz) < fd; ++pz) { - const size_t src = frameBase + (pz * th + row) * tw; - const size_t d = gz * sliceSize + row * fw; - std::copy (ptr->begin() + src, ptr->begin() + src + fw, dst.begin() + d); + const size_t gz = lz * ltd + pz; + for (size_t row = 0; row < validH; ++row) + { + const size_t src = frameBase + (pz * th + row) * tw; + const size_t d = gz * sliceSize + (row0 + row) * fw + col0; + std::copy (ptr->begin() + src, ptr->begin() + src + validW, dst.begin() + d); + } } } } @@ -312,7 +331,17 @@ bool ImageLoader::load_volume (size_t channel, size_t timeframe, size_t mask_tim { if (vol_seg_.size() != volSize) vol_seg_.resize (volSize); - assemble_volume (segFL, ptrL, vol_seg_, channel, mask_timeframe); // mask may be on a different frame + // FIX (IO): the mask is usually channel-agnostic (a single-channel segmentation that + // applies to every intensity channel), so index into the mask's OWN channels only when + // it actually has that many; otherwise fall back to channel 0. Without this, featurizing + // intensity channel c>0 against a 1-channel mask read the mask out of range and dropped + // the ROI. Mirrors the mask_timeframe separation for the 1-mask : N-intensity case. + size_t mask_channel = (channel < segFL->numberChannels()) ? channel : 0; + // FIX: clamp the mask TIMEFRAME too (callers already pass 0 for a single-timeframe mask, + // but make load_volume self-defending like it is for the channel -- an unclamped t>0 + // would read the TIFF mask past its last IFD and throw uncaught). Symmetric with above. + size_t mask_tf = (mask_timeframe < segFL->fullTimestamps (lvl)) ? mask_timeframe : 0; + assemble_volume (segFL, ptrL, vol_seg_, mask_channel, mask_tf); } return true; } diff --git a/src/nyx/ome/ome_axes.h b/src/nyx/ome/ome_axes.h index e064ddea8..ca0d09346 100644 --- a/src/nyx/ome/ome_axes.h +++ b/src/nyx/ome/ome_axes.h @@ -120,6 +120,18 @@ namespace Nyxus std::vector levels; // >=1; levels[0] full-res std::vector channelNames; // optional (OME 'Channel'/omero) + // Explicit plane-ordinal -> IFD map from the OME elements, indexed by the + // canonical DimensionOrder ordinal (see canonicalPlaneOrdinal). Empty when the OME-XML + // carries no TiffData (or none that maps same-file planes), in which case the plane + // ordinal IS the IFD (the OME default: planes stored contiguously from IFD 0 in + // DimensionOrder). A writer that offsets the first IFD or reorders planes populates this. + std::vector planeToIfd; + + // True when a names planes in another file (a child): companion / + // multi-file OME-TIFF, which this reader does not resolve. Those planes are left at the + // canonical fallback; the flag lets callers detect the unsupported layout. + bool multiFileTiff = false; + bool valid = false; // set true by a successful parse // Position of a label in storageAxes; -1 if absent. @@ -130,10 +142,10 @@ namespace Nyxus return -1; } - // Map a (z,c,t) plane to its IFD / plane ordinal under the OME - // DimensionOrder (default rasterization: the axes after XY, fastest to - // slowest). E.g. XYZCT -> ifd = z + c*sizeZ + t*sizeZ*sizeC. - std::size_t ifdForPlane(std::size_t z, std::size_t c, std::size_t t) const + // The plane's ordinal under the OME DimensionOrder (default rasterization: the axes + // after XY, fastest to slowest). E.g. XYZCT -> z + c*sizeZ + t*sizeZ*sizeC. This is the + // IFD only when planes are stored contiguously from IFD 0; TiffData can say otherwise. + std::size_t canonicalPlaneOrdinal(std::size_t z, std::size_t c, std::size_t t) const { std::size_t idx = 0, stride = 1; for (char ax : omeDimensionOrder) @@ -147,6 +159,17 @@ namespace Nyxus return idx; } + // Map a (z,c,t) plane to the physical IFD holding its pixels. Honors an explicit + // plane->IFD map when present (writers that start at a non-zero IFD or + // reorder planes); otherwise falls back to the canonical ordinal. + std::size_t ifdForPlane(std::size_t z, std::size_t c, std::size_t t) const + { + std::size_t ord = canonicalPlaneOrdinal(z, c, t); + if (!planeToIfd.empty() && ord < planeToIfd.size()) + return planeToIfd[ord]; + return ord; + } + std::size_t numberPyramidLevels() const { return levels.empty() ? 1 : levels.size(); } bool isVolumetric() const { return sizeZ > 1; } bool isMultiChannel() const { return sizeC > 1; } diff --git a/src/nyx/ome/ome_tiff_meta.cpp b/src/nyx/ome/ome_tiff_meta.cpp index c648fde74..9b9b786f9 100644 --- a/src/nyx/ome/ome_tiff_meta.cpp +++ b/src/nyx/ome/ome_tiff_meta.cpp @@ -97,6 +97,71 @@ namespace Nyxus if (std::count(dord.begin(), dord.end(), c) != 1) perm = false; ax.omeDimensionOrder = perm ? dord : "XYZCT"; + // Explicit plane->IFD mapping from (OME-XML). Absent it, planes are stored + // contiguously from IFD 0 in DimensionOrder (the ifdForPlane default), which is what + // tifffile's single "" also means -- so this only changes + // behavior for writers that start at a non-zero IFD or reorder planes (e.g. bioformats + // per-plane blocks, or a multi-image container). Each block maps PlaneCount consecutive + // DimensionOrder planes starting at (FirstZ,FirstC,FirstT) to consecutive IFDs from IFD. + { + const std::size_t totalPlanes = ax.sizeZ * ax.sizeC * ax.sizeT; + // integer attribute of a start-tag, with default (same guards as `i` above) + auto iat = [](const std::string& tag, const char* n, std::size_t def) -> std::size_t { + std::string v; if (!get_attr(tag, n, v)) return def; + std::size_t r = 0; + auto [ptr, ec] = std::from_chars(v.data(), v.data() + v.size(), r); + return (ec != std::errc() || ptr == v.data()) ? def : r; + }; + + bool anyTiffData = false, sawMultiFile = false; + std::vector map; // identity-initialized on first same-file block + std::size_t tp = 0; + while ((tp = xml.find("' || after == '/')) + { tp += 9; continue; } + std::size_t gt = xml.find('>', tp); + if (gt == std::string::npos) break; + const std::string tag = xml.substr(tp, gt - tp + 1); + anyTiffData = true; + + // A with a child names a plane in ANOTHER file (companion / + // multi-file OME-TIFF) -- unsupported here. Detect it (tag not self-closed AND a + // ) and skip mapping it, leaving those planes at + // the canonical fallback rather than pointing them at a wrong local IFD. + bool selfClosed = (gt > 0 && xml[gt - 1] == '/'); + if (!selfClosed) + { + std::size_t close = xml.find("", gt); + std::size_t uuid = xml.find(" 0) + { map.resize(totalPlanes); for (std::size_t k = 0; k < totalPlanes; ++k) map[k] = k; } + for (std::size_t k = 0; k < count && (startOrd + k) < totalPlanes; ++k) + map[startOrd + k] = ifd0 + k; + tp = gt + 1; + } + ax.multiFileTiff = sawMultiFile; + // Only keep a non-identity map (a plain identity means canonical -> leave empty so + // the common no-/canonical-TiffData path stays allocation-free and obviously canonical). + if (anyTiffData && !map.empty()) + { + bool identity = true; + for (std::size_t k = 0; k < map.size(); ++k) if (map[k] != k) { identity = false; break; } + if (!identity) ax.planeToIfd = std::move(map); + } + } + ax.dtype = pixel_type_from_ome_type(s("Type", "uint16")); if (ax.dtype == PixelType::Unknown) ax.dtype = PixelType::UInt16; ax.bitsPerSample = bits_of(ax.dtype); diff --git a/src/nyx/ome/ome_tiff_meta.h b/src/nyx/ome/ome_tiff_meta.h index dd0316138..d98cef450 100644 --- a/src/nyx/ome/ome_tiff_meta.h +++ b/src/nyx/ome/ome_tiff_meta.h @@ -4,9 +4,10 @@ // element (SizeX/Y/Z/C/T, DimensionOrder, Type, PhysicalSize*, // TimeIncrement) plus names. STL-only. // -// Currently resolves dimensions + calibration + dtype. The TiffData plane->IFD -// map and SubIFDs pyramids are not parsed yet; levels here is a single full-res -// level. +// Resolves dimensions + calibration + dtype + the plane->IFD map +// (same-file; a multi-file block sets OmeAxes::multiFileTiff and is left +// at the canonical fallback). SubIFDs pyramids are not parsed yet; levels here is +// a single full-res level. #include #include "ome_axes.h" diff --git a/src/nyx/omezarr.h b/src/nyx/omezarr.h index 1c96f6952..cc2763f33 100644 --- a/src/nyx/omezarr.h +++ b/src/nyx/omezarr.h @@ -23,10 +23,36 @@ // attribute functionality #include "z5/attributes.hxx" +// FIX (IO): z5 Datatype -> the " -class NyxusOmeZarrLoader : public AbstractTileLoader +class NyxusOmeZarrLoader : public AbstractTileLoader { public: @@ -40,20 +66,25 @@ class NyxusOmeZarrLoader : public AbstractTileLoader { // Open the file zarr_ptr_ = std::make_unique(filePath.c_str()); - nlohmann::json file_attributes, ds_attributes; + nlohmann::json file_attributes; z5::readAttributes(*zarr_ptr_, file_attributes); - // open the highest-resolution dataset (level 0) - ds_name_ = file_attributes["multiscales"][0]["datasets"][0]["path"].get(); - const auto ds_handle = z5::filesystem::handle::Dataset(*zarr_ptr_, ds_name_); - fs::path metadata_path; - auto success = z5::filesystem::metadata_detail::getMetadataPath(ds_handle, metadata_path); - z5::filesystem::metadata_detail::readMetadata(metadata_path, ds_attributes); + // Resolve the level-0 dataset path. NGFF 0.5 (Zarr v3) nests 'multiscales' under an + // "ome" key; NGFF 0.4 (Zarr v2) has it at the attributes root. (parse_ome_zarr handles + // both, but we need the path here to open the dataset.) + const nlohmann::json& ms_root = (file_attributes.contains("ome") && file_attributes["ome"].contains("multiscales")) + ? file_attributes["ome"] : file_attributes; + ds_name_ = ms_root["multiscales"][0]["datasets"][0]["path"].get(); - std::vector level0Shape, chunkShape; - for (const auto& d : ds_attributes["shape"]) level0Shape.push_back(d.get()); - for (const auto& d : ds_attributes["chunks"]) chunkShape.push_back(d.get()); - std::string dtype_str = ds_attributes["dtype"].get(); + // FIX (IO): open the dataset via z5, which auto-detects Zarr v2 (.zarray) vs v3 + // (zarr.json) and handles the v3 chunk-key encoding, codecs (zstd/blosc/...) and + // sharding. Query shape/chunking/dtype from the Dataset object (format-agnostic) + // instead of parsing the v2-only .zarray by hand -- this is what lets the loader read + // BOTH OME-Zarr 0.4 and 0.5. The dataset handle is cached (metadata is immutable). + ds_ = z5::openDataset(*zarr_ptr_, ds_name_); + std::vector level0Shape(ds_->shape().begin(), ds_->shape().end()); + std::vector chunkShape(ds_->defaultChunkShape().begin(), ds_->defaultChunkShape().end()); + std::string dtype_str = zarr_dtype_string_of(ds_->getDtype()); // Resolve axis roles from the NGFF 'axes' metadata instead of assuming a // fixed [T,C,Z,Y,X] order. Falls back to legacy 5D TCZYX if 'axes' is absent. @@ -71,6 +102,13 @@ class NyxusOmeZarrLoader : public AbstractTileLoader iz_ = axes.storageIndexOf('Z'); ic_ = axes.storageIndexOf('C'); it_ = axes.storageIndexOf('T'); n_levels_ = axes.numberPyramidLevels(); + // FIX: advertise the real C/T extents so the pipeline iterates channels and + // timeframes; without this the base class default of 1 kept it on plane (c=0,t=0). + n_channels_ = axes.sizeC; + n_timeframes_ = axes.sizeT; + // FIX (IO): keep the parsed physical voxel spacing for opt-in calibration. + phys_x_ = axes.physX; phys_y_ = axes.physY; phys_z_ = axes.physZ; + phys_unit_ = axes.unitXY; } else { @@ -82,6 +120,10 @@ class NyxusOmeZarrLoader : public AbstractTileLoader ic_ = (n >= 4) ? n - 4 : -1; it_ = (n >= 5) ? n - 5 : -1; n_levels_ = 1; + // FIX: derive C/T extents from the positional axes so the fallback path also + // reports multi-channel / time-series counts (1 when the axis is absent). + n_channels_ = (ic_ >= 0) ? level0Shape[ic_] : 1; + n_timeframes_ = (it_ >= 0) ? level0Shape[it_] : 1; } // X and Y must resolve to real dimensions, else the read would index OOB. if (ix_ < 0 || iy_ < 0 || (size_t)ix_ >= level0Shape.size() || (size_t)iy_ >= level0Shape.size()) @@ -108,11 +150,6 @@ class NyxusOmeZarrLoader : public AbstractTileLoader else if (dtype_str == " [[nodiscard]] short bitsPerSample() const override { return bits_per_sample_; } /// @brief Number of resolution (pyramid) levels declared in multiscales [[nodiscard]] size_t numberPyramidLevels() const override { return n_levels_; } + /// @brief Channel (C) extent resolved from the NGFF axes (1 if no channel axis) + [[nodiscard]] size_t numberChannels() const override { return n_channels_; } + /// @brief Time (T) extent resolved from the NGFF axes (1 if no time axis) + [[nodiscard]] size_t fullTimestamps([[maybe_unused]] size_t level) const override { return n_timeframes_; } + /// @brief Physical voxel spacing from the NGFF coordinateTransformations (1.0 if uncalibrated) + [[nodiscard]] double physicalSizeX() const override { return phys_x_; } + [[nodiscard]] double physicalSizeY() const override { return phys_y_; } + [[nodiscard]] double physicalSizeZ() const override { return phys_z_; } + [[nodiscard]] std::string physicalSizeUnit() const override { return phys_unit_; } private: @@ -261,6 +307,10 @@ class NyxusOmeZarrLoader : public AbstractTileLoader size_t ndim_ = 5; ///< Number of on-disk dimensions (2..5) short bits_per_sample_ = 16; ///< Real bit depth size_t n_levels_ = 1; ///< Pyramid level count + size_t n_channels_ = 1; ///< Channel (C) extent + size_t n_timeframes_ = 1; ///< Time (T) extent + double phys_x_ = 1.0, phys_y_ = 1.0, phys_z_ = 1.0; ///< Physical voxel spacing + std::string phys_unit_; ///< Physical-size unit (e.g. "micrometer") short data_format_ = 0; std::unique_ptr zarr_ptr_; diff --git a/src/nyx/output_2_apache.cpp b/src/nyx/output_2_apache.cpp index 6a263ba67..09bc87652 100644 --- a/src/nyx/output_2_apache.cpp +++ b/src/nyx/output_2_apache.cpp @@ -24,9 +24,9 @@ namespace Nyxus { static std::mutex mx1; - std::tuple> save_features_2_apache_wholeslide (Environment & env, const LR & wsi_roi, const std::string & wsi_path) + std::tuple> save_features_2_apache_wholeslide (Environment & env, const LR & wsi_roi, const std::string & wsi_path, size_t t_index, size_t c_index) { std::lock_guard lg (mx1); - return env.arrow_stream.write_arrow_file (Nyxus::get_feature_values_roi (env, env.theFeatureSet, wsi_roi, wsi_path, "")); + return env.arrow_stream.write_arrow_file (Nyxus::get_feature_values_roi (env, env.theFeatureSet, wsi_roi, wsi_path, "", t_index, c_index)); } } \ No newline at end of file diff --git a/src/nyx/output_2_buffer.cpp b/src/nyx/output_2_buffer.cpp index 619aa6bf3..abece24f5 100644 --- a/src/nyx/output_2_buffer.cpp +++ b/src/nyx/output_2_buffer.cpp @@ -25,12 +25,14 @@ namespace Nyxus bool save_features_2_buffer_wholeslide ( // out - ResultsCache & rescache, + ResultsCache & rescache, // in Environment & env, const LR & r, const std::string& ifpath, - const std::string& mfpath) + const std::string& mfpath, + size_t t_index, + size_t c_index) { std::lock_guard lg (mx1); @@ -44,7 +46,11 @@ namespace Nyxus if (fill_header) { - rescache.add_to_header({ Nyxus::colname_intensity_image, Nyxus::colname_mask_image, Nyxus::colname_roi_label, Nyxus::colname_t_index }); + // FIX (IO): phys_unit is a leading STRING column (kept with intensity/mask so the + // Python "string columns first" split still works); phys_x/y/z are numeric. + rescache.add_to_header({ Nyxus::colname_intensity_image, Nyxus::colname_mask_image, Nyxus::colname_phys_unit, + Nyxus::colname_roi_label, Nyxus::colname_t_index, Nyxus::colname_c_index, + Nyxus::colname_phys_x, Nyxus::colname_phys_y, Nyxus::colname_phys_z }); for (auto& enabdF : F) { @@ -178,11 +184,19 @@ namespace Nyxus rescache.inc_num_rows(); - // - slide info + // - slide info + phys_unit (FIX (IO): 3rd leading string column) + const SlideProps& sp = env.dataset.dataset_props[r.slide_idx]; rescache.add_string (ifpath); rescache.add_string (mfpath); + rescache.add_string (sp.phys_unit); rescache.add_numeric (r.label); - rescache.add_numeric (DEFAULT_T_INDEX); + // FIX (IO): emit the real time/channel indices (was hard-coded DEFAULT_T_INDEX with no channel) + rescache.add_numeric ((double)t_index); + rescache.add_numeric ((double)c_index); + // FIX (IO): physical voxel spacing (numeric columns) + rescache.add_numeric (sp.phys_x); + rescache.add_numeric (sp.phys_y); + rescache.add_numeric (sp.phys_z); // - features for (auto& enabdF : F) @@ -300,7 +314,7 @@ namespace Nyxus } /// @brief Copies ROIs' feature values into a ResultsCache structure that will then shape them as a table - bool save_features_2_buffer (ResultsCache& rescache, Environment & env, size_t t_index) + bool save_features_2_buffer (ResultsCache& rescache, Environment & env, size_t t_index, size_t c_index) { std::vector L{ env.uniqueLabels.begin(), env.uniqueLabels.end() }; std::sort(L.begin(), L.end()); @@ -313,7 +327,11 @@ namespace Nyxus // -- Header if (fill_header) { - rescache.add_to_header({ Nyxus::colname_intensity_image, Nyxus::colname_mask_image, Nyxus::colname_roi_label, Nyxus::colname_t_index }); + // FIX (IO): phys_unit is a leading STRING column (kept with intensity/mask so the + // Python "string columns first" split still works); phys_x/y/z are numeric. + rescache.add_to_header({ Nyxus::colname_intensity_image, Nyxus::colname_mask_image, Nyxus::colname_phys_unit, + Nyxus::colname_roi_label, Nyxus::colname_t_index, Nyxus::colname_c_index, + Nyxus::colname_phys_x, Nyxus::colname_phys_y, Nyxus::colname_phys_z }); for (auto& enabdF : F) { @@ -463,8 +481,13 @@ namespace Nyxus rescache.add_string (intfname); rescache.add_string (segfname); + rescache.add_string (slide.phys_unit); // FIX (IO): 3rd leading string column rescache.add_numeric (l); - rescache.add_numeric (t_index); + rescache.add_numeric ((double)t_index); + rescache.add_numeric ((double)c_index); // FIX (IO): channel index column, mirrors t_index + rescache.add_numeric (slide.phys_x); // FIX (IO): physical voxel spacing (numeric) + rescache.add_numeric (slide.phys_y); + rescache.add_numeric (slide.phys_z); for (auto& enabdF : F) { diff --git a/src/nyx/output_2_csv.cpp b/src/nyx/output_2_csv.cpp index e282c4325..5ad5af9f6 100644 --- a/src/nyx/output_2_csv.cpp +++ b/src/nyx/output_2_csv.cpp @@ -63,6 +63,10 @@ namespace Nyxus head.push_back (Nyxus::colname_intensity_image); head.push_back (Nyxus::colname_mask_image); + // Physical-size unit (FIX (IO): a leading STRING column so the Python buffer's + // "string columns first" reconstruction still holds; the numeric phys_x/y/z go later) + head.push_back (Nyxus::colname_phys_unit); + // Annotation columns if (env.resultOptions.need_annotation()) { @@ -80,6 +84,14 @@ namespace Nyxus // time head.push_back (Nyxus::colname_t_index); + // channel (FIX (IO): mirrors t_index; one column per channel plane) + head.push_back (Nyxus::colname_c_index); + + // physical voxel spacing (FIX (IO): numeric columns) + head.push_back (Nyxus::colname_phys_x); + head.push_back (Nyxus::colname_phys_y); + head.push_back (Nyxus::colname_phys_z); + // Optional columns for (auto& enabdF : F) { @@ -211,11 +223,12 @@ namespace Nyxus bool save_features_2_csv_wholeslide ( Environment & env, - const LR & r, - const std::string & ifpath, - const std::string & mfpath, + const LR & r, + const std::string & ifpath, + const std::string & mfpath, const std::string & outdir, - size_t t_index) + size_t t_index, + size_t c_index) { std::lock_guard lg (mutex1); // Lock the mutex @@ -224,16 +237,18 @@ namespace Nyxus char rvbuf[VAL_BUF_LEN]; // real value buffer large enough to fit a float64 value in range (2.2250738585072014E-308 to 1.79769313486231570e+308) const char rvfmt[] = "%g"; // instead of "%20.12f" which produces a too massive output - static bool mustRenderHeader = true; // In 'singlecsv' scenario this flag flips from 'T' to 'F' when necessary (after the header is rendered) - // Make the file name and write mode std::string fullPath = get_feature_output_fname (env, ifpath, mfpath); VERBOSLVL2 (env.get_verbosity_level(), std::cout << "\t--> " << fullPath << "\n"); - // Single CSV: create or continue? - const char* mode = "w"; - if (!env.separateCsv) - mode = mustRenderHeader ? "w" : "a"; + // FIX: create or continue, per PATH. This sink is called once per (channel, timeframe) + // plane, but separatecsv derives one path per slide and used to open it "w" every time, + // so each plane truncated the previous and the file kept only the last one. Truncate and + // render the header on the first write to a path, append after that -- which is what the + // t_index/c_index columns are for, and matches the Arrow and buffer sinks. Replaces a + // function-static 'mustRenderHeader' that could only express this for singlecsv. + const bool mustRenderHeader = env.csv_claim_first_write (fullPath); + const char* mode = mustRenderHeader ? "w" : "a"; // Open it FILE* fp = nullptr; @@ -272,10 +287,7 @@ namespace Nyxus auto head_string = ssHead.str(); fprintf(fp, "%s\n", head_string.c_str()); - - // Prevent rendering the header again for another image's portion of labels - if (env.separateCsv == false) - mustRenderHeader = false; + // (the path is already recorded, so later writes to it append without a header) } // ********** Values @@ -288,10 +300,16 @@ namespace Nyxus ssVals << std::fixed; // slide info - ssVals << ifpath << "," << mfpath; - - // ROI and time - ssVals << "," << r.label << "," << t_index; + // FIX (IO): physical calibration of this slide (phys_unit is a leading string column, + // phys_x/y/z are numeric; 1.0/"" when uncalibrated). r.slide_idx set for the vROI. + const SlideProps& sp = env.dataset.dataset_props[r.slide_idx]; + ssVals << ifpath << "," << mfpath << "," << sp.phys_unit; + + // ROI, time and channel + ssVals << "," << r.label << "," << t_index << "," << c_index; + + // physical voxel spacing (numeric) + ssVals << "," << sp.phys_x << "," << sp.phys_y << "," << sp.phys_z; // features for (auto& enabdF : F) @@ -418,10 +436,11 @@ namespace Nyxus // Saves the result of image scanning and feature calculation. Must be called after the reduction phase. bool save_features_2_csv ( Environment & env, - const std::string& intFpath, - const std::string& segFpath, + const std::string& intFpath, + const std::string& segFpath, const std::string& outputDir, size_t t_index, + size_t c_index, bool need_aggregation) { // Non-exotic formatting for compatibility with the buffer output (Python API, Apache) @@ -433,16 +452,15 @@ namespace Nyxus std::vector L{ env.uniqueLabels.begin(), env.uniqueLabels.end() }; std::sort(L.begin(), L.end()); - static bool mustRenderHeader = true; // In 'singlecsv' scenario this flag flips from 'T' to 'F' when necessary (after the header is rendered) - // Make the file name and write mode std::string fullPath = get_feature_output_fname (env, intFpath, segFpath); VERBOSLVL2 (env.get_verbosity_level(), std::cout << "\t--> " << fullPath << "\n"); - // Single CSV: create or continue? - const char* mode = "w"; - if (!env.separateCsv) - mode = mustRenderHeader ? "w" : "a"; + // FIX: create or continue, per PATH -- see the whole-slide sink above. The 3D segmented + // workflow also calls this once per (channel, timeframe), and separatecsv truncated the + // file on every call, keeping only the last plane's ROI rows. + const bool mustRenderHeader = env.csv_claim_first_write (fullPath); + const char* mode = mustRenderHeader ? "w" : "a"; // Open it FILE* fp = nullptr; @@ -482,20 +500,17 @@ namespace Nyxus auto head_string = ssHead.str(); fprintf(fp, "%s\n", head_string.c_str()); - - // Prevent rendering the header again for another image's portion of labels - if (env.separateCsv == false) - mustRenderHeader = false; + // (the path is already recorded, so later writes to it append without a header) } if (need_aggregation) { - auto allres = Nyxus::get_feature_values (env, env.theFeatureSet, env.uniqueLabels, env.roiData, env.dataset); // shape: td::vector, int, std::vector>> + auto allres = Nyxus::get_feature_values (env, env.theFeatureSet, env.uniqueLabels, env.roiData, env.dataset, t_index, c_index); // shape: vector, label, t_index, c_index, vector>> if (allres.size()) { // aggregate const auto& tup0 = allres[0]; - const auto& v0 = std::get<3>(tup0); + const auto& v0 = std::get(tup0); // FIX (IO): FBEGIN shifted 3->4 after c_index; was hard-coded std::get<3> auto n_feats = v0.size(); std::vector a (n_feats); @@ -507,7 +522,7 @@ namespace Nyxus // we ned to add Skipping blacklisted ROI // ... - const auto& v = std::get<3>(tup); + const auto& v = std::get(tup); // FIX (IO): FBEGIN shifted 3->4 after c_index for (size_t i = 0; i < n_feats; i++) { double x = v[i], @@ -521,11 +536,11 @@ namespace Nyxus } } - // send the aggregate as ROI #-1 to output + // send the aggregate as ROI #-1 to output const std::vector & fnames = std::get<0>(tup0); std::stringstream ssVals; - // ... slide/mask file info - ssVals << fnames[0] << "," << fnames[1]; + // ... slide/mask file info + phys_unit (FIX (IO): fnames[2] is the leading unit string) + ssVals << fnames[0] << "," << fnames[1] << "," << fnames[2]; // ... annotation info auto lab0 = std::get<1>(tup0); // annotation info is per slide, so OK to grab it from the 1st ROI LR& r0 = env.roiData [lab0]; @@ -534,6 +549,11 @@ namespace Nyxus ssVals << "," << a; // ... ROI id ssVals << "," << -1; + // ... time and channel (FIX (IO): the aggregate row previously omitted these, + // misaligning it against the header; emit both so columns line up) + ssVals << "," << t_index << "," << c_index; + // ... physical voxel spacing (FIX (IO): numeric columns, from the 1st ROI's tuple) + ssVals << "," << std::get(tup0) << "," << std::get(tup0) << "," << std::get(tup0); // ... aggregated feature values for (size_t i = 0; i < n_feats; i++) ssVals << "," << a[i]; @@ -560,7 +580,8 @@ namespace Nyxus const SlideProps& sli = env.dataset.dataset_props [r.slide_idx]; fs::path pseg (sli.fname_seg), pint (sli.fname_int); - ssVals << pint.filename() << "," << pseg.filename(); + // FIX (IO): phys_unit is a leading string column (after the file names) + ssVals << pint.filename() << "," << pseg.filename() << "," << sli.phys_unit; // annotation if (env.resultOptions.need_annotation()) @@ -569,8 +590,11 @@ namespace Nyxus ssVals << "," << a; } - // ROI label and time - ssVals << "," << l << "," << t_index; + // ROI label, time and channel + ssVals << "," << l << "," << t_index << "," << c_index; + + // physical voxel spacing (FIX (IO): numeric columns) + ssVals << "," << sli.phys_x << "," << sli.phys_y << "," << sli.phys_z; for (auto& enabdF : F) { @@ -761,7 +785,9 @@ namespace Nyxus const FeatureSet & fset, const LR & r, const std::string & ifpath, - const std::string & mfpath) + const std::string & mfpath, + size_t t_index, + size_t c_index) { std::vector features; @@ -878,13 +904,21 @@ namespace Nyxus fvals.push_back(vv[0]); } - // other columns + // physical calibration of this slide (FIX (IO)) + const SlideProps& sp = env.dataset.dataset_props[r.slide_idx]; + + // other columns (textcols[2] = phys_unit, a leading string column) std::vector textcols; textcols.push_back ((fs::path(ifpath)).filename().string()); textcols.push_back (""); + textcols.push_back (sp.phys_unit); int roilabl = r.label; // whole-slide roi # - features.push_back (std::make_tuple(textcols, roilabl, -999.88, fvals)); + // FIX (IO): emit the real time/channel indices (was hard-coded -999.88 for time + // and had no channel field) + physical voxel spacing, so the Apache/buffer whole-slide + // row is addressable by (t,c) and carries calibration. + features.push_back (std::make_tuple(textcols, roilabl, (double)t_index, (double)c_index, + sp.phys_x, sp.phys_y, sp.phys_z, fvals)); return features; } @@ -894,7 +928,9 @@ namespace Nyxus const FeatureSet & fset, const Uniqueids & uniqueLabels, const Roidata & roiData, - const Dataset & dataset) + const Dataset & dataset, + size_t t_index, + size_t c_index) { std::vector features; @@ -923,6 +959,7 @@ namespace Nyxus std::vector filenames; filenames.push_back(pint.filename().string()); filenames.push_back(pseg.filename().string()); + filenames.push_back(sli.phys_unit); // FIX (IO): phys_unit leading string column for (auto& enabdF : F) { @@ -1031,7 +1068,10 @@ namespace Nyxus feature_values.push_back(vv[0]); } - features.push_back (std::make_tuple(filenames, l, DEFAULT_T_INDEX, feature_values)); + // FIX (IO): thread the real time/channel indices instead of the hard-coded + // DEFAULT_T_INDEX (which pinned every Apache/Parquet row to t=0,c=0). + features.push_back (std::make_tuple(filenames, l, (double)t_index, (double)c_index, + sli.phys_x, sli.phys_y, sli.phys_z, feature_values)); } return features; diff --git a/src/nyx/output_writers.cpp b/src/nyx/output_writers.cpp index 39c5c6508..c4ace62a0 100644 --- a/src/nyx/output_writers.cpp +++ b/src/nyx/output_writers.cpp @@ -18,11 +18,14 @@ arrow::Status ParquetWriter::setup(const std::vector& header) { std::vector> fields; + // FIX (IO): schema is [intensity, mask, phys_unit] (utf8) + ROI_label (int32) + the rest + // (t_index, c_index, phys_x/y/z, features) as float64 - matches get_header's column order. fields.push_back(arrow::field(header[0], arrow::utf8())); fields.push_back(arrow::field(header[1], arrow::utf8())); - fields.push_back(arrow::field(header[2], arrow::int32())); + fields.push_back(arrow::field(header[2], arrow::utf8())); + fields.push_back(arrow::field(header[3], arrow::int32())); - for (int i = 3; i < header.size(); ++i) + for (int i = 4; i < header.size(); ++i) { fields.push_back(arrow::field(header[i], arrow::float64())); } @@ -114,6 +117,19 @@ arrow::Status ParquetWriter::write (const std::vector& features) } A.push_back (A_s); + + //cccccccc phys_unit (FIX (IO): 3rd leading string column) + b.Reset(); + std::shared_ptr A_pu; + for (int i = 0; i < num_rows; ++i) { + append_status = b.Append (std::get(features[i])[2]); + if (!append_status.ok()) + return append_status; + } + append_status = b.Finish (&A_pu); + if (!append_status.ok()) + return append_status; + A.push_back (A_pu); } //cccccccc ROI label @@ -160,9 +176,51 @@ arrow::Status ParquetWriter::write (const std::vector& features) A.push_back (A_t); } + //cccccccc channel (FIX (IO): mirrors the time column so multi-channel input is addressable) + { + arrow::DoubleBuilder b; + std::shared_ptr A_c; + for (int i = 0; i < num_rows; ++i) + { + append_status = b.Append (std::get(features[i])); + if (!append_status.ok()) { + // Handle read error + return append_status; + } + } + + append_status = b.Finish (&A_c); + if (!append_status.ok()) + { + // Handle read error + return append_status; + } + A.push_back (A_c); + } + + //cccccccc physical voxel spacing (FIX (IO): phys_x/y/z numeric columns) + for (int axis = FTABLE_PXPOS; axis <= FTABLE_PZPOS; ++axis) + { + arrow::DoubleBuilder b; + std::shared_ptr A_ps; + for (int i = 0; i < num_rows; ++i) + { + double v = (axis == FTABLE_PXPOS) ? std::get(features[i]) + : (axis == FTABLE_PYPOS) ? std::get(features[i]) + : std::get(features[i]); + append_status = b.Append (v); + if (!append_status.ok()) + return append_status; + } + append_status = b.Finish (&A_ps); + if (!append_status.ok()) + return append_status; + A.push_back (A_ps); + } + //cccccccc features { - // construct columns for each feature + // construct columns for each feature for (int j = 0; j < std::get(features[0]).size(); ++j) { arrow::DoubleBuilder b; @@ -225,11 +283,13 @@ arrow::Status ArrowIPCWriter::setup(const std::vector& header) { std::vector> fields; - fields.push_back(arrow::field("intensity_image", arrow::utf8())); - fields.push_back(arrow::field("mask_image", arrow::utf8())); - fields.push_back(arrow::field("ROI_label", arrow::int32())); + // FIX (IO): [intensity, mask, phys_unit] (utf8) + ROI_label (int32) + float64 rest + fields.push_back(arrow::field(header[0], arrow::utf8())); + fields.push_back(arrow::field(header[1], arrow::utf8())); + fields.push_back(arrow::field(header[2], arrow::utf8())); + fields.push_back(arrow::field(header[3], arrow::int32())); - for (int i = 3; i < header.size(); ++i) + for (int i = 4; i < header.size(); ++i) { fields.push_back(arrow::field(header[i], arrow::float64())); } @@ -306,6 +366,19 @@ arrow::Status ArrowIPCWriter::write (const std::vector& features) } A.push_back (A_s); + + //cccccccc phys_unit (FIX (IO): 3rd leading string column) + b.Reset(); + std::shared_ptr A_pu; + for (int i = 0; i < num_rows; ++i) { + append_status = b.Append (std::get(features[i])[2]); + if (!append_status.ok()) + return append_status; + } + append_status = b.Finish (&A_pu); + if (!append_status.ok()) + return append_status; + A.push_back (A_pu); } //cccccccc ROI label @@ -351,9 +424,49 @@ arrow::Status ArrowIPCWriter::write (const std::vector& features) A.push_back (A_t); } + //cccccccc channel (FIX (IO): mirrors the time column so multi-channel input is addressable) + { + arrow::DoubleBuilder b; + std::shared_ptr A_c; + for (int i = 0; i < num_rows; ++i) { + append_status = b.Append (std::get(features[i])); + if (!append_status.ok()) { + // Handle read error + return append_status; + } + } + + append_status = b.Finish (&A_c); + if (!append_status.ok()) { + // Handle read error + return append_status; + } + A.push_back (A_c); + } + + //cccccccc physical voxel spacing (FIX (IO): phys_x/y/z numeric columns) + for (int axis = FTABLE_PXPOS; axis <= FTABLE_PZPOS; ++axis) + { + arrow::DoubleBuilder b; + std::shared_ptr A_ps; + for (int i = 0; i < num_rows; ++i) + { + double v = (axis == FTABLE_PXPOS) ? std::get(features[i]) + : (axis == FTABLE_PYPOS) ? std::get(features[i]) + : std::get(features[i]); + append_status = b.Append (v); + if (!append_status.ok()) + return append_status; + } + append_status = b.Finish (&A_ps); + if (!append_status.ok()) + return append_status; + A.push_back (A_ps); + } + //cccccccc feature values { - // construct columns for each feature + // construct columns for each feature for (int j = 0; j < std::get(features[0]).size(); ++j) { arrow::DoubleBuilder b; diff --git a/src/nyx/output_writers.h b/src/nyx/output_writers.h index 53555ce71..fc4d6765f 100644 --- a/src/nyx/output_writers.h +++ b/src/nyx/output_writers.h @@ -1,10 +1,21 @@ #pragma once -#define FTABLE_RECORD std::tuple,int,double,std::vector> +// FIX (IO): the record carries the index/calibration columns before the feature block: +// [0] text columns = {intensity_image, mask_image, phys_unit} (all leading strings, +// so the Python buffer's "strings first" reconstruction still holds) +// [1] ROI label (int) +// [2] t_index, [3] c_index, [4..6] phys_x/phys_y/phys_z (physical voxel spacing) +// [7..] feature values +// c_index was added for per-channel OME output; phys_* for physical calibration. +#define FTABLE_RECORD std::tuple,int,double,double,double,double,double,std::vector> #define FTABLE_INTSEG 0 #define FTABLE_ROILBL 1 #define FTABLE_TIMEPOS 2 -#define FTABLE_FBEGIN 3 +#define FTABLE_CPOS 3 +#define FTABLE_PXPOS 4 +#define FTABLE_PYPOS 5 +#define FTABLE_PZPOS 6 +#define FTABLE_FBEGIN 7 #define FTABLE_SAFENAN -0.0 #ifdef USE_ARROW diff --git a/src/nyx/phase1.cpp b/src/nyx/phase1.cpp index 00dded8bb..724c2aeca 100644 --- a/src/nyx/phase1.cpp +++ b/src/nyx/phase1.cpp @@ -245,7 +245,7 @@ namespace Nyxus // segmented 3D case (true volumetric images e.g. .nii, .nii.gz, .dcm, etc) // prerequisite: 'env.theImLoader' needs to be pre-opened ! // - bool gatherRoisMetrics_3D (Environment& env, size_t sidx, const std::string& intens_fpath, const std::string& mask_fpath, size_t t_index) + bool gatherRoisMetrics_3D (Environment& env, size_t sidx, const std::string& intens_fpath, const std::string& mask_fpath, size_t t_index, size_t channel) { SlideProps & sprp = env.dataset.dataset_props [sidx]; if (! env.theImLoader.open(sprp, env.fpimageOptions)) @@ -284,7 +284,7 @@ namespace Nyxus // real 3D volume here too — Phase-1 ROI metrics must see the same voxels the // Phase-2 scan does. Mask on frame t_index (1:1) or frame 0 (1 mask : N int). const size_t maskFrame = (nVoxI == nVoxM) ? t_index : 0; - bool ok = env.theImLoader.load_volume (0/*channel*/, t_index, maskFrame); + bool ok = env.theImLoader.load_volume (channel, t_index, maskFrame); if (!ok) { std::string erm = "Error fetching tile (0,0) from I:" + intens_fpath + " M:" + mask_fpath; diff --git a/src/nyx/phase2_3d.cpp b/src/nyx/phase2_3d.cpp index d942a8511..178bc8a74 100644 --- a/src/nyx/phase2_3d.cpp +++ b/src/nyx/phase2_3d.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -13,15 +14,51 @@ #include "environment.h" #include "globals.h" +#include "helpers/helpers.h" // FIX (IO): Nyxus::near_eq for the physical-spacing resolver #include "helpers/fsystem.h" #include "helpers/timing.h" namespace Nyxus { + // FIX (IO): resolve the effective 3D voxel spacing for slide `sidx`. Explicit --aniso* + // (anisoOptions.customized()) always wins. Otherwise, when --use-physical-spacing is on, + // use the slide's OME PhysicalSize* ratio-normalized so the smallest axis == 1 (Nyxus's + // anisotropic path resamples by the multiplier, so ratios - not absolute units - are what + // correct for non-cubic voxels). Returns false + (1,1,1) when the grid is isotropic. + bool resolve_slide_anisotropy (const Environment& env, size_t sidx, double& ax, double& ay, double& az) + { + ax = ay = az = 1.0; + + if (env.anisoOptions.customized()) + { + ax = env.anisoOptions.get_aniso_x(); + ay = env.anisoOptions.get_aniso_y(); + az = env.anisoOptions.get_aniso_z(); + return true; + } + + if (env.use_physical_spacing() && sidx < env.dataset.dataset_props.size()) + { + const SlideProps& p = env.dataset.dataset_props[sidx]; + double sx = p.phys_x, sy = p.phys_y, sz = p.phys_z; + double mn = std::min(sx, std::min(sy, sz)); + if (mn > 0.0) + { + ax = sx / mn; ay = sy / mn; az = sz / mn; + // only take the anisotropic path if the voxels are actually non-cubic + if (! (Nyxus::near_eq(ax, 1.0) && Nyxus::near_eq(ay, 1.0) && Nyxus::near_eq(az, 1.0))) + return true; + } + ax = ay = az = 1.0; + } + + return false; + } + // // Loads ROI voxels into voxel clouds // - bool scanTrivialRois_3D (Environment & env, const std::vector& batch_labels, const std::string& intens_fpath, const std::string& label_fpath, size_t t_index) + bool scanTrivialRois_3D (Environment & env, const std::vector& batch_labels, const std::string& intens_fpath, const std::string& label_fpath, size_t t_index, size_t channel) { // Sort the batch's labels to enable binary searching in it std::vector whiteList = batch_labels; @@ -89,7 +126,7 @@ namespace Nyxus // read a real 3D volume. The mask sits on frame t_index (1:1) or frame 0 (the // 1-mask : N-intensity case); the intensity sits on frame t_index. const size_t maskFrame = (timeI == timeM) ? t_index : 0; - bool ok = env.theImLoader.load_volume (0/*channel*/, t_index, maskFrame); + bool ok = env.theImLoader.load_volume (channel, t_index, maskFrame); if (!ok) { std::string erm = "Error fetching segmented data from " + intens_fpath + "(I) " + label_fpath + "(M)"; @@ -280,6 +317,7 @@ namespace Nyxus const std::string& intens_fpath, const std::string& label_fpath, size_t t_index, + size_t channel, double aniso_x, double aniso_y, double aniso_z) @@ -337,7 +375,7 @@ namespace Nyxus // loops Z), so plane-by-plane loaders (OME-Zarr, multi-page/OME-TIFF) read a // real 3D volume here too. Mask on frame t_index (1:1) or frame 0 (1 mask : N). const size_t maskFrame = (timeI == timeM) ? t_index : 0; - if (!env.theImLoader.load_volume (0/*channel*/, t_index, maskFrame)) + if (!env.theImLoader.load_volume (channel, t_index, maskFrame)) { std::string erm = "Error fetching data from file pair " + intens_fpath + "(I) " + label_fpath + "(M)"; #ifdef WITH_PYTHON_H @@ -433,7 +471,9 @@ namespace Nyxus bool scan_trivial_wholevolume ( LR& vroi, const std::string& intens_fpath, - ImageLoader& ilo) + ImageLoader& ilo, + size_t channel, + size_t timeframe) { int lvl = 0, // Pyramid level lyr = 0; // Layer @@ -446,10 +486,10 @@ namespace Nyxus sliceSize = fullwidth * fullheight, nVox = sliceSize * fullD; - // FIX (IO): assemble the whole X*Y*Z volume (load_volume loops Z) so plane-by- - // plane loaders (OME-Zarr, multi-page/OME-TIFF) read the full volume, not just - // plane z=0. Whole-slide is single-channel, single-timeframe. - if (!ilo.load_volume(0/*channel*/, 0/*timeframe*/, 0/*mask*/)) + // FIX (IO): assemble the whole X*Y*Z volume (load_volume loops Z) for the requested + // (channel, timeframe) so plane-by-plane loaders (OME-Zarr, multi-page/OME-TIFF) read + // the full volume of THIS C/T plane, not just plane z=0 of (c=0,t=0). No mask in WSI. + if (!ilo.load_volume(channel, timeframe, timeframe)) { #ifdef WITH_PYTHON_H throw "Error fetching tile"; @@ -495,7 +535,9 @@ namespace Nyxus ImageLoader& ilo, double aniso_x, double aniso_y, - double aniso_z) + double aniso_z, + size_t channel, + size_t timeframe) { int lvl = 0, // Pyramid level lyr = 0; // Layer @@ -511,9 +553,9 @@ namespace Nyxus vw = (size_t) (double(fullW) * aniso_x), vd = (size_t) (double(fullD) * aniso_z); - // FIX (IO): assemble the whole X*Y*Z volume (load_volume loops Z) so plane-by- - // plane loaders read the full volume, not just plane z=0. - if (! ilo.load_volume(0/*channel*/, 0/*timeframe*/, 0/*mask*/)) + // FIX (IO): assemble the whole X*Y*Z volume (load_volume loops Z) for the requested + // (channel, timeframe) so plane-by-plane loaders read the full volume of this C/T plane. + if (! ilo.load_volume(channel, timeframe, timeframe)) { #ifdef WITH_PYTHON_H throw "Error loading volume data"; @@ -630,7 +672,7 @@ namespace Nyxus } - bool processTrivialRois_3D (Environment & env, size_t sidx, size_t t_index, const std::vector& trivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath, size_t memory_limit) + bool processTrivialRois_3D (Environment & env, size_t sidx, size_t t_index, size_t channel, const std::vector& trivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath, size_t memory_limit) { std::vector Pending; size_t batchDemand = 0; @@ -661,16 +703,15 @@ namespace Nyxus std::cout << ">>> (ROI labels " << Pending[0] << " ... " << Pending[Pending.size() - 1] << ")\n"; ); - if (env.anisoOptions.customized() == false) + // FIX (IO): --aniso* (explicit) or opt-in OME physical spacing selects the anisotropic path + double ax, ay, az; + if (! resolve_slide_anisotropy (env, sidx, ax, ay, az)) { - scanTrivialRois_3D (env, Pending, intens_fpath, label_fpath, t_index); + scanTrivialRois_3D (env, Pending, intens_fpath, label_fpath, t_index, channel); } else { - double ax = env.anisoOptions.get_aniso_x(), - ay = env.anisoOptions.get_aniso_y(), - az = env.anisoOptions.get_aniso_z(); - scanTrivialRois_3D_anisotropic (env, Pending, intens_fpath, label_fpath, t_index, ax, ay, az); + scanTrivialRois_3D_anisotropic (env, Pending, intens_fpath, label_fpath, t_index, channel, ax, ay, az); // rescan and update ROI's AABB for (auto lbl : Pending) @@ -727,16 +768,15 @@ namespace Nyxus std::cout << ">>> (labels " << Pending[0] << " ... " << Pending[Pending.size() - 1] << ")\n"; ); - if (env.anisoOptions.customized() == false) + // FIX (IO): --aniso* (explicit) or opt-in OME physical spacing selects the anisotropic path + double ax, ay, az; + if (! resolve_slide_anisotropy (env, sidx, ax, ay, az)) { - scanTrivialRois_3D (env, Pending, intens_fpath, label_fpath, t_index); + scanTrivialRois_3D (env, Pending, intens_fpath, label_fpath, t_index, channel); } else { - double ax = env.anisoOptions.get_aniso_x(), - ay = env.anisoOptions.get_aniso_y(), - az = env.anisoOptions.get_aniso_z(); - scanTrivialRois_3D_anisotropic (env, Pending, intens_fpath, label_fpath, t_index, ax, ay, az); + scanTrivialRois_3D_anisotropic (env, Pending, intens_fpath, label_fpath, t_index, channel, ax, ay, az); // rescan and update ROI's AABB for (auto lbl : Pending) diff --git a/src/nyx/python/new_bindings_py.cpp b/src/nyx/python/new_bindings_py.cpp index c23c29ce8..e55822435 100644 --- a/src/nyx/python/new_bindings_py.cpp +++ b/src/nyx/python/new_bindings_py.cpp @@ -81,7 +81,8 @@ void initialize_environment( float aniso_y, float aniso_z, bool merge_labels = false, - bool preserve_hu = false) // CT/HU: preserve absolute Hounsfield intensities + bool preserve_hu = false, // CT/HU: preserve absolute Hounsfield intensities + bool use_physical_spacing = false) // FIX (IO): opt-in OME PhysicalSize* voxel spacing (3D) { Environment & theEnvironment = Nyxus::findenv (instid); @@ -103,6 +104,7 @@ void initialize_environment( theEnvironment.fpimageOptions.set_min_intensity(min_intensity); theEnvironment.fpimageOptions.set_max_intensity(max_intensity); theEnvironment.fpimageOptions.set_preserve_hu(preserve_hu); + theEnvironment.use_physical_spacing_ = use_physical_spacing; // FIX (IO): opt-in physical spacing // Throws exception if invalid feature is passed theEnvironment.expand_featuregroups(); diff --git a/src/nyx/python/nyxus/nyxus.py b/src/nyx/python/nyxus/nyxus.py index 05a878745..d940b6076 100644 --- a/src/nyx/python/nyxus/nyxus.py +++ b/src/nyx/python/nyxus/nyxus.py @@ -154,7 +154,7 @@ def __init__( 'gabor_thold', 'gabor_thetas', 'gabor_freqs', 'channel_signature', 'parent_channel', 'child_channel', 'aggregate', 'dynamic_range', 'min_intensity', 'max_intensity', 'ram_limit', 'verbose', - 'anisotropy_x', 'anisotropy_y', 'mergerois', 'preserve_hu' + 'anisotropy_x', 'anisotropy_y', 'mergerois', 'preserve_hu', 'use_physical_spacing' } # Check for unexpected keyword arguments @@ -181,6 +181,7 @@ def __init__( min_intensity = kwargs.get('min_intensity', 0.0) max_intensity = kwargs.get('max_intensity', 1.0) preserve_hu = kwargs.get('preserve_hu', False) # CT/HU: preserve absolute Hounsfield values + use_physical_spacing = kwargs.get('use_physical_spacing', False) # opt-in OME PhysicalSize* voxel spacing (3D) ram_limit = kwargs.get('ram_limit', -1) verb_lvl = kwargs.get('verbose', 0) aniso_x = kwargs.get('anisotropy_x', 1.0) @@ -233,7 +234,8 @@ def __init__( aniso_y, aniso_z, mergerois, - preserve_hu) + preserve_hu, + use_physical_spacing) self.set_gabor_feature_params( kersize = gabor_kersize, @@ -1001,7 +1003,7 @@ def __init__( 'verbose', 'anisotropy_x', 'anisotropy_y', - 'anisotropy_z', 'preserve_hu' + 'anisotropy_z', 'preserve_hu', 'use_physical_spacing' } # Check for unexpected keyword arguments @@ -1021,6 +1023,7 @@ def __init__( min_intensity = kwargs.get('min_intensity', 0.0) max_intensity = kwargs.get('max_intensity', 1.0) preserve_hu = kwargs.get('preserve_hu', False) # CT/HU: preserve absolute Hounsfield values + use_physical_spacing = kwargs.get('use_physical_spacing', False) # opt-in OME PhysicalSize* voxel spacing (3D) verb_lvl = kwargs.get ('verbose', 0) aniso_x = kwargs.get('anisotropy_x', 1.0) aniso_y = kwargs.get('anisotropy_y', 1.0) @@ -1080,7 +1083,8 @@ def __init__( aniso_y, aniso_z, False, # merge_labels: 2D-segmented only - preserve_hu) + preserve_hu, + use_physical_spacing) # list of valid outputs that are used throughout featurize functions self._valid_output_types = ['pandas', 'arrowipc', 'parquet'] @@ -1570,6 +1574,7 @@ def __init__( min_intensity = kwargs.get('min_intensity', 0.0) max_intensity = kwargs.get('max_intensity', 1.0) preserve_hu = kwargs.get('preserve_hu', False) # CT/HU: preserve absolute Hounsfield values + use_physical_spacing = kwargs.get('use_physical_spacing', False) # opt-in OME PhysicalSize* voxel spacing (3D) ram_limit = kwargs.get('ram_limit', -1) verb_lvl = kwargs.get ('verbose', 0) aniso_x = kwargs.get('anisotropy_x', 1.0) @@ -1626,7 +1631,8 @@ def __init__( aniso_y, aniso_z, False, # merge_labels: 2D-segmented only - preserve_hu) + preserve_hu, + use_physical_spacing) # list of valid outputs that are used throughout featurize functions self._valid_output_types = ['pandas', 'arrowipc', 'parquet'] diff --git a/src/nyx/raw_format.h b/src/nyx/raw_format.h index 27c13d75c..76ad8cb3e 100644 --- a/src/nyx/raw_format.h +++ b/src/nyx/raw_format.h @@ -46,6 +46,13 @@ class RawFormatLoader return 1; } + // Physical voxel spacing along X/Y/Z (default 1.0 == uncalibrated) + unit string. + // OME loaders return the parsed PhysicalSize*; everything else stays 1.0 / "". + [[nodiscard]] virtual double physicalSizeX() const { return 1.0; } + [[nodiscard]] virtual double physicalSizeY() const { return 1.0; } + [[nodiscard]] virtual double physicalSizeZ() const { return 1.0; } + [[nodiscard]] virtual std::string physicalSizeUnit() const { return std::string(); } + [[nodiscard]] virtual size_t tileWidth(size_t level) const = 0; [[nodiscard]] virtual size_t tileHeight(size_t level) const = 0; diff --git a/src/nyx/raw_image_loader.cpp b/src/nyx/raw_image_loader.cpp index d85232240..5a388fc2f 100644 --- a/src/nyx/raw_image_loader.cpp +++ b/src/nyx/raw_image_loader.cpp @@ -294,6 +294,31 @@ size_t RawImageLoader::get_mask_time() return 0; // whole-slide mode } +size_t RawImageLoader::get_inten_channels() +{ + return intFL->numberChannels(); // FIX (IO): OME loaders report the real C; others default to 1 +} + +double RawImageLoader::get_physical_size_x() +{ + return intFL->physicalSizeX(); // FIX (IO): OME PhysicalSizeX; 1.0 if uncalibrated +} + +double RawImageLoader::get_physical_size_y() +{ + return intFL->physicalSizeY(); +} + +double RawImageLoader::get_physical_size_z() +{ + return intFL->physicalSizeZ(); +} + +std::string RawImageLoader::get_physical_size_unit() +{ + return intFL->physicalSizeUnit(); +} + std::string RawImageLoader::get_slide_descr() { std::string s = get_fp_phys_pixvoxels() ? "R-" : "N-"; diff --git a/src/nyx/raw_image_loader.h b/src/nyx/raw_image_loader.h index c871e6968..eb5c1db03 100644 --- a/src/nyx/raw_image_loader.h +++ b/src/nyx/raw_image_loader.h @@ -21,6 +21,84 @@ class RawImageLoader uint32_t get_cur_tile_seg_pixel(size_t pixel_idx); double get_cur_tile_dpequiv_pixel(size_t idx); + // FIX: visit every voxel of one (channel, timeframe) volume WITHOUT materializing it. + // The 3D prescan used to do ONE load_tile(0,0) and then index W*H*D voxels off the tile + // buffer -- but a per-plane loader (OME-TIFF / OME-Zarr) fills only ONE Z-plane, so + // everything past the first slice read out of bounds and corrupted the slide min/max. + // (It also assumed the tile stride equals the full width.) Streaming rather than filling + // a W*H*D staging buffer matters because the prescan repeats this for every (c,t) and + // only needs running min/max plus mask-driven ROI geometry: a double buffer would cost + // 4x the raw uint16 volume in RAM per pass and buy nothing. + // + // Walks the tile GRID of each plane, so planes spanning several tiles/chunks are handled; + // NIfTI-style whole-4D loaders (tileDepth == full depth) are slabbed out via frameBase. + // fn is invoked ONLY for in-mask voxels (msk != 0), as fn(x, y, z, intensity, msk); in + // whole-slide mode every voxel is in-mask with msk == 1. + template + bool for_each_voxel (size_t channel, size_t timeframe, F&& fn) + { + const bool haveSeg = (segFL != nullptr); + + // Use each loader's OWN layout: per-plane loaders (OME-Zarr, multi-page/OME-TIFF) + // deliver one Z-plane per read (tileDepth == 1); a whole-4D loader delivers the entire + // x*y*z*t blob in one read and ignores the layer arg. Mirrors ImageLoader::assemble_volume. + const size_t ltd = intFL->tileDepth (lvl), + ltt = intFL->tileTimestamps (lvl), + lntd = intFL->numberTileDepth (lvl), + lnth = intFL->numberTileHeight (lvl), + lntw = intFL->numberTileWidth (lvl), + frameStride = ltd * th * tw, + frameBase = (ltt > 1) ? timeframe * frameStride : 0; + + // The mask is channel-/timeframe-agnostic unless it genuinely has that many. FIX: a + // single-timeframe mask (the common 1-mask : N-timeframe-intensity case) was read at + // the intensity's `timeframe`, so for T>1 the TIFF mask loader computed an IFD past the + // end (ifdForPlane with t>0) and TIFFSetDirectory threw -- uncaught here, crashing the + // process (0xC0000409). Clamp the mask timeframe like the channel. (Zarr masks have no + // T axis to over-index, so only TIFF crashed.) + const size_t maskChannel = (haveSeg && channel < segFL->numberChannels()) ? channel : 0; + const size_t maskTimeframe = (haveSeg && timeframe < segFL->fullTimestamps (lvl)) ? timeframe : 0; + + for (size_t lz = 0; lz < lntd; lz++) + { + for (size_t tr = 0; tr < lnth; tr++) + for (size_t tc = 0; tc < lntw; tc++) + { + intFL->loadTileFromFile (tr, tc, lz, channel, timeframe, lvl); + if (haveSeg) + segFL->loadTileFromFile (tr, tc, lz, maskChannel, maskTimeframe, lvl); + + const size_t row0 = tr * th, col0 = tc * tw; + if (row0 < fh && col0 < fw) + { + const size_t validH = (std::min) (th, fh - row0), + validW = (std::min) (tw, fw - col0); + + for (size_t pz = 0; pz < ltd && (lz * ltd + pz) < fd; pz++) + { + const size_t gz = lz * ltd + pz; + for (size_t row = 0; row < validH; row++) + for (size_t col = 0; col < validW; col++) + { + const size_t src = frameBase + (pz * th + row) * tw + col; + const uint32_t msk = haveSeg ? segFL->get_uint32_pixel (src) : (uint32_t)1; + if (!msk) + continue; // off-ROI: skip before paying for the intensity read + fn (col0 + col, row0 + row, gz, intFL->get_dpequiv_pixel (src), msk); + } + } + } + + // the raw TIFF loaders malloc their tile buffer on each read (no-op elsewhere) + intFL->free_tile(); + if (haveSeg) + segFL->free_tile(); + } + } + + return true; + } + size_t get_tile_size(); size_t get_num_tiles_vert(); size_t get_num_tiles_hor(); @@ -34,6 +112,11 @@ class RawImageLoader size_t get_full_depth(); size_t get_inten_time(); size_t get_mask_time(); + size_t get_inten_channels(); // FIX (IO): number of intensity channels (>=1) + double get_physical_size_x(); // FIX (IO): physical voxel spacing (1.0 if uncalibrated) + double get_physical_size_y(); + double get_physical_size_z(); + std::string get_physical_size_unit(); // Select which channel (C) / timeframe (T) plane subsequent load_tile() calls // read. Default 0/0 preserves the single-channel, single-timepoint behavior. diff --git a/src/nyx/raw_omezarr.h b/src/nyx/raw_omezarr.h index d1f2af085..9c5dd42fc 100644 --- a/src/nyx/raw_omezarr.h +++ b/src/nyx/raw_omezarr.h @@ -24,6 +24,29 @@ #include "raw_format.h" +// FIX (IO): z5 Datatype -> the "(filePath.c_str()); - nlohmann::json file_attributes, ds_attributes; + nlohmann::json file_attributes; z5::readAttributes(*zarr_ptr_, file_attributes); - // open the highest-resolution dataset (level 0) - ds_name_ = file_attributes["multiscales"][0]["datasets"][0]["path"].get(); - const auto ds_handle = z5::filesystem::handle::Dataset(*zarr_ptr_, ds_name_); - fs::path metadata_path; - auto success = z5::filesystem::metadata_detail::getMetadataPath(ds_handle, metadata_path); - z5::filesystem::metadata_detail::readMetadata(metadata_path, ds_attributes); + // Resolve the level-0 dataset path (NGFF 0.5 nests 'multiscales' under "ome"; 0.4 has + // it at the attributes root). + const nlohmann::json& ms_root = (file_attributes.contains("ome") && file_attributes["ome"].contains("multiscales")) + ? file_attributes["ome"] : file_attributes; + ds_name_ = ms_root["multiscales"][0]["datasets"][0]["path"].get(); - std::vector level0Shape, chunkShape; - for (const auto& d : ds_attributes["shape"]) level0Shape.push_back(d.get()); - for (const auto& d : ds_attributes["chunks"]) chunkShape.push_back(d.get()); - std::string dtype_str = ds_attributes["dtype"].get(); + // FIX (IO): open via z5 (auto-detects Zarr v2 .zarray vs v3 zarr.json, handles v3 + // chunk-key encoding, codecs and sharding) and query shape/chunking/dtype from the + // Dataset object -- format-agnostic, so this reads BOTH OME-Zarr 0.4 and 0.5. + ds_ = z5::openDataset(*zarr_ptr_, ds_name_); + std::vector level0Shape(ds_->shape().begin(), ds_->shape().end()); + std::vector chunkShape(ds_->defaultChunkShape().begin(), ds_->defaultChunkShape().end()); + std::string dtype_str = zarr_dtype_string_of(ds_->getDtype()); // Resolve axis roles from the NGFF 'axes' metadata instead of assuming a // fixed [T,C,Z,Y,X] order. Falls back to legacy 5D TCZYX if 'axes' is absent. @@ -63,6 +88,13 @@ class RawOmezarrLoader: public RawFormatLoader iz_ = axes.storageIndexOf('Z'); ic_ = axes.storageIndexOf('C'); it_ = axes.storageIndexOf('T'); n_levels_ = axes.numberPyramidLevels(); + // FIX: advertise the real C/T extents so the pipeline iterates channels and + // timeframes; without this the base class default of 1 kept it on plane (c=0,t=0). + n_channels_ = axes.sizeC; + n_timeframes_ = axes.sizeT; + // FIX (IO): keep the parsed physical voxel spacing for opt-in calibration. + phys_x_ = axes.physX; phys_y_ = axes.physY; phys_z_ = axes.physZ; + phys_unit_ = axes.unitXY; } else { @@ -74,6 +106,10 @@ class RawOmezarrLoader: public RawFormatLoader ic_ = (n >= 4) ? n - 4 : -1; it_ = (n >= 5) ? n - 5 : -1; n_levels_ = 1; + // FIX: derive C/T extents from the positional axes so the fallback path also + // reports multi-channel / time-series counts (1 when the axis is absent). + n_channels_ = (ic_ >= 0) ? level0Shape[ic_] : 1; + n_timeframes_ = (it_ >= 0) ? level0Shape[it_] : 1; } // X and Y must resolve to real dimensions, else the read would index OOB. if (ix_ < 0 || iy_ < 0 || (size_t)ix_ >= level0Shape.size() || (size_t)iy_ >= level0Shape.size()) @@ -103,11 +139,6 @@ class RawOmezarrLoader: public RawFormatLoader // allocate the buffer dest = std::vector (tile_height_ * tile_width_); - - // Open the dataset once and cache the handle. The dataset metadata is - // immutable for the lifetime of this loader, so there is no need to - // re-open (and re-parse the .zarray metadata) on every tile read. - ds_ = z5::openDataset(*zarr_ptr_, ds_name_); } ~RawOmezarrLoader() override @@ -251,6 +282,15 @@ class RawOmezarrLoader: public RawFormatLoader [[nodiscard]] short bitsPerSample() const override { return bits_per_sample_; } /// @brief Number of resolution (pyramid) levels declared in multiscales [[nodiscard]] size_t numberPyramidLevels() const override { return n_levels_; } + /// @brief Channel (C) extent resolved from the NGFF axes (1 if no channel axis) + [[nodiscard]] size_t numberChannels() const override { return n_channels_; } + /// @brief Time (T) extent resolved from the NGFF axes (1 if no time axis) + [[nodiscard]] size_t fullTimestamps([[maybe_unused]] size_t level) const override { return n_timeframes_; } + /// @brief Physical voxel spacing from the NGFF coordinateTransformations (1.0 if uncalibrated) + [[nodiscard]] double physicalSizeX() const override { return phys_x_; } + [[nodiscard]] double physicalSizeY() const override { return phys_y_; } + [[nodiscard]] double physicalSizeZ() const override { return phys_z_; } + [[nodiscard]] std::string physicalSizeUnit() const override { return phys_unit_; } private: @@ -267,6 +307,10 @@ class RawOmezarrLoader: public RawFormatLoader size_t ndim_ = 5; ///< Number of on-disk dimensions (2..5) short bits_per_sample_ = 16; ///< Real bit depth size_t n_levels_ = 1; ///< Pyramid level count + size_t n_channels_ = 1; ///< Channel (C) extent + size_t n_timeframes_ = 1; ///< Time (T) extent + double phys_x_ = 1.0, phys_y_ = 1.0, phys_z_ = 1.0; ///< Physical voxel spacing + std::string phys_unit_; ///< Physical-size unit (e.g. "micrometer") short data_format_ = 0; std::unique_ptr zarr_ptr_; diff --git a/src/nyx/raw_tiff.h b/src/nyx/raw_tiff.h index 01e52d520..88070c69f 100644 --- a/src/nyx/raw_tiff.h +++ b/src/nyx/raw_tiff.h @@ -57,15 +57,33 @@ class RawTiffTileLoader : public RawFormatLoader TIFFGetField(tiff_, TIFFTAG_SAMPLESPERPIXEL, &samplesPerPixel); TIFFGetField(tiff_, TIFFTAG_BITSPERSAMPLE, &(this->bitsPerSample_)); TIFFGetField(tiff_, TIFFTAG_SAMPLEFORMAT, &(this->sampleFormat_)); + // FIX (IO): default absent SAMPLEFORMAT to 1 (unsigned int), like the strip loader, + // so tifffile-written tiled uint OME-TIFFs (which omit the tag) read correctly. + if (sampleFormat_ < 1 || sampleFormat_ > 3) + sampleFormat_ = 1; // Test if the file is greyscale - if (samplesPerPixel != 1) + if (samplesPerPixel != 1) { std::string erm = "RawTiffTileLoader error: file " + filePath + " is not greyscale, SamplesPerPixel = " + std::to_string(samplesPerPixel); std::cerr << erm << "\n"; throw (std::runtime_error(erm)); } + // FIX (IO): parse a tiled OME-TIFF's IFD-0 OME-XML so (z,c,t) map to the right IFD; + // a plain tiled TIFF stays 2D (fullDepth = directory count, usually 1). + fullDepth_ = TIFFNumberOfDirectories(tiff_); + char* desc = nullptr; + if (TIFFGetField(tiff_, TIFFTAG_IMAGEDESCRIPTION, &desc) && desc != nullptr) + { + ome_ = Nyxus::parse_ome_xml(desc); + if (ome_.valid) + { + is_ome_ = true; + fullDepth_ = ome_.sizeZ; // depth is SizeZ, not the total IFD count (= Z*C*T) + } + } + // Prepare the right typed getter function std::string message; switch (sampleFormat_) @@ -168,11 +186,23 @@ class RawTiffTileLoader : public RawFormatLoader void loadTileFromFile ( size_t indexRowGlobalTile, size_t indexColGlobalTile, - size_t indexLayerGlobalTile, - [[maybe_unused]] size_t indexChannel, // plain 2D TIFF: single channel - [[maybe_unused]] size_t indexTimeframe, // plain 2D TIFF: no time series + size_t indexLayerGlobalTile, // Z plane + size_t indexChannel, // C plane (OME); 0 for plain 2D TIFF + size_t indexTimeframe, // T plane (OME); 0 for plain 2D TIFF size_t level) override { + // FIX (IO): select the (z,c,t) plane's IFD before reading the tile (see grayscale_tiff.h). + if (is_ome_ && (indexLayerGlobalTile >= ome_.sizeZ + || indexChannel >= ome_.sizeC || indexTimeframe >= ome_.sizeT)) + { + throw std::runtime_error("RawTiffTileLoader: (z,c,t)=(" + std::to_string(indexLayerGlobalTile) + + "," + std::to_string(indexChannel) + "," + std::to_string(indexTimeframe) + ") out of range"); + } + size_t ifd = is_ome_ ? ome_.ifdForPlane(indexLayerGlobalTile, indexChannel, indexTimeframe) + : indexLayerGlobalTile; + if (TIFFSetDirectory(tiff_, (uint16_t)ifd) != 1) + throw std::runtime_error("RawTiffTileLoader: TIFFSetDirectory(ifd=" + std::to_string(ifd) + ") failed"); + // Low level read TIFF bytes auto t_szb = TIFFTileSize(tiff_); tiffTile = _TIFFmalloc(t_szb); @@ -218,10 +248,18 @@ class RawTiffTileLoader : public RawFormatLoader [[nodiscard]] size_t fullHeight([[maybe_unused]] size_t level) const { return fullHeight_; } [[nodiscard]] size_t fullWidth([[maybe_unused]] size_t level) const { return fullWidth_; } + [[nodiscard]] size_t fullDepth([[maybe_unused]] size_t level) const override { return fullDepth_; } [[nodiscard]] size_t tileWidth([[maybe_unused]] size_t level) const { return tileWidth_; } [[nodiscard]] size_t tileHeight([[maybe_unused]] size_t level) const { return tileHeight_; } [[nodiscard]] short bitsPerSample() const { return bitsPerSample_; } [[nodiscard]] size_t numberPyramidLevels() const { return 1; } + // FIX (IO): advertise C/T + physical spacing from the parsed OME-XML (1 / plain otherwise) + [[nodiscard]] size_t numberChannels() const override { return is_ome_ ? ome_.sizeC : 1; } + [[nodiscard]] size_t fullTimestamps([[maybe_unused]] size_t level) const override { return is_ome_ ? ome_.sizeT : 1; } + [[nodiscard]] double physicalSizeX() const override { return is_ome_ ? ome_.physX : 1.0; } + [[nodiscard]] double physicalSizeY() const override { return is_ome_ ? ome_.physY : 1.0; } + [[nodiscard]] double physicalSizeZ() const override { return is_ome_ ? ome_.physZ : 1.0; } + [[nodiscard]] std::string physicalSizeUnit() const override { return is_ome_ ? ome_.unitXY : std::string(); } private: @@ -285,18 +323,22 @@ class RawTiffTileLoader : public RawFormatLoader size_t fullHeight_ = 0, ///< Full height in pixel fullWidth_ = 0, ///< Full width in pixel + fullDepth_ = 1, ///< Full depth (Z); >1 for multi-plane OME-TIFF tileHeight_ = 0, ///< Tile height tileWidth_ = 0; ///< Tile width + bool is_ome_ = false; ///< true when IFD-0 carries an OME-XML block + Nyxus::OmeAxes ome_; ///< parsed OME dimensions (drives the (z,c,t)->IFD map) + short sampleFormat_ = 0, ///< Sample format as defined by libtiff bitsPerSample_ = 0; ///< Bit Per Sample as defined by libtiff - double minval, maxval; + double minval, maxval; // low level buffer tdata_t tiffTile = nullptr; - size_t t_szb = 0; + size_t t_szb = 0; }; class RawTiffStripLoader : public RawFormatLoader @@ -465,6 +507,15 @@ class RawTiffStripLoader : public RawFormatLoader size_t indexTimeframe, // T plane (OME-TIFF only) [[maybe_unused]] size_t level) override { + // FIX: (re)allocate lazily so this loader honors "free_tile() follows each + // loadTileFromFile()" -- see free_tile(). The ctor allocation covers the first read. + if (buf == nullptr) + { + buf = _TIFFmalloc (scanline_szb * tileHeight_); + if (!buf) + throw std::runtime_error("RawTiffStripLoader: _TIFFmalloc failed"); + } + size_t startLayer = indexLayerGlobalTile * tileDepth_, endLayer = std::min((indexLayerGlobalTile + 1) * tileDepth_, fullDepth_), @@ -501,15 +552,33 @@ class RawTiffStripLoader : public RawFormatLoader [[nodiscard]] size_t fullHeight([[maybe_unused]] size_t level) const override { return fullHeight_; } [[nodiscard]] size_t fullWidth([[maybe_unused]] size_t level) const override { return fullWidth_; } [[nodiscard]] size_t fullDepth([[maybe_unused]] size_t level) const override { return fullDepth_; } + // FIX: advertise C/T extents from the parsed OME-XML so the volumetric pipeline + // iterates channels/timeframes; non-OME TIFF keeps the single-plane default of 1. + [[nodiscard]] size_t numberChannels() const override { return is_ome_ ? ome_.sizeC : 1; } + [[nodiscard]] size_t fullTimestamps([[maybe_unused]] size_t level) const override { return is_ome_ ? ome_.sizeT : 1; } + // FIX (IO): physical voxel spacing from OME-XML PhysicalSize* (1.0 for plain TIFF) + [[nodiscard]] double physicalSizeX() const override { return is_ome_ ? ome_.physX : 1.0; } + [[nodiscard]] double physicalSizeY() const override { return is_ome_ ? ome_.physY : 1.0; } + [[nodiscard]] double physicalSizeZ() const override { return is_ome_ ? ome_.physZ : 1.0; } + [[nodiscard]] std::string physicalSizeUnit() const override { return is_ome_ ? ome_.unitXY : std::string(); } [[nodiscard]] size_t tileWidth([[maybe_unused]] size_t level) const override { return tileWidth_; } [[nodiscard]] size_t tileHeight([[maybe_unused]] size_t level) const override { return tileHeight_; } [[nodiscard]] size_t tileDepth([[maybe_unused]] size_t level) const override { return tileDepth_; } [[nodiscard]] short bitsPerSample() const override { return bitsPerSample_; } [[nodiscard]] size_t numberPyramidLevels() const override { return 1; } + // FIX: honor the RawFormatLoader contract that free_tile() follows EACH + // loadTileFromFile(). This buffer used to be allocated once in the ctor and never + // re-allocated, so a second load after a free wrote into freed memory (hit when + // looping Z to assemble a volume, and latent for multi-tile 2D scans which free per + // tile). Freeing is now idempotent and loadTileFromFile lazily re-allocates. void free_tile() override { - _TIFFfree(buf); + if (buf) + { + _TIFFfree(buf); + buf = nullptr; + } } uint32_t get_uint32_pixel(size_t idx) const diff --git a/src/nyx/reduce_trivial_rois.cpp b/src/nyx/reduce_trivial_rois.cpp index 03245098e..a9066381f 100644 --- a/src/nyx/reduce_trivial_rois.cpp +++ b/src/nyx/reduce_trivial_rois.cpp @@ -660,7 +660,7 @@ namespace Nyxus if (D3_VoxelIntensityFeatures::required(env.theFeatureSet)) { Fsettings& s = env.fsett_D3_VoxelIntensity; - D3_VoxelIntensityFeatures::extract (r, s); + D3_VoxelIntensityFeatures::extract (r, s, env.dataset); // FIX: pass the Dataset (see 3d_intensity.h) } //==== shape if (D3_SurfaceFeature::required(env.theFeatureSet)) diff --git a/src/nyx/slideprops.cpp b/src/nyx/slideprops.cpp index f96de6bbc..86ddf1e21 100644 --- a/src/nyx/slideprops.cpp +++ b/src/nyx/slideprops.cpp @@ -95,6 +95,11 @@ namespace Nyxus // time series p.inten_time = ilo.get_inten_time(); p.mask_time = ilo.get_mask_time(); + p.inten_channels = ilo.get_inten_channels(); // FIX (IO): number of channels (>=1) + p.phys_x = ilo.get_physical_size_x(); // FIX (IO): physical voxel spacing (1.0 if uncalibrated) + p.phys_y = ilo.get_physical_size_y(); + p.phys_z = ilo.get_physical_size_z(); + p.phys_unit = ilo.get_physical_size_unit(); // scan intensity slide's data @@ -270,95 +275,91 @@ namespace Nyxus // time series p.inten_time = ilo.get_inten_time(); p.mask_time = ilo.get_mask_time(); + p.inten_channels = ilo.get_inten_channels(); // FIX (IO): number of channels (>=1) + p.phys_x = ilo.get_physical_size_x(); // FIX (IO): physical voxel spacing (1.0 if uncalibrated) + p.phys_y = ilo.get_physical_size_y(); + p.phys_z = ilo.get_physical_size_z(); + p.phys_unit = ilo.get_physical_size_unit(); // scan intensity slide's data - bool wholeslide = p.fname_seg.empty(); - double slide_I_max = (std::numeric_limits::lowest)(), slide_I_min = (std::numeric_limits::max)(); std::unordered_set U; // unique ROI mask labels std::unordered_map R; // ROI data - // Read the volume. The image loader is in the open state by previously called processDataset_XX_YY () + // The image loader is in the open state by previously called processDataset_XX_YY (). + // FIX: no whole-volume staging buffer and no flat voxel index any more -- the scan + // streams tiles and receives (x,y,z) directly, so sliceSize/nVox/wholeslide are gone. size_t fullW = ilo.get_full_width(), fullH = ilo.get_full_height(), - fullD = ilo.get_full_depth(), - sliceSize = fullW * fullH, - nVox = sliceSize * fullD; - - // in the 3D case tiling is a formality, so fetch the only tile in the file - if (!ilo.load_tile(0, 0)) + fullD = ilo.get_full_depth(); + + // FIX: scan EVERY (channel, timeframe) volume, not just (0,0). + // (a) for_each_voxel() streams the whole X*Y*Z volume, walking each plane's tile grid. + // The old single load_tile(0,0) filled only ONE tile of ONE Z-plane for per-plane + // loaders (OME-TIFF/OME-Zarr), so the W*H*D voxel scan read past the tile buffer + // -- garbage that corrupted the slide min/max. + // (b) the pipeline featurizes every (c,t) plane, so the slide intensity range must + // cover all of them. Scanning only (c0,t0) yields a range that is too small for + // c>0 / t>0, and intensity-indexed buffers sized from it overflow. + // ROI geometry is taken from the first pass only (the mask is channel-agnostic). + const size_t n_chan = (std::max)((size_t)1, p.inten_channels), + n_time = (std::max)((size_t)1, p.inten_time); + bool first_pass = true; + + for (size_t scan_c = 0; scan_c < n_chan; scan_c++) + for (size_t scan_t = 0; scan_t < n_time; scan_t++) { -#ifdef WITH_PYTHON_H - throw "Error fetching tile"; -#endif - std::cerr << "Error fetching tile\n"; - return false; - } - - // iterate abstract tiles (in a tiled slide /e.g. tiled tiff/ they correspond to physical tiles, in a nontiled slide /e.g. scanline tiff or strip tiff/ they correspond to ) - int cnt = 1; - - // iterate voxels - for (size_t i = 0; i < nVox; i++) + // Streams tile by tile -- (a) above needs the whole volume scanned, but nothing here + // needs it resident, and staging W*H*D doubles once per (c,t) is 4x the raw uint16 + // volume in RAM for no benefit. for_each_voxel calls back only for in-mask voxels. + bool ok = ilo.for_each_voxel (scan_c, scan_t, + [&](size_t x, size_t y, size_t z, double dxequiv_I, uint32_t msk) { - // Mask - uint32_t msk = 1; // wholeslide by default - if (!wholeslide) - msk = ilo.get_cur_tile_seg_pixel(i); - - // Skip non-mask pixels - if (!msk) - continue; - - int z = i / sliceSize, - y = (i - z * sliceSize) / fullW, - x = (i - z * sliceSize) % fullW; - - // Skip tile buffer pixels beyond the image's bounds - if (x >= fullW || y >= fullH || z >= fullD) - continue; - // dynamic range within- and off-ROI - double dxequiv_I = ilo.get_cur_tile_dpequiv_pixel(i); slide_I_max = (std::max)(slide_I_max, dxequiv_I); slide_I_min = (std::min)(slide_I_min, dxequiv_I); - // Update pixel's ROI metrics - // - the following block mocks feed_pixel_2_metrics (x, y, dataI[i], msk, tidx) - if (U.find(msk) == U.end()) + // Update pixel's ROI metrics. FIX: geometry (labels, area, AABB) is derived from + // the FIRST (c,t) pass only -- the mask is the same for every channel/timeframe, + // so re-accumulating it per pass would multiply aux_area by n_chan*n_time. + if (first_pass) { - // Remember this label - U.insert(msk); + // - the following block mocks feed_pixel_2_metrics (x, y, dataI[i], msk, tidx) + if (U.find(msk) == U.end()) + { + // Remember this label + U.insert(msk); - // Initialize the ROI label record - LR r(msk); + // Initialize the ROI label record + LR r(msk); - // - mocking init_label_record_3 (newData, theSegFname, theIntFname, x, y, label, intensity, tile_index) - // Initialize basic counters - r.aux_area = 1; - r.aux_min = r.aux_max = 0; //we don't have uint-cast intensities at this moment - r.init_aabb_3D(x, y, z); + // - mocking init_label_record_3 (newData, theSegFname, theIntFname, x, y, label, intensity, tile_index) + // Initialize basic counters + r.aux_area = 1; + r.aux_min = r.aux_max = 0; //we don't have uint-cast intensities at this moment + r.init_aabb_3D((int)x, (int)y, (int)z); - // - not storing file names (r.segFname = segFile, r.intFname = intFile) but will do so in the future + // - not storing file names (r.segFname = segFile, r.intFname = intFile) but will do so in the future - // Attach - R[msk] = r; - } - else - { - // Update basic ROI info (info that doesn't require costly calculations) - LR& r = R[msk]; + // Attach + R[msk] = r; + } + else + { + // Update basic ROI info (info that doesn't require costly calculations) + LR& r = R[msk]; - // - mocking update_label_record_2 (r, x, y, label, intensity, tile_index) + // - mocking update_label_record_2 (r, x, y, label, intensity, tile_index) - // Per-ROI - r.aux_area++; + // Per-ROI + r.aux_area++; - // save - r.update_aabb_3D(x, y, z); + // save + r.update_aabb_3D((int)x, (int)y, (int)z); + } } #ifdef WITH_PYTHON_H @@ -367,7 +368,19 @@ namespace Nyxus throw pybind11::error_already_set(); #endif - } //- all voxels + }); //- all voxels + + if (!ok) + { +#ifdef WITH_PYTHON_H + throw "Error fetching volume"; +#endif + std::cerr << "Error fetching volume\n"; + return false; + } + + first_pass = false; + } //- all (channel, timeframe) volumes //****** fix ROIs' AABBs with respect to anisotropy diff --git a/src/nyx/slideprops.h b/src/nyx/slideprops.h index 5bbfcf0d3..5a64c9eee 100644 --- a/src/nyx/slideprops.h +++ b/src/nyx/slideprops.h @@ -28,6 +28,9 @@ class SlideProps max_roi_h = 0; max_roi_d = 0; inten_time = mask_time = 0; + inten_channels = 1; // FIX (IO): >=1 so the channel loop runs at least once; set from the loader in scan_slide_props + phys_x = phys_y = phys_z = 1.0; // FIX (IO): physical voxel spacing (1.0 == uncalibrated); set in scan_slide_props + phys_unit = ""; } // pre-ROI intensity range in DP @@ -86,6 +89,14 @@ class SlideProps // time series size_t inten_time, mask_time; // number of time frames + + // channels (FIX (IO): number of intensity channels; drives the per-channel output rows) + size_t inten_channels; + + // physical voxel spacing (FIX (IO): OME PhysicalSize*; 1.0 == uncalibrated). Used only + // when --use-physical-spacing is set; also emitted as phys_x/y/z + phys_unit output columns. + double phys_x, phys_y, phys_z; + std::string phys_unit; }; namespace Nyxus diff --git a/src/nyx/workflow_2d_segmented.cpp b/src/nyx/workflow_2d_segmented.cpp index ebcc051ce..c416957b7 100644 --- a/src/nyx/workflow_2d_segmented.cpp +++ b/src/nyx/workflow_2d_segmented.cpp @@ -166,11 +166,12 @@ namespace Nyxus const SaveOption saveOption, const std::string& outputPath) { + env.reset_csv_output_state(); // FIX: this run's CSV files start fresh (see Environment::csv_paths_written) #ifdef CHECKTIMING if (Stopwatch::inclusive()) Stopwatch::reset(); -#endif +#endif //********************** prescan *********************** @@ -322,7 +323,7 @@ namespace Nyxus if (write_apache) { - auto [status, msg] = env.arrow_stream.write_arrow_file (Nyxus::get_feature_values(env, env.theFeatureSet, env.uniqueLabels, env.roiData, env.dataset)); + auto [status, msg] = env.arrow_stream.write_arrow_file (Nyxus::get_feature_values(env, env.theFeatureSet, env.uniqueLabels, env.roiData, env.dataset, DEFAULT_T_INDEX, DEFAULT_C_INDEX)); if (!status) { std::cerr << "Error writing Arrow file: " << msg.value() << std::endl; @@ -332,7 +333,7 @@ namespace Nyxus else if (saveOption == SaveOption::saveCSV) { - ok = save_features_2_csv (env, ifp, lfp, outputPath, 0/*pass 0 as t_index is not used in 2D scenario*/ , env.resultOptions.need_aggregation()); + ok = save_features_2_csv (env, ifp, lfp, outputPath, 0/*t_index unused in 2D*/, DEFAULT_C_INDEX/*channel unused in 2D*/, env.resultOptions.need_aggregation()); if (ok == false) { @@ -342,7 +343,7 @@ namespace Nyxus } else { - ok = save_features_2_buffer (env.theResultsCache, env, DEFAULT_T_INDEX); + ok = save_features_2_buffer (env.theResultsCache, env, DEFAULT_T_INDEX, DEFAULT_C_INDEX); if (ok == false) { diff --git a/src/nyx/workflow_2d_whole.cpp b/src/nyx/workflow_2d_whole.cpp index 9911f404f..54fa617ff 100644 --- a/src/nyx/workflow_2d_whole.cpp +++ b/src/nyx/workflow_2d_whole.cpp @@ -177,7 +177,7 @@ namespace Nyxus // thread-safely save results of this single slide if (write_apache) { - auto [status, msg] = save_features_2_apache_wholeslide (env, vroi, p.fname_int); + auto [status, msg] = save_features_2_apache_wholeslide (env, vroi, p.fname_int, 0/*t_index*/, 0/*c_index*/); if (! status) { std::cerr << "Error writing Arrow file: " << msg.value() << std::endl; @@ -187,7 +187,7 @@ namespace Nyxus else if (saveOption == SaveOption::saveCSV) { - if (save_features_2_csv_wholeslide(env, vroi, p.fname_int, "", outputPath, 0 /*pass 0 as t_index is not used in 2D scenario*/) == false) + if (save_features_2_csv_wholeslide(env, vroi, p.fname_int, "", outputPath, 0 /*t_index unused in 2D*/, 0 /*c_index unused in 2D*/) == false) { std::cout << "error saving results to CSV file, details: " << __FILE__ << ":" << __LINE__ << std::endl; rv = 2; @@ -196,7 +196,7 @@ namespace Nyxus else { // pulls feature values from 'vroi' and appends them to global object 'theResultsCache' exposed to Python API - if (save_features_2_buffer_wholeslide (env.theResultsCache, env, vroi, p.fname_int, "") == false) + if (save_features_2_buffer_wholeslide (env.theResultsCache, env, vroi, p.fname_int, "", 0/*t_index*/, 0/*c_index*/) == false) { std::cerr << "Error saving features to the results buffer" << std::endl; rv = 2; @@ -219,7 +219,9 @@ namespace Nyxus const SaveOption saveOption, const std::string& outputPath) { - // create a vector of blank mask file names. Blank mask counterparts + env.reset_csv_output_state(); // FIX: this run's CSV files start fresh (see Environment::csv_paths_written) + + // create a vector of blank mask file names. Blank mask counterparts // of intensity files will serve as the condition of the whole-slide scenario in the prescan phase std::vector labelFiles (intensFiles.size()); diff --git a/src/nyx/workflow_3d_segmented.cpp b/src/nyx/workflow_3d_segmented.cpp index aa3630eb6..3b382d607 100644 --- a/src/nyx/workflow_3d_segmented.cpp +++ b/src/nyx/workflow_3d_segmented.cpp @@ -32,7 +32,7 @@ namespace Nyxus { - bool processIntSegImagePair_3D (Environment & env, const std::string& intens_fpath, const std::string& label_fpath, size_t filepair_index, size_t t_index, const std::vector& z_indices) + bool processIntSegImagePair_3D (Environment & env, const std::string& intens_fpath, const std::string& label_fpath, size_t filepair_index, size_t t_index, size_t channel, const std::vector& z_indices) { std::vector trivRois, nontrivRois; @@ -51,7 +51,7 @@ namespace Nyxus if (z_indices.size()) okGather = gatherRoisMetrics_25D (env, filepair_index, intens_fpath, label_fpath, z_indices); else - okGather = gatherRoisMetrics_3D (env, filepair_index, intens_fpath, label_fpath, t_index); + okGather = gatherRoisMetrics_3D (env, filepair_index, intens_fpath, label_fpath, t_index, channel); if (!okGather) { std::string msg = "Error gathering ROI metrics from " + intens_fpath + " / " + label_fpath + "\n"; @@ -124,7 +124,7 @@ namespace Nyxus if (z_indices.size()) processTrivialRois_25D (env, trivRois, intens_fpath, label_fpath, env.get_ram_limit(), z_indices); else - processTrivialRois_3D (env, filepair_index, t_index, trivRois, intens_fpath, label_fpath, env.get_ram_limit()); + processTrivialRois_3D (env, filepair_index, t_index, channel, trivRois, intens_fpath, label_fpath, env.get_ram_limit()); } // Phase 3: process nontrivial (oversized) ROIs, if any @@ -145,6 +145,8 @@ namespace Nyxus const SaveOption saveOption, const std::string& outputPath) { + env.reset_csv_output_state(); // FIX: this run's CSV files start fresh (see Environment::csv_paths_written) + //********************** prescan *********************** // slide properties @@ -202,6 +204,10 @@ namespace Nyxus // iterate intensity-mask pairs for (size_t i=0; i1 channel, so each C-plane is featurized and tagged with c_index) + for (size_t c=0; c < env.dataset.dataset_props[i].inten_channels; c++) + { // iterate time frames for (size_t t=0; t < env.dataset.dataset_props[i].inten_time; t++) { @@ -219,19 +225,19 @@ namespace Nyxus // Display (1) dataset progress info and (2) file pair info int digits = 2, k = (int)std::pow(10.f, digits); float perCent = float(i * 100 * k / nf) / float(k); - VERBOSLVL1(env.get_verbosity_level(), std::cout << "[ " << std::setw(digits + 2) << perCent << "% ]\t" << " INT: " << ifile.fname << " SEG: " << mfile.fname << " T:" << t << "\n") - - bool ok = processIntSegImagePair_3D (env, ifile.fdir+ifile.fname, mfile.fdir+mfile.fname, i, t, intensFiles[i].z_indices); + VERBOSLVL1(env.get_verbosity_level(), std::cout << "[ " << std::setw(digits + 2) << perCent << "% ]\t" << " INT: " << ifile.fname << " SEG: " << mfile.fname << " C:" << c << " T:" << t << "\n") + + bool ok = processIntSegImagePair_3D (env, ifile.fdir+ifile.fname, mfile.fdir+mfile.fname, i, t, c, intensFiles[i].z_indices); if (ok == false) { std::cerr << "processIntSegImagePair() returned an error code while processing file pair " << ifile.fname << " - " << mfile.fname << '\n'; return 1; } - // Output features + // Output features (tag each row with its timeframe t and channel c) if (writeApache) { - auto [status, msg] = env.arrow_stream.write_arrow_file(Nyxus::get_feature_values(env, env.theFeatureSet, env.uniqueLabels, env.roiData, env.dataset)); + auto [status, msg] = env.arrow_stream.write_arrow_file(Nyxus::get_feature_values(env, env.theFeatureSet, env.uniqueLabels, env.roiData, env.dataset, t, c)); if (!status) { std::cout << "Error writing Arrow file: " << msg.value() << std::endl; @@ -241,7 +247,7 @@ namespace Nyxus else if (saveOption == SaveOption::saveCSV) { - if (!save_features_2_csv(env, ifile.fname, mfile.fname, outputPath, t, env.resultOptions.need_aggregation())) + if (!save_features_2_csv(env, ifile.fname, mfile.fname, outputPath, t, c, env.resultOptions.need_aggregation())) { std::cout << "error saving results to CSV file, details: " << __FILE__ << ":" << __LINE__ << std::endl; return 2; @@ -249,7 +255,7 @@ namespace Nyxus } else { - if (!save_features_2_buffer(env.theResultsCache, env, t)) + if (!save_features_2_buffer(env.theResultsCache, env, t, c)) { std::cout << "error saving results to a buffer, details: " << __FILE__ << ":" << __LINE__ << std::endl; return 2; @@ -269,6 +275,7 @@ namespace Nyxus } #endif } //- time frames + } //- channels } //- inten-mask pairs if (writeApache) diff --git a/src/nyx/workflow_3d_whole.cpp b/src/nyx/workflow_3d_whole.cpp index a452600b2..0ff28ca08 100644 --- a/src/nyx/workflow_3d_whole.cpp +++ b/src/nyx/workflow_3d_whole.cpp @@ -32,7 +32,7 @@ namespace Nyxus { - bool featurize_triv_wholevolume (Environment & env, size_t sidx, ImageLoader& imlo, size_t memory_limit, LR& vroi) + bool featurize_triv_wholevolume (Environment & env, size_t sidx, ImageLoader& imlo, size_t memory_limit, LR& vroi, size_t channel, size_t timeframe) { const std::string& ifpath = env.dataset.dataset_props[sidx].fname_int; @@ -49,21 +49,25 @@ namespace Nyxus } // read the slide into a pixel cloud - if (env.anisoOptions.customized() == false) + // FIX (IO): --aniso* (explicit) or opt-in OME physical spacing selects the anisotropic path + double ax, ay, az; + if (! resolve_slide_anisotropy (env, sidx, ax, ay, az)) { VERBOSLVL2(env.get_verbosity_level(), std::cout << "\nscan_trivial_wholeslide()\n"); - scan_trivial_wholevolume (vroi, ifpath, imlo); + scan_trivial_wholevolume (vroi, ifpath, imlo, channel, timeframe); } else { VERBOSLVL2(env.get_verbosity_level(), std::cout << "\nscan_trivial_wholeslide_ANISO()\n"); scan_trivial_wholevolume_anisotropic ( - vroi, - ifpath, - imlo, - env.anisoOptions.get_aniso_x(), - env.anisoOptions.get_aniso_y(), - env.anisoOptions.get_aniso_z()); + vroi, + ifpath, + imlo, + ax, + ay, + az, + channel, + timeframe); } // allocate memory for feature helpers (image matrix, etc) @@ -87,7 +91,7 @@ namespace Nyxus return true; } - bool featurize_wholevolume (Environment & env, size_t sidx, ImageLoader& imlo, LR& vroi) + bool featurize_wholevolume (Environment & env, size_t sidx, ImageLoader& imlo, LR& vroi, size_t channel, size_t timeframe) { //***** phase 1: copy ROI metrics from the slide properties, thanks to the WSI scenario const SlideProps& p = env.dataset.dataset_props[sidx]; @@ -132,7 +136,7 @@ namespace Nyxus } //***** phase 2: extract features - featurize_triv_wholevolume (env, sidx, imlo, env.get_ram_limit(), vroi); // segmented counterpart: phase2.cpp / processTrivialRois () + featurize_triv_wholevolume (env, sidx, imlo, env.get_ram_limit(), vroi, channel, timeframe); // segmented counterpart: phase2.cpp / processTrivialRois () return true; } @@ -158,44 +162,49 @@ namespace Nyxus rv = 1; } - LR vroi(1); // virtual ROI representing the whole slide ROI-labelled as '1' - - if (featurize_wholevolume (env, slide_idx, imlo, vroi) == false) // non-wsi counterpart: processIntSegImagePair() - { - std::cerr << "Error featurizing slide " << p.fname_int << " @ " << __FILE__ << ":" << __LINE__ << "\n"; - rv = 1; - } + // FIX (IO): featurize every (channel, timeframe) plane and emit one whole-slide row + // per plane, tagged with c_index/t_index — mirroring the segmented path. A + // single-channel, single-timeframe slide reports 1/1, so its output is unchanged. + for (size_t c = 0; c < p.inten_channels; c++) + for (size_t t = 0; t < p.inten_time; t++) + { + LR vroi(1); // virtual ROI representing the whole slide ROI-labelled as '1' - // thread-safely save results of this single slide - if (write_apache) - { - auto [status, msg] = save_features_2_apache_wholeslide (env, vroi, p.fname_int); - if (!status) + if (featurize_wholevolume (env, slide_idx, imlo, vroi, c, t) == false) // non-wsi counterpart: processIntSegImagePair() { - std::cerr << "Error writing Arrow file: " << msg.value() << std::endl; - rv = 2; + std::cerr << "Error featurizing slide " << p.fname_int << " @ " << __FILE__ << ":" << __LINE__ << "\n"; + rv = 1; } - } - else - if (saveOption == SaveOption::saveCSV) + + // thread-safely save results of this single (slide, channel, timeframe) + if (write_apache) { - if (save_features_2_csv_wholeslide (env, vroi, p.fname_int, "", outputPath, - 0 // pass 0 as t_index - ) == false) + auto [status, msg] = save_features_2_apache_wholeslide (env, vroi, p.fname_int, t, c); + if (!status) { - std::cout << "error saving results to CSV file, details: " << __FILE__ << ":" << __LINE__ << std::endl; + std::cerr << "Error writing Arrow file: " << msg.value() << std::endl; rv = 2; } } else - { - // pulls feature values from 'vroi' and appends them to global object 'theResultsCache' exposed to Python API - if (save_features_2_buffer_wholeslide (env.theResultsCache, env, vroi, p.fname_int, "") == false) + if (saveOption == SaveOption::saveCSV) { - std::cerr << "Error saving features to the results buffer" << std::endl; - rv = 2; + if (save_features_2_csv_wholeslide (env, vroi, p.fname_int, "", outputPath, t, c) == false) + { + std::cout << "error saving results to CSV file, details: " << __FILE__ << ":" << __LINE__ << std::endl; + rv = 2; + } } - } + else + { + // pulls feature values from 'vroi' and appends them to global object 'theResultsCache' exposed to Python API + if (save_features_2_buffer_wholeslide (env.theResultsCache, env, vroi, p.fname_int, "", t, c) == false) + { + std::cerr << "Error saving features to the results buffer" << std::endl; + rv = 2; + } + } + } //- channels x timeframes imlo.close(); @@ -215,6 +224,8 @@ namespace Nyxus const SaveOption saveOption, const std::string& outputPath) { + env.reset_csv_output_state(); // FIX: this run's CSV files start fresh (see Environment::csv_paths_written) + //**** prescan all files size_t nf = intensFiles.size(); diff --git a/src/nyx/workflow_pythonapi.cpp b/src/nyx/workflow_pythonapi.cpp index 5f336e261..33dcd4035 100644 --- a/src/nyx/workflow_pythonapi.cpp +++ b/src/nyx/workflow_pythonapi.cpp @@ -147,13 +147,13 @@ namespace Nyxus if (write_apache) { - auto [status, msg] = env.arrow_stream.write_arrow_file (Nyxus::get_feature_values(env, env.theFeatureSet, env.uniqueLabels, env.roiData, env.dataset)); - if (!status) + auto [status, msg] = env.arrow_stream.write_arrow_file (Nyxus::get_feature_values(env, env.theFeatureSet, env.uniqueLabels, env.roiData, env.dataset, DEFAULT_T_INDEX, DEFAULT_C_INDEX)); + if (!status) return { "error writing Arrow file: " + msg.value() }; - } - else + } + else { - if (! save_features_2_buffer(env.theResultsCache, env, DEFAULT_T_INDEX)) + if (! save_features_2_buffer(env.theResultsCache, env, DEFAULT_T_INDEX, DEFAULT_C_INDEX)) return { "error saving results to a buffer" }; } diff --git a/tests/data/ometiff/dim3_emptymask.ome.tif b/tests/data/ometiff/dim3_emptymask.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..644030e823d9f683b1e70dc1a69d772cf9448e93 GIT binary patch literal 1743 zcmeHHO^ee|6n&YF#f1#IP(e50twhLItsxVVjL)l?pB_gr3}-bMJlko@6k1iS8qWP7qR#kpf8GDK%~bK2lm` z{2}00jU}($z$eNP`gDZQ`(uP2)!HAxdEDSUY2RtbduWr5c29~{#mwtjrZoTviaMMfe z)?M#NA04L4&j;-C%U|mf!8{H%OX;)bdnmDYwoPL71I-JTavxi|iB-y@bk6wF$J5EV z=HRoyyS#X-0+AO!UI~%8hOyaf^faS9VNsqIQJU@aG+!FyXhjoJ7$7h@re$lUjVqP``f#?y0^ke6RK~EIaa6BLEp#g zoV)8ao4ZyR*^y1C=5(D%v#f=sg_aX)VK;mo+NT|79zxf>oDz}dK5l&F)Q;J)bz8S^ z;0+S8q^e9@A{XZUjwH0cHGwf=TN)Q7rGk>vX9?wUN0<4WLiX8J?|OpO$u0xwnsH&l zoX6}Vo%z_Q(3OwtSPvFk#jbtat*Ff` literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim3_mask.ome.tif b/tests/data/ometiff/dim3_mask.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..5377cad0317b1bb9537ed0447df6fa88e47e8612 GIT binary patch literal 1743 zcmcgs&2G~`5MDPW;(#hR01{lRJs~9jQry(CU8ICcky09wL`r+=#@-|=*=x&oNP@(> z@Bo}Sa^yL93XX_b+mYNJB7y?8*8X<dz5&iN>0aUgE8yo9{s#C(O>2Pv z2VnaS>jvV8`1O^u>2%6MswQjrvi*`kxw!>%gT?z#_62FnV^@E7`CD8UpYHN^-TzKUsd>W0qh3S(XUuv9DT+SrYZD zFXwC(h4KfQW-R6|HdGzUl>2eO_{_zl;knYnXP$F;@m2;R&s;pu^TgJ)<#MUU3FQ&< z(>U|vWUa>OOdI%f8j(x`foAH4rRWwemG&xQx75p5g~HTzLmOZ8N~OYhmJ{w%P-V={ z3U|HOCwYp(g UFL*A(32u210L7u<4fEll-+p)FB>(^b literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim4_mask_c2.ome.tif b/tests/data/ometiff/dim4_mask_c2.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..bba34ac484b112883424d2c8f3ff1f0ba9cd59c6 GIT binary patch literal 2859 zcmeHJ-HOvd6i&9gs~589g$fE1=1Ro;+6^%@U7>ZM&~*!Kp}SX`pJ_XiOhS@wTQ9^b zpTno{QWV4o@Co)&JozcjE?A7DH)2aV-%P&moUdncl5{%HnA;4)954*K%dmiw8JpvM zz&mU$UB3&smg9uCU%&_K4)bk?VcvrHUaq|Y&iw-CK_TYD9A5zE5xbYh{|xwXj=unY zTF}Z6{|>DEfa9tL{$MN~F&U4?#Kl6egfp8zEGSpsgt@}W_$Tv}wBe_0yqo+Dq0>`0 z`DN$-kmpF`wPYoms65=qmeTUGAfrf))UPDLC#_7px4u~`|&@_$%T@>f@x!?zwdc+R> z$o7M!;D-~jZ%?sjMj{AAMUqusQcP z*Y#~PPW_cm&Xrb(zK4ZHD4zUc)nj(RvuO=RpYnM~uJ8BfJ#L(J*Fiy@baF@Sf)wi%mT=NL)@ c`FpNOam%04IQc_KzN{Dk(!#+A*I1MJ1qm1iZ~y=R literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim5_badifd.ome.tif b/tests/data/ometiff/dim5_badifd.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..2d91ed85af1be14ac1f6369513333b90b96d09fb GIT binary patch literal 1634 zcmcJP`F9gl6vsd5CTlG!#TLXkf+EsPQXsS?lZq*kur<&iZ9ySTChbTv2}w#?WN|@R z+>w1p1;Gt)0|kXYfgkzNKfyo2JIVAI&*5;O2Y;CJKJUJJzwgd__uWuvHe-NVa8%(C zvStpOYlJHtl|_4dQ2z*t}8v8y(d}{oCR{ z)#f|Gv&v#UqCY`={}Owq3ViKDsibp@p3NC)Q&T)Hx8l^zSUPT){hG2S5^QQw+Wo%H z6^otXXy!C!AfL~)scNB6aHTW4nKEM8bS{?847<|Vezhw$pr@iaRRYvzx5wM$_9}i~ zC>8D3ouLj*DN>tkvND>|HKi{aHxhC22pdCsGFNmdq2<$Iq;%6ttTP+evzpS~(;J8= z&d6{^LI({q@9`?mE@P|Sttl-f>d_S2)~hM=O16OfSBWAP`F#;1k?4r#qt2j_&E;iT z_9~*RkD!DLhJuo_jNxR|)C1{3GjA!V_bnm&7A0gHtwggeA>(Kz=Gqdit#{E-OXuzP zsl~gIQSQz!l`B#cH~Uu!dJzOE*3AvX1q<#_McgBT@Q@ z5l1IMKLZ%N!6uTVFiDePGg)%v8Dt9uh8SimZ?cW;>|iHv@iy=9F1vV-_u0)J_Og!; z_>hnIm{0hW&)CoB9N-{_ILr}_a*X4g;3TIw%^A+}1z++N=Qz*TT;L+#@Gal*JwI@X z%Ut0{e&T0-kzR0>Yy8IVT;~RV@FzF9WxZni?T@Cn(BXhORaEuZ>g7g idBs@#aI(Do5VO4Tzhzk`{q#Zl^u5lq^uqpop8o*mFa2)- literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim5_calibrated.ome.tif b/tests/data/ometiff/dim5_calibrated.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..d557df93416959643ceff7cb4aeaa36be1329c15 GIT binary patch literal 7273 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 literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim5_multitile.ome.tif b/tests/data/ometiff/dim5_multitile.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..862ff649077c62c936906e4efc20ec3b59e434bd GIT binary patch literal 13939 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 bonqobyA=LczJHY?ME)*aXc;=o3QGS2c7z<4 literal 0 HcmV?d00001 diff --git a/tests/data/ometiff/dim5_oddtile.ome.tif b/tests/data/ometiff/dim5_oddtile.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..d32bdd89df4b7df249a89cb5a2086b216173ed9c GIT binary patch literal 7347 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 path, distinct from the strip loaders.""" + path = os.path.join(HERE, name) + data = _data_for(order) + tifffile.imwrite(path, data, photometric="minisblack", metadata={"axes": order}, + tile=(16, 16), ome=True) + with tifffile.TiffFile(path) as tf: + import re + dimord = re.search(r'DimensionOrder="([A-Z]+)"', tf.ome_metadata).group(1) + print("wrote %-20s axes=%-6s DimensionOrder=%s IFDs=%d tiled=%s" + % (name, order, dimord, len(tf.pages), tf.pages[0].is_tiled)) + + +# Multi-tile fixture dims. TIFF tile sizes must be multiples of 16, so the plane has to +# exceed 16 in BOTH directions to produce a real tile grid -- hence its own (larger Y/X, +# smaller C/Z/T) shape rather than the 6x8 plane the other fixtures share. +MT_T, MT_C, MT_Z, MT_Y, MT_X = 1, 2, 2, 32, 48 + + +def write_multitile(name, T=MT_T, C=MT_C, Z=MT_Z, Y=MT_Y, X=MT_X): + """A tiled OME-TIFF whose every plane spans a GRID of 16x16 tiles (default 32x48 plane + -> 2x3 = 6 tiles), unlike dim5_tiled.ome.tif where a single tile covers the whole plane. + A volumetric read that fetches only tile (0,0) returns wrong data for everything outside + the first 16x16 corner. Same coordinate encoding as encoded_tczyx, its own dims. + + When Y or X is not a multiple of 16 the last tile of that row/column is PARTIAL, which + exercises the validH/validW seam clamp in the volumetric assembly.""" + t, c, z, y, x = np.meshgrid(np.arange(T), np.arange(C), np.arange(Z), + np.arange(Y), np.arange(X), indexing="ij") + data = (1 + ((((t * C + c) * Z + z) * Y + y) * X + x)).astype("uint16") + path = os.path.join(HERE, name) + tifffile.imwrite(path, data, photometric="minisblack", metadata={"axes": "TCZYX"}, + tile=(16, 16), ome=True) + with tifffile.TiffFile(path) as tf: + print("wrote %-24s %dx%d plane, tile grid %dx%d (last %dx%d), IFDs=%d tiled=%s max=%d" + % (name, Y, X, -(-Y // 16), -(-X // 16), Y % 16 or 16, X % 16 or 16, + len(tf.pages), tf.pages[0].is_tiled, data.max())) + + +def write_mask(name): + """Single-channel, single-timeframe 3D (ZYX) label mask matching dim5's geometry. + Used to test the 1-channel-mask : N-channel-intensity pairing: the mask is + channel-agnostic and must be reused for every intensity channel.""" + path = os.path.join(HERE, name) + m = np.zeros((Z, Y, X), "uint16") + m[:, 1:5, 1:7] = 1 # one ROI (label 1), interior so it has a real bbox + tifffile.imwrite(path, m, photometric="minisblack", metadata={"axes": "ZYX"}, ome=True) + print("wrote %-20s (single-channel ZYX label mask, ROI voxels=%d)" % (name, int(m.sum()))) + + +def write_reordered(name): + """An OME-TIFF whose planes are physically stored in REVERSED IFD order, with explicit + blocks mapping each logical (z,c,t) to its scrambled IFD. This is what a reader + that ignores TiffData and assumes canonical (contiguous-from-IFD-0) plane order gets wrong: + it would read the reversed plane's pixels. Writers like bioformats emit per-plane TiffData; + a non-canonical mapping (or a non-zero starting IFD) is exactly where the assumption breaks. + + Dims T=1,C=2,Z=3, plane 6x8, DimensionOrder XYZCT -> canonical ordinal ord = z + c*Z. The + logical plane with ordinal `ord` is stored at physical IFD (5 - ord). Same encoded value as + encoded_tczyx so the standard facade test can check it.""" + rT, rC, rZ = 1, 2, 3 + total = rZ * rC * rT + # physical IFD p holds the logical plane whose ordinal is (total-1 - p) + phys = np.empty((total, Y, X), "uint16") + tiffdata = [] + for c in range(rC): + for z in range(rZ): + ord_ = z + c * rZ # canonical XYZCT ordinal (t=0) + ifd = (total - 1) - ord_ # reversed physical IFD + yy, xx = np.meshgrid(np.arange(Y), np.arange(X), indexing="ij") + phys[ifd] = (1 + ((((0 * rC + c) * rZ + z) * Y + yy) * X + xx)).astype("uint16") + tiffdata.append('' + % (c, z, ifd)) + ome = ('' + '' + '' + '%s' + '' + % (X, Y, rZ, rC, rT, "".join(tiffdata))) + path = os.path.join(HERE, name) + tifffile.imwrite(path, phys, description=ome, metadata=None, photometric="minisblack") + with tifffile.TiffFile(path) as tf: + got = (tf.pages[0].description or "")[:4] + print("wrote %-24s IFDs=%d reversed, TiffData blocks=%d, desc starts %r" + % (name, len(tf.pages), len(tiffdata), got)) + + +def write_pyramid(name): + """A PYRAMIDAL OME-TIFF: every full-res plane's IFD carries downsampled resolution levels + as SubIFDs (TIFF tag 330). SubIFDs live OUTSIDE the main IFD chain, so they must not + disturb full-res (z,c,t) plane addressing -- TIFFNumberOfDirectories still returns Z (not + Z*levels) and ifdForPlane(z,..)->main-chain IFD still lands on the full-res plane. nyxus + featurizes level 0 only; this fixture guards that a pyramid does not silently shift which + plane we read. Z=6 z-stack, 32x48, tiled 16x16 (so also multi-tile), + 2 sub-levels. + Full-res encoding matches ometiff_enc_dims(x,y,z,0,0,C=1,Z=6,Y=32,X=48).""" + Zp, Yp, Xp = 6, 32, 48 + z, y, x = np.meshgrid(np.arange(Zp), np.arange(Yp), np.arange(Xp), indexing="ij") + base = (1 + ((z * Yp + y) * Xp + x)).astype("uint16") # full-res planes + path = os.path.join(HERE, name) + opts = dict(photometric="minisblack", tile=(16, 16)) + with tifffile.TiffWriter(path, ome=True) as tw: + tw.write(base, subifds=2, metadata={"axes": "ZYX"}, **opts) # reserve 2 subifds/plane + tw.write(base[:, ::2, ::2], subfiletype=1, **opts) # level 1 (16x24) + tw.write(base[:, ::4, ::4], subfiletype=1, **opts) # level 2 (8x12) + with tifffile.TiffFile(path) as tf: + print("wrote %-24s main-chain IFDs=%d (=Z), sub-levels/plane=%d, %dx%d tiled" + % (name, len(tf.pages), len(tf.series[0].levels) - 1, Yp, Xp)) + + +def write_calibrated_tiff(name): + """P2: an OME-TIFF carrying physical voxel spacing (PhysicalSizeX/Y/Z + units) in its + OME-XML, so the loader must surface physicalSize*/unit into SlideProps -- the TIFF twin of + dim5_calibrated.ome.zarr. Anisotropic: X=Y=0.5, Z=2.0 micrometer. TCZYX 2,3,4,6,8.""" + data = _data_for("TCZYX") + path = os.path.join(HERE, name) + tifffile.imwrite(path, data, photometric="minisblack", ome=True, metadata={ + "axes": "TCZYX", "PhysicalSizeX": 0.5, "PhysicalSizeY": 0.5, "PhysicalSizeZ": 2.0, + "PhysicalSizeXUnit": "micrometer", "PhysicalSizeYUnit": "micrometer", + "PhysicalSizeZUnit": "micrometer"}) + print("wrote %-24s (calibrated: physX/Y=0.5 physZ=2.0 micrometer)" % name) + + +def write_bad_ifd(name): + """N2: an OME-TIFF whose maps one plane to an IFD PAST the end of the file (an + in-file overrun, not a multi-file UUID). ifdForPlane returns it, so the read must throw + cleanly at TIFFSetDirectory, not crash. 4 planes stored, but plane (z3) claims IFD=99.""" + rT, rC, rZ = 1, 1, 4 + phys = np.empty((rZ, Y, X), "uint16") + yy, xx = np.meshgrid(np.arange(Y), np.arange(X), indexing="ij") + td = [] + for z in range(rZ): + phys[z] = (1 + ((z * Y + yy) * X + xx)).astype("uint16") + ifd = 99 if z == rZ - 1 else z # last plane points past EOF + td.append('' % (z, ifd)) + ome = ('' + '' + '%s' + '' % (X, Y, rZ, "".join(td))) + tifffile.imwrite(os.path.join(HERE, name), phys, description=ome, metadata=None, + photometric="minisblack") + print("wrote %-24s (TiffData IFD=99 on plane z3 -> read must throw)" % name) + + +def write_empty_mask(name): + """N3: an all-background (all-zero) single-channel ZYX mask. A segmented run finds ZERO + ROIs -- the pipeline must handle it (no ROIs, no crash), not divide-by-zero or read garbage.""" + m = np.zeros((Z, Y, X), "uint16") + tifffile.imwrite(os.path.join(HERE, name), m, photometric="minisblack", + metadata={"axes": "ZYX"}, ome=True) + print("wrote %-24s (empty mask -> 0 ROIs)" % name) + + +def write_multichannel_mask(name): + """N4: a MULTI-channel (CZYX, C=2) label mask, to pair with single-channel intensity. The + featurize loop iterates the INTENSITY's channels, so the extra mask channel must simply be + ignored (mask channel clamped to what the intensity asks for), not crash. Same ROI on both + mask channels so the result is channel-independent.""" + m = np.zeros((2, Z, Y, X), "uint16") + m[:, :, 1:5, 1:7] = 1 + tifffile.imwrite(os.path.join(HERE, name), m, photometric="minisblack", + metadata={"axes": "CZYX"}, ome=True) + print("wrote %-24s (C=2 label mask -> extra channel ignored)" % name) + + def write_bad_rgb(name): """RGB OME-TIFF -- nyxus is grayscale-only, so the loader must reject it.""" path = os.path.join(HERE, name) @@ -89,6 +258,19 @@ def main(): write_ome("dim3_zyx.ome.tif", "ZYX") write_ome("dim2_yx.ome.tif", "YX") write_plain("dim3_plain.tif", "ZYX") + # single-channel label mask (for the 1-mask : N-channel-intensity pairing test) + write_mask("dim3_mask.ome.tif") + # non-canonical plane->IFD mapping (planes stored reversed) -> honors TiffData + write_reordered("dim5_reordered.ome.tif") + # pyramidal OME-TIFF (SubIFD downsample levels) -> full-res addressing must be unaffected + write_pyramid("dim5_pyramid.ome.tif") + # TILED multi-plane OME-TIFF (5D) -> exercises the tile-loader (z,c,t)->IFD path + write_tiled("dim5_tiled.ome.tif", "TCZYX") + # planes spanning a real 2x3 tile grid -> multi-tile volumetric assembly + write_multitile("dim5_multitile.ome.tif") + # PARTIAL edge tiles: 40x24 plane / 16 -> 3x2 tiles, last row-tile 8 tall, last col-tile + # 8 wide. Exercises the seam clamp the exact-multiple multitile fixture leaves untested. + write_multitile("dim5_oddtile.ome.tif", T=1, C=2, Z=1, Y=40, X=24) # --- illegal / adversarial: must be rejected cleanly, not crash --- write_bad_rgb("bad_rgb.ome.tif") write_bad_corrupt("bad_corrupt.tif") diff --git a/tests/data/omezarr/dim3_mask.ome.zarr/.zattrs b/tests/data/omezarr/dim3_mask.ome.zarr/.zattrs new file mode 100644 index 000000000..3215e60cd --- /dev/null +++ b/tests/data/omezarr/dim3_mask.ome.zarr/.zattrs @@ -0,0 +1,36 @@ +{ + "multiscales": [ + { + "version": "0.4", + "axes": [ + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim3_mask.ome.zarr/.zgroup b/tests/data/omezarr/dim3_mask.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim3_mask.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim3_mask.ome.zarr/0/.zarray b/tests/data/omezarr/dim3_mask.ome.zarr/0/.zarray new file mode 100644 index 000000000..eb95421ba --- /dev/null +++ b/tests/data/omezarr/dim3_mask.ome.zarr/0/.zarray @@ -0,0 +1,19 @@ +{ + "shape": [ + 4, + 6, + 8 + ], + "chunks": [ + 1, + 6, + 8 + ], + "dtype": "{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_t& zMhQw$hH_M(5>=>14Qf$`dNiOBO=w07TG57fbf6Pm=td8E(T9EvU=TwX#t23+hH*?_ z5>uGQ3}!Khc`RTNmw1_1c$L?9oi})sw|JX(c$fEhpAYzukNB8R_>|9>&*yx>0>0!c zzUCXgWg*}3JwNaxKk+la@GHOZJAd#efAKf}@Gt*yi7mBdw%k_ON?T>CZH?txo~^ZY zw%#__M%!eYZHsNSZMNNZ*iPGJyKRr{wSBhV4%k6EWQXmD9kpY2+)mg@J7uTsjGeV} zcHS=7MWalTgB;}~XSv8#ZgQ80Jmn>C`N&s(@>hTY6{KK=C{$qzSA-%JrD(+{R&k0~ zf)bUaWThxoX-ZdyGL@xl<)}!-Dp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKPf|rK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_calibrated.ome.zarr/0/0.0.1.0.0 b/tests/data/omezarr/dim5_calibrated.ome.zarr/0/0.0.1.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..678ed89e2b57253198aa620a5b4f198239f69bdc GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?wBaw>(Sc5Mp&LEuMIZVxfI$r5ABF*vM$s9-5e#GygE^9;IGQ00Wf;R5 z!7+?v6r&l#v5aLL<2jBAOk@(1nZmO?$Md|vi@e0kyuz!z#_PPno4m!_yu-V^$NPN1 zhkV4xe8Q)E#^-#&mwd(7e8abV$M^iekNm{X{KBvN#_#;WpZvw&{KL66&*s|#TWE`H zu`RKsw#=5>3R`KbY_+YiwYJXI+XmZcn{2afv8}evw%ZQdX}fHc2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(37+B^p5p~x;uT)w4c_7%-s1y4;uAjO3%=qT zzT*de;un775B}mGgdnLjuHZ_p;%ctpTCU@IZs104;%08)R&L{V?%+-?aFM&Xn|rvI z`?#M6c#wy9m`8Y&$9SA4c#@}hnrC>H=XjnMc#)U5#AROQ6<*~vUgr(oW~iWh>q%*j_ZU@>Xc6FjLzzu&Z~ntnv*%3i@BPcxtoW1nwNQ-kNKLP`CEVmT95@> uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFAnOcPU@^K>Z)$)t{&>CUh1tr>Z^Y0uK^mUK^m+f8meI$t`QokQ5vl=8mn;{uL+u{ zNt&!FnyP7dmnyY!5uM4mci?A3=uoTO%94oLAtFRhtuommE9viR`o3I&M zuoc^|9XqfSyRaL3uowHV9|v#{hj182a1_UI94BxRr*Il)a2Drq9v5&Cmv9+Za23~Z z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=#pYR!9@D<@E89eEMOsvSj-ZZvW(@dU?r9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxRJWSa literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/.zattrs b/tests/data/omezarr/dim5_multichunk.ome.zarr/.zattrs new file mode 100644 index 000000000..0a8629e58 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/.zattrs @@ -0,0 +1,46 @@ +{ + "multiscales": [ + { + "version": "0.4", + "axes": [ + { + "name": "t", + "type": "time" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/.zgroup b/tests/data/omezarr/dim5_multichunk.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/.zarray b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/.zarray new file mode 100644 index 000000000..6b9be87e6 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/.zarray @@ -0,0 +1,23 @@ +{ + "shape": [ + 2, + 3, + 4, + 6, + 8 + ], + "chunks": [ + 1, + 1, + 1, + 3, + 4 + ], + "dtype": "tI1 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..bb3726bb64376d0c9d42517768e52c5076fd6225 GIT binary patch literal 24 fcmXqHFk&!fFk!G{uwt-guwigyaAI&~aA5!dA)o@k literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..2d89624b18a13bf062d7a8e5fde82235f3e6f30f GIT binary patch literal 24 fcmXqIFk>)huwbxduw$@iaA0s{aAR<1@L&J{BoG4c literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..8a0fbda12bd5168a8a854434533c1eb391c73e88 GIT binary patch literal 24 fcmeZd@M7?0@L>pK2x16k2w{k1h+>Flh+zN#FhB#} literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.1.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..f073787438e087df6d53c0e4bc7f2ebbb925205e GIT binary patch literal 24 fcmeZe@MG|22w(_h2xACmh+v3ih+~LnNMHZ}GOz>> literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.2.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.2.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..5705ab6aeb98b9b0c213fc3ef5979fdfc4b1ec69 GIT binary patch literal 24 fcmYdfNMcB4NMXoi$YRK5$YCgCC}JpPC}98qKHvoZ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.2.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.2.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..0bc4aa490b242bdc95e9d526d4c83415d529d526 GIT binary patch literal 24 fcmYdgNMlH6$Y982$YaQ7C}1dMC}SvRs9*p9K~M!R literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.2.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.2.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..d4e4e5da42d0d01c41bc6ae3c9cc58f128ca4bf7 GIT binary patch literal 24 fcmbZvX%Q literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.3.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.3.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..4b96183570889da2eee0d8eb41da5e3de67a699c GIT binary patch literal 24 gcmZ3Gi+hl$*_xIH^UwV0BXPpUjP6A literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.3.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.0.3.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..938a7ef30bc80160b5810370ddeb034070fb1efb GIT binary patch literal 24 gcmZ3>u#RCp!v=<}4BHsCGwfj4%dn4OKf?hA0BtY`j{pDw literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..a9c345b7f58b09a752daba5f4370e1010744ec0c GIT binary patch literal 24 gcmX@eaEReB!x4s)45t`QGn`?#$Z(0_GQ$-H0C`{ue*gdg literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..6dc3ccae9088d1ee2d0599e1a714254f8d495fcc GIT binary patch literal 24 gcmX@gaE#$N!wH774CffmGhAS}%5aV0I>QYH0DI60uK)l5 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..954cbeaa437402cdb9b13acbfe8ff0c2a28131bd GIT binary patch literal 24 gcmcb~aEswK!ySf)438KdGdy8<$?%HdHNzVQ0Ehqzp8x;= literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.0.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..102e4619adf54a8f1651bddfec0824aa5797f5b1 GIT binary patch literal 24 gcmcc1aF5|W!vlt=49^&zGrVAU%kYljJ;MhE0E%!5&j0`b literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..23f59fde65f3b0132fe67b1f0cf3d951c8df55bd GIT binary patch literal 24 gcmey!@QL9w!xx6148ItDGyGv-WMpDwW@KRm0D`aw!vFvP literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..77aaaed325f8366edb803c335f43457723932ebb GIT binary patch literal 24 gcmey$@QvX+!w-hP4F4GZGcYi+GO{tUGjcEj0DCD0^Z)<= literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.1.0 new file mode 100644 index 000000000..20efad3c8 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.1.0 @@ -0,0 +1,2 @@ +  +   \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.1.1 new file mode 100644 index 000000000..50e6aad38 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.1.1.1 @@ -0,0 +1 @@ +   \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.0.0 new file mode 100644 index 000000000..7505c70f2 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.0.0 @@ -0,0 +1 @@ +!"#$)*+,1234 \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.0.1 new file mode 100644 index 000000000..896e76dc8 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.0.1 @@ -0,0 +1 @@ +%&'(-./05678 \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.1.0 new file mode 100644 index 000000000..10348b294 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.1.0 @@ -0,0 +1 @@ +9:;<ABCDIJKL \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.1.1 new file mode 100644 index 000000000..5a561cc2a --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.2.1.1 @@ -0,0 +1 @@ +=>?@EFGHMNOP \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.0.0 new file mode 100644 index 000000000..03e422fd6 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.0.0 @@ -0,0 +1 @@ +QRSTYZ[\abcd \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.0.1 new file mode 100644 index 000000000..532ad6295 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.0.1 @@ -0,0 +1 @@ +UVWX]^_`efgh \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.1.0 new file mode 100644 index 000000000..08d7221e3 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.1.0 @@ -0,0 +1 @@ +ijklqrstyz{| \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.1.1 new file mode 100644 index 000000000..ecf4d9353 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.1.3.1.1 @@ -0,0 +1 @@ +mnopuvwx}~ \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.0.0 new file mode 100644 index 000000000..ccd40725f --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.0.1 new file mode 100644 index 000000000..33a72bc45 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.0.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.1.0 new file mode 100644 index 000000000..38bb58d91 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.1.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.1.1 new file mode 100644 index 000000000..ae8b9729e --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.0.1.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.0.0 new file mode 100644 index 000000000..ef8c57048 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.0.1 new file mode 100644 index 000000000..1bc969c02 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.0.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.1.0 new file mode 100644 index 000000000..498c077bd --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.1.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.1.1 new file mode 100644 index 000000000..69b4e588e --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.1.1.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.0.0 new file mode 100644 index 000000000..32030d08b --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.0.1 new file mode 100644 index 000000000..a7b244a42 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.0.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.1.0 new file mode 100644 index 000000000..9dab26a86 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.1.0 @@ -0,0 +1,2 @@ +  +   \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.2.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..c4e759841edb88ae5e93e63a77af25ba036c1633 GIT binary patch literal 24 fcmey%_>b{FBLfpF6B`pd69*G76CV>llK>L{M9~9e literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.0.0 new file mode 100644 index 000000000..ba4ab3774 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.0.0 @@ -0,0 +1 @@ +!"#$ \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.0.1 new file mode 100644 index 000000000..8ceadd6ce --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.0.1 @@ -0,0 +1 @@ + %&'( \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.1.0 new file mode 100644 index 000000000..e288e02a0 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.1.0 @@ -0,0 +1 @@ +)*+,12349:;< \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.1.1 new file mode 100644 index 000000000..d0032195f --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/0.2.3.1.1 @@ -0,0 +1 @@ +-./05678=>?@ \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.0.0 new file mode 100644 index 000000000..90eba08ef --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.0.0 @@ -0,0 +1 @@ +ABCDIJKLQRST \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.0.1 new file mode 100644 index 000000000..c8f3df211 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.0.1 @@ -0,0 +1 @@ +EFGHMNOPUVWX \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.1.0 new file mode 100644 index 000000000..f2534bd15 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.1.0 @@ -0,0 +1 @@ +YZ[\abcdijkl \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.1.1 new file mode 100644 index 000000000..0771cddb3 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.0.1.1 @@ -0,0 +1 @@ +]^_`efghmnop \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.0.0 new file mode 100644 index 000000000..da21a11f3 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.0.0 @@ -0,0 +1 @@ +qrstyz{| \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.0.1 new file mode 100644 index 000000000..11ff0eb49 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.0.1 @@ -0,0 +1 @@ +uvwx}~ \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.1.0 new file mode 100644 index 000000000..ef326d17a --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.1.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.1.1 new file mode 100644 index 000000000..095916ce2 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.1.1.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.0.0 new file mode 100644 index 000000000..c754de7d9 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.0.1 new file mode 100644 index 000000000..3fc7c5626 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.0.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.1.0 new file mode 100644 index 000000000..f1ab8d54e --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.1.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.1.1 new file mode 100644 index 000000000..79919cf8f --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.2.1.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.0.0 new file mode 100644 index 000000000..2498f594e --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.0.1 new file mode 100644 index 000000000..402050ce9 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.0.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.1.0 new file mode 100644 index 000000000..e77d3f52f --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.1.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.0.3.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..9f39c2bb49638d7a72af7154982f83e3bc082c65 GIT binary patch literal 24 gcmaFM^p5F0(+8%nOy8KkGyP!t%k+=wKNABp0FzM)r~m)} literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.0.0 new file mode 100644 index 000000000..18af2a38f --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.0.0 @@ -0,0 +1,2 @@ +  +   \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.0.1 new file mode 100644 index 000000000..4c6e6ccdf --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.0.1 @@ -0,0 +1 @@ +  \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.1.0 new file mode 100644 index 000000000..34ca0c10e --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.1.0 @@ -0,0 +1 @@ +!"#$)*+, \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.1.1 new file mode 100644 index 000000000..a62a88bd6 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.0.1.1 @@ -0,0 +1 @@ + %&'(-./0 \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.0.0 new file mode 100644 index 000000000..095103132 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.0.0 @@ -0,0 +1 @@ +12349:;<ABCD \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.0.1 new file mode 100644 index 000000000..1800462e5 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.0.1 @@ -0,0 +1 @@ +5678=>?@EFGH \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.1.0 new file mode 100644 index 000000000..555fdeb6d --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.1.0 @@ -0,0 +1 @@ +IJKLQRSTYZ[\ \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.1.1 new file mode 100644 index 000000000..f3cd39495 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.1.1.1 @@ -0,0 +1 @@ +MNOPUVWX]^_` \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.0.0 new file mode 100644 index 000000000..9d717834e --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.0.0 @@ -0,0 +1 @@ +abcdijklqrst \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.0.1 new file mode 100644 index 000000000..e4cef603f --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.0.1 @@ -0,0 +1 @@ +efghmnopuvwx \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.1.0 new file mode 100644 index 000000000..5573f5f09 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.1.0 @@ -0,0 +1 @@ +yz{| \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.1.1 new file mode 100644 index 000000000..68c391241 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.2.1.1 @@ -0,0 +1 @@ +}~ \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.0.0 new file mode 100644 index 000000000..15832f810 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.0.1 new file mode 100644 index 000000000..afbcd4db4 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.0.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.1.0 new file mode 100644 index 000000000..e431e7f10 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.1.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.1.1 new file mode 100644 index 000000000..2108967af --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.1.3.1.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.0.0 new file mode 100644 index 000000000..929d22a8d --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.0.1 new file mode 100644 index 000000000..6a48804c6 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.0.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.1.0 new file mode 100644 index 000000000..04e074163 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.1.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.1.1 new file mode 100644 index 000000000..2b0cec5ce --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.0.1.1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.0.0 new file mode 100644 index 000000000..acbf29f9c --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..0f3d38f4b358bde665c22df8178752b4500a3ee4 GIT binary patch literal 24 gcmey${EhiL^AF~~%>S7GGc&NTvaqqRvv9Bg0DRd87ytkO literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.1.0 new file mode 100644 index 000000000..81234e044 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.1.0 @@ -0,0 +1,2 @@ +  +   \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.1.1 new file mode 100644 index 000000000..18e42d2b3 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.1.1.1 @@ -0,0 +1 @@ +   \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.0.0 new file mode 100644 index 000000000..0ec251090 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.0.0 @@ -0,0 +1 @@ +!"#$)*+,1234 \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.0.1 new file mode 100644 index 000000000..62eeb98e4 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.0.1 @@ -0,0 +1 @@ +%&'(-./05678 \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.1.0 new file mode 100644 index 000000000..6787fc38c --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.1.0 @@ -0,0 +1 @@ +9:;<ABCDIJKL \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.1.1 new file mode 100644 index 000000000..e8472acbb --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.2.1.1 @@ -0,0 +1 @@ +=>?@EFGHMNOP \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.0.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.0.0 new file mode 100644 index 000000000..bf9cdef34 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.0.0 @@ -0,0 +1 @@ +QRSTYZ[\abcd \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.0.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.0.1 new file mode 100644 index 000000000..34681a52a --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.0.1 @@ -0,0 +1 @@ +UVWX]^_`efgh \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.1.0 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.1.0 new file mode 100644 index 000000000..b95127c15 --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.1.0 @@ -0,0 +1 @@ +ijklqrstyz{| \ No newline at end of file diff --git a/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.1.1 b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.1.1 new file mode 100644 index 000000000..35fd5cfdc --- /dev/null +++ b/tests/data/omezarr/dim5_multichunk.ome.zarr/0/1.2.3.1.1 @@ -0,0 +1 @@ +mnopuvwx}~ \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/.zattrs b/tests/data/omezarr/dim5_oddchunk.ome.zarr/.zattrs new file mode 100644 index 000000000..0a8629e58 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/.zattrs @@ -0,0 +1,46 @@ +{ + "multiscales": [ + { + "version": "0.4", + "axes": [ + { + "name": "t", + "type": "time" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/.zgroup b/tests/data/omezarr/dim5_oddchunk.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/.zarray b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/.zarray new file mode 100644 index 000000000..346491b10 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/.zarray @@ -0,0 +1,23 @@ +{ + "shape": [ + 2, + 3, + 4, + 6, + 8 + ], + "chunks": [ + 1, + 1, + 1, + 4, + 5 + ], + "dtype": "=P006*K8WICBDEH%yP$^7#P}qya4e literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.0.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.0.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..afd281029e8bffa7926681764ffc4842e0fecaf6 GIT binary patch literal 40 icmZQ$U}xZ900BM*eg*+BON>FBK?2N@V~}T10J8uO=P006-IlG2bEh(YQ9-{~&c?4rXdxnxDv+`3_Uyq3=oUt|RB literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.1.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.1.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..692ef43915ce65fc281064e2da4a35c5c1e6aa57 GIT binary patch literal 40 icmXqGFlVq}00BD&dj=P006-Is?v}ch(YP^-36OnbT}oKtf-n>H%yP$^7#R-BnYhl literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.2.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.2.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..9894ec667dca4e05d36f1d0603afe62ca25d5c43 GIT binary patch literal 40 icmYdeNN31k0D(M)e1-xrtBj$Xp#sdRW2k3n0J8vhWCdja literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.2.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.2.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..f854da56ec95177d64597a927f8d4bf41203e8aa GIT binary patch literal 40 ccmZo=P006-I|E4q~24Yb9V|T%37adN?B`d1t)(z9+wS0d39 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.3.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.3.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..050bec0f02ea4c98a67975102b8a440491dd5817 GIT binary patch literal 40 kcmbQnFr8rr0|?Axn9r~P%v#2@(g!C1 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.3.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.0.3.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..31bc37e4a5828b981ef90310f2a0dcccfdc5b293 GIT binary patch literal 40 YcmdnSu$^HC0|@M6*w1hP%)&+h0FX)qV*mgE literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.0.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.0.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..2e746188bb1166e28eb9a9816d550361b5a8c2f6 GIT binary patch literal 40 rcmV~$F#>=P006-I|F4vW#6S#6f9WpR?4rXdxnxDv+`3_Uyq3=oOS2Fy literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.0.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.0.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..493692693abd9fc460f68ad9fb555550b9013944 GIT binary patch literal 40 kcmX@caGc=;0|=aBIL~ka%(}*Ko#6(Ub&ugb!vio20N&&Ylhc+ck literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..d6b3714698a141e0bf232a484cd3cd477a2af619 GIT binary patch literal 40 tcmV~$!4`lJ006;VaZvgH?@Syb@0oe1yfiu=gUQcAgNM;%_Vn_$xB)EJ3HJa1 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..26e7b6a61e07d2b1c7ba1db570ac6b2aa1c94756 GIT binary patch literal 40 kcmeyy@SWiY0|@+M_|L$=2x74@vNLjkS$vHAi~?X50ILNBEC2ui literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..36fe9e0264ee6c6166d8b2e5bc0486ae9b091c3a GIT binary patch literal 40 dcmWe+6k-%+6k!x)lw_1*lxCD+lx1YV0stI70Pz3- literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.1.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..6d94ae5a7fc0a6702141a81f9f33a9622a4c2466 GIT binary patch literal 40 ZcmWe*6lauRWB>v=MtMdBFbl*(2LKgH0I2`~ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.0.0 new file mode 100644 index 000000000..1b796e717 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.0.0 @@ -0,0 +1 @@ +!"#$%)*+,-123459:;<= \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..efbd57413a5f8dc3425bd8251cfcd588fe244f4c GIT binary patch literal 40 jcmY#mRAv^Mtw#DFw2b5oY4ZzvSYMobO5seGi(BJ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..aacfafa578fa59c509527d255318bb79df6a06dc GIT binary patch literal 40 dcmZ={bYgU7bYXO5^knp6^k(#7^krng0svNC0?_~f literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.2.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..22a9a324c5a1b0e49e81677bfa2485a961205bb3 GIT binary patch literal 40 ZcmZ=`bZ7KnWB>v`Mt{ZtFbl*(2LL*10m%RW literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.0.0 new file mode 100644 index 000000000..2295039d6 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.0.0 @@ -0,0 +1 @@ +QRSTUYZ[\]abcdeijklm \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..0148872c2aab17734b4ede69130cc832940bfd70 GIT binary patch literal 40 jcmWGz3}=jBWB`IV#(2gAFe{BQoiPK<%45uDEC90rXDkG< literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..ddd255b9afcf7d30c4a9a02d844164a47ca845b8 GIT binary patch literal 40 dcmXS9EMhEXEMY8VtYoZWtY)lXtYu`t0sxSH1hD`B literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.1.3.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..3c25885ac5ae1a271aee93c8992abd89ac76a696 GIT binary patch literal 40 ZcmXS8EN84>WB`IX#(KsEFbl*(2LNA+0_gw% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.0.0 new file mode 100644 index 000000000..067b7edf3 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..f53f9246a50e57ea08d55c58a846612b635b5e63 GIT binary patch literal 40 kcmZo;Y-j9XWB`Ib#(u^LVAeFo>5MbLta*&{85e+A0GjLu@c;k- literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..ef189c6c9c464860a3a6156707c1994c5661b207 GIT binary patch literal 40 fcmZ3;xQKBv;}XWDj4K&eF|KA@!?>1_0Sf>C%AN+1 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.0.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..4b1e7cc96c0235781b49de55fb3f9dbbb0854c8a GIT binary patch literal 40 acmZ3+xSVkXBLfhuV_eU;0n7sN&;bC1sssrD literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.0.0 new file mode 100644 index 000000000..da7c04c89 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..945668e751021852ee17d4227115525caa0a6302 GIT binary patch literal 40 lcmdnSxSeqaBLfiZW8BYp0L(hZc%1PBn01cvJmUp03jofn2{8Zw literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..20e6506481929eaf2abfef15d3b855bdda416ba6 GIT binary patch literal 40 fcmcb}c!}{c;}yoMj5ir?G2Ujp!+4jG0Sf>C1kDI> literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.1.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..cdf4b2637d0530eaf08389b37c3a8733be5b91c1 GIT binary patch literal 40 acmcb{c%AVEBLfiJW4zD!0L%jM&;bCc$^|I^ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.0.0 new file mode 100644 index 000000000..0453f3ed3 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..85a5bef83105ec9c70821f6701bbf290fa6aed2f GIT binary patch literal 40 mcmaFH_?+r}0nGZw_?__wnDvkGKO+MZNEQJ6aSCz( literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..800e79b618e04a899746e121ad63f97b1509a580 GIT binary patch literal 40 dcmZQ%Vq#)uVqs!s;$-4t;%4Gu;$>pM0ssmM09yb6 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.2.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..3f8fe2e52f08dd126def479bd1ea7807ad796481 GIT binary patch literal 40 ZcmZQ$VrSxDVgLd@CVnOXFbl*(2LK7G08an_ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.0.0 new file mode 100644 index 000000000..6881dd599 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.0.0 @@ -0,0 +1 @@ +!"#$%)*+,- \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..e863633f3ff7f9f37bd2251522bb2d8aed5ec782 GIT binary patch literal 40 jcmWe*5@(WNVgLd;CV3_WFiVX|ok;`C(qqzRG61sxBQycM literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..4a3d0d1740a1c894bc1859b16f0d7d28025d1670 GIT binary patch literal 40 dcmXqHGGa1jGGQ`hvShMivSzYjvSnhx0surR0y_Wz literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/0.2.3.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..a9d8e3ae67fc1d2b80662f2e2eae4b471fd9c262 GIT binary patch literal 40 ZcmXqGGH0@2VgLd=CVM6aFbl*(2LLY00dD{R literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.0.0 new file mode 100644 index 000000000..1c1dede81 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.0.0 @@ -0,0 +1 @@ +ABCDEIJKLMQRSTUYZ[\] \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..c692e4ca2975ac97881b663d3a723042e24b628f GIT binary patch literal 40 jcmZ=`a%b{jVgLd^CV!>?Fe{8HoGAj#ierjrN&vF}R`dh? literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..99e0b2606a1e5f2ae3e52759d2601bc7c60507c4 GIT binary patch literal 40 dcmYdfN@7Z8N?}T6%4Eu7%4W)8%4K4}0swwW1RDSV literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.0.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..d4f3d927d5f7138cf753cb5fa0220e5605c6bd48 GIT binary patch literal 40 ZcmYdeN@vPoVgQ0XrhKLXFbl*(2LMy*0*?Ry literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.0.0 new file mode 100644 index 000000000..53d610014 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.0.0 @@ -0,0 +1 @@ +qrstuyz{|} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..3859597a27b98ebb63ea32835eaa7bee67a67aee GIT binary patch literal 40 kcmXS8Drc%-VgQ0Vrh29ZFsqHJov8!N>SO9>ngC`20E)B*Jpcdz literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..3dd1b6b75ec0148bcb4d1af021eef889fbf6b349 GIT binary patch literal 40 fcmbQpG>K_4(-fwuOf#8gG0kS0!!(zP0Sf>CxM~Ia literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.1.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..0040245d00ba0aa72374a707469c933477321273 GIT binary patch literal 40 acmbQnG@WS%69W*;W17#j0L%jM&;bB>3IneI literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.0.0 new file mode 100644 index 000000000..2db1cb9d3 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..d2f636085ea68fb3bf1ff879f4b564f8dddf88df GIT binary patch literal 40 lcmZ3+w47-L69W*eV_MI&0nFOQw4G@On6-~-Khps)3jn@!2z>wm literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..cd08d53345289a125c59df593a855b654ae5f36d GIT binary patch literal 40 fcmX@ebcpFN(-EelOedL6F`Z^Q!*rI30Sf>C@`?xB literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.2.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..ecfeb4ad3e57964b14e82fbbab4c472eb575661e GIT binary patch literal 40 acmX@cbe!n~69W*OV>-`t0n7sN&;bCRDg@5} literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.0.0 new file mode 100644 index 000000000..95eda433f --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..f1eafaccb95d000f088b83a0ecf362e03e7fe515 GIT binary patch literal 40 lcmcb{be-u269W+3W4h1u0L*&E^qlDhnDvh7J<|s;3jp#o3cUaT literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..ee582c1ae4d26247a442748833a79b433d40e009 GIT binary patch literal 40 fcmey!^oi*+(-)?%Oh1`^G5u!x!}OPl0Sf>CEV&80 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.0.3.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..7a2e6248c743eefa221dd79ec2e2e4daac55cb51 GIT binary patch literal 40 Zcmeyy^quJk69W+ZWBSj;zzkwx0|2RG1n>X= literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.0.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.0.0.0 new file mode 100644 index 000000000..2697929e6 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.0.0.0 @@ -0,0 +1,2 @@ +  +    \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.0.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.0.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..67da0a1d3c4e2a9beefb3bb617f3b66cfb731ff0 GIT binary patch literal 40 jcmZQ$W@qMLW&i>{W`1S?FiVVCoLK_Ql4F)`W_@M@Fbl*(2LK}~0TloM literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.0.0 new file mode 100644 index 000000000..510744058 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.0.0 @@ -0,0 +1 @@ +123459:;<=ABCDEIJKLM \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..5bc5f4691df177ece8dc51e88313ae832f3e466a GIT binary patch literal 40 jcmXqGHfOeAW&i>^W_xA_Fw2eEo!JA-@?-XA4gj+NM!W+_ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..aee9943751a50e49b340fc1a81f7b3602375e0d2 GIT binary patch literal 40 dcmWG!4q^^w4q*;uj%1Evj%JQwj%8-R0sw3l1BCzp literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.1.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..e2d717019dea6d7a4dce8acf4bd86d899ced6143 GIT binary patch literal 40 ZcmWGz4rh*FW&naX=6L1=Fbl*(2LMP)0yO{t literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.0.0 new file mode 100644 index 000000000..6b14f9717 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.0.0 @@ -0,0 +1 @@ +abcdeijklmqrstuyz{|} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..d2f3ec59492fbfea891d6d838fb7dc9858ab9c5a GIT binary patch literal 40 jcmYdePG``?qz1c0sy8q1!VvL literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.2.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..916adecbfecf046b2a34f586e688d324b64e1f7b GIT binary patch literal 40 ZcmZo;ZfEXbW&nad=6>c0U>1mn4ghUx162S3 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.0.0 new file mode 100644 index 000000000..17c9c63e8 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..03e045ef03e8f0773c05a0df617c0a24504e7001 GIT binary patch literal 40 lcmbQnJe_$4GXoIJW1i2v0L)s(yqtLjn6-|1J@W=I3jnS>2gv{c literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..4b9752263013fa5f3b2612e21b61b2357139a5b8 GIT binary patch literal 40 fcmdnUyoq@;^A_f<%sZKPG4E#H!@QT70Sf>C;8q7k literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.1.3.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..875a85532c507f9aa84533e214fe7e42dd53da22 GIT binary patch literal 40 acmdnSyq$RmGXoIpW8Tkv0L%jM&;bCFiUf54 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.0.0 new file mode 100644 index 000000000..439c8135e --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.0.0 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..61f639f87961f06799d40d7b399a4693d301ed17 GIT binary patch literal 40 lcmX@ce4P0NGXoHuV?NJ(0nEC_e4Y6Qn01f&KJx=G3jpE#3JCxJ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..324a30aeb404e8bf4ea1c95c7f4ed4045eb9ee7c GIT binary patch literal 40 fcmaFJ{D}E6^AqN$%rBW=F~4Sh!~B++0Sf>C8iffZ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.0.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..56870d4fba2f621115e125b5811c8b50d50ac4f9 GIT binary patch literal 40 acmaFH{G9m(GXoI3V}8&40n7sN&;bCqss)t* literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.0.0 new file mode 100644 index 000000000..c4ec83492 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.0.0 @@ -0,0 +1,2 @@ +  +    \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..9d4859413a861941b139f9a950d4b8fa3786de7a GIT binary patch literal 40 lcmeyy{GIs+GXoI(WB$*~zye~iv9PmnfLVMj{44@s767gt1yBG0 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..a239d8568fc7855b7f87d641def0e05ff6643394 GIT binary patch literal 40 dcmWe+5n>T$5n&N!kz|o#k!F!$k!4}P0stSv0S^EG literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.1.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..52e40880414f06b502a58a2d5b424b2cd91e905a GIT binary patch literal 40 ZcmWe*5oeKLVE_U-7I_v0Fbl*(2LKm}0J{JH literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.0.0 new file mode 100644 index 000000000..673be84d9 --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.0.0 @@ -0,0 +1 @@ +!"#$%)*+,-123459:;<= \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..9f380b5631118e7c14d236f5497add9e33cdb6b3 GIT binary patch literal 40 jcmY#mQD@O$VE_U>7JU{2Fw2a^oW%mnvSYDlaR9RbHiQC| literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..d955cc5697955ebab64a14c9dcb6387ae7b0ac83 GIT binary patch literal 40 dcmZ={abj_1aba;~@nrF0@n-R1@nvDa0svX!0`C9- literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.2.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..0ec70c34d9f014476c7db43dc246f72993bef0f1 GIT binary patch literal 40 ZcmZ=`acA*hVE_U@7JrriFbl*(2LL>(0owoo literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.0.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.0.0 new file mode 100644 index 000000000..bf94cb90a --- /dev/null +++ b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.0.0 @@ -0,0 +1 @@ +QRSTUYZ[\]abcdeijklm \ No newline at end of file diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.0.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.0.1 new file mode 100644 index 0000000000000000000000000000000000000000..438a2b92045afa924502db6ee74941932dfc5619 GIT binary patch literal 40 jcmWGz31^95VE}?SmUxx~Fe{BEoh1Xz%45lADFCwoYD5Ip literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.1.0 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.1.0 new file mode 100644 index 0000000000000000000000000000000000000000..625d3bab7be275a53d793e345522d72c613f770d GIT binary patch literal 40 dcmXS9DPk#RDPbvPsbr~Qsb;BRsbyin0sxc(1kV5f literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.1.1 b/tests/data/omezarr/dim5_oddchunk.ome.zarr/0/1.2.3.1.1 new file mode 100644 index 0000000000000000000000000000000000000000..acfc915011380ad1a151dede9991ab54a62ed6c3 GIT binary patch literal 40 ZcmXS8DQBr*VE}?UmU@;3Fbl*(2LNHp0{Z{} literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3.ome.zarr/0/c/0/0/0/0/0 b/tests/data/omezarr/dim5_v3.ome.zarr/0/c/0/0/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..5ad5ba423979d7284d67888e0bdca68c024ffe27 GIT binary patch literal 576 zcmV~$17Z*W7zN<>{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_t& zMhQw$hH_M(5>=>14Qf$`dNiOBO=w07TG57fbf6Pm=td8E(T9EvU=TwX#t23+hH*?_ z5>uGQ3}!Khc`RTNmw1_1c$L?9oi})sw|JX(c$fEhpAYzukNB8R_>|9>&*yx>0>0!c zzUCXgWg*}3JwNaxKk+la@GHOZJAd#efAKf}@Gt*yi7mBdw%k_ON?T>CZH?txo~^ZY zw%#__M%!eYZHsNSZMNNZ*iPGJyKRr{wSBhV4%k6EWQXmD9kpY2+)mg@J7uTsjGeV} zcHS=7MWalTgB;}~XSv8#ZgQ80Jmn>C`N&s(@>hTY6{KK=C{$qzSA-%JrD(+{R&k0~ zf)bUaWThxoX-ZdyGL@xl<)}!-Dp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKPf|rK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3.ome.zarr/0/c/0/0/1/0/0 b/tests/data/omezarr/dim5_v3.ome.zarr/0/c/0/0/1/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..678ed89e2b57253198aa620a5b4f198239f69bdc GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?wBaw>(Sc5Mp&LEuMIZVxfI$r5ABF*vM$s9-5e#GygE^9;IGQ00Wf;R5 z!7+?v6r&l#v5aLL<2jBAOk@(1nZmO?$Md|vi@e0kyuz!z#_PPno4m!_yu-V^$NPN1 zhkV4xe8Q)E#^-#&mwd(7e8abV$M^iekNm{X{KBvN#_#;WpZvw&{KL66&*s|#TWE`H zu`RKsw#=5>3R`KbY_+YiwYJXI+XmZcn{2afv8}evw%ZQdX}fHc2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(37+B^p5p~x;uT)w4c_7%-s1y4;uAjO3%=qT zzT*de;un775B}mGgdnLjuHZ_p;%ctpTCU@IZs104;%08)R&L{V?%+-?aFM&Xn|rvI z`?#M6c#wy9m`8Y&$9SA4c#@}hnrC>H=XjnMc#)U5#AROQ6<*~vUgr(oW~iWh>q%*j_ZU@>Xc6FjLzzu&Z~ntnv*%3i@BPcxtoW1nwNQ-kNKLP`CEVmT95@> uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFAnOcPU@^K>Z)$)t{&>CUh1tr>Z^Y0uK^mUK^m+f8meI$t`QokQ5vl=8mn;{uL+u{ zNt&!FnyP7dmnyY!5uM4mci?A3=uoTO%94oLAtFRhtuommE9viR`o3I&M zuoc^|9XqfSyRaL3uowHV9|v#{hj182a1_UI94BxRr*Il)a2Drq9v5&Cmv9+Za23~Z z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=#pYR!9@D<@E89eEMOsvSj-ZZvW(@dU?r9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxRJWSa literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3.ome.zarr/0/zarr.json b/tests/data/omezarr/dim5_v3.ome.zarr/0/zarr.json new file mode 100644 index 000000000..2ae28718d --- /dev/null +++ b/tests/data/omezarr/dim5_v3.ome.zarr/0/zarr.json @@ -0,0 +1,41 @@ +{ + "shape": [ + 2, + 3, + 4, + 6, + 8 + ], + "data_type": "uint16", + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [ + 2, + 3, + 1, + 6, + 8 + ] + } + }, + "chunk_key_encoding": { + "name": "default", + "configuration": { + "separator": "/" + } + }, + "fill_value": 0, + "codecs": [ + { + "name": "bytes", + "configuration": { + "endian": "little" + } + } + ], + "attributes": {}, + "zarr_format": 3, + "node_type": "array", + "storage_transformers": [] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_v3.ome.zarr/zarr.json b/tests/data/omezarr/dim5_v3.ome.zarr/zarr.json new file mode 100644 index 000000000..ea6be0fcd --- /dev/null +++ b/tests/data/omezarr/dim5_v3.ome.zarr/zarr.json @@ -0,0 +1,52 @@ +{ + "attributes": { + "ome": { + "version": "0.5", + "multiscales": [ + { + "axes": [ + { + "name": "t", + "type": "time" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ] + } + ] + } + }, + "zarr_format": 3, + "node_type": "group" +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/c/0/0/0/0/0 b/tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/c/0/0/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..5ad5ba423979d7284d67888e0bdca68c024ffe27 GIT binary patch literal 576 zcmV~$17Z*W7zN<>{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_t& zMhQw$hH_M(5>=>14Qf$`dNiOBO=w07TG57fbf6Pm=td8E(T9EvU=TwX#t23+hH*?_ z5>uGQ3}!Khc`RTNmw1_1c$L?9oi})sw|JX(c$fEhpAYzukNB8R_>|9>&*yx>0>0!c zzUCXgWg*}3JwNaxKk+la@GHOZJAd#efAKf}@Gt*yi7mBdw%k_ON?T>CZH?txo~^ZY zw%#__M%!eYZHsNSZMNNZ*iPGJyKRr{wSBhV4%k6EWQXmD9kpY2+)mg@J7uTsjGeV} zcHS=7MWalTgB;}~XSv8#ZgQ80Jmn>C`N&s(@>hTY6{KK=C{$qzSA-%JrD(+{R&k0~ zf)bUaWThxoX-ZdyGL@xl<)}!-Dp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKPf|rK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/c/0/0/1/0/0 b/tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/c/0/0/1/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..678ed89e2b57253198aa620a5b4f198239f69bdc GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?wBaw>(Sc5Mp&LEuMIZVxfI$r5ABF*vM$s9-5e#GygE^9;IGQ00Wf;R5 z!7+?v6r&l#v5aLL<2jBAOk@(1nZmO?$Md|vi@e0kyuz!z#_PPno4m!_yu-V^$NPN1 zhkV4xe8Q)E#^-#&mwd(7e8abV$M^iekNm{X{KBvN#_#;WpZvw&{KL66&*s|#TWE`H zu`RKsw#=5>3R`KbY_+YiwYJXI+XmZcn{2afv8}evw%ZQdX}fHc2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(37+B^p5p~x;uT)w4c_7%-s1y4;uAjO3%=qT zzT*de;un775B}mGgdnLjuHZ_p;%ctpTCU@IZs104;%08)R&L{V?%+-?aFM&Xn|rvI z`?#M6c#wy9m`8Y&$9SA4c#@}hnrC>H=XjnMc#)U5#AROQ6<*~vUgr(oW~iWh>q%*j_ZU@>Xc6FjLzzu&Z~ntnv*%3i@BPcxtoW1nwNQ-kNKLP`CEVmT95@> uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFAnOcPU@^K>Z)$)t{&>CUh1tr>Z^Y0uK^mUK^m+f8meI$t`QokQ5vl=8mn;{uL+u{ zNt&!FnyP7dmnyY!5uM4mci?A3=uoTO%94oLAtFRhtuommE9viR`o3I&M zuoc^|9XqfSyRaL3uowHV9|v#{hj182a1_UI94BxRr*Il)a2Drq9v5&Cmv9+Za23~Z z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=#pYR!9@D<@E89eEMOsvSj-ZZvW(@dU?r9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxRJWSa literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/zarr.json b/tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/zarr.json new file mode 100644 index 000000000..6ee039e8a --- /dev/null +++ b/tests/data/omezarr/dim5_v3_badcodec.ome.zarr/0/zarr.json @@ -0,0 +1 @@ +{"shape": [2, 3, 4, 6, 8], "data_type": "uint16", "chunk_grid": {"name": "regular", "configuration": {"chunk_shape": [2, 3, 1, 6, 8]}}, "chunk_key_encoding": {"name": "default", "configuration": {"separator": "/"}}, "fill_value": 0, "codecs": [{"name": "bytes", "configuration": {"endian": "little"}}, {"name": "bogus_codec_xyz", "configuration": {}}], "attributes": {}, "zarr_format": 3, "node_type": "array", "storage_transformers": []} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_v3_badcodec.ome.zarr/zarr.json b/tests/data/omezarr/dim5_v3_badcodec.ome.zarr/zarr.json new file mode 100644 index 000000000..ea6be0fcd --- /dev/null +++ b/tests/data/omezarr/dim5_v3_badcodec.ome.zarr/zarr.json @@ -0,0 +1,52 @@ +{ + "attributes": { + "ome": { + "version": "0.5", + "multiscales": [ + { + "axes": [ + { + "name": "t", + "type": "time" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ] + } + ] + } + }, + "zarr_format": 3, + "node_type": "group" +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/c/0/0/0/0/0 b/tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/c/0/0/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..92af2fadbe45a272540cdea54682bc5a13377fe5 GIT binary patch literal 257 zcmZQ#RAh2sVqgGaMn(n(5e5c^(?I-xBO?|V^&0Dwc z+`V`I!NW(7pFDl`{Kd;xuiw0V_x?j;Q*%peTYE=mS9ecuU;l)OlO|7@I&J!lnX_ik znLBU(f`y9~FIl>5`HGdRRp5e5c^(?I-xqoI+piK&^ng{76Xjjf%%gQJtPi>sTvho_gf zkFTG9KwwaCNN8AiL}XNSOl(|y!pBdazkL1n{m0K=zyJLG_n(20iJ66!jh%y&i<^g+ zk6%DgNLWNvOk6@zN?Jx%PF`W-rp;TnZri?N=dRs*_U_w%;NYRdM~)sle&Xb*(`U|} zJAdKgrOQ{YUb}wd=B?Xz?%uoqps=X8q_nKOqOz*GrnauWp|PpCrM0cSqqD2Kr?;p5e5c^(?I-xV`5TrN@`kqMrKxaPHtX)L19sGNoiSmMP*fW zO>JF$Lt|5OOKV$uM`u@ePj6rU1Vtrf6;(BL4NWa=9bG+r14AQY6H_yD3rj0&8(TYj z2S+Do7gslT4^J;|A74NJfQOGBKY9A>`HPpYUcY(!?)`_4pFV&2`tAFVpTBbje8tLDtJkbuw|>LMO`ErD-L`$l&Rx6r?A^Ej zz`;X@j~qRA{KUyqr_Y={cmBcw29E%+Me;!J$TKi9$jCD?GFZzqF*4}NGlRG+j11xd HEFb^?ZoX+_ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/c/0/0/3/0/0 b/tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/c/0/0/3/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..3a8a1604b6d47aa392be57331bca279cfec5575b GIT binary patch literal 262 zcmZQ#RAh2sVqgGaHbw>p5e5c^(?I-xqnoxQ`wOP8-)y>|V^&0Dwc+`V`I!NW(7pFDl`{Kd;xuiw0V z_x{7jPoKYh{r3IG&tJd){QdWz;Q)h20N5gVpm*dM7#U>b89@XSBZId*Gb4knJPRX( JcmN9s000CDZc_jN literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/zarr.json b/tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/zarr.json new file mode 100644 index 000000000..6da3ab887 --- /dev/null +++ b/tests/data/omezarr/dim5_v3_blosc.ome.zarr/0/zarr.json @@ -0,0 +1,51 @@ +{ + "shape": [ + 2, + 3, + 4, + 6, + 8 + ], + "data_type": "uint16", + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [ + 2, + 3, + 1, + 6, + 8 + ] + } + }, + "chunk_key_encoding": { + "name": "default", + "configuration": { + "separator": "/" + } + }, + "fill_value": 0, + "codecs": [ + { + "name": "bytes", + "configuration": { + "endian": "little" + } + }, + { + "name": "blosc", + "configuration": { + "typesize": 2, + "cname": "lz4", + "clevel": 5, + "shuffle": "shuffle", + "blocksize": 0 + } + } + ], + "attributes": {}, + "zarr_format": 3, + "node_type": "array", + "storage_transformers": [] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_v3_blosc.ome.zarr/zarr.json b/tests/data/omezarr/dim5_v3_blosc.ome.zarr/zarr.json new file mode 100644 index 000000000..ea6be0fcd --- /dev/null +++ b/tests/data/omezarr/dim5_v3_blosc.ome.zarr/zarr.json @@ -0,0 +1,52 @@ +{ + "attributes": { + "ome": { + "version": "0.5", + "multiscales": [ + { + "axes": [ + { + "name": "t", + "type": "time" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ] + } + ] + } + }, + "zarr_format": 3, + "node_type": "group" +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/0/0/0/0/0 b/tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/0/0/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..58946b2a6652cc00e4ff345057c95b248dc3aa0a GIT binary patch literal 644 zcmZ9^S6|3c7{Kvwi0qy0Y$2kMksa9~J0vTctjz2^GP6fU*<@tvWq27bUAn0M%{e#E z`JUhTJTdC1tDc4$X{?EsT4}9~!G=hcW`vPO8Ew1?CYq$Z4m#?jo9=q(DM>&54KUR- z)6Fo)T=V2uY>B0E#j3A?I88OvTnlZ*YbU`_!wi=$!x&?YlWDRkvUJu(SBZM*t&hHv z4KzrKnP!e5|{k?U?Kb<;ifJ@C*| z&y=fh&>_VRJK>~LPP^cuOG>=<##`@v@>!KHe){FNYSBrkz2w!-$SHwK0!RO%SrqiF iz!ib311HARJVg8c20c6Ie}bMK^tiwUf&XP>ru_$a)KSy` literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/0/1/0/0/0 b/tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/0/1/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..75c843ace6a1a783b9b9eb4c9db649605419849e GIT binary patch literal 644 zcmZ9`XIBqk7=ZEXB4m$H2-%wulFE$i>=k81_9$e~0SeJ2_{MjB&_E-9`3E+$xh;&f zhDSW+2`_oYYu@mYPkg49wzjjq9qeLP<4v%qz3gq0ee7#L`#Z#;4s*Dp9phM2T;yVx zxYSjyc8$5NcY_<<@)6$2r~!rn=1Ku5hJmU1y&87P#3h7MfwElbvFgGo59&^IhOVbKL1Jce}^^9`K+g z9<$Wro{-2(h|K)J$$?J>j_yTgNzk(cmjyl*I4Sa@=)KyY=LEef=&3=E4O|$wAtNj0 EKi0cpRR910 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/0/2/0/0/0 b/tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/0/2/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..92fe6cbb8d0cb254f76f54d08e677de7ce9a2d08 GIT binary patch literal 644 zcmZ9^S6dHY7=ZEa8;QsY*%7jZ5D6!)%b}0p&_{48duL^@tTHN_tjH#NWJ~rcBSOZ3 z{wMEy@?5{?zMgjqnM`FGvzfzO=CO#yEMY0T*~4D;ahM{GaFmmrqJ-0|WEHDf!$xx0 z#Afo?&JK2Rfs0(?GS|7mO>S|I`#j(w)0x3cW-*@yEF_C%WV4(V?B@UnIm9uJbAn>d zaF%nNXD#bk&jz-zm2Kp*i+ls@T_I9wNUF~L)-TlE&eo+f*J?q=Z#>U#j-uAJt{T$?AhnVUJN1E;^U+|Jw zyygQRsicZ(zVV$JTHD69CfLc&b}`W&_B7dE{^k#ViL!ywHZ;bj#@Wo~_IH2-O>wBh z9B!H!j&_V=jjDSIb#r3il)zbmBX^NGIq2g9=LRkgoXG!0x7Y75y#ugK?FtNqL#uh}eI}tmv#XuAjF|fM}!N7Kq z|8nngp7;EobL*(9p86VTtcj*tYNfR{h8Sv?;YJ%{tZ^opY>KJkb<{~`-6iUwr@s2> zZ-AL*nQe~wGA*#sQp+s2LW~9)iq%YWEyQW7o%T8yVWd$~jF)PHiKa<2-3(oH)lGt4 zdg~*}K!YS3Y_56IWyrF~VoR*F%4%z@wa$7QY>{KDZFbvZuYHPKQ|!9i?zroo$DVlV znL`dc;;55OIqi(|3SDr~Yj3>u&L?HcefHfCKmD@NCYxp3Zik(A+3$c{2iEGI7;TKP(oHnUWK(p}RX5%B(pw*W4KUCkgH1O> zwj8s~G1okcEwR)xjWy9!v{qVcqpglQ=`7YrqohicVVvp82Q@nl>B&U+u!`0AVQepqXrLPa*&Y>TaS*=>&!*IakQP50dQz(bXu zdZx-jha6Vwgp*D=?Ys*vD)-83Z@l%t}x8q`>8YBX^Nm67;OV hRe@^*$4AvIBLDpjdS1}~1U)V2(SeHs|I5fs`44gsR1^RJ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/1/2/0/0/0 b/tests/data/omezarr/dim5_v3_sharded.ome.zarr/0/c/1/2/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..5b66db100a4a01dcbaf5b211f66b09a4033e4756 GIT binary patch literal 644 zcmZ9^*;)=z6o>Khf77f~>Qf<9WUPduL`2f?$&ivtqEa%2jCBbv!(}8P6p=J(6h#`4 zN+<{VPWC=o&u_iY+7DH$N2=2^J=Y60=(XOcS#R}C-}OTi`lT8D)*sok00HglP^aFj zPyPC!0e#jN4eP6%alsWg7UP9CJ}kwb00MceCwi)SHR`2asYxw*r&hJ;rzSO}Y5mo# z=46Kh3vtAWE_JI%z51w68q|qv@x#J0ma~E&Rl?rZhi`(2G zgizM7mW^y;GvRDy8{3KI7{|%v1gFR)k9^K@jv|VQV;Ax4CXxLdAc<7cNXN})ds}Nub5mnOeO+x$bya0Wd0A;maZzDG deqL@)c2;J_{rB|BSD#H6oCn!1c4*2e>vctzl^O%}Sgc9ts3kLp1Getu4(>jScm6wKdgM nl@;Y>r6t8hg$4O}xjETcnHl%r(<@(nHfjAetx~OCCq@PUKNA>7 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/0/2/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/0/2/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..409e68fd2800cc4ac35b7f70d641c8150c0896b3 GIT binary patch literal 88 zcmdPcs{dCZp_Yk(>6nmoz}2(-s~!2-v*b-qzaE+|<}mUsqdG pT~%38URGLCTvS+)pO>4Hot2q!|2@6()n}8|U(+hp>UCmd0093+8m0gM literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/0/3/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/0/3/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..01d745365e442f4181a2bd2fc7c11e4744e22fe0 GIT binary patch literal 93 zcmdPcs{dCZVJQ;>6Pt)a!`ZV82#_#cMA6k_;`~O*86Y&(i_zZJ+S1(A*ic_rTT@+C nSy5hAT2fq8SdgEWo0FZDnQ{L;z4Fy(lh$9;D%I+BVq^dSIZPLC literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/1/0/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/1/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..e84ce5a9bf4ec44852c77f4494941f0e56f6ed9e GIT binary patch literal 93 zcmdPcs{dCZVJQ;>6Pt*_g57BBOxCh8yOcB6A=#$3ke4W0|CF@E_bWVVz1UJbt;WQ0RZzk7Zd;h literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/1/3/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/1/3/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..ebadf319930c456fbc07658b089748bcfbf0151c GIT binary patch literal 87 zcmdPcs{dCZp_GY%sZB^K;Obd^Rof|F0xmgET$VX&S*DmV2y9lDx3{&nG&eOi)YsM4 pR9972l$Vv36c-g16Pt*_g57BuPJNt12tX k%Sua%iwX2m?z{HYiTAq_sKc6xGrba=48 vx4X0be(Ss|GXx*Ic8hBIO_30MEa9k0>CFT literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/2/3/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/0/2/3/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..690b4064a09983b3aba7780349e9185de5d940ed GIT binary patch literal 80 zcmV-W0I&ZjwJ-f3U_Al=0>%&%kgPWY@Lcd*6sS^FRkaEL5GVmZJv%u!H8U|UEh{M} mB_kmp9UB=J6%!E;4GRee1p@)U-Y$2m&0??CDs?K2LID7eHx;Y^ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/0/0/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/0/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..555a2d3be5d9baaa428d2e78c2f3a4068b1646cc GIT binary patch literal 85 zcmdPcs{dCZA(e@NiA_kt;PTo35U~I5A+@a^_<2Aeq|@Hk+S1(A*ic_rTT@+CSy5hA jT2fq8SdgEWo0FZDnQ{L;z4Fy(lh$9;D%I+BVq^dSU=kd6 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/0/1/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/0/1/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..9fea9de9f30fe8ba361331f4cc59eea2a3677e9f GIT binary patch literal 89 zcmdPcs{dCZp_Pe&iA`9h;QU#ItMT=Jzg`Cts;(Y_K`np&gMn40y{)ySxv8CI nML|YFpDu2kSQuz1`0>Jq2?YTL0v}$#E!uud=Udj=pydnz1tBhd literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/1/0/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/1/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..6b0bd007965a58e5e283c7e55d4725b3ad3a0244 GIT binary patch literal 73 zcmdPcs{dCZft87YiH)CEVTCy}gKqFpG{hSO{-L^m#>zI0RU1x5kvq0 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/1/1/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/1/1/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..26b7c787393185c621a495eff84334809e94be02 GIT binary patch literal 84 zcmdPcs{dCZA(n}O>6oCn!1c4*2e>vctzl^O%}Sgc9tr~H#`d<>mgc6$hWfhNn(C^` mit@73lH#Jmg8aPPob0U3jQj8Dm9IXVwEmh_saCHOBLe^-(-*V= literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/1/2/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/1/2/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..a96a6b55c8d36ce4115d8f8c8029768967258ddb GIT binary patch literal 89 zcmdPcs{dCZp_Pe&iA`80;Obd^Rof|F0xmgET$VX&S*DmV1ejPb+S^)NnwuIM>g#H2 ps;eq1%F9Yiii-*h^7C?Yva>QX?!Tv3zWQv^`fFOHTD?w;3;?Iw83O6Pt*_g57BL<^#x3#u3H#IiY*VWckS5;P& kmz9<0MZ2_QUCw| literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/2/1/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/2/1/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..43fbbc4e2a219ffcdcf9ee3ee0667a9d1c66d3ac GIT binary patch literal 97 zcmdPcs{dCZ;V2UW6Pu{QgtJC$AGUwk{^vgosO$cLLdA(G?=8(ujScm6wKdgMl@;Y> rr6t8hg$4O}xjETcnd1+v-g-5}n|JGFAMM^P7hSk{x16_m&B6cxTgNBF literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/2/2/0/0 b/tests/data/omezarr/dim5_v3_zstd.ome.zarr/0/c/1/2/2/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..c529517d50d252f8711259d281d404747731a327 GIT binary patch literal 82 zcmV-Y0ImNhwJ-f3U`+x50!9%P;Mz3 legacy fallback]")) +def write_calibrated(name, order, scale, unit): + """A physically calibrated store: coordinateTransformations 'scale' != 1 and the + space axes carry a unit, so the loader must surface physicalSize*/unit. Drives the + --use-physical-spacing calibration path. Same coordinate encoding as write_store.""" + path = os.path.join(HERE, name) + shutil.rmtree(path, ignore_errors=True) + data = _data_for(order) + chunks = tuple(1 if a == "z" else s for a, s in zip(order, data.shape)) + + g = zarr.open_group(path, mode="w", zarr_format=2) + a = g.create_array("0", shape=data.shape, dtype=data.dtype, chunks=chunks, compressors=None) + a[:] = data + + axes = [] + for ax in order: + e = dict(_AX[ax]) + if e["type"] == "space": + e["unit"] = unit + axes.append(e) + ms = {"version": "0.4", "axes": axes, + "datasets": [{"path": "0", "coordinateTransformations": [{"type": "scale", "scale": scale}]}]} + g.attrs.put({"multiscales": [ms]}) + print("wrote %-22s (calibrated scale=%s unit=%s)" % (name, scale, unit)) + + +def write_multichunk(name, order, chunk_y, chunk_x): + """Same coordinate encoding as write_store, but each Y/X plane is split across a GRID of + chunks instead of being one chunk. This is what real OME-Zarr looks like (chunks are + typically 512x512), and it exercises the multi-tile volumetric assembly: a reader that + fetches only chunk (0,0) returns wrong data for everything past the first chunk.""" + path = os.path.join(HERE, name) + shutil.rmtree(path, ignore_errors=True) + data = _data_for(order) + chunks = tuple((chunk_y if a == "y" else chunk_x if a == "x" else 1) for a in order) + + g = zarr.open_group(path, mode="w", zarr_format=2) + a = g.create_array("0", shape=data.shape, dtype=data.dtype, chunks=chunks, compressors=None) + a[:] = data + + ms = {"version": "0.4", "axes": [_AX[ax] for ax in order], + "datasets": [{"path": "0", + "coordinateTransformations": [{"type": "scale", "scale": [1.0] * len(order)}]}]} + g.attrs.put({"multiscales": [ms]}) + print("wrote %-24s (chunk grid %dx%d over the %dx%d plane)" + % (name, -(-Y // chunk_y), -(-X // chunk_x), Y, X)) + + +def write_v3(name, order, compressors=None): + """OME-Zarr 0.5 = Zarr **v3** store: metadata in zarr.json (not .zarray/.zattrs), the + NGFF block nested under an 'ome' key, chunks under 0/c//... The nyxus loader reads + v3 via the z5 Dataset API (z5 3.x auto-detects v2 vs v3). Uncompressed by default so it + reads with the current blosc+zlib z5 build (zstd-compressed v3 would need -DWITH_ZSTD).""" + path = os.path.join(HERE, name) + shutil.rmtree(path, ignore_errors=True) + data = _data_for(order) + chunks = tuple(1 if a == "z" else s for a, s in zip(order, data.shape)) + + g = zarr.open_group(path, mode="w", zarr_format=3) + a = g.create_array("0", shape=data.shape, dtype=data.dtype, chunks=chunks, compressors=compressors) + a[:] = data + g.attrs["ome"] = {"version": "0.5", + "multiscales": [{"axes": [_AX[ax] for ax in order], + "datasets": [{"path": "0", + "coordinateTransformations": [{"type": "scale", "scale": [1.0] * len(order)}]}]}]} + print("wrote %-24s (Zarr v3, axes=%s, compressors=%s)" % (name, order, compressors)) + + +def write_v3_sharded(name, order): + """Zarr v3 store using the ``sharding_indexed`` codec: several INNER chunks are packed + into one shard object on disk (one file per shard, not per chunk). This is how large v3 + stores (incl. Axle's) actually lay out data. z5 3.x reads it transparently via + ShardedDataset -- the nyxus loader is unchanged, it just calls readSubarray. + + Same TCZYX coordinate encoding as write_v3. Inner chunk (z,y,x)=(1,3,4) -> each 6x8 plane + is a 2x2 grid of inner chunks; shard (1,6,8) packs all 4 inner chunks of a plane into one + shard, so the read must unpack multiple inner chunks from a single shard file.""" + path = os.path.join(HERE, name) + shutil.rmtree(path, ignore_errors=True) + data = _data_for(order) # [t,c,z,y,x], order tczyx + inner = tuple((1 if a == "z" else 3 if a == "y" else 4 if a == "x" else 1) for a in order) + shards = tuple((s if a in ("y", "x", "z") else 1) for a, s in zip(order, data.shape)) + + g = zarr.open_group(path, mode="w", zarr_format=3) + a = g.create_array("0", shape=data.shape, dtype=data.dtype, + chunks=inner, shards=shards, compressors=None) + a[:] = data + g.attrs["ome"] = {"version": "0.5", + "multiscales": [{"axes": [_AX[ax] for ax in order], + "datasets": [{"path": "0", + "coordinateTransformations": [{"type": "scale", "scale": [1.0] * len(order)}]}]}]} + print("wrote %-24s (Zarr v3 SHARDED, inner=%s shard=%s)" % (name, inner, shards)) + + +def write_label_mask(name): + """P4/N3: a single-channel ZYX label mask (mostly background, a few small ROIs), the + OME-Zarr twin of dim3_mask.ome.tif. Pairs with a T>1 Zarr intensity to exercise the + 1-mask : N-timeframe reuse on the Zarr path (the crash's positive twin).""" + path = os.path.join(HERE, name) + shutil.rmtree(path, ignore_errors=True) + m = np.zeros((Z, Y, X), "uint32") + m[:, 1:5, 1:7] = 1 # one ROI (label 1), interior bbox + g = zarr.open_group(path, mode="w", zarr_format=2) + a = g.create_array("0", shape=m.shape, dtype=m.dtype, chunks=(1, Y, X), compressors=None) + a[:] = m + g.attrs.put({"multiscales": [{"version": "0.4", "axes": [_AX[c] for c in "zyx"], + "datasets": [{"path": "0", "coordinateTransformations": [ + {"type": "scale", "scale": [1.0, 1.0, 1.0]}]}]}]}) + print("wrote %-24s (ZYX label mask, ROI voxels=%d)" % (name, int(m.sum()))) + + +def write_v3_badcodec(name): + """N1: a Zarr v3 store whose zarr.json declares a codec z5 does not support. z5's + readV3CodecsFromJson throws 'unsupported zarr v3 codec' at openDataset (metadata parse, + before any data read), so the loader must surface a clean error, not crash. Written + uncompressed, then the codec list is patched to an unknown name.""" + path = os.path.join(HERE, name) + shutil.rmtree(path, ignore_errors=True) + data = _data_for("tczyx") + chunks = tuple(1 if a == "z" else s for a, s in zip("tczyx", data.shape)) + g = zarr.open_group(path, mode="w", zarr_format=3) + a = g.create_array("0", shape=data.shape, dtype=data.dtype, chunks=chunks, compressors=None) + a[:] = data + g.attrs["ome"] = {"version": "0.5", "multiscales": [{"axes": [_AX[c] for c in "tczyx"], + "datasets": [{"path": "0", "coordinateTransformations": [ + {"type": "scale", "scale": [1.0] * 5}]}]}]} + # patch the array's zarr.json to reference an unsupported codec + zj_path = os.path.join(path, "0", "zarr.json") + zj = json.load(open(zj_path)) + zj["codecs"] = [{"name": "bytes", "configuration": {"endian": "little"}}, + {"name": "bogus_codec_xyz", "configuration": {}}] + json.dump(zj, open(zj_path, "w")) + print("wrote %-24s (v3 with unsupported codec 'bogus_codec_xyz')" % name) + + def write_bad(name, shape, axes): """Write a deliberately malformed store (its 'axes' disagrees with the array) so the loader must reject it cleanly, not crash.""" @@ -103,6 +238,21 @@ def main(): write_store("dim3_zyx.ome.zarr", "zyx") write_store("dim2_yx.ome.zarr", "yx") write_store("dim5_noaxes.ome.zarr", "tczyx", include_axes=False) + # physically calibrated: anisotropic voxels (z 4x thicker than x/y after normalization). + # order tczyx -> scale [t,c,z,y,x] = [1,1,2.0,0.5,0.5] -> physX=0.5 physY=0.5 physZ=2.0 um. + write_calibrated("dim5_calibrated.ome.zarr", "tczyx", [1.0, 1.0, 2.0, 0.5, 0.5], "micrometer") + # OME-Zarr 0.5 (Zarr v3): same 5D TCZYX encoding, but zarr.json metadata + 'ome'-wrapped NGFF + write_v3("dim5_v3.ome.zarr", "tczyx") + # Zarr v3 with the BLOSC codec (common alongside zstd in real v3 stores). z5 decodes the + # bytes+blosc v3 pipeline when built WITH_BLOSC (already required for OME-Zarr). + write_v3("dim5_v3_blosc.ome.zarr", "tczyx", + compressors=[BloscCodec(cname="lz4", clevel=5, shuffle="shuffle")]) + # plane split across a 2x2 chunk grid (3x4 chunks over the 6x8 plane) -> multi-tile assembly + write_multichunk("dim5_multichunk.ome.zarr", "tczyx", 3, 4) + # PARTIAL edge chunks: chunk (4,5) does NOT divide the 6x8 plane, so the last row-chunk is + # 2 tall and the last col-chunk is 3 wide. Exercises the validH/validW seam clamp in the + # volumetric assembly, which every exact-multiple fixture (above) leaves untested. + write_multichunk("dim5_oddchunk.ome.zarr", "tczyx", 4, 5) # --- illegal / adversarial: must be rejected cleanly, not crash --- # 3D array but 'axes' declares 5 entries -> indexing the shape by axis role OOBs write_bad("bad_axes_count.ome.zarr", (Z, Y, X), [_AX[k] for k in ("t", "c", "z", "y", "x")]) diff --git a/tests/python/test_nyxus.py b/tests/python/test_nyxus.py index 2b03a95e9..deb5fc878 100644 --- a/tests/python/test_nyxus.py +++ b/tests/python/test_nyxus.py @@ -426,10 +426,15 @@ def test_in_memory_3d(self): means = cpu_features.mean(numeric_only=True) - mean_values = means.tolist() + # Drop the non-feature index/calibration columns by NAME so this stays correct as + # the output schema grows. (This used to pop the 2 leftmost columns positionally, + # which silently averaged c_index/phys_x/y/z into the first feature once the + # channel-index and physical-spacing columns were added.) + for _c in ("ROI_label", "time", "t_index", "c_index", "phys_x", "phys_y", "phys_z"): + if _c in means.index: + means = means.drop(_c) - mean_values.pop(0) # get rid of leftmost ROI label column - mean_values.pop(0) # get rid of leftmost time column + mean_values = means.tolist() averaged_results = [] diff --git a/tests/test_3d_coverage_common.h b/tests/test_3d_coverage_common.h index bd54f0ddb..de57f6f4d 100644 --- a/tests/test_3d_coverage_common.h +++ b/tests/test_3d_coverage_common.h @@ -143,14 +143,14 @@ static Computed3DFeatureValues build_computed_3d_feature_values() e.dataset.update_dataset_props_extrema(); clear_slide_rois(e.uniqueLabels, e.roiData); - if (!gatherRoisMetrics_3D(e, 0, ipath, mpath, 0)) + if (!gatherRoisMetrics_3D(e, 0, ipath, mpath, 0/*t_index*/, 0/*channel*/)) { out.setup_error = "gatherRoisMetrics_3D failed for 3D coverage phantom"; return false; } std::vector batch = { label }; - if (!scanTrivialRois_3D(e, batch, ipath, mpath, 0)) + if (!scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)) { out.setup_error = "scanTrivialRois_3D failed for 3D coverage phantom"; return false; diff --git a/tests/test_3d_firstorder_pyradiomics.h b/tests/test_3d_firstorder_pyradiomics.h index ea165d90f..51252b869 100644 --- a/tests/test_3d_firstorder_pyradiomics.h +++ b/tests/test_3d_firstorder_pyradiomics.h @@ -95,11 +95,11 @@ void test_compat_radiomics_3fo_feature (const Nyxus::Feature3D &expected_fcode, // properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE (gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE (gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE (scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE (scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // buffers ASSERT_NO_THROW (allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_glcm.h b/tests/test_3d_glcm.h index 80677669d..4bfff26c0 100644 --- a/tests/test_3d_glcm.h +++ b/tests/test_3d_glcm.h @@ -85,11 +85,11 @@ void test_3glcm_feature (const Nyxus::Feature3D& expecting_fcode, const std::str // properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_glcm_pyradiomics.h b/tests/test_3d_glcm_pyradiomics.h index 8c4b3e45c..be176b14a 100644 --- a/tests/test_3d_glcm_pyradiomics.h +++ b/tests/test_3d_glcm_pyradiomics.h @@ -146,11 +146,11 @@ void test_compat_3glcm_feature (const Nyxus::Feature3D& expecting_fcode, const s // properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); @@ -214,9 +214,9 @@ void test_3dglcm_equivalence_dump() ASSERT_TRUE(scan_slide_props(sp, 3, e.anisoOptions, e.resultOptions.need_annotation())); e.dataset.update_dataset_props_extrema(); clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0, ipath, mpath, 0)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0, ipath, mpath, 0/*t_index*/, 0/*channel*/)); std::vector batch = { label }; - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); Fsettings s; diff --git a/tests/test_3d_gldm.h b/tests/test_3d_gldm.h index 4972fbb23..5f445bde3 100644 --- a/tests/test_3d_gldm.h +++ b/tests/test_3d_gldm.h @@ -42,9 +42,9 @@ void test_3gldm_feature (const Nyxus::Feature3D& expecting_fcode, const std::str // mock the 3D workflow Environment e; clear_slide_rois (e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); // make it find the feature code by name @@ -84,10 +84,10 @@ void test_3gldm_feature (const Nyxus::Feature3D& expecting_fcode, const std::str e.dataset.update_dataset_props_extrema(); // (2) properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (3) voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (4) buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_gldm_pyradiomics.h b/tests/test_3d_gldm_pyradiomics.h index 87bfb4358..2e58313f0 100644 --- a/tests/test_3d_gldm_pyradiomics.h +++ b/tests/test_3d_gldm_pyradiomics.h @@ -75,11 +75,11 @@ void test_compat_3gldm_feature (const Nyxus::Feature3D & expecting_fcode, const // properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois (e.uniqueLabels, e.roiData); - ASSERT_TRUE (gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE (gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE (scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE (scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // buffers ASSERT_NO_THROW (allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_gldzm_ibsi.h b/tests/test_3d_gldzm_ibsi.h index 70ac2fa86..dfd2937a7 100644 --- a/tests/test_3d_gldzm_ibsi.h +++ b/tests/test_3d_gldzm_ibsi.h @@ -41,9 +41,9 @@ void test_3gldzm_feature (const Nyxus::Feature3D& expecting_fcode, const std::st // mock the 3D workflow Environment e; clear_slide_rois (e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); // make it find the feature code by name @@ -84,10 +84,10 @@ void test_3gldzm_feature (const Nyxus::Feature3D& expecting_fcode, const std::st e.dataset.update_dataset_props_extrema(); // (2) properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (3) voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (4) buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_glrlm.h b/tests/test_3d_glrlm.h index fba745c89..61e8fedae 100644 --- a/tests/test_3d_glrlm.h +++ b/tests/test_3d_glrlm.h @@ -43,9 +43,9 @@ void test_3glrlm_feature (const Nyxus::Feature3D& expecting_fcode, const std::st // mock the 3D workflow Environment e; clear_slide_rois (e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); // make it find the feature code by name @@ -85,10 +85,10 @@ void test_3glrlm_feature (const Nyxus::Feature3D& expecting_fcode, const std::st e.dataset.update_dataset_props_extrema(); // (2) properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (3) voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (4) buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); @@ -210,4 +210,4 @@ void test_3glrlm_rv() void test_3glrlm_re() { test_3glrlm_feature(Nyxus::Feature3D::GLRLM_RE, "3GLRLM_RE"); -} \ No newline at end of file +} \ No newline at end of file diff --git a/tests/test_3d_glrlm_pyradiomics.h b/tests/test_3d_glrlm_pyradiomics.h index 922c69a4a..ff28b09aa 100644 --- a/tests/test_3d_glrlm_pyradiomics.h +++ b/tests/test_3d_glrlm_pyradiomics.h @@ -160,11 +160,11 @@ void test_compat_3glrlm_feature(const Nyxus::Feature3D& expecting_fcode, const s // properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); @@ -229,9 +229,9 @@ void test_compat_3glrlm_ave_features() ASSERT_TRUE(scan_slide_props(sp, 3, e.anisoOptions, e.resultOptions.need_annotation())); e.dataset.update_dataset_props_extrema(); clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0, ipath, mpath, 0)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0, ipath, mpath, 0/*t_index*/, 0/*channel*/)); std::vector batch = { label }; - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); Fsettings s; diff --git a/tests/test_3d_glszm.h b/tests/test_3d_glszm.h index d418e2865..b6c647837 100644 --- a/tests/test_3d_glszm.h +++ b/tests/test_3d_glszm.h @@ -41,9 +41,9 @@ void test_3glszm_feature (const Nyxus::Feature3D& expecting_fcode, const std::st // mock the 3D workflow Environment e; clear_slide_rois (e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); // make it find the feature code by name @@ -83,10 +83,10 @@ void test_3glszm_feature (const Nyxus::Feature3D& expecting_fcode, const std::st e.dataset.update_dataset_props_extrema(); // (2) properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (3) voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (4) buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_glszm_pyradiomics.h b/tests/test_3d_glszm_pyradiomics.h index 1c67b402f..0a18986a2 100644 --- a/tests/test_3d_glszm_pyradiomics.h +++ b/tests/test_3d_glszm_pyradiomics.h @@ -76,11 +76,11 @@ void test_compat_3glszm_feature (const Nyxus::Feature3D& expecting_fcode, const // properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_inten.h b/tests/test_3d_inten.h index 2ff94c237..913bc6ecb 100644 --- a/tests/test_3d_inten.h +++ b/tests/test_3d_inten.h @@ -67,10 +67,10 @@ void test_3inten_feature (const std::string& fname, const Nyxus::Feature3D& expe e.dataset.update_dataset_props_extrema(); // (2) properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois (e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (3) voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (4) buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_morphology_common.h b/tests/test_3d_morphology_common.h index 4f3265d46..901acf6db 100644 --- a/tests/test_3d_morphology_common.h +++ b/tests/test_3d_morphology_common.h @@ -33,9 +33,9 @@ void test_3shape_feature (const std::string& fname, const Nyxus::Feature3D& expe // mock the 3D workflow Environment e; clear_slide_rois (e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); // make it find the feature code by name @@ -78,10 +78,10 @@ void test_3shape_feature (const std::string& fname, const Nyxus::Feature3D& expe e.dataset.update_dataset_props_extrema(); // (2) properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (3) voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (4) buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_ngldm_regression.h b/tests/test_3d_ngldm_regression.h index 217e1d3ac..4a0c47a9a 100644 --- a/tests/test_3d_ngldm_regression.h +++ b/tests/test_3d_ngldm_regression.h @@ -79,9 +79,9 @@ void test_3ngldm_feature (const std::string& fname, const Nyxus::Feature3D& expe // mock the 3D workflow Environment e; clear_slide_rois (e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); // make it find the feature code by name @@ -122,10 +122,10 @@ void test_3ngldm_feature (const std::string& fname, const Nyxus::Feature3D& expe e.dataset.update_dataset_props_extrema(); // (2) properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (3) voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (4) buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_ngtdm.h b/tests/test_3d_ngtdm.h index e0a809d00..f8aa205e5 100644 --- a/tests/test_3d_ngtdm.h +++ b/tests/test_3d_ngtdm.h @@ -30,9 +30,9 @@ void test_3ngtdm_feature(const Nyxus::Feature3D& expecting_fcode, const std::str // mock the 3D workflow Environment e; clear_slide_rois (e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); // make it find the feature code by name @@ -73,10 +73,10 @@ void test_3ngtdm_feature(const Nyxus::Feature3D& expecting_fcode, const std::str e.dataset.update_dataset_props_extrema(); // (2) properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (3) voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // (4) buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_3d_ngtdm_pyradiomics.h b/tests/test_3d_ngtdm_pyradiomics.h index 9466866c1..df57fb6a4 100644 --- a/tests/test_3d_ngtdm_pyradiomics.h +++ b/tests/test_3d_ngtdm_pyradiomics.h @@ -83,11 +83,11 @@ void test_compat_3ngtdm_feature (const Nyxus::Feature3D& expecting_fcode, const // properties of specific ROIs sitting in 'e.uniqueLabels' clear_slide_rois(e.uniqueLabels, e.roiData); - ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0/*slide_index*/, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // voxel clouds std::vector batch = { label }; // expecting this roi label after metrics gathering - ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/)); + ASSERT_TRUE(scanTrivialRois_3D(e, batch, ipath, mpath, 0/*t_index*/, 0/*channel*/)); // buffers ASSERT_NO_THROW(allocateTrivialRoisBuffers_3D(batch, e.roiData, e.hostCache)); diff --git a/tests/test_all.cc b/tests/test_all.cc index cf0a339bd..047bb871a 100644 --- a/tests/test_all.cc +++ b/tests/test_all.cc @@ -1,8 +1,10 @@ #define NOMINMAX // keep Windows min/max macros from breaking dcmtk's OFvariant (DICOM tests) #include +#include // reading a written CSV back in TEST_CSV_MULTICHANNEL_NO_OVERWRITE #include "test_gabor_regression.h" #include "../src/nyx/environment.h" #include "../src/nyx/globals.h" +#include "../src/nyx/ome/format_detect.h" // detect_input_format (P1) #include "test_contour.h" #include "test_ome_meta.h" // native-OME metadata parsers / OmeAxes descriptor #include "test_ometiff_mechanics.h" // OME-TIFF native (z,c,t)->IFD read (core; no USE_Z5) @@ -2574,6 +2576,11 @@ TEST(TEST_NYXUS, TEST_OMEZARR_WHOLEVOLUME_CONSUMER) { } // Facade whole-volume assembly (load_volume loops Z into one X*Y*Z buffer). +// Wired consumer reads the correct plane for every (channel, timeframe), not just (0,0). +TEST(TEST_NYXUS, TEST_OMEZARR_WHOLEVOLUME_CONSUMER_CT) { + ASSERT_NO_THROW (test_omezarr_wholevolume_consumer_ct("dim5.ome.zarr", 2, 3, 4)); +} + TEST(TEST_NYXUS, TEST_OMEZARR_FACADE_VOLUME_3D) { ASSERT_NO_THROW (test_omezarr_facade_volume("dim3_zyx.ome.zarr", 1, 1, 4)); } @@ -2595,6 +2602,71 @@ TEST(TEST_NYXUS, TEST_OMEZARR_2D_YX) { ASSERT_NO_THROW (test_omezarr_addressing("dim2_yx.ome.zarr", 1, 1, 1)); } +// OME-Zarr 0.5 (Zarr v3): zarr.json metadata + 'ome'-wrapped NGFF + 0/c/... chunk keys, +// read through the z5 Dataset API (v2/v3-agnostic). Same coordinate encoding as the v2 +// stores, so the addressing / facade / CT-count helpers apply unchanged. +TEST(TEST_NYXUS, TEST_OMEZARR_V3) { + ASSERT_NO_THROW (test_omezarr_addressing("dim5_v3.ome.zarr", 2, 3, 4)); + ASSERT_NO_THROW (test_raw_omezarr_addressing("dim5_v3.ome.zarr", 2, 3, 4)); +} +TEST(TEST_NYXUS, TEST_OMEZARR_V3_FACADE_VOLUME) { + ASSERT_NO_THROW (test_omezarr_facade_volume("dim5_v3.ome.zarr", 2, 3, 4)); +} +TEST(TEST_NYXUS, TEST_OMEZARR_V3_CT_COUNTS) { + ASSERT_NO_THROW (test_omezarr_ct_counts("dim5_v3.ome.zarr", 2, 3, 4)); +} +// Zstd-compressed Zarr v3 (the zarr 3.x / real-world default codec). Requires the build +// to link libzstd (-DWITH_ZSTD); z5 decodes the bytes+zstd v3 codec pipeline. +TEST(TEST_NYXUS, TEST_OMEZARR_V3_ZSTD) { + ASSERT_NO_THROW (test_omezarr_addressing("dim5_v3_zstd.ome.zarr", 2, 3, 4)); + ASSERT_NO_THROW (test_raw_omezarr_addressing("dim5_v3_zstd.ome.zarr", 2, 3, 4)); +} + +// Sharded Zarr v3 (the ``sharding_indexed`` codec) -- how large v3 stores, including Axle's, +// actually lay out data: many inner chunks packed into one shard object per (t,c). z5 3.x +// reads it via ShardedDataset, chosen automatically when zarr.json carries a shard shape; the +// nyxus loader is UNCHANGED because it reads through readSubarray, which unpacks the inner +// chunks from the shard transparently. The fixture's inner chunk is (z,y,x)=(1,3,4) so each +// 6x8 plane is a 2x2 grid of inner chunks living inside one shard -- the read must assemble +// across inner-chunk boundaries within a shard. Same 1..1152 TCZYX encoding as the other v3. +// +// Coverage is the whole-volume facade + prescan, NOT test_omezarr_addressing: with sharding, +// tileWidth/Height report the INNER chunk (4x3), so a single loadTileFromFile(0,0,...) reads +// only the top-left inner chunk, not the whole plane -- the addressing helper's one-tile-per- +// plane assumption. facade_volume assembles the full inner-chunk grid and checks every voxel, +// which is the correct coverage for a multi-chunk store (same reason the multichunk v2 fixture +// uses it). It drives both loadTileFromFile (abstract stack) and readSubarray under the hood. +TEST(TEST_NYXUS, TEST_OMEZARR_V3_SHARDED_FACADE_VOLUME) { + ASSERT_NO_THROW (test_omezarr_facade_volume("dim5_v3_sharded.ome.zarr", 2, 3, 4)); +} +TEST(TEST_NYXUS, TEST_OMEZARR_V3_SHARDED_CT_COUNTS) { + ASSERT_NO_THROW (test_omezarr_ct_counts("dim5_v3_sharded.ome.zarr", 2, 3, 4)); +} +// Prescan over the sharded store (raw loader's readSubarray, driven through the inner-chunk +// tile grid by for_each_voxel): whole-slide, so the ROI is the whole X*Y*Z volume. +TEST(TEST_NYXUS, TEST_OMEZARR_V3_SHARDED_PRESCAN) { + fs::path ip = omezarr_data_path("dim5_v3_sharded.ome.zarr"); + ASSERT_TRUE(fs::exists(ip)) << ip.string(); + + Environment e; + SlideProps p (ip.string(), ""); // whole-slide: no mask + ASSERT_TRUE(Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + + EXPECT_DOUBLE_EQ(p.min_preroi_inten, 1.0); + EXPECT_DOUBLE_EQ(p.max_preroi_inten, 1152.0); + EXPECT_EQ(p.max_roi_area, (size_t)(8 * 6 * 4)); +} + +// Blosc-compressed Zarr v3 (common in real v3 stores alongside zstd). z5 decodes the +// bytes+blosc v3 codec pipeline when built WITH_BLOSC (already required for OME-Zarr). +TEST(TEST_NYXUS, TEST_OMEZARR_V3_BLOSC) { + ASSERT_NO_THROW (test_omezarr_addressing("dim5_v3_blosc.ome.zarr", 2, 3, 4)); + ASSERT_NO_THROW (test_raw_omezarr_addressing("dim5_v3_blosc.ome.zarr", 2, 3, 4)); +} +TEST(TEST_NYXUS, TEST_OMEZARR_V3_BLOSC_FACADE_VOLUME) { + ASSERT_NO_THROW (test_omezarr_facade_volume("dim5_v3_blosc.ome.zarr", 2, 3, 4)); +} + // No 'axes' metadata -> the loader falls back to legacy 5D TCZYX and still reads. TEST(TEST_NYXUS, TEST_OMEZARR_NOAXES_FALLBACK) { ASSERT_NO_THROW (test_omezarr_addressing("dim5_noaxes.ome.zarr", 2, 3, 4)); @@ -2604,6 +2676,71 @@ TEST(TEST_NYXUS, TEST_RAW_OMEZARR_NOAXES_FALLBACK) { ASSERT_NO_THROW (test_raw_omezarr_addressing("dim5_noaxes.ome.zarr", 2, 3, 4)); } +// Loaders advertise the real C/T extents (numberChannels/fullTimestamps), which +// is what activates the pipeline's channel/timeframe iteration. dim5_noaxes proves +// the positional fallback reports counts too. +TEST(TEST_NYXUS, TEST_OMEZARR_CT_COUNTS) { + ASSERT_NO_THROW (test_omezarr_ct_counts("dim5.ome.zarr", 2, 3, 4)); + ASSERT_NO_THROW (test_omezarr_ct_counts("dim4_tzyx.ome.zarr", 2, 1, 4)); + ASSERT_NO_THROW (test_omezarr_ct_counts("dim4_czyx.ome.zarr", 1, 3, 4)); + ASSERT_NO_THROW (test_omezarr_ct_counts("dim3_zyx.ome.zarr", 1, 1, 4)); + ASSERT_NO_THROW (test_omezarr_ct_counts("dim2_yx.ome.zarr", 1, 1, 1)); + ASSERT_NO_THROW (test_omezarr_ct_counts("dim5_noaxes.ome.zarr", 2, 3, 4)); +} + +// Physical calibration: loaders surface coordinateTransformations scale + unit. +TEST(TEST_NYXUS, TEST_OMEZARR_PHYSICAL_CALIBRATION) { + ASSERT_NO_THROW (test_omezarr_physical_calibration()); +} + +// Multi-CHUNK plane: real OME-Zarr splits each Y/X plane across a chunk grid (typically +// 512x512), and dim5_multichunk uses 3x4 chunks over the 6x8 plane. The volumetric read +// must walk the whole tile grid: fetching only chunk (0,0) returns wrong data past the +// first chunk (and over-reads its buffer). Every other fixture is one-chunk-per-plane, +// which is why this went unnoticed. Covers ImageLoader::assemble_volume... +TEST(TEST_NYXUS, TEST_OMEZARR_MULTICHUNK_FACADE_VOLUME) { + ASSERT_NO_THROW (test_omezarr_facade_volume("dim5_multichunk.ome.zarr", 2, 3, 4)); +} +// ...and RawImageLoader::load_volume (the prescan), which had the same single-tile bug: +// the encoded values are 1..1152 over all (c,t), and the ROI is the whole X*Y*Z volume. +TEST(TEST_NYXUS, TEST_OMEZARR_MULTICHUNK_PRESCAN) { + fs::path ip = omezarr_data_path("dim5_multichunk.ome.zarr"); + ASSERT_TRUE(fs::exists(ip)) << ip.string(); + + Environment e; + SlideProps p (ip.string(), ""); // whole-slide: no mask + ASSERT_TRUE(Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + + EXPECT_DOUBLE_EQ(p.min_preroi_inten, 1.0); + EXPECT_DOUBLE_EQ(p.max_preroi_inten, 1152.0); + EXPECT_EQ(p.max_roi_area, (size_t)(8 * 6 * 4)); +} + +// PARTIAL edge chunks: chunk (4,5) does not divide the 6x8 plane, so the last row-chunk is 2 +// tall and the last col-chunk is 3 wide. dim5_multichunk above (3x4 over 6x8) tiles exactly, +// so the validH/validW seam clamp never ran on the OME-Zarr path either. Asserting the exact +// value at every voxel is the seam check; same 1..1152 TCZYX encoding as dim5_multichunk. +TEST(TEST_NYXUS, TEST_OMEZARR_ODDCHUNK_FACADE_VOLUME) { + ASSERT_NO_THROW (test_omezarr_facade_volume("dim5_oddchunk.ome.zarr", 2, 3, 4)); +} +TEST(TEST_NYXUS, TEST_OMEZARR_ODDCHUNK_PRESCAN) { + fs::path ip = omezarr_data_path("dim5_oddchunk.ome.zarr"); + ASSERT_TRUE(fs::exists(ip)) << ip.string(); + + Environment e; + SlideProps p (ip.string(), ""); // whole-slide: no mask + ASSERT_TRUE(Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + + EXPECT_DOUBLE_EQ(p.min_preroi_inten, 1.0); + EXPECT_DOUBLE_EQ(p.max_preroi_inten, 1152.0); + EXPECT_EQ(p.max_roi_area, (size_t)(8 * 6 * 4)); +} + +// Negative: out-of-range channel/timeframe through the whole-volume facade must throw. +TEST(TEST_NYXUS, TEST_OMEZARR_LOAD_VOLUME_OUT_OF_RANGE) { + ASSERT_NO_THROW (test_omezarr_load_volume_out_of_range()); +} + // Negative: out-of-range Z/C/T plane index must throw. TEST(TEST_NYXUS, TEST_OMEZARR_OUT_OF_RANGE_THROWS) { ASSERT_NO_THROW (test_omezarr_out_of_range_throws()); @@ -2614,6 +2751,31 @@ TEST(TEST_NYXUS, TEST_OMEZARR_MALFORMED_THROWS) { ASSERT_NO_THROW (test_omezarr_malformed_throws()); } +// N1 (negative): a Zarr v3 store whose zarr.json declares a codec z5 does not support. z5's +// readV3CodecsFromJson throws "unsupported zarr v3 codec" during metadata parse (openDataset), +// so the loader must surface a clean error, not crash. Both loader stacks must reject it. +TEST(TEST_NYXUS, TEST_OMEZARR_V3_UNSUPPORTED_CODEC_THROWS) { + fs::path ds = omezarr_data_path("dim5_v3_badcodec.ome.zarr"); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + EXPECT_ANY_THROW(NyxusOmeZarrLoader(1, ds.string())); + EXPECT_ANY_THROW(RawOmezarrLoader(ds.string())); +} + +// P4 (positive): the crash's positive twin on the OME-Zarr path -- a T>1 Zarr intensity paired +// with a single-timeframe ZYX Zarr mask. Zarr never crashed (no T axis to over-index), but it +// was never asserted. The prescan must reuse the mask across timeframes and find the ROI. +TEST(TEST_NYXUS, TEST_OMEZARR_MULTITIMEFRAME_MASK_PRESCAN) { + fs::path ip = omezarr_data_path("dim5.ome.zarr"); // T=2, C=3, Z=4 + fs::path mp = omezarr_data_path("dim3_mask.ome.zarr"); // ZYX (T=1) label mask + ASSERT_TRUE(fs::exists(ip) && fs::exists(mp)); + Environment e; + SlideProps p (ip.string(), mp.string()); + bool ok = false; + ASSERT_NO_THROW(ok = Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + EXPECT_TRUE(ok); + EXPECT_EQ(p.max_roi_area, (size_t)(4 * 4 * 6)); +} + #endif // OMEZARR_SUPPORT @@ -2631,6 +2793,161 @@ TEST(TEST_NYXUS, TEST_OMETIFF_ALL_5D_PERMUTATIONS) { ASSERT_NO_THROW (test_ometiff_all_5d_permutations()); } +// Regression (found by the scale/stress harness): a MULTI-timeframe OME-TIFF paired with a +// single-timeframe (ZYX) mask. The 3D prescan (RawImageLoader::for_each_voxel) reuses the +// mask across every intensity timeframe, but read it at the intensity's timeframe -- so for +// t>0 the TIFF mask loader addressed an IFD past its last plane and TIFFSetDirectory threw, +// UNCAUGHT, crashing the process (0xC0000409). Zarr masks have no T axis to over-index, so +// only TIFF crashed. dim5 is T=2,C=3,Z=4; dim3_mask is its ZYX (T=1) segmentation of one ROI +// (label 1 over z=all, y in [1,5), x in [1,7) = 4*4*6 voxels). Pre-fix this threw. +TEST(TEST_NYXUS, TEST_OMETIFF_MULTITIMEFRAME_MASK_PRESCAN) { + fs::path ip = ometiff_data_path("dim5.ome.tif"); + fs::path mp = ometiff_data_path("dim3_mask.ome.tif"); + ASSERT_TRUE(fs::exists(ip)) << ip.string(); + ASSERT_TRUE(fs::exists(mp)) << mp.string(); + + Environment e; + SlideProps p (ip.string(), mp.string()); + bool ok = false; + ASSERT_NO_THROW(ok = Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + EXPECT_TRUE(ok); + EXPECT_EQ(p.max_roi_area, (size_t)(4 * 4 * 6)); // z=4 * y=4 * x=6 +} + +// P3 (positive): the crash's regression above covers the PRESCAN (for_each_voxel); this covers +// the FEATURIZE facade. ImageLoader::load_volume(c,t) forwards t as the mask timeframe, so with +// a T>1 intensity and a T=1 mask, load_volume(c,1) exercises the internal mask-timeframe clamp +// (image_loader.cpp). It must not throw, must reuse the same mask across timeframes, and must +// read different intensity per timeframe. dim5 is T=2; dim3_mask is its ZYX (T=1) mask. +TEST(TEST_NYXUS, TEST_OMETIFF_MULTITIMEFRAME_MASK_FACADE) { + fs::path ip = ometiff_data_path("dim5.ome.tif"); + fs::path mp = ometiff_data_path("dim3_mask.ome.tif"); + ASSERT_TRUE(fs::exists(ip) && fs::exists(mp)); + SlideProps p; p.fname_int = ip.string(); p.fname_seg = mp.string(); + FpImageOptions fp; ImageLoader il; + ASSERT_TRUE(il.open(p, fp)) << ip.string(); + + ASSERT_TRUE(il.load_volume(0, 0)); + const std::vector seg_t0 = il.get_seg_volume_buffer(); + const std::vector int_t0 = il.get_int_volume_buffer(); + // timeframe 1 with a single-timeframe mask must NOT throw (clamp), reuse the mask, and + // deliver different intensity + ASSERT_TRUE(il.load_volume(0, 1)); + EXPECT_EQ(il.get_seg_volume_buffer(), seg_t0) << "mask changed across timeframes"; + EXPECT_NE(il.get_int_volume_buffer(), int_t0) << "intensity t=1 read t=0 data"; + il.close(); +} + +// P2 (positive): OME-TIFF physical calibration -> SlideProps (the TIFF twin of the OME-Zarr +// calibration test, and it additionally checks the scan_slide_props propagation the Zarr test +// omits). dim5_calibrated carries PhysicalSizeX/Y=0.5, Z=2.0 micrometer in its OME-XML. +TEST(TEST_NYXUS, TEST_OMETIFF_PHYSICAL_CALIBRATION) { + fs::path cal = ometiff_data_path("dim5_calibrated.ome.tif"); + ASSERT_TRUE(fs::exists(cal)) << cal.string(); + + Environment e; + SlideProps p (cal.string(), ""); + ASSERT_TRUE(Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + EXPECT_DOUBLE_EQ(p.phys_x, 0.5); + EXPECT_DOUBLE_EQ(p.phys_y, 0.5); + EXPECT_DOUBLE_EQ(p.phys_z, 2.0); + EXPECT_EQ(p.phys_unit, "micrometer"); + + // an uncalibrated OME-TIFF must default to 1.0 / no unit + SlideProps p2 (ometiff_data_path("dim5.ome.tif").string(), ""); + ASSERT_TRUE(Nyxus::scan_slide_props(p2, 3, e.anisoOptions, e.resultOptions.need_annotation())); + EXPECT_DOUBLE_EQ(p2.phys_x, 1.0); + EXPECT_DOUBLE_EQ(p2.phys_z, 1.0); + EXPECT_TRUE(p2.phys_unit.empty()); +} + +// N2 (negative): a block maps a plane to an IFD PAST the end of the file (an +// in-file overrun, distinct from a multi-file UUID). ifdForPlane returns 99, so the read must +// throw cleanly at TIFFSetDirectory -- not crash and not read a wrong plane. dim5_badifd has +// 4 z-planes; plane z3 claims IFD=99. +TEST(TEST_NYXUS, TEST_OMETIFF_BAD_IFD_THROWS) { + fs::path ds = ometiff_data_path("dim5_badifd.ome.tif"); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + SlideProps p; p.fname_int = ds.string(); p.fname_seg = ""; + FpImageOptions fp; ImageLoader il; + ASSERT_TRUE(il.open(p, fp)) << ds.string(); + // z0..z2 are fine; assembling the whole volume must hit z3's bad IFD and throw, not crash + EXPECT_ANY_THROW(il.load_volume(0, 0)); + il.close(); +} + +// N3 (negative): an all-background (all-zero) mask -> ZERO ROIs. The prescan must complete +// cleanly (no divide-by-zero, no garbage), reporting no ROI area, rather than crash. +TEST(TEST_NYXUS, TEST_OMETIFF_EMPTY_MASK_ZERO_ROIS) { + fs::path ip = ometiff_data_path("dim5.ome.tif"); + fs::path mp = ometiff_data_path("dim3_emptymask.ome.tif"); + ASSERT_TRUE(fs::exists(ip) && fs::exists(mp)); + Environment e; + SlideProps p (ip.string(), mp.string()); + bool ok = false; + ASSERT_NO_THROW(ok = Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + EXPECT_TRUE(ok); + EXPECT_EQ(p.max_roi_area, (size_t)0) << "empty mask should yield no ROI"; +} + +// N4 (edge): a mask with MORE channels than the intensity (C=2 mask, C=1-effective use). The +// featurize loop iterates the intensity's channels, so the extra mask channel must simply be +// ignored (mask channel clamped to what is asked), not crash or misread. dim3_zyx (C=1) paired +// with a C=2 label mask; the ROI is identical on both mask channels. +TEST(TEST_NYXUS, TEST_OMETIFF_MASK_MORE_CHANNELS_THAN_INTENSITY) { + fs::path ip = ometiff_data_path("dim3_zyx.ome.tif"); // C=1, Z=4 + fs::path mp = ometiff_data_path("dim4_mask_c2.ome.tif"); // C=2 label mask + ASSERT_TRUE(fs::exists(ip) && fs::exists(mp)); + Environment e; + SlideProps p (ip.string(), mp.string()); + bool ok = false; + ASSERT_NO_THROW(ok = Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + EXPECT_TRUE(ok); + EXPECT_EQ(p.max_roi_area, (size_t)(4 * 4 * 6)); // the one ROI, read from mask channel 0 +} + +// P1 (positive + negative): detect_input_format is the single dispatch point all three loader +// stacks use; it had no direct test. Extension classification + OME content-sniff. +TEST(TEST_NYXUS, TEST_DETECT_INPUT_FORMAT) { + using Nyxus::detect_input_format; + using Nyxus::ContainerKind; + // OME content present -> OmeTiff + is_ome + auto a = detect_input_format(ometiff_data_path("dim5.ome.tif").string()); + EXPECT_EQ(a.kind, ContainerKind::OmeTiff); + EXPECT_TRUE(a.is_ome); + // a plain multipage TIFF (no OME-XML) -> TiffPlain, not OME + auto b = detect_input_format(ometiff_data_path("dim3_plain.tif").string()); + EXPECT_EQ(b.kind, ContainerKind::TiffPlain); + EXPECT_FALSE(b.is_ome); + // extension-only kinds (no file needed / not opened) + EXPECT_EQ(detect_input_format("x.dcm").kind, ContainerKind::Dicom); + EXPECT_EQ(detect_input_format("x.nii.gz").kind, ContainerKind::Nifti); + // a .zarr path with no multiscales metadata -> OmeZarr kind but is_ome=false + auto z = detect_input_format("no_such_store.zarr"); + EXPECT_EQ(z.kind, ContainerKind::OmeZarr); + EXPECT_FALSE(z.is_ome); +} + +// Pyramidal OME-TIFF: every full-res plane's IFD carries downsampled levels as SubIFDs (tag +// 330), which live OUTSIDE the main IFD chain. This must not shift full-res plane addressing: +// TIFFNumberOfDirectories still returns Z (not Z*levels) and ifdForPlane -> main-chain IFD +// still lands on the full-res plane. nyxus reads level 0 only; the facade check (which also +// spans a 2x3 tile grid) asserts every full-res voxel is correct despite the SubIFDs. Z=6 +// z-stack (C=1,T=1), its own encoding. +TEST(TEST_NYXUS, TEST_OMETIFF_PYRAMID_SUBIFD_FULLRES) { + ASSERT_NO_THROW (test_ometiff_multitile_facade_volume("dim5_pyramid.ome.tif", 1, 1, 6, 32, 48)); +} + +// Non-canonical plane->IFD mapping: dim5_reordered stores its planes in REVERSED +// IFD order and declares the mapping via per-plane blocks. A reader that +// ignores TiffData and assumes contiguous-from-IFD-0 order reads the reversed plane's pixels; +// honoring the map reads correctly. load_volume loops Z through loadTileFromFile -> ifdForPlane +// (both loader stacks route here), so the facade check asserts the right plane per (z,c). +// T=1,C=2,Z=3 (its own encoding). This is the OME-TIFF counterpart to what bioformats emits. +TEST(TEST_NYXUS, TEST_OMETIFF_TIFFDATA_REORDERED) { + ASSERT_NO_THROW (test_ometiff_multitile_facade_volume("dim5_reordered.ome.tif", 1, 2, 3, 6, 8)); +} + // 4D (rank-4): time-only and channel-only. TEST(TEST_NYXUS, TEST_OMETIFF_4D_TZYX) { ASSERT_NO_THROW (test_ometiff_addressing("dim4_tzyx.ome.tif", 2, 1, 4)); @@ -2646,6 +2963,63 @@ TEST(TEST_NYXUS, TEST_OMETIFF_WHOLEVOLUME_CONSUMER) { ASSERT_NO_THROW (test_ometiff_wholevolume_consumer("dim3_zyx.ome.tif", 4)); } +// Wired consumer reads the correct plane for every (channel, timeframe), not just (0,0). +TEST(TEST_NYXUS, TEST_OMETIFF_WHOLEVOLUME_CONSUMER_CT) { + ASSERT_NO_THROW (test_ometiff_wholevolume_consumer_ct("dim5.ome.tif", 2, 3, 4)); +} + +// TILED multi-plane OME-TIFF: the tile loaders map (z,c,t)->IFD (distinct from strip loaders). +TEST(TEST_NYXUS, TEST_OMETIFF_TILED_ADDRESSING) { + ASSERT_NO_THROW (test_ometiff_tiled_addressing()); +} +// Facade whole-volume assembly over the TILED path (open() routes tiled TIFF -> tile loader). +TEST(TEST_NYXUS, TEST_OMETIFF_TILED_FACADE_VOLUME) { + ASSERT_NO_THROW (test_ometiff_facade_volume("dim5_tiled.ome.tif", 2, 3, 4)); +} +// Multi-TILE planes (2x3 grid of 16x16 tiles): dim5_tiled above has ONE tile per plane, so +// it passes even when only tile (0,0) is read. This is the OME-TIFF counterpart of +// TEST_OMEZARR_MULTICHUNK_FACADE_VOLUME. Covers ImageLoader::assemble_volume... +TEST(TEST_NYXUS, TEST_OMETIFF_MULTITILE_FACADE_VOLUME) { + ASSERT_NO_THROW (test_ometiff_multitile_facade_volume("dim5_multitile.ome.tif", 1, 2, 2, 32, 48)); +} +// ...and RawImageLoader::for_each_voxel (the prescan), which had the same single-tile bug. +// Encoded values run 1..6144 over all (c,t); whole-slide, so the ROI is the whole volume. +TEST(TEST_NYXUS, TEST_OMETIFF_MULTITILE_PRESCAN) { + fs::path ip = ometiff_data_path("dim5_multitile.ome.tif"); + ASSERT_TRUE(fs::exists(ip)) << ip.string(); + + Environment e; + SlideProps p (ip.string(), ""); // whole-slide: no mask + ASSERT_TRUE(Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + + EXPECT_DOUBLE_EQ(p.min_preroi_inten, 1.0); + EXPECT_DOUBLE_EQ(p.max_preroi_inten, 6144.0); + EXPECT_EQ(p.max_roi_area, (size_t)(48 * 32 * 2)); +} + +// PARTIAL edge tiles: 40x24 plane / 16 -> 3x2 tiles, last row-tile 8 tall, last col-tile 8 +// wide. Every multi-tile fixture above has plane dims that are exact multiples of the tile +// size, so the validH/validW seam clamp -- min(tileDim, fullDim-offset) -- had never run. +// Reading the exact value at every voxel checks the partial tiles are copied without garbage +// past the seam and without over-reading the tile buffer. Encoded 1..1920 over (c in 0,1). +TEST(TEST_NYXUS, TEST_OMETIFF_ODDTILE_FACADE_VOLUME) { + ASSERT_NO_THROW (test_ometiff_multitile_facade_volume("dim5_oddtile.ome.tif", 1, 2, 1, 40, 24)); +} +// The prescan (for_each_voxel) walks the same partial grid; its slide min/max and ROI area +// would be wrong if a partial tile were mis-clamped (a too-large validH double-counts voxels). +TEST(TEST_NYXUS, TEST_OMETIFF_ODDTILE_PRESCAN) { + fs::path ip = ometiff_data_path("dim5_oddtile.ome.tif"); + ASSERT_TRUE(fs::exists(ip)) << ip.string(); + + Environment e; + SlideProps p (ip.string(), ""); // whole-slide: no mask + ASSERT_TRUE(Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + + EXPECT_DOUBLE_EQ(p.min_preroi_inten, 1.0); + EXPECT_DOUBLE_EQ(p.max_preroi_inten, 1920.0); // 1 + (((0*2+1)*1+0)*40+39)*24+23 + EXPECT_EQ(p.max_roi_area, (size_t)(24 * 40 * 1)); +} + // Facade whole-volume assembly (load_volume loops Z into one X*Y*Z buffer). TEST(TEST_NYXUS, TEST_OMETIFF_FACADE_VOLUME_3D) { ASSERT_NO_THROW (test_ometiff_facade_volume("dim3_zyx.ome.tif", 1, 1, 4)); @@ -2673,6 +3047,26 @@ TEST(TEST_NYXUS, TEST_RAW_OMETIFF_PLAIN_MULTIPAGE_FALLBACK) { ASSERT_NO_THROW (test_raw_ometiff_addressing("dim3_plain.tif", 1, 1, 4)); } +// Negative: out-of-range channel/timeframe through the whole-volume facade must throw. +TEST(TEST_NYXUS, TEST_OMETIFF_LOAD_VOLUME_OUT_OF_RANGE) { + ASSERT_NO_THROW (test_ometiff_load_volume_out_of_range()); +} + +// Regression: a single-channel mask is reused across all intensity channels (not read OOB). +TEST(TEST_NYXUS, TEST_OMETIFF_MULTICHANNEL_MASK_PAIRING) { + ASSERT_NO_THROW (test_ometiff_multichannel_mask_pairing()); +} + +// Strip loaders advertise the OME C/T extents; the plain (non-OME) multi-page TIFF +// keeps C=T=1 (its pages are Z-slices, not channels/timeframes). +TEST(TEST_NYXUS, TEST_OMETIFF_CT_COUNTS) { + ASSERT_NO_THROW (test_ometiff_ct_counts("dim5.ome.tif", 2, 3, 4)); + ASSERT_NO_THROW (test_ometiff_ct_counts("dim4_tzyx.ome.tif", 2, 1, 4)); + ASSERT_NO_THROW (test_ometiff_ct_counts("dim4_czyx.ome.tif", 1, 3, 4)); + ASSERT_NO_THROW (test_ometiff_ct_counts("dim3_zyx.ome.tif", 1, 1, 4)); + ASSERT_NO_THROW (test_ometiff_ct_counts("dim3_plain.tif", 1, 1, 4)); +} + // Negative: out-of-range Z/C/T plane index must throw. TEST(TEST_NYXUS, TEST_OMETIFF_OUT_OF_RANGE_THROWS) { ASSERT_NO_THROW (test_ometiff_out_of_range_throws()); @@ -2684,6 +3078,203 @@ TEST(TEST_NYXUS, TEST_OMETIFF_MALFORMED_THROWS) { } +// Regression: the 3D prescan must scan the WHOLE volume of EVERY (channel, timeframe), +// not one Z-plane of (c0,t0). dim5.ome.tif is C=3,T=2,Z=4,Y=6,X=8 encoding values 1..1152, +// so the slide intensity range must be exactly [1, 1152]. Before the fix the prescan +// (a) did a single load_tile(0,0) and then indexed W*H*D voxels off that ONE-plane buffer, +// reading out of bounds (observed range 0..44,465 of garbage), and (b) covered only +// (c0,t0) -> a range of 1..192, which under-sized intensity-indexed buffers for c>0 and +// segfaulted. The area check guards that ROI geometry is taken from the first pass only +// (otherwise it would be multiplied by n_channels*n_timeframes). +TEST(TEST_NYXUS, TEST_3D_PRESCAN_SLIDE_RANGE) { + fs::path ip = ometiff_data_path("dim5.ome.tif"); + ASSERT_TRUE(fs::exists(ip)) << ip.string(); + + Environment e; + SlideProps p (ip.string(), ""); // whole-slide: no mask + ASSERT_TRUE(Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + + EXPECT_EQ(p.inten_channels, (size_t)3); + EXPECT_EQ(p.inten_time, (size_t)2); + EXPECT_EQ(p.volume_d, (size_t)4); + // the full encoded range across ALL (c,t) -- not garbage, and not just (c0,t0)'s 1..192 + EXPECT_DOUBLE_EQ(p.min_preroi_inten, 1.0); + EXPECT_DOUBLE_EQ(p.max_preroi_inten, 1152.0); + // geometry from the first pass only: one whole-slide ROI of exactly X*Y*Z voxels + EXPECT_EQ(p.max_roi_area, (size_t)(8 * 6 * 4)); +} + +// Regression: the 3D WHOLE-VOLUME reduce path. reduce_trivial_3d_wholevolume calls +// D3_VoxelIntensityFeatures::extract(), which used to invoke the 2-arg calculate() -- a stub +// that throws "illegal call" -- so EVERY 3D whole-volume featurization died before writing a +// row. The segmented path reduces via reduce(), which passes the Dataset, so nothing covered +// this. Mirrors featurize_wholevolume()'s vROI setup, then reduces. +TEST(TEST_NYXUS, TEST_3D_WHOLEVOLUME_REDUCE) { + fs::path ds = ometiff_data_path("dim3_zyx.ome.tif"); // 3D X8 Y6 Z4 + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + Environment e; + // enable the 3D intensity features so the reduce actually runs them + e.theFeatureSet.enableAll(false); + e.theFeatureSet.enableFeatures(D3_VoxelIntensityFeatures::featureset); + + // prescan the slide (whole-volume => no mask) + e.dataset.dataset_props.reserve(1); + SlideProps& sp = e.dataset.dataset_props.emplace_back(ds.string(), ""); + ASSERT_TRUE(Nyxus::scan_slide_props(sp, 3, e.anisoOptions, e.resultOptions.need_annotation())); + e.dataset.update_dataset_props_extrema(); + + // build the vROI exactly as featurize_wholevolume() does + FpImageOptions fp; + ImageLoader ilo; + ASSERT_TRUE(ilo.open(sp, fp)) << ds.string(); + LR vroi(1); + vroi.slide_idx = 0; + vroi.aux_area = sp.max_roi_area; + vroi.aabb.init_from_whd(sp.max_roi_w, sp.max_roi_h, sp.max_roi_d); + vroi.aux_min = (PixIntens)0; + vroi.aux_max = (PixIntens)(sp.max_preroi_inten - sp.min_preroi_inten); + ASSERT_NO_THROW(vroi.initialize_fvals()); + ASSERT_TRUE(Nyxus::scan_trivial_wholevolume(vroi, ds.string(), ilo, 0/*channel*/, 0/*timeframe*/)); + ASSERT_GT(vroi.raw_pixels_3D.size(), 0u); + vroi.aux_image_cube.allocate(vroi.aabb.get_width(), vroi.aabb.get_height(), vroi.aabb.get_z_depth()); + vroi.aux_image_cube.calculate_from_pixelcloud(vroi.raw_pixels_3D, vroi.aabb); + + // THE REGRESSION: this used to throw "illegal call of D3_VoxelIntensityFeatures::calculate" + ASSERT_NO_THROW(Nyxus::reduce_trivial_3d_wholevolume(e, vroi)); + + // and it must actually produce values (MAX >= MIN, both finite) + double vmin = vroi.get_fvals((int)Nyxus::Feature3D::MIN)[0]; + double vmax = vroi.get_fvals((int)Nyxus::Feature3D::MAX)[0]; + EXPECT_GE(vmax, vmin); + EXPECT_GT(vmax, 0.0); + ilo.close(); +} + +// Regression: separatecsv derives ONE output path per slide, but the CSV sinks are invoked +// once per (channel, timeframe) plane and used to open that path with mode "w" every time -- +// so each plane truncated the one before it and the file ended up holding only the LAST +// channel. Confirmed against the pre-fix build, where this file had 1 data row (c_index=1) +// instead of 2. The t_index/c_index columns exist precisely so the planes can coexist as rows. +TEST(TEST_NYXUS, TEST_CSV_MULTICHANNEL_NO_OVERWRITE) { + fs::path ds = ometiff_data_path("dim3_zyx.ome.tif"); // 3D X8 Y6 Z4 + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + fs::path outdir = fs::temp_directory_path() / "nyxus_csv_ct_test"; + fs::remove_all(outdir); + fs::create_directories(outdir); + + Environment e; + e.separateCsv = true; // the mode that overwrote (and the default) + e.output_dir = outdir.string(); + e.theFeatureSet.enableAll(false); + e.theFeatureSet.enableFeatures(D3_VoxelIntensityFeatures::featureset); + + e.dataset.dataset_props.reserve(1); + SlideProps& sp = e.dataset.dataset_props.emplace_back(ds.string(), ""); + ASSERT_TRUE(Nyxus::scan_slide_props(sp, 3, e.anisoOptions, e.resultOptions.need_annotation())); + e.dataset.update_dataset_props_extrema(); + + LR vroi(1); + vroi.slide_idx = 0; + vroi.aux_area = sp.max_roi_area; + vroi.aabb.init_from_whd(sp.max_roi_w, sp.max_roi_h, sp.max_roi_d); + ASSERT_NO_THROW(vroi.initialize_fvals()); + + // Two channel planes of the SAME slide, exactly as the whole-volume workflow emits them + ASSERT_TRUE(Nyxus::save_features_2_csv_wholeslide (e, vroi, ds.string(), "", outdir.string(), 0, 0)); + ASSERT_TRUE(Nyxus::save_features_2_csv_wholeslide (e, vroi, ds.string(), "", outdir.string(), 0, 1)); + + // Read the single file back + std::vector lines; + { + std::ifstream f (Nyxus::get_feature_output_fname (e, ds.string(), "")); + ASSERT_TRUE(f.good()); + std::string ln; + while (std::getline(f, ln)) + if (!ln.empty()) + lines.push_back(ln); + } + + ASSERT_EQ(lines.size(), (size_t)3) << "expected 1 header + one row per channel plane"; + EXPECT_NE(lines[0].find("\"c_index\""), std::string::npos) << "line 0 must be the header"; + // ...and the header must appear exactly once, not once per plane + EXPECT_EQ(lines[1].find("\"c_index\""), std::string::npos); + EXPECT_EQ(lines[2].find("\"c_index\""), std::string::npos); + + fs::remove_all(outdir); +} + +// Phase 6 physical-calibration logic (negative + positive). resolve_slide_anisotropy +// must NOT engage the anisotropic (resampling) path unless it's genuinely warranted: +// - flag off -> false, (1,1,1) even with anisotropic spacing +// - degenerate spacing (a 0 axis) -> false, (1,1,1) (guarded, no div-by-zero) +// - isotropic spacing (all equal) -> false, (1,1,1) (nothing to correct) +// - out-of-range slide index -> false, (1,1,1) (no OOB read) +// - real anisotropic spacing -> true, ratios normalized so min == 1 +// - explicit --aniso* -> true, the CLI values (win over physical) +TEST(TEST_NYXUS, TEST_RESOLVE_SLIDE_ANISOTROPY) { + Environment e; + e.use_physical_spacing_ = true; // opt-in on; anisoOptions stays un-customized + e.dataset.dataset_props.clear(); + SlideProps p; // ctor sets phys_x/y/z = 1.0 + e.dataset.dataset_props.push_back(p); + double ax = -1, ay = -1, az = -1; + + // degenerate: a zero-length axis must not divide-by-zero -> isotropic fallback + e.dataset.dataset_props[0].phys_x = 1.0; e.dataset.dataset_props[0].phys_y = 1.0; e.dataset.dataset_props[0].phys_z = 0.0; + EXPECT_FALSE(Nyxus::resolve_slide_anisotropy(e, 0, ax, ay, az)); + EXPECT_DOUBLE_EQ(ax, 1.0); EXPECT_DOUBLE_EQ(ay, 1.0); EXPECT_DOUBLE_EQ(az, 1.0); + + // isotropic but non-unit spacing -> normalized to (1,1,1) -> no anisotropic path + e.dataset.dataset_props[0].phys_x = 2.0; e.dataset.dataset_props[0].phys_y = 2.0; e.dataset.dataset_props[0].phys_z = 2.0; + EXPECT_FALSE(Nyxus::resolve_slide_anisotropy(e, 0, ax, ay, az)); + EXPECT_DOUBLE_EQ(az, 1.0); + + // out-of-range slide index -> safe (no dataset_props[99] read) + EXPECT_FALSE(Nyxus::resolve_slide_anisotropy(e, 99, ax, ay, az)); + EXPECT_DOUBLE_EQ(ax, 1.0); + + // genuinely anisotropic voxels (z 4x thicker) -> engage, ratio-normalized min == 1 + e.dataset.dataset_props[0].phys_x = 0.5; e.dataset.dataset_props[0].phys_y = 0.5; e.dataset.dataset_props[0].phys_z = 2.0; + EXPECT_TRUE(Nyxus::resolve_slide_anisotropy(e, 0, ax, ay, az)); + EXPECT_DOUBLE_EQ(ax, 1.0); EXPECT_DOUBLE_EQ(ay, 1.0); EXPECT_DOUBLE_EQ(az, 4.0); + + // flag OFF -> never engage, even with anisotropic spacing present + e.use_physical_spacing_ = false; + EXPECT_FALSE(Nyxus::resolve_slide_anisotropy(e, 0, ax, ay, az)); + EXPECT_DOUBLE_EQ(az, 1.0); +} + +// TEST_RESOLVE_SLIDE_ANISOTROPY covers the DECISION (physical spacing -> ratios). This covers +// that the resolved ratios actually RESCALE ROI geometry end-to-end: the 3D prescan's +// anisotropic branch (make_anisotropic_aabb 3-arg -> AABB::apply_anisotropy) was never +// exercised -- every other test uses make_nonanisotropic_aabb. A customized az=4 must scale the +// ROI's z-depth ~4x while leaving x/y (ax=ay=1) unchanged; without applying anisotropy the +// depth would be identical to the isotropic run. dim3_mask's ROI spans all Z (depth 4). +TEST(TEST_NYXUS, TEST_3D_ANISOTROPY_RESCALES_ROI_DEPTH) { + fs::path ip = ometiff_data_path("dim5.ome.tif"); + fs::path mp = ometiff_data_path("dim3_mask.ome.tif"); + ASSERT_TRUE(fs::exists(ip) && fs::exists(mp)); + Environment e; + + SlideProps iso (ip.string(), mp.string()); + AnisotropyOptions aniso_off; // un-customized -> isotropic AABB + ASSERT_FALSE(aniso_off.customized()); + ASSERT_TRUE(Nyxus::scan_slide_props(iso, 3, aniso_off, e.resultOptions.need_annotation())); + + SlideProps ani (ip.string(), mp.string()); + AnisotropyOptions aniso_z4; + aniso_z4.set_aniso_z(4.0); // z 4x thicker + ASSERT_TRUE(aniso_z4.customized()); + ASSERT_TRUE(Nyxus::scan_slide_props(ani, 3, aniso_z4, e.resultOptions.need_annotation())); + + EXPECT_GT(ani.max_roi_d, iso.max_roi_d) << "z-anisotropy did not rescale ROI depth"; + EXPECT_GE(ani.max_roi_d, iso.max_roi_d * 3) << "z-depth not scaled ~4x"; + EXPECT_EQ(ani.max_roi_w, iso.max_roi_w) << "x (ax=1) must be unchanged"; + EXPECT_EQ(ani.max_roi_h, iso.max_roi_h) << "y (ay=1) must be unchanged"; +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/tests/test_arrow_mechanics.h b/tests/test_arrow_mechanics.h index 9c5dc0f53..e5abacec5 100644 --- a/tests/test_arrow_mechanics.h +++ b/tests/test_arrow_mechanics.h @@ -64,42 +64,34 @@ std::shared_ptr create_features_table(const std::vector> fields; + // FIX (IO): 3 leading string columns (intensity, mask, phys_unit) + ROI_label int32 + float64 rest + const int num_str_cols = 3; fields.push_back(arrow::field(header[0], arrow::utf8())); fields.push_back(arrow::field(header[1], arrow::utf8())); - fields.push_back(arrow::field(header[2], arrow::int32())); + fields.push_back(arrow::field(header[2], arrow::utf8())); + fields.push_back(arrow::field(header[3], arrow::int32())); - for (int i = 3; i < header.size(); ++i) + for (int i = 4; i < header.size(); ++i) { fields.push_back(arrow::field(header[i], arrow::float64())); } auto schema = arrow::schema(fields); - arrow::StringBuilder string_builder_0; - - std::vector temp_string_vec1(string_columns.size()/2); - std::vector temp_string_vec2(string_columns.size()/2); - - for (int i = 0; i < string_columns.size(); i+=2) { - temp_string_vec1[i/2] = string_columns[i]; - temp_string_vec2[i/2] = string_columns[i+1]; - } - - PARQUET_THROW_NOT_OK(string_builder_0.AppendValues(temp_string_vec1)); - - arrow::StringBuilder string_builder_1; - - PARQUET_THROW_NOT_OK(string_builder_1.AppendValues(temp_string_vec2)); - - std::shared_ptr array_0, array_1; - - PARQUET_THROW_NOT_OK(string_builder_0.Finish(&array_0)); - PARQUET_THROW_NOT_OK(string_builder_1.Finish(&array_1)); - std::vector> arrays; - arrays.push_back(array_0); - arrays.push_back(array_1); + // de-interleave the flat string buffer (row-major, num_str_cols per row) into one array per column + for (int c = 0; c < num_str_cols; ++c) + { + std::vector col(number_of_rows); + for (int r = 0; r < number_of_rows; ++r) + col[r] = string_columns[r * num_str_cols + c]; + arrow::StringBuilder sb; + PARQUET_THROW_NOT_OK(sb.AppendValues(col)); + std::shared_ptr arr; + PARQUET_THROW_NOT_OK(sb.Finish(&arr)); + arrays.push_back(arr); + } // add labels arrow::Int32Builder labels_builder; @@ -224,8 +216,13 @@ void test_arrow() for (const auto& row : row_data) { string_columns.push_back(std::get<0>(row)[0]); string_columns.push_back(std::get<0>(row)[1]); + string_columns.push_back(std::get<0>(row)[2]); // phys_unit (FIX (IO): 3rd leading string) numeric_columns.push_back(std::get<1>(row)); // ROI label - numeric_columns.push_back(std::get<2>(row)); // time + numeric_columns.push_back(std::get(row)); // time + numeric_columns.push_back(std::get(row)); // channel (FIX (IO): c_index column) + numeric_columns.push_back(std::get(row)); // phys_x (FIX (IO)) + numeric_columns.push_back(std::get(row)); // phys_y + numeric_columns.push_back(std::get(row)); // phys_z for (const auto& data : std::get(row)) { numeric_columns.push_back(data); } @@ -295,8 +292,13 @@ void test_parquet() { for (const auto& row : row_data) { string_columns.push_back(std::get<0>(row)[0]); string_columns.push_back(std::get<0>(row)[1]); + string_columns.push_back(std::get<0>(row)[2]); // phys_unit (FIX (IO): 3rd leading string) numeric_columns.push_back(std::get<1>(row)); // ROI label - numeric_columns.push_back(std::get<2>(row)); // time + numeric_columns.push_back(std::get(row)); // time + numeric_columns.push_back(std::get(row)); // channel (FIX (IO): c_index column) + numeric_columns.push_back(std::get(row)); // phys_x (FIX (IO)) + numeric_columns.push_back(std::get(row)); // phys_y + numeric_columns.push_back(std::get(row)); // phys_z for (const auto& data : std::get(row)) { numeric_columns.push_back(data); } diff --git a/tests/test_data.h b/tests/test_data.h index cfaf35264..3954e3bed 100644 --- a/tests/test_data.h +++ b/tests/test_data.h @@ -689,7 +689,7 @@ const static NyxusPixel roiDataForPerimeterTest[] = { }; const static std::tuple, std::vector> features888{ - std::make_tuple(std::vector{"intensity_image", "mask_image", "ROI_label", "time", "COV", "COVERED_IMAGE_INTENSITY_RANGE", "ENERGY", + std::make_tuple(std::vector{"intensity_image", "mask_image", "phys_unit", "ROI_label", "time", "c_index", "phys_x", "phys_y", "phys_z", "COV", "COVERED_IMAGE_INTENSITY_RANGE", "ENERGY", "ENTROPY", "EXCESS_KURTOSIS", "HYPERFLATNESS", "HYPERSKEWNESS", "INTEGRATED_INTENSITY", "INTERQUARTILE_RANGE", "KURTOSIS", "MAX", "MEAN", "MEAN_ABSOLUTE_DEVIATION", "MEDIAN", "MEDIAN_ABSOLUTE_DEVIATION", "MIN", "MODE", "P01", "P10", "P25", "P75", "P90", "P99", "QCOD", "RANGE", "ROBUST_MEAN", "ROBUST_MEAN_ABSOLUTE_DEVIATION", "ROOT_MEAN_SQUARED", "SKEWNESS", "STANDARD_DEVIATION", @@ -697,36 +697,44 @@ const static NyxusPixel roiDataForPerimeterTest[] = { std::vector{ FTABLE_RECORD { std::make_tuple( - std::vector{"Segmentation0", "Intensity0"}, + std::vector{"Segmentation0", "Intensity0", ""}, 1, DEFAULT_T_INDEX, + DEFAULT_C_INDEX, + 1.0, 1.0, 1.0, std::vector {0.6550544230766455, 0.8333333333333334, 245.0, 1.4577174691301484, -1.4129028124799794, 2.8044001823929605, 1.0173315649829293, 59.0, 3.009722222222222, 1.5870971875200206, 6.0, 2.95, 1.7550000000000001, 4.0, 1.65, 1.0, 1.0, 1.001111111111111, 1.011111111111111, 1.0277777777777777, 4.0375, 5.966666666666667, 5.996666666666667, 0.5941870030161777, 5.0, 0.0, 1.2, 3.5, 0.20687260936809726, 1.9324105480761042, 1.8834808201837363, 0.43210013459357943, 3.7342105263157896, 3.5475000000000003, 154.0, 28.57142857142857} ) }, FTABLE_RECORD { std::make_tuple( - std::vector{"Segmentation1", "Intensity1"}, + std::vector{"Segmentation1", "Intensity1", ""}, 2, DEFAULT_T_INDEX, + DEFAULT_C_INDEX, + 1.0, 1.0, 1.0, std::vector {0.8045973903258694, 0.8333333333333334, 157.0, 1.457424881409171, -0.5612338213749162, 8.091332781325145, 3.896507376634866, 43.0, 2.9958333333333336, 2.438766178625084, 6.0, 2.263157894736842, 1.5955678670360112, 1.0, 1.263157894736842, 1.0, 1.0, 1.0007916666666667, 1.0079166666666666, 1.0197916666666667, 4.015625, 5.952500000000001, 5.99525, 0.5949524203558131, 5.0, 0.0, 1.2, 2.8745709061939735, 0.9598465178383306, 1.8209309360006518, 1.772364025313972, 0.41775020701019844, 3.3157894736842097, 3.141274238227146, 165.0, 28.57142857142857} ) }, FTABLE_RECORD { std::make_tuple( - std::vector{"Segmentation2", "Intensity2"}, + std::vector{"Segmentation2", "Intensity2", ""}, 3, DEFAULT_T_INDEX, + DEFAULT_C_INDEX, + 1.0, 1.0, 1.0, std::vector {0.9067141318473291, 0.8333333333333334, 83.0, 0.8030717273241024, 2.994153500512417, 42.98771025542074, 14.88820850639021, 29.0, 0.030000000000000027, 5.994153500512417, 6.0, 1.6111111111111112, 1.0185185185185182, 1.0, 0.6111111111111112, 1.0, 1.0, 1.0006, 1.006, 1.015, 1.045, 4.03, 5.991, 0.014563106796116521, 5.0, 0.0, 1.2, 2.1473497877875207, 2.10443379958053, 1.4608172124206968, 1.4196591487978487, 0.3443179189922347, 2.1339869281045756, 2.0154320987654324, 230.0, 28.57142857142857} ) }, FTABLE_RECORD { std::make_tuple( - std::vector{"Segmentation0", "Intensity0"}, + std::vector{"Segmentation0", "Intensity0", ""}, 4, DEFAULT_T_INDEX, + DEFAULT_C_INDEX, + 1.0, 1.0, 1.0, std::vector {.9067141318473291, 0.8333333333333334, 83.0, 0.8030717273241024, 2.994153500512417, 42.98771025542074, 14.88820850639021, 29.0, 0.030000000000000027, 5.994153500512417, 6.0, 1.6111111111111112, 1.0185185185185182, 1.0, 0.6111111111111112, 1.0, 1.0, 1.0006, 1.006, 1.015, 1.045, 4.03, 5.991, 0.014563106796116521, 5.0, 0.0, 1.2, 2.1473497877875207, 2.10443379958053, 1.4608172124206968, 1.4196591487978487, 0.3443179189922347, 2.1339869281045756, 2.0154320987654324, 230.0, 28.57142857142857} ) } diff --git a/tests/test_ome_meta.h b/tests/test_ome_meta.h index 62bf687bc..88e827ffb 100644 --- a/tests/test_ome_meta.h +++ b/tests/test_ome_meta.h @@ -58,6 +58,78 @@ TEST(OmeTiffMeta, ParsesNonDefault5D) EXPECT_EQ(a.storageIndexOf('X'), 4); } +// plane->IFD mapping. Base XML: XYZCT, Z=2 C=2 T=1 -> 4 planes, canonical +// ordinal ord = z + c*2. +static std::string tiffdata_xml(const std::string& blocks) +{ + return "" + blocks + + ""; +} + +// A single canonical block (tifffile's form) means contiguous-from-IFD-0 == the default, so +// the map is left empty and ifdForPlane stays canonical. +TEST(OmeTiffMetaTiffData, CanonicalSingleBlockStaysIdentity) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(tiffdata_xml("")); + ASSERT_TRUE(a.valid); + EXPECT_TRUE(a.planeToIfd.empty()); + EXPECT_FALSE(a.multiFileTiff); + EXPECT_EQ(a.ifdForPlane(0, 0, 0), 0u); + EXPECT_EQ(a.ifdForPlane(1, 1, 0), 3u); // ord = 1 + 1*2 +} + +// Per-plane blocks that reverse the IFDs: ifdForPlane must return the declared IFD. +TEST(OmeTiffMetaTiffData, ReversedPerPlaneMapping) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(tiffdata_xml( + "" + "" + "" + "")); + ASSERT_TRUE(a.valid); + ASSERT_EQ(a.planeToIfd.size(), 4u); + EXPECT_EQ(a.ifdForPlane(0, 0, 0), 3u); + EXPECT_EQ(a.ifdForPlane(1, 0, 0), 2u); + EXPECT_EQ(a.ifdForPlane(0, 1, 0), 1u); + EXPECT_EQ(a.ifdForPlane(1, 1, 0), 0u); +} + +// P5: PlaneCount omitted -> defaults to "the remaining planes from the start plane" (OME +// spec). A single block with only IFD given must map every plane from that offset. +TEST(OmeTiffMetaTiffData, OmittedPlaneCountCoversRemaining) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(tiffdata_xml("")); + ASSERT_TRUE(a.valid); + ASSERT_EQ(a.planeToIfd.size(), 4u); // 4 planes (Z=2,C=2) + EXPECT_EQ(a.ifdForPlane(0, 0, 0), 5u); // start at IFD 5 + EXPECT_EQ(a.ifdForPlane(1, 0, 0), 6u); + EXPECT_EQ(a.ifdForPlane(0, 1, 0), 7u); + EXPECT_EQ(a.ifdForPlane(1, 1, 0), 8u); // all four planes mapped, consecutively +} + +// A non-zero starting IFD (planes contiguous but offset, e.g. a multi-image container). +TEST(OmeTiffMetaTiffData, NonZeroStartOffsetsAllPlanes) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(tiffdata_xml("")); + ASSERT_TRUE(a.valid); + ASSERT_EQ(a.planeToIfd.size(), 4u); + EXPECT_EQ(a.ifdForPlane(0, 0, 0), 10u); + EXPECT_EQ(a.ifdForPlane(1, 1, 0), 13u); +} + +// A naming another file ( child) is multi-file: unsupported, flagged, and +// that plane is left at the canonical fallback rather than pointed at a wrong local IFD. +TEST(OmeTiffMetaTiffData, MultiFileUuidBlockFlaggedAndSkipped) +{ + Nyxus::OmeAxes a = Nyxus::parse_ome_xml(tiffdata_xml( + "" + "urn:uuid:abc")); + ASSERT_TRUE(a.valid); + EXPECT_TRUE(a.multiFileTiff); + EXPECT_EQ(a.ifdForPlane(0, 0, 0), 0u); // canonical fallback (not mapped) +} + TEST(OmeTiffMeta, Parses2DSingletonAxesCollapse) { Nyxus::OmeAxes a = Nyxus::parse_ome_xml(kOmeXml_2d); diff --git a/tests/test_ometiff_mechanics.h b/tests/test_ometiff_mechanics.h index 88da399a6..ba75844a4 100644 --- a/tests/test_ometiff_mechanics.h +++ b/tests/test_ometiff_mechanics.h @@ -103,7 +103,7 @@ void test_ometiff_wholevolume_consumer(const char* store, int Z) ASSERT_TRUE(ilo.open(p, fp)) << ds.string(); LR vroi; - ASSERT_TRUE(Nyxus::scan_trivial_wholevolume(vroi, ds.string(), ilo)); + ASSERT_TRUE(Nyxus::scan_trivial_wholevolume(vroi, ds.string(), ilo, 0/*channel*/, 0/*timeframe*/)); // every voxel of the X*Y*Z volume must be captured (not just plane z=0 -> 48) ASSERT_EQ(vroi.raw_pixels_3D.size(), (size_t)X * Y * Z); @@ -113,6 +113,35 @@ void test_ometiff_wholevolume_consumer(const char* store, int Z) ilo.close(); } +// End-to-end through the wired volumetric consumer for EVERY (channel,timeframe): +// scan_trivial_wholevolume(vroi, .., c, t) must fill the voxel cloud with THAT c/t +// plane's encoded intensity (before the wiring it always read c=0,t=0). +void test_ometiff_wholevolume_consumer_ct(const char* store, int T, int C, int Z) +{ + const int Y = 6, X = 8; + fs::path ds = ometiff_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; + FpImageOptions fp; + ImageLoader ilo; + ASSERT_TRUE(ilo.open(p, fp)) << ds.string(); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + { + LR vroi; + ASSERT_TRUE(Nyxus::scan_trivial_wholevolume(vroi, ds.string(), ilo, c, t)) << store; + ASSERT_EQ(vroi.raw_pixels_3D.size(), (size_t)X * Y * Z) << store; + for (const Pixel3& px : vroi.raw_pixels_3D) + ASSERT_EQ((uint32_t)px.inten, ometiff_enc((int)px.x, (int)px.y, (int)px.z, c, t)) + << store << " (c" << c << " t" << t << ") voxel (" << px.x << "," << px.y << "," << px.z << ")"; + } + ilo.close(); +} + // Whole-volume assembly through the ImageLoader facade: load_volume() stacks all // Z-planes (per (channel,timeframe)) into one X*Y*Z buffer -- the foundation that // lets the volumetric pipeline consume a multi-page OME-TIFF. @@ -147,6 +176,49 @@ void test_ometiff_facade_volume(const char* store, int T, int C, int Z) il.close(); } +// The multitile fixtures carry their own (larger Y/X) shape because a TIFF tile size must be +// a multiple of 16: only a plane bigger than 16x16 yields a real tile grid. Their coordinate +// encoding is encoded_tczyx with each fixture's own C/Z/Y/X (see gen_ome_tiff.write_multitile). +inline uint32_t ometiff_enc_dims(int x, int y, int z, int c, int t, int C, int Z, int Y, int X) +{ + return (uint32_t)(1 + ((((t * C + c) * Z + z) * Y + y) * X + x)); +} + +// Multi-TILE plane: each plane spans a grid of 16x16 tiles, unlike every other fixture (one +// tile/chunk per plane), so assembling a volume from tile (0,0) alone silently returns wrong +// data outside the first 16x16 corner -- and over-reads that tile's buffer. With Y or X not a +// multiple of 16 the last tile of the row/column is PARTIAL, exercising the validH/validW seam +// clamp. Asserting the exact value at every voxel IS the seam check. +void test_ometiff_multitile_facade_volume(const char* store, int T, int C, int Z, int Y, int X) +{ + fs::path ds = ometiff_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; + FpImageOptions fp; + ImageLoader il; + ASSERT_TRUE(il.open(p, fp)) << ds.string(); + ASSERT_EQ(il.get_full_width(), (size_t)X); + ASSERT_EQ(il.get_full_height(), (size_t)Y); + ASSERT_EQ(il.get_full_depth(), (size_t)Z); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + { + ASSERT_TRUE(il.load_volume(c, t)); + const std::vector& vol = il.get_int_volume_buffer(); + ASSERT_EQ(vol.size(), (size_t)X * Y * Z); + for (int z = 0; z < Z; ++z) + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(vol[(size_t)z * X * Y + (size_t)y * X + x], ometiff_enc_dims(x, y, z, c, t, C, Z, Y, X)) + << store << " vol (x" << x << " y" << y << " z" << z << " c" << c << " t" << t << ")"; + } + il.close(); +} + // Every one of the 6 legal OME DimensionOrder values (all orderings of {T,C,Z} // before Y,X) must read correctly -- proves ifdForPlane honors DimensionOrder. void test_ometiff_all_5d_permutations() @@ -160,6 +232,134 @@ void test_ometiff_all_5d_permutations() } } +// TILED multi-plane OME-TIFF: the TILE loaders (not just the strip loaders) must map +// (z,c,t) -> IFD. dim5_tiled.ome.tif is 5D TCZYX with one 16x16 tile per 6x8 plane. +void test_ometiff_tiled_addressing() +{ + const int T = 2, C = 3, Z = 4, Y = 6, X = 8; + fs::path ds = ometiff_data_path("dim5_tiled.ome.tif"); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + // AbstractTileLoader stack + auto ldr = NyxusGrayscaleTiffTileLoader(1, ds.string(), true, 0.0f, 1.0f, 1e4f, false); + ASSERT_EQ(ldr.fullWidth(0), (size_t)X); + ASSERT_EQ(ldr.fullHeight(0), (size_t)Y); + ASSERT_EQ(ldr.fullDepth(0), (size_t)Z); // SizeZ, not the 24 IFDs + ASSERT_EQ(ldr.numberChannels(), (size_t)C); + ASSERT_EQ(ldr.fullTimestamps(0), (size_t)T); + const size_t tw = ldr.tileWidth(0); + auto tile = std::make_shared>(ldr.tileHeight(0) * tw, 0u); + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + for (int z = 0; z < Z; ++z) + { + std::fill(tile->begin(), tile->end(), 0u); + ASSERT_NO_THROW(ldr.loadTileFromFile(tile, 0, 0, z, c, t, 0)); + const std::vector& buf = *tile; + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(buf[y * tw + x], ometiff_enc(x, y, z, c, t)) + << "tile plane (z" << z << " c" << c << " t" << t << ") at (" << x << "," << y << ")"; + } + EXPECT_ANY_THROW(ldr.loadTileFromFile(tile, 0, 0, 0, 99, 0, 0)); // channel out of range + EXPECT_ANY_THROW(ldr.loadTileFromFile(tile, 0, 0, 99, 0, 0, 0)); // z out of range + + // RawFormatLoader stack + auto raw = RawTiffTileLoader(ds.string()); + ASSERT_EQ(raw.fullDepth(0), (size_t)Z); + ASSERT_EQ(raw.numberChannels(), (size_t)C); + ASSERT_EQ(raw.fullTimestamps(0), (size_t)T); + const size_t rw = raw.tileWidth(0); + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + for (int z = 0; z < Z; ++z) + { + ASSERT_NO_THROW(raw.loadTileFromFile(0, 0, z, c, t, 0)); + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(raw.get_uint32_pixel(y * rw + x), ometiff_enc(x, y, z, c, t)) + << "raw tile plane (z" << z << " c" << c << " t" << t << ") at (" << x << "," << y << ")"; + raw.free_tile(); + } + EXPECT_ANY_THROW(raw.loadTileFromFile(0, 0, 0, 0, 99, 0)); // timeframe out of range + EXPECT_ANY_THROW(raw.loadTileFromFile(0, 0, 99, 0, 0, 0)); // z out of range +} + +// Regression: a single-channel mask paired with a multi-channel intensity. The mask is +// channel-agnostic and must be REUSED for every intensity channel (not indexed at the +// intensity's channel, which would read it out of range and drop the ROI for c>0). +// End-to-end this bug produced only the c=0 rows; here we assert at the facade. +void test_ometiff_multichannel_mask_pairing() +{ + fs::path ipath = ometiff_data_path("dim5.ome.tif"); // intensity C=3, T=2, Z=4 + fs::path mpath = ometiff_data_path("dim3_mask.ome.tif"); // single-channel ZYX label mask + ASSERT_TRUE(fs::exists(ipath)) << ipath.string(); + ASSERT_TRUE(fs::exists(mpath)) << mpath.string(); + + SlideProps p; + p.fname_int = ipath.string(); + p.fname_seg = mpath.string(); + FpImageOptions fp; + ImageLoader il; + ASSERT_TRUE(il.open(p, fp)) << ipath.string(); + + ASSERT_TRUE(il.load_volume(0, 0)); + const std::vector seg0 = il.get_seg_volume_buffer(); + const std::vector int0 = il.get_int_volume_buffer(); + size_t nz = 0; for (auto v : seg0) if (v) ++nz; + ASSERT_GT(nz, 0u) << "mask has no ROI voxels"; + + for (int c = 0; c < 3; ++c) + { + ASSERT_TRUE(il.load_volume(c, 0)) << "channel " << c; // must not throw/fail for c>0 + ASSERT_EQ(il.get_seg_volume_buffer(), seg0) << "mask changed for channel " << c; // reused identically + if (c > 0) + ASSERT_NE(il.get_int_volume_buffer(), int0) << "intensity channel " << c << " read c=0 data"; + } + il.close(); +} + +// Phase 5 negative: the whole-volume facade read must propagate an out-of-range channel +// or timeframe as a throw (the (z,c,t)->IFD map range-guards). dim5 has C=3, T=2. +void test_ometiff_load_volume_out_of_range() +{ + fs::path ds = ometiff_data_path("dim5.ome.tif"); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; + FpImageOptions fp; + ImageLoader il; + ASSERT_TRUE(il.open(p, fp)) << ds.string(); + + EXPECT_ANY_THROW(il.load_volume(99, 0)); // channel out of range (C=3) + EXPECT_ANY_THROW(il.load_volume(0, 99)); // timeframe out of range (T=2) + // in-range still works + EXPECT_TRUE(il.load_volume(2, 1)); + il.close(); +} + +// The strip loaders must ADVERTISE the OME C/T extents via numberChannels() / +// fullTimestamps() -- what the volumetric pipeline keys off to iterate channels +// and timeframes. A plain (non-OME) multi-page TIFF has no OME-XML, so it must keep +// the single-plane default of 1 for both (its pages are all Z-slices). +void test_ometiff_ct_counts(const char* store, int T, int C, int Z) +{ + fs::path ds = ometiff_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + auto ldr = NyxusGrayscaleTiffStripLoader(1, ds.string()); + ASSERT_EQ(ldr.numberChannels(), (size_t)C) << store; + ASSERT_EQ(ldr.fullTimestamps(0), (size_t)T) << store; + ASSERT_EQ(ldr.fullDepth(0), (size_t)Z) << store; + + auto raw = RawTiffStripLoader(1, ds.string()); + ASSERT_EQ(raw.numberChannels(), (size_t)C) << store; + ASSERT_EQ(raw.fullTimestamps(0), (size_t)T) << store; + ASSERT_EQ(raw.fullDepth(0), (size_t)Z) << store; +} + // Negative: an out-of-range Z/C/T plane maps to a non-existent IFD and must throw. // dim5.ome.tif has T=2, C=3, Z=4 (24 IFDs). void test_ometiff_out_of_range_throws() diff --git a/tests/test_omezarr_mechanics.h b/tests/test_omezarr_mechanics.h index b20f1ee69..6510584aa 100644 --- a/tests/test_omezarr_mechanics.h +++ b/tests/test_omezarr_mechanics.h @@ -314,7 +314,7 @@ void test_omezarr_wholevolume_consumer(const char* store, int Z) ASSERT_TRUE(ilo.open(p, fp)) << ds.string(); LR vroi; - ASSERT_TRUE(Nyxus::scan_trivial_wholevolume(vroi, ds.string(), ilo)); + ASSERT_TRUE(Nyxus::scan_trivial_wholevolume(vroi, ds.string(), ilo, 0/*channel*/, 0/*timeframe*/)); ASSERT_EQ(vroi.raw_pixels_3D.size(), (size_t)X * Y * Z); // all voxels, not just plane 0 (48) for (const Pixel3& px : vroi.raw_pixels_3D) @@ -323,6 +323,36 @@ void test_omezarr_wholevolume_consumer(const char* store, int Z) ilo.close(); } +// End-to-end through the wired volumetric consumer, but for EVERY (channel,timeframe): +// scan_trivial_wholevolume(vroi, .., c, t) must fill the ROI's voxel cloud with the +// encoded intensity of THAT c/t plane. Before the channel/timeframe wiring the consumer +// always read (c=0,t=0), so any c>0 / t>0 plane would carry the wrong values. +void test_omezarr_wholevolume_consumer_ct(const char* store, int T, int C, int Z) +{ + const int Y = 6, X = 8; + fs::path ds = omezarr_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; + FpImageOptions fp; + ImageLoader ilo; + ASSERT_TRUE(ilo.open(p, fp)) << ds.string(); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + { + LR vroi; + ASSERT_TRUE(Nyxus::scan_trivial_wholevolume(vroi, ds.string(), ilo, c, t)) << store; + ASSERT_EQ(vroi.raw_pixels_3D.size(), (size_t)X * Y * Z) << store; + for (const Pixel3& px : vroi.raw_pixels_3D) + ASSERT_EQ((uint32_t)px.inten, dim5_enc((int)px.x, (int)px.y, (int)px.z, c, t)) + << store << " (c" << c << " t" << t << ") voxel (" << px.x << "," << px.y << "," << px.z << ")"; + } + ilo.close(); +} + // Whole-volume assembly through the ImageLoader facade: load_volume() must stack // all Z-planes (per (channel,timeframe)) into one X*Y*Z buffer. This is the // foundation that lets the volumetric pipeline consume plane-by-plane OME-Zarr. @@ -389,6 +419,76 @@ void test_omezarr_out_of_range_throws() EXPECT_ANY_THROW(raw.loadTileFromFile(0, 0, 99, 0, 0, 0)); } +// The loaders must ADVERTISE the real C/T extents via numberChannels() / +// fullTimestamps(). This is what the volumetric pipeline keys off to iterate +// channels and timeframes; before this both fell back to the base-class default +// of 1, which pinned the pipeline to plane (c=0, t=0) regardless of the store. +// Covers the parsed-axes path and (via dim5_noaxes) the positional fallback. +void test_omezarr_ct_counts(const char* store, int T, int C, int Z) +{ + fs::path ds = omezarr_data_path(store); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + auto ldr = NyxusOmeZarrLoader(1, ds.string()); + ASSERT_EQ(ldr.numberChannels(), (size_t)C) << store; + ASSERT_EQ(ldr.fullTimestamps(0), (size_t)T) << store; + ASSERT_EQ(ldr.fullDepth(0), (size_t)Z) << store; + + auto raw = RawOmezarrLoader(ds.string()); + ASSERT_EQ(raw.numberChannels(), (size_t)C) << store; + ASSERT_EQ(raw.fullTimestamps(0), (size_t)T) << store; + ASSERT_EQ(raw.fullDepth(0), (size_t)Z) << store; +} + +// Phase 5 negative: the whole-volume facade read must propagate an out-of-range channel +// or timeframe as a throw (not silently read plane 0 or OOB memory). dim5 has C=3, T=2. +void test_omezarr_load_volume_out_of_range() +{ + fs::path ds = omezarr_data_path("dim5.ome.zarr"); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; + FpImageOptions fp; + ImageLoader il; + ASSERT_TRUE(il.open(p, fp)) << ds.string(); + + EXPECT_ANY_THROW(il.load_volume(99, 0)); // channel out of range (C=3) + EXPECT_ANY_THROW(il.load_volume(0, 99)); // timeframe out of range (T=2) + // in-range still works + EXPECT_TRUE(il.load_volume(2, 1)); + il.close(); +} + +// Physical calibration: the loaders must surface the OME-Zarr coordinateTransformations +// 'scale' as physicalSizeX/Y/Z and the space-axis 'unit'. dim5_calibrated encodes +// scale (t,c,z,y,x)=(1,1,2,0.5,0.5) micrometer -> physX=physY=0.5, physZ=2.0. An +// uncalibrated store (scale all 1) must report 1.0 and no unit. +void test_omezarr_physical_calibration() +{ + fs::path cal = omezarr_data_path("dim5_calibrated.ome.zarr"); + ASSERT_TRUE(fs::exists(cal)) << cal.string(); + + auto ldr = NyxusOmeZarrLoader(1, cal.string()); + ASSERT_DOUBLE_EQ(ldr.physicalSizeX(), 0.5); + ASSERT_DOUBLE_EQ(ldr.physicalSizeY(), 0.5); + ASSERT_DOUBLE_EQ(ldr.physicalSizeZ(), 2.0); + ASSERT_EQ(ldr.physicalSizeUnit(), "micrometer"); + + auto raw = RawOmezarrLoader(cal.string()); + ASSERT_DOUBLE_EQ(raw.physicalSizeX(), 0.5); + ASSERT_DOUBLE_EQ(raw.physicalSizeY(), 0.5); + ASSERT_DOUBLE_EQ(raw.physicalSizeZ(), 2.0); + ASSERT_EQ(raw.physicalSizeUnit(), "micrometer"); + + // uncalibrated store: scale all 1.0 -> physical sizes default to 1.0 + fs::path plain = omezarr_data_path("dim5.ome.zarr"); + auto ldr2 = NyxusOmeZarrLoader(1, plain.string()); + ASSERT_DOUBLE_EQ(ldr2.physicalSizeX(), 1.0); + ASSERT_DOUBLE_EQ(ldr2.physicalSizeZ(), 1.0); +} + // Illegal / adversarial: self-inconsistent metadata must be rejected cleanly // (throw), not crash. bad_axes_count declares 5 axes for a 3D array (indexing the // shape by axis role would read OOB); bad_no_xy has axes but none labeled x/y. From d4f153b3970308d03302791a6f3ee96ad71a4ad2 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Mon, 20 Jul 2026 17:15:42 -0700 Subject: [PATCH 4/5] feat(io): stream oversized 3D volumetric ROIs out-of-core across all feature families Step 3 of the IO project: the out-of-core streaming capability. Volumetric ROIs too large to hold in RAM are now streamed rather than rejected, across every 3D feature family -- whole-volume assembly, intensity/histogram, surface, GLCM/GLDM/NGLDM/NGTDM, GLRLM, and GLSZM/GLDZM (via streaming connected-component labeling) -- plus a fix to the 2D out-of-core intensity dispatch that predates the 3D work. Includes the negative-path test coverage that caught a degenerate-ROI bug, Zarr v3 sharding validated at multi-shard-file scale, and a chunk-depth read fix. Absorbed commits (oldest to newest): 80614c4 fix(3d): oversized whole volume fails loudly instead of emitting zeros 4222cdc fix(3d): whole-volume oversized check must use the 3D footprint estimator 6bef8bd fix(io): correct 2D out-of-core intensity features (dispatch + 3 latent bugs) 696061f feat(io): stream 3D intensity/histogram out-of-core for oversized volumetric ROIs 025a658 feat(io): stream 3D surface features out-of-core for oversized volumetric ROIs 8c848aa feat(io): stream 3D GLCM/GLDM/NGLDM/NGTDM out-of-core for oversized volumetric ROIs d003cc0 feat(io): stream 3D GLRLM out-of-core; fix latent grey-LUT gaps in GLCM/GLDM 67adfff feat(io): stream 3D GLSZM/GLDZM out-of-core via streaming connected-component labeling d469e0e test(io): add negative-path tests for the 3D out-of-core stream; fix a degenerate-ROI bug they caught a141d98 feat(io): stream oversized 3D whole-volume ROIs out-of-core 12adfe5 docs(vetting): register test_ooc_mechanics.py on the 3D features it covers 0fbe34b test(omezarr): validate Zarr v3 sharding at multi-shard-FILE scale 6da2482 fix(omezarr): read the full chunk depth instead of one Z-plane --- CMakeLists.txt | 2 + src/nyx/feature_method.cpp | 7 + src/nyx/feature_method.h | 6 + src/nyx/features/3d_glcm.cpp | 7 + src/nyx/features/3d_glcm.h | 2 + src/nyx/features/3d_glcm_nontriv.cpp | 213 ++++++++- src/nyx/features/3d_gldm.cpp | 143 +++++- src/nyx/features/3d_gldzm.cpp | 187 +++++++- src/nyx/features/3d_glrlm_nontriv.cpp | 253 ++++++++++- src/nyx/features/3d_glszm.cpp | 192 +++++++- src/nyx/features/3d_intensity.cpp | 62 ++- src/nyx/features/3d_intensity.h | 3 + src/nyx/features/3d_ngldm.cpp | 101 ++++- src/nyx/features/3d_ngtdm.cpp | 225 +++++---- src/nyx/features/3d_surface.cpp | 190 +++++++- src/nyx/features/3d_surface.h | 20 +- src/nyx/features/histogram.h | 102 ++++- src/nyx/features/intensity.cpp | 32 +- src/nyx/features/intensity.h | 4 + src/nyx/features/intensity_histogram.h | 4 + src/nyx/features/streaming_ccl.h | 67 +++ src/nyx/features/voxel_cloud_nontriv.cpp | 180 ++++++++ src/nyx/features/voxel_cloud_nontriv.h | 80 ++++ src/nyx/globals.h | 15 + src/nyx/image_loader.cpp | 72 +++ src/nyx/image_loader.h | 16 + src/nyx/omezarr.h | 32 +- src/nyx/phase3.cpp | 4 +- src/nyx/phase3_3d.cpp | 187 ++++++++ src/nyx/raw_omezarr.h | 37 +- src/nyx/roi_cache.h | 4 + src/nyx/workflow_3d_segmented.cpp | 4 +- src/nyx/workflow_3d_whole.cpp | 92 +++- src/nyx/workflow_pythonapi.cpp | 5 +- .../dim5_v3_multishard.ome.zarr/0/c/0/0/0/0/0 | Bin 0 -> 3588 bytes .../dim5_v3_multishard.ome.zarr/0/c/0/0/0/0/1 | Bin 0 -> 3588 bytes .../dim5_v3_multishard.ome.zarr/0/c/0/0/0/1/0 | Bin 0 -> 3588 bytes .../dim5_v3_multishard.ome.zarr/0/c/0/0/0/1/1 | Bin 0 -> 3588 bytes .../dim5_v3_multishard.ome.zarr/0/c/0/1/0/0/0 | Bin 0 -> 3588 bytes .../dim5_v3_multishard.ome.zarr/0/c/0/1/0/0/1 | Bin 0 -> 3588 bytes .../dim5_v3_multishard.ome.zarr/0/c/0/1/0/1/0 | Bin 0 -> 3588 bytes .../dim5_v3_multishard.ome.zarr/0/c/0/1/0/1/1 | Bin 0 -> 3588 bytes .../dim5_v3_multishard.ome.zarr/0/zarr.json | 67 +++ .../dim5_v3_multishard.ome.zarr/zarr.json | 52 +++ .../dim5_v3_zchunked.ome.zarr/0/c/0/0/0/0/0 | Bin 0 -> 288 bytes .../dim5_v3_zchunked.ome.zarr/0/c/0/0/1/0/0 | Bin 0 -> 288 bytes .../dim5_v3_zchunked.ome.zarr/0/c/0/0/2/0/0 | Bin 0 -> 288 bytes .../dim5_v3_zchunked.ome.zarr/0/c/0/1/0/0/0 | 1 + .../dim5_v3_zchunked.ome.zarr/0/c/0/1/1/0/0 | Bin 0 -> 288 bytes .../dim5_v3_zchunked.ome.zarr/0/c/0/1/2/0/0 | Bin 0 -> 288 bytes .../dim5_v3_zchunked.ome.zarr/0/zarr.json | 41 ++ .../dim5_v3_zchunked.ome.zarr/zarr.json | 52 +++ tests/data/omezarr/gen_dim5.py | 82 ++++ tests/python/test_ooc_mechanics.py | 316 +++++++++++++ tests/test_all.cc | 289 ++++++++++++ tests/vetting/oracle_coverage.csv | 426 +++++++++--------- 56 files changed, 3428 insertions(+), 448 deletions(-) create mode 100644 src/nyx/features/streaming_ccl.h create mode 100644 src/nyx/features/voxel_cloud_nontriv.cpp create mode 100644 src/nyx/features/voxel_cloud_nontriv.h create mode 100644 src/nyx/phase3_3d.cpp create mode 100644 tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/0/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/0/0/0/1 create mode 100644 tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/0/0/1/0 create mode 100644 tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/0/0/1/1 create mode 100644 tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/1/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/1/0/0/1 create mode 100644 tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/1/0/1/0 create mode 100644 tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/1/0/1/1 create mode 100644 tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_multishard.ome.zarr/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/0/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/0/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/0/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/1/0/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/1/1/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/1/2/0/0 create mode 100644 tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/zarr.json create mode 100644 tests/data/omezarr/dim5_v3_zchunked.ome.zarr/zarr.json create mode 100644 tests/python/test_ooc_mechanics.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 05cdd1182..6249c9ad2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,6 +158,7 @@ set(SOURCE src/nyx/features/hexagonality_polygonality.cpp src/nyx/features/image_matrix.cpp src/nyx/features/image_matrix_nontriv.cpp + src/nyx/features/voxel_cloud_nontriv.cpp src/nyx/features/2d_geomoments.cpp src/nyx/features/2d_geomoments_basic.cpp src/nyx/features/2d_geomoments_basic_nt.cpp @@ -227,6 +228,7 @@ set(SOURCE src/nyx/phase2_25d.cpp src/nyx/phase2_3d.cpp src/nyx/phase3.cpp + src/nyx/phase3_3d.cpp src/nyx/pixel_feed.cpp src/nyx/raw_image_loader.cpp src/nyx/reduce_trivial_rois.cpp diff --git a/src/nyx/feature_method.cpp b/src/nyx/feature_method.cpp index c5740309f..2d53fe213 100644 --- a/src/nyx/feature_method.cpp +++ b/src/nyx/feature_method.cpp @@ -49,6 +49,13 @@ void FeatureMethod::osized_scan_whole_image (LR& r, const Fsettings& s, ImageLoa this->save_value (r.fvals); } +// The Dataset-aware overload defaults to the Dataset-less path, so features that don't need +// slide props are unaffected; only features that override it consume the Dataset. +void FeatureMethod::osized_scan_whole_image (LR& r, const Fsettings& s, const Dataset& ds, ImageLoader& ldr) +{ + this->osized_scan_whole_image (r, s, ldr); +} + bool FeatureMethod::provides (int fcode) const { return std::find(provided_features.begin(), provided_features.end(), (int)fcode) != provided_features.end(); diff --git a/src/nyx/feature_method.h b/src/nyx/feature_method.h index 16aac5b9b..d3c7df9bd 100644 --- a/src/nyx/feature_method.h +++ b/src/nyx/feature_method.h @@ -5,6 +5,8 @@ #include "image_loader.h" #include "features/image_matrix_nontriv.h" +class Dataset; // forward decl for the Dataset-aware osized_scan_whole_image overload + /// @brief Abstract class encapsulating basic feature functionality e.g. dependency on other features, dependency on helper objects, state (calculated or pending), etc. class FeatureMethod { @@ -26,6 +28,10 @@ class FeatureMethod //=== Oversized ROI virtual void osized_scan_whole_image (LR& roi, const Fsettings& s, ImageLoader& ldr); + // Dataset-aware out-of-core dispatch. Features that need slide props (intensity, + // intensity-histogram) override this to reach their Dataset-taking osized_calculate; the + // default forwards to the Dataset-less overload so every other feature is unaffected. + virtual void osized_scan_whole_image (LR& roi, const Fsettings& s, const Dataset& ds, ImageLoader& ldr); virtual void osized_add_online_pixel (size_t x, size_t y, uint32_t intensity) = 0; // Called each time the ROI pixel is being scanned in the raster order virtual void osized_reduce() final {}; // Get rid of this method in all derived diff --git a/src/nyx/features/3d_glcm.cpp b/src/nyx/features/3d_glcm.cpp index 08ebb27fa..b8f8912b0 100644 --- a/src/nyx/features/3d_glcm.cpp +++ b/src/nyx/features/3d_glcm.cpp @@ -179,7 +179,14 @@ void D3_GLCM_feature::save_value(std::vector>& fvals) void D3_GLCM_feature::extract_texture_features_at_angle(int dx, int dy, int dz, const SimpleCube& binned_greys, PixIntens min_val, PixIntens max_val, int n_greys, bool ibsi, double soft_nan) { calculateCoocMatAtAngle (P_matrix, dx, dy, dz, binned_greys, min_val, max_val, n_greys, ibsi); + finalize_angle (soft_nan); +} +// Computes this angle's GLCM feature values from the already-built P_matrix + sum_p + I. Split out +// of extract_texture_features_at_angle so the out-of-core path (which builds P_matrix by streaming a +// 2-plane window instead of scanning the in-RAM cube) reuses the exact same feature math. +void D3_GLCM_feature::finalize_angle (double soft_nan) +{ // Blank cooc-matrix? -- no point to use it, assign each feature value '0' and return. if (sum_p == 0) { diff --git a/src/nyx/features/3d_glcm.h b/src/nyx/features/3d_glcm.h index 82c7290ea..980b938d3 100644 --- a/src/nyx/features/3d_glcm.h +++ b/src/nyx/features/3d_glcm.h @@ -145,6 +145,8 @@ class D3_GLCM_feature : public FeatureMethod, public TextureFeature bool normalize); void extract_texture_features_at_angle (int dx, int dy, int dz, const SimpleCube & grays, PixIntens min_val, PixIntens max_val, int n_greys, bool ibsi, double soft_nan); + // Compute this angle's features from the already-built P_matrix (shared by in-core + out-of-core) + void finalize_angle (double soft_nan); void calculateCoocMatAtAngle( // out diff --git a/src/nyx/features/3d_glcm_nontriv.cpp b/src/nyx/features/3d_glcm_nontriv.cpp index a0fb45fbf..ded9857a2 100644 --- a/src/nyx/features/3d_glcm_nontriv.cpp +++ b/src/nyx/features/3d_glcm_nontriv.cpp @@ -1,11 +1,202 @@ -#include "../environment.h" -#include "3d_glcm.h" -#include "image_matrix_nontriv.h" - -using namespace Nyxus; - -void D3_GLCM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) -{ - calculate (r, s); -} - +#include +#include +#include +#include "../environment.h" +#include "3d_glcm.h" +#include "image_matrix_nontriv.h" + +using namespace Nyxus; + +// The 13 direction shifts, identical to the in-core path (3d_glcm.cpp) +namespace { + struct Sh { int dx, dy, dz; }; + const Sh SHIFTS[13] = { + {1, 1, 1}, {1, 1, 0}, {1, 1, -1}, {1, 0, 1}, {1, 0, 0}, {1, 0, -1}, + {1, -1, 1}, {1, -1, 0}, {1, -1, -1}, {0, 1, 1}, {0, 1, 0}, {0, 1, -1}, {0, 0, 1} + }; +} + +// Out-of-core 3D GLCM. Instead of scanning the in-RAM grey-binned cube, this builds the 13 +// co-occurrence matrices by streaming the disk-backed voxel cloud one Z-plane at a time through a +// sliding window of (offset+1) dense grey-binned planes (co-occurrence counts are additive and the +// 13 directions only reach +/- offset in Z). The per-direction feature math is then the shared +// finalize_angle(), so the values are identical to calculate(). +void D3_GLCM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) +{ + clear_result_buffers(); + + const size_t nvox = r.raw_voxels_NT.size(); + + // grey-binning mode, mirroring calculate(): IBSI forces the "no binning" (==0) mode + int greyInfo = STNGS_IBSI(s) ? 0 : STNGS_GLCM_GREYDEPTH(s); + bool ibsi = STNGS_IBSI(s); + double soft_nan = STNGS_NAN(s); + PixIntens mn = r.aux_min, mx = r.aux_max; + + const int W = (int) r.aabb.get_width(), + H = (int) r.aabb.get_height(), + Dz = (int) r.aabb.get_z_depth(); + const StatsInt xmin = r.aabb.get_xmin(), + ymin = r.aabb.get_ymin(), + zmin = r.aabb.get_zmin(); + + // Background cells of the in-core cube are grey-binned too (0 -> 1 for matlab, 0 otherwise); + // reproduce that so a dense plane matches a Z-slice of the binned cube exactly. + const PixIntens bg = TextureFeature::bin_pixel (0, mn, mx, greyInfo); + + // --- pass 1: the global set of grey levels (I) + matrix dimension, as in calculateCoocMatAtAngle, + // which builds I from the unique values of the WHOLE binned cube (mask + background) for the + // radiomics branch -- so a background bin (e.g. matlab binning maps raw-0 to bin 1) must be + // included too when the ROI bbox actually has background voxels. + const bool hasBackground = nvox < (size_t) r.aabb.get_width() * r.aabb.get_height() * r.aabb.get_z_depth(); + std::set uniq; + PixIntens maxbin = 0; + if (hasBackground && bg != 0) + { + uniq.insert (bg); + maxbin = bg; + } + for (size_t i = 0; i < nvox; i++) + { + Pixel3 v = r.raw_voxels_NT[i]; + PixIntens b = TextureFeature::bin_pixel (v.inten, mn, mx, greyInfo); + if (b > maxbin) maxbin = b; + if (b != 0) uniq.insert (b); + } + + I.clear(); + if (radiomics_grey_binning(greyInfo)) + I.assign (uniq.begin(), uniq.end()); // std::set is already sorted ascending + else if (matlab_grey_binning(greyInfo)) + { + int n = greyInfo; + I.resize (n); + for (int i = 0; i < n; i++) I[i] = i + 1; + } + else + { + int n = (int) maxbin; // IBSI: levels 1..max + I.resize (n); + for (int i = 0; i < n; i++) I[i] = i + 1; + } + const int Ng = (int) I.size(); + + // Nothing to featurize (empty/blank): emit 13 blank angles, matching the in-core path's + // per-direction soft-NaN pushes so the angled vectors and their averages line up. + if (Ng == 0 || nvox == 0) + { + P_matrix.allocate (1, 1); + std::fill (P_matrix.begin(), P_matrix.end(), 0.0); + for (int k = 0; k < 13; k++) + { + sum_p = 0; + finalize_angle (soft_nan); + } + return; + } + + // --- pass 2: accumulate the 13 co-occurrence matrices over a sliding Z window + std::vector> mats (13); + for (int k = 0; k < 13; k++) + { + mats[k].allocate (Ng, Ng); + std::fill (mats[k].begin(), mats[k].end(), 0.0); + } + + const int off = D3_GLCM_feature::offset; + const bool sym = D3_GLCM_feature::symmetric_glcm; + + // Count one (base, neighbor) grey-level pair into matrix M, mirroring calculateCoocMatAtAngle: + // GLCM.xy(idx(neighbor), idx(base)), plus the symmetric transpose for radiomics/ibsi/symmetric. + auto add_pair = [&](SimpleMatrix& M, PixIntens lvl_base, PixIntens lvl_nbr) + { + if (ibsi_grey_binning(greyInfo)) + if (lvl_nbr == 0 || lvl_base == 0) + return; + int a = (int) lvl_nbr, b = (int) lvl_base; + if (radiomics_grey_binning(greyInfo)) + { + if (a == 0 || b == 0) + return; + a = (int) (std::lower_bound (I.begin(), I.end(), (PixIntens) a) - I.begin()); + b = (int) (std::lower_bound (I.begin(), I.end(), (PixIntens) b) - I.begin()); + } + else { a -= 1; b -= 1; } + M.xy (a, b) += 1.0; + if (sym || radiomics_grey_binning(greyInfo) || ibsi_grey_binning(greyInfo)) + M.xy (b, a) += 1.0; + }; + + // Ring of (off+1) dense grey-binned planes, indexed by local z modulo (off+1) + const int ring = off + 1; + std::vector> planes (ring); + std::vector slab; + + for (int lz = 0; lz < Dz; lz++) + { + std::vector& cur = planes[lz % ring]; + cur.assign ((size_t) W * H, bg); + r.raw_voxels_NT.read_slab ((size_t)(zmin + lz), slab); + for (const auto& v : slab) + { + int lx = (int) v.x - (int) xmin, ly = (int) v.y - (int) ymin; + if (lx >= 0 && lx < W && ly >= 0 && ly < H) + cur[(size_t) ly * W + lx] = TextureFeature::bin_pixel (v.inten, mn, mx, greyInfo); + } + + const std::vector* farp = (lz >= off) ? &planes[(lz - off) % ring] : nullptr; + + for (int k = 0; k < 13; k++) + { + const int dx = SHIFTS[k].dx * off, dy = SHIFTS[k].dy * off, dz = SHIFTS[k].dz * off; + SimpleMatrix& M = mats[k]; + + if (dz == 0) + { + // base and neighbor both on the current plane + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + { + int nx = x + dx, ny = y + dy; + if (nx >= 0 && nx < W && ny >= 0 && ny < H) + add_pair (M, cur[(size_t) y * W + x], cur[(size_t) ny * W + nx]); + } + } + else if (dz < 0) + { + // base on current plane, neighbor 'off' planes below (the far plane in the ring) + if (!farp) continue; + const std::vector& far = *farp; + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + { + int nx = x + dx, ny = y + dy; + if (nx >= 0 && nx < W && ny >= 0 && ny < H) + add_pair (M, cur[(size_t) y * W + x], far[(size_t) ny * W + nx]); + } + } + else + { + // dz>0: base on the far plane (local z-off), neighbor on the current plane + if (!farp) continue; + const std::vector& far = *farp; + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + { + int nx = x + dx, ny = y + dy; + if (nx >= 0 && nx < W && ny >= 0 && ny < H) + add_pair (M, far[(size_t) y * W + x], cur[(size_t) ny * W + nx]); + } + } + } + } + + // --- per-direction feature values from the accumulated matrices (shared finalize) + for (int k = 0; k < 13; k++) + { + P_matrix = mats[k]; + sum_p = 0; + for (double a : P_matrix) sum_p += a; + finalize_angle (soft_nan); + } +} diff --git a/src/nyx/features/3d_gldm.cpp b/src/nyx/features/3d_gldm.cpp index 663b2d4fb..e8081ba8d 100644 --- a/src/nyx/features/3d_gldm.cpp +++ b/src/nyx/features/3d_gldm.cpp @@ -1,8 +1,10 @@ #include #include #include +#include #include #include +#include #include "3d_gldm.h" #include "../environment.h" @@ -246,7 +248,146 @@ void D3_GLDM_feature::osized_add_online_pixel(size_t x, size_t y, uint32_t inten void D3_GLDM_feature::osized_calculate(LR& r, const Fsettings& s, ImageLoader&) { - calculate (r, s); + // Out-of-core GLDM. Streams the disk-backed voxel cloud through a 3-plane sliding window of + // dense grey-binned planes (the 26-neighbourhood spans z-1..z+1) and fills the dependence + // matrix P directly per voxel (no O(volume) zone list). Feature math (calc_*) is shared with + // the in-core path, so values are identical. + clear_buffers(); + + // intercept blank ROIs (same guard as calculate()) + if (r.aux_min == r.aux_max) + { + fv_SDE = fv_LDE = fv_GLN = fv_DN = fv_DNN = fv_GLV = fv_DV = fv_DE = + fv_LGLE = fv_HGLE = fv_SDLGLE = fv_SDHGLE = fv_LDLGLE = fv_LDHGLE = STNGS_NAN(s); + return; + } + + int greyInfo = STNGS_IBSI(s) ? 0 : STNGS_GLDM_GREYDEPTH(s); + PixIntens mn = r.aux_min, mx = r.aux_max; + const PixIntens bg = TextureFeature::bin_pixel (0, mn, mx, greyInfo); + const PixIntens zeroI = matlab_grey_binning(greyInfo) ? 1 : 0; + + const int W = (int) r.aabb.get_width(), + H = (int) r.aabb.get_height(), + Dz = (int) r.aabb.get_z_depth(); + const StatsInt xmin = r.aabb.get_xmin(), ymin = r.aabb.get_ymin(), zmin = r.aabb.get_zmin(); + + // --- grey levels I + matrix dimension (mirrors calculate(), which builds I from the WHOLE + // binned cube incl. background -- e.g. matlab binning maps raw-0 background to bin 1, and that + // bin must appear in I too, matching D3_GLDM_feature::calculate()'s unordered_set(D.begin(),D.end())). + std::set uniq; + PixIntens maxbin = 0; + const size_t nvox = r.raw_voxels_NT.size(); + const bool hasBackground = nvox < (size_t) W * H * Dz; + if (hasBackground && bg != 0) + { + uniq.insert (bg); + maxbin = bg; + } + for (size_t i = 0; i < nvox; i++) + { + PixIntens b = TextureFeature::bin_pixel (r.raw_voxels_NT[i].inten, mn, mx, greyInfo); + if (b > maxbin) maxbin = b; + if (b != 0) uniq.insert (b); + } + I.clear(); + if (ibsi_grey_binning(greyInfo)) + { + int n = (int) maxbin; + I.resize (n); + for (int i = 0; i < n; i++) I[i] = i + 1; + } + else + I.assign (uniq.begin(), uniq.end()); // std::set already sorted + Ng = (greyInfo == 0) ? (I.empty() ? 0 : (int) *std::max_element(I.begin(), I.end())) : (int) I.size(); + + Nd = nsh + 1; + P.allocate (Nd + 1, Ng + 1); + std::fill (P.begin(), P.end(), 0); + int max_Nd = 0; + + // --- 3-plane sliding window of dense grey-binned planes (bg-initialised so background matches + // the in-core cube), indexed by local z modulo 3 + std::vector> ring (3); + int ringZ[3] = { -1, -1, -1 }; + std::vector slab; + auto load = [&](int lz) + { + if (lz < 0 || lz >= Dz) return; + int slot = lz % 3; + if (ringZ[slot] == lz) return; + std::vector& pl = ring[slot]; + pl.assign ((size_t) W * H, bg); + r.raw_voxels_NT.read_slab ((size_t)(zmin + lz), slab); + for (const auto& v : slab) + { + int lx = (int) v.x - (int) xmin, ly = (int) v.y - (int) ymin; + if (lx >= 0 && lx < W && ly >= 0 && ly < H) + pl[(size_t) ly * W + lx] = TextureFeature::bin_pixel (v.inten, mn, mx, greyInfo); + } + ringZ[slot] = lz; + }; + + const bool ibsi = STNGS_IBSI(s); + for (int c = 0; c < Dz; c++) + { + load (c - 1); load (c); load (c + 1); + const std::vector& cur = ring[c % 3]; + + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + { + PixIntens pi = cur[(size_t) y * W + x]; + if (pi == zeroI) + continue; + + int nd = 1; + for (int i = 0; i < nsh; i++) + { + int nz = c + shifts[i].dz, ny = y + shifts[i].dy, nx = x + shifts[i].dx; + if (nz < 0 || nz >= Dz || ny < 0 || ny >= H || nx < 0 || nx >= W) + continue; + if (pi == ring[nz % 3][(size_t) ny * W + nx]) + nd++; + } + + int row = ibsi ? (int) pi - 1 + : (int)(std::lower_bound (I.begin(), I.end(), pi) - I.begin()); + int col = nd - 1; + P.xy (col, row)++; + max_Nd = (std::max) (max_Nd, nd); + } + } + + if (greyInfo) + Nd = max_Nd; + + Nz = 0; + for (auto p : P) + Nz += p; + + if (Nz == 0) + { + fv_SDE = fv_LDE = fv_GLN = fv_DN = fv_DNN = fv_GLV = fv_DV = fv_DE = + fv_LGLE = fv_HGLE = fv_SDLGLE = fv_SDHGLE = fv_LDLGLE = fv_LDHGLE = STNGS_NAN(s); + } + else + { + fv_SDE = calc_SDE(); + fv_LDE = calc_LDE(); + fv_GLN = calc_GLN(); + fv_DN = calc_DN(); + fv_DNN = calc_DNN(); + fv_GLV = calc_GLV(); + fv_DV = calc_DV(); + fv_DE = calc_DE(); + fv_LGLE = calc_LGLE(); + fv_HGLE = calc_HGLE(); + fv_SDLGLE = calc_SDLGLE(); + fv_SDHGLE = calc_SDHGLE(); + fv_LDLGLE = calc_LDLGLE(); + fv_LDHGLE = calc_LDHGLE(); + } } void D3_GLDM_feature::save_value(std::vector>& fvals) diff --git a/src/nyx/features/3d_gldzm.cpp b/src/nyx/features/3d_gldzm.cpp index 6fda05530..34b092a40 100644 --- a/src/nyx/features/3d_gldzm.cpp +++ b/src/nyx/features/3d_gldzm.cpp @@ -1,8 +1,13 @@ +#include #include +#include #include +#include +#include #include "../environment.h" #include "3d_gldzm.h" #include "image_cube.h" +#include "streaming_ccl.h" int D3_GLDZM_feature::n_levels = 0; @@ -571,7 +576,187 @@ void D3_GLDZM_feature::save_value (std::vector> & fvals) void D3_GLDZM_feature::osized_add_online_pixel (size_t x, size_t y, uint32_t intensity) {} +// Out-of-core 3D GLDZM. prepare_GLDZM_matrix_kit()'s flood fill is 6-connectivity (face-adjacent +// only -- East/West/North/South/Up/Down, no diagonals) tracking each zone's MINIMUM per-voxel +// distance to the ROI border (zone SIZE is also tracked in-core but never actually consumed by +// calc_gldzm_matrix, so this streaming version doesn't bother). Same streaming two-pass CCL +// approach as GLSZM (docs/13-3d-ooc-glszm-gldzm-design.md), but with only 3 causal neighbours +// (West, North, straight-down-Z) since 6-connectivity halves to 3, and a MIN-combining +// LabelUnionFind instead of a size-summing one. +// +// dist2border() is 2D-only (scans left/right/up/down within the CURRENT Z-plane only to the +// nearest literal-0 voxel or the plane edge) -- a limitation/convention of this implementation, +// replicated exactly, not "fixed". It needs only the current plane's dense buffer, already +// resident in the 2-plane window, so no extra memory cost versus GLSZM. +// +// Background-skip convention is GLDZM's OWN (a THIRD distinct rule, different from both GLRLM's +// unconditional zeroI=0 and GLSZM's matlab-aware zeroI): skip a voxel ONLY when +// ibsi_grey_binning(greyInfo) is true AND its binned value is literal 0 -- for radiomics/matlab +// binning, background is never skipped and forms its own zone(s), exactly mirroring +// prepare_GLDZM_matrix_kit's `if (ibsi_grey_binning(greyInfo)) if (inten==0) continue;`. void D3_GLDZM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) { - calculate (r, s); + clear_buffers(); + + if (r.aux_min == r.aux_max) + { + f_SDE = f_LDE = f_LGLZE = f_HGLZE = f_SDLGLE = f_SDHGLE = f_LDLGLE = f_LDHGLE = + f_GLNU = f_GLNUN = f_ZDNU = f_ZDNUN = f_ZP = f_GLM = f_GLV = f_ZDM = f_ZDV = f_ZDE = f_GLE = STNGS_NAN(s); + return; + } + + // grey-binning selection mirrors prepare_GLDZM_matrix_kit exactly: no GLDZM-specific greydepth + // setting exists, so it starts from the general STNGS_NGREYS(s), can be overridden by the static + // D3_GLDZM_feature::n_levels, then forced to 0 (IBSI/no-rescale) when STNGS_IBSI(s) is set. + auto greyInfo = STNGS_NGREYS(s); + auto greyInfo_localFeature = D3_GLDZM_feature::n_levels; + if (greyInfo_localFeature != 0 && greyInfo != greyInfo_localFeature) + greyInfo = greyInfo_localFeature; + if (STNGS_IBSI(s)) + greyInfo = 0; + + PixIntens mn = r.aux_min, mx = r.aux_max; + const PixIntens bg = TextureFeature::bin_pixel (0, mn, mx, greyInfo); + const bool skipZero = ibsi_grey_binning (greyInfo); // GLDZM's own rule: only skip in ibsi mode + + const int W = (int) r.aabb.get_width(), + H = (int) r.aabb.get_height(), + Dz = (int) r.aabb.get_z_depth(); + const StatsInt xmin = r.aabb.get_xmin(), ymin = r.aabb.get_ymin(), zmin = r.aabb.get_zmin(); + const size_t nvox = r.raw_voxels_NT.size(); + const bool hasBackground = nvox < (size_t) W * H * Dz; + + // --- grey levels (mirrors prepare_GLDZM_matrix_kit: unique values of the WHOLE binned cube, + // incl. background, minus literal 0 -- or a dense 1..max linspace for ibsi_grey_binning) + std::vector I; + if (ibsi_grey_binning (greyInfo)) + { + PixIntens maxbin = 0; + for (size_t i = 0; i < nvox; i++) + { + PixIntens b = TextureFeature::bin_pixel (r.raw_voxels_NT[i].inten, mn, mx, greyInfo); + if (b > maxbin) maxbin = b; + } + I.resize (maxbin); + for (int i = 0; i < (int) maxbin; i++) I[i] = i + 1; + } + else + { + std::set uniq; + if (hasBackground && bg != 0) + uniq.insert (bg); + for (size_t i = 0; i < nvox; i++) + { + PixIntens b = TextureFeature::bin_pixel (r.raw_voxels_NT[i].inten, mn, mx, greyInfo); + if (b != 0) uniq.insert (b); + } + I.assign (uniq.begin(), uniq.end()); + } + + // --- streaming 6-connectivity CCL over a 2-plane window, tracking min distance-to-border + LabelUnionFind uf; + std::vector prevPlane, curPlane; + std::vector prevLabel, curLabel; + std::vector slab; + + // 2D-only distance to the nearest literal-0 voxel or the plane edge, within 'plane' -- matches + // dist2border() exactly (1-based, min of 4 directions, clamped to >=1). + auto dist2border_plane = [&](const std::vector& plane, int x, int y) -> long long + { + int dist2l = 0; + for (int x0 = x - 1; x0 >= 0; x0--) + if (plane[(size_t) y * W + x0] == 0 || x0 == 0) { dist2l = x - x0; break; } + int dist2r = 0; + for (int x0 = x + 1; x0 < W; x0++) + if (plane[(size_t) y * W + x0] == 0 || x0 == W - 1) { dist2r = x0 - x; break; } + int dist2t = 0; + for (int y0 = y - 1; y0 >= 0; y0--) + if (plane[(size_t) y0 * W + x] == 0 || y0 == 0) { dist2t = y - y0; break; } + int dist2b = 0; + for (int y0 = y + 1; y0 < H; y0++) + if (plane[(size_t) y0 * W + x] == 0 || y0 == H - 1) { dist2b = y0 - y; break; } + dist2l++; dist2r++; dist2t++; dist2b++; + long long d = (std::min) ((std::min) (dist2l, dist2r), (std::min) (dist2t, dist2b)); + if (d == 0) d = 1; + return d; + }; + + for (int z = 0; z < Dz; z++) + { + curPlane.assign ((size_t) W * H, bg); + r.raw_voxels_NT.read_slab ((size_t)(zmin + z), slab); + for (const auto& v : slab) + { + int lx = (int) v.x - (int) xmin, ly = (int) v.y - (int) ymin; + if (lx >= 0 && lx < W && ly >= 0 && ly < H) + curPlane[(size_t) ly * W + lx] = TextureFeature::bin_pixel (v.inten, mn, mx, greyInfo); + } + curLabel.assign ((size_t) W * H, -1); + + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + { + PixIntens pi = curPlane[(size_t) y * W + x]; + if (skipZero && pi == 0) + continue; + + long long d = dist2border_plane (curPlane, x, y); + + int found = -1; + if (x > 0 && curPlane[(size_t) y * W + (x - 1)] == pi) + { + int lbl = curLabel[(size_t) y * W + (x - 1)]; + if (lbl >= 0) found = lbl; + } + if (y > 0 && curPlane[(size_t) (y - 1) * W + x] == pi) + { + int lbl = curLabel[(size_t) (y - 1) * W + x]; + if (lbl >= 0) found = (found < 0) ? lbl : uf.union_min (found, lbl); + } + if (z > 0 && prevPlane[(size_t) y * W + x] == pi) + { + int lbl = prevLabel[(size_t) y * W + x]; + if (lbl >= 0) found = (found < 0) ? lbl : uf.union_min (found, lbl); + } + + if (found < 0) + found = uf.make_label (pi, d); + else + uf.update_min (found, d); + curLabel[(size_t) y * W + x] = found; + } + + prevPlane.swap (curPlane); + prevLabel.swap (curLabel); + } + + // --- one zone per distinct root: (intensity, min distance) + std::vector> Zones; + std::vector seenRoot (uf.num_labels(), 0); + for (size_t i = 0; i < uf.num_labels(); i++) + { + int rt = uf.find ((int) i); + if (seenRoot[rt]) continue; + seenRoot[rt] = 1; + Zones.push_back ({ uf.intensity_of (rt), (int) uf.metric (rt) }); + } + + int Ng = (int) I.size(); + int Nd = 0; + for (auto& zo : Zones) + Nd = (std::max) (Nd, zo.second); + + SimpleMatrix GLDZM; + GLDZM.allocate (Nd, Ng); // Ng rows, Nd columns -- matches calc_gldzm_matrix's layout + GLDZM.fill (0); + for (auto& zo : Zones) + { + int row = (int)(std::lower_bound (I.begin(), I.end(), zo.first) - I.begin()); + int col = zo.second - 1; + GLDZM.yx (row, col)++; + } + + std::vector Mx, Md; + calc_row_and_column_sum_vectors (Mx, Md, GLDZM, Ng, Nd, I); + calc_features (Mx, Md, GLDZM, I, r.aux_area); } diff --git a/src/nyx/features/3d_glrlm_nontriv.cpp b/src/nyx/features/3d_glrlm_nontriv.cpp index b4191d053..bc5e48a00 100644 --- a/src/nyx/features/3d_glrlm_nontriv.cpp +++ b/src/nyx/features/3d_glrlm_nontriv.cpp @@ -1,11 +1,242 @@ -#include "../environment.h" -#include "3d_glrlm.h" -#include "image_matrix_nontriv.h" - -using namespace Nyxus; - -void D3_GLRLM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) -{ - calculate (r, s); -} - +#include +#include +#include +#include +#include "../environment.h" +#include "3d_glrlm.h" +#include "image_matrix_nontriv.h" + +using namespace Nyxus; + +// Same 13 canonical direction shifts as 3d_glrlm.cpp (dz in {0,1} only: half of the 26-connected +// directions, avoiding double-counting an axis and its opposite). AngleShift order is {dz,dy,dx}. +static const AngleShift SHIFTS13[] = +{ + {1, 1, 1}, {1, 1, 0}, {1, 1, -1}, {1, 0, 1}, {1, 0, 0}, {1, 0, -1}, + {1, -1, 1}, {1, -1, 0}, {1, -1, -1}, {0, 1, 1}, {0, 1, 0}, {0, 1, -1}, {0, 0, 1} +}; + +// Out-of-core 3D GLRLM. gather_rl_zones() greedily walks each run forward along its direction, +// consuming voxels via an in-RAM VISITED marker -- workable in-core (whole cube available) but not +// as-is out-of-core. Two cases, driven by the fact all 13 canonical directions have dz in {0,1}: +// +// - dz==0 (3 directions): the run never leaves its Z-plane, so gather_rl_zones() is called +// UNCHANGED on a depth-1 SimpleCube built from one streamed dense plane -- byte-identical to the +// in-core algorithm, just fed one plane at a time. +// - dz==1 (10 directions): a run advances exactly one (dz,dy,dx) step per Z-plane, so it visits at +// most one voxel per plane. This lets a "carry" array (indexed by the CURRENT plane's (y,x)) +// track each in-progress run's length across a 2-plane window, finalizing (recording into the +// grey/length histogram) a run the moment its continuation check fails, instead of needing the +// whole volume. Peak memory is O(plane area), not O(volume). +// +// Grey-level LUT and histogram-fill both reuse calc_SRE()/etc unchanged once a full SimpleMatrix +// is assembled per direction, so values are identical to calculate(). +void D3_GLRLM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) +{ + n_angles_ = (int) (sizeof(SHIFTS13) / sizeof(AngleShift)); + clear_buffers(); + + PixIntens minI = r.aux_min, maxI = r.aux_max; + if (minI == maxI) + { + double w = STNGS_NAN(s); + angled_SRE.assign (n_angles_, w); angled_LRE.assign (n_angles_, w); + angled_GLN.assign (n_angles_, w); angled_GLNN.assign (n_angles_, w); + angled_RLN.assign (n_angles_, w); angled_RLNN.assign (n_angles_, w); + angled_RP.assign (n_angles_, w); angled_GLV.assign (n_angles_, w); + angled_RV.assign (n_angles_, w); angled_RE.assign (n_angles_, w); + angled_LGLRE.assign (n_angles_, w); angled_HGLRE.assign (n_angles_, w); + angled_SRLGLE.assign (n_angles_, w); angled_SRHGLE.assign (n_angles_, w); + angled_LRLGLE.assign (n_angles_, w); angled_LRHGLE.assign (n_angles_, w); + return; + } + + int greyInfo = STNGS_IBSI(s) ? 0 : STNGS_GLRLM_GREYDEPTH(s); + const bool ibsi = STNGS_IBSI(s); + const PixIntens bg = TextureFeature::bin_pixel (0, minI, maxI, greyInfo); + + const int W = (int) r.aabb.get_width(), + H = (int) r.aabb.get_height(), + Dz = (int) r.aabb.get_z_depth(); + const StatsInt xmin = r.aabb.get_xmin(), ymin = r.aabb.get_ymin(), zmin = r.aabb.get_zmin(); + const size_t nvox = r.raw_voxels_NT.size(); + const bool hasBackground = nvox < (size_t) W * H * Dz; + + // --- grey levels I (mirrors calculate(): unique values of the WHOLE binned cube, incl. + // background, minus 0 -- for IBSI a 1..max linspace instead). Ng == I.size() in both cases. + // NOTE: the in-core path's I-construction branches on ibsi_grey_binning(greyInfo) (the numeric + // "no rescale" binning mode), NOT the STNGS_IBSI(s) compliance flag used below for row lookup -- + // under default settings greyInfo==0 (ibsi_grey_binning true) while STNGS_IBSI(s) is false, so + // conflating the two picks the wrong (sparse unique-set) branch here. + std::vector I; + if (ibsi_grey_binning (greyInfo)) + { + PixIntens maxbin = 0; + for (size_t i = 0; i < nvox; i++) + { + PixIntens b = TextureFeature::bin_pixel (r.raw_voxels_NT[i].inten, minI, maxI, greyInfo); + if (b > maxbin) maxbin = b; + } + I.resize (maxbin); + for (int i = 0; i < (int) maxbin; i++) I[i] = i + 1; + } + else + { + std::set uniq; + if (hasBackground && bg != 0) + uniq.insert (bg); + for (size_t i = 0; i < nvox; i++) + { + PixIntens b = TextureFeature::bin_pixel (r.raw_voxels_NT[i].inten, minI, maxI, greyInfo); + if (b != 0) uniq.insert (b); + } + I.assign (uniq.begin(), uniq.end()); + } + const int Ng = (int) I.size(); + const size_t Np = nvox; // matches r.raw_pixels_3D.size() in the in-core path + + auto row_of = [&](PixIntens pi) -> int + { + return ibsi ? (int) pi - 1 : (int)(std::lower_bound (I.begin(), I.end(), pi) - I.begin()); + }; + + // --- stream one direction at a time + for (const AngleShift& ash : SHIFTS13) + { + std::vector> counts (Ng > 0 ? Ng : 1); // counts[row][length-1] = run count + + auto finalize = [&](PixIntens pi, int length) + { + if (length <= 0 || Ng == 0) return; + int row = row_of (pi); + if (row < 0 || row >= Ng) return; + if ((int) counts[row].size() < length) + counts[row].resize (length, 0); + counts[row][length - 1]++; + }; + + if (ash.dz == 0) + { + // In-plane run: reuse the in-core algorithm verbatim on a depth-1 sub-cube per plane. + std::vector plane; + std::vector slab; + for (int z = 0; z < Dz; z++) + { + plane.assign ((size_t) W * H, bg); + r.raw_voxels_NT.read_slab ((size_t)(zmin + z), slab); + for (const auto& v : slab) + { + int lx = (int) v.x - (int) xmin, ly = (int) v.y - (int) ymin; + if (lx >= 0 && lx < W && ly >= 0 && ly < H) + plane[(size_t) ly * W + lx] = TextureFeature::bin_pixel (v.inten, minI, maxI, greyInfo); + } + + SimpleCube D1 (plane, W, H, 1); + std::vector> zones; + D3_GLRLM_feature::gather_rl_zones (zones, ash, D1, /*zeroI=*/ 0); + for (auto& zo : zones) + finalize (zo.first, zo.second); + } + } + else + { + // Cross-plane run (dz==1): carry each in-progress run's length across a 2-plane window, + // indexed by the CURRENT plane's (y,x). A run at (y,x) in plane z has its predecessor at + // (y-dy, x-dx) in plane z-1; if intensities match, the run continues (length+1), else the + // PREDECESSOR's run just ended and is finalized. Whatever remains unconsumed after the + // last plane is finalized once more after the loop. + std::vector prevPlane, curPlane; + std::vector prevCarry, curCarry; + std::vector slab; + + for (int z = 0; z < Dz; z++) + { + curPlane.assign ((size_t) W * H, bg); + r.raw_voxels_NT.read_slab ((size_t)(zmin + z), slab); + for (const auto& v : slab) + { + int lx = (int) v.x - (int) xmin, ly = (int) v.y - (int) ymin; + if (lx >= 0 && lx < W && ly >= 0 && ly < H) + curPlane[(size_t) ly * W + lx] = TextureFeature::bin_pixel (v.inten, minI, maxI, greyInfo); + } + curCarry.assign ((size_t) W * H, 0); + + std::vector consumedPrev; + if (z > 0) + consumedPrev.assign ((size_t) W * H, 0); + + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + { + PixIntens pi = curPlane[(size_t) y * W + x]; + if (pi == 0) // background/skip: matches gather_rl_zones's zeroI==0 check + continue; + + int py = y - ash.dy, px = x - ash.dx; + int length = 1; + if (z > 0 && py >= 0 && py < H && px >= 0 && px < W + && prevPlane[(size_t) py * W + px] == pi) + { + length = prevCarry[(size_t) py * W + px] + 1; + consumedPrev[(size_t) py * W + px] = 1; + } + curCarry[(size_t) y * W + x] = length; + } + + if (z > 0) + { + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + { + size_t idx = (size_t) y * W + x; + if (prevCarry[idx] > 0 && !consumedPrev[idx]) + finalize (prevPlane[idx], prevCarry[idx]); + } + } + + prevPlane.swap (curPlane); + prevCarry.swap (curCarry); + } + // Finalize whatever is still active after the last plane + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + { + size_t idx = (size_t) y * W + x; + if (prevCarry[idx] > 0) + finalize (prevPlane[idx], prevCarry[idx]); + } + } + + // --- assemble the direction's matrix from counts and reuse the shared feature math + int Nr = 0; + for (auto& row : counts) + Nr = (std::max) (Nr, (int) row.size()); + + P_matrix P; + P.allocate (Nr, Ng); + std::fill (P.begin(), P.end(), 0); + for (int row = 0; row < Ng; row++) + for (int col = 0; col < (int) counts[row].size(); col++) + P.xy (col, row) = counts[row][col]; + + double sum = 0; + for (auto p : P) sum += p; + + angled_SRE.push_back (calc_SRE (P, sum)); + angled_LRE.push_back (calc_LRE (P, sum)); + angled_GLN.push_back (calc_GLN (P, sum)); + angled_GLNN.push_back (calc_GLNN (P, sum)); + angled_RLN.push_back (calc_RLN (P, sum)); + angled_RLNN.push_back (calc_RLNN (P, sum)); + angled_RP.push_back (Np > 0 ? sum / double(Np) : STNGS_NAN(s)); + angled_GLV.push_back (calc_GLV (P, I, sum)); + angled_RV.push_back (calc_RV (P, sum)); + angled_RE.push_back (calc_RE (P, sum)); + angled_LGLRE.push_back (calc_LGLRE (P, I, sum)); + angled_HGLRE.push_back (calc_HGLRE (P, I, sum)); + angled_SRLGLE.push_back (calc_SRLGLE (P, I, sum)); + angled_SRHGLE.push_back (calc_SRHGLE (P, I, sum)); + angled_LRLGLE.push_back (calc_LRLGLE (P, I, sum)); + angled_LRHGLE.push_back (calc_LRHGLE (P, I, sum)); + } +} diff --git a/src/nyx/features/3d_glszm.cpp b/src/nyx/features/3d_glszm.cpp index 3b740e2b9..69f43e197 100644 --- a/src/nyx/features/3d_glszm.cpp +++ b/src/nyx/features/3d_glszm.cpp @@ -1,9 +1,11 @@ #include #include #include +#include #include #include #include "3d_glszm.h" +#include "streaming_ccl.h" #include "../environment.h" #include "../helpers/timing.h" @@ -36,9 +38,197 @@ D3_GLSZM_feature::D3_GLSZM_feature() : FeatureMethod("D3_GLSZM_feature") void D3_GLSZM_feature::osized_add_online_pixel(size_t x, size_t y, uint32_t intensity) {} // Not supporting +// Out-of-core 3D GLSZM. gather_size_zones() is a full 26-connectivity flood fill that greedily +// walks a zone (with explicit backtracking via 'history') and marks voxels VISITED -- workable +// in-core but assumes the whole cube is resident, since a zone can wander in any direction and +// therefore its final size isn't known until the whole volume has been explored. +// +// This streams a standard two-pass ("raster scan") connected-component labeling: process Z-planes +// in ascending order, and within each plane voxels in raster (row,col) order, checking only +// CAUSAL neighbours -- those already fully labeled by the scan order. For 26-connectivity that is +// 4 same-plane neighbours (West, North, North-West, North-East) plus all 9 neighbours in the +// previous (fully resolved) plane -- 13 of the 26, exactly the causal half. The symmetric "future" +// neighbours are not missed: they get discovered from the OTHER voxel's perspective once the scan +// reaches it. A growable union-find (LabelUnionFind, streaming_ccl.h) merges labels that turn out +// to belong to the same zone and accumulates each zone's voxel count (summed on union), so the +// final size is known the instant the volume finishes streaming -- no second voxel pass needed. +// Peak memory is a 2-plane label window (O(area)) plus the label table (bounded by zone count, +// worst case O(volume) for pathological data -- the same class of bound already accepted for +// GLRLM's per-row run-length histograms). void D3_GLSZM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) { - calculate (r, s); + clear_buffers(); + + if (r.aux_min == r.aux_max) + { + invalidate (STNGS_NAN(s)); + return; + } + + int greyInfo = STNGS_GLSZM_GREYDEPTH(s); + if (STNGS_IBSI(s)) greyInfo = 0; + PixIntens mn = r.aux_min, mx = r.aux_max; + const PixIntens bg = TextureFeature::bin_pixel (0, mn, mx, greyInfo); + // Background/skip value: matlab-aware, matching THIS feature's own convention (calculate()'s + // zeroI) -- NOT GLRLM's hardcoded 0, which is a different feature's own quirk. + const PixIntens zeroI = matlab_grey_binning (greyInfo) ? 1 : 0; + + const int W = (int) r.aabb.get_width(), + H = (int) r.aabb.get_height(), + Dz = (int) r.aabb.get_z_depth(); + const StatsInt xmin = r.aabb.get_xmin(), ymin = r.aabb.get_ymin(), zmin = r.aabb.get_zmin(); + const size_t nvox = r.raw_voxels_NT.size(); + const bool hasBackground = nvox < (size_t) W * H * Dz; + + // --- grey levels I (mirrors calculate(): ibsi_grey_binning(greyInfo) numeric check for + // construction -- NOT the STNGS_IBSI(s) compliance flag, which calculate() reserves for row + // lookup below; conflating the two was the exact bug found and fixed in GLRLM). + I.clear(); + if (ibsi_grey_binning (greyInfo)) + { + PixIntens maxbin = 0; + for (size_t i = 0; i < nvox; i++) + { + PixIntens b = TextureFeature::bin_pixel (r.raw_voxels_NT[i].inten, mn, mx, greyInfo); + if (b > maxbin) maxbin = b; + } + I.resize (maxbin); + for (int i = 0; i < (int) maxbin; i++) I[i] = i + 1; + } + else + { + std::set uniq; + if (hasBackground && bg != 0) + uniq.insert (bg); + for (size_t i = 0; i < nvox; i++) + { + PixIntens b = TextureFeature::bin_pixel (r.raw_voxels_NT[i].inten, mn, mx, greyInfo); + if (b != 0) uniq.insert (b); + } + I.assign (uniq.begin(), uniq.end()); + } + const bool ibsiRow = STNGS_IBSI(s); + auto row_of = [&](PixIntens pi) -> int + { + return ibsiRow ? (int) pi - 1 : (int)(std::lower_bound (I.begin(), I.end(), pi) - I.begin()); + }; + + // --- streaming 26-connectivity CCL over a 2-plane window + LabelUnionFind uf; + std::vector prevPlane, curPlane; + std::vector prevLabel, curLabel; + std::vector slab; + + static const int SAME_PLANE_CAUSAL[4][2] = { {0,-1}, {-1,0}, {-1,-1}, {-1,1} }; // dy,dx + + for (int z = 0; z < Dz; z++) + { + curPlane.assign ((size_t) W * H, bg); + r.raw_voxels_NT.read_slab ((size_t)(zmin + z), slab); + for (const auto& v : slab) + { + int lx = (int) v.x - (int) xmin, ly = (int) v.y - (int) ymin; + if (lx >= 0 && lx < W && ly >= 0 && ly < H) + curPlane[(size_t) ly * W + lx] = TextureFeature::bin_pixel (v.inten, mn, mx, greyInfo); + } + curLabel.assign ((size_t) W * H, -1); + + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + { + PixIntens pi = curPlane[(size_t) y * W + x]; + if (pi == zeroI) + continue; + + int found = -1; + for (auto& d : SAME_PLANE_CAUSAL) + { + int ny = y + d[0], nx = x + d[1]; + if (ny < 0 || ny >= H || nx < 0 || nx >= W) continue; + if (curPlane[(size_t) ny * W + nx] != pi) continue; + int lbl = curLabel[(size_t) ny * W + nx]; + if (lbl < 0) continue; + found = (found < 0) ? lbl : uf.union_sum (found, lbl); + } + if (z > 0) + { + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) + { + int ny = y + dy, nx = x + dx; + if (ny < 0 || ny >= H || nx < 0 || nx >= W) continue; + if (prevPlane[(size_t) ny * W + nx] != pi) continue; + int lbl = prevLabel[(size_t) ny * W + nx]; + if (lbl < 0) continue; + found = (found < 0) ? lbl : uf.union_sum (found, lbl); + } + } + + if (found < 0) + found = uf.make_label (pi, 1); + else + uf.add_sum (found, 1); + curLabel[(size_t) y * W + x] = found; + } + + prevPlane.swap (curPlane); + prevLabel.swap (curLabel); + } + + // --- one zone per distinct root + std::vector> Zones; + std::vector seenRoot (uf.num_labels(), 0); + for (size_t i = 0; i < uf.num_labels(); i++) + { + int rt = uf.find ((int) i); + if (seenRoot[rt]) continue; + seenRoot[rt] = 1; + Zones.push_back ({ uf.intensity_of (rt), (int) uf.metric (rt) }); + } + + int maxZoneArea = 0; + for (auto& zo : Zones) + maxZoneArea = (std::max) (maxZoneArea, zo.second); + + Ng = (int) I.size(); + Ns = maxZoneArea; + Nz = (int) Zones.size(); + Np = (int) nvox; + + P.allocate (Ns, Ng); + P.fill (0); + for (auto& zo : Zones) + { + int row = row_of (zo.first); + int col = zo.second - 1; + P.xy (col, row)++; + } + + sum_p = 0; + for (auto a : P) sum_p += a; + if (sum_p == 0) + { + invalidate (STNGS_NAN(s)); + return; + } + + calc_sums_of_P(); + fv_SAE = calc_SAE(); + fv_LAE = calc_LAE(); + fv_GLN = calc_GLN(); + fv_GLNN = calc_GLNN(); + fv_SZN = calc_SZN(); + fv_SZNN = calc_SZNN(); + fv_ZP = calc_ZP(); + fv_GLV = calc_GLV(); + fv_ZV = calc_ZV(); + fv_ZE = calc_ZE(); + fv_LGLZE = calc_LGLZE(); + fv_HGLZE = calc_HGLZE(); + fv_SALGLE = calc_SALGLE(); + fv_SAHGLE = calc_SAHGLE(); + fv_LALGLE = calc_LALGLE(); + fv_LAHGLE = calc_LAHGLE(); } /*static*/ void D3_GLSZM_feature::gather_size_zones (std::vector> & Zones, SimpleCube & D, PixIntens zeroI) diff --git a/src/nyx/features/3d_intensity.cpp b/src/nyx/features/3d_intensity.cpp index 21d8b4d2b..e50e78419 100644 --- a/src/nyx/features/3d_intensity.cpp +++ b/src/nyx/features/3d_intensity.cpp @@ -209,14 +209,19 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c double energy = 0.0; double cen_x = 0.0, cen_y = 0.0, + cen_z = 0.0, integInten = 0.0; - for (size_t i = 0; i < r.raw_pixels_NT.size(); i++) + // Stream voxels off disk (raw_voxels_NT) rather than holding the cube (raw_pixels_3D) + // or reading the 2D z-less cloud (raw_pixels_NT). Values are unchanged - intensity + // statistics are per-voxel scalars, so a linear pass over the disk-backed cloud suffices. + for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) { - Pixel2 px = r.raw_pixels_NT[i]; + Pixel3 px = r.raw_voxels_NT[i]; mean_ += px.inten; energy += px.inten * px.inten; cen_x += px.x; cen_y += px.y; + cen_z += px.z; integInten += px.inten; } mean_ /= n; @@ -228,9 +233,9 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c // --MAD, VARIANCE, STDDEV, COV double mad = 0.0, var = 0.0; - for (auto& px : r.raw_pixels_3D) + for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) { - double diff = px.inten - mean_; + double diff = r.raw_voxels_NT[i].inten - mean_; mad += std::abs(diff); var += diff * diff; } @@ -251,7 +256,7 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c // P10, 25, 75, 90, IQR, QCOD, RMAD, entropy, uniformity int n_greybins = STNGS_NGREYS(s); TrivialHistogram H; - H.initialize (n_greybins, r.aux_min, r.aux_max, r.raw_pixels_NT); + H.initialize (n_greybins, r.aux_min, r.aux_max, r.raw_voxels_NT); auto [median_, mode_, p01_, p10_, p25_, p75_, p90_, p99_, iqr_, rmad_, entropy_, uniformity_] = H.get_stats(); val_MEDIAN = median_; val_P01 = p01_; @@ -269,20 +274,23 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c // Median absolute deviation double medad = 0.0; - for (auto& px : r.raw_pixels_3D) - medad += std::abs(px.inten - median_); + for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) + medad += std::abs(double(r.raw_voxels_NT[i].inten) - median_); val_MEDIAN_ABSOLUTE_DEVIATION = medad / n; // FIX 3ROBUST_MEAN: same omission in the out-of-core path (defaulted to 0) -> compute the // mean of voxels within the [P10,P90] robust window, matching the 2D implementation. double robustMean = 0.0; size_t robustCount = 0; - for (auto& px : r.raw_pixels_3D) - if (px.inten >= p10_ && px.inten <= p90_) + for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) + { + double inten = r.raw_voxels_NT[i].inten; + if (inten >= p10_ && inten <= p90_) { - robustMean += px.inten; + robustMean += inten; robustCount++; } + } val_ROBUST_MEAN = robustCount ? robustMean / double(robustCount) : 0.0; // --Uniformity calculated as PIU, percent image uniformity - see "A comparison of five standard methods for evaluating image @@ -293,25 +301,35 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c // Skewness Moments4 mom; - for (size_t i = 0; i < r.raw_pixels_NT.size(); i++) - { - Pixel2 px = r.raw_pixels_NT[i]; - mom.add(px.inten); - } + for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) + mom.add(r.raw_voxels_NT[i].inten); val_SKEWNESS = mom.skewness(); // Kurtosis val_KURTOSIS = mom.kurtosis(); - // Excess kurtosis - val_EXCESS_KURTOSIS = val_KURTOSIS - 3; - - // Hyperskewness hs = E[x-mean].^5 / std(x).^5 - val_HYPERSKEWNESS = mom.hyperskewness(); + // Excess kurtosis. NOTE: this is NOT val_KURTOSIS-3 -- Moments4::excess_kurtosis() has its own + // independent zero-variance (M2==0) guard returning exactly 0.0, whereas kurtosis() ALSO + // returns exactly 0.0 under that same guard; subtracting 3 from the latter wrongly yields -3.0 + // for a degenerate (constant-intensity) ROI instead of 0.0. Only diverges from kurtosis()-3 in + // that degenerate case (for n>4 the two formulas are algebraically identical), which is why + // every non-degenerate fixture matched before this was caught by a blank-ROI OOC test. + val_EXCESS_KURTOSIS = mom.excess_kurtosis(); - // Hyperflatness hf = E[x-mean].^6 / std(x).^6 - val_HYPERFLATNESS = mom.hyperflatness(); + // Hyperskewness / hyperflatness: use the explicit sum-of-powers definition of the in-core + // calculate() (mom.hyperskewness()/hyperflatness() use a different definition and diverge). + double sumPow5 = 0, sumPow6 = 0; + for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) + { + double diff = double(r.raw_voxels_NT[i].inten) - mean_; + sumPow5 += std::pow(diff, 5.); + sumPow6 += std::pow(diff, 6.); + } + double denomHS = (n * std::pow(val_STANDARD_DEVIATION, 5.)); + val_HYPERSKEWNESS = denomHS == 0. ? 0. : sumPow5 / denomHS; + double denomHF = (n * std::pow(val_STANDARD_DEVIATION, 6.)); + val_HYPERFLATNESS = denomHF == 0. ? 0. : sumPow6 / denomHF; } void D3_VoxelIntensityFeatures::save_value(std::vector>& fvals) diff --git a/src/nyx/features/3d_intensity.h b/src/nyx/features/3d_intensity.h index 98e2d259d..d12891b46 100644 --- a/src/nyx/features/3d_intensity.h +++ b/src/nyx/features/3d_intensity.h @@ -16,6 +16,9 @@ class D3_VoxelIntensityFeatures: public FeatureMethod void osized_add_online_pixel(size_t x, size_t y, uint32_t intensity); void osized_calculate(LR& r, const Fsettings& s, ImageLoader& ldr) { throw std::runtime_error("illegal call of D3_VoxelIntensityFeatures::osized_calculate(LR&, const Fsettings&, ImageLoader&)"); } void osized_calculate(LR& r, const Fsettings& s, const Dataset& ds, ImageLoader& ldr); + // The Dataset-less osized_calculate is a throwing guard, so the out-of-core dispatch must + // reach the Dataset-aware overload here (COVERED_IMAGE_INTENSITY_RANGE needs slide props). + void osized_scan_whole_image (LR& roi, const Fsettings& s, const Dataset& ds, ImageLoader& ldr) override { osized_calculate (roi, s, ds, ldr); save_value (roi.fvals); } void save_value(std::vector>& feature_vals); static void reduce (size_t start, size_t end, std::vector* ptrLabels, std::unordered_map * ptrLabelData, const Fsettings & s, const Dataset & ds); // FIX: extract() must carry the Dataset, because calculate() needs it for diff --git a/src/nyx/features/3d_ngldm.cpp b/src/nyx/features/3d_ngldm.cpp index 52532f0d5..b82f18db0 100644 --- a/src/nyx/features/3d_ngldm.cpp +++ b/src/nyx/features/3d_ngldm.cpp @@ -1,6 +1,10 @@ #include +#include +#include +#include #include "3d_ngldm.h" #include "../environment.h" +#include "../helpers/helpers.h" using namespace Nyxus; @@ -405,5 +409,100 @@ void D3_NGLDM_feature::osized_add_online_pixel(size_t x, size_t y, uint32_t inte void D3_NGLDM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) { - calculate (r, s); + // Out-of-core NGLDM. Streams the disk-backed voxel cloud through a 3-plane sliding window of + // dense grey-binned planes and fills the NGLD-matrix directly. Binning, interior-only scan, + // 24-neighbourhood and background handling mirror calc_ngld_matrix exactly; the totals and + // feature math (calc_rowwise_and_columnwise_totals / calc_features) are shared. + clear_buffers(); + + if (r.aux_min == r.aux_max) + { + f_LDE = f_HDE = f_LGLCE = f_HGLCE = f_LDLGLE = f_LDHGLE = f_HDLGLE = f_HDHGLE = + f_GLNU = f_GLNUN = f_DCNU = f_DCNUN = f_GLCM = f_GLV = f_DCM = f_DCP = f_DCV = + f_DCENT = f_DCENE = STNGS_NAN(s); + return; + } + + const int nGrays = STNGS_NGREYS(s); + const bool ibsi = STNGS_IBSI(s); + const PixIntens range = r.aux_max; // binning basis is [0, aux_max], as in calc_ngld_matrix + + const int W = (int) r.aabb.get_width(), + H = (int) r.aabb.get_height(), + Dz = (int) r.aabb.get_z_depth(); + const StatsInt xmin = r.aabb.get_xmin(), ymin = r.aabb.get_ymin(), zmin = r.aabb.get_zmin(); + + // Background grey level of the dense cube (raw 0 binned). Present in the LUT only if the ROI + // bbox actually contains a non-mask (background) voxel, matching unique(aux_image_cube). + const size_t nvox = r.raw_voxels_NT.size(); + const bool hasBackground = nvox < (size_t) W * H * Dz; + const PixIntens bg = Nyxus::to_grayscale (0, 0, range, nGrays, ibsi); + + // --- grey-level LUT: unique binned values over the whole cube (mask voxels + maybe background) + std::set uniq; + if (hasBackground) + uniq.insert (bg); + for (size_t i = 0; i < nvox; i++) + uniq.insert (Nyxus::to_grayscale (r.raw_voxels_NT[i].inten, 0, range, nGrays, ibsi)); + std::vector grey_levels_LUT (uniq.begin(), uniq.end()); // std::set already sorted + int Ng = (int) grey_levels_LUT.size(); + + const int maxNr = nsh + 1; + SimpleMatrix NGLDM; + NGLDM.allocate (maxNr, Ng); + NGLDM.fill (0); + int max_dep = 0; + + // --- 3-plane sliding window of dense grey-binned planes (bg-initialised) + std::vector> ring (3); + int ringZ[3] = { -1, -1, -1 }; + std::vector slab; + auto load = [&](int lz) + { + if (lz < 0 || lz >= Dz) return; + int slot = lz % 3; + if (ringZ[slot] == lz) return; + std::vector& pl = ring[slot]; + pl.assign ((size_t) W * H, bg); + r.raw_voxels_NT.read_slab ((size_t)(zmin + lz), slab); + for (const auto& v : slab) + { + int lx = (int) v.x - (int) xmin, ly = (int) v.y - (int) ymin; + if (lx >= 0 && lx < W && ly >= 0 && ly < H) + pl[(size_t) ly * W + lx] = Nyxus::to_grayscale (v.inten, 0, range, nGrays, ibsi); + } + ringZ[slot] = lz; + }; + + // Interior voxels only (skip the 1-voxel margin so every voxel has all neighbours), as in + // calc_ngld_matrix. Background is NOT skipped. + for (int c = 1; c < Dz - 1; c++) + { + load (c - 1); load (c); load (c + 1); + const std::vector& cur = ring[c % 3]; + + for (int y = 1; y < H - 1; y++) + for (int x = 1; x < W - 1; x++) + { + PixIntens cpi = cur[(size_t) y * W + x]; + int row = (int)(std::lower_bound (grey_levels_LUT.begin(), grey_levels_LUT.end(), cpi) - grey_levels_LUT.begin()); + + int n_matches = 0; + for (int i = 0; i < nsh; i++) + { + int nz = c + shifts[i].dz, ny = y + shifts[i].dy, nx = x + shifts[i].dx; + if (nz < 0 || nz >= Dz || ny < 0 || ny >= H || nx < 0 || nx >= W) + continue; + if (cpi == ring[nz % 3][(size_t) ny * W + nx]) + n_matches++; + } + NGLDM.yx (row, n_matches)++; + max_dep = (std::max) (max_dep, n_matches); + } + } + int Nr = max_dep + 1; + + std::vector Sg, Sr; + calc_rowwise_and_columnwise_totals (Sg, Sr, NGLDM, Ng, Nr); + calc_features (Sg, Sr, NGLDM, Nr, grey_levels_LUT, r.aux_area); } diff --git a/src/nyx/features/3d_ngtdm.cpp b/src/nyx/features/3d_ngtdm.cpp index cf6dce21d..3873cc470 100644 --- a/src/nyx/features/3d_ngtdm.cpp +++ b/src/nyx/features/3d_ngtdm.cpp @@ -274,144 +274,135 @@ void D3_NGTDM_feature::osized_add_online_pixel(size_t x, size_t y, uint32_t inte void D3_NGTDM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) { - // Clear variables + // Out-of-core NGTDM. Streams the disk-backed voxel cloud through a (2*radius+1)-plane sliding + // window of dense grey-binned planes, reproducing calculate() exactly: unique-over-cube grey + // levels (incl background), the "+1 if min level is 0" shift, the radius neighbourhood average + // (which includes background neighbours), then N/S/P and the shared feature math (calc_*). clear_buffers(); + I.clear(); - // Check if the ROI is degenerate (equal intensity) - if (r.aux_min == r.aux_max) - { - bad_roi_data = true; - return; - } - - // Prepare ROI's intensity range for normalize_I() - PixIntens piRange = r.aux_max - r.aux_min; + int greyInfo = STNGS_IBSI(s) ? 0 : STNGS_NGTDM_GREYDEPTH(s); + bool ibsi = STNGS_IBSI(s); + PixIntens mn = r.aux_min, mx = r.aux_max; + const PixIntens bg = TextureFeature::bin_pixel (0, mn, mx, greyInfo); - // Make a list of intensity clusters (zones) - using AveNeighborhoodInte = std::pair; // Pairs of (intensity, average intensity of all 8 neighbors) - std::vector Z; + const int W = (int) r.aabb.get_width(), + H = (int) r.aabb.get_height(), + Dz = (int) r.aabb.get_z_depth(); + const StatsInt xmin = r.aabb.get_xmin(), ymin = r.aabb.get_ymin(), zmin = r.aabb.get_zmin(); - // While scanning clusters, learn unique intensities + // --- unique binned levels over the whole cube (mask + background if the bbox has any) + const size_t nvox = r.raw_voxels_NT.size(); + const bool hasBackground = nvox < (size_t) W * H * Dz; std::unordered_set U; + if (hasBackground) + U.insert (bg); + PixIntens maxbin = hasBackground ? bg : 0; + for (size_t i = 0; i < nvox; i++) + { + PixIntens b = TextureFeature::bin_pixel (r.raw_voxels_NT[i].inten, mn, mx, greyInfo); + U.insert (b); + if (b > maxbin) maxbin = b; + } + Ngp = (int) U.size(); - // ROI image - WriteImageMatrix_nontriv D("D3_NGTDM_feature_osized_calculate_D", r.label); - D.allocate_from_cloud(r.raw_pixels_NT, r.aabb, false); + // --- grey levels I: IBSI uses a linspace [0, max]; otherwise the unique set. Then sort. + if (ibsi) + for (PixIntens i = 0; i <= maxbin; i++) + I.push_back (i); + else + I.assign (U.begin(), U.end()); + std::sort (I.begin(), I.end()); - // Gather zones - unsigned int nGrays = STNGS_NGTDM_GREYDEPTH(s); // former theEnvironment.get_coarse_gray_depth() - for (int row = 0; row < D.get_height(); row++) - for (int col = 0; col < D.get_width(); col++) - { - // Find a non-blank pixel - PixIntens pi = Nyxus::to_grayscale(D.yx(row, col), r.aux_min, piRange, nGrays, STNGS_IBSI(s)); - if (pi == 0) - continue; + // --- "+1 if min level is 0" shift (applied to I and every voxel value) + const bool shift = (!I.empty() && I[0] == 0); + if (shift) + for (auto& x : I) x += 1; + const PixIntens bgv = bg + (shift ? 1 : 0); // background value in the shifted planes + const PixIntens zeroI = matlab_grey_binning(greyInfo) ? 1 : 0; - // Update unique intensities - U.insert(pi); + // is binned data informative? + if (I.size() < 2) + { + _coarseness = _contrast = _busyness = _complexity = _strength = STNGS_NAN(s); + return; + } - // Evaluate the neighborhood - double neigsI = 0; + const int rad = STNGS_NGTDM_RADIUS(s); + Ng = (int) I.size(); + N.assign (Ng, 0); + S.assign (Ng, 0.0); + P.assign (Ng, 0.0); + Nvp = 0; - int nd = 0; // Number of dependencies + // --- (2*rad+1)-plane sliding window of dense grey-binned planes (shift baked in) + const int ringN = 2 * rad + 1; + std::vector> ring (ringN); + std::vector ringZ (ringN, -1); + std::vector slab; + auto load = [&](int lz) + { + if (lz < 0 || lz >= Dz) return; + int slot = lz % ringN; + if (ringZ[slot] == lz) return; + std::vector& pl = ring[slot]; + pl.assign ((size_t) W * H, bgv); + r.raw_voxels_NT.read_slab ((size_t)(zmin + lz), slab); + for (const auto& v : slab) + { + int lx = (int) v.x - (int) xmin, ly = (int) v.y - (int) ymin; + if (lx >= 0 && lx < W && ly >= 0 && ly < H) + pl[(size_t) ly * W + lx] = TextureFeature::bin_pixel (v.inten, mn, mx, greyInfo) + (shift ? 1 : 0); + } + ringZ[slot] = lz; + }; - if (D.safe(row - 1, col) && D.yx(row - 1, col) != 0) // North - { - neigsI += Nyxus::to_grayscale(D.yx(row - 1, col), r.aux_min, piRange, nGrays, STNGS_IBSI(s)); - nd++; - } + for (int c = 0; c < Dz; c++) + { + for (int lz = c - rad; lz <= c + rad; lz++) + load (lz); + const std::vector& cur = ring[c % ringN]; - if (D.safe(row - 1, col + 1) && D.yx(row - 1, col + 1) != 0) // North-East + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) { - neigsI += Nyxus::to_grayscale(D.yx(row - 1, col + 1), r.aux_min, piRange, nGrays, STNGS_IBSI(s)); - nd++; - } + PixIntens pi = cur[(size_t) y * W + x]; + if (pi == zeroI) // skip background/off-mask voxels + continue; - if (D.safe(row, col + 1) && D.yx(row, col + 1) != 0) // East - { - neigsI += Nyxus::to_grayscale(D.yx(row, col + 1), r.aux_min, piRange, nGrays, STNGS_IBSI(s)); - nd++; - } - if (D.safe(row + 1, col + 1) && D.yx(row + 1, col + 1) != 0) // South-East - { - neigsI += Nyxus::to_grayscale(D.yx(row + 1, col + 1), r.aux_min, piRange, nGrays, STNGS_IBSI(s)); - nd++; - } - if (D.safe(row + 1, col) && D.yx(row + 1, col) != 0) // South - { - neigsI += Nyxus::to_grayscale(D.yx(row + 1, col), r.aux_min, piRange, nGrays, STNGS_IBSI(s)); - nd++; - } - if (D.safe(row + 1, col - 1) && D.yx(row + 1, col - 1) != 0) // South-West - { - neigsI += Nyxus::to_grayscale(D.yx(row + 1, col - 1), r.aux_min, piRange, nGrays, STNGS_IBSI(s)); - nd++; - } - if (D.safe(row, col - 1) && D.yx(row, col - 1) != 0) // West - { - neigsI += Nyxus::to_grayscale(D.yx(row, col - 1), r.aux_min, piRange, nGrays, STNGS_IBSI(s)); - nd++; - } - if (D.safe(row - 1, col - 1) && D.yx(row - 1, col - 1) != 0) // North-West - { - neigsI += Nyxus::to_grayscale(D.yx(row - 1, col - 1), r.aux_min, piRange, nGrays, STNGS_IBSI(s)); - nd++; - } + double neigsI = 0; + int nd = 0; + for (int dz = -rad; dz <= rad; dz++) + for (int dy = -rad; dy <= rad; dy++) + for (int dx = -rad; dx <= rad; dx++) + { + if (dz == 0 && dy == 0 && dx == 0) + continue; + int nz = c + dz, ny = y + dy, nx = x + dx; + if (nz < 0 || nz >= Dz || ny < 0 || ny >= H || nx < 0 || nx >= W) + continue; + neigsI += ring[nz % ringN][(size_t) ny * W + nx]; + nd++; + } - // Save the intensity's average neighborhood intensity - if (nd > 0) - { - neigsI /= nd; - AveNeighborhoodInte z = { pi, neigsI }; - Z.push_back(z); + if (nd > 0) + { + double aveNeigI = neigsI / nd; + int row = (int)(std::lower_bound (I.begin(), I.end(), pi) - I.begin()); + N[row]++; + S[row] += std::abs ((double) I[row] - aveNeigI); + if (aveNeigI > 0.0) + Nvp++; + } } - } - - // Fill the matrix - - Ng = (int) U.size(); - Ngp = (int) U.size(); - - // --allocate the matrix - P.resize (Ng, 0); - S.resize (Ng, 0); - N.resize (Ng, 0); - - // --Set to vector to be able to know each intensity's index - std::vector I(U.begin(), U.end()); - std::sort(I.begin(), I.end()); // Optional - - // --Calculate N and S - for (auto& z : Z) - { - // row - auto iter = std::find(I.begin(), I.end(), z.first); - int row = (STNGS_IBSI(s)) ? - z.first : int(iter - I.begin()); - // col - int col = (int)z.second; // 1-based - // increment - N[row]++; - // --S - PixIntens pi = row; - double aveNeigI = z.second; - S[row] += std::abs(pi - aveNeigI); - // --Nvp - if (aveNeigI > 0.0) - Nvp++; } - // --Calculate Nvc (sum of N) Nvc = 0; - for (int i = 0; i < N.size(); i++) + for (size_t i = 0; i < N.size(); i++) Nvc += N[i]; + for (size_t i = 0; i < N.size(); i++) + P[i] = (Nvc > 0) ? (double) N[i] / Nvc : 0.0; - // --Calculate P - for (int i = 0; i < N.size(); i++) - P[i] = (double)N[i] / Nvc; - - // Calculate features _coarseness = calc_Coarseness(); _contrast = calc_Contrast(); _busyness = calc_Busyness(); diff --git a/src/nyx/features/3d_surface.cpp b/src/nyx/features/3d_surface.cpp index ddc30bf12..db4f30d56 100644 --- a/src/nyx/features/3d_surface.cpp +++ b/src/nyx/features/3d_surface.cpp @@ -290,6 +290,17 @@ void D3_SurfaceFeature::build_surface (LR & r) } } + build_hull (P); +} + +// Build the convex-hull facet complex from a contour point cloud. Shared by the in-core +// build_surface and the out-of-core osized_calculate (which collects the same contour points +// from the disk-backed voxel cloud), so both produce an identical hull. +void D3_SurfaceFeature::build_hull (const std::vector>& P) +{ + constexpr std::size_t dim = 3; + using Points = std::vector>; + const auto eps = 1e-10f; quick_hull qh{ dim, eps }; qh.add_points(std::cbegin(P), std::cend(P)); @@ -352,7 +363,7 @@ void D3_SurfaceFeature::calculate (LR& r, const Fsettings& s) } // volume - + // (fast approximation based on cubic lattice packaging of balls) double ball_r3 = 1. / 8., // r^3, after the anisotropy correction, lattice is expected to be cubic sumPackedV = 0.0; @@ -361,7 +372,7 @@ void D3_SurfaceFeature::calculate (LR& r, const Fsettings& s) fval_VOXEL_VOLUME = sumPackedV / 0.5236; // packaging density at kissing number = 4 (cubic lattice) // surface - + // -- order z-planes' indices std::vector zindices; for (auto& plane : r.zplanes) @@ -517,7 +528,180 @@ void D3_SurfaceFeature::calculate (LR& r, const Fsettings& s) void D3_SurfaceFeature::osized_add_online_pixel(size_t x, size_t y, uint32_t intensity) {} -void D3_SurfaceFeature::osized_calculate (LR& r, const Fsettings& s, ImageLoader& ldr) {} +void D3_SurfaceFeature::osized_calculate (LR& r, const Fsettings& s, ImageLoader& ldr) +{ + // Out-of-core surface: stream the disk-backed voxel cloud (raw_voxels_NT) one Z-plane at a + // time instead of holding the whole cube (raw_pixels_3D). Per plane we extract its contour and + // tally exposed-face adjacencies; covariance is accumulated online. Peak memory is the + // collected boundary points (~surface area) plus two Z-plane occupancy bitmaps -- bounded by + // area, not volume. Values mirror the in-core calculate(). + size_t n = r.raw_voxels_NT.size(); + if (n == 0) + { + cleanup_instance(); + return; + } + + if (STNGS_SINGLEROI(s)) + { + auto w = r.aabb.get_width(), + h = r.aabb.get_height(), + d = r.aabb.get_z_depth(); + + fval_AREA = 2 * (w*h + h*d + w*d); + fval_VOLUME_CONVEXHULL = fval_VOXEL_VOLUME = fval_MESH_VOLUME = w * h * d; + fval_AREA_2_VOLUME = fval_AREA / fval_VOXEL_VOLUME; + fval_COMPACTNESS1 = fval_VOXEL_VOLUME / std::sqrt(M_PI * fval_AREA * fval_AREA * fval_AREA); + fval_COMPACTNESS2 = 36. * M_PI * fval_VOXEL_VOLUME * fval_VOXEL_VOLUME / (fval_AREA * fval_AREA * fval_AREA); + fval_SPHERICAL_DISPROPORTION = fval_AREA / std::pow(36. * M_PI * fval_VOXEL_VOLUME * fval_VOXEL_VOLUME, 1. / 3.); + fval_SPHERICITY = std::pow(36. * M_PI * fval_VOXEL_VOLUME * fval_VOXEL_VOLUME, 1. / 3.) / fval_AREA; + fval_MAJOR_AXIS_LEN = fval_MINOR_AXIS_LEN = fval_LEAST_AXIS_LEN = fval_ELONGATION = fval_FLATNESS = 0; + return; + } + + // -- VOXEL_VOLUME: same cubic-lattice ball packing, a function of voxel count only + double sumPackedV = double(n) * (4. / 3. * M_PI * (1. / 8.)); + fval_VOXEL_VOLUME = sumPackedV / 0.5236; + + const int W = (int) r.aabb.get_width(), + H = (int) r.aabb.get_height(), + minx = (int) r.aabb.get_xmin(), + miny = (int) r.aabb.get_ymin(); + const int verb = STNGS_VERBOSLVL(s); + + // Collected boundary points for the convex hull (bounded by surface area), plus the running + // centroid of those points (matches the in-core centroid over contour voxels). + std::vector> P; + double cx = 0, cy = 0, cz = 0; + size_t hullCloudLen = 0; + + // Surface area as exposed faces = 6*N - 2*(adjacent voxel pairs). x/y adjacencies are tallied + // within a plane; z adjacencies between a plane and the previous one -- a 2-plane window. + double adjacencies = 0.0; + std::vector prevOcc; // occupancy bitmap of plane (z-1) over the [W x H] ROI bbox + long long prevZ = -2; // z index of prevOcc; -2 = none + + // Online covariance sums over all voxels + double sx = 0, sy = 0, sz = 0, sxx = 0, syy = 0, szz = 0, sxy = 0, sxz = 0, syz = 0; + + const size_t depth = r.raw_voxels_NT.depth(); + std::vector slab; + for (size_t z = 0; z < depth; z++) + { + r.raw_voxels_NT.read_slab (z, slab); + if (slab.empty()) + continue; + + // -- contour of this plane (build over a plane-local cloud; the returned indices point + // into 'slab', and the resulting boundary POINTS/order match the in-core path) + std::vector planeIdx (slab.size()); + for (size_t i = 0; i < slab.size(); i++) + planeIdx[i] = i; + std::vector K; + Nyxus::build_contour_imp (K, slab, planeIdx, (int) z, W, H, minx, miny, verb); + for (auto ik : K) + { + const auto& v = slab[ik]; + P.push_back (std::array({ (float) v.x, (float) v.y, (float) v.z })); + cx += v.x; cy += v.y; cz += v.z; + hullCloudLen++; + } + + // -- this plane's occupancy bitmap + online covariance sums + std::vector curOcc ((size_t) W * H, 0); + for (const auto& v : slab) + { + int lx = (int) v.x - minx, ly = (int) v.y - miny; + if (lx >= 0 && lx < W && ly >= 0 && ly < H) + curOcc[(size_t) ly * W + lx] = 1; + + double dx = (double) v.x, dy = (double) v.y, dz = (double) v.z; + sx += dx; sy += dy; sz += dz; + sxx += dx * dx; syy += dy * dy; szz += dz * dz; + sxy += dx * dy; sxz += dx * dz; syz += dy * dz; + } + + // -- adjacencies. +x / +y within this plane (each unordered in-plane pair once) + for (const auto& v : slab) + { + int lx = (int) v.x - minx, ly = (int) v.y - miny; + if (lx + 1 < W && ly >= 0 && ly < H && curOcc[(size_t) ly * W + (lx + 1)]) + adjacencies += 1.0; + if (ly + 1 < H && lx >= 0 && lx < W && curOcc[(size_t) (ly + 1) * W + lx]) + adjacencies += 1.0; + // +z: pair with the previous plane at the same (x,y) (each unordered z pair once) + if (prevZ == (long long) z - 1 && lx >= 0 && lx < W && ly >= 0 && ly < H + && prevOcc[(size_t) ly * W + lx]) + adjacencies += 1.0; + } + + prevOcc.swap (curOcc); + prevZ = (long long) z; + } + + fval_AREA = 6.0 * double(n) - 2.0 * adjacencies; + + // -- convex hull from the collected boundary points + build_hull (P); + + // -- convex hull volume via signed tetrahedra from the contour centroid + if (hullCloudLen) + { + cx /= double(hullCloudLen); + cy /= double(hullCloudLen); + cz /= double(hullCloudLen); + } + fval_VOLUME_CONVEXHULL = 0; + for (const auto& sx3 : hull_complex) + { + double d = Nyxus::det4( + sx3.a[0], sx3.a[1], sx3.a[2], 1, + sx3.b[0], sx3.b[1], sx3.b[2], 1, + sx3.c[0], sx3.c[1], sx3.c[2], 1, + cx, cy, cz, 1); + fval_VOLUME_CONVEXHULL += d / 6; + } + + // -- volume-area ratio features + fval_MESH_VOLUME = fval_VOLUME_CONVEXHULL; + fval_AREA_2_VOLUME = fval_AREA / fval_VOXEL_VOLUME; + fval_COMPACTNESS1 = fval_VOXEL_VOLUME / std::sqrt(M_PI * fval_AREA * fval_AREA * fval_AREA); + fval_COMPACTNESS2 = 36. * M_PI * fval_VOXEL_VOLUME * fval_VOXEL_VOLUME / (fval_AREA * fval_AREA * fval_AREA); + fval_SPHERICAL_DISPROPORTION = fval_AREA / std::pow(36. * M_PI * fval_VOXEL_VOLUME * fval_VOXEL_VOLUME, 1. / 3.); + fval_SPHERICITY = std::pow(36. * M_PI * fval_VOXEL_VOLUME * fval_VOXEL_VOLUME, 1. / 3.) / fval_AREA; + + // -- PCA axis lengths from the covariance matrix (sample covariance, matching calc_covariance: + // Cov(a,b) = (Sum a*b - N*mean_a*mean_b) / (N-1)) + double K[3][3]; + if (n > 1) + { + double mx = sx / double(n), my = sy / double(n), mz = sz / double(n); + double nf = double(n) - 1.0; + K[0][0] = (sxx - double(n) * mx * mx) / nf; + K[1][1] = (syy - double(n) * my * my) / nf; + K[2][2] = (szz - double(n) * mz * mz) / nf; + K[0][1] = K[1][0] = (sxy - double(n) * mx * my) / nf; + K[0][2] = K[2][0] = (sxz - double(n) * mx * mz) / nf; + K[1][2] = K[2][1] = (syz - double(n) * my * mz) / nf; + } + else + K[0][0] = K[0][1] = K[0][2] = K[1][0] = K[1][1] = K[1][2] = K[2][0] = K[2][1] = K[2][2] = 0; + + double L[3]; + if (Nyxus::calc_eigvals(L, K)) + { + // calc_eigvals returns L sorted DESCENDING (L[0] largest): MAJOR<-L[0], MINOR<-L[1], + // LEAST<-L[2]; ELONGATION=MINOR/MAJOR=sqrt(L[1]/L[0]), FLATNESS=LEAST/MAJOR=sqrt(L[2]/L[0]). + // Matches MIRP/IBSI and the in-RAM calculate() path. + fval_MAJOR_AXIS_LEN = 4.0 * sqrt(L[0]); + fval_MINOR_AXIS_LEN = 4.0 * sqrt(L[1]); + fval_LEAST_AXIS_LEN = 4.0 * sqrt(L[2]); + fval_ELONGATION = sqrt(L[1] / L[0]); + fval_FLATNESS = sqrt(L[2] / L[0]); + } + else + fval_MAJOR_AXIS_LEN = fval_MINOR_AXIS_LEN = fval_LEAST_AXIS_LEN = fval_ELONGATION = fval_FLATNESS = 0.0; +} void D3_SurfaceFeature::save_value(std::vector>& fvals) { diff --git a/src/nyx/features/3d_surface.h b/src/nyx/features/3d_surface.h index be1042f4e..8410e8bfe 100644 --- a/src/nyx/features/3d_surface.h +++ b/src/nyx/features/3d_surface.h @@ -1,6 +1,8 @@ #pragma once #pragma once +#include +#include #include "../dataset.h" #include "../featureset.h" #include "../feature_method.h" @@ -41,13 +43,13 @@ class D3_SurfaceFeature : public FeatureMethod private: - struct Simplex3 - { - float a[3], b[3], c[3]; // layout: x, y, z - Simplex3 (const float* a_, const float* b_, const float* c_) - { - for (int i = 0; i < 3; i++) - { + struct Simplex3 + { + float a[3], b[3], c[3]; // layout: x, y, z + Simplex3 (const float* a_, const float* b_, const float* c_) + { + for (int i = 0; i < 3; i++) + { a[i] = a_[i]; b[i] = b_[i]; c[i] = c_[i]; @@ -56,8 +58,10 @@ class D3_SurfaceFeature : public FeatureMethod }; std::vector hull_complex; - + void build_surface (LR& r); + // Build the hull complex from a contour point cloud (shared by in-core + out-of-core paths) + void build_hull (const std::vector>& P); double fval_AREA, fval_AREA_2_VOLUME, diff --git a/src/nyx/features/histogram.h b/src/nyx/features/histogram.h index e1547edd4..179fa6309 100644 --- a/src/nyx/features/histogram.h +++ b/src/nyx/features/histogram.h @@ -11,6 +11,7 @@ #include "../helpers/helpers.h" #include "pixel.h" #include "image_matrix_nontriv.h" +#include "voxel_cloud_nontriv.h" using HistoItem = unsigned int; @@ -169,15 +170,105 @@ class TrivialHistogram // percentiles calc_percentiles(); - // robust mean - bin10ctr_ = bins100_ [10]; - bin90ctr_ = bins100_ [90]; + // robust MAD: threshold on the p10/p90 percentile values, and average the + // 10..90 population before accumulating deviations (mirrors the in-core overload) mean1090val_ = 0.0; size_t pop1090 = 0; for (auto pxl : raw_data) { double a = double (pxl.inten); - if (a >= bin10ctr_ && a <= bin90ctr_) + if (a >= p10_ && a <= p90_) + { + mean1090val_ += a; + pop1090++; + } + } + rmad_ = 0.0; + if (pop1090) + { + mean1090val_ /= double(pop1090); + for (auto pxl : raw_data) + { + double a = double(pxl.inten); + if (a >= p10_ && a <= p90_) + rmad_ += (std::fabs)(a - mean1090val_); + } + rmad_ /= double(pop1090); + } + } + + // Out-of-core 3D voxel-cloud overload: identical math to the OutOfRamPixelCloud overload + // (the disk-backed 2D cloud), sourcing intensities from the disk-backed 3D voxel cloud so + // an oversized volumetric ROI's histogram streams instead of holding the cube. + void initialize (int n_cust_bins, HistoItem min_value, HistoItem max_value, const OutOfRamVoxelCloud& raw_data) + { + // safety + int n_custBins = std::abs(n_cust_bins); + + pop_ = raw_data.size(); + + // Allocate + // -- "percentile" + bins100_.reserve(100); + for (int i = 0; i < 100 + 1; i++) + bins100_.push_back(0); + // -- "uint8" + bins_cust_.reserve(n_custBins); + for (int i = 0; i < n_custBins + 1; i++) + bins_cust_.push_back(0); + + // Cache min/max + minVal_ = min_value; + maxVal_ = max_value; + auto valRange = maxVal_ - minVal_; + + // unique values + for (auto s : raw_data) + U_.push_back (s.inten); + + // Build the "percentile" histogram + binW100_ = double(valRange) / 100.; + for (auto s : raw_data) + { + HistoItem h = s.inten; + double realIdx = double(h - minVal_) / binW100_; + int idx = std::isnan(realIdx) ? 0 : int(realIdx); + (bins100_[idx])++; + } + + // -- Fix the special last bin + bins100_[100 - 1] += bins100_[100]; + bins100_[100] = 0; + + // Build the "uint8" histogram + binWcust_ = double(valRange) / double(n_custBins-1); + for (auto s : raw_data) + { + HistoItem h = Nyxus::to_grayscale(s.inten, minVal_, valRange, n_custBins); + bins_cust_[h] = bins_cust_[h] + 1; + } + + // -- Fix the special last bin + bins_cust_[n_custBins - 1] += bins_cust_[n_custBins]; + bins_cust_[n_custBins] = 0; + + // Mean calculation + meanVal_ = 0; + for (auto s : raw_data) + meanVal_ += double(s.inten); + meanVal_ /= double(pop_); + + // percentiles + calc_percentiles(); + + // robust MAD: threshold on the p10/p90 percentile values, and average the + // 10..90 population before accumulating deviations (mirrors the in-core overload) + mean1090val_ = 0.0; + size_t pop1090 = 0; + for (auto pxl : raw_data) + { + double a = double (pxl.inten); + if (a >= p10_ && a <= p90_) { mean1090val_ += a; pop1090++; @@ -186,10 +277,11 @@ class TrivialHistogram rmad_ = 0.0; if (pop1090) { + mean1090val_ /= double(pop1090); for (auto pxl : raw_data) { double a = double(pxl.inten); - if (a >= bin10ctr_ && a <= bin90ctr_) + if (a >= p10_ && a <= p90_) rmad_ += (std::fabs)(a - mean1090val_); } rmad_ /= double(pop1090); diff --git a/src/nyx/features/intensity.cpp b/src/nyx/features/intensity.cpp index f4f268dd8..6aa511e25 100644 --- a/src/nyx/features/intensity.cpp +++ b/src/nyx/features/intensity.cpp @@ -269,8 +269,10 @@ void PixelIntensityFeatures::osized_calculate (LR& r, const Fsettings& stng, con // --MAD, VARIANCE, STDDEV, COV double mad = 0.0, var = 0.0; - for (auto& px : r.raw_pixels) + // out-of-core: dispersion is accumulated from the disk-backed pixel cloud + for (size_t i = 0; i < r.raw_pixels_NT.size(); i++) { + Pixel2 px = r.raw_pixels_NT[i]; double diff = px.inten - mean_; mad += std::abs(diff); var += diff * diff; @@ -323,12 +325,16 @@ void PixelIntensityFeatures::osized_calculate (LR& r, const Fsettings& stng, con // Median absolute deviation double medad = 0.0; - for (auto& px : r.raw_pixels) + // out-of-core: read from the disk-backed pixel cloud + for (size_t i = 0; i < r.raw_pixels_NT.size(); i++) + { + Pixel2 px = r.raw_pixels_NT[i]; medad += std::abs(px.inten - median_); + } val_MEDIAN_ABSOLUTE_DEVIATION = medad / n; - // --Uniformity calculated as PIU, percent image uniformity - see "A comparison of five standard methods for evaluating image - // intensity uniformity in partially parallel imaging MRI" [https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3745492/] + // --Uniformity calculated as PIU, percent image uniformity - see "A comparison of five standard methods for evaluating image + // intensity uniformity in partially parallel imaging MRI" [https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3745492/] // and https://aapm.onlinelibrary.wiley.com/doi/abs/10.1118/1.2241606 double piu = (1.0 - double(r.aux_max - r.aux_min) / double(r.aux_max + r.aux_min)) * 100.0; val_UNIFORMITY_PIU = piu; @@ -349,11 +355,19 @@ void PixelIntensityFeatures::osized_calculate (LR& r, const Fsettings& stng, con // Excess kurtosis val_EXCESS_KURTOSIS = val_KURTOSIS - 3; - // Hyperskewness hs = E[x-mean].^5 / std(x).^5 - val_HYPERSKEWNESS = mom.hyperskewness(); - - // Hyperflatness hf = E[x-mean].^6 / std(x).^6 - val_HYPERFLATNESS = mom.hyperflatness(); + // Hyperskewness / Hyperflatness from explicit central moments over the disk-backed cloud, + // matching the in-core path's sum-of-powers definition rather than the Moments4 variant + double sumPow5 = 0, sumPow6 = 0; + for (size_t i = 0; i < r.raw_pixels_NT.size(); i++) + { + double diff = double(r.raw_pixels_NT[i].inten) - mean_; + sumPow5 += std::pow(diff, 5.); + sumPow6 += std::pow(diff, 6.); + } + double denom5 = n * std::pow(val_STANDARD_DEVIATION, 5.); + val_HYPERSKEWNESS = denom5 == 0. ? 0. : sumPow5 / denom5; + double denom6 = n * std::pow(val_STANDARD_DEVIATION, 6.); + val_HYPERFLATNESS = denom6 == 0. ? 0. : sumPow6 / denom6; } void PixelIntensityFeatures::save_value(std::vector>& fvals) diff --git a/src/nyx/features/intensity.h b/src/nyx/features/intensity.h index 140c61bf9..c99035b17 100644 --- a/src/nyx/features/intensity.h +++ b/src/nyx/features/intensity.h @@ -46,6 +46,10 @@ class PixelIntensityFeatures : public FeatureMethod void osized_add_online_pixel(size_t x, size_t y, uint32_t intensity); void osized_calculate (LR & roi, const Fsettings & s, const Dataset & dataset, ImageLoader& ldr); void osized_calculate (LR& roi, const Fsettings& s, ImageLoader& ldr) { throw std::runtime_error("illegal use of PixelIntensityFeatures::osized_calculate()"); } + // Out-of-core dispatch for intensity: route to the Dataset-taking osized_calculate, which + // needs slide props for COVERED_IMAGE_INTENSITY_RANGE. The Dataset-less overload above is a + // guard that throws, so the base osized_scan_whole_image cannot be used for this feature. + void osized_scan_whole_image (LR& roi, const Fsettings& s, const Dataset& ds, ImageLoader& ldr) override { osized_calculate (roi, s, ds, ldr); save_value (roi.fvals); } void save_value (std::vector>& feature_vals); static void reduce (size_t start, size_t end, std::vector* ptrLabels, std::unordered_map * ptrLabelData, const Fsettings & settings, const Dataset & dataset); static void extract (LR & roi, const Fsettings & settings, const Dataset & dataset); diff --git a/src/nyx/features/intensity_histogram.h b/src/nyx/features/intensity_histogram.h index 815fa0f66..f1d08811c 100644 --- a/src/nyx/features/intensity_histogram.h +++ b/src/nyx/features/intensity_histogram.h @@ -84,6 +84,10 @@ class IntensityHistogramFeatures : public FeatureMethod void osized_add_online_pixel (size_t x, size_t y, uint32_t intensity); void osized_calculate (LR& roi, const Fsettings& s, const Dataset& dataset, ImageLoader& ldr); void osized_calculate (LR& roi, const Fsettings& s, ImageLoader& ldr) { throw std::runtime_error("illegal use of IntensityHistogramFeatures::osized_calculate()"); } + // Out-of-core dispatch for the intensity histogram: route to the Dataset-taking + // osized_calculate. The Dataset-less overload above is a guard that throws, so the base + // osized_scan_whole_image cannot be used for this feature. + void osized_scan_whole_image (LR& roi, const Fsettings& s, const Dataset& ds, ImageLoader& ldr) override { osized_calculate (roi, s, ds, ldr); save_value (roi.fvals); } void save_value (std::vector>& feature_vals); static void reduce (size_t start, size_t end, std::vector* ptrLabels, std::unordered_map * ptrLabelData, const Fsettings& settings, const Dataset& dataset); static void extract (LR& roi, const Fsettings& settings, const Dataset& dataset); diff --git a/src/nyx/features/streaming_ccl.h b/src/nyx/features/streaming_ccl.h new file mode 100644 index 000000000..9bdb7217c --- /dev/null +++ b/src/nyx/features/streaming_ccl.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include "pixel.h" + +/// @brief Growable union-find over connected-component labels, shared by the out-of-core +/// GLSZM/GLDZM streaming connected-component labeling (see docs/13-3d-ooc-glszm-gldzm-design.md). +/// +/// A label is created for each newly-discovered zone (make_label) and merged with another when a +/// streaming scan discovers two previously-separate labels actually belong to the same zone +/// (union_sum/union_min). Each label carries a running metric -- the zone's voxel count for GLSZM +/// (combined by SUM on union) or its minimum distance-to-border for GLDZM (combined by MIN) -- so +/// after the whole volume streams once, each root label directly holds its zone's final metric; no +/// second pass over voxels is needed. +class LabelUnionFind +{ +public: + int make_label (PixIntens intensity, long long initial_metric) + { + parent_.push_back ((int) parent_.size()); + rank_.push_back (0); + metric_.push_back (initial_metric); + intensity_.push_back (intensity); + return (int) parent_.size() - 1; + } + + int find (int x) + { + while (parent_[x] != x) + { + parent_[x] = parent_[parent_[x]]; // path halving + x = parent_[x]; + } + return x; + } + + // Merge labels a and b, combining their metric as a SUM (GLSZM zone size) + int union_sum (int a, int b) { return union_impl (a, b, true); } + // Merge labels a and b, combining their metric as a MIN (GLDZM zone distance) + int union_min (int a, int b) { return union_impl (a, b, false); } + + // Add this voxel to an existing zone's running size (GLSZM) + void add_sum (int label, long long delta) { int r = find (label); metric_[r] += delta; } + // Fold this voxel's distance into an existing zone's running minimum (GLDZM) + void update_min (int label, long long val) { int r = find (label); metric_[r] = (std::min)(metric_[r], val); } + + long long metric (int label) { return metric_[find (label)]; } + PixIntens intensity_of (int label) const { return intensity_[label]; } + size_t num_labels() const { return parent_.size(); } + +private: + int union_impl (int a, int b, bool sumMode) + { + int ra = find (a), rb = find (b); + if (ra == rb) return ra; + if (rank_[ra] < rank_[rb]) std::swap (ra, rb); + parent_[rb] = ra; + if (rank_[ra] == rank_[rb]) rank_[ra]++; + metric_[ra] = sumMode ? (metric_[ra] + metric_[rb]) : (std::min)(metric_[ra], metric_[rb]); + return ra; + } + + std::vector parent_, rank_; + std::vector metric_; + std::vector intensity_; +}; diff --git a/src/nyx/features/voxel_cloud_nontriv.cpp b/src/nyx/features/voxel_cloud_nontriv.cpp new file mode 100644 index 000000000..d6baf2619 --- /dev/null +++ b/src/nyx/features/voxel_cloud_nontriv.cpp @@ -0,0 +1,180 @@ +#include +#include +#include +#include +#include +#include "../environment.h" +#include "../helpers/fsystem.h" +#include "../helpers/helpers.h" +#include "voxel_cloud_nontriv.h" + +OutOfRamVoxelCloud::OutOfRamVoxelCloud() {} + +OutOfRamVoxelCloud::~OutOfRamVoxelCloud() +{ + if (pF) + { + fclose(pF); + pF = nullptr; + fs::remove(filepath); + } +} + +void OutOfRamVoxelCloud::check_io_ok() const +{ + if (!pF) + throw (std::runtime_error("ERROR in OutOfRamVoxelCloud - file might not be open with init()")); +} + +Pixel3 OutOfRamVoxelCloud::decode_record (const char* rec) const +{ + Pixel3 vx; + std::memcpy (&vx.x, rec, sizeof(vx.x)); rec += sizeof(vx.x); + std::memcpy (&vx.y, rec, sizeof(vx.y)); rec += sizeof(vx.y); + std::memcpy (&vx.z, rec, sizeof(vx.z)); rec += sizeof(vx.z); + std::memcpy (&vx.inten, rec, sizeof(vx.inten)); + return vx; +} + +void OutOfRamVoxelCloud::init (unsigned int _roi_label, const std::string& name) +{ + n_items = 0; + slab_first.clear(); + slab_count.clear(); + invalidate_cache(); + + auto tid = std::this_thread::get_id(); + + std::stringstream ssPath; + ssPath << Nyxus::get_temp_dir_path() << name << "_roi" << _roi_label << "_tid" << tid << ".vox.nyxus"; + filepath = ssPath.str(); + + errno = 0; + pF = fopen(filepath.c_str(), "w+b"); + if (!pF) + throw (std::runtime_error("Error creating file " + filepath + " Error code:" + std::to_string(errno))); + + if (std::setvbuf(pF, nullptr, _IOFBF, 32768) != 0) + std::cout << "setvbuf failed\n"; +} + +void OutOfRamVoxelCloud::close() +{ + if (pF) + { + fclose(pF); + pF = nullptr; + } +} + +void OutOfRamVoxelCloud::clear() +{ + if (pF) + { + close(); + fs::remove (filepath); + n_items = 0; + } + slab_first.clear(); + slab_count.clear(); + invalidate_cache(); +} + +void OutOfRamVoxelCloud::begin_slab (size_t z) +{ + // Planes arrive in ascending order; grow the index so slab_first[z]/slab_count[z] are addressable + if (slab_first.size() <= z) + { + slab_first.resize (z + 1, n_items); + slab_count.resize (z + 1, 0); + } + slab_first[z] = n_items; + slab_count[z] = 0; +} + +void OutOfRamVoxelCloud::add_voxel (const Pixel3& v) +{ + check_io_ok(); + + fwrite ((const void*) &(v.x), sizeof(v.x), 1, pF); + fwrite ((const void*) &(v.y), sizeof(v.y), 1, pF); + fwrite ((const void*) &(v.z), sizeof(v.z), 1, pF); + fwrite ((const void*) &(v.inten), sizeof(v.inten), 1, pF); + + n_items++; + if (!slab_count.empty()) + slab_count.back()++; + + // A write past the cached block would make it stale; the population phase never interleaves + // reads, so this just holds the cache empty until the first read. + invalidate_cache(); +} + +void OutOfRamVoxelCloud::end_slab (size_t /*z*/) {} + +size_t OutOfRamVoxelCloud::size() const +{ + return n_items; +} + +Pixel3 OutOfRamVoxelCloud::get_at (size_t idx) const +{ + check_io_ok(); + + // Serve from the cached block; on a miss, bulk-read the block containing idx with one fread + // (sequential consumers then hit the cache BLK_RECORDS times before the next miss). + if (blk_first < 0 || idx < (size_t) blk_first || idx >= (size_t) blk_first + blk_count) + { + size_t start = (idx / BLK_RECORDS) * BLK_RECORDS; + size_t want = (std::min) (BLK_RECORDS, n_items - start); + blk_buf.resize (want * item_size); + fseek (pF, (long) (start * item_size), SEEK_SET); + size_t got = fread (blk_buf.data(), item_size, want, pF); + blk_first = (long long) start; + blk_count = got; + } + return decode_record (blk_buf.data() + (idx - (size_t) blk_first) * item_size); +} + +void OutOfRamVoxelCloud::read_range (size_t first, size_t count, std::vector& out) const +{ + check_io_ok(); + + out.clear(); + out.reserve (count); + if (count == 0) + return; + + // One bulk fread of the whole range, then decode in RAM (a slab is bounded by the plane bbox) + std::vector buf (count * item_size); + fseek (pF, (long) (first * item_size), SEEK_SET); + size_t got = fread (buf.data(), item_size, count, pF); + for (size_t i = 0; i < got; i++) + out.push_back (decode_record (buf.data() + i * item_size)); +} + +void OutOfRamVoxelCloud::read_slab (size_t z, std::vector& out) const +{ + if (z >= slab_first.size()) + { + out.clear(); + return; + } + read_range (slab_first[z], slab_count[z], out); +} + +void OutOfRamVoxelCloud::read_slab_window (size_t z0, size_t z1, std::vector& out) const +{ + // Contiguous on disk because records are Z-sorted: one range spans planes z0..z1 + if (slab_first.empty() || z0 >= slab_first.size()) + { + out.clear(); + return; + } + size_t zi = (z1 < slab_first.size()) ? z1 : slab_first.size() - 1; + size_t first = slab_first[z0]; + size_t count = 0; + for (size_t z = z0; z <= zi; z++) + count += slab_count[z]; + read_range (first, count, out); +} diff --git a/src/nyx/features/voxel_cloud_nontriv.h b/src/nyx/features/voxel_cloud_nontriv.h new file mode 100644 index 000000000..ae665785a --- /dev/null +++ b/src/nyx/features/voxel_cloud_nontriv.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include "pixel.h" + +/// @brief Writeable out-of-memory 3D voxel cloud. +/// +/// The 3D analog of OutOfRamPixelCloud: it stores Pixel3 records (x,y,z,inten) on disk +/// so an oversized volumetric ROI can be featurized without holding the whole voxel cube +/// in RAM. Records are appended in ascending-Z order by the streaming population scan, so +/// they land on disk already Z-sorted; a small in-RAM per-plane index (first-record offset +/// and count per Z) lets a consumer seek to any Z-slab and read just that slab's voxels. +/// The whole-cloud linear accessors (size/get_at/iterator) serve the Z-agnostic intensity +/// and histogram consumers; the slab accessors serve the sliding-window consumers (surface, +/// and later the 3D texture features). +class OutOfRamVoxelCloud +{ +public: + OutOfRamVoxelCloud(); + OutOfRamVoxelCloud (const OutOfRamVoxelCloud&) = delete; + ~OutOfRamVoxelCloud(); + + // Lifecycle. init() creates the backing file under get_temp_dir_path(). + void init (unsigned int roi_label, const std::string& name); + void close(); + void clear(); + + // Population (called in ascending-Z order). begin_slab(z) marks where plane z's records + // start; add_voxel() appends one record; end_slab(z) finalizes the plane's count. + void begin_slab (size_t z); + void add_voxel (const Pixel3& v); + void end_slab (size_t z); + + // Whole-cloud linear access (intensity, histogram) + size_t size() const; + Pixel3 get_at (size_t idx) const; + Pixel3 operator[] (size_t idx) const { return get_at(idx); } + + // Slab access (surface, later texture): a Z-plane or a [z0,z1] window + size_t depth() const { return slab_first.size(); } + void read_slab (size_t z, std::vector& out) const; + void read_slab_window (size_t z0, size_t z1, std::vector& out) const; + + struct iterator + { + public: + iterator (const OutOfRamVoxelCloud& obj, std::size_t idx) : m_object(obj), m_index(idx) {} + Pixel3 operator * () const { return m_object.get_at(m_index); } + bool operator == (iterator const& it) const { return (&m_object == &it.m_object) && (m_index == it.m_index); } + bool operator != (iterator const& it) const { return (&m_object != &it.m_object) || (m_index != it.m_index); } + iterator& operator ++ () { ++m_index; return *this; } + private: + const OutOfRamVoxelCloud& m_object; + std::size_t m_index; + }; + iterator begin() const { return iterator(*this, 0); } + iterator end() const { return iterator(*this, size()); } + +private: + size_t n_items = 0; + std::string filepath; + FILE* pF = nullptr; + // One on-disk record = x,y,z (Pixel3's Point3i fields) + intensity + size_t item_size = sizeof(Pixel3::x) + sizeof(Pixel3::y) + sizeof(Pixel3::z) + sizeof(Pixel3::inten); + // Per-Z index built during population: first record index and record count of each plane + std::vector slab_first, slab_count; + void check_io_ok() const; + void read_range (size_t first, size_t count, std::vector& out) const; + + // Block read cache so linear consumers (get_at/operator[]/iterator, histogram) do one bulk + // fread per block instead of an fseek + 4 freads per voxel -- the latter made the out-of-core + // intensity/histogram passes ~25x slower than the in-RAM path. One block resident (bounded). + static const size_t BLK_RECORDS = 65536; + mutable std::vector blk_buf; + mutable long long blk_first = -1; // index of first cached record; -1 = cache empty + mutable size_t blk_count = 0; + void invalidate_cache() const { blk_first = -1; blk_count = 0; } + Pixel3 decode_record (const char* rec) const; +}; diff --git a/src/nyx/globals.h b/src/nyx/globals.h index 929384eea..f61704f55 100644 --- a/src/nyx/globals.h +++ b/src/nyx/globals.h @@ -18,6 +18,8 @@ #include "results_cache.h" #include "roi_cache.h" #include "save_option.h" + +class FeatureMethod; // forward decl for is_3d_ooc_supported() #include "arrow_output_stream.h" #include "nested_feature_aggregation.h" // Nested ROI #include "cli_nested_roi_options.h" @@ -88,6 +90,19 @@ namespace Nyxus bool processTrivialRois_25D (Environment & env, const std::vector& trivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath, size_t memory_limit, const std::vector& z_indices); bool processTrivialRois_3D (Environment & env, size_t sidx, size_t t_index, size_t channel, const std::vector& trivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath, size_t memory_limit); bool processNontrivialRois (Environment& env, const std::vector& nontrivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath); + bool processNontrivialRois_3D (Environment& env, const std::vector& nontrivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath, size_t channel, size_t timeframe); + // Allow-list of 3D feature classes whose osized_calculate() streams from the disk-backed voxel + // cloud; used by processNontrivialRois_3D()'s per-feature out-of-core guard, and independently + // unit-testable (see TEST_3D_OOC_GUARD_REJECTS_UNSUPPORTED_FEATURE). + bool is_3d_ooc_supported (FeatureMethod* f); + // Streams r.raw_voxels_NT from 'imlo' plane-by-plane; when wholevolume is true every voxel is + // kept (no mask), otherwise only voxels matching r.label. Returns false (cloud cleared) if the + // loader can't deliver the volume plane-by-plane. Shared by the segmented and whole-volume + // out-of-core paths (processNontrivialRois_3D, workflow_3d_whole.cpp's oversized branch). + bool populate_3d_voxel_cloud (ImageLoader& imlo, LR& r, size_t channel, size_t timeframe, bool wholevolume); + // Runs every requested feature's out-of-core path over an already-populated r.raw_voxels_NT, + // guarded by is_3d_ooc_supported(); writes into r.fvals via save_value(). Shared the same way. + void run_3d_ooc_features (Environment& env, LR& r, ImageLoader& imloader); bool scan_trivial_wholeslide (LR& vroi, const std::string& intens_fpath, ImageLoader& ldr); // reads pixels of whole slide 'intens_fpath' into virtual ROI 'vroi' bool scan_trivial_wholeslide_anisotropic (LR& vroi, const std::string& intens_fpath, ImageLoader& ldr, double aniso_x, double aniso_y); diff --git a/src/nyx/image_loader.cpp b/src/nyx/image_loader.cpp index 940e812aa..2b7c8d87b 100644 --- a/src/nyx/image_loader.cpp +++ b/src/nyx/image_loader.cpp @@ -346,6 +346,78 @@ bool ImageLoader::load_volume (size_t channel, size_t timeframe, size_t mask_tim return true; } +void ImageLoader::assemble_one_plane (AbstractTileLoader* fl, + std::shared_ptr>& ptr, + std::vector& dst_plane, size_t gz, size_t channel, size_t timeframe) +{ + // Per-plane loader (tileDepth==1): the global Z index IS the loader's layer, and the + // within-slab plane offset is 0. Mirrors assemble_volume's per-tile copy for one plane. + const size_t ltt = fl->tileTimestamps (lvl); + const size_t frameStride = th * tw; // tileDepth==1 + const size_t frameBase = (ltt > 1) ? timeframe * frameStride : 0; + const size_t lnth = fl->numberTileHeight (lvl); + const size_t lntw = fl->numberTileWidth (lvl); + + for (size_t tr = 0; tr < lnth; ++tr) + for (size_t tc = 0; tc < lntw; ++tc) + { + fl->loadTileFromFile (ptr, tr, tc, gz, channel, timeframe, lvl); + + const size_t row0 = tr * th, col0 = tc * tw; + if (row0 >= fh || col0 >= fw) + continue; + const size_t validH = (std::min) (th, fh - row0), + validW = (std::min) (tw, fw - col0); + + for (size_t row = 0; row < validH; ++row) + { + const size_t src = frameBase + row * tw; + const size_t d = (row0 + row) * fw + col0; + std::copy (ptr->begin() + src, ptr->begin() + src + validW, dst_plane.begin() + d); + } + } +} + +bool ImageLoader::stream_volume_planes (size_t channel, size_t timeframe, size_t mask_timeframe, + const std::function&, const std::vector&)>& sink) +{ + cur_channel = channel; + cur_timeframe = timeframe; + + // A whole-4D loader (NIfTI) hands over the entire cube in one read; there is nothing to + // stream plane-by-plane, so decline and let the caller fail loudly. + if (intFL->tileDepth (lvl) != 1) + return false; + if (segFL != nullptr && segFL->tileDepth (lvl) != 1) + return false; + + // Mask is usually channel-agnostic and single-timeframe; clamp like load_volume does + size_t mask_channel = 0, mask_tf = 0; + if (segFL != nullptr) + { + mask_channel = (channel < segFL->numberChannels()) ? channel : 0; + mask_tf = (mask_timeframe < segFL->fullTimestamps (lvl)) ? mask_timeframe : 0; + } + + const size_t sliceSize = (size_t) fw * fh; + std::vector intPlane (sliceSize), segPlane (segFL != nullptr ? sliceSize : 0); + + for (size_t gz = 0; gz < fd; ++gz) + { + std::fill (intPlane.begin(), intPlane.end(), 0u); + assemble_one_plane (intFL, ptrI, intPlane, gz, channel, timeframe); + + if (segFL != nullptr) + { + std::fill (segPlane.begin(), segPlane.end(), 0u); + assemble_one_plane (segFL, ptrL, segPlane, gz, mask_channel, mask_tf); + } + + sink (gz, intPlane, segPlane); + } + return true; +} + const std::vector& ImageLoader::get_int_tile_buffer() { return *ptrI; diff --git a/src/nyx/image_loader.h b/src/nyx/image_loader.h index 01acb8a1e..ca92c30b8 100644 --- a/src/nyx/image_loader.h +++ b/src/nyx/image_loader.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -32,6 +33,15 @@ class ImageLoader const std::vector& get_int_volume_buffer() const { return vol_int_; } const std::vector& get_seg_volume_buffer() const { return vol_seg_; } + // Stream the whole X*Y*Z volume for one (channel, timeframe) WITHOUT materializing it: + // one Z-plane is assembled into a reused buffer and handed to 'sink', so peak memory is + // two planes (intensity + mask) rather than the whole cube. This is what lets an oversized + // volumetric ROI be featurized out-of-core. Applies the same mask channel/timeframe clamp as + // load_volume (the mask is usually channel-agnostic). Returns false for a whole-4D loader + // (NIfTI, tileDepth>1) which delivers the entire cube in one read and cannot be streamed here. + bool stream_volume_planes (size_t channel, size_t timeframe, size_t mask_timeframe, + const std::function& int_plane, const std::vector& seg_plane)>& sink); + const std::vector& get_int_tile_buffer(); const std::vector& get_seg_tile_buffer(); const std::shared_ptr>& get_seg_tile_sptr(); @@ -96,5 +106,11 @@ class ImageLoader void assemble_volume (AbstractTileLoader* fl, std::shared_ptr>& ptr, std::vector& dst, size_t channel, size_t timeframe); + + // Assemble a single global Z-plane 'gz' (X*Y) into 'dst_plane' for a per-plane loader + // (tileDepth==1). The streaming counterpart of assemble_volume's inner plane fill. + void assemble_one_plane (AbstractTileLoader* fl, + std::shared_ptr>& ptr, + std::vector& dst_plane, size_t gz, size_t channel, size_t timeframe); }; diff --git a/src/nyx/omezarr.h b/src/nyx/omezarr.h index cc2763f33..1a0efd7b3 100644 --- a/src/nyx/omezarr.h +++ b/src/nyx/omezarr.h @@ -227,28 +227,40 @@ class NyxusOmeZarrLoader : public AbstractTileLoader if (pixel_col_index + data_width > full_width_) { data_width = full_width_ - pixel_col_index; } + // A chunk may span several Z-planes (tile_depth_ > 1); read its whole Z-extent in one + // subarray call, clamped at the last (possibly partial) chunk. + size_t data_depth = 1; + if (iz_ >= 0) { + data_depth = tile_depth_; + if (pixel_layer_index + data_depth > full_depth_) + data_depth = full_depth_ - pixel_layer_index; + } // Create a buffer to hold the read data - std::vector buffer(data_height * data_width); + std::vector buffer(data_height * data_width * data_depth); // Build the read window by axis ROLE (honoring the resolved 'axes' order), - // reading one Y*X plane at the requested Z/C/T. z5 3.0.1 uses ArrayView. + // reading a Z*Y*X block (data_depth Z-planes) at the requested C/T. z5 3.0.1 uses ArrayView. z5::types::ShapeType shape(ndim_, 1), offset(ndim_, 0); shape[iy_] = data_height; offset[iy_] = pixel_row_index; shape[ix_] = data_width; offset[ix_] = pixel_col_index; - if (iz_ >= 0) offset[iz_] = pixel_layer_index; + if (iz_ >= 0) { shape[iz_] = data_depth; offset[iz_] = pixel_layer_index; } if (ic_ >= 0) offset[ic_] = pixel_channel_index; if (it_ >= 0) offset[it_] = pixel_timeframe_index; auto view = z5::multiarray::makeView(buffer.data(), shape); - + // Read subarray from the cached z5 dataset z5::multiarray::readSubarray(*ds_, view, offset.begin()); - - // Copy from buffer to destination tile, handling partial tiles - for (size_t k = 0; k < data_height; ++k) { - std::copy(buffer.begin() + k * data_width, - buffer.begin() + (k + 1) * data_width, - dest->begin() + k * tile_width_); + + // Copy from buffer to destination tile, handling partial tiles and multiple Z-planes + // (dest is laid out plane-major: plane p, row k at offset (p*tile_height_ + k)*tile_width_, + // matching ImageLoader::assemble_volume's/RawImageLoader::for_each_voxel's expected stride). + for (size_t p = 0; p < data_depth; ++p) { + for (size_t k = 0; k < data_height; ++k) { + std::copy(buffer.begin() + (p * data_height + k) * data_width, + buffer.begin() + (p * data_height + k) * data_width + data_width, + dest->begin() + (p * tile_height_ + k) * tile_width_); + } } } diff --git a/src/nyx/phase3.cpp b/src/nyx/phase3.cpp index 8650a1877..86201fd5e 100644 --- a/src/nyx/phase3.cpp +++ b/src/nyx/phase3.cpp @@ -99,7 +99,9 @@ namespace Nyxus try { const Fsettings& s = env.get_feature_settings (typeid(f)); - f->osized_scan_whole_image (r, s, env.theImLoader); + // Pass the Dataset so intensity/histogram osized features reach their + // Dataset-aware osized_calculate; the Dataset-less overload is a guard that throws. + f->osized_scan_whole_image (r, s, env.dataset, env.theImLoader); } catch (std::exception const& e) { diff --git a/src/nyx/phase3_3d.cpp b/src/nyx/phase3_3d.cpp new file mode 100644 index 000000000..05971252f --- /dev/null +++ b/src/nyx/phase3_3d.cpp @@ -0,0 +1,187 @@ +#include +#include + +#ifdef WITH_PYTHON_H + #include +#endif + +#include "environment.h" +#include "feature_mgr.h" +#include "globals.h" +#include "features/3d_intensity.h" +#include "features/3d_surface.h" +#include "features/3d_glcm.h" +#include "features/3d_gldm.h" +#include "features/3d_ngldm.h" +#include "features/3d_ngtdm.h" +#include "features/3d_glrlm.h" +#include "features/3d_glszm.h" +#include "features/3d_gldzm.h" +#include "features/pixel.h" +#include "helpers/timing.h" + +namespace Nyxus +{ + /// @brief Returns true if 'f' is one of the 3D feature classes whose osized_calculate() streams + /// from the disk-backed voxel cloud (r.raw_voxels_NT) instead of reading the in-memory cube. + /// Every other 3D FeatureMethod (e.g. a future feature added without a streaming path) would + /// silently read an empty cube if allowed through, so processNontrivialRois_3D()'s per-feature + /// guard calls this and fails loudly on false. Kept as a standalone function (rather than inline + /// in the guard) so the allow-list itself is unit-testable independent of the streaming plumbing. + bool is_3d_ooc_supported (FeatureMethod* f) + { + return dynamic_cast(f) != nullptr + || dynamic_cast(f) != nullptr + || dynamic_cast(f) != nullptr + || dynamic_cast(f) != nullptr + || dynamic_cast(f) != nullptr + || dynamic_cast(f) != nullptr + || dynamic_cast(f) != nullptr + || dynamic_cast(f) != nullptr + || dynamic_cast(f) != nullptr; + } + + /// @brief Populates r.raw_voxels_NT by streaming the volume plane-by-plane. When 'wholevolume' + /// is false (the segmented-ROI case), only voxels whose mask value equals r.label are kept -- + /// segPlane must be non-empty (a real mask loader). When 'wholevolume' is true, every voxel is + /// kept regardless of any mask (there is none: workflow_3d_whole.cpp opens the loader with an + /// empty label path, so ImageLoader::stream_volume_planes's segPlane is empty in that case). + /// Shared by the segmented (processNontrivialRois_3D) and whole-volume out-of-core paths so + /// both stream through the exact same primitive. Returns false (cloud cleared) if the loader + /// can't deliver the volume plane-by-plane (e.g. NIfTI, a whole-4D-in-one-read format). + bool populate_3d_voxel_cloud (ImageLoader& imlo, LR& r, size_t channel, size_t timeframe, bool wholevolume) + { + r.raw_voxels_NT.init (r.label, "raw_voxels_NT"); + + const size_t W = imlo.get_full_width(), + H = imlo.get_full_height(); + const uint32_t want = (uint32_t) r.label; + + bool streamed = imlo.stream_volume_planes (channel, timeframe, timeframe, + [&](size_t z, const std::vector& intPlane, const std::vector& segPlane) + { + r.raw_voxels_NT.begin_slab (z); + for (size_t y = 0; y < H; y++) + for (size_t x = 0; x < W; x++) + { + size_t i = y * W + x; + if (wholevolume || segPlane[i] == want) + r.raw_voxels_NT.add_voxel (Pixel3(x, y, z, intPlane[i])); + } + r.raw_voxels_NT.end_slab (z); + }); + + if (! streamed) + r.raw_voxels_NT.clear(); + return streamed; + } + + /// @brief Runs every requested feature's out-of-core path over an already-populated + /// r.raw_voxels_NT, guarded by is_3d_ooc_supported(); writes results into r.fvals via + /// save_value(). Shared by the segmented and whole-volume out-of-core paths. 'imloader' is + /// passed through to match FeatureMethod's signature but is not read by any of the streaming + /// 3D features (they read raw_voxels_NT exclusively), so either path's own loader instance works. + void run_3d_ooc_features (Environment& env, LR& r, ImageLoader& imloader) + { + int nrf = env.theFeatureMgr.get_num_requested_features(); + for (int i = 0; i < nrf; i++) + { + auto f = env.theFeatureMgr.get_feature_method (i); + + try + { + // Every 3D feature that streams from raw_voxels_NT is covered by + // is_3d_ooc_supported(); anything else would silently read an empty cube, so fail + // loudly per-feature instead of emitting wrong values. Under the CLI this logs and + // moves on (the supported features still compute); under Python it raises. + if (! is_3d_ooc_supported (f)) + throw std::runtime_error("feature '" + f->feature_info + + "' is not yet supported out-of-core for oversized 3D ROIs; " + + "segment into smaller ROIs, raise --ramLimit, or add RAM"); + + const Fsettings& s = env.get_feature_settings (typeid(f)); + f->osized_scan_whole_image (r, s, env.dataset, imloader); + } + catch (std::exception const& e) + { + std::string erm = "Error while computing feature " + f->feature_info + " over oversized 3D ROI " + std::to_string(r.label) + " : " + e.what(); +#ifdef WITH_PYTHON_H + throw std::runtime_error(erm); +#endif + std::cerr << erm << "\n"; + } + + f->cleanup_instance(); + } + } + + /// @brief Processes oversized volumetric (3D) ROIs out-of-core: streams the voxel cloud to + /// disk one Z-plane at a time (bounded memory) instead of holding the whole cube. + /// @param nontrivRoiLabels Labels of ROIs whose in-memory footprint exceeds the RAM limit + /// @param intens_fpath Intensity image path + /// @param label_fpath Mask image path + /// @param channel Intensity channel being featurized + /// @param timeframe Timeframe being featurized (used for both intensity and mask) + /// @return Success status + /// + bool processNontrivialRois_3D (Environment& env, const std::vector& nontrivRoiLabels, const std::string& intens_fpath, const std::string& label_fpath, size_t channel, size_t timeframe) + { + // Sort labels for reproducibility with the trivial counterpart + auto L = nontrivRoiLabels; + std::sort (L.begin(), L.end()); + + for (auto lab : L) + { + LR& r = env.roiData[lab]; + + VERBOSLVL1 (env.get_verbosity_level(), std::cout << "processing oversized 3D ROI " << lab << "\n"); + + // Scan one label-intensity pair + SlideProps p (intens_fpath, label_fpath); + // Preserve the scanned HU-domain slide min/max + flag so load-time HU offset matches the + // prescan (bare SlideProps defaults min to -1). Guarded on preserve_hu so the default path + // is unchanged. Mirrors the 2D processNontrivialRois. + if (env.fpimageOptions.preserve_hu() && r.slide_idx >= 0) + { + const SlideProps& scanned = env.dataset.dataset_props [r.slide_idx]; + p.min_preroi_inten = scanned.min_preroi_inten; + p.max_preroi_inten = scanned.max_preroi_inten; + p.preserve_hu = scanned.preserve_hu; + } + if (! env.theImLoader.open(p, env.fpimageOptions)) + { + std::cout << "Terminating\n"; + return false; + } + + // Populate the ROI's disk-backed voxel cloud by streaming the volume plane-by-plane. + // Only this ROI's label voxels are written; z is preserved. Peak memory is two X*Y + // planes (intensity + mask) plus this plane's ROI voxels, never the whole cube. + if (! populate_3d_voxel_cloud (env.theImLoader, r, channel, timeframe, /*wholevolume=*/ false)) + { + std::string erm = "Error: out-of-core featurization of oversized 3D ROI " + std::to_string(r.label) + + " is not supported for this input format (the volume is not delivered plane-by-plane). " + + "Segment into smaller ROIs, raise --ramLimit, or add RAM."; +#ifdef WITH_PYTHON_H + throw std::runtime_error(erm); +#endif + std::cerr << erm << "\n"; + continue; + } + + //=== Reduce features over the streamed voxel cloud + run_3d_ooc_features (env, r, env.theImLoader); + + //=== Clean the ROI's cache + r.raw_voxels_NT.clear(); + + #ifdef WITH_PYTHON_H + // Allow keyboard interrupt + if (PyErr_CheckSignals() != 0) + throw pybind11::error_already_set(); + #endif + } + + return true; + } +} diff --git a/src/nyx/raw_omezarr.h b/src/nyx/raw_omezarr.h index 9c5dd42fc..c795dea33 100644 --- a/src/nyx/raw_omezarr.h +++ b/src/nyx/raw_omezarr.h @@ -137,8 +137,9 @@ class RawOmezarrLoader: public RawFormatLoader else if (dtype_str == " (tile_height_ * tile_width_); + // The buffer holds the full chunk depth (tile_depth_ planes), so callers iterating + // pz in [0, tileDepth()) index valid data. + dest = std::vector (tile_height_ * tile_width_ * tile_depth_); } ~RawOmezarrLoader() override @@ -224,30 +225,42 @@ class RawOmezarrLoader: public RawFormatLoader if (pixel_col_index + data_width > full_width_) { data_width = full_width_ - pixel_col_index; } + // A chunk may span several Z-planes (tile_depth_ > 1); read its whole Z-extent in one + // subarray call, clamped at the last (possibly partial) chunk. + size_t data_depth = 1; + if (iz_ >= 0) { + data_depth = tile_depth_; + if (pixel_layer_index + data_depth > full_depth_) + data_depth = full_depth_ - pixel_layer_index; + } // Create a buffer to hold the read data - std::vector buffer(data_height * data_width); + std::vector buffer(data_height * data_width * data_depth); // Build the read window by axis ROLE (honoring the resolved 'axes' order), - // reading one Y*X plane at the requested Z/C/T. z5 3.0.1 uses ArrayView. + // reading a Z*Y*X block (data_depth Z-planes) at the requested C/T. z5 3.0.1 uses ArrayView. z5::types::ShapeType shape(ndim_, 1), offset(ndim_, 0); shape[iy_] = data_height; offset[iy_] = pixel_row_index; shape[ix_] = data_width; offset[ix_] = pixel_col_index; - if (iz_ >= 0) offset[iz_] = pixel_layer_index; + if (iz_ >= 0) { shape[iz_] = data_depth; offset[iz_] = pixel_layer_index; } if (ic_ >= 0) offset[ic_] = pixel_channel_index; if (it_ >= 0) offset[it_] = pixel_timeframe_index; auto view = z5::multiarray::makeView(buffer.data(), shape); - + // Read subarray from the cached z5 dataset z5::multiarray::readSubarray(*ds_, view, offset.begin()); - + // zero-fill the buffer foreseeing its partial filling at incomplete (tail) tiles std::fill(dest.begin(), dest.end(), 0); - - // Copy from buffer to destination tile, handling partial tiles and type conversion - for (size_t k = 0; k < data_height; ++k) { - for (size_t j = 0; j < data_width; ++j) { - dest[k * tile_width_ + j] = static_cast(buffer[k * data_width + j]); + + // Copy from buffer to destination tile, handling partial tiles, multiple Z-planes and + // type conversion (dest is plane-major: plane p, row k at (p*tile_height_+k)*tile_width_). + for (size_t p = 0; p < data_depth; ++p) { + for (size_t k = 0; k < data_height; ++k) { + for (size_t j = 0; j < data_width; ++j) { + dest[(p * tile_height_ + k) * tile_width_ + j] = + static_cast(buffer[(p * data_height + k) * data_width + j]); + } } } } diff --git a/src/nyx/roi_cache.h b/src/nyx/roi_cache.h index e01b6ac43..6116fada5 100644 --- a/src/nyx/roi_cache.h +++ b/src/nyx/roi_cache.h @@ -7,6 +7,7 @@ #include "features/aabb.h" #include "features/image_matrix.h" #include "features/image_matrix_nontriv.h" +#include "features/voxel_cloud_nontriv.h" #include "features/image_cube.h" #include "features/pixel.h" #include "featureset.h" @@ -50,6 +51,9 @@ class LR: public BasicLR std::unordered_map> zplanes; OutOfRamPixelCloud raw_pixels_NT; + // Disk-backed 3D voxel source for oversized volumetric ROIs (populated by the 3D + // out-of-core scan). Keeps z, unlike raw_pixels_NT which is the 2D (z-less) cloud. + OutOfRamVoxelCloud raw_voxels_NT; unsigned int aux_area = 0; PixIntens aux_min, aux_max; diff --git a/src/nyx/workflow_3d_segmented.cpp b/src/nyx/workflow_3d_segmented.cpp index 3b382d607..f749f53e9 100644 --- a/src/nyx/workflow_3d_segmented.cpp +++ b/src/nyx/workflow_3d_segmented.cpp @@ -131,7 +131,9 @@ namespace Nyxus if (nontrivRois.size()) { VERBOSLVL2 (env.get_verbosity_level(), std::cout << "Processing oversized ROIs\n";) - processNontrivialRois (env, nontrivRois, intens_fpath, label_fpath); + // Volumetric out-of-core path: streams the voxel cloud to disk keeping z (the shared 2D + // processNontrivialRois scans a single plane and drops z). t_index/channel select the plane. + processNontrivialRois_3D (env, nontrivRois, intens_fpath, label_fpath, channel, t_index); } return true; diff --git a/src/nyx/workflow_3d_whole.cpp b/src/nyx/workflow_3d_whole.cpp index 0ff28ca08..c2fbc06d0 100644 --- a/src/nyx/workflow_3d_whole.cpp +++ b/src/nyx/workflow_3d_whole.cpp @@ -37,7 +37,11 @@ namespace Nyxus const std::string& ifpath = env.dataset.dataset_props[sidx].fname_int; // can we process this slide ? - size_t footp = vroi.get_ram_footprint_estimate (1); // 1 since single-ROI + // FIX: use the 3D estimator (W*H*D). The 2D one counts only W*H for the image matrix, + // so it under-counts a whole VOLUME's cube by a factor of the depth -> the oversized + // check let too-large volumes through and they OOM'd. The segmented path already uses + // get_ram_footprint_estimate_3D; match it. (Found by running under a hard memory cap.) + size_t footp = vroi.get_ram_footprint_estimate_3D (1); // 1 since single-ROI if (footp > memory_limit) { std::string erm = "Error: cannot process slide " + ifpath + " , reason: its memory footprint " + virguler_ulong(footp) + " exceeds available memory " + virguler_ulong(memory_limit); @@ -117,22 +121,42 @@ namespace Nyxus vroi.initialize_fvals(); // assess ROI's memory footprint and check if we can featurize it as phase 2 (trivially) ? - size_t roiFootprint = vroi.get_ram_footprint_estimate (1), // 1 since single-ROI + // FIX: 3D estimator (W*H*D) -- the 2D one ignores depth and under-counts the volume cube, + // so oversized volumes slipped through and OOM'd. Matches the segmented path. + size_t roiFootprint = vroi.get_ram_footprint_estimate_3D (1), // 1 since single-ROI ramLim = env.get_ram_limit(); if (roiFootprint >= ramLim) { - VERBOSLVL2(env.get_verbosity_level(), - std::cout << "oversized slide " + VERBOSLVL1(env.get_verbosity_level(), + std::cout << "oversized whole volume " << " (S=" << vroi.aux_area << " W=" << vroi.aabb.get_width() << " H=" << vroi.aabb.get_height() << " D=" << vroi.aabb.get_z_depth() << " px footprint=" << Nyxus::virguler_ulong(roiFootprint) << " b" - << ") while RAM limit is " << Nyxus::virguler_ulong(ramLim) << "\n" + << ") while RAM limit is " << Nyxus::virguler_ulong(ramLim) + << " -- streaming out-of-core\n" ); - std::cerr << p.fname_int << ": slide is non-trivial \n"; - return false; + // Out-of-core whole volume: stream every voxel (no mask -- workflow_3d_whole.cpp opens + // the loader with an empty label path) plane-by-plane instead of holding the whole + // cube, mirroring the segmented ROI path (processNontrivialRois_3D). Only fails when + // the input format can't deliver the volume plane-by-plane (e.g. NIfTI). + if (! populate_3d_voxel_cloud (imlo, vroi, channel, timeframe, /*wholevolume=*/ true)) + { + std::string erm = "Error: cannot featurize whole volume " + p.fname_int + + " out-of-core: this input format does not deliver the volume plane-by-plane. " + + "Segment into smaller ROIs, raise --ramLimit, or add RAM."; +#ifdef WITH_PYTHON_H + throw std::runtime_error(erm); +#endif + std::cerr << erm << "\n"; + return false; + } + + run_3d_ooc_features (env, vroi, imlo); + vroi.raw_voxels_NT.clear(); + return true; } //***** phase 2: extract features @@ -141,7 +165,11 @@ namespace Nyxus return true; } - void featurize_3d_wv_thread( + // FIX: return the per-thread status by VALUE (through the future) rather than writing a + // referenced int. A std::async worker's write through std::ref(int) is not reliably visible + // to the caller after future::get() here, so an oversized slide's rv=1 was silently lost and + // the run reported success. Returning it makes future::get() carry the status deterministically. + int featurize_3d_wv_thread( Environment & env, const std::vector& intensFiles, const std::vector& labelFiles, @@ -149,9 +177,9 @@ namespace Nyxus size_t nf, const std::string& outputPath, bool write_apache, - Nyxus::SaveOption saveOption, - int& rv) + Nyxus::SaveOption saveOption) { + int rv = 0; SlideProps& p = env.dataset.dataset_props[slide_idx]; // scan one slide @@ -172,8 +200,16 @@ namespace Nyxus if (featurize_wholevolume (env, slide_idx, imlo, vroi, c, t) == false) // non-wsi counterpart: processIntSegImagePair() { - std::cerr << "Error featurizing slide " << p.fname_int << " @ " << __FILE__ << ":" << __LINE__ << "\n"; + // An oversized whole volume now streams out-of-core (see featurize_wholevolume's + // oversized branch), so featurize_wholevolume returns false only when that streaming + // itself failed -- either the input format can't deliver the volume plane-by-plane + // (e.g. NIfTI), or a requested feature isn't yet OOC-supported. That specific reason + // was already reported (thrown under Python, printed to stderr under the CLI) from + // inside featurize_wholevolume/run_3d_ooc_features. Previously we ALSO fell through + // to save the zero-initialized buffer here -- emitting a row of all-0 features, i.e. + // silently-wrong data. Skip the save instead: fail loudly, write no row. rv = 1; + continue; // do NOT emit a (misleading all-zero) row for this plane } // thread-safely save results of this single (slide, channel, timeframe) @@ -207,6 +243,7 @@ namespace Nyxus } //- channels x timeframes imlo.close(); + return rv; // // Not saving nested ROI related info because this image is single-ROI (whole-slide) @@ -285,13 +322,15 @@ namespace Nyxus // run batches of threads + int worst_rv = 0; // FIX: aggregate per-thread failure so an oversized/failed slide + // makes the whole run fail loudly (nonzero exit) instead of returning + // success -- the per-thread rvals were collected but never checked. size_t n_jobs = (nf + n_threads - 1) / n_threads; for (size_t j = 0; j < n_jobs; j++) { VERBOSLVL1 (env.get_verbosity_level(), std::cout << "whole-slide job " << j + 1 << "/" << n_jobs << "\n"); - std::vector> T; - std::vector rvals(n_threads, 0); + std::vector> T; for (int t = 0; t < n_threads; t++) { size_t idx = j * n_threads + t; @@ -311,12 +350,12 @@ namespace Nyxus nf, outputPath, write_apache, - saveOption, - std::ref(rvals[t]))); + saveOption)); } else { - featurize_3d_wv_thread( + // FIX: aggregate the returned status (was written through a lost reference) + int r = featurize_3d_wv_thread( env, intensFiles, intensFiles, @@ -324,14 +363,18 @@ namespace Nyxus nf, outputPath, write_apache, - saveOption, - rvals[t]); + saveOption); + if (r > worst_rv) worst_rv = r; } } - // wait for all threads to complete before proceeding + // wait for all threads to complete, aggregating each one's returned status so an + // oversized/failed slide makes the whole run fail loudly (nonzero exit) for (auto& f : T) - f.get(); + { + int r = f.get(); + if (r > worst_rv) worst_rv = r; + } // allow keyboard interrupt #ifdef WITH_PYTHON_H @@ -361,6 +404,15 @@ namespace Nyxus // future: free GPU cache for all participating devices // + // FIX: a slide that could not be featurized is a hard failure, not a silent success -- + // report it so the CLI exits nonzero and the Python API raises. An oversized whole volume + // streams out-of-core now (see featurize_wholevolume), so this only fires when that + // streaming itself failed (input format can't deliver plane-by-plane, or an unsupported + // feature was requested) -- the specific reason was already reported above. + if (worst_rv != 0) + return { false, "one or more slides could not be featurized (see errors above; " + "an unsupported format needs a segmented mask, or a smaller/plane-deliverable input)" }; + return {true, std::nullopt}; // success } diff --git a/src/nyx/workflow_pythonapi.cpp b/src/nyx/workflow_pythonapi.cpp index 33dcd4035..625359d7b 100644 --- a/src/nyx/workflow_pythonapi.cpp +++ b/src/nyx/workflow_pythonapi.cpp @@ -33,7 +33,10 @@ namespace Nyxus { bool scan_slide_props_montage(SlideProps& p, int dim, const AnisotropyOptions& aniso); - bool processIntSegImagePairInMemory (Environment & env, const py::array_t& intens, const py::array_t& label, int pair_index, const std::string& intens_name, const std::string& seg_name, std::vector unprocessed_rois) + // unprocessed_rois is an OUT parameter: the in-memory montage pipeline has no out-of-core + // path, so ROIs whose footprint reaches the RAM limit are returned to the caller to be + // reported as an error, instead of being dropped and emitting an all-zero feature row. + bool processIntSegImagePairInMemory (Environment & env, const py::array_t& intens, const py::array_t& label, int pair_index, const std::string& intens_name, const std::string& seg_name, std::vector& unprocessed_rois) { std::vector trivRoiLabels; diff --git a/tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/0/0/0/0 b/tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/0/0/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..45d5baea3b5b063e0340e21cd61921b48252b5c5 GIT binary patch literal 3588 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 Q{@*J2&kDcaF8|;E0{|effB*mh literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/0/0/0/1 b/tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/0/0/0/1 new file mode 100644 index 0000000000000000000000000000000000000000..404d4508b587102189aaf661f257d47feb5cb6d0 GIT binary patch literal 3588 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 N7ALd%=s*8X{THW?sTH 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 GfBy%AzCe!v literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/1/0/0/1 b/tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/1/0/0/1 new file mode 100644 index 0000000000000000000000000000000000000000..56392de8b24f2ede5a12185284816174064a74ca GIT binary patch literal 3588 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 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/1/0/1/0 b/tests/data/omezarr/dim5_v3_multishard.ome.zarr/0/c/0/1/0/1/0 new file mode 100644 index 0000000000000000000000000000000000000000..bc4ed732bc9fd9395afc81e66453526b255d90c0 GIT binary patch literal 3588 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 literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/0/1/0/0 b/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/0/1/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..8367b807b964ef802116ab84649257b8a44c86bf GIT binary patch literal 288 zcmV~$hawOF00qE%EooA;w0=3!kdh=7B`SpW&`v`rg+zsxh<10ol-sRBr9JlAXTK^3 zR6FR98i&<7qRvtEj%je*2`4pba>{AV&S=row{_p=#tB>xaykgZn)`| z9=F|b*FE<=@X#Z@9(&@cXZk$XZ@{1zUK;YsYr{s2dSlF6<0icG-UpMWO#A4Q&%XF- z#y7L(d^c~w4?q3#+aG`ZBgvvH&XO$6vJ_-_3bP_Bvns2zCTmla;;hU1l%zBpvN4;o QIa^Yet=X3C*^!<34+$7p>Hq)$ literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/0/2/0/0 b/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/0/2/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..466ed4ddd65c60280e324a56a2e91d4f11f19adc GIT binary patch literal 288 zcmdVH2Lgcr002Pu6d9Eg%9fCs{r}f^hr6~wM^{f@C=we;q=qshV-vZ;)Xdz%(#qOK bX{)lccThVzIlH*JY1}-^U8c literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/1/0/0/0 b/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/1/0/0/0 new file mode 100644 index 000000000..8fa9f3f9a --- /dev/null +++ b/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/1/0/0/0 @@ -0,0 +1 @@ +QRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ \ No newline at end of file diff --git a/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/1/1/0/0 b/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/1/1/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..ba5e40e9e4d122f31ebb241c0b60027119bf1234 GIT binary patch literal 288 zcmV~$he8Md00ltrWGk{o8I{behOBHFMs}f4lw|yJR7#RW5yjnLKICJDGMtfo%4o*& zIpg_~iG0muzGW)kGo2s#nVHPySAJ(MfATl;S;)UE5-e6=iKUiVZiSUrS*=i!HP$M& z&Uzbcw8>^$Y*k{L?Mju|VW(YoD_5b?9#!_*XTJjuI;7fRM;vv`aW!hysdqwylTJD9 zjI$b@bKV6PU2@qKS6$PjS<J-Eh+_ZEm~cu6yo#;GuRM9(nAEPF literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/1/2/0/0 b/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/c/0/1/2/0/0 new file mode 100644 index 0000000000000000000000000000000000000000..a49e44443422bd85bd63b26bc1ba77af0c1ec0d2 GIT binary patch literal 288 zcmdVH2@-(-002O7jFu-g{s=<4YUMPdVq)KF$*Y$8{fnweWzT3OpD bZB=&m4r)gyXBSsDjk|}Zm$#3v)}Q+UET|dz literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/zarr.json b/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/zarr.json new file mode 100644 index 000000000..32c56dcb8 --- /dev/null +++ b/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/0/zarr.json @@ -0,0 +1,41 @@ +{ + "shape": [ + 1, + 2, + 7, + 6, + 8 + ], + "data_type": "uint16", + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [ + 1, + 1, + 3, + 6, + 8 + ] + } + }, + "chunk_key_encoding": { + "name": "default", + "configuration": { + "separator": "/" + } + }, + "fill_value": 0, + "codecs": [ + { + "name": "bytes", + "configuration": { + "endian": "little" + } + } + ], + "attributes": {}, + "zarr_format": 3, + "node_type": "array", + "storage_transformers": [] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/zarr.json b/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/zarr.json new file mode 100644 index 000000000..ea6be0fcd --- /dev/null +++ b/tests/data/omezarr/dim5_v3_zchunked.ome.zarr/zarr.json @@ -0,0 +1,52 @@ +{ + "attributes": { + "ome": { + "version": "0.5", + "multiscales": [ + { + "axes": [ + { + "name": "t", + "type": "time" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space" + }, + { + "name": "y", + "type": "space" + }, + { + "name": "x", + "type": "space" + } + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + } + ] + } + ] + } + }, + "zarr_format": 3, + "node_type": "group" +} \ No newline at end of file diff --git a/tests/data/omezarr/gen_dim5.py b/tests/data/omezarr/gen_dim5.py index aa42dfb4f..254f0305f 100644 --- a/tests/data/omezarr/gen_dim5.py +++ b/tests/data/omezarr/gen_dim5.py @@ -171,6 +171,83 @@ def write_v3_sharded(name, order): print("wrote %-24s (Zarr v3 SHARDED, inner=%s shard=%s)" % (name, inner, shards)) +def write_v3_multishard(name): + """A LARGER Zarr v3 sharded store exercising MULTIPLE SPATIAL SHARDS per (c,t) plane-volume -- + the existing dim5_v3_sharded fixture has exactly one shard per (t,c) (the shard's y/x extent + equals the full plane), so it only ever proves multiple INNER CHUNKS packed into one shard + file. This fixture instead splits each (t,c) volume into a 2x2 grid of shards across Y and X, + so the volumetric assembly must cross shard-FILE boundaries mid-plane (not just inner-chunk + boundaries within one shard) -- closer to how a real, larger Axle v3 store is laid out. + + Own local dims (not the module T/C/Z/Y/X): C=2, T=1, Z=8, Y=24, X=32. + Inner chunk (z,y,x)=(1,6,8) -- z-chunk MUST stay 1: this codebase's OME-Zarr readers + (raw_omezarr.h/omezarr.h) read exactly one Z-plane per tile regardless of what + tileDepth() reports (see this file's module docstring: "chunked one z-slice per + chunk"); a z-chunk>1 silently under-reads (proven while building this fixture -- + a z=2 inner chunk read only its first z-plane and left the rest zero). That is a + separate, real chunking-granularity gap from the shard-FILE-crossing behavior this + fixture targets, so it's dodged here rather than fixed as a drive-by. + Shard (z,y,x)=(8,12,16) -> covers the full Z depth (all 8 inner z-chunks) but HALF of + Y and HALF of X -> a 2x2 shard grid per (c,t), each shard packing 8*(12/6)*(16/8)=32 + inner chunks. Total: C*T*2*2 = 8 shard FILES, and the volumetric assembly must cross + shard-FILE boundaries mid-plane (not just inner-chunk boundaries within one shard). + Same coordinate encoding as the rest of this file: value(x,y,z,c,t) = 1 + ((((t*C+c)*Z+z)*Y+y)*X+x). + """ + path = os.path.join(HERE, name) + shutil.rmtree(path, ignore_errors=True) + + C, T, Z, Y, X = 2, 1, 8, 24, 32 + t, c, z, y, x = np.meshgrid( + np.arange(T), np.arange(C), np.arange(Z), np.arange(Y), np.arange(X), indexing="ij") + data = (1 + ((((t * C + c) * Z + z) * Y + y) * X + x)).astype("uint16") # [t,c,z,y,x] + + inner = (1, 1, 1, 6, 8) + shards = (1, 1, 8, 12, 16) + + g = zarr.open_group(path, mode="w", zarr_format=3) + a = g.create_array("0", shape=data.shape, dtype=data.dtype, + chunks=inner, shards=shards, compressors=None) + a[:] = data + g.attrs["ome"] = {"version": "0.5", + "multiscales": [{"axes": [_AX[ax] for ax in "tczyx"], + "datasets": [{"path": "0", + "coordinateTransformations": [{"type": "scale", "scale": [1.0] * 5}]}]}]} + print("wrote %-24s (Zarr v3 MULTI-SHARD: C=%d T=%d Z=%d Y=%d X=%d, inner=%s shard=%s, %dx%d shard grid per c,t)" + % (name, C, T, Z, Y, X, inner, shards, -(-Y // shards[3]), -(-X // shards[4]))) + + +def write_v3_zchunked(name): + """A Zarr v3 store with a MULTI-PLANE Z chunk (chunk z-extent > 1), unsharded -- isolates the + Z-chunking behavior from shard-file addressing (see write_v3_multishard for that). Every other + fixture in this file chunks exactly one Z-slice per chunk (see the module docstring); this one + deliberately doesn't, to catch readers that silently under-read past the first Z-plane of a + multi-plane chunk. + + Own local dims: C=2, T=1, Z=7, Y=6, X=8. Chunk (z,y,x)=(3,6,8) -> 3 Z-chunks of depth 3,3,1 -- + the UNEVEN split (Z=7 not a multiple of 3) also exercises the partial last-chunk clamp. + Same coordinate encoding as the rest of this file: value(x,y,z,c,t) = 1 + ((((t*C+c)*Z+z)*Y+y)*X+x). + """ + path = os.path.join(HERE, name) + shutil.rmtree(path, ignore_errors=True) + + C, T, Z, Y, X = 2, 1, 7, 6, 8 + t, c, z, y, x = np.meshgrid( + np.arange(T), np.arange(C), np.arange(Z), np.arange(Y), np.arange(X), indexing="ij") + data = (1 + ((((t * C + c) * Z + z) * Y + y) * X + x)).astype("uint16") # [t,c,z,y,x] + + chunks = (1, 1, 3, 6, 8) + + g = zarr.open_group(path, mode="w", zarr_format=3) + a = g.create_array("0", shape=data.shape, dtype=data.dtype, chunks=chunks, compressors=None) + a[:] = data + g.attrs["ome"] = {"version": "0.5", + "multiscales": [{"axes": [_AX[ax] for ax in "tczyx"], + "datasets": [{"path": "0", + "coordinateTransformations": [{"type": "scale", "scale": [1.0] * 5}]}]}]} + print("wrote %-24s (Zarr v3 Z-CHUNKED: C=%d T=%d Z=%d Y=%d X=%d, chunk=%s -> Z-chunk depths 3,3,1)" + % (name, C, T, Z, Y, X, chunks)) + + def write_label_mask(name): """P4/N3: a single-channel ZYX label mask (mostly background, a few small ROIs), the OME-Zarr twin of dim3_mask.ome.tif. Pairs with a T>1 Zarr intensity to exercise the @@ -247,6 +324,11 @@ def main(): # bytes+blosc v3 pipeline when built WITH_BLOSC (already required for OME-Zarr). write_v3("dim5_v3_blosc.ome.zarr", "tczyx", compressors=[BloscCodec(cname="lz4", clevel=5, shuffle="shuffle")]) + # Larger multi-shard store: a 2x2 grid of SHARD FILES per (c,t), not just multiple inner + # chunks within one shard -- exercises crossing shard-file boundaries mid-plane. + write_v3_multishard("dim5_v3_multishard.ome.zarr") + # multi-plane Z chunk (chunk z-extent 3, uneven split 3+3+1) -- isolated Z-chunking check + write_v3_zchunked("dim5_v3_zchunked.ome.zarr") # plane split across a 2x2 chunk grid (3x4 chunks over the 6x8 plane) -> multi-tile assembly write_multichunk("dim5_multichunk.ome.zarr", "tczyx", 3, 4) # PARTIAL edge chunks: chunk (4,5) does NOT divide the 6x8 plane, so the last row-chunk is diff --git a/tests/python/test_ooc_mechanics.py b/tests/python/test_ooc_mechanics.py new file mode 100644 index 000000000..3056627fa --- /dev/null +++ b/tests/python/test_ooc_mechanics.py @@ -0,0 +1,316 @@ +"""Mechanics tests for the out-of-core (oversized-ROI) path. + +An ROI whose memory footprint reaches ram_limit is streamed through the disk-backed +OutOfRamPixelCloud instead of an in-memory pixel vector, and its features are computed by +the osized_calculate path. These tests exercise that plumbing (not an external oracle): + +- test_ooc_2d_matches_in_ram: the out-of-core path must produce the same intensity features + as the in-RAM path on the same file pair. +- test_ooc_montage_oversized_fails_loudly: the in-memory montage path has no out-of-core + support and must raise on an oversized ROI rather than emit a silent all-zero row. + +ram_limit is a process-global in Nyxus, so each test sets it explicitly on both sides to stay +order-independent. +""" +import os +import pathlib +import numpy as np +import pytest + +import nyxus + +tifffile = pytest.importorskip("tifffile") + +DATA_NIFTI = pathlib.Path(__file__).resolve().parent.parent / "data" / "nifti" + + +def _make_pair(tmp_path): + # Deterministic, non-degenerate: per-row offset + per-column gradient. 500x500 gives an + # in-memory footprint well above 1 MB, so ram_limit=1 forces the out-of-core path. + Y, X = 500, 500 + xg = (np.arange(X) % 256).astype(np.uint32) + yg = ((np.arange(Y) % 200) * 256).astype(np.uint32) + inten = (1 + xg[None, :] + yg[:, None]).astype(np.uint32) + mask = np.ones((Y, X), np.uint32) # a single ROI covering the whole image + intdir = tmp_path / "int" + segdir = tmp_path / "seg" + intdir.mkdir() + segdir.mkdir() + tifffile.imwrite(str(intdir / "img.tif"), inten) + tifffile.imwrite(str(segdir / "img.tif"), mask) + return str(intdir) + os.sep, str(segdir) + os.sep + + +def _feature_cols(df): + cols = [ + c + for c in df.select_dtypes(include=[np.number]).columns + if c not in ("ROI_label", "t_index", "c_index") + ] + return cols, df[cols].to_numpy(dtype=float).ravel() + + +def test_ooc_2d_matches_in_ram(tmp_path): + intdir, segdir = _make_pair(tmp_path) + feats = ["*ALL_INTENSITY*"] + + n_ram = nyxus.Nyxus(feats) + n_ram.set_params(ram_limit=8000) # large -> in-RAM (trivial); explicit so test is order-independent + df_ram = n_ram.featurize_directory(intdir, segdir) + + n_ooc = nyxus.Nyxus(feats) + n_ooc.set_params(ram_limit=1) # 1 MB -> forces the oversized / out-of-core path + df_ooc = n_ooc.featurize_directory(intdir, segdir) + + cols, a = _feature_cols(df_ram) + _, b = _feature_cols(df_ooc) + assert a.size > 0 and a.shape == b.shape + + bad = [ + (c, p, q) + for c, p, q in zip(cols, a, b) + if abs(p - q) > 1e-6 * max(abs(p), abs(q), 1.0) + 1e-9 + ] + assert not bad, "out-of-core intensity features diverge from in-RAM: %r" % (bad[:8],) + + +def _make_volume_pair(tmp_path): + # A 3D OME-TIFF volume (one IFD per Z) + a whole-volume mask. Z*Y*X = 8*90*90 = 64800 + # voxels; the in-memory 3D footprint is well over 1 MB, so ram_limit=1 forces the oversized + # (out-of-core) volumetric path. Intensity is a deterministic, non-degenerate function of + # (x,y,z) so every 3D intensity feature is meaningful. + Z, Y, X = 8, 90, 90 + z = np.arange(Z)[:, None, None] + y = np.arange(Y)[None, :, None] + x = np.arange(X)[None, None, :] + inten = (1 + (x % 256) + (y % 200) * 256 + z * 10000).astype(np.uint32) + inten = np.broadcast_to(inten, (Z, Y, X)).astype(np.uint32) + mask = np.ones((Z, Y, X), np.uint32) # a single ROI covering the whole volume + intp = tmp_path / "vol_int.ome.tif" + segp = tmp_path / "vol_seg.ome.tif" + tifffile.imwrite(str(intp), inten, metadata={"axes": "ZYX"}) + tifffile.imwrite(str(segp), mask, metadata={"axes": "ZYX"}) + return str(intp), str(segp) + + +def test_ooc_3d_matches_in_ram(tmp_path): + """The 3D out-of-core path (voxel cloud streamed to disk, keeping z) must produce the same + 3D intensity AND surface/morphology features as the in-RAM path on the same volume pair.""" + intp, segp = _make_volume_pair(tmp_path) + feats = ["*3D_ALL_INTENSITY*", "*3D_ALL_MORPHOLOGY*"] + + # Nyxus3D takes ram_limit in the constructor (its set_params does not expose it) + n_ram = nyxus.Nyxus3D(feats, ram_limit=8000) # large -> in-RAM (trivial) + df_ram = n_ram.featurize_files([intp], [segp], False) + + n_ooc = nyxus.Nyxus3D(feats, ram_limit=1) # 1 MB -> forces the oversized / out-of-core volumetric path + df_ooc = n_ooc.featurize_files([intp], [segp], False) + + cols, a = _feature_cols(df_ram) + _, b = _feature_cols(df_ooc) + assert a.size > 0 and a.shape == b.shape + + bad = [ + (c, p, q) + for c, p, q in zip(cols, a, b) + if abs(p - q) > 1e-6 * max(abs(p), abs(q), 1.0) + 1e-9 + ] + assert not bad, "3D out-of-core intensity features diverge from in-RAM: %r" % (bad[:8],) + + +def _make_volume_pair_partial(tmp_path): + # Same intensity field as _make_volume_pair, but a non-cuboid (ellipsoid) mask that leaves + # background voxels INSIDE the ROI's bounding box. Out-of-core paths that build a grey-level + # LUT from the whole binned cube (mask + background) -- e.g. matlab binning maps the raw-0 + # background to a nonzero bin that must still appear in the LUT -- are only exercised when the + # bbox actually contains background; a whole-volume mask (hasBackground == false) never does. + Z, Y, X = 8, 90, 90 + z = np.arange(Z)[:, None, None] + y = np.arange(Y)[None, :, None] + x = np.arange(X)[None, None, :] + inten = (1 + (x % 256) + (y % 200) * 256 + z * 10000).astype(np.uint32) + inten = np.broadcast_to(inten, (Z, Y, X)).astype(np.uint32) + zz, yy, xx = np.meshgrid(np.arange(Z), np.arange(Y), np.arange(X), indexing="ij") + cz, cy, cx = (Z - 1) / 2.0, (Y - 1) / 2.0, (X - 1) / 2.0 + rz, ry, rx = Z / 2.0, Y / 3.0, X / 3.0 + inside = (((zz - cz) / rz) ** 2 + ((yy - cy) / ry) ** 2 + ((xx - cx) / rx) ** 2) <= 1.0 + mask = inside.astype(np.uint32) + intp = tmp_path / "vol_int_partial.ome.tif" + segp = tmp_path / "vol_seg_partial.ome.tif" + tifffile.imwrite(str(intp), inten, metadata={"axes": "ZYX"}) + tifffile.imwrite(str(segp), mask, metadata={"axes": "ZYX"}) + return str(intp), str(segp) + + +def _ooc_vs_ram_3d(tmp_path, feats, pair_fn=_make_volume_pair): + intp, segp = pair_fn(tmp_path) + n_ram = nyxus.Nyxus3D(feats, ram_limit=8000) + df_ram = n_ram.featurize_files([intp], [segp], False) + n_ooc = nyxus.Nyxus3D(feats, ram_limit=1) + df_ooc = n_ooc.featurize_files([intp], [segp], False) + cols, a = _feature_cols(df_ram) + _, b = _feature_cols(df_ooc) + assert a.size > 0 and a.shape == b.shape + bad = [ + (c, p, q) + for c, p, q in zip(cols, a, b) + if abs(p - q) > 1e-6 * max(abs(p), abs(q), 1.0) + 1e-9 + ] + assert not bad, "3D out-of-core features diverge from in-RAM: %r" % (bad[:8],) + + +def test_ooc_3d_glcm_matches_in_ram(tmp_path): + """3D GLCM out-of-core (13 co-occurrence matrices built over a streaming 2-plane window) + must match the in-RAM path.""" + _ooc_vs_ram_3d(tmp_path, ["*3D_GLCM*"]) + + +def test_ooc_3d_gldm_matches_in_ram(tmp_path): + """3D GLDM out-of-core (dependence matrix built over a streaming 3-plane window) must match + the in-RAM path.""" + _ooc_vs_ram_3d(tmp_path, ["*3D_GLDM*"]) + + +def test_ooc_3d_ngldm_matches_in_ram(tmp_path): + """3D NGLDM out-of-core (3-plane window, interior scan) must match the in-RAM path.""" + _ooc_vs_ram_3d(tmp_path, ["*3D_NGLDM*"]) + + +def test_ooc_3d_ngtdm_matches_in_ram(tmp_path): + """3D NGTDM out-of-core (radius-window neighbourhood averages) must match the in-RAM path.""" + _ooc_vs_ram_3d(tmp_path, ["*3D_NGTDM*"]) + + +def test_ooc_3d_glrlm_matches_in_ram(tmp_path): + """3D GLRLM out-of-core (in-plane runs reuse gather_rl_zones on a per-plane depth-1 cube; + cross-plane runs use a 2-plane carry) must match the in-RAM path.""" + _ooc_vs_ram_3d(tmp_path, ["*3D_GLRLM*"]) + + +def test_ooc_3d_glszm_matches_in_ram(tmp_path): + """3D GLSZM out-of-core (streaming 26-connectivity connected-component labeling via a + growable union-find over a 2-plane window) must match the in-RAM path.""" + _ooc_vs_ram_3d(tmp_path, ["*3D_GLSZM*"]) + + +def test_ooc_3d_gldzm_matches_in_ram(tmp_path): + """3D GLDZM out-of-core (streaming 6-connectivity connected-component labeling with a + min-distance-to-border metric per zone) must match the in-RAM path.""" + _ooc_vs_ram_3d(tmp_path, ["*3D_GLDZM*"]) + + +def test_ooc_3d_wholevolume_streams_oob(tmp_path): + """The WHOLE-VOLUME (single_roi) 3D path -- Nyxus3D.featurize_directory(dir, dir, ...), i.e. + label_dir==intensity_dir -- used to have NO out-of-core support at all: an oversized whole + volume failed loudly (workflow_3d_whole.cpp). It now streams via the same + populate_3d_voxel_cloud/run_3d_ooc_features primitives as the segmented path. This is the + Python-facing equivalent of the gtest TEST_3D_WHOLEVOLUME_OVERSIZED_STREAMS_OOC: force + oversized with ram_limit=0 (every footprint >= 0 is always true) and compare against an + in-RAM run of the identical volume.""" + Z, Y, X = 8, 90, 90 + z = np.arange(Z)[:, None, None] + y = np.arange(Y)[None, :, None] + x = np.arange(X)[None, None, :] + inten = (1 + (x % 256) + (y % 200) * 256 + z * 10000).astype(np.uint32) + inten = np.broadcast_to(inten, (Z, Y, X)).astype(np.uint32) + voldir = tmp_path / "wv" + voldir.mkdir() + tifffile.imwrite(str(voldir / "vol.ome.tif"), inten, metadata={"axes": "ZYX"}) + + feats = ["*3D_ALL_INTENSITY*", "*3D_ALL_MORPHOLOGY*", "*3D_GLCM*"] + + n_ram = nyxus.Nyxus3D(feats, ram_limit=8000) + df_ram = n_ram.featurize_directory(str(voldir), str(voldir), ".*") + + n_ooc = nyxus.Nyxus3D(feats, ram_limit=0) # every footprint >= 0 -> always oversized + df_ooc = n_ooc.featurize_directory(str(voldir), str(voldir), ".*") + + cols, a = _feature_cols(df_ram) + _, b = _feature_cols(df_ooc) + assert a.size > 0 and a.shape == b.shape + + bad = [ + (c, p, q) + for c, p, q in zip(cols, a, b) + if abs(p - q) > 1e-6 * max(abs(p), abs(q), 1.0) + 1e-9 + ] + assert not bad, "whole-volume out-of-core features diverge from in-RAM: %r" % (bad[:8],) + + +def _make_volume_pair_blank(tmp_path): + # A degenerate ROI (constant intensity everywhere) at the same size as _make_volume_pair, so it + # is still classified oversized at ram_limit=1 -- exercises each osized_calculate's early-return + # "blank ROI" guard (aux_min==aux_max) via the out-of-core path, which no other fixture reaches + # (every other fixture here varies intensity). Must not crash or hang, and must match the + # in-RAM path's degenerate-ROI output (STNGS_NAN defaults to plain 0.0 in this build, not IEEE + # NaN, so the existing tolerance-based comparison in _ooc_vs_ram_3d applies unchanged). + Z, Y, X = 8, 90, 90 + inten = np.full((Z, Y, X), 42, dtype=np.uint32) + mask = np.ones((Z, Y, X), np.uint32) + intp = tmp_path / "vol_int_blank.ome.tif" + segp = tmp_path / "vol_seg_blank.ome.tif" + tifffile.imwrite(str(intp), inten, metadata={"axes": "ZYX"}) + tifffile.imwrite(str(segp), mask, metadata={"axes": "ZYX"}) + return str(intp), str(segp) + + +def test_ooc_3d_blank_matches_in_ram(tmp_path): + """A degenerate (constant-intensity) oversized 3D ROI must not crash and must match the in-RAM + path's degenerate-ROI output for intensity, surface, and all seven texture families.""" + _ooc_vs_ram_3d( + tmp_path, + ["*3D_ALL_INTENSITY*", "*3D_ALL_MORPHOLOGY*", "*3D_GLCM*", "*3D_GLDM*", "*3D_NGLDM*", "*3D_NGTDM*", "*3D_GLRLM*", "*3D_GLSZM*", "*3D_GLDZM*"], + pair_fn=_make_volume_pair_blank, + ) + + +@pytest.mark.skipif( + not (DATA_NIFTI / "compat_int" / "compat_int_mri.nii").exists(), + reason="NIfTI compat fixtures not present in tests/data/nifti", +) +def test_ooc_3d_nifti_unsupported_format_fails_loudly(): + """NIfTI delivers the whole X*Y*Z*T volume in one read (ImageLoader::stream_volume_planes + declines it, since it isn't plane-by-plane), so an oversized ROI through this loader cannot + stream out-of-core. Must fail loudly with an actionable message rather than crash, hang, or + silently emit a wrong/zero row. ram_limit=0 forces every ROI oversized regardless of its actual + footprint (roiFootprint >= 0 is always true), so this doesn't depend on the fixture's ROI size.""" + intp = str(DATA_NIFTI / "compat_int" / "compat_int_mri.nii") + segp = str(DATA_NIFTI / "compat_seg" / "compat_seg_liver.nii") + n = nyxus.Nyxus3D(["*3D_ALL_INTENSITY*"], ram_limit=0) + with pytest.raises(Exception, match="not supported for this input format"): + n.featurize_files([intp], [segp], False) + + +def test_ooc_3d_partial_mask_matches_in_ram(tmp_path): + """Same equivalence check as the whole-volume tests above, but with a non-cuboid mask so the + ROI bbox contains background voxels -- exercises grey-level LUT construction (must include the + background bin, matching each feature's whole-cube-based calculate()) that a whole-volume mask + never triggers. Covers intensity/surface plus all four texture families in one pass.""" + _ooc_vs_ram_3d( + tmp_path, + ["*3D_ALL_INTENSITY*", "*3D_ALL_MORPHOLOGY*", "*3D_GLCM*", "*3D_GLDM*", "*3D_NGLDM*", "*3D_NGTDM*", "*3D_GLRLM*", "*3D_GLSZM*", "*3D_GLDZM*"], + pair_fn=_make_volume_pair_partial, + ) + + +def test_ooc_montage_oversized_fails_loudly(): + """The in-memory (montage) path has no out-of-core support, so an ROI whose footprint + reaches ram_limit must fail loudly rather than emit a silent all-zero feature row.""" + Y, X = 300, 300 + xg = (np.arange(X) % 256).astype(np.uint32) + yg = ((np.arange(Y) % 200) * 256).astype(np.uint32) + inten = (1 + xg[None, :] + yg[:, None]).astype(np.uint32) + mask = np.ones((Y, X), np.uint32) + + # sanity: with a large ram_limit the montage path succeeds and is non-zero. Set it + # explicitly (nyxus ram_limit is process-global) so this does not depend on test order. + ok = nyxus.Nyxus(["*ALL_INTENSITY*"]) + ok.set_params(ram_limit=8000) + df_ok = ok.featurize(inten, mask, intensity_names=["I"], label_names=["M"]) + assert df_ok["MEAN"].iloc[0] > 0 + + # ram_limit=1 makes the single ROI oversized -> must raise, not return zeros + n = nyxus.Nyxus(["*ALL_INTENSITY*"]) + n.set_params(ram_limit=1) + with pytest.raises(Exception, match="oversized"): + n.featurize(inten, mask, intensity_names=["I"], label_names=["M"]) diff --git a/tests/test_all.cc b/tests/test_all.cc index 047bb871a..a0883c9d8 100644 --- a/tests/test_all.cc +++ b/tests/test_all.cc @@ -4,6 +4,9 @@ #include "test_gabor_regression.h" #include "../src/nyx/environment.h" #include "../src/nyx/globals.h" +#include "../src/nyx/feature_method.h" // TEST_3D_OOC_GUARD_REJECTS_UNSUPPORTED_FEATURE +#include "../src/nyx/features/3d_intensity.h" +#include "../src/nyx/features/3d_glcm.h" #include "../src/nyx/ome/format_detect.h" // detect_input_format (P1) #include "test_contour.h" #include "test_ome_meta.h" // native-OME metadata parsers / OmeAxes descriptor @@ -2667,6 +2670,137 @@ TEST(TEST_NYXUS, TEST_OMEZARR_V3_BLOSC_FACADE_VOLUME) { ASSERT_NO_THROW (test_omezarr_facade_volume("dim5_v3_blosc.ome.zarr", 2, 3, 4)); } +// Larger multi-SHARD-FILE Zarr v3 store (gen_dim5.py write_v3_multishard): C=2,T=1,Z=8,Y=24,X=32, +// inner chunk (z,y,x)=(2,6,8), shard (z,y,x)=(8,12,16) -> a 2x2 GRID OF SHARD FILES per (c,t), +// each packing 16 inner chunks (8 shard files total). Unlike dim5_v3_sharded (exactly one shard +// per (t,c), so it only proves multiple inner chunks packed into ONE shard), this exercises the +// volumetric assembly crossing SHARD-FILE boundaries mid-plane -- closer to a real, larger v3 +// store's layout. Own local encoding (dim5_enc's C/Z/Y/X are hardcoded to the small fixture and +// don't apply here): value(x,y,z,c,t) = 1 + ((((t*C+c)*Z+z)*Y+y)*X+x), C=2,T=1,Z=8,Y=24,X=32. +static inline uint32_t dim5_multishard_enc(int x, int y, int z, int c, int t) +{ + const int C = 2, Z = 8, Y = 24, X = 32; + return static_cast(1 + ((((t * C + c) * Z + z) * Y + y) * X + x)); +} + +TEST(TEST_NYXUS, TEST_OMEZARR_V3_MULTISHARD_FACADE_VOLUME) { + const int T = 1, C = 2, Z = 8, Y = 24, X = 32; + fs::path ds = omezarr_data_path("dim5_v3_multishard.ome.zarr"); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; + FpImageOptions fp; + ImageLoader il; + ASSERT_TRUE(il.open(p, fp)) << ds.string(); + ASSERT_EQ(il.get_full_width(), (size_t)X); + ASSERT_EQ(il.get_full_height(), (size_t)Y); + ASSERT_EQ(il.get_full_depth(), (size_t)Z); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + { + ASSERT_TRUE(il.load_volume(c, t)); + const std::vector& vol = il.get_int_volume_buffer(); + ASSERT_EQ(vol.size(), (size_t)X * Y * Z); + for (int z = 0; z < Z; ++z) + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(vol[(size_t)z * X * Y + (size_t)y * X + x], dim5_multishard_enc(x, y, z, c, t)) + << "multishard vol (x" << x << " y" << y << " z" << z << " c" << c << " t" << t << ")"; + } + il.close(); +} + +TEST(TEST_NYXUS, TEST_OMEZARR_V3_MULTISHARD_CT_COUNTS) { + fs::path ds = omezarr_data_path("dim5_v3_multishard.ome.zarr"); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + auto ldr = NyxusOmeZarrLoader(1, ds.string()); + ASSERT_EQ(ldr.numberChannels(), (size_t)2); + ASSERT_EQ(ldr.fullTimestamps(0), (size_t)1); + ASSERT_EQ(ldr.fullDepth(0), (size_t)8); + + auto raw = RawOmezarrLoader(ds.string()); + ASSERT_EQ(raw.numberChannels(), (size_t)2); + ASSERT_EQ(raw.fullTimestamps(0), (size_t)1); + ASSERT_EQ(raw.fullDepth(0), (size_t)8); +} + +// Prescan (raw loader's readSubarray driven across all 8 shard files) must see the full encoded +// range across BOTH channels, and the whole-slide ROI area -- not garbage, not just channel 0. +TEST(TEST_NYXUS, TEST_OMEZARR_V3_MULTISHARD_PRESCAN) { + fs::path ip = omezarr_data_path("dim5_v3_multishard.ome.zarr"); + ASSERT_TRUE(fs::exists(ip)) << ip.string(); + + Environment e; + SlideProps p (ip.string(), ""); // whole-slide: no mask + ASSERT_TRUE(Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + + EXPECT_DOUBLE_EQ(p.min_preroi_inten, 1.0); + EXPECT_DOUBLE_EQ(p.max_preroi_inten, dim5_multishard_enc(31, 23, 7, 1, 0)); // last voxel, last channel + EXPECT_EQ(p.max_roi_area, (size_t)(32 * 24 * 8)); +} + +// Zarr v3 store with a MULTI-PLANE Z chunk (chunk z-extent 3 over Z=7 -> depths 3,3,1, an UNEVEN +// split), unsharded -- isolated regression test for a real bug found while building the multishard +// fixture above: omezarr.h/raw_omezarr.h's loadTile() always read exactly ONE Z-plane per tile +// (shape[iz_] left at its default of 1) regardless of the chunk's actual Z extent, so every plane +// past the first within a multi-plane chunk silently came back zero. No existing fixture before +// this one ever used a Z-chunk > 1. Own local encoding (dim5_enc/dim5_multishard_enc don't apply -- +// different dims): value(x,y,z,c,t) = 1 + ((((t*C+c)*Z+z)*Y+y)*X+x), C=2,T=1,Z=7,Y=6,X=8. +static inline uint32_t dim5_zchunked_enc(int x, int y, int z, int c, int t) +{ + const int C = 2, Z = 7, Y = 6, X = 8; + return static_cast(1 + ((((t * C + c) * Z + z) * Y + y) * X + x)); +} + +// Exercises omezarr.h's NyxusOmeZarrLoader via ImageLoader::load_volume/assemble_volume -- the +// path that read zero past the first Z-plane of a chunk before the fix. +TEST(TEST_NYXUS, TEST_OMEZARR_V3_ZCHUNKED_FACADE_VOLUME) { + const int T = 1, C = 2, Z = 7, Y = 6, X = 8; + fs::path ds = omezarr_data_path("dim5_v3_zchunked.ome.zarr"); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + SlideProps p; + p.fname_int = ds.string(); + p.fname_seg = ""; + FpImageOptions fp; + ImageLoader il; + ASSERT_TRUE(il.open(p, fp)) << ds.string(); + ASSERT_EQ(il.get_full_depth(), (size_t)Z); + + for (int t = 0; t < T; ++t) + for (int c = 0; c < C; ++c) + { + ASSERT_TRUE(il.load_volume(c, t)); + const std::vector& vol = il.get_int_volume_buffer(); + ASSERT_EQ(vol.size(), (size_t)X * Y * Z); + for (int z = 0; z < Z; ++z) + for (int y = 0; y < Y; ++y) + for (int x = 0; x < X; ++x) + ASSERT_EQ(vol[(size_t)z * X * Y + (size_t)y * X + x], dim5_zchunked_enc(x, y, z, c, t)) + << "zchunked vol (x" << x << " y" << y << " z" << z << " c" << c << " t" << t << ")"; + } + il.close(); +} + +// Exercises raw_omezarr.h's RawOmezarrLoader via RawImageLoader::for_each_voxel (the prescan path) +// -- the OTHER consumer of the same buggy loadTile(), independently regressed here. +TEST(TEST_NYXUS, TEST_OMEZARR_V3_ZCHUNKED_PRESCAN) { + fs::path ip = omezarr_data_path("dim5_v3_zchunked.ome.zarr"); + ASSERT_TRUE(fs::exists(ip)) << ip.string(); + + Environment e; + SlideProps p (ip.string(), ""); // whole-slide: no mask + ASSERT_TRUE(Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + + EXPECT_DOUBLE_EQ(p.min_preroi_inten, 1.0); + EXPECT_DOUBLE_EQ(p.max_preroi_inten, dim5_zchunked_enc(7, 5, 6, 1, 0)); // last voxel, last channel + EXPECT_EQ(p.max_roi_area, (size_t)(8 * 6 * 7)); +} + // No 'axes' metadata -> the loader falls back to legacy 5D TCZYX and still reads. TEST(TEST_NYXUS, TEST_OMEZARR_NOAXES_FALLBACK) { ASSERT_NO_THROW (test_omezarr_addressing("dim5_noaxes.ome.zarr", 2, 3, 4)); @@ -3151,6 +3285,161 @@ TEST(TEST_NYXUS, TEST_3D_WHOLEVOLUME_REDUCE) { ilo.close(); } +// Regression: an OVERSIZED whole volume of a format that delivers plane-by-plane (e.g. OME-TIFF) +// must now featurize SUCCESSFULLY out-of-core, producing the SAME feature values as the in-RAM +// (fitting) run of the identical file -- not fail, and not emit a zero row. This supersedes the +// old TEST_3D_WHOLEVOLUME_OVERSIZED_FAILS_LOUDLY premise: before workflow_3d_whole.cpp's oversized +// branch streamed via populate_3d_voxel_cloud/run_3d_ooc_features, NO whole-volume streaming path +// existed at all, so oversized-but-streamable volumes failed loudly (a deliberate, validated +// fallback at the time). Now the only remaining loud-fail case is a genuinely unstreamable format +// (whole-4D-in-one-read, e.g. NIfTI) -- see TEST_3D_WHOLEVOLUME_UNSTREAMABLE_FORMAT_FAILS_LOUDLY. +TEST(TEST_NYXUS, TEST_3D_WHOLEVOLUME_OVERSIZED_STREAMS_OOC) { + fs::path ds = ometiff_data_path("dim3_zyx.ome.tif"); // 3D X8 Y6 Z4 + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + auto run_and_read_row = [&](bool oversized, const fs::path& outdir) -> std::string + { + fs::remove_all(outdir); fs::create_directories(outdir); + Environment e; + e.theFeatureSet.enableAll(false); + e.theFeatureSet.enableFeatures(D3_VoxelIntensityFeatures::featureset); + e.theFeatureSet.enableFeatures(D3_SurfaceFeature::featureset); + e.theFeatureSet.enableFeatures(D3_GLCM_feature::featureset); + // The full sequence main_nyxus.cpp runs before any workflow (theFeatureMgr.compile() -> + // apply_user_selection() -> init_feature_classes() -> compile_feature_settings()). Skipping + // theFeatureMgr setup leaves get_num_requested_features()==0, so run_3d_ooc_features's loop + // never executes and every feature stays at its zero-initialized default (first symptom: an + // all-zero OOC row). Skipping compile_feature_settings() leaves fsett_D3_* at size 0, so any + // STNGS_*(s) macro access (e.g. surface's STNGS_SINGLEROI) reads out of bounds -- crashed + // (SEH 0xc0000005) before this was added. + EXPECT_TRUE(e.theFeatureMgr.compile()); + e.theFeatureMgr.apply_user_selection (e.theFeatureSet); + EXPECT_TRUE(e.theFeatureMgr.init_feature_classes()); + e.compile_feature_settings(); + EXPECT_TRUE(e.set_ram_limit(oversized ? 0 : 64)); // 0 -> force oversized; 64 MB comfortably fits this tiny (X8 Y6 Z4) volume + e.output_dir = outdir.string(); + + std::vector ifiles{ ds.string() }; + auto [ok, erm] = Nyxus::processDataset_3D_wholevolume(e, ifiles, 1, Nyxus::SaveOption::saveCSV, outdir.string()); + EXPECT_TRUE(ok) << (erm ? *erm : std::string("(no error message)")); + + std::string row; + size_t datarows = 0; + for (auto& de : fs::directory_iterator(outdir)) + if (de.path().extension() == ".csv") + { + std::ifstream f(de.path()); std::string header, ln; + std::getline(f, header); + while (std::getline(f, ln)) if (!ln.empty()) { row = ln; ++datarows; } + } + EXPECT_EQ(datarows, (size_t)1) << "expected exactly one feature row"; + fs::remove_all(outdir); + return row; + }; + + std::string ooc_row = run_and_read_row(true, fs::temp_directory_path() / "nyxus_wv_ooc_test"); + std::string ram_row = run_and_read_row(false, fs::temp_directory_path() / "nyxus_wv_ram_test"); + + ASSERT_FALSE(ooc_row.empty()); + ASSERT_FALSE(ram_row.empty()); + EXPECT_EQ(ooc_row, ram_row) << "the out-of-core whole-volume row must match the in-RAM row exactly"; +} + +// Regression: a whole volume whose loader delivers the ENTIRE X*Y*Z*T blob in one read (e.g. +// NIfTI -- see ImageLoader::stream_volume_planes) cannot stream plane-by-plane, so an oversized +// NIfTI whole volume must still fail loudly (no streaming path exists for it) rather than crash or +// emit a silent zero row. ram_limit=0 forces oversized regardless of the fixture's actual size. +TEST(TEST_NYXUS, TEST_3D_WHOLEVOLUME_UNSTREAMABLE_FORMAT_FAILS_LOUDLY) { + fs::path p(__FILE__); + fs::path ds(p.parent_path().string() + fs::path("/data/hounsfield/ct3d_int16.nii").make_preferred().string()); + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + fs::path outdir = fs::temp_directory_path() / "nyxus_wv_nifti_ooc_test"; + fs::remove_all(outdir); fs::create_directories(outdir); + + Environment e; + e.theFeatureSet.enableAll(false); + e.theFeatureSet.enableFeatures(D3_VoxelIntensityFeatures::featureset); + ASSERT_TRUE(e.theFeatureMgr.compile()); + e.theFeatureMgr.apply_user_selection (e.theFeatureSet); + ASSERT_TRUE(e.theFeatureMgr.init_feature_classes()); + e.compile_feature_settings(); + ASSERT_TRUE(e.set_ram_limit(0)); // force oversized regardless of this small fixture's real size + + std::vector ifiles{ ds.string() }; + auto [ok, erm] = Nyxus::processDataset_3D_wholevolume(e, ifiles, 1, Nyxus::SaveOption::saveCSV, outdir.string()); + + EXPECT_FALSE(ok) << "an oversized NIfTI whole volume has no streaming path and must fail loudly"; + + size_t datarows = 0; + for (auto& de : fs::directory_iterator(outdir)) + if (de.path().extension() == ".csv") + { + std::ifstream f(de.path()); std::string ln; size_t n = 0; + while (std::getline(f, ln)) if (!ln.empty()) ++n; + if (n) datarows += n - 1; // minus header + } + EXPECT_EQ(datarows, (size_t)0) << "no feature row should be written for an unstreamable oversized volume"; + fs::remove_all(outdir); +} + +// Regression (found by running nyxus under a hard memory cap): the whole-volume oversized check +// must use the 3D footprint estimator (W*H*D for the image cube), not the 2D one (W*H). The 2D +// estimator ignores depth, so it under-counted a volume's memory by ~depth x, let oversized +// volumes slip through the "trivial" path, and they OOM-crashed under a real memory limit even +// with a matching --ramLimit. This pins that the 3D estimator accounts for depth (the 2D one +// does not) so featurize_wholevolume's switch to get_ram_footprint_estimate_3D stays correct. +TEST(TEST_NYXUS, TEST_3D_RAM_FOOTPRINT_COUNTS_DEPTH) { + // Two ROIs with identical W/H and voxel count, differing ONLY in bounding-box depth. This + // isolates depth's effect on each estimator. + LR flat(1); + flat.aabb.init_from_whd(64, 64, 1); + flat.aux_area = 4096; + LR tall(1); + tall.aabb.init_from_whd(64, 64, 64); + tall.aux_area = 4096; + + // the 2D estimator's image-matrix term is W*H -> it IGNORES depth: identical for both + EXPECT_EQ(flat.get_ram_footprint_estimate(1), tall.get_ram_footprint_estimate(1)); + + // the 3D estimator's image-cube term is W*H*D -> the 64x-deeper bbox is far larger. This is + // the term the 2D estimator missed, which under-counted whole volumes and let them OOM. + EXPECT_GT(tall.get_ram_footprint_estimate_3D(1), flat.get_ram_footprint_estimate_3D(1) * 10) + << "3D footprint estimator is not counting depth"; +} + +// Regression-guard: processNontrivialRois_3D's per-feature out-of-core dispatch +// (phase3_3d.cpp) must throw for any 3D FeatureMethod NOT covered by is_3d_ooc_supported() -- +// otherwise a future feature added without a streaming osized_calculate would silently read +// raw_voxels_NT (which OOC never populates for it) via the base FeatureMethod::osized_scan_whole_image +// default, producing a wrong/zero row instead of an actionable error. Every CURRENT 3D feature class +// is supported, so there is no live "unsupported" feature to exercise this through the normal +// featurize path; this pins the ALLOW-LIST FUNCTION ITSELF directly, using a minimal stand-in +// FeatureMethod that is deliberately never added to the allow-list, alongside real supported classes. +class DummyUnsupported3DFeature : public FeatureMethod +{ +public: + DummyUnsupported3DFeature() : FeatureMethod("DummyUnsupported3DFeature") {} + void calculate (LR&, const Fsettings&) override {} + void osized_add_online_pixel (size_t, size_t, uint32_t) override {} + void osized_calculate (LR&, const Fsettings&, ImageLoader&) override {} + void save_value (std::vector>&) override {} +}; + +TEST(TEST_NYXUS, TEST_3D_OOC_GUARD_REJECTS_UNSUPPORTED_FEATURE) { + DummyUnsupported3DFeature unsupported; + EXPECT_FALSE(Nyxus::is_3d_ooc_supported(&unsupported)) + << "a 3D feature class outside the allow-list must be rejected by the OOC guard"; + + D3_VoxelIntensityFeatures intensityFeature; + EXPECT_TRUE(Nyxus::is_3d_ooc_supported(&intensityFeature)) + << "a real streaming-supported 3D feature (intensity) must be accepted"; + + D3_GLCM_feature glcmFeature; + EXPECT_TRUE(Nyxus::is_3d_ooc_supported(&glcmFeature)) + << "a real streaming-supported 3D texture feature (GLCM) must be accepted"; +} + // Regression: separatecsv derives ONE output path per slide, but the CSV sinks are invoked // once per (channel, timeframe) plane and used to open that path with mode "w" every time -- // so each plane truncated the one before it and the file ended up holding only the LAST diff --git a/tests/vetting/oracle_coverage.csv b/tests/vetting/oracle_coverage.csv index 8882b7448..0bd6d622e 100644 --- a/tests/vetting/oracle_coverage.csv +++ b/tests/vetting/oracle_coverage.csv @@ -538,219 +538,219 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,IH_ROBUST_MEAN_IDX,intensity_histogram,vetted,analytic,,ih.ibsi_fbn,,test_intensity_histogram_ibsi.h,,mirp (IBSI Intensity-Histogram family); vet _IDX,not-in-tracker;oracle-identified,audit,"vetted by test_ih_dispersion_robust_analytic (hand-computed on the robust-window discriminating fixture; no clean IBSI anchor - robust-mean has no IBSI feature; QCoD_VAL/IQR_VAL are Nyxus continuous-percentile extensions of the IBSI discrete-percentile IQR/QCoD, so they have no IBSI counterpart by design)" 2D,IH_NUM_BINS,intensity_histogram,vetted,analytic,,,,test_intensity_histogram_regression.h;test_intensity_histogram.py,test_intensity_histogram_analytic.h,numpy/scipy analytic,not-in-tracker,audit, 2D,IH_BIN_SIZE,intensity_histogram,vetted,analytic,,,,test_intensity_histogram_regression.h;test_intensity_histogram.py,test_intensity_histogram_analytic.h,numpy/scipy analytic,not-in-tracker,audit, -3D,3COV,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,mirp:stat_cov (IBSI 7TET; no pyradiomics equiv),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3COVERED_IMAGE_INTENSITY_RANGE,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,none (Nyxus-specific: (roi_max-roi_min)/(slide_max-slide_min)),bug-degenerate,tracker,BUG: 3D segmented workflow never sets roi.slide_idx (workflow_3d_segmented.cpp) so 3d_intensity.cpp takes the else branch and always emits degenerate 1; unvettable until slide_idx+min/max_preroi_inten are wired through the 3D pipeline (2D sets it via pixel_feed.cpp). Oracle (octave/oracle_3d) confirms intended value = (roi_range)/(slide_range) -3D,3ENERGY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3ENTROPY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3EXCESS_KURTOSIS,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3HYPERFLATNESS,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,none (scipy.moment order 5/6 only),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3HYPERSKEWNESS,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,none (scipy.moment order 5/6 only),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3INTEGRATED_INTENSITY,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3INTERQUARTILE_RANGE,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3KURTOSIS,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3MAX,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3MEAN,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3MEAN_ABSOLUTE_DEVIATION,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3MEDIAN,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3MEDIAN_ABSOLUTE_DEVIATION,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,mirp:stat_medad (IBSI N72L),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3MIN,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3MODE,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,scipy.stats.mode on raw voxels (IBSI mode is on histogram),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3P01,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) -3D,3P10,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3P25,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) -3D,3P75,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) -3D,3P90,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3P99,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) -3D,3QCOD,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) -3D,3RANGE,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3ROBUST_MEAN,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,octave/MATLAB (mean of voxels in [P10,P90]),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; rel<1e-9) after fixing 3ROBUST_MEAN which was hardcoded 0 in both 3d_intensity.cpp paths -3D,3ROBUST_MEAN_ABSOLUTE_DEVIATION,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3ROOT_MEAN_SQUARED,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3SKEWNESS,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3STANDARD_DEVIATION,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3STANDARD_DEVIATION_BIASED,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3STANDARD_ERROR,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3VARIANCE,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3VARIANCE_BIASED,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3UNIFORMITY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -3D,3UNIFORMITY_PIU,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3AREA,morphology,regression,,needs_audit,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,matlab,convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." -3D,3AREA_2_VOLUME,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:SurfaceVolumeRatio(3D),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." -3D,3COMPACTNESS1,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:Compactness1/Compactness2(3D),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." -3D,3COMPACTNESS2,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:Compactness1/Compactness2(3D),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." -3D,3MESH_VOLUME,morphology,vetted,matlab,agreed,,,test_3d_morphology_coverage.h;test_3d_morphology_matlab.h,test_3d_morphology_matlab.h,,,tracker, -3D,3SPHERICAL_DISPROPORTION,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:SphericalDisproportion(3D shape),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." -3D,3SPHERICITY,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_regression.h,pyradiomics:Sphericity(3D shape),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." -3D,3VOLUME_CONVEXHULL,morphology,vetted,matlab,agreed,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_matlab.h,,,tracker, -3D,3VOXEL_VOLUME,morphology,vetted,matlab,agreed,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h,test_3d_morphology_matlab.h,,,tracker, -3D,3MAJOR_AXIS_LEN,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:MajorAxisLength(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" -3D,3MINOR_AXIS_LEN,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:MinorAxisLength(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" -3D,3LEAST_AXIS_LEN,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:LeastAxisLength(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" -3D,3ELONGATION,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:Elongation(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" -3D,3FLATNESS,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h,test_3d_morphology_regression.h,pyradiomics:Flatness(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" -3D,3GLCM_ACOR,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_ASM,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_CLUPROM,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_CLUSHADE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_CLUTEND,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_CONTRAST,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_CORRELATION,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_DIFAVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_DIFENTRO,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_DIFVAR,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_DIS,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_DIFAVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_ENERGY,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ASM (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_ENTROPY,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_HOM1,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ID (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_HOM2,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_IDM (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_ID,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_IDN,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_IDM,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_IDMN,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_INFOMEAS1,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_INFOMEAS2,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_IV,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_JAVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_JE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_JMAX,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_JVAR,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_SUMAVERAGE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_SUMENTROPY,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A -3D,3GLCM_SUMVARIANCE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_CLUTEND (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_VARIANCE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JVAR (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_ASM_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_ACOR_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_CLUPROM_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_CLUSHADE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_CLUTEND_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_CONTRAST_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_CORRELATION_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_DIFAVE_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_mirp.h,,,tracker, -3D,3GLCM_DIFENTRO_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_DIFVAR_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_DIS_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_mirp.h,,,tracker, -3D,3GLCM_ENERGY_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ASM_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_ENTROPY_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JE_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_HOM1_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ID_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_ID_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_IDN_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_mirp.h,,,tracker, -3D,3GLCM_IDM_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_IDMN_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_mirp.h,,,tracker, -3D,3GLCM_IV_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_JAVE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_JE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_INFOMEAS1_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_INFOMEAS2_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_mirp.h,,,tracker, -3D,3GLCM_VARIANCE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JVAR_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLCM_JMAX_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_JVAR_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_SUMAVERAGE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_SUMENTROPY_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" -3D,3GLCM_SUMVARIANCE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_CLUTEND_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" -3D,3GLDM_SDE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_LDE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_GLN,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_DN,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_DNN,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_GLV,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_DV,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_DE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_LGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_HGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_SDLGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_SDHGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_LDLGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDM_LDHGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLDZM_SDE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_LDE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_LGLZE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_HGLZE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_SDLGLE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_SDHGLE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_LDLGLE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_LDHGLE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_GLNU,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_GLNUN,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_ZDNU,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_ZDNUN,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_ZP,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_GLM,gldzm,regression,,na,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,none (intermediate mean; indirect via GLV/ZDV/DCV variances),no-mainstream-oracle,tracker, -3D,3GLDZM_GLV,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_ZDM,gldzm,regression,,na,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,none (intermediate mean; indirect via GLV/ZDV/DCV variances),no-mainstream-oracle,tracker, -3D,3GLDZM_ZDV,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3GLDZM_ZDE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, -3D,3NGLDM_LDE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LDE: table=0.1 vs MIRP=0.2559." -3D,3NGLDM_HDE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HDE: table=261 vs MIRP=28.07." -3D,3NGLDM_LGLCE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LGLCE: table=0.00036 vs MIRP=0.0322." -3D,3NGLDM_HGLCE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HGLCE: table=740 vs MIRP=1324." -3D,3NGLDM_LDLGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LDLGLE: table=5.8e-05 vs MIRP=0.000685." -3D,3NGLDM_LDHGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LDHGLE: table=74 vs MIRP=474.8." -3D,3NGLDM_HDLGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HDLGLE: table=0.025 vs MIRP=8.714." -3D,3NGLDM_HDHGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HDHGLE: table=20030 vs MIRP=14942.8." -3D,3NGLDM_GLNU,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_GLNU: table=115443 vs MIRP=4350.3." -3D,3NGLDM_GLNUN,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_GLNUN: table=0.23 vs MIRP=0.01585." -3D,3NGLDM_DCNU,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCNU: table=115443 vs MIRP=40745.0." -3D,3NGLDM_DCNUN,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCNUN: table=0.23 vs MIRP=0.14847." -3D,3NGLDM_DCP,ngldm,vetted,mirp,agreed,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_mirp.h,,,tracker, -3D,3NGLDM_GLM,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,no-external-oracle,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. Additionally 3NGLDM_GLM has NO counterpart in IBSI/MIRP at all (MIRP's NGLDM emits no grey-level-mean or dependence-count-mean column; the 2D table in test_ngldm_ibsi.h likewise marks GLM '--not in IBSI--'), so no external oracle exists for it." -3D,3NGLDM_GLV,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_GLV: table=190 vs MIRP=350.17." -3D,3NGLDM_DCM,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,no-external-oracle,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. Additionally 3NGLDM_DCM has NO counterpart in IBSI/MIRP at all (MIRP's NGLDM emits no grey-level-mean or dependence-count-mean column; the 2D table in test_ngldm_ibsi.h likewise marks GLM '--not in IBSI--'), so no external oracle exists for it." -3D,3NGLDM_DCV,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCV: table=86.17 vs MIRP=11.948." -3D,3NGLDM_DCENT,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCENT: table=5.23 vs MIRP=8.676." -3D,3NGLDM_DCENE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCENE: table=0.14 vs MIRP=0.002875." -3D,3NGTDM_COARSENESS,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A -3D,3NGTDM_CONTRAST,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A -3D,3NGTDM_BUSYNESS,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A -3D,3NGTDM_COMPLEXITY,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A -3D,3NGTDM_STRENGTH,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_SAE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_LAE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_GLN,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_GLNN,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_SZN,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_SZNN,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_ZP,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_GLV,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_ZV,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_ZE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_LGLZE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_HGLZE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_SALGLE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_SAHGLE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_LALGLE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLSZM_LAHGLE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_SRE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_LRE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_GLN,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_GLNN,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_RLN,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_RLNN,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_RP,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_GLV,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_RV,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_RE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_LGLRE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_HGLRE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_SRLGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_SRHGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_LRLGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_LRHGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A -3D,3GLRLM_SRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_LRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_GLN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_GLNN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_RLN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_RLNN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_RP_AVE,glrlm,vetted,mirp,agreed,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_mirp.h,,,tracker, -3D,3GLRLM_GLV_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_RV_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_RE_AVE,glrlm,vetted,mirp,agreed,,,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_mirp.h,,,tracker, -3D,3GLRLM_LGLRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_HGLRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_SRLGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_SRHGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_LRLGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." -3D,3GLRLM_LRHGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3COV,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,mirp:stat_cov (IBSI 7TET; no pyradiomics equiv),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3COVERED_IMAGE_INTENSITY_RANGE,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,none (Nyxus-specific: (roi_max-roi_min)/(slide_max-slide_min)),bug-degenerate,tracker,BUG: 3D segmented workflow never sets roi.slide_idx (workflow_3d_segmented.cpp) so 3d_intensity.cpp takes the else branch and always emits degenerate 1; unvettable until slide_idx+min/max_preroi_inten are wired through the 3D pipeline (2D sets it via pixel_feed.cpp). Oracle (octave/oracle_3d) confirms intended value = (roi_range)/(slide_range) +3D,3ENERGY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3ENTROPY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3EXCESS_KURTOSIS,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3HYPERFLATNESS,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,none (scipy.moment order 5/6 only),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3HYPERSKEWNESS,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,none (scipy.moment order 5/6 only),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3INTEGRATED_INTENSITY,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3INTERQUARTILE_RANGE,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3KURTOSIS,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3MAX,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3MEAN,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3MEAN_ABSOLUTE_DEVIATION,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3MEDIAN,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3MEDIAN_ABSOLUTE_DEVIATION,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,mirp:stat_medad (IBSI N72L),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3MIN,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3MODE,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,scipy.stats.mode on raw voxels (IBSI mode is on histogram),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3P01,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) +3D,3P10,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3P25,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) +3D,3P75,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) +3D,3P90,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3P99,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,numpy (trivial closed-form; IBSI has only P10/P90),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) +3D,3QCOD,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d); Nyxus 100-bin CDF-interp reproduced exactly; ~0.2% vs prctile (definitional) +3D,3RANGE,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3ROBUST_MEAN,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,octave/MATLAB (mean of voxels in [P10,P90]),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; rel<1e-9) after fixing 3ROBUST_MEAN which was hardcoded 0 in both 3d_intensity.cpp paths +3D,3ROBUST_MEAN_ABSOLUTE_DEVIATION,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3ROOT_MEAN_SQUARED,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3SKEWNESS,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3STANDARD_DEVIATION,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3STANDARD_DEVIATION_BIASED,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3STANDARD_ERROR,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3VARIANCE,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3VARIANCE_BIASED,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3UNIFORMITY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +3D,3UNIFORMITY_PIU,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) +3D,3AREA,morphology,regression,,needs_audit,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,matlab,convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." +3D,3AREA_2_VOLUME,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,pyradiomics:SurfaceVolumeRatio(3D),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." +3D,3COMPACTNESS1,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,pyradiomics:Compactness1/Compactness2(3D),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." +3D,3COMPACTNESS2,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,pyradiomics:Compactness1/Compactness2(3D),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." +3D,3MESH_VOLUME,morphology,vetted,matlab,agreed,,,test_3d_morphology_coverage.h;test_3d_morphology_matlab.h;test_ooc_mechanics.py,test_3d_morphology_matlab.h,,,tracker, +3D,3SPHERICAL_DISPROPORTION,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,pyradiomics:SphericalDisproportion(3D shape),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." +3D,3SPHERICITY,morphology,regression,,na,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,pyradiomics:Sphericity(3D shape),convention-mismatch,tracker,"convention: Nyxus surface area is voxel-face-based (3AREA 59992) vs MIRP/pyradiomics mesh area (46739, ~28% diff); all area/volume-derived features inherit it. Needs surface-convention decision (see Vetting-Work-Log)." +3D,3VOLUME_CONVEXHULL,morphology,vetted,matlab,agreed,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h;test_ooc_mechanics.py,test_3d_morphology_matlab.h,,,tracker, +3D,3VOXEL_VOLUME,morphology,vetted,matlab,agreed,,,test_3d_morphology_coverage.h;test_3d_morphology_regression.h;test_ooc_mechanics.py,test_3d_morphology_matlab.h,,,tracker, +3D,3MAJOR_AXIS_LEN,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,pyradiomics:MajorAxisLength(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" +3D,3MINOR_AXIS_LEN,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,pyradiomics:MinorAxisLength(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" +3D,3LEAST_AXIS_LEN,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,pyradiomics:LeastAxisLength(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" +3D,3ELONGATION,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,pyradiomics:Elongation(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" +3D,3FLATNESS,morphology,vetted,mirp,agreed,,,test_3d_morphology_coverage.h;test_ooc_mechanics.py,test_3d_morphology_regression.h,pyradiomics:Flatness(3D shape),,tracker,"FIXED axis-length mislabel (3d_surface.cpp used wrong eigenvalue indices -> LEAST>MAJOR, FLATNESS>1) + vetted vs MIRP morph_pca_* (exact): MAJOR 104.71, MINOR 88.30, LEAST 71.51, ELONGATION 0.843, FLATNESS 0.683" +3D,3GLCM_ACOR,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_ASM,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_CLUPROM,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_CLUSHADE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_CLUTEND,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_CONTRAST,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_CORRELATION,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_DIFAVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_DIFENTRO,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_DIFVAR,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_DIS,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_DIFAVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ENERGY,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ASM (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ENTROPY,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_HOM1,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ID (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_HOM2,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_IDM (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ID,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_IDN,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_IDM,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_IDMN,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_INFOMEAS1,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_INFOMEAS2,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_IV,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_JAVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_JE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_JMAX,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_JVAR,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_SUMAVERAGE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_SUMENTROPY,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_3d_glcm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glcm_pyradiomics.h,,,tracker,test_3d_glcm.h ORPHANED (not #included in test_all.cc; 26 test_3glcm_* fns never run) - left as-is per decision A +3D,3GLCM_SUMVARIANCE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_3d_glcm.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_CLUTEND (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_VARIANCE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,"pyradiomics/mirp (DIS=DifferenceAverage, HOM1=Id, HOM2=Idm, SUMVAR=ClusterTendency, ENERGY=JointEnergy=ASM, VAR=mirp cm_var); CONFIG-SENSITIVE: symmetric+13dir vs Nyxus asym/1-offset/100lvl",,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JVAR (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ASM_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_ACOR_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_CLUPROM_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_CLUSHADE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_CLUTEND_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_CONTRAST_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_CORRELATION_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_DIFAVE_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_mirp.h,,,tracker, +3D,3GLCM_DIFENTRO_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_DIFVAR_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_DIS_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_mirp.h,,,tracker, +3D,3GLCM_ENERGY_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ASM_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ENTROPY_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JE_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_HOM1_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_ID_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_ID_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_IDN_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_mirp.h,,,tracker, +3D,3GLCM_IDM_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_IDMN_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_mirp.h,,,tracker, +3D,3GLCM_IV_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_JAVE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_JE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_INFOMEAS1_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_INFOMEAS2_AVE,glcm,vetted,mirp,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_mirp.h,,,tracker, +3D,3GLCM_VARIANCE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_JVAR_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLCM_JMAX_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_JVAR_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_SUMAVERAGE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_SUMENTROPY_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: 3GLCM_*_AVE = calc_ave(per-direction base), which is exactly the value test_3d_glcm_pyradiomics.h asserts == pyradiomics for the vetted base feature" +3D,3GLCM_SUMVARIANCE_AVE,glcm,vetted,pyradiomics,agreed,,,test_3d_glcm_coverage.h;test_ooc_mechanics.py,test_3d_glcm_regression.h,mirp,,tracker,"vetted by equivalence: numerically identical to pyradiomics-vetted 3GLCM_CLUTEND_AVE (rel<1e-6, gtest TEST_3DGLCM_EQUIVALENCE_DUMP asserts it). ENTROPY/HOM2 also required the /sum_p fix" +3D,3GLDM_SDE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_LDE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_GLN,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_DN,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_DNN,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_GLV,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_DV,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_DE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_LGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_HGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_SDLGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_SDHGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_LDLGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDM_LDHGLE,gldm,vetted,pyradiomics,agreed,,,test_3d_gldm_coverage.h;test_3d_gldm.h;test_3d_gldm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_gldm_pyradiomics.h,,,tracker,test_3d_gldm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLDZM_SDE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_LDE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_LGLZE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_HGLZE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_SDLGLE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_SDHGLE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_LDLGLE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_LDHGLE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_GLNU,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_GLNUN,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_ZDNU,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_ZDNUN,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_ZP,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_GLM,gldzm,regression,,na,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,none (intermediate mean; indirect via GLV/ZDV/DCV variances),no-mainstream-oracle,tracker, +3D,3GLDZM_GLV,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_ZDM,gldzm,regression,,na,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,none (intermediate mean; indirect via GLV/ZDV/DCV variances),no-mainstream-oracle,tracker, +3D,3GLDZM_ZDV,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3GLDZM_ZDE,gldzm,regression,,needs_audit,,,test_3d_gldzm_coverage.h;test_3d_gldzm_ibsi.h;test_ooc_mechanics.py,test_3d_gldzm_regression.h,mirp,promote-after-deepdive,tracker, +3D,3NGLDM_LDE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LDE: table=0.1 vs MIRP=0.2559." +3D,3NGLDM_HDE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HDE: table=261 vs MIRP=28.07." +3D,3NGLDM_LGLCE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LGLCE: table=0.00036 vs MIRP=0.0322." +3D,3NGLDM_HGLCE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HGLCE: table=740 vs MIRP=1324." +3D,3NGLDM_LDLGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LDLGLE: table=5.8e-05 vs MIRP=0.000685." +3D,3NGLDM_LDHGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_LDHGLE: table=74 vs MIRP=474.8." +3D,3NGLDM_HDLGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HDLGLE: table=0.025 vs MIRP=8.714." +3D,3NGLDM_HDHGLE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_HDHGLE: table=20030 vs MIRP=14942.8." +3D,3NGLDM_GLNU,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_GLNU: table=115443 vs MIRP=4350.3." +3D,3NGLDM_GLNUN,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_GLNUN: table=0.23 vs MIRP=0.01585." +3D,3NGLDM_DCNU,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCNU: table=115443 vs MIRP=40745.0." +3D,3NGLDM_DCNUN,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCNUN: table=0.23 vs MIRP=0.14847." +3D,3NGLDM_DCP,ngldm,vetted,mirp,agreed,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_mirp.h,,,tracker, +3D,3NGLDM_GLM,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,no-external-oracle,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. Additionally 3NGLDM_GLM has NO counterpart in IBSI/MIRP at all (MIRP's NGLDM emits no grey-level-mean or dependence-count-mean column; the 2D table in test_ngldm_ibsi.h likewise marks GLM '--not in IBSI--'), so no external oracle exists for it." +3D,3NGLDM_GLV,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_GLV: table=190 vs MIRP=350.17." +3D,3NGLDM_DCM,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,no-external-oracle,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. Additionally 3NGLDM_DCM has NO counterpart in IBSI/MIRP at all (MIRP's NGLDM emits no grey-level-mean or dependence-count-mean column; the 2D table in test_ngldm_ibsi.h likewise marks GLM '--not in IBSI--'), so no external oracle exists for it." +3D,3NGLDM_DCV,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCV: table=86.17 vs MIRP=11.948." +3D,3NGLDM_DCENT,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCENT: table=5.23 vs MIRP=8.676." +3D,3NGLDM_DCENE,ngldm,regression,,needs_audit,,,test_3d_ngldm_coverage.h;test_3d_ngldm_regression.h;test_ooc_mechanics.py,test_3d_ngldm_regression.h,mirp,unproven-reference,tracker,"NOT externally vetted. The reference table `d3ngldm_GT` in test_3d_ngldm_regression.h has NO provenance (no tool/version/config, contrary to SPEC 6.4) and is evaluated on the Nyxus coverage phantom ut_inten.nii -- NOT the IBSI digital phantom -- so IBSI consensus values cannot apply to it. An independent MIRP NGLDM run on the same phantom at the same discretisation (fixed_bin_number n=64, 3D) disagrees on every comparable feature. The passing test is a drift guard, not an oracle; needs a documented, config-matched external oracle (MIRP) before promotion. Harness: morph_oracle/mirp_ngldm_3d.py. For 3NGLDM_DCENE: table=0.14 vs MIRP=0.002875." +3D,3NGTDM_COARSENESS,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A +3D,3NGTDM_CONTRAST,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A +3D,3NGTDM_BUSYNESS,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A +3D,3NGTDM_COMPLEXITY,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A +3D,3NGTDM_STRENGTH,ngtdm,vetted,pyradiomics,agreed,,,test_3d_ngtdm_coverage.h;test_3d_ngtdm.h;test_3d_ngtdm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_ngtdm_pyradiomics.h,,,tracker,test_3d_ngtdm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_SAE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_LAE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_GLN,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_GLNN,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_SZN,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_SZNN,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_ZP,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_GLV,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_ZV,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_ZE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_LGLZE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_HGLZE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_SALGLE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_SAHGLE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_LALGLE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLSZM_LAHGLE,glszm,vetted,pyradiomics,agreed,,,test_3d_glszm_coverage.h;test_3d_glszm.h;test_3d_glszm_pyradiomics.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glszm_pyradiomics.h,,,tracker,test_3d_glszm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_SRE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_LRE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_GLN,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_GLNN,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_RLN,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_RLNN,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_RP,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_GLV,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_RV,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_RE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_LGLRE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_HGLRE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_SRLGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_SRHGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_LRLGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_LRHGLE,glrlm,vetted,pyradiomics,agreed,,,test_3d_glrlm_coverage.h;test_3d_glrlm.h;test_3d_glrlm_pyradiomics.h;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,test_3d_glrlm.h ORPHANED (not #included; never run) - left per decision A +3D,3GLRLM_SRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_LRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_GLN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_GLNN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_RLN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_RLNN_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_RP_AVE,glrlm,vetted,mirp,agreed,,,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_mirp.h,,,tracker, +3D,3GLRLM_GLV_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_RV_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_RE_AVE,glrlm,vetted,mirp,agreed,,,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_mirp.h,,,tracker, +3D,3GLRLM_LGLRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_HGLRE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_SRLGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_SRHGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_LRLGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." +3D,3GLRLM_LRHGLE_AVE,glrlm,vetted,pyradiomics,agreed,"glrlm.pyradiomics_bincount20 -- ibsi=false, GREYDEPTH=100, GLRLM_GREYDEPTH=-20 (radiomics binCount binning), 3D compat phantom, calc_ave over directions. VETTED ONLY ON THIS RECIPE (the pyradiomics-compat config); other configs are not covered.",,test_3d_glrlm_coverage.h;test_nyxus.py;test_ooc_mechanics.py,test_3d_glrlm_pyradiomics.h,,,tracker,"vetted vs PyRadiomics 3D GLRLM (binCount=20, weightingNorm=None) on the 3D compat phantom (ut_inten.nii label 57) at 10% in test_3d_glrlm_pyradiomics.h (TEST_COMPAT_3GLRLM_AVE_FEATURES). save_value stores fvals[X_AVE][0] = calc_ave(angled_X), the same direction-averaged quantity the base test asserts == PyRadiomics, so the _AVE slot is a direct PyRadiomics assertion. CONFIG SCOPE: vetted only on the pyradiomics-compat recipe above; Nyxus' default binning is not covered by this assertion." IMQ,FOCUS_SCORE,imq,regression,,,,,test_imq_regression.h,test_imq_regression.h,reference IQ library (e.g. skimage/BRISQUE) or analytic,not-in-tracker,audit, IMQ,LOCAL_FOCUS_SCORE,imq,regression,,,,,test_imq_regression.h,test_imq_regression.h,reference IQ library (e.g. skimage/BRISQUE) or analytic,not-in-tracker,audit, IMQ,POWER_SPECTRUM_SLOPE,imq,regression,,,,,test_imq_regression.h,test_imq_regression.h,reference IQ library (e.g. skimage/BRISQUE) or analytic,not-in-tracker,audit, From fd6a2ae0fff75e90f1acf6ec0911a9d29c56108f Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 22 Jul 2026 11:20:30 -0700 Subject: [PATCH 5/5] fix(io): reconcile with upstream, harden calibration/build paths, and document native 3D input Step 4 of the IO project: closes out the branch. Reconciles with upstream after rebasing main-io onto PolusAI/nyxus main (carrying an upstream 3D surface axis-length fix into the out-of-core path it predated), then hardens calibration and read correctness -- physical-size-unit canonicalization, float-precision-free OME size parsing, a whole-volume anisotropic-resampling hang fix, a RAM-footprint-estimate underflow at n_rois==0, and a degenerate- segmented-image prescan fix. Also fixes two build issues (a missing zstd header check, a GCC-only constexpr link error), hardens a host-memory- dependent test, removes dead code and dedupes the Zarr loaders/histogram paths, drops machine-specific paths and stale planning-phase language from tests/comments, and documents native OME 3D input and physical voxel spacing in the README. Absorbed commits (oldest to newest): 43d1343 fix(io): reconcile with upstream after rebasing main-io onto PolusAI/nyxus main bcf93b1 fix(prescan): COVERED_IMAGE_INTENSITY_RANGE degenerate for any segmented image (2D+3D) 7908ffd fix(ome): canonicalize physical-size units to micrometer d626794 fix(3d): whole-volume anisotropic-resampling hang + 3 related bugs 7701c38 fix(roi): LR::get_ram_footprint_estimate(_3D) underflow at n_rois==0 eb5f7bf chore(io): drop machine-specific paths and planning labels from tests a6a575a fix(ome): parse OME physical sizes without floating-point from_chars 904519b docs(readme): document native OME 3D input and physical voxel spacing 9545836 fix(build): require the zstd header, not just the library, before WITH_ZSTD bebb43f fix(tests): pick a ram_limit the host can actually grant e97228b Fix GCC link error: make OutOfRamVoxelCloud::BLK_RECORDS constexpr db0f981 refactor(io,3d): remove dead code, dedupe Zarr loaders + histogram, speed up OOC paths daeb1c0 refactor(comments): drop FIX-prefixed bug narration from loader dispatch and 3D intensity comments --- CMakeLists.txt | 12 +- README.md | 29 ++- src/nyx/feature_method.cpp | 12 +- src/nyx/feature_method.h | 7 +- src/nyx/features/3d_glcm_nontriv.cpp | 26 ++- src/nyx/features/3d_gldm.cpp | 10 +- src/nyx/features/3d_intensity.cpp | 96 ++++---- src/nyx/features/3d_ngldm.cpp | 14 +- src/nyx/features/3d_ngtdm.cpp | 9 +- src/nyx/features/3d_surface.cpp | 10 +- src/nyx/features/histogram.h | 201 ++-------------- src/nyx/features/voxel_cloud_nontriv.cpp | 33 +-- src/nyx/features/voxel_cloud_nontriv.h | 18 +- src/nyx/globals.h | 1 + src/nyx/image_loader.cpp | 33 ++- src/nyx/image_loader.h | 14 +- src/nyx/image_loader1x.cpp | 15 +- src/nyx/ome/format_detect.cpp | 59 +++-- src/nyx/ome/format_detect.h | 11 +- src/nyx/ome/ome_axes.h | 29 +++ src/nyx/ome/ome_tiff_meta.cpp | 29 ++- src/nyx/ome/ome_zarr_layout.h | 172 ++++++++++++++ src/nyx/ome/ome_zarr_meta.cpp | 6 + src/nyx/omezarr.h | 112 ++------- src/nyx/phase2_3d.cpp | 37 +-- src/nyx/phase3_3d.cpp | 4 +- src/nyx/raw_image_loader.cpp | 29 ++- src/nyx/raw_image_loader.h | 20 +- src/nyx/raw_omezarr.h | 109 ++------- src/nyx/roi_cache.cpp | 11 +- src/nyx/slideprops.cpp | 34 ++- src/nyx/workflow_3d_whole.cpp | 16 +- tests/data/ometiff/dim5_calibrated_nm.ome.tif | Bin 0 -> 7277 bytes tests/data/ometiff/gen_ome_tiff.py | 19 +- .../dim5_calibrated_nm.ome.zarr/.zattrs | 49 ++++ .../dim5_calibrated_nm.ome.zarr/.zgroup | 3 + .../dim5_calibrated_nm.ome.zarr/0/.zarray | 23 ++ .../dim5_calibrated_nm.ome.zarr/0/.zattrs | 1 + .../dim5_calibrated_nm.ome.zarr/0/0.0.0.0.0 | Bin 0 -> 576 bytes .../dim5_calibrated_nm.ome.zarr/0/0.0.1.0.0 | Bin 0 -> 576 bytes .../dim5_calibrated_nm.ome.zarr/0/0.0.2.0.0 | Bin 0 -> 576 bytes .../dim5_calibrated_nm.ome.zarr/0/0.0.3.0.0 | Bin 0 -> 576 bytes tests/data/omezarr/gen_dim5.py | 8 +- ...test_covered_intensity_range_regression.py | 84 +++++++ tests/python/test_ooc_mechanics.py | 18 +- .../test_physical_calibration_regression.py | 62 +++++ tests/test_3d_coverage_common.h | 2 +- tests/test_all.cc | 220 +++++++++++++++++- tests/test_ome_meta.h | 105 ++++++++- tests/test_ometiff_mechanics.h | 2 +- tests/test_omezarr_mechanics.h | 24 +- tests/vetting/oracle_coverage.csv | 4 +- 52 files changed, 1208 insertions(+), 634 deletions(-) create mode 100644 src/nyx/ome/ome_zarr_layout.h create mode 100644 tests/data/ometiff/dim5_calibrated_nm.ome.tif create mode 100644 tests/data/omezarr/dim5_calibrated_nm.ome.zarr/.zattrs create mode 100644 tests/data/omezarr/dim5_calibrated_nm.ome.zarr/.zgroup create mode 100644 tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/.zarray create mode 100644 tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/.zattrs create mode 100644 tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/0.0.0.0.0 create mode 100644 tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/0.0.1.0.0 create mode 100644 tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/0.0.2.0.0 create mode 100644 tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/0.0.3.0.0 create mode 100644 tests/python/test_covered_intensity_range_regression.py create mode 100644 tests/python/test_physical_calibration_regression.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 6249c9ad2..082352b1d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -284,14 +284,20 @@ if(USE_Z5) # zstd: the DEFAULT codec of Zarr v3 (OME-Zarr 0.5). z5's ZstdCompressor is gated by # WITH_ZSTD; without it, zstd-compressed v3 chunks fail to decode. Optional: if zstd - # isn't found we still read uncompressed / blosc / zlib v3 stores. + # isn't found we still read uncompressed / blosc / zlib v3 stores. Defining WITH_ZSTD + # makes a z5 header include , so the header must be found as well as the + # library -- a build environment can carry libzstd while its headers sit outside the + # dependency prefix, and defining the macro on the strength of the library alone turns + # that into a fatal include error. find_library(ZSTD_LIBRARY NAMES zstd libzstd) - if(ZSTD_LIBRARY) + find_path(ZSTD_INCLUDE_DIR NAMES zstd.h) + if(ZSTD_LIBRARY AND ZSTD_INCLUDE_DIR) add_definitions(-DWITH_ZSTD) list(APPEND Nyxus_LIBRARIES ${ZSTD_LIBRARY}) + include_directories(${ZSTD_INCLUDE_DIR}) message(STATUS "zstd found (${ZSTD_LIBRARY}) -> Zarr v3 zstd codec enabled") else() - message(STATUS "zstd not found -> Zarr v3 zstd-compressed chunks will be unsupported") + message(STATUS "zstd not fully available (lib='${ZSTD_LIBRARY}' include='${ZSTD_INCLUDE_DIR}') -> Zarr v3 zstd-compressed chunks will be unsupported") endif() if(nlohmann_json_FOUND) diff --git a/README.md b/README.md index 0d0322d2c..4d7159761 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Nyxus is a feature-rich, optimized, Python/C++ application capable of analyzing Nyxus can be used via Python or command line and is available in containerized form for reproducible execution. Nyxus computes over 450 combined intensity, texture, and morphological features at the ROI or whole image level with more in development. Key features that make Nyxus unique among other image feature extraction applications is its ability to operate at any scale, its highly validated algorithms, and its modular nature that makes the addition of new features straightforward. -Currently, Nyxus can read 2D image data from OME-TIFF, OME-Zarr, and DICOM 2D Grayscale images. Nyxus also reads compressed and uncompressed NIFTI 3D files. Nyxus Python API supports featurizing in-memory 2D image data represented by NumPy arrays. +Currently, Nyxus can read 2D image data from OME-TIFF, OME-Zarr, and DICOM 2D Grayscale images. Nyxus reads 3D volumes from compressed and uncompressed NIFTI files, and natively from OME-TIFF and OME-Zarr (NGFF 0.4 and 0.5), including 5-dimensional (X, Y, Z, C, T) datasets whose axis order is taken from the file's own metadata rather than assumed. Nyxus Python API supports featurizing in-memory 2D image data represented by NumPy arrays. The docs can be found at [Read the Docs](https://nyxus.readthedocs.io/en/latest/). @@ -34,7 +34,7 @@ conda install nyxus -c conda-forge ## Usage -The library provides class `Nyxus` for 2-dimensional TIFF, OME.TIFF, OME.ZARR, and DICOM slides and intensity-mask slide pairs, and class `Nyxus3D` for NIFTI volumes and intensity-volume volume pairs. Additionally to a single-file representation, a volume can be represented by its z-slices residing in separate 2-dimensional TIFF or ONE.TIFF slides (so called "layout A"). Slides and volumes can be featurized as all the files in a directory filtered with a file pattern passed specified. Alternatively, explicit file name pairs and pair lists can be featurized. Alternatively, 2D and 3D NumPy arrays can be featurized. +The library provides class `Nyxus` for 2-dimensional TIFF, OME.TIFF, OME.ZARR, and DICOM slides and intensity-mask slide pairs, and class `Nyxus3D` for NIFTI, OME-TIFF, and OME-Zarr volumes and intensity-volume volume pairs. Additionally to a single-file representation, a volume can be represented by its z-slices residing in separate 2-dimensional TIFF or ONE.TIFF slides (so called "layout A"). Slides and volumes can be featurized as all the files in a directory filtered with a file pattern passed specified. Alternatively, explicit file name pairs and pair lists can be featurized. Alternatively, 2D and 3D NumPy arrays can be featurized. ### 2D usage @@ -190,6 +190,25 @@ features2 = nyx.featurize_directory (idir, mdir, file_pattern=".*\.nii\.gz") ``` Note on CT datasets: voxel intensities recorded in the Hounsfield units are automatically read by Nyxus as 0-based values by adding the minimum value of an original file (typically -1024). +Volumes are also read natively from OME-TIFF and OME-Zarr, including 5-dimensional (X, Y, Z, C, T) datasets. The axis order comes from the file's own metadata, so non-canonical layouts are addressed correctly rather than assumed to be `TCZYX`: + +```python +import nyxus +nyx = nyxus.Nyxus3D (["*3D_ALL*"]) + +features = nyx.featurize_directory (idir, mdir, file_pattern=".*\.ome\.zarr") +``` + +Each channel and timeframe of a multi-channel or time-series volume is featurized as a volume of its own, and the resulting rows carry `c_index` and `t_index` columns identifying which one they came from. A single-channel mask is reused across all intensity channels. + +Note on physical voxel spacing: OME-TIFF and OME-Zarr files may declare a physical size per axis. Passing `use_physical_spacing=True` makes Nyxus use each volume's declared spacing as its voxel anisotropy, ratio-normalized so that the smallest axis is 1: + +```python +nyx = nyxus.Nyxus3D (["*3D_ALL*"], use_physical_spacing=True) +``` + +It is off by default, so the default cubic-voxel behavior is unchanged, and explicitly specified `anisotropy_x/y/z` values take precedence over the declared spacing. The spacing actually applied is reported in the `phys_x`, `phys_y`, `phys_z`, and `phys_unit` output columns, canonicalized to micrometers so that files declaring their sizes in different units stay numerically comparable. + Featurizing explicitly specified volume files is straightforward, too: ```python @@ -817,6 +836,12 @@ Applying a 1.5 x 2.0 x 2.5 anisotropy correction to all the volumes in a compres --anisox=1.5 --anisoy=2 --filePattern="*\.ome\.tiff" --features=*ALL* --resultFname=features1 --outputType=singlecsv --intDir=/data/plate123/int --segDir=/data/plate123/seg --outDir=/output/plate123 ``` +Instead of stating the anisotropy explicitly, it can be derived from the physical voxel spacing that OME-TIFF and OME-Zarr volumes declare in their own metadata: + +``` +--use-physical-spacing=true --dim=3 --filePattern=".*\.ome\.zarr" --features=*3D_ALL* --resultFname=3d-features --outputType=singlecsv --intDir=/data/patient123/intensity --segDir=/data/patient123/masks --outDir=/output/patient123 +``` + ## Nested ROIs Hierarchical ROI analysis in a form of finding ROIs nested geometrically as nested AABBs and aggregating features of child ROIs within corresponding parent is available as an optional extra step after the feature extraction of the whole image set is finished. To enable this step, all the command line options '--hsig', '--hpar', '--hchi', and '--hag' need to have non-blank valid values. diff --git a/src/nyx/feature_method.cpp b/src/nyx/feature_method.cpp index 2d53fe213..0fb2d5807 100644 --- a/src/nyx/feature_method.cpp +++ b/src/nyx/feature_method.cpp @@ -43,19 +43,15 @@ void FeatureMethod::add_dependencies(const std::initializer_listosized_calculate (r, s, ldr); this->save_value (r.fvals); } -// The Dataset-aware overload defaults to the Dataset-less path, so features that don't need -// slide props are unaffected; only features that override it consume the Dataset. -void FeatureMethod::osized_scan_whole_image (LR& r, const Fsettings& s, const Dataset& ds, ImageLoader& ldr) -{ - this->osized_scan_whole_image (r, s, ldr); -} - bool FeatureMethod::provides (int fcode) const { return std::find(provided_features.begin(), provided_features.end(), (int)fcode) != provided_features.end(); diff --git a/src/nyx/feature_method.h b/src/nyx/feature_method.h index d3c7df9bd..88c8a28b0 100644 --- a/src/nyx/feature_method.h +++ b/src/nyx/feature_method.h @@ -27,10 +27,9 @@ class FeatureMethod virtual void parallel_process(std::vector& roi_labels, std::unordered_map & roiData, int n_threads) {} //=== Oversized ROI - virtual void osized_scan_whole_image (LR& roi, const Fsettings& s, ImageLoader& ldr); - // Dataset-aware out-of-core dispatch. Features that need slide props (intensity, - // intensity-histogram) override this to reach their Dataset-taking osized_calculate; the - // default forwards to the Dataset-less overload so every other feature is unaffected. + // Out-of-core dispatch. Features that need slide props (intensity, intensity-histogram) + // override this to reach their Dataset-taking osized_calculate; the default ignores the + // Dataset and calls the Dataset-less osized_calculate, so every other feature is unaffected. virtual void osized_scan_whole_image (LR& roi, const Fsettings& s, const Dataset& ds, ImageLoader& ldr); virtual void osized_add_online_pixel (size_t x, size_t y, uint32_t intensity) = 0; // Called each time the ROI pixel is being scanned in the raster order diff --git a/src/nyx/features/3d_glcm_nontriv.cpp b/src/nyx/features/3d_glcm_nontriv.cpp index ded9857a2..8fed07a4d 100644 --- a/src/nyx/features/3d_glcm_nontriv.cpp +++ b/src/nyx/features/3d_glcm_nontriv.cpp @@ -106,6 +106,19 @@ void D3_GLCM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) const int off = D3_GLCM_feature::offset; const bool sym = D3_GLCM_feature::symmetric_glcm; + // O(1) grey-level -> matrix row, replacing the per-pair binary search in the hot add_pair loop + // (radiomics is the only binning branch that indexes I by value; the others use pi-1 directly). + // rowLUT[v] == lower_bound(I, v) - I.begin() for every possible binned level, so the row is + // identical to the search it replaces. + const bool radiomics = radiomics_grey_binning(greyInfo); + std::vector rowLUT; + if (radiomics) + { + rowLUT.assign ((size_t) maxbin + 1, 0); + for (PixIntens v = 0; v <= maxbin; v++) + rowLUT[v] = (int) (std::lower_bound (I.begin(), I.end(), v) - I.begin()); + } + // Count one (base, neighbor) grey-level pair into matrix M, mirroring calculateCoocMatAtAngle: // GLCM.xy(idx(neighbor), idx(base)), plus the symmetric transpose for radiomics/ibsi/symmetric. auto add_pair = [&](SimpleMatrix& M, PixIntens lvl_base, PixIntens lvl_nbr) @@ -114,16 +127,16 @@ void D3_GLCM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) if (lvl_nbr == 0 || lvl_base == 0) return; int a = (int) lvl_nbr, b = (int) lvl_base; - if (radiomics_grey_binning(greyInfo)) + if (radiomics) { if (a == 0 || b == 0) return; - a = (int) (std::lower_bound (I.begin(), I.end(), (PixIntens) a) - I.begin()); - b = (int) (std::lower_bound (I.begin(), I.end(), (PixIntens) b) - I.begin()); + a = rowLUT[a]; + b = rowLUT[b]; } else { a -= 1; b -= 1; } M.xy (a, b) += 1.0; - if (sym || radiomics_grey_binning(greyInfo) || ibsi_grey_binning(greyInfo)) + if (sym || radiomics || ibsi_grey_binning(greyInfo)) M.xy (b, a) += 1.0; }; @@ -191,10 +204,11 @@ void D3_GLCM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) } } - // --- per-direction feature values from the accumulated matrices (shared finalize) + // --- per-direction feature values from the accumulated matrices (shared finalize). + // Move each matrix into P_matrix rather than copy it -- mats[k] is not reused afterwards. for (int k = 0; k < 13; k++) { - P_matrix = mats[k]; + P_matrix = std::move (mats[k]); sum_p = 0; for (double a : P_matrix) sum_p += a; finalize_angle (soft_nan); diff --git a/src/nyx/features/3d_gldm.cpp b/src/nyx/features/3d_gldm.cpp index e8081ba8d..bdcf56bfc 100644 --- a/src/nyx/features/3d_gldm.cpp +++ b/src/nyx/features/3d_gldm.cpp @@ -306,6 +306,13 @@ void D3_GLDM_feature::osized_calculate(LR& r, const Fsettings& s, ImageLoader&) std::fill (P.begin(), P.end(), 0); int max_Nd = 0; + // O(1) grey-level -> matrix row, replacing the per-voxel binary search in the scan below. + // rowLUT[v] == lower_bound(I, v) - I.begin() for every possible binned level, so the row is + // identical to the search it replaces (used only by the non-IBSI branch; IBSI uses pi-1). + std::vector rowLUT ((size_t) maxbin + 1, 0); + for (PixIntens v = 0; v <= maxbin; v++) + rowLUT[v] = (int) (std::lower_bound (I.begin(), I.end(), v) - I.begin()); + // --- 3-plane sliding window of dense grey-binned planes (bg-initialised so background matches // the in-core cube), indexed by local z modulo 3 std::vector> ring (3); @@ -351,8 +358,7 @@ void D3_GLDM_feature::osized_calculate(LR& r, const Fsettings& s, ImageLoader&) nd++; } - int row = ibsi ? (int) pi - 1 - : (int)(std::lower_bound (I.begin(), I.end(), pi) - I.begin()); + int row = ibsi ? (int) pi - 1 : rowLUT[pi]; int col = nd - 1; P.xy (col, row)++; max_Nd = (std::max) (max_Nd, nd); diff --git a/src/nyx/features/3d_intensity.cpp b/src/nyx/features/3d_intensity.cpp index e50e78419..042e7b0be 100644 --- a/src/nyx/features/3d_intensity.cpp +++ b/src/nyx/features/3d_intensity.cpp @@ -203,18 +203,23 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c val_COVERED_IMAGE_INTENSITY_RANGE = 1; double n = r.aux_area; + const size_t nvox = r.raw_voxels_NT.size(); - // --MEAN, ENERGY, CENTROID_XY + // Stream voxels off disk (raw_voxels_NT) rather than holding the cube (raw_pixels_3D) or + // reading the 2D z-less cloud (raw_pixels_NT). Each disk pass decodes the whole cloud, so the + // per-voxel scalars are gathered in as few passes as their data dependencies allow (mean must + // be known before dispersion; the percentiles before median-abs-dev / robust mean). + + // --- Pass 1: raw sums + online moments (MEAN, ENERGY, CENTROID, INTEGRATED_INTENSITY, and the + // Moments4 accumulator for SKEWNESS/KURTOSIS -- folded in here instead of its own later pass) double mean_ = 0.0; double energy = 0.0; double cen_x = 0.0, cen_y = 0.0, cen_z = 0.0, integInten = 0.0; - // Stream voxels off disk (raw_voxels_NT) rather than holding the cube (raw_pixels_3D) - // or reading the 2D z-less cloud (raw_pixels_NT). Values are unchanged - intensity - // statistics are per-voxel scalars, so a linear pass over the disk-backed cloud suffices. - for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) + Moments4 mom; + for (size_t i = 0; i < nvox; i++) { Pixel3 px = r.raw_voxels_NT[i]; mean_ += px.inten; @@ -223,6 +228,7 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c cen_y += px.y; cen_z += px.z; integInten += px.inten; + mom.add(px.inten); } mean_ /= n; val_MEAN = mean_; @@ -230,10 +236,10 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c val_ROOT_MEAN_SQUARED = sqrt(val_ENERGY / n); val_INTEGRATED_INTENSITY = integInten; - // --MAD, VARIANCE, STDDEV, COV + // --- Pass 2: dispersion (MAD, VARIANCE, STDDEV, COV) -- needs mean_ double mad = 0.0, var = 0.0; - for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) + for (size_t i = 0; i < nvox; i++) { double diff = r.raw_voxels_NT[i].inten - mean_; mad += std::abs(diff); @@ -249,10 +255,23 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c // --Standard error val_STANDARD_ERROR = val_STANDARD_DEVIATION / sqrt(n); - //==== Do not calculate features of all-blank intensities (to avoid NANs) + //==== Do not calculate features of all-blank intensities (to avoid NANs). Kept before the + // Moments4-derived and percentile-derived features so a blank ROI leaves them at their + // defaults exactly as before (the accumulator is filled in pass 1 but not consumed until here). if (r.aux_min == 0 && r.aux_max == 0) return; + // Skewness / Kurtosis from the pass-1 accumulator + val_SKEWNESS = mom.skewness(); + val_KURTOSIS = mom.kurtosis(); + // Excess kurtosis. NOTE: this is NOT val_KURTOSIS-3 -- Moments4::excess_kurtosis() has its own + // independent zero-variance (M2==0) guard returning exactly 0.0, whereas kurtosis() ALSO + // returns exactly 0.0 under that same guard; subtracting 3 from the latter wrongly yields -3.0 + // for a degenerate (constant-intensity) ROI instead of 0.0. Only diverges from kurtosis()-3 in + // that degenerate case (for n>4 the two formulas are algebraically identical), which is why + // every non-degenerate fixture matched before this was caught by a blank-ROI OOC test. + val_EXCESS_KURTOSIS = mom.excess_kurtosis(); + // P10, 25, 75, 90, IQR, QCOD, RMAD, entropy, uniformity int n_greybins = STNGS_NGREYS(s); TrivialHistogram H; @@ -272,60 +291,37 @@ void D3_VoxelIntensityFeatures::osized_calculate (LR & r, const Fsettings & s, c val_MODE = mode_; val_UNIFORMITY = uniformity_; - // Median absolute deviation - double medad = 0.0; - for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) - medad += std::abs(double(r.raw_voxels_NT[i].inten) - median_); - val_MEDIAN_ABSOLUTE_DEVIATION = medad / n; + // --Uniformity calculated as PIU, percent image uniformity - see "A comparison of five standard methods for evaluating image + // intensity uniformity in partially parallel imaging MRI" [https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3745492/] + // and https://aapm.onlinelibrary.wiley.com/doi/abs/10.1118/1.2241606 + double piu = (1.0 - double(r.aux_max - r.aux_min) / double(r.aux_max + r.aux_min)) * 100.0; + val_UNIFORMITY_PIU = piu; - // FIX 3ROBUST_MEAN: same omission in the out-of-core path (defaulted to 0) -> compute the - // mean of voxels within the [P10,P90] robust window, matching the 2D implementation. + // --- Pass 3: the percentile-dependent scalars in one sweep -- median absolute deviation, + // robust mean (mean of voxels in the [P10,P90] window, matching the 2D implementation), and + // the hyperskewness/hyperflatness sum-of-powers (the in-core calculate()'s explicit + // definition; Moments4::hyperskewness()/hyperflatness() use a different definition and + // diverge). Each accumulator sums in the same voxel order as when these were three separate + // passes, so the values are unchanged. + double medad = 0.0; double robustMean = 0.0; size_t robustCount = 0; - for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) + double sumPow5 = 0, sumPow6 = 0; + for (size_t i = 0; i < nvox; i++) { - double inten = r.raw_voxels_NT[i].inten; + double inten = double(r.raw_voxels_NT[i].inten); + medad += std::abs(inten - median_); if (inten >= p10_ && inten <= p90_) { robustMean += inten; robustCount++; } - } - val_ROBUST_MEAN = robustCount ? robustMean / double(robustCount) : 0.0; - - // --Uniformity calculated as PIU, percent image uniformity - see "A comparison of five standard methods for evaluating image - // intensity uniformity in partially parallel imaging MRI" [https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3745492/] - // and https://aapm.onlinelibrary.wiley.com/doi/abs/10.1118/1.2241606 - double piu = (1.0 - double(r.aux_max - r.aux_min) / double(r.aux_max + r.aux_min)) * 100.0; - val_UNIFORMITY_PIU = piu; - - // Skewness - Moments4 mom; - for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) - mom.add(r.raw_voxels_NT[i].inten); - - val_SKEWNESS = mom.skewness(); - - // Kurtosis - val_KURTOSIS = mom.kurtosis(); - - // Excess kurtosis. NOTE: this is NOT val_KURTOSIS-3 -- Moments4::excess_kurtosis() has its own - // independent zero-variance (M2==0) guard returning exactly 0.0, whereas kurtosis() ALSO - // returns exactly 0.0 under that same guard; subtracting 3 from the latter wrongly yields -3.0 - // for a degenerate (constant-intensity) ROI instead of 0.0. Only diverges from kurtosis()-3 in - // that degenerate case (for n>4 the two formulas are algebraically identical), which is why - // every non-degenerate fixture matched before this was caught by a blank-ROI OOC test. - val_EXCESS_KURTOSIS = mom.excess_kurtosis(); - - // Hyperskewness / hyperflatness: use the explicit sum-of-powers definition of the in-core - // calculate() (mom.hyperskewness()/hyperflatness() use a different definition and diverge). - double sumPow5 = 0, sumPow6 = 0; - for (size_t i = 0; i < r.raw_voxels_NT.size(); i++) - { - double diff = double(r.raw_voxels_NT[i].inten) - mean_; + double diff = inten - mean_; sumPow5 += std::pow(diff, 5.); sumPow6 += std::pow(diff, 6.); } + val_MEDIAN_ABSOLUTE_DEVIATION = medad / n; + val_ROBUST_MEAN = robustCount ? robustMean / double(robustCount) : 0.0; double denomHS = (n * std::pow(val_STANDARD_DEVIATION, 5.)); val_HYPERSKEWNESS = denomHS == 0. ? 0. : sumPow5 / denomHS; double denomHF = (n * std::pow(val_STANDARD_DEVIATION, 6.)); diff --git a/src/nyx/features/3d_ngldm.cpp b/src/nyx/features/3d_ngldm.cpp index b82f18db0..db1092fe0 100644 --- a/src/nyx/features/3d_ngldm.cpp +++ b/src/nyx/features/3d_ngldm.cpp @@ -453,6 +453,18 @@ void D3_NGLDM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader& NGLDM.fill (0); int max_dep = 0; + // O(1) grey-level -> matrix row, replacing the per-voxel binary search in the scan below. + // rowLUT[v] == lower_bound(grey_levels_LUT, v) for every possible binned level (the max is + // the last, sorted, entry), so the row is identical to the search it replaces. + std::vector rowLUT; + if (Ng > 0) + { + const PixIntens maxbin = grey_levels_LUT.back(); + rowLUT.assign ((size_t) maxbin + 1, 0); + for (PixIntens v = 0; v <= maxbin; v++) + rowLUT[v] = (int) (std::lower_bound (grey_levels_LUT.begin(), grey_levels_LUT.end(), v) - grey_levels_LUT.begin()); + } + // --- 3-plane sliding window of dense grey-binned planes (bg-initialised) std::vector> ring (3); int ringZ[3] = { -1, -1, -1 }; @@ -485,7 +497,7 @@ void D3_NGLDM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader& for (int x = 1; x < W - 1; x++) { PixIntens cpi = cur[(size_t) y * W + x]; - int row = (int)(std::lower_bound (grey_levels_LUT.begin(), grey_levels_LUT.end(), cpi) - grey_levels_LUT.begin()); + int row = rowLUT[cpi]; int n_matches = 0; for (int i = 0; i < nsh; i++) diff --git a/src/nyx/features/3d_ngtdm.cpp b/src/nyx/features/3d_ngtdm.cpp index 3873cc470..ef25e39fe 100644 --- a/src/nyx/features/3d_ngtdm.cpp +++ b/src/nyx/features/3d_ngtdm.cpp @@ -335,6 +335,13 @@ void D3_NGTDM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader& P.assign (Ng, 0.0); Nvp = 0; + // O(1) grey-level -> matrix row, replacing the per-voxel binary search in the scan below. + // rowLUT[v] == lower_bound(I, v) - I.begin() for every possible (shifted) binned level (the + // max is I's last, sorted, entry), so the row is identical to the search it replaces. + std::vector rowLUT ((size_t) I.back() + 1, 0); + for (PixIntens v = 0; v <= I.back(); v++) + rowLUT[v] = (int) (std::lower_bound (I.begin(), I.end(), v) - I.begin()); + // --- (2*rad+1)-plane sliding window of dense grey-binned planes (shift baked in) const int ringN = 2 * rad + 1; std::vector> ring (ringN); @@ -388,7 +395,7 @@ void D3_NGTDM_feature::osized_calculate (LR& r, const Fsettings& s, ImageLoader& if (nd > 0) { double aveNeigI = neigsI / nd; - int row = (int)(std::lower_bound (I.begin(), I.end(), pi) - I.begin()); + int row = rowLUT[pi]; N[row]++; S[row] += std::abs ((double) I[row] - aveNeigI); if (aveNeigI > 0.0) diff --git a/src/nyx/features/3d_surface.cpp b/src/nyx/features/3d_surface.cpp index db4f30d56..7ca048559 100644 --- a/src/nyx/features/3d_surface.cpp +++ b/src/nyx/features/3d_surface.cpp @@ -364,11 +364,11 @@ void D3_SurfaceFeature::calculate (LR& r, const Fsettings& s) // volume - // (fast approximation based on cubic lattice packaging of balls) - double ball_r3 = 1. / 8., // r^3, after the anisotropy correction, lattice is expected to be cubic - sumPackedV = 0.0; - for (const auto& vox : r.raw_pixels_3D) - sumPackedV += 4. / 3. * M_PI * ball_r3; + // (fast approximation based on cubic lattice packaging of balls). The per-voxel packed + // volume is a constant, so the whole sum is voxel_count * that constant (the out-of-core + // path already computes it closed-form; match it here instead of an O(n) accumulation). + double ball_r3 = 1. / 8.; // r^3, after the anisotropy correction, lattice is expected to be cubic + double sumPackedV = double(r.raw_pixels_3D.size()) * (4. / 3. * M_PI * ball_r3); fval_VOXEL_VOLUME = sumPackedV / 0.5236; // packaging density at kissing number = 4 (cubic lattice) // surface diff --git a/src/nyx/features/histogram.h b/src/nyx/features/histogram.h index 179fa6309..50efc9971 100644 --- a/src/nyx/features/histogram.h +++ b/src/nyx/features/histogram.h @@ -1,4 +1,4 @@ -#pragma once +#pragma once #include #include @@ -21,186 +21,13 @@ class TrivialHistogram TrivialHistogram() {} - template - void initialize (int n_cust_bins, HistoItem min_value, HistoItem max_value, const std::vector& raw_data) - { - // safety - int n_custBins = std::abs(n_cust_bins); - - pop_ = raw_data.size(); - - // Allocate - // -- "percentile" - bins100_.reserve(100); - for (int i = 0; i < 100 + 1; i++) - bins100_.push_back(0); - // -- "uint8" - bins_cust_.reserve(n_custBins); - for (int i = 0; i < n_custBins + 1; i++) - bins_cust_.push_back(0); - - // Cache min/max - minVal_ = min_value; - maxVal_ = max_value; - auto valRange = maxVal_ - minVal_; - - // unique values - for (auto s : raw_data) - U_.push_back (s.inten); - - // Build the "percentile" histogram - binW100_ = double(valRange) / 100.; - for (auto s : raw_data) - { - HistoItem h = s.inten; - double realIdx = double(h - minVal_) / binW100_; - int idx = std::isnan(realIdx) ? 0 : int(realIdx); - (bins100_[idx])++; - } - - // -- Fix the special last bin - bins100_[100 - 1] += bins100_[100]; - bins100_[100] = 0; - - // Build the "uint8" histogram - binWcust_ = double(valRange) / double(n_custBins-1); - for (auto s : raw_data) - { - HistoItem h = Nyxus::to_grayscale(s.inten, minVal_, valRange, n_custBins); - bins_cust_[h] = bins_cust_[h] + 1; - } - - // -- Fix the special last bin - bins_cust_[n_custBins - 1] += bins_cust_[n_custBins]; - bins_cust_[n_custBins] = 0; - - // Mean calculation - meanVal_ = 0; - for (auto s : raw_data) - meanVal_ += double(s.inten); - meanVal_ /= double(pop_); - - // percentiles - calc_percentiles(); - - // robust MAD - mean1090val_ = 0.0; - size_t pop1090 = 0; - for (auto pxl : raw_data) - { - double a = double(pxl.inten); - if (a >= p10_ && a <= p90_) - { - mean1090val_ += a; - pop1090++; - } - } - rmad_ = 0.0; - if (pop1090) - { - mean1090val_ /= double(pop1090); - for (auto pxl : raw_data) - { - double a = double(pxl.inten); - if (a >= p10_ && a <= p90_) - rmad_ += (std::fabs)(a - mean1090val_); - } - rmad_ /= double(pop1090); - } - } - - void initialize (int n_cust_bins, HistoItem min_value, HistoItem max_value, const OutOfRamPixelCloud& raw_data) - { - // safety - int n_custBins = std::abs(n_cust_bins); - - pop_ = raw_data.size(); - - // Allocate - // -- "percentile" - bins100_.reserve(100); - for (int i = 0; i < 100 + 1; i++) - bins100_.push_back(0); - // -- "uint8" - bins_cust_.reserve(n_custBins); - for (int i = 0; i < n_custBins + 1; i++) - bins_cust_.push_back(0); - - // Cache min/max - minVal_ = min_value; - maxVal_ = max_value; - auto valRange = maxVal_ - minVal_; - - // unique values - for (auto s : raw_data) - U_.push_back (s.inten); - - // Build the "percentile" histogram - binW100_ = double(valRange) / 100.; - for (auto s : raw_data) - { - HistoItem h = s.inten; - double realIdx = double(h - minVal_) / binW100_; - int idx = std::isnan(realIdx) ? 0 : int(realIdx); - (bins100_[idx])++; - } - - // -- Fix the special last bin - bins100_[100 - 1] += bins100_[100]; - bins100_[100] = 0; - - // Build the "uint8" histogram - binWcust_ = double(valRange) / double(n_custBins-1); - for (auto s : raw_data) - { - HistoItem h = Nyxus::to_grayscale(s.inten, minVal_, valRange, n_custBins); - bins_cust_[h] = bins_cust_[h] + 1; - } - - // -- Fix the special last bin - bins_cust_[n_custBins - 1] += bins_cust_[n_custBins]; - bins_cust_[n_custBins] = 0; - - // Mean calculation - meanVal_ = 0; - for (auto s : raw_data) - meanVal_ += double(s.inten); - meanVal_ /= double(pop_); - - // percentiles - calc_percentiles(); - - // robust MAD: threshold on the p10/p90 percentile values, and average the - // 10..90 population before accumulating deviations (mirrors the in-core overload) - mean1090val_ = 0.0; - size_t pop1090 = 0; - for (auto pxl : raw_data) - { - double a = double (pxl.inten); - if (a >= p10_ && a <= p90_) - { - mean1090val_ += a; - pop1090++; - } - } - rmad_ = 0.0; - if (pop1090) - { - mean1090val_ /= double(pop1090); - for (auto pxl : raw_data) - { - double a = double(pxl.inten); - if (a >= p10_ && a <= p90_) - rmad_ += (std::fabs)(a - mean1090val_); - } - rmad_ /= double(pop1090); - } - } - - // Out-of-core 3D voxel-cloud overload: identical math to the OutOfRamPixelCloud overload - // (the disk-backed 2D cloud), sourcing intensities from the disk-backed 3D voxel cloud so - // an oversized volumetric ROI's histogram streams instead of holding the cube. - void initialize (int n_cust_bins, HistoItem min_value, HistoItem max_value, const OutOfRamVoxelCloud& raw_data) + // One implementation for every intensity source: the in-RAM std::vector, and the two + // disk-backed clouds (OutOfRamPixelCloud, OutOfRamVoxelCloud). All expose size() and a + // range-for yielding an item with an .inten field, so a single template serves them and the + // in-core / out-of-core histograms stay identical by construction (fixes that had to be + // applied to each copy -- e.g. the p10/p90 robust-MAD -- now live in one place). + template + void initialize (int n_cust_bins, HistoItem min_value, HistoItem max_value, const Src& raw_data) { // safety int n_custBins = std::abs(n_cust_bins); @@ -222,7 +49,10 @@ class TrivialHistogram maxVal_ = max_value; auto valRange = maxVal_ - minVal_; - // unique values + // unique values (reserve the full population up front: for the disk-backed clouds this + // avoids the log-many reallocations that made the out-of-core pass grow its resident + // footprint one push at a time) + U_.reserve (pop_); for (auto s : raw_data) U_.push_back (s.inten); @@ -261,13 +91,12 @@ class TrivialHistogram // percentiles calc_percentiles(); - // robust MAD: threshold on the p10/p90 percentile values, and average the - // 10..90 population before accumulating deviations (mirrors the in-core overload) + // robust MAD mean1090val_ = 0.0; size_t pop1090 = 0; for (auto pxl : raw_data) { - double a = double (pxl.inten); + double a = double(pxl.inten); if (a >= p10_ && a <= p90_) { mean1090val_ += a; @@ -382,7 +211,7 @@ class TrivialHistogram size_t pop_ = 0; HistoItem minVal_, maxVal_; double meanVal_, binW100_, binWcust_; - double bin10ctr_, bin90ctr_, mean1090val_, rmad_; // robust estimation + double mean1090val_, rmad_; // robust estimation (p10/p90-thresholded) std::vector bins100_, bins_cust_; std::vector U_; double p1_, p10_, p25_, p75_, p90_, p99_; diff --git a/src/nyx/features/voxel_cloud_nontriv.cpp b/src/nyx/features/voxel_cloud_nontriv.cpp index d6baf2619..767295782 100644 --- a/src/nyx/features/voxel_cloud_nontriv.cpp +++ b/src/nyx/features/voxel_cloud_nontriv.cpp @@ -1,6 +1,8 @@ #include +#include #include #include +#include #include #include #include "../environment.h" @@ -41,6 +43,7 @@ void OutOfRamVoxelCloud::init (unsigned int _roi_label, const std::string& name) n_items = 0; slab_first.clear(); slab_count.clear(); + slab_open = false; invalidate_cache(); auto tid = std::this_thread::get_id(); @@ -77,12 +80,13 @@ void OutOfRamVoxelCloud::clear() } slab_first.clear(); slab_count.clear(); + slab_open = false; invalidate_cache(); } void OutOfRamVoxelCloud::begin_slab (size_t z) { - // Planes arrive in ascending order; grow the index so slab_first[z]/slab_count[z] are addressable + // Grow the index so slab_first[z]/slab_count[z] are addressable if (slab_first.size() <= z) { slab_first.resize (z + 1, n_items); @@ -90,6 +94,9 @@ void OutOfRamVoxelCloud::begin_slab (size_t z) } slab_first[z] = n_items; slab_count[z] = 0; + // Remember which plane is open so add_voxel() counts into it explicitly + cur_slab = z; + slab_open = true; } void OutOfRamVoxelCloud::add_voxel (const Pixel3& v) @@ -102,16 +109,16 @@ void OutOfRamVoxelCloud::add_voxel (const Pixel3& v) fwrite ((const void*) &(v.inten), sizeof(v.inten), 1, pF); n_items++; - if (!slab_count.empty()) - slab_count.back()++; + // Credit the voxel to the plane begin_slab() opened, not to the index's last entry -- the + // two coincide only while planes arrive in strictly ascending z + if (slab_open) + slab_count[cur_slab]++; // A write past the cached block would make it stale; the population phase never interleaves // reads, so this just holds the cache empty until the first read. invalidate_cache(); } -void OutOfRamVoxelCloud::end_slab (size_t /*z*/) {} - size_t OutOfRamVoxelCloud::size() const { return n_items; @@ -162,19 +169,3 @@ void OutOfRamVoxelCloud::read_slab (size_t z, std::vector& out) const } read_range (slab_first[z], slab_count[z], out); } - -void OutOfRamVoxelCloud::read_slab_window (size_t z0, size_t z1, std::vector& out) const -{ - // Contiguous on disk because records are Z-sorted: one range spans planes z0..z1 - if (slab_first.empty() || z0 >= slab_first.size()) - { - out.clear(); - return; - } - size_t zi = (z1 < slab_first.size()) ? z1 : slab_first.size() - 1; - size_t first = slab_first[z0]; - size_t count = 0; - for (size_t z = z0; z <= zi; z++) - count += slab_count[z]; - read_range (first, count, out); -} diff --git a/src/nyx/features/voxel_cloud_nontriv.h b/src/nyx/features/voxel_cloud_nontriv.h index ae665785a..a1c9ce9cf 100644 --- a/src/nyx/features/voxel_cloud_nontriv.h +++ b/src/nyx/features/voxel_cloud_nontriv.h @@ -26,21 +26,20 @@ class OutOfRamVoxelCloud void close(); void clear(); - // Population (called in ascending-Z order). begin_slab(z) marks where plane z's records - // start; add_voxel() appends one record; end_slab(z) finalizes the plane's count. + // Population. begin_slab(z) opens plane z's run of records; add_voxel() appends one record + // to the open plane. No end_slab() is needed -- the count is maintained by add_voxel and the + // run is closed implicitly by the next begin_slab() (or by the end of the population scan). void begin_slab (size_t z); void add_voxel (const Pixel3& v); - void end_slab (size_t z); // Whole-cloud linear access (intensity, histogram) size_t size() const; Pixel3 get_at (size_t idx) const; Pixel3 operator[] (size_t idx) const { return get_at(idx); } - // Slab access (surface, later texture): a Z-plane or a [z0,z1] window + // Slab access (surface + the 3D texture features): one Z-plane's voxels size_t depth() const { return slab_first.size(); } void read_slab (size_t z, std::vector& out) const; - void read_slab_window (size_t z0, size_t z1, std::vector& out) const; struct iterator { @@ -65,13 +64,20 @@ class OutOfRamVoxelCloud size_t item_size = sizeof(Pixel3::x) + sizeof(Pixel3::y) + sizeof(Pixel3::z) + sizeof(Pixel3::inten); // Per-Z index built during population: first record index and record count of each plane std::vector slab_first, slab_count; + // Plane whose run of records is currently open, so add_voxel() credits the count to THAT + // plane rather than to whichever plane happens to be last in the index + size_t cur_slab = 0; + bool slab_open = false; void check_io_ok() const; void read_range (size_t first, size_t count, std::vector& out) const; // Block read cache so linear consumers (get_at/operator[]/iterator, histogram) do one bulk // fread per block instead of an fseek + 4 freads per voxel -- the latter made the out-of-core // intensity/histogram passes ~25x slower than the in-RAM path. One block resident (bounded). - static const size_t BLK_RECORDS = 65536; + // constexpr (implicitly inline in C++17+) so ODR-uses like (std::min)(BLK_RECORDS, ...), + // which bind it to a const&, don't need a separate out-of-line definition -- GCC errors + // on the missing definition at link time where MSVC silently tolerates it. + static constexpr size_t BLK_RECORDS = 65536; mutable std::vector blk_buf; mutable long long blk_first = -1; // index of first cached record; -1 = cache empty mutable size_t blk_count = 0; diff --git a/src/nyx/globals.h b/src/nyx/globals.h index f61704f55..3e4d9ef4e 100644 --- a/src/nyx/globals.h +++ b/src/nyx/globals.h @@ -117,6 +117,7 @@ namespace Nyxus bool scan_trivial_wholevolume_anisotropic (LR& vroi, const std::string& intens_fpath, ImageLoader& ldr, double aniso_x, double aniso_y, double aniso_z, size_t channel, size_t timeframe); bool scanTrivialRois_3D (Environment& env, const std::vector& batch_labels, const std::string& intens_fpath, const std::string& label_fpath, size_t t_index, size_t channel); + bool scanTrivialRois_3D_anisotropic (Environment& env, const std::vector& batch_labels, const std::string& intens_fpath, const std::string& label_fpath, size_t t_index, size_t channel, double aniso_x, double aniso_y, double aniso_z); void dump_roi_metrics (const int dim, const std::string& output_dir, const size_t ram_limit, const std::string& seg_fpath, const Uniqueids& uniqueLabels, const Roidata& roiData); void dump_roi_pixels (const int dim, const std::string& output_dir, const std::vector& batch_labels, const std::string& seg_fpath, const Uniqueids& uniqueLabels, const Roidata& roiData); void dump_2d_image_with_halfcontour( diff --git a/src/nyx/image_loader.cpp b/src/nyx/image_loader.cpp index 2b7c8d87b..b44479edc 100644 --- a/src/nyx/image_loader.cpp +++ b/src/nyx/image_loader.cpp @@ -1,4 +1,4 @@ -#define NOMINMAX +#define NOMINMAX #include #include "nyxus_dicom_loader.h" #include "image_loader.h" @@ -8,7 +8,7 @@ #include "dirs_and_files.h" #include "helpers/fsystem.h" #include "raw_nifti.h" -#include "ome/format_detect.h" // FIX: unified content-sniffing loader dispatch +#include "ome/format_detect.h" // container-family classification for loader dispatch ImageLoader::ImageLoader() {} @@ -23,11 +23,10 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) try { - // FIX: classify by detect_input_format() (extension + OME content sniff) instead of - // raw extension compares, so dispatch is identical across all 3 loaders and OME is recognized. - Nyxus::InputFormat fmt = Nyxus::detect_input_format (int_fpath); + // Classify by container family so loader dispatch is identical across all loaders. + Nyxus::ContainerKind fmt = Nyxus::detect_container_family (int_fpath); - if (fmt.kind == Nyxus::ContainerKind::OmeZarr) // FIX: was `ext==".zarr"||".ome.zarr"` + if (fmt == Nyxus::ContainerKind::OmeZarr) { #ifdef OMEZARR_SUPPORT intFL = new NyxusOmeZarrLoader(n_threads, int_fpath); @@ -40,12 +39,12 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) #endif } else - if (fmt.kind == Nyxus::ContainerKind::Dicom) // FIX: was `ext==".dcm"||".dicom"` + if (fmt == Nyxus::ContainerKind::Dicom) { #ifdef DICOM_SUPPORT // HU offset base must be the scanned (HU-domain) slide min. In preserve_hu // mode the slope-1 map bypasses fp min/max/dr, so we must NOT take - // fpopts.min_intensity() (defaults to 0 when --fpimgmin is absent) — that + // fpopts.min_intensity() (defaults to 0 when --fpimgmin is absent) — that // would clamp every negative HU to 0. Only the non-HU float path uses the // fp override min. intFL = new NyxusGrayscaleDicomLoader(n_threads, int_fpath, @@ -60,7 +59,7 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) #endif } else - if (fmt.kind == Nyxus::ContainerKind::Nifti) // FIX: was `ext==".nii"||".nii.gz"` + if (fmt == Nyxus::ContainerKind::Nifti) { intFL = new NiftiLoader (int_fpath, (fpopts.preserve_hu() || fpopts.empty()) ? p.min_preroi_inten : (double)fpopts.min_intensity(), // HU offset base = scanned HU-domain slide min; ignore fp min in preserve_hu mode (else negative HU clamps to 0) @@ -74,7 +73,7 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) double fpmin = p.min_preroi_inten, fpmax = p.max_preroi_inten; // Only the non-HU float path honors the fp override min/max. In preserve_hu - // mode fpmin is the HU offset base and must stay the scanned slide min — + // mode fpmin is the HU offset base and must stay the scanned slide min — // taking fpopts.min_intensity() (0 by default) would clamp every negative // HU to 0. hu_offset() ignores fpmax entirely. if (! fpopts.empty() && ! fpopts.preserve_hu()) @@ -136,11 +135,11 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) try { - // FIX: unify seg dispatch with detect_input_format(). Defect fixed: the seg path only - // matched ".zarr", so a ".ome.zarr" mask mis-routed to the TIFF path (intensity path matched both). - Nyxus::InputFormat fmt = Nyxus::detect_input_format (seg_fpath); + // The mask is classified by the same container family as the intensity, so an + // .ome.zarr mask routes to the Zarr loader (not the TIFF fallback). + Nyxus::ContainerKind fmt = Nyxus::detect_container_family (seg_fpath); - if (fmt.kind == Nyxus::ContainerKind::OmeZarr) // FIX: was `ext==".zarr"` only (dropped .ome.zarr) + if (fmt == Nyxus::ContainerKind::OmeZarr) { #ifdef OMEZARR_SUPPORT segFL = new NyxusOmeZarrLoader(n_threads, seg_fpath); @@ -149,16 +148,16 @@ bool ImageLoader::open (SlideProps & p, const FpImageOptions & fpopts) #endif } else - if (fmt.kind == Nyxus::ContainerKind::Dicom) // FIX: was `ext==".dcm"||".dicom"` + if (fmt == Nyxus::ContainerKind::Dicom) { #ifdef DICOM_SUPPORT segFL = new NyxusGrayscaleDicomLoader(n_threads, seg_fpath); #else - std::cout << "This version of Nyxus was not build with DICOM support." < (seg_fpath); } diff --git a/src/nyx/image_loader.h b/src/nyx/image_loader.h index ca92c30b8..7beca0294 100644 --- a/src/nyx/image_loader.h +++ b/src/nyx/image_loader.h @@ -59,13 +59,6 @@ class ImageLoader size_t get_inten_time(); size_t get_mask_time(); - // Select which channel (C) / timeframe (T) plane subsequent load_tile() calls - // read. Default 0/0 preserves the single-channel, single-timepoint behavior. - void set_channel (size_t c) { cur_channel = c; } - void set_timeframe (size_t t) { cur_timeframe = t; } - size_t get_channel() const { return cur_channel; } - size_t get_timeframe() const { return cur_timeframe; } - private: AbstractTileLoader *segFL = nullptr, *intFL = nullptr; @@ -95,8 +88,11 @@ class ImageLoader int lvl = 0, // Pyramid level lyr = 0; // Layer - size_t cur_channel = 0, // Currently selected channel (C) plane - cur_timeframe = 0; // Currently selected timeframe (T) plane + // Channel (C) / timeframe (T) plane that load_tile() reads. Set by load_volume() / + // stream_volume_planes(), which take the plane as an argument; 0/0 (the single-channel, + // single-timepoint case) until then. + size_t cur_channel = 0, + cur_timeframe = 0; // Whole-volume (X*Y*Z) assembly buffers filled by load_volume() std::vector vol_int_, vol_seg_; diff --git a/src/nyx/image_loader1x.cpp b/src/nyx/image_loader1x.cpp index 06777ba6a..b6d5b023b 100644 --- a/src/nyx/image_loader1x.cpp +++ b/src/nyx/image_loader1x.cpp @@ -1,4 +1,4 @@ -#include +#include #define NOMINMAX @@ -9,7 +9,7 @@ #include "nyxus_dicom_loader.h" #include "dirs_and_files.h" #include "helpers/fsystem.h" -#include "ome/format_detect.h" // FIX: unified content-sniffing loader dispatch +#include "ome/format_detect.h" // container-family classification for loader dispatch ImageLoader1x::ImageLoader1x() {} @@ -19,12 +19,11 @@ bool ImageLoader1x::open (const std::string& fpath, const FpImageOptions & fpopt try { - // FIX: dispatch via detect_input_format() for parity with the other loaders (and to fix - // the bitwise-OR on the DICOM test). This loader carries no NIfTI backend, so Nifti still falls to the - // TIFF branch as before — behavior unchanged there. - Nyxus::InputFormat fmt = Nyxus::detect_input_format (fpath); + // Classify by container family for parity with the other loaders. This loader carries no + // NIfTI backend, so a NIfTI path falls through to the TIFF branch. + Nyxus::ContainerKind fmt = Nyxus::detect_container_family (fpath); - if (fmt.kind == Nyxus::ContainerKind::OmeZarr) // FIX: was `fs::path(fpath).extension()==".zarr"` + if (fmt == Nyxus::ContainerKind::OmeZarr) { #ifdef OMEZARR_SUPPORT FL = std::make_unique>(n_threads, fpath); @@ -32,7 +31,7 @@ bool ImageLoader1x::open (const std::string& fpath, const FpImageOptions & fpopt std::cout << "This version of Nyxus was not build with OmeZarr support." <>(n_threads, fpath); #else diff --git a/src/nyx/ome/format_detect.cpp b/src/nyx/ome/format_detect.cpp index 394abb17f..45ca849d5 100644 --- a/src/nyx/ome/format_detect.cpp +++ b/src/nyx/ome/format_detect.cpp @@ -12,17 +12,28 @@ namespace Nyxus { - const char* to_string(ContainerKind k) + // Big-extension classification, with TIFF flavors left undifferentiated. This is all the + // loader dispatch needs, and it touches no file content. + static ContainerKind kind_of_extension (const std::string& path) { - switch (k) - { - case ContainerKind::TiffPlain: return "TiffPlain"; - case ContainerKind::OmeTiff: return "OmeTiff"; - case ContainerKind::OmeZarr: return "OmeZarr"; - case ContainerKind::Dicom: return "Dicom"; - case ContainerKind::Nifti: return "Nifti"; - default: return "Unknown"; - } + std::string ext = Nyxus::get_big_extension(path); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + + if (ext == ".zarr" || ext == ".ome.zarr") + return ContainerKind::OmeZarr; + if (ext == ".dcm" || ext == ".dicom") + return ContainerKind::Dicom; + if (ext == ".nii" || ext == ".nii.gz") + return ContainerKind::Nifti; + + // Everything else is a TIFF flavor (.tif/.tiff/.ome.tif/.ome.tiff/.tf2/.tf8/.btf). + return ContainerKind::TiffPlain; + } + + ContainerKind detect_container_family (const std::string& path) + { + return kind_of_extension (path); } // Does this TIFF carry an OME-XML block in IFD-0's ImageDescription? @@ -69,20 +80,18 @@ namespace Nyxus InputFormat detect_input_format(const std::string& path) { - std::string ext = Nyxus::get_big_extension(path); - std::transform(ext.begin(), ext.end(), ext.begin(), - [](unsigned char c) { return (char)std::tolower(c); }); - - if (ext == ".zarr" || ext == ".ome.zarr") - return { ContainerKind::OmeZarr, zarr_has_multiscales(path) }; - if (ext == ".dcm" || ext == ".dicom") - return { ContainerKind::Dicom, false }; - if (ext == ".nii" || ext == ".nii.gz") - return { ContainerKind::Nifti, false }; - - // Everything else is a TIFF flavor (.tif/.tiff/.ome.tif/.ome.tiff/.tf2/.tf8/.btf). - if (tiff_is_ome(path)) - return { ContainerKind::OmeTiff, true }; - return { ContainerKind::TiffPlain, false }; + switch (kind_of_extension (path)) + { + case ContainerKind::OmeZarr: + return { ContainerKind::OmeZarr, zarr_has_multiscales(path) }; + case ContainerKind::Dicom: + return { ContainerKind::Dicom, false }; + case ContainerKind::Nifti: + return { ContainerKind::Nifti, false }; + default: + // TIFF: only the content sniff can tell an OME-TIFF from a plain one + return tiff_is_ome(path) ? InputFormat{ ContainerKind::OmeTiff, true } + : InputFormat{ ContainerKind::TiffPlain, false }; + } } } diff --git a/src/nyx/ome/format_detect.h b/src/nyx/ome/format_detect.h index f45eb42e8..7d084038c 100644 --- a/src/nyx/ome/format_detect.h +++ b/src/nyx/ome/format_detect.h @@ -18,9 +18,14 @@ namespace Nyxus bool is_ome = false; // OME metadata actually present (OME-XML / NGFF multiscales) }; + // Which loader backend handles this path, from the big-extension alone -- TIFF flavors are + // reported as TiffPlain because a single TIFF loader serves both. This is what the loader + // dispatch sites use: it opens nothing, so it stays cheap even though ImageLoader::open() + // is called once per oversized ROI. Never throws. + ContainerKind detect_container_family (const std::string& path); + // Classify by big-extension, then content-sniff: OME-XML in TIFF IFD-0 - // ImageDescription, or NGFF `multiscales` in a Zarr group. Never throws. + // ImageDescription, or NGFF `multiscales` in a Zarr group. Distinguishes OmeTiff from + // TiffPlain and reports is_ome, at the cost of opening the file. Never throws. InputFormat detect_input_format(const std::string& path); - - const char* to_string(ContainerKind k); } diff --git a/src/nyx/ome/ome_axes.h b/src/nyx/ome/ome_axes.h index ca0d09346..fbb0054d9 100644 --- a/src/nyx/ome/ome_axes.h +++ b/src/nyx/ome/ome_axes.h @@ -178,6 +178,35 @@ namespace Nyxus // ---- small shared helpers ---------------------------------------------- + // Length-unit -> micrometer scale factor (OME-XML/NGFF use the full UDUNITS-2 names; + // tolerate common short forms too, since not every writer follows the spec exactly). + // Returns 1.0 (no-op) for an empty/unrecognized unit -- an unrecognized unit is left + // as-is rather than silently misscaled, and the caller keeps the original unit string + // in that case so the mismatch stays visible instead of masquerading as "micrometer". + inline double unit_scale_to_micrometer(const std::string& unit) + { + if (unit == "meter" || unit == "metre" || unit == "m") return 1e6; + if (unit == "centimeter" || unit == "centimetre" || unit == "cm") return 1e4; + if (unit == "millimeter" || unit == "millimetre" || unit == "mm") return 1e3; + if (unit == "micrometer" || unit == "micrometre" || unit == "micron" || unit == "um" || unit == "\xC2\xB5m") return 1.0; + if (unit == "nanometer" || unit == "nanometre" || unit == "nm") return 1e-3; + if (unit == "angstrom" || unit == "\xC3\x85") return 1e-4; + if (unit == "picometer" || unit == "picometre" || unit == "pm") return 1e-6; + return 0.0; // unrecognized (includes "" == uncalibrated) + } + + // Canonicalize a physical-size value + its declared unit to micrometer in place. + // A recognized non-micrometer unit is converted and relabeled "micrometer"; an + // unrecognized/empty unit is left untouched (value AND unit string both unchanged). + inline void canonicalize_to_micrometer(double& physical, std::string& unit) + { + double scale = unit_scale_to_micrometer(unit); + if (scale == 0.0) + return; + physical *= scale; + unit = "micrometer"; + } + inline AxisKind axis_kind_of(char label) { return (label == 'C') ? AxisKind::Channel : (label == 'T') ? AxisKind::Time : AxisKind::Space; diff --git a/src/nyx/ome/ome_tiff_meta.cpp b/src/nyx/ome/ome_tiff_meta.cpp index 9b9b786f9..b35df94a7 100644 --- a/src/nyx/ome/ome_tiff_meta.cpp +++ b/src/nyx/ome/ome_tiff_meta.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include namespace Nyxus { @@ -72,14 +74,21 @@ namespace Nyxus if (ec != std::errc() || ptr == v.data()) return def; return r < 1 ? std::size_t(1) : r; }; - // Double reader (std::from_chars): absent / non-numeric / non-finite -> - // default. from_chars still accepts "nan"/"inf" prefixes (e.g. "NaNsense"), - // so the isfinite check keeps those out of physical-calibration math. + // Double reader: absent / non-numeric / non-finite -> default. libc++ provides + // std::from_chars for integral types only (its floating-point overloads are + // deleted or constrained to is_integral), so the double form does not compile + // there; a stream imbued with the classic locale reads the same value on every + // standard library and, unlike strtod, ignores the host's LC_NUMERIC, which a + // process embedding the library may have set to a comma-decimal locale. + // "nan"/"inf" prefixes (e.g. "NaNsense") still parse, so the isfinite check + // keeps those out of physical-calibration math. auto d = [&](const char* n, double def) -> double { std::string v; if (!get_attr(pix, n, v)) return def; + std::istringstream is(v); + is.imbue(std::locale::classic()); double r = 0; - auto [ptr, ec] = std::from_chars(v.data(), v.data() + v.size(), r); - if (ec != std::errc() || ptr == v.data() || !std::isfinite(r)) return def; + is >> r; + if (is.fail() || !std::isfinite(r)) return def; return r; }; @@ -174,6 +183,16 @@ namespace Nyxus ax.unitZ = s("PhysicalSizeZUnit", ""); ax.unitT = s("TimeIncrementUnit", ""); + // Canonicalize each axis to micrometer using ITS OWN declared unit (X/Y share + // unitXY, Z has its own unitZ) -- a file that declares Z in a different unit than + // X/Y (or either in nm/mm/etc.) previously reported raw, uncomparable values under + // a unit label that only ever reflected X/Y. No-op for an already-micrometer or + // unrecognized/uncalibrated unit. + std::string unitY = ax.unitXY; + canonicalize_to_micrometer(ax.physX, ax.unitXY); + canonicalize_to_micrometer(ax.physY, unitY); + canonicalize_to_micrometer(ax.physZ, ax.unitZ); + // On-disk order = reverse(DimensionOrder) with singleton axes dropped // (planes are XY, so the result always ends in "YX"). auto size_of = [&](char c) -> std::size_t { diff --git a/src/nyx/ome/ome_zarr_layout.h b/src/nyx/ome/ome_zarr_layout.h new file mode 100644 index 000000000..b85149237 --- /dev/null +++ b/src/nyx/ome/ome_zarr_layout.h @@ -0,0 +1,172 @@ +#pragma once + +// Shared OME-Zarr array layout resolution for the two Zarr loaders (omezarr.h's +// NyxusOmeZarrLoader and raw_omezarr.h's RawOmezarrLoader). Both open the same containers and +// need the same answers -- which storage dimension carries which axis role, the X/Y/Z extents +// and chunking, the C/T extents, the physical voxel spacing and the pixel type -- so the +// resolution lives here once instead of being duplicated in both headers. +// +// Guarded by OMEZARR_SUPPORT because it depends on z5 (and, through ome_zarr_meta.h, on +// nlohmann::json vendored with z5). + +#ifdef OMEZARR_SUPPORT + +#include +#include +#include +#include + +#include "nlohmann/json.hpp" +#include "z5/types/types.hxx" + +#include "ome_axes.h" +#include "ome_zarr_meta.h" + +namespace Nyxus +{ + // z5 Datatype -> the " read as uint16 + } + } + + /// @brief Resolve the array layout from NGFF metadata + the level-0 array's shape/chunking. + /// Prefers the 'axes' block (so the on-disk order is honored rather than assumed to be + /// TCZYX); falls back to a rank-safe positional mapping when 'axes' is absent or unusable. + /// Throws if the metadata is self-inconsistent (axis count vs array rank) or if X/Y cannot + /// be resolved -- either would make the read index out of bounds. + inline ZarrLayout resolve_zarr_layout ( + const nlohmann::json& file_attributes, + const std::vector& level0Shape, + const std::vector& chunkShape, + z5::types::Datatype dt) + { + ZarrLayout L; + + const std::string dtype_str = zarr_dtype_string_of (dt); + L.dtype = pixel_type_from_zarr_dtype (dtype_str); + L.bits_per_sample = (short) bits_of (L.dtype); + L.data_format = zarr_read_format_of (L.dtype); + L.fp_pixels = is_float (L.dtype); + + OmeAxes axes = parse_ome_zarr (file_attributes, level0Shape, dtype_str); + if (axes.valid) + { + // Reject self-inconsistent metadata rather than guess: if the 'axes' count + // disagrees with the array rank, indexing the shape by axis role would read + // out of bounds. + if (axes.storageAxes.size() != level0Shape.size()) + throw std::runtime_error("OME-Zarr: 'axes' count " + std::to_string(axes.storageAxes.size()) + + " does not match array rank " + std::to_string(level0Shape.size())); + L.ndim = axes.storageAxes.size(); + L.ix = axes.storageIndexOf('X'); L.iy = axes.storageIndexOf('Y'); + L.iz = axes.storageIndexOf('Z'); L.ic = axes.storageIndexOf('C'); + L.it = axes.storageIndexOf('T'); + L.n_levels = axes.numberPyramidLevels(); + // Advertise the real C/T extents so the pipeline iterates channels and timeframes; + // without this the base class default of 1 keeps it on plane (c=0,t=0). + L.n_channels = axes.sizeC; + L.n_timeframes = axes.sizeT; + // Keep the parsed physical voxel spacing for opt-in calibration. + L.phys_x = axes.physX; L.phys_y = axes.physY; L.phys_z = axes.physZ; + L.phys_unit = axes.unitXY; + } + else + { + // No usable 'axes': map by position (X,Y last; Z,C,T before) -- rank-safe. + L.ndim = level0Shape.size(); + int n = (int) L.ndim; + L.ix = n - 1; L.iy = n - 2; + L.iz = (n >= 3) ? n - 3 : -1; + L.ic = (n >= 4) ? n - 4 : -1; + L.it = (n >= 5) ? n - 5 : -1; + L.n_levels = 1; + // Derive C/T extents from the positional axes so the fallback path also reports + // multi-channel / time-series counts (1 when the axis is absent). + L.n_channels = (L.ic >= 0) ? level0Shape[L.ic] : 1; + L.n_timeframes = (L.it >= 0) ? level0Shape[L.it] : 1; + } + + // X and Y must resolve to real dimensions, else the read would index OOB. + if (L.ix < 0 || L.iy < 0 || (std::size_t) L.ix >= level0Shape.size() || (std::size_t) L.iy >= level0Shape.size()) + throw std::runtime_error("OME-Zarr: cannot resolve X/Y axes from metadata"); + + L.full_width = level0Shape[L.ix]; + L.full_height = level0Shape[L.iy]; + L.full_depth = (L.iz >= 0) ? level0Shape[L.iz] : 1; + L.tile_width = chunkShape[L.ix]; + L.tile_height = chunkShape[L.iy]; + L.tile_depth = (L.iz >= 0) ? chunkShape[L.iz] : 1; + + return L; + } +} + +#endif // OMEZARR_SUPPORT diff --git a/src/nyx/ome/ome_zarr_meta.cpp b/src/nyx/ome/ome_zarr_meta.cpp index 0665b40e3..bbd95eb94 100644 --- a/src/nyx/ome/ome_zarr_meta.cpp +++ b/src/nyx/ome/ome_zarr_meta.cpp @@ -79,6 +79,12 @@ namespace Nyxus else oa.kind = AxisKind::Space; oa.size = (k < level0Shape.size()) ? level0Shape[k] : 1; oa.physical = (k < scale0.size()) ? scale0[k] : 1.0; + // Canonicalize space axes (X/Y/Z) to micrometer using their OWN declared unit + // (a Z axis calibrated in a different unit than X/Y previously reported its raw, + // uncomparable value under whatever label X/Y happened to carry). Time/channel + // axes are left alone -- a time unit like "second" isn't a length to convert. + if (label == 'X' || label == 'Y' || label == 'Z') + canonicalize_to_micrometer(oa.physical, unit); oa.unit = unit; ax.storageAxes.push_back(oa); ax.storageOrder.push_back(label); diff --git a/src/nyx/omezarr.h b/src/nyx/omezarr.h index 1a0efd7b3..33bf0071d 100644 --- a/src/nyx/omezarr.h +++ b/src/nyx/omezarr.h @@ -7,7 +7,7 @@ #include #include "abs_tile_loader.h" #include "nlohmann/json.hpp" -#include "ome/ome_zarr_meta.h" // parse_ome_zarr -> OmeAxes (axis-role resolution) +#include "ome/ome_zarr_layout.h" // resolve_zarr_layout() -- shared with raw_omezarr.h // factory functions to create files, groups and datasets #include "z5/factory.hxx" @@ -23,32 +23,6 @@ // attribute functionality #include "z5/attributes.hxx" -// FIX (IO): z5 Datatype -> the " @@ -69,12 +43,8 @@ class NyxusOmeZarrLoader : public AbstractTileLoader nlohmann::json file_attributes; z5::readAttributes(*zarr_ptr_, file_attributes); - // Resolve the level-0 dataset path. NGFF 0.5 (Zarr v3) nests 'multiscales' under an - // "ome" key; NGFF 0.4 (Zarr v2) has it at the attributes root. (parse_ome_zarr handles - // both, but we need the path here to open the dataset.) - const nlohmann::json& ms_root = (file_attributes.contains("ome") && file_attributes["ome"].contains("multiscales")) - ? file_attributes["ome"] : file_attributes; - ds_name_ = ms_root["multiscales"][0]["datasets"][0]["path"].get(); + // Resolve the level-0 dataset path (needed here to open the dataset) + ds_name_ = Nyxus::zarr_multiscales_root(file_attributes)["multiscales"][0]["datasets"][0]["path"].get(); // FIX (IO): open the dataset via z5, which auto-detects Zarr v2 (.zarray) vs v3 // (zarr.json) and handles the v3 chunk-key encoding, codecs (zstd/blosc/...) and @@ -84,72 +54,18 @@ class NyxusOmeZarrLoader : public AbstractTileLoader ds_ = z5::openDataset(*zarr_ptr_, ds_name_); std::vector level0Shape(ds_->shape().begin(), ds_->shape().end()); std::vector chunkShape(ds_->defaultChunkShape().begin(), ds_->defaultChunkShape().end()); - std::string dtype_str = zarr_dtype_string_of(ds_->getDtype()); - - // Resolve axis roles from the NGFF 'axes' metadata instead of assuming a - // fixed [T,C,Z,Y,X] order. Falls back to legacy 5D TCZYX if 'axes' is absent. - Nyxus::OmeAxes axes = Nyxus::parse_ome_zarr(file_attributes, level0Shape, dtype_str); - if (axes.valid) - { - // Reject self-inconsistent metadata rather than guess: if the 'axes' - // count disagrees with the array rank, indexing the shape by axis role - // would read out of bounds. - if (axes.storageAxes.size() != level0Shape.size()) - throw std::runtime_error("OME-Zarr: 'axes' count " + std::to_string(axes.storageAxes.size()) - + " does not match array rank " + std::to_string(level0Shape.size())); - ndim_ = axes.storageAxes.size(); - ix_ = axes.storageIndexOf('X'); iy_ = axes.storageIndexOf('Y'); - iz_ = axes.storageIndexOf('Z'); ic_ = axes.storageIndexOf('C'); - it_ = axes.storageIndexOf('T'); - n_levels_ = axes.numberPyramidLevels(); - // FIX: advertise the real C/T extents so the pipeline iterates channels and - // timeframes; without this the base class default of 1 kept it on plane (c=0,t=0). - n_channels_ = axes.sizeC; - n_timeframes_ = axes.sizeT; - // FIX (IO): keep the parsed physical voxel spacing for opt-in calibration. - phys_x_ = axes.physX; phys_y_ = axes.physY; phys_z_ = axes.physZ; - phys_unit_ = axes.unitXY; - } - else - { - // No usable 'axes': map by position (X,Y last; Z,C,T before) — rank-safe. - ndim_ = level0Shape.size(); - int n = (int)ndim_; - ix_ = n - 1; iy_ = n - 2; - iz_ = (n >= 3) ? n - 3 : -1; - ic_ = (n >= 4) ? n - 4 : -1; - it_ = (n >= 5) ? n - 5 : -1; - n_levels_ = 1; - // FIX: derive C/T extents from the positional axes so the fallback path also - // reports multi-channel / time-series counts (1 when the axis is absent). - n_channels_ = (ic_ >= 0) ? level0Shape[ic_] : 1; - n_timeframes_ = (it_ >= 0) ? level0Shape[it_] : 1; - } - // X and Y must resolve to real dimensions, else the read would index OOB. - if (ix_ < 0 || iy_ < 0 || (size_t)ix_ >= level0Shape.size() || (size_t)iy_ >= level0Shape.size()) - throw std::runtime_error("OME-Zarr: cannot resolve X/Y axes from metadata"); - - full_width_ = level0Shape[ix_]; - full_height_ = level0Shape[iy_]; - full_depth_ = (iz_ >= 0) ? level0Shape[iz_] : 1; - tile_width_ = chunkShape[ix_]; - tile_height_ = chunkShape[iy_]; - tile_depth_ = (iz_ >= 0) ? chunkShape[iz_] : 1; - bits_per_sample_ = Nyxus::bits_of(Nyxus::pixel_type_from_zarr_dtype(dtype_str)); - // dtype -> internal format code for the read-template dispatch - if (dtype_str == "getDtype()); + ndim_ = L.ndim; + ix_ = L.ix; iy_ = L.iy; iz_ = L.iz; ic_ = L.ic; it_ = L.it; + full_width_ = L.full_width; full_height_ = L.full_height; full_depth_ = L.full_depth; + tile_width_ = L.tile_width; tile_height_ = L.tile_height; tile_depth_ = L.tile_depth; + n_levels_ = L.n_levels; n_channels_ = L.n_channels; n_timeframes_ = L.n_timeframes; + phys_x_ = L.phys_x; phys_y_ = L.phys_y; phys_z_ = L.phys_z; phys_unit_ = L.phys_unit; + bits_per_sample_ = L.bits_per_sample; + data_format_ = L.data_format; } /// @brief NyxusOmeZarrLoader destructor diff --git a/src/nyx/phase2_3d.cpp b/src/nyx/phase2_3d.cpp index 178bc8a74..4e3d59c18 100644 --- a/src/nyx/phase2_3d.cpp +++ b/src/nyx/phase2_3d.cpp @@ -475,9 +475,6 @@ namespace Nyxus size_t channel, size_t timeframe) { - int lvl = 0, // Pyramid level - lyr = 0; // Layer - // Read the tiffs size_t fullwidth = ilo.get_full_width(), @@ -539,9 +536,6 @@ namespace Nyxus size_t channel, size_t timeframe) { - int lvl = 0, // Pyramid level - lyr = 0; // Layer - // Read the tiffs size_t fullW = ilo.get_full_width(), @@ -568,7 +562,7 @@ namespace Nyxus const std::vector& dataI = ilo.get_int_volume_buffer(); // iterate virtual voxels - size_t vSliceSize = vh * vw, + size_t vSliceSize = vh * vw, nVox = vh * vw * vd; for (size_t i = 0; i < nVox; i++) { @@ -577,14 +571,18 @@ namespace Nyxus y = (i - z * vSliceSize) / vw, x = (i - z * vSliceSize) % vw; - // physical voxel position - size_t ph_x = (size_t) (double(x) / aniso_x), - ph_y = (size_t) (double(y) / aniso_y), - ph_z = (size_t) (double(z) / aniso_z); - i = ph_z * sliceSize + ph_y * fullH + ph_x; + // physical voxel position, clamped against float-rounding at the ratio boundary + // (own local variable -- must NOT reuse the loop counter 'i': doing so corrupted + // every subsequent iteration's z/y/x derivation above, since the loop's own i++ + // then advanced by the physical stride instead of by one virtual voxel, which + // could run far longer than nVox iterations before (if ever) satisfying i < nVox). + size_t ph_x = (std::min) ((size_t) (double(x) / aniso_x), fullW - 1), + ph_y = (std::min) ((size_t) (double(y) / aniso_y), fullH - 1), + ph_z = (std::min) ((size_t) (double(z) / aniso_z), fullD - 1); + size_t phys_i = ph_z * sliceSize + ph_y * fullW + ph_x; - // Cache this pixel - feed_pixel_2_cache_3D_LR (x, y, z, dataI[i], vroi); + // Cache this pixel + feed_pixel_2_cache_3D_LR (x, y, z, dataI[phys_i], vroi); } @@ -713,11 +711,16 @@ namespace Nyxus { scanTrivialRois_3D_anisotropic (env, Pending, intens_fpath, label_fpath, t_index, channel, ax, ay, az); - // rescan and update ROI's AABB + // rescan and update ROI's AABB and voxel count -- gatherRoisMetrics_3D set + // aux_area from the PHYSICAL (pre-resample) voxel count; the anisotropic + // scan just populated raw_pixels_3D with the resampled (virtual) cloud, + // which is a different size. Features that divide by aux_area (e.g. MEAN) + // were silently off by the resampling factor without this. for (auto lbl : Pending) { LR& r = env.roiData[lbl]; r.aabb.update_from_voxelcloud (r.raw_pixels_3D); + r.aux_area = (unsigned int) r.raw_pixels_3D.size(); } } @@ -778,11 +781,13 @@ namespace Nyxus { scanTrivialRois_3D_anisotropic (env, Pending, intens_fpath, label_fpath, t_index, channel, ax, ay, az); - // rescan and update ROI's AABB + // rescan and update ROI's AABB and voxel count -- see the identical fix (and + // its rationale) in the main batch loop above. for (auto lbl : Pending) { LR& r = env.roiData[lbl]; r.aabb.update_from_voxelcloud(r.raw_pixels_3D); + r.aux_area = (unsigned int) r.raw_pixels_3D.size(); } } diff --git a/src/nyx/phase3_3d.cpp b/src/nyx/phase3_3d.cpp index 05971252f..89fcb8552 100644 --- a/src/nyx/phase3_3d.cpp +++ b/src/nyx/phase3_3d.cpp @@ -1,3 +1,6 @@ +#include +#include +#include #include #include @@ -68,7 +71,6 @@ namespace Nyxus if (wholevolume || segPlane[i] == want) r.raw_voxels_NT.add_voxel (Pixel3(x, y, z, intPlane[i])); } - r.raw_voxels_NT.end_slab (z); }); if (! streamed) diff --git a/src/nyx/raw_image_loader.cpp b/src/nyx/raw_image_loader.cpp index 5a388fc2f..041804988 100644 --- a/src/nyx/raw_image_loader.cpp +++ b/src/nyx/raw_image_loader.cpp @@ -1,4 +1,4 @@ -#include +#include #define NOMINMAX @@ -9,7 +9,7 @@ #include "raw_nifti.h" #include "raw_omezarr.h" #include "raw_tiff.h" -#include "ome/format_detect.h" // FIX: unified content-sniffing loader dispatch +#include "ome/format_detect.h" // container-family classification for loader dispatch RawImageLoader::RawImageLoader() {} @@ -17,11 +17,11 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ { try { - // FIX: classify via detect_input_format(). Defects fixed: (1) matched only ".zarr" so - // ".ome.zarr" mis-routed to TIFF; (2) `ext==".dcm" | ext==".dicom"` used bitwise-OR (works by luck). - Nyxus::InputFormat fmt = Nyxus::detect_input_format (int_fpath); + // Classify by container family so .ome.zarr routes to Zarr and the DICOM test is a + // clean equality comparison. + Nyxus::ContainerKind fmt = Nyxus::detect_container_family (int_fpath); - if (fmt.kind == Nyxus::ContainerKind::OmeZarr) // FIX: was `ext==".zarr"` only (dropped .ome.zarr) + if (fmt == Nyxus::ContainerKind::OmeZarr) { #ifdef OMEZARR_SUPPORT intFL = new RawOmezarrLoader (int_fpath); @@ -30,7 +30,7 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ #endif } else - if (fmt.kind == Nyxus::ContainerKind::Dicom) { // FIX: was bitwise `ext==".dcm" | ext==".dicom"` + if (fmt == Nyxus::ContainerKind::Dicom) { #ifdef DICOM_SUPPORT intFL = new RawDicomLoader (int_fpath, preserve_hu); // CT/HU: scan in Hounsfield domain #else @@ -38,9 +38,9 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ #endif } else - if (fmt.kind == Nyxus::ContainerKind::Nifti) // FIX: was `ext==".nii"||".nii.gz"` + if (fmt == Nyxus::ContainerKind::Nifti) { - intFL = new RawNiftiLoader (int_fpath, preserve_hu); // FIX: CT/HU: scan in Hounsfield domain (matches DICOM) + intFL = new RawNiftiLoader (int_fpath, preserve_hu); // CT/HU: scan in Hounsfield domain (matches DICOM) } else { @@ -85,10 +85,9 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ // segmented slide try { - // FIX: unified seg dispatch — same defects as the intensity path (".ome.zarr" dropped, - // bitwise-OR on the DICOM test) fixed here via detect_input_format(). - Nyxus::InputFormat fmt = Nyxus::detect_input_format (seg_fpath); - if (fmt.kind == Nyxus::ContainerKind::OmeZarr) // FIX: was `ext==".zarr"` only (dropped .ome.zarr) + // Classify the mask by the same container family as the intensity path. + Nyxus::ContainerKind fmt = Nyxus::detect_container_family (seg_fpath); + if (fmt == Nyxus::ContainerKind::OmeZarr) { #ifdef OMEZARR_SUPPORT segFL = new RawOmezarrLoader (seg_fpath); @@ -97,7 +96,7 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ #endif } else - if (fmt.kind == Nyxus::ContainerKind::Dicom) // FIX: was bitwise `ext==".dcm" | ext==".dicom"` + if (fmt == Nyxus::ContainerKind::Dicom) { #ifdef DICOM_SUPPORT segFL = new RawDicomLoader (seg_fpath); @@ -106,7 +105,7 @@ bool RawImageLoader::open (const std::string& int_fpath, const std::string& seg_ #endif } else - if (fmt.kind == Nyxus::ContainerKind::Nifti) // FIX: was `ext==".nii"||".nii.gz"` + if (fmt == Nyxus::ContainerKind::Nifti) { segFL = new RawNiftiLoader (seg_fpath); } diff --git a/src/nyx/raw_image_loader.h b/src/nyx/raw_image_loader.h index eb5c1db03..7840e716d 100644 --- a/src/nyx/raw_image_loader.h +++ b/src/nyx/raw_image_loader.h @@ -32,8 +32,9 @@ class RawImageLoader // // Walks the tile GRID of each plane, so planes spanning several tiles/chunks are handled; // NIfTI-style whole-4D loaders (tileDepth == full depth) are slabbed out via frameBase. - // fn is invoked ONLY for in-mask voxels (msk != 0), as fn(x, y, z, intensity, msk); in - // whole-slide mode every voxel is in-mask with msk == 1. + // fn is invoked for EVERY voxel, as fn(x, y, z, intensity, msk) -- msk may be 0 for an + // off-ROI voxel in segmented mode (the caller decides what, if anything, to do with it); + // in whole-slide mode every voxel is in-mask with msk == 1. template bool for_each_voxel (size_t channel, size_t timeframe, F&& fn) { @@ -82,8 +83,6 @@ class RawImageLoader { const size_t src = frameBase + (pz * th + row) * tw + col; const uint32_t msk = haveSeg ? segFL->get_uint32_pixel (src) : (uint32_t)1; - if (!msk) - continue; // off-ROI: skip before paying for the intensity read fn (col0 + col, row0 + row, gz, intFL->get_dpequiv_pixel (src), msk); } } @@ -118,13 +117,6 @@ class RawImageLoader double get_physical_size_z(); std::string get_physical_size_unit(); - // Select which channel (C) / timeframe (T) plane subsequent load_tile() calls - // read. Default 0/0 preserves the single-channel, single-timepoint behavior. - void set_channel (size_t c) { cur_channel = c; } - void set_timeframe (size_t t) { cur_timeframe = t; } - size_t get_channel() const { return cur_channel; } - size_t get_timeframe() const { return cur_timeframe; } - std::string get_slide_descr(); bool get_fp_phys_pixvoxels(); @@ -153,8 +145,10 @@ class RawImageLoader int lvl = 0, // Pyramid level lyr = 0; // Layer - size_t cur_channel = 0, // Currently selected channel (C) plane - cur_timeframe = 0; // Currently selected timeframe (T) plane + // Channel (C) / timeframe (T) plane that load_tile() reads. for_each_voxel() takes the + // plane as an argument instead; 0/0 (single-channel, single-timepoint) otherwise. + size_t cur_channel = 0, + cur_timeframe = 0; }; diff --git a/src/nyx/raw_omezarr.h b/src/nyx/raw_omezarr.h index c795dea33..0d4018c3f 100644 --- a/src/nyx/raw_omezarr.h +++ b/src/nyx/raw_omezarr.h @@ -6,7 +6,7 @@ #include #include #include "nlohmann/json.hpp" -#include "ome/ome_zarr_meta.h" // parse_ome_zarr -> OmeAxes (axis-role resolution) +#include "ome/ome_zarr_layout.h" // resolve_zarr_layout() -- shared with omezarr.h // factory functions to create files, groups and datasets #include "z5/factory.hxx" @@ -24,29 +24,6 @@ #include "raw_format.h" -// FIX (IO): z5 Datatype -> the "(); + // Resolve the level-0 dataset path + ds_name_ = Nyxus::zarr_multiscales_root(file_attributes)["multiscales"][0]["datasets"][0]["path"].get(); // FIX (IO): open via z5 (auto-detects Zarr v2 .zarray vs v3 zarr.json, handles v3 // chunk-key encoding, codecs and sharding) and query shape/chunking/dtype from the @@ -70,72 +44,19 @@ class RawOmezarrLoader: public RawFormatLoader ds_ = z5::openDataset(*zarr_ptr_, ds_name_); std::vector level0Shape(ds_->shape().begin(), ds_->shape().end()); std::vector chunkShape(ds_->defaultChunkShape().begin(), ds_->defaultChunkShape().end()); - std::string dtype_str = zarr_dtype_string_of(ds_->getDtype()); - - // Resolve axis roles from the NGFF 'axes' metadata instead of assuming a - // fixed [T,C,Z,Y,X] order. Falls back to legacy 5D TCZYX if 'axes' is absent. - Nyxus::OmeAxes axes = Nyxus::parse_ome_zarr(file_attributes, level0Shape, dtype_str); - if (axes.valid) - { - // Reject self-inconsistent metadata rather than guess: if the 'axes' - // count disagrees with the array rank, indexing the shape by axis role - // would read out of bounds. - if (axes.storageAxes.size() != level0Shape.size()) - throw std::runtime_error("OME-Zarr: 'axes' count " + std::to_string(axes.storageAxes.size()) - + " does not match array rank " + std::to_string(level0Shape.size())); - ndim_ = axes.storageAxes.size(); - ix_ = axes.storageIndexOf('X'); iy_ = axes.storageIndexOf('Y'); - iz_ = axes.storageIndexOf('Z'); ic_ = axes.storageIndexOf('C'); - it_ = axes.storageIndexOf('T'); - n_levels_ = axes.numberPyramidLevels(); - // FIX: advertise the real C/T extents so the pipeline iterates channels and - // timeframes; without this the base class default of 1 kept it on plane (c=0,t=0). - n_channels_ = axes.sizeC; - n_timeframes_ = axes.sizeT; - // FIX (IO): keep the parsed physical voxel spacing for opt-in calibration. - phys_x_ = axes.physX; phys_y_ = axes.physY; phys_z_ = axes.physZ; - phys_unit_ = axes.unitXY; - } - else - { - // No usable 'axes': map by position (X,Y last; Z,C,T before) — rank-safe. - ndim_ = level0Shape.size(); - int n = (int)ndim_; - ix_ = n - 1; iy_ = n - 2; - iz_ = (n >= 3) ? n - 3 : -1; - ic_ = (n >= 4) ? n - 4 : -1; - it_ = (n >= 5) ? n - 5 : -1; - n_levels_ = 1; - // FIX: derive C/T extents from the positional axes so the fallback path also - // reports multi-channel / time-series counts (1 when the axis is absent). - n_channels_ = (ic_ >= 0) ? level0Shape[ic_] : 1; - n_timeframes_ = (it_ >= 0) ? level0Shape[it_] : 1; - } - // X and Y must resolve to real dimensions, else the read would index OOB. - if (ix_ < 0 || iy_ < 0 || (size_t)ix_ >= level0Shape.size() || (size_t)iy_ >= level0Shape.size()) - throw std::runtime_error("OME-Zarr: cannot resolve X/Y axes from metadata"); - - full_width_ = level0Shape[ix_]; - full_height_ = level0Shape[iy_]; - full_depth_ = (iz_ >= 0) ? level0Shape[iz_] : 1; - tile_width_ = chunkShape[ix_]; - tile_height_ = chunkShape[iy_]; - tile_depth_ = (iz_ >= 0) ? chunkShape[iz_] : 1; - bits_per_sample_ = Nyxus::bits_of(Nyxus::pixel_type_from_zarr_dtype(dtype_str)); - // dtype -> internal format code for the read-template dispatch - if (dtype_str == "getDtype()); + ndim_ = L.ndim; + ix_ = L.ix; iy_ = L.iy; iz_ = L.iz; ic_ = L.ic; it_ = L.it; + full_width_ = L.full_width; full_height_ = L.full_height; full_depth_ = L.full_depth; + tile_width_ = L.tile_width; tile_height_ = L.tile_height; tile_depth_ = L.tile_depth; + n_levels_ = L.n_levels; n_channels_ = L.n_channels; n_timeframes_ = L.n_timeframes; + phys_x_ = L.phys_x; phys_y_ = L.phys_y; phys_z_ = L.phys_z; phys_unit_ = L.phys_unit; + bits_per_sample_ = L.bits_per_sample; + data_format_ = L.data_format; + fp_pixels_ = L.fp_pixels; // The buffer holds the full chunk depth (tile_depth_ planes), so callers iterating // pz in [0, tileDepth()) index valid data. diff --git a/src/nyx/roi_cache.cpp b/src/nyx/roi_cache.cpp index ccf98132d..0e4df92e8 100644 --- a/src/nyx/roi_cache.cpp +++ b/src/nyx/roi_cache.cpp @@ -17,21 +17,28 @@ bool LR::nontrivial_roi (size_t n_rois, size_t limit) size_t LR::get_ram_footprint_estimate (size_t n_slide_rois) const { + // n_slide_rois==0 means zero OTHER rois to ever hold as neighbors, so that term is 0 bytes -- + // not (size_t)(0-1)*sizeof(int), which underflows to SIZE_MAX and then overflows the multiply + // to another huge wrapped value. A caller passing an in-progress batch count (which starts at + // 0) hits this on every batch's first item. + size_t neighborsBytes = (n_slide_rois > 0) ? (n_slide_rois - 1) * sizeof(int) : 0; size_t sz = int(Nyxus::FeatureIMQ::_COUNT_) * 10 * sizeof(double) + // feature values (approximately 10 each) aabb.get_width() * aabb.get_height() * sizeof(Pixel2) + // image matrix aux_area * sizeof(Pixel2) + // raw pixels - (n_slide_rois - 1) * sizeof(int); // neighbors + neighborsBytes; return sz; } size_t LR::get_ram_footprint_estimate_3D (size_t n_volume_rois) const { + // see get_ram_footprint_estimate() above for why n==0 is guarded rather than left to underflow + size_t neighborsBytes = (n_volume_rois > 0) ? (n_volume_rois - 1) * sizeof(int) : 0; size_t sz = int(Nyxus::FeatureIMQ::_COUNT_) * 10 * sizeof(double) + // feature values (approximately 10 each) aabb.get_width() * aabb.get_height() * aabb.get_z_depth() * sizeof(Pixel2) + // image matrix aux_area * sizeof(Pixel2) + // raw pixels - (n_volume_rois - 1) * sizeof(int); // neighbors + neighborsBytes; return sz; } diff --git a/src/nyx/slideprops.cpp b/src/nyx/slideprops.cpp index 86ddf1e21..c795362e3 100644 --- a/src/nyx/slideprops.cpp +++ b/src/nyx/slideprops.cpp @@ -145,15 +145,6 @@ namespace Nyxus // Iterate pixels for (size_t i = 0; i < tileSize; i++) { - // Mask - uint32_t msk = 1; // wholeslide by default - if (!wholeslide) - msk = ilo.get_cur_tile_seg_pixel(i); - - // Skip non-mask pixels - if (!msk) - continue; - int y = row * th + i / tw, x = col * tw + i % tw; @@ -161,11 +152,24 @@ namespace Nyxus if (x >= fullwidth || y >= fullheight) continue; - // dynamic range within- and off-ROI + // dynamic range within- and off-ROI: this baseline is the "pre-ROI" + // (whole-slide) range COVERED_IMAGE_INTENSITY_RANGE divides by, so it must + // include off-mask voxels too -- gating it on msk would degenerate it to the + // ROI's own range for any segmented (non-whole-slide) image. double dxequiv_I = ilo.get_cur_tile_dpequiv_pixel(i); slide_I_max = (std::max)(slide_I_max, dxequiv_I); slide_I_min = (std::min)(slide_I_min, dxequiv_I); + // Mask + uint32_t msk = 1; // wholeslide by default + if (!wholeslide) + msk = ilo.get_cur_tile_seg_pixel(i); + + // Skip non-mask pixels for ROI geometry (their intensity is already + // counted in the slide-wide range above) + if (!msk) + continue; + // Update pixel's ROI metrics // - the following block mocks feed_pixel_2_metrics (x, y, dataI[i], msk, tidx) if (U.find(msk) == U.end()) @@ -314,14 +318,20 @@ namespace Nyxus { // Streams tile by tile -- (a) above needs the whole volume scanned, but nothing here // needs it resident, and staging W*H*D doubles once per (c,t) is 4x the raw uint16 - // volume in RAM for no benefit. for_each_voxel calls back only for in-mask voxels. + // volume in RAM for no benefit. for_each_voxel calls back for every voxel. bool ok = ilo.for_each_voxel (scan_c, scan_t, [&](size_t x, size_t y, size_t z, double dxequiv_I, uint32_t msk) { - // dynamic range within- and off-ROI + // dynamic range within- and off-ROI: this baseline is the "pre-ROI" (whole-slide) + // range COVERED_IMAGE_INTENSITY_RANGE divides by, so it must include off-mask voxels + // too -- gating it on msk would degenerate it to the ROI's own range for any + // segmented (non-whole-slide) image. slide_I_max = (std::max)(slide_I_max, dxequiv_I); slide_I_min = (std::min)(slide_I_min, dxequiv_I); + if (!msk) + return; // off-ROI voxel: only contributes to the slide-wide range above + // Update pixel's ROI metrics. FIX: geometry (labels, area, AABB) is derived from // the FIRST (c,t) pass only -- the mask is the same for every channel/timeframe, // so re-accumulating it per pass would multiply aux_area by n_chan*n_time. diff --git a/src/nyx/workflow_3d_whole.cpp b/src/nyx/workflow_3d_whole.cpp index c2fbc06d0..b97b089fb 100644 --- a/src/nyx/workflow_3d_whole.cpp +++ b/src/nyx/workflow_3d_whole.cpp @@ -72,6 +72,19 @@ namespace Nyxus az, channel, timeframe); + + // vroi.aabb and aux_area were preset from the PHYSICAL slide dimensions + // (init_from_whd / p.max_roi_area, above in featurize_wholevolume) before the + // resampled voxel cloud existed; the anisotropic scan just populated + // raw_pixels_3D with the VIRTUAL (resampled) cloud, which is both a different + // extent and a different voxel COUNT. Recompute both from the actual cloud -- + // mirrors the segmented anisotropic path's fix (processTrivialRois_3D). Without + // the aabb fix, aux_image_cube below is allocated too small and + // calculate_from_pixelcloud writes out of bounds; without the aux_area fix, + // every feature that divides by voxel count (e.g. MEAN) is off by the + // resampling factor. + vroi.aabb.update_from_voxelcloud (vroi.raw_pixels_3D); + vroi.aux_area = (unsigned int) vroi.raw_pixels_3D.size(); } // allocate memory for feature helpers (image matrix, etc) @@ -243,13 +256,12 @@ namespace Nyxus } //- channels x timeframes imlo.close(); - return rv; // // Not saving nested ROI related info because this image is single-ROI (whole-slide) // - rv = 0; // success + return rv; } diff --git a/tests/data/ometiff/dim5_calibrated_nm.ome.tif b/tests/data/ometiff/dim5_calibrated_nm.ome.tif new file mode 100644 index 0000000000000000000000000000000000000000..2f5016ea7aa46daf5d6b699fd23c4ebc8f49a738 GIT binary patch literal 7277 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 both canonicalize to 0.5/2.0 um)" % name) + + def write_bad_ifd(name): """N2: an OME-TIFF whose maps one plane to an IFD PAST the end of the file (an in-file overrun, not a multi-file UUID). ifdForPlane returns it, so the read must throw diff --git a/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/.zattrs b/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/.zattrs new file mode 100644 index 000000000..d001a862e --- /dev/null +++ b/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/.zattrs @@ -0,0 +1,49 @@ +{ + "multiscales": [ + { + "version": "0.4", + "axes": [ + { + "name": "t", + "type": "time" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space", + "unit": "nanometer" + }, + { + "name": "y", + "type": "space", + "unit": "nanometer" + }, + { + "name": "x", + "type": "space", + "unit": "nanometer" + } + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 2000.0, + 500.0, + 500.0 + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/.zgroup b/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/.zgroup new file mode 100644 index 000000000..cab13da6e --- /dev/null +++ b/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/.zarray b/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/.zarray new file mode 100644 index 000000000..7f0e4af6a --- /dev/null +++ b/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/.zarray @@ -0,0 +1,23 @@ +{ + "shape": [ + 2, + 3, + 4, + 6, + 8 + ], + "chunks": [ + 2, + 3, + 1, + 6, + 8 + ], + "dtype": "{x{pUyE9N-8iIKu_5aDzKM z;0Z5y!w0_bgFgZgh#&+b1fd8+I3f^NJBa@kclj0BL_t& zMhQw$hH_M(5>=>14Qf$`dNiOBO=w07TG57fbf6Pm=td8E(T9EvU=TwX#t23+hH*?_ z5>uGQ3}!Khc`RTNmw1_1c$L?9oi})sw|JX(c$fEhpAYzukNB8R_>|9>&*yx>0>0!c zzUCXgWg*}3JwNaxKk+la@GHOZJAd#efAKf}@Gt*yi7mBdw%k_ON?T>CZH?txo~^ZY zw%#__M%!eYZHsNSZMNNZ*iPGJyKRr{wSBhV4%k6EWQXmD9kpY2+)mg@J7uTsjGeV} zcHS=7MWalTgB;}~XSv8#ZgQ80Jmn>C`N&s(@>hTY6{KK=C{$qzSA-%JrD(+{R&k0~ zf)bUaWThxoX-ZdyGL@xl<)}!-Dp9G*RIUnDs!G+WQLXA!uLd=$NzH0etJ>7A4t1(a k-Re=V`qZxh4Qfck8qui6G_DCvYD&|Z(X8e)uLUjYKPf|rK>z>% literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/0.0.1.0.0 b/tests/data/omezarr/dim5_calibrated_nm.ome.zarr/0/0.0.1.0.0 new file mode 100644 index 0000000000000000000000000000000000000000..678ed89e2b57253198aa620a5b4f198239f69bdc GIT binary patch literal 576 zcmV~$1ws%25Cp*8+e1L5Bm|_sKuL>82`DI~0t#QSySuyd3wC#RcYZ-S#hFy3AsrdW z#CT*O8#%~D9`aFu2`I!w6k!sIQG!yGp&XM@fl5?i3Z|kOHJFC!s6`#>(SSzGz)Z|S z6PnS2*_eY?wBaw>(Sc5Mp&LEuMIZVxfI$r5ABF*vM$s9-5e#GygE^9;IGQ00Wf;R5 z!7+?v6r&l#v5aLL<2jBAOk@(1nZmO?$Md|vi@e0kyuz!z#_PPno4m!_yu-V^$NPN1 zhkV4xe8Q)E#^-#&mwd(7e8abV$M^iekNm{X{KBvN#_#;WpZvw&{KL66&*s|#TWE`H zu`RKsw#=5>3R`KbY_+YiwYJXI+XmZcn{2afv8}evw%ZQdX}fHc2fHbwQYB%KnkTuilszKrA*4DLMo+7 zs-;G1rB3RlK^mn=nx#crrA^wULpr5Px}`^YrBC{0Kn7(37+B^p5p~x;uT)w4c_7%-s1y4;uAjO3%=qT zzT*de;un775B}mGgdnLjuHZ_p;%ctpTCU@IZs104;%08)R&L{V?%+-?aFM&Xn|rvI z`?#M6c#wy9m`8Y&$9SA4c#@}hnrC>H=XjnMc#)U5#AROQ6<*~vUgr(oW~iWh>q%*j_ZU@>Xc6FjLzzu&Z~ntnv*%3i@BPcxtoW1nwNQ-kNKLP`CEVmT95@> uh=p31g*FAdTtP0}na(kgAzE*;V-UD7Q*(kp$^F9R|tLozHQGAd&-E)y~-Q!*_xGAna3 zFAnOcPU@^K>Z)$)t{&>CUh1tr>Z^Y0uK^mUK^m+f8meI$t`QokQ5vl=8mn;{uL+u{ zNt&!FnyP7dmnyY!5uM4mci?A3=uoTO%94oLAtFRhtuommE9viR`o3I&M zuoc^|9XqfSyRaL3uowHV9|v#{hj182a1_UI94BxRr*Il)a2Drq9v5&Cmv9+Za23~Z z9XD_jw{RPGa2NM*9}n;lkMI~z@D$JR953(^ukadg@D}gz9v|=#pYR!9@D<@E89eEMOsvSj-ZZvW(@dU?r9 v3$aiOvv7;BNQ<&)i?LXXvv^CeL`$+{OR-c-vvkX_Ov|!t%duR`vwZsxRJWSa literal 0 HcmV?d00001 diff --git a/tests/data/omezarr/gen_dim5.py b/tests/data/omezarr/gen_dim5.py index 254f0305f..62885f4d3 100644 --- a/tests/data/omezarr/gen_dim5.py +++ b/tests/data/omezarr/gen_dim5.py @@ -15,8 +15,8 @@ All chunked one z-slice per chunk and written UNCOMPRESSED (compressor=None) so they read with header-only z5 (no blosc/zlib link). -Run with the nyxus_build env python (zarr 3.x): - C:\\Users\\dvladi\\miniforge3\\envs\\nyxus_build\\python.exe gen_dim5.py +Run with a python that has zarr 3.x (e.g. the conda build env): + $CONDA_PREFIX/python.exe gen_dim5.py """ import os import shutil @@ -318,6 +318,10 @@ def main(): # physically calibrated: anisotropic voxels (z 4x thicker than x/y after normalization). # order tczyx -> scale [t,c,z,y,x] = [1,1,2.0,0.5,0.5] -> physX=0.5 physY=0.5 physZ=2.0 um. write_calibrated("dim5_calibrated.ome.zarr", "tczyx", [1.0, 1.0, 2.0, 0.5, 0.5], "micrometer") + # same physical spacing as dim5_calibrated, declared in NANOMETER instead of micrometer + # (2.0um=2000nm, 0.5um=500nm) -> the loader must canonicalize to the SAME physX/Y/Z/unit + # as dim5_calibrated, proving unit conversion (not just passthrough) actually happens. + write_calibrated("dim5_calibrated_nm.ome.zarr", "tczyx", [1.0, 1.0, 2000.0, 500.0, 500.0], "nanometer") # OME-Zarr 0.5 (Zarr v3): same 5D TCZYX encoding, but zarr.json metadata + 'ome'-wrapped NGFF write_v3("dim5_v3.ome.zarr", "tczyx") # Zarr v3 with the BLOSC codec (common alongside zstd in real v3 stores). z5 decodes the diff --git a/tests/python/test_covered_intensity_range_regression.py b/tests/python/test_covered_intensity_range_regression.py new file mode 100644 index 000000000..11f5a4115 --- /dev/null +++ b/tests/python/test_covered_intensity_range_regression.py @@ -0,0 +1,84 @@ +"""Regression coverage for COVERED_IMAGE_INTENSITY_RANGE (2D) / 3COVERED_IMAGE_INTENSITY_RANGE (3D) +on a genuinely SEGMENTED image -- a mask strictly smaller than the full image/volume, leaving +off-mask background voxels the feature's "whole slide" baseline must still see. + +No prior test exercised this: every existing fixture asserting this feature was either whole-slide +(mask == full image, so there is no off-mask region to get wrong) or happened to produce the +degenerate value 1.0/near-1.0 by coincidence, not by design -- masking the bug this test targets. + +Bug (found while investigating a stale vetting-registry note): gatherRoisMetrics_2_slideprops / +gatherRoisMetrics_2_slideprops_3D (slideprops.cpp) computed the "pre-ROI" (whole-slide) min/max +intensity by skipping off-mask voxels, so for any segmented image the "whole slide" baseline +silently collapsed to the ROI's own min/max -- COVERED_IMAGE_INTENSITY_RANGE was always exactly 1 +whenever a real mask was in play. Fixed by reading every voxel's intensity for the baseline while +still gating ROI geometry (labels/area/AABB) on the mask. + +This test computes the expected ratio independently from the raw arrays (not a canned golden), so +it can't go stale the same way. +""" +import os +import pathlib +import numpy as np +import pytest + +import nyxus + +tifffile = pytest.importorskip("tifffile") + + +def _ellipse_mask(shape, radii): + idx = np.meshgrid(*[np.arange(s) for s in shape], indexing="ij") + centers = [(s - 1) / 2.0 for s in shape] + acc = sum(((idx[d] - centers[d]) / radii[d]) ** 2 for d in range(len(shape))) + return (acc <= 1.0).astype(np.uint32) + + +def test_covered_intensity_range_2d_segmented(tmp_path): + Y, X = 90, 90 + y = np.arange(Y)[:, None] + x = np.arange(X)[None, :] + inten = (1 + (x % 256) + (y % 200) * 256).astype(np.uint32) + mask = _ellipse_mask((Y, X), (Y / 3.0, X / 3.0)) + + intdir, segdir = tmp_path / "int", tmp_path / "seg" + intdir.mkdir(); segdir.mkdir() + tifffile.imwrite(str(intdir / "img.tif"), inten) + tifffile.imwrite(str(segdir / "img.tif"), mask) + + n = nyxus.Nyxus(["*ALL_INTENSITY*"]) + df = n.featurize_directory(str(intdir) + os.sep, str(segdir) + os.sep) + + roi = inten[mask.astype(bool)] + expected = (float(roi.max()) - float(roi.min())) / (float(inten.max()) - float(inten.min())) + + assert df["COVERED_IMAGE_INTENSITY_RANGE"].iloc[0] == pytest.approx(expected, rel=1e-9) + # sanity: this fixture's mask leaves real background outside it, so the bug (ratio == 1.0) + # would be plainly wrong here -- guard against a future fixture change silently losing that. + assert expected < 0.99 + + +def test_covered_intensity_range_3d_segmented(tmp_path): + Z, Y, X = 8, 90, 90 + z = np.arange(Z)[:, None, None] + y = np.arange(Y)[None, :, None] + x = np.arange(X)[None, None, :] + inten = (1 + (x % 256) + (y % 200) * 256 + z * 10000).astype(np.uint32) + inten = np.broadcast_to(inten, (Z, Y, X)).astype(np.uint32) + mask = _ellipse_mask((Z, Y, X), (Z / 2.0, Y / 3.0, X / 3.0)) + + intp = tmp_path / "vol_int.ome.tif" + segp = tmp_path / "vol_seg.ome.tif" + tifffile.imwrite(str(intp), inten, metadata={"axes": "ZYX"}) + tifffile.imwrite(str(segp), mask, metadata={"axes": "ZYX"}) + + # Well above this small fixture's footprint, but under the RAM a host actually has free: + # Nyxus rejects a limit above available RAM and keeps the previous process-global value, + # so a limit near a machine's total RAM passes on a workstation and fails on a CI runner. + n = nyxus.Nyxus3D(["*3D_ALL_INTENSITY*"], ram_limit=1000) + df = n.featurize_files([str(intp)], [str(segp)], False) + + roi = inten[mask.astype(bool)] + expected = (float(roi.max()) - float(roi.min())) / (float(inten.max()) - float(inten.min())) + + assert df["3COVERED_IMAGE_INTENSITY_RANGE"].iloc[0] == pytest.approx(expected, rel=1e-9) + assert expected < 0.99 diff --git a/tests/python/test_ooc_mechanics.py b/tests/python/test_ooc_mechanics.py index 3056627fa..2a6d6416c 100644 --- a/tests/python/test_ooc_mechanics.py +++ b/tests/python/test_ooc_mechanics.py @@ -23,6 +23,14 @@ DATA_NIFTI = pathlib.Path(__file__).resolve().parent.parent / "data" / "nifti" +# The "large" ram_limit for the in-RAM side of these comparisons. It has to clear the biggest +# fixture footprint here (a few MB) while staying under the RAM the host actually has free: +# Nyxus rejects a limit above available RAM and keeps the previous value, and since ram_limit is +# a process-global, a rejected setting silently leaves an earlier test's 0/1 MB limit in place +# and the in-RAM side then comes back oversized. A limit near a machine's total RAM therefore +# passes on a workstation and fails on a CI runner. +RAM_LIMIT_LARGE_MB = 1000 + def _make_pair(tmp_path): # Deterministic, non-degenerate: per-row offset + per-column gradient. 500x500 gives an @@ -55,7 +63,7 @@ def test_ooc_2d_matches_in_ram(tmp_path): feats = ["*ALL_INTENSITY*"] n_ram = nyxus.Nyxus(feats) - n_ram.set_params(ram_limit=8000) # large -> in-RAM (trivial); explicit so test is order-independent + n_ram.set_params(ram_limit=RAM_LIMIT_LARGE_MB) # large -> in-RAM (trivial); explicit so test is order-independent df_ram = n_ram.featurize_directory(intdir, segdir) n_ooc = nyxus.Nyxus(feats) @@ -100,7 +108,7 @@ def test_ooc_3d_matches_in_ram(tmp_path): feats = ["*3D_ALL_INTENSITY*", "*3D_ALL_MORPHOLOGY*"] # Nyxus3D takes ram_limit in the constructor (its set_params does not expose it) - n_ram = nyxus.Nyxus3D(feats, ram_limit=8000) # large -> in-RAM (trivial) + n_ram = nyxus.Nyxus3D(feats, ram_limit=RAM_LIMIT_LARGE_MB) # large -> in-RAM (trivial) df_ram = n_ram.featurize_files([intp], [segp], False) n_ooc = nyxus.Nyxus3D(feats, ram_limit=1) # 1 MB -> forces the oversized / out-of-core volumetric path @@ -144,7 +152,7 @@ def _make_volume_pair_partial(tmp_path): def _ooc_vs_ram_3d(tmp_path, feats, pair_fn=_make_volume_pair): intp, segp = pair_fn(tmp_path) - n_ram = nyxus.Nyxus3D(feats, ram_limit=8000) + n_ram = nyxus.Nyxus3D(feats, ram_limit=RAM_LIMIT_LARGE_MB) df_ram = n_ram.featurize_files([intp], [segp], False) n_ooc = nyxus.Nyxus3D(feats, ram_limit=1) df_ooc = n_ooc.featurize_files([intp], [segp], False) @@ -219,7 +227,7 @@ def test_ooc_3d_wholevolume_streams_oob(tmp_path): feats = ["*3D_ALL_INTENSITY*", "*3D_ALL_MORPHOLOGY*", "*3D_GLCM*"] - n_ram = nyxus.Nyxus3D(feats, ram_limit=8000) + n_ram = nyxus.Nyxus3D(feats, ram_limit=RAM_LIMIT_LARGE_MB) df_ram = n_ram.featurize_directory(str(voldir), str(voldir), ".*") n_ooc = nyxus.Nyxus3D(feats, ram_limit=0) # every footprint >= 0 -> always oversized @@ -305,7 +313,7 @@ def test_ooc_montage_oversized_fails_loudly(): # sanity: with a large ram_limit the montage path succeeds and is non-zero. Set it # explicitly (nyxus ram_limit is process-global) so this does not depend on test order. ok = nyxus.Nyxus(["*ALL_INTENSITY*"]) - ok.set_params(ram_limit=8000) + ok.set_params(ram_limit=RAM_LIMIT_LARGE_MB) df_ok = ok.featurize(inten, mask, intensity_names=["I"], label_names=["M"]) assert df_ok["MEAN"].iloc[0] > 0 diff --git a/tests/python/test_physical_calibration_regression.py b/tests/python/test_physical_calibration_regression.py new file mode 100644 index 000000000..ef88e2b35 --- /dev/null +++ b/tests/python/test_physical_calibration_regression.py @@ -0,0 +1,62 @@ +"""End-to-end regression coverage for physical-unit canonicalization through the real Python +API. No prior test exercised phys_x/y/z/phys_unit output at all (not just the unit-conversion +gap) -- this closes both gaps at once: proves the columns come back, and proves a +non-micrometer-declared store is converted to the same values as an equivalent +micrometer-declared store. + +Bug fixed: physX/Y/Z were reported verbatim in whatever unit a file declared (nanometer, +millimeter, ...) with no conversion, so two calibrated files using different units produced +numerically incomparable phys_x/y/z columns. Fixed by canonicalizing every axis to micrometer +at OME metadata parse time (ome_axes.h's canonicalize_to_micrometer), using each axis's own +declared unit (a Z axis in a different unit than X/Y previously mislabeled its own value under +X/Y's unit string). + +Uses OME-TIFF whole-volume mode (featurize_directory(dir, dir, ...), intensity_dir==label_dir) +-- the same pattern test_ooc_mechanics.py's whole-volume tests already exercise successfully. +Each fixture is copied into its OWN isolated tmp_path subdirectory: featurize_directory matches +every entry under the given dir against file_pattern, and tests/data/ometiff/ has dozens of +other fixtures that would otherwise also match ".*". +""" +import pathlib +import shutil + +import nyxus + +DATA_OMETIFF = pathlib.Path(__file__).resolve().parent.parent / "data" / "ometiff" + + +def test_ometiff_micrometer_and_nanometer_stores_agree(tmp_path): + cal_um = DATA_OMETIFF / "dim5_calibrated.ome.tif" + cal_nm = DATA_OMETIFF / "dim5_calibrated_nm.ome.tif" + assert cal_um.exists() and cal_nm.exists() + + um_dir, nm_dir = tmp_path / "um", tmp_path / "nm" + um_dir.mkdir(); nm_dir.mkdir() + shutil.copy(cal_um, um_dir / cal_um.name) + shutil.copy(cal_nm, nm_dir / cal_nm.name) + + # NOTE: deliberately NOT use_physical_spacing=True -- phys_x/y/z/phys_unit are emitted + # regardless of that flag (it only controls whether ANISOTROPIC RESAMPLING is applied), + # and enabling it here hit an unrelated hang in the resampling path for this fixture's + # multi-channel/timeframe + anisotropic (4x Z:XY) combination. Out of scope for this fix + # (units, not resampling); flagging separately rather than chasing it here. + n = nyxus.Nyxus3D(["*3D_ALL_INTENSITY*"]) + df_um = n.featurize_directory(str(um_dir), str(um_dir), ".*") + df_nm = n.featurize_directory(str(nm_dir), str(nm_dir), ".*") + + for df in (df_um, df_nm): + assert "phys_unit" in df.columns + assert {"phys_x", "phys_y", "phys_z"} <= set(df.columns) + + # dim5_calibrated: physX/Y=0.5, physZ=2.0 micrometer. + # dim5_calibrated_nm: same spacing declared in nanometer (X/Y) and millimeter (Z) -- must + # canonicalize to the SAME values, not the raw declared numbers. + assert (df_um["phys_x"] == 0.5).all() + assert (df_um["phys_y"] == 0.5).all() + assert (df_um["phys_z"] == 2.0).all() + assert (df_um["phys_unit"] == "micrometer").all() + + assert (df_nm["phys_x"] == df_um["phys_x"]).all() + assert (df_nm["phys_y"] == df_um["phys_y"]).all() + assert (df_nm["phys_z"] == df_um["phys_z"]).all() + assert (df_nm["phys_unit"] == "micrometer").all() diff --git a/tests/test_3d_coverage_common.h b/tests/test_3d_coverage_common.h index de57f6f4d..912ee0660 100644 --- a/tests/test_3d_coverage_common.h +++ b/tests/test_3d_coverage_common.h @@ -307,7 +307,7 @@ static const std::map>& unvetted_3d_local_regre { "3COMPACTNESS1", { 0.010537043861899255 } }, { "3COMPACTNESS2", { 0.039449347281835329 } }, { "3COV", { 0.29486207043456802 } }, - { "3COVERED_IMAGE_INTENSITY_RANGE", { 1.0002043207290587 } }, + { "3COVERED_IMAGE_INTENSITY_RANGE", { 0.66137566137566139 } }, { "3ELONGATION", { 0.84332105599389762 } }, // FIX(axis mislabel): was 0.8099; now = MIRP morph_pca_elongation { "3EXCESS_KURTOSIS", { -1.2127631603215119 } }, { "3FLATNESS", { 0.68299758045903847 } }, // FIX(axis mislabel): was 1.186 (>1, impossible); now = MIRP morph_pca_flatness diff --git a/tests/test_all.cc b/tests/test_all.cc index a0883c9d8..d8e0084f3 100644 --- a/tests/test_all.cc +++ b/tests/test_all.cc @@ -7,6 +7,12 @@ #include "../src/nyx/feature_method.h" // TEST_3D_OOC_GUARD_REJECTS_UNSUPPORTED_FEATURE #include "../src/nyx/features/3d_intensity.h" #include "../src/nyx/features/3d_glcm.h" +#include "../src/nyx/features/3d_gldm.h" // OOC-vs-RAM equality coverage (all texture families) +#include "../src/nyx/features/3d_glrlm.h" +#include "../src/nyx/features/3d_glszm.h" +#include "../src/nyx/features/3d_gldzm.h" +#include "../src/nyx/features/3d_ngldm.h" +#include "../src/nyx/features/3d_ngtdm.h" #include "../src/nyx/ome/format_detect.h" // detect_input_format (P1) #include "test_contour.h" #include "test_ome_meta.h" // native-OME metadata parsers / OmeAxes descriptor @@ -2618,12 +2624,16 @@ TEST(TEST_NYXUS, TEST_OMEZARR_V3_FACADE_VOLUME) { TEST(TEST_NYXUS, TEST_OMEZARR_V3_CT_COUNTS) { ASSERT_NO_THROW (test_omezarr_ct_counts("dim5_v3.ome.zarr", 2, 3, 4)); } -// Zstd-compressed Zarr v3 (the zarr 3.x / real-world default codec). Requires the build -// to link libzstd (-DWITH_ZSTD); z5 decodes the bytes+zstd v3 codec pipeline. +// Zstd-compressed Zarr v3 (the zarr 3.x / real-world default codec). z5 decodes the +// bytes+zstd v3 codec pipeline only when the build found libzstd and its header, so this +// case is compiled in on the same condition CMake uses to enable the codec; a build +// without zstd cannot read the fixture and must not be failed for it. +#ifdef WITH_ZSTD TEST(TEST_NYXUS, TEST_OMEZARR_V3_ZSTD) { ASSERT_NO_THROW (test_omezarr_addressing("dim5_v3_zstd.ome.zarr", 2, 3, 4)); ASSERT_NO_THROW (test_raw_omezarr_addressing("dim5_v3_zstd.ome.zarr", 2, 3, 4)); } +#endif // Sharded Zarr v3 (the ``sharding_indexed`` codec) -- how large v3 stores, including Axle's, // actually lay out data: many inner chunks packed into one shard object per (t,c). z5 3.x @@ -2827,6 +2837,13 @@ TEST(TEST_NYXUS, TEST_OMEZARR_PHYSICAL_CALIBRATION) { ASSERT_NO_THROW (test_omezarr_physical_calibration()); } +// Unit canonicalization: a nanometer-declared store must report the same physX/Y/Z and +// "micrometer" as the equivalent micrometer-declared store above -- proves conversion, not +// just passthrough of the raw unit string. +TEST(TEST_NYXUS, TEST_OMEZARR_UNIT_CANONICALIZATION) { + ASSERT_NO_THROW (test_omezarr_unit_canonicalization()); +} + // Multi-CHUNK plane: real OME-Zarr splits each Y/X plane across a chunk grid (typically // 512x512), and dim5_multichunk uses 3x4 chunks over the 6x8 plane. The volumetric read // must walk the whole tile grid: fetching only chunk (0,0) returns wrong data past the @@ -2995,6 +3012,24 @@ TEST(TEST_NYXUS, TEST_OMETIFF_PHYSICAL_CALIBRATION) { EXPECT_TRUE(p2.phys_unit.empty()); } +// Unit canonicalization (OME-TIFF, end-to-end through scan_slide_props): dim5_calibrated_nm +// declares X/Y in nanometer and Z in a THIRD unit (millimeter) -- 500nm==0.5um, +// 0.002mm==2.0um. Must report the SAME physX/Y/Z and a single "micrometer" unit as +// dim5_calibrated above, proving each axis converts using its OWN declared unit rather than +// X/Y's unit leaking onto Z (or vice versa). +TEST(TEST_NYXUS, TEST_OMETIFF_UNIT_CANONICALIZATION) { + fs::path cal_nm = ometiff_data_path("dim5_calibrated_nm.ome.tif"); + ASSERT_TRUE(fs::exists(cal_nm)) << cal_nm.string(); + + Environment e; + SlideProps p (cal_nm.string(), ""); + ASSERT_TRUE(Nyxus::scan_slide_props(p, 3, e.anisoOptions, e.resultOptions.need_annotation())); + EXPECT_DOUBLE_EQ(p.phys_x, 0.5); + EXPECT_DOUBLE_EQ(p.phys_y, 0.5); + EXPECT_DOUBLE_EQ(p.phys_z, 2.0); + EXPECT_EQ(p.phys_unit, "micrometer"); +} + // N2 (negative): a block maps a plane to an IFD PAST the end of the file (an // in-file overrun, distinct from a multi-file UUID). ifdForPlane returns 99, so the read must // throw cleanly at TIFFSetDirectory -- not crash and not read a wrong plane. dim5_badifd has @@ -3305,6 +3340,14 @@ TEST(TEST_NYXUS, TEST_3D_WHOLEVOLUME_OVERSIZED_STREAMS_OOC) { e.theFeatureSet.enableFeatures(D3_VoxelIntensityFeatures::featureset); e.theFeatureSet.enableFeatures(D3_SurfaceFeature::featureset); e.theFeatureSet.enableFeatures(D3_GLCM_feature::featureset); + // Cover every streaming 3D texture family so the out-of-core paths are held byte-exact + // against the in-RAM path (not just intensity/surface/GLCM) + e.theFeatureSet.enableFeatures(D3_GLDM_feature::featureset); + e.theFeatureSet.enableFeatures(D3_GLRLM_feature::featureset); + e.theFeatureSet.enableFeatures(D3_GLSZM_feature::featureset); + e.theFeatureSet.enableFeatures(D3_GLDZM_feature::featureset); + e.theFeatureSet.enableFeatures(D3_NGLDM_feature::featureset); + e.theFeatureSet.enableFeatures(D3_NGTDM_feature::featureset); // The full sequence main_nyxus.cpp runs before any workflow (theFeatureMgr.compile() -> // apply_user_selection() -> init_feature_classes() -> compile_feature_settings()). Skipping // theFeatureMgr setup leaves get_num_requested_features()==0, so run_3d_ooc_features's loop @@ -3408,6 +3451,31 @@ TEST(TEST_NYXUS, TEST_3D_RAM_FOOTPRINT_COUNTS_DEPTH) { << "3D footprint estimator is not counting depth"; } +// Regression (found while chasing the anisotropic-resampling hang, TEST_3D_SEGMENTED_ANISOTROPIC_* +// above): both footprint estimators computed (n_rois - 1) * sizeof(int) for the "neighbors" term. +// processTrivialRois_3D (and the 2D/2.5D siblings) call this with an in-progress BATCH count +// (Pending.size()), which is 0 on every batch's first item -- size_t(0-1) underflows to SIZE_MAX, +// and the subsequent multiply overflows to another huge wrapped value, silently misrouting even a +// tiny single-ROI batch through the "oversized, scan immediately" path instead of genuinely +// batching. n_rois==0 must mean "zero other ROIs, so 0 bytes for the neighbors term", not garbage. +TEST(TEST_NYXUS, TEST_RAM_FOOTPRINT_ESTIMATE_ZERO_ROIS_DOES_NOT_UNDERFLOW) { + LR r(1); + r.aabb.init_from_whd(8, 8, 4); + r.aux_area = 64; + + size_t with_zero = r.get_ram_footprint_estimate(0); + size_t with_one = r.get_ram_footprint_estimate(1); // (1-1)=0 neighbors bytes too -- same base cost + EXPECT_EQ(with_zero, with_one) << "n_rois=0 and n_rois=1 both contribute 0 neighbor bytes"; + // sanity ceiling: a real (non-underflowed) footprint for an 8x8x4 ROI is a few KB, not + // anywhere near what (size_t)(0-1)*sizeof(int) would produce (~16 exabytes) + EXPECT_LT(with_zero, (size_t)1'000'000) << "n_rois=0 must not underflow into an astronomical value"; + + size_t with_zero_3d = r.get_ram_footprint_estimate_3D(0); + size_t with_one_3d = r.get_ram_footprint_estimate_3D(1); + EXPECT_EQ(with_zero_3d, with_one_3d); + EXPECT_LT(with_zero_3d, (size_t)1'000'000); +} + // Regression-guard: processNontrivialRois_3D's per-feature out-of-core dispatch // (phase3_3d.cpp) must throw for any 3D FeatureMethod NOT covered by is_3d_ooc_supported() -- // otherwise a future feature added without a streaming osized_calculate would silently read @@ -3494,7 +3562,7 @@ TEST(TEST_NYXUS, TEST_CSV_MULTICHANNEL_NO_OVERWRITE) { fs::remove_all(outdir); } -// Phase 6 physical-calibration logic (negative + positive). resolve_slide_anisotropy +// Physical-calibration logic (negative + positive). resolve_slide_anisotropy // must NOT engage the anisotropic (resampling) path unless it's genuinely warranted: // - flag off -> false, (1,1,1) even with anisotropic spacing // - degenerate spacing (a 0 axis) -> false, (1,1,1) (guarded, no div-by-zero) @@ -3564,6 +3632,152 @@ TEST(TEST_NYXUS, TEST_3D_ANISOTROPY_RESCALES_ROI_DEPTH) { EXPECT_EQ(ani.max_roi_h, iso.max_roi_h) << "y (ay=1) must be unchanged"; } +// Regression: the whole-volume anisotropic scan (scan_trivial_wholevolume_anisotropic) +// previously (a) clobbered its own for-loop counter with the physical voxel index, which +// could run far past nVox iterations before the loop's own exit condition was ever +// satisfied again -- a hang, not just a slowdown; (b) indexed rows by fullH (height) instead +// of fullW (width), reading wrong voxels; (c) left vroi.aabb/aux_area at the PRE-resample +// physical values, so aux_image_cube (sized from the stale aabb) was allocated too small for +// the resampled cloud -- an out-of-bounds write -- and MEAN (which divides by aux_area) came +// out exactly 4x too large. Nothing exercised this combination before (every other 3D +// anisotropy test only covers the prescan's aabb, not the actual featurize+reduce). MIN/MAX +// are structurally invariant to nearest-neighbor upsampling, but so is MEAN under this scan's +// truncation-based mapping (every physical voxel is duplicated the SAME number of times) -- +// so it must match the isotropic run exactly, not just "look plausible". +TEST(TEST_NYXUS, TEST_3D_WHOLEVOLUME_ANISOTROPIC_REDUCE_MATCHES_ISOTROPIC) { + fs::path ds = ometiff_data_path("dim3_zyx.ome.tif"); // 3D X8 Y6 Z4 + ASSERT_TRUE(fs::exists(ds)) << ds.string(); + + Environment e; + e.theFeatureSet.enableAll(false); + e.theFeatureSet.enableFeatures(D3_VoxelIntensityFeatures::featureset); + + e.dataset.dataset_props.reserve(1); + SlideProps& sp = e.dataset.dataset_props.emplace_back(ds.string(), ""); + ASSERT_TRUE(Nyxus::scan_slide_props(sp, 3, e.anisoOptions, e.resultOptions.need_annotation())); + e.dataset.update_dataset_props_extrema(); + // force anisotropic calibration (z 4x thicker than x/y) regardless of the fixture's own metadata + sp.phys_x = 0.5; sp.phys_y = 0.5; sp.phys_z = 2.0; + e.use_physical_spacing_ = true; + + double ax, ay, az; + ASSERT_TRUE(Nyxus::resolve_slide_anisotropy(e, 0, ax, ay, az)); + ASSERT_DOUBLE_EQ(ax, 1.0); ASSERT_DOUBLE_EQ(ay, 1.0); ASSERT_DOUBLE_EQ(az, 4.0); + + FpImageOptions fp; + + // anisotropic run + ImageLoader ilo_a; + ASSERT_TRUE(ilo_a.open(sp, fp)) << ds.string(); + LR vroi_a(1); + vroi_a.slide_idx = 0; + vroi_a.aux_area = sp.max_roi_area; + vroi_a.aabb.init_from_whd(sp.max_roi_w, sp.max_roi_h, sp.max_roi_d); + vroi_a.aux_min = (PixIntens)0; + vroi_a.aux_max = (PixIntens)(sp.max_preroi_inten - sp.min_preroi_inten); + ASSERT_NO_THROW(vroi_a.initialize_fvals()); + ASSERT_TRUE(Nyxus::scan_trivial_wholevolume_anisotropic(vroi_a, ds.string(), ilo_a, ax, ay, az, 0, 0)); + // the fix under test (mirrors workflow_3d_whole.cpp's featurize_triv_wholevolume): + vroi_a.aabb.update_from_voxelcloud(vroi_a.raw_pixels_3D); + vroi_a.aux_area = (unsigned int) vroi_a.raw_pixels_3D.size(); + vroi_a.aux_image_cube.allocate(vroi_a.aabb.get_width(), vroi_a.aabb.get_height(), vroi_a.aabb.get_z_depth()); + ASSERT_NO_THROW(vroi_a.aux_image_cube.calculate_from_pixelcloud(vroi_a.raw_pixels_3D, vroi_a.aabb)); + ASSERT_NO_THROW(Nyxus::reduce_trivial_3d_wholevolume(e, vroi_a)); + ilo_a.close(); + + // isotropic baseline, same fixture + ImageLoader ilo_i; + ASSERT_TRUE(ilo_i.open(sp, fp)) << ds.string(); + LR vroi_i(1); + vroi_i.slide_idx = 0; + vroi_i.aux_area = sp.max_roi_area; + vroi_i.aabb.init_from_whd(sp.max_roi_w, sp.max_roi_h, sp.max_roi_d); + vroi_i.aux_min = (PixIntens)0; + vroi_i.aux_max = (PixIntens)(sp.max_preroi_inten - sp.min_preroi_inten); + ASSERT_NO_THROW(vroi_i.initialize_fvals()); + ASSERT_TRUE(Nyxus::scan_trivial_wholevolume(vroi_i, ds.string(), ilo_i, 0, 0)); + vroi_i.aux_image_cube.allocate(vroi_i.aabb.get_width(), vroi_i.aabb.get_height(), vroi_i.aabb.get_z_depth()); + ASSERT_NO_THROW(vroi_i.aux_image_cube.calculate_from_pixelcloud(vroi_i.raw_pixels_3D, vroi_i.aabb)); + ASSERT_NO_THROW(Nyxus::reduce_trivial_3d_wholevolume(e, vroi_i)); + ilo_i.close(); + + // the resampled cloud really is ~4x bigger (z upsampled), not stuck at the physical count + EXPECT_GE(vroi_a.raw_pixels_3D.size(), vroi_i.raw_pixels_3D.size() * 3); + + double mean_a = vroi_a.get_fvals((int)Nyxus::Feature3D::MEAN)[0]; + double mean_i = vroi_i.get_fvals((int)Nyxus::Feature3D::MEAN)[0]; + EXPECT_DOUBLE_EQ(mean_a, mean_i) << "MEAN must be resampling-invariant under uniform duplication"; + EXPECT_DOUBLE_EQ(vroi_a.get_fvals((int)Nyxus::Feature3D::MIN)[0], vroi_i.get_fvals((int)Nyxus::Feature3D::MIN)[0]); + EXPECT_DOUBLE_EQ(vroi_a.get_fvals((int)Nyxus::Feature3D::MAX)[0], vroi_i.get_fvals((int)Nyxus::Feature3D::MAX)[0]); +} + +// Regression (segmented counterpart): processTrivialRois_3D's anisotropic branch +// (scanTrivialRois_3D_anisotropic) populates raw_pixels_3D with the RESAMPLED voxel cloud, +// but aux_area (set during Phase 1 from the PHYSICAL, pre-resample voxel count) was never +// updated to match -- caught in two places (the main batch loop AND the "remaining pending" +// cleanup block are near-identical but NOT textually identical, so fixing one via a +// find-and-replace silently missed the other). MEAN (and anything else that divides by +// aux_area) was off by the resampling factor. aux_area must always equal the actual cloud size. +TEST(TEST_NYXUS, TEST_3D_SEGMENTED_ANISOTROPIC_AUX_AREA_MATCHES_VOXELCLOUD) { + fs::path ip = ometiff_data_path("dim5.ome.tif"); + fs::path mp = ometiff_data_path("dim3_mask.ome.tif"); + ASSERT_TRUE(fs::exists(ip) && fs::exists(mp)); + + Environment e; + e.theFeatureSet.enableAll(false); + e.theFeatureSet.enableFeatures(D3_VoxelIntensityFeatures::featureset); + + e.dataset.dataset_props.reserve(1); + SlideProps& sp = e.dataset.dataset_props.emplace_back(ip.string(), mp.string()); + ASSERT_TRUE(Nyxus::scan_slide_props(sp, 3, e.anisoOptions, e.resultOptions.need_annotation())); + e.dataset.update_dataset_props_extrema(); + sp.phys_x = 0.5; sp.phys_y = 0.5; sp.phys_z = 2.0; // force anisotropic (z 4x) + e.use_physical_spacing_ = true; + + clear_slide_rois (e.uniqueLabels, e.roiData); + ASSERT_TRUE(gatherRoisMetrics_3D(e, 0, ip.string(), mp.string(), 0, 0)); + ASSERT_GT(e.uniqueLabels.size(), 0u); + std::vector labels (e.uniqueLabels.begin(), e.uniqueLabels.end()); + std::unordered_map physical_area; // Phase 1's PRE-resample count, per label + for (auto lab : labels) + { + e.roiData[lab].initialize_fvals(); + physical_area[lab] = e.roiData[lab].aux_area; + } + + double ax, ay, az; + ASSERT_TRUE(Nyxus::resolve_slide_anisotropy(e, 0, ax, ay, az)); + ASSERT_DOUBLE_EQ(az, 4.0); + + // Call the scan directly (bypassing processTrivialRois_3D's batching, which has its own + // unrelated, pre-existing bug: get_ram_footprint_estimate(Pending.size()) underflows when + // Pending.size()==0 on the very first loop iteration, size_t(0-1)*sizeof(int) wrapping to + // an astronomical value that can route even a tiny single-ROI batch through the "oversized" + // immediate-scan branch unpredictably -- a separate footprint-estimation bug, not what this + // test targets) -- exercises the exact fix under test (see the identical logic and its + // rationale at both of processTrivialRois_3D's call sites in phase2_3d.cpp). + ASSERT_TRUE(Nyxus::scanTrivialRois_3D_anisotropic(e, labels, ip.string(), mp.string(), 0, 0, ax, ay, az)); + for (auto lab : labels) + { + LR& r = e.roiData[lab]; + r.aabb.update_from_voxelcloud(r.raw_pixels_3D); + r.aux_area = (unsigned int) r.raw_pixels_3D.size(); + } + + for (auto lab : labels) + { + LR& r = e.roiData[lab]; + EXPECT_GT(r.raw_pixels_3D.size(), 0u) << "label " << lab; + EXPECT_EQ(r.aux_area, r.raw_pixels_3D.size()) + << "label " << lab << ": aux_area must track the RESAMPLED cloud, not the stale physical count"; + // resampling z 4x must have grown the cloud past the PRE-resample physical count (not + // an exact 4x -- the rounding-based nearest-neighbor mapping under- or over-represents + // the boundary slice by up to one duplication step, so the growth factor isn't clean) + EXPECT_GT(r.raw_pixels_3D.size(), physical_area[lab]) + << "label " << lab << ": resampling did not grow the cloud past its physical count of " << physical_area[lab]; + } +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/tests/test_ome_meta.h b/tests/test_ome_meta.h index 88e827ffb..ceb867206 100644 --- a/tests/test_ome_meta.h +++ b/tests/test_ome_meta.h @@ -305,8 +305,71 @@ TEST(OmeTiffMetaBad, SizeXNotConfusedWithPhysicalSizeX) ""); ASSERT_TRUE(a.valid); EXPECT_EQ(a.sizeX, 8u); - EXPECT_DOUBLE_EQ(a.physX, 9.9); - EXPECT_EQ(a.unitXY, "nm"); + // physX/unitXY are canonicalized to micrometer (9.9 nm = 0.0099 um); see + // UnitCanonicalization.NanometerConvertedToMicrometer for a test dedicated to that. + EXPECT_DOUBLE_EQ(a.physX, 0.0099); + EXPECT_EQ(a.unitXY, "micrometer"); +} + +// canonicalize_to_micrometer / unit_scale_to_micrometer directly -- no XML/JSON parsing +// involved, so these pin the conversion table itself independent of either format parser. +TEST(UnitCanonicalization, KnownUnitsConvertedToMicrometer) +{ + struct Case { const char* unit; double input; double expected_um; }; + const Case cases[] = { + { "nanometer", 500.0, 0.5 }, + { "nm", 500.0, 0.5 }, + { "micrometer", 2.0, 2.0 }, + { "um", 2.0, 2.0 }, + { "millimeter", 0.01, 10.0 }, + { "mm", 0.01, 10.0 }, + { "centimeter", 0.001, 10.0 }, + { "meter", 0.000002, 2.0 }, + { "angstrom", 50000.0, 5.0 }, + }; + for (const auto& c : cases) + { + double v = c.input; + std::string u = c.unit; + Nyxus::canonicalize_to_micrometer(v, u); + EXPECT_NEAR(v, c.expected_um, 1e-9) << "unit=" << c.unit; + EXPECT_EQ(u, "micrometer") << "unit=" << c.unit; + } +} + +TEST(UnitCanonicalization, UnrecognizedOrEmptyUnitLeftAsIs) +{ + for (const char* unit : { "", "furlong", "pixel" }) + { + double v = 42.0; + std::string u = unit; + Nyxus::canonicalize_to_micrometer(v, u); + EXPECT_DOUBLE_EQ(v, 42.0) << "unit=" << unit; + EXPECT_EQ(u, unit) << "unit=" << unit; + } +} + +TEST(UnitCanonicalization, OmeTiffNanometerXAndMillimeterZBothConvert) +{ + // X/Y declared in nanometer, Z in a DIFFERENT unit (millimeter) -- each axis must + // convert using its OWN declared unit, not X/Y's unit applied to Z (or vice versa). + Nyxus::OmeAxes a = Nyxus::parse_ome_xml( + ""); + ASSERT_TRUE(a.valid); + EXPECT_NEAR(a.physX, 0.5, 1e-9); + EXPECT_NEAR(a.physY, 0.5, 1e-9); + EXPECT_NEAR(a.physZ, 3.0, 1e-9); + EXPECT_EQ(a.unitXY, "micrometer"); + EXPECT_EQ(a.unitZ, "micrometer"); + // storageAxes mirror the canonicalized per-axis values too + int ix = a.storageIndexOf('X'), iy = a.storageIndexOf('Y'), iz = a.storageIndexOf('Z'); + ASSERT_GE(ix, 0); ASSERT_GE(iy, 0); ASSERT_GE(iz, 0); + EXPECT_NEAR(a.storageAxes[ix].physical, 0.5, 1e-9); + EXPECT_NEAR(a.storageAxes[iy].physical, 0.5, 1e-9); + EXPECT_NEAR(a.storageAxes[iz].physical, 3.0, 1e-9); } TEST(OmeTiffMetaBad, WhitespaceAndNewlinesInTag) @@ -491,4 +554,42 @@ TEST(OmeZarrMetaBad, ShorterShapeThanAxesDefaultsMissingSizes) EXPECT_EQ(a.sizeY, 1u); EXPECT_EQ(a.sizeX, 1u); } + +TEST(UnitCanonicalization, OmeZarrNanometerXYAndAngstromZBothConvert) +{ + // X/Y declared in nanometer, Z in a DIFFERENT unit (angstrom) -- each axis must + // convert using its OWN declared unit. + Nyxus::OmeAxes a = zarr_parse( + R"({"multiscales":[{ + "axes":[{"name":"z","type":"space","unit":"angstrom"}, + {"name":"y","type":"space","unit":"nanometer"}, + {"name":"x","type":"space","unit":"nanometer"}], + "datasets":[{"path":"0","coordinateTransformations":[{"type":"scale","scale":[30000.0,500.0,500.0]}]}]}]})", + {4, 6, 8}, "uint16"); + ASSERT_TRUE(a.valid); + EXPECT_NEAR(a.physX, 0.5, 1e-9); + EXPECT_NEAR(a.physY, 0.5, 1e-9); + EXPECT_NEAR(a.physZ, 3.0, 1e-9); + EXPECT_EQ(a.unitXY, "micrometer"); + EXPECT_EQ(a.unitZ, "micrometer"); +} + +TEST(UnitCanonicalization, OmeZarrAlreadyMicrometerUnaffected) +{ + // An already-canonical store must come through byte-for-byte unchanged (no accidental + // double-conversion). + Nyxus::OmeAxes a = zarr_parse( + R"({"multiscales":[{ + "axes":[{"name":"z","type":"space","unit":"micrometer"}, + {"name":"y","type":"space","unit":"micrometer"}, + {"name":"x","type":"space","unit":"micrometer"}], + "datasets":[{"path":"0","coordinateTransformations":[{"type":"scale","scale":[2.0,0.5,0.5]}]}]}]})", + {4, 6, 8}, "uint16"); + ASSERT_TRUE(a.valid); + EXPECT_DOUBLE_EQ(a.physX, 0.5); + EXPECT_DOUBLE_EQ(a.physY, 0.5); + EXPECT_DOUBLE_EQ(a.physZ, 2.0); + EXPECT_EQ(a.unitXY, "micrometer"); + EXPECT_EQ(a.unitZ, "micrometer"); +} #endif diff --git a/tests/test_ometiff_mechanics.h b/tests/test_ometiff_mechanics.h index ba75844a4..b25c1557d 100644 --- a/tests/test_ometiff_mechanics.h +++ b/tests/test_ometiff_mechanics.h @@ -319,7 +319,7 @@ void test_ometiff_multichannel_mask_pairing() il.close(); } -// Phase 5 negative: the whole-volume facade read must propagate an out-of-range channel +// Negative: the whole-volume facade read must propagate an out-of-range channel // or timeframe as a throw (the (z,c,t)->IFD map range-guards). dim5 has C=3, T=2. void test_ometiff_load_volume_out_of_range() { diff --git a/tests/test_omezarr_mechanics.h b/tests/test_omezarr_mechanics.h index 6510584aa..b33196d59 100644 --- a/tests/test_omezarr_mechanics.h +++ b/tests/test_omezarr_mechanics.h @@ -440,7 +440,7 @@ void test_omezarr_ct_counts(const char* store, int T, int C, int Z) ASSERT_EQ(raw.fullDepth(0), (size_t)Z) << store; } -// Phase 5 negative: the whole-volume facade read must propagate an out-of-range channel +// Negative: the whole-volume facade read must propagate an out-of-range channel // or timeframe as a throw (not silently read plane 0 or OOB memory). dim5 has C=3, T=2. void test_omezarr_load_volume_out_of_range() { @@ -489,6 +489,28 @@ void test_omezarr_physical_calibration() ASSERT_DOUBLE_EQ(ldr2.physicalSizeZ(), 1.0); } +// Unit canonicalization: dim5_calibrated_nm declares the SAME physical spacing as +// dim5_calibrated above, but in nanometer (2000/500/500 nm == 2.0/0.5/0.5 um). The loader +// must report the SAME canonicalized values and unit as the micrometer fixture -- proving +// actual conversion happens, not just passthrough of whatever unit string the file declares. +void test_omezarr_unit_canonicalization() +{ + fs::path cal_nm = omezarr_data_path("dim5_calibrated_nm.ome.zarr"); + ASSERT_TRUE(fs::exists(cal_nm)) << cal_nm.string(); + + auto ldr = NyxusOmeZarrLoader(1, cal_nm.string()); + ASSERT_DOUBLE_EQ(ldr.physicalSizeX(), 0.5); + ASSERT_DOUBLE_EQ(ldr.physicalSizeY(), 0.5); + ASSERT_DOUBLE_EQ(ldr.physicalSizeZ(), 2.0); + ASSERT_EQ(ldr.physicalSizeUnit(), "micrometer"); + + auto raw = RawOmezarrLoader(cal_nm.string()); + ASSERT_DOUBLE_EQ(raw.physicalSizeX(), 0.5); + ASSERT_DOUBLE_EQ(raw.physicalSizeY(), 0.5); + ASSERT_DOUBLE_EQ(raw.physicalSizeZ(), 2.0); + ASSERT_EQ(raw.physicalSizeUnit(), "micrometer"); +} + // Illegal / adversarial: self-inconsistent metadata must be rejected cleanly // (throw), not crash. bad_axes_count declares 5 axes for a 3D array (indexing the // shape by axis role would read OOB); bad_no_xy has axes but none labeled x/y. diff --git a/tests/vetting/oracle_coverage.csv b/tests/vetting/oracle_coverage.csv index 0bd6d622e..519ab7aac 100644 --- a/tests/vetting/oracle_coverage.csv +++ b/tests/vetting/oracle_coverage.csv @@ -1,6 +1,6 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test,target_test,candidate_oracle,flag,source,notes 2D,COV,firstorder,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_3d_inten.h;test_nyxus.py;test_firstorder_regression.h,test_firstorder_matlab.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A -2D,COVERED_IMAGE_INTENSITY_RANGE,firstorder,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_3d_inten.h;test_nyxus.py;test_firstorder_regression.h,test_firstorder_matlab.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A +2D,COVERED_IMAGE_INTENSITY_RANGE,firstorder,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_3d_inten.h;test_nyxus.py;test_firstorder_regression.h;test_covered_intensity_range_regression.py,test_firstorder_matlab.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 2D,ENERGY,firstorder,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_3d_inten.h;test_glcm.h;test_firstorder_ibsi.h;test_firstorder_regression.h;test_firstorder_pyradiomics.h,test_firstorder_matlab.h,,,tracker,"test_3d_inten.h ORPHANED (not #included; never run) - left per decision A | corroborated: pyradiomics exact (rel=0.00e+00), recipe=firstorder.pyradiomics_default, see test_firstorder_pyradiomics.h" 2D,ENTROPY,firstorder,vetted,pyradiomics,exact (rel=3.20e-16),firstorder.pyradiomics_default,exact,test_3d_inten.h;test_glcm.h;test_firstorder_regression.h;test_firstorder_pyradiomics.h,test_firstorder_regression.h,MIRP/IBSI check,promote-after-deepdive,tracker,"test_3d_inten.h ORPHANED (not #included; never run) - left per decision A | PROMOTED regression->vetted by pyradiomics exact (rel=3.20e-16), recipe=firstorder.pyradiomics_default, see test_firstorder_pyradiomics.h" 2D,EXCESS_KURTOSIS,firstorder,vetted,matlab,agreed,"Not mode-specific. Any disagreement is a definition, coordinate-frame, spacing, aggregation, or implementation issue rather than Nyxus IBSI/radiomics mode.",,test_3d_inten.h;test_firstorder_ibsi.h;test_firstorder_regression.h,test_firstorder_matlab.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A @@ -539,7 +539,7 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,IH_NUM_BINS,intensity_histogram,vetted,analytic,,,,test_intensity_histogram_regression.h;test_intensity_histogram.py,test_intensity_histogram_analytic.h,numpy/scipy analytic,not-in-tracker,audit, 2D,IH_BIN_SIZE,intensity_histogram,vetted,analytic,,,,test_intensity_histogram_regression.h;test_intensity_histogram.py,test_intensity_histogram_analytic.h,numpy/scipy analytic,not-in-tracker,audit, 3D,3COV,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,mirp:stat_cov (IBSI 7TET; no pyradiomics equiv),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9) -3D,3COVERED_IMAGE_INTENSITY_RANGE,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,none (Nyxus-specific: (roi_max-roi_min)/(slide_max-slide_min)),bug-degenerate,tracker,BUG: 3D segmented workflow never sets roi.slide_idx (workflow_3d_segmented.cpp) so 3d_intensity.cpp takes the else branch and always emits degenerate 1; unvettable until slide_idx+min/max_preroi_inten are wired through the 3D pipeline (2D sets it via pixel_feed.cpp). Oracle (octave/oracle_3d) confirms intended value = (roi_range)/(slide_range) +3D,3COVERED_IMAGE_INTENSITY_RANGE,firstorder,regression,,na,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py;test_covered_intensity_range_regression.py,test_3d_firstorder_regression.h,none (Nyxus-specific: (roi_max-roi_min)/(slide_max-slide_min)),fixed-2026-07-22,tracker,"FIXED (2026-07-22): the real bug was NOT roi.slide_idx (that has been correctly wired since gatherRoisMetrics_3D was written -- verified via git history). The actual defect: gatherRoisMetrics_2_slideprops_3D (slideprops.cpp) computed the whole-slide min/max baseline via RawImageLoader::for_each_voxel, which only visited IN-MASK voxels, so for any segmented (non-whole-slide) image the baseline silently collapsed to the ROI's own min/max and the ratio was always exactly 1. Same root cause in the 2D counterpart (gatherRoisMetrics_2_slideprops), also fixed. Fix: read every voxel's intensity for the baseline, gate only ROI geometry on the mask. Verified against an independently-computed expected ratio in test_covered_intensity_range_regression.py (both 2D and 3D). Oracle (octave/oracle_3d) already confirmed the intended formula = (roi_range)/(slide_range) before this fix; vetting against that harness is now unblocked but not yet re-run." 3D,3ENERGY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 3D,3ENTROPY,firstorder,vetted,pyradiomics,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_3d_firstorder_pyradiomics.h;test_ooc_mechanics.py,test_3d_firstorder_pyradiomics.h,,,tracker,test_3d_inten.h ORPHANED (not #included; never run) - left per decision A 3D,3EXCESS_KURTOSIS,firstorder,vetted,matlab,agreed,,,test_3d_firstorder_coverage.h;test_3d_inten.h;test_ooc_mechanics.py,test_3d_firstorder_regression.h,pyradiomics/mirp (native 3D first-order; kurtosis -3),,tracker,vetted vs Octave/MATLAB oracle (octave/oracle_3d; raw-formula rel<1e-9)