fix(fractal-dim): correct box-count & perimeter dimension (oracle-validated)#365
Closed
sameeul wants to merge 11 commits into
Closed
fix(fractal-dim): correct box-count & perimeter dimension (oracle-validated)#365sameeul wants to merge 11 commits into
sameeul wants to merge 11 commits into
Conversation
Box counting is registration sensitive. Power2PaddedImageMatrix padded to a power of two strictly larger than the ROI (closest_pow2) and centered it, so the ROI never lined up with the coarse boxes: a filled square read D=1.75 instead of 2.0, Sierpinski 1.39 instead of 1.585 - all biased low. Add ceil_pow2 (tight next-power-of-two) and place the ROI at the grid origin, in both the trivial and oversized (_NT) padded matrices. Validated against the ImageJ/FracLac box-count oracle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace calc_lyapunov_slope (mean of local slopes, which collapses to a two-point endpoint slope under uniform log spacing) with loglog_slope, a least-squares fit of log(measure) vs log(scale) shared by both features: box-count uses D=-slope, perimeter uses D=1-slope. Rewrite calculate_perimeter_fdim as a clean closed-contour Richardson divider: store the perimeter as double (was truncated to int), drop the broken tail segment (it added a variable-length closing chord at every scale, and used squared distance instead of the Euclidean chord), and use the actual mean ruler length as the scale. Recovers the Koch snowflake divider dimension log4/log3=1.262 and a smooth disk 1.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reproduces FracLac's shifting-grid box counting headlessly (FracLac is GUI-only) plus analytic-mask generator and docs, so the box-count oracle cross-check runs with no clicking. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Assert nyxus recovers known dimensions: box-count square->2, line->1, Sierpinski->1.585; perimeter disk->1, Koch snowflake->1.262; and both dimensions in [1,2] on the irregular 154-px ROI. Replaces the earlier range-only check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shape2d_morphology ROI, computed by the fixed build (verified locally via gtest): FRACT_DIM_BOXCOUNT 0.319-artifact-free -> 1.7297158093186493 (grid-aligned) FRACT_DIM_PERIMETER out-of-range 0.319 -> 1.2632300929080915 (valid boundary dim) The old 1.585 box-count golden was itself a coincidence of the grid-misalignment bug. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
depth=5/size=1400 snowflake resolves the finest Koch detail -> perimeter D=1.253 (truth log4/log3=1.262, err 0.009). Tighten disk and Koch perimeter tolerances to 0.05 so the oracle asserts genuine recovery, not just a loose band. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…in CI Neither the conda nor cibuildwheel test env ships Pillow, so the PIL-based Koch snowflake would skip in CI, leaving the fractal-perimeter recovery unverified there. Replace the PIL polygon fill with an even-odd numpy scanline fill (D=1.266, err 0.005), so square/line/Sierpinski/disk/Koch/arbitrary-ROI all run under CI's pytest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on code - Remove tests/oracle/ (ImageJ macro + mask generator). The oracle values are now baked into the test as offline constants rather than shipping the generation code. - Tighten the arbitrary 154-px ROI check: was a loose [1,2] range, now pinned to the offline ImageJ shifting-grid box-count oracle (1.389; nyxus computes 1.398, agree to 0.009) at 0.05 tolerance. Perimeter pinned as a regression benchmark (no software divider oracle exists). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Drop redundant [1,2] range assert in the arbitrary-ROI python test (the oracle/ benchmark equality checks already imply it). - Revert cosmetic loop-var rename in calculate_boxcount_fdim (tr/tc -> r/c); the loops are unchanged, only the necessary double-coverage + loglog_slope changes remain. - Reclassify the C++ FRACT_DIM goldens from the 3p-oracle table to the regression table: they are Nyxus' own values, NOT third-party-oracle matches. On this 26-px non-fractal fixture an ImageJ shifting-grid box counter gives ~1.585 vs Nyxus' 1.730 (only 3 box sizes fit). The methods' correctness is oracle-validated on analytic shapes in the python suite; these remain as regression goldens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep both grid strategies and auto-switch on ROI size:
- large ROI (padded side > 32): single origin grid via the aligned padded-mask
matrix (fast early-exit tile scan) - the well-resolved case where one grid is
already accurate and shifting would be the costly option.
- small ROI (padded side <= 32): shift the grid over 4 origins ({0,s/2} per axis)
and take the minimum box count, as FracLac does - removes the residual grid
registration bias that dominates when only a few box sizes fit.
Registration bias only matters when few box sizes fit, and those ROIs are cheap to
shift, so this is perf-neutral: large-ROI box-count is unchanged (~16 ms on a 400x400
ROI, vs ~18 ms before), small ROIs cost ~35 us. Box index uses a bit-shift (box sizes
are powers of two) and a flat occupancy grid (no per-pixel division or hashing).
Small ROIs now reproduce the ImageJ/FracLac shifting-grid oracle exactly: the 26-px
C++ fixture computes log2(3)=1.5849625 (moved back to the 3p-oracle table) and the
154-px python ROI computes 1.389, matching the offline oracle to <0.001. Analytic
large shapes stay exact (square->2, Sierpinski->1.585). Perimeter path unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The divider perimeter is not just a self-referential benchmark: box-counting the ROI's edge (ImageJ shifting-grid) estimates the same boundary dimension by a different algorithm (box dim == compass/divider dim for boundary curves). On the 154-px ROI that gives 1.163 vs nyxus' divider 1.101 - agreement to 0.06, i.e. cross-method convergent validity. Assert the divider value against that independent oracle within the cross-method tolerance instead of pinning nyxus' own number. (The 26-px C++ fixture stays a regression value: its edge has only 18 px over 3 box sizes, so the cross-method estimate is unreliable there - 1.585 vs 1.263.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
65764ef to
c63cdc4
Compare
Member
Author
|
Superseded by #366 — same final code, but with a clean 4-commit logical stack, comments updated to the final approach, and a PR description that records the divider-vs-box-count research. |
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.
Summary
Fixes the accuracy of the 2D fractal-dimension features. Builds on #358 (which
corrected the sign/aggregation) and makes the values correct against an external oracle.
Validated against the ImageJ / FracLac box-count oracle and analytic ground truth:
a filled square now reads
FRACT_DIM_BOXCOUNT = 2.0(was 1.75), a Sierpinski triangle1.585(was 1.39), and the Koch-snowflakeFRACT_DIM_PERIMETER = 1.262(was anout-of-range 0.32).
Root cause
Box counting is grid-registration sensitive.
Power2PaddedImageMatrixpadded to a powerof two strictly larger than the ROI (
closest_pow2) and centered it, so the ROInever lined up with the coarse boxes — halving the box size didn't cleanly quadruple the
count, biasing the dimension low on every shape.
Changes
image_matrix.h,image_matrix_nontriv.cpp,helpers.h): tightceil_pow2canvas + origin alignment (no centering) in both padded-matrix classes.fractal_dim.cpp): replace the mean-of-local-slopes (an endpoint slope)with a least-squares
loglog_slopeshared by both features.double(was truncated toint), drop thebroken tail chord (variable-length + missing
sqrt), scale by actual mean ruler length.tests/python/test_fractal_dim_oracle.py): analytic shapes recoverknown dimensions; the irregular 154-px ROI stays in
[1,2].tests/oracle/): headless shifting-grid box-count macro(FracLac is GUI-only) + analytic-mask generator.
Oracle validation
🤖 Generated with Claude Code