diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..93c57acb3 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,76 @@ +name: Build Documentation + +on: + push: + branches: [ main ] + paths: + - 'docs/**' + - 'README.md' + - 'src/nyx/**' + - 'tests/vetting/oracle_coverage.csv' + - '.readthedocs.yml' + - '.github/workflows/docs.yml' + pull_request: + paths: + - 'docs/**' + - 'README.md' + - 'src/nyx/**' + - 'tests/vetting/oracle_coverage.csv' + - '.readthedocs.yml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + Build_Docs: + name: Build documentation (warnings are errors) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: docs/requirements.txt + + - name: Install documentation dependencies + run: python -m pip install -r docs/requirements.txt + + - name: Check the command-line reference against the CLI + run: python docs/tools/gen_cli_reference.py --check + + - name: Check the feature reference against the code + run: python docs/tools/gen_feature_reference.py --check + + - name: Build HTML + run: python -m sphinx -b html -W --keep-going docs/source docs/build/html + + # External sites rate-limit and time out from CI runners now and then, so a broken link is + # reported here without failing the workflow. + Check_Links: + name: Check external links (does not block) + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: docs/requirements.txt + + - name: Install documentation dependencies + run: python -m pip install -r docs/requirements.txt + + - name: Check links + run: python -m sphinx -b linkcheck -W --keep-going docs/source docs/build/linkcheck diff --git a/.gitignore b/.gitignore index 5b66f5953..f83bc43be 100644 --- a/.gitignore +++ b/.gitignore @@ -571,3 +571,6 @@ src/nyx/python/nyxus/_version.py # default result filename stem (Environment::nyxus_result_fname); the extension follows whichever # writer ran, so pytest leaves .arrow and .parquet behind on every run of tests/python/. NyxusFeatures.* + +# Sphinx autosummary output +docs/source/References/stubs/ diff --git a/.readthedocs.yml b/.readthedocs.yml index 6254453cd..2d07f2fd0 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,28 +1,19 @@ -# .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details -# Required - version: 2 build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "mambaforge-4.10" - -conda: - environment: environment.yml - -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/source/conf.py + python: "3.12" -submodules: - include: all - -# Optionally declare the Python requirements required to build your docs +# The docs need only the Python sources: docs/source/conf.py mocks the +# compiled backend, so the C++ extension is not built here. python: install: - - method: pip - path: . \ No newline at end of file + - requirements: docs/requirements.txt + +sphinx: + configuration: docs/source/conf.py + fail_on_warning: true diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 000000000..367c2f385 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,24 @@ +cff-version: 1.2.0 +message: "If you use Nyxus in your work, please cite the software as below." +type: software +title: Nyxus +abstract: >- + Scalable library for calculating features from intensity-label image data: + intensity, texture, morphology and image quality features for every region of + interest in 2D images and 3D volumes, from the command line or from Python. +authors: + - given-names: Andriy + family-names: Kharchenko + affiliation: Axle Informatics +version: 0.11.0 # x-release-please-version +license: MIT +repository-code: "https://github.com/PolusAI/nyxus" +url: "https://nyxus.readthedocs.io" +keywords: + - image analysis + - feature extraction + - radiomics + - texture features + - morphology + - microscopy + - medical imaging diff --git a/CLAUDE.md b/CLAUDE.md index 389637845..e49542b1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,18 +6,29 @@ Guidance for Claude Code (and humans) working in this repository. 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. +whole-slide/whole-volume images. It computes 758 features in all, 539 for 2D +images, 213 for 3D volumes and 6 image quality features, at the ROI or +whole-image level (`docs/tools/gen_feature_reference.py --check` keeps these +counts equal to the code), 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. +- **IO**: TIFF/OME-TIFF and OME-Zarr as 2D images or 3D volumes (OME files up + to 5D, X/Y/Z/C/T); DICOM 2D; 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`. + CMake build system. Releases are cut by release-please + (`release-please-config.json`, `.release-please-manifest.json`) from the + Conventional Commit subjects on `main`; on each release the release and + wheel builds run `ci-utils/sync_version.py` to stamp the version into + `.version`, `src/nyx/version.h` (`PROJECT_VER`, printed by the CLI) and + `plugin.json` of the build. + The Python package version is derived from the release's git tag via + `setuptools_scm`. ## Repository layout @@ -26,7 +37,7 @@ 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 +ci-utils/install_prereq_win.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) @@ -51,7 +62,7 @@ src/nyx/ all C++ sources 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). + (`register_feature(new GLCMFeature()); ...` for every 2D, 3D and image-quality feature class). **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*`, @@ -76,18 +87,20 @@ src/nyx/ all C++ sources - **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 + `workflow_pythonapi.cpp`; then `phase1.cpp` (gather ROI metrics; the + workflow files then sort ROIs into trivial and oversized), `phase2_2d.cpp` / `phase2_25d.cpp` / `phase2_3d.cpp` + (feature calc), `phase3.cpp` / `phase3_3d.cpp` (oversized ROIs, out of core); + output in `output_2_csv.cpp`, `output_2_buffer.cpp`, `output_2_apache.cpp`. + ROIs over the memory limit go through `processNontrivialRois()` (`phase3.cpp`) + and `processNontrivialRois_3D()` (`phase3_3d.cpp`), which call each feature's + `osized_scan_whole_image()`; 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` / + features (Gabor, erosion, geometric moments, GLSZM). 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`). @@ -119,8 +132,8 @@ A C++20 compiler + CMake ≥ 3.20 are required. Key CMake options: 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. +finds them. The "Building from source" section of the installation guide +(`docs/source/installation.rst`) has the full recipes. ### Windows (MSVC + optional CUDA) @@ -135,14 +148,19 @@ build cannot link them. Full-feature + CUDA build (paths below are placeholders: ``` 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`. + `vcvars64.bat` first, then `cmake -S . -B build-cli -G Ninja + -DCMAKE_CXX_COMPILER=cl -DCMAKE_BUILD_TYPE=Release -DBUILD_CLI=ON + -DALLEXTRAS=ON -DUSEGPU=ON -DCMAKE_PREFIX_PATH=$ENV\Library` then + `cmake --build build-cli`. With MSVC, CMake presets `Debug` for Ninja and + `--config` has no effect there, so `-DCMAKE_BUILD_TYPE=Release` is required. + (`NYXUS_DEP_DIR` is read only as an environment variable, not as a `-D` + cache variable.) 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 +same as above (keep `-DCMAKE_BUILD_TYPE=Release`, which +`CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE` below relies on) 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` @@ -172,15 +190,17 @@ test can hide such failures. Requires an importable `nyxus.backend` (`.pyd` on `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 +**C++ (GoogleTest, `tests/`)** — target `runAllTests` (enabled with +`-DRUN_GTEST=ON` in the top-level `CMakeLists.txt`, which fetches gtest at +configure time from `tests/CMakeLists.txt.gtest` and adds `tests/`). +`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/`. +in CMake — see the note in `tests/CMakeLists.txt`). Headers are named +`test___.h` (see "Feature validation" below): +e.g. `test_2d_glcm_ibsi.h` (IBSI conformance), `test_2d_glcm_pyradiomics.h`, +`test_3d_glcm_regression.h`, `test_2d_glcm_mechanics.h`; a family's shared +fixtures are in `test___common.h`, the general ones in +`test_data.h`. Images live in `tests/data/`. ## Feature validation ("oracle") framework @@ -199,9 +219,9 @@ governing documents are: 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 + assertion (`feature × config_recipe × oracle`) with its `status` + (`vetted` / `regression` / `invariant` / `untested`; only a `vetted` row + names an `oracle`), 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 @@ -210,7 +230,7 @@ governing documents are: 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 + pydicom, opencv, 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**. @@ -232,9 +252,9 @@ tolerance loose enough to pass a known-bad value is itself a test bug. - **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. +- **Comment changed source lines** with what the code does and why it does it + that way. The history of a change (the defect and the fix) belongs in the + commit message, not 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. @@ -263,6 +283,7 @@ tolerance loose enough to pass a known-bad value is itself a test bug. ## 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). +https://nyxus.readthedocs.io. Usage examples (directory/file/NumPy +featurization, whole-slide, 3D, nested ROIs, Hounsfield/CT handling, +anisotropy, Arrow/Parquet output) are in the documentation, starting at +`docs/source/cmdline_and_examples.rst`; the README has only a short quick start. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..581caabf5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,244 @@ +# Contributing to Nyxus + +Thank you for helping improve Nyxus. This page covers the conventions a change has to follow to be +merged: branches and pull requests, commit messages, building, testing, the feature-validation +framework, line endings and the documentation. For how the code is organized (feature classes, +registration, the analysis pipeline, the GPU kernels and the out-of-core path) read the +[developer guide](docs/source/devguide.rst), published at +. + +Bugs and feature requests go to the [issue tracker](https://github.com/PolusAI/nyxus/issues). + +## Branches and pull requests + +- `main` is protected. Never commit or push to it directly; the repository's pre-commit + configuration (`.pre-commit-config.yaml`) also refuses commits to `main` locally. +- Cut a branch from `main` and name it after its base: `main-`, for example + `main-glcm-offset` (not `fix/...` or `feat/...`). +- Open a pull request against `main` of [PolusAI/nyxus](https://github.com/PolusAI/nyxus). Review + and merge are done by the maintainers. +- Before you ask for review, run the full Python suite and, for a C++ change, the gtest suite + (see [Running the tests](#running-the-tests)). CI runs both on Linux, Windows and macOS. + +## Commit messages + +Releases are made by [release-please](https://github.com/googleapis/release-please), which reads the +commit subjects on `main` to choose the next version and write `CHANGELOG.md`. Every commit that +lands on `main` is read, so **every commit subject, and the pull request title, is a +[Conventional Commit](https://www.conventionalcommits.org/)**: + +``` +(): +``` + +for example `fix(imq): score oversized ROIs like in-RAM ones` or +`docs(glcm): correct the angle-averaging formula`. The types and where they appear in the +changelog (`release-please-config.json`): + +| type | changelog section | +|------|-------------------| +| `feat` | Features | +| `fix` | Bug Fixes | +| `perf` | Performance Improvements | +| `revert` | Reverts | +| `docs` | Documentation | +| `refactor` | Code Refactoring | +| `build` | Build System | +| `chore`, `style`, `test`, `ci` | not shown | + +A breaking change is marked with `!` after the type (`feat(io)!: ...`) or a `BREAKING CHANGE:` +footer. While the version is below 1.0, a breaking change bumps the minor version and a `feat` +bumps the patch version (`bump-minor-pre-major`, `bump-patch-for-minor-pre-major`). + +Do not edit version numbers or `CHANGELOG.md` by hand. When the release pull request that +release-please keeps open is merged, the release workflow (`.github/workflows/release-please.yml`) +tags the release and starts the PyPI and Docker publish workflows. The release and wheel builds +run `ci-utils/sync_version.py` to stamp the version into `.version`, `src/nyx/version.h` and +`plugin.json` of the build. The Python +package takes its version from the release tag through `setuptools_scm`. + +## Building + +Nyxus needs a C++20 compiler and CMake 3.20 or newer. The dependencies are easiest to get from +conda-forge, using the lists in `ci-utils/envs/`: + +```bash +conda create -n nyxus-dev -c conda-forge python=3.12 pytest bfio imagecodecs \ + --file ci-utils/envs/conda_cpp.txt --file ci-utils/envs/conda_py.txt +conda activate nyxus-dev +``` + +On Apple Silicon Macs and Linux aarch64 use `conda_cpp_mac.txt` in place of `conda_cpp.txt`; on +Linux also add `--file ci-utils/envs/conda_linux_compiler.txt`, as the CI builds do. + +**Python package.** Build and install the extension into the environment, as CI does +(`setup.py` always configures with `-DALLEXTRAS=ON`): + +```bash +CMAKE_ARGS="-DCMAKE_PREFIX_PATH=$CONDA_PREFIX" python -m pip install . -vv +``` + +**Command-line tool and C++ tests.** + +```bash +cmake -S . -B build-cli -DCMAKE_BUILD_TYPE=Release -DBUILD_CLI=ON -DRUN_GTEST=ON -DALLEXTRAS=ON \ + -DCMAKE_PREFIX_PATH=$CONDA_PREFIX +cmake --build build-cli --config Release --parallel +``` + +Use a directory other than `build`, which `pip` uses for the Python package. + +The main CMake options: + +| Option | Meaning | +|---|---| +| `-DBUILD_CLI=ON` | build the `nyxus` command-line executable | +| `-DBUILD_LIB=ON` | build the Python extension (`backend` target) | +| `-DRUN_GTEST=ON` | fetch GoogleTest and build the `runAllTests` executable | +| `-DALLEXTRAS=ON` | enable all optional I/O: OME-Zarr (z5), DICOM (dcmtk) and Arrow/Parquet | +| `-DNOEXTRAS=OFF` with `-DUSE_ARROW` / `-DUSE_Z5` / `-DUSE_DCMTK=ON` | enable the optional I/O selectively (`NOEXTRAS` defaults to `ON` and turns all three off) | +| `-DUSEGPU=ON` | build the CUDA kernels; needs a CUDA toolkit that supports the host compiler | +| `-DRUN_BENCHMARKS=ON` | build the microbenchmarks in [`benchmarks/`](benchmarks/README.md) | + +**Windows.** Use MSVC, not gcc: conda-forge's Windows packages are built with MSVC, so a gcc build +cannot link them. Run the Visual Studio `vcvars64.bat` first and configure with the Ninja generator, +which also lets `nvcc` use `cl` as its host compiler for a CUDA build: + +```bat +cmake -S . -B build-cli -G Ninja -DCMAKE_CXX_COMPILER=cl -DCMAKE_BUILD_TYPE=Release ^ + -DBUILD_CLI=ON -DALLEXTRAS=ON -DCMAKE_PREFIX_PATH=%CONDA_PREFIX%\Library +cmake --build build-cli +``` + +Pass `-DCMAKE_BUILD_TYPE=Release`: with MSVC, CMake presets `Debug` for Ninja, and `--config` has +no effect on a single-configuration generator. + +If CMake still misses a dependency, set the environment variable `NYXUS_DEP_DIR` to the same +prefix before configuring; `CMakeLists.txt` reads it from the environment, not as a `-D` option. + +To run the result, put `%CONDA_PREFIX%\Library\bin` (the dependency DLLs), and for a GPU build the +CUDA toolkit's `bin`, on `PATH`. + +Without conda, `ci-utils/install_prereq_linux.sh` and `ci-utils/install_prereq_win.bat` build the +dependencies from source. The "Building from source" section of the +[installation guide](https://nyxus.readthedocs.io/en/latest/installation.html#building-from-source) +has the full recipes. + +## Running the tests + +**Python.** Run the whole directory, as CI does: + +```bash +python -m pytest tests/python/ -vv +``` + +Run the full directory rather than selected files: some defects only show as state carried from one +test to the next, and running a single file hides them. The markers in `pytest.ini` are `arrow`, +`serial` and `skip_ci`. In a build without Arrow (`ALLEXTRAS=OFF`) the `arrow` tests fail with +"Apache Arrow functionality is not available"; that is expected. + +**C++.** Build with `-DRUN_GTEST=ON` and run `runAllTests` from the build directory +(`build-cli/tests/runAllTests`, or `build-cli\tests\Release\runAllTests.exe` with a +multi-config generator such as Visual Studio). +`tests/test_all.cc` is the single gtest `main`; it `#include`s the `test_*.h` headers, which are +deliberately not listed in `tests/CMakeLists.txt`. Test images are in `tests/data/`. + +**Validation checks.** CI also runs these, and they need only the Python standard library: + +```bash +python tests/vetting/check_coverage.py --check +python tests/vetting/check_test_names.py --check +python tests/vetting/audit/scanlib_selftest.py +python tests/vetting/audit/run_scanners.py +python tests/vetting/report_features.py --check +``` + +## Tests and feature validation + +Nyxus validates feature values against independent reference implementations ("oracles") and +closed-form values, not only against snapshots of its own output. Every new or changed test follows +[`tests/vetting/SPEC.md`](tests/vetting/SPEC.md); [`tests/vetting/README.md`](tests/vetting/README.md) +lists the rest of the framework. In short: + +- **Four test kinds, in separate files.** *Oracle* tests compare with an external tool or a closed + form and are the only tests that establish that a feature is vetted. *Regression* tests pin + current output to catch drift and never claim correctness. *Invariant* tests assert a required + relation, such as the out-of-core result equalling the in-memory one. *Mechanics* tests cover + plumbing: gating, defaults and I/O. +- **Naming.** Test files are `test___.{h,py}`, for example + `test_2d_glcm_pyradiomics.py`, `test_3d_glcm_regression.h` or `test_2d_glcm_mechanics.h`. Test + functions follow the same shape, `test__[_]_`, and a gtest + case is `TEST(TEST_NYXUS, )`. The `2d`/`3d` token is mandatory; a file + whose subject has no image dimensionality is listed in `DIM_AGNOSTIC` in + `tests/vetting/check_test_names.py` with its reason. Helpers that take arguments are named + `assert_*`. A family's shared fixtures live in `test___common.h`. +- **Oracle tokens** (SPEC §4): `pyradiomics`, `radiomicsj`, `skimage`, `mirp`, `matlab`, + `cellprofiler`, `mitk`, `feature2djava`, `wndcharm`, `imea`, `imagej`, `fraclac`, `pydicom`, + `opencv`, `ibsi`, `analytic`. +- **Goldens are generated offline.** A checked-in generator, + `tests/vetting/oracles/gen__.*`, runs the reference tool, and its numbers are + pinned in the test with the tool, version, settings and generator recorded beside them. Reference + tools are never CI dependencies. +- **The registry.** `tests/vetting/oracle_coverage.csv` has one row per assertion + (`feature × config_recipe × oracle`) with its `status`: `vetted`, `regression`, `invariant` or + `untested`. Only a `vetted` row names an `oracle`. A feature counts as vetted when it has at least + one `vetted` row. Update the row with the test, and regenerate the generated reports: + + ```bash + python tests/vetting/check_coverage.py --write + python tests/vetting/report_features.py --write + ``` + +- **Config recipes and benchmarks.** A row's `config_recipe` must be defined in + `tests/vetting/config_recipes.md` and its `benchmark` in `tests/vetting/benchmarks.md`. +- **Tolerances.** Use the tolerance policy in SPEC §7 and give every non-exact tolerance a one-line + reason. A tolerance loose enough to pass a known-bad value is a bug in the test. + +When you change a feature's math, re-vet it against its oracle at the recipe's configuration and +update its registry row; do not just re-baseline a snapshot. A fix comes with a test that fails +without it. + +## Code conventions + +- Match the naming, density and idiom of the surrounding code. +- A feature's in-memory ("trivial ROI") and out-of-core ("oversized ROI", `osized_*`) + implementations must return identical values. If you change one, check the other. +- Comments describe what the code does and why it does it that way. The history of a change belongs + in the commit message. +- Never commit machine-specific content: no absolute paths, user names, personal install locations + or references to files that exist on one machine only. Use placeholders such as + `$CONDA_PREFIX`, `` or `~`. + +## Line endings + +The repository stores LF line endings everywhere, and `.gitattributes` enforces it: git normalizes +text files on check-in, whatever your editor or `core.autocrlf` setting does. The one exception is +Windows scripts (`*.bat`, `*.cmd`, `*.ps1`), which are checked out with CRLF in the working tree but +stored as LF. Test images and other binary fixtures are marked `binary` so they are never rewritten. + +## Documentation + +The documentation is built with Sphinx from `docs/source` and published on +[Read the Docs](https://nyxus.readthedocs.io). It builds without the compiled extension, because +`docs/source/conf.py` mocks `nyxus.backend`: + +```bash +python -m pip install -r docs/requirements.txt +python docs/tools/gen_cli_reference.py --check +python docs/tools/gen_feature_reference.py --check +python -m sphinx -b html -W --keep-going docs/source docs/build/html +python -m sphinx -b linkcheck -W --keep-going docs/source docs/build/linkcheck +``` + +These are the steps the docs CI workflow (`.github/workflows/docs.yml`) runs; warnings are +errors. The command-line and feature references are generated from the code: after changing a CLI +option or a feature, run the two generators without `--check` to rewrite their pages. + +## Citing Nyxus + +If you use Nyxus in your work, please cite it as described in [`CITATION.cff`](CITATION.cff). + +## License + +By contributing, you agree that your contributions are licensed under the repository's +[MIT License](LICENSE). diff --git a/README.md b/README.md index c60a5bc61..a01f8dd2e 100644 --- a/README.md +++ b/README.md @@ -6,1107 +6,118 @@ [![Conda](https://img.shields.io/conda/v/conda-forge/nyxus)](https://anaconda.org/conda-forge/nyxus) [![Conda Downloads](https://img.shields.io/conda/dn/conda-forge/nyxus?label=Conda%20downloads)](https://anaconda.org/conda-forge/nyxus) -Scalable Python library for calculating engineered geometric features from segmented and whole-slide images and volumes +Nyxus computes intensity, texture, morphology and image quality features for every region of +interest (ROI) in 2D images and 3D volumes: 758 features in all, 539 for 2D images, 213 for 3D +volumes and 6 image quality features. It runs from Python or the command line, reads TIFF, +OME-TIFF, OME-Zarr, NIfTI and DICOM files (OME-TIFF and OME-Zarr with up to five dimensions), and +assembles ROIs that span several image tiles. Each feature's validation against reference tools +such as PyRadiomics, MIRP, MATLAB and CellProfiler is listed in the documentation. -## Overview -Nyxus is a feature-rich, optimized, Python/C++ application capable of analyzing images of arbitrary size and assembling complex regions of interest (ROIs) split across multiple image tiles and files. This accomplished through multi-threaded tile prefetching and a three phase analysis pipeline shown below. +ROIs larger than the memory budget are processed out of core when they come from files; which +features then keep their in-memory values is listed under [large images](https://nyxus.readthedocs.io/en/latest/large_images.html). -![](docs/source/nyxus_workflow.jpg) +![The Nyxus pipeline](https://raw.githubusercontent.com/PolusAI/nyxus/main/docs/source/nyxus_workflow.png) -Nyxus can be used via Python or command line and is available in containerized form for reproducible execution. Nyxus computes over 450 combined intensity, texture, and morphological features at the ROI or whole image level with more in development. Key features that make Nyxus unique among other image feature extraction applications is its ability to operate at any scale, its highly validated algorithms, and its modular nature that makes the addition of new features straightforward. +**Documentation:** -Currently, Nyxus can read 2D image data from OME-TIFF, OME-Zarr, and DICOM 2D Grayscale images. Nyxus reads 3D volumes from compressed and uncompressed NIFTI files, and natively from OME-TIFF and OME-Zarr (NGFF 0.4 and 0.5), including 5-dimensional (X, Y, Z, C, T) datasets whose axis order is taken from the file's own metadata rather than assumed. Nyxus Python API supports featurizing in-memory 2D image data represented by NumPy arrays. - -The docs can be found at [Read the Docs](https://nyxus.readthedocs.io/en/latest/). - -## Getting started - -For use in python, the latest version of Nyxus can be installed via the [Pip package manager](https://pypi.org/project/pip) or [Conda package manager](https://docs.conda.io/en/latest/): +## Installation ``` pip install nyxus ``` -or -``` -conda install nyxus -c conda-forge -``` - -## Usage - -The library provides class `Nyxus` for 2-dimensional TIFF, OME.TIFF, OME.ZARR, and DICOM slides and intensity-mask slide pairs, and class `Nyxus3D` for NIFTI, OME-TIFF, and OME-Zarr volumes and intensity-volume volume pairs. Additionally to a single-file representation, a volume can be represented by its z-slices residing in separate 2-dimensional TIFF or ONE.TIFF slides (so called "layout A"). Slides and volumes can be featurized as all the files in a directory filtered with a file pattern passed specified. Alternatively, explicit file name pairs and pair lists can be featurized. Alternatively, 2D and 3D NumPy arrays can be featurized. - -### 2D usage - -Given `intensities` and `labels` folders, Nyxus pairs up intensity-segmentation mask images and extracts features from all of them. A summary of the available feature are [listed below](#available-features). - -#### featurizing data in file system directories - -```python -from nyxus import Nyxus -nyx = Nyxus (["*ALL*"]) -intensityDir = "/path/to/images/intensities/" -maskDir = "/path/to/images/labels/" -features = nyx.featurize_directory (intensityDir, maskDir) # selecting all the .ome.tif slides (default) -``` - -#### featurizing explicitly defined lists of files -Alternatively, Nyxus can process explicitly defined pairs of intensity-mask images thus specifying custom 1:N and M:N mapping between segmentation mask and intensity image files. -The following example extracts all the features (note parameter "*ALL*") from intensity images 'i1', 'i2', and 'i3' related with mask images 'm1' and 'm2' via a custom mapping: - -```python -from nyxus import Nyxus -nyx = Nyxus (["*ALL*"]) -features = nyx.featurize_files( - [ - "/path/to/images/intensities/i1.ome.tif", - "/path/to/images/intensities/i2.ome.tif", - "/path/to/images/intensities/i3.ome.tif" - ], - [ - "/path/to/images/labels/m1.ome.tif", - "/path/to/images/labels/m2.ome.tif", - "/path/to/images/labels/m2.ome.tif" - ], - False) # pass True to featurize intensity files as whole segments -``` - -The result variable `features` is a Pandas dataframe similar to what is shown below. Note that if multiple segments are stored in a segmentation mask file, each segment's features in the resultcan be identified by the mask file name and segment mask label. - -| | mask_image | intensity_image | label | MEAN | MEDIAN |...| GABOR_6 | -|----:|:---------------------|:---------------------|--------:|--------:|---------:|--:|-----------:| -| 0 | p1_y2_r51_c0.ome.tif | p1_y2_r51_c0.ome.tif | 1 | 45366.9 | 46887 |...| 0.873016 | -| 1 | p1_y2_r51_c0.ome.tif | p1_y2_r51_c0.ome.tif | 2 | 27122.8 | 27124.5 |...| 1.000000 | -| 2 | p1_y2_r51_c0.ome.tif | p1_y2_r51_c0.ome.tif | 3 | 34777.4 | 33659 |...| 0.942857 | -| 3 | p1_y2_r51_c0.ome.tif | p1_y2_r51_c0.ome.tif | 4 | 35808.2 | 36924 |...| 0.824074 | -| 4 | p1_y2_r51_c0.ome.tif | p1_y2_r51_c0.ome.tif | 5 | 36739.7 | 37798 |...| 0.854067 | -| ... | ... | ... | ... | ... | ... |...| ... | -| 734 | p5_y0_r51_c0.ome.tif | p5_y0_r51_c0.ome.tif | 223 | 54573.3 | 54573.3 |...| 0.980769 | - -#### featurizing in-memory 2D images; featurizing a montage - -Nyxus can also featurize in-memory intensity-mask pairs that are loaded as NumPy arrays using the `featurize` method. This method takes in either a single pair of 2D intensity-mask pairs -or a pair of 3D arrays containing 2D intensity and mask images. There is also two optional parameters to supply names to the resulting dataframe, . - -```python -from nyxus import Nyxus -import numpy as np - -nyx = Nyxus (["*ALL*"]) - -intens = np.array([ - [[1, 4, 4, 1, 1], - [1, 4, 6, 1, 1], - [4, 1, 6, 4, 1], - [4, 4, 6, 4, 1]], -]) - -seg = np.array([ - [[1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [0, 1, 1, 1, 1], - [1, 1, 1, 1, 1]] -]) - -features = nyx.featurize(intens, seg) -``` - -Note: if array `intens` contains negative values similarly to intensities in Hounsfeld units observed in CT-scan datasets, method `featurize()` automatically adjusts values of of array `intens` while passing them to Nyxus backend so as to make them zero-based, like in the following example: +or ``` -import numpy as np -import nyxus -I = np.array([ - [-1024.74, -1019.67, -1005.70, -998.60, -998.66, -1005.82, -1019.65, -1024.72], - [-1019.44, -1001.22, -1023.82, -1034.34, -1035.81, -1027.00, -1001.89, -1019.62], - [-1011.86, -1002.17, -724.06, -521.43, -471.04, -671.30, -1006.98, -1010.62], - [-1008.78, -703.58, 21.66, 44.32, 130.35, 113.37, -608.11, -1056.33], - [-415.46, -106.08, 69.80, 59.70, 97.64, 120.62, -49.77, -480.57], - [-464.06, -176.81, 76.79, 93.34, 131.99, 73.16, -106.70, -348.06], - [-1012.75, -740.21, -502.72, -370.36, -377.42, -497.65, -719.82, -1000.82], - [-1032.57, -979.63, -867.71, -815.30, -830.90, -875.08, -983.76, -1033.78]], np.float64) -M = np.array([ - [0, 0, 0, 0, 0, 0, 0, 0], - [0, 0, 1, 1, 1, 1, 0, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [0, 0, 1, 1, 1, 1, 0, 0], - [0, 0, 0, 0, 0, 0, 0, 0]], np.uint16) -nyx = nyxus.Nyxus (["*ALL_INTENSITY*"]) -f = nyx.featurize (I, M, intensity_names=['I'], label_names=['M']) -``` - - -The `features` variable is a Pandas dataframe similar to what is shown below. - -| | mask_image | intensity_image | label | MEAN | MEDIAN |...| GABOR_6 | -|----:|:--------------|:----------------|------:|--------:|---------:|--:|-----------:| -| 0 | Segmentation1 | Intensity1 | 1 | 45366.9 | 46887 |...| 0.873016 | -| 1 | Segmentation1 | Intensity1 | 2 | 27122.8 | 27124.5 |...| 1.000000 | -| 2 | Segmentation1 | Intensity1 | 3 | 34777.4 | 33659 |...| 0.942857 | -| 3 | Segmentation1 | Intensity1 | 4 | 35808.2 | 36924 |...| 0.824074 | -| ... | ... | ... | ... | ... | ... |...| ... | -| 14 | Segmentation2 | Intensity2 | 6 | 54573.3 | 54573.3 |...| 0.980769 | - -Note that in this case, default names of virtual image files were provided for the `mask_image` and `intensity_image` columns. To override default names 'Intensity' and 'Segmentation' appearing in these columns, the optional arguments `intensity_names` and `label_names` are used by passing lists of names in. -The length of the lists must be the same as the length of the mask and intensity arrays. The following example sets mask and intensity images in the output to desired values: - -```python -intens_names = ['int1', 'int2'] -seg_names = ['seg1', 'seg2'] -features = nyx.featurize(intens, seg, intens_name, seg_name) -``` - -The `features` variable will now use the custom names, as shown below - -| | mask_image | intensity_image | label | MEAN | MEDIAN |...| GABOR_6 | -|----:|:-----------------|:-------------------------|------:|--------:|---------:|--:|-----------:| -| 0 | seg1 | int1 | 1 | 45366.9 | 46887 |...| 0.873016 | -| 1 | seg1 | int1 | 2 | 27122.8 | 27124.5 |...| 1.000000 | -| 2 | seg1 | int1 | 3 | 34777.4 | 33659 |...| 0.942857 | -| 3 | seg1 | int1 | 4 | 35808.2 | 36924 |...| 0.824074 | -| ... | ... | ... | ... | ... | ... |...| ... | -| 14 | seg2 | int2 | 6 | 54573.3 | 54573.3 |...| 0.980769 | - -### 3D usage - -Featurizing a directory of volume files is similar to the 2D case except class `Nyxus3D` should be used, and the "all" feature group nickname string should be passed as "*3D_ALL*": - -```python -import nyxus -nyx = nyxus.Nyxus3D (["*3D_ALL*"]) - -# dataset -idir = "/dataset1/input" -mdir = "/dataset1/masks" - -# selecting only uncompressed NIFTI files -features1 = nyx.featurize_directory (idir, mdir, file_pattern=".*\.nii") - -# selecting only compressed NIFTI files -features2 = nyx.featurize_directory (idir, mdir, file_pattern=".*\.nii\.gz") +conda install nyxus -c conda-forge ``` -Note on CT datasets: voxel intensities recorded in the Hounsfield units are automatically read by Nyxus as 0-based values by adding the minimum value of an original file (typically -1024). - -Volumes are also read natively from OME-TIFF and OME-Zarr, including 5-dimensional (X, Y, Z, C, T) datasets. The axis order comes from the file's own metadata, so non-canonical layouts are addressed correctly rather than assumed to be `TCZYX`: - -```python -import nyxus -nyx = nyxus.Nyxus3D (["*3D_ALL*"]) -features = nyx.featurize_directory (idir, mdir, file_pattern=".*\.ome\.zarr") -``` +The conda-forge package has no build for Apple Silicon Macs or Linux aarch64; use `pip` there. +On Windows, the PyPI wheels do not read OME-Zarr or compressed NIfTI (`.nii.gz`), and the +conda-forge packages of 0.11.0 need Visual Studio installed (they were built in Debug mode). +GPU wheels, the Docker image and building from source are described in +[Installation](https://nyxus.readthedocs.io/en/latest/installation.html). -Each channel and timeframe of a multi-channel or time-series volume is featurized as a volume of its own, and the resulting rows carry `c_index` and `t_index` columns identifying which one they came from. A single-channel mask is reused across all intensity channels. +## Quick start -Note on physical voxel spacing: OME-TIFF and OME-Zarr files may declare a physical size per axis. Passing `use_physical_spacing=True` makes Nyxus use each volume's declared spacing as its voxel anisotropy, ratio-normalized so that the smallest axis is 1: +### 2D, from arrays in memory ```python -nyx = nyxus.Nyxus3D (["*3D_ALL*"], use_physical_spacing=True) -``` - -It is off by default, so the default cubic-voxel behavior is unchanged, and explicitly specified `anisotropy_x/y/z` values take precedence over the declared spacing. The spacing actually applied is reported in the `phys_x`, `phys_y`, `phys_z`, and `phys_unit` output columns, canonicalized to micrometers so that files declaring their sizes in different units stay numerically comparable. - -Featurizing explicitly specified volume files is straightforward, too: - -```python -import nyxus -nyx = nyxus.Nyxus3D (["*3D_ALL*"]) - -idir = [ - "/patient123/mri.nii.gz", - "/patient123/mri.nii.gz", - "/patient123/mri.nii.gz"] - -mdir = [ - "/patient123/segmentation/kidney_right.nii.gz", - "/patient123/segmentation/liver.nii.gz", - "/patient123/segmentation/kidney_left.nii.gz"] - -nyx.featurize_files (idir, mdir, False) # pass True to featurize intensity files as whole segments -``` - -### Whole-slide and whole-volume feature extraction - -Nyxus provides dedicated workflows for extracting features from whole 2D slides and volumes. The workflows scale across CPU cores as controlled by constructor parameter `n_feature_calc_threads` of classes Nyxus and Nyxus3D. - -Example -- whole-slide featurization with 4 threads: - -``` -dir = "/2d_dataset/intensity" -import nyxus -nyx = nyxus.Nyxus (features=["*WHOLESLIDE*"], n_feature_calc_threads=4) -f = nyx.featurize_directory (intensity_dir=dir, label_dir=dir) -``` - -Note: feature group `*WHOLESLIDE*` but not `*ALL*` is used to avoid calculation of shape features meaningless in case of the trivial rectangular shape of unsegmented slides. particularly, group `*WHOLESLIDE*` disables the time-consuming group GLDZM, basic morphology features (AREA_PIXELS_COUNT, AREA_UM2, ASPECT_RATIO, BBOX_XMIN, BBOX_YMIN, BBOX_WIDTH, BBOX_HEIGHT, CENTROID_X, CENTROID_Y, COMPACTNESS, DIAMETER_EQUAL_AREA, EXTENT, MASS_DISPLACEMENT, WEIGHTED_CENTROID_X, WEIGHTED_CENTROID_Y), enclosing/inscribing/circumscribing circle features, convex hull based features (CONVEX_HULL_AREA, SOLIDITY, CIRCULARITY, POLYGONALITY_AVE, HEXAGONALITY_AVE, HEXAGONALITY_STDDEV), fractal dimension features, geodetic features, ROI neighbor features, ROI radius features, ellipse fitting features, extrema features, morphological erosion features, Caliper and chords features. - -Disabled features can be requested by explicitly specifying them, for example enforcing calculation of the grey level distance zone matrix based (GLDZM) features: - -``` -dir = "/2d_dataset/intensity" -import nyxus -nyx = nyxus.Nyxus (features=["*WHOLESLIDE*", "*ALL_GLDZM*"], n_feature_calc_threads=4) -f = nyx.featurize_directory (intensity_dir=dir, label_dir=dir) -``` - - -## Further steps - -For more information on all of the available options and features, check out [the documentation](#). - -Nyxus can also be [built from source](#building-from-source) and used from the command line, or via a pre-built Docker container. - -## Getting and setting parameters of Nyxus - -All parameters to configure Nyxus are available to set within the constructor. These parameters can also be updated after the object is created using the `set_params` -method. This method takes in keyword arguments where the key is a valid parameter in Nyxus and the value is the updated value for the parameter. For example, -to update the `coarse_gray_depth` to 256 and the `gabor_f0` parameter to 0.1, the following can be done: - -```python +import numpy as np from nyxus import Nyxus -nyx = Nyxus(["*ALL*"]) -intensityDir = "/path/to/images/intensities/" -maskDir = "/path/to/images/labels/" -features = nyx.featurize_directory (intensityDir, maskDir) -nyx.set_params(coarse_gray_depth=256, gabor_f0=0.1) -``` -A list of valid parameters is included in the documentation for this method. +intensity = np.array([[1, 4, 4, 1], + [1, 4, 6, 1], + [4, 1, 6, 4], + [4, 4, 6, 4]], dtype=np.uint16) +labels = np.array([[1, 1, 0, 2], + [1, 1, 0, 2], + [1, 1, 0, 2], + [1, 1, 0, 2]], dtype=np.uint32) -To get the values of the parameters in Nyxus, the `get_params` method is used. If no arguments are passed to this function, then a dictionary mapping all of the variable names to the respective value is returned. For example, - -```python -from nyxus import Nyxus -nyx = Nyxus(["*ALL*"]) -intensityDir = "/path/to/images/intensities/" -maskDir = "/path/to/images/labels/" -features = nyx.featurize_directory (intensityDir, maskDir) -print(nyx.get_params()) +nyx = Nyxus(["*ALL_INTENSITY*"]) +features = nyx.featurize(intensity, labels) +print(features[["ROI_label", "MEAN", "MAX"]]) ``` -will print the dictionary +A directory of image files and their label images is featurized with +`nyx.featurize_directory("intensities/", "labels/")`. -```bash -{'coarse_gray_depth': 256, -'features': ['*ALL*'], -'gabor_f0': 0.1, -'gabor_freqs': [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0], -'gabor_gamma': 0.1, -'gabor_kersize': 16, -'gabor_sig2lam': 0.8, -'gabor_theta': 45.0, -'gabor_thold': 0.025, -'ibsi': 0, -'n_loader_threads': 1, -'n_feature_calc_threads': 4, -'neighbor_distance': 5, -'pixels_per_micron': 1.0} -``` +### 3D, from volume files -There is also the option to pass arguments to this function to only receive a subset of parameter values. The arguments should be -valid parameter names as string, separated by commas. For example, - -```python -from nyxus import Nyxus -nyx = Nyxus(["*ALL*"]) -intensityDir = "/path/to/images/intensities/" -maskDir = "/path/to/images/labels/" -features = nyx.featurize_directory (intensityDir, maskDir) -print(nyx.get_params('coarse_gray_depth', 'features', 'gabor_f0')) -``` -will print the dictionary +This example writes its volume with tifffile (`pip install tifffile`). ```python -{ - 'coarse_gray_depth': 256, - 'features': ['*ALL*'], - 'gabor_f0': 0.1 -} -``` - -## Using Arrow for feature results - -Nyxus provides the ability to get the results of the feature calculations in Arrow IPC and Parquet formats. - -To create an Arrow IPC or Parquet file, use `output_type="arrowipc"` or `output_type="parquet"` in `Nyxus.featurize*` calls. -Optionally, an `output_path` argument can be passed to specify the location of the output file. For example, - -```python - from nyxus import Nyxus - import numpy as np - - intens = np.array([ - [[1, 4, 4, 1, 1], - [1, 4, 6, 1, 1], - [4, 1, 6, 4, 1], - [4, 4, 6, 4, 1]], - - [[1, 4, 4, 1, 1], - [1, 1, 6, 1, 1], - [1, 1, 3, 1, 1], - [4, 4, 6, 1, 1]], - - [[1, 4, 4, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 6, 1, 1], - [1, 1, 6, 1, 1]], - - [[1, 4, 4, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 6, 1, 1]], - ]) - - seg = np.array([ - [[1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1]], - - [[1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [0, 1, 1, 1, 1], - [1, 1, 1, 1, 1]], - - [[1, 1, 1, 0, 0], - [1, 1, 1, 1, 1], - [1, 1, 0, 1, 1], - [1, 1, 1, 1, 1]], - - [[1, 1, 1, 0, 0], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1]] - - ]) - - nyx = Nyxus(["*ALL_INTENSITY*"]) - - arrow_file = nyx.featurize(intens, seg, output_type="arrowipc", output_path="some_path") - - print(arrow_file) - -``` - -The output is: - -```bash - NyxusFeatures.arrow -``` - -This functionality is also available in the through the command line using the flag `--outputType`. If this flag is set to `--outputType=arrowipc` then the results will be written to an Arrow IPC file in the output directory and `--outputType=parquet` will write to a Parquet file. - -## Available 2D features -The feature extraction plugin extracts morphology and intensity based features from pairs of intensity/binary mask images and produces a csv file output. The input image should be in tiled [OME TIFF format](https://docs.openmicroscopy.org/ome-model/6.2.0/ome-tiff/specification.html). The plugin extracts the following features: - -Nyxus provides a set of pixel intensity, morphology, texture, intensity distribution features, digital filter based features and image moments - ------------------- -| Nyxus feature code | Description | -|--------|-------| -| INTEGRATED_INTENSITY | Integrated intensity of the region of interest (ROI) | -| MEAN, MAX, MEDIAN, STANDARD_DEVIATION, MODE | Mean/max/median/stddev/mode intensity value of the ROI | -| SKEWNESS, KURTOSIS, HYPERSKEWNESS, HYPERFLATNESS | higher standardized moments | -| MEAN_ABSOLUTE_DEVIATION | Mean absolute deviation | -| ENERGY | ROI energy | -| ROOT_MEAN_SQUARED | Root of mean squared deviation | -| ENTROPY | ROI entropy - a measure of the amount of information in the ROI | -| UNIFORMITY | Uniformity - measures how uniform the distribution of ROI intensities is | -| UNIFORMITY_PIU | Percent image uniformity, another measure of intensity distribution uniformity | -| P01, P10, P25, P75, P90, P99 | 1%, 10%, 25%, 75%, 90%, and 99% percentiles of intensity distribution | -| INTERQUARTILE_RANGE | Distribution's interquartile range | -| ROBUST_MEAN_ABSOLUTE_DEVIATION | Robust mean absolute deviation | -| MASS_DISPLACEMENT | ROI mass displacement | -| AREA_PIXELS_COUNT | ROI area in the number of pixels | -| COMPACTNESS | Mean squared distance of the object’s pixels from the centroid divided by the area | -| BBOX_YMIN | Y-position and size of the smallest axis-aligned box containing the ROI | -| BBOX_XMIN | X-position and size of the smallest axis-aligned box containing the ROI | -| BBOX_HEIGHT | Height of the smallest axis-aligned box containing the ROI | -| BBOX_WIDTH | Width of the smallest axis-aligned box containing the ROI | -| MAJOR/MINOR_AXIS_LENGTH, ECCENTRICITY, ORIENTATION, ROUNDNESS | Inertia ellipse features | -| NUM_NEIGHBORS, PERCENT_TOUCHING | The number of neighbors bordering the ROI's perimeter and related neighbor methods | -| EXTENT | Proportion of the pixels in the bounding box that are also in the region | -| CONVEX_HULL_AREA | Area of ROI's convex hull | -| SOLIDITY | Ratio of pixels in the ROI common with its convex hull image | -| PERIMETER | Number of pixels in ROI's contour | -| EQUIVALENT_DIAMETER | Diameter of the circle having circumference equal to the ROI's perimeter | -| EDGE_MEAN/MAX/MIN/STDDEV_INTENSITY | Intensity statistics of ROI's contour pixels | -| CIRCULARITY | Represents how similar a shape is to circle. Clculated based on ROI's area and its convex perimeter | -| EROSIONS_2_VANISH | Number of erosion operations for a ROI to vanish in its axis aligned bounding box | -| EROSIONS_2_VANISH_COMPLEMENT | Number of erosion operations for a ROI to vanish in its convex hull | -| FRACT_DIM_BOXCOUNT, FRACT_DIM_PERIMETER | Fractal dimension features | -| GLCM | Grey level co-occurrence Matrix features | -| GLRLM | Grey level run-length matrix based features | -| GLDZM | Grey level distance zone matrix based features | -| GLSZM | Grey level size zone matrix based features | -| GLDM | Grey level dependency matrix based features | -| NGTDM | Neighbouring grey tone difference matrix features | -| ZERNIKE2D, FRAC_AT_D, RADIAL_CV, MEAN_FRAC | Radial distribution features | -| GABOR | A set of Gabor filters of varying frequencies and orientations | - -For the complete list of features see [Nyxus provided features](docs/source/featurelist.rst) - -## 2D feature groups - -Apart from defining your feature set by explicitly specifying comma-separated feature code, Nyxus lets a user specify popular feature groups. Supported feature groups are: - ------------------------------------- -| Group code | Belonging features | -|--------------------|-------------| -| \*all_intensity\* | integrated_intensity, mean, median, min, max, range, standard_deviation, standard_error, uniformity, skewness, kurtosis, hyperskewness, hyperflatness, mean_absolute_deviation, energy, root_mean_squared, entropy, mode, uniformity, p01, p10, p25, p75, p90, p99, interquartile_range, robust_mean_absolute_deviation, mass_displacement -| \*all_morphology\* | area_pixels_count, area_um2, centroid_x, centroid_y, weighted_centroid_y, weighted_centroid_x, compactness, bbox_ymin, bbox_xmin, bbox_height, bbox_width, major_axis_length, minor_axis_length, eccentricity, orientation, num_neighbors, extent, aspect_ratio, equivalent_diameter, convex_hull_area, solidity, perimeter, edge_mean_intensity, edge_stddev_intensity, edge_max_intensity, edge_min_intensity, circularity -| \*basic_morphology\* | area_pixels_count, area_um2, centroid_x, centroid_y, bbox_ymin, bbox_xmin, bbox_height, bbox_width -| \*geomoms\* | shape and intensity geometric moments, equivalent to \*igeomoms\* and \*sgeomoms\* combined -| \*igeomoms\* | intensity raw moments IMOM_RM_pq, central moments IMOM_CM_pq, normalized raw moments IMOM_NRM_pq, normalized central moments IMOM_NCM_pq, Hu invariants IMOM_HUk, weighted raw moments IMOM_WRM_pq, weighted central moments IMOM_WCM_pq, weighted normalized central moments IMOM_WNCM_pq, weighted Hu invariants IMOM_WHUk -| \*sgeomoms\* | shape raw moments SPAT_MOMENT_pq, central moments CENTRAL_MOMENT_pq, normalized raw moments NORM_SPAT_MOMENT_pq, normalized central moments NORM_CENTRAL_MOMENT_pq, Hu invariants HU_Mk, weighted raw moments WEIGHTED_SPAT_MOMENT_pq, weighted central moments WEIGHTED_CENTRAL_MOMENT_pq, weighted normalized central moments WT_NORM_CTR_MOM_pq, weighted Hu invariants WEIGHTED_HU_Mk -| \*all_glcm\* | glcm_asm, glcm_acor, glcm_cluprom, glcm_clushade, glcm_clutend, glcm_contrast, glcm_correlation, glcm_difave, glcm_difentro, glcm_difvar, glcm_dis, glcm_energy, glcm_entropy, glcm_hom1, glcm_hom2, glcm_id, glcm_idn, glcm_idm, glcm_idmn, glcm_infomeas1, glcm_infomeas2, glcm_iv, glcm_jave, glcm_je, glcm_jmax, glcm_jvar, glcm_sumaverage, glcm_sumentropy, glcm_sumvariance, glcm_variance -| \*all_glrlm\* | glrlm_sre, glrlm_lre, glrlm_gln, glrlm_glnn, glrlm_rln, glrlm_rlnn, glrlm_rp, glrlm_glv, glrlm_rv, glrlm_re, glrlm_lglre, glrlm_hglre, glrlm_srlgle, glrlm_srhgle, glrlm_lrlgle, glrlm_lrhgle -| \*all_glszm\* | glszm_sae, glszm_lae, glszm_gln, glszm_glnn, glszm_szn, glszm_sznn, glszm_zp, glszm_glv, glszm_zv, glszm_ze, glszm_lglze, glszm_hglze, glszm_salgle, glszm_sahgle, glszm_lalgle, glszm_lahgle -| \*all_gldm\* | gldm_sde, gldm_lde, gldm_gln, gldm_dn, gldm_dnn, gldm_glv, gldm_dv, gldm_de, gldm_lgle, gldm_hgle, gldm_sdlgle, gldm_sdhgle, gldm_ldlgle, gldm_ldhgle -| \*all_ngtdm\* | ngtdm_coarseness, ngtdm_contrast, ngtdm_busyness, ngtdm_complexity, ngtdm_strength -| \*wholeslide\* | Only features relevant to the whole-slides whose single ROIs match the images themselves, except shape features meaningless in the whole-slide use case and certain time-consuming texture features (GLDZM features). -| \*all\* | All the features - -## Available 3D features ------------------- -| Nyxus feature code | Description | -|--------|-------| -| Intensity | | -| 3COV | Coefficient of variation| -| 3COVERED_IMAGE_INTENSITY_RANGE | Covered image intensity range | -| 3ENERGY | Energy | -| 3ENTROPY | Entropy | -| 3EXCESS_KURTOSIS | Excess kurtosis | -| 3HYPERFLATNESS | Hyperflatness | -| 3HYPERSKEWNESS | Hyperskewness | -| 3INTEGRATED_INTENSITY | Integrated intensity | -| 3INTERQUARTILE_RANGE | Interquartile range | -| 3KURTOSIS | Kurtosis | -| 3MAX | Max | -| 3MEAN | Mean | -| 3MEAN_ABSOLUTE_DEVIATION | Mean absolute deviation | -| 3MEDIAN | Median | -| 3MEDIAN_ABSOLUTE_DEVIATION | Median absolute deviation | -| 3MIN | Min | -| 3MODE | Mode | -| 3P01, 3P10, 3P25, 3P75, 3P90, 3P99 | 1%, 10%, 25%, 75%, 90%, 99% percentiles | -| 3QCOD | Quantile coefficient of dispersion | -| 3RANGE | Range | -| 3ROBUST_MEAN | Robust mean | -| 3ROBUST_MEAN_ABSOLUTE_DEVIATION | Robust mean absolute deviation | -| 3ROOT_MEAN_SQUARED | Root mean squared | -| 3SKEWNESS | Skewness | -| 3STANDARD_DEVIATION | Standard deviation | -| 3STANDARD_DEVIATION_BIASED | Biased standard deviation | -| 3STANDARD_ERROR | Standard error | -| 3UNIFORMITY | Uniformity | -| 3UNIFORMITY_PIU | Uniformity in PIU units | -| 3VARIANCE | Variance | -| 3VARIANCE_BIASED | Biased variance | -| shape | | -| 3AREA | Surface area | -| 3AREA_2_VOLUME | Surface area to volume ratio | -| 3COMPACTNESS1 | Compactness1 | -| 3COMPACTNESS2 | Compactness2 | -| 3MESH_VOLUME | Mesh volume | -| 3SPHERICAL_DISPROPORTION | Spherical disproportion | -| 3SPHERICITY | Sphericity | -| 3VOLUME_CONVEXHULL | Volume of the convex hull | -| 3VOXEL_VOLUME | Volume as total of volumes of voxels | -| 3MAJOR_AXIS_LEN | Major axis length | -| 3MINOR_AXIS_LEN | Minor axis length | -| 3LEAST_AXIS_LEN | Least axis length | -| 3ELONGATION | Elongation | -| 3FLATNESS | Flatness | -| texture | | -| 3GLCM_ACOR | Autocorrelation (grey level co-occurrence matrix) | -| 3GLCM_ASM | Angular second moment (grey level co-occurrence matrix) | -| 3GLCM_CLUPROM | Cluster prominence (grey level co-occurrence matrix) | -| 3GLCM_CLUSHADE | Cluster shade (grey level co-occurrence matrix) | -| 3GLCM_CLUTEND | Cluster tendency (grey level co-occurrence matrix) | -| 3GLCM_CONTRAST | Contrast (grey level co-occurrence matrix) | -| 3GLCM_CORRELATION | Correlation (grey level co-occurrence matrix) | -| 3GLCM_DIFAVE | Defference average (grey level co-occurrence matrix) | -| 3GLCM_DIFENTRO | Difference entropy (grey level co-occurrence matrix) | -| 3GLCM_DIFVAR | Difference variance (grey level co-occurrence matrix) | -| 3GLCM_DIS | Dissimilarity (grey level co-occurrence matrix) | -| 3GLCM_ENERGY | Energy (grey level co-occurrence matrix) | -| 3GLCM_ENTROPY | Entropy (grey level co-occurrence matrix) | -| 3GLCM_HOM1 | Homogeneity-1 (grey level co-occurrence matrix) | -| 3GLCM_HOM2 | Homogeneity-2 (grey level co-occurrence matrix) | -| 3GLCM_ID | Inverse difference (grey level co-occurrence matrix) | -| 3GLCM_IDN | Normalized inverse difference (grey level co-occurrence matrix) | -| 3GLCM_IDM | Inverse difference moment (grey level co-occurrence matrix) | -| 3GLCM_IDMN | Normalized inverse difference moment (grey level co-occurrence matrix) | -| 3GLCM_INFOMEAS1 | 1st information measure of correlation (grey level co-occurrence matrix) | -| 3GLCM_INFOMEAS2 | 2nd information measure of correlation (grey level co-occurrence matrix) | -| 3GLCM_IV | Inverse variance (grey level co-occurrence matrix) | -| 3GLCM_JAVE | Joint average (grey level co-occurrence matrix) | -| 3GLCM_JE | Joint entropy (grey level co-occurrence matrix) | -| 3GLCM_JMAX | Joint maximum aka "max probability" (grey level co-occurrence matrix) | -| 3GLCM_JVAR | Joint variance aka "sum of squares" (grey level co-occurrence matrix) | -| 3GLCM_SUMAVERAGE | Sum average (grey level co-occurrence matrix) | -| 3GLCM_SUMENTROPY | Sum entropy (grey level co-occurrence matrix) | -| 3GLCM_SUMVARIANCE | Sum variance (grey level co-occurrence matrix) | -| 3GLCM_VARIANCE | Variance (grey level co-occurrence matrix) | -| 3GLCM_ASM_AVE | directional average of 3GLCM_ASM | -| 3GLCM_ACOR_AVE | directional average of 3GLCM_ACOR | -| 3GLCM_CLUPROM_AVE | directional average of 3GLCM_CLUPROM | -| 3GLCM_CLUSHADE_AVE | directional average of 3GLCM_CLUSHADE | -| 3GLCM_CLUTEND_AVE | directional average of 3GLCM_CLUTEND | -| 3GLCM_CONTRAST_AVE | directional average of 3GLCM_CONTRAST | -| 3GLCM_CORRELATION_AVE | directional average of 3GLCM_CORRELATION | -| 3GLCM_DIFAVE_AVE | directional average of 3GLCM_DIFAVE | -| 3GLCM_DIFENTRO_AVE | directional average of 3GLCM_DIFENTRO | -| 3GLCM_DIFVAR_AVE | directional average of 3GLCM_DIFVAR | -| 3GLCM_DIS_AVE | directional average of 3GLCM_DIS | -| 3GLCM_ENERGY_AVE | directional average of 3GLCM_ENERGY | -| 3GLCM_ENTROPY_AVE | directional average of 3GLCM_ENTROPY | -| 3GLCM_HOM1_AVE | directional average of 3GLCM_HOM1 | -| 3GLCM_ID_AVE | directional average of 3GLCM_ID | -| 3GLCM_IDN_AVE | directional average of 3GLCM_IDN | -| 3GLCM_IDM_AVE | directional average of 3GLCM_IDM | -| 3GLCM_IDMN_AVE | directional average of 3GLCM_IDMN | -| 3GLCM_IV_AVE | directional average of 3GLCM_IV | -| 3GLCM_JAVE_AVE | directional average of 3GLCM_JAVE | -| 3GLCM_JE_AVE | directional average of 3GLCM_JE | -| 3GLCM_INFOMEAS1_AVE | directional average of 3GLCM_INFOMEAS1 | -| 3GLCM_INFOMEAS2_AVE | directional average of 3GLCM_INFOMEAS2 | -| 3GLCM_VARIANCE_AVE | directional average of 3GLCM_VARIANCE | -| 3GLCM_JMAX_AVE | directional average of 3GLCM_JMAX | -| 3GLCM_JVAR_AVE | directional average of 3GLCM_JVAR | -| 3GLCM_SUMAVERAGE_AVE | directional average of 3GLCM_SUMAVERAGE | -| 3GLCM_SUMENTROPY_AVE | directional average of 3GLCM_SUMENTROPY | -| 3GLCM_SUMVARIANCE_AVE | directional average of 3GLCM_SUMVARIANCE | -| 3GLDM_SDE | Small dependence emphasis (grey level dependence matrix) | -| 3GLDM_LDE | Large dependence emphasis (grey level dependence matrix) | -| 3GLDM_GLN | Gray level non-uniformity (grey level dependence matrix) | -| 3GLDM_DN | Dependence non-uniformity (grey level dependence matrix) | -| 3GLDM_DNN | Normalized dependence non-uniformity (grey level dependence matrix) | -| 3GLDM_GLV | Gray level variance (grey level dependence matrix) | -| 3GLDM_DV | Dependence variance (grey level dependence matrix) | -| 3GLDM_DE | Dependence entropy (grey level dependence matrix) | -| 3GLDM_LGLE | Low gray level emphasis (grey level dependence matrix) | -| 3GLDM_HGLE | High gray level emphasis (grey level dependence matrix) | -| 3GLDM_SDLGLE | Small dependence low gray level emphasis (grey level dependence matrix) | -| 3GLDM_SDHGLE | Small dependence high gray level emphasis (grey level dependence matrix) | -| 3GLDM_LDLGLE | Large dependence low gray level emphasis (grey level dependence matrix) | -| 3GLDM_LDHGLE | Large dependence high gray level emphasis (grey level dependence matrix) | -| 3GLDZM_SDE | Small distance emphasis (grey level distance zone matrix) | -| 3GLDZM_LDE | Large distance emphasis (grey level distance zone matrix) | -| 3GLDZM_LGLZE | Low grey level zone emphasis (grey level distance zone matrix) | -| 3GLDZM_HGLZE | High grey level zone emphasis (grey level distance zone matrix) | -| 3GLDZM_SDLGLE | Small distance low grey level emphasis (grey level distance zone matrix) | -| 3GLDZM_SDHGLE | Small distance high grey level emphasis (grey level distance zone matrix) | -| 3GLDZM_LDLGLE | Large distance low grey level emphasis (grey level distance zone matrix) | -| 3GLDZM_LDHGLE | Large distance high grey level emphasis (grey level distance zone matrix) | -| 3GLDZM_GLNU | Grey level non uniformity (grey level distance zone matrix) | -| 3GLDZM_GLNUN | Normalized grey level non uniformity (grey level distance zone matrix) | -| 3GLDZM_ZDNU | Zone distance non uniformity (grey level distance zone matrix) | -| 3GLDZM_ZDNUN | Normalized zone distance non uniformity (grey level distance zone matrix) | -| 3GLDZM_ZP | Zone percentage (grey level distance zone matrix) | -| 3GLDZM_GLM | Grey level mean (grey level distance zone matrix) | -| 3GLDZM_GLV | Grey level variance (grey level distance zone matrix) | -| 3GLDZM_ZDM | Zone distance mean (grey level distance zone matrix) | -| 3GLDZM_ZDV | Zone distance variance (grey level distance zone matrix) | -| 3GLDZM_ZDE | Zone distance entropy (grey level distance zone matrix) | -| 3GLRLM_SRE | Short run emphasis (grey level run length matrix) | -| 3GLRLM_LRE | Long run emphasis (grey level run length matrix) | -| 3GLRLM_GLN | Gray level non-uniformity (grey level run length matrix) | -| 3GLRLM_GLNN | Normalized gray level non-uniformity (grey level run length matrix) | -| 3GLRLM_RLN | Run length non-uniformity (grey level run length matrix) | -| 3GLRLM_RLNN | Normalized run length non-uniformity (grey level run length matrix) | -| 3GLRLM_RP | Run percentage (grey level run length matrix) | -| 3GLRLM_GLV | Gray level variance (grey level run length matrix) | -| 3GLRLM_RV | Run variance (grey level run length matrix) | -| 3GLRLM_RE | Run entropy (grey level run length matrix) | -| 3GLRLM_LGLRE | Low gray level run emphasis (grey level run length matrix) | -| 3GLRLM_HGLRE | High gray level run emphasis (grey level run length matrix) | -| 3GLRLM_SRLGLE | Short run low gray level emphasis (grey level run length matrix) | -| 3GLRLM_SRHGLE | Short run high gray level emphasis (grey level run length matrix) | -| 3GLRLM_LRLGLE | Long run low gray level emphasis (grey level run length matrix) | -| 3GLRLM_LRHGLE | Long run high gray level emphasis (grey level run length matrix) | -| 3GLRLM_SRE_AVE | directional average of 3GLRLM_SRE | -| 3GLRLM_LRE_AVE | directional average of 3GLRLM_LRE | -| 3GLRLM_GLN_AVE | directional average of 3GLRLM_GLN | -| 3GLRLM_GLNN_AVE | directional average of 3GLRLM_GLNN | -| 3GLRLM_RLN_AVE | directional average of 3GLRLM_RLN | -| 3GLRLM_RLNN_AVE | directional average of 3GLRLM_RLNN | -| 3GLRLM_RP_AVE | directional average of 3GLRLM_RP | -| 3GLRLM_GLV_AVE | directional average of 3GLRLM_GLV | -| 3GLRLM_RV_AVE | directional average of 3GLRLM_RV | -| 3GLRLM_RE_AVE | directional average of 3GLRLM_RE | -| 3GLRLM_LGLRE_AVE | directional average of 3GLRLM_LGLRE | -| 3GLRLM_HGLRE_AVE | directional average of 3GLRLM_HGLRE | -| 3GLRLM_SRLGLE_AVE | directional average of 3GLRLM_SRLGLE | -| 3GLRLM_SRHGLE_AVE | directional average of 3GLRLM_SRHGLE | -| 3GLRLM_LRLGLE_AVE | directional average of 3GLRLM_LRLGLE | -| 3GLRLM_LRHGLE_AVE | directional average of 3GLRLM_LRHGLE | -| 3GLSZM_SAE | Small area emphasis (grey level size zone matrix) | -| 3GLSZM_LAE | Large area emphasis (grey level size zone matrix) | -| 3GLSZM_GLN | Gray level non-uniformity (grey level size zone matrix) | -| 3GLSZM_GLNN | Normalized gray level non-uniformity (grey level size zone matrix) | -| 3GLSZM_SZN | Size-zone non-uniformity (grey level size zone matrix) | -| 3GLSZM_SZNN | Normalized size-zone non-uniformity (grey level size zone matrix) | -| 3GLSZM_ZP | Zone percentage (grey level size zone matrix) | -| 3GLSZM_GLV | Gray level variance (grey level size zone matrix) | -| 3GLSZM_ZV | Zone variance (grey level size zone matrix) | -| 3GLSZM_ZE | Zone entropy (grey level size zone matrix) | -| 3GLSZM_LGLZE | Low gray level zone emphasis (grey level size zone matrix) | -| 3GLSZM_HGLZE | High gray level zone emphasis (grey level size zone matrix) | -| 3GLSZM_SALGLE | Small area low gray level emphasis (grey level size zone matrix) | -| 3GLSZM_SAHGLE | Small area high gray level emphasis (grey level size zone matrix) | -| 3GLSZM_LALGLE | Large area low gray level emphasis (grey level size zone matrix) | -| 3GLSZM_LAHGLE | Large area high gray level emphasis (grey level size zone matrix) | -| 3NGLDM_LDE | Low dependence emphasis (neighbouring grey level dependence matrix) | -| 3NGLDM_HDE | High dependence emphasis (neighbouring grey level dependence matrix) | -| 3NGLDM_LGLCE | Low grey level count emphasis (neighbouring grey level dependence matrix) | -| 3NGLDM_HGLCE | High grey level count emphasis (neighbouring grey level dependence matrix) | -| 3NGLDM_LDLGLE | Low dependence low grey level emphasis (neighbouring grey level dependence matrix) | -| 3NGLDM_LDHGLE | Low dependence high grey level emphasis (neighbouring grey level dependence matrix) | -| 3NGLDM_HDLGLE | High dependence low grey level emphasis (neighbouring grey level dependence matrix) | -| 3NGLDM_HDHGLE | High dependence high grey level emphasis (neighbouring grey level dependence matrix) | -| 3NGLDM_GLNU | Grey level non-uniformity (neighbouring grey level dependence matrix) | -| 3NGLDM_GLNUN | Grey level non-uniformity normalised (neighbouring grey level dependence matrix) | -| 3NGLDM_DCNU | Dependence count non-uniformity (neighbouring grey level dependence matrix) | -| 3NGLDM_DCNUN | Dependence count non-uniformity normalised (neighbouring grey level dependence matrix) | -| 3NGLDM_DCP | Dependence count percentage (neighbouring grey level dependence matrix) | -| 3NGLDM_GLM | Grey level mean (neighbouring grey level dependence matrix) | -| 3NGLDM_GLV | Grey level variance (neighbouring grey level dependence matrix) | -| 3NGLDM_DCM | Dependence count mean (neighbouring grey level dependence matrix) | -| 3NGLDM_DCV | Dependence count variance (neighbouring grey level dependence matrix) | -| 3NGLDM_DCENT | Dependence count entropy (neighbouring grey level dependence matrix) | -| 3NGLDM_DCENE | Dependence count energy (neighbouring grey level dependence matrix) | -| 3NGTDM_COARSENESS | Coarseness (neighbouring grey tone difference matrix) | -| 3NGTDM_CONTRAST | Contrast (neighbouring grey tone difference matrix) | -| 3NGTDM_BUSYNESS | Busyness (neighbouring grey tone difference matrix) | -| 3NGTDM_COMPLEXITY | Complexity (neighbouring grey tone difference matrix) | -| 3NGTDM_STRENGTH | Strength (neighbouring grey tone difference matrix) | - - -## 3D feature groups ------------------------------------- -| Group code | Belonging features | -|--------------------|-------------| -| \*3D_ALL\* | All the 3D features | -| \*3D_ALL_INTENSITY\* | 3COV, 3COVERED_IMAGE_INTENSITY_RANGE, 3ENERGY, 3ENTROPY, 3EXCESS_KURTOSIS, 3HYPERFLATNESS, 3HYPERSKEWNESS, 3INTEGRATED_INTENSITY, 3INTERQUARTILE_RANGE, 3KURTOSIS, 3MAX, 3MEAN, 3MEAN_ABSOLUTE_DEVIATION, 3MEDIAN, 3MEDIAN_ABSOLUTE_DEVIATION, 3MIN, 3MODE, 3P01, 3P10, 3P25, 3P75, 3P90, 3P99, 3QCOD, 3RANGE, 3ROBUST_MEAN, 3ROBUST_MEAN_ABSOLUTE_DEVIATION, 3ROOT_MEAN_SQUARED, 3SKEWNESS, 3STANDARD_DEVIATION, 3STANDARD_DEVIATION_BIASED, 3STANDARD_ERROR, 3UNIFORMITY, 3UNIFORMITY_PIU, 3VARIANCE, and 3VARIANCE_BIASED | -| \*3D_ALL_MORPHOLOGY\* | 3AREA, 3AREA_2_VOLUME, 3COMPACTNESS1 and -2, 3MESH_VOLUME, 3SPHERICAL_DISPROPORTION, 3SPHERICITY, 3VOLUME_CONVEXHULL, 3VOXEL_VOLUME, 3MAJOR_AXIS_LEN, 3MINOR_AXIS_LEN, 3LEAST_AXIS_LEN, 3ELONGATION, and 3FLATNESS | -| \*3D_ALL_TEXTURE\* | All the 3GLCM_... 3GLDM_... 3GLDZM_... 3GLSZM_... 3GLRLM_... 3NGLDM_... and 3NGTDM_... features | -| \*3D_GLCM\* | All the 3GLCM_... features | -| \*3D_GLDM\* | All the 3GLDM_... features | -| \*3D_GLDZM\* | All the 3GLDZM_... features | -| \*3D_GLSZM\* | All the 3GLSZM_... features | -| \*3D_GLRLM\* | All the 3GLRLM_... features | -| \*3D_NGLDM\* | All the 3NGLDM_... features | -| \*3D_NGTDM\* | All the 3NGTDM_... features | - - -## Command line usage - -Assuming you [built the Nyxus binary](#building-from-source) as outlined below, the following parameters are available for the command line interface: - -|
Parameter
| Description | Type | -|------|-------------|------| ---outputType | Output type for feature values (speratecsv, singlecsv, arrow, parquet). Default value: '--outputType=separatecsv' | string constant ---features | String constant or comma-seperated list of constants requesting a group of features or particular feature. Default value: '--features=\*ALL\*' | string ---filePattern | Regular expression to match image files in directories specified by parameters '--intDir' and '--segDir'. To match all the files, use '--filePattern=.\*' | string ---intDir | Directory of intensity image collection | path ---outDir | Output directory | path ---segDir | Directory of labeled image collection | path ---useGpu | ${\color{red}\textsf{(optional)}}$ Calculate compute-expensive features on an NVIDIA GPU device specified by parameter --gpuDeviceID. Default: '--useGpu=false'. Example: --useGpu=true | boolean ---gpuDeviceID | ${\color{red}\textsf{(optional)}}$ ID of a GPU device to be used when '--useGpu=true'. Default: '--gpuDeviceID=0'. Example 1 (single GPU device): '--useGpu=true --gpuDeviceID=2' to strictly use device 2. Example 2 (multiple GPU devices, usually in SLURM scenarios): '--useGpu=true --gpuDeviceID=0,1,3' to use the GPU device having maximum free RAM of devices 0, 1, and 3. | integer or list of integers ---coarseGrayDepth | ${\color{red}\textsf{(optional)}}$ Custom number of greyscale level bins used in texture features. Default: '--coarseGrayDepth=256' | integer ---glcmAngles | ${\color{red}\textsf{(optional)}}$ Enabled direction angles of the GLCM feature. Superset of values: 0, 45, 90, and 135. Default: '--glcmAngles=0,45,90,135' | list of integers ---intSegMapDir | ${\color{red}\textsf{(optional)}}$ Data collection of the ad-hoc intensity-to-mask file mapping. Must be used in combination with parameter '--intSegMapFile' | path ---intSegMapFile | ${\color{red}\textsf{(optional)}}$ Name of the text file containing an ad-hoc intensity-to-mask file mapping. The files are assumed to reside in corresponding intensity and label collections. Must be used in combination with parameter '--intSegMapDir' | string ---pixelDistance | ${\color{red}\textsf{(optional)}}$ Number of pixels to treat ROIs within specified distance as neighbors. Default value: '--pixelDistance=5' | integer ---pixelsPerCentimeter | ${\color{red}\textsf{(optional)}}$ Number of pixels in centimeter used by unit length-related features. Default value: 0 | real ---ramLimit | ${\color{red}\textsf{(optional)}}$ Amount of memory not to exceed by Nyxus, in megabytes. Default value: 50\% of available memory. Example: '--ramLimit=2000' to use 2,000 megabytes | integer ---reduceThreads | ${\color{red}\textsf{(optional)}}$ Number of CPU threads used on the feature calculation step. Default: '--reduceThreads=1' | integer ---skiproi | ${\color{red}\textsf{(optional)}}$ Skip ROIs having specified labels. Example: '--skiproi=image1.tif:2,3,4;image2.tif:45,56' | string ---tempDir | ${\color{red}\textsf{(optional)}}$ Directory used by temporary out-of-RAM objects. Default value: system temporary directory | path ---hsig | ${\color{red}\textsf{(optional)}}$ Channel signature Example: "--hsig=_c" to match images whose file names have channel info starting substring '_c' like in 'p0_y1_r1_c1.ome.tiff' | string ---hpar | ${\color{red}\textsf{(optional)}}$ Channel number that should be used as a provider of parent segments. Example: '--hpar=1' | integer ---hchi | ${\color{red}\textsf{(optional)}}$ Channel number that should be used as a provider of child segments. Example: '--hchi=0' | integer ---hag | ${\color{red}\textsf{(optional)}}$ Name of a method how to aggregate features of segments recognized as children of same parent segment. Valid options are 'SUM', 'MEAN', 'MIN', 'MAX', 'WMA' (weighted mean average), and 'NONE' (no aggregation, instead, same parent child segments will be laid out horizontally) | string ---fpimgdr | ${\color{red}\textsf{(optional)}}$ Desired dynamic range of voxels of a floating point TIFF image. Example: --fpimgdr=240 makes intensities be read in range 0-240. Default value: 10e4 | unsigned integer ---fpimgmin | ${\color{red}\textsf{(optional)}}$ Minimum intensity of voxels of a floating point TIFF image. Default value: 0.0 | real ---fpimgmax | ${\color{red}\textsf{(optional)}}$ Maximum intensity of voxels of a floating point TIFF image. Default value: 1.0 | real ---preserve-hu | ${\color{red}\textsf{(optional)}}$ NOT needed for CT -- Hounsfield units are preserved and reported either way, despite the name. Carries a floating-point slide on the offset-preserving map (1 grey level = 1 intensity unit, offset by the slide's floored minimum) instead of the min-max rescaling into --fpimgdr used by --fpimgmin/--fpimgmax. A DICOM or integer NIfTI slide takes it instead of the stored map, which keeps the stored integers as grey levels and carries RescaleSlope/RescaleIntercept (scl_slope/scl_inter) in the recorded inverse, so a fractional slope loses no stored level. Other integer slides take the offset map regardless of this flag. The DICOM and NIfTI rescale reaches the reported values either way, so CT Hounsfield Units survive the load either way. Whichever map a slide takes is recorded and undone on the way out, so every intensity feature is reported in the slide's own domain -- a CT is reported in Hounsfield units, negatives included. Shape and texture features are unaffected. Example: --preserve-hu=true. Default value: false | boolean ---anisox | ${\color{red}\textsf{(optional)}}$ x-anisotropy: the size of a pixel or voxel along x relative to the other axes. Only geometric features see it. A volume (3D or 2.5D) is featurized on its voxels as acquired, and the shape features scale their coordinates by the anisotropy; a 2D slide's geometric features are computed on its pixels resampled by the anisotropy. Intensity and texture features are computed on the pixels or voxels as acquired either way, so they report the same values with or without anisotropy. On a 2D slide, the intensity-weighted geometric features -- the EDGE_* statistics, WEIGHTED_CENTROID_X/Y, MASS_DISPLACEMENT, the intensity moments IMOM_*, the radial distribution (FRAC_AT_D, MEAN_FRAC, RADIAL_CV) and ZERNIKE2D -- are computed on the pixels as acquired too, each pixel at its position scaled by the anisotropy and weighted by its intensity times its area, with distances to the contour and the Zernike unit disk taken in the same units. Default value: 1.0 | real ---anisoy | ${\color{red}\textsf{(optional)}}$ y-anisotropy, as --anisox. Default value: 1.0 | real ---anisoz | ${\color{red}\textsf{(optional)}}$ z-anisotropy (for 3D datasets), as --anisox. Default value: 1.0 | real ---- - -## Examples - -Example 1: __Running Nyxus to process images of specific image channel__ - -Suppose we need to process intensity/mask images of channel 1 : -``` -./nyxus --features=*all_intensity*,*basic_morphology* --intDir=/path/to/intensity/images --segDir=/path/to/mask/images --outDir=/path/to/output --filePattern=.*_c1\.ome\.tif --outputType=singlecsv -``` -Example 2: __Running Nyxus to process specific image__ - -Suppose we need to process intensity/mask file p1_y2_r68_c1.ome.tif : -``` -./nyxus --features=*all_intensity*,*basic_morphology* --intDir=/path/to/intensity/images --segDir=/path/to/mask/images --outDir=/path/to/output --filePattern=p1_y2_r68_c1\.ome\.tif --outputType=singlecsv -``` - -Example 3: __Running Nyxus to extract only intensity and basic morphology features__ - -``` -./nyxus --features=*all_intensity*,*basic_morphology* --intDir=/path/to/intensity/images --segDir=/path/to/mask/images --outDir=/path/to/output --filePattern=.* --outputType=singlecsv -``` - -Example 4: __Skipping specified ROIs while extracting features__ - -Suppose we need to blacklist ROI labels 2 and 3 from the kurtosis feature extraction globally, in each image. The command line way to do that is using option __--skiproi__ : -```shell -./nyxus --skiproi=2,3 --features=KURTOSIS --intDir=/path/to/intensity/images --segDir=/path/to/mask/images --outDir=/path/to/output --filePattern=.* --outputType=singlecsv -``` - -As a result, the default feature extraction result produced without option --skiproi looking like - -
-          mask_image  intensity_image  label    KURTOSIS
-0    p0_y1_r1_c0.tif  p0_y1_r1_c0.tif      1   -0.134216
-1    p0_y1_r1_c0.tif  p0_y1_r1_c0.tif      2   -0.130024
-2    p0_y1_r1_c0.tif  p0_y1_r1_c0.tif      3   -1.259801
-3    p0_y1_r1_c0.tif  p0_y1_r1_c0.tif      4   -0.934786
-4    p0_y1_r1_c0.tif  p0_y1_r1_c0.tif      5   -1.072111
-..          ...             ...           ...      ...
-
- -will start looking like - -
-          mask_image  intensity_image  label    KURTOSIS
-0    p0_y1_r1_c0.tif  p0_y1_r1_c0.tif      1   -0.134216
-1    p0_y1_r1_c0.tif  p0_y1_r1_c0.tif      4   -0.934786
-2    p0_y1_r1_c0.tif  p0_y1_r1_c0.tif      5   -1.072111
-3    p0_y1_r1_c0.tif  p0_y1_r1_c0.tif      6   -0.347741
-4    p0_y1_r1_c0.tif  p0_y1_r1_c0.tif      7   -1.283468
-..          ...             ...           ...      ...
-
- - -Note the comma character separator   ,   in the blacklisted ROI label list. - -If we need to blacklist ROI labels 15 and 16 only in image image421.tif ROI label 17 in image image422.tif, we can do it via a per-file blacklist : -``` -./nyxus --skiproi=image421.tif:15,16;image421.tif:17 --features=KURTOSIS --intDir=/path/to/intensity/images --segDir=/path/to/mask/images --outDir=/path/to/output --filePattern=.* --outputType=singlecsv -``` -Note the colon character   :   between the file name and backlisted labels within this file and semicolon character separator   ;   of file blacklists. - -Example 5: __Skipping specified ROIs while extracting features (via Python API)__ - -The Nyxus Python API equivalent of global ROI blacklisting is implemented by method __blacklist_roi(*string*)__ called before a call of method __featurize...()__, for example, labels 15, 16, and 17 can be globally blacklisted as follows: -```python -from nyxus import Nyxus -nyx = Nyxus(features=["KURTOSIS"]) -nyx.blacklist_roi('15,16,17') -features = nyx.featurize_directory (intensity_dir="/path/to/intensity/images", label_dir="/path/to/mask/images", file_pattern=".*") -``` - -Similarly, per-file ROI blacklists are defined in a way similar to the command line interface: -```python -from nyxus import Nyxus -nyx = Nyxus(features=["KURTOSIS"]) -nyx.blacklist_roi('p0_y1_r1_c0.ome.tif:15,16;p0_y1_r2_c0.ome.tif:17') -features = nyx.featurize_directory (intensity_dir="/path/to/intensity/images", label_dir="/path/to/mask/images", file_pattern=".*") -``` - -See also methods __clear_roi_blacklist()__ and __roi_blacklist_get_summary()__ . - - -Example 6: __Specifying anisotropy in 3D (via command line)__ - -Applying a 1.5 x 2.0 x 2.5 anisotropy correction to all the volumes in a compressed NIFTI dataset: -``` ---anisox=1.5 --anisoy=2 --anisoz=2.5 --dim=3 --filePattern="*\.nii\.gz" --features=*3D_ALL* --resultFname=3d-features --outputType=singlecsv --intDir=/data/patient123/intensity --segDir=/data/patient123/masks --outDir=/output/patient123 -``` - -2D compatibility: - -``` ---anisox=1.5 --anisoy=2 --filePattern="*\.ome\.tiff" --features=*ALL* --resultFname=features1 --outputType=singlecsv --intDir=/data/plate123/int --segDir=/data/plate123/seg --outDir=/output/plate123 -``` - -Instead of stating the anisotropy explicitly, it can be derived from the physical voxel spacing that OME-TIFF and OME-Zarr volumes declare in their own metadata: - -``` ---use-physical-spacing=true --dim=3 --filePattern=".*\.ome\.zarr" --features=*3D_ALL* --resultFname=3d-features --outputType=singlecsv --intDir=/data/patient123/intensity --segDir=/data/patient123/masks --outDir=/output/patient123 -``` - -## Nested ROIs - -Hierarchical ROI analysis in a form of finding ROIs nested geometrically as nested AABBs and aggregating features of child ROIs within corresponding parent is available as an optional extra step after the feature extraction of the whole image set is finished. To enable this step, all the command line options '--hsig', '--hpar', '--hchi', and '--hag' need to have non-blank valid values. - -Valid aggregation options are SUM, MEAN, MIN, MAX, WMA (weighted mean average), or NONE (no aggregation). - -Example 6: __Processing an image set with nested ROI postprocessing__ - -``` -nyxus --features=*ALL_intensity* --intDir=/path/to/intensity/images --segDir=/path/to/mask/images --outDir=/path/to/output/directory --filePattern=.* --outputType=separatecsv --reduceThreads=4 --hsig=_c --hpar=1 --hchi=0 --hag=WMA -``` - -As a result, 2 additional CSV files will be produced for each mask image whose channel number matches the value of option '--hpar': file - -``` -_nested_features.csv -``` - -where features of the detected child ROIs are laid next to their parent ROIs on same lines and auxiliary file - -``` -_nested_relations.csv -``` - -serving as a relational table of parent and child ROI labels within parent ROI channel image ``````. - -### Nested features Python API - -The nested features functionality can also be utilized in Python using the `Nested` class in `nyxus`. The `Nested` class -contains two methods, `find_relations` and `featurize`. - -The `find_relations` method takes in a path to the label files, along with a child -filepattern to identify the files in the child channel and a parent filepattern to match the files in the parent channel. The `find_relation` method -returns a Pandas DataFrame containing a mapping between parent ROIs and the respective child ROIs. - -The `featurize` method takes in the parent-child mapping along with the features of the ROIs in the child channel. If a list of aggregate functions -is provided to the constructor, this method will return a pivoted DataFrame where the rows are the ROI labels and the columns are grouped by the features. - - -__Example 7__: Using aggregate functions - -``` python - -from nyxus import Nyxus, Nested import numpy as np +import tifffile +from nyxus import Nyxus3D -int_path = 'path/to/intensity' -seg_path = 'path/to/segmentation' - -nyx = Nyxus(['GABOR']) +volume = np.random.default_rng(0).integers(1, 100, size=(8, 32, 32), dtype=np.uint16) +labels = np.zeros_like(volume) +labels[2:6, 8:24, 8:24] = 1 +tifffile.imwrite("volume.ome.tif", volume, ome=True, metadata={"axes": "ZYX"}) +tifffile.imwrite("labels.ome.tif", labels, ome=True, metadata={"axes": "ZYX"}) -child_features = nyx.featurize(int_path, seg_path, file_pattern='p[0-9]_y[0-9]_r[0-9]_c0\.ome\.tif') - -nest = Nested(['sum', 'mean', 'min', ('nanmean', lambda x: np.nanmean(x))]) - -df = nest.find_relations(seg_path, 'p{r}_y{c}_r{z}_c1.ome.tif', 'p{r}_y{c}_r{z}_c0.ome.tif') - -df2 = nest.featurize(df, features) +nyx = Nyxus3D(["*3D_ALL_INTENSITY*"]) +features = nyx.featurize_files(["./volume.ome.tif"], ["./labels.ome.tif"], False) +print(features[["ROI_label", "3MEAN", "3MAX"]]) ``` -The parent-child map is +### Command line -``` bash - Image Parent_Label Child_Label - 0 /path/to/image 72 65 - 1 /path/to/image 71 66 - 2 /path/to/image 70 64 - 3 /path/to/image 68 61 - 4 /path/to/image 67 65 +The Python packages do not include the command-line program; run it from the Docker image or +build it from source (see [Installation](https://nyxus.readthedocs.io/en/latest/installation.html)). +The output directory must already exist. ``` - -and the aggregated DataFrame is - -``` bash - GABOR_0 GABOR_1 GABOR_2 ... - sum mean min nanmean sum mean min nanmean sum mean ... - label ... - 1 24.010227 0.666951 0.000000 0.666951 19.096262 0.530452 0.001645 0.530452 17.037345 0.473260 ... - 2 13.374170 0.445806 0.087339 0.445806 7.279187 0.242640 0.075000 0.242640 6.390529 0.213018 ... - 3 5.941783 0.198059 0.000000 0.198059 3.364149 0.112138 0.000000 0.112138 2.426409 0.080880 ... - 4 13.428773 0.559532 0.000000 0.559532 12.021938 0.500914 0.008772 0.500914 9.938915 0.414121 ... - 5 6.535722 0.181548 0.000000 0.181548 1.833463 0.050930 0.000000 0.050930 2.083023 0.057862 ... - +nyxus --features="*ALL_INTENSITY*,*ALL_MORPHOLOGY*" --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv ``` -Example 8: __Without aggregate functions__ - -``` python - -from nyxus import Nyxus, Nested -import numpy as np - -int_path = 'path/to/intensity' -seg_path = 'path/to/segmentation' - -nyx = Nyxus(['GABOR']) - -child_features = nyx.featurize(int_path, seg_path, file_pattern='p[0-9]_y[0-9]_r[0-9]_c0\.ome\.tif') - -nest = Nested() - -df = nest.find_relations(seg_path, 'p{r}_y{c}_r{z}_c1.ome.tif', 'p{r}_y{c}_r{z}_c0.ome.tif') - -df2 = nest.featurize(df, features) -``` - -the parent-child map remains the same but the `featurize` result becomes - -``` bash - GABOR_0 ... - Child_Label 1 2 3 4 5 6 7 8 9 10 ... - label ... - 1 0.666951 NaN NaN NaN NaN NaN NaN NaN NaN NaN ... - 2 NaN 0.445806 NaN NaN NaN NaN NaN NaN NaN NaN ... - 3 NaN NaN 0.198059 NaN NaN NaN NaN NaN NaN NaN ... - 4 NaN NaN NaN 0.559532 NaN NaN NaN NaN NaN NaN ... - 5 NaN NaN NaN NaN 0.181548 NaN NaN NaN NaN NaN ... - -``` - -## Building from source -Nyxus uses `CMake` as the build system and needs a `C++17` supported compiler to build from the source. - -### __Dependencies__ -To build Nyxus from source, several build dependencies are needed to be satisfied. These dependencies arise from Nyxus's need to read and write various data format. The dependencies are listed below. -* Tiff Support: libtiff, libdeflate, zlib -* Zarr Support: z5, xtensor, nlohman_json, blosc, zlib -* Dicom Support: dcmtk, fmjpeg, zlib -* Apache Arrow Support: arrow-cpp, pyarrow -* Python Interface: pybind11 - -These packages also have underlying dependencies and at times, these dependency resolution may appear challenging. We prefer `conda` to help with resolving these dependencies. However, for users without access to a `conda` enviornment, we have also provided installation script to build and install all the dependencies except `Apache Arrow`. - -By default, Nyxus can be built with a minimal set of dependecies (Tiff support and Python interface). To build Nyxus with all the supported IO options mentioned above, pass `-DALLEXTRAS=ON` in the `cmake` command. - -### __Adding GPU Support__ -Nyxus also can be build with NVIDIA GPU support. To do so, a `CUDA` Development toolkit compatible with the host `C++` compiler need to be present in the system. For building with GPU support, pass `-DUSEGPU=ON` flag in the `cmake` command. - -### __Inside Conda__ -To build the command line interface, pass `-DBUILD_CLI=ON` in the `cmake` command. - - -Below is an example of how to build Nyxus inside a `conda` environment on Linux. - -```bash -conda create -n nyxus_build python=3.12 -conda activate nyxus_build -git clone https://github.com/PolusAI/nyxus.git -cd nyxus -conda install mamba -c conda-forge -mamba install -y -c conda-forge --file ci-utils/envs/conda_cpp.txt -export NYXUS_DEP_DIR=$CONDA_PREFIX -mkdir build -cd build -cmake -DBUILD_CLI=ON -DALLEXTRAS=ON -DUSEGPU=ON .. -make -j4 -``` - -To install the python package in the `conda` environment on Linux, use the following direction. -```bash -conda create -n nyxus_build python=3.12 -conda activate nyxus_build -git clone https://github.com/PolusAI/nyxus.git -cd nyxus -conda install mamba -c conda-forge -mamba install -y -c conda-forge --file ci-utils/envs/conda_cpp.txt --file ci-utils/envs/conda_py.txt -export NYXUS_DEP_DIR=$CONDA_PREFIX -CMAKE_ARGS="-DUSEGPU=ON -DALLEXTRAS=ON -DPython_ROOT_DIR=$CONDA_PREFIX -DPython_FIND_VIRTUALENV=ONLY" python -m pip install . -vv -``` - -If there's no system wide compatible C++ compiler, we may install it using conda and the configuration script will pick it up. -```bash -... -conda activate nyxus_build -conda install cxx-compiler -y -export NYXUS_DEP_DIR=$CONDA_PREFIX -... -``` - -### __Without Using Conda__ -To build Nyxus outside of a `conda` environment, we will first need to build and install all the required and optional dependecies. `ci-utils/install_prereq_windwos.bat` and `ci-utils/install_prereq_linux.sh` performs the task for Windows and Linux (and Mac) respectively. These script take a `--min_build yes` option to only build the minimal dependencies. Below, we provide an example for Windows OS. - -```bash -git clone https://github.com/PolusAI/nyxus.git -cd nyxus -mkdir build -cd build -..\ci-utils\install_prereq_windows.bat -cmake -DBUILD_CLI=ON -DUSEGPU=ON -DALLEXTRAS=ON -DCMAKE_PREFIX_PATH=.\local_install -DCMAKE_INSTALL_PREFIX=.\local_install .. -cmake --build . --config Release -set PATH=%PATH%;%cd%\local_install\bin -``` -To install the python package in the environment on Linux, use the following direction. - -```bash -python -m virtualenv venv -venv\Scripts\activate.bat -git clone https://github.com/PolusAI/nyxus.git -cd nyxus -mkdir build_dep -cd build_dep -..\ci-utils\install_prereq_windows.bat -cd .. -set NYXUS_DEP_DIR=%cd%\build_dep\local_install -set CMAKE_ARGS=-DUSEGPU=ON -DALLEXTRAS=ON -python -m pip install . -vv -xcopy /E /I /y %NYXUS_DEP_DIR%\bin\*.dll %VIRTUAL_ENV%\lib\site-packages\nyxus -``` -Note that, in both cases, the `dll`s of the dependencies need to be in the `PATH` (for CLI) or in the `site-packages` location (for Python package). - -## Running via Docker -Running Nyxus from a local directory freshly made Docker container is a good idea. It allows one to test-run conteinerized Nyxus before it reaches Docker cloud deployment. - -To search available Nyxus images run command -``` -docker search nyxus -``` -and you'll be shown that it's available at least via organization 'polusai'. To pull it, run -``` -docker pull polusai/nyxus -``` - -The following command line is an example of running the dockerized feature extractor (image hash 87f3b560bbf2) with only intensity features selected: -``` -docker run -it [--gpus all] --mount type=bind,source=/images/collections,target=/data 87f3b560bbf2 --intDir=/data/c1/int --segDir=/data/c1/seg --outDir=/data/output --filePattern=.* --outputType=separatecsv --features=entropy,kurtosis,skewness,max_intensity,mean_intensity,min_intensity,median,mode,standard_deviation -``` - -## WIPP Usage - -Nyxus is available as plugin for [WIPP](https://isg.nist.gov/deepzoomweb/software/wipp). - -__Label image collection:__ -The input should be a labeled image in tiled OME TIFF format (.ome.tif). Extracting morphology features, Feret diameter statistics, neighbors, hexagonality and polygonality scores requires the segmentation labels image. If extracting morphological features is not required, the label image collection can be not specified. - -__Intensity image collection:__ -Extracting intensity-based features requires intensity image in tiled OME TIFF format. This is an optional parameter - the input for this parameter is required only when intensity-based features needs to be extracted. - -__File pattern:__ -Enter file pattern to match the intensity and labeled/segmented images to extract features (https://pypi.org/project/filepattern/) Filepattern will sort and process files in the labeled and intensity image folders alphabetically if universal selector(.*.ome.tif) is used. If a more specific file pattern is mentioned as input, it will get matches from labeled image folder and intensity image folder based on the pattern implementation. - -__Pixel distance:__ -Enter value for this parameter if neighbors touching cells needs to be calculated. The default value is 5. This parameter is optional. - -__Features:__ -Comma separated list of features to be extracted. If all the features are required, then choose option __*all*__. - -__Outputtype:__ -There are 4 options available under this category. __*Separatecsv*__ - to save all the features extracted for each image in separate csv file. __*Singlecsv*__ - to save all the features extracted from all the images in the same csv file. __*Arrow*__ - to save all the features extracted from all the images in Apache Arrow format. __*Parquet*__ - to save all the features extracted from all the images in Apache Parquet format - -__Embedded pixel size:__ -This is an optional parameter. Use this parameter only if units are present in the metadata and want to use those embedded units for the features extraction. If this option is selected, value for the length of unit and pixels per unit parameters are not required. - -__Length of unit:__ -Unit name for conversion. This is also an optional parameter. This parameter will be displayed in plugin's WIPP user interface only when embedded pixel size parameter is not selected (ckrresponding check box checked). +## Where to go next -__Pixels per unit:__ -If there is a metric mentioned in Length of unit, then Pixels per unit cannot be left blank and hence the scale per unit value must be mentioned in this parameter. This parameter will be displayed in plugin's user interface only when embedded pixel size parameter is not selected. +- [Troubleshooting](https://nyxus.readthedocs.io/en/latest/troubleshooting.html): the common + error messages and what they mean +- [Command line and Python examples](https://nyxus.readthedocs.io/en/latest/cmdline_and_examples.html) + and [supported formats](https://nyxus.readthedocs.io/en/latest/supported_formats.html) +- [3D volumes](https://nyxus.readthedocs.io/en/latest/volumetric.html) and + [large images](https://nyxus.readthedocs.io/en/latest/large_images.html) +- [Output formats and columns](https://nyxus.readthedocs.io/en/latest/output.html), + [GPU acceleration](https://nyxus.readthedocs.io/en/latest/gpu.html) and + [IBSI mode](https://nyxus.readthedocs.io/en/latest/ibsi_mode.html) +- [Feature list](https://nyxus.readthedocs.io/en/latest/featurelist.html), + [feature groups](https://nyxus.readthedocs.io/en/latest/featuregroups.html) and + [how features are validated](https://nyxus.readthedocs.io/en/latest/validation.html) +- [Python API](https://nyxus.readthedocs.io/en/latest/Reference.html) and + [command-line reference](https://nyxus.readthedocs.io/en/latest/cli_reference.html) +- [WIPP plugin](https://nyxus.readthedocs.io/en/latest/wipp.html) +- [Developer guide](https://nyxus.readthedocs.io/en/latest/devguide.html) and + [contributing](https://github.com/PolusAI/nyxus/blob/main/CONTRIBUTING.md) -__Note:__ If Embedded pixel size is not selected and values are entered in Length of unit and Pixels per unit, then the metric unit mentioned in length of unit will be considered. -If Embedded pixel size, Length of unit and Pixels per unit is not selected and the unit and pixels per unit fields are left blank, the unit will be assumed to be pixels. +## Citing Nyxus -__Output:__ -The output is a csv file containing the value of features required. +If you use Nyxus in your work, please cite it as described in [CITATION.cff](https://github.com/PolusAI/nyxus/blob/main/CITATION.cff); +GitHub's *Cite this repository* button gives APA and BibTeX. -For more information on WIPP, visit the [official WIPP page](https://github.com/usnistgov/WIPP/tree/master/user-guide). +## License - \ No newline at end of file +Nyxus is released under the [MIT License](https://github.com/PolusAI/nyxus/blob/main/LICENSE). diff --git a/docs/cli_options.toml b/docs/cli_options.toml new file mode 100644 index 000000000..cc8044053 --- /dev/null +++ b/docs/cli_options.toml @@ -0,0 +1,605 @@ +# Command-line options, rendered into docs/source/cli_reference.rst by +# docs/tools/gen_cli_reference.py. `evidence` lists the code (under src/nyx/) that +# applies each default and requirement; re-check it when the option's code changes. + +[[option]] +name = "-h, --help" +group = "Diagnostics" +required = false +default = "" +values = "no value" +build = "" +summary = "Prints the usage screen and exits." +notes = "Running nyxus with no arguments also prints the usage screen." +evidence = "environment.cpp:409-414, 422-426; main_nyxus.cpp:17-18" + +[[option]] +name = "--intDir" +group = "Input" +required = true +default = "" +values = "path to an existing directory" +build = "" +summary = "Directory holding the intensity images." +notes = "Required even when --intSegMapFile is used: mapped intensity file names are resolved relative to this directory. When it is the same directory as --segDir, Nyxus runs in whole-slide mode and treats each image as one ROI." +evidence = "environment.cpp:563-567, 575-578; dirs_and_files.cpp:65-66, 135" + +[[option]] +name = "--segDir" +group = "Input" +required = true +default = "" +values = "path to an existing directory" +build = "" +summary = "Directory holding the label (mask) images that define the ROIs." +notes = "Set it to the same directory as --intDir for whole-slide mode, where each intensity image is a single ROI and no mask is read. Required even with --intSegMapFile: mapped mask file names are resolved relative to this directory." +evidence = "environment.cpp:558-562, 575-578; main_nyxus.cpp:83-91, 147-168; dirs_and_files.cpp:74-91, 139" + +[[option]] +name = "--filePattern" +group = "Input" +required = false +default = ".*" +default_at = "environment_basic.h:rawFilePattern" +values = "regular expression (2D); regular expression or file pattern with `{d+}`, `{c+}`, `{set d+}` (3D)" +build = "" +summary = "Selects which files in the input directories are processed. In 2D the pattern must match the whole file name." +notes = "A regular expression, so use `.*\\.tif` rather than `*.tif`. In 3D a pattern containing `{set d+}` is read as a z-slice file pattern, for example `BRATS_{d+}_z{set d+}_t{d+}.ome.tif`. Such a pattern currently matches no files and the run stops with 'No intensity and/or label file pairs to process'. Not applied to pairs listed in an --intSegMapFile." +evidence = "environment_basic.h:27; environment.cpp:345-346, 519-523, 538-554; environment_basic.cpp:19-55; strpat.cpp:9-35; dirs_and_files.cpp:28-46" + +[[option]] +name = "--intSegMapFile" +group = "Input" +required = false +default = "" +values = "file name inside --intSegMapDir" +build = "" +summary = "Names a text file that lists explicit intensity/mask pairs, one pair per line, instead of matching files by name." +notes = "Each line holds an intensity file name and then a mask file name, separated by whitespace. Intensity names are relative to --intDir and mask names to --segDir. Requires --intSegMapDir. --filePattern is not applied to listed pairs. 2D only." +evidence = "dirs_and_files.cpp:71, 117-153; environment.cpp:433; main_nyxus.cpp:62-71, 174-183" + +[[option]] +name = "--intSegMapDir" +group = "Input" +required = false +default = "" +values = "path to an existing directory" +build = "" +summary = "Directory that contains the --intSegMapFile pairing file." +notes = "Has no effect unless --intSegMapFile is also given." +evidence = "dirs_and_files.cpp:71, 117-124; environment.cpp:432" + +[[option]] +name = "--dim" +group = "Input" +required = false +default = "2" +default_at = "environment.cpp:dim_" +values = "2 | 3" +build = "" +summary = "Sets whether the dataset is processed as 2D images or as 3D volumes." +notes = "In 3D the default feature set is *3D_ALL*, the Gabor and nested-ROI options are rejected, and --intSegMapFile is ignored." +evidence = "environment.cpp:124, 527-537, 589-592, 993-997, 1042-1046; main_nyxus.cpp:58, 145" + +[[option]] +name = "--outDir" +group = "Output" +required = true +default = "" +values = "path to an existing directory" +build = "" +summary = "Directory where result files are written." +notes = "The directory must already exist." +evidence = "environment.cpp:568-572; dirs_and_files.cpp:62-63, 275-276" + +[[option]] +name = "--outputType" +group = "Output" +required = true +default = "" +values = "separatecsv | singlecsv | arrowipc | parquet (case-insensitive)" +build = "" +summary = "Selects the result format: one CSV per image pair, one combined CSV, an Arrow IPC file, or a Parquet file." +notes = "There is no default. arrowipc and parquet need a build with Arrow support." +evidence = "environment.cpp:94-95, 581-585, 604-641" + +[[option]] +name = "--resultFname" +group = "Output" +required = false +default = "NyxusFeatures" +default_at = "environment.cpp:nyxus_result_fname" +values = "non-empty file name without extension" +build = "" +summary = "Base name of the result file for singlecsv, arrowipc and parquet output." +notes = "Ignored with separatecsv, which names each file after its image pair." +evidence = "environment.cpp:77, 345-346, 597-601; output_2_csv.cpp:212-219; workflow_2d_segmented.cpp:277" + +[[option]] +name = "--noval" +group = "Output" +required = false +default = "0.0" +default_at = "cli_result_options.h:noval_" +values = "real number" +build = "" +summary = "Value reported in place of a feature that cannot be computed (NaN or undefined)." +notes = "In Arrow and Parquet output, values that are still NaN or infinite when they are written become -0.0 instead, whatever --noval is. A non-numeric value stops the run with an error." +evidence = "cli_result_options.h:31-37, 75-76; env_features.cpp:723; output_writers.h:19" + +[[option]] +name = "--tinyval" +group = "Output" +required = false +default = "1e-10" +default_at = "cli_result_options.h:tiny_" +values = "real number" +build = "" +summary = "Accepted and validated, but no feature reads it." +notes = "Intended as the small constant feature formulas use to avoid division by zero; the stored value is not used by any feature. A non-numeric value stops the run with an error." +evidence = "cli_result_options.h:39-44, 75-76; env_features.cpp:724" + +[[option]] +name = "--aggr" +group = "Output" +required = false +default = "false" +default_at = "cli_result_options.h:need_aggregation_" +values = "true | false | t | f (case-insensitive)" +build = "" +summary = "When true, writes one row per image holding the mean of each feature over that image's ROIs, labelled ROI -1, instead of one row per ROI." +notes = "Applies only to CSV output (separatecsv or singlecsv) for images with masks. Arrow, Parquet and whole-slide output ignore it. Values the features substituted with --noval enter the mean at that value. A value still NaN at output time contributes the full --noval rather than --noval divided by the number of ROIs, and an infinite value is not replaced, so the aggregated cell can be inf." +evidence = "cli_result_options.h:46-52, 77; output_2_csv.cpp:505-557; workflow_2d_segmented.cpp:334-336; workflow_3d_segmented.cpp:261-263" + +[[option]] +name = "--annot" +group = "Output" +required = false +default = "false" +default_at = "cli_result_options.h:need_annotation_" +values = "true | false | t | f (case-insensitive)" +build = "" +summary = "When true, adds annotation columns (anno0, anno1, ...) to CSV output. Their values come from the mask file name split at underscores." +notes = "Only the part of the mask file name before the first dot is used, and empty pieces are dropped. Annotation columns appear in CSV output only. With Arrow IPC or Parquet output, --annot=true currently stops the run with a crash and leaves an unreadable file (known defect, https://github.com/PolusAI/nyxus/issues/517)." +evidence = "cli_result_options.h:54-60, 78; slideprops.cpp:518-537; output_2_csv.cpp:71-79, 586-590" + +[[option]] +name = "--annotsep" +group = "Output" +required = false +default = "_" +default_at = "cli_result_options.h:anno_sep_" +values = "string" +build = "" +summary = "Separator for splitting file names into annotations." +notes = "The value is accepted but annotations are always split at '_'." +evidence = "cli_result_options.h:62-65, 79; slideprops.cpp:531 (hard-coded \"_\"); no other reader of the stored separator" + +[[option]] +name = "--features" +group = "Features" +required = false +default = "*ALL* (2D) or *3D_ALL* (3D)" +values = "comma-separated feature and group names (case-insensitive), or the path of a text file listing them" +build = "" +summary = "Selects the features or feature groups to compute." +notes = "If omitted, *ALL* (2D) or *3D_ALL* (3D) is computed; in 2D the IH_* features are then left out unless --ibsi=true. If the value is the path of an existing file, the names are read from it: one or more per line, and `#` starts a comment. Names are case-insensitive. An unknown name stops the run and lists the valid names. Image-quality names cannot be mixed with other features. Intensity Histogram (IH_*) features need --ibsi=true." +evidence = "environment.cpp:587-594, 839-901; env_features.cpp:71-182, 459-529; featureset.cpp:650, 940" + +[[option]] +name = "--ibsi" +group = "Features" +required = false +default = "false" +default_at = "environment.cpp:ibsi_compliance" +values = "true | 1 | on (case-insensitive); any other value means false" +build = "" +summary = "Turns on IBSI compliance mode: texture families skip grey-level binning and follow IBSI definitions, and the Intensity Histogram family becomes available." +notes = "Invalid values are not reported; they silently mean false." +evidence = "environment.cpp:120, 878-879; env_features.cpp:515-528; features/glcm.cpp:355; features/glrlm.cpp:85; features/ngtdm.cpp:48" + +[[option]] +name = "--mergerois" +group = "Features" +required = false +default = "false" +default_at = "environment.h:mergeLabels" +values = "true | 1 | on (case-insensitive); any other value means false" +build = "" +summary = "Merges all nonzero mask labels into one ROI covering the whole foreground, instead of one ROI per label." +notes = "Background (label 0) is still excluded. Invalid values are not reported; they silently mean false." +evidence = "environment.h:61-62; environment.cpp:882-883; phase1.cpp:76, 330; phase2_2d.cpp:165, 289" + +[[option]] +name = "--skiproi" +group = "Features" +required = false +default = "no ROIs skipped" +values = "label[,label...] or maskfile:label[,label...][;maskfile:label...]" +build = "" +summary = "Leaves ROIs with the listed labels out of feature calculation and output, either in every image or only in named mask files." +notes = "File names are mask file names without directories. A plain label list applies to every image and takes precedence over file-specific lists. Not used in whole-slide mode." +evidence = "environment.cpp:723-731; roi_blacklist.cpp:7-75, 77-135, 147-169; workflow_2d_segmented.cpp:105-120; workflow_3d_segmented.cpp:94" + +[[option]] +name = "--pixelDistance" +group = "Features" +required = false +default = "5" +default_at = "environment.cpp:n_pixel_distance" +values = "integer >= 1" +build = "" +summary = "Distance in pixels within which two ROIs count as neighbors, for the neighbor features." +notes = "" +evidence = "environment.cpp:87, 655-663; env_features.cpp:728" + +[[option]] +name = "--pixelsPerCentimeter" +group = "Features" +required = false +default = "" +values = "real number > 0" +build = "" +summary = "Image resolution, for reporting area in square micrometres (AREA_UM2)." +notes = "The value is accepted and validated but does not reach the results: AREA_UM2 is reported as 0 whatever the resolution (known defect, https://github.com/PolusAI/nyxus/issues/496)." +evidence = "environment.cpp:904-914; env_features.cpp:719-748 (sets PIXELSIZEUM, never XYRES); features/basic_morphology.cpp:24-28, 101-105" + +[[option]] +name = "--embeddedpixelsize" +group = "Features" +required = false +default = "" +values = "any string" +build = "" +summary = "Accepted for compatibility." +notes = "The value is accepted and shown in the run summary but does not affect any result." +evidence = "environment.cpp:81, 274, 438 (only uses)" + +[[option]] +name = "--onlineStatsThresh" +group = "Features" +required = false +default = "" +values = "not accepted" +build = "" +summary = "Reserved option name." +notes = "The name is reserved but the option is not accepted: passing it stops the run as an unrecognized argument." +evidence = "cli_option_constants.h:15; environment.cpp:428-489 (not in the parse list), 507-513" + +[[option]] +name = "--coarseGrayDepth" +group = "Texture settings" +required = false +default = "64" +default_at = "environment.cpp:coarse_grayscale_depth" +values = "integer: > 0 MATLAB-style bins from 0 to the ROI maximum, < 0 |n| PyRadiomics-style bins over the ROI range, 0 no binning for the texture families that use the shared rule" +build = "" +summary = "Grey depth used to bin intensities for the 2D texture families, 3D GLCM, 3D GLDZM and 3D NGLDM, and the bin count of the ENTROPY, UNIFORMITY and HISTOGRAM histograms and of the Intensity Histogram (IH_*) family." +notes = "3D GLRLM, GLSZM, GLDM and NGTDM ignore it: they use their own greydepth metaparameter. NGLDM uses its own rule: 0 puts every pixel at level 0, and a negative value does not select PyRadiomics-style bins. ENTROPY and UNIFORMITY use |n| bins; HISTOGRAM writes n columns and none when n is negative (a fix is coming); none of the three has bins at 0; the IH_* features return the --noval substitute when n is below 2. --ibsi=true makes every texture family skip binning, except the 2D GLCM test for a constant ROI. The value is read as its leading integer: 8.5 runs as 8, and a value that does not start with an integer stops the run with an error." +evidence = "environment.cpp:106, 666-674; env_features.cpp:726, 735; features/texture_feature.h:23-49, 101-103; features/intensity_histogram.cpp:311" + +[[option]] +name = "--glcmAngles" +group = "Texture settings" +required = false +default = "0,45,90,135" +default_at = "cli_glcm_options.h:glcmAngles" +values = "comma-separated integers from {0, 45, 90, 135}" +build = "" +summary = "Directions, in degrees, at which 2D GLCM co-occurrence matrices are computed. GLCM output has one column per angle." +notes = "2D GLCM only: 3D GLCM always uses its own fixed directions. Other integers pass parsing, but the calculation reports 'unsupported angle' for them and their columns hold 0. A non-integer list stops the run with an error." +evidence = "cli_glcm_options.h:20-21; cli_glcm_options.cpp:7-24; features/glcm.cpp:9, 229-256; features/3d_glcm.cpp:10; output_2_csv.cpp:116" + +[[option]] +name = "--glcmOff" +group = "Texture settings" +required = false +default = "1" +default_at = "env_features.cpp:s[(int)NyxSetting::GLCM_OFFSET].ival" +values = "integer >= 1" +build = "" +summary = "GLCM co-occurrence distance in pixels." +notes = "The value is validated but does not reach the GLCM calculation, which always uses an offset of 1." +evidence = "cli_glcm_options.cpp:26-38 (stores offset_, never read); env_features.cpp:735 (GLCM_OFFSET fixed to 1); features/glcm.cpp:24" + +[[option]] +name = "--gaborfreqs" +group = "Gabor" +required = false +default = "built-in pairs (see notes)" +values = "comma-separated real numbers; same count as --gabortheta" +build = "" +summary = "Frequencies of the Gabor high-pass filters. A value f gives a wavelength of 2*pi/f pixels. GABOR output has one column per frequency/orientation pair." +notes = "Give --gaborfreqs and --gabortheta together, with the same number of values: value i of one pairs with value i of the other. Without both, the command line uses its built-in pairs: frequencies 0, pi/4, pi/2 and 3*pi/4 at orientations 4, 16, 32 and 64 radians. The Python API's default set is frequencies 4, 16, 32, 64 at 0, 45, 90, 135 degrees; to compute that set from the command line, pass --gaborfreqs=4,16,32,64 --gabortheta=0,45,90,135. 2D only." +evidence = "cli_gabor_options.cpp:7-47; features/gabor.cpp:19-25, 107-112, 395 (lambda = 2*pi/f0); features/gabor.h:62-63; python/nyxus/nyxus.py:299-300; environment.cpp:993-997" + +[[option]] +name = "--gabortheta" +group = "Gabor" +required = false +default = "built-in pairs (see --gaborfreqs)" +values = "comma-separated real numbers in degrees; same count as --gaborfreqs" +build = "" +summary = "Orientations of the Gabor filters in degrees, one per frequency in --gaborfreqs." +notes = "Converted to radians internally; no range check is made. Give it together with --gaborfreqs, with the same number of values. See --gaborfreqs for the built-in pairs. 2D only." +evidence = "cli_gabor_options.cpp:10-41; features/gabor.cpp:19-25, 646-651" + +[[option]] +name = "--gaborgamma" +group = "Gabor" +required = false +default = "0.1" +default_at = "features/gabor.cpp:GaborFeature::gamma" +values = "real number > 0" +build = "" +summary = "Aspect ratio (ellipticity) of the Gaussian envelope of the Gabor filter." +notes = "" +evidence = "features/gabor.cpp:14; cli_gabor_options.cpp:49-62" + +[[option]] +name = "--gaborsig2lam" +group = "Gabor" +required = false +default = "0.8" +default_at = "features/gabor.cpp:GaborFeature::sig2lam" +values = "real number > 0" +build = "" +summary = "Ratio of the Gaussian width (sigma) to the wavelength, which sets the filter's spatial-frequency bandwidth." +notes = "" +evidence = "features/gabor.cpp:15, 398; cli_gabor_options.cpp:64-77" + +[[option]] +name = "--gaborkersize" +group = "Gabor" +required = false +default = "16" +default_at = "features/gabor.cpp:GaborFeature::n" +values = "integer >= 2" +build = "" +summary = "Width and height, in pixels, of the square Gabor convolution kernel." +notes = "1 is accepted but reads past the end of the convolution buffer, so its values are arbitrary (known defect, https://github.com/PolusAI/nyxus/issues/506)." +evidence = "features/gabor.cpp:16, 69, 493-505; cli_gabor_options.cpp:79-92" + +[[option]] +name = "--gaborf0" +group = "Gabor" +required = false +default = "0.1" +default_at = "features/gabor.cpp:GaborFeature::f0LP" +values = "real number > 0" +build = "" +summary = "Frequency of the low-pass Gabor filter that produces the baseline image the high-pass responses are compared against." +notes = "Same units as --gaborfreqs: the wavelength is 2*pi/f pixels. 2D only." +evidence = "features/gabor.cpp:17, 395; cli_gabor_options.cpp:94-107" + +[[option]] +name = "--gaborthold" +group = "Gabor" +required = false +default = "0.025" +default_at = "features/gabor.cpp:GaborFeature::GRAYthr" +values = "real number" +build = "" +summary = "Threshold on the ratio of filtered image to baseline; pixels above it are counted in the GABOR feature." +notes = "Any real number is accepted, with no range check. A non-numeric value stops the run with an error. 2D only." +evidence = "features/gabor.cpp:18; cli_gabor_options.cpp:109-122" + +[[option]] +name = "--fpimgmin" +group = "Image handling" +required = false +default = "0.0 (used only when an --fpimg* option is given; otherwise each image's own minimum)" +values = "real number, less than --fpimgmax" +build = "" +summary = "Lower end of the intensity range that floating-point images are rescaled from before conversion to integer grey levels." +notes = "Giving any of --fpimgmin, --fpimgmax or --fpimgdr switches every float image from its own scanned min/max to the fixed range [--fpimgmin, --fpimgmax]. Unset ends then default to 0 and 1. A min that is not below the max stops the run with an error. Integer images are not affected." +evidence = "cli_fpimage_options.h:65-67; cli_fpimage_options.cpp:7-41, 73-78; slideprops.cpp:585-602; environment.cpp:760-768" + +[[option]] +name = "--fpimgmax" +group = "Image handling" +required = false +default = "1.0 (used only when an --fpimg* option is given; otherwise each image's own maximum)" +values = "real number, greater than --fpimgmin" +build = "" +summary = "Upper end of the intensity range that floating-point images are rescaled from before conversion to integer grey levels." +notes = "See --fpimgmin: giving any --fpimg* option replaces each image's own range with the fixed [min, max] range. A non-numeric value stops the run with an error." +evidence = "cli_fpimage_options.h:65-67; cli_fpimage_options.cpp:22-41; slideprops.cpp:585-602" + +[[option]] +name = "--fpimgdr" +group = "Image handling" +required = false +default = "10000" +default_at = "cli_fpimage_options.h:target_dyn_range_" +values = "integer >= 1" +build = "" +summary = "Number of integer grey levels that floating-point intensities are rescaled into." +notes = "Giving only --fpimgdr also switches float images to the fixed range 0 to 1 (the --fpimgmin/--fpimgmax defaults) instead of each image's own range. Pair it with --fpimgmin and --fpimgmax." +evidence = "cli_fpimage_options.h:67; cli_fpimage_options.cpp:43-56, 73-78; slideprops.cpp:585-602" + +[[option]] +name = "--preserve-hu" +group = "Image handling" +required = false +default = "false" +default_at = "cli_fpimage_options.h:preserve_hu_" +values = "true | false | t | f (case-insensitive)" +build = "" +summary = "Loads floating-point and rescaled DICOM/NIfTI images with one grey level per intensity unit (offset by the image minimum) instead of min-max rescaling or the stored-integer map." +notes = "Not needed to get Hounsfield units in the output: intensity features are reported in the image's own units either way. '1', 'on' and 'yes' are rejected with an error. Shape and texture features are unaffected." +evidence = "cli_fpimage_options.h:68; cli_fpimage_options.cpp:58-68; environment.cpp:756-768; slideprops.cpp:585-636; helpers/helpers.h:120-129" + +[[option]] +name = "--anisox" +group = "Image handling" +required = false +default = "1" +default_at = "cli_anisotropy_options.h:aniso_x" +values = "real number" +build = "" +summary = "Size of a pixel or voxel along x relative to the other axes. Geometric features use it: a volume (3D or 2.5D) is featurized on its voxels as acquired and the shape features scale their coordinates by the anisotropy, and a 2D slide's geometric features are computed on its pixels resampled by the anisotropy. Intensity and texture features are computed on the pixels or voxels as acquired, so they report the same values with or without anisotropy. On a 2D slide the intensity-weighted geometric features (EDGE_*, WEIGHTED_CENTROID_X/Y, MASS_DISPLACEMENT, IMOM_*, the radial distribution and ZERNIKE2D) are computed on the pixels as acquired, each at its position scaled by the anisotropy. Of the image quality features only POWER_SPECTRUM_SLOPE changes: it is computed on the resampled ROI (known defect, https://github.com/PolusAI/nyxus/issues/519)." +notes = "A value of 1 counts as unset. No positivity check is made. A value other than 1 in any --aniso* option replaces the physical spacing of --use-physical-spacing on all three axes; the axes not given then use 1. A non-numeric value stops the run with an error." +evidence = "cli_anisotropy_options.h:21-29; cli_anisotropy_options.cpp:4-30; phase2_3d.cpp:29-66; phase2_2d.cpp:716-727" + +[[option]] +name = "--anisoy" +group = "Image handling" +required = false +default = "1" +default_at = "cli_anisotropy_options.h:aniso_y" +values = "real number" +build = "" +summary = "Pixel/voxel scale factor along y; same behavior as --anisox." +notes = "A value of 1 counts as unset. No positivity check is made." +evidence = "cli_anisotropy_options.h:21-29; cli_anisotropy_options.cpp:13-19" + +[[option]] +name = "--anisoz" +group = "Image handling" +required = false +default = "1" +default_at = "cli_anisotropy_options.h:aniso_z" +values = "real number" +build = "" +summary = "Voxel scale factor along z for 3D volumes; same behavior as --anisox." +notes = "Only 3D processing uses it. A value of 1 counts as unset." +evidence = "cli_anisotropy_options.cpp:20-26; phase2_3d.cpp:29-66; phase2_2d.cpp:726-727 (x and y only)" + +[[option]] +name = "--use-physical-spacing" +group = "Image handling" +required = false +default = "false" +default_at = "environment.h:use_physical_spacing_" +values = "true | t | 1 | on | yes (case-insensitive); any other value means false" +build = "" +summary = "In 3D, uses each volume's OME PhysicalSize values, scaled so the smallest axis is 1, as the voxel spacing by which the shape features scale their coordinates." +notes = "3D only. Any --aniso* value other than 1 replaces the physical spacing on all three axes. Invalid values are not reported; they silently mean false. Physical spacing columns are written to the output whether or not this is on." +evidence = "environment.h:104-110; environment.cpp:804-809; phase2_3d.cpp:29-66; slideprops.cpp:325-327, 506" + +[[option]] +name = "--reduceThreads" +group = "Performance and memory" +required = false +default = "4" +default_at = "environment.cpp:n_reduce_threads" +values = "integer >= 1" +build = "" +summary = "Number of CPU threads used to calculate features." +notes = "" +evidence = "environment.cpp:84, 645-653; main_nyxus.cpp:88, 98, 162, 194; reduce_trivial_rois.cpp:775-785" + +[[option]] +name = "--ramLimit" +group = "Performance and memory" +required = false +default = "50% of available physical RAM at startup" +values = "integer megabytes >= 0, not above available RAM" +build = "" +summary = "Memory budget in megabytes. ROIs whose estimated footprint reaches it are processed out of core, and the rest are batched to fit within it." +notes = "The value is read as its leading integer: 100.5 and 100MB both run as 100. A value above available RAM, a negative value, or a value that does not start with an integer stops the run with an error." +evidence = "environment.cpp:137-138, 687-706, 938-954; workflow_2d_segmented.cpp:123-148; workflow_3d_segmented.cpp:103-133" + +[[option]] +name = "--tempDir" +group = "Performance and memory" +required = false +default = "system temporary directory" +values = "path to an existing directory" +build = "" +summary = "Directory for the temporary files of out-of-core ROIs." +notes = "The directory must exist and is shown in the run summary, but out-of-core temporary files are written to the system temporary directory regardless." +evidence = "environment.cpp:709-720; helpers/fsystem.h:25-35; features/image_matrix_nontriv.cpp:36, 102; features/voxel_cloud_nontriv.cpp:67" + +[[option]] +name = "--hsig" +group = "Nested ROIs" +required = false +default = "" +values = "string (channel marker in file names, e.g. _c)" +build = "" +summary = "Text that marks the channel number in mask file names (for example '_c' in p0_y1_r1_c1.ome.tiff). Nested-ROI analysis uses it to find the parent and child masks of the same image." +notes = "Nested-ROI analysis runs only when all four of --hsig, --hpar, --hchi and --hag are given. If any is missing, the others are silently ignored. 2D only. The analysis reads back the per-image CSV files, so use --outputType=separatecsv: with singlecsv each parent's row is taken from the first row with its label in the combined file, which can belong to another image. It writes _nested_features.csv and a relations CSV to --outDir." +evidence = "cli_nested_roi_options.cpp:15-21, 23-29, 156-183, 431, 682-798; output_2_csv.cpp:212-219; environment.cpp:793-801, 1040-1054; main_nyxus.cpp:125-135" + +[[option]] +name = "--hpar" +group = "Nested ROIs" +required = false +default = "" +values = "integer" +build = "" +summary = "Channel number of the masks that provide parent ROIs." +notes = "Needs --hsig, --hchi and --hag as well. A non-integer value stops the run with an error." +evidence = "cli_nested_roi_options.cpp:31-44" + +[[option]] +name = "--hchi" +group = "Nested ROIs" +required = false +default = "" +values = "integer" +build = "" +summary = "Channel number of the masks that provide child ROIs. A child is a ROI whose bounding box lies inside a parent's bounding box." +notes = "Needs --hsig, --hpar and --hag as well. A non-integer value stops the run with an error." +evidence = "cli_nested_roi_options.cpp:46-59, 786-794" + +[[option]] +name = "--hag" +group = "Nested ROIs" +required = false +default = "" +values = "SUM | MEAN | MIN | MAX | WMA | NONE (uppercase only)" +build = "" +summary = "How the features of a parent's children are combined into the parent's row. NONE lists every child's features side by side." +notes = "Uppercase only. WMA computes the same unweighted mean as MEAN. There is no default: nested-ROI analysis runs only when all four nested options are given." +evidence = "cli_nested_roi_options.cpp:61-91, 634-660" + +[[option]] +name = "--verbose" +group = "Diagnostics" +required = false +default = "1" +default_at = "environment_basic.h:verbosity_level" +values = "integer >= 0" +build = "" +summary = "Amount of console output. The version banner, the list of recognized arguments, and errors and warnings are printed at every level; 1 adds the run summary, and higher levels add timing, per-ROI and diagnostic detail." +notes = "At level 2 and above, ROI metric files are also written to the output directory." +evidence = "environment_basic.h:28; environment.cpp:500-504, 676-684; main_nyxus.cpp:15, 20; workflow_2d_segmented.cpp:101; environment.h:302-306" + +[[option]] +name = "--exclusivetiming" +group = "Diagnostics" +required = false +default = "false" +default_at = "-" +values = "true | 1 | on (case-insensitive); any other value means false" +build = "CHECKTIMING builds" +summary = "In timing builds, true measures time per image pair (exclusive) and false measures the whole collection (inclusive)." +notes = "Rejected as unrecognized in builds without CHECKTIMING." +evidence = "cli_option_constants.h:27-29; environment.cpp:480-482, 812-821; helpers/timing.cpp:10" + +[[option]] +name = "--useGpu" +group = "GPU" +required = false +default = "false" +default_at = "cli_gpu_options.h:using_gpu_" +values = "true | false (case-insensitive)" +build = "GPU builds" +summary = "Runs supported feature calculations on a CUDA GPU." +notes = "Other values stop the run with an error. If the chosen device cannot be initialized, the run stops and suggests --useGpu=false. Rejected as unrecognized in non-GPU builds." +evidence = "cli_option_constants.h:31-34; cli_gpu_options.h:28; cli_gpu_options.cpp:76-94; environment.cpp:484-487, 825-833; main_nyxus.cpp:22-31" + +[[option]] +name = "--gpuDeviceID" +group = "GPU" +required = false +default = "0" +default_at = "cli_gpu_options.cpp:devIds.push_back" +values = "comma-separated integers >= 0" +build = "GPU builds" +summary = "GPU device IDs to choose from. Nyxus uses the listed device with the most free memory at startup." +notes = "Ignored unless --useGpu=true. A value that does not start with a non-negative integer stops the run with an error. A listed device that cannot be used is skipped; if none of them can be used, the run stops with an error." +evidence = "cli_gpu_options.cpp:68-106; gpu/gpu_helpers.cu:60-86; environment.cpp:486" diff --git a/docs/examples/arrow_output.py b/docs/examples/arrow_output.py new file mode 100644 index 000000000..664eb728d --- /dev/null +++ b/docs/examples/arrow_output.py @@ -0,0 +1,23 @@ +# Example for docs/source/cmdline_and_examples.rst: writing results to an Arrow IPC file. +# Needs a Nyxus build with Arrow support (the PyPI and conda packages have it). +# The page shows the lines between the "example" markers. + +# example-start +import numpy as np +import pyarrow as pa +from nyxus import Nyxus + +intens = np.array([[[1, 4, 4, 1, 1], [1, 4, 6, 1, 1], [4, 1, 6, 4, 1], [4, 4, 6, 4, 1]]]) +seg = np.ones_like(intens) + +nyx = Nyxus(["*ALL_INTENSITY*"]) +arrow_path = nyx.featurize(intens, seg, output_type="arrowipc", output_path="results") +print(arrow_path) + +with pa.memory_map(arrow_path) as source: + table = pa.ipc.open_file(source).read_all() +print(table.num_rows, table.column_names[:4]) +# example-end + +assert table.num_rows == 1 +assert table.column_names[:4] == ["intensity_image", "mask_image", "phys_unit", "ROI_label"] diff --git a/docs/examples/check_install.py b/docs/examples/check_install.py new file mode 100644 index 000000000..5cb27eff2 --- /dev/null +++ b/docs/examples/check_install.py @@ -0,0 +1,25 @@ +# Example for docs/source/installation.rst: checking an installation. +# The page shows the lines between the "example" markers. Everything after them checks the +# result, so tests/python/test_docs_examples_mechanics.py can run the script as written. + +# example-start +import numpy as np +import nyxus +from nyxus import Nyxus + +print("Nyxus version:", nyxus.__version__) + +nyx = Nyxus(["MEAN"]) +print("Arrow IPC and Parquet output:", nyx.arrow_is_enabled()) +print("CUDA GPU usable:", nyxus.gpu_is_available(id(nyx))) + +intensity = np.array([[1, 2], [3, 4]], dtype=np.uint16) +features = nyx.featurize(intensity, np.ones_like(intensity)) +print("MEAN of 1, 2, 3, 4:", features["MEAN"].iloc[0]) +# example-end + +assert isinstance(nyxus.__version__, str) and nyxus.__version__ +assert isinstance(nyx.arrow_is_enabled(), bool) +assert isinstance(nyxus.gpu_is_available(id(nyx)), bool) +assert features.shape[0] == 1 +assert features["MEAN"].iloc[0] == 2.5 diff --git a/docs/examples/conventions_example.py b/docs/examples/conventions_example.py new file mode 100644 index 000000000..0247a9e8a --- /dev/null +++ b/docs/examples/conventions_example.py @@ -0,0 +1,45 @@ +# Example for docs/source/conventions.rst: three conventions on the 5x5 ROI of the texture pages. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, so the page cannot drift from the code. +import math + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint32) +mask = np.ones_like(img) +zero_inside = img.copy() +zero_inside[2, 2] = 0 # a ROI pixel of intensity 0 +constant = np.full_like(img, 7) # a ROI of one intensity + +codes = ["MEAN", "GLDM_SDE", "GLRLM_HGLRE_AVE"] +results = {} +for ibsi in (False, True): + nyx = Nyxus(codes, ibsi=ibsi) + for name, roi in [("example", img), ("zero inside", zero_inside), ("constant", constant)]: + f = nyx.featurize(roi, mask) + results[ibsi, name] = [float(f[c][0]) for c in codes] + print(f"ibsi={ibsi!s:5} {name:11}", *(f"{v:10.4f}" for v in results[ibsi, name])) +# example-end + +# Every value the page quotes: (ibsi, roi) -> MEAN, GLDM_SDE, GLRLM_HGLRE_AVE +quoted = { + (False, "example"): (2.8, 0.3725, 1654.7051), + (False, "zero inside"): (2.76, 0.4282, 1590.1715), + (False, "constant"): (7.0, 0.0, 0.0), + (True, "example"): (2.8, 0.3725, 9.9006), + (True, "zero inside"): (2.76, 0.4282, 9.9709), + (True, "constant"): (7.0, 0.0, 0.0), +} +for key, expected in quoted.items(): + for code, got, want in zip(codes, results[key], expected): + assert math.isclose(got, want, rel_tol=5e-4, abs_tol=1e-12), f"{key} {code} = {got}, page says {want}" + +# The zero inside the mask is a ROI pixel for MEAN (69 / 25) and is left out by GLDM in both modes +assert results[False, "zero inside"][0] == 69 / 25 +assert results[False, "zero inside"][1] == results[True, "zero inside"][1] diff --git a/docs/examples/directory_and_params.py b/docs/examples/directory_and_params.py new file mode 100644 index 000000000..f4117b2cf --- /dev/null +++ b/docs/examples/directory_and_params.py @@ -0,0 +1,35 @@ +# Example for docs/source/cmdline_and_examples.rst: a directory of image pairs, and reading +# and changing parameters. The page shows the lines between the "example" markers. +import os + +import numpy as np +import tifffile + +rng = np.random.default_rng(0) +os.makedirs("data/intensities", exist_ok=True) +os.makedirs("data/labels", exist_ok=True) +for k in (1, 2): + mask = np.zeros((64, 64), dtype=np.uint16) + mask[4:20, 4:20] = 1 + mask[30:50, 8:24] = 2 + tifffile.imwrite(f"data/intensities/img{k}.ome.tif", rng.integers(100, 4000, (64, 64), dtype=np.uint16)) + tifffile.imwrite(f"data/labels/img{k}.ome.tif", mask) + +# example-start +from nyxus import Nyxus + +nyx = Nyxus(["*ALL_INTENSITY*", "*ALL_GLCM*"]) + +# Pairs files with the same name in the two directories +features = nyx.featurize_directory("data/intensities", "data/labels", file_pattern=r".*\.ome\.tif") +print(features.shape) + +print(nyx.get_params("coarse_gray_depth", "n_feature_calc_threads")) +nyx.set_params(coarse_gray_depth=256) +print(nyx.get_params("coarse_gray_depth", "n_feature_calc_threads")) +# example-end + +assert features.shape[0] == 4 +p = nyx.get_params() +assert p["coarse_gray_depth"] == 256 and p["n_feature_calc_threads"] == 4 +assert {"gabor_freqs", "gabor_thetas", "dynamic_range", "ram_limit"} <= set(p) diff --git a/docs/examples/erosion_worked_example.py b/docs/examples/erosion_worked_example.py new file mode 100644 index 000000000..837831c72 --- /dev/null +++ b/docs/examples/erosion_worked_example.py @@ -0,0 +1,88 @@ +# Example for docs/source/Math/f_erosion.rst: erosion features of the page's irregular 8x9 ROI. +# The page shows the lines between the "example" markers. The assertions below pin every Nyxus +# value the page quotes, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +mask = np.array([[0, 0, 1, 1, 1, 0, 0, 0, 0], + [0, 1, 1, 1, 1, 1, 0, 0, 0], + [1, 1, 1, 1, 1, 1, 1, 0, 0], + [1, 1, 1, 1, 1, 1, 1, 1, 0], + [1, 1, 1, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1, 1, 0, 0], + [0, 0, 0, 1, 1, 0, 0, 0, 0]], dtype=np.uint32) +img = np.arange(1, mask.size + 1, dtype=np.uint32).reshape(mask.shape) # any non-constant values + +nyx = Nyxus(["EROSIONS_2_VANISH", "EROSIONS_2_VANISH_COMPLEMENT"]) +features = nyx.featurize(img, mask) +print(features[["EROSIONS_2_VANISH", "EROSIONS_2_VANISH_COMPLEMENT"]]) +# example-end + +# Every value in the page's feature index, as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["erosion"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4, abs_tol=1e-12), \ + f"{code} = {got}, page says {facts['example']}" + + +def erosions(m, values=None): + """EROSIONS_2_VANISH of mask `m` placed with a background margin of 3 pixels.""" + m = np.pad(np.asarray(m, dtype=np.uint32), 3) + if values is None: + values = np.arange(1, m.size + 1, dtype=np.uint32).reshape(m.shape) + f = Nyxus(["EROSIONS_2_VANISH", "EROSIONS_2_VANISH_COMPLEMENT"]).featurize(values * m, m) + assert f["EROSIONS_2_VANISH_COMPLEMENT"][0] == 0 + return f["EROSIONS_2_VANISH"][0] + + +def disk(r): + y, x = np.mgrid[-r:r + 1, -r:r + 1] + return (x * x + y * y <= r * r).astype(np.uint32) + + +# Values the page quotes +assert erosions(mask) == 2 +# pins known defect https://github.com/PolusAI/nyxus/issues/501 +assert erosions(np.fliplr(mask)) == 3, "mirrored worked example" +assert erosions(np.rot90(mask, 2)) == 3, "worked example rotated by 180 degrees" +# pins known defect https://github.com/PolusAI/nyxus/issues/501 +assert erosions(np.ones((7, 9))) == 1000, "rectangle filling its box" +notched = np.ones((7, 9)); notched[0, 0] = 0 +assert erosions(notched) == 1000, "rectangle less one corner pixel" +assert erosions(disk(3)) == 3 +assert erosions(disk(6)) == 6 +assert erosions(disk(10)) == 10 +assert erosions(disk(20)) == 21 +tee = np.zeros((5, 5)); tee[1:, 1:] = 1; tee[0, 2] = tee[2, 0] = 1 # 4x4 square, two pixels sticking out +# pins known defect https://github.com/PolusAI/nyxus/issues/501 +assert erosions(tee) == 1000, "pixels sticking out at the top and left" +assert erosions(np.rot90(tee, 2)) == 2, "pixels sticking out at the bottom and right" +for h, w in ((1, 1), (2, 5), (3, 3), (3, 9)): + assert erosions(np.ones((h, w))) == 0, f"{h}x{w} rectangle" +for h, w in ((4, 4), (4, 9), (5, 5)): + assert erosions(np.ones((h, w))) == 1000, f"{h}x{w} rectangle" + +# A ROI of one intensity reports 0, whatever its shape +for m in (mask, disk(6), np.ones((7, 9))): + # pins known defect https://github.com/PolusAI/nyxus/issues/502 + assert erosions(m, values=np.full(np.pad(m, 3).shape, 5, dtype=np.uint32)) == 0, "constant ROI" + +# Whole-slide mode: the smaller side minus 3, constant images included (files in the working dir) +import tifffile +for (h, w), want in (((10, 10), 7), ((20, 20), 17), ((20, 40), 17), ((3, 9), 0)): + for const in (True, False): + img = (np.full((h, w), 5, dtype=np.uint16) if const + else np.arange(1, h * w + 1, dtype=np.uint16).reshape(h, w)) + path = f"./ero_slide_{h}x{w}.tif" + tifffile.imwrite(path, img) + got = Nyxus(["EROSIONS_2_VANISH"]).featurize_files([path], [path], single_roi=True) + # pins known defect https://github.com/PolusAI/nyxus/issues/493 + assert got["EROSIONS_2_VANISH"][0] == want, f"whole-slide {h}x{w}, constant={const}" diff --git a/docs/examples/featurize_arrays.py b/docs/examples/featurize_arrays.py new file mode 100644 index 000000000..d6a284cd3 --- /dev/null +++ b/docs/examples/featurize_arrays.py @@ -0,0 +1,42 @@ +# Example for docs/source/cmdline_and_examples.rst: features of in-memory numpy arrays. +# The page shows the lines between the "example" markers; the rest checks the result. + +# example-start +import numpy as np +from nyxus import Nyxus + +# Two 4x5 intensity images and their label images, stacked along the first axis +intens = np.array([ + [[1, 4, 4, 1, 1], + [1, 4, 6, 1, 1], + [4, 1, 6, 4, 1], + [4, 4, 6, 4, 1]], + [[1, 4, 4, 1, 1], + [1, 1, 6, 1, 1], + [1, 1, 3, 1, 1], + [4, 4, 6, 1, 1]], +]) +seg = np.array([ + [[1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1]], + [[1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [0, 1, 1, 1, 1], + [1, 1, 1, 1, 1]], +]) + +nyx = Nyxus(["*ALL_INTENSITY*"]) +features = nyx.featurize(intens, seg) +print(features[["intensity_image", "mask_image", "ROI_label", "MEAN", "MEDIAN"]]) + +# The same call with names for the images +features = nyx.featurize(intens, seg, + intensity_names=["cells_a", "cells_b"], + label_names=["cells_a_mask", "cells_b_mask"]) +print(features[["intensity_image", "mask_image", "ROI_label", "MEAN", "MEDIAN"]]) +# example-end + +assert list(features["intensity_image"]) == ["cells_a", "cells_b"] +assert np.allclose(features["MEAN"], [2.95, 2.263158], atol=1e-6) diff --git a/docs/examples/featurize_files.py b/docs/examples/featurize_files.py new file mode 100644 index 000000000..8a8dc0e56 --- /dev/null +++ b/docs/examples/featurize_files.py @@ -0,0 +1,34 @@ +# Example for docs/source/cmdline_and_examples.rst: explicit intensity/mask pairs. +# The page shows the lines between the "example" markers. Everything else creates input +# data and checks the result, so tests/python/test_docs_examples_mechanics.py can run +# the script as written. +import os + +import numpy as np +import tifffile + +rng = np.random.default_rng(0) +os.makedirs("images/intensities", exist_ok=True) +os.makedirs("images/labels", exist_ok=True) +for k in (1, 2): + mask = np.zeros((64, 64), dtype=np.uint16) + mask[4:20, 4:20] = 1 + mask[30:50, 8:24] = 2 + mask[12:40, 34:60] = 3 + tifffile.imwrite(f"images/intensities/i{k}.ome.tif", rng.integers(100, 4000, (64, 64), dtype=np.uint16)) + tifffile.imwrite(f"images/labels/m{k}.ome.tif", mask) + +# example-start +from nyxus import Nyxus + +nyx = Nyxus(["*ALL_INTENSITY*"]) +features = nyx.featurize_files( + ["images/intensities/i1.ome.tif", "images/intensities/i2.ome.tif"], + ["images/labels/m1.ome.tif", "images/labels/m2.ome.tif"], + single_roi=False) + +print(features[["intensity_image", "mask_image", "ROI_label", "MEAN"]]) +# example-end + +assert list(features["ROI_label"]) == [1, 2, 3, 1, 2, 3] +assert list(features["intensity_image"]) == ["i1.ome.tif"] * 3 + ["i2.ome.tif"] * 3 diff --git a/docs/examples/fractal_worked_example.py b/docs/examples/fractal_worked_example.py new file mode 100644 index 000000000..5fde3727f --- /dev/null +++ b/docs/examples/fractal_worked_example.py @@ -0,0 +1,169 @@ +# Example for docs/source/Math/f_fractal_dimension.rst: fractal dimensions of the page's 10 x 9 +# worked-example ROI. The page shows the lines between the "example" markers. The assertions below +# pin every value the page quotes, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +mask = np.array([[0, 0, 1, 1, 0, 0, 0, 0, 0, 0], + [0, 1, 1, 1, 1, 0, 0, 1, 1, 0], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 0], + [1, 1, 1, 1, 1, 1, 1, 1, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 0, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 0], + [0, 0, 1, 1, 1, 1, 1, 1, 1, 0], + [0, 0, 1, 1, 0, 0, 1, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 1, 1, 0]], dtype=np.uint32) +img = mask * 100 # the intensities do not matter; only the mask does + +nyx = Nyxus(["FRACT_DIM_BOXCOUNT", "FRACT_DIM_PERIMETER"]) +features = nyx.featurize(img, mask) +print(features[["FRACT_DIM_BOXCOUNT", "FRACT_DIM_PERIMETER"]]) +# example-end + +# Every value in the page's feature index, as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["fractal"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4, abs_tol=1e-12), \ + f"{code} = {got}, page says {facts['example']}" + +F = ["FRACT_DIM_BOXCOUNT", "FRACT_DIM_PERIMETER"] + + +def fd(m, codes=F): + m = np.asarray(m, dtype=np.uint32) + row = Nyxus(codes).featurize(m * 100, m) + return [row[c][0] for c in codes] + + +def check(got, quoted, label): + for g, q in zip(got, quoted): + assert math.isclose(g, q, rel_tol=5e-4, abs_tol=1e-12), f"{label}: {got}, page says {quoted}" + + +def square(n): + return np.pad(np.ones((n, n), np.uint32), 2) + + +def disk(r): + n = 2 * r + 5 + yy, xx = np.ogrid[:n, :n] + return (((xx - n // 2) ** 2 + (yy - n // 2) ** 2) <= r * r).astype(np.uint32) + + +# The divider walk at stride 1 is the PERIMETER of the contour family: 32.63 on the example +check(fd(mask, ["FRACT_DIM_PERIMETER", "PERIMETER"]), [1.131, 32.63], "example PERIMETER") + +# Only the mask matters: other intensities, including 0 inside the mask, give the same values +varied = np.where(mask > 0, np.arange(mask.size).reshape(mask.shape) % 7, 0).astype(np.uint32) +row = Nyxus(F).featurize(varied, mask) +assert [row[c][0] for c in F] == [features[c][0] for c in F] + +# Squares: box counting reaches 2 only when the side fills its power-of-two canvas or the grid +# shifts absorb the rest; the divider dimension of a square is close to 1 +check(fd(square(16)), [2.0, 1.008], "square 16") +check(fd(square(20)), [1.593, 1.006], "square 20") +check(fd(square(32)), [2.0, 1.004], "square 32") +check(fd(square(33)), [1.582, 1.0], "square 33") +check(fd(square(100)), [1.851, 1.003], "square 100") + +# Disks +check(fd(disk(8)), [1.430, 1.060], "disk 8") +check(fd(disk(40)), [1.686, 1.032], "disk 40") + +# One-pixel-wide straight lines: box counting reaches 1 only at power-of-two lengths, and the +# divider walk reads above 1 because the closing chord spans the whole line +check(fd(np.ones((1, 16))), [1.0, 1.190], "line 16") +check(fd(np.ones((1, 10))), [0.7551, 1.205], "line 10") +check(fd(np.ones((1, 100)))[:1], [0.9257], "line 100") + +# Two separate squares form one ROI with two contours; the divider walk joins them +two = np.zeros((40, 80), np.uint32) +two[5:30, 5:30] = 1 +two[5:30, 45:70] = 1 +check(fd(two), [1.423, 1.107], "two squares") +check(fd(square(25)), [1.842, 1.0], "one square") + +# A 40 x 40 square and one separate pixel. In memory an intensity of 4294967295 makes the box +# count's marker overflow its 32-bit conversion, which is undefined, so only 100 is checked here +lone = np.zeros((80, 80), np.uint32) +lone[0:40, 0:40] = 1 +lone[70, 70] = 1 +inten = lone * 100 +check([Nyxus(F).featurize(inten, lone)["FRACT_DIM_BOXCOUNT"][0]], [1.4156], "lone pixel") + +# Small ROIs +check(fd(np.ones((1, 1))), [0.0, 0.0], "1 pixel") +check(fd(np.ones((1, 2))), [0.0, 0.0], "2 pixels") +check(fd(np.ones((1, 3))), [1.0, 1.0], "1 x 3") +check(fd(np.ones((3, 3))), [2.0, 1.0], "3 x 3") + + +# The box counts of the worked example, per grid origin, and of the 20 x 20 square (S = 32) +def box_counts(m): + ys, xs = np.nonzero(m) + xs, ys = xs - xs.min(), ys - ys.min() + side = 1 << int(max(xs.max(), ys.max())).bit_length() + sizes = [side >> k for k in range(side.bit_length() - 1)] + return [[len(set(zip((xs + ox) // s, (ys + oy) // s))) for ox, oy in + ((0, 0), (s // 2, 0), (0, s // 2), (s // 2, s // 2))] for s in sizes] + + +assert box_counts(mask) == [[1, 2, 2, 4], [4, 3, 4, 4], [8, 7, 9, 8], [20, 18, 18, 21]] +assert [min(c) for c in box_counts(square(20))] == [1, 4, 9, 25, 100] +# The unshifted grid alone gives 1.3966 +s_ln, n_ln = np.log([16, 8, 4, 2]), np.log([c[0] for c in box_counts(mask)]) +assert math.isclose(-np.polyfit(s_ln, n_ln, 1)[0], 1.3966, rel_tol=5e-4) + +# Sides 2^k - 1 read exactly 2 (squares) and 1 (lines); a 2 x 2 square, like any ROI with L = 2, +# gives 0; a smooth outline can read slightly below 1 +for k in (3, 7, 15, 31, 63): + check([fd(square(k))[0], fd(np.ones((1, k)))[0]], [2.0, 1.0], f"side {k}") +assert fd(np.ones((2, 2)))[0] == 0.0 +check(fd(square(6))[1:], [0.9947], "square 6") + +# File input: out of core (ram_limit=0), anisotropy, merged labels and whole-slide mode +import tifffile + + +def from_files(m, intensity=None, single_roi=False, codes=F + ["PERIMETER"], **kw): + m = np.asarray(m, dtype=np.uint32) + tifffile.imwrite("fd_int.tif", (m * 100 if intensity is None else intensity).astype(np.uint32)) + tifffile.imwrite("fd_seg.tif", m) + table = Nyxus(codes, **kw).featurize_files([os.path.abspath("fd_int.tif")], + [os.path.abspath("fd_seg.tif")], single_roi) + return [[table[c][i] for c in codes] for i in range(len(table))] + + +check(from_files(mask, ram_limit=0)[0], [1.3966, 1.1311, 26.0], "example out of core") +# pins known defect https://github.com/PolusAI/nyxus/issues/509 +check(from_files(two, ram_limit=0)[0][1:2], [1.0], "two squares out of core") +for value in (100, 4294967295): + inten = lone * 100 + inten[70, 70] = value + check(from_files(lone, inten, ram_limit=0)[0][:1], [1.4156], f"lone pixel {value} out of core") +check(from_files(mask)[0][:2], [1.3732, 1.1311], "example, no anisotropy") +check(from_files(mask, anisotropy_x=2)[0][:2], [1.2459, 1.1731], "anisotropy_x=2") +check(from_files(mask, anisotropy_y=3)[0][:2], [1.4458, 1.2546], "anisotropy_y=3") +# With anisotropy the out-of-core contour differs, so a single-contour disk differs too +check(from_files(disk(8), anisotropy_x=2)[0][1:2], [1.1431], "disk 8, anisotropy_x=2") +# pins known defect https://github.com/PolusAI/nyxus/issues/509 +check(from_files(disk(8), anisotropy_x=2, ram_limit=0)[0][1:2], [1.0482], "disk 8, anisotropy_x=2, out of core") +labels = two.copy() +labels[5:30, 45:70] = 2 +for row in from_files(labels, (labels > 0) * 100): + check(row[:2], [1.8416, 1.0], "two labels") +check(from_files(labels, (labels > 0) * 100, mergerois=True)[0][:2], [1.4231, 1.107], "merged") +# pins known defect https://github.com/PolusAI/nyxus/issues/493: the box is 201 x 201, which rounds +# up to the same 256 canvas as 200 x 200 +check(from_files(np.ones((200, 200)), single_roi=True)[0][:2], [1.8703, 1.0], "whole slide") +# The whole-slide box is one pixel larger than the image, so a 256 x 256 image sits on a 512 canvas +# pins known defect https://github.com/PolusAI/nyxus/issues/493 +check(from_files(np.ones((256, 256)), single_roi=True)[0][:1], [1.8667], "whole slide 256") +check(fd(square(256))[:1], [2.0], "square 256") diff --git a/docs/examples/gabor_worked_example.py b/docs/examples/gabor_worked_example.py new file mode 100644 index 000000000..c8345bf9a --- /dev/null +++ b/docs/examples/gabor_worked_example.py @@ -0,0 +1,97 @@ +# Example for docs/source/Math/f_gabor.rst: GABOR on the page's worked example, the 5x5 image of the +# texture pages with its four corners left out of the mask. The page shows the lines between the +# "example" markers. The assertions below pin every value the page quotes, so the page cannot drift +# from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint32) +mask = np.ones_like(img) +mask[0, 0] = mask[0, 4] = mask[4, 0] = mask[4, 4] = 0 # the four corners are not in the ROI + +# The default pairs: frequencies 4, 16, 32, 64 at 0, 45, 90, 135 degrees +nyx = Nyxus(["GABOR"]) +features = nyx.featurize(img, mask) +print(features[["GABOR_0", "GABOR_1", "GABOR_2", "GABOR_3"]]) +# example-end + + +def columns(table): + return [float(table[c][0]) for c in table.columns if c.startswith("GABOR_")] + + +def check(got, expected, label): + assert len(got) == len(expected), f"{label}: {len(got)} GABOR columns, page says {len(expected)}" + for k, (g, e) in enumerate(zip(got, expected)): + assert math.isclose(g, e, rel_tol=5e-4, abs_tol=1e-12), f"{label} GABOR_{k} = {g}, page says {e}" + + +# The page's feature index (the value of GABOR_0), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["gabor"] +column = {"GABOR": "GABOR_0"} +for code, facts in extras.items(): + got = features[column.get(code, code)][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" + +# Every column of the worked example: 25 of 25 pixels pass in the first three filters, 23 in the +# last, over a baseline count of 24 +check(columns(features), [25 / 24, 25 / 24, 25 / 24, 23 / 24], "worked example") + +# Scaling the intensities changes nothing; adding an offset does +check(columns(Nyxus(["GABOR"]).featurize(img * 7, mask)), [25 / 24, 25 / 24, 25 / 24, 23 / 24], "x7") +check(columns(Nyxus(["GABOR"]).featurize(img + 100, mask)), [20 / 24, 25 / 24, 25 / 24, 23 / 24], "+100") + +# The default frequencies are radians per pixel: wavelengths 2 pi / f of 1.57, 0.39, 0.20 and 0.10 +# pixels, envelope widths sig2lam * wavelength of 1.26 down to 0.08 pixels +wavelengths = [2 * math.pi / f for f in (4, 16, 32, 64)] +assert [round(w, 2) for w in wavelengths] == [1.57, 0.39, 0.20, 0.10], wavelengths +assert round(0.8 * wavelengths[0], 2) == 1.26 and round(0.8 * wavelengths[-1], 2) == 0.08 +# The baseline wavelength is 62.8 pixels; settings are read in single precision +assert round(2 * math.pi / 0.1, 1) == 62.8 +stored = Nyxus(["GABOR"]).get_params("gabor_gamma", "gabor_freqs") +assert stored["gabor_gamma"] == float(np.float32(0.1)), stored +assert stored["gabor_freqs"] == [float(np.float32(f)) for f in (4, 16, 32, 64)], stored + +# IBSI mode has no effect +check(columns(Nyxus(["GABOR"], ibsi=True).featurize(img, mask)), [25 / 24, 25 / 24, 25 / 24, 23 / 24], "ibsi") + +# An odd kernel size: the response image is shifted by one pixel +# pins known defect https://github.com/PolusAI/nyxus/issues/505 +check(columns(Nyxus(["GABOR"], gabor_kersize=15).featurize(img, mask)), + [1.0417, 0.9167, 0.8333, 0.9167], "kersize 15") + +# Stripes with a period of 4 pixels, varying along x (vertical stripes) and along y (horizontal) +yy, xx = np.mgrid[0:24, 0:24] +vertical = (10 + 8 * np.cos(2 * np.pi * xx / 4)).astype(np.uint32) +horizontal = vertical.T.copy() +full = np.ones_like(vertical) +check(columns(Nyxus(["GABOR"]).featurize(vertical, full)), [1.0017, 1.0017, 1.0017, 1.0017], "vertical, defaults") +check(columns(Nyxus(["GABOR"]).featurize(horizontal, full)), [0.16696, 1.0017, 1.0017, 1.0017], + "horizontal, defaults") +matched = dict(gabor_freqs=[1.5708, 1.5708], gabor_thetas=[0, 90]) +check(columns(Nyxus(["GABOR"], **matched).featurize(vertical, full)), [1.0017, 0.38957], "vertical, pi/2") +check(columns(Nyxus(["GABOR"], **matched).featurize(horizontal, full)), [0.38957, 1.0017], "horizontal, pi/2") +check(columns(Nyxus(["GABOR"], **matched, gabor_thold=0.3).featurize(vertical, full)), [0.65913, 0.0], + "vertical, pi/2, thold 0.3") + +# A constant ROI, and a two-pixel ROI whose single counted baseline pixel gives 2 +const = np.full((5, 5), 7, dtype=np.uint32) +check(columns(Nyxus(["GABOR"]).featurize(const, mask)), [0.0, 0.0, 0.0, 0.0], "constant") +pair = np.array([[3, 8]], dtype=np.uint32) +check(columns(Nyxus(["GABOR"]).featurize(pair, np.ones_like(pair))), [2.0, 2.0, 2.0, 2.0], "two pixels") + +# The Gabor settings belong to the process: the object built last decides them for every object +first = Nyxus(["GABOR"], **matched) +check(columns(first.featurize(vertical, full)), [1.0017, 0.38957], "first object alone") +second = Nyxus(["GABOR"]) +check(columns(first.featurize(vertical, full)), [1.0017, 1.0017, 1.0017, 1.0017], "first object after second") diff --git a/docs/examples/glcm_3d_worked_example.py b/docs/examples/glcm_3d_worked_example.py new file mode 100644 index 000000000..01c3164ab --- /dev/null +++ b/docs/examples/glcm_3d_worked_example.py @@ -0,0 +1,84 @@ +# Example for docs/source/Math/f_glcm_3d.rst: 3D GLCM features of the page's 3 x 4 x 4 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, in IBSI mode and with the default settings, so the page cannot drift from the code. +# The script writes its OME-TIFF files into the current directory. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +import tifffile +from nyxus import Nyxus3D + +# 3 planes of 4 x 4 voxels, grey levels 1 to 4, indexed [z, y, x] +roi = np.array([[[1, 2, 4, 3], [2, 1, 3, 4], [4, 4, 1, 2], [3, 1, 2, 2]], + [[2, 3, 1, 1], [4, 2, 2, 3], [1, 3, 4, 4], [2, 2, 3, 1]], + [[3, 1, 2, 4], [1, 1, 4, 2], [2, 4, 3, 3], [4, 3, 1, 2]]], dtype=np.uint16) + +# place it in a 5 x 6 x 6 volume with a one-voxel background border +img = np.zeros((5, 6, 6), dtype=np.uint16) +seg = np.zeros_like(img) +img[1:4, 1:5, 1:5] = roi +seg[1:4, 1:5, 1:5] = 1 +tifffile.imwrite("int.ome.tif", img, ome=True, metadata={"axes": "ZYX"}) +tifffile.imwrite("seg.ome.tif", seg, ome=True, metadata={"axes": "ZYX"}) + +nyx = Nyxus3D(["*3D_GLCM*"], ibsi=True) +features = nyx.featurize_files([os.path.abspath("int.ome.tif")], + [os.path.abspath("seg.ome.tif")], False) +print(features[["3GLCM_ASM", "3GLCM_CONTRAST", "3GLCM_JE", "3GLCM_CONTRAST_AVE"]]) +# example-end + + +def run(intensity, mask, tag, ibsi, metaparams=(), **kwargs): + i, m = os.path.abspath(f"int_{tag}.ome.tif"), os.path.abspath(f"seg_{tag}.ome.tif") + tifffile.imwrite(i, intensity, ome=True, metadata={"axes": "ZYX"}) + tifffile.imwrite(m, mask, ome=True, metadata={"axes": "ZYX"}) + n = Nyxus3D(["*3D_GLCM*"], ibsi=ibsi, **kwargs) + for p in metaparams: + n.set_metaparam(p) + return n.featurize_files([i], [m], False) + + +def check(table, column, expected, what): + got = table[column][0] + assert math.isclose(got, expected, rel_tol=5e-4, abs_tol=1e-12), f"{what} {column} = {got}, page says {expected}" + + +# Every plain-code value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["glcm_3d"] +for code, facts in extras.items(): + check(features, code, facts["example"], "IBSI") + +# The direction average the page quotes +check(features, "3GLCM_CONTRAST_AVE", 2.5191, "IBSI") +check(features, "3GLCM_CORRELATION_AVE", -0.03872, "IBSI") + +# Default settings (64-level binning, ordered pairs) +default = run(img, seg, "default", False) +for column, expected in {"3GLCM_CONTRAST": 478.61, "3GLCM_CONTRAST_AVE": 620.39, + "3GLCM_JVAR": 257.66, "3GLCM_VARIANCE": 334.03}.items(): + check(default, column, expected, "default") + +# 3glcm/greydepth and coarse_gray_depth both set the number of levels +for table in (run(img, seg, "gd8", False, metaparams=["3glcm/greydepth=8"]), + run(img, seg, "cgd8", False, coarse_gray_depth=8)): + check(table, "3GLCM_CONTRAST", 5.7222, "8 levels") + +# 3glcm/offset is accepted but the distance stays 1 +check(run(img, seg, "off2", False, metaparams=["3glcm/offset=2"]), "3GLCM_CONTRAST", 478.61, "offset=2") + +# A mask that leaves three voxels of the bounding box out of the ROI +holes = seg.copy() +holes[1, 1, 1] = holes[1, 1, 2] = holes[3, 4, 4] = 0 +check(run(img, holes, "holes_def", False), "3GLCM_CONTRAST", 745.28, "masked default") +check(run(img, holes, "holes_ibsi", True), "3GLCM_CONTRAST", 2.0, "masked IBSI") + +# A ROI one plane thick: the (1,1,1) direction has no pairs +thin = np.zeros_like(seg) +thin[2, 1:5, 1:5] = 1 +flat = run(img, thin, "thin", True) +check(flat, "3GLCM_CONTRAST", 0.0, "one-plane") +check(flat, "3GLCM_CONTRAST_AVE", 0.67308, "one-plane") diff --git a/docs/examples/glcm_worked_example.py b/docs/examples/glcm_worked_example.py new file mode 100644 index 000000000..75fd2b976 --- /dev/null +++ b/docs/examples/glcm_worked_example.py @@ -0,0 +1,93 @@ +# Example for docs/source/Math/f_glcm.rst: GLCM features of the page's 5x5 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, in IBSI mode and with the default settings, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[1, 2, 5, 2, 3], + [3, 2, 1, 3, 1], + [1, 3, 5, 5, 2], + [1, 1, 1, 1, 2], + [1, 2, 4, 3, 5]], dtype=np.uint32) +mask = np.ones_like(img) + +nyx = Nyxus(["*ALL_GLCM*"], ibsi=True) +features = nyx.featurize(img, mask) +print(features[["GLCM_ASM_0", "GLCM_CONTRAST_0", "GLCM_JE_0", "GLCM_ASM_AVE"]]) +# example-end + +# Every 0-degree value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["glcm"] +for code, facts in extras.items(): + got = features[f"{code}_0"][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code}_0 = {got}, page says {facts['example']}" + +# Values the page quotes for the default settings (64-level binning, ordered pairs) +default = Nyxus(["*ALL_GLCM*"]).featurize(img, mask) +quoted = {"CONTRAST": 473.45, "VARIANCE": 332.44, "JVAR": 345.41, "CLUSHADE": -585.66, + "SUMVARIANCE": 882.25, "CLUTEND": 882.25} +for name, expected in quoted.items(): + got = default[f"GLCM_{name}_0"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"default GLCM_{name}_0 = {got}, page says {expected}" + +# The default-settings values the IBSI definitions give on the same 64-level matrix of ordered pairs +binned = np.minimum(np.floor(64 * img.astype(int) / 5 + 1), 64).astype(int) +P = np.zeros((64, 64)) +for r in range(5): + for c in range(4): + P[binned[r, c] - 1, binned[r, c + 1] - 1] += 1 +p = P / P.sum() +i, j = np.arange(1, 65)[:, None], np.arange(1, 65)[None, :] +mu_x, mu_y = (i * p).sum(), (j * p).sum() +assert math.isclose(((i + j - mu_x - mu_y) ** 3 * p).sum(), 9490.19, rel_tol=5e-6), "IBSI cluster shade" +k = np.arange(2, 129) +p_sum = np.array([p[(i + j) == kk].sum() for kk in k]) +sum_var = ((k - (k * p_sum).sum()) ** 2 * p_sum).sum() +assert math.isclose(sum_var, 867.43, rel_tol=5e-6), f"IBSI sum variance = {sum_var}, page says 867.43" + +# The approximate logarithm against an exact one, on the symmetric IBSI-mode matrix of each direction +def exact_entropies(dx, dy): + P = np.zeros((5, 5)) + for r in range(5): + for c in range(5): + if 0 <= r + dy < 5 and 0 <= c + dx < 5: + a, b = img[r, c] - 1, img[r + dy, c + dx] - 1 + P[a, b] += 1 + P[b, a] += 1 + p = P / P.sum() + H = lambda v: float(-(v[v > 0] * np.log2(v[v > 0])).sum()) + px, py = p.sum(1), p.sum(0) + i, j = np.arange(5)[:, None], np.arange(5)[None, :] + pxpy = np.outer(px, py) + hxy, hxy2 = H(p.ravel()), H(pxpy.ravel()) + hxy1 = float(-(p * np.log2(np.where(pxpy > 0, pxpy, 1))).sum()) + return {"JE": hxy, "SUMENTROPY": H(np.array([p[(i + j) == k].sum() for k in range(9)])), + "DIFENTRO": H(np.array([p[abs(i - j) == k].sum() for k in range(5)])), + "INFOMEAS2": math.sqrt(1 - math.exp(-2 * (hxy2 - hxy))), + "INFOMEAS1": (hxy - hxy1) / max(H(px), H(py))} + +rel = {} +for angle, (dx, dy) in zip((0, 45, 90, 135), ((1, 0), (1, 1), (0, 1), (-1, 1))): + for code, exact in exact_entropies(dx, dy).items(): + rel.setdefault(code, []).append(abs(features[f"GLCM_{code}_{angle}"][0] / exact - 1)) +assert math.isclose(exact_entropies(1, 0)["JE"], 3.80869, rel_tol=5e-6), "exact JE_0 differs from the page" +assert max(max(rel[c]) for c in ("JE", "SUMENTROPY", "DIFENTRO", "INFOMEAS2")) < 3.15e-3, rel +assert 3.75e-3 < min(rel["INFOMEAS1"]) and max(rel["INFOMEAS1"]) < 5.65e-3, rel["INFOMEAS1"] + +# Constant-ROI test: the ROI's minimum and maximum binned to 64 levels, in IBSI mode too +def glcm_contrast(image, **meta): + nyx = Nyxus(["*ALL_GLCM*"], ibsi=True) + for key, value in meta.items(): + nyx.set_metaparam(f"glcm/{key}={value}") + return nyx.featurize(image, np.ones_like(image))["GLCM_CONTRAST_0"][0] + +checker = np.array([[0, 1, 0, 1], [1, 0, 1, 0], [0, 1, 0, 1], [1, 0, 1, 0]], dtype=np.uint32) +assert glcm_contrast(checker + 1000) == 0, "a 1000/1001 ROI is not the substitute" +assert glcm_contrast(checker + 10) == 1, "a 10/11 ROI is the substitute" +assert glcm_contrast(checker + 10, greydepth=8) == 0, "glcm/greydepth does not reach the constant-ROI test" \ No newline at end of file diff --git a/docs/examples/gldm_3d_worked_example.py b/docs/examples/gldm_3d_worked_example.py new file mode 100644 index 000000000..91d48ef9a --- /dev/null +++ b/docs/examples/gldm_3d_worked_example.py @@ -0,0 +1,123 @@ +# Example for docs/source/Math/f_gldm_3d.rst: 3D GLDM features of the page's 3 x 4 x 4 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, in IBSI mode, with the default settings and with the 3gldm/greydepth metaparameter, +# so the page cannot drift from the code. The script writes its OME-TIFF files into the current +# directory. +import json +import math +import os + +# example-start +from pathlib import Path + +import numpy as np +import tifffile +from nyxus import Nyxus3D + +# A 3 x 4 x 4 ROI (slices, rows, columns) with grey levels 1 to 4, inside a 3 x 6 x 6 volume +roi = np.array([[[1, 1, 2, 3], [1, 2, 2, 3], [4, 4, 2, 1], [4, 3, 3, 1]], + [[1, 2, 2, 3], [1, 1, 3, 3], [4, 2, 2, 1], [4, 4, 3, 3]], + [[2, 2, 3, 3], [1, 1, 3, 4], [4, 4, 1, 1], [2, 4, 4, 3]]], dtype=np.uint16) +img = np.zeros((3, 6, 6), dtype=np.uint16) +img[:, 1:5, 1:5] = roi +seg = (img > 0).astype(np.uint16) + +# Nyxus3D reads volumes from files: OME-TIFF, slices along Z +int_file, seg_file = str(Path("int.ome.tif").resolve()), str(Path("seg.ome.tif").resolve()) +tifffile.imwrite(int_file, img, ome=True, metadata={"axes": "ZYX"}) +tifffile.imwrite(seg_file, seg, ome=True, metadata={"axes": "ZYX"}) + +nyx = Nyxus3D(["*3D_GLDM*"], ibsi=True) +features = nyx.featurize_files([int_file], [seg_file], False) +print(features[["3GLDM_SDE", "3GLDM_LDE", "3GLDM_DN", "3GLDM_GLV"]]) +# example-end + + +def run(volume, mask, name, metaparam=None, **kwargs): + """Featurizes one intensity/mask pair written under its own file names.""" + ip, sp = str(Path(f"{name}_int.ome.tif").resolve()), str(Path(f"{name}_seg.ome.tif").resolve()) + tifffile.imwrite(ip, volume.astype(np.uint16), ome=True, metadata={"axes": "ZYX"}) + tifffile.imwrite(sp, mask.astype(np.uint16), ome=True, metadata={"axes": "ZYX"}) + n = Nyxus3D(["*3D_GLDM*"], **kwargs) + if metaparam: + n.set_metaparam(metaparam) + return n.featurize_files([ip], [sp], False) + + +def check(table, quoted, what): + for name, expected in quoted.items(): + got = table[f"3GLDM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4, abs_tol=1e-12), \ + f"{what} 3GLDM_{name} = {got}, page says {expected}" + + +def voxels(table): + """N_z, the number of voxels in the matrix, recovered as DN / DNN.""" + return table["3GLDM_DN"][0] / table["3GLDM_DNN"][0] + + +# Every value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["gldm_3d"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" +assert round(voxels(features)) == 48 + +# The default settings (3gldm/greydepth = 0) give the IBSI values; --coarseGrayDepth has no effect +for table in (run(img, seg, "default"), run(img, seg, "coarse", coarse_gray_depth=8)): + for code in extras: + assert math.isclose(table[code][0], features[code][0], rel_tol=1e-12), f"default {code} differs from IBSI" + +# Intensities times 250: no binning by default, so the grey-level features scale; 64 levels rescale +bright = np.where(seg > 0, img * 250, 0) +check(run(bright, seg, "bright"), {"SDE": 0.06470, "LDE": 36.54, "HGLE": 455729.2}, "x250 default") +check(run(bright, seg, "bright64", "3gldm/greydepth=64"), {"SDE": 0.06470, "HGLE": 1916.8}, "x250 greydepth=64") + +# 3gldm/greydepth = -2: two PyRadiomics-style bins +check(run(img, seg, "radiomics2", "3gldm/greydepth=-2"), + {"SDE": 0.02258, "LDE": 87.21, "GLN": 24.0, "GLV": 0.25}, "greydepth=-2") + +# 3gldm/greydepth = 2: the 13 voxels of intensity 1 fall in level 1 and leave the matrix +matlab2 = run(img, seg, "matlab2", "3gldm/greydepth=2") +check(matlab2, {"SDE": 0.01297, "LDE": 146.2, "GLN": 35.0, "GLV": 0.0}, "greydepth=2") +assert round(voxels(matlab2)) == 35 +probe = Nyxus3D(["*3D_GLDM*"]) +assert probe.get_metaparam("3gldm/greydepth") == 0 +probe.set_metaparam("3gldm/greydepth=2") +assert probe.get_metaparam("3gldm/greydepth") == 2 +# ... and with greydepth = 1 every voxel does, so every feature is the no-value substitute (0) +check(run(img, seg, "matlab1", "3gldm/greydepth=1"), {"SDE": 0.0, "LDE": 0.0, "GLN": 0.0}, "greydepth=1") +# IBSI mode ignores the metaparameter +check(run(img, seg, "ibsi2", "3gldm/greydepth=2", ibsi=True), {"SDE": 0.06470, "LDE": 36.54}, "IBSI + greydepth=2") + +# Three voxels of the bounding box left out of the mask +holes = seg.copy() +holes[0, 1, 1] = holes[2, 4, 4] = holes[1, 2, 3] = 0 +masked = run(img, holes, "holes", ibsi=True) +check(masked, {"SDE": 0.07001, "LDE": 32.87}, "masked") +assert round(voxels(masked)) == 45 + +# The same three voxels given to a second ROI: ROI 1 is unchanged, so other ROIs' voxels never count +two = seg.copy() +two[0, 1, 1] = two[2, 4, 4] = two[1, 2, 3] = 2 +check(run(img, two, "two", ibsi=True), {"SDE": 0.07001, "LDE": 32.87}, "two-ROI") + +# A voxel of intensity 0 inside the mask +zero = img.copy() +zero[1, 2, 2] = 0 +zeroed = run(zero, seg, "zero", ibsi=True) +check(zeroed, {"SDE": 0.06801, "LDE": 33.64}, "zero-voxel") +assert round(voxels(zeroed)) == 47 + +# Two isolated voxels: each is counted with dependence 1 +iso = np.zeros_like(seg) +iso[0, 1, 1] = iso[2, 4, 4] = 1 +check(run(img, iso, "isolated", ibsi=True), {"SDE": 1.0, "LDE": 1.0, "HGLE": 5.0}, "isolated") + +# A constant ROI returns the no-value substitute (0) for every feature +check(run(np.where(seg > 0, 7, 0), seg, "constant", ibsi=True), + {"SDE": 0.0, "LDE": 0.0, "GLN": 0.0, "DN": 0.0}, "constant") + +# Out-of-core (a zero RAM budget) gives the in-memory values, with the metaparameter too +check(run(img, seg, "ooc", ram_limit=0), {"SDE": 0.06470, "LDE": 36.54}, "out-of-core") +check(run(img, seg, "ooc2", "3gldm/greydepth=2", ram_limit=0), {"SDE": 0.01297, "LDE": 146.2}, "out-of-core greydepth=2") diff --git a/docs/examples/gldm_worked_example.py b/docs/examples/gldm_worked_example.py new file mode 100644 index 000000000..21d9a0116 --- /dev/null +++ b/docs/examples/gldm_worked_example.py @@ -0,0 +1,61 @@ +# Example for docs/source/Math/f_gldm.rst: GLDM features of the page's 5x5 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, in IBSI mode and with the default settings, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint32) +mask = np.ones_like(img) + +nyx = Nyxus(["*ALL_GLDM*"], ibsi=True) +features = nyx.featurize(img, mask) +print(features[["GLDM_SDE", "GLDM_LDE", "GLDM_DN", "GLDM_GLV"]]) +# example-end + +# Every value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["gldm"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" + +# Values the page quotes for the default settings (64-level binning) +default = Nyxus(["*ALL_GLDM*"]).featurize(img, mask) +quoted = {"SDE": 0.3725, "GLV": 236.6, "HGLE": 1552.8, "LGLE": 0.001612} +for name, expected in quoted.items(): + got = default[f"GLDM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"default GLDM_{name} = {got}, page says {expected}" + +# Values the page quotes for a mask that leaves three pixels of the bounding box out of the ROI +holes = np.ones_like(img) +holes[0, 0] = holes[0, 1] = holes[4, 4] = 0 +masked_default = Nyxus(["*ALL_GLDM*"]).featurize(img, holes) +masked_ibsi = Nyxus(["*ALL_GLDM*"], ibsi=True).featurize(img, holes) +quoted = [(masked_default, "SDE", 0.3273), (masked_ibsi, "SDE", 0.3273), + (masked_default, "LDE", 5.818), (masked_ibsi, "LDE", 5.818)] +for table, name, expected in quoted: + got = table[f"GLDM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"masked GLDM_{name} = {got}, page says {expected}" + +# Values the page quotes for a pixel of intensity 0 inside the mask (IBSI mode) +zero = img.copy() +zero[2, 2] = 0 +zero_ibsi = Nyxus(["*ALL_GLDM*"], ibsi=True).featurize(zero, mask) +for name, expected in {"SDE": 0.4282, "LDE": 4.583}.items(): + got = zero_ibsi[f"GLDM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"zero-pixel GLDM_{name} = {got}, page says {expected}" + +# Values the page quotes for intensities 1001 to 1005, which the default binning merges into one level +narrow = Nyxus(["*ALL_GLDM*"]).featurize(img + 1000, mask) +for name, expected in {"SDE": 0.02778, "LDE": 49.0, "GLN": 25.0}.items(): + got = narrow[f"GLDM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"narrow-range GLDM_{name} = {got}, page says {expected}" diff --git a/docs/examples/gldzm_3d_worked_example.py b/docs/examples/gldzm_3d_worked_example.py new file mode 100644 index 000000000..219431e68 --- /dev/null +++ b/docs/examples/gldzm_3d_worked_example.py @@ -0,0 +1,74 @@ +# Example for docs/source/Math/f_gldzm_3d.rst: 3D GLDZM features of the page's 3 x 4 x 4 worked +# example. The page shows the lines between the "example" markers. The assertions below pin every +# value the page quotes, in IBSI mode and with the default settings, so the page cannot drift from +# the code. The volumes are written as OME-TIFF files into the current directory. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +import tifffile +from nyxus import Nyxus3D + +# grey levels, indexed (z, y, x) +vol = np.array([[[5, 1, 1, 1], + [1, 1, 1, 1], + [2, 2, 2, 2], + [2, 2, 2, 2]], + [[1, 1, 1, 1], + [1, 3, 4, 1], + [2, 3, 4, 2], + [2, 2, 2, 2]], + [[1, 1, 2, 3], + [1, 1, 2, 2], + [2, 2, 2, 4], + [2, 2, 2, 5]]], dtype=np.uint16) +mask = np.ones_like(vol) + +tifffile.imwrite("int.ome.tif", vol, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) +tifffile.imwrite("seg.ome.tif", mask, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + +nyx = Nyxus3D(["*3D_GLDZM*"], ibsi=True) +features = nyx.featurize_files(["./int.ome.tif"], ["./seg.ome.tif"], False) +print(features[["3GLDZM_SDE", "3GLDZM_LDE", "3GLDZM_ZP", "3GLDZM_ZDE"]]) +# example-end + + +def run(volume, labels, **settings): + tifffile.imwrite("int_x.ome.tif", volume, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + tifffile.imwrite("seg_x.ome.tif", labels, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + return Nyxus3D(["*3D_GLDZM*"], **settings).featurize_files(["./int_x.ome.tif"], ["./seg_x.ome.tif"], False) + + +def check(table, quoted, what): + for name, expected in quoted.items(): + got = table[f"3GLDZM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"{what} 3GLDZM_{name} = {got}, page says {expected}" + + +# Every value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["gldzm_3d"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" + +# Values the page quotes for the default settings (64-level binning) +check(run(vol, mask), {"GLM": 42.43, "GLV": 311.67, "HGLZE": 2111.9, "LGLZE": 0.001367, "ZP": 0.1458}, + "default") + +# Values the page quotes for a mask that leaves one voxel of the bounding box out of the ROI; +# the voxel's intensity does not matter, so it is set to 0 as a segmentation would leave it +hole = mask.copy() +hole[0, 1, 1] = 0 +holed = vol.copy() +holed[0, 1, 1] = 0 +quoted = {"SDE": 1.0, "ZDM": 1.0, "ZP": 0.1489} +check(run(holed, hole, ibsi=True), quoted, "masked IBSI") +check(run(holed, hole), quoted, "masked default") + +# Values the page quotes for an ROI voxel of intensity 0 in IBSI mode +zero = vol.copy() +zero[1, 1, 1] = 0 +check(run(zero, mask, ibsi=True), {"GLM": 3.875, "ZP": 0.1667}, "zero-voxel IBSI") diff --git a/docs/examples/gldzm_worked_example.py b/docs/examples/gldzm_worked_example.py new file mode 100644 index 000000000..50c59474f --- /dev/null +++ b/docs/examples/gldzm_worked_example.py @@ -0,0 +1,104 @@ +# Example for docs/source/Math/f_gldzm.rst: GLDZM features of the page's 5x5 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, in IBSI mode and with the default settings, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[1, 2, 5, 2, 3], + [3, 2, 1, 3, 1], + [1, 3, 5, 5, 2], + [1, 1, 1, 1, 2], + [1, 2, 4, 3, 5]], dtype=np.uint32) +mask = np.ones_like(img) + +nyx = Nyxus(["*ALL_GLDZM*"], ibsi=True) +features = nyx.featurize(img, mask) +print(features[["GLDZM_SDE", "GLDZM_LDE", "GLDZM_ZP", "GLDZM_ZDE"]]) +# example-end + +# Every value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["gldzm"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" + + +# The page's matrix P reproduces the zone count, zone percentage and the exact-logarithm entropy +P = np.array([[3, 1], + [4, 0], + [3, 0], + [1, 0], + [2, 1]], dtype=float) +p = P[P > 0] / P.sum() +exact = float(-(p * np.log2(p)).sum()) +assert math.isclose(features["GLDZM_ZDE"][0], exact, rel_tol=1e-12), "GLDZM_ZDE is not the exact entropy of P" +assert P.sum() == 15 and math.isclose(features["GLDZM_ZP"][0], 15 / 25), "zone count differs from the page's P" +d = np.arange(1, 3) +assert math.isclose(features["GLDZM_SDE"][0], (P.sum(0) / d**2).sum() / 15), "GLDZM_SDE differs from P" + + +def gldzm(image, roi, **kw): + return Nyxus(["*ALL_GLDZM*"], **kw).featurize(image, roi) + + +# Default 64-level binning on the worked example +default = gldzm(img, mask) +quoted = {"GLV": 341.05, "HGLZE": 1529.0, "LGLZE": 0.002177, "GLM": 34.47} +for name, expected in quoted.items(): + got = default[f"GLDZM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"default GLDZM_{name} = {got}, page says {expected}" + + +# Three pixels of the bounding box outside the mask +holes = np.ones_like(img) +holes[0, 0] = holes[0, 1] = holes[4, 4] = 0 +masked_default = gldzm(img, holes) +masked_ibsi = gldzm(img, holes, ibsi=True) +quoted = [(masked_default, "ZP", 0.6818), (masked_default, "LGLZE", 0.1351), + (masked_ibsi, "ZP", 0.5909), (masked_ibsi, "SDE", 0.8269), (masked_ibsi, "ZDM", 1.231)] +for table, name, expected in quoted: + got = table[f"GLDZM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"masked GLDZM_{name} = {got}, page says {expected}" + +# The same 64-level binning without those three pixels: the example binned by hand to levels 13, +# 26, 39, 52 and 64, with the outside pixels set to 0, in IBSI mode so that 0 stays background +binned = np.minimum(64 * img // 5 + 1, 64).astype(np.uint32) +got = gldzm(binned * holes, holes, ibsi=True)["GLDZM_LGLZE"][0] +assert math.isclose(got, 0.002038, rel_tol=5e-4), f"masked binned GLDZM_LGLZE = {got}, page says 0.002038" + +# Relation quoted on the page: GLSZM finds the same zones, so its grey level non-uniformity is 3.4 too +glszm = Nyxus(["*ALL_GLSZM*"], ibsi=True).featurize(img, mask) +assert math.isclose(glszm["GLSZM_GLN"][0], 3.4, rel_tol=5e-4), f"GLSZM_GLN = {glszm['GLSZM_GLN'][0]}" +assert math.isclose(features["GLDZM_GLNU"][0], 3.4, rel_tol=5e-4), f"GLDZM_GLNU = {features['GLDZM_GLNU'][0]}" + + +# Distance next to background: a 5x5 ROI whose centre pixel is outside the mask. The only pixel of +# level 2 touches that hole; IBSI gives its zone distance 1, Nyxus gives 2. +hole_img = np.ones((5, 5), dtype=np.uint32) +hole_img[2, 1] = 2 +hole_mask = np.ones_like(hole_img) +hole_mask[2, 2] = 0 +f = gldzm(hole_img, hole_mask, ibsi=True) +quoted = {"SDE": 0.625, "LDE": 2.5, "ZDM": 1.5, "ZP": 0.08333} +for name, expected in quoted.items(): + got = f[f"GLDZM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"hole GLDZM_{name} = {got}, page says {expected}" + +# The same ROI with the default binning: the hole becomes a zone of grey level 1 at distance 3 +f = gldzm(hole_img * 10, hole_mask) +quoted = {"ZP": 0.125, "ZDM": 2.0} +for name, expected in quoted.items(): + got = f[f"GLDZM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"default hole GLDZM_{name} = {got}, page says {expected}" + + +# Constant ROI: every feature is the missing-value substitute (0 by default) +const = np.full((4, 4), 7, dtype=np.uint32) +f = gldzm(const, np.ones_like(const), ibsi=True) +assert all(f[f"GLDZM_{c}"][0] == 0 for c in ("SDE", "ZDE", "GLV", "ZP")), "constant ROI is not the substitute" diff --git a/docs/examples/glrlm_3d_worked_example.py b/docs/examples/glrlm_3d_worked_example.py new file mode 100644 index 000000000..2f74e49c7 --- /dev/null +++ b/docs/examples/glrlm_3d_worked_example.py @@ -0,0 +1,102 @@ +# Example for docs/source/Math/f_glrlm_3d.rst: 3D GLRLM features of the page's 3x3x3 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, except the results of the 32-bit overflow on the 8 x 8 x 8 ROI, so the page cannot +# drift from the code. The volume is written as OME-TIFF files into the current directory. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +import tifffile +from nyxus import Nyxus3D + +# a 3 x 3 x 3 volume, indexed (z, y, x), with four grey levels +img = np.array([[[1, 2, 4], [3, 1, 2], [3, 3, 1]], + [[2, 2, 3], [4, 1, 3], [2, 1, 1]], + [[3, 4, 2], [2, 2, 3], [1, 2, 1]]], dtype=np.uint16) +mask = np.ones_like(img) + +tifffile.imwrite("int.ome.tif", img, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) +tifffile.imwrite("seg.ome.tif", mask, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + +nyx = Nyxus3D(["*3D_GLRLM*"], ibsi=True) +features = nyx.featurize_files([os.path.abspath("int.ome.tif")], [os.path.abspath("seg.ome.tif")], False) +print(features[["3GLRLM_SRE", "3GLRLM_RP", "3GLRLM_SRE_AVE", "3GLRLM_RP_AVE"]]) +# example-end + + +def run(img, mask, metaparam=None, **kwargs): + tifffile.imwrite("int.ome.tif", img, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + tifffile.imwrite("seg.ome.tif", mask, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + n = Nyxus3D(["*3D_GLRLM*"], **kwargs) + if metaparam: + n.set_metaparam(metaparam) + return n.featurize_files([os.path.abspath("int.ome.tif")], [os.path.abspath("seg.ome.tif")], False) + + +def check(table, column, expected, what): + got = table[column][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"{what} {column} = {got}, page says {expected}" + + +# Every plain-code value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["glrlm_3d"] +for code, facts in extras.items(): + check(features, code, facts["example"], "IBSI") + +# The direction averages the page quotes +check(features, "3GLRLM_SRE_AVE", 0.9033, "IBSI") +check(features, "3GLRLM_RP_AVE", 0.8803, "IBSI") + +# Default settings: no binning, the same values as IBSI mode; --coarseGrayDepth has no effect +for kwargs in ({}, {"coarse_gray_depth": 2}): + table = run(img, mask, **kwargs) + for code, facts in extras.items(): + check(table, code, facts["example"], f"default {kwargs}") + +# 3glrlm/greydepth=2 (MATLAB-style binning) +binned = run(img, mask, metaparam="3glrlm/greydepth=2") +check(binned, "3GLRLM_RP", 0.7407, "greydepth=2") +check(binned, "3GLRLM_GLV", 0.1875, "greydepth=2") + +# Two voxels of the bounding box outside the mask, 3glrlm/greydepth=2 +holes = np.ones_like(img) +holes[0, 0, 2] = holes[2, 0, 0] = 0 +masked = run(img, holes, metaparam="3glrlm/greydepth=2") +check(masked, "3GLRLM_RP", 0.8, "masked greydepth=2") +check(masked, "3GLRLM_LGLRE", 0.5125, "masked greydepth=2") + +# Run percentage above 1: an ellipsoid with semi-axes 12, 30 and 30 voxels in a 24 x 60 x 60 box, +# intensities drawn uniformly from 1 to 255; the bounding-box voxels outside the mask form runs +z, y, x = np.mgrid[0:24, 0:60, 0:60] +inside = ((z - 11.5) / 12) ** 2 + ((y - 29.5) / 30) ** 2 + ((x - 29.5) / 30) ** 2 <= 1 +assert inside.sum() == 45224 +noise = np.random.default_rng(0).integers(1, 256, size=inside.shape) +ellipsoid = np.where(inside, noise, 0).astype(np.uint16) +emask = inside.astype(np.uint16) +for depth, rp in ((16, 1.131), (64, 1.182), (128, 1.191)): + check(run(ellipsoid, emask, metaparam=f"3glrlm/greydepth={depth}"), "3GLRLM_RP", rp, + f"ellipsoid greydepth={depth}") +check(run(ellipsoid, emask, metaparam="3glrlm/greydepth=64"), "3GLRLM_RP_AVE", 1.130, + "ellipsoid greydepth=64") +unbinned = run(ellipsoid, emask) +check(unbinned, "3GLRLM_RP", 0.9962, "ellipsoid unbinned") +check(unbinned, "3GLRLM_RP_AVE", 0.9965, "ellipsoid unbinned") + +# A ROI that fills its bounding box: a 20 x 20 x 20 cube of the same intensities +cube = noise[:20, :20, :20].astype(np.uint16) +check(run(cube, np.ones_like(cube), metaparam="3glrlm/greydepth=64"), "3GLRLM_RP", 0.9863, + "cube greydepth=64") + +# Grey levels up to 65535 with runs of length 1 stay exact: i^2 j^2 = i^2 < 2^32. All eight voxels +# differ, so every run along direction 1 is one voxel long and LRHGLE is the mean of i^2. +levels = np.array([[[65535, 1000], [2000, 3000]], [[4000, 5000], [6000, 7000]]], dtype=np.uint16) +big = run(levels, np.ones_like(levels), ibsi=True) +assert math.isclose(big["3GLRLM_LRHGLE"][0], float((levels.astype(float) ** 2).mean()), rel_tol=1e-12) + +# Constant ROI: every 3D GLRLM column, the _AVE columns included, is the substitute (0 by default) +const = np.full((2, 2, 2), 7, dtype=np.uint16) +table = run(const, np.ones_like(const), ibsi=True) +assert all(table[c][0] == 0 for c in table.columns if c.startswith("3GLRLM_")), "constant ROI" \ No newline at end of file diff --git a/docs/examples/glrlm_worked_example.py b/docs/examples/glrlm_worked_example.py new file mode 100644 index 000000000..3bf857f7d --- /dev/null +++ b/docs/examples/glrlm_worked_example.py @@ -0,0 +1,111 @@ +# Example for docs/source/Math/f_glrlm.rst: GLRLM features of the page's 5x5 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, in IBSI mode and with the default settings, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint32) +mask = np.ones_like(img) + +nyx = Nyxus(["*ALL_GLRLM*"], ibsi=True) +features = nyx.featurize(img, mask) +print(features[["GLRLM_SRE_0", "GLRLM_LRE_0", "GLRLM_RP_0", "GLRLM_SRE_AVE"]]) +# example-end + +# Every 0-degree value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["glrlm"] +for code, facts in extras.items(): + got = features[f"{code}_0"][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code}_0 = {got}, page says {facts['example']}" + +# Values the page quotes for the default settings (64-level binning) +default = Nyxus(["*ALL_GLRLM*"]).featurize(img, mask) +quoted = {"SRE": 0.7549, "RP": 0.68, "GLV": 249.44, "HGLRE": 1756.7, "LGLRE": 0.001363} +for name, expected in quoted.items(): + got = default[f"GLRLM_{name}_0"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"default GLRLM_{name}_0 = {got}, page says {expected}" + +# Values the page quotes for a mask that leaves three pixels of the bounding box out of the ROI +holes = np.ones_like(img) +holes[0, 0] = holes[0, 1] = holes[4, 4] = 0 +masked_default = Nyxus(["*ALL_GLRLM*"]).featurize(img, holes) +masked_ibsi = Nyxus(["*ALL_GLRLM*"], ibsi=True).featurize(img, holes) +quoted = [(masked_default, "RP", 0.7273), (masked_default, "LGLRE", 0.1262), + (masked_ibsi, "RP", 0.6364)] +for table, name, expected in quoted: + got = table[f"GLRLM_{name}_0"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"masked GLRLM_{name}_0 = {got}, page says {expected}" + +# The masked values with those pixels excluded: 0-degree runs of the binned ROI pixels only +binned = np.minimum(np.floor(64 * img.astype(int) / 5 + 1), 64).astype(int) +runs = [] +for r in range(5): + c = 0 + while c < 5: + if not holes[r, c]: + c += 1 + continue + length = 1 + while c + length < 5 and holes[r, c + length] and binned[r, c + length] == binned[r, c]: + length += 1 + runs.append(binned[r, c]) + c += length +assert math.isclose(len(runs) / 22, 0.6364, rel_tol=5e-4), "excluded run percentage" +assert math.isclose(sum(1 / g**2 for g in runs) / len(runs), 0.001426, rel_tol=5e-4), "excluded LGLRE" + +# Run entropy: the approximate logarithm against an exact one on the page's matrix P +P = np.array([[1, 0, 1], [3, 0, 1], [4, 1, 1], [1, 1, 0], [3, 0, 0]], dtype=float) +p = P[P > 0] / P.sum() +exact = float(-(p * np.log2(p)).sum()) +assert math.isclose(exact, 3.0575, rel_tol=5e-5), f"exact run entropy = {exact}, page says 3.0575" +assert math.isclose(features["GLRLM_RE_0"][0], 3.0582, rel_tol=5e-5), "GLRLM_RE_0 differs from the page" + + +def glrlm(image, **kw): + image = np.asarray(image, dtype=np.uint32) + return Nyxus(["*ALL_GLRLM*"], **kw).featurize(image, np.ones_like(image)) + + +# 32-bit overflow in IBSI mode: one run of 8 pixels above eight runs of 1 pixel at levels 1 and 2 +f = glrlm([[10000] * 8, [1, 2] * 4], ibsi=True) +assert math.isclose(f["GLRLM_LRHGLE_0"][0], 2.339e8, rel_tol=5e-4), "wrapped LRHGLE" +assert math.isclose((10000**2 * 64 + 4 * 1 + 4 * 4) / 9, 7.111e8, rel_tol=5e-4), "true LRHGLE" +assert glrlm([[8192] * 8, [1, 2] * 4], ibsi=True)["GLRLM_SRLGLE_0"][0] == 0, "SRLGLE at 8192" +assert math.isclose((1 / (8192**2 * 64) + 4 + 4 / 4) / 9, 0.5556, rel_tol=5e-4), "true SRLGLE" +assert glrlm([[65535, 7, 7, 3]], ibsi=True)["GLRLM_SRLGLE_0"][0] == 0, "SRLGLE with maximum 65535" +f = glrlm([[70000, 7, 8, 3]], ibsi=True) +assert f["GLRLM_LGLRE_0"][0] == 0 and f["GLRLM_LRLGLE_0"][0] == 0, "N_g above 65536" + +# Constant ROI: every feature, the _AVE columns included, is the missing-value substitute +f = glrlm(np.full((4, 4), 7)) +assert all(f[c][0] == 0 for c in f.columns if c.startswith("GLRLM_")), "constant ROI is not the substitute" + +# Run entropy over the four directions: rows for 0, down-right for 45, columns for 90, down-left for 135 +def exact_run_entropy(dy, dx): + seen, runs = np.zeros(img.shape, bool), [] + for r in range(5): + for c in range(5): + if seen[r, c]: + continue + length, rr, cc = 0, r, c + while 0 <= rr < 5 and 0 <= cc < 5 and img[rr, cc] == img[r, c] and not seen[rr, cc]: + seen[rr, cc] = True + length, rr, cc = length + 1, rr + dy, cc + dx + runs.append((img[r, c], length)) + _, counts = np.unique(np.array(runs), axis=0, return_counts=True) + p = counts / counts.sum() + return float(-(p * np.log2(p)).sum()) + +rel = [abs(features[f"GLRLM_RE_{a}"][0] / exact_run_entropy(dy, dx) - 1) + for a, (dy, dx) in zip((0, 45, 90, 135), ((0, 1), (1, 1), (1, 0), (1, -1)))] +assert 2e-4 <= min(rel) and max(rel) <= 1.2e-3, f"run entropy relative differences {rel}" \ No newline at end of file diff --git a/docs/examples/glszm_3d_worked_example.py b/docs/examples/glszm_3d_worked_example.py new file mode 100644 index 000000000..735ca5023 --- /dev/null +++ b/docs/examples/glszm_3d_worked_example.py @@ -0,0 +1,161 @@ +# Example for docs/source/Math/f_glszm_3d.rst: 3D GLSZM features of the page's 3 x 3 x 3 worked +# example. The page shows the lines between the "example" markers. The assertions below pin every +# value the page quotes, so the page cannot drift from the code, except two groups: the results of +# the 32-bit overflow under "Very large zones", and the values under "MATLAB-style binning drops the +# lowest level" for counting the level-1 voxels as zones (4 zones, zone percentage 0.1481, small +# area emphasis 0.5283, low grey level zone emphasis 0.8125), which no setting produces. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +import tifffile +from nyxus import Nyxus3D + +# Three 3 x 3 slices, indexed (z, y, x), with grey levels 1 to 4 +vol = np.array([[[3, 3, 4], + [2, 2, 4], + [4, 3, 1]], + [[4, 2, 1], + [3, 2, 2], + [1, 1, 3]], + [[1, 2, 3], + [4, 4, 4], + [4, 3, 4]]], dtype=np.uint16) +mask = np.ones_like(vol) + +tifffile.imwrite("int.ome.tif", vol, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) +tifffile.imwrite("seg.ome.tif", mask, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + +nyx = Nyxus3D(["*3D_GLSZM*"], ibsi=True) +features = nyx.featurize_files([str(Path("int.ome.tif").resolve())], + [str(Path("seg.ome.tif").resolve())], False) +print(features[["3GLSZM_SAE", "3GLSZM_LAE", "3GLSZM_ZP", "3GLSZM_ZE"]]) +# example-end + +CODES = ["SAE", "LAE", "GLN", "GLNN", "SZN", "SZNN", "ZP", "GLV", "ZV", "ZE", + "LGLZE", "HGLZE", "SALGLE", "SAHGLE", "LALGLE", "LAHGLE"] + +# Every value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["glszm_3d"] +assert len(extras) == 16, f"the page's index has {len(extras)} codes, the family has 16" +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" + + +_run = [0] + + +def glszm3d(v, m, meta=None, **kw): + """All sixteen 3GLSZM values of one volume and mask, as {code suffix: value}.""" + _run[0] += 1 + iname, sname = Path(f"int{_run[0]}.ome.tif"), Path(f"seg{_run[0]}.ome.tif") + tifffile.imwrite(iname, v.astype(np.uint16), ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + tifffile.imwrite(sname, m.astype(np.uint16), ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + n = Nyxus3D(["*3D_GLSZM*"], **kw) + if meta: + n.set_metaparam(meta) + f = n.featurize_files([str(iname.resolve())], [str(sname.resolve())], False) + return {c: float(f[f"3GLSZM_{c}"][0]) for c in CODES} + + +def same(a, b, rel=1e-12): + return all(math.isclose(a[c], b[c], rel_tol=rel, abs_tol=1e-15) for c in CODES) + + +# The page's matrix P: 9 zones over 27 voxels, and zone entropy against an exact logarithm +P = np.array([[2, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 1], + [1, 1, 0, 0, 0, 1]], dtype=float) +assert P.sum() == 9 and math.isclose(features["3GLSZM_ZP"][0], 9 / 27), "zone count differs from the page's P" +p = P[P > 0] / P.sum() +exact = float(-(p * np.log2(p)).sum()) +assert math.isclose(exact, 2.9477, rel_tol=5e-5), f"exact zone entropy = {exact}, page says 2.9477" +assert math.isclose(features["3GLSZM_ZE"][0], 2.9485, rel_tol=5e-5), "3GLSZM_ZE differs from the page's 2.9485" + +# 26-connectivity: the level-3 voxels at (z, y, x) = (1, 1, 0) and (0, 2, 1) touch only at a corner, +# and they join two groups of three into one zone of 6. 18- or 6-connectivity would split it. +ref = glszm3d(vol, mask, ibsi=True) +assert same(ref, {c: features[f"3GLSZM_{c}"][0] for c in CODES}) +assert vol[1, 1, 0] == vol[0, 2, 1] == 3 and P[2, 5] == 1, "the corner-joined zone of level 3 and size 6" + + +def zone_count(v, max_axes): + """Zones of equal level, joining voxels whose offset changes at most `max_axes` axes by 1.""" + offsets = [d for d in np.ndindex(3, 3, 3) if 0 < sum(abs(c - 1) for c in d) <= max_axes] + seen, zones = np.zeros(v.shape, bool), 0 + for start in np.ndindex(v.shape): + if seen[start]: + continue + zones += 1 + seen[start], stack = True, [start] + while stack: + z, y, x = stack.pop() + for dz, dy, dx in offsets: + q = (z + dz - 1, y + dy - 1, x + dx - 1) + if all(0 <= q[i] < v.shape[i] for i in range(3)) and not seen[q] and v[q] == v[z, y, x]: + seen[q] = True + stack.append(q) + return zones + + +assert [zone_count(vol, k) for k in (3, 2, 1)] == [9, 10, 15], "26-, 18- and 6-connected zone counts" + +# Default settings: no binning, the same values as IBSI mode; --coarseGrayDepth has no effect +assert same(glszm3d(vol, mask), ref), "default settings differ from IBSI mode" +assert same(glszm3d(vol, mask, coarse_gray_depth=8), ref), "coarse_gray_depth changed 3GLSZM" +assert same(glszm3d(vol, mask, ibsi=True, meta="3glszm/greydepth=2"), ref), "IBSI mode did not ignore greydepth" + +# Out of core: the same values +assert same(glszm3d(vol, mask, ibsi=True, ram_limit=0), ref), "out-of-core values differ" + +# PyRadiomics-style binning, 3 bins: levels 1, 2, 3 +f = glszm3d(vol, mask, meta="3glszm/greydepth=-3") +quoted = {"ZP": 0.1852, "SAE": 0.4286, "GLV": 0.64} +for name, expected in quoted.items(): + assert math.isclose(f[name], expected, rel_tol=5e-4), f"greydepth=-3 3GLSZM_{name} = {f[name]}, page says {expected}" + +# MATLAB-style binning, 2 levels: the five voxels of intensity 1 bin to level 1 and form no zone +f = glszm3d(vol, mask, meta="3glszm/greydepth=2") +quoted = {"ZP": 1 / 27, "SAE": 0.002066, "LAE": 484.0, "LGLZE": 0.25} +for name, expected in quoted.items(): + assert math.isclose(f[name], expected, rel_tol=5e-4), f"greydepth=2 3GLSZM_{name} = {f[name]}, page says {expected}" + +# A ROI that does not fill its bounding box: outside voxels belong to no zone and are not in Np +part = mask.copy() +part[0, 0, 0] = part[1, 1, 1] = part[2, 2, 2] = 0 +f = glszm3d(vol, part, ibsi=True) +assert math.isclose(f["ZP"], 9 / 24), f"partial ROI 3GLSZM_ZP = {f['ZP']}, page says 0.375" +assert same(glszm3d(vol, part, ibsi=True, ram_limit=0), f), "partial ROI out-of-core values differ" + +# Voxels of intensity 0 inside the mask: no zone, but counted in Np +zeros = vol.copy() +zeros[0, 0, 0] = zeros[1, 1, 1] = 0 +f = glszm3d(zeros, mask, ibsi=True) +assert math.isclose(f["ZP"], 9 / 27), f"in-mask zeros 3GLSZM_ZP = {f['ZP']}, page says 0.3333" + +# Constant ROI: every feature is the missing-value substitute (0 by default) +const = np.full((2, 2, 2), 7, dtype=np.uint16) +f = glszm3d(const, np.ones_like(const), ibsi=True) +assert all(v == 0 for v in f.values()), "constant ROI is not the substitute" + +# Whole-volume mode (no mask): 3glszm/greydepth has no effect +wv = Path("wholevolume") +wv.mkdir() +tifffile.imwrite(wv / "vol.ome.tif", vol, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) +n = Nyxus3D(["*3D_GLSZM*"]) +n.set_metaparam("3glszm/greydepth=2") +f = n.featurize_directory(str(wv.resolve()), None, ".*") +assert math.isclose(f["3GLSZM_ZP"][0], 9 / 27), "whole-volume mode applied 3glszm/greydepth" + +# Reading the metaparameter back fails, although setting it works +try: + n.get_metaparam("3glszm/greydepth") +except Exception as e: + assert "unrecognized" in str(e).lower(), f"get_metaparam failed differently: {e}" +else: + raise AssertionError("get_metaparam('3glszm/greydepth') no longer fails") diff --git a/docs/examples/glszm_worked_example.py b/docs/examples/glszm_worked_example.py new file mode 100644 index 000000000..201184eae --- /dev/null +++ b/docs/examples/glszm_worked_example.py @@ -0,0 +1,127 @@ +# Example for docs/source/Math/f_glszm.rst: GLSZM features of the page's 5x5 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, in IBSI mode and with the default settings, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[1, 2, 5, 2, 3], + [3, 2, 1, 3, 1], + [1, 3, 5, 5, 2], + [1, 1, 1, 1, 2], + [1, 2, 4, 3, 5]], dtype=np.uint32) +mask = np.ones_like(img) + +nyx = Nyxus(["*ALL_GLSZM*"], ibsi=True) +features = nyx.featurize(img, mask) +print(features[["GLSZM_SAE", "GLSZM_LAE", "GLSZM_ZP", "GLSZM_ZE"]]) +# example-end + +# Every value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["glszm"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" + + +# Zone entropy: Nyxus' approximate logarithm against an exact one on the page's matrix P +P = np.array([[3, 0, 0, 0, 0, 1], + [2, 2, 0, 0, 0, 0], + [1, 2, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0], + [2, 1, 0, 0, 0, 0]], dtype=float) +p = P[P > 0] / P.sum() +exact = float(-(p * np.log2(p)).sum()) +assert math.isclose(exact, 3.0566, rel_tol=5e-4), f"exact zone entropy = {exact}, page says 3.0566" +assert math.isclose(features["GLSZM_ZE"][0], 3.0544, rel_tol=5e-5), "GLSZM_ZE differs from the page's 3.0544" +assert P.sum() == 15 and math.isclose(features["GLSZM_ZP"][0], 15 / 25), "zone count differs from the page's P" + + +def glszm(image, roi, **kw): + return Nyxus(["*ALL_GLSZM*"], **kw).featurize(image, roi) + + +# Grey level variance with the default 64-level binning +got = glszm(img, mask)["GLSZM_GLV"][0] +assert math.isclose(got, 341.05, rel_tol=5e-4), f"default GLSZM_GLV = {got}, page says 341.05" + + +# Zone growth: images whose zones are split by the east/south-east/south/south-west walk +split = np.array([[2, 2, 1], + [1, 1, 1]], dtype=np.uint32) +f = glszm(split, np.ones_like(split), ibsi=True) +quoted = {"ZP": 0.5, "SAE": 0.4537} +for name, expected in quoted.items(): + got = f[f"GLSZM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"2x3 GLSZM_{name} = {got}, page says {expected}" + +pyrad = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint32) +f = glszm(pyrad, np.ones_like(pyrad), ibsi=True) +quoted = {"ZP": 0.52, "SAE": 0.6596} +for name, expected in quoted.items(): + got = f[f"GLSZM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"PyRadiomics image GLSZM_{name} = {got}, page says {expected}" + +# Background as grey level 1: a 6x5 ROI whose four corners are outside the mask +roi_img = np.array([[3, 3, 7, 7, 7, 2], + [3, 5, 5, 7, 2, 2], + [6, 5, 5, 9, 9, 2], + [6, 6, 4, 9, 4, 4], + [1, 6, 4, 4, 4, 8]], dtype=np.uint32) +roi_mask = np.array([[0, 1, 1, 1, 1, 0], + [1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 0]], dtype=np.uint32) +default = glszm(roi_img, roi_mask) +quoted = {"ZP": 0.4, "SAE": 0.4144, "LGLZE": 0.3342} +for name, expected in quoted.items(): + got = default[f"GLSZM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"default GLSZM_{name} = {got}, page says {expected}" +ibsi = glszm(roi_img, roi_mask, ibsi=True) +assert math.isclose(ibsi["GLSZM_ZP"][0], 8 / 26, rel_tol=5e-4), f"IBSI GLSZM_ZP = {ibsi['GLSZM_ZP'][0]}" + +# Constant ROI: every feature is the missing-value substitute (0 by default) +const = np.full((4, 4), 7, dtype=np.uint32) +f = glszm(const, np.ones_like(const), ibsi=True) +assert all(f[f"GLSZM_{c}"][0] == 0 for c in ("SAE", "ZE", "GLV", "ZP")), "constant ROI is not the substitute" + +# The 8-connected values for the 2 x 3 image: one zone of 4 pixels and one of 2 +assert math.isclose(2 / 6, 0.3333, rel_tol=5e-4), "8-connected zone percentage" +assert math.isclose((1 / 16 + 1 / 4) / 2, 0.1563, rel_tol=5e-4), "8-connected small area emphasis" + +# 32-bit overflow of large area emphasis, and small area emphasis on bounding boxes of 65 536 pixels +for n, wrapped, true in ((250, -194421147, 1953062501), (300, -245057295, 4049910001)): + big = np.full((n, n), 5, dtype=np.uint32) + big[n - 1, n - 1] = 9 + f = glszm(big, np.ones_like(big), ibsi=True) + # the overflow is undefined: most builds wrap the square as a signed 32-bit integer (the page's + # value); a build may square in unsigned 32-bit arithmetic instead + unsigned = (((n * n - 1) ** 2) % 2 ** 32 + 1) / 2 + assert f["GLSZM_LAE"][0] in (wrapped, unsigned), f"{n}x{n} GLSZM_LAE = {f['GLSZM_LAE'][0]}, page says {wrapped}" + assert ((n * n - 1) ** 2 + 1) / 2 == true, f"{n}x{n} true large area emphasis" +rng = np.random.default_rng(1) +for shape, low, high in (((255, 256), 0.45, 0.55), ((300, 300), 0, 0)): + noise = rng.integers(1, 5, size=shape).astype(np.uint32) + got = glszm(noise, np.ones_like(noise), ibsi=True)["GLSZM_SAE"][0] + assert low <= got <= high, f"random texture {shape} GLSZM_SAE = {got}" + +# The 6 x 5 ROI without its corners: IBSI mode on the image binned the default way, corners set to 0 +binned = np.minimum(np.floor(64 * roi_img.astype(int) / 9) + 1, 64).astype(np.uint32) * roi_mask +f = glszm(binned, roi_mask, ibsi=True) +quoted = {"ZP": 0.3077, "SAE": 0.1215, "LGLZE": 0.001356} +for name, expected in quoted.items(): + got = f[f"GLSZM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"corners excluded GLSZM_{name} = {got}, page says {expected}" + +# Memory of the 32-bit matrix: N_g rows by one column per bounding-box pixel +assert round(4095 * 200 * 200 * 4 / 1e6) == 655 and round(64 * 300 * 300 * 4 / 1e6) == 23 \ No newline at end of file diff --git a/docs/examples/gpu_check.py b/docs/examples/gpu_check.py new file mode 100644 index 000000000..b409541c6 --- /dev/null +++ b/docs/examples/gpu_check.py @@ -0,0 +1,42 @@ +# Example for docs/source/gpu.rst: using a CUDA GPU when one is available. +# Runs on any build: without a usable GPU it computes the same features on the CPU. +# The page shows the lines between the "example" markers. +import os + +import numpy as np +import tifffile + +os.makedirs("data/int", exist_ok=True) +os.makedirs("data/seg", exist_ok=True) +rng = np.random.default_rng(0) +intens = rng.integers(100, 4000, (128, 128), dtype=np.uint16) +seg = np.zeros((128, 128), dtype=np.uint16) +seg[10:60, 10:60] = 1 +seg[70:120, 20:110] = 2 +tifffile.imwrite("data/int/cells.ome.tif", intens) +tifffile.imwrite("data/seg/cells.ome.tif", seg) + +# example-start +import nyxus +from nyxus import Nyxus + +features = ["*GEOMOMS*", "EROSIONS_2_VANISH"] +nyx = Nyxus(features) + +if nyxus.gpu_is_available(id(nyx)): + for device in nyxus.get_gpu_properties(id(nyx)): + print(device["Device number"], device["Device name"], device["Memory"]) + nyx = Nyxus(features, use_gpu_device=0) +else: + print("No CUDA GPU available: the features are computed on the CPU") + +result = nyx.featurize_directory("data/int", "data/seg") +print(result.shape) +# example-end + +assert result.shape[0] == 2 +assert "SPAT_MOMENT_00" in result.columns and "IMOM_RM_00" in result.columns +assert "EROSIONS_2_VANISH" in result.columns +assert not any(c.startswith("GABOR") for c in result.columns) +if not nyxus.gpu_is_available(id(nyx)): + assert nyxus.get_gpu_properties(id(nyx)) == [] diff --git a/docs/examples/ibsi_mode.py b/docs/examples/ibsi_mode.py new file mode 100644 index 000000000..3a5226f93 --- /dev/null +++ b/docs/examples/ibsi_mode.py @@ -0,0 +1,32 @@ +# Example for docs/source/ibsi_mode.rst: what IBSI mode changes in the results. +# The page shows the lines between the "example" markers. +import numpy as np + +# Two 64 x 64 images with grey levels 1 to 40, each with one square ROI +rng = np.random.default_rng(0) +intens = rng.integers(1, 41, (2, 64, 64)).astype(np.uint32) +seg = np.zeros_like(intens) +seg[:, 8:56, 8:56] = 1 + +# example-start +from nyxus import Nyxus + +default = Nyxus(["*ALL*"]).featurize(intens, seg) +ibsi = Nyxus(["*ALL*"], ibsi=True).featurize(intens, seg) + +# IBSI mode adds the intensity histogram family +added = [c for c in ibsi.columns if c not in default.columns] +print(len(default.columns), len(ibsi.columns), added[:3]) + +# Compare every feature column the two runs share +shared = [c for c in default.columns[9:] if c in ibsi.columns] +changed = [c for c in shared if not np.allclose(default[c], ibsi[c])] +print(sorted({c.split("_")[0] for c in changed})) +print(default.loc[0, ["MEAN", "GLCM_CONTRAST_AVE", "NGTDM_COARSENESS"]].tolist()) +print(ibsi.loc[0, ["MEAN", "GLCM_CONTRAST_AVE", "NGTDM_COARSENESS"]].tolist()) +# example-end + +assert len(added) == 46 and all(c.startswith("IH_") for c in added) +assert sorted({c.split("_")[0] for c in changed}) == ["GLCM", "GLDM", "GLDZM", "GLRLM", "GLSZM", "NGLDM", "NGTDM"] +assert default["MEAN"].equals(ibsi["MEAN"]) +assert default.loc[0, "GLCM_CONTRAST_AVE"] > 2 * ibsi.loc[0, "GLCM_CONTRAST_AVE"] diff --git a/docs/examples/image_quality.py b/docs/examples/image_quality.py new file mode 100644 index 000000000..35d320a93 --- /dev/null +++ b/docs/examples/image_quality.py @@ -0,0 +1,26 @@ +# Example for docs/source/imagequality.rst: image quality features of whole images. +# The page shows the lines between the "example" markers. +import os + +import numpy as np +import tifffile + +rng = np.random.default_rng(0) +os.makedirs("data/int", exist_ok=True) +sharp = rng.integers(0, 4000, (128, 128), dtype=np.uint16) +blurred = ((sharp[:-1, :-1].astype(np.uint32) + sharp[1:, :-1] + sharp[:-1, 1:] + sharp[1:, 1:]) // 4).astype(np.uint16) +tifffile.imwrite("data/int/sharp.ome.tif", sharp) +tifffile.imwrite("data/int/blurred.ome.tif", blurred) + +# example-start +from nyxus import ImageQuality + +# The intensity directory doubles as the label directory: each image is one ROI +imq = ImageQuality(["*ALL_IMQ*"]) +features = imq.featurize_directory("data/int", "data/int") +print(features[["intensity_image", "FOCUS_SCORE", "MAX_SATURATION", "SHARPNESS"]]) +# example-end + +focus = {os.path.basename(n.replace("\\", "/")): v for n, v in zip(features["intensity_image"], features["FOCUS_SCORE"])} +assert focus["sharp.ome.tif"] > focus["blurred.ome.tif"] +assert features["MAX_SATURATION"].between(0, 1).all() diff --git a/docs/examples/imq_worked_example.py b/docs/examples/imq_worked_example.py new file mode 100644 index 000000000..deed283eb --- /dev/null +++ b/docs/examples/imq_worked_example.py @@ -0,0 +1,148 @@ +# Example for docs/source/Math/f_image_quality.rst: image quality features of the page's 5x5 worked +# example, read from a file in whole-image mode. The page shows the lines between the "example" +# markers. The assertions below pin every value the page quotes, so the page cannot drift from the +# code. +import json +import math +import os +from pathlib import Path + +# example-start +import tempfile + +import numpy as np +import tifffile +from nyxus import ImageQuality + +img = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint16) +folder = tempfile.mkdtemp() +tifffile.imwrite(os.path.join(folder, "example.tif"), img) + +# Whole-image mode: the intensity directory doubles as the label directory +imq = ImageQuality(["*ALL_IMQ*"]) +features = imq.featurize_directory(folder, folder) +print(features[["FOCUS_SCORE", "LOCAL_FOCUS_SCORE", "MAX_SATURATION", "MIN_SATURATION", "SHARPNESS"]]) +# example-end + +# Every value in the page's feature index, as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["image_quality"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4, abs_tol=1e-12), \ + f"{code} = {got}, page says {facts['example']}" + + +def check(table, quoted, label, row=0): + for name, expected in quoted.items(): + got = table[name].iloc[row] + assert math.isclose(got, expected, rel_tol=5e-4, abs_tol=1e-12), \ + f"{label} {name} = {got}, page says {expected}" + + +def whole_image(images): + """ImageQuality in whole-image mode on {name: array}; returns {name: row}.""" + d = tempfile.mkdtemp() + for name, a in images.items(): + tifffile.imwrite(os.path.join(d, name + ".tif"), a) + table = ImageQuality(["*ALL_IMQ*"]).featurize_directory(d, d) + return {os.path.basename(p.replace("\\", "/"))[:-4]: table.iloc[[k]] + for k, p in enumerate(table["intensity_image"])} + + +# Whole-image mode scores the image plus a row and a column of 0: 36 pixels, 11 of them 0 +# pins known defect https://github.com/PolusAI/nyxus/issues/493 +check(features, {"MAX_SATURATION": 3 / 36, "MIN_SATURATION": 11 / 36}, "whole image") + +# LOCAL_FOCUS_SCORE: the four 3x3 tiles of the 6x6 image matrix, each filtered with 0 outside it +matrix = np.zeros((6, 6)) +matrix[:5, :5] = img + + +def laplacian_var(t): + p = np.pad(t, 1) + lap = p[:-2, 1:-1] + p[2:, 1:-1] + p[1:-1, :-2] + p[1:-1, 2:] - 4 * t + return lap.var() + + +tiles = [laplacian_var(matrix[r:r + 3, c:c + 3]) for r in (0, 3) for c in (0, 3)] +for got, expected in zip(tiles, (42.32, 32.89, 35.06, 16.77)): + assert math.isclose(got, expected, rel_tol=5e-4), f"tile score {got}, page says {expected}" +check(features, {"LOCAL_FOCUS_SCORE": sum(tiles) / 4, "FOCUS_SCORE": laplacian_var(matrix)}, "tiles") + +# The same 5x5 image as an array, or with a mask of ones: no extra row and column +array = ImageQuality(["*ALL_IMQ*"]).featurize(img.astype(np.uint32), np.ones((5, 5), dtype=np.uint32)) +check(array, {"FOCUS_SCORE": 30.12, "LOCAL_FOCUS_SCORE": 22.69, "POWER_SPECTRUM_SLOPE": 0.0, + "MAX_SATURATION": 0.12, "MIN_SATURATION": 0.16, "SHARPNESS": 1.384}, "array") +seg = tempfile.mkdtemp() +os.makedirs(os.path.join(seg, "int")) +os.makedirs(os.path.join(seg, "seg")) +tifffile.imwrite(os.path.join(seg, "int", "example.tif"), img) +tifffile.imwrite(os.path.join(seg, "seg", "example.tif"), np.ones_like(img)) +check(ImageQuality(["*ALL_IMQ*"]).featurize_directory(os.path.join(seg, "int"), os.path.join(seg, "seg")), + {"FOCUS_SCORE": 30.12, "MAX_SATURATION": 0.12, "MIN_SATURATION": 0.16}, "mask of ones") + +# A mask narrower than its bounding box: the 2 off-mask pixels of the 4x3 box count as 0 +plus = np.zeros((5, 5), dtype=np.uint16) +plus[1:4, 1:4] = 1 +plus[0, 2] = 1 +tifffile.imwrite(os.path.join(seg, "seg", "example.tif"), plus) +check(ImageQuality(["*ALL_IMQ*"]).featurize_directory(os.path.join(seg, "int"), os.path.join(seg, "seg")), + {"MIN_SATURATION": 2 / 12, "MAX_SATURATION": 1 / 12, "FOCUS_SCORE": 41.02}, "plus mask") + + +# A 128x128 disk blurred step by step +def blur(a, sigma): + """Separable Gaussian blur, kernel truncated at 4 sigma, edges mirrored.""" + if sigma == 0: + return a.astype(np.float64) + r = int(4 * sigma + 0.5) + x = np.arange(-r, r + 1) + k = np.exp(-x * x / (2 * sigma * sigma)) + k /= k.sum() + a = np.pad(a.astype(np.float64), r, mode="symmetric") + a = np.apply_along_axis(lambda v: np.convolve(v, k, mode="valid"), 0, a) + return np.apply_along_axis(lambda v: np.convolve(v, k, mode="valid"), 1, a) + + +yy, xx = np.mgrid[:128, :128] +disk = np.where((yy - 63.5) ** 2 + (xx - 63.5) ** 2 < 40 ** 2, 255, 0) +sigmas = (0, 1, 2, 4) +series = whole_image({f"s{s}": np.round(blur(disk, s)).astype(np.uint16) for s in sigmas}) +quoted = {"FOCUS_SCORE": (3970.0, 124.0, 17.04, 2.828), + "POWER_SPECTRUM_SLOPE": (1.178, 1.653, 1.996, 2.324), + "SHARPNESS": (4.748, 11.23, 3.755, 1.881)} +for code, values in quoted.items(): + for s, v in zip(sigmas, values): + check(series[f"s{s}"], {code: v}, f"disk sigma={s}") +check(series["s1"], {"LOCAL_FOCUS_SCORE": 1404.0}, "disk sigma=1") + +# The unblurred disk with no extra row and column (a mask of ones): the slope is not 1.178 +ones = np.ones((128, 128), dtype=np.uint32) +check(ImageQuality(["*ALL_IMQ*"]).featurize(disk.astype(np.uint32), ones), + {"POWER_SPECTRUM_SLOPE": 2.106}, "disk mask of ones") + +# A constant image (as an array, so without the extra row and column) +const = np.full((30, 30), 7, dtype=np.uint32) +check(ImageQuality(["*ALL_IMQ*"]).featurize(const, np.ones_like(const)), + {"FOCUS_SCORE": 6.098, "LOCAL_FOCUS_SCORE": 11.32, "POWER_SPECTRUM_SLOPE": 0.0, + "MAX_SATURATION": 1.0, "MIN_SATURATION": 0.0, "SHARPNESS": 0.0}, "constant") + +# Too small: LOCAL_FOCUS_SCORE needs both sides >= 2, POWER_SPECTRUM_SLOPE the shorter side >= 24 +row = np.array([[3, 7, 1]], dtype=np.uint32) +check(ImageQuality(["*ALL_IMQ*"]).featurize(row, np.ones_like(row)), + {"FOCUS_SCORE": 128.2, "LOCAL_FOCUS_SCORE": 0.0}, "1x3") +g = np.random.default_rng(1).integers(0, 50, (23, 40)).astype(np.uint32) +check(ImageQuality(["*ALL_IMQ*"]).featurize(g, np.ones_like(g)), {"POWER_SPECTRUM_SLOPE": 0.0}, "23x40") + +# Whole-image mode names the image by the path it was found under, relative if the directory is +here = os.getcwd() +os.chdir(folder) +try: + name = ImageQuality(["*ALL_IMQ*"]).featurize_directory(".", ".")["intensity_image"][0] +finally: + os.chdir(here) +assert not os.path.isabs(name) and name.replace("\\", "/").endswith("example.tif"), name diff --git a/docs/examples/intensity_3d_worked_example.py b/docs/examples/intensity_3d_worked_example.py new file mode 100644 index 000000000..cb34d7993 --- /dev/null +++ b/docs/examples/intensity_3d_worked_example.py @@ -0,0 +1,132 @@ +# Example for docs/source/Math/f_intensity_3d.rst: 3D intensity features of the page's worked +# example, a 22-voxel ROI over three slices. The page shows the lines between the "example" +# markers. The assertions below pin the worked-example values the page quotes, so the page cannot +# drift from the code; the page's Validation section names the values that were measured once and +# are not pinned. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +import tifffile +from nyxus import Nyxus3D + +# a 3 x 4 x 4 volume (Z, Y, X) +img = np.array([[[1, 2, 2, 3], [2, 4, 5, 3], [3, 5, 6, 4], [0, 3, 4, 2]], + [[2, 3, 4, 1], [3, 6, 6, 5], [4, 6, 5, 3], [2, 4, 3, 1]], + [[1, 1, 2, 0], [2, 3, 4, 2], [3, 4, 5, 3], [1, 2, 2, 0]]], dtype=np.uint16) + +# ROI 1: 4 voxels in slice 0, 12 in slice 1, 6 in slice 2 +seg = np.zeros_like(img) +seg[0, 1:3, 1:3] = 1 +seg[1] = 1 +seg[1, [0, 0, 3, 3], [0, 3, 0, 3]] = 0 +seg[2, 1:3, 1:4] = 1 + + +def save(name, volume): + # minisblack keeps a last axis of 3 or 4 from being read as colour samples + path = os.path.abspath(name) + tifffile.imwrite(path, volume, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + return path + + +nyx = Nyxus3D(["*3D_ALL_INTENSITY*"]) +features = nyx.featurize_files([save("int.ome.tif", img)], [save("seg.ome.tif", seg)], False) +print(features[["3MEAN", "3MEDIAN", "3MODE", "3P10", "3P90", "3ROBUST_MEAN", "3ENTROPY"]]) +# example-end + +# Every value in the page's feature index, as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["intensity_3d"] +assert len(extras) == 36, len(extras) +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4, abs_tol=1e-12), \ + f"{code} = {got}, page says {facts['example']}" + + +def run(volume, mask, tag, **kwargs): + return Nyxus3D(["*3D_ALL_INTENSITY*"], **kwargs).featurize_files( + [save(f"int_{tag}.ome.tif", volume)], [save(f"seg_{tag}.ome.tif", mask)], False) + + +def check(row, quoted, label): + for name, expected in quoted.items(): + got = row[name] + assert math.isclose(got, expected, rel_tol=5e-4, abs_tol=1e-12), \ + f"{label} {name} = {got}, page says {expected}" + + +one = features.iloc[0] + +# The voxel count and the values the page derives by hand +assert int((seg == 1).sum()) == 22 +check(one, {"3INTEGRATED_INTENSITY": 93, "3MEAN": 4.22727, "3MEDIAN": 4.0, "3MODE": 3.0, + "3MIN": 2.0, "3VARIANCE": 1.42208, "3P10": 3.008, "3P90": 5.978, + "3ROBUST_MEAN": 4.45455, "3ENTROPY": 2.15810, "3COVERED_IMAGE_INTENSITY_RANGE": 1.0}, + "worked example") + +# What exact percentiles would give instead (numpy.percentile, linear interpolation) +x = img[seg == 1].astype(float) +assert np.percentile(x, 10) == 3.0 and np.percentile(x, 90) == 6.0 +exact = x[(x >= 3.0) & (x <= 6.0)].mean() +assert math.isclose(exact, 4.33333, rel_tol=1e-5), exact +assert np.median(np.abs(x - np.median(x))) == 1.0 # MEDIAN_ABSOLUTE_DEVIATION is 0.9545 + +# Four coarse bins instead of 64: only entropy and uniformity change +four = run(img, seg, "four", coarse_gray_depth=4).iloc[0] +check(four, {"3ENTROPY": 1.75266, "3UNIFORMITY": 0.318182, "3P10": 3.008, "3MEAN": 4.22727}, "4 bins") + +# IBSI mode changes nothing +ibsi = run(img, seg, "ibsi", ibsi=True).iloc[0] +for code in extras: + assert ibsi[code] == one[code], code + +# Covered range with two ROIs: the denominator is the range of all ROI voxels; a voxel of 40 +# outside every ROI is not used +img2, seg2 = img.copy(), seg.copy() +img2[0, 0, 0] = 40 +seg2[0, 3, 0:2] = 2 # ROI 2 holds intensities 0 and 3 +two = run(img2, seg2, "two") +covered = dict(zip(two["ROI_label"], two["3COVERED_IMAGE_INTENSITY_RANGE"])) +assert math.isclose(covered[1], 4 / 6) and math.isclose(covered[2], 3 / 6), covered + +# A constant ROI +flat = run(np.full_like(img, 7), seg, "flat").iloc[0] +check(flat, {"3MEAN": 7, "3MODE": 7, "3P01": 7, "3P99": 7, "3ROBUST_MEAN": 7, "3UNIFORMITY": 1.0, + "3UNIFORMITY_PIU": 100.0, "3STANDARD_DEVIATION": 0.0, "3COV": 0.0, "3SKEWNESS": 0.0, + "3KURTOSIS": 0.0, "3QCOD": 0.0, "3COVERED_IMAGE_INTENSITY_RANGE": 0.0}, "constant") +assert abs(flat["3ENTROPY"]) < 1e-15 + +# Small ROIs: one voxel in each of three slices +small = np.zeros((3, 5, 5), np.uint16) +small[:, 2, 2] = [1, 2, 4] +check(run(small, (small > 0).astype(np.uint16), "small").iloc[0], + {"3SKEWNESS": 0.0, "3KURTOSIS": 0.0, "3EXCESS_KURTOSIS": 0.0, "3HYPERSKEWNESS": 0.3464, + "3HYPERFLATNESS": 0.7099}, "3 voxels") + +# A voxel of intensity 0 inside the mask is a ROI voxel +zero = img.copy() +zero[1, 1, 1] = 0 +check(run(zero, seg, "zero").iloc[0], {"3MIN": 0.0, "3MEAN": 87 / 22}, "zero in mask") + +# A signed volume read from a file is reported in its own units +neg = run(img.astype(np.int16) - 10, seg, "neg").iloc[0] +check(neg, {"3MIN": -8.0, "3MEAN": -5.77273, "3VARIANCE": 1.42208, "3UNIFORMITY_PIU": 133.333}, + "signed") + +# Intensities above 65535: each voxel's square wraps modulo 2^32 before it is summed +big = run(img.astype(np.uint32) * 20000, seg, "big").iloc[0] +# pins known defect https://github.com/PolusAI/nyxus/issues/490 +check(big, {"3MEAN": 84545.45, "3ENERGY": 48940915712.0, "3ROOT_MEAN_SQUARED": 47165.53}, "x 20000") +assert math.isclose(float((x * 20000) @ (x * 20000)), 1.692e11) + +# IBSI's coefficient of variation divides sigma_b by the mean +assert round(one["3STANDARD_DEVIATION_BIASED"] / one["3MEAN"], 4) == 0.2756 +# Exact percentiles and quartiles (numpy.percentile, linear interpolation) +exact_p = [round(float(np.percentile(x, q)), 2) for q in (1, 10, 25, 75, 90, 99)] +assert exact_p == [2.21, 3, 3, 5, 6, 6], exact_p +check(one, {"3P01": 2.009, "3P25": 3.03, "3P75": 5.028, "3P99": 5.998, "3INTERQUARTILE_RANGE": 1.998}, + "percentiles") diff --git a/docs/examples/intensity_histogram_worked_example.py b/docs/examples/intensity_histogram_worked_example.py new file mode 100644 index 000000000..c06f930a5 --- /dev/null +++ b/docs/examples/intensity_histogram_worked_example.py @@ -0,0 +1,98 @@ +# Example for docs/source/Math/f_intensity_histogram.rst: IBSI intensity histogram features of the +# page's 5x5 worked example. The page shows the lines between the "example" markers. The assertions +# below pin the worked-example values the page quotes, so the page cannot drift from the code; the +# page's Validation section names the values that were measured once and are not pinned. +import json +import math +import os +import sys +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint32) +mask = np.ones_like(img) + +# IH_* features exist only in IBSI mode; 5 bins keep the levels 1 to 5 as they are +nyx = Nyxus(["*ALL_IH*"], ibsi=True, coarse_gray_depth=5) +features = nyx.featurize(img, mask) +print(features[["IH_MEAN_IDX", "IH_MEAN_VAL", "IH_P90_IDX", "IH_MAX_GRADIENT"]]) +# example-end + +# Every value in the page's feature index, as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["intensity_histogram"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" + + +def ih(values, n_bins, **kw): + a = np.array([values], dtype=np.uint32) + return Nyxus(["*ALL_IH*"], ibsi=True, coarse_gray_depth=n_bins, **kw).featurize(a, np.ones_like(a)) + + +def check(table, quoted, label): + for name, expected in quoted.items(): + got = table[f"IH_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4, abs_tol=1e-12), \ + f"{label} IH_{name} = {got}, page says {expected}" + + +# Values the page quotes for the default 64 bins +default = Nyxus(["*ALL_IH*"], ibsi=True).featurize(img, mask) +check(default, {"NUM_BINS": 64, "BIN_SIZE": 0.0625, "MEAN_VAL": 2.824, "MEAN_IDX": 29.68, "MEDIAN_VAL": 3.031, + "MEDIAN_IDX": 33, "P90_VAL": 4.948, "P90_IDX": 64, "VARIANCE_IDX": 360.3, "ENTROPY_IDX": 2.182, + "MAX_GRADIENT": 4.5, "MAX_GRADIENT_IDX": 32}, "64 bins") + +# Without IBSI mode the family is dropped from the request, even through *ALL* +assert not [c for c in Nyxus(["*ALL_IH*"]).featurize(img, mask).columns if c.startswith("IH_")] +assert not [c for c in Nyxus(["*ALL*"]).featurize(img, mask).columns if c.startswith("IH_")] + +# A constant ROI, and fewer than 2 bins: every feature is the no-value substitute, 0 by default +const = np.full((4, 4), 7, dtype=np.uint32) +for table, label in ((Nyxus(["*ALL_IH*"], ibsi=True).featurize(const, np.ones_like(const)), "constant"), + (Nyxus(["*ALL_IH*"], ibsi=True, coarse_gray_depth=1).featurize(img, mask), "1 bin")): + assert all(table[c][0] == 0 for c in table.columns if c.startswith("IH_")), label + +# Median of an even number of pixels: the upper of the two middle bins +check(ih([1, 1, 2, 2], 2), {"MEDIAN_IDX": 2, "MEDIAN_VAL": 1.75}, "even median") + +# Mode ties go to the lowest bin +check(ih([1, 1, 3, 4, 4, 5, 5], 5), {"MODE_IDX": 1, "MODE_VAL": 1.4}, "mode tie") + +# No bin's gradient above 0: the maximum gradient keeps its seed +# pins known defect https://github.com/PolusAI/nyxus/issues/491 +check(ih([1, 2], 2), {"MAX_GRADIENT": sys.float_info.min, "MAX_GRADIENT_IDX": 0, "MIN_GRADIENT": 0, + "MIN_GRADIENT_IDX": 1}, "flat histogram") +check(ih([1, 1, 2], 2), {"MAX_GRADIENT": sys.float_info.min, "MAX_GRADIENT_IDX": 0}, "falling histogram") + +# A percentile on a bin's upper edge names the next bin, here an empty one +check(ih([1, 3, 3, 3, 3, 3, 3, 3, 3, 5], 4), {"P10_VAL": 2, "P10_IDX": 2, "ROBUST_MEAN_IDX": 3.111}, "edge percentile") + +# A value on an inner bin edge can fall one bin low +# pins known defect https://github.com/PolusAI/nyxus/issues/492 +check(ih([1, 10, 19], 14), {"MEDIAN_IDX": 7}, "bin edge") + +# Pixels of intensity 0 inside the mask take part +zero = img.copy() +zero[0, 0] = 0 +check(Nyxus(["*ALL_IH*"], ibsi=True, coarse_gray_depth=5).featurize(zero, mask), + {"MINIMUM_VAL": 0, "BIN_SIZE": 1.0}, "zero pixel") + +# The worked example minus 10, with the example's 5 bins: in memory it is shifted up by its +# minimum; read from a file it keeps its own units. The _IDX features do not change. +import tifffile +shifted = nyx.featurize(img.astype(np.int32) - 10, mask) +check(shifted, {"MINIMUM_VAL": 0, "MEAN_VAL": 1.84}, "in-memory minus 10") +tifffile.imwrite("neg_int.tif", img.astype(np.int16) - 10) +tifffile.imwrite("neg_seg.tif", mask.astype(np.uint16)) +from_file = nyx.featurize_files([os.path.abspath("neg_int.tif")], [os.path.abspath("neg_seg.tif")], False) +check(from_file, {"MINIMUM_VAL": -9, "MEAN_VAL": -7.16}, "file minus 10") +for c in (c for c in features.columns if c.startswith("IH_") and c.endswith("_IDX")): + assert math.isclose(shifted[c][0], features[c][0]) and math.isclose(from_file[c][0], features[c][0]), c diff --git a/docs/examples/intensity_worked_example.py b/docs/examples/intensity_worked_example.py new file mode 100644 index 000000000..879bb327b --- /dev/null +++ b/docs/examples/intensity_worked_example.py @@ -0,0 +1,122 @@ +# Example for docs/source/Math/f_pixel_intensity.rst: intensity features of the page's 5x5 worked +# example. The page shows the lines between the "example" markers. The assertions below pin the +# worked-example values the page quotes, so the page cannot drift from the code; the page's +# Validation section names the values that were measured once and are not pinned. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint32) +mask = np.ones_like(img) + +# HISTOGRAM is not part of *ALL_INTENSITY*; name it next to the group to get its columns +nyx = Nyxus(["*ALL_INTENSITY*", "HISTOGRAM"]) +features = nyx.featurize(img, mask) +print(features[["MEAN", "STANDARD_DEVIATION", "P25", "P75", "ENTROPY", "HISTOGRAM_BIN_0"]]) +# example-end + +# Every value in the page's feature index, as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["intensity"] +column = {"HISTOGRAM": "HISTOGRAM_BIN_0"} +for code, facts in extras.items(): + got = features[column.get(code, code)][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4, abs_tol=1e-12), \ + f"{code} = {got}, page says {facts['example']}" + + +def check(table, quoted, label): + for name, expected in quoted.items(): + got = table[name][0] + assert math.isclose(got, expected, rel_tol=5e-4, abs_tol=1e-12), \ + f"{label} {name} = {got}, page says {expected}" + + +def bins(table): + return [table[c][0] for c in table.columns if c.startswith("HISTOGRAM_BIN_")] + + +# The histogram the page shows: 64 bins, counts 4, 6, 9, 3, 3 in bins 0, 16, 32, 48 and 63 +hist = bins(features) +assert len(hist) == 64 and sum(hist) == 25, hist +assert {k: v for k, v in enumerate(hist) if v} == {0: 4, 16: 6, 32: 9, 48: 3, 63: 3}, hist + +# HISTOGRAM requested on its own: 64 columns, all 0 +alone = bins(Nyxus(["HISTOGRAM"]).featurize(img, mask)) +# pins known defect https://github.com/PolusAI/nyxus/issues/489 +assert len(alone) == 64 and not any(alone), alone + +# Four bins instead of 64: entropy, uniformity and the histogram change; nothing else does +four = Nyxus(["*ALL_INTENSITY*", "HISTOGRAM"], coarse_gray_depth=4).featurize(img, mask) +check(four, {"ENTROPY": 1.942, "UNIFORMITY": 0.2704, "P25": 2.015, "MEAN": 2.8}, "4 bins") +assert bins(four) == [4, 6, 9, 6], bins(four) + +# IBSI mode changes nothing +ibsi = Nyxus(["*ALL_INTENSITY*"], ibsi=True).featurize(img, mask) +for code in extras: + if code != "HISTOGRAM": + assert ibsi[code][0] == features[code][0], code + +# A percentile whose count p*n lands exactly on the end of a bin: 5 ones and 15 threes +v = np.array([[1] * 5 + [3] * 15], dtype=np.uint32) +check(Nyxus(["*ALL_INTENSITY*"]).featurize(v, np.ones_like(v)), {"P25": 2.98}, "boundary") +v = np.array([[1] * 6 + [3] * 14], dtype=np.uint32) +check(Nyxus(["*ALL_INTENSITY*"]).featurize(v, np.ones_like(v)), {"P25": 1.01667}, "6 ones") + +# Small ROIs: skewness needs 4 pixels and kurtosis 5; the 5th and 6th moments have no minimum +three = np.array([[1, 2, 4]], dtype=np.uint32) +check(Nyxus(["*ALL_INTENSITY*"]).featurize(three, np.ones_like(three)), + {"SKEWNESS": 0.0, "KURTOSIS": 0.0, "EXCESS_KURTOSIS": 0.0, "HYPERSKEWNESS": 0.3464, + "HYPERFLATNESS": 0.7099}, "3 pixels") + +# A constant ROI +const = np.full((4, 4), 7, dtype=np.uint32) +flat = Nyxus(["*ALL_INTENSITY*", "HISTOGRAM"]).featurize(const, np.ones_like(const)) +check(flat, {"MEAN": 7, "MODE": 7, "P01": 7, "P99": 7, "ROBUST_MEAN": 7, "UNIFORMITY": 1.0, + "UNIFORMITY_PIU": 100.0, "STANDARD_DEVIATION": 0.0, "COV": 0.0, "SKEWNESS": 0.0, + "KURTOSIS": 0.0, "HYPERSKEWNESS": 0.0, "QCOD": 0.0, "HISTOGRAM_BIN_0": 16}, "constant") +assert abs(flat["ENTROPY"][0]) < 1e-15 + +# A pixel of intensity 0 inside the mask is an ROI pixel +zero = img.copy() +zero[0, 0] = 0 +check(Nyxus(["*ALL_INTENSITY*"]).featurize(zero, mask), {"MIN": 0.0, "MEAN": 2.6}, "zero in mask") + +# In-memory arrays: negative values are shifted up by the minimum, fractions truncated +check(Nyxus(["*ALL_INTENSITY*"]).featurize(img.astype(np.int32) - 10, mask), + {"MIN": 0.0, "MAX": 4.0, "MEAN": 1.8}, "negative array") +check(Nyxus(["*ALL_INTENSITY*"]).featurize(img + 0.7, mask), {"MIN": 1.0, "MEAN": 2.8}, "float array") + +# The IBSI standardizations the page compares with: sigma_b in place of sigma +m = features.iloc[0] +sb, sd = m["STANDARD_DEVIATION_BIASED"], m["STANDARD_DEVIATION"] +cov_ibsi, hf_ibsi, hs_ibsi = sb / m["MEAN"], m["HYPERFLATNESS"] * (sd / sb) ** 6, m["HYPERSKEWNESS"] * (sd / sb) ** 5 +check(features, {"COV": 0.4374, "HYPERFLATNESS": 5.769, "HYPERSKEWNESS": 1.227}, "worked example") +assert [round(v, 4) for v in (cov_ibsi, hf_ibsi, hs_ibsi)] == [0.4286, 6.5201, 1.3588], (cov_ibsi, hf_ibsi, hs_ibsi) +gaps = [round(100 * (b - a) / b) for a, b in ((m["COV"], cov_ibsi), (m["HYPERSKEWNESS"], hs_ibsi), + (m["HYPERFLATNESS"], hf_ibsi))] +assert gaps == [-2, 10, 12], gaps + +# Grey levels above 65535: each pixel's square wraps modulo 2^32 before it is summed +big = Nyxus(["*ALL_INTENSITY*"]).featurize(img * 20000, mask) +# pins known defect https://github.com/PolusAI/nyxus/issues/490 +check(big, {"MEAN": 56000.0, "ENERGY": 5.4145e10, "ROOT_MEAN_SQUARED": 46538.28}, "x 20000") +x = img.astype(float).ravel() * 20000 +assert math.isclose(x @ x, 9.28e10) and math.isclose(math.sqrt(x @ x / 25), 60926.18, rel_tol=1e-6) + +# UNIFORMITY_PIU of a ROI from -99 to -95 read from a file, in the image's own units +import tifffile +ct = np.array([[-99, -98, -97, -96, -95]], dtype=np.int16) +tifffile.imwrite("ct_int.tif", ct) +tifffile.imwrite("ct_seg.tif", np.ones_like(ct, dtype=np.uint16)) +piu = Nyxus(["*ALL_INTENSITY*"]).featurize_files([os.path.abspath("ct_int.tif")], + [os.path.abspath("ct_seg.tif")], False) +check(piu, {"MIN": -99.0, "MAX": -95.0, "UNIFORMITY_PIU": 102.06}, "CT") diff --git a/docs/examples/moments_worked_example.py b/docs/examples/moments_worked_example.py new file mode 100644 index 000000000..4ad0bd089 --- /dev/null +++ b/docs/examples/moments_worked_example.py @@ -0,0 +1,151 @@ +# Example for docs/source/Math/f_2dmoments.rst: shape and intensity moments of the page's 7x8 worked +# example. The page shows the lines between the "example" markers. The assertions below pin every +# value the page quotes, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[0, 0, 6, 2, 5, 0, 0, 0], + [0, 3, 7, 4, 1, 5, 2, 0], + [4, 1, 2, 6, 3, 7, 4, 1], + [5, 7, 3, 1, 6, 2, 5, 3], + [2, 4, 6, 5, 2, 4, 1, 0], + [0, 6, 1, 3, 7, 5, 0, 0], + [0, 0, 3, 2, 4, 0, 0, 0]], dtype=np.uint32) +mask = (img > 0).astype(np.uint32) + +nyx = Nyxus(["*GEOMOMS*"]) +features = nyx.featurize(img, mask) +print(features[["SPAT_MOMENT_00", "SPAT_MOMENT_10", "IMOM_RM_00", "HU_M1", "IMOM_HU1"]]) +# example-end + + +def same(got, expected, rel=5e-4): + if expected == 0: + return abs(got) <= 1e-9 + return math.isclose(got, expected, rel_tol=rel) + + +# Every value in the page's two feature indexes, as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8")) +codes = [] +for family in ("shape_moments", "intensity_moments"): + for code, facts in extras[family].items(): + got = features[code][0] + assert same(got, facts["example"]), f"{code} = {got}, page says {facts['example']}" + codes.append(code) +assert len(codes) == 180 + +# Coordinates are measured from the bounding-box corner: the same ROI placed elsewhere in a larger +# image gives the same 180 values +big_img = np.zeros((14, 16), dtype=np.uint32) +big_img[3:10, 5:13] = img +moved = nyx.featurize(big_img, (big_img > 0).astype(np.uint32)) +for code in codes: + assert math.isclose(moved[code][0], features[code][0], rel_tol=1e-9, abs_tol=1e-9), code + +# Values the page quotes for the worked example +assert features["SPAT_MOMENT_00"][0] == 40 and features["IMOM_RM_00"][0] == 150 +assert same(features["SPAT_MOMENT_10"][0] / features["SPAT_MOMENT_00"][0], 3.275) +assert same(features["SPAT_MOMENT_01"][0] / features["SPAT_MOMENT_00"][0], 2.925) + +# 48 x 40 rectangle: Nyxus, and the same moment with exact distances to the contour as traced +# (one pixel right of and below the ROI's border) and to the ROI's own border pixels +H, W = 40, 48 +rect = np.ones((H, W), dtype=np.uint32) +w00 = Nyxus(["WEIGHTED_SPAT_MOMENT_00"]).featurize(rect, rect)["WEIGHTED_SPAT_MOMENT_00"][0] +yy, xx = np.mgrid[0:H, 0:W] +on_border = (xx == 0) | (yy == 0) | (xx == W - 1) | (yy == H - 1) +bx, by = xx[on_border].astype(float), yy[on_border].astype(float) +px, py = xx.ravel().astype(float)[:, None], yy.ravel().astype(float)[:, None] + + +def weight_sum(cx, cy): + d = np.sqrt(np.min((px - cx[None, :]) ** 2 + (py - cy[None, :]) ** 2, axis=1)) + return float(np.sum(np.log(d + 0.001))) + + +assert same(w00, 2498.7), w00 +assert same(weight_sum(bx + 1, by + 1), 2422.7) +assert same(weight_sum(bx, by), 1821.3) + +# Digital disks: the sum of the weights changes sign between radius 5 and radius 8, and the +# odd-order normalized weighted moments follow it +for r, m00, ncm30 in ((5, -68.94, 0.0), (8, 6.806, 7881.0), (20, 1549.1, 0.007713)): + n = 2 * r + 3 + yy, xx = np.mgrid[0:n, 0:n] + disk = (((yy - r - 1) ** 2 + (xx - r - 1) ** 2) <= r * r).astype(np.uint32) + got = Nyxus(["WEIGHTED_SPAT_MOMENT_00", "WT_NORM_CTR_MOM_30"]).featurize(disk, disk) + assert same(got["WEIGHTED_SPAT_MOMENT_00"][0], m00), (r, got["WEIGHTED_SPAT_MOMENT_00"][0]) + assert same(got["WT_NORM_CTR_MOM_30"][0], ncm30, rel=2e-3), (r, got["WT_NORM_CTR_MOM_30"][0]) + +# Intensities times 10 +scaled = nyx.featurize(img * 10, mask) +assert same(features["IMOM_HU1"][0], 0.04106) and same(scaled["IMOM_HU1"][0], 0.004106) +assert same(scaled["IMOM_NCM_20"][0] / features["IMOM_NCM_20"][0], 0.1) +assert same(scaled["IMOM_HU2"][0] / features["IMOM_HU2"][0], 0.01) +assert same(scaled["IMOM_NRM_10"][0] / features["IMOM_NRM_10"][0], 10 ** -0.5) + +# A ROI whose intensities are all 0, and a single-pixel ROI +zero = nyx.featurize(np.zeros((5, 5), dtype=np.uint32), np.pad(np.ones((3, 3), dtype=np.uint32), 1)) +assert all(zero[c][0] == 0 for c in extras["intensity_moments"]) +assert zero["SPAT_MOMENT_00"][0] == 9 +dot = np.zeros((3, 3), dtype=np.uint32) +dot[1, 1] = 7 +single = nyx.featurize(dot, (dot > 0).astype(np.uint32)) +assert same(single["WEIGHTED_SPAT_MOMENT_00"][0], -6.908) +assert single["CENTRAL_MOMENT_20"][0] == 0 and single["NORM_CENTRAL_MOMENT_20"][0] == 0 + +# mu01 and mu10 are rounding residues of order 1e-14 +for code in ("CENTRAL_MOMENT_01", "CENTRAL_MOMENT_10", "IMOM_CM_01", "IMOM_CM_10"): + assert abs(features[code][0]) < 1e-13, (code, features[code][0]) + +# The 48 x 40 rectangle has 172 border pixels; 85 ROI pixels lie on the traced (shifted) contour +on_traced = (bx + 1 < W) & (by + 1 < H) +assert len(bx) == 172 and on_traced.sum() == 85 + +# Out of core (ram_limit=0, file input): the weighted moments of a 245-pixel ellipse depend on where +# its bounding box starts; the 118 unweighted moments do not +import tifffile + + +def from_files(mask_image, **kw): + tifffile.imwrite("mom_int.tif", (mask_image * 5).astype(np.uint16)) + tifffile.imwrite("mom_seg.tif", mask_image.astype(np.uint16)) + return Nyxus(["*GEOMOMS*"], **kw).featurize_files( + [os.path.abspath("mom_int.tif")], [os.path.abspath("mom_seg.tif")], False) + + +def ellipse(n, cx, cy): + yy, xx = np.mgrid[0:n, 0:n] + return ((((xx - cx) / 10) ** 2 + ((yy - cy) / 8) ** 2) <= 1).astype(np.uint16) + + +weighted = [c for c in codes if c.startswith(("WEIGHTED_", "WT_NORM_", "IMOM_W"))] +assert len(weighted) == 62 +# pins known defect https://github.com/PolusAI/nyxus/issues/509 +for n, cx, cy, corner, ooc in ((30, 12, 10, (2, 2), 7.484), (26, 13, 13, (3, 5), 58.50), + (40, 20, 20, (10, 12), 338.2)): + e = ellipse(n, cx, cy) + ys, xs = np.nonzero(e) + assert e.sum() == 245 and (xs.min(), ys.min()) == corner + mem, out = from_files(e), from_files(e, ram_limit=0) + assert same(mem["WEIGHTED_SPAT_MOMENT_00"][0], 49.81) + assert same(out["WEIGHTED_SPAT_MOMENT_00"][0], ooc), (corner, out["WEIGHTED_SPAT_MOMENT_00"][0]) + for code in codes: + if code not in weighted: + assert math.isclose(out[code][0], mem[code][0], rel_tol=1e-9, abs_tol=1e-9), code +# At the image origin: the same ellipse still differs, the rectangle does not +e = ellipse(30, 12, 10)[2:, 2:] +# pins known defect https://github.com/PolusAI/nyxus/issues/509 +assert same(from_files(e, ram_limit=0)["WEIGHTED_SPAT_MOMENT_00"][0], 43.75) +corner_rect = np.zeros((50, 60), dtype=np.uint16) +corner_rect[:40, :48] = 1 +mem, out = from_files(corner_rect), from_files(corner_rect, ram_limit=0) +for code in codes: + assert math.isclose(out[code][0], mem[code][0], rel_tol=1e-9, abs_tol=1e-9), code diff --git a/docs/examples/morphology_worked_example.py b/docs/examples/morphology_worked_example.py new file mode 100644 index 000000000..5f4c72a39 --- /dev/null +++ b/docs/examples/morphology_worked_example.py @@ -0,0 +1,190 @@ +# Example for docs/source/Math/f_morphology.rst: 2D morphology features of the page's worked +# example, a notched ROI (label 1) with a small rectangle touching it (label 2). The page shows the +# lines between the "example" markers. The assertions below pin every value the page quotes, so the +# page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +roi = """ +..XXX...XXX. +.XXXX...XXXX +XXXXX...XXXX +XXXXX...XXXX +XXXXX...XXXX +XXXXXXXXXXXX +XXXXXXXXXXXX +XXXXXXXXXXXX +XXXXXXXXXXXX +XXXXXXXXXXX. +.XXXXXXXXX.. +..XXXXXXX... +""".split() + +mask = np.zeros((14, 17), dtype=np.uint32) +mask[1:13, 1:13] = [[c == "X" for c in row] for row in roi] # label 1: x 1..12, y 1..12 +mask[4:9, 13:16] = 2 # label 2: x 13..15, y 4..8 +y, x = np.mgrid[0:14, 0:17] +img = (1 + (3 * x + 5 * y) % 9).astype(np.uint32) # grey levels 1 to 9 + +nyx = Nyxus(["*ALL_MORPHOLOGY*"]) +features = nyx.featurize(img, mask) +print(features[["ROI_label", "AREA_PIXELS_COUNT", "PERIMETER", "CONVEX_HULL_AREA", + "ORIENTATION", "STAT_FERET_DIAM_MAX", "NUM_NEIGHBORS", "PERCENT_TOUCHING"]]) +# example-end + +# Every value in the page's feature indexes, as recorded in docs/feature_extras.toml (ROI 1) +# Some of these values pin known defects; feature_extras.toml names the issue above each of them. +FAMILIES = ["basic_morphology", "contour", "convex_hull", "ellipse", "circles", "roi_radius", "extrema", + "euler", "geodetic", "feret", "martin", "nassenstein", "chords", "neighbors"] +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8")) +one = features[features["ROI_label"] == 1].reset_index(drop=True) +two = features[features["ROI_label"] == 2].reset_index(drop=True) +for family in FAMILIES: + assert extras[family], family + for code, facts in extras[family].items(): + got = one[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4, abs_tol=1e-12), \ + f"{code} = {got}, page says {facts['example']}" + + +def check(table, quoted, label): + for name, expected in quoted.items(): + got = table[name][0] + assert math.isclose(got, expected, rel_tol=5e-4, abs_tol=1e-9), \ + f"{label} {name} = {got}, page says {expected}" + + +def run(image, labels, **kwargs): + t = Nyxus(["*ALL_MORPHOLOGY*"], **kwargs).featurize(image, labels) + return t.sort_values("ROI_label").reset_index(drop=True) + + +# The worked example as the page walks through it +check(one, {"POLYGONALITY_AVE": -1, "HEXAGONALITY_AVE": -1, "EDGE_INTEGRATED_INTENSITY": 228}, "ROI 1") +assert round(one["EDGE_INTEGRATED_INTENSITY"][0] / one["EDGE_MEAN_INTENSITY"][0]) == 44 # contour pixels +# pins known defect https://github.com/PolusAI/nyxus/issues/497 +check(two, {"AREA_PIXELS_COUNT": 15, "PERIMETER": 12, "CIRCULARITY": 1.144, "ASPECT_RATIO": 0.6, + "ORIENTATION": 90, "CONVEX_HULL_AREA": 15, "PERCENT_TOUCHING": 41.67, + "CLOSEST_NEIGHBOR1_ANG": 174.4, "DIAMETER_MIN_ENCLOSING_CIRCLE": 4.4723, + "DIAMETER_CIRCUMSCRIBING_CIRCLE": 10, "DIAMETER_INSCRIBING_CIRCLE": 2, + "ROI_RADIUS_MEAN": 0.6943, "ROI_RADIUS_MAX": 1.414, "ROI_RADIUS_MEDIAN": 1, + "MAXCHORDS_MIN": 2, "MAXCHORDS_MEAN": 3.722}, "ROI 2") + +# Settings that change nothing in memory: IBSI mode, the grey-level depth, the anisotropy keywords +cols = list(features.columns) +codes = cols[cols.index("AREA_PIXELS_COUNT"):] +for kwargs in ({"ibsi": True}, {"coarse_gray_depth": 8}, {"anisotropy_x": 2.0}): + other = run(img, mask, **kwargs) + for code in codes: + assert np.allclose(other[code], features.sort_values("ROI_label")[code]), (kwargs, code) + +# AREA_UM2 is 0 whatever the resolution +# pins known defect https://github.com/PolusAI/nyxus/issues/496 +assert run(img, mask, pixels_per_micron=2.0)["AREA_UM2"].tolist() == [0, 0] + +# ORIENTATION: a bar running down and to the right in the image is at about +45 degrees +bar = np.zeros((20, 20), dtype=np.uint32) +for k in range(2, 17): + bar[k, k - 1:k + 2] = 1 +check(run(bar * 5, bar), {"ORIENTATION": 44.49}, "diagonal bar") + +# A ROI with all-zero intensities: weighted centroid (0, 0), mass displacement = |centroid| +zero = img.copy() +zero[mask == 2] = 0 +z2 = run(zero, mask).iloc[[1]].reset_index(drop=True) +check(z2, {"WEIGHTED_CENTROID_X": 0, "WEIGHTED_CENTROID_Y": 0, "MASS_DISPLACEMENT": 15.23, + "EDGE_MEAN_INTENSITY": 0, "MAXCHORDS_MAX": 0}, "zero-intensity ROI") + +# One pixel of intensity 0 inside ROI 1 splits chords +hole = img.copy() +hole[9, 6] = 0 +check(run(hole, mask), {"AREA_PIXELS_COUNT": 116, "MAXCHORDS_MEAN": 8.8, "ALLCHORDS_MEAN": 5.197}, + "zero pixel") + +# PERIMETER of a ROI with a hole, and of a label in two pieces +sq = np.zeros((13, 13), dtype=np.uint32) +sq[2:11, 2:11] = 1 +sq[5:8, 5:8] = 0 +check(run(sq * 4, sq), {"PERIMETER": 50.01, "EULER_NUMBER": 0}, "holed square") +pieces = np.zeros((10, 16), dtype=np.uint32) +pieces[2:7, 2:6] = 1 +pieces[2:7, 10:14] = 1 +check(run(pieces * 4, pieces), {"PERIMETER": 42.12, "EULER_NUMBER": 2}, "two pieces") +one_piece = np.zeros((10, 10), dtype=np.uint32) +one_piece[2:7, 2:6] = 1 +check(run(one_piece * 4, one_piece), {"PERIMETER": 14}, "one piece") + +# A 1 x 13 bar: hull of two vertices, Nassenstein 0, Martin only rounding residue below 1e-6 +line = np.zeros((7, 20), dtype=np.uint32) +line[3, 3:16] = 1 +flat = run(line * 100, line) +for stat in ("MIN", "MAX", "MEAN", "MEDIAN", "STDDEV", "MODE"): + assert flat[f"STAT_NASSENSTEIN_DIAM_{stat}"][0] == 0, stat + assert 0 <= flat[f"STAT_MARTIN_DIAM_{stat}"][0] < 1e-6, stat + +# The neighbour gate is the gap between contours: ROI 2 moved right, 3 pixels from ROI 1 +gap = mask.copy() +gap[gap == 2] = 0 +gap[4:9, 15:17] = 2 +for distance, expected in ((2, 0), (3, 1)): + g = run(img, gap, neighbor_distance=distance) + assert g["NUM_NEIGHBORS"].tolist() == [expected, expected], (distance, g["NUM_NEIGHBORS"].tolist()) +check(run(img, gap, neighbor_distance=3), {"CLOSEST_NEIGHBOR1_DIST": 9.126, "PERCENT_TOUCHING": 0}, "gap 3") + +# Two neighbours on either side of the x axis: linear mean, n-1 deviation of 2 angles is 0 +scene = np.zeros((30, 30), dtype=np.uint32) +scene[12:17, 10:15] = 1 +scene[9:12, 18:23] = 2 +scene[17:20, 18:23] = 3 +# pins known defect https://github.com/PolusAI/nyxus/issues/499 +check(run(scene * 2, scene), {"NUM_NEIGHBORS": 2, "CLOSEST_NEIGHBOR1_ANG": 333.4, "CLOSEST_NEIGHBOR2_ANG": 26.57, + "ANG_BW_NEIGHBORS_MEAN": 180, "ANG_BW_NEIGHBORS_STDDEV": 0, + "ANG_BW_NEIGHBORS_MODE": 27}, "two neighbours") + +# A disk with four neighbours: polygonality and hexagonality +yy, xx = np.mgrid[0:40, 0:40] +disk = np.zeros((40, 40), dtype=np.uint32) +disk[(xx - 20) ** 2 + (yy - 20) ** 2 <= 36] = 1 +disk[14:27, 28:32] = 2 +disk[14:27, 8:12] = 3 +disk[8:12, 14:27] = 4 +disk[28:32, 16:24] = 5 +dimg = (1 + (2 * xx + 7 * yy) % 11).astype(np.uint32) +check(run(dimg, disk), {"NUM_NEIGHBORS": 4, "POLYGONALITY_AVE": 8.484, "HEXAGONALITY_AVE": 9.223, + "HEXAGONALITY_STDDEV": 0.08352, "ANG_BW_NEIGHBORS_MEAN": 135.8}, "disk") + +# Codes that need companions: requested without the family that computes their inputs +# pins known defect https://github.com/PolusAI/nyxus/issues/495 + + +def alone(codes, image=img, labels=mask): + t = Nyxus(codes).featurize(image, labels) + return t[t["ROI_label"] == 1].reset_index(drop=True) + + +check(alone(["MAJOR_AXIS_LENGTH", "ORIENTATION", "ROUNDNESS"]), + {"MAJOR_AXIS_LENGTH": 39.36, "ORIENTATION": 45.93, "ROUNDNESS": 0}, "ellipse codes alone") +assert alone(["ELONGATION"])["ELONGATION"][0] == 0 +circle_codes = ["DIAMETER_MIN_ENCLOSING_CIRCLE", "DIAMETER_CIRCUMSCRIBING_CIRCLE", "DIAMETER_INSCRIBING_CIRCLE"] +assert (alone(circle_codes)[circle_codes].to_numpy() == 0).all() +check(alone(circle_codes + ["PERIMETER"]), + {"DIAMETER_MIN_ENCLOSING_CIRCLE": 14.17, "DIAMETER_CIRCUMSCRIBING_CIRCLE": 35.61}, "circles with PERIMETER") +hex_codes = ["HEXAGONALITY_AVE", "HEXAGONALITY_STDDEV"] +assert (alone(hex_codes, dimg, disk)[hex_codes].to_numpy() == 0).all() + +# Whole-slide mode on a 64 x 48 image: the box is one pixel larger than the image +# pins known defect https://github.com/PolusAI/nyxus/issues/493 +import tifffile + +tifffile.imwrite("whole.tif", np.full((48, 64), 7, np.uint32)) +whole = Nyxus(["*ALL_MORPHOLOGY*"]).featurize_files([os.path.abspath("whole.tif")], + [os.path.abspath("whole.tif")], True) +check(whole, {"PERIMETER": 224, "EXTREMA_P3_X": 64, "EXTREMA_P3_Y": -1, "EXTREMA_P4_X": 64, + "EXTREMA_P4_Y": -1, "EXTREMA_P5_X": -1, "EXTREMA_P5_Y": 48, "EXTREMA_P6_X": -1, + "EXTREMA_P6_Y": 48}, "whole slide 64 x 48") diff --git a/docs/examples/nested_rois.py b/docs/examples/nested_rois.py new file mode 100644 index 000000000..7570da566 --- /dev/null +++ b/docs/examples/nested_rois.py @@ -0,0 +1,27 @@ +# Example for docs/source/cmdline_and_examples.rst: features of child ROIs and the +# parent/child relations between two label channels. The page shows the lines between the +# "example" markers. The input images are copied from the test data that the example names. +import os +import shutil + +data = os.environ["NYXUS_DOCS_DATA"] # tests/python/data in the repository +for sub in ("int", "seg"): + os.makedirs(sub, exist_ok=True) + for c in (0, 1): + shutil.copy(os.path.join(data, sub, f"p0_y1_r1_c{c}.ome.tif"), sub) + +# example-start +from nyxus import Nyxus, Nested + +# Channel 0 holds the child ROIs, channel 1 the parent ROIs +nyx = Nyxus(["*ALL_INTENSITY*"]) +child_features = nyx.featurize_directory("int", "seg", file_pattern=r"p[0-9]_y[0-9]_r[0-9]_c0\.ome\.tif") +print(child_features[["mask_image", "ROI_label", "MEAN"]].head()) + +nest = Nested() +relations = nest.find_relations("seg", r"p.*_c1\.ome\.tif", r"p.*_c0\.ome\.tif") +print(relations.columns.tolist()) +# example-end + +assert len(child_features) == 66 +assert relations.columns.tolist() == ["Image", "Parent_Label", "Child_Label"] diff --git a/docs/examples/ngldm_3d_worked_example.py b/docs/examples/ngldm_3d_worked_example.py new file mode 100644 index 000000000..8c356a330 --- /dev/null +++ b/docs/examples/ngldm_3d_worked_example.py @@ -0,0 +1,117 @@ +# Example for docs/source/Math/f_ngldm_3d.rst: 3D NGLDM features of the page's 3 x 3 x 3 worked +# example. The page shows the lines between the "example" markers. The assertions below pin every +# value the page quotes, in IBSI mode and with the default settings, so the page cannot drift from +# the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +import tifffile +from nyxus import Nyxus3D + +# Three slices of 3 x 3 voxels, (Z, Y, X), grey levels 1 to 4 +vol = np.array([[[1, 1, 2], + [1, 2, 2], + [4, 3, 3]], + [[1, 1, 2], + [3, 2, 2], + [4, 4, 3]], + [[1, 2, 2], + [3, 3, 4], + [4, 4, 1]]], dtype=np.uint16) +mask = np.ones_like(vol) + +tifffile.imwrite("int.ome.tif", vol, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) +tifffile.imwrite("seg.ome.tif", mask, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + +nyx = Nyxus3D(["*3D_NGLDM*"], ibsi=True) +features = nyx.featurize_files([os.path.abspath("int.ome.tif")], + [os.path.abspath("seg.ome.tif")], False) +print(features[["3NGLDM_LDE", "3NGLDM_HDE", "3NGLDM_GLNU", "3NGLDM_DCENT"]]) +# example-end + +_n = [0] + + +def run(v, m, **kw): + """Featurize volume v with label volume m; files go to the current directory.""" + _n[0] += 1 + a, b = os.path.abspath(f"int{_n[0]}.ome.tif"), os.path.abspath(f"seg{_n[0]}.ome.tif") + tifffile.imwrite(a, v.astype(np.uint16), ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + tifffile.imwrite(b, m.astype(np.uint16), ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + return Nyxus3D(["*3D_NGLDM*"], **kw).featurize_files([a], [b], False) + + +def check(df, code, expected, what): + got = df[code].iloc[0] + assert math.isclose(got, expected, rel_tol=5e-4), f"{what} {code} = {got}, page says {expected}" + + +# Every value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["ngldm_3d"] +for code, facts in extras.items(): + check(features, code, facts["example"], "IBSI") + +# Values the page quotes for the default settings (64 levels: grey level floor(64 x / 4)) +default = run(vol, mask) +for name, expected in {"GLM": 38.52, "GLV": 308.3, "HGLCE": 1792.0, "LGLCE": 0.001453, "LDE": 0.08747}.items(): + check(default, f"3NGLDM_{name}", expected, "default") + +# Out of core (ram_limit=0 sends every ROI down the oversized path): the same values +for ibsi in (True, False): + ooc = run(vol, mask, ibsi=ibsi, ram_limit=0) + ref = features if ibsi else default + for code in extras: + assert math.isclose(ooc[code].iloc[0], ref[code].iloc[0], rel_tol=1e-12, abs_tol=1e-15), \ + f"out-of-core (ibsi={ibsi}) {code} = {ooc[code].iloc[0]}, in memory {ref[code].iloc[0]}" + +# A mask that leaves three voxels of the bounding box out of the ROI: the same in both modes, +# because voxels outside the mask are neither centres nor neighbours +holes = mask.copy() +holes[0, 0, 0] = holes[1, 1, 1] = holes[2, 2, 2] = 0 +for ibsi in (True, False): + masked = run(vol, holes, ibsi=ibsi) + check(masked, "3NGLDM_LDE", 0.06111, f"masked (ibsi={ibsi})") + check(masked, "3NGLDM_HDE", 20.75, f"masked (ibsi={ibsi})") + +# The same ROI inside a larger volume whose other voxels all have intensity 4: those neighbours +# are outside the mask, so every value equals the worked example's +big = np.full((5, 5, 5), 4, dtype=np.uint16) +big[1:4, 1:4, 1:4] = vol +bseg = np.zeros_like(big) +bseg[1:4, 1:4, 1:4] = 1 +embedded = run(big, bseg, ibsi=True) +for code in extras: + assert math.isclose(embedded[code].iloc[0], features[code].iloc[0], rel_tol=1e-12), \ + f"embedded {code} = {embedded[code].iloc[0]}, worked example {features[code].iloc[0]}" + +# Grey level 0: one ROI voxel of intensity 0 in IBSI mode, and one voxel below x_max / 64 with the +# default binning. Its voxels count in N_s but not in the low-grey-level numerators. +zero = vol.copy() +zero[1, 1, 1] = 0 +check(run(zero, mask, ibsi=True), "3NGLDM_LGLCE", 0.3627, "in-mask zero") +low = vol * 20 +low[0, 0, 0] = 1 +check(run(low, mask), "3NGLDM_LGLCE", 0.001308, "low-intensity") + +# Constant ROI: every feature is the missing-value substitute, 0 by default +const = run(np.full_like(vol, 7), mask, ibsi=True) +for code in extras: + assert const[code].iloc[0] == 0, f"constant ROI {code} = {const[code].iloc[0]}" + +# The 3D family has no metaparameter of its own +try: + Nyxus3D(["*3D_NGLDM*"]).set_metaparam("3ngldm/greydepth=8") +except Exception: + pass +else: + raise AssertionError("3ngldm/greydepth was accepted") + +# Relation quoted on the page: 3GLDM_SDE is the same as 3NGLDM_LDE on the worked example +gldm_a, gldm_b = os.path.abspath("int.ome.tif"), os.path.abspath("seg.ome.tif") +gldm = Nyxus3D(["*3D_GLDM*"], ibsi=True).featurize_files([gldm_a], [gldm_b], False) +assert math.isclose(gldm["3GLDM_SDE"].iloc[0], features["3NGLDM_LDE"].iloc[0], rel_tol=1e-12) +check(gldm, "3GLDM_DE", 3.557, "3D GLDM") diff --git a/docs/examples/ngldm_worked_example.py b/docs/examples/ngldm_worked_example.py new file mode 100644 index 000000000..493d75aa2 --- /dev/null +++ b/docs/examples/ngldm_worked_example.py @@ -0,0 +1,69 @@ +# Example for docs/source/Math/f_ngldm.rst: NGLDM features of the page's 5x5 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, in IBSI mode and with the default settings, so the page cannot drift from the code. +# The values for a negative or zero --coarseGrayDepth are not: they are command-line only. The +# grey-level ladders under "NGLDM converts grey levels differently" are the formulas evaluated on +# the page, not Nyxus output. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint32) +mask = np.ones_like(img) + +nyx = Nyxus(["*ALL_NGLDM*"], ibsi=True) +features = nyx.featurize(img, mask) +print(features[["NGLDM_LDE", "NGLDM_HDE", "NGLDM_GLNU", "NGLDM_DCENT"]]) +# example-end + +# Every value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["ngldm"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" + +# Values the page quotes for the default settings (64 levels, grey level floor(64 x / 5)) +default = Nyxus(["*ALL_NGLDM*"]).featurize(img, mask) +quoted = {"GLM": 35.4, "GLV": 243.36, "HGLCE": 1496.5, "LGLCE": 0.00182, "LDE": 0.3725, "HDHGLE": 5608.0} +for name, expected in quoted.items(): + got = default[f"NGLDM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"default NGLDM_{name} = {got}, page says {expected}" + +# Values the page quotes for a mask that leaves three pixels of the bounding box out of the ROI: +# the same in both modes, because pixels outside the mask are never counted +holes = np.ones_like(img) +holes[0, 0] = holes[0, 1] = holes[4, 4] = 0 +for ibsi in (True, False): + masked = Nyxus(["*ALL_NGLDM*"], ibsi=ibsi).featurize(img, holes) + for name, expected in {"LDE": 0.3273, "HDE": 5.818}.items(): + got = masked[f"NGLDM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4), f"masked (ibsi={ibsi}) NGLDM_{name} = {got}, page says {expected}" + +# Grey level 0: one ROI pixel of intensity 0 in IBSI mode, and one pixel below x_max / 64 with +# the default binning. Its pixels count in N_s but not in the low-grey-level numerators. +zero = img.copy() +zero[2, 2] = 0 +got = Nyxus(["*ALL_NGLDM*"], ibsi=True).featurize(zero, mask)["NGLDM_LGLCE"][0] +assert math.isclose(got, 0.2323, rel_tol=5e-4), f"in-mask zero NGLDM_LGLCE = {got}, page says 0.2323" +# leaving the level-0 pixel out of N_s as well would scale it by 25 / 24 +assert math.isclose(got * 25 / 24, 0.2420, rel_tol=5e-4) +low = img * 20 +low[0, 0] = 1 +got = Nyxus(["*ALL_NGLDM*"]).featurize(low, mask)["NGLDM_LGLCE"][0] +assert math.isclose(got, 0.00181, rel_tol=5e-4), f"low-intensity NGLDM_LGLCE = {got}, page says 0.00181" +assert math.isclose(got * 25 / 24, 0.00189, rel_tol=5e-3) + + +# Relation to GLDM quoted on the page: same matrix in IBSI mode, entropy differs by the GLDM log2 +gldm = Nyxus(["*ALL_GLDM*"], ibsi=True).featurize(img, mask) +assert math.isclose(gldm["GLDM_SDE"][0], features["NGLDM_LDE"][0], rel_tol=1e-12) +assert math.isclose(gldm["GLDM_DE"][0], 3.380, rel_tol=5e-4), f"GLDM_DE = {gldm['GLDM_DE'][0]}, page says 3.380" diff --git a/docs/examples/ngtdm_3d_worked_example.py b/docs/examples/ngtdm_3d_worked_example.py new file mode 100644 index 000000000..ad6d2aa9e --- /dev/null +++ b/docs/examples/ngtdm_3d_worked_example.py @@ -0,0 +1,109 @@ +# Example for docs/source/Math/f_ngtdm_3d.rst: 3D NGTDM features of the page's 3 x 3 x 3 worked +# example. The page shows the lines between the "example" markers. The assertions below pin every +# value the page quotes, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +import tifffile +from nyxus import Nyxus3D + +# planes z = 0, 1, 2 of a 3 x 3 x 3 ROI that fills its bounding box +vol = np.array([[[1, 2, 2], [3, 1, 4], [2, 2, 3]], + [[4, 3, 1], [2, 4, 3], [1, 3, 2]], + [[2, 1, 3], [4, 2, 1], [3, 4, 4]]], dtype=np.uint16) +mask = np.ones_like(vol) + + +def write(name, arr): + path = str(Path(name).resolve()) + tifffile.imwrite(path, arr, ome=True, photometric="minisblack", metadata={"axes": "ZYX"}) + return path + + +nyx = Nyxus3D(["*3D_NGTDM*"]) +features = nyx.featurize_files([write("int.ome.tif", vol)], [write("seg.ome.tif", mask)], False) +print(features[["3NGTDM_COARSENESS", "3NGTDM_CONTRAST", "3NGTDM_BUSYNESS"]]) +# example-end + +# Every value in the page's feature index, as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["ngtdm_3d"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" + +_n = [0] + + +def run(img, seg, ibsi=False, metaparams=()): + _n[0] += 1 + n = Nyxus3D(["*3D_NGTDM*"], ibsi=ibsi) + for m in metaparams: + n.set_metaparam(m) + return n.featurize_files([write(f"i{_n[0]}.ome.tif", img)], [write(f"s{_n[0]}.ome.tif", seg)], False) + + +def check(table, quoted, label): + for name, expected in quoted.items(): + got = table[f"3NGTDM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4, abs_tol=1e-12), \ + f"{label} 3NGTDM_{name} = {got}, page says {expected}" + + +# IBSI mode: grey levels are lifted by one, which moves busyness only +check(run(vol, mask, ibsi=True), {"COARSENESS": 0.1549, "CONTRAST": 0.1889, "BUSYNESS": 1.502, + "COMPLEXITY": 5.241, "STRENGTH": 0.7111}, "IBSI") + +# Busyness depends on the grey levels themselves: every intensity shifted by 9 +check(run(vol + 9, mask), {"BUSYNESS": 0.9682, "COARSENESS": 0.1549}, "shifted") +check(run(vol + 9, mask, ibsi=True), {"BUSYNESS": 0.8984}, "shifted IBSI") + +# --coarseGrayDepth does not reach the 3D family +check(Nyxus3D(["*3D_NGTDM*"], coarse_gray_depth=8).featurize_files( + [write("cg_i.ome.tif", vol)], [write("cg_s.ome.tif", mask)], False), + {"COARSENESS": 0.1549, "BUSYNESS": 1.477}, "coarse_gray_depth=8") + +# 3ngtdm/greydepth=64 (MATLAB-style binning) and 3ngtdm/radius=2 +check(run(vol, mask, metaparams=["3ngtdm/greydepth=64"]), + {"COARSENESS": 0.009831, "CONTRAST": 734.1, "BUSYNESS": 1.485, "COMPLEXITY": 1294.3, + "STRENGTH": 11.13}, "greydepth=64") +check(run(vol, mask, metaparams=["3ngtdm/radius=2"]), + {"COARSENESS": 0.1583, "CONTRAST": 0.1860, "BUSYNESS": 1.445, "COMPLEXITY": 5.173, + "STRENGTH": 0.7222}, "radius=2") + +# The eight corner voxels left out of the mask: they still take part, as grey level 1 +corners = np.ones_like(vol) +corners[::2, ::2, ::2] = 0 +for ibsi in (False, True): + check(run(vol, corners, ibsi=ibsi), + {"COARSENESS": 0.1015, "CONTRAST": 0.3376, "BUSYNESS": 1.663, "COMPLEXITY": 15.68, + "STRENGTH": 1.024}, f"corners off (ibsi={ibsi})") + +# A centre voxel of intensity 0 inside the mask is counted as grey level 1 +zero = vol.copy() +zero[1, 1, 1] = 0 +check(run(zero, mask, ibsi=True), {"COARSENESS": 0.1750, "CONTRAST": 0.1286}, "in-ROI zero") + +# A constant ROI: every feature is the substitute (0 in Python) when it fills its box, in every +# binning mode but IBSI; computed when it does not fill its box +const = np.full((3, 3, 3), 7, dtype=np.uint16) +for ibsi in (False, True): + check(run(const, mask, ibsi=ibsi), + {"COARSENESS": 0.0, "CONTRAST": 0.0, "BUSYNESS": 0.0, "COMPLEXITY": 0.0, "STRENGTH": 0.0}, + f"constant (ibsi={ibsi})") + check(run(const, corners, ibsi=ibsi), + {"COARSENESS": 0.02793, "CONTRAST": 31.52, "BUSYNESS": 3.357, "COMPLEXITY": 18.57, + "STRENGTH": 1.176}, f"constant, corners off (ibsi={ibsi})") +for depth in (64, -8): + check(run(const, mask, metaparams=[f"3ngtdm/greydepth={depth}"]), + {"COARSENESS": 0.0, "CONTRAST": 0.0, "BUSYNESS": 0.0, "COMPLEXITY": 0.0, "STRENGTH": 0.0}, + f"constant (greydepth={depth})") + +# A single-voxel ROI +single = np.zeros_like(vol) +single[1, 1, 1] = 1 +check(run(const, single), {"COARSENESS": 0.0, "CONTRAST": 0.0, "BUSYNESS": 0.0, "COMPLEXITY": 0.0, + "STRENGTH": 0.0}, "single voxel") diff --git a/docs/examples/ngtdm_worked_example.py b/docs/examples/ngtdm_worked_example.py new file mode 100644 index 000000000..ca9004f72 --- /dev/null +++ b/docs/examples/ngtdm_worked_example.py @@ -0,0 +1,76 @@ +# Example for docs/source/Math/f_ngtdm.rst: NGTDM features of the page's 5x5 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, in IBSI mode and with the default settings, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint32) +mask = np.ones_like(img) + +nyx = Nyxus(["*ALL_NGTDM*"], ibsi=True) +features = nyx.featurize(img, mask) +print(features[["NGTDM_COARSENESS", "NGTDM_CONTRAST", "NGTDM_BUSYNESS"]]) +# example-end + +# Every value in the page's feature index (IBSI mode), as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["ngtdm"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4), f"{code} = {got}, page says {facts['example']}" + + +def check(table, quoted, label): + for name, expected in quoted.items(): + got = table[f"NGTDM_{name}"][0] + assert math.isclose(got, expected, rel_tol=5e-4, abs_tol=1e-12), \ + f"{label} NGTDM_{name} = {got}, page says {expected}" + + +# Values the page quotes for the default settings (64-level binning) +default = Nyxus(["*ALL_NGTDM*"]).featurize(img, mask) +check(default, {"COARSENESS": 0.01551, "CONTRAST": 309.1, "BUSYNESS": 0.6357, "COMPLEXITY": 1359.7, + "STRENGTH": 17.47}, "default") + +# Values the page quotes for a mask that leaves three pixels of the bounding box out of the ROI +holes = np.ones_like(img) +holes[0, 0] = holes[0, 1] = holes[4, 4] = 0 +check(Nyxus(["*ALL_NGTDM*"]).featurize(img, holes), {"COARSENESS": 0.01324, "CONTRAST": 351.2}, "masked default") +check(Nyxus(["*ALL_NGTDM*"], ibsi=True).featurize(img, holes), {"CONTRAST": 0.1302}, "masked IBSI") + +# The same 64-level binning without those three pixels: the example binned by hand to levels 13, +# 26, 39, 52 and 64, with the outside pixels set to 0, in IBSI mode so that 0 stays background +binned = np.minimum(64 * img // 5 + 1, 64).astype(np.uint32) +check(Nyxus(["*ALL_NGTDM*"], ibsi=True).featurize(binned * holes, holes), + {"COARSENESS": 0.01685, "CONTRAST": 277.7}, "masked binned") + +# The worked example with one more ROI pixel, of intensity 7, that has no ROI neighbour +wide = np.zeros((5, 7), dtype=np.uint32) +wide[:, :5] = img +wide[2, 6] = 7 +lone = Nyxus(["*ALL_NGTDM*"], ibsi=True).featurize(wide, (wide > 0).astype(np.uint32)) +check(lone, {"CONTRAST": 0.09734, "COARSENESS": 0.2006, "STRENGTH": 1.373, + "BUSYNESS": features["NGTDM_BUSYNESS"][0], "COMPLEXITY": features["NGTDM_COMPLEXITY"][0]}, + "isolated pixel") + +# A block of 1s plus an isolated 2: no grey-tone difference anywhere, and only one level with p_i > 0 +block = np.array([[1, 1, 0, 0], [1, 1, 0, 0], [0, 0, 0, 2]], dtype=np.uint32) +flat = Nyxus(["*ALL_NGTDM*"], ibsi=True).featurize(block, (block > 0).astype(np.uint32)) +check(flat, {"COARSENESS": 0.0, "CONTRAST": 0.0, "BUSYNESS": 0.0, "COMPLEXITY": 0.0, + "STRENGTH": 0.0}, "zero differences") + +# A constant ROI +const = np.full((4, 4), 7, dtype=np.uint32) +for ibsi in (True, False): + check(Nyxus(["*ALL_NGTDM*"], ibsi=ibsi).featurize(const, np.ones_like(const)), + {"COARSENESS": 0.0, "CONTRAST": 0.0, "BUSYNESS": 0.0, "COMPLEXITY": 0.0, "STRENGTH": 0.0}, + f"constant (ibsi={ibsi})") diff --git a/docs/examples/out_of_core.py b/docs/examples/out_of_core.py new file mode 100644 index 000000000..fb804ca1f --- /dev/null +++ b/docs/examples/out_of_core.py @@ -0,0 +1,39 @@ +# Example for docs/source/large_images.rst: forcing ROIs out of core with ram_limit. +# The page shows the lines between the "example" markers. Everything else creates input +# data and checks the result, so tests/python/test_docs_examples_mechanics.py can run +# the script as written. +import os + +import numpy as np +import tifffile + +rng = np.random.default_rng(3) +os.makedirs("large/int", exist_ok=True) +os.makedirs("large/seg", exist_ok=True) +mask = np.zeros((96, 96), dtype=np.uint16) +mask[4:30, 6:40] = 1 +mask[40:90, 10:35] = 2 +mask[20:80, 50:92] = 3 +tifffile.imwrite("large/int/tissue.ome.tif", rng.integers(100, 4000, (96, 96), dtype=np.uint16)) +tifffile.imwrite("large/seg/tissue.ome.tif", mask) + +# example-start +from nyxus import Nyxus + +features = ["*ALL_INTENSITY*", "*ALL_GLCM*"] + +# Default budget: half of the RAM available when the instance is created +in_ram = Nyxus(features).featurize_directory("large/int", "large/seg", ".*") + +# A budget of 0 MB makes every ROI oversized, so each one is processed out of core +out_of_core = Nyxus(features, ram_limit=0).featurize_directory("large/int", "large/seg", ".*") + +print(in_ram[["ROI_label", "MEAN", "GLCM_CONTRAST_AVE"]]) +print(out_of_core[["ROI_label", "MEAN", "GLCM_CONTRAST_AVE"]]) +# example-end + +a = in_ram.sort_values("ROI_label").reset_index(drop=True) +b = out_of_core.sort_values("ROI_label").reset_index(drop=True) +assert list(a["ROI_label"]) == [1, 2, 3] +for column in a.columns[9:]: + assert np.allclose(a[column], b[column], rtol=1e-9, equal_nan=True), column diff --git a/docs/examples/output_columns.py b/docs/examples/output_columns.py new file mode 100644 index 000000000..19fbf2c9a --- /dev/null +++ b/docs/examples/output_columns.py @@ -0,0 +1,42 @@ +# Example for docs/source/output.rst: the layout of the result table. +# The page shows the lines between the "example" markers. +import os + +import numpy as np +import tifffile + +# Two image pairs: each label image holds two ROIs +os.makedirs("data/int", exist_ok=True) +os.makedirs("data/seg", exist_ok=True) +rng = np.random.default_rng(0) +for name in ("plate1_well_A1.ome.tif", "plate1_well_B2.ome.tif"): + intens = rng.integers(100, 4000, (64, 64), dtype=np.uint16) + seg = np.zeros((64, 64), dtype=np.uint16) + seg[4:28, 4:28] = 1 + seg[36:60, 30:62] = 2 + tifffile.imwrite("data/int/" + name, intens) + tifffile.imwrite("data/seg/" + name, seg) + +# example-start +from nyxus import Nyxus + +nyx = Nyxus(["MEAN", "AREA_PIXELS_COUNT"]) + +# One row per ROI +features = nyx.featurize_directory("data/int", "data/seg") +print(features.columns.tolist()) +print(features[["intensity_image", "ROI_label", "MEAN", "AREA_PIXELS_COUNT"]]) + +# One row per image: the mean over each image's ROIs +per_image = features.groupby("intensity_image")[["MEAN", "AREA_PIXELS_COUNT"]].mean() +print(per_image) +# example-end + +assert features.columns.tolist()[:9] == ["intensity_image", "mask_image", "phys_unit", "ROI_label", + "t_index", "c_index", "phys_x", "phys_y", "phys_z"] +assert features.columns.tolist()[9:] == ["MEAN", "AREA_PIXELS_COUNT"] +assert len(features) == 4 and sorted(features["ROI_label"].unique()) == [1, 2] +assert features["ROI_label"].dtype == np.uint32 +assert (features["t_index"] == 0).all() and (features["c_index"] == 0).all() +assert (features["phys_x"] == 1).all() +assert len(per_image) == 2 diff --git a/docs/examples/radial_worked_example.py b/docs/examples/radial_worked_example.py new file mode 100644 index 000000000..17edb0436 --- /dev/null +++ b/docs/examples/radial_worked_example.py @@ -0,0 +1,210 @@ +# Example for docs/source/Math/f_radial_distribution.rst: radial distribution features of the +# page's 47-pixel ROI. The page shows the lines between the "example" markers. The assertions below +# pin every value the page quotes, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +mask = np.array([[0, 0, 1, 1, 1, 1, 0, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0], + [1, 1, 1, 1, 1, 1, 1, 1, 0], + [1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1, 1, 0], + [0, 0, 1, 1, 1, 0, 0, 0, 0]], dtype=np.uint32) +img = np.array([[0, 0, 3, 4, 4, 3, 0, 0, 0], + [0, 3, 5, 6, 6, 5, 3, 2, 0], + [2, 4, 6, 8, 9, 7, 5, 3, 0], + [3, 5, 7, 9, 9, 8, 6, 4, 2], + [2, 4, 6, 8, 8, 7, 5, 3, 2], + [0, 3, 4, 6, 6, 5, 4, 2, 0], + [0, 0, 2, 3, 3, 0, 0, 0, 0]], dtype=np.uint32) + +nyx = Nyxus(["FRAC_AT_D", "MEAN_FRAC", "RADIAL_CV"]) +features = nyx.featurize(img, mask) +for code in ("FRAC_AT_D", "MEAN_FRAC", "RADIAL_CV"): + print(code, features[[f"{code}_{k}" for k in range(8)]].to_numpy().round(4)) +# example-end + +CODES = ("FRAC_AT_D", "MEAN_FRAC", "RADIAL_CV") + + +def vec(table, code): + return np.array([table[f"{code}_{k}"][0] for k in range(8)]) + + +# Every value in the page's feature index, as recorded in docs/feature_extras.toml; the example of +# a multi-column code is its first column, ring 0 +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["radial"] +for code, facts in extras.items(): + got = features[f"{code}_0"][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4, abs_tol=1e-12), \ + f"{code}_0 = {got}, page says {facts['example']}" + +# The 24 values of the worked-example table +quoted = {"FRAC_AT_D": [0.02128, 0.08511, 0.08511, 0.2128, 0.06383, 0.1702, 0.1277, 0.2340], + "MEAN_FRAC": [7.0, 6.5, 6.25, 5.6, 4.667, 4.375, 3.833, 3.455], + "RADIAL_CV": [2.646, 1.052, 1.079, 0.7284, 1.532, 0.9558, 1.223, 1.571]} +for code, values in quoted.items(): + for k, (got, want) in enumerate(zip(vec(features, code), values)): + assert math.isclose(got, want, rel_tol=5e-4), f"{code}_{k} = {got}, page says {want}" + + +def formulas(img, mask, offset=1): + """The page's formulas: contour = ROI pixels with a 4-neighbour outside the ROI, moved by + (offset, offset); full scans for the centre and the radius. Returns the three vectors, the + centre (x, y), R^2 and the pixel count of each ring.""" + m = mask > 0 + xs, ys = np.nonzero(m.T) # pixels column by column, as Nyxus.featurize + inten = img[ys, xs].astype(float) + p = np.pad(m, 1) + edge = m & ~(p[:-2, 1:-1] & p[2:, 1:-1] & p[1:-1, :-2] & p[1:-1, 2:]) + ky, kx = np.nonzero(edge) + d2 = (xs[:, None] - kx[None, :] - offset) ** 2 + (ys[:, None] - ky[None, :] - offset) ** 2 + o = int(np.argmin(d2.max(1) - d2.min(1))) + r2 = d2[o].max() + d = np.hypot(xs - xs[o], ys - ys[o]) + ring = np.minimum((d / math.sqrt(r2) * 7).astype(int), 7) + theta = np.mod(np.arctan2(ys - ys[o], xs - xs[o]), 2 * np.pi) + wedge = (theta / (np.pi / 4)).astype(int) + n = np.bincount(ring, minlength=8) + s = np.bincount(ring, weights=inten, minlength=8) + w = np.zeros((8, 8)) + np.add.at(w, (ring, wedge), inten) + mu = w.mean(1) + cv = np.sqrt(((w - mu[:, None]) ** 2).mean(1)) / (mu + 1e-9) + out = {"FRAC_AT_D": n / (len(xs) + 1e-9), "MEAN_FRAC": s / (n + 1e-9), "RADIAL_CV": cv} + return out, (int(xs[o]), int(ys[o])), r2, n.tolist() + + +# The formulas reproduce Nyxus on the worked example +mine, centre, r2, n = formulas(img, mask) +for code in CODES: + assert np.allclose(mine[code], vec(features, code), rtol=1e-12, atol=1e-12), code +assert centre == (5, 4) and r2 == 17 and n == [1, 4, 4, 10, 3, 8, 6, 11] +d = np.hypot(*(np.nonzero(mask.T) - np.array(centre)[:, None])) +assert (d > math.sqrt(17) + 1e-12).sum() == 8 and np.isclose(d, math.sqrt(17)).sum() == 3 +assert math.isclose(vec(features, "FRAC_AT_D").sum(), 1.0, rel_tol=1e-9) +assert math.isclose((vec(features, "FRAC_AT_D") * vec(features, "MEAN_FRAC")).sum() * 47, 224, rel_tol=1e-9) +assert math.isclose(vec(features, "RADIAL_CV")[3], math.sqrt(26) / 7, rel_tol=1e-9) + +# pins known defect https://github.com/PolusAI/nyxus/issues/497 +# The contour points (boundary pixels moved by +1, +1), the centre's spread and the runner-up's +inner = np.pad(mask > 0, 1) +edge = (mask > 0) & ~(inner[:-2, 1:-1] & inner[2:, 1:-1] & inner[1:-1, :-2] & inner[1:-1, 2:]) +ky, kx = np.nonzero(edge) +cy, cx = ky + 1, kx + 1 +assert len(cx) == 20 +assert sum(not (y < 7 and x < 9 and mask[y, x]) for y, x in zip(cy, cx)) == 9 +spread = {p: (cx - p[0]) ** 2 + (cy - p[1]) ** 2 for p in ((5, 4), (5, 3))} +assert spread[(5, 4)].min() == 5 and spread[(5, 4)].max() == 17 +assert np.ptp(spread[(5, 3)]) == 16 + +# The contour offset: on the ROI's own boundary the centre and the rings would be different +on_roi, centre0, r20, n0 = formulas(img, mask, offset=0) +assert centre0 == (4, 3) and r20 == 17 and n0 == [1, 4, 4, 12, 4, 11, 8, 3] +assert math.isclose(on_roi["MEAN_FRAC"][0], 9.0, rel_tol=1e-6) +assert math.isclose(on_roi["RADIAL_CV"][3], 0.3709, rel_tol=5e-4) + +# A 3 x 3 square with intensities 1..9 row by row: the centre is the bottom-right corner +sq_img = np.pad(np.arange(1, 10, dtype=np.uint32).reshape(3, 3), 2) +sq_mask = np.pad(np.ones((3, 3), dtype=np.uint32), 2) +sq = nyx.featurize(sq_img, sq_mask) +assert math.isclose(sq["MEAN_FRAC_0"][0], 9.0, rel_tol=1e-6) +assert np.allclose(vec(sq, "FRAC_AT_D") * 9, [1, 0, 0, 0, 2, 0, 0, 6], atol=1e-6) +sq0 = formulas(sq_img, sq_mask, offset=0)[0] +assert math.isclose(sq0["MEAN_FRAC"][0], 5.0, rel_tol=1e-6) +assert np.allclose(sq0["FRAC_AT_D"] * 9, [1, 0, 0, 0, 4, 0, 0, 4], atol=1e-6) + +# A ROI with a hole: the coded searches find a smaller radius than the farthest contour pixel +holed = np.pad(np.array([[0, 1, 1, 1, 1, 1, 1, 0], + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 0, 1, 1, 1, 1, 1], + [1, 1, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 1, 0]], dtype=np.uint32), 2) +h_img = (holed * (1 + np.arange(holed.size).reshape(holed.shape) % 7)).astype(np.uint32) +h = nyx.featurize(h_img, holed) +h_full, h_centre, h_r2, h_n = formulas(h_img, holed) +assert h_centre == (7, 6) and h_r2 == 20 and h_n == [1, 4, 4, 12, 7, 4, 7, 11] +assert np.allclose(vec(h, "FRAC_AT_D") * 50, [1, 4, 4, 12, 4, 7, 6, 12], atol=1e-6) +assert math.isclose(h["FRAC_AT_D_4"][0], 0.08, rel_tol=1e-6) +assert math.isclose(h_full["FRAC_AT_D"][4], 0.14, rel_tol=1e-6) +# Nyxus's rings are those of centre (7, 6) with R^2 = 18: 11 pixels lie beyond R, against 8 for 20 +hy, hx = np.nonzero(holed) +h_d = np.hypot(hx - 7, hy - 6) +assert np.bincount(np.minimum((h_d / math.sqrt(18) * 7).astype(int), 7), minlength=8).tolist() == \ + [1, 4, 4, 12, 4, 7, 6, 12] +assert (h_d > math.sqrt(18) + 1e-12).sum() == 11 and (h_d > math.sqrt(20) + 1e-12).sum() == 8 + +# A pixel of intensity 0 inside the mask counts as a pixel: the centre set to 0 +zero = img.copy() +zero[4, 5] = 0 +z = nyx.featurize(zero, mask) +assert z["MEAN_FRAC_0"][0] == 0 and z["RADIAL_CV_0"][0] == 0 +assert np.allclose(vec(z, "FRAC_AT_D"), vec(features, "FRAC_AT_D")) + +# A ROI of one intensity is not skipped +flat = nyx.featurize(np.pad(np.full((5, 5), 4, dtype=np.uint32), 2), np.pad(np.ones((5, 5), np.uint32), 2)) +assert np.allclose(vec(flat, "FRAC_AT_D") * 25, [1, 0, 4, 4, 2, 4, 0, 10], atol=1e-6) +assert np.allclose(vec(flat, "MEAN_FRAC"), [4, 0, 4, 4, 4, 4, 0, 4], rtol=1e-6) +assert math.isclose(flat["RADIAL_CV_7"][0], 1.183, rel_tol=5e-4) + +# ROIs of 1 or 2 pixels have no contour and report 0 in all 24 columns +for m in (np.array([[1]]), np.array([[1, 1]])): + tiny = nyx.featurize(np.pad(5 * m, 2).astype(np.uint32), np.pad(m, 2).astype(np.uint32)) + assert all((vec(tiny, c) == 0).all() for c in CODES) + +# File input. A tie between two centre candidates: an array is read column by column, a file tile by +# tile and row by row, so the first candidate differs +import tifffile + + +def from_files(intensity, roi, **kw): + tifffile.imwrite("rd_int.tif", intensity.astype(np.uint32)) + tifffile.imwrite("rd_seg.tif", roi.astype(np.uint32)) + return Nyxus(list(CODES), **kw).featurize_files( + [os.path.abspath("rd_int.tif")], [os.path.abspath("rd_seg.tif")], False) + + +tie = np.pad(np.array([[0, 0, 1, 0], [0, 1, 1, 1], [1, 1, 1, 1], [0, 1, 1, 0]], dtype=np.uint32), 2) +tie_img = (tie * (1 + np.arange(tie.size).reshape(tie.shape) % 9)).astype(np.uint32) +assert tie.sum() == 10 +assert math.isclose(nyx.featurize(tie_img, tie)["MEAN_FRAC_0"][0], 9.0, rel_tol=1e-6) +assert math.isclose(from_files(tie_img, tie)["MEAN_FRAC_0"][0], 2.0, rel_tol=1e-6) + +# Out of core (ram_limit=0): the worked example at the image origin gets the in-memory values; +# moved 2 pixels right and 3 down it does not +at_origin = np.zeros((20, 20), np.uint32) +at_origin_mask = np.zeros((20, 20), np.uint32) +at_origin[:7, :9], at_origin_mask[:7, :9] = img, mask +out = from_files(at_origin, at_origin_mask, ram_limit=0) +for code in CODES: + assert np.allclose(vec(out, code), vec(features, code), rtol=1e-9, atol=1e-12), code +moved = np.roll(np.roll(at_origin, 3, axis=0), 2, axis=1) +moved_mask = np.roll(np.roll(at_origin_mask, 3, axis=0), 2, axis=1) +assert np.allclose(vec(from_files(moved, moved_mask), "FRAC_AT_D"), vec(features, "FRAC_AT_D")) +out = from_files(moved, moved_mask, ram_limit=0) +# pins known defect https://github.com/PolusAI/nyxus/issues/509 +assert math.isclose(out["FRAC_AT_D_7"][0], 0.3404, rel_tol=5e-4), out["FRAC_AT_D_7"][0] +assert math.isclose(out["MEAN_FRAC_0"][0], 6.0, rel_tol=1e-6), out["MEAN_FRAC_0"][0] + +# Whole-slide mode: the centre lies on the top edge, (19, 1) with R = 19.03 on a 20 x 20 image and +# (50, 0) with R = 50 on a 64 x 48 image. The ring counts Nyxus reports are those of that centre. +# pins known defect https://github.com/PolusAI/nyxus/issues/516 +for (h, w), (ox, oy), ring0 in (((20, 20), (19, 1), 11), ((48, 64), (50, 0), 88)): + tifffile.imwrite("rd_whole.tif", np.full((h, w), 7, np.uint32)) + t = Nyxus(["FRAC_AT_D"]).featurize_files([os.path.abspath("rd_whole.tif")], + [os.path.abspath("rd_whole.tif")], True) + counts = vec(t, "FRAC_AT_D") * h * w + wy, wx = np.mgrid[0:h, 0:w] + rings = np.minimum((np.hypot(wx - ox, wy - oy) / math.hypot(ox, oy) * 7).astype(int), 7) + assert np.allclose(counts, np.bincount(rings.ravel(), minlength=8), atol=1e-6), (h, w, counts) + assert round(counts[0]) == ring0, (h, w, counts[0]) +assert math.isclose(math.hypot(19, 1), 19.03, rel_tol=5e-4) diff --git a/docs/examples/shape_3d_worked_example.py b/docs/examples/shape_3d_worked_example.py new file mode 100644 index 000000000..897e9366d --- /dev/null +++ b/docs/examples/shape_3d_worked_example.py @@ -0,0 +1,163 @@ +# Example for docs/source/Math/f_morphology_3d.rst: 3D shape features of the page's 44-voxel ROI. +# The page shows the lines between the "example" markers. The assertions below pin every Nyxus +# value the page quotes, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +import tifffile +from nyxus import Nyxus3D + +# The ROI, slice by slice (z = 0, 1, 2); rows are y, columns are x +roi = np.array([[[0, 1, 1, 1, 0, 0], + [1, 1, 1, 1, 1, 0], + [1, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1], + [0, 0, 0, 1, 1, 0]], + [[0, 0, 1, 1, 0, 0], + [0, 1, 1, 1, 1, 0], + [0, 1, 1, 1, 1, 1], + [0, 0, 1, 1, 1, 1], + [0, 0, 0, 0, 1, 0]], + [[0, 0, 0, 0, 0, 0], + [0, 0, 1, 1, 0, 0], + [0, 0, 1, 1, 1, 0], + [0, 0, 0, 1, 1, 0], + [0, 0, 0, 0, 0, 0]]], dtype=np.uint16) +seg = np.pad(roi, 1) # 5 x 7 x 8 volume, ROI label 1 +img = (np.arange(seg.size) % 7 + 1).astype(np.uint16).reshape(seg.shape) # any values + +tifffile.imwrite("int.ome.tif", img, ome=True, metadata={"axes": "ZYX"}) +tifffile.imwrite("seg.ome.tif", seg, ome=True, metadata={"axes": "ZYX"}) + +nyx = Nyxus3D(["*3D_ALL_MORPHOLOGY*"]) +features = nyx.featurize_files(["./int.ome.tif"], ["./seg.ome.tif"], False) +print(features.filter(like="3").T) +# example-end + +# Every value in the page's feature index, as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["shape_3d"] +for code, facts in extras.items(): + got = features[code][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4, abs_tol=1e-12), \ + f"{code} = {got}, page says {facts['example']}" + +f = {c: float(features[c][0]) for c in extras} +PACK = (math.pi / 6) / 0.5236 # the voxel-volume factor, 0.9999976... + +# Values the page quotes for the worked example +assert f["3AREA"] == 96 +assert math.isclose(f["3VOXEL_VOLUME"], 44 * PACK, rel_tol=1e-14) +assert math.isclose(f["3MESH_VOLUME"], 475 / 12, rel_tol=1e-14) +assert math.isclose(f["3VOLUME_CONVEXHULL"], 122 / 3, rel_tol=1e-14) +z, y, x = np.nonzero(roi) +lam = np.sort(np.linalg.eigvalsh(np.cov(np.vstack([x, y, z]).astype(float))))[::-1] +assert np.allclose(lam, [2.0920, 0.94139, 0.52260], rtol=1e-4) +for code, k in (("3MAJOR_AXIS_LEN", 0), ("3MINOR_AXIS_LEN", 1), ("3LEAST_AXIS_LEN", 2)): + assert math.isclose(f[code], 4 * math.sqrt(lam[k]), rel_tol=1e-12) +assert math.isclose(f["3AREA_2_VOLUME"], 96 / f["3VOXEL_VOLUME"], rel_tol=1e-14) +assert math.isclose(f["3SPHERICITY"] * f["3SPHERICAL_DISPROPORTION"], 1.0, rel_tol=1e-14) + + +def shape3d(vol, single_roi=False, **kw): + """3D shape features of label image `vol` (or, with single_roi, of the whole volume).""" + tifffile.imwrite("s_int.ome.tif", (vol > 0).astype(np.uint16) + 1, ome=True, metadata={"axes": "ZYX"}) + tifffile.imwrite("s_seg.ome.tif", vol.astype(np.uint16), ome=True, metadata={"axes": "ZYX"}) + segs = ["./s_int.ome.tif"] if single_roi else ["./s_seg.ome.tif"] + r = Nyxus3D(["*3D_ALL_MORPHOLOGY*"], **kw).featurize_files(["./s_int.ome.tif"], segs, single_roi) + return {c: float(r[c][0]) for c in extras} + + +# A single voxel: the 0.5-isolevel surface is an octahedron of volume 1/6; no axes +one = shape3d(np.pad(np.ones((1, 1, 1), np.uint16), 2)) +assert one["3AREA"] == 6 and math.isclose(one["3MESH_VOLUME"], 1 / 6, rel_tol=1e-14) +assert math.isclose(one["3VOLUME_CONVEXHULL"], 1 / 6, rel_tol=1e-14) +assert one["3MAJOR_AXIS_LEN"] == one["3ELONGATION"] == one["3FLATNESS"] == 0 + +# A one-slice ROI (slice z = 0 of the example): LEAST_AXIS_LEN and FLATNESS are 0 +flat = shape3d(np.pad(roi[:1], 1)) +assert flat["3LEAST_AXIS_LEN"] == 0 and flat["3FLATNESS"] == 0 +assert math.isclose(flat["3MESH_VOLUME"], 103 / 6, rel_tol=1e-14) + +# A digital ball of radius 10: the face count is far above the mesh area +g = np.mgrid[-10:11, -10:11, -10:11] +ball = shape3d(np.pad((np.sum(g ** 2, axis=0) <= 100).astype(np.uint16), 1)) +assert ball["3AREA"] == 1902 +assert math.isclose(ball["3SPHERICITY"], 0.6586, rel_tol=5e-4) +assert math.isclose(ball["3MESH_VOLUME"], 4147.8333, rel_tol=1e-7) +assert math.isclose(ball["3ELONGATION"], 1.0, rel_tol=1e-12) + +# A 6 x 4 x 5 box filling its volume: segmented ROI vs whole-volume (single-ROI) mode +box = np.ones((5, 4, 6), np.uint16) +seg_box, whole = shape3d(box), shape3d(box, single_roi=True) +# pins known defect https://github.com/PolusAI/nyxus/issues/494 +assert math.isclose(seg_box["3MESH_VOLUME"], 120 - 12 / 2 - 5 / 6, rel_tol=1e-14) +assert whole["3MESH_VOLUME"] == seg_box["3MESH_VOLUME"] and whole["3AREA"] == seg_box["3AREA"] == 148 +assert whole["3VOXEL_VOLUME"] == 120 and math.isclose(seg_box["3VOXEL_VOLUME"], 120 * PACK, rel_tol=1e-14) +assert math.isclose(seg_box["3MAJOR_AXIS_LEN"], 6.8599, rel_tol=5e-4) +assert whole["3MAJOR_AXIS_LEN"] == whole["3MINOR_AXIS_LEN"] == whole["3LEAST_AXIS_LEN"] == 0 +assert whole["3ELONGATION"] == whole["3FLATNESS"] == 0 + +# Anisotropy: the voxels as acquired, with the coordinates scaled; anisotropy_z=2 doubles the volumes +aniso = shape3d(seg, anisotropy_z=2.0) +for c in ("3VOXEL_VOLUME", "3MESH_VOLUME", "3VOLUME_CONVEXHULL"): + assert math.isclose(aniso[c], 2 * f[c], rel_tol=1e-12), c +lam_z2 = np.sort(np.linalg.eigvalsh(np.cov(np.vstack([x, y, 2.0 * z]).astype(float))))[::-1] +for code, k in (("3MAJOR_AXIS_LEN", 0), ("3MINOR_AXIS_LEN", 1), ("3LEAST_AXIS_LEN", 2)): + assert math.isclose(aniso[code], 4 * math.sqrt(lam_z2[k]), rel_tol=1e-12), code +# The same in the first image plane: every plane is measured once, at its scaled position +assert math.isclose(shape3d(seg, anisotropy_z=3.0)["3VOXEL_VOLUME"], 3 * 44 * PACK, rel_tol=1e-14) +first = np.pad(roi, ((0, 1), (1, 1), (1, 1))) # slice z = 0 of the ROI in image plane 0 +assert math.isclose(shape3d(first, anisotropy_z=3.0)["3VOXEL_VOLUME"], 3 * 44 * PACK, rel_tol=1e-14) + +# Whole-volume mode on the command line or through featurize_directory: a box one voxel larger on +# every axis, 7 x 5 x 6 for the 6 x 4 x 5 volume +os.makedirs("whole_dir", exist_ok=True) +tifffile.imwrite("whole_dir/v.ome.tif", np.full((5, 4, 6), 7, np.uint16), ome=True, + metadata={"axes": "ZYX"}) +r = Nyxus3D(["*3D_ALL_MORPHOLOGY*"]).featurize_directory("whole_dir", "whole_dir", ".*") +# pins known defect https://github.com/PolusAI/nyxus/issues/493 +assert r["3VOXEL_VOLUME"][0] == 210 and r["3AREA"][0] == 214 +assert math.isclose(r["3MESH_VOLUME"][0], 210 - 15 / 2 - 5 / 6, rel_tol=1e-14) +assert r["3MAJOR_AXIS_LEN"][0] == 0 + +# featurize_files(..., single_roi=True) skips zero voxels: the box is that of the nonzero voxels +part = np.zeros((5, 4, 6), np.uint16) +part[1:3, 1:3, 1:4] = 7 # a 3 x 2 x 2 block +tifffile.imwrite("part.ome.tif", part, ome=True, metadata={"axes": "ZYX"}) +r = Nyxus3D(["*3D_ALL_MORPHOLOGY*"]).featurize_files(["./part.ome.tif"], ["./part.ome.tif"], True) +assert r["3VOXEL_VOLUME"][0] == 12 and r["3AREA"][0] == 32 + +# single_roi=True measures that box whatever the mask's shape: a radius-5 ball (515 voxels) in a +# 15 x 15 x 15 volume, as a segmented ROI and as one ROI, which gives its 11 x 11 x 11 box +g5 = np.indices((15, 15, 15)) - 7 +ball5 = (np.sum(g5 ** 2, axis=0) <= 25).astype(np.uint16) +tifffile.imwrite("ball5.ome.tif", 7 * ball5, ome=True, metadata={"axes": "ZYX"}) +tifffile.imwrite("ball5_mask.ome.tif", ball5, ome=True, metadata={"axes": "ZYX"}) +codes = ("3VOXEL_VOLUME", "3AREA", "3MAJOR_AXIS_LEN") +for single, expected in ((False, (514.999, 486, 8.909)), + # pins known defect https://github.com/PolusAI/nyxus/issues/494 + (True, (1331, 726, 0))): + r = Nyxus3D(["*3D_ALL_MORPHOLOGY*"]).featurize_files(["./ball5.ome.tif"], ["./ball5_mask.ome.tif"], + single) + for code, value in zip(codes, expected): + assert math.isclose(r[code][0], value, rel_tol=5e-4, abs_tol=1e-12), \ + f"single_roi={single} {code} = {r[code][0]}, page says {value}" + +# A cavity is subtracted from the mesh volume +cube = np.pad(np.ones((5, 5, 5), np.uint16), 1) +solid = shape3d(cube) +cube[3, 3, 3] = 0 +assert math.isclose(solid["3MESH_VOLUME"], 125 - 12 / 2 - 5 / 6, rel_tol=1e-14) # 118.167 +assert math.isclose(shape3d(cube)["3MESH_VOLUME"], 118.0, rel_tol=1e-12) +assert shape3d(cube)["3AREA"] == 156 + +# A solid cube scores as more compact than the digital ball +for code in ("3COMPACTNESS1", "3COMPACTNESS2", "3SPHERICITY"): + assert solid[code] > ball[code], code +assert solid["3SPHERICAL_DISPROPORTION"] < ball["3SPHERICAL_DISPROPORTION"] +assert math.isclose(solid["3SPHERICAL_DISPROPORTION"], 1.241, rel_tol=5e-4) +assert math.isclose(ball["3SPHERICAL_DISPROPORTION"], 1.518, rel_tol=5e-4) diff --git a/docs/examples/timing_a_run.py b/docs/examples/timing_a_run.py new file mode 100644 index 000000000..263782235 --- /dev/null +++ b/docs/examples/timing_a_run.py @@ -0,0 +1,38 @@ +# Example for docs/source/troubleshooting.rst: timing feature groups and thread counts. +# The page shows the lines between the "example" markers. Everything else creates input data +# and checks the result, so tests/python/test_docs_examples_mechanics.py can run the +# script as written. The timings depend on the machine and are not checked. +import os + +import numpy as np +import tifffile + +rng = np.random.default_rng(0) +os.makedirs("data/int", exist_ok=True) +os.makedirs("data/seg", exist_ok=True) +mask = np.zeros((512, 512), dtype=np.uint16) +yy, xx = np.ogrid[:100, :100] +disk = (yy - 50) ** 2 + (xx - 50) ** 2 < 40 ** 2 +for k in range(16): + r, c = divmod(k, 4) + mask[r * 128 + 10:r * 128 + 110, c * 128 + 10:c * 128 + 110][disk] = k + 1 +tifffile.imwrite("data/int/cells.tif", rng.integers(100, 4000, (512, 512), dtype=np.uint16)) +tifffile.imwrite("data/seg/cells.tif", mask) + +# example-start +import time + +from nyxus import Nyxus + +for group in ("*ALL_INTENSITY*", "*ALL_MORPHOLOGY*"): + for threads in (1, 4): + nyx = Nyxus([group], n_feature_calc_threads=threads) + start = time.perf_counter() + features = nyx.featurize_directory("data/int", "data/seg") + seconds = time.perf_counter() - start + print(f"{group:18} {threads} threads: {seconds:.2f} s for {len(features)} ROIs") +# example-end + +assert len(features) == 16 +assert nyx.get_params("n_feature_calc_threads")["n_feature_calc_threads"] == 4 +assert "EROSIONS_2_VANISH" in features.columns diff --git a/docs/examples/troubleshooting_errors.py b/docs/examples/troubleshooting_errors.py new file mode 100644 index 000000000..4d41b63fe --- /dev/null +++ b/docs/examples/troubleshooting_errors.py @@ -0,0 +1,59 @@ +# Example for docs/source/troubleshooting.rst: the errors of a misspelled feature name and of a +# file pattern that selects nothing. The page shows the lines between the "example" markers. +# Everything else creates input data and checks the result, so +# tests/python/test_docs_examples_mechanics.py can run the script as written. +import os + +import numpy as np +import tifffile + +rng = np.random.default_rng(0) +os.makedirs("data/int", exist_ok=True) +os.makedirs("data/seg", exist_ok=True) +mask = np.zeros((64, 64), dtype=np.uint16) +mask[4:20, 4:20] = 1 +mask[30:50, 8:24] = 2 +tifffile.imwrite("data/int/img1.tif", rng.integers(100, 4000, (64, 64), dtype=np.uint16)) +tifffile.imwrite("data/seg/img1.tif", mask) + +# example-start +from nyxus import Nyxus + +# Python feature names are upper case +try: + Nyxus(["mean"]) +except ValueError as err: + print(err) + +nyx = Nyxus(["MEAN"]) + +# The file pattern is a regular expression, not a wildcard +try: + nyx.featurize_directory("data/int", "data/seg", file_pattern="*.tif") +except ValueError as err: + print(err) + +# The pattern must match the whole file name +try: + nyx.featurize_directory("data/int", "data/seg", file_pattern=r".*\.tiff") +except RuntimeError as err: + print(err) + +features = nyx.featurize_directory("data/int", "data/seg", file_pattern=r".*\.tif") +print(features[["intensity_image", "ROI_label", "MEAN"]]) +# example-end + +messages = [] +for call in (lambda: Nyxus(["mean"]), + lambda: nyx.featurize_directory("data/int", "data/seg", file_pattern="*.tif"), + lambda: nyx.featurize_directory("data/int", "data/seg", file_pattern=r".*\.tiff")): + try: + call() + messages.append(None) + except (ValueError, RuntimeError) as err: + messages.append(str(err)) +assert messages[0].startswith("Error: 'mean' is not a valid 2D feature name") +assert messages[1] == "Invalid filepattern *.tif" +assert messages[2] == ("Error reading the dataset: no intensity and/or label files to process, " + "probably due to file pattern .*\\.tiff") +assert list(features["ROI_label"]) == [1, 2] diff --git a/docs/examples/volume_channels.py b/docs/examples/volume_channels.py new file mode 100644 index 000000000..cae3a18e2 --- /dev/null +++ b/docs/examples/volume_channels.py @@ -0,0 +1,40 @@ +# Example for docs/source/volumetric.rst: a 5D OME-TIFF with channels and timepoints. +# The page shows the lines between the "example" markers. Everything else creates input +# data and checks the result, so tests/python/test_docs_examples_mechanics.py can run +# the script as written. +import os + +import numpy as np +import tifffile + +rng = np.random.default_rng(1) +os.makedirs("volumes", exist_ok=True) +# 2 timepoints, 3 channels, 8 Z planes of 32 x 32 pixels +image = rng.integers(100, 1000, (2, 3, 8, 32, 32), dtype=np.uint16) +image += (1000 * np.arange(3, dtype=np.uint16))[None, :, None, None, None] +labels = np.zeros((8, 32, 32), dtype=np.uint16) +labels[1:6, 4:14, 4:14] = 1 + +# example-start +import tifffile +from nyxus import Nyxus3D + +# One file holds every timepoint (T) and channel (C); the label volume has one frame +tifffile.imwrite("volumes/series.ome.tif", image, ome=True, metadata={"axes": "TCZYX"}) +tifffile.imwrite("volumes/series_labels.ome.tif", labels, ome=True, metadata={"axes": "ZYX"}) + +nyx = Nyxus3D(["3MEAN"]) +features = nyx.featurize_files( + ["volumes/series.ome.tif"], ["volumes/series_labels.ome.tif"], single_roi=False) +print(features[["ROI_label", "t_index", "c_index", "3MEAN"]]) + +# Keep only channel 1 +channel_1 = features[features["c_index"] == 1] +print(channel_1[["t_index", "3MEAN"]]) +# example-end + +assert len(features) == 6 +for _, row in features.iterrows(): + t, c = int(row["t_index"]), int(row["c_index"]) + assert np.isclose(row["3MEAN"], image[t, c][labels == 1].mean()) +assert sorted(channel_1["t_index"].astype(int)) == [0, 1] diff --git a/docs/examples/volume_files.py b/docs/examples/volume_files.py new file mode 100644 index 000000000..16af256a0 --- /dev/null +++ b/docs/examples/volume_files.py @@ -0,0 +1,39 @@ +# Example for docs/source/volumetric.rst: 3D features of a labelled volume. +# The page shows the lines between the "example" markers. Everything else creates input +# data and checks the result, so tests/python/test_docs_examples_mechanics.py can run +# the script as written. +import os + +import numpy as np +import tifffile + +rng = np.random.default_rng(0) +os.makedirs("volumes", exist_ok=True) +image = rng.integers(100, 4000, (16, 48, 48), dtype=np.uint16) +labels = np.zeros((16, 48, 48), dtype=np.uint16) +labels[2:10, 4:20, 4:20] = 1 +labels[4:14, 24:44, 20:40] = 2 + +# example-start +import tifffile +from nyxus import Nyxus3D + +# Write a (Z, Y, X) intensity volume and its label volume as OME-TIFF Z stacks +tifffile.imwrite("volumes/cells.ome.tif", image, ome=True, metadata={"axes": "ZYX"}) +tifffile.imwrite("volumes/cells_labels.ome.tif", labels, ome=True, metadata={"axes": "ZYX"}) + +nyx = Nyxus3D(["*3D_ALL_INTENSITY*"]) +features = nyx.featurize_files( + ["volumes/cells.ome.tif"], + ["volumes/cells_labels.ome.tif"], + single_roi=False) + +print(features[["intensity_image", "ROI_label", "3MEAN", "3MIN", "3MAX"]]) +# example-end + +assert list(features["ROI_label"]) == [1, 2] +assert list(features["intensity_image"]) == ["cells.ome.tif"] * 2 +for k, lab in enumerate((1, 2)): + assert np.isclose(features["3MEAN"][k], image[labels == lab].mean()) + assert features["3MIN"][k] == image[labels == lab].min() + assert features["3MAX"][k] == image[labels == lab].max() diff --git a/docs/examples/whole_slide.py b/docs/examples/whole_slide.py new file mode 100644 index 000000000..8a0e81604 --- /dev/null +++ b/docs/examples/whole_slide.py @@ -0,0 +1,35 @@ +# Example for docs/source/large_images.rst: whole-image (whole-slide) features. +# The page shows the lines between the "example" markers. Everything else creates input +# data and checks the result, so tests/python/test_docs_examples_mechanics.py can run +# the script as written. +import os + +import numpy as np +import tifffile + +rng = np.random.default_rng(2) +os.makedirs("slides", exist_ok=True) +for k in (1, 2): + tifffile.imwrite(f"slides/slide{k}.ome.tif", rng.integers(0, 4000, (48, 64), dtype=np.uint16)) + +# example-start +from nyxus import Nyxus + +nyx = Nyxus(["*WHOLESLIDE*"]) +features = nyx.featurize_files( + ["slides/slide1.ome.tif", "slides/slide2.ome.tif"], + None, # no label images: each whole image is one ROI + single_roi=True) + +print(features[["intensity_image", "mask_image", "ROI_label", "MEAN", "PERIMETER"]]) +# example-end + +assert list(features["ROI_label"]) == [1, 1] +assert list(features["mask_image"]) == ["", ""] +# The rows can come back in either order, so each is matched to its file by name +for k in (1, 2): + pixels = tifffile.imread(f"slides/slide{k}.ome.tif") + row = features[features["intensity_image"] == f"slides/slide{k}.ome.tif"] + assert len(row) == 1 and np.isclose(row["MEAN"].iloc[0], pixels.mean()) +# The contour is the image's bounding box, so the perimeter is 2 * (width + height) +assert list(features["PERIMETER"]) == [2 * (64 + 48)] * 2 diff --git a/docs/examples/zernike_worked_example.py b/docs/examples/zernike_worked_example.py new file mode 100644 index 000000000..f660f525a --- /dev/null +++ b/docs/examples/zernike_worked_example.py @@ -0,0 +1,144 @@ +# Example for docs/source/Math/f_zernike.rst: Zernike moments of the page's 5x5 worked example. +# The page shows the lines between the "example" markers. The assertions below pin every value the +# page quotes, so the page cannot drift from the code. +import json +import math +import os +from pathlib import Path + +# example-start +import numpy as np +from nyxus import Nyxus + +img = np.array([[5, 2, 5, 4, 4], + [3, 3, 3, 1, 3], + [2, 1, 1, 1, 3], + [4, 2, 2, 2, 3], + [3, 5, 3, 3, 2]], dtype=np.uint32) +mask = np.ones_like(img) + +nyx = Nyxus(["ZERNIKE2D"]) +features = nyx.featurize(img, mask) +# 30 columns, ZERNIKE2D_Z0 to ZERNIKE2D_Z29 +print(features[["ZERNIKE2D_Z0", "ZERNIKE2D_Z2", "ZERNIKE2D_Z3", "ZERNIKE2D_Z29"]]) +# example-end + +# Every value in the page's feature index, as recorded in docs/feature_extras.toml +extras = json.loads(Path(os.environ["NYXUS_DOCS_EXTRAS"]).read_text(encoding="utf-8"))["zernike"] +column = {"ZERNIKE2D": "ZERNIKE2D_Z0"} +for code, facts in extras.items(): + got = features[column.get(code, code)][0] + assert math.isclose(got, facts["example"], rel_tol=5e-4, abs_tol=1e-12), \ + f"{code} = {got}, page says {facts['example']}" + +# The columns, in the order the page's table gives: order n ascending, then repetition m ascending, +# with n - m even +orders = [(n, m) for n in range(10) for m in range(n + 1) if (n - m) % 2 == 0] +names = [c for c in features.columns if c.startswith("ZERNIKE2D")] +assert names == ["ZERNIKE2D_Z%d" % k for k in range(30)], names +assert orders[3] == (2, 2) and orders[12] == (6, 0) and orders[29] == (9, 9) + +# All 30 values on the worked example, as the page's table lists them +quoted = [0.3183, 0.0, 0.6051, 0.01349, 0.009830, 0.009666, 0.2363, 0.04984, 0.03165, 0.03977, + 0.03889, 0.0009361, 0.3727, 0.08806, 0.1363, 0.0003018, 0.07493, 0.06683, 0.001380, + 0.002329, 0.5813, 0.1047, 0.2508, 0.002560, 0.004304, 0.08618, 0.05453, 0.009942, 0.01636, + 0.0004792] +for k, expected in enumerate(quoted): + got = features["ZERNIKE2D_Z%d" % k][0] + assert math.isclose(got, expected, rel_tol=5e-4, abs_tol=1e-12), \ + f"Z{k} = {got}, page says {expected}" + + +def zern(image, roi): + table = Nyxus(["ZERNIKE2D"]).featurize(image, roi) + return [table["ZERNIKE2D_Z%d" % k][0] for k in range(30)] + + +# The disk geometry: centroid (2.9143, 2.8857) in 1-based pixel coordinates, radius 5 +jj, ii = np.mgrid[:5, :5] +cx, cy = ((ii + 1) * img).sum() / img.sum(), ((jj + 1) * img).sum() / img.sum() +assert math.isclose(cx, 2.9143, rel_tol=5e-4) and math.isclose(cy, 2.8857, rel_tol=5e-4) +assert round(np.hypot(ii + 1 - cx, jj + 1 - cy).max() / 5, 2) == 0.59 + +# Z0 is 1/pi when every pixel lies in the disk; Z1 is 0 because the disk sits on the centroid +assert math.isclose(features["ZERNIKE2D_Z0"][0], 1 / math.pi, rel_tol=1e-12) +assert abs(features["ZERNIKE2D_Z1"][0]) < 1e-15 + +# A quarter turn and a mirror image leave every value unchanged; so does scaling the intensities +for other in (np.rot90(img), np.fliplr(img)): + other = np.ascontiguousarray(other) + assert np.allclose(zern(other, np.ones_like(other)), zern(img, mask), rtol=1e-12, atol=1e-15) +assert zern(img * 3, mask) == zern(img, mask) + +# An elongated ROI: 3 x 9 bounding box, radius 3, so its ends fall outside the disk +long_img = np.array([[0, 0, 7, 8, 9, 2, 0, 0, 0], + [1, 3, 5, 0, 6, 4, 2, 6, 9], + [0, 2, 4, 6, 8, 0, 0, 3, 8]], dtype=np.uint32) +long_mask = np.array([[0, 0, 1, 1, 1, 1, 0, 0, 0], + [1, 1, 1, 1, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 0, 0, 1, 1]], dtype=np.uint32) +z = zern(long_img, long_mask) +assert math.isclose(z[0] * math.pi, 70 / 93, rel_tol=1e-12), z[0] +assert math.isclose(z[0], 0.2396, rel_tol=5e-4) and math.isclose(z[1], 0.09145, rel_tol=5e-4), z[:2] +# 5 of the 19 ROI pixels, holding 23 of the total intensity of 93, lie farther than R = 3 from the +# intensity centroid +jj, ii = np.mgrid[:3, :9] +w = (long_img * long_mask).astype(float) +cx, cy = ((ii + 1) * w).sum() / w.sum(), ((jj + 1) * w).sum() / w.sum() +out = (np.hypot(ii + 1 - cx, jj + 1 - cy) / 3 > 1) & (long_mask == 1) +assert (long_mask.sum(), out.sum(), w.sum(), w[out].sum()) == (19, 5, 93, 23) + +# A centroid exactly on a pixel centre: that pixel is left out +sym = np.array([[1, 2, 1], [2, 4, 2], [1, 2, 1]], dtype=np.uint32) +z = zern(sym, np.ones_like(sym)) +# pins known defect https://github.com/PolusAI/nyxus/issues/503 +assert math.isclose(z[0] * math.pi, 0.75, rel_tol=1e-12), z[0] +assert math.isclose(z[2], 0.5040, rel_tol=5e-4), z[2] +# With the centre pixel kept, A20 = (3/pi) sum w (2 rho^2 - 1) would be 0.7427 (centroid (2, 2), +# R = 3) +jj, ii = np.mgrid[:3, :3] +rho2 = ((ii - 1) ** 2 + (jj - 1) ** 2) / 9 +a20 = 3 / math.pi * (sym / sym.sum() * (2 * rho2 - 1)).sum() +assert math.isclose(abs(a20), 0.7427, rel_tol=5e-4), a20 + +# A constant ROI, such as a binary image inside its own foreground, gives 0 (the --noval value) in +# every column +const = np.full((4, 4), 7, dtype=np.uint32) +assert zern(const, np.ones_like(const)) == [0.0] * 30 +assert zern(mask, mask) == [0.0] * 30 +# The same 0/1 image under a mask that also covers its background is not constant +blob = np.zeros((7, 7), dtype=np.uint32) +blob[2:5, 1:6] = 1 +assert zern(blob, blob) == [0.0] * 30 +z = zern(blob, np.ones_like(blob)) +assert math.isclose(z[0], 0.2971, rel_tol=5e-4) and math.isclose(z[2], 0.7873, rel_tol=5e-4), z[:3] +# A zero pixel in the mask stops a ROI of otherwise one intensity from counting as constant +const[0, 0] = 0 +no_corner = np.ones_like(const) +no_corner[0, 0] = 0 +assert zern(const, no_corner) == [0.0] * 30 +assert zern(const, np.ones_like(const))[0] > 0.3 + +# An intensity-0 pixel inside the mask weighs the same as a pixel outside it, as long as it does not +# change the bounding box +dark = img.copy() +dark[2, 2] = 0 +hole = mask.copy() +hole[2, 2] = 0 +assert zern(dark, mask) == zern(dark, hole) +# A zero row inside the mask extends the bounding box, and with it the radius, from 4 to 5 +dark = img.copy() +dark[4] = 0 +short = mask.copy() +short[4] = 0 +z_out, z_in = zern(dark, short), zern(dark, mask) +assert math.isclose(z_out[2], 0.5122, rel_tol=5e-4) and math.isclose(z_in[2], 0.6716, rel_tol=5e-4) + +# Adding a constant to the intensities changes the weights, and so the moments +z = zern(img + 100, mask) +assert math.isclose(z[2], 0.6481, rel_tol=5e-4), z[2] + +# IBSI mode changes nothing +ibsi = Nyxus(["ZERNIKE2D"], ibsi=True).featurize(img, mask) +for k in range(30): + assert ibsi["ZERNIKE2D_Z%d" % k][0] == features["ZERNIKE2D_Z%d" % k][0], k diff --git a/docs/feature_descriptions.toml b/docs/feature_descriptions.toml new file mode 100644 index 000000000..3357377d8 --- /dev/null +++ b/docs/feature_descriptions.toml @@ -0,0 +1,866 @@ +# One-line descriptions of every feature code, by family, and a note per family. Rendered +# into docs/source/featurelist.rst by docs/tools/gen_feature_reference.py, which fails on a +# code with no entry and on an entry that names no code. Each description states what the +# code computes; it was checked against the source when written. + +[family_notes] +intensity = "Statistics of the intensities of the ROI's pixels. They are reported in the image's own units; see Intensity units and CT images in the supported formats page." +intensity_histogram = "Available only in IBSI mode (--ibsi=true). All features come from one histogram per ROI with --coarseGrayDepth equal-width bins over the ROI's intensity range." +neighbors = "Two ROIs are neighbours when their contours come within --pixelDistance pixels of each other (5 by default), measured between contour pixels, not centroids." +glcm = "The grey-level co-occurrence matrix counts pairs of ROI pixels one pixel apart, built separately for each angle in --glcmAngles (default 0, 45, 90 and 135 degrees); pairs with a pixel of stored intensity 0 are skipped, which covers every pixel outside the ROI and any ROI pixel whose value is 0. Each plain code is output as one column per angle, named with the angle as a suffix (for example GLCM_ASM_45), and its _AVE code is the mean of those columns. Grey levels come from --coarseGrayDepth binning or are the raw intensities under --ibsi; each ordered pair is counted once with a positive grey depth, and the matrix is made symmetric with a negative or zero grey depth and under --ibsi." +glrlm = "A run is a straight line of consecutive pixels with the same grey level; runs are collected separately along 0, 45, 90 and 135 degrees into a grey level by run length matrix. Each plain code is output as one column per angle (suffixes _0, _45, _90 and _135), and its _AVE code is the mean of the four. Grey levels come from --coarseGrayDepth binning, or are the raw intensities under --ibsi. With a positive --coarseGrayDepth (the default), pixels of stored intensity 0, including every bounding-box pixel outside the ROI, are binned to grey level 1 and form runs; under --ibsi or a negative --coarseGrayDepth they are skipped." +glszm = "A zone is a group of connected pixels sharing one grey level, grown from each unvisited pixel in scan order through same-level neighbours to its east, south-east, south and south-west; the matrix counts zones by grey level and size. The family has no directions, so each code is one value per ROI. Grey levels come from --coarseGrayDepth binning, or are the raw intensities under --ibsi. With a positive --coarseGrayDepth (the default), pixels of stored intensity 0, including every bounding-box pixel outside the ROI, are binned to grey level 1 and form zones; under --ibsi or a negative --coarseGrayDepth they are skipped." +gldm = "A pixel's dependence is one plus the number of its 8 neighbours of nonzero stored intensity that have exactly the same grey level, and the matrix counts the ROI pixels of nonzero stored intensity by grey level and dependence. The family has no directions, so each code is one value per ROI. Grey levels come from --coarseGrayDepth binning, or are the raw intensities under --ibsi." +gldzm = "A zone is an 8-connected group of pixels sharing one grey level. Its distance is the smallest, over its pixels, of the steps along a row or column to the nearest pixel of stored intensity 0 plus one, or to the bounding-box edge, where a pixel on the bounding-box edge is at distance 1. With a positive --coarseGrayDepth (the default) the pixels outside the ROI are binned to grey level 1 and form zones like ROI pixels, so only the bounding-box edge counts; under --ibsi pixels of stored intensity 0 are skipped. The matrix counts zones by grey level and distance; each code is one value per ROI. Grey levels come from --coarseGrayDepth binning, or are the raw intensities under --ibsi." +ngldm = "Every ROI pixel is a neighbourhood centre whose dependence count is one plus the number of its 8 neighbours inside the ROI with the same grey level; the matrix counts centres by grey level and dependence count. Each code is one value per ROI. Grey levels come from scaling the range 0 to the ROI maximum by --coarseGrayDepth and truncating (levels 0 to --coarseGrayDepth), or are the raw intensities under --ibsi." +ngtdm = "For each pixel of nonzero binned grey level that has at least one such neighbour, the matrix accumulates the absolute difference between its grey level and the average grey level of its 8 neighbours (neighbours whose binned grey level is 0 are left out), grouped by grey level, together with the probability of each grey level. Each code is one value per ROI. Grey levels come from --coarseGrayDepth binning, or are the raw intensities under --ibsi. With a positive --coarseGrayDepth (the default), pixels outside the ROI are binned to grey level 1 and take part both as centres and as neighbours." +shape_moments = "Geometric moments of the ROI's shape: every ROI pixel counts as 1. The two digits of a code are the powers of x and y. The weighted moments weight each pixel by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour (taken one pixel down and to the right of the ROI), found by a sampled search that can overestimate it." +intensity_moments = "The same moments with each pixel weighted by its intensity, in grey levels. The two digits of a code are the powers of x and y. The weighted moments multiply each intensity by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour (taken one pixel down and to the right of the ROI), found by a sampled search that can overestimate it." +gabor = "GABOR has one output column per frequency and orientation pair, GABOR_0, GABOR_1 and so on." +intensity_3d = "The 3D counterparts of the 2D intensity features, computed over the ROI's voxels and reported in the volume's own units." +glcm_3d = "The co-occurrence matrix counts pairs of voxels one voxel apart within the bounding box, built separately for the 13 unique directions of the 26-voxel neighbourhood; with a positive grey depth (the default) the background around the ROI is binned to level 1 and its pairs are counted, while with a zero or negative grey depth and under --ibsi pairs touching level 0 are skipped. Each _AVE code is the mean over the 13 directions; the plain code is a single value taken from the first direction only (one voxel step along x, y and z together), not an average. Grey levels come from --coarseGrayDepth binning (overridable with the 3glcm/greydepth metaparameter) or are the raw intensities under --ibsi; each ordered pair is counted once with a positive grey depth, and the matrix is made symmetric with a negative or zero grey depth and under --ibsi." +glrlm_3d = "Runs of consecutive voxels with the same grey level are collected separately along the 13 unique directions of the 26-voxel neighbourhood. Each _AVE code is the mean over the 13 directions; the plain code is a single value taken from the first direction only (one voxel step along x, y and z together), not an average. Grey levels are binned to the 3glrlm/greydepth metaparameter, which defaults to 0 (no binning, raw intensities) rather than following --coarseGrayDepth; --ibsi also uses the raw intensities." +glszm_3d = "A zone is a 26-connected group of voxels sharing one grey level, and the matrix counts zones by grey level and size. The family has no directions, so each code is one value per ROI. Grey levels are binned to the 3glszm/greydepth metaparameter, which defaults to 0 (no binning, raw intensities) rather than following --coarseGrayDepth; --ibsi also uses the raw intensities." +gldm_3d = "A voxel's dependence is one plus the number of its 26 neighbours in the bounding box that have exactly the same grey level, and the matrix counts ROI voxels by grey level and dependence. The family has no directions, so each code is one value per ROI. Grey levels are binned to the 3gldm/greydepth metaparameter, which defaults to 0 (no binning, raw intensities) rather than following --coarseGrayDepth; --ibsi also uses the raw intensities." +gldzm_3d = "A zone is a 26-connected group of ROI voxels sharing one grey level, and its distance is the smallest city-block distance of any of its voxels to the nearest voxel outside the ROI, with surface voxels at distance 1. The matrix counts zones by grey level and distance; each code is one value per ROI. Grey levels come from --coarseGrayDepth binning, or are the raw intensities under --ibsi." +ngldm_3d = "Every ROI voxel is a neighbourhood centre whose dependence count is one plus the number of its 26 neighbours inside the ROI with the same grey level; the matrix counts centres by grey level and dependence count. Each code is one value per ROI. Grey levels come from scaling the range 0 to the ROI maximum by --coarseGrayDepth and truncating (levels 0 to --coarseGrayDepth), or are the raw intensities under --ibsi." +ngtdm_3d = "For each voxel of the bounding box the matrix accumulates the absolute difference between its grey level and the average grey level of the voxels within the 3ngtdm/radius Chebyshev distance (default 1, the 26 neighbours) in the bounding box, grouped by grey level, together with the probability of each grey level. Each code is one value per ROI. Grey levels are binned to the 3ngtdm/greydepth metaparameter, which defaults to 0 (no binning, raw intensities) rather than following --coarseGrayDepth; --ibsi also uses the raw intensities. With the default 3ngtdm/greydepth of 0, and under --ibsi, the background voxels of the bounding box are centres too and form their own grey level; with a positive greydepth they are skipped." +image_quality = "2D features computed on each ROI's bounding box with pixels outside the ROI set to 0." + +[intensity] +COV = "Coefficient of variation: the unbiased (n-1) standard deviation of the ROI's pixel intensities divided by their mean; 0 when the mean is 0." +COVERED_IMAGE_INTENSITY_RANGE = "Ratio of the ROI's intensity range (MAX minus MIN) to the intensity range of the pixels of all ROIs in the image; pixels outside every ROI are not used." +ENERGY = "Sum of the squared pixel intensities of the ROI." +ENTROPY = "Shannon entropy (base 2) of the ROI's intensity histogram, binned into |--coarseGrayDepth| equal-width bins (default 64) spanning the ROI's minimum to maximum intensity." +EXCESS_KURTOSIS = "Excess kurtosis of the ROI's pixel intensities: the population fourth standardized moment minus 3; 0 for ROIs with fewer than 5 pixels or a single intensity." +HYPERFLATNESS = "Sixth standardized moment of the ROI's pixel intensities: the mean sixth power of the deviations from the mean, divided by the sixth power of the unbiased (n-1) standard deviation." +HYPERSKEWNESS = "Fifth standardized moment of the ROI's pixel intensities: the mean fifth power of the deviations from the mean, divided by the fifth power of the unbiased (n-1) standard deviation." +INTEGRATED_INTENSITY = "Sum of the pixel intensities of the ROI." +INTERQUARTILE_RANGE = "Difference between the 75th and 25th percentiles (P75 minus P25) of the ROI's pixel intensities." +KURTOSIS = "Pearson kurtosis of the ROI's pixel intensities: the population fourth standardized moment, without subtracting 3; 0 for ROIs with fewer than 5 pixels or a single intensity." +MAX = "Maximum intensity value in the ROI" +MEAN = "Mean intensity value of the ROI" +MEAN_ABSOLUTE_DEVIATION = "Mean absolute deviation of the ROI's pixel intensities from their mean." +MEDIAN = "Median pixel intensity of the ROI; the average of the two middle values when the pixel count is even." +MEDIAN_ABSOLUTE_DEVIATION = "Mean absolute deviation of the ROI's pixel intensities from their median (differs from the median of the absolute deviations from the median)." +MIN = "Minimum intensity value in the ROI" +MODE = "Most frequent pixel intensity in the ROI; the smallest such intensity when several share the top count." +P01 = "1st percentile of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +P10 = "10th percentile of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +P25 = "25th percentile (first quartile) of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +P75 = "75th percentile (third quartile) of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +P90 = "90th percentile of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +P99 = "99th percentile of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +QCOD = "Quartile coefficient of dispersion: (P75 minus P25) divided by (P75 plus P25); 0 when the denominator is 0." +RANGE = "Difference between the maximum and minimum pixel intensities of the ROI (MAX minus MIN)." +ROBUST_MEAN = "Mean of the ROI's pixel intensities that lie between P10 and P90, inclusive." +ROBUST_MEAN_ABSOLUTE_DEVIATION = "Mean absolute deviation from ROBUST_MEAN of the ROI's pixel intensities that lie between P10 and P90, inclusive." +ROOT_MEAN_SQUARED = "Root mean square of the ROI's pixel intensities: the square root of ENERGY divided by the pixel count." +SKEWNESS = "Skewness of the ROI's pixel intensities: the population third standardized moment; 0 for ROIs with fewer than 4 pixels or a single intensity." +STANDARD_DEVIATION = "Unbiased standard deviation of the ROI's pixel intensities, with n-1 in the denominator." +STANDARD_DEVIATION_BIASED = "Biased (population) standard deviation of the ROI's pixel intensities, with n in the denominator." +STANDARD_ERROR = "Standard error of the mean: the unbiased standard deviation divided by the square root of the pixel count." +VARIANCE = "Unbiased variance of the ROI's pixel intensities, with n-1 in the denominator." +VARIANCE_BIASED = "Biased (population) variance of the ROI's pixel intensities, with n in the denominator." +UNIFORMITY = "Sum of the squared bin probabilities of the ROI's intensity histogram, binned into |--coarseGrayDepth| equal-width bins (default 64) spanning the ROI's minimum to maximum intensity." +UNIFORMITY_PIU = "Percent image uniformity: (1 minus (MAX minus MIN) divided by (MAX plus MIN)) times 100; 0 when MAX plus MIN is 0." +HISTOGRAM = "Multi-column feature HISTOGRAM_BIN_0 to HISTOGRAM_BIN_N-1: the number of ROI pixels in each of N equal-width bins spanning the ROI's minimum to maximum intensity, where N is --coarseGrayDepth (default 64); a negative value writes no columns (a fix is coming). It is not part of *ALL_INTENSITY*; it is computed only together with another intensity feature or with *ALL*, and requested on its own every column is 0 (known defect, https://github.com/PolusAI/nyxus/issues/489)." + +[intensity_histogram] +IH_MEAN_VAL = "Mean intensity read from the ROI's N-bin intensity histogram: the frequency-weighted mean of the bin centres. All IH_ features use N equal-width bins over the ROI's intensity range, N = --coarseGrayDepth (default 64), and are computed only in IBSI mode (--ibsi)." +IH_VARIANCE_VAL = "Population variance of the histogram's bin-centre intensities, weighted by bin frequency." +IH_SKEWNESS_VAL = "Skewness (third standardized moment) of the histogram's bin-centre intensities, weighted by bin frequency." +IH_EXCESS_KURTOSIS_VAL = "Excess kurtosis (fourth standardized moment minus 3) of the histogram's bin-centre intensities, weighted by bin frequency." +IH_MEDIAN_VAL = "Centre intensity of the first histogram bin at which the cumulative pixel count exceeds half the ROI's pixel count." +IH_MINIMUM_VAL = "Minimum intensity of the ROI, the lower edge of the histogram's first bin." +IH_P10_VAL = "10th percentile intensity, interpolated linearly within the histogram bin that contains it." +IH_P90_VAL = "90th percentile intensity, interpolated linearly within the histogram bin that contains it." +IH_MAXIMUM_VAL = "Maximum intensity of the ROI, the upper edge of the histogram's last bin." +IH_MODE_VAL = "Centre intensity of the most populated histogram bin; the lowest such bin on ties." +IH_INTERQUANTILE_RANGE_VAL = "Difference between the 75th and 25th percentile intensities, each interpolated within its histogram bin." +IH_RANGE_VAL = "Difference between the ROI's maximum and minimum intensities." +IH_MEAN_ABSOLUTE_DEVIATION_VAL = "Frequency-weighted mean absolute deviation of the histogram's bin-centre intensities from IH_MEAN_VAL." +IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_VAL = "Mean absolute deviation from IH_ROBUST_MEAN_VAL of the bin-centre intensities of the bins from the 10th-percentile bin to the 90th-percentile bin, inclusive." +IH_MEDIAN_ABSOLUTE_DEVIATION_VAL = "Frequency-weighted mean absolute deviation of the histogram's bin-centre intensities from IH_MEDIAN_VAL." +IH_COEFFICIENT_OF_VARIATION_VAL = "Square root of IH_VARIANCE_VAL divided by IH_MEAN_VAL." +IH_QUANTILE_COEFFICIENT_OF_DISPERSION_VAL = "Quartile coefficient of dispersion of the histogram: (P75 minus P25) divided by (P75 plus P25), using the interpolated percentile intensities." +IH_ENTROPY_VAL = "Shannon entropy (base 2) of the histogram's bin probabilities." +IH_UNIFORMITY_VAL = "Sum of the squared bin probabilities of the histogram." +IH_ROBUST_MEAN_VAL = "Frequency-weighted mean of the bin-centre intensities of the bins from the 10th-percentile bin to the 90th-percentile bin, inclusive." +IH_MEAN_IDX = "Frequency-weighted mean of the 1-based histogram bin indices." +IH_VARIANCE_IDX = "Population variance of the histogram bin indices, weighted by bin frequency." +IH_SKEWNESS_IDX = "Skewness (third standardized moment) of the histogram bin indices, weighted by bin frequency." +IH_EXCESS_KURTOSIS_IDX = "Excess kurtosis (fourth standardized moment minus 3) of the histogram bin indices, weighted by bin frequency." +IH_MEDIAN_IDX = "1-based index of the histogram bin that holds IH_MEDIAN_VAL." +IH_MINIMUM_IDX = "1-based index of the histogram bin that holds the ROI's minimum intensity, which is always 1." +IH_P10_IDX = "1-based index of the histogram bin that holds the 10th percentile intensity." +IH_P90_IDX = "1-based index of the histogram bin that holds the 90th percentile intensity." +IH_MAXIMUM_IDX = "1-based index of the histogram bin that holds the ROI's maximum intensity, which is always N." +IH_MODE_IDX = "1-based index of the most populated histogram bin; the lowest such bin on ties." +IH_INTERQUANTILE_RANGE_IDX = "Difference between the bin indices that hold the 75th and 25th percentile intensities." +IH_RANGE_IDX = "Difference between the bin indices that hold the maximum and minimum intensities, which is N minus 1." +IH_MEAN_ABSOLUTE_DEVIATION_IDX = "Frequency-weighted mean absolute deviation of the histogram bin indices from their mean." +IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_IDX = "Mean absolute deviation from the robust mean index of the bin indices from the 10th-percentile bin to the 90th-percentile bin, inclusive." +IH_MEDIAN_ABSOLUTE_DEVIATION_IDX = "Frequency-weighted mean absolute deviation of the histogram bin indices from the median bin index." +IH_COEFFICIENT_OF_VARIATION_IDX = "Square root of IH_VARIANCE_IDX divided by IH_MEAN_IDX." +IH_QUANTILE_COEFFICIENT_OF_DISPERSION_IDX = "Quartile coefficient of dispersion of the bin indices: the difference of the 1-based bin indices holding P75 and P25, divided by their sum." +IH_ENTROPY_IDX = "Shannon entropy (base 2) of the histogram's bin probabilities; equal to IH_ENTROPY_VAL." +IH_UNIFORMITY_IDX = "Sum of the squared bin probabilities of the histogram; equal to IH_UNIFORMITY_VAL." +IH_MAX_GRADIENT = "Largest gradient of the histogram's bin counts, in pixels per bin, using central differences inside and one-sided differences at the first and last bins; when no gradient is above 0 it returns 2.2251e-308 (DBL_MIN) instead." +IH_MAX_GRADIENT_IDX = "1-based index of the first histogram bin at which IH_MAX_GRADIENT occurs; 0 when no gradient is above 0." +IH_MIN_GRADIENT = "Smallest (most negative) gradient of the histogram's bin counts, in pixels per bin, using central differences inside and one-sided differences at the first and last bins." +IH_MIN_GRADIENT_IDX = "1-based index of the histogram bin at which IH_MIN_GRADIENT occurs." +IH_ROBUST_MEAN_IDX = "Frequency-weighted mean of the 1-based bin indices from the 10th-percentile bin to the 90th-percentile bin, inclusive." +IH_NUM_BINS = "Number of histogram bins N, set by --coarseGrayDepth (default 64)." +IH_BIN_SIZE = "Width of one histogram bin in intensity units: the ROI's intensity range divided by N." + +[basic_morphology] +AREA_PIXELS_COUNT = "ROI area as the number of pixels in the ROI." +AREA_UM2 = "ROI area in square micrometers, meant as the pixel count times the squared pixel size; currently reported as 0, because the image resolution does not reach this feature (known defect, https://github.com/PolusAI/nyxus/issues/496)." +CENTROID_X = "X coordinate of the ROI's centroid: the mean x coordinate of its pixels, in pixels." +CENTROID_Y = "Y coordinate of the ROI's centroid: the mean y coordinate of its pixels, in pixels." +WEIGHTED_CENTROID_Y = "Y coordinate of the ROI's intensity-weighted centroid: the mean of the pixel y coordinates weighted by pixel intensity; 0 when the ROI's total intensity is 0." +WEIGHTED_CENTROID_X = "X coordinate of the ROI's intensity-weighted centroid: the mean of the pixel x coordinates weighted by pixel intensity; 0 when the ROI's total intensity is 0." +MASS_DISPLACEMENT = "Distance in pixels between the ROI's intensity-weighted centroid and its unweighted centroid." +COMPACTNESS = "Unbiased (n-1) standard deviation of the distances from the ROI's pixels to its centroid, divided by the ROI's pixel count (differs from the mean squared distance to the centroid divided by the area)." +BBOX_YMIN = "Smallest y coordinate (top row) of the ROI's axis-aligned bounding box, in pixels." +BBOX_XMIN = "Smallest x coordinate (leftmost column) of the ROI's axis-aligned bounding box, in pixels." +BBOX_HEIGHT = "Height of the smallest axis-aligned box containing the ROI" +BBOX_WIDTH = "Width of the smallest axis-aligned box containing the ROI" +DIAMETER_EQUAL_AREA = "Diameter of a circle with the same area as the ROI: 2 times the square root of the pixel count divided by pi, in pixels." +EXTENT = "Fraction of the ROI's axis-aligned bounding box covered by the ROI: the pixel count divided by the bounding box area." +ASPECT_RATIO = "Width of the ROI's axis-aligned bounding box divided by its height." + +[contour] +PERIMETER = "Length of the ROI's traced contour in pixels: the sum of the Euclidean distances between consecutive contour pixels, closing back to the first one. A hole or a second fragment appends its loop to the same list, and the jumps between loops are counted too. On the out-of-core path it is the number of contour pixels instead." +DIAMETER_EQUAL_PERIMETER = "Diameter of a circle with the same perimeter as the ROI: PERIMETER divided by pi, in pixels." +EDGE_MEAN_INTENSITY = "Mean intensity of ROI's contour pixels" +EDGE_STDDEV_INTENSITY = "Unbiased (n-1) standard deviation of the intensities of the ROI's contour pixels." +EDGE_MAX_INTENSITY = "Maximum intensity of ROI's contour pixels" +EDGE_MIN_INTENSITY = "Minimum intensity of ROI's contour pixels" +EDGE_INTEGRATED_INTENSITY = "Sum of the intensities of the ROI's contour pixels." + +[convex_hull] +CIRCULARITY = "Square root of 4 pi times the ROI's pixel count divided by the square of its PERIMETER; close to 1 for a digital disk and smaller for less circular shapes; small or blocky ROIs can exceed 1." +CONVEX_HULL_AREA = "Area of the convex hull of the ROI's pixel centres, in pixels, converted to a pixel-count equivalent with Pick's theorem (polygon area plus half the hull boundary lattice points plus 1)." +SOLIDITY = "ROI's pixel count divided by CONVEX_HULL_AREA." +POLYGONALITY_AVE = "Score of how closely the ROI matches a regular polygon with NUM_NEIGHBORS sides: 10 times the mean of a side-length ratio and an area ratio computed from the ROI's area and PERIMETER. 10 means a perfect match, lower values a poorer one; -1 when the ROI has fewer than 3 neighbors." +HEXAGONALITY_AVE = "Score of how closely the ROI matches a regular hexagon: 10 times the mean of the average pairwise agreement among 11 hexagon area estimates and among 14 hexagon perimeter estimates, built from the area, PERIMETER, convex hull area and minimum and maximum Feret diameters. 10 means a perfect hexagon, lower values a poorer match; -1 when the ROI has fewer than 3 neighbors." +HEXAGONALITY_STDDEV = "Dispersion of the hexagonality estimates: the square root of the mean of the population variances of the pairwise area ratios and of the pairwise perimeter ratios used in HEXAGONALITY_AVE; -1 when the ROI has fewer than 3 neighbors." + +[ellipse] +MAJOR_AXIS_LENGTH = "Length (in pixels) of the major axis of the ellipse that has the same normalized second central moments as the region" +MINOR_AXIS_LENGTH = "Length (in pixels) of the minor axis of the ellipse that has the same normalized second central moments as the region" +ELONGATION = "MINOR_AXIS_LENGTH divided by MAJOR_AXIS_LENGTH of the ROI's moment-equivalent ellipse; 1 for a circle." +ECCENTRICITY = "Eccentricity of the ROI's moment-equivalent ellipse: the distance between its foci divided by its major axis length, from 0 for a circle toward 1 for a line." +ORIENTATION = "Angle in degrees, from -90 to 90, between the x axis and the major axis of the ROI's moment-equivalent ellipse, measured in image coordinates with y increasing downward." +ROUNDNESS = "4 times the ROI's area divided by pi times the square of MAJOR_AXIS_LENGTH; 1 for a circle." + +[circles] +DIAMETER_MIN_ENCLOSING_CIRCLE = "Diameter in pixels of the circle that Welzl's algorithm fits to the ROI's contour pixels, with chord midpoints truncated to whole pixels (known defect, https://github.com/PolusAI/nyxus/issues/498); it approximates the smallest enclosing circle and can come out about a pixel larger or smaller than it." +DIAMETER_CIRCUMSCRIBING_CIRCLE = "Twice the largest distance from the ROI's centroid to its contour pixels, in pixels. The contour is taken one pixel down and to the right of the ROI and the centroid one pixel up and to the left (known defect, https://github.com/PolusAI/nyxus/issues/497)." +DIAMETER_INSCRIBING_CIRCLE = "Twice the smallest distance from the ROI's centroid to its contour pixels, in pixels. The contour is taken one pixel down and to the right of the ROI and the centroid one pixel up and to the left (known defect, https://github.com/PolusAI/nyxus/issues/497)." + +[roi_radius] +ROI_RADIUS_MEAN = "Mean over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497)" +ROI_RADIUS_MAX = "Maximum over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497)" +ROI_RADIUS_MEDIAN = "Median over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497)" + +[extrema] +EXTREMA_P1_X = "X coordinate of extremum point 1: the leftmost ROI pixel in the ROI's top row." +EXTREMA_P1_Y = "Y coordinate of extremum point 1: the ROI's top row (the bounding box's minimum y)." +EXTREMA_P2_X = "X coordinate of extremum point 2: the rightmost ROI pixel in the ROI's top row." +EXTREMA_P2_Y = "Y coordinate of extremum point 2: the ROI's top row (the bounding box's minimum y)." +EXTREMA_P3_X = "X coordinate of extremum point 3: the ROI's rightmost column (the bounding box's maximum x)." +EXTREMA_P3_Y = "Y coordinate of extremum point 3: the topmost ROI pixel in the ROI's rightmost column." +EXTREMA_P4_X = "X coordinate of extremum point 4: the ROI's rightmost column (the bounding box's maximum x)." +EXTREMA_P4_Y = "Y coordinate of extremum point 4: the bottommost ROI pixel in the ROI's rightmost column." +EXTREMA_P5_X = "X coordinate of extremum point 5: the rightmost ROI pixel in the ROI's bottom row." +EXTREMA_P5_Y = "Y coordinate of extremum point 5: the ROI's bottom row (the bounding box's maximum y)." +EXTREMA_P6_X = "X coordinate of extremum point 6: the leftmost ROI pixel in the ROI's bottom row." +EXTREMA_P6_Y = "Y coordinate of extremum point 6: the ROI's bottom row (the bounding box's maximum y)." +EXTREMA_P7_X = "X coordinate of extremum point 7: the ROI's leftmost column (the bounding box's minimum x)." +EXTREMA_P7_Y = "Y coordinate of extremum point 7: the bottommost ROI pixel in the ROI's leftmost column." +EXTREMA_P8_X = "X coordinate of extremum point 8: the ROI's leftmost column (the bounding box's minimum x)." +EXTREMA_P8_Y = "Y coordinate of extremum point 8: the topmost ROI pixel in the ROI's leftmost column." + +[euler] +EULER_NUMBER = "Euler characteristic of the ROI - the number of objects in the ROI minus the number of holes assuming the 8-neighbor connectivity of ROI's pixels" + +[erosion] +EROSIONS_2_VANISH = "Number of binary erosions with a 3x3 cross-shaped structuring element, applied to the ROI mask inside its bounding box, before the one that leaves no pixel set in the eroded area; the first two and the last row and column of the box are never eroded, so a ROI that fills its box, such as a solid rectangle of at least 4x4 pixels, never vanishes and reports the cap of 1000. 0 for ROIs of a single intensity." +EROSIONS_2_VANISH_COMPLEMENT = "Not computed: the column is never assigned and keeps its initial value of 0." + +[fractal] +FRACT_DIM_BOXCOUNT = "Box-counting fractal dimension of the ROI mask: minus the least-squares slope of log box count versus log box size over power-of-two box sizes; ROIs whose padded side is 32 pixels or less take the smallest count over four grid offsets." +FRACT_DIM_PERIMETER = "Divider (Richardson) fractal dimension of the ROI's contour: 1 minus the least-squares slope of log perimeter versus log mean ruler length, where the contour is walked with strides of decreasing numbers of contour pixels." + +[geodetic] +GEODETIC_LENGTH = "Length of a rectangle with the same area and perimeter as the ROI: PERIMETER divided by 4 plus the square root of (PERIMETER squared divided by 16 minus the area), in pixels; when that difference is negative, as for a disk, no such rectangle exists and the value is PERIMETER divided by 4." +THICKNESS = "Width of a rectangle with the same area and perimeter as the ROI: PERIMETER divided by 2 minus GEODETIC_LENGTH, in pixels; equal to GEODETIC_LENGTH, PERIMETER divided by 4, when no such rectangle exists." + +[feret] +MIN_FERET_ANGLE = "Rotation angle in degrees (0 to 180, in 10-degree steps) at which the ROI's convex hull has its smallest Feret diameter." +MAX_FERET_ANGLE = "Rotation angle in degrees (0 to 180, in 10-degree steps) at which the ROI's convex hull has its largest Feret diameter." +STAT_FERET_DIAM_MIN = "Smallest Feret (caliper) diameter of the ROI's convex hull over rotations from 0 to 180 degrees in 10-degree steps, in pixels." +STAT_FERET_DIAM_MAX = "Largest Feret (caliper) diameter of the ROI's convex hull over rotations from 0 to 180 degrees in 10-degree steps, in pixels." +STAT_FERET_DIAM_MEAN = "Mean Feret (caliper) diameter of the ROI's convex hull over rotations from 0 to 180 degrees in 10-degree steps, in pixels." +STAT_FERET_DIAM_MEDIAN = "Median Feret (caliper) diameter of the ROI's convex hull over rotations from 0 to 180 degrees in 10-degree steps, in pixels." +STAT_FERET_DIAM_STDDEV = "Population standard deviation of the Feret (caliper) diameters of the ROI's convex hull over rotations from 0 to 180 degrees in 10-degree steps, in pixels." +STAT_FERET_DIAM_MODE = "Mode of the Feret diameters of the ROI's convex hull over rotations from 0 to 180 degrees, taken over 1-pixel-wide bins and reported as the bin's whole-pixel value." + +[martin] +STAT_MARTIN_DIAM_MIN = "Smallest Martin diameter of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps; the Martin diameter is the length of the horizontal chord that splits the hull's area in half, in pixels." +STAT_MARTIN_DIAM_MAX = "Largest Martin diameter (area-bisecting horizontal chord) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels." +STAT_MARTIN_DIAM_MEAN = "Mean Martin diameter (area-bisecting horizontal chord) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels." +STAT_MARTIN_DIAM_MEDIAN = "Median Martin diameter (area-bisecting horizontal chord) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels." +STAT_MARTIN_DIAM_STDDEV = "Population standard deviation of the Martin diameters (area-bisecting horizontal chords) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels." +STAT_MARTIN_DIAM_MODE = "Mode of the Martin diameters of the ROI's convex hull over rotations from 0 to 170 degrees, taken over 1-pixel-wide bins and reported as the bin's whole-pixel value." + +[nassenstein] +STAT_NASSENSTEIN_DIAM_MIN = "Smallest Nassenstein diameter of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps; the Nassenstein diameter is the hull's vertical extent through its lowest point, in pixels." +STAT_NASSENSTEIN_DIAM_MAX = "Largest Nassenstein diameter (vertical extent through the lowest point) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels." +STAT_NASSENSTEIN_DIAM_MEAN = "Mean Nassenstein diameter (vertical extent through the lowest point) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels." +STAT_NASSENSTEIN_DIAM_MEDIAN = "Median Nassenstein diameter (vertical extent through the lowest point) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels." +STAT_NASSENSTEIN_DIAM_STDDEV = "Population standard deviation of the Nassenstein diameters of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels." +STAT_NASSENSTEIN_DIAM_MODE = "Mode of the Nassenstein diameters of the ROI's convex hull over rotations from 0 to 170 degrees, taken over 1-pixel-wide bins and reported as the bin's whole-pixel value." + +[chords] +MAXCHORDS_MAX = "Largest of the ROI's maximum chords, in pixels. At each of 20 rotations from 0 to 171 degrees in 9-degree steps, a chord is the longest vertical run of ROI pixels, ending before the raster's last row, in a sampled column of the rotated ROI (every column under 200 pixels of width, otherwise every (width/100)-th), and the maximum chord is the longest of them." +MAXCHORDS_MAX_ANG = "Rotation angle in radians at which MAXCHORDS_MAX occurs." +MAXCHORDS_MIN = "Smallest of the ROI's per-rotation maximum chords (see MAXCHORDS_MAX), one for each of the 20 rotations that has a chord, in pixels." +MAXCHORDS_MIN_ANG = "Rotation angle in radians at which MAXCHORDS_MIN occurs." +MAXCHORDS_MEDIAN = "Median of the ROI's per-rotation maximum chords, one for each of the 20 rotations that has a chord, in pixels." +MAXCHORDS_MEAN = "Mean of the ROI's per-rotation maximum chords, one for each of the 20 rotations that has a chord, in pixels." +MAXCHORDS_MODE = "Most frequent length among the ROI's per-rotation maximum chords, one for each of the 20 rotations that has a chord, in pixels; the smallest such length on ties." +MAXCHORDS_STDDEV = "Unbiased (n-1) standard deviation of the ROI's per-rotation maximum chords, one for each of the 20 rotations that has a chord, in pixels." +ALLCHORDS_MAX = "Longest of all the ROI's chords, in pixels: one chord per column, the column's longest vertical run of ROI pixels ending before the raster's last row, over the columns of the ROI rotated by 0 to 171 degrees in 9-degree steps; every column when the rotated ROI is under 200 pixels wide, otherwise every (width/100)-th column." +ALLCHORDS_MAX_ANG = "Rotation angle in radians at which ALLCHORDS_MAX occurs." +ALLCHORDS_MIN = "Shortest of all the ROI's chords (see ALLCHORDS_MAX), in pixels." +ALLCHORDS_MIN_ANG = "Rotation angle in radians at which ALLCHORDS_MIN occurs." +ALLCHORDS_MEDIAN = "Median length of all the ROI's chords, in pixels." +ALLCHORDS_MEAN = "Mean length of all the ROI's chords, in pixels." +ALLCHORDS_MODE = "Most frequent length among all the ROI's chords, in pixels; the smallest such length on ties." +ALLCHORDS_STDDEV = "Unbiased (n-1) standard deviation of the lengths of all the ROI's chords, in pixels." + +[neighbors] +NUM_NEIGHBORS = "Number of other ROIs whose contour comes within --pixelDistance pixels (default 5, Euclidean distance between contour pixels) of this ROI's contour." +PERCENT_TOUCHING = "Percentage of the ROI's contour pixels that are adjacent, including diagonally, to a contour pixel of another ROI." +CLOSEST_NEIGHBOR1_DIST = "Distance in pixels from the ROI's centroid to the centroid of its nearest neighbor among the ROIs counted in NUM_NEIGHBORS." +CLOSEST_NEIGHBOR1_ANG = "Direction in degrees (0 to 360, from the x axis with y increasing downward) from the ROI's centroid to the centroid of its nearest neighbor." +CLOSEST_NEIGHBOR2_DIST = "Distance in pixels from the ROI's centroid to the centroid of its second-nearest neighbor among the ROIs counted in NUM_NEIGHBORS." +CLOSEST_NEIGHBOR2_ANG = "Direction in degrees (0 to 360, from the x axis with y increasing downward) from the ROI's centroid to the centroid of its second-nearest neighbor." +ANG_BW_NEIGHBORS_MEAN = "Arithmetic mean of the directions in degrees (0 to 360) from the ROI's centroid to the centroids of all its neighbors." +ANG_BW_NEIGHBORS_STDDEV = "Unbiased (n-1) standard deviation of the directions in degrees from the ROI's centroid to the centroids of its neighbors; 0 with fewer than 3 neighbors (with 2, a known defect, https://github.com/PolusAI/nyxus/issues/499)." +ANG_BW_NEIGHBORS_MODE = "Most frequent direction, rounded to whole degrees, from the ROI's centroid to the centroids of its neighbors; the smallest such angle on ties." + +[glcm] +GLCM_ASM = "Angular second moment: the sum of the squared co-occurrence probabilities, which is high when a few grey-level pairs dominate (a uniform texture)." +GLCM_ACOR = "Autocorrelation: the co-occurrence-weighted mean of the product of the two grey levels of a pixel pair, which grows with both coarseness and brightness." +GLCM_CLUPROM = "Cluster prominence: the fourth moment of the sum of the two grey levels of a pair about twice the neighbour pixel's mean grey level, which is large when that distribution is asymmetric or heavy-tailed." +GLCM_CLUSHADE = "Cluster shade: the third moment of the sum of the two grey levels of a pair about twice the neighbour pixel's mean grey level, which measures the skewness of the co-occurrence matrix." +GLCM_CLUTEND = "Cluster tendency: the second moment of the sum of the two grey levels of a pair about twice the neighbour pixel's mean grey level, which measures how strongly pairs group into similar grey levels." +GLCM_CONTRAST = "Contrast: the co-occurrence-weighted mean squared grey-level difference between the two pixels of a pair, which grows with local intensity variation." +GLCM_CORRELATION = "Correlation: the linear correlation, from -1 to 1, between the grey levels of the two pixels of a pair." +GLCM_DIFAVE = "Difference average: the mean absolute grey-level difference between the two pixels of a pair, taken from the distribution of those differences." +GLCM_DIFENTRO = "Difference entropy: the base-2 entropy of the distribution of absolute grey-level differences between paired pixels." +GLCM_DIFVAR = "Difference variance: the variance of the absolute grey-level difference between paired pixels about its mean." +GLCM_DIS = "Dissimilarity: the co-occurrence-weighted mean absolute difference between the grey levels of the two pixels of a pair, taken directly from the co-occurrence matrix." +GLCM_ENERGY = "Energy: the sum of the squared co-occurrence probabilities; it returns the same value as GLCM_ASM." +GLCM_ENTROPY = "Entropy: the base-2 entropy of the co-occurrence probabilities, which is high when many grey-level pairs are about equally likely; it returns the same value as GLCM_JE." +GLCM_HOM1 = "Homogeneity 1: the co-occurrence probabilities weighted by one over one plus the grey-level difference of the pair; it returns the same value as GLCM_ID." +GLCM_HOM2 = "Homogeneity 2: the co-occurrence probabilities weighted by one over one plus the squared grey-level difference of the pair; it returns the same value as GLCM_IDM and has no _AVE code." +GLCM_ID = "Inverse difference: the co-occurrence probabilities weighted by one over one plus the grey-level difference of the pair, which is high when paired pixels have similar grey levels." +GLCM_IDN = "Inverse difference normalized: inverse difference with the grey-level difference divided by the number of grey levels, which makes it less dependent on the number of grey levels." +GLCM_IDM = "Inverse difference moment: the co-occurrence probabilities weighted by one over one plus the squared grey-level difference of the pair, a measure of local homogeneity." +GLCM_IDMN = "Inverse difference moment normalized: inverse difference moment with the squared grey-level difference divided by the squared number of grey levels." +GLCM_INFOMEAS1 = "Informational measure of correlation 1: the joint entropy minus the entropy expected if the two grey levels were independent, divided by the entropy of one marginal distribution; it measures how much the two grey levels depend on each other." +GLCM_INFOMEAS2 = "Informational measure of correlation 2: a 0-to-1 measure of the dependence between the two grey levels of a pair, computed from the gap between the joint entropy and the entropy of the product of the marginals." +GLCM_IV = "Inverse variance: the distribution of grey-level differences between paired pixels weighted by one over the squared difference, leaving out pairs with equal grey levels." +GLCM_JAVE = "Joint average: the co-occurrence-weighted mean grey level of the reference pixel of a pair." +GLCM_JE = "Joint entropy: the base-2 entropy of the co-occurrence probabilities, which is high when many grey-level pairs are about equally likely." +GLCM_JMAX = "Joint maximum: the largest co-occurrence probability, that is the share of pairs taken by the most common grey-level pair." +GLCM_JVAR = "Joint variance: the co-occurrence-weighted spread of the neighbour pixel's grey level around the joint average (the reference pixel's mean); it equals GLCM_VARIANCE only when the matrix is symmetric." +GLCM_SUMAVERAGE = "Sum average: the mean of the sum of the two grey levels of a pixel pair." +GLCM_SUMENTROPY = "Sum entropy: the base-2 entropy of the distribution of the sum of the two grey levels of a pixel pair." +GLCM_SUMVARIANCE = "Sum variance: the spread of the sum of the two grey levels of a pair about twice the neighbour pixel's mean grey level; this code returns the cluster tendency value, the same as GLCM_CLUTEND." +GLCM_VARIANCE = "Variance: the co-occurrence-weighted variance of the reference pixel's grey level about its mean (PyRadiomics sum of squares)." +GLCM_ASM_AVE = "Angular second moment, averaged over the angles." +GLCM_ACOR_AVE = "Autocorrelation, averaged over the angles." +GLCM_CLUPROM_AVE = "Cluster prominence, averaged over the angles." +GLCM_CLUSHADE_AVE = "Cluster shade, averaged over the angles." +GLCM_CLUTEND_AVE = "Cluster tendency, averaged over the angles." +GLCM_CONTRAST_AVE = "Contrast, averaged over the angles." +GLCM_CORRELATION_AVE = "Correlation, averaged over the angles." +GLCM_DIFAVE_AVE = "Difference average, averaged over the angles." +GLCM_DIFENTRO_AVE = "Difference entropy, averaged over the angles." +GLCM_DIFVAR_AVE = "Difference variance, averaged over the angles." +GLCM_DIS_AVE = "Dissimilarity, averaged over the angles." +GLCM_ENERGY_AVE = "Energy, averaged over the angles." +GLCM_ENTROPY_AVE = "Entropy, averaged over the angles." +GLCM_HOM1_AVE = "Homogeneity 1, averaged over the angles." +GLCM_ID_AVE = "Inverse difference, averaged over the angles." +GLCM_IDN_AVE = "Inverse difference normalized, averaged over the angles." +GLCM_IDM_AVE = "Inverse difference moment, averaged over the angles." +GLCM_IDMN_AVE = "Inverse difference moment normalized, averaged over the angles." +GLCM_IV_AVE = "Inverse variance, averaged over the angles." +GLCM_JAVE_AVE = "Joint average, averaged over the angles." +GLCM_JE_AVE = "Joint entropy, averaged over the angles." +GLCM_INFOMEAS1_AVE = "Informational measure of correlation 1, averaged over the angles." +GLCM_INFOMEAS2_AVE = "Informational measure of correlation 2, averaged over the angles." +GLCM_VARIANCE_AVE = "Variance, averaged over the angles." +GLCM_JMAX_AVE = "Joint maximum, averaged over the angles." +GLCM_JVAR_AVE = "Joint variance, averaged over the angles." +GLCM_SUMAVERAGE_AVE = "Sum average, averaged over the angles." +GLCM_SUMENTROPY_AVE = "Sum entropy, averaged over the angles." +GLCM_SUMVARIANCE_AVE = "Sum variance, averaged over the angles." + +[glrlm] +GLRLM_SRE = "Short run emphasis: the mean over all runs of one over the squared run length, which is high when short runs dominate (fine texture)." +GLRLM_LRE = "Long run emphasis: the mean over all runs of the squared run length, which is high when long runs dominate (coarse texture)." +GLRLM_GLN = "Grey level non-uniformity: the sum over grey levels of the squared number of runs at that level, divided by the number of runs; it is low when runs are spread evenly over the grey levels." +GLRLM_GLNN = "Grey level non-uniformity normalized: grey level non-uniformity divided by the number of runs once more, giving a value between 0 and 1." +GLRLM_RLN = "Run length non-uniformity: the sum over run lengths of the squared number of runs of that length, divided by the number of runs; it is low when run lengths are spread evenly." +GLRLM_RLNN = "Run length non-uniformity normalized: run length non-uniformity divided by the number of runs once more, giving a value between 0 and 1." +GLRLM_RP = "Run percentage: the number of runs divided by the number of ROI pixels, which is high for fine texture; with the default binning, runs of bounding-box pixels outside the mask are counted too, so it can exceed 1." +GLRLM_GLV = "Grey level variance: the variance of the grey level over all runs." +GLRLM_RV = "Run variance: the variance of the run length over all runs." +GLRLM_RE = "Run entropy: the base-2 entropy of the joint distribution of run grey level and run length." +GLRLM_LGLRE = "Low grey level run emphasis: the mean over all runs of one over the squared grey level, which is high when dark runs dominate." +GLRLM_HGLRE = "High grey level run emphasis: the mean over all runs of the squared grey level, which is high when bright runs dominate." +GLRLM_SRLGLE = "Short run low grey level emphasis: the mean over all runs of one over the squared run length times the squared grey level, which is high for short dark runs." +GLRLM_SRHGLE = "Short run high grey level emphasis: the mean over all runs of the squared grey level over the squared run length, which is high for short bright runs." +GLRLM_LRLGLE = "Long run low grey level emphasis: the mean over all runs of the squared run length over the squared grey level, which is high for long dark runs." +GLRLM_LRHGLE = "Long run high grey level emphasis: the mean over all runs of the squared run length times the squared grey level, which is high for long bright runs." +GLRLM_SRE_AVE = "Short run emphasis, averaged over the angles." +GLRLM_LRE_AVE = "Long run emphasis, averaged over the angles." +GLRLM_GLN_AVE = "Grey level non-uniformity, averaged over the angles." +GLRLM_GLNN_AVE = "Grey level non-uniformity normalized, averaged over the angles." +GLRLM_RLN_AVE = "Run length non-uniformity, averaged over the angles." +GLRLM_RLNN_AVE = "Run length non-uniformity normalized, averaged over the angles." +GLRLM_RP_AVE = "Run percentage, averaged over the angles." +GLRLM_GLV_AVE = "Grey level variance, averaged over the angles." +GLRLM_RV_AVE = "Run variance, averaged over the angles." +GLRLM_RE_AVE = "Run entropy, averaged over the angles." +GLRLM_LGLRE_AVE = "Low grey level run emphasis, averaged over the angles." +GLRLM_HGLRE_AVE = "High grey level run emphasis, averaged over the angles." +GLRLM_SRLGLE_AVE = "Short run low grey level emphasis, averaged over the angles." +GLRLM_SRHGLE_AVE = "Short run high grey level emphasis, averaged over the angles." +GLRLM_LRLGLE_AVE = "Long run low grey level emphasis, averaged over the angles." +GLRLM_LRHGLE_AVE = "Long run high grey level emphasis, averaged over the angles." + +[glszm] +GLSZM_SAE = "Small area emphasis: the mean over all zones of one over the squared zone size, which is high when small zones dominate (fine texture); IBSI calls it small zone emphasis." +GLSZM_LAE = "Large area emphasis: the mean over all zones of the squared zone size, which is high when large zones dominate (coarse texture); IBSI calls it large zone emphasis." +GLSZM_GLN = "Grey level non-uniformity: the sum over grey levels of the squared number of zones at that level, divided by the number of zones; it is low when zones are spread evenly over the grey levels." +GLSZM_GLNN = "Grey level non-uniformity normalized: grey level non-uniformity divided by the number of zones once more, giving a value between 0 and 1." +GLSZM_SZN = "Size zone non-uniformity: the sum over zone sizes of the squared number of zones of that size, divided by the number of zones; it is low when zone sizes are spread evenly." +GLSZM_SZNN = "Size zone non-uniformity normalized: size zone non-uniformity divided by the number of zones once more, giving a value between 0 and 1." +GLSZM_ZP = "Zone percentage: the number of zones divided by the number of pixels in zones, which is high for fine texture; with the default binning every pixel of the bounding box is in a zone, inside the mask or not." +GLSZM_GLV = "Grey level variance: the variance of the grey level over all zones." +GLSZM_ZV = "Zone variance: the variance of the zone size over all zones." +GLSZM_ZE = "Zone entropy: the base-2 entropy of the joint distribution of zone grey level and zone size." +GLSZM_LGLZE = "Low grey level zone emphasis: the mean over all zones of one over the squared grey level, which is high when dark zones dominate." +GLSZM_HGLZE = "High grey level zone emphasis: the mean over all zones of the squared grey level, which is high when bright zones dominate." +GLSZM_SALGLE = "Small area low grey level emphasis: the mean over all zones of one over the squared zone size times the squared grey level, which is high for small dark zones." +GLSZM_SAHGLE = "Small area high grey level emphasis: the mean over all zones of the squared grey level over the squared zone size, which is high for small bright zones." +GLSZM_LALGLE = "Large area low grey level emphasis: the mean over all zones of the squared zone size over the squared grey level, which is high for large dark zones." +GLSZM_LAHGLE = "Large area high grey level emphasis: the mean over all zones of the squared zone size times the squared grey level, which is high for large bright zones." + +[gldm] +GLDM_SDE = "Small dependence emphasis: the mean over all pixels of one over the squared dependence, which is high when pixels have few same-level neighbours (heterogeneous texture)." +GLDM_LDE = "Large dependence emphasis: the mean over all pixels of the squared dependence, which is high when pixels have many same-level neighbours (homogeneous texture)." +GLDM_GLN = "Grey level non-uniformity: the sum over grey levels of the squared number of pixels at that level, divided by the number of pixels; it is low when the grey levels are evenly represented." +GLDM_DN = "Dependence non-uniformity: the sum over dependence values of the squared number of pixels with that dependence, divided by the number of pixels; it is low when dependences are spread evenly." +GLDM_DNN = "Dependence non-uniformity normalized: dependence non-uniformity divided by the number of pixels once more, giving a value between 0 and 1." +GLDM_GLV = "Grey level variance: the variance of the grey level over the dependence matrix." +GLDM_DV = "Dependence variance: the variance of the dependence over the dependence matrix." +GLDM_DE = "Dependence entropy: the base-2 entropy of the joint distribution of grey level and dependence." +GLDM_LGLE = "Low grey level emphasis: the mean over all pixels of one over the squared grey level, which is high when dark pixels dominate." +GLDM_HGLE = "High grey level emphasis: the mean over all pixels of the squared grey level, which is high when bright pixels dominate." +GLDM_SDLGLE = "Small dependence low grey level emphasis: the mean over all pixels of one over the squared dependence times the squared grey level, which is high for dark pixels with few same-level neighbours." +GLDM_SDHGLE = "Small dependence high grey level emphasis: the mean over all pixels of the squared grey level over the squared dependence, which is high for bright pixels with few same-level neighbours." +GLDM_LDLGLE = "Large dependence low grey level emphasis: the mean over all pixels of the squared dependence over the squared grey level, which is high for dark pixels with many same-level neighbours." +GLDM_LDHGLE = "Large dependence high grey level emphasis: the mean over all pixels of the squared dependence times the squared grey level, which is high for bright pixels with many same-level neighbours." + +[gldzm] +GLDZM_SDE = "Small distance emphasis: the mean over all zones of one over the squared zone distance, which is high when zones lie close to the ROI edge." +GLDZM_LDE = "Large distance emphasis: the mean over all zones of the squared zone distance, which is high when zones lie deep inside the ROI." +GLDZM_LGLZE = "Low grey level zone emphasis: the mean over all zones of one over the squared grey level, which is high when dark zones dominate." +GLDZM_HGLZE = "High grey level zone emphasis: the mean over all zones of the squared grey level, which is high when bright zones dominate." +GLDZM_SDLGLE = "Small distance low grey level emphasis: the mean over all zones of one over the squared zone distance times the squared grey level, which is high for dark zones near the ROI edge." +GLDZM_SDHGLE = "Small distance high grey level emphasis: the mean over all zones of the squared grey level over the squared zone distance, which is high for bright zones near the ROI edge." +GLDZM_LDLGLE = "Large distance low grey level emphasis: the mean over all zones of the squared zone distance over the squared grey level, which is high for dark zones deep inside the ROI." +GLDZM_LDHGLE = "Large distance high grey level emphasis: the mean over all zones of the squared zone distance times the squared grey level, which is high for bright zones deep inside the ROI." +GLDZM_GLNU = "Grey level non-uniformity: the sum over grey levels of the squared number of zones at that level, divided by the number of zones; it is low when zones are spread evenly over the grey levels." +GLDZM_GLNUN = "Grey level non-uniformity normalized: grey level non-uniformity divided by the number of zones once more, giving a value between 0 and 1." +GLDZM_ZDNU = "Zone distance non-uniformity: the sum over distances of the squared number of zones at that distance, divided by the number of zones; it is low when zone distances are spread evenly." +GLDZM_ZDNUN = "Zone distance non-uniformity normalized: zone distance non-uniformity divided by the number of zones once more, giving a value between 0 and 1." +GLDZM_ZP = "Zone percentage: the number of zones divided by the number of ROI pixels, which is high for fine texture." +GLDZM_GLM = "Grey level mean: the mean grey level over all zones." +GLDZM_GLV = "Grey level variance: the variance of the grey level over all zones." +GLDZM_ZDM = "Zone distance mean: the mean zone distance over all zones." +GLDZM_ZDV = "Zone distance variance: the variance of the zone distance over all zones." +GLDZM_ZDE = "Zone distance entropy: the base-2 entropy of the joint distribution of zone grey level and zone distance." + +[ngldm] +NGLDM_LDE = "Low dependence emphasis: the mean over all ROI pixels of one over the squared dependence count, which is high when pixels have few same-level neighbours." +NGLDM_HDE = "High dependence emphasis: the mean over all ROI pixels of the squared dependence count, which is high when pixels have many same-level neighbours." +NGLDM_LGLCE = "Low grey level count emphasis: the mean over all ROI pixels of one over the squared grey level, which is high when dark pixels dominate." +NGLDM_HGLCE = "High grey level count emphasis: the mean over all ROI pixels of the squared grey level, which is high when bright pixels dominate." +NGLDM_LDLGLE = "Low dependence low grey level emphasis: the mean over all ROI pixels of one over the squared dependence count times the squared grey level, which is high for dark pixels with few same-level neighbours." +NGLDM_LDHGLE = "Low dependence high grey level emphasis: the mean over all ROI pixels of the squared grey level over the squared dependence count, which is high for bright pixels with few same-level neighbours." +NGLDM_HDLGLE = "High dependence low grey level emphasis: the mean over all ROI pixels of the squared dependence count over the squared grey level, which is high for dark pixels with many same-level neighbours." +NGLDM_HDHGLE = "High dependence high grey level emphasis: the mean over all ROI pixels of the squared dependence count times the squared grey level, which is high for bright pixels with many same-level neighbours." +NGLDM_GLNU = "Grey level non-uniformity: the sum over grey levels of the squared number of pixels at that level, divided by the number of pixels; it is low when the grey levels are evenly represented." +NGLDM_GLNUN = "Grey level non-uniformity normalized: grey level non-uniformity divided by the number of pixels once more, giving a value between 0 and 1." +NGLDM_DCNU = "Dependence count non-uniformity: the sum over dependence counts of the squared number of pixels with that count, divided by the number of pixels; it is low when dependence counts are spread evenly." +NGLDM_DCNUN = "Dependence count non-uniformity normalized: dependence count non-uniformity divided by the number of pixels once more, giving a value between 0 and 1." +NGLDM_DCP = "Dependence count percentage: the fraction of ROI pixels that serve as neighbourhood centres; every ROI pixel is one, so the value is always 1." +NGLDM_GLM = "Grey level mean: the mean grey level over the dependence matrix." +NGLDM_GLV = "Grey level variance: the variance of the grey level over the dependence matrix." +NGLDM_DCM = "Dependence count mean: the mean dependence count over the dependence matrix." +NGLDM_DCV = "Dependence count variance: the variance of the dependence count over the dependence matrix." +NGLDM_DCENT = "Dependence count entropy: the base-2 entropy of the joint distribution of grey level and dependence count." +NGLDM_DCENE = "Dependence count energy: the sum of the squared probabilities of the dependence matrix, which is high when a few combinations of grey level and dependence count dominate." + +[ngtdm] +NGTDM_COARSENESS = "Coarseness: one over the probability-weighted sum of the differences between each grey level and its neighbourhood averages, which is high when intensity changes slowly (large uniform patches)." +NGTDM_CONTRAST = "Contrast: the spread of the grey levels present multiplied by the mean difference between a pixel and its neighbourhood average, which is high when both the dynamic range and the local variation are large." +NGTDM_BUSYNESS = "Busyness: the probability-weighted neighbourhood differences divided by the summed differences between probability-weighted grey levels, which is high when intensity changes rapidly from pixel to pixel." +NGTDM_COMPLEXITY = "Complexity: the grey-level differences between pairs of levels weighted by their neighbourhood differences, which is high when the image has many abrupt intensity changes." +NGTDM_STRENGTH = "Strength: the probability-weighted squared differences between grey levels divided by the summed neighbourhood differences, which is high when the image has large, distinct regions of different intensity." + +[shape_moments] +SPAT_MOMENT_00 = "Raw moment of the ROI shape: the sum of x to the power 0 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_01 = "Raw moment of the ROI shape: the sum of x to the power 0 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_02 = "Raw moment of the ROI shape: the sum of x to the power 0 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_03 = "Raw moment of the ROI shape: the sum of x to the power 0 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_10 = "Raw moment of the ROI shape: the sum of x to the power 1 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_11 = "Raw moment of the ROI shape: the sum of x to the power 1 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_12 = "Raw moment of the ROI shape: the sum of x to the power 1 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_13 = "Raw moment of the ROI shape: the sum of x to the power 1 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_20 = "Raw moment of the ROI shape: the sum of x to the power 2 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_21 = "Raw moment of the ROI shape: the sum of x to the power 2 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_22 = "Raw moment of the ROI shape: the sum of x to the power 2 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_23 = "Raw moment of the ROI shape: the sum of x to the power 2 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner." +SPAT_MOMENT_30 = "Raw moment of the ROI shape: the sum of x to the power 3 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner." +CENTRAL_MOMENT_00 = "Central moment of the ROI shape: the sum of (x - xc) to the power 0 times (y - yc) to the power 0 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_01 = "Central moment of the ROI shape: the sum of (x - xc) to the power 0 times (y - yc) to the power 1 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_02 = "Central moment of the ROI shape: the sum of (x - xc) to the power 0 times (y - yc) to the power 2 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_03 = "Central moment of the ROI shape: the sum of (x - xc) to the power 0 times (y - yc) to the power 3 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_10 = "Central moment of the ROI shape: the sum of (x - xc) to the power 1 times (y - yc) to the power 0 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_11 = "Central moment of the ROI shape: the sum of (x - xc) to the power 1 times (y - yc) to the power 1 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_12 = "Central moment of the ROI shape: the sum of (x - xc) to the power 1 times (y - yc) to the power 2 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_13 = "Central moment of the ROI shape: the sum of (x - xc) to the power 1 times (y - yc) to the power 3 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_20 = "Central moment of the ROI shape: the sum of (x - xc) to the power 2 times (y - yc) to the power 0 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_21 = "Central moment of the ROI shape: the sum of (x - xc) to the power 2 times (y - yc) to the power 1 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_22 = "Central moment of the ROI shape: the sum of (x - xc) to the power 2 times (y - yc) to the power 2 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_23 = "Central moment of the ROI shape: the sum of (x - xc) to the power 2 times (y - yc) to the power 3 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_30 = "Central moment of the ROI shape: the sum of (x - xc) to the power 3 times (y - yc) to the power 0 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_31 = "Central moment of the ROI shape: the sum of (x - xc) to the power 3 times (y - yc) to the power 1 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_32 = "Central moment of the ROI shape: the sum of (x - xc) to the power 3 times (y - yc) to the power 2 over the ROI's pixels, where (xc, yc) is the centroid." +CENTRAL_MOMENT_33 = "Central moment of the ROI shape: the sum of (x - xc) to the power 3 times (y - yc) to the power 3 over the ROI's pixels, where (xc, yc) is the centroid." +NORM_SPAT_MOMENT_00 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_00 divided by SPAT_MOMENT_00 to the power (0+0)/2 + 1." +NORM_SPAT_MOMENT_01 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_01 divided by SPAT_MOMENT_00 to the power (0+1)/2 + 1." +NORM_SPAT_MOMENT_02 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_02 divided by SPAT_MOMENT_00 to the power (0+2)/2 + 1." +NORM_SPAT_MOMENT_03 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_03 divided by SPAT_MOMENT_00 to the power (0+3)/2 + 1." +NORM_SPAT_MOMENT_10 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_10 divided by SPAT_MOMENT_00 to the power (1+0)/2 + 1." +NORM_SPAT_MOMENT_11 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_11 divided by SPAT_MOMENT_00 to the power (1+1)/2 + 1." +NORM_SPAT_MOMENT_12 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_12 divided by SPAT_MOMENT_00 to the power (1+2)/2 + 1." +NORM_SPAT_MOMENT_13 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_13 divided by SPAT_MOMENT_00 to the power (1+3)/2 + 1." +NORM_SPAT_MOMENT_20 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_20 divided by SPAT_MOMENT_00 to the power (2+0)/2 + 1." +NORM_SPAT_MOMENT_21 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_21 divided by SPAT_MOMENT_00 to the power (2+1)/2 + 1." +NORM_SPAT_MOMENT_22 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_22 divided by SPAT_MOMENT_00 to the power (2+2)/2 + 1." +NORM_SPAT_MOMENT_23 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_23 divided by SPAT_MOMENT_00 to the power (2+3)/2 + 1." +NORM_SPAT_MOMENT_30 = "Normalized raw moment of the ROI shape: SPAT_MOMENT_30 divided by SPAT_MOMENT_00 to the power (3+0)/2 + 1." +NORM_SPAT_MOMENT_31 = "Normalized raw moment of the ROI shape: the raw moment of order (3, 1) (the sum of x to the power 3 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner), divided by SPAT_MOMENT_00 to the power (3+1)/2 + 1." +NORM_SPAT_MOMENT_32 = "Normalized raw moment of the ROI shape: the raw moment of order (3, 2) (the sum of x to the power 3 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner), divided by SPAT_MOMENT_00 to the power (3+2)/2 + 1." +NORM_SPAT_MOMENT_33 = "Normalized raw moment of the ROI shape: the raw moment of order (3, 3) (the sum of x to the power 3 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner), divided by SPAT_MOMENT_00 to the power (3+3)/2 + 1." +NORM_CENTRAL_MOMENT_02 = "Normalized central moment of the ROI shape: CENTRAL_MOMENT_02 divided by SPAT_MOMENT_00 to the power (0+2)/2 + 1." +NORM_CENTRAL_MOMENT_03 = "Normalized central moment of the ROI shape: CENTRAL_MOMENT_03 divided by SPAT_MOMENT_00 to the power (0+3)/2 + 1." +NORM_CENTRAL_MOMENT_11 = "Normalized central moment of the ROI shape: CENTRAL_MOMENT_11 divided by SPAT_MOMENT_00 to the power (1+1)/2 + 1." +NORM_CENTRAL_MOMENT_12 = "Normalized central moment of the ROI shape: CENTRAL_MOMENT_12 divided by SPAT_MOMENT_00 to the power (1+2)/2 + 1." +NORM_CENTRAL_MOMENT_20 = "Normalized central moment of the ROI shape: CENTRAL_MOMENT_20 divided by SPAT_MOMENT_00 to the power (2+0)/2 + 1." +NORM_CENTRAL_MOMENT_21 = "Normalized central moment of the ROI shape: CENTRAL_MOMENT_21 divided by SPAT_MOMENT_00 to the power (2+1)/2 + 1." +NORM_CENTRAL_MOMENT_30 = "Normalized central moment of the ROI shape: CENTRAL_MOMENT_30 divided by SPAT_MOMENT_00 to the power (3+0)/2 + 1." +HU_M1 = "Hu's invariant number 1 of the ROI shape, computed from its normalized central moments." +HU_M2 = "Hu's invariant number 2 of the ROI shape, computed from its normalized central moments." +HU_M3 = "Hu's invariant number 3 of the ROI shape, computed from its normalized central moments." +HU_M4 = "Hu's invariant number 4 of the ROI shape, computed from its normalized central moments." +HU_M5 = "Hu's invariant number 5 of the ROI shape, computed from its normalized central moments." +HU_M6 = "Hu's invariant number 6 of the ROI shape, computed from its normalized central moments." +HU_M7 = "Hu's invariant number 7 of the ROI shape, computed from its normalized central moments." +WEIGHTED_SPAT_MOMENT_00 = "Raw moment of order (0, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_SPAT_MOMENT_01 = "Raw moment of order (0, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_SPAT_MOMENT_02 = "Raw moment of order (0, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_SPAT_MOMENT_03 = "Raw moment of order (0, 3) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_SPAT_MOMENT_10 = "Raw moment of order (1, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_SPAT_MOMENT_11 = "Raw moment of order (1, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_SPAT_MOMENT_12 = "Raw moment of order (1, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_SPAT_MOMENT_20 = "Raw moment of order (2, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_SPAT_MOMENT_21 = "Raw moment of order (2, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_SPAT_MOMENT_30 = "Raw moment of order (3, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_CENTRAL_MOMENT_02 = "Central moment of order (0, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_CENTRAL_MOMENT_03 = "Central moment of order (0, 3) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_CENTRAL_MOMENT_11 = "Central moment of order (1, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_CENTRAL_MOMENT_12 = "Central moment of order (1, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_CENTRAL_MOMENT_20 = "Central moment of order (2, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_CENTRAL_MOMENT_21 = "Central moment of order (2, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_CENTRAL_MOMENT_30 = "Central moment of order (3, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WT_NORM_CTR_MOM_02 = "Normalized central moment of order (0, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WT_NORM_CTR_MOM_03 = "Normalized central moment of order (0, 3) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WT_NORM_CTR_MOM_11 = "Normalized central moment of order (1, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WT_NORM_CTR_MOM_12 = "Normalized central moment of order (1, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WT_NORM_CTR_MOM_20 = "Normalized central moment of order (2, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WT_NORM_CTR_MOM_21 = "Normalized central moment of order (2, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WT_NORM_CTR_MOM_30 = "Normalized central moment of order (3, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_HU_M1 = "Hu's invariant number 1 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_HU_M2 = "Hu's invariant number 2 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_HU_M3 = "Hu's invariant number 3 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_HU_M4 = "Hu's invariant number 4 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_HU_M5 = "Hu's invariant number 5 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_HU_M6 = "Hu's invariant number 6 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." +WEIGHTED_HU_M7 = "Hu's invariant number 7 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note." + +[intensity_moments] +IMOM_RM_00 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 0 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_01 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 0 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_02 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 0 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_03 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 0 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_10 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 1 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_11 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 1 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_12 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 1 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_13 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 1 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_20 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 2 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_21 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 2 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_22 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 2 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_23 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 2 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_RM_30 = "Raw moment of the ROI's intensities: the sum of the intensity times x to the power 3 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner." +IMOM_CM_00 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 0 times (y - yc) to the power 0, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_01 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 0 times (y - yc) to the power 1, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_02 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 0 times (y - yc) to the power 2, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_03 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 0 times (y - yc) to the power 3, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_10 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 1 times (y - yc) to the power 0, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_11 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 1 times (y - yc) to the power 1, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_12 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 1 times (y - yc) to the power 2, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_13 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 1 times (y - yc) to the power 3, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_20 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 2 times (y - yc) to the power 0, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_21 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 2 times (y - yc) to the power 1, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_22 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 2 times (y - yc) to the power 2, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_23 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 2 times (y - yc) to the power 3, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_30 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 3 times (y - yc) to the power 0, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_31 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 3 times (y - yc) to the power 1, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_32 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 3 times (y - yc) to the power 2, where (xc, yc) is the intensity-weighted centroid." +IMOM_CM_33 = "Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 3 times (y - yc) to the power 3, where (xc, yc) is the intensity-weighted centroid." +IMOM_NRM_00 = "Normalized raw intensity moment: IMOM_RM_00 divided by IMOM_RM_00 to the power (0+0)/2 + 1." +IMOM_NRM_01 = "Normalized raw intensity moment: IMOM_RM_01 divided by IMOM_RM_00 to the power (0+1)/2 + 1." +IMOM_NRM_02 = "Normalized raw intensity moment: IMOM_RM_02 divided by IMOM_RM_00 to the power (0+2)/2 + 1." +IMOM_NRM_03 = "Normalized raw intensity moment: IMOM_RM_03 divided by IMOM_RM_00 to the power (0+3)/2 + 1." +IMOM_NRM_10 = "Normalized raw intensity moment: IMOM_RM_10 divided by IMOM_RM_00 to the power (1+0)/2 + 1." +IMOM_NRM_11 = "Normalized raw intensity moment: IMOM_RM_11 divided by IMOM_RM_00 to the power (1+1)/2 + 1." +IMOM_NRM_12 = "Normalized raw intensity moment: IMOM_RM_12 divided by IMOM_RM_00 to the power (1+2)/2 + 1." +IMOM_NRM_13 = "Normalized raw intensity moment: IMOM_RM_13 divided by IMOM_RM_00 to the power (1+3)/2 + 1." +IMOM_NRM_20 = "Normalized raw intensity moment: IMOM_RM_20 divided by IMOM_RM_00 to the power (2+0)/2 + 1." +IMOM_NRM_21 = "Normalized raw intensity moment: IMOM_RM_21 divided by IMOM_RM_00 to the power (2+1)/2 + 1." +IMOM_NRM_22 = "Normalized raw intensity moment: IMOM_RM_22 divided by IMOM_RM_00 to the power (2+2)/2 + 1." +IMOM_NRM_23 = "Normalized raw intensity moment: IMOM_RM_23 divided by IMOM_RM_00 to the power (2+3)/2 + 1." +IMOM_NRM_30 = "Normalized raw intensity moment: IMOM_RM_30 divided by IMOM_RM_00 to the power (3+0)/2 + 1." +IMOM_NRM_31 = "Normalized raw intensity moment: the raw intensity moment of order (3, 1) (the sum of the intensity times x to the power 3 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner), divided by IMOM_RM_00 to the power (3+1)/2 + 1." +IMOM_NRM_32 = "Normalized raw intensity moment: the raw intensity moment of order (3, 2) (the sum of the intensity times x to the power 3 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner), divided by IMOM_RM_00 to the power (3+2)/2 + 1." +IMOM_NRM_33 = "Normalized raw intensity moment: the raw intensity moment of order (3, 3) (the sum of the intensity times x to the power 3 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner), divided by IMOM_RM_00 to the power (3+3)/2 + 1." +IMOM_NCM_02 = "Normalized central intensity moment: IMOM_CM_02 divided by IMOM_RM_00 to the power (0+2)/2 + 1." +IMOM_NCM_03 = "Normalized central intensity moment: IMOM_CM_03 divided by IMOM_RM_00 to the power (0+3)/2 + 1." +IMOM_NCM_11 = "Normalized central intensity moment: IMOM_CM_11 divided by IMOM_RM_00 to the power (1+1)/2 + 1." +IMOM_NCM_12 = "Normalized central intensity moment: IMOM_CM_12 divided by IMOM_RM_00 to the power (1+2)/2 + 1." +IMOM_NCM_20 = "Normalized central intensity moment: IMOM_CM_20 divided by IMOM_RM_00 to the power (2+0)/2 + 1." +IMOM_NCM_21 = "Normalized central intensity moment: IMOM_CM_21 divided by IMOM_RM_00 to the power (2+1)/2 + 1." +IMOM_NCM_30 = "Normalized central intensity moment: IMOM_CM_30 divided by IMOM_RM_00 to the power (3+0)/2 + 1." +IMOM_HU1 = "Hu's invariant number 1 of the ROI's intensities, computed from the normalized central intensity moments." +IMOM_HU2 = "Hu's invariant number 2 of the ROI's intensities, computed from the normalized central intensity moments." +IMOM_HU3 = "Hu's invariant number 3 of the ROI's intensities, computed from the normalized central intensity moments." +IMOM_HU4 = "Hu's invariant number 4 of the ROI's intensities, computed from the normalized central intensity moments." +IMOM_HU5 = "Hu's invariant number 5 of the ROI's intensities, computed from the normalized central intensity moments." +IMOM_HU6 = "Hu's invariant number 6 of the ROI's intensities, computed from the normalized central intensity moments." +IMOM_HU7 = "Hu's invariant number 7 of the ROI's intensities, computed from the normalized central intensity moments." +IMOM_WRM_00 = "Raw intensity moment of order (0, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WRM_01 = "Raw intensity moment of order (0, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WRM_02 = "Raw intensity moment of order (0, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WRM_03 = "Raw intensity moment of order (0, 3) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WRM_10 = "Raw intensity moment of order (1, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WRM_11 = "Raw intensity moment of order (1, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WRM_12 = "Raw intensity moment of order (1, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WRM_20 = "Raw intensity moment of order (2, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WRM_21 = "Raw intensity moment of order (2, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WRM_30 = "Raw intensity moment of order (3, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WCM_02 = "Central intensity moment of order (0, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WCM_03 = "Central intensity moment of order (0, 3) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WCM_11 = "Central intensity moment of order (1, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WCM_12 = "Central intensity moment of order (1, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WCM_20 = "Central intensity moment of order (2, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WCM_21 = "Central intensity moment of order (2, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WCM_30 = "Central intensity moment of order (3, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WNCM_02 = "Normalized central intensity moment of order (0, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WNCM_03 = "Normalized central intensity moment of order (0, 3) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WNCM_11 = "Normalized central intensity moment of order (1, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WNCM_12 = "Normalized central intensity moment of order (1, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WNCM_20 = "Normalized central intensity moment of order (2, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WNCM_21 = "Normalized central intensity moment of order (2, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WNCM_30 = "Normalized central intensity moment of order (3, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WHU1 = "Hu's invariant number 1 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WHU2 = "Hu's invariant number 2 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WHU3 = "Hu's invariant number 3 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WHU4 = "Hu's invariant number 4 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WHU5 = "Hu's invariant number 5 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WHU6 = "Hu's invariant number 6 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." +IMOM_WHU7 = "Hu's invariant number 7 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note." + +[gabor] +GABOR = "Multi-column feature GABOR_0 to GABOR_N-1, one column per configured (frequency, orientation) pair. Each value is the number of bounding-box pixels (pixels outside the mask count as 0) whose Gabor filter response, divided by the maximum response of a low-pass baseline Gabor filter, exceeds a threshold (default 0.025), divided by the number of pixels where the baseline response exceeds its minimum; 0 for ROIs of a single intensity." + +[zernike] +ZERNIKE2D = "Multi-column feature ZERNIKE2D_Z0 to ZERNIKE2D_Z29: the magnitudes of the 30 Zernike moments of orders 0 to 9 (repetitions with order minus repetition even) of the ROI's intensity image, over a unit disk centred on the intensity centroid whose radius is the smaller side of the ROI's bounding box." + +[radial] +FRAC_AT_D = "Multi-column feature FRAC_AT_D_0 to FRAC_AT_D_7: the fraction of the ROI's pixels in each of 8 radial bins around the ROI's center pixel: 7 equal rings out to the distance of the farthest contour pixel, as an approximate search finds it, plus an eighth ring for every pixel at or beyond that distance (differs from CellProfiler FracAtD, the fraction of total intensity)." +MEAN_FRAC = "Multi-column feature MEAN_FRAC_0 to MEAN_FRAC_7: the mean pixel intensity in each of the 8 radial bins used by FRAC_AT_D (differs from CellProfiler MeanFrac, the fraction of intensity divided by the fraction of pixels)." +RADIAL_CV = "Multi-column feature RADIAL_CV_0 to RADIAL_CV_7: for each of the 8 radial bins used by FRAC_AT_D, the coefficient of variation (population standard deviation over mean) of the summed intensity across 8 equal angular wedges around the ROI's center pixel." + +[intensity_3d] +3COV = "Coefficient of variation: the unbiased (n-1) standard deviation of the ROI's voxel intensities divided by their mean; 0 when the mean is 0." +3COVERED_IMAGE_INTENSITY_RANGE = "Ratio of the ROI's intensity range (3MAX minus 3MIN) to the intensity range of the voxels of all ROIs in the volume; voxels outside every ROI are not used." +3ENERGY = "Sum of the squared voxel intensities of the ROI." +3ENTROPY = "Shannon entropy (base 2) of the ROI's intensity histogram, binned into |--coarseGrayDepth| equal-width bins (default 64) spanning the ROI's minimum to maximum intensity." +3EXCESS_KURTOSIS = "Excess kurtosis of the ROI's voxel intensities: the population fourth standardized moment minus 3; 0 for ROIs with fewer than 5 voxels or a single intensity." +3HYPERFLATNESS = "Sixth standardized moment of the ROI's voxel intensities: the mean sixth power of the deviations from the mean, divided by the sixth power of the unbiased (n-1) standard deviation." +3HYPERSKEWNESS = "Fifth standardized moment of the ROI's voxel intensities: the mean fifth power of the deviations from the mean, divided by the fifth power of the unbiased (n-1) standard deviation." +3INTEGRATED_INTENSITY = "Sum of the voxel intensities of the ROI." +3INTERQUARTILE_RANGE = "Difference between the 75th and 25th percentiles (3P75 minus 3P25) of the ROI's voxel intensities." +3KURTOSIS = "Pearson kurtosis of the ROI's voxel intensities: the population fourth standardized moment, without subtracting 3; 0 for ROIs with fewer than 5 voxels or a single intensity." +3MAX = "Maximum voxel intensity in the ROI." +3MEAN = "Mean voxel intensity of the ROI." +3MEAN_ABSOLUTE_DEVIATION = "Mean absolute deviation of the ROI's voxel intensities from their mean." +3MEDIAN = "Median voxel intensity of the ROI; the average of the two middle values when the voxel count is even." +3MEDIAN_ABSOLUTE_DEVIATION = "Mean absolute deviation of the ROI's voxel intensities from their median (differs from the median of the absolute deviations from the median)." +3MIN = "Minimum voxel intensity in the ROI." +3MODE = "Most frequent voxel intensity in the ROI; the smallest such intensity when several share the top count." +3P01 = "1st percentile of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +3P10 = "10th percentile of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +3P25 = "25th percentile (first quartile) of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +3P75 = "75th percentile (third quartile) of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +3P90 = "90th percentile of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +3P99 = "99th percentile of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range." +3QCOD = "Quartile coefficient of dispersion: (3P75 minus 3P25) divided by (3P75 plus 3P25); 0 when the denominator is 0." +3RANGE = "Difference between the maximum and minimum voxel intensities of the ROI (3MAX minus 3MIN)." +3ROBUST_MEAN = "Mean of the ROI's voxel intensities that lie between 3P10 and 3P90, inclusive." +3ROBUST_MEAN_ABSOLUTE_DEVIATION = "Mean absolute deviation from their own mean of the ROI's voxel intensities that lie between 3P10 and 3P90, inclusive." +3ROOT_MEAN_SQUARED = "Root mean square of the ROI's voxel intensities: the square root of 3ENERGY divided by the voxel count." +3SKEWNESS = "Skewness of the ROI's voxel intensities: the population third standardized moment; 0 for ROIs with fewer than 4 voxels or a single intensity." +3STANDARD_DEVIATION = "Unbiased standard deviation of the ROI's voxel intensities, with n-1 in the denominator." +3STANDARD_DEVIATION_BIASED = "Biased (population) standard deviation of the ROI's voxel intensities, with n in the denominator." +3STANDARD_ERROR = "Standard error of the mean: the unbiased standard deviation divided by the square root of the voxel count." +3VARIANCE = "Unbiased variance of the ROI's voxel intensities, with n-1 in the denominator." +3VARIANCE_BIASED = "Biased (population) variance of the ROI's voxel intensities, with n in the denominator." +3UNIFORMITY = "Sum of the squared bin probabilities of the ROI's intensity histogram, binned into |--coarseGrayDepth| equal-width bins (default 64) spanning the ROI's minimum to maximum intensity." +3UNIFORMITY_PIU = "Percent image uniformity: (1 minus (3MAX minus 3MIN) divided by (3MAX plus 3MIN)) times 100; 0 when 3MAX plus 3MIN is 0." + +[shape_3d] +3AREA = "Surface area of the ROI from its exposed voxel faces: faces of ROI voxels whose 6-connected neighbour is not in the ROI, each weighted by its area (the product of the anisotropy factors of the two axes it spans), so with no anisotropy it is the number of exposed faces (a staircase area, not a mesh area)." +3AREA_2_VOLUME = "3AREA divided by 3VOXEL_VOLUME." +3COMPACTNESS1 = "3VOXEL_VOLUME divided by the square root of pi times 3AREA cubed (differs from IBSI, which uses the mesh area and mesh volume)." +3COMPACTNESS2 = "36 pi times 3VOXEL_VOLUME squared, divided by 3AREA cubed (differs from IBSI, which uses the mesh area and mesh volume)." +3MESH_VOLUME = "Volume enclosed by the ROI's marching-cubes surface mesh, in cubic voxels." +3SPHERICAL_DISPROPORTION = "3AREA divided by the surface area of a sphere whose volume is 3VOXEL_VOLUME (differs from IBSI, which uses the mesh area and mesh volume)." +3SPHERICITY = "Surface area of a sphere whose volume is 3VOXEL_VOLUME, divided by 3AREA; the reciprocal of 3SPHERICAL_DISPROPORTION (differs from IBSI, which uses the mesh area and mesh volume)." +3VOLUME_CONVEXHULL = "Volume of the convex hull of the vertices of the ROI's marching-cubes surface mesh, in cubic voxels." +3VOXEL_VOLUME = "Volume of the ROI from its voxel count: the count times the volume of a ball of diameter 1 voxel (pi/6), divided by the packing density 0.5236, which leaves the voxel count scaled by 0.9999976, times the product of the three anisotropy factors; with a single whole-volume ROI it is the exact volume of the box Nyxus substitutes for the ROI, times the same product but without the 0.9999976 factor (on the command line and through featurize_directory a box one voxel larger than the volume on every axis, known defect, https://github.com/PolusAI/nyxus/issues/493; see the 3D shape page)." +3MAJOR_AXIS_LEN = "4 times the square root of the largest eigenvalue of the covariance matrix of the ROI's voxel coordinates, in voxels." +3MINOR_AXIS_LEN = "4 times the square root of the second-largest eigenvalue of the covariance matrix of the ROI's voxel coordinates, in voxels." +3LEAST_AXIS_LEN = "4 times the square root of the smallest eigenvalue of the covariance matrix of the ROI's voxel coordinates, in voxels." +3ELONGATION = "3MINOR_AXIS_LEN divided by 3MAJOR_AXIS_LEN: the square root of the ratio of the second-largest to the largest eigenvalue of the voxel-coordinate covariance matrix." +3FLATNESS = "3LEAST_AXIS_LEN divided by 3MAJOR_AXIS_LEN: the square root of the ratio of the smallest to the largest eigenvalue of the voxel-coordinate covariance matrix." + +[glcm_3d] +3GLCM_ACOR = "Autocorrelation: the co-occurrence-weighted mean of the product of the two grey levels of a voxel pair, which grows with both coarseness and brightness." +3GLCM_ASM = "Angular second moment: the sum of the squared co-occurrence probabilities, which is high when a few grey-level pairs dominate (a uniform texture)." +3GLCM_CLUPROM = "Cluster prominence: the fourth moment of the sum of the two grey levels of a pair about twice the mean grey level, which is large when that distribution is asymmetric or heavy-tailed." +3GLCM_CLUSHADE = "Cluster shade: the third moment of the sum of the two grey levels of a pair about twice the mean grey level, which measures the skewness of the co-occurrence matrix." +3GLCM_CLUTEND = "Cluster tendency: the second moment of the sum of the two grey levels of a pair about twice the mean grey level, which measures how strongly pairs group into similar grey levels." +3GLCM_CONTRAST = "Contrast: the co-occurrence-weighted mean squared grey-level difference between the two voxels of a pair, which grows with local intensity variation." +3GLCM_CORRELATION = "Correlation: the linear correlation, from -1 to 1, between the grey levels of the two voxels of a pair." +3GLCM_DIFAVE = "Difference average: the mean absolute grey-level difference between the two voxels of a pair, taken from the distribution of those differences." +3GLCM_DIFENTRO = "Difference entropy: the base-2 entropy of the distribution of absolute grey-level differences between paired voxels." +3GLCM_DIFVAR = "Difference variance: the variance of the absolute grey-level difference between paired voxels about its mean." +3GLCM_DIS = "Dissimilarity: the co-occurrence-weighted mean absolute difference between the grey levels of the two voxels of a pair, taken directly from the co-occurrence matrix." +3GLCM_ENERGY = "Energy: the sum of the squared co-occurrence probabilities; it returns the same value as 3GLCM_ASM." +3GLCM_ENTROPY = "Entropy: the base-2 entropy of the co-occurrence probabilities, which is high when many grey-level pairs are about equally likely; it returns the same value as 3GLCM_JE." +3GLCM_HOM1 = "Homogeneity 1: the co-occurrence probabilities weighted by one over one plus the grey-level difference of the pair; it returns the same value as 3GLCM_ID." +3GLCM_HOM2 = "Homogeneity 2: the co-occurrence probabilities weighted by one over one plus the squared grey-level difference of the pair; it returns the same value as 3GLCM_IDM and has no _AVE code." +3GLCM_ID = "Inverse difference: the co-occurrence probabilities weighted by one over one plus the grey-level difference of the pair, which is high when paired voxels have similar grey levels." +3GLCM_IDN = "Inverse difference normalized: inverse difference with the grey-level difference divided by the number of grey levels, which makes it less dependent on the number of grey levels." +3GLCM_IDM = "Inverse difference moment: the co-occurrence probabilities weighted by one over one plus the squared grey-level difference of the pair, a measure of local homogeneity." +3GLCM_IDMN = "Inverse difference moment normalized: inverse difference moment with the squared grey-level difference divided by the squared number of grey levels." +3GLCM_INFOMEAS1 = "Informational measure of correlation 1: the joint entropy minus the entropy expected if the two grey levels were independent, divided by the entropy of one marginal distribution; it measures how much the two grey levels depend on each other." +3GLCM_INFOMEAS2 = "Informational measure of correlation 2: a 0-to-1 measure of the dependence between the two grey levels of a pair, computed from the gap between the joint entropy and the entropy of the product of the marginals." +3GLCM_IV = "Inverse variance: the distribution of grey-level differences between paired voxels weighted by one over the squared difference, leaving out pairs with equal grey levels." +3GLCM_JAVE = "Joint average: the co-occurrence-weighted mean grey level of the reference voxel of a pair." +3GLCM_JE = "Joint entropy: the base-2 entropy of the co-occurrence probabilities, which is high when many grey-level pairs are about equally likely." +3GLCM_JMAX = "Joint maximum: the largest co-occurrence probability, that is the share of pairs taken by the most common grey-level pair." +3GLCM_JVAR = "Joint variance: the co-occurrence-weighted spread of the neighbour voxel's grey level around the joint average (the reference voxel's mean)." +3GLCM_SUMAVERAGE = "Sum average: the mean of the sum of the two grey levels of a voxel pair." +3GLCM_SUMENTROPY = "Sum entropy: the base-2 entropy of the distribution of the sum of the two grey levels of a voxel pair." +3GLCM_SUMVARIANCE = "Sum variance: the spread of the sum of the two grey levels of a pair about twice the mean grey level; this code returns the cluster tendency value, the same as 3GLCM_CLUTEND." +3GLCM_VARIANCE = "Variance: the co-occurrence-weighted variance of the reference voxel's grey level about its mean (PyRadiomics sum of squares)." +3GLCM_ASM_AVE = "Angular second moment, averaged over the directions." +3GLCM_ACOR_AVE = "Autocorrelation, averaged over the directions." +3GLCM_CLUPROM_AVE = "Cluster prominence, averaged over the directions." +3GLCM_CLUSHADE_AVE = "Cluster shade, averaged over the directions." +3GLCM_CLUTEND_AVE = "Cluster tendency, averaged over the directions." +3GLCM_CONTRAST_AVE = "Contrast, averaged over the directions." +3GLCM_CORRELATION_AVE = "Correlation, averaged over the directions." +3GLCM_DIFAVE_AVE = "Difference average, averaged over the directions." +3GLCM_DIFENTRO_AVE = "Difference entropy, averaged over the directions." +3GLCM_DIFVAR_AVE = "Difference variance, averaged over the directions." +3GLCM_DIS_AVE = "Dissimilarity, averaged over the directions." +3GLCM_ENERGY_AVE = "Energy, averaged over the directions." +3GLCM_ENTROPY_AVE = "Entropy, averaged over the directions." +3GLCM_HOM1_AVE = "Homogeneity 1, averaged over the directions." +3GLCM_ID_AVE = "Inverse difference, averaged over the directions." +3GLCM_IDN_AVE = "Inverse difference normalized, averaged over the directions." +3GLCM_IDM_AVE = "Inverse difference moment, averaged over the directions." +3GLCM_IDMN_AVE = "Inverse difference moment normalized, averaged over the directions." +3GLCM_IV_AVE = "Inverse variance, averaged over the directions." +3GLCM_JAVE_AVE = "Joint average, averaged over the directions." +3GLCM_JE_AVE = "Joint entropy, averaged over the directions." +3GLCM_INFOMEAS1_AVE = "Informational measure of correlation 1, averaged over the directions." +3GLCM_INFOMEAS2_AVE = "Informational measure of correlation 2, averaged over the directions." +3GLCM_VARIANCE_AVE = "Variance, averaged over the directions." +3GLCM_JMAX_AVE = "Joint maximum, averaged over the directions." +3GLCM_JVAR_AVE = "Joint variance, averaged over the directions." +3GLCM_SUMAVERAGE_AVE = "Sum average, averaged over the directions." +3GLCM_SUMENTROPY_AVE = "Sum entropy, averaged over the directions." +3GLCM_SUMVARIANCE_AVE = "Sum variance, averaged over the directions." + +[glrlm_3d] +3GLRLM_SRE = "Short run emphasis: the mean over all runs of one over the squared run length, which is high when short runs dominate (fine texture)." +3GLRLM_LRE = "Long run emphasis: the mean over all runs of the squared run length, which is high when long runs dominate (coarse texture)." +3GLRLM_GLN = "Grey level non-uniformity: the sum over grey levels of the squared number of runs at that level, divided by the number of runs; it is low when runs are spread evenly over the grey levels." +3GLRLM_GLNN = "Grey level non-uniformity normalized: grey level non-uniformity divided by the number of runs once more, giving a value between 0 and 1." +3GLRLM_RLN = "Run length non-uniformity: the sum over run lengths of the squared number of runs of that length, divided by the number of runs; it is low when run lengths are spread evenly." +3GLRLM_RLNN = "Run length non-uniformity normalized: run length non-uniformity divided by the number of runs once more, giving a value between 0 and 1." +3GLRLM_RP = "Run percentage: the number of runs divided by the number of ROI voxels, which is high for fine texture." +3GLRLM_GLV = "Grey level variance: the variance of the grey level over all runs." +3GLRLM_RV = "Run variance: the variance of the run length over all runs." +3GLRLM_RE = "Run entropy: the base-2 entropy of the joint distribution of run grey level and run length." +3GLRLM_LGLRE = "Low grey level run emphasis: the mean over all runs of one over the squared grey level, which is high when dark runs dominate." +3GLRLM_HGLRE = "High grey level run emphasis: the mean over all runs of the squared grey level, which is high when bright runs dominate." +3GLRLM_SRLGLE = "Short run low grey level emphasis: the mean over all runs of one over the squared run length times the squared grey level, which is high for short dark runs." +3GLRLM_SRHGLE = "Short run high grey level emphasis: the mean over all runs of the squared grey level over the squared run length, which is high for short bright runs." +3GLRLM_LRLGLE = "Long run low grey level emphasis: the mean over all runs of the squared run length over the squared grey level, which is high for long dark runs." +3GLRLM_LRHGLE = "Long run high grey level emphasis: the mean over all runs of the squared run length times the squared grey level, which is high for long bright runs." +3GLRLM_SRE_AVE = "Short run emphasis, averaged over the directions." +3GLRLM_LRE_AVE = "Long run emphasis, averaged over the directions." +3GLRLM_GLN_AVE = "Grey level non-uniformity, averaged over the directions." +3GLRLM_GLNN_AVE = "Grey level non-uniformity normalized, averaged over the directions." +3GLRLM_RLN_AVE = "Run length non-uniformity, averaged over the directions." +3GLRLM_RLNN_AVE = "Run length non-uniformity normalized, averaged over the directions." +3GLRLM_RP_AVE = "Run percentage, averaged over the directions." +3GLRLM_GLV_AVE = "Grey level variance, averaged over the directions." +3GLRLM_RV_AVE = "Run variance, averaged over the directions." +3GLRLM_RE_AVE = "Run entropy, averaged over the directions." +3GLRLM_LGLRE_AVE = "Low grey level run emphasis, averaged over the directions." +3GLRLM_HGLRE_AVE = "High grey level run emphasis, averaged over the directions." +3GLRLM_SRLGLE_AVE = "Short run low grey level emphasis, averaged over the directions." +3GLRLM_SRHGLE_AVE = "Short run high grey level emphasis, averaged over the directions." +3GLRLM_LRLGLE_AVE = "Long run low grey level emphasis, averaged over the directions." +3GLRLM_LRHGLE_AVE = "Long run high grey level emphasis, averaged over the directions." + +[glszm_3d] +3GLSZM_SAE = "Small area emphasis: the mean over all zones of one over the squared zone size, which is high when small zones dominate (fine texture); IBSI calls it small zone emphasis." +3GLSZM_LAE = "Large area emphasis: the mean over all zones of the squared zone size, which is high when large zones dominate (coarse texture); IBSI calls it large zone emphasis." +3GLSZM_GLN = "Grey level non-uniformity: the sum over grey levels of the squared number of zones at that level, divided by the number of zones; it is low when zones are spread evenly over the grey levels." +3GLSZM_GLNN = "Grey level non-uniformity normalized: grey level non-uniformity divided by the number of zones once more, giving a value between 0 and 1." +3GLSZM_SZN = "Size zone non-uniformity: the sum over zone sizes of the squared number of zones of that size, divided by the number of zones; it is low when zone sizes are spread evenly." +3GLSZM_SZNN = "Size zone non-uniformity normalized: size zone non-uniformity divided by the number of zones once more, giving a value between 0 and 1." +3GLSZM_ZP = "Zone percentage: the number of zones divided by the number of voxels, which is high for fine texture." +3GLSZM_GLV = "Grey level variance: the variance of the grey level over all zones." +3GLSZM_ZV = "Zone variance: the variance of the zone size over all zones." +3GLSZM_ZE = "Zone entropy: the base-2 entropy of the joint distribution of zone grey level and zone size." +3GLSZM_LGLZE = "Low grey level zone emphasis: the mean over all zones of one over the squared grey level, which is high when dark zones dominate." +3GLSZM_HGLZE = "High grey level zone emphasis: the mean over all zones of the squared grey level, which is high when bright zones dominate." +3GLSZM_SALGLE = "Small area low grey level emphasis: the mean over all zones of one over the squared zone size times the squared grey level, which is high for small dark zones." +3GLSZM_SAHGLE = "Small area high grey level emphasis: the mean over all zones of the squared grey level over the squared zone size, which is high for small bright zones." +3GLSZM_LALGLE = "Large area low grey level emphasis: the mean over all zones of the squared zone size over the squared grey level, which is high for large dark zones." +3GLSZM_LAHGLE = "Large area high grey level emphasis: the mean over all zones of the squared zone size times the squared grey level, which is high for large bright zones." + +[gldm_3d] +3GLDM_SDE = "Small dependence emphasis: the mean over all voxels of one over the squared dependence, which is high when voxels have few same-level neighbours (heterogeneous texture)." +3GLDM_LDE = "Large dependence emphasis: the mean over all voxels of the squared dependence, which is high when voxels have many same-level neighbours (homogeneous texture)." +3GLDM_GLN = "Grey level non-uniformity: the sum over grey levels of the squared number of voxels at that level, divided by the number of voxels; it is low when the grey levels are evenly represented." +3GLDM_DN = "Dependence non-uniformity: the sum over dependence values of the squared number of voxels with that dependence, divided by the number of voxels; it is low when dependences are spread evenly." +3GLDM_DNN = "Dependence non-uniformity normalized: dependence non-uniformity divided by the number of voxels once more, giving a value between 0 and 1." +3GLDM_GLV = "Grey level variance: the variance of the grey level over the dependence matrix." +3GLDM_DV = "Dependence variance: the variance of the dependence over the dependence matrix." +3GLDM_DE = "Dependence entropy: the base-2 entropy of the joint distribution of grey level and dependence." +3GLDM_LGLE = "Low grey level emphasis: the mean over all voxels of one over the squared grey level, which is high when dark voxels dominate." +3GLDM_HGLE = "High grey level emphasis: the mean over all voxels of the squared grey level, which is high when bright voxels dominate." +3GLDM_SDLGLE = "Small dependence low grey level emphasis: the mean over all voxels of one over the squared dependence times the squared grey level, which is high for dark voxels with few same-level neighbours." +3GLDM_SDHGLE = "Small dependence high grey level emphasis: the mean over all voxels of the squared grey level over the squared dependence, which is high for bright voxels with few same-level neighbours." +3GLDM_LDLGLE = "Large dependence low grey level emphasis: the mean over all voxels of the squared dependence over the squared grey level, which is high for dark voxels with many same-level neighbours." +3GLDM_LDHGLE = "Large dependence high grey level emphasis: the mean over all voxels of the squared dependence times the squared grey level, which is high for bright voxels with many same-level neighbours." + +[gldzm_3d] +3GLDZM_SDE = "Small distance emphasis: the mean over all zones of one over the squared zone distance, which is high when zones lie close to the ROI edge." +3GLDZM_LDE = "Large distance emphasis: the mean over all zones of the squared zone distance, which is high when zones lie deep inside the ROI." +3GLDZM_LGLZE = "Low grey level zone emphasis: the mean over all zones of one over the squared grey level, which is high when dark zones dominate." +3GLDZM_HGLZE = "High grey level zone emphasis: the mean over all zones of the squared grey level, which is high when bright zones dominate." +3GLDZM_SDLGLE = "Small distance low grey level emphasis: the mean over all zones of one over the squared zone distance times the squared grey level, which is high for dark zones near the ROI edge." +3GLDZM_SDHGLE = "Small distance high grey level emphasis: the mean over all zones of the squared grey level over the squared zone distance, which is high for bright zones near the ROI edge." +3GLDZM_LDLGLE = "Large distance low grey level emphasis: the mean over all zones of the squared zone distance over the squared grey level, which is high for dark zones deep inside the ROI." +3GLDZM_LDHGLE = "Large distance high grey level emphasis: the mean over all zones of the squared zone distance times the squared grey level, which is high for bright zones deep inside the ROI." +3GLDZM_GLNU = "Grey level non-uniformity: the sum over grey levels of the squared number of zones at that level, divided by the number of zones; it is low when zones are spread evenly over the grey levels." +3GLDZM_GLNUN = "Grey level non-uniformity normalized: grey level non-uniformity divided by the number of zones once more, giving a value between 0 and 1." +3GLDZM_ZDNU = "Zone distance non-uniformity: the sum over distances of the squared number of zones at that distance, divided by the number of zones; it is low when zone distances are spread evenly." +3GLDZM_ZDNUN = "Zone distance non-uniformity normalized: zone distance non-uniformity divided by the number of zones once more, giving a value between 0 and 1." +3GLDZM_ZP = "Zone percentage: the number of zones divided by the number of ROI voxels, which is high for fine texture." +3GLDZM_GLM = "Grey level mean: the mean grey level over all zones." +3GLDZM_GLV = "Grey level variance: the variance of the grey level over all zones." +3GLDZM_ZDM = "Zone distance mean: the mean zone distance over all zones." +3GLDZM_ZDV = "Zone distance variance: the variance of the zone distance over all zones." +3GLDZM_ZDE = "Zone distance entropy: the base-2 entropy of the joint distribution of zone grey level and zone distance." + +[ngldm_3d] +3NGLDM_LDE = "Low dependence emphasis: the mean over all ROI voxels of one over the squared dependence count, which is high when voxels have few same-level neighbours." +3NGLDM_HDE = "High dependence emphasis: the mean over all ROI voxels of the squared dependence count, which is high when voxels have many same-level neighbours." +3NGLDM_LGLCE = "Low grey level count emphasis: the mean over all ROI voxels of one over the squared grey level, which is high when dark voxels dominate." +3NGLDM_HGLCE = "High grey level count emphasis: the mean over all ROI voxels of the squared grey level, which is high when bright voxels dominate." +3NGLDM_LDLGLE = "Low dependence low grey level emphasis: the mean over all ROI voxels of one over the squared dependence count times the squared grey level, which is high for dark voxels with few same-level neighbours." +3NGLDM_LDHGLE = "Low dependence high grey level emphasis: the mean over all ROI voxels of the squared grey level over the squared dependence count, which is high for bright voxels with few same-level neighbours." +3NGLDM_HDLGLE = "High dependence low grey level emphasis: the mean over all ROI voxels of the squared dependence count over the squared grey level, which is high for dark voxels with many same-level neighbours." +3NGLDM_HDHGLE = "High dependence high grey level emphasis: the mean over all ROI voxels of the squared dependence count times the squared grey level, which is high for bright voxels with many same-level neighbours." +3NGLDM_GLNU = "Grey level non-uniformity: the sum over grey levels of the squared number of voxels at that level, divided by the number of voxels; it is low when the grey levels are evenly represented." +3NGLDM_GLNUN = "Grey level non-uniformity normalized: grey level non-uniformity divided by the number of voxels once more, giving a value between 0 and 1." +3NGLDM_DCNU = "Dependence count non-uniformity: the sum over dependence counts of the squared number of voxels with that count, divided by the number of voxels; it is low when dependence counts are spread evenly." +3NGLDM_DCNUN = "Dependence count non-uniformity normalized: dependence count non-uniformity divided by the number of voxels once more, giving a value between 0 and 1." +3NGLDM_DCP = "Dependence count percentage: the fraction of ROI voxels that serve as neighbourhood centres; every ROI voxel is one, so the value is always 1." +3NGLDM_GLM = "Grey level mean: the mean grey level over the dependence matrix." +3NGLDM_GLV = "Grey level variance: the variance of the grey level over the dependence matrix." +3NGLDM_DCM = "Dependence count mean: the mean dependence count over the dependence matrix." +3NGLDM_DCV = "Dependence count variance: the variance of the dependence count over the dependence matrix." +3NGLDM_DCENT = "Dependence count entropy: the base-2 entropy of the joint distribution of grey level and dependence count." +3NGLDM_DCENE = "Dependence count energy: the sum of the squared probabilities of the dependence matrix, which is high when a few combinations of grey level and dependence count dominate." + +[ngtdm_3d] +3NGTDM_COARSENESS = "Coarseness: one over the probability-weighted sum of the differences between each grey level and its neighbourhood averages, which is high when intensity changes slowly (large uniform patches)." +3NGTDM_CONTRAST = "Contrast: the spread of the grey levels present multiplied by the mean difference between a voxel and its neighbourhood average, which is high when both the dynamic range and the local variation are large." +3NGTDM_BUSYNESS = "Busyness: the probability-weighted neighbourhood differences divided by the summed differences between probability-weighted grey levels, which is high when intensity changes rapidly from voxel to voxel." +3NGTDM_COMPLEXITY = "Complexity: the grey-level differences between pairs of levels weighted by their neighbourhood differences, which is high when the image has many abrupt intensity changes." +3NGTDM_STRENGTH = "Strength: the probability-weighted squared differences between grey levels divided by the summed neighbourhood differences, which is high when the image has large, distinct regions of different intensity." + +[image_quality] +FOCUS_SCORE = "Variance of the Laplacian (3x3 kernel) of the ROI's bounding box; higher values mean a sharper image." +LOCAL_FOCUS_SCORE = "Mean of FOCUS_SCORE over a 2x2 grid of equal, non-overlapping tiles of the bounding box." +POWER_SPECTRUM_SLOPE = "Slope of a straight-line fit of log power against log bin index, computed from the image's Fourier transform with each coefficient binned by its own magnitude rather than by its frequency; 0 when the bounding box's shorter side is under 24 pixels." +MAX_SATURATION = "Fraction of bounding-box pixels at the maximum grey level, from 0 to 1." +MIN_SATURATION = "Fraction of bounding-box pixels at the minimum grey level, from 0 to 1; pixels outside the ROI count as 0." +SHARPNESS = "Edge sharpness from the difference of medians of a 3x3 median-filtered image, combined over the horizontal and vertical directions." diff --git a/docs/feature_extras.toml b/docs/feature_extras.toml new file mode 100644 index 000000000..fb5bc4e2e --- /dev/null +++ b/docs/feature_extras.toml @@ -0,0 +1,761 @@ +# Per-feature facts for the family definition pages that are not in the code: a short name, the +# IBSI identifier, and the value on the page's worked example. docs/tools/gen_feature_reference.py +# renders them, with each feature's validation from tests/vetting/oracle_coverage.csv, into the +# page's feature index; the page's example script asserts every `example` value. +# +# Codes are the family's base codes; the _AVE codes are described with the output columns. + +[glcm] +GLCM_ACOR = { name = "Autocorrelation", ibsi = "QWB0", example = 6.85 } +GLCM_ASM = { name = "Angular second moment", ibsi = "8ZQL", example = 0.08 } +GLCM_CLUPROM = { name = "Cluster prominence", ibsi = "AE86", example = 58.1 } +GLCM_CLUSHADE = { name = "Cluster shade", ibsi = "7NFM", example = 4.8 } +GLCM_CLUTEND = { name = "Cluster tendency", ibsi = "DG8W", example = 5.3 } +GLCM_CONTRAST = { name = "Contrast", ibsi = "ACUI", example = 2.9 } +GLCM_CORRELATION = { name = "Correlation", ibsi = "NI2N", example = 0.2927 } +GLCM_DIFAVE = { name = "Difference average", ibsi = "TF7R", example = 1.4 } +GLCM_DIFENTRO = { name = "Difference entropy", ibsi = "NTRS", example = 1.923 } +GLCM_DIFVAR = { name = "Difference variance", ibsi = "D3YU", example = 0.94 } +GLCM_DIS = { name = "Dissimilarity", ibsi = "8S9J", example = 1.4 } +GLCM_ENERGY = { name = "Energy (same as angular second moment)", ibsi = "", example = 0.08 } +GLCM_ENTROPY = { name = "Entropy (same as joint entropy)", ibsi = "", example = 3.805 } +GLCM_HOM1 = { name = "Homogeneity 1 (same as inverse difference)", ibsi = "", example = 0.5125 } +GLCM_HOM2 = { name = "Homogeneity 2 (same as inverse difference moment)", ibsi = "", example = 0.45 } +GLCM_ID = { name = "Inverse difference", ibsi = "IB1Z", example = 0.5125 } +GLCM_IDM = { name = "Inverse difference moment", ibsi = "WF0Z", example = 0.45 } +GLCM_IDMN = { name = "Inverse difference moment normalized", ibsi = "1QCO", example = 0.9055 } +GLCM_IDN = { name = "Inverse difference normalized", ibsi = "NDRX", example = 0.7997 } +GLCM_INFOMEAS1 = { name = "Information measure of correlation 1", ibsi = "R8DG", example = -0.2276 } +GLCM_INFOMEAS2 = { name = "Information measure of correlation 2", ibsi = "JN9H", example = 0.7897 } +GLCM_IV = { name = "Inverse variance", ibsi = "E8JP", example = 0.4417 } +GLCM_JAVE = { name = "Joint average", ibsi = "60VM", example = 2.5 } +GLCM_JE = { name = "Joint entropy", ibsi = "TU9B", example = 3.805 } +GLCM_JMAX = { name = "Joint maximum", ibsi = "GYBY", example = 0.15 } +GLCM_JVAR = { name = "Joint variance", ibsi = "UR99", example = 2.05 } +GLCM_SUMAVERAGE = { name = "Sum average", ibsi = "ZGXS", example = 5.0 } +GLCM_SUMENTROPY = { name = "Sum entropy", ibsi = "P6QZ", example = 2.843 } +GLCM_SUMVARIANCE = { name = "Sum variance", ibsi = "OEEB", example = 5.3 } +GLCM_VARIANCE = { name = "Variance", ibsi = "", example = 2.05 } + +[glrlm] +GLRLM_GLN = { name = "Grey level non-uniformity", ibsi = "R5YN", example = 4.059 } +GLRLM_GLNN = { name = "Grey level non-uniformity normalized", ibsi = "OVBL", example = 0.2388 } +GLRLM_GLV = { name = "Grey level variance", ibsi = "8CE5", example = 1.529 } +GLRLM_HGLRE = { name = "High grey level run emphasis", ibsi = "G3QZ", example = 10.53 } +GLRLM_LGLRE = { name = "Low grey level run emphasis", ibsi = "V3SW", example = 0.2301 } +GLRLM_LRE = { name = "Long run emphasis", ibsi = "W4KF", example = 2.765 } +GLRLM_LRHGLE = { name = "Long run high grey level emphasis", ibsi = "3KUM", example = 21.53 } +GLRLM_LRLGLE = { name = "Long run low grey level emphasis", ibsi = "IVPO", example = 0.9013 } +GLRLM_RE = { name = "Run entropy", ibsi = "HJ9O", example = 3.058 } +GLRLM_RLN = { name = "Run length non-uniformity", ibsi = "W92Y", example = 9.235 } +GLRLM_RLNN = { name = "Run length non-uniformity normalized", ibsi = "IC23", example = 0.5433 } +GLRLM_RP = { name = "Run percentage", ibsi = "9ZK5", example = 0.68 } +GLRLM_RV = { name = "Run variance", ibsi = "SXLW", example = 0.6021 } +GLRLM_SRE = { name = "Short run emphasis", ibsi = "22OV", example = 0.7549 } +GLRLM_SRHGLE = { name = "Short run high grey level emphasis", ibsi = "GD3A", example = 8.694 } +GLRLM_SRLGLE = { name = "Short run low grey level emphasis", ibsi = "HTZT", example = 0.1513 } + +[glszm] +GLSZM_GLN = { name = "Grey level non-uniformity", ibsi = "JNSA", example = 3.4 } +GLSZM_GLNN = { name = "Grey level non-uniformity normalized", ibsi = "Y1RO", example = 0.2267 } +GLSZM_GLV = { name = "Grey level variance", ibsi = "BYLV", example = 2.089 } +GLSZM_HGLZE = { name = "High grey level zone emphasis", ibsi = "5GN9", example = 9.2 } +GLSZM_LAE = { name = "Large area emphasis", ibsi = "48P8", example = 4.333 } +GLSZM_LAHGLE = { name = "Large area high grey level emphasis", ibsi = "J17V", example = 21.73 } +GLSZM_LALGLE = { name = "Large area low grey level emphasis", ibsi = "YH51", example = 2.853 } +GLSZM_LGLZE = { name = "Low grey level zone emphasis", ibsi = "XMSY", example = 0.3677 } +GLSZM_SAE = { name = "Small area emphasis", ibsi = "5QRC", example = 0.6852 } +GLSZM_SAHGLE = { name = "Small area high grey level emphasis", ibsi = "HW1V", example = 6.585 } +GLSZM_SALGLE = { name = "Small area low grey level emphasis", ibsi = "5RAI", example = 0.2648 } +GLSZM_SZN = { name = "Size zone non-uniformity", ibsi = "4JP3", example = 7.133 } +GLSZM_SZNN = { name = "Size zone non-uniformity normalized", ibsi = "VB3A", example = 0.4756 } +GLSZM_ZE = { name = "Zone entropy", ibsi = "GU8N", example = 3.054 } +GLSZM_ZP = { name = "Zone percentage", ibsi = "P30P", example = 0.6 } +GLSZM_ZV = { name = "Zone variance", ibsi = "3NSA", example = 1.556 } + +[gldm] +GLDM_DE = { name = "Dependence entropy", ibsi = "", example = 3.380 } +GLDM_DN = { name = "Dependence non-uniformity", ibsi = "", example = 7.96 } +GLDM_DNN = { name = "Dependence non-uniformity normalized", ibsi = "", example = 0.3184 } +GLDM_DV = { name = "Dependence variance", ibsi = "", example = 0.72 } +GLDM_GLN = { name = "Grey level non-uniformity", ibsi = "", example = 6.04 } +GLDM_GLV = { name = "Grey level variance", ibsi = "", example = 1.44 } +GLDM_HGLE = { name = "High grey level emphasis", ibsi = "", example = 9.28 } +GLDM_LDE = { name = "Large dependence emphasis", ibsi = "", example = 5.56 } +GLDM_LDHGLE = { name = "Large dependence high grey level emphasis", ibsi = "", example = 35.12 } +GLDM_LDLGLE = { name = "Large dependence low grey level emphasis", ibsi = "", example = 2.143 } +GLDM_LGLE = { name = "Low grey level emphasis", ibsi = "", example = 0.2723 } +GLDM_SDE = { name = "Small dependence emphasis", ibsi = "", example = 0.3725 } +GLDM_SDHGLE = { name = "Small dependence high grey level emphasis", ibsi = "", example = 5.155 } +GLDM_SDLGLE = { name = "Small dependence low grey level emphasis", ibsi = "", example = 0.05914 } + +[gldzm] +GLDZM_GLM = { name = "Grey level mean", ibsi = "", example = 2.667 } +GLDZM_GLNU = { name = "Grey level non-uniformity", ibsi = "VFT7", example = 3.4 } +GLDZM_GLNUN = { name = "Grey level non-uniformity normalized", ibsi = "7HP3", example = 0.2267 } +GLDZM_GLV = { name = "Grey level variance", ibsi = "QK93", example = 2.089 } +GLDZM_HGLZE = { name = "High grey level zone emphasis", ibsi = "K26C", example = 9.2 } +GLDZM_LDE = { name = "Large distance emphasis", ibsi = "MB4I", example = 1.4 } +GLDZM_LDHGLE = { name = "Large distance high grey level emphasis", ibsi = "KLTH", example = 14.4 } +GLDZM_LDLGLE = { name = "Large distance low grey level emphasis", ibsi = "A7WM", example = 0.5757 } +GLDZM_LGLZE = { name = "Low grey level zone emphasis", ibsi = "S1RA", example = 0.3677 } +GLDZM_SDE = { name = "Small distance emphasis", ibsi = "0GBI", example = 0.9 } +GLDZM_SDHGLE = { name = "Small distance high grey level emphasis", ibsi = "DKNJ", example = 7.9 } +GLDZM_SDLGLE = { name = "Small distance low grey level emphasis", ibsi = "RUVG", example = 0.3157 } +GLDZM_ZDE = { name = "Zone distance entropy", ibsi = "GBDU", example = 2.606 } +GLDZM_ZDM = { name = "Zone distance mean", ibsi = "", example = 1.133 } +GLDZM_ZDNU = { name = "Zone distance non-uniformity", ibsi = "V294", example = 11.53 } +GLDZM_ZDNUN = { name = "Zone distance non-uniformity normalized", ibsi = "IATH", example = 0.7689 } +GLDZM_ZDV = { name = "Zone distance variance", ibsi = "7WT1", example = 0.1156 } +GLDZM_ZP = { name = "Zone percentage", ibsi = "VIWW", example = 0.6 } + + +[ngldm] +NGLDM_DCENE = { name = "Dependence count energy", ibsi = "CAS9", example = 0.1072 } +NGLDM_DCENT = { name = "Dependence count entropy", ibsi = "FCBV", example = 3.383 } +NGLDM_DCM = { name = "Dependence count mean", ibsi = "", example = 2.2 } +NGLDM_DCNU = { name = "Dependence count non-uniformity", ibsi = "Z87G", example = 7.96 } +NGLDM_DCNUN = { name = "Dependence count non-uniformity normalized", ibsi = "OKJI", example = 0.3184 } +NGLDM_DCP = { name = "Dependence count percentage", ibsi = "6XV8", example = 1.0 } +NGLDM_DCV = { name = "Dependence count variance", ibsi = "DNX2", example = 0.72 } +NGLDM_GLM = { name = "Grey level mean", ibsi = "", example = 2.8 } +NGLDM_GLNU = { name = "Grey level non-uniformity", ibsi = "FP8K", example = 6.04 } +NGLDM_GLNUN = { name = "Grey level non-uniformity normalized", ibsi = "5SPA", example = 0.2416 } +NGLDM_GLV = { name = "Grey level variance", ibsi = "1PFV", example = 1.44 } +NGLDM_HDE = { name = "High dependence emphasis", ibsi = "IMOQ", example = 5.56 } +NGLDM_HDHGLE = { name = "High dependence high grey level emphasis", ibsi = "9QMG", example = 35.12 } +NGLDM_HDLGLE = { name = "High dependence low grey level emphasis", ibsi = "NBZI", example = 2.143 } +NGLDM_HGLCE = { name = "High grey level count emphasis", ibsi = "OAE7", example = 9.28 } +NGLDM_LDE = { name = "Low dependence emphasis", ibsi = "SODN", example = 0.3725 } +NGLDM_LDHGLE = { name = "Low dependence high grey level emphasis", ibsi = "JA6D", example = 5.155 } +NGLDM_LDLGLE = { name = "Low dependence low grey level emphasis", ibsi = "EQ3F", example = 0.05914 } +NGLDM_LGLCE = { name = "Low grey level count emphasis", ibsi = "TL9H", example = 0.2723 } + +[ngtdm] +NGTDM_BUSYNESS = { name = "Busyness", ibsi = "NQ30", example = 0.636 } +NGTDM_COARSENESS = { name = "Coarseness", ibsi = "QCDE", example = 0.2006 } +NGTDM_COMPLEXITY = { name = "Complexity", ibsi = "HDEZ", example = 8.251 } +NGTDM_CONTRAST = { name = "Contrast", ibsi = "65HE", example = 0.146 } +NGTDM_STRENGTH = { name = "Strength", ibsi = "1X9X", example = 1.373 } + +[intensity] +COV = { name = "Coefficient of variation", ibsi = "7TET", example = 0.4374 } +COVERED_IMAGE_INTENSITY_RANGE = { name = "Covered image intensity range", ibsi = "", example = 0.0 } +ENERGY = { name = "Energy", ibsi = "N8CA", example = 232.0 } +ENTROPY = { name = "Entropy", ibsi = "", example = 2.182 } +EXCESS_KURTOSIS = { name = "Excess kurtosis", ibsi = "IPH6", example = -0.6667 } +HYPERFLATNESS = { name = "Hyperflatness", ibsi = "", example = 5.769 } +HYPERSKEWNESS = { name = "Hyperskewness", ibsi = "", example = 1.227 } +INTEGRATED_INTENSITY = { name = "Integrated intensity", ibsi = "", example = 70.0 } +INTERQUARTILE_RANGE = { name = "Interquartile range", ibsi = "SALO", example = 1.024 } +KURTOSIS = { name = "Kurtosis", ibsi = "", example = 2.333 } +MAX = { name = "Maximum", ibsi = "84IY", example = 5.0 } +MEAN = { name = "Mean", ibsi = "Q4LE", example = 2.8 } +MEAN_ABSOLUTE_DEVIATION = { name = "Mean absolute deviation", ibsi = "4FUA", example = 0.96 } +MEDIAN = { name = "Median", ibsi = "Y12H", example = 3.0 } +MEDIAN_ABSOLUTE_DEVIATION = { name = "Median absolute deviation", ibsi = "N72L", example = 0.92 } +MIN = { name = "Minimum", ibsi = "1GSF", example = 1.0 } +MODE = { name = "Mode", ibsi = "", example = 3.0 } +P01 = { name = "1st percentile", ibsi = "", example = 1.003 } +P10 = { name = "10th percentile", ibsi = "QG58", example = 1.025 } +P25 = { name = "25th percentile", ibsi = "", example = 2.015 } +P75 = { name = "75th percentile", ibsi = "", example = 3.039 } +P90 = { name = "90th percentile", ibsi = "8DWT", example = 4.967 } +P99 = { name = "99th percentile", ibsi = "", example = 4.997 } +QCOD = { name = "Quartile coefficient of dispersion", ibsi = "9S40", example = 0.2026 } +RANGE = { name = "Range", ibsi = "2OJQ", example = 4.0 } +ROBUST_MEAN = { name = "Robust mean", ibsi = "", example = 2.833 } +ROBUST_MEAN_ABSOLUTE_DEVIATION = { name = "Robust mean absolute deviation", ibsi = "1128", example = 0.5556 } +ROOT_MEAN_SQUARED = { name = "Root mean square", ibsi = "5ZWQ", example = 3.046 } +SKEWNESS = { name = "Skewness", ibsi = "KE2A", example = 0.25 } +STANDARD_DEVIATION = { name = "Standard deviation", ibsi = "", example = 1.225 } +STANDARD_DEVIATION_BIASED = { name = "Biased standard deviation", ibsi = "", example = 1.2 } +STANDARD_ERROR = { name = "Standard error", ibsi = "", example = 0.2449 } +VARIANCE = { name = "Variance", ibsi = "", example = 1.5 } +VARIANCE_BIASED = { name = "Biased variance", ibsi = "ECT3", example = 1.44 } +UNIFORMITY = { name = "Uniformity", ibsi = "", example = 0.2416 } +UNIFORMITY_PIU = { name = "Percent image uniformity", ibsi = "", example = 33.33 } +HISTOGRAM = { name = "Histogram", ibsi = "", example = 4.0 } + +[shape_moments] +CENTRAL_MOMENT_00 = { name = "Central moment (0, 0)", ibsi = "", example = 40.0 } +CENTRAL_MOMENT_01 = { name = "Central moment (0, 1)", ibsi = "", example = 0.0 } +CENTRAL_MOMENT_02 = { name = "Central moment (0, 2)", ibsi = "", example = 112.8 } +CENTRAL_MOMENT_03 = { name = "Central moment (0, 3)", ibsi = "", example = 16.39 } +CENTRAL_MOMENT_10 = { name = "Central moment (1, 0)", ibsi = "", example = 0.0 } +CENTRAL_MOMENT_11 = { name = "Central moment (1, 1)", ibsi = "", example = -9.175 } +CENTRAL_MOMENT_12 = { name = "Central moment (1, 2)", ibsi = "", example = -16.45 } +CENTRAL_MOMENT_13 = { name = "Central moment (1, 3)", ibsi = "", example = -29.07 } +CENTRAL_MOMENT_20 = { name = "Central moment (2, 0)", ibsi = "", example = 146.0 } +CENTRAL_MOMENT_21 = { name = "Central moment (2, 1)", ibsi = "", example = -17.78 } +CENTRAL_MOMENT_22 = { name = "Central moment (2, 2)", ibsi = "", example = 220.3 } +CENTRAL_MOMENT_23 = { name = "Central moment (2, 3)", ibsi = "", example = -23.36 } +CENTRAL_MOMENT_30 = { name = "Central moment (3, 0)", ibsi = "", example = 33.74 } +CENTRAL_MOMENT_31 = { name = "Central moment (3, 1)", ibsi = "", example = -89.63 } +CENTRAL_MOMENT_32 = { name = "Central moment (3, 2)", ibsi = "", example = -25.15 } +CENTRAL_MOMENT_33 = { name = "Central moment (3, 3)", ibsi = "", example = -217.7 } +HU_M1 = { name = "Hu invariant 1", ibsi = "", example = 0.1617 } +HU_M2 = { name = "Hu invariant 2", ibsi = "", example = 0.0005621 } +HU_M3 = { name = "Hu invariant 3", ibsi = "", example = 0.0001149 } +HU_M4 = { name = "Hu invariant 4", ibsi = "", example = 2.937e-06 } +HU_M5 = { name = "Hu invariant 5", ibsi = "", example = 4.84e-11 } +HU_M6 = { name = "Hu invariant 6", ibsi = "", example = 6.554e-08 } +HU_M7 = { name = "Hu invariant 7", ibsi = "", example = -2.386e-11 } +NORM_CENTRAL_MOMENT_02 = { name = "Normalized central moment (0, 2)", ibsi = "", example = 0.07048 } +NORM_CENTRAL_MOMENT_03 = { name = "Normalized central moment (0, 3)", ibsi = "", example = 0.00162 } +NORM_CENTRAL_MOMENT_11 = { name = "Normalized central moment (1, 1)", ibsi = "", example = -0.005734 } +NORM_CENTRAL_MOMENT_12 = { name = "Normalized central moment (1, 2)", ibsi = "", example = -0.001626 } +NORM_CENTRAL_MOMENT_20 = { name = "Normalized central moment (2, 0)", ibsi = "", example = 0.09123 } +NORM_CENTRAL_MOMENT_21 = { name = "Normalized central moment (2, 1)", ibsi = "", example = -0.001757 } +NORM_CENTRAL_MOMENT_30 = { name = "Normalized central moment (3, 0)", ibsi = "", example = 0.003334 } +NORM_SPAT_MOMENT_00 = { name = "Normalized raw moment (0, 0)", ibsi = "", example = 1.0 } +NORM_SPAT_MOMENT_01 = { name = "Normalized raw moment (0, 1)", ibsi = "", example = 0.4625 } +NORM_SPAT_MOMENT_02 = { name = "Normalized raw moment (0, 2)", ibsi = "", example = 0.2844 } +NORM_SPAT_MOMENT_03 = { name = "Normalized raw moment (0, 3)", ibsi = "", example = 0.1983 } +NORM_SPAT_MOMENT_10 = { name = "Normalized raw moment (1, 0)", ibsi = "", example = 0.5178 } +NORM_SPAT_MOMENT_11 = { name = "Normalized raw moment (1, 1)", ibsi = "", example = 0.2338 } +NORM_SPAT_MOMENT_12 = { name = "Normalized raw moment (1, 2)", ibsi = "", example = 0.1403 } +NORM_SPAT_MOMENT_13 = { name = "Normalized raw moment (1, 3)", ibsi = "", example = 0.09631 } +NORM_SPAT_MOMENT_20 = { name = "Normalized raw moment (2, 0)", ibsi = "", example = 0.3594 } +NORM_SPAT_MOMENT_21 = { name = "Normalized raw moment (2, 1)", ibsi = "", example = 0.1585 } +NORM_SPAT_MOMENT_22 = { name = "Normalized raw moment (2, 2)", ibsi = "", example = 0.09041 } +NORM_SPAT_MOMENT_23 = { name = "Normalized raw moment (2, 3)", ibsi = "", example = 0.05918 } +NORM_SPAT_MOMENT_30 = { name = "Normalized raw moment (3, 0)", ibsi = "", example = 0.2839 } +NORM_SPAT_MOMENT_31 = { name = "Normalized raw moment (3, 1)", ibsi = "", example = 0.1226 } +NORM_SPAT_MOMENT_32 = { name = "Normalized raw moment (3, 2)", ibsi = "", example = 0.0664 } +NORM_SPAT_MOMENT_33 = { name = "Normalized raw moment (3, 3)", ibsi = "", example = 0.04126 } +SPAT_MOMENT_00 = { name = "Raw moment (0, 0)", ibsi = "", example = 40.0 } +SPAT_MOMENT_01 = { name = "Raw moment (0, 1)", ibsi = "", example = 117.0 } +SPAT_MOMENT_02 = { name = "Raw moment (0, 2)", ibsi = "", example = 455.0 } +SPAT_MOMENT_03 = { name = "Raw moment (0, 3)", ibsi = "", example = 2007.0 } +SPAT_MOMENT_10 = { name = "Raw moment (1, 0)", ibsi = "", example = 131.0 } +SPAT_MOMENT_11 = { name = "Raw moment (1, 1)", ibsi = "", example = 374.0 } +SPAT_MOMENT_12 = { name = "Raw moment (1, 2)", ibsi = "", example = 1420.0 } +SPAT_MOMENT_13 = { name = "Raw moment (1, 3)", ibsi = "", example = 6164.0 } +SPAT_MOMENT_20 = { name = "Raw moment (2, 0)", ibsi = "", example = 575.0 } +SPAT_MOMENT_21 = { name = "Raw moment (2, 1)", ibsi = "", example = 1604.0 } +SPAT_MOMENT_22 = { name = "Raw moment (2, 2)", ibsi = "", example = 5786.0 } +SPAT_MOMENT_23 = { name = "Raw moment (2, 3)", ibsi = "", example = 23950.0 } +SPAT_MOMENT_30 = { name = "Raw moment (3, 0)", ibsi = "", example = 2873.0 } +WEIGHTED_CENTRAL_MOMENT_02 = { name = "Weighted central moment (0, 2)", ibsi = "", example = -171.6 } +WEIGHTED_CENTRAL_MOMENT_03 = { name = "Weighted central moment (0, 3)", ibsi = "", example = -251.6 } +WEIGHTED_CENTRAL_MOMENT_11 = { name = "Weighted central moment (1, 1)", ibsi = "", example = 153.2 } +WEIGHTED_CENTRAL_MOMENT_12 = { name = "Weighted central moment (1, 2)", ibsi = "", example = 274.1 } +WEIGHTED_CENTRAL_MOMENT_20 = { name = "Weighted central moment (2, 0)", ibsi = "", example = -183.1 } +WEIGHTED_CENTRAL_MOMENT_21 = { name = "Weighted central moment (2, 1)", ibsi = "", example = -248.6 } +WEIGHTED_CENTRAL_MOMENT_30 = { name = "Weighted central moment (3, 0)", ibsi = "", example = -57.93 } +WEIGHTED_HU_M1 = { name = "Weighted Hu invariant 1", ibsi = "", example = -0.194 } +WEIGHTED_HU_M2 = { name = "Weighted Hu invariant 2", ibsi = "", example = 0.0281 } +WEIGHTED_HU_M3 = { name = "Weighted Hu invariant 3", ibsi = "", example = 0.0 } +WEIGHTED_HU_M4 = { name = "Weighted Hu invariant 4", ibsi = "", example = 0.0 } +WEIGHTED_HU_M5 = { name = "Weighted Hu invariant 5", ibsi = "", example = 0.0 } +WEIGHTED_HU_M6 = { name = "Weighted Hu invariant 6", ibsi = "", example = 0.0 } +WEIGHTED_HU_M7 = { name = "Weighted Hu invariant 7", ibsi = "", example = 0.0 } +WEIGHTED_SPAT_MOMENT_00 = { name = "Weighted raw moment (0, 0)", ibsi = "", example = -42.77 } +WEIGHTED_SPAT_MOMENT_01 = { name = "Weighted raw moment (0, 1)", ibsi = "", example = -113.6 } +WEIGHTED_SPAT_MOMENT_02 = { name = "Weighted raw moment (0, 2)", ibsi = "", example = -473.2 } +WEIGHTED_SPAT_MOMENT_03 = { name = "Weighted raw moment (0, 3)", ibsi = "", example = -2419.0 } +WEIGHTED_SPAT_MOMENT_10 = { name = "Weighted raw moment (1, 0)", ibsi = "", example = -170.2 } +WEIGHTED_SPAT_MOMENT_11 = { name = "Weighted raw moment (1, 1)", ibsi = "", example = -298.8 } +WEIGHTED_SPAT_MOMENT_12 = { name = "Weighted raw moment (1, 2)", ibsi = "", example = -795.9 } +WEIGHTED_SPAT_MOMENT_20 = { name = "Weighted raw moment (2, 0)", ibsi = "", example = -860.8 } +WEIGHTED_SPAT_MOMENT_21 = { name = "Weighted raw moment (2, 1)", ibsi = "", example = -1315.0 } +WEIGHTED_SPAT_MOMENT_30 = { name = "Weighted raw moment (3, 0)", ibsi = "", example = -4942.0 } +WT_NORM_CTR_MOM_02 = { name = "Weighted normalized central moment (0, 2)", ibsi = "", example = -0.09385 } +WT_NORM_CTR_MOM_03 = { name = "Weighted normalized central moment (0, 3)", ibsi = "", example = 0.0 } +WT_NORM_CTR_MOM_11 = { name = "Weighted normalized central moment (1, 1)", ibsi = "", example = 0.08376 } +WT_NORM_CTR_MOM_12 = { name = "Weighted normalized central moment (1, 2)", ibsi = "", example = 0.0 } +WT_NORM_CTR_MOM_20 = { name = "Weighted normalized central moment (2, 0)", ibsi = "", example = -0.1001 } +WT_NORM_CTR_MOM_21 = { name = "Weighted normalized central moment (2, 1)", ibsi = "", example = 0.0 } +WT_NORM_CTR_MOM_30 = { name = "Weighted normalized central moment (3, 0)", ibsi = "", example = 0.0 } + +[intensity_moments] +IMOM_CM_00 = { name = "Intensity central moment (0, 0)", ibsi = "", example = 150.0 } +IMOM_CM_01 = { name = "Intensity central moment (0, 1)", ibsi = "", example = 0.0 } +IMOM_CM_02 = { name = "Intensity central moment (0, 2)", ibsi = "", example = 424.3 } +IMOM_CM_03 = { name = "Intensity central moment (0, 3)", ibsi = "", example = 23.96 } +IMOM_CM_10 = { name = "Intensity central moment (1, 0)", ibsi = "", example = 0.0 } +IMOM_CM_11 = { name = "Intensity central moment (1, 1)", ibsi = "", example = -15.55 } +IMOM_CM_12 = { name = "Intensity central moment (1, 2)", ibsi = "", example = -26.64 } +IMOM_CM_13 = { name = "Intensity central moment (1, 3)", ibsi = "", example = 39.18 } +IMOM_CM_20 = { name = "Intensity central moment (2, 0)", ibsi = "", example = 499.5 } +IMOM_CM_21 = { name = "Intensity central moment (2, 1)", ibsi = "", example = -23.58 } +IMOM_CM_22 = { name = "Intensity central moment (2, 2)", ibsi = "", example = 780.4 } +IMOM_CM_23 = { name = "Intensity central moment (2, 3)", ibsi = "", example = 25.31 } +IMOM_CM_30 = { name = "Intensity central moment (3, 0)", ibsi = "", example = 68.7 } +IMOM_CM_31 = { name = "Intensity central moment (3, 1)", ibsi = "", example = -226.2 } +IMOM_CM_32 = { name = "Intensity central moment (3, 2)", ibsi = "", example = -82.84 } +IMOM_CM_33 = { name = "Intensity central moment (3, 3)", ibsi = "", example = -535.7 } +IMOM_HU1 = { name = "Intensity Hu invariant 1", ibsi = "", example = 0.04106 } +IMOM_HU2 = { name = "Intensity Hu invariant 2", ibsi = "", example = 1.307e-05 } +IMOM_HU3 = { name = "Intensity Hu invariant 3", ibsi = "", example = 4.089e-07 } +IMOM_HU4 = { name = "Intensity Hu invariant 4", ibsi = "", example = 2.33e-08 } +IMOM_HU5 = { name = "Intensity Hu invariant 5", ibsi = "", example = 1.885e-15 } +IMOM_HU6 = { name = "Intensity Hu invariant 6", ibsi = "", example = 7.727e-11 } +IMOM_HU7 = { name = "Intensity Hu invariant 7", ibsi = "", example = -1.274e-15 } +IMOM_NCM_02 = { name = "Intensity normalized central moment (0, 2)", ibsi = "", example = 0.01886 } +IMOM_NCM_03 = { name = "Intensity normalized central moment (0, 3)", ibsi = "", example = 8.693e-05 } +IMOM_NCM_11 = { name = "Intensity normalized central moment (1, 1)", ibsi = "", example = -0.000691 } +IMOM_NCM_12 = { name = "Intensity normalized central moment (1, 2)", ibsi = "", example = -9.666e-05 } +IMOM_NCM_20 = { name = "Intensity normalized central moment (2, 0)", ibsi = "", example = 0.0222 } +IMOM_NCM_21 = { name = "Intensity normalized central moment (2, 1)", ibsi = "", example = -8.557e-05 } +IMOM_NCM_30 = { name = "Intensity normalized central moment (3, 0)", ibsi = "", example = 0.0002493 } +IMOM_NRM_00 = { name = "Intensity normalized raw moment (0, 0)", ibsi = "", example = 1.0 } +IMOM_NRM_01 = { name = "Intensity normalized raw moment (0, 1)", ibsi = "", example = 0.2362 } +IMOM_NRM_02 = { name = "Intensity normalized raw moment (0, 2)", ibsi = "", example = 0.07467 } +IMOM_NRM_03 = { name = "Intensity normalized raw moment (0, 3)", ibsi = "", example = 0.02664 } +IMOM_NRM_10 = { name = "Intensity normalized raw moment (1, 0)", ibsi = "", example = 0.2575 } +IMOM_NRM_11 = { name = "Intensity normalized raw moment (1, 1)", ibsi = "", example = 0.06013 } +IMOM_NRM_12 = { name = "Intensity normalized raw moment (1, 2)", ibsi = "", example = 0.0188 } +IMOM_NRM_13 = { name = "Intensity normalized raw moment (1, 3)", ibsi = "", example = 0.006685 } +IMOM_NRM_20 = { name = "Intensity normalized raw moment (2, 0)", ibsi = "", example = 0.08849 } +IMOM_NRM_21 = { name = "Intensity normalized raw moment (2, 1)", ibsi = "", example = 0.02046 } +IMOM_NRM_22 = { name = "Intensity normalized raw moment (2, 2)", ibsi = "", example = 0.006161 } +IMOM_NRM_23 = { name = "Intensity normalized raw moment (2, 3)", ibsi = "", example = 0.00212 } +IMOM_NRM_30 = { name = "Intensity normalized raw moment (3, 0)", ibsi = "", example = 0.03446 } +IMOM_NRM_31 = { name = "Intensity normalized raw moment (3, 1)", ibsi = "", example = 0.007871 } +IMOM_NRM_32 = { name = "Intensity normalized raw moment (3, 2)", ibsi = "", example = 0.002275 } +IMOM_NRM_33 = { name = "Intensity normalized raw moment (3, 3)", ibsi = "", example = 0.0007519 } +IMOM_RM_00 = { name = "Intensity raw moment (0, 0)", ibsi = "", example = 150.0 } +IMOM_RM_01 = { name = "Intensity raw moment (0, 1)", ibsi = "", example = 434.0 } +IMOM_RM_02 = { name = "Intensity raw moment (0, 2)", ibsi = "", example = 1680.0 } +IMOM_RM_03 = { name = "Intensity raw moment (0, 3)", ibsi = "", example = 7340.0 } +IMOM_RM_10 = { name = "Intensity raw moment (1, 0)", ibsi = "", example = 473.0 } +IMOM_RM_11 = { name = "Intensity raw moment (1, 1)", ibsi = "", example = 1353.0 } +IMOM_RM_12 = { name = "Intensity raw moment (1, 2)", ibsi = "", example = 5181.0 } +IMOM_RM_13 = { name = "Intensity raw moment (1, 3)", ibsi = "", example = 22560.0 } +IMOM_RM_20 = { name = "Intensity raw moment (2, 0)", ibsi = "", example = 1991.0 } +IMOM_RM_21 = { name = "Intensity raw moment (2, 1)", ibsi = "", example = 5639.0 } +IMOM_RM_22 = { name = "Intensity raw moment (2, 2)", ibsi = "", example = 20800.0 } +IMOM_RM_23 = { name = "Intensity raw moment (2, 3)", ibsi = "", example = 87620.0 } +IMOM_RM_30 = { name = "Intensity raw moment (3, 0)", ibsi = "", example = 9497.0 } +IMOM_WCM_02 = { name = "Intensity weighted central moment (0, 2)", ibsi = "", example = -617.3 } +IMOM_WCM_03 = { name = "Intensity weighted central moment (0, 3)", ibsi = "", example = -440.8 } +IMOM_WCM_11 = { name = "Intensity weighted central moment (1, 1)", ibsi = "", example = 599.2 } +IMOM_WCM_12 = { name = "Intensity weighted central moment (1, 2)", ibsi = "", example = 483.1 } +IMOM_WCM_20 = { name = "Intensity weighted central moment (2, 0)", ibsi = "", example = -592.0 } +IMOM_WCM_21 = { name = "Intensity weighted central moment (2, 1)", ibsi = "", example = -434.8 } +IMOM_WCM_30 = { name = "Intensity weighted central moment (3, 0)", ibsi = "", example = -434.1 } +IMOM_WHU1 = { name = "Intensity weighted Hu invariant 1", ibsi = "", example = -0.05777 } +IMOM_WHU2 = { name = "Intensity weighted Hu invariant 2", ibsi = "", example = 0.003279 } +IMOM_WHU3 = { name = "Intensity weighted Hu invariant 3", ibsi = "", example = 0.0 } +IMOM_WHU4 = { name = "Intensity weighted Hu invariant 4", ibsi = "", example = 0.0 } +IMOM_WHU5 = { name = "Intensity weighted Hu invariant 5", ibsi = "", example = 0.0 } +IMOM_WHU6 = { name = "Intensity weighted Hu invariant 6", ibsi = "", example = 0.0 } +IMOM_WHU7 = { name = "Intensity weighted Hu invariant 7", ibsi = "", example = 0.0 } +IMOM_WNCM_02 = { name = "Intensity weighted normalized central moment (0, 2)", ibsi = "", example = -0.02949 } +IMOM_WNCM_03 = { name = "Intensity weighted normalized central moment (0, 3)", ibsi = "", example = 0.0 } +IMOM_WNCM_11 = { name = "Intensity weighted normalized central moment (1, 1)", ibsi = "", example = 0.02863 } +IMOM_WNCM_12 = { name = "Intensity weighted normalized central moment (1, 2)", ibsi = "", example = 0.0 } +IMOM_WNCM_20 = { name = "Intensity weighted normalized central moment (2, 0)", ibsi = "", example = -0.02828 } +IMOM_WNCM_21 = { name = "Intensity weighted normalized central moment (2, 1)", ibsi = "", example = 0.0 } +IMOM_WNCM_30 = { name = "Intensity weighted normalized central moment (3, 0)", ibsi = "", example = 0.0 } +IMOM_WRM_00 = { name = "Intensity weighted raw moment (0, 0)", ibsi = "", example = -144.7 } +IMOM_WRM_01 = { name = "Intensity weighted raw moment (0, 1)", ibsi = "", example = -426.2 } +IMOM_WRM_02 = { name = "Intensity weighted raw moment (0, 2)", ibsi = "", example = -1873.0 } +IMOM_WRM_03 = { name = "Intensity weighted raw moment (0, 3)", ibsi = "", example = -9595.0 } +IMOM_WRM_10 = { name = "Intensity weighted raw moment (1, 0)", ibsi = "", example = -496.8 } +IMOM_WRM_11 = { name = "Intensity weighted raw moment (1, 1)", ibsi = "", example = -864.2 } +IMOM_WRM_12 = { name = "Intensity weighted raw moment (1, 2)", ibsi = "", example = -2417.0 } +IMOM_WRM_20 = { name = "Intensity weighted raw moment (2, 0)", ibsi = "", example = -2298.0 } +IMOM_WRM_21 = { name = "Intensity weighted raw moment (2, 1)", ibsi = "", example = -3089.0 } +IMOM_WRM_30 = { name = "Intensity weighted raw moment (3, 0)", ibsi = "", example = -12390.0 } + +[zernike] +ZERNIKE2D = { name = "Zernike moments", ibsi = "", example = 0.3183 } + +[gabor] +GABOR = { name = "Gabor filter-bank score", ibsi = "", example = 1.042 } + +[image_quality] +FOCUS_SCORE = { name = "Focus score", ibsi = "", example = 27.86 } +LOCAL_FOCUS_SCORE = { name = "Local focus score", ibsi = "", example = 31.76 } +POWER_SPECTRUM_SLOPE = { name = "Power spectrum slope", ibsi = "", example = 0.0 } +MAX_SATURATION = { name = "Maximum saturation", ibsi = "", example = 0.08333 } +MIN_SATURATION = { name = "Minimum saturation", ibsi = "", example = 0.3056 } +SHARPNESS = { name = "Sharpness", ibsi = "", example = 1.903 } + +[fractal] +FRACT_DIM_BOXCOUNT = { name = "Box-counting fractal dimension", ibsi = "", example = 1.373 } +FRACT_DIM_PERIMETER = { name = "Perimeter (divider) fractal dimension", ibsi = "", example = 1.131 } + +[basic_morphology] +AREA_PIXELS_COUNT = { name = "Area in pixels", ibsi = "", example = 116 } +AREA_UM2 = { name = "Area in square micrometres", ibsi = "", example = 0 } +CENTROID_X = { name = "Centroid x", ibsi = "", example = 6.405 } +CENTROID_Y = { name = "Centroid y", ibsi = "", example = 6.75 } +WEIGHTED_CENTROID_Y = { name = "Intensity-weighted centroid y", ibsi = "", example = 6.706 } +WEIGHTED_CENTROID_X = { name = "Intensity-weighted centroid x", ibsi = "", example = 6.373 } +MASS_DISPLACEMENT = { name = "Mass displacement", ibsi = "", example = 0.05448 } +COMPACTNESS = { name = "Compactness", ibsi = "", example = 0.01369 } +BBOX_YMIN = { name = "Bounding box top row", ibsi = "", example = 1 } +BBOX_XMIN = { name = "Bounding box left column", ibsi = "", example = 1 } +BBOX_HEIGHT = { name = "Bounding box height", ibsi = "", example = 12 } +BBOX_WIDTH = { name = "Bounding box width", ibsi = "", example = 12 } +DIAMETER_EQUAL_AREA = { name = "Equal-area diameter", ibsi = "", example = 12.15 } +EXTENT = { name = "Extent", ibsi = "", example = 0.8056 } +ASPECT_RATIO = { name = "Aspect ratio", ibsi = "", example = 1 } + +[contour] +PERIMETER = { name = "Perimeter", ibsi = "", example = 48.14 } +DIAMETER_EQUAL_PERIMETER = { name = "Equal-perimeter diameter", ibsi = "", example = 15.32 } +EDGE_MEAN_INTENSITY = { name = "Edge mean intensity", ibsi = "", example = 5.182 } +EDGE_STDDEV_INTENSITY = { name = "Edge intensity standard deviation", ibsi = "", example = 2.644 } +EDGE_MAX_INTENSITY = { name = "Edge maximum intensity", ibsi = "", example = 9 } +EDGE_MIN_INTENSITY = { name = "Edge minimum intensity", ibsi = "", example = 1 } +EDGE_INTEGRATED_INTENSITY = { name = "Edge integrated intensity", ibsi = "", example = 228 } + +[convex_hull] +CIRCULARITY = { name = "Circularity", ibsi = "", example = 0.7931 } +CONVEX_HULL_AREA = { name = "Convex hull area", ibsi = "", example = 131 } +SOLIDITY = { name = "Solidity", ibsi = "", example = 0.8855 } +POLYGONALITY_AVE = { name = "Polygonality", ibsi = "", example = -1 } +HEXAGONALITY_AVE = { name = "Hexagonality", ibsi = "", example = -1 } +HEXAGONALITY_STDDEV = { name = "Hexagonality dispersion", ibsi = "", example = -1 } + +[ellipse] +MAJOR_AXIS_LENGTH = { name = "Major axis length", ibsi = "", example = 13.91 } +MINOR_AXIS_LENGTH = { name = "Minor axis length", ibsi = "", example = 12.55 } +ELONGATION = { name = "Elongation", ibsi = "", example = 0.9025 } +ECCENTRICITY = { name = "Eccentricity", ibsi = "", example = 0.4306 } +ORIENTATION = { name = "Orientation", ibsi = "", example = -18.77 } +ROUNDNESS = { name = "Roundness", ibsi = "", example = 0.7635 } + +[circles] +# example pins known defect https://github.com/PolusAI/nyxus/issues/498 +DIAMETER_MIN_ENCLOSING_CIRCLE = { name = "Minimum enclosing circle diameter", ibsi = "", example = 14.17 } +# example pins known defect https://github.com/PolusAI/nyxus/issues/497 +DIAMETER_CIRCUMSCRIBING_CIRCLE = { name = "Circumscribing circle diameter", ibsi = "", example = 17.41 } +# example pins known defect https://github.com/PolusAI/nyxus/issues/497 +DIAMETER_INSCRIBING_CIRCLE = { name = "Inscribing circle diameter", ibsi = "", example = 1.29 } + +[roi_radius] +# example pins known defect https://github.com/PolusAI/nyxus/issues/497 +ROI_RADIUS_MEAN = { name = "Mean distance to the contour", ibsi = "", example = 1.124 } +ROI_RADIUS_MAX = { name = "Largest distance to the contour", ibsi = "", example = 3 } +ROI_RADIUS_MEDIAN = { name = "Median distance to the contour", ibsi = "", example = 1 } + +[extrema] +EXTREMA_P1_X = { name = "Extremum point 1, x", ibsi = "", example = 3 } +EXTREMA_P1_Y = { name = "Extremum point 1, y", ibsi = "", example = 1 } +EXTREMA_P2_X = { name = "Extremum point 2, x", ibsi = "", example = 11 } +EXTREMA_P2_Y = { name = "Extremum point 2, y", ibsi = "", example = 1 } +EXTREMA_P3_X = { name = "Extremum point 3, x", ibsi = "", example = 12 } +EXTREMA_P3_Y = { name = "Extremum point 3, y", ibsi = "", example = 2 } +EXTREMA_P4_X = { name = "Extremum point 4, x", ibsi = "", example = 12 } +EXTREMA_P4_Y = { name = "Extremum point 4, y", ibsi = "", example = 9 } +EXTREMA_P5_X = { name = "Extremum point 5, x", ibsi = "", example = 9 } +EXTREMA_P5_Y = { name = "Extremum point 5, y", ibsi = "", example = 12 } +EXTREMA_P6_X = { name = "Extremum point 6, x", ibsi = "", example = 3 } +EXTREMA_P6_Y = { name = "Extremum point 6, y", ibsi = "", example = 12 } +EXTREMA_P7_X = { name = "Extremum point 7, x", ibsi = "", example = 1 } +EXTREMA_P7_Y = { name = "Extremum point 7, y", ibsi = "", example = 10 } +EXTREMA_P8_X = { name = "Extremum point 8, x", ibsi = "", example = 1 } +EXTREMA_P8_Y = { name = "Extremum point 8, y", ibsi = "", example = 3 } + +[euler] +EULER_NUMBER = { name = "Euler number", ibsi = "", example = 1 } + +[geodetic] +GEODETIC_LENGTH = { name = "Geodetic length", ibsi = "", example = 17.41 } +THICKNESS = { name = "Thickness", ibsi = "", example = 6.664 } + +[feret] +MIN_FERET_ANGLE = { name = "Angle of the smallest Feret diameter", ibsi = "", example = 0 } +MAX_FERET_ANGLE = { name = "Angle of the largest Feret diameter", ibsi = "", example = 50 } +STAT_FERET_DIAM_MIN = { name = "Feret diameter, minimum", ibsi = "", example = 11 } +STAT_FERET_DIAM_MAX = { name = "Feret diameter, maximum", ibsi = "", example = 13.57 } +# example pins known defect https://github.com/PolusAI/nyxus/issues/500 +STAT_FERET_DIAM_MEAN = { name = "Feret diameter, mean", ibsi = "", example = 12.42 } +STAT_FERET_DIAM_MEDIAN = { name = "Feret diameter, median", ibsi = "", example = 12.39 } +# example pins known defect https://github.com/PolusAI/nyxus/issues/500 +STAT_FERET_DIAM_STDDEV = { name = "Feret diameter, standard deviation", ibsi = "", example = 0.8183 } +STAT_FERET_DIAM_MODE = { name = "Feret diameter, mode", ibsi = "", example = 12 } + +[martin] +STAT_MARTIN_DIAM_MIN = { name = "Martin diameter, minimum", ibsi = "", example = 11 } +STAT_MARTIN_DIAM_MAX = { name = "Martin diameter, maximum", ibsi = "", example = 13.49 } +STAT_MARTIN_DIAM_MEAN = { name = "Martin diameter, mean", ibsi = "", example = 11.93 } +STAT_MARTIN_DIAM_MEDIAN = { name = "Martin diameter, median", ibsi = "", example = 11.71 } +STAT_MARTIN_DIAM_STDDEV = { name = "Martin diameter, standard deviation", ibsi = "", example = 0.7683 } +STAT_MARTIN_DIAM_MODE = { name = "Martin diameter, mode", ibsi = "", example = 11 } + +[nassenstein] +STAT_NASSENSTEIN_DIAM_MIN = { name = "Nassenstein diameter, minimum", ibsi = "", example = 11 } +STAT_NASSENSTEIN_DIAM_MAX = { name = "Nassenstein diameter, maximum", ibsi = "", example = 13.49 } +STAT_NASSENSTEIN_DIAM_MEAN = { name = "Nassenstein diameter, mean", ibsi = "", example = 11.93 } +STAT_NASSENSTEIN_DIAM_MEDIAN = { name = "Nassenstein diameter, median", ibsi = "", example = 11.71 } +STAT_NASSENSTEIN_DIAM_STDDEV = { name = "Nassenstein diameter, standard deviation", ibsi = "", example = 0.7805 } +STAT_NASSENSTEIN_DIAM_MODE = { name = "Nassenstein diameter, mode", ibsi = "", example = 11 } + +[chords] +MAXCHORDS_MAX = { name = "Maximum chords, maximum", ibsi = "", example = 12 } +MAXCHORDS_MAX_ANG = { name = "Maximum chords, angle of the maximum", ibsi = "", example = 0.1571 } +MAXCHORDS_MIN = { name = "Maximum chords, minimum", ibsi = "", example = 4 } +MAXCHORDS_MIN_ANG = { name = "Maximum chords, angle of the minimum", ibsi = "", example = 0.6283 } +MAXCHORDS_MEDIAN = { name = "Maximum chords, median", ibsi = "", example = 11 } +MAXCHORDS_MEAN = { name = "Maximum chords, mean", ibsi = "", example = 9.5 } +MAXCHORDS_MODE = { name = "Maximum chords, mode", ibsi = "", example = 12 } +MAXCHORDS_STDDEV = { name = "Maximum chords, standard deviation", ibsi = "", example = 3.137 } +ALLCHORDS_MAX = { name = "All chords, maximum", ibsi = "", example = 12 } +ALLCHORDS_MAX_ANG = { name = "All chords, angle of the maximum", ibsi = "", example = 0.1571 } +ALLCHORDS_MIN = { name = "All chords, minimum", ibsi = "", example = 1 } +ALLCHORDS_MIN_ANG = { name = "All chords, angle of the minimum", ibsi = "", example = 0.1571 } +ALLCHORDS_MEDIAN = { name = "All chords, median", ibsi = "", example = 4 } +ALLCHORDS_MEAN = { name = "All chords, mean", ibsi = "", example = 5.441 } +ALLCHORDS_MODE = { name = "All chords, mode", ibsi = "", example = 4 } +ALLCHORDS_STDDEV = { name = "All chords, standard deviation", ibsi = "", example = 2.865 } + +[neighbors] +NUM_NEIGHBORS = { name = "Number of neighbours", ibsi = "", example = 1 } +PERCENT_TOUCHING = { name = "Percent of the contour touching", ibsi = "", example = 15.91 } +CLOSEST_NEIGHBOR1_DIST = { name = "Distance to the closest neighbour", ibsi = "", example = 7.632 } +CLOSEST_NEIGHBOR1_ANG = { name = "Direction of the closest neighbour", ibsi = "", example = 354.4 } +CLOSEST_NEIGHBOR2_DIST = { name = "Distance to the second-closest neighbour", ibsi = "", example = 0 } +CLOSEST_NEIGHBOR2_ANG = { name = "Direction of the second-closest neighbour", ibsi = "", example = 0 } +ANG_BW_NEIGHBORS_MEAN = { name = "Mean neighbour direction", ibsi = "", example = 354.4 } +ANG_BW_NEIGHBORS_STDDEV = { name = "Neighbour direction standard deviation", ibsi = "", example = 0 } +ANG_BW_NEIGHBORS_MODE = { name = "Most frequent neighbour direction", ibsi = "", example = 354 } + +[erosion] +EROSIONS_2_VANISH = { name = "Erosions to vanish", ibsi = "", example = 2.0 } +EROSIONS_2_VANISH_COMPLEMENT = { name = "Erosions to vanish, complement (not computed)", ibsi = "", example = 0.0 } + +[ngldm_3d] +3NGLDM_DCENE = { name = "Dependence count energy", ibsi = "CAS9", example = 0.09191 } +3NGLDM_DCENT = { name = "Dependence count entropy", ibsi = "FCBV", example = 3.560 } +3NGLDM_DCM = { name = "Dependence count mean", ibsi = "", example = 4.852 } +3NGLDM_DCNU = { name = "Dependence count non-uniformity", ibsi = "Z87G", example = 5.370 } +3NGLDM_DCNUN = { name = "Dependence count non-uniformity normalized", ibsi = "OKJI", example = 0.1989 } +3NGLDM_DCP = { name = "Dependence count percentage", ibsi = "6XV8", example = 1.0 } +3NGLDM_DCV = { name = "Dependence count variance", ibsi = "DNX2", example = 2.719 } +3NGLDM_GLM = { name = "Grey level mean", ibsi = "", example = 2.407 } +3NGLDM_GLNU = { name = "Grey level non-uniformity", ibsi = "FP8K", example = 6.852 } +3NGLDM_GLNUN = { name = "Grey level non-uniformity normalized", ibsi = "5SPA", example = 0.2538 } +3NGLDM_GLV = { name = "Grey level variance", ibsi = "1PFV", example = 1.204 } +3NGLDM_HDE = { name = "High dependence emphasis", ibsi = "IMOQ", example = 26.26 } +3NGLDM_HDHGLE = { name = "High dependence high grey level emphasis", ibsi = "9QMG", example = 156.1 } +3NGLDM_HDLGLE = { name = "High dependence low grey level emphasis", ibsi = "NBZI", example = 9.671 } +3NGLDM_HGLCE = { name = "High grey level count emphasis", ibsi = "OAE7", example = 7.0 } +3NGLDM_LDE = { name = "Low dependence emphasis", ibsi = "SODN", example = 0.08747 } +3NGLDM_LDHGLE = { name = "Low dependence high grey level emphasis", ibsi = "JA6D", example = 0.4688 } +3NGLDM_LDLGLE = { name = "Low dependence low grey level emphasis", ibsi = "EQ3F", example = 0.05245 } +3NGLDM_LGLCE = { name = "Low grey level count emphasis", ibsi = "TL9H", example = 0.3719 } + +[glrlm_3d] +3GLRLM_GLN = { name = "Grey level non-uniformity", ibsi = "R5YN", example = 6.391 } +3GLRLM_GLNN = { name = "Grey level non-uniformity normalized", ibsi = "OVBL", example = 0.2779 } +3GLRLM_GLV = { name = "Grey level variance", ibsi = "8CE5", example = 0.9225 } +3GLRLM_HGLRE = { name = "High grey level run emphasis", ibsi = "G3QZ", example = 6.435 } +3GLRLM_LGLRE = { name = "Low grey level run emphasis", ibsi = "V3SW", example = 0.3463 } +3GLRLM_LRE = { name = "Long run emphasis", ibsi = "W4KF", example = 1.609 } +3GLRLM_LRHGLE = { name = "Long run high grey level emphasis", ibsi = "3KUM", example = 7.435 } +3GLRLM_LRLGLE = { name = "Long run low grey level emphasis", ibsi = "IVPO", example = 0.8572 } +3GLRLM_RE = { name = "Run entropy", ibsi = "HJ9O", example = 2.398 } +3GLRLM_RLN = { name = "Run length non-uniformity", ibsi = "W92Y", example = 17.61 } +3GLRLM_RLNN = { name = "Run length non-uniformity normalized", ibsi = "IC23", example = 0.7656 } +3GLRLM_RP = { name = "Run percentage", ibsi = "9ZK5", example = 0.8519 } +3GLRLM_RV = { name = "Run variance", ibsi = "SXLW", example = 0.2306 } +3GLRLM_SRE = { name = "Short run emphasis", ibsi = "22OV", example = 0.8961 } +3GLRLM_SRHGLE = { name = "Short run high grey level emphasis", ibsi = "GD3A", example = 6.233 } +3GLRLM_SRLGLE = { name = "Short run low grey level emphasis", ibsi = "HTZT", example = 0.2669 } + +[glcm_3d] +3GLCM_ACOR = { name = "Autocorrelation", ibsi = "QWB0", example = 6.611 } +3GLCM_ASM = { name = "Angular second moment", ibsi = "8ZQL", example = 0.07562 } +3GLCM_CLUPROM = { name = "Cluster prominence", ibsi = "AE86", example = 15.47 } +3GLCM_CLUSHADE = { name = "Cluster shade", ibsi = "7NFM", example = -0.08299 } +3GLCM_CLUTEND = { name = "Cluster tendency", ibsi = "DG8W", example = 2.83 } +3GLCM_CONTRAST = { name = "Contrast", ibsi = "ACUI", example = 1.944 } +3GLCM_CORRELATION = { name = "Correlation", ibsi = "NI2N", example = 0.1855 } +3GLCM_DIFAVE = { name = "Difference average", ibsi = "TF7R", example = 1.167 } +3GLCM_DIFENTRO = { name = "Difference entropy", ibsi = "NTRS", example = 1.614 } +3GLCM_DIFVAR = { name = "Difference variance", ibsi = "D3YU", example = 0.5833 } +3GLCM_DIS = { name = "Dissimilarity", ibsi = "8S9J", example = 1.167 } +3GLCM_ENERGY = { name = "Energy (same as angular second moment)", ibsi = "", example = 0.07562 } +3GLCM_ENTROPY = { name = "Entropy (same as joint entropy)", ibsi = "", example = 3.808 } +3GLCM_HOM1 = { name = "Homogeneity 1 (same as inverse difference)", ibsi = "", example = 0.5324 } +3GLCM_HOM2 = { name = "Homogeneity 2 (same as inverse difference moment)", ibsi = "", example = 0.4944 } +3GLCM_ID = { name = "Inverse difference", ibsi = "IB1Z", example = 0.5324 } +3GLCM_IDN = { name = "Inverse difference normalized", ibsi = "NDRX", example = 0.791 } +3GLCM_IDM = { name = "Inverse difference moment", ibsi = "WF0Z", example = 0.4944 } +3GLCM_IDMN = { name = "Inverse difference moment normalized", ibsi = "1QCO", example = 0.9029 } +3GLCM_INFOMEAS1 = { name = "Information measure of correlation 1", ibsi = "R8DG", example = -0.0914 } +3GLCM_INFOMEAS2 = { name = "Information measure of correlation 2", ibsi = "JN9H", example = 0.5527 } +3GLCM_IV = { name = "Inverse variance", ibsi = "E8JP", example = 0.6173 } +3GLCM_JAVE = { name = "Joint average", ibsi = "60VM", example = 2.528 } +3GLCM_JE = { name = "Joint entropy", ibsi = "TU9B", example = 3.808 } +3GLCM_JMAX = { name = "Joint maximum", ibsi = "GYBY", example = 0.1111 } +3GLCM_JVAR = { name = "Joint variance", ibsi = "UR99", example = 1.194 } +3GLCM_SUMAVERAGE = { name = "Sum average", ibsi = "ZGXS", example = 5.056 } +3GLCM_SUMENTROPY = { name = "Sum entropy", ibsi = "P6QZ", example = 2.641 } +3GLCM_SUMVARIANCE = { name = "Sum variance", ibsi = "OEEB", example = 2.83 } +3GLCM_VARIANCE = { name = "Variance", ibsi = "", example = 1.194 } + +[intensity_3d] +3COV = { name = "Coefficient of variation", ibsi = "7TET", example = 0.2821 } +3COVERED_IMAGE_INTENSITY_RANGE = { name = "Covered image intensity range", ibsi = "", example = 1.0 } +3ENERGY = { name = "Energy", ibsi = "N8CA", example = 423.0 } +3ENTROPY = { name = "Entropy", ibsi = "", example = 2.158 } +3EXCESS_KURTOSIS = { name = "Excess kurtosis", ibsi = "IPH6", example = -1.038 } +3HYPERFLATNESS = { name = "Hyperflatness", ibsi = "", example = 4.232 } +3HYPERSKEWNESS = { name = "Hyperskewness", ibsi = "", example = -0.002163 } +3INTEGRATED_INTENSITY = { name = "Integrated intensity", ibsi = "", example = 93.0 } +3INTERQUARTILE_RANGE = { name = "Interquartile range", ibsi = "SALO", example = 1.998 } +3KURTOSIS = { name = "Kurtosis", ibsi = "", example = 1.962 } +3MAX = { name = "Maximum", ibsi = "84IY", example = 6.0 } +3MEAN = { name = "Mean", ibsi = "Q4LE", example = 4.227 } +3MEAN_ABSOLUTE_DEVIATION = { name = "Mean absolute deviation", ibsi = "4FUA", example = 0.9959 } +3MEDIAN = { name = "Median", ibsi = "Y12H", example = 4.0 } +3MEDIAN_ABSOLUTE_DEVIATION = { name = "Median absolute deviation", ibsi = "N72L", example = 0.9545 } +3MIN = { name = "Minimum", ibsi = "1GSF", example = 2.0 } +3MODE = { name = "Mode", ibsi = "", example = 3.0 } +3P01 = { name = "1st percentile", ibsi = "", example = 2.009 } +3P10 = { name = "10th percentile", ibsi = "QG58", example = 3.008 } +3P25 = { name = "25th percentile", ibsi = "", example = 3.03 } +3P75 = { name = "75th percentile", ibsi = "", example = 5.028 } +3P90 = { name = "90th percentile", ibsi = "8DWT", example = 5.978 } +3P99 = { name = "99th percentile", ibsi = "", example = 5.998 } +3QCOD = { name = "Quartile coefficient of dispersion", ibsi = "9S40", example = 0.248 } +3RANGE = { name = "Range", ibsi = "2OJQ", example = 4.0 } +3ROBUST_MEAN = { name = "Robust mean", ibsi = "", example = 4.455 } +3ROBUST_MEAN_ABSOLUTE_DEVIATION = { name = "Robust mean absolute deviation", ibsi = "1128", example = 0.4959 } +3ROOT_MEAN_SQUARED = { name = "Root mean square", ibsi = "5ZWQ", example = 4.385 } +3SKEWNESS = { name = "Skewness", ibsi = "KE2A", example = 0.06841 } +3STANDARD_DEVIATION = { name = "Standard deviation", ibsi = "", example = 1.193 } +3STANDARD_DEVIATION_BIASED = { name = "Biased standard deviation", ibsi = "", example = 1.165 } +3STANDARD_ERROR = { name = "Standard error", ibsi = "", example = 0.2542 } +3VARIANCE = { name = "Variance", ibsi = "", example = 1.422 } +3VARIANCE_BIASED = { name = "Biased variance", ibsi = "ECT3", example = 1.357 } +3UNIFORMITY = { name = "Uniformity", ibsi = "", example = 0.2355 } +3UNIFORMITY_PIU = { name = "Percent image uniformity", ibsi = "", example = 50.0 } + +[ngtdm_3d] +3NGTDM_BUSYNESS = { name = "Busyness", ibsi = "NQ30", example = 1.477 } +3NGTDM_COARSENESS = { name = "Coarseness", ibsi = "QCDE", example = 0.1549 } +3NGTDM_COMPLEXITY = { name = "Complexity", ibsi = "HDEZ", example = 5.241 } +3NGTDM_CONTRAST = { name = "Contrast", ibsi = "65HE", example = 0.1889 } +3NGTDM_STRENGTH = { name = "Strength", ibsi = "1X9X", example = 0.7111 } + +[gldm_3d] +3GLDM_DE = { name = "Dependence entropy", ibsi = "", example = 4.363 } +3GLDM_DN = { name = "Dependence non-uniformity", ibsi = "", example = 7.75 } +3GLDM_DNN = { name = "Dependence non-uniformity normalized", ibsi = "", example = 0.1615 } +3GLDM_DV = { name = "Dependence variance", ibsi = "", example = 3.957 } +3GLDM_GLN = { name = "Grey level non-uniformity", ibsi = "", example = 12.08 } +3GLDM_GLV = { name = "Grey level variance", ibsi = "", example = 1.248 } +3GLDM_HGLE = { name = "High grey level emphasis", ibsi = "", example = 7.292 } +3GLDM_LDE = { name = "Large dependence emphasis", ibsi = "", example = 36.54 } +3GLDM_LDHGLE = { name = "Large dependence high grey level emphasis", ibsi = "", example = 292.2 } +3GLDM_LDLGLE = { name = "Large dependence low grey level emphasis", ibsi = "", example = 12.98 } +3GLDM_LGLE = { name = "Low grey level emphasis", ibsi = "", example = 0.3725 } +3GLDM_SDE = { name = "Small dependence emphasis", ibsi = "", example = 0.06470 } +3GLDM_SDHGLE = { name = "Small dependence high grey level emphasis", ibsi = "", example = 0.5953 } +3GLDM_SDLGLE = { name = "Small dependence low grey level emphasis", ibsi = "", example = 0.01907 } + +[glszm_3d] +3GLSZM_GLN = { name = "Grey level non-uniformity", ibsi = "JNSA", example = 2.556 } +3GLSZM_GLNN = { name = "Grey level non-uniformity normalized", ibsi = "Y1RO", example = 0.284 } +3GLSZM_GLV = { name = "Grey level variance", ibsi = "BYLV", example = 1.58 } +3GLSZM_HGLZE = { name = "High grey level zone emphasis", ibsi = "5GN9", example = 8.111 } +3GLSZM_LAE = { name = "Large area emphasis", ibsi = "48P8", example = 13.89 } +3GLSZM_LAHGLE = { name = "Large area high grey level emphasis", ibsi = "J17V", example = 127.1 } +3GLSZM_LALGLE = { name = "Large area low grey level emphasis", ibsi = "YH51", example = 2.964 } +3GLSZM_LGLZE = { name = "Low grey level zone emphasis", ibsi = "XMSY", example = 0.4066 } +3GLSZM_SAE = { name = "Small area emphasis", ibsi = "5QRC", example = 0.4938 } +3GLSZM_SAHGLE = { name = "Small area high grey level emphasis", ibsi = "HW1V", example = 3.546 } +3GLSZM_SALGLE = { name = "Small area low grey level emphasis", ibsi = "5RAI", example = 0.2569 } +3GLSZM_SZN = { name = "Size zone non-uniformity", ibsi = "4JP3", example = 3 } +3GLSZM_SZNN = { name = "Size zone non-uniformity normalized", ibsi = "VB3A", example = 0.3333 } +3GLSZM_ZE = { name = "Zone entropy", ibsi = "GU8N", example = 2.949 } +3GLSZM_ZP = { name = "Zone percentage", ibsi = "P30P", example = 0.3333 } +3GLSZM_ZV = { name = "Zone variance", ibsi = "3NSA", example = 4.889 } + +[gldzm_3d] +3GLDZM_GLM = { name = "Grey level mean", ibsi = "", example = 3.286 } +3GLDZM_GLNU = { name = "Grey level non-uniformity", ibsi = "VFT7", example = 1.571 } +3GLDZM_GLNUN = { name = "Grey level non-uniformity normalized", ibsi = "7HP3", example = 0.2245 } +3GLDZM_GLV = { name = "Grey level variance", ibsi = "QK93", example = 1.918 } +3GLDZM_HGLZE = { name = "High grey level zone emphasis", ibsi = "K26C", example = 12.71 } +3GLDZM_LDE = { name = "Large distance emphasis", ibsi = "MB4I", example = 1.429 } +3GLDZM_LDHGLE = { name = "Large distance high grey level emphasis", ibsi = "KLTH", example = 16.57 } +3GLDZM_LDLGLE = { name = "Large distance low grey level emphasis", ibsi = "A7WM", example = 0.2783 } +3GLDZM_LGLZE = { name = "Low grey level zone emphasis", ibsi = "S1RA", example = 0.2307 } +3GLDZM_SDE = { name = "Small distance emphasis", ibsi = "0GBI", example = 0.8929 } +3GLDZM_SDHGLE = { name = "Small distance high grey level emphasis", ibsi = "DKNJ", example = 11.75 } +3GLDZM_SDLGLE = { name = "Small distance low grey level emphasis", ibsi = "RUVG", example = 0.2188 } +3GLDZM_ZDE = { name = "Zone distance entropy", ibsi = "GBDU", example = 2.522 } +3GLDZM_ZDM = { name = "Zone distance mean", ibsi = "", example = 1.143 } +3GLDZM_ZDNU = { name = "Zone distance non-uniformity", ibsi = "V294", example = 5.286 } +3GLDZM_ZDNUN = { name = "Zone distance non-uniformity normalized", ibsi = "IATH", example = 0.7551 } +3GLDZM_ZDV = { name = "Zone distance variance", ibsi = "7WT1", example = 0.1224 } +3GLDZM_ZP = { name = "Zone percentage", ibsi = "VIWW", example = 0.1458 } + +[shape_3d] +3AREA = { name = "Surface area (exposed voxel faces)", ibsi = "", example = 96.0 } +3AREA_2_VOLUME = { name = "Surface to volume ratio", ibsi = "", example = 2.182 } +3COMPACTNESS1 = { name = "Compactness 1", ibsi = "", example = 0.02639 } +3COMPACTNESS2 = { name = "Compactness 2", ibsi = "", example = 0.2475 } +3MESH_VOLUME = { name = "Volume (mesh)", ibsi = "RNU0", example = 39.58 } +3SPHERICAL_DISPROPORTION = { name = "Spherical disproportion", ibsi = "", example = 1.593 } +3SPHERICITY = { name = "Sphericity", ibsi = "", example = 0.6278 } +3VOLUME_CONVEXHULL = { name = "Convex hull volume", ibsi = "", example = 40.67 } +3VOXEL_VOLUME = { name = "Volume (voxel counting)", ibsi = "YEKZ", example = 44.0 } +3MAJOR_AXIS_LEN = { name = "Major axis length", ibsi = "TDIC", example = 5.786 } +3MINOR_AXIS_LEN = { name = "Minor axis length", ibsi = "P9VJ", example = 3.881 } +3LEAST_AXIS_LEN = { name = "Least axis length", ibsi = "7J51", example = 2.892 } +3ELONGATION = { name = "Elongation", ibsi = "Q3CK", example = 0.6708 } +3FLATNESS = { name = "Flatness", ibsi = "N17B", example = 0.4998 } + +[intensity_histogram] +IH_MEAN_VAL = { name = "Mean of the bin-centre intensities", ibsi = "", example = 2.84 } +IH_VARIANCE_VAL = { name = "Variance of the bin-centre intensities", ibsi = "", example = 0.9216 } +IH_SKEWNESS_VAL = { name = "Skewness of the bin-centre intensities", ibsi = "", example = 0.25 } +IH_EXCESS_KURTOSIS_VAL = { name = "Excess kurtosis of the bin-centre intensities", ibsi = "", example = -0.6667 } +IH_MEDIAN_VAL = { name = "Median bin-centre intensity", ibsi = "", example = 3.0 } +IH_MINIMUM_VAL = { name = "Minimum intensity", ibsi = "", example = 1.0 } +IH_P10_VAL = { name = "10th percentile intensity", ibsi = "", example = 1.5 } +IH_P90_VAL = { name = "90th percentile intensity", ibsi = "", example = 4.333 } +IH_MAXIMUM_VAL = { name = "Maximum intensity", ibsi = "", example = 5.0 } +IH_MODE_VAL = { name = "Mode bin-centre intensity", ibsi = "", example = 3.0 } +IH_INTERQUANTILE_RANGE_VAL = { name = "Interquartile range of the intensities", ibsi = "", example = 1.278 } +IH_RANGE_VAL = { name = "Intensity range", ibsi = "", example = 4.0 } +IH_MEAN_ABSOLUTE_DEVIATION_VAL = { name = "Mean absolute deviation of the bin-centre intensities", ibsi = "", example = 0.768 } +IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_VAL = { name = "Robust mean absolute deviation of the bin-centre intensities", ibsi = "", example = 0.768 } +IH_MEDIAN_ABSOLUTE_DEVIATION_VAL = { name = "Median absolute deviation of the bin-centre intensities", ibsi = "", example = 0.736 } +IH_COEFFICIENT_OF_VARIATION_VAL = { name = "Coefficient of variation of the bin-centre intensities", ibsi = "", example = 0.338 } +IH_QUANTILE_COEFFICIENT_OF_DISPERSION_VAL = { name = "Quartile coefficient of dispersion of the intensities", ibsi = "", example = 0.2333 } +IH_ENTROPY_VAL = { name = "Histogram entropy (same as IH_ENTROPY_IDX)", ibsi = "", example = 2.182 } +IH_UNIFORMITY_VAL = { name = "Histogram uniformity (same as IH_UNIFORMITY_IDX)", ibsi = "", example = 0.2416 } +IH_ROBUST_MEAN_VAL = { name = "Robust mean of the bin-centre intensities", ibsi = "", example = 2.84 } +IH_MEAN_IDX = { name = "Mean discretised intensity", ibsi = "X6K6", example = 2.8 } +IH_VARIANCE_IDX = { name = "Discretised intensity variance", ibsi = "CH89", example = 1.44 } +IH_SKEWNESS_IDX = { name = "Discretised intensity skewness", ibsi = "88K1", example = 0.25 } +IH_EXCESS_KURTOSIS_IDX = { name = "(Excess) discretised intensity kurtosis", ibsi = "C3I7", example = -0.6667 } +IH_MEDIAN_IDX = { name = "Median discretised intensity", ibsi = "WIFQ", example = 3.0 } +IH_MINIMUM_IDX = { name = "Minimum discretised intensity", ibsi = "1PR8", example = 1.0 } +IH_P10_IDX = { name = "10th discretised intensity percentile", ibsi = "1PR8", example = 1.0 } +IH_P90_IDX = { name = "90th discretised intensity percentile", ibsi = "GPMT", example = 5.0 } +IH_MAXIMUM_IDX = { name = "Maximum discretised intensity", ibsi = "3NCY", example = 5.0 } +IH_MODE_IDX = { name = "Intensity histogram mode", ibsi = "AMMC", example = 3.0 } +IH_INTERQUANTILE_RANGE_IDX = { name = "Discretised intensity interquartile range", ibsi = "WR0O", example = 1.0 } +IH_RANGE_IDX = { name = "Discretised intensity range", ibsi = "5Z3W", example = 4.0 } +IH_MEAN_ABSOLUTE_DEVIATION_IDX = { name = "Intensity histogram mean absolute deviation", ibsi = "D2ZX", example = 0.96 } +IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_IDX = { name = "Intensity histogram robust mean absolute deviation", ibsi = "WRZB", example = 0.96 } +IH_MEDIAN_ABSOLUTE_DEVIATION_IDX = { name = "Intensity histogram median absolute deviation", ibsi = "4RNL", example = 0.92 } +IH_COEFFICIENT_OF_VARIATION_IDX = { name = "Intensity histogram coefficient of variation", ibsi = "CWYJ", example = 0.4286 } +IH_QUANTILE_COEFFICIENT_OF_DISPERSION_IDX = { name = "Intensity histogram quartile coefficient of dispersion", ibsi = "SLWD", example = 0.2 } +IH_ENTROPY_IDX = { name = "Discretised intensity entropy", ibsi = "TLU2", example = 2.182 } +IH_UNIFORMITY_IDX = { name = "Discretised intensity uniformity", ibsi = "BJ5W", example = 0.2416 } +IH_MAX_GRADIENT = { name = "Maximum histogram gradient", ibsi = "12CE", example = 2.5 } +IH_MAX_GRADIENT_IDX = { name = "Maximum histogram gradient intensity", ibsi = "8E6O", example = 2.0 } +IH_MIN_GRADIENT = { name = "Minimum histogram gradient", ibsi = "VQB3", example = -3.0 } +IH_MIN_GRADIENT_IDX = { name = "Minimum histogram gradient intensity", ibsi = "RHQZ", example = 4.0 } +IH_ROBUST_MEAN_IDX = { name = "Robust mean discretised intensity", ibsi = "", example = 2.8 } +IH_NUM_BINS = { name = "Number of bins", ibsi = "", example = 5.0 } +IH_BIN_SIZE = { name = "Bin width", ibsi = "", example = 0.8 } + +[radial] +FRAC_AT_D = { name = "Fraction of pixels in a ring", ibsi = "", example = 0.02128 } +MEAN_FRAC = { name = "Mean intensity in a ring", ibsi = "", example = 7.0 } +RADIAL_CV = { name = "Coefficient of variation across the wedges of a ring", ibsi = "", example = 2.646 } diff --git a/docs/make.bat b/docs/make.bat index 7b5d91f9e..fdfb1c981 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -7,8 +7,8 @@ REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) -set SOURCEDIR=. -set BUILDDIR=_build +set SOURCEDIR=source +set BUILDDIR=build if "%1" == "" goto help diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..ec5354453 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,8 @@ +# Python packages needed to build the documentation. The compiled extension is +# not needed: docs/source/conf.py mocks nyxus.backend. +sphinx>=7,<9 +pydata-sphinx-theme>=0.15,<0.24 +sphinx-design>=0.5,<0.8 +sphinx-autodoc-typehints>=2,<4 +numpy +pandas diff --git a/docs/source/Math.rst b/docs/source/Math.rst index 26d128d45..0df9d3168 100644 --- a/docs/source/Math.rst +++ b/docs/source/Math.rst @@ -8,17 +8,28 @@ Math formulas :caption: Math: Math/f_pixel_intensity + Math/f_intensity_3d + Math/f_intensity_histogram Math/f_2dmoments Math/f_erosion Math/f_fractal_dimension Math/f_gabor Math/f_glcm + Math/f_glcm_3d Math/f_gldm + Math/f_gldm_3d Math/f_glrlm + Math/f_glrlm_3d Math/f_gldzm + Math/f_gldzm_3d Math/f_glszm + Math/f_glszm_3d Math/f_ngldm + Math/f_ngldm_3d Math/f_ngtdm + Math/f_ngtdm_3d Math/f_morphology + Math/f_morphology_3d + Math/f_radial_distribution Math/f_zernike Math/f_image_quality diff --git a/docs/source/Math/basic_morphology_index.inc b/docs/source/Math/basic_morphology_index.inc new file mode 100644 index 000000000..2f2168b75 --- /dev/null +++ b/docs/source/Math/basic_morphology_index.inc @@ -0,0 +1,87 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`AREA_PIXELS_COUNT ` + - Area in pixels + - — + - MATLAB (rel. 1e-3) + - 116 + * - :ref:`AREA_UM2 ` + - Area in square micrometres + - — + - MATLAB (rel. 1e-3) + - 0 + * - :ref:`CENTROID_X ` + - Centroid x + - — + - MATLAB (rel. 1e-3) + - 6.405 + * - :ref:`CENTROID_Y ` + - Centroid y + - — + - MATLAB (rel. 1e-3) + - 6.75 + * - :ref:`WEIGHTED_CENTROID_Y ` + - Intensity-weighted centroid y + - — + - MATLAB (rel. 1e-3) + - 6.706 + * - :ref:`WEIGHTED_CENTROID_X ` + - Intensity-weighted centroid x + - — + - MATLAB (rel. 1e-3) + - 6.373 + * - :ref:`MASS_DISPLACEMENT ` + - Mass displacement + - — + - CellProfiler (rel. 1e-6) + - 0.05448 + * - :ref:`COMPACTNESS ` + - Compactness + - — + - regression + - 0.01369 + * - :ref:`BBOX_YMIN ` + - Bounding box top row + - — + - MATLAB (rel. 1e-3) + - 1 + * - :ref:`BBOX_XMIN ` + - Bounding box left column + - — + - MATLAB (rel. 1e-3) + - 1 + * - :ref:`BBOX_HEIGHT ` + - Bounding box height + - — + - MATLAB (rel. 1e-3) + - 12 + * - :ref:`BBOX_WIDTH ` + - Bounding box width + - — + - MATLAB (rel. 1e-3) + - 12 + * - :ref:`DIAMETER_EQUAL_AREA ` + - Equal-area diameter + - — + - scikit-image (rel. 1e-3) + - 12.15 + * - :ref:`EXTENT ` + - Extent + - — + - MATLAB (rel. 1e-3) + - 0.8056 + * - :ref:`ASPECT_RATIO ` + - Aspect ratio + - — + - MATLAB (rel. 1e-3) + - 1 diff --git a/docs/source/Math/bw_mask.png b/docs/source/Math/bw_mask.png deleted file mode 100644 index 51bc20caa..000000000 Binary files a/docs/source/Math/bw_mask.png and /dev/null differ diff --git a/docs/source/Math/chords_index.inc b/docs/source/Math/chords_index.inc new file mode 100644 index 000000000..d79c323df --- /dev/null +++ b/docs/source/Math/chords_index.inc @@ -0,0 +1,92 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`MAXCHORDS_MAX ` + - Maximum chords, maximum + - — + - regression + - 12 + * - :ref:`MAXCHORDS_MAX_ANG ` + - Maximum chords, angle of the maximum + - — + - regression + - 0.1571 + * - :ref:`MAXCHORDS_MIN ` + - Maximum chords, minimum + - — + - regression + - 4 + * - :ref:`MAXCHORDS_MIN_ANG ` + - Maximum chords, angle of the minimum + - — + - regression + - 0.6283 + * - :ref:`MAXCHORDS_MEDIAN ` + - Maximum chords, median + - — + - regression + - 11 + * - :ref:`MAXCHORDS_MEAN ` + - Maximum chords, mean + - — + - regression + - 9.5 + * - :ref:`MAXCHORDS_MODE ` + - Maximum chords, mode + - — + - regression + - 12 + * - :ref:`MAXCHORDS_STDDEV ` + - Maximum chords, standard deviation + - — + - regression + - 3.137 + * - :ref:`ALLCHORDS_MAX ` + - All chords, maximum + - — + - regression + - 12 + * - :ref:`ALLCHORDS_MAX_ANG ` + - All chords, angle of the maximum + - — + - regression + - 0.1571 + * - :ref:`ALLCHORDS_MIN ` + - All chords, minimum + - — + - IMEA (rel. 6e-2) + - 1 + * - :ref:`ALLCHORDS_MIN_ANG ` + - All chords, angle of the minimum + - — + - regression + - 0.1571 + * - :ref:`ALLCHORDS_MEDIAN ` + - All chords, median + - — + - regression + - 4 + * - :ref:`ALLCHORDS_MEAN ` + - All chords, mean + - — + - regression + - 5.441 + * - :ref:`ALLCHORDS_MODE ` + - All chords, mode + - — + - regression + - 4 + * - :ref:`ALLCHORDS_STDDEV ` + - All chords, standard deviation + - — + - regression + - 2.865 diff --git a/docs/source/Math/circles_index.inc b/docs/source/Math/circles_index.inc new file mode 100644 index 000000000..bde1476c5 --- /dev/null +++ b/docs/source/Math/circles_index.inc @@ -0,0 +1,27 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`DIAMETER_MIN_ENCLOSING_CIRCLE ` + - Minimum enclosing circle diameter + - — + - IMEA (rel. 6e-2) + - 14.17 + * - :ref:`DIAMETER_CIRCUMSCRIBING_CIRCLE ` + - Circumscribing circle diameter + - — + - regression + - 17.41 + * - :ref:`DIAMETER_INSCRIBING_CIRCLE ` + - Inscribing circle diameter + - — + - regression + - 1.29 diff --git a/docs/source/Math/contour_index.inc b/docs/source/Math/contour_index.inc new file mode 100644 index 000000000..a63276aef --- /dev/null +++ b/docs/source/Math/contour_index.inc @@ -0,0 +1,47 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`PERIMETER ` + - Perimeter + - — + - scikit-image (rel. 1e-3) + - 48.14 + * - :ref:`DIAMETER_EQUAL_PERIMETER ` + - Equal-perimeter diameter + - — + - IMEA (exact) + - 15.32 + * - :ref:`EDGE_MEAN_INTENSITY ` + - Edge mean intensity + - — + - CellProfiler (rel. 1e-6) + - 5.182 + * - :ref:`EDGE_STDDEV_INTENSITY ` + - Edge intensity standard deviation + - — + - invariant + - 2.644 + * - :ref:`EDGE_MAX_INTENSITY ` + - Edge maximum intensity + - — + - CellProfiler (rel. 1e-6) + - 9 + * - :ref:`EDGE_MIN_INTENSITY ` + - Edge minimum intensity + - — + - CellProfiler (rel. 1e-6) + - 1 + * - :ref:`EDGE_INTEGRATED_INTENSITY ` + - Edge integrated intensity + - — + - CellProfiler (rel. 1e-6) + - 228 diff --git a/docs/source/Math/contour_tracing_moore_algorithm_1.jpg b/docs/source/Math/contour_tracing_moore_algorithm_1.jpg deleted file mode 100644 index 27a3ccfde..000000000 Binary files a/docs/source/Math/contour_tracing_moore_algorithm_1.jpg and /dev/null differ diff --git a/docs/source/Math/contour_tracing_moore_algorithm_2.jpg b/docs/source/Math/contour_tracing_moore_algorithm_2.jpg deleted file mode 100644 index d2bf275e2..000000000 Binary files a/docs/source/Math/contour_tracing_moore_algorithm_2.jpg and /dev/null differ diff --git a/docs/source/Math/contour_tracing_moore_algorithm_3.jpg b/docs/source/Math/contour_tracing_moore_algorithm_3.jpg deleted file mode 100644 index 4525ebbd4..000000000 Binary files a/docs/source/Math/contour_tracing_moore_algorithm_3.jpg and /dev/null differ diff --git a/docs/source/Math/convex_hull_index.inc b/docs/source/Math/convex_hull_index.inc new file mode 100644 index 000000000..0956a128f --- /dev/null +++ b/docs/source/Math/convex_hull_index.inc @@ -0,0 +1,42 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`CIRCULARITY ` + - Circularity + - — + - analytic + - 0.7931 + * - :ref:`CONVEX_HULL_AREA ` + - Convex hull area + - — + - scikit-image + - 131 + * - :ref:`SOLIDITY ` + - Solidity + - — + - scikit-image + - 0.8855 + * - :ref:`POLYGONALITY_AVE ` + - Polygonality + - — + - regression + - −1 + * - :ref:`HEXAGONALITY_AVE ` + - Hexagonality + - — + - regression + - −1 + * - :ref:`HEXAGONALITY_STDDEV ` + - Hexagonality dispersion + - — + - regression + - −1 diff --git a/docs/source/Math/ellipse_index.inc b/docs/source/Math/ellipse_index.inc new file mode 100644 index 000000000..90c944a32 --- /dev/null +++ b/docs/source/Math/ellipse_index.inc @@ -0,0 +1,42 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`MAJOR_AXIS_LENGTH ` + - Major axis length + - — + - MATLAB (rel. 1e-3) + - 13.91 + * - :ref:`MINOR_AXIS_LENGTH ` + - Minor axis length + - — + - MATLAB (rel. 1e-3) + - 12.55 + * - :ref:`ELONGATION ` + - Elongation + - — + - MATLAB (rel. 1e-3) + - 0.9025 + * - :ref:`ECCENTRICITY ` + - Eccentricity + - — + - MATLAB (rel. 1e-3) + - 0.4306 + * - :ref:`ORIENTATION ` + - Orientation + - — + - scikit-image + - −18.77 + * - :ref:`ROUNDNESS ` + - Roundness + - — + - analytic + - 0.7635 diff --git a/docs/source/Math/erosion.jpg b/docs/source/Math/erosion.jpg deleted file mode 100644 index 4a4734ad0..000000000 Binary files a/docs/source/Math/erosion.jpg and /dev/null differ diff --git a/docs/source/Math/erosion2vanish.png b/docs/source/Math/erosion2vanish.png deleted file mode 100644 index a8a719dcf..000000000 Binary files a/docs/source/Math/erosion2vanish.png and /dev/null differ diff --git a/docs/source/Math/erosion_index.inc b/docs/source/Math/erosion_index.inc new file mode 100644 index 000000000..d5f17c3a7 --- /dev/null +++ b/docs/source/Math/erosion_index.inc @@ -0,0 +1,22 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`EROSIONS_2_VANISH ` + - Erosions to vanish + - — + - regression + - 2 + * - :ref:`EROSIONS_2_VANISH_COMPLEMENT ` + - Erosions to vanish, complement (not computed) + - — + - regression + - 0 diff --git a/docs/source/Math/euler_index.inc b/docs/source/Math/euler_index.inc new file mode 100644 index 000000000..4b096046a --- /dev/null +++ b/docs/source/Math/euler_index.inc @@ -0,0 +1,17 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`EULER_NUMBER ` + - Euler number + - — + - MATLAB (rel. 1e-3) + - 1 diff --git a/docs/source/Math/extrema_index.inc b/docs/source/Math/extrema_index.inc new file mode 100644 index 000000000..1896e0a71 --- /dev/null +++ b/docs/source/Math/extrema_index.inc @@ -0,0 +1,92 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`EXTREMA_P1_X ` + - Extremum point 1, x + - — + - MATLAB (rel. 1e-3) + - 3 + * - :ref:`EXTREMA_P1_Y ` + - Extremum point 1, y + - — + - MATLAB (rel. 1e-3) + - 1 + * - :ref:`EXTREMA_P2_X ` + - Extremum point 2, x + - — + - MATLAB (rel. 1e-3) + - 11 + * - :ref:`EXTREMA_P2_Y ` + - Extremum point 2, y + - — + - MATLAB (rel. 1e-3) + - 1 + * - :ref:`EXTREMA_P3_X ` + - Extremum point 3, x + - — + - MATLAB (rel. 1e-3) + - 12 + * - :ref:`EXTREMA_P3_Y ` + - Extremum point 3, y + - — + - MATLAB (rel. 1e-3) + - 2 + * - :ref:`EXTREMA_P4_X ` + - Extremum point 4, x + - — + - MATLAB (rel. 1e-3) + - 12 + * - :ref:`EXTREMA_P4_Y ` + - Extremum point 4, y + - — + - MATLAB (rel. 1e-3) + - 9 + * - :ref:`EXTREMA_P5_X ` + - Extremum point 5, x + - — + - MATLAB (rel. 1e-3) + - 9 + * - :ref:`EXTREMA_P5_Y ` + - Extremum point 5, y + - — + - MATLAB (rel. 1e-3) + - 12 + * - :ref:`EXTREMA_P6_X ` + - Extremum point 6, x + - — + - MATLAB (rel. 1e-3) + - 3 + * - :ref:`EXTREMA_P6_Y ` + - Extremum point 6, y + - — + - MATLAB (rel. 1e-3) + - 12 + * - :ref:`EXTREMA_P7_X ` + - Extremum point 7, x + - — + - MATLAB (rel. 1e-3) + - 1 + * - :ref:`EXTREMA_P7_Y ` + - Extremum point 7, y + - — + - MATLAB (rel. 1e-3) + - 10 + * - :ref:`EXTREMA_P8_X ` + - Extremum point 8, x + - — + - MATLAB (rel. 1e-3) + - 1 + * - :ref:`EXTREMA_P8_Y ` + - Extremum point 8, y + - — + - MATLAB (rel. 1e-3) + - 3 diff --git a/docs/source/Math/f_2dmoments.rst b/docs/source/Math/f_2dmoments.rst index a0b0275d3..db482c853 100644 --- a/docs/source/Math/f_2dmoments.rst +++ b/docs/source/Math/f_2dmoments.rst @@ -1,206 +1,739 @@ +2D geometric moments +==================== + +Geometric moments sum powers of the pixel coordinates over a ROI. Nyxus computes them twice: the +*shape moments* count every ROI pixel as 1, and the *intensity moments* weight each pixel by its +intensity. Each family has raw, central and normalized moments, Hu's seven invariants, and the same +set again with every pixel also weighted by its distance to the ROI contour. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - Shape moments: ``SPAT_MOMENT_*``, ``CENTRAL_MOMENT_*``, ``NORM_SPAT_MOMENT_*``, + ``NORM_CENTRAL_MOMENT_*``, ``HU_M1`` to ``HU_M7``, ``WEIGHTED_SPAT_MOMENT_*``, + ``WEIGHTED_CENTRAL_MOMENT_*``, ``WT_NORM_CTR_MOM_*`` and ``WEIGHTED_HU_M1`` to + ``WEIGHTED_HU_M7``: 90 features. Intensity moments: ``IMOM_RM_*``, ``IMOM_CM_*``, + ``IMOM_NRM_*``, ``IMOM_NCM_*``, ``IMOM_HU1`` to ``IMOM_HU7``, ``IMOM_WRM_*``, + ``IMOM_WCM_*``, ``IMOM_WNCM_*`` and ``IMOM_WHU1`` to ``IMOM_WHU7``: 90 features. One value + per ROI each. + * - **Group** + - ``*GEOMOMS*`` for both families, ``*SGEOMOMS*`` for the shape moments, ``*IGEOMOMS*`` for + the intensity moments. Both families are also in ``*ALL*``, and the intensity moments in + ``*WHOLESLIDE*``. + * - **Dimensions** + - 2D only. + * - **GPU** + - Yes, in GPU builds with ``--useGpu=true`` (Python ``use_gpu_device``), for ROIs computed + in memory with a label image read from files; out-of-core ROIs and whole slides use the + CPU, and ``featurize`` on arrays must not be combined with the GPU (see :doc:`/gpu`). The + GPU values differ from the CPU values: the GPU takes the central moments about the centroid + rounded down to whole pixels, so the central, normalized central and Hu moments differ, and + with anisotropy the run can crash, a known defect (`#521 `__). The GPU's distance search computes + the step with a single-precision logarithm, and it has no guard for a ROI without a traced + contour, such as a single pixel. + * - **Out-of-core** + - Partly. Without anisotropy: on an image that Nyxus reads as one tile, the 118 unweighted + moments have the same values as in memory. On an image read as several tiles, out-of-core + processing of this family raises an error or returns wrong values, depending on the ROI (see + :ref:`large-images-tiles`). The 62 weighted moments + differ unless the ROI's bounding box starts at pixel (0, 0), and can differ even then, a + known defect (`#509 `__; see + :ref:`moments-possible-issues` and :doc:`/large_images`). With anisotropy, on an image + that Nyxus reads as one tile, the weighted intensity moments get their in-memory values and + only weighted shape moments differ. + * - **IBSI** + - None. IBSI defines no geometric moment features. + +.. tip:: + + The code digits are the powers of :math:`x` and :math:`y`, with :math:`x` the column and + :math:`y` the row. OpenCV's ``cv2.moments`` uses the same convention, so ``m10`` is + ``SPAT_MOMENT_10``. scikit-image's ``moments(image)[i, j]`` puts the row first: Nyxus's + :math:`m_{pq}` is ``moments(image)[q, p]``, or ``moments(image.T)[p, q]``. + + +How the features are computed +----------------------------- + +- **Pixels:** the ROI's pixels, those of the mask with the ROI's label. The other pixels of the + bounding box take no part. +- **Coordinates:** :math:`x_i` is the column and :math:`y_i` the row of pixel :math:`i`, both + integers measured from the top-left corner of the ROI's bounding box. A pixel's coordinates are + those of its centre. Moving the ROI inside the image does not change any value computed in + memory; out of core the weighted moments depend on the ROI's position (see + :ref:`moments-possible-issues`). With anisotropy the positions change (see `Settings`_). +- **Values:** the shape moments use :math:`I_i = 1` for every pixel. The intensity moments use the + pixel's intensity as a grey level, the integer Nyxus holds after loading the image. They are not + converted back to the image's units (see *Intensity units and CT images* in + :doc:`/supported_formats`). A ROI pixel of intensity 0 adds nothing to an intensity moment and 1 + to a shape moment. +- **Binning:** none; ``--coarseGrayDepth`` and ``--ibsi`` do not apply. +- **Weighted moments:** each pixel gets the weight :math:`w_i = I_i \ln(d_i + 0.001)`, where + :math:`d_i` is its distance to the ROI contour. The weight is stored in single precision, then + summed in double precision. It is negative for :math:`d_i < 0.999`, about 0.001 for + :math:`d_i = 1` and positive beyond. A pixel at :math:`d_i = 0` gets :math:`I_i \ln 0.001 = + -6.908\,I_i`. +- **Missing values:** a result that is not a finite number, such as a division by + :math:`m_{00} = 0`, is output as the ``--noval`` substitute, 0 by default (``-0.0`` in Arrow + and Parquet files; see :ref:`conventions-missing`). + +How the distance to the contour is found +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +1. **Contour.** Nyxus traces the border of the ROI in a copy of its bounding box padded by one + pixel on every side, and concatenates every traced contour into one list. The traced + coordinates keep the padding offset, a known defect (`#497 `__): each contour pixel lies one pixel to the right of and one + pixel below the ROI border pixel it stands for. The same contour gives ``PERIMETER``, which the + offset does not change. +2. **Search.** :math:`d_i^2` is the smallest squared distance from pixel :math:`i` to the contour + pixels that an approximate search visits. With :math:`n` contour pixels, it first visits every + :math:`s`-th pixel of the list, :math:`s = \lfloor n / \ln n \rfloor`, starting at the first. + Around the nearest one it searches a window of :math:`\pm s` list positions, then narrower + windows (step :math:`\lfloor w / \ln w \rfloor` for a window of :math:`w` positions, 1 once + :math:`w \le 10`), until the window is 2 positions wide. The window never wraps around the + ends of the list, so the true nearest contour pixel can be missed, and :math:`d_i` is then too + large. It is never too small. +3. **Weight.** :math:`d_i` is the square root, and the weight is :math:`I_i \ln(d_i + 0.001)`. + +A ROI without a traced contour, such as a single pixel, has :math:`d_i = 0` for every pixel. + +In whole-slide mode (``--segDir`` equal to ``--intDir``, or ``single_roi=True`` in Python) the +contour is the four corners of the image, at :math:`(0, 0)`, :math:`(W, 0)`, :math:`(W, H)` and +:math:`(0, H)` for an image of :math:`W \times H` pixels. A pixel in row 0 or column 0 gets the +weight 0.001, whatever its intensity. Every other pixel gets :math:`I_i \ln(d_i + 0.001)` with +:math:`d_i = \min(x_i, y_i, W - x_i, H - y_i)`, the distance to the nearest edge line. + + +Worked example +^^^^^^^^^^^^^^ + +Take a ROI of 40 pixels in a 7 × 8 bounding box; the mask is the pixels with a non-zero intensity: -2D moments -========== +.. math:: + + \textbf{I} = \begin{bmatrix} + \cdot & \cdot & 6 & 2 & 5 & \cdot & \cdot & \cdot\\ + \cdot & 3 & 7 & 4 & 1 & 5 & 2 & \cdot\\ + 4 & 1 & 2 & 6 & 3 & 7 & 4 & 1\\ + 5 & 7 & 3 & 1 & 6 & 2 & 5 & 3\\ + 2 & 4 & 6 & 5 & 2 & 4 & 1 & \cdot\\ + \cdot & 6 & 1 & 3 & 7 & 5 & \cdot & \cdot\\ + \cdot & \cdot & 3 & 2 & 4 & \cdot & \cdot & \cdot \end{bmatrix} -Idea ----- +The top-left entry is :math:`x = 0, y = 0`. The shape moments give :math:`m_{00} = 40`, +:math:`m_{10} = 131` and :math:`m_{01} = 117`, so the centroid is :math:`\bar{x} = 3.275`, +:math:`\bar{y} = 2.925`. The intensity moments give :math:`m_{00} = 150`, the sum of the +intensities. -Let :math:`f(x,y)` be a real valued function at Cartesian -location :math:`(x,y)`. The central moments of :math:`f(x,y)` are defined as +The traced contour has 17 pixels, each one right of and one below a border pixel of the ROI. The +distances :math:`d_i` that Nyxus uses are: .. math:: - \mu_{pq}=\int_{a_1}^{a_2} \int_{b_1}^{b_2} (x-\bar{x})^p(y-\bar{y})^q f(x,y) dxdy -where :math:`\bar{x}` and :math:`\bar{y}` are defined as + \textbf{d} = \begin{bmatrix} + \cdot & \cdot & 1.414 & 1 & 1 & \cdot & \cdot & \cdot\\ + \cdot & 2 & 1 & 0 & 0 & 0 & 1 & \cdot\\ + 3.162 & 2.236 & 1.414 & 1 & 1 & 1 & 0 & 0\\ + 3.606 & 2.828 & 2.236 & 2 & 2 & 1.414 & 1 & 1\\ + 1 & 0 & 1 & 2 & 2.828 & 2.236 & 1.414 & \cdot\\ + \cdot & 0 & 1 & 1.414 & 2 & 1.414 & \cdot & \cdot\\ + \cdot & \cdot & 0 & 1 & 1 & \cdot & \cdot & \cdot \end{bmatrix} + +The left-hand pixels of rows 2 and 3 are border pixels, but get :math:`d = 3.162` and +:math:`d = 3.606`. For :math:`(x, y) = (0, 3)` the search visits list positions 0, 6 and 12 +(:math:`s = \lfloor 17 / \ln 17 \rfloor = 6`), keeps position 0 at :math:`d^2 = 13`, and +searches positions 0 to 5 around it. The traced pixel at distance 1, position 15, lies outside +that window. Eight of the 40 pixels get a larger :math:`d` than their nearest traced pixel, by up +to 2.83 pixels. + +The weights sum to :math:`m^w_{00} = -42.77` for the shape and :math:`-144.7` for the intensity. +Both are negative, so every weighted normalized moment of odd order, and weighted Hu invariants 3 +to 7, are not real numbers and are output as 0 (see :ref:`moments-possible-issues`). + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for +this ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/moments_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features="*GEOMOMS*" \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`x_i, y_i` + - Column and row of ROI pixel :math:`i`, from the top-left corner of the bounding box. + * - :math:`I_i` + - 1 for the shape moments; the pixel's grey level for the intensity moments. + * - :math:`m_{pq}` + - Raw moment, :math:`\sum_i I_i\, x_i^p\, y_i^q`, summed over the ROI's pixels. + * - :math:`\bar{x}, \bar{y}` + - Centroid, :math:`\bar{x} = m_{10} / m_{00}` and :math:`\bar{y} = m_{01} / m_{00}`. + * - :math:`\mu_{pq}` + - Central moment, :math:`\sum_i I_i\, (x_i - \bar{x})^p\, (y_i - \bar{y})^q`. + * - :math:`\eta_{pq}` + - Normalized central moment, :math:`\mu_{pq} / m_{00}^{(p+q)/2+1}`. + * - :math:`d_i` + - Distance from pixel :math:`i` to the traced contour, as found by the search above. + * - :math:`w_i` + - Weight of pixel :math:`i` in the weighted moments, :math:`I_i \ln(d_i + 0.001)`. + * - :math:`m^w_{pq}, \mu^w_{pq}, \eta^w_{pq}` + - The same moments with :math:`w_i` in place of :math:`I_i`, about the weighted centroid + :math:`\bar{x}_w = m^w_{10} / m^w_{00}`, :math:`\bar{y}_w = m^w_{01} / m^w_{00}`. + +In a code, the first digit is :math:`p`, the power of :math:`x`, and the second is :math:`q`, the +power of :math:`y`. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Whole-slide mode + - ``--segDir`` equal to ``--intDir`` + - ``featurize_files(..., single_roi=True)`` + - The image is one ROI, and the weighted moments measure distance to the image's edges (see + above). + * - Missing value + - ``--noval=0`` + - not available + - Replaces results that are not finite numbers. Python output uses 0; Arrow and Parquet + files hold ``-0.0``. + * - Memory limit + - ``--ramLimit`` + - ``Nyxus(..., ram_limit=...)`` + - ROIs whose footprint reaches the limit are computed out of core, which changes the weighted + moments; on an image read as several tiles it raises an error or changes the other moments + too. + * - GPU + - ``--useGpu=true`` + - ``Nyxus(..., use_gpu_device=0)`` + - Computes the moments of in-memory ROIs on the GPU, in GPU builds (see :doc:`/gpu`). + * - Anisotropy + - ``--anisox``, ``--anisoy`` + - ``Nyxus(..., anisotropy_x=..., anisotropy_y=...)`` + - Files only. The shape moments are computed on the ROI resampled by the factors, in pixels of + the resampled grid. The intensity moments are computed on the pixels as acquired, each at its + position scaled by the factors and weighted by its area: with ``anisotropy_x=2`` + ``SPAT_MOMENT_00`` and ``IMOM_RM_00`` both double. Arrays passed to ``featurize`` are not + resampled. + +The moments have no metaparameters. ``--coarseGrayDepth``, ``--ibsi`` and ``--pixelDistance`` do +not change them. All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``SPAT_MOMENT_00``, ``IMOM_HU1`` and so +on. There are no per-direction columns and no ``_AVE`` columns. + + +.. _moments-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. + +**Shape moments** + +.. include:: shape_moments_index.inc + +**Intensity moments** + +.. include:: intensity_moments_index.inc + + +Features +-------- + +Each section below covers one kind of moment for both families. The shape code uses +:math:`I_i = 1`; the intensity code uses the pixel's grey level. + +.. _feature-spat-moment-00: +.. _feature-spat-moment-01: +.. _feature-spat-moment-02: +.. _feature-spat-moment-03: +.. _feature-spat-moment-10: +.. _feature-spat-moment-11: +.. _feature-spat-moment-12: +.. _feature-spat-moment-13: +.. _feature-spat-moment-20: +.. _feature-spat-moment-21: +.. _feature-spat-moment-22: +.. _feature-spat-moment-23: +.. _feature-spat-moment-30: +.. _feature-imom-rm-00: +.. _feature-imom-rm-01: +.. _feature-imom-rm-02: +.. _feature-imom-rm-03: +.. _feature-imom-rm-10: +.. _feature-imom-rm-11: +.. _feature-imom-rm-12: +.. _feature-imom-rm-13: +.. _feature-imom-rm-20: +.. _feature-imom-rm-21: +.. _feature-imom-rm-22: +.. _feature-imom-rm-23: +.. _feature-imom-rm-30: -.. math:: - \bar{x} = \frac {M_{10}} {M_{00}} +Raw moments +^^^^^^^^^^^ + +:Codes: ``SPAT_MOMENT_pq`` and ``IMOM_RM_pq``, for :math:`pq` = 00, 01, 02, 03, 10, 11, 12, 13, + 20, 21, 22, 23 and 30 +:Range: :math:`[0, \infty)` -and +Sums of the coordinate powers over the ROI. :math:`m_{00}` is the ROI's area in pixels for the +shape, and the sum of its intensities for the intensity moments. :math:`m_{10}/m_{00}` and +:math:`m_{01}/m_{00}` are the centroid. .. math:: - \bar{y} = \frac {M_{01}} {M_{00}}. -The 0-th order moment :math:`M_{00}` of function :math:`f(x,y)` + m_{pq} = \sum_i I_i\, x_i^p\, y_i^q + +Because :math:`x_i` and :math:`y_i` are measured from the bounding-box corner, every raw moment is +non-negative. + + +.. _feature-central-moment-00: +.. _feature-central-moment-01: +.. _feature-central-moment-02: +.. _feature-central-moment-03: +.. _feature-central-moment-10: +.. _feature-central-moment-11: +.. _feature-central-moment-12: +.. _feature-central-moment-13: +.. _feature-central-moment-20: +.. _feature-central-moment-21: +.. _feature-central-moment-22: +.. _feature-central-moment-23: +.. _feature-central-moment-30: +.. _feature-central-moment-31: +.. _feature-central-moment-32: +.. _feature-central-moment-33: +.. _feature-imom-cm-00: +.. _feature-imom-cm-01: +.. _feature-imom-cm-02: +.. _feature-imom-cm-03: +.. _feature-imom-cm-10: +.. _feature-imom-cm-11: +.. _feature-imom-cm-12: +.. _feature-imom-cm-13: +.. _feature-imom-cm-20: +.. _feature-imom-cm-21: +.. _feature-imom-cm-22: +.. _feature-imom-cm-23: +.. _feature-imom-cm-30: +.. _feature-imom-cm-31: +.. _feature-imom-cm-32: +.. _feature-imom-cm-33: -.. math:: - M_{00} = \int _{a_1}^{a_2} \int _{b_1}^{b_2} f(x,y) dxdy +Central moments +^^^^^^^^^^^^^^^ + +:Codes: ``CENTRAL_MOMENT_pq`` and ``IMOM_CM_pq``, for :math:`p, q` from 0 to 3 (16 codes each) +:Range: :math:`(-\infty, \infty)`; non-negative when :math:`p` and :math:`q` are both even -represents the total mass of the -function :math:`f(x,y)` and the two 1-st order moments +The raw moments taken about the centroid, so they do not depend on where the ROI lies. The +second-order moments describe the spread, the third-order ones the skew. .. math:: - M_{10} = \int _{a_1}^{a_2} \int _{b_1}^{b_2} x f(x,y) dxdy -and + \mu_{pq} = \sum_i I_i\, (x_i - \bar{x})^p\, (y_i - \bar{y})^q, + \qquad \bar{x} = \frac{m_{10}}{m_{00}},\quad \bar{y} = \frac{m_{01}}{m_{00}} + +:math:`\mu_{00} = m_{00}`. :math:`\mu_{01}` and :math:`\mu_{10}` are 0 by definition; Nyxus returns +rounding residues of order :math:`10^{-14}` for them. + + +.. _feature-norm-spat-moment-00: +.. _feature-norm-spat-moment-01: +.. _feature-norm-spat-moment-02: +.. _feature-norm-spat-moment-03: +.. _feature-norm-spat-moment-10: +.. _feature-norm-spat-moment-11: +.. _feature-norm-spat-moment-12: +.. _feature-norm-spat-moment-13: +.. _feature-norm-spat-moment-20: +.. _feature-norm-spat-moment-21: +.. _feature-norm-spat-moment-22: +.. _feature-norm-spat-moment-23: +.. _feature-norm-spat-moment-30: +.. _feature-norm-spat-moment-31: +.. _feature-norm-spat-moment-32: +.. _feature-norm-spat-moment-33: +.. _feature-imom-nrm-00: +.. _feature-imom-nrm-01: +.. _feature-imom-nrm-02: +.. _feature-imom-nrm-03: +.. _feature-imom-nrm-10: +.. _feature-imom-nrm-11: +.. _feature-imom-nrm-12: +.. _feature-imom-nrm-13: +.. _feature-imom-nrm-20: +.. _feature-imom-nrm-21: +.. _feature-imom-nrm-22: +.. _feature-imom-nrm-23: +.. _feature-imom-nrm-30: +.. _feature-imom-nrm-31: +.. _feature-imom-nrm-32: +.. _feature-imom-nrm-33: + +Normalized raw moments +^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``NORM_SPAT_MOMENT_pq`` and ``IMOM_NRM_pq``, for :math:`p, q` from 0 to 3 (16 codes each) +:Range: :math:`[0, \infty)` + +Each raw moment divided by :math:`m_{00}` to the power used for the normalized central moments. .. math:: - M_{10} = \int _{a_1}^{a_2} \int _{b_1}^{b_2} y f(x,y) dxdy -represent the center of mass of the image :math:`f(x,y)`. Hu's Uniqueness Theorem states that if :math:`f(x,y)` is piecewise continuous and has nonzero values only in the finite part -of the :math:`(x,y)` plane, then geometric moments of all orders exist. It can then be shown that the moment set :math:`{\mu_{pq}}` is -uniquely determined by :math:`f(x,y)` and conversely, :math:`f(x,y)` is uniquely determined by :math:`{\mu_{pq}}`. Since an image has -finite area, a moment set can be evaluated computationally and used to uniquely describe the information contained in the image. Nyxus calculates 2 sets of geometric moments: moments where :math:`f(x,y)=1` within the ROI mask and 0 outside the ROI mask define shape moments while moments whose :math:`f(x,y)` are intensity at pixel location :math:`x,y` define intensity moments. + \frac{m_{pq}}{m_{00}^{(p+q)/2+1}} + +This applies the exponent of :math:`\eta_{pq}` to the *raw* moments, which are measured from the +bounding-box corner. It is neither :math:`\eta_{pq}` nor the moment per unit mass +:math:`m_{pq}/m_{00}`: on the worked example ``NORM_SPAT_MOMENT_10`` is +:math:`131 / 40^{1.5} = \bar{x} / \sqrt{40} = 0.5178`. ``NORM_SPAT_MOMENT_00`` is always 1. +The codes 31, 32 and 33 are computed from :math:`m_{31}`, :math:`m_{32}` and :math:`m_{33}`, which +have no code of their own. + + +.. _feature-norm-central-moment-02: +.. _feature-norm-central-moment-03: +.. _feature-norm-central-moment-11: +.. _feature-norm-central-moment-12: +.. _feature-norm-central-moment-20: +.. _feature-norm-central-moment-21: +.. _feature-norm-central-moment-30: +.. _feature-imom-ncm-02: +.. _feature-imom-ncm-03: +.. _feature-imom-ncm-11: +.. _feature-imom-ncm-12: +.. _feature-imom-ncm-20: +.. _feature-imom-ncm-21: +.. _feature-imom-ncm-30: -Raw moments ------------ +Normalized central moments +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``NORM_CENTRAL_MOMENT_pq`` and ``IMOM_NCM_pq``, for :math:`pq` = 02, 03, 11, 12, 20, 21 + and 30 +:Range: :math:`(-\infty, \infty)`; non-negative for 02 and 20 -Considering image pixels :math:`p(x,y)` as sampled greyscaled values of :math:`f(x,y)` at discrete locations, the moments introduced above can be approximated -by summation, and raw (spatial) moments :math:`m_{ij}` are defined as +The central moments made independent of size. For the shape, scaling the ROI leaves them nearly +unchanged; for the intensity, multiplying every intensity by :math:`c` multiplies them by +:math:`c^{-(p+q)/2}`. .. math:: - - m_{{ij}}=\sum _{x}\sum _{y}x^{i}y^{j}p(x,y) -Spatial moment features are calculated as: + \eta_{pq} = \frac{\mu_{pq}}{m_{00}^{(p+q)/2+1}} -.. math:: +This is the normalization of Hu, of scikit-image's ``moments_normalized`` and of OpenCV's +``nu`` moments. - \text{SPAT_MOMENT_00} &=m_{00} \\ - \text{SPAT_MOMENT_01} &=m_{01} \\ - \text{SPAT_MOMENT_02} &=m_{02} \\ - \text{SPAT_MOMENT_03} &=m_{03} \\ - \text{SPAT_MOMENT_10} &=m_{10} \\ - \text{SPAT_MOMENT_11} &=m_{11} \\ - \text{SPAT_MOMENT_12} &=m_{12} \\ - \text{SPAT_MOMENT_20} &=m_{20} \\ - \text{SPAT_MOMENT_21} &=m_{21} \\ - \text{SPAT_MOMENT_30} &=m_{30} -Central moments ---------------- +.. _feature-hu-m1: +.. _feature-hu-m2: +.. _feature-hu-m3: +.. _feature-hu-m4: +.. _feature-hu-m5: +.. _feature-hu-m6: +.. _feature-hu-m7: +.. _feature-imom-hu1: +.. _feature-imom-hu2: +.. _feature-imom-hu3: +.. _feature-imom-hu4: +.. _feature-imom-hu5: +.. _feature-imom-hu6: +.. _feature-imom-hu7: -A central moment :math:`\mu_{ij}` is defined as +Hu invariants +^^^^^^^^^^^^^ -.. math:: +:Codes: ``HU_M1`` to ``HU_M7`` and ``IMOM_HU1`` to ``IMOM_HU7`` +:Range: :math:`I_1, I_2, I_3, I_4 \ge 0`; :math:`I_5, I_6, I_7` of either sign - \mu_{{ij}}=\sum_{{x}}\sum _{{y}}(x-{\bar {x}})^{i}(y-{\bar {y}})^{j}p(x,y) +Hu's seven combinations of the normalized central moments, unchanged by translation and rotation. +:math:`I_7` changes sign when the ROI is mirrored. -Central moment features are calculated as: +.. math:: -.. math:: + I_1 &= \eta_{20} + \eta_{02}\\ + I_2 &= (\eta_{20} - \eta_{02})^2 + 4\eta_{11}^2\\ + I_3 &= (\eta_{30} - 3\eta_{12})^2 + (3\eta_{21} - \eta_{03})^2\\ + I_4 &= (\eta_{30} + \eta_{12})^2 + (\eta_{21} + \eta_{03})^2\\ + I_5 &= (\eta_{30} - 3\eta_{12})(\eta_{30} + \eta_{12}) + \left[(\eta_{30} + \eta_{12})^2 - 3(\eta_{21} + \eta_{03})^2\right]\\ + &\quad + (3\eta_{21} - \eta_{03})(\eta_{21} + \eta_{03}) + \left[3(\eta_{30} + \eta_{12})^2 - (\eta_{21} + \eta_{03})^2\right]\\ + I_6 &= (\eta_{20} - \eta_{02})\left[(\eta_{30} + \eta_{12})^2 - (\eta_{21} + \eta_{03})^2\right] + + 4\eta_{11}(\eta_{30} + \eta_{12})(\eta_{21} + \eta_{03})\\ + I_7 &= (3\eta_{21} - \eta_{03})(\eta_{30} + \eta_{12}) + \left[(\eta_{30} + \eta_{12})^2 - 3(\eta_{21} + \eta_{03})^2\right]\\ + &\quad - (\eta_{30} - 3\eta_{12})(\eta_{21} + \eta_{03}) + \left[3(\eta_{30} + \eta_{12})^2 - (\eta_{21} + \eta_{03})^2\right] + +``HU_Mk`` and ``IMOM_HUk`` are :math:`I_k`. The values equal OpenCV's ``cv2.HuMoments``, and +scikit-image's ``moments_hu`` computed on ``image.T``; on ``image`` itself scikit-image's +:math:`I_7` has the opposite sign, because it puts the row first. + + +.. _feature-weighted-spat-moment-00: +.. _feature-weighted-spat-moment-01: +.. _feature-weighted-spat-moment-02: +.. _feature-weighted-spat-moment-03: +.. _feature-weighted-spat-moment-10: +.. _feature-weighted-spat-moment-11: +.. _feature-weighted-spat-moment-12: +.. _feature-weighted-spat-moment-20: +.. _feature-weighted-spat-moment-21: +.. _feature-weighted-spat-moment-30: +.. _feature-imom-wrm-00: +.. _feature-imom-wrm-01: +.. _feature-imom-wrm-02: +.. _feature-imom-wrm-03: +.. _feature-imom-wrm-10: +.. _feature-imom-wrm-11: +.. _feature-imom-wrm-12: +.. _feature-imom-wrm-20: +.. _feature-imom-wrm-21: +.. _feature-imom-wrm-30: - \text{CENTRAL_MOMENT_02} &=\mu_{02} \\ - \text{CENTRAL_MOMENT_03} &=\mu_{03} \\ - \text{CENTRAL_MOMENT_11} &=\mu_{11} \\ - \text{CENTRAL_MOMENT_12} &=\mu_{12} \\ - \text{CENTRAL_MOMENT_20} &=\mu_{20} \\ - \text{CENTRAL_MOMENT_21} &=\mu_{21} \\ - \text{CENTRAL_MOMENT_30} &=\mu_{20} \\ +Weighted raw moments +^^^^^^^^^^^^^^^^^^^^ -Normalized raw moments ----------------------- +:Codes: ``WEIGHTED_SPAT_MOMENT_pq`` and ``IMOM_WRM_pq``, for :math:`pq` = 00, 01, 02, 03, 10, 11, + 12, 20, 21 and 30 +:Range: :math:`(-\infty, \infty)` -Raw (spatial) moments :math:`m_{ij}` of a 2-dimensional greyscale image :math:`p(x,y)` are calculated by +The raw moments with each pixel weighted by :math:`w_i = I_i \ln(d_i + 0.001)`, where :math:`d_i` +is its distance to the traced contour (see `How the distance to the contour is found`_). Pixels +on the traced contour count negatively (:math:`-6.9\,I_i`), pixels one step from it about 0, and +pixels deeper inside positively. .. math:: - w_{{ij}} = \frac {\mu_{ij}}{\mu_{22}^ {max(i,j)} } + m^w_{pq} = \sum_i w_i\, x_i^p\, y_i^q, \qquad w_i = I_i \ln(d_i + 0.001) -Spatial moment features are calculated as: +**Possible issues.** :math:`d_i` comes from a contour shifted by one pixel and from an approximate +search, so these values differ from the same formula with the exact distance to the ROI border +(see :ref:`moments-possible-issues`). -.. math:: - \text{NORM_SPAT_MOMENT_00} =w_{00} \\ - \text{NORM_SPAT_MOMENT_01} =w_{01} \\ - \text{NORM_SPAT_MOMENT_02} =w_{02} \\ - \text{NORM_SPAT_MOMENT_03} =w_{03} \\ - \text{NORM_SPAT_MOMENT_10} =w_{10} \\ - \text{NORM_SPAT_MOMENT_20} =w_{20} \\ - \text{NORM_SPAT_MOMENT_30} =w_{30} \\ +.. _feature-weighted-central-moment-02: +.. _feature-weighted-central-moment-03: +.. _feature-weighted-central-moment-11: +.. _feature-weighted-central-moment-12: +.. _feature-weighted-central-moment-20: +.. _feature-weighted-central-moment-21: +.. _feature-weighted-central-moment-30: +.. _feature-imom-wcm-02: +.. _feature-imom-wcm-03: +.. _feature-imom-wcm-11: +.. _feature-imom-wcm-12: +.. _feature-imom-wcm-20: +.. _feature-imom-wcm-21: +.. _feature-imom-wcm-30: -Normalized central moments --------------------------- +Weighted central moments +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``WEIGHTED_CENTRAL_MOMENT_pq`` and ``IMOM_WCM_pq``, for :math:`pq` = 02, 03, 11, 12, 20, + 21 and 30 +:Range: :math:`(-\infty, \infty)` -A normalized central moment :math:`\eta_{ij}` is defined as +The weighted moments taken about the weighted centroid. .. math:: - \eta_{{ij}}={\frac {\mu_{{ij}}}{\mu_{{00}}^{{\left(1+{\frac {i+j}{2}}\right)}}}}\, + \mu^w_{pq} = \sum_i w_i\, (x_i - \bar{x}_w)^p\, (y_i - \bar{y}_w)^q, + \qquad \bar{x}_w = \frac{m^w_{10}}{m^w_{00}},\quad \bar{y}_w = \frac{m^w_{01}}{m^w_{00}} -where :math:`\mu _{{ij}}` is central moment. +When :math:`m^w_{00}` is close to 0 the weighted centroid can lie far outside the ROI, and these +moments become very large. -Normalized central moment features are calculated as: -.. math:: - \text{NORM_CENTRAL_MOMENT_02} &=\eta_{{02}} \\ - \text{NORM_CENTRAL_MOMENT_03} &=\eta_{{03}} \\ - \text{NORM_CENTRAL_MOMENT_11} &=\eta_{{11}} \\ - \text{NORM_CENTRAL_MOMENT_12} &=\eta_{{12}} \\ - \text{NORM_CENTRAL_MOMENT_20} &=\eta_{{20}} \\ - \text{NORM_CENTRAL_MOMENT_21} &=\eta_{{21}} \\ - \text{NORM_CENTRAL_MOMENT_30} &=\eta_{{30}} +.. _feature-wt-norm-ctr-mom-02: +.. _feature-wt-norm-ctr-mom-03: +.. _feature-wt-norm-ctr-mom-11: +.. _feature-wt-norm-ctr-mom-12: +.. _feature-wt-norm-ctr-mom-20: +.. _feature-wt-norm-ctr-mom-21: +.. _feature-wt-norm-ctr-mom-30: +.. _feature-imom-wncm-02: +.. _feature-imom-wncm-03: +.. _feature-imom-wncm-11: +.. _feature-imom-wncm-12: +.. _feature-imom-wncm-20: +.. _feature-imom-wncm-21: +.. _feature-imom-wncm-30: -Hu moments ----------- +Weighted normalized central moments +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Using nonlinear combinations of geometric moments, M.K. Hu derived a set of invariant moments which has the desirable properties of -being invariant under image translation, scaling, and rotation. Hu moments HU_M1 through HU_M7 are calculated as +:Codes: ``WT_NORM_CTR_MOM_pq`` and ``IMOM_WNCM_pq``, for :math:`pq` = 02, 03, 11, 12, 20, 21 and + 30 +:Range: :math:`(-\infty, \infty)` + +The weighted central moments normalized by the sum of the weights. .. math:: - \text{HU_M1} =& \eta_{{20}}+\eta _{{02}} \\ - \text{HU_M2} =& (\eta_{{20}}-\eta_{{02}})^{2}+4\eta_{{11}}^{2} \\ - \text{HU_M3} =& (\eta_{{30}}-3\eta_{{12}})^{2}+(3\eta_{{21}}-\eta _{{03}})^{2} \\ - \text{HU_M4} =& (\eta_{{30}}+\eta_{{12}})^{2}+(\eta_{{21}}+\eta _{{03}})^{2} \\ - \text{HU_M5} =& (\eta_{{30}}-3\eta_{{12}})(\eta_{{30}}+\eta_{{12}})[(\eta_{{30}}+\eta_{{12}})^{2}-3(\eta_{{21}}+\eta_{{03}})^{2}]+ \\ - &(3\eta_{{21}}-\eta_{{03}})(\eta_{{21}}+\eta_{{03}})[3(\eta_{{30}}+\eta_{{12}})^{2}-(\eta_{{21}}+\eta _{{03}})^{2}] \\ - \text{HU_M6} =& (\eta_{{20}}-\eta_{{02}})[(\eta_{{30}}+\eta_{{12}})^{2}-(\eta_{{21}}+\eta_{{03}})^{2}]+4\eta_{{11}}(\eta_{{30}}+\eta_{{12}})(\eta_{{21}}+\eta_{{03}}) \\ - \text{HU_M7} =& (3\eta_{{21}}-\eta_{{03}})(\eta_{{30}}+\eta_{{12}})[(\eta_{{30}}+\eta_{{12}})^{2}-3(\eta_{{21}}+\eta_{{03}})^{2}]- \\ - &(\eta_{{30}}-3\eta_{{12}})(\eta_{{21}}+\eta_{{03}})[3(\eta_{{30}}+\eta_{{12}})^{2}-(\eta_{{21}}+\eta _{{03}})^{2}] + \eta^w_{pq} = \frac{\mu^w_{pq}}{\left(m^w_{00}\right)^{(p+q)/2+1}} +**Possible issues.** When :math:`m^w_{00} < 0` the odd orders (03, 12, 21, 30) raise a negative +number to a half-integer power. The result is not a real number and is output as the ``--noval`` +substitute, 0 by default. The second-order ones (02, 11, 20) are always computed: their +denominator :math:`(m^w_{00})^2` is positive, so they take the sign of :math:`\mu^w_{pq}`. -Weighted raw moments --------------------- -Let :math:`W(x,y)` be a 2-dimensional weighted greyscale image such that each pixel of :math:`I` is weighted with respect to its distance to the nearest contour pixel: +.. _feature-weighted-hu-m1: +.. _feature-weighted-hu-m2: +.. _feature-weighted-hu-m3: +.. _feature-weighted-hu-m4: +.. _feature-weighted-hu-m5: +.. _feature-weighted-hu-m6: +.. _feature-weighted-hu-m7: +.. _feature-imom-whu1: +.. _feature-imom-whu2: +.. _feature-imom-whu3: +.. _feature-imom-whu4: +.. _feature-imom-whu5: +.. _feature-imom-whu6: +.. _feature-imom-whu7: -.. math:: - W(x,y) = \frac {p(x,y)} {\min_i d^2(x,y,C_i)} +Weighted Hu invariants +^^^^^^^^^^^^^^^^^^^^^^ -where C - set of 2-dimensional ROI contour pixels, :math:`d^2(.)` - Euclidean distance norm. Weighted raw moments :math:`w_{Mij}`` are defined as +:Codes: ``WEIGHTED_HU_M1`` to ``WEIGHTED_HU_M7`` and ``IMOM_WHU1`` to ``IMOM_WHU7`` +:Range: :math:`(-\infty, \infty)` -.. math:: - - w_{Mij}=\sum_{x}\sum _{y}x^{i}y^{j}W(x,y) +Hu's formulas (see `Hu invariants`_) applied to :math:`\eta^w_{pq}` in place of +:math:`\eta_{pq}`. Unlike :math:`I_1`, the weighted :math:`I_1` can be negative: it is +:math:`-0.194` on the worked example. -Weighted central moments ------------------------- +**Possible issues.** Invariants 3 to 7 use the odd-order :math:`\eta^w_{pq}`, so they are output as +the ``--noval`` substitute whenever :math:`m^w_{00} < 0`. Invariants 1 and 2 use only second-order +moments and are always computed. -Weighted central moments :math:`w_{\mu ij}` are defined as -.. math:: +.. _moments-possible-issues: - w_{\mu ij} = \sum_{{x}}\sum_{{y}}(x-{\bar {x}})^{i}(y-{\bar {y}})^{j}W(x,y) +Possible issues +--------------- -Weighted Hu moments -------------------- +The contour is shifted by one pixel + Each traced contour pixel lies one pixel right of and below the border pixel it stands for, so + the distances :math:`d_i` are measured to the wrong pixels. Border pixels on the top and left + sides are at distance 1 or more from the traced contour instead of 0. On a 48 × 40 rectangle, + 85 of the 1920 pixels lie on the traced contour, against 172 border pixels. With the exact + distance to the traced contour ``WEIGHTED_SPAT_MOMENT_00`` would be 2422.7; with the exact + distance to the ROI's own border it is 1821.3. + +The distance search is approximate + The search can miss the nearest contour pixel, so :math:`d_i` can be too large, never too + small. On the 48 × 40 rectangle 121 of the 1920 pixels get too large a distance, by up to 12 + pixels, and ``WEIGHTED_SPAT_MOMENT_00`` is 2498.7 instead of 2422.7. Together with the shifted + contour, Nyxus reports 37 % more than the exact distance to the ROI border gives. On the worked + example 8 of the 40 pixels get too large a distance. + +The sum of the weights is often negative + Every pixel at :math:`d_i < 0.999` has a negative weight, and a pixel at :math:`d_i = 0` weighs + :math:`-6.9\,I_i`. On small ROIs the sum :math:`m^w_{00}` is therefore negative, and all odd-order + weighted normalized moments and weighted Hu invariants 3 to 7 are output as the ``--noval`` + substitute. On digital disks ``WEIGHTED_SPAT_MOMENT_00`` is -68.94 at radius 5, 6.806 at radius 8 + and 1549 at radius 20. Near the sign change the normalization divides by a number close to 0: + ``WT_NORM_CTR_MOM_30`` is 7881 at radius 8 and 0.007713 at radius 20. + +Weighted moments differ out of core + Without anisotropy, an ROI computed out of core gets its contour from a different routine, which places it + relative to the ROI's bounding box while the pixels keep their image positions, a known defect + (`#509 `__). The distances + :math:`d_i` are then off by the box's offset, and all 62 weighted moments depend on where the + ROI lies. On an ellipse of 245 pixels ``WEIGHTED_SPAT_MOMENT_00`` is 49.81 in memory wherever + it lies, and 7.484, 58.50 or 338.2 out of core depending on where its bounding box starts: + (2, 2), (3, 5) or (10, 12). A ROI whose bounding box starts at (0, 0) is placed correctly, but + the out-of-core contour can list its pixels in a different order, which changes the + approximate distance search: a 48 × 40 rectangle there gets the in-memory values, while the + ellipse gives 43.75. The 118 unweighted moments are the same on both paths. + +Whole-slide mode + The image corners used as the contour lie one pixel past the last column and row, a known + defect (`#493 `__). Row 0 and + column 0 get the weight 0.001 whatever their intensity, while the last row and column get + :math:`I_i \ln 1.001`, so the weighting is not symmetric. + +Weighted values carry single-precision rounding + The weights are stored as 32-bit floats. The weighted moments agree with a double-precision + computation of the same formula to about :math:`10^{-7}` relative. + +Intensity moments depend on the intensity scale + Multiplying every intensity by :math:`c` multiplies ``IMOM_NCM_pq`` by :math:`c^{-(p+q)/2}`, + ``IMOM_NRM_pq`` by :math:`c^{-(p+q)/2}`, ``IMOM_HU1`` by :math:`c^{-1}` and ``IMOM_HU2`` by + :math:`c^{-2}`. On the worked example with intensities times 10, ``IMOM_HU1`` drops from + 0.04106 to 0.004106. Floating-point images are rescaled to grey levels when they are loaded (see + :doc:`/supported_formats`), so compare intensity moments only between images loaded the same + way. The shape moments do not depend on intensity. + +ROI with all intensities 0 + :math:`m_{00} = 0` for the intensity moments. The raw and weighted raw intensity moments are + 0. The others divide by :math:`m_{00}` or use the centroid, which is :math:`0/0`, and are + output as the ``--noval`` substitute, 0 by default. The shape moments are computed as usual. + +Single-pixel ROI + No contour is traced, so the pixel has :math:`d = 0` and the weight :math:`\ln 0.001`. + ``WEIGHTED_SPAT_MOMENT_00`` is -6.908, and the central and normalized central moments are 0. + + +Validation +---------- -A normalized weighted central moment :math:`w_{\eta ij}` is defined as +The test suite checks the 118 unweighted moments of both families (raw, central, normalized raw, +normalized central and Hu) against scikit-image to a relative :math:`10^{-6}`. scikit-image has no +function for the normalized raw moment, so ``NORM_SPAT_MOMENT_pq`` and ``IMOM_NRM_pq`` are +checked against its raw moments divided by the same power of :math:`m_{00}`. The 62 weighted +moments are covered by regression tests only: no reference tool computes the distance weight. +The worked-example script asserts every feature value of the worked example and the feature values +quoted under Possible issues, including the out-of-core ones. It does not assert the distance +matrix of the worked example, the counts of pixels given too large a distance, the +single-precision rounding of the weights or the comparison with OpenCV and scikit-image Hu +invariants; these were measured separately. The script compares no value with a reference tool. -.. math:: - - w_{{\eta ij}}={\frac {w_{{\mu ij}}}{w_{{\mu 00}}^{{\left(1+{\frac {i+j}{2}}\right)}}}}\, - -where :math:`w _{{\mu ij}}` is weighted central moment. Weighted Hu moments are defined as - -.. math:: - \text{WEIGHTED_HU_M1} =& w_{\eta 20}+w_{\eta 02} \\ - \text{WEIGHTED_HU_M2} =& (w_{\eta 20}-w_{\eta 02})^{2}+4w_{\eta 11}^{2} \\ - \text{WEIGHTED_HU_M3} =& (w_{\eta 30}-3w_{\eta 12})^{2}+(3w_{\eta 21}-w _{\eta 03})^{2} \\ - \text{WEIGHTED_HU_M4} =& (w_{\eta 30}+w_{\eta 12})^{2}+(w_{\eta 21}+w _{\eta 03})^{2} \\ - \text{ WEIGHTED_HU_M5} =& (w_{\eta 30}-3w_{\eta 12})(w_{\eta 30}+w_{\eta 12})[(w_{\eta 30}+w_{\eta 12})^{2}-3(w_{\eta 21}+ w_{\eta 03})^{2}]+ \\ - &(3w_{\eta 21}-w_{\eta 03})(w_{\eta 21}+w_{\eta 03})[3(w_{\eta 30}+w_{\eta 12})^{2}-(w_{\eta 21}+w _{\eta 03})^{2}] \\ - \text{WEIGHTED_HU_M6} =& (w_{\eta 20}-w_{\eta 02})[(w_{\eta 30}+w_{\eta 12})^{2}-(w_{\eta 21}+w_{\eta 03})^{2}]+ \\ - &4w_{\eta 11}(w_{\eta 30}+w_{\eta 12})(w_{\eta 21}+w_{\eta 03})\\ - \text{WEIGHTED_HU_M7} =& (3w_{\eta 21}-w_{\eta 03})(w_{\eta 30}+w_{\eta 12})[(w_{\eta 30}+w_{\eta 12})^{2}-3(w_{\eta 21}+w_{\eta 03})^{2}]- \\ - &(w_{\eta 30}-3w_{\eta 12})(w_{\eta 21}+w_{\eta 03})[3(w_{\eta 30}+w_{\eta 12})^{2}-(w_{\eta 21}+w _{\eta 03})^{2}] References ---------- -M.K. Hu. Pattern recognition by moment invariants, proc. IRE 49, 1961, 1428. - -M.K. Hu. Visual problem recognition by moment invariant. IRE Trans. Inform. Theory, Vol. IT-8, pp. 179-187, Feb. 1962. - -T.H. Reiss. The Revised Fundamental Theorem of Moment Invariants. IEEE Trans. Pattern Anal. Machine Intell., Vol. PAMI-13. No. 8, August 1991. pp. 830-834. \ No newline at end of file +1. Hu MK. *Visual pattern recognition by moment invariants.* IRE Transactions on Information + Theory. 1962;8(2):179–187. doi:10.1109/TIT.1962.1057692 +2. Flusser J, Suk T, Zitová B. *2D and 3D Image Analysis by Moments.* Chichester: Wiley; 2016. diff --git a/docs/source/Math/f_erosion.rst b/docs/source/Math/f_erosion.rst index e190ef96f..45cbc2f7a 100644 --- a/docs/source/Math/f_erosion.rst +++ b/docs/source/Math/f_erosion.rst @@ -1,87 +1,327 @@ - Erosion features ================ -A morphological transformation T of an image (point set) A in 2-dimensional Euclidean space :math:`\mathbb{E}^2` by a smaller image (point set of smaller cardinality) B can be formulated in terms of a relation of the point set A with so called structuring element B. -The application of morphological transformation T(A) means that the structuring element moves systematically across the entire set A with respect to a local origin. +Erosion features count how many times a ROI's mask can be eroded with a small cross-shaped +structuring element before nothing of it is left. Thick, compact ROIs survive more erosions than +thin or ragged ones, so the count is a rough measure of the ROI's thickness in pixels. -Define translation :math:`X_h` of point set :math:`X` by a vector :math:`h` as +.. list-table:: + :widths: 26 74 -.. math:: - X_h = \{ p \in \mathbb{E}^2, p=x+h \text{ for some } x \in X \} + * - **Feature codes** + - ``EROSIONS_2_VANISH`` and ``EROSIONS_2_VANISH_COMPLEMENT``, one value per ROI. + ``EROSIONS_2_VANISH_COMPLEMENT`` is never computed and is always 0. + * - **Group** + - ``*ALL_MORPHOLOGY*``; ``*ALL*`` includes it. + * - **Dimensions** + - 2D only. There is no 3D erosion family. + * - **GPU** + - Yes, in GPU builds with ``--useGpu=true``, for ROIs computed in memory with a label image, + but only when a moment feature is in the same run: without one every ROI reports 0 (see + :doc:`/gpu`), a known defect (`#520 `__). The GPU uses the same structuring element, the same pixel + range and the same cap. + * - **Out-of-core** + - Yes, with the same values as in memory on an image that Nyxus reads as one tile. On other + images out-of-core erosion raises an error or returns a wrong value, depending on the ROI; see + :ref:`large-images-tiles`. + * - **IBSI** + - Neither feature has an IBSI identifier; IBSI defines no erosion count. -.. figure:: translation.jpg - :width: 200 - :align: center - :alt: Translation of X by h +.. tip:: - Translation of X by h + A ROI of at least 4 × 4 pixels that fills its bounding box, such as a solid rectangle, reports + 1000, the cap, and so does + any ROI that touches the edges of its box broadly enough. The count is best read as a + comparison between ROIs that do not fill their boxes; see :ref:`erosion-possible-issues`. -The erosion operation combines two point sets by Minkowski set subtraction [1]. -.. math:: - X \ominus B = \{ p \in \mathbb{E}^2: p+b \in X \| b \in B \} +How the features are computed +----------------------------- -that is equivalent to taking the minkowski sum of a shape and the mirror of another shape. +Nyxus copies the ROI's mask into a binary image the size of the ROI's tight bounding box: 1 at +every ROI pixel, 0 elsewhere. It then erodes this image repeatedly and stops as soon as an erosion +leaves no pixel set inside the area it updates. -every point :math:`p` from the image is tested, and the result of the erosion is given by those pixels :math:`p` for which all possible :math:`p + b` are in :math:`X`. Erosion can be expressed as an intersection of all translations of the image -:math:`X` by the vectors :math:`−b \in B` : +- **Which pixels:** the ROI's pixels, as a set. Their intensities are not used, except to skip + ROIs of a single intensity (below). Pixels outside the bounding box are not part of the image: + nothing pads the box with background. +- **Structuring element:** the 3 × 3 cross, the pixel and its 4 neighbours above, below, left and + right. A pixel stays set only if all five pixels under the cross are set (a 4-connected + erosion). +- **Updated area:** with :math:`H` rows and :math:`W` columns in the box, numbered from 0, only + pixels in rows 2 to :math:`H-2` and columns 2 to :math:`W-2` are eroded. The first two rows and + columns and the last row and column of the box keep their initial values in every iteration, + and still count as neighbours of the pixels next to them. This is a known defect (`#501 `__). A box of 3 or fewer pixels in either + direction has no updated pixel at all. +- **Count:** the loop starts at :math:`k = 0`. Each pass erodes the updated area once and counts + the pixels left set in it. If none are left, the loop stops and reports :math:`k`; otherwise + :math:`k` grows by 1. The value is therefore the number of erosions performed before the one that + empties the updated area, one less than the number of erosions performed. +- **Cap:** the loop runs at most 1000 times. A ROI whose updated area never empties reports 1000. +- **ROIs of a single intensity:** a ROI whose pixels all have the same intensity is skipped and + reports 0, whatever its shape, a known defect (`#502 `__). A single-pixel ROI is such a ROI. +- **Units:** a count of erosions, dimensionless. It does not depend on the pixel size, the + intensities or their units, or ``--coarseGrayDepth``. -.. math:: - X \ominus B = \bigcap_{b \in B} X_{−b}. +Outside the frozen border the erosion is the textbook binary erosion with a 4-connected cross. +``scipy.ndimage.binary_erosion`` reproduces the value exactly when it is given the cross as +``structure`` and a ``mask`` that allows only rows and columns 2 to :math:`H-2` and +:math:`W-2` to change, and when the count is taken as above. -Example: given point sets + +Worked example +^^^^^^^^^^^^^^ + +Take an irregular ROI of 46 pixels in an 8 × 9 box, with one hole: .. math:: - X = \{ (1, 0),(1, 1),(1, 2),(0, 3),(1, 3),(2, 3),(3, 3),(1, 4) \} -and + \textbf{M} = \begin{bmatrix} + 0&0&1&1&1&0&0&0&0\\ + 0&1&1&1&1&1&0&0&0\\ + 1&1&1&1&1&1&1&0&0\\ + 1&1&1&1&1&1&1&1&0\\ + 1&1&1&0&1&1&1&1&1\\ + 0&1&1&1&1&1&1&1&1\\ + 0&0&1&1&1&1&1&0&0\\ + 0&0&0&1&1&0&0&0&0 \end{bmatrix} + +The updated area is rows 2 to 6 and columns 2 to 7. The first erosion leaves 17 of its pixels set, +the second 4 and the third none, so the loop stops at :math:`k = 2` and ``EROSIONS_2_VANISH`` is 2. +After the third erosion, 19 ROI pixels are still set, all in the rows and columns that are never +eroded. + +Textbook erosion with the same cross and the box padded with background leaves 23, then 4, then 0 +pixels: 3 erosions to vanish. Here the two differ only by the one-less count. The mirror image of +the ROI, however, reports 3, because its rows and columns that are never eroded hold up an +erosion more (see :ref:`erosion-possible-issues`). + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for +this ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/erosion_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features=EROSIONS_2_VANISH,EROSIONS_2_VANISH_COMPLEMENT \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`H,\; W` + - Number of rows and columns of the ROI's tight bounding box. + * - :math:`M_k(r, c)` + - Binary image of the box after :math:`k` erosions; :math:`M_0` is 1 at the ROI's pixels and + 0 elsewhere. Rows :math:`r` and columns :math:`c` are numbered from 0. + * - :math:`V` + - The updated area, :math:`\{(r, c) : 2 \le r \le H-2,\; 2 \le c \le W-2\}`. + * - :math:`B` + - The cross, :math:`\{(0,0), (-1,0), (1,0), (0,-1), (0,1)\}`. + * - :math:`K` + - The cap, 1000. + + +Settings +-------- + +The erosion features have no settings of their own. The structuring element and the cap of 1000 +are fixed. ``--ibsi``, ``--coarseGrayDepth`` and the intensity map options do not change them. +Anisotropy (``--anisox``, ``--anisoy``; files only) resamples the ROI before it is eroded, so it +changes the value (see :ref:`conventions-units`). ``--useGpu`` +moves the computation to the GPU (see :ref:`erosion-possible-issues`). All options are listed in +the :ref:`cli-reference`. + +In whole-slide mode (``--segDir`` equal to ``--intDir``, or ``featurize_files(..., +single_roi=True)`` in Python) and without anisotropy, the ROI is the whole image, background included, and its bounding +box is one pixel larger than the image, a known defect (`#493 `__; see +:ref:`large-images-whole`). The extra last row and +column are empty, so the image erodes away from the bottom and right, and the value is the +image's smaller side minus 3: 17 for a 20 × 20 image. Images of a single intensity are not +skipped in this mode. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``EROSIONS_2_VANISH`` and +``EROSIONS_2_VANISH_COMPLEMENT``. There are no ``_AVE`` columns. + + +.. _erosion-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. + +.. include:: erosion_index.inc + + +Features +-------- + +.. _feature-erosions-2-vanish: + +Erosions to vanish +^^^^^^^^^^^^^^^^^^ + +:Code: ``EROSIONS_2_VANISH`` +:Range: integers 0 to 1000, dimensionless + +The number of erosions with the 3 × 3 cross that the ROI's mask survives inside its bounding box, +counted before the erosion that leaves no pixel set in the updated area. Larger values mean a +thicker ROI. .. math:: - B = {(0, 0),(1, 0)} -the result of erosion will be + M_{k+1}(p) = \begin{cases} + \min_{b \in B} M_k(p + b) & p \in V\\ + M_k(p) & p \notin V \end{cases} .. math:: - X \ominus B = {(0,3),(1,3),(2,3)} -.. figure:: erosion.jpg - :width: 300 - :align: center - :alt: Erosion of X with structuring element B + \mathrm{EROSIONS\_2\_VANISH} = \min\left(\{\, k \ge 0 : M_{k+1}(p) = 0 \;\; \forall p \in V \,\} + \cup \{K\}\right) + +The value is 0 when the ROI has a single intensity (a known defect, `#502 `__), and when the box is 3 or fewer pixels in +either direction. + +On three shapes, Nyxus and textbook erosion with the same cross and background padding give: + +.. list-table:: + :widths: 46 18 18 18 + :header-rows: 1 - Erosion of X with structuring element B + * - ROI + - Nyxus + - Textbook, erosions to vanish + - Textbook minus 1 + * - Solid 7 × 9 rectangle, filling its box + - 1000 + - 4 + - 3 + * - Disk of radius 6 (13 × 13 box) + - 6 + - 7 + - 6 + * - Irregular ROI of the worked example + - 2 + - 3 + - 2 -Nyxus structuring element -------------------------- +**Relations.** When the frozen rows and columns do not hold up the erosion, the value is the +textbook count of erosions to vanish, minus 1. This holds for disks of radius 3, 6 and 10 (3, 6 +and 10). -By default, Nyxus uses the :math:`3 \times 3` structuring element in the erosion operations. +**Possible issues.** The rows and columns that are never eroded, a known defect (`#501 `__), +change the value for many shapes, +and make it depend on the ROI's orientation; see :ref:`erosion-possible-issues`. -.. figure:: structuring_element_2.jpg - :width: 100 - :align: center - :alt: Default Nyxus structuring element - Default Nyxus structuring element +.. _feature-erosions-2-vanish-complement: -EROSIONS_2_VANISH ------------------ +Erosions to vanish, complement +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Feature EROSIONS_2_VANISH calculates the number of erosion operations on a ROI to vanish in its axis aligned bounding box in a sense that each pixel intensity becomes :math:`=0`. +:Code: ``EROSIONS_2_VANISH_COMPLEMENT`` +:Range: always 0 -.. figure:: erosion2vanish.png - :width: 500 - :align: center - :alt: 5 erosion operations applied to a star-shaped ROI +Not computed. The column exists and is requested with ``*ALL_MORPHOLOGY*``, but no code assigns +it, so it keeps its initial value of 0 for every ROI. Requesting it runs the +``EROSIONS_2_VANISH`` computation and the convex hull, and produces nothing else. - Example: 5 erosion operations applied to a star-shaped ROI -EROSIONS_2_VANISH_COMPLEMENT ----------------------------- +.. _erosion-possible-issues: + +Possible issues +--------------- + +``EROSIONS_2_VANISH_COMPLEMENT`` is always 0 + The feature is listed and written to the output, but never computed. + +The edges of the bounding box are never eroded + Rows 0 and 1, columns 0 and 1, the last row and the last column of the box keep their pixels in + every iteration, and the pixels next to them lean on them as neighbours. This is a known defect + (`#501 `__). A ROI that fills its + box therefore never vanishes and reports the cap: a solid 7 × 9 rectangle reports 1000, where + textbook erosion vanishes after 4 erosions. Removing one corner pixel of the rectangle still + reports 1000. Every filled rectangle of at least 4 × 4 reports 1000. Where the ROI only touches + the edges of its box, the frozen pixels can still delay the erosion: a disk of radius 20 + reports 21, where the textbook count minus 1 is 20. + +The value depends on orientation + The frozen strip is two pixels wide at the top and left of the box and one pixel wide at the + bottom and right, a known defect (`#501 `__). The worked example reports 2; mirrored left-right, mirrored top-bottom or + rotated by 180° it reports 3. A 4 × 4 square with one pixel sticking out at the top and one at + the left reports 1000; rotated by 180°, so that they stick out at the bottom and right, it + reports 2. Textbook erosion vanishes after 3 erosions in both orientations. + +The count is one less than the erosions performed + The value counts the erosions before the one that empties the updated area. Textbook + definitions, and scikit-image loops that erode until nothing is left, count that last erosion + too. A disk of radius 6 reports 6 and vanishes after 7 erosions. + +Small boxes + A box of 3 or fewer pixels in either direction has no pixel to erode, and the value is 0. A + 1 × 1, 2 × 5, 3 × 3 or 3 × 9 ROI reports 0, while textbook erosion needs 1 or 2 erosions; a + filled 4 × 4 square reports 1000. + +ROIs of a single intensity report 0 + The value depends only on the shape, but a ROI whose pixels all have one intensity is skipped + and reports 0, a known defect (`#502 `__). A solid rectangle that reports 1000 with varying intensities reports 0 when all + its pixels are 5, and so does a disk of radius 6. ``Nyxus.featurize`` truncates fractional + values, so an in-memory ROI whose values differ only in their fractions also counts as a ROI of + a single intensity. + +Whole-slide mode + The ROI is the whole image and its bounding box is one pixel larger than the image, with an + empty last row and column, a known defect (`#493 `__). The value is the image's smaller side minus 3, whatever the + intensities: 7 for a 10 × 10 image, 17 for a 20 × 20 or a 20 × 40 image, and 0 for a 3 × 9 + image. Images of a single intensity are not skipped, so the cap and the 0 of constant ROIs do + not apply. + +Constant ROIs on the GPU + The GPU path does not skip ROIs of a single intensity; it erodes them like any other ROI. + This has not been measured on a GPU build. + + +Validation +---------- + +``EROSIONS_2_VANISH`` is regression-only. On the test suite's 26-pixel shape, in a 7 × 6 box, +scikit-image also gives 1 when it erodes with a 3 × 3 square and counts every erosion, but Nyxus +erodes with a cross and counts one fewer, so that agreement confirms neither the structuring +element nor the counting convention. ``EROSIONS_2_VANISH_COMPLEMENT`` is regression-only, and its +regression value is 0. Out-of-core +results are checked against in-memory results of the same build. The Nyxus values quoted on this +page, including the whole-slide values, are pinned by the worked-example script; the textbook +counts and the per-iteration pixel counts quoted beside them are not. -Unlike EROSIONS_2_VANISH which is measured with respect to a ROI's axis aligned bounding box, this feature calculates the number of erosion operations on a ROI to vanish in its convex hull. References ---------- -1. Hadwiger, H. (1957) Vorlesungen uber Inhalt, Oberflache und isoperimetrie. Springer Verlag, Berlin. http://dx.doi.org/10.1007/978-3-642-94702-5 \ No newline at end of file +1. Soille P. *Morphological Image Analysis: Principles and Applications.* 2nd ed. Springer; 2003. + doi:10.1007/978-3-662-05088-0 +2. Virtanen P, et al. *SciPy 1.0: fundamental algorithms for scientific computing in Python.* + Nature Methods. 2020;17:261–272. (``scipy.ndimage.binary_erosion``) +3. van der Walt S, et al. *scikit-image: image processing in Python.* PeerJ. 2014;2:e453. diff --git a/docs/source/Math/f_fractal_dimension.rst b/docs/source/Math/f_fractal_dimension.rst index 058365445..fd9428111 100644 --- a/docs/source/Math/f_fractal_dimension.rst +++ b/docs/source/Math/f_fractal_dimension.rst @@ -1,45 +1,460 @@ - Fractal dimension features ========================== -The complexity or, informally, roughness of a ROI boundary can be described via its fractal dimension. +Fractal dimension features describe how irregular a ROI's shape is. Box counting measures how the +number of boxes needed to cover the ROI grows as the boxes get smaller. The divider method measures +how the length of the ROI's contour grows as the ruler used to walk it gets shorter. Smooth shapes +give values near the shape's topological dimension, 2 for a filled region and 1 for its outline; +rough or ragged shapes give larger values. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``FRACT_DIM_BOXCOUNT`` and ``FRACT_DIM_PERIMETER``, one value per ROI + * - **Group** + - ``*ALL_MORPHOLOGY*`` (and ``*ALL*``) + * - **Dimensions** + - 2D only. There is no 3D fractal dimension. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Partly. Out of core, box counting always uses the unshifted grid, so ROIs whose padded + side :math:`S` is 32 or less can read differently (the worked example gives 1.3966 instead + of 1.3732). The divider walks only the first loop of the contour, so ROIs with holes or + several parts differ (two separate 25 × 25 squares give 1.000 instead of 1.107); with + anisotropy every ROI's divider value can differ. These contour differences are a known + defect (`#509 `__). On an image + that Nyxus reads as several tiles out-of-core processing raises an error or returns wrong + values, depending on the ROI (see :ref:`large-images-tiles` + and :doc:`/large_images`). See :ref:`fractal-possible-issues`. + * - **IBSI** + - Neither feature has an IBSI identifier; IBSI defines no fractal dimension. + +.. tip:: + + Both features depend on the ROI's size as well as its shape. A filled square reads exactly 2 + only for some side lengths, and a 20 × 20 square reads 1.593 (see + :ref:`fractal-possible-issues`). Compare values between ROIs of similar size, and do not read + them as the dimension of an ideal fractal. + + +How the features are computed +----------------------------- + +Both features use only the ROI's mask. The intensities play no part. + +- **Which pixels:** ``FRACT_DIM_BOXCOUNT`` uses every pixel of the ROI, including pixels of + intensity 0 inside the mask. ``FRACT_DIM_PERIMETER`` uses the ROI's contour, the ordered chain of + boundary pixels that the :ref:`contour features ` trace and measure + ``PERIMETER`` on. +- **Values:** pixel positions in pixels. Box sizes are powers of two; ruler lengths are Euclidean + distances between contour pixel centres. +- **Units:** both features are dimensionless. They do not change under an intensity map, so the + image's units (see :ref:`intensity-units`) do not matter. +- **Log base:** both regressions use natural logarithms. The slope does not depend on the base. +- **IBSI mode:** ``--ibsi=true`` does not change either feature. + +**Box counting.** Nyxus takes the larger side :math:`L` of the ROI's bounding box and rounds it up +to a power of two, :math:`S`. The ROI is placed with its bounding-box corner at the origin of an +:math:`S \times S` canvas. For each box size :math:`s = S, S/2, \ldots, 2` it counts the boxes of an +:math:`s \times s` grid that hold at least one ROI pixel. Box size 1 is not used, and the largest +size, which covers the canvas with one box, is. When :math:`S \le 32` the grid is also shifted by +half a box, along :math:`x`, along :math:`y` and along both, and the smallest of the four counts is +kept. When :math:`S > 32` only the unshifted grid is used. The dimension is minus the least-squares +slope of :math:`\ln N(s)` against :math:`\ln s`. + +**Divider walk.** For a contour of :math:`n` pixels, Nyxus walks the contour with strides of +:math:`d = \lfloor n/4 \rfloor, \lfloor d/2 \rfloor, \ldots, 1` pixels. Each walk starts at the +first contour pixel and jumps :math:`d` pixels at a time while a full stride remains, adding the +straight-line length of each jump. It then closes the loop with one chord from the last pixel +reached back to the first pixel; this chord spans the remaining :math:`n - qd` pixels, which can be +fewer than :math:`d`. The walk's length :math:`P(d)` and its mean chord, the ruler +:math:`r(d) = P(d)/m(d)` over its :math:`m(d)` chords, give one point. The dimension is 1 minus the +least-squares slope of :math:`\ln P` against :math:`\ln r`. At stride 1 the walk is the contour's +``PERIMETER``. -Suppose :math:`A` is a shape's area and :math:`P` is its perimeter, and we are determining :math:`D`. +A ROI with holes or separate parts has several contours. Nyxus joins them, in the order it found +them, into one chain and walks that chain as a single closed contour (see +:ref:`fractal-possible-issues`). The contour coordinates are offset by one pixel in both axes, a known defect +(`#497 `__); a translation changes no distance, so neither feature depends on it. -FRACT_DIM_BOXCOUNT ------------------- -The Minkowski–Bouligand box counting method consists in the overlaying a set of boxes of known edge on top of -the ROI to entirely cover it. The size of the covering box set obviously depends on the box edge length, so -consecutive covering a ROI with increasingly large boxes can be organized as an iterative procedure. On each -iteration, the number of cells needed to cover the ROI shape is plotted versus the iteration-specific box edge -size which is usually varied as an exponent 2 progression i.e. :math:`1 \times 1`, :math:`2 \times 2`, :math:`4 \times 4`, etc. -The number :math:`N` of boxes of size :math:`r` needed to -cover a ROI follows a power law: +Worked example +^^^^^^^^^^^^^^ + +Take this 10 × 9 ROI, with 1 marking the mask: .. math:: - N(r) = N_0 r^{−D} + \textbf{M} = \begin{bmatrix} + 0 & 0 & 1 & 1 & 0 & 0 & 0 & 0 & 0 & 0\\ + 0 & 1 & 1 & 1 & 1 & 0 & 0 & 1 & 1 & 0\\ + 1 & 1 & 1 & 1 & 1 & 1 & 1 & 1 & 1 & 0\\ + 1 & 1 & 1 & 1 & 1 & 1 & 1 & 1 & 0 & 0\\ + 0 & 1 & 1 & 1 & 1 & 1 & 1 & 0 & 0 & 0\\ + 0 & 1 & 1 & 1 & 1 & 1 & 1 & 1 & 0 & 0\\ + 0 & 0 & 1 & 1 & 1 & 1 & 1 & 1 & 1 & 0\\ + 0 & 0 & 1 & 1 & 0 & 0 & 1 & 1 & 1 & 1\\ + 0 & 0 & 0 & 0 & 0 & 0 & 0 & 1 & 1 & 0 \end{bmatrix} + +**Box counting.** The larger side is :math:`L = 10`, so :math:`S = 16` and the box sizes are 16, 8, +4 and 2. Because :math:`S \le 32`, each size is counted on four grids. A shift of :math:`o_x` adds +:math:`o_x` to every column index before it is divided by :math:`s`. + +.. list-table:: + :widths: 12 16 16 16 16 12 + :header-rows: 1 + + * - :math:`s` + - :math:`(0, 0)` + - :math:`(s/2, 0)` + - :math:`(0, s/2)` + - :math:`(s/2, s/2)` + - :math:`N(s)` + * - 16 + - 1 + - 2 + - 2 + - 4 + - 1 + * - 8 + - 4 + - 3 + - 4 + - 4 + - 3 + * - 4 + - 8 + - 7 + - 9 + - 8 + - 7 + * - 2 + - 20 + - 18 + - 18 + - 21 + - 18 + +The least-squares slope of :math:`\ln(1, 3, 7, 18)` against :math:`\ln(16, 8, 4, 2)` is +:math:`-1.3732`, so ``FRACT_DIM_BOXCOUNT`` is 1.3732. The unshifted grid alone, with counts 1, 4, 8 +and 20, would give 1.3966. + +**Divider walk.** The contour has :math:`n = 26` pixels, so the strides are 6, 3 and 1. + +.. list-table:: + :widths: 14 18 34 34 + :header-rows: 1 + + * - :math:`d` + - :math:`m(d)` + - :math:`P(d)` + - :math:`r(d)` + * - 6 + - 5 + - 26.8249 + - 5.3650 + * - 3 + - 9 + - 29.4352 + - 3.2706 + * - 1 + - 26 + - 32.6274 + - 1.2549 + +With stride 6 the walk visits pixels 0, 6, 12, 18 and 24, and the closing chord runs from pixel 24 +back to pixel 0 across two contour steps. The least-squares slope of :math:`\ln P` against +:math:`\ln r` is :math:`-0.1311`, so ``FRACT_DIM_PERIMETER`` is 1.1311. :math:`P(1) = 32.6274` is +the ROI's ``PERIMETER``. + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for +this ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: -where :math:`N_0` is a constant and :math:`D` is the dimension of the covering space e.g. 1, 2, 3, etc. + .. tab-item:: Python -The regression slope :math:`D` of the straight line + .. literalinclude:: ../../examples/fractal_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features=FRACT_DIM_BOXCOUNT,FRACT_DIM_PERIMETER \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`(x_k, y_k)` + - Column and row of the :math:`k`-th ROI pixel. + * - :math:`x_{\min},\; y_{\min}` + - Smallest column and row of the ROI, the corner of its bounding box. + * - :math:`L` + - Larger side of the bounding box, in pixels. + * - :math:`S` + - :math:`L` rounded up to a power of two; 1 when :math:`L = 1`. + * - :math:`s` + - Box size, one of :math:`S, S/2, \ldots, 2`. + * - :math:`N(s)` + - Number of boxes of size :math:`s` that hold at least one ROI pixel. + * - :math:`K_0, \ldots, K_{n-1}` + - Contour pixels in walking order; :math:`n` is their number. + * - :math:`d` + - Stride of the divider walk, in contour pixels. + * - :math:`q` + - Number of full strides in a walk, :math:`q = \lceil n/d \rceil - 1`. + * - :math:`P(d),\; m(d),\; r(d)` + - Length of the walk with stride :math:`d`, its number of chords :math:`m(d) = q + 1`, and its + mean chord :math:`r(d) = P(d)/m(d)`. + * - :math:`\beta\{(a_j, b_j)\}` + - Least-squares slope of :math:`\ln b_j` against :math:`\ln a_j`, defined below. + +The slope over :math:`M` points with :math:`u_j = \ln a_j` and :math:`v_j = \ln b_j` is .. math:: - \log N(r) = −D \log r + \log N_0 + \beta = \frac{M \sum_j u_j v_j - \sum_j u_j \sum_j v_j}{M \sum_j u_j^2 - \left(\sum_j u_j\right)^2} + +Points with :math:`a_j \le 0` or :math:`b_j \le 0` are skipped. :math:`\beta = 0` when fewer than +two points remain or the denominator is 0. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Whole-slide mode + - ``--segDir`` equal to ``--intDir`` + - ``featurize_files(..., single_roi=True)`` + - The ROI is the whole image, but its bounding box is one pixel larger than the image in + each direction, a known defect (`#493 `__), and the canvas side :math:`S` is taken from that box. ``FRACT_DIM_BOXCOUNT`` + is that of a filled rectangle of the image's size on that canvas: 1.8703 for 200 × 200, + and 1.8667 for 256 × 256, where a 256 × 256 mask of ones reads 2. + ``FRACT_DIM_PERIMETER`` is exactly 1, because the contour is the image's four corners. + * - Memory limit + - ``--ramLimit`` + - ``Nyxus(..., ram_limit=...)``, with ``featurize_files`` or ``featurize_directory`` + - ROIs whose estimated footprint reaches the limit are processed out of core. Their box count + always uses the unshifted grid, and their divider walk covers only the first loop of the + contour. Arrays passed to ``Nyxus.featurize`` are never processed out of core: an oversized + ROI raises ``RuntimeError``. + * - Anisotropy + - ``--anisox``, ``--anisoy`` + - ``Nyxus(..., anisotropy_x=..., anisotropy_y=...)``, with ``featurize_files`` or + ``featurize_directory``; arrays passed to ``Nyxus.featurize`` are not rescaled + - The image is resampled before the ROIs are measured, which changes both features. The + worked example gives 1.3732 and 1.1311 without anisotropy, 1.2459 and 1.1731 with + ``anisotropy_x=2``, and 1.4458 and 1.2546 with ``anisotropy_y=3``. + * - Merged ROIs + - ``--mergerois=true`` + - ``Nyxus(..., mergerois=True)`` + - All labels of a mask form one ROI. Two 25 × 25 squares with labels 1 and 2 give 1.8416 and + 1.000 each; merged, they give 1.4231 and 1.107. + +No other setting changes these features: not ``--coarseGrayDepth``, not ``--ibsi`` and not the +image intensity map. All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``FRACT_DIM_BOXCOUNT`` and +``FRACT_DIM_PERIMETER``. There are no ``_AVE`` columns. + + +.. _fractal-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. -formed by plotting :math:`\log(N(r))` against :math:`log(r)` indicates the degree of complexity, or fractal dimension, of the ROI. The feature is calculated as FRACT_DIM_BOXCOUNT :math:`=D`. +.. include:: fractal_index.inc -FRACT_DIM_PERIMETER -------------------- -In Euclidean geometry, the perimeter :math:`P` is related to the diameter :math:`d` or the area :math:`S` as: +Features +-------- + +.. _feature-fract-dim-boxcount: + +Box-counting dimension +^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``FRACT_DIM_BOXCOUNT`` +:Range: from 0 to about 2, dimensionless + +How fast the number of covering boxes grows as the boxes shrink. Values near 2 mean the ROI fills +its area; lower values mean a thin, sparse or ragged ROI. + +.. math:: + + N(s) = \min_{o_x,\, o_y \in O(s)} \left|\left\{ \left( + \left\lfloor \tfrac{x_k - x_{\min} + o_x}{s} \right\rfloor, + \left\lfloor \tfrac{y_k - y_{\min} + o_y}{s} \right\rfloor \right) \right\}\right|, + \qquad + O(s) = \begin{cases} \{0,\; s/2\} & S \le 32 \\ \{0\} & S > 32 \end{cases} + +.. math:: + + \mathrm{FRACT\_DIM\_BOXCOUNT} = -\beta\left\{ \left(s, N(s)\right) : + s = S, S/2, \ldots, 2 \right\} + +The value is 0 for a ROI of fewer than 2 pixels, and for a ROI whose bounding box has no side +longer than 2 pixels, because only one box size remains. + +**Relations.** A ROI that fills a square bounding box of side :math:`2^k` or :math:`2^k - 1` +reads 2, and a one-pixel-wide straight line of length :math:`2^k` or :math:`2^k - 1` reads 1, both +up to rounding of order :math:`10^{-15}`. Other sizes read lower. + +**Possible issues.** The value depends on the ROI's size relative to the power of two above it, as +shown under :ref:`fractal-possible-issues`. + + +.. _feature-fract-dim-perimeter: + +Perimeter (divider) dimension +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``FRACT_DIM_PERIMETER`` +:Range: about :math:`[1, 2)` in practice, dimensionless; smooth outlines can read slightly below 1 + (a 6 × 6 square gives 0.9947); 0 for a contour of fewer than 3 pixels + +How fast the measured length of the contour grows as the ruler shrinks, after Richardson. Values +near 1 mean a smooth outline; larger values mean a rough or wiggly one. + +.. math:: + + P(d) = \sum_{j=0}^{q-1} \left\| K_{(j+1)d} - K_{jd} \right\| + \left\| K_0 - K_{qd} \right\|, + \qquad + r(d) = \frac{P(d)}{q + 1} .. math:: - P \propto d^D \propto S^{D/2} + \mathrm{FRACT\_DIM\_PERIMETER} = 1 - \beta\left\{ \left(r(d), P(d)\right) : + d = \lfloor n/4 \rfloor, \lfloor d/2 \rfloor, \ldots, 1 \right\} + +The value is 0 when the contour has fewer than 3 pixels. A contour of 3 to 7 pixels gives at most one +point, so :math:`\beta = 0` and the value is exactly 1. + +**Relations.** In memory, :math:`P(1)` is ``PERIMETER``; out of core, ``PERIMETER`` is the number +of contour pixels instead (26 on the worked example). The fit runs against the mean chord +:math:`r(d)`, not against the stride :math:`d`. + +**Possible issues.** For a ROI with several contours the walk includes the jumps between them (see +:ref:`fractal-possible-issues`). + + +.. _fractal-possible-issues: + +Possible issues +--------------- + +Box counting depends on the ROI's size + :math:`N(s)` counts whole boxes, so a ROI whose side does not fill the boxes evenly is + over-covered at the coarse sizes, and the box that covers the whole canvas always counts 1. A + filled square reads 2 at sides 16, 31 and 32, but 1.593 at side 20, 1.582 at side 33 and 1.851 + at side 100. For the 20 × 20 square the counts are 1, 4, 9, 25 and 100 at sizes 32 to 2. Disks + read 1.430 at radius 8 and 1.686 at radius 40. A one-pixel-wide line reads 1 at lengths 16 and + 32 but 0.755 at length 10 and 0.926 at length 100, below the dimension of a line. + +One-pixel-wide parts in the divider walk + The contour of a one-pixel-wide line visits each pixel once, from one end to the other, and the + closing chord jumps back across the whole length. That long chord enters every ruler + :math:`r(d)`, and a straight line reads above 1: 1.190 at length 16, 1.205 at length 10. Thin + branches of larger ROIs are affected in the same way. + +Shifted grids only for small ROIs + Grid shifts are tried only when the padded side :math:`S` is 32 or less. Larger ROIs, and every + out-of-core ROI, use the unshifted grid anchored at the bounding-box corner. The shifts matter + for irregular shapes: the worked example reads 1.3732 with them and 1.3966 on the unshifted + grid alone. On a filled square they change nothing; the drop from 2 for a 32 × 32 square to + 1.582 for a 33 × 33 square comes from :math:`S` doubling from 32 to 64, which leaves the coarse + boxes mostly empty. + +Out-of-core ROIs + An out-of-core ROI uses the unshifted grid whatever its size, so a ROI with :math:`S \le 32` + can read a different ``FRACT_DIM_BOXCOUNT``: the worked example gives 1.3966 out of core and + 1.3732 in memory. The out-of-core contour holds only the first loop of the ROI's boundary, so + the divider walk ignores holes and separate parts: two separate 25 × 25 squares in one ROI give + 1.000 out of core and 1.107 in memory. Without anisotropy, a ROI with a single contour gives the + same ``FRACT_DIM_PERIMETER`` on both paths. With anisotropy the out-of-core contour is wrong, + so single-contour ROIs differ too: a disk of radius 8 with ``anisotropy_x=2`` gives 1.1431 in + memory and 1.0482 out of core. The out-of-core contour is a known defect (`#509 `__). Images that Nyxus reads as several tiles have further + out-of-core differences (see :ref:`large-images-tiles`). + +Several contours are walked as one + A ROI with holes, or with parts that do not touch, has several contours. The divider walk joins + them into one chain, so the chords between the end of one contour and the start of the next are + part of the walk. Two separate 25 × 25 squares in one ROI give 1.107; one such square gives + 1.000. + +Small ROIs + ``FRACT_DIM_BOXCOUNT`` is 0 for fewer than 2 pixels and for bounding boxes no larger than + 2 × 2. ``FRACT_DIM_PERIMETER`` is 0 for contours of fewer than 3 pixels and exactly 1 for + contours of 3 to 7 pixels. A 3 × 3 square gives 2 and 1. + +Largest 32-bit intensity + This is a known defect (`#515 `__). In memory, on ROIs whose padded side :math:`S` exceeds 32, box + counting marks each ROI pixel with its intensity plus 1, converted to a 32-bit unsigned + integer. For a pixel of intensity 4294967295 that conversion is out of range, so whether the + pixel is counted depends on the compiler. Out of core the pixel is counted: a 40 × 40 square + with one separate pixel reads 1.4156 at any intensity. + + +Validation +---------- + +The test suite checks both features against ImageJ/FracLac on an irregular ROI in a 512 × 512 +image: + +- ``FRACT_DIM_BOXCOUNT`` against FracLac's box count of the same ROI, 1.8706, to a relative + 10\ :sup:`-2`. Nyxus gives 1.8608. +- ``FRACT_DIM_PERIMETER`` against FracLac's box count of the ROI's edge, 1.0493, to a relative + 3 × 10\ :sup:`-2`. This compares two methods for the same boundary dimension. Nyxus gives 1.0572. + +The tests also check shapes with a known dimension: box counting gives 2 for a 256 × 256 square, 1 +for a straight line and :math:`\log_2 3 = 1.585` for a Sierpinski triangle, to an absolute 0.1; the +divider gives 1 for a disk and :math:`\log 4 / \log 3 = 1.262` for a Koch snowflake, to an absolute +0.05. The feature values quoted above Validation, and the box counts of the worked example and of +the 20 × 20 square, are asserted by the worked-example script; the divider-walk table of the worked +example is not. None is compared with a reference tool. + +A standard box-counting implementation, with the grid anchored at the image corner and box sizes +from 1 to the largest power of two that fits in the image, gives different values: 1.251 on the +worked example and 1.676 on the 20 × 20 square. It uses box size 1, does not try shifted grids and +does not use box sizes larger than the image. Neither result is the exact dimension of a finite +pixel set. + + +References +---------- -The area of the ROI can be expressed as a set of equivalent circles of diameter :math:`d` and consecutive approximations of -ROI's :math:`S` with a series of :math:`d`. Similar to the boxcount method, by log-log plotting the approximation perimeters versus :math:`d`, -the fractal dimension FRACT_DIM_PERIMETER :math:`=D` is defined as the slope of a least squares fitted line. +1. Mandelbrot B. *How long is the coast of Britain? Statistical self-similarity and fractional + dimension.* Science. 1967;156(3775):636–638. doi:10.1126/science.156.3775.636 +2. Richardson LF. *The problem of contiguity: an appendix to Statistics of Deadly Quarrels.* + General Systems Yearbook. 1961;6:139–187. +3. ISO 9276-6:2008. *Representation of results of particle size analysis — Part 6: Descriptive + and quantitative representation of particle shape and morphology.* +4. Karperien A. *FracLac for ImageJ.* https://imagej.net/ij/plugins/fraclac/FLHelp/Introduction.htm diff --git a/docs/source/Math/f_gabor.rst b/docs/source/Math/f_gabor.rst index 53820d630..403376e60 100644 --- a/docs/source/Math/f_gabor.rst +++ b/docs/source/Math/f_gabor.rst @@ -1,143 +1,444 @@ Gabor features -======================= +============== -The idea behind Gabor feature calculation is filtering an image with an oriented texture element and treating a statistics -calculated over pixels of the filter response image as a measure of the texture element's abundance in the image. A set of :math:`k` -texture elements with unique calibrated direction and spatial frequency, called Gabor filter bank, is used to calculate a :math:`k` -dimensional feature vector -serving as a discriminating descriptor of the whole image. -To dampen the bias of varying intensity range in an image, its feature vector is normalized with respect to a feature vector -of an image produced with a equalizing filter. +The Gabor feature filters a ROI with a bank of oriented Gabor filters, each tuned to one frequency +and one orientation. For each filter it counts the pixels whose response is strong compared with a +smooth, low-pass baseline, and divides by the number of pixels the baseline itself picks out. The +result is one score per filter, which describes how much of the ROI responds to that frequency and +orientation. -Let :math:`I(x,y)` be a real valued 2D image intensity function at Cartesian location :math:`(x,y)` . -The oriented texture element is defined as a kernel of Gabor filter at location :math:`(x,y)`. -A Gabor kernel defined on a 2D domain of size :math:`n \times n` is a product of Gaussian factor :math:`g` -and harmonic factor :math:`f` : +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``GABOR``: one feature with one column per filter, ``GABOR_0`` to ``GABOR_N-1`` + * - **Group** + - No group of its own; request it by its code, ``GABOR``. ``*ALL*`` and ``*WHOLESLIDE*`` + include it. ``*ALL_BUT_GABOR*`` is listed as a group name but is rejected when used (see + :doc:`/featuregroups`). + * - **Dimensions** + - 2D only. There is no 3D Gabor feature, and in 3D the Gabor options are rejected. + * - **GPU** + - Command line only. In a GPU build run with ``--useGpu=true``, the filters of the ROIs + processed in memory are applied on the GPU; out-of-core ROIs and whole slides use the CPU. + The Python package never builds the GPU filter bank, so do not use the GPU for ``GABOR`` + from Python. The GPU values are not checked against the CPU values (see + :ref:`gabor-possible-issues`). + * - **Out-of-core** + - Yes, with the same values as in memory on an image that Nyxus reads as one tile. On other + images out-of-core processing of this family raises an error or returns wrong values, + depending on the ROI (see :ref:`large-images-tiles`). + * - **IBSI** + - No identifier. IBSI defines no Gabor feature; its image-feature chapter names Gabor filters + only as an image transformation. + +.. tip:: + + The Python package and the command line start from different filter pairs. To get the same + columns from both, give the frequencies and orientations explicitly, for example + ``--gaborfreqs=4,16,32,64 --gabortheta=0,45,90,135`` on the command line, which is the Python + default (see :ref:`gabor-settings`). + + +How the features are computed +----------------------------- + +Nyxus takes the ROI's bounding box as an image :math:`I`, of :math:`H` rows and :math:`W` columns. +ROI pixels keep their intensity; bounding-box pixels outside the mask are 0. It builds one complex +Gabor kernel per configured pair of frequency :math:`f_k` and orientation :math:`\theta_k`, plus a +low-pass baseline kernel, convolves :math:`I` with each, and takes the magnitude of the result. +Each column is a ratio of two pixel counts made on these response images. + +- **Which pixels:** every pixel of the bounding box, inside the mask or not, is filtered and + counted. Outside the bounding box the image is taken as 0. +- **Filters:** the pairs are used one to one: column ``GABOR_k`` uses frequency :math:`f_k` with + orientation :math:`\theta_k`. The frequency and orientation lists are not combined into a grid, + and they must have the same length. +- **Baseline:** one more kernel, at frequency :math:`f_{LP}` (``--gaborf0``, 0.1) and a fixed + orientation of :math:`\pi/2`, gives the baseline response :math:`R_0`. Its maximum sets the scale + of the threshold, and its pixel count is the denominator. +- **Values:** a column is the number of bounding-box pixels whose response exceeds + :math:`T` times the baseline maximum, divided by the number of bounding-box pixels whose baseline + response exceeds the baseline minimum. It is a ratio of counts, so it has no units, and it can + exceed 1. +- **Intensity domain:** the filters run on the grey levels Nyxus stores, without binning; see + :ref:`intensity-units`. Multiplying every intensity by a constant leaves the values unchanged, + but adding a constant changes them, because the pixels outside the mask and outside the bounding + box stay 0. +- **IBSI mode:** ``--ibsi=true`` has no effect on ``GABOR``. + + +Kernels +^^^^^^^ + +For a filter of frequency :math:`f` and orientation :math:`\theta`, the wavelength is +:math:`\lambda = 2\pi / f` pixels and the width of the Gaussian envelope is +:math:`\sigma = \textrm{sig2lam} \cdot \lambda`. The kernel has :math:`n \times n` taps, at column +offsets :math:`u` and row offsets :math:`v` from :math:`-\lfloor n/2 \rfloor` to +:math:`n - 1 - \lfloor n/2 \rfloor`. Rows run downward. Each tap is .. math:: - h(x, y, \lambda, \theta, \psi, \sigma, \gamma) = g(x',y',\gamma,\sigma) \ f(x',\lambda,\psi) \\ - = exp \Big(-\frac {x'^2+\gamma^2 y'^2} {2\sigma^2} \Big) \ exp \Big[ i \Big( \frac {2 \pi x'}{\lambda} + \psi \Big) \Big] - -where :math:`x,y \in \overline{1,n}`, :math:`x' = x \ \cos \ \theta + y \ \sin \ \theta` and :math:`y' = -x \ sin \ \theta + y \ cos \ \theta` . -:math:`\sigma` is standard deviation of the Gaussian factor :math:`g` playing the role of bandwidth controlling the overall size of the Gabor envelope. -Larger bandwidth values increase the envelope allowing more periodic ridges while smaller values result in fewer ridges. + x' = u\cos\theta + v\sin\theta, \qquad y' = v\cos\theta - u\sin\theta -:math:`\theta` is the angle of orientation of the normal to the major axis of the Gabor envelope. -:math:`\theta=0` corresponds to the vertical position of ridges of the harmonic factor. +.. math:: -:math:`\gamma` is the aspect ratio of the Gaussian factor :math:`g`. + g(u, v) = \exp\!\left(-\frac{x'^2 + \gamma^2 y'^2}{2\sigma^2}\right) e^{\,i f x'}, + \qquad h(u, v) = \frac{g(u, v)}{\sum_{u,v} |g(u, v)|} -The effect of varying :math:`\gamma` at :math:`\theta=\frac{3}{4}\pi` is shown below: +The carrier :math:`e^{ifx'}` oscillates along :math:`x'`, so :math:`\theta = 0` responds to +intensity that changes along a row, as in vertical stripes, and :math:`\theta = 90°` to horizontal +stripes. The envelope has width :math:`\sigma` across the stripes and :math:`\sigma/\gamma` along +them; with the default :math:`\gamma = 0.1` it is ten times longer along the stripes. The phase +offset is always 0. The kernel is normalised so that the magnitudes of its taps sum to 1. The +response magnitude does not change when :math:`\theta` changes by 180°. .. figure:: gabor_gamma.png - :width: 800 - :align: center - :alt: Gabor kernels at different gamma -| -Parameters :math:`\lambda` and :math:`\psi` are the wavelength and phase shift of complex harmonic factor + :width: 800 + :align: center + :alt: Real part of the Nyxus Gabor kernel for three values of gamma -.. math:: - f = exp \Big[ i(\frac {2 \pi x'}{\lambda} + \psi) \Big] + Real part of the kernel above, computed as Nyxus computes it, at :math:`\theta = 3\pi/4` and + :math:`f = \pi/4` with ``sig2lam`` 0.8 on 64 × 64 taps, for three values of :math:`\gamma`; + rows run downward. Smaller :math:`\gamma` stretches the envelope along the stripes. The + defaults are 16 × 16 taps and :math:`\gamma = 0.1`. -or equivalently +A frequency of 0 gives an infinite wavelength: the envelope and the carrier are both 1, and the +kernel is a flat :math:`n \times n` window, each tap :math:`1/n^2`. -.. math:: - Re(f) = cos \Big[ 2 \pi (\frac {2 \pi x'}{\lambda} + \psi) \Big] \\ - Im(f) = sin \Big[ 2 \pi (\frac {2 \pi x'}{\lambda} + \psi) \Big] -In Nyxus implementation of the Gabor feature, parameters :math:`\lambda` and :math:`\sigma` are coupled -in the form of parameter "sig2lam" playing the role of spatial bandwidth: +Response and score +^^^^^^^^^^^^^^^^^^ + +The response of filter :math:`k` is the magnitude of the full 2D convolution of :math:`I` with +:math:`h_k`, read at an offset of :math:`\lceil n/2 \rceil`: .. math:: - \textrm{sig2lam} = \frac {\sigma} {\lambda} -Tuning Gabor feature parameters is advised to start with tuning :math:`\frac {\sigma} {\lambda}`. -There are multiple approaches to the tuning, for example, one based on maximizing the signal to noise ratio -of the filter response image :math:`R(x,y)` : + R_k(y, x) = \left| \sum_{u,v} I(y + s - v,\; x + s - u)\, h_k(u, v) \right|, + \qquad s = \lceil n/2 \rceil - \lfloor n/2 \rfloor + +for :math:`0 \le y < H`, :math:`0 \le x < W`, with :math:`I = 0` outside the bounding box. For an +even kernel size :math:`s = 0` and the response is centred on each pixel. For an odd size +:math:`s = 1` and the response image is shifted by one pixel down and right, a known defect +(`#505 `__; see :ref:`gabor-possible-issues`). + +With :math:`R_0` the baseline response, :math:`M = \max R_0` and :math:`m = \min R_0` over the +bounding box, .. math:: - \textrm{SNR} = - \Big( \frac {\mu_R} {\sigma_R} \Big) ^2 -where :math:`\mu_R` and :math:`\sigma_R` is the mean intensity and standard deviation of :math:`R`, respectively . + \mathrm{GABOR\_}k = \frac{\#\{(y, x) : R_k(y, x) / M > T\}}{\#\{(y, x) : R_0(y, x) > m\}} -The filter response image :math:`R(x,y)` is calculated by convolving the original image :math:`I(x,y)` -with Gabor filter :math:`g(x, y, \lambda, \theta, \psi, \sigma, \gamma)` in the discrete domain: +Two cases skip the formula: + +- When every ROI pixel has the same intensity, every column is 0. +- When the baseline response is the same at every pixel (:math:`M = m`), every column is the + ``--noval`` substitute, 0 by default. -.. math:: - R (x, y, \lambda, \theta, \psi, \sigma, \gamma) = \sum_{x'} \ \sum_{y'} \ I(x-x', y-y') \ h(x', y', \lambda, \theta, \psi, \sigma, \gamma) -Define a set of values :math:`C = \{ \lambda, \theta, \psi, \sigma, \gamma \}` a filter configuration :math:`C`, and a set of -configurations :math:`B = \{ C_1, C_2, ... C_k \}` a filter bank :math:`B`. Nyxus implementation of Gabor filter banks requires that -some parameters be shared by the filter bank per Gabor feature calculation: :math:`\psi=0` and -:math:`\sigma=const, \gamma=const` while the other parameters can be user-specified as arrays of values. An example: +Worked example +^^^^^^^^^^^^^^ + +Take the 5 × 5 image of the texture pages and leave its four corners out of the mask, so the +bounding box holds 21 ROI pixels and 4 pixels of value 0: .. math:: - C = \{ {\lambda_1, \theta_1, \psi, \sigma, \gamma}, \\ - {\lambda_2, \theta_1, \psi, \sigma, \gamma}, \\ - {\lambda_3, \theta_1, \psi, \sigma, \gamma}, \\ - {\lambda_4, \theta_1, \psi, \sigma, \gamma}, \\ - {\lambda_1, \theta_2, \psi, \sigma, \gamma}, \\ - {\lambda_2, \theta_2, \psi, \sigma, \gamma}, \\ - {\lambda_3, \theta_2, \psi, \sigma, \gamma}, \\ - {\lambda_4, \theta_2, \psi, \sigma, \gamma} \} -Values of :math:`k` -dimensional feature vector in Nyxus are calculated as scored ratios + \textbf{I} = \begin{bmatrix} + 0 & 2 & 5 & 4 & 0\\ + 3 & 3 & 3 & 1 & 3\\ + 2 & 1 & 1 & 1 & 3\\ + 4 & 2 & 2 & 2 & 3\\ + 0 & 5 & 3 & 3 & 0 \end{bmatrix} + +The settings are the Python defaults: :math:`n = 16`, :math:`\gamma = 0.1`, sig2lam 0.8, +:math:`f_{LP} = 0.1`, :math:`T = 0.025`, and the pairs :math:`(4, 0°)`, :math:`(16, 45°)`, +:math:`(32, 90°)` and :math:`(64, 135°)`. + +The baseline wavelength is :math:`2\pi/0.1 = 62.8` pixels, so the 16 × 16 baseline kernel is close +to a flat window of weight :math:`1/256`. Every one of its windows covers the whole ROI, and the +baseline response is close to the ROI's sum over 256, :math:`56/256 = 0.21875`, everywhere: +:math:`R_0` runs from :math:`m = 0.21720` to :math:`M = 0.21737`. Only the bottom-right pixel takes +the minimum, so the denominator is 24. The threshold is +:math:`T \cdot M = 0.025 \times 0.21737 = 0.005434`. + +The four high-pass responses are far larger. In the first three filters all 25 pixels exceed the +threshold, which gives :math:`25/24 = 1.0417`. The fourth kernel, at :math:`f = 64` and 135°, is +almost a line of five taps along the diagonal from top left to bottom right. For the top-right and +bottom-left corners that diagonal holds only the corner itself, which is 0, so their responses are +about :math:`10^{-17}` and they are not counted: :math:`23/24 = 0.9583`. .. math:: - r_i = \frac {R_i} {R_0} -of the ROI image :math:`I(x,y)` -convolved with -Gabor high-pass filter kernels at various wavelengths and orientations to :math:`I(x,y)` convolved with -a low-pass Gabor kernel of wavelength :math:`f_0` . + \mathrm{GABOR\_0} = \mathrm{GABOR\_1} = \mathrm{GABOR\_2} = \frac{25}{24} = 1.0417, + \qquad \mathrm{GABOR\_3} = \frac{23}{24} = 0.9583 +The *Example* value in the :ref:`index ` is ``GABOR_0``. The test suite runs this +example and checks all four columns. -The score :math:`s` is calculated as the number of pixels of the convolved image greater than -some threshold :math:`r_0`: +.. tab-set:: -.. math:: - s_i = \sum_{x} \ \sum_{y} r_i(x,y) : r_i(x,y)>r_0 + .. tab-item:: Python + .. literalinclude:: ../../examples/gabor_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end -Nyxus calculates the baseline filter response :math:`h_0` as + .. tab-item:: Command line -.. math:: - h_0 = g(x',y',\gamma,\sigma) f(x',\lambda_0,\psi) + .. code-block:: console -where + $ nyxus --features=GABOR --gaborfreqs=4,16,32,64 --gabortheta=0,45,90,135 \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv -.. math:: - \lambda_0 = \pi / f_0 - -with respect to customizable parameter "f0". -The following table presents customizable parameter of the Gabor feature and their default values: +Notation +-------- .. list-table:: - :widths: 20 20 20 + :widths: 25 75 :header-rows: 1 - * - Parameter - - Nyxus CLI option - - Default value - * - :math:`\lambda` - - --gaborfreqs - - {1, 2, 4, 8, 16, 32, 64} + * - Symbol + - Meaning + * - :math:`I(y, x)` + - The ROI's bounding-box image: the intensity of ROI pixels, 0 elsewhere. :math:`H` rows, + :math:`W` columns. + * - :math:`f_k,\; \theta_k` + - Frequency, in radians per pixel, and orientation, in radians, of filter :math:`k`. The + orientation is given in degrees and converted. + * - :math:`\lambda,\; \sigma` + - Wavelength :math:`2\pi/f` and envelope width :math:`\textrm{sig2lam} \cdot \lambda`, both in + pixels. * - :math:`\gamma` - - --gaborgamma - - 0.1 - * - :math:`\sigma / \lambda` - - --gaborsig2lam - - 0.8 + - Aspect ratio of the envelope (``--gaborgamma``). * - :math:`n` - - --gaborkersize - - 16 - * - :math:`f_0` - - --gaborf0 - - 0.1 - * - :math:`\theta` - - --gabortheta - - 45 (degrees) - * - :math:`r_0` - - --gaborthold - - 0.025 + - Kernel size in pixels (``--gaborkersize``). + * - :math:`u,\; v` + - Column and row offset of a kernel tap. + * - :math:`h_k` + - Kernel of filter :math:`k`, normalised so that its tap magnitudes sum to 1. + * - :math:`R_k,\; R_0` + - Response magnitude of filter :math:`k`, and of the baseline filter. + * - :math:`f_{LP}` + - Frequency of the baseline filter (``--gaborf0``); its orientation is :math:`\pi/2`. + * - :math:`M,\; m` + - Largest and smallest baseline response over the bounding box. + * - :math:`T` + - Threshold on :math:`R_k / M` (``--gaborthold``). + +Counts :math:`\#\{\cdot\}` run over the :math:`H \times W` pixels of the bounding box. + + +.. _gabor-settings: + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Frequencies + - ``--gaborfreqs`` + - ``Nyxus(..., gabor_freqs=[4, 16, 32, 64])`` + - :math:`f_k` in radians per pixel; the wavelength is :math:`2\pi/f_k` pixels. One column per + value. Any real number is accepted, 0 included. + * - Orientations + - ``--gabortheta`` + - ``Nyxus(..., gabor_thetas=[0, 45, 90, 135])`` + - :math:`\theta_k` in degrees, paired one to one with the frequencies. No range check. + * - Envelope aspect ratio + - ``--gaborgamma=0.1`` + - ``Nyxus(..., gabor_gamma=0.1)`` + - :math:`\gamma`; must be positive. + * - Bandwidth + - ``--gaborsig2lam=0.8`` + - ``Nyxus(..., gabor_sig2lam=0.8)`` + - :math:`\sigma/\lambda`; must be positive. + * - Kernel size + - ``--gaborkersize=16`` + - ``Nyxus(..., gabor_kersize=16)`` + - :math:`n`; an integer of at least 2. An odd size shifts the response by one pixel. Size 1 + is accepted but reads past the end of the convolution buffer, so its values are arbitrary + (a known defect, `#506 `__). + * - Baseline frequency + - ``--gaborf0=0.1`` + - ``Nyxus(..., gabor_f0=0.1)`` + - :math:`f_{LP}`, in the same units as the frequencies; must be positive. + * - Threshold + - ``--gaborthold=0.025`` + - ``Nyxus(..., gabor_thold=0.025)`` + - :math:`T`. Any real number; at :math:`T < 0` every pixel is counted. + +The frequency and orientation lists go together: give both or neither, with the same number of +values, or the run stops with an error. Without them the command line uses its built-in pairs, +frequencies :math:`0, \pi/4, \pi/2, 3\pi/4` at orientations of 4, 16, 32 and 64 *radians*, which act +as 49.2°, 16.7°, 33.5° and 66.9°. The Python package always passes its own defaults, frequencies 4, +16, 32 and 64 at 0°, 45°, 90° and 135°, so a Python run never uses the built-in pairs. Every value is +read as a single-precision number, so ``gabor_gamma=0.1`` is used as 0.100000001. + +In Python, ``Nyxus.set_gabor_feature_params(kersize=, gamma=, sig2lam=, f0=, thold=, thetas=, +freqs=)`` and ``Nyxus.set_params(gabor_...=)`` change the settings after construction; settings not +named keep their value. ``--coarseGrayDepth`` and ``--ibsi`` have no effect on ``GABOR``. All options +are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +``GABOR`` produces one column per frequency and orientation pair, ``GABOR_0`` to ``GABOR_N-1``, in +the order the pairs are given. With the Python defaults there are four: ``GABOR_0`` is +:math:`(4, 0°)`, ``GABOR_1`` :math:`(16, 45°)`, ``GABOR_2`` :math:`(32, 90°)` and ``GABOR_3`` +:math:`(64, 135°)`. The column names carry only the index; the frequencies and orientations are not +written to the output. In Python, ``Nyxus.get_params()`` returns them as ``gabor_freqs`` and +``gabor_thetas``. There is no ``_AVE`` column. + + +.. _gabor-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value of ``GABOR_0`` on the worked example. + +.. include:: gabor_index.inc + + +Features +-------- + +.. _feature-gabor: + +Gabor filter-bank score +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GABOR`` (columns ``GABOR_0`` to ``GABOR_N-1``) +:Range: :math:`[0, H W]` per column, dimensionless; usually close to 1 + +For each filter, the number of bounding-box pixels whose response exceeds :math:`T` times the +largest baseline response, divided by the number of bounding-box pixels whose baseline response +exceeds its smallest value. High values mean the filter responds over much of the bounding box; +low values mean the response is strong only in part of it. + +.. math:: + + \mathrm{GABOR\_}k = \frac{\#\{(y, x) : R_k(y, x) / M > T\}}{\#\{(y, x) : R_0(y, x) > m\}} + +The value is 0 for every column when the ROI has a single intensity, and the ``--noval`` +substitute when the baseline response is constant. + +**Possible issues.** The numerator counts every pixel of the bounding box, while the denominator +leaves out the pixels where the baseline takes its minimum, so a value can exceed 1. A 1 × 2 ROI of +intensities 3 and 8 gives 2 in every column. See :ref:`gabor-possible-issues` for the default +frequencies and the other conventions. + + +.. _gabor-possible-issues: + +Possible issues +--------------- + +Different default pairs on the command line and in Python + Without ``--gaborfreqs`` and ``--gabortheta``, the command line pairs frequencies + :math:`0, \pi/4, \pi/2, 3\pi/4` with orientations of 4, 16, 32 and 64 radians: the built-in list + stores each frequency where an orientation belongs, and the reverse, a known defect (`#504 `__). Python uses frequencies 4, + 16, 32, 64 at 0°, 45°, 90°, 135°. The same image gives different ``GABOR`` columns from the two + unless both lists are given. + +Frequencies are in radians per pixel + The command-line help describes ``--gaborfreqs`` and ``--gaborf0`` as "denominators of + :math:`\pi`", but the code uses each value as :math:`f` in :math:`\lambda = 2\pi/f`, as the + Python docstrings say. The Python + defaults 4, 16, 32 and 64 are therefore wavelengths of 1.57, 0.39, 0.20 and 0.10 pixels, all + shorter than the 2-pixel limit a pixel grid can represent, with envelope widths of 1.26 down to + 0.08 pixels. The last three kernels shrink to a short line of a few taps and respond to almost + any intensity. On 24 × 24 stripes with a period of 4 pixels, the defaults give 1.0017 in every + column for vertical stripes, and 0.16696, 1.0017, 1.0017, 1.0017 for horizontal ones: only + ``GABOR_0`` tells them apart. Two filters at :math:`f = \pi/2` (a 4-pixel wavelength) and 0° and + 90° give 1.0017 and 0.38957 for vertical stripes and the reverse for horizontal ones; with + ``--gaborthold=0.3`` they give 0.65913 and 0. + +The baseline is close to a ROI average + With the default :math:`f_{LP} = 0.1` the baseline kernel is nearly a flat 16 × 16 window, so + :math:`M` is about the mean intensity of the 16 × 16 window, zeros included. On small ROIs it is + much smaller than the high-pass responses, and with :math:`T = 0.025` almost every pixel passes + the threshold: on the worked example the threshold is 0.0054, and the smallest response of the + first three filters is 0.012. Most values then lie near 1, and differences between ROIs come + from a few pixels. + +Pixels outside the mask take part + The pixels of the bounding box outside the mask are filtered and counted as 0, and the image is + 0 beyond the bounding box. The same ROI therefore gives different values in a different mask + shape, and adding a constant to every intensity changes the values: on the worked example, + adding 100 changes ``GABOR_0`` from 1.0417 to 0.8333. Multiplying every intensity by a constant + changes nothing. + +Odd kernel sizes shift the response + The response is read from the full convolution at an offset of :math:`\lceil n/2 \rceil`, while + the kernel's centre tap is at :math:`\lfloor n/2 \rfloor`. For odd :math:`n` the response image + is shifted by one pixel down and right, a known defect (`#505 `__). With + ``--gaborkersize=15`` the worked example gives 1.0417, 0.9167, 0.8333, 0.9167; a centred + response would give 1.0417, 1.0417, 1.0417, 0.9583. + +Settings are shared by all ``Nyxus`` objects in a process + The Gabor settings are held once per process, not per object, and every ``Nyxus`` constructor + sets all of them. An object built with ``gabor_freqs=[1.5708, 1.5708]``, + ``gabor_thetas=[0, 90]`` returns two columns until a second ``Nyxus()`` is constructed; from + then on the first object also returns the four default columns. + +GPU values + The GPU path, used only from the command line, builds the same kernels and computes the linear + convolution through an FFT. It differs from the CPU path in three ways: it stores the + responses in single precision, which can move a response across the threshold; it reads the + response at an offset of :math:`\lfloor n/2 \rfloor`, so odd kernel sizes are centred on the + GPU and shifted on the CPU (`#505 `__); and it has no guard for a constant baseline, a known defect (`#507 `__): where the CPU stores + the ``--noval`` value, it divides by a count of 0, and the infinite or NaN result is replaced + at output time (by ``--noval`` in CSV and pandas, by ``-0.0`` in Arrow and Parquet). A ROI is + refused when :math:`(H+n-1)(W+n-1)` times the number of filters, the frequency–angle pairs + plus the baseline, reaches :math:`2^{26}` complex values, half of cuFFT's limit of + :math:`2^{27}` doubles. A refused ROI gets all-zero responses, so each of its columns is + written as that 0/0 result. The GPU values are not checked in continuous integration. + +Constant ROI + When every ROI pixel has one intensity, every column is 0, even when the mask leaves zeros in the + bounding box that would make the responses vary. + + +Validation +---------- + +The test suite checks ``GABOR`` against scikit-image 0.26.0 on four DSB2018 nuclei, in bounding +boxes of 90 to 224 pixels, to a relative 10\ :sup:`-3`, at two settings: the command line's built-in +pairs and the Python default pairs. All 32 values agree exactly. scikit-image supplies the kernels: +its ``gabor_kernel`` at frequency :math:`f/2\pi`, cropped to the 16 × 16 grid. The frequency-0 +kernel of the built-in pairs is the flat window in closed form, because ``gabor_kernel`` cannot take +a frequency of 0. scikit-image has no equivalent of the count-ratio score, so the score is computed +by the test's own code from the definition above. The check covers the CPU path in memory; the GPU +path is not checked in continuous integration. The ``GABOR`` values quoted in the worked example +and under Possible issues, and the wavelengths and envelope widths, are asserted by the +worked-example script; the intermediate responses (the baseline range, the threshold and the +smallest high-pass response) and the centred-response values for an odd kernel are not. None is +compared with a reference tool. + + +References +---------- + +1. Gabor D. *Theory of communication.* Journal of the Institution of Electrical Engineers. + 1946;93(26):429–457. +2. Daugman JG. *Uncertainty relation for resolution in space, spatial frequency, and orientation + optimized by two-dimensional visual cortical filters.* Journal of the Optical Society of + America A. 1985;2(7):1160–1169. +3. Orlov N, Shamir L, Macura T, Johnston J, Eckley DM, Goldberg IG. *WND-CHARM: Multi-purpose image + classification using compound image transforms.* Pattern Recognition Letters. + 2008;29(11):1684–1693. (Origin of the count-ratio Gabor score.) +4. van der Walt S, et al. *scikit-image: image processing in Python.* PeerJ. 2014;2:e453. diff --git a/docs/source/Math/f_glcm.rst b/docs/source/Math/f_glcm.rst index adab56a8c..a3f23163c 100644 --- a/docs/source/Math/f_glcm.rst +++ b/docs/source/Math/f_glcm.rst @@ -1,158 +1,835 @@ +Grey-level co-occurrence matrix (GLCM) +====================================== + +GLCM features describe how often pairs of grey levels occur next to each other at a fixed distance +and direction inside an ROI. They capture texture properties such as contrast, homogeneity and +regularity that first-order intensity statistics cannot see. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``GLCM_*``: 30 features, each with a column per direction, and 29 ``GLCM_*_AVE`` direction + averages (``GLCM_HOM2`` has none) + * - **Group** + - ``*ALL_GLCM*``; also part of ``*ALL*`` and ``*WHOLESLIDE*`` + * - **Dimensions** + - 2D. The 3D family, ``3GLCM_*``, has its own page, :doc:`f_glcm_3d`. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory on an image that Nyxus reads as one tile. On other + images out-of-core processing of this family raises an error or returns wrong + values, depending on the ROI (see :ref:`large-images-tiles`). + * - **IBSI** + - 25 of the 30 features have an IBSI identifier. Four of the other 5 return the same value as + one that does; ``GLCM_VARIANCE`` equals IBSI joint variance, which ``GLCM_JVAR`` matches only + on a symmetric matrix. + +.. tip:: + + To reproduce IBSI, PyRadiomics or MIRP values, run in IBSI mode: ``--ibsi=true`` on the command + line, ``ibsi=True`` in Python. With the default settings each neighbour pair is counted in one + order only, and several features then differ from those tools (see + :ref:`glcm-possible-issues`). + + +How the matrix is built +----------------------- + +For a direction :math:`\theta` and a distance :math:`\delta`, Nyxus visits every pixel of the ROI +and looks at the pixel :math:`\delta` pixels away along :math:`\theta`. If both pixels are inside +the image and neither has intensity 0, the pair of their grey levels is counted. + +- **Directions:** :math:`\theta \in \{0^\circ, 45^\circ, 90^\circ, 135^\circ\}`. Each direction + gets its own matrix and its own feature values. With rows numbered from the top, 0° pairs a + pixel with the one to its right, 45° with the one below and to the right, 90° with the one below, + and 135° with the one below and to the left. MATLAB ``graycomatrix``, PyRadiomics and IBSI + measure angles with the row axis pointing up: their 0° is the same pixel to the right, their 90° + is the pixel above, and they call the two diagonals 135° and 45°. With the default one-order + counting the Nyxus 90°, 45° and 135° matrices are therefore the transposes of their 90°, 135° + and 45° matrices, and only 0° matches as it is. +- **Distance:** :math:`\delta = 1` pixel. +- **Grey levels:** intensities are first binned into :math:`N_g` grey levels, as set by + ``--coarseGrayDepth`` (64 by default). In IBSI mode they are not binned: each integer intensity + is a grey level. +- **Symmetry:** in IBSI mode, and with a negative or zero ``--coarseGrayDepth`` (PyRadiomics-style + bins or no binning), each pair is counted in both orders, so :math:`\textbf{P}` is symmetric. + With a positive grey depth, as by default, each ordered pair is counted once. +- **Background:** pairs that include a pixel of intensity 0 are not counted, even inside the mask. + + +Worked example +^^^^^^^^^^^^^^ + +Take a 5 × 5 ROI that already has five grey levels: -Texture features / GLCM -======================= +.. math:: + + \textbf{G} = \begin{bmatrix} + 1 & 2 & 5 & 2 & 3\\ + 3 & 2 & 1 & 3 & 1\\ + 1 & 3 & 5 & 5 & 2\\ + 1 & 1 & 1 & 1 & 2\\ + 1 & 2 & 4 & 3 & 5 \end{bmatrix} -A Gray Level Co-occurrence Matrix (GLCM) of size :math:`N_g \times N_g` describes the joint probability function of an image and is defined as :math:`\textbf{P}(i,j|\delta,\theta)`. -The :math:`(i,j)`-th element of this matrix represents the number of times the combination of -indices :math:`i` and :math:`j` occur in two pixels in the image, that are separated by a distance of :math:`\delta` -pixels along angle :math:`\theta`. -The distance :math:`\delta` from the center pixel is defined as the distance according to the Euclidean norm. -For :math:`\delta=1`, this results in 2 neighbors for each of 13 angles in 3D (26-connectivity) and for -:math:`\delta=2` a 98-connectivity (49 unique angles). +With :math:`\delta = 1`, :math:`\theta = 0^\circ` and IBSI mode (no binning, both orders counted), +the matrix is below. Row 1 of the image contains the pairs (1,2), (2,5), (5,2) and (2,3), and each +is also counted reversed. + +.. math:: -As an example, let the following matrix represent a 5x5 ROI image having 5 discrete -grey levels: + \textbf{P} = \begin{bmatrix} + 6 & 4 & 3 & 0 & 0\\ + 4 & 0 & 2 & 1 & 3\\ + 3 & 2 & 0 & 1 & 2\\ + 0 & 1 & 1 & 0 & 0\\ + 0 & 3 & 2 & 0 & 2 \end{bmatrix}, + \qquad \sum_{i,j} \textbf{P}(i,j) = 40 + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for this +ROI in the ``_0`` (0°) column. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/glcm_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features="*ALL_GLCM*" --ibsi=true --glcmAngles=0 \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_g` + - Number of grey levels. + * - :math:`i,\; j` + - Grey level of the reference pixel (:math:`i`) and of its neighbour :math:`\delta` pixels + along :math:`\theta` (:math:`j`). + * - :math:`\textbf{P}(i,j)` + - Co-occurrence counts for one :math:`(\delta, \theta)`. + * - :math:`p_{ij}` + - Joint probability, :math:`p_{ij} = \textbf{P}(i,j) / \sum_{i,j}\textbf{P}(i,j)`. + * - :math:`p_x(i),\; p_y(j)` + - Marginal probabilities of the reference and neighbour levels, + :math:`p_x(i) = \sum_j p_{ij}` and :math:`p_y(j) = \sum_i p_{ij}`. + * - :math:`\mu_x,\; \mu_y` + - Marginal means, :math:`\mu_x = \sum_i i\,p_x(i)` and :math:`\mu_y = \sum_j j\,p_y(j)`. + * - :math:`\sigma_x,\; \sigma_y` + - Marginal standard deviations. + * - :math:`p_{x+y}(k)` + - :math:`\sum_{i+j=k} p_{ij}`, for :math:`k = 2, \dots, 2N_g`. + * - :math:`p_{x-y}(k)` + - :math:`\sum_{|i-j|=k} p_{ij}`, for :math:`k = 0, \dots, N_g - 1`. + * - :math:`\varepsilon` + - :math:`10^{-9}`, added inside logarithms to avoid :math:`\log 0`. + * - :math:`HX,\; HY` + - Marginal entropies, :math:`HX = -\sum_i p_x(i)\log_2\big(p_x(i)+\varepsilon\big)` and + likewise :math:`HY` for :math:`p_y`. + * - :math:`HXY` + - :math:`-\sum_{i,j} p_{ij}\log_2\big(p_{ij}+\varepsilon\big)` + * - :math:`HXY1` + - :math:`-\sum_{i,j} p_{ij}\log_2\big(p_x(i)\,p_y(j)+\varepsilon\big)` + * - :math:`HXY2` + - :math:`-\sum_{i,j} p_x(i)\,p_y(j)\log_2\big(p_x(i)\,p_y(j)+\varepsilon\big)` + +Sums over :math:`i` and :math:`j` run from 1 to :math:`N_g`. On a symmetric matrix +:math:`p_x = p_y`, :math:`\mu_x = \mu_y` and :math:`HX = HY`. + +Logarithms are computed with a fast base-2 approximation. On the worked example above, over the +four directions in IBSI mode, ``GLCM_JE``, ``GLCM_SUMENTROPY``, ``GLCM_DIFENTRO`` and +``GLCM_INFOMEAS2`` differ from an exact logarithm by a relative 3.1 × 10\ :sup:`-3` at most, and +``GLCM_INFOMEAS1``, a difference of two entropies divided by a third, by 3.8 × 10\ :sup:`-3` to +5.6 × 10\ :sup:`-3`. ``GLCM_JE_0`` is 3.80453 where the exact value is 3.80869, a relative +1.1 × 10\ :sup:`-3`. On the validation phantoms these features agree with the reference tools to a +relative 5 × 10\ :sup:`-3`. See :ref:`conventions-entropy`. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - ``--coarseGrayDepth=64`` + - ``Nyxus(..., coarse_gray_depth=64)`` + - Number of grey levels. Positive: equal-width bins; negative: PyRadiomics-style bins (command + line only). + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - No binning, and pairs counted in both orders. + * - Directions + - ``--glcmAngles=0,45,90,135`` + - not available + - Which :math:`\theta` to compute; each enabled angle adds one output column per feature. + * - Distance + - ``--glcmOff`` + - ``set_metaparam("glcm/offset=N")`` + - :math:`\delta` in pixels. The command-line option is accepted but has no effect: the + distance is 1 unless set from Python. + +``set_metaparam("glcm/greydepth=N")`` does not change the 2D matrix, which always uses +``coarse_gray_depth``; it changes only the constant-ROI test (see :ref:`glcm-possible-issues`). +All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column per enabled direction and one average: + +- ``GLCM_CONTRAST_0``, ``GLCM_CONTRAST_45``, ``GLCM_CONTRAST_90``, ``GLCM_CONTRAST_135``: the value + for each direction. +- ``GLCM_CONTRAST_AVE``: the mean of the per-direction values. + +Each direction has its own matrix; the matrices are not merged. ``GLCM_HOM2`` has no ``_AVE`` +column; ``GLCM_IDM_AVE`` has the same value. + + +.. _glcm-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the 0° value on the worked example. + +.. include:: glcm_index.inc + + +Features +-------- + +.. _feature-glcm-acor: + +Autocorrelation +^^^^^^^^^^^^^^^ + +:Code: ``GLCM_ACOR`` +:Range: :math:`[1, N_g^2]`, squared grey levels + +Measures the magnitude of the fineness and coarseness of texture. Pairs of bright pixels contribute +most. .. math:: - \textbf{G} = \begin{bmatrix} - 1 & 2 & 5 & 2 & 3\\ - 3 & 2 & 1 & 3 & 1\\ - 1 & 3 & 5 & 5 & 2\\ - 1 & 1 & 1 & 1 & 2\\ - 1 & 2 & 4 & 3 & 5 \end{bmatrix} + \mathrm{ACOR} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} i\,j\; p_{ij} + + +.. _feature-glcm-asm: + +Angular second moment +^^^^^^^^^^^^^^^^^^^^^ +:Code: ``GLCM_ASM`` +:Range: :math:`(0, 1]`, dimensionless -For distance :math:`\delta = 1` (considering pixels with a distance of 1 pixel from each other) -and angle :math:`\theta=0^\circ`` (horizontal plane, i.e. voxels to the left and right of the center voxel), -the following symmetrical GLCM is obtained: +Measures how concentrated the co-occurrence probabilities are. A uniform texture with few, repeated +grey-level transitions gives values near 1. .. math:: - \textbf{P} = \begin{bmatrix} - 6 & 4 & 3 & 0 & 0\\ - 4 & 0 & 2 & 1 & 3\\ - 3 & 2 & 0 & 1 & 2\\ - 0 & 1 & 1 & 0 & 0\\ - 0 & 3 & 2 & 0 & 2 \end{bmatrix} + \mathrm{ASM} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} p_{ij}^{\,2} +**Relations.** ``GLCM_ENERGY`` returns the same value. IBSI calls this feature both *angular second +moment* and *joint energy*. -Let: -* :math:`\epsilon` be an arbitrarily small positive number (:math:`\approx 2.2\times10^{-16}`) -* :math:`\textbf{P}(i,j)` be the co-occurence matrix for an arbitrary :math:`\delta` and :math:`\theta`` -* :math:`p_{ij}` be the normalized co-occurence matrix and equal to :math:`\frac{\textbf{P}(i,j)}{\sum{\textbf{P}(i,j)}}` -* :math:`N_g`` be the number of discrete intensity levels in the image -* :math:`p_x(i) = \sum^{N_g}_{j=1}{p_{ij}}` be the marginal row probabilities -* :math:`p_y(j) = \sum^{N_g}_{i=1}{p_{ij}}`` be the marginal column probabilities -* :math:`\mu_x`` be the mean gray level intensity of :math:`p_x` and defined as :math:`\mu_x = \sum^{N_g}_{i=1}{p_x(i)i}` -* :math:`\mu_y`` be the mean gray level intensity of :math:`p_y` and defined as :math:`\mu_y = \sum^{N_g}_{j=1}{p_y(j)j}` -* :math:`\sigma_x` be the standard deviation of :math:`p_x`` -* :math:`\sigma_y`` be the standard deviation of :math:`p_y` -* :math:`p_{x+y}(k) = \sum^{N_g}_{i=1}\sum^{N_g}_{j=1}{p_{ij}},\text{ where }i+j=k,\text{ and }k=2,3,\dots,2N_g` -* :math:`p_{x-y}(k) = \sum^{N_g}_{i=1}\sum^{N_g}_{j=1}{p_{ij}},\text{ where }|i-j|=k,\text{ and }k=0,1,\dots,N_g-1` -* :math:`HX = -\sum^{N_g}_{i=1}{p_x(i)\log_2\big(p_x(i)+\epsilon\big)}` be the entropy of :math:`p_x` -* :math:`HY = -\sum^{N_g}_{j=1}{p_y(j)\log_2\big(p_y(j)+\epsilon\big)}`` be the entropy of :math:`p_y` -* :math:`HXY = -\sum^{N_g}_{i=1}\sum^{N_g}_{j=1}{p_{ij}\log_2\big(p_{ij}+\epsilon\big)}`` be the entropy of :math:`p_{ij}` -* :math:`HXY1 = -\sum^{N_g}_{i=1}\sum^{N_g}_{j=1}{p_{ij}\log_2\big(p_x(i)p_y(j)+\epsilon\big)}` -* :math:`HXY2 = -\sum^{N_g}_{i=1}\sum^{N_g}_{j=1}{p_x(i)p_y(j)\log_2\big(p_x(i)p_y(j)+\epsilon\big)}`` +.. _feature-glcm-cluprom: -By default, the value of a feature is calculated on the GLCM for each angle separately, after which the mean of these -values is returned. If distance weighting is enabled, GLCM matrices are weighted by weighting factor W and -then summed and normalised. +Cluster prominence +^^^^^^^^^^^^^^^^^^ -The following features are then calculated based on the collocation matrix. +:Code: ``GLCM_CLUPROM`` +:Range: :math:`[0, \infty)`, grey levels to the fourth power + +Measures the skewness and peakedness of the sum of the two grey levels in a pair. High values mean +the sums are spread far from their mean. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_ASM}} {\textup{angular second moment}} = \sum^{N_g}_{i=1}\sum^{N_g}_{j=1}{p_{i,j}^2} + + \mathrm{CLUPROM} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} \big(i + j - 2\mu_y\big)^4\, p_{ij} + +**Possible issues.** IBSI and PyRadiomics centre on :math:`\mu_x + \mu_y`; Nyxus centres on +:math:`2\mu_y`, twice the neighbour mean. The two are equal on a symmetric matrix. With the default +counting they differ, and the value changes when the image is mirrored. + + +.. _feature-glcm-clushade: + +Cluster shade +^^^^^^^^^^^^^ + +:Code: ``GLCM_CLUSHADE`` +:Range: :math:`(-\infty, \infty)`, grey levels cubed + +Measures the skewness of the sum of the two grey levels in a pair. Its sign shows on which side of +the mean the sums are spread. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_ACOR}} {\textup{autocorrelation}} = \sum^{N_g}_{i=1} \sum^{N_g}_{j=1} p(i,j) i j + + \mathrm{CLUSHADE} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} \big(i + j - 2\mu_y\big)^3\, p_{ij} + +**Possible issues.** Centred on :math:`2\mu_y` like cluster prominence. The power is odd, so the +difference from IBSI can change the sign: with the default settings the worked example gives +−585.66, while the IBSI definition on the same matrix gives 9490.19. + + +.. _feature-glcm-clutend: + +Cluster tendency +^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_CLUTEND`` +:Range: :math:`[0, \infty)`, squared grey levels + +Measures how grey levels group into clusters of similar values. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_CLUPROM}} {\textup{cluster prominence}} = \sum^{N_g}_{i=1} \sum^{N_g}_{j=1} (i + j - \mu_x - \mu_y) ^4 p(i,j) + + \mathrm{CLUTEND} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} \big(i + j - 2\mu_y\big)^2\, p_{ij} + +**Relations.** ``GLCM_SUMVARIANCE`` returns the same value. + +**Possible issues.** Centred on :math:`2\mu_y` like cluster prominence. + + +.. _feature-glcm-contrast: + +Contrast +^^^^^^^^ + +:Code: ``GLCM_CONTRAST`` +:Range: :math:`[0, (N_g-1)^2]`, squared grey levels + +Measures local intensity variation. Each pair is weighted by the square of its grey-level +difference, so large jumps between neighbours dominate. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_CLUSHADE}} {\textup{cluster shade}} = \sum^{N_g}_{i=1} \sum^{N_g}_{j=1} (i + j - \mu_x - \mu_y) ^3 p(i,j) + + \mathrm{Contrast} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} (i-j)^2\, p_{ij} + +**Possible issues.** Contrast scales with the square of the grey-level spacing, so it depends +strongly on binning. On the worked example it is 2.9 in IBSI mode and 473.45 with the default +64-level binning, which spreads the 5 levels over the range 1 to 64. Compare contrast only between +runs with the same ``--coarseGrayDepth``. + + +.. _feature-glcm-correlation: + +Correlation +^^^^^^^^^^^ + +:Code: ``GLCM_CORRELATION`` +:Range: :math:`[-1, 1]`, dimensionless + +Measures the linear dependence between the grey levels of neighbouring pixels. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_CLUTEND}} {\textup{cluster tendency}} = \sum^{N_g}_{i=1} \sum^{N_g}_{j=1} (i + j - \mu_x - \mu_y) ^2 p(i,j) + + \mathrm{Correlation} = + \frac{\sum_{i,j} (i-\mu_x)(j-\mu_y)\, p_{ij}}{\sigma_x\,\sigma_y} + +**Possible issues.** When either marginal has zero variance, the denominator is 0 and Nyxus returns +the ``--noval`` substitute (0 by default) instead of NaN. This can happen on very small ROIs. + + +.. _feature-glcm-difave: + +Difference average +^^^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_DIFAVE`` +:Range: :math:`[0, N_g-1]`, grey levels + +The mean absolute grey-level difference between neighbours. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_CONTRAST}} {\textup{contrast}} = \sum^{N_g}_{i=1}\sum^{N_g}_{j=1}{(i-j)^2p_{ij}} + + \mathrm{DIFAVE} = \sum_{k=0}^{N_g-1} k\; p_{x-y}(k) + +**Relations.** Equal to ``GLCM_DIS`` with the default binning and in IBSI mode; see +:ref:`glcm-possible-issues` for PyRadiomics-style binning. + + +.. _feature-glcm-difentro: + +Difference entropy +^^^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_DIFENTRO`` +:Range: :math:`[0, \log_2 N_g]` bits + +Measures the randomness of the grey-level differences between neighbours. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_CORRELATION}} {\textup{correlation}} = \frac{\sum^{N_g}_{i=1}\sum^{N_g}_{j=1}{p_{ij}ij-\mu_x\mu_y}}{\sigma_x(i)\sigma_y(j)} + + \mathrm{DIFENTRO} = -\sum_{k=0}^{N_g-1} p_{x-y}(k)\,\log_2\!\big(p_{x-y}(k)+\varepsilon\big) + + +.. _feature-glcm-difvar: + +Difference variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_DIFVAR`` +:Range: :math:`[0, \infty)`, squared grey levels + +Measures how much the grey-level differences between neighbours vary around their mean. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_DIFAVE}} {\textup{difference average}} =\sum_{k=0}^{N_g-1} k\, p_{i-j,k} + + \mathrm{DIFVAR} = \sum_{k=0}^{N_g-1} \big(k - \mathrm{DIFAVE}\big)^2\, p_{x-y}(k) + + +.. _feature-glcm-dis: + +Dissimilarity +^^^^^^^^^^^^^ + +:Code: ``GLCM_DIS`` +:Range: :math:`[0, N_g-1]`, grey levels + +The mean absolute grey-level difference between neighbours, computed from the matrix directly. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_DIFENTRO}} {\textup{difference entropy}} = \sum^{N_g-1}_{k=0}{p_{x-y}(k)\log_2\big(p_{x-y}(k)+\epsilon\big)} + + \mathrm{DIS} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} |i-j|\; p_{ij} + +**Relations.** Equal to ``GLCM_DIFAVE`` with the default binning and in IBSI mode. + + +.. _feature-glcm-energy: + +Energy +^^^^^^ + +:Code: ``GLCM_ENERGY`` +:Range: :math:`(0, 1]`, dimensionless + +The same value as ``GLCM_ASM``, under the name other tools use. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_DIFVAR}} {\textup{difference variance}} = \sum^{N_g-1}_{k=0}{(k-\mu_d)^2p_{x-y}(k)} - -where the difference average :math:`\mu_d` is + + \mathrm{Energy} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} p_{ij}^{\,2} + + +.. _feature-glcm-entropy: + +Entropy +^^^^^^^ + +:Code: ``GLCM_ENTROPY`` +:Range: :math:`[0, 2\log_2 N_g]` bits + +The same value as ``GLCM_JE``, under the name other tools use. .. math:: - \mu_d = \sum^{N_g-1}_{k=0} kp_{x-y}(k) . -.. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_DIS}} {\textup{dissimilarity}} = \sum_{i=1}^{N_g} \sum_{j=1}^{N_g} |i-j|\, p_{ij} + \mathrm{Entropy} = -\sum_{i=1}^{N_g}\sum_{j=1}^{N_g} p_{ij}\,\log_2\!\big(p_{ij}+\varepsilon\big) + +.. versionchanged:: 0.11.0+ + + The sum is taken over the normalized matrix :math:`p_{ij}`. Version 0.11.0 and earlier summed + raw counts and returned negative values. + + +.. _feature-glcm-hom1: + +Homogeneity 1 +^^^^^^^^^^^^^ + +:Code: ``GLCM_HOM1`` +:Range: :math:`(0, 1]`, dimensionless + +Measures how close the pairs are to the matrix diagonal, with a weight that falls off linearly with +the grey-level difference. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_JE}} {\textup{joint entropy}} = - \sum^{N_g}_{i=1} \sum^{N_g}_{j=1} p(i,j) \log_2 ( p(i,j) + \epsilon ) + + \mathrm{HOM1} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} \frac{p_{ij}}{1+|i-j|} + +**Relations.** Equal to ``GLCM_ID``. + + +.. _feature-glcm-hom2: + +Homogeneity 2 +^^^^^^^^^^^^^ + +:Code: ``GLCM_HOM2`` (no ``_AVE`` column) +:Range: :math:`(0, 1]`, dimensionless + +Measures how close the pairs are to the matrix diagonal, with a weight that falls off with the +square of the grey-level difference. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_HOM1}} {\textup{homogeneity 1}} = \sum^{N_g}_{i=1}\displaystyle\sum^{N_g}_{j=1} \frac{p(i,j)}{1+|i-j|} + + \mathrm{HOM2} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} \frac{p_{ij}}{1+(i-j)^2} + +**Relations.** Equal to ``GLCM_IDM``; for the direction average use ``GLCM_IDM_AVE``. + +.. versionchanged:: 0.11.0+ + + The sum is taken over the normalized matrix :math:`p_{ij}`. Version 0.11.0 and earlier summed + raw counts and returned values above 1. + + +.. _feature-glcm-id: + +Inverse difference +^^^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_ID`` +:Range: :math:`(0, 1]`, dimensionless + +Measures local homogeneity. Pairs with equal grey levels contribute fully, and the weight falls off +linearly with their difference. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_HOM2}} {\textup{homogeneity 2}} = \sum^{N_g}_{i=1} \sum^{N_g}_{j=1}{\frac{p(i,j)}{1+|i-j|^2}} + + \mathrm{ID} = \sum_{k=0}^{N_g-1} \frac{p_{x-y}(k)}{1+k} + +**Relations.** Equal to ``GLCM_HOM1``. + + +.. _feature-glcm-idm: + +Inverse difference moment +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_IDM`` +:Range: :math:`(0, 1]`, dimensionless + +Measures local homogeneity, penalizing large grey-level differences more than the inverse +difference. Also known as *homogeneity*. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_IDM}} {\textup{inverse difference moment}} = \sum^{N_g-1}_{k=0}{\frac{p_{x-y}(k)}{1+k^2}} + + \mathrm{IDM} = \sum_{k=0}^{N_g-1} \frac{p_{x-y}(k)}{1+k^2} + +**Relations.** Equal to ``GLCM_HOM2``. + + +.. _feature-glcm-idmn: + +Inverse difference moment normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_IDMN`` +:Range: :math:`(0, 1]`, dimensionless + +The inverse difference moment with the difference scaled by the number of grey levels, so values +are comparable across binnings. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_IDMN}} {\textup{inverse difference moment normalized}} = \sum^{N_g-1}_{k=0} \frac {p_{x-y}(k)} {1+\frac{k^2}{N_g^2}} + + \mathrm{IDMN} = \sum_{k=0}^{N_g-1} \frac{p_{x-y}(k)}{1+k^2/N_g^2} + + +.. _feature-glcm-idn: + +Inverse difference normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_IDN`` +:Range: :math:`(0, 1]`, dimensionless + +The inverse difference with the difference scaled by the number of grey levels. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_ID}} {\textup{inverse difference}} =\sum_{i=1}^{N_g} \sum_{j=1}^{N_g} \frac{p_{ij}}{1+|i-j|} + + \mathrm{IDN} = \sum_{k=0}^{N_g-1} \frac{p_{x-y}(k)}{1+k/N_g} + + +.. _feature-glcm-infomeas1: + +Information measure of correlation 1 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_INFOMEAS1`` +:Range: :math:`[-1, 0]`, dimensionless + +Compares the joint entropy with the entropy expected if neighbouring grey levels were independent. +Values closer to −1 mean stronger dependence. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_IDN}} {\textup{inverse difference normalized}} = \sum^{N_g-1}_{k=0} \frac {p_{x-y}(k)} {1+\frac{k}{N_g}} + + \mathrm{IMC1} = \frac{HXY - HXY1}{HY} + +**Possible issues.** IBSI and PyRadiomics divide by :math:`\max(HX, HY)`; Nyxus divides by +:math:`HY`, the entropy of the neighbour marginal. On a symmetric matrix :math:`HX = HY`, so the +difference shows only with the default counting, where the value can change when the image is +mirrored. When :math:`HY = 0` the result is the ``--noval`` substitute. + + +.. _feature-glcm-infomeas2: + +Information measure of correlation 2 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_INFOMEAS2`` +:Range: :math:`[0, 1)`, dimensionless + +A second measure of the dependence between neighbouring grey levels; values near 1 mean strong +dependence. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_INFOMEAS1}} {\textup{informational measure of correlation 1}} = \frac{HXY-HXY1}{\max{HX,HY}} + + \mathrm{IMC2} = \sqrt{\,\big|\,1 - e^{-2\,(HXY2 - HXY)}\big|\,} + +The absolute value guards against a small negative argument caused by rounding. + + +.. _feature-glcm-iv: + +Inverse variance +^^^^^^^^^^^^^^^^ + +:Code: ``GLCM_IV`` +:Range: :math:`[0, 1]`, dimensionless + +Measures homogeneity among pairs with different grey levels; pairs with equal levels are excluded. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_INFOMEAS2}} {\textup{information measure of correlation 2}} = \sqrt{1-e^{-2(HXY2-HXY)}} + + \mathrm{IV} = \sum_{k=1}^{N_g-1} \frac{p_{x-y}(k)}{k^2} + + +.. _feature-glcm-jave: + +Joint average +^^^^^^^^^^^^^ + +:Code: ``GLCM_JAVE`` +:Range: :math:`[1, N_g]`, grey levels + +The mean grey level of the reference pixel over all counted pairs. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_VARIANCE}} {\textup{variance}} = \sum^{N_g}_{i=1}\sum^{N_g}_{j=1}{(i-\mu_x)^2p_{ij}} + + \mathrm{JAVE} = \mu_x = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} i\; p_{ij} + + +.. _feature-glcm-je: + +Joint entropy +^^^^^^^^^^^^^ + +:Code: ``GLCM_JE`` +:Range: :math:`[0, 2\log_2 N_g]` bits + +Measures the randomness of the co-occurrence distribution; higher values mean more varied +grey-level pairs. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_SUMAVERAGE}} {\textup{sum average}} = \sum^{2N_g}_{k=2} {p_{x+y}(k)k} + + \mathrm{JE} = -\sum_{i=1}^{N_g}\sum_{j=1}^{N_g} p_{ij}\,\log_2\!\big(p_{ij}+\varepsilon\big) + +**Relations.** ``GLCM_ENTROPY`` returns the same value. + +**Possible issues.** :math:`\varepsilon` is 10\ :sup:`-9`; PyRadiomics uses machine epsilon, a +second, smaller source of difference besides the approximate logarithm. + + +.. _feature-glcm-jmax: + +Joint maximum +^^^^^^^^^^^^^ + +:Code: ``GLCM_JMAX`` +:Range: :math:`(0, 1]`, dimensionless + +The probability of the most frequent grey-level pair. PyRadiomics calls it *maximum probability*. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_SUMVARIANCE}} {\textup{sum variance}} = \sum^{2N_g}_{k=2} {(k-SA)^2p_{x+y}(k)} + + \mathrm{JMAX} = \max_{i,j}\; p_{ij} + + +.. _feature-glcm-jvar: + +Joint variance +^^^^^^^^^^^^^^ + +:Code: ``GLCM_JVAR`` +:Range: :math:`[0, \infty)`, squared grey levels + +Measures the spread of grey levels in the counted pairs around the joint average. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_SUMENTROPY}} {\textup{sum entropy}} = \sum^{2N_g}_{k=2} {p_{x+y}(k)\log_2\big(p_{x+y}(k)+\epsilon\big)} + + \mathrm{JVAR} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} (j-\mu_x)^2\, p_{ij} + +**Possible issues.** IBSI defines joint variance as :math:`\sum_{i,j}(i-\mu_x)^2\,p_{ij}`; Nyxus +takes the neighbour's level :math:`j` around the reference mean :math:`\mu_x`. The two agree on a +symmetric matrix. With the default counting they differ: 345.41 instead of 332.44 on the worked +example with 64-level binning. ``GLCM_VARIANCE`` gives the IBSI value in both cases. + + +.. _feature-glcm-sumaverage: + +Sum average +^^^^^^^^^^^ + +:Code: ``GLCM_SUMAVERAGE`` +:Range: :math:`[2, 2N_g]`, grey levels + +The mean of the sum of the two grey levels in a pair. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_JAVE}} {\textup{joint average}} = \sum_{i=1}^{N_g} \sum_{j=1}^{N_g} i\, p_{ij} + + \mathrm{SUMAVERAGE} = \sum_{k=2}^{2N_g} k\; p_{x+y}(k) + +**Relations.** Equal to :math:`\mu_x + \mu_y`, which is twice ``GLCM_JAVE`` on a symmetric matrix. + + +.. _feature-glcm-sumentropy: + +Sum entropy +^^^^^^^^^^^ + +:Code: ``GLCM_SUMENTROPY`` +:Range: :math:`[0, \log_2(2N_g-1)]` bits + +Measures the randomness of the sums of the two grey levels in a pair. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_JE}} {\textup{joint entropy}} = -\sum_{i=1}^{N_g} \sum_{j=1}^{N_g} p_{ij} \log_2 p_{ij} + + \mathrm{SUMENTROPY} = -\sum_{k=2}^{2N_g} p_{x+y}(k)\,\log_2\!\big(p_{x+y}(k)+\varepsilon\big) + + +.. _feature-glcm-sumvariance: + +Sum variance +^^^^^^^^^^^^ + +:Code: ``GLCM_SUMVARIANCE`` +:Range: :math:`[0, \infty)`, squared grey levels + +Measures the spread of the sums of the two grey levels in a pair. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_JMAX}} {\textup{joint maximum}} = \text{max}(p_{ij}) + + \mathrm{SUMVARIANCE} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} \big(i + j - 2\mu_y\big)^2\, p_{ij} + +**Relations.** Nyxus returns the ``GLCM_CLUTEND`` value. IBSI defines sum variance as +:math:`\sum_k (k - \mathrm{SUMAVERAGE})^2\, p_{x+y}(k)`, the same quantity on a symmetric matrix. + +**Possible issues.** With the default counting the two differ: 882.25 instead of 867.43 on the +worked example with 64-level binning. + + +.. _feature-glcm-variance: + +Variance +^^^^^^^^ + +:Code: ``GLCM_VARIANCE`` +:Range: :math:`[0, \infty)`, squared grey levels + +Measures the spread of the reference pixel's grey level around its mean. PyRadiomics calls it *sum +of squares*. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLCM\_JVAR}} {\textup{joint variance}} = \sum_{i=1}^{N_g} \sum_{j=1}^{N_g} \left(i-\mu\right)^2 p_{ij} + + \mathrm{Variance} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_g} (i-\mu_x)^2\, p_{ij} + +**Relations.** Matches the IBSI definition of joint variance on every matrix; ``GLCM_JVAR`` matches +it only on a symmetric one. + + +.. _glcm-possible-issues: + +Possible issues +--------------- + +Asymmetric matrix by default + With the default (positive) ``--coarseGrayDepth`` each ordered pair is counted once, while + PyRadiomics, MIRP and IBSI count both orders. Features that use only one side of the pair then + take it from a fixed side, and some mix the two sides: + + .. list-table:: + :widths: 40 60 + :header-rows: 1 + + * - Feature + - Difference from IBSI with the default settings + * - ``CLUPROM``, ``CLUSHADE``, ``CLUTEND``, ``SUMVARIANCE`` + - Centred on :math:`2\mu_y` instead of :math:`\mu_x + \mu_y` + * - ``JVAR`` + - Neighbour level around the reference mean + * - ``INFOMEAS1`` + - Divides by :math:`HY` instead of :math:`\max(HX, HY)` + + These values can change when the image is mirrored. Run in IBSI mode to match IBSI, + PyRadiomics or MIRP. + +Constant ROI + Before building any matrix, Nyxus bins the ROI's minimum and maximum intensities to + ``--coarseGrayDepth`` levels counted from 0 (``glcm/greydepth`` from Python), in IBSI mode too. + When the two fall into the same level, every GLCM feature returns 0, whatever ``--noval`` is, + a known defect (`#511 `__): the substitute is written and then overwritten by the empty + per-angle results, which the output pads with 0. + This covers a constant ROI and, with a positive grey depth :math:`N` (64 by default), any ROI + whose minimum is at least :math:`(N-1)/N` of its maximum: in IBSI mode with the default + :math:`N`, a ROI of intensities 1000 and 1001 returns 0 in every column, while one of 10 and + 11 does not. With a zero or negative ``--coarseGrayDepth`` only a constant ROI does. + +Grey-level values and indices + With PyRadiomics-style binning (negative ``--coarseGrayDepth``) the grey levels are the distinct + binned values present in the ROI. Most features use those values, but ``DIS``, ``HOM1``, + ``HOM2``, ``DIFENTRO``, ``SUMENTROPY`` and the inverse-difference features use positions 1 to + :math:`N_g` in that list, and ``IDN`` and ``IDMN`` divide by :math:`N_g`, the number of levels + present. ``DIFAVE``, ``SUMAVERAGE``, ``IV``, ``JVAR`` and ``DIFVAR`` are a known defect + (`#510 `__) and, when the levels present are not + consecutive, match neither convention. ``DIFAVE``, ``SUMAVERAGE`` and ``IV`` group pairs by the + difference or sum of their positions, but weight each group by the value difference or sum of + one pair in it. ``JVAR`` takes positions minus ``JAVE``, a mean of values, and ``DIFVAR`` takes + position differences minus ``DIFAVE``. Values and positions coincide with the default binning + and in IBSI mode. + + +Validation +---------- + +The test suite checks every 2D GLCM feature against an independent implementation, named in the +:ref:`index `. PyRadiomics and MIRP are compared on matched settings (symmetric matrix, +identical grey levels), and IBSI mode reproduces the IBSI consensus values on the IBSI digital +phantom. Because the reference tools count pairs symmetrically, the default asymmetric counting is +covered by regression tests only. + References ---------- -Haralick, R., Shanmugan, K., Dinstein, I; Textural features for image classification; IEEE Transactions on Systems, Man and Cybernetics; 1973(3), p610-621 +1. Haralick RM, Shanmugam K, Dinstein I. *Textural features for image classification.* IEEE + Transactions on Systems, Man, and Cybernetics. 1973;SMC-3(6):610–621. +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io +3. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. diff --git a/docs/source/Math/f_glcm_3d.rst b/docs/source/Math/f_glcm_3d.rst new file mode 100644 index 000000000..5faea8255 --- /dev/null +++ b/docs/source/Math/f_glcm_3d.rst @@ -0,0 +1,665 @@ +3D grey-level co-occurrence matrix (3D GLCM) +============================================ + +3D GLCM features describe how often pairs of grey levels occur next to each other inside a 3D ROI, +along each of the 13 directions of the 26-voxel neighbourhood. Every formula is the one of the +:doc:`2D GLCM `, applied to a 3D co-occurrence matrix. This page covers what is specific to +3D: the directions, which direction a plain code reports, binning, background and the settings. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``3GLCM_*``: 30 features, each a single column, and 29 ``3GLCM_*_AVE`` direction averages + * - **Group** + - ``*3D_GLCM*``; also part of ``*3D_ALL_TEXTURE*`` and ``*3D_ALL*`` + * - **Dimensions** + - 3D. The 2D family, ``GLCM_*``, is on the :doc:`GLCM page `. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes. The out-of-core path streams the volume plane by plane and builds the same 13 matrices. + When one read of the file returns the whole volume (NIfTI, or OME-Zarr chunked over all of + Z), an oversized ROI stops the run with an error (see :doc:`/large_images`). + * - **IBSI** + - 25 of the 30 features have an IBSI identifier. Four of the other 5 return the same value as + one that does; ``3GLCM_VARIANCE`` equals IBSI joint variance, which ``3GLCM_JVAR`` matches + only on a symmetric matrix. The ``_AVE`` codes follow the IBSI *3D:avg* aggregation (ITBB). + +.. tip:: + + A plain code such as ``3GLCM_CONTRAST`` reports **one direction only**, the diagonal step + :math:`(1,1,1)`. It is not an average and it changes when the volume is rotated or mirrored. For + a value over all directions, as IBSI, PyRadiomics and MIRP report it, use the ``_AVE`` code, and + run in IBSI mode (``ibsi=True``). + + +How the matrix is built +----------------------- + +For a direction :math:`\mathbf{d} = (d_x, d_y, d_z)`, Nyxus visits every voxel of the ROI's +bounding box and pairs it with the voxel one step along :math:`\mathbf{d}`, where :math:`x` is the +column, :math:`y` the row and :math:`z` the plane. If both voxels are inside the bounding box, the +pair of their grey levels is counted. + +- **Directions:** the 13 directions that cover the 26-voxel neighbourhood once each, in this order: + + .. list-table:: + :widths: 10 90 + :header-rows: 1 + + * - # + - :math:`(d_x, d_y, d_z)` + * - 1–9 + - :math:`(1,1,1)`, :math:`(1,1,0)`, :math:`(1,1,-1)`, :math:`(1,0,1)`, :math:`(1,0,0)`, + :math:`(1,0,-1)`, :math:`(1,-1,1)`, :math:`(1,-1,0)`, :math:`(1,-1,-1)` + * - 10–13 + - :math:`(0,1,1)`, :math:`(0,1,0)`, :math:`(0,1,-1)`, :math:`(0,0,1)` + + Each direction gets its own matrix and its own feature values; the matrices are not merged. +- **Which direction a plain code reports:** the first one, :math:`(1,1,1)`. The other 12 values + enter only the ``_AVE`` codes. +- **Numbering:** the :doc:`3D GLRLM ` page lists the same 13 triples in the same + order, but written :math:`(\Delta z, \Delta y, \Delta x)`, so a direction number names a + different step in the two families. Direction 5, :math:`(1,0,0)`, is a step along :math:`x` + here and a step across planes in 3D GLRLM. Directions 1, 4, 7 and 11, among them direction 1, + :math:`(1,1,1)`, which the plain codes report, are the same step in both. +- **Distance:** 1 voxel, along each axis of the direction. It cannot be changed (see + `Settings`_). +- **Grey levels:** intensities are binned into :math:`N_g` equal-width levels spanning 0 to the + ROI's maximum, as set by ``coarse_gray_depth`` (64 by default) or the ``3glcm/greydepth`` + metaparameter. In IBSI mode they are not binned: each integer intensity is a grey level and + :math:`N_g` is the ROI's maximum intensity. +- **Symmetry:** in IBSI mode, and with a negative or zero grey depth (PyRadiomics-style bins or no + binning), each pair is counted in both orders, so :math:`\textbf{P}` is symmetric. With a + positive grey depth, as by default, each ordered pair is counted once. +- **Background:** in IBSI mode and with PyRadiomics-style binning, pairs that include a voxel + outside the ROI (or of intensity 0) are not counted. With the default binning they are counted, + with the background voxel at grey level 1 (see :ref:`glcm-3d-possible-issues`). + + +Worked example +^^^^^^^^^^^^^^ + +Take a 3 × 4 × 4 ROI that already has four grey levels, shown plane by plane (rows :math:`y`, +columns :math:`x`): + +.. math:: + + z=0:\ \begin{bmatrix} + 1 & 2 & 4 & 3\\ + 2 & 1 & 3 & 4\\ + 4 & 4 & 1 & 2\\ + 3 & 1 & 2 & 2 \end{bmatrix} + \qquad + z=1:\ \begin{bmatrix} + 2 & 3 & 1 & 1\\ + 4 & 2 & 2 & 3\\ + 1 & 3 & 4 & 4\\ + 2 & 2 & 3 & 1 \end{bmatrix} + \qquad + z=2:\ \begin{bmatrix} + 3 & 1 & 2 & 4\\ + 1 & 1 & 4 & 2\\ + 2 & 4 & 3 & 3\\ + 4 & 3 & 1 & 2 \end{bmatrix} + +Along :math:`(1,1,1)` each voxel of planes 0 and 1 that is not in the last row or column has a +neighbour, which gives :math:`2 \times 3 \times 3 = 18` pairs. The voxel at :math:`z=0, y=0, x=0` +(level 1) pairs with :math:`z=1, y=1, x=1` (level 2), for example. In IBSI mode each pair is also +counted reversed, and the matrix the plain codes are computed from is: + +.. math:: + + \textbf{P}_{(1,1,1)} = \begin{bmatrix} + 2 & 3 & 2 & 1\\ + 3 & 2 & 3 & 2\\ + 2 & 3 & 0 & 4\\ + 1 & 2 & 4 & 2 \end{bmatrix}, + \qquad \sum_{i,j} \textbf{P}(i,j) = 36 + +The other directions give other matrices, with sums of 54 for :math:`(1,1,0)`, 72 for +:math:`(1,0,0)`, 64 for :math:`(0,0,1)`, and so on. The per-direction values differ widely on this ROI. Contrast runs from +1.778 along :math:`(1,-1,0)` to 3.25 along :math:`(0,1,1)`. ``3GLCM_CONTRAST`` is 1.944, the +:math:`(1,1,1)` value, and ``3GLCM_CONTRAST_AVE`` is 2.519, the mean of all 13. Correlation even +changes sign: ``3GLCM_CORRELATION`` is 0.1855 and ``3GLCM_CORRELATION_AVE`` is −0.0387. + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for +this ROI in IBSI mode in the plain column, that is for :math:`\textbf{P}_{(1,1,1)}` above. The test suite runs +this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/glcm_3d_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --dim=3 --features="*3D_GLCM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +The symbols are those of the :doc:`2D GLCM page ` (:math:`N_g`, :math:`\textbf{P}(i,j)`, +:math:`p_{ij}`, the marginals, :math:`p_{x+y}`, :math:`p_{x-y}`, the entropies and +:math:`\varepsilon`), with one addition: + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`\mathbf{d}` + - One of the 13 directions; :math:`\textbf{P}_{\mathbf{d}}` is its co-occurrence matrix and + :math:`F_{\mathbf{d}}` the value of feature :math:`F` computed from it. + * - :math:`i,\; j` + - Grey level of the reference voxel (:math:`i`) and of its neighbour one step along + :math:`\mathbf{d}` (:math:`j`). + +Logarithms use the same fast base-2 approximation as in 2D. On the worked example the entropy +features differ from an exact logarithm by a relative 2 × 10\ :sup:`-4` (joint entropy) to +2 × 10\ :sup:`-3` (information measure of correlation 1). + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - ``--coarseGrayDepth=64`` + - ``Nyxus3D(..., coarse_gray_depth=64)`` + - Number of grey levels. Positive: equal-width bins; negative: PyRadiomics-style bins (command + line only; ``Nyxus3D`` rejects a value below 1). + * - Grey levels, 3D GLCM only + - not available + - ``set_metaparam("3glcm/greydepth=N")`` + - Overrides the grey levels for this family only, with the same sign convention; a negative + value selects PyRadiomics-style bins from Python. On the worked example + ``3glcm/greydepth=8`` gives the same values as ``coarse_gray_depth=8``. + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus3D(..., ibsi=True)`` + - No binning, pairs counted in both orders, background pairs skipped. + * - Distance + - not available + - ``set_metaparam("3glcm/offset=N")`` + - Accepted but has no effect: the distance is always 1. + +``set_metaparam`` also accepts ``3glcm/numang`` and ``3glcm/sparseintensities``; neither changes +the result. ``get_metaparam`` does not read the ``3glcm/...`` values back (see +:ref:`glcm-3d-possible-issues`). ``--glcmAngles`` and ``--glcmOff`` apply to +the 2D family only. All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces a plain column and, except ``3GLCM_HOM2``, an average column: + +- ``3GLCM_CONTRAST``: the value for the direction :math:`(1,1,1)`. Unlike the 2D family, the + column has no direction suffix, and there are no columns for the other 12 directions. +- ``3GLCM_CONTRAST_AVE``: the mean over the 13 directions, + + .. math:: + + F_{\mathrm{AVE}} = \frac{1}{13}\sum_{\mathbf{d}} F_{\mathbf{d}} + +Every ``_AVE`` column is named ``3GLCM__AVE``. ``3GLCM_HOM2`` has no ``_AVE`` column; +``3GLCM_IDM_AVE`` has the same value. The output lists the 30 plain columns first, then the 29 +averages, in the order of the C++ feature set (``3GLCM_VARIANCE_AVE`` comes after +``3GLCM_INFOMEAS2_AVE``, for example), not alphabetically. + + +.. _glcm-3d-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the plain-column value, direction :math:`(1,1,1)`, on the worked example in IBSI +mode. + +.. include:: glcm_3d_index.inc + + +Features +-------- + +Each feature is computed from one directional matrix exactly as its 2D counterpart, with +:math:`\textbf{P}_{\mathbf{d}}` in place of the 2D matrix. The plain code reports +:math:`F_{(1,1,1)}`; the ``_AVE`` code reports the mean over the 13 directions. Ranges are those of +the 2D feature. + +.. _feature-3glcm-acor: + +Autocorrelation +^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_ACOR`` +:Range: :math:`[1, N_g^2]`, squared grey levels + +Defined as :ref:`GLCM_ACOR `. + + +.. _feature-3glcm-asm: + +Angular second moment +^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_ASM`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLCM_ASM `. ``3GLCM_ENERGY`` returns the same value. + + +.. _feature-3glcm-cluprom: + +Cluster prominence +^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_CLUPROM`` +:Range: :math:`[0, \infty)`, grey levels to the fourth power + +Defined as :ref:`GLCM_CLUPROM `, centred on :math:`2\mu_y`. This equals the +IBSI centre :math:`\mu_x + \mu_y` only on a symmetric matrix. + + +.. _feature-3glcm-clushade: + +Cluster shade +^^^^^^^^^^^^^ + +:Code: ``3GLCM_CLUSHADE`` +:Range: :math:`(-\infty, \infty)`, grey levels cubed + +Defined as :ref:`GLCM_CLUSHADE `, centred on :math:`2\mu_y`. + + +.. _feature-3glcm-clutend: + +Cluster tendency +^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_CLUTEND`` +:Range: :math:`[0, \infty)`, squared grey levels + +Defined as :ref:`GLCM_CLUTEND `, centred on :math:`2\mu_y`. +``3GLCM_SUMVARIANCE`` returns the same value. + + +.. _feature-3glcm-contrast: + +Contrast +^^^^^^^^ + +:Code: ``3GLCM_CONTRAST`` +:Range: :math:`[0, (N_g-1)^2]`, squared grey levels + +Defined as :ref:`GLCM_CONTRAST `. It scales with the square of the +grey-level spacing: on the worked example it is 1.944 in IBSI mode and 478.61 with the default +64-level binning. + + +.. _feature-3glcm-correlation: + +Correlation +^^^^^^^^^^^ + +:Code: ``3GLCM_CORRELATION`` +:Range: :math:`[-1, 1]`, dimensionless + +Defined as :ref:`GLCM_CORRELATION `. When a marginal has zero variance, +as on a constant ROI, the result is the ``--noval`` substitute, 0 by default (``-0.0`` in Arrow +and Parquet files; see :ref:`conventions-missing`). + + +.. _feature-3glcm-difave: + +Difference average +^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_DIFAVE`` +:Range: :math:`[0, N_g-1]`, grey levels + +Defined as :ref:`GLCM_DIFAVE `. Equal to ``3GLCM_DIS``. + + +.. _feature-3glcm-difentro: + +Difference entropy +^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_DIFENTRO`` +:Range: :math:`[0, \log_2 N_g]` bits + +Defined as :ref:`GLCM_DIFENTRO `. + + +.. _feature-3glcm-difvar: + +Difference variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_DIFVAR`` +:Range: :math:`[0, \infty)`, squared grey levels + +Defined as :ref:`GLCM_DIFVAR `. + + +.. _feature-3glcm-dis: + +Dissimilarity +^^^^^^^^^^^^^ + +:Code: ``3GLCM_DIS`` +:Range: :math:`[0, N_g-1]`, grey levels + +Defined as :ref:`GLCM_DIS `. Equal to ``3GLCM_DIFAVE``. + + +.. _feature-3glcm-energy: + +Energy +^^^^^^ + +:Code: ``3GLCM_ENERGY`` +:Range: :math:`(0, 1]`, dimensionless + +The same value as ``3GLCM_ASM``; see :ref:`GLCM_ENERGY `. + + +.. _feature-3glcm-entropy: + +Entropy +^^^^^^^ + +:Code: ``3GLCM_ENTROPY`` +:Range: :math:`[0, 2\log_2 N_g]` bits + +The same value as ``3GLCM_JE``; see :ref:`GLCM_ENTROPY `. + + +.. _feature-3glcm-hom1: + +Homogeneity 1 +^^^^^^^^^^^^^ + +:Code: ``3GLCM_HOM1`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLCM_HOM1 `. Equal to ``3GLCM_ID``. + + +.. _feature-3glcm-hom2: + +Homogeneity 2 +^^^^^^^^^^^^^ + +:Code: ``3GLCM_HOM2`` (no ``_AVE`` column) +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLCM_HOM2 `. Equal to ``3GLCM_IDM``; for the direction average +use ``3GLCM_IDM_AVE``. + + +.. _feature-3glcm-id: + +Inverse difference +^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_ID`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLCM_ID `. + + +.. _feature-3glcm-idm: + +Inverse difference moment +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_IDM`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLCM_IDM `. + + +.. _feature-3glcm-idmn: + +Inverse difference moment normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_IDMN`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLCM_IDMN `. + + +.. _feature-3glcm-idn: + +Inverse difference normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_IDN`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLCM_IDN `. + + +.. _feature-3glcm-infomeas1: + +Information measure of correlation 1 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_INFOMEAS1`` +:Range: :math:`[-1, 0]`, dimensionless + +Defined as :ref:`GLCM_INFOMEAS1 `: it divides by :math:`HY`, the entropy of +the neighbour marginal. When :math:`HY = 0`, as on a constant ROI, the result is the ``--noval`` +substitute, 0 by default (``-0.0`` in Arrow and Parquet files; see :ref:`conventions-missing`). + + +.. _feature-3glcm-infomeas2: + +Information measure of correlation 2 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_INFOMEAS2`` +:Range: :math:`[0, 1)`, dimensionless + +Defined as :ref:`GLCM_INFOMEAS2 `. + + +.. _feature-3glcm-iv: + +Inverse variance +^^^^^^^^^^^^^^^^ + +:Code: ``3GLCM_IV`` +:Range: :math:`[0, 1]`, dimensionless + +Defined as :ref:`GLCM_IV `. + + +.. _feature-3glcm-jave: + +Joint average +^^^^^^^^^^^^^ + +:Code: ``3GLCM_JAVE`` +:Range: :math:`[1, N_g]`, grey levels + +Defined as :ref:`GLCM_JAVE `. + + +.. _feature-3glcm-je: + +Joint entropy +^^^^^^^^^^^^^ + +:Code: ``3GLCM_JE`` +:Range: :math:`[0, 2\log_2 N_g]` bits + +Defined as :ref:`GLCM_JE `. ``3GLCM_ENTROPY`` returns the same value. + + +.. _feature-3glcm-jmax: + +Joint maximum +^^^^^^^^^^^^^ + +:Code: ``3GLCM_JMAX`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLCM_JMAX `. + + +.. _feature-3glcm-jvar: + +Joint variance +^^^^^^^^^^^^^^ + +:Code: ``3GLCM_JVAR`` +:Range: :math:`[0, \infty)`, squared grey levels + +Defined as :ref:`GLCM_JVAR `: the neighbour's level :math:`j` around the +reference mean :math:`\mu_x`. It equals ``3GLCM_VARIANCE`` on a symmetric matrix; with the default +counting the worked example gives 257.66 against 334.03. + + +.. _feature-3glcm-sumaverage: + +Sum average +^^^^^^^^^^^ + +:Code: ``3GLCM_SUMAVERAGE`` +:Range: :math:`[2, 2N_g]`, grey levels + +Defined as :ref:`GLCM_SUMAVERAGE `. + + +.. _feature-3glcm-sumentropy: + +Sum entropy +^^^^^^^^^^^ + +:Code: ``3GLCM_SUMENTROPY`` +:Range: :math:`[0, \log_2(2N_g-1)]` bits + +Defined as :ref:`GLCM_SUMENTROPY `. + + +.. _feature-3glcm-sumvariance: + +Sum variance +^^^^^^^^^^^^ + +:Code: ``3GLCM_SUMVARIANCE`` +:Range: :math:`[0, \infty)`, squared grey levels + +Returns the ``3GLCM_CLUTEND`` value, as :ref:`GLCM_SUMVARIANCE ` does in +2D. + + +.. _feature-3glcm-variance: + +Variance +^^^^^^^^ + +:Code: ``3GLCM_VARIANCE`` +:Range: :math:`[0, \infty)`, squared grey levels + +Defined as :ref:`GLCM_VARIANCE `; it matches the IBSI joint variance on +every matrix. + + +.. _glcm-3d-possible-issues: + +Possible issues +--------------- + +Plain codes report one direction + ``3GLCM_`` is the value along :math:`(1,1,1)` only. It depends on the orientation of + the volume, and on the worked example it differs from the 13-direction mean by 23 % for + contrast (1.944 against 2.519) and in sign for correlation (0.1855 against −0.0387). Use the + ``_AVE`` codes for orientation-averaged values. + +Directions without pairs enter the average + A direction whose matrix is empty, because the ROI is too thin along it, reports the + ``--noval`` substitute (0 by default), and that value is averaged with the others. For a ROI + one plane thick, the 9 directions with :math:`d_z \neq 0`, :math:`(1,1,1)` among them, have no + pairs: every plain code is 0, and on the middle plane of the worked example + ``3GLCM_CONTRAST_AVE`` is 0.673, while the mean of the 4 in-plane directions is 2.1875. + +Background counted at grey level 1 with the default binning + With the default (positive) grey depth, voxels of the bounding box outside the ROI are binned to + level 1 and their pairs are counted. Removing three voxels from the worked example's mask gives + ``3GLCM_CONTRAST`` 745.28; counting only pairs of ROI voxels would give 493.2. IBSI mode and + PyRadiomics-style binning skip those pairs (2.0 in IBSI mode on the same mask). The 2D family + skips them in every mode. + +Asymmetric matrix by default + As in 2D, the default counting takes each ordered pair once, so features centred on + :math:`2\mu_y` (``CLUPROM``, ``CLUSHADE``, ``CLUTEND``, ``SUMVARIANCE``), ``JVAR`` and + ``INFOMEAS1`` differ from IBSI and can change when the volume is mirrored. See + :ref:`glcm-possible-issues` on the 2D page. + +Constant ROI + When all voxels share one grey level the matrix is a single diagonal entry and the features take + their limiting values (``3GLCM_ASM`` 1, ``3GLCM_CONTRAST`` 0, ``3GLCM_JAVE`` the level). + ``3GLCM_CORRELATION`` and ``3GLCM_INFOMEAS1`` divide by zero; the result is the ``--noval`` + substitute in CSV files and pandas, and ``-0.0`` in Arrow and Parquet files. This differs from + the 2D family, which returns 0 for every feature in every format, whatever ``--noval`` is, a + known defect (`#511 `__; see :ref:`glcm-possible-issues`). + +``get_metaparam("3glcm/...")`` returns the 2D settings + ``get_metaparam("3glcm/greydepth")`` and ``get_metaparam("3glcm/offset")`` return the 2D GLCM + settings, not the values set with ``3glcm/...``, a known defect (`#513 `__). After + ``set_metaparam("3glcm/greydepth=8")``, ``get_metaparam("3glcm/greydepth")`` returns 64, the + 2D default. The features use the value that was set. + +Grey-level values and indices + As in 2D, with PyRadiomics-style binning some features use positions 1 to :math:`N_g` in the + list of grey levels present, and others the levels themselves. ``DIFAVE``, ``SUMAVERAGE``, + ``IV``, ``JVAR`` and ``DIFVAR`` mix the two as on the 2D page, a known defect + (`#510 `__). + + +Validation +---------- + +The test suite checks every 3D GLCM feature against PyRadiomics 3.0.1 with PyRadiomics-style +binning (20 bins), where both matrices are symmetric. Each plain feature is compared direction by +direction, all 13 values against PyRadiomics' own per-direction values, and each ``_AVE`` code +against PyRadiomics' direction-averaged value. Entropy features are compared at a looser tolerance +because of the approximate logarithm. Values with the default binning are covered by regression +tests only, and IBSI mode only by the worked-example script on this page. + + +References +---------- + +1. Haralick RM, Shanmugam K, Dinstein I. *Textural features for image classification.* IEEE + Transactions on Systems, Man, and Cybernetics. 1973;SMC-3(6):610–621. +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io +3. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. diff --git a/docs/source/Math/f_gldm.rst b/docs/source/Math/f_gldm.rst index 0a80e465c..079671e96 100644 --- a/docs/source/Math/f_gldm.rst +++ b/docs/source/Math/f_gldm.rst @@ -1,117 +1,484 @@ +Grey-level dependence matrix (GLDM) +=================================== + +GLDM features describe how many pixels of the same grey level surround each pixel of an ROI. They +count pixels by grey level and by dependence, and capture whether a texture is homogeneous (many +pixels with many same-level neighbours) or heterogeneous (few). + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``GLDM_*``: 14 features, one value per ROI + * - **Group** + - ``*ALL_GLDM*``; also part of ``*ALL*`` and ``*WHOLESLIDE*`` + * - **Dimensions** + - 2D. The 3D family, ``3GLDM_*``, has its own page, :doc:`f_gldm_3d`. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory on an image that Nyxus reads as one tile. On other + images out-of-core processing of this family raises an error or returns wrong + values, depending on the ROI (see :ref:`large-images-tiles`). + * - **IBSI** + - None. IBSI defines no GLDM; the family follows PyRadiomics. IBSI's neighbouring grey level + dependence matrix with :math:`\alpha = 0` and :math:`\delta = 1` counts the same + dependences; see also the :doc:`NGLDM family `. + +.. tip:: + + To reproduce PyRadiomics values, run in IBSI mode: ``--ibsi=true`` on the command line, + ``ibsi=True`` in Python, and use ``binWidth=1`` and ``gldm_a=0`` in PyRadiomics. The values then + match on ROIs whose intensities start at 1; otherwise PyRadiomics counts levels from the ROI + minimum and every feature that weights pixels by grey level differs. With the default settings + the grey levels are binned from 0, so those features differ as well, and levels that binning + merges change the other features too (see :ref:`gldm-possible-issues`). + + +How the matrix is built +----------------------- + +Nyxus visits every pixel of the ROI and counts its *dependence*: the pixel itself plus each of its +8 neighbours that lies inside the ROI and has exactly the same grey level. A pixel of grey level +:math:`i` and dependence :math:`j` adds 1 to :math:`\textbf{P}(i,j)`. -Texture features / GLDM -======================= +- **Neighbourhood:** the 8 pixels at Chebyshev distance 1. The family has no directions. +- **Dependence:** a neighbour is dependent only when its grey level equals the centre's + (PyRadiomics ``gldm_a = 0``). The centre pixel counts, so :math:`j` runs from 1 to 9. +- **Grey levels:** intensities are first binned into grey levels, as set by ``--coarseGrayDepth`` + (64 by default). The default binning maps intensity :math:`x` to + :math:`\lfloor N x / x_{\max} + 1 \rfloor`, capped at :math:`N`, where :math:`N` is the number of + levels and :math:`x_{\max}` the ROI's maximum intensity. In IBSI mode they are not binned: each + integer intensity is a grey level. +- **Background:** pixels of intensity 0 are neither counted nor counted as neighbours, whatever the + binning. This excludes pixels of the bounding box outside the mask, and also ROI pixels of + intensity 0. -A Gray Level Dependence Matrix (GLDM) quantifies gray level dependencies in an image. -A gray level dependency is defined as a the number of connected voxels within distance :math:`\delta` that are -dependent on the center voxel. -A neighbouring voxel with gray level :math:`j` is considered dependent on center voxel with gray level :math:`i` -if :math:`|i-j|\le\alpha`. In a gray level dependence matrix :math:`\textbf{P}(i,j)` the :math:`(i,j)`-th -element describes the number of times a voxel with gray level :math:`i` with :math:`j` dependent voxels -in its neighbourhood appears in image. -As an example, consider the following 5x5 ROI image having 5 gray levels: +Worked example +^^^^^^^^^^^^^^ + +Take a 5 × 5 ROI that already has five grey levels: .. math:: - \textbf{G} = \begin{bmatrix} - 5 & 2 & 5 & 4 & 4\\ - 3 & 3 & 3 & 1 & 3\\ - 2 & 1 & 1 & 1 & 3\\ - 4 & 2 & 2 & 2 & 3\\ - 3 & 5 & 3 & 3 & 2 \end{bmatrix} + \textbf{G} = \begin{bmatrix} + 5 & 2 & 5 & 4 & 4\\ + 3 & 3 & 3 & 1 & 3\\ + 2 & 1 & 1 & 1 & 3\\ + 4 & 2 & 2 & 2 & 3\\ + 3 & 5 & 3 & 3 & 2 \end{bmatrix} -For :math:`\alpha=0` and :math:`\delta = 1`, the GLDM then becomes: +In IBSI mode (no binning), the matrix is below, with grey levels 1 to 5 as rows and dependences 1 +to 4 as columns; no pixel has a dependence above 4. The 1 in row 2, column 4 of the image has two +neighbours of level 1, below and below-left, so its dependence is 3. .. math:: - \textbf{P} = \begin{bmatrix} - 0 & 1 & 2 & 1 \\ - 1 & 2 & 3 & 0 \\ - 1 & 4 & 4 & 0 \\ - 1 & 2 & 0 & 0 \\ - 3 & 0 & 0 & 0 \end{bmatrix} + \textbf{P} = \begin{bmatrix} + 0 & 1 & 2 & 1\\ + 1 & 2 & 3 & 0\\ + 1 & 4 & 4 & 0\\ + 1 & 2 & 0 & 0\\ + 3 & 0 & 0 & 0 \end{bmatrix}, + \qquad N_z = \sum_{i,j} \textbf{P}(i,j) = 25 + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for this +ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + .. tab-item:: Python -Let: + .. literalinclude:: ../../examples/gldm_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + .. tab-item:: Command line -* :math:`N_g` be the number of discrete intensity values in the image -* :math:`N_d` be the number of discrete dependency sizes in the image -* :math:`N_z` be the number of dependency zones in the image, which is equal to :math:`\sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{\textbf{P}(i,j)}` -* :math:`\textbf{P}(i,j)` be the dependence matrix -* :math:`p(i,j)` be the normalized dependence matrix, defined as :math:`p(i,j) = \frac{\textbf{P}(i,j)}{N_z}` + .. code-block:: console -Small Dependence Emphasis -------------------------- + $ nyxus --features="*ALL_GLDM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv -GLDM_SDE :math:`= \frac{\sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{\frac{\textbf{P}(i,j)}{i^2}}}{N_z}` -Large Dependence Emphasis -------------------------- +Notation +-------- -GLDM_LDE :math:`= \frac{\sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{\textbf{P}(i,j)j^2}}{N_z}` +.. list-table:: + :widths: 25 75 + :header-rows: 1 -Gray Level Non-Uniformity -------------------------- + * - Symbol + - Meaning + * - :math:`N_g` + - Number of grey levels: the maximum intensity in IBSI mode, otherwise the number of distinct + binned levels in the bounding box. + * - :math:`N_d` + - Largest possible dependence, 9. + * - :math:`i,\; j` + - Grey level of a pixel (:math:`i`) and its dependence (:math:`j`). Formulas use the grey + level's value: the intensity in IBSI mode, the binned level otherwise. + * - :math:`\textbf{P}(i,j)` + - Number of ROI pixels with grey level :math:`i` and dependence :math:`j`. + * - :math:`N_z` + - Number of ROI pixels with nonzero intensity, :math:`N_z = \sum_{i,j}\textbf{P}(i,j)`. + * - :math:`p_{ij}` + - Joint probability, :math:`p_{ij} = \textbf{P}(i,j) / N_z`. + * - :math:`s_{i\cdot},\; s_{\cdot j}` + - Pixels per grey level, :math:`s_{i\cdot} = \sum_j \textbf{P}(i,j)`, and pixels per + dependence, :math:`s_{\cdot j} = \sum_i \textbf{P}(i,j)`. + * - :math:`\mu_i,\; \mu_j` + - Mean grey level and mean dependence over all pixels, :math:`\mu_i = \sum_{i,j} i\,p_{ij}` and + :math:`\mu_j = \sum_{i,j} j\,p_{ij}`. + * - :math:`\varepsilon` + - :math:`2.2 \times 10^{-16}`, added inside the logarithm to avoid :math:`\log 0`. -GLDM_GLN :math:`= \frac{\sum^{N_g}_{i=1}\left(\sum^{N_d}_{j=1}{\textbf{P}(i,j)}\right)^2}{N_z}` +Sums over :math:`i` run over the grey levels and sums over :math:`j` from 1 to :math:`N_d`. Empty +rows and columns of :math:`\textbf{P}` add nothing. -Dependence Non-Uniformity -------------------------- +Logarithms are computed with a fast base-2 approximation (see :ref:`conventions-entropy`). +Dependence entropy therefore differs from an exact logarithm by a relative 1.1 × 10\ :sup:`-3` on +the worked example and by up to 1.3 × 10\ :sup:`-3` on the slices of the IBSI digital phantom, +which is why it is validated at a relative tolerance of 2.5 × 10\ :sup:`-3`. -GLDM_DN :math:`= \frac{\sum^{N_d}_{j=1}\left(\sum^{N_g}_{i=1}{\textbf{P}(i,j)}\right)^2}{N_z}` -Dependence Non-Uniformity Normalized ------------------------------------- +Settings +-------- -GLDM_DNN :math:`= \frac{\sum^{N_d}_{j=1}\left(\sum^{N_g}_{i=1}{\textbf{P}(i,j)}\right)^2}{N_z^2}` +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 -Gray Level Variance -------------------- + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - ``--coarseGrayDepth=64`` + - ``Nyxus(..., coarse_gray_depth=64)`` + - Number of grey levels. Positive: equal-width bins from 0 to the ROI maximum; negative: + PyRadiomics-style bins between the ROI minimum and maximum (command line only). + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - No binning: each integer intensity is a grey level. -GLDM_GLV :math:`= \sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{p(i,j)(i - \mu)^2}` +The neighbourhood, the distance and :math:`\alpha` are fixed. No ``set_metaparam`` parameter +changes the 2D matrix; ``3gldm/greydepth`` applies to the 3D family only. All options are listed in +the :ref:`cli-reference`. -where, -:math:`\mu = \sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{ip(i,j)}` +Output columns +-------------- -Dependence Variance -------------------- +Each feature produces one column, named by its code, for example ``GLDM_SDE``. There are no +per-direction columns and no ``_AVE`` columns. -GLDM_DV :math:`= \sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{p(i,j)(j - \mu)^2}` where :math:`\mu = \sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{jp(i,j)}` -Dependence Entropy ------------------- +.. _gldm-index: -GLDM_DE :math:`= -\sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{p(i,j)\log_{2}(p(i,j)+\epsilon)}` +Feature index +------------- -Low Gray Level Emphasis ------------------------ +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. + +.. include:: gldm_index.inc + + +Features +-------- + +.. _feature-gldm-de: + +Dependence entropy +^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_DE`` +:Range: :math:`[0, \log_2(N_g N_d)]` bits + +Measures the randomness of the joint distribution of grey level and dependence. + +.. math:: + + \mathrm{DE} = -\sum_{i}\sum_{j=1}^{N_d} p_{ij}\,\log_2\!\big(p_{ij}+\varepsilon\big) + +**Possible issues.** The logarithm is approximate: on the worked example Nyxus returns 3.3797, +while an exact logarithm, and PyRadiomics, give 3.3835. + + +.. _feature-gldm-dn: + +Dependence non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_DN`` +:Range: :math:`[1, N_z]`, pixels + +Measures how evenly the pixels are spread over the dependences. Low values mean the dependences +are spread evenly. + +.. math:: + + \mathrm{DN} = \frac{1}{N_z}\sum_{j=1}^{N_d} s_{\cdot j}^{\,2} + + +.. _feature-gldm-dnn: + +Dependence non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_DNN`` +:Range: :math:`(0, 1]`, dimensionless + +Dependence non-uniformity divided by the number of pixels once more, so values are comparable +across ROIs of different size. -GLDM_LGLE :math:`= \frac{\sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{\frac{\textbf{P}(i,j)}{i^2}}}{N_z}` +.. math:: + + \mathrm{DNN} = \frac{1}{N_z^{\,2}}\sum_{j=1}^{N_d} s_{\cdot j}^{\,2} + + +.. _feature-gldm-dv: + +Dependence variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_DV`` +:Range: :math:`[0, 16]`, squared dependences + +The variance of the dependence over all pixels. + +.. math:: + + \mathrm{DV} = \sum_{i}\sum_{j=1}^{N_d} (j-\mu_j)^2\, p_{ij} + + +.. _feature-gldm-gln: + +Grey level non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_GLN`` +:Range: :math:`[1, N_z]`, pixels -High Gray Level Emphasis ------------------------- +Measures how evenly the pixels are spread over the grey levels. Low values mean the grey levels are +evenly represented. -GLDM_HGLE :math:`= \frac{\sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{\textbf{P}(i,j)i^2}}{N_z}` +.. math:: + + \mathrm{GLN} = \frac{1}{N_z}\sum_{i} s_{i\cdot}^{\,2} + + +.. _feature-gldm-glv: + +Grey level variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_GLV`` +:Range: :math:`[0, \infty)`, squared grey levels + +The variance of the grey level over all pixels. + +.. math:: + + \mathrm{GLV} = \sum_{i}\sum_{j=1}^{N_d} (i-\mu_i)^2\, p_{ij} + +**Possible issues.** Grey level variance scales with the square of the grey-level spacing. On the +worked example it is 1.44 in IBSI mode and 236.6 with the default 64-level binning, which maps +the levels 1 to 5 to 13, 26, 39, 52 and 64. -Small Dependence Low Gray Level Emphasis ----------------------------------------- -GLDM_SDLGLE :math:`= \frac{\sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{\frac{\textbf{P}(i,j)}{i^2j^2}}}{N_z}` +.. _feature-gldm-hgle: -Small Dependence High Gray Level Emphasis ------------------------------------------ +High grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^ -GLDM_SDHGLE :math:`= \frac{\sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{\frac{\textbf{P}(i,j)i^2}{j^2}}}{N_z}` +:Code: ``GLDM_HGLE`` +:Range: :math:`[1, \infty)`, squared grey levels + +The mean over all pixels of the squared grey level. High values mean bright pixels dominate. + +.. math:: -Large Dependence Low Gray Level Emphasis ----------------------------------------- + \mathrm{HGLE} = \frac{1}{N_z}\sum_{i} i^2\, s_{i\cdot} -GLDM_LDLGLE :math:`= \frac{\sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{\frac{\textbf{P}(i,j)j^2}{i^2}}}{N_z}` +**Possible issues.** Depends on the binning: 9.28 in IBSI mode and 1552.8 with the default +settings on the worked example. -Large Dependence High Gray Level Emphasis ------------------------------------------ -GLDM_LDHGLE :math:`= \frac{\sum^{N_g}_{i=1}\sum^{N_d}_{j=1}{\textbf{P}(i,j)i^2j^2}}{N_z}` +.. _feature-gldm-lde: + +Large dependence emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_LDE`` +:Range: :math:`[1, 81]`, squared dependences + +The mean over all pixels of the squared dependence. High values mean pixels with many same-level +neighbours dominate, as in a homogeneous texture. + +.. math:: + + \mathrm{LDE} = \frac{1}{N_z}\sum_{i}\sum_{j=1}^{N_d} j^2\,\textbf{P}(i,j) + + +.. _feature-gldm-ldhgle: + +Large dependence high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_LDHGLE`` +:Range: :math:`[1, \infty)`, squared grey levels times squared dependences + +The mean over all pixels of the squared dependence times the squared grey level. High values mean +bright pixels with many same-level neighbours dominate. + +.. math:: + + \mathrm{LDHGLE} = \frac{1}{N_z}\sum_{i}\sum_{j=1}^{N_d} i^2 j^2\,\textbf{P}(i,j) + + +.. _feature-gldm-ldlgle: + +Large dependence low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_LDLGLE`` +:Range: :math:`(0, 81]`, squared dependences over squared grey levels + +The mean over all pixels of the squared dependence over the squared grey level. High values mean +dark pixels with many same-level neighbours dominate. + +.. math:: + + \mathrm{LDLGLE} = \frac{1}{N_z}\sum_{i}\sum_{j=1}^{N_d} \frac{j^2\,\textbf{P}(i,j)}{i^2} + + +.. _feature-gldm-lgle: + +Low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_LGLE`` +:Range: :math:`(0, 1]`, inverse squared grey levels + +The mean over all pixels of one over the squared grey level. High values mean dark pixels +dominate. + +.. math:: + + \mathrm{LGLE} = \frac{1}{N_z}\sum_{i} \frac{s_{i\cdot}}{i^2} + +**Possible issues.** Depends on the binning: 0.2723 in IBSI mode and 0.001612 with the default +settings on the worked example. + + +.. _feature-gldm-sde: + +Small dependence emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_SDE`` +:Range: :math:`[1/81, 1]`, inverse squared dependences + +The mean over all pixels of one over the squared dependence. High values mean pixels with few +same-level neighbours dominate, as in a heterogeneous texture. + +.. math:: + + \mathrm{SDE} = \frac{1}{N_z}\sum_{j=1}^{N_d} \frac{s_{\cdot j}}{j^2} + + +.. _feature-gldm-sdhgle: + +Small dependence high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_SDHGLE`` +:Range: :math:`(0, \infty)`, squared grey levels over squared dependences + +The mean over all pixels of the squared grey level over the squared dependence. High values mean +bright pixels with few same-level neighbours dominate. + +.. math:: + + \mathrm{SDHGLE} = \frac{1}{N_z}\sum_{i}\sum_{j=1}^{N_d} \frac{i^2\,\textbf{P}(i,j)}{j^2} + + +.. _feature-gldm-sdlgle: + +Small dependence low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDM_SDLGLE`` +:Range: :math:`(0, 1]`, inverse squared grey levels times inverse squared dependences + +The mean over all pixels of one over the squared dependence times the squared grey level. High +values mean dark pixels with few same-level neighbours dominate. + +.. math:: + + \mathrm{SDLGLE} = \frac{1}{N_z}\sum_{i}\sum_{j=1}^{N_d} \frac{\textbf{P}(i,j)}{i^2 j^2} + + +.. _gldm-possible-issues: + +Possible issues +--------------- + +Grey-level values depend on the binning + ``GLV`` and the six emphasis features that weight pixels by grey level use the grey level's + value. The default binning counts levels from intensity 0, not from the ROI minimum, so a ROI + whose intensities span a narrow range uses only the top levels, and neighbouring intensities + can merge into one level. Adding 1000 to the worked example (intensities 1001 to 1005) puts + every pixel in level 64 with the default settings: small dependence emphasis drops from 0.3725 + to 0.02778, large dependence emphasis rises from 5.56 to 49 and grey level non-uniformity is 25, + the values PyRadiomics gives for a flat ROI. Compare these features only between runs with the same + ``--coarseGrayDepth``. The features that use only dependences and counts (``SDE``, ``LDE``, + ``GLN``, ``DN``, ``DNN``, ``DV``, ``DE``) change only when binning merges or splits levels. + +Pixels of intensity 0 inside the mask + A ROI pixel of intensity 0 is treated as background in every mode: it is not counted and does + not count as a neighbour. PyRadiomics keeps it as a grey level. On the worked example with the + centre pixel set to 0, Nyxus counts 24 pixels and returns small dependence emphasis 0.4282 and + large dependence emphasis 4.583, while PyRadiomics returns 0.4511 and 4.44. + +Pixels outside the mask + Pixels of the bounding box outside the mask are excluded in every mode, including the default + binning, which maps intensity 0 to level 1. On the worked example with three pixels of the + bounding box left out of the mask, the default settings and IBSI mode give the same matrix: + small dependence emphasis is 0.3273 and large dependence emphasis 5.818 in both. + +Constant ROI + When all of a ROI's pixels have one intensity, every GLDM feature returns the ``--noval`` + substitute. PyRadiomics returns values for such a ROI; on a constant 5 × 5 ROI large dependence + emphasis is 49. A ROI whose intensities differ but fall into one binned level is computed, as in + the narrow-range example above. + + +Validation +---------- + +The test suite checks every 2D GLDM feature against PyRadiomics, named in the +:ref:`index `. In IBSI mode Nyxus matches PyRadiomics (identity binning, +``gldm_a = 0``, distance 1) on each slice of the IBSI digital phantom and on their mean to a +relative 10\ :sup:`-9`; dependence entropy agrees to 2.5 × 10\ :sup:`-3` because of the approximate +logarithm. The four-slice means are also compared with IBSI's published consensus values for the +neighbouring grey level dependence matrix, at a relative 10\ :sup:`-2`, the precision those values +are published to; that comparison is a consistency check, not one of the validations in the index. The default binning is covered by regression tests only. + + +References +---------- + +1. Sun C, Wee WG. *Neighboring gray level dependence matrix for texture classification.* Computer + Vision, Graphics, and Image Processing. 1983;23(3):341–352. +2. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. +3. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io diff --git a/docs/source/Math/f_gldm_3d.rst b/docs/source/Math/f_gldm_3d.rst new file mode 100644 index 000000000..87b8385f2 --- /dev/null +++ b/docs/source/Math/f_gldm_3d.rst @@ -0,0 +1,427 @@ +Grey-level dependence matrix, 3D (GLDM 3D) +========================================== + +3D GLDM features describe how many voxels of the same grey level surround each voxel of an ROI. +They are the volumetric form of the :doc:`2D GLDM family `: the matrix counts voxels by +grey level and by dependence over the full 26-voxel neighbourhood, and the fourteen features are +the same contractions of it. This page covers what is specific to 3D; the formulas are defined on +the 2D page. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``3GLDM_*``: 14 features, one value per ROI + * - **Group** + - ``*3D_GLDM*``; also part of ``*3D_ALL_TEXTURE*`` and ``*3D_ALL*`` + * - **Dimensions** + - 3D. The 2D family, ``GLDM_*``, is defined on :doc:`its own page `. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory. + When one read of the file returns the whole volume (NIfTI, or OME-Zarr chunked over all of + Z), an oversized ROI stops the run with an error (see :doc:`/large_images`). + * - **IBSI** + - None. IBSI defines no GLDM; the family follows PyRadiomics. IBSI's neighbouring grey level + dependence matrix with :math:`\alpha = 0` and :math:`\delta = 1` counts the same + dependences; see also the :doc:`NGLDM family `. + +.. tip:: + + By default the 3D family does not bin intensities: each integer intensity is a grey level, in + IBSI mode and out of it, and ``--coarseGrayDepth`` has no effect. These values match + PyRadiomics with ``binWidth=1`` and ``gldm_a=0`` on volumes whose intensities start at 1. To + bin, set the ``3gldm/greydepth`` metaparameter (see :ref:`gldm-3d-settings`). + + +How the matrix is built +----------------------- + +Nyxus visits every voxel of the ROI's bounding box that carries a nonzero grey level and counts its +*dependence*: the voxel itself plus each of its 26 neighbours that has exactly the same grey level. +A voxel of grey level :math:`i` and dependence :math:`j` adds 1 to :math:`\textbf{P}(i,j)`. + +- **Neighbourhood:** the 26 voxels at Chebyshev distance 1: 8 in the voxel's own slice and 9 in + each of the slices above and below. Neighbours outside the bounding box do not exist. The family + has no directions and no per-direction values. +- **Dependence:** a neighbour is dependent only when its grey level equals the centre's + (:math:`\alpha = 0`, PyRadiomics ``gldm_a = 0``). The centre voxel counts, so :math:`j` runs from + 1 to 27. A voxel with no dependent neighbour is counted with :math:`j = 1`. +- **Grey levels:** set by ``3gldm/greydepth``, 0 by default. + + - 0, or IBSI mode: no binning. Each integer intensity is a grey level, and the matrix has rows + 1 to the ROI maximum. + - :math:`N > 0`: intensity :math:`x` maps to :math:`\min(\lfloor N x / x_{\max} + 1 \rfloor, N)`, + where :math:`x_{\max}` is the ROI's maximum intensity. + - :math:`N < 0`: :math:`|N|` equal-width bins between the ROI minimum and maximum. Intensity + :math:`x > 0` maps to :math:`\min(\lfloor (x - x_{\min})/w + 1 \rfloor, |N|)` with + :math:`w = (x_{\max} - x_{\min})/|N|`. + + Formulas use the grey level's value: the intensity without binning, the level otherwise. +- **Background:** the bounding box holds 0 for every voxel outside the ROI's mask, including + voxels of other ROIs. A voxel whose grey level is the background level, 0 (or 1 when + :math:`N > 0`), is not counted, and no counted voxel has that level, so background never counts + as a neighbour. The mask itself is not consulted: a ROI voxel of intensity 0 is treated as + background too (see :ref:`gldm-3d-possible-issues`). + + +Worked example +^^^^^^^^^^^^^^ + +Take a 3 × 4 × 4 ROI (slices, rows, columns) that already has four grey levels, inside a +3 × 6 × 6 volume of zeros: + +.. math:: + + \textbf{G}_1 = \begin{bmatrix} + 1 & 1 & 2 & 3\\ + 1 & 2 & 2 & 3\\ + 4 & 4 & 2 & 1\\ + 4 & 3 & 3 & 1 \end{bmatrix},\quad + \textbf{G}_2 = \begin{bmatrix} + 1 & 2 & 2 & 3\\ + 1 & 1 & 3 & 3\\ + 4 & 2 & 2 & 1\\ + 4 & 4 & 3 & 3 \end{bmatrix},\quad + \textbf{G}_3 = \begin{bmatrix} + 2 & 2 & 3 & 3\\ + 1 & 1 & 3 & 4\\ + 4 & 4 & 1 & 1\\ + 2 & 4 & 4 & 3 \end{bmatrix} + +With the default settings, or in IBSI mode, the matrix is below, with grey levels 1 to 4 as rows +and dependences 1 to 10 as columns; no voxel has a dependence above 10. The 4 in slice 2, row 4, +column 2 has nine neighbours of level 4 (three in slice 1, two in its own slice, four in slice 3), +so its dependence is 10. The 4 in slice 3, row 2, column 4 has none, so its dependence is 1. + +.. math:: + + \textbf{P} = \begin{bmatrix} + 0 & 0 & 3 & 0 & 3 & 4 & 0 & 2 & 1 & 0\\ + 0 & 1 & 1 & 1 & 3 & 2 & 1 & 2 & 0 & 0\\ + 0 & 0 & 2 & 2 & 3 & 3 & 0 & 3 & 0 & 0\\ + 1 & 0 & 0 & 1 & 0 & 4 & 2 & 0 & 2 & 1 \end{bmatrix}, + \qquad N_z = \sum_{i,j} \textbf{P}(i,j) = 48 + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for +this ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/gldm_3d_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --dim=3 --features="*3D_GLDM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +The symbols are those of the :doc:`2D page `, with voxels in place of pixels: + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_g` + - Number of grey levels: the ROI maximum without binning, otherwise the number of distinct + binned levels in the bounding box. + * - :math:`N_d` + - Largest possible dependence, 27. + * - :math:`i,\; j` + - Grey level of a voxel (:math:`i`) and its dependence (:math:`j`). + * - :math:`\textbf{P}(i,j)` + - Number of ROI voxels with grey level :math:`i` and dependence :math:`j`. + * - :math:`N_z` + - Number of counted voxels, :math:`N_z = \sum_{i,j}\textbf{P}(i,j)`: every ROI voxel that is + not at the background level. + * - :math:`p_{ij}` + - Joint probability, :math:`p_{ij} = \textbf{P}(i,j) / N_z`. + * - :math:`s_{i\cdot},\; s_{\cdot j}` + - Voxels per grey level and voxels per dependence. + * - :math:`\mu_i,\; \mu_j` + - Mean grey level and mean dependence over all counted voxels. + +Sums over :math:`j` run from 1 to :math:`N_d`. Empty rows and columns of :math:`\textbf{P}` add +nothing, so the levels between the ROI minimum and maximum that no voxel has do not change any +feature. + +Logarithms are computed with the same fast base-2 approximation as in 2D. Dependence entropy on the +worked example is 4.3631, where an exact logarithm, and PyRadiomics, give 4.3656 (relative +5.7 × 10\ :sup:`-4`). + + +.. _gldm-3d-settings: + +Settings +-------- + +.. list-table:: + :widths: 16 22 32 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - none + - ``Nyxus3D.set_metaparam("3gldm/greydepth=0")`` + - 0 (default): no binning. Positive :math:`N`: equal-width levels from 0 to the ROI maximum. + Negative :math:`N`: :math:`|N|` PyRadiomics-style bins between the ROI minimum and + maximum. Read back with ``Nyxus3D.get_metaparam("3gldm/greydepth")``. + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus3D(..., ibsi=True)`` + - No binning, whatever ``3gldm/greydepth`` holds. + +``--coarseGrayDepth`` and the ``coarse_gray_depth`` argument of ``Nyxus3D`` do not affect this +family. The neighbourhood, the distance and :math:`\alpha` are fixed. The metaparameter applies to +in-memory and out-of-core ROIs alike. All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named by its code, for example ``3GLDM_SDE``. There are no +per-direction columns and no ``_AVE`` columns. + + +.. _gldm-3d-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with the tolerance; see the :doc:`/featurelist` for what the categories mean. *Example* is +the value on the worked example. + +.. include:: gldm_3d_index.inc + + +Features +-------- + +Every 3D feature is the 2D formula with the 3D matrix :math:`\textbf{P}` substituted and +:math:`N_d = 27`. Only the ranges change where they depend on :math:`N_d`. + +.. _feature-3gldm-de: + +Dependence entropy (3D) +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_DE`` +:Range: :math:`[0, \log_2(27 N_g)]` bits + +Defined as :ref:`GLDM_DE `, with the same approximate logarithm. + +.. _feature-3gldm-dn: + +Dependence non-uniformity (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_DN`` +:Range: :math:`[1, N_z]`, voxels + +Defined as :ref:`GLDM_DN `. + +.. _feature-3gldm-dnn: + +Dependence non-uniformity normalized (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_DNN`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLDM_DNN `. + +.. _feature-3gldm-dv: + +Dependence variance (3D) +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_DV`` +:Range: :math:`[0, 169]`, squared dependences + +Defined as :ref:`GLDM_DV `. + +.. _feature-3gldm-gln: + +Grey level non-uniformity (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_GLN`` +:Range: :math:`[1, N_z]`, voxels + +Defined as :ref:`GLDM_GLN `. + +.. _feature-3gldm-glv: + +Grey level variance (3D) +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_GLV`` +:Range: :math:`[0, \infty)`, squared grey levels + +Defined as :ref:`GLDM_GLV `. Without binning it is the variance of the raw +intensities over the counted voxels. + +.. _feature-3gldm-hgle: + +High grey level emphasis (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_HGLE`` +:Range: :math:`[1, \infty)`, squared grey levels + +Defined as :ref:`GLDM_HGLE `. + +.. _feature-3gldm-lde: + +Large dependence emphasis (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_LDE`` +:Range: :math:`[1, 729]`, squared dependences + +Defined as :ref:`GLDM_LDE `. + +.. _feature-3gldm-ldhgle: + +Large dependence high grey level emphasis (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_LDHGLE`` +:Range: :math:`[1, \infty)`, squared grey levels times squared dependences + +Defined as :ref:`GLDM_LDHGLE `. + +.. _feature-3gldm-ldlgle: + +Large dependence low grey level emphasis (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_LDLGLE`` +:Range: :math:`(0, 729]`, squared dependences over squared grey levels + +Defined as :ref:`GLDM_LDLGLE `. + +.. _feature-3gldm-lgle: + +Low grey level emphasis (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_LGLE`` +:Range: :math:`(0, 1]`, inverse squared grey levels + +Defined as :ref:`GLDM_LGLE `. + +.. _feature-3gldm-sde: + +Small dependence emphasis (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_SDE`` +:Range: :math:`[1/729, 1]`, inverse squared dependences + +Defined as :ref:`GLDM_SDE `. + +.. _feature-3gldm-sdhgle: + +Small dependence high grey level emphasis (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_SDHGLE`` +:Range: :math:`(0, \infty)`, squared grey levels over squared dependences + +Defined as :ref:`GLDM_SDHGLE `. + +.. _feature-3gldm-sdlgle: + +Small dependence low grey level emphasis (3D) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDM_SDLGLE`` +:Range: :math:`(0, 1]`, inverse squared grey levels times inverse squared dependences + +Defined as :ref:`GLDM_SDLGLE `. + + +.. _gldm-3d-possible-issues: + +Possible issues +--------------- + +Positive ``3gldm/greydepth`` drops the dimmest voxels + With :math:`N > 0`, intensity 0 and every intensity below :math:`x_{\max}/N` share level 1, + and level 1 is treated as background: those ROI voxels are neither counted nor counted as + neighbours. On the worked example with ``3gldm/greydepth=2`` the 13 voxels of intensity 1 + leave the matrix, :math:`N_z` drops from 48 to 35, a single level remains, grey level variance + is 0, and small dependence emphasis is 0.01297 instead of 0.06470. With + ``3gldm/greydepth=1`` every voxel is dropped and every feature returns the ``--noval`` + substitute. Zero or negative values of the metaparameter do not drop ROI voxels. + +Grey-level values follow the raw intensities by default + Without binning, ``GLV`` and the six emphasis features that weight voxels by grey level scale + with the intensities. Multiplying the worked example by 250 leaves every dependence feature + unchanged and raises high grey level emphasis from 7.292 to 455729; with + ``3gldm/greydepth=64`` it is 1916.8. Compare these features only between volumes of the same + intensity scale and the same ``3gldm/greydepth``. + +Voxels of intensity 0 inside the mask + A ROI voxel of intensity 0 is treated as background in every mode: it is not counted and does + not count as a neighbour. PyRadiomics keeps it as a grey level. On the worked example with the + voxel in slice 2, row 2, column 2 set to 0, Nyxus counts 47 voxels and returns small dependence + emphasis 0.06801 and large dependence emphasis 33.64, while PyRadiomics (``binWidth=1``) counts + 48 and returns 0.08743 and 32.96. + +Voxels outside the mask + Voxels of the bounding box outside the mask, including voxels of other ROIs, are excluded in + every mode. On the worked example with three voxels left out of the mask, small dependence + emphasis is 0.07001 and large dependence emphasis 32.87, and the values are the same when those + three voxels belong to a second ROI. + +Isolated voxels + A ROI voxel with no ROI neighbour of its own level is still counted, with dependence 1. A ROI of + two separate voxels returns small and large dependence emphasis 1. + +Constant ROI + When all of a ROI's voxels have one intensity, every 3D GLDM feature returns the ``--noval`` + substitute. PyRadiomics returns values for such a ROI. + + +Validation +---------- + +The test suite checks every 3D GLDM feature against PyRadiomics, named in the +:ref:`index `. On a 3D phantom volume with 20 PyRadiomics-style bins +(``3gldm/greydepth=-20`` against PyRadiomics ``binCount=20``, ``gldm_a=0``, distance 1), the +thirteen features without a logarithm agree to an absolute 10\ :sup:`-9` (measured +7 × 10\ :sup:`-15`), and the test also checks every cell of the dependence matrix. Dependence +entropy agrees to an absolute 4 × 10\ :sup:`-3` because of the approximate logarithm. The +default (no binning) and the positive ``3gldm/greydepth`` binning are covered by regression tests +only, and so is the constant-ROI result. + + +References +---------- + +1. Sun C, Wee WG. *Neighboring gray level dependence matrix for texture classification.* Computer + Vision, Graphics, and Image Processing. 1983;23(3):341–352. +2. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. +3. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io diff --git a/docs/source/Math/f_gldzm.rst b/docs/source/Math/f_gldzm.rst index 89d9c1964..02ff8227a 100644 --- a/docs/source/Math/f_gldzm.rst +++ b/docs/source/Math/f_gldzm.rst @@ -1,186 +1,605 @@ +Grey-level distance-zone matrix (GLDZM) +======================================= + +GLDZM features describe the zones of an ROI, groups of connected pixels that share one grey level, +by how far each zone lies from the edge of the ROI. The matrix counts zones by grey level and by +distance, regardless of direction. Zones close to the edge give small distances; large uniform +areas deep inside the ROI give large ones. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``GLDZM_*``: 18 features, one value per ROI + * - **Group** + - ``*ALL_GLDZM*``; also part of ``*ALL*`` (not of ``*WHOLESLIDE*``) + * - **Dimensions** + - 2D. The 3D family, ``3GLDZM_*``, has its own page, :doc:`f_gldzm_3d`. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory on an image that Nyxus reads as one tile. On other + images out-of-core processing of this family raises an error or returns wrong + values, depending on the ROI (see :ref:`large-images-tiles`). + * - **IBSI** + - 16 of the 18 features have an IBSI identifier. Grey level mean and zone distance mean have + none. + +.. tip:: + + To reproduce IBSI or MIRP values, run in IBSI mode: ``--ibsi=true`` on the command line, + ``ibsi=True`` in Python. With the default settings the pixels of the bounding box that lie + outside the mask become grey level 1 and form zones, which ROI pixels of level 1 can join, and + only the edges of the bounding box count as the ROI edge (see :ref:`gldzm-possible-issues`). In + IBSI mode a pixel next to background inside the bounding box gets distance 2 where IBSI gives 1. + + +How the matrix is built +----------------------- + +Nyxus scans the ROI's bounding box row by row, from the top left. Each pixel that is not yet in a +zone starts a new zone, which grows through every pixel of the same grey level that touches it at +an edge or a corner. Each pixel of the zone has a distance to the edge, and the zone's distance is +the smallest of them. :math:`\textbf{P}(i,j)` counts the zones of grey level :math:`g_i` and +distance :math:`j`. + +- **Connectivity:** 8-connected, as IBSI defines for 2D. Two pixels of one grey level that touch + only at a corner belong to the same zone. +- **Distance:** from a pixel, Nyxus walks left, right, up and down along its row and column. Each + walk stops at the first pixel of grey level 0 or at the last pixel of the bounding box. The + pixel's distance is the shortest walk plus 1, and at least 1. A pixel on the edge of the bounding + box has distance 1, the next ring inwards distance 2, and so on. A pixel next to a pixel of grey + level 0 has distance 2 (see :ref:`gldzm-possible-issues`). +- **Directions:** none. One matrix is built per ROI, and each feature has one value. +- **Grey levels:** intensities are first binned into :math:`N` grey levels, as set by + ``--coarseGrayDepth`` (64 by default). A pixel of intensity :math:`x` gets level + :math:`\lfloor N x / x_{\max} \rfloor + 1`, capped at :math:`N`, where :math:`x_{\max}` is + the ROI's largest intensity. In IBSI mode they are not binned: each integer intensity is a grey + level. +- **Background:** in IBSI mode, pixels of intensity 0 belong to no zone, whether they lie outside + the mask or inside it, and the distance walks stop at them. With the default binning, intensity + 0 becomes grey level 1, so the bounding-box pixels outside the mask form zones, and the walks run + through them to the edges of the bounding box. + + +Worked example +^^^^^^^^^^^^^^ + +Take a 5 × 5 ROI that already has five grey levels: -Texture features / GLDZM -======================== - -The Grey Level Distance Zone Matrix (GLDZM) indicates the number of times each grey level's zones occur within a distance from the zone to the ROI border. +.. math:: -A zone is a continuous set of pixels of same intensity (or "grey level"). + \textbf{G} = \begin{bmatrix} + 1 & 2 & 5 & 2 & 3\\ + 3 & 2 & 1 & 3 & 1\\ + 1 & 3 & 5 & 5 & 2\\ + 1 & 1 & 1 & 1 & 2\\ + 1 & 2 & 4 & 3 & 5 \end{bmatrix} -The continuity is meant as a 4-connected neighbourhood. For example, the following intensity image matrix :math:`I` of 2 non-zero intensities 1 and 3 contains 4 zones of intensity 3 -- 1 single-pixel, 1 2-pixel, 1 3-pixel, and 2 4-pixel zones. +The ROI fills its bounding box, so the outer ring of pixels has distance 1, the next ring distance 2 +and the centre pixel distance 3. In IBSI mode (no binning) the matrix is below; rows are grey +levels 1 to 5 and columns are distances 1 and 2. The 1 in the middle of row 2 touches no other 1, +so it is a zone of its own at distance 2. The two 5s in row 3 form one zone; the centre pixel has +distance 3 and its neighbour distance 2, so the zone has distance 2. .. math:: - I = \begin{bmatrix} - 0 & 0 & 0 & 1 & 1 & 1 & 0\\ - 0 & 0 & \fbox{3} & 1 & \fbox{3} & 1 & 0\\ - 1 & \fbox{3} & 1 & 1 & \fbox{3} & 1 & 0\\ - 1 & \fbox{3} & 1 & 1 & \fbox{3} & \fbox{3} & 1\\ - \fbox{3} & 1 & \fbox{3} & \fbox{3} & 1 & 1 & 1\\ - \fbox{3} & 1 & \fbox{3} & \fbox{3} & 1 & 1 & 0\\ - \fbox{3} & 1 & 0 & 0 & 0 & 1 & 0 - \end{bmatrix} + \textbf{P} = \begin{bmatrix} + 3 & 1\\ + 4 & 0\\ + 3 & 0\\ + 1 & 0\\ + 2 & 1 \end{bmatrix}, + \qquad N_s = \sum_{i,j} \textbf{P}(i,j) = 15, \qquad N_v = 25 + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for this +ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/gldzm_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end -The zone's distance is the minimum of its each pixel's distance to the ROI or image border measured as the number of pixel boundaries to the first off-ROI or off-image pixel. + .. tab-item:: Command line -Considering the following ROI image + .. code-block:: console -.. math:: - R = \begin{bmatrix} - 0 & 0 & 0 & 1 & 1 & 1 & 0\\ - 0 & 0 & 1 & 1 & 1 & 1 & 0\\ - 1 & 1 & 1 & 1 & 1 & 1 & 0\\ - 1 & 1 & 1 & 1 & 1 & 1 & 1\\ - 1 & 1 & 1 & 1 & 1 & 1 & 1\\ - 1 & 1 & 1 & 1 & 1 & 1 & 0\\ - 1 & 1 & 0 & 0 & 0 & 1 & 0 - \end{bmatrix} + $ nyxus --features="*ALL_GLDZM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv -the distances of zons of intensity 3, ignoring pixels of other non-zero intensities (shown as :math:`*`), in the masked image (whose off-ROI pixels are shown as :math:`\times`) are -.. math:: - D = \begin{bmatrix} - \times & \times & \times & * & * & * & \times \\ - \times & \times & \fbox{2} & * & \fbox{2} & * & \times \\ - * & \fbox{2} & * & * & \fbox{2} & * & \times \\ - * & \fbox{2} & * & * & \fbox{2} & \fbox{2} & * \\ - \fbox{1} & * & \fbox{2} & \fbox{2} & * & * & * \\ - \fbox{1} & * & \fbox{2} & \fbox{2} & * & * & \times \\ - \fbox{1} & * & \times & \times & \times & * & \times - \end{bmatrix} +Notation +-------- +.. list-table:: + :widths: 25 75 + :header-rows: 1 -The following example is an image having 5 discrete grey values masked with the above ROI mask :math:`R` : + * - Symbol + - Meaning + * - :math:`N_g` + - Number of rows of :math:`\textbf{P}`. With binning, the number of grey levels present in the + bounding box; in IBSI mode, the ROI's largest intensity. + * - :math:`g_i` + - Grey level of row :math:`i`. With binning, the :math:`i`-th smallest level present; in IBSI + mode, :math:`g_i = i`. + * - :math:`N_d` + - Number of columns of :math:`\textbf{P}`: the largest zone distance. + * - :math:`\textbf{P}(i,j)` + - Number of zones of grey level :math:`g_i` and distance :math:`j`. + * - :math:`N_s` + - Number of zones, :math:`N_s = \sum_{i,j}\textbf{P}(i,j)`. + * - :math:`N_v` + - Number of ROI pixels: the pixels inside the mask, including those of intensity 0. + * - :math:`p(i,j)` + - Zone probability, :math:`p(i,j) = \textbf{P}(i,j) / N_s`. + * - :math:`m_i,\; m_j` + - Zones per grey level, :math:`m_i = \sum_j \textbf{P}(i,j)`, and per distance, + :math:`m_j = \sum_i \textbf{P}(i,j)`. + * - :math:`\mu_g,\; \mu_d` + - Mean grey level and mean distance over zones, :math:`\mu_g = \sum_{i,j} g_i\,p(i,j)` and + :math:`\mu_d = \sum_{i,j} j\,p(i,j)`. + * - :math:`\varepsilon` + - :math:`2.2 \times 10^{-16}`, added inside the logarithm. -.. math:: +Sums over :math:`i` run from 1 to :math:`N_g` and over :math:`j` from 1 to :math:`N_d`. Empty rows +and columns add nothing. Every feature uses the grey level :math:`g_i`, never the row position. - I_2 = \begin{bmatrix} - \times & \times & \times & 4 & 4 & 4 & \times \\ - \times & \times & 3 & 1 & 3 & 4 & \times \\ - 2 & 1 & 1 & 1 & 3 & 2 & \times \\ - 4 & 4 & 2 & 2 & 3 & 3 & 1 \\ - 3 & 5 & 3 & 3 & 2 & 1 & 1 \\ - 3 & 5 & 3 & 3 & 2 & 4 & \times \\ - 3 & 1 & \times & \times & \times & 4 & \times - \end{bmatrix} +Zone distance entropy uses the exact base-2 logarithm in double precision, not the fast +approximation of the GLCM, GLRLM, GLSZM and GLDM entropies (see :ref:`conventions-entropy`). -Its distance map :math:`D_2` is: +Settings +-------- -.. math:: +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 - D_2 = \begin{bmatrix} - \times & \times & \times & 1 & 1 & 1 & \times \\ - \times & \times & 1 & 2 & 2 & 1 & \times \\ - 1 & 1 & 2 & 3 & 2 & 1 & \times \\ - 1 & 2 & 3 & 3 & 3 & 2 & 1 \\ - 1 & 2 & 2 & 2 & 2 & 2 & 1 \\ - 1 & 2 & 1 & 1 & 1 & 1 & \times \\ - 1 & 1 & \times & \times & \times & 1 & \times - \end{bmatrix} + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - ``--coarseGrayDepth=64`` + - ``Nyxus(..., coarse_gray_depth=64)`` + - Number of grey levels. Positive: equal-width bins from 0 to the ROI maximum, with background + as level 1; negative: PyRadiomics-style bins over the ROI range (command line only, and only + for ROIs with no pixel of intensity 0 in the bounding box; see + :ref:`gldzm-possible-issues`). + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - No binning; pixels of intensity 0 are excluded from zones and stop the distance walks. -In a grey level distance zone matrix (GLDZM) :math:`M`, the element :math:`(x,d)` describes the number of zones in an image -with grey level :math:`x` located at distance :math:`d` from the edge of the ROI or image border. +The 2D family has no direction, distance or metaparameter settings, and zone connectivity is fixed. +No ``set_metaparam`` parameter applies to ``GLDZM_*`` or ``3GLDZM_*``. All options are listed in the +:ref:`cli-reference`. -Applied to the example, the GLDZM :math:`M(I_2)` of image :math:`I_2` having distance matrix :math:`D_2`, is: -.. math:: +Output columns +-------------- - M(I_2)=\begin{bmatrix} - 3 & 0 & 0\\ - 3 & 0 & 1\\ - 3 & 1 & 0\\ - 2 & 0 & 0\\ - 1 & 1 & 0\end{bmatrix} +Each feature produces one column, named with its code: ``GLDZM_SDE``, ``GLDZM_ZP`` and so on. There +are no per-direction columns and no ``_AVE`` columns. -Let -:math:`m(x,d)` be an element of the distance zone matrix corresponding to grey level :math:`x` and zone distance :math:`d` , -:math:`N_g` -- the number of grey levels , +.. _gldzm-index: -:math:`N_d` -- the maximum zone distance, and +Feature index +------------- -:math:`N_s` -- the number of zones of any non-zero intensity. +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. -:math:`p(x,d)` be an element of the normalized distance zone matrix expressing the relative probability of element :math:`(x,d)`, defined as +.. include:: gldzm_index.inc -.. math:: - p_{x,d} = \frac{m_{x,d}}{N_s} . -:math:`N_v` is the number of ROI image pixels. +Features +-------- -In addition, the marginal totals +.. _feature-gldzm-glm: -.. math:: - m_{x,\cdot} = m_x = \sum_d m_{x,d} +Grey level mean +^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_GLM`` +:Range: :math:`[g_1, g_{N_g}]`, grey levels -represent the total of all zones with a given intensity :math:`x`, and +The mean grey level over all zones, each zone counted once whatever its size. IBSI defines it only +as the mean :math:`\mu` inside grey level variance. .. math:: - m_{\cdot, d} = m_d = \sum_x m_{x,d} -represent the total of all zones with a given distance :math:`d`. + \mathrm{GLM} = \mu_g = \sum_{i=1}^{N_g}\sum_{j=1}^{N_d} g_i\, p(i,j) -The following features are then defined: +**Possible issues.** Depends on the binning: 2.667 in IBSI mode and 34.47 with the default +64-level binning on the worked example. -.. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_SDE}} {\textup{Small Distance Emphasis}} = \frac{1}{N_s} \sum_d \frac{m_d}{d^2} -.. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_LDE}} {\textup{Large Distance Emphasis}} = \frac{1}{N_s} \sum_d d^2 m_d +.. _feature-gldzm-glnu: + +Grey level non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_GLNU`` +:Range: :math:`[1, N_s]`, zones + +Measures how evenly the zones are spread over the grey levels. Low values mean the zones are spread +evenly. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_LGLE}} {\textup{Low Grey Level Emphasis}} = \frac{1}{N_s} \sum_x \frac{m_x}{x^2} + + \mathrm{GLNU} = \frac{1}{N_s}\sum_{i=1}^{N_g} m_i^{\,2} + +**Relations.** It uses only the zones per grey level, not their distances, so it equals GLSZM's +:ref:`grey level non-uniformity ` whenever both families find the same zones: +3.4 for both on the worked example. + + +.. _feature-gldzm-glnun: + +Grey level non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_GLNUN`` +:Range: :math:`(0, 1]`, dimensionless + +Grey level non-uniformity divided by the number of zones once more, so values are comparable across +ROIs. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_HGLE}} {\textup{High Grey Level Emphasis}} = \frac{1}{N_s} \sum_x x^2 m_x + + \mathrm{GLNUN} = \frac{1}{N_s^{\,2}}\sum_{i=1}^{N_g} m_i^{\,2} + + +.. _feature-gldzm-glv: + +Grey level variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_GLV`` +:Range: :math:`[0, \infty)`, squared grey levels + +The variance of the grey level over all zones, each zone counted once whatever its size. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_SDLGLE}} {\textup{Small Distance Low Grey Level Emphasis}} = \frac{1}{N_s} \sum_x \sum_d \frac{ m_{x,d}}{x^2 d^2} + + \mathrm{GLV} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_d} \big(g_i - \mu_g\big)^2\, p(i,j) + +**Possible issues.** The value scales with the square of the grey-level spacing. On the worked +example it is 2.089 in IBSI mode and 341.05 with the default 64-level binning, which maps the 5 +levels to 13, 26, 39, 52 and 64. Compare it only between runs with the same ``--coarseGrayDepth``. + + +.. _feature-gldzm-hglze: + +High grey level zone emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_HGLZE`` +:Range: :math:`[g_1^{\,2}, g_{N_g}^{\,2}]`, squared grey levels + +The mean over all zones of the squared grey level. High when bright zones dominate. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_SDHGLE}} {\textup{Small Distance High Grey Level Emphasis}} = \frac{1}{N_s} \sum_x \sum_d \frac{x^2 m_{x,d}}{d^2} + + \mathrm{HGLZE} = \frac{1}{N_s}\sum_{i=1}^{N_g} g_i^{\,2}\, m_i + +**Possible issues.** Depends on the binning: 9.2 in IBSI mode and 1529 with the default settings on +the worked example. + + +.. _feature-gldzm-lde: + +Large distance emphasis +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_LDE`` +:Range: :math:`[1, N_d^{\,2}]`, squared pixels + +The mean over all zones of the squared zone distance. High when zones lie deep inside the ROI. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_LDLGLE}} {\textup{Large Distance Low Grey Level Emphasis}} = \frac{1}{N_s} \sum_x \sum_d \frac{d^2 m_{x,d}}{x^2} + + \mathrm{LDE} = \frac{1}{N_s}\sum_{j=1}^{N_d} j^2\, m_j + +**Possible issues.** Pixels next to background inside the bounding box get a distance one too large +(see :ref:`gldzm-possible-issues`). + + +.. _feature-gldzm-ldhgle: + +Large distance high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_LDHGLE`` +:Range: :math:`[g_1^{\,2}, g_{N_g}^{\,2} N_d^{\,2}]`, squared grey levels times squared pixels + +The mean over all zones of the squared grey level times the squared zone distance. High for bright +zones deep inside the ROI. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_LDHGLE}} {\textup{Large Distance High Grey Level Emphasis}} = \frac{1}{N_s} \sum_x \sum_d \x^2 d^2 m_{x,d} + + \mathrm{LDHGLE} = \frac{1}{N_s}\sum_{i=1}^{N_g}\sum_{j=1}^{N_d} g_i^{\,2}\, j^2\, \textbf{P}(i,j) + + +.. _feature-gldzm-ldlgle: + +Large distance low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_LDLGLE`` +:Range: :math:`(0, N_d^{\,2}]`, squared pixels over squared grey levels + +The mean over all zones of the squared zone distance over the squared grey level. High for dark +zones deep inside the ROI. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_GLNU}} {\textup{Grey Level Non-Uniformity}} = \frac{1}{N_s} \sum_x m_x^2 + + \mathrm{LDLGLE} = \frac{1}{N_s}\sum_{i=1}^{N_g}\sum_{j=1}^{N_d} \frac{j^2\, \textbf{P}(i,j)}{g_i^{\,2}} + + +.. _feature-gldzm-lglze: + +Low grey level zone emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_LGLZE`` +:Range: :math:`[1/g_{N_g}^{\,2}, 1/g_1^{\,2}]`, inverse squared grey levels + +The mean over all zones of one over the squared grey level. High when dark zones dominate. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_GLNUN}} {\textup{Grey Level Non-Uniformity Normalized}} = \frac{1}{N_s^2} \sum_x m_x^2 + + \mathrm{LGLZE} = \frac{1}{N_s}\sum_{i=1}^{N_g} \frac{m_i}{g_i^{\,2}} + +**Possible issues.** Depends on the binning: 0.3677 in IBSI mode and 0.002177 with the default +settings on the worked example. With the default binning, zones of pixels outside the mask have +level 1 and dominate it (see :ref:`gldzm-possible-issues`). + + +.. _feature-gldzm-sde: + +Small distance emphasis +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_SDE`` +:Range: :math:`[1/N_d^{\,2}, 1]`, inverse squared pixels + +The mean over all zones of one over the squared zone distance. High when zones lie close to the +ROI edge. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_ZDNU}} {\textup{Zone Distance Non-Uniformity}} = \frac{1}{N_s} \sum_d m_d^2 + + \mathrm{SDE} = \frac{1}{N_s}\sum_{j=1}^{N_d} \frac{m_j}{j^2} + +**Possible issues.** Pixels next to background inside the bounding box get a distance one too large, +which lowers this value (see :ref:`gldzm-possible-issues`). + + +.. _feature-gldzm-sdhgle: + +Small distance high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_SDHGLE`` +:Range: :math:`(0, g_{N_g}^{\,2}]`, squared grey levels over squared pixels + +The mean over all zones of the squared grey level over the squared zone distance. High for bright +zones near the ROI edge. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_ZDNUN}} {\textup{Zone Distance Non-Uniformity Normalized}} = \frac{1}{N_s^2} \sum_d m_d^2 + + \mathrm{SDHGLE} = \frac{1}{N_s}\sum_{i=1}^{N_g}\sum_{j=1}^{N_d} \frac{g_i^{\,2}\, \textbf{P}(i,j)}{j^2} + + +.. _feature-gldzm-sdlgle: + +Small distance low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_SDLGLE`` +:Range: :math:`(0, 1/g_1^{\,2}]`, inverse squared grey levels times inverse squared pixels + +The mean over all zones of one over the squared grey level times the squared zone distance. High +for dark zones near the ROI edge. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_ZP}} {\textup{Zone Percentage}} = \frac{N_s}{N_v} + + \mathrm{SDLGLE} = \frac{1}{N_s}\sum_{i=1}^{N_g}\sum_{j=1}^{N_d} \frac{\textbf{P}(i,j)}{g_i^{\,2}\, j^2} + + +.. _feature-gldzm-zde: + +Zone distance entropy +^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_ZDE`` +:Range: :math:`[0, \log_2(N_g N_d)]` bits + +Measures the randomness of the joint distribution of zone grey level and zone distance. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_GLM}} {\textup{Grey Level Mean}} = \mu_x = \sum_x \sum_d x p_{x,d} + + \mathrm{ZDE} = -\sum_{i=1}^{N_g}\sum_{j=1}^{N_d} p(i,j)\,\log_2\!\big(p(i,j)+\varepsilon\big) + +The logarithm is exact, so the value agrees with IBSI and MIRP to rounding: 2.6062 on the worked +example. + + +.. _feature-gldzm-zdm: + +Zone distance mean +^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_ZDM`` +:Range: :math:`[1, N_d]`, pixels + +The mean zone distance over all zones. IBSI defines it only as the mean :math:`\mu` inside zone +distance variance. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_GLV}} {\textup{Grey Level Variance}} = \sum_x \sum_d \left(x - \mu_x \right)^2 p_{x,d} + + \mathrm{ZDM} = \mu_d = \sum_{i=1}^{N_g}\sum_{j=1}^{N_d} j\, p(i,j) + + +.. _feature-gldzm-zdnu: + +Zone distance non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_ZDNU`` +:Range: :math:`[1, N_s]`, zones + +Measures how evenly the zones are spread over the distances. Low values mean the zone distances are +spread evenly. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_ZDM}} {\textup{Zone Distance Mean}} = \mu_d = \sum_x \sum_d d p_{x,d} + + \mathrm{ZDNU} = \frac{1}{N_s}\sum_{j=1}^{N_d} m_j^{\,2} + + +.. _feature-gldzm-zdnun: + +Zone distance non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_ZDNUN`` +:Range: :math:`(0, 1]`, dimensionless + +Zone distance non-uniformity divided by the number of zones once more, so values are comparable +across ROIs. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_ZDV}} {\textup{Zone Distance Variance}} = \sum_x \sum_d \left(d - \mu_d \right)^2 p_{x,d} + + \mathrm{ZDNUN} = \frac{1}{N_s^{\,2}}\sum_{j=1}^{N_d} m_j^{\,2} + + +.. _feature-gldzm-zdv: + +Zone distance variance +^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_ZDV`` +:Range: :math:`[0, \infty)`, squared pixels + +The variance of the zone distance over all zones. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_ZDE}} {\textup{Zone Distance Entropy}} = - \sum_x \sum_d p_{x,d} \textup{log}_2 ( p_{x,d} ) + + \mathrm{ZDV} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_d} \big(j - \mu_d\big)^2\, p(i,j) + + +.. _feature-gldzm-zp: + +Zone percentage +^^^^^^^^^^^^^^^ + +:Code: ``GLDZM_ZP`` +:Range: :math:`(0, 1]` in IBSI mode, dimensionless + +The number of zones divided by the number of ROI pixels. High for fine texture, where most zones are +small. .. math:: - \underset{\mathrm{Nyxus \, code: \, GLDZM\_GLE}} {\textup{Grey Level Entropy}} = - \sum_x \sum_d p_{x,d} \textup{log}_2 ( p_{x,d} ) + + \mathrm{ZP} = \frac{N_s}{N_v} + +**Possible issues.** With the default binning, :math:`N_s` also counts the zones formed by +bounding-box pixels outside the mask, while :math:`N_v` counts only the pixels inside it, so the +value can exceed 1. + + +.. _gldzm-possible-issues: + +Possible issues +--------------- + +Distance next to background is one too large + The distance walk stops on the first pixel of grey level 0 and counts it as a step, so a pixel + that touches background inside the bounding box gets distance 2. IBSI erodes the ROI with the + 4-connected cross and gives every pixel on the ROI edge distance 1, so its distance is the + city-block distance to the nearest pixel outside the ROI. Take this 5 × 5 ROI, whose centre pixel + lies outside the mask: + + .. math:: + + \begin{bmatrix} + 1 & 1 & 1 & 1 & 1\\ + 1 & 1 & 1 & 1 & 1\\ + 1 & 2 & \cdot & 1 & 1\\ + 1 & 1 & 1 & 1 & 1\\ + 1 & 1 & 1 & 1 & 1 \end{bmatrix} + + In IBSI mode the zone of the single 2 has distance 2 in Nyxus and 1 under IBSI. Small distance + emphasis is 0.625 instead of 1, large distance emphasis 2.5 instead of 1 and zone distance mean + 1.5 instead of 1. The walk also looks only along the pixel's row and column, so background that + is nearest along a diagonal is not seen. Over 200 random 9 × 9 masks with a fifth of the + interior left out, 40 % of the ROI pixels get a different distance, almost all of them one more + than IBSI's, and none gets less. A zone's distance changes only when its closest pixel is + affected, so ROIs that fill their bounding box, and many others, are not affected. On the worked + example with three pixels of the bounding box left out of the mask, small distance emphasis is + 0.8269 instead of 0.8846 and zone distance mean 1.231 instead of 1.154. + +Background as grey level 1 with the default binning + With a positive ``--coarseGrayDepth``, intensity 0 bins to level 1. The bounding-box pixels + outside the mask then form zones of level 1, and the bounding box contains no pixel of level 0, + so the distance walks run to the edges of the bounding box and ignore the mask. ROI pixels whose + intensity is below :math:`x_{\max} / N` also get level 1 and can join those zones. On the + worked example with three pixels of the bounding box left out of the mask, zone percentage is + 0.6818 instead of 0.5909 in IBSI mode, and low grey level zone emphasis 0.1351 instead of + 0.002038, the value with those pixels excluded. On the ROI with a hole above, the hole becomes a + zone at distance 3: zone percentage is 0.125 instead of 0.08333 in IBSI mode, and zone distance + mean 2. + Rectangular ROIs with no pixel of intensity 0 are not affected. + +PyRadiomics-style binning with background in the bounding box + With a negative ``--coarseGrayDepth``, pixels of intensity 0 keep grey level 0 but still start + zones, and a zone of level 0 has no row in the matrix. The calculation stops with an error that + the command line does not report: every ``GLDZM_*`` column of that ROI is 0, and so is every + ``GLDZM_*`` column of the ROIs that the same thread would have computed after it + (``--reduceThreads`` splits the ROIs into that many consecutive shares). ROIs computed before + it, or by another thread, keep their values. ROIs that fill their bounding + box with non-zero intensities are not affected. The Python API accepts only a positive + ``coarse_gray_depth``. + +Pixels of intensity 0 inside the mask + In IBSI mode a pixel of intensity 0 belongs to no zone and acts as background for the distances + of its neighbours, but it is counted in :math:`N_v`. + +Constant ROI + When all of a ROI's pixels have the same intensity, every GLDZM feature returns the ``--noval`` + substitute (0 by default). + + +Validation +---------- + +The test suite checks the 16 IBSI features against MIRP on the four slices of the IBSI digital +phantom in IBSI mode, to an absolute 10\ :sup:`-9`. The four-slice means of 14 of them are also +compared with IBSI's published consensus values at a relative 10\ :sup:`-2`, the precision those +values are published to; that comparison is a consistency check, not one of the validations in +the index. Grey level mean and zone distance mean have no independent implementation and are +covered by regression tests. +Three of the phantom slices have pixels outside the mask inside the bounding box, and 6 of their +pixels get a larger distance from Nyxus than from IBSI, but no zone's distance changes, so these +comparisons do not cover the distance issue described above. In the default mode only the values +quoted on this page are pinned, by the worked-example script; no other test checks the default mode +against recorded or reference values. References ---------- -Thibault, G., Angulo, J., and Meyer, F. (2014); Advanced statistical matrices for texture characterization: application to cell classification; IEEE transactions on bio-medical engineering, 61(3):630-7. +1. Thibault G, Angulo J, Meyer F. *Advanced statistical matrices for texture characterization: + application to cell classification.* IEEE Transactions on Biomedical Engineering. + 2014;61(3):630–637. +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io diff --git a/docs/source/Math/f_gldzm_3d.rst b/docs/source/Math/f_gldzm_3d.rst new file mode 100644 index 000000000..a97eef2b6 --- /dev/null +++ b/docs/source/Math/f_gldzm_3d.rst @@ -0,0 +1,504 @@ +Grey-level distance-zone matrix, 3D (3GLDZM) +============================================ + +The 3D GLDZM features describe the zones of a volumetric ROI, groups of connected voxels that share +one grey level, by how far each zone lies from the surface of the ROI. The features are those of the +2D family: this page covers what is specific to volumes, and every formula is on the +:doc:`2D GLDZM page `. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``3GLDZM_*``: 18 features, one value per ROI + * - **Group** + - ``*3D_GLDZM*``; also part of ``*3D_ALL_TEXTURE*`` and ``*3D_ALL*`` + * - **Dimensions** + - 3D. The 2D family, ``GLDZM_*``, is on the :doc:`2D GLDZM page `. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory. A ROI whose bounding box is more than 131,068 voxels + along its shortest side is refused. When one read of the file returns the whole volume + (NIfTI, or OME-Zarr chunked over all of Z), an oversized ROI stops the run with an error + (see :doc:`/large_images`). + * - **IBSI** + - 16 of the 18 features have an IBSI identifier. Grey level mean and zone distance mean have + none. + +.. tip:: + + The 3D family follows the IBSI definition of the matrix in every mode: zones are 26-connected, + a zone's distance is the city-block distance to the nearest voxel outside the ROI, and voxels + outside the mask never form zones. The 2D family differs on the last two points. To reproduce + IBSI or MIRP values, run in IBSI mode (``--ibsi=true``, ``ibsi=True``), which leaves the + intensities unbinned. + + +How the matrix is built +----------------------- + +Nyxus scans the ROI's bounding box plane by plane, row by row. Each ROI voxel that is not yet in a +zone starts a new zone, which grows through every ROI voxel of the same grey level among its 26 +neighbours. Each voxel of the zone has a distance to the ROI surface, and the zone's distance is the +smallest of them. :math:`\textbf{P}(i,j)` counts the zones of grey level :math:`g_i` and distance +:math:`j`. + +- **Connectivity:** 26-connected, as IBSI defines for 3D. Two voxels of one grey level belong to the + same zone when they share a face, an edge or a corner. +- **Distance:** the city-block distance from the voxel to the nearest voxel that is not in the ROI, + through the 6 face neighbours. A voxel with a face neighbour outside the ROI, or on a face of the + bounding box, has distance 1; the next layer inwards has distance 2, and so on. Every voxel outside + the mask counts as outside the ROI, whatever its intensity, and so does everything beyond the + bounding box. Nyxus computes the distances in one breadth-first pass from the surface inwards, + which is IBSI's erosion with the 6-connected element. +- **Directions:** none. One matrix is built per ROI, and each feature has one value. +- **Grey levels:** intensities are first binned into :math:`N_g` grey levels, as set by + ``--coarseGrayDepth`` (64 by default). The 3D family has no metaparameter of its own. A voxel of + intensity :math:`x` gets level :math:`\lfloor N_g\,x / x_{\max} \rfloor + 1`, capped at + :math:`N_g`, where :math:`x_{\max}` is the ROI's largest intensity, and intensity 0 gets level 1. + In IBSI mode, or with ``--coarseGrayDepth=0``, they are not binned: each integer intensity is a + grey level. A negative ``--coarseGrayDepth`` selects PyRadiomics-style bins (see + `Settings`_). +- **Background:** only the voxels inside the mask form zones, in every mode. Voxels of the bounding + box outside the mask are skipped, and they count as outside the ROI for the distances. +- **Level 0:** a GLDZM grey level starts at 1. When a ROI voxel would get level 0 (intensity 0 in + IBSI mode, or with PyRadiomics-style bins), every level of the ROI is raised by one, so that voxel + still forms a zone (see :ref:`gldzm-3d-possible-issues`). + + +Worked example +^^^^^^^^^^^^^^ + +Take a 3 × 4 × 4 ROI that fills its bounding box and already has five grey levels. The three slices, +from :math:`z = 0` to :math:`z = 2`, are: + +.. math:: + + \begin{bmatrix} + 5 & 1 & 1 & 1\\ + 1 & 1 & 1 & 1\\ + 2 & 2 & 2 & 2\\ + 2 & 2 & 2 & 2 \end{bmatrix} + \qquad + \begin{bmatrix} + 1 & 1 & 1 & 1\\ + 1 & 3 & 4 & 1\\ + 2 & 3 & 4 & 2\\ + 2 & 2 & 2 & 2 \end{bmatrix} + \qquad + \begin{bmatrix} + 1 & 1 & 2 & 3\\ + 1 & 1 & 2 & 2\\ + 2 & 2 & 2 & 4\\ + 2 & 2 & 2 & 5 \end{bmatrix} + +The ROI is three voxels thick, so only the 2 × 2 centre of the middle slice has distance 2; every +other voxel lies on the surface and has distance 1. The 26-connected zones are: + +- all the 1s form one zone, and all the 2s another, both at distance 1; +- the two 3s in the centre of the middle slice touch no other 3, so they form a zone at distance 2; + the 3 in the corner of the last slice is a zone of its own at distance 1; +- the two 4s in the centre of the middle slice share an edge with the 4 of the last slice, which is + on the surface, so the three form one zone at distance 1; +- the two 5s lie in opposite corners and form two zones at distance 1. + +In IBSI mode (no binning) the matrix is below; rows are grey levels 1 to 5 and columns are +distances 1 and 2. + +.. math:: + + \textbf{P} = \begin{bmatrix} + 1 & 0\\ + 1 & 0\\ + 1 & 1\\ + 1 & 0\\ + 2 & 0 \end{bmatrix}, + \qquad N_s = \sum_{i,j} \textbf{P}(i,j) = 7, \qquad N_v = 48 + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for +this ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/gldzm_3d_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --dim=3 --features="*3D_GLDZM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + +Leave the voxel at :math:`z = 0`, row 1, column 1 out of the mask and the 3 behind it, in the middle +slice, gets a face neighbour outside the ROI. Its distance drops to 1, and so does the distance of +its zone: small distance emphasis is 1 instead of 0.8929, zone distance mean 1 instead of 1.143, and +zone percentage 7/47 = 0.1489. The values are the same in IBSI mode and with the default binning, +and whatever the intensity of the voxel left out. + + +Notation +-------- + +The symbols are those of the :doc:`2D page `, with voxels in place of pixels. + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_g` + - Number of rows of :math:`\textbf{P}`. With binning, the number of grey levels present among + the ROI's voxels; in IBSI mode, the ROI's largest intensity (one more when a voxel has + intensity 0). + * - :math:`g_i` + - Grey level of row :math:`i`. With binning, the :math:`i`-th smallest level present; in IBSI + mode, :math:`g_i = i`. + * - :math:`N_d` + - Number of columns of :math:`\textbf{P}`: the largest zone distance, in voxels. It is at most + half the shortest side of the ROI's bounding box, rounded up. + * - :math:`\textbf{P}(i,j)` + - Number of zones of grey level :math:`g_i` and distance :math:`j`. + * - :math:`N_s` + - Number of zones, :math:`N_s = \sum_{i,j}\textbf{P}(i,j)`. + * - :math:`N_v` + - Number of ROI voxels: the voxels inside the mask, including those of intensity 0. + +Every feature uses the grey level :math:`g_i`, never the row position. Zone distance entropy uses +the exact base-2 logarithm in double precision, with :math:`\varepsilon = 2.2 \times 10^{-16}` +inside it. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - ``--coarseGrayDepth=64`` + - ``Nyxus3D(..., coarse_gray_depth=64)`` + - Number of grey levels. Positive: equal-width bins from 0 to the ROI maximum; 0: no binning; + negative: :math:`|n|` PyRadiomics-style bins of width :math:`(x_{\max}-x_{\min})/|n|` from + the ROI minimum, with intensity 0 kept at level 0. Python accepts only a positive value. + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus3D(..., ibsi=True)`` + - No binning, whatever the grey-level setting. + * - Anisotropy + - ``--anisox``, ``--anisoy``, ``--anisoz``, or ``--use-physical-spacing=true`` + - ``Nyxus3D(..., anisotropy_x=..., anisotropy_y=..., anisotropy_z=...)``, or + ``use_physical_spacing=True`` + - No effect on these features: zones and distances are taken on the voxels as acquired. + +Zone connectivity and the distance are fixed. No ``set_metaparam`` parameter applies to +``3GLDZM_*``: unlike ``3glcm``, ``3glrlm`` or ``3glszm`` there is no ``3gldzm/greydepth``, and +``set_metaparam("3gldzm/greydepth=8")`` stops with an error. All options are listed in the +:ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``3GLDZM_SDE``, ``3GLDZM_ZP`` and so on. +There are no per-direction columns and no ``_AVE`` columns. + + +.. _gldzm-3d-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with the tolerance; see the :doc:`/featurelist` for what the categories mean. *Example* is +the value on the worked example. + +.. include:: gldzm_3d_index.inc + + +Features +-------- + +Every 3D feature applies the formula of its 2D counterpart to the 3D matrix, with voxels in place of +pixels. + +.. _feature-3gldzm-glm: + +Grey level mean +^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_GLM`` +:Range: :math:`[g_1, g_{N_g}]`, grey levels + +As :ref:`GLDZM_GLM `. Depends on the binning: 3.286 in IBSI mode and 42.43 with +the default 64-level binning on the worked example. + + +.. _feature-3gldzm-glnu: + +Grey level non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_GLNU`` +:Range: :math:`[1, N_s]`, zones + +As :ref:`GLDZM_GLNU `. + + +.. _feature-3gldzm-glnun: + +Grey level non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_GLNUN`` +:Range: :math:`(0, 1]`, dimensionless + +As :ref:`GLDZM_GLNUN `. + + +.. _feature-3gldzm-glv: + +Grey level variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_GLV`` +:Range: :math:`[0, \infty)`, squared grey levels + +As :ref:`GLDZM_GLV `. It scales with the square of the grey-level spacing: 1.918 +in IBSI mode and 311.67 with the default binning on the worked example, which maps the 5 levels to +13, 26, 39, 52 and 64. + + +.. _feature-3gldzm-hglze: + +High grey level zone emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_HGLZE`` +:Range: :math:`[g_1^{\,2}, g_{N_g}^{\,2}]`, squared grey levels + +As :ref:`GLDZM_HGLZE `. 12.71 in IBSI mode and 2111.9 with the default +binning on the worked example. + + +.. _feature-3gldzm-lde: + +Large distance emphasis +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_LDE`` +:Range: :math:`[1, N_d^{\,2}]`, squared voxels + +As :ref:`GLDZM_LDE `. + + +.. _feature-3gldzm-ldhgle: + +Large distance high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_LDHGLE`` +:Range: :math:`[g_1^{\,2}, g_{N_g}^{\,2} N_d^{\,2}]`, squared grey levels times squared voxels + +As :ref:`GLDZM_LDHGLE `. + + +.. _feature-3gldzm-ldlgle: + +Large distance low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_LDLGLE`` +:Range: :math:`(0, N_d^{\,2}]`, squared voxels over squared grey levels + +As :ref:`GLDZM_LDLGLE `. + + +.. _feature-3gldzm-lglze: + +Low grey level zone emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_LGLZE`` +:Range: :math:`[1/g_{N_g}^{\,2}, 1/g_1^{\,2}]`, inverse squared grey levels + +As :ref:`GLDZM_LGLZE `. 0.2307 in IBSI mode and 0.001367 with the default +binning on the worked example. Voxels outside the mask form no zones, so they do not weigh on it. + + +.. _feature-3gldzm-sde: + +Small distance emphasis +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_SDE`` +:Range: :math:`[1/N_d^{\,2}, 1]`, inverse squared voxels + +As :ref:`GLDZM_SDE `. + + +.. _feature-3gldzm-sdhgle: + +Small distance high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_SDHGLE`` +:Range: :math:`(0, g_{N_g}^{\,2}]`, squared grey levels over squared voxels + +As :ref:`GLDZM_SDHGLE `. + + +.. _feature-3gldzm-sdlgle: + +Small distance low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_SDLGLE`` +:Range: :math:`(0, 1/g_1^{\,2}]`, inverse squared grey levels times inverse squared voxels + +As :ref:`GLDZM_SDLGLE `. + + +.. _feature-3gldzm-zde: + +Zone distance entropy +^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_ZDE`` +:Range: :math:`[0, \log_2(N_g N_d)]` bits + +As :ref:`GLDZM_ZDE `, with the exact logarithm: 2.5216 on the worked example. + + +.. _feature-3gldzm-zdm: + +Zone distance mean +^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_ZDM`` +:Range: :math:`[1, N_d]`, voxels + +As :ref:`GLDZM_ZDM `. + + +.. _feature-3gldzm-zdnu: + +Zone distance non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_ZDNU`` +:Range: :math:`[1, N_s]`, zones + +As :ref:`GLDZM_ZDNU `. + + +.. _feature-3gldzm-zdnun: + +Zone distance non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_ZDNUN`` +:Range: :math:`(0, 1]`, dimensionless + +As :ref:`GLDZM_ZDNUN `. + + +.. _feature-3gldzm-zdv: + +Zone distance variance +^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_ZDV`` +:Range: :math:`[0, \infty)`, squared voxels + +As :ref:`GLDZM_ZDV `. + + +.. _feature-3gldzm-zp: + +Zone percentage +^^^^^^^^^^^^^^^ + +:Code: ``3GLDZM_ZP`` +:Range: :math:`(0, 1]`, dimensionless + +As :ref:`GLDZM_ZP `, with :math:`N_v` the number of ROI voxels. Only ROI voxels +form zones, so the value never exceeds 1, with or without binning: 0.1458 on the worked example in +both modes. + + +.. _gldzm-3d-possible-issues: + +Possible issues +--------------- + +Thin ROIs have no depth to measure + A voxel's distance is at most half the shortest side of the ROI's bounding box, rounded up. In a ROI two voxels + thick along any axis, as in a stack of two slices, every voxel is on the surface: all zones have + distance 1, small and large distance emphasis are 1 and zone distance variance is 0. The worked + example, three voxels thick, reaches only distance 2. On volumes with few, thick slices, compare + the distance features only between ROIs of similar thickness. + +ROI voxels of intensity 0 raise every grey level by one + In IBSI mode a voxel of intensity 0 would have grey level 0, which a GLDZM cannot hold. Nyxus + then adds 1 to the level of every ROI voxel, so the voxel keeps its zone and :math:`N_v` keeps + counting it, but every grey level is one more than the intensity. Setting the voxel at the centre + of the worked example (:math:`z = 1`, row 1, column 1) to 0 gives 8 zones over levels 1 to 6; + grey level mean is 3.875, where the intensities of those zones average 2.875, and zone percentage + is 0.1667. The grey-level-weighted features of a ROI shift when a single voxel turns 0, and do + not match an implementation that leaves such voxels out. The same lift applies with + PyRadiomics-style bins, which keep intensity 0 at level 0. Default binning sends intensity 0 to + level 1 and is not affected. + +Default binning starts at 0, not at the ROI minimum + The positive ``--coarseGrayDepth`` bins span 0 to the ROI maximum, so a ROI whose intensities + sit far from 0 uses only the top bins. This is the MATLAB-style rule that every texture family + except NGLDM uses with a positive grey depth (see :ref:`conventions-grey-levels`). MIRP's and + PyRadiomics' fixed bin number spans the ROI's own range, so the grey-level-weighted features + differ from theirs with binning; the distance features do not depend on the bins as long as the + zones stay the same. + +Python offers only the default binning scheme + ``Nyxus3D`` accepts only a positive ``coarse_gray_depth``. ``set_params(coarse_gray_depth=0)`` + leaves the setting unchanged, and a negative value raises a ``TypeError``. Use ``ibsi=True`` for + unbinned grey levels, and the command line for PyRadiomics-style bins. + +Constant ROI + When all of a ROI's voxels have the same intensity, every 3D GLDZM feature returns the + ``--noval`` substitute (0 by default). + + +Validation +---------- + +The test suite checks the 16 IBSI features against MIRP on a 3D phantom built to separate the +family's definition choices, to an absolute 10\ :sup:`-9`, in two configurations: IBSI mode, and +PyRadiomics-style binning with 8 bins, a bin count that leaves the phantom's levels 1 to 8 +unchanged. The largest difference is 5.7 × 10\ :sup:`-15`, on zone distance entropy; 13 of the 16 +values are bit-identical. The phantom is negative-controlled: 6- or 18-connected zones fail all 16 +checks, and a distance taken as the shortest of the six axis-aligned walks fails 9 of them. Grey +level mean and zone distance mean have no independent implementation and are covered by +regression tests. The default binning is covered by regression tests only. PyRadiomics-style +binning at a bin count that merges levels is not tested. + + +References +---------- + +1. Thibault G, Angulo J, Meyer F. *Advanced statistical matrices for texture characterization: + application to cell classification.* IEEE Transactions on Biomedical Engineering. + 2014;61(3):630–637. +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io diff --git a/docs/source/Math/f_glrlm.rst b/docs/source/Math/f_glrlm.rst index 35238b43c..f0832bc2a 100644 --- a/docs/source/Math/f_glrlm.rst +++ b/docs/source/Math/f_glrlm.rst @@ -1,49 +1,568 @@ +Grey-level run-length matrix (GLRLM) +==================================== -Texture features / GLRLM -======================== +GLRLM features describe the runs in an ROI: straight lines of consecutive pixels that share one grey +level. They count runs by grey level and length along a fixed direction, and capture whether a +texture is fine (many short runs) or coarse (few long runs). -A Gray Level Run Length Matrix (GLRLM) quantifies gray level runs, which are defined as the length in number of -pixels, of consecutive pixels that have the same gray level value. In a gray level run length matrix -:math:`\textbf{P}(i,j|\theta)`, the :math:`(i,j)^{\text{th}}` element describes the number of runs with gray level -:math:`i` and length :math:`j` occur in the image (ROI) along angle :math:`\theta`. +.. list-table:: + :widths: 26 74 -As an example, consider the following 5x5 ROI image having 5 gray levels: + * - **Feature codes** + - ``GLRLM_*``: 16 features, each with a column per direction, and their ``GLRLM_*_AVE`` + direction averages + * - **Group** + - ``*ALL_GLRLM*``; also part of ``*ALL*`` and ``*WHOLESLIDE*`` + * - **Dimensions** + - 2D. The 3D family, ``3GLRLM_*``, has its own page, :doc:`f_glrlm_3d`. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory on an image that Nyxus reads as one tile. On other + images out-of-core processing of this family raises an error or returns wrong + values, depending on the ROI (see :ref:`large-images-tiles`). + * - **IBSI** + - All 16 features have an IBSI identifier. + +.. tip:: + + To reproduce IBSI, PyRadiomics or MIRP values, run in IBSI mode: ``--ibsi=true`` on the command + line, ``ibsi=True`` in Python. With the default settings the grey levels are binned from 0, so + every feature that weights runs by grey level differs from those tools, and pixels of the + bounding box outside the mask take part in runs (see :ref:`glrlm-possible-issues`). + + +How the matrix is built +----------------------- + +For a direction :math:`\theta`, Nyxus scans the ROI's bounding box row by row. Each pixel that does +not yet belong to a run starts a new run, which grows along :math:`\theta` while the next pixel has +the same grey level. The finished run, with grey level :math:`i` and length :math:`j` pixels, adds 1 +to :math:`\textbf{P}(i,j)`. + +- **Directions:** :math:`\theta \in \{0^\circ, 45^\circ, 90^\circ, 135^\circ\}`, always all four. + With rows numbered from the top, a run steps along a row for 0°, down a column for 90°, down and + to the right for 45°, and down and to the left for 135°. Each direction gets its own matrix and + its own feature values. +- **Grey levels:** intensities are first binned into grey levels, as set by ``--coarseGrayDepth`` + (64 by default). The default binning maps intensity :math:`x` to + :math:`\lfloor N x / x_{\max} + 1 \rfloor`, capped at :math:`N`, where :math:`N` is the number of + levels and :math:`x_{\max}` the ROI's maximum intensity. In IBSI mode they are not binned: each + integer intensity is a grey level. +- **Background:** pixels with grey level 0 belong to no run. In IBSI mode and with PyRadiomics-style + binning, this excludes pixels outside the mask and ROI pixels of intensity 0. The default binning + maps intensity 0 to level 1, so there such pixels do form runs. + + +Worked example +^^^^^^^^^^^^^^ + +Take a 5 × 5 ROI that already has five grey levels: + +.. math:: + + \textbf{G} = \begin{bmatrix} + 5 & 2 & 5 & 4 & 4\\ + 3 & 3 & 3 & 1 & 3\\ + 2 & 1 & 1 & 1 & 3\\ + 4 & 2 & 2 & 2 & 3\\ + 3 & 5 & 3 & 3 & 2 \end{bmatrix} + +With :math:`\theta = 0^\circ` and IBSI mode (no binning), the matrix is below, with grey levels 1 to +5 as rows and run lengths 1 to 3 as columns. Row 1 of the image contains four runs: a 5, a 2 and a 5 +of length 1, and two 4s that form one run of length 2. + +.. math:: + + \textbf{P} = \begin{bmatrix} + 1 & 0 & 1\\ + 3 & 0 & 1\\ + 4 & 1 & 1\\ + 1 & 1 & 0\\ + 3 & 0 & 0 \end{bmatrix}, + \qquad N_s = \sum_{i,j} \textbf{P}(i,j) = 17, \qquad N_p = 25 + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for this +ROI in the ``_0`` (0°) column. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/glrlm_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features="*ALL_GLRLM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_g` + - Number of grey levels: the maximum intensity in IBSI mode, otherwise the number of distinct + binned levels in the bounding box (with the default binning, including level 1 of the pixels + outside the mask). + * - :math:`N_r` + - Length of the longest run along :math:`\theta`. + * - :math:`i,\; j` + - Grey level of a run (:math:`i`) and its length in pixels (:math:`j`). Formulas use the grey + level's value: the intensity in IBSI mode, the binned level otherwise. + * - :math:`\textbf{P}(i,j)` + - Number of runs with grey level :math:`i` and length :math:`j` along one :math:`\theta`. + * - :math:`N_s` + - Number of runs, :math:`N_s = \sum_{i,j}\textbf{P}(i,j)`. + * - :math:`N_p` + - Number of ROI pixels with nonzero intensity. + * - :math:`p_{ij}` + - Joint probability, :math:`p_{ij} = \textbf{P}(i,j) / N_s`. + * - :math:`r_{i\cdot},\; r_{\cdot j}` + - Runs per grey level, :math:`r_{i\cdot} = \sum_j \textbf{P}(i,j)`, and runs per length, + :math:`r_{\cdot j} = \sum_i \textbf{P}(i,j)`. + * - :math:`\mu_i,\; \mu_j` + - Mean grey level and mean run length over all runs, :math:`\mu_i = \sum_{i,j} i\,p_{ij}` and + :math:`\mu_j = \sum_{i,j} j\,p_{ij}`. + * - :math:`\varepsilon` + - :math:`2.2 \times 10^{-16}`, added inside the logarithm to avoid :math:`\log 0`. + +Sums over :math:`i` run over the grey levels and sums over :math:`j` from 1 to :math:`N_r`. + +Logarithms are computed with a fast base-2 approximation. Run entropy therefore differs from an +exact logarithm by a relative 2 × 10\ :sup:`-4` to 1.2 × 10\ :sup:`-3` on the worked example, which +is why it is validated at a relative tolerance of 5 × 10\ :sup:`-3`. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - ``--coarseGrayDepth=64`` + - ``Nyxus(..., coarse_gray_depth=64)`` + - Number of grey levels. Positive: equal-width bins from 0 to the ROI maximum; negative: + PyRadiomics-style bins between the ROI minimum and maximum (command line only). + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - No binning: each integer intensity is a grey level. + +The four directions are always computed; ``--glcmAngles`` does not apply to GLRLM. No +``set_metaparam`` parameter changes the 2D matrix; ``3glrlm/greydepth`` applies to the 3D family +only. All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column per direction and one average: + +- ``GLRLM_SRE_0``, ``GLRLM_SRE_45``, ``GLRLM_SRE_90``, ``GLRLM_SRE_135``: the value for each + direction. +- ``GLRLM_SRE_AVE``: the mean of the four per-direction values. + +Each direction has its own matrix; the matrices are not merged. + + +.. _glrlm-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the 0° value on the worked example. + +.. include:: glrlm_index.inc + + +Features +-------- + +.. _feature-glrlm-gln: + +Grey level non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_GLN`` +:Range: :math:`[1, N_s]`, runs + +Measures how evenly the runs are spread over the grey levels. Low values mean the runs are spread +evenly. + +.. math:: + + \mathrm{GLN} = \frac{1}{N_s}\sum_{i} r_{i\cdot}^{\,2} + + +.. _feature-glrlm-glnn: + +Grey level non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_GLNN`` +:Range: :math:`(0, 1]`, dimensionless + +Grey level non-uniformity divided by the number of runs once more, so values are comparable across +ROIs of different size. + +.. math:: + + \mathrm{GLNN} = \frac{1}{N_s^{\,2}}\sum_{i} r_{i\cdot}^{\,2} + + +.. _feature-glrlm-glv: + +Grey level variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_GLV`` +:Range: :math:`[0, \infty)`, squared grey levels + +The variance of the grey level over all runs. + +.. math:: + + \mathrm{GLV} = \sum_{i}\sum_{j=1}^{N_r} (i-\mu_i)^2\, p_{ij} + +**Possible issues.** Grey level variance scales with the square of the grey-level spacing. On the +worked example it is 1.529 in IBSI mode and 249.44 with the default 64-level binning, which maps +the levels 1 to 5 to 13, 26, 39, 52 and 64. + + +.. _feature-glrlm-hglre: + +High grey level run emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_HGLRE`` +:Range: :math:`[1, \infty)`, squared grey levels + +The mean over all runs of the squared grey level. High values mean bright runs dominate. + +.. math:: + + \mathrm{HGLRE} = \frac{1}{N_s}\sum_{i}\sum_{j=1}^{N_r} i^2\,\textbf{P}(i,j) + +**Possible issues.** Depends on the binning: 10.53 in IBSI mode and 1756.7 with the default +settings on the worked example. :math:`i^2` is computed in 32-bit unsigned integers and wraps +for grey levels of 65536 and above (see :ref:`glrlm-possible-issues`). + + +.. _feature-glrlm-lglre: + +Low grey level run emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_LGLRE`` +:Range: :math:`(0, 1]`, inverse squared grey levels + +The mean over all runs of one over the squared grey level. High values mean dark runs dominate. + +.. math:: + + \mathrm{LGLRE} = \frac{1}{N_s}\sum_{i}\sum_{j=1}^{N_r} \frac{\textbf{P}(i,j)}{i^2} + +**Possible issues.** Depends on the binning: 0.2301 in IBSI mode and 0.001363 with the default +settings on the worked example. When :math:`N_g` reaches 65536 the result is the ``--noval`` +substitute, 0 by default (``-0.0`` in Arrow and Parquet files; see :ref:`conventions-missing` and +:ref:`glrlm-possible-issues`). + + +.. _feature-glrlm-lre: + +Long run emphasis +^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_LRE`` +:Range: :math:`[1, N_r^2]`, squared pixels + +The mean over all runs of the squared run length. High values mean long runs dominate, as in a +coarse texture. + +.. math:: + + \mathrm{LRE} = \frac{1}{N_s}\sum_{i}\sum_{j=1}^{N_r} j^2\,\textbf{P}(i,j) + + +.. _feature-glrlm-lrhgle: + +Long run high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_LRHGLE`` +:Range: :math:`[1, \infty)`, squared grey levels times squared pixels + +The mean over all runs of the squared run length times the squared grey level. High values mean +long bright runs dominate. + +.. math:: + + \mathrm{LRHGLE} = \frac{1}{N_s}\sum_{i}\sum_{j=1}^{N_r} i^2 j^2\,\textbf{P}(i,j) + +**Possible issues.** :math:`i^2 j^2` is computed in 32-bit unsigned integers and wraps once it +reaches :math:`2^{32}`. A run of 8 pixels at intensity 10000 in IBSI mode gives +:math:`2.339 \times 10^8` instead of :math:`7.111 \times 10^8` (see :ref:`glrlm-possible-issues`). + + +.. _feature-glrlm-lrlgle: + +Long run low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_LRLGLE`` +:Range: :math:`(0, \infty)`, squared pixels over squared grey levels + +The mean over all runs of the squared run length over the squared grey level. High values mean +long dark runs dominate. + +.. math:: + + \mathrm{LRLGLE} = \frac{1}{N_s}\sum_{i}\sum_{j=1}^{N_r} \frac{j^2\,\textbf{P}(i,j)}{i^2} + +**Possible issues.** :math:`i^2` wraps for grey levels of 65536 and above, as for low grey level +run emphasis. + + +.. _feature-glrlm-re: + +Run entropy +^^^^^^^^^^^ + +:Code: ``GLRLM_RE`` +:Range: :math:`[0, \log_2(N_g N_r)]` bits + +Measures the randomness of the joint distribution of run grey level and run length. + +.. math:: + + \mathrm{RE} = -\sum_{i}\sum_{j=1}^{N_r} p_{ij}\,\log_2\!\big(p_{ij}+\varepsilon\big) + +**Possible issues.** The logarithm is approximate: on the worked example Nyxus returns 3.0582 at +0°, while an exact logarithm gives 3.0575. + + +.. _feature-glrlm-rln: + +Run length non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_RLN`` +:Range: :math:`[1, N_s]`, runs + +Measures how evenly the runs are spread over the run lengths. Low values mean the run lengths are +spread evenly. + +.. math:: + + \mathrm{RLN} = \frac{1}{N_s}\sum_{j=1}^{N_r} r_{\cdot j}^{\,2} + + +.. _feature-glrlm-rlnn: + +Run length non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_RLNN`` +:Range: :math:`(0, 1]`, dimensionless + +Run length non-uniformity divided by the number of runs once more, so values are comparable across +ROIs of different size. + +.. math:: + + \mathrm{RLNN} = \frac{1}{N_s^{\,2}}\sum_{j=1}^{N_r} r_{\cdot j}^{\,2} + + +.. _feature-glrlm-rp: + +Run percentage +^^^^^^^^^^^^^^ + +:Code: ``GLRLM_RP`` +:Range: :math:`(0, 1]`, dimensionless + +The number of runs per ROI pixel. Fine textures, with many short runs, give values near 1. .. math:: - \textbf{G} = \begin{bmatrix} - 5 & 2 & 5 & 4 & 4\\ - 3 & 3 & 3 & 1 & 3\\ - 2 & 1 & 1 & 1 & 3\\ - 4 & 2 & 2 & 2 & 3\\ - 3 & 5 & 3 & 3 & 2 \end{bmatrix} + \mathrm{RP} = \frac{N_s}{N_p} +**Possible issues.** With the default binning, runs of pixels outside the mask are counted in +:math:`N_s` but not in :math:`N_p`, so the value can exceed 1 (see :ref:`glrlm-possible-issues`). -The GLRLM for :math:`\theta = 0`, where 0 degrees is the horizontal direction, then becomes: + +.. _feature-glrlm-rv: + +Run variance +^^^^^^^^^^^^ + +:Code: ``GLRLM_RV`` +:Range: :math:`[0, \infty)`, squared pixels + +The variance of the run length over all runs. .. math:: - \textbf{P} = \begin{bmatrix} - 1 & 0 & 1 & 0 & 0\\ - 3 & 0 & 1 & 0 & 0\\ - 4 & 1 & 1 & 0 & 0\\ - 1 & 1 & 0 & 0 & 0\\ - 3 & 0 & 0 & 0 & 0 \end{bmatrix} + \mathrm{RV} = \sum_{i}\sum_{j=1}^{N_r} (j-\mu_j)^2\, p_{ij} + + +.. _feature-glrlm-sre: -Let: +Short run emphasis +^^^^^^^^^^^^^^^^^^ -* :math:`N_g` be the number of discrete intensity values in the image -* :math:`N_r` be the number of discrete run lengths in the image -* :math:`N_p` be the number of voxels in the image -* :math:`N_r(\theta)` be the number of runs in the image along angle :math:`\theta`, which is equal to +:Code: ``GLRLM_SRE`` +:Range: :math:`(0, 1]`, inverse squared pixels + +The mean over all runs of one over the squared run length. High values mean short runs dominate, +as in a fine texture. .. math:: - \sum^{N_g}_{i=1}\sum^{N_r}_{j=1}{\textbf{P}(i,j|\theta)} - -and :math:`1 \leq N_r(\theta) \leq N_p` -* :math:`\textbf{P}(i,j|\theta)` be the run length matrix for an arbitrary direction :math:`\theta` -* :math:`p(i,j|\theta)` be the normalized run length matrix, defined as + \mathrm{SRE} = \frac{1}{N_s}\sum_{j=1}^{N_r} \frac{r_{\cdot j}}{j^2} + + +.. _feature-glrlm-srhgle: + +Short run high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_SRHGLE`` +:Range: :math:`(0, \infty)`, squared grey levels over squared pixels + +The mean over all runs of the squared grey level over the squared run length. High values mean +short bright runs dominate. + +.. math:: + + \mathrm{SRHGLE} = \frac{1}{N_s}\sum_{i}\sum_{j=1}^{N_r} \frac{i^2\,\textbf{P}(i,j)}{j^2} + +**Possible issues.** :math:`i^2` wraps for grey levels of 65536 and above, as for high grey level +run emphasis. + + +.. _feature-glrlm-srlgle: + +Short run low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLRLM_SRLGLE`` +:Range: :math:`(0, 1]`, inverse squared grey levels times inverse squared pixels + +The mean over all runs of one over the squared run length times the squared grey level. High +values mean short dark runs dominate. .. math:: - p(i,j|\theta) = \frac{\textbf{P}(i,j|\theta)}{N_r(\theta)} + + \mathrm{SRLGLE} = \frac{1}{N_s}\sum_{i}\sum_{j=1}^{N_r} \frac{\textbf{P}(i,j)}{i^2 j^2} + +**Possible issues.** :math:`i^2 j^2` is computed in 32-bit unsigned integers. When it wraps to 0 +for any cell of the matrix the result is the ``--noval`` substitute, 0 by default (``-0.0`` in +Arrow and Parquet files): a run of 8 pixels at intensity 8192 in IBSI mode gives 0 instead of +0.5556 (see :ref:`glrlm-possible-issues`). + + +.. _glrlm-possible-issues: + +Possible issues +--------------- + +Pixels outside the mask with the default binning + The default (positive) ``--coarseGrayDepth`` binning maps intensity 0 to grey level 1. Pixels of + the bounding box outside the mask, and ROI pixels of intensity 0, then form runs of level 1 and + join runs of ROI pixels at that level. Every feature changes. On the worked example with three + pixels of the bounding box left out of the mask, run percentage is 0.7273 instead of 0.6364 and + low grey level run emphasis is 0.1262 instead of 0.001426, the values with those pixels + excluded. Rectangular ROIs with no pixel of intensity 0 are not affected. IBSI mode and + PyRadiomics-style binning (negative ``--coarseGrayDepth``) exclude these pixels. + +Grey-level values depend on the binning + ``GLV`` and the six emphasis features that weight runs by grey level use the grey level's + value. The default binning counts levels from intensity 0, not from the ROI minimum, so a ROI + whose intensities span a narrow range uses only the top levels. Compare these features only + between runs with the same ``--coarseGrayDepth``. The features that use only run lengths and + counts (``SRE``, ``LRE``, ``RLN``, ``RLNN``, ``RP``, ``RV``) change only when binning merges or + splits runs. ``GLN``, ``GLNN`` and ``RE`` group the runs by grey level, so they also change when + binning merges grey levels. + +Integer overflow with large grey levels or long runs + The products :math:`i^2` and :math:`i^2 j^2` are computed in 32-bit unsigned integers before + conversion to floating point: + + .. list-table:: + :widths: 40 60 + :header-rows: 1 + + * - Feature + - Effect + * - ``HGLRE``, ``SRHGLE`` + - Wrong when a run has grey level 65536 or above + * - ``LGLRE``, ``LRLGLE`` + - The ``--noval`` substitute when :math:`N_g` reaches 65536 in IBSI mode or a run has grey + level 65536; wrong for runs above 65536 + * - ``LRHGLE`` + - Wrong when :math:`i^2 j^2 \ge 2^{32}` for a run + * - ``SRLGLE`` + - The ``--noval`` substitute when :math:`i^2 j^2` wraps to 0 for any cell of the matrix + + With the default 64 levels this needs runs of 1024 pixels or more. In IBSI mode it can happen + with 16-bit intensities and short runs: an ROI with maximum intensity 65535 and one run of 2 + pixels returns the ``--noval`` substitute (0 by default) for ``SRLGLE``. These substitutes are + applied when the value is written, so Arrow and Parquet files hold ``-0.0`` instead, whatever + ``--noval`` is (see :ref:`conventions-missing`). + +Direction labels + 45° runs down and to the right with rows numbered from the top. Tools that measure angles + counter-clockwise with the row axis pointing up call this diagonal 135°. Compare per-direction + values by step, not by label; the ``_AVE`` columns are unaffected. + +Constant ROI + When all of a ROI's pixels have one intensity, every GLRLM feature, including the ``_AVE`` + columns, returns the ``--noval`` substitute. + + +Validation +---------- + +The test suite checks every 2D GLRLM feature and its ``_AVE`` against an independent +implementation, named in the :ref:`index `. In IBSI mode Nyxus matches PyRadiomics +on the IBSI digital phantom to a relative 10\ :sup:`-9`; run entropy agrees to 5 × 10\ :sup:`-3` +because of the approximate logarithm. The test suite also compares the results with IBSI's +published consensus values at a relative 10\ :sup:`-2`, the precision those values are published +to; that comparison is a consistency check, not one of the validations in the index. The default binning and the treatment of pixels outside the mask are covered by +regression tests only. + + +References +---------- + +1. Galloway MM. *Texture analysis using gray level run lengths.* Computer Graphics and Image + Processing. 1975;4(2):172–179. +2. Chu A, Sehgal CM, Greenleaf JF. *Use of gray value distribution of run lengths for texture + analysis.* Pattern Recognition Letters. 1990;11(6):415–419. +3. Dasarathy BV, Holder EB. *Image characterizations based on joint gray level–run length + distributions.* Pattern Recognition Letters. 1991;12(8):497–502. +4. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io +5. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. diff --git a/docs/source/Math/f_glrlm_3d.rst b/docs/source/Math/f_glrlm_3d.rst new file mode 100644 index 000000000..35e6b87c8 --- /dev/null +++ b/docs/source/Math/f_glrlm_3d.rst @@ -0,0 +1,527 @@ +Grey-level run-length matrix in 3D (3GLRLM) +=========================================== + +3D GLRLM features describe the runs in a volumetric ROI: straight lines of consecutive voxels that +share one grey level. They count runs by grey level and length along each of 13 directions, and +capture whether a texture is fine (many short runs) or coarse (few long runs). The features are +those of the 2D :doc:`GLRLM family `, computed on 3D run-length matrices; this page covers +what is specific to 3D. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``3GLRLM_*``: 16 features, each reporting one direction, and their ``3GLRLM_*_AVE`` + averages over the 13 directions + * - **Group** + - ``*3D_GLRLM*``; also part of ``*3D_ALL_TEXTURE*`` and ``*3D_ALL*`` + * - **Dimensions** + - 3D. The 2D family, ``GLRLM_*``, is described in :doc:`f_glrlm`. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory. + When one read of the file returns the whole volume (NIfTI, or OME-Zarr chunked over all of + Z), an oversized ROI stops the run with an error (see :doc:`/large_images`). + * - **IBSI** + - All 16 features have an IBSI identifier. The ``_AVE`` codes follow the IBSI aggregation + "averaged over the 3D directions" (ITBB). + +.. tip:: + + The plain codes, such as ``3GLRLM_SRE``, report a single direction, not an average. Use the + ``_AVE`` codes to compare with IBSI, PyRadiomics or MIRP values, which aggregate over all + directions. By default the 3D family does not bin grey levels, so the default settings and IBSI + mode give the same values; ``--coarseGrayDepth`` does not apply to it (see + :ref:`glrlm-3d-settings`). + + +How the matrix is built +----------------------- + +For a direction :math:`\theta`, Nyxus scans the voxels of the ROI's bounding box slice by slice and +row by row. Each voxel that does not yet belong to a run starts a new run, which grows along +:math:`\theta` while the next voxel has the same grey level. The finished run, with grey level +:math:`i` and length :math:`j` voxels, adds 1 to :math:`\textbf{P}(i,j)`. Each direction gets its +own matrix and its own feature values; the matrices are not merged. + +- **Directions:** the 13 directions of the 26-voxel neighbourhood, one of each opposite pair, as a + step :math:`(\Delta z, \Delta y, \Delta x)` in voxels, with slices, rows and columns numbered from + 0. They are computed in this order: + + .. list-table:: + :widths: 10 30 10 30 + :header-rows: 1 + + * - # + - Step :math:`(\Delta z, \Delta y, \Delta x)` + - # + - Step :math:`(\Delta z, \Delta y, \Delta x)` + * - 1 + - :math:`(1, 1, 1)` + - 8 + - :math:`(1, -1, 0)` + * - 2 + - :math:`(1, 1, 0)` + - 9 + - :math:`(1, -1, -1)` + * - 3 + - :math:`(1, 1, -1)` + - 10 + - :math:`(0, 1, 1)` + * - 4 + - :math:`(1, 0, 1)` + - 11 + - :math:`(0, 1, 0)` + * - 5 + - :math:`(1, 0, 0)` + - 12 + - :math:`(0, 1, -1)` + * - 6 + - :math:`(1, 0, -1)` + - 13 + - :math:`(0, 0, 1)` + * - 7 + - :math:`(1, -1, 1)` + - + - + + Directions 1 to 9 cross slices; directions 10 to 13 stay within a slice and are the four 2D + directions. Run lengths count voxels, whatever the voxel spacing. The 13 directions are always + all computed; no setting selects them. + The :doc:`3D GLCM ` page lists the same 13 triples in the same order, but written + :math:`(d_x, d_y, d_z)`, so a direction number names a different step in the two families: + direction 5, :math:`(1, 0, 0)`, crosses slices here and is a step along :math:`x` in 3D GLCM. + Directions 1, 4, 7 and 11, among them direction 1, :math:`(1, 1, 1)`, the one the plain codes + report, are the same step in both. +- **Grey levels:** by default intensities are not binned: each integer intensity is a grey level, + from 1 to the ROI's maximum. The ``3glrlm/greydepth`` metaparameter selects a binning (see + :ref:`glrlm-3d-settings`). +- **Background:** voxels with grey level 0 belong to no run. Without binning, in IBSI mode and with + PyRadiomics-style binning, this excludes the voxels of the bounding box outside the mask and ROI + voxels of intensity 0. MATLAB-style binning (positive ``3glrlm/greydepth``) maps intensity 0 to + level 1, so there such voxels do form runs (see :ref:`glrlm-3d-possible-issues`). + + +Worked example +^^^^^^^^^^^^^^ + +Take a 3 × 3 × 3 ROI that already has four grey levels, shown slice by slice +(:math:`z = 0, 1, 2`), with rows :math:`y` from the top and columns :math:`x` from the left: + +.. math:: + + \textbf{G}_{z=0} = \begin{bmatrix} + 1 & 2 & 4\\ + 3 & 1 & 2\\ + 3 & 3 & 1 \end{bmatrix}, + \qquad + \textbf{G}_{z=1} = \begin{bmatrix} + 2 & 2 & 3\\ + 4 & 1 & 3\\ + 2 & 1 & 1 \end{bmatrix}, + \qquad + \textbf{G}_{z=2} = \begin{bmatrix} + 3 & 4 & 2\\ + 2 & 2 & 3\\ + 1 & 2 & 1 \end{bmatrix} + +For direction 1, :math:`(1, 1, 1)`, the matrix is below, with grey levels 1 to 4 as rows and run +lengths 1 to 3 as columns. Voxels are written :math:`(z, y, x)`. The main diagonal, level 1 at :math:`(0,0,0)`, :math:`(1,1,1)` and +:math:`(2,2,2)`, is the one run of length 3; the 2 at :math:`(1,0,0)` continues to the 2 at +:math:`(2,1,1)`, a run of length 2. + +.. math:: + + \textbf{P} = \begin{bmatrix} + 3 & 1 & 1\\ + 7 & 1 & 0\\ + 7 & 0 & 0\\ + 3 & 0 & 0 \end{bmatrix}, + \qquad N_s = \sum_{i,j} \textbf{P}(i,j) = 23, \qquad N_v = 27 + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for this ROI in the plain code, which is +direction 1. The test suite runs this example and checks every one of them, in IBSI mode and with +the default settings, which give the same values. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/glrlm_3d_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --dim=3 --features="*3D_GLRLM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +The symbols are those of the :doc:`2D page `, with voxels in place of pixels: + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_g` + - Number of grey levels: the maximum grey level without binning or in IBSI mode, otherwise the + number of distinct binned levels in the bounding box. + * - :math:`N_r` + - Length of the longest run along :math:`\theta`. + * - :math:`i,\; j` + - Grey level of a run (:math:`i`) and its length in voxels (:math:`j`). Formulas use the grey + level's value: the intensity without binning, the binned level otherwise. + * - :math:`\textbf{P}(i,j)` + - Number of runs with grey level :math:`i` and length :math:`j` along one :math:`\theta`. + * - :math:`N_s` + - Number of runs along :math:`\theta`, :math:`N_s = \sum_{i,j}\textbf{P}(i,j)`. + * - :math:`N_v` + - Number of ROI voxels, including voxels of intensity 0. + * - :math:`p_{ij},\; r_{i\cdot},\; r_{\cdot j},\; \mu_i,\; \mu_j,\; \varepsilon` + - As on the :doc:`2D page `. + +Logarithms are computed with the same fast base-2 approximation as in 2D. On the worked example +run entropy is 2.3975 for direction 1, while an exact logarithm gives 2.4013, a relative +1.6 × 10\ :sup:`-3`. + + +.. _glrlm-3d-settings: + +Settings +-------- + +.. list-table:: + :widths: 16 22 32 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - none + - ``Nyxus3D.set_metaparam("3glrlm/greydepth=0")`` + - 0 (the default): no binning, each integer intensity is a grey level. Positive :math:`N`: + MATLAB-style binning into :math:`N` levels from 0 to the ROI maximum, intensity + :math:`x` going to :math:`\lfloor N x / x_{\max} + 1 \rfloor`, capped at :math:`N`, and 0 to + level 1. Negative :math:`-N`: PyRadiomics-style binning into :math:`N` equal-width bins + between the ROI minimum and maximum, with 0 kept as background. + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus3D(..., ibsi=True)`` + - No binning, whatever ``3glrlm/greydepth`` says. + +``3glrlm/greydepth`` is set and read with ``set_metaparam`` and ``get_metaparam`` on a ``Nyxus3D`` +object; the command line has no option for it. It applies to this family only. ``--coarseGrayDepth`` +(``coarse_gray_depth``) does not change the 3D GLRLM: on the worked example it gives the same values +at 2 levels as at 64. ``--glcmAngles`` does not apply either. All options are listed in the +:ref:`cli-reference`. + +On the worked example, ``3glrlm/greydepth=2`` maps the levels 1, 2, 3 and 4 to 1, 2, 2 and 2, which +merges runs: run percentage drops from 0.8519 to 0.7407 and grey level variance from 0.9225 to +0.1875. + + +Output columns +-------------- + +Each feature produces two columns: + +- ``3GLRLM_SRE``: the value for direction 1, :math:`(1, 1, 1)`, only. The other twelve directions + are computed but not written to the output. +- ``3GLRLM_SRE_AVE``: the mean of the 13 per-direction values. + +On the worked example ``3GLRLM_SRE`` is 0.8961 and ``3GLRLM_SRE_AVE`` is 0.9033; ``3GLRLM_RP`` is +0.8519 and ``3GLRLM_RP_AVE`` is 0.8803. + + +.. _glrlm-3d-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the direction-1 value on the worked example. + +.. include:: glrlm_3d_index.inc + + +Features +-------- + +Each feature is defined on the :doc:`2D page `, with the 3D matrix of one direction in +place of the 2D one and :math:`N_v` in place of :math:`N_p`. The formulas are computed the same way +in 3D, with the differences in integer arithmetic noted below. + +.. _feature-3glrlm-gln: + +Grey level non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_GLN`` +:Range: :math:`[1, N_s]`, runs + +Defined as :ref:`GLRLM_GLN `. + +.. _feature-3glrlm-glnn: + +Grey level non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_GLNN`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLRLM_GLNN `. + +.. _feature-3glrlm-glv: + +Grey level variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_GLV`` +:Range: :math:`[0, \infty)`, squared grey levels + +Defined as :ref:`GLRLM_GLV `. + +.. _feature-3glrlm-hglre: + +High grey level run emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_HGLRE`` +:Range: :math:`[1, \infty)`, squared grey levels + +Defined as :ref:`GLRLM_HGLRE `. Unlike the 2D code, the 3D code computes +:math:`i^2` in floating point, so it does not wrap for large grey levels. + +.. _feature-3glrlm-lglre: + +Low grey level run emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_LGLRE`` +:Range: :math:`(0, 1]`, inverse squared grey levels + +Defined as :ref:`GLRLM_LGLRE `. + +.. _feature-3glrlm-lre: + +Long run emphasis +^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_LRE`` +:Range: :math:`[1, N_r^2]`, squared voxels + +Defined as :ref:`GLRLM_LRE `. + +.. _feature-3glrlm-lrhgle: + +Long run high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_LRHGLE`` +:Range: :math:`[1, \infty)`, squared grey levels times squared voxels + +Defined as :ref:`GLRLM_LRHGLE `. + +**Possible issues.** :math:`i^2 j^2` is computed in 32-bit unsigned integers and wraps once it +reaches :math:`2^{32}`. An 8 × 8 × 8 ROI at intensity 10000, with one voxel at 9999, has a run of 8 +along direction 1 and gives :math:`1.0529 \times 10^9` instead of :math:`1.2308 \times 10^9` in IBSI +mode (see :ref:`glrlm-3d-possible-issues`). + +.. _feature-3glrlm-lrlgle: + +Long run low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_LRLGLE`` +:Range: :math:`(0, \infty)`, squared voxels over squared grey levels + +Defined as :ref:`GLRLM_LRLGLE `. + +.. _feature-3glrlm-re: + +Run entropy +^^^^^^^^^^^ + +:Code: ``3GLRLM_RE`` +:Range: :math:`[0, \log_2(N_g N_r)]` bits + +Defined as :ref:`GLRLM_RE `, with the same approximate logarithm. + +.. _feature-3glrlm-rln: + +Run length non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_RLN`` +:Range: :math:`[1, N_s]`, runs + +Defined as :ref:`GLRLM_RLN `. + +.. _feature-3glrlm-rlnn: + +Run length non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_RLNN`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLRLM_RLNN `. + +.. _feature-3glrlm-rp: + +Run percentage +^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_RP`` +:Range: :math:`(0, 1]`, dimensionless + +The number of runs along the direction per ROI voxel. + +.. math:: + + \mathrm{RP} = \frac{N_s}{N_v} + +:math:`N_v` counts every ROI voxel, including voxels of intensity 0 that form no run. + +**Possible issues.** With MATLAB-style binning, runs of voxels outside the mask are counted in +:math:`N_s` but not in :math:`N_v`, so the value can exceed 1 (see +:ref:`glrlm-3d-possible-issues`). + +.. _feature-3glrlm-rv: + +Run variance +^^^^^^^^^^^^ + +:Code: ``3GLRLM_RV`` +:Range: :math:`[0, \infty)`, squared voxels + +Defined as :ref:`GLRLM_RV `. + +.. _feature-3glrlm-sre: + +Short run emphasis +^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_SRE`` +:Range: :math:`(0, 1]`, inverse squared voxels + +Defined as :ref:`GLRLM_SRE `. + +.. _feature-3glrlm-srhgle: + +Short run high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_SRHGLE`` +:Range: :math:`(0, \infty)`, squared grey levels over squared voxels + +Defined as :ref:`GLRLM_SRHGLE `. + +.. _feature-3glrlm-srlgle: + +Short run low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLRLM_SRLGLE`` +:Range: :math:`(0, 1]`, inverse squared grey levels times inverse squared voxels + +Defined as :ref:`GLRLM_SRLGLE `. + +**Possible issues.** :math:`i^2 j^2` is computed in 32-bit unsigned integers. When it wraps to 0 +for any cell of the matrix, empty or not, the result is 0: the 8 × 8 × 8 ROI described under long +run high grey level emphasis gives 0 instead of :math:`3.374 \times 10^{-9}` (see +:ref:`glrlm-3d-possible-issues`). + + +.. _glrlm-3d-possible-issues: + +Possible issues +--------------- + +The plain codes report one direction + ``3GLRLM_SRE`` and the other plain codes hold direction 1, :math:`(1, 1, 1)`, only: a diagonal + through the slices. They are not comparable with a single direction of another tool unless that + tool's direction is the same step, and they change if the volume is transposed. The ``_AVE`` + codes use all 13 directions and do not depend on their order. + +Voxels outside the mask with MATLAB-style binning + A positive ``3glrlm/greydepth`` maps intensity 0 to grey level 1. Voxels of the bounding box + outside the mask, and ROI voxels of intensity 0, then form runs of level 1 and join runs of ROI + voxels at that level. On the worked example with two corner voxels of the bounding box left out + of the mask and ``3glrlm/greydepth=2``, run percentage is 0.8 instead of 0.72 and low grey level + run emphasis is 0.5125 instead of 0.4583, the values with those voxels excluded. The default + (no binning), IBSI mode and PyRadiomics-style binning exclude these voxels. + + These runs can make run percentage exceed 1. The worked-example script builds an ellipsoid with + semi-axes 12, 30 and 30 voxels, which fills 45,224 of the 86,400 voxels of its 24 × 60 × 60 + bounding box, with intensities drawn uniformly from 1 to 255. ``3GLRLM_RP`` is 1.131 with + ``3glrlm/greydepth=16``, 1.182 with 64 and 1.191 with 128, and ``3GLRLM_RP_AVE`` is 1.130 with + 64. Without binning they are 0.9962 and 0.9965. A ROI that fills its bounding box cannot exceed + 1: a 20 × 20 × 20 cube of the same kind of intensities gives 0.9863 with + ``3glrlm/greydepth=64``. + +Grey-level values depend on the binning + As in 2D, ``GLV`` and the six emphasis features that weight runs by grey level use the grey + level's value: the intensity by default, the bin number with a ``3glrlm/greydepth``. Compare + them only between runs with the same setting. + +Integer overflow with large grey levels or long runs + The products :math:`i^2` and :math:`i^2 j^2` are computed in 32-bit unsigned integers before + conversion to floating point, except :math:`i^2` in ``HGLRE``, which is computed in floating + point: + + .. list-table:: + :widths: 40 60 + :header-rows: 1 + + * - Feature + - Effect + * - ``LGLRE``, ``SRHGLE``, ``LRLGLE`` + - Wrong for grey levels of 65536 and above, which 8- and 16-bit images do not reach + * - ``LRHGLE`` + - Wrong when :math:`i^2 j^2 \ge 2^{32}` for a run + * - ``SRLGLE`` + - 0 when :math:`i^2 j^2` wraps to 0 for any cell of the matrix + + Without binning, the default, this happens with 16-bit intensities and short runs. A 2 × 2 × 2 + ROI with maximum intensity 65535 and runs of length 1 is still exact. + +Constant ROI + When all of a ROI's voxels have one intensity, every 3D GLRLM feature, including the ``_AVE`` + columns, returns the ``--noval`` substitute. + + +Validation +---------- + +The test suite checks every 3D GLRLM feature against PyRadiomics on a test phantom with +PyRadiomics-style binning into 20 bins (``3glrlm/greydepth=-20``), named in the +:ref:`index `. Each of the 13 per-direction values is compared with PyRadiomics' +value for the same direction, and each ``_AVE`` with PyRadiomics' value aggregated over the directions, to a relative +10\ :sup:`-9`; run entropy agrees to 2 × 10\ :sup:`-3` because of the approximate logarithm. +Out-of-core results are checked against the in-memory ones. MATLAB-style binning is covered by +regression tests only. + + +References +---------- + +1. Galloway MM. *Texture analysis using gray level run lengths.* Computer Graphics and Image + Processing. 1975;4(2):172–179. +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io +3. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. diff --git a/docs/source/Math/f_glszm.rst b/docs/source/Math/f_glszm.rst index ed3611eb8..4a1edc748 100644 --- a/docs/source/Math/f_glszm.rst +++ b/docs/source/Math/f_glszm.rst @@ -1,47 +1,576 @@ +Grey-level size-zone matrix (GLSZM) +=================================== -Texture features / GLSZM -======================== +GLSZM features describe the zones of an ROI: groups of connected pixels that share one grey level. +The matrix counts zones by grey level and by size, regardless of direction. Fine textures give many +small zones; coarse or homogeneous textures give few large ones. -A Gray Level Size Zone (GLSZM) quantifies gray level zones in an image. A gray level zone is defined as a the number -of connected voxels that share the same gray level intensity. A voxel is considered connected if the distance is 1 -according to the Euclidean norm (8-connected region in 2D). -In a gray level size zone matrix :math:`P(i,j)` the :math:`(i,j)^{\text{th}}` element equals the number of zones -with gray level :math:`i` and size :math:`j` appear in image. Contrary to GLCM and GLRLM, the GLSZM is rotation -independent, with only one matrix calculated for all directions in the ROI. +.. list-table:: + :widths: 26 74 -As an example, consider the following 5x5 ROI image, with 5 discrete gray levels: + * - **Feature codes** + - ``GLSZM_*``: 16 features, one value per ROI + * - **Group** + - ``*ALL_GLSZM*``; also part of ``*ALL*`` and ``*WHOLESLIDE*`` + * - **Dimensions** + - 2D. The 3D family, ``3GLSZM_*``, has its own page, :doc:`f_glszm_3d`. + * - **GPU** + - With ``--useGpu=true`` in GPU builds the features are summed on the GPU in single + precision, with an exact logarithm for zone entropy; zones are found on the CPU. The GPU + path is not validated by the test suite, and its grey level non-uniformity, normalized grey + level non-uniformity, grey level variance and zone variance do not follow the formulas on + this page. + * - **Out-of-core** + - Yes, with the same values as in memory on an image that Nyxus reads as one tile. On other + images out-of-core processing of this family raises an error or returns wrong + values, depending on the ROI (see :ref:`large-images-tiles`). + * - **IBSI** + - All 16 features have an IBSI identifier. + +.. tip:: + + To reproduce IBSI, PyRadiomics or MIRP values, run in IBSI mode: ``--ibsi=true`` on the command + line, ``ibsi=True`` in Python. With the default settings the pixels of the bounding box that lie + outside the mask become grey level 1 and form zones of their own, which changes every feature + (see :ref:`glszm-possible-issues`). + + +How the matrix is built +----------------------- + +Nyxus scans the ROI's bounding box row by row, from the top left. Each pixel that is not yet in a +zone and has a non-zero grey level starts a new zone. The zone grows through pixels of the same grey +level, one step at a time, to the east, south-east, south or south-west of a pixel already in the +zone. :math:`\textbf{P}(i,j)` counts the zones of grey level :math:`i` and size :math:`j` pixels. + +- **Connectivity:** the steps cover half of the 8-neighbourhood. A zone whose pixels can all be + reached this way from its first pixel is the 8-connected zone that IBSI defines. A zone that needs + a step to the west, north-west, north or north-east is split into several zones (see + :ref:`glszm-possible-issues`). +- **Directions:** none. One matrix is built per ROI, and each feature has one value. +- **Grey levels:** intensities are first binned into :math:`N` grey levels, as set by + ``--coarseGrayDepth`` (64 by default). A pixel of intensity :math:`x` gets level + :math:`\lfloor N x / x_{\max} \rfloor + 1`, capped at :math:`N`, where :math:`x_{\max}` is + the ROI's largest intensity. In IBSI mode they are not binned: each integer intensity is a grey + level. +- **Background:** in IBSI mode, and with PyRadiomics-style binning (a negative + ``--coarseGrayDepth``), pixels of intensity 0 belong to no zone, whether they lie outside the mask + or inside it. With the default binning, intensity 0 becomes grey level 1, so the bounding-box + pixels outside the mask form zones and are counted in :math:`N_p`. + + +Worked example +^^^^^^^^^^^^^^ + +Take a 5 × 5 ROI that already has five grey levels: + +.. math:: + + \textbf{G} = \begin{bmatrix} + 1 & 2 & 5 & 2 & 3\\ + 3 & 2 & 1 & 3 & 1\\ + 1 & 3 & 5 & 5 & 2\\ + 1 & 1 & 1 & 1 & 2\\ + 1 & 2 & 4 & 3 & 5 \end{bmatrix} + +In IBSI mode (no binning) the matrix is below; rows are grey levels 1 to 5 and columns are zone +sizes 1 to 6. The six pixels of level 1 in the lower left form one zone; the 3 at the start of row +2 joins the 3 below it to the south-east, a zone of size 2. Every zone of this image is the same +under Nyxus' scan and under full 8-connectivity. + +.. math:: + + \textbf{P} = \begin{bmatrix} + 3 & 0 & 0 & 0 & 0 & 1\\ + 2 & 2 & 0 & 0 & 0 & 0\\ + 1 & 2 & 0 & 0 & 0 & 0\\ + 1 & 0 & 0 & 0 & 0 & 0\\ + 2 & 1 & 0 & 0 & 0 & 0 \end{bmatrix}, + \qquad N_z = \sum_{i,j} \textbf{P}(i,j) = 15, \qquad N_p = 25 + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for this +ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/glszm_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features="*ALL_GLSZM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_g` + - Number of rows of :math:`\textbf{P}`. With binning, the number of grey levels present in the + bounding box; in IBSI mode, the ROI's largest intensity. + * - :math:`g_i` + - Grey level of row :math:`i`. With binning, the :math:`i`-th smallest level present; in IBSI + mode, :math:`g_i = i`. + * - :math:`N_s` + - Number of columns of :math:`\textbf{P}`: the number of pixels in the bounding box, the + largest possible zone. + * - :math:`\textbf{P}(i,j)` + - Number of zones of grey level :math:`g_i` and size :math:`j` pixels. + * - :math:`N_z` + - Number of zones, :math:`N_z = \sum_{i,j}\textbf{P}(i,j)`. + * - :math:`N_p` + - Number of pixels in zones: the bounding-box pixels with a non-zero grey level. + * - :math:`p(i,j)` + - Zone probability, :math:`p(i,j) = \textbf{P}(i,j) / N_z`. + * - :math:`s_i,\; s_j` + - Zones per grey level, :math:`s_i = \sum_j \textbf{P}(i,j)`, and per size, + :math:`s_j = \sum_i \textbf{P}(i,j)`. + * - :math:`\mu_g,\; \mu_s` + - Mean grey level and mean size over zones, :math:`\mu_g = \sum_{i,j} g_i\,p(i,j)` and + :math:`\mu_s = \sum_{i,j} j\,p(i,j)`. + * - :math:`\varepsilon` + - :math:`2.2 \times 10^{-16}`, added inside the logarithm. + +Sums over :math:`i` run from 1 to :math:`N_g` and over :math:`j` from 1 to :math:`N_s`. Empty rows +and columns add nothing. Every feature uses the grey level :math:`g_i`, never the row position. + +The logarithm in zone entropy is computed with a fast single-precision base-2 approximation (on +the CPU; see :ref:`conventions-entropy` for the GPU path). Zone entropy therefore differs from an +exact logarithm by at most 0.009 bits: a relative 7.1 × 10\ :sup:`-4` on the worked example, and +under 4 × 10\ :sup:`-3` on the IBSI phantom, the tolerance it is validated at. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - ``--coarseGrayDepth=64`` + - ``Nyxus(..., coarse_gray_depth=64)`` + - Number of grey levels. Positive: equal-width bins from 0 to the ROI maximum, with background + as level 1; negative: PyRadiomics-style bins over the ROI range (command line only). + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - No binning; pixels of intensity 0 are excluded. + +The 2D family has no direction, distance or metaparameter settings. ``3glszm/greydepth`` applies to +``3GLSZM_*`` only. All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``GLSZM_SAE``, ``GLSZM_ZP`` and so on. There +are no per-direction columns and no ``_AVE`` columns. + + +.. _glszm-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. + +.. include:: glszm_index.inc + + +Features +-------- + +.. _feature-glszm-gln: + +Grey level non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_GLN`` +:Range: :math:`[1, N_z]`, zones + +Measures how evenly the zones are spread over the grey levels. Low values mean the zones are spread +evenly. + +.. math:: + + \mathrm{GLN} = \frac{1}{N_z}\sum_{i=1}^{N_g} s_i^{\,2} + + +.. _feature-glszm-glnn: + +Grey level non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_GLNN`` +:Range: :math:`(0, 1]`, dimensionless + +Grey level non-uniformity divided by the number of zones once more, so values are comparable across +ROIs. + +.. math:: + + \mathrm{GLNN} = \frac{1}{N_z^{\,2}}\sum_{i=1}^{N_g} s_i^{\,2} + + +.. _feature-glszm-glv: + +Grey level variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_GLV`` +:Range: :math:`[0, \infty)`, squared grey levels + +The variance of the grey level over all zones, each zone counted once whatever its size. + +.. math:: + + \mathrm{GLV} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_s} \big(g_i - \mu_g\big)^2\, p(i,j) + +**Possible issues.** The value scales with the square of the grey-level spacing, so it depends +strongly on binning. On the worked example it is 2.089 in IBSI mode and 341.05 with the default +64-level binning, which maps the 5 levels to 13, 26, 39, 52 and 64. Compare it only between runs +with the same ``--coarseGrayDepth``. + + +.. _feature-glszm-hglze: + +High grey level zone emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_HGLZE`` +:Range: :math:`[g_1^{\,2}, g_{N_g}^{\,2}]`, squared grey levels + +The mean over all zones of the squared grey level. High when bright zones dominate. + +.. math:: + + \mathrm{HGLZE} = \frac{1}{N_z}\sum_{i=1}^{N_g} s_i\, g_i^{\,2} + + +.. _feature-glszm-lae: + +Large area emphasis +^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_LAE`` +:Range: :math:`[1, N_p^{\,2}]`, squared pixels + +The mean over all zones of the squared zone size. High when large zones dominate, as in coarse +texture. IBSI calls it *large zone emphasis*. + +.. math:: + + \mathrm{LAE} = \frac{1}{N_z}\sum_{j=1}^{N_s} s_j\, j^2 + +**Possible issues.** Wrong, and often negative, when a zone has more than 46 340 pixels (see +:ref:`glszm-possible-issues`). + + +.. _feature-glszm-lahgle: + +Large area high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_LAHGLE`` +:Range: :math:`(0, \infty)`, squared pixels times squared grey levels + +The mean over all zones of the squared zone size times the squared grey level. High for large +bright zones. + +.. math:: + + \mathrm{LAHGLE} = \frac{1}{N_z}\sum_{i=1}^{N_g}\sum_{j=1}^{N_s} \textbf{P}(i,j)\, g_i^{\,2}\, j^2 + + +.. _feature-glszm-lalgle: + +Large area low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_LALGLE`` +:Range: :math:`(0, \infty)`, squared pixels over squared grey levels + +The mean over all zones of the squared zone size over the squared grey level. High for large dark +zones. + +.. math:: + + \mathrm{LALGLE} = \frac{1}{N_z}\sum_{i=1}^{N_g}\sum_{j=1}^{N_s} \frac{\textbf{P}(i,j)\, j^2}{g_i^{\,2}} + +**Possible issues.** With the default binning the background zones have level 1, the largest +weight, and dominate the value (see :ref:`glszm-possible-issues`). + + +.. _feature-glszm-lglze: + +Low grey level zone emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_LGLZE`` +:Range: :math:`(0, 1]`, inverse squared grey levels + +The mean over all zones of one over the squared grey level. High when dark zones dominate. + +.. math:: + + \mathrm{LGLZE} = \frac{1}{N_z}\sum_{i=1}^{N_g} \frac{s_i}{g_i^{\,2}} + +**Possible issues.** With the default binning the background zones have level 1 and weight 1, far +above any binned ROI level. On the ROI in :ref:`glszm-possible-issues` the value is 0.3342 with +them and 0.001356 without them. + + +.. _feature-glszm-sae: + +Small area emphasis +^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_SAE`` +:Range: :math:`(0, 1]`, inverse squared pixels + +The mean over all zones of one over the squared zone size. High when small zones dominate, as in +fine texture. IBSI calls it *small zone emphasis*. + +.. math:: + + \mathrm{SAE} = \frac{1}{N_z}\sum_{j=1}^{N_s} \frac{s_j}{j^2} + +**Possible issues.** A zone split by the scan adds smaller zones and changes the value: 0.4537 +instead of 0.1563 on the 2 × 3 image in :ref:`glszm-possible-issues`. +When the bounding box has 65 536 pixels or more, the value is the ``--noval`` substitute, 0 by +default (``-0.0`` in Arrow and Parquet files; see :ref:`conventions-missing`). + + +.. _feature-glszm-sahgle: + +Small area high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_SAHGLE`` +:Range: :math:`(0, g_{N_g}^{\,2}]`, squared grey levels over squared pixels + +The mean over all zones of the squared grey level over the squared zone size. High for small bright +zones. + +.. math:: + + \mathrm{SAHGLE} = \frac{1}{N_z}\sum_{i=1}^{N_g}\sum_{j=1}^{N_s} \frac{\textbf{P}(i,j)\, g_i^{\,2}}{j^2} + + +.. _feature-glszm-salgle: + +Small area low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_SALGLE`` +:Range: :math:`(0, 1]`, inverse squared pixels and grey levels + +The mean over all zones of one over the squared zone size times the squared grey level. High for +small dark zones. + +.. math:: + + \mathrm{SALGLE} = \frac{1}{N_z}\sum_{i=1}^{N_g}\sum_{j=1}^{N_s} \frac{\textbf{P}(i,j)}{g_i^{\,2}\, j^2} + + +.. _feature-glszm-szn: + +Size zone non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_SZN`` +:Range: :math:`[1, N_z]`, zones + +Measures how evenly the zones are spread over the sizes. Low values mean the sizes are spread +evenly. .. math:: - \textbf{G} = \begin{bmatrix} - 5 & 2 & 5 & 4 & 4\\ - 3 & 3 & 3 & 1 & 3\\ - 2 & 1 & 1 & 1 & 3\\ - 4 & 2 & 2 & 2 & 3\\ - 3 & 5 & 3 & 3 & 2 \end{bmatrix} + \mathrm{SZN} = \frac{1}{N_z}\sum_{j=1}^{N_s} s_j^{\,2} -The GLSZM then becomes: + +.. _feature-glszm-sznn: + +Size zone non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_SZNN`` +:Range: :math:`(0, 1]`, dimensionless + +Size zone non-uniformity divided by the number of zones once more, so values are comparable across +ROIs. + +.. math:: + + \mathrm{SZNN} = \frac{1}{N_z^{\,2}}\sum_{j=1}^{N_s} s_j^{\,2} + + +.. _feature-glszm-ze: + +Zone entropy +^^^^^^^^^^^^ + +:Code: ``GLSZM_ZE`` +:Range: :math:`[0, \log_2(N_g N_s)]` bits + +Measures the randomness of the joint distribution of zone grey level and zone size. Higher values +mean more varied zones. .. math:: - \textbf{P} = \begin{bmatrix} - 0 & 0 & 0 & 1 & 0\\ - 1 & 0 & 0 & 0 & 1\\ - 1 & 0 & 1 & 0 & 1\\ - 1 & 1 & 0 & 0 & 0\\ - 3 & 0 & 0 & 0 & 0 \end{bmatrix} + \mathrm{ZE} = -\sum_{i=1}^{N_g}\sum_{j=1}^{N_s} p(i,j)\,\log_2\!\big(p(i,j)+\varepsilon\big) + +**Possible issues.** The logarithm is approximate. On the worked example Nyxus returns 3.0544, while +an exact logarithm gives 3.0566, a relative difference of 7.1 × 10\ :sup:`-4`. The approximation +works in single precision, so :math:`\varepsilon` has no effect on non-zero probabilities. + + +.. _feature-glszm-zp: + +Zone percentage +^^^^^^^^^^^^^^^ + +:Code: ``GLSZM_ZP`` +:Range: :math:`[1/N_p, 1]`, dimensionless + +The number of zones divided by the number of pixels. High for fine texture, where most zones are +small. + +.. math:: + + \mathrm{ZP} = \frac{N_z}{N_p} + +**Possible issues.** :math:`N_p` counts the bounding-box pixels with a non-zero grey level. With the +default binning that is every pixel of the bounding box, inside the mask or not. + + +.. _feature-glszm-zv: -Let: +Zone variance +^^^^^^^^^^^^^ + +:Code: ``GLSZM_ZV`` +:Range: :math:`[0, \infty)`, squared pixels + +The variance of the zone size over all zones. IBSI calls it *zone size variance*. + +.. math:: + + \mathrm{ZV} = \sum_{i=1}^{N_g}\sum_{j=1}^{N_s} \big(j - \mu_s\big)^2\, p(i,j) + + +.. _glszm-possible-issues: + +Possible issues +--------------- + +Zones are not fully 8-connected + A zone grows only by steps to the east, south-east, south and south-west. A same-level pixel that + can be reached only by a step to the west, north-west, north or north-east starts a zone of its + own. In the image below, the 1 at the lower left touches the 1 to its right, but the zone starts + at the top-right 1 and never steps west, so Nyxus finds zones of size 3 and 1 where IBSI has one + zone of size 4. + + .. math:: + + \begin{bmatrix} 2 & 2 & 1\\ 1 & 1 & 1 \end{bmatrix} + + Every feature can change. On this image zone percentage is 0.5 instead of 0.3333, and small area + emphasis 0.4537 instead of 0.1563. On the 5 × 5 example of the PyRadiomics GLSZM documentation + Nyxus finds 13 zones instead of 11, zone percentage is 0.52 instead of 0.44 and small area + emphasis 0.6596 instead of 0.5912. Of the 512 ways to fill a 3 × 3 image with two grey levels, + 152 give a different set of zones. IBSI mode does not change the scan. + +Background as grey level 1 with the default binning + With a positive ``--coarseGrayDepth``, intensity 0 bins to level 1. The bounding-box pixels + outside the mask then form zones of level 1, and :math:`N_p` is the size of the bounding box. ROI + pixels whose intensity is below :math:`x_{\max} / N` also get level 1 and can join those zones. + Take this 6 × 5 ROI, whose four corners lie outside the mask: + + .. math:: + + \begin{bmatrix} + \cdot & 3 & 7 & 7 & 7 & \cdot\\ + 3 & 5 & 5 & 7 & 2 & 2\\ + 6 & 5 & 5 & 9 & 9 & 2\\ + 6 & 6 & 4 & 9 & 4 & 4\\ + \cdot & 6 & 4 & 4 & 4 & \cdot \end{bmatrix} + + With the default settings the four corners add four zones of size 1. Nyxus counts 12 zones over + 30 pixels, so zone percentage is 0.4; without the corners the same binning gives 8 zones over 26 + pixels, 0.3077. Small area emphasis is 0.4144 instead of 0.1215, and low grey level zone + emphasis 0.3342 instead of 0.001356. IBSI mode excludes the corners and gives 8 zones over 26 + pixels. + +Pixels of intensity 0 inside the mask + In IBSI mode a pixel of intensity 0 belongs to no zone and is not counted in :math:`N_p`, even + inside the mask. + +Constant ROI + When all of a ROI's pixels have the same intensity, every GLSZM feature returns the ``--noval`` + substitute (0 by default). + +Very large zones and bounding boxes + Large area emphasis and small area emphasis square the zone size in 32-bit integer arithmetic. + Large area emphasis is wrong as soon as a zone has more than 46 340 pixels. On a 250 × 250 ROI + holding one zone of 62 499 pixels and one of 1 pixel, Nyxus returns -194 421 147 instead of + 1 953 062 501, and on a 300 × 300 ROI with a zone of 89 999 pixels, -245 057 295 instead of + 4 049 910 001. The overflow is undefined behaviour, so the value depends on the build: one macOS + ARM build squares in unsigned 32-bit arithmetic and returns 1 953 062 501 and 1 902 426 353. Small area emphasis sums over every zone size up to the size of the bounding box. + When the bounding box has 65 536 pixels or more, one of those terms is undefined and the value + is replaced by the ``--noval`` substitute (0 by default; ``-0.0`` in Arrow and Parquet files), + whatever the zones: a random texture of four grey levels gives about 0.5 in a 255 × 256 box and + 0 in a 300 × 300 box. The other 14 + features are computed in double precision and are exact at these sizes, including the two + large area grey level emphases. IBSI mode does not change this. + +Memory use + :math:`\textbf{P}` has :math:`N_g` rows and one column per bounding-box pixel. In IBSI mode + :math:`N_g` is the largest intensity, so a 200 × 200 ROI with intensities up to 4095 needs a + 655 MB matrix. Binning to a small :math:`N_g` keeps it small: 23 MB for a 300 × 300 ROI at 64 + levels. + + +Validation +---------- +The test suite checks every 2D GLSZM feature against MIRP on the four slices of the IBSI digital +phantom in IBSI mode. Fifteen features agree to an absolute 10\ :sup:`-9`; zone entropy agrees to a +relative 4 × 10\ :sup:`-3`, which allows for the approximate logarithm. The four-slice means are +also compared with IBSI's published consensus values at a relative 10\ :sup:`-2`, the precision +those values are published to; that comparison is a consistency check, not one of the validations +in the index. The phantom's zones are the same under Nyxus' scan and under +8-connectivity, so these comparisons do not cover the split zones described above. The default +mode is covered by regression tests only. -* :math:`N_g` be the number of discrete intensity values in the image -* :math:`N_s` be the number of discrete zone sizes in the image -* :math:`N_p` be the number of voxels in the image -* :math:`N_z` be the number of zones in the ROI, which is equal to :math:`\sum_{i=1}^{N_g}\sum^{N_s}_{j=1}{\textbf{P}(i,j)}` and :math:`1 \leq N_z \leq N_p` -* :math:`\textbf{P}(i,j)` be the size zone matrix -* :math:`p(i,j)` be the normalized size zone matrix, defined as :math:`p(i,j) = \frac{\textbf{P}(i,j)}{N_z}` References ---------- -Guillaume Thibault; Bernard Fertil; Claire Navarro; Sandrine Pereira; Pierre Cau; Nicolas Levy; Jean Sequeira; Jean-Luc Mari (2009). “Texture Indexes and Gray Level Size Zone Matrix. Application to Cell Nuclei Classification”. Pattern Recognition and Information Processing (PRIP): 140-145. +1. Thibault G, Fertil B, Navarro C, et al. *Texture indexes and gray level size zone matrix: + application to cell nuclei classification.* Pattern Recognition and Information Processing + (PRIP). 2009:140–145. +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io +3. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. diff --git a/docs/source/Math/f_glszm_3d.rst b/docs/source/Math/f_glszm_3d.rst new file mode 100644 index 000000000..ea0a06d35 --- /dev/null +++ b/docs/source/Math/f_glszm_3d.rst @@ -0,0 +1,496 @@ +3D grey-level size-zone matrix (3D GLSZM) +========================================= + +3D GLSZM features describe the zones of a 3D ROI: groups of connected voxels that share one grey +level. The matrix counts zones by grey level and by size in voxels, regardless of direction. The +features are those of the :doc:`2D GLSZM `, computed on the 3D matrix. This page covers +what is specific to 3D: how zones are connected, how grey levels are set, and which voxels take +part. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``3GLSZM_*``: 16 features, one value per ROI + * - **Group** + - ``*3D_GLSZM*``; also part of ``*3D_ALL_TEXTURE*`` and ``*3D_ALL*`` + * - **Dimensions** + - 3D. The 2D family, ``GLSZM_*``, is described in :doc:`f_glszm`. + * - **GPU** + - No. The features are computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory. + When one read of the file returns the whole volume (NIfTI, or OME-Zarr chunked over all of + Z), an oversized ROI stops the run with an error (see :doc:`/large_images`). + * - **IBSI** + - All 16 features have an IBSI identifier. Zones are 26-connected, as IBSI defines them in 3D, + and the matrix is the single 3D matrix of IBSI aggregation method KOBO. + +.. tip:: + + By default the 3D family does not bin intensities: each integer intensity is a grey level, as in + IBSI mode, and ``--coarseGrayDepth`` has no effect on it. Binning is set with the + ``3glszm/greydepth`` metaparameter, from Python only (see `Settings`_). + + +How the matrix is built +----------------------- + +Nyxus takes the ROI's bounding box, sets the voxels outside the mask to intensity 0 and bins the +rest. It then visits the voxels slice by slice, row by row. Each voxel that is not yet in a zone and +does not have the background level starts a new zone. The zone grows through every voxel of the same +grey level that touches a voxel already in it, by a face, an edge or a corner. +:math:`\textbf{P}(i,j)` counts the zones of grey level :math:`i` and size :math:`j` voxels. + +- **Connectivity:** 26-connected. All 26 neighbours of a voxel are searched: 8 in its own slice and + 9 in each adjacent slice. A zone is the full 26-connected component, whatever the scan order. This + differs from the 2D family, whose zones grow only to the east, south-east, south and south-west. +- **Directions:** none. One matrix is built per ROI, and each feature has one value. +- **Grey levels:** set by the ``3glszm/greydepth`` metaparameter, 0 by default. + + - 0, and always in IBSI mode: no binning. A voxel of intensity :math:`x` has grey level + :math:`x`, and :math:`\textbf{P}` has one row for every level from 1 to the ROI's largest + intensity, including levels no voxel has. + - Positive :math:`n` (MATLAB-style): level :math:`\lfloor n\,x / x_{\max} + 1 \rfloor`, clipped + to :math:`[1, n]`, where :math:`x_{\max}` is the ROI's largest intensity. Intensity 0 is level + 1. + - Negative :math:`-n` (PyRadiomics-style): level :math:`\lfloor (x - x_{\min}) / w + 1 \rfloor`, + capped at :math:`n`, with :math:`w = (x_{\max} - x_{\min}) / n` over the ROI's range. Intensity + 0 is level 0. + + With binning, :math:`\textbf{P}` has one row per level present in the bounding box. +- **Background:** the background level is 0, or 1 with MATLAB-style binning. Voxels at that level + belong to no zone: the bounding-box voxels outside the mask, and also ROI voxels whose binned level + is the background level (see :ref:`glszm-3d-possible-issues`). +- **Voxel count:** :math:`N_v` is the number of voxels in the ROI mask, whatever their intensity. + The 2D family counts the non-zero pixels of the bounding box instead. + + +Worked example +^^^^^^^^^^^^^^ + +Take a 3 × 3 × 3 ROI that fills its bounding box and already has four grey levels. The slices are +:math:`z = 0, 1, 2`, each with rows :math:`y` and columns :math:`x` from 0: + +.. math:: + + \textbf{G}_{z=0} = \begin{bmatrix} + 3 & 3 & 4\\ + 2 & 2 & 4\\ + 4 & 3 & 1 \end{bmatrix}, + \qquad + \textbf{G}_{z=1} = \begin{bmatrix} + 4 & 2 & 1\\ + 3 & 2 & 2\\ + 1 & 1 & 3 \end{bmatrix}, + \qquad + \textbf{G}_{z=2} = \begin{bmatrix} + 1 & 2 & 3\\ + 4 & 4 & 4\\ + 4 & 3 & 4 \end{bmatrix} + +In IBSI mode, and with the default settings, the matrix is below; rows are grey levels 1 to 4 and +columns are zone sizes 1 to 6. + +.. math:: + + \textbf{P} = \begin{bmatrix} + 2 & 0 & 1 & 0 & 0 & 0\\ + 0 & 0 & 0 & 0 & 0 & 1\\ + 1 & 0 & 0 & 0 & 0 & 1\\ + 1 & 1 & 0 & 0 & 0 & 1 \end{bmatrix}, + \qquad N_z = 9, \qquad N_v = 27 + +The six voxels of level 2 form one zone. The level-3 zone of size 6 joins two groups of three, the 3s +at :math:`(z,y,x) = (0,0,0), (0,0,1), (1,1,0)` and at :math:`(0,2,1), (1,2,2), (2,2,1)`. Only the +corner shared by :math:`(1,1,0)` and :math:`(0,2,1)` connects them. With 18-connectivity +(faces and edges) the volume has 10 zones; with 6-connectivity (faces only) it has 15. + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for +this ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/glszm_3d_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --dim=3 --features="*3D_GLSZM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +The symbols are those of the :doc:`2D page `, with voxels in place of pixels: + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_g` + - Number of rows of :math:`\textbf{P}`. Without binning, the ROI's largest intensity; with + binning, the number of levels present in the bounding box. + * - :math:`g_i` + - Grey level of row :math:`i`. Without binning, :math:`g_i = i`; with binning, the + :math:`i`-th smallest level present. + * - :math:`N_s` + - Number of columns of :math:`\textbf{P}`: the size of the largest zone, in voxels. + * - :math:`\textbf{P}(i,j)` + - Number of zones of grey level :math:`g_i` and size :math:`j` voxels. + * - :math:`N_z` + - Number of zones, :math:`N_z = \sum_{i,j}\textbf{P}(i,j)`. + * - :math:`N_v` + - Number of voxels in the ROI mask, including voxels that belong to no zone. + * - :math:`p(i,j)` + - Zone probability, :math:`p(i,j) = \textbf{P}(i,j) / N_z`. + * - :math:`s_i,\; s_j` + - Zones per grey level, :math:`s_i = \sum_j \textbf{P}(i,j)`, and per size, + :math:`s_j = \sum_i \textbf{P}(i,j)`. + +Every feature uses the grey level :math:`g_i`, never the row position. Zone entropy uses the same +fast single-precision base-2 logarithm as the 2D family. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - none + - ``nyx.set_metaparam("3glszm/greydepth=64")`` on a ``Nyxus3D`` object + - 0 (the default): no binning. Positive: MATLAB-style bins from 0 to the ROI maximum, with + intensity 0 and the lowest bin as background. Negative: PyRadiomics-style bins over the + ROI range. + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus3D(..., ibsi=True)`` + - No binning, whatever ``3glszm/greydepth`` holds. Voxels of intensity 0 are in no zone. + * - Missing value + - ``--noval=0`` + - — + - Value of every feature of a constant ROI. + * - Memory budget + - ``--ramLimit`` + - ``Nyxus3D(..., ram_limit=...)`` + - ROIs above it are processed out of core, with the same values, unless one read of the file + returns the whole volume: then such a ROI stops the run with an error. + +``--coarseGrayDepth`` and ``coarse_gray_depth`` do not affect this family. The family has no +direction, distance or connectivity setting. All options are listed in the :ref:`cli-reference`. + +On the worked example, ``3glszm/greydepth=-3`` bins levels 1, 2, 3 and 4 to 1, 2, 3 and 3: 5 zones, +zone percentage 0.1852, small area emphasis 0.4286 and grey level variance 0.64. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``3GLSZM_SAE``, ``3GLSZM_ZP`` and so on. +There are no per-direction columns and no ``_AVE`` columns. + + +.. _glszm-3d-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. + +.. include:: glszm_3d_index.inc + + +Features +-------- + +Each feature is the 2D feature of the same name, computed on the 3D matrix with zone sizes in +voxels. Only zone percentage uses a different denominator. + +.. _feature-3glszm-gln: + +3GLSZM_GLN: grey level non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_GLN`` +:Range: :math:`[1, N_z]`, zones + +Defined as :ref:`GLSZM_GLN `. + + +.. _feature-3glszm-glnn: + +3GLSZM_GLNN: grey level non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_GLNN`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLSZM_GLNN `. + + +.. _feature-3glszm-glv: + +3GLSZM_GLV: grey level variance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_GLV`` +:Range: :math:`[0, \infty)`, squared grey levels + +Defined as :ref:`GLSZM_GLV `. Without binning it is in squared intensity units. + + +.. _feature-3glszm-hglze: + +3GLSZM_HGLZE: high grey level zone emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_HGLZE`` +:Range: :math:`[g_1^{\,2}, g_{N_g}^{\,2}]`, squared grey levels + +Defined as :ref:`GLSZM_HGLZE `. + + +.. _feature-3glszm-lae: + +3GLSZM_LAE: large area emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_LAE`` +:Range: :math:`[1, N_v^{\,2}]`, squared voxels + +Defined as :ref:`GLSZM_LAE `. IBSI calls it *large zone emphasis*. + + +.. _feature-3glszm-lahgle: + +3GLSZM_LAHGLE: large area high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_LAHGLE`` +:Range: :math:`(0, \infty)`, squared voxels times squared grey levels + +Defined as :ref:`GLSZM_LAHGLE `. + + +.. _feature-3glszm-lalgle: + +3GLSZM_LALGLE: large area low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_LALGLE`` +:Range: :math:`(0, \infty)`, squared voxels over squared grey levels + +Defined as :ref:`GLSZM_LALGLE `. + + +.. _feature-3glszm-lglze: + +3GLSZM_LGLZE: low grey level zone emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_LGLZE`` +:Range: :math:`(0, 1]`, inverse squared grey levels + +Defined as :ref:`GLSZM_LGLZE `. + + +.. _feature-3glszm-sae: + +3GLSZM_SAE: small area emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_SAE`` +:Range: :math:`(0, 1]`, inverse squared voxels + +Defined as :ref:`GLSZM_SAE `. IBSI calls it *small zone emphasis*. + + +.. _feature-3glszm-sahgle: + +3GLSZM_SAHGLE: small area high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_SAHGLE`` +:Range: :math:`(0, g_{N_g}^{\,2}]`, squared grey levels over squared voxels + +Defined as :ref:`GLSZM_SAHGLE `. + + +.. _feature-3glszm-salgle: + +3GLSZM_SALGLE: small area low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_SALGLE`` +:Range: :math:`(0, 1]`, inverse squared voxels and grey levels + +Defined as :ref:`GLSZM_SALGLE `. + + +.. _feature-3glszm-szn: + +3GLSZM_SZN: size zone non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_SZN`` +:Range: :math:`[1, N_z]`, zones + +Defined as :ref:`GLSZM_SZN `. + + +.. _feature-3glszm-sznn: + +3GLSZM_SZNN: size zone non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_SZNN`` +:Range: :math:`(0, 1]`, dimensionless + +Defined as :ref:`GLSZM_SZNN `. + + +.. _feature-3glszm-ze: + +3GLSZM_ZE: zone entropy +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_ZE`` +:Range: :math:`[0, \log_2(N_g N_s)]` bits + +Defined as :ref:`GLSZM_ZE `, with the same approximate logarithm. + +**Possible issues.** On the worked example Nyxus returns 2.9485, while an exact logarithm gives +2.9477, a relative difference of 2.9 × 10\ :sup:`-4`. + + +.. _feature-3glszm-zp: + +3GLSZM_ZP: zone percentage +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_ZP`` +:Range: :math:`(0, 1]`, dimensionless + +The number of zones divided by the number of voxels in the ROI mask. High for fine texture, where +most zones are small. + +.. math:: + + \mathrm{ZP} = \frac{N_z}{N_v} + +**Possible issues.** :math:`N_v` includes the ROI voxels that belong to no zone: voxels of intensity +0, and with MATLAB-style binning the voxels of level 1. The value is then lower than for the same +zones in a ROI without such voxels (see :ref:`glszm-3d-possible-issues`). + + +.. _feature-3glszm-zv: + +3GLSZM_ZV: zone variance +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3GLSZM_ZV`` +:Range: :math:`[0, \infty)`, squared voxels + +Defined as :ref:`GLSZM_ZV `. IBSI calls it *zone size variance*. + + +.. _glszm-3d-possible-issues: + +Possible issues +--------------- + +MATLAB-style binning drops the lowest level + With a positive ``3glszm/greydepth``, level 1 is treated as background. ROI voxels that bin to + level 1, those with intensity below :math:`x_{\max}/n`, belong to no zone but are still counted + in :math:`N_v`. On the worked example with ``3glszm/greydepth=2``, the five voxels of intensity + 1 bin to level 1 and the other 22 to level 2. Nyxus finds one zone of 22 voxels: zone percentage + is 0.03704, small area emphasis 0.002066, large area emphasis 484 and low grey level zone + emphasis 0.25. Counting the level-1 voxels as zones would give 4 zones, zone percentage 0.1481, + small area emphasis 0.5283 and low grey level zone emphasis 0.8125. The 2D family keeps these + pixels, together with the background, in zones of level 1. + +Voxels of intensity 0 inside the mask + Without binning, in IBSI mode and with PyRadiomics-style binning, a voxel of intensity 0 belongs to + no zone but is counted in :math:`N_v`. On the worked example with two voxels set to 0, Nyxus + finds the same 9 zones and zone percentage 9/27 = 0.3333; over the 25 non-zero voxels it would be + 0.36. + +ROI that does not fill its bounding box + The bounding-box voxels outside the mask have intensity 0 and belong to no zone; they are not + counted in :math:`N_v` either. Removing three voxels of the worked example from the mask gives 9 + zones over 24 voxels, zone percentage 0.375, in memory and out of core. + +``3glszm/greydepth`` is ignored in whole-volume mode + When a volume is processed as one ROI, without a mask (``Nyxus3D.featurize_directory`` with no + label directory), the family does not read ``3glszm/greydepth``, and the intensities are never + binned. On the worked example with ``3glszm/greydepth=2`` the whole-volume + zone percentage is 0.3333, the unbinned value, not 0.03704. + +``get_metaparam("3glszm/greydepth")`` fails + Reading the metaparameter back raises an "unrecognized feature" error, although setting it + works. + +Constant ROI + When all of a ROI's voxels have the same intensity, every 3D GLSZM feature returns the + ``--noval`` substitute (0 by default), although the ROI has a well-defined matrix of one zone + per connected component. + +Very large zones + Large area emphasis and small area emphasis square the zone size in 32-bit integer arithmetic. + Large area emphasis is wrong as soon as a zone has more than 46 340 voxels. On a + 40 × 40 × 40 ROI holding one zone of 63 999 voxels and one of 1 voxel, Nyxus returns + -99 547 647 instead of 2 047 936 001. Small area emphasis sums over every zone size up to the + largest zone. When the largest zone has 65 536 voxels or more, one of those terms is undefined + and the value is replaced by the ``--noval`` substitute (0 by default; ``-0.0`` in Arrow and + Parquet files, see :ref:`conventions-missing`): on a 41 × 40 × 40 ROI with a zone of 65 599 + voxels it is 0 instead of 0.5. The other 14 features are exact at these + sizes. IBSI mode does not change this. + +Memory use + :math:`\textbf{P}` has :math:`N_g` rows and :math:`N_s` columns of 4 bytes. Without binning + :math:`N_g` is the ROI's largest intensity, so a 12-bit volume (intensities up to 4095) whose + largest zone has 100 000 voxels needs a 1.6 GB matrix. Binning to a small :math:`N_g` keeps it + small. + + +Validation +---------- + +The test suite checks every 3D GLSZM feature against PyRadiomics in two configurations. On a 3D +liver phantom with PyRadiomics-style binning into 20 levels, fifteen features agree to a relative +10\ :sup:`-9`, and the size-zone matrix agrees cell by cell. On a 3 × 3 × 3 volume with grey levels 1, +3 and 5 in IBSI mode, the same fifteen agree to a relative 10\ :sup:`-9`. Zone entropy agrees to a +relative 10\ :sup:`-3` on the phantom and 2 × 10\ :sup:`-3` on the small volume, the error of the +approximate logarithm. MATLAB-style binning, the default setting on a larger phantom and the +constant ROI are covered by regression tests only. + + +References +---------- + +1. Thibault G, Fertil B, Navarro C, et al. *Texture indexes and gray level size zone matrix: + application to cell nuclei classification.* Pattern Recognition and Information Processing + (PRIP). 2009:140–145. +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io +3. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. diff --git a/docs/source/Math/f_image_quality.rst b/docs/source/Math/f_image_quality.rst index d09d298ec..e7196c7fa 100644 --- a/docs/source/Math/f_image_quality.rst +++ b/docs/source/Math/f_image_quality.rst @@ -1,153 +1,527 @@ -Image Quality -============= +Image quality features +====================== + +Image quality features score a whole image, or each ROI, for focus, sharpness and saturation. They +are meant to find blurred, out-of-focus or overexposed fields of view before analysis. Each one is +computed on the ROI's bounding box as a rectangular image. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - 6 features, one value per ROI: ``FOCUS_SCORE``, ``LOCAL_FOCUS_SCORE``, + ``POWER_SPECTRUM_SLOPE``, ``MAX_SATURATION``, ``MIN_SATURATION`` and ``SHARPNESS`` + * - **Group** + - ``*ALL_IMQ*``. It is not part of ``*ALL*``, and image quality features cannot be requested + in the same run as other features. + * - **Dimensions** + - 2D only. There is no 3D family. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Partly, with a label image only. On an image that Nyxus reads as one tile (see + :ref:`large-images-tiles`), ``FOCUS_SCORE`` and ``LOCAL_FOCUS_SCORE`` have the same values + as in memory, and so do the saturations unless the ROI is constant. On a tiled image read + as several tiles the call raises an error when a misplaced pixel falls outside the ROI's + bounding box, and otherwise the focus scores and the saturations differ from the in-memory + values. + ``POWER_SPECTRUM_SLOPE`` and ``SHARPNESS`` are not computed out of core, a known defect + (`#508 `__; see :ref:`imq-possible-issues`). In whole-image mode an image too large for memory is refused. + * - **IBSI** + - None. IBSI does not define image quality features. + +.. tip:: + + To detect blur, use ``FOCUS_SCORE`` or ``LOCAL_FOCUS_SCORE``. On an image blurred step by step + they fall steadily, while ``POWER_SPECTRUM_SLOPE`` rises and ``SHARPNESS`` goes up and down (see + :ref:`imq-possible-issues`). + + +How the features are computed +----------------------------- + +Every feature reads one rectangular image, the *image matrix* :math:`I`, of :math:`H` rows and +:math:`W` columns, and nothing else (one exception under anisotropy, see `Settings`_). + +- **Which pixels:** with a label image, :math:`I` is the ROI's bounding box. Pixels of the box that + lie outside the mask are set to 0 and take part like any other pixel. In whole-image mode, where + the intensity directory is also given as the label directory, :math:`I` is the whole image plus + one row of 0 below it and one column of 0 to its right, so a 5 × 5 image is scored as 6 × 6. + ``ImageQuality.featurize`` on in-memory arrays uses the bounding box of the mask, with no added + row or column. +- **Values:** grey levels, as Nyxus stores them. Image quality features are not converted back to + the image's units (see :ref:`intensity-units`). An integer image without negative values is used + as it is; an image with negative values is shifted up by its minimum; a floating-point image is + rescaled to ``0 .. --fpimgdr`` grey levels. ``ImageQuality.featurize`` on in-memory arrays does + not do this: it casts the array to unsigned 32-bit integers, so fractions are truncated and + negative values wrap around. +- **Binning:** none. ``--coarseGrayDepth`` and ``--ibsi`` have no effect. +- **Borders:** each feature has its own rule. The Laplacian and the first and second differences + treat pixels outside :math:`I` as 0. The median filter of ``SHARPNESS`` repeats the edge pixels. + + +Worked example +^^^^^^^^^^^^^^ + +Take the 5 × 5 image of the texture pages, written to a file and read in whole-image mode. Nyxus +adds a row and a column of 0, so the image matrix and its Laplacian (kernel +:math:`[0, 1, 0;\ 1, -4, 1;\ 0, 1, 0]`, 0 outside) are -Image quality features provide calculations to determine how blurry or noisy an image is. +.. math:: + I = \begin{bmatrix} + 5 & 2 & 5 & 4 & 4 & 0\\ + 3 & 3 & 3 & 1 & 3 & 0\\ + 2 & 1 & 1 & 1 & 3 & 0\\ + 4 & 2 & 2 & 2 & 3 & 0\\ + 3 & 5 & 3 & 3 & 2 & 0\\ + 0 & 0 & 0 & 0 & 0 & 0 \end{bmatrix}, + \qquad + \nabla^2 I = \begin{bmatrix} + -15 & 5 & -11 & -6 & -9 & 4\\ + -2 & -3 & -2 & 7 & -4 & 3\\ + 0 & 4 & 3 & 3 & -5 & 3\\ + -9 & 4 & 0 & 1 & -5 & 3\\ + -3 & -12 & -2 & -5 & -2 & 2\\ + 3 & 5 & 3 & 3 & 2 & 0 \end{bmatrix} + +``FOCUS_SCORE`` is the population variance of the 36 values of :math:`\nabla^2 I`, 27.86. +``LOCAL_FOCUS_SCORE`` cuts :math:`I` into four 3 × 3 tiles, each filtered with 0 outside the tile; +their scores are 42.32, 32.89, 35.06 and 16.77, and their mean is 31.76. The largest value, 5, holds +3 of the 36 pixels, so ``MAX_SATURATION`` is 0.08333; the smallest is one of the 11 added zeros, so +``MIN_SATURATION`` is 0.3056. ``POWER_SPECTRUM_SLOPE`` is 0 because the image is narrower than 24 +pixels. ``SHARPNESS`` is 1.903. + +The same image as an array, or read with a mask of ones, has no added row and column. It gives +30.12, 22.69, 0.12, 0.16, 0 and 1.384. + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for the +whole-image run. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/imq_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features="*ALL_IMQ*" \ + --intDir=int/ --segDir=int/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`I(r, c)` + - Grey level of the image matrix at row :math:`r` and column :math:`c`, counted from 0. + :math:`I(r, c) = 0` outside the matrix, unless a feature says otherwise. + * - :math:`H,\; W` + - Number of rows and columns of the image matrix. + * - :math:`N` + - Number of pixels of the image matrix, :math:`N = HW`. + * - :math:`\nabla^2 I` + - Laplacian of :math:`I`, + :math:`I(r-1,c) + I(r+1,c) + I(r,c-1) + I(r,c+1) - 4\,I(r,c)`, with 0 outside the matrix. + * - :math:`\operatorname{Var}` + - Population variance, dividing by the number of values. + * - :math:`I_{\min},\; I_{\max}` + - Smallest and largest grey level of the image matrix. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Undefined value + - ``--noval=0`` + - none + - Value of ``LOCAL_FOCUS_SCORE`` when a side of the image matrix is shorter than 2 pixels. + Python always uses 0. + * - Image intensity map + - ``--fpimgdr``, ``--fpimgmin``, ``--fpimgmax`` + - ``ImageQuality(..., dynamic_range=..., min_intensity=..., max_intensity=...)`` + - Decide the grey levels of a floating-point image, and so every feature value. The + saturations change only where the map merges or splits grey levels at the extremes, for + example with a small ``--fpimgdr``. ``ImageQuality`` applies ``dynamic_range`` but prints an + "unexpected keyword argument" warning for it. + * - Anisotropy + - ``--anisox``, ``--anisoy`` + - ``ImageQuality(..., anisotropy_x=..., anisotropy_y=...)`` + - With files, ``POWER_SPECTRUM_SLOPE`` is computed on the ROI resampled by the factors: with + ``anisotropy_x=2`` it has the value of the ROI made twice as wide, a known defect + (`#519 `__). The other + five features are computed on the pixels as acquired and do not change. In-memory arrays are + not affected. + * - Memory limit + - ``--ramLimit`` + - ``ImageQuality(..., ram_limit=...)`` + - With a label image, a ROI whose footprint reaches the limit is processed out of core. In + whole-image mode, an image that reaches it is refused with an error; in Python the call + raises ``RuntimeError``. + +The features have no parameters: the Laplacian kernel, the 2 × 2 tile grid, the 24-pixel minimum of +``POWER_SPECTRUM_SLOPE`` and every constant of ``SHARPNESS`` are fixed. ``--coarseGrayDepth``, +``--ibsi`` and ``--pixelDistance`` have no effect. All options are listed in the +:ref:`cli-reference`. -Focus Score ------------ -Focus score first applies the Laplacian operator to the image to detect the edges. The Laplacian -operator for :math:`\text{ksize}>1` is a filter using the kernel +Output columns +-------------- -.. math:: +Each feature produces one column, named with its code. There are no per-direction columns and no +``_AVE`` columns. - \begin{bmatrix} - 2 & 0 & 2\\ - 0 & -8 & 0\\ - 2 & 0 & 2 \end{bmatrix}. +In whole-image mode each image gives one row with ``ROI_label`` 1 and an empty ``mask_image``. +``intensity_image`` holds the path the image was found under, built from the directory as it was +given: relative when the directory is relative, absolute when it is absolute. -For the default value of :math:`\text{ksize}=1`, the kernel becomes -.. math:: - - \begin{bmatrix} - 0 & 1 & 0\\ - 1 & -4 & 1\\ - 0 & 1 & 0 \end{bmatrix}. +.. _imq-index: -The filtering uses a zero border condition: kernel taps that fall outside the image are dropped, -which is equivalent to padding the image with zeros. For example, given the matrix - -.. math:: +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with the tolerance; see the :doc:`/featurelist` for what the categories mean. *Example* is +the value on the worked example. + +.. include:: image_quality_index.inc - \begin{bmatrix} - 1 & 2 & 3\\ - 4 & 5 & 6\\ - 7 & 8 & 9 \end{bmatrix}, -the padded matrix becomes +Features +-------- + +.. _feature-focus-score: + +Focus score +^^^^^^^^^^^ + +:Code: ``FOCUS_SCORE`` +:Range: :math:`[0, \infty)`, squared grey levels + +The variance of the Laplacian, the focus measure of Pech-Pacheco et al. Sharp edges give large +Laplacian values of both signs; blur flattens them. Higher values mean a sharper image. .. math:: - \begin{bmatrix} - 0 & 0 & 0 & 0 & 0\\ - 0 & 1 & 2 & 3 & 0\\ - 0 & 4 & 5 & 6 & 0\\ - 0 & 7 & 8 & 9 & 0\\ - 0 & 0 & 0 & 0 & 0\end{bmatrix}. + \mathrm{FOCUS\_SCORE} = \operatorname{Var}\left(\nabla^2 I\right) + = \frac{1}{N}\sum_{r,c}\left(\nabla^2 I(r,c) - \overline{\nabla^2 I}\right)^2 -This is the same convention as ``cv2.Laplacian(image, cv2.CV_64F, ksize=1, -borderType=cv2.BORDER_CONSTANT)``. +The variance is taken over the signed Laplacian, not over its magnitude. This equals +``cv2.Laplacian(I, cv2.CV_64F, ksize=1, borderType=cv2.BORDER_CONSTANT).var()``. -After calculating the Laplacian of the image, the focus score is given by the population variance of -the signed filtered image :math:`x`, +**Possible issues.** The zero border makes the edge of the image matrix an edge of the image. A +constant 30 × 30 image of grey level 7 scores 6.098, not 0, and the value grows with the square of +the grey level. Pixels outside the mask, and the row and column that whole-image mode adds, are 0 +and add edges of their own: the worked example scores 27.86 in whole-image mode and 30.12 as an +array. -.. math:: - \text{variance}(x) = \frac{\sum_i(x_i - \text{mean}(x))^2}{\text{length}(x)} +.. _feature-local-focus-score: -which is the Pech-Pacheco et al. (2000) variance-of-the-Laplacian focus measure, i.e. -``cv2.Laplacian(image, cv2.CV_64F, ksize=1).var()``. The variance is taken over the signed -Laplacian, not over its magnitude: :math:`\text{Var}(|x|)` is a different, smaller statistic -whenever :math:`\text{mean}(x) \neq 0`, which the zero border condition guarantees. +Local focus score +^^^^^^^^^^^^^^^^^ -Local Focus Score ------------------ +:Code: ``LOCAL_FOCUS_SCORE`` +:Range: :math:`[0, \infty)`, squared grey levels -Local Focus Score cuts the image into a :math:`\text{scale}\times\text{scale}` grid of -non-overlapping tiles of :math:`\lfloor\text{height}/\text{scale}\rfloor` by -:math:`\lfloor\text{width}/\text{scale}\rfloor` pixels, computes the Focus Score of each tile, with the -zero border condition applied at the tile's own edge, and returns the mean of the -:math:`\text{scale}^2` tile scores. Nyxus uses :math:`\text{scale}=2`, so the score is the mean over -the four quadrants. When a side is not a multiple of :math:`\text{scale}`, the rows or columns past -the grid belong to no tile. An image with a side shorter than :math:`\text{scale}` has no tiles, so -its Local Focus Score is undefined, and Nyxus reports the no-value substitute set by ``--noval`` -(0 by default), as it does for other undefined features. +The mean focus score of a 2 × 2 grid of equal tiles. Each tile is scored as a separate image, with +0 outside the tile, so the tile borders count as edges. -GLCM Correlation and Dissimilarity ----------------------------------- +.. math:: -See the documentation for these features in :doc:`GLCM math documentation `. + \mathrm{LOCAL\_FOCUS\_SCORE} = \frac{1}{4}\sum_{t_y = 0}^{1}\sum_{t_x = 0}^{1} + \operatorname{Var}\left(\nabla^2 T_{t_y t_x}\right) -Power Spectrum Slope --------------------- +where :math:`T_{t_y t_x}` holds rows :math:`t_y h` to :math:`t_y h + h - 1` and columns +:math:`t_x w` to :math:`t_x w + w - 1` of :math:`I`, with :math:`h = \lfloor H/2 \rfloor` and +:math:`w = \lfloor W/2 \rfloor`. When :math:`H` or :math:`W` is odd, the last row or column belongs +to no tile. When :math:`h = 0` or :math:`w = 0` the value is the ``--noval`` substitute, 0 by +default. -This feature calculates the slope of the image's log-log power spectrum. The power spectrum of an :math:`m\times n` image is obtained -through Fourier transform +**Relations.** The tile borders add to every tile score, so the value is usually larger than +``FOCUS_SCORE``: 1404 against 124.0 on the 128 × 128 disk blurred with :math:`\sigma = 1` (see +:ref:`imq-possible-issues`). -.. math:: +**Possible issues.** In whole-image mode the added row and column fall inside the tiles when the +image side is odd, and outside them when it is even. On a 128 × 128 image the value is the same as +with a mask of ones; on the 5 × 5 worked example it is 31.76 against 22.69. - F(u,v) = \sum(x,y)l(x,y)-\mu\mu w(x,y)e^{2\pi i(ux/m+vy/n)}, -where :math:`u` and :math:`v` are spatial frequency coordinate. The power spectrum :math:`S`can then be found by the -square amplitude of the Fourier transform +.. _feature-max-saturation: -.. math:: +Maximum saturation +^^^^^^^^^^^^^^^^^^ - S(u,v) = L|F(u,v)|^2\Gamma(u,v), +:Code: ``MAX_SATURATION`` +:Range: :math:`[0, 1]`, fraction of pixels -where :math:`\Gamma` is the correction factor and :math:`L` is the number of pixels in the image. The resulting power spectrum -of the image is then converted to log-log scale and the slope of log-log power spectrum is obtained through the least squares solution. +The fraction of the image matrix at its own largest grey level. It is the share of saturated +pixels when the image reaches the top of its range, but the maximum is the observed one, not the +largest value of the pixel type. -Saturation Metrics ------------------- +.. math:: -The min saturation and max saturation metrics are obtained by counting the number of pixels at the maximum and minimum intensities of the image and returning -the percent of pixels found at both intensity levels. + \mathrm{MAX\_SATURATION} = \frac{\left|\{(r,c) : I(r,c) = I_{\max}\}\right|}{N} -Sharpness ---------- +A constant image gives 1. -The Sharpness calculation aims to determine the blurriness of edges in an image. To accomplish this estimation, -a difference of differences in gray scale value of the median-filtered image, :math:`\Delta \text{DoM}`, is used[1]. -To begin, median-filtering is applied to the image to reduce noise while preserving edges. The median-filtered image :math:`I_m` is calculated -using a 3x3 averaging filter, given by, -.. math:: +.. _feature-min-saturation: + +Minimum saturation +^^^^^^^^^^^^^^^^^^ - I_m = \frac{1}{9}\sum_{j=-1}^1\sum_{i=-1}^1 \text{I}(x+i, x+j). +:Code: ``MIN_SATURATION`` +:Range: :math:`[0, 1)`, fraction of pixels -The :math:`\Delta`DoM is calculated separately for horizontal and vertical directions. The horizontal direction is calculated as, +The fraction of the image matrix at its own smallest grey level. .. math:: - \Delta\text{DoM}_x(i,j) = [I_m(i+2, j) - I_m(i,j)] - [I_m(i, j) - I_m(i-2, j)]. + \mathrm{MIN\_SATURATION} = \frac{\left|\{(r,c) : I(r,c) = I_{\min} \ne I_{\max}\}\right|}{N} +A pixel equal to both extremes counts as maximal only, so a constant image gives 0. -The sharpness at a pixel :math:`S_x(i,j)` is computed as +**Possible issues.** Pixels outside the mask are 0 and are counted, so for any ROI that does not +fill its bounding box the minimum is 0 and the value is the share of the box outside the mask: a +plus-shaped ROI of 10 pixels in a 4 × 3 box gives 2/12. In whole-image mode the added row and +column of 0 make the minimum 0 for every image: the worked example gives 0.3056 where the image's +own minimum, 1, holds 0.16 of its pixels. -.. math:: - S_x(i,j) = \frac{\sum_{i-w\leq k \leq i+w} |\Delta \text{DoM}(k,j)|}{\sum_{i-w\leq k \leq i+w} |I(k,j)-I(k-l,j)|}. +.. _feature-power-spectrum-slope: + +Power spectrum slope +^^^^^^^^^^^^^^^^^^^^ +:Code: ``POWER_SPECTRUM_SLOPE`` +:Range: :math:`(-\infty, \infty)`, dimensionless -This ratio is high at sharp locations and low at blurred locations. The sharpness in the vertical or :math:`y` direction is calculated in a similar way. -The sharpness for the entire image is calculated as, +The slope of a straight line fitted to log power against log bin index, computed from the image's +2D Fourier transform. It is ported from CellProfiler's power log-log slope, but bins the power +differently (see *Possible issues*). The steps are: +1. If :math:`\lfloor \min(H, W)/8 \rfloor < 3`, that is if the shorter side is under 24 pixels, the + value is 0. +2. Read :math:`I` in row-major order. If the last pixel at :math:`I_{\max}` comes after the first + pixel at :math:`I_{\min}`, divide every pixel by :math:`m`, the value at position + :math:`\lfloor N/2 \rfloor` of the sorted :math:`|I(r,c) - \bar{I}|`. Otherwise keep :math:`I`. + Then subtract the mean, giving :math:`x`. +3. Place :math:`x` in an :math:`n \times n` array, :math:`n` the smallest power of 2 not below + :math:`\max(H, W)`. Rows from :math:`H` on are 0. When :math:`W < n`, the columns from + :math:`W` on of the first :math:`H` rows hold values of :math:`x` left over from moving the rows + in place, not 0. +4. Take :math:`A(u,v) = |X(u,v)|/n`, where :math:`X` is the 2D discrete Fourier transform of the + array. +5. Put each coefficient in bin :math:`b = \lfloor \sqrt{A(u,v)} \rfloor + 1`, and sum per bin + :math:`P_b = \sum A(u,v)^2` and :math:`M_b = \sum A(u,v)`. +6. Fit :math:`\log P_b = s \log(b + 2) + c` by least squares over the bins with :math:`M_b > 0`. .. math:: - R_x = \frac{\text{#}SharpPixels_x}{\text{#}EdgePixels_x} \\ \\ - R_y= \frac{\text{#}SharpPixels_y}{\text{#}EdgePixels_y}. + \mathrm{POWER\_SPECTRUM\_SLOPE} = s +The value is 0 when every :math:`M_b` is 0, or when fewer than two bins are occupied. -The sharpness of the image is then given by the Frobenius norm, +This is a known defect (`#514 `__): step 6 takes :math:`b + 2` from a table of :math:`\max(H, W)` entries +but looks up every occupied bin in it, so a bin beyond that table reads memory outside it and +the slope is undefined. That happens when the image is not normalized in step 2 and its +coefficients are large, which is common for images of ordinary grey levels. + +**Possible issues.** The bin of a coefficient is set by its own magnitude, not by its frequency +radius :math:`\sqrt{u^2 + v^2}`, so the fit does not describe how power falls with frequency. On +the blur series under :ref:`imq-possible-issues` the slope is positive and rises with blur, where +a spectrum binned by frequency radius has a negative slope. The test in step 2 compares the +positions of the extremes, not their values, so whether the image is normalized depends on where +its brightest and darkest pixels lie. The Fourier transform steps its twiddle angles in single +precision: on the blur series this moves the slope by up to 3.3 × 10\ :sup:`-4` relative, from +2.3245 to 2.3237, against an exact transform. -.. math:: - S_I = \sqrt{R_x^2+R_y^2}. +.. _feature-sharpness: +Sharpness +^^^^^^^^^ + +:Code: ``SHARPNESS`` +:Range: :math:`[0, \infty)`, dimensionless + +An edge-sharpness score built from differences of a median-filtered image, adapted from the +difference-of-differences-of-medians (DOM) measure of Kumar et al. It is not that measure, and it +does not fall steadily with blur (see *Possible issues*). The steps are: + +1. Median-filter :math:`I` with a 3 × 3 window, repeating the edge pixels, and divide by 255: + :math:`B`. +2. From :math:`I`, with 0 outside, take the central differences + :math:`G_x(r,c) = \tfrac{1}{2}\left(I(r,c+1) - I(r,c-1)\right)` and + :math:`G_y(r,c) = \tfrac{1}{2}\left(I(r+1,c) - I(r-1,c)\right)`. The edge maps are + :math:`E_x(r,c) = 1` where :math:`|G_x(r,c)| / (\max G_x + 10^{-8}) > 10^{-4}`, and + :math:`E_y(r,c) = 1` where :math:`|G_y(c,r)| / (\max G_x + 10^{-8}) > 10^{-4}`, 0 elsewhere. + :math:`\max G_x` is the signed maximum, and :math:`E_y` reads :math:`G_y` transposed. On a + non-square image :math:`E_y` reads the elements of :math:`G_y` in the order an in-place swap of + the two layouts leaves them. +3. From :math:`B`, with 0 outside, take + :math:`D_x(r,c) = |B(r-2,c) - 2B(r,c) + B(r+2,c)|`, + :math:`D_y(r,c) = |B(r,c-2) - 2B(r,c) + B(r,c+2)|`, + :math:`C_x(r,c) = E_x(r,c)\,|B(r+1,c) - B(r,c)|` and + :math:`C_y(r,c) = E_y(r,c)\,|B(r,c+1) - B(r,c)|`. +4. For :math:`2 \le r \le H-3` and :math:`0 \le c \le W-3`, sum both over the four rows + :math:`k = r-2, \dots, r+1`: + + .. math:: + + S_x(r,c) = \frac{\sum_k D_x(k,c)}{\sum_k C_x(k,c)}, \qquad + S_y(r,c) = \frac{\sum_k D_y(k,c)}{\sum_k C_y(k,c)} + + A ratio whose denominator is :math:`10^{-3}` or less is 0, and :math:`S_x` and :math:`S_y` are + 0 outside these rows and columns. +5. Combine: -[1] J. Kumar, F. Chen and D. Doermann, "Sharpness estimation for document and scene images," Proceedings of the 21st International Conference on Pattern Recognition (ICPR2012), Tsukuba, Japan, 2012, pp. 3292-3295. +.. math:: + R_x = \frac{\sum_{r,c} S_x(r,c)}{\sum_{r,c} E_x(r,c) + 10^{-8}}, \qquad + R_y = \frac{\sum_{r,c} S_y(r,c)}{\sum_{r,c} E_y(r,c) + 10^{-8}}, \qquad + \mathrm{SHARPNESS} = \sqrt{R_x^2 + R_y^2} + +An image with fewer than 5 rows, and a constant image, give 0. + +**Possible issues.** The DOM measure counts the pixels whose ratio exceeds a threshold and divides +by the number of edge pixels; this feature sums the ratios instead. Its edge maps are taken along +the other axis from the differences they mask, :math:`S_y` is summed down the columns rather than +along the rows, and the last two columns are never scored. On the blur series under +:ref:`imq-possible-issues` the value goes 4.748, 11.23, 3.755, 1.881. The denominator threshold is +in grey levels divided by 255, so the value of a dim, low-contrast image depends on its absolute +brightness. + + +.. _imq-possible-issues: + +Possible issues +--------------- + +Blur series + A 128 × 128 disk of grey level 255 and radius 40 on 0, blurred with a Gaussian of + :math:`\sigma` = 0, 1, 2 and 4 pixels and read in whole-image mode, gives: + + .. list-table:: + :widths: 34 16 16 16 16 + :header-rows: 1 + + * - Feature + - :math:`\sigma = 0` + - 1 + - 2 + - 4 + * - ``FOCUS_SCORE`` + - 3970 + - 124.0 + - 17.04 + - 2.828 + * - ``POWER_SPECTRUM_SLOPE`` + - 1.178 + - 1.653 + - 1.996 + - 2.324 + * - ``SHARPNESS`` + - 4.748 + - 11.23 + - 3.755 + - 1.881 + + ``FOCUS_SCORE`` falls with blur. ``POWER_SPECTRUM_SLOPE`` rises, because it bins power by the + magnitude of each Fourier coefficient rather than by frequency. ``SHARPNESS`` rises and then + falls. Use the focus scores to rank images by blur. + +Whole-image mode adds a row and a column of 0 + In whole-image mode the image matrix is one pixel larger than the image in each direction, a + known defect (`#493 `__), and the extra pixels are 0. ``MIN_SATURATION`` then counts them, ``MAX_SATURATION`` divides by the + larger count, the focus scores see an extra edge, and ``POWER_SPECTRUM_SLOPE`` changes too: + 1.178 in whole-image mode against 2.106 with a mask of ones on the unblurred disk of the blur + series. On the worked example the six values are 27.86, 31.76, 0, 0.08333, 0.3056 and 1.903; + with a mask of ones they are 30.12, 22.69, 0, 0.12, 0.16 and 1.384. + +Pixels outside the mask + With a label image every feature is computed on the bounding box, with pixels outside the mask + set to 0. For ROIs that do not fill their box, the mask outline becomes an edge for the focus + scores and ``SHARPNESS``, and a share of minimal pixels for ``MIN_SATURATION``. + +Out-of-core ROIs + Out of core, ``POWER_SPECTRUM_SLOPE`` and ``SHARPNESS`` are not computed, and their columns + hold whatever value happens to be in memory: 0, a tiny number such as :math:`10^{-311}`, or + anything else; Nyxus does not set them to the ``--noval`` substitute. A constant ROI processed + out of core skips the saturations too, and both of their columns hold such a value. This is + a known defect (`#508 `__). Discard + these values, or raise ``--ramLimit`` so that the ROIs fit in memory. + +Small images + ``POWER_SPECTRUM_SLOPE`` is 0 when the shorter side of the image matrix is under 24 pixels, and + ``SHARPNESS`` when it has fewer than 5 rows. ``LOCAL_FOCUS_SCORE`` is the ``--noval`` substitute + when a side is shorter than 2 pixels. + +Constant images + A constant image, as an array or with a mask that fills its box, gives ``MAX_SATURATION`` 1, + ``MIN_SATURATION`` 0, ``POWER_SPECTRUM_SLOPE`` 0 and ``SHARPNESS`` 0. The focus scores are not + 0, because of the zero border: 6.098 and 11.32 for a 30 × 30 image of grey level 7. + +Grey levels, not image units + The features are computed on stored grey levels. For a floating-point image the values depend + on ``--fpimgdr``, ``--fpimgmin`` and ``--fpimgmax``; the focus scores scale with the square of + the grey-level range. + + +Validation +---------- + +- ``FOCUS_SCORE`` is checked against OpenCV 4.13.0, + ``cv2.Laplacian(roi, cv2.CV_64F, ksize=1, borderType=cv2.BORDER_CONSTANT).var()``, to an absolute + 10\ :sup:`-9`. +- ``LOCAL_FOCUS_SCORE`` is checked against the mean of the same OpenCV score over the 2 × 2 tile + grid, to an absolute 10\ :sup:`-9`, and against a closed form for a single spike in each tile, + exactly. +- ``MAX_SATURATION`` and ``MIN_SATURATION`` are checked against CellProfiler 4.2.8 + ``MeasureImageQuality`` (``PercentMaximal`` and ``PercentMinimal`` divided by 100) to an absolute + 10\ :sup:`-9`. The constant-ROI case and masks narrower than their bounding box, where + CellProfiler counts differently, are covered by regression tests only. +- ``POWER_SPECTRUM_SLOPE`` and ``SHARPNESS`` are regression-only: no independent implementation + confirms them, and a reference DOM implementation gives a different value for ``SHARPNESS``. + +The out-of-core focus scores are checked against the in-memory values of the same build. + +Most values quoted on this page are pinned by the worked-example script; the out-of-core value +:math:`10^{-311}` and the slope of 2.3245 from an exact transform are measured only. Apart from +that slope, the values are Nyxus' own; the focus scores also equal OpenCV's, and the saturations +of ROIs that fill their bounding box CellProfiler's. + + +References +---------- + +1. Pech-Pacheco JL, Cristóbal G, Chamorro-Martínez J, Fernández-Valdivia J. *Diatom autofocusing in + brightfield microscopy: a comparative study.* Proceedings of the 15th International Conference + on Pattern Recognition (ICPR 2000). 2000;3:314–317. doi:10.1109/ICPR.2000.903548 +2. Kumar J, Chen F, Doermann D. *Sharpness estimation for document and scene images.* Proceedings + of the 21st International Conference on Pattern Recognition (ICPR 2012). 2012:3292–3295. +3. Bray MA, Fraser AN, Hasaka TP, Carpenter AE. *Workflow and metrics for image quality control in + large-scale high-content screens.* Journal of Biomolecular Screening. 2012;17(2):266–274. + doi:10.1177/1087057111420292 (CellProfiler ``MeasureImageQuality``: saturation and power + log-log slope) diff --git a/docs/source/Math/f_intensity_3d.rst b/docs/source/Math/f_intensity_3d.rst new file mode 100644 index 000000000..069eb4bf5 --- /dev/null +++ b/docs/source/Math/f_intensity_3d.rst @@ -0,0 +1,726 @@ +3D intensity features +===================== + +3D intensity features are first-order statistics of the intensities of a ROI's voxels. They are +the 3D counterparts of the :doc:`2D intensity features `: the same 36 +statistics, computed by the same formulas over every voxel of a volumetric ROI, whatever slice it +lies in. Voxel positions and neighbours play no part, and anisotropy settings do not change +them. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``3COV`` to ``3VARIANCE_BIASED``: 36 features, one value per ROI + * - **Group** + - ``*3D_ALL_INTENSITY*``; also part of ``*3D_ALL*`` + * - **Dimensions** + - 3D (``--dim=3`` or the ``Nyxus3D`` class). The 2D family, ``MEAN``, ``MEDIAN`` and so on, + is defined on :doc:`f_pixel_intensity`. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory, for inputs Nyxus can stream. When one read of the + file returns the whole volume (NIfTI, or OME-Zarr chunked over all of Z), an oversized ROI + stops the run with an error (see :doc:`/large_images`). + * - **IBSI** + - 18 features have an identifier from the IBSI intensity-based statistics, which list 18 + features. ``3COV`` and the percentiles follow a different convention from IBSI; their 2D + entries say how. + +.. tip:: + + Every 3D code computes exactly the formula of the 2D code without the leading ``3``, over + voxels instead of pixels. The entries below link the 2D definition and give the value on a + 3D example. ``3COVERED_IMAGE_INTENSITY_RANGE`` is written out in full because its denominator + spans the voxels of every ROI in the volume. The 3D family has no ``HISTOGRAM`` code. + + +How the features are computed +----------------------------- + +Every voxel of the mask that carries the ROI's label is used once, with its intensity. Nothing +else is used: not the voxel's position or slice, not its neighbours, not the voxels of the +bounding box outside the mask. + +- **Which voxels:** all voxels of the ROI in all slices, including voxels of intensity 0 inside + the mask. :math:`N_v` is the number of ROI voxels. +- **Values:** the intensities themselves. Nothing is binned except for ``3ENTROPY`` and + ``3UNIFORMITY``, which use a histogram of ``--coarseGrayDepth`` bins, and the percentiles, which + are read off a histogram of 100 bins. Both histograms have equal-width bins over the ROI's own + range, from :math:`X_{\min}` to :math:`X_{\max}`; the maximum goes into the last bin, and a + constant ROI puts every voxel into the first bin. +- **Units:** the features are reported in the volume's own units; see :ref:`intensity-units`. + Location features (minimum, maximum, mean, median, mode, percentiles, robust mean, integrated + intensity, energy, root mean square) go through the whole intensity map, spread features and + ``3COVERED_IMAGE_INTENSITY_RANGE`` through its slope, variances through the square of its slope, + and the ratios ``3COV``, ``3QCOD`` and ``3UNIFORMITY_PIU`` are recomputed from the mapped parts. + A signed integer volume is therefore reported in its own signed values. A floating-point volume + is reported in its own range, to the resolution of its grey levels (``--fpimgdr``, 10000 steps + over the volume's range by default). +- **Files only:** ``Nyxus3D`` reads volumes from files (``featurize_files`` or + ``featurize_directory``); there is no in-memory path. +- **IBSI mode:** ``--ibsi=true`` does not change any 3D intensity feature. + + +Worked example +^^^^^^^^^^^^^^ + +Take a 3 × 4 × 4 volume and a ROI of 22 voxels over its three slices. The ROI voxels are shown +with their intensities; a dot is a voxel outside the mask: + +.. math:: + + z=0:\ \begin{bmatrix} + \cdot & \cdot & \cdot & \cdot\\ + \cdot & 4 & 5 & \cdot\\ + \cdot & 5 & 6 & \cdot\\ + \cdot & \cdot & \cdot & \cdot \end{bmatrix} + \qquad + z=1:\ \begin{bmatrix} + \cdot & 3 & 4 & \cdot\\ + 3 & 6 & 6 & 5\\ + 4 & 6 & 5 & 3\\ + \cdot & 4 & 3 & \cdot \end{bmatrix} + \qquad + z=2:\ \begin{bmatrix} + \cdot & \cdot & \cdot & \cdot\\ + \cdot & 3 & 4 & 2\\ + \cdot & 4 & 5 & 3\\ + \cdot & \cdot & \cdot & \cdot \end{bmatrix} + +The voxels outside the mask hold intensities from 0 to 4 and are not used, so ``3MIN`` is 2. The +ROI holds one 2, six 3s, six 4s, five 5s and four 6s, so :math:`N_v = 22`, :math:`\sum x = 93` +and :math:`\mu = 4.2273`. The squared deviations sum to 29.864, so ``3VARIANCE`` is +:math:`29.864/21 = 1.4221`. :math:`N_v` is even, so the median is the mean of the 11th and 12th +values, both 4. The 3s and the 4s share the highest count; ``3MODE`` is the smaller, 3. + +The 100-bin percentile histogram has bins of width :math:`w = (6-2)/100 = 0.04`. The 2 falls in +bin 0, the 3s in bin 25, the 4s in bin 50, the 5s in bin 75 and the 6s in bin 99. For ``3P90`` +the target count is :math:`0.9 \times 22 = 19.8`. The first 18 voxels lie below bin 99, which +holds 4 voxels, so + +.. math:: + + P_{90} = 2 + 99 \times 0.04 + 0.04 \times \frac{19.8 - 18}{4} = 5.978 + +where ``numpy.percentile`` gives 6. In the same way :math:`P_{10} = 3.008`, so ``3ROBUST_MEAN`` is +the mean of the 4s and 5s, 4.4545. With the default 64 coarse bins the five intensities fall in +bins 0, 16, 32, 48 and 63, and ``3ENTROPY`` is the entropy of the probabilities 1/22, 6/22, 6/22, +5/22 and 4/22, 2.1581 bits. The ROI is the only one in the volume, so +``3COVERED_IMAGE_INTENSITY_RANGE`` is 1. + +The *Example* value of each feature in the :ref:`index ` is what Nyxus +returns for this ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/intensity_3d_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --dim=3 --features="*3D_ALL_INTENSITY*" \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`x_k` + - Intensity of the :math:`k`-th voxel of the ROI. + * - :math:`N_v` + - Number of ROI voxels. + * - :math:`X_{\min},\; X_{\max}` + - Smallest and largest intensity in the ROI. + * - :math:`V_{\min},\; V_{\max}` + - Smallest and largest intensity of the voxels that belong to any ROI of the volume. + * - :math:`\mu` + - Mean intensity, :math:`\mu = \frac{1}{N_v}\sum_k x_k`. + * - :math:`m_r` + - Central moment of order :math:`r`, :math:`m_r = \frac{1}{N_v}\sum_k (x_k - \mu)^r`. + * - :math:`\sigma,\; \sigma_b` + - Standard deviation with :math:`N_v - 1` and with :math:`N_v`; both 0 when + :math:`N_v = 1`. + * - :math:`M` + - Median intensity. + * - :math:`P_q` + - The ``3Pqq`` feature: the :math:`q`-th percentile read off the 100-bin histogram. + * - :math:`N_b` + - Number of bins of the coarse histogram, the absolute value of ``--coarseGrayDepth``. + * - :math:`p_j` + - Fraction of ROI voxels in bin :math:`j` of the coarse histogram. + +The symbols are those of the 2D page, with voxels in place of pixels. Sums over :math:`k` run over +the ROI voxels of all slices. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Histogram bins + - ``--coarseGrayDepth=64`` + - ``Nyxus3D(..., coarse_gray_depth=64)`` + - :math:`N_b`, the number of bins of ``3ENTROPY`` and ``3UNIFORMITY``. The sign is ignored. + No other 3D intensity feature depends on it. Python accepts only positive values. + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus3D(..., ibsi=True)`` + - No effect on these features. + * - Image intensity map + - ``--fpimgdr``, ``--fpimgmin``, ``--fpimgmax``, ``--preserve-hu`` + - ``Nyxus3D(..., dynamic_range=..., min_intensity=..., max_intensity=..., preserve_hu=True)`` + - Decide how a file's intensities become grey levels, and so the units the features are + reported in (see :ref:`intensity-units`). + * - Anisotropy + - ``--anisox``, ``--anisoy``, ``--anisoz``, or ``--use-physical-spacing=true`` + - ``Nyxus3D(..., anisotropy_x=..., anisotropy_y=..., anisotropy_z=...)``, or + ``use_physical_spacing=True`` + - No effect on these features: the statistics run over the voxels as acquired. + * - RAM limit + - ``--ramLimit`` + - ``Nyxus3D(..., ram_limit=...)``, in megabytes + - A ROI larger than the limit is streamed from disk slice by slice, and the values do not + change. When one read of the file returns the whole volume (NIfTI, or OME-Zarr chunked over + all of Z), such a ROI stops the run with an error instead. + +The 3D intensity family has no ``3...`` metaparameter of its own, and the percentile histogram +always has 100 bins. All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``3MEAN``, ``3P25`` and so on. + + +.. _intensity-3d-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. + +.. include:: intensity_3d_index.inc + + +Features +-------- + +Each entry gives the value on the worked example. Unless the entry says otherwise, the formula, +its conventions and its zero rules are those of the linked 2D definition, with voxels in place of +pixels. + + +.. _feature-3cov: + +3D coefficient of variation +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3COV`` +:Range: :math:`[0, \infty)` for non-negative intensities, dimensionless + +:math:`\sigma / \mu`, 0 when :math:`\mu = 0`; see :ref:`COV `. 0.2821 on the worked +example; IBSI's :math:`\sigma_b / \mu` gives 0.2756. + + +.. _feature-3covered-image-intensity-range: + +3D covered image intensity range +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3COVERED_IMAGE_INTENSITY_RANGE`` +:Range: :math:`[0, 1]`, dimensionless + +The ROI's intensity range as a fraction of the intensity range of all ROIs in the volume. + +.. math:: + + \mathrm{3COVERED\_IMAGE\_INTENSITY\_RANGE} = \frac{X_{\max} - X_{\min}}{V_{\max} - V_{\min}} + +:math:`V_{\min}` and :math:`V_{\max}` come from the voxels that belong to any ROI of the volume, in +every slice. Voxels outside every ROI are not used. + +**Possible issues.** In a volume with one ROI the value is always 1, as on the worked example, or +the ``--noval`` substitute for a constant ROI (see below); it carries information only when a +volume holds several ROIs. Adding to the worked example a voxel of 40 outside the mask and a +second ROI of intensities 0 and 3 gives 0.6667 for the first ROI (4/6) and 0.5 for the second +(3/6); the 40 plays no part. When every ROI voxel of the volume has one intensity, the division is +0/0 and the output is the ``--noval`` substitute, 0 by default (``-0.0`` in Arrow and Parquet +files; see :ref:`conventions-missing`). For a floating-point volume the value moves in steps of +grey levels: on random float volumes, ROIs that held the volume's extremes gave 0.9999 or 1. + + +.. _feature-3energy: + +3D energy +^^^^^^^^^ + +:Code: ``3ENERGY`` +:Range: :math:`[0, \infty)`, squared intensity units + +:math:`\sum_k x_k^2`; see :ref:`ENERGY `. 423 on the worked example. + +**Possible issues.** Each voxel's square is formed as a 32-bit unsigned integer before it is +summed, a known defect (`#490 `__), so a stored grey level above 65535 wraps modulo :math:`2^{32}`. Multiplying the worked +example by 20000 gives 4.894 × 10\ :sup:`10` where the sum of squares is 1.692 × 10\ :sup:`11`. +Volumes of 8- and 16-bit integers are not affected. + + +.. _feature-3entropy: + +3D entropy +^^^^^^^^^^ + +:Code: ``3ENTROPY`` +:Range: :math:`[0, \log_2 N_b]`, bits + +:math:`-\sum_j p_j \log_2(p_j + 2.2 \times 10^{-16})` over :math:`N_b` bins; see +:ref:`ENTROPY `. 2.158 on the worked example with 64 bins, 1.753 with 4. + + +.. _feature-3excess-kurtosis: + +3D excess kurtosis +^^^^^^^^^^^^^^^^^^ + +:Code: ``3EXCESS_KURTOSIS`` +:Range: :math:`[-2, \infty)`, dimensionless + +:math:`m_4 / m_2^2 - 3`, 0 when :math:`N_v < 5` or :math:`m_2 = 0`; see +:ref:`EXCESS_KURTOSIS `. −1.038 on the worked example. + + +.. _feature-3hyperflatness: + +3D hyperflatness +^^^^^^^^^^^^^^^^ + +:Code: ``3HYPERFLATNESS`` +:Range: :math:`[0, \infty)`, dimensionless + +:math:`m_6 / \sigma^6`, 0 when :math:`\sigma = 0`; see :ref:`HYPERFLATNESS ` +for the mixed :math:`N_v` and :math:`N_v - 1` standardization. 4.232 on the worked example. + + +.. _feature-3hyperskewness: + +3D hyperskewness +^^^^^^^^^^^^^^^^ + +:Code: ``3HYPERSKEWNESS`` +:Range: :math:`(-\infty, \infty)`, dimensionless + +:math:`m_5 / \sigma^5`, 0 when :math:`\sigma = 0`; see +:ref:`HYPERSKEWNESS `. −0.002163 on the worked example. + + +.. _feature-3integrated-intensity: + +3D integrated intensity +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3INTEGRATED_INTENSITY`` +:Range: :math:`[0, \infty)` for non-negative intensities, intensity units + +:math:`\sum_k x_k`; see :ref:`INTEGRATED_INTENSITY `. 93 on the +worked example. + + +.. _feature-3interquartile-range: + +3D interquartile range +^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3INTERQUARTILE_RANGE`` +:Range: :math:`[0, X_{\max} - X_{\min}]`, intensity units + +:math:`P_{75} - P_{25}` from the 100-bin histogram; see +:ref:`INTERQUARTILE_RANGE `. 1.998 on the worked example; exact +quartiles give 2. + + +.. _feature-3kurtosis: + +3D kurtosis +^^^^^^^^^^^ + +:Code: ``3KURTOSIS`` +:Range: :math:`[1, \infty)`, dimensionless + +:math:`m_4 / m_2^2`, 0 when :math:`N_v < 5` or :math:`m_2 = 0`; see +:ref:`KURTOSIS `. 1.962 on the worked example. + + +.. _feature-3max: + +3D maximum +^^^^^^^^^^ + +:Code: ``3MAX`` +:Range: intensity units + +:math:`X_{\max}`; see :ref:`MAX `. 6 on the worked example. + + +.. _feature-3mean: + +3D mean +^^^^^^^ + +:Code: ``3MEAN`` +:Range: :math:`[X_{\min}, X_{\max}]`, intensity units + +:math:`\mu`; see :ref:`MEAN `. 4.227 on the worked example. + + +.. _feature-3mean-absolute-deviation: + +3D mean absolute deviation +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3MEAN_ABSOLUTE_DEVIATION`` +:Range: :math:`[0, \infty)`, intensity units + +:math:`\frac{1}{N_v}\sum_k |x_k - \mu|`; see +:ref:`MEAN_ABSOLUTE_DEVIATION `. 0.9959 on the worked example. + + +.. _feature-3median: + +3D median +^^^^^^^^^ + +:Code: ``3MEDIAN`` +:Range: :math:`[X_{\min}, X_{\max}]`, intensity units + +The exact median, the mean of the two middle values when :math:`N_v` is even; see +:ref:`MEDIAN `. 4 on the worked example. + + +.. _feature-3median-absolute-deviation: + +3D median absolute deviation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3MEDIAN_ABSOLUTE_DEVIATION`` +:Range: :math:`[0, \infty)`, intensity units + +:math:`\frac{1}{N_v}\sum_k |x_k - M|`, a mean, not a median; see +:ref:`MEDIAN_ABSOLUTE_DEVIATION `. 0.9545 on the worked +example, where the median of the absolute deviations is 1. + + +.. _feature-3min: + +3D minimum +^^^^^^^^^^ + +:Code: ``3MIN`` +:Range: intensity units + +:math:`X_{\min}`; see :ref:`MIN `. 2 on the worked example. A voxel of intensity 0 +inside the mask makes it 0. + + +.. _feature-3mode: + +3D mode +^^^^^^^ + +:Code: ``3MODE`` +:Range: :math:`[X_{\min}, X_{\max}]`, intensity units + +The most frequent intensity, the smallest of them on a tie; see :ref:`MODE `. 3 on +the worked example, where the 3s and the 4s both occur six times. + + +.. _feature-3p01: +.. _feature-3p10: +.. _feature-3p25: +.. _feature-3p75: +.. _feature-3p90: +.. _feature-3p99: + +3D percentiles +^^^^^^^^^^^^^^ + +:Codes: ``3P01``, ``3P10``, ``3P25``, ``3P75``, ``3P90``, ``3P99`` +:Range: :math:`[X_{\min}, X_{\max}]`, intensity units + +The 1st, 10th, 25th, 75th, 90th and 99th percentiles, interpolated within a histogram of 100 +equal-width bins over the ROI's range; see :ref:`Percentiles ` for the formula, the +bin-boundary rule and the error against exact percentiles. On the worked example they are 2.009, +3.008, 3.03, 5.028, 5.978 and 5.998; ``numpy.percentile`` with linear interpolation gives 2.21, 3, +3, 5, 6 and 6. + + +.. _feature-3qcod: + +3D quartile coefficient of dispersion +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3QCOD`` +:Range: :math:`[0, 1]` for non-negative intensities, dimensionless + +:math:`(P_{75} - P_{25}) / (P_{75} + P_{25})`, 0 when the denominator is 0; see +:ref:`QCOD `. 0.248 on the worked example. + + +.. _feature-3range: + +3D range +^^^^^^^^ + +:Code: ``3RANGE`` +:Range: :math:`[0, \infty)`, intensity units + +:math:`X_{\max} - X_{\min}`; see :ref:`RANGE `. 4 on the worked example. + + +.. _feature-3robust-mean: + +3D robust mean +^^^^^^^^^^^^^^ + +:Code: ``3ROBUST_MEAN`` +:Range: :math:`[P_{10}, P_{90}]`, intensity units + +The mean of the voxel intensities between the histogram percentiles :math:`P_{10}` and +:math:`P_{90}`, both included, 0 when the interval holds no voxel; see +:ref:`ROBUST_MEAN `. On the worked example :math:`P_{10} = 3.008` and +:math:`P_{90} = 5.978` exclude the 2, the 3s and the 6s, and the value is 4.4545; with exact +percentiles (3 and 6) the 3s and 6s are kept and the mean is 4.3333. + + +.. _feature-3robust-mean-absolute-deviation: + +3D robust mean absolute deviation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3ROBUST_MEAN_ABSOLUTE_DEVIATION`` +:Range: :math:`[0, \infty)`, intensity units + +The mean absolute deviation of the voxel intensities between :math:`P_{10}` and :math:`P_{90}` +from their own mean; see :ref:`ROBUST_MEAN_ABSOLUTE_DEVIATION `. +0.4959 on the worked example. + + +.. _feature-3root-mean-squared: + +3D root mean square +^^^^^^^^^^^^^^^^^^^ + +:Code: ``3ROOT_MEAN_SQUARED`` +:Range: :math:`[0, \infty)`, intensity units + +:math:`\sqrt{\mathrm{3ENERGY}/N_v}`; see :ref:`ROOT_MEAN_SQUARED `. +4.385 on the worked example. It inherits the wrap-around of ``3ENERGY`` for grey levels above +65535, a known defect (`#490 `__): 47166 instead of 87698 for the worked example times 20000. + + +.. _feature-3skewness: + +3D skewness +^^^^^^^^^^^ + +:Code: ``3SKEWNESS`` +:Range: :math:`(-\infty, \infty)`, dimensionless + +:math:`m_3 / m_2^{3/2}`, 0 when :math:`N_v < 4` or :math:`m_2 = 0`; see +:ref:`SKEWNESS `. 0.06841 on the worked example. + + +.. _feature-3standard-deviation: + +3D standard deviation +^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3STANDARD_DEVIATION`` +:Range: :math:`[0, \infty)`, intensity units + +:math:`\sigma`, with :math:`N_v - 1`; see :ref:`STANDARD_DEVIATION `. +1.193 on the worked example. + + +.. _feature-3standard-deviation-biased: + +3D biased standard deviation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3STANDARD_DEVIATION_BIASED`` +:Range: :math:`[0, \infty)`, intensity units + +:math:`\sigma_b`, with :math:`N_v`; see +:ref:`STANDARD_DEVIATION_BIASED `. 1.165 on the worked +example. + + +.. _feature-3standard-error: + +3D standard error +^^^^^^^^^^^^^^^^^ + +:Code: ``3STANDARD_ERROR`` +:Range: :math:`[0, \infty)`, intensity units + +:math:`\sigma / \sqrt{N_v}`; see :ref:`STANDARD_ERROR `. 0.2542 on the +worked example. + + +.. _feature-3uniformity: + +3D uniformity +^^^^^^^^^^^^^ + +:Code: ``3UNIFORMITY`` +:Range: :math:`[1/N_b, 1]`, dimensionless + +:math:`\sum_j p_j^2` over :math:`N_b` bins; see :ref:`UNIFORMITY `. 0.2355 on +the worked example with 64 bins, 0.3182 with 4. + + +.. _feature-3uniformity-piu: + +3D percent image uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3UNIFORMITY_PIU`` +:Range: :math:`[0, 100]` for non-negative intensities, percent + +:math:`100\,(1 - (X_{\max} - X_{\min}) / (X_{\max} + X_{\min}))`, 0 when +:math:`X_{\max} + X_{\min} = 0`; see :ref:`UNIFORMITY_PIU `. 50 on the +worked example; 133.3 for the worked example minus 10 read as a signed volume. + + +.. _feature-3variance: + +3D variance +^^^^^^^^^^^ + +:Code: ``3VARIANCE`` +:Range: :math:`[0, \infty)`, squared intensity units + +:math:`\sigma^2`, with :math:`N_v - 1`; see :ref:`VARIANCE `. 1.422 on the +worked example. + + +.. _feature-3variance-biased: + +3D biased variance +^^^^^^^^^^^^^^^^^^ + +:Code: ``3VARIANCE_BIASED`` +:Range: :math:`[0, \infty)`, squared intensity units + +:math:`\sigma_b^2 = m_2`, with :math:`N_v`, the IBSI intensity variance; see +:ref:`VARIANCE_BIASED `. 1.357 on the worked example. + + +.. _intensity-3d-possible-issues: + +Possible issues +--------------- + +Histogram percentiles + The six percentiles, and ``3INTERQUARTILE_RANGE``, ``3QCOD``, ``3ROBUST_MEAN`` and + ``3ROBUST_MEAN_ABSOLUTE_DEVIATION`` through them, come from a 100-bin histogram rather than from + the sorted intensities, exactly as in 2D. On the worked example ``3P90`` is 5.978 where the + exact value is 6, and ``3ROBUST_MEAN`` 4.4545 where exact bounds give 4.3333. ``3MEDIAN`` is + exact. + +Covered range with one ROI + ``3COVERED_IMAGE_INTENSITY_RANGE`` divides by the range of all ROI voxels of the volume, so in + a volume with a single ROI it is 1, or the ``--noval`` substitute when that ROI is constant. + Compare ROIs only within one volume. + +Grey levels above 65535 + ``3ENERGY`` and ``3ROOT_MEAN_SQUARED`` square each voxel as a 32-bit unsigned integer, a known + defect (`#490 `__), which + wraps for grey levels above 65535 (32-bit integer volumes, or floating-point volumes read with + ``--fpimgdr`` above 65535). The other features are computed in floating point and are not + affected. + +Mixed standardizations + ``3SKEWNESS``, ``3KURTOSIS`` and ``3EXCESS_KURTOSIS`` standardize with :math:`\sigma_b`; + ``3HYPERSKEWNESS``, ``3HYPERFLATNESS`` and ``3COV`` use :math:`\sigma`, as in 2D. + +Small ROIs + ``3SKEWNESS`` is 0 for ROIs of fewer than 4 voxels, and ``3KURTOSIS`` and ``3EXCESS_KURTOSIS`` + for fewer than 5. ``3HYPERSKEWNESS`` and ``3HYPERFLATNESS`` have no minimum: one voxel of 1, 2 + and 4 in three slices gives 0.3464 and 0.7099. + +Constant ROI + When all of a ROI's voxels have one intensity, the location features, the percentiles and the + robust mean return that intensity, ``3UNIFORMITY`` is 1 and ``3UNIFORMITY_PIU`` is 100. The + dispersions, ``3COV``, ``3QCOD`` and the moments are 0; ``3ENTROPY`` prints as about + :math:`-3.2 \times 10^{-16}`. + +Voxels of intensity 0 inside the mask + They are ROI voxels and take part in every feature. Setting one 6 of the worked example to 0 + lowers ``3MIN`` to 0 and ``3MEAN`` to 3.955. + +Negative intensities + A signed volume read from a file is reported in its own units: the worked example minus 10 + gives ``3MIN`` −8 and ``3MEAN`` −5.773. ``3COV``, ``3QCOD`` and ``3UNIFORMITY_PIU`` are ratios + meant for non-negative intensities; with negative values they can be negative or leave their + usual range. + + +Validation +---------- + +The test suite checks the 3D intensity features against two references: + +- **MATLAB** (R2026a), for every feature except ``3COVERED_IMAGE_INTENSITY_RANGE``, + ``3ENERGY``, ``3ENTROPY``, ``3MEDIAN_ABSOLUTE_DEVIATION``, ``3ROBUST_MEAN``, + ``3ROBUST_MEAN_ABSOLUTE_DEVIATION`` and ``3UNIFORMITY``: to a relative 10\ :sup:`-3`, and to + 10\ :sup:`-2` for the six percentiles, ``3INTERQUARTILE_RANGE`` and ``3QCOD``. +- **PyRadiomics** 3.0.1, for ``3ENERGY``, ``3ENTROPY``, ``3KURTOSIS``, ``3MAX``, ``3MEAN``, + ``3MEAN_ABSOLUTE_DEVIATION``, ``3MEDIAN``, ``3MIN``, ``3RANGE``, ``3ROOT_MEAN_SQUARED``, + ``3SKEWNESS`` and ``3UNIFORMITY`` to a relative 10\ :sup:`-9`; ``3VARIANCE`` to + 10\ :sup:`-3`, because PyRadiomics divides by :math:`N_v`; ``3P10``, ``3P90``, + ``3INTERQUARTILE_RANGE`` and ``3ROBUST_MEAN_ABSOLUTE_DEVIATION`` to 10\ :sup:`-2`. + +``3COVERED_IMAGE_INTENSITY_RANGE``, ``3MEDIAN_ABSOLUTE_DEVIATION`` and ``3ROBUST_MEAN`` are +regression-only: no reference tool computes the same quantity (MATLAB's ``mad`` takes a median of +the deviations and ``trimmean`` trims sample tails). The worked-example values quoted on this page +are pinned by the worked-example script; the floating-point covered-range observation is not, and +the constant-ROI ``3ENTROPY`` is pinned only to below 10\ :sup:`-15` in magnitude. None is +compared with a reference tool. + + +References +---------- + +1. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io, section *Intensity-based statistical + features*. +2. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. +3. Goerner FL, et al. *A comparison of five standard methods for evaluating image intensity + uniformity in partially parallel imaging MRI.* Medical Physics. 2013;40(8):082302. + https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3745492/ (Percent image uniformity) diff --git a/docs/source/Math/f_intensity_histogram.rst b/docs/source/Math/f_intensity_histogram.rst new file mode 100644 index 000000000..97ecc4d30 --- /dev/null +++ b/docs/source/Math/f_intensity_histogram.rst @@ -0,0 +1,786 @@ +IBSI intensity histogram features +================================= + +The intensity histogram features are first-order statistics of a ROI's intensities after they are +sorted into a fixed number of equal-width bins. Each statistic comes in two forms: over the bin +indices, as IBSI defines it, and over the bin-centre intensities, in the image's own units. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``IH_*``: 46 features, one value per ROI. 20 end in ``_VAL`` (intensities), 20 are + statistics of the bin indices ending in ``_IDX``, and 6 describe the histogram's gradient + and its bins (``IH_MAX_GRADIENT``, ``IH_MAX_GRADIENT_IDX``, ``IH_MIN_GRADIENT``, + ``IH_MIN_GRADIENT_IDX``, ``IH_NUM_BINS``, ``IH_BIN_SIZE``). + * - **Group** + - ``*ALL_IH*``; ``*ALL*`` also includes it. Computed only in IBSI mode. + * - **Dimensions** + - 2D only; there is no 3D family. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory on tiled TIFF files and on striped files of up to + 1024 × 1024 pixels; see :ref:`intensity-histogram-possible-issues` for larger striped files + and :ref:`large-images-tiles`. + * - **IBSI** + - 23 features have an identifier from the IBSI intensity histogram features, which list 23 + features: the 19 ``_IDX`` statistics other than ``IH_ROBUST_MEAN_IDX`` and the 4 gradient + features. The IBSI reference manual gives the identifier 1PR8 to both the minimum and the + 10th percentile; the index repeats it as published. + +.. tip:: + + Request the family in IBSI mode: ``--ibsi=true`` on the command line, ``ibsi=True`` in Python. + Without it the ``IH_*`` columns are left out of the output, even when ``*ALL*`` is requested. + The other first-order statistics, computed from the intensities themselves, are on the + :doc:`pixel intensity page `. + + +How the features are computed +----------------------------- + +Nyxus builds one histogram per ROI and computes every feature from it. Nothing else is used: not +the pixels' positions, not their neighbours, not the pixels of the bounding box outside the mask. + +- **Which pixels:** all pixels of the ROI, including pixels of intensity 0 inside the mask. + :math:`N_v` is the number of ROI pixels. +- **Bins:** :math:`N` equal-width bins over the ROI's own range, from :math:`X_{\min}` to + :math:`X_{\max}`. :math:`N` is ``--coarseGrayDepth``, 64 by default. The bin width is + :math:`w = (X_{\max} - X_{\min}) / N`. A pixel of intensity :math:`x` falls in bin + :math:`b(x) = \min\left(\lfloor (x - X_{\min}) / w \rfloor,\; N - 1\right) + 1`, so bins are + numbered from 1 and :math:`X_{\max}` falls in bin :math:`N`. This is the IBSI fixed bin number + discretisation, except that a value on an inner bin edge can fall one bin low, a known defect + (`#492 `__). IBSI mode does not turn the binning off, as it does for most texture families. +- **Two kinds of value:** an ``_IDX`` feature is computed over the bin index :math:`i`, the + discretised intensity of IBSI. A ``_VAL`` feature is the same statistic over the bin centre + :math:`c_i = X_{\min} + (i - \tfrac{1}{2})\,w`, except the minimum and maximum, which are the + ROI's own extremes, and the percentiles, which are interpolated inside their bin. +- **Units:** the ``_VAL`` features are reported in the image's own units when the image is read + from a file; see :ref:`intensity-units`. The histogram is built after the image's intensity map + is undone, so bins, bin centres and ``IH_BIN_SIZE`` are in those units too. Location features + are in intensity units, spreads in intensity units, the variance in squared units, and the + ratios, skewness, kurtosis, entropy and uniformity are dimensionless. The ``_IDX`` features are + in bins. +- **In-memory arrays:** ``Nyxus.featurize`` records no map. The ``_VAL`` features are in the grey + levels Nyxus stores, which equal the input when it holds non-negative integers. An array with + negative values is shifted up by its minimum, and fractions are truncated. +- **No histogram:** a constant ROI has no range to bin, and fewer than 2 bins make no histogram. + Every ``IH_*`` feature then returns the ``--noval`` substitute, 0 by default. + + +Worked example +^^^^^^^^^^^^^^ + +Take the 5 × 5 ROI of the texture pages, with every pixel in the mask: + +.. math:: + + \textbf{X} = \begin{bmatrix} + 5 & 2 & 5 & 4 & 4\\ + 3 & 3 & 3 & 1 & 3\\ + 2 & 1 & 1 & 1 & 3\\ + 4 & 2 & 2 & 2 & 3\\ + 3 & 5 & 3 & 3 & 2 \end{bmatrix} + +With :math:`N = 5` bins, :math:`w = (5 - 1)/5 = 0.8`. The bin edges are 1, 1.8, 2.6, 3.4, 4.2 and +5, so intensity :math:`k` falls in bin :math:`k` and the bin indices equal the intensities. The +counts are :math:`h = (4, 6, 9, 3, 3)`, the probabilities :math:`p = (0.16, 0.24, 0.36, 0.12, +0.12)` and the cumulative fractions :math:`F = (0.16, 0.40, 0.76, 0.88, 1)`. The bin centres are +1.4, 2.2, 3.0, 3.8 and 4.6. + +``IH_MEAN_IDX`` is :math:`\sum_i i\,p_i = 2.8`, the mean of the intensities. ``IH_MEAN_VAL`` is +:math:`\sum_i c_i\,p_i = 2.84`: the bin centres do not sit on the intensities. + +For the 90th percentile, the last bin with :math:`F_{i-1} \le 0.9` is bin 5, with +:math:`F_4 = 0.88`, so + +.. math:: + + Q(0.9) = 4.2 + 0.8 \times \frac{0.9 - 0.88}{0.12} = 4.3333 + +``IH_P90_VAL`` is 4.3333 and ``IH_P90_IDX`` is the bin holding it, 5. The 10th percentile is +:math:`1 + 0.8 \times 0.1/0.16 = 1.5`, in bin 1. Every bin lies between bins 1 and 5, so the robust +features equal their plain counterparts here. + +The histogram gradient is :math:`H' = (6-4,\; (9-4)/2,\; (3-6)/2,\; (3-9)/2,\; 3-3) = +(2, 2.5, -1.5, -3, 0)`, so ``IH_MAX_GRADIENT`` is 2.5 at bin 2 and ``IH_MIN_GRADIENT`` is +:math:`-3` at bin 4. + +The *Example* value of each feature in the :ref:`index ` is what Nyxus +returns for this ROI with 5 bins. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/intensity_histogram_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features="*ALL_IH*" --ibsi=true --coarseGrayDepth=5 \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_v` + - Number of ROI pixels. + * - :math:`X_{\min},\; X_{\max}` + - Smallest and largest intensity in the ROI. + * - :math:`N` + - Number of bins, ``--coarseGrayDepth``. + * - :math:`w` + - Bin width, :math:`w = (X_{\max} - X_{\min}) / N`. + * - :math:`i` + - Bin index, from 1 to :math:`N`. + * - :math:`b(x)` + - Bin of a value :math:`x`, + :math:`b(x) = \min\left(\max\left(\lfloor (x - X_{\min}) / w \rfloor, 0\right), N - 1\right) + 1`. + * - :math:`c_i` + - Centre of bin :math:`i`, :math:`c_i = X_{\min} + (i - \tfrac{1}{2})\,w`. + * - :math:`h_i,\; p_i` + - Number of ROI pixels in bin :math:`i`, and :math:`p_i = h_i / N_v`. + * - :math:`F_i` + - Cumulative fraction, :math:`F_i = \sum_{k \le i} p_k`, with :math:`F_0 = 0`. + * - :math:`v_i` + - The value a bin stands for: :math:`c_i` for a ``_VAL`` feature, :math:`i` for an ``_IDX`` + feature. + * - :math:`\mu` + - Mean, :math:`\mu = \sum_i p_i v_i`. + * - :math:`m_r` + - Central moment of order :math:`r`, :math:`m_r = \sum_i p_i (v_i - \mu)^r`. + * - :math:`Q(q)` + - The histogram quantile at fraction :math:`q`, interpolated inside its bin (see + :ref:`Percentiles `). + +Sums over :math:`i` run over all :math:`N` bins; empty bins add nothing. Where an entry gives one +formula in :math:`v_i` for a ``_VAL`` and an ``_IDX`` code, each code uses its own :math:`v_i`. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Bins + - ``--coarseGrayDepth=64`` + - ``Nyxus(..., coarse_gray_depth=64)`` + - :math:`N`, the number of bins. Below 2 there is no histogram and every feature returns the + ``--noval`` substitute. The only such value Python accepts is 1, since it refuses 0 and + negative values; on the command line, 1, 0 or any negative value. The same option sets the + grey levels of the texture families. + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - Required. Without it the family is removed from the request and no ``IH_*`` column is + written. It does not change the binning. + * - Image intensity map + - ``--fpimgdr``, ``--fpimgmin``, ``--fpimgmax``, ``--preserve-hu`` + - ``Nyxus(..., preserve_hu=True)`` and the matching keywords + - Decide how a file's intensities become grey levels, and so the units of the ``_VAL`` + features (see :ref:`intensity-units`). + +The family has no metaparameters; ``--coarseGrayDepth`` is its only bin setting, and a fixed bin +width cannot be requested. All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``IH_MEAN_VAL``, ``IH_MEAN_IDX`` and so on. +There are no per-bin columns and no ``_AVE`` columns. The bin counts themselves are not written; +``HISTOGRAM``, on the :doc:`pixel intensity page `, writes the counts of a +histogram with the same bins; a value on an inner bin edge can fall one bin lower in the ``IH_*`` +features, a known defect (`#492 `__). + + +.. _intensity-histogram-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example with 5 bins. + +.. include:: intensity_histogram_index.inc + + +Features +-------- + +.. _feature-ih-num-bins: +.. _feature-ih-bin-size: + +Bin count and bin width +^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``IH_NUM_BINS``, ``IH_BIN_SIZE`` +:Range: :math:`N \ge 2`, bins; :math:`w > 0`, intensity units + +The number of bins and the width of one bin. They record the binning the other features used. + +.. math:: + + \mathrm{IH\_NUM\_BINS} = N, \qquad \mathrm{IH\_BIN\_SIZE} = w = \frac{X_{\max} - X_{\min}}{N} + + +.. _feature-ih-coefficient-of-variation-val: +.. _feature-ih-coefficient-of-variation-idx: + +Coefficient of variation +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``IH_COEFFICIENT_OF_VARIATION_VAL``, ``IH_COEFFICIENT_OF_VARIATION_IDX`` +:Range: :math:`[0, \infty)` for positive intensities, dimensionless + +The standard deviation over the mean. ``IH_COEFFICIENT_OF_VARIATION_IDX`` is the IBSI feature. + +.. math:: + + \mathrm{CoV} = \frac{\sqrt{m_2}}{\mu} + +**Possible issues.** The ``_VAL`` form divides by the mean intensity. With negative intensities, +such as a CT in Hounsfield units, it can be negative; with a mean of 0 it divides by zero and +returns the ``--noval`` substitute. + + +.. _feature-ih-entropy-val: +.. _feature-ih-entropy-idx: + +Entropy +^^^^^^^ + +:Codes: ``IH_ENTROPY_VAL``, ``IH_ENTROPY_IDX`` +:Range: :math:`[0, \log_2 N]`, bits + +The Shannon entropy of the bin probabilities. High values mean the pixels spread evenly over many +bins. Both codes return the same value; ``IH_ENTROPY_IDX`` is the IBSI feature. + +.. math:: + + \mathrm{Entropy} = -\sum_{p_i > 10^{-7}} p_i \log_2 p_i + +The logarithm is exact. A bin holding at most one ten-millionth of the pixels is left out, which +can matter only for ROIs of more than :math:`10^7` pixels. + + +.. _feature-ih-excess-kurtosis-val: +.. _feature-ih-excess-kurtosis-idx: + +Excess kurtosis +^^^^^^^^^^^^^^^ + +:Codes: ``IH_EXCESS_KURTOSIS_VAL``, ``IH_EXCESS_KURTOSIS_IDX`` +:Range: :math:`[-2, \infty)`, dimensionless + +How heavy the tails of the binned distribution are, relative to a normal distribution. + +.. math:: + + \mathrm{Kurtosis} = \frac{m_4}{m_2^2} - 3 + +**Relations.** The bin centres are a linear map of the bin indices, so both codes return the same +value. + + +.. _feature-ih-interquantile-range-val: +.. _feature-ih-interquantile-range-idx: + +Interquartile range +^^^^^^^^^^^^^^^^^^^ + +:Codes: ``IH_INTERQUANTILE_RANGE_VAL``, ``IH_INTERQUANTILE_RANGE_IDX`` +:Range: :math:`[0, X_{\max} - X_{\min}]`, intensity units; :math:`[0, N - 1]`, bins + +The spread of the middle half of the pixels. + +.. math:: + + \mathrm{IH\_INTERQUANTILE\_RANGE\_VAL} = Q(0.75) - Q(0.25), \qquad + \mathrm{IH\_INTERQUANTILE\_RANGE\_IDX} = b\left(Q(0.75)\right) - b\left(Q(0.25)\right) + +On the worked example :math:`Q(0.25) = 2.1` and :math:`Q(0.75) = 3.3778`, in bins 2 and 3. + + +.. _feature-ih-maximum-val: +.. _feature-ih-maximum-idx: + +Maximum +^^^^^^^ + +:Codes: ``IH_MAXIMUM_VAL``, ``IH_MAXIMUM_IDX`` +:Range: intensity units; bins + +The largest intensity of the ROI, and its bin. + +.. math:: + + \mathrm{IH\_MAXIMUM\_VAL} = X_{\max}, \qquad \mathrm{IH\_MAXIMUM\_IDX} = b(X_{\max}) = N + +``IH_MAXIMUM_VAL`` is the intensity itself, not the centre of the last bin. + + +.. _feature-ih-max-gradient: +.. _feature-ih-max-gradient-idx: + +Maximum histogram gradient +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``IH_MAX_GRADIENT``, ``IH_MAX_GRADIENT_IDX`` +:Range: pixels per bin; bins + +The steepest rise of the histogram, and the bin where it occurs. The gradient uses one-sided +differences at the first and last bins and central differences inside, as IBSI defines it: + +.. math:: + + H'_i = \begin{cases} + h_2 - h_1 & i = 1\\ + (h_{i+1} - h_{i-1})/2 & 1 < i < N\\ + h_N - h_{N-1} & i = N + \end{cases} + \qquad + \mathrm{IH\_MAX\_GRADIENT} = \max\left(\max_i H'_i,\; \varepsilon\right) + +where :math:`\varepsilon = 2.2251 \times 10^{-308}`, the smallest positive normalized double +(``DBL_MIN``). +``IH_MAX_GRADIENT_IDX`` is the lowest :math:`i` at which :math:`H'_i` reaches the maximum, and 0 +when no :math:`H'_i` exceeds :math:`\varepsilon`. + +**Possible issues.** When no bin's gradient is above 0, the search never replaces its starting +value: ``IH_MAX_GRADIENT`` is :math:`2.2251 \times 10^{-308}` and ``IH_MAX_GRADIENT_IDX`` is 0, +which is not a bin. This is a known defect (`#491 `__). IBSI gives the largest :math:`H'_i`, which is then 0 or negative, and its bin. +A ROI of two pixels, 1 and 2, with 2 bins has :math:`H' = (0, 0)`: IBSI gives 0 at bin 1. The +pixels 1, 1 and 2 give :math:`H' = (-1, -1)`: IBSI gives :math:`-1` at bin 1. + + +.. _feature-ih-mean-val: +.. _feature-ih-mean-idx: + +Mean +^^^^ + +:Codes: ``IH_MEAN_VAL``, ``IH_MEAN_IDX`` +:Range: :math:`[X_{\min}, X_{\max}]`, intensity units; :math:`[1, N]`, bins + +The probability-weighted mean of the bin values. ``IH_MEAN_IDX`` is the IBSI mean discretised +intensity. + +.. math:: + + \mu = \sum_i p_i v_i + +**Relations.** :math:`\mathrm{IH\_MEAN\_VAL} = X_{\min} + (\mathrm{IH\_MEAN\_IDX} - \tfrac{1}{2})\,w`. +It is the mean of the bin centres, not of the intensities: 2.84 on the worked example with 5 bins +and 2.824 with 64, where ``MEAN`` is 2.8. + + +.. _feature-ih-mean-absolute-deviation-val: +.. _feature-ih-mean-absolute-deviation-idx: + +Mean absolute deviation +^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``IH_MEAN_ABSOLUTE_DEVIATION_VAL``, ``IH_MEAN_ABSOLUTE_DEVIATION_IDX`` +:Range: :math:`[0, \infty)`, intensity units; bins + +The mean distance of the pixels' bin values from their mean. + +.. math:: + + \mathrm{MAD} = \sum_i p_i \left| v_i - \mu \right| + +**Relations.** The ``_VAL`` value is :math:`w` times the ``_IDX`` value. + + +.. _feature-ih-median-val: +.. _feature-ih-median-idx: + +Median +^^^^^^ + +:Codes: ``IH_MEDIAN_VAL``, ``IH_MEDIAN_IDX`` +:Range: intensity units; bins + +The bin of the middle pixel, and its centre. The median bin :math:`m` is the first bin at which +the running count exceeds half the pixels, rounded down: + +.. math:: + + m = \min\left\{\, i : \sum_{k \le i} h_k > \lfloor N_v / 2 \rfloor \right\}, \qquad + \mathrm{IH\_MEDIAN\_IDX} = m, \qquad \mathrm{IH\_MEDIAN\_VAL} = c_m + +**Possible issues.** For an even :math:`N_v` this is the upper of the two middle pixels, with no +averaging. IBSI uses the sample median, which averages them. For the pixels 1, 1, 2 and 2 with 2 +bins, ``IH_MEDIAN_IDX`` is 2 and ``IH_MEDIAN_VAL`` 1.75, where IBSI gives 1.5. The two agree when +the two middle pixels share a bin. + + +.. _feature-ih-median-absolute-deviation-val: +.. _feature-ih-median-absolute-deviation-idx: + +Median absolute deviation +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``IH_MEDIAN_ABSOLUTE_DEVIATION_VAL``, ``IH_MEDIAN_ABSOLUTE_DEVIATION_IDX`` +:Range: :math:`[0, \infty)`, intensity units; bins + +The mean distance of the pixels' bin values from the median. + +.. math:: + + \mathrm{MedAD} = \sum_i p_i \left| v_i - v_m \right| + +where :math:`v_m` is ``IH_MEDIAN_VAL`` or ``IH_MEDIAN_IDX``. It inherits the median's convention. + + +.. _feature-ih-minimum-val: +.. _feature-ih-minimum-idx: + +Minimum +^^^^^^^ + +:Codes: ``IH_MINIMUM_VAL``, ``IH_MINIMUM_IDX`` +:Range: intensity units; bins + +The smallest intensity of the ROI, and its bin. + +.. math:: + + \mathrm{IH\_MINIMUM\_VAL} = X_{\min}, \qquad \mathrm{IH\_MINIMUM\_IDX} = b(X_{\min}) = 1 + +``IH_MINIMUM_VAL`` is the intensity itself, not the centre of the first bin. + + +.. _feature-ih-min-gradient: +.. _feature-ih-min-gradient-idx: + +Minimum histogram gradient +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``IH_MIN_GRADIENT``, ``IH_MIN_GRADIENT_IDX`` +:Range: pixels per bin; :math:`[1, N]`, bins + +The steepest fall of the histogram, and the bin where it occurs. :math:`H'_i` is defined under +:ref:`Maximum histogram gradient `. + +.. math:: + + \mathrm{IH\_MIN\_GRADIENT} = \min_i H'_i + +``IH_MIN_GRADIENT_IDX`` is the lowest :math:`i` at which :math:`H'_i` reaches the minimum. + + +.. _feature-ih-mode-val: +.. _feature-ih-mode-idx: + +Mode +^^^^ + +:Codes: ``IH_MODE_VAL``, ``IH_MODE_IDX`` +:Range: intensity units; bins + +The most populated bin, and its centre. + +.. math:: + + \mathrm{IH\_MODE\_IDX} = \min\left\{\, i : h_i = \max_k h_k \right\}, \qquad + \mathrm{IH\_MODE\_VAL} = c_{\mathrm{IH\_MODE\_IDX}} + +**Possible issues.** Ties go to the lowest bin. IBSI picks the tied bin closest to the mean. For +the pixels 1, 1, 3, 4, 4, 5 and 5 with 5 bins, bins 1, 4 and 5 each hold 2 pixels and the mean +is 3.29: ``IH_MODE_IDX`` is 1, where IBSI gives 4. + + +.. _feature-ih-p10-val: +.. _feature-ih-p10-idx: +.. _feature-ih-p90-val: +.. _feature-ih-p90-idx: + +Percentiles +^^^^^^^^^^^ + +:Codes: ``IH_P10_VAL``, ``IH_P10_IDX``, ``IH_P90_VAL``, ``IH_P90_IDX`` +:Range: :math:`[X_{\min}, X_{\max}]`, intensity units; :math:`[1, N]`, bins + +The 10th and 90th percentiles, read off the histogram as grouped data, and the bins that hold +them. The quantile :math:`Q(q)` lies in bin :math:`k`, chosen from the bottom for :math:`q < 0.5` +and from the top otherwise, and is interpolated linearly inside it: + +.. math:: + + k = \begin{cases} + \min\{\, i : F_i \ge q \} & q < 0.5\\ + \max\{\, i : F_{i-1} \le q \} & q \ge 0.5 + \end{cases} + \qquad + Q(q) = X_{\min} + (k - 1)\,w + w\,\frac{q - F_{k-1}}{p_k} + +.. math:: + + \mathrm{IH\_P10\_VAL} = Q(0.1), \quad \mathrm{IH\_P10\_IDX} = b\left(Q(0.1)\right), \quad + \mathrm{IH\_P90\_VAL} = Q(0.9), \quad \mathrm{IH\_P90\_IDX} = b\left(Q(0.9)\right) + +The quartiles :math:`Q(0.25)` and :math:`Q(0.75)` used by the interquartile range and the +quartile coefficient of dispersion are computed the same way. + +**Possible issues.** IBSI defines the percentiles as sample percentiles of the bin indices and +does not fix the interpolation. ``IH_P10_IDX`` and ``IH_P90_IDX`` are the bins of the +interpolated percentiles instead. On the worked example ``IH_P90_IDX`` is 5, where +``numpy.percentile`` gives 4.6 with linear interpolation, 4 with ``method="lower"`` and 5 with +``method="higher"``. The robust features take their bin range from these two codes. + +When :math:`q` equals a cumulative fraction exactly, :math:`Q(q)` is the upper edge of its bin, and +the ``_IDX`` code names the next bin, which can be empty. For the pixels 1, eight 3s and 5 with 4 +bins, :math:`F_1 = 0.1`, so ``IH_P10_VAL`` is 2 and ``IH_P10_IDX`` is 2, a bin that holds no pixel. +The pixel of intensity 1 is then left out of the robust features: ``IH_ROBUST_MEAN_IDX`` is +3.111. + + +.. _feature-ih-quantile-coefficient-of-dispersion-val: +.. _feature-ih-quantile-coefficient-of-dispersion-idx: + +Quartile coefficient of dispersion +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``IH_QUANTILE_COEFFICIENT_OF_DISPERSION_VAL``, ``IH_QUANTILE_COEFFICIENT_OF_DISPERSION_IDX`` +:Range: :math:`[0, 1]` for positive intensities, dimensionless + +The interquartile range over the sum of the quartiles. ``IH_QUANTILE_COEFFICIENT_OF_DISPERSION_IDX`` +is the IBSI feature. + +.. math:: + + \mathrm{IH\_QUANTILE\_COEFFICIENT\_OF\_DISPERSION\_VAL} = \frac{Q(0.75) - Q(0.25)}{Q(0.75) + Q(0.25)} + +.. math:: + + \mathrm{IH\_QUANTILE\_COEFFICIENT\_OF\_DISPERSION\_IDX} = + \frac{b\left(Q(0.75)\right) - b\left(Q(0.25)\right)}{b\left(Q(0.75)\right) + b\left(Q(0.25)\right)} + +**Possible issues.** With negative intensities the ``_VAL`` form can be negative or exceed 1, and +it divides by zero when the quartiles cancel. + + +.. _feature-ih-range-val: +.. _feature-ih-range-idx: + +Range +^^^^^ + +:Codes: ``IH_RANGE_VAL``, ``IH_RANGE_IDX`` +:Range: :math:`(0, \infty)`, intensity units; bins + +The distance between the ROI's extremes. + +.. math:: + + \mathrm{IH\_RANGE\_VAL} = X_{\max} - X_{\min}, \qquad + \mathrm{IH\_RANGE\_IDX} = b(X_{\max}) - b(X_{\min}) = N - 1 + +``IH_RANGE_IDX`` therefore depends only on the setting, as in IBSI fixed bin number +discretisation. + + +.. _feature-ih-robust-mean-val: +.. _feature-ih-robust-mean-idx: + +Robust mean +^^^^^^^^^^^ + +:Codes: ``IH_ROBUST_MEAN_VAL``, ``IH_ROBUST_MEAN_IDX`` +:Range: intensity units; bins + +The mean bin value of the pixels whose bins lie between the 10th-percentile bin and the +90th-percentile bin, inclusive. IBSI defines no histogram robust mean; it uses this mean inside +the robust mean absolute deviation. + +.. math:: + + \mu_R = \frac{\sum_{i=a}^{b} h_i v_i}{\sum_{i=a}^{b} h_i}, \qquad + a = \mathrm{IH\_P10\_IDX}, \quad b = \mathrm{IH\_P90\_IDX} + + +.. _feature-ih-robust-mean-absolute-deviation-val: +.. _feature-ih-robust-mean-absolute-deviation-idx: + +Robust mean absolute deviation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_VAL``, ``IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_IDX`` +:Range: :math:`[0, \infty)`, intensity units; bins + +The mean distance from :math:`\mu_R` of the same pixels the robust mean uses. + +.. math:: + + \mathrm{RMAD} = \frac{\sum_{i=a}^{b} h_i \left| v_i - \mu_R \right|}{\sum_{i=a}^{b} h_i} + +**Possible issues.** The bin range comes from ``IH_P10_IDX`` and ``IH_P90_IDX`` (see +:ref:`Percentiles `). On the worked example it covers every bin, so the value +equals the mean absolute deviation, 0.96. With IBSI's set taken between ``numpy.percentile`` +linear percentiles, 1 and 4.6, the 5s are left out and the value is 0.8182. + + +.. _feature-ih-skewness-val: +.. _feature-ih-skewness-idx: + +Skewness +^^^^^^^^ + +:Codes: ``IH_SKEWNESS_VAL``, ``IH_SKEWNESS_IDX`` +:Range: :math:`(-\infty, \infty)`, dimensionless + +The asymmetry of the binned distribution. Positive values mean a longer tail towards high +intensities. + +.. math:: + + \mathrm{Skewness} = \frac{m_3}{m_2^{3/2}} + +**Relations.** Both codes return the same value. + + +.. _feature-ih-uniformity-val: +.. _feature-ih-uniformity-idx: + +Uniformity +^^^^^^^^^^ + +:Codes: ``IH_UNIFORMITY_VAL``, ``IH_UNIFORMITY_IDX`` +:Range: :math:`[1/N, 1]`, dimensionless + +The sum of the squared bin probabilities. High values mean most pixels share few bins. Both codes +return the same value; ``IH_UNIFORMITY_IDX`` is the IBSI feature. + +.. math:: + + \mathrm{Uniformity} = \sum_i p_i^2 + + +.. _feature-ih-variance-val: +.. _feature-ih-variance-idx: + +Variance +^^^^^^^^ + +:Codes: ``IH_VARIANCE_VAL``, ``IH_VARIANCE_IDX`` +:Range: :math:`[0, \infty)`, squared intensity units; squared bins + +The population variance of the bin values, with :math:`N_v`, as IBSI defines it. + +.. math:: + + \sigma^2 = m_2 = \sum_i p_i (v_i - \mu)^2 + +**Relations.** The ``_VAL`` value is :math:`w^2` times the ``_IDX`` value. + + +.. _intensity-histogram-possible-issues: + +Possible issues +--------------- + +IBSI mode only + Without ``--ibsi=true`` the ``IH_*`` features are removed from the request, whether they were + named one by one, through ``*ALL_IH*`` or through ``*ALL*``. No column is written, rather than a + column of substitutes. + +Values depend on the bin count + Every feature except ``IH_MINIMUM_VAL``, ``IH_MINIMUM_IDX``, ``IH_MAXIMUM_VAL`` and + ``IH_RANGE_VAL`` changes with + :math:`N`. On the worked example ``IH_MEAN_IDX`` is 2.8 with 5 bins and 29.68 with the default + 64; ``IH_MEDIAN_VAL`` is 3 and 3.031, ``IH_P90_VAL`` 4.333 and 4.948. Compare values only + between runs with the same ``--coarseGrayDepth``. + +``_VAL`` features are not the intensity statistics + Except for the extremes and the percentiles, the ``_VAL`` features use bin centres, not the + pixels' own intensities. ``IH_MEAN_VAL`` is 2.84 on the worked example where ``MEAN`` is 2.8. + For statistics of the intensities themselves use the :doc:`pixel intensity features + `. + +Differences from IBSI conventions + The median takes the upper middle pixel for an even pixel count, mode ties go to the lowest + bin, and the percentile codes are the bins of interpolated percentiles. Each entry above gives + an example. When no bin's gradient is above 0, ``IH_MAX_GRADIENT`` is + :math:`2.2251 \times 10^{-308}` and ``IH_MAX_GRADIENT_IDX`` is 0, a known defect (`#491 `__). + +Values on a bin edge + The bin of a value is computed in floating point as :math:`\lfloor (x - X_{\min}) / w \rfloor`. + A value that lies exactly on an inner edge can fall one bin low, a known defect (`#492 `__). For the pixels 1, 10 and 19 + with 14 bins, 10 lies on the lower edge of bin 8, but ``IH_MEDIAN_IDX`` is 7. + +No histogram + A constant ROI, or a bin count below 2, makes every ``IH_*`` feature return the ``--noval`` + substitute, 0 by default. ``--coarseGrayDepth=0`` on the command line gives this result too. + IBSI puts a constant ROI in a single bin and defines its features. + +Pixels of intensity 0 inside the mask + They are ROI pixels and take part in every feature. Setting one pixel of the worked example to + 0 lowers ``IH_MINIMUM_VAL`` to 0 and widens the 5 bins to 1. + +In-memory arrays are not in the input's units + ``Nyxus.featurize`` shifts an array with negative values up by its minimum and does not map the + features back. For the worked example minus 10, ``IH_MINIMUM_VAL`` is 0 and ``IH_MEAN_VAL`` + 1.84. Read from a file, the same image gives :math:`-9` and :math:`-7.16`. The ``_IDX`` features + do not change. + +Out-of-core ROIs of large striped TIFF files + A ROI processed out of core is read from the file again. For a striped (not tiled) TIFF more + than 1024 pixels high or wide, the pixels read back do not match the image, and most ``IH_*`` + features differ from the in-memory values; the minimum, maximum, range, bin size and number of + bins come from the first pass over the image and do not change. On a 1200 × 1200 image of four + quadrants of 100, 1100, 2100 and 3100, ``IH_MAX_GRADIENT`` was 1123200 in place of 360000 and + ``IH_MEAN_VAL`` 1635.0 in place of 1600. The intensity features are affected too; see + :ref:`intensity-possible-issues`. Tiled TIFF files of the same size, and striped files of + 1024 × 1024 pixels, give the in-memory values. + + +Validation +---------- + +The test suite checks the family against two references, in IBSI mode: + +- **MIRP** 2.6.0 for the 19 ``_IDX`` statistics other than ``IH_ROBUST_MEAN_IDX`` and the 4 + gradient features, on the IBSI digital phantom taken as one ROI with 6 bins, to a relative + 10\ :sup:`-9`; the largest difference measured is 1.3 × 10\ :sup:`-15`. +- **Analytic** closed forms, evaluated from the published definitions on the phantom and on small + test ROIs, for the ``_VAL`` features except the four below, and for ``IH_ROBUST_MEAN_IDX``, + ``IH_NUM_BINS`` and ``IH_BIN_SIZE``, to a relative 10\ :sup:`-9`. + +``IH_P10_VAL``, ``IH_P90_VAL``, ``IH_INTERQUANTILE_RANGE_VAL`` and +``IH_QUANTILE_COEFFICIENT_OF_DISPERSION_VAL`` are regression-only: no reference tool reports a +percentile interpolated inside a histogram bin. The conventions listed under Possible issues do +not show on the phantom. The worked-example values quoted there, the in-memory and file values of +the worked example minus 10 among them, are pinned by the worked-example script; the out-of-core +example was measured once and is not pinned. None is compared with a reference tool. + + +References +---------- + +1. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io, section *Intensity histogram features*. +2. Zwanenburg A, Leger S, Agolli L, et al. *Assessing robustness of radiomic features by image + perturbation.* Scientific Reports. 2019;9:614. (MIRP) diff --git a/docs/source/Math/f_morphology.rst b/docs/source/Math/f_morphology.rst index 5af3dc465..178eb3193 100644 --- a/docs/source/Math/f_morphology.rst +++ b/docs/source/Math/f_morphology.rst @@ -1,310 +1,1410 @@ -.. role:: raw-html-m2r(raw) - :format: html - - Morphology features =================== -Let :math:`A` be a set of :math:`n` pixels included in the ROI, -:math:`A_{Xi}` and :math:`A_{Yi}` - :math:`x` and -:math:`y` coordinates of pixel :math:`i`. Denote :math:`\mathbb{E}` the expectation operator. - -| AREA_PIXELS_COUNT :math:`= S = card(A)` -| AREA_UM2 :math:`= card(A) s^2` where :math:`s` is pixel size in micrometers -| CENTROID_X :math:`\gets c_X = \frac{1}{n} \sum_i ^n A_{Xi}` -| CENTROID_Y :math:`\gets c_Y = \frac{1}{n} \sum_i ^n A_{Yi}` -| WEIGHTED_CENTROID_X :math:`\gets w_X = \frac{1}{n} \sum _i ^n A_i (A_{Xi}-c_X)` -| WEIGHTED_CENTROID_Y :math:`\gets w_Y = \frac{1}{n} \sum _i ^n A_i (A_{Yi}-c_Y)` -| MASS_DISPLACEMENT :math:`= \sqrt {( w_X - c_X)^2 + ( w_Y - c_Y)^2}` -| COMPACTNESS :math:`= \frac {1}{n} {\sqrt {\mathbb {E} \left[d((A,(c_X,c_Y)))^{2}\right]}}` -| BBOX_YMIN :math:`\gets \epsilon_X = \operatorname {min}A_Y` -| BBOX_XMIN :math:`\gets \epsilon_Y = \operatorname {min}A_X` -| BBOX_HEIGHT :math:`\gets \epsilon_V = \operatorname {max}A_Y - \epsilon_Y` -| BBOX_WIDTH :math:`\gets \epsilon_H = \operatorname {max}A_X - \epsilon_X` -| ASPECT_RATIO = :math:`\begin{array}{cc} \frac{\epsilon_V}{\epsilon_H} & \epsilon_V>=\epsilon_H \frac{\epsilon_H}{\epsilon_V} & \epsilon_V<\epsilon_H \end{array}.` -| EXTENT :math:`= \frac {S}{S_{BB}}` where :math:`S_BB=\epsilon_H\epsilon_V` - -ROI's contour calculation -------------------------- -The problem of finding the set of contour pixels based on the ROI point set boils down to the concepts of pixel neighborhood and connectivity. -We say that a pixel set :math:`C` is connected (or a connected component) if for every pair of -pixels :math:`p_i`, :math:`p_j` in :math:`C` there exists a sequence of pixels :math:`p_i,..., p_j` such that (1) it is contained in :math:`C` and (2) -every pair of pixels adjacent in the sequence are neighbors of each other. Since we now have two different types -of neighbors, we obtain two different types of connectivity. Accordingly, let :math:`C` be a set of "black" (i.e. of non-zero intensity) pixels. -We say that :math:`C` is 4-connected if for every pair of pixels :math:`p_i`, :math:`p_j` in :math:`C` there exists a sequence of pixels :math:`p_i,..., p_j` -such that (1) it is contained -in :math:`C` and (2) every pair of pixels adjacent in the sequence are 4-neighbors of each other. Similarly, -We say that :math:`C` is 8-connected (or an 8-connected component) if for every pair of pixels :math:`p_i`, :math:`p_j` in :math:`C` -there exists a sequence of pixels :math:`p_i,..., p_j` such that (1) it is contained in :math:`C` and (2) every pair of pixels -adjacent in the sequence are 8-neighbors of each other. Obviously a 4-connected pattern is an 8- -connected pattern but not vice-versa. One method of finding and analyzing the connected components of image :math:`T` is to scan :math:`T` in some -manner until a border pixel of a component :math:`C` is found and subsequently to trace the boundary of -:math:`C` until the entire boundary is obtained. The boundary thus obtained can be stored as a doublylinked -circular list of pixels or as a polygon of the boundary of the pixels in question. Such procedures -are generally termed contour tracing. - -There are two -useful definitions of border points which are related to the boundary. We say that a -"black" pixel of :math:`C` is a 4-border point if at least one of its 4-neighbors is "white". We say that a "black" -pixel of :math:`C` is an 8-border point if at least one of its 8-neighbors is "white". - -More popular modifications of Algorithm Square-Tracing to handle 8-connected patterns -discard the left-turn-right-turn rule altogether. One such procedure is known as Moore-neighborhood -tracing [1]. The eight neighbors of a pixel shown in the following figure are also referred to in the literature as -the Moore neighborhood of a pixel: - -.. figure:: contour_tracing_moore_algorithm_1.jpg - :width: 100 - :align: center - :alt: Pixel neighbors in Moore's algorithm - - Example: Pixel neighbors in Moore's algorithm - -In the Moore neighborhood tracing algorithm, when the current pixel :math:`p` is "black", -the Moore neighborhood of :math:`p` is examined in a clockwise manner starting with -the pixel from which p was entered and advancing pixel by pixel until a new "black" pixel in :math:`C` is -encountered as illustrated in the following figure: - -.. figure:: contour_tracing_moore_algorithm_2.jpg - :width: 100 - :align: center - :alt: Searching for the next "black" contour pixel in Moore-neighborhood contour tracing - - Example: Searching for the next "black" contour pixel in Moore-neighborhood contour tracing - -The following figure shows an example of how the square tracing algorithm starting at the pixel marked -:math:`S` produces a sequence of boundary pixels in the case :math:`C` is 8-connected: - -.. figure:: contour_tracing_moore_algorithm_3.jpg - :width: 300 - :align: center - :alt: Square tracing an 8-connected pattern - - Example: Square tracing an 8-connected pattern - -The algorithm is presented below: - -| **Moore contour tracing algorithm:** -| *Input:* An image :math:`T` containing a connected component :math:`C` of "black" cells. -| *Output:* A sequence :math:`B(b1, b2,..., bk)` of boundary pixels. -| **begin** -| *Step 1.* Initialization: find a pixel in :math:`C`, initialize :math:`B`, define :math:`M(p)` to be the Moore neighborhood of the current pixel :math:`p`. -| *Step 2.* Set :math:`B` to be empty. -| *Step 3.* From bottom to top and left to right scan the cells of :math:`T` until a pixel :math:`s` of :math:`C` is found - (until the current pixel :math:`p=s` is a "black" pixel of :math:`C`), insert :math:`s` in :math:`B`. - Let :math:`b` denote the current boundary point, i.e., :math:`b=s`. -| *Step 4.* Backtrack (move the current pixel to the pixel from which :math:`s` was entered) and advance the current pixel :math:`p` being examined to - the next clockwise pixel in :math:`M(b)`. -| **while** (:math:`p \neq s`) -| **if** (:math:`p` is "black") -| insert :math:`p` in :math:`B`; -| set :math:`b=p`; -| backtrack (move the current pixel to the pixel from which :math:`p=b` was entered); -| **else** -| advance the current pixel :math:`p` to the next clockwise pixel in :math:`M(b)`; -| **end-while** -| **end** - -.. note:: - 1. Nyxus uses the region of interest mask, not intensity, image for contour tracing. - 2. If a region of interest contains multiple contours, the outer contour is recognized with the contour tracing algorithm and used in calculation of contour-dependent features DIAMETER_MIN_ENCLOSING_CIRCLE, DIAMETER_INSCRIBING_CIRCLE, DIAMETER_CIRCUMSCRIBING_CIRCLE, POLYGONALITY_AVE, HEXAGONALITY_AVE, HEXAGONALITY_STDDEV, WEIGHTED_SPAT_MOMENT_pq, WEIGHTED_CENTRAL_MOMENT_pq, WEIGHTED_HU_M1-7, PERCENT_TOUCHING, FRAC_AT_D, MEAN_FRAC, and RADIAL_CV. - - -Features related to ROI's contour ---------------------------------- - -Let :math:`C` be the set of contour pixels of ROI :math:`A`. - -| PERIMETER :math:`\gets P = card(C)` -| CIRCULARITY = :math:`\sqrt { \frac {4 \pi S}{P^2}}` where :math:`S` and :math:`P` are ROI area and perimeter (features AREA_PIXELS_COUNT and PERIMETER) -| EQUIVALENT_DIAMETER = :math:`\frac {P}{\pi}` -| EDGE_MEAN_INTENSITY :math:`\gets \mu_C =\frac{1}{card(C)}\sum_i C_i` -| EDGE_MAX_INTENSITY = :math:`\max \: \textrm C` -| EDGE_MIN_INTENSITY = :math:`\min \: \textrm C` -| EDGE_STDDEV_INTENSITY = :math:`\left[\frac{1}{card(C)}\sum_i (C_i-\mu_C)^2\right]^{\frac {1}{2}}` -| EDGE_INTEGRATEDINTENSITY = :math:`\sum _i {C_i}` - -Features related to ROI's convex hull -------------------------------------- - -CONVEX_HULL_AREA = :math:`S_H= {\frac {1}{2}}{\Biggl \vert }\sum _{i=0}^{n_H-1}(x_{i}y_{i+1}-x_{i+1}y_{i}){\Biggr \vert }` where :math:`n_H` is the number of convex hull vertices, -:math:`{(x_i,y_i)}, i=0, 1, ..., n_H-1` are Cartesian coordinates of the vertives. - -SOLIDITY = :math:`\frac {S}{S_H}` where :math:`S` is ROI area (feature AREA_PIXELS_COUNT) - -Features related to interpreting ROI pixels as a sampled ellipse ----------------------------------------------------------------- - -| MAJOR_AXIS_LENGTH :math:`= a = 4 \sqrt {\lambda_1}` where :math:`\lambda_1` is the first largest principal component -| MINOR_AXIS_LENGTH :math:`= b = 4 \sqrt {\lambda_2}` where :math:`\lambda_2` is the second largest principal component -| ECCENTRICITY :math:`= \sqrt {1 - \frac {b^2} {a^2}}` -| ELONGATION :math:`= \sqrt{\frac{\lambda_2}{\lambda_1}}` -.. I reformatted this part because it would not show up in rst. Let me know if it is not correct now. -| ORIENTATION :math:`\theta = 0` if :math:`b=0` and :math:`a \ge c \ \frac{\pi}{2}` when :math:`b=0` and :math:`a < c \ \arctan \frac{\lambda_1-a}{b}` if :math:`b \ne 0` - -where - -.. math:: - - cov = \left(\begin{array}{cc} - a & b \\ - b & c - \end{array}\right) - -is the covariance matrix of ROI pixel coordinates and +Morphology features describe the shape of a ROI: its size, position, outline, convex hull, +best-fitting ellipse, diameters measured at many angles, and its neighbours. Except for a few +intensity-weighted features, they use only the ROI's mask. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - 104 codes in 14 families: basic morphology (15), contour (7), convex hull (6), ellipse + fitting (6), circles (3), ROI radius (3), extrema (16), Euler number (1), geodetic length + and thickness (2), Feret (8), Martin (6) and Nassenstein (6) diameters, chords (16) and + neighbours (9). Every code produces one column. + * - **Group** + - ``*ALL_MORPHOLOGY*`` holds all 104, and also the erosion and fractal-dimension families, + which have their own pages. ``*BASIC_MORPHOLOGY*`` holds the basic morphology family and + ``*ALL_NEIGHBOR*`` the neighbour family. + * - **Dimensions** + - 2D. The 3D shape family, ``3AREA``, ``3VOLUME_CONVEXHULL`` and so on, has its own page, + :doc:`f_morphology_3d`. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Partly. Out of core the contour is traced as a single loop, so a hole or a second fragment + is left out of it, which also changes the ``EDGE_*`` features of such ROIs; ``PERIMETER`` + counts the loop's pixels, which changes the features built on it + (``DIAMETER_EQUAL_PERIMETER``, ``CIRCULARITY``, the geodetic length and thickness), and the + contour-based circle and radius features use contour coordinates relative to the bounding + box. The neighbour features are not computed: they are 0, and ``POLYGONALITY_AVE`` and + ``HEXAGONALITY_*`` are −1. With anisotropy the out-of-core contour is wrong, so every + contour-based feature can differ. These out-of-core differences are a known defect + (`#509 `__). See :ref:`morphology-possible-issues`, + :doc:`/large_images` and :ref:`large-images-tiles`. + * - **IBSI** + - None. IBSI defines morphological features in 3D only (DHQ4), so no 2D code on this page + has an identifier. + +.. tip:: + + Coordinates are in pixels with the origin at the top-left pixel of the image, :math:`x` along + the columns and :math:`y` down the rows. Angles therefore turn from the :math:`x` axis towards + the bottom of the image, which is clockwise on screen. Neighbour features need several ROIs in + one image: request ``*ALL_NEIGHBOR*`` or the codes together with the other ROIs' masks. + + +How the features are computed +----------------------------- + +Each family starts from one of four sets derived from the ROI's mask: + +- **Pixels:** every pixel of the mask with the ROI's label, at its integer position + :math:`(x_k, y_k)`. :math:`N` is their number. The basic morphology, ellipse, extrema, Euler and + chord families use them. +- **Contour:** the ROI's outer boundary, traced on the mask: the ROI pixels that have a 4-neighbour + outside the ROI, walked around the ROI in order. :math:`M` is their number. A hole or a second + fragment adds further loops, appended to the same list. The contour, perimeter, circle, ROI + radius, neighbour and polygonality features use it. +- **Convex hull:** the convex polygon spanned by the pixel centres. The convex hull and the three + caliper families use it. +- **Other ROIs:** the neighbour family compares the ROI's contour and centroid with those of the + other ROIs in the same image. + +The values are: + +- **Units:** lengths in pixels, areas in pixels, angles in degrees, except the chord angles, which + are in radians. +- **Intensities:** only ``WEIGHTED_CENTROID_X``, ``WEIGHTED_CENTROID_Y``, ``MASS_DISPLACEMENT``, + the ``EDGE_*`` features and the chords look at intensities. They use the grey levels Nyxus stores, + not the image's own units (see :ref:`intensity-units`). The chords treat a ROI pixel of grey + level 0 as a gap. +- **Binning:** none. ``--coarseGrayDepth`` and ``--ibsi`` do not change any morphology feature. +- **Pixel size:** none is applied. ``AREA_UM2`` is meant to convert the area to square micrometres + and is always 0, a known defect (`#496 `__). + + +Worked example +^^^^^^^^^^^^^^ + +Take one 17 × 14 image with two ROIs. ROI 1 occupies :math:`x = 1 \dots 12`, +:math:`y = 1 \dots 12`, with a notch three pixels wide cut into its top and its four corners trimmed. +ROI 2 is a 3 × 5 rectangle at :math:`x = 13 \dots 15`, :math:`y = 4 \dots 8`, touching ROI 1's +right side. The mask, with background ``.``: + +.. code-block:: text + + ................. + ...111...111..... + ..1111...1111.... + .11111...1111.... + .11111...1111222. + .11111...1111222. + .111111111111222. + .111111111111222. + .111111111111222. + .111111111111.... + .11111111111..... + ..111111111...... + ...1111111....... + ................. + +The grey level of the pixel at :math:`(x, y)` is :math:`1 + (3x + 5y) \bmod 9`. + +ROI 1 has :math:`N = 116` pixels, its centroid is :math:`(6.405, 6.75)`, and its bounding box is +12 × 12 at :math:`(1, 1)`, so ``EXTENT`` is :math:`116/144 = 0.8056`. Its contour holds +:math:`M = 44` pixels, joined by 34 axial and 10 diagonal steps, so +``PERIMETER`` :math:`= 34 + 10\sqrt{2} = 48.14`. The convex hull has the eight vertices +:math:`(1,3), (3,1), (11,1), (12,2), (12,9), (9,12), (3,12), (1,10)`: its polygon area is 112 and +36 lattice points lie on its boundary, so ``CONVEX_HULL_AREA`` is :math:`112 + 36/2 + 1 = 131` +and ``SOLIDITY`` is :math:`116/131 = 0.8855`. The moment ellipse has axes 13.91 and 12.55, with +its major axis at −18.77°: it rises to the right on screen. + +The Feret diameter at 0° is the hull's width along :math:`x`, :math:`12 - 1 = 11`; the largest, +13.57, is at 50°. ROI 2 is ROI 1's only neighbour: its centroid :math:`(14, 6)` lies 7.632 pixels +away at 354.4°, slightly above the :math:`x` axis. Seven of ROI 1's 44 contour pixels touch ROI 2, +so ``PERCENT_TOUCHING`` is 15.91; for ROI 2 it is 5 of 12, 41.67. + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns +for ROI 1. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/morphology_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features="*ALL_MORPHOLOGY*" \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`(x_k, y_k)` + - Position of the :math:`k`-th ROI pixel: column and row, in pixels. + * - :math:`I_k` + - Grey level of the :math:`k`-th ROI pixel. + * - :math:`N` + - Number of ROI pixels. + * - :math:`c_x,\; c_y` + - Centroid, :math:`c_x = \frac{1}{N}\sum_k x_k` and :math:`c_y = \frac{1}{N}\sum_k y_k`. + * - :math:`W,\; H` + - Width and height of the bounding box, :math:`W = \max_k x_k - \min_k x_k + 1` and + :math:`H = \max_k y_k - \min_k y_k + 1`. + * - :math:`(u_j, v_j)` + - Position of the :math:`j`-th contour pixel, in walking order, :math:`j = 1 \dots M`. + * - :math:`M` + - Number of contour pixels. + * - :math:`P` + - ``PERIMETER``. + * - :math:`A_h` + - ``CONVEX_HULL_AREA``. + * - :math:`F_{\min},\; F_{\max}` + - ``STAT_FERET_DIAM_MIN`` and ``STAT_FERET_DIAM_MAX``. + * - :math:`n_b` + - ``NUM_NEIGHBORS``. + * - :math:`D` + - The neighbour distance, ``--pixelDistance``. + * - :math:`\mathrm{sd}_{n-1}(\cdot)` + - Sample standard deviation with :math:`n - 1`, and 0 for 2 values or fewer. + +Sums over :math:`k` run over the ROI pixels and sums over :math:`j` over the contour pixels. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Neighbour distance + - ``--pixelDistance=5`` + - ``Nyxus(..., neighbor_distance=5)`` + - :math:`D`. Two ROIs are neighbours when some pair of their contour pixels lies at most + :math:`D` pixels apart. It changes ``NUM_NEIGHBORS``, the ``CLOSEST_NEIGHBOR*`` and + ``ANG_BW_NEIGHBORS_*`` features, and through ``NUM_NEIGHBORS`` the polygonality and + hexagonality scores. ``PERCENT_TOUCHING`` does not depend on it. + * - Pixel size + - ``--pixelsPerCentimeter`` + - ``Nyxus(..., pixels_per_micron=1.0)`` + - No effect: ``AREA_UM2`` is 0 with any value, a known defect (`#496 `__). + * - Anisotropy + - ``--anisox``, ``--anisoy`` + - ``Nyxus(..., anisotropy_x=1.0, anisotropy_y=1.0)`` + - Images read from files: the geometric features are computed on the ROI resampled by these + factors, so lengths are in resampled pixels; the ``EDGE_*`` features, + ``WEIGHTED_CENTROID_*`` and ``MASS_DISPLACEMENT`` use the pixels as acquired, each at its + position scaled by the factors. Arrays passed + to ``Nyxus.featurize`` are not resampled. See :ref:`morphology-possible-issues`. + * - Merged ROIs + - ``--mergerois=true`` + - ``Nyxus(..., mergerois=True)`` + - All labels of a mask form one ROI. On the worked example it has 131 pixels, a perimeter of + 52.97 and no neighbours. + * - Memory limit + - ``--ramLimit`` + - ``Nyxus.set_params(ram_limit=...)`` + - ROIs that do not fit are computed out of core, with the differences listed under + :ref:`morphology-possible-issues`. + +``--coarseGrayDepth`` and ``--ibsi`` have no effect on these features. All options are listed in +the :ref:`cli-reference`. + + +Output columns +-------------- + +Each code produces one column, named with its code: ``AREA_PIXELS_COUNT``, ``PERIMETER``, +``EXTREMA_P1_X`` and so on. No code on this page has per-angle columns or ``_AVE`` columns: +``POLYGONALITY_AVE`` and ``HEXAGONALITY_AVE`` are single scores whose names end in ``_AVE``. + +When a feature cannot be computed, its column holds a substitute value: + +- ``POLYGONALITY_AVE``, ``HEXAGONALITY_AVE`` and ``HEXAGONALITY_STDDEV`` are −1 for a ROI with fewer + than three neighbours. +- ``CLOSEST_NEIGHBOR2_DIST`` and ``CLOSEST_NEIGHBOR2_ANG`` are 0 for a ROI with fewer than two + neighbours, and every neighbour feature except ``PERCENT_TOUCHING`` is 0 for a ROI without + neighbours: with ``--pixelDistance=1`` two ROIs that touch only diagonally are not neighbours + but still count as touching. +- ``WEIGHTED_CENTROID_X`` and ``WEIGHTED_CENTROID_Y`` are 0 when the ROI's grey levels sum to 0. +- The contour features are 0 for a ROI too small to trace, such as one or two pixels. + +The ellipse, circle and hexagonality codes are right only when the codes they read from are in the +same run, a known defect (`#495 `__); see :ref:`morphology-possible-issues`. + + +.. _morphology-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on ROI 1 of the worked example. + +Basic morphology +^^^^^^^^^^^^^^^^ + +.. include:: basic_morphology_index.inc + +Contour +^^^^^^^ + +.. include:: contour_index.inc + +Convex hull +^^^^^^^^^^^ + +.. include:: convex_hull_index.inc + +Ellipse fitting +^^^^^^^^^^^^^^^ + +.. include:: ellipse_index.inc + +Circles +^^^^^^^ + +.. include:: circles_index.inc + +ROI radius +^^^^^^^^^^ + +.. include:: roi_radius_index.inc + +Extrema +^^^^^^^ + +.. include:: extrema_index.inc + +Euler number +^^^^^^^^^^^^ + +.. include:: euler_index.inc + +Geodetic length and thickness +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. include:: geodetic_index.inc + +Feret diameters +^^^^^^^^^^^^^^^ + +.. include:: feret_index.inc + +Martin diameters +^^^^^^^^^^^^^^^^ + +.. include:: martin_index.inc + +Nassenstein diameters +^^^^^^^^^^^^^^^^^^^^^ + +.. include:: nassenstein_index.inc + +Chords +^^^^^^ + +.. include:: chords_index.inc + +Neighbours +^^^^^^^^^^ + +.. include:: neighbors_index.inc + + +Features +-------- + +Basic morphology +^^^^^^^^^^^^^^^^ + +.. _feature-area-pixels-count: + +Area +"""" + +:Code: ``AREA_PIXELS_COUNT`` +:Range: :math:`[1, \infty)`, pixels + +The number of ROI pixels, :math:`N`. + + +.. _feature-area-um2: + +Area in square micrometres +"""""""""""""""""""""""""" + +:Code: ``AREA_UM2`` +:Range: always 0 + +Meant as :math:`N` times the squared pixel size. This is a known defect (`#496 `__): the +computation runs +only when the image resolution setting is positive, and nothing sets it, so the value is 0 for +every ROI whatever pixel size is given on the command line or in Python. + + +.. _feature-centroid-x: +.. _feature-centroid-y: + +Centroid +"""""""" + +:Codes: ``CENTROID_X``, ``CENTROID_Y`` +:Range: image coordinates, pixels + +The mean position of the ROI's pixels, :math:`c_x` and :math:`c_y`. .. math:: - \lambda_1 &= \frac{a+c}{2}+\sqrt{(\frac{a-c}{2})^2 + b^2} \\ - \lambda_2 &= \frac{a+c}{2}-\sqrt{(\frac{a-c}{2})^2 + b^2} + \mathrm{CENTROID\_X} = \frac{1}{N}\sum_k x_k, \qquad + \mathrm{CENTROID\_Y} = \frac{1}{N}\sum_k y_k -ROUNDNESS = :math:`\frac {4 S}{\pi a b}` -Neighbor features ------------------ +.. _feature-weighted-centroid-x: +.. _feature-weighted-centroid-y: -NUM_NEIGHBORS :math:`\gets n_N=` the number of neighbor ROIs +Intensity-weighted centroid +""""""""""""""""""""""""""" -| PERCENT_TOUCHING - the ratio of ROIs situated at Euclidean distance 0 to :math:`n_N` -| CLOSEST_NEIGHBOR1_DIST - distance to ROI's closest neighbor -| CLOSEST_NEIGHBOR1_ANG - angle between the ROI centroid and its closest neighbor's centroid -| CLOSEST_NEIGHBOR2_DIST - distance to ROI's 2nd closest neighbor -| CLOSEST_NEIGHBOR2_ANG - angle between the ROI centroid and its 2nd closest neighbor's centroid -| ANG_BW_NEIGHBORS_MEAN - standard deviation of the angle between ROI's neighbors. -| ANG_BW_NEIGHBORS_STDDEV - standard deviation of the angle between ROI's neighbors. -| ANG_BW_NEIGHBORS_MODE - the histogram bin value of angle between ROI's neighbors having the highest count. +:Codes: ``WEIGHTED_CENTROID_X``, ``WEIGHTED_CENTROID_Y`` +:Range: image coordinates, pixels -Polygonal representation features ---------------------------------- +The mean position of the ROI's pixels, each weighted by its grey level. -POLYGONALITY_AVE = :math:`5 (r_S + r_A)` where :math:`r_S = 1 - \left|1-\frac{\frac{P}{n_N}}{\sqrt{\frac{4S\tan \frac{\pi}{n_N}}{n_N}}} \right|` - polygonal size -:math:`r_A = 1 - \left| 1 - \frac{S\tan \frac{\pi}{n_N}}{\frac{1}{4} \: n_N \: P^2}\right|` - polygonal area ratio, -:math:`n_N` - number of ROI's neighbors, :math:`P` and :math:`S` - ROI's perimeter and area. +.. math:: -| HEXAGONALITY_AVE = :math:`\sqrt {\frac {r_{\sigma A}^2 + r_{\sigma P}^2}{2} }` -| HEXAGONALITY_STDDEV = :math:`5 (r_{HS} + r_{HP})` + \mathrm{WEIGHTED\_CENTROID\_X} = \frac{\sum_k x_k I_k}{\sum_k I_k}, \qquad + \mathrm{WEIGHTED\_CENTROID\_Y} = \frac{\sum_k y_k I_k}{\sum_k I_k} -References: -Nishi O, Hanasaki K. Automated determination of polygonality of corneal endothelial cells. Cornea. 1989;8(1):54-7. PMID: 2924585. +Both are 0 when :math:`\sum_k I_k = 0`. -Other features --------------- -| DIAMETER_MIN_ENCLOSING_CIRCLE minimum diameter of a circle which completely covers the ROI -| DIAMETER_CIRCUMSCRIBING_CIRCLE the smallest circle centered at the ROI centroid that totally encloses the profile, -| DIAMETER_INSCRIBING_CIRCLE maximum diameter of a circle centered at the ROI centroid which fits inside the ROI +.. _feature-mass-displacement: + +Mass displacement +""""""""""""""""" -Let :math:`l_G` - geodetic length, :math:`t_G` - thickness. Assuming +:Code: ``MASS_DISPLACEMENT`` +:Range: :math:`[0, \infty)`, pixels + +The distance between the intensity-weighted centroid and the centroid. Large values mean the +bright part of the ROI lies off its centre. .. math:: - S &= l_G t_G \\ - P &= 2(l_G+t_G) + \mathrm{MASS\_DISPLACEMENT} = \sqrt{(\mathrm{WEIGHTED\_CENTROID\_X} - c_x)^2 + + (\mathrm{WEIGHTED\_CENTROID\_Y} - c_y)^2} -we can express the following features as: +**Possible issues.** For a ROI whose grey levels are all 0 the weighted centroid is +:math:`(0, 0)`, so the value is the distance of the centroid from the image origin: 15.23 for ROI 2 +of the worked example with its pixels set to 0. -GEODETIC_LENGTH :math:`\gets l_G = \frac{P}{4} + \sqrt{\max \left(\frac{P^2}{16}-S, 0\right)}` -THICKNESS :math:`\gets t_G = \frac{P}{2} - l_G` -Let :math:`C` be the ROI's contour and :math:`R` the set of all ROI pixels. For a pixel -:math:`p \in R`, let :math:`r_p = \min_{c \in C} ||pc||` be its distance to the nearest contour -pixel -- the radius of the largest disk centred on :math:`p` that reaches no further than the -boundary. Then +.. _feature-compactness: -| ROI_RADIUS_MEAN :math:`\gets \mu_r = \frac{1}{card(R)}\sum_{p \in R} r_p` -| ROI_RADIUS_MAX = :math:`\max_{p \in R} r_p` -| ROI_RADIUS_MEDIAN - median of :math:`\{r_p\}` +Compactness +""""""""""" -These are statistics of the ROI's *inradius map*, taken over every ROI pixel rather than over the -boundary; on a disk the maximum is attained at the centre. The distance from the centroid to each -contour pixel is a different quantity, and its two extremes are already published above as -DIAMETER_INSCRIBING_CIRCLE and DIAMETER_CIRCUMSCRIBING_CIRCLE. +:Code: ``COMPACTNESS`` +:Range: :math:`[0, \infty)`, 1/pixel -Caliper features ----------------- +The spread of the pixels' distances from the centroid, divided by the area. -Feret diameter -^^^^^^^^^^^^^^ +.. math:: -.. image:: feret3.jpg - :align: center - :width: 400 + d_k = \sqrt{(x_k - c_x)^2 + (y_k - c_y)^2}, \qquad + \mathrm{COMPACTNESS} = \frac{\mathrm{sd}_{n-1}(d_k)}{N} + +**Possible issues.** The value is a standard deviation of distances divided by a pixel count, not +a dimensionless shape ratio: it falls as the ROI grows. On the worked example it is 0.01369; the +mean squared distance to the centroid divided by the area, another common definition, is 0.1877. + + +.. _feature-bbox-xmin: +.. _feature-bbox-ymin: +.. _feature-bbox-width: +.. _feature-bbox-height: + +Bounding box +"""""""""""" + +:Codes: ``BBOX_XMIN``, ``BBOX_YMIN``, ``BBOX_WIDTH``, ``BBOX_HEIGHT`` +:Range: image coordinates and :math:`[1, \infty)`, pixels + +The smallest axis-aligned box that holds every ROI pixel: its left column, top row, width and +height. Width and height count pixels, so a one-pixel ROI has a 1 × 1 box. + +.. math:: + + \mathrm{BBOX\_XMIN} = \min_k x_k, \quad \mathrm{BBOX\_YMIN} = \min_k y_k, \quad + \mathrm{BBOX\_WIDTH} = W, \quad \mathrm{BBOX\_HEIGHT} = H + + +.. _feature-diameter-equal-area: + +Equal-area diameter +""""""""""""""""""" + +:Code: ``DIAMETER_EQUAL_AREA`` +:Range: :math:`[1.128, \infty)`, pixels + +The diameter of the circle with the ROI's area. + +.. math:: + + \mathrm{DIAMETER\_EQUAL\_AREA} = 2\sqrt{N/\pi} + + +.. _feature-extent: + +Extent +"""""" + +:Code: ``EXTENT`` +:Range: :math:`(0, 1]`, dimensionless + +The fraction of the bounding box covered by the ROI. + +.. math:: + + \mathrm{EXTENT} = \frac{N}{W H} + + +.. _feature-aspect-ratio: + +Aspect ratio +"""""""""""" + +:Code: ``ASPECT_RATIO`` +:Range: :math:`(0, \infty)`, dimensionless + +The bounding box's width divided by its height; 0.6 for ROI 2 of the worked example, which is 3 +wide and 5 tall. + +.. math:: + + \mathrm{ASPECT\_RATIO} = \frac{W}{H} + + +Contour +^^^^^^^ + +The contour is traced on the mask padded by one pixel. For a ROI without holes it is the ROI's +pixels that have a 4-neighbour outside the ROI, walked in order: from the first contour pixel in +row order, each step goes to an unvisited contour pixel that is a 4-neighbour if there is one, +otherwise to a diagonal neighbour. Steps are 1 or :math:`\sqrt{2}` long. + +.. _feature-perimeter: + +Perimeter +""""""""" + +:Code: ``PERIMETER`` +:Range: :math:`[0, \infty)`, pixels + +The length of the closed path through the contour pixels' centres. + +.. math:: + + \mathrm{PERIMETER} = \sum_{j=1}^{M} \sqrt{(u_j - u_{j-1})^2 + (v_j - v_{j-1})^2}, + \qquad (u_0, v_0) = (u_M, v_M) + +**Possible issues.** The path runs through pixel centres, so it is shorter than the outline of the +pixel squares: a 3 × 5 rectangle has a perimeter of 12, not 16. On ROIs with holes or several +fragments the loops are joined into one list, and the steps between loops are counted: a 9 × 9 +square with a 3 × 3 hole gives 50.01 where its two loops measure 32 and 13.66, and a label made of +two 5 × 4 rectangles gives 42.12 where each measures 14. Out of core the value is the number of +pixels of the first traced loop, without holes or other fragments: 44 for ROI 1 of the worked +example. + + +.. _feature-diameter-equal-perimeter: + +Equal-perimeter diameter +"""""""""""""""""""""""" + +:Code: ``DIAMETER_EQUAL_PERIMETER`` +:Range: :math:`[0, \infty)`, pixels + +The diameter of the circle with the ROI's perimeter. + +.. math:: + + \mathrm{DIAMETER\_EQUAL\_PERIMETER} = \frac{P}{\pi} + + +.. _feature-edge-mean-intensity: +.. _feature-edge-stddev-intensity: +.. _feature-edge-max-intensity: +.. _feature-edge-min-intensity: +.. _feature-edge-integrated-intensity: + +Edge intensities +"""""""""""""""" + +:Codes: ``EDGE_MEAN_INTENSITY``, ``EDGE_STDDEV_INTENSITY``, ``EDGE_MAX_INTENSITY``, + ``EDGE_MIN_INTENSITY``, ``EDGE_INTEGRATED_INTENSITY`` +:Range: grey levels; :math:`[0, \infty)` for the standard deviation + +Statistics of the grey levels :math:`I_j` of the :math:`M` contour pixels: their mean, sample +standard deviation, maximum, minimum and sum. + +.. math:: + + \mathrm{EDGE\_MEAN\_INTENSITY} = \frac{1}{M}\sum_j I_j, \qquad + \mathrm{EDGE\_STDDEV\_INTENSITY} = \mathrm{sd}_{n-1}(I_j), \qquad + \mathrm{EDGE\_INTEGRATED\_INTENSITY} = \sum_j I_j + +On the worked example the 44 contour pixels of ROI 1 sum to 228, a mean of 5.182. The values are in +grey levels, not converted to the image's units. + + +Convex hull +^^^^^^^^^^^ + +The hull is built from the pixel centres by a monotone chain; points on a hull edge are not +vertices. + +.. _feature-convex-hull-area: + +Convex hull area +"""""""""""""""" + +:Code: ``CONVEX_HULL_AREA`` +:Range: :math:`[N, \infty)`, pixels + +The area of the convex hull, converted with Pick's theorem to the number of pixel centres the +hull covers, so that it is comparable with :math:`N`. + +.. math:: + + \mathrm{CONVEX\_HULL\_AREA} = \frac{1}{2}\left|\sum_i (X_i Y_{i+1} - X_{i+1} Y_i)\right| + + \frac{B}{2} + 1, \qquad B = \sum_i \gcd(|X_{i+1} - X_i|, |Y_{i+1} - Y_i|) + +where :math:`(X_i, Y_i)` are the hull vertices in order and :math:`B` is the number of lattice +points on the hull's boundary. ``skimage.measure.regionprops`` ``area_convex`` gives the same 131 +on the worked example; the bare polygon area is 112. + + +.. _feature-solidity: + +Solidity +"""""""" + +:Code: ``SOLIDITY`` +:Range: :math:`(0, 1]`, dimensionless + +The fraction of the convex hull the ROI fills. + +.. math:: + + \mathrm{SOLIDITY} = \frac{N}{A_h} + + +.. _feature-circularity: + +Circularity +""""""""""" + +:Code: ``CIRCULARITY`` +:Range: :math:`[0, \infty)`, dimensionless + +The ratio of the area to the area of a circle with the ROI's perimeter, square-rooted. + +.. math:: -| MIN_FERET_DIAMETER - minimum :math:`X_{Fe}` -| MAX_FERET_DIAMETER - maximum :math:`X_{Fe}` -| MIN_FERET_ANGLE - rotation angle delivering :math:`\min X_{Fe}` -| MAX_FERET_ANGLE - rotation angle delivering :math:`\max X_{Fe}` + \mathrm{CIRCULARITY} = \sqrt{\frac{4 \pi N}{P^2}} -Statistics of Feret diameter at 0-90 degree rotation angles: +**Possible issues.** :math:`N` counts whole pixels while :math:`P` runs through pixel centres, so +small or blocky ROIs exceed 1: ROI 2 of the worked example, a 3 × 5 rectangle, gives 1.144. The +value is 0 when no contour could be traced. -| STAT_FERET_DIAM_MIN :math:`=\min X_{Fe}` -| STAT_FERET_DIAM_MAX :math:`=\max X_{Fe}` -| STAT_FERET_DIAM_MEAN :math:`=\operatorname {E} ( X_{Fe} )` -| STAT_FERET_DIAM_MEDIAN -| STAT_FERET_DIAM_STDDEV -| STAT_FERET_DIAM_MODE -Martin diameter +.. _feature-polygonality-ave: + +Polygonality +"""""""""""" + +:Code: ``POLYGONALITY_AVE`` +:Range: :math:`(-\infty, 10]`, or −1; dimensionless + +How closely the ROI matches a regular polygon with as many sides as it has neighbours, scored up to +10. It is meant for tiled cells, such as an endothelium, where each neighbour shares one side. +With :math:`n_b \ge 3`: + +.. math:: + + r_S = 1 - \left|1 - \frac{P/n_b}{\sqrt{4 N \tan(\pi/n_b)/n_b}}\right|, \qquad + r_A = 1 - \left|1 - \frac{4 N \tan(\pi/n_b)}{n_b\,(P/n_b)^2}\right|, \qquad + \mathrm{POLYGONALITY\_AVE} = 5\,(r_S + r_A) + +The value is −1 when the ROI has fewer than three neighbours, as both ROIs of the worked example +do. A disk of radius 6 with four neighbours scores 8.484. + + +.. _feature-hexagonality-ave: +.. _feature-hexagonality-stddev: + +Hexagonality +"""""""""""" + +:Codes: ``HEXAGONALITY_AVE``, ``HEXAGONALITY_STDDEV`` +:Range: :math:`(-\infty, 10]` and :math:`[0, \infty)`, or −1; dimensionless + +How closely the ROI matches a regular hexagon. Eleven estimates of a hexagon's area and fourteen of +its perimeter are built from :math:`N`, :math:`P`, :math:`A_h`, :math:`F_{\min}` and +:math:`F_{\max}`; every pair of estimates of one kind is compared, and the score is high when they +agree. With the hull perimeter :math:`P_h = 6\sqrt{A_h/(1.5\sqrt{3})}`, the sides +:math:`s_1 = P/6`, :math:`s_2 = F_{\max}/2`, :math:`s_3 = F_{\min}/\sqrt{3}`, :math:`s_4 = P_h/6` +and the apothems :math:`a_1 = \sqrt{3}P/12`, :math:`a_2 = \sqrt{3}F_{\max}/4`, +:math:`a_3 = F_{\min}/2`, :math:`a_4 = \sqrt{3}P_h/12`, :math:`a_5 = \sqrt{4A_h/(4.5\sqrt{3})}`: + +- areas: :math:`\tfrac{3\sqrt{3}}{2}s_1^2`, :math:`\tfrac{3\sqrt{3}}{2}s_2^2`, + :math:`\tfrac{3\sqrt{3}}{2}s_3^2`, :math:`3s_1a_2`, :math:`3s_1a_3`, :math:`3s_2a_3`, + :math:`3s_4a_1`, :math:`3s_4a_2`, :math:`3s_4a_3`, :math:`A_h`, :math:`N`; +- perimeters: :math:`\sqrt{24N/\sqrt{3}}`, :math:`\sqrt{24A_h/\sqrt{3}}`, :math:`P`, :math:`P_h`, + :math:`3F_{\max}`, :math:`6F_{\min}/\sqrt{3}`, :math:`2N/a_1` to :math:`2N/a_5`, + :math:`2A_h/a_1`, :math:`2A_h/a_2`, :math:`2A_h/a_3`. + +For each kind, every pair :math:`i < j` gives the ratio :math:`1 - |1 - e_i/e_j|`; area ratios that +are not finite are skipped. With :math:`\bar r_A, \bar r_P` the means and +:math:`\sigma^2_A, \sigma^2_P` the population variances of the 55 area and 91 perimeter ratios: + +.. math:: + + \mathrm{HEXAGONALITY\_AVE} = 5\,(\bar r_A + \bar r_P), \qquad + \mathrm{HEXAGONALITY\_STDDEV} = \sqrt{\frac{\sigma^2_A + \sigma^2_P}{2}} + +Both are −1 when the ROI has fewer than three neighbours. The disk of radius 6 with four +neighbours gives 9.223 and 0.08352. + +**Possible issues.** The neighbour count gates the score although the hexagon estimates do not +use it, so an isolated hexagon scores −1. + + +Ellipse fitting ^^^^^^^^^^^^^^^ +The ellipse has the same second moments as the ROI, each pixel counting as a unit square: + +.. math:: + + u_{xx} = \frac{1}{N}\sum_k (x_k - c_x)^2 + \frac{1}{12}, \quad + u_{yy} = \frac{1}{N}\sum_k (y_k - c_y)^2 + \frac{1}{12}, \quad + u_{xy} = \frac{1}{N}\sum_k (x_k - c_x)(y_k - c_y) + +.. math:: + + \delta = \sqrt{(u_{xx} - u_{yy})^2 + 4u_{xy}^2}, \qquad + a = 2\sqrt{2}\sqrt{u_{xx} + u_{yy} + \delta}, \qquad + b = 2\sqrt{2}\sqrt{u_{xx} + u_{yy} - \delta} + +The :math:`1/12` is the second moment of a unit square; MATLAB's ``regionprops`` uses it, +``skimage.measure.regionprops`` does not and gives 13.86 and 12.50 where Nyxus gives 13.91 and +12.55 on the worked example. + +.. _feature-major-axis-length: +.. _feature-minor-axis-length: + +Axis lengths +"""""""""""" + +:Codes: ``MAJOR_AXIS_LENGTH``, ``MINOR_AXIS_LENGTH`` +:Range: :math:`[0, \infty)`, pixels + +The full lengths of the ellipse's axes, :math:`a` and :math:`b`. A single pixel gives +:math:`a = b = 1.155`. + + +.. _feature-elongation: + +Elongation +"""""""""" + +:Code: ``ELONGATION`` +:Range: :math:`(0, 1]`, dimensionless + +.. math:: + + \mathrm{ELONGATION} = \frac{b}{a} + + +.. _feature-eccentricity: + +Eccentricity +"""""""""""" + +:Code: ``ECCENTRICITY`` +:Range: :math:`[0, 1)`, dimensionless + +The distance between the ellipse's foci divided by its major axis: 0 for a circle. + +.. math:: + + \mathrm{ECCENTRICITY} = \sqrt{1 - \frac{b^2}{a^2}} + + +.. _feature-orientation: + +Orientation +""""""""""" + +:Code: ``ORIENTATION`` +:Range: :math:`[-90, 90]`, degrees + +The angle from the :math:`x` axis to the ellipse's major axis. Because :math:`y` points down the +image, positive angles turn clockwise on screen: a bar running down and to the right gives about ++45°, and ROI 1 of the worked example, whose major axis rises to the right, gives −18.77°. + +.. math:: + + \mathrm{ORIENTATION} = \frac{180}{\pi}\arctan\frac{u_{yy} - u_{xx} + \delta}{2u_{xy}} + +When :math:`u_{xy} = 0` the value is 0 if :math:`u_{xx} \ge u_{yy}` and 90 otherwise. +``skimage.measure.regionprops`` measures from the row axis: Nyxus equals +:math:`90° - ` ``orientation`` in degrees, modulo 180°. + + +.. _feature-roundness: + +Roundness +""""""""" + +:Code: ``ROUNDNESS`` +:Range: :math:`(0, \infty)`, dimensionless + +The area relative to the circle on the major axis. + +.. math:: + + \mathrm{ROUNDNESS} = \frac{4N}{\pi a^2} + + +Circles +^^^^^^^ + +.. _feature-diameter-min-enclosing-circle: + +Minimum enclosing circle +"""""""""""""""""""""""" + +:Code: ``DIAMETER_MIN_ENCLOSING_CIRCLE`` +:Range: :math:`[0, \infty)`, pixels + +The diameter of the smallest circle that holds every contour pixel centre, found by Welzl's +incremental algorithm in single precision. Each radius carries an extra :math:`10^{-4}`, so the +diameter of an exact circle comes out :math:`2 \times 10^{-4}` larger: 4.4723 for ROI 2 of the +worked example, whose exact value is :math:`2\sqrt{5} = 4.4721`. + +**Possible issues.** When a circle through three contour pixels is needed, the midpoints of their +chords are truncated to whole pixels, so the circle found can be larger than the smallest one; +this and the collinear branch below are a known defect (`#498 `__). ROI 1 +of the worked example gives 14.17; the smallest circle has a diameter of 13.62, centred at +:math:`(6.71, 6.29)`. Three collinear support points take a separate branch whose radius is half +the square root of the longest distance between them, not half that distance; it does not change +either ROI of the worked example. + + +.. _feature-diameter-circumscribing-circle: +.. _feature-diameter-inscribing-circle: + +Circumscribing and inscribing circles +""""""""""""""""""""""""""""""""""""" + +:Codes: ``DIAMETER_CIRCUMSCRIBING_CIRCLE``, ``DIAMETER_INSCRIBING_CIRCLE`` +:Range: :math:`[0, \infty)`, pixels + +Twice the largest and twice the smallest distance from the centroid to the contour: the circles +centred on the centroid that just enclose the contour and just touch it from inside. As the code +computes them, the contour pixels are two pixels further along both axes than they lie: + +.. math:: + + d_j = \sqrt{(u_j + 2 - c_x)^2 + (v_j + 2 - c_y)^2}, \qquad + \mathrm{DIAMETER\_CIRCUMSCRIBING\_CIRCLE} = 2\max_j d_j, \qquad + \mathrm{DIAMETER\_INSCRIBING\_CIRCLE} = 2\min_j d_j + +**Possible issues.** The traced contour is returned one pixel down and to the right of the ROI, +and the centroid is moved one pixel up and to the left before the distances are taken, a known +defect (`#497 `__). On the +worked example ROI 1 gives 17.41 and 1.290, where the distances to the contour as it lies give 14.72 +and 1.705; ROI 2 gives 10 where :math:`2\sqrt{5} = 4.472`. + + +ROI radius +^^^^^^^^^^ + +.. _feature-roi-radius-mean: +.. _feature-roi-radius-max: +.. _feature-roi-radius-median: + +Distance to the contour +""""""""""""""""""""""" + +:Codes: ``ROI_RADIUS_MEAN``, ``ROI_RADIUS_MAX``, ``ROI_RADIUS_MEDIAN`` +:Range: :math:`[0, \infty)`, pixels + +The mean, largest and median distance from a ROI pixel to the nearest contour pixel, over all ROI +pixels. The maximum is the radius of the largest disk inside the ROI, measured from a pixel centre. +As the code computes it, the contour lies one pixel down and to the right, a known defect +(`#497 `__): + +.. math:: + + r_k = \min_j \sqrt{(x_k - u_j - 1)^2 + (y_k - v_j - 1)^2} + +The median of an even count is the mean of the two middle values. + +**Possible issues.** Because of the contour offset, contour pixels themselves are not at distance +0. On the worked example ROI 1 gives a mean of 1.124 where the contour as it lies gives 0.9288; its +maximum, 3, and median, 1, are unchanged. ROI 2 gives 0.6943, 1.414 and 1 where the contour as it +lies gives 0.2, 1 and 0. Out of core the contour coordinates are relative to the bounding box, a known +defect (`#509 `__), so the values depend on where the ROI lies; there ROI 1 gives 0.9288 and ROI 2 11.13. + + +Extrema +^^^^^^^ + +.. _feature-extrema-p1-x: +.. _feature-extrema-p1-y: +.. _feature-extrema-p2-x: +.. _feature-extrema-p2-y: +.. _feature-extrema-p3-x: +.. _feature-extrema-p3-y: +.. _feature-extrema-p4-x: +.. _feature-extrema-p4-y: +.. _feature-extrema-p5-x: +.. _feature-extrema-p5-y: +.. _feature-extrema-p6-x: +.. _feature-extrema-p6-y: +.. _feature-extrema-p7-x: +.. _feature-extrema-p7-y: +.. _feature-extrema-p8-x: +.. _feature-extrema-p8-y: + +Extremum points +""""""""""""""" + +:Codes: ``EXTREMA_P1_X`` to ``EXTREMA_P8_Y`` +:Range: image coordinates, pixels + +Eight ROI pixels at the ends of the bounding box's sides, going clockwise on screen from the top +left. The ``_X`` and ``_Y`` codes give each point's column and row. + +.. list-table:: + :widths: 12 44 44 + :header-rows: 1 + + * - Point + - Position + - Worked example, ROI 1 + * - P1 + - leftmost pixel of the top row + - (3, 1) + * - P2 + - rightmost pixel of the top row + - (11, 1) + * - P3 + - topmost pixel of the rightmost column + - (12, 2) + * - P4 + - bottommost pixel of the rightmost column + - (12, 9) + * - P5 + - rightmost pixel of the bottom row + - (9, 12) + * - P6 + - leftmost pixel of the bottom row + - (3, 12) + * - P7 + - bottommost pixel of the leftmost column + - (1, 10) + * - P8 + - topmost pixel of the leftmost column + - (1, 3) + + +Euler number +^^^^^^^^^^^^ + +.. _feature-euler-number: + +Euler number +"""""""""""" + +:Code: ``EULER_NUMBER`` +:Range: integer + +The number of 8-connected pieces of the ROI minus the number of its holes. It is counted with bit +quads: the mask, padded by one pixel, is scanned with a 2 × 2 window, and :math:`C_1`, :math:`C_3` +and :math:`C_D` count the windows with one ROI pixel, three ROI pixels, and two diagonal ROI pixels. + +.. math:: + + \mathrm{EULER\_NUMBER} = \frac{C_1 - C_3 - 2C_D}{4} + +Both ROIs of the worked example give 1, as does ``skimage.measure.euler_number`` with +``connectivity=2``; a square with a hole gives 0 and a label in two pieces 2. + + +Geodetic length and thickness +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. _feature-geodetic-length: +.. _feature-thickness: + +Geodetic length and thickness +""""""""""""""""""""""""""""" + +:Codes: ``GEODETIC_LENGTH``, ``THICKNESS`` +:Range: :math:`[0, \infty)`, pixels + +The sides of a rectangle with the ROI's area and perimeter: its length :math:`l` and width +:math:`t` solve :math:`l t = N` and :math:`2(l + t) = P`. + +.. math:: + + \mathrm{GEODETIC\_LENGTH} = \frac{P}{4} + \sqrt{\max\left(\frac{P^2}{16} - N, 0\right)}, \qquad + \mathrm{THICKNESS} = \frac{P}{2} - \mathrm{GEODETIC\_LENGTH} + +When :math:`P^2/16 < N` no such rectangle exists and both are :math:`P/4`. On the worked example +:math:`P^2/16 - N = 28.86`, so ROI 1 gives 17.41 and 6.664. + + +Caliper diameters +^^^^^^^^^^^^^^^^^ + +The three caliper families measure the convex hull, rotated about the mean of its vertices in steps +of 10°. A rotation by :math:`\theta` maps a vertex :math:`(X, Y)` to +:math:`X' = (X - \bar X)\cos\theta - (Y - \bar Y)\sin\theta + \bar X` and +:math:`Y' = (Y - \bar Y)\cos\theta + (X - \bar X)\sin\theta + \bar Y`, in single precision. The +statistics of a family's diameters are their minimum, maximum, mean, median, population standard +deviation and mode. The mode is the most populated of the 1-pixel bins +:math:`[\lfloor F_{\min}\rfloor + i, \lfloor F_{\min}\rfloor + i + 1)`, reported as the bin's lower +end, and the lowest such bin on ties. + +.. _feature-min-feret-angle: +.. _feature-max-feret-angle: +.. _feature-stat-feret-diam-min: +.. _feature-stat-feret-diam-max: +.. _feature-stat-feret-diam-mean: +.. _feature-stat-feret-diam-median: +.. _feature-stat-feret-diam-stddev: +.. _feature-stat-feret-diam-mode: + +Feret diameters +""""""""""""""" + +:Codes: ``STAT_FERET_DIAM_MIN``, ``STAT_FERET_DIAM_MAX``, ``STAT_FERET_DIAM_MEAN``, + ``STAT_FERET_DIAM_MEDIAN``, ``STAT_FERET_DIAM_STDDEV``, ``STAT_FERET_DIAM_MODE``, + ``MIN_FERET_ANGLE``, ``MAX_FERET_ANGLE`` +:Range: :math:`[0, \infty)` pixels; angles :math:`[0, 180]` degrees + +.. image:: feret3.jpg + :align: center + :width: 400 + +The Feret, or caliper, diameter at angle :math:`\theta` is the hull's extent along :math:`x` after +the rotation, for :math:`\theta = 0°, 10°, \dots, 180°`: + +.. math:: + + F(\theta) = \max_i X'_i - \min_i X'_i + +The six ``STAT_FERET_DIAM_*`` codes are the statistics of the 19 values; an angle with +:math:`F(\theta) = 0` is left out. ``MIN_FERET_ANGLE`` and ``MAX_FERET_ANGLE`` are the first angles +at which the smallest and the largest diameter occur. On the worked example ROI 1's diameters run +from 11 at 0° to 13.57 at 40° and 50°, so ``MAX_FERET_ANGLE`` is 50 only because the values at 40° +and 50° differ in the last digits of single precision. + +**Possible issues.** 0° and 180° measure the same direction, so it counts twice, a known defect +(`#500 `__): on ROI 1 the mean +is 12.42 and the standard deviation 0.8183, where the 18 directions 0° to 170° give 12.50 and +0.7675. The 10° steps miss diameters between them: ``skimage.measure.regionprops`` +``feret_diameter_max``, which does not sample angles, gives 14.42 for ROI 1. Two +angles with equal diameters in exact arithmetic are told apart by rounding, so the reported angle +can be either. + + +.. _feature-stat-martin-diam-min: +.. _feature-stat-martin-diam-max: +.. _feature-stat-martin-diam-mean: +.. _feature-stat-martin-diam-median: +.. _feature-stat-martin-diam-stddev: +.. _feature-stat-martin-diam-mode: + +Martin diameters +"""""""""""""""" + +:Codes: ``STAT_MARTIN_DIAM_MIN``, ``STAT_MARTIN_DIAM_MAX``, ``STAT_MARTIN_DIAM_MEAN``, + ``STAT_MARTIN_DIAM_MEDIAN``, ``STAT_MARTIN_DIAM_STDDEV``, ``STAT_MARTIN_DIAM_MODE`` +:Range: :math:`[0, \infty)`, pixels + .. image:: martin.jpg - :align: center - :width: 400 + :align: center + :width: 400 + +The Martin diameter at angle :math:`\theta` is the length of the horizontal chord that splits the +rotated hull's area in half, for :math:`\theta = 0°, 10°, \dots, 170°`. The hull's height is cut +into 100 equal strips; :math:`w_i` is the hull's width at the middle of strip :math:`i`, counted +from the top, and + +.. math:: -Statistics of Martin diameter at 0-90 degree rotation angles: + \mathrm{Martin}(\theta) = w_m, \qquad + m = \min\Bigl\{ i : \sum_{l \le i} w_l \ge \tfrac{1}{2}\sum_l w_l \Bigr\} -| STAT_MARTIN_DIAM_MIN -| STAT_MARTIN_DIAM_MAX -| STAT_MARTIN_DIAM_MEAN -| STAT_MARTIN_DIAM_MEDIAN -| STAT_MARTIN_DIAM_STDDEV -| STAT_MARTIN_DIAM_MODE +The six codes are the statistics of the 18 values. ROI 1 of the worked example gives 11 at 0° and +90° and 13.49 at 40° and 50°. -Nassenstein diameter -^^^^^^^^^^^^^^^^^^^^ + +.. _feature-stat-nassenstein-diam-min: +.. _feature-stat-nassenstein-diam-max: +.. _feature-stat-nassenstein-diam-mean: +.. _feature-stat-nassenstein-diam-median: +.. _feature-stat-nassenstein-diam-stddev: +.. _feature-stat-nassenstein-diam-mode: + +Nassenstein diameters +""""""""""""""""""""" + +:Codes: ``STAT_NASSENSTEIN_DIAM_MIN``, ``STAT_NASSENSTEIN_DIAM_MAX``, + ``STAT_NASSENSTEIN_DIAM_MEAN``, ``STAT_NASSENSTEIN_DIAM_MEDIAN``, + ``STAT_NASSENSTEIN_DIAM_STDDEV``, ``STAT_NASSENSTEIN_DIAM_MODE`` +:Range: :math:`[0, \infty)`, pixels .. image:: nassenstein.jpg - :align: center - :width: 400 + :align: center + :width: 400 -Statistics of Nassenstein diameter at 0-90 degree rotation angles: +The Nassenstein diameter at angle :math:`\theta` is the vertical extent of the rotated hull through +its lowest point, the one with the largest :math:`Y'`, for :math:`\theta = 0°, 10°, \dots, 170°`. +Vertices within :math:`10^{-3}` of the lowest :math:`Y'` share the contact, and the vertical line +passes through the mean of their :math:`X'`. The six codes are the statistics of the 18 values. -|STAT_NASSENSTEIN_DIAM_MIN -| STAT_NASSENSTEIN_DIAM_MAX -| STAT_NASSENSTEIN_DIAM_MEAN -| STAT_NASSENSTEIN_DIAM_MEDIAN -| STAT_NASSENSTEIN_DIAM_STDDEV -| STAT_NASSENSTEIN_DIAM_MODE -All-chords features -^^^^^^^^^^^^^^^^^^^ +Chords +^^^^^^ .. image:: chord.jpg - :align: center - :width: 400 - -| ALLCHORDS_MAX -| ALLCHORDS_MAX_ANG -| ALLCHORDS_MIN -| ALLCHORDS_MIN_ANG -| ALLCHORDS_MEDIAN -| ALLCHORDS_MEAN -| ALLCHORDS_MODE -| ALLCHORDS_STDDEV - -Max-chord features -^^^^^^^^^^^^^^^^^^ - -| MAXCHORDS_MAX -| MAXCHORDS_MAX_ANG -| MAXCHORDS_MIN -| MAXCHORDS_MIN_ANG -| MAXCHORDS_MEDIAN -| MAXCHORDS_MEAN -| MAXCHORDS_MODE -| MAXCHORDS_STDDEV + :align: center + :width: 400 + +.. _feature-maxchords-max: +.. _feature-maxchords-max-ang: +.. _feature-maxchords-min: +.. _feature-maxchords-min-ang: +.. _feature-maxchords-median: +.. _feature-maxchords-mean: +.. _feature-maxchords-mode: +.. _feature-maxchords-stddev: +.. _feature-allchords-max: +.. _feature-allchords-max-ang: +.. _feature-allchords-min: +.. _feature-allchords-min-ang: +.. _feature-allchords-median: +.. _feature-allchords-mean: +.. _feature-allchords-mode: +.. _feature-allchords-stddev: + +Chord statistics +"""""""""""""""" + +:Codes: ``MAXCHORDS_*`` and ``ALLCHORDS_*``, each with ``_MAX``, ``_MAX_ANG``, ``_MIN``, + ``_MIN_ANG``, ``_MEDIAN``, ``_MEAN``, ``_MODE`` and ``_STDDEV`` +:Range: :math:`[0, \infty)` pixels; angles :math:`[0, \pi)` radians + +A chord is a vertical run of ROI pixels. The ROI's pixels are rotated about the centre of the +bounding box by :math:`\alpha = j\pi/20`, :math:`j = 0 \dots 19` (0° to 171° in 9° steps), and each +rotated position is truncated to whole pixels. The rotated pixels, with their grey levels, form a +new raster. In each column of it, every column unless the raster is 200 or more pixels wide, then +every :math:`\lfloor\text{width}/100\rfloor`-th, the column's chord is its longest run of nonzero +pixels that ends before the raster's last row; a column without one has no chord. + +- The ``ALLCHORDS_*`` codes are statistics of all chords of all rotations. +- The ``MAXCHORDS_*`` codes are statistics of the longest chord of each rotation that has a chord. + +For each set, ``_MAX``, ``_MIN`` and ``_MEAN`` are its largest, smallest and mean length, +``_STDDEV`` its sample standard deviation :math:`\mathrm{sd}_{n-1}`, ``_MEDIAN`` its median, the +mean of the two middle lengths for an even count, and ``_MODE`` its most frequent length, the +smallest on ties. ``_MAX_ANG`` and ``_MIN_ANG`` are the rotation angles :math:`\alpha`, in radians, +of the first chord with the largest and with the smallest length. On the worked example ROI 1 has +247 chords; its longest chords per rotation are 11, 12, 12, 12, 4, 9, 4, 11, 11, 11, 7, 12, 12, 12, +4, 9, 4, 9, 12 and 12. + +**Possible issues.** Three conventions of the raster change the values: + +- A run that reaches the raster's last row is not counted. At 0° every column of ROI 2, a 3 × 5 + rectangle, ends there, so ROI 2 has no chord at 0° or at 90°; on ROI 1 the longest chord at 0° + is 11 where the longest column holds 12 pixels. Counting those runs too gives 14 for + ``MAXCHORDS_MAX`` and 10.55 for ``MAXCHORDS_MEAN`` of ROI 1, instead of 12 and 9.5. +- Truncating the rotated positions makes several pixels land on one cell and leaves holes between + them: at 36° the 116 pixels of ROI 1 fill 94 cells and the longest chord is 4, where a + nearest-neighbour rotation of the mask has a column of 14. +- A ROI pixel of grey level 0 is a gap. Setting one pixel of ROI 1 to 0 lowers ``MAXCHORDS_MEAN`` + from 9.5 to 8.8 and ``ALLCHORDS_MEAN`` from 5.441 to 5.197, although the mask is unchanged. + + +Neighbours +^^^^^^^^^^ + +Two ROIs are neighbours when the shortest distance between their contour pixels is at most +:math:`D`, ``--pixelDistance`` (5 by default). Distances and directions between neighbours are +then measured between their centroids. A direction is the angle of the vector from this ROI's +centroid to the neighbour's, in :math:`[0, 360)` degrees from the :math:`x` axis; since :math:`y` +points down, 90° is straight down the image. + +.. _feature-num-neighbors: + +Number of neighbours +"""""""""""""""""""" + +:Code: ``NUM_NEIGHBORS`` +:Range: :math:`[0, \infty)`, integer + +The number of other ROIs within :math:`D` of this ROI. + +.. math:: + + \min_{j,\,j'} \sqrt{(u_j - u'_{j'})^2 + (v_j - v'_{j'})^2} \le D + +where :math:`(u'_{j'}, v'_{j'})` are the other ROI's contour pixels. On the worked example, with +ROI 2 moved right so that 3 pixels separate the two contours, :math:`D = 2` finds no neighbour and +:math:`D = 3` finds one, although the centroids are 9.126 apart. + + +.. _feature-percent-touching: + +Percent touching +"""""""""""""""" + +:Code: ``PERCENT_TOUCHING`` +:Range: :math:`[0, 100]`, percent + +The percentage of the ROI's contour pixels that touch, including diagonally, a contour pixel of +another ROI: those within :math:`\sqrt{2}` of one. + +.. math:: + + \mathrm{PERCENT\_TOUCHING} = \frac{100}{M}\,\Bigl|\{ j : \min_{j'} \|(u_j, v_j) - (u'_{j'}, v'_{j'})\| \le \sqrt{2}\}\Bigr| + +A pixel touching several ROIs counts once. The value does not depend on :math:`D`. + +**Possible issues.** CellProfiler's ``MeasureObjectNeighbors`` defines its percent touching +differently, so the two values differ; Nyxus counts direct contact between contour pixels only. + + +.. _feature-closest-neighbor1-dist: +.. _feature-closest-neighbor1-ang: +.. _feature-closest-neighbor2-dist: +.. _feature-closest-neighbor2-ang: + +Closest neighbours +"""""""""""""""""" + +:Codes: ``CLOSEST_NEIGHBOR1_DIST``, ``CLOSEST_NEIGHBOR1_ANG``, ``CLOSEST_NEIGHBOR2_DIST``, + ``CLOSEST_NEIGHBOR2_ANG`` +:Range: :math:`[0, \infty)` pixels; angles :math:`[0, 360)` degrees + +The centroid distance and direction of the nearest and second-nearest neighbour. Ties go to the +neighbour found first. + +.. math:: + + \mathrm{dist} = \sqrt{(c'_x - c_x)^2 + (c'_y - c_y)^2}, \qquad + \mathrm{ang} = \frac{180}{\pi}\,\mathrm{atan2}(c'_y - c_y,\; c'_x - c_x) \bmod 360 + +The ``2`` codes are 0 with fewer than two neighbours, as on the worked example. + + +.. _feature-ang-bw-neighbors-mean: +.. _feature-ang-bw-neighbors-stddev: +.. _feature-ang-bw-neighbors-mode: + +Neighbour directions +"""""""""""""""""""" + +:Codes: ``ANG_BW_NEIGHBORS_MEAN``, ``ANG_BW_NEIGHBORS_STDDEV``, ``ANG_BW_NEIGHBORS_MODE`` +:Range: :math:`[0, 360)` degrees, the mode :math:`[0, 360]`; :math:`[0, \infty)` for the standard + deviation + +The arithmetic mean, the sample standard deviation :math:`\mathrm{sd}_{n-1}` and the most frequent +value of the directions to all neighbours. The mode rounds each direction to whole degrees and +takes the smallest on ties; a direction of 359.5° or more rounds to 360. + +**Possible issues.** The directions are averaged as plain numbers, not as angles: two neighbours at +333.4° and 26.57°, one above and one below the :math:`x` axis, give a mean of 180°, pointing the +other way, where the circular mean is 0°. With two neighbours the standard deviation is 0, whatever +their directions, a known defect (`#499 `__); with one it is 0 too. + + +.. _morphology-possible-issues: + +Possible issues +--------------- + +Contour offset + The traced contour is returned one pixel down and to the right of the ROI, a known defect + (`#497 `__). ``PERIMETER``, the + ``EDGE_*`` features, the minimum enclosing circle and the neighbour features do not depend on + where the contour lies. The circumscribing and inscribing circles (17.41 and 1.290 for ROI 1, + where the contour as it lies gives 14.72 and 1.705) and ``ROI_RADIUS_*`` (mean 1.124 against + 0.9288) do. + +Holes and fragments in the contour + A hole or a second fragment adds a loop to the contour, and ``PERIMETER`` counts the step from + one loop to the next: 50.01 for a 9 × 9 square with a 3 × 3 hole, whose loops measure 32 and 13.66. + +Minimum enclosing circle + Truncated chord midpoints make the circle too large on some ROIs, a known defect + (`#498 `__): 14.17 for ROI 1 where the + smallest circle has a diameter of 13.62. Every diameter also carries :math:`2 \times 10^{-4}`. + +Chords + A run that reaches the last row of the rotated raster is not counted, truncated rotations leave + holes, and pixels of grey level 0 break chords; see :ref:`Chord statistics `. + The ``*_ANG`` chord codes are in radians while every other angle is in degrees. + +Feret angles + The Feret family samples both 0° and 180°, which are one direction, so that direction counts + twice in the statistics, a known defect (`#500 `__). Martin and Nassenstein sample 0° to 170°. + +Neighbour statistics + Directions are averaged linearly, and the standard deviation of two directions is 0, a known defect + (`#499 `__). The + neighbour gate is the gap between contours, not the distance between centroids, which the + ``*_DIST`` codes report. + +``AREA_UM2`` and ``COMPACTNESS`` + ``AREA_UM2`` is always 0, a known defect (`#496 `__). ``COMPACTNESS`` is a sample standard deviation of distances divided by + the pixel count, which falls with the ROI's size. + +``CIRCULARITY`` above 1 + Pixel-count areas and centre-to-centre perimeters put small or blocky ROIs above 1: 1.144 for a + 3 × 5 rectangle. + +Small ROIs + A ROI of one or two pixels has no traceable contour: ``PERIMETER``, the ``EDGE_*`` features, + ``CIRCULARITY``, the circles and ``ROI_RADIUS_*`` are 0. A single pixel has a hull area of 1, and 0 + for every caliper and chord feature. A ROI whose pixel centres lie on one straight line, such + as a one-pixel-wide bar, has a hull of two vertices and gives 0 for every Nassenstein + statistic and Martin statistics of at most about 10\ :sup:`-6`, rounding residue of the + rotated hull (``STAT_MARTIN_DIAM_MAX`` 4.8 × 10\ :sup:`-7` on a 1 × 13 bar); angles at which + the rotated hull is flat are left out of the Martin values in general. + +Intensity-weighted features + ``WEIGHTED_CENTROID_*``, ``MASS_DISPLACEMENT`` and the ``EDGE_*`` features use stored grey + levels, not the image's units. A ROI whose grey levels are all 0 has a weighted centroid of + :math:`(0, 0)`. + +Anisotropy + With ``--anisox`` or ``--anisoy`` on an image read from a file, the geometric features are + computed on the ROI resampled by the factors, in pixels of the resampled grid: with + ``--anisox=2`` a ROI has twice as many pixels and twice the width. The ``EDGE_*`` features, + ``WEIGHTED_CENTROID_*`` and ``MASS_DISPLACEMENT`` are computed on the pixels as acquired, each + at its position scaled by the factors, so the ``EDGE_*`` values do not change. Arrays passed to ``Nyxus.featurize`` are not resampled. + +Out of core + On ROIs computed out of core, the contour is a single loop: holes and further fragments are + left out of it, which changes ``PERIMETER`` and the ``EDGE_*`` features of such ROIs, and the + minimum enclosing circle of a ROI in several fragments. On a 300 × 300 square with a centred + 100 × 100 hole the out-of-core contour is the outer loop of 1196 pixels, where the in-memory + contour has 1596, and ``PERIMETER`` is 1196 instead of 1876. ``PERIMETER`` is the number of the loop's pixels + (44 for ROI 1 instead of 48.14), and ``DIAMETER_EQUAL_PERIMETER``, ``CIRCULARITY``, + ``GEODETIC_LENGTH`` and ``THICKNESS`` follow it. The contour's coordinates are relative to the + bounding box, which changes the circumscribing and inscribing circles and ``ROI_RADIUS_*`` + (ROI 2: circumscribing 25.30 instead of 10). The neighbour features are not computed and are + 0, and an out-of-core ROI is also not seen as a neighbour by the ROIs computed in memory, so + their neighbour features leave it out. ``POLYGONALITY_AVE`` and ``HEXAGONALITY_*``, which need + the neighbour count, are −1 out of core. On the worked example, whose ROIs have no holes, the + other 88 codes give the in-memory values; on images that Nyxus reads as several tiles see + :ref:`large-images-tiles`, and for the families that differ see :doc:`/large_images`. + +Codes that need companions + Several codes read values that another family computes, and are wrong when no code of that + family is in the same run, a known defect (`#495 `__). The ellipse codes + (``MAJOR_AXIS_LENGTH``, ``MINOR_AXIS_LENGTH``, ``ECCENTRICITY``, ``ORIENTATION``, + ``ROUNDNESS``, ``ELONGATION``) need a basic morphology code such as ``AREA_PIXELS_COUNT``: + without one the moments are taken about the image origin, and ROI 1 of the worked example + gives ``MAJOR_AXIS_LENGTH`` 39.36 instead of 13.91, ``ORIENTATION`` 45.93 instead of −18.77 + and ``ROUNDNESS`` 0. ``ELONGATION`` is 0 unless another ellipse code is in the run too. The + three circle diameters need a contour code such as ``PERIMETER`` and a basic morphology code: + alone they are 0, and with ``PERIMETER`` only ``DIAMETER_MIN_ENCLOSING_CIRCLE`` is right (ROI + 1's ``DIAMETER_CIRCUMSCRIBING_CIRCLE`` is 35.61 instead of 17.41). ``HEXAGONALITY_AVE`` and + ``HEXAGONALITY_STDDEV`` need a Feret code such as ``STAT_FERET_DIAM_MAX``: without one they + are 0, where the disk with four neighbours gives 9.223 and 0.08352. ``*ALL_MORPHOLOGY*`` + computes every one of them correctly. + +Whole-slide mode + Without a mask the ROI is the whole image: the contour is the four corners of its bounding + box, which is one pixel larger than the image, a known defect (`#493 `__). ``PERIMETER``, the + ``EDGE_*``, radius, circularity and geodetic features describe that rectangle (``PERIMETER`` 224 + for a 64 × 48 image). The box also moves the extrema: on a 64 × 48 image ``EXTREMA_P3_Y``, + ``EXTREMA_P4_Y``, ``EXTREMA_P5_X`` and ``EXTREMA_P6_X`` are −1, ``EXTREMA_P3_X`` and + ``EXTREMA_P4_X`` are 64, and ``EXTREMA_P5_Y`` and ``EXTREMA_P6_Y`` are 48, outside the image. The circle diameters and the neighbour, polygonality and hexagonality features + are not computed and are 0. See :ref:`large-images-whole`. + + +Validation +---------- + +The test suite checks the morphology features against five references: + +- **MATLAB** (R2026a, ``regionprops`` and ``bweuler``), on a test ROI, to a relative + 10\ :sup:`-3`: ``AREA_PIXELS_COUNT``, ``AREA_UM2``, ``CENTROID_X``, ``CENTROID_Y``, + ``WEIGHTED_CENTROID_X``, ``WEIGHTED_CENTROID_Y``, the four ``BBOX_*`` codes, ``EXTENT``, + ``ASPECT_RATIO``, ``MAJOR_AXIS_LENGTH``, ``MINOR_AXIS_LENGTH``, ``ELONGATION``, ``ECCENTRICITY``, + ``EULER_NUMBER`` and the sixteen ``EXTREMA_*`` codes. The ``AREA_UM2`` check sets the pixel size + and resolution itself, so it covers the formula but not the program, where the value is 0 (a known defect, + `#496 `__). +- **CellProfiler** 4.2.8: ``MASS_DISPLACEMENT`` and ``EDGE_MEAN_INTENSITY``, ``EDGE_MAX_INTENSITY``, + ``EDGE_MIN_INTENSITY`` and ``EDGE_INTEGRATED_INTENSITY`` to a relative 10\ :sup:`-6`; + ``NUM_NEIGHBORS`` and ``CLOSEST_NEIGHBOR1_DIST`` to an absolute 10\ :sup:`-9`. +- **scikit-image**: ``DIAMETER_EQUAL_AREA``, ``PERIMETER`` (on digital circles), ``ROI_RADIUS_MAX`` + and ``ROI_RADIUS_MEDIAN`` to a relative 10\ :sup:`-3`; ``CONVEX_HULL_AREA``, ``SOLIDITY`` and + ``ORIENTATION``. +- **imea** 0.3.5: ``DIAMETER_EQUAL_PERIMETER``, ``GEODETIC_LENGTH`` and ``THICKNESS`` exactly, + computed from Nyxus' own area and perimeter; ``DIAMETER_MIN_ENCLOSING_CIRCLE``, the minimum, + maximum, mean, median and standard deviation of the Feret, Martin and Nassenstein diameters, and + ``ALLCHORDS_MIN`` to a relative 6 × 10\ :sup:`-2`, on an ellipse. +- **Analytic** values: ``ROI_RADIUS_MAX`` exactly on digital disks; ``CLOSEST_NEIGHBOR1_ANG``, + ``CLOSEST_NEIGHBOR2_DIST``, ``CLOSEST_NEIGHBOR2_ANG`` and the three ``ANG_BW_NEIGHBORS_*`` codes + to an absolute 10\ :sup:`-9`; ``CIRCULARITY`` and ``ROUNDNESS`` against their formulas. + +``COMPACTNESS``, ``EDGE_STDDEV_INTENSITY``, ``POLYGONALITY_AVE``, ``HEXAGONALITY_AVE``, +``HEXAGONALITY_STDDEV``, ``DIAMETER_CIRCUMSCRIBING_CIRCLE``, ``DIAMETER_INSCRIBING_CIRCLE``, +``ROI_RADIUS_MEAN``, ``MIN_FERET_ANGLE``, ``MAX_FERET_ANGLE``, the three ``*_DIAM_MODE`` codes, +``PERCENT_TOUCHING`` and every chord code except ``ALLCHORDS_MIN`` are regression-only. The +out-of-core values of the ellipse family, the ``EDGE_*`` features and ``MASS_DISPLACEMENT`` are +checked against the in-memory values of the same build. The *Example* values and the values the +worked-example script checks beside them are pinned by that script. The comparisons with +``skimage.measure.regionprops``, the alternative conventions (contour as it lies, Feret over 18 +directions, chords counting the last row, merged ROIs) and the out-of-core, anisotropy and +whole-slide values are measured but not pinned. + References ---------- -1. Rosenfeld, A., “Digital topology,” American Mathematical Monthly, vol. 86, 1979, pp. 621-630. +1. Rosenfeld A. *Digital topology.* American Mathematical Monthly. 1979;86:621–630. +2. Gray SB. *Local properties of binary images in two dimensions.* IEEE Transactions on Computers. + 1971;C-20(5):551–561. (Bit-quad Euler number) +3. Welzl E. *Smallest enclosing disks (balls and ellipsoids).* In: New Results and New Trends in + Computer Science. Springer; 1991:359–370. +4. Pahl M, Schädel G, Rumpf H. *Zusammenstellung von Teilchenformbeschreibungsmethoden.* + Aufbereitungs-Technik. 1973;14. (Feret, Martin and Nassenstein diameters) +5. Nishi O, Hanasaki K. *Automated determination of polygonality of corneal endothelial cells.* + Cornea. 1989;8(1):54–57. +6. ISO 9276-6:2008. *Representation of results of particle size analysis — Part 6: Descriptive + and quantitative representation of particle shape and morphology.* diff --git a/docs/source/Math/f_morphology_3d.rst b/docs/source/Math/f_morphology_3d.rst new file mode 100644 index 000000000..99ad9f140 --- /dev/null +++ b/docs/source/Math/f_morphology_3d.rst @@ -0,0 +1,548 @@ +3D shape features +================= + +3D shape features describe the size and form of a ROI in a volume: its surface, its volume +measured three ways, the volume of its convex hull, and the lengths of its principal axes. They use +only the ROI's mask; voxel intensities play no part. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - 14 codes, one value and one column each: ``3AREA``, ``3MESH_VOLUME``, ``3VOXEL_VOLUME``, + ``3VOLUME_CONVEXHULL``, the five ratios ``3AREA_2_VOLUME``, ``3COMPACTNESS1``, + ``3COMPACTNESS2``, ``3SPHERICAL_DISPROPORTION`` and ``3SPHERICITY``, the three axis lengths + ``3MAJOR_AXIS_LEN``, ``3MINOR_AXIS_LEN`` and ``3LEAST_AXIS_LEN``, and ``3ELONGATION`` and + ``3FLATNESS``. + * - **Group** + - ``*3D_ALL_MORPHOLOGY*``; ``*3D_ALL*`` includes it. + * - **Dimensions** + - 3D only. The 2D shape features are on the :doc:`morphology page `. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes. Out of core the volume is streamed one z-plane at a time and gives the same values as + in memory; the axis features can differ in the last digits, because out of core the + covariance is formed from running sums of the coordinates and their products rather than + from coordinates centred on their mean. When one read of the file returns the whole volume + (NIfTI, or OME-Zarr chunked over all of Z), an oversized ROI stops the run with an error + instead (see :doc:`/large_images`). + * - **IBSI** + - ``3MESH_VOLUME`` (RNU0), ``3VOXEL_VOLUME`` (YEKZ), the three axis lengths (TDIC, P9VJ, + 7J51), ``3ELONGATION`` (Q3CK) and ``3FLATNESS`` (N17B). ``3AREA`` and the five ratios have + no identifier: IBSI defines its surface area (C0JK) and the ratios on the mesh, where Nyxus + counts voxel faces and uses the voxel-counting volume. ``3VOLUME_CONVEXHULL`` has no + identifier of its own; IBSI uses it only inside the volume density (R3ER). + +.. tip:: + + Three different volumes are on offer. ``3MESH_VOLUME`` is IBSI's volume (mesh) and is the one + to compare with MIRP or PyRadiomics. ``3VOXEL_VOLUME`` is essentially the voxel count. The five + ratios use ``3VOXEL_VOLUME`` and the voxel-face count ``3AREA``, not the mesh, so they do not + match IBSI's compactness, sphericity or surface-to-volume ratio; see + :ref:`shape-3d-possible-issues`. + + +How the features are computed +----------------------------- + +The features start from the ROI's voxels, the voxels of the mask that carry the ROI's label, at +integer positions :math:`(x_k, y_k, z_k)`: :math:`x` along the columns, :math:`y` down the rows +and :math:`z` across the slices. :math:`N` is their number. From them Nyxus builds: + +- **The voxel faces:** each voxel has six faces, and a face is exposed when the voxel on its other + side, along :math:`x`, :math:`y` or :math:`z`, is not in the ROI. Faces towards a cavity inside + the ROI count too. ``3AREA`` is the number of exposed faces; with anisotropy it is the sum of + their areas. +- **The surface mesh:** the marching-cubes surface of the binary mask at the 0.5 isolevel, built on + the ROI's bounding box padded by one empty voxel on every side, so the surface is closed. The + mask is binary, so every mesh vertex sits halfway between an ROI voxel centre and a neighbouring + non-ROI voxel centre: one of its three coordinates is a half-integer. ``3MESH_VOLUME`` is + the volume this surface encloses. The case table splits the two inside corners of an ambiguous cube face (two diagonal + corners inside, two outside), so the surface stays closed in every case. +- **The convex hull:** the convex hull of the mesh vertices. ``3VOLUME_CONVEXHULL`` is its volume. + The hull is computed in doubled, integer coordinates, so the volume is exact. +- **The covariance matrix:** the 3 × 3 sample covariance (divided by :math:`N-1`) of the voxel + coordinates. Its eigenvalues give the axis lengths, ``3ELONGATION`` and ``3FLATNESS``. + +The values are: + +- **Units:** lengths in voxels, ``3AREA`` in voxel faces (squared voxel edges), volumes in cubic + voxels. No pixel size is applied. With anisotropy settings the coordinates are scaled by the + anisotropy, so the units are those of the scaled voxel (see `Settings`_). +- **Intensities:** not used. A ROI whose voxels are all 0, or all equal, gives the same values as + one with varying intensities. ``--coarseGrayDepth`` and ``--ibsi`` change nothing. +- **Which volume where:** ``3AREA_2_VOLUME``, ``3COMPACTNESS1``, ``3COMPACTNESS2``, + ``3SPHERICAL_DISPROPORTION`` and ``3SPHERICITY`` combine ``3AREA`` with ``3VOXEL_VOLUME``. + ``3MESH_VOLUME`` and ``3VOLUME_CONVEXHULL`` enter no other feature. +- **One ROI per volume:** in whole-volume mode, and with ``single_roi=True``, Nyxus does not trace + the ROI's shape: it takes the ROI to be a solid box and uses closed forms; the axis features are + then 0. With ``single_roi=True`` this is a known defect (`#494 `__); see + :ref:`shape-3d-possible-issues`. In whole-volume mode, on the command line + (``--segDir`` equal to ``--intDir``) or through ``Nyxus3D.featurize_directory`` with the + intensity directory as the label directory, the box is one voxel larger than the volume on + every axis, :math:`(w+1) \times (h+1) \times (d+1)`, a known defect (`#493 `__). ``featurize_files(volumes, volumes, + single_roi=True)`` is not whole-volume mode: it merges the nonzero voxels of each mask, here + the volume itself, into one ROI, and the box is the bounding box of the volume's nonzero + voxels. + +``skimage.measure.marching_cubes`` at level 0.5 on the padded mask, followed by the divergence +theorem, reproduces ``3MESH_VOLUME`` to the float32 precision of its vertices, and +``scipy.spatial.ConvexHull`` on those vertices reproduces ``3VOLUME_CONVEXHULL`` to double +precision. + + +Worked example +^^^^^^^^^^^^^^ + +Take a ROI of 44 voxels on three slices, placed with a margin of one voxel in a 5 × 7 × 8 volume. +Slice by slice, rows :math:`y` and columns :math:`x`, with background ``.``: + +.. code-block:: text + + z = 0 (21 voxels) z = 1 (16 voxels) z = 2 (7 voxels) + .111.. ..11.. ...... + 11111. .1111. ..11.. + 111111 .11111 ..111. + .11111 ..1111 ...11. + ...11. ....1. ...... + +The ROI has 96 exposed voxel faces, so ``3AREA`` is 96. Its 0.5-isolevel mesh encloses +:math:`475/12 = 39.583` cubic voxels, and the convex hull of the mesh vertices :math:`122/3 = +40.667`. ``3VOXEL_VOLUME`` is :math:`44 \times 0.9999977 = 43.9999`. The mesh's own surface area, +which Nyxus does not report, is 66.37 (``skimage.measure.mesh_surface_area``), well below the 96 +faces. + +The covariance matrix of the voxel coordinates, in the order :math:`x, y, z`, is + +.. math:: + + \textbf{K} = \begin{bmatrix} + 1.7611 & 0.5137 & 0.1586\\ + 0.5137 & 1.2474 & 0.0169\\ + 0.1586 & 0.0169 & 0.5476 \end{bmatrix} + +with eigenvalues :math:`\lambda_1 = 2.0920`, :math:`\lambda_2 = 0.94139` and +:math:`\lambda_3 = 0.52260`. The major axis points along :math:`(0.85, 0.52, 0.09)`, mostly along +:math:`x`; the least axis points almost along :math:`z`. So ``3MAJOR_AXIS_LEN`` is +:math:`4\sqrt{2.0920} = 5.786`, ``3ELONGATION`` is :math:`\sqrt{0.94139/2.0920} = 0.6708` and +``3FLATNESS`` is :math:`\sqrt{0.52260/2.0920} = 0.4998`. + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for +this ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/shape_3d_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --dim=3 --features="*3D_ALL_MORPHOLOGY*" \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N` + - Number of ROI voxels. + * - :math:`(x_k, y_k, z_k)` + - Integer position of voxel :math:`k`; :math:`\bar x, \bar y, \bar z` are their means. + * - :math:`A_f` + - Number of exposed voxel faces, ``3AREA``. + * - :math:`V_v` + - Voxel-counting volume, ``3VOXEL_VOLUME``. + * - :math:`V_m` + - Volume enclosed by the marching-cubes mesh, ``3MESH_VOLUME``. + * - :math:`T` + - The mesh's triangles; a triangle has vertices :math:`\mathbf a, \mathbf b, \mathbf c`, and + :math:`\mathbf o` is the first vertex of the first triangle. + * - :math:`\textbf{K}` + - Sample covariance matrix of the voxel coordinates, divided by :math:`N-1`. + * - :math:`\lambda_1 \ge \lambda_2 \ge \lambda_3` + - Eigenvalues of :math:`\textbf{K}`, in descending order. + + +Settings +-------- + +The 3D shape features have no settings of their own. These options change their values: + +- ``--anisox``, ``--anisoy`` and ``--anisoz`` (Python: ``anisotropy_x``, ``anisotropy_y``, + ``anisotropy_z``) give the voxel's size along each axis. The volume is featurized on its voxels + as acquired, and the shape features scale their coordinates by the anisotropy: with + ``--anisoz=2`` the worked example's ``3VOXEL_VOLUME``, ``3MESH_VOLUME`` and + ``3VOLUME_CONVEXHULL`` double, ``3AREA`` sums each exposed face's area, and the axis lengths + are those of the coordinates with z doubled. +- ``--use-physical-spacing=true`` (Python: ``use_physical_spacing``) takes the factors from the + volume's OME ``PhysicalSize`` values, scaled so that the smallest is 1. The values are then in + units of the smallest spacing, not in physical units. Any ``--aniso*`` value other than 1 + replaces the physical spacing on all three axes; the axes not given then use 1. +- Whole-volume mode, ``--segDir`` equal to ``--intDir`` (Python: ``featurize_directory`` with + the intensity directory as the label directory), treats each volume as one ROI and measures a + box in its place; ``featurize_files(..., single_roi=True)`` measures the box of the nonzero + voxels, a known defect (`#494 `__). See `How the features are computed`_. +- ``--ramLimit`` (Python: ``ram_limit``) decides which ROIs are processed out of core; the values + stay the same, except as noted in the *Out-of-core* row above. + +``--coarseGrayDepth``, ``--ibsi`` and the intensity map options have no effect. All options are +listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named with its code. There are no ``_AVE`` columns. + + +.. _shape-3d-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. + +.. include:: shape_3d_index.inc + + +Features +-------- + +.. _feature-3area: + +Surface area +^^^^^^^^^^^^ + +:Code: ``3AREA`` +:Range: without anisotropy, an integer of at least 6 for a nonempty ROI, in voxel faces; with + anisotropy, a positive sum of face areas + +The number of ROI voxel faces whose neighbour across the face, in the 6-neighbourhood, is not in +the ROI. It is a staircase area, not the area of a smooth surface. + +.. math:: + + A_f = \sum_{k=1}^{N} \sum_{\mathbf d \in \{\pm\mathbf e_x, \pm\mathbf e_y, \pm\mathbf e_z\}} + \big[\, (x_k, y_k, z_k) + \mathbf d \notin \mathrm{ROI} \,\big] + +**Relations.** A solid :math:`w \times h \times d` box has :math:`2(wh + hd + wd)` faces. + +**Possible issues.** The face count is well above the mesh area IBSI uses: 96 against 66.37 on the +worked example, 1902 against 1372 on a digital ball of radius 10. On digital balls of radius 5 to +30 the ratio stays between 1.36 and 1.43; it does not shrink as the ROI grows. + + +.. _feature-3mesh-volume: + +Volume (mesh) +^^^^^^^^^^^^^ + +:Code: ``3MESH_VOLUME`` +:Range: positive; in cubic voxels + +The volume enclosed by the ROI's marching-cubes surface at the 0.5 isolevel, by the divergence +theorem: the sum of the signed volumes of the tetrahedra each triangle spans with a fixed point +:math:`\mathbf o` on the surface. + +.. math:: + + V_m = \frac{1}{6} \left| \sum_{(\mathbf a, \mathbf b, \mathbf c) \in T} + (\mathbf a - \mathbf o) \cdot \big[(\mathbf b - \mathbf o) \times (\mathbf c - \mathbf o)\big] + \right| + +**Relations.** A single voxel gives the octahedron of volume :math:`1/6`. A solid +:math:`w \times h \times d` box gives :math:`whd - (w+h+d-3)/2 - 5/6`, the box with its edges and +corners bevelled. A cavity is enclosed by its own part of the surface and is subtracted: a +solid 5 × 5 × 5 cube gives 118.167, and the same cube with its centre voxel removed 118.0. + + +.. _feature-3voxel-volume: + +Volume (voxel counting) +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3VOXEL_VOLUME`` +:Range: positive; in cubic voxels + +The volume from the voxel count. The code multiplies the count by the volume of a ball of diameter +1 voxel and divides by a packing density written as 0.5236, a truncation of :math:`\pi/6`, so the +result is the voxel count times a constant slightly below 1. + +.. math:: + + V_v = N \, \frac{\tfrac{4}{3}\pi \left(\tfrac{1}{2}\right)^3}{0.5236} + = N \, \frac{\pi/6}{0.5236} = 0.99999766\, N + +**Possible issues.** The factor 0.9999976616 makes the value :math:`2.3 \times 10^{-6}` relative +below the voxel count, for example 43.99990 for 44 voxels. In whole-volume mode the value is the +exact volume of the box Nyxus substitutes, without the factor. + + +.. _feature-3volume-convexhull: + +Convex hull volume +^^^^^^^^^^^^^^^^^^ + +:Code: ``3VOLUME_CONVEXHULL`` +:Range: at least ``3MESH_VOLUME``; in cubic voxels + +The volume of the convex hull of the vertices of the ROI's marching-cubes mesh. Every nonempty ROI +has a hull of positive volume: even a single voxel or a one-slice ROI gives the hull of its +bevelled surface. + +.. math:: + + V_{\mathrm{hull}} = \mathrm{vol}\big(\mathrm{conv}\{\text{mesh vertices}\}\big) + +**Relations.** A convex digital solid, such as a box or a single voxel, gives +``3VOLUME_CONVEXHULL`` equal to ``3MESH_VOLUME``. IBSI's volume density (convex hull), R3ER, is +:math:`V_m / V_{\mathrm{hull}}`; Nyxus does not report it, but it can be computed from the two +columns. + + +.. _feature-3area-2-volume: + +Surface to volume ratio +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3AREA_2_VOLUME`` +:Range: positive; in inverse voxels + +.. math:: + + \mathrm{3AREA\_2\_VOLUME} = \frac{A_f}{V_v} + +IBSI's surface to volume ratio (2PR5) has the same form with the mesh area and mesh volume. + + +.. _feature-3compactness1: + +Compactness 1 +^^^^^^^^^^^^^ + +:Code: ``3COMPACTNESS1`` +:Range: positive, dimensionless; a solid cube gives :math:`1/\sqrt{216\pi} = 0.0384` + +.. math:: + + \mathrm{3COMPACTNESS1} = \frac{V_v}{\sqrt{\pi A_f^{3}}} + +IBSI's compactness 1 (SKGS) has the same form with the mesh area and mesh volume, and gives +:math:`1/(6\pi) = 0.0531` for a sphere. + + +.. _feature-3compactness2: + +Compactness 2 +^^^^^^^^^^^^^ + +:Code: ``3COMPACTNESS2`` +:Range: positive, dimensionless; a solid cube gives :math:`\pi/6 = 0.5236` + +.. math:: + + \mathrm{3COMPACTNESS2} = 36\pi \, \frac{V_v^{2}}{A_f^{3}} + +IBSI's compactness 2 (BQWJ) has the same form with the mesh area and mesh volume, and is 1 for a +sphere. A digital ball of radius 10 gives 0.2857 here. + + +.. _feature-3spherical-disproportion: + +Spherical disproportion +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3SPHERICAL_DISPROPORTION`` +:Range: positive, dimensionless; a solid cube gives :math:`(6/\pi)^{1/3} = 1.241` + +The exposed-face area divided by the surface area of a sphere with volume :math:`V_v`. + +.. math:: + + \mathrm{3SPHERICAL\_DISPROPORTION} = \frac{A_f}{\left(36\pi V_v^{2}\right)^{1/3}} + +**Relations.** ``3SPHERICAL_DISPROPORTION`` is :math:`1/` ``3SPHERICITY``. IBSI's spherical +disproportion (KRCK) uses the mesh area and mesh volume. + + +.. _feature-3sphericity: + +Sphericity +^^^^^^^^^^ + +:Code: ``3SPHERICITY`` +:Range: positive, dimensionless; a solid cube gives :math:`(\pi/6)^{1/3} = 0.806` + +The surface area of a sphere with volume :math:`V_v`, divided by the exposed-face area. + +.. math:: + + \mathrm{3SPHERICITY} = \frac{\left(36\pi V_v^{2}\right)^{1/3}}{A_f} + +**Possible issues.** A digital ball of radius 10 gives 0.6586, below a solid cube, because the +face count overestimates a curved surface. IBSI's sphericity (QCFX), from the mesh, gives 0.9099 +on the same ball. + + +.. _feature-3major-axis-len: +.. _feature-3minor-axis-len: +.. _feature-3least-axis-len: + +Major, minor and least axis lengths +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``3MAJOR_AXIS_LEN``, ``3MINOR_AXIS_LEN``, ``3LEAST_AXIS_LEN`` +:Range: non-negative, ``3MAJOR_AXIS_LEN`` :math:`\ge` ``3MINOR_AXIS_LEN`` :math:`\ge` + ``3LEAST_AXIS_LEN``; in voxels + +Four times the square root of the largest, middle and smallest eigenvalue of the covariance +matrix of the voxel coordinates: the full axis lengths of the ellipsoid with the same second +moments as the ROI. The axes are the eigenvectors of :math:`\textbf{K}`, not the image axes; the +major axis is the direction in which the ROI is most spread out. + +.. math:: + + K_{ij} = \frac{1}{N-1} \sum_{k=1}^{N} (u_{k,i} - \bar u_i)(u_{k,j} - \bar u_j), + \qquad \mathbf u_k = (x_k, y_k, z_k) + +.. math:: + + \mathrm{3MAJOR\_AXIS\_LEN} = 4\sqrt{\lambda_1}, \qquad + \mathrm{3MINOR\_AXIS\_LEN} = 4\sqrt{\lambda_2}, \qquad + \mathrm{3LEAST\_AXIS\_LEN} = 4\sqrt{\lambda_3} + +All three are 0 for a single voxel, and ``3LEAST_AXIS_LEN`` is 0 for a ROI in one slice. In +whole-volume mode all three are 0 whatever the volume (see :ref:`shape-3d-possible-issues`). + + +.. _feature-3elongation: +.. _feature-3flatness: + +Elongation and flatness +^^^^^^^^^^^^^^^^^^^^^^^ + +:Codes: ``3ELONGATION``, ``3FLATNESS`` +:Range: 0 to 1, dimensionless + +The minor and the least axis length relative to the major axis length. 1 means the ROI is spread +equally in those directions, as a ball is; values near 0 mean a needle (elongation) or a plate +(flatness). + +.. math:: + + \mathrm{3ELONGATION} = \sqrt{\lambda_2 / \lambda_1}, \qquad + \mathrm{3FLATNESS} = \sqrt{\lambda_3 / \lambda_1} + +Both are 0 for a single voxel and in whole-volume mode, and ``3FLATNESS`` is 0 for a ROI in one +slice. + + +.. _shape-3d-possible-issues: + +Possible issues +--------------- + +``3AREA`` counts voxel faces, and the five ratios inherit it + ``3AREA`` is the staircase area of the voxels, not the mesh area of IBSI, MIRP and PyRadiomics. + On a digital ball of radius 10 it is 1902, where the mesh area is 1372; on the worked example + 96 against 66.37. ``3AREA_2_VOLUME``, ``3COMPACTNESS1``, ``3COMPACTNESS2``, + ``3SPHERICAL_DISPROPORTION`` and ``3SPHERICITY`` combine it with ``3VOXEL_VOLUME``, so they do + not match the IBSI features of the same names: the ball's ``3SPHERICITY`` is 0.659 where the + mesh-based sphericity is 0.910. A sphere does not score as the most compact shape: a solid + cube has higher ``3COMPACTNESS1``, ``3COMPACTNESS2`` and ``3SPHERICITY``, and lower + ``3SPHERICAL_DISPROPORTION`` (1.241 against 1.518 for the ball), than a digital ball. + +``3VOXEL_VOLUME`` is scaled by 0.9999977 + The packing density 0.5236 truncates :math:`\pi/6 = 0.5235988`, so the value is the voxel + count times 0.9999976616. The ratios carry the same factor. + +Whole-volume mode differs from a segmented ROI that fills the volume + With no mask, the features come from closed forms of a box (see `How the features are + computed`_ for which box). ``3MAJOR_AXIS_LEN``, ``3MINOR_AXIS_LEN``, ``3LEAST_AXIS_LEN``, + ``3ELONGATION`` and ``3FLATNESS`` are 0, and ``3VOXEL_VOLUME`` is the exact box volume, without + the packing factor. A 6 × 4 × 5 volume with no zero voxels gives 0 and 120 through + ``featurize_files(..., single_roi=True)``, 0 and 210 (a 7 × 5 × 6 box, a known defect, `#493 `__) on the command line or + through ``featurize_directory``, and 6.860 for the major axis and 119.99972 for the volume when + a mask marks the same box as a ROI. ``3AREA``, ``3MESH_VOLUME`` and ``3VOLUME_CONVEXHULL`` of + the ``featurize_files`` run agree with the masked run; on the command line they are those of + the larger box (``3AREA`` 214 instead of 148). Through ``featurize_files`` zero voxels are left + out: a 3 × 2 × 2 block of nonzero voxels in the same volume gives 12 and a ``3AREA`` of 32. + +``single_roi=True`` measures the bounding box, not the mask + With ``featurize_files(..., single_roi=True)`` every shape feature describes the solid + bounding box of the merged mask, and the axis features are 0, whatever the mask's shape. This + is a known defect (`#494 `__). A ball of radius 5 (515 voxels) in a 15 × 15 × 15 volume + gives ``3VOXEL_VOLUME`` 514.999 and ``3AREA`` 486 as a segmented ROI, and 1331 and 726, the + values of its 11 × 11 × 11 box, with ``single_roi=True``; ``3MAJOR_AXIS_LEN`` is 8.909 and 0. + The values are right only for a mask that fills its box. + +Degenerate ROIs give 0, not NaN + A single voxel reports 0 for the three axis lengths, ``3ELONGATION`` and ``3FLATNESS``. A ROI + in one slice reports 0 for ``3LEAST_AXIS_LEN`` and ``3FLATNESS``. + +Ambiguous cube faces + Where a cube face of the marching-cubes grid has two diagonal corners inside the ROI and two + outside, the case table separates the inside corners. This keeps the surface closed, but it + departs from the classic marching-cubes table, and these cases are not covered by a reference + tool (see `Validation`_). Solid, smooth ROIs rarely produce them; the worked example has none. + + +Validation +---------- + +The test suite checks the 3D shape features against four references: + +- **MIRP** 2.6.0, on a segmented phantom: ``3MESH_VOLUME`` and ``3VOLUME_CONVEXHULL`` to a relative + 10\ :sup:`-6` (measured 3.8 × 10\ :sup:`-8` and 2.7 × 10\ :sup:`-8`, MIRP carrying the mesh in + float32); ``3VOXEL_VOLUME`` to a relative 10\ :sup:`-3` (measured 2.3 × 10\ :sup:`-6`, the + packing factor); ``3MAJOR_AXIS_LEN``, ``3MINOR_AXIS_LEN``, ``3LEAST_AXIS_LEN``, ``3ELONGATION`` + and ``3FLATNESS`` to a relative 10\ :sup:`-9`. +- **PyRadiomics** 3.0.1, on a phantom with anisotropic voxel spacing given as + ``--aniso*``: ``3MESH_VOLUME``, ``3MAJOR_AXIS_LEN``, ``3MINOR_AXIS_LEN``, ``3LEAST_AXIS_LEN``, + ``3ELONGATION`` and ``3FLATNESS`` to a relative 10\ :sup:`-9`. PyRadiomics divides the + covariance by :math:`N` where Nyxus divides by :math:`N - 1`, so its axis lengths are multiplied + by :math:`\sqrt{N/(N-1)}` before the comparison. +- **MATLAB** (R2026a, ``regionprops3``), on the same phantom: ``3VOXEL_VOLUME`` to a relative + 10\ :sup:`-3`, and ``3VOLUME_CONVEXHULL`` within 0.5 % of ``ConvexVolume`` (measured 0.17 %), + which counts the voxels of the rasterised hull rather than integrating it. +- **Analytic** values on lattice solids: ``3MESH_VOLUME`` on a single voxel and boxes, + ``3VOLUME_CONVEXHULL`` also on octahedra and rhombic prisms, both also in whole-volume mode, to + a relative 10\ :sup:`-12`. + +The mesh rows hold for ROIs without ambiguous cube faces; the phantom has none. ``3AREA``, +``3AREA_2_VOLUME``, ``3COMPACTNESS1``, ``3COMPACTNESS2``, ``3SPHERICAL_DISPROPORTION`` and +``3SPHERICITY`` are regression-only: their surface-area convention differs from every reference +tool. Out-of-core results are checked against in-memory results of the same build. The Nyxus +values quoted on this page (the worked example, the single-voxel, one-slice, ball, box, cube, +whole-volume and anisotropy values) are pinned by the worked-example script. The mesh areas, the +IBSI sphericity and the face-count ratio quoted for comparison are computed with +``skimage.measure.marching_cubes`` and are not pinned, nor are the covariance entries, the +major-axis direction and the skimage and scipy reproduction. + + +References +---------- + +1. Zwanenburg A, Vallières M, et al. *The Image Biomarker Standardization Initiative: standardized + quantitative radiomics for high-throughput image-based phenotyping.* Radiology. + 2020;295(2):328–338. doi:10.1148/radiol.2020191145 +2. Lorensen WE, Cline HE. *Marching cubes: a high resolution 3D surface construction algorithm.* + Computer Graphics (SIGGRAPH '87). 1987;21(4):163–169. doi:10.1145/37402.37422 +3. Barber CB, Dobkin DP, Huhdanpaa H. *The quickhull algorithm for convex hulls.* ACM Transactions + on Mathematical Software. 1996;22(4):469–483. doi:10.1145/235815.235821 +4. van der Walt S, et al. *scikit-image: image processing in Python.* PeerJ. 2014;2:e453. diff --git a/docs/source/Math/f_ngldm.rst b/docs/source/Math/f_ngldm.rst index 22c58cb94..ae0836e53 100644 --- a/docs/source/Math/f_ngldm.rst +++ b/docs/source/Math/f_ngldm.rst @@ -1,105 +1,601 @@ +Neighbouring grey-level dependence matrix (NGLDM) +================================================= -Texture features / NGLDM -============================================================================= +NGLDM features describe how many of each pixel's neighbours share its grey level. Every ROI pixel +is the centre of a 3 × 3 neighbourhood, and the matrix counts the pixels by grey level and by +dependence count, regardless of direction. Coarse or homogeneous textures give high dependence +counts; fine textures give low ones. -The neighbouring grey level dependence matrix (NGLDM) features quantify coarseness of the texture in a ROI in a rotationally invariant way. +.. list-table:: + :widths: 26 74 -NGLDM is based on the concept of a neighbourhood around a pixel determined by position and a concept of dependence between two pixels determined by grey level values. All pixels within Chebyshev distance :math:`\delta` -are considered to belong to the pixel's neighbourhood. All pixels whose grey level differenece is within :math:`\alpha` are considered to be dependent. - -Let :math:`I_u` be intensity of a pixel at location :math:`u`, -:math:`d_{cheb}(u,v) = max(|u-v|)` be the Chebyshev distance between locations :math:`u` and :math:`v`, -:math:`[ b ]` be the Iverson bracket over expression :math:`b`. - -A pixel at location :math:`m` is said to belong to the neighborhood of -a "central" pixel at location :math:`k` if :math:`d_{cheb} (k-m) \leq \delta`. Additionally, the neighboring pixel is said to be dependent on the central pixel if :math:`|I_k - I_m| \leq \alpha` with respect to -coarseness parameter :math:`\alpha \geq 0`. The number of dependent pixels :math:`j_k` within the neighborhood of pixel at location :math:`k` is defined as - -.. math:: j_k = \sum_{m_y{=}-\delta}^\delta \sum_{m_x{=}-\delta}^\delta \big[|X_{d}(\mathbf{k})-X_{d}(\mathbf{k}+\mathbf{m})| \leq \alpha\big] + * - **Feature codes** + - ``NGLDM_*``: 19 features, one value per ROI + * - **Group** + - ``*ALL_NGLDM*``; also part of ``*ALL*`` and ``*WHOLESLIDE*`` + * - **Dimensions** + - 2D. The 3D family, ``3NGLDM_*``, has its own page, :doc:`f_ngldm_3d`. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory on an image that Nyxus reads as one tile (see + :ref:`large-images-tiles` for other images), except on a constant ROI (see + :ref:`ngldm-possible-issues`). + * - **IBSI** + - 17 of the 19 features have an IBSI identifier. Grey level mean and dependence count mean + have none. -Let -:math:`N_g` be the number of unique grey level values of the pixels within the ROI, -:math:`N_r=\text{max}(j_k)` be the maximum dependence count across all the neighborhood pixels with respect to chosen :math:`\delta`. +.. tip:: -Let -:math:`\mathbf{M}` be the -:math:`N_g \times N_r` neighbouring grey level dependence matrix (NGLDM). Element :math:`s_{ij}` of :math:`\mathbf{M}` is -then the number of neighbourhoods with a center pixel with discretised -grey level :math:`i` and a neighbouring dependence :math:`j`. + To reproduce IBSI or MIRP values, run in IBSI mode: ``--ibsi=true`` on the command line, + ``ibsi=True`` in Python. NGLDM does not use the grey-level binning of the other texture families: + with the default settings a pixel's grey level is :math:`\lfloor 64\,x / x_{\max} \rfloor`, so the + eight features that use the grey level's value differ from those tools (see + :ref:`ngldm-possible-issues`). Pixels outside the mask are left out in every mode. -Let -:math:`N_v` be the number of pixels in the ROI and -:math:`N_s = \sum_{i=1}^{N_g}\sum_{j=1}^{N_n} s_{ij}` the number of -neighbourhoods. - -The following marginal totals can be defined. Let -:math:`s_{i.}=\sum_{j=1}^{N_r}` be the number of neighbourhood pixels having grey level :math:`i`, and let -:math:`s_{j.}=\sum_{i=1}^{N_g}s_{ij}` be the number of neighbourhood pixels having dependence :math:`j`, regardless of grey level. -Note - that Nyxus presets the coarseness parameter :math:`\alpha=0` - and the neighbourhood radius :math:`\delta=1`. - -The following features are then defined: +How the matrix is built +----------------------- + +Nyxus visits every ROI pixel. It counts the pixel's 8 neighbours that lie inside the ROI and have +exactly the same grey level, and adds 1 for the pixel itself: that is the pixel's dependence count +:math:`j`, from 1 to 9. A pixel with grey level :math:`g_i` and dependence count :math:`j` adds 1 to +:math:`\textbf{S}(i,j)`. + +- **Neighbourhood:** the 8 pixels at Chebyshev distance 1. Two pixels are dependent only when their + grey levels are equal (IBSI's coarseness parameter :math:`\alpha = 0`). Neither the distance nor + :math:`\alpha` can be changed. +- **Directions:** none. One matrix is built per ROI, and each feature has one value. +- **Grey levels:** NGLDM converts intensities with its own rule, not with the binning that GLCM, + GLRLM and GLSZM share. With ``--coarseGrayDepth`` :math:`N` (64 by default), intensity :math:`x` + becomes grey level :math:`\lfloor N x / x_{\max} \rfloor`, where :math:`x_{\max}` is the ROI's + largest intensity. Levels run from 0 to :math:`N`: the ROI maximum gets level :math:`N`, and every + intensity below :math:`x_{\max}/N`, including 0, gets level 0. In IBSI mode the intensities are not + converted: each integer intensity is a grey level. Nyxus computes :math:`(x / x_{\max})\,N` in + floating point, so when :math:`N` is not a power of two an intensity whose :math:`N x / x_{\max}` + is a whole number can get the level below. +- **Rows:** :math:`\textbf{S}` has one row per grey level present in the ROI, in increasing order, in + both modes. Levels with no pixel have no row. +- **Background:** pixels outside the mask are neither centres nor neighbours, in every mode. ROI + pixels of intensity 0 are centres with grey level 0 (see :ref:`ngldm-possible-issues`). + + +Worked example +^^^^^^^^^^^^^^ + +Take a 5 × 5 ROI that already has five grey levels: + +.. math:: + + \textbf{G} = \begin{bmatrix} + 5 & 2 & 5 & 4 & 4\\ + 3 & 3 & 3 & 1 & 3\\ + 2 & 1 & 1 & 1 & 3\\ + 4 & 2 & 2 & 2 & 3\\ + 3 & 5 & 3 & 3 & 2 \end{bmatrix} + +In IBSI mode the matrix is below; rows are grey levels 1 to 5 and columns are dependence counts 1 +to 4. The 1 in row 2 has two neighbouring 1s, in row 3, so its dependence count is 3. None of the +three 5s touches another 5, so all three have dependence count 1. + +.. math:: + + \textbf{S} = \begin{bmatrix} + 0 & 1 & 2 & 1\\ + 1 & 2 & 3 & 0\\ + 1 & 4 & 4 & 0\\ + 1 & 2 & 0 & 0\\ + 3 & 0 & 0 & 0 \end{bmatrix}, + \qquad N_s = \sum_{i,j} \textbf{S}(i,j) = 25 + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for this +ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/ngldm_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features="*ALL_NGLDM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_g` + - Number of rows of :math:`\textbf{S}`: the number of grey levels present in the ROI. + * - :math:`g_i` + - Grey level of row :math:`i`: the :math:`i`-th smallest level present. In IBSI mode it is the + intensity; otherwise it is :math:`\lfloor N x / x_{\max} \rfloor`. + * - :math:`j` + - Dependence count: 1 plus the number of same-level neighbours inside the ROI, from 1 to 9. + * - :math:`N_r` + - Largest dependence count in the ROI. + * - :math:`\textbf{S}(i,j)` + - Number of ROI pixels with grey level :math:`g_i` and dependence count :math:`j`. + * - :math:`N_s` + - Number of neighbourhoods, :math:`N_s = \sum_{i,j}\textbf{S}(i,j)`. Every ROI pixel is one, so + :math:`N_s` is the number of ROI pixels. + * - :math:`p_{ij}` + - Joint probability, :math:`p_{ij} = \textbf{S}(i,j) / N_s`. + * - :math:`s_{i\cdot},\; s_{\cdot j}` + - Pixels per grey level, :math:`s_{i\cdot} = \sum_j \textbf{S}(i,j)`, and per dependence count, + :math:`s_{\cdot j} = \sum_i \textbf{S}(i,j)`. + * - :math:`\mu_g,\; \mu_j` + - Grey level mean and dependence count mean, :math:`\mu_g = \sum_{i,j} g_i\,p_{ij}` and + :math:`\mu_j = \sum_{i,j} j\,p_{ij}`. + +Sums over :math:`i` run from 1 to :math:`N_g` and over :math:`j` from 1 to :math:`N_r`. Every +feature uses the grey level :math:`g_i`, never the row position. The three features that divide by +:math:`g_i^2` skip the row of grey level 0, if there is one, but its pixels stay in :math:`N_s`. + +Dependence count entropy uses an exact base-2 logarithm. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - ``--coarseGrayDepth=64`` + - ``Nyxus(..., coarse_gray_depth=64)`` + - :math:`N` in :math:`\lfloor N x / x_{\max} \rfloor`, giving levels 0 to :math:`N`. A negative + value does not select PyRadiomics-style binning for NGLDM (see + :ref:`ngldm-possible-issues`). + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - No conversion: each integer intensity is a grey level. + +The neighbourhood is fixed at the 8 neighbours with :math:`\alpha = 0`. The 2D family has no +direction, distance or metaparameter settings. All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``NGLDM_LDE``, ``NGLDM_DCENT`` and so on. +There are no per-direction columns and no ``_AVE`` columns. + + +.. _ngldm-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. + +.. include:: ngldm_index.inc + + +Features +-------- + +.. _feature-ngldm-dcene: + +Dependence count energy +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_DCENE`` +:Range: :math:`(0, 1]`, dimensionless + +The sum of the squared joint probabilities. High values mean a few combinations of grey level and +dependence count dominate. + +.. math:: + + \mathrm{DCENE} = \sum_{i}\sum_{j} p_{ij}^{\,2} + + +.. _feature-ngldm-dcent: + +Dependence count entropy +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_DCENT`` +:Range: :math:`[0, \log_2(9 N_g)]` bits + +Measures the randomness of the joint distribution of grey level and dependence count. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_LDE}} {\textup{Low dependence emphasis}} = \frac{1}{N_s} \sum_{j=1}^{N_r} \frac{s_{.j}}{j^2} + + \mathrm{DCENT} = -\sum_{i}\sum_{j} p_{ij}\,\log_2 p_{ij} + +Cells with :math:`p_{ij} = 0` add nothing. + +**Relations.** In IBSI mode ``GLDM_DE`` is computed on the same matrix, but with an approximate +logarithm: on the worked example it is 3.380, against 3.383 here. + + +.. _feature-ngldm-dcm: + +Dependence count mean +^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_DCM`` +:Range: :math:`[1, 9]`, pixels + +The mean dependence count over all ROI pixels. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_HDE}} {\textup{High dependence emphasis}} = \frac{1}{N_s} \sum_{j=1}^{N_r} j^2 s_{.j} + + \mathrm{DCM} = \mu_j = \sum_{i}\sum_{j} j\,p_{ij} + +**Possible issues.** IBSI defines no such feature; :math:`\mu_j` appears there only inside +dependence count variance. + + +.. _feature-ngldm-dcnu: + +Dependence count non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_DCNU`` +:Range: :math:`[N_s/N_r, N_s]`, pixels + +Measures how evenly the pixels are spread over the dependence counts. Low values mean the counts +are spread evenly. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_LGLCE}} {\textup{Low grey level count emphasis}}= \frac{1}{N_s} \sum_{i=1}^{N_g} \frac{s_{i.}}{i^2} + + \mathrm{DCNU} = \frac{1}{N_s}\sum_{j} s_{\cdot j}^{\,2} + + +.. _feature-ngldm-dcnun: + +Dependence count non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_DCNUN`` +:Range: :math:`[1/N_r, 1]`, dimensionless + +Dependence count non-uniformity divided by the number of pixels once more, so values are comparable +across ROIs of different size. + +.. math:: + + \mathrm{DCNUN} = \frac{1}{N_s^{\,2}}\sum_{j} s_{\cdot j}^{\,2} + + +.. _feature-ngldm-dcp: + +Dependence count percentage +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_DCP`` +:Range: 1, dimensionless + +The fraction of ROI pixels that are neighbourhood centres. Every ROI pixel is one, so the value is +always 1. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_HGLCE}} {\textup{ High grey level count emphasis }} = \frac{1}{N_s} \sum_{i=1}^{N_g} i^2 s_{i.} + + \mathrm{DCP} = \frac{N_s}{N_s} = 1 + +**Possible issues.** The value carries no information about the ROI. IBSI defines it as the +number of neighbourhoods over the number of voxels, which is also 1 when every voxel counts. + + +.. _feature-ngldm-dcv: + +Dependence count variance +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_DCV`` +:Range: :math:`[0, 16]`, squared pixels + +The variance of the dependence count over all ROI pixels. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_LDLGLE }} {\textup{ Low dependence low grey level emphasis }} = \frac{1}{N_s} \sum_{i=1}^{N_g} \sum_{j=1}^{N_r} \frac{s_{ij}}{i^2 j^2} + + \mathrm{DCV} = \sum_{i}\sum_{j} (j-\mu_j)^2\, p_{ij} + + +.. _feature-ngldm-glm: + +Grey level mean +^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_GLM`` +:Range: :math:`[g_1, g_{N_g}]`, grey levels + +The mean grey level over all ROI pixels. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_LDHGLE }} {\textup{ Low dependence high grey level emphasis }} = \frac{1}{N_s} \sum_{i=1}^{N_g} \sum_{j=1}^{N_r} \frac{i^2 s_{ij}}{j^2} + + \mathrm{GLM} = \mu_g = \sum_{i}\sum_{j} g_i\,p_{ij} + +**Possible issues.** IBSI defines no such feature. With the default settings it is in units of +:math:`x_{\max}/64`: 35.4 on the worked example, against 2.8 in IBSI mode. + + +.. _feature-ngldm-glnu: + +Grey level non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_GLNU`` +:Range: :math:`[N_s/N_g, N_s]`, pixels + +Measures how evenly the pixels are spread over the grey levels. Low values mean the grey levels are +evenly represented. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_HDLGLE }} {\textup{ High dependence low grey level emphasis }} = \frac{1}{N_s} \sum_{i=1}^{N_g} \sum_{j=1}^{N_r} \frac{j^2 s_{ij}}{i^2} + + \mathrm{GLNU} = \frac{1}{N_s}\sum_{i} s_{i\cdot}^{\,2} + + +.. _feature-ngldm-glnun: + +Grey level non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_GLNUN`` +:Range: :math:`[1/N_g, 1]`, dimensionless + +Grey level non-uniformity divided by the number of pixels once more, so values are comparable +across ROIs of different size. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_HDHGLE }} {\textup{ High dependence high grey level emphasis }} = \frac{1}{N_s} \sum_{i=1}^{N_g} \sum_{j=1}^{N_r} i^2 j^2 s_{ij} + + \mathrm{GLNUN} = \frac{1}{N_s^{\,2}}\sum_{i} s_{i\cdot}^{\,2} + + +.. _feature-ngldm-glv: + +Grey level variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_GLV`` +:Range: :math:`[0, \infty)`, squared grey levels + +The variance of the grey level over all ROI pixels. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_GLNU }} {\textup{ Grey level non-uniformity }} = \frac{1}{N_s} \sum_{i=1}^{N_g} s_{i.}^2 + + \mathrm{GLV} = \sum_{i}\sum_{j} (g_i-\mu_g)^2\, p_{ij} + +**Possible issues.** Scales with the square of the grey-level spacing: 1.44 in IBSI mode and 243.36 +with the default settings on the worked example. + + +.. _feature-ngldm-hde: + +High dependence emphasis +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_HDE`` +:Range: :math:`[1, 81]`, squared pixels + +The mean over all ROI pixels of the squared dependence count. High values mean pixels have many +same-level neighbours, as in a coarse texture. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_GLNUN }} {\textup{ Normalised grey level non-uniformity }} = \frac{1}{N_s^2} \sum_{i=1}^{N_g} s_{i.}^2 + + \mathrm{HDE} = \frac{1}{N_s}\sum_{j} j^2\, s_{\cdot j} + + +.. _feature-ngldm-hdhgle: + +High dependence high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_HDHGLE`` +:Range: :math:`[0, \infty)`, squared grey levels times squared pixels + +The mean over all ROI pixels of the squared dependence count times the squared grey level. High +values mean bright pixels with many same-level neighbours dominate. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_DCNU }} {\textup{ Dependence count non-uniformity }} = \frac{1}{N_s} \sum_{j=1}^{N_r} s_{.j}^2 + + \mathrm{HDHGLE} = \frac{1}{N_s}\sum_{i}\sum_{j} g_i^2\, j^2\,\textbf{S}(i,j) + +**Possible issues.** Depends on the grey-level conversion: 35.12 in IBSI mode and 5608.0 with the +default settings on the worked example. + + +.. _feature-ngldm-hdlgle: + +High dependence low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_HDLGLE`` +:Range: :math:`[0, 81]` when :math:`g_i \ge 1`, squared pixels over squared grey levels + +The mean over all ROI pixels of the squared dependence count over the squared grey level. High +values mean dark pixels with many same-level neighbours dominate. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_DCNUN }} {\textup{ Normalised dependence count non-uniformity }} = \frac{1}{N_s^2} \sum_{i=1}^{N_r} s_{.j}^2 + + \mathrm{HDLGLE} = \frac{1}{N_s}\sum_{i:\,g_i \neq 0}\;\sum_{j} \frac{j^2\,\textbf{S}(i,j)}{g_i^2} + +**Possible issues.** Pixels of grey level 0 add nothing to the sum but count in :math:`N_s` (see +:ref:`ngldm-possible-issues`). + + +.. _feature-ngldm-hglce: + +High grey level count emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_HGLCE`` +:Range: :math:`[0, \infty)`, squared grey levels + +The mean over all ROI pixels of the squared grey level. High values mean bright pixels dominate. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_DCP }} {\textup{ Dependence count percentage }} = \frac{N_s}{N_v} + + \mathrm{HGLCE} = \frac{1}{N_s}\sum_{i} g_i^2\, s_{i\cdot} + +**Possible issues.** Depends on the grey-level conversion: 9.28 in IBSI mode and 1496.5 with the +default settings on the worked example. + + +.. _feature-ngldm-lde: + +Low dependence emphasis +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_LDE`` +:Range: :math:`[1/81, 1]`, inverse squared pixels + +The mean over all ROI pixels of one over the squared dependence count. High values mean pixels have +few same-level neighbours, as in a fine texture. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_GLV }} {\textup{ Grey level variance }} = \sum_{i=1}^{N_g} \sum_{j=1}^{N_r} (i-\mu)^2 p_{ij} -where the mean intensity is defined as :math:`\mu = \sum_{i=1}^{N_g} \sum_{j=1}^{N_r} i\,p_{ij}`. + \mathrm{LDE} = \frac{1}{N_s}\sum_{j} \frac{s_{\cdot j}}{j^2} + +**Relations.** In IBSI mode ``GLDM_SDE`` is computed on the same matrix and returns the same value. + + +.. _feature-ngldm-ldhgle: + +Low dependence high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_LDHGLE`` +:Range: :math:`[0, \infty)`, squared grey levels over squared pixels + +The mean over all ROI pixels of the squared grey level over the squared dependence count. High +values mean bright pixels with few same-level neighbours dominate. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_DCV }} {\textup{ Dependence count variance }} = \sum_{i=1}^{N_g} \sum_{j=1}^{N_r} (j-\mu)^2 p_{ij} -where the mean dependence count is defined as :math:`\mu = \sum_{i=1}^{N_g} \sum_{j=1}^{N_r} j\,p_{ij}`. + \mathrm{LDHGLE} = \frac{1}{N_s}\sum_{i}\sum_{j} \frac{g_i^2\,\textbf{S}(i,j)}{j^2} + + +.. _feature-ngldm-ldlgle: + +Low dependence low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_LDLGLE`` +:Range: :math:`[0, 1]`, inverse squared grey levels times inverse squared pixels + +The mean over all ROI pixels of one over the squared dependence count times the squared grey level. +High values mean dark pixels with few same-level neighbours dominate. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_DCE }} {\textup{ Dependence count entropy }} = - \sum_{i=1}^{N_g} \sum_{j=1}^{N_r} p_{ij} \log_2 p_{ij} + + \mathrm{LDLGLE} = \frac{1}{N_s}\sum_{i:\,g_i \neq 0}\;\sum_{j} \frac{\textbf{S}(i,j)}{g_i^2\, j^2} + +**Possible issues.** Pixels of grey level 0 add nothing to the sum but count in :math:`N_s` (see +:ref:`ngldm-possible-issues`). + + +.. _feature-ngldm-lglce: + +Low grey level count emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``NGLDM_LGLCE`` +:Range: :math:`[0, 1]`, inverse squared grey levels + +The mean over all ROI pixels of one over the squared grey level. High values mean dark pixels +dominate. .. math:: - \underset{\mathrm{Nyxus \, code: \, NGLDM\_DCENE }} {\textup{ Dependence count energy }} = \sum_{i=1}^{N_g} \sum_{j=1}^{N_r} p_{ij}^2 + + \mathrm{LGLCE} = \frac{1}{N_s}\sum_{i:\,g_i \neq 0} \frac{s_{i\cdot}}{g_i^2} + +**Possible issues.** Depends on the grey-level conversion: 0.2723 in IBSI mode and 0.00182 with the +default settings on the worked example. Pixels of grey level 0 add nothing to the sum but count in +:math:`N_s` (see :ref:`ngldm-possible-issues`). + + +.. _ngldm-possible-issues: + +Possible issues +--------------- + +Grey level 0 is counted but not weighted + ``LGLCE``, ``LDLGLE`` and ``HDLGLE`` divide by :math:`g_i^2`, so they skip the row of grey level + 0. Its pixels still count in :math:`N_s`, which lowers these three features instead of leaving + them as if those pixels were absent. Grey level 0 comes from ROI pixels of intensity 0, in every + mode, and with the default conversion from every intensity below :math:`x_{\max}/N`. On the + worked example with its centre pixel set to 0, IBSI mode gives ``LGLCE`` 0.2323; leaving that + pixel out of :math:`N_s` too would give 0.2420. With intensities 20 to 100 and one pixel of + intensity 1, which the default conversion maps to level 0, ``LGLCE`` is 0.00181 instead of + 0.00189. The other features treat level 0 as an ordinary grey level. + +NGLDM converts grey levels differently from the other texture families + NGLDM's :math:`\lfloor N x / x_{\max} \rfloor` gives :math:`N + 1` possible levels, 0 to + :math:`N`. GLCM, GLRLM and GLSZM use :math:`\lfloor N x / x_{\max} \rfloor + 1`, capped at + :math:`N`. With :math:`N = 64`, the two formulas give the worked example's intensities 1 to 5 + the levels 12, 25, 38, 51 and 64 for NGLDM and 13, 26, 39, 52 and 64 for GLRLM. Both count from + intensity 0, not from the ROI minimum. The eight features that use + :math:`g_i` (``GLM``, ``GLV``, ``LGLCE``, ``HGLCE`` and the four combined emphases) therefore + depend on ``--coarseGrayDepth``; compare them only between runs with the same setting. The + features that use only counts (``LDE``, ``HDE``, ``GLNU``, ``GLNUN``, ``DCNU``, ``DCNUN``, + ``DCM``, ``DCV``, ``DCENT``, ``DCENE``) change only when the conversion merges or splits levels. + +Negative or zero ``--coarseGrayDepth`` + A negative value does not select PyRadiomics-style binning for NGLDM. It is read as the unsigned + number :math:`2^{32} - |N|`, so no two intensities share a level and the grey levels are of the + order of :math:`10^9`. On the worked example ``--coarseGrayDepth=-64`` gives ``GLM`` + :math:`2.405 \times 10^9` and ``HGLCE`` :math:`6.847 \times 10^{18}`, while the count-only + features are unchanged. ``--coarseGrayDepth=0`` puts every pixel at level 0, so all 25 pixels of + the worked example share one level and the eight grey-level features return 0. The ``Nyxus`` + constructor refuses both values. + +Constant ROI + When all of a ROI's pixels have one intensity, every NGLDM feature returns the ``--noval`` + substitute (0 by default). Out of core, they return 0 whatever ``--noval`` is. + +The ROI is defined by the mask, not the bounding box + Pixels outside the mask are neither centres nor neighbours, whatever their intensity. Leaving + three pixels of the worked example out of the mask gives ``LDE`` 0.3273 and ``HDE`` 5.818 in + both modes. + +No IBSI counterpart for two features + ``GLM`` and ``DCM`` are not IBSI features. They are the means :math:`\mu_g` and :math:`\mu_j` + that IBSI uses inside grey level variance and dependence count variance. + + +Validation +---------- + +The test suite checks 17 of the 19 2D NGLDM features against MIRP on the IBSI digital phantom, in +IBSI mode, with neighbourhood distance 1 and :math:`\alpha = 0`. Nyxus matches MIRP to a relative +3 × 10\ :sup:`-16`, asserted at 10\ :sup:`-9`. The four-slice means of the same 17 features are +also compared with IBSI's published consensus values at a relative 10\ :sup:`-2`, the precision +those values are published to; that comparison is a consistency check, not one of the validations +in the index. Grey level mean and dependence count mean have no +IBSI or MIRP counterpart and are covered by regression tests only, as is the default grey-level +conversion. The validation of each feature is named in the :ref:`index `. References ---------- -Chengjun Sun; William G Wee (1983). “Neighboring gray level dependence matrix for texture classification”. -Computer Vision, Graphics, and Image Processing, Volume 23, Issue 3, 1983, Pages 341-352, ISSN 0734-189X. +1. Sun C, Wee WG. *Neighboring gray level dependence matrix for texture classification.* Computer + Vision, Graphics, and Image Processing. 1983;23(3):341–352. +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io +3. MIRP, the medical image radiomics processor: https://github.com/oncoray/mirp diff --git a/docs/source/Math/f_ngldm_3d.rst b/docs/source/Math/f_ngldm_3d.rst new file mode 100644 index 000000000..6cb4c5418 --- /dev/null +++ b/docs/source/Math/f_ngldm_3d.rst @@ -0,0 +1,527 @@ +Neighbouring grey-level dependence matrix, 3D (3D NGLDM) +======================================================== + +The 3D NGLDM features describe how many of each voxel's neighbours share its grey level. Every ROI +voxel is the centre of a 3 × 3 × 3 neighbourhood, and the matrix counts the voxels by grey level and +by dependence count, regardless of direction. The features are those of the 2D family, computed on +the 3D matrix; their formulas are on the :doc:`2D NGLDM page `. This page covers what is +specific to 3D. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``3NGLDM_*``: 19 features, one value per ROI + * - **Group** + - ``*3D_NGLDM*``; also part of ``*3D_ALL_TEXTURE*`` and ``*3D_ALL*`` + * - **Dimensions** + - 3D. The 2D family, ``NGLDM_*``, is on :doc:`its own page `. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory. When one read of the file returns the whole volume + (NIfTI, or OME-Zarr chunked over all of Z), an oversized ROI stops the run with an error + (see :doc:`/large_images`). + * - **IBSI** + - 17 of the 19 features have an IBSI identifier. Grey level mean and dependence count mean + have none. + +.. tip:: + + To reproduce IBSI or MIRP values, run in IBSI mode: ``--ibsi=true`` on the command line, + ``ibsi=True`` in Python. With the default settings a voxel's grey level is + :math:`\lfloor 64\,x / x_{\max} \rfloor`, so the eight features that use the grey level's value + differ from those tools (see :ref:`ngldm-3d-possible-issues`). Voxels outside the mask are left + out in every mode. + + +How the matrix is built +----------------------- + +Nyxus visits every ROI voxel. It counts the voxel's 26 neighbours that lie inside the ROI and have +exactly the same grey level, and adds 1 for the voxel itself: that is the voxel's dependence count +:math:`j`, from 1 to 27. A voxel with grey level :math:`g_i` and dependence count :math:`j` adds 1 +to :math:`\textbf{S}(i,j)`. + +- **Neighbourhood:** the 26 voxels at Chebyshev distance 1: the 8 in the voxel's own slice and the 9 + in each of the slices above and below. A neighbour that shares a face, an edge or a corner counts + the same. The distance cannot be changed. +- **Coarseness parameter:** two voxels are dependent only when their grey levels are equal: IBSI's + :math:`\alpha = 0`. :math:`\alpha` cannot be changed. +- **Directions:** none. One matrix is built per ROI, and each feature has one value. +- **Grey levels:** the same rule as the 2D family, not the MATLAB-style or PyRadiomics-style binning + of the other texture families. With ``--coarseGrayDepth`` :math:`N` (64 by default), intensity :math:`x` becomes + grey level :math:`\lfloor N x / x_{\max} \rfloor`, where :math:`x_{\max}` is the ROI's largest + intensity. Levels run from 0 to :math:`N`: the ROI maximum gets level :math:`N`, and every + intensity below :math:`x_{\max}/N`, including 0, gets level 0. In IBSI mode the intensities are + not converted: each integer intensity is a grey level. The 3D family has no ``greydepth`` + metaparameter of its own. +- **Rows:** :math:`\textbf{S}` has one row per grey level present in the ROI, in increasing order, + in both modes. Levels with no voxel have no row, so the matrix has at most :math:`N + 1` rows with + the default conversion. Columns run from dependence count 1 to the largest one in the ROI, at most + 27. +- **Out-of-ROI neighbours:** voxels outside the mask are neither centres nor neighbours, in every + mode, whatever their intensity. A neighbour outside the volume counts the same way. ROI voxels of + intensity 0 are centres with grey level 0 (see :ref:`ngldm-3d-possible-issues`). + + +Worked example +^^^^^^^^^^^^^^ + +Take a 3 × 3 × 3 ROI that already has four grey levels, shown slice by slice: + +.. math:: + + \textbf{G}_{z=1} = \begin{bmatrix} + 1 & 1 & 2\\ + 1 & 2 & 2\\ + 4 & 3 & 3 \end{bmatrix}, + \qquad + \textbf{G}_{z=2} = \begin{bmatrix} + 1 & 1 & 2\\ + 3 & 2 & 2\\ + 4 & 4 & 3 \end{bmatrix}, + \qquad + \textbf{G}_{z=3} = \begin{bmatrix} + 1 & 2 & 2\\ + 3 & 3 & 4\\ + 4 & 4 & 1 \end{bmatrix} + +In IBSI mode the matrix is below; rows are grey levels 1 to 4 and columns are dependence counts 1 +to 8. The 1 in the last corner of slice 3 touches no other 1, so its dependence count is 1. The 1 in +the first corner of slice 1 has four 1s around it in its own slice and the slice below, so its +dependence count is 5. + +.. math:: + + \textbf{S} = \begin{bmatrix} + 1 & 0 & 1 & 0 & 3 & 2 & 0 & 0\\ + 0 & 0 & 0 & 0 & 2 & 3 & 0 & 3\\ + 0 & 0 & 2 & 4 & 0 & 0 & 0 & 0\\ + 0 & 0 & 2 & 1 & 2 & 1 & 0 & 0 \end{bmatrix}, + \qquad N_s = \sum_{i,j} \textbf{S}(i,j) = 27 + +With the default settings the grey levels are 16, 32, 48 and 64, and :math:`\textbf{S}` is the +same. The *Example* value of each feature in the :ref:`index ` is what Nyxus +returns in IBSI mode for this ROI. The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/ngldm_3d_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --dim=3 --features="*3D_NGLDM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +The symbols are those of the :doc:`2D page `, with voxels in place of pixels: + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_g` + - Number of rows of :math:`\textbf{S}`: the number of grey levels present in the ROI. + * - :math:`g_i` + - Grey level of row :math:`i`: the :math:`i`-th smallest level present. In IBSI mode it is the + intensity; otherwise it is :math:`\lfloor N x / x_{\max} \rfloor`. + * - :math:`j` + - Dependence count: 1 plus the number of same-level neighbours inside the ROI, from 1 to 27. + * - :math:`N_r` + - Largest dependence count in the ROI. + * - :math:`\textbf{S}(i,j)` + - Number of ROI voxels with grey level :math:`g_i` and dependence count :math:`j`. + * - :math:`N_s` + - Number of neighbourhoods, :math:`N_s = \sum_{i,j}\textbf{S}(i,j)`. Every ROI voxel is one, so + :math:`N_s` is the number of ROI voxels. + * - :math:`p_{ij}` + - Joint probability, :math:`p_{ij} = \textbf{S}(i,j) / N_s`. + +As in 2D, every feature uses the grey level :math:`g_i`, never the row position. The three features +that divide by :math:`g_i^2` skip the row of grey level 0, if there is one, but its voxels stay in +:math:`N_s`. Dependence count entropy uses an exact base-2 logarithm. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - ``--coarseGrayDepth=64`` + - ``Nyxus3D(..., coarse_gray_depth=64)`` + - :math:`N` in :math:`\lfloor N x / x_{\max} \rfloor`, giving levels 0 to :math:`N`. A negative + value does not select PyRadiomics-style binning (see :ref:`ngldm-3d-possible-issues`). + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus3D(..., ibsi=True)`` + - No conversion: each integer intensity is a grey level. + +The neighbourhood is fixed at the 26 neighbours with :math:`\alpha = 0`. The family reads the global +``--coarseGrayDepth``, not a metaparameter: ``set_metaparam("3ngldm/greydepth=N")`` is refused with +an unrecognized-feature error, and the ``greydepth`` metaparameters of the other 3D texture families +(``3gldm/greydepth`` and so on) do not change this matrix. All options are listed in the +:ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``3NGLDM_LDE``, ``3NGLDM_DCENT`` and so on. +There are no per-direction columns and no ``_AVE`` columns. + + +.. _ngldm-3d-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example, in IBSI mode. + +.. include:: ngldm_3d_index.inc + + +Features +-------- + +Each 3D feature is the 2D formula of the same name, evaluated on the 3D matrix. Only the ranges +change, because the dependence count runs to 27 instead of 9. + +.. _feature-3ngldm-dcene: + +Dependence count energy +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_DCENE`` +:Range: :math:`(0, 1]`, dimensionless + +The sum of the squared joint probabilities. Formula: :ref:`NGLDM_DCENE `. + + +.. _feature-3ngldm-dcent: + +Dependence count entropy +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_DCENT`` +:Range: :math:`[0, \log_2(27 N_g)]` bits + +The base-2 entropy of the joint distribution of grey level and dependence count. Formula: +:ref:`NGLDM_DCENT `. + +**Relations.** In IBSI mode ``3GLDM_DE`` is computed on a matrix that, on the worked example, is +the same as this one, but with an approximate logarithm: 3.557, against 3.560 here. + + +.. _feature-3ngldm-dcm: + +Dependence count mean +^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_DCM`` +:Range: :math:`[1, 27]`, voxels + +The mean dependence count over all ROI voxels. Formula: :ref:`NGLDM_DCM `. IBSI +defines no such feature. + + +.. _feature-3ngldm-dcnu: + +Dependence count non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_DCNU`` +:Range: :math:`[N_s/N_r, N_s]`, voxels + +Measures how evenly the voxels are spread over the dependence counts. Formula: +:ref:`NGLDM_DCNU `. + + +.. _feature-3ngldm-dcnun: + +Dependence count non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_DCNUN`` +:Range: :math:`[1/N_r, 1]`, dimensionless + +Dependence count non-uniformity divided by the number of voxels once more. Formula: +:ref:`NGLDM_DCNUN `. + + +.. _feature-3ngldm-dcp: + +Dependence count percentage +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_DCP`` +:Range: 1, dimensionless + +The fraction of ROI voxels that are neighbourhood centres. Every ROI voxel is one, so the value is +always 1, and it carries no information about the ROI. Formula: +:ref:`NGLDM_DCP `. + + +.. _feature-3ngldm-dcv: + +Dependence count variance +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_DCV`` +:Range: :math:`[0, 169]`, squared voxels + +The variance of the dependence count over all ROI voxels. Formula: +:ref:`NGLDM_DCV `. + + +.. _feature-3ngldm-glm: + +Grey level mean +^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_GLM`` +:Range: :math:`[g_1, g_{N_g}]`, grey levels + +The mean grey level over all ROI voxels. Formula: :ref:`NGLDM_GLM `. IBSI +defines no such feature. With the default settings it is 38.52 on the worked example, against 2.407 +in IBSI mode. + + +.. _feature-3ngldm-glnu: + +Grey level non-uniformity +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_GLNU`` +:Range: :math:`[N_s/N_g, N_s]`, voxels + +Measures how evenly the voxels are spread over the grey levels. Formula: +:ref:`NGLDM_GLNU `. + + +.. _feature-3ngldm-glnun: + +Grey level non-uniformity normalized +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_GLNUN`` +:Range: :math:`[1/N_g, 1]`, dimensionless + +Grey level non-uniformity divided by the number of voxels once more. Formula: +:ref:`NGLDM_GLNUN `. + + +.. _feature-3ngldm-glv: + +Grey level variance +^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_GLV`` +:Range: :math:`[0, \infty)`, squared grey levels + +The variance of the grey level over all ROI voxels. Formula: :ref:`NGLDM_GLV `. +It scales with the square of the grey-level spacing: 1.204 in IBSI mode and 308.3 with the default +settings on the worked example. + + +.. _feature-3ngldm-hde: + +High dependence emphasis +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_HDE`` +:Range: :math:`[1, 729]`, squared voxels + +The mean over all ROI voxels of the squared dependence count. Formula: +:ref:`NGLDM_HDE `. + + +.. _feature-3ngldm-hdhgle: + +High dependence high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_HDHGLE`` +:Range: :math:`[0, \infty)`, squared grey levels times squared voxels + +The mean over all ROI voxels of the squared dependence count times the squared grey level. Formula: +:ref:`NGLDM_HDHGLE `. + + +.. _feature-3ngldm-hdlgle: + +High dependence low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_HDLGLE`` +:Range: :math:`[0, 729]` when :math:`g_i \ge 1`, squared voxels over squared grey levels + +The mean over all ROI voxels of the squared dependence count over the squared grey level. Formula: +:ref:`NGLDM_HDLGLE `. Voxels of grey level 0 add nothing to the sum but count +in :math:`N_s`. + + +.. _feature-3ngldm-hglce: + +High grey level count emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_HGLCE`` +:Range: :math:`[0, \infty)`, squared grey levels + +The mean over all ROI voxels of the squared grey level. Formula: +:ref:`NGLDM_HGLCE `. It depends on the grey-level conversion: 7 in IBSI mode +and 1792 with the default settings on the worked example. + + +.. _feature-3ngldm-lde: + +Low dependence emphasis +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_LDE`` +:Range: :math:`[1/729, 1]`, inverse squared voxels + +The mean over all ROI voxels of one over the squared dependence count. Formula: +:ref:`NGLDM_LDE `. + +**Relations.** In IBSI mode ``3GLDM_SDE`` returns the same value on the worked example. + + +.. _feature-3ngldm-ldhgle: + +Low dependence high grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_LDHGLE`` +:Range: :math:`[0, \infty)`, squared grey levels over squared voxels + +The mean over all ROI voxels of the squared grey level over the squared dependence count. Formula: +:ref:`NGLDM_LDHGLE `. + + +.. _feature-3ngldm-ldlgle: + +Low dependence low grey level emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_LDLGLE`` +:Range: :math:`[0, 1]`, inverse squared grey levels times inverse squared voxels + +The mean over all ROI voxels of one over the squared dependence count times the squared grey level. +Formula: :ref:`NGLDM_LDLGLE `. Voxels of grey level 0 add nothing to the sum +but count in :math:`N_s`. + + +.. _feature-3ngldm-lglce: + +Low grey level count emphasis +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``3NGLDM_LGLCE`` +:Range: :math:`[0, 1]`, inverse squared grey levels + +The mean over all ROI voxels of one over the squared grey level. Formula: +:ref:`NGLDM_LGLCE `. It depends on the grey-level conversion: 0.3719 in IBSI +mode and 0.001453 with the default settings on the worked example. Voxels of grey level 0 add +nothing to the sum but count in :math:`N_s`. + + +.. _ngldm-3d-possible-issues: + +Possible issues +--------------- + +Grey level 0 is counted but not weighted + ``LGLCE``, ``LDLGLE`` and ``HDLGLE`` divide by :math:`g_i^2`, so they skip the row of grey level + 0. Its voxels still count in :math:`N_s`, which lowers these three features instead of leaving + them as if those voxels were absent. Grey level 0 comes from ROI voxels of intensity 0, in every + mode, and with the default conversion from every intensity below :math:`x_{\max}/N`. On the + worked example with its centre voxel set to 0, IBSI mode gives ``LGLCE`` 0.3627; leaving that + voxel out of :math:`N_s` too would give 0.3766. With the worked example's intensities times 20 + and one voxel of intensity 1, which the default conversion maps to level 0, ``LGLCE`` is + 0.001308. The other features treat level 0 as an ordinary grey level. + +The grey levels differ from the other 3D texture families + The 3D NGLDM converts intensities with the 2D NGLDM rule, :math:`\lfloor N x / x_{\max} + \rfloor`, and the global ``--coarseGrayDepth``. 3D GLDM, GLRLM, GLSZM and NGTDM bin to their own + ``greydepth`` metaparameters instead, 3D GLCM bins with ``--coarseGrayDepth`` unless its + ``3glcm/greydepth`` metaparameter is set, and 3D GLDZM, which also reads ``--coarseGrayDepth``, + bins with the shared rule (MATLAB-style for a positive value, PyRadiomics-style for a negative + one). The same setting therefore gives different grey levels in this family and the others. The + eight features that use :math:`g_i` (``GLM``, ``GLV``, ``LGLCE``, ``HGLCE`` and the four + combined emphases) therefore depend on ``--coarseGrayDepth``; compare them only between runs + with the same setting. The features that use only counts (``LDE``, ``HDE``, ``GLNU``, ``GLNUN``, + ``DCNU``, ``DCNUN``, ``DCM``, ``DCV``, ``DCENT``, ``DCENE``) change only when the conversion + merges or splits levels. On the worked example ``--coarseGrayDepth=8`` gives levels 2, 4, 6 and + 8 and leaves every count-only feature unchanged. + +Negative or zero ``--coarseGrayDepth`` + A negative value does not select PyRadiomics-style binning. As in the 2D family, the conversion + reads it as the unsigned number :math:`2^{32} - |N|`. ``--coarseGrayDepth=0`` puts every voxel + at level 0. The ``Nyxus3D`` constructor raises ``ValueError`` for both; ``set_params`` ignores + 0 and raises ``TypeError`` for a negative value. + +Constant ROI + When all of a ROI's voxels have one intensity, every 3D NGLDM feature returns the ``--noval`` + substitute (0 by default), in memory and out of core. + +The ROI is defined by the mask, not the bounding box + Neighbours outside the mask never count, whatever their intensity. On the worked example placed + inside a 5 × 5 × 5 volume whose other voxels all have intensity 4, every feature keeps its value. + Leaving three voxels of the worked example out of the mask gives ``LDE`` 0.06111 and ``HDE`` + 20.75 in both modes. + +No IBSI counterpart for two features + ``GLM`` and ``DCM`` are not IBSI features. They are the means that IBSI uses inside grey level + variance and dependence count variance. + + +Validation +---------- + +The test suite checks 16 of the 19 3D NGLDM features against MIRP at two settings, asserted at a +relative 10\ :sup:`-3`: + +- IBSI mode, with MIRP given the same raw intensities and no discretisation. Nyxus matches MIRP to + a relative 7.5 × 10\ :sup:`-15`. +- The default conversion with 64 levels, with MIRP given the grey levels Nyxus produces. Nyxus + matches MIRP to a relative 8.7 × 10\ :sup:`-16`. This check covers the matrix and the feature + formulas, not the conversion itself. + +Dependence count percentage is pinned by a regression test only: Nyxus always returns 1, and MIRP +returns 1 on the test volume too, so an agreement could not fail. Grey level mean and dependence count mean have no MIRP +counterpart and are covered by regression tests only. The validation of each feature is named in +the :ref:`index `. + + +References +---------- + +1. Sun C, Wee WG. *Neighboring gray level dependence matrix for texture classification.* Computer + Vision, Graphics, and Image Processing. 1983;23(3):341–352. +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io +3. MIRP, the medical image radiomics processor: https://github.com/oncoray/mirp diff --git a/docs/source/Math/f_ngtdm.rst b/docs/source/Math/f_ngtdm.rst index 588c5620e..8846353e6 100644 --- a/docs/source/Math/f_ngtdm.rst +++ b/docs/source/Math/f_ngtdm.rst @@ -1,117 +1,389 @@ +Neighbourhood grey-tone difference matrix (NGTDM) +================================================= + +NGTDM features compare each pixel with the average of its neighbours. For every grey level the +matrix holds how often the level occurs and how far, in total, its pixels differ from their +neighbourhood averages. The features describe how coarse, busy or contrasted a texture looks. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``NGTDM_*``: 5 features, one value per ROI + * - **Group** + - ``*ALL_NGTDM*``; also part of ``*ALL*`` and ``*WHOLESLIDE*`` + * - **Dimensions** + - 2D. The 3D family, ``3NGTDM_*``, has its own page, :doc:`f_ngtdm_3d`. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory on an image that Nyxus reads as one tile, except in + IBSI mode: there only the first ROI that a ``Nyxus`` object or a command-line run computes + out of core matches, and the later ones are wrong, a known defect (`#512 `__): the list + of grey levels is not cleared between out-of-core ROIs in IBSI mode. On other + images out-of-core processing of this family raises an error or returns wrong + values, depending on the ROI (see :ref:`large-images-tiles`). + * - **IBSI** + - All 5 features have an IBSI identifier. + +.. tip:: + + To reproduce IBSI, PyRadiomics or MIRP values, run in IBSI mode: ``--ibsi=true`` on the command + line, ``ibsi=True`` in Python. With the default settings the pixels of the bounding box that lie + outside the mask become grey level 1, and take part both as pixels and as neighbours, which + changes every feature (see :ref:`ngtdm-possible-issues`). + + +How the matrix is built +----------------------- + +Nyxus visits every pixel of the ROI's bounding box that has a non-zero grey level. Its neighbourhood +is the 8 pixels around it, at a distance of 1. Neighbours outside the bounding box or with grey +level 0 are left out. If at least one neighbour remains, the pixel is *valid*: it adds 1 to +:math:`n_i` for its grey level :math:`g_i`, and adds :math:`|g_i - \bar{A}|` to :math:`s_i`, where +:math:`\bar{A}` is the mean grey level of its remaining neighbours. A pixel with no remaining +neighbour is left out of the matrix entirely. + +- **Neighbourhood:** always the 8-neighbourhood at distance 1, the one IBSI defines for 2D. + ``--pixelDistance`` does not apply to NGTDM. +- **Directions:** none. One matrix is built per ROI, and each feature has one value. +- **Grey levels:** intensities are first binned into grey levels, as set by ``--coarseGrayDepth`` + (64 by default). A pixel of intensity :math:`x` gets level + :math:`\lfloor N\,x / x_{\max} \rfloor + 1`, capped at :math:`N`, where :math:`N` is the number + of levels and :math:`x_{\max}` the ROI's largest intensity. In IBSI mode they are not binned: each + integer intensity is a grey level. +- **Background:** in IBSI mode, and with PyRadiomics-style binning (a negative + ``--coarseGrayDepth``), pixels of intensity 0 are neither counted nor used as neighbours, whether + they lie outside the mask or inside it. With the default binning, intensity 0 becomes grey level 1, + so the bounding-box pixels outside the mask are counted and are averaged into their neighbours' + :math:`\bar{A}`. +- **Probabilities:** :math:`p_i = n_i / N_{v,p}`, where :math:`N_{v,p}` is the number of valid + pixels, not the number of ROI pixels. This is the IBSI definition. + + +Worked example +^^^^^^^^^^^^^^ + +Take a 5 × 5 ROI that already has five grey levels: -Texture features / NGTDM -======================== +.. math:: + + \textbf{G} = \begin{bmatrix} + 5 & 2 & 5 & 4 & 4\\ + 3 & 3 & 3 & 1 & 3\\ + 2 & 1 & 1 & 1 & 3\\ + 4 & 2 & 2 & 2 & 3\\ + 3 & 5 & 3 & 3 & 2 \end{bmatrix} -A Neighbouring Gray Tone Difference Matrix quantifies the difference between a gray value and the average gray value -of its neighbours within distance :math:`\delta`. The sum of absolute differences for gray level :math:`i` is stored in the matrix. -Let :math:`\textbf{X}_{gl}` be a set of segmented voxels and :math:`x_{gl}(j_x,j_y,j_z) \in \textbf{X}_{gl}` be the gray level of a voxel at position -:math:`(j_x,j_y,j_z)`, then the average gray level of the neighborhood is: +Every pixel has at least one neighbour, so all 25 are valid. A corner pixel has 3 neighbours, an +edge pixel 5 and an inner pixel 8. The four pixels of level 1 have neighbourhood averages +:math:`24/8`, :math:`20/8`, :math:`15/8` and :math:`18/8`, so .. math:: - \bar{A}_i &= \bar{A}(j_x, j_y, j_z) \\ - &= \frac{1}{W} \sum_{k_x=-\delta}^{\delta}\sum_{k_y=-\delta}^{\delta} \sum_{k_z=-\delta}^{\delta}{x_{gl}(j_x+k_x, j_y+k_y, j_z+k_z)}, + s_1 = |1 - 3| + |1 - 2.5| + |1 - 1.875| + |1 - 2.25| = 5.625 -where +In IBSI mode (no binning) the matrix is below. Its rows are grey levels 1 to 5 and its columns are +:math:`n_i`, :math:`p_i` and :math:`s_i`. .. math:: - (k_x,k_y,k_z)\neq(0,0,0) -and + \begin{bmatrix} + 4 & 0.16 & 5.625\\ + 6 & 0.24 & 4.1917\\ + 9 & 0.36 & 5.0667\\ + 3 & 0.12 & 3.5333\\ + 3 & 0.12 & 6.9333 \end{bmatrix}, + \qquad N_{v,p} = 25, \qquad \sum_i s_i = 25.35 -.. math:: +The exact values are :math:`s_2 = 503/120`, :math:`s_3 = 76/15`, :math:`s_4 = 53/15` and +:math:`s_5 = 104/15`. The *Example* value of each feature in the :ref:`index ` is what +Nyxus returns for this ROI. The test suite runs this example and checks every one of them. - x_{gl}(j_x+k_x, j_y+k_y, j_z+k_z) \in \textbf{X}_{gl} +.. tab-set:: + .. tab-item:: Python + .. literalinclude:: ../../examples/ngtdm_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end -Here, :math:`W` is the number of voxels in the neighbourhood that are also in :math:`\textbf{X}_{gl}`. + .. tab-item:: Command line -As a two dimensional example, let the following matrix :math:`\textbf{I}` represent a 4x4 image, -having 5 discrete grey levels, but no voxels with gray level 4: + .. code-block:: console -.. math:: - \textbf{I} = \begin{bmatrix} - 1 & 2 & 5 & 2\\ - 3 & 5 & 1 & 3\\ - 1 & 3 & 5 & 5\\ - 3 & 1 & 1 & 1\end{bmatrix} + $ nyxus --features="*ALL_NGTDM*" --ibsi=true \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv -The following NGTDM can be obtained: +Notation +-------- -.. math:: - \begin{array}{cccc} - i & n_i & p_i & s_i\\ - \hline - 1 & 6 & 0.375 & 13.35\\ - 2 & 2 & 0.125 & 2.00\\ - 3 & 4 & 0.25 & 2.63\\ - 4 & 0 & 0.00 & 0.00\\ - 5 & 4 & 0.25 & 10.075\end{array} +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N_g` + - Number of rows of the matrix. With binning, the number of grey levels present in the bounding + box; in IBSI mode, the ROI's largest intensity plus one (row 0 is always empty). + * - :math:`g_i` + - Grey level of row :math:`i`. With binning, the :math:`i`-th smallest level present; in IBSI + mode, the intensity itself. + * - :math:`\bar{A}` + - Mean grey level of a pixel's neighbours that have a non-zero grey level. + * - :math:`n_i` + - Number of valid pixels (at least one neighbour) of grey level :math:`g_i`. + * - :math:`N_{v,p}` + - Number of valid pixels, :math:`N_{v,p} = \sum_i n_i`. + * - :math:`p_i` + - Grey-level probability among valid pixels, :math:`p_i = n_i / N_{v,p}`. + * - :math:`s_i` + - Neighbourhood grey-tone difference, :math:`s_i = \sum |g_i - \bar{A}|` over the valid + pixels of grey level :math:`g_i`; 0 when :math:`n_i = 0`. + * - :math:`N_{g,p}` + - Number of distinct non-zero grey levels in the bounding box, including levels whose pixels + are all invalid (see :ref:`ngtdm-possible-issues`). + +Sums over :math:`i` and :math:`j` run over the rows of the matrix. Rows with :math:`p_i = 0` add +nothing. Every feature uses the grey level :math:`g_i`, never the row position. + + +Settings +-------- +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 -6 pixels have gray level 1, therefore: + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - ``--coarseGrayDepth=64`` + - ``Nyxus(..., coarse_gray_depth=64)`` + - Number of grey levels. Positive: equal-width bins from 0 to the ROI maximum, with background + as level 1; negative: PyRadiomics-style bins over the ROI range (command line only). + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - No binning; pixels of intensity 0 are excluded, as pixels and as neighbours. -:math:`s_1 = |1-10/3| + |1-30/8| + |1-15/5| + |1-13/5| + |1-15/5| + |1-11/3| = 13.35` +The 2D family has no direction, distance or metaparameter settings. ``3ngtdm/greydepth`` and +``3ngtdm/radius`` apply to ``3NGTDM_*`` only. All options are listed in the :ref:`cli-reference`. -For gray level 2, there are 2 pixels, therefore: -:math:`s_2 = |2-15/5| + |2-9/3| = 2` +Output columns +-------------- -Similar for gray values 3 and 5: +Each feature produces one column, named with its code: ``NGTDM_COARSENESS``, ``NGTDM_CONTRAST`` and +so on. There are no per-direction columns and no ``_AVE`` columns. -:math:`s_3 = |3-12/5| + |3-18/5| + |3-20/8| + |3-5/3| = 3.03` -:math:`s_5 = |5-14/5| + |5-18/5| + |5-20/8| + |5-11/5| = 10.075` +.. _ngtdm-index: -Let: +Feature index +------------- -:math:`n_i` be the number of voxels in :math:`X_{gl}` with gray level :math:`i` +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. -:math:`N_{v,p}` be the total number of voxels in :math:`X_{gl}` and equal to :math:`\sum{n_i}` (i.e. the number of voxels -with a valid region; at least 1 neighbor). :math:`N_{v,p} \leq N_p`, where :math:`N_p` is the total number of voxels in the ROI. +.. include:: ngtdm_index.inc -:math:`p_i` be the gray level probability and equal to :math:`n_i/N_v` -:math:`s_i = \sum^{n_i}{|i-\bar{A}_i|}` when :math:`n_i \neq 0` and :math:`s_i = 0` when :math:`n_i = 0`. +Features +-------- + +.. _feature-ngtdm-busyness: + +Busyness +^^^^^^^^ + +:Code: ``NGTDM_BUSYNESS`` +:Range: :math:`[0, \infty)`, dimensionless -be the sum of absolute differences for gray level :math:`i`. +Compares the neighbourhood differences with the differences between probability-weighted grey +levels. High values mean intensity changes quickly from pixel to pixel. -:math:`N_g` be the number of discrete gray levels +.. math:: -:math:`N_{g,p}` be the number of gray levels where :math:`p_i \neq 0` + \mathrm{Busyness} = \frac{\sum_i p_i s_i}{\sum_{i}\sum_{j} \left| g_i p_i - g_j p_j \right|}, + \qquad p_i \neq 0,\; p_j \neq 0 + +The value is 0 when :math:`N_{g,p} = 1`, and also whenever the denominator is 0: when only one grey +level has :math:`p_i > 0`, where IBSI also sets busyness to 0, or when every :math:`g_i p_i` is +equal, where IBSI leaves the value undefined. + + +.. _feature-ngtdm-coarseness: Coarseness ----------- +^^^^^^^^^^ -NGTDM_COARSENESS :math:`= \frac{1}{\sum^{N_g}_{i=1}{p_{i}s_{i}}}` +:Code: ``NGTDM_COARSENESS`` +:Range: :math:`(0, \infty)`, inverse grey levels -Contrast --------- +One over the probability-weighted sum of the neighbourhood differences. High values mean intensity +changes slowly, as in large uniform patches. -Assuming :math:`p_i` and :math:`p_j` are row indices of the NGTDM matrix, +.. math:: -NGTDM_CONTRAST :math:`= \left(\frac{1}{N_{g,p}(N_{g,p}-1)}\sum^{N_g}_{i=1}\sum^{N_g}_{j=1}{p_{i}p_{j}(i-j)^2}\right) \left(\frac{1}{N_{v,p}}\sum^{N_g}_{i=1}{s_i}\right)` -where :math:`p_i \neq 0` and :math:`p_j \neq 0` + \mathrm{Coarseness} = \frac{1}{\sum_i p_i s_i} -Busyness --------- +**Possible issues.** No term guards the denominator. When every valid pixel equals its neighbourhood +average, as in a constant ROI, the division gives infinity and the output is the ``--noval`` +substitute, 0 by default (``-0.0`` in Arrow and Parquet files; see :ref:`conventions-missing`). +IBSI defines the value as :math:`10^6` there, so the smoothest ROIs report +the lowest coarseness (see :ref:`ngtdm-possible-issues`). -NGTDM_BUSYNESS :math:`= \frac{\sum^{N_g}_{i = 1}{p_{i}s_{i}}}{\sum^{N_g}_{i = 1}\sum^{N_g}_{j = 1}{|ip_i - jp_j|}}` where :math:`p_i \neq 0`, :math:`p_j \neq 0` + +.. _feature-ngtdm-complexity: Complexity ----------- +^^^^^^^^^^ + +:Code: ``NGTDM_COMPLEXITY`` +:Range: :math:`[0, \infty)`, squared grey levels -NGTDM_COMPLEXITY :math:`= \frac{1}{N_{v,p}}\sum^{N_g}_{i = 1}\sum^{N_g}_{j = 1}{|i - j| \frac{p_{i}s_{i} + p_{j}s_{j}}{p_i + p_j}}` -where :math:`p_i \neq 0, p_j \neq 0` +Grey-level differences between pairs of levels, weighted by their neighbourhood differences. High +values mean many abrupt intensity changes. + +.. math:: + + \mathrm{Complexity} = \frac{1}{N_{v,p}}\sum_{i}\sum_{j} \left| g_i - g_j \right| + \frac{p_i s_i + p_j s_j}{p_i + p_j}, + \qquad p_i \neq 0,\; p_j \neq 0 + + +.. _feature-ngtdm-contrast: + +Contrast +^^^^^^^^ + +:Code: ``NGTDM_CONTRAST`` +:Range: :math:`[0, \infty)`, cubed grey levels + +The spread of the grey levels times the mean neighbourhood difference per valid pixel. High values +mean both a wide dynamic range and strong local variation. + +.. math:: + + \mathrm{Contrast} = \left(\frac{1}{N_{g,p}\,(N_{g,p}-1)}\sum_{i}\sum_{j} p_i\,p_j\,(g_i - g_j)^2\right) + \left(\frac{1}{N_{v,p}}\sum_i s_i\right) + +When :math:`N_{g,p} = 1` the first factor divides by 1 instead, and the value is 0. + +**Possible issues.** :math:`N_{g,p}` counts every grey level in the bounding box, while IBSI counts +only the levels with :math:`p_i > 0`. A level whose pixels have no neighbour therefore lowers +contrast. Adding one isolated pixel of intensity 7 beside the worked example gives 0.09734 in place +of 0.1460; the other four features do not change. + + +.. _feature-ngtdm-strength: Strength --------- +^^^^^^^^ + +:Code: ``NGTDM_STRENGTH`` +:Range: :math:`[0, \infty)`, grey levels + +The probability-weighted squared differences between grey levels, divided by the summed +neighbourhood differences. High values mean large, distinct regions of different intensity. + +.. math:: + + \mathrm{Strength} = \frac{\sum_{i}\sum_{j} (p_i + p_j)(g_i - g_j)^2}{\sum_i s_i}, + \qquad p_i \neq 0,\; p_j \neq 0 + +**Possible issues.** When :math:`\sum_i s_i = 0` the division is 0/0 and the output is the +``--noval`` substitute (``-0.0`` in Arrow and Parquet files). With the default ``--noval=0`` this +equals the IBSI value, 0. + + +.. _ngtdm-possible-issues: + +Possible issues +--------------- + +Background as grey level 1 with the default binning + With a positive ``--coarseGrayDepth``, intensity 0 bins to level 1. The bounding-box pixels + outside the mask then become valid pixels of level 1, and they are averaged into the + neighbourhood of every ROI pixel next to them. ROI pixels whose intensity is below + :math:`x_{\max} / N` also get level 1. On the worked example with three pixels of the bounding + box left out of the mask, coarseness is 0.01324 and contrast 351.2; the same binning without + those pixels gives 0.01685 and 277.7. IBSI mode excludes them and gives a contrast of 0.1302. + Rectangular ROIs with no pixel of intensity 0 are not affected. + +Grey-level values depend on the binning + Every feature except busyness scales with the grey-level spacing. On the worked example contrast + is 0.1460 in IBSI mode and 309.1 with the default 64-level binning, which maps the levels 1 to 5 + to 13, 26, 39, 52 and 64. Coarseness is 0.2006 and 0.01551, complexity 8.251 and 1359.7, + strength 1.373 and 17.47. Busyness changes only slightly, from 0.6360 to 0.6357, because the + default binning does not keep the levels equally spaced. Compare values only between runs with + the same ``--coarseGrayDepth``. + +Pixels with no neighbour + A pixel whose 8 neighbours are all outside the ROI, or all of grey level 0, is not counted in + :math:`n_i` or :math:`N_{v,p}`. Its grey level is still counted in :math:`N_{g,p}`, which changes + contrast (see :ref:`Contrast `). When no pixel of the ROI has a + neighbour, every feature returns the ``--noval`` substitute; in IBSI mode busyness is 0 instead + when the ROI has a single grey level. Except for a single grey level outside IBSI mode, the + substitute is applied when the value is written, so Arrow and Parquet files hold ``-0.0``. + +Zero neighbourhood differences + When every valid pixel equals the mean of its neighbours, :math:`\sum_i p_i s_i = 0`. Coarseness + returns the ``--noval`` substitute (0 by default) where IBSI gives :math:`10^6`. Busyness returns + 0 and strength the ``--noval`` substitute, both 0 by default, as in IBSI. Both substitutes are + ``-0.0`` in Arrow and Parquet files, whatever ``--noval`` is (see :ref:`conventions-missing`). + For a block of four 1s with one isolated pixel of intensity 2, Nyxus returns 0 for all five + features; the IBSI values are 10\ :sup:`6` for coarseness and 0 for the other four. + +Constant ROI + When all of a ROI's pixels have one intensity and the ROI fills its bounding box, every NGTDM + feature returns 0 by default. With the default binning the ROI has a single grey level and every + feature returns the ``--noval`` substitute. In IBSI mode the matrix is built; coarseness and + strength return the ``--noval`` substitute (``-0.0`` in Arrow and Parquet files) and the other + three return 0. IBSI gives + :math:`10^6` for coarseness. With the default binning, a constant ROI that does not fill its + bounding box gains the background as a second level and its features are computed. + +Pixels of intensity 0 inside the mask + In IBSI mode a pixel of intensity 0 is not counted and is not used as a neighbour, even inside + the mask. + +Run time with large intensities in IBSI mode + In IBSI mode the matrix has one row per integer from 0 to the ROI's largest intensity, and + contrast, busyness, complexity and strength each loop over every pair of rows, empty or not; + all five are computed whichever ones are selected. One 40 × 40 ROI takes 0.03 s with + intensities up to 4095, 0.28 s up to 16383 and 4.3 s up to 65535. Binning to a small number of + levels avoids this. + + +Validation +---------- + +The test suite checks every 2D NGTDM feature against MIRP on the four slices of the IBSI digital +phantom in IBSI mode, on each slice and on their mean. All five agree to an absolute +10\ :sup:`-9`; the largest difference measured is 3.6 × 10\ :sup:`-15`. The four-slice means are +also compared with IBSI's published consensus values at a relative 10\ :sup:`-2`, the precision +those values are published to; that comparison is a consistency check, not one of the validations +in the index. The default mode is +covered by regression tests only. The values quoted under Possible issues are pinned by the +worked-example script, except the run times, which are measured only. None is compared with a +reference tool. -NGTDM_STRENGTH :math:`= \frac{\sum^{N_g}_{i = 1}\sum^{N_g}_{j = 1}{(p_i + p_j)(i-j)^2}}{\sum^{N_g}_{i = 1}{s_i}}` where :math:`p_i \neq 0, p_j \neq 0` References ---------- -Amadasun M, King R; Textural features corresponding to textural properties; Systems, Man and Cybernetics, IEEE Transactions on 19:1264-1274 (1989). doi: 10.1109/21.44046 +1. Amadasun M, King R. *Textural features corresponding to textural properties.* IEEE Transactions + on Systems, Man, and Cybernetics. 1989;19(5):1264–1274. doi:10.1109/21.44046 +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io +3. Zwanenburg A, Leger S, Agolli L, et al. *Assessing robustness of radiomic features by image + perturbation.* Scientific Reports. 2019;9:614. (MIRP) diff --git a/docs/source/Math/f_ngtdm_3d.rst b/docs/source/Math/f_ngtdm_3d.rst new file mode 100644 index 000000000..3085cec98 --- /dev/null +++ b/docs/source/Math/f_ngtdm_3d.rst @@ -0,0 +1,366 @@ +Neighbourhood grey-tone difference matrix, 3D (3NGTDM) +====================================================== + +The 3D NGTDM compares each voxel with the average of the voxels around it. For every grey level +the matrix holds how often the level occurs and how far, in total, its voxels differ from their +neighbourhood averages. The five features and their formulas are those of the 2D family, defined on +the :doc:`2D NGTDM page `; this page covers what is specific to volumes. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``3NGTDM_*``: 5 features, one value per ROI + * - **Group** + - ``*3D_NGTDM*``; also part of ``*3D_ALL_TEXTURE*`` and ``*3D_ALL*`` + * - **Dimensions** + - 3D. The 2D family, ``NGTDM_*``, is on the :doc:`2D NGTDM page `. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes. The streamed path builds the same matrix as the in-memory one. + When one read of the file returns the whole volume (NIfTI, or OME-Zarr chunked over all of + Z), an oversized ROI stops the run with an error (see :doc:`/large_images`). + * - **IBSI** + - All 5 features have an IBSI identifier. IBSI calls a matrix built from the whole volume the + 3D aggregation (KOBO), which is what Nyxus computes. + +.. tip:: + + The grey levels of this family are set by the ``3ngtdm/greydepth`` metaparameter, not by + ``--coarseGrayDepth``. Its default, 0, uses the raw intensities. With that default, a ROI that + fills its bounding box and whose lowest intensity is 0 or 1 reproduces PyRadiomics with + ``binWidth=1``; with a higher lowest intensity, all features but busyness do. Voxels of the + bounding box that lie outside the mask still take part in the matrix (see + :ref:`ngtdm-3d-possible-issues`). + + +How the matrix is built +----------------------- + +Nyxus copies the ROI's bounding box into a cube, with the voxels outside the mask set to 0, and bins +the cube's intensities into grey levels. When the lowest grey level in the cube is 0, every voxel is +raised by one level, so the voxels outside the mask become grey level 1. + +Nyxus then visits every voxel of the cube whose grey level is not the background level: 1 with +MATLAB-style binning (a positive ``3ngtdm/greydepth``), 0 otherwise. Its neighbourhood is every +voxel of the cube within a Chebyshev distance :math:`\delta` of it, the voxel itself excluded. With +the default :math:`\delta = 1` that is the 26 voxels of the surrounding 3 × 3 × 3 block. All of +these neighbours are averaged, whatever their grey level and whether or not they are in the mask; +only the neighbours outside the cube are left out. The voxel adds 1 to :math:`n_i` for its grey +level :math:`g_i`, and adds :math:`|g_i - \bar{A}|` to :math:`s_i`, where :math:`\bar{A}` is that +average. + +- **Neighbourhood:** the :math:`(2\delta+1)^3 - 1` voxels within Chebyshev distance :math:`\delta`, + set by ``3ngtdm/radius`` (1 by default). Neighbours outside the bounding box are left out, so a + corner voxel of the box has 7 neighbours at :math:`\delta = 1`, an edge voxel 11, a face voxel 17 + and an inner voxel 26. +- **Directions:** none. One matrix is built from the whole volume, and each feature has one value. +- **Which voxels count:** the grey level decides, not the ROI mask. Voxels of the bounding box + outside the mask are visited as voxels of the lowest grey level, except with MATLAB-style binning, + and are always averaged into their neighbours' :math:`\bar{A}`. A ROI voxel of intensity 0 is + treated the same way as one outside the mask. +- **Grey levels:** set by ``3ngtdm/greydepth``, described under `Settings`_. In IBSI mode the + intensities are not binned, the matrix has a row for every integer from 0 to the ROI's largest + intensity, and the levels are always raised by one. +- **Probabilities:** :math:`p_i = n_i / \sum_k n_k`. Every visited voxel has at least one neighbour + in a cube of more than one voxel, so :math:`\sum_k n_k` is the number of visited voxels. + + +Worked example +^^^^^^^^^^^^^^ + +Take a 3 × 3 × 3 ROI that fills its bounding box and already has four grey levels. Its planes +:math:`z = 0, 1, 2` are + +.. math:: + + \begin{bmatrix} 1 & 2 & 2\\ 3 & 1 & 4\\ 2 & 2 & 3 \end{bmatrix},\qquad + \begin{bmatrix} 4 & 3 & 1\\ 2 & 4 & 3\\ 1 & 3 & 2 \end{bmatrix},\qquad + \begin{bmatrix} 2 & 1 & 3\\ 4 & 2 & 1\\ 3 & 4 & 4 \end{bmatrix} + +With the default settings the grey levels are the intensities. All 27 voxels are visited. Grey +level 1 has six voxels: one corner, with 7 neighbours averaging :math:`19/7`; one face centre, with +17 neighbours averaging :math:`42/17`; and four edge voxels, with 11 neighbours averaging +:math:`26/11`, :math:`30/11`, :math:`29/11` and :math:`30/11`. So + +.. math:: + + s_1 = \left(\tfrac{19}{7} - 1\right) + \left(\tfrac{42}{17} - 1\right) + + \tfrac{26 + 30 + 29 + 30}{11} - 4 = \tfrac{12618}{1309} = 9.6394 + +The matrix is below. Its rows are grey levels 1 to 4 and its columns are :math:`n_i`, :math:`p_i` +and :math:`s_i`. + +.. math:: + + \begin{bmatrix} + 6 & 0.2222 & 9.6394\\ + 8 & 0.2963 & 4.7410\\ + 7 & 0.2593 & 3.5424\\ + 6 & 0.2222 & 8.9535 \end{bmatrix}, + \qquad \sum_i n_i = 27, \qquad N_{g,p} = 4, \qquad \sum_i s_i = 26.876 + +The exact values are :math:`s_2 = 6206/1309`, :math:`s_3 = 4637/1309` and +:math:`s_4 = 17925/2002`. The *Example* value of each feature in the :ref:`index ` is +what Nyxus returns for this ROI with the default settings; PyRadiomics returns the same five values. +The test suite runs this example and checks every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/ngtdm_3d_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --dim=3 --features="*3D_NGTDM*" \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +The symbols are those of the :doc:`2D page `, with voxels in place of pixels. The +differences are: + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`g_i` + - Grey level of row :math:`i`, after the raise by one when the cube's lowest level is 0. In IBSI + mode this is always the intensity plus one. + * - :math:`\bar{A}` + - Mean grey level of all the voxels of the cube within distance :math:`\delta`, in the mask or + not. + * - :math:`n_i` + - Number of visited voxels of grey level :math:`g_i`, including voxels of the bounding box + outside the mask. + * - :math:`N_{v,p}` + - Number of visited voxels whose :math:`\bar{A}` is positive, which is every visited voxel. + Complexity divides by it. + * - :math:`N_{g,p}` + - Number of distinct grey levels in the cube, the level of the voxels outside the mask + included. + +Sums over :math:`i` and :math:`j` run over the rows of the matrix. Rows with :math:`p_i = 0` add +nothing. Every feature uses the grey level :math:`g_i`, never the row position. + + +Settings +-------- + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Grey levels + - not available + - ``set_metaparam("3ngtdm/greydepth=N")`` + - 0 (default): no binning, the grey levels are the intensities. Positive: :math:`N` MATLAB-style + bins from 0 to the ROI maximum, with intensity 0 as level 1, the background level. Negative: + :math:`|N|` PyRadiomics-style bins over the ROI's range, with intensity 0 as level 0. + * - Distance + - not available + - ``set_metaparam("3ngtdm/radius=N")`` + - :math:`\delta`, the Chebyshev radius of the neighbourhood, a positive integer; 1 by default. + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus3D(..., ibsi=True)`` + - Overrides ``3ngtdm/greydepth`` with 0, and gives the matrix a row for every integer from 0 to + the ROI maximum. + +``--coarseGrayDepth`` (``coarse_gray_depth``) has no effect on this family: on the worked example +``coarse_gray_depth=8`` returns the default values. ``--pixelDistance`` does not apply either. An +unrecognised ``3ngtdm/...`` parameter is rejected with an error. All options are listed in the +:ref:`cli-reference`. + + +Output columns +-------------- + +Each feature produces one column, named with its code: ``3NGTDM_COARSENESS``, +``3NGTDM_CONTRAST`` and so on. There are no per-direction columns and no ``_AVE`` columns. + + +.. _ngtdm-3d-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example with the default settings. + +.. include:: ngtdm_3d_index.inc + + +Features +-------- + +Each feature is the 2D formula evaluated on the 3D matrix. The links lead to the definitions. + +.. _feature-3ngtdm-busyness: + +Busyness +^^^^^^^^ + +:Code: ``3NGTDM_BUSYNESS`` +:Range: :math:`[0, \infty)`, dimensionless + +Defined as :ref:`NGTDM_BUSYNESS `. It is 0 when :math:`N_{g,p} = 1` or when +only one grey level has :math:`p_i > 0`. + +**Possible issues.** The only feature that depends on the grey levels themselves rather than on +their differences, so it changes when all levels shift by the same amount. On the worked example it +is 1.477 with the default settings, which PyRadiomics also returns, and 1.502 in IBSI mode, where +the levels are raised by one. The other four features are the same in both modes. PyRadiomics +numbers its bins from 1 at the ROI's lowest intensity, while Nyxus keeps the intensities: adding 9 +to every voxel of the worked example leaves PyRadiomics at 1.477 but gives 0.9682 in Nyxus, and +0.8984 in IBSI mode. + + +.. _feature-3ngtdm-coarseness: + +Coarseness +^^^^^^^^^^ + +:Code: ``3NGTDM_COARSENESS`` +:Range: :math:`(0, \infty)`, inverse grey levels + +Defined as :ref:`NGTDM_COARSENESS `. When every visited voxel equals its +neighbourhood average the division gives infinity and the output is the ``--noval`` substitute, +0 by default (``-0.0`` in Arrow and Parquet files; see :ref:`conventions-missing`), where IBSI +defines :math:`10^6`. + + +.. _feature-3ngtdm-complexity: + +Complexity +^^^^^^^^^^ + +:Code: ``3NGTDM_COMPLEXITY`` +:Range: :math:`[0, \infty)`, squared grey levels + +Defined as :ref:`NGTDM_COMPLEXITY `, with :math:`N_{v,p}` the number of +visited voxels. + + +.. _feature-3ngtdm-contrast: + +Contrast +^^^^^^^^ + +:Code: ``3NGTDM_CONTRAST`` +:Range: :math:`[0, \infty)`, cubed grey levels + +Defined as :ref:`NGTDM_CONTRAST `, with :math:`N_{g,p}` the number of +distinct grey levels in the bounding box. When the box holds voxels outside the mask, their level +is one of them. + + +.. _feature-3ngtdm-strength: + +Strength +^^^^^^^^ + +:Code: ``3NGTDM_STRENGTH`` +:Range: :math:`[0, \infty)`, grey levels + +Defined as :ref:`NGTDM_STRENGTH `. When :math:`\sum_i s_i = 0` the division +is 0/0 and the output is the ``--noval`` substitute, 0 by default, as in IBSI (``-0.0`` in Arrow +and Parquet files). + + +.. _ngtdm-3d-possible-issues: + +Possible issues +--------------- + +Voxels outside the mask take part + IBSI and PyRadiomics use only ROI voxels, as centres and as neighbours. Nyxus uses the whole + bounding box. With the default settings and in IBSI mode the voxels outside the mask become grey + level 1: they are visited as voxels of that level, are counted in :math:`N_{g,p}`, and are + averaged into their neighbours' :math:`\bar{A}`. Leaving the eight corners of the worked example + out of the mask gives coarseness 0.1015, contrast 0.3376, busyness 1.663, complexity 15.68 and + strength 1.024 in both modes. PyRadiomics gives 0.1843, 0.2407, 1.097, 6.190 and 0.9691. With + MATLAB-style binning those voxels are not visited, since level 1 is the background level, but + they are still averaged into the neighbourhoods. ROIs that fill their bounding box are not + affected. + +ROI voxels of intensity 0 + A ROI voxel of intensity 0 cannot be told apart from a voxel outside the mask. In IBSI mode and + with the default settings it becomes grey level 1 and is counted. With MATLAB-style binning it + is level 1, the background level, and is not visited; so are ROI voxels below + :math:`x_{\max} / N`. + +Grey-level values depend on the binning + Every feature except busyness scales with the grey-level spacing. On the worked example + ``3ngtdm/greydepth=64`` maps the levels 1 to 4 to 17, 33, 49 and 64, and gives coarseness + 0.009831, contrast 734.1, busyness 1.485, complexity 1294 and strength 11.13. Compare values + only between runs with the same ``3ngtdm/greydepth``. + +The neighbourhood radius + A larger :math:`\delta` averages over more voxels and smooths :math:`\bar{A}`. On the worked + example ``3ngtdm/radius=2`` gives coarseness 0.1583, contrast 0.1860, busyness 1.445, + complexity 5.173 and strength 0.7222. In PyRadiomics the neighbourhood of radius 2 is + ``distances=[1, 2]``, not ``[2]``, which names one shell only. + +Constant ROI + When all of a ROI's voxels have one intensity and the ROI fills its bounding box, the binned + volume has a single grey level. With the default settings, MATLAB-style and PyRadiomics-style + binning, every feature returns the ``--noval`` substitute (0 by default). In IBSI mode the + matrix is built, since its rows run from 0 to the intensity; coarseness and strength return the + ``--noval`` substitute (``-0.0`` in Arrow and Parquet files) and the other three return 0. IBSI + gives :math:`10^6` for coarseness. A constant ROI that does not fill its bounding box gains the + voxels outside the mask as a second level, and its features are computed: a constant + 3 × 3 × 3 block of intensity 7 without its eight corners gives coarseness 0.02793, contrast 31.52, busyness + 3.357, complexity 18.57 and strength 1.176. + +A single-voxel ROI + The voxel has no neighbour and the matrix has one level, so every feature returns the + ``--noval`` substitute. + +Run time with large intensities in IBSI mode + In IBSI mode the matrix has one row per integer from 0 to the ROI's largest intensity, and + contrast, busyness, complexity and strength each loop over every pair of rows, empty or not; + all five are computed whichever ones are selected. A 6 × 6 × 6 ROI takes 0.04 s with + intensities up to 4095, 0.31 s up to 16383 and 7.1 s up to 65535. With the default settings the + matrix has a row only for each intensity present, and the same ROI takes 0.02 s. + + +Validation +---------- + +The test suite checks every 3D NGTDM feature against PyRadiomics 3.0.1 on a 4 × 4 × 3 phantom whose +ROI fills its bounding box, with ``3ngtdm/greydepth=0`` and PyRadiomics' ``binWidth=1``, at +``3ngtdm/radius`` 1 and 2. All five agree to a relative 10\ :sup:`-9`, with no difference measured, +and the matrix itself (:math:`n_i`, :math:`p_i`, :math:`s_i` for each grey level) is checked +against PyRadiomics' too. Neither configuration has voxels outside the mask or uses binning, so the +behaviour described under `Possible issues`_ is not compared with a reference tool. MATLAB-style +binning is covered by regression tests only. Every Nyxus feature value quoted on this page is +pinned by the worked-example script; the PyRadiomics values and the run times are not. + + +References +---------- + +1. Amadasun M, King R. *Textural features corresponding to textural properties.* IEEE Transactions + on Systems, Man, and Cybernetics. 1989;19(5):1264–1274. doi:10.1109/21.44046 +2. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io +3. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. (PyRadiomics) diff --git a/docs/source/Math/f_pixel_intensity.rst b/docs/source/Math/f_pixel_intensity.rst index fcc7279b8..d4f6e5376 100644 --- a/docs/source/Math/f_pixel_intensity.rst +++ b/docs/source/Math/f_pixel_intensity.rst @@ -1,119 +1,935 @@ Pixel intensity features ======================== -Denote +Intensity features are first-order statistics of the intensities of a ROI's pixels. They describe +the level, spread and shape of the intensity distribution, and ignore where in the ROI each pixel +lies. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - 36 single-value features, from ``COV`` to ``VARIANCE_BIASED``, and ``HISTOGRAM``, which has + one column per histogram bin + * - **Group** + - ``*ALL_INTENSITY*``, which does not include ``HISTOGRAM``. ``*ALL*`` includes both. + * - **Dimensions** + - 2D. The 3D family, ``3MEAN``, ``3MEDIAN`` and so on, has its own page, + :doc:`f_intensity_3d`. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory on tiled TIFF files and on striped files of up to + 1024 × 1024 pixels; see :ref:`intensity-possible-issues` for larger striped files and + :ref:`large-images-tiles`. + * - **IBSI** + - 18 features have an identifier from the IBSI intensity-based statistics, which list 18 + features. ``COV`` and the percentiles follow a different convention from IBSI; their entries + say how. + +.. tip:: + + To get ``HISTOGRAM``, request it together with at least one other intensity feature, for + example ``["*ALL_INTENSITY*", "HISTOGRAM"]``. Requested on its own, every bin is 0, a known defect + (`#489 `__; see :ref:`intensity-possible-issues`). The IBSI intensity-histogram features, ``IH_*``, are a + separate family with its own page, :doc:`f_intensity_histogram`. + + +How the features are computed +----------------------------- + +Every pixel of the mask that belongs to the ROI is used once, with its intensity. Nothing else is +used: not the pixel's position, not its neighbours, not the pixels of the bounding box outside the +mask. + +- **Which pixels:** all pixels of the ROI, including pixels of intensity 0 inside the mask. + :math:`N_v` is the number of ROI pixels. +- **Values:** the intensities themselves. Nothing is binned except for ``ENTROPY``, + ``UNIFORMITY`` and ``HISTOGRAM``, which use a histogram of ``--coarseGrayDepth`` bins, and the + percentiles, which are read off a histogram of 100 bins. +- **Histograms:** both histograms have equal-width bins over the ROI's own range, from + :math:`X_{\min}` to :math:`X_{\max}`. The maximum goes into the last bin. A constant ROI puts + every pixel into the first bin. +- **Units:** the features are reported in the image's own units when the image is read from a + file; see :ref:`intensity-units`. Location features (minimum, maximum, mean, median, mode, + percentiles, robust mean, integrated intensity, energy, root mean square) go through the whole + intensity map, spread features and ``COVERED_IMAGE_INTENSITY_RANGE`` through its slope, + variances through the square of its slope, and the ratios ``COV``, ``QCOD`` and + ``UNIFORMITY_PIU`` are recomputed from the mapped parts. + Skewness, the kurtoses, the 5th and 6th moments, entropy, uniformity and the histogram do not + change under the map. +- **In-memory arrays:** ``Nyxus.featurize`` records no map. The features are in the grey levels + Nyxus stores, which equal the input when it holds non-negative integers. An array with negative + values is shifted up by its minimum, and fractions are truncated. +- **IBSI mode:** ``--ibsi=true`` does not change any intensity feature. + + +Worked example +^^^^^^^^^^^^^^ + +Take the 5 × 5 ROI of the texture pages, with every pixel in the mask: -- :math:`f(x,y)` -- real value of a continuous intensity function :math:`f` at real-valued (continuous) Cartesian image location :math:`(x,y)`; -- :math:`P` -- 2-dimensional point set of greyscale image intensity values at discrete locations; -- :math:`p(x,y)` -- greyscale value of :math:`f(x,y)` at discrete 2-dimensional image pixel location :math:`(x,y)`, or, simply, pixel :math:`(x,y)` intensity; -- :math:`G = {p(x,y) | p(x,y)>0}` -- 1-dimensional set of image pixels of non-zero intensity, or so called region of interest (ROI); -- :math:`g_i` -- element of :math:`G`; -- :math:`n = card(G)` -- number of ROI elements; -- :math:`\mathbb{E}` -- the expectation operator; -- :math:`\min \: X` and :math:`\max \: X` -- the minimum and maximum of random variable :math:`X`; -- :math:`\mu_k` -- central moment of order :math:`k` of a real-valued random variable :math:`X`, :math:`\mu_k = \mathbb{E}[(X − \mathbb{E}[X])^n]`. +.. math:: + + \textbf{X} = \begin{bmatrix} + 5 & 2 & 5 & 4 & 4\\ + 3 & 3 & 3 & 1 & 3\\ + 2 & 1 & 1 & 1 & 3\\ + 4 & 2 & 2 & 2 & 3\\ + 3 & 5 & 3 & 3 & 2 \end{bmatrix} -Nyxus pixel intensity features are calculated as: +It holds four 1s, six 2s, nine 3s, three 4s and three 5s, so :math:`N_v = 25`, +:math:`\sum x = 70` and :math:`\mu = 2.8`. The squared deviations sum to 36, so ``VARIANCE`` is +:math:`36/24 = 1.5` and ``VARIANCE_BIASED`` is :math:`36/25 = 1.44`. The median is the 13th +value, 3, and the mode is 3. -INTEGRATED_INTENSITY = :math:`\sum _i^n g_i` +The 100-bin percentile histogram has bins of width :math:`w = (5-1)/100 = 0.04`. The four 1s fall +in bin 0, the 2s in bin 25, the 3s in bin 50, the 4s in bin 75 and the 5s in bin 99. For ``P90`` +the target count is :math:`0.9 \times 25 = 22.5`. The first 22 pixels lie below bin 99, which holds +3 pixels, so -MEAN :math:`\gets \mu = \frac{1}{n} \sum_i^n g_i` +.. math:: -MIN = :math:`\min \: G` , + P_{90} = 1 + 99 \times 0.04 + 0.04 \times \frac{22.5 - 22}{3} = 4.9667 + +where an exact percentile with linear interpolation, as in ``numpy.percentile``, gives 4.6. + +With the default 64 bins, ``HISTOGRAM_BIN_0`` to ``HISTOGRAM_BIN_63`` are 0 except bins 0, 16, +32, 48 and 63, which hold 4, 6, 9, 3 and 3 pixels. Entropy is therefore the entropy of the +probabilities 0.16, 0.24, 0.36, 0.12 and 0.12, 2.1819 bits. + +The *Example* value of each feature in the :ref:`index ` is what Nyxus returns for +this ROI; for ``HISTOGRAM`` it is ``HISTOGRAM_BIN_0``. The test suite runs this example and checks +every one of them. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/intensity_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line -MAX = :math:`\max \: G`, + .. code-block:: console -RANGE = :math:`\max \: G - \min \: G`, + $ nyxus --features="*ALL_INTENSITY*,HISTOGRAM" \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv -COVERED_IMAGE_INTENSITY_RANGE = :math: `\frac {\max \: G - \min \: G} {\max \: P - \min \: P} ` -STANDARD_DEVIATION :math:`\gets \sigma = (\mathbb{E}[(G-\mu)^2]) ^{\frac {1}{2}} = \left[ \frac{1}{n-1} \sum_i^n (g_i-\mu)^2 \right ] ^{\frac {1}{2}}` +Notation +-------- -STANDARD_DEVIATION_BIASED :math:`\gets \sigma_b = (\mathbb{E}[(G-\mu)^2]) ^{\frac {1}{2}} = \left[ \frac{1}{n} \sum_i^n (g_i-\mu)^2 \right ] ^{\frac {1}{2}}` +.. list-table:: + :widths: 25 75 + :header-rows: 1 -COV = :math:`\frac{\sigma}{\mu}`, + * - Symbol + - Meaning + * - :math:`x_k` + - Intensity of the :math:`k`-th pixel of the ROI. + * - :math:`N_v` + - Number of ROI pixels. + * - :math:`X_{\min},\; X_{\max}` + - Smallest and largest intensity in the ROI. + * - :math:`\mu` + - Mean intensity, :math:`\mu = \frac{1}{N_v}\sum_k x_k`. + * - :math:`m_r` + - Central moment of order :math:`r`, :math:`m_r = \frac{1}{N_v}\sum_k (x_k - \mu)^r`. + * - :math:`\sigma` + - Standard deviation with :math:`N_v - 1`, + :math:`\sigma = \sqrt{\frac{1}{N_v - 1}\sum_k (x_k - \mu)^2}`; 0 when :math:`N_v = 1`. + * - :math:`\sigma_b` + - Standard deviation with :math:`N_v`, :math:`\sigma_b = \sqrt{m_2}`. + * - :math:`M` + - Median intensity. + * - :math:`P_q` + - The ``Pqq`` feature: the :math:`q`-th percentile read off the 100-bin histogram. + * - :math:`N_b` + - Number of bins of the coarse histogram, the absolute value of ``--coarseGrayDepth``. + * - :math:`h_j,\; p_j` + - Count of ROI pixels in bin :math:`j` of the coarse histogram, and :math:`p_j = h_j / N_v`. -STANDARD_ERROR = :math:`\frac{\sigma}{\sqrt{n}}`, +Sums over :math:`k` run over the ROI pixels, and sums over :math:`j` over the :math:`N_b` bins. +A pixel of intensity :math:`x` falls in bin +:math:`j = \lfloor N_b (x - X_{\min}) / (X_{\max} - X_{\min}) \rfloor`, and :math:`x = X_{\max}` +falls in bin :math:`N_b - 1`. -SKEWNESS = :math:`\frac {\sqrt n \mu_3}{\mu_2^{1.5}}` if :math:`n>3` and :math:`\sigma_2 \neq 0`, otherwise :math:`=0`. -KURTOSIS = :math:`\frac{n \mu_4} {\sigma^4}` if :math:`n>4` and :math:`\mu_2 \neq 0`, otherwise :math:`=0`. +Settings +-------- -EXCESS_KURTOSIS = :math:`\frac{n \mu_4} {\sigma^4} - 3` if :math:`n>4` and :math:`\mu_2 \neq 0`, otherwise :math:`=0`. +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 -HYPERSKEWNESS = :math:`\frac{n \mu_4} {\mu_2^{5/2}}` if :math:`n>5` and :math:`\mu_2 \neq 0`, otherwise :math:`=0` + * - Setting + - Command line + - Python + - Effect + * - Histogram bins + - ``--coarseGrayDepth=64`` + - ``Nyxus(..., coarse_gray_depth=64)`` + - :math:`N_b`, the number of bins of ``ENTROPY``, ``UNIFORMITY`` and ``HISTOGRAM``. ``ENTROPY`` + and ``UNIFORMITY`` ignore the sign; with a negative value ``HISTOGRAM`` writes no columns (a + fix is coming). No other intensity feature depends on it. Python accepts only positive values. + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - No effect on these features. + * - Image intensity map + - ``--fpimgdr``, ``--fpimgmin``, ``--fpimgmax``, ``--preserve-hu`` + - ``Nyxus(..., preserve_hu=True)`` and the matching keywords + - Decide how a file's intensities become grey levels, and so the units the features are + reported in (see :ref:`intensity-units`). -HYPERFLATNESS = :math:`\frac {n \mu_5} {\mu_2^3}` if :math:`n>6` and :math:`\mu_2 \neq 0`, otherwise :math:`=0` +The percentile histogram always has 100 bins; no setting changes it. All options are listed in the +:ref:`cli-reference`. -MEAN_ABSOLUTE_DEVIATION = :math:`\sigma = \frac{1}{n} \sum_i^n \left| g_i-\mu \right|` -ENERGY :math:`\gets E = \sum _i^n g_i^2` +Output columns +-------------- -ROOT_MEAN_SQUARED :math:`= \sqrt {\frac {1} {n} \sum_i^n g_i^2 }` +Each single-value feature produces one column, named with its code: ``MEAN``, ``P25`` and so on. -ENTROPY :math:`= \sum_i^k (- b_{i} \: \log \: b_{i})` where :math:`b_i` is a non-zero value of the image histogram of size :math:`k = 1 + \log_2 \: n`, +``HISTOGRAM`` produces :math:`N_b` columns, ``HISTOGRAM_BIN_0`` to ``HISTOGRAM_BIN_63`` with the +default 64 bins. Column ``HISTOGRAM_BIN_j`` holds :math:`h_j`, the number of ROI pixels in bin +:math:`j`. The columns hold counts, not probabilities, and the bin edges are not written; bin +:math:`j` starts at :math:`X_{\min} + j\,(X_{\max} - X_{\min})/N_b`, so ``MIN`` and ``MAX`` +recover them. -MODE :math:`= x_{uk} + w \frac{f_k - f_{k-1}}{2 f_k - f_{k-1} - f_{k+1}}` where :math:`k` - the index of the histogram bin containing the greatest count, -:math:`x_{uk}` - lower bound of the histogram bin containing the greatest count, :math:`f_k` - the greatest bin count, :math:`f_{k-1}` and :math:`f_{k+1}` - -counts of the bins neighboring to the greatest count bin; (informally, the histogram bin value having the highest count) -VARIANCE :math:`\gets \sigma = (\mathbb{E}[(G-\mu)^2]) ^{\frac {1}{2}} = \left[ \frac{1}{n-1} \sum_i^n (g_i-\mu)^2 \right ]` +.. _intensity-index: -VARIANCE_BIASED :math:`\gets \sigma_b = (\mathbb{E}[(G-\mu)^2]) ^{\frac {1}{2}} = \left[ \frac{1}{n} \sum_i^n (g_i-\mu)^2 \right ]` +Feature index +------------- -UNIFORMITY = :math:`\sum_i^k b_{i}^2` where :math:`b_i` is a value of the image histogram of size :math:`k = 256` +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example. + +.. include:: intensity_index.inc -UNIFORMITY_PIU = :math:`(1 - \frac{\max \: G - \min \: G}{\max \: G + \min \: G}) \times 100` -The quantile :math:`q_p` of a random variable (or equivalently of its distribution) is -defined as the smallest number :math:`q`` such that the cumulative distribution function -is greater than or equal to some :math:`p`, where :math:`0`__), so a stored grey level above 65535 wraps modulo :math:`2^{32}`. Multiplying the worked +example by 20000 gives 5.415 × 10\ :sup:`10` where the sum of squares is 9.28 × 10\ :sup:`10`. +Images of 8- and 16-bit integers are not affected. -QCOD = :math:`\frac {P75 - P25} {P75 + P25}` -MEDIAN -- the 50% percentile defined as :math:`0.5 = \int _{-\infty} ^{0.5} f(x)dx`, the value such that an equal number -of samples are less than and greater than the value (for an odd sample size), or the average of the two central values (for an even sample size). +.. _feature-entropy: -MEDIAN_ABSOLUTE_DEVIATION = :math:`\sigma = \frac{1}{n} \sum_i^n \left| g_i - MEDIAN \right|` +Entropy +^^^^^^^ -INTERQUARTILE_RANGE = :math:`q_{0.75} - q_{0.25}` - the difference of the 1st and 3rd sample quartiles, +:Code: ``ENTROPY`` +:Range: :math:`[0, \log_2 N_b]`, bits -ROBUST_MEAN_ABSOLUTE_DEVIATION (RMAD) +The Shannon entropy of the coarse histogram. High values mean the intensities spread evenly over +many bins. .. math:: - RMAD = \frac{1}{k} \underset{q_{0.1} \leqslant g_i \leqslant q_{0.9}} {\sum_i^n} |g_i - \mu_R| -where + \mathrm{ENTROPY} = -\sum_j p_j \log_2\left(p_j + 2.2 \times 10^{-16}\right) + +The logarithm is exact; empty bins add nothing. + +**Possible issues.** The value depends on :math:`N_b`. On the worked example it is 2.182 with 64 +bins and 1.942 with 4, where the 4s and 5s share a bin. Because the bins span the ROI's own range, +the value does not change when all intensities are scaled. The IBSI intensity-histogram entropy is +``IH_ENTROPY_IDX`` in the ``IH_*`` family. + + +.. _feature-excess-kurtosis: + +Excess kurtosis +^^^^^^^^^^^^^^^ + +:Code: ``EXCESS_KURTOSIS`` +:Range: :math:`[-2, \infty)`, dimensionless + +Kurtosis minus 3, so that a normal distribution scores 0. Negative values mean a flat +distribution with light tails. .. math:: - \mu_R = \underset{q_{0.1} \leqslant g_i \leqslant q_{0.9}} { \frac{1}{n} \sum_i^n g_i } - -or, otherwise, MAD calculated over the subset of :math:`G=\{g_i\}^n` whose elements are in the :math:`[q_{0.1},q_{0.9}]` value interval. -References + \mathrm{EXCESS\_KURTOSIS} = \frac{m_4}{m_2^{2}} - 3 + +The value is 0 when :math:`N_v < 5` or :math:`m_2 = 0`. This is the IBSI definition. + + +.. _feature-histogram: + +Histogram +^^^^^^^^^ + +:Code: ``HISTOGRAM`` +:Range: :math:`[0, N_v]` per column, pixel counts + +The coarse histogram itself: column ``HISTOGRAM_BIN_j`` is :math:`h_j`, the number of ROI pixels +in bin :math:`j`, for :math:`j = 0, \dots, N_b - 1`. The columns sum to :math:`N_v`. + +.. math:: + + \mathrm{HISTOGRAM\_BIN\_}j = h_j + +**Possible issues.** ``HISTOGRAM`` is computed only when another intensity feature is computed +too. Requested alone, as ``["HISTOGRAM"]``, all :math:`N_b` columns are 0. This is a known +defect (`#489 `__). + + +.. _feature-hyperflatness: + +Hyperflatness +^^^^^^^^^^^^^ + +:Code: ``HYPERFLATNESS`` +:Range: :math:`[0, \infty)`, dimensionless + +The sixth standardized moment. Like kurtosis it grows with the weight of the tails, but rare +extreme values count even more. + +.. math:: + + \mathrm{HYPERFLATNESS} = \frac{m_6}{\sigma^{6}} + +The value is 0 when :math:`\sigma = 0`. There is no minimum ROI size. + +**Possible issues.** The moment :math:`m_6` divides by :math:`N_v`, but :math:`\sigma` uses +:math:`N_v - 1`, while ``SKEWNESS`` and ``KURTOSIS`` standardize with :math:`\sigma_b`. On the +worked example the value is 5.769; standardizing with :math:`\sigma_b` gives 6.520. The two +converge as :math:`N_v` grows. + + +.. _feature-hyperskewness: + +Hyperskewness +^^^^^^^^^^^^^ + +:Code: ``HYPERSKEWNESS`` +:Range: :math:`(-\infty, \infty)`, dimensionless + +The fifth standardized moment. Its sign tells which tail holds the more extreme values. + +.. math:: + + \mathrm{HYPERSKEWNESS} = \frac{m_5}{\sigma^{5}} + +The value is 0 when :math:`\sigma = 0`. There is no minimum ROI size: a ROI of the three +intensities 1, 2 and 4 gives 0.3464, where ``SKEWNESS`` is 0. + +**Possible issues.** As for ``HYPERFLATNESS``, :math:`m_5` divides by :math:`N_v` and +:math:`\sigma` by :math:`N_v - 1`. On the worked example the value is 1.227; standardizing with +:math:`\sigma_b` gives 1.359. + + +.. _feature-integrated-intensity: + +Integrated intensity +^^^^^^^^^^^^^^^^^^^^ + +:Code: ``INTEGRATED_INTENSITY`` +:Range: :math:`[0, \infty)` for non-negative intensities, intensity units + +The sum of the intensities, the total signal of the ROI. + +.. math:: + + \mathrm{INTEGRATED\_INTENSITY} = \sum_k x_k = N_v\,\mu + + +.. _feature-interquartile-range: + +Interquartile range +^^^^^^^^^^^^^^^^^^^ + +:Code: ``INTERQUARTILE_RANGE`` +:Range: :math:`[0, X_{\max} - X_{\min}]`, intensity units + +The width of the middle half of the distribution. + +.. math:: + + \mathrm{INTERQUARTILE\_RANGE} = P_{75} - P_{25} + +**Possible issues.** Both quartiles come from the 100-bin histogram (see +:ref:`Percentiles `). On the worked example the value is 1.024; exact quartiles give +1. + + +.. _feature-kurtosis: + +Kurtosis +^^^^^^^^ + +:Code: ``KURTOSIS`` +:Range: :math:`[1, \infty)`, dimensionless + +The fourth standardized moment, without subtracting 3; a normal distribution scores 3. This is +Pearson's kurtosis, the value of MATLAB's ``kurtosis`` and of PyRadiomics. + +.. math:: + + \mathrm{KURTOSIS} = \frac{m_4}{m_2^{2}} + +The value is 0 when :math:`N_v < 5` or :math:`m_2 = 0`. + + +.. _feature-max: + +Maximum +^^^^^^^ + +:Code: ``MAX`` +:Range: intensity units + +The largest intensity in the ROI, :math:`X_{\max}`. + + +.. _feature-mean: + +Mean +^^^^ + +:Code: ``MEAN`` +:Range: :math:`[X_{\min}, X_{\max}]`, intensity units + +The average intensity. + +.. math:: + + \mathrm{MEAN} = \mu = \frac{1}{N_v}\sum_k x_k + + +.. _feature-mean-absolute-deviation: + +Mean absolute deviation +^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``MEAN_ABSOLUTE_DEVIATION`` +:Range: :math:`[0, \infty)`, intensity units + +The average distance of the intensities from their mean. + +.. math:: + + \mathrm{MEAN\_ABSOLUTE\_DEVIATION} = \frac{1}{N_v}\sum_k \left|x_k - \mu\right| + + +.. _feature-median: + +Median +^^^^^^ + +:Code: ``MEDIAN`` +:Range: :math:`[X_{\min}, X_{\max}]`, intensity units + +The middle intensity. It is exact: the middle value when :math:`N_v` is odd, and the average of the +two middle values when it is even. Unlike the percentiles, it does not use the histogram. + + +.. _feature-median-absolute-deviation: + +Median absolute deviation +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``MEDIAN_ABSOLUTE_DEVIATION`` +:Range: :math:`[0, \infty)`, intensity units + +The average distance of the intensities from their median. + +.. math:: + + \mathrm{MEDIAN\_ABSOLUTE\_DEVIATION} = \frac{1}{N_v}\sum_k \left|x_k - M\right| + +This is the IBSI definition. It is a mean, not the median of the absolute deviations that +``scipy.stats.median_abs_deviation`` and MATLAB's ``mad(x, 1)`` return: on the worked example +Nyxus gives 0.92 and the median of the absolute deviations is 1. + + +.. _feature-min: + +Minimum +^^^^^^^ + +:Code: ``MIN`` +:Range: intensity units + +The smallest intensity in the ROI, :math:`X_{\min}`. A pixel of intensity 0 inside the mask makes +it 0. + + +.. _feature-mode: + +Mode +^^^^ + +:Code: ``MODE`` +:Range: :math:`[X_{\min}, X_{\max}]`, intensity units + +The most frequent intensity. When several intensities share the highest count, the smallest of +them. It counts exact intensities, not histogram bins, so on an image with many distinct values it +can rest on very few pixels. + + +.. _feature-p01: +.. _feature-p10: +.. _feature-p25: +.. _feature-p75: +.. _feature-p90: +.. _feature-p99: + +Percentiles +^^^^^^^^^^^ + +:Codes: ``P01``, ``P10``, ``P25``, ``P75``, ``P90``, ``P99`` +:Range: :math:`[X_{\min}, X_{\max}]`, intensity units + +The 1st, 10th, 25th, 75th, 90th and 99th percentiles, interpolated within a histogram of 100 +equal-width bins of width :math:`w = (X_{\max} - X_{\min})/100`. Let :math:`n_i` be the count of +bin :math:`i` and :math:`C_i` the number of pixels in bins 0 to :math:`i-1`. For +:math:`q \in \{1, 10, 25, 75, 90, 99\}` the target count is :math:`c = q\,N_v / 100`, and + +.. math:: + + P_q = X_{\min} + w\,i + w\,\frac{c - C_i}{n_i} + +where :math:`i` is the last non-empty bin with :math:`C_i \le c \le C_i + n_i`. Within the bin, its +pixels are taken to be spread evenly over its width. A constant ROI gives :math:`X_{\min}` for +every percentile. + +**Possible issues.** The values are estimates, not exact sample percentiles. On ROIs of 1600 +pixels with intensities drawn from 0 to 4095, they differ from ``numpy.percentile`` (linear +interpolation) by up to a few percent of the ROI's range, most for the extreme percentiles. When the ROI holds few distinct values, a percentile that falls between +two of them is placed inside the bin of the upper one: on the worked example P90 is 4.967 where +the exact value is 4.6, and on ROIs of integers 0 to 9 the difference reached 9 % of the range. +When :math:`c` falls exactly on the end of a bin, the result is the start of the next non-empty +bin. For 5 ones and 15 threes P25 is 2.98; with 6 ones and 14 threes it is 1.017. + + +.. _feature-qcod: + +Quartile coefficient of dispersion +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``QCOD`` +:Range: :math:`[0, 1]` for non-negative intensities, dimensionless + +The interquartile range relative to the sum of the quartiles, a robust counterpart of ``COV``. + +.. math:: + + \mathrm{QCOD} = \frac{P_{75} - P_{25}}{P_{75} + P_{25}} + +The value is 0 when :math:`P_{75} + P_{25} = 0`. On images with negative values it has no +useful meaning. + + +.. _feature-range: + +Range +^^^^^ + +:Code: ``RANGE`` +:Range: :math:`[0, \infty)`, intensity units + +.. math:: + + \mathrm{RANGE} = X_{\max} - X_{\min} + + +.. _feature-robust-mean: + +Robust mean +^^^^^^^^^^^ + +:Code: ``ROBUST_MEAN`` +:Range: :math:`[P_{10}, P_{90}]`, intensity units + +The mean of the intensities between the 10th and the 90th percentile, both included. + +.. math:: + + \mathrm{ROBUST\_MEAN} = \bar{x}_{10\text{-}90} = \frac{1}{N_{10\text{-}90}} + \sum_{P_{10} \le x_k \le P_{90}} x_k + +where :math:`N_{10\text{-}90}` is the number of pixels in that interval. The value is 0 when the +interval holds no pixel. + +**Possible issues.** The bounds are the histogram percentiles. On the worked example +:math:`P_{10} = 1.025` and :math:`P_{90} = 4.967` exclude all 1s and 5s and the mean is 2.833; with +exact percentiles (1 and 4.6) the 1s are kept and the mean is 2.5. + + +.. _feature-robust-mean-absolute-deviation: + +Robust mean absolute deviation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``ROBUST_MEAN_ABSOLUTE_DEVIATION`` +:Range: :math:`[0, \infty)`, intensity units + +The mean absolute deviation of the intensities between :math:`P_{10}` and :math:`P_{90}` from +their own mean. + +.. math:: + + \mathrm{ROBUST\_MEAN\_ABSOLUTE\_DEVIATION} = \frac{1}{N_{10\text{-}90}} + \sum_{P_{10} \le x_k \le P_{90}} \left|x_k - \bar{x}_{10\text{-}90}\right| + +This is the IBSI definition. The value is 0 when the interval holds no pixel. + +**Possible issues.** As for ``ROBUST_MEAN``, the bounds are the histogram percentiles. On the +worked example the value is 0.5556; exact percentiles give 0.8182. + + +.. _feature-root-mean-squared: + +Root mean square +^^^^^^^^^^^^^^^^ + +:Code: ``ROOT_MEAN_SQUARED`` +:Range: :math:`[0, \infty)`, intensity units + +.. math:: + + \mathrm{ROOT\_MEAN\_SQUARED} = \sqrt{\frac{1}{N_v}\sum_k x_k^2} + +It inherits the wrap-around of ``ENERGY`` for grey levels above 65535, a known defect +(`#490 `__): 46538 instead of 60926 for +the worked example times 20000. + + +.. _feature-skewness: + +Skewness +^^^^^^^^ + +:Code: ``SKEWNESS`` +:Range: :math:`(-\infty, \infty)`, dimensionless + +The third standardized moment. Positive values mean a longer tail towards high intensities. + +.. math:: + + \mathrm{SKEWNESS} = \frac{m_3}{m_2^{3/2}} + +The value is 0 when :math:`N_v < 4` or :math:`m_2 = 0`. Apart from the minimum size, this is the +IBSI definition. + + +.. _feature-standard-deviation: + +Standard deviation +^^^^^^^^^^^^^^^^^^ + +:Code: ``STANDARD_DEVIATION`` +:Range: :math:`[0, \infty)`, intensity units + +The sample standard deviation, with :math:`N_v - 1`. + +.. math:: + + \mathrm{STANDARD\_DEVIATION} = \sigma = \sqrt{\frac{1}{N_v - 1}\sum_k (x_k - \mu)^2} + +The value is 0 when :math:`N_v = 1`. + + +.. _feature-standard-deviation-biased: + +Biased standard deviation +^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``STANDARD_DEVIATION_BIASED`` +:Range: :math:`[0, \infty)`, intensity units + +The population standard deviation, with :math:`N_v`. + +.. math:: + + \mathrm{STANDARD\_DEVIATION\_BIASED} = \sigma_b = \sqrt{\frac{1}{N_v}\sum_k (x_k - \mu)^2} + + +.. _feature-standard-error: + +Standard error +^^^^^^^^^^^^^^ + +:Code: ``STANDARD_ERROR`` +:Range: :math:`[0, \infty)`, intensity units + +The standard error of the mean. + +.. math:: + + \mathrm{STANDARD\_ERROR} = \frac{\sigma}{\sqrt{N_v}} + + +.. _feature-uniformity: + +Uniformity +^^^^^^^^^^ + +:Code: ``UNIFORMITY`` +:Range: :math:`[1/N_b, 1]`, dimensionless + +The sum of the squared bin probabilities of the coarse histogram. High values mean the +intensities gather in few bins; a constant ROI gives 1. + +.. math:: + + \mathrm{UNIFORMITY} = \sum_j p_j^2 + +**Possible issues.** Like ``ENTROPY``, it depends on :math:`N_b`: 0.2416 with 64 bins and 0.2704 +with 4 on the worked example. + + +.. _feature-uniformity-piu: + +Percent image uniformity +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``UNIFORMITY_PIU`` +:Range: :math:`[0, 100]` for non-negative intensities, percent + +The percent image uniformity used in MRI quality control. 100 means a constant ROI. + +.. math:: + + \mathrm{UNIFORMITY\_PIU} = 100\left(1 - \frac{X_{\max} - X_{\min}}{X_{\max} + X_{\min}}\right) + +The value is 0 when :math:`X_{\max} + X_{\min} = 0`. On images with negative values it can leave +the range, for example 102.1 for a ROI from −99 to −95. + + +.. _feature-variance: + +Variance +^^^^^^^^ + +:Code: ``VARIANCE`` +:Range: :math:`[0, \infty)`, squared intensity units + +The sample variance, with :math:`N_v - 1`. + +.. math:: + + \mathrm{VARIANCE} = \sigma^2 = \frac{1}{N_v - 1}\sum_k (x_k - \mu)^2 + +The value is 0 when :math:`N_v = 1`. IBSI's intensity variance divides by :math:`N_v`; that is +``VARIANCE_BIASED``. + + +.. _feature-variance-biased: + +Biased variance +^^^^^^^^^^^^^^^ + +:Code: ``VARIANCE_BIASED`` +:Range: :math:`[0, \infty)`, squared intensity units + +The population variance, with :math:`N_v`. This is the IBSI intensity variance and the +PyRadiomics ``Variance``. + +.. math:: + + \mathrm{VARIANCE\_BIASED} = \sigma_b^2 = m_2 = \frac{1}{N_v}\sum_k (x_k - \mu)^2 + +The value is 0 when :math:`N_v = 1`. + + +.. _intensity-possible-issues: + +Possible issues +--------------- + +Histogram percentiles + The six percentiles, and ``INTERQUARTILE_RANGE``, ``QCOD``, ``ROBUST_MEAN`` and + ``ROBUST_MEAN_ABSOLUTE_DEVIATION`` through them, come from a 100-bin histogram rather than from + the sorted intensities. On ROIs with many distinct values they were within a few percent of the + range of the exact values; on ROIs with few distinct values they can differ by most of the gap + between two neighbouring intensities (see :ref:`Percentiles `). ``MEDIAN`` is + exact. + +Mixed standardizations + ``SKEWNESS``, ``KURTOSIS`` and ``EXCESS_KURTOSIS`` standardize with :math:`\sigma_b`, as IBSI and + PyRadiomics do. ``HYPERSKEWNESS`` and ``HYPERFLATNESS`` standardize with :math:`\sigma`, which + uses :math:`N_v - 1`, and ``COV`` divides :math:`\sigma` by the mean where IBSI uses + :math:`\sigma_b`. The differences shrink as :math:`N_v` grows; on the 25-pixel worked example + they are 2 % for ``COV`` and 10 % and 12 % for the 5th and 6th moments. + +Small ROIs + ``SKEWNESS`` is 0 for ROIs of fewer than 4 pixels, and ``KURTOSIS`` and ``EXCESS_KURTOSIS`` for + fewer than 5. ``HYPERSKEWNESS`` and ``HYPERFLATNESS`` have no minimum. ``VARIANCE``, + ``VARIANCE_BIASED`` and both standard deviations are 0 for a single pixel. + +Constant ROI + When all of a ROI's pixels have one intensity, the location features, the percentiles and the + robust mean return that intensity, ``UNIFORMITY`` is 1, ``UNIFORMITY_PIU`` is 100 and + ``HISTOGRAM_BIN_0`` holds every pixel. The dispersions, ``COV``, ``QCOD``, the moments and + entropy are 0; entropy can print as a tiny negative number, about :math:`-3 \times 10^{-16}`. + +``HISTOGRAM`` requested alone + ``HISTOGRAM`` is not part of ``*ALL_INTENSITY*``, and requesting it does not by itself start the + intensity computation. Requested alone, all its columns are 0, a known defect + (`#489 `__). Request it with ``*ALL_INTENSITY*`` + or any other intensity feature, or use ``*ALL*``. + +Covered range of in-memory images + ``COVERED_IMAGE_INTENSITY_RANGE`` is not computed for arrays passed to ``Nyxus.featurize``: it + is 0 in pandas and ``-0.0`` in Arrow and Parquet files. Write the image to a file to get it. + +Out-of-core ROIs of large striped TIFF files + A ROI processed out of core is read from the file again. For a striped (not tiled) TIFF more + than 1024 pixels high or wide, the pixels read back do not match the image, and the features + computed from them differ from the in-memory values (``MIN``, ``MAX``, ``RANGE`` and + ``UNIFORMITY_PIU`` come from the first pass over the image and stay correct): on a + 1200 × 1200 image of four quadrants of 100, 1100, 2100 and 3100, ``MEAN`` was 4764.0 in place + of 1600. Tiled TIFF files of the same size, and striped files of 1024 × 1024 pixels, give the + in-memory values. + +Grey levels above 65535 + ``ENERGY`` and ``ROOT_MEAN_SQUARED`` square each pixel as a 32-bit unsigned integer, a known + defect (`#490 `__), which + wraps for grey levels above 65535 (32-bit integer images, or floating-point images read with + ``--fpimgdr`` above 65535). The other features are computed in floating point and are not + affected. + +In-memory arrays are not in the input's units + ``Nyxus.featurize`` shifts an array with negative values up by its minimum and truncates + fractions, and does not map the features back. For the worked example minus 10, ``MIN`` is 0 + and ``MEAN`` 1.8, not −9 and −7.2. To get features in the original units, read the image from a + file. + +Pixels of intensity 0 inside the mask + They are ROI pixels and take part in every feature. Setting the top-left pixel of the worked + example, a 5, to 0 lowers ``MIN`` to 0 and ``MEAN`` to 2.6. + +Negative intensities + Read from a file, an image with negative values, such as a CT, is reported in its own units. + ``COV``, ``QCOD`` and ``UNIFORMITY_PIU`` are ratios meant for non-negative intensities; with + negative values they can be negative or exceed their usual range. + +A coarse histogram of 0 bins + ``--coarseGrayDepth=0`` on the command line leaves the coarse histogram without bins; the + result of ``ENTROPY``, ``UNIFORMITY`` and ``HISTOGRAM`` is then undefined. Python refuses 0. + Use a positive value with these features. + +Anisotropy on 2D images read from files + ``--anisox``/``--anisoy`` (Python ``anisotropy_x``/``anisotropy_y``) do not change any + intensity feature: they are computed on the pixels as acquired. For 3D anisotropy see + :doc:`f_intensity_3d`. + + +Validation ---------- -Zwillinger, D. (Ed.). CRC Standard Mathematical Tables and Formulae. Boca Raton, FL: CRC Press, p. 602, 1995. +The test suite checks the intensity features against three references: + +- **MATLAB** (R2026a), on test ROIs, for every feature except ``ENTROPY``, ``UNIFORMITY``, + ``ROBUST_MEAN``, ``ROBUST_MEAN_ABSOLUTE_DEVIATION``, ``MEDIAN_ABSOLUTE_DEVIATION`` and + ``HISTOGRAM``: to a relative 10\ :sup:`-3`, and to 3 × 10\ :sup:`-2` for the six percentiles, + ``INTERQUARTILE_RANGE`` and ``QCOD``. +- **PyRadiomics** 3.0.1, for ``ENERGY``, ``ENTROPY``, ``KURTOSIS``, ``MAX``, ``MEAN``, + ``MEAN_ABSOLUTE_DEVIATION``, ``MEDIAN``, ``MIN``, ``RANGE``, + ``ROBUST_MEAN_ABSOLUTE_DEVIATION``, ``ROOT_MEAN_SQUARED``, ``SKEWNESS``, ``UNIFORMITY`` and + ``VARIANCE_BIASED`` to a relative 10\ :sup:`-6`; ``VARIANCE`` to 10\ :sup:`-2`, because + PyRadiomics divides by :math:`N_v`; ``P10``, ``P90`` and ``INTERQUARTILE_RANGE`` to + 5 × 10\ :sup:`-2`. +- **IBSI** published values on the IBSI digital phantom, at their three significant figures + (relative 10\ :sup:`-2`), for ``ENERGY``, ``EXCESS_KURTOSIS``, ``INTERQUARTILE_RANGE``, ``MEAN``, + ``MEAN_ABSOLUTE_DEVIATION``, ``MEDIAN``, ``MIN``, ``P10``, ``P90``, ``RANGE``, + ``ROOT_MEAN_SQUARED`` and ``SKEWNESS``. + +``HISTOGRAM`` is checked against hand-computed bin counts to a relative 10\ :sup:`-4`. +``MEDIAN_ABSOLUTE_DEVIATION`` and ``ROBUST_MEAN`` are regression-only: MATLAB's ``mad`` and +trimmed mean use different definitions. Out-of-core results are checked against in-memory results +of the same build. The worked-example values quoted on this page, the comparisons with the IBSI +standardizations and the CT ``UNIFORMITY_PIU`` example among them, are pinned by the +worked-example script; the error rates of the histogram percentiles and the out-of-core example +were measured once and are not pinned. None of them is compared with a +reference tool. + + +References +---------- +1. Zwanenburg A, et al. *The Image Biomarker Standardization Initiative: standardized quantitative + radiomics for high-throughput image-based phenotyping.* Radiology. 2020;295(2):328–338. + Feature definitions: https://ibsi.readthedocs.io, section *Intensity-based statistical + features*. +2. van Griethuysen JJM, et al. *Computational radiomics system to decode the radiographic + phenotype.* Cancer Research. 2017;77(21):e104–e107. +3. Goerner FL, et al. *A comparison of five standard methods for evaluating image intensity + uniformity in partially parallel imaging MRI.* Medical Physics. 2013;40(8):082302. + https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3745492/ (Percent image uniformity) diff --git a/docs/source/Math/f_radial_distribution.rst b/docs/source/Math/f_radial_distribution.rst new file mode 100644 index 000000000..8b30eadfa --- /dev/null +++ b/docs/source/Math/f_radial_distribution.rst @@ -0,0 +1,589 @@ +Radial distribution features +============================ + +Radial distribution features describe how a ROI's pixels and intensities are spread from its centre +outward. Nyxus picks a centre pixel, divides the ROI into 8 rings around it and each ring into 8 +angular wedges, and reports, per ring, the share of the ROI's pixels, their mean intensity and how +unevenly the intensity is spread over the wedges. + +.. list-table:: + :widths: 26 74 + + * - **Feature codes** + - ``FRAC_AT_D``, ``MEAN_FRAC`` and ``RADIAL_CV``: three codes, 8 columns each per ROI, + ``_0`` (innermost ring) to ``_7``. Requesting any one of them computes all three. + * - **Group** + - ``*ALL*`` and ``*WHOLESLIDE*``. No other group includes them; request them by name. + * - **Dimensions** + - 2D only. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes. Without anisotropy the values differ from the in-memory ones for a ROI whose bounding + box does not start at the image's first row and column, and can differ even when it does, a + known defect (`#509 `__); + with anisotropy an out-of-core ROI gets its in-memory values. On an image that Nyxus reads + as several tiles out-of-core processing raises an error or returns wrong values in both + cases, depending on the ROI (see + :ref:`radial-possible-issues`, :ref:`large-images-tiles` and :doc:`/large_images`). + * - **IBSI** + - None. IBSI defines no radial distribution features. + +.. tip:: + + The three codes carry the names of CellProfiler's ``RadialDistribution_FracAtD``, ``MeanFrac`` + and ``RadialCV`` measurements, but compute different quantities: ``FRAC_AT_D`` is a fraction of + the ROI's *pixels*, not of its intensity, ``MEAN_FRAC`` is a mean intensity in grey levels, not + a ratio near 1, and ``RADIAL_CV`` is taken over wedge sums. The rings are also built + differently. The values cannot be compared with CellProfiler's; see :ref:`radial-other-tools`. + + +How the features are computed +----------------------------- + +Nyxus traces the ROI's contour, chooses the ROI pixel whose distances to the contour vary least as +the centre, takes the largest distance from the centre to the contour as the radius :math:`R`, and +sorts every ROI pixel into a ring by its distance from the centre and into a wedge by its angle. + +- **Which pixels:** every pixel of the ROI, with its intensity. A pixel of intensity 0 inside the + mask counts like any other. +- **Coordinates:** pixel :math:`(x, y)` is column :math:`x` and row :math:`y` of the image, counted + from 0, with :math:`y` growing downward. Distances are in pixels, between pixel centres; the + physical pixel size is not used. Anisotropy (files only) scales each pixel's position by the + factors before distances and angles are taken (see :ref:`conventions-units`). +- **Contour:** the boundary of the ROI as Nyxus's contour tracer returns it, around the outside of + the ROI and around each hole. On compact ROIs such as the worked example it holds every ROI pixel + with a 4-neighbour in the background; on irregular ROIs the tracer skips some of them (350 of 400 + noisy random ROIs in a test). All contours of the ROI are joined into one list, outer contour + first. Every contour point comes back one pixel to the right and one pixel down of the boundary + pixel it stands for, :math:`(x+1, y+1)`; the features use these shifted points, a known defect (`#497 `__; see + :ref:`radial-possible-issues`). +- **Whole-slide mode:** the contour is not traced. It is the four points :math:`(0, 0)`, + :math:`(W, 0)`, :math:`(W, H)` and :math:`(0, H)` of a :math:`W \times H` image, without the + :math:`(+1, +1)` shift. The centre found from them lies on the image's top edge, a known defect + (`#516 `__; see :ref:`radial-possible-issues`). +- **Centre:** the ROI pixel :math:`O` with the smallest spread of squared distances to the contour, + the largest squared distance minus the smallest. The largest and smallest distances are found by + a coarse-to-fine search along the contour list, not by checking every contour point; on many ROIs + the search returns a smaller maximum or a larger minimum than the true ones. When several pixels + share the smallest spread, the first one in the order the pixels were read wins: column by column + for an array given to ``Nyxus.featurize``; tile by tile, and row by row within each tile, for an + image read from a file. +- **Radius:** :math:`R = \sqrt{\max_c |O - c|^2}` over the contour points :math:`c`, with the same + search. +- **Rings:** pixel :math:`p` at distance :math:`d_p = |p - O|` from the centre goes to ring + :math:`k_p = \min(\lfloor 7\, d_p / R \rfloor, 7)`. Rings 0 to 6 are 7 rings of width + :math:`R/7`; ring 7 holds every pixel at distance :math:`R` or more. The number of rings, 8, is + fixed. The index is computed in floating point, so a pixel exactly on a ring boundary can fall + into the ring below. +- **Wedges:** pixel :math:`p` goes to wedge :math:`j_p = \lfloor \theta_p / 45^\circ \rfloor`, where + :math:`\theta_p = \operatorname{atan2}(y_p - y_O,\; x_p - x_O)` taken in :math:`[0^\circ, 360^\circ)`. + Wedge 0 starts in the direction of growing :math:`x` and the wedges follow each other towards + growing :math:`y`, clockwise as the image is displayed. The centre pixel itself lies in wedge 0. +- **Units:** ``FRAC_AT_D`` and ``RADIAL_CV`` are dimensionless. ``MEAN_FRAC`` is an intensity in + grey levels. +- **Intensity domain:** the features are computed on grey levels and not converted back to the + image's units (see :ref:`intensity-units`). ``FRAC_AT_D`` does not read intensities at all. +- **Small constant:** the three feature divisions, by :math:`N`, by :math:`n_k` and by + :math:`\mu_k`, add :math:`\varepsilon = 10^{-9}` to their denominator. The constant is fixed in + the code; ``--tinyval`` does not change it. An empty ring therefore reports 0 in all three + features. +- **ROIs of one intensity** are computed like any other ROI. +- **No contour:** a ROI whose tracer finds no contour, such as a ROI of 1 or 2 pixels, reports 0 + in all 24 columns. +- **IBSI mode:** ``--ibsi=true`` does not change these features. + + +Worked example +^^^^^^^^^^^^^^ + +Take a 47-pixel ROI in a 7 × 9 box at the top-left corner of the image. Every non-zero pixel below +belongs to the ROI, and every zero is background: + +.. math:: + + \textbf{I} = \begin{bmatrix} + 0&0&3&4&4&3&0&0&0\\ + 0&3&5&6&6&5&3&2&0\\ + 2&4&6&8&9&7&5&3&0\\ + 3&5&7&9&9&8&6&4&2\\ + 2&4&6&8&8&7&5&3&2\\ + 0&3&4&6&6&5&4&2&0\\ + 0&0&2&3&3&0&0&0&0 \end{bmatrix} + +The intensities sum to 224. The contour has 20 points, the 20 boundary pixels shifted by +:math:`(+1, +1)`; 9 of them are not ROI pixels. The centre is :math:`O = (5, 4)`, intensity 7: its +squared distances to the contour run from 5 to 17, a spread of 12, the smallest of all pixels; the +next best, :math:`(5, 3)`, has 16. The radius is :math:`R = \sqrt{17} = 4.123`. The ring and the wedge of each pixel are (· is background): + +.. math:: + + k = \begin{bmatrix} + \cdot&\cdot&7&7&7&6&\cdot&\cdot&\cdot\\ + \cdot&7&7&6&5&5&5&6&\cdot\\ + 7&7&6&4&3&3&3&4&\cdot\\ + 7&7&5&3&2&1&2&3&5\\ + 7&6&5&3&1&\mathbf{0}&1&3&5\\ + \cdot&7&5&3&2&1&2&3&\cdot\\ + \cdot&\cdot&6&4&3&\cdot&\cdot&\cdot&\cdot \end{bmatrix} + \qquad + j = \begin{bmatrix} + \cdot&\cdot&5&5&5&6&\cdot&\cdot&\cdot\\ + \cdot&4&5&5&5&6&6&6&\cdot\\ + 4&4&4&5&5&6&6&7&\cdot\\ + 4&4&4&4&5&6&7&7&7\\ + 4&4&4&4&4&\mathbf{0}&0&0&0\\ + \cdot&3&3&3&3&2&1&0&\cdot\\ + \cdot&\cdot&3&3&2&\cdot&\cdot&\cdot&\cdot \end{bmatrix} + +Eight pixels lie farther than :math:`R` from the centre and three exactly at :math:`R`, so ring 7 +holds 11 pixels. Per ring: + +.. list-table:: + :widths: 7 7 8 34 14 14 16 + :header-rows: 1 + + * - Ring + - :math:`n_k` + - :math:`S_k` + - Wedge sums :math:`W_{k0}, \dots, W_{k7}` + - ``FRAC_AT_D`` + - ``MEAN_FRAC`` + - ``RADIAL_CV`` + * - 0 + - 1 + - 7 + - 7, 0, 0, 0, 0, 0, 0, 0 + - 0.02128 + - 7 + - 2.646 + * - 1 + - 4 + - 26 + - 5, 0, 5, 0, 8, 0, 8, 0 + - 0.08511 + - 6.5 + - 1.052 + * - 2 + - 4 + - 25 + - 0, 4, 0, 6, 0, 9, 0, 6 + - 0.08511 + - 6.25 + - 1.079 + * - 3 + - 10 + - 56 + - 5, 0, 3, 6, 17, 9, 12, 4 + - 0.2128 + - 5.6 + - 0.7284 + * - 4 + - 3 + - 14 + - 0, 0, 0, 3, 0, 8, 0, 3 + - 0.06383 + - 4.667 + - 1.532 + * - 5 + - 8 + - 35 + - 2, 0, 0, 4, 13, 6, 8, 2 + - 0.1702 + - 4.375 + - 0.9558 + * - 6 + - 6 + - 23 + - 0, 0, 0, 2, 10, 6, 5, 0 + - 0.1277 + - 3.833 + - 1.223 + * - 7 + - 11 + - 38 + - 0, 0, 0, 3, 19, 16, 0, 0 + - 0.2340 + - 3.455 + - 1.571 + +For ring 3, ``FRAC_AT_D_3`` is :math:`10/47 = 0.2128` and ``MEAN_FRAC_3`` is :math:`56/10 = 5.6`. +The wedge sums have mean :math:`56/8 = 7` and population variance :math:`208/8 = 26`, so +``RADIAL_CV_3`` is :math:`\sqrt{26}/7 = 0.7284`. + +The *Example* value of each feature in the :ref:`index ` is its first column, ring 0. +The test suite runs this example and checks all 24 columns. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/radial_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features=FRAC_AT_D,MEAN_FRAC,RADIAL_CV \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`N` + - Number of pixels in the ROI. + * - :math:`I_p` + - Intensity of ROI pixel :math:`p`, in grey levels. + * - :math:`C` + - The contour points: the boundary pixels the tracer returns, each moved to + :math:`(x+1, y+1)`. + * - :math:`O = (x_O, y_O)` + - The centre pixel. + * - :math:`R` + - The radius, the largest distance from :math:`O` to a contour point that the search finds. + * - :math:`d_p,\; \theta_p` + - Distance and angle of pixel :math:`p` from the centre, :math:`d_p = |p - O|` and + :math:`\theta_p = \operatorname{atan2}(y_p - y_O, x_p - x_O) \in [0, 2\pi)`. + * - :math:`k_p,\; j_p` + - Ring and wedge of pixel :math:`p`: :math:`k_p = \min(\lfloor 7 d_p / R \rfloor, 7)`, + :math:`j_p = \lfloor 4 \theta_p / \pi \rfloor`. + * - :math:`n_k` + - Number of ROI pixels in ring :math:`k`. + * - :math:`S_k` + - Sum of the intensities of the pixels in ring :math:`k`. + * - :math:`W_{kj}` + - Sum of the intensities of the pixels in ring :math:`k` and wedge :math:`j`. + * - :math:`\varepsilon` + - :math:`10^{-9}`, added to the denominator of each of the three features. + + +Settings +-------- + +Only anisotropy changes the centre, the radius, the rings or the wedges. The 8 rings and 8 wedges +are fixed in the code. + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - No effect on these features. + * - Small constant + - ``--tinyval`` + - not available + - No effect; the features use their own :math:`\varepsilon = 10^{-9}`. + * - Image intensity map + - ``--fpimgdr``, ``--fpimgmin``, ``--fpimgmax``, ``--preserve-hu`` + - ``Nyxus(..., preserve_hu=True)`` and the matching keywords + - Decide how a file's intensities become grey levels (see :ref:`intensity-units`). + ``MEAN_FRAC`` follows the grey levels; a map with an offset also changes ``RADIAL_CV``. + ``FRAC_AT_D`` does not change. + * - Anisotropy + - ``--anisox``, ``--anisoy`` + - ``Nyxus(..., anisotropy_x=..., anisotropy_y=...)`` + - Files only. The pixels are placed at their positions scaled by the factors, so all three + features change. Equal factors on both axes change nothing. + * - Memory limit + - ``--ramLimit`` + - ``set_params(ram_limit=...)`` + - Decides whether a ROI is processed out-of-core, which, without anisotropy, changes the values + of most ROIs (see :ref:`radial-possible-issues`). + +All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +Each code produces 8 columns, one per ring, named with the code and the ring number: +``FRAC_AT_D_0`` to ``FRAC_AT_D_7``, ``MEAN_FRAC_0`` to ``MEAN_FRAC_7`` and ``RADIAL_CV_0`` to +``RADIAL_CV_7``, 24 in all. Ring 0 is the innermost; ring 7 holds the pixels at distance :math:`R` +or more. The names are the same in CSV, Arrow and Parquet output and in the tables Python returns. +There are no ``_AVE`` columns. + + +.. _radial-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example, for the first column, ring 0. + +.. include:: radial_index.inc + + +Features +-------- + +.. _feature-frac-at-d: + +Fraction of pixels in a ring +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``FRAC_AT_D`` (columns ``FRAC_AT_D_0`` to ``FRAC_AT_D_7``) +:Range: :math:`[0, 1)`, dimensionless; the 8 columns sum to :math:`N/(N+\varepsilon)`, 1 up to + :math:`10^{-9}/N` + +The share of the ROI's pixels that fall in ring :math:`k`. It depends on the mask only: the +intensities are never read. + +.. math:: + + \mathrm{FRAC\_AT\_D\_}k = \frac{n_k}{N + \varepsilon} + +**Relations.** :math:`N \cdot` ``FRAC_AT_D_k`` is the whole number :math:`n_k`, up to rounding. +:math:`\sum_k N \cdot \mathrm{FRAC\_AT\_D\_}k \cdot \mathrm{MEAN\_FRAC\_}k` is the ROI's total +intensity, 224 on the worked example. + +**Possible issues.** CellProfiler's ``FracAtD`` is a fraction of the ROI's intensity; see +:ref:`radial-other-tools`. + + +.. _feature-mean-frac: + +Mean intensity in a ring +^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``MEAN_FRAC`` (columns ``MEAN_FRAC_0`` to ``MEAN_FRAC_7``) +:Range: between the smallest and the largest intensity of the ROI for a non-empty ring, 0 for an + empty ring; grey levels + +The mean intensity of the pixels in ring :math:`k`. Despite its name it is not a fraction and is +not normalized by the ROI's mean intensity. + +.. math:: + + \mathrm{MEAN\_FRAC\_}k = \frac{S_k}{n_k + \varepsilon} + +``MEAN_FRAC_0`` is the intensity of the centre pixel whenever ring 0 holds the centre alone, which +is the case for every ROI with :math:`R \le 7` without anisotropy. + +**Possible issues.** CellProfiler's ``MeanFrac`` divides the ring's share of the intensity by its +share of the pixels, and is close to 1; see :ref:`radial-other-tools`. + + +.. _feature-radial-cv: + +Coefficient of variation across the wedges of a ring +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:Code: ``RADIAL_CV`` (columns ``RADIAL_CV_0`` to ``RADIAL_CV_7``) +:Range: :math:`[0, \sqrt{7}]`, dimensionless + +How unevenly ring :math:`k`'s intensity is spread over the 8 wedges: the population standard +deviation of the 8 wedge sums divided by their mean. All 8 wedges are used, including those that +hold no pixel of the ring. + +.. math:: + + \mu_k = \frac{1}{8} \sum_{j=0}^{7} W_{kj}, + \qquad + \sigma_k = \sqrt{\frac{1}{8} \sum_{j=0}^{7} \left(W_{kj} - \mu_k\right)^2}, + \qquad + \mathrm{RADIAL\_CV\_}k = \frac{\sigma_k}{\mu_k + \varepsilon} + +A ring whose intensity lies in one wedge reports :math:`\sqrt{7} = 2.646`, the largest value; a +ring with equal sums in all 8 wedges reports 0. ``RADIAL_CV_0`` is :math:`\sqrt{7}` whenever ring +0 holds the centre alone and the centre's intensity is not 0. A ring whose intensities are all 0, +and an empty ring, report 0. + +**Possible issues.** Because the sums are taken over all 8 wedges, a ring that covers only part of +the circle, as rings near the edge of a non-round ROI do, reports a large value even when its +intensity is uniform: the 5 × 5 square of one intensity reports 1.183 in ring 7. CellProfiler +averages wedge means over the occupied wedges only; see :ref:`radial-other-tools`. + + +.. _radial-possible-issues: + +Possible issues +--------------- + +The contour is one pixel off + The contour points stand one pixel to the right of and one pixel below the ROI's boundary, a + known defect (`#497 `__), so the centre is pulled down and to the right, and :math:`R` is measured to points outside the + ROI. On the worked example 9 of the 20 contour points are not ROI pixels. With the contour on + the boundary pixels themselves, the formulas of this page give the centre :math:`(4, 3)`, of + intensity 9, instead of :math:`(5, 4)`: ``MEAN_FRAC_0`` would be 9 instead of 7, ring 7 would + hold 3 pixels instead of 11, and ``RADIAL_CV_3`` would be 0.3709 instead of 0.7284. On a 3 × 3 + square with intensities 1 to 9 row by row, Nyxus takes the bottom-right corner as the centre: + ``MEAN_FRAC_0`` is 9 and the rings hold 1, 2 and 6 pixels, where the middle pixel, 5, and rings + of 1, 4 and 4 pixels would be expected. + +The centre and the radius come from an approximate search + The largest and smallest distances to the contour are found by stepping along the contour list + and refining around the best step, which assumes the distance rises and falls only once along + it. Around a hole the list is two contours joined end to end, and even on a convex ROI the + distance to the boundary has more than one minimum. Against a full scan of the same contour, + the search changes the centre or the radius on 97 of 300 random ellipses, 249 of 451 random + ROIs without holes and 836 of 1020 random ROIs with holes. On a 7 × 8 ROI with a 2-pixel hole + in the example script, the search finds :math:`R^2 = 18` where the farthest contour point is at + 20, so 11 pixels instead of 8 lie beyond :math:`R`, and ``FRAC_AT_D_4`` is 0.08 instead of + 0.14. The worked example is not affected. + +Ring 7 collects everything at or beyond R + The ring index is scaled by 7, not 8: rings 0 to 6 share the distances below :math:`R`, and + ring 7 receives the pixels at :math:`R` or farther. Because the contour is shifted and the + search can stop short, pixels beyond :math:`R` are common, and ring 7 is often the most + populated ring: 11 of the 47 pixels on the worked example. + +Ties between centre candidates depend on the pixel order + When several pixels share the smallest spread, the first one read wins, and an array given to + ``Nyxus.featurize`` is read in a different order from the same image read from a file. For a + 10-pixel ROI with two tied candidates, ``MEAN_FRAC_0`` is 9 for the array and 2 for the same + image read from a file. + +Out-of-core ROIs + Without anisotropy, an out-of-core ROI keeps its contour relative to its bounding box but its + pixels in image coordinates, a known defect (`#509 `__). A ROI whose bounding box does not start at the image's first row + and column gets different values: the worked example moved 2 pixels right and 3 down reports + ``FRAC_AT_D_7`` 0.3404 out-of-core instead of 0.2340, and ``MEAN_FRAC_0`` 6 instead of 7. A ROI + whose bounding box starts there is placed correctly, but the out-of-core contour can list its + points in a different order, which changes the approximate searches: the worked example at the + origin gets the in-memory values, while 2 of 15 random ellipses at the origin did not. With anisotropy an + out-of-core ROI is measured as in memory and gets the in-memory values. + +Small ROIs + A ROI of 1 or 2 pixels has no contour, and all 24 columns are 0. + +Whole-slide centre on the top edge + In whole-slide mode the centre is not the middle of the image but a pixel of the top rows near + the right edge, a known defect (`#516 `__). On the four corners of the contour the + coarse-to-fine search compares only :math:`(0, 0)` and :math:`(W, H)`, so every pixel as far + from one as from the other has a spread of 0, and the first one read wins. The centre is + :math:`(19, 1)` on a 20 × 20 image, with :math:`R = 19.03` and 11 pixels in ring 0, and + :math:`(50, 0)` on a 64 × 48 image, with :math:`R = 50` and 88 pixels in ring 0; the outer + rings then cover only part of the image. + +Intensity domain + ``MEAN_FRAC`` is reported in grey levels, not in the image's units. An array with negative + values given to ``Nyxus.featurize`` is shifted up by its minimum first, which shifts + ``MEAN_FRAC`` and changes ``RADIAL_CV`` (see :ref:`intensity-units`). + +.. _radial-other-tools: + +Other tools + CellProfiler's ``MeasureObjectIntensityDistribution``, with 8 scaled bins, differs at every + step. Its centre is the pixel farthest from the background; its distances from the centre are + geodesic, measured inside the object; each pixel's radial position is + :math:`d_c / (d_c + d_e + 0.001)`, with :math:`d_e` its own distance to the edge, so the 8 rings + follow the object's shape; and the ring index is scaled by 8. ``FracAtD`` is the ring's share of + the object's intensity, ``MeanFrac`` is ``FracAtD`` divided by the ring's share of the pixels, + and ``RadialCV`` is the coefficient of variation of the mean intensities of the occupied + octants. On the worked example, CellProfiler 4.2.8 takes :math:`(3, 3)` as the centre, puts 1, 1, + 4, 7, 4, 15, 15 and 0 pixels in its rings, and reports: + + .. list-table:: + :widths: 8 15 15 15 15 16 16 + :header-rows: 1 + + * - Ring + - ``FRAC_AT_D`` + - ``FracAtD`` + - ``MEAN_FRAC`` + - ``MeanFrac`` + - ``RADIAL_CV`` + - ``RadialCV`` + * - 0 + - 0.02128 + - 0.04018 + - 7 + - 1.888 + - 2.646 + - 0 + * - 1 + - 0.08511 + - 0.04018 + - 6.5 + - 1.888 + - 1.052 + - 0 + * - 2 + - 0.08511 + - 0.1429 + - 6.25 + - 1.679 + - 1.079 + - 0.08839 + * - 3 + - 0.2128 + - 0.2009 + - 5.6 + - 1.349 + - 0.7284 + - 0.1545 + * - 4 + - 0.06383 + - 0.1161 + - 4.667 + - 1.364 + - 1.532 + - 0.06281 + * - 5 + - 0.1702 + - 0.2812 + - 4.375 + - 0.8812 + - 0.9558 + - 0.2079 + * - 6 + - 0.1277 + - 0.1786 + - 3.833 + - 0.5595 + - 1.223 + - 0.2041 + * - 7 + - 0.2340 + - 0 + - 3.455 + - 0 + - 1.571 + - 0 + + All 24 values differ by more than 1%. No setting makes Nyxus compute CellProfiler's + quantities. + + +Validation +---------- + +The three features are regression-only. The test suite pins all 24 columns at a relative +:math:`10^{-9}` on a 26-pixel concave ROI with a hole, and checks properties that hold for any +definition of the rings: ``FRAC_AT_D`` sums to 1, an empty ring is 0 in all three features, and +``RADIAL_CV`` stays within :math:`\sqrt{7}`. CellProfiler ``MeasureObjectIntensityDistribution`` +was run on the same ROI as a candidate reference and disagrees, for the reasons under +:ref:`radial-other-tools`; it validates none of the features. The worked-example script asserts +the values of the worked example, the contour offset, the 5 × 5 and 3 × 3 squares, the ROI with a +hole (:math:`R^2 = 18` against 20, 11 pixels beyond :math:`R` instead of 8), the tie between +centre candidates and the out-of-core values of the moved and unmoved worked example. It does not +assert the CellProfiler values or the counts over random ROIs (search changes, tracer skips, +out-of-core differences at the origin); these were measured separately. + + +References +---------- + +1. Carpenter AE, Jones TR, Lamprecht MR, et al. *CellProfiler: image analysis software for + identifying and quantifying cell phenotypes.* Genome Biology. 2006;7(10):R100. + doi:10.1186/gb-2006-7-10-r100 +2. McQuin C, Goodman A, Chernyshev V, et al. *CellProfiler 3.0: next-generation image processing + for biology.* PLoS Biology. 2018;16(7):e2005970. doi:10.1371/journal.pbio.2005970 diff --git a/docs/source/Math/f_zernike.rst b/docs/source/Math/f_zernike.rst index b424b7c48..863bc2f37 100644 --- a/docs/source/Math/f_zernike.rst +++ b/docs/source/Math/f_zernike.rst @@ -1,61 +1,461 @@ - Zernike moment features ======================= -Zernike moments of order n with repetition m for an image function :math:`f(x,y)` defined on a square somain :math:`N \times N` -are defined as +Zernike moments describe how a ROI's intensity is spread over a disk laid on the ROI. Each moment +projects the intensity onto one Zernike polynomial, a complex function of radius and angle on the +unit disk, and Nyxus reports the magnitude of each projection. Low orders capture coarse, broad +patterns and high orders fine radial and angular detail. -.. math:: - A_{nm} = \frac{n+1}{\pi} \underset{x^2+y^2 \le 1} {\int \int} f(x,y) V^*_{nm}(x,y) dxdy +.. list-table:: + :widths: 26 74 + * - **Feature codes** + - ``ZERNIKE2D``: one code, 30 columns per ROI, ``ZERNIKE2D_Z0`` to ``ZERNIKE2D_Z29`` + * - **Group** + - ``*ALL*`` and ``*WHOLESLIDE*``. No other group includes it; request it by name. + * - **Dimensions** + - 2D only. + * - **GPU** + - No; computed on the CPU in every build. + * - **Out-of-core** + - Yes, with the same values as in memory on an image that Nyxus reads as one tile. On other + images out-of-core processing of this family raises an error or returns wrong values, + depending on the ROI (see :ref:`large-images-tiles`). + * - **IBSI** + - None. IBSI defines no Zernike features. -Consider a set of orthogonal functions with simple rotation properties which forms a complete -orthogonal set over the interior of the unit circle. The form of these polynomials is +.. tip:: -.. math:: - V_{nm} (x,y) = V_{nm} (\rho \:\text{sin} \theta, \rho \:\text{cos} \theta) = R_{nm} (\rho) e^{j m \theta} + The moments are weighted by intensity, not by shape. A ROI of one intensity, such as a binary + image measured inside its own foreground, returns the ``--noval`` value, 0 by default, in every + column (see :ref:`zernike-possible-issues`). The values also depend on the disk Nyxus chooses, + which differs from CellProfiler's, so they cannot be compared with CellProfiler's + ``ZernikeMagnitude`` features. -where :math:`V^*_{nm}` is the complex conjugate of the complex polynomials :math:`V_{nm}(x,y)` -.. math:: - V_{nm}(x,y)=R_{nm}(r) e^{j m \theta} +How the features are computed +----------------------------- -where :math:`r=\sqrt{x^2+y^2}`, :math:`0 \leqslant r \leqslant 1`, :math:`\sqrt{-1}`, :math:`n \geqslant 0`, -:math:`|m| \leqslant n`, :math:`n-m=even`, and :math:`\theta = \text{arctg} \frac{y}{x}`. +Nyxus takes the ROI's bounding box as an image in which pixels outside the mask hold 0. It lays a +unit disk on that image and sums the intensity-weighted Zernike polynomials over the pixels inside +the disk. -Zernike real valued radial polynomials :math:`R_{nm}(r)` are given by +- **Which pixels:** every pixel of the bounding box whose centre lies inside the disk, at a + normalized distance :math:`\rho \le 1` from the disk centre. A pixel outside the mask, or inside + it with intensity 0, has weight 0. A zero pixel inside the mask still counts for the bounding + box, and so for the radius, and it stops a ROI of otherwise one intensity from being treated as + constant. A pixel + whose centre coincides with the disk centre, :math:`\rho < 2.2 \times 10^{-16}`, is left out. +- **The disk:** its centre is the intensity centroid :math:`(\bar{x}, \bar{y})` of the bounding box + image. Its radius :math:`R` is the smaller side of the bounding box, in pixels: the full side, + not half of it. A compact ROI therefore fills only the inner part of the disk, and the ends of an + elongated ROI can fall outside it. +- **Coordinates:** pixel :math:`(i, j)`, column :math:`i` and row :math:`j` counted from 1 within + the bounding box, maps to :math:`x = (i - \bar{x})/R` and :math:`y = (j - \bar{y})/R`. The + angle is :math:`\theta = \operatorname{atan2}(y, x)`, with :math:`y` growing downward. + Anisotropy (files only) multiplies the column and row offsets, and the bounding-box sides that + set :math:`R`, by the factors (see :ref:`conventions-units`). +- **Values:** each pixel weighs :math:`w = I/\sum I`, its intensity divided by the sum of the + intensities of all ROI pixels, including those that fall outside the disk. No pixel-area factor + is applied; the weights take its place. +- **Orders:** all moments with order :math:`n` from 0 to 9 and repetition :math:`m` from 0 to + :math:`n` with :math:`n - m` even, 30 in all. The order 9 is fixed in the code. +- **Units:** dimensionless. Scaling the intensities by a constant does not change the values. +- **Intensity domain:** the moments are computed on grey levels and not converted back to the + image's units (see :ref:`intensity-units`). A map that shifts the intensities, such as the + offset applied to a CT image, changes the weights and so the values. +- **IBSI mode:** ``--ibsi=true`` does not change these features. -.. math:: - R_{nm}(r) = \underset{k=|m| \atop \: n-k=even} {\sum ^n} B_{nmk}r^k -where +Worked example +^^^^^^^^^^^^^^ + +Take the 5 × 5 ROI of the texture pages, with every pixel in the mask: .. math:: - B_{nmk} = \frac{ -1^{\frac{n-k}{2}}(\frac{n+k}{2})! } { (\frac{n-k}{2})! (\frac{k+m}{2})! (\frac{k-m}{2})! } -Approximating the double integration for the discrete image function on the domain of size :math:`N \times N`, we get + \textbf{I} = \begin{bmatrix} + 5 & 2 & 5 & 4 & 4\\ + 3 & 3 & 3 & 1 & 3\\ + 2 & 1 & 1 & 1 & 3\\ + 4 & 2 & 2 & 2 & 3\\ + 3 & 5 & 3 & 3 & 2 \end{bmatrix} -.. math:: - \hat Z_{nm} = \frac {n+1}{\pi} \sum _{i=0}^{N-1} \underset{x_i^2+y_j^2 \leqslant 1}{\sum _{j=0}^{N-1}} f(x_i,y_j)V_{nm}^* (x_i,y_j) \delta a +The intensities sum to 70. The intensity centroid is :math:`\bar{x} = 204/70 = 2.9143` and +:math:`\bar{y} = 202/70 = 2.8857`, and the radius is :math:`R = 5`. The farthest pixel centre lies +at :math:`\rho = 0.59`, so all 25 pixels are inside the disk and none sits on the centre. + +Because every weight is inside the disk, :math:`A_{00} = \frac{1}{\pi}\sum w = 1/\pi = 0.3183`. +Because the disk is centred on the intensity centroid, :math:`A_{11}` is the first moment about +the centroid and vanishes; Nyxus returns a rounding residue of order :math:`10^{-17}`. + +.. list-table:: + :widths: 13 9 18 13 9 18 13 9 18 + :header-rows: 1 + + * - Column + - (n, m) + - Value + - Column + - (n, m) + - Value + - Column + - (n, m) + - Value + * - ``Z0`` + - (0, 0) + - 0.3183 + - ``Z10`` + - (5, 3) + - 0.03889 + - ``Z20`` + - (8, 0) + - 0.5813 + * - ``Z1`` + - (1, 1) + - 0 + - ``Z11`` + - (5, 5) + - 0.0009361 + - ``Z21`` + - (8, 2) + - 0.1047 + * - ``Z2`` + - (2, 0) + - 0.6051 + - ``Z12`` + - (6, 0) + - 0.3727 + - ``Z22`` + - (8, 4) + - 0.2508 + * - ``Z3`` + - (2, 2) + - 0.01349 + - ``Z13`` + - (6, 2) + - 0.08806 + - ``Z23`` + - (8, 6) + - 0.002560 + * - ``Z4`` + - (3, 1) + - 0.009830 + - ``Z14`` + - (6, 4) + - 0.1363 + - ``Z24`` + - (8, 8) + - 0.004304 + * - ``Z5`` + - (3, 3) + - 0.009666 + - ``Z15`` + - (6, 6) + - 0.0003018 + - ``Z25`` + - (9, 1) + - 0.08618 + * - ``Z6`` + - (4, 0) + - 0.2363 + - ``Z16`` + - (7, 1) + - 0.07493 + - ``Z26`` + - (9, 3) + - 0.05453 + * - ``Z7`` + - (4, 2) + - 0.04984 + - ``Z17`` + - (7, 3) + - 0.06683 + - ``Z27`` + - (9, 5) + - 0.009942 + * - ``Z8`` + - (4, 4) + - 0.03165 + - ``Z18`` + - (7, 5) + - 0.001380 + - ``Z28`` + - (9, 7) + - 0.01636 + * - ``Z9`` + - (5, 1) + - 0.03977 + - ``Z19`` + - (7, 7) + - 0.002329 + - ``Z29`` + - (9, 9) + - 0.0004792 + +The *Example* value in the :ref:`index ` is ``ZERNIKE2D_Z0``. The test suite runs +this example and checks all 30 columns. + +.. tab-set:: + + .. tab-item:: Python + + .. literalinclude:: ../../examples/zernike_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --features=ZERNIKE2D \ + --intDir=int/ --segDir=seg/ --outDir=out/ --outputType=singlecsv + + +Notation +-------- + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Symbol + - Meaning + * - :math:`I_{ij}` + - Intensity of the bounding-box pixel in column :math:`i` and row :math:`j`, both counted + from 1; 0 outside the mask. + * - :math:`W,\; H` + - Width and height of the ROI's bounding box, in pixels. + * - :math:`\bar{x},\; \bar{y}` + - Intensity centroid, :math:`\bar{x} = \sum i\,I_{ij} / \sum I_{ij}` and + :math:`\bar{y} = \sum j\,I_{ij} / \sum I_{ij}`, over all bounding-box pixels. + * - :math:`R` + - Disk radius, :math:`R = \min(W, H)`. + * - :math:`\rho_{ij},\; \theta_{ij}` + - Polar coordinates of the pixel on the disk, from :math:`x = (i - \bar{x})/R` and + :math:`y = (j - \bar{y})/R`: :math:`\rho = \sqrt{x^2 + y^2}`, + :math:`\theta = \operatorname{atan2}(y, x)`. + * - :math:`w_{ij}` + - Pixel weight, :math:`w_{ij} = I_{ij} / \sum I_{ij}`. + * - :math:`D` + - The pixels used: those with :math:`\varepsilon \le \rho_{ij} \le 1`, where + :math:`\varepsilon = 2.2 \times 10^{-16}` is the double-precision machine epsilon. + * - :math:`n,\; m` + - Order and repetition of a moment: :math:`0 \le m \le n \le 9`, :math:`n - m` even. + * - :math:`R_{nm}(\rho)` + - Zernike radial polynomial of order :math:`n` and repetition :math:`m`. + * - :math:`A_{nm}` + - Complex Zernike moment. + + +Settings +-------- + +Only anisotropy changes the disk. The weights and the order 9, and with it the 30 columns, are +fixed in the code. + +.. list-table:: + :widths: 16 28 26 30 + :header-rows: 1 + + * - Setting + - Command line + - Python + - Effect + * - Value for a constant ROI + - ``--noval=0`` + - not available; always 0 + - Written to all 30 columns when every ROI pixel has the same intensity. + * - IBSI mode + - ``--ibsi=true`` + - ``Nyxus(..., ibsi=True)`` + - No effect on these features. + * - Anisotropy + - ``--anisox``, ``--anisoy`` + - ``Nyxus(..., anisotropy_x=..., anisotropy_y=...)`` + - Files only. The pixels are used as acquired, each at its position scaled by the factors, and + :math:`R` is the smaller side of the bounding box in the same units, so the values change. + Equal factors on both axes change nothing. + * - Image intensity map + - ``--fpimgdr``, ``--fpimgmin``, ``--fpimgmax``, ``--preserve-hu`` + - ``Nyxus(..., preserve_hu=True)`` and the matching keywords + - Decide how a file's intensities become grey levels (see :ref:`intensity-units`). A map with + an offset changes the weights and so the moments; a pure scaling does not. + +All options are listed in the :ref:`cli-reference`. + + +Output columns +-------------- + +``ZERNIKE2D`` produces 30 columns, ``ZERNIKE2D_Z0`` to ``ZERNIKE2D_Z29``. Column ``ZERNIKE2D_Zk`` +holds :math:`|A_{nm}|` for the :math:`k`-th pair :math:`(n, m)` taken in order of increasing +:math:`n`, then increasing :math:`m`, keeping pairs with :math:`n - m` even: + +.. list-table:: + :widths: 12 88 + :header-rows: 1 + + * - :math:`n` + - Columns + * - 0 + - ``Z0`` (0, 0) + * - 1 + - ``Z1`` (1, 1) + * - 2 + - ``Z2`` (2, 0), ``Z3`` (2, 2) + * - 3 + - ``Z4`` (3, 1), ``Z5`` (3, 3) + * - 4 + - ``Z6`` (4, 0), ``Z7`` (4, 2), ``Z8`` (4, 4) + * - 5 + - ``Z9`` (5, 1), ``Z10`` (5, 3), ``Z11`` (5, 5) + * - 6 + - ``Z12`` (6, 0), ``Z13`` (6, 2), ``Z14`` (6, 4), ``Z15`` (6, 6) + * - 7 + - ``Z16`` (7, 1), ``Z17`` (7, 3), ``Z18`` (7, 5), ``Z19`` (7, 7) + * - 8 + - ``Z20`` (8, 0), ``Z21`` (8, 2), ``Z22`` (8, 4), ``Z23`` (8, 6), ``Z24`` (8, 8) + * - 9 + - ``Z25`` (9, 1), ``Z26`` (9, 3), ``Z27`` (9, 5), ``Z28`` (9, 7), ``Z29`` (9, 9) + +Only non-negative repetitions are reported: :math:`|A_{n,-m}| = |A_{nm}|`. The phases are not +written. + + +.. _zernike-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with its tolerance; see the :doc:`/featurelist` for what the categories mean. +*Example* is the value on the worked example, for ``ZERNIKE2D_Z0``. + +.. include:: zernike_index.inc -where :math:`\delta A = dxdy` is an elemental area of the normalized square image in discrete form when a square image of -any size is mapped on the unit disk. If the image is square-shaped and :math:`R = \frac {N}{\sqrt{2}}` is the -enclosing circle radius, then :math:`\delta A = \frac{1}{R^2}`. Features -------- -A set of features with prefix ZERNIKE2D is calculated. In the source code, the order is controlled with class ZernikeFeature's member constant ZernikeFeature::ZERNIKE2D_ORDER (default value: 9), -and the number of repetitions :math:`m` is controlled via constant ZernikeFeature::NUM_FEATURE_VALS: -ZernikeFeature::NUM_FEATURE_VALS :math:`=` ZernikeFeature::ZERNIKE2D_ORDER :math:`\times m`. +.. _feature-zernike2d: +Zernike moments +^^^^^^^^^^^^^^^ +:Code: ``ZERNIKE2D`` (columns ``ZERNIKE2D_Z0`` to ``ZERNIKE2D_Z29``) +:Range: :math:`[0, (n+1)/\pi]` for column :math:`(n, m)`, dimensionless; ``Z0`` lies in + :math:`[0, 1/\pi]` -References +The magnitude of the projection of the ROI's normalized intensity onto the Zernike polynomial +:math:`V_{nm}(\rho, \theta) = R_{nm}(\rho)\, e^{i m \theta}`, over the unit disk on the intensity +centroid. Moments with :math:`m = 0` measure how the intensity is spread radially; moments with +:math:`m > 0` measure an :math:`m`-fold angular pattern. + +.. math:: + + R_{nm}(\rho) = \sum_{s=0}^{(n-m)/2} (-1)^s + \frac{(n-s)!}{s!\,\left(\frac{n+m}{2}-s\right)!\,\left(\frac{n-m}{2}-s\right)!}\,\rho^{\,n-2s} + +.. math:: + + A_{nm} = \frac{n+1}{\pi} \sum_{(i,j) \in D} w_{ij}\, R_{nm}(\rho_{ij})\, e^{-i m \theta_{ij}}, + \qquad + \mathrm{ZERNIKE2D\_Z}k = |A_{nm}| + +The code evaluates :math:`R_{nm}` with a three-term recurrence in :math:`m` (Singh and Walia) +rather than with the factorial sum; the two agree to about :math:`10^{-13}` relative. The sign of +the imaginary part does not matter, since only the magnitude is reported. + +**Relations.** :math:`\pi\,|A_{00}|` is the share of the ROI's intensity that falls inside the +disk, 1 when no pixel is left out. :math:`|A_{11}|` is 0, up to rounding, when no pixel is left +out, because the disk is centred on the intensity centroid. Rotating the ROI by a multiple of +90°, or mirroring it, leaves every value unchanged. + +**Possible issues.** The values depend on the disk convention; see +:ref:`zernike-possible-issues`. + + +.. _zernike-possible-issues: + +Possible issues +--------------- + +Intensity, not shape + The weights are intensities. A ROI of one intensity returns the ``--noval`` value, 0 by + default, in all 30 columns, and so does a binary image measured inside its own foreground. The + same 0/1 image measured with a mask that also covers its background is not constant: a 3 × 5 + block of 1s in a 7 × 7 mask gives ``Z0`` 0.2971 and ``Z2`` 0.7873. Moments of the ROI's shape + are not available from this feature. A pixel of intensity 0 inside the mask has weight 0, like + a pixel outside it, but it still extends the bounding box, which sets the radius, and a ROI + whose other pixels share one intensity is then computed rather than given the ``--noval`` + value. The worked example with its last row set to 0 gives ``Z2`` 0.5122 when that row is + outside the mask and 0.6716 when it is inside. + +The disk is larger than the ROI + The radius is the full smaller side of the bounding box, not half of it, and not the radius of + an enclosing circle. On the worked example the farthest pixel sits at :math:`\rho = 0.59`, so + the outer part of the disk, where high-order polynomials vary most, holds no pixels. + +Elongated ROIs lose their ends + When the bounding box is much longer than wide, pixels farther than the short side from the + centroid fall outside the disk. They are dropped from the sums but kept in :math:`\sum I`, so + :math:`\pi\,|A_{00}| < 1` and :math:`|A_{11}| > 0`. For a ROI with a 3 × 9 bounding box in the + example script, 5 of the 19 ROI pixels, holding 23 of the total intensity of 93, are dropped: + ``Z0`` is :math:`(70/93)/\pi = 0.2396` and ``Z1`` is 0.09145. + +A pixel on the disk centre is dropped + A pixel whose centre coincides with the intensity centroid is skipped, although it belongs to + every moment with :math:`m = 0`. This is a known defect (`#503 `__). For the symmetric 3 × 3 image with rows 1 2 1, 2 4 2, 1 2 1, + the centre pixel holds a quarter of the intensity: ``Z0`` is :math:`0.75/\pi = 0.2387` instead + of :math:`1/\pi`, and ``Z2`` is 0.5040 instead of 0.7427. Moments with :math:`m > 0` do not + change, since :math:`R_{nm}(0) = 0` for them. Only ROIs whose centroid falls exactly on a pixel + centre are affected. + +Rotation by other angles + The radius comes from the bounding box, which changes when a ROI is rotated by an angle that is + not a multiple of 90°. The values are then not rotation invariant, even though the magnitudes of + Zernike moments on a fixed disk are. + +Intensity offsets + The weights :math:`I/\sum I` do not change when the intensities are scaled, but they do when an + offset is added. Adding 100 to the worked example changes ``Z2`` from 0.6051 to 0.6481. Images + with negative values are shifted to non-negative grey levels before the moments are computed + (see :ref:`intensity-units`). + +Other tools + CellProfiler's ``MeasureObjectIntensityDistribution`` centres the disk on the object's minimum + enclosing circle and uses its radius, sums the raw intensities and divides by the number of + pixels, without the :math:`(n+1)/\pi` factor. Its ``MeasureObjectSizeShape`` Zernikes use the + binary mask on the same circle. ``mahotas.features.zernike_moments`` takes the centre and radius + as arguments, keeps the pixel on the centre and divides by the intensity inside the disk instead + of the ROI's total. + + +Validation ---------- -A. Tahmasbi, F. Saki, S.B. Shokouhi. Classification of benign and malignant masses based on Zernike moments. -Comput Biol Med. 2011 Aug;41(8): 726-35. doi: 10.1016/j.compbiomed.2011.06.009. Epub 2011 Jul 1. PMID: 21722886. +The test suite checks all 30 columns against the closed form: the factorial sum for +:math:`R_{nm}` evaluated directly, at Nyxus's disk centre and radius, on a 26-pixel concave ROI with +a hole. They agree to a relative :math:`10^{-12}` plus an absolute :math:`10^{-15}`, which +matters only for ``Z1``, whose true value is 0; the largest relative difference measured is +:math:`3.4 \times 10^{-15}`. The check covers the moments given the disk; the disk convention +itself is pinned by separate tests and has no external reference. CellProfiler's Zernike +features are not comparable, for the reasons under :ref:`zernike-possible-issues`. Out-of-core +results are checked against in-memory results of the same build on an image that Nyxus reads as +one tile. Every value quoted in the worked example and under Possible issues is asserted by the +worked-example script; none is compared with a reference tool. + + +References +---------- -C. Singh, E. Walia. Algorithms for fast computation of Zernike moments and their numerical stability. -Image and Vision Computing, Volume 29, Issue 4, 2011: 251-259, ISSN 0262-8856, https://doi.org/10.1016/j.imavis.2010.10.003. \ No newline at end of file +1. Teague MR. *Image analysis via the general theory of moments.* Journal of the Optical Society of + America. 1980;70(8):920–930. doi:10.1364/JOSA.70.000920 +2. Khotanzad A, Hong YH. *Invariant image recognition by Zernike moments.* IEEE Transactions on + Pattern Analysis and Machine Intelligence. 1990;12(5):489–497. doi:10.1109/34.55109 +3. Singh C, Walia E. *Algorithms for fast computation of Zernike moments and their numerical + stability.* Image and Vision Computing. 2011;29(4):251–259. doi:10.1016/j.imavis.2010.10.003 +4. Boland MV, Markey MK, Murphy RF. *Automated recognition of patterns characteristic of + subcellular structures in fluorescence microscopy images.* Cytometry. 1998;33(3):366–375. diff --git a/docs/source/Math/feret_index.inc b/docs/source/Math/feret_index.inc new file mode 100644 index 000000000..d3843566c --- /dev/null +++ b/docs/source/Math/feret_index.inc @@ -0,0 +1,52 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`MIN_FERET_ANGLE ` + - Angle of the smallest Feret diameter + - — + - regression + - 0 + * - :ref:`MAX_FERET_ANGLE ` + - Angle of the largest Feret diameter + - — + - regression + - 50 + * - :ref:`STAT_FERET_DIAM_MIN ` + - Feret diameter, minimum + - — + - IMEA (rel. 6e-2) + - 11 + * - :ref:`STAT_FERET_DIAM_MAX ` + - Feret diameter, maximum + - — + - IMEA (rel. 6e-2) + - 13.57 + * - :ref:`STAT_FERET_DIAM_MEAN ` + - Feret diameter, mean + - — + - IMEA (rel. 6e-2) + - 12.42 + * - :ref:`STAT_FERET_DIAM_MEDIAN ` + - Feret diameter, median + - — + - IMEA (rel. 6e-2) + - 12.39 + * - :ref:`STAT_FERET_DIAM_STDDEV ` + - Feret diameter, standard deviation + - — + - IMEA (rel. 6e-2) + - 0.8183 + * - :ref:`STAT_FERET_DIAM_MODE ` + - Feret diameter, mode + - — + - regression + - 12 diff --git a/docs/source/Math/fractal_index.inc b/docs/source/Math/fractal_index.inc new file mode 100644 index 000000000..166d8f5cb --- /dev/null +++ b/docs/source/Math/fractal_index.inc @@ -0,0 +1,22 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`FRACT_DIM_BOXCOUNT ` + - Box-counting fractal dimension + - — + - FracLac + - 1.373 + * - :ref:`FRACT_DIM_PERIMETER ` + - Perimeter (divider) fractal dimension + - — + - FracLac + - 1.131 diff --git a/docs/source/Math/gabor_gamma.png b/docs/source/Math/gabor_gamma.png index 2e292f5dc..ceae67bfd 100644 Binary files a/docs/source/Math/gabor_gamma.png and b/docs/source/Math/gabor_gamma.png differ diff --git a/docs/source/Math/gabor_index.inc b/docs/source/Math/gabor_index.inc new file mode 100644 index 000000000..d0e970768 --- /dev/null +++ b/docs/source/Math/gabor_index.inc @@ -0,0 +1,17 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`GABOR ` + - Gabor filter-bank score + - — + - scikit-image (rel. 1e-3) + - 1.042 diff --git a/docs/source/Math/geodetic_index.inc b/docs/source/Math/geodetic_index.inc new file mode 100644 index 000000000..875a6309a --- /dev/null +++ b/docs/source/Math/geodetic_index.inc @@ -0,0 +1,22 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`GEODETIC_LENGTH ` + - Geodetic length + - — + - IMEA (exact) + - 17.41 + * - :ref:`THICKNESS ` + - Thickness + - — + - IMEA (exact) + - 6.664 diff --git a/docs/source/Math/glcm_3d_index.inc b/docs/source/Math/glcm_3d_index.inc new file mode 100644 index 000000000..9d5b3a210 --- /dev/null +++ b/docs/source/Math/glcm_3d_index.inc @@ -0,0 +1,162 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`3GLCM_ACOR ` + - Autocorrelation + - QWB0 + - PyRadiomics (rel. 1e-9) + - 6.611 + * - :ref:`3GLCM_ASM ` + - Angular second moment + - 8ZQL + - PyRadiomics (rel. 1e-9) + - 0.07562 + * - :ref:`3GLCM_CLUPROM ` + - Cluster prominence + - AE86 + - PyRadiomics (rel. 1e-9) + - 15.47 + * - :ref:`3GLCM_CLUSHADE ` + - Cluster shade + - 7NFM + - PyRadiomics (rel. 1e-9) + - −0.08299 + * - :ref:`3GLCM_CLUTEND ` + - Cluster tendency + - DG8W + - PyRadiomics (rel. 1e-9) + - 2.83 + * - :ref:`3GLCM_CONTRAST ` + - Contrast + - ACUI + - PyRadiomics (rel. 1e-9) + - 1.944 + * - :ref:`3GLCM_CORRELATION ` + - Correlation + - NI2N + - PyRadiomics (rel. 1e-9) + - 0.1855 + * - :ref:`3GLCM_DIFAVE ` + - Difference average + - TF7R + - PyRadiomics (rel. 1e-9) + - 1.167 + * - :ref:`3GLCM_DIFENTRO ` + - Difference entropy + - NTRS + - PyRadiomics (rel. 3e-3) + - 1.614 + * - :ref:`3GLCM_DIFVAR ` + - Difference variance + - D3YU + - PyRadiomics (rel. 1e-9) + - 0.5833 + * - :ref:`3GLCM_DIS ` + - Dissimilarity + - 8S9J + - PyRadiomics (rel. 1e-9) + - 1.167 + * - :ref:`3GLCM_ENERGY ` + - Energy (same as angular second moment) + - — + - PyRadiomics (rel. 1e-9) + - 0.07562 + * - :ref:`3GLCM_ENTROPY ` + - Entropy (same as joint entropy) + - — + - PyRadiomics (rel. 1e-9) + - 3.808 + * - :ref:`3GLCM_HOM1 ` + - Homogeneity 1 (same as inverse difference) + - — + - PyRadiomics (rel. 1e-9) + - 0.5324 + * - :ref:`3GLCM_HOM2 ` + - Homogeneity 2 (same as inverse difference moment) + - — + - PyRadiomics (rel. 1e-9) + - 0.4944 + * - :ref:`3GLCM_ID ` + - Inverse difference + - IB1Z + - PyRadiomics (rel. 1e-9) + - 0.5324 + * - :ref:`3GLCM_IDN ` + - Inverse difference normalized + - NDRX + - PyRadiomics (rel. 1e-9) + - 0.791 + * - :ref:`3GLCM_IDM ` + - Inverse difference moment + - WF0Z + - PyRadiomics (rel. 1e-9) + - 0.4944 + * - :ref:`3GLCM_IDMN ` + - Inverse difference moment normalized + - 1QCO + - PyRadiomics (rel. 1e-9) + - 0.9029 + * - :ref:`3GLCM_INFOMEAS1 ` + - Information measure of correlation 1 + - R8DG + - PyRadiomics (rel. 4e-2) + - −0.0914 + * - :ref:`3GLCM_INFOMEAS2 ` + - Information measure of correlation 2 + - JN9H + - PyRadiomics (rel. 2e-2) + - 0.5527 + * - :ref:`3GLCM_IV ` + - Inverse variance + - E8JP + - PyRadiomics (rel. 1e-9) + - 0.6173 + * - :ref:`3GLCM_JAVE ` + - Joint average + - 60VM + - PyRadiomics (rel. 1e-9) + - 2.528 + * - :ref:`3GLCM_JE ` + - Joint entropy + - TU9B + - PyRadiomics (rel. 1e-3) + - 3.808 + * - :ref:`3GLCM_JMAX ` + - Joint maximum + - GYBY + - PyRadiomics (rel. 1e-9) + - 0.1111 + * - :ref:`3GLCM_JVAR ` + - Joint variance + - UR99 + - PyRadiomics (rel. 1e-9) + - 1.194 + * - :ref:`3GLCM_SUMAVERAGE ` + - Sum average + - ZGXS + - PyRadiomics (rel. 1e-9) + - 5.056 + * - :ref:`3GLCM_SUMENTROPY ` + - Sum entropy + - P6QZ + - PyRadiomics (rel. 2e-3) + - 2.641 + * - :ref:`3GLCM_SUMVARIANCE ` + - Sum variance + - OEEB + - PyRadiomics (rel. 1e-9) + - 2.83 + * - :ref:`3GLCM_VARIANCE ` + - Variance + - — + - PyRadiomics (rel. 1e-9) + - 1.194 diff --git a/docs/source/Math/glcm_index.inc b/docs/source/Math/glcm_index.inc new file mode 100644 index 000000000..5a520fe9e --- /dev/null +++ b/docs/source/Math/glcm_index.inc @@ -0,0 +1,162 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`GLCM_ASM ` + - Angular second moment + - 8ZQL + - PyRadiomics (rel. 1e-9) + - 0.08 + * - :ref:`GLCM_ACOR ` + - Autocorrelation + - QWB0 + - MIRP (rel. 1e-9) + - 6.85 + * - :ref:`GLCM_CLUPROM ` + - Cluster prominence + - AE86 + - PyRadiomics (rel. 1e-9) + - 58.1 + * - :ref:`GLCM_CLUSHADE ` + - Cluster shade + - 7NFM + - MIRP (rel. 1e-9) + - 4.8 + * - :ref:`GLCM_CLUTEND ` + - Cluster tendency + - DG8W + - PyRadiomics (rel. 1e-9) + - 5.3 + * - :ref:`GLCM_CONTRAST ` + - Contrast + - ACUI + - PyRadiomics (rel. 1e-9) + - 2.9 + * - :ref:`GLCM_CORRELATION ` + - Correlation + - NI2N + - PyRadiomics (rel. 1e-9) + - 0.2927 + * - :ref:`GLCM_DIFAVE ` + - Difference average + - TF7R + - PyRadiomics (rel. 1e-9) + - 1.4 + * - :ref:`GLCM_DIFENTRO ` + - Difference entropy + - NTRS + - PyRadiomics (rel. 5e-3) + - 1.923 + * - :ref:`GLCM_DIFVAR ` + - Difference variance + - D3YU + - PyRadiomics (rel. 1e-9) + - 0.94 + * - :ref:`GLCM_DIS ` + - Dissimilarity + - 8S9J + - PyRadiomics (rel. 1e-9) + - 1.4 + * - :ref:`GLCM_ENERGY ` + - Energy (same as angular second moment) + - — + - PyRadiomics (rel. 1e-9) + - 0.08 + * - :ref:`GLCM_ENTROPY ` + - Entropy (same as joint entropy) + - — + - MIRP (rel. 5e-3) + - 3.805 + * - :ref:`GLCM_HOM1 ` + - Homogeneity 1 (same as inverse difference) + - — + - PyRadiomics (rel. 1e-9) + - 0.5125 + * - :ref:`GLCM_HOM2 ` + - Homogeneity 2 (same as inverse difference moment) + - — + - MIRP (rel. 1e-9) + - 0.45 + * - :ref:`GLCM_ID ` + - Inverse difference + - IB1Z + - PyRadiomics (rel. 1e-9) + - 0.5125 + * - :ref:`GLCM_IDN ` + - Inverse difference normalized + - NDRX + - PyRadiomics (rel. 1e-9) + - 0.7997 + * - :ref:`GLCM_IDM ` + - Inverse difference moment + - WF0Z + - PyRadiomics (rel. 1e-9) + - 0.45 + * - :ref:`GLCM_IDMN ` + - Inverse difference moment normalized + - 1QCO + - PyRadiomics (rel. 1e-9) + - 0.9055 + * - :ref:`GLCM_INFOMEAS1 ` + - Information measure of correlation 1 + - R8DG + - MIRP (rel. 5e-3) + - −0.2276 + * - :ref:`GLCM_INFOMEAS2 ` + - Information measure of correlation 2 + - JN9H + - PyRadiomics (rel. 5e-3) + - 0.7897 + * - :ref:`GLCM_IV ` + - Inverse variance + - E8JP + - PyRadiomics (rel. 1e-9) + - 0.4417 + * - :ref:`GLCM_JAVE ` + - Joint average + - 60VM + - MIRP (rel. 1e-9) + - 2.5 + * - :ref:`GLCM_JE ` + - Joint entropy + - TU9B + - PyRadiomics (rel. 5e-3) + - 3.805 + * - :ref:`GLCM_JMAX ` + - Joint maximum + - GYBY + - PyRadiomics (rel. 1e-9) + - 0.15 + * - :ref:`GLCM_JVAR ` + - Joint variance + - UR99 + - MIRP (rel. 1e-9) + - 2.05 + * - :ref:`GLCM_SUMAVERAGE ` + - Sum average + - ZGXS + - MIRP (rel. 1e-9) + - 5 + * - :ref:`GLCM_SUMENTROPY ` + - Sum entropy + - P6QZ + - PyRadiomics (rel. 5e-3) + - 2.843 + * - :ref:`GLCM_SUMVARIANCE ` + - Sum variance + - OEEB + - PyRadiomics (rel. 1e-9) + - 5.3 + * - :ref:`GLCM_VARIANCE ` + - Variance + - — + - IBSI (rel. 1e-9) + - 2.05 diff --git a/docs/source/Math/gldm_3d_index.inc b/docs/source/Math/gldm_3d_index.inc new file mode 100644 index 000000000..4213659f8 --- /dev/null +++ b/docs/source/Math/gldm_3d_index.inc @@ -0,0 +1,82 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`3GLDM_SDE ` + - Small dependence emphasis + - — + - PyRadiomics (abs. 1e-9) + - 0.0647 + * - :ref:`3GLDM_LDE ` + - Large dependence emphasis + - — + - PyRadiomics (abs. 1e-9) + - 36.54 + * - :ref:`3GLDM_GLN ` + - Grey level non-uniformity + - — + - PyRadiomics (abs. 1e-9) + - 12.08 + * - :ref:`3GLDM_DN ` + - Dependence non-uniformity + - — + - PyRadiomics (abs. 1e-9) + - 7.75 + * - :ref:`3GLDM_DNN ` + - Dependence non-uniformity normalized + - — + - PyRadiomics (abs. 1e-9) + - 0.1615 + * - :ref:`3GLDM_GLV ` + - Grey level variance + - — + - PyRadiomics (abs. 1e-9) + - 1.248 + * - :ref:`3GLDM_DV ` + - Dependence variance + - — + - PyRadiomics (abs. 1e-9) + - 3.957 + * - :ref:`3GLDM_DE ` + - Dependence entropy + - — + - PyRadiomics (abs. 4e-3) + - 4.363 + * - :ref:`3GLDM_LGLE ` + - Low grey level emphasis + - — + - PyRadiomics (abs. 1e-9) + - 0.3725 + * - :ref:`3GLDM_HGLE ` + - High grey level emphasis + - — + - PyRadiomics (abs. 1e-9) + - 7.292 + * - :ref:`3GLDM_SDLGLE ` + - Small dependence low grey level emphasis + - — + - PyRadiomics (abs. 1e-9) + - 0.01907 + * - :ref:`3GLDM_SDHGLE ` + - Small dependence high grey level emphasis + - — + - PyRadiomics (abs. 1e-9) + - 0.5953 + * - :ref:`3GLDM_LDLGLE ` + - Large dependence low grey level emphasis + - — + - PyRadiomics (abs. 1e-9) + - 12.98 + * - :ref:`3GLDM_LDHGLE ` + - Large dependence high grey level emphasis + - — + - PyRadiomics (abs. 1e-9) + - 292.2 diff --git a/docs/source/Math/gldm_index.inc b/docs/source/Math/gldm_index.inc new file mode 100644 index 000000000..5ae16996b --- /dev/null +++ b/docs/source/Math/gldm_index.inc @@ -0,0 +1,82 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`GLDM_SDE ` + - Small dependence emphasis + - — + - PyRadiomics (rel. 1e-9) + - 0.3725 + * - :ref:`GLDM_LDE ` + - Large dependence emphasis + - — + - PyRadiomics (rel. 1e-9) + - 5.56 + * - :ref:`GLDM_GLN ` + - Grey level non-uniformity + - — + - PyRadiomics (rel. 1e-9) + - 6.04 + * - :ref:`GLDM_DN ` + - Dependence non-uniformity + - — + - PyRadiomics (rel. 1e-9) + - 7.96 + * - :ref:`GLDM_DNN ` + - Dependence non-uniformity normalized + - — + - PyRadiomics (rel. 1e-9) + - 0.3184 + * - :ref:`GLDM_GLV ` + - Grey level variance + - — + - PyRadiomics (rel. 1e-9) + - 1.44 + * - :ref:`GLDM_DV ` + - Dependence variance + - — + - PyRadiomics (rel. 1e-9) + - 0.72 + * - :ref:`GLDM_DE ` + - Dependence entropy + - — + - PyRadiomics (rel. 2.5e-3) + - 3.38 + * - :ref:`GLDM_LGLE ` + - Low grey level emphasis + - — + - PyRadiomics (rel. 1e-9) + - 0.2723 + * - :ref:`GLDM_HGLE ` + - High grey level emphasis + - — + - PyRadiomics (rel. 1e-9) + - 9.28 + * - :ref:`GLDM_SDLGLE ` + - Small dependence low grey level emphasis + - — + - PyRadiomics (rel. 1e-9) + - 0.05914 + * - :ref:`GLDM_SDHGLE ` + - Small dependence high grey level emphasis + - — + - PyRadiomics (rel. 1e-9) + - 5.155 + * - :ref:`GLDM_LDLGLE ` + - Large dependence low grey level emphasis + - — + - PyRadiomics (rel. 1e-9) + - 2.143 + * - :ref:`GLDM_LDHGLE ` + - Large dependence high grey level emphasis + - — + - PyRadiomics (rel. 1e-9) + - 35.12 diff --git a/docs/source/Math/gldzm_3d_index.inc b/docs/source/Math/gldzm_3d_index.inc new file mode 100644 index 000000000..c4c71ac7f --- /dev/null +++ b/docs/source/Math/gldzm_3d_index.inc @@ -0,0 +1,102 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`3GLDZM_SDE ` + - Small distance emphasis + - 0GBI + - MIRP (abs. 1e-9) + - 0.8929 + * - :ref:`3GLDZM_LDE ` + - Large distance emphasis + - MB4I + - MIRP (abs. 1e-9) + - 1.429 + * - :ref:`3GLDZM_LGLZE ` + - Low grey level zone emphasis + - S1RA + - MIRP (abs. 1e-9) + - 0.2307 + * - :ref:`3GLDZM_HGLZE ` + - High grey level zone emphasis + - K26C + - MIRP (abs. 1e-9) + - 12.71 + * - :ref:`3GLDZM_SDLGLE ` + - Small distance low grey level emphasis + - RUVG + - MIRP (abs. 1e-9) + - 0.2188 + * - :ref:`3GLDZM_SDHGLE ` + - Small distance high grey level emphasis + - DKNJ + - MIRP (abs. 1e-9) + - 11.75 + * - :ref:`3GLDZM_LDLGLE ` + - Large distance low grey level emphasis + - A7WM + - MIRP (abs. 1e-9) + - 0.2783 + * - :ref:`3GLDZM_LDHGLE ` + - Large distance high grey level emphasis + - KLTH + - MIRP (abs. 1e-9) + - 16.57 + * - :ref:`3GLDZM_GLNU ` + - Grey level non-uniformity + - VFT7 + - MIRP (abs. 1e-9) + - 1.571 + * - :ref:`3GLDZM_GLNUN ` + - Grey level non-uniformity normalized + - 7HP3 + - MIRP (abs. 1e-9) + - 0.2245 + * - :ref:`3GLDZM_ZDNU ` + - Zone distance non-uniformity + - V294 + - MIRP (abs. 1e-9) + - 5.286 + * - :ref:`3GLDZM_ZDNUN ` + - Zone distance non-uniformity normalized + - IATH + - MIRP (abs. 1e-9) + - 0.7551 + * - :ref:`3GLDZM_ZP ` + - Zone percentage + - VIWW + - MIRP (abs. 1e-9) + - 0.1458 + * - :ref:`3GLDZM_GLM ` + - Grey level mean + - — + - regression + - 3.286 + * - :ref:`3GLDZM_GLV ` + - Grey level variance + - QK93 + - MIRP (abs. 1e-9) + - 1.918 + * - :ref:`3GLDZM_ZDM ` + - Zone distance mean + - — + - regression + - 1.143 + * - :ref:`3GLDZM_ZDV ` + - Zone distance variance + - 7WT1 + - MIRP (abs. 1e-9) + - 0.1224 + * - :ref:`3GLDZM_ZDE ` + - Zone distance entropy + - GBDU + - MIRP (abs. 1e-9) + - 2.522 diff --git a/docs/source/Math/gldzm_index.inc b/docs/source/Math/gldzm_index.inc new file mode 100644 index 000000000..ed3becb07 --- /dev/null +++ b/docs/source/Math/gldzm_index.inc @@ -0,0 +1,102 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`GLDZM_SDE ` + - Small distance emphasis + - 0GBI + - MIRP (abs. 1e-9) + - 0.9 + * - :ref:`GLDZM_LDE ` + - Large distance emphasis + - MB4I + - MIRP (abs. 1e-9) + - 1.4 + * - :ref:`GLDZM_LGLZE ` + - Low grey level zone emphasis + - S1RA + - MIRP (abs. 1e-9) + - 0.3677 + * - :ref:`GLDZM_HGLZE ` + - High grey level zone emphasis + - K26C + - MIRP (abs. 1e-9) + - 9.2 + * - :ref:`GLDZM_SDLGLE ` + - Small distance low grey level emphasis + - RUVG + - MIRP (abs. 1e-9) + - 0.3157 + * - :ref:`GLDZM_SDHGLE ` + - Small distance high grey level emphasis + - DKNJ + - MIRP (abs. 1e-9) + - 7.9 + * - :ref:`GLDZM_LDLGLE ` + - Large distance low grey level emphasis + - A7WM + - MIRP (abs. 1e-9) + - 0.5757 + * - :ref:`GLDZM_LDHGLE ` + - Large distance high grey level emphasis + - KLTH + - MIRP (abs. 1e-9) + - 14.4 + * - :ref:`GLDZM_GLNU ` + - Grey level non-uniformity + - VFT7 + - MIRP (abs. 1e-9) + - 3.4 + * - :ref:`GLDZM_GLNUN ` + - Grey level non-uniformity normalized + - 7HP3 + - MIRP (abs. 1e-9) + - 0.2267 + * - :ref:`GLDZM_ZDNU ` + - Zone distance non-uniformity + - V294 + - MIRP (abs. 1e-9) + - 11.53 + * - :ref:`GLDZM_ZDNUN ` + - Zone distance non-uniformity normalized + - IATH + - MIRP (abs. 1e-9) + - 0.7689 + * - :ref:`GLDZM_ZP ` + - Zone percentage + - VIWW + - MIRP (abs. 1e-9) + - 0.6 + * - :ref:`GLDZM_GLM ` + - Grey level mean + - — + - regression + - 2.667 + * - :ref:`GLDZM_GLV ` + - Grey level variance + - QK93 + - MIRP (abs. 1e-9) + - 2.089 + * - :ref:`GLDZM_ZDM ` + - Zone distance mean + - — + - regression + - 1.133 + * - :ref:`GLDZM_ZDV ` + - Zone distance variance + - 7WT1 + - MIRP (abs. 1e-9) + - 0.1156 + * - :ref:`GLDZM_ZDE ` + - Zone distance entropy + - GBDU + - MIRP (abs. 1e-9) + - 2.606 diff --git a/docs/source/Math/glrlm_3d_index.inc b/docs/source/Math/glrlm_3d_index.inc new file mode 100644 index 000000000..dde1e7baf --- /dev/null +++ b/docs/source/Math/glrlm_3d_index.inc @@ -0,0 +1,92 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`3GLRLM_SRE ` + - Short run emphasis + - 22OV + - PyRadiomics (rel. 1e-9) + - 0.8961 + * - :ref:`3GLRLM_LRE ` + - Long run emphasis + - W4KF + - PyRadiomics (rel. 1e-9) + - 1.609 + * - :ref:`3GLRLM_GLN ` + - Grey level non-uniformity + - R5YN + - PyRadiomics (rel. 1e-9) + - 6.391 + * - :ref:`3GLRLM_GLNN ` + - Grey level non-uniformity normalized + - OVBL + - PyRadiomics (rel. 1e-9) + - 0.2779 + * - :ref:`3GLRLM_RLN ` + - Run length non-uniformity + - W92Y + - PyRadiomics (rel. 1e-9) + - 17.61 + * - :ref:`3GLRLM_RLNN ` + - Run length non-uniformity normalized + - IC23 + - PyRadiomics (rel. 1e-9) + - 0.7656 + * - :ref:`3GLRLM_RP ` + - Run percentage + - 9ZK5 + - PyRadiomics (rel. 1e-9) + - 0.8519 + * - :ref:`3GLRLM_GLV ` + - Grey level variance + - 8CE5 + - PyRadiomics (rel. 1e-9) + - 0.9225 + * - :ref:`3GLRLM_RV ` + - Run variance + - SXLW + - PyRadiomics (rel. 1e-9) + - 0.2306 + * - :ref:`3GLRLM_RE ` + - Run entropy + - HJ9O + - PyRadiomics (rel. 2e-3) + - 2.398 + * - :ref:`3GLRLM_LGLRE ` + - Low grey level run emphasis + - V3SW + - PyRadiomics (rel. 1e-9) + - 0.3463 + * - :ref:`3GLRLM_HGLRE ` + - High grey level run emphasis + - G3QZ + - PyRadiomics (rel. 1e-9) + - 6.435 + * - :ref:`3GLRLM_SRLGLE ` + - Short run low grey level emphasis + - HTZT + - PyRadiomics (rel. 1e-9) + - 0.2669 + * - :ref:`3GLRLM_SRHGLE ` + - Short run high grey level emphasis + - GD3A + - PyRadiomics (rel. 1e-9) + - 6.233 + * - :ref:`3GLRLM_LRLGLE ` + - Long run low grey level emphasis + - IVPO + - PyRadiomics (rel. 1e-9) + - 0.8572 + * - :ref:`3GLRLM_LRHGLE ` + - Long run high grey level emphasis + - 3KUM + - PyRadiomics (rel. 1e-9) + - 7.435 diff --git a/docs/source/Math/glrlm_index.inc b/docs/source/Math/glrlm_index.inc new file mode 100644 index 000000000..3ef82b451 --- /dev/null +++ b/docs/source/Math/glrlm_index.inc @@ -0,0 +1,92 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`GLRLM_SRE ` + - Short run emphasis + - 22OV + - PyRadiomics (rel. 1e-9) + - 0.7549 + * - :ref:`GLRLM_LRE ` + - Long run emphasis + - W4KF + - PyRadiomics (rel. 1e-9) + - 2.765 + * - :ref:`GLRLM_GLN ` + - Grey level non-uniformity + - R5YN + - PyRadiomics (rel. 1e-9) + - 4.059 + * - :ref:`GLRLM_GLNN ` + - Grey level non-uniformity normalized + - OVBL + - PyRadiomics (rel. 1e-9) + - 0.2388 + * - :ref:`GLRLM_RLN ` + - Run length non-uniformity + - W92Y + - PyRadiomics (rel. 1e-9) + - 9.235 + * - :ref:`GLRLM_RLNN ` + - Run length non-uniformity normalized + - IC23 + - PyRadiomics (rel. 1e-9) + - 0.5433 + * - :ref:`GLRLM_RP ` + - Run percentage + - 9ZK5 + - PyRadiomics (rel. 1e-9) + - 0.68 + * - :ref:`GLRLM_GLV ` + - Grey level variance + - 8CE5 + - PyRadiomics (rel. 1e-9) + - 1.529 + * - :ref:`GLRLM_RV ` + - Run variance + - SXLW + - PyRadiomics (rel. 1e-9) + - 0.6021 + * - :ref:`GLRLM_RE ` + - Run entropy + - HJ9O + - PyRadiomics (rel. 5e-3) + - 3.058 + * - :ref:`GLRLM_LGLRE ` + - Low grey level run emphasis + - V3SW + - PyRadiomics (rel. 1e-9) + - 0.2301 + * - :ref:`GLRLM_HGLRE ` + - High grey level run emphasis + - G3QZ + - PyRadiomics (rel. 1e-9) + - 10.53 + * - :ref:`GLRLM_SRLGLE ` + - Short run low grey level emphasis + - HTZT + - PyRadiomics (rel. 1e-9) + - 0.1513 + * - :ref:`GLRLM_SRHGLE ` + - Short run high grey level emphasis + - GD3A + - PyRadiomics (rel. 1e-9) + - 8.694 + * - :ref:`GLRLM_LRLGLE ` + - Long run low grey level emphasis + - IVPO + - PyRadiomics (rel. 1e-9) + - 0.9013 + * - :ref:`GLRLM_LRHGLE ` + - Long run high grey level emphasis + - 3KUM + - PyRadiomics (rel. 1e-9) + - 21.53 diff --git a/docs/source/Math/glszm_3d_index.inc b/docs/source/Math/glszm_3d_index.inc new file mode 100644 index 000000000..a6fb8d72e --- /dev/null +++ b/docs/source/Math/glszm_3d_index.inc @@ -0,0 +1,92 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`3GLSZM_SAE ` + - Small area emphasis + - 5QRC + - PyRadiomics (rel. 1e-9) + - 0.4938 + * - :ref:`3GLSZM_LAE ` + - Large area emphasis + - 48P8 + - PyRadiomics (rel. 1e-9) + - 13.89 + * - :ref:`3GLSZM_GLN ` + - Grey level non-uniformity + - JNSA + - PyRadiomics (rel. 1e-9) + - 2.556 + * - :ref:`3GLSZM_GLNN ` + - Grey level non-uniformity normalized + - Y1RO + - PyRadiomics (rel. 1e-9) + - 0.284 + * - :ref:`3GLSZM_SZN ` + - Size zone non-uniformity + - 4JP3 + - PyRadiomics (rel. 1e-9) + - 3 + * - :ref:`3GLSZM_SZNN ` + - Size zone non-uniformity normalized + - VB3A + - PyRadiomics (rel. 1e-9) + - 0.3333 + * - :ref:`3GLSZM_ZP ` + - Zone percentage + - P30P + - PyRadiomics (rel. 1e-9) + - 0.3333 + * - :ref:`3GLSZM_GLV ` + - Grey level variance + - BYLV + - PyRadiomics (rel. 1e-9) + - 1.58 + * - :ref:`3GLSZM_ZV ` + - Zone variance + - 3NSA + - PyRadiomics (rel. 1e-9) + - 4.889 + * - :ref:`3GLSZM_ZE ` + - Zone entropy + - GU8N + - PyRadiomics (rel. 1e-3, rel. 2e-3) + - 2.949 + * - :ref:`3GLSZM_LGLZE ` + - Low grey level zone emphasis + - XMSY + - PyRadiomics (rel. 1e-9) + - 0.4066 + * - :ref:`3GLSZM_HGLZE ` + - High grey level zone emphasis + - 5GN9 + - PyRadiomics (rel. 1e-9) + - 8.111 + * - :ref:`3GLSZM_SALGLE ` + - Small area low grey level emphasis + - 5RAI + - PyRadiomics (rel. 1e-9) + - 0.2569 + * - :ref:`3GLSZM_SAHGLE ` + - Small area high grey level emphasis + - HW1V + - PyRadiomics (rel. 1e-9) + - 3.546 + * - :ref:`3GLSZM_LALGLE ` + - Large area low grey level emphasis + - YH51 + - PyRadiomics (rel. 1e-9) + - 2.964 + * - :ref:`3GLSZM_LAHGLE ` + - Large area high grey level emphasis + - J17V + - PyRadiomics (rel. 1e-9) + - 127.1 diff --git a/docs/source/Math/glszm_index.inc b/docs/source/Math/glszm_index.inc new file mode 100644 index 000000000..8b7990a5d --- /dev/null +++ b/docs/source/Math/glszm_index.inc @@ -0,0 +1,92 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`GLSZM_SAE ` + - Small area emphasis + - 5QRC + - MIRP (abs. 1e-9) + - 0.6852 + * - :ref:`GLSZM_LAE ` + - Large area emphasis + - 48P8 + - MIRP (abs. 1e-9) + - 4.333 + * - :ref:`GLSZM_GLN ` + - Grey level non-uniformity + - JNSA + - MIRP (abs. 1e-9) + - 3.4 + * - :ref:`GLSZM_GLNN ` + - Grey level non-uniformity normalized + - Y1RO + - MIRP (abs. 1e-9) + - 0.2267 + * - :ref:`GLSZM_SZN ` + - Size zone non-uniformity + - 4JP3 + - MIRP (abs. 1e-9) + - 7.133 + * - :ref:`GLSZM_SZNN ` + - Size zone non-uniformity normalized + - VB3A + - MIRP (abs. 1e-9) + - 0.4756 + * - :ref:`GLSZM_ZP ` + - Zone percentage + - P30P + - MIRP (abs. 1e-9) + - 0.6 + * - :ref:`GLSZM_GLV ` + - Grey level variance + - BYLV + - MIRP (abs. 1e-9) + - 2.089 + * - :ref:`GLSZM_ZV ` + - Zone variance + - 3NSA + - MIRP (abs. 1e-9) + - 1.556 + * - :ref:`GLSZM_ZE ` + - Zone entropy + - GU8N + - MIRP (rel. 4e-3) + - 3.054 + * - :ref:`GLSZM_LGLZE ` + - Low grey level zone emphasis + - XMSY + - MIRP (abs. 1e-9) + - 0.3677 + * - :ref:`GLSZM_HGLZE ` + - High grey level zone emphasis + - 5GN9 + - MIRP (abs. 1e-9) + - 9.2 + * - :ref:`GLSZM_SALGLE ` + - Small area low grey level emphasis + - 5RAI + - MIRP (abs. 1e-9) + - 0.2648 + * - :ref:`GLSZM_SAHGLE ` + - Small area high grey level emphasis + - HW1V + - MIRP (abs. 1e-9) + - 6.585 + * - :ref:`GLSZM_LALGLE ` + - Large area low grey level emphasis + - YH51 + - MIRP (abs. 1e-9) + - 2.853 + * - :ref:`GLSZM_LAHGLE ` + - Large area high grey level emphasis + - J17V + - MIRP (abs. 1e-9) + - 21.73 diff --git a/docs/source/Math/image_quality_index.inc b/docs/source/Math/image_quality_index.inc new file mode 100644 index 000000000..97403e270 --- /dev/null +++ b/docs/source/Math/image_quality_index.inc @@ -0,0 +1,42 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`FOCUS_SCORE ` + - Focus score + - — + - OpenCV (abs. 1e-9) + - 27.86 + * - :ref:`LOCAL_FOCUS_SCORE ` + - Local focus score + - — + - analytic (abs. 0), OpenCV (abs. 1e-9) + - 31.76 + * - :ref:`POWER_SPECTRUM_SLOPE ` + - Power spectrum slope + - — + - regression + - 0 + * - :ref:`MAX_SATURATION ` + - Maximum saturation + - — + - CellProfiler (abs. 1e-9) + - 0.08333 + * - :ref:`MIN_SATURATION ` + - Minimum saturation + - — + - CellProfiler (abs. 1e-9) + - 0.3056 + * - :ref:`SHARPNESS ` + - Sharpness + - — + - regression + - 1.903 diff --git a/docs/source/Math/intensity_3d_index.inc b/docs/source/Math/intensity_3d_index.inc new file mode 100644 index 000000000..f8fc5fd88 --- /dev/null +++ b/docs/source/Math/intensity_3d_index.inc @@ -0,0 +1,192 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`3COV ` + - Coefficient of variation + - 7TET + - MATLAB (rel. 1e-3) + - 0.2821 + * - :ref:`3COVERED_IMAGE_INTENSITY_RANGE ` + - Covered image intensity range + - — + - regression + - 1 + * - :ref:`3ENERGY ` + - Energy + - N8CA + - PyRadiomics (rel. 1e-9) + - 423 + * - :ref:`3ENTROPY ` + - Entropy + - — + - PyRadiomics (rel. 1e-9) + - 2.158 + * - :ref:`3EXCESS_KURTOSIS ` + - Excess kurtosis + - IPH6 + - MATLAB (rel. 1e-3) + - −1.038 + * - :ref:`3HYPERFLATNESS ` + - Hyperflatness + - — + - MATLAB (rel. 1e-3) + - 4.232 + * - :ref:`3HYPERSKEWNESS ` + - Hyperskewness + - — + - MATLAB (rel. 1e-3) + - −0.002163 + * - :ref:`3INTEGRATED_INTENSITY ` + - Integrated intensity + - — + - MATLAB (rel. 1e-3) + - 93 + * - :ref:`3INTERQUARTILE_RANGE ` + - Interquartile range + - SALO + - MATLAB (rel. 1e-2), PyRadiomics (rel. 1e-2) + - 1.998 + * - :ref:`3KURTOSIS ` + - Kurtosis + - — + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-9) + - 1.962 + * - :ref:`3MAX ` + - Maximum + - 84IY + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-9) + - 6 + * - :ref:`3MEAN ` + - Mean + - Q4LE + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-9) + - 4.227 + * - :ref:`3MEAN_ABSOLUTE_DEVIATION ` + - Mean absolute deviation + - 4FUA + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-9) + - 0.9959 + * - :ref:`3MEDIAN ` + - Median + - Y12H + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-9) + - 4 + * - :ref:`3MEDIAN_ABSOLUTE_DEVIATION ` + - Median absolute deviation + - N72L + - regression + - 0.9545 + * - :ref:`3MIN ` + - Minimum + - 1GSF + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-9) + - 2 + * - :ref:`3MODE ` + - Mode + - — + - MATLAB (rel. 1e-3) + - 3 + * - :ref:`3P01 ` + - 1st percentile + - — + - MATLAB (rel. 1e-2) + - 2.009 + * - :ref:`3P10 ` + - 10th percentile + - QG58 + - MATLAB (rel. 1e-2), PyRadiomics (rel. 1e-2) + - 3.008 + * - :ref:`3P25 ` + - 25th percentile + - — + - MATLAB (rel. 1e-2) + - 3.03 + * - :ref:`3P75 ` + - 75th percentile + - — + - MATLAB (rel. 1e-2) + - 5.028 + * - :ref:`3P90 ` + - 90th percentile + - 8DWT + - MATLAB (rel. 1e-2), PyRadiomics (rel. 1e-2) + - 5.978 + * - :ref:`3P99 ` + - 99th percentile + - — + - MATLAB (rel. 1e-2) + - 5.998 + * - :ref:`3QCOD ` + - Quartile coefficient of dispersion + - 9S40 + - MATLAB (rel. 1e-2) + - 0.248 + * - :ref:`3RANGE ` + - Range + - 2OJQ + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-9) + - 4 + * - :ref:`3ROBUST_MEAN ` + - Robust mean + - — + - regression + - 4.455 + * - :ref:`3ROBUST_MEAN_ABSOLUTE_DEVIATION ` + - Robust mean absolute deviation + - 1128 + - PyRadiomics (rel. 1e-2) + - 0.4959 + * - :ref:`3ROOT_MEAN_SQUARED ` + - Root mean square + - 5ZWQ + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-9) + - 4.385 + * - :ref:`3SKEWNESS ` + - Skewness + - KE2A + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-9) + - 0.06841 + * - :ref:`3STANDARD_DEVIATION ` + - Standard deviation + - — + - MATLAB (rel. 1e-3) + - 1.193 + * - :ref:`3STANDARD_DEVIATION_BIASED ` + - Biased standard deviation + - — + - MATLAB (rel. 1e-3) + - 1.165 + * - :ref:`3STANDARD_ERROR ` + - Standard error + - — + - MATLAB (rel. 1e-3) + - 0.2542 + * - :ref:`3VARIANCE ` + - Variance + - — + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-3) + - 1.422 + * - :ref:`3VARIANCE_BIASED ` + - Biased variance + - ECT3 + - MATLAB (rel. 1e-3) + - 1.357 + * - :ref:`3UNIFORMITY ` + - Uniformity + - — + - PyRadiomics (rel. 1e-9) + - 0.2355 + * - :ref:`3UNIFORMITY_PIU ` + - Percent image uniformity + - — + - MATLAB (rel. 1e-3) + - 50 diff --git a/docs/source/Math/intensity_histogram_index.inc b/docs/source/Math/intensity_histogram_index.inc new file mode 100644 index 000000000..fc3c73312 --- /dev/null +++ b/docs/source/Math/intensity_histogram_index.inc @@ -0,0 +1,242 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`IH_MEAN_VAL ` + - Mean of the bin-centre intensities + - — + - analytic (rel. 1e-9) + - 2.84 + * - :ref:`IH_VARIANCE_VAL ` + - Variance of the bin-centre intensities + - — + - analytic (rel. 1e-9) + - 0.9216 + * - :ref:`IH_SKEWNESS_VAL ` + - Skewness of the bin-centre intensities + - — + - analytic (rel. 1e-9) + - 0.25 + * - :ref:`IH_EXCESS_KURTOSIS_VAL ` + - Excess kurtosis of the bin-centre intensities + - — + - analytic (rel. 1e-9) + - −0.6667 + * - :ref:`IH_MEDIAN_VAL ` + - Median bin-centre intensity + - — + - analytic (rel. 1e-9) + - 3 + * - :ref:`IH_MINIMUM_VAL ` + - Minimum intensity + - — + - analytic (rel. 1e-9) + - 1 + * - :ref:`IH_P10_VAL ` + - 10th percentile intensity + - — + - regression + - 1.5 + * - :ref:`IH_P90_VAL ` + - 90th percentile intensity + - — + - regression + - 4.333 + * - :ref:`IH_MAXIMUM_VAL ` + - Maximum intensity + - — + - analytic (rel. 1e-9) + - 5 + * - :ref:`IH_MODE_VAL ` + - Mode bin-centre intensity + - — + - analytic (rel. 1e-9) + - 3 + * - :ref:`IH_INTERQUANTILE_RANGE_VAL ` + - Interquartile range of the intensities + - — + - regression + - 1.278 + * - :ref:`IH_RANGE_VAL ` + - Intensity range + - — + - analytic (rel. 1e-9) + - 4 + * - :ref:`IH_MEAN_ABSOLUTE_DEVIATION_VAL ` + - Mean absolute deviation of the bin-centre intensities + - — + - analytic (rel. 1e-9) + - 0.768 + * - :ref:`IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_VAL ` + - Robust mean absolute deviation of the bin-centre intensities + - — + - analytic (rel. 1e-9) + - 0.768 + * - :ref:`IH_MEDIAN_ABSOLUTE_DEVIATION_VAL ` + - Median absolute deviation of the bin-centre intensities + - — + - analytic (rel. 1e-9) + - 0.736 + * - :ref:`IH_COEFFICIENT_OF_VARIATION_VAL ` + - Coefficient of variation of the bin-centre intensities + - — + - analytic (rel. 1e-9) + - 0.338 + * - :ref:`IH_QUANTILE_COEFFICIENT_OF_DISPERSION_VAL ` + - Quartile coefficient of dispersion of the intensities + - — + - regression + - 0.2333 + * - :ref:`IH_ENTROPY_VAL ` + - Histogram entropy (same as IH_ENTROPY_IDX) + - — + - analytic (rel. 1e-9) + - 2.182 + * - :ref:`IH_UNIFORMITY_VAL ` + - Histogram uniformity (same as IH_UNIFORMITY_IDX) + - — + - analytic (rel. 1e-9) + - 0.2416 + * - :ref:`IH_ROBUST_MEAN_VAL ` + - Robust mean of the bin-centre intensities + - — + - analytic (rel. 1e-9) + - 2.84 + * - :ref:`IH_MEAN_IDX ` + - Mean discretised intensity + - X6K6 + - MIRP (rel. 1e-9) + - 2.8 + * - :ref:`IH_VARIANCE_IDX ` + - Discretised intensity variance + - CH89 + - MIRP (rel. 1e-9) + - 1.44 + * - :ref:`IH_SKEWNESS_IDX ` + - Discretised intensity skewness + - 88K1 + - MIRP (rel. 1e-9) + - 0.25 + * - :ref:`IH_EXCESS_KURTOSIS_IDX ` + - (Excess) discretised intensity kurtosis + - C3I7 + - MIRP (rel. 1e-9) + - −0.6667 + * - :ref:`IH_MEDIAN_IDX ` + - Median discretised intensity + - WIFQ + - MIRP (rel. 1e-9) + - 3 + * - :ref:`IH_MINIMUM_IDX ` + - Minimum discretised intensity + - 1PR8 + - MIRP (rel. 1e-9) + - 1 + * - :ref:`IH_P10_IDX ` + - 10th discretised intensity percentile + - 1PR8 + - MIRP (rel. 1e-9) + - 1 + * - :ref:`IH_P90_IDX ` + - 90th discretised intensity percentile + - GPMT + - MIRP (rel. 1e-9) + - 5 + * - :ref:`IH_MAXIMUM_IDX ` + - Maximum discretised intensity + - 3NCY + - MIRP (rel. 1e-9) + - 5 + * - :ref:`IH_MODE_IDX ` + - Intensity histogram mode + - AMMC + - MIRP (rel. 1e-9) + - 3 + * - :ref:`IH_INTERQUANTILE_RANGE_IDX ` + - Discretised intensity interquartile range + - WR0O + - MIRP (rel. 1e-9) + - 1 + * - :ref:`IH_RANGE_IDX ` + - Discretised intensity range + - 5Z3W + - MIRP (rel. 1e-9) + - 4 + * - :ref:`IH_MEAN_ABSOLUTE_DEVIATION_IDX ` + - Intensity histogram mean absolute deviation + - D2ZX + - MIRP (rel. 1e-9) + - 0.96 + * - :ref:`IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_IDX ` + - Intensity histogram robust mean absolute deviation + - WRZB + - MIRP (rel. 1e-9) + - 0.96 + * - :ref:`IH_MEDIAN_ABSOLUTE_DEVIATION_IDX ` + - Intensity histogram median absolute deviation + - 4RNL + - MIRP (rel. 1e-9) + - 0.92 + * - :ref:`IH_COEFFICIENT_OF_VARIATION_IDX ` + - Intensity histogram coefficient of variation + - CWYJ + - MIRP (rel. 1e-9) + - 0.4286 + * - :ref:`IH_QUANTILE_COEFFICIENT_OF_DISPERSION_IDX ` + - Intensity histogram quartile coefficient of dispersion + - SLWD + - MIRP (rel. 1e-9) + - 0.2 + * - :ref:`IH_ENTROPY_IDX ` + - Discretised intensity entropy + - TLU2 + - MIRP (rel. 1e-9) + - 2.182 + * - :ref:`IH_UNIFORMITY_IDX ` + - Discretised intensity uniformity + - BJ5W + - MIRP (rel. 1e-9) + - 0.2416 + * - :ref:`IH_MAX_GRADIENT ` + - Maximum histogram gradient + - 12CE + - MIRP (rel. 1e-9) + - 2.5 + * - :ref:`IH_MAX_GRADIENT_IDX ` + - Maximum histogram gradient intensity + - 8E6O + - MIRP (rel. 1e-9) + - 2 + * - :ref:`IH_MIN_GRADIENT ` + - Minimum histogram gradient + - VQB3 + - MIRP (rel. 1e-9) + - −3 + * - :ref:`IH_MIN_GRADIENT_IDX ` + - Minimum histogram gradient intensity + - RHQZ + - MIRP (rel. 1e-9) + - 4 + * - :ref:`IH_ROBUST_MEAN_IDX ` + - Robust mean discretised intensity + - — + - analytic (rel. 1e-9) + - 2.8 + * - :ref:`IH_NUM_BINS ` + - Number of bins + - — + - analytic (rel. 1e-9) + - 5 + * - :ref:`IH_BIN_SIZE ` + - Bin width + - — + - analytic (rel. 1e-9) + - 0.8 diff --git a/docs/source/Math/intensity_index.inc b/docs/source/Math/intensity_index.inc new file mode 100644 index 000000000..239d456bf --- /dev/null +++ b/docs/source/Math/intensity_index.inc @@ -0,0 +1,197 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`COV ` + - Coefficient of variation + - 7TET + - MATLAB (rel. 1e-3) + - 0.4374 + * - :ref:`COVERED_IMAGE_INTENSITY_RANGE ` + - Covered image intensity range + - — + - MATLAB (rel. 1e-3) + - 0 + * - :ref:`ENERGY ` + - Energy + - N8CA + - IBSI (rel. 1e-2), MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 232 + * - :ref:`ENTROPY ` + - Entropy + - — + - PyRadiomics (rel. 1e-6) + - 2.182 + * - :ref:`EXCESS_KURTOSIS ` + - Excess kurtosis + - IPH6 + - IBSI (rel. 1e-2), MATLAB (rel. 1e-3) + - −0.6667 + * - :ref:`HYPERFLATNESS ` + - Hyperflatness + - — + - MATLAB (rel. 1e-3) + - 5.769 + * - :ref:`HYPERSKEWNESS ` + - Hyperskewness + - — + - MATLAB (rel. 1e-3) + - 1.227 + * - :ref:`INTEGRATED_INTENSITY ` + - Integrated intensity + - — + - MATLAB (rel. 1e-3) + - 70 + * - :ref:`INTERQUARTILE_RANGE ` + - Interquartile range + - SALO + - IBSI (rel. 1e-2), MATLAB (rel. 3e-2), PyRadiomics (rel. 5e-2) + - 1.024 + * - :ref:`KURTOSIS ` + - Kurtosis + - — + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 2.333 + * - :ref:`MAX ` + - Maximum + - 84IY + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 5 + * - :ref:`MEAN ` + - Mean + - Q4LE + - IBSI (rel. 1e-2), MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 2.8 + * - :ref:`MEAN_ABSOLUTE_DEVIATION ` + - Mean absolute deviation + - 4FUA + - IBSI (rel. 1e-2), MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 0.96 + * - :ref:`MEDIAN ` + - Median + - Y12H + - IBSI (rel. 1e-2), MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 3 + * - :ref:`MEDIAN_ABSOLUTE_DEVIATION ` + - Median absolute deviation + - N72L + - regression + - 0.92 + * - :ref:`MIN ` + - Minimum + - 1GSF + - IBSI (rel. 1e-2), MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 1 + * - :ref:`MODE ` + - Mode + - — + - MATLAB (rel. 1e-3) + - 3 + * - :ref:`P01 ` + - 1st percentile + - — + - MATLAB (rel. 3e-2) + - 1.003 + * - :ref:`P10 ` + - 10th percentile + - QG58 + - IBSI (rel. 1e-2), MATLAB (rel. 3e-2), PyRadiomics (rel. 5e-2) + - 1.025 + * - :ref:`P25 ` + - 25th percentile + - — + - MATLAB (rel. 3e-2) + - 2.015 + * - :ref:`P75 ` + - 75th percentile + - — + - MATLAB (rel. 3e-2) + - 3.039 + * - :ref:`P90 ` + - 90th percentile + - 8DWT + - IBSI (rel. 1e-2), MATLAB (rel. 3e-2), PyRadiomics (rel. 5e-2) + - 4.967 + * - :ref:`P99 ` + - 99th percentile + - — + - MATLAB (rel. 3e-2) + - 4.997 + * - :ref:`QCOD ` + - Quartile coefficient of dispersion + - 9S40 + - MATLAB (rel. 3e-2) + - 0.2026 + * - :ref:`RANGE ` + - Range + - 2OJQ + - IBSI (rel. 1e-2), MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 4 + * - :ref:`ROBUST_MEAN ` + - Robust mean + - — + - regression + - 2.833 + * - :ref:`ROBUST_MEAN_ABSOLUTE_DEVIATION ` + - Robust mean absolute deviation + - 1128 + - PyRadiomics (rel. 1e-6) + - 0.5556 + * - :ref:`ROOT_MEAN_SQUARED ` + - Root mean square + - 5ZWQ + - IBSI (rel. 1e-2), MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 3.046 + * - :ref:`SKEWNESS ` + - Skewness + - KE2A + - IBSI (rel. 1e-2), MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 0.25 + * - :ref:`STANDARD_DEVIATION ` + - Standard deviation + - — + - MATLAB (rel. 1e-3) + - 1.225 + * - :ref:`STANDARD_DEVIATION_BIASED ` + - Biased standard deviation + - — + - MATLAB (rel. 1e-3) + - 1.2 + * - :ref:`STANDARD_ERROR ` + - Standard error + - — + - MATLAB (rel. 1e-3) + - 0.2449 + * - :ref:`VARIANCE ` + - Variance + - — + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-2) + - 1.5 + * - :ref:`VARIANCE_BIASED ` + - Biased variance + - ECT3 + - MATLAB (rel. 1e-3), PyRadiomics (rel. 1e-6) + - 1.44 + * - :ref:`UNIFORMITY ` + - Uniformity + - — + - PyRadiomics (rel. 1e-6) + - 0.2416 + * - :ref:`UNIFORMITY_PIU ` + - Percent image uniformity + - — + - MATLAB (rel. 1e-3) + - 33.33 + * - :ref:`HISTOGRAM ` + - Histogram + - — + - analytic (rel. 1e-4) + - 4 diff --git a/docs/source/Math/intensity_moments_index.inc b/docs/source/Math/intensity_moments_index.inc new file mode 100644 index 000000000..22f86e6c3 --- /dev/null +++ b/docs/source/Math/intensity_moments_index.inc @@ -0,0 +1,462 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`IMOM_RM_00 ` + - Intensity raw moment (0, 0) + - — + - scikit-image (rel. 1e-6) + - 150 + * - :ref:`IMOM_RM_01 ` + - Intensity raw moment (0, 1) + - — + - scikit-image (rel. 1e-6) + - 434 + * - :ref:`IMOM_RM_02 ` + - Intensity raw moment (0, 2) + - — + - scikit-image (rel. 1e-6) + - 1680 + * - :ref:`IMOM_RM_03 ` + - Intensity raw moment (0, 3) + - — + - scikit-image (rel. 1e-6) + - 7340 + * - :ref:`IMOM_RM_10 ` + - Intensity raw moment (1, 0) + - — + - scikit-image (rel. 1e-6) + - 473 + * - :ref:`IMOM_RM_11 ` + - Intensity raw moment (1, 1) + - — + - scikit-image (rel. 1e-6) + - 1353 + * - :ref:`IMOM_RM_12 ` + - Intensity raw moment (1, 2) + - — + - scikit-image (rel. 1e-6) + - 5181 + * - :ref:`IMOM_RM_13 ` + - Intensity raw moment (1, 3) + - — + - scikit-image (rel. 1e-6) + - 22560 + * - :ref:`IMOM_RM_20 ` + - Intensity raw moment (2, 0) + - — + - scikit-image (rel. 1e-6) + - 1991 + * - :ref:`IMOM_RM_21 ` + - Intensity raw moment (2, 1) + - — + - scikit-image (rel. 1e-6) + - 5639 + * - :ref:`IMOM_RM_22 ` + - Intensity raw moment (2, 2) + - — + - scikit-image (rel. 1e-6) + - 20800 + * - :ref:`IMOM_RM_23 ` + - Intensity raw moment (2, 3) + - — + - scikit-image (rel. 1e-6) + - 87620 + * - :ref:`IMOM_RM_30 ` + - Intensity raw moment (3, 0) + - — + - scikit-image (rel. 1e-6) + - 9497 + * - :ref:`IMOM_CM_00 ` + - Intensity central moment (0, 0) + - — + - scikit-image (rel. 1e-6) + - 150 + * - :ref:`IMOM_CM_01 ` + - Intensity central moment (0, 1) + - — + - scikit-image (rel. 1e-6) + - 0 + * - :ref:`IMOM_CM_02 ` + - Intensity central moment (0, 2) + - — + - scikit-image (rel. 1e-6) + - 424.3 + * - :ref:`IMOM_CM_03 ` + - Intensity central moment (0, 3) + - — + - scikit-image (rel. 1e-6) + - 23.96 + * - :ref:`IMOM_CM_10 ` + - Intensity central moment (1, 0) + - — + - scikit-image (rel. 1e-6) + - 0 + * - :ref:`IMOM_CM_11 ` + - Intensity central moment (1, 1) + - — + - scikit-image (rel. 1e-6) + - −15.55 + * - :ref:`IMOM_CM_12 ` + - Intensity central moment (1, 2) + - — + - scikit-image (rel. 1e-6) + - −26.64 + * - :ref:`IMOM_CM_13 ` + - Intensity central moment (1, 3) + - — + - scikit-image (rel. 1e-6) + - 39.18 + * - :ref:`IMOM_CM_20 ` + - Intensity central moment (2, 0) + - — + - scikit-image (rel. 1e-6) + - 499.5 + * - :ref:`IMOM_CM_21 ` + - Intensity central moment (2, 1) + - — + - scikit-image (rel. 1e-6) + - −23.58 + * - :ref:`IMOM_CM_22 ` + - Intensity central moment (2, 2) + - — + - scikit-image (rel. 1e-6) + - 780.4 + * - :ref:`IMOM_CM_23 ` + - Intensity central moment (2, 3) + - — + - scikit-image (rel. 1e-6) + - 25.31 + * - :ref:`IMOM_CM_30 ` + - Intensity central moment (3, 0) + - — + - scikit-image (rel. 1e-6) + - 68.7 + * - :ref:`IMOM_CM_31 ` + - Intensity central moment (3, 1) + - — + - scikit-image (rel. 1e-6) + - −226.2 + * - :ref:`IMOM_CM_32 ` + - Intensity central moment (3, 2) + - — + - scikit-image (rel. 1e-6) + - −82.84 + * - :ref:`IMOM_CM_33 ` + - Intensity central moment (3, 3) + - — + - scikit-image (rel. 1e-6) + - −535.7 + * - :ref:`IMOM_NRM_00 ` + - Intensity normalized raw moment (0, 0) + - — + - scikit-image (rel. 1e-6) + - 1 + * - :ref:`IMOM_NRM_01 ` + - Intensity normalized raw moment (0, 1) + - — + - scikit-image (rel. 1e-6) + - 0.2362 + * - :ref:`IMOM_NRM_02 ` + - Intensity normalized raw moment (0, 2) + - — + - scikit-image (rel. 1e-6) + - 0.07467 + * - :ref:`IMOM_NRM_03 ` + - Intensity normalized raw moment (0, 3) + - — + - scikit-image (rel. 1e-6) + - 0.02664 + * - :ref:`IMOM_NRM_10 ` + - Intensity normalized raw moment (1, 0) + - — + - scikit-image (rel. 1e-6) + - 0.2575 + * - :ref:`IMOM_NRM_11 ` + - Intensity normalized raw moment (1, 1) + - — + - scikit-image (rel. 1e-6) + - 0.06013 + * - :ref:`IMOM_NRM_12 ` + - Intensity normalized raw moment (1, 2) + - — + - scikit-image (rel. 1e-6) + - 0.0188 + * - :ref:`IMOM_NRM_13 ` + - Intensity normalized raw moment (1, 3) + - — + - scikit-image (rel. 1e-6) + - 0.006685 + * - :ref:`IMOM_NRM_20 ` + - Intensity normalized raw moment (2, 0) + - — + - scikit-image (rel. 1e-6) + - 0.08849 + * - :ref:`IMOM_NRM_21 ` + - Intensity normalized raw moment (2, 1) + - — + - scikit-image (rel. 1e-6) + - 0.02046 + * - :ref:`IMOM_NRM_22 ` + - Intensity normalized raw moment (2, 2) + - — + - scikit-image (rel. 1e-6) + - 0.006161 + * - :ref:`IMOM_NRM_23 ` + - Intensity normalized raw moment (2, 3) + - — + - scikit-image (rel. 1e-6) + - 0.00212 + * - :ref:`IMOM_NRM_30 ` + - Intensity normalized raw moment (3, 0) + - — + - scikit-image (rel. 1e-6) + - 0.03446 + * - :ref:`IMOM_NRM_31 ` + - Intensity normalized raw moment (3, 1) + - — + - scikit-image (rel. 1e-6) + - 0.007871 + * - :ref:`IMOM_NRM_32 ` + - Intensity normalized raw moment (3, 2) + - — + - scikit-image (rel. 1e-6) + - 0.002275 + * - :ref:`IMOM_NRM_33 ` + - Intensity normalized raw moment (3, 3) + - — + - scikit-image (rel. 1e-6) + - 0.0007519 + * - :ref:`IMOM_NCM_02 ` + - Intensity normalized central moment (0, 2) + - — + - scikit-image (rel. 1e-6) + - 0.01886 + * - :ref:`IMOM_NCM_03 ` + - Intensity normalized central moment (0, 3) + - — + - scikit-image (rel. 1e-6) + - 8.693e−05 + * - :ref:`IMOM_NCM_11 ` + - Intensity normalized central moment (1, 1) + - — + - scikit-image (rel. 1e-6) + - −0.000691 + * - :ref:`IMOM_NCM_12 ` + - Intensity normalized central moment (1, 2) + - — + - scikit-image (rel. 1e-6) + - −9.666e−05 + * - :ref:`IMOM_NCM_20 ` + - Intensity normalized central moment (2, 0) + - — + - scikit-image (rel. 1e-6) + - 0.0222 + * - :ref:`IMOM_NCM_21 ` + - Intensity normalized central moment (2, 1) + - — + - scikit-image (rel. 1e-6) + - −8.557e−05 + * - :ref:`IMOM_NCM_30 ` + - Intensity normalized central moment (3, 0) + - — + - scikit-image (rel. 1e-6) + - 0.0002493 + * - :ref:`IMOM_HU1 ` + - Intensity Hu invariant 1 + - — + - scikit-image (rel. 1e-6) + - 0.04106 + * - :ref:`IMOM_HU2 ` + - Intensity Hu invariant 2 + - — + - scikit-image (rel. 1e-6) + - 1.307e−05 + * - :ref:`IMOM_HU3 ` + - Intensity Hu invariant 3 + - — + - scikit-image (rel. 1e-6) + - 4.089e−07 + * - :ref:`IMOM_HU4 ` + - Intensity Hu invariant 4 + - — + - scikit-image (rel. 1e-6) + - 2.33e−08 + * - :ref:`IMOM_HU5 ` + - Intensity Hu invariant 5 + - — + - scikit-image (rel. 1e-6) + - 1.885e−15 + * - :ref:`IMOM_HU6 ` + - Intensity Hu invariant 6 + - — + - scikit-image (rel. 1e-6) + - 7.727e−11 + * - :ref:`IMOM_HU7 ` + - Intensity Hu invariant 7 + - — + - scikit-image (rel. 1e-6) + - −1.274e−15 + * - :ref:`IMOM_WRM_00 ` + - Intensity weighted raw moment (0, 0) + - — + - regression + - −144.7 + * - :ref:`IMOM_WRM_01 ` + - Intensity weighted raw moment (0, 1) + - — + - regression + - −426.2 + * - :ref:`IMOM_WRM_02 ` + - Intensity weighted raw moment (0, 2) + - — + - regression + - −1873 + * - :ref:`IMOM_WRM_03 ` + - Intensity weighted raw moment (0, 3) + - — + - regression + - −9595 + * - :ref:`IMOM_WRM_10 ` + - Intensity weighted raw moment (1, 0) + - — + - regression + - −496.8 + * - :ref:`IMOM_WRM_11 ` + - Intensity weighted raw moment (1, 1) + - — + - regression + - −864.2 + * - :ref:`IMOM_WRM_12 ` + - Intensity weighted raw moment (1, 2) + - — + - regression + - −2417 + * - :ref:`IMOM_WRM_20 ` + - Intensity weighted raw moment (2, 0) + - — + - regression + - −2298 + * - :ref:`IMOM_WRM_21 ` + - Intensity weighted raw moment (2, 1) + - — + - regression + - −3089 + * - :ref:`IMOM_WRM_30 ` + - Intensity weighted raw moment (3, 0) + - — + - regression + - −12390 + * - :ref:`IMOM_WCM_02 ` + - Intensity weighted central moment (0, 2) + - — + - regression + - −617.3 + * - :ref:`IMOM_WCM_03 ` + - Intensity weighted central moment (0, 3) + - — + - regression + - −440.8 + * - :ref:`IMOM_WCM_11 ` + - Intensity weighted central moment (1, 1) + - — + - regression + - 599.2 + * - :ref:`IMOM_WCM_12 ` + - Intensity weighted central moment (1, 2) + - — + - regression + - 483.1 + * - :ref:`IMOM_WCM_20 ` + - Intensity weighted central moment (2, 0) + - — + - regression + - −592 + * - :ref:`IMOM_WCM_21 ` + - Intensity weighted central moment (2, 1) + - — + - regression + - −434.8 + * - :ref:`IMOM_WCM_30 ` + - Intensity weighted central moment (3, 0) + - — + - regression + - −434.1 + * - :ref:`IMOM_WNCM_02 ` + - Intensity weighted normalized central moment (0, 2) + - — + - regression + - −0.02949 + * - :ref:`IMOM_WNCM_03 ` + - Intensity weighted normalized central moment (0, 3) + - — + - regression + - 0 + * - :ref:`IMOM_WNCM_11 ` + - Intensity weighted normalized central moment (1, 1) + - — + - regression + - 0.02863 + * - :ref:`IMOM_WNCM_12 ` + - Intensity weighted normalized central moment (1, 2) + - — + - regression + - 0 + * - :ref:`IMOM_WNCM_20 ` + - Intensity weighted normalized central moment (2, 0) + - — + - regression + - −0.02828 + * - :ref:`IMOM_WNCM_21 ` + - Intensity weighted normalized central moment (2, 1) + - — + - regression + - 0 + * - :ref:`IMOM_WNCM_30 ` + - Intensity weighted normalized central moment (3, 0) + - — + - regression + - 0 + * - :ref:`IMOM_WHU1 ` + - Intensity weighted Hu invariant 1 + - — + - regression + - −0.05777 + * - :ref:`IMOM_WHU2 ` + - Intensity weighted Hu invariant 2 + - — + - regression + - 0.003279 + * - :ref:`IMOM_WHU3 ` + - Intensity weighted Hu invariant 3 + - — + - regression + - 0 + * - :ref:`IMOM_WHU4 ` + - Intensity weighted Hu invariant 4 + - — + - regression + - 0 + * - :ref:`IMOM_WHU5 ` + - Intensity weighted Hu invariant 5 + - — + - regression + - 0 + * - :ref:`IMOM_WHU6 ` + - Intensity weighted Hu invariant 6 + - — + - regression + - 0 + * - :ref:`IMOM_WHU7 ` + - Intensity weighted Hu invariant 7 + - — + - regression + - 0 diff --git a/docs/source/Math/martin_index.inc b/docs/source/Math/martin_index.inc new file mode 100644 index 000000000..99f970147 --- /dev/null +++ b/docs/source/Math/martin_index.inc @@ -0,0 +1,42 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`STAT_MARTIN_DIAM_MIN ` + - Martin diameter, minimum + - — + - IMEA (rel. 6e-2) + - 11 + * - :ref:`STAT_MARTIN_DIAM_MAX ` + - Martin diameter, maximum + - — + - IMEA (rel. 6e-2) + - 13.49 + * - :ref:`STAT_MARTIN_DIAM_MEAN ` + - Martin diameter, mean + - — + - IMEA (rel. 6e-2) + - 11.93 + * - :ref:`STAT_MARTIN_DIAM_MEDIAN ` + - Martin diameter, median + - — + - IMEA (rel. 6e-2) + - 11.71 + * - :ref:`STAT_MARTIN_DIAM_STDDEV ` + - Martin diameter, standard deviation + - — + - IMEA (rel. 6e-2) + - 0.7683 + * - :ref:`STAT_MARTIN_DIAM_MODE ` + - Martin diameter, mode + - — + - regression + - 11 diff --git a/docs/source/Math/nassenstein_index.inc b/docs/source/Math/nassenstein_index.inc new file mode 100644 index 000000000..4dc9dfff8 --- /dev/null +++ b/docs/source/Math/nassenstein_index.inc @@ -0,0 +1,42 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`STAT_NASSENSTEIN_DIAM_MIN ` + - Nassenstein diameter, minimum + - — + - IMEA (rel. 6e-2) + - 11 + * - :ref:`STAT_NASSENSTEIN_DIAM_MAX ` + - Nassenstein diameter, maximum + - — + - IMEA (rel. 6e-2) + - 13.49 + * - :ref:`STAT_NASSENSTEIN_DIAM_MEAN ` + - Nassenstein diameter, mean + - — + - IMEA (rel. 6e-2) + - 11.93 + * - :ref:`STAT_NASSENSTEIN_DIAM_MEDIAN ` + - Nassenstein diameter, median + - — + - IMEA (rel. 6e-2) + - 11.71 + * - :ref:`STAT_NASSENSTEIN_DIAM_STDDEV ` + - Nassenstein diameter, standard deviation + - — + - IMEA (rel. 6e-2) + - 0.7805 + * - :ref:`STAT_NASSENSTEIN_DIAM_MODE ` + - Nassenstein diameter, mode + - — + - regression + - 11 diff --git a/docs/source/Math/neighbors_index.inc b/docs/source/Math/neighbors_index.inc new file mode 100644 index 000000000..f651a7db8 --- /dev/null +++ b/docs/source/Math/neighbors_index.inc @@ -0,0 +1,57 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`NUM_NEIGHBORS ` + - Number of neighbours + - — + - CellProfiler (abs. 1e-9) + - 1 + * - :ref:`PERCENT_TOUCHING ` + - Percent of the contour touching + - — + - regression + - 15.91 + * - :ref:`CLOSEST_NEIGHBOR1_DIST ` + - Distance to the closest neighbour + - — + - CellProfiler (abs. 1e-9) + - 7.632 + * - :ref:`CLOSEST_NEIGHBOR1_ANG ` + - Direction of the closest neighbour + - — + - analytic (abs. 1e-9) + - 354.4 + * - :ref:`CLOSEST_NEIGHBOR2_DIST ` + - Distance to the second-closest neighbour + - — + - analytic (abs. 1e-9) + - 0 + * - :ref:`CLOSEST_NEIGHBOR2_ANG ` + - Direction of the second-closest neighbour + - — + - analytic (abs. 1e-9) + - 0 + * - :ref:`ANG_BW_NEIGHBORS_MEAN ` + - Mean neighbour direction + - — + - analytic (abs. 1e-9) + - 354.4 + * - :ref:`ANG_BW_NEIGHBORS_STDDEV ` + - Neighbour direction standard deviation + - — + - analytic (abs. 1e-9) + - 0 + * - :ref:`ANG_BW_NEIGHBORS_MODE ` + - Most frequent neighbour direction + - — + - analytic (abs. 1e-9) + - 354 diff --git a/docs/source/Math/ngldm_3d_index.inc b/docs/source/Math/ngldm_3d_index.inc new file mode 100644 index 000000000..7fe674e75 --- /dev/null +++ b/docs/source/Math/ngldm_3d_index.inc @@ -0,0 +1,107 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`3NGLDM_LDE ` + - Low dependence emphasis + - SODN + - MIRP (rel. 1e-3) + - 0.08747 + * - :ref:`3NGLDM_HDE ` + - High dependence emphasis + - IMOQ + - MIRP (rel. 1e-3) + - 26.26 + * - :ref:`3NGLDM_LGLCE ` + - Low grey level count emphasis + - TL9H + - MIRP (rel. 1e-3) + - 0.3719 + * - :ref:`3NGLDM_HGLCE ` + - High grey level count emphasis + - OAE7 + - MIRP (rel. 1e-3) + - 7 + * - :ref:`3NGLDM_LDLGLE ` + - Low dependence low grey level emphasis + - EQ3F + - MIRP (rel. 1e-3) + - 0.05245 + * - :ref:`3NGLDM_LDHGLE ` + - Low dependence high grey level emphasis + - JA6D + - MIRP (rel. 1e-3) + - 0.4688 + * - :ref:`3NGLDM_HDLGLE ` + - High dependence low grey level emphasis + - NBZI + - MIRP (rel. 1e-3) + - 9.671 + * - :ref:`3NGLDM_HDHGLE ` + - High dependence high grey level emphasis + - 9QMG + - MIRP (rel. 1e-3) + - 156.1 + * - :ref:`3NGLDM_GLNU ` + - Grey level non-uniformity + - FP8K + - MIRP (rel. 1e-3) + - 6.852 + * - :ref:`3NGLDM_GLNUN ` + - Grey level non-uniformity normalized + - 5SPA + - MIRP (rel. 1e-3) + - 0.2538 + * - :ref:`3NGLDM_DCNU ` + - Dependence count non-uniformity + - Z87G + - MIRP (rel. 1e-3) + - 5.37 + * - :ref:`3NGLDM_DCNUN ` + - Dependence count non-uniformity normalized + - OKJI + - MIRP (rel. 1e-3) + - 0.1989 + * - :ref:`3NGLDM_DCP ` + - Dependence count percentage + - 6XV8 + - regression + - 1 + * - :ref:`3NGLDM_GLM ` + - Grey level mean + - — + - regression + - 2.407 + * - :ref:`3NGLDM_GLV ` + - Grey level variance + - 1PFV + - MIRP (rel. 1e-3) + - 1.204 + * - :ref:`3NGLDM_DCM ` + - Dependence count mean + - — + - regression + - 4.852 + * - :ref:`3NGLDM_DCV ` + - Dependence count variance + - DNX2 + - MIRP (rel. 1e-3) + - 2.719 + * - :ref:`3NGLDM_DCENT ` + - Dependence count entropy + - FCBV + - MIRP (rel. 1e-3) + - 3.56 + * - :ref:`3NGLDM_DCENE ` + - Dependence count energy + - CAS9 + - MIRP (rel. 1e-3) + - 0.09191 diff --git a/docs/source/Math/ngldm_index.inc b/docs/source/Math/ngldm_index.inc new file mode 100644 index 000000000..964c961f6 --- /dev/null +++ b/docs/source/Math/ngldm_index.inc @@ -0,0 +1,107 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`NGLDM_LDE ` + - Low dependence emphasis + - SODN + - MIRP (rel. 1e-9) + - 0.3725 + * - :ref:`NGLDM_HDE ` + - High dependence emphasis + - IMOQ + - MIRP (rel. 1e-9) + - 5.56 + * - :ref:`NGLDM_LGLCE ` + - Low grey level count emphasis + - TL9H + - MIRP (rel. 1e-9) + - 0.2723 + * - :ref:`NGLDM_HGLCE ` + - High grey level count emphasis + - OAE7 + - MIRP (rel. 1e-9) + - 9.28 + * - :ref:`NGLDM_LDLGLE ` + - Low dependence low grey level emphasis + - EQ3F + - MIRP (rel. 1e-9) + - 0.05914 + * - :ref:`NGLDM_LDHGLE ` + - Low dependence high grey level emphasis + - JA6D + - MIRP (rel. 1e-9) + - 5.155 + * - :ref:`NGLDM_HDLGLE ` + - High dependence low grey level emphasis + - NBZI + - MIRP (rel. 1e-9) + - 2.143 + * - :ref:`NGLDM_HDHGLE ` + - High dependence high grey level emphasis + - 9QMG + - MIRP (rel. 1e-9) + - 35.12 + * - :ref:`NGLDM_GLNU ` + - Grey level non-uniformity + - FP8K + - MIRP (rel. 1e-9) + - 6.04 + * - :ref:`NGLDM_GLNUN ` + - Grey level non-uniformity normalized + - 5SPA + - MIRP (rel. 1e-9) + - 0.2416 + * - :ref:`NGLDM_DCNU ` + - Dependence count non-uniformity + - Z87G + - MIRP (rel. 1e-9) + - 7.96 + * - :ref:`NGLDM_DCNUN ` + - Dependence count non-uniformity normalized + - OKJI + - MIRP (rel. 1e-9) + - 0.3184 + * - :ref:`NGLDM_DCP ` + - Dependence count percentage + - 6XV8 + - MIRP (rel. 1e-9) + - 1 + * - :ref:`NGLDM_GLM ` + - Grey level mean + - — + - regression + - 2.8 + * - :ref:`NGLDM_GLV ` + - Grey level variance + - 1PFV + - MIRP (rel. 1e-9) + - 1.44 + * - :ref:`NGLDM_DCM ` + - Dependence count mean + - — + - regression + - 2.2 + * - :ref:`NGLDM_DCV ` + - Dependence count variance + - DNX2 + - MIRP (rel. 1e-9) + - 0.72 + * - :ref:`NGLDM_DCENT ` + - Dependence count entropy + - FCBV + - MIRP (rel. 1e-9) + - 3.383 + * - :ref:`NGLDM_DCENE ` + - Dependence count energy + - CAS9 + - MIRP (rel. 1e-9) + - 0.1072 diff --git a/docs/source/Math/ngtdm_3d_index.inc b/docs/source/Math/ngtdm_3d_index.inc new file mode 100644 index 000000000..e71dacf8f --- /dev/null +++ b/docs/source/Math/ngtdm_3d_index.inc @@ -0,0 +1,37 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`3NGTDM_COARSENESS ` + - Coarseness + - QCDE + - PyRadiomics (rel. 1e-9) + - 0.1549 + * - :ref:`3NGTDM_CONTRAST ` + - Contrast + - 65HE + - PyRadiomics (rel. 1e-9) + - 0.1889 + * - :ref:`3NGTDM_BUSYNESS ` + - Busyness + - NQ30 + - PyRadiomics (rel. 1e-9) + - 1.477 + * - :ref:`3NGTDM_COMPLEXITY ` + - Complexity + - HDEZ + - PyRadiomics (rel. 1e-9) + - 5.241 + * - :ref:`3NGTDM_STRENGTH ` + - Strength + - 1X9X + - PyRadiomics (rel. 1e-9) + - 0.7111 diff --git a/docs/source/Math/ngtdm_index.inc b/docs/source/Math/ngtdm_index.inc new file mode 100644 index 000000000..34b01e5b3 --- /dev/null +++ b/docs/source/Math/ngtdm_index.inc @@ -0,0 +1,37 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`NGTDM_COARSENESS ` + - Coarseness + - QCDE + - MIRP (abs. 1e-9) + - 0.2006 + * - :ref:`NGTDM_CONTRAST ` + - Contrast + - 65HE + - MIRP (abs. 1e-9) + - 0.146 + * - :ref:`NGTDM_BUSYNESS ` + - Busyness + - NQ30 + - MIRP (abs. 1e-9) + - 0.636 + * - :ref:`NGTDM_COMPLEXITY ` + - Complexity + - HDEZ + - MIRP (abs. 1e-9) + - 8.251 + * - :ref:`NGTDM_STRENGTH ` + - Strength + - 1X9X + - MIRP (abs. 1e-9) + - 1.373 diff --git a/docs/source/Math/radial_index.inc b/docs/source/Math/radial_index.inc new file mode 100644 index 000000000..1e22f7ca7 --- /dev/null +++ b/docs/source/Math/radial_index.inc @@ -0,0 +1,27 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`FRAC_AT_D ` + - Fraction of pixels in a ring + - — + - regression + - 0.02128 + * - :ref:`MEAN_FRAC ` + - Mean intensity in a ring + - — + - regression + - 7 + * - :ref:`RADIAL_CV ` + - Coefficient of variation across the wedges of a ring + - — + - regression + - 2.646 diff --git a/docs/source/Math/roi_radius_index.inc b/docs/source/Math/roi_radius_index.inc new file mode 100644 index 000000000..d95f00a99 --- /dev/null +++ b/docs/source/Math/roi_radius_index.inc @@ -0,0 +1,27 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`ROI_RADIUS_MEAN ` + - Mean distance to the contour + - — + - regression + - 1.124 + * - :ref:`ROI_RADIUS_MAX ` + - Largest distance to the contour + - — + - analytic (exact), scikit-image (rel. 1e-3) + - 3 + * - :ref:`ROI_RADIUS_MEDIAN ` + - Median distance to the contour + - — + - scikit-image (rel. 1e-3) + - 1 diff --git a/docs/source/Math/shape_3d_index.inc b/docs/source/Math/shape_3d_index.inc new file mode 100644 index 000000000..03c8efdf8 --- /dev/null +++ b/docs/source/Math/shape_3d_index.inc @@ -0,0 +1,82 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`3AREA ` + - Surface area (exposed voxel faces) + - — + - regression + - 96 + * - :ref:`3AREA_2_VOLUME ` + - Surface to volume ratio + - — + - regression + - 2.182 + * - :ref:`3COMPACTNESS1 ` + - Compactness 1 + - — + - regression + - 0.02639 + * - :ref:`3COMPACTNESS2 ` + - Compactness 2 + - — + - regression + - 0.2475 + * - :ref:`3MESH_VOLUME ` + - Volume (mesh) + - RNU0 + - analytic (rel. 1e-12), MIRP (rel. 1e-6), PyRadiomics (rel. 1e-9) + - 39.58 + * - :ref:`3SPHERICAL_DISPROPORTION ` + - Spherical disproportion + - — + - regression + - 1.593 + * - :ref:`3SPHERICITY ` + - Sphericity + - — + - regression + - 0.6278 + * - :ref:`3VOLUME_CONVEXHULL ` + - Convex hull volume + - — + - analytic (rel. 1e-12), MATLAB (rel. 5e-3), MIRP (rel. 1e-6) + - 40.67 + * - :ref:`3VOXEL_VOLUME ` + - Volume (voxel counting) + - YEKZ + - MATLAB (rel. 1e-3), MIRP (rel. 1e-3) + - 44 + * - :ref:`3MAJOR_AXIS_LEN ` + - Major axis length + - TDIC + - MIRP (rel. 1e-9), PyRadiomics (rel. 1e-9) + - 5.786 + * - :ref:`3MINOR_AXIS_LEN ` + - Minor axis length + - P9VJ + - MIRP (rel. 1e-9), PyRadiomics (rel. 1e-9) + - 3.881 + * - :ref:`3LEAST_AXIS_LEN ` + - Least axis length + - 7J51 + - MIRP (rel. 1e-9), PyRadiomics (rel. 1e-9) + - 2.892 + * - :ref:`3ELONGATION ` + - Elongation + - Q3CK + - MIRP (rel. 1e-9), PyRadiomics (rel. 1e-9) + - 0.6708 + * - :ref:`3FLATNESS ` + - Flatness + - N17B + - MIRP (rel. 1e-9), PyRadiomics (rel. 1e-9) + - 0.4998 diff --git a/docs/source/Math/shape_moments_index.inc b/docs/source/Math/shape_moments_index.inc new file mode 100644 index 000000000..7dfac83d0 --- /dev/null +++ b/docs/source/Math/shape_moments_index.inc @@ -0,0 +1,462 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`SPAT_MOMENT_00 ` + - Raw moment (0, 0) + - — + - scikit-image (rel. 1e-6) + - 40 + * - :ref:`SPAT_MOMENT_01 ` + - Raw moment (0, 1) + - — + - scikit-image (rel. 1e-6) + - 117 + * - :ref:`SPAT_MOMENT_02 ` + - Raw moment (0, 2) + - — + - scikit-image (rel. 1e-6) + - 455 + * - :ref:`SPAT_MOMENT_03 ` + - Raw moment (0, 3) + - — + - scikit-image (rel. 1e-6) + - 2007 + * - :ref:`SPAT_MOMENT_10 ` + - Raw moment (1, 0) + - — + - scikit-image (rel. 1e-6) + - 131 + * - :ref:`SPAT_MOMENT_11 ` + - Raw moment (1, 1) + - — + - scikit-image (rel. 1e-6) + - 374 + * - :ref:`SPAT_MOMENT_12 ` + - Raw moment (1, 2) + - — + - scikit-image (rel. 1e-6) + - 1420 + * - :ref:`SPAT_MOMENT_13 ` + - Raw moment (1, 3) + - — + - scikit-image (rel. 1e-6) + - 6164 + * - :ref:`SPAT_MOMENT_20 ` + - Raw moment (2, 0) + - — + - scikit-image (rel. 1e-6) + - 575 + * - :ref:`SPAT_MOMENT_21 ` + - Raw moment (2, 1) + - — + - scikit-image (rel. 1e-6) + - 1604 + * - :ref:`SPAT_MOMENT_22 ` + - Raw moment (2, 2) + - — + - scikit-image (rel. 1e-6) + - 5786 + * - :ref:`SPAT_MOMENT_23 ` + - Raw moment (2, 3) + - — + - scikit-image (rel. 1e-6) + - 23950 + * - :ref:`SPAT_MOMENT_30 ` + - Raw moment (3, 0) + - — + - scikit-image (rel. 1e-6) + - 2873 + * - :ref:`CENTRAL_MOMENT_00 ` + - Central moment (0, 0) + - — + - scikit-image (rel. 1e-6) + - 40 + * - :ref:`CENTRAL_MOMENT_01 ` + - Central moment (0, 1) + - — + - scikit-image (rel. 1e-6) + - 0 + * - :ref:`CENTRAL_MOMENT_02 ` + - Central moment (0, 2) + - — + - scikit-image (rel. 1e-6) + - 112.8 + * - :ref:`CENTRAL_MOMENT_03 ` + - Central moment (0, 3) + - — + - scikit-image (rel. 1e-6) + - 16.39 + * - :ref:`CENTRAL_MOMENT_10 ` + - Central moment (1, 0) + - — + - scikit-image (rel. 1e-6) + - 0 + * - :ref:`CENTRAL_MOMENT_11 ` + - Central moment (1, 1) + - — + - scikit-image (rel. 1e-6) + - −9.175 + * - :ref:`CENTRAL_MOMENT_12 ` + - Central moment (1, 2) + - — + - scikit-image (rel. 1e-6) + - −16.45 + * - :ref:`CENTRAL_MOMENT_13 ` + - Central moment (1, 3) + - — + - scikit-image (rel. 1e-6) + - −29.07 + * - :ref:`CENTRAL_MOMENT_20 ` + - Central moment (2, 0) + - — + - scikit-image (rel. 1e-6) + - 146 + * - :ref:`CENTRAL_MOMENT_21 ` + - Central moment (2, 1) + - — + - scikit-image (rel. 1e-6) + - −17.78 + * - :ref:`CENTRAL_MOMENT_22 ` + - Central moment (2, 2) + - — + - scikit-image (rel. 1e-6) + - 220.3 + * - :ref:`CENTRAL_MOMENT_23 ` + - Central moment (2, 3) + - — + - scikit-image (rel. 1e-6) + - −23.36 + * - :ref:`CENTRAL_MOMENT_30 ` + - Central moment (3, 0) + - — + - scikit-image (rel. 1e-6) + - 33.74 + * - :ref:`CENTRAL_MOMENT_31 ` + - Central moment (3, 1) + - — + - scikit-image (rel. 1e-6) + - −89.63 + * - :ref:`CENTRAL_MOMENT_32 ` + - Central moment (3, 2) + - — + - scikit-image (rel. 1e-6) + - −25.15 + * - :ref:`CENTRAL_MOMENT_33 ` + - Central moment (3, 3) + - — + - scikit-image (rel. 1e-6) + - −217.7 + * - :ref:`NORM_SPAT_MOMENT_00 ` + - Normalized raw moment (0, 0) + - — + - scikit-image (rel. 1e-6) + - 1 + * - :ref:`NORM_SPAT_MOMENT_01 ` + - Normalized raw moment (0, 1) + - — + - scikit-image (rel. 1e-6) + - 0.4625 + * - :ref:`NORM_SPAT_MOMENT_02 ` + - Normalized raw moment (0, 2) + - — + - scikit-image (rel. 1e-6) + - 0.2844 + * - :ref:`NORM_SPAT_MOMENT_03 ` + - Normalized raw moment (0, 3) + - — + - scikit-image (rel. 1e-6) + - 0.1983 + * - :ref:`NORM_SPAT_MOMENT_10 ` + - Normalized raw moment (1, 0) + - — + - scikit-image (rel. 1e-6) + - 0.5178 + * - :ref:`NORM_SPAT_MOMENT_11 ` + - Normalized raw moment (1, 1) + - — + - scikit-image (rel. 1e-6) + - 0.2338 + * - :ref:`NORM_SPAT_MOMENT_12 ` + - Normalized raw moment (1, 2) + - — + - scikit-image (rel. 1e-6) + - 0.1403 + * - :ref:`NORM_SPAT_MOMENT_13 ` + - Normalized raw moment (1, 3) + - — + - scikit-image (rel. 1e-6) + - 0.09631 + * - :ref:`NORM_SPAT_MOMENT_20 ` + - Normalized raw moment (2, 0) + - — + - scikit-image (rel. 1e-6) + - 0.3594 + * - :ref:`NORM_SPAT_MOMENT_21 ` + - Normalized raw moment (2, 1) + - — + - scikit-image (rel. 1e-6) + - 0.1585 + * - :ref:`NORM_SPAT_MOMENT_22 ` + - Normalized raw moment (2, 2) + - — + - scikit-image (rel. 1e-6) + - 0.09041 + * - :ref:`NORM_SPAT_MOMENT_23 ` + - Normalized raw moment (2, 3) + - — + - scikit-image (rel. 1e-6) + - 0.05918 + * - :ref:`NORM_SPAT_MOMENT_30 ` + - Normalized raw moment (3, 0) + - — + - scikit-image (rel. 1e-6) + - 0.2839 + * - :ref:`NORM_SPAT_MOMENT_31 ` + - Normalized raw moment (3, 1) + - — + - scikit-image (rel. 1e-6) + - 0.1226 + * - :ref:`NORM_SPAT_MOMENT_32 ` + - Normalized raw moment (3, 2) + - — + - scikit-image (rel. 1e-6) + - 0.0664 + * - :ref:`NORM_SPAT_MOMENT_33 ` + - Normalized raw moment (3, 3) + - — + - scikit-image (rel. 1e-6) + - 0.04126 + * - :ref:`NORM_CENTRAL_MOMENT_02 ` + - Normalized central moment (0, 2) + - — + - scikit-image (rel. 1e-6) + - 0.07048 + * - :ref:`NORM_CENTRAL_MOMENT_03 ` + - Normalized central moment (0, 3) + - — + - scikit-image (rel. 1e-6) + - 0.00162 + * - :ref:`NORM_CENTRAL_MOMENT_11 ` + - Normalized central moment (1, 1) + - — + - scikit-image (rel. 1e-6) + - −0.005734 + * - :ref:`NORM_CENTRAL_MOMENT_12 ` + - Normalized central moment (1, 2) + - — + - scikit-image (rel. 1e-6) + - −0.001626 + * - :ref:`NORM_CENTRAL_MOMENT_20 ` + - Normalized central moment (2, 0) + - — + - scikit-image (rel. 1e-6) + - 0.09123 + * - :ref:`NORM_CENTRAL_MOMENT_21 ` + - Normalized central moment (2, 1) + - — + - scikit-image (rel. 1e-6) + - −0.001757 + * - :ref:`NORM_CENTRAL_MOMENT_30 ` + - Normalized central moment (3, 0) + - — + - scikit-image (rel. 1e-6) + - 0.003334 + * - :ref:`HU_M1 ` + - Hu invariant 1 + - — + - scikit-image (rel. 1e-6) + - 0.1617 + * - :ref:`HU_M2 ` + - Hu invariant 2 + - — + - scikit-image (rel. 1e-6) + - 0.0005621 + * - :ref:`HU_M3 ` + - Hu invariant 3 + - — + - scikit-image (rel. 1e-6) + - 0.0001149 + * - :ref:`HU_M4 ` + - Hu invariant 4 + - — + - scikit-image (rel. 1e-6) + - 2.937e−06 + * - :ref:`HU_M5 ` + - Hu invariant 5 + - — + - scikit-image (rel. 1e-6) + - 4.84e−11 + * - :ref:`HU_M6 ` + - Hu invariant 6 + - — + - scikit-image (rel. 1e-6) + - 6.554e−08 + * - :ref:`HU_M7 ` + - Hu invariant 7 + - — + - scikit-image (rel. 1e-6) + - −2.386e−11 + * - :ref:`WEIGHTED_SPAT_MOMENT_00 ` + - Weighted raw moment (0, 0) + - — + - regression + - −42.77 + * - :ref:`WEIGHTED_SPAT_MOMENT_01 ` + - Weighted raw moment (0, 1) + - — + - regression + - −113.6 + * - :ref:`WEIGHTED_SPAT_MOMENT_02 ` + - Weighted raw moment (0, 2) + - — + - regression + - −473.2 + * - :ref:`WEIGHTED_SPAT_MOMENT_03 ` + - Weighted raw moment (0, 3) + - — + - regression + - −2419 + * - :ref:`WEIGHTED_SPAT_MOMENT_10 ` + - Weighted raw moment (1, 0) + - — + - regression + - −170.2 + * - :ref:`WEIGHTED_SPAT_MOMENT_11 ` + - Weighted raw moment (1, 1) + - — + - regression + - −298.8 + * - :ref:`WEIGHTED_SPAT_MOMENT_12 ` + - Weighted raw moment (1, 2) + - — + - regression + - −795.9 + * - :ref:`WEIGHTED_SPAT_MOMENT_20 ` + - Weighted raw moment (2, 0) + - — + - regression + - −860.8 + * - :ref:`WEIGHTED_SPAT_MOMENT_21 ` + - Weighted raw moment (2, 1) + - — + - regression + - −1315 + * - :ref:`WEIGHTED_SPAT_MOMENT_30 ` + - Weighted raw moment (3, 0) + - — + - regression + - −4942 + * - :ref:`WEIGHTED_CENTRAL_MOMENT_02 ` + - Weighted central moment (0, 2) + - — + - regression + - −171.6 + * - :ref:`WEIGHTED_CENTRAL_MOMENT_03 ` + - Weighted central moment (0, 3) + - — + - regression + - −251.6 + * - :ref:`WEIGHTED_CENTRAL_MOMENT_11 ` + - Weighted central moment (1, 1) + - — + - regression + - 153.2 + * - :ref:`WEIGHTED_CENTRAL_MOMENT_12 ` + - Weighted central moment (1, 2) + - — + - regression + - 274.1 + * - :ref:`WEIGHTED_CENTRAL_MOMENT_20 ` + - Weighted central moment (2, 0) + - — + - regression + - −183.1 + * - :ref:`WEIGHTED_CENTRAL_MOMENT_21 ` + - Weighted central moment (2, 1) + - — + - regression + - −248.6 + * - :ref:`WEIGHTED_CENTRAL_MOMENT_30 ` + - Weighted central moment (3, 0) + - — + - regression + - −57.93 + * - :ref:`WT_NORM_CTR_MOM_02 ` + - Weighted normalized central moment (0, 2) + - — + - regression + - −0.09385 + * - :ref:`WT_NORM_CTR_MOM_03 ` + - Weighted normalized central moment (0, 3) + - — + - regression + - 0 + * - :ref:`WT_NORM_CTR_MOM_11 ` + - Weighted normalized central moment (1, 1) + - — + - regression + - 0.08376 + * - :ref:`WT_NORM_CTR_MOM_12 ` + - Weighted normalized central moment (1, 2) + - — + - regression + - 0 + * - :ref:`WT_NORM_CTR_MOM_20 ` + - Weighted normalized central moment (2, 0) + - — + - regression + - −0.1001 + * - :ref:`WT_NORM_CTR_MOM_21 ` + - Weighted normalized central moment (2, 1) + - — + - regression + - 0 + * - :ref:`WT_NORM_CTR_MOM_30 ` + - Weighted normalized central moment (3, 0) + - — + - regression + - 0 + * - :ref:`WEIGHTED_HU_M1 ` + - Weighted Hu invariant 1 + - — + - regression + - −0.194 + * - :ref:`WEIGHTED_HU_M2 ` + - Weighted Hu invariant 2 + - — + - regression + - 0.0281 + * - :ref:`WEIGHTED_HU_M3 ` + - Weighted Hu invariant 3 + - — + - regression + - 0 + * - :ref:`WEIGHTED_HU_M4 ` + - Weighted Hu invariant 4 + - — + - regression + - 0 + * - :ref:`WEIGHTED_HU_M5 ` + - Weighted Hu invariant 5 + - — + - regression + - 0 + * - :ref:`WEIGHTED_HU_M6 ` + - Weighted Hu invariant 6 + - — + - regression + - 0 + * - :ref:`WEIGHTED_HU_M7 ` + - Weighted Hu invariant 7 + - — + - regression + - 0 diff --git a/docs/source/Math/structuring_element.jpg b/docs/source/Math/structuring_element.jpg deleted file mode 100644 index 82c93207f..000000000 Binary files a/docs/source/Math/structuring_element.jpg and /dev/null differ diff --git a/docs/source/Math/structuring_element_2.jpg b/docs/source/Math/structuring_element_2.jpg deleted file mode 100644 index 8b9304719..000000000 Binary files a/docs/source/Math/structuring_element_2.jpg and /dev/null differ diff --git a/docs/source/Math/translation.jpg b/docs/source/Math/translation.jpg deleted file mode 100644 index 9456ae592..000000000 Binary files a/docs/source/Math/translation.jpg and /dev/null differ diff --git a/docs/source/Math/zernike_index.inc b/docs/source/Math/zernike_index.inc new file mode 100644 index 000000000..cb0884161 --- /dev/null +++ b/docs/source/Math/zernike_index.inc @@ -0,0 +1,17 @@ +.. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and + tests/vetting/oracle_coverage.csv; do not edit by hand. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 8 26 8 + + * - Code + - Name + - IBSI + - Validation + - Example + * - :ref:`ZERNIKE2D ` + - Zernike moments + - — + - analytic (rel. 1e-12) + - 0.3183 diff --git a/docs/source/References/Classes.rst b/docs/source/References/Classes.rst index 113ae3021..ad49e489a 100644 --- a/docs/source/References/Classes.rst +++ b/docs/source/References/Classes.rst @@ -1,7 +1,19 @@ Nyxus Classes -================ +============= + .. autosummary:: - :toctree: stubs + :toctree: stubs nyxus.Nyxus - nyxus.Nested \ No newline at end of file + nyxus.Nyxus3D + nyxus.Nested + nyxus.ImageQuality + +Functions +========= + +.. autosummary:: + :toctree: stubs + + nyxus.gpu_is_available + nyxus.get_gpu_properties diff --git a/docs/source/api_reference.rst b/docs/source/api_reference.rst new file mode 100644 index 000000000..601043ebd --- /dev/null +++ b/docs/source/api_reference.rst @@ -0,0 +1,10 @@ +API reference +============= + +The Python interface and the command-line options. + +.. toctree:: + :maxdepth: 2 + + Reference + cli_reference diff --git a/docs/source/cli_reference.rst b/docs/source/cli_reference.rst new file mode 100644 index 000000000..b6e0ace7c --- /dev/null +++ b/docs/source/cli_reference.rst @@ -0,0 +1,338 @@ +.. This page is generated by docs/tools/gen_cli_reference.py from docs/cli_options.toml. + Edit the table and re-run the script; do not edit this file by hand. + +.. _cli-reference: + +Command-line reference +====================== + +Every option is written either as ``--option=value`` or as ``--option value``. +Options marked *required* must be given on every run. + +Input +----- + +.. list-table:: + :header-rows: 1 + :widths: 22 10 16 52 + + * - Option + - Required + - Default + - Description + * - ``--intDir`` + - yes + - — + - Directory holding the intensity images. Values: path to an existing directory. Required even when ``--intSegMapFile`` is used: mapped intensity file names are resolved relative to this directory. When it is the same directory as ``--segDir``, Nyxus runs in whole-slide mode and treats each image as one ROI. + * - ``--segDir`` + - yes + - — + - Directory holding the label (mask) images that define the ROIs. Values: path to an existing directory. Set it to the same directory as ``--intDir`` for whole-slide mode, where each intensity image is a single ROI and no mask is read. Required even with ``--intSegMapFile``: mapped mask file names are resolved relative to this directory. + * - ``--filePattern`` + - no + - ``.*`` + - Selects which files in the input directories are processed. In 2D the pattern must match the whole file name. Values: regular expression (2D); regular expression or file pattern with ``{d+}``, ``{c+}``, ``{set d+}`` (3D). A regular expression, so use ``.*\.tif`` rather than ``*.tif``. In 3D a pattern containing ``{set d+}`` is read as a z-slice file pattern, for example ``BRATS_{d+}_z{set d+}_t{d+}.ome.tif``. Such a pattern currently matches no files and the run stops with 'No intensity and/or label file pairs to process'. Not applied to pairs listed in an ``--intSegMapFile``. + * - ``--intSegMapFile`` + - no + - — + - Names a text file that lists explicit intensity/mask pairs, one pair per line, instead of matching files by name. Values: file name inside ``--intSegMapDir``. Each line holds an intensity file name and then a mask file name, separated by whitespace. Intensity names are relative to ``--intDir`` and mask names to ``--segDir``. Requires ``--intSegMapDir``. ``--filePattern`` is not applied to listed pairs. 2D only. + * - ``--intSegMapDir`` + - no + - — + - Directory that contains the ``--intSegMapFile`` pairing file. Values: path to an existing directory. Has no effect unless ``--intSegMapFile`` is also given. + * - ``--dim`` + - no + - ``2`` + - Sets whether the dataset is processed as 2D images or as 3D volumes. Values: 2 \| 3. In 3D the default feature set is ``*3D_ALL*``, the Gabor and nested-ROI options are rejected, and ``--intSegMapFile`` is ignored. + +Output +------ + +.. list-table:: + :header-rows: 1 + :widths: 22 10 16 52 + + * - Option + - Required + - Default + - Description + * - ``--outDir`` + - yes + - — + - Directory where result files are written. Values: path to an existing directory. The directory must already exist. + * - ``--outputType`` + - yes + - — + - Selects the result format: one CSV per image pair, one combined CSV, an Arrow IPC file, or a Parquet file. Values: separatecsv \| singlecsv \| arrowipc \| parquet (case-insensitive). There is no default. arrowipc and parquet need a build with Arrow support. + * - ``--resultFname`` + - no + - ``NyxusFeatures`` + - Base name of the result file for singlecsv, arrowipc and parquet output. Values: non-empty file name without extension. Ignored with separatecsv, which names each file after its image pair. + * - ``--noval`` + - no + - ``0.0`` + - Value reported in place of a feature that cannot be computed (NaN or undefined). Values: real number. In Arrow and Parquet output, values that are still NaN or infinite when they are written become -0.0 instead, whatever ``--noval`` is. A non-numeric value stops the run with an error. + * - ``--tinyval`` + - no + - ``1e-10`` + - Accepted and validated, but no feature reads it. Values: real number. Intended as the small constant feature formulas use to avoid division by zero; the stored value is not used by any feature. A non-numeric value stops the run with an error. + * - ``--aggr`` + - no + - ``false`` + - When true, writes one row per image holding the mean of each feature over that image's ROIs, labelled ROI -1, instead of one row per ROI. Values: true \| false \| t \| f (case-insensitive). Applies only to CSV output (separatecsv or singlecsv) for images with masks. Arrow, Parquet and whole-slide output ignore it. Values the features substituted with ``--noval`` enter the mean at that value. A value still NaN at output time contributes the full ``--noval`` rather than ``--noval`` divided by the number of ROIs, and an infinite value is not replaced, so the aggregated cell can be inf. + * - ``--annot`` + - no + - ``false`` + - When true, adds annotation columns (anno0, anno1, ...) to CSV output. Their values come from the mask file name split at underscores. Values: true \| false \| t \| f (case-insensitive). Only the part of the mask file name before the first dot is used, and empty pieces are dropped. Annotation columns appear in CSV output only. With Arrow IPC or Parquet output, ``--annot=true`` currently stops the run with a crash and leaves an unreadable file (known defect, https://github.com/PolusAI/nyxus/issues/517). + * - ``--annotsep`` + - no + - ``_`` + - Separator for splitting file names into annotations. Values: string. The value is accepted but annotations are always split at '_'. + +Features +-------- + +.. list-table:: + :header-rows: 1 + :widths: 22 10 16 52 + + * - Option + - Required + - Default + - Description + * - ``--features`` + - no + - ``*ALL* (2D) or *3D_ALL* (3D)`` + - Selects the features or feature groups to compute. Values: comma-separated feature and group names (case-insensitive), or the path of a text file listing them. If omitted, ``*ALL*`` (2D) or ``*3D_ALL*`` (3D) is computed; in 2D the ``IH_*`` features are then left out unless ``--ibsi=true``. If the value is the path of an existing file, the names are read from it: one or more per line, and ``#`` starts a comment. Names are case-insensitive. An unknown name stops the run and lists the valid names. Image-quality names cannot be mixed with other features. Intensity Histogram (``IH_*``) features need ``--ibsi=true``. + * - ``--ibsi`` + - no + - ``false`` + - Turns on IBSI compliance mode: texture families skip grey-level binning and follow IBSI definitions, and the Intensity Histogram family becomes available. Values: true \| 1 \| on (case-insensitive); any other value means false. Invalid values are not reported; they silently mean false. + * - ``--mergerois`` + - no + - ``false`` + - Merges all nonzero mask labels into one ROI covering the whole foreground, instead of one ROI per label. Values: true \| 1 \| on (case-insensitive); any other value means false. Background (label 0) is still excluded. Invalid values are not reported; they silently mean false. + * - ``--skiproi`` + - no + - ``no ROIs skipped`` + - Leaves ROIs with the listed labels out of feature calculation and output, either in every image or only in named mask files. Values: label[,label...] or maskfile:label[,label...][;maskfile:label...]. File names are mask file names without directories. A plain label list applies to every image and takes precedence over file-specific lists. Not used in whole-slide mode. + * - ``--pixelDistance`` + - no + - ``5`` + - Distance in pixels within which two ROIs count as neighbors, for the neighbor features. Values: integer >= 1. + * - ``--pixelsPerCentimeter`` + - no + - — + - Image resolution, for reporting area in square micrometres (AREA_UM2). Values: real number > 0. The value is accepted and validated but does not reach the results: AREA_UM2 is reported as 0 whatever the resolution (known defect, https://github.com/PolusAI/nyxus/issues/496). + * - ``--embeddedpixelsize`` + - no + - — + - Accepted for compatibility. Values: any string. The value is accepted and shown in the run summary but does not affect any result. + * - ``--onlineStatsThresh`` + - no + - — + - Reserved option name. Values: not accepted. The name is reserved but the option is not accepted: passing it stops the run as an unrecognized argument. + +Texture settings +---------------- + +.. list-table:: + :header-rows: 1 + :widths: 22 10 16 52 + + * - Option + - Required + - Default + - Description + * - ``--coarseGrayDepth`` + - no + - ``64`` + - Grey depth used to bin intensities for the 2D texture families, 3D GLCM, 3D GLDZM and 3D NGLDM, and the bin count of the ENTROPY, UNIFORMITY and HISTOGRAM histograms and of the Intensity Histogram (``IH_*``) family. Values: integer: > 0 MATLAB-style bins from 0 to the ROI maximum, < 0 \|n\| PyRadiomics-style bins over the ROI range, 0 no binning for the texture families that use the shared rule. 3D GLRLM, GLSZM, GLDM and NGTDM ignore it: they use their own greydepth metaparameter. NGLDM uses its own rule: 0 puts every pixel at level 0, and a negative value does not select PyRadiomics-style bins. ENTROPY and UNIFORMITY use \|n\| bins; HISTOGRAM writes n columns and none when n is negative (a fix is coming); none of the three has bins at 0; the ``IH_*`` features return the ``--noval`` substitute when n is below 2. ``--ibsi=true`` makes every texture family skip binning, except the 2D GLCM test for a constant ROI. The value is read as its leading integer: 8.5 runs as 8, and a value that does not start with an integer stops the run with an error. + * - ``--glcmAngles`` + - no + - ``0,45,90,135`` + - Directions, in degrees, at which 2D GLCM co-occurrence matrices are computed. GLCM output has one column per angle. Values: comma-separated integers from {0, 45, 90, 135}. 2D GLCM only: 3D GLCM always uses its own fixed directions. Other integers pass parsing, but the calculation reports 'unsupported angle' for them and their columns hold 0. A non-integer list stops the run with an error. + * - ``--glcmOff`` + - no + - ``1`` + - GLCM co-occurrence distance in pixels. Values: integer >= 1. The value is validated but does not reach the GLCM calculation, which always uses an offset of 1. + +Gabor +----- + +.. list-table:: + :header-rows: 1 + :widths: 22 10 16 52 + + * - Option + - Required + - Default + - Description + * - ``--gaborfreqs`` + - no + - ``built-in pairs (see notes)`` + - Frequencies of the Gabor high-pass filters. A value f gives a wavelength of 2\*pi/f pixels. GABOR output has one column per frequency/orientation pair. Values: comma-separated real numbers; same count as ``--gabortheta``. Give ``--gaborfreqs`` and ``--gabortheta`` together, with the same number of values: value i of one pairs with value i of the other. Without both, the command line uses its built-in pairs: frequencies 0, pi/4, pi/2 and 3\*pi/4 at orientations 4, 16, 32 and 64 radians. The Python API's default set is frequencies 4, 16, 32, 64 at 0, 45, 90, 135 degrees; to compute that set from the command line, pass ``--gaborfreqs=4,16,32,64`` ``--gabortheta=0,45,90,135``. 2D only. + * - ``--gabortheta`` + - no + - ``built-in pairs (see --gaborfreqs)`` + - Orientations of the Gabor filters in degrees, one per frequency in ``--gaborfreqs``. Values: comma-separated real numbers in degrees; same count as ``--gaborfreqs``. Converted to radians internally; no range check is made. Give it together with ``--gaborfreqs``, with the same number of values. See ``--gaborfreqs`` for the built-in pairs. 2D only. + * - ``--gaborgamma`` + - no + - ``0.1`` + - Aspect ratio (ellipticity) of the Gaussian envelope of the Gabor filter. Values: real number > 0. + * - ``--gaborsig2lam`` + - no + - ``0.8`` + - Ratio of the Gaussian width (sigma) to the wavelength, which sets the filter's spatial-frequency bandwidth. Values: real number > 0. + * - ``--gaborkersize`` + - no + - ``16`` + - Width and height, in pixels, of the square Gabor convolution kernel. Values: integer >= 2. 1 is accepted but reads past the end of the convolution buffer, so its values are arbitrary (known defect, https://github.com/PolusAI/nyxus/issues/506). + * - ``--gaborf0`` + - no + - ``0.1`` + - Frequency of the low-pass Gabor filter that produces the baseline image the high-pass responses are compared against. Values: real number > 0. Same units as ``--gaborfreqs``: the wavelength is 2\*pi/f pixels. 2D only. + * - ``--gaborthold`` + - no + - ``0.025`` + - Threshold on the ratio of filtered image to baseline; pixels above it are counted in the GABOR feature. Values: real number. Any real number is accepted, with no range check. A non-numeric value stops the run with an error. 2D only. + +Image handling +-------------- + +.. list-table:: + :header-rows: 1 + :widths: 22 10 16 52 + + * - Option + - Required + - Default + - Description + * - ``--fpimgmin`` + - no + - ``0.0 (used only when an --fpimg* option is given; otherwise each image's own minimum)`` + - Lower end of the intensity range that floating-point images are rescaled from before conversion to integer grey levels. Values: real number, less than ``--fpimgmax``. Giving any of ``--fpimgmin``, ``--fpimgmax`` or ``--fpimgdr`` switches every float image from its own scanned min/max to the fixed range [``--fpimgmin``, ``--fpimgmax``]. Unset ends then default to 0 and 1. A min that is not below the max stops the run with an error. Integer images are not affected. + * - ``--fpimgmax`` + - no + - ``1.0 (used only when an --fpimg* option is given; otherwise each image's own maximum)`` + - Upper end of the intensity range that floating-point images are rescaled from before conversion to integer grey levels. Values: real number, greater than ``--fpimgmin``. See ``--fpimgmin``: giving any ``--fpimg``\* option replaces each image's own range with the fixed [min, max] range. A non-numeric value stops the run with an error. + * - ``--fpimgdr`` + - no + - ``10000`` + - Number of integer grey levels that floating-point intensities are rescaled into. Values: integer >= 1. Giving only ``--fpimgdr`` also switches float images to the fixed range 0 to 1 (the ``--fpimgmin``/``--fpimgmax`` defaults) instead of each image's own range. Pair it with ``--fpimgmin`` and ``--fpimgmax``. + * - ``--preserve-hu`` + - no + - ``false`` + - Loads floating-point and rescaled DICOM/NIfTI images with one grey level per intensity unit (offset by the image minimum) instead of min-max rescaling or the stored-integer map. Values: true \| false \| t \| f (case-insensitive). Not needed to get Hounsfield units in the output: intensity features are reported in the image's own units either way. '1', 'on' and 'yes' are rejected with an error. Shape and texture features are unaffected. + * - ``--anisox`` + - no + - ``1`` + - Size of a pixel or voxel along x relative to the other axes. Geometric features use it: a volume (3D or 2.5D) is featurized on its voxels as acquired and the shape features scale their coordinates by the anisotropy, and a 2D slide's geometric features are computed on its pixels resampled by the anisotropy. Intensity and texture features are computed on the pixels or voxels as acquired, so they report the same values with or without anisotropy. On a 2D slide the intensity-weighted geometric features (``EDGE_*``, WEIGHTED_CENTROID_X/Y, MASS_DISPLACEMENT, ``IMOM_*``, the radial distribution and ZERNIKE2D) are computed on the pixels as acquired, each at its position scaled by the anisotropy. Of the image quality features only POWER_SPECTRUM_SLOPE changes: it is computed on the resampled ROI (known defect, https://github.com/PolusAI/nyxus/issues/519). Values: real number. A value of 1 counts as unset. No positivity check is made. A value other than 1 in any ``--aniso``\* option replaces the physical spacing of ``--use-physical-spacing`` on all three axes; the axes not given then use 1. A non-numeric value stops the run with an error. + * - ``--anisoy`` + - no + - ``1`` + - Pixel/voxel scale factor along y; same behavior as ``--anisox``. Values: real number. A value of 1 counts as unset. No positivity check is made. + * - ``--anisoz`` + - no + - ``1`` + - Voxel scale factor along z for 3D volumes; same behavior as ``--anisox``. Values: real number. Only 3D processing uses it. A value of 1 counts as unset. + * - ``--use-physical-spacing`` + - no + - ``false`` + - In 3D, uses each volume's OME PhysicalSize values, scaled so the smallest axis is 1, as the voxel spacing by which the shape features scale their coordinates. Values: true \| t \| 1 \| on \| yes (case-insensitive); any other value means false. 3D only. Any ``--aniso``\* value other than 1 replaces the physical spacing on all three axes. Invalid values are not reported; they silently mean false. Physical spacing columns are written to the output whether or not this is on. + +Performance and memory +---------------------- + +.. list-table:: + :header-rows: 1 + :widths: 22 10 16 52 + + * - Option + - Required + - Default + - Description + * - ``--reduceThreads`` + - no + - ``4`` + - Number of CPU threads used to calculate features. Values: integer >= 1. + * - ``--ramLimit`` + - no + - ``50% of available physical RAM at startup`` + - Memory budget in megabytes. ROIs whose estimated footprint reaches it are processed out of core, and the rest are batched to fit within it. Values: integer megabytes >= 0, not above available RAM. The value is read as its leading integer: 100.5 and 100MB both run as 100. A value above available RAM, a negative value, or a value that does not start with an integer stops the run with an error. + * - ``--tempDir`` + - no + - ``system temporary directory`` + - Directory for the temporary files of out-of-core ROIs. Values: path to an existing directory. The directory must exist and is shown in the run summary, but out-of-core temporary files are written to the system temporary directory regardless. + +Nested ROIs +----------- + +.. list-table:: + :header-rows: 1 + :widths: 22 10 16 52 + + * - Option + - Required + - Default + - Description + * - ``--hsig`` + - no + - — + - Text that marks the channel number in mask file names (for example '_c' in p0_y1_r1_c1.ome.tiff). Nested-ROI analysis uses it to find the parent and child masks of the same image. Values: string (channel marker in file names, e.g. _c). Nested-ROI analysis runs only when all four of ``--hsig``, ``--hpar``, ``--hchi`` and ``--hag`` are given. If any is missing, the others are silently ignored. 2D only. The analysis reads back the per-image CSV files, so use ``--outputType=separatecsv``: with singlecsv each parent's row is taken from the first row with its label in the combined file, which can belong to another image. It writes _nested_features.csv and a relations CSV to ``--outDir``. + * - ``--hpar`` + - no + - — + - Channel number of the masks that provide parent ROIs. Values: integer. Needs ``--hsig``, ``--hchi`` and ``--hag`` as well. A non-integer value stops the run with an error. + * - ``--hchi`` + - no + - — + - Channel number of the masks that provide child ROIs. A child is a ROI whose bounding box lies inside a parent's bounding box. Values: integer. Needs ``--hsig``, ``--hpar`` and ``--hag`` as well. A non-integer value stops the run with an error. + * - ``--hag`` + - no + - — + - How the features of a parent's children are combined into the parent's row. NONE lists every child's features side by side. Values: SUM \| MEAN \| MIN \| MAX \| WMA \| NONE (uppercase only). Uppercase only. WMA computes the same unweighted mean as MEAN. There is no default: nested-ROI analysis runs only when all four nested options are given. + +Diagnostics +----------- + +.. list-table:: + :header-rows: 1 + :widths: 22 10 16 52 + + * - Option + - Required + - Default + - Description + * - ``-h, --help`` + - no + - — + - Prints the usage screen and exits. Values: no value. Running nyxus with no arguments also prints the usage screen. + * - ``--verbose`` + - no + - ``1`` + - Amount of console output. The version banner, the list of recognized arguments, and errors and warnings are printed at every level; 1 adds the run summary, and higher levels add timing, per-ROI and diagnostic detail. Values: integer >= 0. At level 2 and above, ROI metric files are also written to the output directory. + * - ``--exclusivetiming`` + - no + - ``false`` + - In timing builds, true measures time per image pair (exclusive) and false measures the whole collection (inclusive). Values: true \| 1 \| on (case-insensitive); any other value means false. Rejected as unrecognized in builds without CHECKTIMING. Available in CHECKTIMING builds only. + +GPU +--- + +.. list-table:: + :header-rows: 1 + :widths: 22 10 16 52 + + * - Option + - Required + - Default + - Description + * - ``--useGpu`` + - no + - ``false`` + - Runs supported feature calculations on a CUDA GPU. Values: true \| false (case-insensitive). Other values stop the run with an error. If the chosen device cannot be initialized, the run stops and suggests ``--useGpu=false``. Rejected as unrecognized in non-GPU builds. Available in GPU builds only. + * - ``--gpuDeviceID`` + - no + - ``0`` + - GPU device IDs to choose from. Nyxus uses the listed device with the most free memory at startup. Values: comma-separated integers >= 0. Ignored unless ``--useGpu=true``. A value that does not start with a non-negative integer stops the run with an error. A listed device that cannot be used is skipped; if none of them can be used, the run stops with an error. Available in GPU builds only. diff --git a/docs/source/cmdline_and_examples.rst b/docs/source/cmdline_and_examples.rst index 7a318693c..257a0bf9c 100644 --- a/docs/source/cmdline_and_examples.rst +++ b/docs/source/cmdline_and_examples.rst @@ -1,549 +1,280 @@ +Command line and Python examples +================================ -Command line -============== +Running Nyxus from the command line +----------------------------------- -Assuming you built the Nyxus executable from source code, -the following parameters are available for the command -line usage. Regular command line users should adhere -parameter value to the "Type" column. WIPP developers -should adhere to columns "WIPP I/O role" and "WIPP type". - - -.. list-table:: - :widths: 15 45 10 10 10 - :header-rows: 1 - - * - Parameter - - Description - - Type - - WIPP I/O role - - WIPP type - * - --outputType - - Output type for feature values. Acceptable value: speratecsv, singlecsv, arrow, parquet. Default value: '--outputType=separatecsv' - - string constant - - input - - enum - * - --features - - String constant or comma-seperated list of constants requesting a group of features or particular feature. Default value: '--features=\*ALL\*' - - string - - input - - array - * - --filePattern - - Regular expression to match image files in directories specified by parameters '--intDir' and '--segDir'. To match all the files, use '--filePattern=.\*' - - string - - input - - string - * - --intDir - - Directory of intensity image collection - - path - - input - - collection - * - --outDir - - Output directory - - path - - output - - csvCollection - * - --segDir - - Directory of labeled image collection - - path - - input - - collection - * - --coarseGrayDepth - - (Optional) Custom number of grayscale level bins used in texture features. Default: '--coarseGrayDepth=256' - - integer - - input - - integer - * - --gaborfreqs - - (Optional) Feature GABOR: custom denominators of :math:`\pi` as frequencies of Gabor filter's harmonic factor. Default: '--gaborfreqs=1,2,4,8,16,32,64' - - list of integer constants - - input - - collection - * - --gaborf0 - - (Optional) Feature GABOR: frequency of the baseline lowpass filter as denominator of :math:`\pi`. Default: '--gaborf0=0.1' - - real - - input - - number - * - --gaborgamma - - (Optional) Feature GABOR: aspect ratio of Gabor filter's Gaussian factor. Default: '--gaborgamma=0.1' - - real - - input - - number - * - --gaborkersize - - (Optional) Feature GABOR: dimension of the 2D Gabor filter kernel. Default: '--gaborkersize=16' - - integer - - input - - integer - * - --gaborsig2lam - - (Optional) Feature GABOR: spatial frequency bandwidth of Gabor filter. Default: '--gaborsig2lam=0.8' - - real - - input - - number - * - --gabortheta - - (Optional) Feature GABOR: orientation of the Gaussian in degrees 0-180. Default: '--gabortheta=45' - - real - - input - - number - * - --gaborthold - - (Optional) Feature GABOR: lower threshold of the filtered image to baseline ratio. Default: '--gaborthold=0.025' - - real - - input - - number - * - --glcmAngles - - (Optional) Feature GLCM: enabled direction angles. Superset of values: 0, 45, 90, and 135. Default: '--glcmAngles=0,45,90,135' - - list of integer constants - - input - - collection - * - --intSegMapDir - - (Optional) Data collection of the ad-hoc intensity-to-mask file mapping. Must be used in combination with parameter '--intSegMapFile' - - path - - input - - collection - * - --intSegMapFile - - (Optional) Name of the text file containing an ad-hoc intensity-to-mask file mapping. The files are assumed to reside in corresponding intensity and label collections. Must be used in combination with parameter '--intSegMapDir' - - string - - input - - string - * - --pixelDistance - - (Optional) Number of pixels to treat ROIs within specified distance as neighbors. Default value: '--pixelDistance=5' - - integer - - input - - integer - * - --pixelsPerCentimeter - - (Optional) Number of pixels in centimeter used by unit length-related features. Default value: 0 - - real - - input - - number - * - --ramLimit - - (Optional) Amount of memory not to exceed by Nyxus, in megabytes. Default value: 50\% of available memory. Example: '--ramLimit=2000' to use 2,000 megabytes - - integer - - input - - integer - * - --reduceThreads - - (Optional) Number of CPU threads used on the feature calculation step. Default: '--reduceThreads=1' - - integer - - input - - integer - * - --skiproi - - (Optional) Skip ROIs having specified labels. Example: '--skiproi=image1.tif:2,3,4;image2.tif:45,56' - - string - - input - - string - * - --tempDir - - (Optional) Directory used by temporary out-of-RAM objects. Default value: system temporary directory - - path - - input - - path - * - --arrowOutputType - - (Optional) Type of Arrow file to write the feature results to. Current options are 'arrow' for Arrow IPC or 'parquet' for Parquet - - string - - output - - enum - * - --ibsi - - (Optional) Enable IBSI compliance mode. Required to compute the Intensity Histogram (IH_*) feature family. Default: '--ibsi=false' - - true or false - - input - - bool - * - --mergerois - - (Optional) Merge all nonzero mask labels into a single whole-foreground ROI (background excluded) instead of one ROI per label. Default: '--mergerois=false' - - true or false - - input - - bool - * - --fpimgmin - - (Optional) Expected minimum intensity of a floating-point image, used when quantizing its voxels into the internal integer range. Default: '--fpimgmin=0.0' - - real - - input - - float - * - --fpimgmax - - (Optional) Expected maximum intensity of a floating-point image, used when quantizing its voxels into the internal integer range. Default: '--fpimgmax=1.0' - - real - - input - - float - * - --fpimgdr - - (Optional) Target integer dynamic range that floating-point voxels are mapped onto. Default: '--fpimgdr=10000' - - positive integer - - input - - int - * - --preserve-hu - - (Optional) NOT needed for CT -- Hounsfield units are preserved and reported either way, despite the name. Carries a floating-point slide on the offset-preserving map (1 grey level = 1 intensity unit, offset by the slide's floored minimum) instead of the min-max rescaling into '--fpimgdr' used by '--fpimgmin'/'--fpimgmax'. A DICOM or integer NIfTI slide takes it instead of the stored map, which keeps the stored integers as grey levels and carries RescaleSlope/RescaleIntercept (scl_slope/scl_inter) in the recorded inverse, so a fractional slope loses no stored level. Other integer slides take the offset map regardless of this flag. The DICOM and NIfTI rescale reaches the reported values either way, so CT Hounsfield Units survive the load either way. Whichever map a slide takes is recorded and undone on the way out, so every intensity feature is reported in the slide's own domain -- a CT is reported in Hounsfield units, negatives included. Shape and texture features are unaffected. Default: '--preserve-hu=false' - - true or false - - input - - bool - -Examples -======== - -This chapter presents some particular usage cases of Nyxus - -1. Requesting specific features -------------------------------- - -Suppose we need to extract only Zernike features and first 3 Hu's moments: +A command-line run reads pairs of intensity and label images from two directories and writes +the features of every ROI to an output directory. Four options are required on every run: .. code-block:: bash - ./nyxus --features=ZERNIKE2D,HU_M1,HU_M2,HU_M3 --intDir=/home/ec2-user/data-ratbrain/int --segDir=/home/ec2-user/data-ratbrain/seg --outDir=/home/ec2-user/work/OUTPUT-ratbrain --filePattern=.* --outputType=singlecsv + nyxus --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv -2. Requesting specific feature groups -------------------------------------- +``--outputType`` has no default: choose ``separatecsv`` (one CSV per image pair), ``singlecsv``, +``arrowipc`` or ``parquet``. Without ``--features``, Nyxus computes every 2D feature except the +IBSI intensity histogram (``IH_*``) features, which need ``--ibsi=true``. Every option is listed +with its default in the :ref:`cli-reference`. -Suppose we need to extract only intensity features basic morphology features: +Quote option values that contain ``*``, ``?``, ``[`` or ``\``, such as +``--features="*ALL_INTENSITY*"`` and the regular expressions of ``--filePattern``. Unquoted, the +shell treats them as file-name patterns or escapes: zsh, the default shell on macOS, stops on a +pattern that matches no file, and bash and zsh remove the backslashes from a regular expression. -.. code-block:: bash +Command-line examples +--------------------- - ./nyxus --features=*all_intensity*,*basic_morphology* --intDir=/home/ec2-user/data-ratbrain/int --segDir=/home/ec2-user/data-ratbrain/seg --outDir=/home/ec2-user/work/OUTPUT-ratbrain --filePattern=.* --outputType=singlecsv +The examples use ``/data/int``, ``/data/seg`` and ``/data/out`` for the intensity, label and output +directories. -3. Mixing specific feature groups and individual features ---------------------------------------------------------- +Requesting specific features +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Suppose we need to extract intensity features, basic morphology features, and Zernike features: +Zernike features and the first three Hu moments: .. code-block:: bash - ./nyxus --features=*all_intensity*,*basic_morphology*,zernike2d --intDir=/home/ec2-user/data-ratbrain/int --segDir=/home/ec2-user/data-ratbrain/seg --outDir=/home/ec2-user/work/OUTPUT-ratbrain --filePattern=.* --outputType=singlecsv - -3a. Requesting the IBSI Intensity Histogram features ----------------------------------------------------- - -The Intensity Histogram (``IH_*``) family requires IBSI mode (``--ibsi=true``) and uses -``--coarseGrayDepth`` as the histogram bin count. Add ``--mergerois=true`` to compute one -whole-foreground ROI instead of one ROI per label: - -.. code-block:: bash + nyxus --features=ZERNIKE2D,HU_M1,HU_M2,HU_M3 --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv - ./nyxus --features=*ALL_IH* --ibsi=true --coarseGrayDepth=32 --mergerois=true --intDir=/data/int --segDir=/data/seg --outDir=/data/out --filePattern=.* --outputType=singlecsv +.. _requesting-feature-groups: -4. Specifying a feature list from with a file instead of command line ---------------------------------------------------------------------- +Requesting feature groups +^^^^^^^^^^^^^^^^^^^^^^^^^ -Sometimes a list of requested features can be long making Nyxus command line huge. An alternative to dealing with a long command line is specifying all the desired features in a comma, space, and newline delimited text file. Suppose a feature set is in file feature_list.txt: +All intensity features and the basic morphology features. On the command line, feature and +group names are not case-sensitive: .. code-block:: bash - mean,min,kurtosis - skewness + nyxus --features="*all_intensity*,*basic_morphology*" --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv -Then the command line will be: +Groups and individual features can be mixed: .. code-block:: bash - ./nyxus --features=feature_list.txt --intDir=/home/ec2-user/data-ratbrain/int --segDir=/home/ec2-user/data-ratbrain/seg --outDir=/home/ec2-user/work/OUTPUT-ratbrain --filePattern=.* --outputType=singlecsv - -5. Whole-image feature extraction ---------------------------------- + nyxus --features="*all_intensity*,*basic_morphology*,zernike2d" --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv -The regular operation mode of Nyxus is processing pairs of intensity and mask images treating non-zero pixel values of the mask image as segment label. The other operation mode is the so called "single-ROI mode" - treating the intensity image as segment. To activate it, just reference the intensity image collection as mask in the command line: +A name prefixed with ``-`` removes that feature or group, and one prefixed with ``+`` adds it, which +is the same as no prefix. Names are applied from left to right, so a removal must come after the +group it removes from. Prefixes work in ``--features`` and in Python, not in a feature list +file (see :ref:`below `). All intensity features except ``MEAN``: .. code-block:: bash - ./nyxus --features=*basic_morphology* --intDir=/home/ec2-user/data-ratbrain/int --segDir=/home/ec2-user/data-ratbrain/int --outDir=/home/ec2-user/work/OUTPUT-ratbrain --filePattern=.* --outputType=singlecsv - -6. Regular and ad-hoc mapping between intensity and mask image files --------------------------------------------------------------------- - -Intensity and mask image collections are specified in the command line (via parameters --intDir and --segDir) and the default mapping between an intensity and mask image, after applying a file name pattern (via parameter --filePattern), is the 1:1 mapping: + nyxus --features="*all_intensity*,-mean" --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv -.. code-block:: bash +The same works in Python, for example ``Nyxus(["*ALL_INTENSITY*", "-MEAN"])``. ``-COV`` and +``-*ALL*`` add instead of removing: ``["MEAN", "-COV"]`` gives ``COV`` and ``MEAN``, and +``["*ALL_INTENSITY*", "-*ALL*"]`` gives every feature that ``*ALL*`` selects. On an image quality +name a ``-`` or ``+`` prefix is ignored: ``ImageQuality(["FOCUS_SCORE", "-SHARPNESS"])`` computes +both features. - intensity_image_1 segment_image_1 - intensity_image_2 segment_image_2 - intensity_image_3 segment_image_3 - intensity_image_4 segment_image_4 +Requesting the IBSI intensity histogram features +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Here, each intensity and mask image is assumed to reside in the corresponding image collection directory specified with command line arguments --intDir=/home/ec2-user/data-ratbrain/int --segDir=/home/ec2-user/data-ratbrain/seg. More precisely: +The intensity histogram (``IH_*``) family requires IBSI mode (``--ibsi=true``) and uses +``--coarseGrayDepth`` as the histogram bin count. Add ``--mergerois=true`` to compute one +whole-foreground ROI instead of one ROI per label: .. code-block:: bash - /home/ec2-user/data-ratbrain/int/image_1.ome.tif /home/ec2-user/data-ratbrain/seg/image_1.ome.tif - /home/ec2-user/data-ratbrain/int/image_2.ome.tif /home/ec2-user/data-ratbrain/seg/image_2.ome.tif - /home/ec2-user/data-ratbrain/int/image_3.ome.tif /home/ec2-user/data-ratbrain/seg/image_3.ome.tif - /home/ec2-user/data-ratbrain/int/image_4.ome.tif /home/ec2-user/data-ratbrain/seg/image_4.ome.tif + nyxus --features="*ALL_IH*" --ibsi=true --coarseGrayDepth=32 --mergerois=true --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv -In case the dataset is based on a 1:N mapping, for example - -.. code-block:: bash - - intensity_image_1 segment_image_A - intensity_image_2 segment_image_A - intensity_image_3 segment_image_A - intensity_image_4 segment_image_B +.. _feature-list-file: -the user needs to pass such an ad-hoc mapping to Nyxus via referenceing a mapping definition text file in the command line (parameter --intSegMapFile). +Reading the feature list from a file +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -**Note: the order of mapping definition file columns is critical, and the 1-st column is interpreted as the intensity image files column while the 2-nd column is interpreted as the mask image files.** +A long feature list can be kept in a text file, with names separated by commas or new lines. +``#`` starts a comment. Only individual feature names work in a file: Nyxus drops every +character other than letters, digits, ``_`` and ``,``, so spaces join neighbouring names, the +``*`` of a group name is removed, and a ``-`` or ``+`` prefix is lost. +Suppose ``feature_list.txt`` contains: -Assuming contents of file mapping.txt is +.. code-block:: text -.. code-block:: bash - - image_1.ome.tif image_A.ome.tif - image_2.ome.tif image_A.ome.tif - image_3.ome.tif image_A.ome.tif - image_4.ome.tif image_B.ome.tif + # first-order statistics + mean,min,kurtosis + skewness -and the file is passed to Nyxus via parameter --intSegMapFile, the mapping will resolve to mapping +Pass the file name to ``--features``: .. code-block:: bash - /home/ec2-user/data-ratbrain/int/image_1.ome.tif /home/ec2-user/data-ratbrain/seg/image_A.ome.tif - /home/ec2-user/data-ratbrain/int/image_2.ome.tif /home/ec2-user/data-ratbrain/seg/image_A.ome.tif - /home/ec2-user/data-ratbrain/int/image_3.ome.tif /home/ec2-user/data-ratbrain/seg/image_A.ome.tif - /home/ec2-user/data-ratbrain/int/image_4.ome.tif /home/ec2-user/data-ratbrain/seg/image_B.ome.tif - -7. Ad-hoc mapping between intensity and mask image files via Python interface ------------------------------------------------------------------------------ + nyxus --features=feature_list.txt --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv -Alternatively, Nyxus can process explicitly defined pairs of intensity-mask images, for example image "i1" with mask "m1" and image "i2" with mask "m2": +Selecting files +^^^^^^^^^^^^^^^ -.. code-block:: python +``--filePattern`` is a regular expression that must match the whole file name. It defaults to +``.*``, which selects every file. To process only OME-TIFF files: - from nyxus import Nyxus - nyx = Nyxus(["*ALL*"]) - features = nyx.featurize_files( - [ - "/path/to/images/intensities/i1.ome.tif", - "/path/to/images/intensities/i2.ome.tif" - ], - [ - "/path/to/images/labels/m1.ome.tif", - "/path/to/images/labels/m2.ome.tif" - ]) +.. code-block:: bash + nyxus --filePattern=".*\.ome\.tif" --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv -Nyxus can also process intensity-mask pairs that are stored as Numpy arrays using the `featurize` method. This method takes in either a single pair of 2D intensity-mask pairs -or a pair of 3D arrays containing 2D intensity and mask images. There is also two optional parameters to supply names to the resulting dataframe, . +Nyxus prints ``Warning: --filePattern= already has value '.*'`` whenever ``--filePattern`` is +given. The warning is harmless: the pattern you give is the one used. -.. code-block:: python +Whole-image features +^^^^^^^^^^^^^^^^^^^^ - from nyxus import Nyxus - import numpy as np +Normally each nonzero label in a label image is one ROI. To treat each whole intensity image as a +single ROI, give the intensity directory as the label directory too: - nyx = Nyxus(["*ALL*"]) +.. code-block:: bash - intens = [ - [[1, 4, 4, 1, 1], - [1, 4, 6, 1, 1], - [4, 1, 6, 4, 1], - [4, 4, 6, 4, 1]], - - [[1, 4, 4, 1, 1], - [1, 1, 6, 1, 1], - [1, 1, 3, 1, 1], - [4, 4, 6, 1, 1]] - ] + nyxus --features="*basic_morphology*" --intDir=/data/int --segDir=/data/int --outDir=/data/out --outputType=singlecsv - seg = [ - [[1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1]], - - [[1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [0, 1, 1, 1, 1], - [1, 1, 1, 1, 1]] - ] +Pairing intensity and label images explicitly +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +By default an intensity image is paired with the label image of the same file name: - features = nyx.featurize(intens, seg) +.. code-block:: text + /data/int/image_1.ome.tif /data/seg/image_1.ome.tif + /data/int/image_2.ome.tif /data/seg/image_2.ome.tif -The `features` variable is a Pandas dataframe similar to what is shown below. +When several intensity images share one label image, list the pairs in a text file, one pair per +line: the intensity file name first, then the label file name. Names are relative to ``--intDir`` +and ``--segDir``. For a file ``mapping.txt``: -.. code-block:: bash +.. code-block:: text - mask_image intensity_image label MEAN MEDIAN ... GABOR_6 - - 0 Segmentation1 Intensity1 1 45366.9 46887 ... 0.873016 - 1 Segmentation1 Intensity1 2 27122.8 27124.5 ... 1.000000 - 2 Segmentation1 Intensity1 3 34777.4 33659 ... 0.942857 - 3 Segmentation1 Intensity1 4 35808.2 36924 ... 0.824074 - ... ... ... ... ... ... ... ... - 14 Segmentation2 Intensity2 6 54573.3 54573.3 ... 0.980769 + image_1.ome.tif image_A.ome.tif + image_2.ome.tif image_A.ome.tif + image_3.ome.tif image_B.ome.tif -Note that in this case, default names were provided for the `mask_image` and `intensity_image` columns. To supply names -for these columns, the optional arguments `intensity_names` and `label_names` are used by passing lists of names in. -The length of the lists must be the same as the length of the mask and intensity arrays. To name the images, use +pass the directory that holds it with ``--intSegMapDir`` and its name with ``--intSegMapFile``. +``--filePattern`` is not applied to the listed pairs. This option is available for 2D data. -.. code-block:: python +.. code-block:: bash - intens_names = ['custom_intens_name1', 'custom_intens_name2'] - seg_names = ['custom_seg_name1', 'custom_seg_name2'] + nyxus --intSegMapDir=/data --intSegMapFile=mapping.txt --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv - features = nyx.featurize(intens, seg, intens_name, seg_name) +Python examples +--------------- +The examples below are run by the test suite exactly as shown. Each prints part of the feature +table; the full table has one column per feature after nine description columns +(``intensity_image, mask_image, phys_unit, ROI_label, t_index, c_index, phys_x, phys_y, phys_z``). -The `features` variable will now use the custom names, as shown below +Image files listed explicitly +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. code-block:: bash +``featurize_files`` takes a list of intensity files and the matching list of label files. +``single_roi=False`` treats each nonzero label as a ROI; ``True`` treats each whole image as one. - mask_image intensity_image label MEAN MEDIAN ... GABOR_6 - - 0 custom_seg_name1 custom_intens_name1 1 45366.9 46887 ... 0.873016 - 1 custom_seg_name1 custom_intens_name1 2 27122.8 27124.5 ... 1.000000 - 2 custom_seg_name1 custom_intens_name1 3 34777.4 33659 ... 0.942857 - 3 custom_seg_name1 custom_intens_name1 4 35808.2 36924 ... 0.824074 - ... ... ... ... ... ... ... ... - 14 custom_seg_name2 Intensity2 6 54573.3 54573.3 ... 0.980769 +.. literalinclude:: ../examples/featurize_files.py + :language: python + :start-after: # example-start + :end-before: # example-end -All parameters to configure Nyxus are available to set within the constructor. These parameters can also be updated after the object is created using the `set_params` -method. This method takes in keyword arguments where the key is a valid parameter in Nyxus and the value is the updated value for the parameter. For example, -to update the `coarse_gray_depth` to 256 and the `gabor_f0` parameter to 0.1, the following can be done: +Output: -.. code-block:: python +.. code-block:: text - from nyxus import Nyxus - nyx = Nyxus(["*ALL*"]) - intensityDir = "/path/to/images/intensities/" - maskDir = "/path/to/images/labels/" - features = nyx.featurize_directory (intensityDir, maskDir) + intensity_image mask_image ROI_label MEAN + 0 i1.ome.tif m1.ome.tif 1 2081.718750 + 1 i1.ome.tif m1.ome.tif 2 2034.362500 + 2 i1.ome.tif m1.ome.tif 3 2123.508242 + 3 i2.ome.tif m2.ome.tif 1 2120.781250 + 4 i2.ome.tif m2.ome.tif 2 1980.590625 + 5 i2.ome.tif m2.ome.tif 3 2088.107143 - nyx.set_params(coarse_gray_depth=256, gabor_f0=0.1) +Images in memory +^^^^^^^^^^^^^^^^ +``featurize`` takes numpy arrays: one 2D image, or a stack of 2D images along the first axis, with +a label array of the same shape. Images are named ``Intensity0``, ``Intensity1``, ... unless names +are given. -A list of valid parameters is included in the documentation for this method. +.. literalinclude:: ../examples/featurize_arrays.py + :language: python + :start-after: # example-start + :end-before: # example-end -To get the values of the parameters in Nyxus, the `get_params` method is used. If no arguments are passed to this function, then a dictionary mapping all of the variable names to the respective value is returned. For example, +Output: -.. code-block:: python +.. code-block:: text - from nyxus import Nyxus - nyx = Nyxus(["*ALL*"]) - intensityDir = "/path/to/images/intensities/" - maskDir = "/path/to/images/labels/" - features = nyx.featurize_directory (intensityDir, maskDir) + intensity_image mask_image ROI_label MEAN MEDIAN + 0 Intensity0 Segmentation0 1 2.950000 4.0 + 1 Intensity1 Segmentation1 1 2.263158 1.0 + intensity_image mask_image ROI_label MEAN MEDIAN + 0 cells_a cells_a_mask 1 2.950000 4.0 + 1 cells_b cells_b_mask 1 2.263158 1.0 - print(nyx.get_params()) +A directory of images, and reading and changing parameters +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +``featurize_directory`` pairs each intensity image with the label image of the same name. +``get_params`` returns the current parameters, all of them when called with no arguments, and +``set_params`` records new values, which ``get_params`` then reports. -will print the dictionary +.. note:: -.. code-block:: bash + ``features``, ``coarse_gray_depth`` and ``ibsi`` changed with ``set_params`` after the object is + created do not reach the computation. After ``set_params(coarse_gray_depth=8)``, + ``GLCM_ASM_0`` keeps the value of the 64-level matrix (0.000882 on a random 40 x 40 image, + where an object created with ``coarse_gray_depth=8`` gives 0.0162), and after + ``set_params(features=["MAX"])`` the object still computes its original features. Pass these + three to the constructor, or create a new object. - {'coarse_gray_depth': 256, - 'features': ['*ALL*'], - 'gabor_f0': 0.1, - 'gabor_freqs': [1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0], - 'gabor_gamma': 0.1, - 'gabor_kersize': 16, - 'gabor_sig2lam': 0.8, - 'gabor_theta': 45.0, - 'gabor_thold': 0.025, - 'ibsi': 0, - 'n_loader_threads': 1, - 'n_feature_calc_threads': 4, - 'neighbor_distance': 5, - 'pixels_per_micron': 1.0} +.. literalinclude:: ../examples/directory_and_params.py + :language: python + :start-after: # example-start + :end-before: # example-end -There is also the option to pass arguments to this function to only receive a subset of parameter values. The arguments should be -valid parameter names as string, separated by commas. For example, +Output: -.. code-block:: python +.. code-block:: text - from nyxus import Nyxus - nyx = Nyxus(["*ALL*"]) - intensityDir = "/path/to/images/intensities/" - maskDir = "/path/to/images/labels/" - features = nyx.featurize_directory (intensityDir, maskDir) + (4, 194) + {'coarse_gray_depth': 64, 'n_feature_calc_threads': 4} + {'coarse_gray_depth': 256, 'n_feature_calc_threads': 4} - print(nyx.get_params('coarse_gray_depth', 'features', 'gabor_f0')) +Arrow IPC and Parquet output +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -will print the dictionary +Pass ``output_type="arrowipc"`` or ``output_type="parquet"`` to any ``featurize*`` call to write the +results to a file instead of returning a DataFrame; the call returns the file's path. +``output_path`` sets the directory or file name. This needs a Nyxus build with Arrow support, +which the PyPI and conda packages have. -.. code-block:: bash +.. literalinclude:: ../examples/arrow_output.py + :language: python + :start-after: # example-start + :end-before: # example-end - {'coarse_gray_depth': 256, - 'features': ['*ALL*'], - 'gabor_f0': 0.1} - - -8. Using Arrow for feature results ------------------------------------------------------------------------------ - -Nyxus provides the ability to get the results of the feature calculations in Arrow IPC and Parquet formats. To create an Arrow IPC or Parquet file, use `output_type="arrowipc"` or `output_type="parquet"` in `Nyxus.featurize*` calls. -Optionally, an `output_path` argument can be passed to specify the location of the output file. - -.. code-block:: python - - from nyxus import Nyxus - import numpy as np - - intens = np.array([ - [[1, 4, 4, 1, 1], - [1, 4, 6, 1, 1], - [4, 1, 6, 4, 1], - [4, 4, 6, 4, 1]], - - [[1, 4, 4, 1, 1], - [1, 1, 6, 1, 1], - [1, 1, 3, 1, 1], - [4, 4, 6, 1, 1]], - - [[1, 4, 4, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 6, 1, 1], - [1, 1, 6, 1, 1]], - - [[1, 4, 4, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 6, 1, 1]], - ]) - - seg = np.array([ - [[1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1]], - - [[1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [0, 1, 1, 1, 1], - [1, 1, 1, 1, 1]], - - [[1, 1, 1, 0, 0], - [1, 1, 1, 1, 1], - [1, 1, 0, 1, 1], - [1, 1, 1, 1, 1]], - - [[1, 1, 1, 0, 0], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1], - [1, 1, 1, 1, 1]] - - ]) - - nyx = Nyxus(["*ALL_INTENSITY*"]) - - arrow_file = nyx.featurize(intens, seg, output_type="arrowipc", output_path="some_path") - - print(arrow_file) - - -The output is: +Output (the path uses the platform's separator): -.. code-block:: bash +.. code-block:: text - some_path/NyxusFeatures.arrow + results/NyxusFeatures.arrow + 1 ['intensity_image', 'mask_image', 'phys_unit', 'ROI_label'] -9. Nested Features Examples ------------------------------------------------------------------------------ +Nested ROIs +^^^^^^^^^^^ -The Nested class is the Python API of Nyxus identifies child-parent relations of ROIs in images with a child and parent channel. -For example, consider the following intensity and segmentation images of the parent channel, +The ``Nested`` class relates ROIs of two label channels: a ROI of the child channel is a child of a +ROI of the parent channel when its bounding box lies inside the parent's bounding box. For +example, consider the intensity and label images of a parent channel, -.. list-table:: +.. list-table:: * - .. figure:: img/parent_int.png - Fig 1. Parent channel intensity + Fig 1. Parent channel intensity - .. figure:: img/parent_seg.png Fig 2. Parent channel segmentation -With the child channel +and of a child channel: -.. list-table:: +.. list-table:: * - .. figure:: img/child_int.png @@ -551,130 +282,25 @@ With the child channel - .. figure:: img/child_seg.png - Fig 4. Child channel segmentation - - -As shown by the figures, there are ROIs in the child segmentation that are completely contained in the the ROIs of the parent channel. -The purpose of the Nested class is to identify the child ROIs of the parent channel. The Nested class also contains functionality to -apply aggregate functions to the child features, as shown belong in the example. - -To use the Nested class, first call the constructor with the optional argument `aggregate`. If `aggregate` is not passed, the -`find_relation` behavior will change (described later). Any aggregate function supported by Pandas is available, -such as `min`, `max`, `count`, and `mean`. Lambda functions can also be used, and named using a 2-tuple, where the first -element is the name and the second is the lambda function. This allows functions that are not supported by Pandas to be used, -such as Numpy's `np.nanmean`. - -To use the Nested class, first call Nyxus to get the features of all ROIs from the child channels. If the child channels are described -by a channel number in the filename, a filepattern can be used to filter down to only the child channel. Consider a directory with the images - -.. code-block:: bash - - p0_y1_r1_c0.ome.tif - p0_y1_r1_c1.ome.tif - p0_y1_r2_c0.ome.tif - p0_y1_r2_1.ome.tif - p0_y1_r3_c0.ome.tif - p0_y1_r3_c1.ome.tif - ... - -where the child channel is designated by `c0` and the parent channel is `c1`. We can filter down to only the child channel using the -`filepattern `_ `p{r}_y{c}_r{z}_c0.ome.tif` or the equivalent regex `p[0-9]_y[0-9]_r[0-9]_c0\.ome\.tif`. - - -Next, we calculate the features for the child channel. For simplicity, we only use the Gabor features, but any or all features can be used. - -.. code-block:: python - - from nyxus import Nyxus, Nested - import numpy as np - - int_path = 'path/to/intensity' - seg_path = 'path/to/segmentation' - - nyx = Nyxus(['GABOR']) - - child_features = nyx.featurize(int_path, seg_path, file_pattern='p[0-9]_y[0-9]_r[0-9]_c0\.ome\.tif') - - print(features.head()) - -The result of this code is - -.. code-block:: bash - - mask_image intensity_image label GABOR_0 GABOR_1 GABOR_2 GABOR_3 GABOR_4 GABOR_5 GABOR_6 - 0 p0_y1_r1_c0.ome.tif p0_y1_r1_c0.ome.tif 1 0.224206 0.172619 0.166667 0.730159 0.773810 0.767857 0.753968 - 1 p0_y1_r1_c0.ome.tif p0_y1_r1_c0.ome.tif 2 1.000000 0.610000 0.540000 0.980000 0.990000 0.990000 0.970000 - 2 p0_y1_r1_c0.ome.tif p0_y1_r1_c0.ome.tif 3 0.429864 0.217195 0.122172 0.877828 0.941176 0.936652 0.909502 - 3 p0_y1_r1_c0.ome.tif p0_y1_r1_c0.ome.tif 4 0.846154 0.948718 0.717949 1.000000 1.000000 1.000000 1.000000 - 4 p0_y1_r1_c0.ome.tif p0_y1_r1_c0.ome.tif 5 0.277778 0.021368 0.029915 0.794872 0.841880 0.841880 0.824786 - -Next, the `find_relation` method is used to find the child-parent relations. This method takes in the segmentation path along with -filepatterns to distinguish the child channel from the parent channel. - -.. code-block:: python - - nest = Nested(['sum', 'mean', 'min', ('nanmean', lambda x: np.nanmean(x))]) - - df = nest.find_relations(seg_path, 'p{r}_y{c}_r{z}_c1.ome.tif', 'p{r}_y{c}_r{z}_c0.ome.tif') - print(df.head()) - -The result is - -.. code-block:: bash - - Image Parent_Label Child_Label - 0 /path/to/image 72.0 65.0 - 1 /path/to/image 71.0 66.0 - 2 /path/to/image 70.0 64.0 - 3 /path/to/image 68.0 61.0 - 4 /path/to/image 67.0 65.0 - -The `featurize` method can then be used along with the child features to apply the aggregate functions. The `featurize` method -takes in the `features` DataFrame generated by Nyxus, which contains the features calculations for each ROI, along with the DataFrame -containing the parent-child relations from the `find_relations` method. The output of this method is a DataFrame containing - -.. code-block:: python - - df = nest.featurize(df, features) - print(df.head()) - -The result is - -.. code-block:: bash - - GABOR_0 GABOR_1 GABOR_2 ... GABOR_4 GABOR_5 GABOR_6 - sum mean min nanmean sum mean min nanmean sum mean ... min nanmean sum mean min nanmean sum mean min nanmean - label ... - 1 24.010227 0.666951 0.000000 0.666951 19.096262 0.530452 0.001645 0.530452 17.037345 0.473260 ... 0.773810 0.897924 32.060053 0.890557 0.767857 0.890557 31.643434 0.878984 0.753968 0.878984 - 2 13.374170 0.445806 0.087339 0.445806 7.279187 0.242640 0.075000 0.242640 6.390529 0.213018 ... 0.735000 0.885494 26.414860 0.880495 0.727500 0.880495 25.886468 0.862882 0.700000 0.862882 - 3 5.941783 0.198059 0.000000 0.198059 3.364149 0.112138 0.000000 0.112138 2.426409 0.080880 ... 0.858462 0.900500 26.836040 0.894535 0.858462 0.894535 26.172914 0.872430 0.829231 0.872430 - 4 13.428773 0.559532 0.000000 0.559532 12.021938 0.500914 0.008772 0.500914 9.938915 0.414121 ... 0.820175 0.945459 22.572913 0.940538 0.802632 0.940538 22.270382 0.927933 0.787281 0.927933 - 5 6.535722 0.181548 0.000000 0.181548 1.833463 0.050930 0.000000 0.050930 2.083023 0.057862 ... 0.697917 0.819318 29.094328 0.808176 0.693452 0.808176 28.427727 0.789659 0.675595 0.789659 - -The other way to utilize the Nested class is to not pass any aggregate features to the constructor. In this case, the `featurize` method with create a -pivot table where the rows are the ROI labels and the columns are grouped by the features. - -.. code-block:: python - - nest = Nested(['sum', 'mean', 'min', ('nanmean', lambda x: np.nanmean(x))]) - - df = nest.find_relations(seg_path, 'p{r}_y{c}_r{z}_c1.ome.tif', 'p{r}_y{c}_r{z}_c0.ome.tif') - - df = nest.featurize(df, features) - print(df.head()) - + Fig 4. Child channel segmentation -The result is +Some child ROIs lie inside parent ROIs. In this data set the channel is part of the file name: +``c0`` marks the child channel and ``c1`` the parent channel, so a regular expression selects +each one. First compute the features of the child ROIs, then find the relations: -.. code-block:: bash +.. literalinclude:: ../examples/nested_rois.py + :language: python + :start-after: # example-start + :end-before: # example-end - GABOR_0 ... GABOR_6 - Child_Label 1.0 2.0 3.0 4.0 5.0 6.0 7.0 8.0 9.0 10.0 ... 55.0 56.0 58.0 59.0 60.0 61.0 62.0 64.0 65.0 66.0 - label ... - 1 0.666951 NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN - 2 NaN 0.445806 NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN - 3 NaN NaN 0.198059 NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN - 4 NaN NaN NaN 0.559532 NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN - 5 NaN NaN NaN NaN 0.181548 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN +``find_relations`` returns one row per parent/child pair, with the columns +``Image, Parent_Label, Child_Label``. +.. warning:: + ``find_relations`` reports every child ROI as contained in every parent ROI: on the images + above it returns 4818 pairs for 73 parents and 66 children, while 57 of the children lie + inside a parent's bounding box. ``Nested.featurize`` expects the child label column to be + named ``label``, while ``featurize_directory`` names it ``ROI_label``, and it groups the + joined child features by that child label, not by parent. Check the relations before relying + on aggregated results. diff --git a/docs/source/conf.py b/docs/source/conf.py index 0aa8f504e..1cc88f85f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,95 +1,79 @@ # Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. # +# The docs build without the compiled extension: the Python package is imported +# from the source tree and its pybind11 module, nyxus.backend, is mocked. + import os import sys -from pathlib import Path -import subprocess -import shutil -import io -sys.path.insert(0, os.path.abspath('../..')) -sys.setrecursionlimit(10000) +_repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, os.path.join(_repo_root, 'src', 'nyx', 'python')) # -- Project information ----------------------------------------------------- -project = 'nyxus' +project = 'Nyxus' author = 'Andriy Kharchenko' +copyright = 'Nyxus developers' + +# The citation on the index page quotes the latest release through |release|. CITATION.cff +# records it, and release-please updates its version line on every release. +with open(os.path.join(_repo_root, 'CITATION.cff'), encoding='utf-8') as _f: + release = next(line.split(':', 1)[1].split('#')[0].strip() + for line in _f if line.startswith('version:')) +version = '.'.join(release.split('.')[:2]) + # -- General configuration --------------------------------------------------- -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ 'sphinx.ext.napoleon', 'sphinx.ext.autodoc', 'sphinx_autodoc_typehints', 'sphinx.ext.autosummary', - 'autodocsumm' -] - -install_requires = [ - 'gcc>=8.0' + 'sphinx_design', ] -autodoc_mock_imports = ["backend", "nyx_backend"] +autodoc_mock_imports = ['nyxus.backend'] napoleon_use_param = True -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['..'] - # If true, the current module name will be prepended to all description # unit titles (such as .. function::). add_module_names = False - -html_sidebars = { '**': ['globaltoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html'] } - - autodoc_member_order = 'groupwise' autodoc_typehints = 'description' -autoclass_content = 'class' # Shows both the class docstring and __init__ +autoclass_content = 'both' # class docstring followed by the __init__ docstring autodoc_default_options = { 'members': True, 'inherited-members': True, 'special-members': '__getitem__,__call__,__setitem__', - 'show-inheritance': True - #'autosummary': True - # 'exclude-members': '__weakref__' + 'show-inheritance': True, } -# Autosummary settings autosummary_generate = True -# Set the master doc master_doc = 'index' # -- Options for HTML output ------------------------------------------------- -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'sphinx_rtd_theme' +html_theme = 'pydata_sphinx_theme' +# The pages describe the development head, not a release, so the title carries no version number. +html_title = 'Nyxus' +html_theme_options = { + 'github_url': 'https://github.com/PolusAI/nyxus', + 'navbar_align': 'left', + 'show_toc_level': 2, + 'navigation_with_keys': False, + 'secondary_sidebar_items': ['page-toc'], +} + +# -- Options for the link check ---------------------------------------------- -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -#html_static_path = ['html'] +# External sites time out or rate-limit now and then; retry before reporting a link as broken. +linkcheck_retries = 3 +linkcheck_timeout = 30 +linkcheck_rate_limit_timeout = 120 diff --git a/docs/source/conventions.rst b/docs/source/conventions.rst new file mode 100644 index 000000000..e19838d9c --- /dev/null +++ b/docs/source/conventions.rst @@ -0,0 +1,599 @@ +.. _conventions: + +Conventions +=========== + +A few conventions run through every feature family: which pixels make up a ROI, how intensities +become grey levels, which pixels count as background, which units a value is in, what a ROI of one +intensity returns, and how entropies take their logarithm. This page states each convention once +and summarises, in one table per topic, how each family applies it, in 2D and in 3D. The family +pages give the details, worked examples and measured values; each row links to its page. + +.. tip:: + + Three rules explain most surprises: + + - A pixel of intensity 0 inside the mask is a ROI pixel. The intensity and shape families use + it; several texture families treat it as background (see `Background`_). + - The 2D texture families, and three of the 3D ones, bin intensities into 64 grey levels by + default, counting from intensity 0, not from the ROI's minimum. IBSI mode turns the binning + off (see `Grey levels`_ and `2D and 3D defaults`_). + - Only the intensity families report values in the image's own units. Every other family that + reads intensities works on the grey levels Nyxus stores (see `Intensity domain`_). + + +Example +------- + +The script computes one intensity feature and two texture features of the 5 × 5 ROI of the texture +pages, of the same ROI with one pixel set to 0, and of a ROI of one intensity, with the default +settings and in IBSI mode: + +.. literalinclude:: ../examples/conventions_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + +Output: + +.. code-block:: text + + ibsi=False example 2.8000 0.3725 1654.7051 + ibsi=False zero inside 2.7600 0.4282 1590.1715 + ibsi=False constant 7.0000 0.0000 0.0000 + ibsi=True example 2.8000 0.3725 9.9006 + ibsi=True zero inside 2.7600 0.4282 9.9709 + ibsi=True constant 7.0000 0.0000 0.0000 + +- The pixel set to 0 is a ROI pixel for ``MEAN``, which drops from 2.8 to 69/25 = 2.76 in both + modes. +- ``GLDM_SDE`` leaves that pixel out in both modes: the matrix counts 24 pixels and the value rises + from 0.3725 to 0.4282. +- ``GLRLM_HGLRE_AVE`` weights runs by the square of their grey level. The default binning maps + intensities 1 to 5 to levels 13, 26, 39, 52 and 64, so the value is 1654.7; IBSI mode keeps the + intensities as grey levels and gives 9.90. +- The ROI of one intensity has no texture: both texture features return the ``--noval`` + substitute, 0 in Python. + + +.. _conventions-roi: + +The ROI +------- + +A ROI is the set of pixels (voxels in 3D) of the label image that carry the ROI's label. Its +bounding box is the smallest rectangle (box) that holds them. The intensity of a pixel does not +decide whether it belongs to the ROI: a pixel of intensity 0 inside the mask is a ROI pixel, and a +pixel of the bounding box outside the mask is not. + +Families that see only the ROI's pixels use every one of them, zeros included. Families that work +on the bounding box as an image fill the pixels outside the mask with 0, and then cannot tell them +apart from ROI pixels of intensity 0. The texture families are covered under `Background`_. + +.. list-table:: + :header-rows: 1 + :widths: 26 37 37 + + * - Family + - Pixels it reads + - A ROI pixel of intensity 0 + * - :doc:`Intensity `, :doc:`3D intensity ` + - The ROI's pixels + - Counted; lowers ``MIN`` and ``MEAN`` + * - :doc:`Intensity histogram ` (2D only) + - The ROI's pixels + - Counted; widens the bins when it is the ROI's minimum + * - :doc:`Morphology `, :doc:`3D shape `, + :doc:`erosion `, :doc:`fractal dimension ` + - The ROI's mask; intensities are not used, except by the few intensity-weighted morphology + features + - Counted as part of the shape. The chords treat it as a gap, and an erosion count is 0 for a + ROI of one intensity, a known defect (`#502 `__) + * - :doc:`2D moments ` + - The ROI's pixels + - Adds 1 to a shape moment and nothing to an intensity moment + * - :doc:`Gabor ` + - The whole bounding box, with pixels outside the mask set to 0, and 0 beyond the box + - The same as a pixel outside the mask + * - :doc:`Zernike ` + - The bounding-box pixels inside the Zernike disk, weighted by intensity + - Weight 0, like a pixel outside the mask, but it still extends the bounding box, which sets + the radius, and a ROI whose other pixels share one intensity is then computed rather than + given the ``--noval`` substitute + * - :doc:`Image quality ` + - The whole bounding box, with pixels outside the mask set to 0 + - The same as a pixel outside the mask + * - Texture families + - The bounding box or the ROI's pixels, by family + - See `Background`_ + +In whole-slide mode (whole-volume mode in 3D) the whole image, background included, is one ROI, +but without anisotropy its bounding box is one pixel or voxel larger than the image on every axis, +a known defect (`#493 `__); see :ref:`large-images-whole`. In 3D, ``Nyxus3D.featurize_files(..., single_roi=True)`` is not +whole-volume mode: it merges every nonzero label of the mask into one ROI, leaves out voxels where +the mask is 0, and uses the box of the remaining voxels; passing the volumes as their own masks +gives the box of the nonzero voxels (see :doc:`Math/f_morphology_3d`). The 3D shape features then +describe that box rather than the mask, a known defect (`#494 `__). + + +.. _conventions-grey-levels: + +Grey levels +----------- + +The texture families count grey levels, so they first turn intensities into a small set of integer +levels. Most of them share one rule, chosen by the sign of a grey-depth setting :math:`N`: the +global ``--coarseGrayDepth`` (Python ``coarse_gray_depth``, 64 by default) or a family's own +``greydepth`` metaparameter. With :math:`x_{\max}` and :math:`x_{\min}` the ROI's largest and +smallest intensity: + +.. list-table:: + :header-rows: 1 + :widths: 22 40 38 + + * - :math:`N` + - Grey level of intensity :math:`x` + - Notes + * - :math:`N > 0`, MATLAB-style (the default, 64) + - :math:`\lfloor N x / x_{\max} \rfloor + 1`, capped at :math:`N` + - The bins span 0 to :math:`x_{\max}`, not :math:`x_{\min}` to :math:`x_{\max}`, so a ROI + whose intensities lie far from 0 uses only the top levels. Intensity 0 becomes level 1, an + ordinary grey level. + * - :math:`N < 0`, PyRadiomics-style + - :math:`\lfloor (x - x_{\min}) / w \rfloor + 1`, capped at :math:`|N|`, with + :math:`w = (x_{\max} - x_{\min}) / |N|` + - :math:`|N|` equal-width bins over the ROI's own range. Intensity 0 stays level 0, the + background level. + * - :math:`N = 0`, or IBSI mode + - :math:`x` + - No binning: each integer intensity is a grey level. IBSI mode overrides every family's + setting with 0, except the 2D GLCM constant-ROI test (see :ref:`glcm-possible-issues`). + +The ``Nyxus`` and ``Nyxus3D`` constructors accept only a positive ``coarse_gray_depth`` and raise +``ValueError`` for 0 or a negative value. 0 and negative values are given on the command line, or, +for the 3D families that have one, through the family's ``greydepth`` metaparameter, set with +``set_metaparam``. IBSI mode is described on its own page, :ref:`ibsi-mode`. + +Two families do not use the shared rule: + +- **NGLDM**, 2D and 3D, maps intensity :math:`x` to :math:`\lfloor N x / x_{\max} \rfloor`, with + :math:`N` the global ``--coarseGrayDepth``. Its levels run from 0 to :math:`N`, every intensity + below :math:`x_{\max}/N` gets level 0, and a negative :math:`N` does not select PyRadiomics-style + bins. +- The **intensity** and **intensity histogram** families bin only for their histograms, into + equal-width bins from the ROI's minimum to its maximum: :math:`|N|` bins for ``ENTROPY`` and + ``UNIFORMITY`` (the sign is ignored), :math:`N` bins for ``HISTOGRAM``, which writes no columns + when :math:`N` is negative (a fix is coming), and :math:`N` bins for ``IH_*``, which + return the ``--noval`` substitute when :math:`N < 2`. IBSI mode does not change these bins. + +.. list-table:: + :header-rows: 1 + :widths: 24 38 38 + + * - Family + - 2D setting (default) + - 3D setting (default) + * - GLCM (:doc:`2D `, :doc:`3D `) + - ``--coarseGrayDepth`` (64). ``glcm/greydepth`` (Python) does not change the matrix; it + changes only the constant-ROI test (see :ref:`glcm-possible-issues`). + - ``--coarseGrayDepth`` or ``3glcm/greydepth`` (64) + * - GLRLM (:doc:`2D `, :doc:`3D `) + - ``--coarseGrayDepth`` (64) + - ``3glrlm/greydepth`` (0: no binning). ``--coarseGrayDepth`` has no effect. + * - GLSZM (:doc:`2D `, :doc:`3D `) + - ``--coarseGrayDepth`` (64) + - ``3glszm/greydepth`` (0: no binning). ``--coarseGrayDepth`` has no effect, and + whole-volume mode ignores the metaparameter. + * - GLDM (:doc:`2D `, :doc:`3D `) + - ``--coarseGrayDepth`` (64) + - ``3gldm/greydepth`` (0: no binning). ``--coarseGrayDepth`` has no effect. + * - GLDZM (:doc:`2D `, :doc:`3D `) + - ``--coarseGrayDepth`` (64). A negative value fails on ROIs with background in the + bounding box (see :ref:`gldzm-possible-issues`). + - ``--coarseGrayDepth`` (64); 0 turns binning off. No metaparameter. + * - NGLDM (:doc:`2D `, :doc:`3D `) + - ``--coarseGrayDepth`` (64), NGLDM's own rule + - ``--coarseGrayDepth`` (64), NGLDM's own rule. No metaparameter. + * - NGTDM (:doc:`2D `, :doc:`3D `) + - ``--coarseGrayDepth`` (64) + - ``3ngtdm/greydepth`` (0: no binning). ``--coarseGrayDepth`` has no effect. + * - ``ENTROPY``, ``UNIFORMITY``, ``HISTOGRAM`` (:doc:`2D `), + ``3ENTROPY``, ``3UNIFORMITY`` (:doc:`3D `) + - ``--coarseGrayDepth`` (64) histogram bins; the sign is ignored, except that ``HISTOGRAM`` + writes no columns for a negative value (a fix is coming) + - ``--coarseGrayDepth`` (64) histogram bins; the sign is ignored + * - :doc:`Intensity histogram ` + - ``--coarseGrayDepth`` (64) bins; computed only in IBSI mode + - No 3D family + +The other families, morphology, moments, Gabor, Zernike, erosion, fractal dimension and image +quality, do not bin. ``--coarseGrayDepth`` and IBSI mode do not change them. + +Grey-level-weighted texture features, such as grey level variance or the high and low grey level +emphases, use the level's value, so they change with the grey-depth setting and with the +intensity scale. Compare them only between runs with the same setting; each texture page shows the +size of the effect under *Possible issues*. + + +.. _conventions-2d-3d: + +2D and 3D defaults +------------------ + +The 3D texture families are not the 2D families with one more axis. With the default settings +they differ in these ways: + +.. list-table:: + :header-rows: 1 + :widths: 22 39 39 + + * - Topic + - 2D + - 3D + * - Default binning + - Every texture family bins into 64 levels. + - GLCM, GLDZM and NGLDM bin into 64 levels. GLRLM, GLSZM, GLDM and NGTDM do not bin, and + ``--coarseGrayDepth`` does not change them. Their default values equal their IBSI-mode + values, except 3D NGTDM busyness, which IBSI mode computes over a row for every integer + from 0 to the ROI maximum (see :doc:`Math/f_ngtdm_3d`). + * - Directions + - GLCM uses 0°, 45°, 90° and 135° (``--glcmAngles``); GLRLM always uses all four. + - GLCM and GLRLM use the 13 directions of the 26-voxel neighbourhood; no setting selects + them. + * - What a plain code reports + - ``GLCM_*`` and ``GLRLM_*`` have one column per angle, ``_0`` to ``_135``, plus ``_AVE``. + - ``3GLCM_*`` and ``3GLRLM_*`` are one column: the direction :math:`(1,1,1)` only. ``_AVE`` + averages the 13 directions. + * - Zones + - GLSZM zones grow only east, south-east, south and south-west, so some 8-connected zones + split (see :ref:`glszm-possible-issues`). GLDZM zones are 8-connected. + - GLSZM and GLDZM zones are 26-connected. + * - GLDZM distance + - A walk along the pixel's row and column to the first pixel of grey level 0 or the box + edge; a pixel next to background gets distance 2. + - The city-block distance to the nearest voxel outside the ROI, as IBSI defines it. + * - Background + - GLCM skips every pair with a pixel of intensity 0. + - GLCM counts pairs with voxels outside the mask, at grey level 1, with the default binning. + NGTDM uses the voxels outside the mask in every mode. See `Background`_. + * - Constant ROI + - Every GLCM feature returns 0, whatever ``--noval`` is, a known defect (`#511 `__). + - Most 3D GLCM features return their limiting values when the ROI fills its bounding box. See + `Constant ROIs and missing values`_. + * - Neighbourhood size + - Fixed at distance 1, except the GLCM offset, ``glcm/offset`` from Python. + - Fixed at distance 1, except the NGTDM radius, ``3ngtdm/radius``. + * - Voxel count + - GLSZM's :math:`N_p` counts the bounding-box pixels with a non-zero grey level, which with + the default binning is every pixel of the box. + - GLSZM's :math:`N_v` counts the voxels of the mask. + +The 3D family pages state these differences in their own *Settings* and *Possible issues* +sections, for example :ref:`glcm-3d-possible-issues` and :ref:`ngtdm-3d-possible-issues`. + + +.. _conventions-background: + +Background +---------- + +The texture families build their matrices on the ROI's bounding box. Most of them decide what is +background by grey level, not by the mask: a pixel whose grey level is the background level, 0, is +left out, whether it lies outside the mask or inside it. The default MATLAB-style binning turns +intensity 0 into level 1, an ordinary level, so the pixels of the bounding box outside the mask +then take part as a level of their own. IBSI mode and PyRadiomics-style binning mostly keep them +at level 0 and leave them out. NGLDM, 2D and 3D, uses the mask and leaves the pixels outside it out +in every mode. GLDM, 2D and 3D, leaves out every pixel of intensity 0, so the pixels outside the +mask are out in every mode too, together with ROI pixels of intensity 0. 3D NGTDM uses the pixels +outside the mask in every mode. ROIs that fill their bounding box with +non-zero intensities are not affected. + +.. list-table:: + :header-rows: 1 + :widths: 18 30 26 26 + + * - Family + - Pixels outside the mask, default settings + - Pixels outside the mask, IBSI mode + - ROI pixels of intensity 0 + * - :doc:`GLCM ` + - Pairs with them are not counted + - Not counted + - Not counted, in every mode + * - :doc:`GLRLM ` + - Level 1; they form runs + - Excluded + - As pixels outside the mask + * - :doc:`GLSZM ` + - Level 1; they form zones and count in :math:`N_p` + - Excluded + - As pixels outside the mask + * - :doc:`GLDM ` + - Excluded + - Excluded + - Excluded, in every mode + * - :doc:`GLDZM ` + - Level 1; they form zones, and the distance walks run through them to the box edge + - Excluded; the distance walks stop at them + - Default: level 1. IBSI: in no zone, stop the distance walks of their neighbours, and + counted in :math:`N_v` + * - :doc:`NGLDM ` + - Excluded, as centres and as neighbours + - Excluded + - Centres at grey level 0 + * - :doc:`NGTDM ` + - Level 1; counted and averaged into their neighbours + - Excluded, as pixels and as neighbours + - As pixels outside the mask + * - :doc:`3D GLCM ` + - Level 1; their pairs are counted + - Not counted + - As voxels outside the mask + * - :doc:`3D GLRLM ` + - Excluded (no binning). A positive ``3glrlm/greydepth`` makes them level 1, and they form + runs. + - Excluded + - As voxels outside the mask + * - :doc:`3D GLSZM ` + - In no zone, and not counted in :math:`N_v` + - The same + - In no zone, but counted in :math:`N_v`. A positive ``3glszm/greydepth`` also leaves out + every voxel of the lowest level. + * - :doc:`3D GLDM ` + - Excluded + - Excluded + - Excluded, in every mode. A positive ``3gldm/greydepth`` also leaves out every voxel of + level 1. + * - :doc:`3D GLDZM ` + - In no zone; outside the ROI for the distances + - The same + - Default: level 1. IBSI and PyRadiomics-style: every level of the ROI is raised by one, so + the voxel keeps its zone. + * - :doc:`3D NGLDM ` + - Excluded, as centres and as neighbours + - Excluded + - Centres at grey level 0 + * - :doc:`3D NGTDM ` + - Level 1; visited and averaged into their neighbours. A positive ``3ngtdm/greydepth`` stops + visiting them, but still averages them into their neighbours. + - Level 1; visited and averaged into their neighbours + - As voxels outside the mask + +"Excluded" means that a pixel is neither counted nor used as a neighbour. Each family page shows +the effect on a mask that leaves pixels of the bounding box out, under *Possible issues*: for +example :ref:`glrlm-possible-issues` and :ref:`gldzm-possible-issues`. + + +.. _conventions-intensity-domain: + +Intensity domain +---------------- + +Nyxus stores pixels as unsigned integer grey levels. When it reads an image from a file it maps the +stored values to grey levels and records the map: an integer image without negative values is used +as it is, an image with negative values is shifted up by its minimum, and a floating-point image is +rescaled to ``0 .. --fpimgdr`` grey levels. :ref:`intensity-units` gives the full rules. Arrays +passed to ``Nyxus.featurize`` are converted to unsigned integers, shifted up by their minimum when +they hold negative values and truncated, and no map is recorded for them. + +Only the intensity families convert their values back through the map: + +.. list-table:: + :header-rows: 1 + :widths: 36 64 + + * - Family + - Values are reported in + * - :doc:`Intensity `, :doc:`3D intensity ` + - The image's own units, for an image read from a file. Location features go through the + whole map, spreads through its slope, variances through its square. In-memory arrays are + reported in the stored grey levels. + * - :doc:`Intensity histogram ` + - ``_VAL`` features: the image's own units, for an image read from a file. ``_IDX`` + features: bin numbers. + * - Texture families + - Grey levels after binning, or the stored grey levels in IBSI mode + * - :doc:`2D moments ` (intensity moments), the intensity-weighted + :doc:`morphology ` features, :doc:`Gabor `, + :doc:`Zernike `, :doc:`image quality ` + - The stored grey levels, without binning + * - :doc:`Morphology `, :doc:`3D shape `, + :doc:`erosion `, :doc:`fractal dimension ` + - Do not depend on intensities + +A map that shifts the intensities, such as the offset applied to a CT image, therefore changes +features that depend on the grey levels' distance from 0: the default texture binning, the Zernike +weights, the Gabor scores and the intensity moments. Floating-point images give grey-level-based +features that depend on ``--fpimgdr``, ``--fpimgmin`` and ``--fpimgmax``. + + +.. _conventions-units: + +Units, pixel size and anisotropy +-------------------------------- + +Nyxus measures in pixels and voxels. It applies no physical pixel size to any feature. + +- **Lengths** are in pixels (2D) or voxels (3D), **areas** in pixels, and **volumes** in cubic + voxels. ``3AREA`` counts exposed voxel faces. Angles are in degrees, except the chord angles, + which are in radians. Run lengths, zone sizes and zone distances count pixels or voxels, + whatever the spacing. +- **Pixel size.** ``AREA_UM2`` is always 0, a known defect (`#496 `__). ``--pixelsPerCentimeter`` and the Python + ``pixels_per_micron`` are accepted but change no feature. The ``phys_x``, ``phys_y`` and + ``phys_z`` output columns report the file's pixel size (see :ref:`output`). +- **Anisotropy.** A scale factor per axis gives the size of a pixel or voxel along that axis, + relative to the other axes. Geometric features use it: intensity and texture features are + computed on the pixels or voxels as acquired and report the same values with or without it. A + factor of 1 counts as unset. + +.. list-table:: + :header-rows: 1 + :widths: 16 30 54 + + * - Dimension + - Settings + - Effect + * - 2D + - ``--anisox``, ``--anisoy``; Python ``anisotropy_x``, ``anisotropy_y`` + - Files only: arrays passed to ``Nyxus.featurize`` are featurized without anisotropy. The + geometric features are computed on the ROI resampled by nearest-neighbour sampling, in + pixels of the resampled grid: with ``anisotropy_x=2``, ``AREA_PIXELS_COUNT``, + ``BBOX_WIDTH`` and ``CENTROID_X`` are those of a ROI twice as wide. The intensity-weighted + geometric features (``EDGE_*``, ``WEIGHTED_CENTROID_X`` and ``_Y``, ``MASS_DISPLACEMENT``, + ``IMOM_*``, the radial distribution and ``ZERNIKE2D``) are computed on the pixels as + acquired, each at its position scaled by the factors. The intensity, texture, Gabor and + image quality features do not change, except ``POWER_SPECTRUM_SLOPE``, which is computed on + the resampled ROI, a known defect (`#519 `__). + * - 3D + - ``--anisox``, ``--anisoy``, ``--anisoz``; Python ``anisotropy_x``, ``anisotropy_y``, + ``anisotropy_z``; or ``--use-physical-spacing=true`` (Python ``use_physical_spacing``) + - The volume (3D or 2.5D) is featurized on its voxels as acquired, and the 3D + shape features scale their coordinates by the factors: the volumes scale by the factors' + product and the axis lengths are those of the scaled coordinates. The 3D intensity and + texture features do not change. ``use_physical_spacing`` takes the factors from the OME + ``PhysicalSize`` values, scaled so that the smallest is 1. A factor other than 1 in any + ``anisotropy_*`` / ``--aniso*`` replaces the physical spacing on all three axes; the axes + not given then use 1. + +:ref:`volumetric` describes the 3D spacing options with an example. + + +.. _conventions-missing: + +Constant ROIs and missing values +-------------------------------- + +A feature that cannot be computed, such as a division by zero or a texture matrix with a single +grey level, is reported as the ``--noval`` substitute, 0 by default. The Python classes have no +``noval`` option and always use 0. Values that are still NaN or infinite when the results are +written become ``--noval`` in CSV files and in pandas, and ``-0.0`` in Arrow and Parquet files, so +the tables hold no NaN (see :ref:`output`). On the command line, a ``--noval`` that no real value +can take, such as ``--noval=-999``, tells these cells apart from real zeros in CSV output. In Arrow +and Parquet files the values that are still NaN or infinite at output time are ``-0.0`` whatever +``--noval`` is, and some real values are also ``-0.0``, so these cells cannot be told apart from +zeros there. + +A ROI whose pixels all have one intensity is the most common case: + +.. list-table:: + :header-rows: 1 + :widths: 22 42 36 + + * - Family + - 2D + - 3D + * - Intensity (:doc:`2D `, :doc:`3D `) + - Defined: the location features return the intensity, the dispersions 0 (``ENTROPY`` about + :math:`-3 \times 10^{-16}`), ``UNIFORMITY`` 1; ``COVERED_IMAGE_INTENSITY_RANGE`` is the + substitute when the image holds only this ROI + - The same, with ``3ENTROPY`` and ``3COVERED_IMAGE_INTENSITY_RANGE`` + * - :doc:`Intensity histogram ` + - Every ``IH_*`` feature returns the substitute + - No 3D family + * - GLCM (:doc:`2D `, :doc:`3D `) + - Every feature returns 0, whatever ``--noval`` is, a known defect (`#511 `__) + - Limiting values (``3GLCM_ASM`` 1, ``3GLCM_CONTRAST`` 0); ``3GLCM_CORRELATION`` and + ``3GLCM_INFOMEAS1`` return the substitute (``-0.0`` in Arrow and Parquet) + * - GLRLM, GLSZM, GLDM, GLDZM + - Every feature returns the substitute, GLRLM's ``_AVE`` columns included + - The same + * - NGLDM (:doc:`2D `, :doc:`3D `) + - Every feature returns the substitute in memory; 0 out of core + - The substitute, in memory and out of core + * - NGTDM (:doc:`2D `, :doc:`3D `) + - Default binning: every feature returns the substitute. IBSI mode: coarseness and strength + return the substitute (``-0.0`` in Arrow and Parquet), the other three 0 + - Default, MATLAB-style and PyRadiomics-style binning: the substitute. IBSI mode: as in 2D + * - :doc:`Gabor ` + - Every column is 0, whatever ``--noval`` is + - No 3D family + * - :doc:`Zernike ` + - All 30 columns return the substitute + - No 3D family + * - :doc:`Erosion ` + - 0, whatever the ROI's shape, on the CPU, a known defect (`#502 `__). The GPU path erodes the + ROI as usual + - No 3D family + * - :doc:`Image quality ` + - ``MAX_SATURATION`` 1, ``MIN_SATURATION`` 0, ``POWER_SPECTRUM_SLOPE`` and ``SHARPNESS`` 0; + the focus scores are not 0 + - No 3D family + +The table is for a ROI that fills its bounding box. A constant ROI that does not fill it can gain +the pixels outside the mask as a second grey level, and its features are then computed: NGTDM in +2D with the default binning and in 3D, and 3D GLCM with the default binning (see `Background`_). +Image quality also reads the zeros outside the mask, so its values differ too. Morphology, 3D +shape, fractal dimension and the shape moments do not depend on intensity, and a constant ROI +changes nothing for them. + + +.. _conventions-entropy: + +Entropy logarithm +----------------- + +Every entropy is in bits, a base-2 logarithm. Two kinds of logarithm are in use: + +- **A fast approximation** in the GLCM, GLRLM, GLSZM and GLDM families, 2D and 3D. It works in + single precision and replaces :math:`\log_2` of the significand by a quadratic fit. It is a + deliberate fast path, kept for speed. A single :math:`\log_2 p` is off by up to 0.009, mostly + upward. An entropy is a probability-weighted sum, so its error is a probability-weighted average + of the per-term errors: at most 0.009 bits, and it usually reads slightly low. On the worked + examples the families' entropies differ from an exact logarithm by a relative + :math:`3.1 \times 10^{-3}` at most (0 where every probability is a power of 2). On random + distributions the relative error reaches about :math:`8 \times 10^{-3}`, and it grows without + bound as the entropy goes to 0. Measures derived from entropies can differ more: + ``GLCM_INFOMEAS1``, a difference of two entropies divided by a third, by up to + :math:`7 \times 10^{-3}` on the worked example. Reference tools are therefore compared with + these features at a looser tolerance. +- **The exact logarithm**, in double precision, everywhere else that an entropy appears. + +.. list-table:: + :header-rows: 1 + :widths: 24 34 14 28 + + * - Family + - Features + - Logarithm + - Relative difference from exact, worked example + * - :doc:`GLCM ` + - ``ENTROPY``, ``JE``, ``DIFENTRO``, ``SUMENTROPY``, ``INFOMEAS1``, ``INFOMEAS2`` + - Fast + - IBSI mode, over the four angles: entropies up to :math:`3.1 \times 10^{-3}` + (``DIFENTRO``), exact where every probability is a power of 2 (``SUMENTROPY_45``); + ``INFOMEAS2`` up to :math:`3.0 \times 10^{-3}`, ``INFOMEAS1`` up to + :math:`5.6 \times 10^{-3}`. Default settings: ``INFOMEAS1`` up to + :math:`7.0 \times 10^{-3}` + * - :doc:`3D GLCM ` + - The same, as ``3GLCM_*`` + - Fast + - :math:`2 \times 10^{-4}` (``JE``) to :math:`2 \times 10^{-3}` (``INFOMEAS1``) + * - :doc:`GLRLM `, :doc:`3D GLRLM ` + - ``RE`` + - Fast + - :math:`2 \times 10^{-4}` to :math:`1.2 \times 10^{-3}` (``GLRLM_RE`` over the four + angles), :math:`1.6 \times 10^{-3}` (``3GLRLM_RE``) + * - :doc:`GLSZM `, :doc:`3D GLSZM ` + - ``ZE`` + - Fast + - :math:`7.1 \times 10^{-4}`, :math:`2.9 \times 10^{-4}` + * - :doc:`GLDM `, :doc:`3D GLDM ` + - ``DE`` + - Fast + - :math:`1.1 \times 10^{-3}`, :math:`5.7 \times 10^{-4}` + * - :doc:`GLDZM `, :doc:`3D GLDZM ` + - ``ZDE`` + - Exact + - :math:`10^{-15}` or less + * - :doc:`NGLDM `, :doc:`3D NGLDM ` + - ``DCENT`` + - Exact + - :math:`10^{-15}` or less + * - :doc:`Intensity `, :doc:`3D intensity `, + :doc:`intensity histogram ` + - ``ENTROPY``, ``3ENTROPY``, ``IH_ENTROPY_VAL``, ``IH_ENTROPY_IDX`` + - Exact + - — + +NGTDM has no entropy feature. In GPU builds run with ``--useGpu=true`` on the command line, 2D +GLSZM zone entropy is summed on the GPU with the exact logarithm in single precision. diff --git a/docs/source/developer_guide.rst b/docs/source/developer_guide.rst new file mode 100644 index 000000000..4a5f81f00 --- /dev/null +++ b/docs/source/developer_guide.rst @@ -0,0 +1,9 @@ +Developer guide +=============== + +Working on the Nyxus code. + +.. toctree:: + :maxdepth: 2 + + devguide diff --git a/docs/source/devguide.rst b/docs/source/devguide.rst index 6ca10900e..4e3e9b48e 100644 --- a/docs/source/devguide.rst +++ b/docs/source/devguide.rst @@ -1,271 +1,1367 @@ -.. role:: raw-html-m2r(raw) - :format: html +Developer guide +=============== -Developer's guide -================= +This guide is for people who change Nyxus itself: add a feature, a feature group, a setting or a +command-line option, fix a computation, or write tests and documentation for one. The first part is +a step-by-step walkthrough of adding a feature. The rest is reference material that the walkthrough +links to. -Adding a new feature --------------------- +All C++ paths are relative to ``src/nyx/`` unless they start with another top-level directory. + +.. contents:: On this page + :local: + :depth: 1 + + +How a run flows through the code +-------------------------------- + +A run, whether it is started from the command line or from Python, goes through the same stages. + +#. **Parse.** ``Environment`` (``environment.h``) collects the options. The command line goes + through ``Environment::parse_cmdline`` in ``environment.cpp``; Python goes through + ``initialize_environment`` and ``set_environment_params_imp`` in + ``python/new_bindings_py.cpp``. +#. **Select features.** ``Environment::expand_featuregroups`` in ``env_features.cpp`` turns the + requested names and groups into a set of enabled feature codes in ``FeatureSet`` + (``featureset.h``). +#. **Compile.** ``FeatureManager::compile`` checks that every 2D and 3D feature code has exactly + one provider and that there are no dependency cycles. + ``FeatureManager::apply_user_selection`` keeps the feature methods that the enabled codes need. + ``Environment::compile_feature_settings`` builds one settings vector per feature class. +#. **Scan.** The workflow for the run type (``workflow_2d_segmented.cpp``, + ``workflow_2d_whole.cpp``, ``workflow_3d_segmented.cpp``, ``workflow_3d_whole.cpp`` or + ``workflow_pythonapi.cpp``) scans each image pair, builds one ``LR`` record per ROI, and + estimates each ROI's memory footprint. +#. **Compute.** ROIs whose footprint is below the RAM limit are computed in memory by the + functions in ``reduce_trivial_rois.cpp``. For file input, larger ROIs are computed out of core + by ``processNontrivialRois`` (``phase3.cpp``) or ``processNontrivialRois_3D`` + (``phase3_3d.cpp``); in-memory arrays (``workflow_pythonapi.cpp``) and whole slides refuse + them. See :ref:`devguide-dispatch`. +#. **Output.** The values that each feature wrote into ``LR::fvals`` are written as CSV, Arrow IPC + or Parquet, or returned to Python as a table (``output_2_csv.cpp``, ``output_2_buffer.cpp``, + ``output_2_apache.cpp``). + + +.. _devguide-adding-a-feature: + +Adding a feature +---------------- -Adding a feature is a 9-step procedure. +The example adds a 2D feature class ``MyFeature`` that provides two codes, ``MY_FEATURE_A`` and +``MY_FEATURE_B``. The steps for a 3D or image-quality feature are the same; the differences are +noted in each step. -Step 1 - create a numeric identifier -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Do the steps in order. Each one names the reference section that explains the code it touches. - Come up with an internal c++ compliant identifier for the feature and its user-facing counterpart. Edit enum AvailableFeatures in file featureset.h keeping constant _COUNT_ , for example : +Step 1. Add the feature codes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Add one enumerator per output code to the matching enum in ``featureset.h``: ``Feature2D``, +``Feature3D`` or ``FeatureIMQ``. Add them immediately before ``_COUNT_``: .. code-block:: c++ - - enum AvailableFeatures + + enum class Feature2D { + COV = 0, ... - MYFEATURE1, - MYFEATURE2, - MYFEATURE3, - ... - _COUNT_ + IH_BIN_SIZE, + + MY_FEATURE_A, + MY_FEATURE_B, + + _COUNT_, + _FIRST_ = COV }; -Step 2 - create a user facing string identifier -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Adding a 2D code changes the numeric value of every 3D and image-quality code, because those enums +start where the previous one ends. Never store or compare a feature code as a number. See +:ref:`devguide-feature-codes`. -edit the integer to string feature identifier mapping in mapping UserFacingFeatureNames in file featureset.cpp. For example, if we want to give features MYFEATURE1, MYFEATURE2, MYFEATURE3 which so far are just numeric constants user-facing names MY_FEATURE_1, MY_FEATURE_2, and MY_FEATURE_3 that can be used in the command line, we need to edit UserFacingFeatureNames the following way +Step 2. Give the codes user-facing names +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Add one entry per code to the name map in ``featureset.cpp``: + +- 2D: ``Nyxus::UserFacingFeatureNames``; +- 3D: ``Nyxus::UserFacing_3D_featureNames`` (3D names start with ``3``, for example ``3COV``); +- image quality: ``Nyxus::UserFacingIMQFeatureNames``. .. code-block:: c++ - std::map UserFacingFeatureNames = - { - ... - {"MY_FEATURE_1", MYFEATURE1}, - {"MY_FEATURE_2", MYFEATURE2}, - {"MY_FEATURE_3", MYFEATURE3 }, - ... - }; + { "MY_FEATURE_A", Feature2D::MY_FEATURE_A }, + { "MY_FEATURE_B", Feature2D::MY_FEATURE_B }, + +This name is what users type, what the output column is called, and what the documentation +generator lists. -Step 3 - create a feature method class -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Step 3. Declare the feature class +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Any Nyxus feature needs to be derived from class FeatureMethod defining a particular calculator of one or multiple features. FeatureMethod is a skeleton of the custom feature calculator responding to image data streamed to it in various ways - pixel by pixel (so called online mode), as a cached pixel cloud in the form of std::vector\ :raw-html-m2r:`` object for images whose size permits caching a single ROI's data in user computer's RAM (so called trivial ROIs), and as a browser of a mask-intensity image pair for ROIs whose cache exceeds the RAM limit (so called non-trivial or oversized ROIs). All the particular feature calculation logic needs to be placed in your implementation of FeatureMethod's pure virtual methods. The class's header and source files are suggested to be placed in directory "features". For example, if we want to implement a class calculating a 3-segmental ROI intensity statistics (means) weighted by unit perimeter length delivered to user as features MYFEATURE1, MYFEATURE2, and MYFEATURE3: +Create ``features/my_feature.h``. The class derives from ``FeatureMethod`` and declares a static +``featureset`` list that names every code it provides: .. code-block:: c++ + #pragma once + + #include + #include "../dataset.h" #include "../feature_method.h" - class ShamrockFeature: public FeatureMethod + #include "../feature_settings.h" + #include "../roi_cache.h" + + class MyFeature : public FeatureMethod { public: - ShamrockFeature(); - void calculate(LR& r); - void osized_add_online_pixel(size_t x, size_t y, uint32_t intensity); - void osized_calculate(LR& r, ImageLoader& imloader); - void save_value(std::vector>& feature_vals); - virtual void parallel_process(std::vector& roi_labels, std::unordered_map & roiData, int n_threads); - static void parallel_process_1_batch(size_t start, size_t end, std::vector* ptrLabels, std::unordered_map * ptrLabelData); + const constexpr static std::initializer_list featureset = + { + Nyxus::Feature2D::MY_FEATURE_A, + Nyxus::Feature2D::MY_FEATURE_B + }; + + MyFeature(); + + // in-RAM path + void calculate (LR& r, const Fsettings& s); + + // out-of-core path + void osized_add_online_pixel (size_t x, size_t y, uint32_t intensity) {} + void osized_calculate (LR& r, const Fsettings& s, ImageLoader& ldr); + + // results + void save_value (std::vector>& feature_vals); + void cleanup_instance(); - // Constants used in the output - const static int num_segments = 3; + // hooks used by the dispatcher + static bool required (const FeatureSet& fs) { return fs.anyEnabled (MyFeature::featureset); } + static void extract (LR& r, const Fsettings& s); + static void parallel_process_1_batch (size_t start, size_t end, + std::vector* ptrLabels, std::unordered_map* ptrLabelData, + const Fsettings& s, const Dataset& ds); private: - std::vector segment_means; + double val_a = 0, val_b = 0; }; -Step 4 - define feature class' provided features and feature dependencies -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Write the ``featureset`` list exactly in this form, with fully qualified ``Nyxus::Feature2D::`` +names. The group expanders and the documentation generator both read it. See +:ref:`devguide-feature-method`. -Feature methods are run by Nyxus feature manager. The order of their running is determined by their inter-dependencies. Class FeatureMethod's function method provide_features() lets you declare specific features implemented by your class; method add_dependencies() lets you declare features that need to be calculated and saved to ROIs' LR::fvals cache prior to running calculations of your feature method. The feature codes that you use as arguments to add_dependencies() and provide_features() come from file featureset.h . For example +Step 4. Declare provided features and dependencies +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In the constructor, pass a short class name to ``FeatureMethod``, list the provided codes, and list +the codes whose values or helper data this class reads: .. code-block:: c++ - ShamrockFeature::ShamrockFeature() : - FeatureMethod("ShamrockFeature") + MyFeature::MyFeature() : FeatureMethod ("MyFeature") { - // we expose them - provide_features ({MYFEATURE1, MYFEATURE2, MYFEATURE3}); - - // we need this feature prior to working on MYFEATURE1, MYFEATURE2, and MYFEATURE3 - add_dependencies ({PERIMETER}); + provide_features ({ Nyxus::Feature2D::MY_FEATURE_A, Nyxus::Feature2D::MY_FEATURE_B }); + add_dependencies ({ Nyxus::Feature2D::PERIMETER }); // reads the contour } -Step 5 - plan feature's internal and exposed data; implement saving results -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Every code must have exactly one provider. ``FeatureManager::compile`` rejects a 2D or 3D code with +none or with several; image-quality codes are not checked. Dependencies are listed as feature +codes, not classes. They are used to order and check the feature methods; in the in-RAM path you +also have to place the dispatch block correctly (step 9). +Step 5. Implement the in-RAM calculation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Step 6 - implement feature method's online behavior (for oversized ROIs only) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -In order to perform some action on the level of individual pixel while scanning a trivial ROI e.g. calculate some statistics using Welford principle, override abstract method +Create ``features/my_feature.cpp`` and implement ``calculate``, ``save_value``, ``extract`` and +the batch function: .. code-block:: c++ - void osized_add_online_pixel(size_t x, size_t y, uint32_t intensity); + void MyFeature::calculate (LR& r, const Fsettings& s) + { + for (const Pixel2& px : r.raw_pixels) + { + // px.x, px.y are image coordinates, px.inten is the grey level + ... + } + val_a = ...; + val_b = ...; + } + void MyFeature::save_value (std::vector>& fvals) + { + fvals[(int)Nyxus::Feature2D::MY_FEATURE_A][0] = val_a; + fvals[(int)Nyxus::Feature2D::MY_FEATURE_B][0] = val_b; + } -or give it empty body. + void MyFeature::extract (LR& r, const Fsettings& s) + { + MyFeature f; + f.calculate (r, s); + f.save_value (r.fvals); + } -Step 7 - implement feature calculation of regular sized ROIs -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -ROIs are classified to regular ("trivial") or oversized automatically based on their area in pixels. It's developer's responsibility to handle both cases by implementing pure virtual methods of abstract class FeatureMethod, parent of your particular feature method. To implement regular-sized feature calculation, override method + void MyFeature::parallel_process_1_batch (size_t start, size_t end, + std::vector* ptrLabels, std::unordered_map* ptrLabelData, + const Fsettings& s, const Dataset& ds) + { + for (auto i = start; i < end; i++) + { + int lab = (*ptrLabels)[i]; + LR& r = (*ptrLabelData)[lab]; + extract (r, s); + } + } -.. code-block:: c++ +The batch function must have the signature that ``runParallel`` (``parallel.h``) expects. Some +classes call it ``reduce``; the name does not matter, the signature does. - void calculate (LR& r); +Read only the ``LR`` fields listed in :ref:`devguide-lr`, and read settings only through the +``STNGS_*`` macros (:ref:`devguide-settings`). Decide what the feature reports for a +constant-intensity ROI and for an ROI too small for the definition, and report it the same way on +every path. +Step 6. Implement the out-of-core calculation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -For example +An ROI larger than the RAM limit has no ``raw_pixels``; its pixels are in the disk-backed cloud +``LR::raw_pixels_NT``. Implement ``osized_calculate`` so that it gives exactly the same values as +``calculate``. The simplest way is to rebuild the in-RAM data and delegate: .. code-block:: c++ - void ShamrockFeature::calculate(LR& r) + void MyFeature::osized_calculate (LR& r, const Fsettings& s, ImageLoader&) { - // prepare the results buffer - segment_means.resize(num_segments); + val_a = val_b = 0; // this instance is reused across ROIs + r.rebuild_raw_pixels_from_cloud(); // or rebuild_aux_image_matrix_from_cloud() + calculate (r, s); + } + void MyFeature::cleanup_instance() + { + val_a = val_b = 0; + } +Two things differ from the in-RAM path: - // iterate cached ROI pixels - for (auto& px : raw_pixels) - { - // accumulate sums - ... - } +- The out-of-core loop uses one feature-method instance for every oversized ROI, while the in-RAM + path creates a new instance per ROI in ``extract``. Reset every member at the start of + ``osized_calculate`` and in ``cleanup_instance``, or one ROI's values leak into the next. +- ``osized_add_online_pixel`` is a pure virtual that no code path calls. Give it an empty body. - // calculate elements of segment_means - ... +For a 3D feature, also add the class to ``is_3d_ooc_supported`` in ``phase3_3d.cpp``. Without it, +the out-of-core 3D path refuses the feature with an error. 3D out-of-core features read +``LR::raw_voxels_NT``. - } +See :ref:`devguide-ooc-rule`. +Step 7. Register the class +^^^^^^^^^^^^^^^^^^^^^^^^^^ -Step 8 - implement feature calculation of oversized ROIs -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -An oversized ROI's cached data cannot fit in computer memory so in the oversized ROI scenarios we cannot rely on its pixel cloud or image matrix. Instead, all the calculations should be performed "in place" - using the image browser class ImageLoader (header image_loader.h) similarly to class ImageMatrix (image_matrix.h) and creating out of memory cache using classes OutOfRamPixelCloud, OOR_ReadMatrix, ReadImageMatrix_nontriv, and WriteImageMatrix_nontriv (header image_matrix.nontriv). You are guaranteed to have initialized object LR::osized_pixel_cloud prior to the call of method osized_calculate(). For example: +Add one line to the ``FeatureManager`` constructor in ``feature_mgr_init.cpp``, in the 2D, 3D or +image-quality block: .. code-block:: c++ - void ShamrockFeature::osized_calculate (LR& r, ImageLoader& imlo) - { - // prepare the results buffer - segment_means.resize(num_segments); + register_feature (new MyFeature()); +See :ref:`devguide-registration`. +Step 8. Give the class a settings vector +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - // iterate ROI pixels directly in the huge source image - OutOfRamPixelCloud& cloud = r.osized_pixel_cloud; - for (size_t i = 0; i < cloud.get_size(); i++) // oversized analog for for(auto& px : raw_pixels) - { - auto pxA = cloud.get_at(i); - // accumulate sums - ... - } +Each feature class has its own ``Fsettings`` member on ``Environment``. - // calculate elements of segment_means - ... +#. Add ``fsett_MyFeature`` to the ``Fsettings`` list in ``environment.h``. +#. Register it in ``Environment::compile_feature_settings`` (``env_features.cpp``): - } + .. code-block:: c++ + f_settings_.push_back (fsett_MyFeature); + feature2settings_ [typeid(MyFeature)] = f_settings_.size() - 1; -Step 9 - implementing the output of composite features -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -If your feature method class provides multiple features, like ShamrockFeature calculating intensity statistics in 3 segmental bins in the above example, the output of corresponding values can be managed for the CSV-file and Python bindings in functions +The out-of-core path looks the vector up by the class's ``typeid``. A class that is registered in +step 7 but missing here makes the run fail with "No feature settings are registered for ...". If the +feature needs its own parameter, see :ref:`devguide-settings`. + +Step 9. Dispatch the feature +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The in-RAM, whole-slide and whole-volume paths call feature classes explicitly, in a fixed order, in +``reduce_trivial_rois.cpp``. Add a block to each path that applies: + +- segmented 2D: ``reduce_trivial_2d``; +- whole-slide 2D: ``reduce_trivial_2d_wholeslide``; +- segmented 3D: ``reduce_trivial_3d``; +- whole-volume 3D: ``reduce_trivial_3d_wholevolume``. .. code-block:: c++ - save_features_2_csv (std::string intFpath, std::string segFpath, std::string outputDir) + //==== My feature + if (MyFeature::required (env.theFeatureSet)) + { + STOPWATCH("Morphology/MyFeature/MF/#4aaaea", "\t="); + const Fsettings& fst = env.fsett_MyFeature; + runParallel (MyFeature::parallel_process_1_batch, n_threads, work_per_thread, job_size, + &L, &env.roiData, fst, env.dataset); + } + +The whole-slide and whole-volume blocks call ``MyFeature::extract (r, s)`` on the single ROI +instead of ``runParallel``. + +Place the block after the blocks of the features it depends on. If it reads helper data that +another feature builds, such as the contour or the convex hull, also add ``MyFeature::required`` +to the condition of that feature's block. For example, the contour block runs when any of the +contour-based features is requested. Out of core, the feature manager runs the dependencies +first (:ref:`devguide-registration`). + +If the feature compares ROIs with each other, it belongs in +``reduce_neighbors_and_dependencies_manual``, which runs once per image after its in-RAM ROIs are +computed and before its oversized ROIs are. + +See :ref:`devguide-dispatch`. +Step 10. Add the feature to groups +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -and +Add the class to the groups that should include it, in ``Environment::expand_2D_featuregroup`` +(or ``expand_3D_featuregroup``) in ``env_features.cpp``. For example, a morphology feature goes into +the ``Fgroup2D::FG2_MORPHOLOGY`` branch: .. code-block:: c++ - save_features_2_buffer(std::vectorstd::string& headerBuf, std::vector\ :raw-html-m2r:``\ & resultBuf, std::vectorstd::string& stringColBuf) + theFeatureSet.enableFeatures (MyFeature::featureset, enable); + +``*ALL*`` and ``*3D_ALL*`` need no change. ``*ALL*`` enables every 2D code by range. ``*3D_ALL*`` +lists each 3D class, so a new 3D class has to be added to it. See :ref:`devguide-groups`. + +Step 11. Output more than one column per code, if needed +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``LR::fvals`` holds a vector per code. Most codes write element ``[0]`` and produce one output +column. A code that produces several values, like ``GABOR``, ``ZERNIKE2D``, ``FRAC_AT_D`` or +``HISTOGRAM``, resizes its vector in ``save_value`` and needs a branch in every writer that names the +columns and copies the values. The writers are in ``output_2_buffer.cpp`` and ``output_2_csv.cpp``; +search them for ``Feature2D::ZERNIKE2D`` to find every place. The per-angle GLCM and GLRLM codes use +the angle lists instead of a code branch. + +Step 12. Add the sources to the build +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Add ``src/nyx/features/my_feature.cpp`` to the ``set(SOURCE ...)`` list in the top-level +``CMakeLists.txt``. Headers do not need to be listed. The same list builds the command-line +executable, the Python module and the test runner. See :ref:`devguide-build`. + +Step 13. Test it +^^^^^^^^^^^^^^^^ + +Add tests that follow the vetting specification in ``tests/vetting/SPEC.md``: + +#. An **oracle** test that compares the values with a reference tool or a closed form, in + ``test_2d_myfamily_.h`` (or ``.py``). +#. An **invariant** test that the out-of-core values equal the in-RAM values. +#. A **mechanics** test for anything the feature gates on: settings, defaults, refusals. +#. One row per assertion in ``tests/vetting/oracle_coverage.csv``, then regenerate the reports + and run the checks. + +See :ref:`devguide-testing`. + +Step 14. Document it +^^^^^^^^^^^^^^^^^^^^ + +#. Add a one-line description of each code to ``docs/feature_descriptions.toml``. +#. If the family has a definition page in ``docs/source/Math``, add the codes to + ``docs/feature_extras.toml`` and a definition with a ``.. _feature-:`` label, the code in + lower case with hyphens (``.. _feature-my-feature-a:``), to the page. + A new family needs a new page, its worked-example script, and entries in the generator's family + lists. +#. Update the feature-count sentence in ``README.md``, ``docs/source/index.rst`` and the other + files listed in ``COUNT_PAGES`` in ``docs/tools/gen_feature_reference.py``; the generator + checks them. +#. Regenerate the reference pages and build the docs with warnings as errors. +See :ref:`devguide-docs`. -accordingly. +Step 15. Commit +^^^^^^^^^^^^^^^ -The ROI cache - structure LR ------------------------------ +Use a Conventional Commit message, for example ``feat(morphology): add MY_FEATURE_A and +MY_FEATURE_B``. The type decides the release notes section and the version bump. See +:ref:`devguide-release`. -A mask-intensity image pair is being prescanned and examined before the feature manager runs feature calculation of each feature method. As a result of that examination ROIs are being determined themselves and structure LR (defined in file roi_cache.h) is initialized for each ROI. Some fields are essential to developer's feature calculation in overridable methods of base class FeatureMethod: +.. _devguide-feature-codes: + +Feature codes and names +----------------------- + +Enums +^^^^^ + +``featureset.h`` declares three ``enum class`` types in namespace ``Nyxus``: + +.. list-table:: + :header-rows: 1 + :widths: 18 30 52 + + * - Enum + - First value + - Contents + * - ``Feature2D`` + - ``COV = 0`` + - 2D features, in families. The IBSI intensity-histogram codes (``IH_*``) come last. + * - ``Feature3D`` + - ``COV = (int) Feature2D::_COUNT_`` + - 3D features. + * - ``FeatureIMQ`` + - ``FOCUS_SCORE = (int) Feature3D::_COUNT_`` + - Image-quality features. + +Each enum ends with ``_COUNT_`` and defines ``_FIRST_``. The three enums form one continuous range +of integers, so any code identifies its dimension. Code that is shared by all dimensions uses that +range: + +- ``FeatureSet`` keeps one flag per code in ``m_enabledFeatures[(int) FeatureIMQ::_COUNT_]``; +- ``LR::initialize_fvals`` sizes ``fvals`` to ``FeatureIMQ::_COUNT_``; +- ``FeatureMethod`` stores provided codes and dependencies as plain ``int``. + +The consequence is that inserting a 2D code shifts every 3D and image-quality code. Codes are +internal: users, output files, tests and documentation refer to features by name. Never hard-code +the numeric value of a code. + +The feature groups use the same scheme: ``Fgroup3D`` starts at ``Fgroup2D::_COUNT_`` and +``FgroupIMQ`` at ``Fgroup3D::_COUNT_``. + +Name maps +^^^^^^^^^ + +``featureset.cpp`` maps user-facing names to codes: .. list-table:: :header-rows: 1 - - * - LR field - - Description - * - int label - - ROI's integer ID number - * - std::string segFname, intFname - - ROI's host mask and intensity image names - * - std::vector raw_pixels - - cloud of ROI's cached pixels - * - OutOfRamPixelCloud osized_pixel_cloud - - cloud of ROI's pixels cached out of memory - * - unsigned int aux_area - - ROI area in pixels - * - PixIntens aux_min, aux_max - - minimum and maximum pixel intensity within the ROI mask - * - AABB aabb - - axis aligned bounding box giving ROI's bounding box dimensions and origin position - * - std::vector contour - - (trivial ROIs only) pixlels of ROI contour initialized by feature PERIMETER - * - std::vector convHull_CH - - (trivial ROIs only) pixels of ROI's convex hull initialized as a result of calculating any of features CONVEX_HULL_AREA, SOLIDITY, and CIRCULARITY - * - std::vector> fvals - - vector of feature value vectors of length AvailableFeatures::\_COUNT\_ (see file featureset.h) - * - ImageMatrix aux_image_matrix - - (trivial ROIs only) matrix of pixel intensities - * - std::unordered_set host_tiles - - indices of TIFF tiles hosting the ROI (generally a ROI can span multiple TIFF tiles) - - -Adding a feature group + :widths: 45 55 + + * - Map + - Holds + * - ``UserFacingFeatureNames`` + - 2D feature names to ``Feature2D`` + * - ``UserFacing_3D_featureNames`` + - 3D feature names to ``Feature3D`` + * - ``UserFacingIMQFeatureNames`` + - image-quality feature names to ``FeatureIMQ`` + * - ``UserFacing2dFeaturegroupNames`` + - 2D group names, such as ``*ALL_GLCM*``, to ``Fgroup2D`` + * - ``UserFacing3dFeaturegroupNames`` + - 3D group names, such as ``*3D_GLCM*``, to ``Fgroup3D`` + * - ``UserFacingIMQFeaturegroupNames`` + - ``*ALL_IMQ*`` to ``FgroupIMQ`` + +``FeatureSet::find_2D_FeatureByString``, ``find_3D_FeatureByString`` and their group and IMQ +counterparts look names up in these maps. A leading ``+`` or ``-`` on a 2D or 3D name is the +enable or disable operator; the lookup returns the code negated for ``-``. A code with no entry in +its map is still computed if a group enables it, but it is output under a placeholder name such as +``2D-feature``, so every code needs a name. + + +.. _devguide-feature-method: + +The FeatureMethod class ----------------------- -Often multiple features need to be calculated together and the user faces the need to specify a long comma separated list of features. As a result the command line may become cumbersome. For example, calculating some popular morphologic features may involve the following command line + +``FeatureMethod`` (``feature_method.h``) is the base class of every feature class. One class usually +computes a whole family, because the codes of a family share intermediate data. + +Virtual interface +^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Member + - Purpose + * - ``calculate (LR&, const Fsettings&)`` + - Pure virtual. Computes the features of one in-RAM ROI from its cached data. + * - ``osized_calculate (LR&, const Fsettings&, ImageLoader&)`` + - Pure virtual. Computes the features of one oversized ROI from its disk-backed data. + * - ``osized_add_online_pixel (size_t x, size_t y, uint32_t intensity)`` + - Pure virtual, never called. Implement it with an empty body. + * - ``save_value (std::vector>&)`` + - Pure virtual. Copies the computed values into the ROI's ``fvals``. + * - ``osized_scan_whole_image (LR&, const Fsettings&, const Dataset&, ImageLoader&)`` + - The out-of-core entry point. The default calls ``osized_calculate`` and then + ``save_value``. Classes that need the dataset's slide properties (intensity, intensity + histogram) override it. + * - ``cleanup_instance()`` + - Called after each oversized ROI. The default does nothing; override it to reset state. + * - ``parallel_process (...)`` + - Virtual with an empty default; the dispatcher does not use it. + +The non-virtual ``provide_features`` and ``add_dependencies`` each have an overload for +``Feature2D``, ``Feature3D`` and ``FeatureIMQ`` lists; ``provides`` and ``depends`` query them. +``osized_reduce`` is declared ``final`` with an empty body. + +Static hooks +^^^^^^^^^^^^ + +The dispatcher in ``reduce_trivial_rois.cpp`` calls static members by class name, so every feature +class also defines: + +- ``featureset``: a ``constexpr std::initializer_list`` of the codes it provides. The group + expanders, the IBSI gate and the documentation generator read it. +- ``required (const FeatureSet&)``: whether any of its codes is enabled. Most classes return + ``fs.anyEnabled (featureset)``. +- ``extract (LR&, const Fsettings&)``: creates an instance, calls ``calculate`` and ``save_value``. + The whole-slide and whole-volume paths call it directly. +- A batch function, ``parallel_process_1_batch`` or ``reduce``, with the ``functype`` signature from + ``parallel.h``: + + .. code-block:: c++ + + void (size_t start, size_t end, std::vector* ptrLabels, + std::unordered_map* ptrLabelData, + const Fsettings& s, const Dataset& ds); + + ``runParallel`` splits the ROI labels between threads and calls it once per slice. + +- In GPU builds, optionally a ``gpu_process_all_rois`` function (:ref:`devguide-gpu`). + +Where the files are +^^^^^^^^^^^^^^^^^^^ + +Feature classes live in ``features/``. 3D classes are in files prefixed ``3d_`` and are named +``D3_*``. Some families keep their out-of-core implementation in a separate ``*_nontriv.cpp`` +file. Image-quality classes are ``focus_score``, ``power_spectrum``, ``saturation`` and +``sharpness``. + + +.. _devguide-registration: + +Registration and the feature manager +------------------------------------ + +``FeatureManager`` (``feature_mgr.h``) owns one instance of every feature class. + +- The constructor, in ``feature_mgr_init.cpp``, calls ``register_feature (new X())`` for every + class: 2D first, then 3D, then image quality. +- ``compile()`` runs two checks. ``check_11_correspondence`` requires every 2D and 3D code to be + provided by exactly one registered class; image-quality codes are not checked yet. + ``gather_dependencies`` follows each class's dependencies through their providers and rejects a + cycle. +- ``apply_user_selection (FeatureSet&)`` builds the ordered list of classes the run needs: for + each enabled code, the providers of its direct dependencies, then its own provider, each once. + Only direct dependencies are added, so if your dependency has dependencies of its own, list them + too. +- ``init_feature_classes()`` runs one-time class initialisation. Today only + ``GaborFeature::init_class`` uses it, and only the command-line entry point calls it. + +The ordered list is what the out-of-core loops in ``phase3.cpp`` and ``phase3_3d.cpp`` iterate over. +The in-RAM paths do not use it: they call the classes explicitly in ``reduce_trivial_rois.cpp``. + + +.. _devguide-dispatch: + +Dispatch paths +-------------- + +Which path computes an ROI depends on the run type and on the ROI's memory footprint. +``LR::get_ram_footprint_estimate`` estimates the footprint, and the workflow compares it with +``Environment::get_ram_limit()`` (set by ``--ramLimit`` or ``ram_limit``; by default half the memory +available at start-up). An ROI whose estimate reaches the limit is oversized. + +.. list-table:: + :header-rows: 1 + :widths: 18 32 50 + + * - Path + - Entry point + - What it does + * - In-RAM (trivial), segmented + - ``reduce_trivial_rois_manual`` in ``reduce_trivial_rois.cpp``, called from ``phase2_2d.cpp``, + ``phase2_25d.cpp`` and ``phase2_3d.cpp`` + - Calls ``reduce_trivial_2d`` or ``reduce_trivial_3d``. Each feature block checks + ``X::required`` and runs the class's batch function on all pending ROIs through + ``runParallel``, with ``--reduceThreads`` threads. The order of the blocks is the + execution order. + * - Cross-ROI + - ``reduce_neighbors_and_dependencies_manual`` in ``reduce_trivial_rois.cpp`` + - Runs once per image, after the in-RAM ROIs and before the out-of-core ones: neighbours, then + hexagonality and polygonality, then + the enclosing, inscribing and circumscribing circles. Single-threaded. + * - Out-of-core (oversized) + - ``processNontrivialRois`` in ``phase3.cpp`` (2D), ``processNontrivialRois_3D`` and + ``run_3d_ooc_features`` in ``phase3_3d.cpp`` (3D) + - Streams the ROI's pixels into ``LR::raw_pixels_NT`` (2D) or ``raw_voxels_NT`` (3D). Then, + for each class in the feature manager's list, calls ``osized_scan_whole_image`` with the + class's settings and then ``cleanup_instance``. One instance serves all oversized ROIs. In + 3D, only classes accepted by ``is_3d_ooc_supported`` run; any other raises an error. + * - Whole-slide 2D + - ``reduce_trivial_wholeslide`` in ``reduce_trivial_rois.cpp``, called from + ``workflow_2d_whole.cpp`` + - Treats the whole image as one ROI and calls ``X::extract`` on it, class by class + (``reduce_trivial_2d_wholeslide``). Neighbour features are not computed. A slide that + exceeds the RAM limit is refused. + * - Whole-volume 3D + - ``reduce_trivial_3d_wholevolume``, called from ``workflow_3d_whole.cpp`` + - The 3D counterpart of whole-slide. An oversized volume goes through ``run_3d_ooc_features``. + * - GPU + - The ``#ifdef USE_GPU`` section of ``reduce_trivial_2d`` + - When the build has CUDA and the run has the GPU enabled, uploads ROIs in batches of + ``gpu_batch_len`` with ``GpusideCache::send_roi_data_gpuside`` and calls + ``gpu_process_all_rois`` for erosion, intensity moments, shape moments and Gabor. Otherwise + those four run on the CPU like any other block. See :ref:`devguide-gpu`. + +The ``*WHOLESLIDE*`` group (``Fgroup2D::FG2_WHOLESLIDE``) selects the families that make sense on a +whole slide: contour, intensity, the texture families except GLDZM, Gabor, intensity moments, +radial distribution and Zernike. + +.. _devguide-ooc-rule: + +The out-of-core rule +^^^^^^^^^^^^^^^^^^^^ + +**Out-of-core values must equal in-RAM values.** The same ROI must give the same feature row +whether it is computed in memory or streamed, apart from floating-point summation order. Users +cannot tell which path computed a row, and the oracle tests vet only the in-RAM path, so the +out-of-core path is correct only by being equal to it. + +The rule is enforced by invariant tests that featurize the same input twice, once with a large RAM +limit and once with ``ram_limit=0`` or ``1``, and compare every column: +``tests/python/test_2d_ooc_invariant.py``, ``test_imq_ooc_invariant.py`` and the out-of-core cases +in ``test_ooc_mechanics.py`` and ``tests/test_all.cc``. + +The ways to meet it, in order of preference: + +#. Delegate. Rebuild the in-RAM data from the cloud with ``LR::rebuild_raw_pixels_from_cloud`` or + ``LR::rebuild_aux_image_matrix_from_cloud`` and call ``calculate``. Most 2D families do this. +#. Stream with the same arithmetic as ``calculate``, when the ROI cannot be materialised. The 3D + texture families stream over a window of planes. +#. Apply the same guards. A constant ROI, an empty ROI and a too-small ROI must take the same + branch on both paths, and every member must be reset, because the out-of-core instance is + reused. + +Where a feature does not meet the rule yet, its definition page states the difference, and a +regression test pins it. + + +.. _devguide-settings: + +Settings and metaparameters +--------------------------- + +Fsettings +^^^^^^^^^ + +``feature_settings.h`` defines ``Fsettings`` as ``std::vector``, where +``FeatureSetting`` is a union of ``bool bval``, ``int ival`` and ``double rval``. The vector is +indexed by ``enum class NyxSetting``: + +- common slots: ``SOFTNAN``, ``TINY``, ``SINGLEROI``, ``GREYDEPTH``, ``PIXELSIZEUM``, + ``PIXELDISTANCE``, ``XYRES``, ``USEGPU``, ``VERBOSLVL``, ``IBSI``; +- family slots: ``GLCM_GREYDEPTH``, ``GLCM_OFFSET``, ``GLCM_NUMANG``, ``GLCM_SPARSEINTENS``, + ``GLDM_GREYDEPTH``, ``NGTDM_GREYDEPTH``, ``NGTDM_RADIUS``, ``GLRLM_GREYDEPTH``, + ``GLSZM_GREYDEPTH``; +- floating-point image slots: ``FPIMG_TARGET_DR``, ``FPIMG_MIN``, ``FPIMG_MAX``, ``FPIMG_ACTIVE``. + +Features read the slots through macros, such as ``STNGS_NAN(s)``, ``STNGS_NGREYS(s)``, +``STNGS_IBSI(s)``, ``STNGS_USEGPU(s)`` and ``STNGS_GLCM_OFFSET(s)``. A feature never reads +``Environment`` directly. + +``Environment::compile_feature_settings`` (``env_features.cpp``) registers one vector per feature +class, maps the class's ``typeid`` to it, and fills every vector the same way: + +- the common slots from the run's options (``XYRES`` is left at 0); +- ``GLCM_OFFSET = 1``, ``GLCM_GREYDEPTH`` = the coarse grey depth, ``GLCM_NUMANG`` = the number of + GLCM angles, ``NGTDM_RADIUS = 1``; +- the ``FPIMG_*`` slots from the floating-point image options. + +The other family slots start at 0. ``Environment::get_feature_settings (typeid)`` returns a class's +vector and throws if the class has none. + +Metaparameters +^^^^^^^^^^^^^^ + +A metaparameter changes one family slot of one class's settings after initialisation. They are set +from Python with ``set_metaparam ("/=")`` and read with +``get_metaparam ("/")``. Both call ``Environment::set_metaparam`` and +``get_metaparam`` in ``env_metaparams.cpp``. There is no command-line form. + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Key + - Slot written + * - ``glcm/greydepth``, ``glcm/offset`` + - ``GLCM_GREYDEPTH``, ``GLCM_OFFSET`` of ``fsett_GLCM`` + * - ``3glcm/greydepth``, ``3glcm/offset``, ``3glcm/numang``, ``3glcm/sparseintensities`` + - the ``GLCM_*`` slots of ``fsett_D3_GLCM`` + * - ``3gldm/greydepth`` + - ``GLDM_GREYDEPTH`` of ``fsett_D3_GLDM`` + * - ``3ngtdm/greydepth``, ``3ngtdm/radius`` + - ``NGTDM_GREYDEPTH``, ``NGTDM_RADIUS`` of ``fsett_D3_NGTDM`` + * - ``3glrlm/greydepth`` + - ``GLRLM_GREYDEPTH`` of ``fsett_D3_GLRLM`` + * - ``3glszm/greydepth`` + - ``GLSZM_GREYDEPTH`` of ``fsett_D3_GLSZM`` + +Each function returns an error message for an unknown family, an unknown parameter or a value it +cannot parse, and Python raises it. ``set_metaparam`` accepts a key without ``/`` and ignores it; +``get_metaparam`` of such a key raises nothing and returns an undefined value. The two functions +are not symmetric: ``get_metaparam`` reads the ``3glcm/*`` keys from ``fsett_GLCM`` (the 2D vector), +a known defect (`#513 `__), and knows the GLSZM key only as ``3glsz/greydepth``. ``glcm/greydepth`` set from Python is stored in +the 2D GLCM settings and is read only by the constant-ROI test: the 2D matrix is binned with the +common ``GREYDEPTH`` slot, so the metaparameter changes no value of a ROI whose binned minimum and +maximum differ. The ``set_metaparam`` and ``get_metaparam`` docstrings in ``nyxus.py`` describe the +same behaviour. + +Adding a setting +^^^^^^^^^^^^^^^^ + +#. Add a slot to ``NyxSetting`` before ``__COUNT__`` and a ``STNGS_*`` macro for it in + ``feature_settings.h``. +#. Give it a default in the loop at the end of ``compile_feature_settings``, if 0 is not the + right default. +#. Expose it: as a metaparameter, with a branch in both ``set_metaparam`` and ``get_metaparam``, or + as an option (:ref:`devguide-cli`, :ref:`devguide-python`) whose value the loop copies in. +#. Read it in the feature only through the macro. +#. Add a mechanics test that the setting reaches the feature, and document it in the family page's + Settings section. + + +.. _devguide-groups: + +Feature groups +-------------- + +A group is a name like ``*ALL_GLCM*`` that enables a set of codes. Asterisks are part of the name. + +How groups are expanded +^^^^^^^^^^^^^^^^^^^^^^^ + +- ``Environment::spellcheck_raw_featurelist`` checks every requested name against the feature and + group maps for the run's dimension, and switches on image-quality mode when an IMQ name is found. +- ``Environment::expand_featuregroups`` starts with every feature disabled and processes the names + in order. A group name goes to ``expand_2D_featuregroup``, ``expand_3D_featuregroup`` or + ``expand_IMQ_featuregroup``; anything else is looked up as a single feature and enabled with + ``FeatureSet::enableFeature``. A ``-`` prefix disables instead of enabling, so + ``*ALL*,-GABOR`` works. ``-COV`` and ``-*ALL*`` enable instead, because their codes are 0, and + an image-quality name enables its feature whatever its prefix. +- Each expander branch calls ``theFeatureSet.enableFeatures (X::featureset, enable)`` for the + classes in the group. ``*ALL*`` enables every ``Feature2D`` code by range; ``*ALL_IMQ*`` enables + every image-quality code. +- After the loop, if IBSI mode is off, the ``IH_*`` codes are disabled again. They are available + only with ``--ibsi``. + +Adding a group +^^^^^^^^^^^^^^ + +#. Add an enumerator to ``Fgroup2D`` or ``Fgroup3D`` in ``featureset.h``, before ``_COUNT_``. +#. Add its name to ``UserFacing2dFeaturegroupNames`` or ``UserFacing3dFeaturegroupNames`` in + ``featureset.cpp``. +#. Add a branch to the expander, in the same form as the others, because the documentation + generator parses it: + + .. code-block:: c++ + + if ((Fgroup2D)fgcode == Fgroup2D::FG2_MYGROUP) + { + theFeatureSet.enableFeatures (MyFeature::featureset, enable); + theFeatureSet.enableFeatures (OtherFeature::featureset, enable); + return true; + } + + A group name that is in the map but has no branch passes the name check and then fails the + run. +#. Run ``python docs/tools/gen_feature_reference.py`` so ``featuregroups.rst`` lists it. +#. Update the hand-written group lists: the docstrings of ``Nyxus`` and ``Nyxus3D`` in + ``src/nyx/python/nyxus/nyxus.py`` and the ``features`` enum in ``plugin.json``. +#. Add a mechanics test that the group enables exactly the intended codes. + + +.. _devguide-lr: + +The ROI record: LR and BasicLR +------------------------------ + +Each ROI has one ``LR`` record (``roi_cache.h``), derived from ``BasicLR`` (``roi_cache_basic.h``). +The workflow fills the scan fields before any feature runs; features then read them and write +``fvals``. These are the fields a feature uses: + +.. list-table:: + :header-rows: 1 + :widths: 34 66 + + * - Field + - Contents + * - ``int label`` + - The ROI's label in the mask. ``BasicLR``. + * - ``int slide_idx`` + - Index of the ROI's image in ``Dataset::dataset_props``; -1 when there is none. ``BasicLR``. + * - ``AABB aabb`` + - Bounding box in pixels, as acquired. In the geometric pass of an anisotropic 2D run it is + the box of the resampled ROI. ``BasicLR``. + * - ``AABB ph_aabb`` + - Bounding box as scanned, before anisotropy. ``BasicLR``. + * - ``unsigned int aux_area`` + - Number of ROI pixels (voxels in 3D). + * - ``PixIntens aux_min, aux_max`` + - Smallest and largest grey level in the ROI. ``aux_min == aux_max`` identifies a constant + ROI. + * - ``std::vector raw_pixels`` + - In-RAM 2D: every ROI pixel with its image coordinates and grey level. + * - ``std::vector raw_pixels_3D`` + - In-RAM 3D: every ROI voxel. + * - ``std::unordered_map> zplanes`` + - In-RAM 3D: indices into ``raw_pixels_3D`` by z. + * - ``OutOfRamPixelCloud raw_pixels_NT`` + - Out-of-core 2D: the disk-backed pixel cloud. + * - ``OutOfRamVoxelCloud raw_voxels_NT`` + - Out-of-core 3D: the disk-backed voxel cloud, with z. + * - ``ImageMatrix aux_image_matrix`` + - In-RAM 2D: the ROI's bounding-box image, built before the features run. + * - ``SimpleCube aux_image_cube`` + - In-RAM 3D: the ROI's bounding-box volume. + * - ``std::vector> multicontour_`` + - The ROI's contours, written by ``ContourFeature``. ``merge_multicontour`` flattens them. + Depend on ``PERIMETER`` to have it. + * - ``std::vector convHull_CH`` + - The convex hull, written by ``ConvexHullFeature``. Depend on ``CONVEX_HULL_AREA`` to have it. + * - ``std::vector aux_neighboring_labels`` + - Labels of neighbouring ROIs, written by ``NeighborsFeature``. + * - ``std::vector> fvals`` + - Output: one vector per feature code, ``FeatureIMQ::_COUNT_`` vectors in all, each + initialised to ``{0.0}``. ``save_value`` writes here. + * - ``bool blacklisted`` + - The ROI was excluded with ``--skiproi`` or ``blacklist_roi``. + +Helper methods: ``rebuild_raw_pixels_from_cloud`` and ``rebuild_aux_image_matrix_from_cloud`` +materialise an oversized ROI's data for an out-of-core path that delegates to ``calculate``. +``get_fvals (code)`` returns a code's values. ``has_bad_data`` always returns ``false``; use +``aux_min == aux_max`` to detect a constant ROI. + +``NestedLR``, also derived from ``BasicLR``, holds the parent-child relations for nested ROIs. It +does not hold feature values. + + +.. _devguide-build: + +The build +--------- + +``CMakeLists.txt`` builds everything from one source list: + +- ``set(SOURCE ...)`` lists the ``.cpp`` files shared by every target. A new source file must be + added here. Headers are not listed. +- ``BUILD_CLI`` builds the ``nyxus`` executable from ``${SOURCE}`` plus ``main_nyxus.cpp``. +- ``BUILD_LIB`` builds the Python module ``backend`` with ``pybind11_add_module`` from + ``${SOURCE}`` plus ``python/new_bindings_py.cpp``, ``python/nested_roi_py.cpp``, + ``image_loader1x.cpp`` and ``nested_roi.cpp``. +- ``USEGPU`` defines ``USE_GPU`` and builds the ``nyxus_gpu`` library from ``GPU_SOURCE_FILES``. +- ``RUN_GTEST`` downloads GoogleTest and builds ``runAllTests`` from ``tests/CMakeLists.txt``, + which reuses ``${SOURCE}``. +- ``RUN_BENCHMARKS`` builds the benchmark executable ``bench_convex_hull``. +- I/O backends: ``USE_Z5`` (OME-Zarr), ``USE_DCMTK`` (DICOM), ``USE_ARROW`` (Arrow and Parquet), + or ``ALLEXTRAS`` for all three. ``NOEXTRAS`` is on by default and switches the three off, so a + single backend also needs ``-DNOEXTRAS=OFF``; ``ALLEXTRAS`` overrides it. + +The project is C++20. ``pip install .`` builds the Python module through ``setup.py``, which passes +``-DBUILD_LIB=ON -DALLEXTRAS=ON``; extra CMake arguments go in the ``CMAKE_ARGS`` environment +variable. + + +.. _devguide-cli: + +Command-line options +-------------------- + +An option touches four places in the code and one in the docs. + +- ``cli_option_constants.h`` defines each option as ``#define clo_ "--option"``. Options + that exist only in some builds are inside ``#ifdef`` blocks, such as ``USE_GPU``. +- ``Environment`` (``environment.h``) holds the raw string of each option, either as its own member + or inside an option object (``gaborOptions``, ``glcmOptions``, ``nestedOptions``, + ``fpimageOptions``, ``anisoOptions``, ``resultOptions``, ``gpuOptions``). The option objects are + in the ``cli_*_options.h/.cpp`` files. +- ``Environment::parse_cmdline`` (``environment.cpp``) matches each argument with + ``find_string_argument``, which accepts ``--opt=value`` and ``--opt value``. An argument that + matches no option is an error. After the loop it checks required options and converts and + validates the values. +- ``Environment::show_cmdline_help`` prints the usage screen. +- ``docs/cli_options.toml`` describes every option for the command-line reference page. + +To add an option: + +#. Define ``clo_MYOPTION`` in ``cli_option_constants.h``. +#. Add a raw string member to ``Environment``, or a field to the matching ``cli_*_options`` class. +#. Add ``|| find_string_argument (i, clo_MYOPTION, member)`` to the chain in ``parse_cmdline``. +#. Validate and convert it after the loop, or in the option class's ``parse_input``. +#. Add it to ``show_cmdline_help``. +#. Add an ``[[option]]`` entry to ``docs/cli_options.toml`` with the keys ``name``, ``group``, + ``required``, ``default``, ``default_at`` (``"file:target"``, the variable or call that is + assigned a non-empty default, for example ``"environment.cpp:n_pixel_distance"``; ``"-"`` opts + out), ``values``, ``build``, ``summary``, ``notes`` and ``evidence``. + ``evidence`` names the source lines that apply the default. +#. Run ``python docs/tools/gen_cli_reference.py``. It rewrites ``docs/source/cli_reference.rst``, + and its ``--check`` mode fails in CI if an option defined in the header has no TOML entry, or the + other way round. +#. If Python users need it too, follow :ref:`devguide-python`. + + +.. _devguide-python: + +Python bindings +--------------- + +``python/new_bindings_py.cpp`` defines the pybind11 module ``backend`` (``PYBIND11_MODULE(backend, +m)``). Its functions all take ``instid``, the Python object's ``id``, and look up a separate +``Environment`` per object, so several ``Nyxus`` objects can coexist. The main functions are: + +- ``initialize_environment``: applies the constructor arguments, expands the feature groups, + compiles the feature manager and the feature settings; +- ``set_environment_params_imp`` and ``get_params_imp``: back ``set_params`` and ``get_params``; +- ``set_metaparam_imp`` and ``get_metaparam_imp``: back the metaparameters; +- ``featurize_directory_imp``, ``featurize_montage_imp`` (in-memory arrays, through + ``workflow_pythonapi.cpp``), ``featurize_fname_lists_imp`` and their 3D variants. + +``src/nyx/python/nyxus/nyxus.py`` wraps them in the classes ``Nyxus``, ``Nyxus3D``, +``ImageQuality`` and ``Nested``, which ``nyxus/__init__.py`` exports. + +To expose a new parameter: + +#. Add an argument to ``initialize_environment`` at the end, with a default, and apply it to the + ``Environment``. +#. If it can change after construction, add it to ``set_environment_params_imp`` with a sentinel + value meaning "unchanged". +#. Report it in ``get_params_imp``. +#. In ``nyxus.py``, add the keyword to the constructor's accepted keys, read and validate it, and pass + it to ``initialize_environment``. For ``set_params``, add it to the accepted parameter list. +#. Repeat for ``Nyxus3D`` and ``ImageQuality`` where it applies. +#. Document it in the class docstring; the API reference is generated from the docstrings. + + +.. _devguide-gpu: + +GPU kernels +----------- + +CUDA code is in ``src/nyx/gpu`` and is built only with ``-DUSEGPU=ON``, which defines ``USE_GPU``. +At run time the GPU is used when the user asks for it (``--useGpu=true`` and ``--gpuDeviceID`` on +the command line, ``use_gpu_device`` in Python). All CUDA-dependent C++ code is inside ``#ifdef USE_GPU``. + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Files + - Contents + * - ``gpu.h``, ``gpu_helpers.cu``, ``helper_cuda.h`` + - Error-checking macros (``CHECKERR``, ``OK``), which throw in the Python module and print + elsewhere; ``NyxusGpu::gpu_initialize``, which selects the device. + * - ``cache.cu``, ``gpucache.cu`` + - Device memory helpers of ``GpusideCache`` (``cache.h``), which holds the uploaded ROI clouds, + contours and intermediate buffers. The cache is allocated per dataset in + ``workflow_2d_segmented.cpp``. + * - ``erosion.cu`` + - ``EROSIONS_2_VANISH``. + * - ``geomoments_*.cu``, ``geomoments.cuh`` + - Shape and intensity geometric moments: raw, central, normalized, Hu, weighted. + * - ``gabor.cu``, ``gabor.cuh`` + - Gabor filtering with cuFFT. + * - ``glszm.cu`` + - The GLSZM sums, called from ``GLSZMFeature::calculate`` when ``STNGS_USEGPU(s)`` is set. + * - ``reducers.cu`` + - Shared reductions. + +Erosion, the two moment families and Gabor go through the batch path in ``reduce_trivial_2d``: +each has a static ``gpu_process_all_rois`` that reads the uploaded batch and writes ``fvals``. +GLSZM uses the GPU inside its ordinary ``calculate``. Every other feature runs on the CPU in GPU +builds too. + +To add GPU support to a feature, add the ``.cu`` file to ``GPU_SOURCE_FILES`` in ``CMakeLists.txt``, +keep the CPU path, and add a test that the GPU and CPU values agree. The GPU path is held to the +same values as the CPU path. + + +.. _devguide-testing: + +Testing +------- + +C++ tests (GoogleTest) +^^^^^^^^^^^^^^^^^^^^^^ + +The C++ tests build into one executable, ``runAllTests``: .. code-block:: bash - nyxus --features=AREA_PIXELS_COUNT,AREA_UM2,CENTROID_X,CENTROID_Y,BBOX_YMIN,BBOX_XMIN,BBOX_HEIGHT,BBOX_WIDTH --intDir=/home/ec2-user/work/datasetXYZ/int --segDir=/home/ec2-user/work/dataXYZ/seg --outDir=/home/ec2-user/work/datasetXYZ --filePattern=.* --outputType=separatecsv + cmake -B build-cli -DCMAKE_BUILD_TYPE=Release -DBUILD_CLI=ON -DRUN_GTEST=ON -DALLEXTRAS=ON + cmake --build build-cli --config Release --parallel 2 + ./build-cli/tests/runAllTests # build-cli\tests\Release\runAllTests.exe with a Visual Studio generator + +- ``tests/test_all.cc`` holds every ``TEST`` case and the gtest ``main``. It includes every + ``tests/test_*.h`` header (``test_2d_gabor_skimage.cc`` is the one test source compiled + separately) and registers each test function with one ``TEST`` case in the suite + ``TEST_NYXUS``: + + .. code-block:: c++ + + #include "test_2d_myfamily_analytic.h" + ... + TEST(TEST_NYXUS, TEST_2D_MYFAMILY_DISK_ANALYTIC) { ASSERT_NO_THROW(test_2d_myfamily_disk_analytic()); } + + The case name is the function name in upper case, and each case calls one test function. +- Headers are not listed in ``tests/CMakeLists.txt``; only ``.cc`` files are. +- Image fixtures are in ``tests/data``. +Python tests (pytest) +^^^^^^^^^^^^^^^^^^^^^ -Features can be grouped toegther and given convenient aliases, for example the above features AREA_PIXELS_COUNT, AREA_UM2, CENTROID_X, CENTROID_Y, BBOX_YMIN, BBOX_XMIN, BBOX_HEIGHT, and BBOX_WIDTH can be referred to as \*BASIC_MORPHOLOGY\* . (Asterisks are a part of the alias and aren't special symbols.) The command line then becomes simpler +Python tests are in ``tests/python`` and need an installed or importable ``nyxus`` module. Run the +whole directory, as CI does: .. code-block:: bash - nyxus --features=\ *BASIC_MORPHOLOGY* AREA_PIXELS_COUNT,AREA_UM2,CENTROID_X,CENTROID_Y,BBOX_YMIN,BBOX_XMIN,BBOX_HEIGHT,BBOX_WIDTH*\ * --intDir=/home/ec2-user/work/datasetXYZ/int --segDir=/home/ec2-user/work/dataXYZ/seg --outDir=/home/ec2-user/work/datasetXYZ --filePattern=.* --outputType=separatecsv + python -m pytest tests/python/ -vv -Step 1 - giving an alias to a multiple features -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Given the features that you need to group together are already implemented, to create a feature group define its user-facing identifier in file environment.h, for example create alias MY_FEATURE_GROUP for features MYF1, MYF2, and MYF3 +Running single files can hide failures caused by state that one test leaves for another. +``pytest.ini`` defines the markers ``arrow`` (needs an Arrow build), ``serial`` and ``skip_ci``. -.. code-block:: c++ +The vetting system +^^^^^^^^^^^^^^^^^^ - define MY_FEATURE_GROUP "MYFEATURES" +Nyxus distinguishes a test that shows a value is correct from a test that only shows it did not +change. The rules are in ``tests/vetting/SPEC.md``, and the checks below enforce them. +Every test belongs to one of four kinds, kept in separate files: -Step 2 - reflect the new group in the command line help -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Make sure that the new feature group's alias is visible in the command line help. -Then handle the command line input in file environment.cpp, method Environment::process_feature_list() +.. list-table:: + :header-rows: 1 + :widths: 16 44 20 20 + + * - Kind + - Question it answers + - Claims correctness + - File suffix + * - Oracle + - Does Nyxus match an external tool or a closed form? + - Yes + - ``_`` + * - Regression + - Did Nyxus's output change? + - No + - ``_regression`` + * - Invariant + - Does the output obey a required property or relation, such as out-of-core = in-RAM? + - Partly + - ``_invariant`` + * - Mechanics + - Does the plumbing work: gating, defaults, I/O, refusals? + - No + - ``_mechanics`` + +The oracle tokens are ``pyradiomics``, ``radiomicsj``, ``skimage``, ``mirp``, ``matlab``, +``cellprofiler``, ``mitk``, ``feature2djava``, ``wndcharm``, ``imea``, ``imagej``, ``fraclac``, +``pydicom``, ``opencv``, ``ibsi`` and ``analytic`` (a closed form). Only oracle tests establish +that a feature is vetted, and only for the configuration they run. + +**Naming.** + +- Files: ``test___.{h,py}``, for example ``test_2d_glcm_ibsi.h``. + ```` is ``2d`` or ``3d``. Image-quality files use ``imq`` and no dimension, and files with + no image dimension (writers, environment) use none. +- Functions: ``test__[_]_``. The function's dimension and + kind must match its file's. +- gtest cases: ``TEST(TEST_NYXUS, )``. +- Helpers: ``assert_*``, taking arguments. A helper called directly by a ``TEST`` is that case's + test function and takes the ``test_`` name. +- Reference tables: ``___ref_vals``, declared ``const`` through the + ``ref_vals_map`` alias, in the file whose assertions read them. ``_common.h`` headers hold + fixtures, never reference values. A family's headers are included only from that family (and + from ``test_all.cc``). +- Fixtures: ``bench__`` for new inline fixtures (standard phantoms keep + their names), each listed in ``tests/vetting/benchmarks.md``. +- Every pinned oracle value records the tool, its version, the exact configuration and the + generator script, ``tests/vetting/oracles/gen__.*``. + +**The registry.** ``tests/vetting/oracle_coverage.csv`` has one row per assertion, that is per +(feature, configuration, reference). Its columns are: + +``dim, feature, family, status, oracle, agreement, config_recipe, tolerance, current_test, +target_test, candidate_oracle, flag, source, notes, test_name, benchmark`` + +- ``status`` is ``vetted``, ``regression``, ``invariant`` or ``untested``. A ``vetted`` row names + an ``oracle``; other rows leave it empty. +- ``config_recipe`` names a ``## `` heading in ``tests/vetting/config_recipes.md``: the Nyxus + settings and the reference tool's settings that make the two comparable. +- ``tolerance`` is ``exact``, ``abs=`` or ``rel=`` (see SPEC section 7 for when each applies). +- ``test_name`` is the gtest case as ``TEST_NYXUS.``; ``benchmark`` names a fixture in + ``benchmarks.md``. +- ``source`` is ``in-tree``, ``tracker`` or ``audit``. + +**Matrices.** ``tests/vetting/matrix/.md`` lists, for a family, the settings that change its +values and, for each combination, whether a reference is valid there and which recipe and test +cover it. + +**Adding a vetted feature** (SPEC section 8): + +#. Pick the reference tool and the config recipe that makes Nyxus comparable; add the recipe if it + is new. +#. Pick or define a benchmark fixture that exercises the feature. +#. Generate the reference values offline with a checked-in generator, and record the tool version + and configuration where the values are defined. +#. Write the assertion in the oracle file, named as above, at the tolerance the SPEC gives. +#. If the same feature is also snapshotted, keep that in the ``_regression`` file. +#. Add the registry rows and regenerate the reports: + + .. code-block:: bash + + python tests/vetting/check_coverage.py --write + python tests/vetting/report_features.py --write + +#. Run the checks: + + .. code-block:: bash + + python tests/vetting/check_coverage.py --check + python tests/vetting/check_test_names.py --check + python tests/vetting/audit/run_scanners.py + python tests/vetting/report_features.py --check + +What the checks do: + +- ``check_coverage.py`` validates the registry's columns and values, resolves every + ``config_recipe``, ``benchmark`` and ``test_name``, and with ``--check`` fails if + ``coverage_report.md`` is not what ``--write`` would produce. +- ``check_test_names.py`` checks file, function, case, helper and table names, file purity and + cross-family includes. Exceptions live in lists in the script, each with its reason. +- ``audit/run_scanners.py`` runs the per-family scanners that find the assertions in the test tree. +- ``report_features.py`` joins each registry row with what the scanners found and, with + ``--check``, fails when ``report_output.csv`` or ``report_output.md`` is stale, and on any row + whose verdict is not ``agree`` or ``unscanned`` and is not in its ``ALLOWLIST``: a vetted row + with no oracle assertion, a row that claims no oracle while an oracle test asserts the feature, + a different oracle, the wrong kind of test, or a ``test_name`` that does not assert the feature. + It also fails on an ``unscanned`` family that is not recorded in ``KNOWN_UNSCANNED`` (empty, so + every family must be scanned), and on two rows that share the key dim, feature, oracle and + recipe. + +``tests/python/test_vetting_mechanics.py`` runs ``check_coverage.py`` and ``check_test_names.py`` +against planted defects to show they report them, and runs the name checker on the tree. + +Continuous integration +^^^^^^^^^^^^^^^^^^^^^^ + +These workflows in ``.github/workflows`` run on pull requests (the publish workflows run only on +demand or from a release): -.. code-block:: c++ +.. list-table:: + :header-rows: 1 + :widths: 28 72 + + * - Workflow + - Checks + * - ``build_and_test_ubuntu.yml`` + - Builds with ``-DBUILD_CLI=ON -DRUN_GTEST=ON -DALLEXTRAS=ON`` and runs ``runAllTests`` and + the command-line tests in ``test_cli_mechanics.py``. Installs the Python package and runs + ``pytest tests/python/``, then ``check_coverage.py --check``, + ``check_test_names.py --check``, ``audit/scanlib_selftest.py``, + ``audit/run_scanners.py`` and ``report_features.py --check``. + * - ``build_and_test_mac.yml``, ``build_and_test_windows.yml`` + - ``runAllTests`` and ``pytest tests/python/``. + * - ``asan.yml`` + - Builds ``runAllTests`` in Debug with ``-fsanitize=address,undefined`` and fails on any + AddressSanitizer, LeakSanitizer or UndefinedBehaviorSanitizer report. + * - ``docs.yml`` + - On changes to the docs, the Python package, the feature and option sources or the registry: + both generators with ``--check``, then ``sphinx-build -W --keep-going`` for HTML and for + ``linkcheck``. + * - ``build_wheels.yml``, ``build_cuda11_wheels.yml``, ``build_cuda12_wheels.yml`` + - Build the wheels. The CPU wheels run ``pytest tests/python``; the CUDA wheels run + ``pip check``. + +``.pre-commit-config.yaml`` defines local hooks (file checks, Python and reST lint, codespell); CI +does not run them. + + +.. _devguide-benchmarks: + +Benchmarks +---------- + +``benchmarks/README.md`` describes the benchmark programs and how to run them. They are built with +``-DRUN_BENCHMARKS=ON``: - if (s == MY_FEATURE_GROUP) - { - auto F = {MYF1, MYF2, MYF3}; - theFeatureSet.enableFeatures(F); - continue; - } +.. code-block:: bash + cmake -S . -B build-bench -DRUN_BENCHMARKS=ON -DNOEXTRAS=ON + cmake --build build-bench --target bench_convex_hull -j + ./build-bench/bench_convex_hull -Step 3 - reflect the new group available to plugin users -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -In plugin use cases, don't forget to update the plugin manifest with the information about the new feature group! For example, in WIPP: +To compare a change, build the benchmark from a clean checkout before and after it, and compare the +median times for the same arguments. The benchmark also prints values computed from the result, so +a change that alters the result shows up next to the timing. -.. code-block:: c++ - ... +.. _devguide-docs: - { - "description": "MYFEATURES is a group of my few handy features", - "enum": ["MYFEATURES"] - }, - ... +Documentation +------------- + +The documentation is a Sphinx site in ``docs/source``. It does not need the compiled module: +``conf.py`` mocks ``nyxus.backend``. The pages describe the development head, so their title +carries no version number. + +Building +^^^^^^^^ + +Python 3.11 or later is needed. Run the same steps as CI: + +.. code-block:: bash + + python -m pip install -r docs/requirements.txt + python docs/tools/gen_cli_reference.py --check + python docs/tools/gen_feature_reference.py --check + python -m sphinx -b html -W --keep-going docs/source docs/build/html + +``-W`` turns every warning into an error, and Read the Docs builds with ``fail_on_warning: true`` +too. + +Generated pages +^^^^^^^^^^^^^^^ + +Some pages are written by scripts in ``docs/tools`` and must not be edited by hand. Run a script +without ``--check`` to rewrite its pages; with ``--check`` it fails if a page is out of date. + +.. list-table:: + :header-rows: 1 + :widths: 30 35 35 + + * - Script + - Reads + - Writes + * - ``gen_feature_reference.py`` + - ``featureset.h``, ``featureset.cpp``, ``features/*.h``, ``env_features.cpp`` (through + ``feature_registry.py``), ``docs/feature_descriptions.toml``, + ``docs/feature_extras.toml``, ``tests/vetting/oracle_coverage.csv`` + - ``featurelist.rst``, ``featuregroups.rst``, ``Math/_index.inc`` + * - ``gen_cli_reference.py`` + - ``cli_option_constants.h``, ``docs/cli_options.toml`` + - ``cli_reference.rst`` + +``gen_feature_reference.py`` fails, and writes nothing, when a code has no description, a +description names no code, a code belongs to no family, a family page lacks a code's entry, +``feature_extras.toml`` lists a code that is not a base code of that family, or a file in +``COUNT_PAGES`` does not state the current feature counts ("758 features in all, 539 for 2D +images, ..."). + +Documenting a new feature +^^^^^^^^^^^^^^^^^^^^^^^^^ + +#. Add a one-line description of every code to its family's table in + ``docs/feature_descriptions.toml``. State what the code computes, checked against the source. + Tables are named by the family's slug in ``FAMILIES``: + + .. code-block:: toml + + [my_family] + MY_FEATURE_A = "What MY_FEATURE_A computes, in one sentence." + +#. Add the class to its family's class list in ``FAMILIES`` in ``gen_feature_reference.py``. If it + starts a new family, add a ``FAMILIES`` entry and a note to ``[family_notes]``. +#. If the family has a definition page, add each base code to ``docs/feature_extras.toml`` with its + name, its IBSI identifier (empty if none) and its value in the page's worked example: + + .. code-block:: toml + + [my_family] + MY_FEATURE_A = { name = "My feature A", ibsi = "", example = 1.25 } + +#. Write the definition on the family page, under a ``.. _feature-my-feature-a:`` label. +#. Update the feature-count sentence in ``README.md``, ``docs/source/index.rst`` and the other + files listed in ``COUNT_PAGES`` in ``docs/tools/gen_feature_reference.py``; the generator + checks them. +#. Run ``python docs/tools/gen_feature_reference.py`` and commit the regenerated files. + +Feature pages +^^^^^^^^^^^^^ + +Each family has one page, ``docs/source/Math/f_.rst``, listed in ``Math.rst``. There is no +template file; ``f_erosion.rst`` is the reference example of the structure all pages follow: + +#. Title and a short introduction. +#. A facts table: **Feature codes**, **Group**, **Dimensions**, **GPU**, **Out-of-core**, **IBSI**. +#. *How the features are computed* (*How the matrix is built* for texture families), ending with a + *Worked example* that has a Python tab and a command-line tab. +#. *Notation*, *Settings*, *Output columns*. +#. *Feature index*: a ``.. _-index:`` label and ``.. include:: _index.inc``, the + generated table. +#. *Features*: one subsection per base code with its ``.. _feature-:`` label, ``:Code:`` and + ``:Range:`` fields, the definition, and optional **Relations.** and **Possible issues.** + paragraphs. +#. *Possible issues*, under a ``.. _-possible-issues:`` label, for issues shared by the + family. +#. *Validation* and *References*. + +Describe what the code computes. Where it differs from IBSI or from a reference tool, say so and +give the size of the difference. + +Worked examples +^^^^^^^^^^^^^^^ + +A page's example is a script, ``docs/examples/_worked_example.py``. The page shows the part +between two marker lines: + +.. code-block:: rst + + .. literalinclude:: ../../examples/myfamily_worked_example.py + :language: python + :start-after: # example-start + :end-before: # example-end + +After ``# example-end`` the script asserts every ``example`` value in ``feature_extras.toml``, so the +page cannot drift from the code. It reads those values from a JSON copy of the file, whose path is +in the ``NYXUS_DOCS_EXTRAS`` environment variable. A script that needs files from +``tests/python/data`` finds them through ``NYXUS_DOCS_DATA``. + +``tests/python/test_docs_examples_mechanics.py`` picks up every script in ``docs/examples`` +automatically. It checks that each has both markers and does not import ``tomllib``, which Python +3.10, the oldest supported version, lacks. It runs each script in a clean directory +with ``NYXUS_DOCS_DATA`` set to ``tests/python/data`` and ``NYXUS_DOCS_EXTRAS`` pointing at the +JSON copy, which it writes from ``feature_extras.toml``; the script must exit with 0. It runs in +the normal pytest job, which the wheel builds run on every supported Python version. + + +.. _devguide-release: + +Commits and releases +-------------------- +Commit messages follow `Conventional Commits `_: +``(): ``, for example ``fix(glcm): ...`` or ``docs(erosion): ...``. Releases +are made by `release-please `_ from these messages: + +#. On every push to ``main``, ``.github/workflows/release-please.yml`` runs release-please with + ``release-please-config.json`` and ``.release-please-manifest.json``. It keeps a release pull + request open that bumps the version in the manifest and in ``CITATION.cff``, whose version line + carries the ``x-release-please-version`` marker. ``.version`` is also listed under + ``extra-files`` but has no marker. +#. The commit types decide the bump. Before 1.0, a breaking change bumps the minor version and a + ``feat`` bumps the patch version (``bump-minor-pre-major`` and + ``bump-patch-for-minor-pre-major``). They also decide the release-notes section: ``feat``, + ``fix``, ``perf``, ``revert``, ``docs``, ``refactor`` and ``build`` are listed; ``chore``, + ``style``, ``test`` and ``ci`` are hidden. +#. Merging the release pull request creates the tag and the GitHub release. The workflow then runs + ``python ci-utils/sync_version.py --version ``, which writes the version to + ``.version``, ``plugin.json`` (``version`` and the container tag) and + ``src/nyx/version.h`` (``PROJECT_VER``). It uploads ``.version`` to the release and starts + ``publish_pypi.yml``, ``publish_cuda11_pypi.yml``, ``publish_cuda12_pypi.yml`` and + ``publish_docker.yml`` on the tag. + +The other version consumers: + +- The Python package takes its version from the git tag through ``setuptools_scm`` + (``pyproject.toml``), and ``setup.py`` passes it to the C++ build as ``VERSION_INFO``. +- The wheel workflows run ``sync_version.py`` before building, so ``version.h`` and + ``plugin.json`` in the build carry the tag's version. ``PROJECT_VER`` is printed in the + command-line banner. + +Without ``--version``, ``sync_version.py`` uses the latest tag from ``git describe --tags``. diff --git a/docs/source/feature_reference.rst b/docs/source/feature_reference.rst new file mode 100644 index 000000000..2defc6a7e --- /dev/null +++ b/docs/source/feature_reference.rst @@ -0,0 +1,14 @@ +Feature reference +================= + +The feature codes and groups Nyxus computes, how each feature is defined and validated, and the +conventions they share. + +.. toctree:: + :maxdepth: 2 + + featurelist + featuregroups + validation + conventions + Math diff --git a/docs/source/featuregroups.rst b/docs/source/featuregroups.rst new file mode 100644 index 000000000..e7a510b2f --- /dev/null +++ b/docs/source/featuregroups.rst @@ -0,0 +1,148 @@ +.. This page is generated by docs/tools/gen_feature_reference.py; do not edit it by hand. + +Feature groups +============== + +A group name requests every feature in the group. Group names and feature codes can be +mixed in one request, and are not case-sensitive on the command line: + +.. code-block:: bash + + nyxus --features="*ALL_INTENSITY*,*ALL_GLCM*,ZERNIKE2D" ... + +``*ALL*`` requests every 2D feature, ``*3D_ALL*`` every 3D feature. Image quality features +are requested only through their own codes or ``*ALL_IMQ*``, and not together with other +features. The intensity histogram features (``IH_*``, in ``*ALL*`` and ``*ALL_IH*``) are +computed only in :doc:`IBSI mode `; without it they are left out of the output. + +A group or feature name prefixed with ``-`` is removed from the request (except ``-*ALL*`` +and ``-COV``, which add, and image quality feature codes, where ``-`` is ignored and the +feature is added; ``-*ALL_IMQ*`` stops with an error), and names are applied from left to +right; see +:ref:`requesting-feature-groups`. ``HISTOGRAM`` is in ``*ALL*`` but in no other group, not +even ``*ALL_INTENSITY*``. + +2D groups +--------- + +.. list-table:: + :header-rows: 1 + :widths: 26 10 64 + + * - Group + - Features + - Families + * - ``*ALL*`` + - 539 + - :ref:`Intensity `, :ref:`Basic morphology `, :ref:`Ellipse fitting `, :ref:`Contour `, :ref:`Convex hull `, :ref:`Erosion `, :ref:`Fractal dimension `, :ref:`Feret diameters `, :ref:`Martin diameters `, :ref:`Nassenstein diameters `, :ref:`Chords `, :ref:`Euler number `, :ref:`Extrema `, :ref:`Inscribing and circumscribing circles `, :ref:`Geodetic length and thickness `, :ref:`ROI radius `, :ref:`Neighbors `, :ref:`Grey-level co-occurrence matrix (GLCM) `, :ref:`Grey-level run-length matrix (GLRLM) `, :ref:`Grey-level distance-zone matrix (GLDZM) `, :ref:`Grey-level size-zone matrix (GLSZM) `, :ref:`Grey-level dependence matrix (GLDM) `, :ref:`Neighbouring grey-level dependence matrix (NGLDM) `, :ref:`Neighbourhood grey-tone difference matrix (NGTDM) `, :ref:`Radial distribution `, :ref:`Gabor `, :ref:`Zernike `, :ref:`Shape moments `, :ref:`Intensity moments `, :ref:`IBSI intensity histogram ` + * - ``*WHOLESLIDE*`` + - 283 + - :ref:`Intensity `, :ref:`Contour `, :ref:`Grey-level co-occurrence matrix (GLCM) `, :ref:`Grey-level run-length matrix (GLRLM) `, :ref:`Grey-level size-zone matrix (GLSZM) `, :ref:`Grey-level dependence matrix (GLDM) `, :ref:`Neighbouring grey-level dependence matrix (NGLDM) `, :ref:`Neighbourhood grey-tone difference matrix (NGTDM) `, :ref:`Radial distribution `, :ref:`Gabor `, :ref:`Zernike `, :ref:`Intensity moments ` + * - ``*ALL_INTENSITY*`` + - 36 + - :ref:`Intensity ` + * - ``*ALL_MORPHOLOGY*`` + - 108 + - :ref:`Basic morphology `, :ref:`Ellipse fitting `, :ref:`Contour `, :ref:`Convex hull `, :ref:`Erosion `, :ref:`Fractal dimension `, :ref:`Feret diameters `, :ref:`Martin diameters `, :ref:`Nassenstein diameters `, :ref:`Chords `, :ref:`Euler number `, :ref:`Extrema `, :ref:`Inscribing and circumscribing circles `, :ref:`Geodetic length and thickness `, :ref:`ROI radius `, :ref:`Neighbors ` + * - ``*BASIC_MORPHOLOGY*`` + - 15 + - :ref:`Basic morphology ` + * - ``*ALL_GLCM*`` + - 59 + - :ref:`Grey-level co-occurrence matrix (GLCM) ` + * - ``*ALL_GLRLM*`` + - 32 + - :ref:`Grey-level run-length matrix (GLRLM) ` + * - ``*ALL_GLDZM*`` + - 18 + - :ref:`Grey-level distance-zone matrix (GLDZM) ` + * - ``*ALL_GLSZM*`` + - 16 + - :ref:`Grey-level size-zone matrix (GLSZM) ` + * - ``*ALL_GLDM*`` + - 14 + - :ref:`Grey-level dependence matrix (GLDM) ` + * - ``*ALL_NGLDM*`` + - 19 + - :ref:`Neighbouring grey-level dependence matrix (NGLDM) ` + * - ``*ALL_NGTDM*`` + - 5 + - :ref:`Neighbourhood grey-tone difference matrix (NGTDM) ` + * - ``*ALL_NEIGHBOR*`` + - 9 + - :ref:`Neighbors ` + * - ``*ALL_IH*`` + - 46 + - :ref:`IBSI intensity histogram ` + * - ``*GEOMOMS*`` + - 180 + - :ref:`Shape moments `, :ref:`Intensity moments ` + * - ``*IGEOMOMS*`` + - 90 + - :ref:`Intensity moments ` + * - ``*SGEOMOMS*`` + - 90 + - :ref:`Shape moments ` + +3D groups +--------- + +.. list-table:: + :header-rows: 1 + :widths: 26 10 64 + + * - Group + - Features + - Families + * - ``*3D_ALL*`` + - 213 + - :ref:`Intensity (3D) `, :ref:`Shape (3D) `, :ref:`GLCM (3D) `, :ref:`GLDM (3D) `, :ref:`GLDZM (3D) `, :ref:`NGLDM (3D) `, :ref:`NGTDM (3D) `, :ref:`GLSZM (3D) `, :ref:`GLRLM (3D) ` + * - ``*3D_ALL_INTENSITY*`` + - 36 + - :ref:`Intensity (3D) ` + * - ``*3D_ALL_MORPHOLOGY*`` + - 14 + - :ref:`Shape (3D) ` + * - ``*3D_ALL_TEXTURE*`` + - 163 + - :ref:`GLCM (3D) `, :ref:`GLDM (3D) `, :ref:`GLDZM (3D) `, :ref:`NGLDM (3D) `, :ref:`NGTDM (3D) `, :ref:`GLSZM (3D) `, :ref:`GLRLM (3D) ` + * - ``*3D_GLCM*`` + - 59 + - :ref:`GLCM (3D) ` + * - ``*3D_GLDM*`` + - 14 + - :ref:`GLDM (3D) ` + * - ``*3D_GLDZM*`` + - 18 + - :ref:`GLDZM (3D) ` + * - ``*3D_GLSZM*`` + - 16 + - :ref:`GLSZM (3D) ` + * - ``*3D_GLRLM*`` + - 32 + - :ref:`GLRLM (3D) ` + * - ``*3D_NGLDM*`` + - 19 + - :ref:`NGLDM (3D) ` + * - ``*3D_NGTDM*`` + - 5 + - :ref:`NGTDM (3D) ` + +Image quality groups +-------------------- + +.. list-table:: + :header-rows: 1 + :widths: 26 10 64 + + * - Group + - Features + - Families + * - ``*ALL_IMQ*`` + - 6 + - :ref:`Image quality ` + +.. note:: + + ``*ALL_BUT_GABOR*``, ``*ALL_BUT_GLCM*`` are accepted as group names but no features are defined for them, so a + request that uses them stops with an error. diff --git a/docs/source/featurelist.rst b/docs/source/featurelist.rst index 4da48fc4d..3aac35c8a 100644 --- a/docs/source/featurelist.rst +++ b/docs/source/featurelist.rst @@ -1,820 +1,2945 @@ +.. This page is generated by docs/tools/gen_feature_reference.py. Edit + docs/feature_descriptions.toml and re-run the script; do not edit this file by hand. -Nyxus-provided features -======================= +Feature list +============ -**2D intensity features:** +Nyxus computes 539 features for 2D images, 213 for 3D volumes and 6 image quality features. Request a feature by its code, or a family of features by its group (see :doc:`featuregroups`). + +The *Validation* column says how each feature is tested: + +* **vetted** -- the test suite checks its value against an independent implementation, + published reference values (IBSI) or a closed-form calculation (analytic), named in the + column; +* **invariant** -- the test suite checks a relation its value must satisfy, such as equality + between two Nyxus code paths; +* **regression** -- the test suite pins its current value, so a change is noticed, but no + independent implementation confirms it; +* **untested** -- no test checks its value. + +2D features +----------- + +.. _features-intensity: + +Intensity +^^^^^^^^^ + +Statistics of the intensities of the ROI's pixels. They are reported in the image's own units; see Intensity units and CT images in the supported formats page. + +Definitions, formulas and settings: :doc:`/Math/f_pixel_intensity`. .. list-table:: :header-rows: 1 + :widths: 30 50 20 - * - Nyxus feature code + * - Code - Description - * - INTEGRATED_INTENSITY - - Integrated intensity of the region of interest (ROI) - * - MEAN + - Validation + * - ``COV`` + - Coefficient of variation: the unbiased (n-1) standard deviation of the ROI's pixel intensities divided by their mean; 0 when the mean is 0. + - vetted: MATLAB + * - ``COVERED_IMAGE_INTENSITY_RANGE`` + - Ratio of the ROI's intensity range (MAX minus MIN) to the intensity range of the pixels of all ROIs in the image; pixels outside every ROI are not used. + - vetted: MATLAB + * - ``ENERGY`` + - Sum of the squared pixel intensities of the ROI. + - vetted: IBSI, MATLAB, PyRadiomics + * - ``ENTROPY`` + - Shannon entropy (base 2) of the ROI's intensity histogram, binned into \|``--coarseGrayDepth``\| equal-width bins (default 64) spanning the ROI's minimum to maximum intensity. + - vetted: PyRadiomics + * - ``EXCESS_KURTOSIS`` + - Excess kurtosis of the ROI's pixel intensities: the population fourth standardized moment minus 3; 0 for ROIs with fewer than 5 pixels or a single intensity. + - vetted: IBSI, MATLAB + * - ``HYPERFLATNESS`` + - Sixth standardized moment of the ROI's pixel intensities: the mean sixth power of the deviations from the mean, divided by the sixth power of the unbiased (n-1) standard deviation. + - vetted: MATLAB + * - ``HYPERSKEWNESS`` + - Fifth standardized moment of the ROI's pixel intensities: the mean fifth power of the deviations from the mean, divided by the fifth power of the unbiased (n-1) standard deviation. + - vetted: MATLAB + * - ``INTEGRATED_INTENSITY`` + - Sum of the pixel intensities of the ROI. + - vetted: MATLAB + * - ``INTERQUARTILE_RANGE`` + - Difference between the 75th and 25th percentiles (P75 minus P25) of the ROI's pixel intensities. + - vetted: IBSI, MATLAB, PyRadiomics + * - ``KURTOSIS`` + - Pearson kurtosis of the ROI's pixel intensities: the population fourth standardized moment, without subtracting 3; 0 for ROIs with fewer than 5 pixels or a single intensity. + - vetted: MATLAB, PyRadiomics + * - ``MAX`` + - Maximum intensity value in the ROI + - vetted: MATLAB, PyRadiomics + * - ``MEAN`` - Mean intensity value of the ROI - * - MEDIAN - - The median value of pixels in the ROI - * - MIN + - vetted: IBSI, MATLAB, PyRadiomics + * - ``MEAN_ABSOLUTE_DEVIATION`` + - Mean absolute deviation of the ROI's pixel intensities from their mean. + - vetted: IBSI, MATLAB, PyRadiomics + * - ``MEDIAN`` + - Median pixel intensity of the ROI; the average of the two middle values when the pixel count is even. + - vetted: IBSI, MATLAB, PyRadiomics + * - ``MEDIAN_ABSOLUTE_DEVIATION`` + - Mean absolute deviation of the ROI's pixel intensities from their median (differs from the median of the absolute deviations from the median). + - regression + * - ``MIN`` - Minimum intensity value in the ROI - * - MAX - - Maximum intensity value in the ROI - * - RANGE - - Range between the maximmu and minimum - * - COVERED_IMAGE_INTENSITY_RANGE - - intensity range of the ROI to intensity range of all the ROIs - * - STANDARD_DEVIATION - - Standard deviation (unbiased) - * - STANDARD_DEVIATION_BIASED - - Biased standard deviation - * - COV - - Coefficient of variation - * - STANDARD_ERROR - - Standard error - * - SKEWNESS - - Skewness - the 3rd standardized moment - * - KURTOSIS - - Kurtosis - the 4th standardized moment (Pearson formula) - * - EXCESS_KURTOSIS - - Excess kurtosis - the 4th standardized moment (Fisher-corrected formula, IBSI feature IPH6) - * - HYPERSKEWNESS - - Hyperskewness - the 5th standardized moment - * - HYPERFLATNESS - - Hyperflatness - the 6th standardized moment - * - MEAN_ABSOLUTE_DEVIATION - - Mean absolute deviation - * - MEDIAN_ABSOLUTE_DEVIATION - - Median absolute deviation - * - ENERGY - - ROI energy - * - ROOT_MEAN_SQUARED - - Root of mean squared deviation - * - ENTROPY - - ROI entropy - a measure of the amount of information (that is, randomness) in the ROI - * - MODE - - The mode value of pixels in the ROI - the value that appears most often in a set of ROI intensity values - * - UNIFORMITY - - Uniformity - measures how uniform the distribution of ROI intensities is - * - UNIFORMITY_PIU - - Percent image uniformity, another measure of intensity distribution uniformity - * - P01, P10, P25, P75, P90, P99 - - 1%, 10%, 25%, 75%, 90%, and 99% percentiles of intensity distribution - * - QCOD - - quartile coefficient of dispersion - * - INTERQUARTILE_RANGE - - Distribution's interquartile range - * - ROBUST_MEAN_ABSOLUTE_DEVIATION - - Robust mean absolute deviation - * - MASS_DISPLACEMENT - - ROI mass displacement - - -**2D intensity histogram features (IBSI):** - -The Intensity Histogram (IH) family is the IBSI intensity-histogram first-order set. -All 46 features are derived from a single per-ROI histogram of ``N`` equal-width bins -spanning the ROI intensity range, where ``N`` is the coarse gray depth -(``--coarseGrayDepth`` / ``coarse_gray_depth``). For floating-point images the -histogram is built in the original (unrescaled) float intensity domain. - -This family is available only when **IBSI mode is enabled** (``--ibsi=true`` / -``ibsi=True``). Request it as the group ``*ALL_IH*`` or by individual ``IH_*`` code. -The ``...VAL`` features are computed over discretised intensities, the ``...IDX`` -features over the (1-based) bin indices. - -.. list-table:: - :header-rows: 1 - - * - Nyxus feature code - - Description - * - IH_MEAN_VAL, IH_MEAN_IDX - - Mean of the histogram (over intensities / over bin indices) - * - IH_VARIANCE_VAL, IH_VARIANCE_IDX - - Variance of the histogram - * - IH_SKEWNESS_VAL, IH_SKEWNESS_IDX - - Skewness of the histogram - * - IH_EXCESS_KURTOSIS_VAL, IH_EXCESS_KURTOSIS_IDX - - Excess kurtosis of the histogram - * - IH_MEDIAN_VAL, IH_MEDIAN_IDX - - Median (center of the bin reaching half the count) - * - IH_MINIMUM_VAL, IH_MINIMUM_IDX - - Minimum value / its bin index - * - IH_MAXIMUM_VAL, IH_MAXIMUM_IDX - - Maximum value / its bin index - * - IH_P10_VAL, IH_P10_IDX - - 10th percentile value / its bin index - * - IH_P90_VAL, IH_P90_IDX - - 90th percentile value / its bin index - * - IH_MODE_VAL, IH_MODE_IDX - - Mode (most frequent bin) value / its bin index - * - IH_INTERQUANTILE_RANGE_VAL, IH_INTERQUANTILE_RANGE_IDX - - Interquartile range (P75 - P25) - * - IH_RANGE_VAL, IH_RANGE_IDX - - Range (max - min) - * - IH_MEAN_ABSOLUTE_DEVIATION_VAL, IH_MEAN_ABSOLUTE_DEVIATION_IDX - - Mean absolute deviation - * - IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_VAL, IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_IDX - - Robust mean absolute deviation (P10-P90 range) - * - IH_MEDIAN_ABSOLUTE_DEVIATION_VAL, IH_MEDIAN_ABSOLUTE_DEVIATION_IDX - - Median absolute deviation - * - IH_COEFFICIENT_OF_VARIATION_VAL, IH_COEFFICIENT_OF_VARIATION_IDX - - Coefficient of variation - * - IH_QUANTILE_COEFFICIENT_OF_DISPERSION_VAL, IH_QUANTILE_COEFFICIENT_OF_DISPERSION_IDX - - Quantile coefficient of dispersion - * - IH_ENTROPY_VAL, IH_ENTROPY_IDX - - Histogram entropy - * - IH_UNIFORMITY_VAL, IH_UNIFORMITY_IDX - - Histogram uniformity (energy) - * - IH_ROBUST_MEAN_VAL, IH_ROBUST_MEAN_IDX - - Robust mean (over the P10-P90 range) - * - IH_MAX_GRADIENT, IH_MAX_GRADIENT_IDX - - Maximum histogram gradient / its bin index - * - IH_MIN_GRADIENT, IH_MIN_GRADIENT_IDX - - Minimum histogram gradient / its bin index - * - IH_NUM_BINS - - Number of histogram bins (N) - * - IH_BIN_SIZE - - Histogram bin width (range / N) - - -**2D morphology features:** - - -.. list-table:: - :header-rows: 1 - - * - Nyxus feature code - - Description - * - AREA_PIXELS_COUNT - - ROI area in the number of pixels - * - AREA_UM2 - - ROI area in metric units - * - CENTROID_X - - X-coordinate of the enter point of the ROI - * - CENTROID_Y - - Y-coordinate of the center point of the ROI - * - COMPACTNESS - - Mean squared distance of the object’s pixels from the centroid divided by the area. Compactness of a filled circle is 1, compactness of irregular objects or objects with holes is greater than 1 - * - BBOX_YMIN - - Y-position and size of the smallest axis-aligned box containing the ROI - * - BBOX_XMIN - - X-position and size of the smallest axis-aligned box containing the ROI - * - BBOX_HEIGHT + - vetted: IBSI, MATLAB, PyRadiomics + * - ``MODE`` + - Most frequent pixel intensity in the ROI; the smallest such intensity when several share the top count. + - vetted: MATLAB + * - ``P01`` + - 1st percentile of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: MATLAB + * - ``P10`` + - 10th percentile of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: IBSI, MATLAB, PyRadiomics + * - ``P25`` + - 25th percentile (first quartile) of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: MATLAB + * - ``P75`` + - 75th percentile (third quartile) of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: MATLAB + * - ``P90`` + - 90th percentile of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: IBSI, MATLAB, PyRadiomics + * - ``P99`` + - 99th percentile of the ROI's pixel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: MATLAB + * - ``QCOD`` + - Quartile coefficient of dispersion: (P75 minus P25) divided by (P75 plus P25); 0 when the denominator is 0. + - vetted: MATLAB + * - ``RANGE`` + - Difference between the maximum and minimum pixel intensities of the ROI (MAX minus MIN). + - vetted: IBSI, MATLAB, PyRadiomics + * - ``ROBUST_MEAN`` + - Mean of the ROI's pixel intensities that lie between P10 and P90, inclusive. + - regression + * - ``ROBUST_MEAN_ABSOLUTE_DEVIATION`` + - Mean absolute deviation from ROBUST_MEAN of the ROI's pixel intensities that lie between P10 and P90, inclusive. + - vetted: PyRadiomics + * - ``ROOT_MEAN_SQUARED`` + - Root mean square of the ROI's pixel intensities: the square root of ENERGY divided by the pixel count. + - vetted: IBSI, MATLAB, PyRadiomics + * - ``SKEWNESS`` + - Skewness of the ROI's pixel intensities: the population third standardized moment; 0 for ROIs with fewer than 4 pixels or a single intensity. + - vetted: IBSI, MATLAB, PyRadiomics + * - ``STANDARD_DEVIATION`` + - Unbiased standard deviation of the ROI's pixel intensities, with n-1 in the denominator. + - vetted: MATLAB + * - ``STANDARD_DEVIATION_BIASED`` + - Biased (population) standard deviation of the ROI's pixel intensities, with n in the denominator. + - vetted: MATLAB + * - ``STANDARD_ERROR`` + - Standard error of the mean: the unbiased standard deviation divided by the square root of the pixel count. + - vetted: MATLAB + * - ``VARIANCE`` + - Unbiased variance of the ROI's pixel intensities, with n-1 in the denominator. + - vetted: MATLAB, PyRadiomics + * - ``VARIANCE_BIASED`` + - Biased (population) variance of the ROI's pixel intensities, with n in the denominator. + - vetted: MATLAB, PyRadiomics + * - ``UNIFORMITY`` + - Sum of the squared bin probabilities of the ROI's intensity histogram, binned into \|``--coarseGrayDepth``\| equal-width bins (default 64) spanning the ROI's minimum to maximum intensity. + - vetted: PyRadiomics + * - ``UNIFORMITY_PIU`` + - Percent image uniformity: (1 minus (MAX minus MIN) divided by (MAX plus MIN)) times 100; 0 when MAX plus MIN is 0. + - vetted: MATLAB + * - ``HISTOGRAM`` + - Multi-column feature HISTOGRAM_BIN_0 to HISTOGRAM_BIN_N-1: the number of ROI pixels in each of N equal-width bins spanning the ROI's minimum to maximum intensity, where N is ``--coarseGrayDepth`` (default 64); a negative value writes no columns (a fix is coming). It is not part of ``*ALL_INTENSITY*``; it is computed only together with another intensity feature or with ``*ALL*``, and requested on its own every column is 0 (known defect, https://github.com/PolusAI/nyxus/issues/489). + - vetted: analytic + +.. _features-intensity-histogram: + +IBSI intensity histogram +^^^^^^^^^^^^^^^^^^^^^^^^ + +Available only in IBSI mode (``--ibsi=true``). All features come from one histogram per ROI with ``--coarseGrayDepth`` equal-width bins over the ROI's intensity range. + +Definitions, formulas and settings: :doc:`/Math/f_intensity_histogram`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``IH_MEAN_VAL`` + - Mean intensity read from the ROI's N-bin intensity histogram: the frequency-weighted mean of the bin centres. All IH\_ features use N equal-width bins over the ROI's intensity range, N = ``--coarseGrayDepth`` (default 64), and are computed only in IBSI mode (``--ibsi``). + - vetted: analytic + * - ``IH_VARIANCE_VAL`` + - Population variance of the histogram's bin-centre intensities, weighted by bin frequency. + - vetted: analytic + * - ``IH_SKEWNESS_VAL`` + - Skewness (third standardized moment) of the histogram's bin-centre intensities, weighted by bin frequency. + - vetted: analytic + * - ``IH_EXCESS_KURTOSIS_VAL`` + - Excess kurtosis (fourth standardized moment minus 3) of the histogram's bin-centre intensities, weighted by bin frequency. + - vetted: analytic + * - ``IH_MEDIAN_VAL`` + - Centre intensity of the first histogram bin at which the cumulative pixel count exceeds half the ROI's pixel count. + - vetted: analytic + * - ``IH_MINIMUM_VAL`` + - Minimum intensity of the ROI, the lower edge of the histogram's first bin. + - vetted: analytic + * - ``IH_P10_VAL`` + - 10th percentile intensity, interpolated linearly within the histogram bin that contains it. + - regression + * - ``IH_P90_VAL`` + - 90th percentile intensity, interpolated linearly within the histogram bin that contains it. + - regression + * - ``IH_MAXIMUM_VAL`` + - Maximum intensity of the ROI, the upper edge of the histogram's last bin. + - vetted: analytic + * - ``IH_MODE_VAL`` + - Centre intensity of the most populated histogram bin; the lowest such bin on ties. + - vetted: analytic + * - ``IH_INTERQUANTILE_RANGE_VAL`` + - Difference between the 75th and 25th percentile intensities, each interpolated within its histogram bin. + - regression + * - ``IH_RANGE_VAL`` + - Difference between the ROI's maximum and minimum intensities. + - vetted: analytic + * - ``IH_MEAN_ABSOLUTE_DEVIATION_VAL`` + - Frequency-weighted mean absolute deviation of the histogram's bin-centre intensities from IH_MEAN_VAL. + - vetted: analytic + * - ``IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_VAL`` + - Mean absolute deviation from IH_ROBUST_MEAN_VAL of the bin-centre intensities of the bins from the 10th-percentile bin to the 90th-percentile bin, inclusive. + - vetted: analytic + * - ``IH_MEDIAN_ABSOLUTE_DEVIATION_VAL`` + - Frequency-weighted mean absolute deviation of the histogram's bin-centre intensities from IH_MEDIAN_VAL. + - vetted: analytic + * - ``IH_COEFFICIENT_OF_VARIATION_VAL`` + - Square root of IH_VARIANCE_VAL divided by IH_MEAN_VAL. + - vetted: analytic + * - ``IH_QUANTILE_COEFFICIENT_OF_DISPERSION_VAL`` + - Quartile coefficient of dispersion of the histogram: (P75 minus P25) divided by (P75 plus P25), using the interpolated percentile intensities. + - regression + * - ``IH_ENTROPY_VAL`` + - Shannon entropy (base 2) of the histogram's bin probabilities. + - vetted: analytic + * - ``IH_UNIFORMITY_VAL`` + - Sum of the squared bin probabilities of the histogram. + - vetted: analytic + * - ``IH_ROBUST_MEAN_VAL`` + - Frequency-weighted mean of the bin-centre intensities of the bins from the 10th-percentile bin to the 90th-percentile bin, inclusive. + - vetted: analytic + * - ``IH_MEAN_IDX`` + - Frequency-weighted mean of the 1-based histogram bin indices. + - vetted: MIRP + * - ``IH_VARIANCE_IDX`` + - Population variance of the histogram bin indices, weighted by bin frequency. + - vetted: MIRP + * - ``IH_SKEWNESS_IDX`` + - Skewness (third standardized moment) of the histogram bin indices, weighted by bin frequency. + - vetted: MIRP + * - ``IH_EXCESS_KURTOSIS_IDX`` + - Excess kurtosis (fourth standardized moment minus 3) of the histogram bin indices, weighted by bin frequency. + - vetted: MIRP + * - ``IH_MEDIAN_IDX`` + - 1-based index of the histogram bin that holds IH_MEDIAN_VAL. + - vetted: MIRP + * - ``IH_MINIMUM_IDX`` + - 1-based index of the histogram bin that holds the ROI's minimum intensity, which is always 1. + - vetted: MIRP + * - ``IH_P10_IDX`` + - 1-based index of the histogram bin that holds the 10th percentile intensity. + - vetted: MIRP + * - ``IH_P90_IDX`` + - 1-based index of the histogram bin that holds the 90th percentile intensity. + - vetted: MIRP + * - ``IH_MAXIMUM_IDX`` + - 1-based index of the histogram bin that holds the ROI's maximum intensity, which is always N. + - vetted: MIRP + * - ``IH_MODE_IDX`` + - 1-based index of the most populated histogram bin; the lowest such bin on ties. + - vetted: MIRP + * - ``IH_INTERQUANTILE_RANGE_IDX`` + - Difference between the bin indices that hold the 75th and 25th percentile intensities. + - vetted: MIRP + * - ``IH_RANGE_IDX`` + - Difference between the bin indices that hold the maximum and minimum intensities, which is N minus 1. + - vetted: MIRP + * - ``IH_MEAN_ABSOLUTE_DEVIATION_IDX`` + - Frequency-weighted mean absolute deviation of the histogram bin indices from their mean. + - vetted: MIRP + * - ``IH_ROBUST_MEAN_ABSOLUTE_DEVIATION_IDX`` + - Mean absolute deviation from the robust mean index of the bin indices from the 10th-percentile bin to the 90th-percentile bin, inclusive. + - vetted: MIRP + * - ``IH_MEDIAN_ABSOLUTE_DEVIATION_IDX`` + - Frequency-weighted mean absolute deviation of the histogram bin indices from the median bin index. + - vetted: MIRP + * - ``IH_COEFFICIENT_OF_VARIATION_IDX`` + - Square root of IH_VARIANCE_IDX divided by IH_MEAN_IDX. + - vetted: MIRP + * - ``IH_QUANTILE_COEFFICIENT_OF_DISPERSION_IDX`` + - Quartile coefficient of dispersion of the bin indices: the difference of the 1-based bin indices holding P75 and P25, divided by their sum. + - vetted: MIRP + * - ``IH_ENTROPY_IDX`` + - Shannon entropy (base 2) of the histogram's bin probabilities; equal to IH_ENTROPY_VAL. + - vetted: MIRP + * - ``IH_UNIFORMITY_IDX`` + - Sum of the squared bin probabilities of the histogram; equal to IH_UNIFORMITY_VAL. + - vetted: MIRP + * - ``IH_MAX_GRADIENT`` + - Largest gradient of the histogram's bin counts, in pixels per bin, using central differences inside and one-sided differences at the first and last bins; when no gradient is above 0 it returns 2.2251e-308 (DBL_MIN) instead. + - vetted: MIRP + * - ``IH_MAX_GRADIENT_IDX`` + - 1-based index of the first histogram bin at which IH_MAX_GRADIENT occurs; 0 when no gradient is above 0. + - vetted: MIRP + * - ``IH_MIN_GRADIENT`` + - Smallest (most negative) gradient of the histogram's bin counts, in pixels per bin, using central differences inside and one-sided differences at the first and last bins. + - vetted: MIRP + * - ``IH_MIN_GRADIENT_IDX`` + - 1-based index of the histogram bin at which IH_MIN_GRADIENT occurs. + - vetted: MIRP + * - ``IH_ROBUST_MEAN_IDX`` + - Frequency-weighted mean of the 1-based bin indices from the 10th-percentile bin to the 90th-percentile bin, inclusive. + - vetted: analytic + * - ``IH_NUM_BINS`` + - Number of histogram bins N, set by ``--coarseGrayDepth`` (default 64). + - vetted: analytic + * - ``IH_BIN_SIZE`` + - Width of one histogram bin in intensity units: the ROI's intensity range divided by N. + - vetted: analytic + +.. _features-basic-morphology: + +Basic morphology +^^^^^^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``AREA_PIXELS_COUNT`` + - ROI area as the number of pixels in the ROI. + - vetted: MATLAB + * - ``AREA_UM2`` + - ROI area in square micrometers, meant as the pixel count times the squared pixel size; currently reported as 0, because the image resolution does not reach this feature (known defect, https://github.com/PolusAI/nyxus/issues/496). + - vetted: MATLAB + * - ``CENTROID_X`` + - X coordinate of the ROI's centroid: the mean x coordinate of its pixels, in pixels. + - vetted: MATLAB + * - ``CENTROID_Y`` + - Y coordinate of the ROI's centroid: the mean y coordinate of its pixels, in pixels. + - vetted: MATLAB + * - ``WEIGHTED_CENTROID_Y`` + - Y coordinate of the ROI's intensity-weighted centroid: the mean of the pixel y coordinates weighted by pixel intensity; 0 when the ROI's total intensity is 0. + - vetted: MATLAB + * - ``WEIGHTED_CENTROID_X`` + - X coordinate of the ROI's intensity-weighted centroid: the mean of the pixel x coordinates weighted by pixel intensity; 0 when the ROI's total intensity is 0. + - vetted: MATLAB + * - ``MASS_DISPLACEMENT`` + - Distance in pixels between the ROI's intensity-weighted centroid and its unweighted centroid. + - vetted: CellProfiler + * - ``COMPACTNESS`` + - Unbiased (n-1) standard deviation of the distances from the ROI's pixels to its centroid, divided by the ROI's pixel count (differs from the mean squared distance to the centroid divided by the area). + - regression + * - ``BBOX_YMIN`` + - Smallest y coordinate (top row) of the ROI's axis-aligned bounding box, in pixels. + - vetted: MATLAB + * - ``BBOX_XMIN`` + - Smallest x coordinate (leftmost column) of the ROI's axis-aligned bounding box, in pixels. + - vetted: MATLAB + * - ``BBOX_HEIGHT`` - Height of the smallest axis-aligned box containing the ROI - * - BBOX_WIDTH + - vetted: MATLAB + * - ``BBOX_WIDTH`` - Width of the smallest axis-aligned box containing the ROI - * - MAJOR_AXIS_LENGTH - - Length (in pixels) of the major axis of the ellipse that has the same normalized second central moments as the region - * - MINOR_AXIS_LENGTH - - Length (in pixels) of the minor axis of the ellipse that has the same normalized second central moments as the region - * - ECCENTRICITY - - Ratio of ROI's inertia ellipse focal distance over the major axis length - * - ORIENTATION - - Angle between the 0th axis and the major axis of the ellipse that has same second moments as the region - * - ROUNDNESS - - Represents how similar a ROI's inertia ellipse is to circle. Calculated based on the major and minor exis lengths - * - EXTENT - - Proportion of the pixels (2D) or voxels (3D) in the bounding box that are also in the region. Computed as the area/volume of the object divided by the area/volume of the bounding box - * - ASPECT_RATIO - - The ratio of the major axis to the minor axis of ROI's inertia ellipse - * - CONVEX_HULL_AREA - - Area of ROI's convex hull - * - SOLIDITY - - Ratio of pixels in the ROI common with its convex hull image - * - PERIMETER - - Number of pixels in ROI's contour - * - EQUIVALENT_DIAMETER - - Diameter of a circle with the same area as the ROI - * - EDGE_MEAN_INTENSITY + - vetted: MATLAB + * - ``DIAMETER_EQUAL_AREA`` + - Diameter of a circle with the same area as the ROI: 2 times the square root of the pixel count divided by pi, in pixels. + - vetted: scikit-image + * - ``EXTENT`` + - Fraction of the ROI's axis-aligned bounding box covered by the ROI: the pixel count divided by the bounding box area. + - vetted: MATLAB + * - ``ASPECT_RATIO`` + - Width of the ROI's axis-aligned bounding box divided by its height. + - vetted: MATLAB + +.. _features-contour: + +Contour +^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``PERIMETER`` + - Length of the ROI's traced contour in pixels: the sum of the Euclidean distances between consecutive contour pixels, closing back to the first one. A hole or a second fragment appends its loop to the same list, and the jumps between loops are counted too. On the out-of-core path it is the number of contour pixels instead. + - vetted: scikit-image + * - ``DIAMETER_EQUAL_PERIMETER`` + - Diameter of a circle with the same perimeter as the ROI: PERIMETER divided by pi, in pixels. + - vetted: IMEA + * - ``EDGE_MEAN_INTENSITY`` - Mean intensity of ROI's contour pixels - * - EDGE_STDDEV_INTENSITY - - Standard deviation of ROI's contour pixels - * - EDGE_MAX_INTENSITY + - vetted: CellProfiler + * - ``EDGE_STDDEV_INTENSITY`` + - Unbiased (n-1) standard deviation of the intensities of the ROI's contour pixels. + - invariant + * - ``EDGE_MAX_INTENSITY`` - Maximum intensity of ROI's contour pixels - * - EDGE_MIN_INTENSITY + - vetted: CellProfiler + * - ``EDGE_MIN_INTENSITY`` - Minimum intensity of ROI's contour pixels - * - CIRCULARITY - - Represents how similar a shape is to circle. Clculated based on ROI's area and its convex perimeter - * - EROSIONS_2_VANISH - - Number of erosion operations for a ROI to vanish in its axis aligned bounding box - * - EROSIONS_2_VANISH_COMPLEMENT - - Number of erosion operations for a ROI to vanish in its convex hull - * - FRACT_DIM_BOXCOUNT - - Fractal dimension determined by the box counting method according to ISO 9276-6. If C is a fractal set, with fractal dimension DF < D, then the number N of boxes of size R needed to cover the set scales as R^(-DF). DF is known as the Hausdorf dimension, or Kolmogorov capacity, or Kolmogorov dimension, or simply box-counting dimension - * - FRACT_DIM_PERIMETER - - Fractal dimension determined by the perimeter method according to ISO 9276-6. If we approximate ROI's contour with rulers of length lambda, the perimeter based fractal dimension is the slope of the best fit line of log ROI perimeter versus log lambda, subtracted from 1 - * - WEIGHTED_CENTROID_Y - - X-coordinate of centroid - * - WEIGHTED_CENTROID_X - - Y-coordinate of centroid - * - MIN_FERET_DIAMETER - - Feret diameter (or maximum caliber diameter) is the longest distance between any two ROI points along the same (horizontal) direction. This feature is the minimum Feret diameter for angles ranging 0 to 180 degrees - * - MAX_FERET_DIAMETER - - Maximum Feret diameter for angles ranging 0 to 180 degrees - * - MIN_FERET_ANGLE - - Angle of the minimum Feret diameter - * - MAX_FERET_ANGLE - - Angle of the maximum Feret diameter - * - STAT_FERET_DIAM_MIN - - Minimum of Feret diameters of the ROI rotated at angles 0-180 degrees - * - STAT_FERET_DIAM_MAX - - Maximum of Feret diameters of the ROI rotated at angles 0-180 degrees - * - STAT_FERET_DIAM_MEAN - - Mean Feret diameter of the ROI rotated at angles 0-180 degrees - * - STAT_FERET_DIAM_MEDIAN - - Median value of Feret diameters of the ROI rotated at angles 0-180 degrees - * - STAT_FERET_DIAM_STDDEV - - Standard deviation of Feret diameter of the ROI rotated at angles 0-180 degrees - * - STAT_FERET_DIAM_MODE - - Histogram mode of Feret diameters of the ROI rotated at angles 0-180 degrees - * - STAT_MARTIN_DIAM_MIN - - Minimum of Martin diameters of the ROI rotated at angles 0-180 degrees - * - STAT_MARTIN_DIAM_MAX - - Maximum of Martin diameters of the ROI rotated at angles 0-180 degrees - * - STAT_MARTIN_DIAM_MEAN - - Mean of Martin diameter of the ROI rotated at angles 0-180 degrees - * - STAT_MARTIN_DIAM_MEDIAN - - Median value of Martin diameters of the ROI rotated at angles 0-180 degrees - * - STAT_MARTIN_DIAM_STDDEV - - Standard deviation of Martin diameter of the ROI rotated at angles 0-180 degrees - * - STAT_MARTIN_DIAM_MODE - - Histogram mode of Martin diameters of the ROI rotated at angles 0-180 degrees - * - STAT_NASSENSTEIN_DIAM_MIN - - Minimum of Nassenstein diameters of the ROI rotated at angles 0-180 degrees - * - STAT_NASSENSTEIN_DIAM_MAX - - Maximum of Nassenstein diameters of the ROI rotated at angles 0-180 degrees - * - STAT_NASSENSTEIN_DIAM_MEAN - - Mean of Nassenstein diameter of the ROI rotated at angles 0-180 degrees - * - STAT_NASSENSTEIN_DIAM_MEDIAN - - Median value of Nassenstein diameters of the ROI rotated at angles 0-180 degrees - * - STAT_NASSENSTEIN_DIAM_STDDEV - - Standard deviation of Nassenstein diameter of the ROI rotated at angles 0-180 degrees - * - STAT_NASSENSTEIN_DIAM_MODE - - Histogram mode of Nassenstein diameters of the ROI rotated at angles 0-180 degrees - * - MAXCHORDS_MAX - - Maximum of ROI's longest chords built at angles 0-180 degrees - * - MAXCHORDS_MAX_ANG - - Angle of the chord referenced in MAXCHORDS_MAX - * - MAXCHORDS_MIN - - Minimum of ROI's longest chords built at angles 0-180 degrees - * - MAXCHORDS_MIN_ANG - - Angle of the chord referenced in MAXCHORDS_MIN - * - MAXCHORDS_MEDIAN - - Median value of ROI's longest chords built at angles 0-180 degrees - * - MAXCHORDS_MEAN - - Mean value of ROI's longest chords built at angles 0-180 degrees - * - MAXCHORDS_MODE - - Histogram mode of ROI's longest chords built at angles 0-180 degrees - * - MAXCHORDS_STDDEV - - Sndard deviation of ROI's longest chords built at angles 0-180 degrees - * - ALLCHORDS_MAX - - Maximum of all the ROI's chords built at angles 0-180 degrees - * - ALLCHORDS_MAX_ANG - - Angle of the chord referenced in ALLCHORDS_MAX - * - ALLCHORDS_MIN - - Minimum of all the ROI's chords built at angles 0-180 degrees - * - ALLCHORDS_MIN_ANG - - Angle of the chord referenced in ALLCHORDS_MIN - * - ALLCHORDS_MEDIAN - - Median value of all the ROI's chords built at angles 0-180 degrees - * - ALLCHORDS_MEAN - - Mean value of all the ROI's chords built at angles 0-180 degrees - * - ALLCHORDS_MODE - - Histogram mode of all the ROI's chords built at angles 0-180 degrees - * - ALLCHORDS_STDDEV - - Sndard deviation of all the ROI's chords built at angles 0-180 degrees - * - EULER_NUMBER + - vetted: CellProfiler + * - ``EDGE_INTEGRATED_INTENSITY`` + - Sum of the intensities of the ROI's contour pixels. + - vetted: CellProfiler + +.. _features-convex-hull: + +Convex hull +^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``CIRCULARITY`` + - Square root of 4 pi times the ROI's pixel count divided by the square of its PERIMETER; close to 1 for a digital disk and smaller for less circular shapes; small or blocky ROIs can exceed 1. + - vetted: analytic + * - ``CONVEX_HULL_AREA`` + - Area of the convex hull of the ROI's pixel centres, in pixels, converted to a pixel-count equivalent with Pick's theorem (polygon area plus half the hull boundary lattice points plus 1). + - vetted: scikit-image + * - ``SOLIDITY`` + - ROI's pixel count divided by CONVEX_HULL_AREA. + - vetted: scikit-image + * - ``POLYGONALITY_AVE`` + - Score of how closely the ROI matches a regular polygon with NUM_NEIGHBORS sides: 10 times the mean of a side-length ratio and an area ratio computed from the ROI's area and PERIMETER. 10 means a perfect match, lower values a poorer one; -1 when the ROI has fewer than 3 neighbors. + - regression + * - ``HEXAGONALITY_AVE`` + - Score of how closely the ROI matches a regular hexagon: 10 times the mean of the average pairwise agreement among 11 hexagon area estimates and among 14 hexagon perimeter estimates, built from the area, PERIMETER, convex hull area and minimum and maximum Feret diameters. 10 means a perfect hexagon, lower values a poorer match; -1 when the ROI has fewer than 3 neighbors. + - regression + * - ``HEXAGONALITY_STDDEV`` + - Dispersion of the hexagonality estimates: the square root of the mean of the population variances of the pairwise area ratios and of the pairwise perimeter ratios used in HEXAGONALITY_AVE; -1 when the ROI has fewer than 3 neighbors. + - regression + +.. _features-ellipse: + +Ellipse fitting +^^^^^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``MAJOR_AXIS_LENGTH`` + - Length (in pixels) of the major axis of the ellipse that has the same normalized second central moments as the region + - vetted: MATLAB + * - ``MINOR_AXIS_LENGTH`` + - Length (in pixels) of the minor axis of the ellipse that has the same normalized second central moments as the region + - vetted: MATLAB + * - ``ELONGATION`` + - MINOR_AXIS_LENGTH divided by MAJOR_AXIS_LENGTH of the ROI's moment-equivalent ellipse; 1 for a circle. + - vetted: MATLAB + * - ``ECCENTRICITY`` + - Eccentricity of the ROI's moment-equivalent ellipse: the distance between its foci divided by its major axis length, from 0 for a circle toward 1 for a line. + - vetted: MATLAB + * - ``ORIENTATION`` + - Angle in degrees, from -90 to 90, between the x axis and the major axis of the ROI's moment-equivalent ellipse, measured in image coordinates with y increasing downward. + - vetted: scikit-image + * - ``ROUNDNESS`` + - 4 times the ROI's area divided by pi times the square of MAJOR_AXIS_LENGTH; 1 for a circle. + - vetted: analytic + +.. _features-circles: + +Inscribing and circumscribing circles +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``DIAMETER_MIN_ENCLOSING_CIRCLE`` + - Diameter in pixels of the circle that Welzl's algorithm fits to the ROI's contour pixels, with chord midpoints truncated to whole pixels (known defect, https://github.com/PolusAI/nyxus/issues/498); it approximates the smallest enclosing circle and can come out about a pixel larger or smaller than it. + - vetted: IMEA + * - ``DIAMETER_CIRCUMSCRIBING_CIRCLE`` + - Twice the largest distance from the ROI's centroid to its contour pixels, in pixels. The contour is taken one pixel down and to the right of the ROI and the centroid one pixel up and to the left (known defect, https://github.com/PolusAI/nyxus/issues/497). + - regression + * - ``DIAMETER_INSCRIBING_CIRCLE`` + - Twice the smallest distance from the ROI's centroid to its contour pixels, in pixels. The contour is taken one pixel down and to the right of the ROI and the centroid one pixel up and to the left (known defect, https://github.com/PolusAI/nyxus/issues/497). + - regression + +.. _features-roi-radius: + +ROI radius +^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``ROI_RADIUS_MEAN`` + - Mean over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497) + - regression + * - ``ROI_RADIUS_MAX`` + - Maximum over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497) + - vetted: analytic, scikit-image + * - ``ROI_RADIUS_MEDIAN`` + - Median over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497) + - vetted: scikit-image + +.. _features-extrema: + +Extrema +^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``EXTREMA_P1_X`` + - X coordinate of extremum point 1: the leftmost ROI pixel in the ROI's top row. + - vetted: MATLAB + * - ``EXTREMA_P1_Y`` + - Y coordinate of extremum point 1: the ROI's top row (the bounding box's minimum y). + - vetted: MATLAB + * - ``EXTREMA_P2_X`` + - X coordinate of extremum point 2: the rightmost ROI pixel in the ROI's top row. + - vetted: MATLAB + * - ``EXTREMA_P2_Y`` + - Y coordinate of extremum point 2: the ROI's top row (the bounding box's minimum y). + - vetted: MATLAB + * - ``EXTREMA_P3_X`` + - X coordinate of extremum point 3: the ROI's rightmost column (the bounding box's maximum x). + - vetted: MATLAB + * - ``EXTREMA_P3_Y`` + - Y coordinate of extremum point 3: the topmost ROI pixel in the ROI's rightmost column. + - vetted: MATLAB + * - ``EXTREMA_P4_X`` + - X coordinate of extremum point 4: the ROI's rightmost column (the bounding box's maximum x). + - vetted: MATLAB + * - ``EXTREMA_P4_Y`` + - Y coordinate of extremum point 4: the bottommost ROI pixel in the ROI's rightmost column. + - vetted: MATLAB + * - ``EXTREMA_P5_X`` + - X coordinate of extremum point 5: the rightmost ROI pixel in the ROI's bottom row. + - vetted: MATLAB + * - ``EXTREMA_P5_Y`` + - Y coordinate of extremum point 5: the ROI's bottom row (the bounding box's maximum y). + - vetted: MATLAB + * - ``EXTREMA_P6_X`` + - X coordinate of extremum point 6: the leftmost ROI pixel in the ROI's bottom row. + - vetted: MATLAB + * - ``EXTREMA_P6_Y`` + - Y coordinate of extremum point 6: the ROI's bottom row (the bounding box's maximum y). + - vetted: MATLAB + * - ``EXTREMA_P7_X`` + - X coordinate of extremum point 7: the ROI's leftmost column (the bounding box's minimum x). + - vetted: MATLAB + * - ``EXTREMA_P7_Y`` + - Y coordinate of extremum point 7: the bottommost ROI pixel in the ROI's leftmost column. + - vetted: MATLAB + * - ``EXTREMA_P8_X`` + - X coordinate of extremum point 8: the ROI's leftmost column (the bounding box's minimum x). + - vetted: MATLAB + * - ``EXTREMA_P8_Y`` + - Y coordinate of extremum point 8: the topmost ROI pixel in the ROI's leftmost column. + - vetted: MATLAB + +.. _features-euler: + +Euler number +^^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``EULER_NUMBER`` - Euler characteristic of the ROI - the number of objects in the ROI minus the number of holes assuming the 8-neighbor connectivity of ROI's pixels - * - EXTREMA_P1_X - - X-ccordinate of ROI's axis aligned bounding box extremum point #1 - * - EXTREMA_P1_Y - - Y-ccordinate of ROI's axis aligned bounding box extremum point #1 - * - EXTREMA_P2_X - - X-ccordinate of ROI's axis aligned bounding box extremum point #2 - * - EXTREMA_P2_Y - - - * - EXTREMA_P3_X - - X-ccordinate of ROI's axis aligned bounding box extremum point #3 - * - EXTREMA_P3_Y - - - * - EXTREMA_P4_X - - X-ccordinate of ROI's axis aligned bounding box extremum point #4 - * - EXTREMA_P4_Y - - - * - EXTREMA_P5_X - - X-ccordinate of ROI's axis aligned bounding box extremum point #5 - * - EXTREMA_P5_Y - - - * - EXTREMA_P6_X - - X-ccordinate of ROI's axis aligned bounding box extremum point #6 - * - EXTREMA_P6_Y - - - * - EXTREMA_P7_X - - X-ccordinate of ROI's axis aligned bounding box extremum point #7 - * - EXTREMA_P7_Y - - - * - EXTREMA_P8_X - - X-ccordinate of ROI's axis aligned bounding box extremum point #8 - * - EXTREMA_P8_Y - - - * - POLYGONALITY_AVE - - The score ranges from $ -\infty $ to 10. Score 10 indicates the object shape is polygon and score $ -\infty $ indicates the ROI shape is not polygon - * - HEXAGONALITY_AVE - - The score ranges from $ -\infty $ to 10. Score 10 indicates the object shape is hexagon and score $ -\infty $ indicates the ROI shape is not hexagon - * - HEXAGONALITY_STDDEV - - Standard deviation of hexagonality_score relative to its mean - * - DIAMETER_MIN_ENCLOSING_CIRCLE - - Diameter of the minimum enclosing circle - * - DIAMETER_CIRCUMSCRIBING_CIRCLE - - Diameter of the circumscribing circle - * - DIAMETER_INSCRIBING_CIRCLE - - Diameter of inscribing circle - * - GEODETIC_LENGTH - - Geodetic length approximated by a rectangle with the same area and perimeter: $ area = geodeticlength * thickness$; $perimeter = 2 * (geodetic_length + thickness) $ - * - THICKNESS - - Thickness approximated by a rectangle with the same area and perimeter: $ area = geodeticlength * thickness$; $perimeter = 2 * (geodetic_length + thickness) $ - * - ROI_RADIUS_MEAN - - Mean over ROI pixels of the distance from a pixel to the nearest contour pixel - * - ROI_RADIUS_MAX - - Maximum over ROI pixels of the distance from a pixel to the nearest contour pixel - * - ROI_RADIUS_MEDIAN - - Median over ROI pixels of the distance from a pixel to the nearest contour pixel - - -**2D texture features:** - - -.. list-table:: - :header-rows: 1 - - * - Nyxus feature code - - Description - * - GLCM_ASM - - GLCM, Angular second moment, IBSI # 8ZQL - * - GLCM_ACOR - - GLCM, Autocorrelation, IBSI # QWB0 - * - GLCM_CLUPROM - - GLCM, Cluster prominence, IBSI # AE86 - * - GLCM_CLUSHADE - - GLCM, Cluster shade, IBSI # 7NFM - * - GLCM_CLUTEND - - GLCM, Cluster tendency, IBSI # DG8W - * - GLCM_CONTRAST - - GLCM, Contrast, IBSI # ACUI - * - GLCM_CORRELATION - - GLCM, Correlation, IBSI # NI2N - * - GLCM_DIFAVE - - GLCM, Difference average, IBSI # TF7R - * - GLCM_DIFENTRO - - GLCM, Difference entropy, IBSI # NTRS - * - GLCM_DIFVAR - - GLCM, Difference variance, IBSI # D3YU - * - GLCM_DIS - - GLCM, Dissimilarity, IBSI # 8S9J - * - GLCM_ENERGY - - GLCM, Energy - * - GLCM_ENTROPY - - GLCM, Entropy - * - GLCM_HOM1 - - GLCM, Homogeneity-1 - * - GLCM_HOM2 - - GLCM, Homogeneity-2 - * - GLCM_ID - - GLCM, Inverse difference, IBSI # IB1Z - * - GLCM_IDN - - GLCM, Inverse difference normalized, IBSI # NDRX - * - GLCM_IDM - - GLCM, Inverse difference moment, IBSI # WF0Z - * - GLCM_IDMN - - GLCM, Inverse difference moment normalized, IBSI # 1QCO - * - GLCM_INFOMEAS1 - - GLCM, Information measure of correlation 1, IBSI # R8DG - * - GLCM_INFOMEAS2 - - GLCM, Information measure of correlation 2, IBSI # JN9H - * - GLCM_IV - - GLCM, Inverse variance, IBSI # E8JP - * - GLCM_JAVE - - GLCM, Joint average, IBSI # 60VM - * - GLCM_JE - - GLCM, Joint entropy, IBSI # TU9B - * - GLCM_JMAX - - GLCM, Joint maximum (aka max probability), IBSI # GYBY - * - GLCM_JVAR - - GLCM, Joint variance (aka sum of squares), IBSI # UR99 - * - GLCM_SUMAVERAGE - - GLCM, Sum average, IBSI # ZGXS - * - GLCM_SUMENTROPY - - GLCM, Sum entropy, IBSI # P6QZ - * - GLCM_SUMVARIANCE - - GLCM, Sum variance, IBSI # OEEB - * - GLCM_VARIANCE - - GLCM, Variance - * - GLRLM_SRE - - Grey level run-length matrix (GLRLM) based feature, Short Run Emphasis - * - GLRLM_LRE - - GLRLM, Long Run Emphasis - * - GLRLM_GLN - - GLRLM, Grey Level Non-Uniformity - * - GLRLM_GLNN - - GLRLM, Grey Level Non-Uniformity Normalized - * - GLRLM_RLN - - GLRLM, Run Length Non-Uniformity - * - GLRLM_RLNN - - GLRLM, Run Length Non-Uniformity Normalized - * - GLRLM_RP - - GLRLM, Run Percentage - * - GLRLM_GLV - - GLRLM, Grey Level Variance - * - GLRLM_RV - - GLRLM, Run Variance - * - GLRLM_RE - - GLRLM, Run Entropy - * - GLRLM_LGLRE - - GLRLM, Low Grey Level Run Emphasis - * - GLRLM_HGLRE - - GLRLM, High Grey Level Run Emphasis - * - GLRLM_SRLGLE - - GLRLM, Short Run Low Grey Level Emphasis - * - GLRLM_SRHGLE - - GLRLM, Short Run High Grey Level Emphasis - * - GLRLM_LRLGLE - - GLRLM, Long Run Low Grey Level Emphasis - * - GLRLM_LRHGLE - - GLRLM, Long Run High Grey Level Emphasis - * - GLDZM_SDE - - GLDZM, Small Distance Emphasis - * - GLDZM_LDE - - GLDZM, Large Distance Emphasis - * - GLDZM_LGLE - - GLDZM, Low Grey Level Emphasis - * - GLDZM_HGLE - - GLDZM, High GreyLevel Emphasis - * - GLDZM_SDLGLE - - GLDZM, Small Distance Low Grey Level Emphasis - * - GLDZM_SDHGLE - - GLDZM, Small Distance High GreyLevel Emphasis - * - GLDZM_LDLGLE - - GLDZM, Large Distance Low Grey Level Emphasis - * - GLDZM_LDHGLE - - GLDZM, Large Distance High Grey Level Emphasis - * - GLDZM_GLNU - - GLDZM, Grey Level Non Uniformity - * - GLDZM_GLNUN - - GLDZM, Grey Level Non Uniformity Normalized - * - GLDZM_ZDNU - - GLDZM, Zone Distance Non Uniformity - * - GLDZM_ZDNUN - - GLDZM, Zone Distance Non Uniformity Normalized - * - GLDZM_ZP - - GLDZM, Zone Percentage - * - GLDZM_GLM - - GLDZM, Grey Level Mean - * - GLDZM_GLV - - GLDZM, Grey Level Variance - * - GLDZM_ZDM - - GLDZM, Zone Distance Mean - * - GLDZM_ZDV - - GLDZM, Zone Distance Variance - * - GLDZM_ZDE - - GLDZM, Zone Distance Entropy - * - GLSZM_SAE - - GLDZM, Grey level size zone matrix (GLSZM) based feature, Small Area Emphasis - * - GLSZM_LAE - - Large Area Emphasis - * - GLSZM_GLN - - Grey Level Non - Uniformity - * - GLSZM_GLNN - - Grey Level Non - Uniformity Normalized - * - GLSZM_SZN - - Size - Zone Non - Uniformity - * - GLSZM_SZNN - - Size - Zone Non - Uniformity Normalized - * - GLSZM_ZP - - Zone Percentage - * - GLSZM_GLV - - Grey Level Variance - * - GLSZM_ZV - - Zone Variance - * - GLSZM_ZE - - Zone Entropy - * - GLSZM_LGLZE - - Low Grey Level Zone Emphasis - * - GLSZM_HGLZE - - High Grey Level Zone Emphasis - * - GLSZM_SALGLE - - Small Area Low Grey Level Emphasis - * - GLSZM_SAHGLE - - Small Area High Grey Level Emphasis - * - GLSZM_LALGLE - - Large Area Low Grey Level Emphasis - * - GLSZM_LAHGLE - - Large Area High Grey Level Emphasis - * - GLDM_SDE - - Grey level dependency matrix (GLDM) based feature, Small Dependence Emphasis(SDE) - * - GLDM_LDE - - Large Dependence Emphasis (LDE) - * - GLDM_GLN - - Grey Level Non-Uniformity (GLN) - * - GLDM_DN - - Dependence Non-Uniformity (DN) - * - GLDM_DNN - - Dependence Non-Uniformity Normalized (DNN) - * - GLDM_GLV - - Grey Level Variance (GLV) - * - GLDM_DV - - Dependence Variance (DV) - * - GLDM_DE - - Dependence Entropy (DE) - * - GLDM_LGLE - - Low Grey Level Emphasis (LGLE) - * - GLDM_HGLE - - High Grey Level Emphasis (HGLE) - * - GLDM_SDLGLE - - Small Dependence Low Grey Level Emphasis (SDLGLE) - * - GLDM_SDHGLE - - Small Dependence High Grey Level Emphasis (SDHGLE) - * - GLDM_LDLGLE - - Large Dependence Low Grey Level Emphasis (LDLGLE) - * - GLDM_LDHGLE - - Large Dependence High Grey Level Emphasis (LDHGLE) - - * - NGLDM_LDE - - Low Dependence Emphasis - * - NGLDM_HDE - - High Dependence Emphasis - * - NGLDM_LGLCE - - Low Grey Level Count Emphasis - * - NGLDM_HGLCE - - High Grey Level Count Emphasis - * - NGLDM_LDLGLE - - Low Dependence Low Grey Level Emphasis - * - NGLDM_LDHGLE - - Low Dependence High Grey Level Emphasis - * - NGLDM_HDLGLE - - High Dependence Low Grey Level Emphasis - * - NGLDM_HDHGLE - - High Dependence High Grey Level Emphasis - * - NGLDM_GLNU - - Grey Level Non-Uniformity - * - NGLDM_GLNUN - - Grey Level Non-Uniformity Normalised - * - NGLDM_DCNU - - Dependence Count Non-Uniformity - * - NGLDM_DCNUN - - Dependence Count Non-Uniformity Normalised - * - NGLDM_GLM - - Grey Level Mean - * - NGLDM_GLV - - Grey Level Variance - * - NGLDM_DCM - - Dependence Count Mean - * - NGLDM_DCV - - Dependence Count Variance - * - NGLDM_DCE - - Dependence Count Entropy - * - NGLDM_DCENE - - Dependence Count Energy - - * - NGTDM_COARSENESS - - Neighbouring Grey Tone Difference Matrix (NGTDM) Features, Coarseness - * - NGTDM_CONTRAST - - NGTDM, Contrast - * - NGTDM_BUSYNESS - - NGTDM, Busyness - * - NGTDM_COMPLEXITY - - NGTDM, Complexity - * - NGTDM_STRENGTH - - NGTDM, Strength - - -**2D radial intensity distribution features:** - - -.. list-table:: - :header-rows: 1 - - * - Nyxus feature code - - Description - * - ZERNIKE2D - - Zernike features - * - FRAC_AT_D - - Fraction of total intensity at a given radius - * - MEAN_FRAC - - Mean fractional intensity at a given radius - * - RADIAL_CV - - Coefficient of variation of intensity within a ring (band), calculated across $n$ slices - - -**2D frequency and orientational features:** - - -.. list-table:: - :header-rows: 1 - - * - Nyxus feature code - - Description - * - GABOR - - A set of Gabor filters of varying frequencies and orientations - - -**2D shape image moments (calculated with constant pixel intensity 1.0 within the ROI segment):** - -.. list-table:: - :header-rows: 1 - - * - Nyxus feature code - - Description - * - SPAT_MOMENT_ - - Spatial (raw) moments of order 00, 01, 02, 03, 10, 11, 12, 20, 21, 30 - * - WEIGHTED_SPAT_MOMENT_ - - Spatial moments weighted by pixel distance to ROI edge of order 00, 01, 02, 03, 10, 11, 20, 21, 30 - * - CENTRAL_MOMENT_ - - Central moments of order 02, 03, 11, 12, 20, 21, 30 - * - WEIGHTED_CENTRAL_MOMENT_ - - Central moments weighted by pixel distance to ROI edge of order 02, 03, 11, 12, 20, 21, 30 - * - NORM_CENTRAL_MOMENT_ - - Normalized central moments of order 02, 03, 11, 12, 20, 21, 30 - * - NORM_SPAT_MOMENT_ - - Normalized (standardized) spatial moments of order 00, 01, 02, 03, 10, 20, 30 - * - HU_M<1-7> - - Hu's moments of order 1 to 7 - * - WEIGHTED_HU_M<1-7> - - Weighted Hu's moment of order 1-7 - -**2D intensity image moments (calculated with respect to pixels' actual intensities within the ROI segment):** - -.. list-table:: - :header-rows: 1 - - * - Nyxus feature code - - Description - * - IMOM_RM_ - - Spatial (raw) moments of order 00, 01, 02, 03, 10, 11, 12, 20, 21, 30 - * - IMOM_RM_01 - - of order 00, 01, 02, etc - * - IMOM_WRM_ - - Spatial moments weighted by pixel distance to ROI edge of order 00, 01, 02, 03, 10, 11, 12, 20, 21, 30 - * - IMOM_СM_ - - Central moments of order 02, 03, 11, 12, 20, 21, 30 - * - IMOM_WСM_ - - Central moments weighted by pixel distance to ROI edge of order 02, 03, 11, 12, 20, 21, 30 - * - IMOM_NСM_ - - Normalized central moments of order 02, 03, 11, 20, 21, 30 - * - IMOM_NRM_ - - Normalized (standardized) spatial moments of order 00, 01, 02, 03, 10, 20, 30 - * - IMOM_HU<1-7> - - Hu's moment 1-7 - * - IMOM_WHU<1-7> - - Weighted Hu's moment 1-7 + - vetted: MATLAB + +.. _features-erosion: + +Erosion +^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_erosion`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``EROSIONS_2_VANISH`` + - Number of binary erosions with a 3x3 cross-shaped structuring element, applied to the ROI mask inside its bounding box, before the one that leaves no pixel set in the eroded area; the first two and the last row and column of the box are never eroded, so a ROI that fills its box, such as a solid rectangle of at least 4x4 pixels, never vanishes and reports the cap of 1000. 0 for ROIs of a single intensity. + - regression + * - ``EROSIONS_2_VANISH_COMPLEMENT`` + - Not computed: the column is never assigned and keeps its initial value of 0. + - regression + +.. _features-fractal: + +Fractal dimension +^^^^^^^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_fractal_dimension`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``FRACT_DIM_BOXCOUNT`` + - Box-counting fractal dimension of the ROI mask: minus the least-squares slope of log box count versus log box size over power-of-two box sizes; ROIs whose padded side is 32 pixels or less take the smallest count over four grid offsets. + - vetted: FracLac + * - ``FRACT_DIM_PERIMETER`` + - Divider (Richardson) fractal dimension of the ROI's contour: 1 minus the least-squares slope of log perimeter versus log mean ruler length, where the contour is walked with strides of decreasing numbers of contour pixels. + - vetted: FracLac + +.. _features-geodetic: + +Geodetic length and thickness +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``GEODETIC_LENGTH`` + - Length of a rectangle with the same area and perimeter as the ROI: PERIMETER divided by 4 plus the square root of (PERIMETER squared divided by 16 minus the area), in pixels; when that difference is negative, as for a disk, no such rectangle exists and the value is PERIMETER divided by 4. + - vetted: IMEA + * - ``THICKNESS`` + - Width of a rectangle with the same area and perimeter as the ROI: PERIMETER divided by 2 minus GEODETIC_LENGTH, in pixels; equal to GEODETIC_LENGTH, PERIMETER divided by 4, when no such rectangle exists. + - vetted: IMEA + +.. _features-feret: + +Feret diameters +^^^^^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``MIN_FERET_ANGLE`` + - Rotation angle in degrees (0 to 180, in 10-degree steps) at which the ROI's convex hull has its smallest Feret diameter. + - regression + * - ``MAX_FERET_ANGLE`` + - Rotation angle in degrees (0 to 180, in 10-degree steps) at which the ROI's convex hull has its largest Feret diameter. + - regression + * - ``STAT_FERET_DIAM_MIN`` + - Smallest Feret (caliper) diameter of the ROI's convex hull over rotations from 0 to 180 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_FERET_DIAM_MAX`` + - Largest Feret (caliper) diameter of the ROI's convex hull over rotations from 0 to 180 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_FERET_DIAM_MEAN`` + - Mean Feret (caliper) diameter of the ROI's convex hull over rotations from 0 to 180 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_FERET_DIAM_MEDIAN`` + - Median Feret (caliper) diameter of the ROI's convex hull over rotations from 0 to 180 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_FERET_DIAM_STDDEV`` + - Population standard deviation of the Feret (caliper) diameters of the ROI's convex hull over rotations from 0 to 180 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_FERET_DIAM_MODE`` + - Mode of the Feret diameters of the ROI's convex hull over rotations from 0 to 180 degrees, taken over 1-pixel-wide bins and reported as the bin's whole-pixel value. + - regression + +.. _features-martin: + +Martin diameters +^^^^^^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``STAT_MARTIN_DIAM_MIN`` + - Smallest Martin diameter of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps; the Martin diameter is the length of the horizontal chord that splits the hull's area in half, in pixels. + - vetted: IMEA + * - ``STAT_MARTIN_DIAM_MAX`` + - Largest Martin diameter (area-bisecting horizontal chord) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_MARTIN_DIAM_MEAN`` + - Mean Martin diameter (area-bisecting horizontal chord) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_MARTIN_DIAM_MEDIAN`` + - Median Martin diameter (area-bisecting horizontal chord) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_MARTIN_DIAM_STDDEV`` + - Population standard deviation of the Martin diameters (area-bisecting horizontal chords) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_MARTIN_DIAM_MODE`` + - Mode of the Martin diameters of the ROI's convex hull over rotations from 0 to 170 degrees, taken over 1-pixel-wide bins and reported as the bin's whole-pixel value. + - regression + +.. _features-nassenstein: + +Nassenstein diameters +^^^^^^^^^^^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``STAT_NASSENSTEIN_DIAM_MIN`` + - Smallest Nassenstein diameter of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps; the Nassenstein diameter is the hull's vertical extent through its lowest point, in pixels. + - vetted: IMEA + * - ``STAT_NASSENSTEIN_DIAM_MAX`` + - Largest Nassenstein diameter (vertical extent through the lowest point) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_NASSENSTEIN_DIAM_MEAN`` + - Mean Nassenstein diameter (vertical extent through the lowest point) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_NASSENSTEIN_DIAM_MEDIAN`` + - Median Nassenstein diameter (vertical extent through the lowest point) of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_NASSENSTEIN_DIAM_STDDEV`` + - Population standard deviation of the Nassenstein diameters of the ROI's convex hull over rotations from 0 to 170 degrees in 10-degree steps, in pixels. + - vetted: IMEA + * - ``STAT_NASSENSTEIN_DIAM_MODE`` + - Mode of the Nassenstein diameters of the ROI's convex hull over rotations from 0 to 170 degrees, taken over 1-pixel-wide bins and reported as the bin's whole-pixel value. + - regression + +.. _features-chords: + +Chords +^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``MAXCHORDS_MAX`` + - Largest of the ROI's maximum chords, in pixels. At each of 20 rotations from 0 to 171 degrees in 9-degree steps, a chord is the longest vertical run of ROI pixels, ending before the raster's last row, in a sampled column of the rotated ROI (every column under 200 pixels of width, otherwise every (width/100)-th), and the maximum chord is the longest of them. + - regression + * - ``MAXCHORDS_MAX_ANG`` + - Rotation angle in radians at which MAXCHORDS_MAX occurs. + - regression + * - ``MAXCHORDS_MIN`` + - Smallest of the ROI's per-rotation maximum chords (see MAXCHORDS_MAX), one for each of the 20 rotations that has a chord, in pixels. + - regression + * - ``MAXCHORDS_MIN_ANG`` + - Rotation angle in radians at which MAXCHORDS_MIN occurs. + - regression + * - ``MAXCHORDS_MEDIAN`` + - Median of the ROI's per-rotation maximum chords, one for each of the 20 rotations that has a chord, in pixels. + - regression + * - ``MAXCHORDS_MEAN`` + - Mean of the ROI's per-rotation maximum chords, one for each of the 20 rotations that has a chord, in pixels. + - regression + * - ``MAXCHORDS_MODE`` + - Most frequent length among the ROI's per-rotation maximum chords, one for each of the 20 rotations that has a chord, in pixels; the smallest such length on ties. + - regression + * - ``MAXCHORDS_STDDEV`` + - Unbiased (n-1) standard deviation of the ROI's per-rotation maximum chords, one for each of the 20 rotations that has a chord, in pixels. + - regression + * - ``ALLCHORDS_MAX`` + - Longest of all the ROI's chords, in pixels: one chord per column, the column's longest vertical run of ROI pixels ending before the raster's last row, over the columns of the ROI rotated by 0 to 171 degrees in 9-degree steps; every column when the rotated ROI is under 200 pixels wide, otherwise every (width/100)-th column. + - regression + * - ``ALLCHORDS_MAX_ANG`` + - Rotation angle in radians at which ALLCHORDS_MAX occurs. + - regression + * - ``ALLCHORDS_MIN`` + - Shortest of all the ROI's chords (see ALLCHORDS_MAX), in pixels. + - vetted: IMEA + * - ``ALLCHORDS_MIN_ANG`` + - Rotation angle in radians at which ALLCHORDS_MIN occurs. + - regression + * - ``ALLCHORDS_MEDIAN`` + - Median length of all the ROI's chords, in pixels. + - regression + * - ``ALLCHORDS_MEAN`` + - Mean length of all the ROI's chords, in pixels. + - regression + * - ``ALLCHORDS_MODE`` + - Most frequent length among all the ROI's chords, in pixels; the smallest such length on ties. + - regression + * - ``ALLCHORDS_STDDEV`` + - Unbiased (n-1) standard deviation of the lengths of all the ROI's chords, in pixels. + - regression + +.. _features-neighbors: + +Neighbors +^^^^^^^^^ + +Two ROIs are neighbours when their contours come within ``--pixelDistance`` pixels of each other (5 by default), measured between contour pixels, not centroids. + +Definitions, formulas and settings: :doc:`/Math/f_morphology`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``NUM_NEIGHBORS`` + - Number of other ROIs whose contour comes within ``--pixelDistance`` pixels (default 5, Euclidean distance between contour pixels) of this ROI's contour. + - vetted: CellProfiler + * - ``PERCENT_TOUCHING`` + - Percentage of the ROI's contour pixels that are adjacent, including diagonally, to a contour pixel of another ROI. + - regression + * - ``CLOSEST_NEIGHBOR1_DIST`` + - Distance in pixels from the ROI's centroid to the centroid of its nearest neighbor among the ROIs counted in NUM_NEIGHBORS. + - vetted: CellProfiler + * - ``CLOSEST_NEIGHBOR1_ANG`` + - Direction in degrees (0 to 360, from the x axis with y increasing downward) from the ROI's centroid to the centroid of its nearest neighbor. + - vetted: analytic + * - ``CLOSEST_NEIGHBOR2_DIST`` + - Distance in pixels from the ROI's centroid to the centroid of its second-nearest neighbor among the ROIs counted in NUM_NEIGHBORS. + - vetted: analytic + * - ``CLOSEST_NEIGHBOR2_ANG`` + - Direction in degrees (0 to 360, from the x axis with y increasing downward) from the ROI's centroid to the centroid of its second-nearest neighbor. + - vetted: analytic + * - ``ANG_BW_NEIGHBORS_MEAN`` + - Arithmetic mean of the directions in degrees (0 to 360) from the ROI's centroid to the centroids of all its neighbors. + - vetted: analytic + * - ``ANG_BW_NEIGHBORS_STDDEV`` + - Unbiased (n-1) standard deviation of the directions in degrees from the ROI's centroid to the centroids of its neighbors; 0 with fewer than 3 neighbors (with 2, a known defect, https://github.com/PolusAI/nyxus/issues/499). + - vetted: analytic + * - ``ANG_BW_NEIGHBORS_MODE`` + - Most frequent direction, rounded to whole degrees, from the ROI's centroid to the centroids of its neighbors; the smallest such angle on ties. + - vetted: analytic + +.. _features-glcm: + +Grey-level co-occurrence matrix (GLCM) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The grey-level co-occurrence matrix counts pairs of ROI pixels one pixel apart, built separately for each angle in ``--glcmAngles`` (default 0, 45, 90 and 135 degrees); pairs with a pixel of stored intensity 0 are skipped, which covers every pixel outside the ROI and any ROI pixel whose value is 0. Each plain code is output as one column per angle, named with the angle as a suffix (for example GLCM_ASM_45), and its _AVE code is the mean of those columns. Grey levels come from ``--coarseGrayDepth`` binning or are the raw intensities under ``--ibsi``; each ordered pair is counted once with a positive grey depth, and the matrix is made symmetric with a negative or zero grey depth and under ``--ibsi``. + +Definitions, formulas and settings: :doc:`/Math/f_glcm`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``GLCM_ASM`` + - Angular second moment: the sum of the squared co-occurrence probabilities, which is high when a few grey-level pairs dominate (a uniform texture). + - vetted: PyRadiomics + * - ``GLCM_ACOR`` + - Autocorrelation: the co-occurrence-weighted mean of the product of the two grey levels of a pixel pair, which grows with both coarseness and brightness. + - vetted: MIRP + * - ``GLCM_CLUPROM`` + - Cluster prominence: the fourth moment of the sum of the two grey levels of a pair about twice the neighbour pixel's mean grey level, which is large when that distribution is asymmetric or heavy-tailed. + - vetted: PyRadiomics + * - ``GLCM_CLUSHADE`` + - Cluster shade: the third moment of the sum of the two grey levels of a pair about twice the neighbour pixel's mean grey level, which measures the skewness of the co-occurrence matrix. + - vetted: MIRP + * - ``GLCM_CLUTEND`` + - Cluster tendency: the second moment of the sum of the two grey levels of a pair about twice the neighbour pixel's mean grey level, which measures how strongly pairs group into similar grey levels. + - vetted: PyRadiomics + * - ``GLCM_CONTRAST`` + - Contrast: the co-occurrence-weighted mean squared grey-level difference between the two pixels of a pair, which grows with local intensity variation. + - vetted: PyRadiomics + * - ``GLCM_CORRELATION`` + - Correlation: the linear correlation, from -1 to 1, between the grey levels of the two pixels of a pair. + - vetted: PyRadiomics + * - ``GLCM_DIFAVE`` + - Difference average: the mean absolute grey-level difference between the two pixels of a pair, taken from the distribution of those differences. + - vetted: PyRadiomics + * - ``GLCM_DIFENTRO`` + - Difference entropy: the base-2 entropy of the distribution of absolute grey-level differences between paired pixels. + - vetted: PyRadiomics + * - ``GLCM_DIFVAR`` + - Difference variance: the variance of the absolute grey-level difference between paired pixels about its mean. + - vetted: PyRadiomics + * - ``GLCM_DIS`` + - Dissimilarity: the co-occurrence-weighted mean absolute difference between the grey levels of the two pixels of a pair, taken directly from the co-occurrence matrix. + - vetted: PyRadiomics + * - ``GLCM_ENERGY`` + - Energy: the sum of the squared co-occurrence probabilities; it returns the same value as GLCM_ASM. + - vetted: PyRadiomics + * - ``GLCM_ENTROPY`` + - Entropy: the base-2 entropy of the co-occurrence probabilities, which is high when many grey-level pairs are about equally likely; it returns the same value as GLCM_JE. + - vetted: MIRP + * - ``GLCM_HOM1`` + - Homogeneity 1: the co-occurrence probabilities weighted by one over one plus the grey-level difference of the pair; it returns the same value as GLCM_ID. + - vetted: PyRadiomics + * - ``GLCM_HOM2`` + - Homogeneity 2: the co-occurrence probabilities weighted by one over one plus the squared grey-level difference of the pair; it returns the same value as GLCM_IDM and has no _AVE code. + - vetted: MIRP + * - ``GLCM_ID`` + - Inverse difference: the co-occurrence probabilities weighted by one over one plus the grey-level difference of the pair, which is high when paired pixels have similar grey levels. + - vetted: PyRadiomics + * - ``GLCM_IDN`` + - Inverse difference normalized: inverse difference with the grey-level difference divided by the number of grey levels, which makes it less dependent on the number of grey levels. + - vetted: PyRadiomics + * - ``GLCM_IDM`` + - Inverse difference moment: the co-occurrence probabilities weighted by one over one plus the squared grey-level difference of the pair, a measure of local homogeneity. + - vetted: PyRadiomics + * - ``GLCM_IDMN`` + - Inverse difference moment normalized: inverse difference moment with the squared grey-level difference divided by the squared number of grey levels. + - vetted: PyRadiomics + * - ``GLCM_INFOMEAS1`` + - Informational measure of correlation 1: the joint entropy minus the entropy expected if the two grey levels were independent, divided by the entropy of one marginal distribution; it measures how much the two grey levels depend on each other. + - vetted: MIRP + * - ``GLCM_INFOMEAS2`` + - Informational measure of correlation 2: a 0-to-1 measure of the dependence between the two grey levels of a pair, computed from the gap between the joint entropy and the entropy of the product of the marginals. + - vetted: PyRadiomics + * - ``GLCM_IV`` + - Inverse variance: the distribution of grey-level differences between paired pixels weighted by one over the squared difference, leaving out pairs with equal grey levels. + - vetted: PyRadiomics + * - ``GLCM_JAVE`` + - Joint average: the co-occurrence-weighted mean grey level of the reference pixel of a pair. + - vetted: MIRP + * - ``GLCM_JE`` + - Joint entropy: the base-2 entropy of the co-occurrence probabilities, which is high when many grey-level pairs are about equally likely. + - vetted: PyRadiomics + * - ``GLCM_JMAX`` + - Joint maximum: the largest co-occurrence probability, that is the share of pairs taken by the most common grey-level pair. + - vetted: PyRadiomics + * - ``GLCM_JVAR`` + - Joint variance: the co-occurrence-weighted spread of the neighbour pixel's grey level around the joint average (the reference pixel's mean); it equals GLCM_VARIANCE only when the matrix is symmetric. + - vetted: MIRP + * - ``GLCM_SUMAVERAGE`` + - Sum average: the mean of the sum of the two grey levels of a pixel pair. + - vetted: MIRP + * - ``GLCM_SUMENTROPY`` + - Sum entropy: the base-2 entropy of the distribution of the sum of the two grey levels of a pixel pair. + - vetted: PyRadiomics + * - ``GLCM_SUMVARIANCE`` + - Sum variance: the spread of the sum of the two grey levels of a pair about twice the neighbour pixel's mean grey level; this code returns the cluster tendency value, the same as GLCM_CLUTEND. + - vetted: PyRadiomics + * - ``GLCM_VARIANCE`` + - Variance: the co-occurrence-weighted variance of the reference pixel's grey level about its mean (PyRadiomics sum of squares). + - vetted: IBSI + * - ``GLCM_ASM_AVE`` + - Angular second moment, averaged over the angles. + - vetted: PyRadiomics + * - ``GLCM_ACOR_AVE`` + - Autocorrelation, averaged over the angles. + - vetted: MIRP + * - ``GLCM_CLUPROM_AVE`` + - Cluster prominence, averaged over the angles. + - vetted: IBSI + * - ``GLCM_CLUSHADE_AVE`` + - Cluster shade, averaged over the angles. + - vetted: IBSI + * - ``GLCM_CLUTEND_AVE`` + - Cluster tendency, averaged over the angles. + - vetted: IBSI + * - ``GLCM_CONTRAST_AVE`` + - Contrast, averaged over the angles. + - vetted: PyRadiomics + * - ``GLCM_CORRELATION_AVE`` + - Correlation, averaged over the angles. + - vetted: PyRadiomics + * - ``GLCM_DIFAVE_AVE`` + - Difference average, averaged over the angles. + - vetted: IBSI + * - ``GLCM_DIFENTRO_AVE`` + - Difference entropy, averaged over the angles. + - vetted: IBSI + * - ``GLCM_DIFVAR_AVE`` + - Difference variance, averaged over the angles. + - vetted: IBSI + * - ``GLCM_DIS_AVE`` + - Dissimilarity, averaged over the angles. + - vetted: IBSI + * - ``GLCM_ENERGY_AVE`` + - Energy, averaged over the angles. + - vetted: PyRadiomics + * - ``GLCM_ENTROPY_AVE`` + - Entropy, averaged over the angles. + - vetted: IBSI + * - ``GLCM_HOM1_AVE`` + - Homogeneity 1, averaged over the angles. + - vetted: PyRadiomics + * - ``GLCM_ID_AVE`` + - Inverse difference, averaged over the angles. + - vetted: IBSI + * - ``GLCM_IDN_AVE`` + - Inverse difference normalized, averaged over the angles. + - vetted: PyRadiomics + * - ``GLCM_IDM_AVE`` + - Inverse difference moment, averaged over the angles. + - vetted: IBSI + * - ``GLCM_IDMN_AVE`` + - Inverse difference moment normalized, averaged over the angles. + - vetted: PyRadiomics + * - ``GLCM_IV_AVE`` + - Inverse variance, averaged over the angles. + - vetted: IBSI + * - ``GLCM_JAVE_AVE`` + - Joint average, averaged over the angles. + - vetted: IBSI + * - ``GLCM_JE_AVE`` + - Joint entropy, averaged over the angles. + - vetted: IBSI + * - ``GLCM_INFOMEAS1_AVE`` + - Informational measure of correlation 1, averaged over the angles. + - vetted: IBSI + * - ``GLCM_INFOMEAS2_AVE`` + - Informational measure of correlation 2, averaged over the angles. + - vetted: IBSI + * - ``GLCM_VARIANCE_AVE`` + - Variance, averaged over the angles. + - vetted: IBSI + * - ``GLCM_JMAX_AVE`` + - Joint maximum, averaged over the angles. + - vetted: IBSI + * - ``GLCM_JVAR_AVE`` + - Joint variance, averaged over the angles. + - vetted: IBSI + * - ``GLCM_SUMAVERAGE_AVE`` + - Sum average, averaged over the angles. + - vetted: MIRP + * - ``GLCM_SUMENTROPY_AVE`` + - Sum entropy, averaged over the angles. + - vetted: IBSI + * - ``GLCM_SUMVARIANCE_AVE`` + - Sum variance, averaged over the angles. + - vetted: IBSI + +.. _features-glrlm: + +Grey-level run-length matrix (GLRLM) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A run is a straight line of consecutive pixels with the same grey level; runs are collected separately along 0, 45, 90 and 135 degrees into a grey level by run length matrix. Each plain code is output as one column per angle (suffixes _0, _45, _90 and _135), and its _AVE code is the mean of the four. Grey levels come from ``--coarseGrayDepth`` binning, or are the raw intensities under ``--ibsi``. With a positive ``--coarseGrayDepth`` (the default), pixels of stored intensity 0, including every bounding-box pixel outside the ROI, are binned to grey level 1 and form runs; under ``--ibsi`` or a negative ``--coarseGrayDepth`` they are skipped. + +Definitions, formulas and settings: :doc:`/Math/f_glrlm`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``GLRLM_SRE`` + - Short run emphasis: the mean over all runs of one over the squared run length, which is high when short runs dominate (fine texture). + - vetted: PyRadiomics + * - ``GLRLM_LRE`` + - Long run emphasis: the mean over all runs of the squared run length, which is high when long runs dominate (coarse texture). + - vetted: PyRadiomics + * - ``GLRLM_GLN`` + - Grey level non-uniformity: the sum over grey levels of the squared number of runs at that level, divided by the number of runs; it is low when runs are spread evenly over the grey levels. + - vetted: PyRadiomics + * - ``GLRLM_GLNN`` + - Grey level non-uniformity normalized: grey level non-uniformity divided by the number of runs once more, giving a value between 0 and 1. + - vetted: PyRadiomics + * - ``GLRLM_RLN`` + - Run length non-uniformity: the sum over run lengths of the squared number of runs of that length, divided by the number of runs; it is low when run lengths are spread evenly. + - vetted: PyRadiomics + * - ``GLRLM_RLNN`` + - Run length non-uniformity normalized: run length non-uniformity divided by the number of runs once more, giving a value between 0 and 1. + - vetted: PyRadiomics + * - ``GLRLM_RP`` + - Run percentage: the number of runs divided by the number of ROI pixels, which is high for fine texture; with the default binning, runs of bounding-box pixels outside the mask are counted too, so it can exceed 1. + - vetted: PyRadiomics + * - ``GLRLM_GLV`` + - Grey level variance: the variance of the grey level over all runs. + - vetted: PyRadiomics + * - ``GLRLM_RV`` + - Run variance: the variance of the run length over all runs. + - vetted: PyRadiomics + * - ``GLRLM_RE`` + - Run entropy: the base-2 entropy of the joint distribution of run grey level and run length. + - vetted: PyRadiomics + * - ``GLRLM_LGLRE`` + - Low grey level run emphasis: the mean over all runs of one over the squared grey level, which is high when dark runs dominate. + - vetted: PyRadiomics + * - ``GLRLM_HGLRE`` + - High grey level run emphasis: the mean over all runs of the squared grey level, which is high when bright runs dominate. + - vetted: PyRadiomics + * - ``GLRLM_SRLGLE`` + - Short run low grey level emphasis: the mean over all runs of one over the squared run length times the squared grey level, which is high for short dark runs. + - vetted: PyRadiomics + * - ``GLRLM_SRHGLE`` + - Short run high grey level emphasis: the mean over all runs of the squared grey level over the squared run length, which is high for short bright runs. + - vetted: PyRadiomics + * - ``GLRLM_LRLGLE`` + - Long run low grey level emphasis: the mean over all runs of the squared run length over the squared grey level, which is high for long dark runs. + - vetted: PyRadiomics + * - ``GLRLM_LRHGLE`` + - Long run high grey level emphasis: the mean over all runs of the squared run length times the squared grey level, which is high for long bright runs. + - vetted: PyRadiomics + * - ``GLRLM_SRE_AVE`` + - Short run emphasis, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_LRE_AVE`` + - Long run emphasis, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_GLN_AVE`` + - Grey level non-uniformity, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_GLNN_AVE`` + - Grey level non-uniformity normalized, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_RLN_AVE`` + - Run length non-uniformity, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_RLNN_AVE`` + - Run length non-uniformity normalized, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_RP_AVE`` + - Run percentage, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_GLV_AVE`` + - Grey level variance, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_RV_AVE`` + - Run variance, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_RE_AVE`` + - Run entropy, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_LGLRE_AVE`` + - Low grey level run emphasis, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_HGLRE_AVE`` + - High grey level run emphasis, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_SRLGLE_AVE`` + - Short run low grey level emphasis, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_SRHGLE_AVE`` + - Short run high grey level emphasis, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_LRLGLE_AVE`` + - Long run low grey level emphasis, averaged over the angles. + - vetted: PyRadiomics + * - ``GLRLM_LRHGLE_AVE`` + - Long run high grey level emphasis, averaged over the angles. + - vetted: PyRadiomics + +.. _features-glszm: +Grey-level size-zone matrix (GLSZM) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -**2D neighbor features:** +A zone is a group of connected pixels sharing one grey level, grown from each unvisited pixel in scan order through same-level neighbours to its east, south-east, south and south-west; the matrix counts zones by grey level and size. The family has no directions, so each code is one value per ROI. Grey levels come from ``--coarseGrayDepth`` binning, or are the raw intensities under ``--ibsi``. With a positive ``--coarseGrayDepth`` (the default), pixels of stored intensity 0, including every bounding-box pixel outside the ROI, are binned to grey level 1 and form zones; under ``--ibsi`` or a negative ``--coarseGrayDepth`` they are skipped. + +Definitions, formulas and settings: :doc:`/Math/f_glszm`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``GLSZM_SAE`` + - Small area emphasis: the mean over all zones of one over the squared zone size, which is high when small zones dominate (fine texture); IBSI calls it small zone emphasis. + - vetted: MIRP + * - ``GLSZM_LAE`` + - Large area emphasis: the mean over all zones of the squared zone size, which is high when large zones dominate (coarse texture); IBSI calls it large zone emphasis. + - vetted: MIRP + * - ``GLSZM_GLN`` + - Grey level non-uniformity: the sum over grey levels of the squared number of zones at that level, divided by the number of zones; it is low when zones are spread evenly over the grey levels. + - vetted: MIRP + * - ``GLSZM_GLNN`` + - Grey level non-uniformity normalized: grey level non-uniformity divided by the number of zones once more, giving a value between 0 and 1. + - vetted: MIRP + * - ``GLSZM_SZN`` + - Size zone non-uniformity: the sum over zone sizes of the squared number of zones of that size, divided by the number of zones; it is low when zone sizes are spread evenly. + - vetted: MIRP + * - ``GLSZM_SZNN`` + - Size zone non-uniformity normalized: size zone non-uniformity divided by the number of zones once more, giving a value between 0 and 1. + - vetted: MIRP + * - ``GLSZM_ZP`` + - Zone percentage: the number of zones divided by the number of pixels in zones, which is high for fine texture; with the default binning every pixel of the bounding box is in a zone, inside the mask or not. + - vetted: MIRP + * - ``GLSZM_GLV`` + - Grey level variance: the variance of the grey level over all zones. + - vetted: MIRP + * - ``GLSZM_ZV`` + - Zone variance: the variance of the zone size over all zones. + - vetted: MIRP + * - ``GLSZM_ZE`` + - Zone entropy: the base-2 entropy of the joint distribution of zone grey level and zone size. + - vetted: MIRP + * - ``GLSZM_LGLZE`` + - Low grey level zone emphasis: the mean over all zones of one over the squared grey level, which is high when dark zones dominate. + - vetted: MIRP + * - ``GLSZM_HGLZE`` + - High grey level zone emphasis: the mean over all zones of the squared grey level, which is high when bright zones dominate. + - vetted: MIRP + * - ``GLSZM_SALGLE`` + - Small area low grey level emphasis: the mean over all zones of one over the squared zone size times the squared grey level, which is high for small dark zones. + - vetted: MIRP + * - ``GLSZM_SAHGLE`` + - Small area high grey level emphasis: the mean over all zones of the squared grey level over the squared zone size, which is high for small bright zones. + - vetted: MIRP + * - ``GLSZM_LALGLE`` + - Large area low grey level emphasis: the mean over all zones of the squared zone size over the squared grey level, which is high for large dark zones. + - vetted: MIRP + * - ``GLSZM_LAHGLE`` + - Large area high grey level emphasis: the mean over all zones of the squared zone size times the squared grey level, which is high for large bright zones. + - vetted: MIRP + +.. _features-gldm: + +Grey-level dependence matrix (GLDM) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A pixel's dependence is one plus the number of its 8 neighbours of nonzero stored intensity that have exactly the same grey level, and the matrix counts the ROI pixels of nonzero stored intensity by grey level and dependence. The family has no directions, so each code is one value per ROI. Grey levels come from ``--coarseGrayDepth`` binning, or are the raw intensities under ``--ibsi``. + +Definitions, formulas and settings: :doc:`/Math/f_gldm`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``GLDM_SDE`` + - Small dependence emphasis: the mean over all pixels of one over the squared dependence, which is high when pixels have few same-level neighbours (heterogeneous texture). + - vetted: PyRadiomics + * - ``GLDM_LDE`` + - Large dependence emphasis: the mean over all pixels of the squared dependence, which is high when pixels have many same-level neighbours (homogeneous texture). + - vetted: PyRadiomics + * - ``GLDM_GLN`` + - Grey level non-uniformity: the sum over grey levels of the squared number of pixels at that level, divided by the number of pixels; it is low when the grey levels are evenly represented. + - vetted: PyRadiomics + * - ``GLDM_DN`` + - Dependence non-uniformity: the sum over dependence values of the squared number of pixels with that dependence, divided by the number of pixels; it is low when dependences are spread evenly. + - vetted: PyRadiomics + * - ``GLDM_DNN`` + - Dependence non-uniformity normalized: dependence non-uniformity divided by the number of pixels once more, giving a value between 0 and 1. + - vetted: PyRadiomics + * - ``GLDM_GLV`` + - Grey level variance: the variance of the grey level over the dependence matrix. + - vetted: PyRadiomics + * - ``GLDM_DV`` + - Dependence variance: the variance of the dependence over the dependence matrix. + - vetted: PyRadiomics + * - ``GLDM_DE`` + - Dependence entropy: the base-2 entropy of the joint distribution of grey level and dependence. + - vetted: PyRadiomics + * - ``GLDM_LGLE`` + - Low grey level emphasis: the mean over all pixels of one over the squared grey level, which is high when dark pixels dominate. + - vetted: PyRadiomics + * - ``GLDM_HGLE`` + - High grey level emphasis: the mean over all pixels of the squared grey level, which is high when bright pixels dominate. + - vetted: PyRadiomics + * - ``GLDM_SDLGLE`` + - Small dependence low grey level emphasis: the mean over all pixels of one over the squared dependence times the squared grey level, which is high for dark pixels with few same-level neighbours. + - vetted: PyRadiomics + * - ``GLDM_SDHGLE`` + - Small dependence high grey level emphasis: the mean over all pixels of the squared grey level over the squared dependence, which is high for bright pixels with few same-level neighbours. + - vetted: PyRadiomics + * - ``GLDM_LDLGLE`` + - Large dependence low grey level emphasis: the mean over all pixels of the squared dependence over the squared grey level, which is high for dark pixels with many same-level neighbours. + - vetted: PyRadiomics + * - ``GLDM_LDHGLE`` + - Large dependence high grey level emphasis: the mean over all pixels of the squared dependence times the squared grey level, which is high for bright pixels with many same-level neighbours. + - vetted: PyRadiomics + +.. _features-gldzm: + +Grey-level distance-zone matrix (GLDZM) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A zone is an 8-connected group of pixels sharing one grey level. Its distance is the smallest, over its pixels, of the steps along a row or column to the nearest pixel of stored intensity 0 plus one, or to the bounding-box edge, where a pixel on the bounding-box edge is at distance 1. With a positive ``--coarseGrayDepth`` (the default) the pixels outside the ROI are binned to grey level 1 and form zones like ROI pixels, so only the bounding-box edge counts; under ``--ibsi`` pixels of stored intensity 0 are skipped. The matrix counts zones by grey level and distance; each code is one value per ROI. Grey levels come from ``--coarseGrayDepth`` binning, or are the raw intensities under ``--ibsi``. + +Definitions, formulas and settings: :doc:`/Math/f_gldzm`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``GLDZM_SDE`` + - Small distance emphasis: the mean over all zones of one over the squared zone distance, which is high when zones lie close to the ROI edge. + - vetted: MIRP + * - ``GLDZM_LDE`` + - Large distance emphasis: the mean over all zones of the squared zone distance, which is high when zones lie deep inside the ROI. + - vetted: MIRP + * - ``GLDZM_LGLZE`` + - Low grey level zone emphasis: the mean over all zones of one over the squared grey level, which is high when dark zones dominate. + - vetted: MIRP + * - ``GLDZM_HGLZE`` + - High grey level zone emphasis: the mean over all zones of the squared grey level, which is high when bright zones dominate. + - vetted: MIRP + * - ``GLDZM_SDLGLE`` + - Small distance low grey level emphasis: the mean over all zones of one over the squared zone distance times the squared grey level, which is high for dark zones near the ROI edge. + - vetted: MIRP + * - ``GLDZM_SDHGLE`` + - Small distance high grey level emphasis: the mean over all zones of the squared grey level over the squared zone distance, which is high for bright zones near the ROI edge. + - vetted: MIRP + * - ``GLDZM_LDLGLE`` + - Large distance low grey level emphasis: the mean over all zones of the squared zone distance over the squared grey level, which is high for dark zones deep inside the ROI. + - vetted: MIRP + * - ``GLDZM_LDHGLE`` + - Large distance high grey level emphasis: the mean over all zones of the squared zone distance times the squared grey level, which is high for bright zones deep inside the ROI. + - vetted: MIRP + * - ``GLDZM_GLNU`` + - Grey level non-uniformity: the sum over grey levels of the squared number of zones at that level, divided by the number of zones; it is low when zones are spread evenly over the grey levels. + - vetted: MIRP + * - ``GLDZM_GLNUN`` + - Grey level non-uniformity normalized: grey level non-uniformity divided by the number of zones once more, giving a value between 0 and 1. + - vetted: MIRP + * - ``GLDZM_ZDNU`` + - Zone distance non-uniformity: the sum over distances of the squared number of zones at that distance, divided by the number of zones; it is low when zone distances are spread evenly. + - vetted: MIRP + * - ``GLDZM_ZDNUN`` + - Zone distance non-uniformity normalized: zone distance non-uniformity divided by the number of zones once more, giving a value between 0 and 1. + - vetted: MIRP + * - ``GLDZM_ZP`` + - Zone percentage: the number of zones divided by the number of ROI pixels, which is high for fine texture. + - vetted: MIRP + * - ``GLDZM_GLM`` + - Grey level mean: the mean grey level over all zones. + - regression + * - ``GLDZM_GLV`` + - Grey level variance: the variance of the grey level over all zones. + - vetted: MIRP + * - ``GLDZM_ZDM`` + - Zone distance mean: the mean zone distance over all zones. + - regression + * - ``GLDZM_ZDV`` + - Zone distance variance: the variance of the zone distance over all zones. + - vetted: MIRP + * - ``GLDZM_ZDE`` + - Zone distance entropy: the base-2 entropy of the joint distribution of zone grey level and zone distance. + - vetted: MIRP + +.. _features-ngldm: + +Neighbouring grey-level dependence matrix (NGLDM) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Every ROI pixel is a neighbourhood centre whose dependence count is one plus the number of its 8 neighbours inside the ROI with the same grey level; the matrix counts centres by grey level and dependence count. Each code is one value per ROI. Grey levels come from scaling the range 0 to the ROI maximum by ``--coarseGrayDepth`` and truncating (levels 0 to ``--coarseGrayDepth``), or are the raw intensities under ``--ibsi``. + +Definitions, formulas and settings: :doc:`/Math/f_ngldm`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``NGLDM_LDE`` + - Low dependence emphasis: the mean over all ROI pixels of one over the squared dependence count, which is high when pixels have few same-level neighbours. + - vetted: MIRP + * - ``NGLDM_HDE`` + - High dependence emphasis: the mean over all ROI pixels of the squared dependence count, which is high when pixels have many same-level neighbours. + - vetted: MIRP + * - ``NGLDM_LGLCE`` + - Low grey level count emphasis: the mean over all ROI pixels of one over the squared grey level, which is high when dark pixels dominate. + - vetted: MIRP + * - ``NGLDM_HGLCE`` + - High grey level count emphasis: the mean over all ROI pixels of the squared grey level, which is high when bright pixels dominate. + - vetted: MIRP + * - ``NGLDM_LDLGLE`` + - Low dependence low grey level emphasis: the mean over all ROI pixels of one over the squared dependence count times the squared grey level, which is high for dark pixels with few same-level neighbours. + - vetted: MIRP + * - ``NGLDM_LDHGLE`` + - Low dependence high grey level emphasis: the mean over all ROI pixels of the squared grey level over the squared dependence count, which is high for bright pixels with few same-level neighbours. + - vetted: MIRP + * - ``NGLDM_HDLGLE`` + - High dependence low grey level emphasis: the mean over all ROI pixels of the squared dependence count over the squared grey level, which is high for dark pixels with many same-level neighbours. + - vetted: MIRP + * - ``NGLDM_HDHGLE`` + - High dependence high grey level emphasis: the mean over all ROI pixels of the squared dependence count times the squared grey level, which is high for bright pixels with many same-level neighbours. + - vetted: MIRP + * - ``NGLDM_GLNU`` + - Grey level non-uniformity: the sum over grey levels of the squared number of pixels at that level, divided by the number of pixels; it is low when the grey levels are evenly represented. + - vetted: MIRP + * - ``NGLDM_GLNUN`` + - Grey level non-uniformity normalized: grey level non-uniformity divided by the number of pixels once more, giving a value between 0 and 1. + - vetted: MIRP + * - ``NGLDM_DCNU`` + - Dependence count non-uniformity: the sum over dependence counts of the squared number of pixels with that count, divided by the number of pixels; it is low when dependence counts are spread evenly. + - vetted: MIRP + * - ``NGLDM_DCNUN`` + - Dependence count non-uniformity normalized: dependence count non-uniformity divided by the number of pixels once more, giving a value between 0 and 1. + - vetted: MIRP + * - ``NGLDM_DCP`` + - Dependence count percentage: the fraction of ROI pixels that serve as neighbourhood centres; every ROI pixel is one, so the value is always 1. + - vetted: MIRP + * - ``NGLDM_GLM`` + - Grey level mean: the mean grey level over the dependence matrix. + - regression + * - ``NGLDM_GLV`` + - Grey level variance: the variance of the grey level over the dependence matrix. + - vetted: MIRP + * - ``NGLDM_DCM`` + - Dependence count mean: the mean dependence count over the dependence matrix. + - regression + * - ``NGLDM_DCV`` + - Dependence count variance: the variance of the dependence count over the dependence matrix. + - vetted: MIRP + * - ``NGLDM_DCENT`` + - Dependence count entropy: the base-2 entropy of the joint distribution of grey level and dependence count. + - vetted: MIRP + * - ``NGLDM_DCENE`` + - Dependence count energy: the sum of the squared probabilities of the dependence matrix, which is high when a few combinations of grey level and dependence count dominate. + - vetted: MIRP + +.. _features-ngtdm: + +Neighbourhood grey-tone difference matrix (NGTDM) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For each pixel of nonzero binned grey level that has at least one such neighbour, the matrix accumulates the absolute difference between its grey level and the average grey level of its 8 neighbours (neighbours whose binned grey level is 0 are left out), grouped by grey level, together with the probability of each grey level. Each code is one value per ROI. Grey levels come from ``--coarseGrayDepth`` binning, or are the raw intensities under ``--ibsi``. With a positive ``--coarseGrayDepth`` (the default), pixels outside the ROI are binned to grey level 1 and take part both as centres and as neighbours. + +Definitions, formulas and settings: :doc:`/Math/f_ngtdm`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``NGTDM_COARSENESS`` + - Coarseness: one over the probability-weighted sum of the differences between each grey level and its neighbourhood averages, which is high when intensity changes slowly (large uniform patches). + - vetted: MIRP + * - ``NGTDM_CONTRAST`` + - Contrast: the spread of the grey levels present multiplied by the mean difference between a pixel and its neighbourhood average, which is high when both the dynamic range and the local variation are large. + - vetted: MIRP + * - ``NGTDM_BUSYNESS`` + - Busyness: the probability-weighted neighbourhood differences divided by the summed differences between probability-weighted grey levels, which is high when intensity changes rapidly from pixel to pixel. + - vetted: MIRP + * - ``NGTDM_COMPLEXITY`` + - Complexity: the grey-level differences between pairs of levels weighted by their neighbourhood differences, which is high when the image has many abrupt intensity changes. + - vetted: MIRP + * - ``NGTDM_STRENGTH`` + - Strength: the probability-weighted squared differences between grey levels divided by the summed neighbourhood differences, which is high when the image has large, distinct regions of different intensity. + - vetted: MIRP + +.. _features-shape-moments: + +Shape moments +^^^^^^^^^^^^^ + +Geometric moments of the ROI's shape: every ROI pixel counts as 1. The two digits of a code are the powers of x and y. The weighted moments weight each pixel by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour (taken one pixel down and to the right of the ROI), found by a sampled search that can overestimate it. + +Definitions, formulas and settings: :doc:`/Math/f_2dmoments`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``SPAT_MOMENT_00`` + - Raw moment of the ROI shape: the sum of x to the power 0 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_01`` + - Raw moment of the ROI shape: the sum of x to the power 0 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_02`` + - Raw moment of the ROI shape: the sum of x to the power 0 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_03`` + - Raw moment of the ROI shape: the sum of x to the power 0 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_10`` + - Raw moment of the ROI shape: the sum of x to the power 1 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_11`` + - Raw moment of the ROI shape: the sum of x to the power 1 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_12`` + - Raw moment of the ROI shape: the sum of x to the power 1 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_13`` + - Raw moment of the ROI shape: the sum of x to the power 1 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_20`` + - Raw moment of the ROI shape: the sum of x to the power 2 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_21`` + - Raw moment of the ROI shape: the sum of x to the power 2 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_22`` + - Raw moment of the ROI shape: the sum of x to the power 2 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_23`` + - Raw moment of the ROI shape: the sum of x to the power 2 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``SPAT_MOMENT_30`` + - Raw moment of the ROI shape: the sum of x to the power 3 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_00`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 0 times (y - yc) to the power 0 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_01`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 0 times (y - yc) to the power 1 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_02`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 0 times (y - yc) to the power 2 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_03`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 0 times (y - yc) to the power 3 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_10`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 1 times (y - yc) to the power 0 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_11`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 1 times (y - yc) to the power 1 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_12`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 1 times (y - yc) to the power 2 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_13`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 1 times (y - yc) to the power 3 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_20`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 2 times (y - yc) to the power 0 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_21`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 2 times (y - yc) to the power 1 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_22`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 2 times (y - yc) to the power 2 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_23`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 2 times (y - yc) to the power 3 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_30`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 3 times (y - yc) to the power 0 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_31`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 3 times (y - yc) to the power 1 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_32`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 3 times (y - yc) to the power 2 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``CENTRAL_MOMENT_33`` + - Central moment of the ROI shape: the sum of (x - xc) to the power 3 times (y - yc) to the power 3 over the ROI's pixels, where (xc, yc) is the centroid. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_00`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_00 divided by SPAT_MOMENT_00 to the power (0+0)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_01`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_01 divided by SPAT_MOMENT_00 to the power (0+1)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_02`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_02 divided by SPAT_MOMENT_00 to the power (0+2)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_03`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_03 divided by SPAT_MOMENT_00 to the power (0+3)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_10`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_10 divided by SPAT_MOMENT_00 to the power (1+0)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_11`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_11 divided by SPAT_MOMENT_00 to the power (1+1)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_12`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_12 divided by SPAT_MOMENT_00 to the power (1+2)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_13`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_13 divided by SPAT_MOMENT_00 to the power (1+3)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_20`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_20 divided by SPAT_MOMENT_00 to the power (2+0)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_21`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_21 divided by SPAT_MOMENT_00 to the power (2+1)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_22`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_22 divided by SPAT_MOMENT_00 to the power (2+2)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_23`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_23 divided by SPAT_MOMENT_00 to the power (2+3)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_30`` + - Normalized raw moment of the ROI shape: SPAT_MOMENT_30 divided by SPAT_MOMENT_00 to the power (3+0)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_31`` + - Normalized raw moment of the ROI shape: the raw moment of order (3, 1) (the sum of x to the power 3 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner), divided by SPAT_MOMENT_00 to the power (3+1)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_32`` + - Normalized raw moment of the ROI shape: the raw moment of order (3, 2) (the sum of x to the power 3 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner), divided by SPAT_MOMENT_00 to the power (3+2)/2 + 1. + - vetted: scikit-image + * - ``NORM_SPAT_MOMENT_33`` + - Normalized raw moment of the ROI shape: the raw moment of order (3, 3) (the sum of x to the power 3 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner), divided by SPAT_MOMENT_00 to the power (3+3)/2 + 1. + - vetted: scikit-image + * - ``NORM_CENTRAL_MOMENT_02`` + - Normalized central moment of the ROI shape: CENTRAL_MOMENT_02 divided by SPAT_MOMENT_00 to the power (0+2)/2 + 1. + - vetted: scikit-image + * - ``NORM_CENTRAL_MOMENT_03`` + - Normalized central moment of the ROI shape: CENTRAL_MOMENT_03 divided by SPAT_MOMENT_00 to the power (0+3)/2 + 1. + - vetted: scikit-image + * - ``NORM_CENTRAL_MOMENT_11`` + - Normalized central moment of the ROI shape: CENTRAL_MOMENT_11 divided by SPAT_MOMENT_00 to the power (1+1)/2 + 1. + - vetted: scikit-image + * - ``NORM_CENTRAL_MOMENT_12`` + - Normalized central moment of the ROI shape: CENTRAL_MOMENT_12 divided by SPAT_MOMENT_00 to the power (1+2)/2 + 1. + - vetted: scikit-image + * - ``NORM_CENTRAL_MOMENT_20`` + - Normalized central moment of the ROI shape: CENTRAL_MOMENT_20 divided by SPAT_MOMENT_00 to the power (2+0)/2 + 1. + - vetted: scikit-image + * - ``NORM_CENTRAL_MOMENT_21`` + - Normalized central moment of the ROI shape: CENTRAL_MOMENT_21 divided by SPAT_MOMENT_00 to the power (2+1)/2 + 1. + - vetted: scikit-image + * - ``NORM_CENTRAL_MOMENT_30`` + - Normalized central moment of the ROI shape: CENTRAL_MOMENT_30 divided by SPAT_MOMENT_00 to the power (3+0)/2 + 1. + - vetted: scikit-image + * - ``HU_M1`` + - Hu's invariant number 1 of the ROI shape, computed from its normalized central moments. + - vetted: scikit-image + * - ``HU_M2`` + - Hu's invariant number 2 of the ROI shape, computed from its normalized central moments. + - vetted: scikit-image + * - ``HU_M3`` + - Hu's invariant number 3 of the ROI shape, computed from its normalized central moments. + - vetted: scikit-image + * - ``HU_M4`` + - Hu's invariant number 4 of the ROI shape, computed from its normalized central moments. + - vetted: scikit-image + * - ``HU_M5`` + - Hu's invariant number 5 of the ROI shape, computed from its normalized central moments. + - vetted: scikit-image + * - ``HU_M6`` + - Hu's invariant number 6 of the ROI shape, computed from its normalized central moments. + - vetted: scikit-image + * - ``HU_M7`` + - Hu's invariant number 7 of the ROI shape, computed from its normalized central moments. + - vetted: scikit-image + * - ``WEIGHTED_SPAT_MOMENT_00`` + - Raw moment of order (0, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_SPAT_MOMENT_01`` + - Raw moment of order (0, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_SPAT_MOMENT_02`` + - Raw moment of order (0, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_SPAT_MOMENT_03`` + - Raw moment of order (0, 3) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_SPAT_MOMENT_10`` + - Raw moment of order (1, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_SPAT_MOMENT_11`` + - Raw moment of order (1, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_SPAT_MOMENT_12`` + - Raw moment of order (1, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_SPAT_MOMENT_20`` + - Raw moment of order (2, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_SPAT_MOMENT_21`` + - Raw moment of order (2, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_SPAT_MOMENT_30`` + - Raw moment of order (3, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_CENTRAL_MOMENT_02`` + - Central moment of order (0, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_CENTRAL_MOMENT_03`` + - Central moment of order (0, 3) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_CENTRAL_MOMENT_11`` + - Central moment of order (1, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_CENTRAL_MOMENT_12`` + - Central moment of order (1, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_CENTRAL_MOMENT_20`` + - Central moment of order (2, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_CENTRAL_MOMENT_21`` + - Central moment of order (2, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_CENTRAL_MOMENT_30`` + - Central moment of order (3, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WT_NORM_CTR_MOM_02`` + - Normalized central moment of order (0, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WT_NORM_CTR_MOM_03`` + - Normalized central moment of order (0, 3) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WT_NORM_CTR_MOM_11`` + - Normalized central moment of order (1, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WT_NORM_CTR_MOM_12`` + - Normalized central moment of order (1, 2) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WT_NORM_CTR_MOM_20`` + - Normalized central moment of order (2, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WT_NORM_CTR_MOM_21`` + - Normalized central moment of order (2, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WT_NORM_CTR_MOM_30`` + - Normalized central moment of order (3, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_HU_M1`` + - Hu's invariant number 1 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_HU_M2`` + - Hu's invariant number 2 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_HU_M3`` + - Hu's invariant number 3 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_HU_M4`` + - Hu's invariant number 4 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_HU_M5`` + - Hu's invariant number 5 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_HU_M6`` + - Hu's invariant number 6 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + * - ``WEIGHTED_HU_M7`` + - Hu's invariant number 7 of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. + - regression + +.. _features-intensity-moments: + +Intensity moments +^^^^^^^^^^^^^^^^^ + +The same moments with each pixel weighted by its intensity, in grey levels. The two digits of a code are the powers of x and y. The weighted moments multiply each intensity by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour (taken one pixel down and to the right of the ROI), found by a sampled search that can overestimate it. + +Definitions, formulas and settings: :doc:`/Math/f_2dmoments`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``IMOM_RM_00`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 0 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_01`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 0 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_02`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 0 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_03`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 0 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_10`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 1 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_11`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 1 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_12`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 1 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_13`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 1 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_20`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 2 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_21`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 2 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_22`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 2 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_23`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 2 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_RM_30`` + - Raw moment of the ROI's intensities: the sum of the intensity times x to the power 3 times y to the power 0 over the ROI's pixels, with x and y measured from the bounding box corner. + - vetted: scikit-image + * - ``IMOM_CM_00`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 0 times (y - yc) to the power 0, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_01`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 0 times (y - yc) to the power 1, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_02`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 0 times (y - yc) to the power 2, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_03`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 0 times (y - yc) to the power 3, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_10`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 1 times (y - yc) to the power 0, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_11`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 1 times (y - yc) to the power 1, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_12`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 1 times (y - yc) to the power 2, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_13`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 1 times (y - yc) to the power 3, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_20`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 2 times (y - yc) to the power 0, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_21`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 2 times (y - yc) to the power 1, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_22`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 2 times (y - yc) to the power 2, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_23`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 2 times (y - yc) to the power 3, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_30`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 3 times (y - yc) to the power 0, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_31`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 3 times (y - yc) to the power 1, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_32`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 3 times (y - yc) to the power 2, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_CM_33`` + - Central moment of the ROI's intensities: the sum of the intensity times (x - xc) to the power 3 times (y - yc) to the power 3, where (xc, yc) is the intensity-weighted centroid. + - vetted: scikit-image + * - ``IMOM_NRM_00`` + - Normalized raw intensity moment: IMOM_RM_00 divided by IMOM_RM_00 to the power (0+0)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_01`` + - Normalized raw intensity moment: IMOM_RM_01 divided by IMOM_RM_00 to the power (0+1)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_02`` + - Normalized raw intensity moment: IMOM_RM_02 divided by IMOM_RM_00 to the power (0+2)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_03`` + - Normalized raw intensity moment: IMOM_RM_03 divided by IMOM_RM_00 to the power (0+3)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_10`` + - Normalized raw intensity moment: IMOM_RM_10 divided by IMOM_RM_00 to the power (1+0)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_11`` + - Normalized raw intensity moment: IMOM_RM_11 divided by IMOM_RM_00 to the power (1+1)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_12`` + - Normalized raw intensity moment: IMOM_RM_12 divided by IMOM_RM_00 to the power (1+2)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_13`` + - Normalized raw intensity moment: IMOM_RM_13 divided by IMOM_RM_00 to the power (1+3)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_20`` + - Normalized raw intensity moment: IMOM_RM_20 divided by IMOM_RM_00 to the power (2+0)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_21`` + - Normalized raw intensity moment: IMOM_RM_21 divided by IMOM_RM_00 to the power (2+1)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_22`` + - Normalized raw intensity moment: IMOM_RM_22 divided by IMOM_RM_00 to the power (2+2)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_23`` + - Normalized raw intensity moment: IMOM_RM_23 divided by IMOM_RM_00 to the power (2+3)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_30`` + - Normalized raw intensity moment: IMOM_RM_30 divided by IMOM_RM_00 to the power (3+0)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_31`` + - Normalized raw intensity moment: the raw intensity moment of order (3, 1) (the sum of the intensity times x to the power 3 times y to the power 1 over the ROI's pixels, with x and y measured from the bounding box corner), divided by IMOM_RM_00 to the power (3+1)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_32`` + - Normalized raw intensity moment: the raw intensity moment of order (3, 2) (the sum of the intensity times x to the power 3 times y to the power 2 over the ROI's pixels, with x and y measured from the bounding box corner), divided by IMOM_RM_00 to the power (3+2)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NRM_33`` + - Normalized raw intensity moment: the raw intensity moment of order (3, 3) (the sum of the intensity times x to the power 3 times y to the power 3 over the ROI's pixels, with x and y measured from the bounding box corner), divided by IMOM_RM_00 to the power (3+3)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NCM_02`` + - Normalized central intensity moment: IMOM_CM_02 divided by IMOM_RM_00 to the power (0+2)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NCM_03`` + - Normalized central intensity moment: IMOM_CM_03 divided by IMOM_RM_00 to the power (0+3)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NCM_11`` + - Normalized central intensity moment: IMOM_CM_11 divided by IMOM_RM_00 to the power (1+1)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NCM_12`` + - Normalized central intensity moment: IMOM_CM_12 divided by IMOM_RM_00 to the power (1+2)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NCM_20`` + - Normalized central intensity moment: IMOM_CM_20 divided by IMOM_RM_00 to the power (2+0)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NCM_21`` + - Normalized central intensity moment: IMOM_CM_21 divided by IMOM_RM_00 to the power (2+1)/2 + 1. + - vetted: scikit-image + * - ``IMOM_NCM_30`` + - Normalized central intensity moment: IMOM_CM_30 divided by IMOM_RM_00 to the power (3+0)/2 + 1. + - vetted: scikit-image + * - ``IMOM_HU1`` + - Hu's invariant number 1 of the ROI's intensities, computed from the normalized central intensity moments. + - vetted: scikit-image + * - ``IMOM_HU2`` + - Hu's invariant number 2 of the ROI's intensities, computed from the normalized central intensity moments. + - vetted: scikit-image + * - ``IMOM_HU3`` + - Hu's invariant number 3 of the ROI's intensities, computed from the normalized central intensity moments. + - vetted: scikit-image + * - ``IMOM_HU4`` + - Hu's invariant number 4 of the ROI's intensities, computed from the normalized central intensity moments. + - vetted: scikit-image + * - ``IMOM_HU5`` + - Hu's invariant number 5 of the ROI's intensities, computed from the normalized central intensity moments. + - vetted: scikit-image + * - ``IMOM_HU6`` + - Hu's invariant number 6 of the ROI's intensities, computed from the normalized central intensity moments. + - vetted: scikit-image + * - ``IMOM_HU7`` + - Hu's invariant number 7 of the ROI's intensities, computed from the normalized central intensity moments. + - vetted: scikit-image + * - ``IMOM_WRM_00`` + - Raw intensity moment of order (0, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WRM_01`` + - Raw intensity moment of order (0, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WRM_02`` + - Raw intensity moment of order (0, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WRM_03`` + - Raw intensity moment of order (0, 3) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WRM_10`` + - Raw intensity moment of order (1, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WRM_11`` + - Raw intensity moment of order (1, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WRM_12`` + - Raw intensity moment of order (1, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WRM_20`` + - Raw intensity moment of order (2, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WRM_21`` + - Raw intensity moment of order (2, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WRM_30`` + - Raw intensity moment of order (3, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WCM_02`` + - Central intensity moment of order (0, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WCM_03`` + - Central intensity moment of order (0, 3) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WCM_11`` + - Central intensity moment of order (1, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WCM_12`` + - Central intensity moment of order (1, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WCM_20`` + - Central intensity moment of order (2, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WCM_21`` + - Central intensity moment of order (2, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WCM_30`` + - Central intensity moment of order (3, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WNCM_02`` + - Normalized central intensity moment of order (0, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WNCM_03`` + - Normalized central intensity moment of order (0, 3) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WNCM_11`` + - Normalized central intensity moment of order (1, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WNCM_12`` + - Normalized central intensity moment of order (1, 2) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WNCM_20`` + - Normalized central intensity moment of order (2, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WNCM_21`` + - Normalized central intensity moment of order (2, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WNCM_30`` + - Normalized central intensity moment of order (3, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WHU1`` + - Hu's invariant number 1 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WHU2`` + - Hu's invariant number 2 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WHU3`` + - Hu's invariant number 3 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WHU4`` + - Hu's invariant number 4 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WHU5`` + - Hu's invariant number 5 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WHU6`` + - Hu's invariant number 6 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + * - ``IMOM_WHU7`` + - Hu's invariant number 7 of the ROI's intensities with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. + - regression + +.. _features-gabor: + +Gabor +^^^^^ + +GABOR has one output column per frequency and orientation pair, GABOR_0, GABOR_1 and so on. + +Definitions, formulas and settings: :doc:`/Math/f_gabor`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``GABOR`` + - Multi-column feature GABOR_0 to GABOR_N-1, one column per configured (frequency, orientation) pair. Each value is the number of bounding-box pixels (pixels outside the mask count as 0) whose Gabor filter response, divided by the maximum response of a low-pass baseline Gabor filter, exceeds a threshold (default 0.025), divided by the number of pixels where the baseline response exceeds its minimum; 0 for ROIs of a single intensity. + - vetted: scikit-image + +.. _features-zernike: + +Zernike +^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_zernike`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``ZERNIKE2D`` + - Multi-column feature ZERNIKE2D_Z0 to ZERNIKE2D_Z29: the magnitudes of the 30 Zernike moments of orders 0 to 9 (repetitions with order minus repetition even) of the ROI's intensity image, over a unit disk centred on the intensity centroid whose radius is the smaller side of the ROI's bounding box. + - vetted: analytic + +.. _features-radial: + +Radial distribution +^^^^^^^^^^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_radial_distribution`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``FRAC_AT_D`` + - Multi-column feature FRAC_AT_D_0 to FRAC_AT_D_7: the fraction of the ROI's pixels in each of 8 radial bins around the ROI's center pixel: 7 equal rings out to the distance of the farthest contour pixel, as an approximate search finds it, plus an eighth ring for every pixel at or beyond that distance (differs from CellProfiler FracAtD, the fraction of total intensity). + - regression + * - ``MEAN_FRAC`` + - Multi-column feature MEAN_FRAC_0 to MEAN_FRAC_7: the mean pixel intensity in each of the 8 radial bins used by FRAC_AT_D (differs from CellProfiler MeanFrac, the fraction of intensity divided by the fraction of pixels). + - regression + * - ``RADIAL_CV`` + - Multi-column feature RADIAL_CV_0 to RADIAL_CV_7: for each of the 8 radial bins used by FRAC_AT_D, the coefficient of variation (population standard deviation over mean) of the summed intensity across 8 equal angular wedges around the ROI's center pixel. + - regression + +3D features +----------- + +.. _features-intensity-3d: + +Intensity (3D) +^^^^^^^^^^^^^^ + +The 3D counterparts of the 2D intensity features, computed over the ROI's voxels and reported in the volume's own units. + +Definitions, formulas and settings: :doc:`/Math/f_intensity_3d`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``3COV`` + - Coefficient of variation: the unbiased (n-1) standard deviation of the ROI's voxel intensities divided by their mean; 0 when the mean is 0. + - vetted: MATLAB + * - ``3COVERED_IMAGE_INTENSITY_RANGE`` + - Ratio of the ROI's intensity range (3MAX minus 3MIN) to the intensity range of the voxels of all ROIs in the volume; voxels outside every ROI are not used. + - regression + * - ``3ENERGY`` + - Sum of the squared voxel intensities of the ROI. + - vetted: PyRadiomics + * - ``3ENTROPY`` + - Shannon entropy (base 2) of the ROI's intensity histogram, binned into \|``--coarseGrayDepth``\| equal-width bins (default 64) spanning the ROI's minimum to maximum intensity. + - vetted: PyRadiomics + * - ``3EXCESS_KURTOSIS`` + - Excess kurtosis of the ROI's voxel intensities: the population fourth standardized moment minus 3; 0 for ROIs with fewer than 5 voxels or a single intensity. + - vetted: MATLAB + * - ``3HYPERFLATNESS`` + - Sixth standardized moment of the ROI's voxel intensities: the mean sixth power of the deviations from the mean, divided by the sixth power of the unbiased (n-1) standard deviation. + - vetted: MATLAB + * - ``3HYPERSKEWNESS`` + - Fifth standardized moment of the ROI's voxel intensities: the mean fifth power of the deviations from the mean, divided by the fifth power of the unbiased (n-1) standard deviation. + - vetted: MATLAB + * - ``3INTEGRATED_INTENSITY`` + - Sum of the voxel intensities of the ROI. + - vetted: MATLAB + * - ``3INTERQUARTILE_RANGE`` + - Difference between the 75th and 25th percentiles (3P75 minus 3P25) of the ROI's voxel intensities. + - vetted: MATLAB, PyRadiomics + * - ``3KURTOSIS`` + - Pearson kurtosis of the ROI's voxel intensities: the population fourth standardized moment, without subtracting 3; 0 for ROIs with fewer than 5 voxels or a single intensity. + - vetted: MATLAB, PyRadiomics + * - ``3MAX`` + - Maximum voxel intensity in the ROI. + - vetted: MATLAB, PyRadiomics + * - ``3MEAN`` + - Mean voxel intensity of the ROI. + - vetted: MATLAB, PyRadiomics + * - ``3MEAN_ABSOLUTE_DEVIATION`` + - Mean absolute deviation of the ROI's voxel intensities from their mean. + - vetted: MATLAB, PyRadiomics + * - ``3MEDIAN`` + - Median voxel intensity of the ROI; the average of the two middle values when the voxel count is even. + - vetted: MATLAB, PyRadiomics + * - ``3MEDIAN_ABSOLUTE_DEVIATION`` + - Mean absolute deviation of the ROI's voxel intensities from their median (differs from the median of the absolute deviations from the median). + - regression + * - ``3MIN`` + - Minimum voxel intensity in the ROI. + - vetted: MATLAB, PyRadiomics + * - ``3MODE`` + - Most frequent voxel intensity in the ROI; the smallest such intensity when several share the top count. + - vetted: MATLAB + * - ``3P01`` + - 1st percentile of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: MATLAB + * - ``3P10`` + - 10th percentile of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: MATLAB, PyRadiomics + * - ``3P25`` + - 25th percentile (first quartile) of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: MATLAB + * - ``3P75`` + - 75th percentile (third quartile) of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: MATLAB + * - ``3P90`` + - 90th percentile of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: MATLAB, PyRadiomics + * - ``3P99`` + - 99th percentile of the ROI's voxel intensities, interpolated within a 100-bin histogram spanning the ROI's intensity range. + - vetted: MATLAB + * - ``3QCOD`` + - Quartile coefficient of dispersion: (3P75 minus 3P25) divided by (3P75 plus 3P25); 0 when the denominator is 0. + - vetted: MATLAB + * - ``3RANGE`` + - Difference between the maximum and minimum voxel intensities of the ROI (3MAX minus 3MIN). + - vetted: MATLAB, PyRadiomics + * - ``3ROBUST_MEAN`` + - Mean of the ROI's voxel intensities that lie between 3P10 and 3P90, inclusive. + - regression + * - ``3ROBUST_MEAN_ABSOLUTE_DEVIATION`` + - Mean absolute deviation from their own mean of the ROI's voxel intensities that lie between 3P10 and 3P90, inclusive. + - vetted: PyRadiomics + * - ``3ROOT_MEAN_SQUARED`` + - Root mean square of the ROI's voxel intensities: the square root of 3ENERGY divided by the voxel count. + - vetted: MATLAB, PyRadiomics + * - ``3SKEWNESS`` + - Skewness of the ROI's voxel intensities: the population third standardized moment; 0 for ROIs with fewer than 4 voxels or a single intensity. + - vetted: MATLAB, PyRadiomics + * - ``3STANDARD_DEVIATION`` + - Unbiased standard deviation of the ROI's voxel intensities, with n-1 in the denominator. + - vetted: MATLAB + * - ``3STANDARD_DEVIATION_BIASED`` + - Biased (population) standard deviation of the ROI's voxel intensities, with n in the denominator. + - vetted: MATLAB + * - ``3STANDARD_ERROR`` + - Standard error of the mean: the unbiased standard deviation divided by the square root of the voxel count. + - vetted: MATLAB + * - ``3VARIANCE`` + - Unbiased variance of the ROI's voxel intensities, with n-1 in the denominator. + - vetted: MATLAB, PyRadiomics + * - ``3VARIANCE_BIASED`` + - Biased (population) variance of the ROI's voxel intensities, with n in the denominator. + - vetted: MATLAB + * - ``3UNIFORMITY`` + - Sum of the squared bin probabilities of the ROI's intensity histogram, binned into \|``--coarseGrayDepth``\| equal-width bins (default 64) spanning the ROI's minimum to maximum intensity. + - vetted: PyRadiomics + * - ``3UNIFORMITY_PIU`` + - Percent image uniformity: (1 minus (3MAX minus 3MIN) divided by (3MAX plus 3MIN)) times 100; 0 when 3MAX plus 3MIN is 0. + - vetted: MATLAB + +.. _features-shape-3d: + +Shape (3D) +^^^^^^^^^^ + +Definitions, formulas and settings: :doc:`/Math/f_morphology_3d`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``3AREA`` + - Surface area of the ROI from its exposed voxel faces: faces of ROI voxels whose 6-connected neighbour is not in the ROI, each weighted by its area (the product of the anisotropy factors of the two axes it spans), so with no anisotropy it is the number of exposed faces (a staircase area, not a mesh area). + - regression + * - ``3AREA_2_VOLUME`` + - 3AREA divided by 3VOXEL_VOLUME. + - regression + * - ``3COMPACTNESS1`` + - 3VOXEL_VOLUME divided by the square root of pi times 3AREA cubed (differs from IBSI, which uses the mesh area and mesh volume). + - regression + * - ``3COMPACTNESS2`` + - 36 pi times 3VOXEL_VOLUME squared, divided by 3AREA cubed (differs from IBSI, which uses the mesh area and mesh volume). + - regression + * - ``3MESH_VOLUME`` + - Volume enclosed by the ROI's marching-cubes surface mesh, in cubic voxels. + - vetted: analytic, MIRP, PyRadiomics + * - ``3SPHERICAL_DISPROPORTION`` + - 3AREA divided by the surface area of a sphere whose volume is 3VOXEL_VOLUME (differs from IBSI, which uses the mesh area and mesh volume). + - regression + * - ``3SPHERICITY`` + - Surface area of a sphere whose volume is 3VOXEL_VOLUME, divided by 3AREA; the reciprocal of 3SPHERICAL_DISPROPORTION (differs from IBSI, which uses the mesh area and mesh volume). + - regression + * - ``3VOLUME_CONVEXHULL`` + - Volume of the convex hull of the vertices of the ROI's marching-cubes surface mesh, in cubic voxels. + - vetted: analytic, MATLAB, MIRP + * - ``3VOXEL_VOLUME`` + - Volume of the ROI from its voxel count: the count times the volume of a ball of diameter 1 voxel (pi/6), divided by the packing density 0.5236, which leaves the voxel count scaled by 0.9999976, times the product of the three anisotropy factors; with a single whole-volume ROI it is the exact volume of the box Nyxus substitutes for the ROI, times the same product but without the 0.9999976 factor (on the command line and through featurize_directory a box one voxel larger than the volume on every axis, known defect, https://github.com/PolusAI/nyxus/issues/493; see the 3D shape page). + - vetted: MATLAB, MIRP + * - ``3MAJOR_AXIS_LEN`` + - 4 times the square root of the largest eigenvalue of the covariance matrix of the ROI's voxel coordinates, in voxels. + - vetted: MIRP, PyRadiomics + * - ``3MINOR_AXIS_LEN`` + - 4 times the square root of the second-largest eigenvalue of the covariance matrix of the ROI's voxel coordinates, in voxels. + - vetted: MIRP, PyRadiomics + * - ``3LEAST_AXIS_LEN`` + - 4 times the square root of the smallest eigenvalue of the covariance matrix of the ROI's voxel coordinates, in voxels. + - vetted: MIRP, PyRadiomics + * - ``3ELONGATION`` + - 3MINOR_AXIS_LEN divided by 3MAJOR_AXIS_LEN: the square root of the ratio of the second-largest to the largest eigenvalue of the voxel-coordinate covariance matrix. + - vetted: MIRP, PyRadiomics + * - ``3FLATNESS`` + - 3LEAST_AXIS_LEN divided by 3MAJOR_AXIS_LEN: the square root of the ratio of the smallest to the largest eigenvalue of the voxel-coordinate covariance matrix. + - vetted: MIRP, PyRadiomics + +.. _features-glcm-3d: + +GLCM (3D) +^^^^^^^^^ + +The co-occurrence matrix counts pairs of voxels one voxel apart within the bounding box, built separately for the 13 unique directions of the 26-voxel neighbourhood; with a positive grey depth (the default) the background around the ROI is binned to level 1 and its pairs are counted, while with a zero or negative grey depth and under ``--ibsi`` pairs touching level 0 are skipped. Each _AVE code is the mean over the 13 directions; the plain code is a single value taken from the first direction only (one voxel step along x, y and z together), not an average. Grey levels come from ``--coarseGrayDepth`` binning (overridable with the 3glcm/greydepth metaparameter) or are the raw intensities under ``--ibsi``; each ordered pair is counted once with a positive grey depth, and the matrix is made symmetric with a negative or zero grey depth and under ``--ibsi``. + +Definitions, formulas and settings: :doc:`/Math/f_glcm_3d`. .. list-table:: :header-rows: 1 + :widths: 30 50 20 - * - Nyxus feature code + * - Code - Description - * - NUM_NEIGHBORS - - The number of neighbors bordering the ROI's perimeter within proximity radius specified by command line argument --pixelDistance. (Default value of --pixelDistance is 5.) Algorithmically calculating this feature invilves solving the nearest neighbors search problem that in turn involves the proximity measure and the proximity threshold. Particularly, this plugin uses the :math:`L_2` norm measure over Cartesian space of pixel coordinates and parameter --pixelDistance - * - PERCENT_TOUCHING - - Percent of ROI's contour pixels located at distance 0 from neighboring other ROIs's contour - * - CLOSEST_NEIGHBOR1_DIST - - Distance in pixels from ROI's centroid to the nearest neighboring ROI's centroid - * - CLOSEST_NEIGHBOR1_ANG - - Angle in degrees between ROI's centroid and its nearest neighboring ROI's centroid - * - CLOSEST_NEIGHBOR2_DIST - - Distance in pixels from ROI's centroid to the second nearest neighboring ROI's centroid - * - CLOSEST_NEIGHBOR2_ANG - - Angle in degrees between ROI's centroid and its second nearest neighboring ROI's centroid - * - ANG_BW_NEIGHBORS_MEAN - - Mean angle in degrees between ROI's centroid and centroids of its neighboring ROIs - * - ANG_BW_NEIGHBORS_STDDEV - - Standard deviation in degrees of angles between ROI's centroid and centroids of its neighboring ROIs - * - ANG_BW_NEIGHBORS_MODE - - Mode value in degrees of angles between ROI's centroid and centroids of its neighboring ROIs - - -**3D voxel intensity features:** - -.. list-table:: - :header-rows: 1 - - * - Nyxus feature code - - Description - * - 3COV - - Coefficient of variation - * - 3COVERED_IMAGE_INTENSITY_RANGE - - intensity range of the ROI to intensity range of all the ROIs - * - 3ENERGY - - ROI energy - * - 3ENTROPY - - ROI entropy - a measure of the amount of information (that is, randomness) in the ROI - * - 3EXCESS_KURTOSIS - - measure of how ROI intensity distribution's tails deviate from those of a normal distribution - * - 3HYPERFLATNESS - - 6th standardized moment - * - 3HYPERSKEWNESS - - 5th standardized moment - * - 3INTEGRATED_INTENSITY - - total signal within the ROI - * - 3INTERQUARTILE_RANGE - - difference between the 75th and 25th percentiles - * - 3KURTOSIS - - 4th standardized moment - * - 3MAX, 3MEAN, 3MEDIAN, 3MIN - - minimum, mean, median, and minimum signal value within the ROI - * - 3MEAN_ABSOLUTE_DEVIATION - - measure of dispersion around the mean value - * - 3MEDIAN_ABSOLUTE_DEVIATION - - measure of dispersion around the median value - * - 3MODE - - mode value of voxels signal within the ROI (the most abundant signal value within the ROI) - * - 3P01, 3P10, 3P25, 3P75, 3P90, 3P99 - - 1%, 10%, 25%, 75%, 90%, and 99% percentiles of intensity distribution - * - 3QCOD - - quartile coefficient of dispersion - measure of statistical dispersion that focuses on the middle 50% of the data, making it robust to outliers - * - 3RANGE - - range between the 3MAX and 3MIN - * - 3ROBUST_MEAN - - the mean calculated after discarding outliers - * - 3ROBUST_MEAN_ABSOLUTE_DEVIATION - - the mean absolute deviation calculated after discarding outliers - * - 3ROOT_MEAN_SQUARED - - root of mean squared deviation - * - 3SKEWNESS - - 3rd standardized moment - * - 3STANDARD_DEVIATION - - standard deviation (unbiased) - * - 3STANDARD_DEVIATION_BIASED - - biased standard deviation - * - 3STANDARD_ERROR - - standard error - * - 3UNIFORMITY - - measure of how uniform the distribution of ROI intensities is - * - 3UNIFORMITY_PIU - - the uniformity expressed in the units of percent image uniformity (PIU) - * - 3VARIANCE - - variance - * - 3VARIANCE_BIASED - - variance calculated with respect to biased sample mean - - -**3D shape features:** - -.. list-table:: - :header-rows: 1 - - * - Nyxus feature code - - Description - * - 3AREA - - total of areas of surface faces - * - 3AREA_2_VOLUME - - ratio of the surface area to the voxel volume (lower values correspond to more sphere-like ROIs) - * - 3COMPACTNESS1 - - measure of how compact the shape of the ROI is to a sphere - * - 3COMPACTNESS2 - - another measure of how compact the shape of the ROI is to a sphere - * - 3MESH_VOLUME - - volume of the triangle mesh of the ROI - * - 3SPHERICAL_DISPROPORTION - - ratio of ROI's surface area to the surface area of a sphere of the same volume as the ROI (value of 1 corresponds to a perfect square) - * - 3SPHERICITY - - measure of the ROI shape's roundness relative to a sphere (value of 1 is a perfect sphere) - * - 3VOLUME_CONVEXHULL - - volume of the convex hull of the vertices of the ROI's triangle mesh - * - 3VOXEL_VOLUME - - total of volumes of voxels of the ROI - * - 3MAJOR_AXIS_LEN - - measure of the largest principal component of the ROI voxel cloud - * - 3MINOR_AXIS_LEN - - measure of the 2nd largest principal component of the ROI voxel cloud - * - 3LEAST_AXIS_LEN - - measure of the smallest principal component of the ROI voxel cloud - * - 3ELONGATION - - ratio of the two largest principal components of the ROI voxel cloud - * - 3FLATNESS - - ratio of the largest and smallest principal components of the ROI voxel cloud - -**3D texture features:** - -.. list-table:: - :header-rows: 1 - - * - Nyxus feature code - - Description - * - 3GLCM_ - - 3-dimensional version of the GLCM feature (name = ACOR, ASM, CLUPROM, CLUSHADE, CLUTEND, CONTRAST, CORRELATION, DIFAVE, DIFENTRO, DIFVAR, DIS, ID, IDN, IDM, IDMN, INFOMEAS1, INFOMEAS2, IV, JAVE, JE, JMAX, JVAR, SUMAVERAGE, SUMENTROPY, or SUMVARIANCE) - * - 3GLDM_ - - 3-dimensional version of the GLDM feature (name = SDE, LDE, LGLE, HGLE, SDLGLE, SDHGLE, LDLGLE, LDHGLE, GLN, DN, DNN, GLV, DV, or DE) - * - 3GLDZM_ - - 3-dimensional version of the GLDZM feature (name = SDE, LDE, LGLZE, HGLZE, SDLGLE, SDHGLE, LDLGLE, LDHGLE, GLNU, GLNUN, ZDNU, ZDNUN, ZP, GLM, GLV, ZDM, ZDV, or ZDE) - * - 3GLRLM_ - - 3-dimensional version of the GLRLM feature (name = SRE, LRE, LGLRE, HGLRE, SRLGLE, SRHGLE, LRLGLE, LRHGLE, GLN, GLNN, RLN, RLNN, RP, GLV, RV, or RE) - * - 3GLSZM_ - - 3-dimensional version of the GLSZM feature (name = SAE, LAE, LGLZE, HGLZE, SALGLE, SAHGLE, LALGLE, LAHGLE, GLN, GLNN, SZN, SZNN, ZP, GLV, ZV, or ZE) - * - 3NGLDM_ - - 3-dimensional version of the NGLDM feature (name = LDE, HDE, LGLCE, HGLCE, LDLGLE, LDHGLE, HDLGLE, HDHGLE, GLNU, GLNUN, DCNU, DCNUN, DCP, GLM, GLV, DCM, DCV, DCENT, or DCENE) - * - 3NGTDM_ - - 3-dimensional version of the NGTDM feature (name = COARSENESS, CONTRAST, BUSYNESS, COMPLEXITY, or STRENGTH) + - Validation + * - ``3GLCM_ACOR`` + - Autocorrelation: the co-occurrence-weighted mean of the product of the two grey levels of a voxel pair, which grows with both coarseness and brightness. + - vetted: PyRadiomics + * - ``3GLCM_ASM`` + - Angular second moment: the sum of the squared co-occurrence probabilities, which is high when a few grey-level pairs dominate (a uniform texture). + - vetted: PyRadiomics + * - ``3GLCM_CLUPROM`` + - Cluster prominence: the fourth moment of the sum of the two grey levels of a pair about twice the mean grey level, which is large when that distribution is asymmetric or heavy-tailed. + - vetted: PyRadiomics + * - ``3GLCM_CLUSHADE`` + - Cluster shade: the third moment of the sum of the two grey levels of a pair about twice the mean grey level, which measures the skewness of the co-occurrence matrix. + - vetted: PyRadiomics + * - ``3GLCM_CLUTEND`` + - Cluster tendency: the second moment of the sum of the two grey levels of a pair about twice the mean grey level, which measures how strongly pairs group into similar grey levels. + - vetted: PyRadiomics + * - ``3GLCM_CONTRAST`` + - Contrast: the co-occurrence-weighted mean squared grey-level difference between the two voxels of a pair, which grows with local intensity variation. + - vetted: PyRadiomics + * - ``3GLCM_CORRELATION`` + - Correlation: the linear correlation, from -1 to 1, between the grey levels of the two voxels of a pair. + - vetted: PyRadiomics + * - ``3GLCM_DIFAVE`` + - Difference average: the mean absolute grey-level difference between the two voxels of a pair, taken from the distribution of those differences. + - vetted: PyRadiomics + * - ``3GLCM_DIFENTRO`` + - Difference entropy: the base-2 entropy of the distribution of absolute grey-level differences between paired voxels. + - vetted: PyRadiomics + * - ``3GLCM_DIFVAR`` + - Difference variance: the variance of the absolute grey-level difference between paired voxels about its mean. + - vetted: PyRadiomics + * - ``3GLCM_DIS`` + - Dissimilarity: the co-occurrence-weighted mean absolute difference between the grey levels of the two voxels of a pair, taken directly from the co-occurrence matrix. + - vetted: PyRadiomics + * - ``3GLCM_ENERGY`` + - Energy: the sum of the squared co-occurrence probabilities; it returns the same value as 3GLCM_ASM. + - vetted: PyRadiomics + * - ``3GLCM_ENTROPY`` + - Entropy: the base-2 entropy of the co-occurrence probabilities, which is high when many grey-level pairs are about equally likely; it returns the same value as 3GLCM_JE. + - vetted: PyRadiomics + * - ``3GLCM_HOM1`` + - Homogeneity 1: the co-occurrence probabilities weighted by one over one plus the grey-level difference of the pair; it returns the same value as 3GLCM_ID. + - vetted: PyRadiomics + * - ``3GLCM_HOM2`` + - Homogeneity 2: the co-occurrence probabilities weighted by one over one plus the squared grey-level difference of the pair; it returns the same value as 3GLCM_IDM and has no _AVE code. + - vetted: PyRadiomics + * - ``3GLCM_ID`` + - Inverse difference: the co-occurrence probabilities weighted by one over one plus the grey-level difference of the pair, which is high when paired voxels have similar grey levels. + - vetted: PyRadiomics + * - ``3GLCM_IDN`` + - Inverse difference normalized: inverse difference with the grey-level difference divided by the number of grey levels, which makes it less dependent on the number of grey levels. + - vetted: PyRadiomics + * - ``3GLCM_IDM`` + - Inverse difference moment: the co-occurrence probabilities weighted by one over one plus the squared grey-level difference of the pair, a measure of local homogeneity. + - vetted: PyRadiomics + * - ``3GLCM_IDMN`` + - Inverse difference moment normalized: inverse difference moment with the squared grey-level difference divided by the squared number of grey levels. + - vetted: PyRadiomics + * - ``3GLCM_INFOMEAS1`` + - Informational measure of correlation 1: the joint entropy minus the entropy expected if the two grey levels were independent, divided by the entropy of one marginal distribution; it measures how much the two grey levels depend on each other. + - vetted: PyRadiomics + * - ``3GLCM_INFOMEAS2`` + - Informational measure of correlation 2: a 0-to-1 measure of the dependence between the two grey levels of a pair, computed from the gap between the joint entropy and the entropy of the product of the marginals. + - vetted: PyRadiomics + * - ``3GLCM_IV`` + - Inverse variance: the distribution of grey-level differences between paired voxels weighted by one over the squared difference, leaving out pairs with equal grey levels. + - vetted: PyRadiomics + * - ``3GLCM_JAVE`` + - Joint average: the co-occurrence-weighted mean grey level of the reference voxel of a pair. + - vetted: PyRadiomics + * - ``3GLCM_JE`` + - Joint entropy: the base-2 entropy of the co-occurrence probabilities, which is high when many grey-level pairs are about equally likely. + - vetted: PyRadiomics + * - ``3GLCM_JMAX`` + - Joint maximum: the largest co-occurrence probability, that is the share of pairs taken by the most common grey-level pair. + - vetted: PyRadiomics + * - ``3GLCM_JVAR`` + - Joint variance: the co-occurrence-weighted spread of the neighbour voxel's grey level around the joint average (the reference voxel's mean). + - vetted: PyRadiomics + * - ``3GLCM_SUMAVERAGE`` + - Sum average: the mean of the sum of the two grey levels of a voxel pair. + - vetted: PyRadiomics + * - ``3GLCM_SUMENTROPY`` + - Sum entropy: the base-2 entropy of the distribution of the sum of the two grey levels of a voxel pair. + - vetted: PyRadiomics + * - ``3GLCM_SUMVARIANCE`` + - Sum variance: the spread of the sum of the two grey levels of a pair about twice the mean grey level; this code returns the cluster tendency value, the same as 3GLCM_CLUTEND. + - vetted: PyRadiomics + * - ``3GLCM_VARIANCE`` + - Variance: the co-occurrence-weighted variance of the reference voxel's grey level about its mean (PyRadiomics sum of squares). + - vetted: PyRadiomics + * - ``3GLCM_ASM_AVE`` + - Angular second moment, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_ACOR_AVE`` + - Autocorrelation, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_CLUPROM_AVE`` + - Cluster prominence, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_CLUSHADE_AVE`` + - Cluster shade, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_CLUTEND_AVE`` + - Cluster tendency, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_CONTRAST_AVE`` + - Contrast, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_CORRELATION_AVE`` + - Correlation, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_DIFAVE_AVE`` + - Difference average, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_DIFENTRO_AVE`` + - Difference entropy, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_DIFVAR_AVE`` + - Difference variance, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_DIS_AVE`` + - Dissimilarity, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_ENERGY_AVE`` + - Energy, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_ENTROPY_AVE`` + - Entropy, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_HOM1_AVE`` + - Homogeneity 1, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_ID_AVE`` + - Inverse difference, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_IDN_AVE`` + - Inverse difference normalized, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_IDM_AVE`` + - Inverse difference moment, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_IDMN_AVE`` + - Inverse difference moment normalized, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_IV_AVE`` + - Inverse variance, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_JAVE_AVE`` + - Joint average, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_JE_AVE`` + - Joint entropy, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_INFOMEAS1_AVE`` + - Informational measure of correlation 1, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_INFOMEAS2_AVE`` + - Informational measure of correlation 2, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_VARIANCE_AVE`` + - Variance, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_JMAX_AVE`` + - Joint maximum, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_JVAR_AVE`` + - Joint variance, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_SUMAVERAGE_AVE`` + - Sum average, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_SUMENTROPY_AVE`` + - Sum entropy, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLCM_SUMVARIANCE_AVE`` + - Sum variance, averaged over the directions. + - vetted: PyRadiomics + +.. _features-glrlm-3d: + +GLRLM (3D) +^^^^^^^^^^ + +Runs of consecutive voxels with the same grey level are collected separately along the 13 unique directions of the 26-voxel neighbourhood. Each _AVE code is the mean over the 13 directions; the plain code is a single value taken from the first direction only (one voxel step along x, y and z together), not an average. Grey levels are binned to the 3glrlm/greydepth metaparameter, which defaults to 0 (no binning, raw intensities) rather than following ``--coarseGrayDepth``; ``--ibsi`` also uses the raw intensities. + +Definitions, formulas and settings: :doc:`/Math/f_glrlm_3d`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``3GLRLM_SRE`` + - Short run emphasis: the mean over all runs of one over the squared run length, which is high when short runs dominate (fine texture). + - vetted: PyRadiomics + * - ``3GLRLM_LRE`` + - Long run emphasis: the mean over all runs of the squared run length, which is high when long runs dominate (coarse texture). + - vetted: PyRadiomics + * - ``3GLRLM_GLN`` + - Grey level non-uniformity: the sum over grey levels of the squared number of runs at that level, divided by the number of runs; it is low when runs are spread evenly over the grey levels. + - vetted: PyRadiomics + * - ``3GLRLM_GLNN`` + - Grey level non-uniformity normalized: grey level non-uniformity divided by the number of runs once more, giving a value between 0 and 1. + - vetted: PyRadiomics + * - ``3GLRLM_RLN`` + - Run length non-uniformity: the sum over run lengths of the squared number of runs of that length, divided by the number of runs; it is low when run lengths are spread evenly. + - vetted: PyRadiomics + * - ``3GLRLM_RLNN`` + - Run length non-uniformity normalized: run length non-uniformity divided by the number of runs once more, giving a value between 0 and 1. + - vetted: PyRadiomics + * - ``3GLRLM_RP`` + - Run percentage: the number of runs divided by the number of ROI voxels, which is high for fine texture. + - vetted: PyRadiomics + * - ``3GLRLM_GLV`` + - Grey level variance: the variance of the grey level over all runs. + - vetted: PyRadiomics + * - ``3GLRLM_RV`` + - Run variance: the variance of the run length over all runs. + - vetted: PyRadiomics + * - ``3GLRLM_RE`` + - Run entropy: the base-2 entropy of the joint distribution of run grey level and run length. + - vetted: PyRadiomics + * - ``3GLRLM_LGLRE`` + - Low grey level run emphasis: the mean over all runs of one over the squared grey level, which is high when dark runs dominate. + - vetted: PyRadiomics + * - ``3GLRLM_HGLRE`` + - High grey level run emphasis: the mean over all runs of the squared grey level, which is high when bright runs dominate. + - vetted: PyRadiomics + * - ``3GLRLM_SRLGLE`` + - Short run low grey level emphasis: the mean over all runs of one over the squared run length times the squared grey level, which is high for short dark runs. + - vetted: PyRadiomics + * - ``3GLRLM_SRHGLE`` + - Short run high grey level emphasis: the mean over all runs of the squared grey level over the squared run length, which is high for short bright runs. + - vetted: PyRadiomics + * - ``3GLRLM_LRLGLE`` + - Long run low grey level emphasis: the mean over all runs of the squared run length over the squared grey level, which is high for long dark runs. + - vetted: PyRadiomics + * - ``3GLRLM_LRHGLE`` + - Long run high grey level emphasis: the mean over all runs of the squared run length times the squared grey level, which is high for long bright runs. + - vetted: PyRadiomics + * - ``3GLRLM_SRE_AVE`` + - Short run emphasis, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_LRE_AVE`` + - Long run emphasis, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_GLN_AVE`` + - Grey level non-uniformity, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_GLNN_AVE`` + - Grey level non-uniformity normalized, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_RLN_AVE`` + - Run length non-uniformity, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_RLNN_AVE`` + - Run length non-uniformity normalized, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_RP_AVE`` + - Run percentage, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_GLV_AVE`` + - Grey level variance, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_RV_AVE`` + - Run variance, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_RE_AVE`` + - Run entropy, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_LGLRE_AVE`` + - Low grey level run emphasis, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_HGLRE_AVE`` + - High grey level run emphasis, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_SRLGLE_AVE`` + - Short run low grey level emphasis, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_SRHGLE_AVE`` + - Short run high grey level emphasis, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_LRLGLE_AVE`` + - Long run low grey level emphasis, averaged over the directions. + - vetted: PyRadiomics + * - ``3GLRLM_LRHGLE_AVE`` + - Long run high grey level emphasis, averaged over the directions. + - vetted: PyRadiomics + +.. _features-glszm-3d: + +GLSZM (3D) +^^^^^^^^^^ + +A zone is a 26-connected group of voxels sharing one grey level, and the matrix counts zones by grey level and size. The family has no directions, so each code is one value per ROI. Grey levels are binned to the 3glszm/greydepth metaparameter, which defaults to 0 (no binning, raw intensities) rather than following ``--coarseGrayDepth``; ``--ibsi`` also uses the raw intensities. + +Definitions, formulas and settings: :doc:`/Math/f_glszm_3d`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``3GLSZM_SAE`` + - Small area emphasis: the mean over all zones of one over the squared zone size, which is high when small zones dominate (fine texture); IBSI calls it small zone emphasis. + - vetted: PyRadiomics + * - ``3GLSZM_LAE`` + - Large area emphasis: the mean over all zones of the squared zone size, which is high when large zones dominate (coarse texture); IBSI calls it large zone emphasis. + - vetted: PyRadiomics + * - ``3GLSZM_GLN`` + - Grey level non-uniformity: the sum over grey levels of the squared number of zones at that level, divided by the number of zones; it is low when zones are spread evenly over the grey levels. + - vetted: PyRadiomics + * - ``3GLSZM_GLNN`` + - Grey level non-uniformity normalized: grey level non-uniformity divided by the number of zones once more, giving a value between 0 and 1. + - vetted: PyRadiomics + * - ``3GLSZM_SZN`` + - Size zone non-uniformity: the sum over zone sizes of the squared number of zones of that size, divided by the number of zones; it is low when zone sizes are spread evenly. + - vetted: PyRadiomics + * - ``3GLSZM_SZNN`` + - Size zone non-uniformity normalized: size zone non-uniformity divided by the number of zones once more, giving a value between 0 and 1. + - vetted: PyRadiomics + * - ``3GLSZM_ZP`` + - Zone percentage: the number of zones divided by the number of voxels, which is high for fine texture. + - vetted: PyRadiomics + * - ``3GLSZM_GLV`` + - Grey level variance: the variance of the grey level over all zones. + - vetted: PyRadiomics + * - ``3GLSZM_ZV`` + - Zone variance: the variance of the zone size over all zones. + - vetted: PyRadiomics + * - ``3GLSZM_ZE`` + - Zone entropy: the base-2 entropy of the joint distribution of zone grey level and zone size. + - vetted: PyRadiomics + * - ``3GLSZM_LGLZE`` + - Low grey level zone emphasis: the mean over all zones of one over the squared grey level, which is high when dark zones dominate. + - vetted: PyRadiomics + * - ``3GLSZM_HGLZE`` + - High grey level zone emphasis: the mean over all zones of the squared grey level, which is high when bright zones dominate. + - vetted: PyRadiomics + * - ``3GLSZM_SALGLE`` + - Small area low grey level emphasis: the mean over all zones of one over the squared zone size times the squared grey level, which is high for small dark zones. + - vetted: PyRadiomics + * - ``3GLSZM_SAHGLE`` + - Small area high grey level emphasis: the mean over all zones of the squared grey level over the squared zone size, which is high for small bright zones. + - vetted: PyRadiomics + * - ``3GLSZM_LALGLE`` + - Large area low grey level emphasis: the mean over all zones of the squared zone size over the squared grey level, which is high for large dark zones. + - vetted: PyRadiomics + * - ``3GLSZM_LAHGLE`` + - Large area high grey level emphasis: the mean over all zones of the squared zone size times the squared grey level, which is high for large bright zones. + - vetted: PyRadiomics + +.. _features-gldm-3d: + +GLDM (3D) +^^^^^^^^^ + +A voxel's dependence is one plus the number of its 26 neighbours in the bounding box that have exactly the same grey level, and the matrix counts ROI voxels by grey level and dependence. The family has no directions, so each code is one value per ROI. Grey levels are binned to the 3gldm/greydepth metaparameter, which defaults to 0 (no binning, raw intensities) rather than following ``--coarseGrayDepth``; ``--ibsi`` also uses the raw intensities. +Definitions, formulas and settings: :doc:`/Math/f_gldm_3d`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``3GLDM_SDE`` + - Small dependence emphasis: the mean over all voxels of one over the squared dependence, which is high when voxels have few same-level neighbours (heterogeneous texture). + - vetted: PyRadiomics + * - ``3GLDM_LDE`` + - Large dependence emphasis: the mean over all voxels of the squared dependence, which is high when voxels have many same-level neighbours (homogeneous texture). + - vetted: PyRadiomics + * - ``3GLDM_GLN`` + - Grey level non-uniformity: the sum over grey levels of the squared number of voxels at that level, divided by the number of voxels; it is low when the grey levels are evenly represented. + - vetted: PyRadiomics + * - ``3GLDM_DN`` + - Dependence non-uniformity: the sum over dependence values of the squared number of voxels with that dependence, divided by the number of voxels; it is low when dependences are spread evenly. + - vetted: PyRadiomics + * - ``3GLDM_DNN`` + - Dependence non-uniformity normalized: dependence non-uniformity divided by the number of voxels once more, giving a value between 0 and 1. + - vetted: PyRadiomics + * - ``3GLDM_GLV`` + - Grey level variance: the variance of the grey level over the dependence matrix. + - vetted: PyRadiomics + * - ``3GLDM_DV`` + - Dependence variance: the variance of the dependence over the dependence matrix. + - vetted: PyRadiomics + * - ``3GLDM_DE`` + - Dependence entropy: the base-2 entropy of the joint distribution of grey level and dependence. + - vetted: PyRadiomics + * - ``3GLDM_LGLE`` + - Low grey level emphasis: the mean over all voxels of one over the squared grey level, which is high when dark voxels dominate. + - vetted: PyRadiomics + * - ``3GLDM_HGLE`` + - High grey level emphasis: the mean over all voxels of the squared grey level, which is high when bright voxels dominate. + - vetted: PyRadiomics + * - ``3GLDM_SDLGLE`` + - Small dependence low grey level emphasis: the mean over all voxels of one over the squared dependence times the squared grey level, which is high for dark voxels with few same-level neighbours. + - vetted: PyRadiomics + * - ``3GLDM_SDHGLE`` + - Small dependence high grey level emphasis: the mean over all voxels of the squared grey level over the squared dependence, which is high for bright voxels with few same-level neighbours. + - vetted: PyRadiomics + * - ``3GLDM_LDLGLE`` + - Large dependence low grey level emphasis: the mean over all voxels of the squared dependence over the squared grey level, which is high for dark voxels with many same-level neighbours. + - vetted: PyRadiomics + * - ``3GLDM_LDHGLE`` + - Large dependence high grey level emphasis: the mean over all voxels of the squared dependence times the squared grey level, which is high for bright voxels with many same-level neighbours. + - vetted: PyRadiomics + +.. _features-gldzm-3d: + +GLDZM (3D) +^^^^^^^^^^ + +A zone is a 26-connected group of ROI voxels sharing one grey level, and its distance is the smallest city-block distance of any of its voxels to the nearest voxel outside the ROI, with surface voxels at distance 1. The matrix counts zones by grey level and distance; each code is one value per ROI. Grey levels come from ``--coarseGrayDepth`` binning, or are the raw intensities under ``--ibsi``. + +Definitions, formulas and settings: :doc:`/Math/f_gldzm_3d`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``3GLDZM_SDE`` + - Small distance emphasis: the mean over all zones of one over the squared zone distance, which is high when zones lie close to the ROI edge. + - vetted: MIRP + * - ``3GLDZM_LDE`` + - Large distance emphasis: the mean over all zones of the squared zone distance, which is high when zones lie deep inside the ROI. + - vetted: MIRP + * - ``3GLDZM_LGLZE`` + - Low grey level zone emphasis: the mean over all zones of one over the squared grey level, which is high when dark zones dominate. + - vetted: MIRP + * - ``3GLDZM_HGLZE`` + - High grey level zone emphasis: the mean over all zones of the squared grey level, which is high when bright zones dominate. + - vetted: MIRP + * - ``3GLDZM_SDLGLE`` + - Small distance low grey level emphasis: the mean over all zones of one over the squared zone distance times the squared grey level, which is high for dark zones near the ROI edge. + - vetted: MIRP + * - ``3GLDZM_SDHGLE`` + - Small distance high grey level emphasis: the mean over all zones of the squared grey level over the squared zone distance, which is high for bright zones near the ROI edge. + - vetted: MIRP + * - ``3GLDZM_LDLGLE`` + - Large distance low grey level emphasis: the mean over all zones of the squared zone distance over the squared grey level, which is high for dark zones deep inside the ROI. + - vetted: MIRP + * - ``3GLDZM_LDHGLE`` + - Large distance high grey level emphasis: the mean over all zones of the squared zone distance times the squared grey level, which is high for bright zones deep inside the ROI. + - vetted: MIRP + * - ``3GLDZM_GLNU`` + - Grey level non-uniformity: the sum over grey levels of the squared number of zones at that level, divided by the number of zones; it is low when zones are spread evenly over the grey levels. + - vetted: MIRP + * - ``3GLDZM_GLNUN`` + - Grey level non-uniformity normalized: grey level non-uniformity divided by the number of zones once more, giving a value between 0 and 1. + - vetted: MIRP + * - ``3GLDZM_ZDNU`` + - Zone distance non-uniformity: the sum over distances of the squared number of zones at that distance, divided by the number of zones; it is low when zone distances are spread evenly. + - vetted: MIRP + * - ``3GLDZM_ZDNUN`` + - Zone distance non-uniformity normalized: zone distance non-uniformity divided by the number of zones once more, giving a value between 0 and 1. + - vetted: MIRP + * - ``3GLDZM_ZP`` + - Zone percentage: the number of zones divided by the number of ROI voxels, which is high for fine texture. + - vetted: MIRP + * - ``3GLDZM_GLM`` + - Grey level mean: the mean grey level over all zones. + - regression + * - ``3GLDZM_GLV`` + - Grey level variance: the variance of the grey level over all zones. + - vetted: MIRP + * - ``3GLDZM_ZDM`` + - Zone distance mean: the mean zone distance over all zones. + - regression + * - ``3GLDZM_ZDV`` + - Zone distance variance: the variance of the zone distance over all zones. + - vetted: MIRP + * - ``3GLDZM_ZDE`` + - Zone distance entropy: the base-2 entropy of the joint distribution of zone grey level and zone distance. + - vetted: MIRP + +.. _features-ngldm-3d: + +NGLDM (3D) +^^^^^^^^^^ + +Every ROI voxel is a neighbourhood centre whose dependence count is one plus the number of its 26 neighbours inside the ROI with the same grey level; the matrix counts centres by grey level and dependence count. Each code is one value per ROI. Grey levels come from scaling the range 0 to the ROI maximum by ``--coarseGrayDepth`` and truncating (levels 0 to ``--coarseGrayDepth``), or are the raw intensities under ``--ibsi``. + +Definitions, formulas and settings: :doc:`/Math/f_ngldm_3d`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``3NGLDM_LDE`` + - Low dependence emphasis: the mean over all ROI voxels of one over the squared dependence count, which is high when voxels have few same-level neighbours. + - vetted: MIRP + * - ``3NGLDM_HDE`` + - High dependence emphasis: the mean over all ROI voxels of the squared dependence count, which is high when voxels have many same-level neighbours. + - vetted: MIRP + * - ``3NGLDM_LGLCE`` + - Low grey level count emphasis: the mean over all ROI voxels of one over the squared grey level, which is high when dark voxels dominate. + - vetted: MIRP + * - ``3NGLDM_HGLCE`` + - High grey level count emphasis: the mean over all ROI voxels of the squared grey level, which is high when bright voxels dominate. + - vetted: MIRP + * - ``3NGLDM_LDLGLE`` + - Low dependence low grey level emphasis: the mean over all ROI voxels of one over the squared dependence count times the squared grey level, which is high for dark voxels with few same-level neighbours. + - vetted: MIRP + * - ``3NGLDM_LDHGLE`` + - Low dependence high grey level emphasis: the mean over all ROI voxels of the squared grey level over the squared dependence count, which is high for bright voxels with few same-level neighbours. + - vetted: MIRP + * - ``3NGLDM_HDLGLE`` + - High dependence low grey level emphasis: the mean over all ROI voxels of the squared dependence count over the squared grey level, which is high for dark voxels with many same-level neighbours. + - vetted: MIRP + * - ``3NGLDM_HDHGLE`` + - High dependence high grey level emphasis: the mean over all ROI voxels of the squared dependence count times the squared grey level, which is high for bright voxels with many same-level neighbours. + - vetted: MIRP + * - ``3NGLDM_GLNU`` + - Grey level non-uniformity: the sum over grey levels of the squared number of voxels at that level, divided by the number of voxels; it is low when the grey levels are evenly represented. + - vetted: MIRP + * - ``3NGLDM_GLNUN`` + - Grey level non-uniformity normalized: grey level non-uniformity divided by the number of voxels once more, giving a value between 0 and 1. + - vetted: MIRP + * - ``3NGLDM_DCNU`` + - Dependence count non-uniformity: the sum over dependence counts of the squared number of voxels with that count, divided by the number of voxels; it is low when dependence counts are spread evenly. + - vetted: MIRP + * - ``3NGLDM_DCNUN`` + - Dependence count non-uniformity normalized: dependence count non-uniformity divided by the number of voxels once more, giving a value between 0 and 1. + - vetted: MIRP + * - ``3NGLDM_DCP`` + - Dependence count percentage: the fraction of ROI voxels that serve as neighbourhood centres; every ROI voxel is one, so the value is always 1. + - regression + * - ``3NGLDM_GLM`` + - Grey level mean: the mean grey level over the dependence matrix. + - regression + * - ``3NGLDM_GLV`` + - Grey level variance: the variance of the grey level over the dependence matrix. + - vetted: MIRP + * - ``3NGLDM_DCM`` + - Dependence count mean: the mean dependence count over the dependence matrix. + - regression + * - ``3NGLDM_DCV`` + - Dependence count variance: the variance of the dependence count over the dependence matrix. + - vetted: MIRP + * - ``3NGLDM_DCENT`` + - Dependence count entropy: the base-2 entropy of the joint distribution of grey level and dependence count. + - vetted: MIRP + * - ``3NGLDM_DCENE`` + - Dependence count energy: the sum of the squared probabilities of the dependence matrix, which is high when a few combinations of grey level and dependence count dominate. + - vetted: MIRP + +.. _features-ngtdm-3d: + +NGTDM (3D) +^^^^^^^^^^ + +For each voxel of the bounding box the matrix accumulates the absolute difference between its grey level and the average grey level of the voxels within the 3ngtdm/radius Chebyshev distance (default 1, the 26 neighbours) in the bounding box, grouped by grey level, together with the probability of each grey level. Each code is one value per ROI. Grey levels are binned to the 3ngtdm/greydepth metaparameter, which defaults to 0 (no binning, raw intensities) rather than following ``--coarseGrayDepth``; ``--ibsi`` also uses the raw intensities. With the default 3ngtdm/greydepth of 0, and under ``--ibsi``, the background voxels of the bounding box are centres too and form their own grey level; with a positive greydepth they are skipped. + +Definitions, formulas and settings: :doc:`/Math/f_ngtdm_3d`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``3NGTDM_COARSENESS`` + - Coarseness: one over the probability-weighted sum of the differences between each grey level and its neighbourhood averages, which is high when intensity changes slowly (large uniform patches). + - vetted: PyRadiomics + * - ``3NGTDM_CONTRAST`` + - Contrast: the spread of the grey levels present multiplied by the mean difference between a voxel and its neighbourhood average, which is high when both the dynamic range and the local variation are large. + - vetted: PyRadiomics + * - ``3NGTDM_BUSYNESS`` + - Busyness: the probability-weighted neighbourhood differences divided by the summed differences between probability-weighted grey levels, which is high when intensity changes rapidly from voxel to voxel. + - vetted: PyRadiomics + * - ``3NGTDM_COMPLEXITY`` + - Complexity: the grey-level differences between pairs of levels weighted by their neighbourhood differences, which is high when the image has many abrupt intensity changes. + - vetted: PyRadiomics + * - ``3NGTDM_STRENGTH`` + - Strength: the probability-weighted squared differences between grey levels divided by the summed neighbourhood differences, which is high when the image has large, distinct regions of different intensity. + - vetted: PyRadiomics + +Image quality features +---------------------- + +.. _features-image-quality: + +Image quality +^^^^^^^^^^^^^ + +2D features computed on each ROI's bounding box with pixels outside the ROI set to 0. + +Definitions, formulas and settings: :doc:`/Math/f_image_quality`. + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Code + - Description + - Validation + * - ``FOCUS_SCORE`` + - Variance of the Laplacian (3x3 kernel) of the ROI's bounding box; higher values mean a sharper image. + - vetted: OpenCV + * - ``LOCAL_FOCUS_SCORE`` + - Mean of FOCUS_SCORE over a 2x2 grid of equal, non-overlapping tiles of the bounding box. + - vetted: analytic, OpenCV + * - ``POWER_SPECTRUM_SLOPE`` + - Slope of a straight-line fit of log power against log bin index, computed from the image's Fourier transform with each coefficient binned by its own magnitude rather than by its frequency; 0 when the bounding box's shorter side is under 24 pixels. + - regression + * - ``MAX_SATURATION`` + - Fraction of bounding-box pixels at the maximum grey level, from 0 to 1. + - vetted: CellProfiler + * - ``MIN_SATURATION`` + - Fraction of bounding-box pixels at the minimum grey level, from 0 to 1; pixels outside the ROI count as 0. + - vetted: CellProfiler + * - ``SHARPNESS`` + - Edge sharpness from the difference of medians of a 3x3 median-filtered image, combined over the horizontal and vertical directions. + - regression diff --git a/docs/source/gpu.rst b/docs/source/gpu.rst new file mode 100644 index 000000000..63329d779 --- /dev/null +++ b/docs/source/gpu.rst @@ -0,0 +1,178 @@ +.. _gpu: + +GPU acceleration +================ + +A Nyxus build with CUDA support can compute a few expensive 2D feature families on an NVIDIA +GPU. Every other family runs on the CPU, so a GPU run returns the same columns as a CPU run. + +.. note:: + + The test suite runs without a GPU, and the GPU values are not compared with the CPU values in + continuous integration. Some GPU features differ from the CPU features by much more than + rounding: on the same ROIs about half of the shape and intensity moment columns differ, some + by a fraction of a percent and some by many times the CPU value (see :doc:`Math/f_2dmoments`). + + +What runs on the GPU +-------------------- + +Only 2D ROIs use the GPU. Erosion, moments and Gabor use it only for ROIs that Nyxus processes in +memory; out-of-core ROIs (see ``--ramLimit`` in the :ref:`cli-reference`) compute them on the CPU. +GLSZM uses the GPU in memory and out of core. 3D volumes are computed on the CPU. Erosion, moments +and Gabor also need a label image: in whole-slide mode only GLSZM uses the GPU. + +.. list-table:: + :header-rows: 1 + :widths: 25 35 40 + + * - Family + - Features + - GPU work + * - Erosion + - ``EROSIONS_2_VANISH``, ``EROSIONS_2_VANISH_COMPLEMENT`` + - Erodes each ROI on the GPU, but only when a moment feature is in the same run: without one, + the buffer the erosion kernel needs is not allocated, every ROI gets 0 and the run prints + ``ErosionFeature: error calculating on GPU``. This is a known defect (`#520 `__). + * - Shape moments + - ``*SGEOMOMS*``: spatial, central, normalized, Hu and weighted moments + - Computes the moments and their weights on the GPU. + * - Intensity moments + - ``*IGEOMOMS*``: the ``IMOM_*`` codes + - Same as the shape moments, weighted by intensity. + * - Gabor + - ``GABOR`` + - Applies the filter bank on the GPU through an FFT. Command line only (see below). + * - GLSZM + - ``*ALL_GLSZM*`` + - Finds the zones on the CPU and sums the 16 features on the GPU. Command line only. + +The family pages describe how the GPU results relate to the CPU results: +:ref:`erosion-possible-issues`, :doc:`Math/f_2dmoments`, :ref:`gabor-possible-issues` and +:doc:`Math/f_glszm`. + + +Get a build with GPU support +---------------------------- + +The ``nyxus`` package on PyPI is CPU-only. You need a GPU build: + +* The CUDA wheels are published as separate packages, named ``nyxus-cuda12x`` for CUDA 12 and + ``nyxus-cuda11x`` for CUDA 11, for Linux x86_64 (the newest ``nyxus-cuda12x`` with Windows + wheels is 0.9.3, a known issue, `#526 `__). They + do not bundle cuFFT, and the Linux wheels do not bundle the CUDA runtime either, so install them + with the CUDA toolkit. +* The conda-forge ``nyxus`` package has CUDA 12.9 builds for Linux x86_64 and Windows x86_64, + which bring the runtime and cuFFT with them (see :doc:`installation`). +* To build from source, pass ``-DUSEGPU=ON`` to CMake. The CUDA toolkit must be installed, or + CMake stops with an error. See :doc:`installation` for the full build steps. + +A source build compiles kernels for a fixed list of architectures chosen by the CUDA major +version: compute capability 5.0 to 9.0 with CUDA 12, and 7.5 to 12.1 with CUDA 13. + + +Check for a GPU +--------------- + +``nyxus.gpu_is_available`` tells whether a usable CUDA device is present. +``nyxus.get_gpu_properties`` lists the devices. Each entry is a dictionary with the keys +``Device number``, ``Device name``, ``Memory`` and ``Capability``. Both functions take the +``id()`` of a ``Nyxus`` object. In a CPU-only build they return ``False`` and an empty list. + +The command-line program has no device listing. Use ``nvidia-smi`` to see the device numbers. + + +Turn the GPU on +--------------- + +.. tab-set:: + + .. tab-item:: Python + + Pass ``use_gpu_device`` with a device number when you create the object. The default, + ``-1``, computes everything on the CPU. + + .. literalinclude:: ../examples/gpu_check.py + :language: python + :start-after: # example-start + :end-before: # example-end + + Output without a GPU: + + .. code-block:: text + + No CUDA GPU available: the features are computed on the CPU + (2, 190) + + .. tab-item:: Command line + + Add ``--useGpu=true``. ``--gpuDeviceID`` lists the devices Nyxus may use; it defaults + to ``0``. + + .. code-block:: console + + $ nyxus --features="*GEOMOMS*,GABOR" --useGpu=true --gpuDeviceID=0,1 \ + --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv + + With several IDs, Nyxus picks the listed device with the most free memory at startup. + Both options exist only in GPU builds. + +Use ``featurize_directory`` or ``featurize_files`` with the GPU. ``featurize`` on arrays never +allocates the GPU buffers that the erosion and moment code uses, so do not combine it with +``use_gpu_device`` when the features include erosion or the moments. + + +When the GPU is not available +----------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Situation + - What happens + * - ``--useGpu`` given to a CPU-only build + - The run stops: the option is not recognized. + * - ``--useGpu`` with a value other than ``true`` or ``false`` + - The run stops with an error that lists the valid values. + * - ``--useGpu=true`` and none of the ``--gpuDeviceID`` devices can be used + - The run stops with ``cannot use any of GPU devices in due to ``, where the + reason is the last device's failure, for example ``invalid device ID 7``. A listed device + that cannot be used is skipped when another one can. + * - The chosen device fails to initialize + - The run stops and suggests ``--useGpu=false``. + * - ``Nyxus(..., use_gpu_device=n)`` without a usable GPU + - ``ValueError: No need to set GPU device ID because GPU is unavailable``. + * - ``Nyxus3D(..., use_gpu_device=n)`` or ``ImageQuality(..., using_gpu=n)`` without a usable + GPU + - Prints ``No gpu available.`` and computes on the CPU, with one feature thread. + ``ImageQuality`` also prints ``Warning: unexpected keyword argument(s): using_gpu``, but + reads the value. + * - ``nyx.use_gpu_device(n)`` in a CPU-only build + - ``RuntimeError: this Nyxus backend was built without the GPU support``. + +Nyxus never moves to the CPU on its own once the GPU is in use: a failed allocation of the GPU +cache stops the run with ``allocating GPU cache failed``, and a failed GLSZM allocation gives that +ROI the ``--noval`` value. + + +Deviations in the Python interface +---------------------------------- + +.. warning:: + + * The device number given to ``use_gpu_device`` is not passed to CUDA, so the computation + runs on the CUDA default device, device 0. A number past the last device prints ``Warning: + GPU choice () is out of range. Defaulting to device 0``. + * The method ``nyx.use_gpu_device(n)`` turns the GPU on or off rather than choosing a device: + ``0`` turns it off and any other number turns it on. + * ``set_params(use_gpu_device=n)`` is accepted and has no effect. + * The GLSZM family stays on the CPU in Python, because its GPU switch is read before the GPU + is turned on. + * Gabor must not be computed on the GPU from Python: the GPU filter bank is built only by the + command-line program, so the Python package runs the GPU convolution with an empty filter + bank: it prints CUDA errors and then, depending on the input, raises ``RuntimeError: Error: + 1`` or returns 0 in every ``GABOR`` column. Leave the GPU off for ``GABOR`` in Python. + * ``Nyxus3D`` and ``ImageQuality`` drop to one feature thread whenever a device number is + given, even when they then compute on the CPU. No 3D or image-quality feature has a GPU + path. diff --git a/docs/source/ibsi_mode.rst b/docs/source/ibsi_mode.rst new file mode 100644 index 000000000..d0f9e91b5 --- /dev/null +++ b/docs/source/ibsi_mode.rst @@ -0,0 +1,128 @@ +.. _ibsi-mode: + +IBSI mode +========= + +IBSI mode makes the texture families follow the definitions of the Image Biomarker +Standardisation Initiative (IBSI). It also makes the intensity histogram family (``IH_*``) +available. The mode is off by default. + +.. tab-set:: + + .. tab-item:: Python + + .. code-block:: python + + nyx = Nyxus(["*ALL*"], ibsi=True) + + .. tab-item:: Command line + + .. code-block:: console + + $ nyxus --ibsi=true --intDir=/data/int --segDir=/data/seg --outDir=/data/out \ + --outputType=singlecsv + +On the command line ``true``, ``1`` and ``on`` turn the mode on, in any case. Any other value +turns it off without an error. + + +What changes +------------ + +IBSI mode changes three things: grey-level binning, the layout of some texture matrices, and the +set of available features. + +**No grey-level binning.** By default the texture families first bin the ROI's intensities into +``--coarseGrayDepth`` grey levels (64 by default). In IBSI mode they use the intensities as they +are. The 2D families affected are: + +.. list-table:: + :header-rows: 1 + :widths: 20 40 40 + + * - Family + - Default + - IBSI mode + * - :doc:`GLCM ` + - 64 MATLAB-style levels. Each neighbour pair is counted in one order only. + - No binning. The matrix has one row and column per grey value from 1 to the largest + intensity in the ROI's bounding box. Each pair is counted in both orders. + * - :doc:`GLRLM `, :doc:`GLSZM `, :doc:`GLDM ` + - 64 levels. One matrix row per grey level that occurs in the ROI. + - No binning. One matrix row per grey value from 1 to the largest one, so absent grey + values give empty rows. + * - :doc:`NGTDM ` + - 64 levels. + - No binning; the rows are indexed by grey value. + * - :doc:`GLDZM ` + - 64 levels. + - No binning. + * - :doc:`NGLDM ` + - Its own conversion to ``--coarseGrayDepth`` levels. + - No conversion. + +One binning step remains in IBSI mode: before building the 2D GLCM, Nyxus bins the ROI's minimum +and maximum to ``--coarseGrayDepth`` levels and when they fall into one level every GLCM feature returns 0, whatever +``--noval`` is (a known defect, `#511 `__), so a ROI whose minimum is at least 63/64 of its maximum gets 0 +in every GLCM column (see :ref:`glcm-possible-issues`). + +The 3D twins of these families skip binning in the same way. In 3D the default binning differs +by family; the 3D family pages give it, for example :doc:`Math/f_glcm_3d`. + +**The intensity histogram family becomes available.** The 46 +:doc:`IH_* ` features are computed only in IBSI mode, from a +histogram with ``--coarseGrayDepth`` bins. In IBSI mode ``*ALL*`` includes +them. Without IBSI mode, a request for ``IH_*`` features or ``*ALL_IH*`` gives no ``IH_*`` +columns. The command line then prints a warning; Python prints it only with ``verbose`` above 0. + +**Unchanged.** IBSI mode does not change the intensity, morphology, moment, Gabor, Zernike, +erosion, fractal, neighbour and image quality features. ``ENTROPY``, ``UNIFORMITY`` and +``HISTOGRAM`` keep binning into ``--coarseGrayDepth`` bins. + +The family pages compare the IBSI-mode values with IBSI, PyRadiomics and MIRP. Each texture page +has a tip on reproducing those tools, such as the one in :doc:`Math/f_glcm`. + + +Example +------- + +The script computes every 2D feature of two images, once with the default settings and once in +IBSI mode, and lists the families whose values differ: + +.. literalinclude:: ../examples/ibsi_mode.py + :language: python + :start-after: # example-start + :end-before: # example-end + +Output: + +.. code-block:: text + + 756 802 ['IH_MEAN_VAL', 'IH_VARIANCE_VAL', 'IH_SKEWNESS_VAL'] + ['GLCM', 'GLDM', 'GLDZM', 'GLRLM', 'GLSZM', 'NGLDM', 'NGTDM'] + [20.789930555555557, 680.5772479062924, 0.0010357191235226917] + [20.789930555555557, 266.70620661687036, 0.0016546934699797648] + +IBSI mode adds 46 columns. ``MEAN`` is the same in both runs. ``GLCM_CONTRAST_AVE`` drops +from 680.6 to 266.7, because the default run first stretches grey levels 1 to 40 over 64 levels. +On a 16-bit microscopy image with 66 ROIs, IBSI mode changed 300 of the 747 shared feature +columns. All of them belonged to the seven texture families; only ``NGLDM_DCP`` stayed the same. + + +Edge cases +---------- + +.. warning:: + + **Images with large intensities.** Without binning, the 2D GLCM matrix has one row and one + column per grey value up to the ROI's largest intensity. A ROI whose largest value is 50 000 + needs a 50 000 × 50 000 matrix of doubles, about 20 GB. When that allocation fails, Nyxus + prints ``cannot allocate a ... matrix`` and reports 0 for every GLCM feature of the image. On a + 16-bit image whose ROI maxima ran from 19 552 to 65 519, every GLCM value of all 66 ROIs was 0. + Rescale such images to a small integer range before using IBSI mode, or leave it off. + +.. warning:: + + **Set IBSI mode when you create the object.** ``set_params(ibsi=True)`` on an existing + ``Nyxus`` object changes what ``get_params`` reports but not the results: the texture values + stay binned, and ``IH_*`` columns do not appear. Pass ``ibsi=True`` to the constructor. diff --git a/docs/source/imagequality.rst b/docs/source/imagequality.rst index 07b73b8a3..d753b375a 100644 --- a/docs/source/imagequality.rst +++ b/docs/source/imagequality.rst @@ -1,27 +1,59 @@ -Image Quality features +Image quality features ====================== -Image quality features are available in Nyxus to determine how blurry an image is. -These features are available in the `IMAGE_QUALITY` feature group. This group is not included -in the `ALL` group and must be enabled separately. The following features are in the image quality -group. +Image quality features measure focus, sharpness and saturation, for example to find blurred or +overexposed fields of view before analysis. They are 2D features, computed per ROI on the ROI's +bounding box with pixels outside the ROI set to 0. In whole-image mode (the intensity directory +given as the label directory) the ROI is the whole image plus one row and one column of 0, a known defect (`#493 `__; see +:ref:`imq-possible-issues`). + +The group is ``*ALL_IMQ*``. It is not part of ``*ALL*``, and image quality features cannot be +requested in the same run as other features. The ``ImageQuality`` class refuses other feature +names, except after ``*ALL_IMQ*``: names that follow it are dropped without a message. .. list-table:: :header-rows: 1 + :widths: 24 76 - * - Image Quality feature code + * - Feature code - Description - * - FOCUS_SCORE - - Uses edge detection to highlight regions where intesnity changes rapidly. Higher focus score means lower blurriness - * - LOCAL_FOCUS_SCORE - - Tiles image into non-overlapping regions and calculates the FOCUS_SCORE for each region. Higher local focus score means lower blurriness - * - GLCM_DIS - - Blurry images low dissimilarity - * - GLCM_CORRELATION - - Blurry images have a high correlation - * - POWER_SPECTRUM - - The slope of the image log-log power spectrum. A low score means a blurry image - * - SATURATION - - Percent of pixels at minimum and maximum pixel values - * - SHARPNESS - - Uses median-filtered image as indicator of edge scharpness. Values range from 0 to sqrt(2). Low scores indicate blurriness. \ No newline at end of file + * - ``FOCUS_SCORE`` + - Variance of the Laplacian of the image (3×3 kernel). Higher values mean a sharper image. + In squared grey levels, 0 or more. + * - ``LOCAL_FOCUS_SCORE`` + - Mean of ``FOCUS_SCORE`` over a 2×2 grid of equal, non-overlapping tiles. When a side of the + bounding box is shorter than 2 pixels the value is the ``--noval`` substitute. + * - ``POWER_SPECTRUM_SLOPE`` + - Slope of a straight-line fit of log power against log bin index, computed from the image's + Fourier transform, with each coefficient binned by its own magnitude rather than by its + frequency. 0 when the shorter side of the bounding box is under 24 pixels. + The current implementation does not track blur: its value can rise, fall or change sign as + an image is blurred. Use ``FOCUS_SCORE`` to detect blur. + * - ``MAX_SATURATION`` + - Fraction of bounding-box pixels at the maximum grey level, from 0 to 1. + * - ``MIN_SATURATION`` + - Fraction of bounding-box pixels at the minimum grey level, from 0 to 1. Pixels outside the + ROI count as 0. + * - ``SHARPNESS`` + - Edge sharpness from the difference of medians of a 3×3 median-filtered image, combined over + the horizontal and vertical directions. The value has no fixed upper bound, and it does not + fall steadily as an image is blurred, so compare it only between images of similar content. + +:doc:`Math/f_image_quality` gives the formulas, a worked example and the cases that need care, +such as small bounding boxes and ROIs processed out of core. + +The features have no settings of their own. Values are computed on grey levels, so for +floating-point images they depend on ``--fpimgdr`` (see :ref:`intensity-units`). + +Command line: + +.. code-block:: bash + + nyxus --features="*ALL_IMQ*" --intDir=/data/int --segDir=/data/int --outDir=/data/out --outputType=singlecsv + +Python, with the ``ImageQuality`` class: + +.. literalinclude:: ../examples/image_quality.py + :language: python + :start-after: # example-start + :end-before: # example-end diff --git a/docs/source/index.rst b/docs/source/index.rst index a75a9ef78..0ea88ac25 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,26 +1,75 @@ Nyxus -=============== +===== -Nyxus is a feature-rich, highly optimized, Python/C++ application capable of analyzing images of arbitrary size -and assembling complex regions of interest (ROIs) split across multiple image tiles and files. This accomplished through -multi-threaded tile prefetching and a three phase analysis pipeline shown below. +Nyxus computes intensity, texture, morphology and image quality features for every region of +interest (ROI) in 2D images and 3D volumes: 758 features in all, 539 for 2D images, 213 for 3D +volumes and 6 image quality features. It runs from the command line or from Python, reads +TIFF, OME-TIFF, OME-Zarr, NIfTI and DICOM files (OME-TIFF and OME-Zarr with up to five +dimensions), and assembles ROIs that span several image tiles. -.. image :: nyxus_workflow.jpg +ROIs larger than the memory budget are processed out of core when they come from files; which +features then keep their in-memory values is listed under :ref:`large-images`. + +.. image:: nyxus_workflow.svg + :alt: The Nyxus pipeline: input, scan, compute and output + +.. grid:: 1 2 2 2 + :gutter: 3 + + .. grid-item-card:: User guide + :link: user_guide + :link-type: doc + + How to install and run Nyxus, choose features, read and write each format, process 3D + volumes and large images, use the GPU, IBSI mode, image quality features and the WIPP + plugin, and fix common errors. + + .. grid-item-card:: Feature reference + :link: feature_reference + :link-type: doc + + Every feature code, feature group and definition, how features are validated, and the + conventions they share. + + .. grid-item-card:: API reference + :link: api_reference + :link-type: doc + + The Python classes and functions, and every command-line option. + + .. grid-item-card:: Developer guide + :link: developer_guide + :link-type: doc + + Add a feature and work on the Nyxus code. + +Citing Nyxus +------------ + +If you use Nyxus in your work, please cite the software. The citation metadata is kept in +``CITATION.cff`` at the repository root, and GitHub's *Cite this repository* button on the +`repository page `_ produces APA and BibTeX from it. + +.. parsed-literal:: + + Kharchenko, A. *Nyxus* (version |release|). https\://github.com/PolusAI/nyxus + +.. parsed-literal:: + + @software{nyxus, + author = {Kharchenko, Andriy}, + title = {Nyxus}, + version = {|release|}, + url = {https\://github.com/PolusAI/nyxus}, + license = {MIT} + } -Nyxus can be used via Python or command line and is available in containerized form for reproducible execution. -Nyxus computes over 450 combined intensity, texture, and morphological features at the ROI or whole image level with -more in development. Key features that make Nyxus unique among other image feature extraction applications is its ability -to operate at any scale, its highly validated algorithms, and its modular nature that makes the addition of new features straightforward. .. toctree:: - :maxdepth: 1 - :caption: Contents: - - featurelist - cmdline_and_examples - Reference - devguide - Math - supported_formats - imagequality - \ No newline at end of file + :hidden: + + user_guide + feature_reference + api_reference + developer_guide + release_notes diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 000000000..018cc85e6 --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,453 @@ +.. _installation: + +Installation +============ + +Nyxus comes as a Python package and as a command-line program. The Python packages on PyPI and +conda-forge hold the Python module only; run the command-line program from a Docker image, or +build either one from source. + +Python packages +--------------- + +.. tab-set:: + + .. tab-item:: pip + + .. code-block:: bash + + pip install nyxus + + .. tab-item:: conda + + .. code-block:: bash + + conda install -c conda-forge nyxus + + .. tab-item:: pip, CUDA 12 + + .. code-block:: bash + + pip install nyxus-cuda12x + +The PyPI wheels are built for CPython 3.10, 3.11, 3.12 and 3.13 on these platforms: + +* Linux x86_64 and aarch64, glibc 2.28 or newer (``manylinux_2_28``); +* Windows x86_64; +* macOS x86_64 (10.15 or newer) and arm64 (11.0 or newer). + +PyPI has no source distribution, so where no wheel fits, ``pip`` falls back to an older release +without a warning, or stops with ``No matching distribution found for nyxus``: + +* Release 0.11.0 has no wheel for CPython 3.13 on Linux x86_64, so ``pip install nyxus`` installs + 0.10.0 there. Use Python 3.10 to 3.12, conda-forge or a source build to get 0.11.0. This is a known + issue (`#525 `__). +* On Linux with a glibc older than 2.28 (for example CentOS 7 or Ubuntu 18.04), ``pip`` installs + 0.8.3, the last release built for ``manylinux2014``, on x86_64 with Python 3.8 to 3.12, and an + even older release with Python 3.6 or 3.7. With Python 3.13 or on aarch64 it stops with ``No + matching distribution found``. The conda-forge Linux x86_64 packages need glibc 2.17 or newer, + so use conda on these systems; on aarch64, build from source. +* On Python 3.9, ``pip`` installs 0.9.3, the last release built for it. On Python 3.8 it installs + 0.8.3 on Linux x86_64, Windows and Intel Macs, and stops with ``No matching distribution found`` + on Apple Silicon Macs and Linux aarch64. +* Python 3.14 has no wheel of any release, so ``pip`` stops with ``No matching distribution found + for nyxus``; the conda-forge package has Python 3.14 builds. +* There is no wheel for other platforms, such as Alpine and other musl-based Linux, Windows on ARM + or 32-bit systems. Build from source there. + +Check the installed version with ``python -c "import nyxus; print(nyxus.__version__)"``. + +The conda-forge package is built for Linux x86_64, Windows x86_64 and macOS x86_64, for Python 3.10 +to 3.14. It has no build for Apple Silicon Macs or Linux aarch64, where ``conda install`` reports +that the package is not available from the current channels (``PackagesNotFoundError``, +``PackagesNotFoundInChannelsError`` in recent conda); install the PyPI wheel into the conda +environment there: + +.. code-block:: bash + + conda create -n nyxus -c conda-forge python=3.12 numpy pandas + conda activate nyxus + pip install nyxus + +The Python package needs NumPy and pandas, which ``pip`` and ``conda`` install with it; NumPy 1 +and 2 both work. To read Arrow IPC or Parquet output back into Python, also install ``pyarrow``. + +What the packages read +^^^^^^^^^^^^^^^^^^^^^^ + +Every package build goes through ``setup.py``, which configures CMake with ``-DALLEXTRAS=ON``. +The PyPI and conda-forge packages therefore read DICOM files and write Arrow IPC and Parquet +output, in addition to TIFF, OME-TIFF and NIfTI. They also read OME-Zarr and compressed NIfTI +(``.nii.gz``), with these exceptions: + +* The Windows wheels on PyPI (``nyxus`` 0.9.3 and 0.11.0, and ``nyxus-cuda12x`` 0.9.3) were built + without OME-Zarr support, and so without ``.nii.gz``: they refuse OME-Zarr and fail on + ``.nii.gz`` files. This is a known issue (`#527 `__). +* The first conda-forge Windows builds of 0.11.0 (build number 0, a build string ending in ``_0`` + in ``conda list nyxus``) read none of OME-Zarr, ``.nii.gz`` and DICOM; conda installs one of + them into an environment that holds ``libarrow`` 19 or 20. + +These exceptions concern the published packages. What a source build reads depends on the +libraries it finds (see `Building from source`_). + +.. warning:: + + The conda-forge Windows packages of 0.11.0 were built in Debug mode: their extension module + imports the Visual Studio debug runtime (``MSVCP140D.dll``, ``VCRUNTIME140D.dll``, + ``ucrtbased.dll``), which only an installation of Visual Studio provides, not the Visual C++ + redistributable. Without Visual Studio, ``import nyxus`` fails. This is a known issue + (`#528 `__). + +GPU packages +^^^^^^^^^^^^ + +``nyxus-cuda12x`` is the same package built with ``-DUSEGPU=ON`` against CUDA 12, and +``nyxus-cuda11x`` the same against CUDA 11. Both are published as Linux x86_64 wheels. The newest +``nyxus-cuda12x`` release with Windows wheels is 0.9.3, for CPython 3.9 to 3.12, a known issue +(`#526 `__). On Windows with Python 3.13 or newer, +``pip install nyxus-cuda12x`` therefore stops with ``No matching distribution found for +nyxus-cuda12x``; use Python 3.9 to 3.12 there, or conda-forge with Python 3.10 to 3.13, where conda +picks the CUDA build (it needs Visual Studio, see the warning above). + +The Linux wheels do not bundle the CUDA runtime or cuFFT, and the Windows wheels bundle the +runtime but not cuFFT: install the CUDA runtime and cuFFT libraries of the matching CUDA version, +and an NVIDIA driver that supports it. Without them, ``import nyxus`` fails: on Linux with +``ImportError: libcufft.so.11: cannot open shared object file`` (``libcufft.so.10`` for +``nyxus-cuda11x``), on Windows with ``ImportError: DLL load failed while importing backend`` (the +missing file is ``cufft64_11.dll``). On Linux, a CUDA toolkit of the package's major version +provides both libraries, with its ``lib64`` directory on ``LD_LIBRARY_PATH``. + +``nyxus``, ``nyxus-cuda12x`` and ``nyxus-cuda11x`` all install the module ``nyxus``, so an +environment holds only one of them. To change from one to another, remove all of them and install +the one you want (check first that a wheel of it exists for your platform and Python, or the +environment is left without Nyxus): + +.. code-block:: bash + + pip uninstall -y nyxus nyxus-cuda12x nyxus-cuda11x + pip install nyxus-cuda12x + +The conda-forge ``nyxus`` package has CUDA 12.9 builds for Linux x86_64 and Windows x86_64 next to +its CPU builds. A CUDA build depends on the conda packages ``cuda-cudart`` and ``libcufft`` (about +155 MB), so conda installs the runtime with it. On those two platforms conda currently picks a +CUDA build, both on a machine without a GPU and on one whose driver supports CUDA 12, and the CPU +build for an older driver. The exception is Python 3.14 on Windows, where it picks the CPU build. +To get the CPU build everywhere, ask for it: + +.. code-block:: bash + + conda install -c conda-forge nyxus "cuda-version<12" + +``conda list cuda-cudart`` in the environment shows whether a CUDA runtime was installed. +:doc:`gpu` says which features run on the GPU and how to turn it on. + +Docker image +------------ + +The ``polusai/nyxus`` image on Docker Hub holds the command-line program, built with all +optional formats and with CUDA support. The images of releases 0.9.2 to 0.11.0 are built on +``nvidia/cuda:11.5.2-runtime-ubuntu20.04`` (CUDA 11.5), images 0.4.0 to 0.8.3 on CUDA 11.0, and +the tags up to 0.2.3 on one older image without CUDA. The ``Dockerfile`` in the repository uses +``nvidia/cuda:12.6.0-runtime-ubuntu22.04``, and images built from it get that base. The images are +built for ``linux/amd64`` only: on Apple Silicon, Docker runs them under emulation, and on other +ARM hosts the command-line program has to be built from source. Each release since 0.9.2 is +tagged with its version number, for example ``polusai/nyxus:0.11.0``, and the newest release is +also tagged ``latest``. The image's entry point is the ``nyxus`` executable, so the container +arguments are Nyxus options. + +#. Pull a release: + + .. code-block:: bash + + docker pull polusai/nyxus:0.11.0 + +#. Mount the directory that holds your images, and pass paths inside the container. The output + directory (``/path/to/experiment/out`` here) must already exist: + + .. code-block:: bash + + docker run --rm -v /path/to/experiment:/data polusai/nyxus:0.11.0 \ + --intDir=/data/int --segDir=/data/seg --outDir=/data/out \ + --outputType=singlecsv --features="*ALL_INTENSITY*" + +#. To use a GPU, add ``--gpus all`` to ``docker run`` and ``--useGpu=true`` to the Nyxus options. + This needs the NVIDIA Container Toolkit on the host. + +#. Nyxus sets its default memory budget from the host's memory, not from a limit given with + ``docker run --memory``. When you set such a limit, also pass ``--ramLimit`` with a value in + megabytes below it, so that large ROIs are processed out of core rather than exceeding the + container's memory (see :doc:`large_images`). + +Every option is listed in the :ref:`cli-reference`. + +.. _installation-check: + +Checking the installation +------------------------- + +The script below prints the package version, whether Arrow IPC and Parquet output are +available, and whether a CUDA GPU can be used, and then computes one feature. + +.. literalinclude:: ../examples/check_install.py + :language: python + :start-after: # example-start + :end-before: # example-end + +Output from a CPU package: + +.. code-block:: text + + Nyxus version: 0.11.0 + Arrow IPC and Parquet output: True + CUDA GPU usable: False + MEAN of 1, 2, 3, 4: 2.5 + +``gpu_is_available`` takes the ``id`` of a ``Nyxus`` object. It returns ``False`` for a package +built without CUDA, and otherwise ``True`` when at least one CUDA device is found. + +The command-line program has no ``--version`` option. It prints its version in the first line of +every run, whatever the ``--verbose`` level, for example +``Nyxus /// 0.11.0 /// (c) 2021-2025 Axle Research Build of ``. ``nyxus --help`` prints the +usage screen. + +No call reports whether OME-Zarr or DICOM support is compiled in. A build without it refuses +such a file with ``This version of Nyxus was not build with OmeZarr support`` or ``This version +of Nyxus was not build with DICOM support``. When you build from source, the CMake configure step +prints which optional parts were requested and which were found: + +.. code-block:: text + + -- Nyxus Dependencies Summary + -- ================================ + -- Support | Requested | Found + -- ================================ + -- Z5 | ON | ON + -- Arrow | ON | ON + -- Dicom | ON | ON + -- CUDA | OFF | OFF + +Building from source +-------------------- + +Build from a git clone of the repository. The package version comes from the git tags, so a +GitHub source archive of the development head or a copy without the ``.git`` directory stops with +``setuptools-scm was unable to detect version``: + +.. code-block:: bash + + git clone https://github.com/PolusAI/nyxus.git + cd nyxus + +The ``ci-utils`` and ``tests`` paths on this page are relative to this directory. The build steps +below describe the development head; a release tag can differ (0.11.0, for example, is built as +C++17 and has no ``ci-utils/envs/conda_cpp_mac.txt``). + +Requirements +^^^^^^^^^^^^ + +* CMake 3.20 or newer. +* A C++20 compiler. On Windows, use MSVC: the conda-forge Windows libraries are built with MSVC + and do not link with GCC. +* libtiff, which is always required. +* For each optional part, its libraries: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Part + - Libraries + * - OME-Zarr input + - z5 (headers), Blosc, zlib, nlohmann_json; zstd for Zstandard-compressed Zarr version 3 + arrays, used when both its library and header are found + * - DICOM input + - DCMTK and zlib; fmjpeg2k for JPEG 2000 pixel data + * - Arrow IPC and Parquet output + - Apache Arrow and Parquet + * - Python package + - pybind11 + * - GPU + - a CUDA toolkit whose ``nvcc`` supports your host compiler + +Getting the dependencies +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. tab-set:: + + .. tab-item:: conda (recommended) + + The files in ``ci-utils/envs/`` list the conda-forge packages: + + * ``conda_cpp.txt``: the C++ libraries, CMake and pybind11; + * ``conda_cpp_mac.txt``: the same without DCMTK and fmjpeg2koj, for Apple Silicon Macs and + Linux aarch64; + * ``conda_py.txt``: numpy, pandas, pyarrow and pip; + * ``conda_linux_compiler.txt`` and ``conda_linux_gpu.txt``: a Linux compiler and the CUDA 12.6 + compiler and libraries. + + .. code-block:: bash + + 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 + conda activate nyxus_build + + On Apple Silicon Macs and on Linux aarch64, use ``conda_cpp_mac.txt`` in place of + ``conda_cpp.txt``: the other file does not solve there, and this one has no DICOM libraries. + On Linux, also add ``--file ci-utils/envs/conda_linux_compiler.txt`` so that the compiler + matches the conda libraries, as the CI builds do. + + Point CMake at the environment with the ``NYXUS_DEP_DIR`` environment variable: + ``$CONDA_PREFIX`` on Linux and macOS, ``%CONDA_PREFIX%\Library`` on Windows. When it is set, + CMake uses it as ``CMAKE_PREFIX_PATH`` and as a library directory. + + .. tab-item:: Linux and macOS scripts + + ``ci-utils/install_prereq_linux.sh`` downloads and builds the dependencies into + ``./local_install``: + + .. code-block:: bash + + bash ci-utils/install_prereq_linux.sh --build_arrow yes + export NYXUS_DEP_DIR=$PWD/local_install + + Its options are ``--min_build yes`` (only pybind11, libtiff and their codecs, no OME-Zarr, + DICOM or Boost), ``--build_arrow yes`` (also Arrow and Parquet; off by default) and + ``--install_dir ``, a path relative to the current directory (the script prefixes it + with ``../../`` for each dependency build). The script needs ``curl``, ``unzip``, ``bzip2``, + ``make``, CMake and a C++ compiler. It does not stop when a step fails, so check its output + for errors before building. On macOS, run it with bash 4 or newer (for example Homebrew's + ``bash``): the system ``/bin/bash`` 3.2 reports ``bad substitution`` at the ``--min_build`` + check, ignores the option and builds every dependency. + + .. tab-item:: Windows script + + ``ci-utils\install_prereq_win.bat`` downloads and builds the dependencies, including Arrow, + into ``.\local_install``. Run it from a Visual Studio developer command prompt: + + .. code-block:: bat + + set PATH=%PATH%;C:\Program Files\Git\usr\bin + ci-utils\install_prereq_win.bat + set NYXUS_DEP_DIR=%cd%\local_install + set PATH=%NYXUS_DEP_DIR%\bin;%PATH% + + ``--min_build yes`` skips OME-Zarr and DICOM. The first line adds Git for Windows' + ``usr\bin``, for the ``unzip`` the script needs, at the end of ``PATH``. Placed earlier, its + ``link.exe`` and ``tar.exe`` hide the MSVC linker and the Windows ``tar`` that the script + uses for ``.zip`` files. The script does not stop when a step fails, so check its output for + errors before building. + + The script's Boost download, in its OME-Zarr part, currently fails: the URL returns HTTP + 404. The Windows release wheels built with the script since 0.9.3 have no OME-Zarr support. + This is a known issue (`#527 `__). + + The dependency DLLs in ``local_install\bin`` must be on ``PATH`` when you run the + command-line program. Python does not search ``PATH`` for an extension module's DLLs, so in + Python call ``os.add_dll_directory(r"\local_install\bin")`` before + ``import nyxus``. + +Building the Python package +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``pip`` builds the package through ``setup.py`` (see the CMake options below). With +``NYXUS_DEP_DIR`` set as above, run from the source tree: + +.. code-block:: bash + + python -m pip install . -vv + +Pass more CMake options in the ``CMAKE_ARGS`` environment variable, separated by spaces. For +example, a GPU build, which needs a CUDA toolkit: ``CMAKE_ARGS="-DUSEGPU=ON" python -m pip install +. -vv`` in bash, or ``set CMAKE_ARGS=-DUSEGPU=ON`` before the ``pip`` command in a Windows command +prompt. + +The package then includes every optional part whose libraries CMake finds. A missing optional +dependency switches its part off and the build continues, except the GPU part: without a CUDA +toolkit, configuring stops. Not every such case prints a warning (a missing DCMTK drops DICOM with +only status lines), so check the dependency summary at the end of the configure output. libtiff +and pybind11 are required: configuring stops without them. + +Building the command-line program +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: bash + + cmake -S . -B build-cli -DBUILD_CLI=ON -DALLEXTRAS=ON -DCMAKE_PREFIX_PATH=$NYXUS_DEP_DIR + cmake --build build-cli --config Release --parallel 4 + +The executable is ``build-cli/nyxus`` (``build-cli\Release\nyxus.exe`` with a Visual Studio +generator). Use a directory other than ``build``, which ``pip`` uses for the Python package. + +CMake options +^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 20 12 68 + + * - Option + - Default + - Effect + * - ``BUILD_CLI`` + - not set (off) + - Builds the ``nyxus`` command-line executable. + * - ``BUILD_LIB`` + - not set (off) + - Builds the Python extension module ``backend``. ``setup.py`` turns it on. + * - ``NOEXTRAS`` + - ``ON`` + - Turns ``USE_Z5``, ``USE_DCMTK`` and ``USE_ARROW`` off: the build reads TIFF, OME-TIFF and + uncompressed NIfTI only. Because it is on by default, a ``USE_*`` option has no effect + unless you also pass ``-DNOEXTRAS=OFF``. + * - ``ALLEXTRAS`` + - ``OFF`` + - Turns ``USE_Z5``, ``USE_DCMTK`` and ``USE_ARROW`` on. It takes precedence over + ``NOEXTRAS``. ``setup.py`` always passes it. + * - ``USE_Z5`` + - ``OFF`` + - OME-Zarr input. Also enables compressed NIfTI (``.nii.gz``), which needs the zlib that this + option finds. Dropped with a warning when zlib, Blosc, nlohmann_json or the z5 headers + are missing. + * - ``USE_DCMTK`` + - ``OFF`` + - DICOM input. Dropped when DCMTK or zlib is missing. JPEG 2000 pixel data is read when + fmjpeg2k is also found. + * - ``USE_ARROW`` + - ``OFF`` + - Arrow IPC and Parquet output (``--outputType=arrowipc`` or ``parquet``). Dropped with a + warning when Arrow or Parquet is missing. + * - ``USEGPU`` + - ``OFF`` + - Builds the CUDA kernels and the ``--useGpu`` and ``--gpuDeviceID`` options. Configuring + stops with ``USEGPU flag was set to ON but no CUDA compiler was found`` when no CUDA + compiler or toolkit is found. The GPU architectures compiled for follow the CUDA major + version, from 10 to 13. + * - ``RUN_GTEST`` + - ``OFF`` + - Downloads GoogleTest and builds the C++ test executable ``runAllTests``. + * - ``RUN_BENCHMARKS`` + - ``OFF`` + - Builds the ``bench_convex_hull`` micro-benchmark (see ``benchmarks/README.md``). + * - ``CMAKE_BUILD_TYPE`` + - ``Release`` (``Debug`` with MSVC) + - Single-configuration generators build ``Release`` unless told otherwise. With MSVC, CMake + itself presets ``Debug``, so pass ``-DCMAKE_BUILD_TYPE=Release`` when you use Ninja or + NMake. This is a known defect (`#524 `__). + +The C++ standard is C++20 and is required (``CMAKE_CXX_STANDARD 20``). On MSVC the build adds +``/permissive- /EHsc /bigobj /Zc:__cplusplus``. + +Running the tests +^^^^^^^^^^^^^^^^^ + +After installing the Python package, run the Python test suite from the source tree. Use a +checkout of the same version as the installed package (``git checkout 0.11.0`` for 0.11.0): the +tests on the development head cover features that a released package may not have. + +.. code-block:: bash + + python -m pip install pytest bfio imagecodecs pyarrow + python -m pytest tests/python/ + +With ``-DRUN_GTEST=ON``, the build also produces ``runAllTests``, the C++ test executable. It +does not need ``BUILD_CLI`` or ``BUILD_LIB``. diff --git a/docs/source/large_images.rst b/docs/source/large_images.rst new file mode 100644 index 000000000..8b537df14 --- /dev/null +++ b/docs/source/large_images.rst @@ -0,0 +1,308 @@ +.. _large-images: + +Whole images and large images +============================= + +This page covers two cases: computing features of a whole image or volume without a label image, +and processing ROIs that do not fit in the memory budget. + +.. _large-images-whole: + +Whole-slide and whole-volume mode +--------------------------------- + +In whole-slide mode (whole-volume mode in 3D), Nyxus reads no label image. It treats each intensity +image as one ROI with label 1 that covers every pixel, including zero-intensity background. + +.. tab-set:: + + .. tab-item:: Python + + Pass ``single_roi=True`` to ``featurize_files``: + + .. literalinclude:: ../examples/whole_slide.py + :language: python + :start-after: # example-start + :end-before: # example-end + + Output: + + .. code-block:: text + + intensity_image mask_image ROI_label MEAN PERIMETER + 0 slides/slide1.ome.tif 1 2002.230794 224.0 + 1 slides/slide2.ome.tif 1 1981.880208 224.0 + + ``featurize_directory`` switches to whole-slide mode when ``label_dir`` is ``None`` or the + same path string as ``intensity_dir``. For whole volumes, background included, call + ``Nyxus3D.featurize_directory(volume_dir, None, ".*")`` or + ``Nyxus3D.featurize_directory(volume_dir, volume_dir, ".*")``. + ``Nyxus3D.featurize_files(volumes, volumes, single_roi=True)`` is not whole-volume mode: it + uses each volume as its own mask, so the ROI is the volume's nonzero voxels (see + :doc:`Math/f_morphology_3d`); its 3D shape features describe the box of those voxels, a + known defect (`#494 `__). + + .. tab-item:: Command line + + Give the intensity directory as the label directory too. The two paths are compared as + text, ignoring case: + + .. code-block:: bash + + nyxus --features="*WHOLESLIDE*" --intDir=/data/int --segDir=/data/int --outDir=/data/out --outputType=singlecsv + + Add ``--dim=3`` for whole volumes. + +Each image gives one row with ``ROI_label`` 1 and an empty ``mask_image``. ``intensity_image`` +holds the path as it was given, such as ``slides/slide1.ome.tif``, where a run with label images +holds the bare file name. ``--aggr`` and ``--skiproi`` have no effect in this mode. + +Which features to request +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Nyxus computes every feature you request, but many describe the shape of a ROI, and in this mode +the ROI is the image rectangle. The ``*WHOLESLIDE*`` group selects the families that still +describe the image: intensity, the contour family, the texture families except GLDZM, Gabor, +intensity moments, radial distribution and Zernike. :doc:`featuregroups` lists its members. + +Whole-slide features differ from those of an ordinary ROI in these ways: + +* **The contour is the bounding box.** Nyxus does not trace a contour. It uses the four corners of + the image, so ``PERIMETER`` is 2 x (width + height): 224 for a 64 x 48 image. Every feature + built on the contour describes this rectangle: circularity, the perimeter-equivalent diameter, + geodetic length and thickness, the ROI radii, fractal dimension, radial distribution and the + weighted moments. The radial distribution's centre then lies on the image's top edge, a known + defect (`#516 `__). +* **Edge features are degenerate.** The edge is the four corners, and each corner carries the + image's maximum grey level. ``EDGE_MIN_INTENSITY`` and ``EDGE_MAX_INTENSITY`` both equal + ``MAX``, ``EDGE_STDDEV_INTENSITY`` is 0 and ``EDGE_INTEGRATED_INTENSITY`` is 4 x ``MAX``. +* **Some features are not computed** and are reported as 0: the neighbor features, + ``POLYGONALITY_AVE``, ``HEXAGONALITY_AVE``, ``HEXAGONALITY_STDDEV`` and the minimum enclosing, + inscribing and circumscribing circle diameters. +* **The bounding box is one pixel larger than the image**, a known defect (`#493 `__). On a + 256 x 256 image, ``BBOX_WIDTH`` + and ``BBOX_HEIGHT`` are 257 and ``EXTENT`` is 0.992. + +A whole-slide run is therefore not the same as a run with a label image of ones. On a 256 x 256 +test image the two agree on the intensity and GLCM features, but differ in 263 of the 747 output +columns of ``*ALL*``, +including the GLRLM, GLSZM, GLDZM, NGTDM and Gabor features. For example, ``GLDZM_SDE`` is 0.0238 +in whole-slide mode and 0.0351 with a label image of ones. + +A 2D image must fit in the memory budget described below. A larger image is refused with +``slide is non-trivial``, and the Python call raises ``RuntimeError``. A whole volume larger than +the budget is streamed out of core like any oversized 3D ROI. When one read of the file returns +the whole volume, as with a NIfTI file, it cannot be streamed and the run stops with an error +(see the end of :ref:`large-images-tiles`). + +In 2D whole-slide mode, ``--reduceThreads`` (Python: ``n_feature_calc_threads``) sets how many +images are processed at the same time. + +Memory budget and out-of-core processing +---------------------------------------- + +Before it computes features, Nyxus estimates the memory footprint of each ROI and compares it with +a RAM limit. A ROI whose estimate reaches the limit is *oversized* and is processed out of core: +Nyxus streams its pixels from the file into a temporary file and computes its features from +there. The other ROIs are computed in memory, in batches that fit the limit. + +.. tab-set:: + + .. tab-item:: Python + + Pass ``ram_limit``, in megabytes, when you create the object. ``Nyxus.set_params`` + can change it later; ``Nyxus3D`` accepts it only when the object is created. + + .. literalinclude:: ../examples/out_of_core.py + :language: python + :start-after: # example-start + :end-before: # example-end + + Output: + + .. code-block:: text + + ROI_label MEAN GLCM_CONTRAST_AVE + 0 1 1984.574661 625.447468 + 1 2 2038.488800 711.010030 + 2 3 2045.812302 644.997573 + ROI_label MEAN GLCM_CONTRAST_AVE + 0 1 1984.574661 625.447468 + 1 2 2038.488800 711.010030 + 2 3 2045.812302 644.997573 + + ``get_params("ram_limit")`` returns the current limit in megabytes. + + .. tab-item:: Command line + + .. code-block:: bash + + nyxus --ramLimit=2048 --intDir=/data/int --segDir=/data/seg --outDir=/data/out --outputType=singlecsv + +The default limit is half of the physical memory available when the run starts, or when the +Python object is created. A limit above the available memory is refused: the command line stops +with ``exceeds the actual amount of available RAM``. The Python classes print that error and keep +their previous limit. + +What counts as available memory depends on the platform: + +* **Windows:** the available physical memory. +* **Linux:** the free memory, ``MemFree`` in ``/proc/meminfo``. It leaves out the page cache, + which Linux fills with recently read files and releases on demand, so the default limit is + usually lower than the memory a process can get, and it falls as the cache grows. The same + figure caps ``--ramLimit``, so a limit can be refused while enough memory is available. +* **macOS:** the total physical memory, however much of it is in use. + +Container memory limits are not read. In a Docker container or another cgroup with a memory +limit, the default limit is computed from the host's memory, so a ROI that fits it can need more +memory than the container allows, and the container then stops Nyxus. In a container, set +``--ramLimit`` (Python ``ram_limit``) below the container's memory limit. + +``Nyxus.featurize``, which takes numpy arrays, has no out-of-core path. An oversized ROI makes it +raise ``RuntimeError`` with ``the following ROIS are oversized and cannot be processed``. + +Temporary files +^^^^^^^^^^^^^^^ + +Out-of-core ROIs are written to the system temporary directory. ``--tempDir`` is accepted, must +name an existing directory and is shown in the run summary, but the temporary files are written to +the system temporary directory regardless. To move them, set the ``TMPDIR`` environment variable +on Linux and macOS, or ``TMP`` on Windows, before you start Nyxus. + +``--onlineStatsThresh`` is a reserved name. The option is not accepted: a run that passes it stops +with ``unrecognized arguments``. + +Which features differ out of core +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In 3D, every family gives the same values out of core as in memory, the 3D axis features up to +the last digits (see :doc:`Math/f_morphology_3d`). A test volume with two ROIs, computed with +``ram_limit=0``, matched the in-memory run in the 213 ``*3D_ALL*`` features, and so did a whole +volume. This holds for inputs Nyxus can stream; see the end of +:ref:`large-images-tiles` for those it refuses. + +In 2D, the intensity, basic morphology, convex hull, ellipse, caliper, chord, Euler, extrema, +erosion, texture, Gabor and Zernike features and the unweighted moments match the in-memory values +on an image that Nyxus reads as one tile (see :ref:`large-images-tiles` for other images), with two +texture exceptions: in IBSI mode 2D NGTDM matches only on the first ROI computed out of core, a known +defect (`#512 `__; see :doc:`Math/f_ngtdm`), and NGLDM differs on a constant ROI (see :doc:`Math/f_ngldm`). The +contour-based features, the weighted moments and the fractal dimension do not. On a 256 x 256 test +image with 66 ROIs, 116 of the 747 output columns of ``*ALL*`` differ when every ROI is computed +out of core. + +The rest of this section describes runs without anisotropy. With ``--anisox`` or ``--anisoy`` +(Python ``anisotropy_x``, ``anisotropy_y``), the radial distribution and the weighted intensity +moments get their in-memory values out of core, while the out-of-core contour is wrong, so every +contour-based feature can differ, a known defect (`#509 `__). See :doc:`Math/f_morphology`, +:doc:`Math/f_radial_distribution` and :doc:`Math/f_2dmoments`. + +The out-of-core contour differs from the in-memory one in three ways, a known defect +(`#509 `__). It is a single loop: Nyxus +keeps the first loop it traces and drops holes and further fragments. Its coordinates are relative +to the ROI's bounding box, while the pixels keep their image positions. And its pixels come in a +different order. On a 340 x 340 image, a 300 x 300 square with a 100 x 100 hole has ``PERIMETER`` +1196 out of core, the outer loop only, against 1876 in memory, and the same square cut into two +130-pixel-wide bars has ``DIAMETER_MIN_ENCLOSING_CIRCLE`` 325.6 against 422.9. + +.. list-table:: + :header-rows: 1 + :widths: 35 65 + + * - Features + - Out-of-core value + * - ``PERIMETER``, ``DIAMETER_EQUAL_PERIMETER``, ``CIRCULARITY``, ``GEODETIC_LENGTH``, + ``THICKNESS`` + - The perimeter is the number of pixels of the single loop, not the traced length: 14% + shorter on the median ROI, and shorter still on a ROI with holes or fragments. The + features derived from it change accordingly. + * - ``ROI_RADIUS_*``, ``DIAMETER_INSCRIBING_CIRCLE``, ``DIAMETER_CIRCUMSCRIBING_CIRCLE``, + ``FRAC_AT_D_*``, ``MEAN_FRAC_*``, ``RADIAL_CV_*``, the weighted moments + (``WEIGHTED_*``, ``WT_NORM_CTR_MOM_*``, ``IMOM_W*``) + - Without anisotropy, wrong for ROIs away from the image origin, because pixel-to-contour + distances are off by + the bounding box's offset. ``ROI_RADIUS_MAX`` was 28 times its in-memory value on the + median ROI, and the weighted moments of one ROI change as it moves in the image. A + bounding box that starts at pixel (0, 0) is necessary for the in-memory values but not + sufficient: a ROI with holes or fragments still differs, and the radial distribution + can differ through the contour order (2 of 15 random ellipses at the origin). + * - ``EDGE_*``, ``DIAMETER_MIN_ENCLOSING_CIRCLE`` + - Differ where the out-of-core contour holds other pixels than the in-memory one: the + single loop leaves out holes and fragments, and on some ROIs either tracer skips part of + the outline. ``DIAMETER_MIN_ENCLOSING_CIRCLE`` also changes with the contour order. On the + 66-ROI test image, which has no holes or fragments, they differ on 5 and 6 ROIs. + * - ``FRACT_DIM_BOXCOUNT``, ``FRACT_DIM_PERIMETER`` + - Box counting uses a single unshifted grid for every out-of-core ROI, so ROIs whose padded + side is 32 or less can read differently (the fractal dimension worked example gives + 1.3966 instead of 1.3732). The divider walks the out-of-core contour, which holds one + loop in a different order, so it can differ too. ``FRACT_DIM_BOXCOUNT`` differs on 38 of + the 66 ROIs and ``FRACT_DIM_PERIMETER`` on 11. + * - Neighbor features (``NUM_NEIGHBORS``, ``PERCENT_TOUCHING``, ``CLOSEST_NEIGHBOR*``, + ``ANG_BW_NEIGHBORS_*``), ``POLYGONALITY_AVE``, ``HEXAGONALITY_*`` + - Not computed. The neighbor features are reported as 0, and ``POLYGONALITY_AVE`` and + ``HEXAGONALITY_*`` as -1, their value for a ROI with fewer than 3 neighbors. + +If you need these features for large ROIs, raise the RAM limit so that the ROIs are computed in +memory. + +When a feature fails on an out-of-core 2D ROI, Python raises ``RuntimeError`` with the message +``Error while computing feature ... over oversized ROI ...``. The command line prints the same +message and continues: the ROI's row is written with 0 in that feature's columns, and the run +exits with status 0. For example, on a 120 x 120 ROI of a 256 x 256 image stored in 64 x 64 +tiles, ``EROSIONS_2_VANISH`` failed out of core with ``invalid vector subscript`` (see +:ref:`large-images-tiles`). After a command-line run with large ROIs, search the console output for +``Error while computing feature``. + +.. _large-images-tiles: + +Tiled images and ROIs spanning tiles +------------------------------------ + +Nyxus reads tiled and striped TIFF files tile by tile. A tiled file is read in its own tiles; a +striped file is read in tiles of 1024 x 1024 pixels, so a striped image larger than 1024 pixels in +either direction is read as several tiles. An intensity image and its label image must have the +same tile size; a pair with different tile sizes is refused with an error that starts +``mismatch in tile height, width``. + +In memory and in whole-slide mode, tiling does not change the results: a 256 x 256 image with 66 +ROIs, 30 of them crossing 64 x 64 tile borders, gave the same features tiled and striped. + +.. warning:: + + Out-of-core processing of a 2D image is correct only when Nyxus reads the image as one tile: a + tiled TIFF with a single tile, or a striped TIFF of at most 1024 x 1024 pixels. On any other + image it places the pixels of later tiles at wrong positions, and on a striped image larger + than 1024 pixels it can also read ROI pixels more than once, so the results depend on the + family. Measured on a 120 x 120 square and a 120 x 120 elliptical ROI of a 256 x 256 image + stored in 64 x 64 tiles, and on a 300 x 300 square and a 300 x 240 elliptical ROI inside the + first 1024 x 1024 region of a striped 1200 x 1200 image: + + * Every family that builds the ROI's pixel matrix or contour (the texture families, the + moments, morphology, the radial distribution, the fractal dimension, Zernike and Gabor) stops + when a misplaced pixel lies outside the ROI's bounding box: from Python the call raises + ``RuntimeError`` ("pixel ... lies outside the bounding box ... that sizes its buffer"); the + command line prints that error and leaves those features uncomputed. Erosion stops with + ``invalid vector subscript`` instead. This happened for both ROIs on both images. + * When every misplaced pixel stays inside the bounding box, the same families return wrong + values without an error (see the plus-shaped ROI below). + * The intensity features were correct on the tiled image. On the striped image ROI pixels + were read more than once: the intensity features built from sums, such as ``MEAN``, differed + from the in-memory values, while ``MIN`` and ``MAX`` kept them. + + Keep large 2D ROIs in memory by raising the RAM limit, or split large images so that each is + read as one tile. + + The contour is traced from the misplaced pixels as well, so the contour features are wrong + even for a ROI whose bounding box starts at pixel (0, 0). On a + plus-shaped ROI of two 20-pixel bars spanning the image, stored in 64 x 64 tiles, + ``ROI_RADIUS_MAX`` out of core was 310.7 on a 256 x 256 image, where the in-memory value is + 13.45, and ``PERIMETER``, which out of core counts contour pixels, was 144 where the contour has + 1016 pixels; ``MEAN`` matched. On a 100 x 100 image the same ROI raised the error above. Stored striped (the images are at most 256 x 256 + pixels, so they are read as one tile), the same ROI gave the in-memory ``ROI_RADIUS_*`` values + out of core. + +Out-of-core processing of a volume streams it one read at a time. When a single read of the +intensity or label file returns the whole volume, as with a NIfTI file or an OME-Zarr array +chunked over the whole Z axis, Nyxus cannot stream it, and the run stops with an error that names +the input and the number of planes one read delivers. An array +chunked several planes deep, but not over all of Z, is streamed. Re-chunk the array along +Z, convert it to a Z stack of TIFF planes, or raise the RAM limit. diff --git a/docs/source/nyxus_workflow.jpg b/docs/source/nyxus_workflow.jpg deleted file mode 100644 index 922ed9940..000000000 Binary files a/docs/source/nyxus_workflow.jpg and /dev/null differ diff --git a/docs/source/nyxus_workflow.png b/docs/source/nyxus_workflow.png new file mode 100644 index 000000000..7d0a23e5d Binary files /dev/null and b/docs/source/nyxus_workflow.png differ diff --git a/docs/source/nyxus_workflow.svg b/docs/source/nyxus_workflow.svg new file mode 100644 index 000000000..51659508a --- /dev/null +++ b/docs/source/nyxus_workflow.svg @@ -0,0 +1,60 @@ + + The Nyxus pipeline: input, scan, compute and output + + + + + + + + + + + + + + + + + + + + + + Input + Scan + Compute + Output + + + + Intensity and label images + TIFF, OME-TIFF, OME-Zarr, + NIfTI, DICOM + or NumPy arrays + (Python, 2D) + 2D images, 3D volumes, + channels and timepoints + + Pair intensity and label images + Read each image tile by tile + and assemble every ROI, + including ROIs across tiles + Estimate each ROI's + memory footprint + + ROIs within the RAM limit: + in memory, in batches, + on several threads + Larger ROIs: + out of core + Optional GPU for some + feature families + + CSV, one file or + one per image + Arrow IPC or Parquet + pandas DataFrame + (Python) + + diff --git a/docs/source/output.rst b/docs/source/output.rst new file mode 100644 index 000000000..8fb7f0de9 --- /dev/null +++ b/docs/source/output.rst @@ -0,0 +1,203 @@ +.. _output: + +Output formats and columns +========================== + +Nyxus returns one table row per ROI, or one row per image in whole-slide mode. This page +describes where that table goes, how its columns are laid out, and how missing values appear. + + +Output types +------------ + +.. list-table:: + :header-rows: 1 + :widths: 20 20 60 + + * - Command line ``--outputType`` + - Python ``output_type`` + - Result + * - ``separatecsv`` + - -- + - One CSV file per intensity/label image pair. + * - ``singlecsv`` + - -- + - One CSV file for the whole run. + * - ``arrowipc`` + - ``"arrowipc"`` + - One Arrow IPC file (``.arrow``). Needs a build with Arrow support. + * - ``parquet`` + - ``"parquet"`` + - One Parquet file (``.parquet``). Needs a build with Arrow support. + * - -- + - ``"pandas"`` (default) + - A pandas DataFrame returned by the call. + +``--outputType`` has no default on the command line; the values are not case-sensitive. The PyPI +and conda packages are built with Arrow support. An example that writes and reads an Arrow IPC +file is in the section *Arrow IPC and Parquet output* of :doc:`cmdline_and_examples`. + + +File names and locations +------------------------ + +**Command line.** Every file goes to ``--outDir``, which must exist. + +* ``separatecsv`` names each file after its image pair: ``_INT__SEG_:`, for every base code of the family. +EXTRAS = REPO / "docs" / "feature_extras.toml" +FAMILY_PAGES = { + "glcm": {"page": "Math/f_glcm", "index": "Math/glcm_index.inc"}, + "glrlm": {"page": "Math/f_glrlm", "index": "Math/glrlm_index.inc"}, + "glszm": {"page": "Math/f_glszm", "index": "Math/glszm_index.inc"}, + "gldm": {"page": "Math/f_gldm", "index": "Math/gldm_index.inc"}, + "gldzm": {"page": "Math/f_gldzm", "index": "Math/gldzm_index.inc"}, + "ngldm": {"page": "Math/f_ngldm", "index": "Math/ngldm_index.inc"}, + "ngtdm": {"page": "Math/f_ngtdm", "index": "Math/ngtdm_index.inc"}, + "intensity": {"page": "Math/f_pixel_intensity", "index": "Math/intensity_index.inc"}, + "shape_moments": {"page": "Math/f_2dmoments", "index": "Math/shape_moments_index.inc"}, + "intensity_moments": {"page": "Math/f_2dmoments", "index": "Math/intensity_moments_index.inc"}, + "zernike": {"page": "Math/f_zernike", "index": "Math/zernike_index.inc"}, + "gabor": {"page": "Math/f_gabor", "index": "Math/gabor_index.inc"}, + "image_quality": {"page": "Math/f_image_quality", "index": "Math/image_quality_index.inc"}, + "fractal": {"page": "Math/f_fractal_dimension", "index": "Math/fractal_index.inc"}, + "basic_morphology": {"page": "Math/f_morphology", "index": "Math/basic_morphology_index.inc"}, + "contour": {"page": "Math/f_morphology", "index": "Math/contour_index.inc"}, + "convex_hull": {"page": "Math/f_morphology", "index": "Math/convex_hull_index.inc"}, + "ellipse": {"page": "Math/f_morphology", "index": "Math/ellipse_index.inc"}, + "circles": {"page": "Math/f_morphology", "index": "Math/circles_index.inc"}, + "roi_radius": {"page": "Math/f_morphology", "index": "Math/roi_radius_index.inc"}, + "extrema": {"page": "Math/f_morphology", "index": "Math/extrema_index.inc"}, + "euler": {"page": "Math/f_morphology", "index": "Math/euler_index.inc"}, + "geodetic": {"page": "Math/f_morphology", "index": "Math/geodetic_index.inc"}, + "feret": {"page": "Math/f_morphology", "index": "Math/feret_index.inc"}, + "martin": {"page": "Math/f_morphology", "index": "Math/martin_index.inc"}, + "nassenstein": {"page": "Math/f_morphology", "index": "Math/nassenstein_index.inc"}, + "chords": {"page": "Math/f_morphology", "index": "Math/chords_index.inc"}, + "neighbors": {"page": "Math/f_morphology", "index": "Math/neighbors_index.inc"}, + "erosion": {"page": "Math/f_erosion", "index": "Math/erosion_index.inc"}, + "ngldm_3d": {"page": "Math/f_ngldm_3d", "index": "Math/ngldm_3d_index.inc"}, + "glrlm_3d": {"page": "Math/f_glrlm_3d", "index": "Math/glrlm_3d_index.inc"}, + "glcm_3d": {"page": "Math/f_glcm_3d", "index": "Math/glcm_3d_index.inc"}, + "intensity_3d": {"page": "Math/f_intensity_3d", "index": "Math/intensity_3d_index.inc"}, + "ngtdm_3d": {"page": "Math/f_ngtdm_3d", "index": "Math/ngtdm_3d_index.inc"}, + "gldm_3d": {"page": "Math/f_gldm_3d", "index": "Math/gldm_3d_index.inc"}, + "glszm_3d": {"page": "Math/f_glszm_3d", "index": "Math/glszm_3d_index.inc"}, + "gldzm_3d": {"page": "Math/f_gldzm_3d", "index": "Math/gldzm_3d_index.inc"}, + "shape_3d": {"page": "Math/f_morphology_3d", "index": "Math/shape_3d_index.inc"}, + "intensity_histogram": {"page": "Math/f_intensity_histogram", "index": "Math/intensity_histogram_index.inc"}, + "radial": {"page": "Math/f_radial_distribution", "index": "Math/radial_index.inc"}, +} + + +def label(code): + return "feature-" + code.lower().replace("_", "-") + + +def base_codes(features, slug): + # A code ending in _AVE is the angle average of a per-angle code only when that code is also + # in the family; otherwise (POLYGONALITY_AVE, HEXAGONALITY_AVE) it is a base code of its own. + members = [f for f in features if family_of(f) == slug] + codes = {f.code for f in members} + return [f for f in members if not (f.code.endswith("_AVE") and f.code[:-4] in codes)] +DIM_TITLES = {"2D": "2D features", "3D": "3D features", "IMQ": "Image quality features"} +STATUS_TEXT = {"vetted": "vetted", "invariant": "invariant", "regression": "regression", "untested": "untested"} +ORACLE_NAMES = { + "pyradiomics": "PyRadiomics", "radiomicsj": "RadiomicsJ", "mirp": "MIRP", "matlab": "MATLAB", + "cellprofiler": "CellProfiler", "mitk": "MITK", "feature2djava": "Feature2DJava", + "wndcharm": "WND-CHARM", "imea": "IMEA", "imagej": "ImageJ", "fraclac": "FracLac", + "ibsi": "IBSI", "analytic": "analytic", "skimage": "scikit-image", "pydicom": "pydicom", + "opencv": "OpenCV", +} + + +def family_of(feature): + if (feature.dim, feature.code) in CODE_FAMILY: + return CODE_FAMILY[(feature.dim, feature.code)] + for slug, _, dim, classes in FAMILIES: + if dim == feature.dim and (classes == ["*"] or feature.feature_class in classes): + return slug + return None + + +def validation(with_tolerance=False): + """{(dim, code): (status, [oracles])} rolled up from the registry; with_tolerance adds, as a + third element, {oracle: [tolerances]} of the vetted rows.""" + rank = {"untested": 0, "regression": 1, "invariant": 2, "vetted": 3} + best, oracles, tols = {}, {}, {} + with open(REGISTRY, newline="") as fh: + for r in csv.DictReader(fh): + key = (r["dim"].strip(), r["feature"].strip()) + st = r["status"].strip() + if key not in best or rank[st] > rank[best[key]]: + best[key] = st + if st == "vetted" and r["oracle"].strip(): + oracles.setdefault(key, set()).add(r["oracle"].strip()) + tols.setdefault(key, {}).setdefault(r["oracle"].strip(), set()) + if r["tolerance"].strip(): + tols[key][r["oracle"].strip()].add(r["tolerance"].strip()) + out = {} + for k, v in best.items(): + ors = sorted(oracles.get(k, ())) if v == "vetted" else [] + per_oracle = {o: sorted(t) for o, t in tols.get(k, {}).items()} if v == "vetted" else {} + out[k] = (v, ors, per_oracle) if with_tolerance else (v, ors) + return out + + +from gen_cli_reference import escape # noqa: E402 options and group names as literals, the rest escaped + + +def validation_cell(v): + if v is None: + return "untested" + status, oracles = v + if status == "vetted": + return "vetted: " + ", ".join(ORACLE_NAMES.get(o, o) for o in oracles) + return STATUS_TEXT[status] + + +def render_list(features, desc, notes, valid): + out = [ + ".. This page is generated by docs/tools/gen_feature_reference.py. Edit", + " docs/feature_descriptions.toml and re-run the script; do not edit this file by hand.", + "", + "Feature list", + "============", + "", + f"Nyxus computes {sum(f.dim == '2D' for f in features)} features for 2D images, " + f"{sum(f.dim == '3D' for f in features)} for 3D volumes and " + f"{sum(f.dim == 'IMQ' for f in features)} image quality features. Request a feature by its " + "code, or a family of features by its group (see :doc:`featuregroups`).", + "", + "The *Validation* column says how each feature is tested:", + "", + "* **vetted** -- the test suite checks its value against an independent implementation,", + " published reference values (IBSI) or a closed-form calculation (analytic), named in the", + " column;", + "* **invariant** -- the test suite checks a relation its value must satisfy, such as equality", + " between two Nyxus code paths;", + "* **regression** -- the test suite pins its current value, so a change is noticed, but no", + " independent implementation confirms it;", + "* **untested** -- no test checks its value.", + "", + ] + for dim in ("2D", "3D", "IMQ"): + out += [DIM_TITLES[dim], "-" * len(DIM_TITLES[dim]), ""] + for slug, title, fdim, _ in FAMILIES: + if fdim != dim: + continue + rows = [f for f in features if family_of(f) == slug] + if not rows: + continue + heading = title if dim != "3D" else f"{title} (3D)" + out += [f".. _features-{slug.replace('_', '-')}:", "", heading, "^" * len(heading), ""] + if notes.get(slug): + out += [escape(" ".join(notes[slug].split())), ""] + if slug in FAMILY_PAGES: + out += [f"Definitions, formulas and settings: :doc:`/{FAMILY_PAGES[slug]['page']}`.", ""] + out += [".. list-table::", " :header-rows: 1", " :widths: 30 50 20", "", + " * - Code", " - Description", " - Validation"] + for f in rows: + text = desc.get(slug, {}).get(f.code, "") + out += [f" * - ``{f.code}``", + f" - {escape(' '.join(text.split())) or '—'}", + f" - {validation_cell(valid.get((f.dim, f.code)))}"] + out.append("") + return "\n".join(out).rstrip("\n") + "\n" + + +def render_groups(features, groups): + out = [ + ".. This page is generated by docs/tools/gen_feature_reference.py; do not edit it by hand.", + "", + "Feature groups", + "==============", + "", + "A group name requests every feature in the group. Group names and feature codes can be", + "mixed in one request, and are not case-sensitive on the command line:", + "", + ".. code-block:: bash", + "", + " nyxus --features=\"*ALL_INTENSITY*,*ALL_GLCM*,ZERNIKE2D\" ...", + "", + "``*ALL*`` requests every 2D feature, ``*3D_ALL*`` every 3D feature. Image quality features", + "are requested only through their own codes or ``*ALL_IMQ*``, and not together with other", + "features. The intensity histogram features (``IH_*``, in ``*ALL*`` and ``*ALL_IH*``) are", + "computed only in :doc:`IBSI mode `; without it they are left out of the output.", + "", + "A group or feature name prefixed with ``-`` is removed from the request (except ``-*ALL*``", + "and ``-COV``, which add, and image quality feature codes, where ``-`` is ignored and the", + "feature is added; ``-*ALL_IMQ*`` stops with an error), and names are applied from left to", + "right; see", + ":ref:`requesting-feature-groups`. ``HISTOGRAM`` is in ``*ALL*`` but in no other group, not", + "even ``*ALL_INTENSITY*``.", + "", + ] + titles = {slug: (title if dim != "3D" else f"{title} (3D)") for slug, title, dim, _ in FAMILIES} + unusable = [] + for dim in ("2D", "3D", "IMQ"): + rows = [g for g in groups if g.dim == dim] + out += [DIM_TITLES[dim].replace("features", "groups"), "-" * len(DIM_TITLES[dim].replace("features", "groups")), "", + ".. list-table::", " :header-rows: 1", " :widths: 26 10 64", "", + " * - Group", " - Features", " - Families"] + for g in rows: + if not g.expanded: + unusable.append(g) + continue + m = reg.members(g, features) + fams = [] + for f in m: + fam = family_of(f) + if fam not in fams: + fams.append(fam) + links = ", ".join(f":ref:`{titles[s]} `" for s in fams) + out += [f" * - ``{g.name}``", f" - {len(m)}", f" - {links}"] + out.append("") + if unusable: + names = ", ".join(f"``{g.name}``" for g in unusable) + out += [".. note::", "", + f" {names} are accepted as group names but no features are defined for them, so a", + " request that uses them stops with an error.", ""] + return "\n".join(out).rstrip("\n") + "\n" + + +def render_family_index(features, slug, extras, valid): + """The feature index table of a family's definition page.""" + out = [".. Generated by docs/tools/gen_feature_reference.py from docs/feature_extras.toml and", + " tests/vetting/oracle_coverage.csv; do not edit by hand.", + "", + ".. list-table::", + " :header-rows: 1", + " :widths: 24 34 8 26 8", + "", + " * - Code", + " - Name", + " - IBSI", + " - Validation", + " - Example"] + for f in base_codes(features, slug): + e = extras[f.code] + status, oracles, tols = valid.get((f.dim, f.code), ("untested", [], {})) + if status == "vetted": + # each oracle with its own tolerances, so a row with several oracles pairs them correctly + parts = [] + for o in oracles: + ts = ", ".join(t.replace("rel=", "rel. ").replace("abs=", "abs. ") for t in tols.get(o, [])) + parts.append(ORACLE_NAMES.get(o, o) + (f" ({ts})" if ts else "")) + v = ", ".join(parts) + else: + v = status + out += [f" * - :ref:`{f.code} <{label(f.code)}>`", + f" - {escape(e['name'])}", + f" - {e['ibsi'] or '—'}", + f" - {v}", + " - " + f"{e['example']:g}".replace("-", "−")] + return "\n".join(out) + "\n" + + +def check_family_pages(features, extras): + problems = [] + for slug, cfg in FAMILY_PAGES.items(): + page = REPO / "docs" / "source" / (cfg["page"] + ".rst") + text = page.read_text(encoding="utf-8") if page.exists() else "" + codes = [f.code for f in base_codes(features, slug)] + fam_extras = extras.get(slug, {}) + for c in codes: + if c not in fam_extras: + problems.append(f"{c}: no entry in [{slug}] of {EXTRAS.name}") + if f".. _{label(c)}:" not in text: + problems.append(f"{c}: {cfg['page']}.rst has no entry '.. _{label(c)}:'") + for c in fam_extras: + if c not in codes: + problems.append(f"[{slug}] {c} in {EXTRAS.name} is not a base code of that family") + return problems + + +def check_descriptions(features, desc): + problems = [] + codes = {(family_of(f), f.code) for f in features} + for f in features: + fam = family_of(f) + if fam is None: + problems.append(f"{f.dim} {f.code}: no family in FAMILIES claims class {f.feature_class or '(none)'}") + elif not " ".join(desc.get(fam, {}).get(f.code, "").split()): + problems.append(f"{f.code}: no entry in [{fam}] of {DESCRIPTIONS.name}") + for fam, entries in desc.items(): + for code in entries: + if (fam, code) not in codes: + problems.append(f"[{fam}] {code} in {DESCRIPTIONS.name} is not a feature code of that family") + return problems + + +COUNT_PAGES = [REPO / "README.md", REPO / "docs" / "source" / "index.rst", REPO / "CLAUDE.md"] + + +def check_counts(features): + # README.md, the landing page and CLAUDE.md state the feature counts in prose; keep them equal + # to the registry. + n = {d: sum(1 for f in features if f.dim == d) for d in ("2D", "3D", "IMQ")} + sentence = (f"{sum(n.values())} features in all, {n['2D']} for 2D images, {n['3D']} for 3D volumes " + f"and {n['IMQ']} image quality features") + return [f"{p.relative_to(REPO)} does not say \"{sentence}\"" for p in COUNT_PAGES + if sentence not in " ".join(p.read_text(encoding="utf-8").split())] + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--check", action="store_true", help="verify instead of writing") + args = ap.parse_args(argv) + + features, groups = reg.load() + with open(DESCRIPTIONS, "rb") as f: + data = tomllib.load(f) + notes = data.pop("family_notes", {}) + desc = data + with open(EXTRAS, "rb") as f: + extras = tomllib.load(f) + problems = check_descriptions(features, desc) + check_family_pages(features, extras) + check_counts(features) + if any(family_of(f) is None for f in features): + # the pages cannot be rendered without a family for every code + for p in problems: + print(f"error: {p}", file=sys.stderr) + return 1 + pages = {LIST_PAGE: render_list(features, desc, notes, validation()), + GROUPS_PAGE: render_groups(features, groups)} + valid_t = validation(with_tolerance=True) + for slug, cfg in FAMILY_PAGES.items(): + if all(f.code in extras.get(slug, {}) for f in base_codes(features, slug)): + pages[REPO / "docs" / "source" / cfg["index"]] = render_family_index(features, slug, extras.get(slug, {}), valid_t) + + if args.check: + for page, text in pages.items(): + if not page.exists() or page.read_text(encoding="utf-8") != text: + problems.append(f"{page.relative_to(REPO)} is stale; run docs/tools/gen_feature_reference.py") + for p in problems: + print(f"error: {p}", file=sys.stderr) + if problems: + return 1 + if not args.check: + for page, text in pages.items(): + page.write_text(text, encoding="utf-8", newline="\n") + print(f"wrote {page.relative_to(REPO)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/tools/gen_gabor_figure.py b/docs/tools/gen_gabor_figure.py new file mode 100644 index 000000000..31b0cb06a --- /dev/null +++ b/docs/tools/gen_gabor_figure.py @@ -0,0 +1,38 @@ +"""Draw docs/source/Math/gabor_gamma.png, the Gabor kernel figure of the Gabor feature page. + + python docs/tools/gen_gabor_figure.py [output.png] + +The kernel is a line-for-line port of GaborFeature::Gabor, so the figure shows what Nyxus computes. +""" +import sys + +import matplotlib +import numpy as np +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +def gabor(f0, sig2lam, gamma, theta, fi, n): + """Line-for-line port of GaborFeature::Gabor (src/nyx/features/gabor.cpp:393-449).""" + lam = 2 * np.pi / f0 + sig = sig2lam * lam + t = np.arange(n) - ((n - 1) // 2 if n % 2 else n // 2) + ty, tx = np.meshgrid(t, t, indexing="ij") # row index y, column index x + xte = tx * np.cos(theta) + ty * np.sin(theta) + yte = ty * np.cos(theta) - tx * np.sin(theta) + ge = np.exp(-(xte**2 + gamma**2 * yte**2) / (2 * sig**2)) + re, im = ge * np.cos(xte * f0 + fi), ge * np.sin(xte * f0 + fi) + s = np.sqrt(re**2 + im**2).sum() + return re / s, im / s + +theta, f0, n = 3 * np.pi / 4, np.pi / 4, 64 +fig, axes = plt.subplots(1, 3, figsize=(11, 3.9), dpi=110) +for ax, g in zip(axes, (0.25, 0.5, 1.0)): + re, _ = gabor(f0, 0.8, g, theta, 0.0, n) + v = np.abs(re).max() + im = ax.imshow(re, cmap="gray", vmin=-v, vmax=v, origin="upper") + ax.set_title(f"$\\gamma$ = {g:g}") + ax.set_xlabel("column $x$"); ax.set_ylabel("row $y$") + fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) +fig.suptitle("Real part of the Nyxus Gabor kernel, $\\theta = 3\\pi/4$, $f = \\pi/4$, sig2lam = 0.8, 64 x 64 taps", fontsize=10) +fig.tight_layout() +fig.savefig(sys.argv[1] if len(sys.argv) > 1 else "docs/source/Math/gabor_gamma.png", facecolor="white") diff --git a/environment.yml b/environment.yml index 85937eba0..899437401 100644 --- a/environment.yml +++ b/environment.yml @@ -17,17 +17,12 @@ dependencies: - numpy - pip>=20.1 - pyarrow - - fmjpeg2koj + - fmjpeg2koj - arrow-cpp[version='>=10.0.0'] - parquet-cpp - pip: - - sphinx - - sphinx_rtd_theme - - autodocsumm - - sphinx-autodoc-typehints - setuptools-scm>=8 - ghapi==0.1.20 - nox==2022.1.7 - pytest==6.2.4 - rich==12.4.4 - - sphinx_rtd_theme==1.0.0 diff --git a/plugin.json b/plugin.json index 52c2aef53..5538e8473 100644 --- a/plugin.json +++ b/plugin.json @@ -15,14 +15,14 @@ "description": "Labeled image collection", "type": "collection", "options": null, - "required": false + "required": true }, { "name": "intDir", "description": "Intensity image collection", "type": "collection", "options": null, - "required": false + "required": true }, { "name": "filePattern", @@ -101,24 +101,6 @@ "*ALL_NGTDM*" ] }, - { - "description": "ALL_BUT_GABOR", - "enum": [ - "*ALL_BUT_GABOR*" - ] - }, - { - "description": "ALL_BUT_GLCM", - "enum": [ - "*ALL_BUT_GLCM*" - ] - }, - { - "description": "ALL_EASY = ALL except GABOR,GLCM, and 2D moments", - "enum": [ - "*ALL_EASY*" - ] - }, { "description": "MEAN", "enum": [ @@ -326,12 +308,6 @@ "ASPECT_RATIO" ] }, - { - "description": "EQUIVALENT_DIAMETER", - "enum": [ - "EQUIVALENT_DIAMETER" - ] - }, { "description": "CONVEX_HULL_AREA", "enum": [ @@ -356,36 +332,6 @@ "CIRCULARITY" ] }, - { - "description": "EDGE_INTEGRATEDINTENSITY", - "enum": [ - "EDGE_INTEGRATEDINTENSITY" - ] - }, - { - "description": "EDGE_MAXINTENSITY", - "enum": [ - "EDGE_MAXINTENSITY" - ] - }, - { - "description": "EDGE_MEANINTENSITY", - "enum": [ - "EDGE_MEANINTENSITY" - ] - }, - { - "description": "EDGE_MININTENSITY", - "enum": [ - "EDGE_MININTENSITY" - ] - }, - { - "description": "EDGE_STDDEVINTENSITY", - "enum": [ - "EDGE_STDDEVINTENSITY" - ] - }, { "description": "EXTREMA_P1_X", "enum": [ @@ -482,18 +428,6 @@ "EXTREMA_P8_Y" ] }, - { - "description": "MIN_FERET_DIAMETER", - "enum": [ - "MIN_FERET_DIAMETER" - ] - }, - { - "description": "MAX_FERET_DIAMETER", - "enum": [ - "MAX_FERET_DIAMETER" - ] - }, { "description": "MIN_FERET_ANGLE", "enum": [ @@ -668,84 +602,6 @@ "THICKNESS" ] }, - { - "description": "TEXTURE_ANGULAR2NDMOMENT", - "enum": [ - "TEXTURE_ANGULAR2NDMOMENT" - ] - }, - { - "description": "TEXTURE_CONTRAST", - "enum": [ - "TEXTURE_CONTRAST" - ] - }, - { - "description": "TEXTURE_CORRELATION", - "enum": [ - "TEXTURE_CORRELATION" - ] - }, - { - "description": "TEXTURE_VARIANCE", - "enum": [ - "TEXTURE_VARIANCE" - ] - }, - { - "description": "TEXTURE_INVERSEDIFFERENCEMOMENT", - "enum": [ - "TEXTURE_INVERSEDIFFERENCEMOMENT" - ] - }, - { - "description": "TEXTURE_SUMAVERAGE", - "enum": [ - "TEXTURE_SUMAVERAGE" - ] - }, - { - "description": "TEXTURE_SUMVARIANCE", - "enum": [ - "TEXTURE_SUMVARIANCE" - ] - }, - { - "description": "TEXTURE_SUMENTROPY", - "enum": [ - "\tTEXTURE_SUMENTROPY" - ] - }, - { - "description": "TEXTURE_ENTROPY", - "enum": [ - "TEXTURE_ENTROPY" - ] - }, - { - "description": "TEXTURE_DIFFERENCEVARIANCE", - "enum": [ - "TEXTURE_DIFFERENCEVARIANCE" - ] - }, - { - "description": "TEXTURE_DIFFERENCEENTROPY", - "enum": [ - "TEXTURE_DIFFERENCEENTROPY" - ] - }, - { - "description": "TEXTURE_INFOMEAS1", - "enum": [ - "TEXTURE_INFOMEAS1" - ] - }, - { - "description": "TEXTURE_INFOMEAS2", - "enum": [ - "TEXTURE_INFOMEAS2" - ] - }, { "description": "GLRLM_SRE", "enum": [ @@ -1105,12 +961,6 @@ "enum": [ "GABOR" ] - }, - { - "description": "EROSION_PIXELS", - "enum": [ - "EROSION_PIXELS" - ] } ], "default": "ALL_INTENSITY", @@ -1124,13 +974,13 @@ }, { "name": "outputType", - "description": "outputType : singlecsv for saving values in one csv file and separate csv to save values for each image in separate csv file, arrow and parquet for saving in Apache Arrow and Parquet file formats respectively", + "description": "outputType : singlecsv for saving values in one csv file, separatecsv to save values for each image in a separate csv file, arrowipc and parquet for saving in Apache Arrow IPC and Parquet file formats respectively", "type": "enum", "options": { "values": [ "singlecsv", "separatecsv", - "arrow", + "arrowipc", "parquet" ] }, @@ -1191,7 +1041,7 @@ { "key": "inputs.outputType", "title": "Output file type", - "description": "Save csv file separately for each image or one csv file for all images. Alternatively, output can be written as arrow or parquet file" + "description": "Save csv file separately for each image or one csv file for all images. Alternatively, output can be written as an Arrow IPC or Parquet file" }, { "key": "inputs.embeddedpixelsize", diff --git a/release-please-config.json b/release-please-config.json index a7170b36b..6be837d86 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -18,7 +18,7 @@ ], "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, - "extra-files": [".version"] + "extra-files": [".version", "CITATION.cff"] } } } diff --git a/src/nyx/python/nyxus/functions.py b/src/nyx/python/nyxus/functions.py index 265792d35..a8ce12a4e 100644 --- a/src/nyx/python/nyxus/functions.py +++ b/src/nyx/python/nyxus/functions.py @@ -1,7 +1,48 @@ from .backend import gpu_available_imp, get_gpu_props def gpu_is_available (nyxus_instance_id): + r"""Report whether a CUDA GPU can be used. + + Parameters + ---------- + nyxus_instance_id : int + ``id()`` of a :class:`Nyxus`, :class:`Nyxus3D` or + :class:`ImageQuality` instance. + + Returns + ------- + bool + True when this build has GPU support and a CUDA device is present. + + Examples + -------- + >>> import nyxus + >>> nyx = nyxus.Nyxus(["MEAN"]) + >>> available = nyxus.gpu_is_available(id(nyx)) + """ return gpu_available_imp (nyxus_instance_id) def get_gpu_properties (nyxus_instance_id): + r"""Describe the CUDA GPU devices. + + Parameters + ---------- + nyxus_instance_id : int + ``id()`` of a :class:`Nyxus`, :class:`Nyxus3D` or + :class:`ImageQuality` instance. + + Returns + ------- + list of dict + One dict of property name to value (both str) per device. Empty when + this build has no GPU support. + + Examples + -------- + >>> import nyxus + >>> nyx = nyxus.Nyxus(["MEAN"]) + >>> props = nyxus.get_gpu_properties(id(nyx)) + >>> isinstance(props, list) + True + """ return get_gpu_props (nyxus_instance_id) \ No newline at end of file diff --git a/src/nyx/python/nyxus/nyxus.py b/src/nyx/python/nyxus/nyxus.py index 5b79e1835..90829505c 100644 --- a/src/nyx/python/nyxus/nyxus.py +++ b/src/nyx/python/nyxus/nyxus.py @@ -28,134 +28,238 @@ from typing import Optional, List class Nyxus: - r"""Nyxus image feature extraction library + r"""Extract 2D features from images and their ROI masks. - Scalably extracts features from images. - - Example: - nyx = Nyxus( - features = [["*ALL*"]], - neighbor_distance = 5, - pixels_per_micron = 1.0, - coarse_gray_depth= 256, - n_feature_calc_threads = 4, - use_gpu_device = 2, - ibsi = False, - gabor_kersize = 16, - gabor_gamma = 0.1, - gabor_sig2lam = 0.8, - gabor_f0 = 0.1, - gabor_thold = 0.025, - gabor_thetas = [0, 45, 90, 135], - gabor_freqs = [4, 16, 32, 64] - ) + Each instance holds its own settings. Features are computed for every + labelled ROI of each intensity/mask image pair, from numpy arrays + (:meth:`featurize`), a directory (:meth:`featurize_directory`) or lists of + files (:meth:`featurize_files`). Parameters ---------- - features : list[str] - List of features to be calculated. Individual features can be - provided or pre-specified feature groups. Valid groups include: - *ALL* - *ALL_INTENSITY* - *ALL_MORPHOLOGY* - *BASIC_MORPHOLOGY* - *ALL_GLCM* - *ALL_GLRM* - *ALL_GLSZM* - *ALL_GLDM* - *ALL_NGTDM* - *ALL_BUT_GABOR* - *ALL_BUT_GLCM* - Both individual features and feature groups are case sensitive. - neighbor_distance: int (optional, default 5) - Any two objects separated by a Euclidean distance (pixel units) greater than this - value will not be be considered neighbors. This cutoff is used by all features which - rely on neighbor identification. - pixels_per_micron: float (optional, default 1.0) - Specify the image resolution in terms of pixels per micron for unit conversion - of non-unitless features. - coarse_gray_depth: int (optional, default 64) - Custom number of levels in grayscale denoising used in texture features. - n_feature_calc_threads: int (optional, default 4) - Number of threads to use for feature calculation parallelization purposes. - use_gpu_device: int (optional, default -1) - Id of the gpu to use. To find available gpus along with ids, using nyxus.get_gpu_properties(). - The default value of -1 uses cpu calculations. Note that the gpu features only support a single - thread for feature calculation. - ibsi: bool (optional, default false) - IBSI available features will be IBSI compliant when true. Required to enable - the Intensity Histogram (IH_*) feature family (*ALL_IH*). - mergerois: bool (optional, default false) - Merge all nonzero mask labels into a single whole-foreground ROI (background - excluded) instead of one ROI per label. - gabor_kersize: int (optional, default 16) - Size of filter kernel's side - gabor_gamma: float (optional, default 0.1) - Aspect ratio of the Gaussian factor - gabor_sig2lam: float (optional, default 0.8) - Spatial frequency bandwidth. - gabor_f0: float (optional, default 0.1) - frequency of the baseline lowpass filter as denominator of `\pi`. - gabor_thetas: list[float] (optional, default [0, 45, 90, 135]): - Orientation of the Gaussian in degrees 0-180. - gabor_thold: float (optional, 0.025) - lower threshold of the filtered image to baseline ratio. - gabor_freqs: list[float] (optional, default [4, 16, 32, 64]) - comma-separated denominators of `\pi` as frequencies of Gabor filter's harmonic factor. - channel_signature: (optional) - Channel signature to match files with the given channel name, such as channel_signature='c' for '_c' like in 'p0_y1_r1_c1.ome.tiff' - parent_channel: (optional) - Channel number that should be used as a provider of parent segments - child_channel: (optional) - Channel number that should be used as a provider of child segments - aggregate: (optional) - Name of a method how to aggregate features of segments recognized as children of same parent segment. - Options are 'SUM', 'MEAN', 'MIN', 'MAX', 'WMA', and 'NONE'. - dynamic_range: (optional) - Desired dynamic range of voxels of a floating point TIFF image. - min_intensity: (optional, default 0.0) - Minimum intensity of voxels of a floating point TIFF image. - max_intensity: (optional, default 1.0) - Maximum intensity of voxels of a floating point TIFF image. - preserve_hu: bool (optional, default False) - NOT needed for CT: Hounsfield units are preserved and reported either way, - despite this option's name. What it actually does: - Carry a FLOATING-POINT slide on the offset-preserving map (1 grey level = 1 - intensity unit, offset by the slide's floored minimum) instead of the min-max - rescaling controlled by dynamic_range/min_intensity/max_intensity. A DICOM or - integer NIfTI slide takes it instead of the stored map, which keeps the stored - integers as grey levels and carries RescaleSlope/RescaleIntercept (scl_slope/ - scl_inter) in the recorded inverse, so a fractional slope loses no stored level. - Other integer slides take the offset map regardless of this flag. The DICOM and - NIfTI rescale reaches the reported values either way, so CT Hounsfield Units survive - the load either way and negative values never wrap. + features : list of str + Feature names and feature groups to compute. 2D groups: + ``*ALL*``, ``*WHOLESLIDE*``, ``*ALL_INTENSITY*``, ``*ALL_MORPHOLOGY*``, + ``*BASIC_MORPHOLOGY*``, ``*ALL_GLCM*``, ``*ALL_GLRLM*``, ``*ALL_GLDZM*``, + ``*ALL_GLSZM*``, ``*ALL_GLDM*``, ``*ALL_NGLDM*``, ``*ALL_NGTDM*``, + ``*ALL_NEIGHBOR*``, ``*ALL_IH*``, ``*GEOMOMS*``, ``*IGEOMOMS*`` and + ``*SGEOMOMS*``. + ``*ALL_IH*`` (the Intensity Histogram family) is computed only when + `ibsi` is True; without it the IH features are dropped silently. + Names are case-sensitive and must be upper case: ``"MEAN"`` is accepted, + ``"mean"`` raises ValueError. (The command-line tool upper-cases its + ``--features`` list; the Python API does not.) Entries are applied left to + right; a leading ``-`` removes a feature or group enabled by an earlier + entry (except ``-COV`` and ``-*ALL*``, which add) and a leading ``+`` + adds it, e.g. ``["*ALL_INTENSITY*", "-MEAN"]``. An empty list computes + no features (the command line instead falls back to ``*ALL*``). + neighbor_distance : int, optional + Default 5. Two ROIs farther apart than this Euclidean distance, in + pixels, are not neighbors. Used by every neighbor-based feature. + Must be positive. + pixels_per_micron : float, optional + Default 1.0. Must be positive. Stored and reported by + :meth:`get_params`; no feature currently depends on it + (``AREA_UM2`` is reported as 0, a known defect, + `#496 `__). + coarse_gray_depth : int, optional + Default 64. Must be positive. Number of grey levels intensities are + binned to for the texture features, and the number of bins of the + ``ENTROPY``, ``UNIFORMITY``, ``HISTOGRAM`` and ``IH_*`` histograms. + With ``ibsi=True`` the texture features are not binned and read the + image's own grey levels; the histograms still follow this depth. + n_feature_calc_threads : int, optional + Default 4. Number of feature calculation threads. Must be at least 1. + use_gpu_device : int, optional + Default -1 (CPU). Any non-negative value turns the GPU on for erosion + and the 2D moments (leave it off when computing ``GABOR`` and when + passing arrays to :meth:`featurize`, where it can end the process); the + computation runs on CUDA device 0 whatever the value (see + :func:`get_gpu_properties` and the GPU page). A + non-negative id raises ValueError when no GPU is available; a value + below -1 raises RuntimeError when this build of Nyxus has no GPU + support. + ibsi : bool, optional + Default False. Compute the IBSI-compliant variant of the features that + have one, and enable the Intensity Histogram (``IH_*``) family. The + texture features then skip grey-level binning, whatever + ``coarse_gray_depth`` is. + mergerois : bool, optional + Default False. Merge all nonzero mask labels into one whole-foreground + ROI (background excluded) instead of one ROI per label. + gabor_kersize : int, optional + Default 16. Side of the Gabor filter kernel, in pixels; at least 2 + (1 is accepted, but its values are arbitrary). + gabor_gamma : float, optional + Default 0.1. Aspect ratio of the Gaussian factor. + gabor_sig2lam : float, optional + Default 0.8. Spatial frequency bandwidth (sigma to lambda ratio). + gabor_f0 : float, optional + Default 0.1. Angular frequency of the baseline low-pass filter, in + radians per pixel; its wavelength is :math:`2\pi/f_0` pixels. + gabor_thold : float, optional + Default 0.025. Lower threshold of the filtered-image to baseline + ratio. + gabor_thetas : list of float, optional + Default [0, 45, 90, 135]. Orientations of the Gaussian, in degrees, + conventionally 0-180 (not enforced), one per entry of `gabor_freqs`. + gabor_freqs : list of float, optional + Default [4, 16, 32, 64]. Angular frequencies of the filters' harmonic + factor, in radians per pixel (wavelength :math:`2\pi/f` pixels), one + per entry of `gabor_thetas`. + dynamic_range : float, optional + Default 10000. Number of grey levels a floating-point image is + rescaled to. + min_intensity : float, optional + Default 0.0. Intensity of a floating-point image mapped to the lowest + grey level. + max_intensity : float, optional + Default 1.0. Intensity of a floating-point image mapped to + `dynamic_range`. + preserve_hu : bool, optional + Default False. Not needed for CT: Hounsfield units are preserved and + reported either way, despite the option's name. What it does: carries + a floating-point slide on the offset-preserving map (1 grey level = 1 + intensity unit, offset by the slide's floored minimum) instead of the + min-max rescaling controlled by `dynamic_range`, `min_intensity` and + `max_intensity`. A DICOM or integer NIfTI slide takes it instead of + the stored map, which keeps the stored integers as grey levels and + carries RescaleSlope/RescaleIntercept (scl_slope/scl_inter) in the + recorded inverse, so a fractional slope loses no stored level. Other + integer slides take the offset map regardless of this flag. The DICOM + and NIfTI rescale reaches the reported values either way, so CT + Hounsfield units survive the load and negative values never wrap. + Whichever map a slide takes is recorded and undone on the way out: - * Every intensity feature -- location (mean, median, mode, percentiles, min, max), - dispersion, the sum/energy family, and the Intensity Histogram (IH_*) features -- - is reported in the slide's own intensity domain. A CT read in Hounsfield units is - reported in Hounsfield units, negatives included. - * Shift-invariant intensity features (variance, standard deviation, skewness, - kurtosis, range, interquartile range) and all shape/texture features: unaffected. - * Sub-unit precision survives on the stored map, which reports a fractional - rescaled value exactly. Elsewhere grey levels are integers, so a value that is not - integer-valued is reported as the grey level it was stored as: truncated, or - rounded to nearest under preserve_hu. Hounsfield units are integer-valued, so CT - is unaffected. - ram_limit: int (optional) - Maximum amount of ram to be used by Nyxus in megabytes - verbose: int (optional, default 0) - Level of diagnostic information in the standard output. Non-negative. 0 is no diagnostic output. - anisotropy_x: float (optional, default 1.0) - Size of a pixel along X relative to Y. The geometric features are computed on the pixels resampled by - the anisotropy; intensity and texture features on the pixels as acquired, so it does not change them. - The intensity-weighted geometric features -- the EDGE_* statistics, WEIGHTED_CENTROID_X/Y, - MASS_DISPLACEMENT, the intensity moments IMOM_*, the radial distribution and ZERNIKE2D -- are computed - on the pixels as acquired too, each pixel at its position scaled by the anisotropy and weighted by its - intensity times its area, with distances to the contour and the Zernike unit disk in the same units. - Applies to featurize_directory() and featurize_files(); featurize() on in-memory arrays does not - apply anisotropy. - anisotropy_y: float (optional, default 1.0) - Y-dimension counterpart of anisotropy_x + + * Every intensity feature -- location (mean, median, mode, + percentiles, min, max), dispersion, the sum/energy family, and the + Intensity Histogram (IH_*) features -- is reported in the slide's + own intensity domain. A CT read in Hounsfield units is reported in + Hounsfield units, negatives included. + * Shift-invariant intensity features (variance, standard deviation, + skewness, kurtosis, range, interquartile range) and all + shape/texture features are unaffected. + * Sub-unit precision survives on the stored map, which reports a + fractional rescaled value exactly. Elsewhere grey levels are + integers, so a value that is not integer-valued is reported as the + grey level it was stored as: truncated, or rounded to nearest under + `preserve_hu`. Hounsfield units are integer-valued, so CT is + unaffected. + + Applies to images read from files; arrays passed to ``featurize`` do + not go through the file loaders. + ram_limit : int, optional + Memory budget in megabytes. A ROI read from files with a mask whose + estimated footprint reaches it is processed out of core. Arrays passed + to :meth:`featurize` are never processed out of core (the call raises + RuntimeError), and a whole-slide image that reaches it is refused. + Default -1 keeps the built-in limit, half + of the physical memory available when the instance is created. A + value larger than the available memory is rejected with a message on + standard error and the previous limit is kept. + verbose : int, optional + Default 0 (silent). Level of diagnostic output. Must be non-negative. + anisotropy_x : float, optional + Default 1.0. Size of a pixel along X relative to Y. Must be positive. + The geometric features are computed on the pixels resampled by the + anisotropy; intensity and texture features on the pixels as acquired, + so it does not change them. The intensity-weighted geometric features + -- the EDGE_* statistics, WEIGHTED_CENTROID_X/Y, MASS_DISPLACEMENT, the + intensity moments IMOM_*, the radial distribution and ZERNIKE2D -- are + computed on the pixels as acquired too, each pixel at its position + scaled by the anisotropy and weighted by its intensity times its area, + with distances to the contour and the Zernike unit disk in the same + units. Applies to ``featurize_directory`` and ``featurize_files``; + ``featurize`` on in-memory arrays does not apply anisotropy. + anisotropy_y : float, optional + Default 1.0. Y-dimension counterpart of `anisotropy_x`. Must be + positive. + + Other Parameters + ---------------- + use_physical_spacing : bool, optional + Default False. Accepted for parity with :class:`Nyxus3D`; it only + affects 3D volumes. + channel_signature, parent_channel, child_channel, aggregate + Accepted without a warning and ignored. Use :class:`Nested` for + parent-child analysis. + + Raises + ------ + ValueError + If a feature name or group is not recognized, or a numeric parameter + is out of range, or `use_gpu_device` is non-negative and no GPU is + available. + RuntimeError + If `use_gpu_device` is below -1 and this build has no GPU support. + + Notes + ----- + Unknown keyword arguments print a warning and are ignored. + + The enabled feature set and the per-feature settings derived from + `coarse_gray_depth` are fixed when the instance is constructed. Changing + `features`, `coarse_gray_depth` or `ibsi` afterwards updates what + :meth:`get_params` reports but not the features computed by this + instance; construct a new instance to change them. + + Gabor parameters are process-wide, not per instance: every constructor + call that accepts ``gabor_*`` arguments sets them (to the defaults when + omitted), and :meth:`set_gabor_feature_params` changes them for all + instances. + + Examples + -------- + >>> import numpy as np + >>> from nyxus import Nyxus + >>> intens = np.zeros((32, 32), dtype=np.uint16) + >>> labels = np.zeros((32, 32), dtype=np.uint16) + >>> intens[4:12, 4:12] = 100 + >>> labels[4:12, 4:12] = 1 + >>> intens[16:28, 16:28] = 200 + >>> labels[16:28, 16:28] = 2 + >>> nyx = Nyxus(["MEAN", "AREA_PIXELS_COUNT"]) + >>> df = nyx.featurize(intens, labels) + >>> df[["ROI_label", "MEAN", "AREA_PIXELS_COUNT"]].values.tolist() + [[1.0, 100.0, 64.0], [2.0, 200.0, 144.0]] + + Every keyword with its default value: + + >>> nyx = Nyxus( + ... ["*ALL_INTENSITY*", "*ALL_GLCM*"], + ... neighbor_distance=5, + ... pixels_per_micron=1.0, + ... coarse_gray_depth=64, + ... n_feature_calc_threads=4, + ... use_gpu_device=-1, + ... ibsi=False, + ... mergerois=False, + ... gabor_kersize=16, + ... gabor_gamma=0.1, + ... gabor_sig2lam=0.8, + ... gabor_f0=0.1, + ... gabor_thold=0.025, + ... gabor_thetas=[0, 45, 90, 135], + ... gabor_freqs=[4, 16, 32, 64], + ... dynamic_range=10000, + ... min_intensity=0.0, + ... max_intensity=1.0, + ... preserve_hu=False, + ... ram_limit=-1, + ... verbose=0, + ... anisotropy_x=1.0, + ... anisotropy_y=1.0, + ... ) + >>> nyx.get_params("coarse_gray_depth") + {'coarse_gray_depth': 64} + + Feature names are case-sensitive: + + >>> Nyxus(["mean"]) + Traceback (most recent call last): + ... + ValueError: Error: 'mean' is not a valid 2D feature name """ def __del__(self): @@ -221,7 +325,7 @@ def __init__( raise ValueError("Pixels per micron must be greater than zero.") if coarse_gray_depth <= 0: - raise ValueError("Custom number of grayscale levels (parameter coarse_gray_depth, default=64) must be non-negative.") + raise ValueError("Custom number of grayscale levels (parameter coarse_gray_depth, default=64) must be positive.") if n_feature_calc_threads < 1: raise ValueError("There must be at least one feature calculation thread.") @@ -281,17 +385,46 @@ def __init__( def set_metaparam( self, paramval: str): - """ Sets feature-specific parameter to a new value - Example: nyx.set_metaparam("glcm/greydepth=25") + r"""Set a feature-specific parameter. Parameters - ------------- + ---------- paramval : str - feature-specific value, for example: "3glcm/greydepth=25" - - Returns - --------- - success and optional error details + Assignment of the form ``"/="``, for + example ``"glcm/greydepth=32"``. + Recognized names: ``glcm/greydepth``, ``glcm/offset``, + ``3glcm/greydepth``, ``3glcm/offset``, ``3glcm/numang``, + ``3glcm/sparseintensities``, ``3gldm/greydepth``, ``3ngtdm/greydepth``, + ``3ngtdm/radius``, ``3glrlm/greydepth`` and ``3glszm/greydepth``. The + value is an integer, except ``3glcm/sparseintensities`` which takes + ``true`` or ``false``. + + Raises + ------ + ValueError + If the string is malformed, the feature or parameter is not + recognized, or the value cannot be parsed. + + Notes + ----- + A name without a ``/`` (e.g. ``"greydepth=8"``) is accepted and has no + effect. ``glcm/greydepth`` does not change the 2D co-occurrence + matrix, which is binned with `coarse_gray_depth`; it sets only the grey + depth of the constant-ROI test, so it decides whether a ROI whose + minimum and maximum fall into one bin returns the missing-value + substitute. + + See Also + -------- + get_metaparam + + Examples + -------- + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["*ALL_GLCM*"]) + >>> nyx.set_metaparam("glcm/greydepth=32") + >>> nyx.get_metaparam("glcm/greydepth") + 32.0 """ ok,errordetails = set_metaparam_imp (id(self), paramval) if ok==False: @@ -300,17 +433,41 @@ def set_metaparam( def get_metaparam ( self, paramname: str): - """ Gets feature-specific parameter - Example: n_greys = nyx.get_metaparam("glcm/greydepth") + r"""Get a feature-specific parameter. Parameters - ------------- - paramval : str - feature-specific value, for example: "3glcm/greydepth" + ---------- + paramname : str + Name of the form ``"/"``, for example + ``"glcm/greydepth"``. See :meth:`set_metaparam` for the names. Returns - --------- - requested value or throws an exception + ------- + float + Current value. + + Raises + ------ + NameError + If the feature or parameter is not recognized. + + Notes + ----- + The ``3glcm/*`` names read the 2D ``glcm`` values, not the ones set + through ``3glcm/*``, a known defect + (`#513 `__). ``3glszm/greydepth`` is read under the name + ``3glsz/greydepth``. A name without a ``/`` returns an undefined value. + + See Also + -------- + set_metaparam + + Examples + -------- + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["*ALL_GLCM*"]) + >>> nyx.get_metaparam("glcm/greydepth") + 64.0 """ val,errordetails = get_metaparam_imp (id(self), paramname) if len(errordetails) > 0: @@ -325,38 +482,73 @@ def featurize_directory( output_type: Optional[str] = "pandas", output_path: Optional[str] = "", ): - """Extract features from all the images satisfying the file pattern of provided image directories. + r"""Extract features from the images in a directory. - Extracts all the requested features at the image level from the images - present in `intensity_dir`. If `label_dir` is specified, features will be - extracted for each unique label present in the label images. The file names - of the label images are expected to match those of the intensity images. + Pairs each intensity image in `intensity_dir` with the mask image of the + same file name in `label_dir` and computes the features of every ROI. + Without `label_dir` each intensity image is processed as a single ROI. Parameters ---------- intensity_dir : str - Path to directory containing intensity images. - label_dir : str (optional, default None) - Path to directory containing label images. - file_pattern: str (optional, default ".*") - Regular expression used to filter the images present in both - `intensity_dir` and `label_dir` - output_type: str (optional, default "pandas") - Output format for the features values. Valid options are "pandas", "arrowipc", and "parquet". - output_path: str (optional, default "") - Output filepath for Arrow IPC and Parquet output formats. Default is "", which is the current directory. - The output_path can either be to a directory or filename. For example, - - If 'output_path=/path/to/directory' then a file with the default name NyxusFeatures. will be created. If the directory does not exist, it will be created. - - If 'output_path=/path/to/directory/some_file_name.arrow' then this file will be created. If the directory does not exist, it will also be created. - - If 'output_path=some_file_name.arrow' then this file will be created in the current working directory. + Directory of intensity images. + label_dir : str, optional + Directory of mask (label) images. Default None processes each + intensity image as one ROI covering the whole image. + file_pattern : str, optional + Default ".*". Regular expression selecting the files. It must match + the whole file name, case-sensitively, in both directories. + output_type : {"pandas", "arrowipc", "parquet"}, optional + Output format. Default "pandas" returns a DataFrame; "arrowipc" and + "parquet" write a file and return its path. + output_path : str, optional + Destination of the Arrow IPC or Parquet file; ignored for "pandas". + Default "" writes ``NyxusFeatures.arrow`` or ``NyxusFeatures.parquet`` + in the current working directory. A path with a file name + (``out/features.arrow``) writes that file; a directory path (``out``) + writes the default file name inside it. A missing last directory is + created; if its parent is missing too, the call raises RuntimeError, a + known defect (`#518 `__). Returns ------- - df : pd.DataFrame or str - if output_type == pandas - Pandas DataFrame containing the requested features with one row per label - per image. - Otherwise, arrow file path + pandas.DataFrame or str + For "pandas", a DataFrame with one row per ROI per image: the columns + ``intensity_image``, ``mask_image``, ``phys_unit``, ``ROI_label``, + ``t_index``, ``c_index``, ``phys_x``, ``phys_y``, ``phys_z``, then one + column per feature. Otherwise the path of the written file. + + Raises + ------ + OSError + If `intensity_dir` or `label_dir` does not exist. + ValueError + If `output_type` is not recognized or `file_pattern` is not a valid + pattern. + RuntimeError + If the dataset cannot be read. + + Notes + ----- + ROIs on the ROI blacklist (:meth:`blacklist_roi`) are skipped. + + Examples + -------- + The file-based examples assume the working directory holds the ``int`` and + ``seg`` folders of the two-image sample under ``tests/python/data`` in the + Nyxus repository. + + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["*ALL_INTENSITY*"]) + >>> df = nyx.featurize_directory("int", "seg") + >>> df.shape + (139, 45) + >>> df = nyx.featurize_directory("int", "seg", file_pattern=r".*_c0\.ome\.tif") + >>> df.shape + (66, 45) + >>> nyx.featurize_directory("int", "seg", output_type="arrowipc", + ... output_path="out/features.arrow") + 'out/features.arrow' """ if not os.path.exists(intensity_dir): raise IOError( @@ -409,40 +601,77 @@ def featurize( output_path: Optional[str] = "", ): - """Extract features from a single image pair in a 2D np.array or for all the images in a 3D np.array. + r"""Extract features from images held in numpy arrays. - Extracts all the requested features at the image level from the images - in `intensity_images`. Features will be extracted for each unique label - present in the label images. The name of resulting features in df will be - autogenerated if no intensity and label names are provided. Note that if - names are provided, the number of names must be the same as the number of images. + Computes the features of every labelled ROI of one image pair (2D arrays) + or of a stack of image pairs (3D arrays, first axis = image). Parameters ---------- - intensity_images : np.ndarray - 2D image or 3D collection of intensity images. - label_images : np.ndarray - 2D image or 3D collection of label images. - intensity_names: list (optional, default []) - names for the images in for the DataFrame output. - label_names: list (optional, default []) - names for the labels in for the DataFrame output. - output_type: str (optional, default "pandas") - Output format for the features values. Valid options are "pandas", "arrowipc", and "parquet". - output_path: str (optional, default "") - Output filepath for Arrow IPC and Parquet output formats. Default is "", which is the current directory. - The output_path can either be to a directory or filename. For example, - - If 'output_path=/path/to/directory' then a file with the default name NyxusFeatures. will be created. If the directory does not exist, it will be created. - - If 'output_path=/path/to/directory/some_file_name.arrow' then this file will be created. If the directory does not exist, it will also be created. - - If 'output_path=some_file_name.arrow' then this file will be created in the current working directory. - + intensity_images : numpy.ndarray + One 2D intensity image, or a 3D stack of them. + label_images : numpy.ndarray + Mask (label) images matching `intensity_images` in shape; 0 is + background. + intensity_names : list of str, optional + Names reported in the ``intensity_image`` column, one per image. + Default ``Intensity0``, ``Intensity1``, ... + label_names : list of str, optional + Names reported in the ``mask_image`` column, one per image. Default + ``Segmentation0``, ``Segmentation1``, ... + output_type : {"pandas", "arrowipc", "parquet"}, optional + Output format. Default "pandas" returns a DataFrame; "arrowipc" and + "parquet" write a file and return its path. + output_path : str, optional + Destination of the Arrow IPC or Parquet file; ignored for "pandas". + Default "" writes ``NyxusFeatures.arrow`` or ``NyxusFeatures.parquet`` + in the current working directory. A path with a file name + (``out/features.arrow``) writes that file; a directory path (``out``) + writes the default file name inside it. A missing last directory is + created; if its parent is missing too, the call raises RuntimeError, a + known defect (`#518 `__). + Returns ------- - df : pd.DataFrame or str - if output_type == pandas - Pandas DataFrame containing the requested features with one row per label - per image. - Otherwise, arrow file path + pandas.DataFrame or str + For "pandas", a DataFrame with one row per ROI per image: the columns + ``intensity_image``, ``mask_image``, ``phys_unit``, ``ROI_label``, + ``t_index``, ``c_index``, ``phys_x``, ``phys_y``, ``phys_z``, then one + column per feature. Otherwise the path of the written file. + + Raises + ------ + ValueError + If an argument is not a numpy array, the arrays are not both 2D or + both 3D, their shapes differ, the number of names does not match the + number of images, or `output_type` is not recognized. + RuntimeError + If a ROI's estimated footprint reaches `ram_limit`: arrays are not + processed out of core. + + Notes + ----- + Intensities are converted to ``uint32`` before featurization: an array + whose minimum is negative is first shifted so the minimum becomes 0, and + fractional values are truncated (1.7 becomes 1). A 3D array is shifted in + place, so the caller's array changes. Labels are converted to ``uint32``. + The file-loader options (`dynamic_range`, `min_intensity`, + `max_intensity`, `preserve_hu`) do not apply to arrays, and the ROI + blacklist is not applied. + + Examples + -------- + >>> import numpy as np + >>> from nyxus import Nyxus + >>> intens = np.zeros((2, 16, 16), dtype=np.uint16) + >>> labels = np.zeros((2, 16, 16), dtype=np.uint16) + >>> intens[:, 2:8, 2:8] = [[[10]], [[20]]] + >>> labels[:, 2:8, 2:8] = 1 + >>> nyx = Nyxus(["MEAN"]) + >>> df = nyx.featurize(intens, labels, intensity_names=["a", "b"], + ... label_names=["a_mask", "b_mask"]) + >>> df[["intensity_image", "mask_image", "MEAN"]].values.tolist() + [['a', 'a_mask', 10.0], ['b', 'b_mask', 20.0]] """ if (output_type != "" and output_type not in self._valid_output_types): raise ValueError(f'Invalid output type: {output_type}. Valid options are: {self._valid_output_types}') @@ -536,6 +765,27 @@ def featurize( def use_gpu_device (self, gpu_device_id: int): + r"""Switch GPU computation on or off for this instance. + + Parameters + ---------- + gpu_device_id : int + Interpreted as a flag: nonzero switches GPU computation on, 0 + switches it off. It does not select a device: from Python the + computation runs on CUDA device 0. + + Raises + ------ + RuntimeError + If this build of Nyxus has no GPU support. + + Examples + -------- + >>> import nyxus + >>> nyx = nyxus.Nyxus(["MEAN"]) + >>> if nyxus.gpu_is_available(id(nyx)): + ... nyx.use_gpu_device(1) + """ use_gpu (id(self), gpu_device_id) def featurize_files ( @@ -546,33 +796,69 @@ def featurize_files ( output_type: Optional[str] = "pandas", output_path: Optional[str] = "", ): - """Extract features from image file pairs passed as lists - - Extracts all the requested features at the image level from the intensity images - present in list `intensity_files` with respect to region of interest masks presented in - list `mask_files`. Single-ROI feature extraction is enforced via passing 'True' as parameter 'single_roi' + r"""Extract features from lists of image files. Parameters ---------- - intensity_files : list of intensity image file paths - mask_files : list of mask image file paths - single_roi : 'True' to treat items of 'intensity_files' as single-ROI ('mask_files' will be ignored), 'False' to treat items of 'intensity_files' and 'mask_files' as intensity/segmentation image pairs - output_type: str (optional, default "pandas") - Output format for the features values. Valid options are "pandas", "arrowipc", and "parquet". - output_path: str (optional, default "") - Output filepath for Arrow IPC and Parquet output formats. Default is "", which is the current directory. - The output_path can either be to a directory or filename. For example, - - If 'output_path=/path/to/directory' then a file with the default name NyxusFeatures. will be created. If the directory does not exist, it will be created. - - If 'output_path=/path/to/directory/some_file_name.arrow' then this file will be created. If the directory does not exist, it will also be created. - - If 'output_path=some_file_name.arrow' then this file will be created in the current working directory. + intensity_files : list of str + Paths of intensity images. + mask_files : list of str + Paths of mask (label) images, paired with `intensity_files` by + position. May be None when `single_roi` is True. + single_roi : bool + True processes each intensity image as one ROI covering the whole + image and ignores `mask_files`; False pairs `intensity_files` with + `mask_files`. + output_type : {"pandas", "arrowipc", "parquet"}, optional + Output format. Default "pandas" returns a DataFrame; "arrowipc" and + "parquet" write a file and return its path. + output_path : str, optional + Destination of the Arrow IPC or Parquet file; ignored for "pandas". + Default "" writes ``NyxusFeatures.arrow`` or ``NyxusFeatures.parquet`` + in the current working directory. A path with a file name + (``out/features.arrow``) writes that file; a directory path (``out``) + writes the default file name inside it. A missing last directory is + created; if its parent is missing too, the call raises RuntimeError, a + known defect (`#518 `__). Returns ------- - df : pd.DataFrame or str - if output_type == pandas - Pandas DataFrame containing the requested features with one row per label - per image. - Otherwise, arrow file path + pandas.DataFrame or str + For "pandas", a DataFrame with one row per ROI per image: the columns + ``intensity_image``, ``mask_image``, ``phys_unit``, ``ROI_label``, + ``t_index``, ``c_index``, ``phys_x``, ``phys_y``, ``phys_z``, then one + column per feature. Otherwise the path of the written file. + + Raises + ------ + OSError + If `intensity_files` is None, or `mask_files` is None while + `single_roi` is False. + ValueError + If `output_type` is not recognized. + RuntimeError + If `intensity_files` is empty, a file does not exist, the two + lists differ in length while `single_roi` is False, or + featurization fails. + + Notes + ----- + ROIs on the ROI blacklist (:meth:`blacklist_roi`) are skipped. + + Examples + -------- + The file-based examples assume the working directory holds the ``int`` and + ``seg`` folders of the two-image sample under ``tests/python/data`` in the + Nyxus repository. + + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["*ALL_INTENSITY*"]) + >>> df = nyx.featurize_files(["int/p0_y1_r1_c0.ome.tif"], + ... ["seg/p0_y1_r1_c0.ome.tif"], False) + >>> df.shape + (66, 45) + >>> nyx.featurize_files(["int/p0_y1_r1_c0.ome.tif"], None, True).shape + (1, 45) """ if intensity_files is None: @@ -611,21 +897,39 @@ def featurize_files ( def blacklist_roi(self, blacklist:str): - """Defines the application-wide ROI blacklist + r"""Set the ROI blacklist. - Defines a set of ROI labels that need to be skipped during feature extraction and in the output. Examples: - '27,28,30' - defines a global blacklist of ROI labels 27, 28, and 30 - 'file1.ome.tif:5,6,7;file2.ome.tif:11,12,13,14' - defines 2 file-specific blacklists + Blacklisted ROI labels are skipped during featurization of files + (:meth:`featurize_directory`, :meth:`featurize_files`) and do not appear in + the output. Each call replaces the previous blacklist. Parameters ---------- - blacklist : blacklist definition (string) - - Returns - ------- - None - - """ + blacklist : str + Either a global list of labels, ``"27,28,30"``, or per-file lists + separated by semicolons, ``"file1.ome.tif:5,6,7;file2.ome.tif:11,12"``. + A per-file list names the mask file without its directory; a name + with a directory part is accepted but matches no file. + + Raises + ------ + OSError + If `blacklist` is None or blank. + RuntimeError + If `blacklist` cannot be parsed. + + See Also + -------- + clear_roi_blacklist, roi_blacklist_get_summary + + Examples + -------- + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["MEAN"]) + >>> nyx.blacklist_roi("27,28,30") + >>> nyx.roi_blacklist_get_summary() + 'global 27 28 30' + """ if blacklist is None: raise IOError ("ROI blacklist argument is empty") @@ -635,31 +939,43 @@ def blacklist_roi(self, blacklist:str): blacklist_roi_imp (id(self), blacklist) def clear_roi_blacklist(self): - """Clears the ROI blacklist - - Parameters - ---------- - None - - Returns - ------- - None - + r"""Clear the ROI blacklist. + + See Also + -------- + blacklist_roi + + Examples + -------- + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["MEAN"]) + >>> nyx.blacklist_roi("5") + >>> nyx.clear_roi_blacklist() + >>> nyx.roi_blacklist_get_summary() is None + True """ clear_roi_blacklist_imp (id(self)) def roi_blacklist_get_summary (self): - """Returns a human-friendly text of the summary of the application-wide ROI blacklist - - Parameters - ---------- - None + r"""Describe the ROI blacklist. Returns ------- - text of blacklist summary (string) - - """ + str or None + Human-readable summary of the blacklist, or None when it is empty. + + See Also + -------- + blacklist_roi + + Examples + -------- + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["MEAN"]) + >>> nyx.blacklist_roi("file1.ome.tif:5,6;file2.ome.tif:7") + >>> nyx.roi_blacklist_get_summary() + 'file1.ome.tif : 5 6 file2.ome.tif : 7' + """ s = roi_blacklist_get_summary_imp (id(self)) s = s.strip() if len(s) == 0: @@ -667,16 +983,54 @@ def roi_blacklist_get_summary (self): return s def set_gabor_feature_params (self, **kwargs): - r"""Sets parameters of feature GABOR - - Keyword args: - * kersize (int): size of filter kernel's side. Example: customize_gabor_feature(kersize=16) - * gamma (float): aspect ratio of the Gaussian factor. Example: customize_gabor_feature(gamma=0.1) - * sig2lam (float): spatial frequency bandwidth. Example: customize_gabor_feature(sig2lam=0.8) - * f0 (float): frequency of the baseline lowpass filter as denominator of `\pi`. Example: customize_gabor_feature(f0=0.1) - * thold (float): lower threshold of the filtered image to baseline ratio. Example: customize_gabor_feature(thold=0.025) - * thetas (list[int]): list of orientations of the Gaussian in degrees 0-180 matching 'freqs'. Example: customize_gabor_feature(theta=1.5708) - * freqs (list[int]): list of denominators of `\pi` as frequencies of Gabor filter's harmonic factor. Example: customize_gabor_feature(freqs="1,2,4,8,16,32,64") + r"""Set the parameters of the GABOR feature. + + Only the parameters passed are changed. + + Parameters + ---------- + kersize : int, optional + Side of the filter kernel, in pixels. + gamma : float, optional + Aspect ratio of the Gaussian factor. + sig2lam : float, optional + Spatial frequency bandwidth (sigma to lambda ratio). + f0 : float, optional + Angular frequency of the baseline low-pass filter, in radians per + pixel; its wavelength is :math:`2\pi/f_0` pixels. + thold : float, optional + Lower threshold of the filtered-image to baseline ratio. + thetas : list of float, optional + Orientations of the Gaussian, in degrees, conventionally 0-180 + (not enforced), one per entry of `freqs`. Must be a list; a + comma-separated string is rejected. + freqs : list of float, optional + Angular frequencies of the filters' harmonic factor, in radians per + pixel (wavelength :math:`2\pi/f` pixels), one per entry of `thetas`. + Must be a list. + + Raises + ------ + ValueError + If a keyword is not one of the above, or a value is invalid (for + example `thetas` and `freqs` of different lengths). + OSError + If no keyword is passed. + + Notes + ----- + Gabor parameters are process-wide: the change applies to every instance, + and the next :class:`Nyxus` constructor call resets them. + + Examples + -------- + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["GABOR"]) + >>> nyx.set_gabor_feature_params(kersize=16, gamma=0.1, sig2lam=0.8, + ... f0=0.1, thold=0.025, + ... thetas=[0, 90], freqs=[4, 16]) + >>> nyx.get_params("gabor_thetas", "gabor_freqs") + {'gabor_freqs': [4.0, 16.0], 'gabor_thetas': [0.0, 90.0]} """ params = [ @@ -718,26 +1072,63 @@ def set_gabor_feature_params (self, **kwargs): def set_environment_params (self, **params): - """Sets parameters of the environment for Nyxus - - Keyword args: - *neighbor_distance: int - Any two objects separated by a Euclidean distance (pixel units) greater than this - value will not be be considered neighbors. This cutoff is used by all features which - rely on neighbor identification. - * pixels_per_micron: float - Specify the image resolution in terms of pixels per micron for unit conversion - of non-unitless features. - * coarse_gray_depth: int - Custom number of levels in grayscale denoising used in texture features. - * n_feature_calc_threads: int (optional, default 4) - Number of threads to use for feature calculation parallelization purposes. - * use_gpu_device: int (optional, default -1) - GPU device -1 means GPU is not used - * verbose: int (optional, non-negative, default 0) - Level of diagnostic output shown in the console. Set '0' to disable any diagnostic output. - * ram_limit: int (optional) - Maximum amount of ram to be used by Nyxus in megabytes + r"""Change environment parameters of this instance. + + Only the parameters passed are changed, except `verbose` (see Notes). + + Parameters + ---------- + features : list of str, optional + Stored and reported by :meth:`get_params`, but the enabled feature + set does not change (see Notes). + neighbor_distance : int, optional + Neighbor cutoff distance, in pixels. + pixels_per_micron : float, optional + Stored and reported by :meth:`get_params`; no feature currently + depends on it. + coarse_gray_depth : int, optional + Number of grey levels for texture features (see Notes). + n_feature_calc_threads : int, optional + Number of feature calculation threads. + use_gpu_device : int, optional + Accepted and ignored. + verbose : int, optional + Level of diagnostic output. + dynamic_range : float, optional + Number of grey levels a floating-point image is rescaled to. + min_intensity : float, optional + Intensity mapped to the lowest grey level. Negative values are + ignored. + max_intensity : float, optional + Intensity mapped to `dynamic_range`. Negative values are ignored. + ram_limit : int, optional + Memory budget in megabytes, with the same effect as the + constructor's `ram_limit`. A + value larger than the available memory is rejected with a message on + standard error and the previous limit is kept. + + Raises + ------ + ValueError + If a keyword is not one of the above. + + Notes + ----- + `verbose` is applied on every call and falls back to 0 when omitted. + + The enabled feature set and the per-feature settings derived from + `coarse_gray_depth` are fixed when the instance is constructed. Changing + `features`, `coarse_gray_depth` or `ibsi` afterwards updates what + :meth:`get_params` reports but not the features computed by this + instance; construct a new instance to change them. + + Examples + -------- + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["MEAN"]) + >>> nyx.set_environment_params(neighbor_distance=3, pixels_per_micron=2.0) + >>> nyx.get_params("neighbor_distance", "pixels_per_micron") + {'neighbor_distance': 3, 'pixels_per_micron': 2.0} """ valid_params = [ @@ -784,28 +1175,52 @@ def set_environment_params (self, **params): verb_lvl) def set_params(self, **params): - r"""Sets parameters of the Nyxus class - - Keyword args: - - * features: List[str], - * neighbor_distance - * pixels_per_micron - * coarse_gray_depth - * n_feature_calc_threads - * use_gpu_device - * ibsi: bool - * dynamic_range (float): Desired dynamic range of voxels of a floating point TIFF image. - * min_intensity (float): Minimum intensity of voxels of a floating point TIFF image. - * max_intensity (float): Maximum intensity of voxels of a floating point TIFF image. - - * gabor_kersize (int): size of filter kernel's side. Example: set_params(gabor_kersize=16) - * gabor_gamma (float): aspect ratio of the Gaussian factor. Example: set_params(gabor_gamma=0.1) - * gabor_sig2lam (float): spatial frequency bandwidth. Example: set_params(gabor_sig2lam=0.8) - * gabor_f0 (float): frequency of the baseline lowpass filter as denominator of `\pi`. Example: set_params(gabor_f0=0.1) - * gabor_thetas (str): comma-separated orientations of the Gaussian in degrees 0-180. Example: set_params(gabor_thetas="0,45,60") - * gabor_thold (float): lower threshold of the filtered image to baseline ratio. Example: set_params(gabor_thold=0.025) - * gabor_freqs (str): comma-separated denominators of `\pi` as frequencies of Gabor filter's harmonic factor. Example: set_params(gabor_freqs="1,2,4,8,16,32,64") + r"""Change parameters of this instance. + + Accepts the keywords of :meth:`set_environment_params`, plus `ibsi` and + the ``gabor_*`` keywords of the constructor, which are forwarded to + :meth:`set_gabor_feature_params` without the ``gabor_`` prefix. + + Parameters + ---------- + **params + Any of `features`, `neighbor_distance`, `pixels_per_micron`, + `coarse_gray_depth`, `n_feature_calc_threads`, `use_gpu_device`, + `ibsi` (bool), `dynamic_range`, `min_intensity`, `max_intensity`, + `ram_limit`, `verbose`, `gabor_kersize`, `gabor_gamma`, + `gabor_sig2lam`, `gabor_f0`, `gabor_thold`, `gabor_thetas` (list) and + `gabor_freqs` (list). + + Raises + ------ + ValueError + If a keyword is not recognized or a Gabor value is invalid. + + Notes + ----- + The enabled feature set and the per-feature settings derived from + `coarse_gray_depth` are fixed when the instance is constructed. Changing + `features`, `coarse_gray_depth` or `ibsi` afterwards updates what + :meth:`get_params` reports but not the features computed by this + instance; construct a new instance to change them. Changing `ibsi` afterwards does not enable the + Intensity Histogram (``IH_*``) family. + + Gabor parameters are process-wide, not per instance: every constructor + call that accepts ``gabor_*`` arguments sets them (to the defaults when + omitted), and :meth:`set_gabor_feature_params` changes them for all + instances. + + See Also + -------- + set_environment_params, set_gabor_feature_params, get_params + + Examples + -------- + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["MEAN"]) + >>> nyx.set_params(neighbor_distance=3, gabor_thetas=[0, 90], gabor_freqs=[4, 16]) + >>> nyx.get_params("neighbor_distance", "gabor_thetas") + {'gabor_thetas': [0.0, 90.0], 'neighbor_distance': 3} """ available_environment_params = [ @@ -848,36 +1263,31 @@ def set_params(self, **params): def get_params(self, *args): - r"""Returns the parameters of a Nyxus object. If no args are supplied, all parameters will be returned. - - Valid parameters are: - - * features: List[str], - * neighbor_distance - * pixels_per_micron - * coarse_gray_depth - * n_feature_calc_threads - * using_gpu - * gpu_device_id - * ibsi: bool - * dynamic_range (float): Desired dynamic range of voxels of a floating point TIFF image. - * min_intensity (float): Minimum intensity of voxels of a floating point TIFF image. - * max_intensity (float): Maximum intensity of voxels of a floating point TIFF image. - - * gabor_kersize (int): size of filter kernel's side. Example: set_params(gabor_kersize=16) - * gabor_gamma (float): aspect ratio of the Gaussian factor. Example: set_params(gabor_gamma=0.1) - * gabor_sig2lam (float): spatial frequency bandwidth. Example: set_params(gabor_sig2lam=0.8) - * gabor_f0 (float): frequency of the baseline lowpass filter as denominator of `\pi`. Example: set_params(gabor_f0=0.1) - * gabor_thetas (str): comma-separated orientations of the Gaussian in degrees 0-180. Example: set_params(gabor_thetas="45,60,70") - * gabor_thold (float): lower threshold of the filtered image to baseline ratio. Example: set_params(gabor_thold=0.025) - * gabor_freqs (str): comma-separated denominators of `\pi` as frequencies of Gabor filter's harmonic factor. Example: set_params(gabor_freqs="1,2,4,8,16,32,64") - + r"""Return parameters of this instance. + Parameters ---------- - args: Strings containing parameter names to get the value of. (Optional) + *args : str + Names of the parameters to return. With no names, all are returned. + Unknown names are ignored. Available names: ``features``, + ``neighbor_distance``, ``pixels_per_micron``, ``coarse_gray_depth``, + ``n_feature_calc_threads``, ``ibsi``, ``dynamic_range``, + ``min_intensity``, ``max_intensity``, ``ram_limit`` (megabytes), + ``gabor_kersize``, ``gabor_gamma``, ``gabor_sig2lam``, ``gabor_f0``, + ``gabor_thold``, ``gabor_thetas`` and ``gabor_freqs``. - Returns: - dict: A dictionary mapping the parameter name to the value + Returns + ------- + dict + Parameter name to value. ``ibsi`` is reported as 0 or 1. The GPU + settings and `verbose` are not reported. + + Examples + -------- + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["MEAN"]) + >>> nyx.get_params("coarse_gray_depth", "neighbor_distance", "ibsi") + {'coarse_gray_depth': 64, 'ibsi': 0, 'neighbor_distance': 5} """ vars = list(args) @@ -885,16 +1295,31 @@ def get_params(self, *args): def get_arrow_ipc_file(self): - """Returns the path to the Arrow IPC file. - - Parameters - ---------- - None + r"""Return the path of the last Arrow IPC file written by this instance. Returns ------- - Path to the Arrow IPC file (string) - + str + Path of the most recent Arrow IPC or Parquet output file. The same + path is returned by :meth:`get_arrow_ipc_file` and + :meth:`get_parquet_file`, whichever format was written last. + + Raises + ------ + RuntimeError + If this build of Nyxus has no Arrow support. + + Examples + -------- + The file-based examples assume the working directory holds the ``int`` and + ``seg`` folders of the two-image sample under ``tests/python/data`` in the + Nyxus repository. + + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["MEAN"]) + >>> path = nyx.featurize_directory("int", "seg", output_type="arrowipc", output_path="out") + >>> nyx.get_arrow_ipc_file() == path + True """ if self.arrow_is_enabled(): @@ -903,16 +1328,31 @@ def get_arrow_ipc_file(self): raise RuntimeError("Nyxus was not built with Arrow. To use this functionality, rebuild Nyxus with Arrow support on.") def get_parquet_file(self): - """Returns the path to the Arrow IPC file. - - Parameters - ---------- - None + r"""Return the path of the last Parquet file written by this instance. Returns ------- - Path to the Parquet file (string) - + str + Path of the most recent Arrow IPC or Parquet output file. The same + path is returned by :meth:`get_arrow_ipc_file` and + :meth:`get_parquet_file`, whichever format was written last. + + Raises + ------ + RuntimeError + If this build of Nyxus has no Arrow support. + + Examples + -------- + The file-based examples assume the working directory holds the ``int`` and + ``seg`` folders of the two-image sample under ``tests/python/data`` in the + Nyxus repository. + + >>> from nyxus import Nyxus + >>> nyx = Nyxus(["MEAN"]) + >>> path = nyx.featurize_directory("int", "seg", output_type="parquet", output_path="out") + >>> nyx.get_parquet_file() == path + True """ if self.arrow_is_enabled(): return get_parquet_file_imp (id(self)) @@ -922,15 +1362,17 @@ def get_parquet_file(self): def arrow_is_enabled(self): - """Returns true if arrow support is enabled. - - Parameters - ---------- - None + r"""Report whether this build of Nyxus supports Arrow IPC and Parquet output. Returns ------- - bool: If arrow support is enabled + bool + True when the "arrowipc" and "parquet" output types are available. + + Examples + -------- + >>> from nyxus import Nyxus + >>> enabled = Nyxus(["MEAN"]).arrow_is_enabled() """ @@ -938,93 +1380,157 @@ def arrow_is_enabled(self): class Nyxus3D: - """Nyxus3D image feature extraction library + r"""Extract 3D features from volumes and their ROI masks. - Scalably extracts features from images. - - Example: - nyx = Nyxus3D( - features = [["*3D_ALL*"]], - neighbor_distance = 5, - pixels_per_micron = 1.0, - coarse_gray_depth= 64, - n_feature_calc_threads = 4, - use_gpu_device = -1, - ibsi = False, - gabor_kersize = 16, - gabor_gamma = 0.1, - gabor_sig2lam = 0.8, - gabor_f0 = 0.1, - gabor_thold = 0.025, - gabor_thetas = [0, 45, 90, 135], - gabor_freqs = [4, 16, 32, 64] - ) + Volumes are read from files (:meth:`featurize_directory`, + :meth:`featurize_files`); there is no numpy-array entry point. Parameters ---------- - features : list[str] - List of features to be calculated. Individual features can be - provided or pre-specified feature groups. Valid groups include: - *3D_ALL* - *3D_ALL_INTENSITY* - Both individual features and feature groups are case sensitive. - neighbor_distance: int (optional, default 5) - Any two objects separated by a Euclidean distance (pixel units) greater than this - value will not be be considered neighbors. This cutoff is used by all features which - rely on neighbor identification. - pixels_per_micron: float (optional, default 1.0) - Specify the image resolution in terms of pixels per micron for unit conversion - of non-unitless features. - coarse_gray_depth: int (optional, default 64) - Custom number of levels in grayscale denoising used in texture features. - n_feature_calc_threads: int (optional, default 4) - Number of threads to use for feature calculation parallelization purposes. - use_gpu_device: int (optional, default -1) - Id of the gpu to use. To find available gpus along with ids, using nyxus.get_gpu_properties(). - The default value of -1 uses cpu calculations. Note that the gpu features only support a single - thread for feature calculation. - ibsi: bool (optional, default false) - IBSI available features will be IBSI compliant when true. - dynamic_range: (optional) - Desired dynamic range of voxels of a floating point TIFF image. - min_intensity: (optional, default 0.0) - Minimum intensity of voxels of a floating point TIFF image. - max_intensity: (optional, default 1.0) - Maximum intensity of voxels of a floating point TIFF image. - preserve_hu: bool (optional, default False) - NOT needed for CT: Hounsfield units are preserved and reported either way, - despite this option's name. What it actually does: - Carry a FLOATING-POINT slide on the offset-preserving map (1 grey level = 1 - intensity unit, offset by the slide's floored minimum) instead of the min-max - rescaling controlled by dynamic_range/min_intensity/max_intensity. A DICOM or - integer NIfTI slide takes it instead of the stored map, which keeps the stored - integers as grey levels and carries RescaleSlope/RescaleIntercept (scl_slope/ - scl_inter) in the recorded inverse, so a fractional slope loses no stored level. - Other integer slides take the offset map regardless of this flag. The DICOM and - NIfTI rescale reaches the reported values either way, so CT Hounsfield Units survive - the load either way and negative values never wrap. + features : list of str + 3D feature names and feature groups to compute. 3D groups: + ``*3D_ALL*``, ``*3D_ALL_INTENSITY*``, ``*3D_ALL_MORPHOLOGY*``, + ``*3D_ALL_TEXTURE*``, ``*3D_GLCM*``, ``*3D_GLDM*``, ``*3D_GLDZM*``, + ``*3D_GLSZM*``, ``*3D_GLRLM*``, ``*3D_NGLDM*`` and ``*3D_NGTDM*``. + Names are case-sensitive and must be upper case: ``"3MEAN"`` is accepted, + ``"3mean"`` raises ValueError. (The command-line tool upper-cases its + ``--features`` list; the Python API does not.) Entries are applied left to + right; a leading ``-`` removes a feature or group enabled by an earlier + entry and a leading ``+`` adds it, e.g. ``["*3D_ALL_INTENSITY*", "-3MEAN"]``. + neighbor_distance : int, optional + Default 5. Must be positive. There are no 3D neighbor features; the + value is stored and reported by :meth:`get_params` only. + pixels_per_micron : float, optional + Default 1.0. Must be positive. Stored and reported by + :meth:`get_params`; no 3D feature depends on it. + coarse_gray_depth : int, optional + Default 64. Must be positive. Grey depth of the MATLAB-style binning + of 3D GLCM and 3D GLDZM, of the 3D NGLDM conversion, and the bin count + of ``3ENTROPY`` and ``3UNIFORMITY``. 3D GLRLM, GLSZM, GLDM and NGTDM + ignore it: they bin to their own ``greydepth`` metaparameter (see + :meth:`set_metaparam`), 0 (no binning) by default. With ``ibsi=True`` + no 3D texture family bins. + n_feature_calc_threads : int, optional + Default 4. Number of feature calculation threads. Must be at least 1. + use_gpu_device : int, optional + Default -1 (CPU). No 3D feature uses the GPU, and the value is not + passed to CUDA. A non-negative id forces `n_feature_calc_threads` to + 1, and falls back to -1 with a printed message when no GPU is + available. A value below -1 raises RuntimeError on a build without + GPU support. + ibsi : bool, optional + Default False. Compute the IBSI-compliant variant of the features that + have one. The texture features then skip grey-level binning, whatever + ``coarse_gray_depth`` and the ``greydepth`` metaparameters are. + dynamic_range : float, optional + Default 10000. Number of grey levels a floating-point image is + rescaled to. + min_intensity : float, optional + Default 0.0. Intensity of a floating-point image mapped to the lowest + grey level. + max_intensity : float, optional + Default 1.0. Intensity of a floating-point image mapped to + `dynamic_range`. + preserve_hu : bool, optional + Default False. Not needed for CT: Hounsfield units are preserved and + reported either way, despite the option's name. What it does: carries + a floating-point slide on the offset-preserving map (1 grey level = 1 + intensity unit, offset by the slide's floored minimum) instead of the + min-max rescaling controlled by `dynamic_range`, `min_intensity` and + `max_intensity`. A DICOM or integer NIfTI slide takes it instead of + the stored map, which keeps the stored integers as grey levels and + carries RescaleSlope/RescaleIntercept (scl_slope/scl_inter) in the + recorded inverse, so a fractional slope loses no stored level. Other + integer slides take the offset map regardless of this flag. The DICOM + and NIfTI rescale reaches the reported values either way, so CT + Hounsfield units survive the load and negative values never wrap. + Whichever map a slide takes is recorded and undone on the way out: - * Every intensity feature -- location (mean, median, mode, percentiles, min, max), - dispersion, the sum/energy family, and the Intensity Histogram (IH_*) features -- - is reported in the slide's own intensity domain. A CT read in Hounsfield units is - reported in Hounsfield units, negatives included. - * Shift-invariant intensity features (variance, standard deviation, skewness, - kurtosis, range, interquartile range) and all shape/texture features: unaffected. - * Sub-unit precision survives on the stored map, which reports a fractional - rescaled value exactly. Elsewhere grey levels are integers, so a value that is not - integer-valued is reported as the grey level it was stored as: truncated, or - rounded to nearest under preserve_hu. Hounsfield units are integer-valued, so CT - is unaffected. - verbose: int (optional, default 0) - Level of diagnostic information in the standard output. Non-negative. 0 is no diagnostic output. - anisotropy_x: float (optional, default 1.0) - Size of a voxel along X relative to the other axes. The volume is featurized on its voxels as - acquired and the shape features scale their coordinates by the anisotropy, so it changes only - shape values. - anisotropy_y: float (optional, default 1.0) - Y-dimension counterpart of anisotropy_x - anisotropy_z: float (optional, default 1.0) - Z-dimension counterpart of anisotropy_x + + * Every intensity feature -- location (mean, median, mode, + percentiles, min, max), dispersion and the sum/energy family -- is + reported in the slide's own intensity domain. A CT read in Hounsfield + units is reported in Hounsfield units, negatives included. + * Shift-invariant intensity features (variance, standard deviation, + skewness, kurtosis, range, interquartile range) and all + shape/texture features are unaffected. + * Sub-unit precision survives on the stored map, which reports a + fractional rescaled value exactly. Elsewhere grey levels are + integers, so a value that is not integer-valued is reported as the + grey level it was stored as: truncated, or rounded to nearest under + `preserve_hu`. Hounsfield units are integer-valued, so CT is + unaffected. + use_physical_spacing : bool, optional + Default False. Take the voxel spacing from the OME ``PhysicalSize*`` + metadata and treat non-cubic voxels as anisotropic. An anisotropy + factor other than 1.0 in `anisotropy_x`, `anisotropy_y` or + `anisotropy_z` takes precedence, and then all three come from those + arguments. A factor of 1.0 cannot be told from the default and does + not override the metadata. + ram_limit : int, optional + Memory budget in megabytes. ROIs whose estimated footprint reaches it + are processed out of core, except when one read of the file returns + the whole volume (NIfTI, or OME-Zarr chunked over all of Z): then such + a ROI stops the run with RuntimeError. Default -1 keeps the built-in limit, half + of the physical memory available when the instance is created. A + value larger than the available memory is rejected with a message on + standard error and the previous limit is kept. + verbose : int, optional + Default 0 (silent). Level of diagnostic output. Must be non-negative. + anisotropy_x : float, optional + Default 1.0. Size of a voxel along X relative to the other axes. Must + be positive. The volume is featurized on its voxels as acquired and + the shape features scale their coordinates by the anisotropy, so it + changes only shape values. + anisotropy_y : float, optional + Default 1.0. Y-dimension counterpart of `anisotropy_x`. Must be + positive. + anisotropy_z : float, optional + Default 1.0. Z-dimension counterpart of `anisotropy_x`. Must be + positive. + + Other Parameters + ---------------- + channel_signature, parent_channel, child_channel, aggregate + Accepted without a warning and ignored. + + Raises + ------ + ValueError + If a feature name or group is not recognized or a numeric parameter + is out of range. + RuntimeError + If `use_gpu_device` is below -1 and this build has no GPU support. + + Notes + ----- + Unknown keyword arguments print a warning and are ignored. The ``gabor_*`` + keywords are not accepted: there is no 3D Gabor feature. + + An OME-TIFF takes its Z, C and T sizes from the OME metadata; a plain + multi-page TIFF is read as one volume with one Z plane per page. + + The enabled feature set and the per-feature settings derived from + `coarse_gray_depth` are fixed when the instance is constructed. Changing + `features`, `coarse_gray_depth` or `ibsi` afterwards updates what + :meth:`get_params` reports but not the features computed by this + instance; construct a new instance to change them. + + Examples + -------- + >>> import numpy as np, os, tifffile + >>> from nyxus import Nyxus3D + >>> vol = np.zeros((6, 12, 12), dtype=np.uint16) + >>> vol[1:5, 2:9, 2:9] = 7 + >>> os.makedirs("vol_int", exist_ok=True) + >>> os.makedirs("vol_seg", exist_ok=True) + >>> tifffile.imwrite("vol_int/v.ome.tif", vol, ome=True, metadata={"axes": "ZYX"}) + >>> tifffile.imwrite("vol_seg/v.ome.tif", (vol > 0).astype(np.uint16), + ... ome=True, metadata={"axes": "ZYX"}) + >>> nyx = Nyxus3D(["3MEAN", "3VOXEL_VOLUME"]) + >>> df = nyx.featurize_directory("vol_int", "vol_seg", ".*") + >>> df[["ROI_label", "3MEAN"]].values.tolist() + [[1.0, 7.0]] """ def __del__(self): @@ -1084,7 +1590,7 @@ def __init__( raise ValueError("Pixels per micron must be greater than zero.") if coarse_gray_depth <= 0: - raise ValueError("Custom number of grayscale levels (parameter coarse_gray_depth, default=64) must be non-negative.") + raise ValueError("Custom number of grayscale levels (parameter coarse_gray_depth, default=64) must be positive.") if n_feature_calc_threads < 1: raise ValueError("There must be at least one feature calculation thread.") @@ -1140,17 +1646,42 @@ def __init__( def set_metaparam( self, paramval: str): - """ Sets feature-specific parameter to a new value - Example: nyx.set_metaparam("3glcm/greydepth=25") + r"""Set a feature-specific parameter. Parameters - ------------- + ---------- paramval : str - feature-specific value, for example: "3glcm/greydepth=25" - - Returns - --------- - success and optional error details + Assignment of the form ``"/="``, for + example ``"3ngtdm/radius=2"``. + Recognized names: ``glcm/greydepth``, ``glcm/offset``, + ``3glcm/greydepth``, ``3glcm/offset``, ``3glcm/numang``, + ``3glcm/sparseintensities``, ``3gldm/greydepth``, ``3ngtdm/greydepth``, + ``3ngtdm/radius``, ``3glrlm/greydepth`` and ``3glszm/greydepth``. The + value is an integer, except ``3glcm/sparseintensities`` which takes + ``true`` or ``false``. + + Raises + ------ + ValueError + If the string is malformed, the feature or parameter is not + recognized, or the value cannot be parsed. + + Notes + ----- + A name without a ``/`` (e.g. ``"greydepth=8"``) is accepted and has no + effect. + + See Also + -------- + get_metaparam + + Examples + -------- + >>> from nyxus import Nyxus3D + >>> nyx = Nyxus3D(["*3D_NGTDM*"]) + >>> nyx.set_metaparam("3ngtdm/radius=2") + >>> nyx.get_metaparam("3ngtdm/radius") + 2.0 """ ok,errordetails = set_metaparam_imp (id(self), paramval) if ok==False: @@ -1159,17 +1690,42 @@ def set_metaparam( def get_metaparam ( self, paramname: str): - """ Gets feature-specific parameter - Example: n_greys = nyx.get_metaparam("3glcm/greydepth") + r"""Get a feature-specific parameter. Parameters - ------------- - paramval : str - feature-specific value, for example: "3glcm/greydepth" + ---------- + paramname : str + Name of the form ``"/"``, for example + ``"3ngtdm/radius"``. See :meth:`set_metaparam` for the names. Returns - --------- - requested value or throws an exception + ------- + float + Current value. + + Raises + ------ + NameError + If the feature or parameter is not recognized. + + Notes + ----- + The ``3glcm/*`` names read the 2D ``glcm`` values, not the ones set + through ``3glcm/*``, a known defect + (`#513 `__). ``3glszm/greydepth`` is read under the name + ``3glsz/greydepth``. A name without a ``/`` returns an undefined value. + + See Also + -------- + set_metaparam + + Examples + -------- + >>> from nyxus import Nyxus3D + >>> nyx = Nyxus3D(["*3D_GLRLM*"]) + >>> nyx.set_metaparam("3glrlm/greydepth=32") + >>> nyx.get_metaparam("3glrlm/greydepth") + 32.0 """ val,errordetails = get_metaparam_imp (id(self), paramname) if len(errordetails) > 0: @@ -1184,38 +1740,55 @@ def featurize_directory( output_type: Optional[str] = "pandas", output_path: Optional[str] = "", ): - """Extract features from all the images satisfying the file pattern of provided image directories. + r"""Extract 3D features from the volumes in a directory. - Extracts all the requested features at the image level from the images - present in `intensity_dir`. If `label_dir` is specified, features will be - extracted for each unique label present in the label images. The file names - of the label images are expected to match those of the intensity images. + Pairs each intensity volume in `intensity_dir` with the mask volume of + the same file name in `label_dir` and computes the features of every ROI. Parameters ---------- intensity_dir : str - Path to directory containing intensity images. - label_dir : str (optional, default None) - Path to directory containing label images. - file_pattern: str - Regular expression used to filter the images present in both - `intensity_dir` and `label_dir` - output_type: str (optional, default "pandas") - Output format for the features values. Valid options are "pandas", "arrowipc", and "parquet". - output_path: str (optional, default "") - Output filepath for Arrow IPC and Parquet output formats. Default is "", which is the current directory. - The output_path can either be to a directory or filename. For example, - - If 'output_path=/path/to/directory' then a file with the default name NyxusFeatures. will be created. If the directory does not exist, it will be created. - - If 'output_path=/path/to/directory/some_file_name.arrow' then this file will be created. If the directory does not exist, it will also be created. - - If 'output_path=some_file_name.arrow' then this file will be created in the current working directory. + Directory of intensity volumes. + label_dir : str or None + Directory of mask (label) volumes. None processes each intensity + volume as one ROI. + file_pattern : str + Regular expression selecting the files. It must match the whole file + name, case-sensitively, in both directories. Required; pass ".*" for + all files. + output_type : {"pandas", "arrowipc", "parquet"}, optional + Output format. Default "pandas" returns a DataFrame; "arrowipc" and + "parquet" write a file and return its path. + output_path : str, optional + Destination of the Arrow IPC or Parquet file; ignored for "pandas". + Default "" writes ``NyxusFeatures.arrow`` or ``NyxusFeatures.parquet`` + in the current working directory. A path with a file name + (``out/features.arrow``) writes that file; a directory path (``out``) + writes the default file name inside it. A missing last directory is + created; if its parent is missing too, the call raises RuntimeError, a + known defect (`#518 `__). Returns ------- - df : pd.DataFrame or str - if output_type == pandas - Pandas DataFrame containing the requested features with one row per label - per image. - Otherwise, arrow file path + pandas.DataFrame or str + For "pandas", a DataFrame with one row per ROI per image: the columns + ``intensity_image``, ``mask_image``, ``phys_unit``, ``ROI_label``, + ``t_index``, ``c_index``, ``phys_x``, ``phys_y``, ``phys_z``, then one + column per feature. Otherwise the path of the written file. + + Raises + ------ + OSError + If `intensity_dir` or `label_dir` does not exist. + ValueError + If `output_type` is not recognized or `file_pattern` is not a valid + pattern. + RuntimeError + If the dataset cannot be read. + + Examples + -------- + See :class:`Nyxus3D`. """ if not os.path.exists(intensity_dir): raise IOError( @@ -1259,33 +1832,67 @@ def featurize_files ( output_type: Optional[str] = "pandas", output_path: Optional[str] = "", ): - """Extract features from image file pairs passed as lists - - Extracts all the requested features at the image level from the intensity images - present in list `intensity_files` with respect to region of interest masks presented in - list `mask_files`. Single-ROI feature extraction is enforced via passing 'True' as parameter 'single_roi' + r"""Extract 3D features from lists of volume files. Parameters ---------- - intensity_files : list of intensity image file paths - mask_files : list of mask image file paths - single_roi : 'True' to treat items of 'intensity_files' as single-ROI ('mask_files' will be ignored), 'False' to treat items of 'intensity_files' and 'mask_files' as intensity/segmentation image pairs - output_type: str (optional, default "pandas") - Output format for the features values. Valid options are "pandas", "arrowipc", and "parquet". - output_path: str (optional, default "") - Output filepath for Arrow IPC and Parquet output formats. Default is "", which is the current directory. - The output_path can either be to a directory or filename. For example, - - If 'output_path=/path/to/directory' then a file with the default name NyxusFeatures. will be created. If the directory does not exist, it will be created. - - If 'output_path=/path/to/directory/some_file_name.arrow' then this file will be created. If the directory does not exist, it will also be created. - - If 'output_path=some_file_name.arrow' then this file will be created in the current working directory. + intensity_files : list of str + Paths of intensity images. + mask_files : list of str + Paths of mask (label) volumes, paired with `intensity_files` by + position. Required, one mask per intensity file, whatever + `single_roi` is. + single_roi : bool + True merges every nonzero label of each mask into one ROI (label 1) + and computes the 3D shape features from that ROI's bounding box; + voxels where the mask is 0 stay out. Use + :meth:`featurize_directory` with `label_dir` None for whole-volume + ROIs. + output_type : {"pandas", "arrowipc", "parquet"}, optional + Output format. Default "pandas" returns a DataFrame; "arrowipc" and + "parquet" write a file and return its path. + output_path : str, optional + Destination of the Arrow IPC or Parquet file; ignored for "pandas". + Default "" writes ``NyxusFeatures.arrow`` or ``NyxusFeatures.parquet`` + in the current working directory. A path with a file name + (``out/features.arrow``) writes that file; a directory path (``out``) + writes the default file name inside it. A missing last directory is + created; if its parent is missing too, the call raises RuntimeError, a + known defect (`#518 `__). Returns ------- - df : pd.DataFrame or str - if output_type == pandas - Pandas DataFrame containing the requested features with one row per label - per image. - Otherwise, arrow file path + pandas.DataFrame or str + For "pandas", a DataFrame with one row per ROI per image: the columns + ``intensity_image``, ``mask_image``, ``phys_unit``, ``ROI_label``, + ``t_index``, ``c_index``, ``phys_x``, ``phys_y``, ``phys_z``, then one + column per feature. Otherwise the path of the written file. + + Raises + ------ + OSError + If `intensity_files` or `mask_files` is None. + ValueError + If `output_type` is not recognized. + RuntimeError + If a list is empty, the lists differ in length, a file does not + exist, or featurization fails. + + Examples + -------- + >>> import numpy as np, os, tifffile + >>> from nyxus import Nyxus3D + >>> vol = np.zeros((6, 12, 12), dtype=np.uint16) + >>> vol[1:5, 2:9, 2:9] = 7 + >>> os.makedirs("vol_int", exist_ok=True) + >>> os.makedirs("vol_seg", exist_ok=True) + >>> tifffile.imwrite("vol_int/v.ome.tif", vol, ome=True, metadata={"axes": "ZYX"}) + >>> tifffile.imwrite("vol_seg/v.ome.tif", (vol > 0).astype(np.uint16), + ... ome=True, metadata={"axes": "ZYX"}) + >>> nyx = Nyxus3D(["3MEAN"]) + >>> df = nyx.featurize_files(["vol_int/v.ome.tif"], ["vol_seg/v.ome.tif"], False) + >>> df["3MEAN"].tolist() + [7.0] """ if intensity_files is None: @@ -1320,28 +1927,61 @@ def featurize_files ( featurize_fname_lists_3D_imp (id(self), intensity_files, mask_files, single_roi, output_type, output_path) return get_arrow_file_imp (id(self)) - + def set_environment_params (self, **params): - """Sets parameters of the environment for Nyxus - - Keyword args: - *neighbor_distance: int - Any two objects separated by a Euclidean distance (pixel units) greater than this - value will not be be considered neighbors. This cutoff is used by all features which - rely on neighbor identification. - * pixels_per_micron: float - Specify the image resolution in terms of pixels per micron for unit conversion - of non-unitless features. - * coarse_gray_depth: int - Custom number of levels in grayscale denoising used in texture features. - * n_feature_calc_threads: int (optional, default 4) - Number of threads to use for feature calculation parallelization purposes. - * use_gpu_device: int - Id of the gpu to use. To find available gpus along with ids, using nyxus.get_gpu_properties(). - The default value of -1 uses cpu calculations. Note that the gpu features only support a single - thread for feature calculation. - * verbose: int (optional, non-negative, default 0) - Level of diagnostic output shown in the console. Set '0' to disable any diagnostic output. + r"""Change environment parameters of this instance. + + Only the parameters passed are changed, except `verbose` (see Notes). + + Parameters + ---------- + features : list of str, optional + Stored and reported by :meth:`get_params`, but the enabled feature + set does not change (see Notes). + neighbor_distance : int, optional + Stored and reported by :meth:`get_params`; no feature currently + depends on it. + pixels_per_micron : float, optional + Stored and reported by :meth:`get_params`; no feature currently + depends on it. + coarse_gray_depth : int, optional + Number of grey levels for texture features (see Notes). + n_feature_calc_threads : int, optional + Number of feature calculation threads. + use_gpu_device : int, optional + Accepted and ignored. + verbose : int, optional + Level of diagnostic output. + dynamic_range : float, optional + Number of grey levels a floating-point image is rescaled to. + min_intensity : float, optional + Intensity mapped to the lowest grey level. Negative values are + ignored. + max_intensity : float, optional + Intensity mapped to `dynamic_range`. Negative values are ignored. + + Raises + ------ + ValueError + If a keyword is not one of the above. + + Notes + ----- + `verbose` is applied on every call and falls back to 0 when omitted. + + The enabled feature set and the per-feature settings derived from + `coarse_gray_depth` are fixed when the instance is constructed. Changing + `features`, `coarse_gray_depth` or `ibsi` afterwards updates what + :meth:`get_params` reports but not the features computed by this + instance; construct a new instance to change them. + + Examples + -------- + >>> from nyxus import Nyxus3D + >>> nyx = Nyxus3D(["3MEAN"]) + >>> nyx.set_environment_params(neighbor_distance=3, pixels_per_micron=2.0) + >>> nyx.get_params("neighbor_distance", "pixels_per_micron") + {'neighbor_distance': 3, 'pixels_per_micron': 2.0} """ valid_params = [ @@ -1387,21 +2027,39 @@ def set_environment_params (self, **params): verb_lvl) def set_params(self, **params): - r"""Sets parameters of the Nyxus class - - Keyword args: - - * features: List[str], - * neighbor_distance - * pixels_per_micron - * coarse_gray_depth - * n_feature_calc_threads - * using_gpu - * ibsi: bool - * dynamic_range (float): Desired dynamic range of voxels of a floating point TIFF image. - * min_intensity (float): Minimum intensity of voxels of a floating point TIFF image. - * max_intensity (float): Maximum intensity of voxels of a floating point TIFF image. - + r"""Change parameters of this instance. + + Accepts the keywords of :meth:`set_environment_params` except + `use_gpu_device` and `verbose`, plus `ibsi`. + + Parameters + ---------- + **params + Any of `features`, `neighbor_distance`, `pixels_per_micron`, + `coarse_gray_depth`, `n_feature_calc_threads`, `ibsi` (bool), + `dynamic_range`, `min_intensity` and `max_intensity`. + + Raises + ------ + ValueError + If a keyword is not recognized. ``using_gpu`` passes the first check + but is rejected by :meth:`set_environment_params`. + + Notes + ----- + The enabled feature set and the per-feature settings derived from + `coarse_gray_depth` are fixed when the instance is constructed. Changing + `features`, `coarse_gray_depth` or `ibsi` afterwards updates what + :meth:`get_params` reports but not the features computed by this + instance; construct a new instance to change them. + + Examples + -------- + >>> from nyxus import Nyxus3D + >>> nyx = Nyxus3D(["3MEAN"]) + >>> nyx.set_params(neighbor_distance=3, ibsi=True) + >>> nyx.get_params("neighbor_distance", "ibsi") + {'ibsi': 1, 'neighbor_distance': 3} """ available_environment_params = [ @@ -1438,27 +2096,31 @@ def set_params(self, **params): self.set_environment_params(**environment_params) def get_params(self, *args): - r"""Returns the parameters of a Nyxus object. If no args are supplied, all parameters will be returned. - - Valid parameters are: - - * features: List[str], - * neighbor_distance - * pixels_per_micron - * coarse_gray_depth - * n_feature_calc_threads - * using_gpu - * ibsi: bool - * dynamic_range (float): Desired dynamic range of voxels of a floating point TIFF image. - * min_intensity (float): Minimum intensity of voxels of a floating point TIFF image. - * max_intensity (float): Maximum intensity of voxels of a floating point TIFF image. - + r"""Return parameters of this instance. + Parameters ---------- - args: Strings containing parameter names to get the value of. (Optional) + *args : str + Names of the parameters to return. With no names, all are returned. + Unknown names are ignored. Available names: ``features``, + ``neighbor_distance``, ``pixels_per_micron``, ``coarse_gray_depth``, + ``n_feature_calc_threads``, ``ibsi``, ``dynamic_range``, + ``min_intensity``, ``max_intensity``, ``ram_limit`` (megabytes), + ``gabor_kersize``, ``gabor_gamma``, ``gabor_sig2lam``, ``gabor_f0``, + ``gabor_thold``, ``gabor_thetas`` and ``gabor_freqs``. - Returns: - dict: A dictionary mapping the parameter name to the value + Returns + ------- + dict + Parameter name to value. ``ibsi`` is reported as 0 or 1. The GPU + settings and `verbose` are not reported. + + Examples + -------- + >>> from nyxus import Nyxus3D + >>> nyx = Nyxus3D(["3MEAN"]) + >>> nyx.get_params("coarse_gray_depth", "neighbor_distance", "ibsi") + {'coarse_gray_depth': 64, 'ibsi': 0, 'neighbor_distance': 5} """ vars = list(args) @@ -1466,16 +2128,31 @@ def get_params(self, *args): def get_arrow_ipc_file(self): - """Returns the path to the Arrow IPC file. - - Parameters - ---------- - None + r"""Return the path of the last Arrow IPC file written by this instance. Returns ------- - Path to the Arrow IPC file (string) - + str + Path of the most recent Arrow IPC or Parquet output file. The same + path is returned by :meth:`get_arrow_ipc_file` and + :meth:`get_parquet_file`, whichever format was written last. + + Raises + ------ + RuntimeError + If this build of Nyxus has no Arrow support. + + Examples + -------- + The file-based examples assume the working directory holds the ``int`` and + ``seg`` folders of the two-image sample under ``tests/python/data`` in the + Nyxus repository. + + >>> from nyxus import Nyxus3D + >>> nyx = Nyxus3D(["3MEAN"]) + >>> path = nyx.featurize_directory("int", "seg", ".*", output_type="arrowipc", output_path="out") + >>> nyx.get_arrow_ipc_file() == path + True """ if self.arrow_is_enabled(): @@ -1484,16 +2161,31 @@ def get_arrow_ipc_file(self): raise RuntimeError("Nyxus was not built with Arrow. To use this functionality, rebuild Nyxus with Arrow support on.") def get_parquet_file(self): - """Returns the path to the Arrow IPC file. - - Parameters - ---------- - None + r"""Return the path of the last Parquet file written by this instance. Returns ------- - Path to the Parquet file (string) - + str + Path of the most recent Arrow IPC or Parquet output file. The same + path is returned by :meth:`get_arrow_ipc_file` and + :meth:`get_parquet_file`, whichever format was written last. + + Raises + ------ + RuntimeError + If this build of Nyxus has no Arrow support. + + Examples + -------- + The file-based examples assume the working directory holds the ``int`` and + ``seg`` folders of the two-image sample under ``tests/python/data`` in the + Nyxus repository. + + >>> from nyxus import Nyxus3D + >>> nyx = Nyxus3D(["3MEAN"]) + >>> path = nyx.featurize_directory("int", "seg", ".*", output_type="parquet", output_path="out") + >>> nyx.get_parquet_file() == path + True """ if self.arrow_is_enabled(): return get_parquet_file_imp (id(self)) @@ -1503,99 +2195,108 @@ def get_parquet_file(self): def arrow_is_enabled(self): - """Returns true if arrow support is enabled. - - Parameters - ---------- - None + r"""Report whether this build of Nyxus supports Arrow IPC and Parquet output. Returns ------- - bool: If arrow support is enabled + bool + True when the "arrowipc" and "parquet" output types are available. + + Examples + -------- + >>> from nyxus import Nyxus3D + >>> enabled = Nyxus3D(["3MEAN"]).arrow_is_enabled() """ return arrow_is_enabled_imp (id(self)) class ImageQuality: - """Image quality extraction library + r"""Extract image quality features. - Scalably extracts features from images. - Extracts image quality features from images - - Example: - nyx = ImageQuality( - features = [["*ALL_IMQ*"]], - neighbor_distance = 5, - pixels_per_micron = 1.0, - coarse_gray_depth= 256, - n_feature_calc_threads = 4, - ibsi = False, - ) + Image quality features (FOCUS_SCORE, LOCAL_FOCUS_SCORE, + POWER_SPECTRUM_SLOPE, MAX_SATURATION, MIN_SATURATION, SHARPNESS) are + computed per ROI, or per whole image when no mask is given. Parameters ---------- - features : list[str] - List of features to be calculated. Individual features can be - provided or pre-specified feature groups. Valid groups include: - *ALL* - Both individual features and feature groups are case sensitive. - neighbor_distance: int (optional, default 5) - Any two objects separated by a Euclidean distance (pixel units) greater than this - value will not be be considered neighbors. This cutoff is used by all features which - rely on neighbor identification. - pixels_per_micron: float (optional, default 1.0) - Specify the image resolution in terms of pixels per micron for unit conversion - of non-unitless features. - coarse_gray_depth: int (optional, default 256) - Custom number of levels in grayscale denoising used in texture features. - n_feature_calc_threads: int (optional, default 4) - Number of threads to use for feature calculation parallelization purposes. - ibsi: bool (optional, default false) - IBSI available features will be IBSI compliant when true. - channel_signature: (optional) - Channel signature to match files with the given channel name, such as channel_signature='c' for '_c' like in 'p0_y1_r1_c1.ome.tiff' - parent_channel: (optional) - Channel number that should be used as a provider of parent segments - child_channel: (optional) - Channel number that should be used as a provider of child segments - dynamic_range: (optional) - Desired dynamic range of voxels of a floating point TIFF image. - min_intensity: (optional, default 0.0) - Minimum intensity of voxels of a floating point TIFF image. - max_intensity: (optional, default 1.0) - Maximum intensity of voxels of a floating point TIFF image. - preserve_hu: bool (optional, default False) - NOT needed for CT: Hounsfield units are preserved and reported either way, - despite this option's name. What it actually does: - Carry a FLOATING-POINT slide on the offset-preserving map (1 grey level = 1 - intensity unit, offset by the slide's floored minimum) instead of the min-max - rescaling controlled by dynamic_range/min_intensity/max_intensity. A DICOM or - integer NIfTI slide takes it instead of the stored map, which keeps the stored - integers as grey levels and carries RescaleSlope/RescaleIntercept (scl_slope/ - scl_inter) in the recorded inverse, so a fractional slope loses no stored level. - Other integer slides take the offset map regardless of this flag. The DICOM and - NIfTI rescale reaches the reported values either way, so CT Hounsfield Units survive - the load either way and negative values never wrap. - Whichever map a slide takes is recorded and undone on the way out: - * Every intensity feature -- location (mean, median, mode, percentiles, min, max), - dispersion, the sum/energy family, and the Intensity Histogram (IH_*) features -- - is reported in the slide's own intensity domain. A CT read in Hounsfield units is - reported in Hounsfield units, negatives included. - * Shift-invariant intensity features (variance, standard deviation, skewness, - kurtosis, range, interquartile range) and all shape/texture features: unaffected. - * Sub-unit precision survives on the stored map, which reports a fractional - rescaled value exactly. Elsewhere grey levels are integers, so a value that is not - integer-valued is reported as the grey level it was stored as: truncated, or - rounded to nearest under preserve_hu. Hounsfield units are integer-valued, so CT - is unaffected. - verbose: int (optional, default 0) - Level of diagnostic information in the standard output. Non-negative. 0 is no diagnostic output. - anisotropy_x: float (optional, default 1.0) - Size of a pixel along X relative to Y. Image-quality features are computed on the pixels as acquired, - so it does not change them. - anisotropy_y: float (optional, default 1.0) - Y-dimension counterpart of anisotropy_x + features : list of str + Image quality feature names, or the group ``*ALL_IMQ*``. The 2D + groups of :class:`Nyxus` (``*ALL*`` and others) are not accepted. + Names are case-sensitive and must be upper case. + neighbor_distance, pixels_per_micron, coarse_gray_depth, ibsi : optional + Accepted for parity with :class:`Nyxus` (defaults 5, 1.0, 256, + False), validated as there, and reported by :meth:`get_params`. No + image quality feature depends on them. + n_feature_calc_threads : int, optional + Default 4. Number of feature calculation threads. Must be at least 1. + min_intensity : float, optional + Default 0.0. Intensity of a floating-point image mapped to the lowest + grey level. + max_intensity : float, optional + Default 1.0. Intensity of a floating-point image mapped to the + highest grey level. + ram_limit : int, optional + Memory budget in megabytes. With a mask image, a ROI whose estimated + footprint reaches it is processed out of core, where + ``POWER_SPECTRUM_SLOPE`` and ``SHARPNESS`` are not computed. In + whole-image mode, and for arrays passed to :meth:`featurize`, such an + image or ROI raises RuntimeError instead. Default -1 keeps the built-in limit, half + of the physical memory available when the instance is created. A + value larger than the available memory is rejected with a message on + standard error and the previous limit is kept. + verbose : int, optional + Default 0 (silent). Level of diagnostic output. Must be non-negative. + anisotropy_x : float, optional + Default 1.0. Size of a pixel along X relative to Y. Must be positive. + With files, ``POWER_SPECTRUM_SLOPE`` is computed on the ROI resampled + by the factors, a known defect + (`#519 `__). The other five features are computed + on the pixels as acquired and do not change, and in-memory arrays are + not affected. + anisotropy_y : float, optional + Default 1.0. Y-dimension counterpart of `anisotropy_x`. Must be + positive. + + Other Parameters + ---------------- + dynamic_range, preserve_hu, use_physical_spacing + Applied as in :class:`Nyxus` (defaults 10000, False, False), but the + constructor also prints an "unexpected keyword argument" warning for + them. + gabor_kersize, gabor_gamma, gabor_sig2lam, gabor_f0, channel_signature + Accepted without a warning and ignored. + + Raises + ------ + ValueError + If a feature name is not recognized or a numeric parameter is out of + range. + + Notes + ----- + Unknown keyword arguments print a warning and are ignored, except + ``using_gpu``: it also prints the warning, but a non-negative value sets + `n_feature_calc_threads` to 1, and a value below -1 raises RuntimeError + on a build without GPU support. GPU computation is not available for + image quality features. + + The enabled feature set and the per-feature settings derived from + `coarse_gray_depth` are fixed when the instance is constructed. Changing + `features`, `coarse_gray_depth` or `ibsi` afterwards updates what + :meth:`get_params` reports but not the features computed by this + instance; construct a new instance to change them. + + Examples + -------- + >>> import numpy as np + >>> from nyxus import ImageQuality + >>> rng = np.random.default_rng(0) + >>> image = rng.integers(0, 1000, (64, 64)).astype(np.uint16) + >>> imq = ImageQuality(["*ALL_IMQ*"]) + >>> df = imq.featurize(image) + >>> df.shape + (1, 15) """ def __del__(self): @@ -1655,7 +2356,7 @@ def __init__( raise ValueError("Pixels per micron must be greater than zero.") if coarse_gray_depth <= 0: - raise ValueError("Custom number of grayscale levels (parameter coarse_gray_depth, default=256) must be non-negative.") + raise ValueError("Custom number of grayscale levels (parameter coarse_gray_depth, default=256) must be positive.") if n_feature_calc_threads < 1: raise ValueError("There must be at least one feature calculation thread.") @@ -1713,38 +2414,61 @@ def featurize_directory( output_type: Optional[str] = "pandas", output_path: Optional[str] = "", ): - """Extract image quality features from all the images satisfying the file pattern of provided image directories. + r"""Extract image quality features from the images in a directory. - Extracts all the requested features at the image level from the images - present in `intensity_dir`. If `label_dir` is specified, features will be - extracted for each unique label present in the label images. The file names - of the label images are expected to match those of the intensity images. + Pairs each intensity image in `intensity_dir` with the mask image of the + same file name in `label_dir`. Without `label_dir` each image is one ROI. Parameters ---------- intensity_dir : str - Path to directory containing intensity images. - label_dir : str (optional, default None) - Path to directory containing label images. - file_pattern: str (optional, default ".*") - Regular expression used to filter the images present in both - `intensity_dir` and `label_dir` - output_type: str (optional, default "pandas") - Output format for the features values. Valid options are "pandas", "arrowipc", and "parquet". - output_path: str (optional, default "") - Output filepath for Arrow IPC and Parquet output formats. Default is "", which is the current directory. - The output_path can either be to a directory or filename. For example, - - If 'output_path=/path/to/directory' then a file with the default name NyxusFeatures. will be created. If the directory does not exist, it will be created. - - If 'output_path=/path/to/directory/some_file_name.arrow' then this file will be created. If the directory does not exist, it will also be created. - - If 'output_path=some_file_name.arrow' then this file will be created in the current working directory. + Directory of intensity images. + label_dir : str, optional + Directory of mask (label) images. Default None processes each + intensity image as one ROI covering the whole image. + file_pattern : str, optional + Default ".*". Regular expression selecting the files. It must match + the whole file name, case-sensitively, in both directories. + output_type : {"pandas", "arrowipc", "parquet"}, optional + Output format. Default "pandas" returns a DataFrame; "arrowipc" and + "parquet" write a file and return its path. + output_path : str, optional + Destination of the Arrow IPC or Parquet file; ignored for "pandas". + Default "" writes ``NyxusFeatures.arrow`` or ``NyxusFeatures.parquet`` + in the current working directory. A path with a file name + (``out/features.arrow``) writes that file; a directory path (``out``) + writes the default file name inside it. A missing last directory is + created; if its parent is missing too, the call raises RuntimeError, a + known defect (`#518 `__). Returns ------- - df : pd.DataFrame or str - if output_type == pandas - Pandas DataFrame containing the requested features with one row per label - per image. - Otherwise, arrow file path + pandas.DataFrame or str + For "pandas", a DataFrame with one row per ROI per image: the columns + ``intensity_image``, ``mask_image``, ``phys_unit``, ``ROI_label``, + ``t_index``, ``c_index``, ``phys_x``, ``phys_y``, ``phys_z``, then one + column per feature. Otherwise the path of the written file. + + Raises + ------ + OSError + If `intensity_dir` or `label_dir` does not exist. + ValueError + If `output_type` is not recognized or `file_pattern` is not a valid + pattern. + RuntimeError + If the dataset cannot be read. + + Examples + -------- + The file-based examples assume the working directory holds the ``int`` and + ``seg`` folders of the two-image sample under ``tests/python/data`` in the + Nyxus repository. + + >>> from nyxus import ImageQuality + >>> imq = ImageQuality(["*ALL_IMQ*"]) + >>> imq.featurize_directory("int").shape + (2, 15) """ if not os.path.exists(intensity_dir): raise IOError( @@ -1795,40 +2519,67 @@ def featurize( output_path: Optional[str] = "", ): - """Extract image quality features from a single image pair in a 2D np.array or for all the images in a 3D np.array. - - Extracts all the requested features at the image level from the images - in `intensity_images`. Features will be extracted for each unique label - present in the label images. The name of resulting features in df will be - autogenerated if no intensity and label names are provided. Note that if - names are provided, the number of names must be the same as the number of images. + r"""Extract image quality features from images held in numpy arrays. Parameters ---------- - intensity_images : np.ndarray - 2D image or 3D collection of intensity images. - label_images : np.ndarray (optional, default np.array([[]])) - 2D image or 3D collection of label images. If not supplied, the intensity_images with be processed in whole slide (single ROI) mode. - intensity_names: list (optional, default []) - names for the images in for the DataFrame output. - label_names: list (optional, default []) - names for the labels in for the DataFrame output. - output_type: str (optional, default "pandas") - Output format for the features values. Valid options are "pandas", "arrowipc", and "parquet". - output_path: str (optional, default "") - Output filepath for Arrow IPC and Parquet output formats. Default is "", which is the current directory. - The output_path can either be to a directory or filename. For example, - - If 'output_path=/path/to/directory' then a file with the default name NyxusFeatures. will be created. If the directory does not exist, it will be created. - - If 'output_path=/path/to/directory/some_file_name.arrow' then this file will be created. If the directory does not exist, it will also be created. - - If 'output_path=some_file_name.arrow' then this file will be created in the current working directory. - + intensity_images : numpy.ndarray + One 2D image, or a 3D stack of them (first axis = image). + label_images : numpy.ndarray, optional + Mask (label) images matching `intensity_images` in shape. When + omitted, each image is processed as one ROI covering the whole image. + intensity_names : list of str, optional + Names reported in the ``intensity_image`` column, one per image. + Default ``Intensity0``, ``Intensity1``, ... + label_names : list of str, optional + Names reported in the ``mask_image`` column, one per image. Default + ``Segmentation0``, ``Segmentation1``, ... + output_type : {"pandas", "arrowipc", "parquet"}, optional + Output format. Default "pandas" returns a DataFrame; "arrowipc" and + "parquet" write a file and return its path. + output_path : str, optional + Destination of the Arrow IPC or Parquet file; ignored for "pandas". + Default "" writes ``NyxusFeatures.arrow`` or ``NyxusFeatures.parquet`` + in the current working directory. A path with a file name + (``out/features.arrow``) writes that file; a directory path (``out``) + writes the default file name inside it. A missing last directory is + created; if its parent is missing too, the call raises RuntimeError, a + known defect (`#518 `__). + Returns ------- - df : pd.DataFrame or str - if output_type == pandas - Pandas DataFrame containing the requested features with one row per label - per image. - Otherwise, arrow file path + pandas.DataFrame or str + For "pandas", a DataFrame with one row per ROI per image: the columns + ``intensity_image``, ``mask_image``, ``phys_unit``, ``ROI_label``, + ``t_index``, ``c_index``, ``phys_x``, ``phys_y``, ``phys_z``, then one + column per feature. Otherwise the path of the written file. + + Raises + ------ + ValueError + If the arrays are not both 2D or both 3D, their shapes differ, the + number of names does not match the number of images, or + `output_type` is not recognized. + RuntimeError + If a ROI's estimated footprint reaches `ram_limit`. + + Notes + ----- + The arrays are cast to ``uint32`` by the backend: fractional values + are truncated, and unlike :meth:`Nyxus.featurize` negative values are + not shifted first, so they wrap around to large positive values. + Shift negative images to a non-negative range before calling. + + Examples + -------- + >>> import numpy as np + >>> from nyxus import ImageQuality + >>> rng = np.random.default_rng(0) + >>> images = rng.integers(0, 1000, (2, 64, 64)).astype(np.uint16) + >>> imq = ImageQuality(["FOCUS_SCORE", "SHARPNESS"]) + >>> df = imq.featurize(images, intensity_names=["a", "b"]) + >>> df["intensity_image"].tolist() + ['a', 'b'] """ if (output_type != "" and output_type not in self._valid_output_types): raise ValueError(f'Invalid output type: {output_type}. Valid options are: {self._valid_output_types}') @@ -1927,33 +2678,63 @@ def featurize_files ( output_type: Optional[str] = "pandas", output_path: Optional[str] = "", ): - """Extract image quality features from image file pairs passed as lists - - Extracts all the requested features at the image level from the intensity images - present in list `intensity_files` with respect to region of interest masks presented in - list `mask_files`. Single-ROI feature extraction is enforced via passing 'True' as parameter 'single_roi' + r"""Extract image quality features from lists of image files. Parameters ---------- - intensity_files : list of intensity image file paths - mask_files : list of mask image file paths - single_roi : 'True' to treat items of 'intensity_files' as single-ROI ('mask_files' will be ignored), 'False' to treat items of 'intensity_files' and 'mask_files' as intensity/segmentation image pairs - output_type: str (optional, default "pandas") - Output format for the features values. Valid options are "pandas", "arrowipc", and "parquet". - output_path: str (optional, default "") - Output filepath for Arrow IPC and Parquet output formats. Default is "", which is the current directory. - The output_path can either be to a directory or filename. For example, - - If 'output_path=/path/to/directory' then a file with the default name NyxusFeatures. will be created. If the directory does not exist, it will be created. - - If 'output_path=/path/to/directory/some_file_name.arrow' then this file will be created. If the directory does not exist, it will also be created. - - If 'output_path=some_file_name.arrow' then this file will be created in the current working directory. + intensity_files : list of str + Paths of intensity images. + mask_files : list of str, optional + Paths of mask (label) images, paired with `intensity_files` by + position. Default []. Must be a list; pass [] rather than None. + single_roi : bool, optional + Default True: each intensity image is one ROI covering the whole + image and `mask_files` is ignored. False pairs `intensity_files` + with `mask_files`. + output_type : {"pandas", "arrowipc", "parquet"}, optional + Output format. Default "pandas" returns a DataFrame; "arrowipc" and + "parquet" write a file and return its path. + output_path : str, optional + Destination of the Arrow IPC or Parquet file; ignored for "pandas". + Default "" writes ``NyxusFeatures.arrow`` or ``NyxusFeatures.parquet`` + in the current working directory. A path with a file name + (``out/features.arrow``) writes that file; a directory path (``out``) + writes the default file name inside it. A missing last directory is + created; if its parent is missing too, the call raises RuntimeError, a + known defect (`#518 `__). Returns ------- - df : pd.DataFrame or str - if output_type == pandas - Pandas DataFrame containing the requested features with one row per label - per image. - Otherwise, arrow file path + pandas.DataFrame or str + For "pandas", a DataFrame with one row per ROI per image: the columns + ``intensity_image``, ``mask_image``, ``phys_unit``, ``ROI_label``, + ``t_index``, ``c_index``, ``phys_x``, ``phys_y``, ``phys_z``, then one + column per feature. Otherwise the path of the written file. + + Raises + ------ + OSError + If `intensity_files` is None, or `mask_files` is None while + `single_roi` is False. + TypeError + If `mask_files` is None while `single_roi` is True. + ValueError + If `output_type` is not recognized. + RuntimeError + If `intensity_files` is empty, a file does not exist, the two + lists differ in length while `single_roi` is False, or + featurization fails. + + Examples + -------- + The file-based examples assume the working directory holds the ``int`` and + ``seg`` folders of the two-image sample under ``tests/python/data`` in the + Nyxus repository. + + >>> from nyxus import ImageQuality + >>> imq = ImageQuality(["*ALL_IMQ*"]) + >>> imq.featurize_files(["int/p0_y1_r1_c0.ome.tif"]).shape + (1, 15) """ if intensity_files is None: @@ -1990,21 +2771,39 @@ def featurize_files ( def blacklist_roi(self, blacklist:str): - """Defines the application-wide ROI blacklist + r"""Set the ROI blacklist. - Defines a set of ROI labels that need to be skipped during feature extraction and in the output. Examples: - '27,28,30' - defines a global blacklist of ROI labels 27, 28, and 30 - 'file1.ome.tif:5,6,7;file2.ome.tif:11,12,13,14' - defines 2 file-specific blacklists + Blacklisted ROI labels are skipped during featurization of files + (:meth:`featurize_directory`, :meth:`featurize_files`) and do not appear in + the output. Each call replaces the previous blacklist. Parameters ---------- - blacklist : blacklist definition (string) - - Returns - ------- - None - - """ + blacklist : str + Either a global list of labels, ``"27,28,30"``, or per-file lists + separated by semicolons, ``"file1.ome.tif:5,6,7;file2.ome.tif:11,12"``. + A per-file list names the mask file without its directory; a name + with a directory part is accepted but matches no file. + + Raises + ------ + OSError + If `blacklist` is None or blank. + RuntimeError + If `blacklist` cannot be parsed. + + See Also + -------- + clear_roi_blacklist, roi_blacklist_get_summary + + Examples + -------- + >>> from nyxus import ImageQuality + >>> nyx = ImageQuality(["*ALL_IMQ*"]) + >>> nyx.blacklist_roi("27,28,30") + >>> nyx.roi_blacklist_get_summary() + 'global 27 28 30' + """ if blacklist is None: raise IOError ("ROI blacklist argument is empty") @@ -2014,31 +2813,43 @@ def blacklist_roi(self, blacklist:str): blacklist_roi_imp (id(self), blacklist) def clear_roi_blacklist(self): - """Clears the ROI blacklist - - Parameters - ---------- - None - - Returns - ------- - None - + r"""Clear the ROI blacklist. + + See Also + -------- + blacklist_roi + + Examples + -------- + >>> from nyxus import ImageQuality + >>> nyx = ImageQuality(["*ALL_IMQ*"]) + >>> nyx.blacklist_roi("5") + >>> nyx.clear_roi_blacklist() + >>> nyx.roi_blacklist_get_summary() is None + True """ clear_roi_blacklist_imp (id(self)) def roi_blacklist_get_summary (self): - """Returns a human-friendly text of the summary of the application-wide ROI blacklist - - Parameters - ---------- - None + r"""Describe the ROI blacklist. Returns ------- - text of blacklist summary (string) - - """ + str or None + Human-readable summary of the blacklist, or None when it is empty. + + See Also + -------- + blacklist_roi + + Examples + -------- + >>> from nyxus import ImageQuality + >>> nyx = ImageQuality(["*ALL_IMQ*"]) + >>> nyx.blacklist_roi("file1.ome.tif:5,6;file2.ome.tif:7") + >>> nyx.roi_blacklist_get_summary() + 'file1.ome.tif : 5 6 file2.ome.tif : 7' + """ s = roi_blacklist_get_summary_imp (id(self)) s = s.strip() if len(s) == 0: @@ -2046,16 +2857,54 @@ def roi_blacklist_get_summary (self): return s def set_gabor_feature_params (self, **kwargs): - r"""Sets parameters of feature GABOR - - Keyword args: - * kersize (int): size of filter kernel's side. Example: customize_gabor_feature(kersize=16) - * gamma (float): aspect ratio of the Gaussian factor. Example: customize_gabor_feature(gamma=0.1) - * sig2lam (float): spatial frequency bandwidth. Example: customize_gabor_feature(sig2lam=0.8) - * f0 (float): frequency of the baseline lowpass filter as denominator of `\pi`. Example: customize_gabor_feature(f0=0.1) - * thold (float): lower threshold of the filtered image to baseline ratio. Example: customize_gabor_feature(thold=0.025) - * thetas (list[int]): list of orientations of the Gaussian in degrees 0-180 matching 'freqs'. Example: customize_gabor_feature(theta=1.5708) - * freqs (list[int]): list of denominators of `\pi` as frequencies of Gabor filter's harmonic factor. Example: customize_gabor_feature(freqs="1,2,4,8,16,32,64") + r"""Set the parameters of the GABOR feature. + + Only the parameters passed are changed. + + Parameters + ---------- + kersize : int, optional + Side of the filter kernel, in pixels. + gamma : float, optional + Aspect ratio of the Gaussian factor. + sig2lam : float, optional + Spatial frequency bandwidth (sigma to lambda ratio). + f0 : float, optional + Angular frequency of the baseline low-pass filter, in radians per + pixel; its wavelength is :math:`2\pi/f_0` pixels. + thold : float, optional + Lower threshold of the filtered-image to baseline ratio. + thetas : list of float, optional + Orientations of the Gaussian, in degrees, conventionally 0-180 + (not enforced), one per entry of `freqs`. Must be a list; a + comma-separated string is rejected. + freqs : list of float, optional + Angular frequencies of the filters' harmonic factor, in radians per + pixel (wavelength :math:`2\pi/f` pixels), one per entry of `thetas`. + Must be a list. + + Raises + ------ + ValueError + If a keyword is not one of the above, or a value is invalid (for + example `thetas` and `freqs` of different lengths). + OSError + If no keyword is passed. + + Notes + ----- + Gabor parameters are process-wide: the change applies to every instance, + and the next :class:`Nyxus` constructor call resets them. + + Examples + -------- + >>> from nyxus import ImageQuality + >>> nyx = ImageQuality(["*ALL_IMQ*"]) + >>> nyx.set_gabor_feature_params(kersize=16, gamma=0.1, sig2lam=0.8, + ... f0=0.1, thold=0.025, + ... thetas=[0, 90], freqs=[4, 16]) + >>> nyx.get_params("gabor_thetas", "gabor_freqs") + {'gabor_freqs': [4.0, 16.0], 'gabor_thetas': [0.0, 90.0]} """ params = [ @@ -2097,26 +2946,59 @@ def set_gabor_feature_params (self, **kwargs): def set_environment_params (self, **params): - """Sets parameters of the environment for Nyxus - - Keyword args: - *neighbor_distance: int - Any two objects separated by a Euclidean distance (pixel units) greater than this - value will not be be considered neighbors. This cutoff is used by all features which - rely on neighbor identification. - * pixels_per_micron: float - Specify the image resolution in terms of pixels per micron for unit conversion - of non-unitless features. - * coarse_gray_depth: int - Custom number of levels in grayscale denoising used in texture features. - * n_feature_calc_threads: int (optional, default 4) - Number of threads to use for feature calculation parallelization purposes. - * use_gpu_device: int - Id of the gpu to use. To find available gpus along with ids, using nyxus.get_gpu_properties(). - The default value of -1 uses cpu calculations. Note that the gpu features only support a single - thread for feature calculation. - * verbose: int (optional, non-negative, default 0) - Level of diagnostic output shown in the console. Set '0' to disable any diagnostic output. + r"""Change environment parameters of this instance. + + Only the parameters passed are changed, except `verbose` (see Notes). + + Parameters + ---------- + features : list of str, optional + Stored and reported by :meth:`get_params`, but the enabled feature + set does not change (see Notes). + neighbor_distance : int, optional + Stored and reported by :meth:`get_params`; no feature currently + depends on it. + pixels_per_micron : float, optional + Stored and reported by :meth:`get_params`; no feature currently + depends on it. + coarse_gray_depth : int, optional + Number of grey levels for texture features (see Notes). + n_feature_calc_threads : int, optional + Number of feature calculation threads. + use_gpu_device : int, optional + Accepted and ignored. + verbose : int, optional + Level of diagnostic output. + dynamic_range : float, optional + Number of grey levels a floating-point image is rescaled to. + min_intensity : float, optional + Intensity mapped to the lowest grey level. Negative values are + ignored. + max_intensity : float, optional + Intensity mapped to `dynamic_range`. Negative values are ignored. + + Raises + ------ + ValueError + If a keyword is not one of the above. + + Notes + ----- + `verbose` is applied on every call and falls back to 0 when omitted. + + The enabled feature set and the per-feature settings derived from + `coarse_gray_depth` are fixed when the instance is constructed. Changing + `features`, `coarse_gray_depth` or `ibsi` afterwards updates what + :meth:`get_params` reports but not the features computed by this + instance; construct a new instance to change them. + + Examples + -------- + >>> from nyxus import ImageQuality + >>> nyx = ImageQuality(["*ALL_IMQ*"]) + >>> nyx.set_environment_params(neighbor_distance=3, pixels_per_micron=2.0) + >>> nyx.get_params("neighbor_distance", "pixels_per_micron") + {'neighbor_distance': 3, 'pixels_per_micron': 2.0} """ valid_params = [ @@ -2162,28 +3044,42 @@ def set_environment_params (self, **params): verb_lvl) def set_params(self, **params): - r"""Sets parameters of the Nyxus class - - Keyword args: - - * features: List[str], - * neighbor_distance - * pixels_per_micron - * coarse_gray_depth - * n_feature_calc_threads - * using_gpu - * ibsi: bool - * dynamic_range (float): Desired dynamic range of voxels of a floating point TIFF image. - * min_intensity (float): Minimum intensity of voxels of a floating point TIFF image. - * max_intensity (float): Maximum intensity of voxels of a floating point TIFF image. - - * gabor_kersize (int): size of filter kernel's side. Example: set_params(gabor_kersize=16) - * gabor_gamma (float): aspect ratio of the Gaussian factor. Example: set_params(gabor_gamma=0.1) - * gabor_sig2lam (float): spatial frequency bandwidth. Example: set_params(gabor_sig2lam=0.8) - * gabor_f0 (float): frequency of the baseline lowpass filter as denominator of `\pi`. Example: set_params(gabor_f0=0.1) - * gabor_thetas (str): comma-separated orientations of the Gaussian in degrees 0-180. Example: set_params(gabor_thetas="0,45,60") - * gabor_thold (float): lower threshold of the filtered image to baseline ratio. Example: set_params(gabor_thold=0.025) - * gabor_freqs (str): comma-separated denominators of `\pi` as frequencies of Gabor filter's harmonic factor. Example: set_params(gabor_freqs="1,2,4,8,16,32,64") + r"""Change parameters of this instance. + + Accepts the keywords of :meth:`set_environment_params` except + `use_gpu_device`, plus `ibsi` and the ``gabor_*`` keywords, which are + forwarded to :meth:`set_gabor_feature_params` without the prefix. + + Parameters + ---------- + **params + Any of `features`, `neighbor_distance`, `pixels_per_micron`, + `coarse_gray_depth`, `n_feature_calc_threads`, `ibsi` (bool), + `dynamic_range`, `min_intensity`, `max_intensity`, `verbose`, + `gabor_kersize`, `gabor_gamma`, `gabor_sig2lam`, `gabor_f0`, + `gabor_thold`, `gabor_thetas` (list) and `gabor_freqs` (list). + + Raises + ------ + ValueError + If a keyword is not recognized. ``using_gpu`` passes the first check + but is rejected by :meth:`set_environment_params`. + + Notes + ----- + The enabled feature set and the per-feature settings derived from + `coarse_gray_depth` are fixed when the instance is constructed. Changing + `features`, `coarse_gray_depth` or `ibsi` afterwards updates what + :meth:`get_params` reports but not the features computed by this + instance; construct a new instance to change them. + + Examples + -------- + >>> from nyxus import ImageQuality + >>> imq = ImageQuality(["*ALL_IMQ*"]) + >>> imq.set_params(neighbor_distance=3) + >>> imq.get_params("neighbor_distance") + {'neighbor_distance': 3} """ available_environment_params = [ @@ -2226,35 +3122,31 @@ def set_params(self, **params): self.set_environment_params(**environment_params) def get_params(self, *args): - r"""Returns the parameters of a Nyxus object. If no args are supplied, all parameters will be returned. - - Valid parameters are: - - * features: List[str], - * neighbor_distance - * pixels_per_micron - * coarse_gray_depth - * n_feature_calc_threads - * using_gpu - * ibsi: bool - * dynamic_range (float): Desired dynamic range of voxels of a floating point TIFF image. - * min_intensity (float): Minimum intensity of voxels of a floating point TIFF image. - * max_intensity (float): Maximum intensity of voxels of a floating point TIFF image. - - * gabor_kersize (int): size of filter kernel's side. Example: set_params(gabor_kersize=16) - * gabor_gamma (float): aspect ratio of the Gaussian factor. Example: set_params(gabor_gamma=0.1) - * gabor_sig2lam (float): spatial frequency bandwidth. Example: set_params(gabor_sig2lam=0.8) - * gabor_f0 (float): frequency of the baseline lowpass filter as denominator of `\pi`. Example: set_params(gabor_f0=0.1) - * gabor_thetas (str): comma-separated orientations of the Gaussian in degrees 0-180. Example: set_params(gabor_thetas="45,60,70") - * gabor_thold (float): lower threshold of the filtered image to baseline ratio. Example: set_params(gabor_thold=0.025) - * gabor_freqs (str): comma-separated denominators of `\pi` as frequencies of Gabor filter's harmonic factor. Example: set_params(gabor_freqs="1,2,4,8,16,32,64") - + r"""Return parameters of this instance. + Parameters ---------- - args: Strings containing parameter names to get the value of. (Optional) + *args : str + Names of the parameters to return. With no names, all are returned. + Unknown names are ignored. Available names: ``features``, + ``neighbor_distance``, ``pixels_per_micron``, ``coarse_gray_depth``, + ``n_feature_calc_threads``, ``ibsi``, ``dynamic_range``, + ``min_intensity``, ``max_intensity``, ``ram_limit`` (megabytes), + ``gabor_kersize``, ``gabor_gamma``, ``gabor_sig2lam``, ``gabor_f0``, + ``gabor_thold``, ``gabor_thetas`` and ``gabor_freqs``. - Returns: - dict: A dictionary mapping the parameter name to the value + Returns + ------- + dict + Parameter name to value. ``ibsi`` is reported as 0 or 1. The GPU + settings and `verbose` are not reported. + + Examples + -------- + >>> from nyxus import ImageQuality + >>> nyx = ImageQuality(["*ALL_IMQ*"]) + >>> nyx.get_params("coarse_gray_depth", "neighbor_distance", "ibsi") + {'coarse_gray_depth': 256, 'ibsi': 0, 'neighbor_distance': 5} """ vars = list(args) @@ -2262,38 +3154,40 @@ def get_params(self, *args): class Nested: - r"""Nyxus image feature extraction library / ROI hierarchy analyzer - - Valid aggregate functions are any functions available in pandas.DatFrame.aggregate, - e.g. min, max, count, std. Lambda functions can also be passed. To provide a name to - the aggregate function, pass in a list of tuples where the first element in the name - and the second is the function, e.g. aggregate=[('nanmean', lambda x: np.nanmean(x))]. - + r"""Relate ROIs of two mask channels as parents and children. + + :meth:`find_relations` relates each child ROI to every parent ROI whose + bounding box contains the child's bounding box; :meth:`featurize` joins + child features to those relations and optionally aggregates them. + Parameters ---------- - aggregate : list - List of aggregate functions. Any aggregate function from Pandas can be used - along with lambda functions. - - Example - ------- - from nyxus import Nested, Nyxus - - int_path = '/home/data/6234838c6b123e21c8b736f5/tissuenet_tif/int' - seg_path = '/home/data/6234838c6b123e21c8b736f5/tissuenet_tif/seg' - - nyx = Nyxus(["*ALL*"]) - - features = nyx.featurize(int_path, seg_path, file_pattern='p[0-9]_y[0-9]_r[0-9]_c0\.ome\.tif') - - nn = Nested() - - parent_filepattern = 'p{r}_y{c}_r{z}_c1.ome.tif' - child_filepattern = 'p{r}_y{c}_r{z}_c0.ome.tif' - - rels = nn.find_relations (seg_path, parent_filepattern, child_filepattern) - - df = nn.featurize(rels, features) + aggregate : list, optional + Aggregations applied by :meth:`featurize`, in any form accepted by + :meth:`pandas.DataFrame.aggregate`: names such as ``"mean"``, + ``"min"``, ``"max"``, ``"std"``, ``"count"``, functions, or + ``(name, function)`` tuples such as + ``("nanmean", lambda x: np.nanmean(x))``. Default [] (no aggregation). + + Examples + -------- + The file-based examples assume the working directory holds the ``int`` and + ``seg`` folders of the two-image sample under ``tests/python/data`` in the + Nyxus repository. In it, ``*_c1`` masks hold the parents and ``*_c0`` masks + the children. + + >>> from nyxus import Nested, Nyxus + >>> nyx = Nyxus(["MEAN"]) + >>> child_features = nyx.featurize_directory("int", "seg", + ... file_pattern=r".*_c0\.ome\.tif") + >>> child_features = child_features.rename(columns={"ROI_label": "label"}) + >>> nn = Nested(aggregate=["mean", "max"]) + >>> rels = nn.find_relations("seg", r".*_c1\.ome\.tif", r".*_c0\.ome\.tif") + >>> list(rels.columns) + ['Image', 'Parent_Label', 'Child_Label'] + >>> df = nn.featurize(rels, child_features) + >>> bool(df[("MEAN", "mean")].notna().all()) + True """ def __del__(self): @@ -2318,23 +3212,46 @@ def find_relations( parent_file_pattern: str, child_file_pattern: str): - r"""Finds parent-child relationships. + r"""Find parent-child relations between two sets of mask images. - Find parent-child relationships of parent files matching the parent_file_pattern - and child files matching the child_file_pattern. + Parent and child masks are read from the same directory and paired in + file order; each child ROI is related to every parent ROI whose bounding + box contains the child's bounding box. Parameters ---------- - label_dir : str - Path to directory containing label images. - parent_file_pattern: str - Regex filepattern to filter the parent files e.g. "p.*_c1\.ome\.tif". + label_dir : str + Directory holding both the parent and the child mask images. + parent_file_pattern : str + Regular expression selecting the parent masks, e.g. + ``r".*_c1\.ome\.tif"``. child_file_pattern : str - Regex filepattern to filter the child files e.g. "p.*_c0\.ome\.tif". + Regular expression selecting the child masks, e.g. + ``r".*_c0\.ome\.tif"``. + Returns ------- - rel : array - array of , structure + pandas.DataFrame + One row per relation, with the columns ``Image`` (path of the parent + mask), ``Parent_Label`` and ``Child_Label``. + + Raises + ------ + OSError + If `label_dir` does not exist. + ValueError + If a pattern is not valid. + RuntimeError + If no parent or no child files match, their counts differ, or the + hierarchy cannot be computed. + + Notes + ----- + Progress information is printed to standard output. + + Examples + -------- + See :class:`Nested`. """ if not os.path.exists(label_dir): @@ -2357,26 +3274,45 @@ def find_relations( return df def featurize(self, parent_child_map: pd.DataFrame, child_features: pd.DataFrame): - """Join child ROI features to the parent-child map. + r"""Join child ROI features to parent-child relations. - Joins parent-child map from the find_relations method with the features from Nyxus. - When aggregate functions are provided from the constructor this method will apply - the aggregate functions to the joined DataFrame. When aggregate functions are not - provided, this method will return a pivoted DataFrame where the columns are grouped by - the child labels and the rows are the ROI labels. + Merges `parent_child_map` with `child_features` on ``Child_Label`` == + ``label``. With aggregations set in the constructor, the joined feature + columns are grouped by ``label`` and aggregated. Without them, the result + is pivoted with ``label`` as the index and ``Child_Label`` as the columns. Parameters ---------- - parent_child_map : pd.DataFrame - Map of parent child relations from the find_relations method. - child_features: pd.DataFrame - Features of the child channel ROIs from Nyxus.find_relations method. + parent_child_map : pandas.DataFrame + Relations returned by :meth:`find_relations`. + child_features : pandas.DataFrame + Features of the child ROIs, e.g. from :meth:`Nyxus.featurize_directory`. + Must have a ``label`` column holding the child ROI label; Nyxus names + that column ``ROI_label``, so rename it first. Returns ------- - pd.DataFrame - DataFrame containing aggregated features for each ROI when aggregate functions are provided. - Pivoted DataFrame containing features the child ROI for each label when no aggregate functions are provided. + pandas.DataFrame + Aggregated features, one row per ``label``, with a + ``(feature, aggregation)`` column for each pair when aggregations are + set; otherwise the pivoted table. + + Raises + ------ + KeyError + If `child_features` has no ``label`` column. + + Notes + ----- + The aggregated and pivoted columns are every joined column after the + sixth, which on Nyxus output includes ``label`` itself. Without + aggregations the pivot therefore raises ValueError on Nyxus output. + ``label`` holds the child label, so the grouping is by child, not by + parent. + + Examples + -------- + See :class:`Nested`. """ joined_df = parent_child_map.merge(child_features, left_on=['Child_Label'], right_on=['label']) diff --git a/tests/data/hounsfield/README.md b/tests/data/hounsfield/README.md index 2fd5750e4..d099360e1 100644 --- a/tests/data/hounsfield/README.md +++ b/tests/data/hounsfield/README.md @@ -1,11 +1,12 @@ # Hounsfield-Unit (HU) loader test fixtures -Tiny tiled TIFFs used by the HU loader mechanics tests in `tests/test_2d_hu_mechanics.h` -to verify the `--preserve-hu` offset mapping through the real TIFF decode path. +Tiny tiled TIFFs and DICOM slices used by the HU loader mechanics tests in `tests/test_2d_hu_mechanics.h` +to verify the load-time offset mapping through the real TIFF and DICOM decode paths. The two TIFFs are +also read end to end by `tests/python/test_2d_hu_regression.py`. | File | Type | Notes | |---|---|---| -| `ct_int16.tif` | signed int16 (SampleFormat=2) | exercises `loadTile` (signed CT wraparound fix) | +| `ct_int16.tif` | signed int16 (SampleFormat=2) | exercises `loadTile`; negative values must not wrap | | `ct_float.tif` | float32 (SampleFormat=3) | exercises `loadTile_real_intens` | | `ct_u16.dcm` | DICOM uint16, RescaleIntercept=−1024 | textbook CT: rescale then offset | | `ct_i16.dcm` | DICOM int16 signed, intercept 0 | signed stored values, no wraparound | @@ -13,7 +14,13 @@ to verify the `--preserve-hu` offset mapping through the real TIFF decode path. All are 16×16, a single tile, encoding the SAME logical HU field `HU(r,c) = -1024 + idx*8`, `idx = r*16 + c` (0..255) — a CT/HU-like range -−1024..1016 crossing 0 (water). In HU mode the loader maps HU → HU + 1024 = idx*8. +−1024..1016 crossing 0 (water). On the offset map the loader maps HU → HU + 1024 = idx*8. The int16 +TIFF takes that map with or without `--preserve-hu`; the float32 TIFF takes it only under the flag and +is otherwise min-max quantized; without the flag `ct_u16.dcm` and `ct_i16.dcm` take the stored map, +which for a slope of 1 and an integer intercept lands on the same grey levels. + +`mask.tif` is the matching 16×16 uint16 all-ones mask (no SampleFormat tag), the whole-image ROI that +`tests/python/test_2d_hu_regression.py` pairs with the two TIFFs. These fixtures are committed to the repository and consumed as-is. The construction of each file is documented in this README so the ground truth the tests assert @@ -50,7 +57,8 @@ ds.save_as("ct_frac.dcm", enforce_file_format=True) # explicit VR little endia ## 3D NIfTI fixtures Used by `tests/python/test_3d_hu_nifti_regression.py` to verify `preserve_hu` through the 3D NIfTI -loader path (`RawNiftiLoader`/`NiftiLoader`). +loader path (`RawNiftiLoader`/`NiftiLoader`). `ct3d_int16.nii` is also the oversized-volume input of +`TEST_3D_WHOLEVOLUME_UNSTREAMABLE_FORMAT_FAILS_LOUDLY_MECHANICS` in `tests/test_all.cc`. | File | Type | Notes | |---|---|---| @@ -108,9 +116,10 @@ write("ct3d_nan.nii", 16, 32, 1.0, 0.0, struct.pack("<512f", *v), 0.0, 511.0) `ct_small.dcm` is pydicom's `CT_small.dcm` test file (128x128 signed int16, RescaleSlope=1, RescaleIntercept=-1024, HU range -896..1167), vendored from the pydicom distribution (MIT-licensed test data). It gives the HU tests a genuine -scanner slice alongside the synthetic ones. The `TEST_HU_LOADER_DICOM_CT_SMALL_*` -gtests assert pixel values computed independently with pydicom -(`RescaleSlope*stored + RescaleIntercept`, then offset by floor(HU min) = -896). +scanner slice alongside the synthetic ones. The `TEST_2D_HU_LOADER_DICOM_CT_SMALL_*` +gtests assert pixel values computed independently with pydicom: the preserve case +`RescaleSlope*stored + RescaleIntercept`, then offset by floor(HU min) = -896; the baseline +case, read with rescale off as a mask is, the raw stored values. Obtain the original via: `python -c "from pydicom.data import get_testdata_file; print(get_testdata_file('CT_small.dcm'))"`. ## pydicom oracle fixture (feature-level) @@ -121,8 +130,9 @@ Obtain the original via: `python -c "from pydicom.data import get_testdata_file; | `ct_small_mask.tif` | 128×128 uint16, all ones | whole-image ROI | Used by `tests/python/test_2d_hu_ct_small_pydicom.py` — the oracle test that vets HU *feature* -values (MIN/MAX/MEAN/INTEGRATED) against pydicom (docs/vetting SPEC.md §4 token `pydicom`). The +values (MIN/MAX/MEAN/INTEGRATED) against pydicom (`tests/vetting/SPEC.md` §4 token `pydicom`). The pixels come from pydicom's decode of a real scanner slice and the goldens are pinned from pydicom+numpy, so it is an independent oracle, not a self-consistency snapshot (§5.2). CI -consumes only the committed TIFFs. +consumes only the committed TIFFs. `tests/python/test_2d_tagless_tiled_tiff_mechanics.py` reads the +same pair: `ct_small_mask.tif` carries no SampleFormat tag, and must still read as unsigned. diff --git a/tests/data/omezarr/README.md b/tests/data/omezarr/README.md index 6271f7a1f..a6229f772 100644 --- a/tests/data/omezarr/README.md +++ b/tests/data/omezarr/README.md @@ -1,9 +1,10 @@ # OME-Zarr test datasets -These datasets are consumed by `tests/test_omezarr_mechanics.h` (and the OME-Zarr cases in -`tests/test_all.cc`), which exercise the two z5-based OME-Zarr readers: +These datasets are consumed by `tests/test_2d_omezarr_mechanics.h` (the stores in the table +below except `dim5.ome.zarr`) and `tests/test_omezarr_mechanics.h` (the 3D/5D stores), and by the +OME-Zarr cases in `tests/test_all.cc`, which exercise the two z5-based OME-Zarr readers: -- `NyxusOmeZarrLoader` (`src/nyx/omezarr.h`) — the Hedgehog tile-loader +- `NyxusOmeZarrLoader` (`src/nyx/omezarr.h`) — the `AbstractTileLoader` tile loader - `RawOmezarrLoader` (`src/nyx/raw_omezarr.h`) — the raw-format loader Both readers open an OME-NGFF image group (zarr v2 or v3) whose attributes carry @@ -13,7 +14,7 @@ layout through `resolve_zarr_layout` (`src/nyx/ome/ome_zarr_layout.h`): each axi channel and timeframe extents -- and the tile width, height and depth -- are the `shape` and `chunks` entries at that role's storage index. The on-disk order is therefore whatever `axes` declares: `dim5_ztcyx.ome.zarr` is shape `[4, 2, 3, 6, 8]` and reports depth 4, not `shape[2]`. -Only when `axes` is absent is the order taken positionally, as `(T, C, Z, Y, X)` right-aligned +Only when `axes` is absent (or is not a list) is the order taken positionally, as `(T, C, Z, Y, X)` right-aligned to the array's rank (X last, Y before it, then Z, C, T). ## Datasets @@ -43,28 +44,34 @@ and `3.75`; every other sample is `1.0`. A real-valued store is free to hold any `RawOmezarrLoader::get_uint32_pixel` is what a float32 *mask* is read through -- converting a non-finite or an out-of-range double to an unsigned integer is undefined, not merely lossy. -The table above covers the stores written by hand or by bfio. Everything else in this -directory is written by `gen_dim5.py`, whose module docstring and per-writer docstrings are the -inventory for those; the four described under "Converter-shaped and diagnostic stores" below are -called out here because what they cover is a property of the *container*, not of the pixels. +The table above covers the stores written by hand or by bfio, plus `dim5.ome.zarr` (described +below). Everything else in this directory comes from `gen_dim5.py`, whose module docstring, +per-writer docstrings and `main()` are the inventory for those; the four described under +"Converter-shaped and diagnostic stores" below are called out here because what they cover is a +property of the *container*, not of the pixels. Two exceptions: `main()` does not call +`write_label_mask`, `write_v3_sharded` or `write_v3_badcodec`, so `python gen_dim5.py` leaves +`dim3_mask.ome.zarr`, `dim5_v3_sharded.ome.zarr` and `dim5_v3_badcodec.ome.zarr` as they are; and +`dim5_v3_zstd.ome.zarr` (zarr v3, `bytes` + `zstd` codecs, one chunk per `(t, c, z)` plane, read +only by a build with zstd) has no writer in the script. ### Converter-shaped and diagnostic stores -These exist because the rest of the 3D/5D fixtures here are little-endian, flat-separator and -uncompressed — i.e. none of them resembles what `bioformats2raw` actually writes. They were +These exist because the other zarr v2 fixtures here are little-endian and flat-separator, and +all but the two bfio stores are uncompressed — i.e. none of them resembles what `bioformats2raw` +actually writes. They were added after a cross-container run over real converter output. | Store | What is unusual about it | Expected | |-------|--------------------------|----------| | `dim3_nested.ome.zarr` | `dimension_separator: "/"` (chunk `(1,0,0)` is the file `0/1/0/0`) plus a blosc/lz4 codec. Nesting is the NGFF 0.4 default and what the converter emits; `--no-nested` downgrades the declared version to 0.1. | reads identically to `dim3_zyx.ome.zarr` | -| `bigendian.ome.zarr` | `dtype: ">u2"`, payload genuinely byte-swapped. `bioformats2raw` through 0.9.x writes big-endian and has no switch to change it. z5 maps only the `<` and `|` spellings, so the array cannot be opened. | refused, and the message names big-endianness and the remedy | +| `bigendian.ome.zarr` | `dtype: ">u2"`, payload genuinely byte-swapped. `bioformats2raw` 0.9 writes big-endian and has no switch to change it; 0.12 writes little-endian. z5 maps only the `<` and `|` spellings, so the array cannot be opened. | refused, and the message names big-endianness and the remedy | | `b2r_layout.ome.zarr` | `bioformats2raw` layout: the root carries only `{"bioformats2raw.layout": 3}` and the image is the child group `0`. The data is fine; the path is one level too high. | refused, and the message names the layout and the series group | | `b2r_layout_v3.ome.zarr` | the same, in zarr v3 / NGFF 0.5, where the key sits under `"ome"`. | as above | `tests/test_omezarr_mechanics.h` asserts the first through `test_omezarr_nested_chunk_keys_mechanics()` and the other three through -`test_omezarr_diagnosed_refusals_mechanics()`, which checks the message text rather than only -that a throw happened — the throw alone was already there. +`test_omezarr_diagnosed_refusals_mechanics()`, which checks the message text (and that it names +the store) rather than only that the open throws. ### `dim5.ome.zarr` — 5D channel/timeframe addressability @@ -82,13 +89,14 @@ 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` +`TEST_OMEZARR_5D_CHANNEL_TIME_ADDRESSING_MECHANICS` / `TEST_RAW_OMEZARR_5D_CHANNEL_TIME_ADDRESSING_MECHANICS` +(through `assert_omezarr_addressing` / `assert_raw_omezarr_addressing` in `tests/test_omezarr_mechanics.h`) 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: +Deterministic checksums asserted by `tests/test_2d_omezarr_mechanics.h`: - `test.ome.zarr` sum of all pixels = `133955584` - `multi.ome.zarr` sum of all pixels = `12681000000` diff --git a/tests/python/test_api_arguments_mechanics.py b/tests/python/test_api_arguments_mechanics.py new file mode 100644 index 000000000..d64e178f1 --- /dev/null +++ b/tests/python/test_api_arguments_mechanics.py @@ -0,0 +1,31 @@ +"""Mechanics: the Python constructors refuse a coarse_gray_depth that is not positive, and say so. + +Kind: *mechanics* per tests/vetting/SPEC.md 2 -- this asserts argument checking in the Python +wrappers, not a feature value. + +Nyxus, Nyxus3D and ImageQuality each refuse coarse_gray_depth <= 0 with a ValueError. The +message names the rule the check applies: 0 is refused, so the value must be positive. The +command line accepts 0 and negative values (no binning, PyRadiomics-style binning); the Python +constructors do not reach those modes. +""" +import pytest + +import nyxus + +CONSTRUCTORS = [ + pytest.param(lambda d: nyxus.Nyxus(["MEAN"], coarse_gray_depth=d), id="Nyxus"), + pytest.param(lambda d: nyxus.Nyxus3D(["3MEAN"], coarse_gray_depth=d), id="Nyxus3D"), + pytest.param(lambda d: nyxus.ImageQuality(["FOCUS_SCORE"], coarse_gray_depth=d), id="ImageQuality"), +] + + +@pytest.mark.parametrize("make", CONSTRUCTORS) +@pytest.mark.parametrize("depth", [0, -8]) +def test_coarse_gray_depth_not_positive_refused_mechanics(make, depth): + with pytest.raises(ValueError, match="coarse_gray_depth.*must be positive"): + make(depth) + + +@pytest.mark.parametrize("make", CONSTRUCTORS) +def test_coarse_gray_depth_positive_accepted_mechanics(make): + make(8) diff --git a/tests/python/test_docs_cli_reference_mechanics.py b/tests/python/test_docs_cli_reference_mechanics.py new file mode 100644 index 000000000..77762e455 --- /dev/null +++ b/tests/python/test_docs_cli_reference_mechanics.py @@ -0,0 +1,95 @@ +"""The command-line reference generator catches a default or a citation that does not match the code. + +docs/tools/gen_cli_reference.py --check compares every entry of docs/cli_options.toml with the +source: each single-token default must be the one literal value assigned to the target its +`default_at` key names, and every cited file and line range must exist. These tests change one +entry, or one source file in a copy of the tree, at a time and expect the check to report it, and +move code within a file and expect the check to still pass. +""" +import copy +import importlib.util +import pathlib + +import pytest + +pytest.importorskip("tomllib") # the generator reads TOML with the standard library (Python 3.11+) + +REPO = pathlib.Path(__file__).resolve().parent.parent.parent +_spec = importlib.util.spec_from_file_location("gen_cli_reference", REPO / "docs" / "tools" / "gen_cli_reference.py") +gen = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(gen) + + +def _with(options, name, **changes): + """A copy of the option table with the entry `name` changed.""" + out = copy.deepcopy(options) + entry = next(o for o in out if o["name"] == name) + entry.update(changes) + return out + + +def test_docs_cli_reference_table_matches_code_mechanics(): + options = gen.load_table() + assert gen.check_names(options) == [] + assert gen.check_defaults(options) == [] + + +@pytest.mark.parametrize("name, wrong", [ + ("--verbose", "0"), + ("--reduceThreads", "1"), + ("--pixelDistance", "1"), + ("--coarseGrayDepth", "1"), + ("--tinyval", "0.0"), + ("--gaborkersize", "20"), + ("--anisoy", "2"), + ("--glcmAngles", "0,45"), +]) +def test_docs_cli_reference_wrong_default_is_caught_mechanics(name, wrong): + problems = gen.check_defaults(_with(gen.load_table(), name, default=wrong)) + assert any(p.startswith(name + ":") and "not the value assigned" in p for p in problems), problems + + +def test_docs_cli_reference_missing_cited_file_is_caught_mechanics(): + # --ramLimit has a descriptive default, so its evidence is checked even without a default_at line + options = gen.load_table() + entry = next(o for o in options if o["name"] == "--ramLimit") + problems = gen.check_defaults(_with(options, "--ramLimit", evidence=entry["evidence"] + "; nosuchfile.cpp:12")) + assert any("nosuchfile.cpp" in p for p in problems), problems + + +def test_docs_cli_reference_default_without_default_at_is_refused_mechanics(): + options = _with(gen.load_table(), "--dim") + del next(o for o in options if o["name"] == "--dim")["default_at"] + problems = gen.check_defaults(options) + assert any(p.startswith("--dim:") and "no default_at" in p for p in problems), problems + + +def _source_copy(tmp_path, edit): + """A copy of src/nyx with environment.cpp changed by `edit`.""" + import shutil + dst = tmp_path / "nyx" + shutil.copytree(gen.SRC, dst) + env = dst / "environment.cpp" + env.write_text(edit(env.read_text(encoding="utf-8")), encoding="utf-8") + return dst + + +def test_docs_cli_reference_default_survives_moved_code_mechanics(tmp_path, monkeypatch): + # Lines added above every default in environment.cpp move them; the check finds each by its target. + monkeypatch.setattr(gen, "SRC", _source_copy(tmp_path, lambda t: "#include \n" * 3 + t)) + assert gen.check_defaults(gen.load_table()) == [] + + +def test_docs_cli_reference_changed_default_in_code_is_caught_mechanics(tmp_path, monkeypatch): + monkeypatch.setattr(gen, "SRC", _source_copy( + tmp_path, lambda t: t.replace("n_pixel_distance = 5;", "n_pixel_distance = 3;"))) + problems = gen.check_defaults(gen.load_table()) + assert any(p.startswith("--pixelDistance:") and "not the value assigned" in p for p in problems), problems + + +def test_docs_cli_reference_ambiguous_default_target_is_refused_mechanics(tmp_path, monkeypatch): + # A second literal assignment of the same target leaves the default undecided. + monkeypatch.setattr(gen, "SRC", _source_copy( + tmp_path, lambda t: t + "\nvoid f() { n_pixel_distance = 7; }\n")) + problems = gen.check_defaults(gen.load_table()) + assert any(p.startswith("--pixelDistance:") and "exactly one literal" in p for p in problems), problems diff --git a/tests/python/test_docs_conf_mechanics.py b/tests/python/test_docs_conf_mechanics.py new file mode 100644 index 000000000..4ed82dab9 --- /dev/null +++ b/tests/python/test_docs_conf_mechanics.py @@ -0,0 +1,34 @@ +"""The Sphinx configuration gives the pages' |release| substitution a value. + +docs/source/index.rst quotes the release in its citation through |release|. Sphinx renders an +unset release as an empty string without a warning, so the strict docs build does not catch it; +conf.py must set it from CITATION.cff, and the page title must stay without a version number. +""" +import pathlib +import re +import runpy + +REPO = pathlib.Path(__file__).resolve().parent.parent.parent +CONF = REPO / "docs" / "source" / "conf.py" + + +def _citation_version(): + text = (REPO / "CITATION.cff").read_text(encoding="utf-8") + return re.search(r"^version:\s*([^\s#]+)", text, re.M).group(1) + + +def test_docs_conf_release_set_from_citation_mechanics(): + conf = runpy.run_path(str(CONF)) + assert conf.get("release") == _citation_version() + assert conf.get("version") == ".".join(_citation_version().split(".")[:2]) + + +def test_docs_conf_title_has_no_version_mechanics(): + conf = runpy.run_path(str(CONF)) + assert conf["html_title"] == "Nyxus" + + +def test_docs_conf_release_used_by_pages_mechanics(): + # the substitution this configuration serves is still in use; drop the release if it is not + uses = [p for p in (REPO / "docs" / "source").rglob("*.rst") if "|release|" in p.read_text(encoding="utf-8")] + assert uses diff --git a/tests/python/test_docs_examples_mechanics.py b/tests/python/test_docs_examples_mechanics.py new file mode 100644 index 000000000..3a433ca81 --- /dev/null +++ b/tests/python/test_docs_examples_mechanics.py @@ -0,0 +1,89 @@ +"""The Python examples shown in the documentation run as written. + +Every script in docs/examples/ is run here, unmodified, in a fresh directory. The pages include +those scripts with literalinclude, so what a reader copies is what this test executed. Each +script creates its own input (or copies it from tests/python/data) and ends with assertions +on its result, kept outside the part the page shows. + +A new script in docs/examples/ is picked up automatically. + +The scripts run on every Python the package supports, so they use only modules that Python 3.10 +has. The values each page quotes live in docs/feature_extras.toml; this module reads that file +(tomllib, or tomli, which pytest installs on Python 3.10) and hands the scripts a JSON copy +through NYXUS_DOCS_EXTRAS. +""" +import json +import os +import pathlib +import re +import subprocess +import sys + +import pytest + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib + +pytest.importorskip("tifffile") + +REPO = pathlib.Path(__file__).resolve().parent.parent.parent +EXAMPLES = sorted((REPO / "docs" / "examples").glob("*.py")) +NEEDS_ARROW = {"arrow_output.py"} +# Modules that Python 3.10, the oldest supported version, does not have +NOT_IN_PY310 = {"tomllib"} + + +@pytest.fixture(scope="module") +def extras_json(tmp_path_factory): + extras = tomllib.loads((REPO / "docs" / "feature_extras.toml").read_text(encoding="utf-8")) + path = tmp_path_factory.mktemp("extras") / "feature_extras.json" + path.write_text(json.dumps(extras), encoding="utf-8") + return path + + +def _params(): + return [pytest.param(s, id=s.stem, marks=[pytest.mark.arrow] if s.name in NEEDS_ARROW else []) + for s in EXAMPLES] + + +def test_docs_examples_present_mechanics(): + assert EXAMPLES, "docs/examples/ holds no example scripts" + for script in EXAMPLES: + text = script.read_text(encoding="utf-8") + assert "# example-start" in text and "# example-end" in text, \ + f"{script.name}: the page includes the lines between '# example-start' and '# example-end'" + + +def test_docs_examples_python310_mechanics(): + for script in EXAMPLES: + imported = set(re.findall(r"^\s*(?:import|from)\s+(\w+)", script.read_text(encoding="utf-8"), re.M)) + assert not imported & NOT_IN_PY310, \ + f"{script.name} imports {sorted(imported & NOT_IN_PY310)}, which Python 3.10 does not have" + + +@pytest.mark.parametrize("script", _params()) +def test_docs_examples_run_mechanics(script, tmp_path, extras_json): + env = dict(os.environ) + env["NYXUS_DOCS_DATA"] = str(REPO / "tests" / "python" / "data") + env["NYXUS_DOCS_EXTRAS"] = str(extras_json) + done = subprocess.run([sys.executable, str(script)], cwd=tmp_path, env=env, + capture_output=True, text=True, timeout=600) + assert done.returncode == 0, f"{script.name} failed:\n{done.stdout}\n{done.stderr}" + +README_BLOCKS = re.findall(r"```python\n(.*?)```", (REPO / "README.md").read_text(encoding="utf-8"), re.S) + + +def test_readme_has_quick_starts_mechanics(): + assert README_BLOCKS, "README.md shows no ```python block" + + +@pytest.mark.parametrize("code", README_BLOCKS, ids=[f"readme_block_{i}" for i in range(len(README_BLOCKS))]) +def test_readme_quick_starts_run_mechanics(code, tmp_path): + """README.md is also the PyPI page, so its Python quick starts must run as written.""" + script = tmp_path / "quick_start.py" + script.write_text(code, encoding="utf-8") + done = subprocess.run([sys.executable, str(script)], cwd=tmp_path, + capture_output=True, text=True, timeout=600) + assert done.returncode == 0, f"README quick start failed:\n{code}\n{done.stdout}\n{done.stderr}" diff --git a/tests/python/test_docs_feature_reference_mechanics.py b/tests/python/test_docs_feature_reference_mechanics.py new file mode 100644 index 000000000..4f6af677d --- /dev/null +++ b/tests/python/test_docs_feature_reference_mechanics.py @@ -0,0 +1,51 @@ +"""The feature reference generator reports an empty description and an unclaimed feature class. + +docs/tools/gen_feature_reference.py --check must fail with a message when a feature code has an +empty description in docs/feature_descriptions.toml, and when a feature class belongs to no family +in FAMILIES; the second case used to end in a KeyError traceback instead of the message. +""" +import importlib.util +import pathlib +import sys + +import pytest + +pytest.importorskip("tomllib") # the generator reads TOML with the standard library (Python 3.11+) + +REPO = pathlib.Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(REPO / "docs" / "tools")) +_spec = importlib.util.spec_from_file_location("gen_feature_reference", REPO / "docs" / "tools" / "gen_feature_reference.py") +gen = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(gen) + + +def _descriptions(): + import tomllib + with open(gen.DESCRIPTIONS, "rb") as f: + data = tomllib.load(f) + data.pop("family_notes", None) + return data + + +def test_docs_feature_reference_descriptions_complete_mechanics(): + features, _ = gen.reg.load() + assert gen.check_descriptions(features, _descriptions()) == [] + + +def test_docs_feature_reference_empty_description_is_caught_mechanics(): + features, _ = gen.reg.load() + desc = _descriptions() + fam = next(iter(desc)) + code = next(iter(desc[fam])) + desc[fam][code] = " " + problems = gen.check_descriptions(features, desc) + assert any(p.startswith(f"{code}: no entry") for p in problems), problems + + +def test_docs_feature_reference_unclaimed_class_is_reported_mechanics(monkeypatch, capsys): + real = gen.family_of + features, _ = gen.reg.load() + victim = features[0].feature_class + monkeypatch.setattr(gen, "family_of", lambda f: None if f.feature_class == victim else real(f)) + assert gen.main(["--check"]) == 1 + assert "no family in FAMILIES claims class" in capsys.readouterr().err diff --git a/tests/python/test_docstring_examples_mechanics.py b/tests/python/test_docstring_examples_mechanics.py new file mode 100644 index 000000000..d57fa6363 --- /dev/null +++ b/tests/python/test_docstring_examples_mechanics.py @@ -0,0 +1,68 @@ +"""The ``>>>`` examples in the docstrings of the public nyxus objects run as written. + +Every docstring of the nyxus.nyxus and nyxus.functions modules that holds examples is one test +case. Its examples run with doctest, under NORMALIZE_WHITESPACE and ELLIPSIS, in a fresh working +directory that holds copies of tests/python/data/int and tests/python/data/seg - the "int" and +"seg" directories the examples name. A docstring that writes Arrow IPC or Parquet output is +skipped when the build has no Arrow support. Examples that touch the GPU guard the call with +gpu_is_available(), so they run on a CPU-only build too. + +A new docstring with examples is picked up automatically. + +Kind: *mechanics* per tests/vetting/SPEC.md 2 -- these assert that the documented calls run and +print what the docstrings show, and claim nothing about feature values. +""" +import doctest +import pathlib +import re +import shutil + +import pytest + +nyxus = pytest.importorskip("nyxus") +import nyxus.functions # noqa: E402 +import nyxus.nyxus # noqa: E402 + +DATA = pathlib.Path(__file__).resolve().parent / "data" +FLAGS = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS +ARROW_OUTPUT = re.compile(r"""output_type\s*=\s*["'](arrowipc|parquet)["']""") +ARROW_ENABLED = nyxus.Nyxus(["MEAN"]).arrow_is_enabled() + + +def _docstrings(): + finder = doctest.DocTestFinder() + found = [] + for mod in (nyxus.nyxus, nyxus.functions): + found += [t for t in finder.find(mod, mod.__name__, globs={}) if t.examples] + return sorted(found, key=lambda t: t.name) + + +def _params(): + params = [] + for t in _docstrings(): + marks = [] + if any(ARROW_OUTPUT.search(ex.source) for ex in t.examples): + marks = [pytest.mark.arrow, + pytest.mark.skipif(not ARROW_ENABLED, reason="this build has no Arrow support")] + params.append(pytest.param(t, id=t.name, marks=marks)) + return params + + +DOCSTRINGS = _params() + + +def test_docstring_examples_present_mechanics(): + assert DOCSTRINGS, "no docstring of nyxus.nyxus or nyxus.functions holds a >>> example" + + +@pytest.mark.parametrize("test", DOCSTRINGS) +def test_docstring_examples_run_mechanics(test, tmp_path, monkeypatch): + for d in ("int", "seg"): + shutil.copytree(DATA / d, tmp_path / d) + monkeypatch.chdir(tmp_path) + test.globs = {} + out = [] + runner = doctest.DocTestRunner(optionflags=FLAGS) + result = runner.run(test, out=out.append, clear_globs=True) + assert result.failed == 0, \ + f"{test.name}: {result.failed} of {result.attempted} examples failed\n" + "".join(out) diff --git a/tests/test_2d_morphology_regression.h b/tests/test_2d_morphology_regression.h index 807583368..f032b2351 100644 --- a/tests/test_2d_morphology_regression.h +++ b/tests/test_2d_morphology_regression.h @@ -70,6 +70,9 @@ void test_2d_morphology_caliper_shape2d_regression() } static const ref_vals_map morphology_2d_regression_caliper_chords_ref_vals{ + // A Nyxus snapshot. scikit-image's square-footprint erosion count also gives 1 on this shape, but + // with another structuring element and counting convention, so the agreement vets neither. + {"EROSIONS_2_VANISH", 1.0}, {"EROSIONS_2_VANISH_COMPLEMENT", 0.0}, {"MIN_FERET_ANGLE", 40.0}, // The Feret angles are a Nyxus-frame convention with no comparable imea output, so they are @@ -328,7 +331,7 @@ void test_2d_morphology_fractal_circle_regression() // GEODETIC_LENGTH + THICKNESS are now vetted against imea's geodeticlength_and_thickness (the same // DIN ISO 9276-6 rectangle model) in test_2d_morphology_imea.h, after the geo_len_thickness.cpp -// perimeter-truncation fix. EROSIONS_2_VANISH is vetted vs scikit-image in test_2d_morphology_skimage.h. +// perimeter-truncation fix. // --------------------------------------------------------------------------------------------------- // Migrated from test_2d_remaining_features.h (Wave 6): erosion-complement, caliper (feret/martin/ @@ -337,11 +340,12 @@ void test_2d_morphology_fractal_circle_regression() // test_2d_morphology_common.h. // --------------------------------------------------------------------------------------------------- -void test_2d_morphology_erosion_complement_regression() +void test_2d_morphology_erosions_regression() { std::vector> fvals; calculate_shape2d_feature_values(fvals); + assert_morphology_caliper_chords_regression(fvals, Nyxus::Feature2D::EROSIONS_2_VANISH, "EROSIONS_2_VANISH"); assert_morphology_caliper_chords_regression(fvals, Nyxus::Feature2D::EROSIONS_2_VANISH_COMPLEMENT, "EROSIONS_2_VANISH_COMPLEMENT"); } diff --git a/tests/test_2d_morphology_skimage.h b/tests/test_2d_morphology_skimage.h index 592b09534..7f5ef9bfa 100644 --- a/tests/test_2d_morphology_skimage.h +++ b/tests/test_2d_morphology_skimage.h @@ -22,7 +22,6 @@ static const ref_vals_map morphology_2d_skimage_shape2d_ref_vals{ // loose band. {"CONVEX_HULL_AREA", 27.0}, {"SOLIDITY", 0.9629629629629629}, - {"EROSIONS_2_VANISH", 1.0}, // regionprops.orientation is the major-axis angle from the ROW axis, CCW; Nyxus measures the // same ellipse from the X axis, so NYXUS == 90 - degrees(skimage.orientation). The angle is // invariant to the +1/12 pixel finite-size second-moment correction (it shifts mu20 and mu02 @@ -108,18 +107,15 @@ void test_2d_morphology_convex_hull_skimage() assert_morphology_shape2d_skimage(fvals, Nyxus::Feature2D::SOLIDITY, "SOLIDITY", 100.0); } -// ORIENTATION and EROSIONS_2_VANISH vetted vs scikit-image (tests/vetting/oracles/gen_morphology_skimage.py). -// EROSIONS_2_VANISH: Nyxus' 3x3 (8-connected) structuring element == skimage square(3); the count (1) -// matches, and disk(1)/4-connected gives 2, so the test also pins the connectivity convention. -// Both read the table above rather than repeating the number inline -- a golden pinned in two places -// drifts in one of them, and only the copy an assertion reads is under test. -void test_2d_morphology_orientation_and_erosions_skimage() +// ORIENTATION vetted vs scikit-image (tests/vetting/oracles/gen_morphology_skimage.py). It reads the +// table above rather than repeating the number inline -- a golden pinned in two places drifts in one +// of them, and only the copy an assertion reads is under test. +void test_2d_morphology_orientation_skimage() { std::vector> fvals; calculate_shape2d_feature_values(fvals); assert_morphology_shape2d_skimage(fvals, Nyxus::Feature2D::ORIENTATION, "ORIENTATION"); - assert_morphology_shape2d_skimage(fvals, Nyxus::Feature2D::EROSIONS_2_VANISH, "EROSIONS_2_VANISH"); } // DIAMETER_EQUAL_AREA = sqrt(4*Area/pi), skimage's regionprops.equivalent_diameter_area. Same closed diff --git a/tests/test_all.cc b/tests/test_all.cc index 693b03614..c5f11b7b6 100644 --- a/tests/test_all.cc +++ b/tests/test_all.cc @@ -2010,9 +2010,9 @@ TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_CONVEX_HULL_SKIMAGE) ASSERT_NO_THROW(test_2d_morphology_convex_hull_skimage()); } -TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_ORIENTATION_AND_EROSIONS_SKIMAGE) +TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_ORIENTATION_SKIMAGE) { - ASSERT_NO_THROW(test_2d_morphology_orientation_and_erosions_skimage()); + ASSERT_NO_THROW(test_2d_morphology_orientation_skimage()); } TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_ROI_RADIUS_DISK_CLOSED_FORM_ANALYTIC) @@ -2145,9 +2145,9 @@ TEST(TEST_NYXUS, TEST_2D_MOMENTS_INTENSITY_REGRESSION) ASSERT_NO_THROW(test_2d_moments_intensity_regression()); } -TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_EROSION_COMPLEMENT_REGRESSION) +TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_EROSIONS_REGRESSION) { - ASSERT_NO_THROW(test_2d_morphology_erosion_complement_regression()); + ASSERT_NO_THROW(test_2d_morphology_erosions_regression()); } TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_CALIPER_SPREAD_IMEA) diff --git a/tests/vetting/MIGRATION.md b/tests/vetting/MIGRATION.md index d7e6b5139..897846380 100644 --- a/tests/vetting/MIGRATION.md +++ b/tests/vetting/MIGRATION.md @@ -1,14 +1,17 @@ # Test-Suite Reorganization — Migration Map & Gaps Register -**Status:** Planning document (Phase 1 of 3). **No test code is changed by this document.** -It maps the *current* test suite onto the taxonomy in [`SPEC.md`](SPEC.md) and flags every place -we are missing information or a decision. Companion machine-readable file: -[`oracle_coverage.csv`](oracle_coverage.csv) (one row per feature, 758 rows). - -**Three-phase plan (agreed):** -1. **This doc + `oracle_coverage.csv`** — the "what → what" map + gaps register. *(you are here)* -2. **Oracle-choice discussion** — resolve the flagged gaps (pick mainstream oracles; triage suspected bugs). Decisions recorded back into this doc and the CSV. -3. **Code reorg** — moves/renames/splits + scaffold, delivered in phased waves (scaffold → GLCM template → family-by-family), each keeping the gtest + pytest suites green. +**Status:** Historical record; all three phases are complete. The rollout finished with Wave 27 +(§5.36), and Waves 28-29 (§5.37-§5.38) followed it. Sections 1-5.7 are the map and gaps register +as first drawn, when [`oracle_coverage.csv`](oracle_coverage.csv) held one row per feature (758 +rows); the registry is now one row per assertion (SPEC §3), and its current figures are in +[`coverage_report.md`](coverage_report.md) and [`report_output.md`](report_output.md). File names +throughout are the names of their time: this document is a wave-by-wave narrative and is not +rewritten when a later wave renames a file (§5.37). + +**Three-phase plan (agreed, and executed):** +1. **This doc + `oracle_coverage.csv`** — the "what → what" map + gaps register. +2. **Oracle-choice discussion** — resolve the flagged gaps (pick mainstream oracles; triage suspected bugs). Decisions recorded back into this doc and the CSV (§5.8, §6). +3. **Code reorg** — moves/renames/splits + scaffold, delivered in phased waves (scaffold → GLCM template → family-by-family), each keeping the gtest + pytest suites green (§5.9-§5.39). --- @@ -1068,10 +1071,22 @@ byte-identically. --- -## 7. Next steps +## 7. What remains open + +Both phases this section once planned are done: §6 is settled, and the scaffold +(`config_recipes.md`, `check_coverage.py`, `matrix/`, `oracles/gen_*`), the GLCM template and the +family-by-family waves are §5.9-§5.39. What the reorganization deliberately left open: + +- `test_nyxus.py` keeps its name until its API assertions are split by family into `_mechanics` + files; it is the one entry in `check_test_names.py`'s `GRANDFATHERED` set (§5.35). +- Tests the registry does not reference, or that never run, are tracked in + [`not_covered.md`](not_covered.md). + +The three items §5.38 left "deliberately still open" are closed in the tree: +`test_3d_gldm_lgle_regression()` asserts `GLDM_LGLE` / `"3GLDM_LGLE"`, the 2D first-order +PyRadiomics `ROBUST_MEAN_ABSOLUTE_DEVIATION` assertion reads its golden and tolerance from the +file's tables, and `assert_morphology_feature_cellprofiler()` compares against +`morphology_2d_cellprofiler_ref_vals` rather than a regression snapshot. -- **Phase 2 (discussion):** walk §5 family-by-family, pick oracles for `research-*`, triage the 20 - `suspected-bug` rows, and settle §6. Record decisions in `oracle_coverage.csv` (`oracle`, `status`, - `notes`) and here. -- **Phase 3 (code):** scaffold (`config_recipes.md`, `check_coverage.py`, `matrix/`, `oracles/gen_*`), - then GLCM as the worked template, then family-by-family per §3, each wave green. +New vetting work is recorded in `oracle_coverage.csv` and in the family's vetting report under +`audit/`, not appended here. diff --git a/tests/vetting/README.md b/tests/vetting/README.md index cf1c0f7bf..86aed67f4 100644 --- a/tests/vetting/README.md +++ b/tests/vetting/README.md @@ -1,31 +1,40 @@ # `tests/vetting/` — Nyxus oracle-vetting framework The goal: every number Nyxus produces is verified against an independent tool (or a closed-form -value) to close — ideally exact — agreement. This directory holds the **framework** for that; -the actual test migration/rollout happens after the spec is approved and merged. +value) to close — ideally exact — agreement. This directory holds the framework that governs the +test tree: the spec, the coverage registry, the config recipes and matrices, the oracle golden +generators, and the checkers CI runs to keep the tree and the registry in agreement. ## Contents | Path | What | |------|------| -| [`SPEC.md`](SPEC.md) | The framework spec — assertion-based vetting model, the four test kinds (oracle / regression / invariant / mechanics), the per-assertion coverage registry with the "vetted by ≥1 oracle" metric, config-matching recipes, the config-matrix → valid/invalid triage, naming conventions, tolerance policy, authoring checklist. | -| [`TOOLS.md`](TOOLS.md) | How to stand up each oracle tool locally (Docker or Python venv; MATLAB excluded) — per-tool setup, feasibility, coverage-by-family, rollout order. From one research pass per tool. | -| [`check_test_names.py`](check_test_names.py) | Enforces the SPEC §6.1 file-name and §6.2 function-name conventions over the whole test tree, including gtest case names. `--check` fails in CI. | +| [`SPEC.md`](SPEC.md) | The framework spec — assertion-based vetting model, the four test kinds (oracle / regression / invariant / mechanics), the per-assertion coverage registry with the "vetted by ≥1 oracle" metric, config-matching recipes, the config-matrix → valid/invalid triage, naming conventions, tolerance policy, authoring checklist, and what CI enforces. Any new or changed test follows it. | +| [`oracle_coverage.csv`](oracle_coverage.csv) | The registry, the single source of truth: one row per assertion (`dim × feature × oracle × config_recipe`) with its `status` (`vetted` / `regression` / `invariant` / `untested`), oracle, tolerance, backing test and benchmark. Column meanings are in SPEC §3. | +| [`coverage_report.md`](coverage_report.md) | The headline "features vetted by ≥1 oracle", overall and per family. Generated from the registry by `check_coverage.py --write`; never edit by hand. | +| [`check_coverage.py`](check_coverage.py) | Validates the registry (columns, `status`/`oracle`/`source` values, and that every `benchmark`, `config_recipe` and `test_name` resolves) and regenerates `coverage_report.md`. `--check` also fails when the committed report is stale or missing. Stdlib only. | +| [`check_test_names.py`](check_test_names.py) | Enforces the SPEC §6.1 file-name, §6.2 function-name and gtest case-name conventions, and the §6.3.1 rules for golden reference tables and family-header includes, over the whole test tree. `--check` fails in CI. | +| [`config_recipes.md`](config_recipes.md) | The config recipes (SPEC §5): the exact Nyxus and reference-tool settings that make a feature directly comparable, one `## ` section each, referenced by id from the registry's `config_recipe` column. | +| [`matrix/`](matrix/) | Per-family config matrices (SPEC §5.1): the settings a family actually reads, the config points they produce, and each point's measured verdict. | | [`benchmarks.md`](benchmarks.md) | The fixtures assertions run on (SPEC §6.3): what each one is, why it exists, and which recipes and tests use it. A `benchmark` id in the registry must be defined here, which `check_coverage.py` enforces — as it does for `test_name`, which must resolve to a gtest case in `test_all.cc`. | -| [`matrix/`](matrix/) | Per-family config matrices (SPEC §5.1): the settings a family actually reads, the config points they produce, and each point's verdict. | -| [`report_features.py`](report_features.py) | Generates [`report_output.csv`](report_output.csv) and [`report_output.md`](report_output.md) — the whole report, one row per (dim, feature, oracle, config_recipe), joining what the registry CLAIMS to what the tree ASSERTS. Its `verdict` column is the join and `verdict_scope` says how far that join reached — `row+config` compared the row's own assertion at its own config, `row` its own assertion, `feature` only the feature, which is all a row with no `test_name` can be asked. `report_output.csv` also carries each feature's scanner note (`scan_notes`). `--check` fails in CI on any row that disagrees without an allowlisted reason. **This is the artifact to read.** | +| [`oracles/`](oracles/) | The offline golden generators, `gen__.*` (Python, or `.m` for `matlab`), one per family and reference tool, plus the IBSI digital phantom (`ibsi_phantom.py`) and the `fraclac/` box-count macro. Reference tools are never CI dependencies: the generators are run by hand and their output is pinned in the tests. | +| [`TOOLS.md`](TOOLS.md) | How to stand up each oracle tool locally (Docker, conda or a Python venv; licensed MATLAB or GNU Octave for `matlab`) — per-tool setup, feasibility, coverage by family, and notable findings. | +| [`report_features.py`](report_features.py) | Generates [`report_output.csv`](report_output.csv) and [`report_output.md`](report_output.md) — the whole report, one row per (dim, feature, oracle, config_recipe), joining what the registry CLAIMS to what the tree ASSERTS. Its `verdict` column is the join and `verdict_scope` says how far that join reached — `row+config` compared the row's own assertion at its own config, `row` its own assertion, `feature` only the feature, which is all a row with no `test_name` can be asked. `report_output.csv` also carries each feature's scanner note (`scan_notes`). `--check` fails in CI when either artifact is stale, on any row that disagrees without an allowlisted reason, on an unscanned family not recorded in `KNOWN_UNSCANNED`, and on two rows that share a key. **This is the artifact to read.** | | [`report_output.md`](report_output.md) | The generated report: coverage, verdicts, the families no scanner covers, the family × oracle matrix, the dimensionality split. Never edit by hand. | -| [`audit/`](audit/) | Baseline coverage audit of the current test tree (see below), and the per-family coverage scanners that keep it current. | +| [`not_covered.md`](not_covered.md) | Tests the registry does not reference, and tests that never run (not included, not registered, or excluded by a build flag). | +| [`MIGRATION.md`](MIGRATION.md) | The record of how the test tree was reorganized onto the SPEC taxonomy: the original migration map and gaps register, then each rename and placement wave. | +| [`audit/`](audit/) | Baseline coverage audit of the test tree (see below), and the per-family coverage scanners that keep it current. | | [`audit/scanlib.py`](audit/scanlib.py) | The scanners' shared machinery: the coverage rule and the acceptance checks, in one place. Each `audit/scan_*_coverage.py` is the family's declaration on top of it — which files to read, how the family spells its feature names in C++, which checks apply. | +| [`audit/run_scanners.py`](audit/run_scanners.py) | Runs every family scanner's `--check`, reports all of them, then fails once. Runs in CI. | | [`audit/scanlib_selftest.py`](audit/scanlib_selftest.py) | Negative controls for the coverage rule itself, on fixtures rather than on the tree: a literal the case never loops, a returned value the caller discards, a gtest case registered on one line, and a registry row whose `config_recipe` has been swapped for another of its family's. The per-family `--check` cannot catch these — it reads the tree through the very rule being tested. Runs in CI. | -| [`oracles/`](oracles/) | Oracle golden-generators. Currently `fraclac/` (headless shifting-grid box-count macro). Grows per tool during rollout. | ## `audit/` — baseline snapshot -A point-in-time audit of what the test tree vets today, produced by four parallel review agents -plus an authoritative token scan. **Baseline caveat:** it reflects the *merged* tree, so it predates -the GLCM entropy/hom2 normalization fix and the histogram/GLCM oracle tests that are still on their -own branches (PRs). The registry is maintained forward from this baseline. +A point-in-time audit of what the test tree vetted when the registry was seeded, produced by four +parallel review passes plus an authoritative token scan. **Baseline caveat:** it reflects the tree +as merged at that time, so it predates the GLCM entropy/hom2 normalization fix and the histogram +and GLCM oracle tests that landed afterwards. The registry has been maintained forward from this +baseline; the audit is kept as it was. Tracked artifacts (the rest are regenerable — see below): @@ -35,7 +44,10 @@ Tracked artifacts (the rest are regenerable — see below): not-tested; the `#if 0` "planned for a future PR" 3D-feature correction; includes the group×status pivot as a table) - `extract_features.py` → `scan_tests.py` → `merge.py` → `report.py` (+ `candidate_oracles.py`) — - the pipeline (repo-relative paths); `audit_1..4.txt` are the raw per-feature agent-evidence inputs. + the pipeline (repo-relative paths); `audit_1..4.txt` are the raw per-feature review inputs. +- `*_vetting_report.md` (e.g. `glcm_2d_mirp_vetting_report.md`) and `*_golden_regen.md` (e.g. + `glcm_2d_golden_regen.md`) — the record of each family's vetting runs: what the oracle was run + on, what it measured, and how the pinned goldens were regenerated. ### The per-family scanners @@ -61,12 +73,18 @@ Regenerable byproducts of the retired pipeline, all git-ignored and all under `a ## `oracles/` — golden generators +- `gen__.py` / `.m` — one generator per family and reference tool (for example + `gen_glcm_mirp.py`, `gen_firstorder2d_matlab.m`, `gen_imq_opencv.py`). Each one produces the + goldens a `test___` file pins, and states the tool version and settings + the test's provenance header records (SPEC §6.4). +- `ibsi_phantom.py` — the IBSI digital phantom, shared by the generators that run on it. - `fraclac/shiftgrid_boxcount.ijm` — headless ImageJ macro reproducing FracLac's shifting-grid box-counting (the FracLac plugin itself is GUI-only). `ref_boxcount.py` is a numpy cross-check. See TOOLS.md → `fraclac` for the reconciliation (from-method reimplementation vs. true-tool run). ## Status -Documentation + data only — nothing here changes the test tree. Rollout (registry CSV, config -recipes, per-family config matrices, per-tool generators, and the test renames in SPEC.md §6) begins -after the spec is merged. +In force. The test tree follows SPEC.md, and CI (`.github/workflows/build_and_test_ubuntu.yml`) +runs `check_coverage.py --check`, `check_test_names.py --check`, `audit/scanlib_selftest.py`, +`audit/run_scanners.py` and `report_features.py --check` on every change. The current coverage +figure is the headline of [`coverage_report.md`](coverage_report.md). diff --git a/tests/vetting/SPEC.md b/tests/vetting/SPEC.md index 27587dee2..15171d600 100644 --- a/tests/vetting/SPEC.md +++ b/tests/vetting/SPEC.md @@ -1,8 +1,10 @@ # Nyxus Oracle-Vetting Test Framework — SPEC -**Status.** Specification only. This document defines the framework; it changes nothing in the test -tree. Registry seeding, config recipes, config matrices, oracle generators, and any test -moves/renames are follow-up work that begins **only after this spec is approved and merged** (§9). +**Status.** In force. The test tree follows this spec, and CI enforces it: `check_coverage.py`, +`check_test_names.py`, the per-family scanners under `audit/` and `report_features.py` all run +with `--check` (§9). The registry, config recipes, config matrices and oracle generators it +describes are in this directory; [`MIGRATION.md`](MIGRATION.md) records how the tree was brought +into line with it. **Goal.** Every number Nyxus produces should be *verified against an independent tool* (or a closed-form value) to close — ideally exact — agreement. Where a Nyxus feature depends on @@ -57,36 +59,44 @@ snapshot. The registry is **one row per assertion** — a `(feature × config recipe × oracle)` triple — in `tests/vetting/oracle_coverage.csv`. A feature can (and for confidence should) have several rows: the -same feature vetted by `pyradiomics`, `radiomicsj`, and `feature2djava` is three rows. It will be -seeded from the audit already produced (`.planning/test-vetting-audit/`) and kept under version control. +same feature vetted by `pyradiomics`, `radiomicsj`, and `feature2djava` is three rows. It was +seeded from the audit in [`audit/`](audit/) and is kept under version control. -Columns: +Columns, in file order (`check_coverage.py` rejects a registry that lacks any of them): | column | meaning | |--------|---------| -| `dim` | 2D / 3D / IMQ | +| `dim` | `2D` / `3D` / `IMQ` | | `feature` | enum name (e.g. `GLCM_CONTRAST_AVE`, `3GLCM_CONTRAST`) | -| `family` | GLCM / GLRLM / IntensityHistogram / FirstOrder / Morphology / Moments / … | -| `oracle` | one of the §4 tokens, or `—` for regression/invariant rows | -| `config_recipe` | id of the config recipe (see §5), e.g. `glcm.ibsi_identity` | -| `outcome` | `vetted` (matches oracle) / `regression` (snapshot only) / `invariant` / `not_tested` / `not_implemented` / `dropped_invalid` | -| `tolerance` | `exact` / `rel=1e-3` / `rel=1e-2` / … | -| `test_file`, `test_name` | where the assertion lives (empty if not yet written) | -| `benchmark` | benchmark-data id (see §6) | +| `family` | lower-case family id, e.g. `glcm` / `glrlm` / `intensity_histogram` / `firstorder` / `morphology` / `moments` / … | +| `status` | `vetted` (an oracle agrees) / `regression` (snapshot only) / `invariant` (a required relation between two Nyxus code paths; no external tool) / `untested` (no assertion) | +| `oracle` | one of the §4 tokens on a `vetted` row; empty on every other row | +| `agreement` | how the oracle comparison came out, e.g. `agreed` / `disagrees` / `benign` / `na`; informative, not validated | +| `config_recipe` | id of the config recipe (see §5), e.g. `glcm.ibsi_identity`; when set it must be a `## ` heading in `config_recipes.md` | +| `tolerance` | `exact` / `abs=1e-9` / `rel=1e-9` / `rel=1e-3` / `rel=1e-2` / … | +| `current_test` | the file(s) that assert the row today, `;`-separated | +| `target_test` | the file the assertion belongs in (§6.2.1) | +| `candidate_oracle` | a tool that could vet the row, where none does yet | +| `flag` | open-gap markers, `;`-separated (e.g. `convention-mismatch`, `scoped-oracle`, `impl-defect`) | | `source` | where the verdict comes from: `in-tree` (a test in this repo makes the assertion) / `tracker` (an offline harness run not migrated in — `target_test` names where it belongs) / `audit` | | `notes` | provenance, caveats | +| `test_name` | the gtest case(s) that make the assertion, as `SUITE.CASE` from `test_all.cc`, `;`-separated | +| `benchmark` | benchmark-data id (see §6); when set it must be a `` ## `id` `` heading in `benchmarks.md` | ### 3.1 The headline metric — "vetted by ≥1 oracle" -The primary rollup, per feature: **is there at least one row with `outcome=vetted`?** A generated -report (`tests/vetting/coverage_report.md`) shows, per family and overall: +The primary rollup, per feature: **is there at least one row with `status=vetted`?** A generated +report (`tests/vetting/coverage_report.md`) shows, per family and overall, the features vetted by +≥1 oracle against the real features (the number we drive to 100%), and how many of the rest are +covered only by `regression`, only by `invariant`, or are `untested`. A feature takes the +strongest status among its rows; `regression` and `invariant` rank equal, below `vetted`. -- `# features vetted by ≥1 oracle` / `# real features` ← the number we drive to 100% -- redundancy: `# features vetted by ≥2 oracles` (higher confidence) -- gaps: features with only `regression` / `not_tested` rows, and which oracle *could* cover them - -`tests/vetting/check_coverage.py` (later) both generates this report and flags drift between the -registry and the test tree (e.g. an `outcome=vetted, oracle=pyradiomics` row with no matching test). +`tests/vetting/check_coverage.py` generates this report and validates the registry: the columns, +the `status`, `oracle` and `source` values, `oracle` set exactly on `vetted` rows, and that every +`benchmark`, `config_recipe` and `test_name` resolves. Whether the test tree actually asserts what +a row claims is checked by the per-family scanners (`audit/scan__coverage.py`) and +reported per row by `report_features.py` in `report_output.md`, which also lists every oracle a +feature was matched against. The report is **generated, never hand-edited**, and `--check` enforces that: it re-renders the registry and fails when the committed text differs, or when it is not there at all. A stale report @@ -133,8 +143,8 @@ Notes: - **`radiomicsj`, `mitk`, and `mirp`** give second/third/fourth IBSI-compliant opinions alongside `pyradiomics` — useful for the "≥1 oracle" goal and for cross-checking pyradiomics itself. - **Fractal dimension** (`FRACT_DIM_BOXCOUNT`) → `fraclac` (the ImageJ box-count plugin) is the - reference; already used on the fractal branch. `FRACT_DIM_PERIMETER` (divider/Richardson) has no - tool → `analytic` + cross-method convergence. + reference, used by `test_2d_morphology_fraclac.h`. `FRACT_DIM_PERIMETER` (divider/Richardson) has + no tool → `analytic` + cross-method convergence. - scikit-image (`skimage`) is an accepted mainstream oracle (added 2026-07; covers image moments, shape descriptors). `mahotas`, `DIPlib`, and `Centrosome` are NOT accepted — features only they cover remain regression/analytic. @@ -183,7 +193,8 @@ Because some features are config-sensitive, each oracle assertion names a **conf exact Nyxus settings that make the feature directly comparable to the tool, paired with the tool's own settings. Recipes are defined once in `tests/vetting/config_recipes.md` and referenced by id. -Example recipe `glcm.pyradiomics.ibsi_identity`: +Example recipe (an illustration of the format; the recipes in force are the `## ` sections of +`config_recipes.md`): ``` Nyxus: ibsi=True, coarse_gray_depth=, GLCM_OFFSET=1, angles={0,45,90,135} @@ -214,16 +225,17 @@ For each config point we classify it: - **VALID-BUT-PRODUCTION-ONLY (→ regression test):** a real default config that no external tool reproduces (a Nyxus convention) → keep as a `_regression` snapshot (drift guard), `regression` row. - **INVALID (→ drop):** degenerate or nonsensical combination, or one no one runs → dropped, with a - one-line reason and a `dropped_invalid` row so the decision is recorded, not silently omitted. + one-line reason in the family's matrix file so the decision is recorded, not silently omitted. + The registry has no row for it: `status` holds only `vetted` / `regression` / `invariant` / + `untested` (§3). Each family gets a `tests/vetting/matrix/.md` enumerating the knobs, the points, and each -point's verdict + oracle. Worked example (GLCM): +point's verdict + oracle. Worked example (GLCM, as in `matrix/glcm.md`): | ibsi | symmetric | binning | verdict | oracle / reason | |------|-----------|---------|---------|-----------------| -| True | True | identity (Ng=distinct levels) | VALID | pyradiomics, radiomicsj, matlab, ibsi — recipe `glcm.ibsi_identity` | -| False | True | binWidth-style (radiomics compat) | VALID | pyradiomics (compat path) — recipe `glcm.radiomics_compat` | -| False | False | matlab, Ng=100 | VALID-prod-only | difference-based subset coincides; rest = Nyxus convention → `test_glcm_regression` | +| True | True | identity (Ng=distinct levels) | VALID | ibsi, mirp — recipe `glcm.ibsi_identity` | +| False | True | fixed bin count | VALID | pyradiomics — recipe `glcm.pyradiomics_symmetric` | | any | any | offset/distance = 0 | INVALID | degenerate (dx=dy=0 self-cooc); covered by a `_mechanics` guard, not an oracle | The point: the number of tests is *derived* from the matrix, and every cell has an explicit @@ -249,15 +261,16 @@ axes + values + oracle mapping (human judgment), verdicts measured empirically ( auto-derived; it is authored once per (family, tool). A cell may map to several oracles, or none (a Nyxus-only convention). -4. **Verdicts are measured, not asserted.** A generator `tests/vetting/oracles/gen_matrix_.*` - does, per cell: run Nyxus at the cell's config + each mapped oracle at its config on a benchmark, - record per-feature relative error, then classify each `(cell, feature)`: +4. **Verdicts are measured, not asserted.** Per cell, the family's oracle generator + (`tests/vetting/oracles/gen__.*`) runs the oracle at its config on a benchmark, + Nyxus is run at the cell's config, and the per-feature relative error is recorded; each + `(cell, feature)` is then classified: - **VALID(oracle)** — a mapped oracle agrees within tolerance → oracle test + `vetted` row - **VALID-prod-only** — a real default no tool reproduces → `regression` row - - **INVALID** — degenerate (e.g. `offset=0`) or not a real config → `dropped_invalid` row + reason + - **INVALID** — degenerate (e.g. `offset=0`) or not a real config → reason in the matrix, no row - It emits `matrix/.md` (the table *with the measured errors*) and candidate registry rows - for human review. + `matrix/.md` records the table *with the measured errors*, and the registry rows follow + from it after review. **Why measured, not hand-labeled.** Hand-labeling is exactly what failed before: `ACOR` was hand-marked oracle-vetted on `ibsi=False` yet diverges ~43% there; `ENTROPY` sat as a "convention" @@ -307,11 +320,11 @@ its assertions. `test__[_]_` — the dim token says which implementation, the oracle suffix makes vetting status self-evident: -- `test_2d_glcm_ave_pyradiomics` — 2D GLCM angle-averaged vs PyRadiomics +- `test_2d_glcm_family_pyradiomics` — 2D GLCM vs PyRadiomics - `test_3d_glcm_contrast_pyradiomics` — the 3D implementation of the same family -- `test_2d_intensity_histogram_dispersion_analytic` — IH dispersion vs closed form -- `test_2d_morphology_basic_imea` — area/perimeter/diameters vs imea -- `test_2d_glcm_regression` — snapshot drift guard (no oracle) +- `test_2d_intensity_histogram_dispersion_robust_analytic` — IH dispersion vs closed form +- `test_2d_morphology_caliper_feret_imea` — Feret diameters vs imea +- `test_2d_glcm_acor_regression` — snapshot drift guard (no oracle) A function's dim token must match its file's: a `test_3d_*` function in a `test_2d_*` file is the same category of error as a `_regression` function in an `_ibsi` file. @@ -360,9 +373,9 @@ Two checks before moving anything, both learned the hard way: Names must describe **shape + salient property + provenance**, not opaque indices. `bench__` for inline fixtures: -- `bench_dense8_alllevels` — 8×8, contains every grey level 1..8 (for level-sensitive texture) -- `bench_line5` — 5-pixel 1-D line (tiny hand-computable histogram) -- `bench_ramp9` — 9×9 horizontal intensity ramp (directional contrast) +- `bench_cube2_constant` — a 2×2×2 cube of one intensity (constant-ROI guards) +- `bench_cube3_gapped_levels` — a 3×3×3 cube with grey levels 1, 3 and 5 (gaps between levels) +- `bench_shape8_concave_holed` — one concave 26-pixel ROI with an interior hole Standard reference phantoms keep their canonical names (`ibsi_digital_phantom`). @@ -458,8 +471,8 @@ piece of tracked work, not a permanent waiver. ### 6.4 Golden provenance (mandatory) Every pinned oracle golden must record, at its definition site: **tool + version + exact config + -generator script path**. Generators live in `tests/vetting/oracles/gen__.py` and are -runnable to regenerate the numbers. +generator script path**. Generators live in `tests/vetting/oracles/gen__.*` (Python, +or `.m` for the `matlab` token) and are runnable to regenerate the numbers. --- @@ -489,24 +502,33 @@ value is a bug in the test. --- -## 9. Decisions & rollout +## 9. Decisions, rollout and enforcement **Settled:** - Home directory: `tests/vetting/` (spec + registry + recipes + matrices + generators). - Registry format: **CSV** (diff-friendly). - Naming scheme (§6) and renaming of existing tests (`test_ibsi_*`, `test_compat_3d_*`, - `test_glcm.h`, …): **approved**. - -**Deferred:** -- *How* each tool is run to generate goldens (Docker / venv / Java / MATLAB / pipeline / macro): - TBD — decided per tool, before its first use. - -**Rollout — begins only after this spec is approved and merged:** -1. Scaffold `tests/vetting/`: seed `oracle_coverage.csv` from the audit (`.planning/test-vetting- - audit/`), add `config_recipes.md`, `matrix/.md`, `oracles/gen_*`, `check_coverage.py`, - and the generated `coverage_report.md`. -2. **GLCM** as the worked reference migration (oracle + regression + mechanics + matrix + registry - rows), applying the naming/renames. -3. Roll the pattern out family by family, driving *features vetted by ≥1 oracle* toward 100%. - -Nothing in this list is done in this PR — it is the plan the merged spec authorizes. + `test_glcm.h`, …): **done**. One file, `test_nyxus.py`, is grandfathered in + `check_test_names.py` until its API assertions are split by family. +- *How* each tool is run to generate goldens (Docker / venv / Java / MATLAB / pipeline / macro) is + decided per tool; [`TOOLS.md`](TOOLS.md) records each one. + +**Rollout — complete.** The scaffold (`oracle_coverage.csv` seeded from the audit, +`config_recipes.md`, `matrix/.md`, `oracles/gen_*`, `check_coverage.py`, the generated +`coverage_report.md`), the GLCM reference migration and the family-by-family waves are recorded +wave by wave in [`MIGRATION.md`](MIGRATION.md) §5.9–§5.39. Driving *features vetted by ≥1 oracle* +toward 100% continues one family at a time; the current figure is the headline of +`coverage_report.md`. + +**Enforcement.** CI (`.github/workflows/build_and_test_ubuntu.yml`) runs, after the pytest suite: + +| step | fails when | +|------|------------| +| `check_coverage.py --check` | the registry breaks §3/§4, or `coverage_report.md` is stale or missing | +| `check_test_names.py --check` | a test file, function, gtest case, `#include` or golden table breaks §6 | +| `audit/scanlib_selftest.py` | the scanners' coverage rule misses one of its planted negative controls | +| `audit/run_scanners.py` | any family's registry rows disagree with what its tests assert | +| `report_features.py --check` | `report_output.csv` / `report_output.md` are stale, a row's verdict is neither `agree` nor `unscanned` and is not allowlisted, an `unscanned` family is not in `KNOWN_UNSCANNED` (empty), or two rows share the key dim, feature, oracle, recipe | + +`tests/python/test_vetting_mechanics.py` runs the first two checkers against planted defects as +part of the pytest suite. diff --git a/tests/vetting/TOOLS.md b/tests/vetting/TOOLS.md index 823cee635..3531c9d8b 100644 --- a/tests/vetting/TOOLS.md +++ b/tests/vetting/TOOLS.md @@ -1,6 +1,6 @@ # Oracle tools — local setup & coverage (research findings) -How to stand up each oracle tool locally (Docker or Python venv; MATLAB excluded — license). One +How to stand up each oracle tool locally (Docker or Python venv; MATLAB needs a licensed install and is never a CI dependency). One research pass per tool; see per-tool detail below and the setup matrix first. ## Setup matrix diff --git a/tests/vetting/check_test_names.py b/tests/vetting/check_test_names.py index bdc794521..9fabae65b 100644 --- a/tests/vetting/check_test_names.py +++ b/tests/vetting/check_test_names.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python3 """Enforce the SPEC.md 6.1/6.2 test-naming conventions over the test tree. Two rules, both mechanical: @@ -91,12 +91,19 @@ "test_vetting_mechanics.py": "self-test of check_coverage.py / check_test_names.py", "test_environment_lifecycle_mechanics.py": "instance -> Environment binding in the bindings; no image dimensionality involved", + "test_api_arguments_mechanics.py": + "argument checks of the Python constructors, for Nyxus, Nyxus3D and ImageQuality at once", # the native-OME readers address one 5D container (X,Y,Z,C,T) and are exercised at every # extent it can take -- 2D planes through 5D stacks -- so a dim token would name only one # of the shapes each file already covers "test_omezarr_mechanics.h": "OME-Zarr reader over 2D..5D extents of one container", "test_ometiff_mechanics.h": "OME-TIFF reader over 2D..5D extents of one container", "test_ome_meta_mechanics.h": "OME metadata parsers and the axes descriptor; no image is read", + "test_docstring_examples_mechanics.py": "the docstring examples of the Python API, which use 2D and 3D calls alike", + "test_docs_examples_mechanics.py": "the documentation's example scripts and README quick starts, 2D, 3D and dimension-free alike", + "test_docs_cli_reference_mechanics.py": "the CLI reference generator's checks; no image is read", + "test_docs_conf_mechanics.py": "the Sphinx configuration's release and title; no image is read", + "test_docs_feature_reference_mechanics.py": "the feature reference generator's checks; no image is read", "test_ooc_mechanics.py": "out-of-core streaming machinery, asserted for both dims at once", "test_physical_calibration_regression.py": "physical voxel spacing read from OME metadata, not from an image of either dimension", # imq carries its own dimension: dim=IMQ in oracle_coverage.csv, not 2D/3D diff --git a/tests/vetting/coverage_report.md b/tests/vetting/coverage_report.md index e2e81a60a..1b58b3a37 100644 --- a/tests/vetting/coverage_report.md +++ b/tests/vetting/coverage_report.md @@ -2,8 +2,8 @@ _Generated by check_coverage.py from oracle_coverage.csv. Do not edit by hand._ -Features vetted by >=1 oracle: 637/758 (84%) -regression: 121 invariant: 0 untested: 0 +Features vetted by >=1 oracle: 636/758 (84%) +regression: 122 invariant: 0 untested: 0 | family | total | vetted | regression | invariant | untested | |---|---|---|---|---|---| @@ -17,7 +17,7 @@ regression: 121 invariant: 0 untested: 0 | imq | 6 | 4 | 2 | 0 | 0 | | intensity_histogram | 47 | 43 | 4 | 0 | 0 | | moments | 180 | 118 | 62 | 0 | 0 | -| morphology | 113 | 78 | 35 | 0 | 0 | +| morphology | 113 | 77 | 36 | 0 | 0 | | neighbor | 9 | 8 | 1 | 0 | 0 | | ngldm | 38 | 33 | 5 | 0 | 0 | | ngtdm | 10 | 10 | 0 | 0 | 0 | diff --git a/tests/vetting/oracle_coverage.csv b/tests/vetting/oracle_coverage.csv index 174c2bffc..9e97f5ec5 100644 --- a/tests/vetting/oracle_coverage.csv +++ b/tests/vetting/oracle_coverage.csv @@ -94,7 +94,7 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,CIRCULARITY,morphology,vetted,analytic,agreed,morphology.shape2d_native,,test_2d_morphology_analytic.h,,,no-external-oracle,audit,"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_2D_MORPHOLOGY_DOCUMENTED_FORMULA_CONFORMANCE_ANALYTIC (test_2d_morphology_analytic.h) 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,morphology.shape2d_native,,test_2d_morphology_skimage.h,,Document Nyxus point-hull convention or revise expected value.,,audit,"vetted vs scikit-image (convex_hull_image offset_coordinates=False, Pick's-theorem convex_area); test_2d_morphology_skimage.h TEST_2D_MORPHOLOGY_CONVEX_HULL_SKIMAGE 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,morphology.shape2d_native,,test_2d_morphology_hull_invariant.py;test_2d_morphology_skimage.h,,Document Nyxus point-hull convention or revise expected value.,,audit,"vetted vs scikit-image (convex_hull_image offset_coordinates=False, Pick's-theorem convex_area); test_2d_morphology_skimage.h TEST_2D_MORPHOLOGY_CONVEX_HULL_SKIMAGE 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,vetted,skimage,agreed,morphology.shape2d_native,,test_2d_morphology_skimage.h;test_2d_ooc_invariant.py,,,,audit,"Vetted vs scikit-image: Nyxus 3x3 (8-connected) erosion count == skimage binary_erosion(square(3)) count = 1; disk(1)/4-connected gives 2, so the test also pins the connectivity convention. gen_morphology_skimage.py",, +2D,EROSIONS_2_VANISH,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_regression.h;test_2d_ooc_invariant.py,,"An implementation of the Nyxus definition (4-connected cross, count minus one) on a fixture where it and scikit-image's square-footprint count differ.",convention-mismatch,audit,"Regression pin. scikit-image's erosion with a 3x3 square, counting every erosion, also gives 1 on this shape, but Nyxus erodes with the 4-connected 3x3 cross (erosion.h) and counts one fewer, so the agreement confirms neither the structuring element nor the counting convention. gen_morphology_skimage.py",, 2D,EROSIONS_2_VANISH_COMPLEMENT,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_regression.h,,Degenerate on the shape2d fixture (golden 0 -- the complement erodes away immediately); needs a fixture with an interior hole to vet the count vs skimage.,convention-mismatch,tracker,,, 2D,FRACT_DIM_BOXCOUNT,morphology,vetted,fraclac,agreed,morphology.fractal_blob512,,test_2d_morphology_fraclac.h;test_2d_morphology_fraclac.py,,,,audit,"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_2d_morphology_fraclac.h). Also test_2d_morphology_fraclac.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,morphology.fractal_blob512,,test_2d_morphology_fraclac.h;test_2d_morphology_fraclac.py,,,,audit,"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_2d_morphology_fraclac.h). Also test_2d_morphology_fraclac.py: Koch snowflake and smooth-disk perimeter dimensions match.",, @@ -273,12 +273,12 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,GLRLM_GLV,glrlm,vetted,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | test_3d_glrlm_regression.h ORPHANED (not #included; never run) - left per decision A",, 2D,GLRLM_RV,glrlm,vetted,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | test_3d_glrlm_regression.h ORPHANED (not #included; never run) - left per decision A",, 2D,GLRLM_RE,glrlm,vetted,pyradiomics,agreed,glrlm.ibsi_ng128,rel=5e-3,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=5e-3; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to 1.1e-3, the fast_log10 + EPSILON accuracy choice this family's only logarithmic sum carries. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | test_3d_glrlm_regression.h ORPHANED (not #included; never run) - left per decision A",, -2D,GLRLM_LGLRE,glrlm,vetted,ibsi,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LGLRE = 0.604) at 1% in test_2d_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,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM HGLRE = 9.82) at 1% in test_2d_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,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRLGLE = 0.294) at 1% in test_2d_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,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRHGLE = 8.57) at 1% in test_2d_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,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRLGLE = 3.14) at 1% in test_2d_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,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRHGLE = 17.4) at 1% in test_2d_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_LGLRE,glrlm,vetted,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LGLRE = 0.604) at 1% in test_2d_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,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM HGLRE = 9.82) at 1% in test_2d_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,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRLGLE = 0.294) at 1% in test_2d_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,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRHGLE = 8.57) at 1% in test_2d_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,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRLGLE = 3.14) at 1% in test_2d_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,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h;test_2d_glrlm_ibsi.h,,,,in-tree,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRHGLE = 17.4) at 1% in test_2d_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,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_SRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md.",, 2D,GLRLM_LRE_AVE,glrlm,vetted,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_LRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md.",, 2D,GLRLM_GLN_AVE,glrlm,vetted,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_GLN, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md.",, @@ -289,12 +289,12 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,GLRLM_GLV_AVE,glrlm,vetted,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_GLV, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md.",, 2D,GLRLM_RV_AVE,glrlm,vetted,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_RV, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md.",, 2D,GLRLM_RE_AVE,glrlm,vetted,pyradiomics,agreed,glrlm.ibsi_ng128,rel=5e-3,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_RE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=5e-3; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to 1.1e-3, the fast_log10 + EPSILON accuracy choice this family's only logarithmic sum carries. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md.",, -2D,GLRLM_LGLRE_AVE,glrlm,vetted,ibsi,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_LGLRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LGLRE = 0.604) at 1% in test_2d_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,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_HGLRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM HGLRE = 9.82) at 1% in test_2d_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,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_SRLGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRLGLE = 0.294) at 1% in test_2d_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,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_SRHGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRHGLE = 8.57) at 1% in test_2d_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,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_LRLGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRLGLE = 3.14) at 1% in test_2d_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,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_LRHGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRHGLE = 17.4) at 1% in test_2d_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,GLRLM_LGLRE_AVE,glrlm,vetted,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_LGLRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LGLRE = 0.604) at 1% in test_2d_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,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_HGLRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM HGLRE = 9.82) at 1% in test_2d_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,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_SRLGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRLGLE = 0.294) at 1% in test_2d_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,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_SRHGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRHGLE = 8.57) at 1% in test_2d_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,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_LRLGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRLGLE = 3.14) at 1% in test_2d_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,pyradiomics,agreed,glrlm.ibsi_ng128,rel=1e-9,test_2d_glrlm_ibsi.h;test_2d_glrlm_mirp.h;test_2d_glrlm_pyradiomics.h;test_2d_glrlm_regression.h,,,,in-tree,"Angle average of GLRLM_LRHGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRHGLE = 17.4) at 1% in test_2d_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,gldzm.ibsi_phantom_2d,abs=1e-9,test_2d_gldzm_ibsi.h;test_2d_gldzm_mirp.h,,,,audit,"vetted vs mirp 2.6.0 on the four IBSI phantom slices (by_slice, no discretisation - the phantom is already discrete 1..6), recipe gldzm.ibsi_phantom_2d. Backed per slice AND by the four-slice mean: mirp computes each slice on its own, so both quantities are pinned in test_2d_gldzm_mirp.h at SPEC 7's exact tier, an absolute 1e-9 band (worst absolute residual 1.3e-15 over the 16 features x 4 slices plus the 16 means; worst relative 7.0e-16). The mean alone is blind to errors in two slices that cancel, which is the shape this family's zone connectivity defect actually took. The same means are pinned against the published IBSI consensus in test_2d_gldzm_ibsi.h at rel=1e-2, the 3-significant-figure precision those values are published to (worst residual there 0.35%). gen_gldzm_mirp.py",TEST_NYXUS.TEST_2D_GLDZM_SDE_IBSI;TEST_NYXUS.TEST_2D_GLDZM_SDE_MIRP,ibsi_digital_phantom 2D,GLDZM_LDE,gldzm,vetted,mirp,agreed,gldzm.ibsi_phantom_2d,abs=1e-9,test_2d_gldzm_ibsi.h;test_2d_gldzm_mirp.h,,,,audit,"vetted vs mirp 2.6.0 on the four IBSI phantom slices (by_slice, no discretisation - the phantom is already discrete 1..6), recipe gldzm.ibsi_phantom_2d. Backed per slice AND by the four-slice mean: mirp computes each slice on its own, so both quantities are pinned in test_2d_gldzm_mirp.h at SPEC 7's exact tier, an absolute 1e-9 band (worst absolute residual 1.3e-15 over the 16 features x 4 slices plus the 16 means; worst relative 7.0e-16). The mean alone is blind to errors in two slices that cancel, which is the shape this family's zone connectivity defect actually took. The same means are pinned against the published IBSI consensus in test_2d_gldzm_ibsi.h at rel=1e-2, the 3-significant-figure precision those values are published to (worst residual there 0.35%). gen_gldzm_mirp.py",TEST_NYXUS.TEST_2D_GLDZM_LDE_IBSI;TEST_NYXUS.TEST_2D_GLDZM_LDE_MIRP,ibsi_digital_phantom 2D,GLDZM_LGLZE,gldzm,vetted,mirp,agreed,gldzm.ibsi_phantom_2d,abs=1e-9,test_2d_gldzm_ibsi.h;test_2d_gldzm_mirp.h,,,,audit,"vetted vs mirp 2.6.0 on the four IBSI phantom slices (by_slice, no discretisation - the phantom is already discrete 1..6), recipe gldzm.ibsi_phantom_2d. Backed per slice AND by the four-slice mean: mirp computes each slice on its own, so both quantities are pinned in test_2d_gldzm_mirp.h at SPEC 7's exact tier, an absolute 1e-9 band (worst absolute residual 1.3e-15 over the 16 features x 4 slices plus the 16 means; worst relative 7.0e-16). The mean alone is blind to errors in two slices that cancel, which is the shape this family's zone connectivity defect actually took. The same means are pinned against the published IBSI consensus in test_2d_gldzm_ibsi.h at rel=1e-2, the 3-significant-figure precision those values are published to (worst residual there 0.35%). gen_gldzm_mirp.py",TEST_NYXUS.TEST_2D_GLDZM_LGLZE_IBSI;TEST_NYXUS.TEST_2D_GLDZM_LGLZE_MIRP,ibsi_digital_phantom diff --git a/tests/vetting/oracles/gen_morphology_skimage.py b/tests/vetting/oracles/gen_morphology_skimage.py index 82c450b6e..c7ae31de2 100644 --- a/tests/vetting/oracles/gen_morphology_skimage.py +++ b/tests/vetting/oracles/gen_morphology_skimage.py @@ -25,11 +25,11 @@ though the AXIS LENGTHS differ ~1.4%. Those are vetted against MATLAB instead, which applies the same +1/12 correction Nyxus does -- see gen_morphology_matlab.m. -EROSIONS_2_VANISH -- number of binary erosions until the object disappears. Nyxus uses a 3x3 - (8-connected) structuring element == skimage.morphology.footprint_rectangle((3, 3)). The - 4-connected disk(1) gives a different count (2 vs 1), so the value also pins the connectivity - convention. (EROSIONS_2_VANISH_COMPLEMENT is a degenerate 0 on this fixture -- the complement is - the bbox background ring -- and is not vetted.) +EROSIONS_2_VANISH is not vetted here. Nyxus erodes with the 4-connected 3x3 cross and reports one + less than the number of erosions it performs; skimage.morphology.footprint_rectangle((3, 3)) + counts every erosion of an 8-connected square. Both give 1 on this fixture, which confirms + neither the structuring element nor the counting convention, so the count is printed for + information and the Nyxus value is a regression pin (test_2d_morphology_regression.h). DIAMETER_EQUAL_AREA -- regionprops(...).equivalent_diameter_area = sqrt(4*Area/pi), the same closed form Nyxus uses, over an exact pixel count. @@ -99,7 +99,6 @@ def main(): got = { "CONVEX_HULL_AREA": hull_area, "SOLIDITY": float(M.sum()) / hull_area, - "EROSIONS_2_VANISH": float(erosions), "ORIENTATION": float(90.0 - np.degrees(rp.orientation)), "DIAMETER_EQUAL_AREA": float(rp.equivalent_diameter_area), "PERIMETER": float(perimeter(C)), @@ -110,6 +109,7 @@ def main(): for name in sorted(got): print(f'\t{{"{name}", {got[name]!r}}},') + print(f" (info) square-footprint erosions to vanish={erosions}; not a pin, the conventions differ") # informational: the moment-normalization gap that keeps the axis lengths out of the skimage set print(f" (info) axis_major sk={rp.axis_major_length:.6f} nyx=6.968816 ; " f"eccentricity sk={rp.eccentricity:.6f} nyx=0.616174 -> ~1.4% gap, vetted vs MATLAB") diff --git a/tests/vetting/report_output.csv b/tests/vetting/report_output.csv index c860474c8..98804755e 100644 --- a/tests/vetting/report_output.csv +++ b/tests/vetting/report_output.csv @@ -82,7 +82,7 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,morphology,MINOR_AXIS_LENGTH,vetted,matlab,morphology.shape2d_native,rel=1e-3,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_ELLIPSE_MATLAB,,,in-tree,matlab,test_2d_morphology_ellipse_matlab,test_2d_morphology_ellipse_regression,test_2d_ooc_2d_two_rois_no_state_leak_invariant,1,1,licensed MATLAB R2026a + Image Processing Toolbox 26.1 (gen_morphology_matlab.m); all 33 C++ pins verified; see morphology_2d_matlab_vetting_report.md,test_2d_morphology_ellipse_matlab,agree,row,audit/morphology_2d_matlab_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,MATLAB R2026a + Image Processing Toolbox 26.1; gen_morphology_matlab.m calls regionprops/bweuler on the morphology.shape2d_native fixture from PolusAI/nyxus main and verifies all 33 C++ pins; max rel difference from the prior Octave literals is 3.40e-16. 2D,morphology,ELONGATION,vetted,matlab,morphology.shape2d_native,rel=1e-3,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_ELLIPSE_MATLAB,,,in-tree,matlab,test_2d_morphology_ellipse_matlab,test_2d_morphology_ellipse_regression,test_2d_ooc_2d_two_rois_no_state_leak_invariant,1,1,licensed MATLAB R2026a + Image Processing Toolbox 26.1 (gen_morphology_matlab.m); all 33 C++ pins verified; see morphology_2d_matlab_vetting_report.md,test_2d_morphology_ellipse_matlab,agree,row,audit/morphology_2d_matlab_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,MATLAB R2026a + Image Processing Toolbox 26.1; gen_morphology_matlab.m calls regionprops/bweuler on the morphology.shape2d_native fixture from PolusAI/nyxus main and verifies all 33 C++ pins; max rel difference from the prior Octave literals is 3.40e-16. 2D,morphology,ECCENTRICITY,vetted,matlab,morphology.shape2d_native,rel=1e-3,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_ELLIPSE_MATLAB,,,in-tree,matlab,test_2d_morphology_ellipse_matlab,test_2d_morphology_ellipse_regression,test_2d_ooc_2d_two_rois_no_state_leak_invariant,1,1,licensed MATLAB R2026a + Image Processing Toolbox 26.1 (gen_morphology_matlab.m); all 33 C++ pins verified; see morphology_2d_matlab_vetting_report.md,test_2d_morphology_ellipse_matlab,agree,row,audit/morphology_2d_matlab_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,MATLAB R2026a + Image Processing Toolbox 26.1; gen_morphology_matlab.m calls regionprops/bweuler on the morphology.shape2d_native fixture from PolusAI/nyxus main and verifies all 33 C++ pins; max rel difference from the prior Octave literals is 3.40e-16. -2D,morphology,ORIENTATION,vetted,skimage,morphology.shape2d_native,,agreed,,,,,audit,skimage,test_2d_morphology_orientation_and_erosions_skimage,,test_2d_ooc_2d_two_rois_no_state_leak_invariant,1,0,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,Vetted vs scikit-image regionprops: NYXUS == 90 - degrees(skimage.orientation) = 70.4174 (row-axis vs x-axis convention); matches to 10 decimals because the ellipse angle is invariant to the pixel-size second-moment correction that makes MAJOR/MINOR/ECCENTRICITY differ ~1.4%. gen_morphology_skimage.py +2D,morphology,ORIENTATION,vetted,skimage,morphology.shape2d_native,,agreed,,,,,audit,skimage,test_2d_morphology_orientation_skimage,,test_2d_ooc_2d_two_rois_no_state_leak_invariant,1,0,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,Vetted vs scikit-image regionprops: NYXUS == 90 - degrees(skimage.orientation) = 70.4174 (row-axis vs x-axis convention); matches to 10 decimals because the ellipse angle is invariant to the pixel-size second-moment correction that makes MAJOR/MINOR/ECCENTRICITY differ ~1.4%. gen_morphology_skimage.py 2D,morphology,ROUNDNESS,vetted,analytic,morphology.shape2d_native,,agreed,,,no-external-oracle,,audit,analytic,test_2d_morphology_documented_formula_conformance_analytic,test_2d_morphology_ellipse_regression,test_2d_ooc_2d_two_rois_no_state_leak_invariant,1,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"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_2D_MORPHOLOGY_DOCUMENTED_FORMULA_CONFORMANCE_ANALYTIC (test_2d_morphology_analytic.h) 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,morphology,PERIMETER,vetted,skimage,morphology.perimeter_circles,rel=1e-3,agreed,,,,,audit,skimage,test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_bad_contour,1,6,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image 0.26.0 measure.perimeter on the circles fixture, recipe morphology.perimeter_circles; agreement 3.8e-15. Previously asserted in test_2d_morphology_matlab.h under a bwperim() recipe that returns a different quantity (846 perimeter pixels; regionprops('Perimeter') 952.848)." 2D,morphology,DIAMETER_EQUAL_PERIMETER,vetted,imea,morphology.shape2d_native,exact,agreed,,,,,audit,imea,test_2d_morphology_diameter_equal_perimeter_imea,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression,test_bad_contour,1,4,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 26.935), a gap inherited from PERIMETER." @@ -94,8 +94,8 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,morphology,CIRCULARITY,vetted,analytic,morphology.shape2d_native,,agreed,,,no-external-oracle,,audit,analytic,test_2d_morphology_documented_formula_conformance_analytic,,,1,0,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"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_2D_MORPHOLOGY_DOCUMENTED_FORMULA_CONFORMANCE_ANALYTIC (test_2d_morphology_analytic.h) 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,morphology,CONVEX_HULL_AREA,vetted,skimage,morphology.shape2d_native,,agreed,,,,Document Nyxus point-hull convention or revise expected value.,audit,skimage,test_2d_morphology_convex_hull_skimage,,,1,0,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image (convex_hull_image offset_coordinates=False, Pick's-theorem convex_area); test_2d_morphology_skimage.h TEST_2D_MORPHOLOGY_CONVEX_HULL_SKIMAGE 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,morphology,SOLIDITY,vetted,skimage,morphology.shape2d_native,,agreed,,,,Document Nyxus point-hull convention or revise expected value.,audit,skimage,test_2d_morphology_convex_hull_skimage,,test_2d_morphology_solidity_le_one_invariant,1,0,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image (convex_hull_image offset_coordinates=False, Pick's-theorem convex_area); test_2d_morphology_skimage.h TEST_2D_MORPHOLOGY_CONVEX_HULL_SKIMAGE 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,morphology,EROSIONS_2_VANISH,vetted,skimage,morphology.shape2d_native,,agreed,,,,,audit,skimage,test_2d_morphology_orientation_and_erosions_skimage,,test_2d_ooc_2d_two_rois_no_state_leak_invariant,1,0,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Vetted vs scikit-image: Nyxus 3x3 (8-connected) erosion count == skimage binary_erosion(square(3)) count = 1; disk(1)/4-connected gives 2, so the test also pins the connectivity convention. gen_morphology_skimage.py" -2D,morphology,EROSIONS_2_VANISH_COMPLEMENT,regression,,morphology.shape2d_native,,benign,,,convention-mismatch,Degenerate on the shape2d fixture (golden 0 -- the complement erodes away immediately); needs a fixture with an interior hole to vet the count vs skimage.,tracker,,,test_2d_morphology_erosion_complement_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md, +2D,morphology,EROSIONS_2_VANISH,regression,,morphology.shape2d_native,,benign,,,convention-mismatch,"An implementation of the Nyxus definition (4-connected cross, count minus one) on a fixture where it and scikit-image's square-footprint count differ.",audit,,,test_2d_morphology_erosions_regression,test_2d_ooc_2d_two_rois_no_state_leak_invariant,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Regression pin. scikit-image's erosion with a 3x3 square, counting every erosion, also gives 1 on this shape, but Nyxus erodes with the 4-connected 3x3 cross (erosion.h) and counts one fewer, so the agreement confirms neither the structuring element nor the counting convention. gen_morphology_skimage.py" +2D,morphology,EROSIONS_2_VANISH_COMPLEMENT,regression,,morphology.shape2d_native,,benign,,,convention-mismatch,Degenerate on the shape2d fixture (golden 0 -- the complement erodes away immediately); needs a fixture with an interior hole to vet the count vs skimage.,tracker,,,test_2d_morphology_erosions_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md, 2D,morphology,FRACT_DIM_BOXCOUNT,vetted,fraclac,morphology.fractal_blob512,,agreed,,,,,audit,analytic;fraclac,test_2d_morphology_boxcount_known_dimension_analytic;test_2d_morphology_fractal_dimension_blob512_fraclac,,,2,0,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"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_2d_morphology_fraclac.h). Also test_2d_morphology_fraclac.py recovers analytic ground-truth dimensions: filled square -> 2.0, straight line -> 1.0, Sierpinski triangle -> 1.585." 2D,morphology,FRACT_DIM_PERIMETER,vetted,fraclac,morphology.fractal_blob512,,agreed,,,,,audit,analytic;fraclac,test_2d_morphology_fractal_dimension_blob512_fraclac;test_2d_morphology_perimeter_disk_analytic;test_2d_morphology_perimeter_koch_snowflake_analytic,,,3,0,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"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_2d_morphology_fraclac.h). Also test_2d_morphology_fraclac.py: Koch snowflake and smooth-disk perimeter dimensions match." 2D,morphology,MIN_FERET_ANGLE,regression,,morphology.shape2d_native,,benign,,,promote-after-deepdive;convention-mismatch,Definition review; do not mark agreed unless a tool matches within 5%.,tracker,,,test_2d_morphology_caliper_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md, @@ -273,12 +273,12 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,glrlm,GLRLM_GLV,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_glv_ibsi,test_2d_glrlm_glv_regression,,3,1,,,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | test_3d_glrlm_regression.h ORPHANED (not #included; never run) - left per decision A" 2D,glrlm,GLRLM_RV,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_rv_ibsi,test_2d_glrlm_rv_regression,,3,1,,,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | test_3d_glrlm_regression.h ORPHANED (not #included; never run) - left per decision A" 2D,glrlm,GLRLM_RE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=5e-3,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_re_ibsi,test_2d_glrlm_re_regression,,3,1,,,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=5e-3; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to 1.1e-3, the fast_log10 + EPSILON accuracy choice this family's only logarithmic sum carries. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | test_3d_glrlm_regression.h ORPHANED (not #included; never run) - left per decision A" -2D,glrlm,GLRLM_LGLRE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lglre_ibsi,test_2d_glrlm_lglre_regression,,3,1,,,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LGLRE = 0.604) at 1% in test_2d_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,GLRLM_HGLRE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_hglre_ibsi,test_2d_glrlm_hglre_regression,,3,1,,,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM HGLRE = 9.82) at 1% in test_2d_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,GLRLM_SRLGLE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_srlgle_ibsi,test_2d_glrlm_srlgle_regression,,3,1,,,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRLGLE = 0.294) at 1% in test_2d_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,GLRLM_SRHGLE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_srhgle_ibsi,test_2d_glrlm_srhgle_regression,,3,1,,,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRHGLE = 8.57) at 1% in test_2d_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,GLRLM_LRLGLE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lrlgle_ibsi,test_2d_glrlm_lrlgle_regression,,3,1,,,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRLGLE = 3.14) at 1% in test_2d_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,GLRLM_LRHGLE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lrhgle_ibsi,test_2d_glrlm_lrhgle_regression,,3,1,,,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRHGLE = 17.4) at 1% in test_2d_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,GLRLM_LGLRE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lglre_ibsi,test_2d_glrlm_lglre_regression,,3,1,,,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LGLRE = 0.604) at 1% in test_2d_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,GLRLM_HGLRE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_hglre_ibsi,test_2d_glrlm_hglre_regression,,3,1,,,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM HGLRE = 9.82) at 1% in test_2d_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,GLRLM_SRLGLE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_srlgle_ibsi,test_2d_glrlm_srlgle_regression,,3,1,,,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRLGLE = 0.294) at 1% in test_2d_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,GLRLM_SRHGLE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_srhgle_ibsi,test_2d_glrlm_srhgle_regression,,3,1,,,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRHGLE = 8.57) at 1% in test_2d_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,GLRLM_LRLGLE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lrlgle_ibsi,test_2d_glrlm_lrlgle_regression,,3,1,,,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRLGLE = 3.14) at 1% in test_2d_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,GLRLM_LRHGLE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lrhgle_ibsi,test_2d_glrlm_lrhgle_regression,,3,1,,,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRHGLE = 17.4) at 1% in test_2d_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,GLRLM_SRE_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_sre_ave_ibsi,test_2d_glrlm_sre_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_SRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md." 2D,glrlm,GLRLM_LRE_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lre_ave_ibsi,test_2d_glrlm_lre_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_LRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md." 2D,glrlm,GLRLM_GLN_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_gln_ave_ibsi,test_2d_glrlm_gln_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_GLN, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md." @@ -289,12 +289,12 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,glrlm,GLRLM_GLV_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_glv_ave_ibsi,test_2d_glrlm_glv_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_GLV, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md." 2D,glrlm,GLRLM_RV_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_rv_ave_ibsi,test_2d_glrlm_rv_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_RV, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md." 2D,glrlm,GLRLM_RE_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=5e-3,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_re_ave_ibsi,test_2d_glrlm_re_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_RE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=5e-3; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to 1.1e-3, the fast_log10 + EPSILON accuracy choice this family's only logarithmic sum carries. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md." -2D,glrlm,GLRLM_LGLRE_AVE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lglre_ave_ibsi,test_2d_glrlm_lglre_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_LGLRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LGLRE = 0.604) at 1% in test_2d_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,GLRLM_HGLRE_AVE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_hglre_ave_ibsi,test_2d_glrlm_hglre_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_HGLRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM HGLRE = 9.82) at 1% in test_2d_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,GLRLM_SRLGLE_AVE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_srlgle_ave_ibsi,test_2d_glrlm_srlgle_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_SRLGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRLGLE = 0.294) at 1% in test_2d_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,GLRLM_SRHGLE_AVE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_srhgle_ave_ibsi,test_2d_glrlm_srhgle_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_SRHGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRHGLE = 8.57) at 1% in test_2d_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,GLRLM_LRLGLE_AVE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lrlgle_ave_ibsi,test_2d_glrlm_lrlgle_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_LRLGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRLGLE = 3.14) at 1% in test_2d_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,GLRLM_LRHGLE_AVE,vetted,ibsi,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lrhgle_ave_ibsi,test_2d_glrlm_lrhgle_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_ibsi_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_LRHGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRHGLE = 17.4) at 1% in test_2d_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,glrlm,GLRLM_LGLRE_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lglre_ave_ibsi,test_2d_glrlm_lglre_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_LGLRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LGLRE = 0.604) at 1% in test_2d_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,GLRLM_HGLRE_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_hglre_ave_ibsi,test_2d_glrlm_hglre_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_HGLRE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM HGLRE = 9.82) at 1% in test_2d_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,GLRLM_SRLGLE_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_srlgle_ave_ibsi,test_2d_glrlm_srlgle_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_SRLGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRLGLE = 0.294) at 1% in test_2d_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,GLRLM_SRHGLE_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_srhgle_ave_ibsi,test_2d_glrlm_srhgle_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_SRHGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM SRHGLE = 8.57) at 1% in test_2d_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,GLRLM_LRLGLE_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lrlgle_ave_ibsi,test_2d_glrlm_lrlgle_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_LRLGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRLGLE = 3.14) at 1% in test_2d_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,GLRLM_LRHGLE_AVE,vetted,pyradiomics,glrlm.ibsi_ng128,rel=1e-9,agreed,,,,,in-tree,ibsi;mirp;pyradiomics,test_2d_glrlm_family_mirp;test_2d_glrlm_family_pyradiomics;test_2d_glrlm_lrhgle_ave_ibsi,test_2d_glrlm_lrhgle_ave_regression,,3,1,"angle average; the tools report one value over the 4 directions, so it shares its base feature's golden",,agree,feature,audit/glrlm_2d_pyradiomics_vetting_report.md,audit/glrlm_2d_golden_regen.md,,"Angle average of GLRLM_LRHGLE, asserted per slice against the same golden as its base. Asserted on the IBSI digital phantom in test_2d_glrlm_ibsi.h (IBSI consensus), test_2d_glrlm_pyradiomics.h (pyradiomics v3.0.1) and test_2d_glrlm_mirp.h (mirp 2.6.0) at rel<=rel=1e-9; the two tools agree with each other to ~1e-15 and Nyxus reproduces them to double precision. Was vetted from an offline run only (source=tracker), with target_test naming a file that did not exist. Evidence: tests/vetting/audit/glrlm_2d_*_vetting_report.md. | vetted vs IBSI digital-phantom consensus (GLRLM LRHGLE = 17.4) at 1% in test_2d_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,GLDZM_SDE,vetted,mirp,gldzm.ibsi_phantom_2d,abs=1e-9,agreed,ibsi_digital_phantom,TEST_NYXUS.TEST_2D_GLDZM_SDE_IBSI;TEST_NYXUS.TEST_2D_GLDZM_SDE_MIRP,,,audit,ibsi;mirp,test_2d_gldzm_sde_ibsi;test_2d_gldzm_sde_mirp,,,2,0,"asserted against both oracles by design: IBSI fixes the definition at its published 3-significant-figure precision, mirp fixes the digits at 1.3e-15 absolute",test_2d_gldzm_sde_ibsi;test_2d_gldzm_sde_mirp,agree,row,audit/gldzm_2d_mirp_vetting_report.md,audit/gldzm_2d_golden_regen.md,matrix/gldzm.md,"vetted vs mirp 2.6.0 on the four IBSI phantom slices (by_slice, no discretisation - the phantom is already discrete 1..6), recipe gldzm.ibsi_phantom_2d. Backed per slice AND by the four-slice mean: mirp computes each slice on its own, so both quantities are pinned in test_2d_gldzm_mirp.h at SPEC 7's exact tier, an absolute 1e-9 band (worst absolute residual 1.3e-15 over the 16 features x 4 slices plus the 16 means; worst relative 7.0e-16). The mean alone is blind to errors in two slices that cancel, which is the shape this family's zone connectivity defect actually took. The same means are pinned against the published IBSI consensus in test_2d_gldzm_ibsi.h at rel=1e-2, the 3-significant-figure precision those values are published to (worst residual there 0.35%). gen_gldzm_mirp.py" 2D,gldzm,GLDZM_LDE,vetted,mirp,gldzm.ibsi_phantom_2d,abs=1e-9,agreed,ibsi_digital_phantom,TEST_NYXUS.TEST_2D_GLDZM_LDE_IBSI;TEST_NYXUS.TEST_2D_GLDZM_LDE_MIRP,,,audit,ibsi;mirp,test_2d_gldzm_lde_ibsi;test_2d_gldzm_lde_mirp,,,2,0,"asserted against both oracles by design: IBSI fixes the definition at its published 3-significant-figure precision, mirp fixes the digits at 1.3e-15 absolute",test_2d_gldzm_lde_ibsi;test_2d_gldzm_lde_mirp,agree,row,audit/gldzm_2d_mirp_vetting_report.md,audit/gldzm_2d_golden_regen.md,matrix/gldzm.md,"vetted vs mirp 2.6.0 on the four IBSI phantom slices (by_slice, no discretisation - the phantom is already discrete 1..6), recipe gldzm.ibsi_phantom_2d. Backed per slice AND by the four-slice mean: mirp computes each slice on its own, so both quantities are pinned in test_2d_gldzm_mirp.h at SPEC 7's exact tier, an absolute 1e-9 band (worst absolute residual 1.3e-15 over the 16 features x 4 slices plus the 16 means; worst relative 7.0e-16). The mean alone is blind to errors in two slices that cancel, which is the shape this family's zone connectivity defect actually took. The same means are pinned against the published IBSI consensus in test_2d_gldzm_ibsi.h at rel=1e-2, the 3-significant-figure precision those values are published to (worst residual there 0.35%). gen_gldzm_mirp.py" 2D,gldzm,GLDZM_LGLZE,vetted,mirp,gldzm.ibsi_phantom_2d,abs=1e-9,agreed,ibsi_digital_phantom,TEST_NYXUS.TEST_2D_GLDZM_LGLZE_IBSI;TEST_NYXUS.TEST_2D_GLDZM_LGLZE_MIRP,,,audit,ibsi;mirp,test_2d_gldzm_lglze_ibsi;test_2d_gldzm_lglze_mirp,,,2,0,"asserted against both oracles by design: IBSI fixes the definition at its published 3-significant-figure precision, mirp fixes the digits at 1.3e-15 absolute",test_2d_gldzm_lglze_ibsi;test_2d_gldzm_lglze_mirp,agree,row,audit/gldzm_2d_mirp_vetting_report.md,audit/gldzm_2d_golden_regen.md,matrix/gldzm.md,"vetted vs mirp 2.6.0 on the four IBSI phantom slices (by_slice, no discretisation - the phantom is already discrete 1..6), recipe gldzm.ibsi_phantom_2d. Backed per slice AND by the four-slice mean: mirp computes each slice on its own, so both quantities are pinned in test_2d_gldzm_mirp.h at SPEC 7's exact tier, an absolute 1e-9 band (worst absolute residual 1.3e-15 over the 16 features x 4 slices plus the 16 means; worst relative 7.0e-16). The mean alone is blind to errors in two slices that cancel, which is the shape this family's zone connectivity defect actually took. The same means are pinned against the published IBSI consensus in test_2d_gldzm_ibsi.h at rel=1e-2, the 3-significant-figure precision those values are published to (worst residual there 0.35%). gen_gldzm_mirp.py" diff --git a/tests/vetting/report_output.md b/tests/vetting/report_output.md index 2b4e95775..4c1baa186 100644 --- a/tests/vetting/report_output.md +++ b/tests/vetting/report_output.md @@ -8,8 +8,8 @@ what [`oracle_coverage.csv`](oracle_coverage.csv) CLAIMS to what the test tree A ## Coverage -Features vetted by >=1 oracle: 637/758 (84%) -regression: 121 invariant: 0 untested: 0 +Features vetted by >=1 oracle: 636/758 (84%) +regression: 122 invariant: 0 untested: 0 | family | total | vetted | regression | invariant | untested | |---|---|---|---|---|---| @@ -23,7 +23,7 @@ regression: 121 invariant: 0 untested: 0 | imq | 6 | 4 | 2 | 0 | 0 | | intensity_histogram | 47 | 43 | 4 | 0 | 0 | | moments | 180 | 118 | 62 | 0 | 0 | -| morphology | 113 | 78 | 35 | 0 | 0 | +| morphology | 113 | 77 | 36 | 0 | 0 | | neighbor | 9 | 8 | 1 | 0 | 0 | | ngldm | 38 | 33 | 5 | 0 | 0 | | ngtdm | 10 | 10 | 0 | 0 | 0 | @@ -66,7 +66,7 @@ that alone understates every family that corroborates. | 2D | glszm | . | . | . | 16 | . | . | 16 | . | . | . | . | | 2D | intensity_histogram | 26 | . | . | 16 | . | . | 23 | . | . | . | 4 | | 2D | moments | . | . | . | . | . | . | . | . | . | 118 | 62 | -| 2D | morphology | 5 | 5 | 2 | . | 20 | 33 | . | . | . | 8 | 29 | +| 2D | morphology | 5 | 5 | 2 | . | 20 | 33 | . | . | . | 7 | 30 | | 2D | neighbor | 6 | 2 | . | . | . | . | . | . | . | . | 1 | | 2D | ngldm | . | . | . | 17 | . | . | 17 | . | . | . | 2 | | 2D | ngtdm | . | . | . | 5 | . | . | 5 | . | . | . | . | @@ -82,13 +82,13 @@ that alone understates every family that corroborates. | 3D | ngldm | . | . | . | . | . | . | 16 | . | . | . | 3 | | 3D | ngtdm | . | . | . | . | . | . | . | . | 5 | . | . | | IMQ | imq | 1 | 2 | . | . | . | . | . | 2 | . | . | 2 | -| | **all** | **41** | **9** | **2** | **181** | **20** | **95** | **208** | **2** | **272** | **127** | **121** | +| | **all** | **41** | **9** | **2** | **181** | **20** | **95** | **208** | **2** | **272** | **126** | **122** | ## By dimensionality | dim | features | vetted | scanned | no oracle assertion | unscanned | |---|---:|---:|---:|---:|---:| -| 2D | 539 | 434 | 539 | 105 | 0 | +| 2D | 539 | 433 | 539 | 106 | 0 | | 3D | 213 | 199 | 213 | 14 | 0 | | IMQ | 6 | 4 | 6 | 2 | 0 |