test(moments): assert the normalized raw moments against scikit-image - #430
Open
darkclad wants to merge 3 commits into
Open
test(moments): assert the normalized raw moments against scikit-image#430darkclad wants to merge 3 commits into
darkclad wants to merge 3 commits into
Conversation
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
force-pushed
the
main-vet-2d-moments
branch
2 times, most recently
from
August 14, 2026 15:42
d317635 to
1cd0338
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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:
using namespace Nyxus;. All three now compile as the only thing in a translation unit. Its unused is dropped.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.