feat(io): Native 5D IO + True 3D Out-of-Core Featurization - #389
feat(io): Native 5D IO + True 3D Out-of-Core Featurization#389darkclad wants to merge 52 commits into
Conversation
…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.
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).
…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.
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.
…peed up OOC paths
|
Split or stage the merge Update user-facing docs in this PR (or a same-day follow-up) CI must exercise the new paths Even with full help of AI tools, I am having hard time right scoping and fully critiquing this PR. |
…tch and 3D intensity comments
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:
of assuming a single 2D plane / hard-coded axis order.
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): adimensionOrderstring (e.g."XYZCT") plus astorageAxesvector 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 fixedposition — this is what makes
TCZYX,TZCYX,CZYX,ZYX, andYXall correctly readablethrough the same code path, replacing the old hard-coded "always
TCZYX, always plane 0" reader.OME-TIFF populates
OmeAxesfrom the OME-XMLDimensionOrder/TiffDatablock; OME-Zarrpopulates it from the NGFF
axes+multiscalesmetadata. Everything downstream — the loaderfacades, the 3D consumer (
phase2_3d.cpp), the output schema,SlideProps— only ever consumesOmeAxes, never format-specific metadata directly.1.2 Design principles
triggers when OME metadata is actually present. Every phase was independently shippable.
loadTileFromFile(...,channel,timeframe,level)) was applied to bothAbstractTileLoaderandRawFormatLoader, and to all three format-dispatch sites, to avoid the build fragmenting intotwo half-migrated stacks.
featurization (one row per ROI × timepoint × channel) to preserve information rather than
silently collapsing channels.
CLI
--anisox/y/z> OMEPhysicalSize*(opt-in via--use-physical-spacing) > isotropic1.0. Reconciled in one place (
resolve_slide_anisotropy).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
tensorstoredependency 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 oneZ-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-spacingis used, the OMEPhysicalSizeX/Y/Zvalues are converted to a rationormalized 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, the3D analog of the existing 2D
OutOfRamPixelCloud, with a block cache (65536 records/block) soreads aren't done one
fseek/freadper voxel.shape:
read_slab(z); surface area reformulated as6N − 2·(adjacent voxel pairs)over a 2-plane occupancy-bitmap window; PCA axis lengthsaccumulated online; hull volume via
det4.(offset+1)dense grey-binned Z-planes.(2·radius+1)-plane streaming window.dz ∈ {0,1};dz=0reuses the existingsame-plane run-gathering code,
dz=1uses a "carry" array tracking in-progress runs acrossa 2-plane window.
component labeling via a growable union-find (
features/streaming_ccl.h). The keyinsight, 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.
primitives as the segmented (masked ROI) OOC path via
populate_3d_voxel_cloud/run_3d_ooc_features."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 volumeis treated as "oversized" (streamed OOC) rather than trivial (loaded whole). Default, if not
specified, is 50% of available system RAM (
getAvailPhysMemory()); requesting a limit aboveactual available RAM is rejected rather than silently overcommitting.
2. Features delivered
DimensionOrder-driven) and OME-Zarr (both v2/"0.4" and v3/"0.5", both via the existingz5 library — no new dependency), replacing the old assumption of a single (c=0,t=0) plane.
c_indexoutput column(alongside the pre-existing
t_index) in every sink — CSV, in-memory buffer, and Arrow.--use-physical-spacing/use_physical_spacing=True:new
phys_x,phys_y,phys_z(numeric) andphys_unit(string) output columns, populatedfrom OME
PhysicalSize*/coordinateTransformationsmetadata and canonicalized tomicrometers across mismatched per-axis units. Manual override via
--anisox/y/zstill worksand takes precedence, unchanged from before this project.
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.
workflows (a hang and 3 related correctness bugs in the whole-volume path were found and
fixed — see §3.1).
for v2 — the version is auto-detected (
zarr.jsonvs.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)
OmeAxesdescriptor + format detection (detect_input_format) for both containers.loadTileFromFile(..., channel, timeframe, level)) appliedsymmetrically across both loader stacks (10 concrete loader overrides).
DimensionOrder(strip loaders, then tiled loaders).c_indexcolumn, per-channel default policy.--use-physical-spacing,phys_*/phys_unitcolumns, unitcanonicalization.
planned
tensorstoredependency was never added.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 thatalways 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 actualZ-depth (any store chunked with Z>1 silently returned zero past the first plane of each chunk);
COVERED_IMAGE_INTENSITY_RANGEwas degenerate (always exactly 1.0) for any segmented 2D or3D 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
3MEANcome out exactly 4x too large — all fourfixed, and the fix was extended to the equivalent segmented-ROI code path once the same gap was
found there; and a
size_tunderflow in the ROI memory-footprint estimator (triggered wheneverthe 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:
primitive, detailed in Appendix A).
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).
tests on the first try, attributed to scoping the streaming design in a written note before
writing code).
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_KURTOSISused an algebraic shortcut that diverges from thecorrect value specifically on zero-variance data.
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)
build-test(gtest, no z5)build-test-z5(gtest, with z5/OME-Zarr)pytest tests/python/io_oracle/, independent tifffile/ome-zarr-py cross-check)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), then_rois==0footprint-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
--ramLimitargument), including one case where the in-RAM path was OOM-killedby the kernel while the OOC path on the same fixture succeeded:
3MEANexact match to a hand-derived closed form for all framesThe 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 tothe same hand-derived formula,
np.allcloseon 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:--dim=3--ramLimit=<MB>--anisox,--anisoy,--anisoz--use-physical-spacingPhysicalSize*metadata instead of manual--aniso*.Example — native 3D OME-TIFF, in-RAM, per-channel/per-timepoint output:
Output rows now include one row per (ROI,
t_index,c_index) for every channel/timepointfound 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):
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):
Resampling factors are derived from the file's own
PhysicalSizeX/Y/Z(converted to a commonunit, then ratio-normalized) instead of requiring manual
--anisox/y/z. Output gainsphys_x,phys_y,phys_z,phys_unitcolumns.Example — manual anisotropy override (pre-existing, still supported, e.g. for NIfTI):
5.2 Python API (
Nyxus3D)Relevant
Nyxus3Dconstructor kwargs:ram_limit(int, MB; default -1 = no limit/whole-RAM),use_physical_spacing(bool, defaultFalse),anisotropy_x/y/z(float, default1.0).Gotcha:
ram_limit,use_physical_spacing, and the anisotropy factors are constructor-only—
set_environment_params(**params)does not accept them and always resetsram_limitto"no limit" internally. To change these, construct a new
Nyxus3Dinstance.6. Changes to current usage
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") andCLAUDE.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 3Dfeaturization, and the
c_index/phys_*output columns.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 defaultvalues).
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, onlyperChannelshipped.ROI/volume either loaded fully into RAM (risking OOM) or, for whole-volume mode, failed
outright. Now
--ramLimit/ram_limitgoverns a genuine disk-streaming path with identicaloutput 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.
COVERED_IMAGE_INTENSITY_RANGEpreviously alwayscomputed to exactly
1.0for any segmented (non-whole-slide) image, in both 2D and 3D. It nowreflects 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.
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.
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.
<TiffData>/<UUID>cross-file plane references,.companion.ome). Detected (multiFileTiffflag) and rejected, never resolved — realsupport would need coordinating multiple open loader instances per logical dataset, a
structural change rather than a bolt-on.
addressing model, by far the largest of the three declined items.
labels/-group masks. Declined despite being comparatively small, to keep theformat-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'sdist2borderremains 2D-only (a pre-existing limitation, replicated not fixed inthe new streaming implementation).
TiffDataplane maps andSubIFDpyramids are not parsed — reads fallback to default rasterization.
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)
processTrivialRois/_25D/_3D's in-progress-batch-sizecallers pass
Pending.size()(starts at 0 for every batch) to the footprint estimator, whileother callers correctly pass the total slide ROI count (
env.uniqueLabels.size()). Then==0underflow this exposed is fixed (§3.1), but whetherPending.size()is thesemantically 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:
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.
falling through to the whole-cube trivial calculation. Surface: the out-of-core entry
point was empty.
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:
Depth is thousands at most, so the index is a few tens of KB; everything else stays on disk.
API shape:
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:
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:
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
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).
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).
green before any push.
A.6 Later families (reuse this same primitive, designed separately)
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:
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.
table).
If two different labels both match (the zone was discovered from two separate arms not yet
known to be connected), union them.
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.
(intensity, metric)pair per root, andfeed 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 everyother 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 ofbound 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
straight up/down).
convention — must not reuse GLRLM's constant here.
(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.
index.
B.4 GLDZM specifics
than GLSZM.
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.
with
minon union.the correct convention) and a plain linear search (no separate boolean-flag branch) for row
lookup — simpler than GLSZM, no two-flag trap here.
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:
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.
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.
for row lookup), confirmed present in GLSZM, absent in GLDZM.
voxels (the class of bug found and fixed for GLCM/GLDM earlier in this project) — checked
for both.
caught the background-lookup-table gap the first time this class of bug appeared.
B.6 Implementation plan (as executed)
parent[]/metric[]table withfind(pathcompression) and a caller-supplied combine function (sum for GLSZM, min for GLDZM), so one
helper serves both features.
union-find, final root walk → matrix fill → reuse existing feature-calculation functions.
distance from the current plane, minimum-tracking union-find, final root walk → matrix fill
→ reuse existing feature-calculation functions.
equivalence tests against the in-RAM calculation, including the partial/non-cuboid-mask
fixture.
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.