Skip to content

fix(fractal-dim): correct box-count & perimeter dimension (oracle-validated)#365

Closed
sameeul wants to merge 11 commits into
mainfrom
fix/fractdim-correctness
Closed

fix(fractal-dim): correct box-count & perimeter dimension (oracle-validated)#365
sameeul wants to merge 11 commits into
mainfrom
fix/fractdim-correctness

Conversation

@sameeul

@sameeul sameeul commented Jul 10, 2026

Copy link
Copy Markdown
Member

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 triangle
1.585 (was 1.39), and the Koch-snowflake FRACT_DIM_PERIMETER = 1.262 (was an
out-of-range 0.32).

Root cause

Box counting is grid-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 — halving the box size didn't cleanly quadruple the
count, biasing the dimension low on every shape.

Changes

  • Grid alignment (image_matrix.h, image_matrix_nontriv.cpp, helpers.h): tight
    ceil_pow2 canvas + origin alignment (no centering) in both padded-matrix classes.
  • Estimator (fractal_dim.cpp): replace the mean-of-local-slopes (an endpoint slope)
    with a least-squares loglog_slope shared by both features.
  • Perimeter divider rewrite: perimeter as double (was truncated to int), drop the
    broken tail chord (variable-length + missing sqrt), scale by actual mean ruler length.
  • Goldens regenerated from the fixed build.
  • Oracle tests (tests/python/test_fractal_dim_oracle.py): analytic shapes recover
    known dimensions; the irregular 154-px ROI stays in [1,2].
  • Reproducible ImageJ oracle (tests/oracle/): headless shifting-grid box-count macro
    (FracLac is GUI-only) + analytic-mask generator.

Oracle validation

shape truth ImageJ nyxus (fixed)
filled square 2.000 2.000 2.000
straight line 1.000 1.000 1.000
Sierpinski triangle 1.585 1.585 1.585
Koch (perimeter) 1.262 ~1.26

🤖 Generated with Claude Code

@sameeul sameeul marked this pull request as ready for review July 10, 2026 19:25
sameeul and others added 11 commits July 11, 2026 00:50
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>
@sameeul sameeul force-pushed the fix/fractdim-correctness branch from 65764ef to c63cdc4 Compare July 11, 2026 04:52
@sameeul

sameeul commented Jul 11, 2026

Copy link
Copy Markdown
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.

@sameeul sameeul closed this Jul 11, 2026
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