From 8b77be2443512fd071d89fe00667ccc30d67840d Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Thu, 16 Jul 2026 07:53:51 -0700 Subject: [PATCH 1/3] 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/3] 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 zcmcJUcT^Pj7RT@Rey_dvuIt(hWl<3k5XFWS6?^Yu9K>pT0Nb(Ujw96o1VeJS>5Wo7wuec=U5oL{PcASx7-8d6hgIpPkH)Rs-jZ8%bknB2A^4G?y0AQd&uCX(Mf=owSz@(os5zOFD~Nx=2^)Cf%in^psxG zTlz>}36pS%kVx@Jl=PGSGC-nbpv1@^iIu?;C-IUXLnKj#$}mZi;W9!-$|xBv$udU9 z$~YM>6J(;K$RwF8QzTWU$~2iSGi0XBlG!px=E^*oFKObHbjgrR@ky3sOOE(ufh?3< z$&-NOOMxtsLMf7BDUroeDrK@nmdY|&E-PfEtderMKrWPvMQ)YbWV75ZTV$(jlkIYc+$lTcF1cHF z$}YJ_?v?xGetAG1l!xSDc|>;0qq0Z#%471l?34X+K%S6;@}xW^Ps<^B#;M(N^1Qqt zhvh|iNnVy$D!<9^@`wB>f63o+wqhW|8NV>pKuy#_ zZPYCfiG(&T=KufejYqUXIv_pGzKu2_f3!ULc7j#88bVm>LL@)G4 zAM`~S!V!T;co2nt=#K%2#z4ei5MnVHafn9(h9D6`F$_rycs zjYqKud+`_^$3E=G0X%_&coI+HX&k~ccoxs$dAxwbco8q*WxRq{@fu#o5xjvn@fP03 zJ2;AW@gCmC2lx;l;bR=bC-@Yf;d6X}Z(SHq~a@Tw7>MZKbWX zjkeWx+Fmj)jGqja<;>lhuY<8-`E(21I&lXS99(NvwP({#Gd(3v_* zXX_lDtMhcerm0ucHA6Gir&*eFtRmgy2*s>^h_ zuF#dbO3U>Ey-+XGi*>cG(M$AFy-Y9HD|D@1saNUMdW~MI*Xi}TPS@)O-KaO{jk-y1 z(wp@by;X11&3e0T(XF~ox9c5xr|!_Z^lsg$yYwEtSMSsN^#OfQAJT{Q5#6ni>K@&z zkLlyOPxtEqeL@fFllqiCt%vj(eO8~-=k*0WtS{HGSDeyAVm$9haZ(NFa={anA$XLAncavtY1 zjb5fRgPHU(i`mSfp9{EU0tH*yni;?2B;xAHb_=Iz|Vt=z`# zyn}ag2k+wD+{sd4ARpqxe1yCCDEDwLALHZP$NfCOCwP!g@+m&eLwttM z@;N@w7kHR2@+H2^SNJMl>$JosU1G_x93(`s35 zt7CPop4GPo*3cSRV{2kft(i5q7S_^QS!-)!ZLOWPw+`0PI+@Elo7=irSLUz>8*dYAqNUg*n`~1o)u!4sn{G2~rp>b1Hpk}LJezN6=CyRouuSt=mStOx`E7wM zv|P)xfaP0(EwVx@vSKT-#a3!%w#1g&GFxsdY^ANTa&x+-s$T2=>FfU8%OyI^*7@{u zi|hWUms{!R>b=|=XS;guR-uDKxjBfGed~pa(rHM8mdhg{qf84TWm!deohnyff>ZH> zL8R>1;GcxS8<#HU%_vw`&!Lb}mIsk?LB;E2NGg6Th?I>Rg^DuKf>-g~A*0+JM9MkN z^gw7T?!3AO?ds7cp`uI+BIS;dQ63B;<%Fi8%B9Phb_&-0@=VAm%Y#U{sadFUc`S&O ziOoYrnOG}$6+a#_%FRKfT-zd4xvZGL4c65Y)iP9+X+fks8Zyd*L8M&SDpa|2IWywH zs(4pt20t{<@$w*2R?P7KH)Y(Avb&y0fQ&sVh>=~$%r%Y3;-l_uw8h0Sn(o-a^Qqltx?J}3H&nX{5o-LBLn1&(WpKTzy( z@;t@A)HfsE9dl02jCWVzLPtStC@5F}3ls`NeS*fgtR)4WQ|7L_{Pv1yHJ z74i2tx=p3t@96GTd?TFrb)9`LILpae4xh8Gx)l4eva)=+-q3=D&OcRu5Q>UPHK{H& zoO7!swWW>|tDbYl4WywolE%_Rno2WiE-j>`w361+M%qd{X)hhbB^|{rouspLk*?BB zx=Rn~DZQk(gh;4_Nw`Evr1X)#(odqKzeLLbiIIU4D{&GpgCs!)%MeMFp)yQ{%Lo}M zNis@COR|iSu`*6lWV}p}R7sPGGD#-O6qzd1WV+0dnKDaeOS*Vuj$}xtcqL1+B}aTR zSLR8sf*( za<#0HYvfv4E9>Msxn9=G2Dw3Ql$+#c*(jT2v)m%L%58GHY>_+UPPt3&maVc)?vd?s zuk4VWa-ZBU56FY^kUT8AWVbvbkIEi-Odgjf8O$L}QG;T&c#lU`;qn>qAxF6S|q zdGs@%1)R@97O|KmT);jO%lw{r{c;GMjScXKPZ@g8pHz1+c_ypQ+u0Y1ow z_%L^IHy`1n+{4HCIG^B?+{=A@iu?IA5AYd2%jbBI&+`So$d~vs5AhYg%GdZh5Az7$ z;8DKGxA->S;k!J><2=Fl_&z`2hx~{q`7uA?r~Hhc^Ax|}m;8!f^EA)!8-C00_&tB% zkNk;e`7_V)Jb&S@{Eff!5B|x&c)=O^&&ad#oE((roonYsc}ZTDL-LBeDzC}wa#)VY z8*)_Ml(*zd>|jnM{-g=mQUnU`Aj~SQ}Ts;DPPIga$3&FH}b7~ zC*R8t@}vADXXR%(C+FoC`Bi?C-{lYaQ~r_*Yy&_p*|X* zAsV4EnxH9~p*dQhC0e01+Mq4kp*=dlg^qBe6FQ>{x}qDpqX&AT7kVQEp$J1bA`po_ z=!<@cLVrYK0Aer@v4}%F1|b21F$9SiieVUz5g3UijKXLnV+_V(98xeI6Of8DOvEHi z#uQA&G)%_~%)~6rMmjv0gA8QCi!5X#2R_WjJmew|e&nM7^HGQ*6r%(SP>M1v#3C%l z5-i0sEXN8!sj8;cw7S;Nnp#V1YaOkt^|Zb=(1zMb8*3A7s?D^yw$PT^N?U6iZL96H zy>?KSc2u`^($3mNyJ|P>u06D;_R`)OqM;h5;ToZl+DH3pKaJA<8m$8~Mh9xF#%a6` z(gYo>Lo`u`>M$LyBXp!D=_nno$vQ^I>Nri&@j5|MHBBe#B%Q2NbgE9%={iGa>MWhD z>FUurnxUEM)hx}{9QEm3ou|2)r+&@X0-djgTBOBVq6@TC%XFbG(#5(&m+CTIt}FBs zy;N7~D!ois>*acdUa42<)w)Kn(Q9?BuG8!EdR?y@^aj0AZ_=A}qi)j8dW+twx9RP= zMeoo%^)9_zx9T>%N4M*}xpbzRp`mpZO-TH_=s(bV?eO#Z=Cv~sx)2DR5 zKCK7z5?qRvScS{58kge=T#2i2HP+x7T#L0>hwE@X)?)*1z>T;GH)A6Js7gU9eVp1_mXi+y+s`|&gm z;2AuN=Wr0u;|08km+&$U;T61!*YG+H;|SisQM`$_@HXDTyEum9IDz-@K0d&Q_y{NQ zF+Rbk_za)p6u!Wh_zGX+G|u1~e2ee!J$}HC_z7q6GtS{We!;K!4Zq_L{E5GC0YZ>e z8mqB7Yp^D3u{P_lF6*&A8?Yf8u`!#lDVwo5Td*Ztu{GPUE!(j@JJ7|Bbh8sXvkSYj z8@sayd$JdMGlZcGV>lxi$v*7MevD#&MsomTIFPZ7V>|~jfzRl(`kWrr=k*1BQD4%R z^^m@zuj*_1x*pae`i36WH}x%jTi?-l^_U*l6Z)RMuOH}#`jMX0kM$G%R6oREkjU=6L2HMS>u7H4WSy;xb+vBR z-FjG0>t($y#6m61!Y#rgt&jD!eimi@E!qZHj19C{i?eteWC=FdhFGEvwP7~gM%YM8 zvQakLl5LEQwQ-hW<86YaTAEF?NjBN0*i@Tl(`|;$v{^RW(#>OYEWeomDxgDWQ%QyEwyE~+*X*=HC6Un|4(1{&t5LUX}11O zFSnrfzk0c4j;`9vt#-Dn_HN}mFqB&YNZGqiuqd5|G+??$ zKM+95_VxaqFmU72<-8dM>gqWdG|CkLq?}v+IvJFTp9~;n!v?{kOt8RJd{59Qw*-)K zhBG}7oQgZI?g6`c*eF<(=>eqN9yH1W0i+z;I9R!KInz#ox?i3R8s&-rQf_JztX!T9 zAZ0?+U{NO22wcTa1&wk`04dir3sx@6CvXFG^+Yxg7G-(>DUSt>@<0G7m$e91E?v%y zc%UlY*_pu)&U1W404d97`2UkKc2HSfu4{p}u*jG1k8?+ahPz!}e@1?$&z~LVo|rZ~ zB-%YFK6c!gVJ^qeUlixgDK0LE2@5MNEe*{t@cQ$78HM>p8TkbZL-Px>!%{PHym_9Y zFvlS*GCU$GBs|J}(KDv3$oFsFrF|+qBE!QY!lsN#zUUd^^A{C+{25-ytH>8qR1q#Y zKf_a8!S}Zh?)uvvVflI9(6XXTw`=0Wq@i){l0tt>Nr^8rCMr5QD%$Ib4vCJ4&I*Z$ z$chLV&^J6fWKPtaesiJ*M0zp@I8KR$UQcm;VVt|l*A-V}cw|&)RA_`dJ~k=OlkIht z2d+>tPQJ%_^1PLs;f_U$ugsfURAF*amfH;V<$3+(H5yl#>2;z{nK~^k(d|lGSm3yp z`258YPM%YJi@a0f+|d`+)HrwLzG-ppz8CkU#knIds>C>VALn3+IUc{?n_F?TN*xnk ze!x^uUO}$6D8*Y?0pSb{$2!@Uol~6RDb5Ltj}5Cd|8FrO0uZB|^?&C`bG~N{^%Q$t nNyDA=I_Hp*>+yRN^Gp22&T02`mqTC7s3R(}@2s7H8ZZ9<9))~3 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 zcmcJU2XGW;5{3J{ZgS2UmL&&8KnNiTfk+|>5IJd)R?>pB5(-FUIpKtJAUR-@ogbeV z>FBPNd?TFrwVi!0Im@vc4xh8GycGH}GBSMG-q5^-&OeoZ5Q>UP6{#xKoO7!oHKmpl ztB!NV^`yQukcQGo8cP#tD$S(1w2+q4N?J=BX)Eo-CGEv69i*dllFrgax=J_cEWZ>XD;&?A(7HcdP^UPlD-lx{Uk>EORU66ybO>887PA! zQ3lHp87jkMxFpF487ZS=w2YClk}Tt7yiAZ3nJANFvP_YwGEJt-44EmjWVWQreCD%& zg)HI%7PEv4xrmFogiE=M%UR0HcsZ}&mAr~8cr~x#wY-kkb0u%!jl79B^A_I9+ju+g z;GJB>)m+21T*te3H}B!STu+b8ku*scuVhH3WQkAa$~?)I9Pvx8u($W7eL zE!@g&e1H$~AwJAU_$VLa<9vco@+m&e?R*YSVUpB}_*(94~ zi)@u`@_;-j56Q#wh&(Ef$>Z{bJSk7f)3RNjk!R&Od0t+S7iEX+lwGo0-r*s>%lG&` z5Az6*@&kUzkN7b^;ivqJ$M`vq^8`=w6i@RE&+-d?$*=e|zu~w1j^FbK{>XDY&kOvC zKl2y<%HQ}q|KOkeix-`tzeo1UKG`n^oNMQ0c|~58gYufZE^o-2@|L_U@5mu}SKgEN z<**!)qw;}#C?Cnk@`-#ZpUE-#T#m~LIVq>)w49N%@`ZdUU&+_uUpTsExF-HqoZqOq**9ZKksmyoRjl%L4J~-Y+XwpdlKeF`A$$nxQ#bpe0(NHQJyp+QEegjnrP+Tl;8~_SIM3Wb$}-5KpmusI#`G3P#vbjHAzS4NFAl4b&QVHWF4pDb%LhoM4hCQb&5{a zX*ykJ=uDlZvo%%Q!;KE;h)(E?F6fGG=#C!fi4cS$4B?1CBzmDY`XCB@5siL`L4U*| z4)GX(1PsI=Bw{dzU?_%RIFc{|BQXl2F$QCi43EyyG)-5pW@x5nsZZzXJk8b|^=q!? z>3q%C0xi@cU7*EUq6>AAF4iTwRF~;;E!E5Pa=k*Y)T?xbUai;YwR)XiuPgNiy-{z{ zn{^zGLVTZ_%Ijqkc}Mpk&8UcM?MNr zh$1XNF-ouyi?A3=uoTO%9HqELZ`IrMcD+OI)K$7#*XUYZr+4YydXL_#>-9dpUpMGR z-K3j!i*D6z`hY&D59!1Dh(4;1>Err@KB-UX)4E-s(P#BJeO_PC7j=j3)Lpt;FT>@y z0$1WHtiaW{2G`;`T#uEw0XO0%+>BdrD{jN>xC3`$6;@*n)?yv*!rizB_hLQn!~NKR zjo5_E*n+Lth6nH<9>T+T1drk|?9sitPxtEqeMw)|SM*gqsITek`i8!#Z|U3mjvmr? z^*w!G59<*lr<(U+9jnKuf7W00SN%ux=)r-fLkg;}^oSfur`-qy#Wtgl5| zKZ~*c7He@9Zv!mB2HGG?w81vShT1S2Zb>%6M%pMFZDVY#C0heFWFt0a6Ec3?+#VrOmicV1&9iLFF~8+n zp3S#>E3iTHYu>exmuNN%J1Pffnw+D@KeE=zEIMV~cskrm% z9hnDH9q8i!z~F z;3|GPXq4*%NV&2}uyR>8fg7l+C$ed2hYo16A>k&J2EV zp5vtfq%51^|4+);0VO%vt_9xw0$;8_&K(gN?sj?oX}Re>e`cI}V#?5vX!n5l*s-IB zxEw=&L7Y3QurMzsEUdV=I5aoU>(B9}<>wZp<>oC6&CSmYn~;{}&G8h3ISyfw;So_G z;Zg2Oo-ri_zJK#B?p5v)86F-HHf8jvOP(P5x`Tq97 zU4PpnEH}p+T2heic1@g^G&s&(l<$uzD)ObrL`6sUP3zq^BP2Q^IwK?^A|oQixwHfJTfXYDm20!ADfip$@IF)0+*{8C*NZ{ zIo^uRaK|FqSK`esC^xwz%WMYwa=iYs8ja0Q_d3z1Oih`V=ys(n%yV3eeEz}+C(jdn zi@a0f+|ifR)Hrv=zLYq3#Ucr!XrlJ~phv{J+JB2tbT7>i?Z1#rd8!*i-0nB@K1b>zqS! fw#V;H%q{X4Itl3QE`z?5QAd<#-&s2YHD3M!QXPDe 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 zcmcJUcT`mO636HJow4`cbzOU*iiik^V#A7xy|KUotFRzNu$y9y>CKkJbknPvqQ)eq zsHvuzZkkCn#hxDD1$pO@ch396cXs!jd-i+p&dmMXncr{k+}}-3?kP1zq`nAD;HdIF zRO;%ECbZ&sLq}_+F1M>*Mb`(Vx{4fVDblFY_lRTDtcp$ZDluDB>NLluHL6v_-{I&s zm3oh(yI1jzaN^f>j=k(`M`}5I&Ue+V*q4=+<;(Sk7A$mrsrrFXR7|Q#b*bT8TP>+A zb(~oBoGWf14W*GZmL}3vnn`nMAuXkqw3ar~R@zB>=^!rYC~oN_ou!L(m2T2qdPq;{ zCA}p?LM2SX<#~BQUX*?ElEe0jyej+UfE<+9!4GDL>TFc~f*Bw0qv zC>bqdWUP#n6d5lQWTK?XB$+HzWU5S)=`urR$}E{Jb0kep$|*T5XXLD$lV9Xl`AvS8 zKjcsOOa7MgZ1V~q7fRS37VoAnxh3;q7_=B4cej| z+M@$pcnpu@2|S7I*nypR3Qyx1Jd0h}jXih{d+|J8z>C<2m+&%P!K>Jh12~A+@H*bW zn|KRv;}G7#yLb=p;{zPVhxiB|;}d*}&+s{pz$59BA(`TpEXkG}@yT47C%KX*e#w^t znJB*ju93#3%aWT7mQ#j-?}$}(9lE94TnR4$XtWu>f=)v`v`$~w71*2|T0m0T^? zNJqHQ37ydeUC|BQ(E~lv3%wD7P=p~I5r{+|M4>OD(GM}`k5~*q9O99Hfk?z43`P=$ zU?_%RI7T2DBQXl2F$QBX4k`EoU*ao#jc;%i-{L!bk7GEFAMhi7!p}H?lQ@ObID@k| zhhOk3e#7th1ApQ#{EhR@qz@~}K2kIG~6xI7_G%68cy zJLM^PTAq<-WtZ%hJ@TCFmGPK>iAcpHOvV&U#WYOE49vtV%*Gs~!Gm;UAQN6>AsadH zVJ_w&7kTg_9|f3?LKLAGC0Kw`lwlzjVKJ6qDVAY5R-h3Zvk9BB8Jn{OTe1~fvklv_ z9ow@5UF=9VJFzpnuq(TC9jz zz06`ZbLit-&SNg~=x06)oXJB~HLa%AwT9NzT3TD{XkD$R^|gUE)JED^n`l#Qrp>j5 zw$xVITH9z_ZKv(EgSxb%y0w#b)-KvryJ>grp*^*i_SO&$)i4d$=k*1BQTORf`m(;F zuj+n1pa=CeeO=$sH}x%jTMy|w`mVmG@9PJ8SU=Q{^ke-*Kh@9lb3LM8=$HDHey!i= zQT||9@iiANBv2E))P9Pg)Cw*OSpigEaO5h;$kl0QZD0iuHYrSl$Y^xuH-7N z<{GZ$I$pu`ypmV(YF@)@c^$9k4ZM*z@n&w|MsDI}Zs9GwmA7##BQ#R`Xq5KVXziyl z+FxUJfW~ROCg?y-)ImB}lXQp<)nPhZM`*H+)KNNG$LLrcrztvKC+I{?)k!*8r|49j zrqgwX&eT~tTjyw+p43x%TF>ZNJ*U6uulk$*u7BvC`j`H#=bal5jHYH*&8k}st7)~Y zw$-t^R?q5N18ZoFtg$t*rq;}wTMKJxt*o`Rv9{LE+FJ*6+3mc8ck(XY&3kw+xA8vS z&j%R! zQeCFYb%kD{m+EDDxvtbzx?0!hT3x4C=z6_UuhOga8trIq>tvm+i*>bb*4=toPwQp9 zEyO}C%)%|gBCU@_Szn8`eimc>E!GBDoW)y$4YWiXWP>fqhS*RWX2WfSCEG|FWutA3 zjkR%>VsG;h-{HG_kMHvX9_EMqh#&J4e#+1IIgjuQe#x)+HNW9ee#`IpJ&*A?f8dY& zi9hoMPx2H`^9;}O9Dm`j{Eff!5B|x&_&3i}^jf`6uh$#&M!iXI)(yH*H|b{GqPOU+ zdYf+5+w~5;Q}5Ed^&Y)fx9NR)zdoQ3>O=alKBAB6WBRy0p-<{|-Jv`6DScX>(Pwp+ z?$$l}obJ`}Ho+!Zs!g)VHpQmeG@EWSY^Ke!**3@0%wy@6VVUN&EX%eW^VwXRXStSV ze#^H4n{S0yWW`ou3#`=2Y@sc(#kRzj+A>>iE6nMdDtoQ}r?2~WFPG>vTNl&IEvWsU zUT&GAtM+oMoc*f3Te%Jl<<3tYu_1dVcQ04ZlW%LBow zxbx^9u-U^#!JO<=Do-%B9O$b_&%0<(Z&St_UFIrY6D4<&gkVCN>Qg zWnzuMRs3ksD7OZXa(%O4<+6MMH&C-DvU#v5(*j6&IB1l614y~7MX++|a#q9xRq@Ww z3Vv|z<0}G4S-!&m-;{9!%kpwv3%rFzzI=bYJ0djP?eh9F@-uz@?0EO2)L|hp?tuw$ zF(b#D=P3$v9Ks^Q zBcemXqum!gW6O$s|L`vDQ{fRA9v%@kb6JXIy5>o!krM8oaf2*y2=Ars8}c8 zV?BA^%H43sBE?tc%`K`hxgg7JhWPTl{_+})E6nsd(Wg#Jnx5)*r7kRRTuXfZ;s__t z6Mc)kQ{&w+7u2+Pcjd99cz2%*$EL@-qb{h_cz2|8vZNf3-|x+>I9sKT4KF|8L{DBp zuD2+~TUY_%3=PM6v@biSIK@+(6P6GcR%!mfVnhTWM&w1sNOj(44e=CvT*b>xyzw1WYiHA*>}F3ftn!y0`8Z5fB*mh 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 zcmcJU2XGW;5{3KqYjVyRmL&&8KnNiTfk+|>5IJcjt)vBMB@~dzIANRYKz6_x+hlCO z31b^4oH5vd6HYj1oC7x!x;oueUDf$&cdMr6&1`qiH{J8k^!$^Q)Lp8JNL>+_z)@ve zsL)j%tzh~7`i^FWF0-pzN!Nu+Rf-&MF4CaF_nc$Xq>@e3N->*N=v2q1C90IiKj`RI z75a#yyH@gzaN^f?_C4b)=V~~7&bsna1<6^kEUt5W~f&)HA}PAr*m|!=4h_^HBa;P33*bUl4J6;bMDW|bMm|#mlxzk zc}ZTDSL9WBO-{(`a#Bvo8}g={mNRlz-jcWF9eG#YlXLREd>|jnNAj_pmrvwV`Aj~S zFXT)4O1_qFT#ajREmmR`R$~p;Vjb4w zI$Vz%a3eNgBQ{|(wqPr^;U?USTW~A3<2KxmJ8&oN!rizBJ8&=V!~NKaUD%C1*o!Cl zB%k6jKFw$NET7}^JkA&RB46Ume1)&_HJ;$>JjqjhgKzRQ&+shY;@fYhTk$mBBht~mOc_CeI;7@NsRQDSc#K(86XKVPzFh& z43;4>REEiLNsFb&GD*ZF-a5theZ`x?R7M@8t)%AV11a^0WLRzshg&yZj-4%3pG^Y@ov# z-!N1`Ra8TD)Id$tLT%JRUDQK;G(bZ%LSr;RQ#3p9K=Kp<`53$Fb-!DM{p!ZaWuzpER#8o<2ivToXAO>%qg78 zX`Id(oXJ_7%~bBgejLC9cn}A12#4_y9>ybh6p!Hu9>-BUfhX}4j^SxMgJ_xynu_#=Pf&-{hI@;Cm@ zKlmsA;zj4i0aK%yRk5m8&8k}st7)~Yw$-t^R?q5N18ZoFtg$t*rq;}wTMKJxt*o`R zv9{LET;`E9NtX=qN~UB29miba5rLsU4$|6}TOJu1m zljU-mTrOA0m2#D=kgMeyxmH%nDp@USWUZ`|^>Uluu6O92dY9g<_vjA2SMSsNb*Jvq z-MUBj>OS4C2lN4bP!H-MJ**Gu!}^Fms*mXreO!;~6Z)h+rN{JXeMXQ9BjWHODWO$gybY{@YOlC2gKF;A><}jCj<}si1SinLS zv6%B&!cs2aLN4NBF5yxx<8ofc%XtN_H~fx2@F)JlMK}*XRi&C%(W+Wa zt7{FdskOAW*3r6JPwQ)Yb6W@NXq~LHb+NA2&AM9;>uDhtYGD>`5f*8^the>CDC=v{ z*3V+Bzr|Xd#oGW&uz@zn5^b;zv7t81hFg-2u#q;(M%x%0Ysq$Sy}7exYCLSNgSnqu=Ux`n~?37xYK{Nq^Q~^jG~&f7d_sPyI_Ts&E{}V**kz z5tA?(Q!o|NFdZ{66SFWIsqi2T>BxW=naDyme3*l|$U!dr$U{Enp#X&_LNVr}1f^Jj zg;<2eSc0WkhUK`9*YgJ6$PL`cP29{a+{$gdi8u2W-pcK~jkog--pRXoH}ByN-pl)V zKX-B$cXJQ-av%5e03YCkJjg>l%!l|eAK{~Xj7Ru5kFtR_)JED^n`l#Qrp>j5w$xVI zTH9z_ZKp16uWs$29kr8o)-KvryJ>grp*=N3Lp4mpH9{k`m-f~^8l`(`>rUu$eZ?W?QOxEX~p_!@QPhS(a@+n`3h=$8ybY zd6sYUtiTGb$ck;gl~}1Qu!XkB7TXeAYRhc7IbBmlul4`*b^q+;5}ao1@APu>YyPX3 zTk7b_z1#|CyK?VVrUOH{J%E%wYXytaX-ET>%M(GPObsAqX<2!lDpy_tQ}Lq#q-q{@lD@@PJUl5vCkQFu|J=>e>DGYNQ!Xm>X zqC&!>+?PCKN(+7e=3UaO+#@nPJR)q$=uwwELwx?iB9A}a>v$FVVhYQ{jmk^+6qWP+ z?Ss4iwntcAt~a!_FvIPdI5BB(oV&QdA5&cH%ZQ1Jj`sBSc+x|nBcd}yA|f&)Led=g zkhG|@K50??B0U-X9H+zrucs)lAkJOs>xwHfJTfXYDm20!ADfix$@03&0+*{8C*NZ{ zx!#J+aK|FqSL)3vEH}9%%WMYwa=ret8jUT;@H)|_Oif9g=60nl$ah?eeg2{dC(jdn z3%yg~+|ifR)Hrv=zLYq3Pf>PQd~8^S`G1QM5r7zF)c-q2it{~du&2o5N*d~<*Exse f9FO0dm{;sCauU$nT?TzAqmC%gzO!~N)Oh&^U!#1b 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 zcmcJU2XGWu5{CQlzsWggSe6_V0U?AW1R{wjkVFnzNh@hVS_uUta(hrIoanHqutwNqgxa9i@}Fq_eoCi*%K4(p`E;Pw6GS zrH}NL5DAqq371#oRe4Qz$m9Zm2oK{CJc`FKREKGz4%ZPnQb*}%P0}$sR>$dhouCsnStsdaP0>_M(eXz`(OmWEJe{w3ny-E>&_ZYMC`n@t)?_W#W*ydLJ=SLf zHe@34!NTl?W{xU$KWS~ULAc>K|5-V{MFGD0jhRQHW zl;JW$M#?A|ElDy)#>zMuFB4>>EXoyB=j3#J`W@wHU zXo*&6jW%eDc4&_d=!i~m;c+~HC-D@X##TIoXYm}K#|wB7FJT*A#&*1dSMeHl;B~x# zH?b4Dup4`@7yGauZ{cmcg9CUM2XP4R;e8y&2lx;l;bVM)Pw^R!V1X8Cv6g75F4QtD z*G0Nmm*`SmrptAOuGCe!S})Rz^%7m9m+EDDxn7}H>RP=@uhwhyTD?xM*BkUky-9D@ zTl7}FP1ot|x?VTvM%|>FHG+}s$Nn6^C=O&a2Qh|&8Ou1va|jbSl*5?F;T*w{9L3R0 z;uwzQIF9E8PGmACaWYew$}~>lR8He`&frYW;%v_0T&D8}{>YzrhClNc{>tC@JJ0eQ z|KOkei|3si2TYA-R>NvqEvs#Htgh9w`qsc2S|e+0O{}Rkv*y;qT3Rb>ZEdWrwX^ot z!8%$ebD2jnBvZ1)E7_7Gx#E*~GGFo}U;I)ag|a}3q*zL%R2E8^l*=MnEK6jmER*H3 zLRQKuSuGdI#d3+PkxS(=xm>Q0D`l-*C0ENea;Z#?#BhwjNQ}a0Bw-B3VjRX}0wy9EpW_RB ziLdZAj^Y@O;{;CP8+?mXIF0Y{J$}HC_z7q5Gk(FZ_zl0~EY9H%{E5GC-nscgRjH;m zw5Hb5+FD2JYCWy54YZ*)(#Cp+-l=!#-FlDStM};^yD2lXL+SRc_x^)Y>1pU@}u zDScYE>NEPRKBv#?3;LqIq}%jm-L9|btNNPm(AV`1eN%VpF5Rtrbg%By{rZ-^tsZ7D zlUejKn>oy-kMlU6dCaGu1uWzO7O|KmEagI$v7C#zm`k{n%eb5?xRR^5niuh6UcxoJ zl$Y^xUcoE5mRIp=Uc+nI+1%E}x>`5uZau80^|Id9$NE}`g<6<}TZBbgKkIJ;EXoF2 zv<reQi}U?yf^Hs)Y1(&0e{GLZ!@vXO&a z_%ILik%xTvQGh}$KoN>jf>JC*8OpH;i?IYtu?)+x0xQu(n`$#{t}V2sw$j$xM%!vT zZLb})qjpl4c2>7`(XQG}yK4{aslBwf_R+o?qM;h5;ToZl+E4rI0FBat8m)sgMh9!G z#%a6`(FA=*59qskP!H*Q`o1345A;L*NI%w3^i%yzkLc(6g?_1D>DPKxkLhtep(phl z{Z>!uY5h*W*B|sp{YlU0&-#o0s=w*)dREWrANr^MrRP<69k1sNypcEYX5PYEc^lX9 zcCP0JZsaCz<{i9~ckyoC!+UujxA1;Gzz6vdALb)`l#lUoKEWsX6rbi+KEr4E9G~Y4 ze33748(-#jPO{0CVyTv9Q*5eDv*|X&X4)*9ZF6j{rJKhxEYq^gYuT1#x#qKZHsA6r z-~3i!g|@(otk_Dd)D~KqmD?g)Y)fpZEwkme!d9BoHC6Rm|4(1{&t5LUX}11OFSoGn zzk0dlj;`Lzt#S6N_ihzBFqE4ENZGeuuqd5|G+? zPQ{%^_khhFZ4xZX^Z-(B2^!`008&n98mwHpoMop#-7n7sjdEoGDc3a%RxXbOkTRip zuqYF11+L;pgGRYIfRt-n1S^*n3%G%rJ&`SgMVTHz%ELjU+#W#6<*kC1OP8}E9;k|U zbyo0$^FO{afRq(0{QpTAJES~6&$ZB7RO~D8$GIay!`&{gKeHgq=g*0Ar=^YziFOZ( zkDWMvgv&AX7st7COG*l3!oteR%0deYz5aY(W>GAk$M) z$@jNU?)uvvVFmf#(DLFew<|3zX?UEww8$S*TI$P+iHeSn_Ik3TL!u+1vqK^xvLiyG zqcb8yGNLjDWJC>$^kfZkoDz$?o|1y1ICr(TE3U}!$f(e$&>l_xdYpG_fel>qMV6Ju!8L+m*Vg&~YvG`AZ_4 zJg4{;d#A;@qc5oGaqg;PiE-|J7mlUIxg#&A8FB9Z&dCyUJ$}D8ukvhFIwrj0geji< z!aQ$rvbU%b!WkNl^*CQnZb`DIBsVNRHmu70zr~0MK#U63|D7Y%d7m}hQ{r(YjdarM iTtjl6$L~!nDD{^(m)+l80evB(j;PGO^X&}Oc=-q127H$Q 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 f7244b73d08c7e6b1a2bea82799fe713adc8bb68 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Thu, 30 Jul 2026 15:44:45 -0700 Subject: [PATCH 3/3] fix(io): avoid libc++ std::from_chars for macOS CI build --- src/nyx/ome/ome_tiff_meta.cpp | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/nyx/ome/ome_tiff_meta.cpp b/src/nyx/ome/ome_tiff_meta.cpp index c648fde74..f2871f1ea 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; };