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

@darkclad darkclad commented Oct 22, 2026 •

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.

Fixes #497. Fixes #516. The out-of-core contour (#509) is not changed here.

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, to the ROI's outer borders only. It used centroid - 1, which put the centre two pixels off the shifted contour. A hole's border is not part of the outline (OpenCV's external contour, which imea reads), so a hole no longer shrinks the inscribing circle to the hole; a ROI of several fragments is measured to every fragment's outer border.
  • 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. A one-pixel ROI now lands in the first radial bin.
  • The distance-to-contour searches behind the radial distribution and the weighted moments (CPU and GPU) search each border's walk on its own, take the best over the borders, and skip a border whose bounding box cannot beat the best distance so far. Searched as one sequence, the merged contour jumps from one border to the next and the coarse-to-fine descent settles on the wrong border. Walks of up to 10 pixels are scanned exhaustively: the coarse step skipped the second point of a 2-point walk. Within one longer border the search is still approximate (PN-200).
  • The walk is emitted clockwise on screen from its first raster pixel, the direction the out-of-core builder walks a simple outline in, so the existing out-of-core invariants on the full-image rectangle hold as before.
  • ContourFeature builds the merged contour once per ROI (LR::contour_pixels_, with the border boundaries in LR::contour_border_ends_), and every consumer reads it: neighbours, circles, radius, radial, moments and the GPU cache.
  • The whole-slide moment weighting reads the slide's corners only when the deduplicated contour has all four (a 1xN slide has two).

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 is measured whole, at its closed length, and adds no ruler step, so a small hole or fragment keeps its length at every stride and the mean ruler comes only from the borders the stride walks. A 17x17 square beside a 3-pixel line has dimension exactly 1. 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, which hold with a centred 3x3 hole too; on two r=10 disks 40 apart, D − 2R and D + 2R). The invariants added in commits 1 and 2 are registered on FRACT_DIM_PERIMETER, WEIGHTED_SPAT_MOMENT_00 and IMOM_WRM_00. The registry, recipes, matrices and audit reports describe the measured state.

Docs. Each commit updates the pages that describe its change. Commit 1: the morphology, radial distribution, moments and fractal pages, their worked examples and the pinned values in docs/feature_extras.toml, all re-measured; the out-of-core notes stay, re-measured. Commit 2: the fractal page's divider walk. Commit 3: the Validation sections and the generated feature indexes.

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 3.6047 disk R=20 (33.106 → 38.053), r=15 circle (23.3 → 28.071), ring 12 hole 4 (→ 11.045), 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 3.6047 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 -12786.2779 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_03 shape2d 3025.5188 4911.5525 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_11 shape2d 1480.0598 -820.4729 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_12 shape2d -3008.6061 -1612.2589 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_20 shape2d -4257.0538 -10317.9412 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 6737.145 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WCM_30 shape2d 31.5506 10842.6447 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.0008986 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 1.329e-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.0004973 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 -3.191e-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.0004013 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 -5070.6542 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 -18885.609 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_02 shape2d -27280.9769 -83125.5695 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_03 shape2d -1.219e+05 -3.999e+05 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_10 shape2d -5542.8104 -15694.3901 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 -59274.0967 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_12 shape2d -71783.3467 -2.65e+05 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_20 shape2d -20307.209 -58894.2925 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 -2.177e+05 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
IMOM_WRM_30 shape2d -83425.4989 -2.353e+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 -369.8144 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_03 shape2d 7.1519 79.7113 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_11 shape2d 46.5008 -27.8648 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_12 shape2d -51.5839 -51.3202 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_20 shape2d -96.2773 -259.6244 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 64.7864 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_CENTRAL_MOMENT_30 shape2d 6.8512 213.7308 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.0459 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 8.116e-05 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 -117.0766 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 -365.7438 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_02 shape2d -534.7393 -1512.3872 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_03 shape2d -2311.1822 -6955.5205 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_10 shape2d -132.5666 -338.1147 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 -1084.1253 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_12 shape2d -1324.8051 -4593.1611 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_20 shape2d -481.966 -1236.093 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 -3957.6776 PN-243 blob, disk R=20, neighbour scene, ring 12, hole 4, two squares 6+8
WEIGHTED_SPAT_MOMENT_30 shape2d -1955.597 -4855.6608 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.027 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.002 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.0189 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). Searching each border on its own lowers the overestimate wherever a ROI has a hole or several fragments (shape2d WEIGHTED_SPAT_MOMENT_00 −95.31 with one search over the merged contour, −117.08 per border). 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)

Added with the per-border search, the outer-border circles and the short-border divider, each failing on this branch's code before that change (measured):

test before the change
TEST_2D_CONTOUR_DISTANCE_SEARCH_SHORT_WALK_INVARIANT 2-point walk: min_sqdist 101, exact 73
TEST_2D_RADIAL_TWO_PIXEL_ROI_MECHANICS both pixels in bin 0 (FRAC_AT_D_0 1, not 0.5)
TEST_2D_RADIAL_TWO_FRAGMENTS_MECHANICS centre (8,1), radius² 49; exact (3,2), 50
TEST_2D_MOMENTS_WEIGHTED_DISK_COPIES_INVARIANT two disk copies weigh 104.997, not twice one copy (4.390)
TEST_2D_MOMENTS_WHOLESLIDE_THIN_SLIDE_MECHANICS (1x1, 1x7, 7x1) out-of-bounds corner read (aborts under a hardened STL)
TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_HOLED_DISK_ANALYTIC inscribing diameter 4 (to the hole), not 28.071
TEST_2D_MORPHOLOGY_FRACTAL_PERIMETER_SHORT_BORDER_INVARIANT square + 3-pixel line 1.02624, not 1
TEST_2D_CONTOUR_BORDERED_CONTOUR_BINDS_LVALUES_MECHANICS BorderedContour could bind a temporary

Two more pin conventions rather than defects: TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_TWO_FRAGMENTS_ANALYTIC (two disks: 20 and 60), and TEST_2D_CONTOUR_DISTANCE_SEARCH_BORDERS_INVARIANT, which checks on 100 seeded multi-border masks that skipping borders by their bounding box changes no distance.

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 1190 passed, 1 skipped (the GPU case); pytest tests/python/ 262 passed, 9 skipped, 8 failed. The 8 failures are the @pytest.mark.arrow cases and the arrow_output.py docs example of a build without Arrow.
  • ASan + UBSan (Linux, gcc), on the final tree: gtest 1190 passed, 1 skipped; pytest tests/python/ 262 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 Sphinx build passes with -W at every commit.

The GPU changes (per-border search in the weighting kernels, the border-boundary buffer, the whole-slide corner guard) were built and run on a CUDA build: on 8 multi-border ROIs and on 1x1, 1x7, 7x1 and 5x4 slides the weighted raw moments match the CPU exactly. The weighted central moments differ between CPU and GPU by up to about 2x, the same before and after the per-border search; that is recorded on PN-242, with the GPU's missing pixel-size scaling.

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.
  • The multi-threaded neighbour pair pass checks only the first contour for emptiness (PN-272; the pass does not run today).
  • 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 16:33
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, and to the ROI's outer
  borders only (the external contour imea reads), so a hole no longer
  shrinks the inscribing circle to the hole
- the distance-to-contour searches behind the radial distribution and
  the weighted moments (CPU and GPU) search each border's walk on its
  own and skip a border whose bounding box cannot beat the best distance
  so far; walks of up to 10 pixels are scanned exhaustively, so a 2- or
  3-pixel contour is never half-skipped
- the merged contour and the border boundaries are built once per ROI
  by the contour feature, and every consumer (neighbours, circles,
  radius, radial, moments, GPU cache) reads them
- 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
- the whole-slide moment weighting reads its slide corners only when
  the deduplicated contour has all four

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, a one-
and a two-pixel ROI and a two-fragment ROI through the radial
distribution, the distance searches on short walks and on 100 random
multi-border masks, two disk copies weighing twice one copy in the
weighted moments, 1x1/1x7/7x1 whole slides, and BorderedContour refusing
a temporary.

The morphology, radial, moments and fractal pages, their worked
examples and the pinned values in feature_extras.toml describe the new
contour; the out-of-core notes stay, re-measured.

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 -> 3.605 (= OpenCV to the external
contour), 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 is measured whole, at its closed length, and adds no
ruler step, so the mean ruler comes only from the borders the stride
walks. 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. A 17x17
square beside a 3-pixel line has dimension exactly 1; dropping the line
at the strides it is too short for gave 1.0262. Both identities are the
new invariant tests. The fractal page and its worked example describe
the per-border walk.

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, and
  keep them with a centred 3x3 hole; on two r=10 disks 40 apart they
  equal D - 2R and D + 2R. imea is not an oracle for these: its own
  values on that circle are 46.86 and 12.65.

The invariants added with the contour and fractal fixes are registered
on FRACT_DIM_PERIMETER, WEIGHTED_SPAT_MOMENT_00 and IMOM_WRM_00.

The generators verify every new pin. The feature pages' Validation
sections and the generated feature indexes name the new oracles.
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

1 participant