From 48ab052e70eb0b9468d4d26176f5de8ac5a816c1 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:21 -0700 Subject: [PATCH 01/23] docs(build): build the site without the compiled extension and fail CI on warnings conf.py imports the Python package from the source tree and mocks nyxus.backend, so the docs build without compiling Nyxus. The site uses pydata-sphinx-theme; the page title carries no version number, and |release| (quoted by the citation) is read from CITATION.cff, which release-please now updates. A docs workflow builds the site with warnings as errors and checks links. The docs dependencies move from environment.yml to docs/requirements.txt, which the Read the Docs configuration uses. test_docs_conf_mechanics.py checks the release, the title and that |release| is still used. --- .github/workflows/docs.yml | 62 ++++++++++++++++++ .gitignore | 3 + .readthedocs.yml | 27 +++----- CITATION.cff | 24 +++++++ docs/make.bat | 4 +- docs/requirements.txt | 8 +++ docs/source/conf.py | 81 +++++++++--------------- environment.yml | 9 +-- release-please-config.json | 2 +- tests/python/test_docs_conf_mechanics.py | 34 ++++++++++ 10 files changed, 173 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 CITATION.cff create mode 100644 docs/requirements.txt create mode 100644 tests/python/test_docs_conf_mechanics.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..13607a077 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,62 @@ +name: Build Documentation + +on: + push: + branches: [ main ] + paths: + - 'docs/**' + - 'README.md' + - 'src/nyx/python/nyxus/**' + - 'src/nyx/cli_option_constants.h' + - 'src/nyx/featureset.h' + - 'src/nyx/featureset.cpp' + - 'src/nyx/env_features.cpp' + - 'src/nyx/features/*.h' + - 'tests/vetting/oracle_coverage.csv' + - '.readthedocs.yml' + - '.github/workflows/docs.yml' + pull_request: + paths: + - 'docs/**' + - 'README.md' + - 'src/nyx/python/nyxus/**' + - 'src/nyx/cli_option_constants.h' + - 'src/nyx/featureset.h' + - 'src/nyx/featureset.cpp' + - 'src/nyx/env_features.cpp' + - 'src/nyx/features/*.h' + - 'tests/vetting/oracle_coverage.csv' + - '.readthedocs.yml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +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" + + - 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 + + - 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/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..88bd6346e --- /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 +sphinx-design>=0.5 +sphinx-autodoc-typehints>=2 +numpy +pandas diff --git a/docs/source/conf.py b/docs/source/conf.py index 0aa8f504e..833f1f37b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,95 +1,72 @@ # 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' - -# 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'] +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'], +} diff --git a/environment.yml b/environment.yml index 85937eba0..07938a479 100644 --- a/environment.yml +++ b/environment.yml @@ -1,6 +1,5 @@ channels: - conda-forge - - https://repo.continuum.io/pkgs/free - defaults dependencies: - python>=3.8 @@ -17,17 +16,11 @@ 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/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/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 From ab8a89ef9a4161c6a8d39e6bd3c92d9554aa1574 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:21 -0700 Subject: [PATCH 02/23] docs(readme): slim the README and add the entry pages The README becomes a landing page: what Nyxus is, how to install it, three quick starts that run as written, links into the site, the citation and the license, with absolute links so the PyPI page renders them. The site gets a card landing page with the citation, an installation page (packages, platforms, GPU packages, building from source with the CMake options), the user and developer guide section pages, and a release notes page. Package facts describe what is published now; release gaps on the PyPI and conda-forge builds are stated where they matter. --- README.md | 1144 +++---------------------------- docs/examples/check_install.py | 25 + docs/source/developer_guide.rst | 9 + docs/source/index.rst | 95 ++- docs/source/installation.rst | 452 ++++++++++++ docs/source/release_notes.rst | 16 + docs/source/user_guide.rst | 21 + 7 files changed, 679 insertions(+), 1083 deletions(-) create mode 100644 docs/examples/check_install.py create mode 100644 docs/source/developer_guide.rst create mode 100644 docs/source/installation.rst create mode 100644 docs/source/release_notes.rst create mode 100644 docs/source/user_guide.rst diff --git a/README.md b/README.md index c60a5bc61..99cf37de2 100644 --- a/README.md +++ b/README.md @@ -6,1107 +6,125 @@ [![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; from +arrays, and as 2D whole-slide images, they stop the run with an error. Out of core, 3D ROIs keep +their in-memory values, but the run stops with an error when one read of the file returns the +whole volume (NIfTI, or OME-Zarr chunked over all of Z). Out of core on an image read as one tile, +most 2D features keep their in-memory values: the contour-based and weighted-moment features +differ, the fractal features can differ, the neighbor, polygonality and hexagonality features are +not computed, and the family pages list the other exceptions. On an image read as several tiles, most 2D feature families +raise an error out of core or give wrong values (see +[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/examples/check_install.py b/docs/examples/check_install.py new file mode 100644 index 000000000..07cf07883 --- /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_2d_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/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/index.rst b/docs/source/index.rst index a75a9ef78..85e1a428c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,26 +1,81 @@ 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; from +arrays, and as 2D whole-slide images, they stop the run with an error. Out of core, 3D ROIs keep +their in-memory values, but the run stops with an error when one read of the file returns the +whole volume (NIfTI, or OME-Zarr chunked over all of Z). Out of core on an image read as one tile, +most 2D features keep their in-memory values: the contour-based and weighted-moment features +differ, the fractal features can differ, the neighbor, polygonality and hexagonality features are +not computed, and the family pages list the other exceptions. On an image read as several tiles, most 2D feature families +raise an error out of core or give wrong values (see :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..f63add4ed --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,452 @@ +.. _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. A fix to the + release workflow is coming. +* 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. A fix to the Windows wheel build is coming. +* 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. A fix to the build is coming. + +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, and a fix to the +release workflow that publishes them again is coming. 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. + A fix is coming. + + 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. A fix that makes ``Release`` the default there too is coming. + +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/release_notes.rst b/docs/source/release_notes.rst new file mode 100644 index 000000000..0727cbf49 --- /dev/null +++ b/docs/source/release_notes.rst @@ -0,0 +1,16 @@ +.. _release-notes: + +Release notes +============= + +Each Nyxus release is tagged with its version, and its notes are published on the +`GitHub releases page `__. These pages describe the +development head of the repository; facts about published packages (wheels, conda-forge builds +and Docker images) describe what is published now. + +The repository is set up to make releases with release-please from the commit history; no release +has been made that way yet, and the notes of 0.11.0 and earlier are GitHub's generated lists. +Commit subjects follow the Conventional Commits format, and release-please groups them by type: +features, bug fixes, performance improvements, reverts, documentation, refactoring and build +changes. Chores, style, test and CI commits are left out of the notes. The commit conventions are +described in ``CONTRIBUTING.md`` and in the :ref:`developer guide `. diff --git a/docs/source/user_guide.rst b/docs/source/user_guide.rst new file mode 100644 index 000000000..927678a2e --- /dev/null +++ b/docs/source/user_guide.rst @@ -0,0 +1,21 @@ +User guide +========== + +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. + +.. toctree:: + :maxdepth: 2 + + installation + cmdline_and_examples + supported_formats + volumetric + large_images + output + gpu + ibsi_mode + imagequality + wipp + troubleshooting From 31633fe2d0d9754ca181c3eb83569a3d76a71231 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:21 -0700 Subject: [PATCH 03/23] docs(cli): generate the command-line reference and correct the usage pages docs/tools/gen_cli_reference.py renders docs/source/cli_reference.rst from docs/cli_options.toml, where each option records its default, the source line that assigns it, its values, its effect and the code it was checked against. Its --check mode fails when the TOML and the option header disagree, a default does not match its source line, or a citation is stale (test_docs_cli_reference_mechanics.py). The command-line and examples page and the WIPP page are rewritten around examples that run; plugin.json offers the arrowipc output type the program accepts. --- docs/cli_options.toml | 605 ++++++++++++++ docs/examples/arrow_output.py | 23 + docs/examples/directory_and_params.py | 35 + docs/examples/featurize_arrays.py | 42 + docs/examples/featurize_files.py | 34 + docs/examples/nested_rois.py | 27 + docs/source/cli_reference.rst | 338 ++++++++ docs/source/cmdline_and_examples.rst | 758 +++++------------- docs/source/wipp.rst | 53 ++ docs/tools/gen_cli_reference.py | 258 ++++++ plugin.json | 160 +--- .../test_docs_cli_reference_mechanics.py | 63 ++ 12 files changed, 1675 insertions(+), 721 deletions(-) create mode 100644 docs/cli_options.toml create mode 100644 docs/examples/arrow_output.py create mode 100644 docs/examples/directory_and_params.py create mode 100644 docs/examples/featurize_arrays.py create mode 100644 docs/examples/featurize_files.py create mode 100644 docs/examples/nested_rois.py create mode 100644 docs/source/cli_reference.rst create mode 100644 docs/source/wipp.rst create mode 100644 docs/tools/gen_cli_reference.py create mode 100644 tests/python/test_docs_cli_reference_mechanics.py diff --git a/docs/cli_options.toml b/docs/cli_options.toml new file mode 100644 index 000000000..ad1f45ade --- /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:27" +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:124" +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:77" +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:75" +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:76" +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:77" +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:78" +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; a fix is coming." +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:79" +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:120" +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:61" +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:87" +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." +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:106" +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:21" +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:735" +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:14" +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:15" +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:16" +values = "integer >= 1" +build = "" +summary = "Width and height, in pixels, of the square Gabor convolution kernel." +notes = "" +evidence = "features/gabor.cpp:16; cli_gabor_options.cpp:79-92" + +[[option]] +name = "--gaborf0" +group = "Gabor" +required = false +default = "0.1" +default_at = "features/gabor.cpp:17" +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:18" +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:67" +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:68" +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:40" +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; a fix is coming." +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:40" +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:40" +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:109" +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:84" +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:28" +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:28" +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:94" +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/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/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..1649f7339 --- /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_2d_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/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/source/cli_reference.rst b/docs/source/cli_reference.rst new file mode 100644 index 000000000..c9ff3fbd5 --- /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; a fix is coming. + * - ``--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. + * - ``--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 >= 1. + * - ``--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; a fix is coming. 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/wipp.rst b/docs/source/wipp.rst new file mode 100644 index 000000000..05f3436bd --- /dev/null +++ b/docs/source/wipp.rst @@ -0,0 +1,53 @@ +.. _wipp: + +WIPP plugin +=========== + +Nyxus is available as a plugin for `WIPP `__, the Web Image +Processing Pipeline. The plugin runs the Nyxus command-line program in the ``polusai/nyxus`` +Docker image of the same version; ``plugin.json`` at the repository root describes it to WIPP. +Each plugin input becomes the command-line option of the same name, listed in the +:ref:`cli-reference`. + +Inputs +------ + +.. list-table:: + :widths: 24 16 60 + :header-rows: 1 + + * - Input + - Required + - Meaning + * - ``intDir`` + - yes + - Collection of intensity images. + * - ``segDir`` + - yes + - Collection of label images. Give the intensity collection again for whole-slide mode, + where each image is one ROI. + * - ``filePattern`` + - yes + - Regular expression that selects files in both collections, for example ``.*`` for all + files or ``.*c0\.ome\.tif`` for files ending in ``c0.ome.tif``. + * - ``features`` + - yes + - Feature groups and features to compute, chosen from the list the plugin offers (``*ALL*``, + ``*ALL_INTENSITY*`` and so on). The groups are described in :doc:`featuregroups`. + * - ``outputType`` + - yes + - ``singlecsv`` (one CSV file for all images), ``separatecsv`` (one CSV file per image), + ``arrowipc`` or ``parquet``. See :doc:`output`. + * - ``pixelDistance`` + - no + - Distance in pixels within which two ROIs count as neighbours; 5 by default. + * - ``pixelsPerCentimeter`` + - no + - Pixel size, in pixels per centimetre. Accepted, but no feature uses it (see + :ref:`conventions-units`). + * - ``embeddedpixelsize`` + - no + - Accepted for compatibility; the program reads it but no feature uses it. + +The plugin writes its results to the output collection ``outDir``, in the format chosen with +``outputType``. diff --git a/docs/tools/gen_cli_reference.py b/docs/tools/gen_cli_reference.py new file mode 100644 index 000000000..e4f7fb5d1 --- /dev/null +++ b/docs/tools/gen_cli_reference.py @@ -0,0 +1,258 @@ +"""Generate the command-line reference page from docs/cli_options.toml. + + python docs/tools/gen_cli_reference.py # write docs/source/cli_reference.rst + python docs/tools/gen_cli_reference.py --check # fail if the page is stale, or if the + # option table and the CLI disagree + +The option table is hand-written: each entry's default, requirement and effect is taken from the +code that applies it, with the evidence recorded next to it. --check keeps the table honest about +which options exist: every option defined in src/nyx/cli_option_constants.h must have an entry, +and every entry must name an option the CLI defines. Every entry's evidence must cite files and +line ranges that exist. Every single-token default must be the value assigned on the one line its +`default_at` key names (`default_at = "-"` opts an entry out). A default that changes in the code +fails the check; a citation that drifts to a different line fails it unless the new line assigns +the same value. +""" + +import argparse +import math +import re +import sys +import tomllib +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +TABLE = REPO / "docs" / "cli_options.toml" +HEADER = REPO / "src" / "nyx" / "cli_option_constants.h" +PAGE = REPO / "docs" / "source" / "cli_reference.rst" + +# Options handled in parse_cmdline() itself rather than through a clo_* constant. +BUILTIN = {"-h", "--help"} + +GROUPS = [ + "Input", + "Output", + "Features", + "Texture settings", + "Gabor", + "Image handling", + "Performance and memory", + "Nested ROIs", + "Diagnostics", + "GPU", +] + + +def cli_options(): + """Option names defined by the CLI: every clo_* constant whose value starts with a dash.""" + text = HEADER.read_text(encoding="utf-8") + return set(re.findall(r'#define\s+clo_\w+\s+"(-[^"]+)"', text)) + + +def load_table(): + with open(TABLE, "rb") as f: + return tomllib.load(f)["option"] + + +# Shown as literals: text the table marks with single backticks, option names, feature groups +# (*ALL_GLCM*) and feature-code families (IH_*). +LITERAL = re.compile(r"(`[^`]+`" + r"|(? reStructuredText: literals for code tokens, everything else escaped.""" + out = [] + for i, part in enumerate(LITERAL.split(text)): + if i % 2: + out.append(f"``{part.strip('`')}``") + continue + part = part.replace("\\", "\\\\") + part = re.sub(r"([*|`])", r"\\\1", part) + part = re.sub(r"(\w)_(?=\W|$)", r"\1\\_", part) # a trailing underscore would make a link + out.append(part) + return "".join(out) + + +def cell(text): + """One list-table cell: an empty cell is rendered as a dash.""" + text = " ".join(str(text).split()) + return escape(text) if text else "—" + + +def literal(text): + return f"``{text}``" if text else "—" + + +def render(options): + by_group = {g: [] for g in GROUPS} + for o in options: + if o["group"] not in by_group: + raise SystemExit(f"{o['name']}: unknown group {o['group']!r}; expected one of {GROUPS}") + by_group[o["group"]].append(o) + + out = [ + ".. 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.", + "", + ] + for group in GROUPS: + rows = by_group[group] + if not rows: + continue + out += [group, "-" * len(group), "", ".. list-table::", " :header-rows: 1", + " :widths: 22 10 16 52", "", + " * - Option", " - Required", " - Default", " - Description"] + for o in rows: + desc = o["summary"].strip() + if o.get("values"): + desc += f" Values: {o['values'].strip()}" + desc += "" if desc.endswith(".") else "." + if o.get("notes"): + desc += " " + o["notes"].strip() + if o.get("build"): + desc += f" Available in {o['build'].strip()} only." + out += [ + f" * - ``{o['name']}``", + f" - {'yes' if o.get('required') else 'no'}", + f" - {literal(o.get('default', ''))}", + f" - {cell(desc)}", + ] + out.append("") + return "\n".join(out).rstrip("\n") + + +def check_names(options): + declared = cli_options() + # An entry may name several spellings of one option, e.g. "-h, --help". + listed = {n.strip() for o in options for n in o["name"].split(",")} + problems = [] + for name in sorted(declared - listed): + problems.append(f"{name} is defined in cli_option_constants.h but has no entry in {TABLE.name}") + for name in sorted(listed - declared - BUILTIN): + problems.append(f"{name} has an entry in {TABLE.name} but the CLI does not define it") + names = [o["name"] for o in options] + dupes = {n for n in names if names.count(n) > 1} + for name in sorted(dupes): + problems.append(f"{name} appears more than once in {TABLE.name}") + return problems + + +SRC = REPO / "src" / "nyx" + + +def cited_lines(evidence): + """The source lines an entry's evidence names: "file:12-14, 20; other.cpp:7 (note)". + + Returns (lines, problems); a cited file that does not exist, or a range that is not a line + range of that file, is a problem. + """ + lines, problems = [], [] + for part in evidence.split(";"): + part = re.sub(r"\([^)]*\)", "", part).strip() # drop "(explanations)" + m = re.match(r"([\w./-]+):\s*(.+)$", part) + if not m: + continue + path = SRC / m.group(1) + if not path.exists(): + problems.append(f"cites {m.group(1)}, which does not exist") + continue + text = path.read_text(encoding="utf-8", errors="replace").splitlines() + for rng in m.group(2).split(","): + r = re.match(r"\s*(\d+)(?:-(\d+))?\s*$", rng) + if not r or int(r.group(2) or r.group(1)) > len(text): + problems.append(f"cites {m.group(1)}:{rng.strip()}, which is not a line range of that file") + continue + lines += text[int(r.group(1)) - 1:int(r.group(2) or r.group(1))] + return lines, problems + + +def default_forms(default): + """Ways the code may write a default: as given, or as another spelling of the same number.""" + d = default.strip() + forms = {d} + try: + x = float(d) + except ValueError: + return forms + if not math.isfinite(x): + return forms + forms |= {repr(x), f"{x:g}", f"{x:.0e}".replace("e+0", "e").replace("e+", "e")} + if x == int(x): + forms.add(str(int(x))) + return forms + + +def assigned(form, text): + """True when `form` is the value assigned or passed on this line: `x = 4;`, `x = "_";`, `push_back (0)`.""" + f = re.escape(form) + return re.search(r'(?:(?=])=|[{(])\s*"?' + f + r'"?\s*[;,})]', text) is not None + + +def check_defaults(options): + """Every entry's evidence must cite existing files and line ranges. Every single-token default + must be the value assigned on its `default_at` line (comments removed; a list default may also + match with the spaces and braces removed). `default_at = "-"` opts an entry out.""" + problems = [] + for o in options: + _, bad = cited_lines(o.get("evidence", "")) + problems += [f"{o['name']}: {b}" for b in bad] + default, at = o.get("default", "").strip(), o.get("default_at", "") + if at == "-": + continue + if not at: + if default and " " not in default: + problems.append(f"{o['name']}: default {default!r} has no default_at line") + continue + lines, bad = cited_lines(at) + problems += [f"{o['name']}: default_at {b}" for b in bad] + if len(lines) != 1: + problems.append(f"{o['name']}: default_at must name exactly one line") + continue + code = re.sub(r"//.*$", "", lines[0]) + squeezed = re.sub(r"\s", "", code) + if not any(assigned(f, code) + or ("," in f and re.search(r'[{=]"?' + re.escape(f) + r'"?[;}]', squeezed)) + for f in default_forms(default)): + problems.append(f"{o['name']}: default {default!r} is not the value assigned at {at}") + return problems + +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) + + options = load_table() + problems = check_names(options) + check_defaults(options) + page = render(options) + "\n" + + if args.check: + current = PAGE.read_text(encoding="utf-8") if PAGE.exists() else "" + if current != page: + problems.append(f"{PAGE.relative_to(REPO)} is stale; run docs/tools/gen_cli_reference.py") + for p in problems: + print(f"error: {p}", file=sys.stderr) + return 1 if problems else 0 + + for p in problems: + print(f"error: {p}", file=sys.stderr) + if problems: + return 1 + PAGE.write_text(page, encoding="utf-8", newline="\n") + print(f"wrote {PAGE.relative_to(REPO)} ({len(options)} options)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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/tests/python/test_docs_cli_reference_mechanics.py b/tests/python/test_docs_cli_reference_mechanics.py new file mode 100644 index 000000000..a242ae58a --- /dev/null +++ b/tests/python/test_docs_cli_reference_mechanics.py @@ -0,0 +1,63 @@ +"""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 value assigned on the line its `default_at` key +names, and every cited file and line range must exist. These tests change one entry at a time in +memory and expect the check to report it. +""" +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 From 3941234c199a64e6e38aa15ac83ed6b0e1f8b38d Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:21 -0700 Subject: [PATCH 04/23] docs(io): describe the supported formats, the output and the shared conventions Supported formats lists what each reader takes (TIFF, OME-TIFF, OME-Zarr, NIfTI, DICOM), how intensities become grey levels and which builds include which reader. The output page describes the columns, CSV, Arrow IPC and Parquet, and missing values. The conventions page collects what every family shares: the ROI and its bounding box, grey-level binning, 2D and 3D defaults, background, the intensity domain, units and anisotropy, constant ROIs and the entropy logarithm. --- docs/examples/conventions_example.py | 45 ++ docs/examples/output_columns.py | 42 ++ docs/source/conventions.rst | 598 +++++++++++++++++++++++++++ docs/source/output.rst | 203 +++++++++ docs/source/supported_formats.rst | 278 ++++++++----- 5 files changed, 1073 insertions(+), 93 deletions(-) create mode 100644 docs/examples/conventions_example.py create mode 100644 docs/examples/output_columns.py create mode 100644 docs/source/conventions.rst create mode 100644 docs/source/output.rst 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/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/source/conventions.rst b/docs/source/conventions.rst new file mode 100644 index 000000000..86fb56c54 --- /dev/null +++ b/docs/source/conventions.rst @@ -0,0 +1,598 @@ +.. _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 + * - :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; +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`). + + +.. _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 fix is coming. + - 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. ``--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 fix is coming. + * - 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 fix is coming + - 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. 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/output.rst b/docs/source/output.rst new file mode 100644 index 000000000..d6ba7ba9e --- /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/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 From 1ec703cf38ccb2e18d56ebaf19d2fd1d089238c6 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:22 -0700 Subject: [PATCH 08/23] test(docs): run every documentation example as part of the test suite test_2d_docs_examples_mechanics.py runs each script in docs/examples in a fresh directory and fails on a non-zero exit. Worked-example scripts assert every value their page quotes against docs/feature_extras.toml, which the harness hands them as JSON, so the scripts run on Python 3.10 without tomllib. check_test_names.py registers the dimension-agnostic docs tests. --- .../python/test_2d_docs_examples_mechanics.py | 89 +++++++++++++++++++ tests/vetting/check_test_names.py | 8 +- 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/python/test_2d_docs_examples_mechanics.py diff --git a/tests/python/test_2d_docs_examples_mechanics.py b/tests/python/test_2d_docs_examples_mechanics.py new file mode 100644 index 000000000..4daed8b05 --- /dev/null +++ b/tests/python/test_2d_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_2d_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_2d_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_2d_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_2d_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_2d_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/vetting/check_test_names.py b/tests/vetting/check_test_names.py index bdc794521..4be72f297 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,18 @@ "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_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 From 9d764c46cf5b0f6d7ceacd873727d32129196bf6 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:22 -0700 Subject: [PATCH 09/23] docs(features): rewrite the 2D intensity, histogram and image quality pages Each page follows the family template: how the values are computed, a worked example whose values a script checks, notation, settings with their real effect, output columns, the generated index, one entry per feature with the formula as the code computes it, possible issues with measured values, validation and references. The IBSI intensity histogram page is new. Known defects are stated as current behaviour with "a fix is coming". --- docs/examples/imq_worked_example.py | 147 +++ .../intensity_histogram_worked_example.py | 96 ++ docs/examples/intensity_worked_example.py | 120 +++ docs/source/Math/f_image_quality.rst | 557 +++++++++-- docs/source/Math/f_intensity_histogram.rst | 783 +++++++++++++++ docs/source/Math/f_pixel_intensity.rst | 938 ++++++++++++++++-- docs/source/Math/image_quality_index.inc | 42 + .../source/Math/intensity_histogram_index.inc | 242 +++++ docs/source/Math/intensity_index.inc | 197 ++++ 9 files changed, 2964 insertions(+), 158 deletions(-) create mode 100644 docs/examples/imq_worked_example.py create mode 100644 docs/examples/intensity_histogram_worked_example.py create mode 100644 docs/examples/intensity_worked_example.py create mode 100644 docs/source/Math/f_intensity_histogram.rst create mode 100644 docs/source/Math/image_quality_index.inc create mode 100644 docs/source/Math/intensity_histogram_index.inc create mode 100644 docs/source/Math/intensity_index.inc diff --git a/docs/examples/imq_worked_example.py b/docs/examples/imq_worked_example.py new file mode 100644 index 000000000..c2644edf9 --- /dev/null +++ b/docs/examples/imq_worked_example.py @@ -0,0 +1,147 @@ +# 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 +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_histogram_worked_example.py b/docs/examples/intensity_histogram_worked_example.py new file mode 100644 index 000000000..c60d0e8c6 --- /dev/null +++ b/docs/examples/intensity_histogram_worked_example.py @@ -0,0 +1,96 @@ +# 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 +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 +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..368d0e18e --- /dev/null +++ b/docs/examples/intensity_worked_example.py @@ -0,0 +1,120 @@ +# 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)) +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) +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/source/Math/f_image_quality.rst b/docs/source/Math/f_image_quality.rst index d09d298ec..2e5149bbd 100644 --- a/docs/source/Math/f_image_quality.rst +++ b/docs/source/Math/f_image_quality.rst @@ -1,153 +1,520 @@ -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 (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 fix is coming. 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: - I_m = \frac{1}{9}\sum_{j=-1}^1\sum_{i=-1}^1 \text{I}(x+i, x+j). +Minimum saturation +^^^^^^^^^^^^^^^^^^ -The :math:`\Delta`DoM is calculated separately for horizontal and vertical directions. The horizontal direction is calculated as, +:Code: ``MIN_SATURATION`` +:Range: :math:`[0, 1)`, fraction of pixels + +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 +^^^^^^^^^^^^^^^^^^^^ -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, +:Code: ``POWER_SPECTRUM_SLOPE`` +:Range: :math:`(-\infty, \infty)`, dimensionless +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, +**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, 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. 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_histogram.rst b/docs/source/Math/f_intensity_histogram.rst new file mode 100644 index 000000000..24a97788b --- /dev/null +++ b/docs/source/Math/f_intensity_histogram.rst @@ -0,0 +1,783 @@ +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. 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. + + +.. _intensity-histogram-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with the relative 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. 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_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. + +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. 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_pixel_intensity.rst b/docs/source/Math/f_pixel_intensity.rst index fcc7279b8..382f047bb 100644 --- a/docs/source/Math/f_pixel_intensity.rst +++ b/docs/source/Math/f_pixel_intensity.rst @@ -1,119 +1,931 @@ 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 (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 the relative 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`). 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: 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. 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, 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/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_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 From bcc73340d365584718161ed94bcae10643d3f932 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:23 -0700 Subject: [PATCH 10/23] docs(features): rewrite the 2D morphology, erosion, fractal and radial pages The morphology page covers its families (basic morphology, contour, convex hull, ellipse, circles, Feret, Martin, Nassenstein, chords, Euler, extrema, geodetic, ROI radius, neighbours) in the family template; erosion, fractal dimension and the radial distribution (new) get their own pages, each with a checked worked example. Images the new pages no longer use are removed. The comments of the scikit-image erosion oracle and its generator now describe the structuring element and the counting convention Nyxus uses. --- docs/examples/erosion_worked_example.py | 83 + docs/examples/fractal_worked_example.py | 161 ++ docs/examples/morphology_worked_example.py | 156 ++ docs/examples/radial_worked_example.py | 194 ++ docs/source/Math/basic_morphology_index.inc | 87 + docs/source/Math/bw_mask.png | Bin 3185 -> 0 bytes docs/source/Math/chords_index.inc | 92 + docs/source/Math/circles_index.inc | 27 + docs/source/Math/contour_index.inc | 47 + .../contour_tracing_moore_algorithm_1.jpg | Bin 41129 -> 0 bytes .../contour_tracing_moore_algorithm_2.jpg | Bin 23528 -> 0 bytes .../contour_tracing_moore_algorithm_3.jpg | Bin 86063 -> 0 bytes docs/source/Math/convex_hull_index.inc | 42 + docs/source/Math/ellipse_index.inc | 42 + docs/source/Math/erosion.jpg | Bin 65481 -> 0 bytes docs/source/Math/erosion2vanish.png | Bin 10612 -> 0 bytes docs/source/Math/erosion_index.inc | 22 + docs/source/Math/euler_index.inc | 17 + docs/source/Math/extrema_index.inc | 92 + docs/source/Math/f_erosion.rst | 337 +++- docs/source/Math/f_fractal_dimension.rst | 462 ++++- docs/source/Math/f_morphology.rst | 1590 ++++++++++++++--- docs/source/Math/f_radial_distribution.rst | 578 ++++++ docs/source/Math/feret_index.inc | 52 + docs/source/Math/fractal_index.inc | 22 + docs/source/Math/geodetic_index.inc | 22 + docs/source/Math/martin_index.inc | 42 + docs/source/Math/nassenstein_index.inc | 42 + docs/source/Math/neighbors_index.inc | 57 + docs/source/Math/radial_index.inc | 27 + docs/source/Math/roi_radius_index.inc | 27 + docs/source/Math/structuring_element.jpg | Bin 26090 -> 0 bytes docs/source/Math/structuring_element_2.jpg | Bin 13042 -> 0 bytes docs/source/Math/translation.jpg | Bin 42318 -> 0 bytes tests/test_2d_morphology_skimage.h | 5 +- .../vetting/oracles/gen_morphology_skimage.py | 9 +- 36 files changed, 3993 insertions(+), 341 deletions(-) create mode 100644 docs/examples/erosion_worked_example.py create mode 100644 docs/examples/fractal_worked_example.py create mode 100644 docs/examples/morphology_worked_example.py create mode 100644 docs/examples/radial_worked_example.py create mode 100644 docs/source/Math/basic_morphology_index.inc delete mode 100644 docs/source/Math/bw_mask.png create mode 100644 docs/source/Math/chords_index.inc create mode 100644 docs/source/Math/circles_index.inc create mode 100644 docs/source/Math/contour_index.inc delete mode 100644 docs/source/Math/contour_tracing_moore_algorithm_1.jpg delete mode 100644 docs/source/Math/contour_tracing_moore_algorithm_2.jpg delete mode 100644 docs/source/Math/contour_tracing_moore_algorithm_3.jpg create mode 100644 docs/source/Math/convex_hull_index.inc create mode 100644 docs/source/Math/ellipse_index.inc delete mode 100644 docs/source/Math/erosion.jpg delete mode 100644 docs/source/Math/erosion2vanish.png create mode 100644 docs/source/Math/erosion_index.inc create mode 100644 docs/source/Math/euler_index.inc create mode 100644 docs/source/Math/extrema_index.inc create mode 100644 docs/source/Math/f_radial_distribution.rst create mode 100644 docs/source/Math/feret_index.inc create mode 100644 docs/source/Math/fractal_index.inc create mode 100644 docs/source/Math/geodetic_index.inc create mode 100644 docs/source/Math/martin_index.inc create mode 100644 docs/source/Math/nassenstein_index.inc create mode 100644 docs/source/Math/neighbors_index.inc create mode 100644 docs/source/Math/radial_index.inc create mode 100644 docs/source/Math/roi_radius_index.inc delete mode 100644 docs/source/Math/structuring_element.jpg delete mode 100644 docs/source/Math/structuring_element_2.jpg delete mode 100644 docs/source/Math/translation.jpg diff --git a/docs/examples/erosion_worked_example.py b/docs/examples/erosion_worked_example.py new file mode 100644 index 000000000..e0eee2f43 --- /dev/null +++ b/docs/examples/erosion_worked_example.py @@ -0,0 +1,83 @@ +# 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 +assert erosions(np.fliplr(mask)) == 3, "mirrored worked example" +assert erosions(np.rot90(mask, 2)) == 3, "worked example rotated by 180 degrees" +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 +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))): + 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) + assert got["EROSIONS_2_VANISH"][0] == want, f"whole-slide {h}x{w}, constant={const}" diff --git a/docs/examples/fractal_worked_example.py b/docs/examples/fractal_worked_example.py new file mode 100644 index 000000000..3058d6a96 --- /dev/null +++ b/docs/examples/fractal_worked_example.py @@ -0,0 +1,161 @@ +# 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: an intensity of 4294967295 hides that pixel from the +# box count on ROIs whose padded side exceeds 32 +lone = np.zeros((80, 80), np.uint32) +lone[0:40, 0:40] = 1 +lone[70, 70] = 1 +for value, quoted in ((100, 1.4156), (4294967295, 1.4951)): + inten = lone * 100 + inten[70, 70] = value + check([Nyxus(F).featurize(inten, lone)["FRACT_DIM_BOXCOUNT"][0]], [quoted], f"lone pixel {value}") + +# 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}") +two_by_two = fd(np.ones((2, 2)))[0] +assert two_by_two == 0.0 and math.copysign(1.0, two_by_two) == -1.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") +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") +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") +check(from_files(np.ones((200, 200)), single_roi=True)[0][:2], [1.8703, 1.0], "whole slide") diff --git a/docs/examples/morphology_worked_example.py b/docs/examples/morphology_worked_example.py new file mode 100644 index 000000000..924f794b2 --- /dev/null +++ b/docs/examples/morphology_worked_example.py @@ -0,0 +1,156 @@ +# 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) +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 +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 +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 +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") diff --git a/docs/examples/radial_worked_example.py b/docs/examples/radial_worked_example.py new file mode 100644 index 000000000..1032f02a3 --- /dev/null +++ b/docs/examples/radial_worked_example.py @@ -0,0 +1,194 @@ +# 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) + +# 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) +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] 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 51bc20caa2b879edc048bc5c35f23d0e0129397e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3185 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 C1t{47 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 27a3ccfde61d238ee8790a9dc95e71396efa22b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41129 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 + - 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 4a4734ad0463abc69b02ec109a507f05107b20bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65481 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�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 diff --git a/docs/source/Math/erosion2vanish.png b/docs/source/Math/erosion2vanish.png deleted file mode 100644 index a8a719dcf56b174afe8115d0e3aacd6752e6b979..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10612 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 LP|CV;_woM%NheHx diff --git a/docs/source/Math/erosion_index.inc b/docs/source/Math/erosion_index.inc new file mode 100644 index 000000000..007f1e4aa --- /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 + - — + - scikit-image + - 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_erosion.rst b/docs/source/Math/f_erosion.rst index e190ef96f..a5154a51c 100644 --- a/docs/source/Math/f_erosion.rst +++ b/docs/source/Math/f_erosion.rst @@ -1,87 +1,324 @@ - 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 fix is coming. 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. 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 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 (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 the relative 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, 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 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. 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. 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 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. 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 +---------- + +The test suite checks ``EROSIONS_2_VANISH`` against scikit-image on its 26-pixel test shape, in a +7 × 6 box. The reference erodes with a 3 × 3 square until nothing is left and counts every +erosion; Nyxus erodes with a cross and counts one fewer. Both give 1 on that shape, so the test +pins the value, but it does not confirm the structuring element or 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..cacefb48b 100644 --- a/docs/source/Math/f_fractal_dimension.rst +++ b/docs/source/Math/f_fractal_dimension.rst @@ -1,45 +1,459 @@ - 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 (a fix is coming). 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 +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. ``FRACT_DIM_BOXCOUNT`` is that of a filled rectangle of the + image's size (1.8703 for 200 × 200), and ``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 the relative 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. Any ROI with :math:`L = 2`, such as a +2-pixel line or a 2 × 2 square, prints as ``-0``. + +**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. A fix is coming. 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 + In memory, on ROIs whose padded side :math:`S` exceeds 32, box counting marks each ROI pixel + with its intensity plus 1, computed in double precision and converted to a 32-bit unsigned + integer. For a pixel of intensity 4294967295 the sum is out of range and the result depends on + the compiler; on the builds tested it is 0, and the pixel is not counted. A 40 × 40 square with + one separate pixel reads 1.4156; with that pixel at intensity 4294967295 it reads 1.4951. Out of + core the pixel is counted, and the ROI reads 1.4156 at both intensities. + + +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_morphology.rst b/docs/source/Math/f_morphology.rst index 5af3dc465..de86aec70 100644 --- a/docs/source/Math/f_morphology.rst +++ b/docs/source/Math/f_morphology.rst @@ -1,310 +1,1378 @@ -.. 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; a fix is coming. 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. + + +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 three 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. + * - 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. + + +.. _morphology-index: + +Feature index +------------- + +*Validation* names the independent implementation each feature is checked against in the test +suite, with the relative 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. The pixel size given on the command line or in +Python does not reach the computation, so the value is 0 for every ROI. + + +.. _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 +""""""""""""""""" + +:Code: ``MASS_DISPLACEMENT`` +:Range: :math:`[0, \infty)`, pixels -Let :math:`l_G` - geodetic length, :math:`t_G` - thickness. Assuming +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 16, 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. 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. 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: + +.. 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, 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: 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; 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. ``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 16. + +Minimum enclosing circle + Truncated chord midpoints make the circle too large on some ROIs: 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. Martin and Nassenstein sample 0° to 170°. + +Neighbour statistics + Directions are averaged linearly, and the standard deviation of two directions is 0. 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. ``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`. + +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. ``PERIMETER``, the ``EDGE_*``, radius, + circularity and geodetic features describe that rectangle (``PERIMETER`` 224 for a 64 × 48 + image), and 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. +- **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_radial_distribution.rst b/docs/source/Math/f_radial_distribution.rst new file mode 100644 index 000000000..cb4cc3d6c --- /dev/null +++ b/docs/source/Math/f_radial_distribution.rst @@ -0,0 +1,578 @@ +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; + 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 (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. +- **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 the relative 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, 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 in memory and 2 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 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. + +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/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/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/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/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/structuring_element.jpg b/docs/source/Math/structuring_element.jpg deleted file mode 100644 index 82c93207fe428f3be08996507ca0b063e17b1af4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26090 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

{H{6s{3MIsaGjOS6koy({TJ=65yIrv_LD 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 diff --git a/docs/source/Math/translation.jpg b/docs/source/Math/translation.jpg deleted file mode 100644 index 9456ae592d3e74dbece31dcceb8e8868be230024..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42318 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 Date: Wed, 14 Oct 2026 18:04:23 -0700 Subject: [PATCH 11/23] docs(features): rewrite the moments, Zernike and Gabor pages The shape and intensity moments, the Zernike moments and the Gabor features get pages in the family template with checked worked examples. The Gabor figure is regenerated by docs/tools/gen_gabor_figure.py from the parameters the code uses. --- docs/examples/gabor_worked_example.py | 94 +++ docs/examples/moments_worked_example.py | 149 ++++ docs/examples/zernike_worked_example.py | 143 ++++ docs/source/Math/f_2dmoments.rst | 799 +++++++++++++++---- docs/source/Math/f_gabor.rst | 493 +++++++++--- docs/source/Math/f_zernike.rst | 466 ++++++++++- docs/source/Math/gabor_gamma.png | Bin 396052 -> 53982 bytes docs/source/Math/gabor_index.inc | 17 + docs/source/Math/intensity_moments_index.inc | 462 +++++++++++ docs/source/Math/shape_moments_index.inc | 462 +++++++++++ docs/source/Math/zernike_index.inc | 17 + docs/tools/gen_gabor_figure.py | 38 + 12 files changed, 2874 insertions(+), 266 deletions(-) create mode 100644 docs/examples/gabor_worked_example.py create mode 100644 docs/examples/moments_worked_example.py create mode 100644 docs/examples/zernike_worked_example.py create mode 100644 docs/source/Math/gabor_index.inc create mode 100644 docs/source/Math/intensity_moments_index.inc create mode 100644 docs/source/Math/shape_moments_index.inc create mode 100644 docs/source/Math/zernike_index.inc create mode 100644 docs/tools/gen_gabor_figure.py diff --git a/docs/examples/gabor_worked_example.py b/docs/examples/gabor_worked_example.py new file mode 100644 index 000000000..efc8acfcb --- /dev/null +++ b/docs/examples/gabor_worked_example.py @@ -0,0 +1,94 @@ +# 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 +assert f"{float(np.float32(0.1)):.9f}" == "0.100000001" + +# 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 +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/moments_worked_example.py b/docs/examples/moments_worked_example.py new file mode 100644 index 000000000..fa07dd4a6 --- /dev/null +++ b/docs/examples/moments_worked_example.py @@ -0,0 +1,149 @@ +# 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 +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:] +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/zernike_worked_example.py b/docs/examples/zernike_worked_example.py new file mode 100644 index 000000000..d9ef8e33e --- /dev/null +++ b/docs/examples/zernike_worked_example.py @@ -0,0 +1,143 @@ +# 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-16 + +# 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)) +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/source/Math/f_2dmoments.rst b/docs/source/Math/f_2dmoments.rst index a0b0275d3..7cf6b4663 100644 --- a/docs/source/Math/f_2dmoments.rst +++ b/docs/source/Math/f_2dmoments.rst @@ -1,206 +1,735 @@ +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 fix is coming. 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 (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: 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 the relative 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 or next to the contour count negatively, 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. 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. 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_gabor.rst b/docs/source/Math/f_gabor.rst index 53820d630..6c6cd5be8 100644 --- a/docs/source/Math/f_gabor.rst +++ b/docs/source/Math/f_gabor.rst @@ -1,143 +1,440 @@ 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 (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`; a positive integer. An odd size shifts the response by one pixel. + * - 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 the relative 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. 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. 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; and it has no guard for a constant baseline: 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). ROIs + whose padded image times the number of filters exceeds cuFFT's limit of :math:`2^{27}` values + are refused. 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_zernike.rst b/docs/source/Math/f_zernike.rst index b424b7c48..a25bf6f83 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 below :math:`10^{-16}`. + +.. 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 the relative 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`. 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/gabor_gamma.png b/docs/source/Math/gabor_gamma.png index 2e292f5dc6446c8bc7b3428c7be9e9827e1055a4..ceae67bfd99a97cd17b7e11b847a93b91320f167 100644 GIT binary patch literal 53982 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®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 AZvX%Q literal 396052 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Π@NZGE@= z!mCndNj}%&ij-Cj-uBg~+`_&>u>i~V!QOYyRv}~3x(gwFtQ)>N{Z*E&#pdj_6W48- zEO#u&6g*>Z^gI~<;0V({NcJ4@lVkxeQ|N)o>+@q8;HR$$q@lwC?R$m>xnoyjSNzSK zIR`n1zo3xsD2ALVHk@NI$EcPUsLf>^2%IhHQqxg?!g}C(U>SNSa?~V(eig6^6UXVyI9bs;)UK!kwMoo@U<&sRB9)uu<93v}=n zP?M7i)INb2ymH&}NXeQud{10f?H&n)G{Ja!5$(^zAvs2y9vB&vvw(LMe52Zrw}wCx z6O<8W`!m67W921PRjv@Ub09~Bt^pu0Qg5~CQa3OMA3loQc6bF1_0G@Ny+w!a%3MxV z`7LQ|0P%We_t4U!m!VB^ZK2CxXS`>YqqVY96SE8~g7krBMq={CK*ur@CH}R$@;m^N zo=@_YN5OI8`%OjiD=K+V{~6%TK-1xYywofu0w*VS>*_?|G0@RZHG?b`OMAY9LE`_+ z)9009W*(jb%arkfuEZT9u)r!j({E1Cp}Pz2)#LiKC?eU7{a2V!bM zDQH3Azen)@Uvf7f=PD~-gP3!fMwXUY`G2%U)#|`8{_`<;3nXKIQf7z;N5-TY;)rwW zIn^DICYLX=oxvyS;jibJQ&@U}{i0!qHsgK@2lPg}&w@t-HadFNXtEX#j2--nXQ2r% zLob>q0jY=Mh+8{Zgndint{CQ3ex;pT%QXt`ab%;nwK1W|kFJrVAHH|4`$caTBZ)ow4{Fwapsmz5+e z*%IWmhyvT4dR{yszi!48Kc(>mzrX7?Tph;yPCmGOZ54P!G+YbRfMcV<-oU2#5||m8 zJnsI)4R#HAi0|>Pib{-|&^idjs{;R97Rxc9AIYo$o6OO<1;@T#llOoqE&!&GE?{N} zV|3yZJ72T|bRL3x&=A(YY6aVXPK%<7d7+jHsubv5rsV)pk(9oVAF6hlKe3_$vCpu# zW$3*gzDO!bCx2PnbqM0qvscyEEJN-cz5yl&K*0adT1U8a{n22zjWUI?20O)N`mRYn zodfkAP{&ao4!;-_+bmOGTXPQpktrBpzWm3@A>Lw+-~PcVoN_)1B^++8!R~ZP z)_T_y$^ED=3dvy-II{I&S?$^GXnY7Y!>n3RAN$nI`%J@;aO&H$LkeCJ*PKl zDi`%*-ObR3K|qO;3FS}u_rwd&#DgC$=b}>h0QeLvvZbuWqyrN^K{_7bRrWy;i;^TA z+d~~}8o?DeqDnY`0@0GC-bs5wx#S-t+I{wlWS#GCQ-v70vSrG|X~nYkr3Ewlhnif$ zT}5l;npAtCN=Y1WP1e0aI$7uGg{V@LwgkWC+}^gcFDed`{{-B$)rS zq?i~&x2Ge#XQn4|b!B)=^+#|Q^r}%A1C?GXB}yFyeOYSd&n*QB2JPI+rK~#$yI~}l zk2sVk)N}g_F*}N_>@Wo$RFKl__NcQV7-)-mmU8oALeibg=pOK2eef%Y9Tw2H*F#tu!+vUNRqCJ3C+24U- zpa3vSIyY*O#n6OfwzE5Dk_aS zb@T1{unxw{wRcXpz<73Y`;qZgG8$HIo(;Q}%|OdXNqIRa%skX*htG!TNaGBR0?vk_ zjoiSgm0M}O3SLYTsxDA>7jEg#sa%4>8NwLvzMDb3hAINJ+4tNm?(^WC5t(~avUzA; zDpDT;et^x(HAFFlpSsXJ0d$p+KY|MX^1bj#$svW>FaX{7`5+2az_Ve)X)Vxh)Q3{& zyU%%ZA{7VuV8EGkX98`7kpqv{$bgS&!V;sufZAcsUEb6Jw7!(J`cinI~DrlO}C&Bk12%s8z_0hVMDUs zW<#=2R|eV*-Wti)OzNLIbJuL?e{Nmumt{qn|EPD@k-A*S8XO@upkFy&K&5ZLERJn{ zcRA9swMh~C?$}?#De%PiQ`7hW@E`k z%_={bW(W4BspMesjHRo>24mifJ8hvm`KAEv0Pd%M9@c4jj6*JxEk|9|$?7Ylm+>J!(=mX-P*5~OE z4t4-O20DJ4=V-*Iu+PKlE0(gsR+ra%$G~yZ3#dHV{Z8I^m+!smMc|ie4qn3XgaVDM z0r=1Cx#C}*R-z6lkCXV{ofF!b2(@W0XZ`@tV^ zPoCuO|7jWmoeJ_G_AW3ny0)QRT2Cd}tY#WxDOYmGF^Xf&)TzorB8YkTVgDz~7NhzR zk+b!*=Vb)(pbaoryWS5spC~rjI$9CH9D8^(_kD&w7sjA^|MLo;vXcZBWgY`qK?;*) zAZ6<{fEM`!=#;tl4~z@?pD%Ixjxs=qB7hEpQtxdvvduTRfaj#ir~_5-ZdXi=)vW9V ze4YLKctP4(lhu_cxKfjh-XBri*yDNbmk7ZCU$M^|FV(ceO6AF7I4av2;qjp>76++E z?1-y6Db4V#5vjZd?X7MtxH~Ndq7oMt+ID-76w6v`!2r``Qr>h60K(WHi8AO?2nm|# zJneL**->dz00^V=EFspd?#o^wkszlklsI`my(^jmSIl~`=dZ66*bWX$I;-KI;X* z1Ts_gL2m;f0ENqA4)&1{X!4EoqJ(sy*k~=c)|fOXOz%G}KN#%NSY5~^wf=(GuEiDj zztY;-2N4{LJM6dcO=f`s z(;5m_%>x7!%nD6F#jDC`y;X^NQ znx|2BU8%oaai@d&mwxbgVPT$pR_D<-_$~&u^OIosqZZ^tgAT%c@$9A^) zU|7d|NzKY)4*R5nNJvSTA=YeJU0lp>06Nf2UBkm>zBA3i8qTI?-nKhS`)868St1jG&P0odZCi{_*YM_?Tj@>Z+ZXo6@? z@bwxjnu8W`KvkFp#&1`{*uo`ggw%QA&4b0ueOE?y&4l%wi@xGJ<86Ufud+xk8!upG zwnc1bcX)i9x)Nb$Cz69m0TF~gOKqeRyd|RBM* zT{?U9T}ze(TtkkLS6RlgHznGbIvkaL-7r$GwwoBYAmXNK9@}v1{bExd% zr*G%l9)MZzf45KjHc9a%1R-DqP zbPk~5*#q@BT;aRhXwu&7$K{Ourp&xrcA03CE@#&CsiIqHjl}fJ;mXZcA@%;6BTezF$gE%xA!Bea1v#|(!gJC_!*6$btlV!eKIPBg~azT_iY?R3c@Z&!u zklC-^e^A^QEsS_4U~hZ{@bS?jjUXMhvC0|}_sm#iSLFGgVqMJ4w~*2)!GoU8`XzYe z4SgX3zLV~Q)%_^E@@}Z4ocKZvWsiu2H9$G#P6NrbFbW9j6_MnAA{hn1Z`(-0FaoPGC$c(h#M$~^wdCSM$Karr6uUj>??+sftE4S(I z+IrlF3%`aNGyzS3h<-!A9V0hCve;vDAbWpB3nEFJi>wAL72P{!_ z0mt=~1PA}Z-)(JXWtEyG8wiJ`H^J0duD$(>L$UhJ!z+zqP|n`jLDc6Z?s)umThy0R zNR*XQ%CW|&?7@WjgK|=W20cz=YKZ-9cq{2F;2}BsOd;U%A^$nnyymJxd;H^5&D-;; zytn2xh6jrS#43^Ab;zbsR%J6VgEgOMs^Pu&5J@}~GTv^oE%Y6%$YB}QfvWT?2F=g0 z2GL_6-rm34cx9cb+4~S-WT5X1D&wSBHO~%0zXEwM##ITrcnps0OtAc8Zu9T&rw|{O z^JU-ulN--xkbj;BASvEgfQSoQZTjqjV+B7p7c3Utc$Ic~3&El{N_uSp@qxCjc5itX zBXJ}RJ|?%aAZ8%Dr_>BM4#dbYN1eiVvTL5G%FX5eOZp{Lk$-(i`(TP`*oeMj1vV&% zdXK#5+W}xba zhe+`5Ir{}`78J`2r>xwKN4y(5Kgi3pRhmI17f0FnCKL_IFIkNhSPsdOEt=G9W(BQ; z?)+4LW8szs4s=V9)C+7(4KB@665KYJfXCF?*%z9$#Q5+<+3Ib1 zfDnHvS~eeLXUJ(l<}X|}8T&Ka+XpCR-%MLBE6^v4fR*pApw%7U0S)b$LDurfRdhm; z@jx2Po{kZ$Awg6H?PuqDEp6l7qZ%B=&d&HjedK(-Y^)jCC=BKgdI-%Phu*bB@nC>6 z9Q-bf%Y&G#(ZD?KL@E_P*8$b4R>73Uc~_`*`U+@>o~Pde;I}wJsv`1=fnn7*Fl`zE zS?hOzQpue~{K}Tjq&}^;HEDuUvF5e&zwYRZKfYK|-WRy)cPPej)Kc2>rsCA-hx}tQ zH#S=6jmc27AoF zF#`}G>ZG-yfcN@rYxi)=Cx`H`#6FPldugtZ0)54JSRk5in z{N*II3@sl~A$a2fM2gEpmN%NLES?Mp7;~|a!)#O!0k%tv zhC=s-+^=X?ntDb`V9f%hZh*CU)!OqWGY%jV(ruO_m8~q5Z0Ti8^pS}86fuvsjTw$h z@FgZZ72{vrD0BswNrCN1imG9Ok*UZsW7L4>wP981z$?LE@om31n5Rv`STk7baSkz4 z_~1|q0BV3r{A=|IV!2;e++_uDxYURr0KT2O)KV~vFXaWFnnEIh^qa>?1lkZGERP|$ z{~C}G7f_{3eN=lU&P;zopoED9rD5HiU%DFa8^|wW8gJ5um5}h1yVy8pYe^3+DBG<) z5ar~s(}(fI-)EXRItf&(Vig7tK$vP9p0_Bqnt8`Jlqb24DAqG|P7l$XE{Q4*FVe{x zTIb`}F6hA9>&Hc8>dA(*=&G$5`(zk*ovyWP@P&Q2k4*%qm- z<)P=hzrKTZx?jn>jn{On;P;gwMAp})w*jC8?tkR`DitPWt{|S(67(|053a%5K7P}D zv^yo{506Z-?uejA`K_lKz~IS~*Cu3ypM-7F&}-eAyPqAVWtQM7=9~UdddHuLqp>AN zt}gAoc^SyTe;)h|iKLdaBdMQ2a@*7q$pCvVI^bw+5W79!Ib3BQ|yzznHeGL(i=L?Sn+6r_jQs4A^ zQw`~d_f}V}B%$_X8hFMOH2~HCv(01;rep;u)KT{n5>oStiwI$<04^daQchJFY;SKL z4g_gR&bt;?VEQJ1jXs@pWdJl)%s1V7>X&xHKRL-W+>&|8-KFbGv|%Q9T`nP}1Ho{5 z#yaO_B5_kD8ffn`a{L0SMN1G43(}N8v(&8b^=nwefsYSxs;Lnd*t}_oN&b#o_*qaG zkE4GuB^Y@4Y$=K(B#>u~X*R0+(!_?zFpIaWRB;4{*Kix=~ z{V%Qh(?ZBM2sDIyW=q~UPh8_=)NW{JQBK|D+$bsUEhn9ibi{u%tcbqcEF%jrF{GLe zG3YSWx?enB1W^wz?5O4L?zFo$n*@$?lYhU?O){@V$267Zkb} zOVdv<;_K@0CV_?}X5A9kP2!X5(VIG#Yr(RSh`L6!!)^N4a0^a;rl}N3m&Lm5aHnR_ zSV{O;hD)f_>6{wh_4hu$;T0!C2yBgpFLHx-r-|(K(RAIGtB8~+_#^#Hh)&vDo_n&DWaY9`+%mpaC>|iYl@gyH4ZOOOy+4{SK<9K(l~!Sw}s(3ExWr$ZEi|W`?@h z`ZGx@9D^9c2xdK8>exj7!~Oi9B=+x4$6~Z&>w{2qT3>|M1B0n zuMMs=28}H&4Kh~*LHofKq>*?z-0HQ<=P}{sLCfa5E52M+CKjI|iH9{i=|E1>_nSLY zKnrH=nQSn=$oKfiPCq3Q`Yr*wZ6KYT&$&p~>Dlkbt4ST&=VPLPCmUYe#&dFI|X`(;MK8=Jk``ZEJNEMu%u zJcuuPvPAh?Fv$^H@8xg8ockp`mq9c)VjfWov+n@q)fRil+e|@_&@-&*o%%nnWEPp? zwBdUNNfKp{=JyFZN%D_)f(Dpg3hSL=+!kn9Au`H^!|*>33FSK<*=)k;G-Q!dE~os^ zvPnUOKl-J$g;izV&~DTG1f_HbOZONSa!gLgPT_69n30Z!Uv=IHM!;C5POo#$<9Llg zI7rlkSy#mz6rF!xw$ms^>B(liLtuGy8S#(hFE5~-d83d)@zB;|&(BUsVJMbdEX&bs ztmpIQwwO~CE-X>230=<$k^b}l$UWH&N1U_Q-pMVRrBxmHg)V{9&YAo+!14uIJC@Up zAmiX!F#Iey?2m%8^gQMo!8R%9hn-%DzkY_CLqBzM18pTJcO=)1ahRGPZ;XH*>UKiE zX%@}1+E@?3q^AOLg zQk=h2iczt?#-A<(bj1iYtC!EVwN5xB4xN)AmKr8W=>AbgzPr=yT>sp=<*z97&6|@` z<%RID2M{ayrTQvOu=1$oL2Q%eY;MYOrd5-t?}fB`m>&iO?W4P#oXyOcGu;!lWFmD0Z+Nnsx=oApT?OnJb+=LOBUoLRYs!`A zp3^;Gg^@CAe&R0BiD{X6W@q>DEM48m3UcWKLi%<{fOH2mLh$E*T)Vxc(%y|TRtx!v zUObW(laCcBwJl`I?`BmHf}nbo&e}nGFv+c4t&Vyy(fRr%gpd1bAj3FJH&C^J^iEC6 z(Fp8j54@aF&!`3(K2qiXCKw9D?AJAKQ!l399U;xq@5fvEGN)Ryl1;Fr!~-TYmbdvt zZf59+GZ_S;nKv>wu!jSO{nne>f@qw8vFAD#VpVxOhc~0EtSyMD<-$?vNa+(U z0MwvAa0Z0j&)EFf_tuwsJBaqX2LhknaxNoeqU!5X1Z!Hwb=_K0jTcEIpg3 zr4YD}=k_<*X23&{^c4#h#a~z0_azcW1VN=Lga-+0h!r6dg0R_7r9r((>QS2wh>H4q zt-OfDEd9M+3d(d|8U{d?0Elr4m~6CB$vQ7DZBY@$5g9H-g;*bV_VK@^4>T=y#kw>^ zas1Ck8d7XBP#dP8_WWMg6wrkXrpeI)R6KtM{GkTl9Z_DM^>bAamwg#zuQQzpgNkDv_y!(|{eCknh@EB1~*u zK^E!U8(Ft#dHizZWr$|mKw6OTZ+tKkBF5Fc#3G>7!b|Ly9uhk5(nZ@tsV`aD5nl960X3OR9$M7EupB>+E8z#aC}o?)^@nN*`!R8=HRV zUcf2VKXU##d-v=f^3*}zgN(Dij4I5np{IhhS`>5J84R~}*d!E5gvCXDfohk$FOYFN zFYGu-ER#{FW+F{Z&2Vm;X-_(J4Gm_S<9!=RDn7(7qBWq@M;F83a$bq{sjI6q-7LCo z`MB({;6KGE5aKJQYlrW>mgJ_{ufbumIX(ETG3`Nf98cHIx5h6CM*6KppfD>E>2UBJJ=ny){HC7Ugvnj*(fn_#}q==~891tK=P5AL+hzJIQg#(X9FqzlP z&d4Yb0Xz=62f&HS7f@$16lv3mvp5jTs*)nrG~8XtCcy(a<7r=XuAPH}2CY`JZ+F*` z>U{Ijt&OTi+n9!50;1+S8It4y|52QlA1-Fp3qGhH1^&HNKuA>JKPT!ugfuP50c~{& zj%A8zmUUVy?9E!1{1!O~nOjxf$xv~+krn8-GTYehy{yCrd3kw9X~J7ssz*=bcLsoKqT7bQcB$BC zT&(s{k#;!%11f?E_|$!Dqtq*Jr*!fac@LTEuk}nl5uixp)pbp?81U?+vd`hfc1Pn6!wok?rnWwV}Z zimAQy_eZ_fd*mNh_tHPK0G|*~ejda%n)bfnoHb@Rw)bgG-+%230RW4!f zd-~S^TL*9nmUW8QW)i78g_CM7=2^{4n=+1Tf{dwq3SL1@YuHSHmbX_}Xy=ICp^g}L zOL^6;;MKlCaq0B?^8zDBg^FwAM;*5)*qRWdg5;?MZRWd~lxt0@Dp^~__lgz9kVQs5 z*~0}M`WF6R?G~iIQF`0FVeoTdQ>*TdaPsDd<2+esFugN;<3=#>KRZD0&*;TL&VRt& z*acXT6^c~>f$6jMwd0^x$POyuyo_-F{x3fXp))&sdtg6n2EhLSvZbPFuLP{Njt{oV z%gcdY>IEgL6`=C#ftdU<&?W{8EJ(UHuyg&5;8ntv8i!#3F(es%|lZe1&*H}Q<()=SdpI=x#bi-r?P2c;R=z`#UwV0n~3$P zdoy6D?T7pVpF=-Uqf80591?Jh#AI6o@Shh(ca4k6)&*Sh;{ig@DRlAxH3b+EL>m|e zT?)d9CwRffgL;#>q2SA|NZ{+q=xdT?Gt;D|C&h}PW~Ufy;@_n~W&glQ!q|azR8@jD zor*3dHEsVl|LI2184VMC^UGgMbjfckBvV6lsePCCT-WyUcevXC>3N-7TE}zd2KF~n z{EqHsecyX8B~dvh(YZD`mBYD$?~8g8*84WIs)}?xH24&9*~s|Ra<}gAmFE%DI2RGq zP#$YYUnexGEZUXjY0KN!+YLFpayID3=bu;W8`~lk%?Y~Cv}bldCsmu5M}Kl!(%-2* z?8_Nl0`wwS@=9+LNH5p%2m@DOe1^GfSN)$Fp-6VkMv7E2hy_h#-Y;7oS&4UR)yJ%l z6%M@Or#G}9h>;-)6xl5YnyW*8BD72@r}OwW*FW*J`Mo$Zlfoam9t+YtAH({XoUDEO&O3jKRNy_7$@Lz2nnW(=8a*- z7{w&jH2A#1BE~wEBGClrcNRd27@`Iq8wkXScQ^U2p&Q3_9-w`>o|{ z-zkom6PQRMo#y*4H{pzO={JZ!A-!&E(SH4X?qw2Z0qT4yf*2BV%Fdg6WmvrOT9T6| zEp4LWSgT6{3DdLyewgLRYc}rdKF%*|4Ukp~3&Qhhd#qObH|3NA1f(ETOmg=;rjFye zK}rjKEbM$#C1kYl2p|-s^i1{U8JG1kvRj?XQkI64c81;OpyC$c#9y9iK^CO}FAuT? ziyO?5f~DbbYqHE0vr^4~;_&8oWtp!1YKXM4$5mX82)Xg@-%iHP3YFK8_9MKeu!1%< zobe4;67K2BCAB>Z@l0pL!8m@VeK&p!dCude%`5_CHh#Mxn45m=dF%m$HNZawp1rFq z!IEGl`)^X^Yrx>siYNKF`fTrPbO}hHQ^19I-vVFaVO#uj2W=>jUg?nO_Xs+VULO+-~)1xkesh8m{teFY?`y5@gdZ} zaBwzA(+aUXT$t!T*VsBI7Unn527?%loc#bzsib25^!*dq_~Xxfh{n+ap{|3M%e3YC zD&w)Hq6n`sXxUS=G!ry`R#G$+X7~F|m&OM!ztaqKTY`sZ*^{#jZPOBf{_C-{99DHG z8Se$n_{Z|R2?DB}w{@3Kz7R&zJ$7-*Tfd?R-4R#_#BItmMJ_gLZ1?w?|&PIVXkab8!xW?H!w^F%Mhi zIufUxrUo&=sjHc9HMoLn4VqLWr$KBXAJ_81F@oN`$SAf@7{X+{x)hGDMw4meh^F0r z4CSL9HR(ja=E2yU{x=s7H&6}0BOC$C7?_+u8AAogX9LapLMq3Ff%DEdtC_|8e-h zuYn{k$_79^n8H7J3;IB+WjyuZIT$Qa1&U1bk0IoQs`qh)x zO9BD!ex}F*CX@~uT@VI=lJvJf!5e^6wH&S*r@OTp{hWTB>aC7|xAK0pa`g4%WqFN^ zz7%JljGFPpnWnsblZoW!7;Y93ThUQ4|9F;5H(L6W$co?i8rP+Q29S@Lrkaey-^>&% z5Xr%s1KpWzrW>w75EX>(@%~2mC#1wmQ&IRE4|U1Xu5`Ll!rf3$TVPG1rq`bduq&LF zQFNo7LquKA-rN(F6mhYwez4aAFz-oON0W|jLVmJXsTHY*j2*~{c9gsaB>iaVm;%LdAOKvne0C#Sy=W1l)+SUFxPa;>;`KeiGwjQEfEe$tAy43aoW{?;8(KOY2D z4iAOQjhK368O|7(PsrF>C|c8VcMS!`R=;2Opb6+!Ht4fBwib37Njo3@Qs#e+0KU;9 zmDIq5qLP~X%^eAiQ;>dVw6dHLdV1wX<)&klY(@djMOmsUD@^s>yTBhtbE17BQQ#K2RS>Id&-(lrj$IJh?7z? z)uGNxyc(OkOZG~8z-_ir+;;95G(8FZF>9lYZ~%O^!*j)RW#r|v*V~W25U)nW29Wc? zSrK)5_%s5Uy{U3gc>`+pG#g-r61G_l-6zVbm>#h!ipxCa+C$N+B>Ckk2W7!8% z5nH|wlY7rXzyEB0!3HYxA7={wb5{O#q#6 z;MENdUAnxs>wY~=Ht=%>ZuG#US_6=#fvJ4i_kvcN15oFlWjjH!Xw$0B^V+K29XlWu zpUR}P-)bIn(jBga3LUX3Z%e_=di~=|+H>&lY6!athyZXp;(KzkAz%gjld)KtnUzg> zQ;sefMrb0AR!a7kGG{^7gnVx41rJ`D&$a^jfwklIwbB^y80phLvz&7UTc8GWWvBpE zRdqV$LsPYsUEZIgeFi+e}f_S6(-|wuD)n=;6~;Ueh24a&|a}a zC*Le=*L6-!#gc5)p-i98q}-z1dL?)#v;J;t&KAfa7%P(+<~N0TqVnco@ve z^y91lw@T4qxD45N5zX&YfzkoA_1lB0!@je)O1I#=jIy;eH-5I=L2B*1AG~9!|NpKgL>4m}+ZOnL*H*cjO$ z4U`^JbBQEk?YZ9lDFQb~0P(YHImtz}j0TOi(N-ys9m6}F7MFPhOS$*6CckjL zjxkmCFhP1SUE92I$(F3(>xVtgo@3lDMVzh%?|hfq4?OUh9kjI{ zPql1r``|{w?zTVds_=Ijm+}o(Zyw$Gr3S1*9;nOWC3ZS&zTSC7c5slu;Bbv@^)<5!-ZAMFD+ea)QnphO z?`j1WhvSH)VWpk1?=KP8j49nDqH5sC7RY{jnOTxx%fj zxHJCBY_)=UC^E@RZML$=YKt&#fZHz5gMlIS?0BvJF+522=xN-JQB}>=_iw36%~Ja9 z{4yl(Y8IfSv{ZzA*kWgwoWvI#|4J;f)5~00WYSdOD(j%mF@I0jlVE-uuJl5M)hRvX z@ltQ5>(@>(`>&cEgqzqeQ@~$=eFgVoMSmZ@Sw!DU&1dL^XHH*2%CZVmgPanuQtj>6 z`=Zb%61`KAq;sL~PSoexFiog5`Ovt@%Rt`eWzGVhfZ@)*`r6Blb|Cpy>!GS#7LCGXPO&quH`4$5sG8A#vJ3L44)FhRJy1ej# zD?xlRG)-}*Pu+Dk&;uWRAhd#fc#Ba`P!atJ`Byrkw~D&McJbTuEaQ~$xQE*t;roi; z!_*Gao-ckuK1{-iT73E3+SuuS`rGZo@0$uQsQd41BS}ayqg%Y#W3LaDfbAyZpe?=ZIJ44o|Prnd_k+IA`15ItfC*NC} zh^t})M(C$dg>+7Ckm1s<;qp1s^!M#oXfnCW?rp{BSHL^7_GNc3lb9v=#LrH-Z!hKg zrM*nQ)ykm6k$hh(Tlz9h${%;udGGqAb4`>nBhfb72}d)&tK#%!l1TKp9rOitU7X_& z`yT!-m#v02!|`!+D&N4sZ;dVmJfZoId3WtQEN%AlfH0>I8Fypsb>Ir60=IF`D;a;= zlBa#L7)VNHP{tC6zI*0NP+;2Nf;^aoK~=glYa*Gw5?55IIxb4Lw3 zgNZV;Of6T{M&NcCaE=f>Oca!dV43bcb!3Cmlhm>XfFar%!9sKU_7G6jDNqkB0Af)< z=SYfYdNj6)efQ}D zrFyVL^fwv>v3lS3{c0<6%=otVaBJoCm*5qH1visg!32X-7~R$y-8VN8MjM;?3B`PV z+uH4DFB9Ak`&W$wXur9U{yg@VOi9C^c8>by7Vy{ckwh`eda&#_w?wmz+BTUh0#V;4 z_J37J7@XEJZ4o^D=BD2oqtq&GV8y#tt~#9^qwGrm)EE=3diYo(N2(BcNeq%ud^{_e zp=R2Fgr7a>KRa?e>p02Ynf7N%8L;b1Z?p-G%T6xhR)e$^vNg-)^?;Ml3Mb%h@g36q z_R5S=_{%|qZ7c+C?}1>NsBMc0Gi5GHjKi4@#w;7-k6e%`p}&0j=fEgdR-G22m^Ve7%dvvLxxHD^9MSoz!qyhIWI_tlg7uDep`)S9F$> zthO^UPUr4+DtL*2Lk|;zy@3f8F)mu-4P4i|CxL}_TDUH9w;u8q1lFz(+1|~57KhAS zLkDwpJ>D8LgE_?KWNS9#Slf0$HP-?`AQVy^fvhg$lwKaB-vG}$zTA{tPnUo=%a}aT z3w+(4D(X+_Vw8zbQ!E%2XFn9=x{{I8)6P|WAT52S36xfh1&`Fug~@~hq3Ia#vx9*n zlB-(}Jm-pmA5%0KGgcj~N{WJL1(}*CNkd~*x1ZBDeAZm|$|WpDidQ;@9l=Xggg{3% z?54<$UIQTQw>U@nxA5fbdw(~`WMROmkf>oN4|NOFF9P(h?d3>w?*H{B(v;ojN z`SeTegM(=oElsJ{b&fW!4V3QcYwJu-jBn2UtU1cyB2t>y)2yy1N&kJ_%$Ld|Voz8v z6(}EW?d@qw{P4HGeG2HNEPHEow zOFKnj;)RM(+2vR2is?nYK{6&YeIf0$_RNW`G;wprWs6-qpUsd?zfS)8x|m*^TChz{ zc1LSqU*f@$YKk~6U7253-v^mx`zzUa3qK_R+A8c*j>c2C?h6%mr`OfInfCS)Ihx9? zTWt19WDgE#^*?w2WD!()!IxT}+Fv-6;GtH(GyKY9rNIG@dt^U2iDz`pH_7^o4~?ti z$>Gj|{~ToTbiC2*ur?lB^^wzRwA8SbRK;u!lG>_vt3GEi$BdD(b{z&pZ?C-s&$b}W z`YjY&m!PDbBP=Ye{=XDwbntWAI}F~?$3l>t7%(Rxkzz4G$h3nQ>Stx2^JgCJ~u&l}GrT&2mZ?FwWThVoV&Fg7fJC zc}pHX6~XB1`L>eTuKoF;rf=Sx{zmsc&ENlI^x%`xy_V*YH#YO%K7C7h{OJMFrxd(T z<4NP>&DR`dyI^=SjXpmm;TE21v-E0=~xl;ka?VvI)5;i!E#dU zf;D2hnBR`E53Jb3WnXl_v%tO<@v1!2d2}P~`LLedqR*!fn4l)&C<=E8sh)o(P+tLY`{M7c-cnY5 zlXK>)=XefdQJK5nz8K)FM(HlaWjxY_H`>tpffCmv!lfEv*E=)o<702j--Tz`<+3iq z^^+KGU;V}0^YfS$>*YIJNP{x2ShS4O^yl>+e4&)`k!j&qB;d!45|1g#+SSA5IsrU- zikQG*qCnhXvC(G|Dp}hvg)p0LoZ{f~g#YONLRa;!+T^pFyW<}nz>0D}OLD@<4>ozh zSOMqV-J3iz&$l0#Ob(PeCvcp{((lC^MfOHNxg5)4WclUV)zwfvAzV5b*lqIk_Y@a!C7ZEgZD&g0p29jBX{S_fNja6Tft-slqD z8u&i%_1O@+QOcyyGse^J1q!ZbF><6p^3l+rCX`F=_5P)_ymMM$mK;aATk8N#jc z3W~5KD&vO4t5%YgBjvUvcMo9?mTNAy?)@)a!8Hgd^OT`U9Ai)Q36# zxWmOnuj<}D%e3TAN5;L4N4>P&wmbNO*ysYlTJF0)g<`M9xvtodr1=F!>hBBnGLj$L zuE`fjlTvVqByeB@Duw7s?`r2Dbj14%Es0s```fVBVAS!b0!~>4RqZ5BKc*I2oCgQA z-rC_4Yztl27vCwHh`YSne#d#NAR*-bGkS+@_`RScs$o)gmsw9|B<=g_&a( z#{9fTD7lYGmT>Yvap<5S80sLVZ72jQNs+4?o(s}AJo5Jq5hM8Y&Jn#keiYpMZw>1P zIrk4vWFC8#3JP@LSe)On=)c)Gu7w+Y6q_nsv`>V@OHF;+u-tq478ft@4HlOv94EZ! z{DM#8^%9t~bt{7{voS1bUi4U;oi-OaOq7=q(&E9g~VZdzz^%jDQcR9CNEY;J^DiA^)KDLUTm@(6#G??mlmYukVB zA$VfH*Ma_iS=0VFct{JA8h3}4F5*(fyobRB z?@Vq4=t99&pXon{65IZ;eEII!FTDohpt@xHr@D+;=$j2tV@%zXINfYrYO5_P+xV{> zD*pKK7r^rZXO}vS%Lzgt18Bm&$BgopcOZa;*uM|n0dJQiY`sP{W9&IOPihrQNUJVU zFyK({8l$PHRkmooC3HYP^y^oVtf}dAT^s0%#4B{?$iZz2e|9e}<`cKJwkoCwxkzJG z%#XId;nT9Z9B#g1`Xp1b_;J4NWTU05ZDuBeST1=fSqlnRA|U5@VYW1!qsWL|PE3s5 zP)*#jXV{X{2CRvAxVFlp{FR~bZ6I|nYJCJ@Q z^X>wLFRlR>2ds2j6E1YsrOu|jr~Maddb&(@s0nM-hc)bTfr5tnBlQt|u%DH<>+kmj z@vP$Fl=aZjUi(~^&{krA03eN@i%3Sw5aa^Lv&l9{}1({cs+ zaBFT!+q&P*`~381Njq-~tWH%f1N$&v`Hb%MbO1hTw=n$v{GzKBh;&QZx;K~ITm(Qp zhPDJSV`;CXc1p1fp*2)gs-so7Lh7zT;VI&aS}*d&#l>CHMw$7H;(ZuK?7pBLMZM26 z_zNB-2LbZ@h7A zm%F)KEjk~5v^L!CE;ao8>~kbbe!IRsyi)(sI-I55iT3$ctRitB18V^5^DtAUPoE=K zzVFBW%t4ft_Td(jFz~F_G$(RemGNVbJ#5wA($s?6Q$d&N zlD9U$yN6Gkefrs4`YC&o<#BU-YW5tz#|w{G_WFW6Fy0?0j$SWWV*YP}o_=`VHy&j*b3tN}rss{G3_dfJZ+A+!|edO z+B*Tp0xSz>HkrUC=+6!kukRl`zQwD-__m*-wywPk?*C;kHr9@a^qOr_Gg_K0pCBku*|J-E8dY zm6zOU_&H3niziVrcA6pS{3l1b;R>c6DOjG*{MZiV*}I9D$lbZJ9F4WI1@;}*zn{;Z)SLwh#zY)M($A_L>#w;8htJ|hOL9GvFOhEN_ z9sjS&AV+3qkPge<_3Bp}@oVk7&IA>9$4~SWKA1p|C41?)b~GB)@vcWyuR~c7p~+rx zCs2|O13xt%=S}CHledoI@}-c&FMv~_>8HL9X#i=GCZ^drb3Kz~0UrTNJJso7VnU3j z8U1v1?17Uz6_*W&3VY-?tk82wjwfJt6k2jTP<$>d~;7 zpy_rv(*$B3$&ZeF5XhU|xKCU5WEZ4BjH*r0+Xc02Ni-Rt^m16v6uplu3*BXbzisvbkGyjysfd2SXh=A(O2C~C zNGIX-q#-&9G%V^hX1&IyKiI2`r!PD|PoD%W$n53@W00^2VBmF<*a_v|d60etuoSvi zA6BVolp;)UxOiCk6O@cr*R$Sls&8LRjTeNTGcO)Ce65!82RMAO&gEeHBmTo3+sVP` zR;*z?G!HtkeI1|Lm1B@p+vUf}M&KYCSp|x(qY1aA;T;3N?@6}94(NPyV86FT-q)t$ zr{g>Op51^dpcoWmo&|kfMa*-|X(O zSIUK!x6IB_S8zVZiyS%4^R@{1(M+VRSlYcyo&NZ<0`jNJtH4zT(d%_ z7avIgNC{`TA?KqNWw*Q~Y^O;yIb`m&>qR5JyBnkoyp49=?Y^UHzX}+xT^qpaP8CY} zB&quqM$uN4u$+v+Vgj6n~-stZQ-^=2xUjmOuR%CM@(7d4tHz#bq5& z5Mm#^f--=6*yaB+>Ffr50qpB@9|q$3AfFw(8bft9OVf?lr6Rr8^^86*H@~<-sofZb zt(-);hES-y%gTw>wHumkQ}|-n&dW;=3e4KruoGWQkyOHP?3`A-5LjDYcUd&aYG^&6 zqS6BPz9fntKYnCYU;P{CHR7;V(ZtyF`KNNi8ok9SaMk}NW|9DnDt7fa6X0>GSgcnr z4FxRQBXz2Ga9p`*FwT_G2Fxwv6tL>se~Kqif@(rRL);Jw7N_e%(Ke)^r9X{aeuK7a znJmxa#~HHa8(4ZaQLpATj94zUJ)DBQ+<8`->Gk)d^G5r!HXHVBmo01^rBnRQ z6`rrTsx+jkk!>$=G#}*&>PJ#-D@7Ich$P8py#c`L-rX+28w0a;Y&qM1;8-^ce1|=M z6eohd=~4Q{5il)o0xS$jUaPHSH?2zg2gV?(_e|Y+s7yPImD}k{;C)M{ItyPBJR(4e z>;tBK^n6C;-kC@q&IHq!>;xL`Emr9}^d}2)H!T;+vh0hf4aP@uS?H7)Xg4m0VJ6%L zRLVrwP?XI+IVFq7mVo+Ls;+yDEiB?q-231Jk>OS>A~bLEu^X*0y3 zFf(+mZEbng7y?yi4KPy(ODFyWV7rKWzc7W7^V|QbnF61OP3wHBE9Jt^KBT0FhO(~r ze+$M>wKJ|%aGC!^Dp}3=#Q1dkQyZ=QFlCVDY;7JR&D78IL-yxeJDM7&U+7&>VYH^7 zKGW(x3W$7;E)(0LepD(F>ycjdbMz1zwsB~IEbse#Jr`4DignHC4%mtMGvS-D`3m#i zz|C$?OFc#h74oby<~6G*EVXl2D>I9!gLiZUVOPm*7|q$$-v_>>sdfjk2tO6d)b{y1 z+O@wY!RqLSgn1g$Lx)qo%pDX?0wKFX8^`#5b}a=B7{a$f|t zc)VD%Nu0YvVyNlOaW4iBV4!wD)6Ka(z~Jlpxdp9V+r+eSmOV!1OByQ6Dtn%P=i5!BTm0<{X~ALZN+*# zMb&eTvZC}Oi10(+&BLGxxol%xo$Iy@`dyFDnlyhV4V{%@4UGosD1RIgx%hw%WFokz{SWmAoppcLrQp zU0iazh|th+7@%4fz~+>~wEA$Eb+ola5cm#hV=aux5tJ3$yeeyV`f~5Iyn@vx9jWx4 zT3N4KLM5q?7z~fKdvDQ|uf6}aDXZ)*>nhBq$`9h{51i@+x{gB^Qj>Kk2meXSA5Bi; z;tFcSVR2- zGqxA1@LyX9sWOlzzj%PfgeEoXZ|t*@~8glp(Vd3 zhi^J!kbS@o4WH+Gqk70)^3SoJY_~oIIn&(gvJ(8SPglqge8W1@ik8`gP)z8{-0a&@ z>SbT=Pr~aj9we2)b`uV7glHQvIJ1`&q@(Lova-@J!eYhsl~7C?bHK;B7>0nz-?Ob7 zwRtznx`v**y&`dX4H;=#T|u6X$j0iBfG$5@kHC>$Pe)PRsyQvNS!x>?6FV@Hq;`Gz z^}))@13OA^M9(L((}VF$6L zON_m{`_P#35hub%L#3qX2SmDb$1QbprOjmE0>%4o_ch&6byTS-B&}=(&t2PpFkZ>8 zaIlQm_;Xv{&7~M=>_cJC=WdcZVky0vR>}hz$}A1r9?kKZ9O}}157CQ@1otavvY~kL zKw7Eyqb*Jo^s@zv-baABjrY)B0s}^f)l4Y1_2X!Y8XfG`F z8h7!LoAQa6@|l?O1)1{Yn)0o;%#>fiFpP2aLWaa%W}X-ti*X0G>vB1=}- z2NUfyu<Jir#!f2=9{i1?P+Ei==$7vrau!{c{B{vU zAolot!fkT*@Ap$bSFt^S9rUlARJ@07;Ux|GP(ku%xhD?&6L~RrX~Wsh`LtZv-%gK| zH_rYE$N43z^W{cAa;vC4pFoI(a8JuG5gN!tg4_RL+Ypgz$3RT+z4s+n6#-s0MPepK z!l^I(r)Rpvt~K))n#la-4>570*}avia8y{}K3bQ{8b|vptLqj!??6Q7ZWWyh(V)>) z72FprqxdSWLeaJfLClY0~e=~f3jP8Sc0QavCqn+7#t`Y#HE{;|5Rq`?;APbr2sUfo7t*5->M z5=(%X2XNDNrzOFFW-3jX-AxA?`rW#pZ41L26r;ztZklin#PChAH=rvI zPNrcOU%^v80_lkp<-4%N#$z4HYFpY9@+{lUc-6m0U~!l)kWwnqZd8e zzfL>$T$An4yg>#@#jryyGkOUZ0+)`)fSJBfz>-IFi#^|OyU{1ELKU#u8AAl{7YAtl zY|pkPZ6+P^tu3m9Fz5G>=b!?hFv7<*cJZxI+Ti}LmiJmW_artf0Rns~he4iCP;kq8 zMRY^x>(68-k>7eVLCPfF*eK?gAhL#Iv3{mk$_M@oNah7671z=)zGGviBH$cYk)>Z)f~Row%IE>*t6Zc(dP9072tKDqc`Jze;z}QJ z8HE9I4Fk0Y!|&fLuV5-f3M3Y9uDr}uBAOIL0o}NyF^bujS5EH-UXs%+tz@a{GQIji z#oXQpL|#8@$*I>;W@?x&)z;$Diw_9S{+n8+BhuN_6x13}VMWy9m&>*q&Sxe+(+rSi zdT(Lr<^T4wCisVMeud?mad9@)?GL`*oA;F7JEP^=VQ}P;JhocRa@oB_=c1FFcc~F)CP6~Uvs?ovuD#(oi{Sh4 zr|xSw{D`gldOvDz-A@(knZN?FS2}EgJnWK1vBx+Rp1k3*E+^HswVp=wkB0sI{nr4Z zffvBHhIA|@Vq)$xsiuh)$hrc}${#R>PwSU)iO9Sz&JH@5!bI8)SipW7_)o=&_eVPm z?By0+#7w|CJ+tjSg-L!J*jp8MSTcQF0yydXd_T}nW{cr4s8WD}WoK+p#Kel*hO*@2 zg`8*J5CcU+Byr36URnGhfF3z*V6RMO8;~>Q?^VOP?ZTE-mdl7EYW@eKjotSGX2=(c^|}2~z`5fjkk)^92#8qw z^>O+oj51#)Adg-(=%zAuq$uQl@S*l3EUvUPMz!f0KUpF*a1T>TlhqX$(KW4e1_U)z zl=v>=oL{Um%{#s+lgnkx9vmFJv7atM05Og=axTnJ%NZ5n~qY(7b=ZY8+(#cP(wY8l0V>cHhgtD}tbzSroVp2oFFw8?0lIs)X?!RSIe*IX+~ z_+0E$d4bpOIZ2S z?i%<~Lslu{yElZ!uq(lM<4s z9G$+*+k_i}J{LZ^m1wfPS)#9kOvOM&r^aZcqHw;}K;nn1OAj7r8g7GMy;XBql$=x9 zLKWfd`o!Si6CltL^e&p$<#eiQ9D~Cex_sZ}9x+etWXb#Mg@&Hd?C#nyJHqSRp`H!= ztwLLb*Ggk-O`OpF_I>LreBT7OzWez2wHfkelX$Xj-lgoZ)CQ!P!?p8wc;}lD^A&{} z6z}K(vif`qd3EsVV&XIZDfIOpn$^P2Is_i*9Pn5b<2GIHSDNxjKE4_ulCeUVn!cha zpLgct6{Wi5{;4cBaK0#Vc_q$-8W|tQ)iE^yU~m(R`z6nO8v%*&MoK4Ss!ABx2VQO;Qe8SH8v%4AT5ZuhCoBNQ!ECaaPSy3AegZPYLWT*n;+Yb z7X4SPLL?La2Uz)dcSD_RGIIID`rP4lyA07U%@k8;mfseI5A9?lR>;4sm#jE8thcP_ zR5)^VGz@ePiQ7*uqSUV1Y5p2!P})oI7Ty|FV&wfQ-29`lUwKv}9#`EVddJYu+;@yeu*o^|KyoL5kf;G1Uz+)V znK!f(ra;w&=hQU+WYc$lG13nJL-d^cZhoN1J<2VNudlnQ#8$-7bKqE>>NBK92z4A| zp~(#pxf)Go^vGG}ZSCDd67oY_@r0g2MdLeRjX=KLH-O7Op%lRQb#*Sv3aDnVm-tVm zd1-(F;r{p3ga>!Vlh0Spr6=-61RuP-P&S`8M`S1uEsv*I{k2#i4OE0TZY8l~NL_5b z6^9GsBxz;Xk}ao&Y}nHTVXS~2J&oHcJkF7jw^djDRxjl(b9onD?jyocEhIExp4`aZ+gq=py|1Vh z#RBP(j6hBwhJ{89pO5~s0^I=GzFykM-lnp7FwtIQ4)pU4S8@8xIhOfMC_bi<>K5x6`p)2Oe8w@+sYjG~3vv6@G0j|}d!&Xo5zxC6$FX_Viq;@te6 z-8)|;AAhvgI_3JS#baH^-c zxQ%J<%H14y+bj^+65H*cR2Qe;8KE_Pe`J>B{v2KvrlkTFX?%h_%ELa6q!L^(1ayIC<~Se?Dlt^oJTW_WS<7c_RR#EAcKdiRX6b&SfX#@I#d^ zTv@m!WiHSPMhl5u@)ow_qB-$X;V)u2SR4}dxfPX$)(ohLW|faxD*)^w&72P_5*fV9 zNH^pba(Q|c>D*G+EToF&-XNp)GI7O_pwFVl91vJcnY(TLQtfN+Jyf{Bm zBo&na<*KX()u~w`{`Ko|dKDH>V!46vHmoS}vis2fmQ9EIzA~l;jh{HZf}R0il4K{| z(;HKLr&srjwv$+xAMl{Vj3h>4^&_n<@+nL;j+4tH96PMX}a9+gBZ1i7g5%+sx;^Sczb#+ZBz5EKpDyL?K| zw{&3I&#Mzqm!{TC)Kbx4-tE@Rb4#Upwk}(?sOgfQ;OtF0xIJ) zBd{~64p5c5Bg;#`KX!d!aqD&DfGzxQGj5U|N_#kZoUYD>u)?6-|OSkC?}6s#jJa*NIjvB^zrNcRo1)U%{wO4|gx&_-1Z z`9jLgpDbyA3yT1=-_}+ZLp~>d;c?e&xmbOYK5a7V+k}YW7zUGzLl-p$hdv7xJ|h01 z-y>}#x``b6L#|Ria^DIQ!VQg_hvXKjZMa^;Z7ZPI0@Ksg>!&|8)$YWv)zk#j+QzZ% zqJ&z5{i;S3e5?r?1>E1TJ=tj>{q`bJ8Bm#+%uz~A^xg9lUvdv8i?o4>xz%6rC z<=Q#7wq9$*+kSY{9k2I40P>|NU7;2ak+ z4Q8+Di~+V+A_hVXJE@Wa-SLE)(r^OPk9gf`fTHZT_Z<1)q0_hAxo9Sp93Dq|^c61v z_i8omzhP{|fgAhLgI|OFA63Cw@#sUWQWED)>0IpjB@D2pym-)=GWw(}LD*h#LKe`7 z5k3?uG~hdu`kwc)tvKy`cgNS;nWfY8xH6p!tS9NOhA9WXms031OqhBxF8W@Rh+=W2K0C?ufe8APzrbY8Rr(~Pf(@) z(6DbN6WIrJ0$|-OS09YN-PV?`{WKbM9~JR(@;wS^xTmEH6v&VZW6$62M8^Dplo;vy zNejMp(Cwc0i-@#4u1w_lYWDr1{=!%ZKA1>xM3^f*ZL>0GzHiKU)DkO(opU~g$KlBq z&z~PJWV#H*DG{)5Hgvs07msw$o-|}swzy69y_T3 zkE&t{qQF45X-Q$AmO@v=^|xNQVTuJ##f~A>OAc9DB|uSuQIXgBW9Xroo`j=PkH4N` z0&d-|FQda5oBBH2Q3#}+GpH7i;tJ2Ht)~b6ClGsyEwH>CV=lsj?mO6DCTv*5_@ZYQ zl@bxtuNWH=u|^!t;*N##b$UL6_?9QrE&oK&o|*Pt}Xn->T!3p)37JlCtu2!X=p4D+_(FRt@9@wT^!mmNXUk!rcVRNZ7Lgk-zzMud%OeQD9-*cjGFuhO^x zR3H6+M!9_K@)Cr%9ZaT_s zjXFC<`5H{wZ`nFI;P9lg0del>lbtkfeRM#2GZ6Alz#51T^+vWk=r>t%ZwG!UJjA7z zSO9{m`y{D4h?j-dDd5~!f@3h~tSKtF`}`GD%`NzIS(U+{N4xcfc{&@TgeSLp!E(`E z{!&d(S;IniRnNgiUU3TYMoG@T+`!}zLlCiK1PhYH2k_~ibyQ07pN-Vfw34K= zWwTsgx*WA+l^>u2O(AVSSk?$h8h!>M_JE8RxqjIBgweq2=r&b5l4U#b(Oum4Jk<}E zv-5%A(X4q`qyG`R69JA$Ep}K%)KdaXc`xz%uZUb~&L4;W^Vopj*ed$_Z~h?P3D~!9 z-+V#!h6JL3klPQ|!35K;&)FVoI`HBwtNIF{f2>(9^14#A;W6ubJ|Z)-r%dKFc(5JPLv9<@ASxn0Cn>Q^I$ud4QmR`3Y`3|BUWS|=`G8#K#9Xg$vMnZdUalFjV3)NOhEpBTGXg->^*`V}(|4w5>xoM1q|2;}dyDec9lq4xO z$>yp(TC*h}G53uO$c;og(H_6Z|%t@h9V8a!>VT6~OwV|nYNveYMYD}WU>s=1T*sM#B=>h^9tLV>kQj5vc~vVL zq6+HzFZ^+v_g>S953y)8FWZpu!@s-pXY#sD2BO}Flv?`TPIx4bGAk^o9tlF^?N=Kc zzh7#uDP!lEKNMj@RzIr`1=(ZqlT?1 z{^sH9b5IOU|^)X<1vg^$}wfPR)r1Wz1xs}?0)Ad@iC9Hz# zmp9x7K&ezJTk#kPN*WrSOIZ}KD^!L8mwmP;LP9^euxojk!n#3?twsMxKxGxe*kIX#T(^mmxWd2qo+$XhrOtzapa?fm|}^2Pl55p|>Ctb;mOAz_}#vQZ9A4 zR~A^(X6V$EBfAa6-|XsI>^gp+ArQ~H-z4<8{Tzsm)Uf1y0*Cq^O?x{RhhYuSuaBeK z!{hbpoHL1c76x?9fWw0#X)^m1+N>j-i%HHWpX@`gpg z3%k6Stpn9=ljn#gsr%GlGcw5%FY_DGgxEVteO*$S4^IBkw@gA|(@p%f;n zR5x-C`RN`7*eZsltA0Yu=ll*Pl5k#5aRY88t3U~^Ct0I;M4&}XBsW2Ayri(jtD*q} zPgwr_iDYg#t<5L2XDOp2J58yJlS@M@84@U-(qDhlW5IMxMy+-1E*%5R(1*XA3J7@P zWFs)S+IPJ48w^j(=f%Q!F{Rk0Y4=|N&@Ae??zi2=g0ydx2VK!us+jkLe)rL;f7$n_ah&5F58*B5C@o$ZcJht6a@GW)O&9`b`PLfMHd~6A_L9dhk1kg-( zClDBLabU@cVjF$px}~EX|G#{ECgNvbc^-7e1aofGG)@99h*|@q=q6-E%e-?qSlMeX zQOv*)!0Y47|3nbDM>KQkR}@=%&lCfFlx-I=ZEkxw|FqziYJd8M`8aC96Ty!Z+!7TB zBkHt0PTpjwzb0Og1%4TKPXC)_ut(b{%dRAdfX7T@D}KfDjSUQh6k3n8v)s+8oAkL1?CLAO?43K2QT`;LBorCV-qRBi7?1C{S$Pg*(X zbW&V1kOPk(se84tfmtLdA8hj)jd6Gt10j_^-j);6h%E-76sHkO?k<1H5*vegW>8JA z*x$w8=YE{Od6aKnCV~6r*qLXKwfpz(9ZnrIAIt<3IgVA#mv_*BxPPKC6cw_}0_~y7)U(ZODfeZ@;=#ROY+| z{{T3d@b8>%^r~1VLFtk#>DF~HS+M#QPKgYrq<+r+!uc*rSTdIj5vZ1{Pv3&E`9E4~ zFfLyia&ZfEl-8AxpD}T?T3l955V~|F0Wj0cZoCq(AUgdV3Q6+)k82C?)=VS@_ev8u z320(2lwp-uDJ;67o|677LYr$x(EN%djX4CY=vOmBs#tW+rzYA7hTp9V1{=xEIv@iE zp5=SH89-ijFjk#lz#RNbTJXpIBdS#zP`qr*BcqPVgl8c|8Pq_YaC%l!I) zz<-DH7nm0pOq7eNh!TT0uo1)TsYG*=N1%?kS*G;B{P-a=1i-pizj_jcPxTxw!C%$t zgqt|Rw~IIeLrx2JqHIhJMmHFey?KpoGypZiuLqa&Ab}sfpF_X78-98 zvNy$_*s*$r@Ut_C+*iej>7_fb`<RLN}ys_7JmP) z=SaX&k0~2IjMVg#O82K~x4L-=l{JNAg$uLtZWe^vNu6o+EN9m6tFAuF)~-T` z{D>4_e3F-fg^?`3@ZNMGPPcoI_Qwfe47yuwy2t>#x%UkvkF;J$!6lXOloxo{u{4#M zG#P}etEmlu&b|g3Bskqm>k578;C+BgO8Fl@jy3_;RRMhTn3@t6fF8Wx>i!ZJh#CA2 z_ZPXrneVecr}%7J++ri~=6C%qOg#Z-(&)oTnHXUsIZoX^pyMyGTGz*j_wo%$iQT4G zy2vGn9FG=`lYxdSQ7zKBDTFIma>%;zwoP_VOe)8+wl!$AB^=&ncOkMnl6lcX zn}mhA*lC}S55YME4^J#6FirbH!Z;dewl1tgE|MgmpFiY!J>&Xo0%9|fcl{M%z8crx z(A0s>?;>P%_8jSL?~y`f*nU)=Ev(gr(;zN=%P$Pu&RV)>zL35lWbw_LlTeZzFb$JO zJ53j`1|G510HCDWsAfIZL%%e4YFQJqXgRMY*k5&7Ex+T>acMcy8|rS+mJF7-?@qMk z<>2D31M3v*`~n^LKW@X=AyT|FQPhKEl`Ea6vmhM!_RZbb4kX0`N>=yIbOkH2xz(Ad zlh^Jqi+8J6L0G(S9JW7J96<{un)ulIs7!wccP+#1_<$4%PpR-SDXcnd6#Ia(f0Bx= z()*V93{j*a4^aAuG#b(+w36-Ukb#}NgB?49e8*w6H<9G+w!2H_ae0xQ3TII$oUb}PbPLT66Y#Bsh4yHWqxr+Wcd`wN5a7JdG0*$}jWfZsS2a7I;S3uq518U59Qmjxv zCMG5>-|sOScvGB|XGuj*^+Fz9D#N}~R{yua^TTFMvu}4Z_*euuiu~w`wW3!W>b^`O z&?>nuwOL9A$<&Uxxk64yCegja)c!O!#zF=I-H{}LJm8iCl|swkz(ay2$nad9N$w~E zKl|1T$ftjZ6$$C%*w=T!&^4H61HFT^=3S4~pR>i}^Tj4j$Jm9}LS1BNV3>uyS43l9h*goX4i5pWH`FhGRje|B0&$O+hc!xxndVBA; z?}c^Wfya#SzFuy$Arwr^0RCaBws*Dz3_gsYH_c;jJ;jv$#>JQT%XPC{(@#(S+3Ei7 zK0=L7sI;NMqHCCZHxN{V;!_-V=VshM-RR*`M*LStB1o>wTCExuj~jt_Ji+D zfh?5=oE^sjlM{T8^@)OB;2c8ZjfS#ln6-x7?!JPP(d*fi$9W<9^7656*49O*Q}SHA zj6Pa;*W1Ebec15{P)sHQo)o@F+0%L|mqi<{aI=+k5&QwVf7Mckin*$SkmqOOJ~_=cD<= zJQU=8F-YB}lJLK9-emiw^I_V6LqnVuLYo?CS*~R(ID!_fgRL_xG<DK5!z(dhvAR${2=$*#5;+ZX z<**<0XLWILaU#jn&Evh5ljGww|9x<40{uXeS*ozaI{MYTc45N)!%Api>}UVKdYmyX zFCJgHa8HYbLX)oPZ;XfyDkDWmxYgQLLZ2tBd)gD)ppWueTYa)$p;^t$aihto+&P@3 zKwlo34dj|vqjR{Ky9Rv3+OO1Bka0#n@tE?Clpz3fQgF>csKz8HLUW@Sl_e{ zXdHps*>)-Z%Pq~X?Vu&QC@m2KgggHUYFss*|2wE*uLI|)=gHdi`P#1HpU7Kyl-Dy( z%WZ59rxg;{j?4x={;=z8K~6% zk~kg2EtKKoBKl=g)#*|ry<6S}DEhkZVPs(v2|`a%=yn=qxgd}Udz;dXjaaKSpFN17UOVq_}zKWH_E|EL+HN{tX zg~058==!h4^5C=mcf11Y0(T(04t5^cmo*8bPCEYlGF$V@@mG~CvJVWTP5U3@zyT+Jo@pLKgKrSU}xiz)TWEIXEnJ9kWBaYFf0;jJ1L01=70 zg~NQK>l0z6j^-I0Uy^uY|bu$b@ymunch8;%c z@m*BuJm;wEfZb8>Cv1}o^v=M~A^0lK=5I}$@X8MJ@kA1`UkCJ=Dbn#tM9)IgVdy{$ z75cNKNF`Dhs(CnlOxvzdyRekV+ZXhy4y;(4VWvoGQ2{P6X*lEp&1Mx3IISaI5>tbk zv9~v8YqBy83nvf*`%@jWqwRUcVxxKu=x^-(Nfg44i(mHtm;6+r5Z7GUEiO|36M#A6 zE|siZV8Si0O;niP+!9Rw6?JXY#! zsTwLiz)An$nid4RHf`Gv?)B6{J*qkvBe zVbc$>tQKRg`I*N`ns399zL9(pZ6sTZh&opnV~e}fXW7hYr5~cFio6Zl!C>*tk?mWdewQ0 z4*JTJMO*Q!nJJ47;{4@Cq_rB^;r$r>4g>GT>hG9ZM{FgVa_=v{< z={Hu~6MJzObuqJj?djigHzf?#`opl2lWtS0PabO>mFu4)6M<5MP~-G<420OS*jU7o z-cbacCTh+{0ou>e1AYY)C=DIsT>dE%vTqUl}KtIsx#vpD7PP{h57QK@>d1ksBNH zRUcLe{ng#rRLrt+`ss z9+iJ5OaAHQ5**3?>E&U68hE`7=@YgOOr@W39O>%FFv=5~eu5zd9RcLc+oIL7T98}? z(ioL+)-PDnAM2Z<2wDQKjnIojS&F3YVX_*0{Np!+4OW%zwz_`{!c*kHYUb&G%mGWu zIK6t;`8rvRI4*2=;LS$p-fEB@V6C)W1c@pt3IbvvCnJIc6`LGX6cCi0Q=^U|A|R3_D?y1OFyx?QkSriM zHV8=0v0v>2I!?IvyYKy8|1o1|>C@-zs$F~4TB~4ekTwW7ZJNlX33A{78s7@ zQ}Z;LHV8&PB>F6jPJa7lTydqM%(s_Wpti?CE`(`ow%tO8dtK*;_jjZ!KKRm%a8ddG zaZKN&er2U&O3*CZsGy)kl``$Skxk+qBOfa@bvx4PBd$kWV!yG=&HKB%Lwj+RGUXU$ z_9ndj72jhM`kOXUsP?B$r&=3<-5dYDsr_$z(nNqzp|K6Rq|0*TqYApAW=(HZIZc~y zJ05)6rsT+l6Y9?kz*vl66`{VK{fTQObRuRxaM8V6OMIT3OFw z@7bW2ly7qz-BNrmsTq~{JpAfj=lMsVwIDKoQPo!m&=TTQTPz@fcyiUAyY z%t+e){tKI8%YK`NLm}20JyIP|p7(DyBEj z6wapxHuA{GCI$)ZFrn<7s*O&~ZrB+CJ%-~7%a@hb3CBq27@q8zxD@R`9GggDo`UHV zcj7+Fl8+95-POtRK%K_v^VL+jAmjH*90@RXOo@eIlD;1xqlx(;IM2Ihr01=!Kl=M>>cV-ED~|gWp_j`yWbi~aMy3DB zMx!Xjj40DWSx`_$o}k87Wf$AP5;NF;X&3)Ul-M8RaF{q3^KB7X6ViF84pbLSklqho zRWNxYon~kFVzNl(f$G!jfTJeVUbP8~xGp1KY>@~knA37Wy-Bh|`Y?P2)fuG8N`sBcsBQlh%0uqouk_IGga??Y*u$n) zBznYz=W;eXdY}#9l?!Jm{S18<*9Xm&m=_1q6@R8il$ zK^q`MMR@w2#T)6e=Q>uqR(wCwvOc6{x3?Exu0hjMg-;oU6nJo{f6LLx{wlolK;ogQ z7;rJB9jKh*9T}PFs0!$crYVt znGWH(>#rb1j8+c6W0@QUKiztF=S)Zrvnd&+CmA==d8fPNtBokDUmmQ=pjHUx0P}#s ztjP5u<*zDhXh~dxmc*I`0{ZEnk6)w+R|q+(QsE`qY8;s_OTJom>bHGnFJhARUu8z3 zLQb>!q}~#ZnLepRrq_|Su|N&))wx4if~R?5pyK+-jf`9QkI#6&V- zx8;f%#X*H9I4T*KmfrPN`vJwLrEgC*5}t1~Iz7&K91{v$5jOftR_}l9`{)Tn^aYY% zRx4gEk1TiLm~z<2{O!|63TESTXWCxW&0IJ80CW)*^d_Ar)$MTDOShI@t_PG#wc|F8 z*1_AEnMbF1t|24k2@ff>dcf1Ftj*^$I2p1B%!y2mai=b`$->(WGoQf$NH_KGiw*Fu zv5Wo3aQ^KWc$(>z_!-k~L(9s|z60^U;j^7adrl$-BJWA;O+aH%iFb6oE!vsAwS1v` zuOwqI1Apz4v{yc62YjRjql4uZ(G|lBnL-SiX0``d+f14>ri6s*2-GI`XdJyh#dlVG zrSD_yRq>C8=ZSqQ%XJ4^f$fWx^H5h00>d} zLo!3#4v{g-{&q$d%j}fi5^+7qM~}#vRqv4;$r&k_0`7I@ZsKy+w45KW&3E1hf>O%4 zS)pZRLZ02;*(PCd5&eAnL%*r4#z z+o(15V?RJv7C>zKOuw$ZG%LdhnF7gCd_(P|oDcpAa#okxznzauOwoIDt;!RqS>9&l zDHlvo>)OfVp&SUyQ`Ie6+SF;ZIJ~VX9r^_uQx`WvJ}(dQ$r#*iBxW{tQa-y%ge%K; zz!=kk`&mx~P&J=#Te=Ii+m8f+tI=401lT3Qe}cH^xxNI8NTl`d&%DFJDJ;d3BH~-| zDPPTsd;D8Z%(v_AlBfB02Rw4ao49$g=d6yu4`gAU=|6}2kiq31ahj0(=pIBus&$g~ zb1sb`v|y2+Bi)f6As^w)$O|bQB)4E0aB8zpR#yGI z0$GhaDH9x6f%zjd*mu2=6bVpOQIO@`T3XJcZMkWa#llj6q7<+mWWJ{qwNV4b6W7_(Z)4{@bQlPHtK?tz!Z-(Ry@J*)_T@DuxDOBCxIa$U zCQHHa14wu_QlO6d;B=TQSyGltaxy_7qs zSSdEE0U@;}e1(gWk2d`VDvR7TLEM6MhiuYLhF*FCoA`l0jJqXV=l*y$#|7Ci|Bug0 zf>sG>wXMUi%&%;s4008~d$* ztBl!qyK5{Vo?fC@G`gwzi>9Tw>`~~;^29q{kD{f<^cM$w!&Q05Zp{pA#GAMoSoTm7136rp7 zPn7h$;;;5h>94jrmhfN$c^l-14aFy%SpzL`A9}gmMWJ!~HT)boV8(IKIfL%&)GF1E z1%HI^Sy^|8%$a`NIA#n4%qDtzW8}vVzON3J?;mR5aHMIe^JlJeiRFUWOw2^-qNKx? z!(~P{p|rqKx4n9fdLVU1T#VyKxK?IUtA!Fk#GE!yX58zKiwmM&U=lJugX|`b4tT%Y z)B}ICe)yJHORFy&_d#O^6R1G;9PG_9LLq$zN{PdKGNmWuqYGufm>NlED;)~)(x^C@ zaT1(R0*{)c9q!3_wVU4)Ei`CULd5|zSPH#81pB~s=_hv6bstxaIbMvO+w8V@2E&W_iKAsG(_~Jpt?*9J^|6 zky8m#s?aDokQ@@l1+3$W$b0&v#t=hV;3m*x;oo*VxZuiUac|-l507de1Hx8+gVoG; zVr)D}FxP9k+u2cg6RT?sy&3nGY`MVhp^l3U%$##<_CrwGC>WpVkBg~}r-*qb)aPiH z_~*WlJ5GUBcINx3>emhU`leUCUVS=ZZxQyn=+I0gZhTiA-F3D#bJc&?J)m3X|8ZTF`>pPb8(GzzDY%$SB`Dt1x_g?H}NhJ)a-@&k9Ms{?(paO4}|qEMu=KB?zl2F&+{fZ zZwIN~puk6HNw=-OhnOG#oLK*?w${_P+c*3uK*(srE3T8wh)j&kyK|=EwQmvEWodOb zwXY}gie%?zY+?t3dL0<|3a;N~-%WVRKhkHr3s_T_Kz&BfFlk5XcJ62m3CZB?H?k(KI;II&85+5E4knGIcESy(TNZ=8Hb4HijUAs{d{2=E>J(I;vZqPg6V0C)UbOCz~LvoJqm(F`wT1Dp9#tTvK_b-t@=)L4dG*-aZ zzGKpi^;KeFOe`1O0%rmtgHd(z`fczlZ_eL|rc595$@YsWLFWT%V~?UB!BK@R5|$}N z-G!(cU|_7c<4lg^Vjyo|ujpAWAlW14R4E9Fa;8tl5QGn1*0Qyj`RopFB_4Q}ycLT;R?4{J6fnYOJFCc2PlW zxIgdU+8r5uEzE_{a{oNS{*1kR75S5_hx=NvGh@jztF5$%kuai;oj~xzU4(uxpuS%l z7C%#(KI!zhDoQL^+sl-!;bgoC)>Q`UdP3cIE2ZY%fLcT^aI;!7o-Z!=M&d!0 z?uF*J!HgB5a3*>4qHE*oc18eZ|2^jZY-2uluroWzdJx%)-AH; zI`gE^eoY^Kab0(9lgCgE^_oJrGdA2`-Q|%f+MvrmJ1{^3#NxlX8_vBTeNi`_e;-Rp zDKx7LM}QKKrJdWUmfk~QNlC+Jm0;K039jgEp*U19Ek!?SBysHADlY zst|DJi^HO&!n6ZlN5QkCaK-ziv_fcx>L0;qkoTJ7{Pk+e;h9>#}i3T>{ zt)bJfg(Hnzc(L)G$QA+V1l}Eh#nlV;Jn5|I9eDLyWRG^_MtQeTK5u6)|gc>Hj7nNR>Ry)on zBWr&|W(lp*6`Kn9a>-Vfbmf{{ISJ;oDm9@s;l%fI_S(G6zKqFoPN2bDyY=aT7`jLB zQ(Hz=bxw6&2oknRpoR0{Mw0dMLx#5wBtU^Az08StgBbJ&xfa9iZ2a^yhR#E$nHCPR z>zjCP=6%XJI|fi(X96+f!Gi~54r6vyV)I?TTxu+K-;+S-%|o;DO;X$zNbUIe;w0@7 zzWXhwU_>I!pr4EuUm4s)CGvtnDiNpx%fX!s!QI{6*KMJR+=OZN=_%w>`nEq%_0 zmPZpfRNOh!bs0H)pj~Qpr&TiEn%0t$tWE9zQggmPgQ^;kU zQB+i{#;o6ZDs}AM$6%I8=*U<&WGp8 zoxf!}vLl?tMhq3Px#sF+F_tqo+LFq72u3fp8Dd!ZJ{Z>YgNx72YWOD?ALdEG#$`Y^ zpANg$QZt(Q1(O!YC}?5V3YCI%3-(*lLEfZ9(qdvl(5E((p9!^4bUAhc`}q|4jVTD@M;NrV%&VKwZktDR?F=1;=F1ef1Wp^(26JKfM-S+#|ghc z%IYJQ;uj@pY>x@jSjS!1P_(;*K6Zx$-`BNc165(^Q>r_b930C1E8FdcnBTT%J#5;V zH3;sG#zu)AMgSXHCCu)ya7zLUz^K^yr{tqo=I3o}zLVFfP~)lBvb=bv=M1ijZ&jW; z{YK1}Paxe&6{v#2zppesz(}v?C=6)M_0&{KP+yQFOiN<7)3{Lxh&7>#QVZ{ z7_}}ROJu%!s;aB2i`lAe;t}iezR0}kNS?t-)RoBJGtXZOEuH9Y^vr=Ed99~W%?K~o zO`$b^*0vo;2~fAi1X((+PK8D{!2}U?Pj)4FPRfIaT~S#k_)1ufwD|0X`45I^SOfpGeOlJmw%)rnBc|=% zJa$AMr8)z_C#J#>SS!l|;-oJM@{#752b)vwdQGC8I;^8!UOZS+39PeoacG~KQE%8v zzGx4O{SH{-#cqr_B8_^EXUkz4OzPKTSPW{E&SmIEns2oI8B;>6_oLax`|@(-ggck} z`GJDxxqG-Nkpyuai&ZGP$-o35DO`}>URNNi1qK`HR?p4xlU2fF5$B(8ywXeb(^_pC z5SxPufIGHt7eOO#VhW*7GH2@)jU&du&y1Gn@am8BqRjX z29bqR)#8akmI>)S&yd^rD}#gyQ>b)!j!YJ?i%o1Lo}zg3_*Dr>brUQDyy-|wy}S7! zH>D8GCpkerJl>px4h9%;W)2$bzhpBBu50SKp*qzc{4CxF>rpCJr@~qE8~{sg$)39| zuwHgg6L_W^ReH-4gINWyH6KL^?lkMk2&%&I_g2E7=-+enmV8L48vt+xI`Ll0M9;fJl7Oykq_ zjzrdhf6NM7Q3zf1#yajYF4gn8oTN*v>75FZ;UYj60|CXW0b^_%een8VG=(oV;8QMn z4Ig6<)c`M9^D;N-&~)Woa%^IjLs}hI~QE(T#0C?ce?p5yjEZ&X0kk&HV59G>y}Q=+*mBQ{@b&08O?N!pu4|H0lZtQw3DwaaqggiAH_4IsgkW|o=F|`42I$CVzD25M6l-SzZo|+5$&vUm1KONNYu|_KZ*CGIW z2GldN(T9$T`=I+ZXrZTfq={MnzswUiI^dzwR4fv3(;Du>Ry=*020mUmOM~lI(uS{6JpvK5~Bs?TDi8 z)F;l+=^0Wsmvb6#K0rYD_9JZThS^#LZP!pIa+Dj5GG zVTv&QF3XZhO7hY2gW_ob0h(~7*PU-DHqnx=-@yZ8LrMLB(}cuKdr16W(`^a*Rps*Y z1Q>6PjhMHA%9o&ch!~6czR0`>7{_3Q2HDeC(bW{x1rE)3{J<{`rm`hE&cuQ{m{!uQ zqPaZM3QPz8L{^EN4Qn3Tdu2hjJvcZv)(OMT!3TlG_FFh;=3%aGwm54e4W|@w` z`a$JdRXQG%X3Nilq-YvAJ639IYfZL_T5%qR;@#&69gWManTSh%Sxrq%{M@`o9U_PN z!Jv#g3EXB6JqtSjV6!$D^fGP8`Bqj_^I~_22dh#K>|)Y;2;-9OOjp6B_`=<*BsBiT zo9gnJYW~aRSpnWw;a@)YeuslnpqjO%ey-ZlmajD$pb!C~O_7{6~3a^zmxtLXz zB^vUe4SyZDLcyKBZ?)H-SSKC@r6x?RTcbQV{;o*5Q5WL+3Dm_ zv&$h@xV7;m8=`J}5VtXZs3qi-Tm$A}wW(zw3i%3TzYeJP?`j4s^J4BPH@<&z~`hfkXXYy8?NUvN78%{pc#l~fO zbBF&)6|7OtZT15rM(BqZ;hf5b2M2BDyPca7wK|)c=Dq{dd3C-w%W8~;p8jjPi8g!E z9yAnitMj(gf1e~2K)xT?J3ygXS^4*wXw_GMmt?0^{*0VL151KwFf*`NRQbrwl$!4^ z#m@B*UjT}D|7rj!gdN(yD0lo%TCA1>-_iS8d>!E!-sQ ztfxL&kLZUkzS>-pYVO`ZP#V!L$zFA#J_l?Zs%x-&u#+0()+ z^8!RNY!OClBk7{ppK}WYn(RRWmq~}wb+#dTBjE2?6k)&iKN@lf&~4z`__L67NBosG zIqmID3|yz(OxK#3M}jfb89(*oW!5r2OolNW>HdDP*2H{9#w?y~{yqZSN?M@>24wEp z_@dKiPjRQc%oqe{J+?b0peYfCi^e~3G8k3)Dmx|xqu3g_?HF$o9KLNDBPov>(lov8 zq1|L;almoRK)g(I^!uiu^x1>rd|X{Vabtdk0{tIXeysYkG1qyI?wO>OUouf(gp zel)H=1j!t0@mbi{t@DR)VRpQ#-`IM;No)6V3L3=R475KZzRNie={cAv@8CJ zoRGR=KfS7hgL@2(z@#EgNzb-6>8Hg$)&+nZLOq|L44$uaOv3#!B_4J}kShvNNc1}d z3OB7#dY1MpnW%id!~dnJskNto!FsAY$Ff?ezZYM`wYlT#`L>Lf>UcllrGr@j6sV4J z^CKgrA20tc>*NMtIB&KIQ2^`({ncN)#p@cT_5MBlwg1iMv_kbv8t*vHD)hujZ8n{( ze%|eTkq25Or+u{*pydZ=-21>B_Qn{aGUdgpQ>5{j#vVhwxWr@PL3b88plIU)U53jzts&$GRme0wvet1U#&P7dkmP zVsUt^ZTdv04&LtnRBiiMoL}r=;Hupha|SNxKqH<#<~y{^*1nE^kv-cRtYr;Z@-Nd@ z{*@Pq)q3kU!d$TWhT*zb)yawtzA__et^di+pE0aiRwVexD#f8MinIroWGV+CF-ee_D8{JV;??_g;psKxzGpp#MqmPc1wBO38yiJ6W2R31uP*KAidKixibx z$dzNZB>^A1OV%YhX5KdE**O_J*`B~-M#qe+p|ML|v+ZUEldi$NXG&`S!@x6*&s5Nc$O1n#-Y;=3y+tKwE>s?&KzNs|_M0g+Ku|#`qI);C&Z2Gb95&r^%sCuMb#={ST>%zTV#&%3OdvX%&t|x(^VRAqtEc|)!OF?*8g`-Be~eWZ(!T!r1lA1} z{w?#tFsMIYh<*I+l(AD}8M*1JJ@O;(dcqX&`-_J%2J_p4yP=%EC~&OfLG4xNZNP9G ze-DSkq>{ruEvN^btJCp7%Jkg+QU5gFS?#X5eZ~Wa%Q(q9B>Ll8=2|KqQ0TkvMx>RN z^3em-Yi&yGb8i&STQ&brIr`Tt4GXk!L>?0Bi9@6Gry(?!I3SSF53gwejNQUaHM?zZ zV*P|_uFTkHJ-Y;6-?Uw}4GMK?C(|X zM?UIu{2b8BJv*CO@8HmpmelW)z7MRmt+JvQ6F+Obb7<1HCc0Q_2Tz=Nw`iOh)>|EK zpwwVhy%9U}t>4#Q_fhmmPKN!uf)GWR1>}EUN?!fA%c=VV)Zg^JmnGBkv?|{JZX=yc zVQ_2@w4ZPRo6H~Y)ByIzgphW|pz*0TkM|VrdqK@Cn4ruE5@l#GlB1T@lwy5JPLU`@ zHaqo%<7Uk~zAn{|S-}}*cqX6ww_!w%QUcM<;&Xfa(0hu*Bq?sQf%MLw6)+I&f(VJw zDf;1}_>1yp-HH`EIQ)GmBWA=b)T0N!D@f8`z-Ln>2P@&IB&8Id#=D#>ytFd4dlcx} zH&h?=-h*|)*JdujFslAEh3J2inS38-x@8xwF2cHcnHRF91Nh)?;Jj16sIkA1egs0N)()#W-`+2M;UalJ-i87_y5HM@TZaNzg~T2 zzySIB6AG`-Px2F@Xzi=EK<1Ztrk62%GCpeWdboAm{qb;KJ$vv3Pt#<%s3NL^ZWap)FxnFhJes_Y$XqdU`q*u}iq*pEsTCcq`72wpNz7CFso6;`>3%3e z4p%sAQ%AQv@5y>H!njCli3S%I`=?nTwzhtV1=!`Eqwk3Q8T{vl&OASo!6NqQ^^%(b zQM>%#QLjdXDjH^TlwR+lSKU9V{Bc9t*-|bv!r%ylU)D7+gy~AZg(3& zRDb?cy!H1b#33M7e}301Y*V+N5Zz(;);Ej(wZ{d@L_AGw?C0U%4276BIDaW;;<$Ia z@MFP7$eP5M{oj=P&h4apn01X&w{k=A*{4Y-l$a~H{&2}SJbHzO)iWunIH*Ad@u^RO zf;KLnOkt%BLj$oZ$2nbG*BrALHm_02*S^u`aiiUTlW6EXGqU)VTei5PMEBK^vtB@` zdEv#As{163m+v_C}`x13;wbVJ+?ov ze$7s38~RbcYy7FaajG}^#hVOkq(*m#k`_W06yCgfqlxXuh%chUch)Pq@A_l!SH}{y zD(qlId^Y(5sDqm!6!no%c1RoIM z8^+HJ9)o9c|GI$Kl&~J|=A)l`-OK2k4 zkJ)wFjejIhyVpGVRoerayAJQ58NOKSB`@|)KbVo3c_lD!ArNNS+aN}Me&3G`Nq0~a@B{iMCC`-0Ye8H#iO1H0^TRX!Icq)5gdWuw;1^~g6O#}p}=~fQne=UEca(t1X zw+edVMl3|4r1KR^eNSp}bm;6S5_G;qRsnSu|AJ0YeN48M0pY$~9a(AWU^t|RLW$suGi;s7ANGKM-u8{eRKQu$$t54(AkxKz-y!S`> zHF*gR8Akux}vxCf!}4ZySUVv}@_ zD1@~Y6ZDBLcl_~mCIp3&mIGcec%H~ZSg<0@S%HjwemHN{K=<@s?95Viq|=GD;?9T( zmell)bPgDT#Yqm}bjx>ryhEj-+cY^Xbn2A%uzU3K;$?r}N02gMq{BdE;9R_sgTbN~ zC)_DqQ1DL@5`bJsqm7~WH9IpCSqL9{nJDuO#M6)zt)%T&hP4Ko&%(b*_BJww&8dAo zi=6s|8fOiAI@Zn4j2a~gq&WhZ3YJ|T2!u|r(j#EeU7=ZBt|p0a0Ro73MV~9w&5F|e ze`GKO)Br=N1JG?Spqqi7i@9O}uj*U2)#L6&APT2mm{HGq7AkUFS8lP(z4zgjFReX9 zzHC)++^6*LeQvIkUR1%xrb#gWB1_((-_T3j*7RP%b7T5JP0DOl8h(w`Jm$%e>n5)F z`?MVjl0xn33ein5^9oa6@W~)b*%@bS-C^)H|+p* zCkQpdy13r0wMx{g>p`KahrGQc=t{-^AQev&G{w^nM5^3|wnkc0YwHp@{+c7lzx1MxzkGC5scMu9jn5$E zacC`+D_!pfLr-~Et;IUO6m*6P8@wnfxpMp^t%k5n2^P~9!PlZN|FNWE=vj^C%yoFr zH>TyLg3$erM`v=A%&qAOl73tI}rUJx8?=|d)9a*=4! zhQ^yh8zklvqPL~I9o~ojN$`}>?qvC)z(?CsQy7W$g0fzQh)AOsuqb2-EhZo4ryOiYPjd_9h(ydIAYT-ecD&6`A>>khheTx1>avQK}lE{nxB6y6$OX2|h_0E^U^r_kk>$ z38PlVkRvl<Q!QJ=*xp*BnhFn#K1Nn=w!xoLL>MR~Bbr6Rs9pf=N)~xsX$! zd*=p*IL#l5e9+UnaX8Z8#b4TakOS@})pde%2~f38G%WP<=PfjJeI#eS{#cweB`)s@ zU4({P(AC!)5($at`wjo`v{1jK0bYs@0x;&qZA@U+RsrCKU>(fCj0w~BH@#5oajDA$3%~L|REG zW*penJa;F=S9`_fdW*j7JsIs1EXIWrR@=LG9ITlKqJxf@zx47Zf4WCdG``e^7$mn_ zD(RLDCozQXjs3I2OO%sz)Wp{j>Z*p>abCgFwrUM@Pb?hJ8yJbx9RIE3a9Q6 zLpJ4q=QSXg7GHC6w(HG3Tc7742?G{0hZCkIPel+u%8Sl?si~=XdFXOuoC-DR!AAc| z4uS|23cAHG+0YowWNOndZNPQCF>Oh_Vt&8QD>S_uMm1{qL&rsKprsFSZm1urvr9kh^Srm&Smg|7=TjYS`SJeBU(VzG-!;w zdiBxxFr+dGB`Nl(KMlPYo@_&PBWng#U@-=;I~`U7_t{Be1=vRX=@Xc#^Y#ixA5F$5 z+6TtCuH9w9Y#!7TmWEDSzDk~^3j?~SH5f5x{y-)SBi^V~Ng@3jd! zX9Q)0!>B62%^e2gdp_jvz)uu!w9;c}yP`X`dOM?2Hqdf2F8NvBnqxa!%kWyak{Ejj z{XCWb;&D|921u(1gl(taCTBAI6#%Tk{~5Tq!R{}u-Lf@GHqa1CKa47i3M|--uAjDn z9`5QMUlo3W|p@CD>;2I=26$EqDchMKkr-5Z8Uo0Yo|D4C>Ox^YV#NlilHv^ z@iepb&qvpQJ6Vt?g_zp+|5LLI4qxEA1kKU8$a_$^SLyds!8*a4K}}wzzYi-I(a*`3 znTQ{SdLKRiFtzE~S=jhyp8$`l|CNEQEj_#0Hc9v^^ZlKDP=)+#Qry@)k6CrCVHBYs zlS0oT62AOo!BCh2vqD#VDH%T7jjyeJr&HV$&<8(eG~;~0f82NJdXe1;8_m3D00Nms zKT;E6Zfl1<`j^-Gv=|AOZ zH4XN3@75av>uR8HXLFIY*&1pBSCPMcoN700MaWL!SA-LDf_Qri7fjJ|KG3xBd_#)~ zeT<91yyBKTA~D8E-B8Hbd1J|TgMzCi8|k24p&m&Trn4p&>a}PY=hM&dtM7UOuA8%( zbZvKmm6N}>Z)|w|A(=B{?0QUE^HU(Vem}mMh8(B?xJhUG&&7WuYF-hYuhBc4MlELD zX(v;S8X6KQYpWB$82ELrF)VL~AIlqq_hFS&EMNwfKd@Y-I(tn@XTU&%-Ny#F%pC$whoyh3XqTrfarld?0*1GWr?TZRw4dD{POqUD8h< zI+gCnJV%W))|q$BU0+0h$IE_R0{kK~D0&M7^z*$1=qiBx!2JUn`Z=Qs-i108c^T?+ zlY?DfuX_#57-VFnGAYIFP&9|MGncNahqXLLavh=7KT@S9)5CfSET zhT3y$h~A%9k>C6f6SD~0^x6C~`fAp@t~$ld!m6dAi81p2fH-%X-HXX5^YZ8*f|Abf zLGLpQT7x(+Q%u)=u<1FR++i`@VYUdnO5QM3+zJ0uk$N*;oec?z zIatmpRdkme-X@{gfB*VS8(FGw!+!o#_)!Dw<71crWi168^nY(*|C&7h)*kuuldGU6 zjp<5Pc|ZX$$^AHPy59f*KITVWfj2>JJG{HlzF`CUEcZE$wx)bCsIa5__TJcn)0+@~ zf}xA~q|h9~hUD0UF|yH&gSTI9-~V9ihTYpV-_pe>Zb;hJGWnV~qw0EH^{Pim%bQ!Q z_hUUAVrH#X-Dhv9iQ)&}%sjllA+?C^oe~GW@75loqXKUq%ZaTAWnPxu(dp>?P6{2- zIdL+eq9eJfV|ePQ#%j}{fM8LB#zlv@uNQLJ3D9mdUEs|GH z?I&?jm+~a=PB!@uZYrHz_J;Zv;gjekqPSXVJ6xI=SiJU=)i3?wzMQ!h2w@-1*Jhue zlFmIJ2A2W}H6>VmI_=@|v%B=|XBA$CM&Lc^WEY*{vu`cR_m&H<$`JD+FKY4|3cgzEZrMFo5=9ctp09l(lq!_%XK@mYqErq##p0<5&bA*;;mi@beC=fA*bl zwQqity{W}^`HGrX;X7uod^DX>gP&T?Y;QBnU;`iU`l6!?bt$7;nu;cGD&EGCc2fLC z9YH6A-05g3yGNs^7VLmEmZr6xYmX|-Vesd3)YDy9bVBcN3s-}ccP`__J>~_AwP2Tc zG)VjEyNRkzk+u@?LbBsNrPcN>ZGx~rT|+77VU5!o3q(1E)q;~NbB*OBC+tc-8Od+B z@#>Fz&8C&WyaVbcFEc%T>E-CV*4Fb-r##K;W9*zS28iXAAFQN$Kk+{OwU4{cPPc+q z8^cAoEfRtB^z_zESsgQo2M^@%7?7kY6FIeF0;~PC{90 z=jv?dQckB`t*hoVqWCAmlJsYwI|+AjkC)+oT-Xj`)f9Zsxntiu^GG|4tPAi;8V&93 zD+uWUsh%*=TY-{EU>xbKn8&OoJM^}7EOpE|c5PyXv+4lgqG*16?lO@_>%;9bP&bVO zuzj@v9^q$+wS}MDdka3(=|DX!n;J^)-1#0PJp(AwPs6eqf!VIyncqNF@QGY({DJaJ zrIF(lV1#mkvZDy$^~;@(q|XLf*pdg>3eLmnX^y~3SdQ?L6N2$Fb{$h8DFA)vA5{Mg z--ygRkxCElTRlB-gm%dOdPt(D(wDgMDOi&-#Fb2)>Z&OZpC-37zC`-5L?LCz+V$W0 zPs^50)%$6UM(_w2@ecCgj?*kWuiG}s8S)s8wn#0A1)+KfK$Y|!uBfr_Og^oKhd6Mra#2M) zb&>}ceTgo6MbAMk^J|Q6i z#u*iN^#>ZsT|WE)teX8pj6ZGVWoBW?9nBmTTLlSd9~g#)$+sewNi*4IjWE| zPZH7B3e)?);%5Ij*$EW7{szv>2NV6@f$XSodc1&2K3Q92)WpMeu&e9Ft6&7MQrAa| zj@$)BAb<|Z=)^e=A6;4^zKR{M9Y0cN8Utt)RS94EaQE6hwRey}x6u-u!aHUXtVBr* z)p@WONZ+ehk3~USVbNJ^I(!R*jqAINzL%GKsB*h^7(qOPs%Q!Mt>%2Yz#~m}IpxTY zXR^OCRGd^=GBh$$2@eNNR`a*CvWjlzt($aMnvHCB0fQ#dC=dt^1a?eERR;2hS~RKv zxYr>#5L6EXaG;F@s7EEF3;sG^lpITyj(;iT)G0^HOXAG`k71l-!T7|SLU#6hJnRo1 z#PYmFM){6g+3fd;3nhGo#0v_;FDrNqMQmKRu5Pud4d=T&`RrWIRw>6B6jtm;yr~2p zT#7S9(0z{?cp3v1Cr{f@*ef;Zm06YYEu?$|bScw%q6d7}Ct-GE?zh*jbwye1!RFWE z7G3k^U7Pc+!9E5I8lz;;&3Fw$o@KIg)59YE5MDHg1zFAB2 zzQ-coGS7)A9xF?JK00{E#ZRZBbvaU$)-n%!ENpUqO3+U|upOm?Lcvo|u zHtUk|cFn0*O-Mim?*JM!h(+c~ZUFHe)~OtYnk%GfCt4)we|w&awnBS^8icC;qn&wq zA4Zh#F7z>9*ssxRH(e_kYEr!St`vTG1$8sEjVrj)A`U?`?I8|t-d2XKj;u_W`M@Sn z*aep%dpSmPWk{)HIs^v&L0YMQ>C$FiFAMlv5Wi6eYNlj=yBZy0JA@o#^B@DOrRw7~ zzg#PCjE>;4WDDtl50d^QjEEBr;B|55^*Ijrt-Cu4RF4t-D>o5vDy)z4b z(@I0k1=qSx9|Tlb`PwThdY|0W_Zx zT450=A;3z)X88hpg+MZ` zEwoU%43n8dB_RRyn()GYf8{rTMxPC)C%ul1H6WJz@TLPy3ZER&nH}-_2@w(dc8ey5 z7d)OFm3gkzjG(fBKX<|{YARhT9IiuMVDl#pe`E#sEE(ZS%reHYHF$1g1G zet>;x?3(=(F70e(F_=_ylPdzd1QxgGSc4Qboyq%WTe_eL3Z0V)a6Gn|D5BGX>E?q{ zw|~uDU!qXCUPIXdV!}|%mb+Kw5qe1nQJ&SgGOCo_L&G|sPkyTbea+cs-RR1f=#pA# zv@`oVLBuKs+l)uv!Oc8&%@t_d$r+S2vL$kAHFtIrAk;|(;>+hcfW&%ZBeqO#l{b;^ zsRKdt?B7$@ph_9F)P0F}x_TVYEMFa!lDEAUGWbmyqzcX;W2gU)l+MAB{-Bz!&xj(~ zCuEZvOF4_}W)(w1yBLDA4fU1A`}~AED5~HO52}>99`?~Hv7V%Lewmiwsf3g0C7Q{j z4U+ct>yv*ZuG>>HQcAUQ48n5UYo{F17r`ln{RqJ`)!POWLRiP--*3#0_gwv0V zWainh$9+wr6zjMB0s+M;?SV&y=1mD2cP*cXl9mT}ndj0DrpgI)|L!j~^G;qiS(^#e zgS^4tF7F9KHMc0y^aD+wk~({Pd%JFkO}`4PcPQ`VR%l<5+ZCS-*+r0A#sh!E20-ro zFX?FW(@QC3M#^h(vlr$iVI`uYCcLQgcBi$39=hq-HfGyHP<09ciPG32Y`4+PCQxaJ zd#FA$3=gQt>tr0`x3{^VL0Iq$J)p>z_|{YD`5f+pNau}(Pz_Hybe@#RAXZnPBbP-F zR(F{kFcEVI*ARkVG?oCQWBV;i~ zl&Py!E?;E+%VBCguKr&p6V)8s8L*UbrOzIJ%p-GI6>WuZVmbR^?WhaaoF8XlVUaUx zk?8tSQf$)GAR*&0*LFX3(=s^5&^!WR+;J``q^V8Af9bgR7L;$m@o?C_x&Z8>SgjR( zReF?pktKr5N5}(~@D!pR$6rXR@$T#F6;;$!VoUVzk5L_bceA%#4XP|S;8xn#m=VbNZekBtVn`(o~?l@JI?&J@Db<4=K2<=OEypLBMZfotKCcf|ElP6rU+7 zrV#FqS;(>5=4uh}WUAmuw=m&X*mVbD9*%ek{1Eeq46wAcnNv@?b)hAWxDIIOdE2|7>K8sJY=%qu zeU~VN5)uWXHXmrYC(t#+_*O|Hn+mnbAqH4n1RJBGy2#5@uNfvJkx>GAeDJNbQel5W zJ?QVl!xgb=FAT%A%I{jE+Of~Axb;}#TZ7`NYWl#~=;yKrNUin{aS{`K3fF&r$1vW> zv4hystZQ#~K!X*aD2C!hU#aQy(rvW#3$=C=@J2>PnqNMwVv zJx16eG4rtw_gHxE$QuX?tIp;5Bq>%2-!#4PbLEqHl~n9FGT&O@8M{`{yAfMzuC8)*nuOch@lq0(ACv`Y zYwB%6%*1VDf4}AUAGcgMTT<3wEAK|!Wn#D%3QY@b$@ycGn=xlH~zMt4EHK>bnBrz zUeHocQRGv}Fpa!dCx9;JkKO+dd+!}ih5P@HAC*FqT~;FFP|6CS=-B(%qNrpRl1)Y# z+4Cej#~z_V%4`@Vj$PSB5kj)c{5@~={(QdI@B04#{pam^UtQiW+~*$8bw3}E=ksoI zd@ClTRJWgZGSCxan-$`|-vKNt6wrFGk+|J@=;fbD$!SBn0tXn!T)zoz0pRvzp%|PIU z3?K=G#HFPyE8cS>b?b1ko`#HEIn<>z^3KcE!Hhec* z1@a%6JmKzuW6m{b`NNfOe;v524Eu(Cq)wmwP3P)D6SM-6m2>268jNv)7 z2lJow@Z(1x$*aHm?<0RecU-ygNU?5DczFxTJnX1DOJ&2TGehTq4+}lLUhMN9I5O9; zy@isgk|Um$84vHm(hyG(b*yZ0V4{LV;J5OO@wqS-G%WL}=1W;4Hg z7~-F{C-&RM9N(2x`7>H2$931TYT~``8I?{jvw~ ztZ=2E+5te|j}V^{<;Gb$-X52hhRyBx)|G=US3rB_fl1%~uE6Py zx*7u7)z{|+IpD|_lbw)hO@{V`7XH34TpGY*GWMkvU@H|Bbp@CR((qrkoxiXjrqpdp~>X10|yVTE-e9SCf8p7O~|RRj(P_r z+JW~v432QR|9H|ekxU67(fps6`_9ESL(Z%W_%7Wh;XkrJ=Iw@+iS1Yzs(&JdM94mWjS zg);xmnS8f9H!LhhKa`j)wlzA|E;J+Ig993eelGsY#TMx{($Yxx>b~-xdM9?@jBFEn z`D$k*zl4rax8Q)B1}dhYBOUvZBU^~pYAkCHO>2N$X}7TtBnjmvd!$a7R@efPU?+RO zj2u`1_~M`kiqznM$dDn@E7 zRJO;a-NWY^!YRCOI%eI%lKxap!;m4260I;4u*3!RR2~e|ak;(I$SB@B-Fne|)Mi9Xc zvMGgVf~>5ptz!3A{>V;${|$eeq{0NPHMbCqk6QUyY_EMp1uCAa89#&gZ_i%0cwbD0 z3GhCC#gk3}F7gK$W#6M$FA)xmQqJBRB6#f##l3|0(9mdj8?fY-q=S|Jk%|rj#bECb z7i`d(y@2M)i&=q+$?LhNd<8l*U+JoFxD7f~eM=5gn%aOp;synP9D*MpOg{A zG~PEqxuPd#8#f6SjWqcCn;QtEAQ)^w1!cL* z4F+~Bvb@IiuoGe@hjH@#p7bnuW{~XBKa&qR4X1!`vVq2dj!n8bTdP0^>B(5BFj)t{ z>uaK!qnepHAcDF?^jx=AA^sjPVr;4MDxFJqKw8t_t?w~g1B2+Xq#Av=3wOUq7fmpXJ3;A zLQi?GP340L+U(f=BL}^VbtaP)E@k)t@{hb;o0vYc;qUk6D^H=iP1?6j;O9gQ%0M8}9l0-a26 z&c_AjK>kW4K9L0yk(oEZJ7a?ZyKMla`cn*bYk|jm5tOH7SPeCj&{Jy;P;$i+Uh9LS z8Ak1Os8zI=0iBY|5yKyYRi|PEA(ci@(O;}A^PL4vv~wu#MAc3mnTFUj*KRd01Ko8z zaHK9b2R%?jt`=`1M1a!=+oLB1%n|6JKm8H-K3-c#JN&m6Evx-D*U&eRydB~(lNfkv z_5N4ERRDy%44mPW=x@4BE*XnyvMG0=+tdmwmjejv;iQbT`9ipH1YF=o>VCz1dk0P%sB!IQss?!_&u zBOI-$lPh2iSE1b6tNz!~0E8a^p89?jnjR6(xvbpJaoXU}R+2O~c zrMP2Yo0aXy|7suGaqUF1qf%76Bh?>@mYLT9z`G7bnwF+gF92h`c1=3(_oE@SrTY(^ zY=a~Jc+o}ch~aGj-#fCSqoL(7qO5pX>m7vG+cnI8#Sa|(fQ;de07Ongi53F67M?z+ zJMnA-F+HHzNTkfO7fFU}QEj|fhpZS1bfS8jzbJuc3Xm{LJF#0H@KoUA2E^ul&4#>>qo~QT!0z&%>WEURzzPPTA7!o?JG2Cz>^#fA1NEAAo z!xKOh(xDZc7X|(*fVm#XL5b}}@Z9Ln5Xqe2_FJ3F9}yb^IX+{*Z^k3=OQ$YA|H--a zg%ee9)K+AVBMS>1$dE@j2Y=%~=kicv99nML@7n~T(jp;WmN)9))W%{*ncvnHV&kQx zzzfuW+H3%>nV+4Vt%ElbfH{{1)*g%+?qznXo;`iAcH})DVN~NEGi{gk69>v~tJ^K?Mac452c2)MVXm zy`Krr6Cpi|wke3i2OTqI;kTepRqhNb;aP`B^3uGw@v_+3PvSw}w>C=v-P*vG?GZtY z7(?PyY77K~%DH?Chkz0V-pEeP#TO!oGc%9Mf3=<0KKJJ<>bly&-ntX%^(;X=uzoN=U_M4jvwtfu-2hcips(4n2t)vlCN%bx+< z^!szTJ!HUHH-AR$ml4{QkCFjO$7AQbs!{e3mDpJO%$A z2Uisij8S<%qd9b@r6*OCK!l$M9fE(E-iczA#&1#+hS3`kdL2^Zkri-8nF0UvQ}P^e z5j9XzgxWTUcur|pqyWp5II;?!3A$_ZcZPf0>FXaqr%y66;C)H z)3_76@$&yP#OMvvjs=3B2J`&y^#4z60757D>CP8Qg$3Bj-25yLpbkbnH3&YD@m|ob ziaoI2%j#Lxz?(h%D6}6o6^h7CUaA%^fgm{6s)9@-+mU#Ln_4bhfAv4>5l9LCwP`xiAdSk!@%_I7bGv?}sI+q=v; z9>&Z~fON2x1^W5jyEv}~I@bhu5B&DYQjBg8?5pA0YsUolJw~mwL``iRY>c%Ij%)ys zvw-ffBJt?~gVa3ULfNQYk~zHlBIHEXs_1gxW^BO^*w>@8`9g9$r#fl!nO@>>1P}AudJR3^yWW6I)mFSUmU>5pF(eBS+VP(NWHY zYh77NeUCf8*B&KewDQd;G7sbmx>S&Dbus_Oidzb&GXMTvoYyBrFqj!W+;SZcbC9Qu zO2v2oFu1D3p@xOUzoO)og}1CxiMn(P*?AmUiYf?rs_JPgYP}0A-6}tIFMP;-9+~B} zcNnMUP{&qQR904Y@)Rfq_K^Y~VX8S?I{YJZnLX(IRQDB1*q(uruavoA3p@t?_vizG zd%213ytR*OX)>?CCRIE$Pz*WTc%#VC62HnnManb77Wx)0NRKlWd5~c;+cnP;#aG9F z|DFa55Ca2?slqHf6SD?lJn5JHL;J(%)gL)rl~o(dbp-wr)Gx{bweQd=^Aki&6yU3w z%Q@2$T0QXwpFoCAQDOx9>x1d0FcCLs}q`m|acT z;?n&!;fCx&k`*r5_fb-mh{YukqX44^^y-8qE^GZOoPmYCD+?-A-9iL&<=WqeHL&su zV2H34B7F|3ZdXFjn~F+~ksDn9HsEZ2R)3*PADvKffh8`iff&ZwqO;V;3u0!=B|yxv zGZifl>K^jcbNl(eU=JfDxh(}sb}-(z2&EUcA4<|Pylik!>yiHWvOz>I8ct^6&^chW zi5Q|W$&^ZtOsC??ro?>>Y~L+?qx8}wO3>?b7v1u-+#bG#4KmQfm7Q*HXi}&>rU#87?~s>wcNWl7Pqc z&Jvy7e!K3*1!`_usbXLLfP+aordu71UC#2>(9k`uN;yO?) zk$ozN$m1uRiC`K}={J4=I+^L5rG8NNmdY27a+hShH8btBWA!No9GSHO=#piwX^Gz0 zM1*Vh)d;&dr(@!!1KgGFX~vS>N#2r8f#_zQ6g|d6I1|le*)~zE0rHMNwU3`*pUe?h z(axTvC0~%8Tt`hwp2PwNzWhM2{IH^PVxnT0gpJV81A1mT61k40A8JIRCH0a0(>G{? zEs7bp+1@#fq~wBJh2F1CYFH%QOE}?A!81 zHK$vl=WE}9e@81lZ2XEroxxJ6;N#9hFNf1;W4Wd$xp@fQ<+IM`fD3vBV5^t&vpc^9p8Q!%|*SGi9x-#m#;Zo`S#9G@qrBLp#wFK zDS6Fp==^4MN0Cq@pxGD}R%`WC@DvqeOR{XQJN}W91R=ZI+pbpRq5o5LG>&^)8GQ3} zZXbiXi>1|5y57HF^l^soy@{2hv|rsxEIg z2r^+J(z0WpG7l(TU_0%=oA-5)LdlseP@=$TQtGJYHIg}~=^m6Fh$?hcV7$WBnvIR` zrpGzNnaYrg_uFaK&G=9OgXlg#QC!kU{M(MGR-_T|S7~!^n8p|Nt7{4@oubIgeX)>Z zAB}XL7hDoH8ykQ#xTL$g>1bkwm7AGGrIP*m=?Q^wC+FMQ* z{_51B5see?&bTBt4qpYprdY!)H@#2F%wSXa2obogpN9#d&H*H{&){fbPThJ%E~XxXS3e*@3dxihpP_N!R2D@i z*TKkm&PN?4MIH*&+}luV(ys%yDH~bej?wHN23{eccDq7Ykpw9hmz=Sb;3bG_u@Q{m z1Bkc6gLXMURaoaQy*hecgV28Lg)DQm9*OU^0?~grv+9w^@*I%PzHvijWSJE-rkrny z4t}**2M2C0v%Z;!Kp}y?+L&NXEZeU<)@e+lVLH%ASM8QeFJfU=!MK3|6O&yyMv06kyMm&0I z{4zaN1usbC@i0Li^}#Nx)h;lj++nYtE2-V+vE~qJH-xcgd{U=}D`Li(_({Vmk)%1LYVuP> zrrNXX{VVehKW-s|(|me7=ru$IOeIYAXe2&*N?yg1!%UXcSHprtcf*oP;`L$02Vuo` z!?Psrvx3imMHg83k$qv`U`@7FbYJ~LUN0w0{vV*){*Sy5VFLR3VSXM@|IW`Ejar0E zYPgxU>}|3u|u1e|SWX;$}s-Ad&r4-#QqLkkV#$H9i6 zIn!s9aCbg{7f;gH{T@`nlyx<MYj*v@uT_ba5v;qUz4c@*{e-zC@AZ;%dRunf9ZnF`wvsb4ja8X9FJG%+Dmr-_l z^}C-~8bk2C3d%W(!B7=YPzH;D?hl;qZS+I2NJWsr3#Ch}S0%!$3Wm_EwRCr8t*b&$ z#h45^xeS_8M}@TCsd~fDEOh%WR?g?Iy0^*1u58c-ewwtr2RREZGnkuVFw9vbuOP#+ zeRDbr-I*Bmi+Zkp77fV~03rB@gg^u$9P8uSvs{8nS%y`!n_uRKihbqks>bVgF9ae} zXIrf<@61MI?i@1F4Z$rI`zqF!m`a8X@qcA1DgC*{oKa z@2T|AJ-uo_sxv>!GcUhk+c*Gsb*a7@-5=q%0f5X*!Cyr!u6Q*P6?u&c{%Y2@9iu9* z4`R^+O=?b>R$v967(^;<_OI~T9v4)V$0}%po=U;Zp(j@Ri-<#5gzI~}L3{4NYLAc4 zTaTlgk8?(ik?ONv8w#RFz1Ln-@>A?0|2SmQHB@$P6*Mg11)ZC*U^T7{o}K}mZsvP- zOmwT5gS<%7YVnSlv7}MO;`e|OE3oMg=k>T#Q~6Cq8=Dm!^k*wU0;0Xi5&Dn=yuG}y zRtEkm@n|P~%6hustRFXQbMGZ}tX)cCj_L9HDPw9HSeWt^?Lriwr$F>4TyKAAkiLqJ z)!0sM!yy*UT4vbes9)yV`Z{vXViLgiYqiBv1<@Lc>9XV!L<$R7t#H{4H*+L`x_8vn|4loHLgUNNf7Zy=HSWAq_+B+(d zw=)pDbx_l{wyB?H;!mO6TkKc}K7CFeX`E`s06xUc+V- z5-Cu`15nAi<|o%6+SQE)j8SlJ!fo1vnwNSjAu3$3WD54%$W-cj=r{Ds9_ZTe=WHM+ zFBx-V=@)o-36KaQ;I-kYL`JMD%;I8rN}nN^>k~~}u3{R^7F9GX^diV4kHlE2M;Ki}>`l{GY zJR}1hju#C|IX4TGdw+<$CDBM($@8$bMNW2luh6GuJv3dm1)7|P{S;Xu(VX4&_2+6% z0x5Um1W`Q8Ae#=P?CC?4D!^1!6}aOF!G)hGbr%(7!&362tz!PO4aq@4yWAks8c?sS zj#{*$zN&VtXwZmQP*jG*ZSXmW+pY0Q$xVuuj(KqB<0?`R9@(DG{&S7Q5Pr4NP<&O! zN-jC;_q+ReD6fleO)c1~(54*Bki{1%&F_G>XeAlx#?BKsCSrLQ3y$e<2fEYRG~PQd z`R*dL+tD0nPu7m@8~j}?m}!{9IYO};WJF{Tk`rWqiVeK) zLs4>}+`6qN_875IyxB`X^Aj3%7}r^3P}SL-H3B(U4*mJ&7%$WP7`!}~ZV?DyY?bKL zvaS}^MH2IG9pri-B43<<&pZieg{h`Nm2MY}QR9s?8U?|dJ$ntB%oxdz+LJT+U+P7A zamH`oSpK=q&-qlJI%FW1MXrfmP~USWv2$piuz5`a1S0csw-t4v4pvBwNyEUmRhw^T zvDV<5lUP=n?=r`p^N;ENpj3_5?mP0Qz;&D=Q{@Q8_9R4(rC`x&A?q#02uTWg7r0Ef z-yft{UR_nWtj4k1@z@@#&mLG|vFhN==LS{sB?hpn28aP{sVK<{m{bBFE3Fj4+++e0 zG%@n^fruiJe!NxyzpHPQ*!aotfG`vMp-K1T{CT(ik=KDbJr&T|)5CBtbovfA)SDL^ zhZ|uUY-`&SMm_semZ1jp7n?Cbs@SQbkb#*}UdF?4;l>6T3^%hN+(vJB*FD1Yu+%wd z*muRP2$zQn_lwIz*E z3bs`qth0+IY%bpym>UG~c#SIB-V2r6b*973uU*H~CNoAxMk;va19QArf#Dx!*Ouq) zaryF5NPVg+K_U#L1npl$lA*JvAI9N1bx9wLstUKyYKs+_WnL&+cI25&nuVT^^Sk7| zAhFp6Qh>LHX@)D~$cAykE|q-_nwDv#<9*W~EM4#9MU3n5%z_YrOB9IOjd6hA3WtPa zfSwu`ulDLxhwOH;vTl&_Hl4y)9(|)%R^$2u?tq!xfha}Ot9&l z&D`;gc6BQfIOpn^%dq{KkKu2D{I016X23;3bKl;*S!q#O&jz+jtI17qMIM15d#13P z75hM=2AX+v6r$}S$6X&vl4AuagY_US%Mfnro)zH=Z~ig$9G=u7Gga5o(UB&Ad(8}n z1=hg*Hd9H7C#>APItpJM$wiu=(;TZf0#U|ZG$%rkBiG?#*j?hNQnfr=gs5a+jk8Jd zhZ<*UIQf99kSmCX3r{9*WB(tBavSZ=IvwbI%ELI20e{D&vI33j-W3nl3ds9}c~_N{ zor-n?K*M=yu-2;_rjE`-T?6*(YIu*c^l25GGs|xL5qc^I*sok8v*8ucJ2y6_l4)iv zvA|IEaFE!KZSJCZ)F$N+XxmvTHRY%>4a3$l@L(-=5zV{et zL%ukbz=*Rl_dZC#C~QN>(VnX3a2Ry zp7UiSI!y|JoE(Ph{@?80qBTErW7&=Lav;EwYXD#5I3CJ=HTGop?97b*@Gec`B9G!j z2Xnn8ql@i?psNBsm3$q@p0H1XMKi4~Z@LYc6c1%^VxC#3gzl_=nNigf;pq3e19=^z z(Bicx5rTlq5g00>e{0JRq8gO@wq*yIu+C)F)5EDf5ZUuU1k)BDgp24FQoOFw=fHkb z-_2}iBxhGr>Mmg!XGJj6TFZ`+z=FCB-42xcQeo3zE$plb_-_7R&Oh4Wz*QL3e~MoV zP)@+xo~aJDJzH2^Ql~0O^IW$}*)FxnY;ztHrC;JP?pXuiHlPR~X4o^7QwxKdrs?LWVu zNDdMSSS}dF7N${bR05}h&F|%6gwx2R;9-QG3$($BMT5QDM|wcqq%xG1*!F!e7@qdu zXImf9=3Dl|ddL-)$m7T|+jC;!YTBsg05q*B5y?J_?B|hWnQvjafL66L-fKWhW!1jL zMywUU2yufO@J1{B^&g|0`I1iu3%zDdo?cTMVGLk~>{4epcYMw$Hs(bXK8Vm1aj2!g z-HIAhqJ$$tEZ()JZ(3rxp~YD^I>gz;m5nRl8F7Rh*$9jSShUQ_Mm0g4I%Ed|r>&^L z4|X1bsmNE+y72gJ9civpMam7XA2Xm-ITB^JW0?ZD!7`|W{$#$@Qw?*`46E7Y6>vsFD^o#?IK! zqpd8E(-<$MkUL1xB;M>i(Q3B4Gn$7nMX8E*`N28=gX88oJNlR@u2+|OZalQ#V4enE zGp@vRO2N7BOTa_T5~E&sKYyO{E_HrBm<1s*==D&Kkm7K!QI*KexRaQDVA}pgI}b^$ zpo+b5LAzdZ!6~;TgCB-$9XQm@WnqjJA5r*$>wb~rQ;Vxo+AmZO1r$bjS5;MkeH4lA zx#xW-pc+=6L`F#-(5%Ll3pO3P2-0bSKYs~G)D&~2cOlfT;c;Y_tHI1o^U{Y8QjiJ5wHFYP(88jX2IJyElfx?xqJLeBaXT}3+{`bM4oVz*MYNR%_XZ&p@Rwod4sEe{MU4@n9(w9i{IJp>X7y!;@C zCt$4cq7cIJ%*WN$Rd{e(QJ4}$N?t(?+Z*S|^;9ZUbLsFf0FkWYQfpsJ$TH*pDol|F zRCt1k&4n-kL6{h>=nZFPit!O;>|QP>Dohj)5r3#UxD~QL;L`Je`2>5e+XaRoa|+@d zG2J4Maq9ci!*HFcWesrWfl*9AhtKqd5Dq_&qQ#JN5rx5xv|`lfnKkglEoDp;-`$LMRPEm9>5yn@x2EbHEr9 zR(_Zqn<@tmOpPT@oqeQ-?i|NgvdrH+jCo2`cpr$E*8aq>O45)a|AOz_rh(sHc3hTh zb@G0Fzx7o6p^h3xc`fCwf}<4q zzLT&(GDW9~W!GCpkGRU7C_?lZ#KwHhknuVDt8>#I>q%1jLSP*Lh;RZ)m%uVXEVA)6 z|GdKb==l5G_jlRW3|rrtd!zoC5gqwGmqcU zMAV7oP4NbQ`Eq5o0K=++OTNe+m70WSRVV8dyq#~_bfQ7OFIx+6E$Te8Jp($^uLnJa z0b`@$vbt4xBQ%9e@E(Q06P~APwZ+L7^u%5@U0CX|Y{G5`PMie&b zCy~Di)FFy5Co;~@L5@{d@ZON8jb1vfm4c=v`@XNW0xvbyuoWjMgH4-enX7^DV6j|e z+(akQ@xV}ebZK?fI91Q`N}A=Eja0hRn2NLmr7+u+Ekko1uY7B_R}cE^N1yn6NGx0> z)h^8^(~Wnxg$BjLMP&%q2x8uO6sI2F8sf>Bbwjn`Si!_zNCs>~Ma#mCzcH)NGyqo4hRg>MS01C4TsMhYsyU*!=GH+Y9)vu<*D3G+jA!{W9wKB zrd-ks7iOxBBc`ijt&E;K5)pY2J*q(se8x!B{Aa00N9X;Z3D}y1_M}BEgDNn#8=(_g zWY%0v&-=35sZVyF<$=#+lCk;m)owB}U2ZnbnTk|Q#>Yp5U_Y?&ixsFKJl_$EM*&q* z)YD*slt+H7=v>*XzUn))b?jz(_B%h#O$-0c^Ax&=O`*2vs5=9P&yOcwx`TxoPFdAa zb{lUE{dXx`f)x{2Y!j%4OiX+#DDB=$>*?W5+_mB2NPvr|{H5L_NEsfxOp^ZR7xw6x z*iFg;VY*0;6@D{Zok{OQO%!dXaGydu^hKlX0isr8ya5qzA9%OP$b=v8{=qoojmaBM za1!1?OY?#UeYZb2Z5m}b9dmXMdzdTrO|yIZh}<2o>jB9{M;ylRViUId?cFXnD|F}e zOyJ=2U^5;v!r{|V@;V(5)@R@)Fda8)?6L;-u!~CtV8_ro>#6U$yVB97I8rU>C(|s4 z-k7SC*UqlIent5$^jNe>{p~-L{Asc0Br^j26dM$XkFQQf4&OqAjdU?Ty&q`~g*S(B z{288Ck5ooTy=t`M+hlAK_sd#p*d2BAXIGx-b%_OAecuBjhI)8xrHoBm9*AUj1#_l4 zjw~s8jGq8-5Xcq;WJf_frUztlKoDEH;E8X^=zBOSyh@*fR&~j($QB*~`F`fFT9bGO zy2C0Bs#s{e6OU|jE;Twc3EIij-@0Kvky{>_#JpP-VLe_W)m}&s2F{G%%Y{JA8vN*N zn!H&&KHxXEL111jrw1HBK%~Mho0`eSvN9?b%-b;~s#<-1V2)6J(IsiOxrgD0!CN5&d@nKx~ zeUjvP7#i>3vNN=$JL!`UO#L{TI$(6!YFPQfq}m&y=ZgskwGn zpxn%M8G6n6&Rz$e?e>fMCP(SfJ)86L3J|Y~vODOh(ZPmlp$#RGDBIv}Q6WyrTNaoH z^ID_V3;1yQ4>7N-?mtsu`|byv&xiMdUEyZs3nCo$3Od;N01+`aSbwPt8gs!j_AkJ! zKKw<|oVF8k*)C8x9c7JXy^@zk&Ix^`3tusyEkcLXsF= z`*6AM*1Qe-l`(RNrdr7Oo1gH|i0bw#+nEJrBg|!WRbngS_aM%j;WhBs;>@--D?;LH zDKA~Rbl;n+Um2s@`ws9T`5g5 zW|uT)90>S;KqmeigZCjxQ4K;gM$q@XBDVV|>2=9#kD};qV&3_O_aK)G%#+Z5lfyiO z!k;dZKKlL#c?pRhGKO)^>Qb9aJ;3bsmuyD0CwfoV_CsPRSwbpjdSv5s;KAn16@@48 z4!bB#eRM-uwu7&i+)Od=NQY}h*tEnjmsqsKJq~x1+KJwcBll9qYJU%`mY<(@*ngi< z=!wT^)54LUPn3s*Oc}R79d0@JV{W*nuLf^QubwP`F%6#Yy^)jMKEy+q@XOEp)n|q! zibrr(RVTd~#1b);;S73XSVn(^${V4A+)nDdrS?}*jTT-|zn4P5cN6)TCB_zG@7~Vm z^Jk%NW&HCZwQj2Z9=&>m{iM(SC;@jc&#fSAn&v<9EU#B?pLHMmbk6yXyFS_xQeP#ctM}XP0Pk&vtNf9i9Rpxd z5)aD!5MDk>2>;KaoW8UWeVP~8R5GWR%SPxab1!T>?`w)2Ri-3Y4X4%9>yO2SXLk2jmgC9qr z@L`;}=1G}~9ozc#N`ccWA3w=&uNz1We0Z>0ULU^VXA~oWnok{KE_e0}d+<^4;DJJ4 zxyjFULY`{66?Me|>g#+49wg6w5&-Q%e(`YQYteaXyx{-rJfmTZ1b}czq*jX$$^P5< zZ-XW^U@hDR&hRo~?{fz+wxqq7JTV0ETVCl3p25kK-ChruoT{J&xQ0I3cpfzQ8))*u zgm0d7O0%d_ol4Wjv#L6We+t_nJ@W`!M0xlsu`5`{#E7+vw6MF3h4I7fNU(Xu`0@$_ zX(~yNfekLAk}B1^ld^7RxHsk*F;*8|#62AkY{JK)0X82g-WgdK|v5*356gqO&8k!4z~3SiUi#LGYUi&&{K3Q|lIl)UwXzdJ*ID^vs#t&&(fvo*2V9FsoJ!Wwu*<*) zIKaJQJg+d-nSD1b^X{u-LfvD`_uzS^j{TM@4nC~-g(<%pU=3g@W#wc(E|RffC%gKr zp<$Fn=>X6Pl{PNfa%Xn(USL?`a)aWhzhYIhSRtN@5>&$3(NYG#j>l6KL_*uSq+EM9 zGlx4d!OkHO-4cdXx}dZ+FzjUAB$n|Cm%Qvf82La|tQP;ybTPuKD6b87zSelS zfZ9?29S(8;FA0i5GE!1fjypq^n~tQMA;JWkf22Jz8#qIT(s@*>I3na!F_&o+dBS!b z8*by<3%A5L^}B-AmUlgeF$}Aka$pKqA8N|j=;*|x zGhmWVQsyeDVHv?CV%qeoyX*|;3S$fS7$1l*Chtt39kBDG5w|2rXCYe**d1KB3(~R* z42PdF|J@g;dP_B@hZj4-#a{KM5T(2EQ=~8>EP{Gu}BlXekz2baYtx`{$rr zpg7Iq&AQrOOYAsye%OZC8!Sy~yy=(q$i#((X_*{u#yO_`bNoa;*ei;sF1zI4sbP7! z{vH{=SOSot*1%98#H_$EirI^YDu9A-eB`_Murk(3-DhW{+%5o;9Zr&1bbFisSZeN^y_nVr;aT2D zE)q`7*)&Y&3J)Xwj+|gJ4Y=bKg~P=BcnDHcE&Xy-z1ZDD$^|!eG|i43mc~|8;s{l@ z^yAZEJn0$+Rk^uvg7zLHxWCY~kti{<#1uRN2K6a}A;%7)jLD434We&`%NyLSBNy|) zi6Tjg$^@zpgYNrg0phc24pvrEX!{$^bR5B`t_f{YabHUWB;iEwyOXAUec@_Ucmhxg z)3>iEbJzaV0F?gsB;;k(v~W{FM*^ktTLjKQmcmjM36GuyVA~%cMcyn<+NVouX5lUx zgiPtl3r}-$iw7%HpUG}|n6qvBel;szw9LjD8vgW7Zf{Pk|I(XQhoPHD z{ey#GTn@K5+D+~WxGJcFy8`(8j*JWO41X8b{xEg>k{0%)CCX-jMN2*l`}|&&^Hu)D zx5LyG>sq1>KOc^+ZN)fgjGsL?w60@Ob1uqm5A2Q=jQ3j540q+Ez%GFkGW-}uqtnDk z7s~naM4}Vi_9TSD<*_`6BIogsv~ti~Y=r>=4IcdDOZH!Ur?H5Qq~2a?k^3Fh2}q~j%}qo&}st+a?^Nzc0kTojN76YK!3VN|NDE_wE{7%I8JC!Ud}w3) zPt-5dG`?^{IfAE1W%5KwsfjP6R?q&dZP<7Kb-o&qUWFqPRRT$}4j=!;Bl#{e&@*y8 zAfxJJ!u$Fa3oir9?G>Tz?<+VL?f)?s6)v#9U@R_6@DRd=JUcc=w-;u;LLlx1ttQ8? za%tyHHL?O+QUZq6xhU$g-mgIl-#>YSA2&88N4MXVcx6oN=YxC zEDyThUht7?6JrcGcvb+-!1@#~L3qv~D2pc2d8Fb|{q1oFj`U0Z>+k&EFG7_B(#!~1NHC`Q2jeE=Wms9g=DA^4)r^?SLcS!p4|Wo zM+@0mu;5nHTOj=tLKmJ*-tqY*8%*30K#cHiIyJpiC3UPbP1K^@O_n!@w_TUrL}pDp zUB>IK*Kb=^9TKEfItoI~j!N^M*cT5<{b*j0jDB4-_-@$qqmE2<%RDoc#q%{YFU15! zHRnb;6?T_45$FnO1UE*=p~5|u+Fct{z)Ng zmt4p(0Ty@ktCm+U)5npD8%hK2VptUllve`dGE?+zyq zcTNdW*Gx-KsZ-vMEQYpS+J7nCys1X#Q{(-By8wjw=k>`w7)q-|zgjCBV;dV4n~zYY zQ&$^0wsV~17#imM*A#rt+SHYlO;*(RmcFGrj*5uRzvWMpeeT?T*OGH}ct&|%Op$EI zp6lhBQ52*u(cR8{?v~J&ygpVb>E`a?=I-w1-tj(y=2viWL%NE%nS&Z#d0|dyx?D*x zpSsMXNX$8p>-@sBc?76?%b(EJNMOnII9!i;==v#Yz=kxmR zan?PxdDk7cd!6RTcZ(Xk_pm2~kGiru1{TP%FCRhnAA%c`B<+oFVhY~yybXR^&+0!@E|EQ}Qcr4>|uI6sy7t(=ci1cIN&Hj=8WsY=(oO5Q(QYa@|J zZ;UgfYCUu&YyW^!Ie2j>@q+v0)xm;-+C`YI#7e$17$&tH+FCRlzQ@^hX8Up67h-hW zm*{9x?bwqKjssa&3Ic9_SNmUkoWJ{dIzzxS5kBG%sJ>5R2Z5qjzqIi=f5!We>%7nR zSG#4Nl?--2e9%qxH`2_U1t?g5Z~`J=NZR~;UUhkCNzq|KIlo-e^J`Ib*FIj4BTs%g zXD_Cwm)fKJWU%2t5aRn4*}Ji|v?L+KWpV90Z*|e2^ouE*8JU{ud#f}w^TQ0~JS1RUS#xw<^k&P8rCcW@lw1yo6EQL|= z;|8fdn4`}3eNPkhrTxr?%sm7D39K3C(@wGl0}I-GFQ+QenMqvsY%DT6Zrc=0qb(uYNX z_tEpnD|5HETdL9*Q}a1JmUOel(mB$H7p0|D{QK*yX9v1?E2O~A*ndRw-iihIj!}P@ zxm8Q<(nsIlPl>}Z)CcEMHcD~tAV?~0n4ObVn1RGP%Eq#9_mIv28Nr>jgFRtbm2m=3 zd52#WT;ASBHacx#6=ImMlDLUFF zZpFHtR@y?H#6FqBSRx@pYDD+TT^|ABjb>k z{WZ^m;ud|3>3!rT;e1?_adwm)Ud{T_DuY4Fa(83;EjC`)1rH>Eu0ZrIYy%7`XRxH! zTAG#@&mJI$+77TV-DjM(BK#MZRQWo*rx{cZK}*?4Y7bYp?J*mK78LKcd2o`hr0re?qtPshr5sYmbx<=IAP|o?H74ZLad1S)NM~%# z@O!vN`-N8g379unPuPii0=&ai>0j}rt6yccNyx~^kZO3|Wf$kh@$BfZ2{!&+>o=8i zGle=oexyjQMG#8oz+TUsjeO(_e$c3x9~8gyoI#b)?48P)d#khQRbSr{FUiO#mkp$= zm*ZPb&29HX&=zZB>x@LZo0RN#pRF7aK?no#a5%f&lB0g}Q#wAs0?pQ(K<2vAy8F+F zy1ulk`OoD`i~*vtLx5Y;p4M4z!(F-LKa<`e{U@hX<(s#}9NY9AC>=tl*D{78J3v{rrXG2!RjXUk{y>C8eS$tQJMC+os6dGdRe` zPxnBiB8|QQv4vY?6}dJU)iZ>H~Gl?B^f3x zsJqmNV|X=CX6{lYcX&6+^=W zA^WOwJ4VO1RYHd9vTGr)B;zrM7O1T0pkUJy!chaW3K=E7JORYIER<;)F{pAVDK9Ucd3o%h+wuw45t+3c z!*RbGNVgZ}zWVun?-l}AU#2gwX&Ix?QPlB(FT_{wh~@T-XU%W z9W|~Cl-T2vp%5P4>Vy~p5s=&UW@&G+DEQQDCCc=O#rcoFi}4{PXh)uJi;kp@Imylv z-T!5CUAZcoK(oaWUd(z~5Ue;II>*-qw%HYs*~sN|^t9dTuHPJ#Xg9lctvbEamVblM zG#EFz@#e$10sHFWD3D>en|vIJ9sMS8ctT&1T81kx7hX|J2}@DoD_c z!vo`mjtd&6n;{D3CwR`JNM4Wb?*wa1t4>=~{AAPd9&(cX<7_w7-`CgQ$Fbeh)wlU_ zzzsq-wf6$~)ARU?eHeWx><;I)?=Nud>A#*e?LJc5Rc|-Y;ZeWYUYgMazU;vKa6(!C z0ew&E{(>*NC5QZUj_B`lW(WMeahPzg=gSVCGl78DtK%XPJ}jsRo!$DLJ=V}w?8aG_ zamBVno)t)In1C}3rqHinBzKlJtT@Ti)8cHsnJ|;ugN?KkTjwg-rh2| zzkfq?K!|Y4`f$2=)mqDakqyLH7FRW3gsBE{io5^I3WY;f$YMr9?o&TJ^fP%@thspY zR#m5M0=`N0+m&k{M{n7rkW-T9LEl^P^J!7=FbtFy7(X&`hG?(IQN#At6Mk2Bw*&KR z>?1WcIeH(XT{WjfuDu%&GFuFdFWj0gHJyjsX0ZoZz*GvBn6nOSB&(I$d^xM(z&|0| z_ajB4(r|DUz{uArQpUB00W%2^KI}X@P3(NPKh#uDv=^TX{2`g)I`)0rRRzy6^<#SO z(ZvyAQE~BV+9SNgmtS4?zZxn8=F1I6(2_KRs*5X8^N|uSPnlHM%=#`yO9_S#kXp%W z`qno%yHieIM>j25g+IN(f4r==JO|l<(6!SDZA@Wro_A2|jjMMX(d^;g9 zpr}MK^9j&|84RlcxGplw3Ys;4{=E3t==Zu{@YQ8QrUlR!Xevh;T99pLL-#bRTp;g) zJGf%{!C@-K_SSg%{f?m;WjX({6B*ON2MEcCa0@gL$iUoUJJQeAGcESETPsAmW-^BT zlko-NwPM1TSHt&3BB2Tov2jBRN*pa|ZPKN7llKYjTYr@z^k#i!Zt$r@bIgaQ_iOe? zMQY|6VyO?v)~?;zw@CfX2+=039HpsH*#>1WiwWGa%GiC{7F0Ns9r?sXWz*JzJ~;Un zJUv1KgcwZoH_^S;pOL`CNJ~e{O+WBawK1-)0&mBeP0bLC&rEdWDoEW)<-|aO04}n` z=H1snxSqOr`|{)p`EhN}B|qGKrYo%nkFEb1_V`LCYdUW<-wrinkS(oX#$YKagD;rY z*eyD345+o7lC!^d&2vd_xT``m#ZY>ZGf@ILbxZ(HFa%e^mp+dA=`_Z=$`MUbu&3Ok z{;hk4M1maJS0-Vo2LPV1uo<+vgZ$zJ9>Xe;RBhoRvpn0LdJQFit`$j;12WTg-xviCls>^+XX_jYj3`JU77ec$iD{*ZG#&vUQqzOMUD z9;#hBgJ2p1^YUx~4((Rh>dM zZe_w}Pyf1u&OOjMr4<$0Jn`^zLHqq9Dtvx;G%g|me8T#{!XB<1yW@$^Biljs^vtD^ zp1^~kV!uy=K(RpAv^GvJ9Mr4G=<_->mGs=NC2@^FcB6 z8JIkm0YF~vbh?A;qj!q`&1uUMP`CppHa6Dea+oD?{>P%au|iN__M&B!`y_2ISA&(& z`{L3};8gSxm@xsUiC99K5UYMx=*eSyj#M7fY1iHQbTHkeL0zCy+NkIbEeA#($+pLkd7 z`SsEiU$y}P3nj{Av(nm3I1c6!>S4sPm7i(#!Izocko{rnjc#UaJUK~J-{lq4T8}-SRjkL`0xgeSK&#yWF4AfiWL!mN%v)`Ha9p`djyn6(Ya2BqrxYe$ z$Mw9LKV*4%Lxf4%ZG)|G_d_-#|0AJ$V+eC(v(J2TYB4NyP#s8*%hw8hQFmhW6+EZ^ z;)7zV$3pvWn&Qaih)05n~U%j#|7=Ah@9@-X>8@BOTy=A6V?I`PYvI%_-k z-)Z0rzO)-?y}>^D!PYVzP!*Lez-)rwnBga&PRk7%cZ9#4J>p(&0f!A3*wp|QxF6CD z@`s$d|1=(eA`Zy6c8wlpmksoSF>SweWCOqEEl=hhb@Zr)N60O;FoOr4rltq_JB~n5 z!21CRGyE|e(zLi8E&^A7U9JYywdT5Kh?Fs$V?;#!t)&IK5;us? zA}~Pv=EY>NE=;)EV7~*)?#Iu8Z+Vh0_S1ShdXCJ)fD@s%vN5>c->8}z6ailnu$&$5 zYR*Wjsy$|bZ3Cp{gLE7Rz(PTe6Sw1IpJrw+$=3l#n84Rw(}Wj2l0HF{dx$-Z|MNc3`Y zi`XDp;g3Ykp)+%A9ozuNEgp1HgZ&cFy-8YrFm#q`>ypa+yQgWW$K}kTE&H}`* zk1fmVo^_<_&?{XHwUvFz)0SGB)o=kAh_YQT9PV*K2V=R@YT_j7XRv zdMv(O?Xde9YD|`8;zMR~?ezzsFxxv>ANPaP1Ah<8_&Rg7@LD+mUOi}8RK^$DVB{i& zY&C!iD;kIE1!pE%_?Z{S#6COM^z%GX&gnilzC7JYHb=&y=M@=Oq4|fOt_QJWk4FLF zeuPUL!$l>W0ns!qG!&v`*5G3gJ2(K?_>TImyvMvM5GJIvd##fedIR118L&((CI2?sdV-Oq-(T$kqvi*v z`t<~HRXes@WLUiPfE6@SX(t=In&#f8x(n~f?-W{xd+z_q+^@rL9+nXgu%B@5;V!Hx z^a2tMaF)wJ58*gn`5G3-23ohtX`zrS6Of|3hi*&7nFOwg_I-?vA%j7E9h5r~h5jeR3IR5}}tbzXJGtjjaKCbR7HL%Jh9lq4V+N429(*lu2vNqr^Zw`zkyjI+P%}w6@vJU zWEnuu2!kgMucpeEv;_Q}Ur`LSP^un7P z8=q?~4uZ4crif1YiHcvMV(tr9ip@d;K*{n6q^8n>ppdb%zMTt}e!lJqC#NQ{5qWEg z9_st(xC6C&I9~zIw)YDRFEuf@7sAg7PV_^hdIca(O8@m?7ISpoO)crTItSJJ^z|S& z$MsSb^fIb=W@TL&aS zM7P>($STW0mo8>!K7(DhT2Y9(iNY^%wT_%_%;JXubGQl}hLe#7{OLSnzya-f0XynDxP{i^3+Bidx%Y9siR<%nqQDNsED@;QeYAWC9R zS15@mnMOz23e57QCgCjy0D;*h$7L0tB5K@kZy!{DJM0ZJW%D3FYIppjA(bLSM^-_bO(HUj&Q0(m8Y zj1e*f>`LapK=40i84Z|*tK}u@J)odc_Ve5|y&Ca(Pch&{cWF~RsP1upZrXFMI${E( z8w0JM-vi0cGT_*E_!W5p6%@wEd5H15jT>yXO>DE6KY_Z%?nL8dM~C%Y2S%Bn!toes zEtaVP5b9BD0%d@!suqK3K|%!>Y}a_ZvJ9M79NF$Z2odS>$H{#1tB0_B*VQvf!;5K? zCh|9+X}1op{5m@HCIHkZ<{*DCf;s7nn{(2f`7=bT+PQdpCIKDobj9@SO>fVqv)Lz+ z>fd&o?#%K1TKXt-5RXs~Bndy?d0U{Jt^@Qrhd>DaNmEh=aa)&>iG6Qpi`|j&(#DiY zLQFhaZ4k7SuuLI9dXN>JKn+yXx>j5Scdc_A#+~4%O+%Yc*rBs7YHITpU`ZpWq0aVq zcd`^O8~%8*>AqD=v!0&vEQ;EX|j9Ovb_Lwa}k+se4(RQk9Duzeg8Z|ZrWZj=5r45TXI!(~RY zcZ&K(umH~~w+=j8N+19NG#!k70+3iBCi0bq&Of0e*Kl6a_~$YJW`n`Uprdx?a&uQC zHvjs1c=&oi$GFKEJtr+u=!K0L$@ggD(oqh4K|ZpAPkVbqo7VUGE>RIjM5bVj3YgZA z@Igmelnn^)7zd{R-gwF`E;&tbvceYwH{19fuMqXazRiyGG3;PNTUSlrbdzKBnnE+XvE?~(o8$~Lyb}i+MPfQflhrN3kD<@X#x4pH0!rD~v%l^cwe(c7)TDLgy zs?)`5;~E=#b|tH?zBQKyAOBB61qMg_nLQXhTJ1|c7n1XRINyUpB1B(G+~6)&pujRs z1~Q{r5CPWHdv0ZglRZf;)!!(ZHQUgLS9Dq_Kc^>2QRDN9MLqv^rty-+gLKFjh@gN* z^wk|8A0zw$T8E^|{Pf?z9XA2Cvm>!V2`GL*Yll0_`)DvLxQ$RMuDZ!9PO_=s?!~BV za`op=5;uM{Eyi6Pk- zd_-}>s2Fa-%p~hFcLPk;*Sqf;hY`#GrR8!c7(QqzSwy_LaL1jOHZ8ZmKOP7S(Ah1B ziH1GI!_}eRDPi&O8xr?_23oT#ZG$YUumK1kF;t_^uAq&Ff2-Q_M2Q4^-kRe1C?C0( z`&V?<$;I*uLqXO|&$#2hy{e2kv&eCvRg(f*HF7XQ3%M`KR(N04>-Ps_>&el{7YhULEKK{&bR@sm=xQ;tp_?K*;oG7-&U8qVBloQpn0(U8Z(H zQ;L}du)qC!t2ioazKDenO>;5n* zB<`!x(SyMT>5fPoRUo;5yyZ&tcv@A41(xZ0H4m`C@|VBS*-htI33>I@RSiWAe6N$$ zI6r8|J)e=bB*A<#o*!4s@kbCYup>Q)Auq$^38%(!LpCMbkE%f{vh1f&i?Q7F)wh6eQbyi z?3d2@$1g$q{rppjSZl7R{(aBn6w}fmX5sGlMh-Rw+Ezqumv=uuRhg&5Q=+`&tqH^7 z>NGS|q9kY?AFsBKSA6keM0!HQ#Ii(W`gxQ}wS)6xQ6)yt$grD4A)K_KXOz8mUmvK% z&?@mW-=ViH%dw7=HQpC`Imw$C$>w3+GqPZq^4BawhKr~Jw_Gn&j0+BdysGphwU9bu zG*{x6$)iwsF`w_HZ=SikkLHC-kGloNGoNq1<;gjr$4kqvXi5;N#FF@UYA)Sn7r@Z3 zR%A?xr^Gmg`S^D+bg_vwgWi!1IwW3<5Pwp9fxD{_^%nYvjPsP_9g+mpak97~6zIfT&v)GWdyQm7%DpBIKg~rmt zRU>stT=KCx@cL=7R63Q_DK&diQ!}Y%s;v)+ty$p6pJ7t;I8$RoU34cMJqH>uOY?+( z*6KvZGAl6#f`dq=Jfx>i_IwZDKe=kSuWCd|5L{B?7#rc==owSr9Hx6yIgGyYSyK%q zjV66ihNi;L6}lA4(A@BGFBTRSlX*vbdzt_vN<6DjPWot0148;qJ{lKiuAfGYO-OCr zt~kdjc+pab$Sf7Eyq$KXEa*226qhvWGC`hgIlPj?)C)%K5PHr&2N)IkHD!Us+`gqs$HWNVfN@8MI< zP`eii7FCcvNr)ary3V!37dPkKw8Oj`3bP*i7kzStjX9$qwi~whMZR)({dAuaqoWDU zCEjT8cftv%`SmO5bS^6k%=!m+K*o)^gQ6E&_A}pY{Z)NPALcoIiDyrD_(Tw$u~GHr zjUj|mmEc7#=DPSR6LIKCLJ$607#R`r;nc-~cTYZR7Wph>*5J)@e(JfMUv9&C6Va!O zP6GbuVL0T1QLKJtS3Zhtt6?wbZW&Q~!UI8CtDwl5K_VOjFi|P*4r)I{gy@I2wc~DW zl-&4_>y0X#yc;sJ6^>xMjKKu$n;PDieP`n(a-QEqImt}^nH^&(<~6+wh)O6@&RO?G^WpxK>3fSM%uOh}wUu3DV};b(+~-;MNP;RUR~96$ynN zu;sRhVMRuw8#u|t%&KcV11k~M1%Uw*4ciBM&tS(cMw&mmA4T*XsWgU#TD?>7QR)G2 ze&WAx9gX>7V*}%Cj>S()>rm-aQqSaI71rJ}rQ2Iq?h<<1U3gY2T;Nd=wY#*7>rYQl zFT8ImFQ;l=v|-^=DileznHZIs%iN(mLFJDdjr+IqJ}C0bj(&LH=|DFEhs-D}MRLYa zg3FA4&=?lZmowcCMa`o24^nd9kcNoVI?UA-1YUR6yV7jIbS^f=Y*J%;?2P$7CMA>!TLF5@l%7LturBrnMC5e9IZ# zyH56ANljGKD~g#PoZ|(Elqt&ffF}vRyTg_ISN-*yH%t4TN|XXb`!`ndKRo?Ga5Gy0 zQRup?`dEXaUYeJM`<{{)62n+HsiG1yKanq!Ei=2*y(adYF8CAZ1Hw&@x=_6`$ z#^@SxlKngY=2liHro`xOJin^dm*DU}#+xgPG0$-R3m&n)%RifZPd>{dTGfd&_{^$D zxUq_`E~QA^^#-yIQiv$-?QDQ3CYT4Bmrt8OS7D`v0&(p zVs3DKtJlt)U-#{7|xSJWb{M_~W|oD$JBW z#@|viM3DPlf<1plI5%FOcieIiR>>h|+edQ%isoi8W@*5a0v&uCt#8K*VAU=HD7n5; ze+qhx8L3{ZQjS^XgIQueTj!=Lvr3yHF}i^Ma=?*sYI+e*f;Q)BXAgNt5Cb0`7V|k; z$t`?j#8r{??c1A);|!zuDfzXWvyy#X*~NSI``OrjGzrQT}>x)v} z%BW8(vu`N1LXjI~p}x}?$~v<@JKK5>_2FoD|9Fj+>M{A17=5rhfSkZzFZ`SseC>>1 z^oDAolIVEx9(Ohk;bJI12d+}#+fE46%c4%m?K?+*6%7)+-Yi@nq7d=W$s^~MiyFn} zvH|P)+By@>2F~#_6U+GuF^W#G3Cus3OxqLBfS+ z77qjN6vXF-G(N#yY|OSe?M=-rFV~hxqNu)^Xkm_O`kP_8pM@l&m65;lt9E|dCb_xk z`k{%!_)b8R`}eR;kLHt4x(Ysh9Cg|L{Ck-pWef`)shKxQym4>lC$`h~&Ij&wT2sjL zqvusuaG!vuH4;3)C#Z#*(0fU)M)b{xdI}@&KF&t7y3eMkr@MiDN?<2D@&cwvfzV17 z1XJ^ye|eQnzrV==7LTye(F_NnyLYa$4n^xJe|{ zT&%fH>dRwFkRCDZ*!N#fY;M;3o&^Xb5(0;X7~vjZz0O3Ve#a29KPxg1;~f2x#OhJA zH%qR4H|;H{ia|anne30bcH;8y--(x8qE~3X3DEzG61@wU^n=W(y0SV}F^4!Ad!ARf}=-$jAnhi2g=ZU9$pd8z!=Z7fCR)jw9#+cdLT-G z-+&e4w$;^QlG}n~lT%aaMf5pCrTuTbvjU-><@VdLCzRoo;LEO&^0&e$vUe6bdPcin zUpt8Thl@p6{xYtvPlL2opNT2BN430pS_a)eYT#%&D4H3#oQs*EFC$^P?T~wYa0Y9G z_?(t_snF31ya#!ss^(8H7%Y}WsZ9HuJ|M#`flb-@HGdmV$IS|~GYAqf3&ErBlrH=U z{I{daHap-92m?`LWr5!+LR$Itim1E9DnBpzMJAx-BA(@jL|RzAPCOM9GHl37LR~gk zAT~Mb<<1&?Cr(S3!So3#sc(CO9;{GnE>vK+3zi=#HfG~MI-%2(s2vdR*4tLgwEb3? z>ggBAwS_;asaCO&IFN~!N-@cr88VLdpr7WXGhp|pIXXIuC@Yw!Zkks4li}sQp(6b@ z+seSHmr1W_lIV>3_iooeu&mW)BS(K1WZ!olZ#2q&zU98J>)}=ZTcJ@yDy3Z;_1jo_ z`8fL%bi{D>E79AQCkNyhyJ`G5@8IkV%br89N5@oaFekwz0=GwCsNHp)9#@7ke;we6 ztg}}BbA>#ds+uOjWhPk7I;V!ZsOGT!F{|q_f&VofH}v^%e7$}ZAv-;r{9Y0t2U2Q1 zlj75Pf8eXW{HG$i^)=0ezYT07Y^HvfHdPi=P6}#}v8`JZveoXD*NtjFKe^I5H2NDM zB)4yNB)~J`Y}tdyDB{g)b5uLT#}PPq-r}m+az*x=FrTMK?2Obu&CNnNQG%5#+0vOy z&sGT*raK(Nbng$}4hplN4FRb4sc3!t`D9^@^G<&WrFN%bD-33IskNrKeBI?(z zHGkzYs!@w@K3(L%cc||QlRIrl+v7p4s7@_k(J^YbNvlZLn(E!GvyER+bVCLl;c~~l zxB@?yx>bXw(pcI$fHkjBlpQbejo(fl|+p%|vK7lXRl-ykmv~oh=hTU~wqmuHF@* zCkj%&v|wLSUg{{I(<8Zw*)a&VyYzfYhoaA+5tPXbU;%`yjuY5S1A->!+hGkp*ZxQx z6yaIIvEQX6DrXKt(@pK%4oOs z<>HAJzM>pG*Z+PW<85BNW=TZEUw3Z&X3HscOnuLIS*z1qk>|;#5U`WLeQSA)_$|wl zenV;S^AaNY_&*&jSrt`uEkf_HnvKC;6p#$i2_|#E{Rj9^$MI0V(@e`}D>h#bE-1H% zBHWES+N<`R9v%=&LADN5tS&?@ij6jKkv<{GB)3qtsaK7n9r_FAwM5%{puL9U0d0<|7=B z*SfXmGZoI;H_zp(d~s!v3$?XFRB~8W)ZCwQ7@K@Gb_`Z8EJIl1s`uy6R}u8+*YFpN z#U&-$w4}Keb^$fz<%ww09+*C5xYUbWvVR`m4qj)w``(@-`}m^X2;MD@ZZ&_iFtdh? zH!bs7`E-nO**1c@{hC%S94&0{-ef?83763u>J>_Z1h<_?B_=u8n)&uY6|pLbqU1w> zXE1gM zE?gRJqwkdpRKyjeWqfR)lv@!QCia1;ux1eC!7^{bsxVicPB3(UXfeaN`SeFlIqwsWVq3p6#w)i=b$4|4KvP;F?o~9*h3OY!~5^Lw0X6cd0DRgh!}5woImb- z_$%vTR@iCDF|;*APokHAtW81r7rhY8I$7@0R}1B-CcheE{*El*O`5Jsee21Ke{G73 zFWhxGz@M0#`zs@3T;s$Rnjak}$Io5SaZ;nB_Z|ZJ8`mPAhMe8OEKu zFt;LJ9dNO(EnS30q;s7Kh~4h6U6WaqjQYfH>b*8AhG8q_Z{9eAE63#klBiv zr&lxi@~HN9i32*4R2}tRiYqeYDTkBANiQ>E^GTt!LoO>Q!}3&PsWG*p=!>tkp|8I_ zz-wk-ET*R92C;$}AOObY(uz(ygB>^UlsjErow;lhYu^lT^;MhmMb*5t#-5KU5qvMi zaVRvxv{04~OsM&KkFWVCBcn~d&c3U@#bI7v`e~Ggmf!kn{QVpBBv)jZjEp{j+~#Cs z2#^R$N=lB(S>a@(W<(0Sl&X0{(siEsxNCRSjt<|?|Fpsl1%iAuW_ZNeOy!gWw#5{v zF;DOro*jVODgKd*o9&Es2xY*-Hm=*t?9i2&tGG$X>hbx#+{K2rn8a&8QgTMAlk&SG zFlCm@fWBnE7voiUU*w*Ev@V46MC&*!Am@a`b9PZ6v&})Nb#Stx$&hzAXg}3UQbN~n z2hoB=X5T>9%x9lonW3*ww2*VT#ebu|gbgMBX%$CZp}Nqrev7l~&oOo&ZJ7%a2?7IJ zkfK`E-c;3W`I(4g5yTWr8;Mm^#7V?W&=f$fd3BMDi*_@vqCUq2uom2)w7_ zBqpUcbG0t3QU(1!8?mtmyM%}z#;pw8V|1atmX~Bo`WQ#yKuOBWf&nfP6ibt2PbNEH zk68O$4Ngza?J{W5pdqO`-3G&Rjn!gLCe$g>=|<9 zJrrHw_e#k0?*GP9s~E=HQ$bVdswAwm{)9SN_f3G;rwkGjl4~Q28jpSJ+kigm_OpzY z<^dRnL*P1YVNPS%PT{t6s*Rf948(|Q-of?Kmn-z&LpYX%WMAlt>%S5*Hz(kJ$in>U zK8Kaa@G#tGpwAh-y%RRV?#EuKvnMI-dSk?mQpn0gyx$J@|>7We{QEs1(P2}TLM(mZmv){9x3ggZ90@TGf^Dqrq=Y_><-E7adxxg zxbvIlwM5%~U@h?%V8J6I+|O14{l;F6&j6Nh8stKlQt++mxEJ;j{J>t_+M+F{vWf#H zC?fbhGVulKrIBHpDoH5i1OxY!I@Jv>CCaoCxoJU`mP7rkGylpGwq^}Pbl07}dCyDV zC+1hR%;TahhtkoW>My6#YoLC7Kno4>&o0N%)RO#)Hyz@sdf;9BF|EIZng^_>oGaoN zvgJd^(@$ZACu!`N!pLALzbfp(VrRM#QOKRZti{UK8chCnBiYvC4&B(JE!_yUW zJ;$q4XaxFUQ8h5$47%d^>w~I~ZC^Ow}-yh;`4TC=rD9`S&44ST0jsM3{gQ-W^LCibEJh9`V|u|DiE!U;fSh+#Y4&7DOh? zUUuCI`BTA#?L<1#X7AfXDL4wzv9Ym7<+P!T!N&X;`w)5H(gEcglLk&B)YbY{9Jl%N z+m~Fey_2;~yGMRm(o0t@irYtK&d+}OpRTpC)jRY&C=4aTCnIIB+l;Wu`PSqHT*$Po z`hRK5O`;AmQT@uq52zebP(agy_z+5FEID(FY6d>h3>_P;X%+Oboy&L3@G>p;eml=z zUmU!!%|?5VJltr0Ue<4OOCqC2nO$?STfOdY0;?TqSU`BEjB=qMNY{)AwuCyJb{Z33 zsRD?wJ8>va&5cJYXW`Y8$N~BDXK{^SY7pyp&YZq>C>Gq66mk(4j%Lgm`f$~7(Lx%M z@M*1DK4*^I|Js!hth_|cHB62XG;5UD{Xt{1C+qB5E#rTJW(eaQ(NBL{lE!%u&)#7~ z)buy>0e{k{{VuM`mm{Uor^<}5y~$h-tX0;6;x%&LM*G8Rjc1Wug6*Yo+w?!AkG*R8 zpH-ONp+ATfz~uOl%Tj+qe5=Tt^wF50K$ajhV+La%41J30Z>YmcIv#Cv#|A!f9S|tG zUjAvIsfcko5Ljnc0tcLY?9^LTg)t@V6N#=0agAjJMX z>AmOv;Rl=cn!---s`Gk4b4Q#D&(h!oN`{4a+19UNe;+KLW8MeJ$Gi2 zJLb9ya_7I-PB|9ueNeim+*8#e#8G_L&)wT~ zJ!GOT!oftGB7&{cCNl2;MBSTj=J{yOWk9?$=3p5%6|P1YLocn zZdlDK33H5S^mwtfNA~i#l%{nGyeXw`vfTJ0++sv?r!_Y>pf|?Lz4P9h1}C`0-y(!}=l@)3IsCnyPOaEB%h&XNm0FmR4iJDeQ@8*>lMrrEZ;D z!s^nLWx7X-aR+DmtM^XQuWt$)U}I{eK~NQeKB^Lb2=Kd;cY5N+T>66}*juUk`l2Sm zOc3M@W973LXGN(Ly>cuErDV-soO9BMkfnzQrQzR-h0{+j2KM|Rpb5oHNovC=qUW3& z54~PyMSyJ(GSn}?pd-paum*McCB+_CIsYDPH_b+EW>+8@o#*OPxM^-ZT2Pn7te^(F zy4l-7Sd2<+9!`J^Lu-a9Ji3&*5`uxNd=7P77~cmpgE)!a;riELS+a@iFv|J1PevxS^Cd2JLvj#) zmyJqAel5|4jFNXB{Z9LI_nFY0oqH!8Ch_*!T*e%Rw}?_en^B1|+Mpy6v$C5FSoDjp zL?L>agQxhTW4rYP{W4o$J7kvL`DIdH`{UX@>5rRw{3buMb5DEmF zstune09DolB92<&fK9E>Nw|ZqN+V+}J<#)p`M)sR$SSilXoR}0EnnfUAdHjNqaK&2 zX>@_@#0MDev)i=e8-{f3Uu+U2f;hRNRI>=*u&A|@#Kvk zSB#ufv={Xep6$j@KT2X1*JIs3i!wCQYPi@V-6|gxtQyo~=M%Ja?KD;qv2Y zsI1)2ZgM9_-c*GCy^IAo2_5LGf*vB~W&no%H&c!whW0|?_q?q-CY>a}z9^ciP?nF1 z0T;xr8q4103U?!Nd2-voJkS3d_70s*`5@_DXfIt6)yNVN@2{vES7ez|yIBA=?YyQd zYp*F6pbRD9qVJ`&0f~FHqlNck7^Ml&!-2KQZJ#g`_6Q&sExhWCO>grv@NMqN0 z^HH@Ie8zqFQ$ehp%Tg{M`_<4Td8$prcb@vWg!BldjhgcV-9ui^p&`{$#TAxN1?3Gs zmFG$V{STYF(dHwX9YvJY5L5&6G1>=9nG znPgR^w%>KwJX~f!lH*z6c=C*s&)T;!Gt#b0|b<6Tsj-~7%aa9miNhoh@a zKI|4QECiMu-G%c7dahrCiZxvL&13wq-VaW>82qRxDVp)Ngaq8$_YyZIxnZyV&Uq}k z!`IexP#pkfLkiG7gZClaVyi%;5ES!=@{F8WqCVH^gn$1_RIwB*&Zr?ljFpO`FkZ?@ zzj6njbm>pQXAOto4Yon3KwQT7x*jUz+rIofXe`mBxQn8gzhPR&&ut=X~wH)*)Pi&hc5# zT0xzaA^XrwUJpi^tIf^kr-R@!wZB`G1%!Q<^|-(7rjqi}vB+bqb*ROEb4lV#!;&TQ zLCJ2gcT(v|ik`HJ6Xd+ft0pg_Y=5tf4d9kNrL@@RN{!n53e`bbY6PM?@0eexJ5p_p zdAEIQ9U_s}{ zJB+oGs3*D1NvV(>SafeFKbA7SPr+Nm2V7 z-ks-eTZsADrV@{Nuf!xB@GNe0=@d; z=BkrEPwL5iFjPY9Kcyz9jhTi7Uw}2z0KB`ryaXQ|`o>zF@%H>hw^XY-HU`#e9|QfM z2@JNS*_HU~0MpP74CcjQdxgwjQxrD#U4GJ6=JR5cg(I-?68Wm23>G(`$^z?FU3Mgc zdTjBj#xjjfH(H~g`_oz%{p!%>X*Iw;##4s11%!X5O%rhHRay=!6;okh&O6O(ACF)P z?@2Q`{TU{MK`*8kg_$vUkQ*#* z_ay@4hhSX04B{Jb?bS0*?^QQ%mJ=4%&7)E0TyMusY=bNMK4JMk1vMM8C1?5V5f~er zCSnz`M?yPT=eEx7vy%AClr?8g0dz#}Z&Xb@_v^h~a1ddrklBSz57E*hZQKl-W{0R^ zTKf1CZuT=-5veVryX9S!1subN8ROcwK4kg@o^1$5O0^*O0$Q$UP;2lR-1!ctq8q11 zZ%5k7Y~FN~h;$0ZSm}{`AgnvX)JSldyt4O2@=Hl@xaaPo*flsFpmlr0*4_~Kndh7tUYzkfD2$Cdr(qU zUbp(aYQJQDw$Wu*H6G1SrQz5a_%QSK*n$!I6u+|WKE^c3i|AgbSr z>0zFi03?Psx3b5?8}s?93wKB-M~6N4hmFi+Wk+IZe=6ylUwt`^J&Yn8dnC*F7In^x z#uGsIy*COt_^6$8d0rKeJUy{^;wclQP(L~xOGWwf=?7w|lm&b8DnhpB_e7VQVAI}= zL0pfCalcx$LJ+74Md;!tU;N6xBLXVrqFEJwplB|b{3c&KFbuQse7^;a(uH1*P(}=p zZ+t)Ko|u@h@IUOjXWLJkv2V9(uh5MZ1E3E5#i}abfeGCRr$2 zUtM}Bnxgk=aSzSnxFQ&jz8v0vttV$QDWl4#g~vaoa~^1r&c-me`$XfqPBDcim}gDz z`?M!fwn})`qm`I}ClL9WE)8pH<$;ojXORwaFVuXz^jO%U?(^jiiWW(GYHDb{c41{E z%@t!$r~l=%zW4f=KKpW{7%lEBDk!%~3c<&s3ppPPO{iL5TYD8*_yQ1uA}}Q(5thG2 z$^5AQDlTb@_vXp3{6C*jogV_t!zk~FA$sC}T{=IsJ~?^GAns?uWkiMdgMf~O-+}t8 zR@_b9)(R0J3dKLSdA|Sv1m-1ly_WLpm15H~P>3)6qgee5MMMWSV{mx$D`a=y{wHX6 znG+tj%~^#6fDR>-GhGrd@0O!B@bAZ0)V z@4O{IK5%YCqSh78>xP}uszJfCA9&u7vy=6<_qKo*KMBf$d_1HUI~C7lU|39gn>c;k z@2?Q(?Y+NyuzMx)KIQ4K)*;8b%FXY4I#Qo5t~k(8dkgQ^199dDrwz9DZk-x3zwvz5 z`Mp@LOsf`iJjsbZkc4QB zms-TeGu}y4@tcW)BL9vEUt0)qIGDKieQstmYfEtTZ8)Sbum6ixbK7uDxifAePIm7e zD@Tak9d157(j7*&SGGsB-B6k1l@{alCL1U!4VHxU~GZcR2CQA3=g>G%N=2)$EiWjv$l3NW)O)P z8`_?p#*b$YM%V_+aFy1bf9VJm^^7_SIGIs>3f3n%<#=^6Vu-MESFm|le0$~cbuCWQObi( zLyq|Jra7V9v`;L43J}ZQ>4d(W85MGFlH2c=Q!@0p^v&(r-0haVP!aKGWvnLgCVd>^ zclY*Txi;k&_|9M|#(Ifw@wcWYtMm_Fc-ikNaVT!x%l3tyntk8v zSD8`U5E`9OzCt9`BxrS$QkAfbxt#FHD2Nf6-Q0Y{K6CL`m+cr?1`XsX3_0pDrtYne z!9Z<#;xE1$$h@Uz7l@hkB5{ug`t6UB4mjL4i}kT#%(s8XnOnMd!nBLzb{Ox_qpv4g zTrLjPj1zlM{YcV1UupqqdGn?jsRkXfDL4V$=Paa-MOlT(hXD6QT*uUl3*0RVndo{} zf)nQ${+ut1I^sU8ru>SijC-na0}1}h6rYbua*g>7AKsS#3rLLNQZDe2UlV35QpmGX zm^UBVEX#+DyXDJq@1DHl>2C6YJm}A7gmJH>UyO=6`*HbSGRhJU(f1}> zn_OZI9TMHpv#@;d^Rs{+Q3t^|cVvsK9xMs;{11Np-V0cyGZY|7!KyUVb5x3O0@b;< znVA`gr~V!omkC&vp~Kkpy_ed5C+=5V%=S6UED=6IA;Pa(A)y5`{pAklA3FKAH-;5>)ZL0Ho3+5yjBQ0MxIsK_WEvs&+#WG7UbEvB7-aHm4yhB0p zkdr3qf+0#epkp&w&CY3smy&U83W8W0H8E27smG4Pi(wWOkpQ+ep3(-PCv|$uQB~i5 zz)>0M>eBhS1Z=`-On8IQlGWWX?ga^MQ7cZcTPI)KyRtcA!bhxLttxt+LNlIqC1uTz z|5AQBB8c1msxN<)nRVBl`er-_k!uncVN=+km6{CMBF9Jld%Z5K&Bi%muE(4#@AcRs zW@*_e{it5=&c?l9O&X=X`NsL>5luTvw3Cd2DQ?TZG*b9kr?7$M_Yz0HK*_7}Cs1Du zouudyV1050Ff=Rw$M46>H4v|T$H>UlrWxoS1(AQ zjzNl>X9Me{iJJcetRh;V*WIknhx;X6lr9T(~=2 zm9ILw=i#T52&1_}BeWaxp&NZr$hKU5j_0>Z!RW*R64+o>mz$N+DZ{a}%wbRz2sPgW zV5Q&|I@djek2FmMX+NMzjcyfIwfEdr{OSa>f*X=A7hi6~yDj7hzOP2R9`f52tw^qf zyormT>biQ^&(hz5*QFwIsNHh3^M8P0Q;K2`{iL8B{maC(> z_A{bn1Ghet5>Dc!K1<)x>z6PP<({NlRRUWkFyp+3!C%F4Ccxl5+ZAV;-FJht>%_ z7na-xb~)(QF6JiBZ50TLoFC-fAg1j7eOISqAX*hbLv|7nw8 z3{xL(ZzH;zwM^oUlUqt; z+5ha`+9`LpK}W*V&YB&+IP3lc-nPvpF0070>-J`)B7)AbR0!>3SbVe6isDyx)mFl| zJ(V;Kh#D+&_M^8qJY&Q0vvj%DpUKC|-SywUvK5PtI@{0~KkwY! zjXJp!5i2FlP1O_oHOR^6+m0{$LCZVnP1^~R?{3NI|AIRhcNP84*?Pe-6t&e-hRcml zJ6FMY`|ciH=Ut`D2Cw5VoQRhe+Z3f=*uoo0>1ii&7O{A5&uea2RGwP##4g>(BRsn~ zMrsB$-ty>lwJf^-aDi|7ku?dRYs5C{B$emsH)S3VMNB{yuA)uuS$GGW9aZVAY9nz19|1dOrV*qdc!sJ|9vyXIU`% zF8nP4&3#IWDSJZduW#}=Zm`FG<0IDC4^ndT`arB6a^(2XS`T0TvTn5EHY5j@Fo7ql z4885&HuTfz^aFN!3R?B$1E~{k0Gl}Z?TtBK2X+G}D&z(Bew6?aw_sg5J$dp(eLf}r z+rQ)Hx;XcOt8TJ9tUd5+Bg5Qsrdh+>MPi)+@h`N?(XjH24T~^>2483=}u`71Zj~F zHVx9ob%m#{{TF&-Zkf#V~#P_p%_+Q>O?{nsf~DpKTZM9V4i#) z-tFL;byQI36HA2~idht*j3N_Bq%n8`p``sq)^#61lv@MrB0$FG83OA2qc&jGmwV7y zU;oTlJ-0b95(rYZ2a?LT>B-b#4g|Nz&jDP7%zpkx2e5dnj zG*~^{)Y_O!^rbZLpMK}H5CGV39G(f~=byy~ci#>4;kq1itsBR1zMcs=4bMjLjx{ec zSrb)qV+N;}-!i<*4Oi6}@x_}G*DJfllQ}crrt>)E8T7M-zE*xo_zll0#FQUHl@%7< zc56BAE#$k!T?VjA1(?y49>6#PR6v6OwgE*Yl!tF?V`B>R*>8}vtMkszo%nmeljpQ~ED?<)Mj~%=&FahE+T+L?8yQ~b z`$ek+MA4Do5VXj>(9OVBnYDm)7{$+e_PB=ML&4fTxY@eT2jP3#8&OpbeDZ%aXwSa` zrhBPN5)fuOQyZHCdCz>?t?`6aJ*r5#YJ=M*8ym;XiH&KEKYle;!-i%eO$n9g3&hZx zdkiTUl2gN_zd{JN;#EtkCdEq%utMLTuAB5N4 z`>_AG%oBRAUL3OnH9Qon(1AzAds{7)M(ah)%-3^vk+#N?=1&3$O%pxQ4kt|b#b2)X zB*nc#C{+E?r(~OfLCl4X zNd05s$I+BwYU1Xx%4YcC%Uh1HyZ!}OYMxl!>pU1Y-&+Q@OQNw@;XY?+I>Rf#P{b80 ze=|JfTE8sXV3v{vrQ%F`XJ%&Z*|0vB_HR`6VB<;9-A}WRl;@`}ArkOJDy1irwwU4s zkqkCW=!Ek5*EWNp)@B=v_VPQpD?jkKUVRr~+?*i5930EqijcAn%5$j@FZXN6XgZ^@wZzRhIf_Ihk)pWCe2i9VHOOlgZ9bl8tB zSgsn9bnx}_aeCe%WC7j3#D~x&lVVU+OX{#x$m8-V4E;;cS*f0?dC&YYS6EEB>IsJ* zT=0UPt;>1vRs?pe8w;YcgdrQvM>;*ZnE`bvSSc^&?SkBl!= zPZz-8Kl|nFoIk@K6h?%`Qdq!%9g`lOB(thJ9W$_Q*bL21>MPPzlMXis9e7 zDs(<}#w!078lK4m*#&>gk_sp7gLA(I_>cP^v!eg|G?ZXCQf_W;n$|buR#4Vlfv*<0 zde=*%^JUgZDRXozm8l_N6IgYty3aaTGx$I2f7V;C+e}tUBtk!zC_d0nHNdqm_bTJN zv1w;`2PMT($m@Od4fu>$wF;F}eRzs}CFu)n670AP;d1bA>q~+VAb0@ok^0*xw&0=c z=f+x|Mi|w3KWa`Z0Rj0Nx2n zGDDe?ZN8=8cmYc8KwcfWoUhskcZ516I!?-mo{N^VBE?0!l9m>uXW1;^eG4G_?;^oC zLJ)+-IOl{w5xIm`vk_s#0iA~xyFRj7F441coF0je4h}`RpmtEF~2H=g~>Ss3C3Uj6e&olwv!!@j6aVE#%HIemXLt+{e`-SZAT9=0m~PE-bYb6$lFvV%W+b| zjAdNRD0`k7)Ogh2SA3%jCnx%_Brnyg&W26GYA|l$3JhXP|0CUbu9PsLT6o^A)*ejCM7>Vuju`s+9H@>ia)fHr-XkgXs8 zkVUi9ynX_Qqy+#W&zF&?2CNe6gN_=?!vOTbYa!5|fEoBEiH8_Ae#>7t7dXSR2il&> z)umcF*;*tqWlp+M;asw!DF=g7?EMEnpQa7EP&QhUnbg6KsDe$0p+7!OPRpR}LqWmI zqV0sq7|uML44KHJWx1=54H$+N~3u71CTc*%dUo4x}Bc3zbBAlzdjeVQM=$n4ymhbrr!|)m2)QY zM;CJ<)-!SFX?0hTm&k$D_1R1L=hbodYLfEW-Yz*u&^J$MAmpb^=1n}2sXZr2F0n}Wi)s(j&or-CuV7&QTq(=R=u)5H?KU@S z;&JBM4*DRv7|^17Yk;sJ_7sBt0Wd}}UgUXP)xss%clBb6nZ_)Fc~rz(BC`RM7$zeCv4_KLKDlbZiX1uqmkvk>bil7fI* z{FSI)0xhu}rD2%KR41w*Mq%XG_RV-^(4TtdwCMlrVK@kodsuYD@$ol8Z`+S62Z6?$ z14x)30jqtbKecY4z7G7zyU!7d$C?5`)9P1{BWy|24;>K$nhtJjLn3bIlb=!6r$1Cc z@8QgHw-V#NtQB0akm+7cxiC<(!c;m6jkC0B45Qp(3 z^))$!oGbfbmUE)g+EbHV()fX4xZuH3-&i@V6=~NyRBuu=QaUvmk7kxaxX;zcgo4X; zPD7Nf6v<@`aM;_e1^lwXmh$XX9t*0R$ zP=<=(ScK@peXzWQ@N3O6i&Q3uei&tSEVgd(e&SXT2&$g)p_$c#IS=bKri_Fg4T=j& zw2PF2ZVFR~JX*ZJ=Kkzwj596p?Gf9f>s4F?ivJfzmf8*;WvpjFhb$p;2hqHm)O4Xv(Z5y8i`( z4^seTv;88WebY}&jd*;)O8*&GumQWS=X>b@cqrBt?Ef9lG@m9g_n*$!mqr&F{S;#K z#U7@_RWUy<=CHJNs{v#E5krL!rWYt)#uGDt$XluB^KT5kc4%%emc&kf_ra2;{M*24 zdym!cK*G^ zL97o%pPC;sT5Z_D!qZmtaXQwDK5C`~M|*M{54jm(?D-nAOqv}u&+F~-o%^u1i= z@4ATa#pX2a+$CJ4O}=@pYgUSLc&<8dfQh%>9U^Lp@o^30O<|1CpoLRFA%cS*7*;}% zY);8e)Q|@iEjV)jG>ht`G%ib^#jXHn#0Dy%xM0llyoreIFo1p%yXfxWK?bGnK+-5j zXZql;)=Do8zPww5zld!IGgf!{Lm^Qyo$Sv*mJpVMz@4175v;q!19#fiK!=YdTJ9tu z`JN+HE|99hRr^9~5_p0bY_X?X(L(CxNUXZ| zAfOF9$o?}kmT&--uGe5*t;M_N6YwnqqnwoK2ZG5<+-KjBs{!qT=OCagSMF#^uvJ`> z1Fs94;5dnoUB;5gh=CjAIL*la7E(Pi`Np||7(mFt z-b)+dd29g@_6Q%Ci=Vc*S2bS?tSZ>|a87jQjiB`|n1_~qP$3mXW82gw2s-aB*iVLY z^-wzh9j?|zv@95fKzIBd>(21U)ph9jeQH&f-zF?Rt+(Bg1_wiw_frd{V!lCYpFuo^ z5NwD3L{APnm8jw(G76s$GcEHJGA&0eWmUx6tNUMGc%eUtq>?xkDrB^8L*;2^3ylTD z4n2CDTWt?6xo?3^hvv^e^>a#?Gx_C=QAhH%?T%* zhlJFE^0XE_p~Fa563F1YnQm3QLVprc49jRTO>#=nzBhePrO#8}exwDV%weGzvAhWq zc~E}7M+h}wG|=<7U-AsniYF|@_Bs(2%{}&(-lco)^rpUPtVg=Sj3X81Wq*ZpVfKzPIyNx^_f4o{%fOaAdKHZW%&!86MogaGP!rkf#bPL=?daabE z`DH~R45|a)b45DV4&mfJeEC4}es*3sSW8>S!eSw~4b!NmnNV*9eQJ)h^#n%nY#PR+ z2a2-9kvbqQA|&j=@YRPC3E1cf9Uyxez#Fo>==_-od`TO?lNMBSoETbtpke&ybq9p6 z+DrTzQasVI-8d8E`t9mY)voibl}&YUR>@;E1?F{&ao2_N*JP}P;+%dxoojqi7+$d6 zlqynwW!M*vT_~Ip!|qyMkeWArqvO)lFtS?s>8fUykHAS!*u$M7ZGjQOE0E3YFq*7l zB{q4UkNs^oH!@a?UHqaJ^XJ?`7)ObiH*wfZVe;B1I?xy470u|W?_q^{3Xs@sILU}r zYw+}RaAVw(f}=$hDOS!XdbP6{nCi1}OwR<`>GFx>Wj1~ykYik}<&2qC+s`cnva)B6 z9&q~n1sf~#^GyGMaF+Po2)CVIPC@oR(4Bo$wuX{`eft8{J7@zl)uF?NWB+zZce0su zA=N7ZvZH-{vpT_W`O(d7%+VY8@D{lSR^)XFIM<}Kj+c<{GU&Erk^f&E2ml!tS{E$A zM%)a~8|qh&n^>y~N`%B=rRokIs~nu5+8%jPwwJvFZ7JuGFWV<^M9*xf_us(L9CIR^ zHOhh&865-7V36Pm*O24LmlILkJDrN zc9e~~v&<>*yHA8ABmS$r6t#>LVeZzB^!@KfX@s$?!tXprLf$UK47gr0s3dV~A#~)s zRM>k?PQ?fj^|m@pfpyW~=UTYn&iQI|_gn}GWoRmyu-&JMMOEh@0f=%&y)VaY4L2Sq z9*8vkZyNfY+c9Gz-d3>c68#T7$9&ZGFOru4%4(-=Rk0BZJ+a55GUFh10QAepjI~a{ z{*AmCWc4V4xvEUSZhtMN$(jX5_8rNUCQ{O8J<`46zUXQ{J$NSL{xyU?lR(m&DN>V;H$G%0L<+^6h8iS%xVTD<1H z-zDH1KLv&0-dj2cEalnv;(s?$0}Utj1|}bbSSdh^DOd%|oEbU&#fdvx&goje$~-q$ zX^s?%zod|DRD(&U@~_dZ7Tf#ZwjRk&U#f#3^h*r*#^}@baY~8v`nF&uBrdVf)o#jF zC2@Hwhn7_RQ?DbbWEJGUG~s}K=+50VV10_?Zou{rxVx{IQGOYe1;m{ZCL z>b(wKXk@-3D@S)HE^BdAJ9^;d!Fwkuw;?qV)NZ@#MG?aohrR-QU57>59<$Kb;p~xP z_P9QUFogRJwvZ8;@R0(tvQCxh0NQa{=7R|Or{AnHZ!q35r1yE?aOD}K>W`%tk~jUa zKgn}8_bh%Eki$Rk=V?$tPvIOXs;5C^fGRRQtnx*vuGvodIWlF;$y0ja+PU(lafEdY zFi*JyG4ra#i|<~KF_jkD?I*sJ{IS9gi8gf;lk)D*7?J2-jQAUX@EV^6gg|uw3h^xS zk+YVc!i)}#98h};mml6Yw+`~ESbmA(O3@idS_Nkhs`R!Om=}q?#br~85XE@gt#7kgFMDGa45!k~boPt1f0MwiIqVza2cqbH$XIZ~RMp1Rv<%XxZcW`bVH1IA&%eqlRx&lO&N3ird$Q{+f2iFJg01R70l}x zB-98iUIoE#7#yZeiJU3_m(vDe-3mo6O_XQ3ZG_^#6I)wUu$SApx;Ct74(cOGXK1*_ zw2x1io&C5SFc80!KXO)!2-EKY>HhXnv}27;h$dH7XWG41IV9ZJ4!_b!yWJdqAezW# z6YiNzt!$S4_uc?0O8``4of1CyauZALOAd)RS~su_lT|QdOeXub92dolZiMlKo5O-wgnET$ZKs$VierZRGd4JatwO|=veF&Ouv?mP7AL`Q zRAcHx9Do7`K-=A(MlLJ;Hvy7MP*^xD5`qSBlF#%VXIL#eam+Kq!;NhOk9Ro`nXnXg z$qJWql3~2kdhc@IT4Dcobyrrp-0=I)qY#;?JE~xsSgxN;_-y)dXIZW{cgjKGWVQsA z`e^N3=a@VoM)MZzjX4-1w2e6+e+6vx&&5K(%t8YaiN+kmx%_YMa699`C_sFeI8jZ$ z*IEf6b1Vg3?J{dS+rsjI9TMNcF==Ne4?EqU;WoeFnMYwmwsGCP%tHGY>F;%noH zd{*^k!I@W>_U}_uNG&rqPr^sKJ?8x$o?xa2Gb;_)_VyFb|-PNofK_^(4Lz*#~B zP7j-1w??{!Kqe-s3ZIN50yX>5aqaO(rufq38S#FkI@9HNrl^Xcz)!=OC08@Be5%-u+A^Dosur) z;*E|yst6vgMZPo0lM*dDaMn?bKljaJXH|KmP`pe zkJm4@w5$;&T!wn=l0~8Xx;p4U<nn$xVR8``u=U#TUoH3hfgXj6EjN$m|{a zMAu2r8|pxf|8@8j-JlTn)%&rPN{i}k@r4clo76gB$UHnIJW`tBzurBD(~}i7F8Ra( zj1je{HVPu+xSUIF0Xj_Oe2oE;cSkW8`xJi}_&L zP(nVq!dJ9R)e*!7wrq!cK7!TZZHkyFdTL5GtICBmF0MKh(FK#S(O!hTYJ1~wo*m{& zA)Y$Jn%N%T>s0mEE+_L^usK7UZ53j@PrCYvzU4&{o!2Has)aYYrcS?s;uDbbZ~H|I6yyNR{6UCeBWNN zCSb$wTv=Qky65WKf@l3Za9H`^)Pl!5-87asQWD_~e-^x7h|l{O0d8Z|q!G|J1Z>D< z>^c6YN=7&IGUs{+6nOSqs(SbtZ(4W5N5>9}2;lyy*^GblA!#sdt{gFRPW%s(KdpP5 z)IU%eHKCq1=%bl#WvnWNImuUV3tlB47c{l`pPmR+>tt-a`{WLUC3KV5v6gYcm&C%p zwiGn?mF@kZAhdOOfIHWu>8H`q%|COgLCiH4ZzOEi3-fhYK=Qw&BsY&+XS(+8U{_mXY+RJp6DZ z6hzhwF4#loT$7-0B5*INLJM_#j?`1_8N-4=#<^JGBFOXt-63ZYQW#e;{C24G7`bJy zr`dI|2i|lIabJM+XuF2c0pR)#>G9#=bys`MA!iD0q@K<~+dJx#MagXc4u3C62{RE& z=BZ-qy$Hf{0jseWbIKE09$)xFTyw#mogY>^E3z+zc1;;&A;w;CYjL?Sw_pNd9* zNVz`E)!hjp0rMaW1@Q;;1=j~Z4!@CfvlSSt%ZJmSze`%0iZk5~X~VTULr5tjIa;gI ztI%Ke9*_=kG8%Va$=6S9DT^o?%(}+t(o1j(j;i$*8cptzGBN}9!HTaZc+cBrk`SVp zXE>Wc`t+C$h%kPB5(d(0%F0z}JLq$LEwS>F#mLI-U_3WtammXVfdMz}L414t1cW$RSTQ=1lzfIyMrsiazmxj&dviU zhj-!eIF`pHi=hk>h!dS1kMA*yJ;JyDl|c>vP0cKKhJxd*TO< z6r&m78ep5(pv=cL_){U$lMI?=xL6%HAfOf~!&EBpYZWFG&GYaQWPMyVi7rwV6tTd} zV=E(o;e8>4sKCemDmgcuMEObnDNj^WJ*O%lvO8VZHWZ91J!Tu&R9UIlVAH->#ji(Jl?{$vRjhm&i4>IN`xb#i87Qj z_~9KSQL-tb+alg!ya&iMnHvgygr;4wq8TXSjXBKKeb&TQ1;~?;q)C1IJ7y(s)O(@`J z`~KyMf>O?MSn)ZY60VP<4DW?4rdn%1m-u4qPWYC`{=&qrA_Gadea4~ye zP_dOYs`l={aRk+pP_a@GpYZG^WWM-5GcXo@?58rm#Vdb?g`kipl#~v>c$(6uU{wr)rx>! zhd@h2wP0hhR;7HnP_+4O#B0g;TVHye)tA&SbdH1b^iOPlUs8>>FTQT zUg!``#QrHIK_-#a`KT5NB`L=Dtr>Z%^}llq=0819m-%JErVwb~Lej*UUU$>HxIFAL zn^6ti@+%fabmAI?n0(iyfjtT}pU^otQh0nD@P`8QxGycy;W6FY)z+@9E;v0X+d`Bc z&~#UPkrP`wsIHs|4(r(ktjEq+AAE+$H*huzml+bOZYc%CH$4fq5&UF3kPjf*`-Y~4 z*yn*Jvgt3-;cmk(X!gt0{dCA(Aw+T`qEO^Zw%ouGs6*P5G4AB#5Sj?y&TbGQmW-(L|~g1>O^ z^X|K{V%6Q~K*PJGTVV@PM0WedbHrdT39cJy(dfOgQ0=|&fql|53KBQeUX(N&u7|6$ z0FKeh3dJ1e=-Js21u9DMp0d)|d11LDMWaSX`FudzovBjN5mB9baSMq`slmpHz^LHg zWhtiP>JO+?o^#PdH6=fpIx7toyFWzK<0*&Jdv8u-2|>_<7_%|M8_LShY*yCuh{2`1 zB0`^6yAx8f=oUuoA9o1Z;{Qz=|3@&&a~m9S@jM5d*rD5MWJL5PSnC@@%=KP|%f?#D z+X&{9(QX$dS&mz#(LIobe3^K&Smnd5p#RhhRZ6uPH#MO&4_C(^EJF%I$1X1@wE1(q zcQgd@b@)GYD}*dq2Rkd0sM1&!Pz-t8qB9b`&bNtdfLdiS{?!V+B zxi2u-uB+CDe%uyGOlfMu5yOBy#BS7G=*#?~yC8Kt&RBl2>gIejzE(#Sbg%enBVe)s zHJv)nhV!izRyJwL)Vo4h8Tyi85|#RxX~r$hyiLp5OMI& z3Upl1vOulnPw8dVm&@Utjo)G7U$Q#3$I?1(kGy%|(s3a#hS=U4dZ`t@pV}GxIM5L> z#PGh~*PphXZd1ew&J+HFouPSZgg^TAwPl$IYVqZ#LuQe!YQ5g=RQKy1SuUeYhik2Q!%c_KevTNP2tU$*p|N!{^77c9gV(E{*}gN0*907t6D=Ow@^G zy}GKL_~LUVlg#vXqlFy7zbNVe>8#jNEhR5Vc_{?aK6O5qg_>s#Evv#Jopu_j+41yJ z&!+qu^4mU;p6zJ2Ir(rIS#|fyW2tl=RX`9SU*3@ zWKjTT;l_nCuyMiLwLg74Jfu>R-cq@j&!XOnSM4T|6 zH%azE1wVyJJt;R`)gJ|Ni35a`QFsr^G|@J=Vu@jd+#D2uc_X;;ui|2)ICl^7gr;Pk z7CskK@V)N+BX)U-1*VmXWmt$rUHT;sg5@xo>v8@u8c%_lP+Pc~UYQHCHV@0u`f(egnEMRJ9=w#5om=}GHe*23JMF#eLb ztoXXm6=3TKaKXQ?fTc>sDQM_#&=J5d!s69}xjO8&a!pu@=mwY9!Jo9LQCKEwZqwnh z;0VbDc6~u;*wk)-^zD)+PMs zGBP3qqfcsZqTAA%X4bp0th(ayyB?0`Wf-<{J)KB6LwLk5sxT{%Cf$x?IFp7{pC|o! z#`1vcPAS&T#mop8c3`q?H*SGjcedUXY3C-=<4Wry<@-w{)Z#kyU;R)e`N?F|&kP)8 za(bjZ$NV0^uI;%gV`5;VKX1US3}Kgl7R{T@_7^Gj-H_w4q%3>GGAJ=HM??gt@ZDCTzCM zoaOI#tP#t1x^QtEiW!;T^Y4o22a`_kF1e!+#e0)q$jd6ffKR@9<+d6o1#d;euj8d` z5AevlU+l)ao*)4Kj?fP$fT2f&lG;7%O*{PGDgOrSd@zl;Ajt|j>l>z*FsJb@*;C0( zVGfwaO=c#}OZBEDX8Xnh=;iPM#$D#?5{Q}FhLlj!J)&D#e%dBoB4;uQ&3cP^pNQvt z8?fHLOaujwwhuYr!UHzr0N2H_W?p=dkv89rBoj&ZI%+fY(aOY&;25_IhnAVPR!idQ zi){Fa!7t~q>7zeG6SUioGQ0b_^zBHGXpaZs#LAyycE%9a39w}KQI)oWpTlYSbBx&l zWq3d7;XnP=CAeXw6#Iqya+TBlg)M*4i=8pt@wAbi4E1oBxi@TeCLXw#RPWtj)Q(nS7zM52|l9i;>3@@?{ZE;B2>z%A6 z6oWTFk8$mHfpraplM-~PE)A!QTWavsca+>!*lB>~!q1(3Moc68N^8MY5-E(3z>HIY z-Dd>zVbgD#Zl-R15g#4xjuV2S->2p35v<_tQ>EpG8rnoR@{!_OEGZBakVq#I_2JAF zRs_ke1SL#sNX2;^zQ#w-gOhUM4wCf*6X<=I43NW*vJ2z>8h>Had-fRARl; zmQDb0qYTOtvj=MW3$J-y}cNmZH! z`MPFPk8C->p1*l5y^-A!kPQU)rW%xR0D~Lg-et&&6tQ^LW0E*ROuc>uOn`hn$I>WC z>$5^Ez4FDG0%N`fAeKeZnVWi+(?>!n;&nxTVE8T_j!IhS)tB!wN|)1#+lXQ) zd-hv&RDEi_)PwvnTxbp3^gOX@!->h}1raf;y3|E)S7H<@?6WWvd>kpw{Lb4WR#D8; zhmfv96ZU$w;Hn-{V6t9qY14x0hFBhF$pb!2Jz^tAYxRQV9x&8foI}!?fg%+1PZ7d_ z?=&(uY_dB1Hu2@=Yi3u#QU%awG|C*ee3*50voKJz0_sp3E_Nb!2g)~pNyFKa{0P}V zI49_IG0BcB3sRFmM|`S>nf&yx4*=r*+=<)f%{R)16RLu6M}5mK111kMiik-c!XDNR_F1u>+dkR`~lb#9{ZDODS!{iM1qjx zct)W|jH*(Bnrm=gX(7hjnCL00WATe3gI2GiO#!2F^zTX(HZ6bjt?+Z2_HPjQi){)U z4~jPkDg^0Od3&KzG+PJ0)3r_RH)vO+fRM8ESqDHd`TXeAb!P@}kfGgOU718GY2qG| zN?*0fK0%%eSY?dDm+xIQo9o!5CqSAN;Nhvp4LU4QYis?XXtrMYLVHKqe8Sj7sVeVd zI}hfq^;|wXnG$-6yt9XXXHzP;2GJ|q-0;>>+Axs)_kkmgAk|UrN86y_| zUnhE=Kw$nqZ7LiFy%2af3J~RJ0z;zj620m@uXZg&kXr}wQh=UvST)zlFP_$X5N-`a z4Txft8>IHZF&B}Zbc|Lpbz_Jz^djib6R$t#LlEloLn-HFU{TqO8+-qy+a!VM>7hrP z5A^sWt%p&dnbnYN1PygQ*9C`XVD6Miu^h#a1#o!EK44&?5@TAC@Ynz4{laNsvKED$ z-pm1c;-?cIpXUW2GrBCDS0jbykl?dyE|EM)T{UDW?xZ27Q;*RmGNGR~Hm7RR);w!e zS8@bNw-1DjQ!X#~XdNtE*!P0CBQ)hgiHZ|<`7pAP;KHl(eF0Rp8&kFz*mzh5BO-C?}7kn@PK<&XlvdqqvoFx4zLDW zYD}_aQZKBHm(7hNkZQ!9LE-qoIZa4ojkfQSuGLOA`PJqez%~m40@mhiuOabcCILzD zaV4K{^a<30aX!EK5ys1`d8NqGCM}7j)f~tR<#JQ{j2cIj|mKxJ{mAdjLRxCx8UXR z$3aroZ6 zx32V6=9egGc&t={k4vgfto#z8qLxZf?)1WlOJrm++*60e7w*FVy_6++4Y321F*wf; zbs<3f0QRXSyb)LILUAGBF-E-7q&nc|JY~@ZF3u^&cWsVWZ`O<;){VBH&j(4c<72w? zob>SPttIIL9ynIj!jpWgXs*OfxNh1|=9JuhJJI?}%vrGidhR8TSjJ)_q0You{||;$ z?E5aCokc;wHnmkIvZ!gs?Tg>9#_>Q5QKZ+%_ntc z^iB!s74$9RsZhgn$oZ5LW>{)aP2mvk2YT(EM{f_7=k7cLZOdUDK+YGELxMb)#F7K1NE3dynkt zwrpsBRRCXMPXRoZ0f_xm$g0di1%6_2{dTY*(ML34R=hPTM5)gduW(s@RI@bOI&WYejzokDgaOe}5yubLBPRwr(zq+F3+2@qRbIE7HH~g=0%3<*rG3 z>1CAl^7v)=)@B&e+2Eh(%*D0krhHMN*ey@kkGkG=ZWe9HP|S4Zb$JB)7-BS^HNJ9c zyFyjeP4nLx*UX%;Bzhfbh&En{>LbebEIPm!VXH#=`r!F%Ab;onbM4Hr`pp6tAj%E@ z>oe3PzI}K|a|9aFledoZi^m1wcc*EJn*l<>{KdXEV~QmFQX)`P`(dT(p@p(gS2Z`w zL}F3qJ9)zNYniTHyw&K)#%@?!ya+0fJ&~(~at5hj3`&bhtD*ODuI{R<-y>&rj>8aE zrr2Li=gh1;{<4rHY>jeWnaFVwfHvQTiHohesFRV&W})_0Wyuv1LBb6n0Y2z?VAnvf zh_b2XV2`>~b_!wKat)b?bf*une3%+l*;>Yo(^toIx`ZEtz_k!rA*3M+vw)&#O~s?U zs8nu5QT_VJvBI(B+I?n|k17yfP-AlI`+VI}c7`w=dCxz@)1Dfs6%m4?urFD=_!S01H{!JFfE$q8L9$-R?fllntk z4hX=|{+Ms_BY_WxtS7}K1GuW;e~}B28_e{!00Z}k;MQ~MM z*7GooeNw&{OV`%z(YL2aA1_T!w^h;4Eq!_G{0$wg`os$(QwG0v!f3-^ZGmjkVaoXf zVc}Q)&%7Ihph6|Mj^KkHD-Bk(cL^NYRG_f*q=x2{=a2F%?$<=yQ%@?87)`NAX_xNeU9q_1${j_$43{(k;X4xVs^$h0mfR|7_F&v_gmTXGk*Vcg<495WP{M%cfgjZSci(O{{EUz{M>)h=R%_g_z`l|Yur*bv0X5oUd^w$-tTzw4_aBR0kvi+tT| zB&_(_bVR1TW(GtC=IgOVpb3}DEl3qNtsETnd(tD*h$w{KU~YHv?lohHqFN2jRWlh3 zpv>{nFC#}M#oNk5Fof~9Ntf%>C9K|t*D=%nCP?#zHf!Nex$@6^jqnu{l4iwwUOrh)`0eFQQDPOmC5fl3Jay%)OX5*0nq&g{*yoU`(IAYTu1--_jA@O z;$m(kW#Xzy$DNGPNk^8u^{67lyCG~%E$v4Jc+9$5QH~B&q-L2R(djF){fB$(!@MgdZjVo&7aGxPRqLxMq7iaqgjfiOPsFm|>4 zI4`Af(_!nWS=Az|%qKIQwyrUd*uE{MM8Upi+lE6N6jlC3W=ZgrD?2iZv_R1uK2vms z4XiCTeg_9WZw;O`;r}fDL%|dWL?a*e&EFhsV%l8jX^cdRN@*6`hjf9lP=G;2AuE{A zS$^ldRqOmda-ay_O4Ns03-FbAGDw%bv8d*kg~1Yp?B-gJvo`IJw*_i}w@b0k{p)N!@E`(i`CqR(Mak1x~C_+`OUYWxMDDiqKN8qs+_al5GK=k9iUj;WP4JG-w( ztv|(0+HO39$3P9Ru}3se{_u`TLBta+Gusj=A3tBTg$eXJ(kdsV-`j9JnT4vQsGbu^ zbu`qApu0~d`(|zr`2fF*Li_B8M#==9%KgP{fw}xs>qTpt)3g~b;K{*3%{IIw1hXT} zO1aMj3>g|R8ZAK#Zi}y^tID(^NMepCt`>U+jUd=)j&Wg*qVdWB8u|#2{`NDY(QoEv z0gJPPs=6VMM&MWCR2bj^hapw~kdB-yI_hh@>H{`ljKyZRVJgJ@q!LGO7lF=0<;Lju{szSkQYuY=KQt?dRi>#_{*==da zw)w;&r~FjN2m0Wp+*y~6gQNxycz{|w&qjae`G^0QpMkCFFD~^30Tx>mSaimMoh$aU z{lON=D0!uU$Sl#?n(_>-xRTJe#Lmh`Xf*b+;Lnp%we(;IN`$Wm{TOJCdrcGIe$k{Z z-QYDQ{4l{n?a;dI!?URlmZ7~pK-Ahgw)Z9~t|6+eY%491j*-vk-a5BzU-^8x} z<}uqNVem1eB%)nR5PZRX@QIN5@g|`LJ-|a6;1L)jN)6j>{YIOqOY$W|6yh;V+!bf_ zK7D&(h`3I@H6A##u)GKce1(p{llN1EssW zLmKJs?rxB7Danm=Z@Rl<(^FkDoHATJ)!qcLzI;xdEPv=spdSD>eeJI#YB zO}iir?gekn?dIh@rwj%+t_ z?$CTA|3rk2{hlK6r=G(xfIWOMD4{!_RpNIv->6`kYTkaQ3euc1lKaA{qrGM0#GlnwgZ%I>PQ^T;Y*nu`f*v z>v%rRl|&W@19PX zKnSZ?>rHZ*?>;)pyy{TYFZ?!%`#~?HiAOfB_PMm0gMObfIkUo4U;@??mt87+QOl-k z+yHO#N(+hLL#NxC$k>0_Z6!Z~&0;lXjc`bxI%+~)qCH)>Y|J~bHK%KPPWA*}pa)lro zu*lLpya*GMt}QyLGMpQ3OwL(FswLG4_Saik`G_Q^K);xv+E#-lc7jgY-}aou)rGd@ zS!NlC^EwNQoQYv8*Ca{F?leRREgVxI4F#^ImJ``Rn+&Dhv!_yzR}Objsf7q>z~O{z z7}xN4KPS2NB)6PTVY0U$thktPdu>&>OifMwcRHg&LUREbW`%Fp5E08V>ES)L`$TEP z|D0=ESYB^;9QpHwvYKVh4L8l|uZi?tmL^HlaLkbA3_GDn$a7heoDBnyslp?*Ms7T@ zI#ul8vqpe*3Q!g}-$&_CZ7zD(9<%;$2+g7e#1ca;&qIJdeY9Jx1t-NwN0s}1t1GOW zJosyf3%aRH!*&bSR(E)Cf{F0b%o8Xa9yr4&jBD~Cow4qUyHZV%?tuumNMHi++3bm5 zlzrx`4lksD`#geq63J6v^{Q_dU>dvGcJ&1;F9w5dzJF z+4EfSGO7YV=63Gtn&~0Ufe5exBEELUcr>|JEez^r0Hv&q?h>{ z&IXJX6{HUI!oqn4Y}Y83AE}HrjjfH3t#RDf;PAKTrQ0HL33sprlx8iEASbwNwG7M! z0?-Bq-a866k?*e2FKy$1gkq#BCdKuES#mBVCt%CLe^t;`DU&D+J$MNddk zOY?J{W1_J$y<1x=Gd(}o)>!`QCUGih;gq>Dch5NP*77m%Qf3lc=!{sC!egfa3(@x!diiN#yJK=l?_q}XZN_&^+U zwNRu?m>ca1A+lzj&$kSw{@vZ(Cb|URWH8WFr0jn#b~rwU?h*dJJ=YZPxy=KJRu|8X zj^F+$2wzl=+qVl^N2R#!mkE%S8N0%DF0wO{)D2>@Y?n@h#kB~=5aEN)BcRANSU|EB zkqM3bDzu^0hb2V;hKChWnB$jloxxbYJR+|hEPq9+6qiJ>qb>icmpVk5%+HhzEh!ZLWxWO|f#ulN=2<;E)P^6di8II-8-7~m|eaYgT$ zFAYmEhR6axaZ8E9QMze0#)MVLkwl&`k4w^*W?ZTC>Q_UXSWaT{D-EXzIQg*GzU9mT z?MMy-@NBeO>X`VGJ>!@Ewq)#2Fj6B|c#JkcrSrx6fC}yn^C7!#)`zjNt%xa-sIFsyXXaj8n5Tgo>{7GJXx7+ds26G^7Dc( zMb#0%k3)qOQne&RL&7g9y;z0(j`Oln->^fD`8WF*Mv8#cKZJmQHM_U>3`nCXFup2q zD;}p_Qg*|xIAaw*=k8$Df0^ry}{3L*RFDh9HL!>`Mx zJ}2u6JSgPbGlv{P@u)4X84iF=P4$8KKbv4VI!ZD_d}CCyv}Gi(>we2x1V#6Qig!!Q zhQFiU|5%1aO<(Qh2PWE1&YRB=Gx2Yrd>QuYn?s^BkQVZ?+v2PA{W%vAt{48mpRwGi z3dx7GB5H3DGK|x6Tn0HWQVnt^S#<9K7L+LOlS%VrwxujV=DLnC z1M*E3<~@gwz#{6As{uRQBuhI0O!6DbeanL~eH&khGQ0`Kw|jGiP9}fPbUF8r|H`aW zU7%}*BGaShX-q;%|4?a&aFJW=ZGs{QwN9ae-&q&92v#hir+^ZCWxr`CebsVM`9jkC zgm;bQPw0-~RY|ioU4vdwAoxoq6gu)lX&SiWM+&O$m>+KQ?lK8SO^z0aIV8)A_dl>6 zX%L@Jt6U@)n0!5MOvJOA%%{g&(deW`+($K_c$xi?tMw$)->YwJq`?VkOYPjdNytYC zI2U75x}KJvGg(2e8K8@0#WgyfQ0ZjCQo-<{X0!JE!ZKtgCJ3jblaw{&AwJe87LgXc ziUWo$wzvc|yrz@56ntIjpr4{+B}`U&OIjl45EUQ)2Y|~1u!)#B)xpVOZ%>m&u!A!H zQQduKRa3&_1?5b;?;l`YbU8X?9;>PpIgBpLclS^%GKuaNo7#|c-%(YRRBb@aFBbSQ zm%Tyl%@lKmI=ooFisd<*87`trrXn>d8Gv0VnV}+K-|Uu$Ut^(9u!M#SgeirZ7^^Uj z37rtCi5$;MHwSP0}nXf9NkM6TGm*b|u;%sn$=of+S*yp`zVgfeG5| z60DXN<>ceTy_?;fK#O+EvL+&Pa}i#&R@$rZc*i$45^yH2!06)ckt)j%SbXt4Fs(Bwd(&uhpNE{8h^4}u_vrq4>7ceJ#F#X%ET zwK9v<`~@`t|@sAOg;!rv(h6sNvtRt5FP;Iny$dZc}xO*jkoU z!zF|NPQ}Y?-=Oo}pzEdap?pnem00x?FM2H$f6QO{>%PgIq}>)0qk}I(=@M}S)QbJU znNsjd6;u29iI3xCioCX*K5zJy2lamq3r+c_bksL&ExGYC<>k7S4(>|k1|4c1&M(N> z9hw;ZpRq24HQwQcrk*xA<$AHgUxgJ2MA(l0j7AKWoZtM!ZY{f$H}ebCCliqh{inBX zzuCjNU+|2|>|czSK-?+s&&Ygw`FhxpBO4sFP4=V%SDR>}?Kotq$74GP+4@stb(%wR+Y;KM0y?GFhk#QL!_tT77$T{U zmkbbN`U;h{lK{UtL=Hj_LMYSEV>W0G1XojQaRWY+8P`Svy@k}}EF%>7R>Fb0Zv7<` zD?CocnWM~soXtueH&zksxa%-|T>vJ7-u)j@Uy=v`FuW=c5A{0CAXo34bwG*IGv-2C z;yF%$awjWgQ>srbQAjNrrJZpghH`}0c|oQWCn&ynL`aWr-5B^4(GoW)?Q2K zLP~^Y&Lu(_mqppG0+TLqT^4$NxNmKsgEhJ^@F2v3vu>i`4q-0)Vi$BqR98xB{po*E zOaK^*2{?cPaDyzcB`pi;&%E^KRUiy091+e-8$@$@=G1Oiu?oFI@I|G* zDH#c-+x9_i*Nby@f?5CrzVu~Xov~9D3^k~jcy~;v7?GfR?JecQ!BoY8Ps|uIK#Qif zCx+hoOxJh1m{xnA9;#eJ&=#cQDD6%yCOt4J2^?#v-#^lu0OOS(H$^_M&$~uw43#l> z7jw;cGbVkEpdE}uL#$CkQnMm>Of!$jEcwbLA6p&Ft}{{-vAxGpcXG}>iW8s~^R8}( z{^>MF2ryuI(P?07Z}KLg2iMvhM@5e}o*uULKKoEyE#fACt*djz#C`2T^Q;rB@0dma_pLg0)lbz;uqfJ3tJ>@NRdoSGzk8RDPGs|cdE$0{Gs+0Z zq*A`sB-~~G-hH9zaX`<3I=<)#oqoOzr#B4OORR_YlIl9|y&lSDQW=m~Y!K497dAfv zZ>}ZF8Op@1nx$PVBq{JdXsk(557a0|B2r|`O4OTA{r}a4yB+EtVo;z2z~9zF4F@6o z;8WQfBxHm{=@Z(FnosM_^z5dTc$k#Z7=jWK-r^IqFKaUt$$;#Xld+a&}tvdEsSLI>$*lCVTU-yDfPT>+jlIIu4d9e1_Brwbig!fJaB z0ki+yZHQ9NKcRUL^nzAI)hX^4PJj~O4RfpKJ)vZ&4Y$Vs!Q16JiYr8+hJowm=XZ`_ zv1Wv{9^6M8;tE&**u}IZN6cBR@Iku6(KbyG`rP~9!-G77J-9;D)hVo>cdU>D#KK^B z1!T5{!{R7JQzeqVDA^-J$Rl zR3(AWBRHREtn-|tDr$q_nDyu&Oa!!LP|L`EgBo6ng8kGwfwOqlxZj9M#wf%B@Onf#Lsc6q_U!v>@ z3EwU(WWa=bcW!~|ecs-*`lFTV1a?^hal4qSy}!HXS}sBAW_sQ`$a^xfAi=-c%%#FxRxj?x~@WN2EmSd_*L(0$@0Q z+U!q*)vKQ9yW>0bx2M4>I$P=W_^f$C4uSm`OsPV0t_nD?bqar}o}BuPB3vkUFLky) z#SEP*T7Qe5Hf@V2fa6H%$7wkfV384Up6s8fffMcrqJ^n`4WVE>a$~*qkZ0WWv*^*St;6Ft5U zz|fWlVi8npnGkWa%RSQQ1SFMd;VO_47HUHM6<M^<3m2CM1RE=2Y>o7M`Zu2EkBXGzR}S^();)H?#6mm!AnER zpbKUij$6U3g$WD;|1E0a%2Pki`ztxwL@LjMP{}0^tUSC2gb-qh2_(;p9JuZ; za|Y(qAoIY*k(9Xa9UPa zFn8?t)6uj^^ACYF1HzEw|KbbZoW2m%dLW;`wjy_iL2ng&JNrN}#7+kCRv;%}UIBRY zYH%=r`^(?M=<$pvQo~wakSv!3E)q>!--p-${>YA;KYx^+uvvxWJlcogY|{6A;Ulr(dcm1t8Mb zVhX9>{6T3jW}PKlrkdli@}Z?F;LY6gDaN5Nhf8kXOM)Uk(5W-=Z*85=sNl-Y2bHyk z{EmXsfxYBquFI)H3DgQPrLMvg`udYk43V%04bYeX?4psFN8ETK=`VDzk?V>_yYa_+hlJFPz} zB1ck!YSe&>N-7}4T6T#ev%jY9|GO$7pz~>cb)nhK|8g@XOckR2P*fB-No}ke`C1d| zh)=x~nuF2}eY9yDe-|OU7v?QXJi2JX$RIsv{yqv(F0w6~$DPW-s5w%jx=D0th12&` zpo2uBPQ1?Ow*bj0UPV13I~f#c2`V2<*6~An79a6c$>eA>gro~^pxb7sHb)u(l>%jC zHym@EYAW)HKhjEZ!kicj(qjNT-3yuDC7#es+()bc@EFY#0FQ~7-b^%th8zG^%pBH2qJikytDxc62!MI+7MwiExc@g3S zwLN&;Lt#aehS*SL4u*Uy&gVz98I9mGM`8l_hvd4_7N1HMoNV)z_{lDW7pd|ueVeDF z3+CN^Jcq%#orsh3m~5qk0s=glQa3ep2n^Z;BBhlARj^x_4;5e`j6};_{nH>Apzsa- zXX*JhMFoJ=h2uYg{}f!Top_k~3%e@mr97#Kxk{OwTzpj|kZO^_sn!Uz+v3^(xpl*; zR74Y}v1}`bihpk2Q|XUYKk8Hrj*-fSt|Nuhr^VC2a9Ep-!~( z5GN+3r!FAi6D8g4scXmiD%fU6trE&K0iqh+H9^XDlilwwsa|3&MW=*B875ZXPYVkn zIpSixhb8$7Az&L+$UcuiQZ$T6*+%w+8^|W^+ukIR81>=>Y-^fax>$j+N(L}%K^fKWE^>-;BMXM%%p|Ug9U8>n zc#Te8p~`Nl^CT;M@ob?{OqLbt=JM(0Vteg&tVXre?p_ZoIrX{-l&BSR&!kOi51xu)|m2OiSY7*LjxHariUZnc045n@Y{AV&T`>_01onPLY=SbyfQG`t;QK zT-d$D4ie~i*OZ?GQ_x+Ykf$h1XC8!Vaqv$;{fIjzf}xSJ74N^$vLVffsCk`lvK zMI8KbcZ>F9$d@?!me?DR=N(D)w=wH}$C zT;sY6YA`$$fiJ_d=-SJSE1?bEb1i-i1REZgE~Abi$x^nrVgdHt3$Uk&J*K{CA-P{q z>!!Qek`K?;Tm3Q}uw#J{r0Vz8#Q?PFWr%0Eeov1C{F)0Ce>%vwMmpp=K`7Iem$Hea z#5Lc2JJHHNujRfZ)1PF`M`50;84s5Kq?b@PF`>=#Z(xJQtLOes=A`fG7@-;Al?;(P zK;Pj&`=7?RKL)_~xt!;hgsn5+u5^+$L%}HAO2~btn?QZ?PS3OtxoGRzMGeStGZU*m zx2N)B`r!KDS7Mauia=#r2KV|l{TSUA0K(^swB<*N(L2a7>%m){Df{VUtQkW)&_VV)1!J$dH{<~IkCY#lTfFk|AnK;uym(AeJI-h}J2{suzAQLB5GEN~exLQ=gI z(4^>yZTd=27)zsW-Uo(pSz>78jQwQz=$^&dq;pv39hC&WlpZ_XrhSq)py2I6*7@rl z5C-4DZ|f&cCdrnIPyozqfesRK=2#f9fxo-y_YdTwm$fBdZX*A1<$||Znj)BNq~oy+ zaYWgW^qHw`4_NGpS!4%TJ#N0*PGG!oCKUby8yKUkfe^Tj={H8lFJgcc=>2*Fd^nXc z%5h>9AMxsAhFK;V>Ev#5%a!-Ag5Hs3gljDOF7{_S9lmRFmn{C}VB8f6&5F%X7TAzD ze7kikWtVVj|EbZ^fty-|0k{8K`zanS3HNour3j27IlJ1T0JAB}+T!0ytZB>=)UU{y z8R%p(>`?OFYh38n!u)jFipgMKi!rEOHs7nT*aNYE{lCrumqW{M>%g1I8X9i%KVg8` zv9A5z#q672I)2zgcOe__2_I>}G|>F*%26%C!5Fr(tff8t6O%zDr&w+&MVY2_(vb%N ziK!5IBAFm+1oX9%?H#|~BRVS##I71t&PXmTMDr$u`2)*e>i6IMjkr$1*k~-1T6*?+{Ghi_F}mCN7U^U)x>|+xis>{9-Uk?vhSb67m|{ePy5|1 zPMK95KSM4JpZ^(o;01h2MEg*_W&KgUx&k$$(eEmv$AndlD_R*PGMYlHAKA#BQqp4slY>QUv2=ccR*9;W$F< zk>9Cwa8l-j1CoK8i4MB7l62+5cB#44S!AJg-_eH4h$HDRZ^#&2J>a~7{{dFMxiKjg z2cafDPWl#?{5W3$a*bj%E4Cz;Af z`N-G(uyGtriQxH1!v)c`L7Hr!v##$)N1=SqW|(3yF!LZ-X|InA9SAo!XO6$o0h52Z z)2x%yq8=Oh2Ttr`E(r=3rd1Yu@anEZA>|tNUFvPs)ljX%Sl3oZhbJHM;0IfDZQxU4*zS0yXz?^Zrj;z!n#9VWAEIHVWxnz?GZ3AgDVj7bN|A zzubE-?n4S2EW96b0rf%|-VVo_{_8v@WYv%;<`6CaOp*>lBg+}fIjHVkCmt0`cO6;$ zbtx9wC&-my+~#`(Qf0<-63en%p3J+Y73bw8dHHzB@OjU77~x=dQm%fo{A|5riy=pe zPcM<|LAMQxJAyPRN|5(jFcG&Vrn6pSWKttbA(@CGDx!9e5=nyvLTRBnZK(`XE8n(? z%-1F06V(McmX?Sq`Nvf0^R2&j`jn_*GD#m$=xn66;cIXnMr*=VgcPC@Y0^nN9gHN= zrDAPGSB(s7syYp6>&urnRY{wSn87JjSKBTko>UwlXs&A!0do&t5D^BDjJA*j^f2JR z)*=M3m)gsHt20VUv#$Y9w+RUtx|(nA9N3a2?YxSQ5do5tgod=JDKNQ-gvP^=*=ViTkMaI2&n_F9J z4BwzRs-#G)JhIHLhr0H7jV-@?1hpPXMH(H76zC9>QYOwLtr!T=y1rWqs)-XHV|rSZ zQAVf`J6cb5F~GYIM(^=>Wga%|O@@zANgxE*~Nnlh5{q@BT)^jmtql!HXkh zkGalOAk8OorNK>l6M$&$54Gl>X-m9YVunsco50BUk)?~331RYRABf!DkTt`IQ`RAI><@C1Rh>3b{v(x&=d2P=GE$`SB`59O&a+y$68Aw$vY~LMZbvymVX{=2Ra?@ z+}w_){P)tG@{~$#hxFuk*}nWp)tPLd?NSWkhND;pGEX3l7I@dJX&r>e#8xvXYpNTs zpLIN5HNbxFc|}ktvEuNECbiR;C}DKbUFXaY`O#yFX9CSIYqgxN1pqzxK*5*49nq!? z@YRCH7qiGuXl@g)8h%J_Fn16A5T95o7f{sftbh=Hzme?_)gM>hweL|UR;bSKkY%#I zc5guuPTk)#Tf-qaB$MaIR=6~E?p+QK666}v810B9h4V!b$O?vXn&uN333X4Vet+c? zyPgj|Ua*<|Z;S&cs|1qf=P;OS&m*NfGEpprS9~swt=8e_uqnV9p5!{&V_(x?ME_hn ztXkG_ceehT$!p^GS#t8LlSaGd34)PC&E1CM@Q}LaXSm-~1GQOt3Wvtp?gxJLM*uNI z)AgkJABzRVP<9JFNm4*O_(LtgF+;p15$WkOpzA+Pg$untwcP9^+346Tm#+|SH~z1i zB7LH{?k2vv{3XW-())o!t3#Bs-G3|TNlOcRLb1tqnK^j~)8qgt&czf|68dNi>+Qlh zKuFHGpyh|Uv}nPFv$EXN*%ZJ(ax*x3s1*)Nk2cJjdmx*Md8$CWyoX%jR~Tb6C?b<% zguscsm*oi($-R!e8(aQ|nzp;tX4aPLghQ`WTUP2Cbc_{G!}em(p)S4HCdov!^tG(S zaUA#kO#5*i(;Yh=P$(3yl2(u zvuF%B877w1AC0M7t#{q~YMEdo(~U+cK2qznKVSWbI;o*ma>Z2q8wuU{e1|rFgCY>; zgZpoa&TktDO9t1%0Rq*_o8JbwacXxpC+8dxYDr3ZG{|hO6 zy4FxcYUM}^?I%~Ibe5LWNQVvImQXh5i4AU3PLf=L?wqVGd4hB$4g5Ck<62LFjJm%5 z6PwECt-7OedZ{InYuB=RZen?;5{V(H^X~(nxDIX+muEKgKAtc?C}@_xcwLp<2ev2w z=>ycBe6cNQNWBxA{ps#_>$wC-aW_|&m;W(10T(Afq`k&?jYAp77|F`-X`x$uKLsjv z!KLRtp0Lhq^}yIizR%S0H3`voMd~kKtf5?7TeO%kf&QJJ{HF#{Zx`x5#S}v9t-m2k zz9SO!;)90HkkiwzTu~jgYQ3(ExDPVN>GO?a-Y$)Q@^IE!n)yE z6>*iX>AVXisfev(jV_3?85vdq3RHm3U8rfpM!Sqa{YcRrZBQ10>A#>$^p`rGGGw)0puevIKVt_u7B^rwm9RD8bv zf4^ZCEgl_zZzLk~oUZ86dzy2Sc-$=lF*Y=tuctEpW*A4oqmy=EsQT5}c7fB{l9%PQ~vT zpNaZy1i>eV4db(^+hdO~6Jr*=mU>qm^A-;rBV{tC(pvAk-a#cPCw)fCah>hI$RSBE za%J=MH(T* z9i@?kp9RV#xB=*>j!f9q;sGiJmDEaHNhE`r&2j!%fM1>ifQUejp_bkh0S{jKQ)0&{ z_P=v4e#RaA25cx$f{xtNuI9_kr9R?lU;a^F-r-xJ<%TG&losFPhJVb;bT4Mb1}1!Po|0xzI9GR}~f(s_@l=y_V$hQh@2Qg?T#(1~rQ2V)Q(r^AX#s z!TDZdJL*$=G?+YbF!(cz+!6~9LK|itU61!bfOd8c;j&d_!kk8WRrxWU3n(DL8c$9> z2F7ojm(w~p;L%k-!u;P>S6o)_Xz%SUf`-r5lX3c+GI2md&WQ0Jr^m)}h4`b_vbc)Z z;UAqLNE2=BG&`D;Ro|{rmwOfPMS$~24x+>0EtuiKXVVO zS!ufo`S;5%9ozjC_Vo*X&Su{d;?h_c6u5IRpnzo?sBydbM znuh~pgYBDe@<(#8tJ#CO9{1y}Ml>@U`r5}jwf03U7kY0Oj#Vn1v4mpLZIcpvB3>am zLi*Wv(L;g@!xia}a2WJ4b4W7uzi~BNt@V60Gq0Z_A=Yi=GY#Sm1gtd6x{5%Q#uV}$ z4o*FI5X3~_tfW0Mi}xqz9ei<4IWplxmW`nj;_b+YB%;ptt7oKXD+Dcx<;|Kl96(Yu zSOGv8$gV9A2Fezc`bX*Cofp-$DC$7=Low-jHnqv+@LwiXP#^-R%!$h&OFDa}r1XoD z!gKXeJTd!GD;Em)4ln0P)mB$qM7qe*mt;&=T)q7Uyk^DP;6PdfTwOW;7AJ=6@2ue| z0p^-pYEN16Y)shim%1steRK$Af*i^u8_Myc=jpTmxekBq^?ptU64o1YkkHTrwy&la z1EqG8cQ6mN`)0#&k4Z-^ZG$Idn0h#%IQ_Sw&cN34oG=e_S-DD~w0CDm8<%ytNv5ZO+Yg6vK@U$9CJA{z@eLTl+szUgN?_ z?U5$1qA-U*pH@pes?$Zs}E4jwzhr^zdU{XvXx4uph;BmN&E6dU4I*4 zfFx3TELfF5tor;hq0rK7#l^BwW>9+`I}zXkC-<<390p<7Gws}=B#VAe(Rmhg|u)_CzN0n0$k^69vmEXM#r zY(U@ z0L1WJ;b0}0-@!mcC9u+0{JZ_}cl{A~bTd(Sz6)W zGHjY-T@E_s&te1U__iS#A^qfi$M#H$ttjic3=p(=FT(DJts#%X*QK!72f~Mf0+yN5 z|1HW6QXd%AzdU!=e5AZdwLdWSn~5Vdby?~BEi#1<^Nn+Qq^I)Ie^%OYE}M8ZolEIK zQ+kfNT7e+M5f(m{f|*jHyWB?RhhQs5?8*@;K;Z zp%kn^JQ3XQEVaNc+Zv+ii=$#+Nc}$$2TYCVhcxx!`O@Z&MF4oL0Kb7%-YHPM=ed7{ zI|{53!psy^V2}@l)H)3f?2yG3bw4;D>G89W=o1<--z;YY-hE&{@8~xS-*p~iZRCsx znYAA01~HCI3JKfgq}HU9AMqq-mOm9dJRmx{MW}4v>~75A)C87Afe)B*qRCGVNs2m`FUZBevu^ACoI=ubuQ@s@+0-M zA8JU>iNrq<^ho3IuIa&Z?6l-rs4f;fo9mSMR~4u~e_-DuJHlX(xIOv;F|&0w>diYF z-<&CHNqOCjSFf5HX!^$2pjct*nYxj!-vPnX&s)=SSgq^Uh4Gy{f(|a!wFeK#@WZ!O zaGygXqgNK=I) z3qWo<56z``0qU3Viy^~*prWoaSGG0k-u3T|7y>1`k_8bCwZ`&IrpBNy?T@y=fQg~# zgM^MFq@=BbzHZA{oLDR}P_tXFo3qO{PNgwGjcW6FT^aK7Z1MPv4z7Zm4aoYPTo*L9$dMlgnoR2ZL(07%m)naHlByxcI zu{wd%R$WM?~qmSmx8Ct6XaO*S1jU3Q-+yIlI{K)?tZa;$W8md8CPSkPZ6aR z+ZU@9Tj*fbkIm0%B`Pu-b$lF-RE57e@|jJ`ZUPmHvLq$r4tjxS`a{j9WBTRnR< zmwFBU=u8pCWINTc3;RM+_|YWjJ5QTJx+}RZu9=9Ahwh5|qpwL9ND31-N@kXV+D@OE zKcYu+JOK#gLI25q9Sjrtv^eX{{3m2ebO!CW8U#!cBK7IpZipA2Qru4Q21ta|lvh`K zZy%kXLkKw*pvSwx)}?D+1~Xqm9O8^oZpZD)}Vp^5TpJ|s^M6_r3 z2BGOXgGvJ)jLu4rZB!0CI`TOPA0O#{PR5q=LodazNj?i*<0Fn!40GaAie{*^9iFDB zczObK?&u%!Fkcprj@`I?2j(F7 zc0=)m)<-*_P&!M130Jb9O z{Ud#jA(gZ}@aNSVHtzZZ0y+&R$B>}+reUek!?T4(ld)2LAC9he6R~7f z1n?&()G9ysOf^_40If>`us6k8LjO{bQS66W)S=h?;q5NyJ@ztl!G6ig%KD%3O#>YW zkJJ>;9wtC>rb$B;>4cxjSJLt6J>)KB0?L4+3=K{WiJVxmA;LiynhWET8cXWoLoVWg zn^;G~earYe5gQC{r9Z_Y7e;%(QYeHXgkjHqH+9wo-1VI0>V_PHp;hy`A3TzePlF9B z^#&l%ffWoTRb+r{K{CRJS6wWN_-2Qt~k8kX|-=BwW%@`fXL@N79n-v~&O?wwG zgEH~%*0<3IDPcrzv(*S`r9e=UDt%thA07RVmR^p{J)B|}5|z84 zzaua1I04Trtk-0gs+qm-Uj8b6;j|SI3UbzCF%>?-B6NM4ju2dAE?s!?!(Q#n}(5x}7YIx_97lLMZ^6c;5kN@IY(PKeCR+t|y z$S_&PDil~zEz5f$PX;vh-^lM&LR-$xK5HWv@R2L@Z2sh-IV64EI^@7?Ky($F14KWu z$o9ZT(<902iPf@AwCWkJ0ejWRCEOcI9{L1ZwWPhUDIkm7GHp5#wLl5}347ox`(dk! zP|h5@J-P~l3MelTdT(Ed?REK6v-f_#s!;fHv%I|A=^z0j)yp6WK>p9zs5Y}us5U$D zU)Z}_Tf|Y})h}i94)Gtpu>^t#G~{6`Zu&_$*}au=UiZsu^p_-c2qAPgeO)RK2xHxy z{ZfrU6_NSN6a%t(=rNt&)1S;41*1({1Ra^fTTzOr2fv|RXG4D7x0Od&iJjT+AQb$W z%q8;Cg8~yl(yGmigdo#h7*pOhNC{#J9w8Ry0gt4$faP%fB`FFUw-jl}RRQ+$ZV}DJ~P+@{B}5m6Bx5 z3g!{i;k1c@9wp-xWJj7+)3>{?-9@3oq9${X!#RqGh~?=Ob?L7Q-W-vE4;A{VjM1*- zrG5*-SaPHz!z1e79334opNVMY6AL+xW)nSXM1{ZoU73SP22N^K0eQJTT7=c|1Nx{cfkMM(OK(exQrAqdRDb=ZoEopI7m4cNi7k3u3q+ zw*LGn%!GeDX33SeBR%7IM{(V8v|dLog|Nt1>R|SCfIZ;0BIw`E{x?x0Bd-siCxYM zf4k8ue5k|SiLd$15*qONd?umTC_N{F)J;o|$63~@d7zHG9&$$#c`XLFC4H2A?ut1nINuZLFpBO3p&^LMXt8wY*?84nWbX6=iabekN`3HYoArg=VrTpg? ztdiJE!}Lx3odUL9oua2F^OErGao}XC8AG+6j^b6hf` z>T_-%L<*^`lY*pDDpOQQ)TL<-fMZ(HFnX7pr7bQT(Zu!-C6QGoana(kOW?-2Q4_V% zY}tL3=rzz=wVv+i?*1h3#wEm)IDX(0l{1&uuv(T7De6E>*%~LwgL-wwgmL4giTa7R z(-uyMcb^Pnir!U{E7V-9c_`Qt#Qq(~=vDDKKlpLf9d7A{zhx42z6~!X45O*NJ$J#2 z+8@Td4bq1*DcBy)8aT38gI(~)W6AnXHd!hKtco}~tlbHSOF9=EYwAV5_1eyQYu2(UZNhLqG8 z5N6K#Qk7n`P62FTMXRQFi{nKOHN8yf<^-9$5xs`ez*S{ZOGqaZLPK>C2}?{C8WwTT zha0Ak@s#>CR62`$Qndl&TM4a$oxjFfx*|vPD@A2X)v1I{ROda>R~Z-D^iZt)sggzm z8izr(nLsT%MRM>~T!fPNHP7(t`Ek1%__q7`$j3~)*`CY>$Xu|iI~`0IG^cIu%uUGU zW)}~cbA<>XX5_jn@bB~XxAuK=bhxP7hI>S|Kthad8PzRJp!&U}MYQCG#f$a(6Ph0M z6E(yK%4d$~kIVN|H?oaryiaIrZfT^JMH}B{SEZ82gR`nX%6Qr+dC*Jhx+=UPRj*;? zsiuEb3esRDlZsttNgRgKU&f*8jvE}%41oJ+hzCRpSBTw=!}HH{`i<29egS7^XVnsK zJFF6-0pnf0L8>hTJZ;V5Q#2h`;HpLhuusY)JQ1$;X7hS7B>TwNVk@J=n+CO@uPgV@ zeWi36AxFgLFn+Q5r*mP2Wc z7Xj$im)_{z`H}{U>PvA+lH4BBF^ZF zlhDR*V=IcFaXd88$^b1@MLxxfqOkcuM$jp`2m4PljA%WVw_K4W-l?A#=DvDA>_}66 z2|6%-jd2%DxP@blsCMxNaH!OA;=Ac;K!N+f?JB>gD1Whb-{F8&DRPOCDSo02zI?BI z_r>(}kluI60Y{G>VRKu4Fn_Jp;1QB0q9?qzfbQqyUw8r}`9AK5@vZ6hMWbw)>?P3G zmZ7?2FbN1&GG=t};|LPLeJxCkH$89;>YYMQJt>Xx0=x2J$3aO@dl=}=2u2JVXxxW&%j3U$yNEh z3!q!sj|=j@`*`y;`^;2(yOY?%HszT~E5*6YYn?#UpG-~&CJQ(?XstrUCQp`HaNSc)A-s%Cr z%4ww%cnZfzH3@TdPh||ehOJQT2vBc__T%f%E9TcjQ5XB^3Xh_b0|8#r)$T7LNm=&K zQh~Y9sMFcPJu|Me41>n~Pgpy=H6?@&cn-@;`varbQ9*~4FFD;LxBjLG*))q`4R0yU0)rUfAO z_4m9dTKn^NRWILvfLD9>PV>AX z!c({r1wH4~}>16z&AjG9ew2Hc7b53qx^ve;i`l!S=15Xu1f|3!cuVc`8 zR$&3|Y9fxN18wkV;J@MWxh8rOB39WMjEN~9F2x3-JU%>$XZCJTN5~81X3z^N#YQlN zmAc1t;uWwpqTFt?B4N3Xh1n6238t7}H<<3HDQ;)vHRK{^HbX(3Mg2*bb!j5LEG($* z229|tsn{_+KPdk#=l(Y=h4ODJ%pW)0X}^?K^sKwRbw3bs6cb8Q(>bN5&q9en%<&tv z0V42YE?#t*md6$*Q)-eXCszyxn`D~EFgn~&_8Xd^TDp2a*7&fXovew}i*D-z ziI>j6=k!*7z`CpEap51`=C#ed?!+lo85*><0X&Kr2k{8N6@PmiGB1la?{jUk)v*}H zetPFM)uVwMa^yPRC2~4gqJuqDUoRvQ6dD{_EG30mf+&V8T!Q=5?8)@%`&Lz@uG5=Y z44>Un^^fKsOItD(m7g){c2l#HY{zRI4;h+`mIn9LOL@VQ?FMbybEgTmR|^uI>G7&% z?u025zZ~RQY?GEdJ}Sj)f+#}xN0~7&M)wK|0-MG9B|k_dyA;)`cm#Hb>6efeofS;& z%yOyGf+oK*0ti9@BpjEI*AP&GvOpi?Z}Neivxz%WdE!ua-QRGsY7TIc9u#1eUun^c z;>fYxae}ijJp0fT%v_i73dcP(Ost{Nh@ah`A=1=dF@1)eYgVTckm=+o?|9i73Pbgn z!Eg2wAuHR(cVj|Ti7+oI@o?z`K&RiZ7uF&hN*J;VJCBByKhHluwn5Z=`VR*c27LsL zs!)0$N;6b}PhYd?#F_QuH0z0A=^GGGPPEAi&LcR=(NW<(J;`2P;MoQJqA(wIIl#v- zQz1e7!w`zMa7qJq*Vrp#D~M3eVzrvum7u%76EBjFwNUQvZB)^=@j-hd7~K~g%O9q) z_%L534)`Jh+9|JtDBYnyNs0@|605VmeY>^=bGD%j|9xz$iq+(GgBA;DXefgX4nX-j20U@n}iZvF+5_*`XJ9H2tL~s(2Qx!rk!%teYVt5#c+#FuTi05 zB2a)ot|#nrmc8XaKmUDylI1h_@xv&;d%xDq>acDWYIK_+9^=|vTmby*(c6h+=TZ-m zC(e>v)E=In955ZhkOSHVqdKbUw}rr5%eShlbs9 zcfq3AFG8qn)NSQ3Bp%Wu0=$<*M&n$xxJj+X`P|iJTnozQIjG7N0q`T&XD{PQ6HTfvGj@G-~=VQ>!tN`Kfn8iMOd>2|3`h}4yJV|0yNp#w*R9<{)(PEZ>V~Y=BDHBvdG$Cvkf}Jq8 zvYa$v1ZA*lV85BHdg)7zsnSe)^ZU7B_BgN+^1eePb!H;X{Mk+BT``1H(4$>G+sD?O z3>CU~PgljjSCxiKMK_AU(F>Z7;df6wLdzHn_i0r0zIg3SFOZ!JPUv3*mu;xb{N`NL0rcx7zPEVxK^uWP#}aXIxX6EYR@ceWXw4dV)X{}6NH z3@umbb$R3_YErGN1v;pPuVaBU=;PsV7j;oomdZi*PIS_>1w_UdgQc}UiM6y37(h?1 zQ2fgZ{y59qRtLz+Y04WO1GqhfKLtngDboGCOp7i64+n3GSZnB?`1N_}A;CoIO#|1R z@#RUz6!;C)ekH= zTF~-esU%utoO2*D{7QhG0v%pYaGH9XAl_<7A10RIr@L*WMl$$;ahnhLJZV;_6hN7F zA_(A!H1ZV1dwf>`Q@nNb@@{0*b3Z%8r$6TWyKgc-k9s=aP`6&KS~q=tECxPyGr`^kQcIu&@n;_roGtsCuzoW`>wluy zoCyn*-D`TNFbBOvf3Ps3H)ie-iojxeE6&bV?H3(E7mnz zy|0OsVF3mjZ`(Bmy3VIA#bkVmYHv!oqKK^5Hlii6~zSasjqI0%o02$l5&=f zEr$0 z51^rta2o0?gxXvmSEVu^d8#!RooN)Cd11XpPmJ0}HclMIRtc$+-D5PYl{hkJGpljX zW30WzbDlHDRyU;f4xqvi!@96h+ywX!%_SevmOxl(6sykUR<-i0Ix+m2k_^tH4hqN? zu_!ZuR%-wyGXc`4+vQsSBd&knBF<;a6yv7B)LMg(ohwvf?ur}onZ}3^Nj^Ca1E2jW zJ7gIH#e-Zw=4OWd852g9gPqNgl}Q$CO`L+$(Gm$x5K|>xGx+Y7abs`JA#Qel*DQba zM(aL>X$~gD<5r>#q(wc}x$S3tl^UDQ#&s|ig*xXH>)-g&_QAw=O(~gd+`XBh!AjzY z36H2O%86|}-XN>;<@=oWLHI!7_`f7rC=MMqp>obG&MA>7^u|Hv@1QT=n+)#&*+BS; z%WDa`>fLvLjz)0r&31t#Se~f?R&60Oin3zPjy&puA;{Fwyi0)tC&V~T%^~$PTruGZM zhMN$CC%7L_VS!UwfHV0xtWtfA;|c$^Zs1$R_B#JZ@6I0n-l!y4mdesJFP>L&kXzFX z07GT0lry}7RdSgGlTxJk4VnrXm=3X_1sqH$jw%mryQYn7=;z#^4gK%;i-TZ*s(0^{ zq$D;`XjO$J?f)PE`5)zhwY4>-?YAkytydAlzdc>a)O2s$VL5V9h#&jO1$cSAa}Q{~ zt{-nhP5akx0{+fo@Nz%__~~Yaye6ciQ{8=3Ps!%=G@Wr>lIqw%i@`x=HGOJ3;{qx9 zI6jfIQ|qSt>u<4qjG#eGVcD4Ba|o)p*(J-G*};1c!Rqxj!3Scllun^M6z4$VTlXMR zNY=b(NbIg@aXvx5gMdSNK@Jy9Qic#ONPx*A1S6s8AsKOnc-W8Kg|pzpyDpBAa0Xmn)kK!$@F(*R`lurP}V&8gNagN(PK>(I=(k~hPSCPw|#k*D3l3|l%X#R;)a(u&Z(v~K7~4ba;Y2L_TKjV|7f&tO6B|R;}#W1${@G+Esx|!Mq5e% z4aFMFLik-c@G}kmf^nt%jC69S8Oi}SuLGC`we+(%NugwDDvFOPBCP_KX8PbJvJJL{ zatGpYICr&Rvrm_3%9SoO4yJ3DKx>OtCf-j@q;{@PW0!AZt-yPJZ7iHSjXB5vsblw4 z>RK+SmHu}Zco-8HHwG!Edcw2#!c{!XH~k>g@B>oH*do#lqE)*yEa#?nL&Dum@TjkM zg-*ZY;aU@IWpm8QCM6YaD@AClW;nyY>OI-}@0*xj%H-=G&mC z07wk(bisY2#-(KFa6}ersog;6@$`^N<`A z$|!>|onFErt@M#{zw!?OIhb zhvb}%3vSTbTc!?Oc6RoI06&{$^2;FCbz3pk7p6=jaN~&%6y}Um-6N7G;EIw89lvyg zu$M!yeA|yDB+V7Nj7GOm8X?-|)Npryke8))#3tlK{EPJH4v?n<0`LeISo-;4O?>&t zYs`Y4Y1Op2j#pg0f&^D^^{)Fa?6KkHz(M`Ea0NdN9ca4xG$!3{f^2sG6^a2_aC6bD zj9)Torfg5n5pO7{jTrsbGj(RW37*>ot;Hb{?kajNYnml|>oPw8gm^;%c7N4MDWrzRgvvYb zEq&X38gNi%==3jT((Jo)lU(GjN&&CXX5UptNPt8zx`Sr0oRx60EJcHx2Y+%5lQY+B zI?H~DYRZy9H|((s$EG0Ud6Nv%#}D=>n+|az>-z#=^Fpi{b%qcfF{-T!p@zqzAj*^^ zn}6vi`cKB@e|TPXolL!U`(AFepGNtC3MYEON@FY9o)06Dgs#|x*L0aUw(1Er?}$Wx z=MUMNZ90lAbe(XNp-HxZ#WkGjbYK?P`YZ1*PHdMFw9O#Omw5h-;mx1Dn5m^rHDrD6 z406{I;dTlaUE%%rn)dxH+7)sboIW(&u*4;!8E_`uknGx-nbW3Ig)+qjVt2)p)*2mN z=f2s$ab4Q|X43D4j>d)UhE<&v_&`xC_|R>331+grsLz-aZ{a$JxlA1D0*f@m*`I7p zw;ZAbu+Sfy`q3^^62DeK*bKJYu?dKv|CR-TTZ3vdjfGVt8j}GXVVUItk*AQrnQzPn z@Ak@y@|8CvqHhGm^iz-anqkI5SEa-NH*_(SkhIg#u%OR@DwbiU`L_R;O|L1Ta-VzN zXW%^v3pcuyF1AS(;Jw(3Y|vjLJ}vRrzTmaCz;og@Vm3iu3zpI0MuLr~&&~3dQyFI| zCtMSI^-CIz_M)y)$pB!ovr{sidKb7!-*65mE`nvwYCMjAR-H|HTpa{+Mpm>6fM2m9 zd{cAa^erdpcg3gfz7;9L2mg^W?Z(VZbUQo`wRa0Lgu6yRQgwCCgt$sU(xNOSHE-FUHZC7*Ui?Qj(=+s4~76$}r*dW`)3- z>edX6PUMvYMtF`fO!g!o9ur~a45f~g(IzIEB6LPpL7UpYO7z<(etUm1?#MKYy6tNR z=?i0N%WZ;V&f00^aN)nI0mO6&9a1Z~{irw4aY8A?&WFu{S(^BoQbvT=zU~`OR3jKM zXd4Tt@;E_zUBrsIu{OwMA_{vs1FG=GjIm-*kz)ug#$wy0IqcZ4$Y}WPl6m`%9jgmj+S5^}2ESZiFzwpNJ@R#~1KW z8%%$GFS{#oCZhj`dChi*HE0>nmZ+q-TZxRLbd+lB{Z21ojokLjKL=h#XtWPnTijiB z{^LRN5)#g?E|Ct^9|bd>22AC4T|&!(bhCTHH4^t1P#R6XWrYz*150<;+TsMq!;xw7 zWA^Y?!lGuV0+#z9+_M;vbb3?3A_Z9OhjLWQrNJvZ0{0(qn+RKzn`C#Ee`brccxboUT7qu%5Nd%!>ctU zs%4VOt}5;;dzEL08bGBQ*w9z2<025Vf|;Im&7|b~vi`KS2^W){0?|4)={my8NM)I= z3p1(@igUjuH9zilT^jl*?7HF@p&r8g+>RsIhjUmpbKmUQlkY(L$KcsUJoi(iuB7vklKTGLlF zzv9mEQ?8-6p~U(-{I0KAYBAnCRAsjB;i|78D$3wxBMV&d) zuyrZx?Q$h-307Tf0pWdQA*6O>pgIeM>GG%IgNP7zAQe&7Nu67w^b596jX}ZXbu{j1 zdmIO~;sU?aR9Ccb2=leD+?pcbuYx<+Y-CkELKp*X@TI-))$Z`bTSX5x=lWmhSx(#c z?jCfm0Mq&d*BQ2Ss20(o>P+-7Xo_Ozs6#SG###USm~n37ZUHLOYZpB<-$jys5E)Y`@h=X&9imq$Fcw~cBP;MgB6^Y@h{7e&b^0j`!4^ZnNrqH zxy2t!DF5NXECkz&Jk{1+CUBP`WLg_cVL}aprQv&W^UT!%(F1Ps^cZK1YE$aS{)Zj@ z`nYHnZc}{Ww#I(9jyipcC~#qa1U=ytE0N{`O+Vur9xKT(8GKc&+q-HZdAH&%m_xnT z(9OOHB6vA?24lD+PIdYdiFdG)ys29D0Ku!!>F+Q9=q3=$^uB+b_OHUd*CJGY!5D

z1fxU&<(A5(Uj8|w`VMr}O%X1t$Z#O5(6{?j!Rd9>|Ec#__h`5?oukM<6Mhy1d;=GxW} zcg)F3aW-GaKixA5Y4s%98%m~cKoDwT<^evL>5cD=r_UQp%_k^K?}DF{5!_vmUo;Sc zNQ#84BWxhwdt9^^^Q)~v2FtZizMT_$aO$g>*?56NP;Q&SX04gsRZ_5DBqENjXqrYE z?3p@kpuU(`j_#Lo{BgUjyUhQif|WH!5df>wquXZ-?;PYM2}ulSQyLN9GDNF^;{XMeOIMZ`me+ch!fBiH84c4JcOZslDcxt0%GpFQTWKYXOqtsHIKl z%=#Yqrr5}2+ofbaDhWqSdf7G)Z;+cSo^J<#WiqF&4KjA##md{H>Ms+}xlvArBjQp$ z9)soz^q@nhp*ET;=RVVc2^r$lu)ppWgD80e9TnGF->GoeB^SBT6Pp?2SiwTNlF}IQ zkhz^>D-hu6xtg(1E5iw8ablR?vG~#7iv_ue?mfaNHVj#4X^e$DfJ~6aM2@6@7xyCc zayW7xY{^`V&T<2=Bf$Ay66mQgw6o6~)@>e}tKiv|Em=kpSXx0nk23}e;vqUrV6r;?%`9m(P8TqL*&NsQpGmYW+pYbWstMwj{(EE5-%gr zEX=h>CswdozM}GT=v2mDgmU-CV$+h>M8&XBYF!r~^D&XFX2Fe*6%`10zJA;O?5!~Z zeLTBQA3qOTI#`YWgOoSl-~Hdar+;HV zgHE(-|BqkZb&1_|t{}|)SQ0RLqvnvf*?r&5IGTn<`_$yZdflL|W{_CZN-7IEmZBw9 z`V?fpWvlC?Z} z3JKDlWZVZWzR{zF(}gEm&$W-t*+SQrw6r)d;=Sb%Mv`!hJUPl?F@$lMQ zi}1=tD>HZ#W(UU2Kzj{nen?YfWZJ38ya=JdXg`>cpzx5sw9IEoN7t(^#j&yt`0b36 zvhBow(2Ug#jT*y;SU|rB@k;)D%0OHiEvVdiR=GU`t_Ql1ziX_OY43Af45gNYkf^c6 zhMsX3p20^%?JeB;{2$#+gJ+(Y&>B0lB7&9Ii;Elj;{5RwKaCy;zZ?&6=(kcvoQ_Vo zb~An{57Z&gBO1ZStd=KvFGwU-fsKZ^$rPG#2Q$kodc($#Z z+agHdw;B7uX=SstRO~V^>417>eagIyvmq8CJgPh3HhV7V4W($)c`*aD;+PA>h9r{v zWt)8vWOqqi$ao6Ty@4pOL1KGS;^Y>u)-waaq?`CzY^q3N9fTmJ+!B{b6SFbZu^@7QxYBkA!=uK%resZnaI0!vt@-i`e=J)KPHYlj@M4& z{IA##N=7(%0+hl>OnPp%`Y~~j~7c7kTCb%4BKer0q>6YGlCRevI>O^&_NGPsW~=nDjctp@mjxd8tKsPEc`#Eij)vT4%B%+; zPf(ENd65gzB|fQn69F~lCB49Stf4NLnQ!*dp}TD-*!jcs&OmsRs}q7TCM=j1!Fv^r zWuuUt?re~vU zvth8mQt?4$I+>dcktjk>!=k^(Q>ZLG^eSy!Dqd+a8n`B~$QA2zE?I?L@_wN<54+7G z6j7g4`Dc&a8@Mk3_kG;%D2I>I@c z)@Zg!2vT*l{QXM7>O{RZ_ma^g_MBZ6=_ts&ewX5zA13v`0?S6oN$;nH#rZfaHggBG zRlglYl{b4_fT)~%C6Nhbb%khGRbuSgC0GTkgkdv~eBGSL_;TzG^;|ZXryEF2wd>5R z6l9qUw|wV4Po~v7M}89-$~DO?Tl*}5W~`hG0mOwWm!cP z!r!;yu)B_V0O_QRi9(HtU(ALwTfn2Qhr+X5RHJxmuyAEoiT2fLC4HU$5Bu~NX+bzF zc1Idjj-SdHFOHXTs&#ZFWsH=2ToSQ3j?UL;qZy-$40FUe0*hJ?Ve12c;roprP zcZuiq&U6w65%l8yP+6*XG>sxP#oP;&GFOSbhdCqqG}De!!BJpTgqx{vqQDMZ+wA2s zVDz=e!>gcgPm3B9>lt`dmcQBPn4O8o$+eU+;+c$DSDT7!)8hxR@iupU+z9kg*o8TM zymT#Q6(X3afKs{X-c#t|tV5fg3;$ZI=xt6bQH zSs~KC2R9T_erF9fUS*hDpHErt*qT+8n#QRplvqERqQ$eY-IXoow1&CeXE8d@cj){1 zuJWO?*&=hb*bA+`B;i0))?(7+u?N~MM{EEv+FHtoh3JsNvpl9}RGOD2?c0oG{o%~P3@^)|=`525KQur}R#u59Kt@uS( zfa7GmHJ$uoEBHeIdOc^eLPOI5nbDqD$e8tT|4S3d|BeFH48L%=?=yh_W@6nxX>^3w zI;@B=cMTum;remvP5iL1Y-+ox(g57lIu|cV#Dyd^%*u(5&@tbXz^tI&(SRP+&gxqX zAarE1cm&R#(ot5wEHF*Qp)c*;E+cIryBCLOFbiU7Ie7e_(f@<8rTWU<;?7UMU5jEV zOm{}albs-}*rW#s#n!bw-}7--=zD^F@kIIp-Fh95?&Kws#gqHs+P5q?6>er5?zq!U zIxH|QvDmmLTGW%hq87nI^l?mx-4}YU8T}1hcgq8D5hH(X``jmHx&cUKw3P%3W$}F? zSbR(g_`2^2Os1#;|t=l|N2m+zV^Z9ZkeeY}KS^e0`80+g+)_lrXG$C6QU=GDZvl*I)#d znZMZRC&XVQ^qT!jfhH!aDNUtgYej>Bv(O8`*k10s zPlqQ2BiDUV8nnJGZ1=}vWHBa0GQae_5XyJL9xKPayw4g3WHU-^*!DHT$_~MfPK*gS zQAqaY1fc_t<5ysnNuT<^3-vaC+q9Dr{YLQ}QX#rNAS?^9ei_A4Vx@z|p))3il|x|K z0H15!VHfHpjNKMO9W29`Z4wTWTn2Zmw?r&$m+aJGri6~*feq^yJzR8xG$3?gM{XXD zA=7$J65NaiaEa&>$t5b*7;B+SBsgiIQyA%#@L`5#?KotFgUrhoJZyX@?luj68X-uG ze(yVQpSrXsEE%nsw?lL4;F#-!_AV|VxQ(8)4&YTu?S>=pGf85O4;JODeDnZwr9K`IZ)(-3U-CY1Yie2GLF*yUBib@9YqYP_qdOgR2B^+Y^lEpqVNw*=Yq{HgTel z62o?FzlEl&{A2Q#jeL=@L9zE1?09tBYpB(s3JihA5Q<%LLt@^J2%fzo;+5GCfrA{~ zqE^f^E@}BAb6+*S42|PY#EZ!gofwI}FRvswFm~K-^cu*qO@Q||NC`I?S$>eNHO-{S zC{~iZ{v(3MTsUXSb#ZnkOHUIn9A3i8R=szP{`sqBHpV$Px-R>)-b`_0|38^Ng0#+yoxi$N)EAstNd3M8g~r9Lww) zheU5ejXeZ9f3<0AVFbUMK=CAFB}U8F=%sS~A~kr>kAjOJNRBfl%a3+gVrdi6Amo#R zjIEGCL{RiQMzs(}jvjtmH|wvBZ3L6REVJT;GNS?e^@`-P>@b^_W zHW%u|gWeVM8I5bd5)dnTXZ=chGJ=9n)}jx+9#=tIUomwSxG+8%>V(e0cf)N_vP;c& znJ*t)gr(1 zPQbVa1dUS8({;GWTXfG;v`ie4>e4>W2n*xq*zxC4n$!1V?X8bpLZ{awBO&MWg>mT{ zOi7r3|2Dq*8DloeaBy4DbVj)bL|$nrWBuR*McEC^ss>(>T|CK`vVMf&nPSNK91$Hp zPLyHIpMQu-CG*F{5lS&+QkJ8B#zJsjd~k+^1zWojN7_5NwY3m6>4H?6br1?US`c_g zD>b@JU|`q%;$xb`s+4Vya(InM_Q#!YbCu`wMP!3ZKw`1@)zg2crQ^C=PrfDmo21lM z1s}qUxl^3~_-@kyc{C5#LMCOYpksew5bJXQV;Z{#$s!qCR*i1)ET40UB?;UxbtxcE zT-v{42SuB$K+`eKOjO`6so;3btaY;P;6dPN3g>?Ac=STb!OYBXN&v+%G}{>$!{-vTcEY+_HS1s=hq95L%L*zh+XHlmKJUik?oQ(Ly$U{_Xi|}4T*82)PZdN zt#!-Bl6EASBjr{_{qE7+R)nn@llp2ph3I!*TNE8)OC@CkCFYV|7Q#T|oh z88Y9+DJ!2ungV>s2p=%M#OVeogD)8zZIu=6tK0E_nm&!ZtX*IHL!V-pNap7TIy1JD zgK^DgLHyYoxf=9C+&=A^4vk*cXiV0{$MBl5om-oG+={9bY;GY!%}81@W!JmR~PINBnwA#TxYH|Ex<|L_sRtUFe zKlqSWHZI_;`O{4;$HY+9Llbf1dhtrI3<6mgK*7lp2PFDupe$0B^8Nw(e%>TI^_)Dk z`rWpOyd7wFzwF2zJ0P{w+=)ljJWIqCT=epr{PeF2DGRi7qJ!l2YdTS@jZH17M5_LE z)>{l})F~dpJ16tmNx@FYK<`-H99eADq$-x9oH^LgI#h!FB#nJJCW-XPj-5iHo6vto zk}n^qxi2t8i$%cU)ef!*YmFf4781JY zd+d1?NR0!{r8Siwd|}P0-ysX!dWj$>l6v2wB`|e2|AKLZ$f?+CFFPD0K>#`N#rl^T zYF0#aTt;KlD4}6=7;!x96!&(jHB@dbBWgbe={l}j02A*7B?^DU<$f)!sfpC(*Bb6N z=U-c=?kL0#6U-`HYx)$eAg8Is&A9z@OGc*OwIUp#r+ZDd*_YU3;gv-1D;--iQo!08>NX5W|JDllSnn+qXO#%b1uWy+-mpQ+i+=9=zr|NoRq0xF zJ`J&aQ)S%R;n(`J3i-R zqrmK@z_EtIBWVF6$CNUG@STgE^b=%W|tI72Yw$ztn6u)_gwp)OR)4F6_v}vAP^l*bUQM+#U&UI~j zy2$IP|3{;UeB}A%W=EwRYe1A?XBbNXgwJ2o>Wu=@uzglHw;AmV(U%7@ z(y7^NxU=n#?A7v5Yv1kNX5VfM$7<;=#VsDVvsXH~BUt&bwW?0dK~uo4MX@rbj`!cG zFVc;}xR(vU@>V&_%%v@f*Sha7?XaXP{2#ZJAmoVy7;rq`71etV)Guc zOuujlYj-Qit)ES7t)ij)LX?~xRibU&q$FHl#sE`YXq5%@zw(^FVxRsqW?yZ~ zp;NUB_;e}7=*;~WOe@;OiK2h~x&>J!z!0FC@a>}0ybbqr6dMZ%ZSaaap%TV_wkmqa zGQb%^xliAi*&c(R@~EgGWo^Sfsa>hw`rfu3!Zc!=q)pb^%zDD=ztj84^?Xc0LP>UH zeV->lyY6(cwcUPxdQ|%@*2C)N4_7g(p#Rl&f?Qj>(#Y-*2P^5{YZ5jfZM_HSDz8?> zPH4ZFRazNF%G|P^!}nS|)JHW*kObAH>xEGerx?7pz)4NuHG?YvCMV{^S%7S45HexV zg3hk}WLFHrnI{bSD3(WAh@dorD*YaNR0|oq`aGcA5o_$`GvY1n5yP>dFZ-Kn})@otnM`)4qum(nh-gYZB9&P>2rd|XW?VWc*&4rH+9 zz^7@kFX#i&l*6u!*HO&st1e!Cx3ZkCey{?iYl~PwBOM~2nWWGDoli8wK+zv^wH+g2t zZ@X8#>WTCmeY6D!;6s5wRqY#A>; zxnVC0*?!eTN`or9bRI#mzyk&C^Z8Es5>AO!Un0OM{?bT8t+-;?V5Uo2`w z_zzM0BFHWv&v#SIbt*O$CFJ_SGxhZXEazCH_BBDz1NLs(! zdcPaAZr=TqW3xX;(yMYmL7o5g-!wQ<*g${It7F%l;??KhdBaDXyuuCcNi~2_bhe1x z4c#|>9aP{LK?RhcLLnt^LAa6^c{#O|{Tj$L^l$&%whP|$g4JV&Shd{=EIdn!1BO5e zihQ5N_$=-h`GJTSA}V|#jS2MpncnY$9B$3dGa_!CY(3d5GIx;hKPF&Iv7uD!aBFKM zv+Y##wz$CDo%ALx>BlNAK$!FMLWv8WG2BE`aRfzd(5RYFIivA>x+r94of8mDJG<3*o|^C{$z2^t z+)FFy4k`yh3iia_D|?v{lsRFMSc*!Ve?}eZcaleyi_a8#%@};|gT7=|4V7e9c_|+H z^ai2{V22RNK1$I*N*f6)#wHru*mO4PY^%w6Em@BhSN0vXK&ta&a{OymAd#0e?s|@@ z1!D)z?%kNRzMng7Uk~>@Evyl|qiK2F?ls&~|AZy++A(^I_7^+;c~;wT8BF@N!(ez5 zmH*nF?|WZmq)2yl(YmeVX49`=c#E;P1w$gEXN}?=KH>RocWV3X?(@cf>u+k0&(6~_ zi7-Xa7!_JR_>*5zDhM#UD5<2$_cmhP0ssoSX78La~p!Y=6FDvvho9E)xl~{?&dI z!gHuy79c)9mJ>yh`9hG|2Dr~U6SQJ%fh1vIy8E+A&<*wDmMGx)Ne$w=x@h_O+Sh{F z+yRQ;?=`AUh*w(sz!Xfys2=;08JXWt(;fIOc%ymkQlRB(GD)fso3`^Q)V-KvoBZ5F zwtJiA=RwOvt5+x_MAoSy)eu}Dt!xbatRV$}FpewB*B8RRJm@S)r6{Cx!&{2<Z9NyQ zit7*=E(y5-Q0Mk1i_^2R|8r^f)yLK{$M;*7Vr|Hl;{2Ea}T%U6{H;%4#<|o)6#HKQCPuoyd z9Bmhe&SPK;cf#J`SktMLnw?l&R{iMw`Ud_*Xh0n)ch(t(5muj>rrwR244@Y7M*)c7 zUOt_~nap@FxQm(E=pVvFJ+YMXv&a|P*X1CdbOjv$@?$O$V$|-}bOOn$jx2Qm>*gS8 zs^=AF>$Vo4I=8E^K0hIHi}XPE)NB&~T2a(mnA|Q{6bsMjgF5j9M=Vcu$s4(hV@@oB z4@-iz4~{jNm^HXm0MaE}PMTRJK1y7}KmkD7pOk0w`4byj>s*b^OFD8mq6PAyfHYBbvxcYbZY6U9U!JS+-$V+XMsfW zV>9m+%dRq12muuB{7`}P)UvCoKgi&Z_5)DI6f;%k?TteE#p$bH(`IHV7K@8gc-hhp z@*nGWa|1!OVr6LX@LTQhgx|t;&Z@8V<+bV@3h(yHFR!$ef%{U>_+2CXTzIOXy|y+l z#G5V~N<=a7;uvJhtj!9Q@QNGU&8R@cNixr7CF~&T8Yg0JOWA~2#V=x%GPv7^+5-Q} zLFZ?`u0_XdCa_LJFvm01j*fag9tAxk1H&$7$Av3ZZ>$HIE~Zt9h`%;R;uL*%aJem` z=mWt)7o)sn7!&%|Yvj|cJT}1VUyu-o_G-PDQx0lUBNo5<&U|Ok=|uy1JH_G5q%{Ac zXm-bv#{-f6_*TXSUFzFC3Ijun5;peYu&Iq8d5<&yA4}KZ*Jt~-vu)d2wr$(CU2ECP zeY5SBx!kgsYc0E$mwWF$zxOZbQ{C5f9p`Z#$orxyq5a9Mgz`N>lp;lVViwG!G@4z! zh(J2x^9UPSzXg0+2a_X~Vbj&4B%7%Vf--aDZ0r}^iO3pqO^kf6VmSqkU}PsA)tte^ z7UzFMJ~SdGdL+TM9`N%RUWv3uct#G*~(lsCG~tNR@&%Mn62vs zpKvo-5f7K3myKUM=kM#^-qt^T+YHZ{kC`nble))C!GI{QIjY3EC&wL}7JrxSvuEbX zSk!5MFj-lEWYi|2d1glH;Bs9#&ln(?DgoW=lg4b>&He26$W&`4k*MeaeGIOkN&Ok= zlf$q5Z8AcwU%dP;GGx?%g*d`0yv75LIrJSgN%@Na1zQqnLWsTYk#hZN#n{QDJ@)|# z*uf}B&H}FyuYfes)Sz?)>}5U_40>{jf}#8m6ftaxc=XrWZ(hO^Apx1$SV$PyxO0|P z3YsgSs7qdzc#>>w9Mi*`T&spe%6oE-hJXU7Os&}IjJYWKx#}Puuuyyw`SJNI@9!5M zJG&Tn!?b_WIB{@I4lKm1qE)AG+eq`R7(+1sbnXgoD<#-_=%yg*aQS|&=6%TI?4$@) zrONf<1|bHRSy{SB+J=@eQx|6uA&j&>z`Nm( zT6iz|pKc9Zi3(z)Zgv!kSu@s8k=+OQ>MR9rg?5riNuD^Oik_P zTUTtH&TN}=g8%sx{24?Yv`l0EH&wK8#}5qa2PCOosJ6>ZSZAx+(N)?-5A5|J#O6|A zk|QIVEz6w9X9$I_%i4p}UbU8kx6`_m*gNC;TVP(p?L1)k%ot!q8HQ=M;sUZ`UK+x$} z4hH)y^yf$ipX?&tfl>N@n2cUkNgc}b9pn_gb+x7P{kVNgE)@ibI?f8Zj>!ME7+~@0 zYPYdA(>5VWat-`D|FBS+ClAY}%;!=BbL_}Aq{VFRxB zi)`B8e1JUpTJS+hQ`s###ESAA5Q?gK`@*JexD1>nItJ~TuK^nk`f7=0dYkDixqE1C z?%!toh-1XQkAW!&)<0+kXj}&1#-KQ*$T=E^Wx~4X@5u;#r!{OG0z^n{+*e(kgEvpN zHwdf{>F3)e3EkLt<27b2e-?A?3vJD@QOczjDW^34dDEo6U#oK~x^#X}KDm;!6vcbI)4a3OvFAga_#fFmF z!-P$h;^m6q*BkCH!+Ks0NnmM?2+DeU5o~|nwI%u2;Mw zhLZtF(X!y##g&FWd6F^hR0Gu#8m{ypWJYhf#~<74^qtuAbcozBFRW>(T+No-jWJFA z7(Ahk4fWwzm2*f3k)HzhTs|s%dage?7oWt)GYHzT{xaH92purJictv&Mfmi2Z4!l; zpGxto$;Z7(1ts+W>HAew#O*MoRXb??{W41UZ|KKW8?ej^`aDVN@^boDrMedGj%O3d z4e7h2Hk+QtA1N!onu=C8Z9~?Rvm&X*RIo~NIE81r!0J$NpDHAFobaHkr%eeNWl@IT zqXIce>W`haCO0H35|@igLCB?1o#305al@p>1VbzsP6Ue?5X#2UNjP!SyCCXc*m{(- z{v~TnYEEHrr?gah1U+=@i)N?qNvaCt_nz7W?Z&=!SGm8n>X>{?)u1Cqh)u`XOc*LP ztTVW~U1g?;4rnX%-yYv#o2jO~)5-+9uL0>9pax(xm#VViJl|6T+no&cH2RjxN)KmQ z4^i8J+96*u%=cxk5Gwn|sAb6(Ixj2OM=^rE*Ze2)c)mpgEY58ZH9r*v$Xm)@ehwGg zI#x2r9F>kv^EgRonks@h#^auJ(kQw}VUP)IMRq;fj=!M}*DfE5Q4Oa6$LnerZ2NrKtL;VBp7H8uqR9UsSw7sVP;Ba66o~Kvj)A&w+gi|XBrM63j7yJAyyin* zu0L4@NO|aMqFb*}_r*B1)>fdw zapE#$xM?IR^-@4-@@3Jjbi%dgJ(>HSgX9odjV}*uEaZ!_OIPJ4{yna#(?ol`peiGO!y{G+1uV1WGG#GAMKTQ}h72%jo)=6qBJ6gs11N8S7)GJx zEL@KJ>N{zok>kUT`y1c?^p^KgK%}M_(M-|T_+*vGQkf8KpZas2FGaqQKU5cO;{Exc z5c~Yw%5zh{7q5=l=y8T89SG8yKQYHvs0nMO3Fie@X+=aaX|D(u*v~LiBbZGjC4)RF zuk!I#(e5EZesPVsmk_KF1-BCs7^Wzawuwx@Lk0@n>bMUc;70BU6rzvE9KzCP3QDw( zd5Mt1Rg|B!tPB@Akzio0xM9&UK^?e!XyDc`-cu*C#;`&sraixk_@phYQM##H_2bw0 zHm&Vj8k`s|O^){vI3ucVrK?X`tlgI9!jHhz_;gc0E1>KC|v>FoK# zdTv`RIZ;cG0HIz|rMmc=0Qs7o_d7KrF>nT!dtwvu!VujC9U!jU4CBk8Gs zOL1M0gTO)T3evCprr$v-t=7lmFdqDYNZSe~&nb#=96aOSUvDPAb;2kxxpZG}?+BV* zZD-wHa*O{{z{i%iJ?A(EzV(=#VKlUjbHu2^cUbGCPqkgIdlShjt^S)#EXThYtfM#7 zNRBe$KP89_DyP!2yZTw!+EYz=1cXe#>atLyv8Qz)L1EcV8>hRD9~gL_hp9X1xpW}#2~_-+EbyX&xqjq>r_@>r_hbB z53xjCl8o#CF0Ywc4n{~5%5osL?nVDK4?4XA`REwFmT{RMP=8f*lLkzGc78`CeatmX z{=LrbGx$%XM~rIdr4qb@-LEuC4pB+hK}g)@HYLws1Bt9)IQHZX zG+v?QDMJ?HFxQ^m`5zjCVTd*W$E&Mxk+c?z8Bt{#BK(y)el)CN2dLGKf>4Pg;PDi*H)B^Rw@oeH?a zS?(h@V=w{H1UL402za zrZIBH5HoUK<+-*rwMNjz88lk32)|f81~ypJ5j+5)ASLsKr`wy9Z@08zGmamxRa;qm z(P5;yWGa?QZYA)L&rQ!q%HcU@04YBmDXq&F9-lC#f`x*X$cZkc>pyZ9HOIIau%i^| zt48oQ?IqZ`!32sCQfixA6%Pf|Cwk|!W^RHhnv$ZZUBFV7bNP0)3?xJd;WY)zkFLIP0#4N8O65L}0Nrq;WZHH(n z_L@baOR-rtf)$kK1~2VvE{$h%sz|9v%<5yA=P32&Z*8{noI6PxG`Ud=W`a;)-;&q` z!aI|(A?y1z5I*njuHUa1E8TDW`FypLYU7zFaF0SrK%PcByZM>cnKZY?4ax#kF0t8w z(t$!}<_-Px}G?Kwj&Zfl9O*Ct|n=&&~7aHD9 z7L|(QRv)XfJxXr|YZSojl8Y?MjoJUYNpVC~uED$uSFRg&i2Tne1xV3=BrK$SfSxaE zp0Aox6%JhS(MpFEYQ3?;D?2GP7Jcw^uA-yKigDS1AZ<1k(q#yC!^u~)%>f#ZSl};` zKKDP%V=7efg)rhv{OtE&%h&vEl}6F(Q_bf1Yl%)OrnpY8@brCUr>un7Zbu=H77K9S z?ZA-4+OnJ@ucVWgw&26F+jKfxBMERrrQVvKvqCLs=Ejt(%{P#?*l5q>es?{8*@y7K z9g`%gwgIteC8<2*A-1LpH^39bHbB^6L4#I@d?oFIUbeA^4sM6i0Lz;S<*EeD6-{fP zN&zZ-RDcQ9e*dGHd2HJHATlIA8wqCzW^tI85{1?HXo6U__)m9EOQ>pYAwwOOc`*W| ztJnb!%$uEuNIHcG(`gSb_H9CHhJ%4fw^|L~Chml+Nlf{WHRl?O0R~Qqr>QTa?|)}E z?>9($TCL&$9N~#L8mv+%JK-J+ZdYBmC@IQmHy z9qVBBzjv0gPe)QJZk$iVD+Z|{&+xu|-da&}2 z)mg*jr!Ml12|STFeoUl2n6h2r!UHT*a!fGv=7GOes|FghrO~3xs#zoTM)HDx zEi6GLjt|}a9fmPpuFt4KsQHDXv~*!xpzVn1rh*0umN z@@>Vm)FP!F?I+IMTFO!&W>Os zt5$gVu#_!zhsW@7+|#PgkGn=6=ZPZkL0=9NU`hB&77$caACIz0$u9do0_kP>b4Je3 znTYAdHaCaeXGV$X-2G&A+O8`W+km#x=bZ7r9}g=noQ4J-+zp)g#rp)MQ@X{pgz(%F zG5!8**ul1C@ul(M{R`Y+?TT%*;%H7QJVTFv$76}MBmwrAJ@wT;TatF%B69L8BMAmU zFc!5zs#rpqG-iQ%0|iX=n6K(S^}~cTiS`jQzLmzaE0%P8gb=7$jmx%eXd1Z|A!Bkb z#YL^!Qey(3XxM+G37qu;A~<+nD}fwxOhNeJ$9~pj*>qeVh*qjNF?P4v`6@BF6udj1#B; zQ%q63@;#P=ay#a1`&vV!qO%jb^OGBDC%20mbIaC*bgUv>S*MK9BM!7{wd?c9o;=%^j!i@EUYl_J4A#+5DUs_GP= zlNwmJ(#nGymvqw%@J0;B#c*arsoTZ_ghtlxjsGVCMHj@Up+^;T@4Dj_caQzixt!}y zo9BCL=9F6DS`6GvO;d_-Y*qbXXOfk8f{3jio#Fxz-Uol9Y%BE9Wb6QOX%05Uk5GAu!|6=xnb>M z_mnE?U7MaTS^Zi>V5x(RuiHa~dYgWjs?Gd#KB|Cp7H{3NhP0xi+RwrZK8qM<6Hv_e z?jpIE%Y;{aNY_}gNc@6?>=x9o$l2f>6$T#F^w7J{;l*S>@N@CLaLs;tid%qqbGb?b zPU&>W^}w7s2gA%xA@47az30&?TE{DcU=DL-dl$b(QhQoa-)5J)6D}G*?g1rXbS@dc z=Wqa&07o*^8^X|Yv~T{(xTXw}nQ0XHri=K`MHvDU^db%N=`W|8 z9{5ZJLvJPiJswxK9^Ph$4G%S~xPM|wNOpv*YQYv(>ZybCw0X269Tav(2p;$c4aVDS zTb`nEIb~MiW2Ezc%S4LJMP2Z5n_FyImS=4vkWz{zc}GC$9|Xp9VWS%=h3do)DDuOl zMYej5M)a-m8@b_0dWOv+%i^FJnkYF1{<RGZh`k^Cy^9GoU@a3`E+R$!m6dFTs@SFc_mBm0cJ(f}ZNOZq)X<(D+CUu8{X`|- zdJx78?5ygG_0e05Gu5Rdwh5>lTLw$0LF$bSO@_#%y;8;MB#xCK$~zK{Dd~EGs7y?Y z)rQF*W$kZehRG%d1{z}j1;n`7&~f%;;7|N>?x)(&xs#-MR4-488vzG5!mPE~@m$VC zwv%KyOu=I31c6?#e$Km}1mg?YI{av32B%UAVp_1xNvg>#?KSpL8g>y9+=|vZjgq8% z0qV_3N>eGF*E{&!wR?3(Tn%QWe}_Aptxw7Oq3MYukmg7qFP&VOgS9Rml)f#&btTA1 zyFg}0T2X84{-W>nw}{fC`(g1h61?6)p_h1P>@X$JK*kRZI^QE18a5H5b9;qzkNyY{ zYt8RMt8fh6W7`Aab}1Ah5pmI@?E?xCk*%&|Ds%2>xbSM9D&dLg_+8sOr@{$fp^PF7 zFso8vCugbDR$;cK#l50Cc%3oNX~q1ne4)1q;HZQBt6}xfLdwjenRio)np{hA2gS6z z(=#j$@OJiTtp4R~{?h)>)|M1QdqwpFkf|^H_VWP%zN_*BXujz!dE`ip1|!l6 zjTMctk$MR1O4yuk^rlujhV9JuU%cSp`fX%Wb z@ugPqyIYX(>`YRShg^w?)sZuXFOsm&V~J-qJM?fXYlO&tyO2r#USjXMgR~6RYEw+| zjex4A)9824S&tGcMQfQCpNm6$05C~wW>MGSb zie|=%aU^BQZ|I7YKm4+#r>Ujocn>d;P^Na+X|#~8I_vo%Z27e!j#+=xpSls~eqsTp z4-{G&>@HO)i62R<@|tlSSSjS`t=c)IRyebeh_vng_Ev9uK0;S2Ti3}!Zxy6H2RVOr z93u05lT7YMp`O18*H|;BV-fMXiXtSwkxIs10iw{)8*o)jm^Ti#v(Oen%(nyD=9kY& zdzQL6M39(y>XG?e?VLCiK1|BEApg%c@GIU9zOVDRJV`(I$!SG?zDem_C+r00S9>$m-UE9UNdn z1li756Qme^7w_Fw%K>;rxDeu-ECU(Dk(qO=DYq$NM5Uuh8)QYDPGF;%!wq#&5}c(F z%E+`6>L$^$DXiE? zFDl*9w;fvbsf`3v*X`R;4VAO}0KL?C;fx0%zhri9YQ3$3T9XTFIIf!r8Ff!F4zdr4yM9bcgXdK3a%E>UNEbjpeGm1k>3N0oC+B-n>}Jy#~KQg=_Qm z{&UTLyt$#^01f@`z{hY$O3T0ec~`Zq`KHryoJUOj@TbsJHs%Vz^8rU~R;;0354&Pm z2`1>p<=&b8@AKp*YZ3Yu+#&l@w`k7N7ltisjya}@=qD)Ds^WmVuN=K$}EhgPW@h2|s_oC)uI)rs2 z^-H3z6r#WF+!-n&cVlmVw?5K7)iWEBS*@{0vD@6#`Dk%2t*}0Z4R;_)P)H8K^IQ(1 zA?7@jeWHa97qb!!4{l`=?AzWN|Jv2@XAw#XYS=0K?Xx{cvqzqC`@Ai;WerK>ruw`N z@}hnB!-Yeee)H|Edb~?VG&q+e`PhlFw?4)8@#Z^@HO?I3(=gc(Btr?>w>UV7IKPrA zq3WeaE=yIocHbrI?u~3C+qQ0BF9KBgIa`cmIImg1njZ?dRrbR8Z^1apF|JcdIH=+`8xBYxu zk}s0q8yewYAdU5&R)?+;C;xQcD+7~%S>NrY8|ul#x11E_vF!br z96-5?r;;($cM<5ND`@nu((KY?FBc4U+>iHL7%(8rGSQ$2jl0WAY0I6ZMt{)i`EeRE zmsG;y!bdK-cbY=b!IR3c0DaP|Y+C#j$y`TlW)yqyC3RjiaW5bkw@_?OpNldgmQ7vU zzhe-MBhl;%>>QCBFb6?8#@>%SA9s{4@5jsl)Bfpo>B}q0b)lRe;ev8)Lt{;wQsPL9 zgS4d#S&8OSRcY7S->AFc+7rkLs$8=zUEV^=h78>)^z@Xo+{^7X2^M%-KyjrFun0m)bmBT%V5KE3ji?C$gl{l zsioif!3-s=P!STopTdf47y}&3HQ)UeX1!#<gticwObe*JHUi+6aP11~c8(aXRMrVIUX?-Aq z>+%SQ`EB_}Q&ho%%-%N%oQGRBOK7N%bCFd5$!u$MOE8L{=+;E0-`TQC*&53BJLf<%j66ul$6|+&Yxt^Xgtt4Vnle@_USD{h8hA^wI$my; zpUy`n2u?%aBr6M8(SaSG&ziKXRn{CPeF0PROggkFGC-fI#kHEXZx`DWs&%hPpyQZGD~S^~@fSwh(M%TtRlxomceGDcM%aNVc~dN#vvQ1Wmyv!vs^h=|u7#$?;=R@&4%MeI{YCXN|2wNB74g-3KY zK!O$fTrPG{IDhPUhWtEHsq;Mubr&^MyGCxn!dcwLRFdHSb97z84|Q-7TN5C-EslGo zeI}2nmPIWn=j-RjWmr^A$7Me+++N{<)iLxfQwLy;4(`G<~%qiGJK-B4r^4FrZ?q0195A;JxTw}F5~b-imPI>DK$xF@pwaZy16X0;m;ya ztZ6P2Zlju^x*DIWtGv}OUC2?UA6NYt@cafR{}5J9(Cc-<+l5H|GLn$l6`npbsNKVw z;0G5ew5+`YP__B?;ykk@EgM+0&%@|uPsWf?T4cZowL)e!B=~toOb?m`I7n2bOKzTe z;{U|Sa~tAH=jaVhiWm_WL;hdCzS92=UoTRd z2aWDkPZ$!spz#i(6g#6Q^W&gkFzfz7-WF5}YVM2aIi^p%nBulRl1)=*n;1HsZY!-G zEAYKYSa+b>fGBGooyqNc>xDIOy;yR2UuACp{XzDym(0vo)#v<45kIDNFd0ET&S}Vw z`SM%o0I$3;96TcZn-j|=yfsd79WsGRV3Zr=(APf+LWQ^oqw(%m_KMDeh0+$37w+2f z3uYp=ZD;W`Ut~aoy902&;HH0ZQq~mA1&vlw;8tnBP%Wx?Q{rMKh8INMdN||~W~0=@ zl7}&E>FyH%n0qOynVkLKl{HF@od#xL7hpvkEw;e)^(d5j?P85f!sUIqZVCGdxUBa7 zT~_Wq{AsrZxriFHL*@;UhmH)IaP?vnBl#uN{3b*Cot*Ib_4*x#MNMd!ZC+tBvE8hz z1~Bp17)rsm9WX3UUWp@FU06eymJ8I88=)Ia(Y=%gbaIx~h;~i(WKNqQlNgx%aF|-b z2-~b~c}8XrRCp|TC35klmp!Sd=6Z?3sbu4RHw)iDKgbi3b|Ifw%a))5zktOM#KgnrLW z$l+1mOmqY_XBkd0I)&0t?efbueFgp++Rb+ebDA#3z)P9Rby7XH23l2APyb5x@i|%M z0KD5$B}S7KnhHt5U-l}T1Ah;RefNGR+s1)vyM~s58o2g1u~5{x2W1r~MaDsJ&gh73|Lm%f?Blq87DJaepNOg^IF6)XM7 zq!{#P1SlA8r?F7_xuPbYGC2kamnS?znP=~$zieqnrks;pMw;ei4QO|?sk^kEiD7>F zKqjdB5>=JA7O~d%f@&iD9<=-C6^lo)67XlLi7SVYD`d3gdKOqzP`1xFwlMR9qAcuC zXm`G&z=X?$Hd3NQM165K?Kk+f&9T44L1o8DI9-$fd?y5p0wvba&>;Ig{??`xJDdJW zSO}-Y==0yArFmPoS>Y*kUZg<$HMq&52H)rm6}7KeNM7I^l(G8$W|w3gdGg|Y441Sm zy68MK;);w{l3leP#c;(*CgpLX7=>t3W{E4}OZL741mDnh2@2E_!EX5AWnOC3$>FyUwp~ff&4WEV^xCfDFpgVH6;m9!c|bGQQH{%KH8)a?cA2+3~|)1JK;z#I-iQCXEK1^S{@m?UtK29$w)R2hC&?rjf(mXNd7hkO{_4!l@z8<(9*hsUe--E17S75B3!1QU*C4z+wRjwI`NDvC7L^RE+Fa zAI-Yc235mL%fBYwc| zJBG`^M$4H{Tn5Y`Sy<<*TVjaH47T;SCXZt^U?WD)P zlRwzfg|Wf!A@wt0m5;gq5U%awZ`DL_EFMT}#er0yH^$K|zHm$JuS&ZIG@^ivq*6SB zmXE}T1FJh;^%6-+r7mDF_11(>m(gRQg%iKN&4p}7)^L{>+98wrZu?YU-O^){9Gyj{ z!js1OA44pjx`VG<{DI-m4X|1Y2=twyitB0}@0-$h24mz4u9NUh2`u!LcFcT9-g>6O ze6lQau*F(xMwB*uETJWFGRlPUhlBacmyo4sPEM8ZqT}_J-*HCBan;H1FV*7k< z#IjAG^7H~R{5cW~4TvF2VimSt&zWSRGSR7ib0-m!1Vj+#L3d2H(I~675s6#>99GmI zF`*-+@bvm;Jd)H9$2Q;!wn1jlS~Dot0%v&je7F%r^efaacWkmqYyPfs}q z5`l^vPpcw<3{N-`lT<7{D$D=9vLx;CXv)5amJTz4R;G0+Hmb$quE+s|gc&K{%<7o1 zgKKJV(yajL+r?`1q-Y#7n~X4gy<{bwAP3rL_AUXiy7*_+h_T#e?+}HZKu4Ypu$CxQ z?T;E^d1zXOjmuOK#if?v8fR#c2`C9i=LD*hsvUn=PFhm`B^V^veogi74ztWO;fjcl zkpA8k@#$A8O`Dwj+c zmq-Me=Jtgo6`mo98QztOCQE}S()WIe4hgc>rzqh{0P4{Wy!Sh)CysZ!^^x@b^N-u3 zZS-HrC?tl4!d^r@hf5!vN`>&s9gQRJ4xdp;(&c2ErDVVR1xJO3cyYhJ27~>I@~9(t z^nxK8hfNo%vf-a-STv~9Z^p0xhW2T_-@%NYA&?Xw?cuW}TYH1U@103p?>}4JA7ES< z$Wc2DD)IBf;huBXWYNB5i>^Iz^Z7)y6$#*7DAd#|f@B@60dcMt~P)$|e3Oj~=qo99Z12F84!4bOazfSlKa2URH#0jCkzAATxdXEb@F984hfm8!R^rTT+q3sohm9#>@+xfDXI z^)9JIA-*2<7`?Rakwn7)DvSc0(CX1ClRHT;s1i0)zXGvB1HS}hi02iCAIRL5%-O1A zklg!cPN~KcMSz4XJ`^!_p6r%sz3j1MIO2x2zwIWGF&S8m(RDZ;(|RqC5f_~VB6L;xCc&`lDPOvU7;~vEvZcT=Adk=Ek4R@*iYRapp~L% zFqJ5=8(;o`Tb#Eb%Ohub5`tx_YF?E*j*C@H&X6A2fsPZWvnxN?7J)b9FiHTP8!Y37 zS{!rRj`7JW3X5cgGGf$Au2cZlOVNUHl!+qCAmOAj@mh?Zcwl&p&OrrQ^N_K5s&sAA z^-CFByKL3X_HNel`=pcxi?oCi%JO;y&Fd)GTTQp|UIBKLa zLExvcsHXBRP=U2$65Ld}eQZ)*K+4eV$gKa)OP$=EE60g0&4qm|MxCO+8vCSk%5|z1 zenj*L!9$GRKrKp(PLyahB13;|6Z-m0HQ_l3z)XIC1trtOus=fiN*qOB2n6Ha+_32h zpx2_Va7*OI>;6htrH8aAOQvsUO<^XmA=wy|EiL<>(mgM?6wUbKdF8j;D6#$;`Kp|J zkyH2`=jah1NM0;H4Upb+z{nBuUGRe~LQCT`CY|)$M&^->p8bQoEx@bW zsg8I$L;?*K|=T))o9*M2%*D8rg*lY38o zZvDEaFqul+Mb)qExCs2d$oSb3Q#ae3d?O8U;QsQEB*^Hi4Jy++8$}LcHd>67pWj?6IXWa%3BqV}}iat``oV!V4vc)=a2E zzY%GNFNLXN9bT&{n9*Wy5_-_f4g;erMqVznLEc~khq##u0BNfaSDHgXkK0Fq5A7e1 zK$|QWhuXFj8%y=W! zjz0J)>gi(a1)2c1k-g6_on~lB89y@`1Y&fpOG#mKU0NDzYB45pJcH(s=U}_F4W%N@ zZ%Z-Z0U|J2=mnj#z2RJGC{IwBG&a*!^>jt-@S<+)#_8#K<5|}v`+)HdZ@l2aZ?JJk?jd`t1EES$GS;{remK#@8wZ96pdp2kt z)&w5<$^89O9(&HL>B3n|B66yq^_ev?i+j1?Iy>AU6Qex2=&UKcJQ$Zc8DXM7UhH4NgHohT;r#+2D6CsvGwFt=D_camyZlp4?2N|{ z=ay~``2O?Yrh>i&957Dg;WWt}0|xdMM@q?5&?ayV8jeWlgUCY*P;$b8P6~m)fEy42UWCPJVTR%$D=NMJsYYSSi#NB$ zUM+-R!Pi?%<}+zJhPs~+O?P8Bv}oxb7Kmy0pq(|RS1G}7=>`?c43R&aBp-0? z8z-m6Yk(zP^~*lYaG+0&oOyl6#XBHhW;YKbtktO6X@j6V|J%?~rcH_h(=&ZgQ@_O} zut62X(QLFCZ0mCe@46WmT!X$qxVctH2G7yl3<&xbpr(T>E9MVjCcMMT(0oGrQ=t(! zDtHhwO--*=@m3(KMt~ zgoK1wGWWEoG-0)6G3nu5B3KsP1{{r)penR>8(CBm7bZh36GEmF*ET~|<-?)VPz-D> zI;iFL)TLbpxYp~_~SimADmwwT_+kQjW27fc*A;c2$Xr4 zd~6wwwwxSEZG9P%vtEN15rqt21R<9PeUuU>N`CeKZoz*CRAD2GH~P(w@1`(2|ZfgWoUHB5#vCLFb4-4o-c^i}#9w zk@2B;cI}i-19+>FzpQDTEn$OkSu+wxFH;FcIo3{veE76 zEP$70L#UNM>9&RXplhsMX=q979!VR&L5o6?A}uWhxgS_t(BV>eXr;Y&0g0nt{~7mS z7GV>01^pF=-6!QUu$KEa%!U2LiVxGI=5a<+$Ob^DxaSiQHDT#x=d4BM*mtqac)q+H zq^iZj98n5Oh@ZzGbaoDQd#41Q+IiU=djFA9R!)*jT}t?A z|JJ{B=ZGgTs%QHJF2n{V5UKdu5vQ!Z<^(ffOJ(d8%xR<0>0({yTNGb^=2MsYdCUqGkW7x_z_bb-yr&yB29A?S6zx~s=4K1ahv$YzQlEM+BShZrya|oRY%ezT@+xE#w)#OK2tDtv z7EXBriYU*xvuB;q7xq;%n8<;=V#W!P@DQ_bRQh+(qE_|Ro zN8F$C4gB`S2#^IElmf4S5A={hJ6H|u_-hQQk!x?XqN)vLHb#PYt@FH#-_KbBHyQ>{MzbGzW03@5x#r*<>-3OMN*U1@=Jc^K z%tu2E|2Qkep%_%o2-J-h1-=wV-H;lF{gsF&FXn%o)kav?b!0-PyXLxE$BfWK$Vhps zA(^9En-&{qy7L;{^#J`ICbR(?AL)Vkmx&%ayOI|494!jQSc304UpiWqwTG4vWfKn6 zdC%$Wl53?B&ei!eAo5Q*o5K5ofWoG}CkvB+$6-p)bu-!w-#mNWaNg305*1FuAWFaQ zP{ju40uQ}iNP=urBVJ`|$0tJr`>C0qwPEgSNljXEZ^?V>;7rhR+X{Wo3EVuyp^-6FKSjFxJpmL(I$@!!1@QlolRc(kh%i$`Q>E8XQ(A+g9B<}rZF&c~ zXD@pDK5&?Cju z`R|s0NleYfPEw2SDHInJrCi6(Rdiy6AYSk)q~(q5p&NAHmg7y?gTBMZ0nh~RtjW0n zIx6`ERk~vsM;97gKpw*y1g_7#sYs?bhXx}C+L^kCZNo>_a+~mM0oCpgX?f89Xc)fo z700B?y24_%nv@s6c#Oi4;7`Ncqi*xU7spJhv6uLH9H3i+l!dAmv$Us<-w-pRIicS| z3_0oQ%c8vZ%hI`Gxuk`hfWt0EM zI2OzOV>JAq7f{Q0U2D%fL6vB=-V?!o{{G7F#EeA&(Q`}K$~ZMhs}m!z3WUERxl8yJnt5+1BEmg&t;6VkVL6O z1X$AunmsK2eqg@@K52dOokk(KtHP@J9-h%=JF|=d6y)`aGocxS~OhjX) z3I!7J;5IP7=#V#YY`dNP-g99SNru-m;Sj>GXOOtR9FgXH=22_wlE?a3 zX94P?K&fKo>MQ<**be(y7jf6`a^}GE*14@I#?{q~jEGPmA$brUecYra+#vkC^&d{~ zH`vTCAGs4KJ2|)5wYBv5tZeE7Fqz0wP%P>*xr6boD@7`2D=EpG?^2{rg82K8<{0b& zhx7OhI3Z}?%(jevQaemf9sYH&S1Q7KFhqb!DwGLn8*r|k$ADy2(W{z}64EEV?@S0m zwaLzvPD8|HvdatsS|Hj8A~%!tS`n~l5P$7|El`sWFb7s?j2>2>)%~8z1qhG-wWqsY zXMwMG+&!P)&X*?={W*57Be<<^pb`0c^t-)oE{r~3#eiKaHQ@)fOoND34&C?7z&EEv zV&NTouE49O04EO*%}&V1j*jP_nZ5Vefj8Jnc|12KfgdMnXZ5wU%6iXm7{)SWazKO(D(44G;MF8bpC5FT7bLfuVl-~B)Tam=}7qQCi* zN-J#3;*D}4-q=sw7qaPoC?{wn-G?N)Hfji>#bHiIbD$nZDodeGR0gfykTsmEye{wf zHo86APKH69C;=qKYxI4=-miyJCL`X%4g+`^d^szwNf>g{t-ABJoF|@Y1+QZbFGoPD z8+iTrW1O8S3fZUGii+87Khk1>Y~bcsS6SuK(QH4@A}KUDWPOkYlv;FOKFKMOw=E01 z7JTGT`d#VAiZxC`BU1jN|IZ?l@W~+ZB#&fMvV}wa)MhKb+Zf**cH>jo9oJ(z>%ZYi8Bw^?3t5 z%Vf~w*&AD^{E7+~PJykjeHY|ceD`Qe8V(?Yi$*Gus62#Q>}4p?|3>)lQ6@n5Y>iZ^ zVHKHEe4Ng8FPIGHWId57o)}+kJ28kJm+CdatFNzqdq|On)Y^!5*#61mfrM6F8*7#W zNTdJmxC*grQm(aOZD#`5D{e90ZRj>cQ)*i=e!GnAb+sU3lu`6cv$d*4M1#@WDEV7l z3RlcXM|dlI?!BK`+x56TPp*?I0kBqlWPe`{f;gYt@o5W%zCV_>sfOUs>@yRxcpW3q z7SAFzi&>kkUeBk~gZ1##L^~bM5!{a_(4gKYemrBpBeuJEy^&A^IG4VF=iS|~t3R$~A5u`bof6U}UUCmwoCcxWk)9!1b&eQ>r3(5mCH2*JCl zjIpjN9Jv-a|FFHW-huGC`c+1Sw2@EVP-h-Yeg|MqM{-%J2;~?UUp!r&GUkNvj!n46GcYzt&;!Gh39KLq!K)J*NgH}ZA~5uw*Q z`!s)?S7m+#rzDEMuqfw{eG+Uc5asQtm80+t$xgE{}b#&MOGQbbGiqtG5H*{H?A& zJvw^DvjM15`F%JQ)nUuRh+(w~#w;Vl{aa5t{0WUs1uhD}+LbECMS+}-EvE>uMJ4|- zMpGsd8{u_3a2DmJ*AESq!!Lcq^{g1i&<+>k@nfeX8&Z=hc~jCeqk9=haR*~PIr-R0 z)c?EGWA432lJ9hZ9Qb+P|GNak(IJVy`y1locipRDPaB$Rp~xbay|dMsKkL{?KA|%T zXsD+brPekjQdGdY_`oLH{zZQ#*rBA--m~|YGvc4Zu0!Ozwu{b?2KEH80j+$u?6~vT zVNQq_CC~gL2&l(cx8Sr0Tj~QS#hBqNUXi-P;cXf^t>HHN8J>7sJm}mEWelj;VXbIC zL#MJJYI5c^GthNK5&m^`QSQIc)La^SfyP4pdn%NU0O*Wz@8kSgqTfTW+6nXfb@JbB zM=eN9A~P?9IxI4M-^*5@(g(8QAP51oDP>c-T-*A(y1#D#FjM%cb57-DHSq7M(QQg* zqa*JcrB4(`ZS=cb zDh}Mv>Q_7ZGN3B3$eogkty+w1nHr;Wcc3AOZp0OV}@?4l!PgFi8?ujOK zmCWGlunppo>R-c-COjbKDHqj3D-C<*3XZ1l_jKG~V{3<#^9_OkS#F&31ph1stg19; zh4if;V+DNL?8mKKec%y%V4Bwx0l)kE`%i$?#c1v7I5q=&R`550e3cs4SK&9r*EP_$ z9_ecwWsI!o-;IV&z>ef2kuB5jVu9Ra{9%H6CQ?>T{FoAgrymd;2%U@~Oe|vqsnCA4 zK(MJV65d^GhndI=_gUMIq}xnLGYqew0}%Q5EpUSJeqz@k_5IuOX^n=x8#L!m+ye;l ze?=7yQ__CefXNO?n_iCg5#q2qsJP*P;D5*t}zhz&QM zZ#{`RpPsWf8v6q4)&6bG?MFvSnM9bbBA}*P(geQDKpVYT6{g|J9vtw`iZ15A*TH7< zw@!>NXV6d(7(r>*FcgQ^P!4|zYRarNx`yJzx7!qBh*=Yu3MPPTuufSImvh{FQcRWK zM%8T(n8aA{L#|ekHY}tGd?O5cICy1LtS?TB9pd~xe&27K6VVbvz*sgJYeFG>f{Su{ zy+giBEHSiFAa*GTjhCNYmx`xs_@+d5Rtq67U%#HYY5!6+WB~CSfn@#ZkZ}61euj~El|kMr5e)Ku`BuS{M98r z((Q<#ErwT;b2q4Oh6_~~g#wDY0dR-ny;i48>!`<5CDRV31ca(8ieWO3J*4C#Lo#W4 z-H8yy{*>TR%gO8E31;f+i0Ce7cYi3a>EbkXj^w)ozRswx+fW#5`9xCVFGqEHBH+kR zkG@`f-u{^MmTfDg`#~wTWlw2no|ZI%5o+cmGtO>3ogGiq0s5Ypd|qC2yM9MPKjX$> ziMsxz4p}#bM>QEo^<#g+I!iy&gsekQg6)YYeAYJ?iz!c20ev z-8_S$`pez}T6pM0l$PcEZC#yje^m5<+^{8znZ9dGgh;;2pGi*;#IKA;F8(*HGsvdG83zWxPwCkC(IPr@~8WhU;%TS~I_a`IA!| zddL3e$y{ADXa@D(l|6+3Ynaf<2Qeevn{?n`z97)N{>Z{8H}7Mi_A=wK^5hf+Wh?@*P>&BdL*T+{zg;^pFd4mR2EP(r%*7j#f=xpA^aWH#y?gK*W4b>zd=1c`*k*E;9$tF3YUi!?XLP^I8uo<=|{=P!_X7ISW*&XoK5qz zQ|1srySIq#G$$Xur*KtvKsY4Hz1j)YnKj)l28-v@^E~V3wYs7u0D_LT!t20jKDhaK z2Qi7Ak1fZM&d0eGtyqEgWXlg|CSrzsAP#{zPgsJTgrODp(K`dxbymLKepZY)k7bCO zaE-Kn`>y=jGS}Yb=hB9!j@?!9U#SKG&x3tnv3Cy_flMH7VaNa?M@-@x;lztCBlYeb z0l6=`V;@!drxI^q&qR&)27A0>J8NG}XGh^i$(bGif&w(wtNSKrGsx5gp^cbIX6^T| z2-n|A(Al_CJqLPpgS%>y_^Lqx}geY z9N$QC7x(aa!n2TaKWTK$Yh0PZ{zByP8z*#1n>zhMy1pRHC@<~C7htJV1I%7ygTo@p z6kuQcE6QG9sc8QVZmxiMf`PjLp^eOo1&@emE=LX$(l;mm7y|Ax~)XU|71RUfD+$$v?n!su}+kVlu!}RoW z2vxeCFS6R;u8_00TfmcBJ^$@(p)V3H6C=PyjZmts|7H8RN7jZxxk}nb8)KXHxEL4G zKC}TDI7OzHC4ZKN(2!vdM2biTFl&P6W zh6Ix6QS!8H#|MV4tSudOs{>Qs^0v&c>ot#8g*_)b>8aG*6KXs-0)C%8 z9g(67KPy#nhD3mm{Ph6Xp>Tiv-FGk+s2dOf=rz<=A9M=gm2vHZH+C5}1(7B=58Mm1 z-d>?sWW~(u1N6=f<=f?j1O#Un)~(h76r1qN-mO^f(A#BK<;w&9xJ`q$q-hIJ6kcSO z$Fc~{Ea)6Xe3DBvrv~`o88+EP5-&v`{3Qt5khE*$Cfz0`mAO?tl!#NE6U!0Cc{OY^ z!(A4&ri~gliwvO@SJwvwGYW=pZgYXh8d7N*`>a7@D?6fLcJ-adRsqLW04m}`twIq% zdFKmwv&EETXJ;F|wHo;@=I(s|OOOeOEz&2$$brv8SMQgrpBF~2o#`_>J&AcE-jbg< zQ@9m8!(sNnOhFIr+%WKBkyq)&YEhyaPFK=3a1`as>l|Ve%E!p@pp`JeQ@=4t?4t?P zm!~N6dvau%I=SZIt)z=>#LQ{tpcP+ab8H=- zwSnQEB?(zc$;(7R0aZS@DsXvLqt}xjzf*D5{kfJ64_t6VFSh-=?XaS0Rrws!RzqX; z#yWEqFA2)rIBees5W;4Xr;J^LBh`7_CkcU9ei@$;PXmwJegjCE)51i2*a<2T?>}7O znDi7nr4Ht8=BE0FHC!qK$&4^~0p7*l+>Szp|7tLW(iv~ddm$>u8Jbil7lSD$;xy1?H<;U9&2U5UwB)r-YiekpIoFoP-}&n@6ghFJWF8Hq;XC7dkrf(Z+AsMbCClzAplsZjDL z`G(-h)X$f~0v?aGWFyLY>Llc61lS|9OOit4MWsTbRfN+&HnienU?a!LjYdnDNu~2aa ze(ZmlE%}4+E8iBuLu}ds>%j0FE-?giulM)6A7pT5!`Uc>IKL^H8aXIH5>*l8XrWLE z53}~-MIuV3n$Cp^t6*)BZ@hW#tM_DDaKqc$Z$RNH^FS9m3*kn2vkL<{&JOqeMwSyBHEa8vRP zq$MZ$g3mBOupd^fvl1!eQ4F!Y%}1(o^>CQdH6~0cs>L2U>-UpVLz)y1n5E?yI*$|W z&1c11#s|f06o_Jb6iV@+i+ffumu_kLWm&Oe4;UZ@ru9qt<-7xYcTH}ckAI(kg#W1! zb~`z7?6tECz1PCu&UQxB3%^mtw-qIBIu^)7bA`qu^$9h!Gfq1+t5BkYsKe4xK}(p< zW4CPBe14Ebio8y!7n*C7DB|d?@lGmXj%MP3@2{ zAW*Z;;}ZGr>G7*i-x#-!ZR`GM4o0H`GGP-j@(QoYV)HJV`F_&XhqMt4uNz!A5+@=_ zVj$raX9lf&Dzz!akQ&xf1CX!!1&Tu=XSXG>v?ibbN7KB0;L{qEz>+P!9Ja3>hArVm zm$fl%LLK7kH`^f-yWQf)Ek8TU`t`H=0zvgMO@KBNz_ zuUPCzTReTp36uBSMe!DT90p`^ej43MXk}#)q}8Q%+Rx$3qVJR;k+=lud$35LRjwDc zWWslN$n-FkF|1+A)s+Jw69GANBopXWR68+MI<1QEH{^d+OY z2<#uAZDyQw!N$T0IG_#)`53M=rgk19^z43JZU}h#vHSP^M7XeejJekDn%=YTD78KI zJIM50>m9feNax10!;IuUR5Rw($VpJ(XN7o%1*}u=InScKPV^|-mz8(V8Fdc=nZnGH zaN4|t_G=|H9csku=I7Z$PDMs}In_oDyIJqVf=p))q3|K1q~xL?azE8xP6EkXyL~7R z%#yMgxKige{k83$+l@cT6{@z&!k%DPw7;-#Onr-4$^=mDCM*ov%HlTiAV4D865vLv z7~+QqJ%WGkh4;*a8;JNGGk?r9yx#@}v@_t3m3*{PHjg4@gLfwale@F1e0JU{QMW@i z^x9!mwvX?1nUP@$G@R;|MbpFNlaPd}WU`Yd1+QY4Vp{ie1u)#Jk@VO}ZH7GY@K0V{ znJG>I0@$nokuiiGjWp0GEa-_=0+hG?7|7Cqr&2VtBuWjo{Hxttk*Z3`%05thwoDR%aThd&+fsrM&2*ohEy>f)%M=Q=>QDaYeO2+hU@APt$%H zEawWN87BEh6HOcg_z-_L&y$jo*Y@1!=_2487Zet5*aH0d0<$+XbILon6PrTM>AvE8 zShGe|Xn~E*>e19TqD(QIdv;B`A#FBsh}LM$_OY*1^HW@uj*g!(Tp93R1My2UV;Lg( z7st84u+(TVz6588d(lF%phxYzV>{DYs;tD6TGr`H2A9>AdZW7P$;`gk7_=-!9Aoz^ zB0C8DJMwH{39WqC7}P6A_`bo-jd48_k?1rGKXbUKRuz5} z@yxy{hsEc=iAX5yIb)^8Iv{SN7#UQ`&)?`9$OM(w{T>lJ zOaQb_DZ7{>{#{lqCzc22iAS-~4Av_%J2n{}uDoF_J(5A5o+X3Du+e-uRC}(ypRT$0 z#EpK!C8<_TRwp!$f&~o4e;v_d09FdkmZWHtzB!b4pNUx#8w&yueerSiF??6wS^J+m z1Y)R^SEl$3cELz6D4zvx=*3L;;L5hXx=e+a<}Pw`mC-C01Ygl1zm4H!a&@+qiJ1oU zp$WYnO`31Zr{Z}{z=)qP@`})PJaQvF)OynK$m63?Qz=I0WYIm!BwA!`45%D~ZuKvX`Ey%W;W@>vRsO8R`2^U;~wDtNN5L*@al}()}=y&@(gcGM* z=edutv#(r(I4dYR=In-7wwmwZYs_Sheugx&9Z_IItqE-(8pvrao61-*53@$qT| z)V)RVn6@aJgcAEJH&>=#BwJ@i&c8bBrYq_6dVw;kc`6d)*Bh1%8oz8=&yS&BiG(KH z6Yl>Q)t5Y$!eu9i@3a`T%RSWiN~rqH2GzYfdbLYL{1{obz62sjo83e-9l|JWk+`onrd&o08dk z0-)mlEPS6R+*&8#jxY7a4*Yf1dp+g!eA0L0*`TEfby~5W#Hbjdpj>HyK#&S-a&S?nP00V|;<2jhtt$%~;kb)~%Nb zH*OudW*&8$3vhBLD;qP9i&nD?in?(#XLZ89L$1c~<}R!$8)s=Uh}$No(2rwc*>M}a z3=(&K;A-{k;cBTgOXv`mrB#By^94r2mN(D(6pHj z(~Lkc>-CoxVnXlM)M`37cTm##z~}w%Xh%W4P|5qYLgh7C0vt(iro)ZpbexeL!^33{ z^3K$4sw;)8Lo0a_HLAxkChXz_=wlV-5PHdbmMR&VF(PTEC`TlE^tpO4Kc_k(M@$q5 z#M&*rbQ*nns}Z45UmB@!fy4FSd(R?I%%(-FsaJ*OO5l@P;NO^b|NBwBUZ3~poAPo> zU=&$A_zO1?@GT>M-b~$D(L7?9E9wL8hz47wVA^Q26w=t_;-eKa;15YJ$dJQDE0Yj2 z%|ARETD?0LkKw?U%Cx9LSQ>b9QT zos)UxR0&T9ecBX2W>BS#n_Xgk{5ghGJ02|Eu8yR1x3gaa-@JwjMk{wimDZ|@arTOk z2!x3``UqmoKMul>$aB7N9%G|=@SNuJ5H{C=?(BDh|YCMF$b_#*$D zY*Ah0iMTZcf}YbT{WUu%=co)1AgZbPVFig-^D!wvw^2I5=FAoXXP_wxs{oM-7C&^S z?)nyNP}iB4j@~l2rlW#2f$Ia>{xk5{n<3C*T&fV{2`J^&5OGJVmLGPXWA*++ek%zG zCyqE?KjT~jc?aC9X#T7)`Z^(gvczr!*dVg0mn9W$uXBU4bpykMTRxdvi3Wzg35%JJypq&Lsluf*y8ha9ecRqxIiDiK#9`Nk> zF723nNQCx3<&q3uEL%!~<9Rcvz^yjnqZjvee$)!qdA{!!Jc>bEB2ROC6s|HNL4)hi ziAP-raJ!=4)&h}QI6aIypB#OjpO|y6``ge>`{>0zPe~Y!v>2@`_9m&+I%A>4^ho(r zB^mH>0^i(e5Fun+9-|tf(+{p7sK<8Ko5ubSI?nhLT2nVkz!0qS+ zbN7lO#!kcu=|;fbudU2>myM3#NKP?pqpb~!cTf?PpNAKnPk_=oI{rqqU>POTLuSBF zV5T^P^ZVbRA}N{Hk=jQFylUBwxbGr3@HHXveY7y(HxMK&wewAP9~$iCO&xSxhuu8D za)#O(tXaL2rf>SzH!Xy700$2%chrmEQ zdGn^~{a<1pPt+y^zB*Dt6D4Z-UbVSOj7XWvaab-%X((QLnRU5pbh4!!uKW#z)wpDT zYL|*JkCscm_@FiDih7N-^)(7zIe+(TPBWQ#$&81aiBoI}qhfT(AB2-yPU++o=Mu4i zyvLxg%O|H5$f{-cH4A>*yKlJFJ-6Y)2!7bhuBzJb zn4JuojD<#@o?kk;t@Z-09-vc-8k?j1-Y6Z_?7$C70`Fu#Dwacn&hce8Xf`d$ZxPiE zWLjQ4(p5)5hJ`)RDdyJIQ5sF<;TP!uI_G<-swf#KImpL!iFQ$AIs`Xyi6w2U6!6(v zu&^c9rO)JpXq*(w#%qFB_Ta^|R>e!ySI0x{KcoCbpeH8NGWct(DFO^@i)&>X>v*8_ z`CBLLB0espc5kEgYUlw?SUn4@3O9y#GfQb6xE;%A_*SE<)=MM+A1vj{q_F}N5YFky zL0K!YshUE41n@MJO9ua$X=3dEKtz?`b@pgt#a0AyTE%R?rm$Tz7)*v#qj_w^X2eA>RSv%?^9^iJM!K$eZWfiIkW7 zrLz;Uf1g*HGd0AzC|#O~8;G+MVB6e8;9d=xwfFrz(mq(U6{}b<2PZ1=+a(YjEqo)@ zYEq~iCUJUSVq2C(ZH4EG%ts({IE`9U@<7~0k|XiMf@M`ogf-;=n-IZ{arU~ z>_?F0IMt30u_(N0FnC2M>A6zkuyp7QVZsmf5tD_-IxlG8niQ>vFPw5Z@)GKNMD^>b zg&vEeMH$cz?9SY~O{#hE9*34=IigSVeXOSV@(; z5;!T*Scd#YNeE%(m!o*bL0T}wW?+Svq=Qx&irQhKZ~b(<)oKJ?4-UwwGlrie>mJV< zg#13i$JViu%?yyf5qa(7;kNVVzl<1QXw+;M80n|2;B{9=xU*n*-QWs=G0Y4wLJ$cO zSrCe$TM8A)56PK~JHUGcU73bCWUwgNOfyb?G1*q?TeWVp^t=@0*iBX;dCi{cf+uI7 zHqGy)yQ5>8uDgz3oq*Lq7Jks>BkH<6_ki|&pMQt^!Ri7kRl9XykX$LRiO2gXy+CsW zBCNpyJIF`PRk`Z@Mp?ZS>Jdp`N{g0jcB3h$X~B7R684sGm2j2C$`baCwE94})SdVAlLJWu zq}7DGbQ)!Ks*f`v`y7R8Wg-|NYRY2*dT=N(Au`OcBD#ZYZW0#Wg5{)cVk}04>WR)_+8%=*9ev?*DDPj;wfnOA&F^rUz%KnV1K=t*`$h6(s!GR9LXzg^ zZZ9eys;X1c6UzON1FLpua9UD-5B`AMKg7JhBZSTBQP;!dp_NE$0JUDT4cg^E zHHLASf;l%i+P)4bR9`a3WGg#JNnS_kbns*Km&F-`Tp)cNu7$*| zy8iCi&L{(1sBe;{Gs?FFsfqT2zP2;Bq*Ut>aLLbfs!yHhD3ODj5(*At5vNI(Rzqr{ zL%{wZQ}RD6MTD-j)l5?wbU5c%=`Ek%Xh1Op8hHLRq=BSSs|o4he1dXi9L?=kbnai{ z@L{y&X-eKKa0xMLE{H0}(FnULUqqf$4iXv}lN3BT&qkIF^bM&kat7yrTRMbSt2ni^ z`vr9LZtR51y-Uyj^*`J8==_GDHW<7i3Y%EJa=uU`jDtYb^@K0}V5wFCf`*)SCT!DE zH)=`2yXPftBvb{zgfBp|m1Pj-HGicafH<=UWko!oDf!Ij<;U`?h)qpEmi2o@QVZA^ z_}sfGr#!K)!;lr;{=TJ3EGehUEK&!>^&~E95^WD9rQ#y zyBVH+TqahIPXBoVfoINRP%tQiu$m`Wxk-_0%_abz*}MnGu3o#A0)ELOi>n%xU0uql zNHIf)JC(kPlA$Ef+dCt`WIals&6wGk6(Mxd0hOSnvbYXv2{E%`%@ehf0leI@Wx#>7 zP=sC2jE+vPOJWa? zj5%=}g4#MucSKdLfhW=AyB+CL6uHbY=1`(PAJcnBj(`AR<#D<0dw>4t{lKa(@a7M@ zow@vdVXucvOG~3K@s1{zL*1`&yNIX=dhAm<08JyqBFyPRg|KoUye60|@)jBstyC>& zeOd^=ZvfF4dT-3we|bDGqJ|R*!c!yKMx3V*C?wx;k!bw8|MDaMx5aTc)^{Jg*(Wmn z#SoO9&pJ6(fKQcoA*~9}2_bF!bt%ZnA^Ag2&Q%_a&pk)t`y&CspT^(q%d{i}j%xrU zo>nIc+JdCNyUGMIjuab&9~pQ~9uJn8pC2>M=ug?*N2BUowm`~C=^YMfGdWMY{=6Sa zm)A$dPl@OfE!_El!*ZZBg`L1yL6QUO$`CWSfm0GXrpTIg!Wz|_RF?uYn5Q`fs*qzL zNty_hc4o;nCr~LspPn@0+`5FS;4Vp1tLr zBE#^6(1sG8^?M0mzZ?`u)(Dacnen7aS55uul`&#OOU#nxnw zXyia49t4ugQ81Tf)ni0@UGDr9A{;~!{};B^h734(_k{!2Gk34&3~y5BG&PytTarR> zUD@Q|UH-Fys!jqS7WFV}%>@M1AC-!40oWZ;kfJU(DmKgE+K}omIrO zeFtC#GPqOLgx zBVx3^;+KcLQ}FcrTr_&w899p2b?mwFFYI>fa4pzM#akg&`Iti_{px*;tB^BJ=uF3m ze4i8ITpDHHgcd%jK4^+fGaf?Py`5E@sIh8WQ=^xdr|qLy*H)vs**9 z2_jsf%~oO=CmWedEh3yc_R}Vn#~wRb}#2pfD*NhIf~sN;@N~JG#=iFW`}|h z>!akDAxZv3u#{AWqL7tdtR6%al$(GXI*Kb!bHvj3u1y)p0nr$vp-m6MW2eMTO|HX} zN>1V7z=9p=P{;A0iP?OqBk1dR?+duTV-6r+L2z(5i-o7xw;iu)5#`#Vzg5?Cd~d=3 zdQ7A(7L?{VcVKehHuZM6r`yoMc04n z29l>3DEDEbv$EHl8{NXo_p(P1IS$~`CNc)^in;XVX>l$na&hyd8k@$8Lp~u=b){!P zE5Cffl&#V!v`>C)!Y(@!S=x;9(er}FZFuR^nalAZpazki)%CKeiX{r!mCdB0(n(7VXXPsMjw^2Aw{E~7+_ zX3c^-h29z=Pkf&(0FT0-EzC0ha?QSr zJ*j3Ura{rsUioNDjxhgdA9M0684>T)GE4zrE;$0!UI*4k3im3SdAbjm><_mwLf)Yj zf6#uU=gOHf5Z3W?8e9`n9O{*Ri?lt|@+8JNV}&3Z*(+IGFd1|`_BaCbi( zD;U4ZjAf~E9BrKt8rwrI6(ytO2F=NTajF=q>=;iJsX+xb%@k&}rskE}njyK!E8ks$ z)LF1fDC)E)jm-zNWYrn;>y@F}SD1M*_04S*IFwyBJFL@qwxi1#L z-$7wuS<*!?wor=D;9kryY%dkO{6ef`KF<$6je0oVzii@%hTh4}v~`zm+Dy0{fjvt` zTEWo1oiFV7y~U3BMrx>6=BsxM#SX@|ASj1Y0MmX0YyyQr%TXc=d)=Rp_`kg+%1N>bGeN`?kHkQqz!E3%Ja z4esWLcy~y>czphF+CY(6Os1(hnI3&8C+IvUA%i1iR4*xOYn;9|qS1t8@E1Sx+~-DJ z5c zexvG~UO8bdC?=r|k%$RT1up5xZ6I6@5Kf(wN`dYF83x{K3M6k%H}z&TTKs_Nn0)Fa zQ`jNOV7@ksdg)+QyTiO;R@go|>10pyWVn+iM-eAtWs!y+Jq;%7P9g_~h>NMTN~xZi z1?M1g%8Sqd1w*mXM5R&*L*At<1Crb#c5CRKGx1wfM{k!x&315}YGtloi2u{8r*?)`e*Z-7^T6zZ$IM0^km$ z;J0cOdsU%X5&e0I5612m;ahMKOBOm_;wo7W9HjDK4EGBlJl6QUkxcuk&>!+5J|ZNL zaR^ng`yOb_4aHHMt~xImNKh9ib8sj3KA&>Pb$(}u89W>MM!Opu2L|~$95Vw?KdlGp ztW!?v(&2ZzF7GP(p7Npl+@IFb$Hu!mgPzot1A(+Kzt}LN!7o%Vp)Fo=L!Dd;wVnLyo8vPNTsLha2og07tf-p=Ty3)cl~_ zu0lZpVPXN}H>|sYN;lF!i5J#PHNXWL`V0H(%sg?xJ^XS`u@vb04+9g1!6x?*sPfYbF~g+%O>sMu>nS4Sv0-3T78=}>}6;UFsM&pDrDZ`RjPk*F#i z%x+3zLLEph$LbCdXG1n;mnP@fl?FS5d6#aR382A0&gU9>SY9ZKbiky&iD?YWbDg*5 ziEHD6qCd&~(fF5~NIl(NGEdq~eRf8VXM9?@IS3;rb!qi)PoXz3wdT-{r-DTJ&M(bs z(H1lg>tln@YJE4@N6~L=^8<|b$6qoeHYM5xjFMKnz>c;pVNcVg-IPtJ4jB+?fj|_e zpZQ@YGC{5c@F~w5QS4gN#FdzM^a$jJq*ligTS!P9^K)7+X1*`*#}FiBe7b~DRliSz z_BGv5_-TvsKv_@7r8^<}HCfXSP*o9-HHm6E7>Z?-B^G~>QNgvwDWtUenV-r~3Ils* z0M%MoIZiIz1WF{1DtFZ959SO1)#sm1x0CAs1WOu7ezBMM%)X#i1ls%W!)ahmVXnqU zuyqJ46l)|g`0G@|{cBxmpi1ERk2G?21fFa~cKqWUi1wxO6L^(Cbu6Y3C?2K{&$5#F zao8dPl*f=-C!@Xj@*ww%*OTL|B)H@a$_Z(erl={Jrujt{sw@l=_!EZAMS}uD=T|u? z)`7(dzrTWb_YwH?uASKewfVg3MxFC{dh>Gceik|o=v4uD0^lhq)06?WrJaqwF1x`% zf%LrOkDAUSmTEHraj62~L2D}t$@W$Weids0(H|uG{$#Y0m7mzQ0xf4%^~sSvXa2wDg#q7&6FNqKrLRnAt zUEZ|nWxP!0dTGI?usnydpXvZkeI*iy$MQD&fD%Az_p1#oSQHE4Ds%METce4|$7wWY zfY%(>97~y``-?!SFPx)ehS?C6)ym9MZQ)K1*ZdYFuR5JmT)UX$79$(|@U^T} zUOQcTssS71Ldmy{0~lzM7N=EKil%Tz^W)m?-#4XoiuTa_|8XP-Xiy~>UE0ga4(o*B>T8+HV+&>rL3tk6L^huYE zJwUmYp9OV5orFtGoa+-Rlls~Dp}J-vQzkEdn&S;lGDON}KuD0b)uZ-8Agf?F)(hXQ zYklT_oxr53A zyAB~;SC0G!o~40`((T8|Dla%eX*_9`^^=)xOd$qokje&LDS9e|&U*yM#O|R4<0JEg zz~`g5Gt>YtP<>7)q%H!+s}J9>b~G1lfyC1?g8l@*1^#c{%pZBX02j?AJa)IN!J?Mw z0rt%$8Xwe8^&~rj0o4F7{jsQt)v;gw!lXFFy`5rtmF>Y}+f8#9MLV`;m>2>2FVqU} z`)?;`od?VbfwM(T1?z>Ui zx&O$m<`m1sc+~0{givmNi#aiR>J5CE6n-CL_8nMEJbw3>P-^@juo9!tl)x-^?36GL z9EeKO0IAYsAfHeqE8f>s#!lN;{IbRT?H~eflK>rQCD%kLLgRr3R>_)XMw&~>JRW{1 zhbl_=FpGqB&85+8GbH#je^Ck=M#;ELnWO8y(8N!gO*&~Sr2jSNYY;7;yZ-x;AN++J z6gb+ASX0`?cirPdpsZRrArLQyTRw>pmtg0-sNN zx3zw6`hVSJqA;QI7azoHACbZAcY|uIJ5>P>gpBEMm%YSihhK0;nVu#sw*L-=J$rh( zpdaoeG0!sQAI;ARYmEfUG_8_mz~n6{9;$|%gyp7dG=vfEqzH0Yn3EdOQP&~y7@{a` zq^uf+M7)qk^2e!}Aqf1DJS|?}L!75T1dw*aj+9*tW0EE7Wufc{iZ1T!Y3%6%)JRrx zs6FM9!y7EDh&C=5ag(M#9m1lA3je|){cVH)oYn5@S%bP=1dtZX0y&*SI;~v4)}G~# zw}OtOhQxN5k-nD@&WjrQ-;-C3W1vX0m$(Q)sGo}IL@zp5yo%WzRc0o6NZrE>vLZ&ZpxAELpKuQuD?Y&TyZEzDLfX-4lZ;EfSSNxnDZk zuP8VBPO#YfTvto|o})wFqGrQ;gJJ#+BZc8r;qMqe8r=O0#t9V*HeBqhRhTD7WiJGRo1U{? zG96?sLfvgex=u+BtsbqIkbb?hsTW%b*fm4hy85qhlW8+m5zsX(Mqo25j$){el^oIJ zjjoNTWfU~#o6$%eNX=YYZM;MT5givCu_mur#+O>mOgOO|W=Du+HG2GJ%gX=tmbO`( zVtudp%z>cieq=w@$mn^JQqz=?*wopyQ;o^@NMbhDHRFP(Sh_LLWTRu~D0EUg{ylzZ zxorAcO!RjOZy;9y=D)_WwH7AI1MWOtI8N_KM|vBr1qCYz?x&n3F2#N0yQmM!9gOJ^ z<%FyPs2a#i98EA4qT%m^i)-Z~KaaJObYc!#9M#U9^OZs8rK^43^C;KtDC`+$s_8jQ zi0+|z0C37kKz6jF|Kmd^v*%hTGx@}>VeKi7{xR9in+s%u7YpAcTIRjBRXmdCv>((r z2<#P>^6^gfg!4Nmp+wtHcIB_2sl52DzL2>D49 zzl@vg>1BA2$Bmm2C9{$D{*R?^aH#xYzRtF7+qSv6ZnkT~Zkuh}ZMIz-?q=KA z+{UeLcD?ue{N6v|gPA#V=FA)jBigkFYCO)AHW&pOn!lZ28PW4|)H7&?l{rynrT<55 zx&=`srY9(J)^s9{GU-o-*jb&}zv`w0;aZJZ3^BEbDRE|DGB^}{sMXc(fXZCPa*En` zjcO|!4;t|;m~cYs34Jb@KX=J{|G}yWR~y|@*R-j0GF98@8?ZyXc5A<^j;qIHLh7O1 zoW*ecN24ybEi_JNC%aXF(M~arh@e(ZGA`si0PxEZGxa(Li^G{b;adkK&JBlR|I==B zKvvgZ43JV zAmS0qr3e(m&KRoDOr_xc{GrH=rjryi0f9(TR(>&a{;@r)et!ZqZs_@lO#Zogwnmof z&N{6c506N_vo|BH)jEaeEmGA|Vo)Nyn)TC>ywk6Z0|2(R!Jr^{ZsxO=*vho)3ZO^++%QQN6r)jX(1CYpTByy>ga>fx`4lydB z%t>^Qc%Yw2I6Mn!fTvpf&l2C^nwLm1RU8l3Ash42m!QqIVpl`(fjw6Eb)`z>17O&$ zyT3k44X!UG6M`<=y#L80?(-_}xaIR4Ui@>}_e$9F(x^=n2-fD8QE;bylFh0g4$-EC zqNIZ%XBIb^f`005yM>cx9F7rcp1HB6l`A(Owdkzew9E<8aiBDSWLqF=ZMhwsPpu}b ztQwIAxSF1FssE!tWXS^me67fBH z8njIi|Nig8B{J6$z+sh-M?qS8_hQCe?f3TC@<^}VVI}WP;q&E)@Z&jwciXV6iia9b zRR@ZgTVKKK4;(Inos$I_E2m~4lJHx4`s`h=lS??~9+e(rJjl?0geTceIe5HPh zW&gSGApp`T*5qf(G=F2A6$AC2TGm;i5t;U@K}=%|E0Hv84-?!&XZcwS?;e7g{#Obb zXRt5iPFKW?y4cFJy3qF%S8C8IO1QZAjqlQ34USgrrHU$tIPive4Jm-R{L8p{$See- zIt;LCd;`L@w}`Z+eqg294ayLksy!VeIknWP6L@6lLxIIYnDs%`XW>=Ltc8P{03#4N zEtRY+6`na%+r*&)Ro1U`D2l5h#1vgw-02 zagvUVnVs05E7V4_sGge>Qvaa2`Tlim#|20xzrjpVa?aaeu*szWKvin_m+?1TX#Zil zlZpqlV08-iRQntW%h4{{+y~2|I6Q{W%wd%tv?H@m(89r+cE0shwEk7J{nc^6@j+~; z@%Ua-akvG@p@?KlIu@U-=X2ozAN`@Joq^mu9F+o{kcvw@OFq}KNqG*q$Ow|Rt4 zmr`O?WC+qg7RKB?vjr&F9RCgjVHOd&r9dDLe-aRb@+35Nuv_I`JIS#?QV8`ZXPyZ9gkJV4l4x5wH zpy%jQR~0YY!~1oA#I-<*%kz8=-o4IGCxkLxztE<1jLy1Zp?-c|{d0(7@yZokNJ`%j zg#02xu}!6@v2iT>vItoR^Y;K$&aMOX4C1g0r*wu%UhBz>K_nOHwKF^ey&rTgawh$> z8pbh&r%?2mEDQ?nDk{*IL8@^>5m4elU>2DspQ0jqSc>JLP^go&zw24~^~G#)tJlpM zv=a&2@e6|dcgVyN&v7Mt0+eRL5UM2-2IexDnvR}>jF!*c9pHs&?}ahV=k=pF0zQ{^ zyu3V z-q~ou(fj-22749h|GvmBLH$s1RNYgo%Cvetf8~P*ZhMR%9gv6GBJ<2EU{iP&j&+$s z5m|IH) zg0IXukVE88ch|SSSoI`kbABa_R{vzAso2fSnvs(s;<2U6gtDxo5Cd^o$#)@}V!eUh z4sM;l1%&d%o}-FfQJKtetZwA{#r{1H^+NFEXdU8UM<%1On>dJ!POl;7OYW`_VXj5| z!;6pNg=veV8&ALClV*~l!HR8JFfELQiSnaTML6j}&UHrBT8&xDp+wcUcR_CB!V1`- z(L}ah$K<^xZKac8iq?f6ME9QSr6wA)9@%J3*9IF-BJwc|BGdyzRo~xRbzyEaBS3HO%D0QNQifE8f%SEtvgzmc^ zSq2tMRX5sU%&wpmuxd&j+AJhMI-0UrHtkyRzNlTX!-vF;K;+bPV23_4pPn(C+bx}eJ#ka zMILwe*5{B3ZYG&jed+Z(z*+L+5b}N9-2Q|>4Dasw_Ed$e7fW5MPUsYp)KI^dESvXm zfE25oI}dpo0?Yizc}=4)p?lU|yt@k84^vVPw3~t&TF$Ncv=(`Z&7|`-*H)4!>K=cD z;GP|HM9m{asK94(Al;VtrD76l^6T7}{NoZ%ByH<4cF_K^)DomUj%?5FnrKxN3KzbF zeCVD)AFW?CgXKtQ_|f;f#)qhayTw_!^@)w$L;W1L+8v*-Jsvmvd;5uGn~>Sf<)P2) z0s9*-c$XpNf(ZoI+LTkOc<+8$4bBTECZLtQ2E56=`!^3FgZ@?bjogFqC zY@Wyl49vG|9JqeNjdA?J@0yW_xTC1ZaZipC)Wzs&J~PS*pyprJoNGd=u+>u1g)?)u zs?#CLkor&1s|RvC9K*7G899~(MB`P+)lVnMBYKdSl4xzwwGy~^p+tJP8%;`$*S#-o zaP8?Rm{?zS4r@Ii=E*95=s-~V>PXc=A*SRkT*T(P4q+ex>X(SP-ZIjyrspEPr{`S! z--_gy{_I7Ve`)^S$p#{P_T9Ok{VW%zcKKxObonu`1ao^%@u^@?w50T31)eK_Vy69_ z1^$IdCGO;wn(-)9ss{FPb%-#U6s2Uo;C1sGMr~*tP*nCqo~VU*qfzriB@}M4#HM^j zz>#m)MA+>Z7Hv|UATwlRj6|Zdq?1fXP+YuyAe;_4%|V!Y0oeTL!8 zx6ekEFJ*hEPgX+wW8p%q_yBcEYLG^dvoVLZ56j13SS>kyRFjKt?6;3D{=Cco`wTho zM*R2dz+Y#FOd)DiO2fbXnhDmq5g8;s0bV{sy&Ha|MG+o4#OGB%mO zV@$h(@2ZxMqPO9&Q141MU4V%tdw~pANqb0};tO5gySTT8lbj-Ce75hlhr(b)WHg2n zE@R7Z^NRfjL#xup@w@28aRsRzQLD&`q2G2Feluo(pSD)>E4(m?UI(oc?o|-LK4KQ{ z8p9jl7tg4XYVlP{8+ssFN%ya*xIh$HcC!dBtAIl1!+=gD@o2TfIHpDBY@_uL$5Zt9U29R0K>h=>a)lGO0kY0Gc(<+LSseBPb!;I>C( zcsB=2=b!~{4Jsyry3OaEHoxpQ2?$#3>Lmn!5sw{t?8btxXg%Zm$PoWl#?MYH45BOh zsf=N6$s9t|_AmH6#i68sri4i5m!SE1Zc(9)LT}$7iejLLWk|^Ls3NH{@ebdS{W#oW z5pAZwjXRW=Y^_>^?!Q#~{;yR=*X$VDV z6Re1_f5VIoyTADoe3s`^5*M-|fZ(EN^%tH6CkjbOxKGi&$IyYVH%=aD+OuK*Cxn)X zsMf9rJdflqtn^!&s!3OHt(U-lcZjcaz$EUTDEaTFz|TE@@w-6afpg%~sY%9kgBa)U z7n+P45Ek~3WQ($lEA&u7q=U%gi8JH4lT<_CGBgk zcdhKSx$un+LjiaNV{Uin~ru)C2y z_xGzUc1>*8w?LE!a*Iu+cEtK!M<)eJGnkmd{{Q7<;xCg1NP8`2)P~fkpeiadDs<)2 zXoAosdq_iQivbv2N@IS3sCbge_~DfoU6^*WT@g(t%oKI5W*D}{@EF}C3*3k!S_{kW z!>UqsV5Joog_{w{Kb%N#uphhLXyG+6*>Oy*uE6J39BpPQCN*#V(Y_^4t%4zpWw4oK z?SQGcQ8@Vc>lrZk%uRpkz5Vk9F(ZD`5(IhLh#1SgA(!ktmtKJyU}zHSr)`h2z99z$ z^6qr=_1-b)+vt&jH7|R#;dw|8~Dzo0H_+ZK^H5K-H$pl!*yFyU?rJP2W*7;p2*axf;RVsl7neh zG$XhyelP#5LYw&8)LIK=*H)?<4>R6obVF0e$$~7S=KyIlLBCcUQA^nMXd7reGv=wb z`fsji(9Lbu_;|3J=y0`S%LSuP<$2yoUph3l6qVACpCFjZjziFqHqCByz#YZTAK}a= z!sf{dGjwc9ig>of-vch)K*k6ml2JcBP;~cfqr3SJz$rW|39zim)ry7a=k!Eb3UnfL z;KuvSQb;{WGwZ;53px~a?%Gd$mK@yK*M8*1U>)#&fs+12jx z%8j9XM%6h!`wNmSd^aD|)WUQ==*DM4EBIJzTUT3G0DLYF5R-u!|Gb}poH;HlRNyV@ zQ>`Q*5u$7Jie1IH%WOG4jE=1QmA;uDthnfuGTvKvG@?tK8Dgg>R6~^67@S}j3Gc^# zZztQ3HV3{ybkyyo@70gNv@ye>A~U4PZ8!&8Q$6iy(vElb1B);RQ+s!$jWZc5oL26T zu1ZcK4ehl&!>s(`inAJNxn`GmZN=$-4Lehj40m4poCAww6VX0eWwpZO3`=8qAJL$O z6QU9_2exxZH6?c%dElmR*Z(C+^dHl6xxn}3<5{QE#Keyefb2aTN6S4sfpn!wcuEmW z!CpXxtWp3cCRVu+gI>h9yqKWBEnx=8OS(~cEXj0OV3<5lNkZ2ywzaEU`YQJgV>#v@ z!;YM|zN|sQXr)=N9D-G2dB(r8gi93TTP?__u|vUET4CF|9(ohb0^CK!vhL97)HDU# zo`I6AgCbtAcBA6v#1|M9n)!BPK_sGFzDiu{thqAndZ?RePYVL~`6nC)@ z_Q%4O>iy9>ysdq1JnijND0@aO+W~Y8vE@rr0K^z<-Z&_GI8kB$K=p^ezlSFJKLNR! zdvDJXTCzwihOw6Stl2tWyH+PxUE?K)1OtzqE4~y+_{oXUfp2k`a8$_}8pU$r6IBN3 zLv)?tLFk0QhXw3UV2T+5!(;|45MGK7G-{WaE}J22e1#W&$v@J4K33$v4JVpD@6vjx zqCZsK0;=1L_Q_C$Xyjkt_w0ba=>sNyCwu6?S9GzLQSv{(x>OJQ&wPLrS6l~%`@fRd z@$ZVvCvXgQ5~|fr$gEfGCEYfeGY4? zb(zxXYfw_BU_1s=i4`*;70p}IHUx(CQ(Xnzprr=Gee`UUVPVb}Hd8=lpxzD}o&sZAn zN6P`QRNfwm2nQBEwW#K5jLRhqE+X3-7#y+^86DLSdl@a-iHLaInjO$202&g(7Bfc_ zYDo9h5Pzj$xH0NO5~EO4t}KZ9`WQdvvl7s!Z5eLM*HLTJd=~Jb=^qspP^DmZ*$MUa z4?)FHSsb?mEVuA;VpyDTjVo&!d;~e{xCnp+QB#8uW+4qjY$s+iULF9eH3uI#qV4|* zn8e1w$T3rqZZM3Zhy8js>=}!&g@6)rGj!@^e2%6GSO$Kza~%vunnv9}R7pv}^xGU- zXU*Q#mNIvmuj%Q|vO4rF^zuWbsux+e6$7QKp{$3Luomfe1eOa&#%bN_gir-A%!!Ij zq35wvHt|($l)ZW1?M4+L4 z8(46d2+{SG{IbW=mUNL=`Qk1s z;SQo{)J70HMW_ALE@UK_9cb*VD6lQjVIl7Fb5mRl>UEy$TMmT)h9IlgabP4(+C@1O zyWk*UqyB6962)%dT97Tjh>k{$sX~I>N%2qOds~*VBf?J99d~P6kYz5$3`|FZA)@-R za-W-VbQx`ylQ6QqmZ1W0$iT|XurfUvM6cq}SYl^7oahoeq8^`BlwNvGaAs8{A%U1i z$AOBkU<_x z_JWX%bzQGPNvOT2eU2C_n6IU{$IZZ-a=`UkQ|eO*_In@R+xC0KT0auj!+#a-;R-;* zv;LhnWw{?DKV0Y|#&EQ_f8yHY8Lw$k%X4=B@$m^7o)z`H`L}Ym#V!=-`V4`r-${|! z*S|o~>lK_2`VpNB(Peou5%68P3l)~M4CA|i7Xks&G`j$|ZF<8@BPqHxl?wfH3xC;K z_xEeW)^zmB2`uFw8B51bL&~;3z3hJL$W=enm#?e1>4ydeVXTak>woNr5JORXLs}ho zr6AkgYvbZFv%X}|Z{Fuh@B60YOOQ3zW24{L3(_N}M0qL_L+Bq1Q z%}$(x6Ckv661}-0AnRgq@JMG!L?{}J@sEZC-Zj0m_IEYNg}v>f=DtsT^Y)~Eig4}AFa8eHA3 zD<^nh-hN}{$H-3yDED(3Mbo+}>3afsB%^I8oT~-wtfNE`sK5Y|OqeT`W=cburY)-T zYgRG|{z7u8k)BBSMIt>8c6eU~Ua%>`kRFQm?F)HYdLPa#8uj~8qncuuJX3JY$N(Jd zB^&PYPu>wMdnF<7Xsm2Qx`ms0HUYW?APu6v6AxIQMoO*4r&VT=UX zW@g68<0lwohT>d-Atp{pios=tswQn@dwh<2anV7$zlzu1DZnloj)SBw@3*610l656 zGsk19UL@aTcdu`lM2}8Fqggym$Znvzpq6rSAwWuFj9&Ja081A76nrjyQ<@2oeko6H z_O%D(u^=nkoQ@|0KH*hE_Xlp@qWOm~yTZnGFIz zL!_0{H$*Mc(oe38c7%gS16VAYlk`#c{=_|o`X#)LMhBh3w8uc-SNO~Kb<@DftH8$p zBR9_>c9ubzHMR^#@{PmnSc4yb2!DY25X(+1<&#?OW$=r~84cCt!^|tf6H^`q;9W|2U9(oUbU> z!wXx`fa<56^^maS*~?Ll8SEAJ{d{c@xjqD6V?#e)WxL*|sqG1-C`V)~Ny@iK zg^HN*TcYJcRh{L+Z?Ok9%VE>zARHbn&yRCw|H1Al9se1(N1wZ9n+Zjug zDc771E|$~yC?^UQNOIUsusirQ(iCE_$zwgZQT@^XtSu+diep6Gmj`vt`!Q6ssHCbw zEtOR(h@(BQBF7{(#t z9cfR`O417X--9iB1lY-tnKe6ws#ukXP zIPNZ1al}Aqvqz7D8sWt??MIi0Ho^Zzb9jB3{xV>DdRE+1(+A|y&=ouQa5JsdI<2c~ zPZcE6Q}D>}IeLS0m-^`%$S;Q*V}3`VdVXhu^GIpG*wtS?aVuLb+?^Az(%ia7cw#we(CI{&R5^0pS52z*g}g;GGsEPfiH+WRk` z8L$;r!;!TVF}d}>KjmnhYUl&d01en?*r1AY;k8ttR9&!vChq65l!uUaEBj%_wV^AD zXv}^0Z7Zkf9Om8k60}6!WTWswIF8@-Y#N+Yo92Z0^z|K{x!BIO8eBhd!1XhtVfkkf z)IP|YicHC7N8KT!6c2M1b#CIiC^TN?=V;_kc5@mE%?bd%z_zMPXoj%Mj};^#xq-C+ z*<7j7Q;oekz>JkdGWpA_v~F!rVPe3+8nn4U!4U?t?asqY{$lD6>*Y$l5EZzDYaB=^6mTB^t>hjSX_|Ott0!{M+VKkZ) za?{lnrg=T0G|19i6|XGDIl1~If_?h*N27-i&|lDWlVB?>7v(0s?Rf_G%WIk%{C3zk zb1hT=G5%qeR(3lHM54vnttz_qQ6evK;_1Pp9>4t!1bbOE=#-w8k)9&>Z#Ok$nOWGW zCf^`VLQ{6iVlUU|Un-w!w58*^7Q&KL2~4-j??X}1!3%gBT8lfn;GE&?6lKg8lK_5l}vCJvnYnS2F~Tn~!8B9M!AEIB#Y z)dyRVSjiPdPZOV-Pk$@>zm_vBqb<$y$zY3s$jqDe2Y3x529Nj*({1ad)(E~7-&*65 zbI+45C8a;cLucS6Ob_pPe_L4oXI4`N$^8I)@uNK@K-=}#AU#=mwahXV_ zTDfIDRkfQ9G?T5qi3+^{u~`2h6>-D<-S^+0YyJfyoj&$KTD{Ony+%{6^b8?uxl}aJ z=zIDq(d1Lo@5BkWZPO*8p^@VE8bed?%0T2_tIV!{Lqn8}Pbm@qo_;04O=4;;$}4z@ z__nj|E>bXajY*Zn$%$usquGVtb&=^GRz-$r@F~DGxV0;S>j(~zAJf2qMTfkT z=_(8}B%vY2{co-ZlCO`?yb?_JhZ|r8FQ$NL6N((lR#}1_hY@wD5XfONL0i0`Lmn_; zWe}8Y*H$m*sV5DlG{2I7t(WK+_i~CRc7RA@4e5`$I$UZ04d$?f&cU75B)M!X6B1=h zVZE(gkQ(ZGGJTu@I3y4`MHgffU{7WBm)^Cds7iLz+h+5b*mT0jZA@@W@j6nV(JeT& z;Z-kb1M#Nu_b3)e@(cbA&<|2_P~`qp^`(*J+}Y75xnw{7*3%KI`8`L*EJbq6r}G;G zqR4zBgOv>9H3xD0AtP^^$L~)d^Y7>2$kNEAS1m_xx7Jy(TK@(p1{H}zq>&IYT?mQ{ zaL^Nlv0VPktId$%KCol1KZg^MQ)nq0?$r7c!X9usLU*Yauv3xfWcN#=F^^@@1!o ztfh|@Aw-1E%+>gD-6iO%(G2orFFCuAeV=rHf1^WNA0p zko`8J;%Bpuxm-b=EJF#$OxfM+CjwJ8nxtEqxHh+nK%io{g?rzX{Gb$r1Aa0M0Xdc5 z&q|2`Av8Qy=xJxrQ95bR_aGE^iFwftZ zVqKT89hh53R5)7cur(JC+v?CCK;GXZJvz%-FugZph=O~rt~h#Jn?6~(j=!31PT1eZ zX5Y6X#d#~(y(52ch;x$W!*z1sH2jiRMFo92bJX^?gu8w<450$2(=e=y->-N&I4T)T zH)%Ux@KdpPA|0 zTo~UwvG|g5pKb71fgeM;^bkL_Z~>E=0xkwC$NUA>UVoO)e2o^mzr}l)2J64F7WL;d z!%t!?V#kR0khm|61A8dPRRAW9&K=_Fwz_ZlXQ-*)tUQ=N0XOPkBu_Zu*}KQ9qb7Av zDQ_t;*{WP`HfNiw;@JQbQBntsja6`vbu!yDV$T0Z@oTx^j z^pd4MtcBP9)WkS9|IcpA2m!p>a&p383*lmNTXoySYd@B2jFY2?LKa(l55Zb2KHRgfrYFNX9 zkFtRrYIp>q;E4P6_SiQ6^3|sZ8FzWS*l|w;MwJ1SZwr%+nQwjltu-ogDr>+Qu@P&}Cb&ydQ|n0Lw{8Pw#F!8Ywz z;i>f@+%iV}?=!f~(a5f#?7>d7{T8G=T%BsHvq7O1h+wXoTwFdpcW{6J9|v3SLIOik z!tQjTClO3^S~WX_HXj)yej(EYSV9|`Ge^`aGhuaVsg_NgQs{)s$I-i_Ex(^3lZ|#< zF_d&Z=1067$gY0$T- zzqC`;jwaXd{Ycf)#^sm?RdE7t6Uknt(M6uF0$xkRZ(IUD&-N^U->iYYI$xd+CSV8H zlAaecZe6FOVazj&T6W^Xk^P{^OC%)n#galBR?X_ob579ZT6=GvWD$Dv;6bbB{SyrMc!zQ1-%o5%XQ;$w z1xF@dLw;;RUuswUY+=Vg%7D&B!h8~x<}scasp*^z7UA(FYVaQg^RsR)2E=c@?7zG} z&u~|Kyl$|ao9dBU1mgnz9f2MGqu&Z7os&sOzkEFPlIP-km14NPyoG6#$oZ|vzj^G3 zMv!_=Iqjufa*eLJS;CFP%B#$q8(AIT#;9PPAos9PwR<<7+9+(6>YQ26$WQVno(G}3FDU%%9HEBJ&kV0Xz)9Oq_5S$_+TLY z=he$E)7KdC*YJ+F4|s4eJDb(7{_xre;=VEUee4c+>L%J&wfViO%N>CMB>s%3Ww1zX zsHs9TTMyj1sD{XJ`iNE%UDqAZo^OUf$3*05sML&)B8XEy1ne?l4P{ymwv#E2Q&2q! zJvlKqDt}fF{WF5Zjk9sCKjCpifxW0o9Jfd_k-*=R6WH5zlIBLc?wf+!18tQtEGD3~ zmc&K6h?A-tNbBPxmW|nk-WKhR=Z@cA{+$?)orH1a^O&pvsS)aX`n1r9_pVMb5ZNV)D+@TO8H5RLQ(_4S0P1*?3zI=xm z=M!u%~H(iH-;od9}bDO?_0+4q}(9vgoPJ`iZ-tfFi%Q?;%B<)&4Uq27QN8 zGdKtr+-^c}mpNjztqhFtgK3yuLZ^8Gs-cr?B#fu zZ`X>G$JD_ME-6RygBeg%fN3-iQsJipkw{wfEj* z@kaBrg4Icer1eD8b>9yxmfO7$1n0(d&WFenYtAdyqv1U@thY<|QJ(a8lh;yNcf7iJ+2EYT! zWXmI`kgV(=f%JutE3=&1LeKvxn*IpbrG(%!e1bf;USsg986jUctpgIJ>R`^{2Ge15 z^|WgFC4N1~|1xHka6G%J1}E2^BqE=IqzysaAZ=<2_vN|L*7nAW*J9ub$gpGJVGIvZ zYpyuMig^KH(2DJ4+F!45NQf-#Kva$vPH@ehP#YAkYSurf%){U@oc(PBGsc2XpJcbq zC~e=O3PbR*BNMq$nP1Eh3C)Z`7KGfHUKWgwQm(e#%{l6or9z}e50W0ux<#j3 zy$85O(Jwt@da-VShac(SIo7TA?gvu+*Q*FyTZLj<(S;R<~KxEBdkRm zM)lKW5I$_&s%no=v+**>d=sTsA|x+Jb0eAb&{CL<91qxkUkol3Vvvr+r(;X+@SG?g5v-GSH0~w5g|kg zk(h3TK5ulUTaL^j6|D&_ySmDAZ=k6pC14PPbf}#E{iWBUMkF2&78__|zqR z2!m7`sRFfPCn*Rx?6_p~iO!uyES^yk@dvu?Gkb^gH2EBw3eaB%(=B`idhgcU`KDJH z#m{e*a74;ex+p~@k&qP(dr)&>Co&w=2Ribm8OA^Q8$M%8^T9U{@{hL_f8l@e)(TL2 zKfdQ&;=2h5TL}i`8)AiWR;VA>$PIHSk;73M8dn6PsBNiaTL9a?q+*DeVgItHyoJg8 zmg%z$KRUc@S!eBKr5yN1#6?qkc4p1IbVotnpfRJW@6Jbptg&znK@{OhM}|Mt&t&L4 zaMph#!Ml%3I*HF7V8*W*9dyiGU+NPkR1i9gG}sXuH?{VA>-iV6>GT!4AnU+mLr^-k)oreQ8J5_%(gt?KT?`X$6hMfC`J&%Skm5@MuJjrs zBVnF4)qYi0emaSqg(wy!OgWP`kr{eb456w)Yf3QDuWD1%2Net=e4gQ9p&}`;DYCsj zSF-{`ef(WFc;p0<(aD)Xk>?p68>x}y6Zo?Q1nr?Gb%+xvdN?62P~nzML*yeK`yw{( z#`)#j4yL;W%6)5Si@zV5mri$Y+q~I zkS14HR%kKP$ly@!VXjlm>!#!w2iBsn8QMvo>rm@v}#nd)l2i zcYrc*FE>*i?A-7>B-+OZ(mQM_U)(muzII9Qv_ngoG4acJ?nxp_^5!z zgUMW1iy%AuraUAVob{>&s6T4ByvtGwCVq@Wp$$#_9qDGGGua&A0KN1k=AuwZG~!Cb z;8y2?pW;Ry!ZCyvi1|;JcnjW&z_0)J1~Lr1Vh;F+|1sR~`}QO%fLVwps30BfbDF~T zaOchQ_o?d6122Hv>i0#JOXG3qCb&!|s%qLnb*6thFjJ^xoCSJO_Ap@z-)TFxfug^% z=x#{M9Zohut>IBK5-OZwX#8|7P}0o+=dM+zQaSFAOQ90>l~|nncuw_di1<1i z52!I5ERyPMTN-VS6|iQfn(|k7Wd@90UkQqV97D)QZLwP9G4qQvDojP za_|jGi6A-E0R|!C4|XSW3HqWLaB8qn)pw!D`8r|#Wkj^m_oX7T2*n%J`mAn0eV*D_ z#`e7_%7?6ZxxUi{5eAobXR&0VtmJ{F-8XO-2L-KABw9ACX3dm8U5xlPmbL z8ygU1-_CW7VAfX#Z}fShIG6fULUa|46g|j`EBw(`8fqoL!rd23`=BHdhj3kc5T-A9 zX~~{pQamr7fs>di{Rw5&CXQ#P|JfN)2|J6f;0xk>PSMwh1@N}Sc~GEbXwE-1?XygJ z_wyMiosJa=JuT8cp%s_zpmEK4UFS;U{@dw)d%fRJ2cpyk1de-HcHvt@wDV1SRA5+p18O$`LMlL4kpjM9j%F3&&x5t0l0zQUFGF=)k#Y;?*dlTjJ_WPx*Dh8IB zH_^8~$EeXwOTYXL-E)O4p|*mq#lu3Qs40K(0%D?-K(wGYd}^mblS#+*l+7HN;q{U< z1eZT`ng!2ayPg2v9umdnmOqw#$*_=@E1+{5oOd_o&V>A}o=R^h$+jj6 zCNqB@UAA=Oy@jxQXEfRe@NcY1MOKx(iE$O}xyPL{j)aqbN~c0WhSD}NAE*WYo1}#; zJwQI&45il(}D_Efm|9p0E?xd`aB}8dn4FkAQD zHIfo2IJB+7B*79(i%nc9AdyaD$^HJ!ad z->gej7ql^Igz7_eeH{u*4(igpm9rU0oBCHy=T(n0ZlBE#U`S1B4~o=+J1Y0fHl9)w znP#E7=8f7a+8?Af7OaX}wwNhuT#u zVND5uWuX@L1hwtWvKr!rWlAQP!gGB zsev{a4q{5$vp4{3i16;MF>Qp@ag!z@`;>MC4Z8kGjY4 zoFcaK3-A=Isn9V3h|Z1kXL)xwpd75Il%l6mNrzAlSlqaVG|w@WVonDsQnKGI+3UFC zoF*>ngL5w*RH?snk(?$MK#`iN%2f|4RntEe_uj2vf6^V=)%*}+eNcze1U4S@T_RVD z)k(Wc@-^-wYp6nt@!DpOFcKFsQ;=I8Z7TLOb`V+w5S4Q5H%E>l;Pfy=jecR=%Vo0p z?^s$y2HrF9hDDLf*`6QL5?+$d_ypkxPXQ5H%k>0zzQ>~7+Bqs*mSM8;te9m{N&>bV zqnV{=ItAP9JVvI5c~jx9zBA`pf;ionLH{03bAf@RGVX;W!GP-O`OSSBxE$v_2buP+ z@YF4@$9{_{{#2HyuQp^**NUz)ImkpZ((e6h!%zgv`Uu7Fbnxe`DeW;}xLZd2ryv8D zlqXD=pQsco!KzwtOECNdqXPvD-TX;nGIjU}ubEuL(lq3k_e zkX@kWrjFb7G`i(C(T12=wrtU9bkuvqzkz*LkC3I6>b!*~R{0nJdP53o-OU&d>>B^$J?GSm!Cfp zSWc7VH`D=2PtR1yv-)%BZ{ZRHm(ti?cab`0;<{@fDQqNug7G^Tq2b99M*z)GY(V94wmwam{0&CuF#h(hV)^b4BVi9Yr57j4`f%e6u#}b*6(zWw z_=unb_T;<7)0%}$rLw#+TkKl$tpjDww<&xtavqS&j_;?-j_2RO#9OBNzwTY#gZ_Qm zXc4EFm&v=C?5QsU?7;J*%`aO2xcQy)1CLZASJ_-lI|F?>f3J0St@SGHz!*KG6fHoU z2w>on?uma-yeKa;#Cg)9R`0$Q^hf%0>)qZ($zC_8Lq^4tdE4s@Q1VEC1o=l}@vB3Y%D+OlYcKcy^~!+j8@IqGbj7o4mfX(h_2gDiig zbP<{PKx=5RzC9WBGb2e!SIQ~~V3)&4aBh92VFptNhE{b@oYME-bI-I{5y99Ak7JN! zxsXt)!-fIz1OCt6Z+P%29+!Py>EjDeV^5YCCGfx+GUGXN{=d2RYsZrMY>HIA3mVn4 zTRdxYkaeV48E3Vldl5sC)LMbS#Q^+>$+emocn#Z_u(h4v%vQvGa^$;g8GaYce+RQb2Uk9DV4 zcfKKq?-P*>Z7EtKV>UF7kcC-L{ofCtc6Sv@FME$^|6^k5D}~}(#k@Q(#;S|YX$!Tu1yyBW$y4KIV3u07CNja6`Pb~n#{Si{U5cxyN6x+` z3P(s&_H~AHZt#NAiqEWK@q4dIQ((t#@bEpI8o5>X)3K{B3(rH&iA5A$J zT#iSHr-`QopqB z2VNz9(IvSEXilyhTo*6rRd579EN3EU99f%%(LXQD$GBKuR+k`o6T_e$nILS%K?*4o z2(HQ3@FS8Oh z(zxq(4scvMiNPLKA}aW#M4#p2$^SXvVxzf-|I_d>v<5z_i2MAHG$65x^ftTm9IoBF z@M1eK8^-`&li$z=OttJ+nxf})E}aTxseIMO5U2B3xZV2}$aHK`ZRc=2S^{^9E3wxl zF(Pp(SzS@hV57*J!p#_}`>{1JlDuh%!&9X&!#mf49u@}bG5YWyH(;AQjIvF^OY~Vo zam2-<&Di*Wu^-mGE!+C2eekoYL{lQ%)j_}bD|M5DsD1^|JdZ#7NcKdRiKkK~C~r%K zUHupjcq@t6xqXLyf9wsoF7V%Xndkx>H^c5~gulq^k8PHyQY%GpBayPyy?>d)HO^sY zIN=USM^%mMB&IBai#zY%oQ~P#GqvbUwQ<`<2g#$p-IA!E|2(k{OIBC`Qyq>iV3mAo z%GiD$ILYmNv%*KW;?L^&8Kh0kX3>iFt7KPy!sQCAWG*NDNX2}1nE_lRz5CSbj8r9P zl9Oygy87-noA)a{cmcGzqy5nri%K+e&kkk%Zl?IQ=5bfHNv)N+LH{298KYOK*|hrc zLmQvRaUx%+d%VZ>8sGCpm&Fpvy#62LTbSpBj;cvCTx+*spdO16%lkr7*Wk*Fsq|(! zZhUoT|A~3x$54%$O8mGRC<@8s!C)59qqY|BUdQ;H_cO5W0j;j2?{*KuxF(21!!GZu zcw))uN8@RonhgbOHm;;Ox@;jY;nxXju|rb1{^z$FM-zTHTix*(rq0U~2=;qSxk z{*ORmdeYN)r-`FFqip-tHhZ)8UxIfQ!rIJzQKk$z($y~EO>lshGIm29y|;ANbU zgFAE7penDmWf~lG`tg)?Oi14IFAzOeGu~p;PZQoX-ZW$xhA)CREbZq&8&AA^Dc?i| z!RE5R1~$EptqRw*g1>cY#p$HCduA`0mHko}N z<;=9LYXnEcRbKj07%WSKl%b5eG8=BWnAH;|_!^q=GgG#U$~XwGKQYXa3OcF=qq0at zDw0eQTjK#K&6#o&BZ_>5iJk~N)pDHD(L@><_^1u+YBAs;$-~5~VP2$xO%jXDUyv!5 zuj8x9N^Hf04-=MRKv+TS!w&z)1;kz<_Wwusp8)OEaAgzdN6q`UmVtLR^Y9DxQ)|>C z;Oz<^(Z2yV^$~F)PpdG|;rQyslGmV>WJf#f`BNUzMm?#b8NQ8-fht56A6;&PSr=0h zV{^Rke4LF8oXizU-gP_aVmAU=O7B5nmy(oP67r>sG6>P!7&6ZkZj&4O5|1CUR-dstc5Z~M##+hnAgm}%9jMAd;F_&r40 zWz{L5TYmk?vHkPse4X!aGtI*fa2KJ@YB`bJbG^G+v9XiG5&AL2CSh5IR$CCclOt}i zzz{7vL0kJ%{H=Ym@YSmz4 zVta%d_&`@bk(pP0>oOFO;0_^FN`Wc^=sy<;-gX+tgR|c$hQW+rO=BTama-8g>M{Ao z*n&PysyVD_tuf{o9<)&wUTzDbOyE#YDf#q?2X1mj^Nc49Y;cP9$I~K|Y-8WJo9h&f z4vEO9otM#6zcx>U0kJZum@gQCW+AoDJWsv=CvU2}q;_#jCBWQIQ26V9vNxSgj|;G` zJM3@s+D`d-DOL*n&mUAohw-=0b6nvUdDG*u+0RY|J$Ny1#4kH|L|^i*c9N<35FUz(=pY zFZXq=!(NaC)P0o29XmTG4dNG*LuG7z;;^eyrowhXk%7^O_SQXejiB#M;|j2Ld^sjR zTbknSg-Ss0$-A~SyxCe_VTsr_$~!_EyE$W8b`v<@{Cx-{ctT^WH!g1Ujs|pS;2BXt zyL@4sNCDEE3K_<4YDV_$0oy?khst@OB%UpS z4Rj<^G#_#SlxWflL^G*rG`dRm7|NCfZoU<*$VVM2(0?k6{O!;aF`lo*{;Sx3@f7jN;*ZzPI$)w7bDPXzf`l>kxS8i zrE@UknPXPP$tyApQ@>4pOQjdH;NsDgL0B90A({|~!L!sz86_#sWhaQgB~?0yUH|qs zRRNlZ#un)UqSqk!ffd?_E@2gN=j*J(OaY%5wtx3rlL)@~8*kb5dm$|nDy)pybn6ww z?)~q|KoPWhfW%NMd;py)xquWz4FYUk;8c~IFMS-jQx}6%-rjXMj}bCtBw1h*7G&k= zN`&$})AnNxsnL^n`0U}6!$fnOeyJE}B=oQkybi0VAY)$20sLEG(#ijfIC!ixm_xGM7YX5GSz?PllX9;xNpr*M^f!v#&w7BbmOIthMFyOVz)p7*Y;+iZ@Xn zpeAL>gIft<^)?B5Kqo!uPcGt7+3HccpD3r0#Y@&B$x2b>gJ<-EEjqcAj``3-L2N-1 z<1D`_`vfwq)hTCvkpl^z|7tniFTMY6g%y74xsoA1P0)@SIf<|2jw*mu=%&9pGbx`G z>KHzBb_)1?Baz{M!t-v?*#3ieL#D(}_@HK-Z~Dg*emEGmcopc;gfw1xcd*HT*@j2h z&Az8iScJc@Q@^n{L@6>2V52HvXXmI)MTa(PDYXnxNitfpL~U`99eOqhCV@|!I{d19zrcr<1jz%cpK%u ze3gD_EgM8S16PnO{|z@+C}1iMSzg-(P0I3Cj|=xT!PcXT-)92Rfgt^}1(VpotUxUk zd8S=Ia+v?00+81G>bLU*6vB6}XVW*qH{(21IYP?UFmV!?K4{~E*J_Hbw)yoqNpEd1;rn_ z;TOUc%Na?`@i#a5tNurB4X=F4GrP(vnH|;t98=F>x)sX{UT2@EMMI8-^0&yvobfCJRiRSRgPB52uT zg@DF*z;xxch;p_bmSO|l5 z+N9Gg6=0zh!YU*wIACR>rOn5J#6?Iav@w5cSt`kkbHU8lLT9^d40wp1i#xbc=Ih#E zr^SQG?+J4!6J^p7+161aF%(TRG#wMD!3n0HDW*9`)KUR^5=~nWhCVL?W8!nw|0)S4 z<-X)FfR9$d|5(H?zaDRYCl#fDt(`=d-|b1`wb!>1oXz(OM{>yY@ekY@ zjpAUeY@ulet^RKv`7xXWtd>jk=k~#sz{dM7rM?rf34TY3lq3z0^aD}+x&Cn1OB?j{ z1QSF{S+`~X;NP%RhqUQTo!MQCYYLp>Ue3Xr6ny~PJu;Y7`Ww{jk%i18UHXB^HhdWH z?fmw6hUZhPlb(MEfg;Y{)ooSn>8?HYx9+Bd4)*uzK9IeAm>mK2P zIFR-0R7tgmw1Y8ekA^1DTymEXjUkjbYeS}2tf!gQOtZ*Q?b6JZOKgLI`zN@zooqeB z0B?7w;eMBuXAn?@d#ASkS`+oxe&rCA_b1}$vqcr6IVg#6V~P?=yYH!?8`YUpW4 zrP3!cp#%h+v6hZ1kwNU)DnQY@$7KPPjb^Fa(>6~M5BuYY>5JgORNgEwX+f-M-h27% z$oiEAX5+VL_GmiAWhY&N4l@tw4Z^kJ|`H)5tA#t;p z6~&@oDGO+H{L`yE*56mLU%tnjLABE!`fd<$aAgVZqdMNsXhw+5eJcr)BUfeyD5D|a z9NTV;bpvlJ?EJ;5ilJ#JvT$EbB|dQXkJ16-oUX4VGX{4!w}Y8}6SEwAQURqYNP$W8 zurY@7jwqB7l&6I(b4NgND>#7lq?Lq~yOXx030vM7zcd&Qf{%t!ZWcBN24rS{Eur<2 zsR)aENuKsrPOjZ5t8uXZ*{~A2R7evFczbW#e|-CI9QLpC`@;%>7XTPA`rtZIc5MBt z3Nic+!JPye;OsT`c&zy@i7=gi46@(VX*ssXgQ}Ewuha`qfmdNLEyUY zU2Gv!HId~ka>6B%ilinZx+0DVoGq7AF!7Bl#k^I3@O%kF1~y6*GIf`rVs z{4E+r<)$;skR#s}5~RZ>!~}G6X)zxvLykh9ZXT_TNlnO!uB3(L44)X3`?!@F>p@d) zLZV*=maSjhS-(EaJ0kSH43!D97#6o3UYUEzVH5v!>*!3*`~F5b@Wi9z6_-#vQMq7&xmx&l-lvqZ-_KkYnHQ$WiCHsajb+|wI9}9|UlLVeZ-#gK57xy6 z4Gf(3oY4KCmacvl7YZ+yCM+G9O$;7`Pka(Ng-DSL*D<9;)0N!K2$PtGY>Rf+lr@?K zUmHGz)4SRIs?jaq!zt>mrfXlJghkRr@tdz=XF+x&Flz&yYneQjF`G@pX2c}vX2=J> zumf@hg{-s4h`fn7Cz($Un7RPEq03hQMe*n3wch_9(dzFrlrYia9tYBivQy8Qe(&Er zkp~{5KX*74qA8rP-o<;!rp$bG9ykbiKUnQPd`l|NoU)~P1;p^&mRhr=bQi&W7C*VR}(^e5L__wvmJ)cfo&5~0PHSV99jMZ}*FkURg~5;tc9E0PynZT#!$c$E*7zeeO-5;^PzgjXhT`gC!z4Qs)kd@0kK1Fw=UOx0dNdVBfeyE_nuxLRkZE(1Kg{4J2vDnDQS z0+`q&`V&&#;_UA^EHm#omQ@B5De|p>y}guig^=~f_bi3Zo58SA_QoNxPIzSIkjS6^v6AAx<_O%lmD?~W_Z*c4}w zeUHkG)3#-QycC$}c|iPC%JgYY;XFRmASJ@&&aG=SFzA0gnEuJ7JHjuw6LH-O0*aJw zHMHbO(FP!W%;cE0NTn|ZSkm@z#`M|emN~XtIOUSu$r$7!q3`4}n0>`7u`QJla4FCr zAqk>XEa%$dt_1mrfxm{b$=Heg^0PU6Qf*S$#^$L=*o`zR7o>)cgRuiX!w8gH%e7Hl zdn&vEOiY2k2)?w)mxjo_Z!{$Tt2E?_{CMor?gkb<_<0<~A^$ya%iUr4ps6l0f*#CX zAN+b2)S9f(_synVN^ii^Xu|=%m#hv5k)g3^WV=(Op~pkkQiimKl`i>=GwH)#u#+-{ z(3vcQjkAwR+D&<+-;L06%ZJwtAlg@mdNYcsMM?sAoLfHNAUx4bi~+ap-6mPsxiw^W zD8pcgMMGeDfvUW+H%sL@eLWt2yfn?M@1X?F`=ta9?a%)A!=RV^(uc1;XN`c$CpZw% z#Y?E3M%5}?l%9Hh(8+#^ye3wt_rwGdsZH?&echo14NuJe61(xF!uNGb)9pf9{q_%f9P635RXCO ziKfFyq<(1!ESp$fxu=-nisrED;narYyC|M^JjUC&pYw?GIvpE&9%V>6O}FZsxv%Ugp8)B0V?bks8@%P zTIX4^^(YxTz_ekNy|V+JNnMOt5PqGJc;F`E0W_eATSJ^uuUpTv^Ie)UpAh*bs)0x* zUzqECMa;7NrVL&bCaS�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

_tbCD^Xgn*IthD@ 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

vY#~FnT8Q!6$Lx?4v>S{w^)gR*Q%5wMq;_4 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 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/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/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/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/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") From 463a80bd1df67983cbceca25c68565b8351709c4 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:23 -0700 Subject: [PATCH 12/23] docs(features): rewrite the GLCM, GLRLM and GLSZM pages The three 2D texture families get pages in the family template, with checked worked examples, their settings (angles, offset, grey depth, IBSI mode) and their differences from IBSI and PyRadiomics measured. --- docs/examples/glcm_worked_example.py | 93 +++ docs/examples/glrlm_worked_example.py | 111 ++++ docs/examples/glszm_worked_example.py | 124 ++++ docs/source/Math/f_glcm.rst | 825 +++++++++++++++++++++++--- docs/source/Math/f_glrlm.rst | 580 +++++++++++++++++- docs/source/Math/f_glszm.rst | 587 +++++++++++++++++- docs/source/Math/glcm_index.inc | 162 +++++ docs/source/Math/glrlm_index.inc | 92 +++ docs/source/Math/glszm_index.inc | 92 +++ 9 files changed, 2524 insertions(+), 142 deletions(-) create mode 100644 docs/examples/glcm_worked_example.py create mode 100644 docs/examples/glrlm_worked_example.py create mode 100644 docs/examples/glszm_worked_example.py create mode 100644 docs/source/Math/glcm_index.inc create mode 100644 docs/source/Math/glrlm_index.inc create mode 100644 docs/source/Math/glszm_index.inc 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/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_worked_example.py b/docs/examples/glszm_worked_example.py new file mode 100644 index 000000000..fcf06139d --- /dev/null +++ b/docs/examples/glszm_worked_example.py @@ -0,0 +1,124 @@ +# 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) + assert f["GLSZM_LAE"][0] == wrapped, 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/source/Math/f_glcm.rst b/docs/source/Math/f_glcm.rst index adab56a8c..dadf95bb9 100644 --- a/docs/source/Math/f_glcm.rst +++ b/docs/source/Math/f_glcm.rst @@ -1,158 +1,821 @@ +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. +- **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 below, 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 the relative 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 fix is coming. + 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``, ``JVAR``, + ``HOM1``, ``HOM2``, ``DIFVAR`` and the inverse-difference features use positions 1 to + :math:`N_g` in that list. The two 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_glrlm.rst b/docs/source/Math/f_glrlm.rst index 35238b43c..2f19bfeb9 100644 --- a/docs/source/Math/f_glrlm.rst +++ b/docs/source/Math/f_glrlm.rst @@ -1,49 +1,567 @@ +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 the relative 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 reproduces the IBSI +consensus values on the IBSI digital phantom, and matches PyRadiomics on the same phantom +to a relative 10\ :sup:`-9`; run entropy agrees to 5 × 10\ :sup:`-3` because of the approximate +logarithm. 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_glszm.rst b/docs/source/Math/f_glszm.rst index ed3611eb8..124272a1c 100644 --- a/docs/source/Math/f_glszm.rst +++ b/docs/source/Math/f_glszm.rst @@ -1,47 +1,574 @@ +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_g` grey levels, as set by + ``--coarseGrayDepth`` (64 by default). A pixel 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. 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 the relative 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_g` 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. 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 checked against IBSI's published consensus values at a relative 10\ :sup:`-2`, the precision +those values are published to. 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/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/glrlm_index.inc b/docs/source/Math/glrlm_index.inc new file mode 100644 index 000000000..697119a38 --- /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 + - IBSI (rel. 1e-9) + - 0.2301 + * - :ref:`GLRLM_HGLRE ` + - High grey level run emphasis + - G3QZ + - IBSI (rel. 1e-9) + - 10.53 + * - :ref:`GLRLM_SRLGLE ` + - Short run low grey level emphasis + - HTZT + - IBSI (rel. 1e-9) + - 0.1513 + * - :ref:`GLRLM_SRHGLE ` + - Short run high grey level emphasis + - GD3A + - IBSI (rel. 1e-9) + - 8.694 + * - :ref:`GLRLM_LRLGLE ` + - Long run low grey level emphasis + - IVPO + - IBSI (rel. 1e-9) + - 0.9013 + * - :ref:`GLRLM_LRHGLE ` + - Long run high grey level emphasis + - 3KUM + - IBSI (rel. 1e-9) + - 21.53 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 From ac1948d06724299b0658d35ba7fcb33b063d22f7 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:23 -0700 Subject: [PATCH 13/23] docs(features): rewrite the GLDM, GLDZM, NGLDM and NGTDM pages The four remaining 2D texture families get pages in the family template, with checked worked examples, their neighbourhood and zone definitions, and their differences from IBSI and PyRadiomics measured. --- docs/examples/gldm_worked_example.py | 61 +++ docs/examples/gldzm_worked_example.py | 104 +++++ docs/examples/ngldm_worked_example.py | 62 +++ docs/examples/ngtdm_worked_example.py | 76 +++ docs/source/Math/f_gldm.rst | 513 ++++++++++++++++++--- docs/source/Math/f_gldzm.rst | 634 +++++++++++++++++++++----- docs/source/Math/f_ngldm.rst | 608 +++++++++++++++++++++--- docs/source/Math/f_ngtdm.rst | 398 +++++++++++++--- docs/source/Math/gldm_index.inc | 82 ++++ docs/source/Math/gldzm_index.inc | 102 +++++ docs/source/Math/ngldm_index.inc | 107 +++++ docs/source/Math/ngtdm_index.inc | 37 ++ 12 files changed, 2482 insertions(+), 302 deletions(-) create mode 100644 docs/examples/gldm_worked_example.py create mode 100644 docs/examples/gldzm_worked_example.py create mode 100644 docs/examples/ngldm_worked_example.py create mode 100644 docs/examples/ngtdm_worked_example.py create mode 100644 docs/source/Math/gldm_index.inc create mode 100644 docs/source/Math/gldzm_index.inc create mode 100644 docs/source/Math/ngldm_index.inc create mode 100644 docs/source/Math/ngtdm_index.inc 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_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/ngldm_worked_example.py b/docs/examples/ngldm_worked_example.py new file mode 100644 index 000000000..243e8dc95 --- /dev/null +++ b/docs/examples/ngldm_worked_example.py @@ -0,0 +1,62 @@ +# 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. +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} +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" +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" + +# 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_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/source/Math/f_gldm.rst b/docs/source/Math/f_gldm.rst index 0a80e465c..615918202 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 the relative 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 checked against 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. 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_gldzm.rst b/docs/source/Math/f_gldzm.rst index 89d9c1964..66e295ed2 100644 --- a/docs/source/Math/f_gldzm.rst +++ b/docs/source/Math/f_gldzm.rst @@ -1,186 +1,604 @@ +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_g` grey levels, as set by + ``--coarseGrayDepth`` (64 by default). A pixel 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. 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 the relative 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_g` 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`, and the four-slice means of 14 of them against +IBSI's published consensus values at a relative 10\ :sup:`-2`, the precision those values are +published to; ``GLDZM_SDLGLE`` and ``GLDZM_ZDV`` are checked against MIRP only. 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_ngldm.rst b/docs/source/Math/f_ngldm.rst index 22c58cb94..ae07d5c19 100644 --- a/docs/source/Math/f_ngldm.rst +++ b/docs/source/Math/f_ngldm.rst @@ -1,105 +1,599 @@ +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 the relative 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`. On the worked example NGLDM's levels are 12, 25, 38, 51 and 64; GLRLM's are 13, 26, + 39, 52 and 64. 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 checked against IBSI's published consensus values at a relative 10\ :sup:`-2`, the precision +those values are published to. 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_ngtdm.rst b/docs/source/Math/f_ngtdm.rst index 588c5620e..6df4cf16e 100644 --- a/docs/source/Math/f_ngtdm.rst +++ b/docs/source/Math/f_ngtdm.rst @@ -1,117 +1,387 @@ +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 fix is coming). 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 the relative 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 checked against IBSI's published consensus values at a relative 10\ :sup:`-2`, the precision +those values are published to. 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/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_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/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_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 From a23e98847ebf1f3e7aaef6276436e11a7d9a0e71 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:24 -0700 Subject: [PATCH 14/23] docs(features): add the 3D intensity, shape and texture pages New pages for the 3D intensity and shape families and the seven 3D texture families (GLCM, GLRLM, GLSZM, GLDM, GLDZM, NGLDM, NGTDM), in the family template with checked worked examples: how each differs from its 2D counterpart, which settings and metaparameters apply, anisotropy and out-of-core behaviour, and validation against MIRP, PyRadiomics and IBSI. --- docs/examples/glcm_3d_worked_example.py | 84 +++ docs/examples/gldm_3d_worked_example.py | 123 ++++ docs/examples/gldzm_3d_worked_example.py | 74 ++ docs/examples/glrlm_3d_worked_example.py | 91 +++ docs/examples/glszm_3d_worked_example.py | 128 ++++ docs/examples/intensity_3d_worked_example.py | 131 ++++ docs/examples/ngldm_3d_worked_example.py | 117 +++ docs/examples/ngtdm_3d_worked_example.py | 109 +++ docs/examples/shape_3d_worked_example.py | 145 ++++ docs/source/Math/f_glcm_3d.rst | 656 +++++++++++++++++ docs/source/Math/f_gldm_3d.rst | 427 +++++++++++ docs/source/Math/f_gldzm_3d.rst | 504 +++++++++++++ docs/source/Math/f_glrlm_3d.rst | 528 ++++++++++++++ docs/source/Math/f_glszm_3d.rst | 496 +++++++++++++ docs/source/Math/f_intensity_3d.rst | 725 +++++++++++++++++++ docs/source/Math/f_morphology_3d.rst | 539 ++++++++++++++ docs/source/Math/f_ngldm_3d.rst | 527 ++++++++++++++ docs/source/Math/f_ngtdm_3d.rst | 366 ++++++++++ docs/source/Math/glcm_3d_index.inc | 162 +++++ docs/source/Math/gldm_3d_index.inc | 82 +++ docs/source/Math/gldzm_3d_index.inc | 102 +++ docs/source/Math/glrlm_3d_index.inc | 92 +++ docs/source/Math/glszm_3d_index.inc | 92 +++ docs/source/Math/intensity_3d_index.inc | 192 +++++ docs/source/Math/ngldm_3d_index.inc | 107 +++ docs/source/Math/ngtdm_3d_index.inc | 37 + docs/source/Math/shape_3d_index.inc | 82 +++ 27 files changed, 6718 insertions(+) create mode 100644 docs/examples/glcm_3d_worked_example.py create mode 100644 docs/examples/gldm_3d_worked_example.py create mode 100644 docs/examples/gldzm_3d_worked_example.py create mode 100644 docs/examples/glrlm_3d_worked_example.py create mode 100644 docs/examples/glszm_3d_worked_example.py create mode 100644 docs/examples/intensity_3d_worked_example.py create mode 100644 docs/examples/ngldm_3d_worked_example.py create mode 100644 docs/examples/ngtdm_3d_worked_example.py create mode 100644 docs/examples/shape_3d_worked_example.py create mode 100644 docs/source/Math/f_glcm_3d.rst create mode 100644 docs/source/Math/f_gldm_3d.rst create mode 100644 docs/source/Math/f_gldzm_3d.rst create mode 100644 docs/source/Math/f_glrlm_3d.rst create mode 100644 docs/source/Math/f_glszm_3d.rst create mode 100644 docs/source/Math/f_intensity_3d.rst create mode 100644 docs/source/Math/f_morphology_3d.rst create mode 100644 docs/source/Math/f_ngldm_3d.rst create mode 100644 docs/source/Math/f_ngtdm_3d.rst create mode 100644 docs/source/Math/glcm_3d_index.inc create mode 100644 docs/source/Math/gldm_3d_index.inc create mode 100644 docs/source/Math/gldzm_3d_index.inc create mode 100644 docs/source/Math/glrlm_3d_index.inc create mode 100644 docs/source/Math/glszm_3d_index.inc create mode 100644 docs/source/Math/intensity_3d_index.inc create mode 100644 docs/source/Math/ngldm_3d_index.inc create mode 100644 docs/source/Math/ngtdm_3d_index.inc create mode 100644 docs/source/Math/shape_3d_index.inc 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/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/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/glrlm_3d_worked_example.py b/docs/examples/glrlm_3d_worked_example.py new file mode 100644 index 000000000..baf67c391 --- /dev/null +++ b/docs/examples/glrlm_3d_worked_example.py @@ -0,0 +1,91 @@ +# 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, 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") diff --git a/docs/examples/glszm_3d_worked_example.py b/docs/examples/glszm_3d_worked_example.py new file mode 100644 index 000000000..dcf1c375d --- /dev/null +++ b/docs/examples/glszm_3d_worked_example.py @@ -0,0 +1,128 @@ +# 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. +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" + +# 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" diff --git a/docs/examples/intensity_3d_worked_example.py b/docs/examples/intensity_3d_worked_example.py new file mode 100644 index 000000000..3b713f4e9 --- /dev/null +++ b/docs/examples/intensity_3d_worked_example.py @@ -0,0 +1,131 @@ +# 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] +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/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/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/shape_3d_worked_example.py b/docs/examples/shape_3d_worked_example.py new file mode 100644 index 000000000..fc90b54ac --- /dev/null +++ b/docs/examples/shape_3d_worked_example.py @@ -0,0 +1,145 @@ +# 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) +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", ".*") +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 + +# 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/source/Math/f_glcm_3d.rst b/docs/source/Math/f_glcm_3d.rst new file mode 100644 index 000000000..044896cf1 --- /dev/null +++ b/docs/source/Math/f_glcm_3d.rst @@ -0,0 +1,656 @@ +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 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("3glcm/greydepth")`` and ``get_metaparam("3glcm/offset")`` return the +2D GLCM settings, not the values set with ``3glcm/...``. ``--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 the relative 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. + +.. 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 (see + :ref:`glcm-possible-issues`). + +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. + + +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_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_3d.rst b/docs/source/Math/f_gldzm_3d.rst new file mode 100644 index 000000000..e9d899cd5 --- /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, and PyRadiomics-style binning at a bin count that merges +levels, are covered by regression tests only. + + +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_3d.rst b/docs/source/Math/f_glrlm_3d.rst new file mode 100644 index 000000000..c513bba88 --- /dev/null +++ b/docs/source/Math/f_glrlm_3d.rst @@ -0,0 +1,528 @@ +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 other directions give between 21 and 26 runs. 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 the relative 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_3d.rst b/docs/source/Math/f_glszm_3d.rst new file mode 100644 index 000000000..f7ca3f85c --- /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 the relative 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_intensity_3d.rst b/docs/source/Math/f_intensity_3d.rst new file mode 100644 index 000000000..06819a480 --- /dev/null +++ b/docs/source/Math/f_intensity_3d.rst @@ -0,0 +1,725 @@ +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 5 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 the relative 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, 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: 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, 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_morphology_3d.rst b/docs/source/Math/f_morphology_3d.rst new file mode 100644 index 000000000..9b943b6b0 --- /dev/null +++ b/docs/source/Math/f_morphology_3d.rst @@ -0,0 +1,539 @@ +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 (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)`. ``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. 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 the relative 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) 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. + +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_3d.rst b/docs/source/Math/f_ngldm_3d.rst new file mode 100644 index 000000000..1c0211d69 --- /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 the relative 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_3d.rst b/docs/source/Math/f_ngtdm_3d.rst new file mode 100644 index 000000000..a13c56899 --- /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 1 reproduces PyRadiomics with + ``binWidth=1``; with any other 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 the relative 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 `, divided by 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 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/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/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/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/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/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/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/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/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/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 From 1ab5e04519cad89863f929f06588b354729cd114 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:24 -0700 Subject: [PATCH 15/23] docs(api): rewrite the Python docstrings in numpydoc style and check them against the code The docstrings of the nyxus module are rewritten in numpydoc style and corrected against the code: defaults, accepted values, group names, case sensitivity, the memory limit, anisotropy, GPU selection, Raises sections and return values. The API reference pages render them. test_docstring_examples_mechanics.py runs every docstring example and test_api_arguments_mechanics.py checks the documented argument errors. Docstring-only: the docstring-stripped syntax trees of nyxus.py and functions.py are unchanged. --- docs/source/References/Classes.rst | 18 +- docs/source/api_reference.rst | 10 + src/nyx/python/nyxus/functions.py | 41 + src/nyx/python/nyxus/nyxus.py | 2652 +++++++++++------ tests/python/test_api_arguments_mechanics.py | 31 + .../test_docstring_examples_mechanics.py | 68 + 6 files changed, 1951 insertions(+), 869 deletions(-) create mode 100644 docs/source/api_reference.rst create mode 100644 tests/python/test_api_arguments_mechanics.py create mode 100644 tests/python/test_docstring_examples_mechanics.py 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/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..1bee8eff6 100644 --- a/src/nyx/python/nyxus/nyxus.py +++ b/src/nyx/python/nyxus/nyxus.py @@ -28,134 +28,232 @@ 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). + 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. + 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. + 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. + 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 +319,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 +379,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 +427,40 @@ 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/*``. ``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 +475,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, matched against + file names 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 + fix is coming). 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 +594,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 + fix is coming). + 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 +758,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 +789,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 + fix is coming). 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 +890,37 @@ 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"``. + + 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 +930,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 +974,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 +1063,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 +1166,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 +1254,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 +1286,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 +1319,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 +1353,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 +1371,155 @@ 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. + 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. + 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 +1579,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 +1635,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 +1679,41 @@ 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/*``. ``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 +1728,54 @@ 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, matched against file names 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 + fix is coming). 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 +1819,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 + fix is coming). 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 +1914,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 +2014,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 +2083,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 +2115,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 +2148,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 +2182,107 @@ 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 fix is coming. 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 +2342,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 +2400,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, matched against + file names 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 + fix is coming). 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 +2505,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 + fix is coming). + 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 +2664,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 + fix is coming). 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 +2757,37 @@ 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"``. + + 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 +2797,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 +2841,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 +2930,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 +3028,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 +3106,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 +3138,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 +3196,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 +3258,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/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_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) From b141226e82e0798dc252237ba5dc8930acd9ff75 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Wed, 14 Oct 2026 18:04:24 -0700 Subject: [PATCH 16/23] docs(devguide): rewrite the developer guide and correct the project notes The developer guide is rewritten for the current architecture: the run flow, adding a feature or an option, settings and metaparameters, the dispatch to in-memory, out-of-core and GPU paths, testing and vetting, CI, the documentation tooling and releases. CONTRIBUTING.md is new; the contributor notes and the vetting README, SPEC, MIGRATION and TOOLS are corrected where they disagreed with the code, and every build recipe uses build-cli with an explicit Release build. The pipeline diagram is redrawn to match the code, and a coverage-registry row and the fixture READMEs are corrected. --- CLAUDE.md | 97 +- CONTRIBUTING.md | 244 +++++ docs/source/devguide.rst | 1425 +++++++++++++++++++++++++---- docs/source/nyxus_workflow.jpg | Bin 254319 -> 0 bytes docs/source/nyxus_workflow.png | Bin 0 -> 29209 bytes docs/source/nyxus_workflow.svg | 60 ++ tests/data/hounsfield/README.md | 30 +- tests/data/omezarr/README.md | 36 +- tests/vetting/MIGRATION.md | 45 +- tests/vetting/README.md | 52 +- tests/vetting/SPEC.md | 144 +-- tests/vetting/TOOLS.md | 2 +- tests/vetting/oracle_coverage.csv | 2 +- tests/vetting/report_output.csv | 2 +- 14 files changed, 1816 insertions(+), 323 deletions(-) create mode 100644 CONTRIBUTING.md delete mode 100644 docs/source/nyxus_workflow.jpg create mode 100644 docs/source/nyxus_workflow.png create mode 100644 docs/source/nyxus_workflow.svg 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/docs/source/devguide.rst b/docs/source/devguide.rst index 6ca10900e..a755ff8e9 100644 --- a/docs/source/devguide.rst +++ b/docs/source/devguide.rst @@ -1,271 +1,1366 @@ -.. 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), +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`` (the source line that assigns a non-empty default; + ``"-"`` 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_2d_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/nyxus_workflow.jpg b/docs/source/nyxus_workflow.jpg deleted file mode 100644 index 922ed9940f545e87f6dbc9d31b5df00cfb1d555e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 254319 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φ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