Skip to content

test(moments): assert the normalized raw moments against scikit-image - #430

Open
darkclad wants to merge 3 commits into
PolusAI:mainfrom
darkclad:main-vet-2d-moments
Open

test(moments): assert the normalized raw moments against scikit-image#430
darkclad wants to merge 3 commits into
PolusAI:mainfrom
darkclad:main-vet-2d-moments

Conversation

@darkclad

Copy link
Copy Markdown

32 rows of the 2D moments family - NORM_SPAT_MOMENT_ij and IMOM_NRM_ij - carried status=vetted with source=tracker while no in-tree test asserted them against any oracle. They now have one.

Both are normalized RAW moments, m_pq / m_00^((p+q)/2 + 1). skimage has no native function for that: moments_normalized() applies the same exponent to the CENTRAL moments, which is a different quantity and one Nyxus already exposes separately as NORM_CENTRAL_MOMENT_pq / IMOM_NCM_pq. On the pinned 48x40 fixture the two differ plainly - 0.3876 against 0.0999 at p=2,q=0 - and the second is what NORM_CENTRAL_MOMENT_20 matches.

So the oracle is skimage's raw moments carried through skimage's own normalization exponent: the moment sums, which are the non-trivial part, come from an independent implementation, and the only step layered on top is an exponent skimage itself uses. Nyxus reproduces all 32 exactly - worst relative difference 0.0e+00 - and the generator now asserts that normalized-raw and moments_normalized are numerically distinct, so a later edit cannot silently swap one for the other.

  • gen_moments_skimage.py gains section D, emitting the 32 goldens plus that guard.
  • test_2d_moments_skimage.h gains two ref-val tables and test_2d_moments_normraw_{shape,intensity}_skimage, registered in test_all.cc.
  • oracle_coverage.csv: all 180 rows move to source=in-tree with config_recipe=moments.skimage_regionprops and tolerance=rel=1e-6, and current_test is now derived from what the tests actually assert rather than from what the tracker-era registry claimed. That correction alone touched 72 rows in both directions: rows naming the regression file that it does not cover, and the 32 that it does cover but which did not say so.

The 62 weighted (distance-to-contour) rows stay regression, and now carry the reason in-tree rather than only in a side document. Re-measured against current code: the weighting takes dist from Pixel2::min_sqdist, an approximate hill-descent its own doc comment warns overestimates, and on the pinned fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - 1.372x, a 37.2% overestimate. Fixing that changes 62 published feature values and swaps an O(pixels x contour) scan for a distance transform, so it is out of scope here and noted for its own branch. One correction for whoever takes it: there is exactly one live weighting path; the second implementation using a different formula (inten/(dist+eps), image_matrix.cpp) has no callers anywhere, and no GPU kernel does this weighting at all.

Also, per tests/vetting/ housekeeping:

  • Include hygiene: test_2d_moments_common.h called init_label_record_3 / update_label_record_3 unqualified, so all three moments headers compiled only because another header earlier in test_all.cc's include order contributes a file-scope using namespace Nyxus;. All three now compile as the only thing in a translation unit. Its unused is dropped.
  • The generator re-verified a hand-picked list of 25 golden names against the fresh run. It now re-verifies EVERY golden pinned in test_2d_moments_skimage.h - 142 of 142, across both fixtures, none without a skimage counterpart - so the check cannot quietly stop covering entries added later. Doing so surfaced that moments_central(order=N) leaves entries with p+q > N at zero while moments() fills the whole matrix, so the verification computes its matrices at order 6; the tables pin up to CENTRAL_MOMENT_33.
  • Comments describing the history of an h5/h6 formula fix are replaced by statements of the current contract - what the value is, and what a wrong formula would produce instead - with the history left to the audit report.
  • New audit artifacts: moments_2d_coverage.csv, moments_2d_skimage_vetting_report.md, moments_2d_golden_regen.md, and scan_moments_coverage.py, whose --check regenerates the CSV from the test sources and enforces that the registry and the tests agree row for row with no unregistered gtest function.
  • TOOLS.md records the moments_normalized-vs-normalized-raw trap and the p-on-x coordinate convention.

gtest 734/734; ASan+UBSan on Linux 730/730 with no sanitizer diagnostics; pytest 85 passed, 1 skipped, 7 arrow-only deselected; check_test_names, check_coverage and scan_moments_coverage all clean.

32 rows of the 2D moments family - NORM_SPAT_MOMENT_ij and IMOM_NRM_ij - carried
status=vetted with source=tracker while no in-tree test asserted them against any
oracle. They now have one.

Both are normalized RAW moments, m_pq / m_00^((p+q)/2 + 1). skimage has no native
function for that: moments_normalized() applies the same exponent to the CENTRAL
moments, which is a different quantity and one Nyxus already exposes separately as
NORM_CENTRAL_MOMENT_pq / IMOM_NCM_pq. On the pinned 48x40 fixture the two differ
plainly - 0.3876 against 0.0999 at p=2,q=0 - and the second is what
NORM_CENTRAL_MOMENT_20 matches.

So the oracle is skimage's raw moments carried through skimage's own normalization
exponent: the moment sums, which are the non-trivial part, come from an independent
implementation, and the only step layered on top is an exponent skimage itself uses.
Nyxus reproduces all 32 exactly - worst relative difference 0.0e+00 - and the
generator now asserts that normalized-raw and moments_normalized are numerically
distinct, so a later edit cannot silently swap one for the other.

- gen_moments_skimage.py gains section D, emitting the 32 goldens plus that guard.
- test_2d_moments_skimage.h gains two ref-val tables and
  test_2d_moments_normraw_{shape,intensity}_skimage, registered in test_all.cc.
- oracle_coverage.csv: all 180 rows move to source=in-tree with
  config_recipe=moments.skimage_regionprops and tolerance=rel=1e-6, and current_test
  is now derived from what the tests actually assert rather than from what the
  tracker-era registry claimed. That correction alone touched 72 rows in both
  directions: rows naming the regression file that it does not cover, and the 32
  that it does cover but which did not say so.

The 62 weighted (distance-to-contour) rows stay regression, and now carry the reason
in-tree rather than only in a side document. Re-measured against current code: the
weighting takes dist from Pixel2::min_sqdist, an approximate hill-descent its own doc
comment warns overestimates, and on the pinned fixture the exact distance to the
contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned
2498.7137504625134 - 1.372x, a 37.2% overestimate. Fixing that changes 62 published
feature values and swaps an O(pixels x contour) scan for a distance transform, so it
is out of scope here and noted for its own branch. One correction for whoever takes
it: there is exactly one live weighting path; the second implementation using a
different formula (inten/(dist+eps), image_matrix.cpp) has no callers anywhere, and
no GPU kernel does this weighting at all.

Also, per tests/vetting/ housekeeping:

- Include hygiene: test_2d_moments_common.h called init_label_record_3 /
  update_label_record_3 unqualified, so all three moments headers compiled only
  because another header earlier in test_all.cc's include order contributes a
  file-scope `using namespace Nyxus;`. All three now compile as the only thing in a
  translation unit. Its unused <iostream> is dropped.
- The generator re-verified a hand-picked list of 25 golden names against the fresh
  run. It now re-verifies EVERY golden pinned in test_2d_moments_skimage.h - 142 of
  142, across both fixtures, none without a skimage counterpart - so the check cannot
  quietly stop covering entries added later. Doing so surfaced that
  moments_central(order=N) leaves entries with p+q > N at zero while moments() fills
  the whole matrix, so the verification computes its matrices at order 6; the tables
  pin up to CENTRAL_MOMENT_33.
- Comments describing the history of an h5/h6 formula fix are replaced by statements
  of the current contract - what the value is, and what a wrong formula would produce
  instead - with the history left to the audit report.
- New audit artifacts: moments_2d_coverage.csv, moments_2d_skimage_vetting_report.md,
  moments_2d_golden_regen.md, and scan_moments_coverage.py, whose --check regenerates
  the CSV from the test sources and enforces that the registry and the tests agree row
  for row with no unregistered gtest function.
- TOOLS.md records the moments_normalized-vs-normalized-raw trap and the p-on-x
  coordinate convention.

gtest 734/734; ASan+UBSan on Linux 730/730 with no sanitizer diagnostics; pytest 85
passed, 1 skipped, 7 arrow-only deselected; check_test_names, check_coverage and
scan_moments_coverage all clean.
@darkclad
darkclad force-pushed the main-vet-2d-moments branch 2 times, most recently from d317635 to 1cd0338 Compare August 14, 2026 15:42
Demian Vladi added 2 commits August 14, 2026 09:06
… a subset

Both generators printed a paste-ready table and stopped there: nothing compared
the numbers already pinned in the tree against the run that had just produced
them. Each now re-verifies the whole table it feeds.

gen_intensity_histogram_mirp.py checks all 23 goldens in
test_2d_intensity_histogram_mirp.h. gen_intensity_histogram_analytic.py checks
the 19 it feeds in test_2d_intensity_histogram_analytic.h and the 4 percentile
drift guards it feeds in test_2d_intensity_histogram_regression.h; the
robust-window tables in those same files come from the 17-px tail-trimming
fixture, not this generator, so they are deliberately not in its list.

Anything pinned that the generator cannot produce is reported and fails the run
rather than being skipped quietly, and both scripts now exit non-zero when a
check fails, so a stale pin cannot pass unnoticed.

Verified: mirp 23/23, analytic 19 + 4, ALL CHECKS PASSED on both.

Python-only change - no compiled artifact is affected, so the gtest and ASan
results already recorded for this branch still stand.
Both generators printed a paste-ready table and stopped there: nothing compared
the numbers already pinned in the tree against the run that had just produced
them. Each now re-verifies the whole table it feeds - all 32 entries of
glrlm_2d_pyradiomics_ref_vals and glrlm_2d_mirp_ref_vals, the per-direction
features and their _AVE twins alike.

Anything pinned that the generator cannot produce is reported and fails the run
rather than being skipped quietly, and both scripts now exit non-zero when a
check fails, so a stale pin cannot pass unnoticed. The comparison band is 5e-3,
the loosest the test file asserts at, which is the log-based GLRLM_RE case.

Verified: 32/32 against pyradiomics v3.0.1 and 32/32 against mirp 2.6.0,
ALL CHECKS PASSED on both.

Python-only change - no compiled artifact is affected, so the gtest and ASan
results already recorded for this branch still stand.
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.

1 participant