Skip to content

feat(io): Native 5D IO + True 3D Out-of-Core Featurization - #389

Open
darkclad wants to merge 52 commits into
PolusAI:mainfrom
darkclad:main-io
Open

feat(io): Native 5D IO + True 3D Out-of-Core Featurization#389
darkclad wants to merge 52 commits into
PolusAI:mainfrom
darkclad:main-io

Conversation

@darkclad

@darkclad darkclad commented Jul 23, 2026

Copy link
Copy Markdown

Status: feature-complete and validated. This document is the consolidated, self-contained
write-up of the feature — design, what shipped, test results, and usage — including the two
out-of-core design notes as appendices.

This work delivers two related-but-distinct pieces of work that grew out of the same effort:

  1. Native 5D IO — Nyxus reads X,Y,Z,C,T directly from OME-TIFF and OME-Zarr metadata instead
    of assuming a single 2D plane / hard-coded axis order.
  2. True 3D out-of-core (OOC) featurization — a disk-streaming path for every 3D feature
    family (previously 3D only worked if the whole volume fit in RAM), discovered to be necessary
    once native 5D reading exposed that the volumetric pipeline had no real streaming path.

1. Design

1.1 One descriptor, many producers

All axis knowledge funnels through a single format-agnostic struct, OmeAxes
(src/nyx/ome/ome_axes.h): a dimensionOrder string (e.g. "XYZCT") plus a storageAxes
vector carrying kind/label/size/physical-size/unit per axis, independent of on-disk order,
with a storageIndexOf(char label) lookup. Sizes are resolved by axis role, not by fixed
position — this is what makes TCZYX, TZCYX, CZYX, ZYX, and YX all correctly readable
through the same code path, replacing the old hard-coded "always TCZYX, always plane 0" reader.

OME-TIFF populates OmeAxes from the OME-XML DimensionOrder/TiffData block; OME-Zarr
populates it from the NGFF axes + multiscales metadata. Everything downstream — the loader
facades, the 3D consumer (phase2_3d.cpp), the output schema, SlideProps — only ever consumes
OmeAxes, never format-specific metadata directly.

1.2 Design principles

  • Additive, not disruptive. Existing 2D/2.5D/NIfTI paths are untouched; new behavior only
    triggers when OME metadata is actually present. Every phase was independently shippable.
  • Symmetry across the two loader stacks. Every interface change (e.g. axis-aware
    loadTileFromFile(...,channel,timeframe,level)) was applied to both AbstractTileLoader and
    RawFormatLoader, and to all three format-dispatch sites, to avoid the build fragmenting into
    two half-migrated stacks.
  • Channel is a first-class but optional axis. Output defaults to per-channel
    featurization (one row per ROI × timepoint × channel) to preserve information rather than
    silently collapsing channels.
  • Physical calibration flows from metadata, overridable by CLI. Precedence is: explicit
    CLI --anisox/y/z > OME PhysicalSize* (opt-in via --use-physical-spacing) > isotropic
    1.0. Reconciled in one place (resolve_slide_anisotropy).
  • Zarr version isolation. Zarr 0.4 (v2) keeps the existing vendored z5 library
    unconditionally; Zarr 0.5 (v3 + sharding) support was added behind the same code path once it
    was discovered z5 3.0.0 already reads v3 natively — the original design called for a new
    tensorstore dependency for this, which turned out to be unnecessary.

1.3 Chunking convention

Every OME-Zarr fixture in this project follows one Z-plane per chunk. This is what makes
plane-by-plane out-of-core streaming (stream_volume_planes) possible: Nyxus reads exactly one
Z-plane per tile request. A store chunked with Z-depth > 1 per chunk is still supported for
in-RAM reads
(fixed in a bug described in §3.1), but a store where a chunk spans multiple Z
AND cannot be read plane-by-plane
is correctly refused for OOC streaming with an explicit
error ("the volume is not delivered plane-by-plane") — the same category of guard used for
genuinely unstreamable formats like NIfTI (see §1.5).

1.4 Anisotropic resampling

Nyxus's anisotropic path works by resampling (nearest-neighbor duplication along the
coarser axis), not by carrying absolute physical units through every calculation. So when
--use-physical-spacing is used, the OME PhysicalSizeX/Y/Z values are converted to a ratio
normalized so the minimum axis = 1
(e.g. Z spacing 4× coarser than X/Y becomes a resampling
factor of 4, not "4.0 micrometers"). Physical units across axes are canonicalized to
micrometers before the ratio is computed, so a file with Z calibrated in millimeters and X/Y in
micrometers is still handled correctly.

1.5 Out-of-core streaming design

This was not part of the original plan — it emerged mid-project once native 5D reading
surfaced that the 3D texture feature families had no disk-streaming path at all (large 3D ROIs
either fit in RAM or the run failed). The full original design reasoning is embedded as
Appendix A (intensity/
histogram/surface streaming foundation) and Appendix B
(the GLSZM/GLDZM connected-component design).

  • OutOfRamVoxelCloud (voxel_cloud_nontriv.{h,cpp}) — a disk-backed 3D voxel source, the
    3D analog of the existing 2D OutOfRamPixelCloud, with a block cache (65536 records/block) so
    reads aren't done one fseek/fread per voxel.
  • Every 3D feature family streams differently, chosen per the family's actual data-dependency
    shape:
    • Intensity / histogram — stream the voxel cloud directly.
    • Surface — per-plane contours via read_slab(z); surface area reformulated as
      6N − 2·(adjacent voxel pairs) over a 2-plane occupancy-bitmap window; PCA axis lengths
      accumulated online; hull volume via det4.
    • GLCM — a ring of (offset+1) dense grey-binned Z-planes.
    • GLDM — 26-neighborhood matrix over a 3-plane sliding window.
    • NGLDM — per-interior-voxel 24-neighborhood.
    • NGTDM — a (2·radius+1)-plane streaming window.
    • GLRLM — all 13 direction shifts have dz ∈ {0,1}; dz=0 reuses the existing
      same-plane run-gathering code, dz=1 uses a "carry" array tracking in-progress runs across
      a 2-plane window.
    • GLSZM / GLDZM — both reduce to the same mechanism: streaming two-pass connected-
      component labeling
      via a growable union-find (features/streaming_ccl.h). The key
      insight, elaborated in Appendix B, is that only causally-earlier neighbors (13 for
      26-connectivity, 3 for 6-connectivity) need to be checked in scan order, which is what makes
      connected-component labeling streamable at all.
  • Whole-volume OOC (no mask, entire image treated as one ROI) shares the same underlying
    primitives as the segmented (masked ROI) OOC path via populate_3d_voxel_cloud /
    run_3d_ooc_features.
  • Guard: any 3D feature not yet ported to a streaming implementation raises a clear
    "not yet supported out-of-core" error (per-feature), rather than silently producing wrong
    values. The only unconditional loud-fail is for loader formats that cannot deliver data
    plane-by-plane at all (currently: NIfTI, which loads its whole 4D blob in one read).

1.6 Memory-limit / RAM budgeting design

--ramLimit (CLI) / ram_limit (Python) sets the threshold above which an ROI or whole volume
is treated as "oversized" (streamed OOC) rather than trivial (loaded whole). Default, if not
specified, is 50% of available system RAM (getAvailPhysMemory()); requesting a limit above
actual available RAM is rejected rather than silently overcommitting.


2. Features delivered

  • Native 5D reading (X,Y,Z,C,T) for both OME-TIFF (strip and tiled, OME-XML
    DimensionOrder-driven) and OME-Zarr (both v2/"0.4" and v3/"0.5", both via the existing
    z5 library — no new dependency), replacing the old assumption of a single (c=0,t=0) plane.
  • Per-channel, per-timepoint output: one row per (ROI, t, c). New c_index output column
    (alongside the pre-existing t_index) in every sink — CSV, in-memory buffer, and Arrow.
  • Physical calibration, opt-in via --use-physical-spacing / use_physical_spacing=True:
    new phys_x, phys_y, phys_z (numeric) and phys_unit (string) output columns, populated
    from OME PhysicalSize*/coordinateTransformations metadata and canonicalized to
    micrometers across mismatched per-axis units. Manual override via --anisox/y/z still works
    and takes precedence, unchanged from before this project.
  • True out-of-core 3D featurization, both workflows (segmented ROI and whole-volume, no
    mask), across all 7 texture feature families plus intensity and histogram — previously
    3D OOC did not exist at all; oversized 3D input either loaded fully into RAM or failed.
  • Anisotropic resampling now works correctly for both whole-volume and segmented 3D
    workflows (a hang and 3 related correctness bugs in the whole-volume path were found and
    fixed — see §3.1).
  • OME-Zarr v3 (0.5) support, including sharded stores, via the same z5-based reader used
    for v2 — the version is auto-detected (zarr.json vs .zattrs).

Not delivered / explicitly out of scope (see §7).


3. What was done

Work proceeded in two overlapping tracks. Numbers below are feature/bug counts; full narrative
detail is captured below and in the appendices.

3.1 Native 5D IO track (delivered across eight incremental phases)

  1. OmeAxes descriptor + format detection (detect_input_format) for both containers.
  2. Axis-aware loader interface (loadTileFromFile(..., channel, timeframe, level)) applied
    symmetrically across both loader stacks (10 concrete loader overrides).
  3. OME-Zarr native 5D via axis roles instead of fixed shape indices.
  4. OME-TIFF native 5D via OME-XML DimensionOrder (strip loaders, then tiled loaders).
  5. Output schema: c_index column, per-channel default policy.
  6. Physical calibration: --use-physical-spacing, phys_*/phys_unit columns, unit
    canonicalization.
  7. OME-Zarr 0.5 (v3/sharding) support — discovered z5 3.0.0 reads v3 natively, so the originally
    planned tensorstore dependency was never added.
  8. HCS / multi-file / labels-group masks — scoped, then explicitly declined (§7).

Along the way, this track found and fixed real bugs, independent of the OOC track: 3D whole-
volume featurization was completely non-functional at first (extract() called a stub that
always threw); a 3-layer prescan bug read out-of-bounds garbage and exposed a use-after-free in
a TIFF strip loader's buffer lifecycle; a mask read used the intensity channel index, dropping
ROIs for channel > 0; a Python binding call site was left on the old (pre-5D) argument count;
an OME-Zarr loadTile() always read exactly one Z-plane regardless of the chunk's actual
Z-depth (any store chunked with Z>1 silently returned zero past the first plane of each chunk);
COVERED_IMAGE_INTENSITY_RANGE was degenerate (always exactly 1.0) for any segmented 2D or
3D image, because the "whole slide" intensity baseline was computed through a voxel-visitor
that (by contract) only visits in-mask voxels — fixed for both 2D and 3D; physical-size units
were stored verbatim with no cross-unit conversion; the whole-volume anisotropic-resampling path
had a hang (a loop control variable was clobbered mid-body) plus three follow-on correctness
bugs surfaced while fixing it — a wrong row stride, a stale bounding box left over from before
resampling, and a stale voxel count that made 3MEAN come out exactly 4x too large — all four
fixed, and the fix was extended to the equivalent segmented-ROI code path once the same gap was
found there; and a size_t underflow in the ROI memory-footprint estimator (triggered whenever
the running count was zero) had been silently defeating ROI batching for essentially all
segmented 2D/2.5D/3D runs — not a crash or wrong output, but a performance/memory-management
regression, fixed narrowly without changing batching semantics further.

3.2 True 3D out-of-core track (not part of the original plan)

Delivered incrementally, one feature family (or family group) per increment:

  1. Intensity/histogram streaming (the disk-backed voxel cloud and plane-by-plane streaming
    primitive, detailed in Appendix A).
  2. Surface (area / PCA axis lengths / hull volume).
  3. GLCM, GLDM, NGLDM, NGTDM.
  4. GLRLM (plus two latent correctness bugs found and fixed: the GLCM/GLDM grey-level lookup
    table was built from mask-only voxels instead of including background voxels the way the
    in-core calculation does; GLRLM conflated two different IBSI-related flags between lookup-
    table construction and row lookup).
  5. GLSZM + GLDZM (detailed in Appendix B; both passed their out-of-core-vs-in-RAM equivalence
    tests on the first try, attributed to scoping the streaming design in a written note before
    writing code).
  6. A dedicated negative-path testing pass, prompted by asking "do we have negative tests for
    out-of-core features?" — the answer was no, so tests were added for degenerate/constant-
    intensity ROIs, unsupported-format loud-failure, and the unsupported-feature guard. One of
    these caught a real bug: EXCESS_KURTOSIS used an algebraic shortcut that diverges from the
    correct value specifically on zero-variance data.
  7. Whole-volume out-of-core (previously only the segmented/masked-ROI path streamed;
    whole-volume input just failed loudly on an oversized volume).

26 real bugs were found and fixed across both tracks over the course of this project; none
were caught by CI before being found here — partway through, this codebase was resynced onto
16 commits of upstream churn, which required 2 additional semantic (non-textual) fixes to
reconcile an upstream PCA axis-length fix and a new upstream test's changed function signature
with the OOC/channel additions described here.

3.3 Final validation phase

The final planned validation phase — a 2D/3D/4D/5D scale/stress test on both Windows and a
memory-constrained Linux VM — had never been executed before the final session of this project.
It is now done; see §4 for results. This was the first time multi-channel or multi-timepoint
data (C>1 or T>1) had been exercised at real scale on either platform in this project.


4. Tests

4.1 Automated suites (Windows, current baseline)

Suite Result
build-test (gtest, no z5) 799 / 799
build-test-z5 (gtest, with z5/OME-Zarr) 852 / 852
pytest tests/python/ 96 passed, 1 skipped
Phase-0 oracle (io_oracle/, independent tifffile/ome-zarr-py cross-check) PASS 21 / FAIL 0

New gtest coverage added by this project includes: whole-volume + segmented anisotropic
resampling correctness (TEST_3D_WHOLEVOLUME_ANISOTROPIC_REDUCE_MATCHES_ISOTROPIC,
TEST_3D_SEGMENTED_ANISOTROPIC_AUX_AREA_MATCHES_VOXELCLOUD), the n_rois==0 footprint-
estimate underflow guard, degenerate/constant-intensity OOC equivalence, unsupported-format
loud-failure, and the unsupported-feature OOC guard.

4.2 Manual memory/scale validation (Linux VM, 3.8 GB RAM / 2 cores)

Used throughout the OOC track to prove memory-boundedness under real memory pressure
(not just a --ramLimit argument), including one case where the in-RAM path was OOM-killed
by the kernel while the OOC path on the same fixture succeeded:

Fixture OOC peak RSS In-RAM peak RSS Values
Single oversized ROI, 46.1M voxels (~2.21 GB footprint) — intensity/histogram 886 MB 2936 MB byte-identical
Same ROI, surface features only 656 MB OOM-killed (3530 MB) 3AREA exact match to analytical formula
Same ROI, all 4 GLCM/GLDM/NGLDM/NGTDM families 669 MB 3095 MB byte-identical
GLRLM (separate ellipsoid-mask fixture, real background) 849 MB 3275 MB byte-identical
Same ellipsoid fixture, all 7 texture families together 940 MB 3275 MB byte-identical
Whole-volume, no mask (2.21 GB) 365 MB 2641 MB byte-identical
4D-T / 4D-C / 5D (~0.75 GB per (c,t) frame) 200–232 MB 910–941 MB byte-identical;3MEAN exact match to a hand-derived closed form for all frames

The 4D/5D result confirms peak RSS does not grow with frame count (3 vs. 4 frames gave
nearly identical peaks in both OOC and in-RAM modes) — no cross-frame memory accumulation in
either path.

4.3 Windows format-parity validation

A 5D (T=2, C=2, Z=60, Y=512, X=512) fixture generated as both OME-TIFF and OME-Zarr:
forced-OOC (ram_limit=200) vs. generous in-RAM (ram_limit=8000) — 4/4 rows, exact match to
the same hand-derived formula, np.allclose on every numeric column, for both formats.


5. How to use, with examples

5.1 CLI

Standard flags (--intDir, --segDir, --outDir, --filePattern, --outputType,
--features, --resultFname) are unchanged. New/relevant flags for this feature:

Flag Meaning
--dim=3 3D (volumetric) mode
--ramLimit=<MB> Threshold above which an ROI/volume streams out-of-core instead of loading whole. Default: 50% of available RAM.
--anisox, --anisoy, --anisoz Manual per-axis resampling factors (unchanged from before this project).
--use-physical-spacing Opt-in: derive resampling factors from the file's own OMEPhysicalSize* metadata instead of manual --aniso*.

Example — native 3D OME-TIFF, in-RAM, per-channel/per-timepoint output:

nyxus --dim=3 --intDir=/data/vol_intensity --segDir=/data/vol_masks --outDir=/output \
      --filePattern=".*\.ome\.tif" --features=*3D_ALL* --outputType=singlecsv \
      --resultFname=3d-features

Output rows now include one row per (ROI, t_index, c_index) for every channel/timepoint
found in the file's OME metadata — previously only (c=0,t=0) was ever read.

Example — same, but forced out-of-core (large volumes, constrained memory):

nyxus --dim=3 --intDir=/data/big_vol --segDir=/data/big_masks --outDir=/output \
      --filePattern=".*\.ome\.zarr" --features=*3D_ALL* --ramLimit=500 \
      --resultFname=3d-features-ooc

Any ROI (or the whole volume, in whole-volume mode) whose estimated footprint exceeds 500 MB
streams from disk instead of loading fully into RAM. Output is numerically identical to the
in-RAM path.

Example — physical-spacing-driven anisotropic resampling (opt-in):

nyxus --dim=3 --intDir=/data/calibrated --segDir=/data/calibrated_masks --outDir=/output \
      --filePattern=".*\.ome\.tif" --features=*3D_ALL* --use-physical-spacing \
      --resultFname=3d-features-calibrated

Resampling factors are derived from the file's own PhysicalSizeX/Y/Z (converted to a common
unit, then ratio-normalized) instead of requiring manual --anisox/y/z. Output gains phys_x,
phys_y, phys_z, phys_unit columns.

Example — manual anisotropy override (pre-existing, still supported, e.g. for NIfTI):

nyxus --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

5.2 Python API (Nyxus3D)

import nyxus

# Directory-of-files, out-of-core, per-channel/per-timepoint output
nyx = nyxus.Nyxus3D(["*3D_ALL*"], ram_limit=500)
df = nyx.featurize_directory(
    intensity_dir="/data/big_vol",
    label_dir="/data/big_masks",
    file_pattern=".*\\.ome\\.zarr",
)

# Whole-volume (no mask), explicit file lists, single-ROI mode
nyx = nyxus.Nyxus3D(["*3D_ALL*"], ram_limit=500)
df = nyx.featurize_files(
    intensity_files=["/data/vol.ome.tif"],
    mask_files=[],          # ignored when single_roi=True
    single_roi=True,
)

# Physical-spacing-driven anisotropic resampling
nyx = nyxus.Nyxus3D(["*3D_ALL*"], use_physical_spacing=True)
df = nyx.featurize_directory("/data/calibrated", "/data/calibrated_masks", ".*\\.ome\\.tif")

# Manual anisotropy override (equivalent to --anisox/y/z)
nyx = nyxus.Nyxus3D(["*3D_ALL*"], anisotropy_x=1.5, anisotropy_y=2.0, anisotropy_z=2.5)

Relevant Nyxus3D constructor kwargs: ram_limit (int, MB; default -1 = no limit/whole-RAM),
use_physical_spacing (bool, default False), anisotropy_x/y/z (float, default 1.0).

Gotcha: ram_limit, use_physical_spacing, and the anisotropy factors are constructor-only
set_environment_params(**params) does not accept them and always resets ram_limit to
"no limit" internally. To change these, construct a new Nyxus3D instance.


6. Changes to current usage

  • Input format coverage expanded. Nyxus can now natively read 3D/4D/5D volumes directly
    from OME-TIFF and OME-Zarr (both v2/"0.4" and v3/"0.5"/sharded), not just from NIfTI. The
    in-repo README.md ("Nyxus also reads compressed and uncompressed NIFTI 3D files") and
    CLAUDE.md ("3D from NIFTI...") summaries predate this project and have not been updated
    — they describe the prior state, not current capability. Anyone updating those files
    should add native 3D OME-TIFF/OME-Zarr reading, --use-physical-spacing, out-of-core 3D
    featurization, and the c_index/phys_* output columns.
  • New output columns, present in all sinks (CSV, buffer, Arrow), additive/non-breaking to
    existing consumers that read by column name: c_index (always present for 3D output now,
    even single-channel files get c_index=0), phys_x, phys_y, phys_z, phys_unit
    (populated when --use-physical-spacing/use_physical_spacing=True, otherwise default
    values).
  • Multi-channel/multi-timepoint files now produce multiple rows per ROI (one per (t,c))
    where previously only the first plane's data was read — existing pipelines that assumed
    exactly one row per ROI for 3D input will see more rows for any file with C>1 or T>1. This is
    the "per-channel" default policy; there is no config to collapse channels back to one row —
    a select=/reduce= channel-policy mode was designed early on but never implemented, only
    perChannel shipped.
  • 3D no longer requires the whole volume to fit in RAM. Previously, an oversized 3D
    ROI/volume either loaded fully into RAM (risking OOM) or, for whole-volume mode, failed
    outright. Now --ramLimit/ram_limit governs a genuine disk-streaming path with identical
    output to in-RAM, for all feature families. This is a strict capability addition — behavior
    for inputs that already fit under the RAM limit is unchanged.
  • A pre-existing 2D-and-3D bug was fixed: COVERED_IMAGE_INTENSITY_RANGE previously always
    computed to exactly 1.0 for any segmented (non-whole-slide) image, in both 2D and 3D. It now
    reflects the true ratio of the ROI's intensity range to the whole slide's. Any downstream
    analysis that depended on the old (always-1.0) value will see different numbers after
    updating past this fix — this is a correctness fix, not a new feature, but is a behavior
    change on data that hasn't changed
    .
  • A pre-existing performance bug was fixed: LR::get_ram_footprint_estimate(_3D)
    underflowed at n_rois==0, which defeated ROI batching for essentially all segmented 2D/2.5D/
    3D runs (every ROI was processed as a batch of one instead of being grouped). Fixed; segmented
    runs may now batch ROIs together where they previously didn't, which can change wall-clock
    time but not output values.
  • No breaking changes to existing 2D/2.5D/NIfTI-3D usage. All changes were additive by
    design (§1.2); the full existing gtest/pytest suites pass unchanged.

7. Explicitly out of scope (declined, not deferred by oversight)

Decided 2026-07-22, after explicit scoping discussion.

  • Multi-file OME-TIFF (<TiffData>/<UUID> cross-file plane references,
    .companion.ome). Detected (multiFileTiff flag) and rejected, never resolved — real
    support would need coordinating multiple open loader instances per logical dataset, a
    structural change rather than a bolt-on.
  • HCS (plate/well) layouts. Never attempted — a fundamentally different discovery/
    addressing model, by far the largest of the three declined items.
  • OME-Zarr labels/-group masks. Declined despite being comparatively small, to keep the
    format-coverage story consistent; Nyxus's existing two-file (separate intensity/mask)
    convention remains the only supported pairing.

None of the three are required by this project's actual goal (native 5D single-image reading +
out-of-core featurization, already delivered). Revisit only if a real dataset needs one of
these.

Other known gaps, carried over rather than newly introduced:

  • GLDZM's dist2border remains 2D-only (a pre-existing limitation, replicated not fixed in
    the new streaming implementation).
  • OME-TIFF explicit TiffData plane maps and SubIFD pyramids are not parsed — reads fall
    back to default rasterization.
  • Whether real-world production data actually needs Zarr v3/sharding urgently was never
    resolved either way — it was implemented regardless since it came at no extra cost once z5's
    existing v3 support was discovered.

8. Open backlog (not blocking, not yet actioned)

  • Batching-margin semantics: processTrivialRois/_25D/_3D's in-progress-batch-size
    callers pass Pending.size() (starts at 0 for every batch) to the footprint estimator, while
    other callers correctly pass the total slide ROI count (env.uniqueLabels.size()). The
    n==0 underflow this exposed is fixed (§3.1), but whether Pending.size() is the
    semantically right quantity for the neighbors-margin term is an open design question, not
    filed as a bug.

Appendix A: Design note — true out-of-core for 3D/4D/5D (foundation)

Original scoping note for the first out-of-core streaming increment: the disk-backed 3D voxel
source and streaming population, plus the first feature wins (intensity, histogram, surface).
Later families (GLCM/GLDM/NGLDM/NGTDM/GLRLM/GLSZM/GLDZM) reuse the same primitive and were
designed in follow-on notes (GLSZM/GLDZM's is Appendix B). All of this was implemented as
described; kept here as-written for the design rationale, not as a status report — see §3–4
above for what actually shipped.

A.1 The problem

A 3D segmented ROI whose footprint exceeds the RAM limit was routed to the same nontrivial-ROI
processing function used by the 2D path. That function scanned with a 2D-only tile loop
no Z loop, and its pixel record had no Z coordinate. So for a 3D ROI the on-disk cloud collapsed
to a single 2D plane (Z was discarded and only plane 0 was ever read). Every downstream 3D
feature that consumed it was therefore wrong or fell back to requiring the whole volume in RAM:

  • Intensity / histogram: read the (2D, plane-0) disk cloud for mean/energy/centroid/moments
    and the full in-RAM voxel vector for variance/MAD/median-AD/robust-mean. It only completed
    when the whole cube fit — i.e. it was not out-of-core at all.
  • Texture (GLCM/GLRLM/GLDM/GLDZM/GLSZM/NGLDM): the out-of-core entry point was a stub
    falling through to the whole-cube trivial calculation. Surface: the out-of-core entry
    point was empty.
  • Disk primitives were all 2D/Z-less: the existing out-of-core pixel cloud and its
    read/write matrix helpers had no Z dimension. No disk-backed 3D voxel source existed.

The full in-RAM voxel vector was the single object forcing the whole cube into RAM. Goal:
eliminate that dependency for intensity, histogram, and surface by streaming voxels off disk,
with bounded resident memory.

A.2 The foundation — a disk-backed 3D voxel cloud

A Z-slab-paged, disk-backed 3D voxel source — the direct 3D analog of the existing 2D
out-of-core pixel cloud — storing (x, y, z, intensity) records instead of (x, y, intensity).
Implemented as a new, separate primitive so the existing 2D path stays untouched.

On-disk layout — exploit Z-ascending population. The streaming population visits planes in
ascending Z, so records naturally arrive Z-sorted; they are simply appended, with an in-RAM
index remembering where each slab begins:

file: <one temp file per ROI per thread>
  record  = { int64 x, int64 y, int64 z, uint32 intensity }
  records written strictly in ascending z (then arbitrary y,x within a plane)

in-RAM index (built during population, negligible — one entry per Z-plane):
  slab_first[z] = index of first record with that z
  slab_count[z] = number of records in plane z  (0 for empty planes)

Depth is thousands at most, so the index is a few tens of KB; everything else stays on disk.

API shape:

class OutOfRamVoxelCloud
{
public:
    void   init (unsigned int roi_label, const std::string& name);
    void   close();
    void   clear();

    // Population (called in ascending-z order by the streaming scan)
    void   begin_slab (size_t z);
    void   add_voxel (const Pixel3& v);
    void   end_slab (size_t z);

    // Whole-cloud linear access (intensity / histogram — z-agnostic scalars)
    size_t size() const;
    Pixel3 get_at (size_t idx) const;
    struct iterator { ... };  iterator begin() const; iterator end() const;

    // Slab access (surface: 2-slab window; texture later: k-slab window)
    size_t depth() const;
    void   read_slab (size_t z, std::vector<Pixel3>& out) const;
    void   read_slab_window (size_t z0, size_t z1, std::vector<Pixel3>& out) const;
};

read_slab's resident set is one plane's ROI voxels, never the cube.

A.3 Streaming population

The existing nontrivial-ROI processing function is shared between 2D and 3D and could not
simply gain a Z-loop without risking the byte-identical 2D behavior already validated. So a
sibling 3D-specific population function was added instead, called from the 3D segmented
workflow's oversized branch in place of the 2D one — the 2D path is left exactly as-is.

The scan mirrors the loader's own volume-assembly Z-loop, but writes to disk per-plane instead
of into one giant in-RAM buffer:

for lz in 0 .. numberTileDepth-1:                    # z-slab of the loader
    cloud.begin_slab(gz)
    for (tr, tc) over the plane's tile grid:
        intFL->loadTileFromFile(ptrI, tr, tc, lz, channel, timeframe, level)
        segFL->loadTileFromFile(ptrL, tr, tc, lz, mask_channel, mask_timeframe, level)
        for each voxel in tile:
            if segL == r.label:
                cloud.add_voxel( Pixel3(globalX, globalY, gz, intI) )
    cloud.end_slab(gz)

Resident set per iteration is the plane's tile buffers (already allocated by the loader) plus
the current slab's ROI voxels being written — no whole-volume buffer is ever allocated,
which is the crux of the memory win. Channel/timeframe come from the workflow; mask
channel/timeframe use the same clamp the whole-volume loader already applies (mask channel
falls back to 0 if the mask has fewer channels than the intensity image).

An open item flagged for implementation time: how the loader's Z-slab argument maps for each
loader implementation at a given pyramid level, and confirming a loader that delivers its whole
volume in one read (rather than plane-by-plane) still produces identical voxels when the loop
degenerates to a single read.

A.4 Feature streaming rewrites (first wins)

Intensity + histogram — low effort. All intensity stats are per-voxel scalars
(Z-agnostic), so a linear pass over the voxel cloud suffices. Two-pass: pass 1 accumulates
mean/energy/min/max/moments; pass 2 (needs mean & median) accumulates variance/MAD/median-AD/
robust-mean. Both are sequential disk reads, resident memory ~ one record. Histogram gets a
cloud-based initializer mirroring the existing one. The math itself (formulas, robust-mean
window, hyperskew/flatness) is unchanged — only the data source changes, so the existing in-RAM
values are the oracle: forced-out-of-core must equal in-RAM exactly.

Surface (marching cubes) — moderate effort (previously unimplemented for out-of-core).
Marching cubes needs only two adjacent Z-slices at a time:

read_slab(0) -> prev
for z in 1 .. depth-1:
    read_slab(z) -> cur
    run marching-cubes / area+volume kernels on the (prev, cur) slice pair; accumulate
    prev = std::move(cur)

Peak resident memory is 2 planes of ROI voxels, reusing the in-core surface kernel on a 2-plane
dense window bounded by the plane bounding box, not the cube.

What this increment does not touch. Texture families stay loud-failing (or explicitly
skipped with a logged message) until streamed in later increments — never silently returning
zeros, matching the same principle already used for the whole-volume loud-fail case.

A.5 Validation approach

  • Unit tests: a forced-out-of-core test per feature — populate the voxel cloud from a small
    fixture volume, run the streaming calculation, and assert equality with the trivial
    whole-cube calculation over the same volume (exact for integer stats, tight relative
    tolerance for float ones).
  • Memory-constrained VM (small RAM, swap off): a segmented 3D volume whose ROI footprint
    exceeds the machine's RAM must featurize out-of-core with bounded peak memory (far below the
    full cube) and produce feature values identical to an in-RAM run on the same data (using a
    generous RAM limit for that baseline).
  • Full regression suite (unit tests across build configurations, plus the Python test suite)
    green before any push.

A.6 Later families (reuse this same primitive, designed separately)

feature(s) streaming approach window
GLCM co-occurrence over 13 directions; stream (z, z+dz) slab pairs; counts additive 2-slab
GLDM, NGLDM, NGTDM 3×3×3 neighborhood 3-slab sliding
GLRLM runs along 13 directions; lines cross slabs, needs a halo/carry 3-slab + carry
GLSZM, GLDZM connected grey-level zones (flood-fill) whole-volume — hardest; needs an on-disk union-find / multi-pass design (Appendix B)

Appendix B: Design note — out-of-core GLSZM/GLDZM

Companion to Appendix A, which flagged GLSZM/GLDZM as "hardest — connected grey-level zones
(flood-fill), needs an on-disk union-find or multi-pass design." This note works out that
design concretely: it turns out to be one shared streaming mechanism — two-pass connected-
component labeling via a growable union-find — not two unrelated problems. As with Appendix A,
this was implemented as designed; both families passed their out-of-core-vs-in-RAM equivalence
tests on the first try, attributed to scoping this design before writing code.

B.1 Why these are different from the other texture families

Every feature streamed before this (GLCM/GLDM/NGLDM/NGTDM/GLRLM) only ever needed a bounded
sliding window
(1–3 Z-planes, or a 2-plane carry for GLRLM), because each feature's basic
unit — a co-occurrence pair, a dependency count, a run — only involves voxels within a fixed,
small neighborhood.

GLSZM and GLDZM are different: their basic unit is a connected component ("zone") of
same-intensity voxels under 26- or 6-connectivity, and a zone's extent is a genuinely global
property — it can span the entire volume (e.g. one solid uniform-intensity region). A
component's membership only ever grows one voxel at a time via a local adjacency step, so no
single computation needs far-apart data at once — but knowing a zone's final size (or GLDZM's
minimum border distance) requires having fully explored every voxel that belongs to it,
wherever in the volume that turns out to be.

The classic answer for exactly this shape of problem is two-pass (or single-pass-with-
deferred-merges) connected-component labeling with union-find
— a well-established streaming
technique, not something novel needed here. It fits a bounded window plus a small side table.

B.2 The shared mechanism: streaming connected-component labeling via union-find

Causal-neighbor trick. Process Z-planes in ascending order; within each plane, process
voxels in raster (row, column) order. For a voxel being visited, only check its causal
neighbors — those already fully labeled by scan order:

  • Same plane: West, North, and (for 26-connectivity) North-West, North-East.
  • Previous plane (z-1): already fully labeled by the time the current plane is reached, so
    every neighbor there counts as causal (not just the causal half).

The symmetric "future" neighbors (East, South, next-Z, …) are not missed — they get caught from
the other voxel's perspective when the scan reaches it (checking its own causal West/North/
previous-Z back to here). This is the standard raster-scan connected-component-labeling
neighbor set; for 26-connectivity it is 4 same-plane + 9 previous-plane = 13 causal neighbors
(exactly half of 26, mirroring the same canonical-half trick used for GLRLM's 13 direction
shifts, though here it decides what to check rather than how to walk a run). For GLDZM's
6-connectivity it reduces to 2 same-plane (West, North) + 1 previous-plane (straight down) = 3.

Union-find over labels, not voxels.

  • A voxel with no matching causal neighbor gets a brand-new label (appended to a growable
    table).
  • A voxel with one or more matching causal neighbors reuses (unions into) their label(s).
    If two different labels both match (the zone was discovered from two separate arms not yet
    known to be connected), union them.
  • Each label's table entry carries the accumulated zone size (GLSZM) or running minimum
    distance
    (GLDZM), combined on union (sum for GLSZM, min for GLDZM) via standard
    union-by-size with path compression. No second pass over voxels is needed — after the whole
    volume streams once, each root label directly holds its zone's final size/min-distance.
  • Final step: walk the (small) label table once, one (intensity, metric) pair per root, and
    feed that into the unchanged matrix-fill and feature-calculation functions — exactly the
    pattern used for the earlier texture families: the streaming path only replaces zone
    discovery, never the feature math.

Memory bound. The 2-plane window of dense grey-binned planes is O(area), same as every
other streamed feature. The union-find table is bounded by the number of distinct labels
created — typically far smaller than the voxel count (real zones are usually large), but worst
case (checkerboard-like data) approaches O(volume) in label count. This is the same class of
bound already accepted for GLRLM's per-row run-length histograms, and each label entry is a
handful of integers versus the far larger per-voxel footprint of holding the actual voxel cube.

B.3 GLSZM specifics

  • Connectivity: full 26 (all 8 in-plane, 8 upper-Z-diagonal, 8 lower-Z-diagonal, plus
    straight up/down).
  • Per-zone metric: size (voxel count).
  • Background/skip convention: matlab-aware, differing from GLRLM's unconditional
    convention — must not reuse GLRLM's constant here.
  • Grey-level lookup table: construction branches on the numeric IBSI-grey-binning flag
    (dense linear range vs. sparse unique-value set); row lookup separately branches on the
    boolean IBSI-compliance flag.
    This is the exact same two-flag split already found (and
    fixed) in GLRLM — the design must use the two flags for their respective purposes, not
    conflate them.
  • Matrix fill is straightforward once zones are known: column = size − 1, row = grey-level
    index.

B.4 GLDZM specifics

  • Connectivity: only 6 (no diagonals at all) — a simpler causal-neighbor set (3, not 13)
    than GLSZM.
  • Per-zone metric: minimum, over all zone voxels, of each voxel's distance to the ROI
    border — and critically, this border-distance calculation is 2D-only: it scans
    left/right/up/down within the same Z-plane to the nearest background voxel or the plane
    edge, takes the minimum of the four, 1-based, clamped to at least 1. It does not consider
    the Z axis at all. This is an existing convention of the underlying calculation, not something
    to "fix" here — it must be replicated exactly. It also means the border-distance calculation
    for a voxel only needs the current plane's dense buffer (already resident in the 2-plane
    window) — no extra window cost versus GLSZM.
  • Per-label running minimum (not sum), updated the same way as GLSZM's size but combined
    with min on union.
  • Grey-level lookup table: uses the numeric IBSI-grey-binning flag for construction (already
    the correct convention) and a plain linear search (no separate boolean-flag branch) for row
    lookup — simpler than GLSZM, no two-flag trap here.
  • Column = distance − 1 (0-based), row = grey-level index.

B.5 What must be replicated exactly (lessons from the earlier texture families)

Every texture feature streamed before this had at least one subtle convention that had to match
the whole-cube calculation exactly (background inclusion in the lookup table, the two-flag IBSI
split, a feature-specific background/shift quirk). Before implementing GLSZM/GLDZM, each of the
following needed confirming:

  1. The exact causal-neighbor set (13 for GLSZM's 26-connectivity, 3 for GLDZM's
    6-connectivity) — getting this wrong causes zones to split or merge incorrectly, a much
    harder bug class to spot than a scalar value being off.
  2. The background-skip convention for each family separately: GLSZM is matlab-aware; GLDZM
    skips voxels only in IBSI-binning mode when intensity is exactly zero, otherwise processes
    every value including background — different again from both GLRLM and GLSZM, and needed its
    own careful replication.
  3. The two-flag split (grey-binning flag for lookup-table construction vs. IBSI-compliance flag
    for row lookup), confirmed present in GLSZM, absent in GLDZM.
  4. Background inclusion in the lookup table when the ROI bounding box contains background
    voxels (the class of bug found and fixed for GLCM/GLDM earlier in this project) — checked
    for both.
  5. Validated from the start with a non-cuboid-mask fixture (not as an afterthought) — it's what
    caught the background-lookup-table gap the first time this class of bug appeared.

B.6 Implementation plan (as executed)

  1. Shared union-find helper: a growable parent[]/metric[] table with find (path
    compression) and a caller-supplied combine function (sum for GLSZM, min for GLDZM), so one
    helper serves both features.
  2. GLSZM streaming calculation: 2-plane window, 13 causal neighbors, size-tracking
    union-find, final root walk → matrix fill → reuse existing feature-calculation functions.
  3. GLDZM streaming calculation: 2-plane window, 3 causal neighbors, per-voxel border
    distance from the current plane, minimum-tracking union-find, final root walk → matrix fill
    → reuse existing feature-calculation functions.
  4. Guard + tests: allow both families through the existing out-of-core feature guard;
    equivalence tests against the in-RAM calculation, including the partial/non-cuboid-mask
    fixture.
  5. Memory-constrained VM validation: same methodology as the earlier texture families —
    in-RAM-vs-out-of-core value diff plus peak-memory comparison.

Both families completed all 7 texture families' out-of-core coverage — no 3D feature remained
loud-failed after this increment.

Demian Vladi added 9 commits July 22, 2026 10:59
…rr addressability tests

Add channel (C) and timeframe (T) tile coordinates to loadTileFromFile on both loader stacks, updating all concrete overrides atomically; add tileTimestamps to RawFormatLoader for parity. The OME-Zarr loaders thread the two indices into the z5 read offset (previously hard-coded to 0), making C/T addressable; other loaders ignore them. ImageLoader/RawImageLoader gain cur_channel/cur_timeframe state plus setters (defaults 0/0 preserve behavior). Modernize ome_tiff_meta.cpp numeric parsing to std::from_chars.

Tests: add a 5D (T,C,Z,Y,X) coordinate-encoded fixture tests/data/omezarr/dim5.ome.zarr (gen_dim5.py, chunked one z-slice per chunk) and gtests that read each (z,c,t) plane and assert the encoded values, proving C/T addressability. Verified in a USE_Z5 build: all 8 OME-Zarr tests pass, full suite 741/741.
…gative tests

Both OME-Zarr loaders resolve axis roles from the NGFF 'axes' metadata (parse_ome_zarr in the ctor) and build the z5 read offset/shape by axis role (ix_/iy_/iz_/ic_/it_, rank ndim_) instead of the fixed shape[2..4] / {T,C,Z,Y,X} positions. Any axis order and 2-5D rank now read correctly; TCZYX reproduces the previous behavior exactly, falling back to legacy TCZYX when 'axes' is absent. bitsPerSample() now returns the real bit depth (was a stub returning 1) and numberPyramidLevels() reports the real multiscales level count.

Tests: gen_dim5.py emits TCZYX, non-default CTZYX, 3D ZYX, 2D YX, and no-axes fixtures. Generalized gtests cover every rank/order; negative tests assert the non-default order is honored, lower ranks read (a fixed-index loader would crash), the no-axes fallback works, and an out-of-range Z/C/T index throws. Verified in a USE_Z5 build: all 16 OME-Zarr tests pass, full suite 749/749; USE_Z5=OFF build unaffected (728/728).
…ersarial hardening

OME-TIFF: multi-page strip loaders parse OME-XML from IFD-0, set fullDepth to SizeZ (was total page count), and map (z,c,t) to the correct page via OmeAxes::ifdForPlane; plain multi-page TIFFs unchanged. Fixes a latent bug (abstract loader ignored TIFFSetDirectory's return and did not range-check the OME plane).

Crash fix: the OME-Zarr loaders indexed the array shape by axis role without validating the 'axes' metadata against the array rank -- a store declaring more axes than dimensions caused an out-of-bounds read. They now reject axes/rank mismatch, use a rank-safe positional fallback when 'axes' is absent, and guard that X/Y resolve.

Tests: both generators emit all 6 legal DimensionOrder permutations, 4D fixtures, and adversarial fixtures (axes/rank mismatch, no x/y, RGB, corrupt). New loop-tests cover every permutation through both loader stacks; malformed-input tests assert a clean throw (not a crash). Normal build 740/740, USE_Z5 763/763.
…the volumetric pipeline

Mapping the 3D read path found the real blocker: scanTrivialRois_3D does a single load_tile(0,0) and indexes the buffer as the whole w*h*d*t volume, which only NIfTI satisfies (it delivers the entire 4D blob in one read). OME-Zarr and multi-page/OME-TIFF deliver one Z-plane per call with no Z-loop anywhere, so the volumetric pipeline only ever read plane z=0 for those formats.

Add ImageLoader::load_volume(channel, timeframe): stacks all Z-planes for one (channel,timeframe) into an X*Y*Z buffer (get_int_volume_buffer/get_seg_volume_buffer). Handles both per-plane loaders (OME-Zarr/TIFF: one read per Z plane, timeframe chosen by arg) and whole-4D loaders (NIfTI: one read for the entire x*y*z*t blob, requested timeframe slabbed out). Tests open the real ImageLoader facade and verify every voxel of a 3D and 5D OME-Zarr/OME-TIFF volume per (channel,timeframe). Normal build 742/742, USE_Z5 767/767.

Foundation only; wiring load_volume into the phase2_3d consumer + (c,t) loop + c_index column follow.
…umer rewrite

Prove load_volume(0,t) produces exactly the t-th timeframe slab that the current NIfTI read (load_tile(0,0) + get_int_tile_buffer) produces, on signal1.nii (512x512x20). This is the safety net for swapping the phase2_3d consumer's read source onto load_volume: if load_volume matches the existing NIfTI buffer, the wiring cannot regress the working NIfTI path. Full suite 743/743.
…end consumer tests

Wired the remaining live volumetric consumers (scanTrivialRois_3D_anisotropic, scan_trivial_wholevolume, scan_trivial_wholevolume_anisotropic) onto load_volume, matching the segmented path. All plane-by-plane OME-Zarr/OME-TIFF reads now assemble the full X*Y*Z volume; the dead __BEFORE4D/__OLD variants are left untouched. The 2.5D tile-grid paths are unaffected.

End-to-end validation: TEST_(OMEZARR|OMETIFF)_WHOLEVOLUME_CONSUMER call the actual wired consumer scan_trivial_wholevolume and assert the ROI captures all X*Y*Z voxels (192, not the 48 of plane 0) with the correct encoded intensity -- proving the volumetric pipeline now reads the whole volume, not just plane z=0. NIfTI equivalence still holds. Normal build 744/744, USE_Z5 build 770/770.

Remaining Phase 5: advertise fullTimestamps/numberChannels on the OME loaders (4D time + channel loop) and add the c_index output column.
…tput column

Phase 5 C/T layer. OME loaders advertise real C/T counts (numberChannels/fullTimestamps from OmeAxes). workflow_3d_segmented + workflow_3d_whole loop over channels and timeframes, threading (c,t) into load_volume(c,t). Adds a c_index output column (FTABLE_CPOS) across CSV, buffer, and Arrow, threading real t/c (fixes the prior 'Arrow always emits t=0' bug) and a CSV aggregate-row misalignment. SlideProps.inten_channels + RawImageLoader::get_inten_channels(). New tests CT_COUNTS and WHOLEVOLUME_CONSUMER_CT for OME-Zarr and OME-TIFF. normal 746/746, USE_Z5 774/774; NIfTI equivalence green.
… output columns

Phase 6. OME loaders now expose physicalSizeX/Y/Z() + physicalSizeUnit() (from the
parsed OME PhysicalSize* / NGFF coordinateTransformations scale; 1.0/"" when
uncalibrated). These flow through RawImageLoader into SlideProps.phys_x/y/z/phys_unit
during the prescan.

New --use-physical-spacing CLI flag (and Python use_physical_spacing= kwarg), off by
default so existing cube-voxel behavior is unchanged. When set, the 3D pipeline uses
each slide's physical spacing, ratio-normalized so the smallest axis == 1, as the
voxel anisotropy (explicit --aniso* still wins). Centralized in
resolve_slide_anisotropy(), used by both the segmented and whole-volume paths.

The physical spacing is also emitted as output columns in every sink (CSV, buffer,
Arrow/Parquet): phys_x/y/z (numeric) plus phys_unit. phys_unit is a leading string
column (kept with intensity/mask) so the Python buffer's strings-first reconstruction
and the Arrow leading-utf8 schema stay valid; FTABLE_RECORD grew accordingly
(FTABLE_PXPOS/PYPOS/PZPOS, FBEGIN 4->7).

New calibrated fixture dim5_calibrated.ome.zarr (scale 1,1,2,0.5,0.5 um) +
TEST_OMEZARR_PHYSICAL_CALIBRATION. Verified across three build configs: normal
746/746, USE_Z5 775/775, USE_ARROW 745/745 (TEST_ARROW/TEST_PARQUET round-trip with
the new schema); NIfTI equivalence green.
@darkclad darkclad changed the title chore(io): drop machine-specific paths and planning labels from tests feat(io): Native 5D IO + True 3D Out-of-Core Featurization Jul 23, 2026
Demian Vladi added 20 commits July 22, 2026 19:20
Test coverage for the physical-voxel-spacing calibration added in the preceding
commit, which shipped without tests of its own.

TEST_RESOLVE_SLIDE_ANISOTROPY exercises the decision function on both sides:
the flag being off, a degenerate (zero-length) axis, isotropic spacing, and an
out-of-range slide index must each fall back to (1,1,1) without engaging the
resampling path, dividing by zero, or reading out of bounds; genuinely
anisotropic spacing engages it with the ratios normalized so the smallest axis
is 1; explicit --aniso* values win over the physical ones.

TEST_OMETIFF_LOAD_VOLUME_OUT_OF_RANGE and its OME-Zarr counterpart assert that
an out-of-range channel or timeframe requested through the whole-volume facade
throws, rather than silently reading plane 0 or out-of-bounds memory, while an
in-range request still succeeds.

Tests only; no production code changed.
Found by driving the segmented 3D pipeline end-to-end through the CLI on a
3-channel OME-TIFF paired with a single-channel mask: only the c=0 rows were
emitted. ImageLoader::load_volume passed the intensity's channel index to the
mask read as well, but a mask is channel-agnostic (typically single-channel),
so featurizing intensity channel c>0 indexed the mask out of range and dropped
the ROI.

Fix: clamp the mask channel to the mask's own channel count
(mask_channel = channel < segFL->numberChannels() ? channel : 0), mirroring the
existing mask_timeframe separation for the 1-mask : N-intensity case. A mask
with as many channels as the intensity is still addressed per-channel.

Regression test TEST_OMETIFF_MULTICHANNEL_MASK_PAIRING (new single-channel
fixture dim3_mask.ome.tif via gen_ome_tiff.py) asserts the seg buffer is reused
identically for every intensity channel while the intensity buffer differs.
The gtest consumer tests were all wholeslide (no mask), so this path was
unexercised until the CLI run.

normal 749/749, USE_Z5 779/779, USE_ARROW 748/748.
… 3D Python classes

The Phase-6 nyxus.py change only updated the 2D Nyxus class's
initialize_environment call; the two 3D entry points (Nyxus3D and the
fname-lists class) still passed 20 positional arguments while the C++ binding
now requires 21 (…, preserve_hu, use_physical_spacing). That raised a TypeError
on construction, breaking all Python 3D featurization.

Thread use_physical_spacing (already read from kwargs) into both 3D call sites.

Verified by building the backend pyd and running
Nyxus3D(features=["*3D_ALL*"], use_physical_spacing=...).featurize_directory()
on a 3-channel OME-TIFF + single-channel mask: a 6-row DataFrame (one per
channel x timeframe) with the phys_unit/c_index/t_index/phys_x/y/z columns
correctly reconstructed.
Until now a TILED (as opposed to strip) multi-plane OME-TIFF routed to the tile
loaders, which ignored z/c/t and only ever read IFD-0 (plane 0). Extend both tile
loaders (NyxusGrayscaleTiffTileLoader, RawTiffTileLoader) to mirror the strip
loaders: parse the IFD-0 OME-XML in the ctor (fullDepth = SizeZ), map (z,c,t) to
the right IFD via OmeAxes::ifdForPlane and TIFFSetDirectory before TIFFReadTile
(range-guarded), and advertise numberChannels / fullTimestamps / physicalSize*.

Also fix a latent tile-loader bug: an absent SAMPLEFORMAT tag (tifffile omits it
for uint images) left sampleFormat_ = 0 and threw "data format not supported".
Default it to 1 (unsigned integer) when out of [1,3], as the strip loader already
does.

New fixture dim5_tiled.ome.tif (gen_ome_tiff.py write_tiled; one 16x16 tile per
6x8 plane) with TEST_OMETIFF_TILED_ADDRESSING (both loader stacks + out-of-range
negative) and TEST_OMETIFF_TILED_FACADE_VOLUME (open() routes a tiled TIFF to the
tile loader). normal 751/751, USE_Z5 781/781, USE_ARROW 750/750.

Caveat: assemble_volume reads only tile (0,0) per plane, so a tiled OME-TIFF whose
plane spans multiple tiles still needs the out-of-core multi-tile assembly (an
existing follow-up); the fixture is one-tile-per-plane so the volumetric path is
fully exercised.
…store

z5 3.0.0 already reads Zarr v3: z5::openDataset auto-detects zarr.json (v3) vs
.zarray (v2), readAttributes surfaces the NGFF-0.5 "ome"-wrapped metadata, and the
Dataset API (shape/defaultChunkShape/getDtype) + readSubarray handle the v3 chunk-key
encoding, codecs and sharding format-agnostically. A standalone probe confirmed this,
so no tensorstore is needed.

The OME-Zarr loaders were the only v2-specific piece: they hand-parsed .zarray.
Rework omezarr.h and raw_omezarr.h to open the dataset first and query z5's Dataset
API instead, resolving the "ome"-wrapped multiscales path (parse_ome_zarr already
handled the wrapper). A small guarded zarr_dtype_string_of() maps z5's dtype enum to
the "<u2"-form string the existing dispatch expects. The read path (chunk offsets,
tiling) is unchanged, so this reads BOTH OME-Zarr 0.4 and 0.5.

zstd is the default codec of Zarr v3; z5's ZstdCompressor is gated by WITH_ZSTD. Add
find_library(zstd) + -DWITH_ZSTD + link to the USE_Z5 CMake block (optional: falls
back gracefully if zstd isn't found), so real zstd-compressed v3 stores read.

Fixtures (gen_dim5.py write_v3): dim5_v3.ome.zarr (uncompressed) and
dim5_v3_zstd.ome.zarr (zstd). Tests TEST_OMEZARR_V3, _FACADE_VOLUME, _CT_COUNTS and
_ZSTD; the existing v2 tests still pass (the rework is v2/v3-agnostic). USE_Z5 785/785,
normal 751/751, USE_ARROW 750/750.

Not yet covered: v3 sharding (z5 has sharded_dataset.hxx, likely works) and the v3
blosc codec.
… no output

reduce_trivial_3d_wholevolume called D3_VoxelIntensityFeatures::extract(r, s), whose
body invoked the 2-arg calculate(LR&, const Fsettings&) - a stub that unconditionally
throws "illegal call". Every --dim=3 whole-volume run therefore died before writing a
single row.

D3_VoxelIntensityFeatures is the only 3D feature whose calculate needs the Dataset (for
COVERED_IMAGE_INTENSITY_RANGE, which reads ds.dataset_props[r.slide_idx]); the other
eight 3D classes implement the 2-arg calculate for real. The segmented path reduces via
reduce(), which passes the Dataset, so it never hit this - and no test exercised the
whole-volume reduce at all.

Give extract() the Dataset and call the 3-arg calculate; pass env.dataset at the call
site. The throwing 2-arg stub is deliberately kept as a guard against future
wrong-overload calls.

Regression test TEST_3D_WHOLEVOLUME_REDUCE mirrors featurize_wholevolume's vROI setup
(prescan, aabb, aux_min/max, voxel cloud, image cube) and then reduces, asserting no
throw and sane MIN/MAX. Verified end-to-end: the previously-failing CLI whole-volume run
now emits 6 rows (3 channels x 2 timeframes) whose 3MEAN values match the segmented run
exactly. normal 752/752, USE_Z5 786/786, USE_ARROW 751/751.
gatherRoisMetrics_2_slideprops_3D did a single load_tile(0,0) and then indexed
W*H*D voxels off that buffer. For a per-plane loader (OME-TIFF / OME-Zarr) a tile
read fills only ONE Z-plane, so everything past the first slice read out of bounds
(it also assumed the tile stride equals the full width). dim5.ome.tif encodes
1..1152 but the prescan reported a slide range of 0..44,465, which corrupted
grey-binning and the whole-volume MIN/MAX/RANGE and COVERED_IMAGE_INTENSITY_RANGE.

Fixing that uncovered two further defects, so this addresses the chain:

1. New RawImageLoader::load_volume(channel, timeframe) assembles the whole X*Y*Z
   volume by looping Z into packed (full-width stride) buffers, honoring each
   loader's own layout (per-plane vs NIfTI whole-4D via frameBase) and clamping the
   mask channel. The prescan now reads get_voxel_dpequiv/get_voxel_seg. This is the
   volumetric counterpart of ImageLoader::load_volume.

2. RawTiffStripLoader allocated its buffer once in the ctor and never re-allocated,
   yet free_tile() freed it - so a second read after a free wrote into freed memory
   (RawTiffTileLoader mallocs per read, so the two loaders had incompatible
   free_tile contracts even though the base class documents that free_tile() must
   follow each loadTileFromFile()). free_tile() is now idempotent and
   loadTileFromFile re-allocates lazily. This also removes a latent use-after-free
   for multi-tile 2D strip scans and a per-plane leak.

3. The prescan only ever scanned (c0,t0) while the pipeline featurizes every (c,t).
   Once the range was no longer garbage-inflated it became too small, so
   intensity-indexed buffers were under-sized for c>0 and segfaulted. The prescan
   now loops all (c,t) accumulating the intensity range; ROI geometry is taken from
   the first pass only, since the mask is channel-agnostic (otherwise aux_area would
   be multiplied by n_channels*n_timeframes).

Verified: the prescan now reports exactly DR 1.0-1,152.0 and the CLI whole-volume
run exits cleanly with 6 correct rows. Regression test TEST_3D_PRESCAN_SLIDE_RANGE
pins both the range and the ROI area (the latter guards the first-pass logic).
normal 753/753, USE_Z5 787/787, USE_ARROW 752/752.

Note: the prescan now performs n_channels*n_timeframes volume reads; worth
revisiting when the scale/stress harness lands.
…heck

test_in_memory_3d popped the two leftmost numeric columns positionally to skip
ROI_label and time. Once c_index and phys_x/y/z were added to the output schema,
four extra non-feature columns remained at the front, so averaged_results[0]
became the mean of (c_index, phys_x, phys_y, phys_z) = 0.75 instead of the
angular-2nd-moment value, and the IBSI assertion failed.

Drop those columns by name instead, which keeps the test correct as the schema
grows. The feature values themselves were never wrong.

Full suite: 79 passed, 1 skipped.
…escan

Both volumetric readers fetched only tile/chunk (0,0) of each Z-plane and then
copied a full fullHeight x fullWidth plane out of that one tile's buffer. That is
correct only when a single tile covers the entire plane -- which is how every
fixture in the tree happened to be built, so nothing caught it. Real data is not
like that: OME-Zarr chunks are typically (1,1,1,512,512) and tiled OME-TIFF uses
256x256, so any plane larger than one tile silently returned wrong voxels outside
the first tile plus an out-of-bounds read of that tile's buffer.

ImageLoader::assemble_volume and the 3D prescan now walk numberTileHeight x
numberTileWidth per plane and copy each tile's valid [validH x validW] sub-region
to its own (row0, col0) offset, clamped at the slide edge for partial edge tiles.

While in the prescan: it no longer materializes the volume at all. It ran once per
(channel, timeframe) and staged W*H*D doubles each time -- 4x the raw uint16 volume
in RAM -- to compute nothing but a running min/max plus mask-driven ROI geometry.
RawImageLoader::load_volume and its voxel accessors are replaced by a streaming
for_each_voxel(channel, timeframe, fn) template that walks the same tile grid and
calls back per in-mask voxel, so the staging buffers are gone.

Fixtures and tests. The gap existed because no fixture had more than one tile per
plane, so two were added:

  dim5_multichunk.ome.zarr  3x4 chunks over the 6x8 plane
  dim5_multitile.ome.tif    2x3 grid of 16x16 tiles over a 32x48 plane
                            (its own larger shape: TIFF tile sizes must be
                            multiples of 16, so the shared 6x8 plane cannot tile)

Four tests cover both readers on both formats. Each was confirmed to FAIL against
the pre-fix code, not merely to pass after it: the facade reads 0 instead of the
encoded value past the first tile, and the prescan reports a ROI area of 512
(= one 16x16 tile x 2 planes) instead of 3072, with max intensity 5344 vs 6144.

gtest green in all three configs (755 normal / 791 z5 / 754 arrow);
Python suite 79 passed, 1 skipped.
…eframe

A slide now produces one row per (channel, timeframe), and the CSV sinks are
invoked once per plane. But separatecsv derives a single path per slide -- it has
no c/t component -- and both sinks opened that path with mode "w" on every call.
Each plane therefore truncated the one before it and the file was left holding
only the LAST channel. Reproduced on the CLI with a 2-channel slide: the output
had one data row, c_index=1, and channel 0 was simply gone. Both the 3D
whole-volume and the 3D segmented workflows were affected, in the default output
mode.

The planes are meant to coexist as rows -- that is what the t_index/c_index
columns are for, and it is what the Arrow and buffer sinks already do -- so the
filenames stay as they are and the rows accumulate: the first write to a path
truncates and renders the header, subsequent writes append.

That decision is now expressed per PATH via Environment::csv_claim_first_write
rather than by the old function-static 'mustRenderHeader', which could only model
"one file for the whole run" and so had nothing to say about separatecsv. The
registry lives on Environment and is cleared at the top of each processDataset_*,
so a long-lived Python session starts each run clean instead of appending to a
file left over from the previous call -- a wart the static had as well.

Verified on the CLI in both modes with two 2-channel slides:
  separatecsv -> 2 files x (1 header + 2 rows)
  singlecsv   -> 1 file  x (1 header + 4 rows)
and the two channels carry different feature values, so the rows are genuinely
distinct planes rather than a duplicated one.

TEST_CSV_MULTICHANNEL_NO_OVERWRITE covers it, and was confirmed to FAIL against
the pre-fix code (2 lines instead of 3).

Also gitignores the NyxusFeatures.* tables the suites drop into the tree.

gtest green in all three configs (756 normal / 792 z5 / 755 arrow);
Python suite 79 passed, 1 skipped.
The multi-tile assembly clamps each tile to the slide edge --
validH = min(tileH, fullH - row0), validW = min(tileW, fullW - col0) -- so a
tile whose row/column runs off the plane copies only its valid sub-region. But
every multi-tile fixture in the tree had plane dimensions that are exact multiples
of the tile/chunk size (zarr 6x8 over 3x4 chunks, tiff 32x48 over 16x16 tiles), so
fullH - row0 always equalled tileH and the clamp had literally never run. That is
the same shape of gap that produced the single-tile bug: fixtures too regular to
reach the branch.

Two fixtures whose planes do NOT divide evenly:

  dim5_oddchunk.ome.zarr  chunk (4,5) over the 6x8 plane -> last row-chunk 2 tall,
                          last col-chunk 3 wide (same 1..1152 TCZYX encoding as
                          dim5_multichunk)
  dim5_oddtile.ome.tif    40x24 plane / 16 -> 3x2 tiles, last row-tile 8 tall,
                          last col-tile 8 wide (1..1920 over c in {0,1})

Four tests (facade whole-volume assembly + prescan, each format) assert the exact
value at every voxel and the prescan's slide min/max and ROI area. Their
discriminating power was verified precisely: with the clamp reverted to the
unclamped tileH/tileW, the six EXACT-multiple fixtures still PASS (the clamp is a
genuine no-op when dims divide) while these odd fixtures FAIL -- the prescan reads
padded voxels (min -> 0) and over-counts area (192 -> 320 zarr, 960 -> 1536 tiff).
So they cover the partial-tile branch the exact fixtures structurally cannot.

No source change: this closes a coverage gap on the already-shipped clamp. A
separate audit confirmed every offset in the assembly is size_t (volSize is
(size_t)fw*fh*fd), so index/stride arithmetic cannot overflow short of ~1.8e19
voxels -- a "large volume" fixture would pass trivially and is not committed.

gtest green in all three configs (758 normal / 796 z5 / 757 arrow). No Python or
C++ source touched, so the pytest gate (last green at the prior commit) is
unaffected; it will run as the final gate before push.
…to-end

Sharding is how large v3 stores -- including Axle's -- actually lay out data:
many inner chunks packed into one shard object per (t,c), rather than one file
per chunk. It had been left explicitly untested (io-state noted z5 "likely" reads
it but no fixture existed).

It reads with NO source change. z5 3.0.0 selects a ShardedDataset automatically
when zarr.json carries a shard shape (metadata.hxx detects the sharding_indexed
codec, sets chunkShape = the inner chunk and shardShape = the outer), and both
nyxus loaders read through z5::multiarray::readSubarray, which unpacks the inner
chunks from a shard transparently. So this is coverage, not a fix.

Fixture dim5_v3_sharded.ome.zarr (gen_dim5.write_v3_sharded): same 1..1152 TCZYX
encoding as the other v3 stores, inner chunk (z,y,x)=(1,3,4) so each 6x8 plane is
a 2x2 grid of inner chunks, all packed into one shard per (t,c) -- 6 shard files
of 16 inner chunks each on disk. The read must assemble across inner-chunk
boundaries within a shard.

Tests: facade whole-volume assembly (abstract loader stack), CT counts, and the
3D prescan (raw loader stack) -- all asserting the exact encoded values / slide
range / ROI area. Both loader stacks are therefore exercised on sharded data.

A note on why NOT test_omezarr_addressing here: with sharding, tileWidth/Height
report the INNER chunk, so a single loadTileFromFile(0,0,...) reads only the
top-left inner chunk, not the whole plane -- that helper's one-tile-per-plane
assumption. (Caught it live: the addressing test read buf[4] = enc(0,1,..)=9
where it wanted enc(4,0,..)=5.) facade_volume assembles the full inner-chunk grid
and checks every voxel, which is the right coverage for any multi-chunk store --
same reason the multichunk v2 fixture uses it.

USE_Z5 suite green (799). normal/arrow unaffected: the tests live inside the
OMEZARR_SUPPORT guard and compile out there.
…ayouts)

ifdForPlane(z,c,t) computed a plane's IFD purely from DimensionOrder, assuming
planes are stored contiguously from IFD 0. That is the OME default and is correct
when <TiffData> is absent (or when it just restates canonical order, as tifffile's
single "<TiffData IFD=0 PlaneCount=N/>" does). But a writer that starts at a
non-zero IFD or reorders planes -- e.g. bioformats emits per-plane <TiffData>
blocks, and a multi-image container offsets the first IFD -- declares a different
mapping, and we ignored it and read the wrong plane's pixels.

parse_ome_xml now reads the <TiffData> elements and builds an explicit
plane-ordinal -> IFD map (OmeAxes::planeToIfd), and ifdForPlane consults it,
falling back to the canonical ordinal when there is no map. Each block maps
PlaneCount consecutive DimensionOrder planes from (FirstZ,FirstC,FirstT) to
consecutive IFDs from IFD (attributes default per the OME spec: First*=0, IFD=0,
PlaneCount=remaining). A purely canonical map is detected and left empty so the
common path stays allocation-free and obviously canonical -- behavior for every
existing fixture is byte-identical. Both loader stacks route through ifdForPlane,
so the fix covers strip and tile, abstract and raw.

Out of scope, handled safely: a <TiffData> with a <UUID> child names a plane in
another file (companion / multi-file OME-TIFF). That is not resolved here; it sets
OmeAxes::multiFileTiff and the plane is left at the canonical fallback rather than
pointed at a wrong local IFD, so a future multi-file feature can detect it.

Fixture dim5_reordered.ome.tif (gen_ome_tiff.write_reordered): T=1,C=2,Z=3, planes
stored in REVERSED IFD order with per-plane <TiffData> declaring the mapping.
TEST_OMETIFF_TIFFDATA_REORDERED (facade whole-volume, drives ifdForPlane through
load_volume) was confirmed to FAIL against the pre-fix code -- it read 241 (the
reversed plane at IFD 0) where it must read 1 (z0,c0). Four parser unit tests
(OmeTiffMetaTiffData) cover canonical-stays-identity, reversed per-plane mapping,
non-zero start offset, and the multi-file UUID flag.

gtest green in all three configs (763 normal / 804 z5 / 762 arrow). Source change
in ome/ (plain STL, compiles in all configs); no Python behavior changes for
canonical files -- the full pytest gate runs before push.
Two remaining OME layouts that real data uses were untested. Both read correctly
with no source change; this is coverage.

Zarr v3 BLOSC codec (dim5_v3_blosc.ome.zarr, gen_dim5 write_v3 + BloscCodec): the
bytes+blosc v3 pipeline, common alongside zstd in real v3 stores. z5 decodes it
when built WITH_BLOSC (already required for OME-Zarr, so no build change). Tests:
addressing (both loader stacks) + facade whole-volume, same 1..1152 TCZYX encoding
as the other v3 fixtures.

Pyramidal OME-TIFF with SubIFD downsample levels (dim5_pyramid.ome.tif,
gen_ome_tiff write_pyramid): every full-res plane's IFD carries 2 reduced-
resolution levels as SubIFDs (TIFF tag 330). SubIFDs live OUTSIDE the main IFD
chain, so TIFFNumberOfDirectories still returns Z (verified: 6, not 18) and
ifdForPlane -> main-chain IFD still lands on the full-res plane. nyxus featurizes
level 0 only, and the loader already uses ome_.sizeZ (not the raw directory count)
for OME files, so full-res addressing is unaffected -- confirmed rather than
assumed. TEST_OMETIFF_PYRAMID_SUBIFD_FULLRES drives the facade over the Z=6 stack
(also a 2x3 tile grid, so multi-tile x pyramid) and asserts every full-res voxel.

Scope note: parsing the SubIFD levels into OmeAxes::levels was deliberately NOT
done -- nothing in the pipeline consumes a level > 0 (loaders always read lvl 0),
so it would be metadata with no consumer. The valuable part -- that a pyramid does
not silently shift which plane we read -- is the test above. A non-OME "separate
top-level IFD" pyramid (the legacy, pre-SubIFD convention) would inflate
TIFFNumberOfDirectories, but that path has no OME metadata to correct it and is
out of scope.

gtest green in all three configs (764 normal / 807 z5 / 763 arrow).
…rashed

A single-timeframe segmentation mask is reused across every intensity timeframe
(the common 1-mask : N-timeframe case). The 3D prescan clamped the mask CHANNEL to
the mask's own count but read the mask at the intensity's raw TIMEFRAME. So for an
intensity with T>1 paired with a T=1 mask, timeframe t>0 made the TIFF mask loader
compute an IFD past the mask's last plane (ifdForPlane with t>0), TIFFSetDirectory
failed and threw -- and the throw was UNCAUGHT in the prescan, terminating the
process via __fastfail (0xC0000409, no message). OME-Zarr masks have no T axis to
over-index, so only OME-TIFF crashed.

RawImageLoader::for_each_voxel now clamps the mask timeframe the same way it clamps
the channel: use the intensity timeframe only when the mask genuinely has that many
timeframes, else 0. ImageLoader::load_volume gains the symmetric internal clamp too
(its callers already passed a clamped maskFrame, but it clamped mask_channel
internally while trusting callers for the timeframe -- now it self-defends on both,
so no caller can reintroduce this).

Found by the IO scale/stress harness: a 5D (T=2) segmented OME-TIFF aborted while
the same case in OME-Zarr, and every whole-slide case, passed. Bisected to purely
T>1 (size-independent). Regression + coverage tests land in the accompanying test
commit (TEST_OMETIFF_MULTITIMEFRAME_MASK_PRESCAN for the prescan path,
TEST_OMETIFF_MULTITIMEFRAME_MASK_FACADE for the load_volume path).

gtest green in all three configs (772 normal / 817 z5 / 771 arrow); pytest 79
passed, 1 skipped.
Regression for the T>1 mask fix (accompanying commit) plus a sweep of positive and
negative cases that scanning the added IO code showed were untested. All verified
the usual way; the two tied to real fixes were confirmed to fail against the broken
code (P3 without the load_volume clamp; the prescan regression without the
for_each_voxel clamp -> "TIFFSetDirectory(ifd = 4)").

Positive:
  P1 TEST_DETECT_INPUT_FORMAT -- detect_input_format had no direct test though all
     three loader stacks dispatch through it: extension classification (.tif/.dcm/
     .nii.gz/.zarr) + OME content-sniff (OME-XML present vs plain TIFF; .zarr with
     no multiscales).
  P2 TEST_OMETIFF_PHYSICAL_CALIBRATION -- the TIFF twin of the OME-Zarr calibration
     test (Zarr had one, TIFF did not), through scan_slide_props -> SlideProps.phys_*
     (+ the uncalibrated 1.0/"" default). New fixture dim5_calibrated.ome.tif.
  P3 TEST_OMETIFF_MULTITIMEFRAME_MASK_FACADE -- the featurize half of the T>1 mask
     fix: ImageLoader::load_volume reuses a T=1 mask across timeframes without throw.
  P4 TEST_OMEZARR_MULTITIMEFRAME_MASK_PRESCAN -- the crash's positive twin on Zarr
     (never crashed, never asserted). New fixture dim3_mask.ome.zarr.
  P5 OmeTiffMetaTiffData.OmittedPlaneCountCoversRemaining -- <TiffData> with no
     PlaneCount defaults to the remaining planes from the start IFD.

Negative:
  N1 TEST_OMEZARR_V3_UNSUPPORTED_CODEC_THROWS -- a v3 store whose zarr.json names a
     codec z5 cannot decode is rejected cleanly (both loader stacks), not crashed.
     New fixture dim5_v3_badcodec.ome.zarr.
  N2 TEST_OMETIFF_BAD_IFD_THROWS -- a <TiffData> mapping a plane to an in-file IFD
     past EOF throws at TIFFSetDirectory, not crash. New fixture dim5_badifd.ome.tif.
  N3 TEST_OMETIFF_EMPTY_MASK_ZERO_ROIS -- an all-background mask yields zero ROIs and
     completes cleanly (no divide-by-zero). New fixture dim3_emptymask.ome.tif.
  N4 TEST_OMETIFF_MASK_MORE_CHANNELS_THAN_INTENSITY -- a C=2 mask paired with C=1
     intensity ignores the extra mask channel rather than misreading. New fixture
     dim4_mask_c2.ome.tif.

Also the T>1-mask prescan regression, TEST_OMETIFF_MULTITIMEFRAME_MASK_PRESCAN.

gtest green in all three configs (772 normal / 817 z5 / 771 arrow).
TEST_RESOLVE_SLIDE_ANISOTROPY covers the DECISION (physical spacing -> anisotropy
ratios), but nothing asserted the ratios are APPLIED. The 3D prescan's anisotropic
branch (make_anisotropic_aabb 3-arg -> AABB::apply_anisotropy) was never exercised
by any test -- every other test uses make_nonanisotropic_aabb.

TEST_3D_ANISOTROPY_RESCALES_ROI_DEPTH runs scan_slide_props twice on the same slide
(dim5 + dim3_mask, whose ROI spans all Z): once isotropic, once with a customized
az=4. The z-anisotropic run must scale max_roi_d ~4x while leaving max_roi_w/h
(ax=ay=1) unchanged. Confirmed discriminating: forcing the anisotropic branch to
make_nonanisotropic_aabb makes ani==iso and the test fails.

gtest green in all three configs (777 normal / 822 z5 / 776 arrow).
An oversized whole-volume ROI (footprint >= RAM limit) cannot be featurized in
memory: nyxus has no streaming path for the 3D texture features (their
osized_calculate just calls the trivial calculate(), which needs the full in-memory
cube), and GLRLM/GLSZM/intensity/surface additionally read raw_pixels_3D, so both
large buffers are genuinely required. featurize_wholevolume already returned false
in that case -- but the caller set rv and then FELL THROUGH to save the
zero-initialized feature buffer, emitting a row of all-0 features: silently-wrong
data. And processDataset_3D_wholevolume returned success regardless, so the run
looked like it worked.

Now:
 - the caller skips the save (continue) on an oversized volume and prints an
   actionable error (use a segmented mask, raise --ramLimit, or add RAM), so no
   misleading row is written;
 - featurize_3d_wv_thread returns its status BY VALUE through the future instead of
   writing a std::ref(int) -- that async write was not reliably visible to the
   caller after future::get(), so rv=1 was lost and worst_rv stayed 0. The run now
   aggregates the returned status and fails (CLI exits nonzero, Python API raises)
   when any slide could not be featurized.

Found by the IO scale/stress harness once probed past the RAM ceiling with
--ramLimit=1 (the 1 GB scale files never crossed 50% of 128 GB, so the disk path
was never hit). Verified on the CLI: oversized -> exit 1, no CSV row, clear error;
normal -> exit 0, real features. Regression TEST_3D_WHOLEVOLUME_OVERSIZED_FAILS_
LOUDLY forces ramLimit=0 and asserts the run fails with no data row; confirmed to
fail (run wrongly "succeeds") without the fix. The 3D SEGMENTED out-of-core path is
unaffected and works within the cube-fits limit.

gtest green in all three configs (778 normal / 823 z5 / 777 arrow); pytest 79
passed, 1 skipped.
…ator

featurize_wholevolume decided trivial-vs-oversized using get_ram_footprint_estimate
-- the 2D estimator, whose image-matrix term is W*H. For a whole VOLUME that ignores
depth and under-counts the image cube by a factor of ~D. So a volume that genuinely
needed more than the RAM limit was mis-classified as trivial, loaded fully in
memory, and OOM-crashed -- and it crashed EVEN with a matching --ramLimit, because
nyxus's own accounting said it would fit. The segmented path already used
get_ram_footprint_estimate_3D (W*H*D); this switches whole-volume to it too (both
the featurize_wholevolume gate and featurize_triv_wholevolume's inner check).

Found by running nyxus under a hard OS memory cap (a Windows Job Object with
JOB_OBJECT_LIMIT_PROCESS_MEMORY) -- something --ramLimit alone cannot reveal, since
it only moves nyxus's classification threshold, not the actual allocation ceiling.
On a 50M-voxel whole volume with `mem_cap 1024 nyxus ... --ramLimit=1024`: before
the fix nyxus hard-crashed at the 1 GB cap (0xC0000409); after, it correctly
classifies the volume oversized and fails loudly (exit 1, "cannot featurize", no
row) while staying under the cap.

Regression TEST_3D_RAM_FOOTPRINT_COUNTS_DEPTH pins that the 3D estimator counts
depth while the 2D one does not (two ROIs, same W/H/area, differing only in bbox
depth: the 2D estimate is identical, the 3D estimate is >10x larger).

gtest green in all three configs (779 normal / 824 z5 / 778 arrow); pytest 79
passed, 1 skipped.
…nt bugs)

The 2D oversized-ROI (out-of-core) path silently produced all-zero/wrong
intensity features. Four defects, all masked because the osized dispatch
always threw:

- phase3 osized dispatch called the Dataset-less osized_calculate (a throwing
  guard) instead of the Dataset-aware overload; add a Dataset-aware
  osized_scan_whole_image overload on FeatureMethod, override it in
  PixelIntensityFeatures/IntensityHistogramFeatures, and pass env.dataset.
  Shared phase3 path also unblocks 3D intensity/histogram out-of-core.
- PixelIntensityFeatures::osized_calculate accumulated variance/MAD/median-AD
  from the empty in-RAM raw_pixels; read raw_pixels_NT instead.
- Hyperskewness/Hyperflatness used Moments4 instead of the in-core explicit
  sum-of-powers formula.
- TrivialHistogram out-of-core RMAD thresholded on bin frequencies (not p10/p90
  values) and skipped the mean division; mirror the in-core overload. Also
  fixes 3D out-of-core intensity RMAD.

Also fix the in-memory montage path (workflow_pythonapi.cpp): its
processIntSegImagePairInMemory took the unprocessed_rois out-list by value, so
oversized ROIs were dropped and a silent all-zero row was emitted instead of the
intended "oversized ROIs cannot be processed" error. Pass it by reference. The
montage path has no out-of-core by design, so it now fails loudly; normal
(default ram_limit) montage runs are unchanged.

Verified on a 4 GB Linux VM: an 8000x8000 ROI (3.07 GB in-memory footprint)
processed out-of-core (peak 1.04 GB) now matches the in-RAM result on all 42
intensity features.

Add tests/python/test_ooc_mechanics.py: (1) featurize a 500x500 file pair in-RAM
vs ram_limit=1 (forced out-of-core) and assert identical intensity features; (2)
assert the in-memory montage path fails loudly on an oversized ROI. pytest
tests/python: 81 passed, 1 skipped; gtest runAllTests 779 (normal) / 824 (z5).
Demian Vladi added 16 commits July 22, 2026 19:20
…umetric ROIs

The 3D segmented oversized path routed through the shared 2D processNontrivialRois,
whose tile loop feeds Pixel2 (no z) and reads only plane 0, so a volumetric ROI's
disk cloud collapsed to a single plane. 3D intensity also held the whole raw_pixels_3D
cube, so it only completed when the cube fit -- not out-of-core.

Add OutOfRamVoxelCloud (a disk-backed, z-slab-indexed, block-cached 3D voxel source)
and ImageLoader::stream_volume_planes (streams the volume one Z-plane at a time, peak
memory two planes not the cube). New processNontrivialRois_3D populates the cloud
keeping z; D3_VoxelIntensityFeatures::osized_calculate and TrivialHistogram now stream
from it. 3D texture on an oversized ROI fails loudly (no silent zeros). Also fix the
osized hyperskewness/hyperflatness to the trivial path's explicit sum-of-powers
definition. Validated on a 4 GB VM: a 2.21 GB-footprint ROI featurizes at 887 MB peak
(30% of the in-RAM cube) with values byte-identical to the in-RAM run.
…tric ROIs

D3_SurfaceFeature::osized_calculate was empty, so surface features on an oversized 3D
ROI silently produced zeros; the in-core path also holds the whole voxel cube plus an
all-voxel unordered_set for the exposed-face count, so it cannot run out-of-core.

Stream from the disk-backed voxel cloud (raw_voxels_NT) added by 6963ce1:
- Per-plane contours via read_slab + build_contour_imp over a plane-local cloud; the
  boundary points are collected in RAM (bounded by surface area, not volume) in the same
  z-then-trace order as the in-core path, so the convex hull is identical.
- Surface AREA computed exactly as 6N - 2*(adjacent voxel pairs) over a 2-plane occupancy
  window (x/y in-plane, z vs the previous plane) instead of the all-voxel unordered_set.
- Covariance for the PCA axis lengths accumulated online in one pass.
- Convex-hull volume from the collected boundary points; build_surface refactored to share
  a build_hull(points) helper with the in-core path (behavior unchanged).

processNontrivialRois_3D now allows D3_SurfaceFeature through its out-of-core guard
(texture still fails loudly). test_ooc_3d_matches_in_ram extended to cover morphology.

Validated on a 4 GB VM: a 2.21 GB-footprint ROI computes surface out-of-core at 656 MB
peak (AREA analytically exact, hull/axis-lengths sane), whereas the in-core surface path
OOM-crashes at 3.5 GB on the same volume.
…olumetric ROIs

These four 3D texture features previously fell back to calculate() on the whole
in-RAM aux_image_cube (GLCM, GLDM) or, for NGTDM, ran a broken 2D stub left over
from the old z-less cloud (2D 8-neighbourhood, wrong binning basis, reading the
2D raw_pixels_NT). None were out-of-core.

Stream each from the disk-backed voxel cloud (raw_voxels_NT) added by 6963ce1,
reusing each feature's existing math so values are guaranteed identical:

- GLCM: extract_texture_features_at_angle split into build (calculateCoocMatAtAngle)
  and a new finalize_angle() that computes the angle's features from an already-built
  P_matrix. The out-of-core path builds all 13 direction matrices by streaming a
  ring of (offset+1) dense grey-binned Z-planes (co-occurrence counts are additive),
  then calls finalize_angle() per direction -- identical to the in-core path.
- GLDM: 26-neighbourhood dependence matrix filled directly per voxel over a 3-plane
  sliding window (no O(volume) zone list needed); calc_* feature math unchanged.
- NGLDM: NGLD-matrix filled per interior voxel (24-neighbourhood, 1-voxel margin
  skipped as in calc_ngld_matrix) over a 3-plane window; calc_rowwise_and_columnwise_totals
  / calc_features reused unchanged.
- NGTDM: radius-window neighbourhood averages accumulated per voxel over a
  (2*radius+1)-plane window, reproducing calculate()'s unique-over-cube grey levels,
  the "+1 if min level is 0" shift, and the N/S/P construction exactly; calc_* reused.

processNontrivialRois_3D's out-of-core guard now allows all four classes (GLSZM/
GLDZM/GLRLM -- the harder, connectivity/run-based features -- still fail loudly).
test_ooc_mechanics.py gained one OOC-vs-in-RAM equivalence test per feature.

Validated on a 4 GB VM: the same 2.21 GB-footprint ROI computes all four texture
groups out-of-core at 669 MB peak in 27.6s, versus 3.1 GB peak in-RAM -- with
CSV output byte-identical between the two runs.
…CM/GLDM

GLRLM's osized_calculate fell back to calculate() on the whole in-RAM
aux_image_cube. gather_rl_zones() greedily walks each run forward and marks
voxels VISITED, which works in-core but assumes the whole cube is resident.

All 13 canonical directions have dz in {0,1} (half of the 26-connected
directions, avoiding double-counting an axis and its opposite):
- dz==0 (3 directions): a run never leaves its Z-plane, so gather_rl_zones()
  is called UNCHANGED on a depth-1 SimpleCube built from one streamed dense
  plane -- byte-identical to the in-core algorithm, one plane at a time.
- dz==1 (10 directions): a run advances exactly one step per Z-plane, so a
  "carry" array (indexed by the current plane's x,y) tracks each in-progress
  run's length across a 2-plane window, finalizing into a per-row histogram
  the moment a run's continuation check fails, instead of needing the whole
  volume. Peak memory is O(plane area), not O(volume).

Both cases assemble a SimpleMatrix<int> per direction and reuse calc_SRE()/
calc_LRE()/etc. unchanged, so values are identical to calculate().

While validating GLRLM, found and fixed two latent gaps in the already-shipped
GLCM/GLDM streamers: their grey-level LUT (I) was built from mask voxels only,
but the in-core calculate() builds it from the WHOLE binned cube including
background (e.g. matlab-style binning maps the raw-0 background to a nonzero
bin that must still appear in I). Both now include the background bin when
the ROI's bounding box actually contains background voxels. Also fixed a
similar bug introduced while writing GLRLM itself: its grey-LUT construction
must branch on ibsi_grey_binning(greyInfo) (the numeric "no rescale" binning
mode used by calculate()), not the STNGS_IBSI(s) compliance flag used for row
lookup -- conflating the two picked the wrong (sparse) grey-level array under
default settings and threw off every low-gray-level-weighted feature.

Added a non-cuboid (ellipsoid) mask fixture so the ROI bounding box actually
contains background voxels, exercising the grey-LUT gap that a whole-volume
mask never triggers; the existing per-feature fixtures + this new one all
match the in-RAM path at rel-tol 1e-6.

Validated on a 4 GB VM: the same 2.21 GB-footprint ROI computes all five
texture groups (GLCM/GLDM/NGLDM/NGTDM/GLRLM) out-of-core at 849 MB peak in
42.2s, versus 3.28 GB peak in-RAM in the same time -- with CSV output
byte-identical between the two runs.
…omponent labeling

GLSZM and GLDZM both need connected gray-level ZONES (26- and 6-connectivity
flood fills respectively), whose final size/distance isn't known until every
voxel that belongs to them has been explored -- unlike every other 3D texture
feature streamed so far, which only ever needed a small, bounded window.

Add a shared growable union-find (LabelUnionFind, features/streaming_ccl.h)
and use the standard two-pass ("raster scan") connected-component labeling
technique: process Z-planes in ascending order and, within each plane,
voxels in raster order, checking only CAUSAL neighbours -- those already
fully labeled by the scan order (4 same-plane + 9 previous-plane for
26-connectivity/GLSZM, 2 same-plane + 1 previous-plane for 6-connectivity/
GLDZM). The symmetric "future" neighbours are not missed: they get
discovered from the other voxel's perspective once the scan reaches it.
Labels are merged when two previously-separate labels turn out to belong to
the same zone, combining a running per-label metric on union (SUM = zone
size for GLSZM, MIN = distance-to-border for GLDZM) so the final metric is
known the instant the volume finishes streaming -- no second voxel pass
needed. Peak memory is a 2-plane label window plus the label table (bounded
by zone count, worst case O(volume) for pathological data -- the same class
of bound already accepted for GLRLM's run-length histograms).

GLDZM's "distance to border" (dist2border) is 2D-only in the existing
implementation -- it scans left/right/up/down within the current Z-plane
only, never considering Z -- so it needs no extra window beyond the 2-plane
one GLSZM already requires; this is a pre-existing limitation of the
implementation, replicated exactly rather than "fixed".

Both features have their own distinct background-skip convention (a THIRD
variant beyond GLRLM's unconditional zeroI=0 and GLDM/NGLDM/NGTDM's
matlab-aware zeroI): GLSZM uses matlab-aware zeroI like GLDM; GLDZM skips a
voxel only when ibsi_grey_binning(greyInfo) is true and its value is
literal 0 -- for radiomics/matlab binning it never skips, so background
forms its own zone(s). Both replicate the same two-flag split found in
GLRLM (ibsi_grey_binning(greyInfo) for grey-level LUT construction vs
STNGS_IBSI(s) for row lookup, where applicable) and the background-inclusion
fix from GLCM/GLDM.

processNontrivialRois_3D's out-of-core guard now allows both classes
(GLRLM remains the last of the seven 3D texture families; GLSZM/GLDZM
complete the set -- no 3D texture feature is loud-failed any longer).
test_ooc_mechanics.py gained one equivalence test per feature, both folded
into the existing non-cuboid-mask combined test.

Validated on a 4 GB VM: the same 2.21 GB-footprint ROI computes all seven
texture groups (GLCM/GLDM/NGLDM/NGTDM/GLRLM/GLSZM/GLDZM) out-of-core at
940 MB peak in 1:37, versus 3.28 GB peak in-RAM in 1:39 -- with CSV output
byte-identical between the two runs.
…a degenerate-ROI bug they caught

Every test added for the 3D segmented out-of-core work (commits 6963ce1..3cea6ce)
was a positive equivalence check (OOC output matches in-RAM). Nothing exercised
a failure path: a degenerate oversized ROI, a loader format that can't stream
plane-by-plane, or the per-feature guard that is supposed to catch a future
unsupported 3D feature. Add three:

- test_ooc_3d_blank_matches_in_ram: a constant-intensity (degenerate) oversized
  ROI, forced through the out-of-core path for intensity, surface, and all
  seven texture families. Caught a real bug: D3_VoxelIntensityFeatures::
  osized_calculate computed EXCESS_KURTOSIS as val_KURTOSIS-3 instead of calling
  Moments4::excess_kurtosis() directly. The two are algebraically identical for
  n>4 non-degenerate data (why every prior test passed), but each has its own
  independent zero-variance (M2==0) guard returning exactly 0.0 -- subtracting
  3 from kurtosis()'s guarded 0.0 wrongly produced -3.0 instead of
  excess_kurtosis()'s correct 0.0. Fixed in 3d_intensity.cpp.

- test_ooc_3d_nifti_unsupported_format_fails_loudly: NIfTI delivers the whole
  X*Y*Z*T volume in one read, so ImageLoader::stream_volume_planes declines it
  (not plane-by-plane) and processNontrivialRois_3D must fail loudly rather
  than crash or silently emit a wrong row. Uses ram_limit=0, which forces every
  ROI oversized regardless of its actual footprint, so the test doesn't depend
  on knowing the fixture's ROI size in advance.

- TEST_3D_OOC_GUARD_REJECTS_UNSUPPORTED_FEATURE (gtest): every current 3D
  feature class is now streaming-supported, so there is no live "unsupported"
  feature left to exercise the guard through the normal API. Refactored
  processNontrivialRois_3D's inline dynamic_cast chain into a standalone
  is_3d_ooc_supported(FeatureMethod*) function (phase3_3d.cpp, declared in
  globals.h) and unit-test it directly against a minimal stand-in FeatureMethod
  that is deliberately never added to the allow-list, alongside real supported
  classes -- pinning the mechanism that would protect a future 8th feature.

gtest 780/780 (normal), 825/825 (USE_Z5); pytest 92 passed, 1 skipped.
Re-validated on the 4 GB VM: the kurtosis fix doesn't change memory profile or
non-degenerate values (908 MB peak, 18.3s, matching the prior commit's numbers
exactly for every non-degenerate feature).
An oversized whole-volume (single_roi) 3D featurization -- e.g.
Nyxus3D.featurize_directory(dir, dir, ...) with label_dir==intensity_dir, or
the CLI with --segDir==--intDir -- previously had no streaming path at all:
workflow_3d_whole.cpp's oversized branch just failed loudly (a deliberate,
validated fallback from an earlier session, since no whole-volume streaming
existed). It now streams the same way the segmented ROI path does.

Refactored phase3_3d.cpp's inline logic into two shared, reusable functions
(declared in globals.h):
- populate_3d_voxel_cloud(imlo, r, channel, timeframe, wholevolume): streams
  r.raw_voxels_NT plane-by-plane. When wholevolume is true, every voxel is
  kept (there is no mask -- workflow_3d_whole.cpp opens the loader with an
  empty label path, so ImageLoader::stream_volume_planes's segPlane is empty);
  otherwise only voxels matching r.label, exactly as before.
- run_3d_ooc_features(env, r, imloader): runs every requested feature's
  out-of-core path over an already-populated cloud, guarded by
  is_3d_ooc_supported(), writing into r.fvals.

processNontrivialRois_3D (segmented path) now calls both -- pure refactor, no
behavior change there. workflow_3d_whole.cpp's oversized branch now calls
both too instead of returning false: streams every voxel (wholevolume=true),
runs the same feature set as the segmented streaming path, and succeeds. It
still fails loudly only when the loader can't deliver the volume
plane-by-plane at all (e.g. NIfTI, a whole-4D-in-one-read format) -- the one
case with no possible streaming path.

Superseded TEST_3D_WHOLEVOLUME_OVERSIZED_FAILS_LOUDLY with
TEST_3D_WHOLEVOLUME_OVERSIZED_STREAMS_OOC (proves an oversized streamable
volume now succeeds and matches the in-RAM run exactly) and a new
TEST_3D_WHOLEVOLUME_UNSTREAMABLE_FORMAT_FAILS_LOUDLY (proves NIfTI still
fails loudly, no streaming path exists for it). Writing these tests surfaced
two test-harness gaps (not product bugs): a fresh gtest Environment must call
theFeatureMgr.compile()/apply_user_selection()/init_feature_classes() and
compile_feature_settings() before running any workflow -- skipping the
former left get_num_requested_features()==0 (an all-zero output row);
skipping the latter left every Fsettings vector at size 0, so a feature that
reads a settings flag (e.g. D3_SurfaceFeature's STNGS_SINGLEROI) read out of
bounds and crashed. main_nyxus.cpp already does this whole sequence; the two
prior whole-volume gtests (TEST_3D_WHOLEVOLUME_REDUCE, the old
OVERSIZED_FAILS_LOUDLY test) happened to use only intensity, whose code path
never touches an out-of-bounds settings index, so this was never exposed
before. Added a Python-level end-to-end test too
(test_ooc_3d_wholevolume_streams_oob), exercising the real
Nyxus3D.featurize_directory API path a user would actually call.

gtest 781/781 (normal, was 780), 826/826 (USE_Z5, was 825); pytest 93 passed,
1 skipped (was 92). Validated on a 4 GB VM: the same 2.21 GB single-file
whole volume (no mask) computes intensity+surface+GLCM out-of-core at 365 MB
peak in 22.6s, versus 2.64 GB peak in-RAM in 12.2s -- CSV output
byte-identical between the two runs.
…overs

This session's 3D out-of-core work (segmented + whole-volume streaming for
intensity, surface, and all 7 texture families) is covered end-to-end by
test_ooc_mechanics.py, but the coverage registry (tests/vetting/
oracle_coverage.csv) predates that work and didn't list it anywhere.

Per SPEC.md's taxonomy, these are Mechanics tests (do the OOC/in-RAM paths
agree, not "does Nyxus match an external tool") -- they don't claim vetting,
so status/oracle/agreement are left untouched; only current_test gains the
file, the same way existing mechanics-only files (e.g. test_gldm_mechanics.h)
are already listed there.

Added to all 213 dim=3D rows across the families this test file actually
exercises (firstorder=36, morphology=14, glcm=59, gldm=14, gldzm=18,
glrlm=32, glszm=16, ngldm=19, ngtdm=5 -- verified against each feature's
FeatureMethod::featureset before editing). Applied via a small script
(csv module, not manual edits) to keep the ~190-row change mechanical and
auditable; verified afterward that no other column changed and
check_coverage.py --check still passes.
The existing v3 sharded fixture (dim5_v3_sharded.ome.zarr) has exactly one
shard per (t,c) -- its shard's Y/X extent equals the full plane, so it only
ever proves multiple inner chunks packed into ONE shard file. It never
exercises the volumetric assembly crossing a shard-FILE boundary mid-plane,
which is the more realistic layout for a larger Axle v3 store.

Add write_v3_multishard() to gen_dim5.py: a new fixture
(dim5_v3_multishard.ome.zarr, C=2 T=1 Z=8 Y=24 X=32) whose shard covers the
full Z depth but only half of Y and half of X, giving a genuine 2x2 grid of
shard FILES per (c,t) -- 8 shard files total. Inner chunk is kept at
z=1 deliberately: this codebase's OME-Zarr readers (raw_omezarr.h/omezarr.h)
read exactly one Z-plane per tile regardless of tileDepth(), so a Z-chunk>1
silently under-reads (found while first drafting this fixture with z=2).
That's a separate, real, pre-existing chunking-granularity gap, unrelated to
the shard-FILE-crossing behavior this fixture targets, so it's dodged here
(documented in the fixture's docstring) rather than fixed as a drive-by.

Add three gtests exercising the new fixture inside the existing
OMEZARR_SUPPORT block: a per-voxel facade-volume check across both channels
(the real proof of correct shard-file assembly), a channel/timeframe/depth
count check via both loader classes, and a whole-slide prescan check
(min/max/area across both channels, not just channel 0).

build-test-z5: 829/829 (was 826). build-test (normal, unaffected -- these
tests compile out entirely outside OMEZARR_SUPPORT): 781/781, unchanged.
omezarr.h (NyxusOmeZarrLoader, via ImageLoader::assemble_volume) and
raw_omezarr.h (RawOmezarrLoader, via RawImageLoader::for_each_voxel) both
called loadTile() with shape[iz_] left at its default of 1, so every read
returned exactly one Z-plane starting at the requested offset -- regardless
of tileDepth(), which correctly reports the chunk's real Z extent. For any
store with a Z-chunk > 1, every plane past the first within a chunk silently
came back zero, even though the caller-allocated tile buffer (sized off
tileDepth()) and the pz-loop in both consumers already expected the full
chunk depth per read.

No existing fixture ever used a Z-chunk > 1 (the module docstring in
gen_dim5.py documents "chunked one z-slice per chunk" as the established
convention), so this was latent until building a larger multi-shard-file
fixture surfaced it: an early draft with a 2-plane Z-chunk read back zero
past z=0 of every chunk. That draft was redesigned around the gap rather
than fixing it (out of scope for a fixture-only session); this commit does
the actual fix and adds a fixture that pins it down.

Fix: read the chunk's real Z-extent (shape[iz_] = tile_depth_, clamped at
the last partial chunk) and copy all returned planes into the destination
tile at the plane-major stride both consumers already expect.

New fixture dim5_v3_zchunked.ome.zarr (gen_dim5.py write_v3_zchunked):
Z=7 chunked at 3 (uneven 3+3+1 split, exercising the partial-last-chunk
clamp), unsharded so it isolates Z-chunking from the shard-file-addressing
already covered by dim5_v3_multishard. Two new gtests exercise the two
independent consumers of the fixed code: TEST_OMEZARR_V3_ZCHUNKED_FACADE_VOLUME
(per-voxel check via assemble_volume) and TEST_OMEZARR_V3_ZCHUNKED_PRESCAN
(via for_each_voxel/scan_slide_props).

build-test-z5: 831/831 (was 826). build-test (normal, unaffected -- these
files only compile under OMEZARR_SUPPORT): 781/781. pytest: 93 passed,
1 skipped, unchanged.
…nyxus main

Rebasing main-io onto upstream/main (16 new commits: morphology/caliper fixes,
GLDM/GLSZM/GLRLM IBSI/PyRadiomics vetting) pulled in upstream commit 0af100b,
which fixed a 3D surface axis-length bug in the in-RAM D3_SurfaceFeature::
calculate() path: calc_eigvals() returns eigenvalues sorted descending, but
the axis lengths were indexed MAJOR<-L[1]/MINOR<-L[2]/LEAST<-L[0] instead of
MAJOR<-L[0]/MINOR<-L[1]/LEAST<-L[2], producing LEAST>MAJOR and FLATNESS>1
(both structurally impossible).

That fix never reached this branch's out-of-core osized_calculate() -- a
separate, independently-written streaming implementation of the same PCA
axis-length computation -- since it didn't exist yet when 0af100b was
written upstream. TEST_3D_WHOLEVOLUME_OVERSIZED_STREAMS_OOC (which asserts
the OOC and in-RAM rows are byte-identical) caught the divergence right
after the rebase. Applied the identical index correction to osized_calculate().

Also fixed tests/test_3d_glrlm_pyradiomics.h: a new upstream test function
(test_compat_3glrlm_ave_features, from commit cb07b9d) called
gatherRoisMetrics_3D/scanTrivialRois_3D with the pre-IO-project 5-argument
signature; every other 3D test file already carries the 6-argument form
(added `channel`) that these two functions require, so this one call site
was updated to match.

Full re-validation after the rebase: gtest build-test 792/792 (was 781),
build-test-z5 842/842 (was 831), pytest 93 passed/1 skipped (unchanged).
Push still on hold.
…ted image (2D+3D)

A vetting-registry note claimed 3COVERED_IMAGE_INTENSITY_RANGE was always 1 in the 3D
segmented workflow because roi.slide_idx was never assigned. That plumbing has actually
been correct since a much older upstream commit (b95a31e, PolusAI#304) -- confirmed via git
history and a runtime check. The real bug is elsewhere:

gatherRoisMetrics_2_slideprops_3D (slideprops.cpp) computes the "pre-ROI" (whole-slide)
min/max intensity baseline that this feature divides the ROI's own range by, via
RawImageLoader::for_each_voxel. That helper's documented contract only visits IN-MASK
voxels (an intentional perf optimization for the geometry-only work it was written for).
So for any genuinely segmented image (mask smaller than the full image/volume), the
"whole slide" baseline silently collapsed to the ROI's own min/max, making the ratio
exactly 1 every time. The identical pattern exists in the 2D counterpart
(gatherRoisMetrics_2_slideprops, same file) -- confirmed empirically before touching any
code, and fixed alongside the 3D case since it's the same root cause in the same file.

Fix: for_each_voxel no longer skips off-mask voxels (it's called for every voxel now;
verified via grep that slideprops.cpp is its only caller, so this doesn't change behavior
for anyone else). Both slideprops.cpp scan loops (2D and 3D) now update the slide-wide
min/max unconditionally and only gate ROI-geometry tracking (labels/area/AABB) on the
mask, so a "label 0" background pseudo-ROI never gets fabricated.

Verified against ground truth via ad-hoc Python checks before and after: 3D ratio went
from 1.0 to 0.8336653279209243, 2D from 1.0 to 0.6607353648406418, both now matching an
independently-computed (roi_max-roi_min)/(slide_max-slide_min) from the raw arrays.

test_3d_coverage_common.h's 3COVERED_IMAGE_INTENSITY_RANGE golden (1.0002043207290587)
was itself baked from the old bug (its fixture is genuinely segmented) -- updated to the
new correct value 0.66137566137566139. test_3d_inten.h's hardcoded 1.0 golden is
untouched and still passes -- that fixture is genuinely whole-slide, where 1.0 is
correct, not a symptom.

Added tests/python/test_covered_intensity_range_regression.py: nothing before this
explicitly tested a segmented image with real off-mask background for this feature, in
either dimension -- every existing assertion was either whole-slide or happened to land
on a non-1.0-but-still-wrong value by coincidence. The new tests assert against an
independently-computed expected ratio (not a canned golden) plus a sanity floor, so they
can't go stale the way the old goldens did.

Updated oracle_coverage.csv: both COVERED_IMAGE_INTENSITY_RANGE rows list the new test;
the 3D row's flag/notes now describe the real root cause and fix instead of the stale
slide_idx claim, and note that oracle vetting against the existing octave/oracle_3d
harness is unblocked but not re-run here.

build-test: 792/792. build-test-z5: 842/842. pytest: 95 passed, 1 skipped (was 93).
physX/physY/physZ were stored verbatim from whatever unit a file's OME-XML/
NGFF metadata declared (nanometer, millimeter, ...), with zero conversion.
Two calibrated files using different units produced numerically
incomparable phys_x/y/z output columns. Also found along the way: the
exposed physicalSizeUnit() returns only unitXY, so a Z axis calibrated in a
genuinely different unit than X/Y had its own value silently mislabeled
under X/Y's unit string.

Fix applied at OME metadata parse time (ome_tiff_meta.cpp / ome_zarr_meta.cpp),
where each axis's own declared unit (unitXY for X/Y, unitZ for Z, or NGFF's
per-axis unit field) is already available before any of it reaches the
loader interface -- converting there fixes both the missing conversion and
the X/Y-vs-Z conflation as one change, with no interface changes needed
across the loader backends (OME-TIFF, OME-Zarr, NIfTI, raw TIFF).

New shared helper in ome_axes.h: unit_scale_to_micrometer() (a lookup table
covering meter/centimeter/millimeter/micrometer/nanometer/angstrom/
picometer, full names plus common abbreviations; returns a sentinel for an
unrecognized or empty unit so those are left untouched rather than silently
misscaled) and canonicalize_to_micrometer() (applies the scale, relabels
the unit "micrometer"). raw_nifti.h never implemented physicalSize*()
overrides at all (reports uncalibrated via the base-class default), so
canonicalization is a correct no-op there -- confirmed, not touched.

New tests: 7 UnitCanonicalization cases in test_ome_meta.h (the conversion
table directly; unrecognized/empty left as-is; OME-TIFF and OME-Zarr each
declaring X/Y and Z in two DIFFERENT units, both converting to the correct
common target; already-micrometer no-op / no double-conversion). New
fixtures dim5_calibrated_nm.ome.zarr / dim5_calibrated_nm.ome.tif encode
the same physical spacing as the existing dim5_calibrated.* fixtures but in
nanometer/millimeter, proving conversion (matches the existing fixture's
output exactly) rather than passthrough. One pre-existing golden fixed:
OmeTiffMetaBad.SizeXNotConfusedWithPhysicalSizeX asserted the raw
pre-canonicalization physX/unitXY; its actual intent (SizeX not misread as
PhysicalSizeX) is unaffected, updated to the correct canonicalized values.
tests/python/test_physical_calibration_regression.py is new end-to-end
coverage through the real Nyxus3D API -- no prior Python test exercised
phys_x/y/z/phys_unit output at all, not just the conversion gap.

While writing that Python test, found (but did NOT fix, and is unrelated to
this change) a hang: Nyxus3D(..., use_physical_spacing=True).featurize_directory
in whole-volume mode never returns for a multi-channel/timeframe,
anisotropically-calibrated OME-TIFF. Reproduces identically on the
pre-existing micrometer fixture with no nm/canonicalization involved, and
disappears entirely with use_physical_spacing left at its default -- almost
certainly in the anisotropic resampling path itself, not metadata parsing.
Flagged in io-state.md as a new backlog item; the new test deliberately
avoids that flag to stay clear of it.

build-test: 796/796 (was 792). build-test-z5: 849/849 (was 842). pytest: 96
passed, 1 skipped.
scan_trivial_wholevolume_anisotropic reassigned its own for-loop counter
(`i`) to the physical voxel index mid-body, so the loop's own `i++` then
advanced by the physical stride instead of by one virtual voxel on every
subsequent iteration -- the exit condition `i < nVox` could run far longer
than nVox iterations before (if ever) being satisfied again. That's the
hang behind Nyxus3D(use_physical_spacing=True).featurize_directory never
returning in whole-volume mode. Same line had a second, independent bug:
the row stride used fullH (height) instead of fullW (width), which would
have read wrong voxels once the hang was fixed. Fixed both: the physical
index now goes into its own local (phys_i), the stride is correct, and
ph_x/ph_y/ph_z are defensively clamped against float-rounding at the
anisotropy-ratio boundary. The segmented anisotropic sibling
(scanTrivialRois_3D_anisotropic) already uses a separate loop variable and
the correct stride, so it wasn't affected by either bug.

Fixing the hang immediately exposed an out-of-bounds crash: vroi.aabb is
preset from the physical slide dimensions before the scan runs (needed for
an earlier footprint check), but the resampled voxel cloud the anisotropic
scan populates spans a larger volume than that preset aabb -- so
aux_image_cube was allocated too small and calculate_from_pixelcloud wrote
out of bounds. Fixed by recomputing vroi.aabb from the actual voxel cloud
via AABB::update_from_voxelcloud after the scan, mirroring a fix the
segmented path (processTrivialRois_3D) already had for the identical
mismatch.

That fix in turn exposed a third bug: vroi.aux_area -- the denominator
MEAN and other count-normalized features divide by -- is also preset from
the physical (pre-resample) voxel count and was never updated afterward,
so MEAN came out exactly 4x too large for a 4x Z-upsample. Fixed the same
way (aux_area = raw_pixels_3D.size() post-scan).

Checked whether the segmented path has the identical aux_area gap: yes.
processTrivialRois_3D has two near-identical update_from_voxelcloud call
sites (the main batch loop and the "process what's remaining pending"
cleanup); fixed both, catching along the way that the two blocks differ by
a single space in formatting, which silently defeated a first find-and-
replace attempt on only one of them.

Verified every fix against an independently-computed expected value, not
just "does it still run": the whole-volume MEAN is exactly resampling-
invariant (truncation-based duplication) and now matches the isotropic run
bit-for-bit; the segmented case's rounding-based nearest-neighbor mapping
is NOT uniform (a boundary Z-slice is under/over-represented depending on
where +0.5 rounding lands), so its expected MEAN was computed by
replicating the exact C++ algorithm in Python against the real fixture
arrays -- matched the production value to 10+ significant figures.

Also hit, and deliberately left unfixed as a separate pre-existing bug:
LR::get_ram_footprint_estimate(n_slide_rois) computes
(n_slide_rois - 1) * sizeof(int) -- for n_slide_rois == 0 (a batching
loop's first iteration), the size_t underflow and subsequent overflow can
silently misroute a tiny single-ROI batch through the "oversized, scan
immediately" branch. Worked around it in the new segmented gtest by
calling scanTrivialRois_3D_anisotropic directly (newly declared in
globals.h) instead of through the batching wrapper. Flagged in
io-state.md as a follow-up, not fixed here.

New tests: TEST_3D_WHOLEVOLUME_ANISOTROPIC_REDUCE_MATCHES_ISOTROPIC and
TEST_3D_SEGMENTED_ANISOTROPIC_AUX_AREA_MATCHES_VOXELCLOUD. Nothing before
this exercised the actual featurize+reduce path for anisotropic 3D --
existing coverage was limited to the anisotropy decision function and the
prescan's aabb scaling.

build-test: 798/798 (was 796). build-test-z5: 851/851 (was 849). pytest:
96 passed, 1 skipped (unchanged).
Both footprint estimators compute a "neighbors" term as
(n_rois - 1) * sizeof(int). Most callers correctly pass
env.uniqueLabels.size() (total ROI count for the slide, never 0 once
there's at least one ROI). Three callers -- processTrivialRois /
_25D / _3D's batching loops -- instead pass the in-progress batch size
(Pending.size() / trivRoiLabels.size()), which is exactly 0 on every
batch's first item: size_t(0-1) underflows to SIZE_MAX, and the
subsequent multiply overflows to another huge wrapped value.

Practical effect: the first ROI considered for any batch, in 2D, 2.5D, and
3D segmented runs alike, got a garbage-huge itemFootprint that almost
always exceeded memory_limit, silently forcing the "oversized, scan this
ROI alone right now" branch instead of genuinely accumulating a batch --
batching was effectively never happening. Not a crash or wrong values
(each ROI's own scan+reduce+free is independent of what else shares its
batch), but a real, silent performance/memory-management defeat.

Fixed narrowly at the shared formula rather than changing the three
callers: n_rois == 0 now contributes 0 to the neighbors term instead of
underflowing, in both get_ram_footprint_estimate and
get_ram_footprint_estimate_3D. This restores genuine batching as a side
effect without touching caller semantics -- whether Pending.size() or
env.uniqueLabels.size() is the "right" quantity for those three call
sites to pass is a separate, unresolved design question, not something
this fix decides.

New test: TEST_RAM_FOOTPRINT_ESTIMATE_ZERO_ROIS_DOES_NOT_UNDERFLOW --
n_rois=0 and n_rois=1 must produce the identical footprint, staying in a
sane range instead of the ~16-exabyte value the underflow produced, for
both estimators.

build-test: 799/799 (was 798). build-test-z5: 852/852 (was 851). pytest:
96 passed, 1 skipped (unchanged -- values are identical, only batch
grouping changed).
The two fixture generators' docstrings documented their run command with a
hard-coded local conda install path. Replaced with the $CONDA_PREFIX
placeholder this repo's conventions call for, so the instruction is
reproducible on any machine.

Three test comments referred to the IO work's internal planning-phase numbers
("Phase 5 negative", "Phase 6 physical-calibration logic") which carry no
meaning outside that plan. Reworded to state what the test actually asserts.

Comments and docstrings only; no behavior change.
Demian Vladi added 6 commits July 23, 2026 09:26
libc++ implements std::from_chars for integral types only -- its
floating-point overloads are deleted or constrained to is_integral -- so
reading PhysicalSize* and TimeIncrement through it did not compile on macOS
("call to deleted function 'from_chars'"), while MSVC and libstdc++, which
both implement the floating-point form, accepted it. That one translation
unit took down the macOS GoogleTest and PyTest jobs and every macOS wheel.

Read those doubles through an istringstream imbued with the classic locale
instead: it is available on every standard library, and unlike strtod it is
unaffected by an LC_NUMERIC that a process embedding the library may have
set to a comma-decimal locale. Parsing behavior is otherwise unchanged --
absent, non-numeric and non-finite values still fall back to the default,
and the isfinite guard still rejects the "nan"/"inf" prefixes the stream
accepts. The size readers keep using from_chars, which libc++ does provide
for integral types.

Also include <algorithm>, <cstdint> and <stdexcept> directly in the two new
translation units that use std::min, std::sort, the fixed-width integer
types and std::runtime_error. The MSVC STL and libstdc++ happen to supply
these transitively; libc++ does not, and the macOS build stopped at the
error above before ever reaching either file.
The overview and usage sections still described 3D input as NIFTI-only, which
has not been true since OME-TIFF and OME-Zarr gained native volumetric reading.
Corrected both, noting that the axis order of a 5D (X,Y,Z,C,T) dataset comes
from the file's own metadata rather than being assumed.

Added to the 3D usage section: reading OME volumes, per-channel/timeframe
featurization and the c_index / t_index columns that identify the source frame,
and the opt-in use_physical_spacing calibration -- what it derives the
anisotropy from, that explicit anisotropy_x/y/z still wins, and the
phys_x/y/z/phys_unit output columns it reports in canonicalized micrometers.

Added a command-line example deriving anisotropy from declared physical
spacing, alongside the existing explicit-anisotropy one.
…H_ZSTD

Defining WITH_ZSTD is what makes z5's zstd_compressor.hxx include <zstd.h>:
the whole header body sits behind that macro, so leaving it undefined is a
clean no-op, but defining it without the header on the include path is a
fatal error. The macOS wheel builds are exactly that case -- libzstd is
present in the dependency prefix while zstd.h is not -- so every macOS wheel
died on "'zstd.h' file not found" in the first translation unit that pulls in
z5.

Look for the header alongside the library, enable the codec only when both
are present, and put the header's directory on the include path so the
compiler can actually find it. The message on the negative branch now reports
which of the two was missing, since "not found" was ambiguous exactly when it
mattered.

Compile the zstd Zarr v3 test on the same condition. It was unconditional
inside OMEZARR_SUPPORT, so a build that legitimately lacks zstd -- which this
CMake logic explicitly allows, still reading uncompressed, blosc and zlib v3
stores -- would fail on a fixture it cannot decode. dim5_v3_zstd is the only
fixture using the codec; the sharded ones use crc32c and are unaffected.
test_ooc_montage_oversized_fails_loudly and the covered-intensity-range
regression passed ram_limit=8000 (8.4 GB) to mean "large enough that this ROI
stays in RAM". Nyxus refuses a limit above available RAM and keeps the previous
one, and since the effective limit is a process-global applied when featurize
runs, a refused setting silently inherits whatever the last test left behind --
0 or 1 MB in this file. The sanity half of the test then classified its 300x300
ROI as oversized and raised, so the test that asserts a loud failure failed for
the wrong reason.

That turned the constant into a host-memory dependency: 8.4 GB is granted on a
workstation and refused on an ~8 GB runner, which is why the suite passed
locally and on Windows CI while failing on the Linux ones.

1000 MB clears the largest fixture here (a few MB) by three orders of magnitude
and is grantable on any machine that can run the suite at all. Named it in
test_ooc_mechanics.py, where the value appears five times, so the constraint is
stated once rather than per call site.
The in-class static const was ODR-used by (std::min)(BLK_RECORDS, ...) via
const-reference binding but had no out-of-line definition. MSVC tolerates
this; GCC on the Ubuntu CI reports an undefined symbol. constexpr is
implicitly inline in C++17+ (this repo builds as C++20), so no separate
definition is required.
@vjaganat90

Copy link
Copy Markdown
Member

Split or stage the merge
51 commits / 518 files / +10k lines is hard to review and hard to revert. I insist on stacked PRs or merge order: (A) OmeAxes + loaders + tests, (B) load_volume + c_index/(c,t) loop, (C) OOC voxel cloud + intensity/surface, (D) texture OOC, (E) physical spacing + docs. Even if history stays one branch, review in those layers.

Update user-facing docs in this PR (or a same-day follow-up)
README/CLAUDE still say “3D = NIfTI.” That is a merge blocker for usability: native OME-TIFF/Zarr 3D/5D, OOC, --use-physical-spacing, c_index/phys_*. Without docs, “feature complete” is only true for people who read the PR body.

CI must exercise the new paths
Local Windows 799/852 + one 3.8 GB Linux VM is strong evidence, but merge confidence needs CI for: USE_Z5 5D fixtures, OOC vs in-RAM equivalence tests, and ideally at least one forced-ramLimit 3D job. If OOC tests only run on the your machine, regressions will land on main unnoticed.

Even with full help of AI tools, I am having hard time right scoping and fully critiquing this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants