Skip to content

fix(texture): square zone sizes, run lengths and grey levels in double - #485

Open
darkclad wants to merge 12 commits into
PolusAI:mainfrom
darkclad:main-texture-int-overflow
Open

darkclad wants to merge 12 commits into
PolusAI:mainfrom
darkclad:main-texture-int-overflow

Conversation

@darkclad

@darkclad darkclad commented Oct 14, 2026 •

Copy link
Copy Markdown

Summary

GLSZM and GLRLM squared a zone size or run length in int, and a grey level in unsigned, before converting to double:

  • A zone or run of 46,341 or more overflowed int, which is undefined behaviour.
  • A grey level × run length of 65,536 or more wrapped unsigned silently. No sanitizer reports that.

Whole-slide and whole-organ ROIs reach both routinely. On the lung CT behind the 3D binning report (3.6M voxels, largest zone about 410,000), the result was:

feature before PyRadiomics binCount=20 after
3GLSZM_SAE 0 0.5350096914613482 0.535009691461 (rel 6e-16)
3GLSZM_LAE 76,324.1 (142× low) 10,864,483.28 10,864,483.28 (rel 0)

The other 14 3D GLSZM features matched before and still match; ZE differs only by the known fast_log10 residual.

Change

Every affected product is now taken in floating point before multiplying: double on the CPU, which 3D GLDM already did, and float in the GPU kernel, whose accumulators are float. Each changed square carries a comment saying why it is taken in floating point.

file features
glszm.cpp, 3d_glszm.cpp, gpu/glszm.cu SAE, LAE
glrlm.cpp, 3d_glrlm.cpp SRE, LRE, LGLRE, HGLRE, SRLGLE, SRHGLE, LRLGLE, LRHGLE

3D HGLRE already squared the grey level in double; in 3d_glrlm.cpp its line is rewritten to the same inten * inten form as the other emphases, and its analytic assertion pins the value without discriminating the fix. The two #if 0 copies of the old 3D GLSZM SAE/LAE are removed.

Follow-ups are tracked on Axle's PN Jira board (https://labshare.atlassian.net/jira/software/projects/PN/boards/851), which is where the PN keys below point.

Other texture families were surveyed:

  • GLDM, GLDZM and NGLDM take their squares in double, or square a bounded index: a dependence count is at most 9 in 2D and 27 in 3D (26 neighbours plus the voxel itself).
  • GLCM's inverse difference moment still squares an int grey-level difference, in 2D and 3D. It overflows only past 46,341 grey levels, where the dense Ng × Ng co-occurrence matrix alone needs 17 GB. Filed as PN-260.
  • NGTDM's contrast normaliser Ngp * (Ngp - 1) (ngtdm.cpp, 3d_ngtdm.cpp) is still an int product. It overflows at 46,342 grey levels present in the ROI, which a 16-bit ROI without binning can reach, and Contrast then divides by the wrapped value. Filed as PN-271.

The GPU kernel change is compiled only in a CUDA build and was not run. That kernel accumulates every GLSZM feature in float, so on large zones its SAE and LAE are float-precision and are not compared against the CPU path; that is tracked in PN-242.

Tests

Six closed-form (analytic) tests at rel=1e-12, not SPEC 7's abs 1e-9, because the values run from below 1e-9 to past 1e9. The zone and run fixtures use L = 65,536, so the old L^2 is exactly 2³², which wraps to 0 in 32 bits whether the product is read signed or unsigned. The last column is what each test gets with src/ reverted to the base commit, built with MSVC and the asserts made non-fatal:

test fixture without the fix
TEST_2D_GLSZM_LARGE_ZONE_SIZE_EMPHASIS_ANALYTIC 256×512, two zones of 65,536 SAE NaN, LAE 0
TEST_3D_GLSZM_LARGE_ZONE_SIZE_EMPHASIS_ANALYTIC 64×64×32, two zones of 65,536 SAE inf, LAE 0
TEST_2D_GLRLM_LONG_RUN_EMPHASIS_ANALYTIC two runs of 65,536, all 4 directions and the _AVE all 12 features wrong: inf, 0 or NaN, and LRE_AVE, LRLGLE_AVE, LRHGLE_AVE 0.75, 0.46875 and 1.875, the wrapped 0 averaged with the other directions
TEST_3D_GLRLM_LONG_RUN_EMPHASIS_ANALYTIC the same, all 13 directions and the _AVE all 12 features wrong; those three _AVE at 0.923, 0.577 and 2.308
TEST_2D_GLRLM_HIGH_GREY_LEVEL_EMPHASIS_ANALYTIC grey levels 1 and 70,000 LGLRE, SRLGLE, LRLGLE NaN; HGLRE, SRHGLE, LRHGLE 302,516,352.5 against 2,450,000,000.5 (off by 2³¹)
TEST_3D_GLRLM_HIGH_GREY_LEVEL_EMPHASIS_ANALYTIC the same, 13 directions LGLRE, SRLGLE, LRLGLE NaN; SRHGLE, LRHGLE off by 2³¹; HGLRE already right

The NaNs come from the grey-level range: with no binning the level vector runs 1..70,000, which includes 65,536, and 65536u * 65536u is 0, so that empty row gives 0/0.

The overflowing zone and run squares are signed, so what the old code returns depends on the compiler. GCC 15, MSVC, and clang 19 and 21 on arm64 wrap them, and every assertion above fails. clang 22, on x86-64 and arm64, widens the loop index to 64 bits and computes j * j exactly at any size. On that compiler only the unsigned grey-level terms and LRE's P * j * j would come out wrong. UBSan reports the signed overflow on every compiler, so once the macOS CI moves to clang 22, the ASan + UBSan job is what guards these squares.

The fix adds no refusal path, so there is no negative test.

The 3D runner that builds an ROI from a literal volume now lives in test_main_nyxus.h. 3D GLSZM, GLRLM and GLDM all call it, and it sets the ROI's bounding box, so a calculate() that places voxels through the box can read it.

The remaining commits record the tests in the vetting registry:

  • 2D GLSZM, 2D GLRLM and 3D GLRLM get 38 analytic rows, one per feature and recipe, at five new recipes: glszm.analytic_two_large_zones, glrlm.analytic_two_long_runs, glrlm.analytic_high_grey_pair, glrlm3d.analytic_two_long_runs and glrlm3d.analytic_high_grey_pair. Those families are row-scope, so every row of the 30 features involved, the _AVE ones included, names the analytic file in current_test.
  • 3D GLSZM gets two analytic rows at a new recipe, glszm3d.analytic_two_large_zones.
  • benchmarks.md, config_recipes.md and both GLSZM matrices describe the new fixtures.
  • report_output.* is regenerated.

Merge order

Verification

  • gtest: 1165 passed, 1 skipped (GPU Gabor).
  • ASan+UBSan on Linux: gtest 1166 cases, the six analytic tests among them, and tests/python (149 passed, 4 skipped, the Arrow cases deselected), 0 diagnostics.
  • check_test_names.py --check, check_coverage.py --check, report_features.py --check and the GLSZM, GLRLM and GLDM scanners' --check: clean.

Demian Vladi added 2 commits October 14, 2026 09:32
GLSZM and GLRLM took the square of a zone size or run length in int and the
square of a grey level in unsigned before widening, so a zone or run of
46,341 or more overflowed (undefined behaviour), and a grey level times a
run length of 65,536 or more wrapped silently. The affected features were
GLSZM SAE/LAE (2D, 3D and the GPU kernel) and the GLRLM run-length and
grey-level emphases (2D and 3D). On a whole-organ ROI this made 3GLSZM_SAE
exactly 0 and 3GLSZM_LAE negative or 142x low. Every such product is now
taken in floating point: double on the CPU, the way GLDM already did it,
and float in the GPU kernel, whose accumulators are float. The two #if 0 copies of the
old 3D GLSZM SAE/LAE are removed.

Tests, closed form at rel=1e-12, each failing without the fix:
- TEST_2D_GLSZM_LARGE_ZONE_SIZE_EMPHASIS_ANALYTIC: two zones of 46,656
- TEST_3D_GLSZM_LARGE_ZONE_SIZE_EMPHASIS_ANALYTIC: two zones of 65,536
- TEST_2D_GLRLM_LONG_RUN_EMPHASIS_ANALYTIC and the 3D twin: two runs of
  46,656, six run-length emphases
- TEST_2D_GLRLM_HIGH_GREY_LEVEL_EMPHASIS_ANALYTIC and the 3D twin: grey
  level 70,000, six grey-level emphases in every direction
Registers the six new analytic tests with the coverage tooling:
- the 2D GLSZM, 2D GLRLM and 3D GLRLM rows now name their analytic file
  in current_test
- 3D GLSZM gets two vetted analytic rows at the new recipe
  glszm3d.analytic_two_large_zones
- benchmarks.md describes the six fixtures, and config_recipes.md and the
  two GLSZM matrices describe the new configuration point
- the four scanners read the analytic files

report_output.csv/.md are regenerated.
@darkclad darkclad changed the title Main texture int overflow fix(texture): square zone sizes, run lengths and grey levels in double Oct 14, 2026
Demian Vladi added 9 commits October 14, 2026 09:46
… rows

The 2D GLSZM, 2D GLRLM and 3D GLRLM closed-form assertions each have an
oracle=analytic registry row, one per feature and recipe, as 3D GLSZM
does. The five recipes they name are defined in config_recipes.md:
glszm.analytic_two_large_zones, glrlm.analytic_two_long_runs,
glrlm.analytic_high_grey_pair, glrlm3d.analytic_two_long_runs and
glrlm3d.analytic_high_grey_pair. The mirp, pyradiomics and ibsi rows of
those features describe only their own oracles again.

The separator after bench_glrlm_3d_high_grey_pair in benchmarks.md has a
blank line before it, so it renders as a rule rather than a heading.
2D GLSZM, 2D GLRLM and 3D GLRLM are row-scope families: each row of a
feature lists in current_test every file that covers the feature. The
rows of the 18 features with an analytic test, the analytic rows among
them, all name the analytic file next to the oracle and regression
files, so the four family scanners' --check pass.
Each of the six 32-bit range fixtures in benchmarks.md names its recipe
on a Recipes: line, and the shared paragraph says each has its own
recipe and analytic rows.

The 3D high-grey-pair fixture, its recipe and the 3GLRLM_HGLRE analytic
row say that 3GLRLM_HGLRE multiplies the level as two doubles, so for
that feature the fixture pins the value without telling a 32-bit square
from a double one.
The GLRLM grey-level emphases, 2D and 3D, multiply the level as
`inten * inten` with `inten` declared double, and the four-factor terms
square the run length as `double(j) * double(j)` rather than relying on
left-to-right promotion. GLV keeps its upstream declarations. 3D GLSZM
SAE and LAE sum the column sums `sj` over the zone sizes, as 2D GLSZM
and 3D SZN do. One comment per file states why the squares are wide.

Tests: the in-memory 3D volume runner is one template in
test_main_nyxus.h, used by the GLSZM and GLRLM 3D tests, and the 3D
GLRLM settings recipe is test_3d_glrlm_common.h, used by the regression
and the analytic tests. The 2D GLSZM closed-form test sits under its
own banner in test_all.cc. The 3D GLSZM fixture and its benchmarks.md
entry say that L^2 = 2^32 is past INT_MAX, and no more.
The run-length comment sits on SRE and the grey-level comment on LGLRE,
2D and 3D, each above the term it describes.

test_3d_glrlm_regression.h and test_2d_glrlm_analytic.h include only
what their common header does not already bring in.
Every call to the 2D and 3D closed-form assertion helpers is wrapped in
ASSERT_NO_FATAL_FAILURE, so a wrong value stops the test there rather
than reporting the same defect once per direction and feature. The 3D
analytic runner labels its grey-depth argument, which GLRLM does not
read.
The long-run fixtures assert all four 2D directions and all thirteen 3D
directions, not only the one along the line: off the line every pixel
or voxel is a run of length 1, so those values and each _AVE have a
closed form, and the _AVE carries the past-INT_MAX value into the mean.
The twelve _AVE features get their own analytic rows in the registry.

Every analytic assertion and registry row states why its band is
rel=1e-12 and not SPEC 7's abs 1e-9: the values run from below 1e-9 to
past 1e9. The 3D GLRLM PyRadiomics tests build their settings with
make_glrlm3d_settings(), so the family has one recipe.
The 3D high-grey-pair recipe, its benchmarks.md entry and the
3GLRLM_HGLRE analytic row say that HGLRE converts the level to double
before squaring it in every version of the code, rather than quoting an
expression. test_3d_glrlm_pyradiomics.h includes only what
test_3d_glrlm_common.h does not already bring in.
test_3d_glrlm_common.h includes feature_settings.h for Fsettings and
NyxSetting rather than reaching them through test_main_nyxus.h, the
PyRadiomics recipe's step comments run in order, and the GPU GLSZM
comment says the zone size is squared in floating point.

@vjaganat90 vjaganat90 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the fix, the six analytic tests, the registry rows and the interaction with #486 and #488. The source fix is complete: every int/unsigned square that feeds SAE/LAE and the eight GLRLM emphases is now taken in floating point, in 2D and 3D, on the out-of-core paths (they share the calc functions) and in the GPU kernel. (double)a * a is used throughout, the settings refactors in the test headers are byte-identical, and no golden or assertion was dropped. I also checked the description's survey: GLDM, GLDZM and NGLDM are fine, and GLCM's IDM is as described.

Blocking

  • Some of the new assertions pass without the fix under clang (inline on test_2d_glszm_analytic.h and test_2d_glrlm_analytic.h). I built runAllTests from this head with src/ reverted to 3949f10 and the asserts made non-fatal. With conda-forge clang 19 at -O2 on arm64, the macOS CI toolchain, the old signed j * j compiles to a 32-bit multiply followed by an unsigned convert. That is exact for every j < 65,536, so at L = 46,656 these come out right without the fix: 2D GLSZM_LAE; 2D GLRLM SRE, SRHGLE, LRLGLE and their _AVE; 3D GLRLM SRE, LRE, SRHGLE, LRLGLE and their _AVE. Each test still fails as a whole, but the registry rows claim per-feature discrimination that doesn't hold on that leg. L = 65,536, as the 3D GLSZM fixture already uses, wraps to 0 under any compiler.
  • Three unrelated registry rows edited (inline on oracle_coverage.csv:400). ffa2eb4 appends the GLSZM/GLRLM tolerance rationale to ZERNIKE2D and the two morphology3d.analytic_lattice_solids rows. The sentence is false for them.

Nits (inline): defect comments on most changed squares; the duplicate runner left in test_3d_gldm_common.h; r.aabb in the shared runner for #486. Two more without an anchor in the diff:

  • NGTDM was missed by the survey. int Ngp_p2 = Ngp > 1 ? Ngp * (Ngp - 1) : Ngp; (ngtdm.cpp:254, 3d_ngtdm.cpp:408) is the same overflow class. It overflows at 46,342 grey levels, which a 16-bit ROI without binning can reach. Unlike GLCM, NGTDM keeps only Ng-length vectors, so nothing else fails first, and Contrast then divides by a garbage value. Please fix it here with double(Ngp) * (Ngp - 1), or file it next to the GLCM one and add it to the survey paragraph.
  • Description figures:
    • The 2D high-grey test first fails on LGLRE = NaN: the levels run 1..70,000, which includes 65,536, and 65536u * 65536u is 0. HGLRE is off by 2³¹ (302,516,352.5 against 2,450,000,000.5), not 4.3e9.
    • "Six emphases wrong" holds only where the compiler converts the overflowed int as signed.
    • The rows of 30 features changed, not 18 (the _AVE ones included).
    • In 3D GLDM Nd is at most 27 (26 neighbours + 1), not 26. Still bounded, so the conclusion holds.
    • PN-260 and PN-242 can't be looked up upstream. Please link GitHub issues.

Merge order with #486 and #488

  • #486: its bin_roi_3d() calls r.aabb.require_contains() and throws on the default empty box. The new run_3d_feature_on_volume never sets the box, so the 3D GLSZM large-zone and 3D GLRLM analytic tests throw once both PRs are in. Git only conflicts in test_3d_glszm_common.h, test_all.cc and report_output.*, so the GLRLM case won't show up as a conflict. Whichever lands second has to move #486's three aabb lines into this runner.
  • #488: its worked examples assert the overflowed values this PR removes, and test_2d_docs_examples_mechanics.py runs them. That covers glszm_worked_example.py:110 and :113 (LAE wrap and SAE = 0 on 300×300) and glrlm_worked_example.py:79-87 (LRHGLE 2.339e8, SRLGLE = 0, LGLRE = LRLGLE = 0). The overflow sections of f_glszm.rst, f_glszm_3d.rst, f_glrlm.rst and f_glrlm_3d.rst also describe behaviour this PR removes. I'd land this PR first and have #488 drop those pins; I'm asking for that on #488.

Comment thread tests/test_2d_glszm_analytic.h Outdated
Comment on lines +10 to +20
// bench_glszm_2d_two_large_zones -- a 216 x 432 rectangle, the top half at intensity 1 and the
// bottom half at intensity 2. Each half is one zone of L = 216 * 216 = 46,656 pixels, and L^2 =
// 2,176,782,336 is past INT_MAX, so a size emphasis is right here only if j^2 is taken in double.
// With two zones, both of size L, the size-zone matrix has one column that matters and:
//
// SAE = (1/L^2 + 1/L^2) / 2 = 1 / L^2 LAE = (L^2 + L^2) / 2 = L^2
//
// Read at IBSI=true, which keeps the two levels as they are.
static void make_glszm_2d_two_large_zones (std::vector<NyxusPixel>& intensity, std::vector<NyxusPixel>& mask)
{
const size_t side = 216;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: L = 46,656 puts L² past INT_MAX but not past UINT_MAX, and in that gap the old code isn't reliably wrong. Overflowing j * j is UB, so clang assumes the product is non-negative and converts it as unsigned. With conda-forge clang 19.1.7 at -O2 on arm64 (the macOS CI toolchain), the pre-fix calc_SAE/calc_LAE compile to a 32-bit mul followed by ucvtf, which is exact for every j < 65,536. I built runAllTests from this branch with src/ reverted to 3949f10 and the asserts made non-fatal:

  • GLSZM_LAE comes out as exactly L², so that assertion passes without the fix.
  • GLSZM_SAE is NaN, but not because of the 46,656-pixel zones. 2D GLSZM sizes the matrix to the bounding-box area (Ns = height * width, glszm.cpp:212, 93,312 here), so the loop reaches the empty column j = 65,536, where j² wraps to 0 and 0/0 gives NaN. If Ns ever becomes the largest zone, as it is in 3D, this assertion passes without the fix too.

The 3D fixture avoids this because 65,536² = 2³² wraps to 0 under either reading. Please do the same here: a 256 × 512 rectangle, two zones of 65,536. Then the old code gives SAE = inf and LAE = 0 on every compiler. The 46,656 also needs changing in the comment, benchmarks.md, config_recipes.md (glszm.analytic_two_large_zones), matrix/glszm.md and the two registry notes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed The fixture is now a 256×512 rectangle with two zones of 65,536, and the comment, benchmarks.md, config_recipes.md, matrix/glszm.md and both registry rows follow. With src/ at the base commit, MSVC gives SAE NaN and LAE 0, and GCC wraps the same square to 0; clang 22 on x86-64 widens j * j to 64 bits and computes it exactly at any size, so no fixture catches a signed square on every compiler, and UBSan is what reports it there. Done in a93c8dc.

Comment thread tests/test_2d_glrlm_analytic.h Outdated
// SRHGLE = LRHGLE = (1 + 4) / 2. Each _AVE is the mean of the four directions.
void test_2d_glrlm_long_run_emphasis_analytic()
{
const size_t L = 46656;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: This has the same gap as the 2D GLSZM fixture: below 65,536, clang compiles the old signed j * j as an unsigned 32-bit square, which is exact. On the pre-fix build (macOS arm64, conda clang 19, -O2, asserts made non-fatal), this test fails only on three emphases and their _AVE. LRE fails because P * j * j still converts signed. SRLGLE and LRHGLE fail because grey level 2 makes 4 L² wrap in unsigned. SRE, SRHGLE, LRLGLE and their _AVE come out right. In test_3d_glrlm_analytic.h:46, only SRLGLE, LRHGLE and their _AVE fail; SRE, LRE, SRHGLE and LRLGLE pass. So on that CI leg a regression in any of those lines goes through, and their registry rows ("right only if the run-length terms are taken in double") don't hold under clang. With L = 65,536 (a line of 131,072 pixels) every run-length square wraps to 0 under any compiler. That fixes both tests. The recipe, benchmark and registry text need the same change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed Both long-run fixtures now use L = 65,536, lines of 131,072, with the recipe, benchmark and registry text to match. With src/ at the base commit all 12 features fail in each test, 2D and 3D, under MSVC. The x86-64 clang caveat from the GLSZM thread applies to the plain j * j terms. Done in a93c8dc.

Comment thread tests/vetting/oracle_coverage.csv Outdated
@@ -377,7 +397,7 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test,
2D,GABOR,gabor,vetted,skimage,agreed,gabor.python_raw_defaults,rel=1e-3,test_2d_gabor_skimage.cc,,,,in-tree,"Vetted vs scikit-image 0.26.0 at the default lists gabor_freqs=[4,16,32,64], gabor_thetas=[0,45,90,135] deg as GaborOptions::parse_input builds them (f0={4,16,32,64}, theta={0,pi/4,pi/2,3pi/4} rad) - the config the Python API always runs and a CLI run that passes both flags. All 16 pins reproduced exactly by gen_gabor_skimage.py (max |diff| 0.000e+00). This config point was added 2026-08 because the family's only vetted row covered gabor.cpp_static_defaults, a different frequency and angle set whose values differ from this one by up to 0.84 absolute on the same ROI - so no in-tree assertion covered what users compute. UNITS: the recipe is named 'python_raw' because parse_input converts the angles (deg->rad) and passes the frequencies through untouched, while nyxus.py and the CLI help both document gabor_freqs as denominators of pi and nothing in the tree divides by pi; the assertion covers the numbers that reach the filter, not the documented units contract, which is an unresolved source question (vetting report section 8 item 4). SCOPE of oracle=skimage (SPEC 4 allows one token per row; the claim is narrower than the token): skimage supplies the KERNEL at the non-degenerate (f0,theta) points - gabor_kernel(frequency=f0/2pi, sigma_x=sig2lam*2pi/f0, sigma_y=sigma_x/gamma, offset=0) cropped to the Nyxus 16x16 grid - while the count-ratio SCORE is Nyxus' own WND-CHARM definition reproduced in the generator, because skimage has no native equivalent to compare the whole feature against. A full-feature oracle would be wndcharm; not built in this tree, recorded in not_covered.md. SCOPE 2: the GPU path is NOT vetted - calculate_gpu (FFT convolution) diverges from the direct-convolution CPU path on these ROIs; the assertion covers the in-RAM CPU path only, and the GPU path's plumbing guard is TEST_NYXUS.TEST_2D_GABOR_GPU_RUNS_MECHANICS. SCOPE 3: the 16x16 kernel crop is part of the recipe rather than a detail - at gamma=0.1 only 7.9-47.6 percent of the kernel's L1 mass falls inside that window at the low frequencies, and scoring off skimage's own filtering (skimage.filters.gabor, untruncated support, zero-padded border) moves values by up to 1.005; generator part D2 re-runs that control. See audit/gabor_2d_skimage_vetting_report.md.",TEST_NYXUS.TEST_2D_GABOR_PYTHON_RAW_DEFAULTS_SKIMAGE,bench_dsb2018_2d
2D,MEAN_FRAC,radial,regression,,disagreed,radial.shape2d_native,rel=1e-9,test_2d_radial_regression.h;test_2d_radial_invariant.h,,"cellprofiler:MeasureObjectIntensityDistribution (RadialDistribution_*) -- run 2026-08-20, does not vet",convention-mismatch,in-tree,"Not CellProfiler-vettable: CP's MeanFrac is normalised by the ROI mean intensity and lands near 1, Nyxus' is the bin's raw mean intensity. Full run, numbers and the six divergences in tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md; generator tests/vetting/oracles/gen_radial_cellprofiler.py. 8-bin vector, every bin asserted separately at rel=1e-9 by TEST_2D_RADIAL_DISTRIBUTION_REGRESSION against a measured residual of 0; the raw-bin-mean convention (intensity range, and the reconstruction of the ROI total together with FRAC_AT_D) is characterized by TEST_2D_RADIAL_BIN_CONVENTIONS_REGRESSION, not asserted as an invariant, because CellProfiler's normalised MeanFrac satisfies neither. test_2d_radial_mechanics.h pins the contour frame, centre and radius the table depends on and is deliberately credited to no feature - it characterizes known defects (report §6) that a fix must change, see not_covered.md §A.1.",TEST_NYXUS.TEST_2D_RADIAL_DISTRIBUTION_REGRESSION;TEST_NYXUS.TEST_2D_RADIAL_BIN_CONVENTIONS_REGRESSION;TEST_NYXUS.TEST_2D_RADIAL_EMPTY_BINS_ARE_ZERO_INVARIANT,bench_shape8_concave_holed
2D,RADIAL_CV,radial,regression,,disagreed,radial.shape2d_native,rel=1e-9,test_2d_radial_regression.h;test_2d_radial_invariant.h,,"cellprofiler:MeasureObjectIntensityDistribution (RadialDistribution_*) -- run 2026-08-20, does not vet",convention-mismatch,in-tree,"Not CellProfiler-vettable: CP takes the CV of the 8 wedge means over the non-empty wedges, Nyxus the CV of the 8 wedge sums over all 8. Full run, numbers and the six divergences in tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md; generator tests/vetting/oracles/gen_radial_cellprofiler.py. 8-bin vector, every bin asserted separately at rel=1e-9 by TEST_2D_RADIAL_DISTRIBUTION_REGRESSION against a measured residual of 0; the sqrt(num_bins-1) bound and the empty-bin zero hold under either wedge convention and are asserted by the two invariants named in test_name. test_2d_radial_mechanics.h pins the contour frame, centre and radius the table depends on and is deliberately credited to no feature - it characterizes known defects (report §6) that a fix must change, see not_covered.md §A.1.",TEST_NYXUS.TEST_2D_RADIAL_DISTRIBUTION_REGRESSION;TEST_NYXUS.TEST_2D_RADIAL_CV_IS_WITHIN_ITS_BOUND_INVARIANT;TEST_NYXUS.TEST_2D_RADIAL_EMPTY_BINS_ARE_ZERO_INVARIANT,bench_shape8_concave_holed
2D,ZERNIKE2D,zernike,vetted,analytic,agreed,zernike.shape2d_native,rel=1e-12,test_2d_zernike_analytic.h;test_2d_zernike_regression.h;test_2d_zernike_invariant.h;test_2d_zernike_mechanics.h,,"cellprofiler:MeasureObjectIntensityDistribution (ZernikeMagnitude) -- run 2026-08-20, different disk and normalisation, not comparable",,in-tree,"Vetted against the closed form: the factorial series for R_nm evaluated at ZernikeFeature's own geometry (unit disk on the intensity centroid, radius min(bbox w,h)). zernike.cpp uses the Singh & Walia recurrence instead, so the agreement tests the recurrence rather than restating it; centrosome's own polynomials corroborate to 4.9e-15. Measured residual 3.4e-15 worst, banded rel=1e-12 with a 1e-15 floor because (n=1,m=1) is a mathematical zero. This PR fixes a defect that made all 30 values wrong -- the angular tables were seeded at cos(theta) instead of cos(0), so every moment of repetition m used cos((m+1)*theta); see tests/vetting/audit/zernike_2d_analytic_vetting_report.md. CellProfiler's own Zernikes are NOT comparable (minimum-enclosing-circle disk, normalised by pixel count).",TEST_NYXUS.TEST_2D_ZERNIKE_MOMENTS_ANALYTIC,bench_shape8_concave_holed
2D,ZERNIKE2D,zernike,vetted,analytic,agreed,zernike.shape2d_native,rel=1e-12,test_2d_zernike_analytic.h;test_2d_zernike_regression.h;test_2d_zernike_invariant.h;test_2d_zernike_mechanics.h,,"cellprofiler:MeasureObjectIntensityDistribution (ZernikeMagnitude) -- run 2026-08-20, different disk and normalisation, not comparable",,in-tree,"Vetted against the closed form: the factorial series for R_nm evaluated at ZernikeFeature's own geometry (unit disk on the intensity centroid, radius min(bbox w,h)). zernike.cpp uses the Singh & Walia recurrence instead, so the agreement tests the recurrence rather than restating it; centrosome's own polynomials corroborate to 4.9e-15. Measured residual 3.4e-15 worst, banded rel=1e-12 with a 1e-15 floor because (n=1,m=1) is a mathematical zero. This PR fixes a defect that made all 30 values wrong -- the angular tables were seeded at cos(theta) instead of cos(0), so every moment of repetition m used cos((m+1)*theta); see tests/vetting/audit/zernike_2d_analytic_vetting_report.md. CellProfiler's own Zernikes are NOT comparable (minimum-enclosing-circle disk, normalised by pixel count). The band is rel=1e-12 rather than SPEC 7's exact tier (abs 1e-9): these emphases run from below 1e-9 to past 1e9, so an absolute 1e-9 would accept a small one of 0 and lies below a large one's last bit.",TEST_NYXUS.TEST_2D_ZERNIKE_MOMENTS_ANALYTIC,bench_shape8_concave_holed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: ffa2eb4 appends the GLSZM/GLRLM sentence "The band is rel=1e-12 rather than SPEC 7's exact tier (abs 1e-9): these emphases run from below 1e-9 to past 1e9, so an absolute 1e-9 would accept a small one of 0 and lies below a large one's last bit." to three rows this PR doesn't touch otherwise: this ZERNIKE2D analytic row, and the 3MESH_VOLUME and 3VOLUME_CONVEXHULL morphology3d.analytic_lattice_solids rows at lines 699–700. None of them is an emphasis, and none runs past 1e9. Zernike magnitudes are at most about 1, and this row already gives its own reason, a 1e-15 floor because (n=1, m=1) is a mathematical zero, which the new sentence contradicts. The lattice-solid volumes run from 1/6 to a few thousand. Please revert those three notes and regenerate report_output.*.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed The three notes on ZERNIKE2D and the two analytic_lattice_solids rows are back to their base text, and report_output.* is regenerated. report_features.py --check and the scanners are clean. Done in a93c8dc.

Comment thread src/nyx/features/glrlm.cpp Outdated
for (int j = 1; j <= Nr; j++)
{
f += P.matlab(i, j) * j * j;
f += P.matlab(i, j) * (double(j) * double(j));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The repo convention (CLAUDE.md, "Annotate changed source lines") is a short comment on each changed line. Only SRE/LGLRE (GLRLM) and SAE (GLSZM) have one. These don't: LRE here, HGLRE/SRLGLE/SRHGLE/LRLGLE/LRHGLE (lines 749–879), LAE (glszm.cpp:440, 3d_glszm.cpp:838), and the 3D GLRLM twins (3d_glrlm.cpp:370, 607–702). Without a comment, (double(j) * double(j)) reads like noise that a later tidy-up could fold back into j * j. A trailing // j^2 in double: int overflows at j >= 46,341, or the grey-level equivalent, on each line would do.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed Every changed square in glrlm.cpp, 3d_glrlm.cpp, glszm.cpp and 3d_glszm.cpp now has a trailing comment naming the type it is taken in and where the integer form overflows. Done in a93c8dc.

Comment thread tests/test_main_nyxus.h

// Runs a 3D feature method on a literal volume rather than on a phantom read from disk, for any family
// whose calculate() reads the voxel cube and the voxel cloud of an LR. Lives here rather than in a family
// header because 3D tests of more than one family run it (SPEC 6.3.1).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The comment says this runner lives here because 3D tests of more than one family use it, but run_3d_gldm_on_volume (tests/test_3d_gldm_common.h:125-155) is still a line-for-line copy. Please make it delegate here, as run_3d_glszm_on_volume now does, so there is one runner. That also means #486's aabb change (see my comment on line 251) only has to land in one place.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed run_3d_gldm_on_volume now delegates to run_3d_feature_on_volume, so the 3D families share one runner. Done in a93c8dc.

Comment thread tests/test_main_nyxus.h
r.aux_max = v;
}

ASSERT_NO_THROW(r.initialize_fvals());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This interacts with #486 at merge time. #486 has 3D GLSZM, GLRLM and GLDM calculate() place voxels through bin_roi_3d(), which reads r.aabb and throws std::out_of_range for a voxel outside the box. A default AABB is empty (xmin = INT32_MAX). #486 therefore adds r.aabb.init_*/update_* to the body of run_3d_glszm_on_volume, and this PR moves that body here. The textual conflict only shows up in test_3d_glszm_common.h. The 3D GLRLM analytic tests call this runner directly, so nothing in them conflicts, but they will throw. Whichever PR lands second needs the three aabb lines here, before initialize_fvals().

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed The shared runner sets r.aabb with #486's three lines before initialize_fvals(), so the 3D GLSZM, GLRLM and GLDM tests run in either merge order. The textual conflicts in test_3d_glszm_common.h and test_3d_gldm_common.h resolve to the one-line delegation. Done in a93c8dc.

…zero

The 2D GLSZM and the 2D and 3D GLRLM long-run fixtures used zones and runs
of 46,656. Its square is past INT_MAX but not past UINT_MAX, and a compiler
that reads the overflowed signed product as unsigned gets it exactly, so
some assertions passed without the fix. They now use 65,536, whose square
2^32 wraps to 0 in 32 bits however the product is read: the 2D GLSZM ROI is
a 256x512 rectangle, the GLRLM lines are 131,072 long. With src/ at the
base commit every one of the six analytic tests fails, all 12 long-run
features included.

A compiler may still compute a signed square exactly by widening the loop
index to 64 bits, which clang 22 on x86-64 does at any size; UBSan reports
the overflow there.

- Registry: the GLSZM/GLRLM tolerance sentence is removed from the
  ZERNIKE2D analytic row and the two morphology3d.analytic_lattice_solids
  rows, which it does not describe. The zone and run rows, recipes,
  benchmarks and the GLSZM matrix describe the 65,536 fixtures.
  report_output.* regenerated.
- Each GLSZM/GLRLM square taken in double carries a comment saying why.
- run_3d_gldm_on_volume delegates to run_3d_feature_on_volume, so the 3D
  families share one runner, and that runner sets the ROI's bounding box
  for a calculate() that places voxels through it.
@darkclad

darkclad commented Oct 16, 2026 •

Copy link
Copy Markdown
Author

Deferred Ngp * (Ngp - 1) in ngtdm.cpp and 3d_ngtdm.cpp is filed as PN-271 and named in the description's survey paragraph. A value test needs at least 46,342 distinct levels, and Contrast, Busyness and Complexity each loop over Ng × Ng, about 4e9 iterations per feature, so it does not fit in this PR's sanitizer run.

@darkclad

darkclad commented Oct 16, 2026 •

Copy link
Copy Markdown
Author

Fixed The description now gives the values measured with src/ at the base commit: 2D high-grey fails on LGLRE NaN and the high-grey emphases are off by 2³¹, 30 features, 3D GLDM's count is at most 27, and the signed-overflow results are stated per compiler. The follow-ups keep their PN keys (PN-260, PN-271, PN-242); see the next comment for where they live.

@darkclad

Copy link
Copy Markdown
Author

Answered PN-260, PN-271 and PN-242 are items on Axle's PN Jira board, https://labshare.atlassian.net/jira/software/projects/PN/boards/851, where this team tracks nyxus follow-ups; they are not GitHub issues, which is why they could not be found upstream. The description links the board.

@vjaganat90 vjaganat90 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. All six threads are fixed:

  • I rebuilt with src/ at 3949f10 and the PR's tests, on macOS arm64 with conda clang 19 and with 21.1.8, the clang the macOS CI uses. Every one of the six analytic tests fails, with every feature the description names. The head passes.
  • The three unrelated registry rows match main again.
  • Every changed square has a comment with the right threshold.
  • The 3D families share one runner, and it sets r.aabb.

A trial merge with #486, in either order, passes the full gtest suite with the one-line delegation.

Nits, none blocking:

  • Description, compilers: clang 22 widens j * j to 64 bits on arm64 too, not only on x86-64. With 22.1.8 at -O2, the base calc_SAE/calc_LAE compile to a 64-bit mul and ucvtf. macOS CI is on clang 21 today, which still wraps. Once conda-forge moves to 22, though, that leg stops catching the old code, and the ASan + UBSan job is the only guard. Please say "x86-64 and arm64".
  • Description, Tests table: "(inf, 0 or NaN)" for the long-run test doesn't hold for LRE_AVE, LRLGLE_AVE and LRHGLE_AVE. With the old code they come out at 0.75, 0.46875 and 1.875 in 2D (0.923, 0.577 and 2.308 in 3D): the wrapped 0 averaged with the other directions. They're still wrong, so the test fails; only the wording is off.
  • Description, merge order: merging #486 also conflicts in tests/test_all.cc (keep both include lists) and in tests/vetting/report_output.csv/.md (regenerate), not only in the two _common.h headers.
  • cc60ec0 is an empty ci: commit. Main lands PRs by rebase, so please drop it before merge.

darkclad pushed a commit to darkclad/nyxus that referenced this pull request Oct 19, 2026
…est build

Merge main-binning-plumbing (PolusAI#486, tip 3f654e2) into the test-build branch
that already carries PolusAI#484 and PolusAI#485, so the wheel also bins a 3D ROI voxel at
the volume minimum into level 1.

Conflicts resolved:
- 3d_ngldm.cpp, 3d_ooc_volume.h: both sides kept. 3D NGLDM takes PolusAI#484's
  roi_mask_3d() and PolusAI#486's ngldm_roi_grey_level(); the header carries
  roi_mask_3d() and bin_roi_3d().
- test_all.cc, scan_glrlm3d_coverage.py, the first config_recipes.md hunk:
  both sides kept.
- test_3d_glszm_common.h: PolusAI#485's run_3d_feature_on_volume() call; the helper
  builds the same voxel cloud and box PolusAI#486 builds inline.
- test_3d_ngtdm_regression.h, matrix/ngtdm3d.md, the NGTDM config_recipes.md
  hunk: PolusAI#484's side, which describes the masked 3D NGTDM this tree runs.
- oracle_coverage.csv, report_output.csv, report_output.md: PolusAI#486's side. The
  registry on this branch is not regenerated and is not authoritative.

Test-build branch only; not for merging.
@darkclad

Copy link
Copy Markdown
Author

Fixed The description now says clang 22 widens j * j on x86-64 and arm64 and that the ASan + UBSan job guards these squares once the macOS CI moves to it. The Tests table gives the three long-run _AVE values, and the merge-order note lists the test_all.cc and report_output.* conflicts.

@darkclad
darkclad force-pushed the main-texture-int-overflow branch 2 times, most recently from a93c8dc to 146d1a1 Compare October 21, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants