Skip to content

fix(contour): trace every ROI border in order, in absolute coordinates - #530

Open
darkclad wants to merge 3 commits into
PolusAI:mainfrom
darkclad:main-contour-inram
Open

darkclad wants to merge 3 commits into
PolusAI:mainfrom
darkclad:main-contour-inram

Conversation

@darkclad

Copy link
Copy Markdown

Summary

The in-memory 2D contour was wrong in three ways, and every contour-based feature inherited them:

  • It lost pixels. The tracer drew the borders into a raster, then rebuilt their order with a greedy nearest-neighbour walk. Where a boundary pixel had more than two boundary neighbours, the walk could dead-end, and everything it had collected was then discarded. A filled 9066-pixel blob with 495 edge pixels kept 2 of them and reported PERIMETER 2.0. A 2×2 square kept 3 of its 4.
  • It was in the wrong place. Every traced pixel came back one pixel right and one pixel down, because the one-pixel pad the ROI is traced in was never subtracted. Features that measure contour pixels against ROI pixels (ROI_RADIUS_*, radial distribution, the inscribing/circumscribing circles, the weighted moments) measured against a shifted boundary.
  • It joined borders with chords. A ROI with holes or several fragments had its borders concatenated, and PERIMETER (and everything built on it) added the jump from the end of one border to the start of the next.

Follow-ups are on Axle's PN Jira board (https://labshare.atlassian.net/jira/software/projects/PN/boards/851), which is where the PN keys below point. This closes PN-243, PN-22 and PN-169, and the in-memory half of PN-70.

Change

Three commits.

1. fix(contour): trace every ROI border in order, in absolute coordinates. ContourFeature::gather_multicontour is now Suzuki and Abe's border following (1985), the algorithm behind OpenCV's findContours. It returns one closed walk in 8-connected steps per outer border of each fragment and per hole, over exactly the ROI pixels that have a 4-neighbour outside the ROI.

  • buildRegularContour subtracts the pad. buildRegularContour_nontriv drops it as well, so the two builders return the same pixel set. The out-of-core contour is still relative to the bounding box (PN-208).
  • PERIMETER is the summed length of the closed walks, with no chord between them.
  • LR::merge_multicontour lists each contour pixel once. A walk passes a one-pixel-wide neck twice, and two borders can share a pixel; the EDGE_* statistics and PERCENT_TOUCHING count such a pixel once.
  • circle.cpp measures from the centroid itself. It used centroid - 1, which put the centre two pixels off the shifted contour.
  • A one-pixel ROI is now a one-pixel contour. That made two latent faults reachable, both fixed here. RadialDistributionFeature divided by a zero radius and wrote to radial_count_bins[INT_MIN], an access violation on Windows. The coarse-to-fine searches Pixel2::min_sqdist/max_sqdist, and their GPU copy, converted 1/log(1) to int. A one-pixel ROI now lands in the first radial bin, and a one-element contour returns its single distance.
  • The walk is emitted clockwise on screen from its first raster pixel, the direction the out-of-core builder walks a simple outline in. The approximate distance searches the weighted moments use depend on that order. With it, the existing out-of-core invariants on the full-image rectangle hold as before.

2. fix(fractal): walk each border separately for FRACT_DIM_PERIMETER. The Richardson divider walks every closed border on its own and sums lengths and steps per stride. A border no longer than the stride takes no step, since a ruler longer than a closed curve cannot be laid along it. The trade-off: a small hole or fragment drops out at the coarsest strides, which reads as detail lost at that scale. A single-border ROI is unchanged, the FracLac-vetted blob included.

3. test(vetting): vet what the corrected contour lets an oracle reproduce. ROI_RADIUS_MEAN (scikit-image, three disks), PERCENT_TOUCHING (CellProfiler, 5 ROIs, residual 0), and DIAMETER_INSCRIBING_CIRCLE / DIAMETER_CIRCUMSCRIBING_CIRCLE (closed forms on the r=15 circle: 2√((R−1)²+1) and 2R). The registry, recipes, matrices and audit reports describe the measured state.

Changed values

Measured with the Python API on main and on this branch. Simple convex ROIs do not move: on the disk R=20, PERIMETER stays 131.88225099390849, and so do every feature derived from it and the EDGE_* statistics. DIAMETER_EQUAL_AREA and FRACT_DIM_BOXCOUNT do not move on any fixture.

feature fixture before after also moves on
PERIMETER shape2d (one-pixel hole) 26.9349 22.9706 PN-243 blob (2 → 537.664), ring 12 hole 4 (68.715 → 61.657), two squares 6+8 (70.166 → 48), neighbour scene
DIAMETER_EQUAL_PERIMETER shape2d 8.5737 7.3118 as PERIMETER
CIRCULARITY shape2d 0.6711 0.7869 as PERIMETER
GEODETIC_LENGTH shape2d 11.1318 8.3842 as PERIMETER
THICKNESS shape2d 2.3356 3.1011 as PERIMETER
POLYGONALITY_AVE neighbour scene, label 2 −15.3178 −7.5
HEXAGONALITY_AVE neighbour scene, label 2 5.0941 5.3571
HEXAGONALITY_STDDEV neighbour scene, label 2 0.3207 0.2973
FRACT_DIM_PERIMETER shape2d 1.2632 1.3448 PN-243 blob (0 → 1.1268), ring, two squares
EDGE_INTEGRATED_INTENSITY PN-243 blob 41 12612 4 speckles (0 → 145), neighbour scene
EDGE_MEAN_INTENSITY PN-243 blob 20.5 25.4788 4 speckles (0 → 36.25), neighbour scene
EDGE_MIN_INTENSITY PN-243 blob 17 1 4 speckles, neighbour scene
EDGE_MAX_INTENSITY PN-243 blob 24 50 4 speckles
EDGE_STDDEV_INTENSITY PN-243 blob 0 14.0639 4 speckles, neighbour scene
ROI_RADIUS_MEAN shape2d 0.6247 0.3077 disk R=20 (6.0871 → 6.0470), PN-243 blob, line, ring, two squares, neighbour scene
ROI_RADIUS_MAX shape2d 1.4142 1 PN-243 blob, line, neighbour scene
ROI_RADIUS_MEDIAN shape2d 1 0 PN-243 blob, line, neighbour scene
DIAMETER_CIRCUMSCRIBING_CIRCLE shape2d 12.3317 6.8888 disk R=20 (45.607 → 40 = 2R), r=15 circle (35.6 → 30 = 2R), every other fixture
DIAMETER_INSCRIBING_CIRCLE shape2d 0.8285 1.2686 disk R=20 (33.106 → 38.053), r=15 circle (23.3 → 28.071), every other fixture
DIAMETER_MIN_ENCLOSING_CIRCLE PN-243 blob 1.0002 135.7647 4 speckles (0 → 7.2113)
PERCENT_TOUCHING neighbour scene, labels 2 / 3 / 5 66.67 / 66.67 / 33.33 50 / 50 / 50
FRAC_AT_D, MEAN_FRAC, RADIAL_CV shape2d e.g. MEAN_FRAC_7 22 47.5 every fixture; all 24 shape2d values are re-pinned
all 62 weighted moments 48×40 rectangle WEIGHTED_SPAT_MOMENT_00 2498.71 1895.00 every fixture
Every changed code, one row each (104 codes)
feature fixture before after also moves on
CIRCULARITY shape2d 0.6711 0.7869 PN-243 blob, neighbour scene, ring 12, hole 4, two squares 6+8
DIAMETER_CIRCUMSCRIBING_CIRCLE shape2d 12.3317 6.8888 1-px line of 5, 4 speckles, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
DIAMETER_EQUAL_PERIMETER shape2d 8.5737 7.3118 PN-243 blob, neighbour scene, ring 12, hole 4, two squares 6+8
DIAMETER_INSCRIBING_CIRCLE shape2d 0.8285 1.2686 1-px line of 5, 4 speckles, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
DIAMETER_MIN_ENCLOSING_CIRCLE PN-243 blob 1.0002 135.7647 4 speckles
EDGE_INTEGRATED_INTENSITY PN-243 blob 41 12612 4 speckles, neighbour scene
EDGE_MAX_INTENSITY PN-243 blob 24 50 4 speckles
EDGE_MEAN_INTENSITY PN-243 blob 20.5 25.4788 4 speckles, neighbour scene
EDGE_MIN_INTENSITY PN-243 blob 17 1 4 speckles, neighbour scene
EDGE_STDDEV_INTENSITY PN-243 blob 0 14.0639 4 speckles, neighbour scene
FRACT_DIM_PERIMETER shape2d 1.2632 1.3448 PN-243 blob, ring 12, hole 4, two squares 6+8
FRAC_AT_D_0 PN-243 blob 0.0029 0.0275 4 speckles, ring 12, hole 4
FRAC_AT_D_1 PN-243 blob 0.0052 0.0834 ring 12, hole 4, two squares 6+8
FRAC_AT_D_2 PN-243 blob 0.0101 0.1412 ring 12, hole 4, two squares 6+8
FRAC_AT_D_3 PN-243 blob 0.0124 0.1946 neighbour scene, ring 12, hole 4
FRAC_AT_D_4 shape2d 0.3077 0.3846 4 speckles, PN-243 blob, neighbour scene, ring 12, hole 4, two squares 6+8
FRAC_AT_D_5 PN-243 blob 0.0245 0.203 4 speckles, ring 12, hole 4, two squares 6+8
FRAC_AT_D_6 shape2d 0.1154 0.1538 1-px line of 5, PN-243 blob, disk R=20, ring 12, hole 4
FRAC_AT_D_7 shape2d 0.2692 0.1538 1-px line of 5, 4 speckles, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
GEODETIC_LENGTH shape2d 11.1318 8.3842 PN-243 blob, neighbour scene, ring 12, hole 4, two squares 6+8
HEXAGONALITY_AVE neighbour scene (label 2) 5.0941 5.3571
HEXAGONALITY_STDDEV neighbour scene (label 2) 0.3207 0.2973
IMOM_WCM_02 shape2d -4454.7889 -11586.782 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_03 shape2d 3025.5188 8889.1109 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_11 shape2d 1480.0598 -990.6348 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_12 shape2d -3008.6061 -1905.7454 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_20 shape2d -4257.0538 -10000.479 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_21 shape2d 827.5594 8001.1052 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_30 shape2d 31.5506 9486.0627 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WHU1 shape2d -0.0024 -0.0013 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WHU2 shape2d 6.556e-07 2.309e-08 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WHU3 neighbour scene (label 2) 2.897e-07 0 1-px line of 5
IMOM_WHU4 neighbour scene (label 2) 2.922e-08 0 1-px line of 5
IMOM_WHU6 1-px line of 5 8.833e-09 0
IMOM_WNCM_02 shape2d -0.0012 -0.0006938 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WNCM_03 PN-243 blob -1.6e-06 -2.921e-06 disk R=20, neighbour scene
IMOM_WNCM_11 shape2d 0.0004039 -5.931e-05 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WNCM_12 PN-243 blob -7.783e-07 -1.414e-06 disk R=20, neighbour scene
IMOM_WNCM_20 shape2d -0.0012 -0.0005988 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WNCM_21 PN-243 blob 7.398e-07 -5.697e-07 disk R=20, neighbour scene
IMOM_WNCM_30 PN-243 blob -3.439e-06 1.909e-06 1-px line of 5, disk R=20, neighbour scene
IMOM_WRM_00 shape2d -1914.1714 -4086.7535 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_01 shape2d -6610.0859 -15881.4942 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_02 shape2d -27280.9769 -73303.7072 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_03 shape2d -1.219e+05 -3.66e+05 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_10 shape2d -5542.8104 -12435.3102 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_11 shape2d -17660.574 -49315.377 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_12 shape2d -71783.3467 -2.327e+05 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_20 shape2d -20307.209 -47839.0573 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_21 shape2d -60726.498 -1.839e+05 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_30 shape2d -83425.4989 -1.969e+05 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
MEAN_FRAC_0 shape2d 51 40 1-px line of 5, 4 speckles, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4
MEAN_FRAC_1 PN-243 blob 26.7872 25.7302 disk R=20, ring 12, hole 4, two squares 6+8
MEAN_FRAC_2 shape2d 53.3333 39.6667 PN-243 blob, disk R=20, ring 12, hole 4, two squares 6+8
MEAN_FRAC_3 shape2d 50.75 41.25 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
MEAN_FRAC_4 shape2d 47.375 38.2 4 speckles, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
MEAN_FRAC_5 PN-243 blob 26.2252 25.3946 4 speckles, disk R=20, ring 12, hole 4, two squares 6+8
MEAN_FRAC_6 shape2d 33.6667 38 1-px line of 5, PN-243 blob, disk R=20, ring 12, hole 4, two squares 6+8
MEAN_FRAC_7 shape2d 22 47.5 1-px line of 5, 4 speckles, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
PERCENT_TOUCHING neighbour scene (label 2) 66.6667 50
PERIMETER shape2d 26.9349 22.9706 PN-243 blob, neighbour scene, ring 12, hole 4, two squares 6+8
POLYGONALITY_AVE neighbour scene (label 2) -15.3178 -7.5
RADIAL_CV_0 PN-243 blob 1.3808 0.0915 4 speckles, disk R=20, ring 12, hole 4
RADIAL_CV_1 PN-243 blob 1.8083 0.0193 disk R=20, ring 12, hole 4, two squares 6+8
RADIAL_CV_2 shape2d 1.2988 1.309 PN-243 blob, disk R=20, ring 12, hole 4, two squares 6+8
RADIAL_CV_3 shape2d 1.0244 1.0288 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
RADIAL_CV_4 shape2d 0.6475 0.6043 4 speckles, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
RADIAL_CV_5 PN-243 blob 1.6834 0.371 4 speckles, disk R=20, ring 12, hole 4, two squares 6+8
RADIAL_CV_6 shape2d 1.3575 1.2455 1-px line of 5, PN-243 blob, disk R=20, ring 12, hole 4, two squares 6+8
RADIAL_CV_7 shape2d 1.3284 1.8153 1-px line of 5, 4 speckles, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
ROI_RADIUS_MAX shape2d 1.4142 1 1-px line of 5, PN-243 blob, neighbour scene
ROI_RADIUS_MEAN shape2d 0.6247 0.3077 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
ROI_RADIUS_MEDIAN shape2d 1 0 1-px line of 5, PN-243 blob, neighbour scene
THICKNESS shape2d 2.3356 3.1011 PN-243 blob, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_02 shape2d -114.175 -354.2193 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_03 shape2d 7.1519 114.7304 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_11 shape2d 46.5008 -28.7007 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_12 shape2d -51.5839 -80.2841 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_20 shape2d -96.2773 -249.5715 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_21 shape2d -11.7736 67.6916 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_30 shape2d 6.8512 154.7116 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_HU_M1 shape2d -0.1014 -0.0665 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_HU_M2 shape2d 0.0021 0.0001726 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_HU_M3 PN-243 blob 2.802e-07 4.138e-07 1-px line of 5, disk R=20, neighbour scene
WEIGHTED_HU_M4 PN-243 blob 3.107e-07 1.569e-07 1-px line of 5, disk R=20, neighbour scene
WEIGHTED_HU_M5 disk R=20 2.55e-09 4.436e-08 1-px line of 5, neighbour scene
WEIGHTED_HU_M6 disk R=20 -5.029e-07 7.04e-06 1-px line of 5, neighbour scene
WEIGHTED_SPAT_MOMENT_00 shape2d -45.565 -95.3114 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_01 shape2d -138.4305 -301.4871 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_02 shape2d -534.7393 -1307.8776 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_03 shape2d -2311.1822 -6263.2423 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_10 shape2d -132.5666 -266.6031 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_11 shape2d -356.2483 -872.0144 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_12 shape2d -1324.8051 -3920.2241 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_20 shape2d -481.966 -995.3082 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_21 shape2d -1205.4511 -3241.2104 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_30 shape2d -1955.597 -4025.537 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WT_NORM_CTR_MOM_02 shape2d -0.055 -0.039 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WT_NORM_CTR_MOM_03 PN-243 blob -0.0002075 -0.0003029 disk R=20, neighbour scene
WT_NORM_CTR_MOM_11 shape2d 0.0224 -0.0032 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WT_NORM_CTR_MOM_12 PN-243 blob -0.0001063 -0.0001093 disk R=20, neighbour scene
WT_NORM_CTR_MOM_20 shape2d -0.0464 -0.0275 1-px line of 5, PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WT_NORM_CTR_MOM_21 PN-243 blob 0.0001025 -4.706e-05 disk R=20, neighbour scene
WT_NORM_CTR_MOM_30 PN-243 blob -0.0004412 0.0002948 1-px line of 5, disk R=20, neighbour scene

Notes on the values:

  • Weighted moments. Each pixel is weighted by I · log(d + 0.001), with d its distance to the contour. On the 48×40 rectangle the exact distance to the true border gives WEIGHTED_SPAT_MOMENT_00 = 1821.28. The old 2498.71 combined the shifted contour with the approximate min_sqdist search; the remaining 4% (1895.00) is the search alone (PN-200). Edge pixels now sit at d = 0 and weigh I · log(0.001) < 0, so several weighted central moments on small fixtures are negative. That follows from the weighting formula and is recorded on PN-200.
  • Speckles. An isolated pixel is a border of its own, so a mask of speckles has PERIMETER 0 and every pixel is an edge pixel. test_bad_contour now asserts EDGE_MEAN_INTENSITY equals the speckles' mean, where it pinned 0.
  • Radial. The radial features still carry the two defects of PN-23 (the approximate radius search and the num_bins - 1 scaling). Only the frame changed here, and their mechanics pins are re-measured.

Oracles

  • OpenCV findContours(RETR_LIST, CHAIN_APPROX_NONE) (4.13.0; 5.0.0 agrees): number of borders, the pixels on them, and PERIMETER, exact, on the PN-243 blob and on shape2d. New generator gen_contour_opencv.py.
  • scikit-image measure.perimeter, on the circles fixture: still 999.259, unchanged. find_boundaries(connectivity=1, mode='inner'): the contour's pixel set on every fixture measured, and ROI_RADIUS_MEAN/MAX/MEDIAN on the three disks.
  • CellProfiler: EDGE_* on shape8 unchanged, and PERCENT_TOUCHING now agrees on all five ROIs. It used to differ on three because the 2×2 borders had lost a pixel.
  • imea ISO transforms fed the new PERIMETER reproduce DIAMETER_EQUAL_PERIMETER, GEODETIC_LENGTH and THICKNESS.
  • FracLac blob512 and the Koch and disk perimeter dimensions: unchanged.

imea's own inscribing/circumscribing diameters are not an oracle for Nyxus': on the r=15 circle imea reports 46.86 and 12.65.

Tests

New gtest cases, each failing on main (measured):

test on main
TEST_2D_CONTOUR_SQUARE_ANALYTIC, _DISK_ANALYTIC contour at (+1, +1)
TEST_2D_CONTOUR_RING_ANALYTIC PERIMETER off by +7.058 (chord between the borders)
TEST_2D_CONTOUR_TWO_FRAGMENTS_ANALYTIC chord between the fragments
TEST_2D_CONTOUR_LINE_ANALYTIC contour is not a closed walk
TEST_2D_CONTOUR_SINGLE_PIXEL_ANALYTIC no contour at all
TEST_2D_CONTOUR_NOISY_ROIS_BOUNDARY_RULE_INVARIANT (400 seeded ROIs) fails on the first ROI
TEST_2D_CONTOUR_BLOB97_BOUNDARY_RULE_INVARIANT, _BLOB97_OPENCV 2 contour pixels of 495, one not a ROI pixel
TEST_2D_CONTOUR_SHAPE2D_OPENCV PERIMETER 26.935 against 22.971
TEST_2D_MORPHOLOGY_FRACTAL_PERIMETER_TWO_COPIES_INVARIANT two identical disks 1.1300, one disk 1.0448
TEST_2D_RADIAL_SINGLE_PIXEL_ROI_MECHANICS access violation (0xC0000005)

TEST_2D_CONTOUR_MULTI_CONNECTED_ANALYTIC asserted 4 contours on a fixture that has 3 fragments and no holes (OpenCV agrees). It now asserts 3. The PN-243 blob is the fixture bench_blob97_filled.

The re-pinned regression goldens are listed under "Changed values". The out-of-core guard for PN-70 is re-measured: both builders now return the same 112 edge pixels, and the remaining gap is the bounding-box frame of PN-208.

Gates:

  • Windows (MSVC): gtest 1180 passed, 1 skipped (the GPU case); pytest tests/python/ 149 passed, 4 skipped, 7 failed. The 7 failures are the @pytest.mark.arrow cases of a build without Arrow.
  • ASan + UBSan (Linux, gcc), on the final tree: gtest 1180 passed, 1 skipped; pytest tests/python/ 149 passed, 4 skipped (Arrow deselected); 0 sanitizer diagnostics.
  • Every commit builds and passes on its own.
  • check_test_names.py, check_coverage.py, report_features.py and every scan_*_coverage.py pass with --check.

The GPU kernel change (one-element contour in pixel_sqdist_2_contour) is compiled only in a CUDA build and was not run.

Not in this PR

  • Out-of-core contours (PN-208 frame, PN-73 first-tile truncation, PN-48 pixel-count PERIMETER, PN-261, PN-244) are a separate change that starts from this one. On ROIs with holes or fragments the out-of-core PERIMETER is further from in-RAM than before, because in-RAM now includes the hole borders.
  • docs/source/Math/f_morphology.rst still describes Moore tracing and PERIMETER = card(C). Those pages are being rewritten in the docs update (PN-194), which now carries this contour definition.
  • A pixel of intensity 4294967295 is read as background by the contour tracer, as by the large-ROI box count (PN-206).

Demian Vladi added 3 commits October 22, 2026 11:20
The in-memory contour builder now follows each border of the ROI with
Suzuki and Abe's border following, the algorithm behind OpenCV's
findContours: one closed walk in 8-connected steps per outer border of
each fragment and per hole, over exactly the ROI pixels with a
4-neighbour outside the ROI. The walk comes out ordered as it is traced.

The builder it replaces drew the borders into a raster and then rebuilt
their order with a greedy nearest-neighbour walk. Wherever a boundary
pixel had more than two boundary neighbours that walk could dead-end, and
everything it had collected was then discarded. On a filled 9066-pixel
blob with 495 edge pixels it kept 2, so PERIMETER was 2; on a 2x2 square
it kept 3 of 4. Every traced pixel was also reported one pixel right and
one pixel down of where it is, because the one-pixel pad the ROI is
traced in was never subtracted.

Consequences, all measured:
- contour pixels are ROI pixels, in the frame of raw_pixels; the
  out-of-core builder drops the same pad, so the in-RAM and out-of-core
  contours of a ROI are the same pixel set (the out-of-core one stays
  relative to the bounding box)
- PERIMETER is the summed length of the closed walks, with no chord
  joining one border to the next
- merge_multicontour lists each contour pixel once, so the EDGE_*
  statistics and PERCENT_TOUCHING count a pixel the walk passes twice,
  or two borders share, once
- the inscribing/circumscribing circle diameters measure from the
  centroid itself rather than from centroid - 1
- a one-pixel ROI is a one-pixel contour: the radial distribution puts
  its pixel in the first bin instead of dividing by a zero radius and
  writing far outside its bins, and the coarse-to-fine distance searches
  (CPU and GPU) return a one-element contour's distance directly instead
  of converting 1/log(1) to int

New tests: closed forms (square, disk, ring, two fragments, one-pixel
line, single pixel), a 400-ROI battery checked against the 4-adjacent
boundary rule, OpenCV goldens on the blob and the shape2d mask, and a
one-pixel ROI through the radial distribution.

Changed values, shape2d fixture unless stated: PERIMETER 26.935 ->
22.971 (= OpenCV), DIAMETER_EQUAL_PERIMETER 8.574 -> 7.312,
GEODETIC_LENGTH 11.132 -> 8.384, THICKNESS 2.336 -> 3.101,
ROI_RADIUS_MEAN/MAX/MEDIAN 0.6247/1.4142/1 -> 0.3077/1/0 (= the
scikit-image inner-boundary reference), DIAMETER_CIRCUMSCRIBING_CIRCLE
12.332 -> 6.889, DIAMETER_INSCRIBING_CIRCLE 0.828 -> 1.269, all 24
radial values, all 62 weighted moments (WEIGHTED_SPAT_MOMENT_00 on the
48x40 fixture 2498.71 -> 1895.00 against 1821.28 at exact distances),
PERCENT_TOUCHING on the 2x2 neighbours 66.67/33.33 -> 50 (= CellProfiler
on all 5 ROIs).

Refs PN-243, PN-22, PN-169, PN-70.
The Richardson divider now walks every closed border of the ROI -- the
outer border of each fragment and the border of each hole -- on its own,
and sums the borders' lengths and steps at each stride. A border no longer
than the stride takes no step. It used to walk all borders as one
sequence, which measured a chord from the end of one border to the start
of the next.

A ROI with a single border is unchanged, the FracLac-vetted blob
included. Two identical separate disks now have exactly the dimension of
one disk (1.0448); walked as one sequence they gave 1.1300. That identity
is the new invariant test.

Refs PN-169.
…n oracle reproduce

With the contour on the ROI's own edge pixels, three features that no
external reference could reproduce now agree with one exactly:

- ROI_RADIUS_MEAN agrees with the scikit-image inner-boundary reference
  on the three disks (2.7517, 6.0470, 12.6860), and is vetted there
  beside MAX and MEDIAN.
- PERCENT_TOUCHING agrees with CellProfiler on all five ROIs of the
  neighbour scene. At a distance of one pixel CellProfiler's dilation is
  the 3x3 neighbourhood, so the two definitions count the same pixels;
  the 3-of-5 divergence on record came from 2x2 borders that had lost a
  pixel.
- DIAMETER_INSCRIBING_CIRCLE and DIAMETER_CIRCUMSCRIBING_CIRCLE equal
  their closed forms on the r=15 circle, 2 sqrt((R-1)^2+1) and 2R. imea
  is not an oracle for these: its own values on that circle are 46.86
  and 12.65.

The generators verify every new pin. Registry rows, recipes, the
morphology and neighbour matrices, not_covered.md and the audit reports
describe the measured state.

Refs PN-22, PN-70.
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