From c7329a1f159f7fcb49e1fdd296e9fe29783141e2 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Fri, 23 Oct 2026 17:55:19 -0700 Subject: [PATCH 1/3] fix(contour): trace every ROI border in order, in absolute coordinates 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 GPU cache carries the border boundaries the per-border search needs. The memory plan and the allocation size the cloud, contour and border buffers with one set of numbers, for the ROIs as resampled under anisotropy; the border count is bounded by twice the mask's row runs found in the prescan, capped at the contour capacity. allocate_gpu_cache starts from an empty cache and frees everything on any failure, the GpuCache alloc/clear helpers free and null host and device buffers independently, upload failures throw and end the slide with a message and the cache freed, and the trivial-ROI reduction uses the GPU only when its cache is allocated (otherwise the CPU path computes the same features). 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. --- docs/examples/fractal_worked_example.py | 14 +- docs/examples/moments_worked_example.py | 38 +- docs/examples/morphology_worked_example.py | 27 +- docs/examples/radial_worked_example.py | 142 ++--- docs/feature_descriptions.toml | 16 +- docs/feature_extras.toml | 99 ++- docs/source/Math/circles_index.inc | 4 +- docs/source/Math/contour_index.inc | 4 +- docs/source/Math/f_2dmoments.rst | 114 ++-- docs/source/Math/f_fractal_dimension.rst | 23 +- docs/source/Math/f_morphology.rst | 119 ++-- docs/source/Math/f_radial_distribution.rst | 278 ++++----- docs/source/Math/intensity_moments_index.inc | 44 +- docs/source/Math/radial_index.inc | 2 +- docs/source/Math/roi_radius_index.inc | 2 +- docs/source/Math/shape_moments_index.inc | 44 +- docs/source/devguide.rst | 9 +- docs/source/featurelist.rst | 20 +- docs/source/large_images.rst | 29 +- docs/source/validation.rst | 8 +- src/nyx/cache.cpp | 296 +++++---- src/nyx/cache.h | 33 +- src/nyx/features/2d_geomoments.h | 4 +- src/nyx/features/2d_geomoments_basic.cpp | 19 +- src/nyx/features/circle.cpp | 29 +- src/nyx/features/contour.cpp | 576 ++++-------------- src/nyx/features/contour.h | 30 +- src/nyx/features/fractal_dim.cpp | 9 +- src/nyx/features/neighbors.cpp | 13 +- src/nyx/features/pixel.cpp | 285 ++++++--- src/nyx/features/pixel.h | 55 +- src/nyx/features/radial_distribution.cpp | 27 +- src/nyx/features/radial_distribution.h | 2 +- src/nyx/features/roi_radius.cpp | 13 +- src/nyx/globals.h | 9 + src/nyx/gpu/geomoments_main.cu | 11 +- src/nyx/gpu/geomoments_weighting.cu | 56 +- src/nyx/gpucache.cpp | 374 ++++-------- src/nyx/reduce_trivial_rois.cpp | 6 +- src/nyx/roi_cache.cpp | 22 +- src/nyx/roi_cache.h | 18 +- src/nyx/slideprops.cpp | 13 + src/nyx/slideprops.h | 7 + src/nyx/workflow_2d_segmented.cpp | 136 ++++- tests/CMakeLists.txt | 3 + tests/python/test_2d_ooc_regression.py | 22 +- tests/python/test_nyxus.py | 5 +- tests/test_2d_contour_analytic.h | 188 +++++- tests/test_2d_contour_common.h | 186 ++++++ tests/test_2d_contour_invariant.h | 195 ++++++ tests/test_2d_contour_mechanics.h | 153 +++++ tests/test_2d_contour_opencv.h | 53 ++ tests/test_2d_gabor_mechanics.h | 20 + tests/test_2d_moments_invariant.h | 70 +++ tests/test_2d_moments_mechanics.h | 480 +++++++++++++++ tests/test_2d_moments_regression.h | 124 ++-- tests/test_2d_morphology_imea.h | 45 +- tests/test_2d_morphology_invariant.h | 6 +- tests/test_2d_morphology_mechanics.h | 52 +- tests/test_2d_morphology_regression.h | 30 +- tests/test_2d_morphology_skimage.h | 7 +- tests/test_2d_neighbor_cellprofiler.h | 10 +- tests/test_2d_neighbor_regression.h | 17 +- tests/test_2d_radial_mechanics.h | 233 +++++-- tests/test_2d_radial_regression.h | 10 +- tests/test_all.cc | 168 +++++ tests/test_main_nyxus.h | 158 +++++ tests/vetting/TOOLS.md | 2 +- .../moments_2d_skimage_vetting_report.md | 4 +- .../vetting/audit/scan_morphology_coverage.py | 11 +- tests/vetting/benchmarks.md | 27 +- tests/vetting/config_recipes.md | 24 + tests/vetting/oracle_coverage.csv | 158 ++--- tests/vetting/oracles/gen_contour_opencv.py | 133 ++++ tests/vetting/oracles/gen_morphology_imea.py | 9 +- .../oracles/gen_morphology_radius_skimage.py | 45 +- .../oracles/gen_radial_cellprofiler.py | 6 +- tests/vetting/report_output.csv | 164 ++--- tests/vetting/report_output.md | 10 +- 79 files changed, 3933 insertions(+), 1974 deletions(-) create mode 100644 tests/test_2d_contour_common.h create mode 100644 tests/test_2d_contour_invariant.h create mode 100644 tests/test_2d_contour_mechanics.h create mode 100644 tests/test_2d_contour_opencv.h create mode 100644 tests/test_2d_moments_invariant.h create mode 100644 tests/test_2d_moments_mechanics.h create mode 100644 tests/vetting/oracles/gen_contour_opencv.py diff --git a/docs/examples/fractal_worked_example.py b/docs/examples/fractal_worked_example.py index 5fde3727f..6093491f8 100644 --- a/docs/examples/fractal_worked_example.py +++ b/docs/examples/fractal_worked_example.py @@ -149,12 +149,20 @@ def from_files(m, intensity=None, single_roi=False, codes=F + ["PERIMETER"], **k inten[70, 70] = value check(from_files(lone, inten, ram_limit=0)[0][:1], [1.4156], f"lone pixel {value} out of core") check(from_files(mask)[0][:2], [1.3732, 1.1311], "example, no anisotropy") -check(from_files(mask, anisotropy_x=2)[0][:2], [1.2459, 1.1731], "anisotropy_x=2") -check(from_files(mask, anisotropy_y=3)[0][:2], [1.4458, 1.2546], "anisotropy_y=3") +check(from_files(mask, anisotropy_x=2)[0][:2], [1.2459, 1.1717], "anisotropy_x=2") +check(from_files(mask, anisotropy_y=3)[0][:2], [1.4458, 1.1635], "anisotropy_y=3") # With anisotropy the out-of-core contour differs, so a single-contour disk differs too -check(from_files(disk(8), anisotropy_x=2)[0][1:2], [1.1431], "disk 8, anisotropy_x=2") +check(from_files(disk(8), anisotropy_x=2)[0][1:2], [1.0771], "disk 8, anisotropy_x=2") # pins known defect https://github.com/PolusAI/nyxus/issues/509 check(from_files(disk(8), anisotropy_x=2, ram_limit=0)[0][1:2], [1.0482], "disk 8, anisotropy_x=2, out of core") +# Without anisotropy a single contour can still be listed in a different order out of core: an 8 x 8 +# square with a one-pixel-wide branch, at the image origin +branch = np.zeros((18, 18), np.uint32) +branch[0:8, 0:8] = 1 +branch[3, 8:16] = 1 +check(from_files(branch)[0][1:2], [1.131], "square with a branch") +# pins known defect https://github.com/PolusAI/nyxus/issues/509 +check(from_files(branch, ram_limit=0)[0][1:2], [1.102], "square with a branch, out of core") labels = two.copy() labels[5:30, 45:70] = 2 for row in from_files(labels, (labels > 0) * 100): diff --git a/docs/examples/moments_worked_example.py b/docs/examples/moments_worked_example.py index 4ad0bd089..13a4ae019 100644 --- a/docs/examples/moments_worked_example.py +++ b/docs/examples/moments_worked_example.py @@ -54,8 +54,8 @@ def same(got, expected, rel=5e-4): assert same(features["SPAT_MOMENT_10"][0] / features["SPAT_MOMENT_00"][0], 3.275) assert same(features["SPAT_MOMENT_01"][0] / features["SPAT_MOMENT_00"][0], 2.925) -# 48 x 40 rectangle: Nyxus, and the same moment with exact distances to the contour as traced -# (one pixel right of and below the ROI's border) and to the ROI's own border pixels +# 48 x 40 rectangle: Nyxus, and the same moment with exact distances to the contour, the ROI's +# border pixels H, W = 40, 48 rect = np.ones((H, W), dtype=np.uint32) w00 = Nyxus(["WEIGHTED_SPAT_MOMENT_00"]).featurize(rect, rect)["WEIGHTED_SPAT_MOMENT_00"][0] @@ -70,13 +70,12 @@ def weight_sum(cx, cy): return float(np.sum(np.log(d + 0.001))) -assert same(w00, 2498.7), w00 -assert same(weight_sum(bx + 1, by + 1), 2422.7) +assert same(w00, 1895.0), w00 assert same(weight_sum(bx, by), 1821.3) -# Digital disks: the sum of the weights changes sign between radius 5 and radius 8, and the +# Digital disks: the sum of the weights changes sign between radius 5 and radius 12, and the # odd-order normalized weighted moments follow it -for r, m00, ncm30 in ((5, -68.94, 0.0), (8, 6.806, 7881.0), (20, 1549.1, 0.007713)): +for r, m00, ncm30 in ((5, -153.9, 0.0), (12, 2.195, -33355.0), (20, 1210.6, -0.001704)): n = 2 * r + 3 yy, xx = np.mgrid[0:n, 0:n] disk = (((yy - r - 1) ** 2 + (xx - r - 1) ** 2) <= r * r).astype(np.uint32) @@ -105,9 +104,9 @@ def weight_sum(cx, cy): for code in ("CENTRAL_MOMENT_01", "CENTRAL_MOMENT_10", "IMOM_CM_01", "IMOM_CM_10"): assert abs(features[code][0]) < 1e-13, (code, features[code][0]) -# The 48 x 40 rectangle has 172 border pixels; 85 ROI pixels lie on the traced (shifted) contour -on_traced = (bx + 1 < W) & (by + 1 < H) -assert len(bx) == 172 and on_traced.sum() == 85 +# The 48 x 40 rectangle's contour is its 172 border pixels: with intensity 1 they sum to 172 +edge = Nyxus(["EDGE_INTEGRATED_INTENSITY"]).featurize(rect, rect)["EDGE_INTEGRATED_INTENSITY"][0] +assert len(bx) == 172 and edge == 172 # Out of core (ram_limit=0, file input): the weighted moments of a 245-pixel ellipse depend on where # its bounding box starts; the 118 unweighted moments do not @@ -129,23 +128,34 @@ def ellipse(n, cx, cy): weighted = [c for c in codes if c.startswith(("WEIGHTED_", "WT_NORM_", "IMOM_W"))] assert len(weighted) == 62 # pins known defect https://github.com/PolusAI/nyxus/issues/509 -for n, cx, cy, corner, ooc in ((30, 12, 10, (2, 2), 7.484), (26, 13, 13, (3, 5), 58.50), - (40, 20, 20, (10, 12), 338.2)): +for n, cx, cy, corner, ooc in ((30, 12, 10, (2, 2), 35.62), (26, 13, 13, (3, 5), 78.42), + (40, 20, 20, (10, 12), 425.0)): e = ellipse(n, cx, cy) ys, xs = np.nonzero(e) assert e.sum() == 245 and (xs.min(), ys.min()) == corner mem, out = from_files(e), from_files(e, ram_limit=0) - assert same(mem["WEIGHTED_SPAT_MOMENT_00"][0], 49.81) + assert same(mem["WEIGHTED_SPAT_MOMENT_00"][0], -93.36) assert same(out["WEIGHTED_SPAT_MOMENT_00"][0], ooc), (corner, out["WEIGHTED_SPAT_MOMENT_00"][0]) for code in codes: if code not in weighted: assert math.isclose(out[code][0], mem[code][0], rel_tol=1e-9, abs_tol=1e-9), code -# At the image origin: the same ellipse still differs, the rectangle does not +# At the image origin: the ellipse keeps WEIGHTED_SPAT_MOMENT_00 but not its weighted first moment, +# the rectangle gets the in-memory values e = ellipse(30, 12, 10)[2:, 2:] +mem, out = from_files(e), from_files(e, ram_limit=0) +assert same(out["WEIGHTED_SPAT_MOMENT_00"][0], -93.36) +assert same(mem["WEIGHTED_SPAT_MOMENT_10"][0], -1158.2) # pins known defect https://github.com/PolusAI/nyxus/issues/509 -assert same(from_files(e, ram_limit=0)["WEIGHTED_SPAT_MOMENT_00"][0], 43.75) +assert same(out["WEIGHTED_SPAT_MOMENT_10"][0], -708.9) corner_rect = np.zeros((50, 60), dtype=np.uint16) corner_rect[:40, :48] = 1 mem, out = from_files(corner_rect), from_files(corner_rect, ram_limit=0) for code in codes: assert math.isclose(out[code][0], mem[code][0], rel_tol=1e-9, abs_tol=1e-9), code +# A ROI with a hole at the origin differs too: the out-of-core contour leaves the hole out +# pins known defect https://github.com/PolusAI/nyxus/issues/509 +holed = np.zeros((20, 20), dtype=np.uint16) +holed[:9, :9] = 1 +holed[3:6, 3:6] = 0 +assert same(from_files(holed)["WEIGHTED_SPAT_MOMENT_00"][0], -274.2) +assert same(from_files(holed, ram_limit=0)["WEIGHTED_SPAT_MOMENT_00"][0], -194.7) diff --git a/docs/examples/morphology_worked_example.py b/docs/examples/morphology_worked_example.py index 5f4c72a39..be2983bd0 100644 --- a/docs/examples/morphology_worked_example.py +++ b/docs/examples/morphology_worked_example.py @@ -68,12 +68,11 @@ def run(image, labels, **kwargs): # The worked example as the page walks through it check(one, {"POLYGONALITY_AVE": -1, "HEXAGONALITY_AVE": -1, "EDGE_INTEGRATED_INTENSITY": 228}, "ROI 1") assert round(one["EDGE_INTEGRATED_INTENSITY"][0] / one["EDGE_MEAN_INTENSITY"][0]) == 44 # contour pixels -# pins known defect https://github.com/PolusAI/nyxus/issues/497 check(two, {"AREA_PIXELS_COUNT": 15, "PERIMETER": 12, "CIRCULARITY": 1.144, "ASPECT_RATIO": 0.6, "ORIENTATION": 90, "CONVEX_HULL_AREA": 15, "PERCENT_TOUCHING": 41.67, "CLOSEST_NEIGHBOR1_ANG": 174.4, "DIAMETER_MIN_ENCLOSING_CIRCLE": 4.4723, - "DIAMETER_CIRCUMSCRIBING_CIRCLE": 10, "DIAMETER_INSCRIBING_CIRCLE": 2, - "ROI_RADIUS_MEAN": 0.6943, "ROI_RADIUS_MAX": 1.414, "ROI_RADIUS_MEDIAN": 1, + "DIAMETER_CIRCUMSCRIBING_CIRCLE": 4.472, "DIAMETER_INSCRIBING_CIRCLE": 2, + "ROI_RADIUS_MEAN": 0.2, "ROI_RADIUS_MAX": 1, "ROI_RADIUS_MEDIAN": 0, "MAXCHORDS_MIN": 2, "MAXCHORDS_MEAN": 3.722}, "ROI 2") # Settings that change nothing in memory: IBSI mode, the grey-level depth, the anisotropy keywords @@ -107,23 +106,35 @@ def run(image, labels, **kwargs): check(run(hole, mask), {"AREA_PIXELS_COUNT": 116, "MAXCHORDS_MEAN": 8.8, "ALLCHORDS_MEAN": 5.197}, "zero pixel") -# PERIMETER of a ROI with a hole, and of a label in two pieces +# PERIMETER of a ROI with a hole, and of a label in two pieces: the sum of their closed walks. The +# circles of the holed square are measured to its outer border only. sq = np.zeros((13, 13), dtype=np.uint32) sq[2:11, 2:11] = 1 sq[5:8, 5:8] = 0 -check(run(sq * 4, sq), {"PERIMETER": 50.01, "EULER_NUMBER": 0}, "holed square") +check(run(sq * 4, sq), {"PERIMETER": 45.66, "EULER_NUMBER": 0, "DIAMETER_CIRCUMSCRIBING_CIRCLE": 11.31, + "DIAMETER_INSCRIBING_CIRCLE": 8}, "holed square") pieces = np.zeros((10, 16), dtype=np.uint32) pieces[2:7, 2:6] = 1 pieces[2:7, 10:14] = 1 -check(run(pieces * 4, pieces), {"PERIMETER": 42.12, "EULER_NUMBER": 2}, "two pieces") +check(run(pieces * 4, pieces), {"PERIMETER": 28, "EULER_NUMBER": 2}, "two pieces") one_piece = np.zeros((10, 10), dtype=np.uint32) one_piece[2:7, 2:6] = 1 check(run(one_piece * 4, one_piece), {"PERIMETER": 14}, "one piece") -# A 1 x 13 bar: hull of two vertices, Nassenstein 0, Martin only rounding residue below 1e-6 +# A 1 x 13 bar: walked on both sides, so PERIMETER is 24; hull of two vertices, Nassenstein 0, +# Martin only rounding residue below 1e-6 line = np.zeros((7, 20), dtype=np.uint32) line[3, 3:16] = 1 flat = run(line * 100, line) +check(flat, {"PERIMETER": 24}, "1 x 13 bar") + +# A single pixel is its own contour; two pixels are walked there and back +dot = run(np.pad(np.full((1, 1), 7, np.uint32), 2), np.pad(np.ones((1, 1), np.uint32), 2)) +check(dot, {"PERIMETER": 0, "EDGE_MEAN_INTENSITY": 7, "EDGE_INTEGRATED_INTENSITY": 7, "CIRCULARITY": 0, + "DIAMETER_MIN_ENCLOSING_CIRCLE": 2e-4, "DIAMETER_CIRCUMSCRIBING_CIRCLE": 0, + "DIAMETER_INSCRIBING_CIRCLE": 0, "ROI_RADIUS_MAX": 0, "CONVEX_HULL_AREA": 1}, "one pixel") +pair = run(np.pad(np.full((1, 2), 7, np.uint32), 2), np.pad(np.ones((1, 2), np.uint32), 2)) +check(pair, {"PERIMETER": 2, "CIRCULARITY": 2.507}, "two pixels") for stat in ("MIN", "MAX", "MEAN", "MEDIAN", "STDDEV", "MODE"): assert flat[f"STAT_NASSENSTEIN_DIAM_{stat}"][0] == 0, stat assert 0 <= flat[f"STAT_MARTIN_DIAM_{stat}"][0] < 1e-6, stat @@ -174,7 +185,7 @@ def alone(codes, image=img, labels=mask): circle_codes = ["DIAMETER_MIN_ENCLOSING_CIRCLE", "DIAMETER_CIRCUMSCRIBING_CIRCLE", "DIAMETER_INSCRIBING_CIRCLE"] assert (alone(circle_codes)[circle_codes].to_numpy() == 0).all() check(alone(circle_codes + ["PERIMETER"]), - {"DIAMETER_MIN_ENCLOSING_CIRCLE": 14.17, "DIAMETER_CIRCUMSCRIBING_CIRCLE": 35.61}, "circles with PERIMETER") + {"DIAMETER_MIN_ENCLOSING_CIRCLE": 14.17, "DIAMETER_CIRCUMSCRIBING_CIRCLE": 30}, "circles with PERIMETER") hex_codes = ["HEXAGONALITY_AVE", "HEXAGONALITY_STDDEV"] assert (alone(hex_codes, dimg, disk)[hex_codes].to_numpy() == 0).all() diff --git a/docs/examples/radial_worked_example.py b/docs/examples/radial_worked_example.py index 17edb0436..8775605d4 100644 --- a/docs/examples/radial_worked_example.py +++ b/docs/examples/radial_worked_example.py @@ -47,25 +47,32 @@ def vec(table, code): f"{code}_0 = {got}, page says {facts['example']}" # The 24 values of the worked-example table -quoted = {"FRAC_AT_D": [0.02128, 0.08511, 0.08511, 0.2128, 0.06383, 0.1702, 0.1277, 0.2340], - "MEAN_FRAC": [7.0, 6.5, 6.25, 5.6, 4.667, 4.375, 3.833, 3.455], - "RADIAL_CV": [2.646, 1.052, 1.079, 0.7284, 1.532, 0.9558, 1.223, 1.571]} +quoted = {"FRAC_AT_D": [0.02128, 0.08511, 0.08511, 0.2553, 0.08511, 0.2340, 0.1702, 0.06383], + "MEAN_FRAC": [9.0, 8.5, 7.5, 5.75, 4.0, 3.636, 2.5, 2.0], + "RADIAL_CV": [2.646, 1.003, 1.004, 0.3709, 1.031, 0.5292, 0.7483, 1.291]} for code, values in quoted.items(): for k, (got, want) in enumerate(zip(vec(features, code), values)): assert math.isclose(got, want, rel_tol=5e-4), f"{code}_{k} = {got}, page says {want}" -def formulas(img, mask, offset=1): - """The page's formulas: contour = ROI pixels with a 4-neighbour outside the ROI, moved by - (offset, offset); full scans for the centre and the radius. Returns the three vectors, the - centre (x, y), R^2 and the pixel count of each ring.""" +def contour_pixels(mask): + """The contour as the page describes it: the ROI pixels with a 4-neighbour outside the ROI, + as (x, y) arrays.""" m = mask > 0 - xs, ys = np.nonzero(m.T) # pixels column by column, as Nyxus.featurize - inten = img[ys, xs].astype(float) p = np.pad(m, 1) edge = m & ~(p[:-2, 1:-1] & p[2:, 1:-1] & p[1:-1, :-2] & p[1:-1, 2:]) ky, kx = np.nonzero(edge) - d2 = (xs[:, None] - kx[None, :] - offset) ** 2 + (ys[:, None] - ky[None, :] - offset) ** 2 + return kx, ky + + +def formulas(img, mask): + """The page's formulas with full scans for the centre and the radius. Returns the three + vectors, the centre (x, y), R^2 and the pixel count of each ring.""" + m = mask > 0 + xs, ys = np.nonzero(m.T) # pixels column by column, as Nyxus.featurize + inten = img[ys, xs].astype(float) + kx, ky = contour_pixels(mask) + d2 = (xs[:, None] - kx[None, :]) ** 2 + (ys[:, None] - ky[None, :]) ** 2 o = int(np.argmin(d2.max(1) - d2.min(1))) r2 = d2[o].max() d = np.hypot(xs - xs[o], ys - ys[o]) @@ -82,84 +89,64 @@ def formulas(img, mask, offset=1): return out, (int(xs[o]), int(ys[o])), r2, n.tolist() -# The formulas reproduce Nyxus on the worked example +# The formulas reproduce Nyxus on the worked example: the searches find the full-scan centre and R mine, centre, r2, n = formulas(img, mask) for code in CODES: assert np.allclose(mine[code], vec(features, code), rtol=1e-12, atol=1e-12), code -assert centre == (5, 4) and r2 == 17 and n == [1, 4, 4, 10, 3, 8, 6, 11] +assert centre == (4, 3) and r2 == 17 and n == [1, 4, 4, 12, 4, 11, 8, 3] +assert len(contour_pixels(mask)[0]) == 20 and img[3, 4] == 9 d = np.hypot(*(np.nonzero(mask.T) - np.array(centre)[:, None])) -assert (d > math.sqrt(17) + 1e-12).sum() == 8 and np.isclose(d, math.sqrt(17)).sum() == 3 +assert (d > math.sqrt(17) + 1e-12).sum() == 0 and np.isclose(d, math.sqrt(17)).sum() == 3 assert math.isclose(vec(features, "FRAC_AT_D").sum(), 1.0, rel_tol=1e-9) assert math.isclose((vec(features, "FRAC_AT_D") * vec(features, "MEAN_FRAC")).sum() * 47, 224, rel_tol=1e-9) -assert math.isclose(vec(features, "RADIAL_CV")[3], math.sqrt(26) / 7, rel_tol=1e-9) - -# pins known defect https://github.com/PolusAI/nyxus/issues/497 -# The contour points (boundary pixels moved by +1, +1), the centre's spread and the runner-up's -inner = np.pad(mask > 0, 1) -edge = (mask > 0) & ~(inner[:-2, 1:-1] & inner[2:, 1:-1] & inner[1:-1, :-2] & inner[1:-1, 2:]) -ky, kx = np.nonzero(edge) -cy, cx = ky + 1, kx + 1 -assert len(cx) == 20 -assert sum(not (y < 7 and x < 9 and mask[y, x]) for y, x in zip(cy, cx)) == 9 -spread = {p: (cx - p[0]) ** 2 + (cy - p[1]) ** 2 for p in ((5, 4), (5, 3))} -assert spread[(5, 4)].min() == 5 and spread[(5, 4)].max() == 17 -assert np.ptp(spread[(5, 3)]) == 16 - -# The contour offset: on the ROI's own boundary the centre and the rings would be different -on_roi, centre0, r20, n0 = formulas(img, mask, offset=0) -assert centre0 == (4, 3) and r20 == 17 and n0 == [1, 4, 4, 12, 4, 11, 8, 3] -assert math.isclose(on_roi["MEAN_FRAC"][0], 9.0, rel_tol=1e-6) -assert math.isclose(on_roi["RADIAL_CV"][3], 0.3709, rel_tol=5e-4) - -# A 3 x 3 square with intensities 1..9 row by row: the centre is the bottom-right corner +assert math.isclose(vec(features, "RADIAL_CV")[5], math.sqrt(7) / 5, rel_tol=1e-9) + +# The centre's spread of squared distances to the contour, and the runner-up's +kx, ky = contour_pixels(mask) +spread = {p: (kx - p[0]) ** 2 + (ky - p[1]) ** 2 for p in ((4, 3), (4, 2))} +assert spread[(4, 3)].min() == 5 and spread[(4, 3)].max() == 17 +assert np.ptp(spread[(4, 2)]) == 16 + +# A 3 x 3 square with intensities 1..9 row by row: the centre is the middle pixel sq_img = np.pad(np.arange(1, 10, dtype=np.uint32).reshape(3, 3), 2) sq_mask = np.pad(np.ones((3, 3), dtype=np.uint32), 2) sq = nyx.featurize(sq_img, sq_mask) -assert math.isclose(sq["MEAN_FRAC_0"][0], 9.0, rel_tol=1e-6) -assert np.allclose(vec(sq, "FRAC_AT_D") * 9, [1, 0, 0, 0, 2, 0, 0, 6], atol=1e-6) -sq0 = formulas(sq_img, sq_mask, offset=0)[0] -assert math.isclose(sq0["MEAN_FRAC"][0], 5.0, rel_tol=1e-6) -assert np.allclose(sq0["FRAC_AT_D"] * 9, [1, 0, 0, 0, 4, 0, 0, 4], atol=1e-6) - -# A ROI with a hole: the coded searches find a smaller radius than the farthest contour pixel -holed = np.pad(np.array([[0, 1, 1, 1, 1, 1, 1, 0], - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 0, 1, 1, 1, 1, 1], - [1, 1, 0, 1, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1, 0]], dtype=np.uint32), 2) -h_img = (holed * (1 + np.arange(holed.size).reshape(holed.shape) % 7)).astype(np.uint32) -h = nyx.featurize(h_img, holed) -h_full, h_centre, h_r2, h_n = formulas(h_img, holed) -assert h_centre == (7, 6) and h_r2 == 20 and h_n == [1, 4, 4, 12, 7, 4, 7, 11] -assert np.allclose(vec(h, "FRAC_AT_D") * 50, [1, 4, 4, 12, 4, 7, 6, 12], atol=1e-6) -assert math.isclose(h["FRAC_AT_D_4"][0], 0.08, rel_tol=1e-6) -assert math.isclose(h_full["FRAC_AT_D"][4], 0.14, rel_tol=1e-6) -# Nyxus's rings are those of centre (7, 6) with R^2 = 18: 11 pixels lie beyond R, against 8 for 20 -hy, hx = np.nonzero(holed) -h_d = np.hypot(hx - 7, hy - 6) -assert np.bincount(np.minimum((h_d / math.sqrt(18) * 7).astype(int), 7), minlength=8).tolist() == \ - [1, 4, 4, 12, 4, 7, 6, 12] -assert (h_d > math.sqrt(18) + 1e-12).sum() == 11 and (h_d > math.sqrt(20) + 1e-12).sum() == 8 +assert math.isclose(sq["MEAN_FRAC_0"][0], 5.0, rel_tol=1e-6) +assert np.allclose(vec(sq, "FRAC_AT_D") * 9, [1, 0, 0, 0, 4, 0, 0, 4], atol=1e-6) + +# A 5 x 4 rectangle: the searches stop short of the farthest contour pixel, R^2 = 5 instead of 8 +rect = np.pad(np.ones((4, 5), dtype=np.uint32), 2) +r_img = (rect * (1 + np.arange(rect.size).reshape(rect.shape) % 7)).astype(np.uint32) +r = nyx.featurize(r_img, rect) +r_full, r_centre, r_r2, r_n = formulas(r_img, rect) +assert r_centre == (4, 3) and r_r2 == 8 and r_n == [1, 0, 4, 4, 3, 6, 0, 2] +assert np.allclose(vec(r, "FRAC_AT_D") * 20, [1, 0, 0, 4, 4, 0, 3, 8], atol=1e-6) +# Nyxus's rings are those of centre (4, 3) with R^2 = 5: 2 pixels lie beyond R, none for 8 +ry, rx = np.nonzero(rect) +r_d = np.hypot(rx - 4, ry - 3) +assert np.bincount(np.minimum((r_d / math.sqrt(5) * 7).astype(int), 7), minlength=8).tolist() == \ + [1, 0, 0, 4, 4, 0, 3, 8] +assert (r_d > math.sqrt(5) + 1e-12).sum() == 2 and (r_d > math.sqrt(8) + 1e-12).sum() == 0 # A pixel of intensity 0 inside the mask counts as a pixel: the centre set to 0 zero = img.copy() -zero[4, 5] = 0 +zero[3, 4] = 0 z = nyx.featurize(zero, mask) assert z["MEAN_FRAC_0"][0] == 0 and z["RADIAL_CV_0"][0] == 0 assert np.allclose(vec(z, "FRAC_AT_D"), vec(features, "FRAC_AT_D")) # A ROI of one intensity is not skipped flat = nyx.featurize(np.pad(np.full((5, 5), 4, dtype=np.uint32), 2), np.pad(np.ones((5, 5), np.uint32), 2)) -assert np.allclose(vec(flat, "FRAC_AT_D") * 25, [1, 0, 4, 4, 2, 4, 0, 10], atol=1e-6) +assert np.allclose(vec(flat, "FRAC_AT_D") * 25, [1, 0, 4, 4, 4, 8, 0, 4], atol=1e-6) assert np.allclose(vec(flat, "MEAN_FRAC"), [4, 0, 4, 4, 4, 4, 0, 4], rtol=1e-6) -assert math.isclose(flat["RADIAL_CV_7"][0], 1.183, rel_tol=5e-4) +assert math.isclose(flat["RADIAL_CV_7"][0], 1.0, rel_tol=5e-4) -# ROIs of 1 or 2 pixels have no contour and report 0 in all 24 columns -for m in (np.array([[1]]), np.array([[1, 1]])): +# A ROI of 1 pixel is its own centre, at radius 0; a ROI of 2 pixels puts one in ring 0, one in ring 7 +for m, frac in ((np.array([[1]]), [1, 0, 0, 0, 0, 0, 0, 0]), (np.array([[1, 1]]), [0.5, 0, 0, 0, 0, 0, 0, 0.5])): tiny = nyx.featurize(np.pad(5 * m, 2).astype(np.uint32), np.pad(m, 2).astype(np.uint32)) - assert all((vec(tiny, c) == 0).all() for c in CODES) + assert np.allclose(vec(tiny, "FRAC_AT_D"), frac, atol=1e-6) + assert math.isclose(tiny["MEAN_FRAC_0"][0], 5.0, rel_tol=1e-6) + assert math.isclose(tiny["RADIAL_CV_0"][0], math.sqrt(7), rel_tol=1e-6) # File input. A tie between two centre candidates: an array is read column by column, a file tile by # tile and row by row, so the first candidate differs @@ -173,9 +160,9 @@ def from_files(intensity, roi, **kw): [os.path.abspath("rd_int.tif")], [os.path.abspath("rd_seg.tif")], False) -tie = np.pad(np.array([[0, 0, 1, 0], [0, 1, 1, 1], [1, 1, 1, 1], [0, 1, 1, 0]], dtype=np.uint32), 2) +tie = np.pad(np.array([[1, 1, 1, 0], [0, 1, 1, 1], [1, 1, 1, 0], [0, 1, 1, 0]], dtype=np.uint32), 2) tie_img = (tie * (1 + np.arange(tie.size).reshape(tie.shape) % 9)).astype(np.uint32) -assert tie.sum() == 10 +assert tie.sum() == 11 assert math.isclose(nyx.featurize(tie_img, tie)["MEAN_FRAC_0"][0], 9.0, rel_tol=1e-6) assert math.isclose(from_files(tie_img, tie)["MEAN_FRAC_0"][0], 2.0, rel_tol=1e-6) @@ -192,13 +179,14 @@ def from_files(intensity, roi, **kw): assert np.allclose(vec(from_files(moved, moved_mask), "FRAC_AT_D"), vec(features, "FRAC_AT_D")) out = from_files(moved, moved_mask, ram_limit=0) # pins known defect https://github.com/PolusAI/nyxus/issues/509 -assert math.isclose(out["FRAC_AT_D_7"][0], 0.3404, rel_tol=5e-4), out["FRAC_AT_D_7"][0] -assert math.isclose(out["MEAN_FRAC_0"][0], 6.0, rel_tol=1e-6), out["MEAN_FRAC_0"][0] - -# Whole-slide mode: the centre lies on the top edge, (19, 1) with R = 19.03 on a 20 x 20 image and -# (50, 0) with R = 50 on a 64 x 48 image. The ring counts Nyxus reports are those of that centre. -# pins known defect https://github.com/PolusAI/nyxus/issues/516 -for (h, w), (ox, oy), ring0 in (((20, 20), (19, 1), 11), ((48, 64), (50, 0), 88)): +assert math.isclose(out["FRAC_AT_D_7"][0], 0.5532, rel_tol=5e-4), out["FRAC_AT_D_7"][0] +assert math.isclose(out["MEAN_FRAC_0"][0], 3.0, rel_tol=1e-6), out["MEAN_FRAC_0"][0] + +# Whole-slide mode: the contour is the four corners of the bounding box, one pixel past the last +# column and row; the centre is the pixel equally far from all four, (10, 10) with R = 14.14 on a +# 20 x 20 image and (32, 24) with R = 40 on a 64 x 48 image. The ring counts Nyxus reports are +# those of that centre. +for (h, w), (ox, oy), ring0 in (((20, 20), (10, 10), 13), ((48, 64), (32, 24), 101)): tifffile.imwrite("rd_whole.tif", np.full((h, w), 7, np.uint32)) t = Nyxus(["FRAC_AT_D"]).featurize_files([os.path.abspath("rd_whole.tif")], [os.path.abspath("rd_whole.tif")], True) @@ -207,4 +195,4 @@ def from_files(intensity, roi, **kw): rings = np.minimum((np.hypot(wx - ox, wy - oy) / math.hypot(ox, oy) * 7).astype(int), 7) assert np.allclose(counts, np.bincount(rings.ravel(), minlength=8), atol=1e-6), (h, w, counts) assert round(counts[0]) == ring0, (h, w, counts[0]) -assert math.isclose(math.hypot(19, 1), 19.03, rel_tol=5e-4) +assert math.isclose(math.hypot(10, 10), 14.14, rel_tol=5e-4) diff --git a/docs/feature_descriptions.toml b/docs/feature_descriptions.toml index 3357377d8..f825b8206 100644 --- a/docs/feature_descriptions.toml +++ b/docs/feature_descriptions.toml @@ -14,8 +14,8 @@ gldm = "A pixel's dependence is one plus the number of its 8 neighbours of nonze gldzm = "A zone is an 8-connected group of pixels sharing one grey level. Its distance is the smallest, over its pixels, of the steps along a row or column to the nearest pixel of stored intensity 0 plus one, or to the bounding-box edge, where a pixel on the bounding-box edge is at distance 1. With a positive --coarseGrayDepth (the default) the pixels outside the ROI are binned to grey level 1 and form zones like ROI pixels, so only the bounding-box edge counts; under --ibsi pixels of stored intensity 0 are skipped. The matrix counts zones by grey level and distance; each code is one value per ROI. Grey levels come from --coarseGrayDepth binning, or are the raw intensities under --ibsi." ngldm = "Every ROI pixel is a neighbourhood centre whose dependence count is one plus the number of its 8 neighbours inside the ROI with the same grey level; the matrix counts centres by grey level and dependence count. Each code is one value per ROI. Grey levels come from scaling the range 0 to the ROI maximum by --coarseGrayDepth and truncating (levels 0 to --coarseGrayDepth), or are the raw intensities under --ibsi." ngtdm = "For each pixel of nonzero binned grey level that has at least one such neighbour, the matrix accumulates the absolute difference between its grey level and the average grey level of its 8 neighbours (neighbours whose binned grey level is 0 are left out), grouped by grey level, together with the probability of each grey level. Each code is one value per ROI. Grey levels come from --coarseGrayDepth binning, or are the raw intensities under --ibsi. With a positive --coarseGrayDepth (the default), pixels outside the ROI are binned to grey level 1 and take part both as centres and as neighbours." -shape_moments = "Geometric moments of the ROI's shape: every ROI pixel counts as 1. The two digits of a code are the powers of x and y. The weighted moments weight each pixel by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour (taken one pixel down and to the right of the ROI), found by a sampled search that can overestimate it." -intensity_moments = "The same moments with each pixel weighted by its intensity, in grey levels. The two digits of a code are the powers of x and y. The weighted moments multiply each intensity by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour (taken one pixel down and to the right of the ROI), found by a sampled search that can overestimate it." +shape_moments = "Geometric moments of the ROI's shape: every ROI pixel counts as 1. The two digits of a code are the powers of x and y. The weighted moments weight each pixel by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour, found by a sampled search that can overestimate it." +intensity_moments = "The same moments with each pixel weighted by its intensity, in grey levels. The two digits of a code are the powers of x and y. The weighted moments multiply each intensity by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour, found by a sampled search that can overestimate it." gabor = "GABOR has one output column per frequency and orientation pair, GABOR_0, GABOR_1 and so on." intensity_3d = "The 3D counterparts of the 2D intensity features, computed over the ROI's voxels and reported in the volume's own units." glcm_3d = "The co-occurrence matrix counts pairs of voxels one voxel apart within the bounding box, built separately for the 13 unique directions of the 26-voxel neighbourhood; with a positive grey depth (the default) the background around the ROI is binned to level 1 and its pairs are counted, while with a zero or negative grey depth and under --ibsi pairs touching level 0 are skipped. Each _AVE code is the mean over the 13 directions; the plain code is a single value taken from the first direction only (one voxel step along x, y and z together), not an average. Grey levels come from --coarseGrayDepth binning (overridable with the 3glcm/greydepth metaparameter) or are the raw intensities under --ibsi; each ordered pair is counted once with a positive grey depth, and the matrix is made symmetric with a negative or zero grey depth and under --ibsi." @@ -132,7 +132,7 @@ EXTENT = "Fraction of the ROI's axis-aligned bounding box covered by the ROI: th ASPECT_RATIO = "Width of the ROI's axis-aligned bounding box divided by its height." [contour] -PERIMETER = "Length of the ROI's traced contour in pixels: the sum of the Euclidean distances between consecutive contour pixels, closing back to the first one. A hole or a second fragment appends its loop to the same list, and the jumps between loops are counted too. On the out-of-core path it is the number of contour pixels instead." +PERIMETER = "Length of the ROI's traced contour in pixels: the sum of the Euclidean distances between consecutive pixels of the closed walk around each border of the ROI (the outside of each separate part and each hole), each walk closing back to its first pixel. On the out-of-core path it is the number of contour pixels instead." DIAMETER_EQUAL_PERIMETER = "Diameter of a circle with the same perimeter as the ROI: PERIMETER divided by pi, in pixels." EDGE_MEAN_INTENSITY = "Mean intensity of ROI's contour pixels" EDGE_STDDEV_INTENSITY = "Unbiased (n-1) standard deviation of the intensities of the ROI's contour pixels." @@ -158,13 +158,13 @@ ROUNDNESS = "4 times the ROI's area divided by pi times the square of MAJOR_AXIS [circles] DIAMETER_MIN_ENCLOSING_CIRCLE = "Diameter in pixels of the circle that Welzl's algorithm fits to the ROI's contour pixels, with chord midpoints truncated to whole pixels (known defect, https://github.com/PolusAI/nyxus/issues/498); it approximates the smallest enclosing circle and can come out about a pixel larger or smaller than it." -DIAMETER_CIRCUMSCRIBING_CIRCLE = "Twice the largest distance from the ROI's centroid to its contour pixels, in pixels. The contour is taken one pixel down and to the right of the ROI and the centroid one pixel up and to the left (known defect, https://github.com/PolusAI/nyxus/issues/497)." -DIAMETER_INSCRIBING_CIRCLE = "Twice the smallest distance from the ROI's centroid to its contour pixels, in pixels. The contour is taken one pixel down and to the right of the ROI and the centroid one pixel up and to the left (known defect, https://github.com/PolusAI/nyxus/issues/497)." +DIAMETER_CIRCUMSCRIBING_CIRCLE = "Twice the largest distance from the ROI's centroid to the pixels of its outer borders, in pixels; the borders of holes are left out." +DIAMETER_INSCRIBING_CIRCLE = "Twice the smallest distance from the ROI's centroid to the pixels of its outer borders, in pixels; the borders of holes are left out." [roi_radius] -ROI_RADIUS_MEAN = "Mean over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497)" -ROI_RADIUS_MAX = "Maximum over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497)" -ROI_RADIUS_MEDIAN = "Median over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497)" +ROI_RADIUS_MEAN = "Mean over ROI pixels of the distance from a pixel to the nearest contour pixel, in pixels; contour pixels are at distance 0." +ROI_RADIUS_MAX = "Maximum over ROI pixels of the distance from a pixel to the nearest contour pixel, in pixels: the radius of the largest disk inside the ROI, measured from a pixel centre." +ROI_RADIUS_MEDIAN = "Median over ROI pixels of the distance from a pixel to the nearest contour pixel, in pixels." [extrema] EXTREMA_P1_X = "X coordinate of extremum point 1: the leftmost ROI pixel in the ROI's top row." diff --git a/docs/feature_extras.toml b/docs/feature_extras.toml index fb5bc4e2e..7269d7c6b 100644 --- a/docs/feature_extras.toml +++ b/docs/feature_extras.toml @@ -237,35 +237,35 @@ SPAT_MOMENT_21 = { name = "Raw moment (2, 1)", ibsi = "", example = 1604.0 } SPAT_MOMENT_22 = { name = "Raw moment (2, 2)", ibsi = "", example = 5786.0 } SPAT_MOMENT_23 = { name = "Raw moment (2, 3)", ibsi = "", example = 23950.0 } SPAT_MOMENT_30 = { name = "Raw moment (3, 0)", ibsi = "", example = 2873.0 } -WEIGHTED_CENTRAL_MOMENT_02 = { name = "Weighted central moment (0, 2)", ibsi = "", example = -171.6 } -WEIGHTED_CENTRAL_MOMENT_03 = { name = "Weighted central moment (0, 3)", ibsi = "", example = -251.6 } -WEIGHTED_CENTRAL_MOMENT_11 = { name = "Weighted central moment (1, 1)", ibsi = "", example = 153.2 } -WEIGHTED_CENTRAL_MOMENT_12 = { name = "Weighted central moment (1, 2)", ibsi = "", example = 274.1 } -WEIGHTED_CENTRAL_MOMENT_20 = { name = "Weighted central moment (2, 0)", ibsi = "", example = -183.1 } -WEIGHTED_CENTRAL_MOMENT_21 = { name = "Weighted central moment (2, 1)", ibsi = "", example = -248.6 } -WEIGHTED_CENTRAL_MOMENT_30 = { name = "Weighted central moment (3, 0)", ibsi = "", example = -57.93 } -WEIGHTED_HU_M1 = { name = "Weighted Hu invariant 1", ibsi = "", example = -0.194 } -WEIGHTED_HU_M2 = { name = "Weighted Hu invariant 2", ibsi = "", example = 0.0281 } +WEIGHTED_CENTRAL_MOMENT_02 = { name = "Weighted central moment (0, 2)", ibsi = "", example = -496.9 } +WEIGHTED_CENTRAL_MOMENT_03 = { name = "Weighted central moment (0, 3)", ibsi = "", example = 141.3 } +WEIGHTED_CENTRAL_MOMENT_11 = { name = "Weighted central moment (1, 1)", ibsi = "", example = 110.8 } +WEIGHTED_CENTRAL_MOMENT_12 = { name = "Weighted central moment (1, 2)", ibsi = "", example = 190.0 } +WEIGHTED_CENTRAL_MOMENT_20 = { name = "Weighted central moment (2, 0)", ibsi = "", example = -504.2 } +WEIGHTED_CENTRAL_MOMENT_21 = { name = "Weighted central moment (2, 1)", ibsi = "", example = -74.72 } +WEIGHTED_CENTRAL_MOMENT_30 = { name = "Weighted central moment (3, 0)", ibsi = "", example = 244.2 } +WEIGHTED_HU_M1 = { name = "Weighted Hu invariant 1", ibsi = "", example = -0.11 } +WEIGHTED_HU_M2 = { name = "Weighted Hu invariant 2", ibsi = "", example = 0.0005931 } WEIGHTED_HU_M3 = { name = "Weighted Hu invariant 3", ibsi = "", example = 0.0 } WEIGHTED_HU_M4 = { name = "Weighted Hu invariant 4", ibsi = "", example = 0.0 } WEIGHTED_HU_M5 = { name = "Weighted Hu invariant 5", ibsi = "", example = 0.0 } WEIGHTED_HU_M6 = { name = "Weighted Hu invariant 6", ibsi = "", example = 0.0 } WEIGHTED_HU_M7 = { name = "Weighted Hu invariant 7", ibsi = "", example = 0.0 } -WEIGHTED_SPAT_MOMENT_00 = { name = "Weighted raw moment (0, 0)", ibsi = "", example = -42.77 } -WEIGHTED_SPAT_MOMENT_01 = { name = "Weighted raw moment (0, 1)", ibsi = "", example = -113.6 } -WEIGHTED_SPAT_MOMENT_02 = { name = "Weighted raw moment (0, 2)", ibsi = "", example = -473.2 } -WEIGHTED_SPAT_MOMENT_03 = { name = "Weighted raw moment (0, 3)", ibsi = "", example = -2419.0 } -WEIGHTED_SPAT_MOMENT_10 = { name = "Weighted raw moment (1, 0)", ibsi = "", example = -170.2 } -WEIGHTED_SPAT_MOMENT_11 = { name = "Weighted raw moment (1, 1)", ibsi = "", example = -298.8 } -WEIGHTED_SPAT_MOMENT_12 = { name = "Weighted raw moment (1, 2)", ibsi = "", example = -795.9 } -WEIGHTED_SPAT_MOMENT_20 = { name = "Weighted raw moment (2, 0)", ibsi = "", example = -860.8 } -WEIGHTED_SPAT_MOMENT_21 = { name = "Weighted raw moment (2, 1)", ibsi = "", example = -1315.0 } -WEIGHTED_SPAT_MOMENT_30 = { name = "Weighted raw moment (3, 0)", ibsi = "", example = -4942.0 } -WT_NORM_CTR_MOM_02 = { name = "Weighted normalized central moment (0, 2)", ibsi = "", example = -0.09385 } +WEIGHTED_SPAT_MOMENT_00 = { name = "Weighted raw moment (0, 0)", ibsi = "", example = -95.4 } +WEIGHTED_SPAT_MOMENT_01 = { name = "Weighted raw moment (0, 1)", ibsi = "", example = -296.0 } +WEIGHTED_SPAT_MOMENT_02 = { name = "Weighted raw moment (0, 2)", ibsi = "", example = -1415.0 } +WEIGHTED_SPAT_MOMENT_03 = { name = "Weighted raw moment (0, 3)", ibsi = "", example = -7333.0 } +WEIGHTED_SPAT_MOMENT_10 = { name = "Weighted raw moment (1, 0)", ibsi = "", example = -360.2 } +WEIGHTED_SPAT_MOMENT_11 = { name = "Weighted raw moment (1, 1)", ibsi = "", example = -1007.0 } +WEIGHTED_SPAT_MOMENT_12 = { name = "Weighted raw moment (1, 2)", ibsi = "", example = -4467.0 } +WEIGHTED_SPAT_MOMENT_20 = { name = "Weighted raw moment (2, 0)", ibsi = "", example = -1864.0 } +WEIGHTED_SPAT_MOMENT_21 = { name = "Weighted raw moment (2, 1)", ibsi = "", example = -5023.0 } +WEIGHTED_SPAT_MOMENT_30 = { name = "Weighted raw moment (3, 0)", ibsi = "", example = -10600.0 } +WT_NORM_CTR_MOM_02 = { name = "Weighted normalized central moment (0, 2)", ibsi = "", example = -0.05459 } WT_NORM_CTR_MOM_03 = { name = "Weighted normalized central moment (0, 3)", ibsi = "", example = 0.0 } -WT_NORM_CTR_MOM_11 = { name = "Weighted normalized central moment (1, 1)", ibsi = "", example = 0.08376 } +WT_NORM_CTR_MOM_11 = { name = "Weighted normalized central moment (1, 1)", ibsi = "", example = 0.01217 } WT_NORM_CTR_MOM_12 = { name = "Weighted normalized central moment (1, 2)", ibsi = "", example = 0.0 } -WT_NORM_CTR_MOM_20 = { name = "Weighted normalized central moment (2, 0)", ibsi = "", example = -0.1001 } +WT_NORM_CTR_MOM_20 = { name = "Weighted normalized central moment (2, 0)", ibsi = "", example = -0.05539 } WT_NORM_CTR_MOM_21 = { name = "Weighted normalized central moment (2, 1)", ibsi = "", example = 0.0 } WT_NORM_CTR_MOM_30 = { name = "Weighted normalized central moment (3, 0)", ibsi = "", example = 0.0 } @@ -329,37 +329,37 @@ IMOM_RM_21 = { name = "Intensity raw moment (2, 1)", ibsi = "", example = 5639.0 IMOM_RM_22 = { name = "Intensity raw moment (2, 2)", ibsi = "", example = 20800.0 } IMOM_RM_23 = { name = "Intensity raw moment (2, 3)", ibsi = "", example = 87620.0 } IMOM_RM_30 = { name = "Intensity raw moment (3, 0)", ibsi = "", example = 9497.0 } -IMOM_WCM_02 = { name = "Intensity weighted central moment (0, 2)", ibsi = "", example = -617.3 } -IMOM_WCM_03 = { name = "Intensity weighted central moment (0, 3)", ibsi = "", example = -440.8 } -IMOM_WCM_11 = { name = "Intensity weighted central moment (1, 1)", ibsi = "", example = 599.2 } -IMOM_WCM_12 = { name = "Intensity weighted central moment (1, 2)", ibsi = "", example = 483.1 } -IMOM_WCM_20 = { name = "Intensity weighted central moment (2, 0)", ibsi = "", example = -592.0 } -IMOM_WCM_21 = { name = "Intensity weighted central moment (2, 1)", ibsi = "", example = -434.8 } -IMOM_WCM_30 = { name = "Intensity weighted central moment (3, 0)", ibsi = "", example = -434.1 } -IMOM_WHU1 = { name = "Intensity weighted Hu invariant 1", ibsi = "", example = -0.05777 } -IMOM_WHU2 = { name = "Intensity weighted Hu invariant 2", ibsi = "", example = 0.003279 } +IMOM_WCM_02 = { name = "Intensity weighted central moment (0, 2)", ibsi = "", example = -1871.0 } +IMOM_WCM_03 = { name = "Intensity weighted central moment (0, 3)", ibsi = "", example = 388.9 } +IMOM_WCM_11 = { name = "Intensity weighted central moment (1, 1)", ibsi = "", example = 270.9 } +IMOM_WCM_12 = { name = "Intensity weighted central moment (1, 2)", ibsi = "", example = 137.7 } +IMOM_WCM_20 = { name = "Intensity weighted central moment (2, 0)", ibsi = "", example = -1542.0 } +IMOM_WCM_21 = { name = "Intensity weighted central moment (2, 1)", ibsi = "", example = -312.9 } +IMOM_WCM_30 = { name = "Intensity weighted central moment (3, 0)", ibsi = "", example = -7.447 } +IMOM_WHU1 = { name = "Intensity weighted Hu invariant 1", ibsi = "", example = -0.03103 } +IMOM_WHU2 = { name = "Intensity weighted Hu invariant 2", ibsi = "", example = 3.318e-05 } IMOM_WHU3 = { name = "Intensity weighted Hu invariant 3", ibsi = "", example = 0.0 } IMOM_WHU4 = { name = "Intensity weighted Hu invariant 4", ibsi = "", example = 0.0 } IMOM_WHU5 = { name = "Intensity weighted Hu invariant 5", ibsi = "", example = 0.0 } IMOM_WHU6 = { name = "Intensity weighted Hu invariant 6", ibsi = "", example = 0.0 } IMOM_WHU7 = { name = "Intensity weighted Hu invariant 7", ibsi = "", example = 0.0 } -IMOM_WNCM_02 = { name = "Intensity weighted normalized central moment (0, 2)", ibsi = "", example = -0.02949 } +IMOM_WNCM_02 = { name = "Intensity weighted normalized central moment (0, 2)", ibsi = "", example = -0.01701 } IMOM_WNCM_03 = { name = "Intensity weighted normalized central moment (0, 3)", ibsi = "", example = 0.0 } -IMOM_WNCM_11 = { name = "Intensity weighted normalized central moment (1, 1)", ibsi = "", example = 0.02863 } +IMOM_WNCM_11 = { name = "Intensity weighted normalized central moment (1, 1)", ibsi = "", example = 0.002463 } IMOM_WNCM_12 = { name = "Intensity weighted normalized central moment (1, 2)", ibsi = "", example = 0.0 } -IMOM_WNCM_20 = { name = "Intensity weighted normalized central moment (2, 0)", ibsi = "", example = -0.02828 } +IMOM_WNCM_20 = { name = "Intensity weighted normalized central moment (2, 0)", ibsi = "", example = -0.01402 } IMOM_WNCM_21 = { name = "Intensity weighted normalized central moment (2, 1)", ibsi = "", example = 0.0 } IMOM_WNCM_30 = { name = "Intensity weighted normalized central moment (3, 0)", ibsi = "", example = 0.0 } -IMOM_WRM_00 = { name = "Intensity weighted raw moment (0, 0)", ibsi = "", example = -144.7 } -IMOM_WRM_01 = { name = "Intensity weighted raw moment (0, 1)", ibsi = "", example = -426.2 } -IMOM_WRM_02 = { name = "Intensity weighted raw moment (0, 2)", ibsi = "", example = -1873.0 } -IMOM_WRM_03 = { name = "Intensity weighted raw moment (0, 3)", ibsi = "", example = -9595.0 } -IMOM_WRM_10 = { name = "Intensity weighted raw moment (1, 0)", ibsi = "", example = -496.8 } -IMOM_WRM_11 = { name = "Intensity weighted raw moment (1, 1)", ibsi = "", example = -864.2 } -IMOM_WRM_12 = { name = "Intensity weighted raw moment (1, 2)", ibsi = "", example = -2417.0 } -IMOM_WRM_20 = { name = "Intensity weighted raw moment (2, 0)", ibsi = "", example = -2298.0 } -IMOM_WRM_21 = { name = "Intensity weighted raw moment (2, 1)", ibsi = "", example = -3089.0 } -IMOM_WRM_30 = { name = "Intensity weighted raw moment (3, 0)", ibsi = "", example = -12390.0 } +IMOM_WRM_00 = { name = "Intensity weighted raw moment (0, 0)", ibsi = "", example = -331.7 } +IMOM_WRM_01 = { name = "Intensity weighted raw moment (0, 1)", ibsi = "", example = -988.6 } +IMOM_WRM_02 = { name = "Intensity weighted raw moment (0, 2)", ibsi = "", example = -4817.0 } +IMOM_WRM_03 = { name = "Intensity weighted raw moment (0, 3)", ibsi = "", example = -25120.0 } +IMOM_WRM_10 = { name = "Intensity weighted raw moment (1, 0)", ibsi = "", example = -1092.0 } +IMOM_WRM_11 = { name = "Intensity weighted raw moment (1, 1)", ibsi = "", example = -2983.0 } +IMOM_WRM_12 = { name = "Intensity weighted raw moment (1, 2)", ibsi = "", example = -14100.0 } +IMOM_WRM_20 = { name = "Intensity weighted raw moment (2, 0)", ibsi = "", example = -5135.0 } +IMOM_WRM_21 = { name = "Intensity weighted raw moment (2, 1)", ibsi = "", example = -13840.0 } +IMOM_WRM_30 = { name = "Intensity weighted raw moment (3, 0)", ibsi = "", example = -27060.0 } [zernike] ZERNIKE2D = { name = "Zernike moments", ibsi = "", example = 0.3183 } @@ -424,14 +424,11 @@ ROUNDNESS = { name = "Roundness", ibsi = "", example = 0.7635 } [circles] # example pins known defect https://github.com/PolusAI/nyxus/issues/498 DIAMETER_MIN_ENCLOSING_CIRCLE = { name = "Minimum enclosing circle diameter", ibsi = "", example = 14.17 } -# example pins known defect https://github.com/PolusAI/nyxus/issues/497 -DIAMETER_CIRCUMSCRIBING_CIRCLE = { name = "Circumscribing circle diameter", ibsi = "", example = 17.41 } -# example pins known defect https://github.com/PolusAI/nyxus/issues/497 -DIAMETER_INSCRIBING_CIRCLE = { name = "Inscribing circle diameter", ibsi = "", example = 1.29 } +DIAMETER_CIRCUMSCRIBING_CIRCLE = { name = "Circumscribing circle diameter", ibsi = "", example = 14.72 } +DIAMETER_INSCRIBING_CIRCLE = { name = "Inscribing circle diameter", ibsi = "", example = 1.705 } [roi_radius] -# example pins known defect https://github.com/PolusAI/nyxus/issues/497 -ROI_RADIUS_MEAN = { name = "Mean distance to the contour", ibsi = "", example = 1.124 } +ROI_RADIUS_MEAN = { name = "Mean distance to the contour", ibsi = "", example = 0.9288 } ROI_RADIUS_MAX = { name = "Largest distance to the contour", ibsi = "", example = 3 } ROI_RADIUS_MEDIAN = { name = "Median distance to the contour", ibsi = "", example = 1 } @@ -757,5 +754,5 @@ IH_BIN_SIZE = { name = "Bin width", ibsi = "", example = 0.8 } [radial] FRAC_AT_D = { name = "Fraction of pixels in a ring", ibsi = "", example = 0.02128 } -MEAN_FRAC = { name = "Mean intensity in a ring", ibsi = "", example = 7.0 } +MEAN_FRAC = { name = "Mean intensity in a ring", ibsi = "", example = 9.0 } RADIAL_CV = { name = "Coefficient of variation across the wedges of a ring", ibsi = "", example = 2.646 } diff --git a/docs/source/Math/circles_index.inc b/docs/source/Math/circles_index.inc index bde1476c5..8c0156769 100644 --- a/docs/source/Math/circles_index.inc +++ b/docs/source/Math/circles_index.inc @@ -19,9 +19,9 @@ - Circumscribing circle diameter - — - regression - - 17.41 + - 14.72 * - :ref:`DIAMETER_INSCRIBING_CIRCLE ` - Inscribing circle diameter - — - regression - - 1.29 + - 1.705 diff --git a/docs/source/Math/contour_index.inc b/docs/source/Math/contour_index.inc index a63276aef..57b678c13 100644 --- a/docs/source/Math/contour_index.inc +++ b/docs/source/Math/contour_index.inc @@ -13,7 +13,7 @@ * - :ref:`PERIMETER ` - Perimeter - — - - scikit-image (rel. 1e-3) + - analytic (exact), OpenCV (exact), scikit-image (rel. 1e-3) - 48.14 * - :ref:`DIAMETER_EQUAL_PERIMETER ` - Equal-perimeter diameter @@ -43,5 +43,5 @@ * - :ref:`EDGE_INTEGRATED_INTENSITY ` - Edge integrated intensity - — - - CellProfiler (rel. 1e-6) + - analytic (exact), CellProfiler (rel. 1e-6) - 228 diff --git a/docs/source/Math/f_2dmoments.rst b/docs/source/Math/f_2dmoments.rst index db482c853..cb9f81f98 100644 --- a/docs/source/Math/f_2dmoments.rst +++ b/docs/source/Math/f_2dmoments.rst @@ -30,8 +30,7 @@ set again with every pixel also weighted by its distance to the ROI contour. GPU values differ from the CPU values: the GPU takes the central moments about the centroid rounded down to whole pixels, so the central, normalized central and Hu moments differ, and with anisotropy the run can crash, a known defect (`#521 `__). The GPU's distance search computes - the step with a single-precision logarithm, and it has no guard for a ROI without a traced - contour, such as a single pixel. + the step with a single-precision logarithm. * - **Out-of-core** - Partly. Without anisotropy: on an image that Nyxus reads as one tile, the 118 unweighted moments have the same values as in memory. On an image read as several tiles, out-of-core @@ -81,22 +80,22 @@ How the features are computed How the distance to the contour is found ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -1. **Contour.** Nyxus traces the border of the ROI in a copy of its bounding box padded by one - pixel on every side, and concatenates every traced contour into one list. The traced - coordinates keep the padding offset, a known defect (`#497 `__): each contour pixel lies one pixel to the right of and one - pixel below the ROI border pixel it stands for. The same contour gives ``PERIMETER``, which the - offset does not change. +1. **Contour.** Nyxus traces every border of the ROI, the outer border of each separate part and + the border of each hole, as a closed walk over the ROI pixels that have a 4-neighbour outside + the ROI. Each border is a list of its pixels in walking order, every pixel listed once. The same + contour gives ``PERIMETER``. 2. **Search.** :math:`d_i^2` is the smallest squared distance from pixel :math:`i` to the contour - pixels that an approximate search visits. With :math:`n` contour pixels, it first visits every - :math:`s`-th pixel of the list, :math:`s = \lfloor n / \ln n \rfloor`, starting at the first. - Around the nearest one it searches a window of :math:`\pm s` list positions, then narrower - windows (step :math:`\lfloor w / \ln w \rfloor` for a window of :math:`w` positions, 1 once - :math:`w \le 10`), until the window is 2 positions wide. The window never wraps around the - ends of the list, so the true nearest contour pixel can be missed, and :math:`d_i` is then too - large. It is never too small. + pixels that a search visits, taken over the borders one at a time. A border of at most 10 + pixels is scanned in full. On a longer border of :math:`n` pixels the search is approximate: it + first visits every :math:`s`-th pixel of the list, :math:`s = \lfloor n / \ln n \rfloor`, + starting at the first. Around the nearest one it searches a window of :math:`\pm s` list + positions, then narrower windows (step :math:`\lfloor w / \ln w \rfloor` for a window of + :math:`w` positions, 1 once :math:`w \le 10`), until the window is 2 positions wide. The window + never wraps around the ends of the list, so the true nearest contour pixel can be missed, and + :math:`d_i` is then too large. It is never too small. 3. **Weight.** :math:`d_i` is the square root, and the weight is :math:`I_i \ln(d_i + 0.001)`. -A ROI without a traced contour, such as a single pixel, has :math:`d_i = 0` for every pixel. +A single-pixel ROI is its own contour, so its pixel has :math:`d_i = 0`. In whole-slide mode (``--segDir`` equal to ``--intDir``, or ``single_roi=True`` in Python) the contour is the four corners of the image, at :math:`(0, 0)`, :math:`(W, 0)`, :math:`(W, H)` and @@ -126,28 +125,28 @@ The top-left entry is :math:`x = 0, y = 0`. The shape moments give :math:`m_{00} :math:`\bar{y} = 2.925`. The intensity moments give :math:`m_{00} = 150`, the sum of the intensities. -The traced contour has 17 pixels, each one right of and one below a border pixel of the ROI. The -distances :math:`d_i` that Nyxus uses are: +The contour is one border of 17 pixels, the ROI pixels with a 4-neighbour outside the ROI, +listed from :math:`(2, 0)` clockwise on screen. The distances :math:`d_i` that Nyxus uses are: .. math:: \textbf{d} = \begin{bmatrix} - \cdot & \cdot & 1.414 & 1 & 1 & \cdot & \cdot & \cdot\\ - \cdot & 2 & 1 & 0 & 0 & 0 & 1 & \cdot\\ - 3.162 & 2.236 & 1.414 & 1 & 1 & 1 & 0 & 0\\ - 3.606 & 2.828 & 2.236 & 2 & 2 & 1.414 & 1 & 1\\ - 1 & 0 & 1 & 2 & 2.828 & 2.236 & 1.414 & \cdot\\ - \cdot & 0 & 1 & 1.414 & 2 & 1.414 & \cdot & \cdot\\ - \cdot & \cdot & 0 & 1 & 1 & \cdot & \cdot & \cdot \end{bmatrix} - -The left-hand pixels of rows 2 and 3 are border pixels, but get :math:`d = 3.162` and -:math:`d = 3.606`. For :math:`(x, y) = (0, 3)` the search visits list positions 0, 6 and 12 -(:math:`s = \lfloor 17 / \ln 17 \rfloor = 6`), keeps position 0 at :math:`d^2 = 13`, and -searches positions 0 to 5 around it. The traced pixel at distance 1, position 15, lies outside -that window. Eight of the 40 pixels get a larger :math:`d` than their nearest traced pixel, by up -to 2.83 pixels. - -The weights sum to :math:`m^w_{00} = -42.77` for the shape and :math:`-144.7` for the intensity. + \cdot & \cdot & 0 & 0 & 0 & \cdot & \cdot & \cdot\\ + \cdot & 1.414 & 1 & 1 & 1 & 0 & 0 & \cdot\\ + 2.828 & 2.236 & 2 & 2 & 1.414 & 1 & 1 & 0\\ + 0 & 1 & 2 & 2.828 & 2.236 & 1.414 & 1 & 0\\ + 0 & 1 & 1.414 & 2 & 1.414 & 1 & 0 & \cdot\\ + \cdot & 0 & 1 & 1 & 1 & 0 & \cdot & \cdot\\ + \cdot & \cdot & 0 & 0 & 0 & \cdot & \cdot & \cdot \end{bmatrix} + +The left-hand pixel of row 2 and the second pixel of row 1 are contour pixels, but get +:math:`d = 2.828` and :math:`d = 1.414`. For :math:`(x, y) = (0, 2)` the search visits list +positions 0, 6 and 12 (:math:`s = \lfloor 17 / \ln 17 \rfloor = 6`), keeps position 0 at +:math:`d^2 = 8`, and searches positions 0 to 5 around it. The pixel itself, position 15, lies +outside that window. Four of the 40 pixels get a larger :math:`d` than their nearest contour +pixel, by up to 2.83 pixels. + +The weights sum to :math:`m^w_{00} = -95.40` for the shape and :math:`-331.7` for the intensity. Both are negative, so every weighted normalized moment of odd order, and weighted Hu invariants 3 to 7, are not real numbers and are output as 0 (see :ref:`moments-possible-issues`). @@ -193,7 +192,7 @@ Notation * - :math:`\eta_{pq}` - Normalized central moment, :math:`\mu_{pq} / m_{00}^{(p+q)/2+1}`. * - :math:`d_i` - - Distance from pixel :math:`i` to the traced contour, as found by the search above. + - Distance from pixel :math:`i` to the contour, as found by the search above. * - :math:`w_i` - Weight of pixel :math:`i` in the weighted moments, :math:`I_i \ln(d_i + 0.001)`. * - :math:`m^w_{pq}, \mu^w_{pq}, \eta^w_{pq}` @@ -538,17 +537,16 @@ Weighted raw moments :Range: :math:`(-\infty, \infty)` The raw moments with each pixel weighted by :math:`w_i = I_i \ln(d_i + 0.001)`, where :math:`d_i` -is its distance to the traced contour (see `How the distance to the contour is found`_). Pixels -on the traced contour count negatively (:math:`-6.9\,I_i`), pixels one step from it about 0, and +is its distance to the contour (see `How the distance to the contour is found`_). Pixels +on the contour count negatively (:math:`-6.9\,I_i`), pixels one step from it about 0, and pixels deeper inside positively. .. math:: m^w_{pq} = \sum_i w_i\, x_i^p\, y_i^q, \qquad w_i = I_i \ln(d_i + 0.001) -**Possible issues.** :math:`d_i` comes from a contour shifted by one pixel and from an approximate -search, so these values differ from the same formula with the exact distance to the ROI border -(see :ref:`moments-possible-issues`). +**Possible issues.** :math:`d_i` comes from an approximate search, so these values differ from +the same formula with the exact distance to the ROI border (see :ref:`moments-possible-issues`). .. _feature-weighted-central-moment-02: @@ -641,7 +639,7 @@ Weighted Hu invariants Hu's formulas (see `Hu invariants`_) applied to :math:`\eta^w_{pq}` in place of :math:`\eta_{pq}`. Unlike :math:`I_1`, the weighted :math:`I_1` can be negative: it is -:math:`-0.194` on the worked example. +:math:`-0.1100` on the worked example. **Possible issues.** Invariants 3 to 7 use the odd-order :math:`\eta^w_{pq}`, so they are output as the ``--noval`` substitute whenever :math:`m^w_{00} < 0`. Invariants 1 and 2 use only second-order @@ -653,40 +651,36 @@ moments and are always computed. Possible issues --------------- -The contour is shifted by one pixel - Each traced contour pixel lies one pixel right of and below the border pixel it stands for, so - the distances :math:`d_i` are measured to the wrong pixels. Border pixels on the top and left - sides are at distance 1 or more from the traced contour instead of 0. On a 48 × 40 rectangle, - 85 of the 1920 pixels lie on the traced contour, against 172 border pixels. With the exact - distance to the traced contour ``WEIGHTED_SPAT_MOMENT_00`` would be 2422.7; with the exact - distance to the ROI's own border it is 1821.3. - The distance search is approximate - The search can miss the nearest contour pixel, so :math:`d_i` can be too large, never too - small. On the 48 × 40 rectangle 121 of the 1920 pixels get too large a distance, by up to 12 - pixels, and ``WEIGHTED_SPAT_MOMENT_00`` is 2498.7 instead of 2422.7. Together with the shifted - contour, Nyxus reports 37 % more than the exact distance to the ROI border gives. On the worked - example 8 of the 40 pixels get too large a distance. + On a border of more than 10 pixels the search can miss the nearest contour pixel, so + :math:`d_i` can be too large, never too small. On a 48 × 40 rectangle, whose contour is its 172 + border pixels, 118 of the 1920 pixels get too large a distance, by up to 12 pixels, and + ``WEIGHTED_SPAT_MOMENT_00`` is 1895.0 where the exact distance to the contour gives 1821.3, + 4.0 % more. On the worked example 4 of the 40 pixels get too large a distance. The sum of the weights is often negative Every pixel at :math:`d_i < 0.999` has a negative weight, and a pixel at :math:`d_i = 0` weighs :math:`-6.9\,I_i`. On small ROIs the sum :math:`m^w_{00}` is therefore negative, and all odd-order weighted normalized moments and weighted Hu invariants 3 to 7 are output as the ``--noval`` - substitute. On digital disks ``WEIGHTED_SPAT_MOMENT_00`` is -68.94 at radius 5, 6.806 at radius 8 - and 1549 at radius 20. Near the sign change the normalization divides by a number close to 0: - ``WT_NORM_CTR_MOM_30`` is 7881 at radius 8 and 0.007713 at radius 20. + substitute. On digital disks ``WEIGHTED_SPAT_MOMENT_00`` is -153.9 at radius 5, 2.195 at + radius 12 and 1211 at radius 20. Near the sign change the normalization divides by a number + close to 0: ``WT_NORM_CTR_MOM_30`` is -33355 at radius 12 and -0.001704 at radius 20. Weighted moments differ out of core Without anisotropy, an ROI computed out of core gets its contour from a different routine, which places it relative to the ROI's bounding box while the pixels keep their image positions, a known defect (`#509 `__). The distances :math:`d_i` are then off by the box's offset, and all 62 weighted moments depend on where the - ROI lies. On an ellipse of 245 pixels ``WEIGHTED_SPAT_MOMENT_00`` is 49.81 in memory wherever - it lies, and 7.484, 58.50 or 338.2 out of core depending on where its bounding box starts: + ROI lies. On an ellipse of 245 pixels ``WEIGHTED_SPAT_MOMENT_00`` is -93.36 in memory wherever + it lies, and 35.62, 78.42 or 425.0 out of core depending on where its bounding box starts: (2, 2), (3, 5) or (10, 12). A ROI whose bounding box starts at (0, 0) is placed correctly, but the out-of-core contour can list its pixels in a different order, which changes the approximate distance search: a 48 × 40 rectangle there gets the in-memory values, while the - ellipse gives 43.75. The 118 unweighted moments are the same on both paths. + ellipse keeps ``WEIGHTED_SPAT_MOMENT_00`` at -93.36 but gives ``WEIGHTED_SPAT_MOMENT_10`` + -708.9 against -1158 in memory. The out-of-core contour is also a single loop, which leaves a + hole's border out: a 9 × 9 square with a 3 × 3 hole at the origin gives + ``WEIGHTED_SPAT_MOMENT_00`` -194.7 out of core and -274.2 in memory. The 118 unweighted moments + are the same on both paths. Whole-slide mode The image corners used as the contour lie one pixel past the last column and row, a known @@ -712,7 +706,7 @@ ROI with all intensities 0 output as the ``--noval`` substitute, 0 by default. The shape moments are computed as usual. Single-pixel ROI - No contour is traced, so the pixel has :math:`d = 0` and the weight :math:`\ln 0.001`. + The pixel is its own contour, so it has :math:`d = 0` and the weight :math:`\ln 0.001`. ``WEIGHTED_SPAT_MOMENT_00`` is -6.908, and the central and normalized central moments are 0. diff --git a/docs/source/Math/f_fractal_dimension.rst b/docs/source/Math/f_fractal_dimension.rst index fd9428111..b0f454756 100644 --- a/docs/source/Math/f_fractal_dimension.rst +++ b/docs/source/Math/f_fractal_dimension.rst @@ -74,10 +74,9 @@ fewer than :math:`d`. The walk's length :math:`P(d)` and its mean chord, the rul least-squares slope of :math:`\ln P` against :math:`\ln r`. At stride 1 the walk is the contour's ``PERIMETER``. -A ROI with holes or separate parts has several contours. Nyxus joins them, in the order it found -them, into one chain and walks that chain as a single closed contour (see -:ref:`fractal-possible-issues`). The contour coordinates are offset by one pixel in both axes, a known defect -(`#497 `__); a translation changes no distance, so neither feature depends on it. +A ROI with holes or separate parts has several borders. Nyxus lists their pixels one border after +another, in the order it found the borders, each pixel once, and walks that list as a single closed +contour (see :ref:`fractal-possible-issues`). Worked example @@ -263,8 +262,8 @@ Settings - ``Nyxus(..., anisotropy_x=..., anisotropy_y=...)``, with ``featurize_files`` or ``featurize_directory``; arrays passed to ``Nyxus.featurize`` are not rescaled - The image is resampled before the ROIs are measured, which changes both features. The - worked example gives 1.3732 and 1.1311 without anisotropy, 1.2459 and 1.1731 with - ``anisotropy_x=2``, and 1.4458 and 1.2546 with ``anisotropy_y=3``. + worked example gives 1.3732 and 1.1311 without anisotropy, 1.2459 and 1.1717 with + ``anisotropy_x=2``, and 1.4458 and 1.1635 with ``anisotropy_y=3``. * - Merged ROIs - ``--mergerois=true`` - ``Nyxus(..., mergerois=True)`` @@ -358,8 +357,9 @@ near 1 mean a smooth outline; larger values mean a rough or wiggly one. The value is 0 when the contour has fewer than 3 pixels. A contour of 3 to 7 pixels gives at most one point, so :math:`\beta = 0` and the value is exactly 1. -**Relations.** In memory, :math:`P(1)` is ``PERIMETER``; out of core, ``PERIMETER`` is the number -of contour pixels instead (26 on the worked example). The fit runs against the mean chord +**Relations.** In memory, :math:`P(1)` is ``PERIMETER`` for a ROI with a single border that its +walk passes only once, such as the worked example; out of core, ``PERIMETER`` is the number of +contour pixels instead (26 on the worked example). The fit runs against the mean chord :math:`r(d)`, not against the stride :math:`d`. **Possible issues.** For a ROI with several contours the walk includes the jumps between them (see @@ -399,8 +399,11 @@ Out-of-core ROIs 1.3732 in memory. The out-of-core contour holds only the first loop of the ROI's boundary, so the divider walk ignores holes and separate parts: two separate 25 × 25 squares in one ROI give 1.000 out of core and 1.107 in memory. Without anisotropy, a ROI with a single contour gives the - same ``FRACT_DIM_PERIMETER`` on both paths. With anisotropy the out-of-core contour is wrong, - so single-contour ROIs differ too: a disk of radius 8 with ``anisotropy_x=2`` gives 1.1431 in + same ``FRACT_DIM_PERIMETER`` on both paths as long as both tracers list its pixels in the same + order, as on the worked example and on straight lines; on a ROI with one-pixel-wide parts they + can differ, and an 8 × 8 square with a one-pixel-wide branch gives 1.131 in memory and 1.102 + out of core. With anisotropy the out-of-core contour is wrong, so single-contour ROIs differ + too: a disk of radius 8 with ``anisotropy_x=2`` gives 1.0771 in memory and 1.0482 out of core. The out-of-core contour is a known defect (`#509 `__). Images that Nyxus reads as several tiles have further out-of-core differences (see :ref:`large-images-tiles`). diff --git a/docs/source/Math/f_morphology.rst b/docs/source/Math/f_morphology.rst index 178eb3193..459301e2e 100644 --- a/docs/source/Math/f_morphology.rst +++ b/docs/source/Math/f_morphology.rst @@ -53,10 +53,10 @@ Each family starts from one of four sets derived from the ROI's mask: - **Pixels:** every pixel of the mask with the ROI's label, at its integer position :math:`(x_k, y_k)`. :math:`N` is their number. The basic morphology, ellipse, extrema, Euler and chord families use them. -- **Contour:** the ROI's outer boundary, traced on the mask: the ROI pixels that have a 4-neighbour - outside the ROI, walked around the ROI in order. :math:`M` is their number. A hole or a second - fragment adds further loops, appended to the same list. The contour, perimeter, circle, ROI - radius, neighbour and polygonality features use it. +- **Contour:** the ROI's boundary, traced on the mask: the ROI pixels that have a 4-neighbour + outside the ROI. :math:`M` is their number. They are walked border by border, once around the + outside of each separate part of the ROI and once around each hole. The contour, perimeter, + circle, ROI radius, neighbour and polygonality features use it. - **Convex hull:** the convex polygon spanned by the pixel centres. The convex hull and the three caliper families use it. - **Other ROIs:** the neighbour family compares the ROI's contour and centroid with those of the @@ -157,9 +157,12 @@ Notation - Width and height of the bounding box, :math:`W = \max_k x_k - \min_k x_k + 1` and :math:`H = \max_k y_k - \min_k y_k + 1`. * - :math:`(u_j, v_j)` - - Position of the :math:`j`-th contour pixel, in walking order, :math:`j = 1 \dots M`. + - Position of the :math:`j`-th contour pixel, :math:`j = 1 \dots M`. * - :math:`M` - Number of contour pixels. + * - :math:`(u^b_i, v^b_i)` + - Position of the :math:`i`-th step of the walk around border :math:`b`, + :math:`i = 1 \dots L_b`. * - :math:`P` - ``PERIMETER``. * - :math:`A_h` @@ -237,7 +240,8 @@ When a feature cannot be computed, its column holds a substitute value: neighbours: with ``--pixelDistance=1`` two ROIs that touch only diagonally are not neighbours but still count as touching. - ``WEIGHTED_CENTROID_X`` and ``WEIGHTED_CENTROID_Y`` are 0 when the ROI's grey levels sum to 0. -- The contour features are 0 for a ROI too small to trace, such as one or two pixels. +- ``PERIMETER`` and the features built on it are 0 for a single pixel, whose contour is the pixel + itself. The ellipse, circle and hexagonality codes are right only when the codes they read from are in the same run, a known defect (`#495 `__); see :ref:`morphology-possible-issues`. @@ -500,10 +504,12 @@ wide and 5 tall. Contour ^^^^^^^ -The contour is traced on the mask padded by one pixel. For a ROI without holes it is the ROI's -pixels that have a 4-neighbour outside the ROI, walked in order: from the first contour pixel in -row order, each step goes to an unvisited contour pixel that is a 4-neighbour if there is one, -otherwise to a diagonal neighbour. Steps are 1 or :math:`\sqrt{2}` long. +The contour is the ROI's pixels that have a 4-neighbour outside the ROI. Nyxus traces it on the +mask padded by one pixel, by Suzuki and Abe's border following, the algorithm behind OpenCV's +``findContours``: one closed walk of 8-connected steps around the outside of each separate part of +the ROI, and one around each hole. Steps are 1 or :math:`\sqrt{2}` long. A walk passes a part +of the ROI that is one pixel wide twice, once on each side, and two borders can share a pixel; the +contour pixels themselves, :math:`M` of them, are each counted once. .. _feature-perimeter: @@ -513,20 +519,21 @@ Perimeter :Code: ``PERIMETER`` :Range: :math:`[0, \infty)`, pixels -The length of the closed path through the contour pixels' centres. +The summed length of the closed walks around the ROI's borders, through the contour pixels' +centres. .. math:: - \mathrm{PERIMETER} = \sum_{j=1}^{M} \sqrt{(u_j - u_{j-1})^2 + (v_j - v_{j-1})^2}, - \qquad (u_0, v_0) = (u_M, v_M) + \mathrm{PERIMETER} = \sum_b \sum_{i=1}^{L_b} \sqrt{(u^b_i - u^b_{i-1})^2 + (v^b_i - v^b_{i-1})^2}, + \qquad (u^b_0, v^b_0) = (u^b_{L_b}, v^b_{L_b}) + +A 9 × 9 square with a 3 × 3 hole gives 45.66, its outer walk of 32 and the hole's walk of 13.66, +and a label made of two 5 × 4 rectangles gives 28, 14 for each. **Possible issues.** The path runs through pixel centres, so it is shorter than the outline of the -pixel squares: a 3 × 5 rectangle has a perimeter of 12, not 16. On ROIs with holes or several -fragments the loops are joined into one list, and the steps between loops are counted: a 9 × 9 -square with a 3 × 3 hole gives 50.01 where its two loops measure 32 and 13.66, and a label made of -two 5 × 4 rectangles gives 42.12 where each measures 14. Out of core the value is the number of -pixels of the first traced loop, without holes or other fragments: 44 for ROI 1 of the worked -example. +pixel squares: a 3 × 5 rectangle has a perimeter of 12, not 16. A part one pixel wide is walked +on both sides: a 1 × 13 bar gives 24. Out of core the value is the number of pixels of the first +traced loop, without holes or other fragments: 44 for ROI 1 of the worked example. .. _feature-diameter-equal-perimeter: @@ -628,7 +635,7 @@ The ratio of the area to the area of a circle with the ROI's perimeter, square-r **Possible issues.** :math:`N` counts whole pixels while :math:`P` runs through pixel centres, so small or blocky ROIs exceed 1: ROI 2 of the worked example, a 3 × 5 rectangle, gives 1.144. The -value is 0 when no contour could be traced. +value is 0 for a single pixel, whose perimeter is 0. .. _feature-polygonality-ave: @@ -825,21 +832,20 @@ Circumscribing and inscribing circles :Codes: ``DIAMETER_CIRCUMSCRIBING_CIRCLE``, ``DIAMETER_INSCRIBING_CIRCLE`` :Range: :math:`[0, \infty)`, pixels -Twice the largest and twice the smallest distance from the centroid to the contour: the circles -centred on the centroid that just enclose the contour and just touch it from inside. As the code -computes them, the contour pixels are two pixels further along both axes than they lie: +Twice the largest and twice the smallest distance from the centroid to the ROI's outer borders: the +circles centred on the centroid that just enclose the outline and just touch it from inside. The +borders of holes are left out, as in imea, which reads the external contour only; a ROI in several +parts has one outer border per part, and all of them count. .. math:: - d_j = \sqrt{(u_j + 2 - c_x)^2 + (v_j + 2 - c_y)^2}, \qquad + d_j = \sqrt{(u_j - c_x)^2 + (v_j - c_y)^2}, \qquad \mathrm{DIAMETER\_CIRCUMSCRIBING\_CIRCLE} = 2\max_j d_j, \qquad \mathrm{DIAMETER\_INSCRIBING\_CIRCLE} = 2\min_j d_j -**Possible issues.** The traced contour is returned one pixel down and to the right of the ROI, -and the centroid is moved one pixel up and to the left before the distances are taken, a known -defect (`#497 `__). On the -worked example ROI 1 gives 17.41 and 1.290, where the distances to the contour as it lies give 14.72 -and 1.705; ROI 2 gives 10 where :math:`2\sqrt{5} = 4.472`. +with :math:`j` over the pixels of the outer borders. On the worked example ROI 1 gives 14.72 and +1.705, and ROI 2 gives :math:`2\sqrt{5} = 4.472` and 2. A 9 × 9 square with a centred 3 × 3 hole +gives 11.31 and 8, the values of the square without the hole. ROI radius @@ -857,20 +863,17 @@ Distance to the contour The mean, largest and median distance from a ROI pixel to the nearest contour pixel, over all ROI pixels. The maximum is the radius of the largest disk inside the ROI, measured from a pixel centre. -As the code computes it, the contour lies one pixel down and to the right, a known defect -(`#497 `__): +Contour pixels are at distance 0. .. math:: - r_k = \min_j \sqrt{(x_k - u_j - 1)^2 + (y_k - v_j - 1)^2} + r_k = \min_j \sqrt{(x_k - u_j)^2 + (y_k - v_j)^2} -The median of an even count is the mean of the two middle values. +The median of an even count is the mean of the two middle values. On the worked example ROI 1 gives +a mean of 0.9288, a maximum of 3 and a median of 1; ROI 2 gives 0.2, 1 and 0. -**Possible issues.** Because of the contour offset, contour pixels themselves are not at distance -0. On the worked example ROI 1 gives a mean of 1.124 where the contour as it lies gives 0.9288; its -maximum, 3, and median, 1, are unchanged. ROI 2 gives 0.6943, 1.414 and 1 where the contour as it -lies gives 0.2, 1 and 0. Out of core the contour coordinates are relative to the bounding box, a known -defect (`#509 `__), so the values depend on where the ROI lies; there ROI 1 gives 0.9288 and ROI 2 11.13. +**Possible issues.** Out of core the contour coordinates are relative to the bounding box, a known +defect (`#509 `__), so the values depend on where the ROI lies; there ROI 1 gives a mean of 1.124 and ROI 2 12.25. Extrema @@ -1254,18 +1257,6 @@ their directions, a known defect (`#499 `__). ``PERIMETER``, the - ``EDGE_*`` features, the minimum enclosing circle and the neighbour features do not depend on - where the contour lies. The circumscribing and inscribing circles (17.41 and 1.290 for ROI 1, - where the contour as it lies gives 14.72 and 1.705) and ``ROI_RADIUS_*`` (mean 1.124 against - 0.9288) do. - -Holes and fragments in the contour - A hole or a second fragment adds a loop to the contour, and ``PERIMETER`` counts the step from - one loop to the next: 50.01 for a 9 × 9 square with a 3 × 3 hole, whose loops measure 32 and 13.66. - Minimum enclosing circle Truncated chord midpoints make the circle too large on some ROIs, a known defect (`#498 `__): 14.17 for ROI 1 where the @@ -1295,9 +1286,11 @@ Neighbour statistics 3 × 5 rectangle. Small ROIs - A ROI of one or two pixels has no traceable contour: ``PERIMETER``, the ``EDGE_*`` features, - ``CIRCULARITY``, the circles and ``ROI_RADIUS_*`` are 0. A single pixel has a hull area of 1, and 0 - for every caliper and chord feature. A ROI whose pixel centres lie on one straight line, such + A single pixel is its own contour: the ``EDGE_*`` features are its grey level (the standard + deviation 0), ``PERIMETER``, ``CIRCULARITY``, the circumscribing and inscribing circles and + ``ROI_RADIUS_*`` are 0, and the minimum enclosing circle is :math:`2 \times 10^{-4}`. A single + pixel has a hull area of 1, and 0 for every caliper and chord feature. A ROI of two pixels is + walked there and back, so ``PERIMETER`` is 2 and ``CIRCULARITY`` 2.507. A ROI whose pixel centres lie on one straight line, such as a one-pixel-wide bar, has a hull of two vertices and gives 0 for every Nassenstein statistic and Martin statistics of at most about 10\ :sup:`-6`, rounding residue of the rotated hull (``STAT_MARTIN_DIAM_MAX`` 4.8 × 10\ :sup:`-7` on a 1 × 13 bar); angles at which @@ -1320,11 +1313,11 @@ Out of core left out of it, which changes ``PERIMETER`` and the ``EDGE_*`` features of such ROIs, and the minimum enclosing circle of a ROI in several fragments. On a 300 × 300 square with a centred 100 × 100 hole the out-of-core contour is the outer loop of 1196 pixels, where the in-memory - contour has 1596, and ``PERIMETER`` is 1196 instead of 1876. ``PERIMETER`` is the number of the loop's pixels + contour has 1596, and ``PERIMETER`` is 1196 instead of 1597.7. ``PERIMETER`` is the number of the loop's pixels (44 for ROI 1 instead of 48.14), and ``DIAMETER_EQUAL_PERIMETER``, ``CIRCULARITY``, ``GEODETIC_LENGTH`` and ``THICKNESS`` follow it. The contour's coordinates are relative to the bounding box, which changes the circumscribing and inscribing circles and ``ROI_RADIUS_*`` - (ROI 2: circumscribing 25.30 instead of 10). The neighbour features are not computed and are + (ROI 2: circumscribing 30.46 instead of 4.472). The neighbour features are not computed and are 0, and an out-of-core ROI is also not seen as a neighbour by the ROIs computed in memory, so their neighbour features leave it out. ``POLYGONALITY_AVE`` and ``HEXAGONALITY_*``, which need the neighbour count, are −1 out of core. On the worked example, whose ROIs have no holes, the @@ -1341,7 +1334,7 @@ Codes that need companions and ``ROUNDNESS`` 0. ``ELONGATION`` is 0 unless another ellipse code is in the run too. The three circle diameters need a contour code such as ``PERIMETER`` and a basic morphology code: alone they are 0, and with ``PERIMETER`` only ``DIAMETER_MIN_ENCLOSING_CIRCLE`` is right (ROI - 1's ``DIAMETER_CIRCUMSCRIBING_CIRCLE`` is 35.61 instead of 17.41). ``HEXAGONALITY_AVE`` and + 1's ``DIAMETER_CIRCUMSCRIBING_CIRCLE`` is 30 instead of 14.72). ``HEXAGONALITY_AVE`` and ``HEXAGONALITY_STDDEV`` need a Feret code such as ``STAT_FERET_DIAM_MAX``: without one they are 0, where the disk with four neighbours gives 9.223 and 0.08352. ``*ALL_MORPHOLOGY*`` computes every one of them correctly. @@ -1359,7 +1352,7 @@ Whole-slide mode Validation ---------- -The test suite checks the morphology features against five references: +The test suite checks the morphology features against six references: - **MATLAB** (R2026a, ``regionprops`` and ``bweuler``), on a test ROI, to a relative 10\ :sup:`-3`: ``AREA_PIXELS_COUNT``, ``AREA_UM2``, ``CENTROID_X``, ``CENTROID_Y``, @@ -1374,11 +1367,17 @@ The test suite checks the morphology features against five references: - **scikit-image**: ``DIAMETER_EQUAL_AREA``, ``PERIMETER`` (on digital circles), ``ROI_RADIUS_MAX`` and ``ROI_RADIUS_MEDIAN`` to a relative 10\ :sup:`-3`; ``CONVEX_HULL_AREA``, ``SOLIDITY`` and ``ORIENTATION``. +- **OpenCV** 4.13.0 (``cv2.findContours`` with ``RETR_LIST`` and ``CHAIN_APPROX_NONE``): + ``PERIMETER`` exactly, the summed closed length of every border, on an irregular blob and on a + ROI with a hole. - **imea** 0.3.5: ``DIAMETER_EQUAL_PERIMETER``, ``GEODETIC_LENGTH`` and ``THICKNESS`` exactly, computed from Nyxus' own area and perimeter; ``DIAMETER_MIN_ENCLOSING_CIRCLE``, the minimum, maximum, mean, median and standard deviation of the Feret, Martin and Nassenstein diameters, and ``ALLCHORDS_MIN`` to a relative 6 × 10\ :sup:`-2`, on an ellipse. -- **Analytic** values: ``ROI_RADIUS_MAX`` exactly on digital disks; ``CLOSEST_NEIGHBOR1_ANG``, +- **Analytic** values: ``PERIMETER`` exactly on squares, a square with a hole, two separate + squares under one label, one-pixel-wide lines and a single pixel; ``EDGE_INTEGRATED_INTENSITY`` + exactly on a one-pixel-wide line, whose walk passes its inner pixels twice, each counted once; + ``ROI_RADIUS_MAX`` exactly on digital disks; ``CLOSEST_NEIGHBOR1_ANG``, ``CLOSEST_NEIGHBOR2_DIST``, ``CLOSEST_NEIGHBOR2_ANG`` and the three ``ANG_BW_NEIGHBORS_*`` codes to an absolute 10\ :sup:`-9`; ``CIRCULARITY`` and ``ROUNDNESS`` against their formulas. @@ -1389,8 +1388,8 @@ The test suite checks the morphology features against five references: out-of-core values of the ellipse family, the ``EDGE_*`` features and ``MASS_DISPLACEMENT`` are checked against the in-memory values of the same build. The *Example* values and the values the worked-example script checks beside them are pinned by that script. The comparisons with -``skimage.measure.regionprops``, the alternative conventions (contour as it lies, Feret over 18 -directions, chords counting the last row, merged ROIs) and the out-of-core, anisotropy and +``skimage.measure.regionprops``, the alternative conventions (Feret over 18 directions, chords +counting the last row, merged ROIs) and the out-of-core, anisotropy and whole-slide values are measured but not pinned. diff --git a/docs/source/Math/f_radial_distribution.rst b/docs/source/Math/f_radial_distribution.rst index 8b30eadfa..1b90cec6b 100644 --- a/docs/source/Math/f_radial_distribution.rst +++ b/docs/source/Math/f_radial_distribution.rst @@ -51,21 +51,20 @@ sorts every ROI pixel into a ring by its distance from the centre and into a wed from 0, with :math:`y` growing downward. Distances are in pixels, between pixel centres; the physical pixel size is not used. Anisotropy (files only) scales each pixel's position by the factors before distances and angles are taken (see :ref:`conventions-units`). -- **Contour:** the boundary of the ROI as Nyxus's contour tracer returns it, around the outside of - the ROI and around each hole. On compact ROIs such as the worked example it holds every ROI pixel - with a 4-neighbour in the background; on irregular ROIs the tracer skips some of them (350 of 400 - noisy random ROIs in a test). All contours of the ROI are joined into one list, outer contour - first. Every contour point comes back one pixel to the right and one pixel down of the boundary - pixel it stands for, :math:`(x+1, y+1)`; the features use these shifted points, a known defect (`#497 `__; see - :ref:`radial-possible-issues`). +- **Contour:** the ROI pixels with a 4-neighbour outside the ROI, as Nyxus's contour tracer + returns them: one closed walk around the outside of each separate part of the ROI and one around + each hole. Each walk is a border, listed in walking order, and every contour pixel is listed + once. - **Whole-slide mode:** the contour is not traced. It is the four points :math:`(0, 0)`, - :math:`(W, 0)`, :math:`(W, H)` and :math:`(0, H)` of a :math:`W \times H` image, without the - :math:`(+1, +1)` shift. The centre found from them lies on the image's top edge, a known defect - (`#516 `__; see :ref:`radial-possible-issues`). + :math:`(W, 0)`, :math:`(W, H)` and :math:`(0, H)` of a :math:`W \times H` image, one pixel past + the last column and row, a known defect (`#493 `__). + The centre found from them is the pixel equally far from all four when :math:`W` and :math:`H` + are even, :math:`(W/2, H/2)`, half a pixel right of and below the middle of the image. - **Centre:** the ROI pixel :math:`O` with the smallest spread of squared distances to the contour, - the largest squared distance minus the smallest. The largest and smallest distances are found by - a coarse-to-fine search along the contour list, not by checking every contour point; on many ROIs - the search returns a smaller maximum or a larger minimum than the true ones. When several pixels + the largest squared distance minus the smallest. The largest and smallest distances are found + border by border. A border of at most 10 pixels is checked point by point; a longer one by a + coarse-to-fine search along its walk, not by checking every contour point, and on many ROIs that + search returns a smaller maximum or a larger minimum than the true ones. When several pixels share the smallest spread, the first one in the order the pixels were read wins: column by column for an array given to ``Nyxus.featurize``; tile by tile, and row by row within each tile, for an image read from a file. @@ -89,8 +88,9 @@ sorts every ROI pixel into a ring by its distance from the centre and into a wed the code; ``--tinyval`` does not change it. An empty ring therefore reports 0 in all three features. - **ROIs of one intensity** are computed like any other ROI. -- **No contour:** a ROI whose tracer finds no contour, such as a ROI of 1 or 2 pixels, reports 0 - in all 24 columns. +- **One-pixel ROI:** the pixel is its own contour and the centre, and :math:`R = 0`; ring 0 holds + the pixel, so ``FRAC_AT_D_0`` is 1, ``MEAN_FRAC_0`` is its intensity and ``RADIAL_CV_0`` is + :math:`\sqrt{7}` when that intensity is not 0. The other 21 columns are 0. - **IBSI mode:** ``--ibsi=true`` does not change these features. @@ -111,33 +111,34 @@ belongs to the ROI, and every zero is background: 0&3&4&6&6&5&4&2&0\\ 0&0&2&3&3&0&0&0&0 \end{bmatrix} -The intensities sum to 224. The contour has 20 points, the 20 boundary pixels shifted by -:math:`(+1, +1)`; 9 of them are not ROI pixels. The centre is :math:`O = (5, 4)`, intensity 7: its -squared distances to the contour run from 5 to 17, a spread of 12, the smallest of all pixels; the -next best, :math:`(5, 3)`, has 16. The radius is :math:`R = \sqrt{17} = 4.123`. The ring and the wedge of each pixel are (· is background): +The intensities sum to 224. The contour is one border of 20 points, the 20 ROI pixels with a +4-neighbour outside the ROI. The centre is :math:`O = (4, 3)`, intensity 9: its squared distances +to the contour run from 5 to 17, a spread of 12, the smallest of all pixels; the next best, +:math:`(4, 2)`, has 16. The radius is :math:`R = \sqrt{17} = 4.123`. The ring and the wedge of each +pixel are (· is background): .. math:: k = \begin{bmatrix} - \cdot&\cdot&7&7&7&6&\cdot&\cdot&\cdot\\ - \cdot&7&7&6&5&5&5&6&\cdot\\ - 7&7&6&4&3&3&3&4&\cdot\\ - 7&7&5&3&2&1&2&3&5\\ - 7&6&5&3&1&\mathbf{0}&1&3&5\\ - \cdot&7&5&3&2&1&2&3&\cdot\\ - \cdot&\cdot&6&4&3&\cdot&\cdot&\cdot&\cdot \end{bmatrix} + \cdot&\cdot&6&5&5&5&\cdot&\cdot&\cdot\\ + \cdot&6&4&3&3&3&4&6&\cdot\\ + 7&5&3&2&1&2&3&5&\cdot\\ + 6&5&3&1&\mathbf{0}&1&3&5&6\\ + 7&5&3&2&1&2&3&5&7\\ + \cdot&6&4&3&3&3&4&6&\cdot\\ + \cdot&\cdot&6&5&5&\cdot&\cdot&\cdot&\cdot \end{bmatrix} \qquad j = \begin{bmatrix} - \cdot&\cdot&5&5&5&6&\cdot&\cdot&\cdot\\ - \cdot&4&5&5&5&6&6&6&\cdot\\ - 4&4&4&5&5&6&6&7&\cdot\\ - 4&4&4&4&5&6&7&7&7\\ - 4&4&4&4&4&\mathbf{0}&0&0&0\\ - \cdot&3&3&3&3&2&1&0&\cdot\\ - \cdot&\cdot&3&3&2&\cdot&\cdot&\cdot&\cdot \end{bmatrix} + \cdot&\cdot&5&5&6&6&\cdot&\cdot&\cdot\\ + \cdot&4&5&5&6&6&7&7&\cdot\\ + 4&4&4&5&6&7&7&7&\cdot\\ + 4&4&4&4&\mathbf{0}&0&0&0&0\\ + 3&3&3&3&2&1&0&0&0\\ + \cdot&3&3&2&2&1&1&0&\cdot\\ + \cdot&\cdot&2&2&2&\cdot&\cdot&\cdot&\cdot \end{bmatrix} -Eight pixels lie farther than :math:`R` from the centre and three exactly at :math:`R`, so ring 7 -holds 11 pixels. Per ring: +No pixel lies farther than :math:`R` from the centre, and three lie exactly at :math:`R`, so ring 7 +holds 3 pixels. Per ring: .. list-table:: :widths: 7 7 8 34 14 14 16 @@ -152,64 +153,64 @@ holds 11 pixels. Per ring: - ``RADIAL_CV`` * - 0 - 1 - - 7 - - 7, 0, 0, 0, 0, 0, 0, 0 + - 9 + - 9, 0, 0, 0, 0, 0, 0, 0 - 0.02128 - - 7 + - 9 - 2.646 * - 1 - 4 - - 26 - - 5, 0, 5, 0, 8, 0, 8, 0 + - 34 + - 8, 0, 8, 0, 9, 0, 9, 0 - 0.08511 - - 6.5 - - 1.052 + - 8.5 + - 1.003 * - 2 - 4 - - 25 - - 0, 4, 0, 6, 0, 9, 0, 6 + - 30 + - 0, 7, 0, 8, 0, 8, 0, 7 - 0.08511 - - 6.25 - - 1.079 + - 7.5 + - 1.004 * - 3 - - 10 - - 56 - - 5, 0, 3, 6, 17, 9, 12, 4 - - 0.2128 - - 5.6 - - 0.7284 + - 12 + - 69 + - 11, 5, 12, 6, 13, 6, 11, 5 + - 0.2553 + - 5.75 + - 0.3709 * - 4 - - 3 - - 14 - - 0, 0, 0, 3, 0, 8, 0, 3 - - 0.06383 - - 4.667 - - 1.532 + - 4 + - 16 + - 0, 4, 0, 4, 0, 5, 0, 3 + - 0.08511 + - 4 + - 1.031 * - 5 + - 11 + - 40 + - 7, 0, 6, 4, 9, 4, 7, 3 + - 0.2340 + - 3.636 + - 0.5292 + * - 6 - 8 - - 35 - - 2, 0, 0, 4, 13, 6, 8, 2 + - 20 + - 4, 0, 2, 3, 6, 3, 0, 2 - 0.1702 - - 4.375 - - 0.9558 - * - 6 - - 6 - - 23 - - 0, 0, 0, 2, 10, 6, 5, 0 - - 0.1277 - - 3.833 - - 1.223 + - 2.5 + - 0.7483 * - 7 - - 11 - - 38 - - 0, 0, 0, 3, 19, 16, 0, 0 - - 0.2340 - - 3.455 - - 1.571 + - 3 + - 6 + - 2, 0, 0, 2, 2, 0, 0, 0 + - 0.06383 + - 2 + - 1.291 -For ring 3, ``FRAC_AT_D_3`` is :math:`10/47 = 0.2128` and ``MEAN_FRAC_3`` is :math:`56/10 = 5.6`. -The wedge sums have mean :math:`56/8 = 7` and population variance :math:`208/8 = 26`, so -``RADIAL_CV_3`` is :math:`\sqrt{26}/7 = 0.7284`. +For ring 5, ``FRAC_AT_D_5`` is :math:`11/47 = 0.2340` and ``MEAN_FRAC_5`` is :math:`40/11 = 3.636`. +The wedge sums have mean :math:`40/8 = 5` and population variance :math:`56/8 = 7`, so +``RADIAL_CV_5`` is :math:`\sqrt{7}/5 = 0.5292`. The *Example* value of each feature in the :ref:`index ` is its first column, ring 0. The test suite runs this example and checks all 24 columns. @@ -245,8 +246,8 @@ Notation * - :math:`I_p` - Intensity of ROI pixel :math:`p`, in grey levels. * - :math:`C` - - The contour points: the boundary pixels the tracer returns, each moved to - :math:`(x+1, y+1)`. + - The contour points: the ROI pixels with a 4-neighbour outside the ROI, grouped by + border. * - :math:`O = (x_O, y_O)` - The centre pixel. * - :math:`R` @@ -408,7 +409,8 @@ and an empty ring, report 0. **Possible issues.** Because the sums are taken over all 8 wedges, a ring that covers only part of the circle, as rings near the edge of a non-round ROI do, reports a large value even when its -intensity is uniform: the 5 × 5 square of one intensity reports 1.183 in ring 7. CellProfiler +intensity is uniform: the 5 × 5 square of one intensity reports 1 in ring 7, whose 4 pixels, the +corners, fill 4 of the 8 wedges. CellProfiler averages wedge means over the occupied wedges only; see :ref:`radial-other-tools`. @@ -417,61 +419,44 @@ averages wedge means over the occupied wedges only; see :ref:`radial-other-tools Possible issues --------------- -The contour is one pixel off - The contour points stand one pixel to the right of and one pixel below the ROI's boundary, a - known defect (`#497 `__), so the centre is pulled down and to the right, and :math:`R` is measured to points outside the - ROI. On the worked example 9 of the 20 contour points are not ROI pixels. With the contour on - the boundary pixels themselves, the formulas of this page give the centre :math:`(4, 3)`, of - intensity 9, instead of :math:`(5, 4)`: ``MEAN_FRAC_0`` would be 9 instead of 7, ring 7 would - hold 3 pixels instead of 11, and ``RADIAL_CV_3`` would be 0.3709 instead of 0.7284. On a 3 × 3 - square with intensities 1 to 9 row by row, Nyxus takes the bottom-right corner as the centre: - ``MEAN_FRAC_0`` is 9 and the rings hold 1, 2 and 6 pixels, where the middle pixel, 5, and rings - of 1, 4 and 4 pixels would be expected. - The centre and the radius come from an approximate search - The largest and smallest distances to the contour are found by stepping along the contour list - and refining around the best step, which assumes the distance rises and falls only once along - it. Around a hole the list is two contours joined end to end, and even on a convex ROI the - distance to the boundary has more than one minimum. Against a full scan of the same contour, - the search changes the centre or the radius on 97 of 300 random ellipses, 249 of 451 random - ROIs without holes and 836 of 1020 random ROIs with holes. On a 7 × 8 ROI with a 2-pixel hole - in the example script, the search finds :math:`R^2 = 18` where the farthest contour point is at - 20, so 11 pixels instead of 8 lie beyond :math:`R`, and ``FRAC_AT_D_4`` is 0.08 instead of - 0.14. The worked example is not affected. + On a border of more than 10 pixels the largest and smallest distances to the contour are found + by stepping along the border's walk and refining around the best step, which assumes the + distance rises and falls only once along it. Even on a convex ROI the distance to the boundary + has more than one minimum. Against a full scan of the same contour, the search changes the + centre or the radius on 191 of 300 random ellipses, 75 of 108 random ROIs without holes and + 1232 of 1388 random ROIs with holes. On a 5 × 4 rectangle in the example script, the search + finds :math:`R^2 = 5` where the farthest contour point is at 8, so 2 pixels lie beyond + :math:`R`, ring 7 holds 8 pixels instead of 2, and ``FRAC_AT_D_2`` is 0 instead of 0.2. The + worked example is not affected. Ring 7 collects everything at or beyond R The ring index is scaled by 7, not 8: rings 0 to 6 share the distances below :math:`R`, and - ring 7 receives the pixels at :math:`R` or farther. Because the contour is shifted and the - search can stop short, pixels beyond :math:`R` are common, and ring 7 is often the most - populated ring: 11 of the 47 pixels on the worked example. + ring 7 receives the pixels at :math:`R` or farther. No ROI pixel lies farther from the centre + than the farthest contour pixel, so with the exact radius ring 7 holds only the pixels at + :math:`R`: 3 of the 47 pixels on the worked example. Where the search stops short, the pixels + beyond :math:`R` join them. Ties between centre candidates depend on the pixel order When several pixels share the smallest spread, the first one read wins, and an array given to - ``Nyxus.featurize`` is read in a different order from the same image read from a file. For a - 10-pixel ROI with two tied candidates, ``MEAN_FRAC_0`` is 9 for the array and 2 for the same + ``Nyxus.featurize`` is read in a different order from the same image read from a file. For an + 11-pixel ROI with two tied candidates, ``MEAN_FRAC_0`` is 9 for the array and 2 for the same image read from a file. Out-of-core ROIs Without anisotropy, an out-of-core ROI keeps its contour relative to its bounding box but its pixels in image coordinates, a known defect (`#509 `__). A ROI whose bounding box does not start at the image's first row and column gets different values: the worked example moved 2 pixels right and 3 down reports - ``FRAC_AT_D_7`` 0.3404 out-of-core instead of 0.2340, and ``MEAN_FRAC_0`` 6 instead of 7. A ROI - whose bounding box starts there is placed correctly, but the out-of-core contour can list its - points in a different order, which changes the approximate searches: the worked example at the - origin gets the in-memory values, while 2 of 15 random ellipses at the origin did not. With anisotropy an - out-of-core ROI is measured as in memory and gets the in-memory values. + ``FRAC_AT_D_7`` 0.5532 out-of-core instead of 0.06383, and ``MEAN_FRAC_0`` 3 instead of 9. A ROI + whose bounding box starts there is placed correctly, but the out-of-core contour is a single + loop that can list its points in a different order, which changes the approximate searches: + the worked example at the origin gets the in-memory values, while 2 of 15 random ellipses at + the origin did not. With anisotropy an out-of-core ROI is measured as in memory and gets the + in-memory values. Small ROIs - A ROI of 1 or 2 pixels has no contour, and all 24 columns are 0. - -Whole-slide centre on the top edge - In whole-slide mode the centre is not the middle of the image but a pixel of the top rows near - the right edge, a known defect (`#516 `__). On the four corners of the contour the - coarse-to-fine search compares only :math:`(0, 0)` and :math:`(W, H)`, so every pixel as far - from one as from the other has a spread of 0, and the first one read wins. The centre is - :math:`(19, 1)` on a 20 × 20 image, with :math:`R = 19.03` and 11 pixels in ring 0, and - :math:`(50, 0)` on a 64 × 48 image, with :math:`R = 50` and 88 pixels in ring 0; the outer - rings then cover only part of the image. + A ROI of 1 pixel has :math:`R = 0` and puts its pixel in ring 0. A ROI of 2 pixels puts one + pixel in ring 0 and the other, at :math:`R`, in ring 7. Intensity domain ``MEAN_FRAC`` is reported in grey levels, not in the image's units. An array with negative @@ -505,58 +490,58 @@ Other tools * - 0 - 0.02128 - 0.04018 - - 7 + - 9 - 1.888 - 2.646 - 0 * - 1 - 0.08511 - 0.04018 - - 6.5 + - 8.5 - 1.888 - - 1.052 + - 1.003 - 0 * - 2 - 0.08511 - 0.1429 - - 6.25 + - 7.5 - 1.679 - - 1.079 + - 1.004 - 0.08839 * - 3 - - 0.2128 + - 0.2553 - 0.2009 - - 5.6 + - 5.75 - 1.349 - - 0.7284 + - 0.3709 - 0.1545 * - 4 - - 0.06383 + - 0.08511 - 0.1161 - - 4.667 + - 4 - 1.364 - - 1.532 + - 1.031 - 0.06281 * - 5 - - 0.1702 + - 0.2340 - 0.2812 - - 4.375 + - 3.636 - 0.8812 - - 0.9558 + - 0.5292 - 0.2079 * - 6 - - 0.1277 + - 0.1702 - 0.1786 - - 3.833 + - 2.5 - 0.5595 - - 1.223 + - 0.7483 - 0.2041 * - 7 - - 0.2340 + - 0.06383 - 0 - - 3.455 + - 2 - 0 - - 1.571 + - 1.291 - 0 All 24 values differ by more than 1%. No setting makes Nyxus compute CellProfiler's @@ -572,11 +557,12 @@ definition of the rings: ``FRAC_AT_D`` sums to 1, an empty ring is 0 in all thre ``RADIAL_CV`` stays within :math:`\sqrt{7}`. CellProfiler ``MeasureObjectIntensityDistribution`` was run on the same ROI as a candidate reference and disagrees, for the reasons under :ref:`radial-other-tools`; it validates none of the features. The worked-example script asserts -the values of the worked example, the contour offset, the 5 × 5 and 3 × 3 squares, the ROI with a -hole (:math:`R^2 = 18` against 20, 11 pixels beyond :math:`R` instead of 8), the tie between -centre candidates and the out-of-core values of the moved and unmoved worked example. It does not -assert the CellProfiler values or the counts over random ROIs (search changes, tracer skips, -out-of-core differences at the origin); these were measured separately. +the values of the worked example and that the page's full-scan formulas reproduce them, the 5 × 5 +and 3 × 3 squares, the 5 × 4 rectangle (:math:`R^2 = 5` against 8, 2 pixels beyond :math:`R`), the +ROIs of 1 and 2 pixels, the tie between centre candidates, the out-of-core values of the moved and +unmoved worked example and the whole-slide rings. It does not assert the CellProfiler values or +the counts over random ROIs (search changes, out-of-core differences at the origin); these were +measured separately. References diff --git a/docs/source/Math/intensity_moments_index.inc b/docs/source/Math/intensity_moments_index.inc index 22f86e6c3..55354f812 100644 --- a/docs/source/Math/intensity_moments_index.inc +++ b/docs/source/Math/intensity_moments_index.inc @@ -309,92 +309,92 @@ - Intensity weighted raw moment (0, 0) - — - regression - - −144.7 + - −331.7 * - :ref:`IMOM_WRM_01 ` - Intensity weighted raw moment (0, 1) - — - regression - - −426.2 + - −988.6 * - :ref:`IMOM_WRM_02 ` - Intensity weighted raw moment (0, 2) - — - regression - - −1873 + - −4817 * - :ref:`IMOM_WRM_03 ` - Intensity weighted raw moment (0, 3) - — - regression - - −9595 + - −25120 * - :ref:`IMOM_WRM_10 ` - Intensity weighted raw moment (1, 0) - — - regression - - −496.8 + - −1092 * - :ref:`IMOM_WRM_11 ` - Intensity weighted raw moment (1, 1) - — - regression - - −864.2 + - −2983 * - :ref:`IMOM_WRM_12 ` - Intensity weighted raw moment (1, 2) - — - regression - - −2417 + - −14100 * - :ref:`IMOM_WRM_20 ` - Intensity weighted raw moment (2, 0) - — - regression - - −2298 + - −5135 * - :ref:`IMOM_WRM_21 ` - Intensity weighted raw moment (2, 1) - — - regression - - −3089 + - −13840 * - :ref:`IMOM_WRM_30 ` - Intensity weighted raw moment (3, 0) - — - regression - - −12390 + - −27060 * - :ref:`IMOM_WCM_02 ` - Intensity weighted central moment (0, 2) - — - regression - - −617.3 + - −1871 * - :ref:`IMOM_WCM_03 ` - Intensity weighted central moment (0, 3) - — - regression - - −440.8 + - 388.9 * - :ref:`IMOM_WCM_11 ` - Intensity weighted central moment (1, 1) - — - regression - - 599.2 + - 270.9 * - :ref:`IMOM_WCM_12 ` - Intensity weighted central moment (1, 2) - — - regression - - 483.1 + - 137.7 * - :ref:`IMOM_WCM_20 ` - Intensity weighted central moment (2, 0) - — - regression - - −592 + - −1542 * - :ref:`IMOM_WCM_21 ` - Intensity weighted central moment (2, 1) - — - regression - - −434.8 + - −312.9 * - :ref:`IMOM_WCM_30 ` - Intensity weighted central moment (3, 0) - — - regression - - −434.1 + - −7.447 * - :ref:`IMOM_WNCM_02 ` - Intensity weighted normalized central moment (0, 2) - — - regression - - −0.02949 + - −0.01701 * - :ref:`IMOM_WNCM_03 ` - Intensity weighted normalized central moment (0, 3) - — @@ -404,7 +404,7 @@ - Intensity weighted normalized central moment (1, 1) - — - regression - - 0.02863 + - 0.002463 * - :ref:`IMOM_WNCM_12 ` - Intensity weighted normalized central moment (1, 2) - — @@ -414,7 +414,7 @@ - Intensity weighted normalized central moment (2, 0) - — - regression - - −0.02828 + - −0.01402 * - :ref:`IMOM_WNCM_21 ` - Intensity weighted normalized central moment (2, 1) - — @@ -429,12 +429,12 @@ - Intensity weighted Hu invariant 1 - — - regression - - −0.05777 + - −0.03103 * - :ref:`IMOM_WHU2 ` - Intensity weighted Hu invariant 2 - — - regression - - 0.003279 + - 3.318e−05 * - :ref:`IMOM_WHU3 ` - Intensity weighted Hu invariant 3 - — diff --git a/docs/source/Math/radial_index.inc b/docs/source/Math/radial_index.inc index 1e22f7ca7..715e912a0 100644 --- a/docs/source/Math/radial_index.inc +++ b/docs/source/Math/radial_index.inc @@ -19,7 +19,7 @@ - Mean intensity in a ring - — - regression - - 7 + - 9 * - :ref:`RADIAL_CV ` - Coefficient of variation across the wedges of a ring - — diff --git a/docs/source/Math/roi_radius_index.inc b/docs/source/Math/roi_radius_index.inc index d95f00a99..1bc152bd0 100644 --- a/docs/source/Math/roi_radius_index.inc +++ b/docs/source/Math/roi_radius_index.inc @@ -14,7 +14,7 @@ - Mean distance to the contour - — - regression - - 1.124 + - 0.9288 * - :ref:`ROI_RADIUS_MAX ` - Largest distance to the contour - — diff --git a/docs/source/Math/shape_moments_index.inc b/docs/source/Math/shape_moments_index.inc index 7dfac83d0..5d2c0f546 100644 --- a/docs/source/Math/shape_moments_index.inc +++ b/docs/source/Math/shape_moments_index.inc @@ -309,92 +309,92 @@ - Weighted raw moment (0, 0) - — - regression - - −42.77 + - −95.4 * - :ref:`WEIGHTED_SPAT_MOMENT_01 ` - Weighted raw moment (0, 1) - — - regression - - −113.6 + - −296 * - :ref:`WEIGHTED_SPAT_MOMENT_02 ` - Weighted raw moment (0, 2) - — - regression - - −473.2 + - −1415 * - :ref:`WEIGHTED_SPAT_MOMENT_03 ` - Weighted raw moment (0, 3) - — - regression - - −2419 + - −7333 * - :ref:`WEIGHTED_SPAT_MOMENT_10 ` - Weighted raw moment (1, 0) - — - regression - - −170.2 + - −360.2 * - :ref:`WEIGHTED_SPAT_MOMENT_11 ` - Weighted raw moment (1, 1) - — - regression - - −298.8 + - −1007 * - :ref:`WEIGHTED_SPAT_MOMENT_12 ` - Weighted raw moment (1, 2) - — - regression - - −795.9 + - −4467 * - :ref:`WEIGHTED_SPAT_MOMENT_20 ` - Weighted raw moment (2, 0) - — - regression - - −860.8 + - −1864 * - :ref:`WEIGHTED_SPAT_MOMENT_21 ` - Weighted raw moment (2, 1) - — - regression - - −1315 + - −5023 * - :ref:`WEIGHTED_SPAT_MOMENT_30 ` - Weighted raw moment (3, 0) - — - regression - - −4942 + - −10600 * - :ref:`WEIGHTED_CENTRAL_MOMENT_02 ` - Weighted central moment (0, 2) - — - regression - - −171.6 + - −496.9 * - :ref:`WEIGHTED_CENTRAL_MOMENT_03 ` - Weighted central moment (0, 3) - — - regression - - −251.6 + - 141.3 * - :ref:`WEIGHTED_CENTRAL_MOMENT_11 ` - Weighted central moment (1, 1) - — - regression - - 153.2 + - 110.8 * - :ref:`WEIGHTED_CENTRAL_MOMENT_12 ` - Weighted central moment (1, 2) - — - regression - - 274.1 + - 190 * - :ref:`WEIGHTED_CENTRAL_MOMENT_20 ` - Weighted central moment (2, 0) - — - regression - - −183.1 + - −504.2 * - :ref:`WEIGHTED_CENTRAL_MOMENT_21 ` - Weighted central moment (2, 1) - — - regression - - −248.6 + - −74.72 * - :ref:`WEIGHTED_CENTRAL_MOMENT_30 ` - Weighted central moment (3, 0) - — - regression - - −57.93 + - 244.2 * - :ref:`WT_NORM_CTR_MOM_02 ` - Weighted normalized central moment (0, 2) - — - regression - - −0.09385 + - −0.05459 * - :ref:`WT_NORM_CTR_MOM_03 ` - Weighted normalized central moment (0, 3) - — @@ -404,7 +404,7 @@ - Weighted normalized central moment (1, 1) - — - regression - - 0.08376 + - 0.01217 * - :ref:`WT_NORM_CTR_MOM_12 ` - Weighted normalized central moment (1, 2) - — @@ -414,7 +414,7 @@ - Weighted normalized central moment (2, 0) - — - regression - - −0.1001 + - −0.05539 * - :ref:`WT_NORM_CTR_MOM_21 ` - Weighted normalized central moment (2, 1) - — @@ -429,12 +429,12 @@ - Weighted Hu invariant 1 - — - regression - - −0.194 + - −0.11 * - :ref:`WEIGHTED_HU_M2 ` - Weighted Hu invariant 2 - — - regression - - 0.0281 + - 0.0005931 * - :ref:`WEIGHTED_HU_M3 ` - Weighted Hu invariant 3 - — diff --git a/docs/source/devguide.rst b/docs/source/devguide.rst index 4e3e9b48e..a79525363 100644 --- a/docs/source/devguide.rst +++ b/docs/source/devguide.rst @@ -822,8 +822,13 @@ The workflow fills the scan fields before any feature runs; features then read t * - ``SimpleCube aux_image_cube`` - In-RAM 3D: the ROI's bounding-box volume. * - ``std::vector> multicontour_`` - - The ROI's contours, written by ``ContourFeature``. ``merge_multicontour`` flattens them. - Depend on ``PERIMETER`` to have it. + - The ROI's borders, written by ``ContourFeature``: one closed walk per outer border of each + fragment and per hole, with ``multicontour_hole_`` marking the holes. Depend on + ``PERIMETER`` to have it. + * - ``std::vector contour_pixels_``, ``std::vector contour_border_ends_`` + - The contour pixels, each once, border after border, and where each border's run ends. + ``ContourFeature`` fills them once per ROI with ``merge_multicontour()``; a feature reads + them instead of merging the borders itself. * - ``std::vector convHull_CH`` - The convex hull, written by ``ConvexHullFeature``. Depend on ``CONVEX_HULL_AREA`` to have it. * - ``std::vector aux_neighboring_labels`` diff --git a/docs/source/featurelist.rst b/docs/source/featurelist.rst index 3aac35c8a..b10c1d2cf 100644 --- a/docs/source/featurelist.rst +++ b/docs/source/featurelist.rst @@ -378,8 +378,8 @@ Definitions, formulas and settings: :doc:`/Math/f_morphology`. - Description - Validation * - ``PERIMETER`` - - Length of the ROI's traced contour in pixels: the sum of the Euclidean distances between consecutive contour pixels, closing back to the first one. A hole or a second fragment appends its loop to the same list, and the jumps between loops are counted too. On the out-of-core path it is the number of contour pixels instead. - - vetted: scikit-image + - Length of the ROI's traced contour in pixels: the sum of the Euclidean distances between consecutive pixels of the closed walk around each border of the ROI (the outside of each separate part and each hole), each walk closing back to its first pixel. On the out-of-core path it is the number of contour pixels instead. + - vetted: analytic, OpenCV, scikit-image * - ``DIAMETER_EQUAL_PERIMETER`` - Diameter of a circle with the same perimeter as the ROI: PERIMETER divided by pi, in pixels. - vetted: IMEA @@ -397,7 +397,7 @@ Definitions, formulas and settings: :doc:`/Math/f_morphology`. - vetted: CellProfiler * - ``EDGE_INTEGRATED_INTENSITY`` - Sum of the intensities of the ROI's contour pixels. - - vetted: CellProfiler + - vetted: analytic, CellProfiler .. _features-convex-hull: @@ -483,10 +483,10 @@ Definitions, formulas and settings: :doc:`/Math/f_morphology`. - Diameter in pixels of the circle that Welzl's algorithm fits to the ROI's contour pixels, with chord midpoints truncated to whole pixels (known defect, https://github.com/PolusAI/nyxus/issues/498); it approximates the smallest enclosing circle and can come out about a pixel larger or smaller than it. - vetted: IMEA * - ``DIAMETER_CIRCUMSCRIBING_CIRCLE`` - - Twice the largest distance from the ROI's centroid to its contour pixels, in pixels. The contour is taken one pixel down and to the right of the ROI and the centroid one pixel up and to the left (known defect, https://github.com/PolusAI/nyxus/issues/497). + - Twice the largest distance from the ROI's centroid to the pixels of its outer borders, in pixels; the borders of holes are left out. - regression * - ``DIAMETER_INSCRIBING_CIRCLE`` - - Twice the smallest distance from the ROI's centroid to its contour pixels, in pixels. The contour is taken one pixel down and to the right of the ROI and the centroid one pixel up and to the left (known defect, https://github.com/PolusAI/nyxus/issues/497). + - Twice the smallest distance from the ROI's centroid to the pixels of its outer borders, in pixels; the borders of holes are left out. - regression .. _features-roi-radius: @@ -504,13 +504,13 @@ Definitions, formulas and settings: :doc:`/Math/f_morphology`. - Description - Validation * - ``ROI_RADIUS_MEAN`` - - Mean over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497) + - Mean over ROI pixels of the distance from a pixel to the nearest contour pixel, in pixels; contour pixels are at distance 0. - regression * - ``ROI_RADIUS_MAX`` - - Maximum over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497) + - Maximum over ROI pixels of the distance from a pixel to the nearest contour pixel, in pixels: the radius of the largest disk inside the ROI, measured from a pixel centre. - vetted: analytic, scikit-image * - ``ROI_RADIUS_MEDIAN`` - - Median over ROI pixels of the distance from a pixel to the nearest contour pixel, with the contour taken one pixel down and to the right of the ROI (known defect, https://github.com/PolusAI/nyxus/issues/497) + - Median over ROI pixels of the distance from a pixel to the nearest contour pixel, in pixels. - vetted: scikit-image .. _features-extrema: @@ -1482,7 +1482,7 @@ Definitions, formulas and settings: :doc:`/Math/f_ngtdm`. Shape moments ^^^^^^^^^^^^^ -Geometric moments of the ROI's shape: every ROI pixel counts as 1. The two digits of a code are the powers of x and y. The weighted moments weight each pixel by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour (taken one pixel down and to the right of the ROI), found by a sampled search that can overestimate it. +Geometric moments of the ROI's shape: every ROI pixel counts as 1. The two digits of a code are the powers of x and y. The weighted moments weight each pixel by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour, found by a sampled search that can overestimate it. Definitions, formulas and settings: :doc:`/Math/f_2dmoments`. @@ -1769,7 +1769,7 @@ Definitions, formulas and settings: :doc:`/Math/f_2dmoments`. Intensity moments ^^^^^^^^^^^^^^^^^ -The same moments with each pixel weighted by its intensity, in grey levels. The two digits of a code are the powers of x and y. The weighted moments multiply each intensity by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour (taken one pixel down and to the right of the ROI), found by a sampled search that can overestimate it. +The same moments with each pixel weighted by its intensity, in grey levels. The two digits of a code are the powers of x and y. The weighted moments multiply each intensity by ln(d + 0.001), where d is an approximate distance from the pixel to the traced ROI contour, found by a sampled search that can overestimate it. Definitions, formulas and settings: :doc:`/Math/f_2dmoments`. diff --git a/docs/source/large_images.rst b/docs/source/large_images.rst index 8b537df14..522171169 100644 --- a/docs/source/large_images.rst +++ b/docs/source/large_images.rst @@ -71,8 +71,8 @@ Whole-slide features differ from those of an ordinary ROI in these ways: the image, so ``PERIMETER`` is 2 x (width + height): 224 for a 64 x 48 image. Every feature built on the contour describes this rectangle: circularity, the perimeter-equivalent diameter, geodetic length and thickness, the ROI radii, fractal dimension, radial distribution and the - weighted moments. The radial distribution's centre then lies on the image's top edge, a known - defect (`#516 `__). + weighted moments. The radial distribution's centre is the pixel nearest the middle of the four + corners. * **Edge features are degenerate.** The edge is the four corners, and each corner carries the image's maximum grey level. ``EDGE_MIN_INTENSITY`` and ``EDGE_MAX_INTENSITY`` both equal ``MAX``, ``EDGE_STDDEV_INTENSITY`` is 0 and ``EDGE_INTEGRATED_INTENSITY`` is 4 x ``MAX``. @@ -187,7 +187,7 @@ on an image that Nyxus reads as one tile (see :ref:`large-images-tiles` for othe texture exceptions: in IBSI mode 2D NGTDM matches only on the first ROI computed out of core, a known defect (`#512 `__; see :doc:`Math/f_ngtdm`), and NGLDM differs on a constant ROI (see :doc:`Math/f_ngldm`). The contour-based features, the weighted moments and the fractal dimension do not. On a 256 x 256 test -image with 66 ROIs, 116 of the 747 output columns of ``*ALL*`` differ when every ROI is computed +image with 66 ROIs, 115 of the 747 output columns of ``*ALL*`` differ when every ROI is computed out of core. The rest of this section describes runs without anisotropy. With ``--anisox`` or ``--anisoy`` @@ -201,7 +201,7 @@ The out-of-core contour differs from the in-memory one in three ways, a known de keeps the first loop it traces and drops holes and further fragments. Its coordinates are relative to the ROI's bounding box, while the pixels keep their image positions. And its pixels come in a different order. On a 340 x 340 image, a 300 x 300 square with a 100 x 100 hole has ``PERIMETER`` -1196 out of core, the outer loop only, against 1876 in memory, and the same square cut into two +1196 out of core, the outer loop only, against 1597.7 in memory, and the same square cut into two 130-pixel-wide bars has ``DIAMETER_MIN_ENCLOSING_CIRCLE`` 325.6 against 422.9. .. list-table:: @@ -220,22 +220,27 @@ different order. On a 340 x 340 image, a 300 x 300 square with a 100 x 100 hole (``WEIGHTED_*``, ``WT_NORM_CTR_MOM_*``, ``IMOM_W*``) - Without anisotropy, wrong for ROIs away from the image origin, because pixel-to-contour distances are off by - the bounding box's offset. ``ROI_RADIUS_MAX`` was 28 times its in-memory value on the + the bounding box's offset. ``ROI_RADIUS_MAX`` was 29 times its in-memory value on the median ROI, and the weighted moments of one ROI change as it moves in the image. A bounding box that starts at pixel (0, 0) is necessary for the in-memory values but not - sufficient: a ROI with holes or fragments still differs, and the radial distribution - can differ through the contour order (2 of 15 random ellipses at the origin). + sufficient. The circles are measured to the outer border on both paths, so a hole leaves + them unchanged, but ``ROI_RADIUS_*`` and the weighted moments of a ROI with a hole differ, + and its radial distribution can, since in memory the centre and the radius are searched + over every border, the hole's included (19 of 40 random squares with holes at the origin; + a 9 x 9 square with a centred hole does not). A ROI in several fragments differs in all of + these features, and the radial distribution can also differ through the contour order (2 + of 15 random ellipses at the origin). * - ``EDGE_*``, ``DIAMETER_MIN_ENCLOSING_CIRCLE`` - Differ where the out-of-core contour holds other pixels than the in-memory one: the - single loop leaves out holes and fragments, and on some ROIs either tracer skips part of - the outline. ``DIAMETER_MIN_ENCLOSING_CIRCLE`` also changes with the contour order. On the - 66-ROI test image, which has no holes or fragments, they differ on 5 and 6 ROIs. + single loop leaves out holes and fragments, and on some ROIs the out-of-core tracer skips + part of the outline. ``DIAMETER_MIN_ENCLOSING_CIRCLE`` also changes with the contour order. + On the 66-ROI test image, which has no holes or fragments, they differ on 10 and 17 ROIs. * - ``FRACT_DIM_BOXCOUNT``, ``FRACT_DIM_PERIMETER`` - Box counting uses a single unshifted grid for every out-of-core ROI, so ROIs whose padded side is 32 or less can read differently (the fractal dimension worked example gives 1.3966 instead of 1.3732). The divider walks the out-of-core contour, which holds one loop in a different order, so it can differ too. ``FRACT_DIM_BOXCOUNT`` differs on 38 of - the 66 ROIs and ``FRACT_DIM_PERIMETER`` on 11. + the 66 ROIs and ``FRACT_DIM_PERIMETER`` on 17. * - Neighbor features (``NUM_NEIGHBORS``, ``PERCENT_TOUCHING``, ``CLOSEST_NEIGHBOR*``, ``ANG_BW_NEIGHBORS_*``), ``POLYGONALITY_AVE``, ``HEXAGONALITY_*`` - Not computed. The neighbor features are reported as 0, and ``POLYGONALITY_AVE`` and @@ -294,7 +299,7 @@ ROIs, 30 of them crossing 64 x 64 tile borders, gave the same features tiled and The contour is traced from the misplaced pixels as well, so the contour features are wrong even for a ROI whose bounding box starts at pixel (0, 0). On a plus-shaped ROI of two 20-pixel bars spanning the image, stored in 64 x 64 tiles, - ``ROI_RADIUS_MAX`` out of core was 310.7 on a 256 x 256 image, where the in-memory value is + ``ROI_RADIUS_MAX`` out of core was 312.1 on a 256 x 256 image, where the in-memory value is 13.45, and ``PERIMETER``, which out of core counts contour pixels, was 144 where the contour has 1016 pixels; ``MEAN`` matched. On a 100 x 100 image the same ROI raised the error above. Stored striped (the images are at most 256 x 256 pixels, so they are read as one tile), the same ROI gave the in-memory ``ROI_RADIUS_*`` values diff --git a/docs/source/validation.rst b/docs/source/validation.rst index 2b44f5018..cd6421d91 100644 --- a/docs/source/validation.rst +++ b/docs/source/validation.rst @@ -122,9 +122,11 @@ Where Nyxus deliberately or knowingly computes something other than a reference family page says so in its *Possible issues* section, with the size of the difference. The main ones are: -* Morphology: traced contours are offset by one pixel, a known defect (`#497 `__), which affects the contour-based radii and - circle diameters. See :ref:`morphology-possible-issues`. -* Moments: the weighted moments use an approximate distance search and the offset contour. See +* Morphology: the minimum enclosing circle truncates chord midpoints, a known defect + (`#498 `__), and the Feret statistics count one + direction twice, a known defect (`#500 `__). See + :ref:`morphology-possible-issues`. +* Moments: the weighted moments use an approximate distance search. See :ref:`moments-possible-issues`. * GLCM: the matrix is asymmetric by default, so several features differ from PyRadiomics and MIRP unless IBSI mode is on. See :ref:`glcm-possible-issues`. diff --git a/src/nyx/cache.cpp b/src/nyx/cache.cpp index f5d1c9010..adc4d3a95 100644 --- a/src/nyx/cache.cpp +++ b/src/nyx/cache.cpp @@ -1,24 +1,23 @@ #ifdef USE_GPU +#include #include "cache.h" #include "roi_cache.h" -#define OK(x) if (x == false) \ -{ \ - std::cerr << "gpu cache related error in " << __FILE__ << ":" << __LINE__; \ - return false; \ -} \ - #define OKDETL(x,detl) if (x == false) \ { \ return std::string("error due to ") + detl + " at " + __FILE__ + ":" + std::to_string(__LINE__); \ } \ -#define OKV(x) if (x == false) \ -{ \ - std::cerr << "gpu cache related error in " << __FILE__ << ":" << __LINE__; \ - return; \ -} \ +namespace +{ + // Border ends the contour buffers hold: one per border, at most the bound the caller gives and, + // since every border holds a contour pixel, at most the contour buffer's capacity + size_t kontur_borders_capacity (size_t roi_kontur_borders, size_t kontur_capacity) + { + return (std::max) ((std::min) (roi_kontur_borders, kontur_capacity), size_t(1)); + } +} size_t GpusideCache::ram_comsumption_szb ( @@ -28,6 +27,7 @@ bool needMoments, size_t roi_cloud_len, size_t roi_kontur_cloud_len, + size_t roi_kontur_borders, size_t n_rois, size_t roi_w, size_t roi_h, @@ -40,7 +40,9 @@ size_t szb_kon = 0; if (needContour) { - szb_kon = GpuCache::/*konturs.*/szb(roi_kontur_cloud_len, n_rois); + // the contour pixels, and where each border's run of them ends, one end per border + szb_kon = GpuCache::/*konturs.*/szb(roi_kontur_cloud_len, n_rois) + + GpuCache::/*kontur_borders.*/szb(kontur_borders_capacity(roi_kontur_borders, roi_cloud_len), n_rois); } size_t szb_mom_ri = 0, @@ -85,6 +87,7 @@ // out GpuCache& clouds, // geo moments GpuCache& konturs, + GpuCache& kontur_borders, RealPixIntens** realintens, double** prereduce, GpuCache& intermediate, @@ -104,6 +107,7 @@ bool needMoments, size_t roi_cloud_len, size_t roi_kontur_cloud_len, + size_t roi_kontur_borders, size_t n_rois, size_t roi_area, size_t roi_w, @@ -111,10 +115,10 @@ int n_gabor_filters, int gabor_ker_side) { - using_contour = - using_erosion = - using_gabor = - using_moments = false; + // the cache as an earlier run left it, every buffer that is set: a run that failed may not have + // freed it, and a buffer kept would have the size that run gave it + OKDETL(free_gpu_cache(clouds, konturs, kontur_borders, *realintens, *prereduce, intermediate, *devicereduce_buf, *imat1, *imat2, + gabor_linear_image, gabor_result, gabor_linear_kernel, gabor_energy_image), "freeing the previous GPU cache"); //****** plan GPU memory size_t amt = 0; @@ -131,6 +135,7 @@ needMoments, ccl0, roi_kontur_cloud_len, + roi_kontur_borders, n_rois, roi_w, roi_h, n_gabFilters, gabor_ker_side); batch_len = n_rois; @@ -151,7 +156,7 @@ needErosion, needGabor, needMoments, - ccl, ccl, try_nrois, roi_w, roi_h, n_gabFilters, gabor_ker_side); + ccl, ccl, roi_kontur_borders, try_nrois, roi_w, roi_h, n_gabFilters, gabor_ker_side); if (critAmt > try_szb) { @@ -169,73 +174,84 @@ size_t batch_roi_cloud_len = roi_area * batch_len; - //****** allocate + //****** allocate: on any failure what was allocated is freed, so the cache is either ready or empty - // ROI clouds (always on) - - OKDETL(clouds.clear(), "clouds.clear()"); - OKDETL(clouds.alloc(batch_roi_cloud_len, batch_len), "clouds.alloc()"); + auto allocate_buffers = [&] () -> std::optional + { + // ROI clouds (always on) - // contours + OKDETL(clouds.alloc(batch_roi_cloud_len, batch_len), "clouds.alloc()"); - if (needContour) - { - using_contour = true; + // contours - OKDETL(konturs.clear(), "konturs.clear()"); - OKDETL(konturs.alloc(batch_roi_cloud_len, batch_len), "konturs.alloc()"); - } + if (needContour) + { + OKDETL(konturs.alloc(batch_roi_cloud_len, batch_len), "konturs.alloc()"); - // moments + // one end per border, as many as the plan counts + OKDETL(kontur_borders.alloc(kontur_borders_capacity(roi_kontur_borders, batch_roi_cloud_len), batch_len), "kontur_borders.alloc()"); + } - if (needMoments) - { - using_moments = true; + // moments - // moments / real intensities - OKDETL(allocate_on_device((void**)realintens, sizeof(RealPixIntens) * batch_roi_cloud_len), "allocate_on_device(moments1)"); + if (needMoments) + { + // moments / real intensities + OKDETL(allocate_on_device((void**)realintens, sizeof(RealPixIntens) * batch_roi_cloud_len), "allocate_on_device(moments1)"); - // moments / pre-reduce - OKDETL(allocate_on_device((void**)prereduce, sizeof(double) * batch_roi_cloud_len * 16), "allocate_on_device(moments2)"); // 16 is the max number of simultaneous totals calculated by a kernel, e.g. RM00-33 + // moments / pre-reduce + OKDETL(allocate_on_device((void**)prereduce, sizeof(double) * batch_roi_cloud_len * 16), "allocate_on_device(moments2)"); // 16 is the max number of simultaneous totals calculated by a kernel, e.g. RM00-33 - // moments / intermediate - OKDETL(intermediate.alloc(GpusideState::__COUNT__, batch_len), "allocate_on_device(moments3)"); + // moments / intermediate + OKDETL(intermediate.alloc(GpusideState::__COUNT__, batch_len), "allocate_on_device(moments3)"); - // moments / CUB DeviceReduce's temp buffer - OKDETL(devicereduce_evaluate_buffer_szb(devicereduce_buf_szb, batch_roi_cloud_len), "allocate_on_device(moments4)"); - OKDETL(allocate_on_device((void**)devicereduce_buf, devicereduce_buf_szb), "allocate_on_device(moments5)"); - } + // moments / CUB DeviceReduce's temp buffer + OKDETL(devicereduce_evaluate_buffer_szb(devicereduce_buf_szb, batch_roi_cloud_len), "allocate_on_device(moments4)"); + OKDETL(allocate_on_device((void**)devicereduce_buf, devicereduce_buf_szb), "allocate_on_device(moments5)"); + } - // erosion / image matrices 1 and 2 + // erosion / image matrices 1 and 2 - if (needErosion) - { - using_erosion = true; + if (needErosion) + { + // imat1 is shared by erosion and Gabor: allocated by whichever of the two comes first + if (!*imat1) + OKDETL(allocate_on_device((void**)imat1, sizeof(imat1[0]) * roi_w * roi_h), "allocate_on_device(eros1)"); - // imat1 is shared by erosion and Gabor - if (!*imat1) - OKDETL(allocate_on_device((void**)imat1, sizeof(imat1[0]) * roi_w * roi_h), "allocate_on_device(eros1)"); + OKDETL(allocate_on_device((void**)imat2, sizeof(imat2[0]) * roi_w * roi_h), "allocate_on_device(eros2)"); + } - OKDETL(allocate_on_device((void**)imat2, sizeof(imat2[0]) * roi_w * roi_h), "allocate_on_device(eros2)"); - } +#ifdef NYXUS_GPU_TEST_HOOKS + if (test_fail_before_gabor) + return std::string("allocation stopped before the Gabor buffers, as a test asked"); +#endif - // Gabor + // Gabor - if (needGabor) - { - using_gabor = true; + if (needGabor) + { + // imat1 is shared by erosion and Gabor + if (!*imat1) + OKDETL(allocate_on_device((void**)imat1, sizeof(imat1[0]) * roi_w * roi_h), "allocate_on_device(gabor)"); + + size_t gabTotlen = (roi_w + gabor_ker_side - 1) * (roi_h + gabor_ker_side - 1) * n_gabFilters; + OKDETL(gabor_linear_image.alloc(gabTotlen, 1), "gabor_linear_image.alloc()"); + OKDETL(gabor_result.alloc(gabTotlen, 1), "gabor_result.alloc()"); + OKDETL(gabor_linear_kernel.alloc(gabTotlen, 1), "gabor_linear_kernel.alloc()"); + OKDETL(gabor_energy_image.alloc(gabTotlen, 1), "gabor_energy_image.alloc()"); + } - // imat1 is shared by erosion and Gabor - if (!*imat1) - OKDETL(allocate_on_device((void**)imat1, sizeof(imat1[0]) * roi_w * roi_h), "allocate_on_device(gabor)"); + return std::nullopt; + }; - size_t gabTotlen = (roi_w + gabor_ker_side - 1) * (roi_h + gabor_ker_side - 1) * n_gabFilters; - OKDETL(gabor_linear_image.alloc(gabTotlen, 1), "gabor_linear_image.alloc()"); - OKDETL(gabor_result.alloc(gabTotlen, 1), "gabor_result.alloc()"); - OKDETL(gabor_linear_kernel.alloc(gabTotlen, 1), "gabor_linear_kernel.alloc()"); - OKDETL(gabor_energy_image.alloc(gabTotlen, 1), "gabor_energy_image.alloc()"); + if (auto err = allocate_buffers()) + { + free_gpu_cache(clouds, konturs, kontur_borders, *realintens, *prereduce, intermediate, *devicereduce_buf, *imat1, *imat2, + gabor_linear_image, gabor_result, gabor_linear_kernel, gabor_energy_image); + return err; } + using_contour = needContour; return std::nullopt; } @@ -243,6 +259,7 @@ // out GpuCache& clouds, GpuCache& konturs, + GpuCache& kontur_borders, RealPixIntens** realintens, double** prereduce, GpuCache& intermediate, @@ -254,45 +271,20 @@ size_t batch_offset, size_t batch_len) { - //***** ROI clouds - - // stats of ROI cloud sizes - size_t totCloLen = 0, maxLen = 0; - for (size_t i = batch_offset; i < batch_len; i++) - { - int lbl = labels[i]; - LR& roi = roi_data[lbl]; - auto n = roi.raw_pixels.size(); - totCloLen += n; - maxLen = (std::max)(maxLen, n); - } - - //****** contours - - if (using_contour) - { - // stats of ROI cloud sizes - size_t totKontLen = 0; - for (size_t i = batch_offset; i < batch_len; i++) - { - int lbl = labels[i]; - LR& roi = roi_data[lbl]; - - std::vector K; - roi.merge_multicontour(K); - - totKontLen += K.size(); - } - } + // The batch is ROIs batch_offset .. batch_offset + batch_len - 1. A failure throws: the GPU-side + // features would otherwise read buffers this batch left unfilled. //****** clouds as a solid buffer size_t off = 0; size_t roiIdx = 0; - for (size_t i = batch_offset; i < batch_len; i++) + for (size_t i = batch_offset; i < batch_offset + batch_len; i++) { int lbl = labels[i]; LR& roi = roi_data[lbl]; + if (! clouds.hobuffer || off + roi.raw_pixels.size() > clouds.total_len) + throw std::runtime_error ("ROI " + std::to_string(lbl) + ": the batch's pixels exceed the GPU cloud buffer"); + clouds.ho_lengths[roiIdx] = roi.raw_pixels.size(); clouds.ho_offsets[roiIdx] = off; roiIdx++; @@ -314,16 +306,30 @@ { off = 0; roiIdx = 0; - for (size_t i = batch_offset; i < batch_len; i++) + size_t offEnds = 0; + for (size_t i = batch_offset; i < batch_offset + batch_len; i++) { int lbl = labels[i]; LR& roi = roi_data[lbl]; - std::vector K; - roi.merge_multicontour(K); + const std::vector& K = roi.contour_pixels_; + const std::vector& ends = roi.contour_border_ends_; + + // the kernels read the borders as the CPU searches do (BorderedContour), and a buffer + // sized by the memory plan holds them only if every border holds a contour pixel + if (!border_ends_cover (K.size(), ends)) + throw std::runtime_error ("ROI " + std::to_string(lbl) + ": contour border ends do not cover its " + std::to_string(K.size()) + " contour pixels"); + if (! konturs.hobuffer || ! kontur_borders.hobuffer || off + K.size() > konturs.total_len || offEnds + ends.size() > kontur_borders.total_len) + throw std::runtime_error ("ROI " + std::to_string(lbl) + ": the batch's contours exceed the GPU contour buffers"); konturs.ho_lengths[roiIdx] = K.size(); konturs.ho_offsets[roiIdx] = off; + + // where each border's run ends, relative to the ROI's first contour pixel + kontur_borders.ho_offsets[roiIdx] = offEnds; + kontur_borders.ho_lengths[roiIdx] = ends.size(); + for (size_t end : ends) + kontur_borders.hobuffer[offEnds++] = end; roiIdx++; // save to buffer fixing pixel coordinates @@ -336,80 +342,63 @@ konturs.hobuffer[off++] = p; } } + + // only the batch's ends, not the whole capacity + if (!kontur_borders.upload (offEnds)) + throw std::runtime_error ("uploading the contour border ends to the GPU failed"); } //****** upload - OKV(clouds.upload()); - OKV(konturs.upload()); + if (!clouds.upload() || (using_contour && !konturs.upload())) + throw std::runtime_error ("uploading the batch's ROI clouds or contours to the GPU failed"); } bool GpusideCache::free_gpu_cache( GpuCache& clouds, GpuCache& konturs, + GpuCache& kontur_borders, RealPixIntens*& realintens, double*& prereduce, GpuCache& intermediate, void*& tempstorage, - PixIntens* imat1, - PixIntens* imat2, + PixIntens*& imat1, + PixIntens*& imat2, GpuCache & gabor_linear_image, GpuCache & gabor_result, GpuCache & gabor_linear_kernel, GpuCache & gabor_energy_image) { - // clouds - - OK(clouds.clear()); - - - // contour - - if (using_contour) - { - OK(konturs.clear()); - } - - // moments - - if (using_moments) - { - OK(gpu_delete(realintens)); - OK(gpu_delete(prereduce)); - OK(intermediate.clear()); - OK(gpu_delete(tempstorage)); - realintens = nullptr; - prereduce = nullptr; - OK(intermediate.clear()); - tempstorage = nullptr; // devicereduce's temp storage - } - - // erosion or Gabor - if (using_erosion || using_gabor) - { - OK(gpu_delete(imat1)); - imat1 = nullptr; - } - - // erosion - - if (using_erosion) + // Every buffer that is set, whichever families the allocation that set it needed, is freed and + // nulled; the cache is then empty and not ready + auto free_device = [] (auto& devptr) { - OK(gpu_delete(imat2)); - imat2 = nullptr; - } - - // Gabor - - if (using_gabor) - { - OK(gabor_linear_image.clear()); - OK(gabor_result.clear()); - OK(gabor_linear_kernel.clear()); - OK(gabor_energy_image.clear()); - } - - return true; + if (! devptr) + return true; + bool ok = gpu_delete(devptr); + devptr = nullptr; + return ok; + }; + + // every free runs, whether or not an earlier one failed + bool ok = clouds.clear(); + ok = konturs.clear() && ok; + ok = kontur_borders.clear() && ok; + ok = free_device(realintens) && ok; + ok = free_device(prereduce) && ok; + ok = intermediate.clear() && ok; + ok = free_device(tempstorage) && ok; // devicereduce's temp storage + ok = free_device(imat1) && ok; + ok = free_device(imat2) && ok; + ok = gabor_linear_image.clear() && ok; + ok = gabor_result.clear() && ok; + ok = gabor_linear_kernel.clear() && ok; + ok = gabor_energy_image.clear() && ok; + + using_contour = false; + if (!ok) + std::cerr << "gpu cache related error in " << __FILE__ << ":" << __LINE__ << ": freeing the GPU cache\n"; + return ok; } #ifdef USE_GPU @@ -420,6 +409,7 @@ // out gpu_roiclouds_2d, gpu_roicontours_2d, + gpu_roicontourborders_2d, &dev_realintens, &dev_prereduce, gpu_featurestatebuf, diff --git a/src/nyx/cache.h b/src/nyx/cache.h index 0139db038..257f306df 100644 --- a/src/nyx/cache.h +++ b/src/nyx/cache.h @@ -96,11 +96,12 @@ class GpuCache size_t* ho_lengths = nullptr; size_t* ho_offsets = nullptr; size_t* dev_offsets = nullptr; - size_t total_len, num_rois; + size_t total_len = 0, num_rois = 0; bool alloc(size_t total_len) { OK(alloc(total_len, 1)); return true; }; bool alloc(size_t total_len, size_t num_rois); static size_t szb(size_t total_len, size_t num_rois); bool upload(); + bool upload(size_t n_items); // the first n_items of the buffer, and every ROI's offset bool download(); bool clear(); ~GpuCache() { clear(); } @@ -115,7 +116,8 @@ class GpusideCache /*--extern--*/ GpuCache gpu_featurestatebuf; // n_rois * GeomomentsState::__COUNT__ /*--extern--*/ GpuCache gpu_roiclouds_2d; // continuous ROI clouds - /*--extern--*/ GpuCache gpu_roicontours_2d; // continuous ROI contours + /*--extern--*/ GpuCache gpu_roicontours_2d; // continuous ROI contours (LR::contour_pixels_) + /*--extern--*/ GpuCache gpu_roicontourborders_2d; // per ROI, where each border's run of its contour ends (LR::contour_border_ends_); sized by the memory plan's bound on a batch's borders // helper buffers of 2D moments features /*--extern--*/ RealPixIntens* dev_realintens = nullptr; // max cloud size over batch @@ -145,6 +147,7 @@ class GpusideCache bool needMoments, size_t roi_cloud_len, size_t roi_kontur_cloud_len, + size_t roi_kontur_borders, // the most contour borders a batch can have size_t n_rois, size_t roi_w, size_t roi_h, @@ -155,6 +158,7 @@ class GpusideCache // out GpuCache& clouds, GpuCache& konturs, + GpuCache& kontur_borders, RealPixIntens** realintens, double** prereduce, GpuCache& state, @@ -174,6 +178,7 @@ class GpusideCache bool needMoments, size_t roi_cloud_len, size_t roi_kontur_cloud_len, + size_t roi_kontur_borders, // the most contour borders a batch can have size_t n_rois, size_t roi_area, size_t roi_w, @@ -184,12 +189,13 @@ class GpusideCache bool free_gpu_cache( GpuCache& clouds, GpuCache& konturs, + GpuCache& kontur_borders, RealPixIntens*& realintens, double*& prereduce, GpuCache& intermediate, void*& tempstorage, - PixIntens* imat1, - PixIntens* imat2, + PixIntens*& imat1, + PixIntens*& imat2, GpuCache & gabor_linear_image, GpuCache & gabor_result, GpuCache & gabor_linear_kernel, @@ -203,6 +209,7 @@ class GpusideCache // out GpuCache& cloud, GpuCache& contours, + GpuCache& contour_borders, RealPixIntens** real_intens, double** prereduce, GpuCache& intermediate, @@ -223,12 +230,22 @@ class GpusideCache static bool devicereduce_evaluate_buffer_szb(size_t& devicereduce_buf_szb, size_t maxLen); static bool gpu_get_free_mem(size_t& amt); + // Whether allocate_gpu_cache() has allocated every buffer its families need, and nothing has freed + // them since. The ROI clouds are allocated by every allocation, an allocation that fails frees what + // it had allocated, and free_gpu_cache() nulls every host buffer, so the clouds' host buffer is set + // exactly then. + bool is_ready() const { return gpu_roiclouds_2d.hobuffer != nullptr; } + +#ifdef NYXUS_GPU_TEST_HOOKS + // for tests: makes allocate_gpu_cache() fail part way, with every family but Gabor allocated + static bool test_fail_before_gabor; + // for tests: makes the next host allocation of a GpuCache fail; reset by that allocation + static bool test_fail_next_host_alloc; +#endif + protected: - bool using_contour = false, - using_erosion = false, - using_gabor = false, - using_moments = false; + bool using_contour = false; }; diff --git a/src/nyx/features/2d_geomoments.h b/src/nyx/features/2d_geomoments.h index e467dcf17..3e703e184 100644 --- a/src/nyx/features/2d_geomoments.h +++ b/src/nyx/features/2d_geomoments.h @@ -79,12 +79,14 @@ class BasicGeomoms2D // helpers + // a pixel's distance to 'contour' is the smallest of its distances to the borders, each searched + // on its own void apply_dist2contour_weighting ( // input & output reintenvec& realintens, // input const pixcloud& cloud, - const pixcloud& contour, + const BorderedContour& contour, const double epsilon); void apply_dist2contour_weighting_wholeslide ( diff --git a/src/nyx/features/2d_geomoments_basic.cpp b/src/nyx/features/2d_geomoments_basic.cpp index 5af9089e0..99b7e81c5 100644 --- a/src/nyx/features/2d_geomoments_basic.cpp +++ b/src/nyx/features/2d_geomoments_basic.cpp @@ -35,7 +35,7 @@ void BasicGeomoms2D::apply_dist2contour_weighting( reintenvec& realintens, // input const pixcloud& cloud, - const pixcloud& contour, + const BorderedContour& contour, const double epsilon) { size_t n = cloud.size(); @@ -65,6 +65,16 @@ void BasicGeomoms2D::apply_dist2contour_weighting_wholeslide( const double epsilon) { size_t n = cloud.size(); + + // 'contour' holds the slide's corners, each once. A slide one pixel wide or high has fewer than 4 + // distinct corners, and every one of its pixels lies on the slide's edge. + if (contour.size() < 4) + { + for (size_t i = 0; i < n; i++) + realintens[i] = epsilon; + return; + } + for (size_t i = 0; i < n; i++) { auto& p = cloud[i]; @@ -114,9 +124,8 @@ void BasicGeomoms2D::calculate(LR& r, const Fsettings& s, intenfunction ifun, do calcNormCentralMoments(c); calcHuInvariants(c); - // flat multicontour - std::vector K; - r.merge_multicontour(K); + // the ROI's contour pixels, each once + const std::vector& K = r.contour_pixels_; // -- prepare weighted pixel cloud std::vector w; @@ -124,7 +133,7 @@ void BasicGeomoms2D::calculate(LR& r, const Fsettings& s, intenfunction ifun, do if (STNGS_SINGLEROI(s)) // former theEnvironment.singleROI apply_dist2contour_weighting_wholeslide (w, c, K, weighting_epsilon); else - apply_dist2contour_weighting (w, c, K, weighting_epsilon); + apply_dist2contour_weighting (w, c, BorderedContour (K, r.contour_border_ends_), weighting_epsilon); // -- weighted moments calcOrigins(c, w); diff --git a/src/nyx/features/circle.cpp b/src/nyx/features/circle.cpp index bb41b998e..671e0e090 100644 --- a/src/nyx/features/circle.cpp +++ b/src/nyx/features/circle.cpp @@ -11,11 +11,21 @@ EnclosingInscribingCircumscribingCircleFeature::EnclosingInscribingCircumscribin void EnclosingInscribingCircumscribingCircleFeature::calculate (LR& r, const Fsettings& s) { - std::vector K; - r.merge_multicontour(K); - - d_minEnclo = calculate_min_enclosing_circle_diam (K); - std::tie(d_inscr, d_circum) = calculate_inscribing_circumscribing_circle (K, r.fvals[(int)Feature2D::CENTROID_X][0], r.fvals[(int)Feature2D::CENTROID_Y][0]); + d_minEnclo = calculate_min_enclosing_circle_diam (r.contour_pixels_); + + // The inscribing and circumscribing circles are measured from the centroid to the ROI's outer + // borders: the external contour imea reads (cv2.findContours with RETR_EXTERNAL). The border of + // a hole is not part of that outline. A ROI of several fragments has one outer border per + // fragment, and the circles are measured to all of them. + std::vector outer; + for (size_t k = 0; k < r.multicontour_.size(); k++) + if (k >= r.multicontour_hole_.size() || !r.multicontour_hole_[k]) + outer.insert (outer.end(), r.multicontour_[k].begin(), r.multicontour_[k].end()); + + if (outer.empty()) + d_inscr = d_circum = 0; + else + std::tie(d_inscr, d_circum) = calculate_inscribing_circumscribing_circle (outer, r.fvals[(int)Feature2D::CENTROID_X][0], r.fvals[(int)Feature2D::CENTROID_Y][0]); } void EnclosingInscribingCircumscribingCircleFeature::save_value(std::vector>& fvals) @@ -222,14 +232,13 @@ std::tuple EnclosingInscribingCircumscribingCircleFeature::calc //-----------------circumscribing and inscribing circle --------------------------- //https://git.rwth-aachen.de/ants/sensorlab/imea/-/blob/master/imea/measure_2d/macro.py#L199 - double yCentroid2 = yCentroid - 1; - double xCentroid2 = xCentroid - 1; + // the contour and the centroid are both in absolute pixel coordinates std::vector distances; - for (size_t j = 0; j < contours.size(); j++) + for (size_t j = 0; j < contours.size(); j++) { - double tmpx = (contours[j].x - xCentroid2); - double tmpy = (contours[j].y - yCentroid2); + double tmpx = (contours[j].x - xCentroid); + double tmpy = (contours[j].y - yCentroid); double distance = sqrt(tmpx * tmpx + tmpy * tmpy); distances.push_back(distance); } diff --git a/src/nyx/features/contour.cpp b/src/nyx/features/contour.cpp index df3185950..39250a829 100644 --- a/src/nyx/features/contour.cpp +++ b/src/nyx/features/contour.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include "moments.h" #include "contour.h" #include "../globals.h" @@ -187,460 +189,151 @@ ContourFeature::ContourFeature() : FeatureMethod("ContourFeature") provide_features (ContourFeature::featureset); } -// -// returns neighboring pixels of 'tip' -// -/*static*/ std::vector ContourFeature::find_cands (const std::list &unordered, const Pixel2 &tip) +namespace { - // straight neighbors first - std::vector cands10; - for (const Pixel2& px : unordered) - { - auto dx = std::fabs((int)px.x - (int)tip.x), - dy = std::fabs((int)px.y - (int)tip.y); - if (dx+dy>1) - continue; - cands10.push_back (px); - } + // The 8 neighbour directions, clockwise on screen (y pointing down) starting East. + const int DIR_DX[8] = { 1, 1, 0, -1, -1, -1, 0, 1 }; + const int DIR_DY[8] = { 0, 1, 1, 1, 0, -1, -1, -1 }; - // straight neighbors, if any - if (cands10.size()) - return cands10; - - // then diagonal neighbors - std::vector cands11; - - for (const Pixel2& px : unordered) + int direction_of (int dx, int dy) { - auto dx = std::fabs((int)px.x - (int)tip.x), - dy = std::fabs((int)px.y - (int)tip.y); - if (dx==1 && dy==1) - cands11.push_back(px); + static const int lookup[3][3] = + { + { 5, 6, 7 }, // dy = -1: NW, N, NE + { 4, -1, 0 }, // dy = 0: W, -, E + { 3, 2, 1 } // dy = +1: SW, S, SE + }; + return lookup[dy + 1][dx + 1]; } - // diagonal neighbors - return cands11; -} - -/*static*/ int ContourFeature::get_dial_pos (const Pixel2 &d) -{ - /* - - (-,-) (0,-) (+,-) - 4 3 2 - \ | / - (-,0) 5----o----1 (+,0) - / | \ - 6 7 8 - -3 -2 -1 - (-,+) (0,+) (+,+) - - */ - - int p = -1; // initially, no position - if (d.x > 0) - { - if (d.y < 0) - p = 2; - else - if (d.y > 0) - p = -1; // eq pos 8; - else - p = 1; - } - else - if (d.x < 0) - { - if (d.y < 0) - p = 4; - else - if (d.y > 0) - p = -3; // eq pos 6; - else - p = 5; - } - else // (d.x == 0) + // Follows one border of the label image 'f' (0 background, 1 unvisited ROI pixel, +-k pixel of + // border k) from its start pixel (x0, y0), whose background neighbour lies in direction 'from', + // and returns the border as a closed walk of 8-connected steps beginning at (x0, y0), an outer + // border clockwise on screen and a hole border counterclockwise. Suzuki & Abe (1985), steps 3.1-3.5: + // every pixel of the border is labelled 'nbd', or '-nbd' where the walk examined its background + // East neighbour, so the raster scan neither restarts this border nor misses the next one. + std::vector follow_border (std::vector& f, const std::vector& P, int W, int x0, int y0, int from, int nbd) { - if (d.y < 0) - p = 3; - else - if (d.y > 0) - p = -2; // eq pos 7; - else - p = 0; - } - return p; -} - -// returns T if c1 is better than c2 as a contour walking pixel candidate -/*static*/ bool ContourFeature::better_step (const Pixel2 &o, const Pixel2 &c1, const Pixel2 &c2) -{ - Pixel2 dif1 = c1 - o, - dif2 = c2 - o; - int pos1 = get_dial_pos (dif1), - pos2 = get_dial_pos (dif2); - return pos1 > pos2; -} + auto label = [&](int x, int y) -> int& { return f[x + y * W]; }; + auto pixel = [&](int x, int y) { return Pixel2 (x, y, P[x + y * W] - 1); }; // undecorate the intensity -/*static*/ bool ContourFeature::prune_cands (std::vector &cands, const Pixel2 &o) -{ - if (cands.empty()) - return false; - - if (cands.size() == 1) - return true; - - Pixel2 best = cands[0]; - for (int i=1; i walk { pixel (x0, y0) }; - cands.clear(); - cands.push_back (best); - return true; -} - -// -// given a starting element "t" ("tip") of set "R" ("raw"), returns loop's length or 0 if "t" leads to no closing -// -/*static*/ int ContourFeature::check_loop( - // out - std::vector& S, - // in - const std::list& R, - const Pixel2& origin) -{ - // void loop? - if (R.size() == 0) - return 0; - - // worker vars - std::list U (R.begin(), R.end()); - std::vector P; - int looplen = 0; - - // --initialize vector 'ordered' (S) with 1st pixel of 'unordered' (U) to prevent finding it as a loop element - S.push_back (origin); - U.remove (origin); - Pixel2 pxTip = origin; - - // -- harvest items of 'unordered' - while (U.size()) - { - // --find tip's neighbors - std::vector cands = find_cands (U, pxTip); - - // --do we have pants? - size_t n_cands = cands.size(); - if (n_cands > 1) + // 3.1: the first ROI neighbour clockwise from the background neighbour + int d1 = -1; + for (int k = 0; k < 8 && d1 < 0; k++) { - P.push_back (pxTip); + int d = (from + k) % 8; + if (label (x0 + DIR_DX[d], y0 + DIR_DY[d]) != 0) + d1 = d; + } + if (d1 < 0) + { + // an isolated pixel is a border of its own + label (x0, y0) = -nbd; + return walk; } - bool okCand = prune_cands (cands, pxTip); - - switch (cands.size()) + const int x1 = x0 + DIR_DX[d1], y1 = y0 + DIR_DY[d1]; + int x2 = x1, y2 = y1, // the walk's previous pixel + x3 = x0, y3 = y0; // the walk's current pixel + while (true) { - case 0: + // 3.3: the first ROI neighbour of the current pixel counterclockwise after the previous one + const int dprev = direction_of (x2 - x3, y2 - y3); + bool eastIsBackground = false; + int d4 = dprev; + for (int k = 1; k <= 8; k++) { - // Reached the end of the chain. - // Can we close it and observe a loop ("origin" is "pxTip's" neighbor), or we have a loose chain (pxTip has no neighbors)? - Pixel2 dist2org = pxTip - origin; - if (std::fabs(dist2org.x) == 1 || std::fabs(dist2org.y) == 1) + int d = (dprev - k + 16) % 8; + if (label (x3 + DIR_DX[d], y3 + DIR_DY[d]) != 0) { - looplen++; - return looplen; - } - else - { - if (P.empty()) - return 0; // not a loop - else - { - // restore the pre-bifurcation tip and proceed with the other (initially rejected) candidate - pxTip = P.back(); - P.pop_back(); - } + d4 = d; + break; } + if (d == 0) + eastIsBackground = true; } - break; - case 1: - // trivial loop element - looplen++; - pxTip = cands[0]; - S.push_back(pxTip); - U.remove(pxTip); - break; - default: - // 2-branch or 3-branch: normally we should not fall through here - return 0; // not a loop - break; + const int x4 = x3 + DIR_DX[d4], y4 = y3 + DIR_DY[d4]; + + // 3.4: label the current pixel + if (eastIsBackground) + label (x3, y3) = -nbd; + else + if (label (x3, y3) == 1) + label (x3, y3) = nbd; + + // 3.5: back at the start, about to repeat the first step. The walk ran counterclockwise on + // screen around an outer border; it is returned in the opposite direction, from the same + // start pixel, which is the direction buildRegularContour_nontriv walks a simple outline in. + // The approximate distance searches (Pixel2::min_sqdist and co.) depend on that order. + if (x4 == x0 && y4 == y0 && x3 == x1 && y3 == y1) + { + std::reverse (walk.begin() + 1, walk.end()); + return walk; + } + + x2 = x3; + y2 = y3; + x3 = x4; + y3 = y4; + walk.push_back (pixel (x3, y3)); } } - - return looplen; } /*static*/ void ContourFeature::gather_multicontour ( // out std::vector>& multicountour, // in - std::vector P, // padded image + const std::vector& P, // padded image int w, int h, - int verbose) + int verbose, + // out, optional + std::vector* is_hole) { - // This function fills the caller-owned contour container from scratch. - // Clearing it up front prevents stale contours from surviving the early - // return below when a ROI degenerates to isolated speckles. - multicountour.clear(); - - const int BLANK = 0; - bool inside = false; - int pos = 0; - - //==== Prepare a contour ("border") image - std::vector borderImage((h + 2) * (w + 2), 0); - - // Initialize the entire image to blank - for (int y = 0; y < (h + 2); y++) - for (int x = 0; x < (w + 2); x++) - borderImage.at(x + y * (w + 2)) = BLANK; - - //==== Scan the padded image and fill the border one - - for (int y = 0; y < (h + 2); y++) - { - for (int x = 0; x < (w + 2); x++) - { - pos = x + y * (w + 2); - - // Scan for a non-blank pixel - PixIntens bi, pi; - bi = borderImage.at(pos); - pi = P.at(pos); - if (bi != 0 && !inside) // Entering an already discovered border - { - inside = true; - } - else if (pi != 0 && inside) // Already discovered border point - { - continue; - } - else if (pi == BLANK && inside) // Leaving a border - { - inside = false; - } - else if (pi != 0 && !inside) // Undiscovered border point - { - borderImage.at(pos) = P.at(pos); // Non-blank - - int checkLocationNr = 1; // The neighbor number of the location we want to check for a new border point - int checkPosition; // The corresponding absolute array address of checkLocationNr - int newCheckLocationNr; // Variable that holds the neighborhood position we want to check if we find a new border at checkLocationNr - int startPos = pos; // Set start position - int counter = 0; // Counter is used for the jacobi stop criterion - int counter2 = 0; // Counter2 is used to determine if the point we have discovered is one single point - - // Defines the neighborhood offset position from current position and the neighborhood - // position we want to check next if we find a new border at checkLocationNr - int neighborhood[8][2] = - { - {-1,7}, - {-3 - w,7}, - {-w - 2,1}, - {-1 - w,1}, - {1,3}, - {3 + w,3}, - {w + 2,5}, - {1 + w,5} - }; - - // Trace around the neighborhood - while (true) - { - checkPosition = pos + neighborhood[checkLocationNr - 1][0]; - newCheckLocationNr = neighborhood[checkLocationNr - 1][1]; - - PixIntens pi2 = 0; - if (checkPosition >= P.size()) // we're done if we start checking outside the image - break; - pi2 = P.at(checkPosition); - if (pi2 != 0) // Next border point found? - { - if (checkPosition == startPos) - { - counter++; - - // Stopping criterion (jacob) - if (newCheckLocationNr == 1 || counter >= 3) - { - // Close loop - inside = true; // Since we are starting the search at were we first started we must set inside to true - break; - } - } + // Every border of the ROI -- the outer border of each 8-connected fragment and the border of each + // hole -- as a closed walk of 8-connected steps over the ROI pixels with a 4-neighbour outside + // the ROI. Raster-scan border following of Suzuki & Abe (1985), "Topological structural analysis + // of digitized binary images by border following", the algorithm behind OpenCV's findContours. + // Coordinates are those of the padded image; its one-pixel frame is read as background. - checkLocationNr = newCheckLocationNr; // Update which neighborhood position we should check next - pos = checkPosition; - counter2 = 0; // Reset the counter that keeps track of how many neighbors we have visited - borderImage.at(checkPosition) = P.at(checkPosition); // Non-blank - } - else - { - // Rotate clockwise in the neighborhood - checkLocationNr = 1 + (checkLocationNr % 8); - if (counter2 > 8) - { - // If counter2 is above 8, we have sought around the neighborhood and - // therefore the border is a single non-blank pixel, and we can exit - counter2 = 0; - break; - } - else - { - counter2++; - } - } - } - } - } - } + multicountour.clear(); + if (is_hole) + is_hole->clear(); - if (verbose >= 4) - dump_2d_image_1d_layout(borderImage, w + 2, h + 2, "\n\nPadded contour image \n", "\n\n"); + const int W = w + 2, H = h + 2; + if (P.size() != (size_t)W * (size_t)H) + throw std::invalid_argument ("gather_multicontour: padded image of " + std::to_string(P.size()) + " pixels is not " + std::to_string(W) + " x " + std::to_string(H)); - //==== remove padding - std::vector C; // current temp contour + std::vector f (P.size(), 0); + for (int y = 1; y <= h; y++) + for (int x = 1; x <= w; x++) + f[x + y * W] = P[x + y * W] != 0 ? 1 : 0; - // gather contour pixels undecorating their intensities back to original values - Pixel2 lastNonzeroPx(0, 0, 0); - for (int y = 0; y < h + 2; y++) - for (int x = 0; x < w + 2; x++) + int nbd = 1; // border labels start at 2 + for (int y = 1; y <= h; y++) + for (int x = 1; x <= w; x++) { - size_t idx = x + y * (w + 2); - auto inte = borderImage.at(idx); - if (inte) - { - // this pixel may happen to be isolated (a speckle), nonetheless, remember it - // as we'll need to report it as a degenerate contour if no properly neighbored - // pixel group is found - lastNonzeroPx = { x, y, inte - 1 }; - - // register a pixel only if it has any immediate neighbor - bool hasNeig = false; - if (x > 0) // left neighbor - { - size_t idxNeig = (x - 1) + y * (w + 2); - hasNeig = hasNeig || borderImage.at(idxNeig) != 0; - } - if (x < w - 1) // right neighbor - { - size_t idxNeig = (x + 1) + y * (w + 2); - hasNeig = hasNeig || borderImage.at(idxNeig) != 0; - } - if (y > 0) // upper neighbor - { - size_t idxNeig = x + (y - 1) * (w + 2); - hasNeig = hasNeig || borderImage.at(idxNeig) != 0; - } - if (y < h - 1) // lower neighbor - { - size_t idxNeig = x + (y + 1) * (w + 2); - hasNeig = hasNeig || borderImage.at(idxNeig) != 0; - } - if (x > 0 && y > 0) // upper left neighbor - { - size_t idxNeig = (x - 1) + (y - 1) * (w + 2); - hasNeig = hasNeig || borderImage.at(idxNeig) != 0; - } - if (x < w - 1 && y > 0) // upper right neighbor - { - size_t idxNeig = (x + 1) + (y - 1) * (w + 2); - hasNeig = hasNeig || borderImage.at(idxNeig) != 0; - } - if (x > 0 && y < h - 1) // lower left neighbor - { - size_t idxNeig = (x - 1) + (y + 1) * (w + 2); - hasNeig = hasNeig || borderImage.at(idxNeig) != 0; - } - if (x < w - 1 && y < h - 1) // lower right neighbor - { - size_t idxNeig = (x + 1) + (y + 1) * (w + 2); - hasNeig = hasNeig || borderImage.at(idxNeig) != 0; - } - if (!hasNeig) + const int i = x + y * W; + int from; + if (f[i] == 1 && f[i - 1] == 0) + from = 4; // an outer border starts here: its background neighbour is West + else + if (f[i] >= 1 && f[i + 1] == 0) + from = 0; // a hole border starts here: its background neighbour is East + else continue; - // pixel is good, save it - Pixel2 p(x, y, inte - 1); - C.push_back(p); - } - } - if (verbose >= 4) - dump_2d_image_with_vertex_chain(borderImage, C, w + 2, h + 2, "\n\nUnsorted contour cloud (on padded image)\n", "\n\n"); + nbd++; + multicountour.push_back (follow_border (f, P, W, x, y, from, nbd)); + if (is_hole) + is_hole->push_back (from == 0); - // no candidates for a contour? - if (C.size() == 0) - return; - - // C - raw unordered contour - // U - unordered subset of C subject to order - // S - ordered subset of C - - - // fix X-crossing points - - std::vector C_fix = C; - int n_xxings = 0; - for (const Pixel2& p : C) - { - Pixel2 pN (p.x, p.y - 1, p.inten), - pS (p.x, p.y + 1, p.inten), - pW (p.x - 1, p.y, p.inten), - pE (p.x + 1, p.y, p.inten); - auto itN = std::find (C_fix.begin(), C_fix.end(), pN); - auto itS = std::find (C_fix.begin(), C_fix.end(), pS); - auto itE = std::find (C_fix.begin(), C_fix.end(), pE); - auto itW = std::find (C_fix.begin(), C_fix.end(), pW); - if (itN != C_fix.end() && itS != C_fix.end() && itW != C_fix.end() && itE != C_fix.end()) - { - auto itP = std::find (C_fix.begin(), C_fix.end(), p); - C_fix.erase (itP); - n_xxings++; - } - } - std::list U (C_fix.begin(), C_fix.end()); - - // find contour by contour - while (!U.empty()) - { - std::vector S; - Pixel2 pxTip = U.front(); - - int looplen = check_loop (S, U, pxTip); - if (looplen) - { if (verbose >= 4) - dump_2d_image_with_vertex_chain (borderImage, S, w+2, h+2, "\n\nLoop\n", "||S||=" + std::to_string(S.size()) + "\n"); - - multicountour.push_back(S); - - // subtract the newly found contour from U - for (Pixel2 & p : S) - U.remove (p); - } - else - { - // failed loop, optionally look into why it failed - if (verbose >= 5) - dump_2d_image_with_vertex_chain (borderImage, S, w+2, h+2, "\n\nfailed loop\n", "||S||=" + std::to_string(S.size()) + "\n"); - - // subtract the failed loop from U - for (Pixel2& p : S) - U.remove(p); + dump_2d_image_with_vertex_chain (P, multicountour.back(), W, H, "\n\nBorder " + std::to_string(nbd - 1) + "\n", "||K||=" + std::to_string(multicountour.back().size()) + "\n"); } - } } /*static*/ void ContourFeature::buildRegularContour(LR& r, const Fsettings& s) @@ -656,6 +349,7 @@ ContourFeature::ContourFeature() : FeatureMethod("ContourFeature") // Rebuild the contour cache from scratch for this ROI. r.multicontour_.clear(); + r.multicontour_hole_.clear(); for (auto px : r.raw_pixels) { @@ -668,14 +362,14 @@ ContourFeature::ContourFeature() : FeatureMethod("ContourFeature") dump_2d_image_1d_layout(paddedImage, width + 2, height + 2, "\n\n\n ContourFeature / buildRegularContour / Padded image ROI " + std::to_string(r.aabb.get_width()) + " W " + std::to_string(r.aabb.get_height()) + " H \n", "\n\n\n"); // gather all the contours - gather_multicontour (r.multicontour_, paddedImage, width, height, STNGS_VERBOSLVL(s)); + gather_multicontour (r.multicontour_, paddedImage, width, height, STNGS_VERBOSLVL(s), &r.multicontour_hole_); - // fix pixel positions back to absolute + // padded-image positions back to absolute: the padded image starts one pixel before the AABB for (std::vector &K : r.multicontour_) for (Pixel2 &p : K) { - p.x += base_x; - p.y += base_y; + p.x += base_x - 1; + p.y += base_y - 1; } } @@ -819,7 +513,9 @@ void ContourFeature::buildRegularContour_nontriv (LR& r, const Fsettings& s) auto inte = borderImage[idx]; if (inte) { - Pixel2 p(x, y, inte - 1); // Undecorate the intensity + // position relative to the AABB origin (the padded image starts one pixel before it), + // intensity undecorated + Pixel2 p(x - 1, y - 1, inte - 1); r.multicontour_[0].push_back(p); } } @@ -931,6 +627,7 @@ void ContourFeature::buildWholeSlideContour(LR& r) K.push_back(bl); r.multicontour_.clear(); r.multicontour_.push_back (K); + r.multicontour_hole_.clear(); } void ContourFeature::calculate (LR& r, const Fsettings& s) @@ -940,9 +637,9 @@ void ContourFeature::calculate (LR& r, const Fsettings& s) else buildRegularContour(r, s); - // flat representation - std::vector K; - r.merge_multicontour(K); + // the ROI's contour pixels, each once, kept on the ROI for every feature that reads the contour + r.merge_multicontour(); + const std::vector& K = r.contour_pixels_; // is there any contour? if (K.empty()) @@ -958,21 +655,16 @@ void ContourFeature::calculate (LR& r, const Fsettings& s) } // ok to calculate features + // PERIMETER is the summed length of the ROI's closed contours, each walked back to its start fval_PERIMETER = 0; - size_t clen = K.size(); - for (size_t i = 0; i < clen; i++) - if (i == 0) - { - Pixel2& p1 = K[clen-1], - & p2 = K[i]; - fval_PERIMETER += std::sqrt(p1.sqdist(p2)); - } - else - { - Pixel2& p1 = K[i-1], - & p2 = K[i]; - fval_PERIMETER += std::sqrt(p1.sqdist(p2)); - } + for (const std::vector& C : r.multicontour_) + { + size_t clen = C.size(); + if (clen < 2) + continue; + for (size_t i = 0; i < clen; i++) + fval_PERIMETER += std::sqrt (C[i == 0 ? clen - 1 : i - 1].sqdist(C[i])); + } fval_DIAMETER_EQUAL_PERIMETER = fval_PERIMETER / M_PI; auto [cmin, cmax, cmean, cstddev] = calc_min_max_mean_stddev_intensity (K); @@ -993,8 +685,10 @@ void ContourFeature::osized_calculate (LR& r, const Fsettings& s, ImageLoader& i { buildRegularContour_nontriv(r, s); // leaves the contour pixels in field 'contour' - std::vector K; - r.merge_multicontour(K); + // one border, an outer one + r.multicontour_hole_.clear(); + r.merge_multicontour(); + const std::vector& K = r.contour_pixels_; //=== Calculate the features fval_PERIMETER = (StatsInt) K.size(); diff --git a/src/nyx/features/contour.h b/src/nyx/features/contour.h index 66c8a85f3..0afe92022 100644 --- a/src/nyx/features/contour.h +++ b/src/nyx/features/contour.h @@ -9,7 +9,7 @@ #include "../feature_settings.h" #include "image_matrix.h" -/// @brief A contour is a vector of X and Y coordinates of all the pixels on the border of a ROI. This class uses Moore's algorithm for cnotour detection. +/// @brief A ROI's contour is the set of its borders, each a closed walk over the ROI pixels that have a 4-neighbour outside the ROI (LR::multicontour_). class ContourFeature: public FeatureMethod { public: @@ -40,29 +40,23 @@ class ContourFeature: public FeatureMethod static void buildRegularContour (LR &r, const Fsettings &s); + /// @brief Traces every border of the ROI in a padded image -- the outer border of each 8-connected + /// fragment and the border of each hole -- as a closed walk of 8-connected steps over the ROI + /// pixels that have a 4-neighbour outside the ROI. + /// @param padded_img (w+2) x (h+2) image whose nonzero pixels are the ROI, each holding its + /// intensity + 1; its one-pixel frame is read as background + /// @param multicountour one closed walk per border, in padded-image coordinates + /// @param is_hole when given, (*is_hole)[k] tells whether border k is the border of a hole static void gather_multicontour ( // out std::vector> &multicountour, // in - std::vector padded_img, + const std::vector& padded_img, int w, int h, - int verbose); - - static std::vector find_cands (const std::list &unordered, const Pixel2 &tip); - - static int get_dial_pos (const Pixel2 &d); - - static bool better_step (const Pixel2 &org, const Pixel2 &cand1, const Pixel2 &cand2); - - static bool prune_cands (std::vector &cands, const Pixel2 &org); - - static int check_loop( - // out - std::vector &S, - // in - const std::list &R, - const Pixel2 &origin); + int verbose, + // out, optional + std::vector* is_hole = nullptr); private: void buildRegularContour_nontriv (LR& roi, const Fsettings& settings); diff --git a/src/nyx/features/fractal_dim.cpp b/src/nyx/features/fractal_dim.cpp index 57abec5a6..39ca1f254 100644 --- a/src/nyx/features/fractal_dim.cpp +++ b/src/nyx/features/fractal_dim.cpp @@ -126,11 +126,10 @@ void FractalDimensionFeature::calculate_boxcount_fdim_oversized (LR & r) void FractalDimensionFeature::calculate_perimeter_fdim (LR& r) { - // The divider walks ONE closed contour. merge_multicontour concatenates every contour of the - // ROI, so a multi-contour ROI (holes / disconnected fragments) injects spurious seam chords - // between them that bias the dimension; this path assumes a single-contour ROI. - std::vector K; - r.merge_multicontour(K); + // The divider walks ONE closed contour. The merged contour lists every border of the ROI one + // after another, so a multi-contour ROI (holes / disconnected fragments) injects spurious seam + // chords between them that bias the dimension; this path assumes a single-contour ROI. + const std::vector& K = r.contour_pixels_; auto conLen = K.size(); if (conLen < 3) diff --git a/src/nyx/features/neighbors.cpp b/src/nyx/features/neighbors.cpp index 61f86529a..08c7a19d1 100644 --- a/src/nyx/features/neighbors.cpp +++ b/src/nyx/features/neighbors.cpp @@ -88,9 +88,8 @@ void parallel_process_1_batch_of_collision_pairs ( if (r1.multicontour_.empty()) continue; - std::vector K1, K2; - r1.merge_multicontour (K1); - r2.merge_multicontour (K2); + const std::vector& K1 = r1.contour_pixels_, + & K2 = r2.contour_pixels_; if (K1.empty()) continue; @@ -253,9 +252,8 @@ void NeighborsFeature::manual_reduce ( LR& r1 = roiData[l1]; LR& r2 = roiData[l2]; - std::vector K1, K2; - r1.merge_multicontour(K1); - r2.merge_multicontour(K2); + const std::vector& K1 = r1.contour_pixels_, + & K2 = r2.contour_pixels_; // Both segments' outer pixels must be available if (K1.size() == 0 || K2.size() == 0) @@ -295,8 +293,7 @@ void NeighborsFeature::manual_reduce ( { LR& r1 = roiData[l]; - std::vector K1; - r1.merge_multicontour(K1); + const std::vector& K1 = r1.contour_pixels_; // Finalize % touching = distinct touching contour pixels / contour length size_t nt = 0; diff --git a/src/nyx/features/pixel.cpp b/src/nyx/features/pixel.cpp index 3dd5c3a37..ad14b8431 100644 --- a/src/nyx/features/pixel.cpp +++ b/src/nyx/features/pixel.cpp @@ -1,3 +1,7 @@ +#include +#include +#include +#include #include #include #include "pixel.h" @@ -10,64 +14,186 @@ bool operator == (const Pixel2& p1, const Pixel2& p2) return true; } -double Pixel2::min_sqdist(const std::vector& cloud, double sx, double sy) const +namespace { - #if 0 - // - // v1, slower version not requiring the ordered contour - // - - auto extrem_d = sqdist(cloud[0]); - int mindi = 0; + // The extreme squared distance from 'p' to the ordered walk W[0..n), n >= 1: the smallest, or the + // largest when 'farther' is set. Up to 10 points are scanned exhaustively. A longer walk is + // sampled at a stride of about n / ln n, and the stride shrinks around the best sample until it + // is 1, so only that neighbourhood is searched at full resolution. + double search_sqdist (const Pixel2& p, const Pixel2* W, size_t n, bool farther, double sx, double sy) + { + auto better = [farther] (double dist, double extrem) { return farther ? dist > extrem : dist < extrem; }; - size_t n = cloud.size(); + auto extrem_d = p.sqdist (W[0], sx, sy); + if (n <= 10) + { + for (size_t i = 1; i < n; i++) + { + auto dist = p.sqdist (W[i], sx, sy); + if (better (dist, extrem_d)) + extrem_d = dist; + } + return extrem_d; + } - for (size_t i = 1; i < n; i++) - { - auto dist = sqdist(cloud[i]); - if (dist < extrem_d) + size_t a = 0, b = n; + auto extrem_i = a; + int step = (b - a) / log(b - a); + do { - extrem_d = dist; - mindi = i; + for (size_t i = a + step; i < b; i += step) + { + auto dist = p.sqdist (W[i], sx, sy); + if (better (dist, extrem_d)) + { + extrem_d = dist; + extrem_i = i; + } + } + + // left or right ? + auto stepL = extrem_i >= step ? step : extrem_i, + stepR = extrem_i + step < n ? step : n - extrem_i; + + a = extrem_i - stepL; + b = extrem_i + stepR; + step = b - a <= 10 ? 1 : (b - a) / log(b - a); } + while (b - a > 2); + + return extrem_d; } - #endif - // - // v2 - // + // The squared distances from 'p' to the nearest point of the box of border 'B' and to its + // farthest corner: no pixel of the border is nearer than the first or farther than the second. + // Taken on whole pixel offsets like Pixel2::sqdist, so they compare exactly against it. + double box_nearest_sqdist (const Pixel2& p, const BorderedContour::Border& B, double sx, double sy) + { + double dx = double(p.x < B.xmin ? B.xmin - p.x : (p.x > B.xmax ? p.x - B.xmax : 0)) * sx, + dy = double(p.y < B.ymin ? B.ymin - p.y : (p.y > B.ymax ? p.y - B.ymax : 0)) * sy; + return dx * dx + dy * dy; + } - if (cloud.empty()) - return 0.0; + double box_farthest_sqdist (const Pixel2& p, const BorderedContour::Border& B, double sx, double sy) + { + double dx = double((std::max) (std::abs (p.x - B.xmin), std::abs (p.x - B.xmax))) * sx, + dy = double((std::max) (std::abs (p.y - B.ymin), std::abs (p.y - B.ymax))) * sy; + return dx * dx + dy * dy; + } - size_t n = cloud.size(); - size_t a = 0, b = n; - auto extrem_d = sqdist(cloud[a], sx, sy); - auto extrem_i = a; - int step = (b - a) / log(b - a); - do + // The extremes of search_sqdist() over the borders of 'K', each border searched on its own: the + // smallest into *mind and the largest into *maxd, either pointer null when that extreme is not + // wanted, and only the box bounds of a wanted extreme taken. The border with the most promising + // bound is searched first; a border whose bound cannot beat the extreme found so far is then + // skipped, which leaves the result as if every border were searched. An empty contour gives 0. + void search_sqdist_borders (const Pixel2& p, const BorderedContour& K, double sx, double sy, double* mind, double* maxd) { - for (size_t i = a + step; i < b; i += step) + const auto& B = K.borders; + if (B.empty()) { - auto dist = sqdist(cloud[i], sx, sy); - if (extrem_d > dist) + if (mind) *mind = 0.0; + if (maxd) *maxd = 0.0; + return; + } + + auto search = [&] (size_t k, bool farther) { return search_sqdist (p, K.pixels.data() + B[k].begin, B[k].end - B[k].begin, farther, sx, sy); }; + + if (B.size() == 1) + { + if (mind) *mind = search (0, false); + if (maxd) *maxd = search (0, true); + return; + } + + // per-thread scratch, so that a query allocates nothing once it has grown + thread_local std::vector nearest, farthest; + size_t firstMin = 0, firstMax = 0; + if (mind) + { + nearest.resize (B.size()); + for (size_t k = 0; k < B.size(); k++) + { + nearest[k] = box_nearest_sqdist (p, B[k], sx, sy); + if (nearest[k] < nearest[firstMin]) + firstMin = k; + } + } + if (maxd) + { + farthest.resize (B.size()); + for (size_t k = 0; k < B.size(); k++) { - extrem_d = dist; - extrem_i = i; + farthest[k] = box_farthest_sqdist (p, B[k], sx, sy); + if (farthest[k] > farthest[firstMax]) + firstMax = k; } } - // left or right ? - auto stepL = extrem_i >= step ? step : extrem_i, - stepR = extrem_i + step < n ? step : n - extrem_i; + if (mind) + { + double extrem_d = search (firstMin, false); + for (size_t k = 0; k < B.size(); k++) + if (k != firstMin && nearest[k] < extrem_d) + extrem_d = (std::min) (extrem_d, search (k, false)); + *mind = extrem_d; + } + if (maxd) + { + double extrem_d = search (firstMax, true); + for (size_t k = 0; k < B.size(); k++) + if (k != firstMax && farthest[k] > extrem_d) + extrem_d = (std::max) (extrem_d, search (k, true)); + *maxd = extrem_d; + } + } +} - a = extrem_i - stepL; - b = extrem_i + stepR; - step = b - a <= 10 ? 1 : (b - a) / log(b - a); - } - while (b - a > 2); +bool border_ends_cover (size_t n_pixels, const std::vector& ends) +{ + size_t begin = 0; + for (size_t end : ends) + { + if (end <= begin) + return false; + begin = end; + } + return begin == n_pixels; +} - return extrem_d; +BorderedContour::BorderedContour (const std::vector& pixels_, const std::vector& ends) + : pixels (pixels_) +{ + if (!border_ends_cover (pixels.size(), ends)) + throw std::invalid_argument ("BorderedContour: border ends do not cover the " + std::to_string(pixels.size()) + " contour pixels in nonempty runs"); + + size_t begin = 0; + for (size_t end : ends) + { + Border b { begin, end, pixels[begin].x, pixels[begin].x, pixels[begin].y, pixels[begin].y }; + for (size_t i = begin + 1; i < end; i++) + { + b.xmin = (std::min) (b.xmin, pixels[i].x); + b.xmax = (std::max) (b.xmax, pixels[i].x); + b.ymin = (std::min) (b.ymin, pixels[i].y); + b.ymax = (std::max) (b.ymax, pixels[i].y); + } + borders.push_back (b); + begin = end; + } +} + +double Pixel2::min_sqdist (const std::vector& cloud, double sx, double sy) const +{ + if (cloud.empty()) + return 0.0; + return search_sqdist (*this, cloud.data(), cloud.size(), false, sx, sy); +} + +double Pixel2::min_sqdist (const BorderedContour& contour, double sx, double sy) const +{ + double mind; + search_sqdist_borders (*this, contour, sx, sy, &mind, nullptr); + return mind; } // exact minimum squared distance via a full linear scan (no ordered-contour assumption). @@ -87,63 +213,28 @@ double Pixel2::exact_min_sqdist(const std::vector& cloud) const return extrem_d; } -double Pixel2::max_sqdist(const std::vector& cloud, double sx, double sy) const +double Pixel2::max_sqdist (const std::vector& cloud, double sx, double sy) const { - #if 0 - // - // v1, slower version not requiring the ordered contour - // - - auto extrem_d = sqdist(cloud[0]); - int mindi = 0; - - size_t n = cloud.size(); - - for (size_t i = 1; i < n; i++) - { - auto dist = sqdist(cloud[i]); - if (dist > extrem_d) - { - extrem_d = dist; - mindi = i; - } - } - #endif - - // - // v2 - // - size_t n = cloud.size(); - size_t a = 0, b = n; - auto extrem_d = sqdist(cloud[a], sx, sy); - auto extrem_i = a; - int step = (b - a) / log(b - a); - do - { - for (size_t i = a + step; i < b; i += step) - { - auto dist = sqdist(cloud[i], sx, sy); - if (extrem_d < dist) - { - extrem_d = dist; - extrem_i = i; - } - } - - // left or right ? - auto stepL = extrem_i >= step ? step : extrem_i, - stepR = extrem_i + step < n ? step : n - extrem_i; - - a = extrem_i - stepL; - b = extrem_i + stepR; - step = b - a <= 10 ? 1 : (b - a) / log(b - a); - } - while (b - a > 2); + if (cloud.empty()) + return 0.0; + return search_sqdist (*this, cloud.data(), cloud.size(), true, sx, sy); +} - return extrem_d; +double Pixel2::max_sqdist (const BorderedContour& contour, double sx, double sy) const +{ + double maxd; + search_sqdist_borders (*this, contour, sx, sy, nullptr, &maxd); + return maxd; } /*static*/ int Pixel2::find_center(const std::vector& cloud, const std::vector& contour, double sx, double sy) +{ + // the whole contour as a single border + const std::vector ends = contour.empty() ? std::vector() : std::vector{ contour.size() }; + return find_center (cloud, BorderedContour (contour, ends), sx, sy); +} + +/*static*/ int Pixel2::find_center(const std::vector& cloud, const BorderedContour& contour, double sx, double sy) { int idxMinDif = 0; auto minmaxDist = cloud[idxMinDif].min_max_sqdist(contour, sx, sy); @@ -168,6 +259,14 @@ std::pair Pixel2::min_max_sqdist(const std::vector& cont return { mind, maxd }; } +std::pair Pixel2::min_max_sqdist(const BorderedContour& contour, double sx, double sy) const +{ + // both extremes off one call, each from its own box bounds + double mind, maxd; + search_sqdist_borders (*this, contour, sx, sy, &mind, &maxd); + return { mind, maxd }; +} + double Pixel2::angle(const Pixel2& other) const { double dotProd = double(this->x * other.x) + double(this->y * other.y), diff --git a/src/nyx/features/pixel.h b/src/nyx/features/pixel.h index ce0f729c6..51f6e92dc 100644 --- a/src/nyx/features/pixel.h +++ b/src/nyx/features/pixel.h @@ -48,6 +48,8 @@ using Point2i = Point2; using Point2f = Point2; inline float normL2(const Point2f& p) { return p.normL2(); } +struct BorderedContour; + /// @brief Class encapsulating a 2D pixel struct Pixel2 : public Point2i { @@ -128,14 +130,29 @@ struct Pixel2 : public Point2i /// @brief Returns true if this pixel belongs to 'cloud' bool belongs_to(const std::vector& cloud) const; - /// @brief Returns an index in argument 'cloud' + /// @brief Returns the index in 'cloud' of the pixel whose largest and smallest squared distances + /// to 'contour' differ least, both taken by min_sqdist() and max_sqdist() static int find_center(const std::vector& cloud, const std::vector& contour, double sx = 1.0, double sy = 1.0); + /// @brief The same over a contour made of several borders, each searched on its own + static int find_center(const std::vector& cloud, const BorderedContour& contour, double sx = 1.0, double sy = 1.0); + std::pair min_max_sqdist(const std::vector& contour, double sx = 1.0, double sy = 1.0) const; - /// @brief Returns the minimum squared distance squared distance from pixel to the + std::pair min_max_sqdist(const BorderedContour& contour, double sx = 1.0, double sy = 1.0) const; + + /// @brief Returns the minimum squared distance from pixel to the , read as one + /// ordered walk. Up to 10 points are scanned exhaustively; a longer walk is searched coarse to + /// fine around the best point found so far, which assumes the distance varies smoothly along the + /// walk and can return more than the true minimum where it does not. double min_sqdist (const std::vector& cloud, double sx = 1.0, double sy = 1.0) const; + /// @brief The same over a contour made of several borders: each border is searched on its own and + /// the smallest result is returned, so the search never steps from one border to another. A border + /// whose bounding box lies no nearer than the smallest distance found so far is not searched, as + /// it cannot return less. + double min_sqdist (const BorderedContour& contour, double sx = 1.0, double sy = 1.0) const; + /// @brief Exact minimum squared distance from pixel to via a full linear scan. /// min_sqdist() is an approximate hill-descent (v2) that assumes a locally-unimodal /// ordered contour and can settle in the wrong basin on a closed contour, OVERESTIMATING the @@ -144,9 +161,15 @@ struct Pixel2 : public Point2i /// per-pixel speed (neighbor touch/percent-touching, radius gate). double exact_min_sqdist (const std::vector& cloud) const; - /// @brief Returns the maximum squared distance squared distance from pixel to the + /// @brief Returns the maximum squared distance from pixel to the , searched as + /// min_sqdist() searches for the minimum, and able to return less than the true maximum double max_sqdist (const std::vector& cloud, double sx = 1.0, double sy = 1.0) const; + /// @brief The same over a contour made of several borders, as min_sqdist() over borders: the + /// largest of the per-border results, a border whose bounding box lies within the largest + /// distance found so far not being searched + double max_sqdist (const BorderedContour& contour, double sx = 1.0, double sy = 1.0) const; + /// @brief Returns the angle in radians between this pixel and 'other' relative to the origin double angle(const Pixel2& other) const; @@ -161,6 +184,32 @@ struct Pixel2 : public Point2i bool operator == (const Pixel2& p1, const Pixel2& p2); +/// @brief Whether 'ends' lays out a contour of 'n_pixels' pixels as borders the way +/// LR::contour_border_ends_ does: border k is pixels [ends[k-1], ends[k]) (from 0 for k == 0), every +/// border holds at least one pixel (the ends rise strictly), and the last end is n_pixels, so the +/// borders cover the contour. No ends at all describes an empty contour. The CPU searches +/// (BorderedContour) and the GPU upload (GpusideCache::send_roi_batch_data_2_gpu) both require it. +bool border_ends_cover (size_t n_pixels, const std::vector& ends); + +/// @brief A contour made of several borders, each an ordered walk (LR::contour_pixels_ and +/// LR::contour_border_ends_), laid out as border_ends_cover() describes; any other layout throws +/// std::invalid_argument. Keeps the bounding box of every border. +struct BorderedContour +{ + BorderedContour (const std::vector& pixels, const std::vector& ends); + // 'pixels' is kept by reference, so it has to outlive the contour: a temporary does not + BorderedContour (const std::vector&& pixels, const std::vector& ends) = delete; + + struct Border + { + size_t begin, end; + StatsInt xmin, xmax, ymin, ymax; + }; + + const std::vector& pixels; + std::vector borders; +}; + template struct Point3 { diff --git a/src/nyx/features/radial_distribution.cpp b/src/nyx/features/radial_distribution.cpp index c7a1df340..cb7a16186 100644 --- a/src/nyx/features/radial_distribution.cpp +++ b/src/nyx/features/radial_distribution.cpp @@ -48,8 +48,9 @@ void RadialDistributionFeature::calculate (LR& r, const Fsettings& s) auto& raw_pixels = r.raw_pixels; - std::vector K; - r.merge_multicontour(K); + // the contour, as borders the distance searches below search one at a time + const std::vector& K = r.contour_pixels_; + const BorderedContour Kb (K, r.contour_border_ends_); // Skip calculation if we have insofficient informative data if (raw_pixels.size() == 0 || K.size() == 0) @@ -59,7 +60,7 @@ void RadialDistributionFeature::calculate (LR& r, const Fsettings& s) this->cached_num_pixels = raw_pixels.size(); // Find the center (most distant pixel from the edge) - int idxO = Pixel2::find_center(raw_pixels, K, pixel_w, pixel_h); + int idxO = Pixel2::find_center(raw_pixels, Kb, pixel_w, pixel_h); // Cache it this->cached_center_x = raw_pixels[idxO].x; @@ -69,15 +70,15 @@ void RadialDistributionFeature::calculate (LR& r, const Fsettings& s) const Pixel2& pxO = raw_pixels[idxO]; // Max radius - double dstOC = std::sqrt (pxO.max_sqdist (K, pixel_w, pixel_h)); //std::sqrt(pxContour.sqdist(pxO)); + double dstOC = std::sqrt (pxO.max_sqdist (Kb, pixel_w, pixel_h)); for (auto& pxA : raw_pixels) { // Distance center to cloud pixel double dstOA = std::sqrt(pxA.sqdist(pxO, pixel_w, pixel_h)); - // Find the radial bin index - double rat = dstOA / dstOC; + // Find the radial bin index. The radius is 0 only for a one-pixel ROI, whose pixel is the centre. + double rat = dstOC > 0 ? dstOA / dstOC : 0.0; int bi = int(rat * (n-1)); // bin index if (bi >= n) bi = n - 1; @@ -106,7 +107,7 @@ void RadialDistributionFeature::calculate (LR& r, const Fsettings& s) void RadialDistributionFeature::osized_add_online_pixel(size_t x, size_t y, uint32_t intensity) {} -size_t RadialDistributionFeature::find_center_NT (const OutOfRamPixelCloud& cloud, const std::vector& contour) +size_t RadialDistributionFeature::find_center_NT (const OutOfRamPixelCloud& cloud, const BorderedContour& contour) { int idxMinDif = 0; auto minmaxDist = cloud[idxMinDif].min_max_sqdist(contour); @@ -130,8 +131,8 @@ void RadialDistributionFeature::osized_calculate (LR& r, const Fsettings& s, Ima auto n = RadialDistributionFeature::num_bins; - std::vector K; - r.merge_multicontour(K); + const std::vector& K = r.contour_pixels_; + const BorderedContour Kb (K, r.contour_border_ends_); // Skip calculation if we have insofficient informative data if (r.raw_pixels_NT.size() == 0 || K.size() == 0) @@ -141,7 +142,7 @@ void RadialDistributionFeature::osized_calculate (LR& r, const Fsettings& s, Ima this->cached_num_pixels = r.raw_pixels_NT.size(); // Find the center (most distant pixel from the edge) - int idxO = find_center_NT (r.raw_pixels_NT, K); + int idxO = find_center_NT (r.raw_pixels_NT, Kb); // Cache it this->cached_center_x = r.raw_pixels_NT[idxO].x; @@ -151,15 +152,15 @@ void RadialDistributionFeature::osized_calculate (LR& r, const Fsettings& s, Ima const Pixel2 pxO = r.raw_pixels_NT[idxO]; // Max radius - double dstOC = std::sqrt(pxO.max_sqdist(K)); + double dstOC = std::sqrt(pxO.max_sqdist(Kb)); for (auto pxA : r.raw_pixels_NT) { // Distance center to cloud pixel double dstOA = std::sqrt(pxA.sqdist(pxO)); - // Find the radial bin index - double rat = dstOA / dstOC; + // Find the radial bin index. The radius is 0 only for a one-pixel ROI, whose pixel is the centre. + double rat = dstOC > 0 ? dstOA / dstOC : 0.0; int bi = int(rat * (n - 1)); // bin index if (bi >= n) bi = n - 1; diff --git a/src/nyx/features/radial_distribution.h b/src/nyx/features/radial_distribution.h index 8dc6bfb98..abc115971 100644 --- a/src/nyx/features/radial_distribution.h +++ b/src/nyx/features/radial_distribution.h @@ -63,7 +63,7 @@ class RadialDistributionFeature: public FeatureMethod void get_RadialCV(); // Returns the index of the pixel in parameter 'cloud' having maximum distance from 'contour' - size_t find_center_NT (const OutOfRamPixelCloud& cloud, const std::vector& contour); + size_t find_center_NT (const OutOfRamPixelCloud& cloud, const BorderedContour& contour); // Zeros the counters void reset_buffers(); diff --git a/src/nyx/features/roi_radius.cpp b/src/nyx/features/roi_radius.cpp index 9760dfc0d..095738309 100644 --- a/src/nyx/features/roi_radius.cpp +++ b/src/nyx/features/roi_radius.cpp @@ -68,9 +68,7 @@ RoiRadiusFeature::RoiRadiusFeature() : FeatureMethod("RoiRadiusFeature") void RoiRadiusFeature::calculate (LR& r, const Fsettings& s) { const std::vector& cloud = r.raw_pixels; - - std::vector K; - r.merge_multicontour (K); + const std::vector& K = r.contour_pixels_; std::vector dists; dists.reserve (cloud.size()); @@ -85,8 +83,9 @@ void RoiRadiusFeature::calculate (LR& r, const Fsettings& s) // extents are measured here rather than read from r.aabb: that member is accumulated while the // ROI is scanned, not derived from raw_pixels, so a caller that fills the cloud by another route // would leave it disagreeing with the pixels and the lookups below would run off the raster. - // The contour is traced on a padded image and comes back offset from the pixel cloud, so its - // extent is not the cloud's either, and taking the union is what keeps every site inside. + // A traced contour lies on the cloud's own edge pixels, but one set on the ROI by any other route + // need not, so its extent is not assumed to be the cloud's either, and taking the union is what + // keeps every site inside. StatsInt xmin = cloud[0].x, xmax = cloud[0].x, ymin = cloud[0].y, ymax = cloud[0].y; for (const auto& p : cloud) { @@ -133,9 +132,7 @@ void RoiRadiusFeature::osized_add_online_pixel(size_t x, size_t y, uint32_t inte void RoiRadiusFeature::osized_calculate (LR& r, const Fsettings& s, ImageLoader& imloader) { const auto& cloud = r.raw_pixels_NT; - - std::vector K; - r.merge_multicontour(K); + const std::vector& K = r.contour_pixels_; std::vector dists; dists.reserve (cloud.size()); diff --git a/src/nyx/globals.h b/src/nyx/globals.h index 195638bed..e23ce378c 100644 --- a/src/nyx/globals.h +++ b/src/nyx/globals.h @@ -57,6 +57,15 @@ namespace Nyxus const SaveOption saveOption, const std::string& outputPath); + // What the segmented 2D workflow sizes the GPU cache by, for its ROIs as the trivial pipeline + // holds them: the largest ROI's pixel count, the most pixels a batch holds (cloud and contour), + // and the most contour borders a batch has + struct GpuCacheSizes + { + size_t roi_area, roicloud_len, kontur_len, kontur_borders; + }; + GpuCacheSizes gpu_cache_sizes (const Environment& env); + // single-segment 2D workflow int processDataset_2D_wholeslide( Environment & env, diff --git a/src/nyx/gpu/geomoments_main.cu b/src/nyx/gpu/geomoments_main.cu index d7eeb5cb5..4b1e9ec8a 100644 --- a/src/nyx/gpu/geomoments_main.cu +++ b/src/nyx/gpu/geomoments_main.cu @@ -22,7 +22,9 @@ namespace NyxusGpu const Pixel2* d_roicloud, size_t cloud_len, const Pixel2* d_roicontour, - size_t contour_len); + size_t contour_len, + const size_t* d_border_ends, + size_t n_borders); bool ImageMomentsFeature_calcHuInvariants3(gpureal* d_state, bool weighted); @@ -43,6 +45,9 @@ namespace NyxusGpu size_t contour_len = devside.gpu_roicontours_2d.ho_lengths[roi_index]; size_t contour_offset = devside.gpu_roicontours_2d.ho_offsets[roi_index]; Pixel2* d_contour = &devside.gpu_roicontours_2d.devbuffer[contour_offset]; + size_t n_borders = devside.gpu_roicontourborders_2d.ho_lengths[roi_index]; + size_t borders_offset = devside.gpu_roicontourborders_2d.ho_offsets[roi_index]; + const size_t* d_border_ends = &devside.gpu_roicontourborders_2d.devbuffer[borders_offset]; double* d_prereduce = devside.dev_prereduce; auto& d_devicereduce_tempstorage = devside.dev_devicereduce_temp_storage; size_t devicereduce_tempstorage_szb = devside.devicereduce_temp_storage_szb; @@ -111,7 +116,9 @@ namespace NyxusGpu d_cloud, cloud_len, d_contour, - contour_len)) + contour_len, + d_border_ends, + n_borders)) return false; //==== Weighted raw moments (result in state[WRM...]) diff --git a/src/nyx/gpu/geomoments_weighting.cu b/src/nyx/gpu/geomoments_weighting.cu index 56d221be2..53728c0c5 100644 --- a/src/nyx/gpu/geomoments_weighting.cu +++ b/src/nyx/gpu/geomoments_weighting.cu @@ -22,19 +22,31 @@ namespace NyxusGpu return dist; } - __device__ double pixel_sqdist_2_contour(StatsInt x, StatsInt y, const Pixel2* d_roicontour, size_t contour_len) + // Pixel2::min_sqdist over one border of the contour, the ordered walk W[0..n), n >= 1: up to 10 + // points are scanned exhaustively, a longer walk is searched coarse to fine + __device__ double pixel_sqdist_2_border (StatsInt x, StatsInt y, const Pixel2* W, size_t n) { - size_t n = contour_len; + auto extrem_d = sqdist(x, y, W[0]); + if (n <= 10) + { + for (size_t i = 1; i < n; i++) + { + auto dist = sqdist(x, y, W[i]); + if (extrem_d > dist) + extrem_d = dist; + } + return extrem_d; + } + size_t a = 0, b = n; - auto extrem_d = sqdist(x, y, d_roicontour[a]); auto extrem_i = a; int step = (b - a) / logf(b - a); do { for (size_t i = a + step; i < b; i += step) { - auto dist = sqdist(x, y, d_roicontour[i]); + auto dist = sqdist(x, y, W[i]); if (extrem_d > dist) { extrem_d = dist; @@ -54,6 +66,23 @@ namespace NyxusGpu return extrem_d; } + // Pixel2::min_sqdist over a contour made of borders: border k is d_roicontour[ends[k-1] .. ends[k]) + // (from 0 for k == 0), and each border is searched on its own + __device__ double pixel_sqdist_2_contour(StatsInt x, StatsInt y, const Pixel2* d_roicontour, const size_t* d_border_ends, size_t n_borders) + { + double mind = 0.0; + size_t begin = 0; + for (size_t k = 0; k < n_borders; k++) + { + size_t end = d_border_ends[k]; + double dist = pixel_sqdist_2_border(x, y, d_roicontour + begin, end - begin); + if (k == 0 || dist < mind) + mind = dist; + begin = end; + } + return mind; + } + __global__ void kerCalcWeightedImage3( // output RealPixIntens* d_realintens, @@ -62,7 +91,8 @@ namespace NyxusGpu const Pixel2* d_roicloud, size_t cloud_len, const Pixel2* d_roicontour, - size_t contour_len) + const size_t* d_border_ends, + size_t n_borders) { // pixel index size_t pxIdx = threadIdx.x + blockIdx.x * blockSize; @@ -73,7 +103,7 @@ namespace NyxusGpu y = d_roicloud[pxIdx].y; // distance - double mind2 = pixel_sqdist_2_contour(x, y, d_roicontour, contour_len); + double mind2 = pixel_sqdist_2_contour(x, y, d_roicontour, d_border_ends, n_borders); double dist = std::sqrt(mind2); // weighted intensity @@ -116,6 +146,14 @@ namespace NyxusGpu [[maybe_unused]] StatsInt x = d_roicloud[pxIdx].x, y = d_roicloud[pxIdx].y; + // the contour holds the slide's corners, each once: fewer than 4 only on a slide one pixel + // wide or high, every pixel of which lies on the slide's edge + if (contour_len < 4) + { + d_realintens[pxIdx] = WEIGHTING_EPSILON; + return; + } + const Pixel2 & p = d_roicloud[pxIdx], & c0 = d_roicontour[0], & c1 = d_roicontour[1], @@ -150,7 +188,9 @@ namespace NyxusGpu const Pixel2* d_roicloud, size_t cloud_len, const Pixel2* d_roicontour, - size_t contour_len) + size_t contour_len, + const size_t* d_border_ends, + size_t n_borders) { // prepare the shape/intensity selector int ipow = need_shape_moments ? 0 : 1; @@ -160,7 +200,7 @@ namespace NyxusGpu if (wholeslide) kerCalcWeightedImageWholeslide <<< nb, blockSize >>> (d_realintens_buf, ipow, d_roicloud, cloud_len, d_roicontour, contour_len); else - kerCalcWeightedImage3 <<< nb, blockSize >>> (d_realintens_buf, ipow, d_roicloud, cloud_len, d_roicontour, contour_len); + kerCalcWeightedImage3 <<< nb, blockSize >>> (d_realintens_buf, ipow, d_roicloud, cloud_len, d_roicontour, d_border_ends, n_borders); cudaError_t ok = cudaDeviceSynchronize(); CHECKERR(ok); diff --git a/src/nyx/gpucache.cpp b/src/nyx/gpucache.cpp index 629492e29..2736c42ce 100644 --- a/src/nyx/gpucache.cpp +++ b/src/nyx/gpucache.cpp @@ -7,150 +7,166 @@ #include "helpers/helpers.h" #include "roi_cache.h" -#define OK(x) if (x == false) \ -{ \ - std::cerr << "gpu cache related error in " << __FILE__ << ":" << __LINE__; \ - return false; \ -} \ - -#define OKV(x) if (x == false) \ -{ \ - std::cerr << "gpu cache related error in " << __FILE__ << ":" << __LINE__; \ - return; \ -} \ +#ifdef NYXUS_GPU_TEST_HOOKS +bool GpusideCache::test_fail_before_gabor = false; +bool GpusideCache::test_fail_next_host_alloc = false; +#endif -template<> -bool GpuCache::clear() +namespace { - if (hobuffer) + // A host allocation, which a test build can make fail once (GpusideCache::test_fail_next_host_alloc) + void* host_alloc (size_t szb) { - std::free(hobuffer); // delete hobuffer; - hobuffer = nullptr; - - // device side buffer - if (!GpusideCache::gpu_delete(devbuffer)) - return false; - devbuffer = nullptr; +#ifdef NYXUS_GPU_TEST_HOOKS + if (GpusideCache::test_fail_next_host_alloc) + { + GpusideCache::test_fail_next_host_alloc = false; + return nullptr; + } +#endif + return std::malloc (szb ? szb : 1); // a null result means a failure, also for 0 bytes } - if (ho_lengths) + // Frees each buffer of any GpuCache that is set, on its own, and nulls it, going on after a failed + // free: an allocation that failed part way can leave a device buffer set while its host + // counterpart is not, or the other way round. Leaves the cache empty, with no capacity. + template + bool clear_cache (GpuCache& c) { - std::free(ho_lengths); // delete ho_lengths; - ho_lengths = nullptr; + std::free(c.hobuffer); + c.hobuffer = nullptr; + std::free(c.ho_lengths); + c.ho_lengths = nullptr; + std::free(c.ho_offsets); + c.ho_offsets = nullptr; + c.total_len = c.num_rois = 0; // nothing is held: a capacity check refuses any item + + bool ok = true; + if (c.devbuffer) + { + ok = GpusideCache::gpu_delete(c.devbuffer) && ok; + c.devbuffer = nullptr; + } + if (c.dev_offsets) + { + ok = GpusideCache::gpu_delete(c.dev_offsets) && ok; + c.dev_offsets = nullptr; + } + return ok; } - if (ho_offsets) + // The layout GpuCache and GpuCache share: a solid buffer of 'total_len' items on + // the host and the device, and per ROI a length and an offset into it, the offsets mirrored on the + // device. A failure frees what was allocated. + template + bool alloc_with_offsets (GpuCache& c, size_t total_len, size_t num_rois) { - std::free(ho_offsets); // delete ho_offsets; - ho_offsets = nullptr; - - // device side buffer - if (!GpusideCache::gpu_delete(dev_offsets)) - return false; - dev_offsets = nullptr; + c.total_len = total_len; + c.num_rois = num_rois; + c.hobuffer = (T*) host_alloc(total_len * sizeof(T)); + c.ho_lengths = (size_t*) host_alloc(num_rois * sizeof(size_t)); + c.ho_offsets = (size_t*) host_alloc(num_rois * sizeof(size_t)); + if (c.hobuffer && c.ho_lengths && c.ho_offsets + && GpusideCache::allocate_on_device((void**)&c.devbuffer, total_len * sizeof(T)) + && GpusideCache::allocate_on_device((void**)&c.dev_offsets, num_rois * sizeof(size_t))) + return true; + clear_cache(c); + return false; } - return true; -} - -template<> -bool GpuCache::clear() -{ - if (hobuffer) + // The layout of the other caches: a solid buffer of roi_buf_len items per ROI on the host and the + // device, with no per-ROI lengths or offsets. A failure frees what was allocated. + template + bool alloc_plain (GpuCache& c, size_t roi_buf_len, size_t num_rois) { - std::free(hobuffer); // delete hobuffer; - hobuffer = nullptr; - - // device side buffer - if (!GpusideCache::gpu_delete(devbuffer)) - return false; - devbuffer = nullptr; + c.num_rois = num_rois; + c.total_len = roi_buf_len * num_rois; + c.hobuffer = (T*) host_alloc(c.total_len * sizeof(T)); + if (c.hobuffer && GpusideCache::allocate_on_device((void**)&c.devbuffer, c.total_len * sizeof(T))) + return true; + clear_cache(c); + return false; } - if (ho_lengths) + template + size_t szb_with_offsets (size_t total_len, size_t num_rois) { - std::free(ho_lengths); // delete ho_lengths; - ho_lengths = nullptr; + // see alloc_with_offsets() ! + return total_len * sizeof(T) + num_rois * sizeof(size_t); } - if (ho_offsets) + // uploads the first n_items of the buffer (at most its total_len) and every ROI's offset + template + bool upload_with_offsets (GpuCache& c, size_t n_items) { - std::free(ho_offsets); // delete ho_offsets; - ho_offsets = nullptr; - - // device side buffer - if (!GpusideCache::gpu_delete(dev_offsets)) + if (n_items > c.total_len) + return false; + if (n_items && !GpusideCache::upload_on_device((void*)c.devbuffer, (void*)c.hobuffer, n_items * sizeof(T))) return false; - dev_offsets = nullptr; + if (!GpusideCache::upload_on_device((void*)c.dev_offsets, (void*)c.ho_offsets, c.num_rois * sizeof(size_t))) + return false; + return true; } +} - return true; +template<> +bool GpuCache::clear() +{ + return clear_cache(*this); +} + +template<> +bool GpuCache::clear() +{ + return clear_cache(*this); } template<> bool GpuCache::clear() { - throw std::runtime_error("unimplemented GpuCache/numeric/clear"); - return true; + return clear_cache(*this); } template<> -bool GpuCache::alloc (size_t total_len__, size_t num_rois__) +bool GpuCache::alloc (size_t total_len__, size_t num_rois__) { - total_len = total_len__; - num_rois = num_rois__; + return alloc_with_offsets(*this, total_len__, num_rois__); +} - // allocate on device - size_t szb = total_len * sizeof(devbuffer[0]); - if (! GpusideCache::allocate_on_device ((void**)&devbuffer, szb)) - return false; +template<> +size_t GpuCache::szb (size_t total_len, size_t num_rois) +{ + return szb_with_offsets(total_len, num_rois); +} - szb = num_rois * sizeof(dev_offsets[0]); - if (! GpusideCache::allocate_on_device ((void**)&dev_offsets, szb)) - return false; +template<> +bool GpuCache::upload() +{ + return upload_with_offsets(*this, total_len); +} - // allocate on host - - hobuffer = (Pixel2*) std::malloc(total_len * sizeof(hobuffer[0])); - if (!hobuffer) return false; - - ho_lengths = (size_t*) std::malloc(num_rois * sizeof(ho_lengths[0])); - if (!ho_lengths) return false; +template<> +bool GpuCache::upload (size_t n_items) +{ + return upload_with_offsets(*this, n_items); +} - ho_offsets = (size_t*) std::malloc(num_rois * sizeof(ho_offsets[0])); - - return true; +template<> +bool GpuCache::alloc (size_t total_len__, size_t num_rois__) +{ + return alloc_with_offsets(*this, total_len__, num_rois__); } template<> size_t GpuCache::szb(size_t total_len, size_t num_rois) { - // see implementation of alloc() ! - size_t s1 = total_len * sizeof(devbuffer[0]); - size_t s2 = num_rois * sizeof(dev_offsets[0]); - return s1 + s2; + return szb_with_offsets(total_len, num_rois); } template<> -bool GpuCache::alloc(size_t roi_buf_len, size_t num_rois__) +bool GpuCache::alloc (size_t roi_buf_len, size_t num_rois__) { - num_rois = num_rois__; - total_len = roi_buf_len * num_rois; - - // allocate on host - hobuffer = (gpureal*) std::malloc(total_len * sizeof(hobuffer[0])); - - // not allocating ho_lengths - // not allocating ho_offsets - - // allocate on device - size_t szb = total_len * sizeof(devbuffer[0]); - if (!GpusideCache::allocate_on_device((void**)&devbuffer, szb)) - return false; - - // not allocating dev_offsets - - return true; + return alloc_plain(*this, roi_buf_len, num_rois__); } template<> @@ -163,15 +179,7 @@ size_t GpuCache::szb(size_t total_len, size_t num_rois) template<> bool GpuCache::upload() { - size_t szb = total_len * sizeof(devbuffer[0]); - if (!GpusideCache::upload_on_device((void*)devbuffer, (void*)hobuffer, szb)) - return false; - - szb = num_rois * sizeof(dev_offsets[0]); - if (!GpusideCache::upload_on_device((void*)dev_offsets, (void*)ho_offsets, szb)) - return false; - - return true; + return upload_with_offsets(*this, total_len); } template<> @@ -187,57 +195,13 @@ bool GpuCache::download() template<> bool GpuCache::clear() { - if (hobuffer) - { - std::free(hobuffer); - hobuffer = nullptr; - - // device side buffer - if (!GpusideCache::gpu_delete(devbuffer)) - return false; - devbuffer = nullptr; - } - - if (ho_lengths) - { - std::free(ho_lengths); - ho_lengths = nullptr; - } - - if (ho_offsets) - { - std::free(ho_offsets); - ho_offsets = nullptr; - - // device side buffer - if (!GpusideCache::gpu_delete(dev_offsets)) - return false; - dev_offsets = nullptr; - } - - return true; + return clear_cache(*this); } template<> -bool GpuCache::alloc(size_t roi_buf_len, size_t num_rois__) +bool GpuCache::alloc (size_t roi_buf_len, size_t num_rois__) { - num_rois = num_rois__; - total_len = roi_buf_len * num_rois; - - // allocate on host - hobuffer = (cufftDoubleComplex*)std::malloc(total_len * sizeof(hobuffer[0])); - - // not allocating ho_lengths - // not allocating ho_offsets - - // allocate on device - size_t szb = total_len * sizeof(devbuffer[0]); - if (!GpusideCache::allocate_on_device((void**)&devbuffer, szb)) - return false; - - // not allocating dev_offsets - - return true; + return alloc_plain(*this, roi_buf_len, num_rois__); } template<> @@ -253,55 +217,13 @@ bool GpuCache::download() template<> bool GpuCache::clear() { - if (hobuffer) - { - std::free(hobuffer); - hobuffer = nullptr; - - // device side buffer - if (!GpusideCache::gpu_delete(devbuffer)) - return false; - devbuffer = nullptr; - } - - if (ho_lengths) - { - std::free(ho_lengths); - ho_lengths = nullptr; - } - - if (ho_offsets) - { - std::free(ho_offsets); - ho_offsets = nullptr; - - // device side buffer - if (!GpusideCache::gpu_delete(dev_offsets)) - return false; - dev_offsets = nullptr; - } - - return true; + return clear_cache(*this); } template<> -bool GpuCache::alloc(size_t roi_buf_len, size_t num_rois__) +bool GpuCache::alloc (size_t roi_buf_len, size_t num_rois__) { - num_rois = num_rois__; - total_len = roi_buf_len * num_rois; - - // allocate on host - hobuffer = (PixIntens*) std::malloc(total_len * sizeof(hobuffer[0])); - - // not allocating ho_lengths - // not allocating ho_offsets - - // allocate on device - size_t szb = total_len * sizeof(devbuffer[0]); - if (!GpusideCache::allocate_on_device((void**)&devbuffer, szb)) - return false; - - return true; + return alloc_plain(*this, roi_buf_len, num_rois__); } //********* float @@ -309,55 +231,13 @@ bool GpuCache::alloc(size_t roi_buf_len, size_t num_rois__) template<> bool GpuCache::clear() { - if (hobuffer) - { - std::free(hobuffer); - hobuffer = nullptr; - - // device side buffer - if (!GpusideCache::gpu_delete(devbuffer)) - return false; - devbuffer = nullptr; - } - - if (ho_lengths) - { - std::free(ho_lengths); - ho_lengths = nullptr; - } - - if (ho_offsets) - { - std::free(ho_offsets); - ho_offsets = nullptr; - - // device side buffer - if (!GpusideCache::gpu_delete(dev_offsets)) - return false; - dev_offsets = nullptr; - } - - return true; + return clear_cache(*this); } template<> -bool GpuCache::alloc(size_t roi_buf_len, size_t num_rois__) +bool GpuCache::alloc (size_t roi_buf_len, size_t num_rois__) { - num_rois = num_rois__; - total_len = roi_buf_len * num_rois; - - // allocate on host - hobuffer = (float*)std::malloc(total_len * sizeof(hobuffer[0])); - - // not allocating ho_lengths - // not allocating ho_offsets - - // allocate on device - size_t szb = total_len * sizeof(devbuffer[0]); - if (!GpusideCache::allocate_on_device((void**)&devbuffer, szb)) - return false; - - return true; + return alloc_plain(*this, roi_buf_len, num_rois__); } template<> diff --git a/src/nyx/reduce_trivial_rois.cpp b/src/nyx/reduce_trivial_rois.cpp index 2096ad738..7ddb4789c 100644 --- a/src/nyx/reduce_trivial_rois.cpp +++ b/src/nyx/reduce_trivial_rois.cpp @@ -262,7 +262,9 @@ namespace Nyxus doGabor = GaborFeature::required (env.theFeatureSet); if (doEros || doImoms || doSmoms || doGabor) { - if (env.using_gpu()) + // Only the segmented 2D workflow allocates the GPU cache; the ROIs of the other paths that + // reach here (2.5D, 3D, arrays), and of a run whose allocation failed, are computed on the CPU + if (env.using_gpu() && env.devCache.is_ready()) { size_t gbl = env.devCache.gpu_batch_len; // former NyxusGpu::gpu_batch_len // Cache ROI clouds and contours @@ -309,7 +311,7 @@ namespace Nyxus } else { - // user refused the GPU opportunity, so route calculation via the regular CPU-multithreaded way + // the GPU is off, or its cache is not ready: the regular CPU-multithreaded way if (doEros) { diff --git a/src/nyx/roi_cache.cpp b/src/nyx/roi_cache.cpp index b497d011e..7ef138b5b 100644 --- a/src/nyx/roi_cache.cpp +++ b/src/nyx/roi_cache.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include "globals.h" #include "roi_cache.h" @@ -62,6 +64,9 @@ void LR::recycle_aux_obj (RoiDataCacheItem itm) break; case CONTOUR: multicontour_.clear(); + multicontour_hole_.clear(); + contour_pixels_.clear(); + contour_border_ends_.clear(); break; case CONVEX_HULL: convHull_CH.clear(); @@ -116,12 +121,23 @@ void LR::initialize_fvals() valVec.push_back(0.0); } -void LR::merge_multicontour (std::vector &super_c) const +void LR::merge_multicontour() { - super_c.clear(); + contour_pixels_.clear(); + contour_border_ends_.clear(); + std::unordered_set seen; for (const std::vector &c : multicontour_) - super_c.insert (std::end(super_c), std::begin(c), std::end(c)); + { + const size_t begin = contour_pixels_.size(); + for (const Pixel2 &p : c) + if (seen.insert ((uint64_t(uint32_t(p.x)) << 32) | uint32_t(p.y)).second) + contour_pixels_.push_back (p); + + // a border all of whose pixels earlier borders hold adds no run + if (contour_pixels_.size() > begin) + contour_border_ends_.push_back (contour_pixels_.size()); + } } diff --git a/src/nyx/roi_cache.h b/src/nyx/roi_cache.h index de130263d..bb7aed125 100644 --- a/src/nyx/roi_cache.h +++ b/src/nyx/roi_cache.h @@ -67,8 +67,24 @@ class LR: public BasicLR // guard (aux_min == aux_max) always keys off computed extrema rather than an indeterminate scalar. PixIntens aux_min = (std::numeric_limits::max)(), aux_max = 0; + // One closed walk per border of the ROI (outer border of each fragment, border of each hole). + // A walk passes a one-pixel-wide part of the ROI twice, and two borders can share a pixel. std::vector> multicontour_; - void merge_multicontour (std::vector &flattened_contour) const; + // multicontour_hole_[k] is true when border k is the border of a hole. A border without an entry + // is an outer one. + std::vector multicontour_hole_; + + // The ROI's contour pixels, each once: the borders of multicontour_ one after another, every pixel + // kept where it is first met. Each border that adds pixels adds one end to contour_border_ends_, + // and its pixels are contour_pixels_[previous end .. its end) (from 0 for the first), in the order + // of its walk; a border all of whose pixels earlier borders hold adds none, so the ends are not + // indexed like multicontour_. The ends rise strictly and the last is contour_pixels_.size(), the + // layout border_ends_cover() (pixel.h) checks. The whole is not a walk: it jumps between borders. + std::vector contour_pixels_; + std::vector contour_border_ends_; + // Fills contour_pixels_ and contour_border_ends_ from multicontour_. ContourFeature calls it once + // per ROI after tracing; whatever sets multicontour_ by another route calls it after. + void merge_multicontour(); std::vector convHull_CH; diff --git a/src/nyx/slideprops.cpp b/src/nyx/slideprops.cpp index 8a5283b58..8a6e21d0e 100644 --- a/src/nyx/slideprops.cpp +++ b/src/nyx/slideprops.cpp @@ -161,6 +161,7 @@ namespace Nyxus // iterate abstract tiles (in a tiled slide /e.g. tiled tiff/ they correspond to physical tiles, in a nontiled slide /e.g. scanline tiff or strip tiff/ they correspond to ) int cnt = 1; + size_t nRowRuns = 0; for (unsigned int row = 0; row < ntVert; row++) for (unsigned int col = 0; col < ntHor; col++) { @@ -175,6 +176,7 @@ namespace Nyxus } // Iterate pixels + uint32_t prevMsk = 0; // the label of the pixel to the left in the tile's row for (size_t i = 0; i < tileSize; i++) { int y = row * th + i / tw, @@ -202,6 +204,13 @@ namespace Nyxus if (!wholeslide) msk = ilo.get_cur_tile_seg_pixel(i); + // a row run of a ROI starts where the label changes along a tile's row, or at the row's + // start + if (i % tw == 0) + prevMsk = 0; + const bool runStart = msk != prevMsk; + prevMsk = msk; + // Skip non-mask pixels if (!msk) continue; @@ -213,6 +222,9 @@ namespace Nyxus slide_I_min = (std::min)(slide_I_min, dxequiv_I); } + if (runStart) + nRowRuns++; + // Update pixel's ROI metrics // - the following block mocks feed_pixel_2_metrics (x, y, dataI[i], msk, tidx) if (U.find(msk) == U.end()) @@ -301,6 +313,7 @@ namespace Nyxus p.max_roi_area = maxArea; p.n_rois = R.size(); + p.n_row_runs = nRowRuns; p.max_roi_w = max_w; p.max_roi_h = max_h; diff --git a/src/nyx/slideprops.h b/src/nyx/slideprops.h index 941398eb7..2c14d6822 100644 --- a/src/nyx/slideprops.h +++ b/src/nyx/slideprops.h @@ -44,6 +44,7 @@ class SlideProps slide_w = slide_h = volume_d = 0; max_roi_area = 0; n_rois = 0; + n_row_runs = 0; max_roi_w = 0; max_roi_h = 0; max_roi_d = 0; @@ -171,6 +172,12 @@ class SlideProps max_roi_h, max_roi_d; + // The number of row runs of the slide's 2D ROIs, all ROIs together: maximal horizontal stretches of + // one ROI's pixels, a stretch cut by a tile's left edge counted as two. A ROI's contour has no more + // borders than twice its row runs, since every outer border starts at a run's first pixel and every + // hole's border at a run's last. 0 for a volume or a montage. + size_t n_row_runs; + // slide file names std::string fname_int, fname_seg; diff --git a/src/nyx/workflow_2d_segmented.cpp b/src/nyx/workflow_2d_segmented.cpp index b079b9660..6981d2ca0 100644 --- a/src/nyx/workflow_2d_segmented.cpp +++ b/src/nyx/workflow_2d_segmented.cpp @@ -8,6 +8,9 @@ #include #include #include +#include +#include +#include #ifdef WITH_PYTHON_H #include @@ -158,6 +161,45 @@ namespace Nyxus return true; } + GpuCacheSizes gpu_cache_sizes (const Environment& env) + { + // The trivial pipeline resamples a ROI only with custom anisotropy (--anisox, --anisoy; not + // the physical pixel size), each pixel to at most ceil(ax) columns and ceil(ay) rows. A + // resampled row holds the runs of the row it repeats, so a slide's borders, at most twice its + // row runs (SlideProps::n_row_runs), grow by ceil(ay) only. + size_t rx = 1, ry = 1; + if (env.anisoOptions.customized()) + { + const double ax = env.anisoOptions.get_aniso_x(), ay = env.anisoOptions.get_aniso_y(); + rx = ax > 1.0 ? (size_t) std::ceil (ax) : 1; + ry = ay > 1.0 ? (size_t) std::ceil (ay) : 1; + } + + GpuCacheSizes s; + s.roi_area = env.dataset.dataset_max_roi_area * rx * ry; + s.roicloud_len = env.dataset.dataset_max_combined_roicloud_len * rx * ry; + s.kontur_len = env.singleROI ? 4 : s.roicloud_len; // a whole slide's contour is its 4 corners + + s.kontur_borders = 1; // a whole slide's contour is one border + if (!env.singleROI) + for (const SlideProps& p : env.dataset.dataset_props) + s.kontur_borders = (std::max) (s.kontur_borders, 2 * p.n_row_runs * ry); + return s; + } + +#ifdef USE_GPU + // Frees the GPU cache processDataset_2D_segmented allocated, if it allocated one + static bool free_gpu_cache_of (Environment& env) + { + if (! env.using_gpu()) + return true; + GpusideCache& g = env.devCache; + return g.free_gpu_cache(g.gpu_roiclouds_2d, g.gpu_roicontours_2d, g.gpu_roicontourborders_2d, g.dev_realintens, + g.dev_prereduce, g.gpu_featurestatebuf, g.dev_devicereduce_temp_storage, g.dev_imat1, g.dev_imat2, + g.gabor_linear_image, g.gabor_result, g.gabor_linear_kernel, g.gabor_energy_image); + } +#endif + int processDataset_2D_segmented ( Environment & env, const std::vector& intensFiles, @@ -216,8 +258,7 @@ namespace Nyxus needSmoments = Smoms2D_feature::required(env.theFeatureSet), needMoments = needImoments || needSmoments; - // whole slide's contour is just 4 vertices long - size_t kontrLen = env.singleROI ? 4 : env.dataset.dataset_max_combined_roicloud_len; + const GpuCacheSizes gsz = gpu_cache_sizes (env); if (env.using_gpu()) { @@ -227,6 +268,7 @@ namespace Nyxus // out env.devCache.gpu_roiclouds_2d, env.devCache.gpu_roicontours_2d, + env.devCache.gpu_roicontourborders_2d, &env.devCache.dev_realintens, &env.devCache.dev_prereduce, env.devCache.gpu_featurestatebuf, @@ -244,10 +286,11 @@ namespace Nyxus needErosion, needGabor, needMoments, - env.dataset.dataset_max_combined_roicloud_len, // desired totCloLen, - kontrLen, // desired totKontLen, + gsz.roicloud_len, // desired totCloLen, + gsz.kontur_len, // desired totKontLen, + gsz.kontur_borders, env.dataset.dataset_max_n_rois, // labels.size() - env.dataset.dataset_max_roi_area, + gsz.roi_area, env.dataset.dataset_max_roi_w, env.dataset.dataset_max_roi_h, GaborFeature::f0_theta_pairs.size(), @@ -313,8 +356,66 @@ namespace Nyxus return 1; } - // Do phased processing: prescan, trivial ROI processing, oversized ROI processing - if (! processIntSegImagePair(env, ifp, lfp, i, nf)) + // Do phased processing: prescan, trivial ROI processing, oversized ROI processing. A failure + // that throws, such as a ROI batch the GPU cache cannot take, ends the run here, with the + // GPU cache freed. + bool pairOk = false; + // frees the GPU cache on the way out; a failure to free cannot replace the error being reported +#ifdef USE_GPU + auto release_gpu_cache = [&env] () + { + try { free_gpu_cache_of (env); } catch (...) {} + }; +#else + auto release_gpu_cache = [] () {}; +#endif + try + { + pairOk = processIntSegImagePair(env, ifp, lfp, i, nf); + } + catch (const std::exception& e) + { + std::cerr << "Error featurizing segmented slide " << ifp << ": " << e.what() << "\n"; + release_gpu_cache(); +#ifdef WITH_PYTHON_H + throw; +#else + return 1; +#endif + } + catch (const std::string& s) + { + // the trivial path also throws strings and string literals; under Python they go on as + // std::runtime_error, which pybind11 carries with its text + std::cerr << "Error featurizing segmented slide " << ifp << ": " << s << "\n"; + release_gpu_cache(); +#ifdef WITH_PYTHON_H + throw std::runtime_error (s); +#else + return 1; +#endif + } + catch (const char* s) + { + std::cerr << "Error featurizing segmented slide " << ifp << ": " << s << "\n"; + release_gpu_cache(); +#ifdef WITH_PYTHON_H + throw std::runtime_error (s); +#else + return 1; +#endif + } + catch (...) + { + std::cerr << "Error featurizing segmented slide " << ifp << "\n"; + release_gpu_cache(); +#ifdef WITH_PYTHON_H + throw; +#else + return 1; +#endif + } + if (! pairOk) { std::cerr << "Error featurizing segmented slide " << ifp << " @ " << __FILE__ << ":" << __LINE__ << "\n"; return 1; @@ -406,25 +507,10 @@ namespace Nyxus } #ifdef USE_GPU - if (env.using_gpu()) + if (! free_gpu_cache_of (env)) { - if (! env.devCache.free_gpu_cache( - env.devCache.gpu_roiclouds_2d, - env.devCache.gpu_roicontours_2d, - env.devCache.dev_realintens, - env.devCache.dev_prereduce, - env.devCache.gpu_featurestatebuf, - env.devCache.dev_devicereduce_temp_storage, - env.devCache.dev_imat1, - env.devCache.dev_imat2, - env.devCache.gabor_linear_image, - env.devCache.gabor_result, - env.devCache.gabor_linear_kernel, - env.devCache.gabor_energy_image)) - { - std::cerr << "error in free_gpu_cache()\n"; - return 1; - } + std::cerr << "error in free_gpu_cache()\n"; + return 1; } #endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 509eecdaa..7a4017bbe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,9 @@ set(TEST_SRC # compile units belong here. add_executable(runAllTests ${TEST_SRC}) +# the GPU cache's test hooks (GpusideCache::test_fail_*) exist in this target only +target_compile_definitions(runAllTests PRIVATE NYXUS_GPU_TEST_HOOKS) + if(USEGPU) include_directories("${CUDA_INCLUDE_DIRS}") if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") diff --git a/tests/python/test_2d_ooc_regression.py b/tests/python/test_2d_ooc_regression.py index bc7eaab3d..7eec02415 100644 --- a/tests/python/test_2d_ooc_regression.py +++ b/tests/python/test_2d_ooc_regression.py @@ -48,13 +48,14 @@ DIAMETER_EQUAL_PERIMETER_OUT_OF_CORE = 35.65070725258456 # The same contour reaches RoiRadiusFeature, which measures every ROI pixel against it. Both builders -# return 112 contour pixels here, so the gap is not a count: it is which pixels they are. MAX is -# unchanged to the last digit while MEAN and MEDIAN rise, i.e. some ROI pixels are farther from the -# out-of-core contour and the one attaining the maximum is not among them. -ROI_RADIUS_MEAN_IN_RAM = 6.087109772358636 -ROI_RADIUS_MEAN_OUT_OF_CORE = 7.169174091182726 +# return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding +# box, whose origin here is (12, 12): measuring the ROI against the scikit-image inner boundary +# shifted by (-12, -12) reproduces all three out-of-core values, and against the unshifted boundary +# all three in-RAM ones. MAX happens to come out the same either way. +ROI_RADIUS_MEAN_IN_RAM = 6.047026418093836 +ROI_RADIUS_MEAN_OUT_OF_CORE = 7.423593657857253 ROI_RADIUS_MEDIAN_IN_RAM = 5.0 -ROI_RADIUS_MEDIAN_OUT_OF_CORE = 6.708203932499369 +ROI_RADIUS_MEDIAN_OUT_OF_CORE = 7.0 ROI_RADIUS_MAX_BOTH_PATHS = 19.026297590440446 @@ -149,11 +150,10 @@ def test_2d_ooc_roi_radius_diverges_from_in_ram_regression(tmp_path): """ROI_RADIUS_MEAN and ROI_RADIUS_MEDIAN do not survive the out-of-core round trip. Not a units or arithmetic difference: both paths take sqrt(exact_min_sqdist(K)) over the same - ROI pixels, so the only input that can differ is K, and it does. Both builders return 112 contour - pixels on this fixture -- the in-RAM count is measured in test_2d_morphology_skimage.h's disk - table and the out-of-core count is what PERIMETER reports there -- so the two contours are the - same size and different sets. No translation of the boundary reproduces the out-of-core numbers, - which is why this is pinned as a characterization rather than explained as an offset. + ROI pixels, so the only input that can differ is K, and it does. Both builders return the same + 112 edge pixels on this fixture, but the out-of-core contour is relative to the bounding box, + whose origin is (12, 12), while the ROI pixels it is measured against are absolute. The + scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values. MAX is asserted EQUAL on both paths deliberately. It is the part that already agrees, so a fix that repairs MEAN and MEDIAN by moving the whole contour would break it, and this test would say diff --git a/tests/python/test_nyxus.py b/tests/python/test_nyxus.py index c2ff69d03..78cc925e4 100644 --- a/tests/python/test_nyxus.py +++ b/tests/python/test_nyxus.py @@ -847,7 +847,8 @@ def test_montage_hounsfeld_data (self): def test_bad_contour (self): ''' - Testing Nyxus ability to not crash ingesting a segment without any contour e.g. speckles only + Testing Nyxus ability to not crash ingesting a segment of speckles only: each isolated + pixel is a border of its own, so PERIMETER is 0 and every pixel is an edge pixel ''' I = np.random.randint(10, 50, size=(10, 10)) # nonzero everywhere M = np.array([ @@ -866,7 +867,7 @@ def test_bad_contour (self): f = nyx.featurize (I, M, intensity_names=['I'], label_names=['M']) assert f.at[0, "PERIMETER"] == 0 assert f.at[0, "DIAMETER_EQUAL_PERIMETER"] == 0 - assert f.at[0, "EDGE_MEAN_INTENSITY"] == 0 + assert f.at[0, "EDGE_MEAN_INTENSITY"] == pytest.approx (I[M > 0].mean()) def test_3d_glcm_compatibility (self): ''' diff --git a/tests/test_2d_contour_analytic.h b/tests/test_2d_contour_analytic.h index 91e5629ee..3e2eaff16 100644 --- a/tests/test_2d_contour_analytic.h +++ b/tests/test_2d_contour_analytic.h @@ -2,8 +2,7 @@ #include #include -#include "test_data.h" -#include "../src/nyx/features/contour.h" +#include "test_2d_contour_common.h" void test_2d_contour_single_tailed_analytic() { @@ -213,7 +212,190 @@ void test_2d_contour_multi_connected_analytic() 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 // 46 }; + // Three fragments: unlike the disconnected variant, the upper-right shape is one piece here, its + // rows 8 and 9 meeting diagonally, so it has one border, not two. std::vector> multic; ContourFeature::gather_multicontour (multic, I, w-2, h-2, 0/*verbose lvl*/); - ASSERT_TRUE (multic.size() == 4); + ASSERT_TRUE (multic.size() == 3); +} + +// The closed forms below are evaluated on ROIs placed away from the image origin, so a contour +// reported in any frame other than the ROI's own absolute pixel coordinates cannot match them. + +// A filled n x n square: one border, made of the 4(n-1) pixels on its edge, walked in unit steps, +// so PERIMETER = 4(n-1). +void test_2d_contour_square_analytic() +{ + const int n = 10, x0 = 37, y0 = 52; + std::set> square, edge; + for (int y = y0; y < y0 + n; y++) + for (int x = x0; x < x0 + n; x++) + { + square.insert({ x, y }); + if (x == x0 || x == x0 + n - 1 || y == y0 || y == y0 + n - 1) + edge.insert({ x, y }); + } + ASSERT_EQ (edge.size(), size_t(4 * (n - 1))); + + LR r (1); + contour_roi_from_pixels (r, square); + contour_calculate (r); + + ASSERT_EQ (r.multicontour_.size(), size_t(1)); + ASSERT_TRUE (contour_is_closed_8_walk (r.multicontour_[0])); + ASSERT_EQ (r.multicontour_[0].size(), edge.size()); + ASSERT_EQ (contour_pixel_set (r), edge); + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::PERIMETER][0], 4.0 * (n - 1), 1e-9); +} + +// A filled digital disk (x-cx)^2 + (y-cy)^2 <= R^2. Its border is exactly the disk pixels with a +// 4-neighbour outside the disk, and each of them lies at a squared distance in ((R-1)^2, R^2] from +// the centre: a pixel at distance <= R-1 has all four neighbours within R. +void test_2d_contour_disk_analytic() +{ + const int R = 20, cx = 60, cy = 45; + auto inside = [&](int x, int y) { return (x - cx) * (x - cx) + (y - cy) * (y - cy) <= R * R; }; + std::set> disk, edge; + for (int y = cy - R; y <= cy + R; y++) + for (int x = cx - R; x <= cx + R; x++) + if (inside (x, y)) + { + disk.insert({ x, y }); + if (!inside (x - 1, y) || !inside (x + 1, y) || !inside (x, y - 1) || !inside (x, y + 1)) + edge.insert({ x, y }); + } + + LR r (1); + contour_roi_from_pixels (r, disk); + contour_calculate (r); + + ASSERT_EQ (r.multicontour_.size(), size_t(1)); + ASSERT_TRUE (contour_is_closed_8_walk (r.multicontour_[0])); + ASSERT_EQ (contour_pixel_set (r), edge); + for (const Pixel2& p : r.multicontour_[0]) + { + int d2 = ((int)p.x - cx) * ((int)p.x - cx) + ((int)p.y - cy) * ((int)p.y - cy); + ASSERT_GT (d2, (R - 1) * (R - 1)); + ASSERT_LE (d2, R * R); + } +} + +// A 12 x 12 square with a centred 4 x 4 hole: two borders. The outer one walks the 44 edge pixels +// in unit steps. The hole's one walks the 16 pixels 4-adjacent to the hole, 3 unit steps along +// each side and one diagonal step around each corner. PERIMETER is their sum, 44 + 12 + 4*sqrt(2), +// with nothing joining the two. +void test_2d_contour_ring_analytic() +{ + const int n = 12, x0 = 20, y0 = 30; + std::set> ring; + for (int y = 0; y < n; y++) + for (int x = 0; x < n; x++) + if (!(x >= 4 && x < 8 && y >= 4 && y < 8)) + ring.insert({ x0 + x, y0 + y }); + + LR r (1); + contour_roi_from_pixels (r, ring); + contour_calculate (r); + + ASSERT_EQ (r.multicontour_.size(), size_t(2)); + double walks = 0; + for (const auto& K : r.multicontour_) + { + ASSERT_TRUE (contour_is_closed_8_walk (K)); + walks += contour_closed_walk_length (K); + } + ASSERT_EQ (contour_pixel_set (r).size(), size_t(44 + 16)); + + // The outer border is found first and walked clockwise on screen from its top-left pixel, East + // first; the hole's is walked counterclockwise. Twice the shoelace sum is positive for a + // clockwise walk when y points down. + auto twice_signed_area = [](const std::vector& K) + { + long long a = 0; + for (size_t i = 0; i < K.size(); i++) + { + const Pixel2& p = K[i == 0 ? K.size() - 1 : i - 1], & q = K[i]; + a += (long long)p.x * q.y - (long long)q.x * p.y; + } + return a; + }; + const auto& outer = r.multicontour_[0]; + ASSERT_EQ (outer[0].x, x0); + ASSERT_EQ (outer[0].y, y0); + ASSERT_EQ (outer[1].x, x0 + 1); + ASSERT_EQ (outer[1].y, y0); + ASSERT_GT (twice_signed_area (outer), 0); + ASSERT_LT (twice_signed_area (r.multicontour_[1]), 0); + + const double expected = 44.0 + 12.0 + 4.0 * std::sqrt(2.0); + ASSERT_NEAR (walks, expected, 1e-9); + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::PERIMETER][0], expected, 1e-9); +} + +// Two separate squares, 6 x 6 and 8 x 8, under one label: one border each, and PERIMETER is +// 4*5 + 4*7 = 48, the sum of the two square perimeters. +void test_2d_contour_two_fragments_analytic() +{ + const int x0 = 15, y0 = 25; + std::set> two; + for (int y = 0; y < 6; y++) + for (int x = 0; x < 6; x++) + two.insert({ x0 + x, y0 + y }); + for (int y = 1; y < 9; y++) + for (int x = 11; x < 19; x++) + two.insert({ x0 + x, y0 + y }); + + LR r (1); + contour_roi_from_pixels (r, two); + contour_calculate (r); + + ASSERT_EQ (r.multicontour_.size(), size_t(2)); + for (const auto& K : r.multicontour_) + ASSERT_TRUE (contour_is_closed_8_walk (K)); + ASSERT_EQ (contour_pixel_set (r).size(), size_t(20 + 28)); + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::PERIMETER][0], 48.0, 1e-9); +} + +// A one-pixel-wide line of n pixels: its border goes out along the line and comes back, so the walk +// visits the n-2 inner pixels twice and PERIMETER = 2(n-1). The EDGE_* statistics are over the +// ROI's edge pixels, so each pixel counts once however often the walk passes it. +void test_2d_contour_line_analytic() +{ + const int n = 5, x0 = 8, y0 = 3; + std::set> line; + double inten_sum = 0; + for (int x = x0; x < x0 + n; x++) + { + line.insert({ x, y0 }); + inten_sum += contour_fixture_intensity (x, y0); + } + + LR r (1); + contour_roi_from_pixels (r, line); + contour_calculate (r); + + ASSERT_EQ (r.multicontour_.size(), size_t(1)); + ASSERT_TRUE (contour_is_closed_8_walk (r.multicontour_[0])); + ASSERT_EQ (r.multicontour_[0].size(), size_t(2 * (n - 1))); + ASSERT_EQ (contour_pixel_set (r), line); + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::PERIMETER][0], 2.0 * (n - 1), 1e-9); + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::EDGE_INTEGRATED_INTENSITY][0], inten_sum, 1e-9); + + ASSERT_EQ (r.contour_pixels_.size(), size_t(n)); + ASSERT_EQ (r.contour_border_ends_, std::vector{ size_t(n) }); +} + +// A single pixel is its own border: one contour of one pixel, and PERIMETER 0. +void test_2d_contour_single_pixel_analytic() +{ + LR r (1); + contour_roi_from_pixels (r, { { 7, 9 } }); + contour_calculate (r); + + ASSERT_EQ (r.multicontour_.size(), size_t(1)); + ASSERT_EQ (r.multicontour_[0].size(), size_t(1)); + ASSERT_EQ (r.multicontour_[0][0].x, 7); + ASSERT_EQ (r.multicontour_[0][0].y, 9); + ASSERT_EQ (r.fvals[(int)Nyxus::Feature2D::PERIMETER][0], 0.0); + ASSERT_EQ (r.fvals[(int)Nyxus::Feature2D::EDGE_INTEGRATED_INTENSITY][0], (double)contour_fixture_intensity (7, 9)); } diff --git a/tests/test_2d_contour_common.h b/tests/test_2d_contour_common.h new file mode 100644 index 000000000..bce0c532c --- /dev/null +++ b/tests/test_2d_contour_common.h @@ -0,0 +1,186 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "test_main_nyxus.h" +#include "../src/nyx/features/contour.h" + +// Fixtures for the contour family: masks drawn as text rows ('1' = ROI, anything else = background), +// an ROI builder that places a mask anywhere in the image, and walk helpers. No reference values +// (SPEC 6.3.1): each assertion file holds its own. + +// bench_blob97_filled: a filled random blob of 9066 pixels spanning the whole 97x97 frame, with no +// holes and one 8-connected component. Its 4-adjacent inner boundary has 495 pixels. +static const std::vector bench_blob97_filled_rows = { + "1111111111111111111111111111111111111111111....11111111111.....11111111111...11111111......111111", + "1111111111111111111111111111111111111111111....111111111111....111111111111..11111111......111111", + "1111111111111111111111111111111111111111111..11111111111111...111111111111111111111111....1111111", + "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111...1111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111.11111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "...11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111..", + "11111.1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111....11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111....11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111...111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "111....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "11.....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "11.....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1......111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + ".......111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "......1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + ".....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111..", + "....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111...", + "....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111...", + "...1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111...", + "..1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111.", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111..1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "11....1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "11....1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1....11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "....111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "..11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111..", + "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111..", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "..11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + ".......111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "......1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "......1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111.", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "111111111111111111111111111111111111111111111111111...1111111111111111111111111111111111111111111", + "111111111111111111111111111111111111111111111111111...1111111111111111111111111111111111111111111", + "111111111111111111111111111111111111111111111111111....111111111111111111111111111111111111111...", + "111111.11111111111111111111111111111111111111111111....11111111111111111111111111111111111111....", + ".1111...111111111111111111111111111111111111111111....111111111111111111111111111111111111111....", + ".........1111111111111111111111111111111111111111....1111111111111111111111111111111111111111..11", + ".........111111111111111111111111111111111111111.....11111111111111111111111111111111111111111111", + "....1....111111111111111111111111111111111111111....111111111111111111111111111111111111111111111", + "..111111111111111111.1111111111111111111111111111....11111111111111111111111111111111111111111111", + "1111111111111111111...111111111111111111111111111....1111111111111111111111111111111111.111111111", + "1111111111111111111....1111111111111111111111111......11111111111111111...111111111111...11111111", + "111111111111..11111.....11111111111111111111111..........11111111111111...111111111111...11111111", + "11111111111....1111.....11111111111111111111111...........1111111111111...1111111111111..11111111", +}; + +// The pixel set a mask covers, in absolute coordinates once shifted by (x0, y0). +static std::set> contour_mask_pixels (const std::vector& rows, int x0, int y0) +{ + std::set> S; + for (int y = 0; y < (int)rows.size(); y++) + for (int x = 0; x < (int)rows[y].size(); x++) + if (rows[y][x] == '1') + S.insert({ x0 + x, y0 + y }); + return S; +} + +// A deterministic, non-constant intensity, so an EDGE_* statistic tells which pixels were visited +// and how often. +static unsigned int contour_fixture_intensity (int x, int y) +{ + return 1u + (unsigned int)((x * 7 + y * 13) % 50); +} + +// Builds a single-ROI LR from a pixel set, the way phase 1 and phase 2 do for a trivial ROI. +static void contour_roi_from_pixels (LR& r, const std::set>& S) +{ + std::vector px; + for (const auto& p : S) + px.push_back (NyxusPixel{ (size_t)p.first, (size_t)p.second, contour_fixture_intensity(p.first, p.second) }); + Nyxus::load_test_roi_data (r, px.data(), px.size()); + r.make_nonanisotropic_aabb(); +} + +// Runs ContourFeature on the ROI at the shape2d config and leaves the feature values in r.fvals. +static void contour_calculate (LR& r) +{ + Fsettings s = make_shape2d_settings(); + ContourFeature f; + f.calculate (r, s); + r.initialize_fvals(); + f.save_value (r.fvals); +} + +// True when consecutive pixels of the closed walk K, including the last-to-first step, are 8-neighbours. +static bool contour_is_closed_8_walk (const std::vector& K) +{ + for (size_t i = 0; i < K.size(); i++) + { + const Pixel2& a = K[i == 0 ? K.size() - 1 : i - 1], & b = K[i]; + if (std::abs((int)a.x - (int)b.x) > 1 || std::abs((int)a.y - (int)b.y) > 1) + return false; + } + return true; +} + +// Euclidean length of the closed walk K, the closing step included. +static double contour_closed_walk_length (const std::vector& K) +{ + if (K.size() < 2) + return 0.0; + double len = 0.0; + for (size_t i = 0; i < K.size(); i++) + len += std::sqrt (K[i == 0 ? K.size() - 1 : i - 1].sqdist(K[i])); + return len; +} + +// The distinct pixels of every contour of the ROI. +static std::set> contour_pixel_set (const LR& r) +{ + std::set> S; + for (const auto& K : r.multicontour_) + for (const Pixel2& p : K) + S.insert({ (int)p.x, (int)p.y }); + return S; +} diff --git a/tests/test_2d_contour_invariant.h b/tests/test_2d_contour_invariant.h new file mode 100644 index 000000000..6d01a045c --- /dev/null +++ b/tests/test_2d_contour_invariant.h @@ -0,0 +1,195 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "test_2d_contour_common.h" + +// The ROI pixels with a 4-neighbour outside the ROI: the edge every contour consumer is defined +// on, and the set skimage.segmentation.find_boundaries(connectivity=1, mode='inner') and +// CellProfiler's edge pixels are. +static std::set> contour_boundary_rule (const std::set>& S) +{ + std::set> B; + for (const auto& p : S) + { + int x = p.first, y = p.second; + if (!S.count({ x - 1, y }) || !S.count({ x + 1, y }) || !S.count({ x, y - 1 }) || !S.count({ x, y + 1 })) + B.insert(p); + } + return B; +} + +// Required of every ROI, however irregular: each contour is a closed walk in 8-connected steps +// over ROI pixels, and together the contours cover exactly the ROI's 4-adjacent edge pixels. Leaves +// in 'walks' the summed closed-walk length, which PERIMETER has to equal, and in 'edge_sum' the +// intensity summed over the edge pixels, each once, which EDGE_INTEGRATED_INTENSITY has to equal. +static void assert_contour_boundary_rule (const std::set>& S, LR& r, double& walks, double& edge_sum) +{ + contour_roi_from_pixels (r, S); + contour_calculate (r); + + walks = 0; + for (const auto& K : r.multicontour_) + { + ASSERT_TRUE (contour_is_closed_8_walk (K)); + for (const Pixel2& p : K) + ASSERT_TRUE (S.count({ (int)p.x, (int)p.y })) << "contour pixel (" << p.x << "," << p.y << ") is not a ROI pixel"; + walks += contour_closed_walk_length (K); + } + + const auto B = contour_boundary_rule (S); + ASSERT_EQ (contour_pixel_set (r), B); + + edge_sum = 0; + for (const auto& p : B) + edge_sum += contour_fixture_intensity (p.first, p.second); +} + +// 400 random masks, 3..39 pixels a side and 30..90 % filled, so they carry holes, fragments, +// one-pixel necks and isolated pixels. Seeded, so a failure names a reproducible case. +void test_2d_contour_noisy_rois_boundary_rule_invariant() +{ + std::mt19937 rng (20261022); + std::uniform_int_distribution side (3, 39), origin (0, 200); + std::uniform_real_distribution u (0.0, 1.0); + int n_tested = 0; + for (int i = 0; i < 400; i++) + { + const int w = side(rng), h = side(rng), x0 = origin(rng), y0 = origin(rng); + const double fill = 0.3 + 0.6 * u(rng); + std::set> S; + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + if (u(rng) < fill) + S.insert({ x0 + x, y0 + y }); + if (S.empty()) + continue; + + SCOPED_TRACE("noisy ROI #" + std::to_string(i)); + LR r (1); + double walks, edge_sum; + assert_contour_boundary_rule (S, r, walks, edge_sum); + if (::testing::Test::HasFatalFailure()) + return; + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::PERIMETER][0], walks, 1e-9); + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::EDGE_INTEGRATED_INTENSITY][0], edge_sum, 1e-9); + n_tested++; + } + ASSERT_GE (n_tested, 390); +} + +// The filled 9066-pixel blob: one border covering its 495 edge pixels. +void test_2d_contour_blob97_boundary_rule_invariant() +{ + const auto S = contour_mask_pixels (bench_blob97_filled_rows, 113, 41); + ASSERT_EQ (S.size(), size_t(9066)); + ASSERT_EQ (contour_boundary_rule (S).size(), size_t(495)); + + LR r (1); + double walks, edge_sum; + assert_contour_boundary_rule (S, r, walks, edge_sum); + if (::testing::Test::HasFatalFailure()) + return; + ASSERT_EQ (r.multicontour_.size(), size_t(1)); + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::PERIMETER][0], walks, 1e-9); + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::EDGE_INTEGRATED_INTENSITY][0], edge_sum, 1e-9); +} + +// Pixel2::min_sqdist and max_sqdist read a contour as one ordered walk. A walk of up to 10 points is +// scanned whole, so on it both return exactly what an exhaustive scan does, at any pixel size. Every +// walk length from 1 to 10, on points scattered at random so that the distance along a walk has +// several extremes, each walk queried from every point of a grid around it. A longer walk is searched +// coarse to fine and may miss its extreme, which is not asserted here. +void test_2d_contour_distance_search_short_walk_invariant() +{ + std::mt19937 rng (20261023); + std::uniform_int_distribution coord (0, 12); + for (size_t n = 1; n <= 10; n++) + for (int trial = 0; trial < 40; trial++) + { + std::vector W; + for (size_t i = 0; i < n; i++) + W.push_back (Pixel2 (coord(rng), coord(rng), 1u)); + + for (int qy = -1; qy <= 13; qy += 2) + for (int qx = -1; qx <= 13; qx += 2) + { + const Pixel2 q (qx, qy, 0u); + for (const auto& [sx, sy] : { std::pair{ 1.0, 1.0 }, { 2.0, 3.0 } }) + { + double mn = q.sqdist (W[0], sx, sy), mx = mn; + for (const Pixel2& p : W) + { + mn = std::min (mn, q.sqdist (p, sx, sy)); + mx = std::max (mx, q.sqdist (p, sx, sy)); + } + SCOPED_TRACE ("walk of " + std::to_string(n) + " points, trial " + std::to_string(trial) + ", query (" + std::to_string(qx) + "," + std::to_string(qy) + "), pixel " + std::to_string(sx) + " x " + std::to_string(sy)); + ASSERT_EQ (q.min_sqdist (W, sx, sy), mn); + ASSERT_EQ (q.max_sqdist (W, sx, sy), mx); + } + } + } + + // no contour at all: both searches report 0 + const Pixel2 q (3, 4, 0u); + ASSERT_EQ (q.min_sqdist (std::vector()), 0.0); + ASSERT_EQ (q.max_sqdist (std::vector()), 0.0); +} + +// Pixel2::min_sqdist and max_sqdist over a contour of several borders search each border on its own +// and skip a border whose bounding box cannot beat the extreme found so far. Skipping must not change +// the answer: it equals the smallest, or largest, of the searches of every border as a walk of its +// own. 100 random masks with holes and fragments, every pixel of each queried, at two pixel sizes. +void test_2d_contour_distance_search_borders_invariant() +{ + std::mt19937 rng (20261024); + std::uniform_int_distribution side (4, 30); + std::uniform_real_distribution u (0.0, 1.0); + size_t n_multi = 0; + for (int i = 0; i < 100; i++) + { + const int w = side(rng), h = side(rng); + const double fill = 0.4 + 0.5 * u(rng); + std::set> S; + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + if (u(rng) < fill) + S.insert({ 10 + x, 20 + y }); + if (S.empty()) + continue; + + LR r (1); + contour_roi_from_pixels (r, S); + contour_calculate (r); + const BorderedContour Kb (r.contour_pixels_, r.contour_border_ends_); + n_multi += Kb.borders.size() > 1; + + ASSERT_TRUE (border_ends_cover (r.contour_pixels_.size(), r.contour_border_ends_)); + std::vector> borders; + size_t begin = 0; + for (size_t end : r.contour_border_ends_) + { + borders.emplace_back (r.contour_pixels_.begin() + begin, r.contour_pixels_.begin() + end); + begin = end; + } + ASSERT_EQ (borders.size(), Kb.borders.size()); + + for (const auto& [sx, sy] : { std::pair{ 1.0, 1.0 }, { 2.0, 3.0 } }) + for (const Pixel2& q : r.raw_pixels) + { + double mn = q.min_sqdist (borders[0], sx, sy), mx = q.max_sqdist (borders[0], sx, sy); + for (const auto& B : borders) + { + mn = std::min (mn, q.min_sqdist (B, sx, sy)); + mx = std::max (mx, q.max_sqdist (B, sx, sy)); + } + SCOPED_TRACE ("mask #" + std::to_string(i) + ", pixel (" + std::to_string(q.x) + "," + std::to_string(q.y) + ")"); + ASSERT_EQ (q.min_sqdist (Kb, sx, sy), mn); + ASSERT_EQ (q.max_sqdist (Kb, sx, sy), mx); + } + } + ASSERT_GE (n_multi, (size_t)50) << "too few masks had more than one border to test the skipping"; +} diff --git a/tests/test_2d_contour_mechanics.h b/tests/test_2d_contour_mechanics.h new file mode 100644 index 000000000..a05b511e7 --- /dev/null +++ b/tests/test_2d_contour_mechanics.h @@ -0,0 +1,153 @@ +#pragma once + +#include +#include +#include +#include "test_2d_contour_common.h" + +// BorderedContour keeps a reference to the pixels it is built over, so it binds only a vector that +// outlives it: built over a temporary, the reference would dangle once the full expression ends. +// Binding an rvalue is refused at compile time; an lvalue, const or not, is accepted. +void test_2d_contour_bordered_contour_binds_lvalues_mechanics() +{ + ASSERT_FALSE((std::is_constructible_v&&, const std::vector&>)); + ASSERT_FALSE((std::is_constructible_v&&, const std::vector&>)); + ASSERT_TRUE((std::is_constructible_v&, const std::vector&>)); + ASSERT_TRUE((std::is_constructible_v&, const std::vector&>)); +} + +// A contour's border ends have one layout, which the CPU searches (BorderedContour) and the GPU upload +// both read: every border holds at least one pixel, so the ends rise strictly, and the last end is +// the pixel count, so the borders cover the contour. BorderedContour accepts that layout and refuses +// any other, rather than reading pixels past the last end as one more border, which the GPU, holding +// only the borders the ends list, would not search. +void test_2d_contour_bordered_contour_layout_mechanics() +{ + const std::vector K { Pixel2(0, 0, 1), Pixel2(1, 0, 1), Pixel2(2, 0, 1), Pixel2(5, 5, 1), Pixel2(6, 5, 1) }; + + // the layout, and the borders it gives + const std::vector ends { 3, 5 }; + ASSERT_TRUE(border_ends_cover(K.size(), ends)); + const BorderedContour Kb (K, ends); + ASSERT_EQ(Kb.borders.size(), (size_t)2); + ASSERT_EQ(Kb.borders[1].begin, (size_t)3); + ASSERT_EQ(Kb.borders[1].xmin, 5); + const std::vector none; + ASSERT_TRUE(border_ends_cover(0, {})); + ASSERT_TRUE(BorderedContour(none, {}).borders.empty()); + + // pixels past the last end, an empty run, ends past the contour, no ends over pixels + for (const std::vector& bad : { std::vector{ 3 }, { 3, 3, 5 }, { 0, 5 }, { 3, 6 }, {} }) + { + ASSERT_FALSE(border_ends_cover(K.size(), bad)); + ASSERT_THROW(BorderedContour(K, bad), std::invalid_argument); + } +} + +// LR::merge_multicontour lists every contour pixel once, so a border all of whose pixels an earlier +// border holds adds no pixels; it adds no end either, and the ends keep the layout BorderedContour +// and the GPU upload read. Here the second border's pixels all lie on the first. +void test_2d_contour_merge_skips_covered_border_mechanics() +{ + LR r (1); + r.multicontour_ = { { Pixel2(4, 4, 1), Pixel2(5, 4, 1), Pixel2(5, 5, 1) }, { Pixel2(5, 4, 1), Pixel2(5, 5, 1) }, { Pixel2(9, 9, 1) } }; + r.merge_multicontour(); + ASSERT_EQ(r.contour_pixels_.size(), (size_t)4); + ASSERT_EQ(r.contour_border_ends_, (std::vector{ 3, 4 })); + ASSERT_TRUE(border_ends_cover(r.contour_pixels_.size(), r.contour_border_ends_)); + + // with no covered border every border adds an end + r.multicontour_ = { { Pixel2(4, 4, 1) }, { Pixel2(9, 9, 1) } }; + r.merge_multicontour(); + ASSERT_EQ(r.contour_border_ends_, (std::vector{ 1, 2 })); +} + +// The row runs of a mask: maximal stretches of '1' along a row, a stretch cut at a multiple of +// 'tile' counted as two (tile 0: no cut). +static size_t contour_mask_row_runs (const std::vector& rows, size_t tile = 0) +{ + size_t n = 0; + for (const auto& row : rows) + for (size_t x = 0; x < row.size(); x++) + if (row[x] == '1' && (x == 0 || row[x - 1] != '1' || (tile && x % tile == 0))) + n++; + return n; +} + +// The GPU memory plan bounds a ROI's contour borders by twice its row runs: every outer border +// starts at a run's first pixel and every hole's border at a run's last. Random masks with holes +// and fragments keep within it, borders and border ends alike; a lattice of separate pixels, every one +// a run and a border, reaches half of it. +void test_2d_contour_borders_row_runs_bound_mechanics() +{ + std::mt19937 rng (5301); + std::uniform_real_distribution u (0.0, 1.0); + for (int i = 0; i < 200; i++) + { + const int w = 3 + i % 17, h = 3 + (i * 7) % 13; + std::vector rows (h, std::string (w, '.')); + const double fill = 0.35 + 0.6 * u (rng); + for (auto& row : rows) + for (auto& c : row) + if (u (rng) < fill) + c = '1'; + const auto S = contour_mask_pixels (rows, 4, 6); + if (S.empty()) + continue; + LR r (1); + contour_roi_from_pixels (r, S); + contour_calculate (r); + const size_t bound = 2 * contour_mask_row_runs (rows); + SCOPED_TRACE ("mask #" + std::to_string(i)); + ASSERT_LE (r.multicontour_.size(), bound); + ASSERT_LE (r.contour_border_ends_.size(), bound); + } + + const std::vector lattice { "1.1.1.1", ".......", "1.1.1.1", ".......", "1.1.1.1" }, + riddled { "1111111", "1.1.1.1", "1111111", "1.1.1.1", "1111111" }; + // 12 pixels, 12 runs, 12 borders; 11 runs, the outer border and 6 holes + for (const auto& [rows, runs, borders] : { std::tuple*, size_t, size_t>{ &lattice, 12, 12 }, { &riddled, 11, 7 } }) + { + LR r (1); + contour_roi_from_pixels (r, contour_mask_pixels (*rows, 4, 6)); + contour_calculate (r); + ASSERT_EQ (contour_mask_row_runs (*rows), runs); + ASSERT_EQ (r.multicontour_.size(), borders); + } +} + +// The 2D prescan counts the slide's row runs (SlideProps::n_row_runs), a run cut by a tile's left +// edge counted as two, for the GPU memory plan's bound on contour borders. +void test_2d_contour_prescan_row_runs_mechanics() +{ + const std::vector rows = { + "..................................", + "..111111111111111111......1111....", + "..1111....1111111111......1111....", + "..1111....11111111111111111111....", + "..111111111111111111..............", + "...........................11111..", + }; + const fs::path dir = fs::temp_directory_path() / "nyxus_contour_row_runs"; + std::error_code ec; + fs::remove_all (dir, ec); + fs::create_directories (dir); + const uint32_t tile = 16; + const uint32_t W = (uint32_t) rows[0].size(), H = (uint32_t) rows.size(); + write_tiled_plane_u16 (dir / "i.tif", W, H, tile, [] (uint32_t, uint32_t) { return (uint16_t) 5; }); + write_tiled_plane_u16 (dir / "m.tif", W, H, tile, [&] (uint32_t x, uint32_t y) { return (uint16_t) (y < H && x < W && rows[y][x] == '1' ? 3 : 0); }); + + SlideProps p ((dir / "i.tif").string(), (dir / "m.tif").string()); + AnisotropyOptions aniso; + FpImageOptions fpo; + ASSERT_TRUE(Nyxus::scan_slide_props (p, 2, aniso, false, fpo, false)); + ASSERT_EQ(p.n_rois, (size_t)1); + ASSERT_EQ(p.n_row_runs, contour_mask_row_runs (rows, tile)); + ASSERT_EQ(p.n_row_runs, (size_t)13); + + // with no mask the slide is one ROI, every row a run, cut at each tile edge + SlideProps whole ((dir / "i.tif").string(), ""); + ASSERT_TRUE(Nyxus::scan_slide_props (whole, 2, aniso, false, fpo, false)); + ASSERT_EQ(whole.n_row_runs, rows.size() * 3); + fs::remove_all (dir, ec); +} diff --git a/tests/test_2d_contour_opencv.h b/tests/test_2d_contour_opencv.h new file mode 100644 index 000000000..5af905ac0 --- /dev/null +++ b/tests/test_2d_contour_opencv.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include "test_2d_contour_common.h" +#include "test_ref_vals.h" + +// The ROI contour and PERIMETER against OpenCV's Suzuki-Abe border following, +// cv2.findContours(RETR_LIST, CHAIN_APPROX_NONE): every outer and hole border as a closed walk of +// pixels, PERIMETER being the summed closed-walk length (accumulated in double from OpenCV's integer +// points). Same algorithm on both sides, so the agreement is exact up to summation order. +// Tool: OpenCV 4.13.0 (5.0.0 gives the same pins). Generator: +// tests/vetting/oracles/gen_contour_opencv.py, which reads the fixtures from their headers and +// re-verifies every pin below. +static const ref_vals_map contour_2d_opencv_ref_vals{ + // bench_blob97_filled: 9066 ROI pixels, no holes + {"NUM_CONTOURS_BLOB97", 1}, + {"CONTOUR_PIXELS_BLOB97", 495}, + {"PERIMETER_BLOB97", 537.6639969244295}, + // shape2d_morphology_mask: 26 ROI pixels around a one-pixel hole + {"NUM_CONTOURS_SHAPE2D", 2}, + {"CONTOUR_PIXELS_SHAPE2D", 18}, + {"PERIMETER_SHAPE2D", 22.970562748477146}, +}; + +// The number of borders and the pixels they cover. +static void assert_contour_borders_opencv (const LR& r, const std::string& tag) +{ + SCOPED_TRACE("OPENCV_ORACLE__" + tag); + ASSERT_EQ ((double)r.multicontour_.size(), contour_2d_opencv_ref_vals.at("NUM_CONTOURS_" + tag)); + ASSERT_EQ ((double)contour_pixel_set(r).size(), contour_2d_opencv_ref_vals.at("CONTOUR_PIXELS_" + tag)); +} + +// The filled 9066-pixel blob on which the contour once collapsed to two pixels (PERIMETER 2). +void test_2d_contour_blob97_opencv() +{ + LR r (1); + contour_roi_from_pixels (r, contour_mask_pixels (bench_blob97_filled_rows, 113, 41)); + contour_calculate (r); + assert_contour_borders_opencv (r, "BLOB97"); + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::PERIMETER][0], contour_2d_opencv_ref_vals.at("PERIMETER_BLOB97"), 1e-9); +} + +// The 8x8 shape2d mask: an outer border and the border of its one-pixel hole. +void test_2d_contour_shape2d_opencv() +{ + LR r (1); + Nyxus::load_masked_test_roi_data (r, shape2d_morphology_intensity, shape2d_morphology_mask, + sizeof(shape2d_morphology_mask) / sizeof(NyxusPixel)); + contour_calculate (r); + assert_contour_borders_opencv (r, "SHAPE2D"); + ASSERT_NEAR (r.fvals[(int)Nyxus::Feature2D::PERIMETER][0], contour_2d_opencv_ref_vals.at("PERIMETER_SHAPE2D"), 1e-9); +} diff --git a/tests/test_2d_gabor_mechanics.h b/tests/test_2d_gabor_mechanics.h index f49217a33..a1146cf7a 100644 --- a/tests/test_2d_gabor_mechanics.h +++ b/tests/test_2d_gabor_mechanics.h @@ -41,3 +41,23 @@ inline void test_2d_gabor_gpu_runs_mechanics() GTEST_SKIP() << "built without USE_GPU"; #endif } + +// With the GPU on and its cache not ready -- never allocated, or after an allocation or a free that +// failed part way -- the Gabor scores are computed on the CPU, with the CPU's values. +void test_2d_gabor_gpu_unready_cache_mechanics() +{ +#ifdef USE_GPU + for (GpuUnready why : { GpuUnready::never_allocated, GpuUnready::allocation_failed_part_way, GpuUnready::free_failed_part_way }) + { + SCOPED_TRACE(gpu_unready_name (why)); + std::vector> on, off; + ASSERT_NO_FATAL_FAILURE(gpu_unready_vs_cpu ({ Nyxus::Feature2D::GABOR }, why, on, off)); + const std::vector& scores = off[(int)Nyxus::Feature2D::GABOR]; + ASSERT_TRUE(std::any_of (scores.begin(), scores.end(), [] (double v) { return v != 0.0; })) + << "the CPU gave " << scores.size() << " Gabor scores, none of them non-zero"; + ASSERT_EQ(on[(int)Nyxus::Feature2D::GABOR], scores); + } +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} diff --git a/tests/test_2d_moments_invariant.h b/tests/test_2d_moments_invariant.h new file mode 100644 index 000000000..24e38091d --- /dev/null +++ b/tests/test_2d_moments_invariant.h @@ -0,0 +1,70 @@ +#pragma once + +#include "test_2d_moments_common.h" + +// Copies of one disk of radius 12, 50 pixels apart along x, under one label, with an intensity that +// depends only on a pixel's offset from its own disk's centre. Returns the feature values of the +// weighted shape and intensity moments. +static void calculate_2d_moments_disk_copies_values (int n_copies, std::vector>& fvals) +{ + const int R = 12, cx = 20, cy = 20, gap = 50; + LR roidata(1402); + for (int k = 0; k < n_copies; k++) + for (int y = cy - R; y <= cy + R; y++) + for (int x = cx - R + k * gap; x <= cx + R + k * gap; x++) + { + const int dx = x - (cx + k * gap), dy = y - cy; + if (dx * dx + dy * dy > R * R) + continue; + const auto intensity = static_cast(10 + (dx + R) + 2 * (dy + R)); + if (roidata.aux_area == 0) + Nyxus::init_label_record_3(roidata, x, y, intensity); + else + Nyxus::update_label_record_3(roidata, x, y, intensity); + roidata.raw_pixels.push_back(Pixel2(x, y, intensity)); + } + roidata.make_nonanisotropic_aabb(); + roidata.aux_image_matrix.allocate(roidata.aabb.get_width(), roidata.aabb.get_height()); + roidata.aux_image_matrix.calculate_from_pixelcloud(roidata.raw_pixels, roidata.aabb); + roidata.initialize_fvals(); + + Fsettings s = make_2d_geomoment_settings(); + ContourFeature contour; + contour.calculate(roidata, s); + contour.save_value(roidata.fvals); + ASSERT_EQ(roidata.multicontour_.size(), (size_t)n_copies); + + Smoms2D_feature shape_moments; + shape_moments.calculate(roidata, s); + shape_moments.save_value(roidata.fvals); + + Imoms2D_feature intensity_moments; + intensity_moments.calculate(roidata, s); + intensity_moments.save_value(roidata.fvals); + + fvals = roidata.fvals; +} + +// The weighted moments weigh each pixel by log(d + epsilon), d its distance to the ROI's contour +// (2d_geomoments_basic.cpp). For two copies of a disk far apart, the nearest border to every pixel +// is its own disk's and lies where it does in a lone disk, so every pixel carries the weight it +// carries in one copy, and the zeroth weighted moments -- the summed weights -- of the pair are twice +// those of one copy. The distance is searched along each border on its own; a search that ran +// along the two borders as one sequence would give the copies different weights. +void test_2d_moments_weighted_disk_copies_invariant() +{ + std::vector> one, two; + calculate_2d_moments_disk_copies_values(1, one); + if (::testing::Test::HasFatalFailure()) + return; + calculate_2d_moments_disk_copies_values(2, two); + if (::testing::Test::HasFatalFailure()) + return; + + // the two sums add the same terms in a different order + const double w1 = one[(int)Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_00][0], i1 = one[(int)Nyxus::Feature2D::IMOM_WRM_00][0]; + ASSERT_NE(w1, 0.0); + ASSERT_NE(i1, 0.0); + ASSERT_NEAR(two[(int)Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_00][0], 2.0 * w1, 1e-12 * std::abs(2.0 * w1)); + ASSERT_NEAR(two[(int)Nyxus::Feature2D::IMOM_WRM_00][0], 2.0 * i1, 1e-12 * std::abs(2.0 * i1)); +} diff --git a/tests/test_2d_moments_mechanics.h b/tests/test_2d_moments_mechanics.h new file mode 100644 index 000000000..b0704e795 --- /dev/null +++ b/tests/test_2d_moments_mechanics.h @@ -0,0 +1,480 @@ +#pragma once + +#include "test_2d_moments_common.h" + +// A whole slide's contour is the 4 corners of its bounding box (ContourFeature::buildWholeSlideContour), +// kept on the ROI each once. The slides here have their pixels' own extent as the box, as a caller +// building the ROI directly gives it (the file pipelines widen a whole slide's box by one pixel each +// way). One pixel wide or high, such a slide has fewer than 4 distinct corners - 2 on a 1xN or Nx1 +// slide, 1 on a 1x1 slide - and every one of its pixels lies on the slide's edge, so every pixel takes +// the edge weight, epsilon (BasicGeomoms2D::apply_dist2contour_weighting_wholeslide). The zeroth +// weighted moments are then the pixel count times epsilon, as the weights are stored (float), and +// EDGE_INTEGRATED_INTENSITY counts each distinct corner once at the slide's maximum intensity. +// The weighting reads no corner past the contour's end, which a build with bounds-checked +// std::vector access (_MSVC_STL_HARDENING=1 on MSVC, ASan elsewhere) verifies. +void test_2d_moments_wholeslide_thin_slide_mechanics() +{ + const double eps = double(RealPixIntens(0.001)); + for (const auto& [w, h] : { std::pair{ 1, 1 }, { 1, 7 }, { 7, 1 } }) + { + SCOPED_TRACE(std::to_string(w) + " x " + std::to_string(h) + " slide"); + LR roidata(1403); + PixIntens maxI = 0; + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + const auto intensity = static_cast(geomoment_fixture_intensity(x, y)); + maxI = std::max(maxI, intensity); + if (roidata.aux_area == 0) + Nyxus::init_label_record_3(roidata, x, y, intensity); + else + Nyxus::update_label_record_3(roidata, x, y, intensity); + roidata.raw_pixels.push_back(Pixel2(x, y, intensity)); + } + roidata.make_nonanisotropic_aabb(); + roidata.aux_image_matrix.allocate(roidata.aabb.get_width(), roidata.aabb.get_height()); + roidata.aux_image_matrix.calculate_from_pixelcloud(roidata.raw_pixels, roidata.aabb); + roidata.initialize_fvals(); + + Fsettings s = make_2d_geomoment_settings(); + s[static_cast(NyxSetting::SINGLEROI)].bval = true; + + ContourFeature contour; + contour.calculate(roidata, s); + contour.save_value(roidata.fvals); + const size_t n_corners = (w == 1 && h == 1) ? 1 : 2; + ASSERT_EQ(roidata.contour_pixels_.size(), n_corners); + const double edgeSum = roidata.fvals[(int)Nyxus::Feature2D::EDGE_INTEGRATED_INTENSITY][0]; + ASSERT_EQ(edgeSum, double(n_corners) * double(maxI)); + + Smoms2D_feature shape_moments; + shape_moments.calculate(roidata, s); + shape_moments.save_value(roidata.fvals); + Imoms2D_feature intensity_moments; + intensity_moments.calculate(roidata, s); + intensity_moments.save_value(roidata.fvals); + + const double expected = double(w * h) * eps; + const double shapeSum = roidata.fvals[(int)Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_00][0], + intenSum = roidata.fvals[(int)Nyxus::Feature2D::IMOM_WRM_00][0]; + ASSERT_NEAR(shapeSum, expected, 1e-12 * expected); + ASSERT_NEAR(intenSum, expected, 1e-12 * expected); + } +} + +// GpuCache::clear() frees each buffer that is set on its own. The buffers are allocated device side +// first, so after a failed host allocation the device buffers are set while the host ones are not, +// and clear() still frees them. +void test_2d_moments_gpu_cache_clear_mechanics() +{ +#ifdef USE_GPU + GpuCache c; + ASSERT_TRUE(GpusideCache::allocate_on_device((void**)&c.devbuffer, 16 * sizeof(size_t))); + ASSERT_TRUE(GpusideCache::allocate_on_device((void**)&c.dev_offsets, 2 * sizeof(size_t))); + ASSERT_EQ(c.hobuffer, nullptr); + ASSERT_EQ(c.ho_offsets, nullptr); + ASSERT_TRUE(c.clear()); + ASSERT_EQ(c.devbuffer, nullptr); + ASSERT_EQ(c.dev_offsets, nullptr); + + // a cache with nothing allocated clears too + GpuCache empty; + ASSERT_TRUE(empty.clear()); +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} + +// The border-end buffer belongs to the GPU memory plan: allocate_gpu_cache() allocates as many ends +// as the plan counts, the bound on the batch's borders it is given (the segmented workflow passes +// twice the slides' row runs), so the plan and the allocation agree. A batch fills it for ROIs +// batch_offset .. batch_offset + batch_len - 1, clouds and contours alike: the second batch of one +// ROI each carries the second ROI's pixels, contour and border ends. A batch with more borders than +// the bound is refused. +void test_2d_moments_gpu_border_upload_mechanics() +{ +#ifdef USE_GPU + std::unordered_map rois; + // ROI 1 a 3 x 3 square, 3 row runs; ROI 2 two 3 x 3 squares under one label, two borders, 6 row runs + gpu_test_roi (rois, 1, { "111", "111", "111" }, 2, 2); + LR& two = gpu_test_roi (rois, 2, { "111....111", "111....111", "111....111" }, 2, 10); + ASSERT_EQ(two.contour_border_ends_, (std::vector{ 8, 16 })); + const std::vector labels { 1, 2 }; + const size_t borders = 2 * (3 + 6); + + GpuCacheTestRig cache; + auto err = cache.allocate (2, 30, 10, 3, borders); + ASSERT_FALSE(err.has_value()) << err.value_or(""); + GpusideCache& g = cache.g; + ASSERT_NE(g.gpu_roicontourborders_2d.hobuffer, nullptr); + ASSERT_EQ(g.gpu_roicontourborders_2d.total_len, borders); + ASSERT_EQ(g.gpu_roicontourborders_2d.num_rois, g.gpu_roicontours_2d.num_rois); + const size_t planWith = g.ram_comsumption_szb(true, false, false, true, 60, 60, borders, 2, 10, 3, 1, 0), + planWithout = g.ram_comsumption_szb(false, false, false, true, 60, 60, borders, 2, 10, 3, 1, 0); + ASSERT_EQ(planWith - planWithout, GpuCache::szb(60, 2) + GpuCache::szb(borders, 2)); + + // a bound above the contour buffer's capacity, as twice the row runs of a lattice of one-pixel + // ROIs can be, is capped at it in the plan and the allocation: every border holds a contour pixel + ASSERT_EQ(g.ram_comsumption_szb(true, false, false, true, 60, 60, 1000000, 2, 10, 3, 1, 0), + g.ram_comsumption_szb(true, false, false, true, 60, 60, 60, 2, 10, 3, 1, 0)); + GpuCacheTestRig capped; + ASSERT_FALSE(capped.allocate (2, 30, 10, 3, 1000000).has_value()); + ASSERT_EQ(capped.g.gpu_roicontourborders_2d.total_len, (size_t)60); + const size_t unset = 12345; + g.gpu_roiclouds_2d.ho_lengths[0] = unset; + g.gpu_roiclouds_2d.ho_offsets[0] = unset; + g.gpu_roicontours_2d.ho_lengths[0] = unset; + g.send_roi_batch_data_2_gpu(g.gpu_roiclouds_2d, g.gpu_roicontours_2d, g.gpu_roicontourborders_2d, &g.dev_realintens, + &g.dev_prereduce, g.gpu_featurestatebuf, g.devicereduce_temp_storage_szb, &g.dev_devicereduce_temp_storage, + labels, rois, 1, 1); + ASSERT_EQ(g.gpu_roiclouds_2d.ho_lengths[0], two.raw_pixels.size()); + ASSERT_EQ(g.gpu_roiclouds_2d.ho_offsets[0], (size_t)0); + ASSERT_EQ(g.gpu_roicontours_2d.ho_lengths[0], two.contour_pixels_.size()); + ASSERT_EQ(g.gpu_roicontourborders_2d.ho_lengths[0], (size_t)2); + ASSERT_EQ(g.gpu_roicontourborders_2d.ho_offsets[0], (size_t)0); + ASSERT_EQ(g.gpu_roicontourborders_2d.hobuffer[0], (size_t)8); + ASSERT_EQ(g.gpu_roicontourborders_2d.hobuffer[1], (size_t)16); + + // more borders than the plan's bound + GpuCacheTestRig small; + err = small.allocate (2, 30, 10, 3, 2); + ASSERT_FALSE(err.has_value()) << err.value_or(""); + ASSERT_THROW(small.g.send_roi_batch_data_2_gpu(small.g.gpu_roiclouds_2d, small.g.gpu_roicontours_2d, small.g.gpu_roicontourborders_2d, + &small.g.dev_realintens, &small.g.dev_prereduce, small.g.gpu_featurestatebuf, small.g.devicereduce_temp_storage_szb, + &small.g.dev_devicereduce_temp_storage, labels, rois, 0, 2), std::runtime_error); + + // a ROI whose border ends do not cover its contour is refused before anything is uploaded + two.contour_border_ends_ = { 8 }; + ASSERT_THROW(g.send_roi_batch_data_2_gpu(g.gpu_roiclouds_2d, g.gpu_roicontours_2d, g.gpu_roicontourborders_2d, &g.dev_realintens, + &g.dev_prereduce, g.gpu_featurestatebuf, g.devicereduce_temp_storage_szb, &g.dev_devicereduce_temp_storage, + labels, rois, 1, 1), std::runtime_error); +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} + +// The GPU's distance weighting reads the same borders as the CPU's: a ROI of two separate 3 x 3 +// squares, whose borders of 8 pixels both searches scan in full, and a 1 x 7 whole slide, whose two +// distinct corners take the edge weight on both, give the CPU's zeroth weighted moments. +void test_2d_moments_gpu_cpu_parity_mechanics() +{ +#ifdef USE_GPU + for (bool wholeslide : { false, true }) + { + SCOPED_TRACE(wholeslide ? "1 x 7 whole slide" : "two squares"); + Fsettings s = make_2d_geomoment_settings(); + s[static_cast(NyxSetting::SINGLEROI)].bval = wholeslide; + + // the same ROI twice: one computed on the CPU, one on the GPU + std::unordered_map cpu, gpu; + for (auto* rois : { &cpu, &gpu }) + { + if (!wholeslide) + { + gpu_test_roi (*rois, 1, { "111....111", "111....111", "111....111" }, 0, 0); + continue; + } + LR& r = (*rois)[1]; + r.label = 1; + for (int x = 0; x < 7; x++) + { + const auto intensity = static_cast(geomoment_fixture_intensity(x, 0)); + if (r.aux_area == 0) + Nyxus::init_label_record_3(r, x, 0, intensity); + else + Nyxus::update_label_record_3(r, x, 0, intensity); + r.raw_pixels.push_back(Pixel2(x, 0, intensity)); + } + r.make_nonanisotropic_aabb(); + r.aux_image_matrix.allocate(r.aabb.get_width(), r.aabb.get_height()); + r.aux_image_matrix.calculate_from_pixelcloud(r.raw_pixels, r.aabb); + r.initialize_fvals(); + ContourFeature contour; + contour.calculate(r, s); + ASSERT_EQ(r.contour_pixels_.size(), (size_t)2); + } + + LR& c = cpu[1]; + Smoms2D_feature shape; + shape.calculate(c, s); + shape.save_value(c.fvals); + Imoms2D_feature inten; + inten.calculate(c, s); + inten.save_value(c.fvals); + + LR& g = gpu[1]; + GpuCacheTestRig cache; + auto err = cache.allocate (1, g.raw_pixels.size(), g.aabb.get_width(), g.aabb.get_height(), 2 * g.aabb.get_height()); + ASSERT_FALSE(err.has_value()) << err.value_or(""); + const std::vector labels { 1 }; + cache.g.send_roi_batch_data_2_gpu(cache.g.gpu_roiclouds_2d, cache.g.gpu_roicontours_2d, cache.g.gpu_roicontourborders_2d, + &cache.g.dev_realintens, &cache.g.dev_prereduce, cache.g.gpu_featurestatebuf, cache.g.devicereduce_temp_storage_szb, + &cache.g.dev_devicereduce_temp_storage, labels, gpu, 0, 1); + Smoms2D_feature::gpu_process_all_rois(labels, gpu, 0, 1, wholeslide, cache.g); + Imoms2D_feature::gpu_process_all_rois(labels, gpu, 0, 1, wholeslide, cache.g); + + for (auto f : { Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_00, Nyxus::Feature2D::IMOM_WRM_00 }) + { + const double cv = c.fvals[(int)f][0], gv = g.fvals[(int)f][0]; + ASSERT_NEAR(gv, cv, 1e-6 * std::abs(cv)) << "feature " << (int)f; + } + } +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} + +// gpu_cache_sizes() sizes the GPU cache for the ROIs as the trivial pipeline holds them. Custom +// anisotropy resamples each pixel to at most ceil(ax) x ceil(ay) pixels, so the largest ROI and a +// batch's pixels grow by that, and the borders, bounded by twice the row runs, by ceil(ay). A factor +// below 1 adds no pixel. The physical pixel size does not resample a 2D ROI, so it changes nothing. +// A whole slide's contour is its 4 corners, one border. +void test_2d_moments_gpu_cache_sizes_mechanics() +{ + auto sizes = [] (bool whole, double ax, double ay, bool physical) + { + Environment e; + e.singleROI = whole; + e.anisoOptions.set_aniso_x (ax); + e.anisoOptions.set_aniso_y (ay); + e.use_physical_spacing_ = physical; + SlideProps& p = e.dataset.dataset_props.emplace_back ("i.tif", "m.tif"); + p.max_roi_area = 100; + p.n_rois = 3; + p.n_row_runs = 20; + p.phys_x = 1.0; + p.phys_y = 3.0; + p.phys_z = 1.0; + e.dataset.update_dataset_props_extrema(); + const Nyxus::GpuCacheSizes s = Nyxus::gpu_cache_sizes (e); + return std::vector{ s.roi_area, s.roicloud_len, s.kontur_len, s.kontur_borders }; + }; + ASSERT_EQ(sizes (false, 1.0, 1.0, false), (std::vector{ 100, 300, 300, 40 })); + ASSERT_EQ(sizes (false, 2.0, 1.5, false), (std::vector{ 400, 1200, 1200, 80 })); + ASSERT_EQ(sizes (false, 0.5, 0.5, false), (std::vector{ 100, 300, 300, 40 })); + ASSERT_EQ(sizes (false, 1.0, 1.0, true), (std::vector{ 100, 300, 300, 40 })); + ASSERT_EQ(sizes (true, 2.0, 1.5, false), (std::vector{ 400, 1200, 4, 1 })); +} + +// A ROI resampled by the factors fits a cache sized for it, and overflows one sized for the ROI as +// acquired: a 3 x 3 ROI at --anisox=2 --anisoy=2 is a 6 x 6 one, 36 pixels, not 9. +void test_2d_moments_gpu_anisotropic_batch_mechanics() +{ +#ifdef USE_GPU + std::unordered_map rois; + gpu_test_roi (rois, 1, { "111111", "111111", "111111", "111111", "111111", "111111" }, 0, 0); + const std::vector labels { 1 }; + + GpuCacheTestRig resampled; + ASSERT_FALSE(resampled.allocate (1, 9 * 2 * 2, 6, 6, 2 * 6 * 2).has_value()); + ASSERT_NO_THROW(resampled.send (labels, rois, 0, 1)); + + GpuCacheTestRig acquired; + ASSERT_FALSE(acquired.allocate (1, 9, 6, 6, 2 * 6 * 2).has_value()); + ASSERT_THROW(acquired.send (labels, rois, 0, 1), std::runtime_error); +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} + +// allocate_gpu_cache() over a cache that a failed run left allocated frees what it allocates again, +// so the device memory does not grow: a batch whose upload throws, then a second allocation, leave +// the free memory where the first allocation left it. free_gpu_cache() forgets every buffer it +// frees, the erosion ones included. +void test_2d_moments_gpu_cache_reallocate_mechanics() +{ +#ifdef USE_GPU + std::unordered_map rois; + LR& r = gpu_test_roi (rois, 1, { "111", "111", "111" }, 2, 2); + const std::vector labels { 1 }; + const size_t area = size_t(1) << 20; // a moments buffer of this many pixels shows in the free memory + + GpuCacheTestRig cache; + ASSERT_FALSE(cache.allocate (1, area, 1024, 1024, 6, true).has_value()); + size_t free1 = 0, free2 = 0; + ASSERT_TRUE(GpusideCache::gpu_get_free_mem (free1)); + + r.contour_border_ends_ = { 1 }; // does not cover the contour: the upload throws + ASSERT_THROW(cache.send (labels, rois, 0, 1), std::runtime_error); + ASSERT_FALSE(cache.allocate (1, area, 1024, 1024, 6, true).has_value()); + ASSERT_TRUE(GpusideCache::gpu_get_free_mem (free2)); + ASSERT_GE(free2 + (size_t(16) << 20), free1) << "the second allocation leaked " << (free1 - free2) << " bytes"; + + ASSERT_TRUE(cache.free()); + GpusideCache& g = cache.g; + ASSERT_EQ(g.dev_realintens, nullptr); + ASSERT_EQ(g.dev_prereduce, nullptr); + ASSERT_EQ(g.dev_devicereduce_temp_storage, nullptr); + ASSERT_EQ(g.dev_imat1, nullptr); + ASSERT_EQ(g.dev_imat2, nullptr); +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} + +// A freed cache holds nothing and has no capacity, so an upload into it is refused rather than +// copied through a null buffer. +void test_2d_moments_gpu_upload_after_free_mechanics() +{ +#ifdef USE_GPU + std::unordered_map rois; + gpu_test_roi (rois, 1, { "111", "111", "111" }, 2, 2); + const std::vector labels { 1 }; + + GpuCacheTestRig cache; + ASSERT_FALSE(cache.allocate (1, 9, 3, 3, 6).has_value()); + ASSERT_NO_THROW(cache.send (labels, rois, 0, 1)); + ASSERT_TRUE(cache.free()); + ASSERT_EQ(cache.g.gpu_roiclouds_2d.total_len, (size_t)0); + ASSERT_EQ(cache.g.gpu_roicontourborders_2d.total_len, (size_t)0); + ASSERT_THROW(cache.send (labels, rois, 0, 1), std::runtime_error); +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} + +// allocate_gpu_cache() allocates every buffer the families it is asked for need, and says so. +void test_2d_moments_gpu_cache_families_mechanics() +{ +#ifdef USE_GPU + GpuCacheTestRig cache; + ASSERT_FALSE(cache.allocate (1, 121, 11, 11, 22, true, true).has_value()); + GpusideCache& g = cache.g; + ASSERT_TRUE(g.is_ready()); + for (const void* p : { (const void*) g.gpu_roiclouds_2d.devbuffer, (const void*) g.gpu_roicontours_2d.devbuffer, + (const void*) g.gpu_roicontourborders_2d.devbuffer, (const void*) g.dev_realintens, (const void*) g.dev_prereduce, + (const void*) g.gpu_featurestatebuf.devbuffer, (const void*) g.dev_devicereduce_temp_storage, + (const void*) g.dev_imat1, (const void*) g.dev_imat2, (const void*) g.gabor_linear_image.devbuffer, + (const void*) g.gabor_result.devbuffer, (const void*) g.gabor_linear_kernel.devbuffer, (const void*) g.gabor_energy_image.devbuffer }) + ASSERT_NE(p, nullptr); +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} + +// A second allocation for larger ROIs gives the Gabor buffers the new size, rather than keeping those +// of the first (the erosion matrices' size shows in test_2d_morphology_gpu_erosion_mechanics). +void test_2d_moments_gpu_cache_regrow_mechanics() +{ +#ifdef USE_GPU + GpuCacheTestRig cache; + ASSERT_FALSE(cache.allocate (1, 9, 3, 3, 6, true, true).has_value()); + ASSERT_FALSE(cache.allocate (1, 41 * 41, 41, 41, 82, true, true).has_value()); + ASSERT_EQ(cache.g.gabor_linear_image.total_len, (size_t)(41 + 16 - 1) * (41 + 16 - 1) * (4 + 1)); +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} + +// An allocation frees every buffer an earlier one left, whichever families the new one needs: after +// an allocation with every family, one with none of them leaves only the clouds, which every +// allocation has, and a free then leaves all 13 buffers null. +void test_2d_moments_gpu_cache_drops_families_mechanics() +{ +#ifdef USE_GPU + GpuCacheTestRig cache; + GpusideCache& g = cache.g; + auto family_buffers = [&g] () + { + return std::vector { g.gpu_roicontours_2d.devbuffer, + g.gpu_roicontourborders_2d.devbuffer, g.dev_realintens, g.dev_prereduce, + g.gpu_featurestatebuf.devbuffer, g.dev_devicereduce_temp_storage, + g.dev_imat1, g.dev_imat2, g.gabor_linear_image.devbuffer, + g.gabor_result.devbuffer, g.gabor_linear_kernel.devbuffer, g.gabor_energy_image.devbuffer }; + }; + ASSERT_FALSE(cache.allocate (1, 121, 11, 11, 22, true, true).has_value()); + for (const void* p : family_buffers()) + ASSERT_NE(p, nullptr); + ASSERT_FALSE(cache.allocate (1, 121, 11, 11, 22, false, false, false).has_value()); + const std::vector after_no_families = family_buffers(); + ASSERT_TRUE(g.is_ready()); + ASSERT_NE(g.gpu_roiclouds_2d.devbuffer, nullptr); + for (size_t i = 0; i < after_no_families.size(); i++) + ASSERT_EQ(after_no_families[i], nullptr) << "family buffer " << i << " kept after an allocation with no families"; + ASSERT_TRUE(cache.free()); + ASSERT_FALSE(g.is_ready()); + ASSERT_EQ(g.gpu_roiclouds_2d.devbuffer, nullptr); + for (const void* p : family_buffers()) + ASSERT_EQ(p, nullptr); +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} + +// A GpuCache allocation that fails frees what it had allocated, the device buffers included, and +// clear() frees each buffer that is set on its own, nulls it, and goes on after a device free that +// fails, reporting it: GpusideCache::is_ready() reads the clouds' host buffer, and an allocation of +// the cache relies on a failed or cleared GpuCache holding nothing. +void test_2d_moments_gpu_cache_alloc_clear_mechanics() +{ +#ifdef USE_GPU + struct Reset { ~Reset() { GpusideCache::test_fail_next_host_alloc = false; } } reset; + + // a plain cache and one with per-ROI offsets, whose first host allocation fails + { + GpuCache c; + GpusideCache::test_fail_next_host_alloc = true; + ASSERT_FALSE(c.alloc (16, 2)); + ASSERT_FALSE(GpusideCache::test_fail_next_host_alloc); + ASSERT_EQ(c.hobuffer, nullptr); + ASSERT_EQ(c.devbuffer, nullptr); + ASSERT_EQ(c.total_len, 0u); + ASSERT_TRUE(c.alloc (16, 2)); // the next allocation succeeds + ASSERT_NE(c.hobuffer, nullptr); + ASSERT_NE(c.devbuffer, nullptr); + ASSERT_EQ(c.total_len, 32u); + } + { + GpuCache c; + GpusideCache::test_fail_next_host_alloc = true; + ASSERT_FALSE(c.alloc (16, 2)); + for (const void* p : { (const void*) c.hobuffer, (const void*) c.devbuffer, (const void*) c.ho_lengths, + (const void*) c.ho_offsets, (const void*) c.dev_offsets }) + ASSERT_EQ(p, nullptr); + } + + // only the device buffer set, as a failed allocation could leave it + { + GpuCache c; + ASSERT_TRUE(GpusideCache::allocate_on_device ((void**)&c.devbuffer, 64 * sizeof(float))); + ASSERT_TRUE(c.clear()); + ASSERT_EQ(c.devbuffer, nullptr); + } + + // a device free that fails: reported, and the host buffer freed and both pointers nulled all the same + { + GpuCache c; + static float not_on_device; + c.hobuffer = (float*) std::malloc (sizeof(float)); + c.devbuffer = ¬_on_device; + ASSERT_FALSE(c.clear()); + cudaGetLastError(); // the refused free is CUDA's last error, which a later CUB call would report + ASSERT_EQ(c.hobuffer, nullptr); + ASSERT_EQ(c.devbuffer, nullptr); + } +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} + +// With the GPU on and its cache not ready -- never allocated, as on the paths other than the +// segmented 2D workflow, or after an allocation or a free that failed part way -- the moments are +// computed on the CPU, with the CPU's values. +void test_2d_moments_gpu_unready_cache_mechanics() +{ +#ifdef USE_GPU + for (GpuUnready why : { GpuUnready::never_allocated, GpuUnready::allocation_failed_part_way, GpuUnready::free_failed_part_way }) + { + SCOPED_TRACE(gpu_unready_name (why)); + std::vector> on, off; + ASSERT_NO_FATAL_FAILURE(gpu_unready_vs_cpu ({ Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_00, Nyxus::Feature2D::IMOM_WRM_00 }, why, on, off)); + for (auto f : { Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_00, Nyxus::Feature2D::IMOM_WRM_00 }) + { + ASSERT_EQ(on[(int)f][0], off[(int)f][0]) << "feature " << (int)f; + ASSERT_NE(off[(int)f][0], 0.0) << "feature " << (int)f; + } + } +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} diff --git a/tests/test_2d_moments_regression.h b/tests/test_2d_moments_regression.h index 27d44a6c6..09773a239 100644 --- a/tests/test_2d_moments_regression.h +++ b/tests/test_2d_moments_regression.h @@ -20,37 +20,37 @@ static const ref_vals_list moments_2d_regression_shape_ref {Nyxus::Feature2D::NORM_SPAT_MOMENT_31, "NORM_SPAT_MOMENT_31", 0.14021972656250001}, {Nyxus::Feature2D::NORM_SPAT_MOMENT_32, "NORM_SPAT_MOMENT_32", 0.084268320449909992}, {Nyxus::Feature2D::NORM_SPAT_MOMENT_33, "NORM_SPAT_MOMENT_33", 0.056964263916015626}, - {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_00, "WEIGHTED_SPAT_MOMENT_00", 2498.7137504625134}, - {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_01, "WEIGHTED_SPAT_MOMENT_01", 56490.46666226536}, - {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_02, "WEIGHTED_SPAT_MOMENT_02", 1384474.7358805416}, - {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_03, "WEIGHTED_SPAT_MOMENT_03", 37605700.252866454}, - {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_10, "WEIGHTED_SPAT_MOMENT_10", 66428.8643021069}, - {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_11, "WEIGHTED_SPAT_MOMENT_11", 1505032.6027383565}, - {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_12, "WEIGHTED_SPAT_MOMENT_12", 37215299.0752626}, - {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_20, "WEIGHTED_SPAT_MOMENT_20", 1970501.3783921746}, - {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_21, "WEIGHTED_SPAT_MOMENT_21", 45088115.396607324}, - {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_30, "WEIGHTED_SPAT_MOMENT_30", 65035567.204033986}, - {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_02, "WEIGHTED_CENTRAL_MOMENT_02", 107348.52519321327}, - {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_03, "WEIGHTED_CENTRAL_MOMENT_03", 1451914.945274622}, - {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_11, "WEIGHTED_CENTRAL_MOMENT_11", 3220.9031886536227}, - {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_12, "WEIGHTED_CENTRAL_MOMENT_12", 263093.1689978499}, - {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_20, "WEIGHTED_CENTRAL_MOMENT_20", 204475.1532389633}, - {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_21, "WEIGHTED_CENTRAL_MOMENT_21", 368121.2456062267}, - {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_30, "WEIGHTED_CENTRAL_MOMENT_30", 1777311.6081707373}, - {Nyxus::Feature2D::WT_NORM_CTR_MOM_02, "WT_NORM_CTR_MOM_02", 0.017193451534902194}, - {Nyxus::Feature2D::WT_NORM_CTR_MOM_03, "WT_NORM_CTR_MOM_03", 0.00465210928951103}, - {Nyxus::Feature2D::WT_NORM_CTR_MOM_11, "WT_NORM_CTR_MOM_11", 0.0005158752090264311}, - {Nyxus::Feature2D::WT_NORM_CTR_MOM_12, "WT_NORM_CTR_MOM_12", 0.0008429820076480387}, - {Nyxus::Feature2D::WT_NORM_CTR_MOM_20, "WT_NORM_CTR_MOM_20", 0.032749715293974795}, - {Nyxus::Feature2D::WT_NORM_CTR_MOM_21, "WT_NORM_CTR_MOM_21", 0.0011795045377311554}, - {Nyxus::Feature2D::WT_NORM_CTR_MOM_30, "WT_NORM_CTR_MOM_30", 0.005694719149793572}, - {Nyxus::Feature2D::WEIGHTED_HU_M1, "WEIGHTED_HU_M1", 0.04994316682887699}, - {Nyxus::Feature2D::WEIGHTED_HU_M2, "WEIGHTED_HU_M2", 0.00024306185106698786}, - {Nyxus::Feature2D::WEIGHTED_HU_M3, "WEIGHTED_HU_M3", 1.1262214820995351e-05}, - {Nyxus::Feature2D::WEIGHTED_HU_M4, "WEIGHTED_HU_M4", 7.674925625409561e-05}, - {Nyxus::Feature2D::WEIGHTED_HU_M5, "WEIGHTED_HU_M5", -1.8387946856938181e-09}, // skimage moments_hu over the pinned WT_NORM_CTR_MOM_* (gen_moments_skimage.py) - {Nyxus::Feature2D::WEIGHTED_HU_M6, "WEIGHTED_HU_M6", 2.1453724299933224e-07}, // distinct from WT_NORM_CTR_MOM_03; equality there means h6 lost the (eta21+eta03) grouping - {Nyxus::Feature2D::WEIGHTED_HU_M7, "WEIGHTED_HU_M7", -1.3078000499389243e-09}, + {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_00, "WEIGHTED_SPAT_MOMENT_00", 1895.0047386658844}, + {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_01, "WEIGHTED_SPAT_MOMENT_01", 35936.03530272888}, + {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_02, "WEIGHTED_SPAT_MOMENT_02", 638259.19023700757}, + {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_03, "WEIGHTED_SPAT_MOMENT_03", 10157210.471993469}, + {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_10, "WEIGHTED_SPAT_MOMENT_10", 43477.893744408153}, + {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_11, "WEIGHTED_SPAT_MOMENT_11", 840156.49488028791}, + {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_12, "WEIGHTED_SPAT_MOMENT_12", 14971355.233518988}, + {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_20, "WEIGHTED_SPAT_MOMENT_20", 998251.12667152286}, + {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_21, "WEIGHTED_SPAT_MOMENT_21", 19364279.49826885}, + {Nyxus::Feature2D::WEIGHTED_SPAT_MOMENT_30, "WEIGHTED_SPAT_MOMENT_30", 22560196.678065144}, + {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_02, "WEIGHTED_CENTRAL_MOMENT_02", -43215.956990404084}, + {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_03, "WEIGHTED_CENTRAL_MOMENT_03", -307398.96983443695}, + {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_11, "WEIGHTED_CENTRAL_MOMENT_11", 15660.865604279265}, + {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_12, "WEIGHTED_CENTRAL_MOMENT_12", -266466.34221961326}, + {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_20, "WEIGHTED_CENTRAL_MOMENT_20", 719.45517772604308}, + {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_21, "WEIGHTED_CENTRAL_MOMENT_21", -284742.93987422739}, + {Nyxus::Feature2D::WEIGHTED_CENTRAL_MOMENT_30, "WEIGHTED_CENTRAL_MOMENT_30", -376113.88641494792}, + {Nyxus::Feature2D::WT_NORM_CTR_MOM_02, "WT_NORM_CTR_MOM_02", -0.012034374825642609}, + {Nyxus::Feature2D::WT_NORM_CTR_MOM_03, "WT_NORM_CTR_MOM_03", -0.0019664216948016336}, + {Nyxus::Feature2D::WT_NORM_CTR_MOM_11, "WT_NORM_CTR_MOM_11", 0.004361091131633606}, + {Nyxus::Feature2D::WT_NORM_CTR_MOM_12, "WT_NORM_CTR_MOM_12", -0.0017045769429783677}, + {Nyxus::Feature2D::WT_NORM_CTR_MOM_20, "WT_NORM_CTR_MOM_20", 0.0002003471375382717}, + {Nyxus::Feature2D::WT_NORM_CTR_MOM_21, "WT_NORM_CTR_MOM_21", -0.0018214917724410384}, + {Nyxus::Feature2D::WT_NORM_CTR_MOM_30, "WT_NORM_CTR_MOM_30", -0.0024059888891652882}, + {Nyxus::Feature2D::WEIGHTED_HU_M1, "WEIGHTED_HU_M1", -0.011834027688104336}, + {Nyxus::Feature2D::WEIGHTED_HU_M2, "WEIGHTED_HU_M2", 0.00022576488494999378}, + {Nyxus::Feature2D::WEIGHTED_HU_M3, "WEIGHTED_HU_M3", 1.9568245558424062e-05}, + {Nyxus::Feature2D::WEIGHTED_HU_M4, "WEIGHTED_HU_M4", 3.1245039895705273e-05}, + {Nyxus::Feature2D::WEIGHTED_HU_M5, "WEIGHTED_HU_M5", 7.7258053721697436e-10}, // skimage moments_hu over the pinned WT_NORM_CTR_MOM_* (gen_moments_skimage.py) + {Nyxus::Feature2D::WEIGHTED_HU_M6, "WEIGHTED_HU_M6", 3.0279665049143974e-07}, // distinct from WT_NORM_CTR_MOM_03; equality there means h6 lost the (eta21+eta03) grouping + {Nyxus::Feature2D::WEIGHTED_HU_M7, "WEIGHTED_HU_M7", -3.2348409954860699e-12}, }; static const ref_vals_list moments_2d_regression_intensity_ref_vals{ @@ -70,37 +70,37 @@ static const ref_vals_list moments_2d_regression_intensity {Nyxus::Feature2D::IMOM_NRM_31, "IMOM_NRM_31", 6.1552927901407291e-06}, {Nyxus::Feature2D::IMOM_NRM_32, "IMOM_NRM_32", 2.9283383602959163e-07}, {Nyxus::Feature2D::IMOM_NRM_33, "IMOM_NRM_33", 1.5262053460086997e-08}, - {Nyxus::Feature2D::IMOM_WRM_00, "IMOM_WRM_00", 512893.00982429727}, - {Nyxus::Feature2D::IMOM_WRM_01, "IMOM_WRM_01", 12144895.632545877}, - {Nyxus::Feature2D::IMOM_WRM_02, "IMOM_WRM_02", 317009217.47022206}, - {Nyxus::Feature2D::IMOM_WRM_03, "IMOM_WRM_03", 8987339922.381422}, - {Nyxus::Feature2D::IMOM_WRM_10, "IMOM_WRM_10", 14267758.335710838}, - {Nyxus::Feature2D::IMOM_WRM_11, "IMOM_WRM_11", 340242564.82820618}, - {Nyxus::Feature2D::IMOM_WRM_12, "IMOM_WRM_12", 8924143257.7546329}, - {Nyxus::Feature2D::IMOM_WRM_20, "IMOM_WRM_20", 445196520.83852273}, - {Nyxus::Feature2D::IMOM_WRM_21, "IMOM_WRM_21", 10672248980.453976}, - {Nyxus::Feature2D::IMOM_WRM_30, "IMOM_WRM_30", 15212433464.582859}, - {Nyxus::Feature2D::IMOM_WCM_02, "IMOM_WCM_02", 29427817.24404288}, - {Nyxus::Feature2D::IMOM_WCM_03, "IMOM_WCM_03", 87161304.30764286}, - {Nyxus::Feature2D::IMOM_WCM_11, "IMOM_WCM_11", 2393476.230527348}, - {Nyxus::Feature2D::IMOM_WCM_12, "IMOM_WCM_12", -7832526.665747583}, - {Nyxus::Feature2D::IMOM_WCM_20, "IMOM_WCM_20", 48293221.09587332}, - {Nyxus::Feature2D::IMOM_WCM_21, "IMOM_WCM_21", -2812405.054201167}, - {Nyxus::Feature2D::IMOM_WCM_30, "IMOM_WCM_30", 141008736.4786922}, - {Nyxus::Feature2D::IMOM_WNCM_02, "IMOM_WNCM_02", 0.00011186764375543395}, - {Nyxus::Feature2D::IMOM_WNCM_03, "IMOM_WNCM_03", 4.6265447913743816e-07}, - {Nyxus::Feature2D::IMOM_WNCM_11, "IMOM_WNCM_11", 9.098620671498624e-06}, - {Nyxus::Feature2D::IMOM_WNCM_12, "IMOM_WNCM_12", -4.1575256057220215e-08}, - {Nyxus::Feature2D::IMOM_WNCM_20, "IMOM_WNCM_20", 0.0001835830638933708}, - {Nyxus::Feature2D::IMOM_WNCM_21, "IMOM_WNCM_21", -1.4928319462526036e-08}, - {Nyxus::Feature2D::IMOM_WNCM_30, "IMOM_WNCM_30", 7.484780551139263e-07}, - {Nyxus::Feature2D::IMOM_WHU1, "IMOM_WHU1", 0.00029545070764880473}, - {Nyxus::Feature2D::IMOM_WHU2, "IMOM_WHU2", 5.6161730111679807e-09}, - {Nyxus::Feature2D::IMOM_WHU3, "IMOM_WHU3", 1.1923046864432925e-12}, - {Nyxus::Feature2D::IMOM_WHU4, "IMOM_WHU4", 3.1287168309169019e-12}, - {Nyxus::Feature2D::IMOM_WHU5, "IMOM_WHU5", -3.578057915124686e-25}, // skimage moments_hu over the pinned IMOM_WNCM_* (gen_moments_skimage.py) - {Nyxus::Feature2D::IMOM_WHU6, "IMOM_WHU6", 3.297985694277869e-17}, // distinct from IMOM_WNCM_03; equality there means h6 lost the (eta21+eta03) grouping - {Nyxus::Feature2D::IMOM_WHU7, "IMOM_WHU7", -5.6202519491182231e-24}, + {Nyxus::Feature2D::IMOM_WRM_00, "IMOM_WRM_00", 335162.57809632458}, + {Nyxus::Feature2D::IMOM_WRM_01, "IMOM_WRM_01", 6168510.5631702933}, + {Nyxus::Feature2D::IMOM_WRM_02, "IMOM_WRM_02", 103775456.18151155}, + {Nyxus::Feature2D::IMOM_WRM_03, "IMOM_WRM_03", 1168564811.2264738}, + {Nyxus::Feature2D::IMOM_WRM_10, "IMOM_WRM_10", 7752350.6516130678}, + {Nyxus::Feature2D::IMOM_WRM_11, "IMOM_WRM_11", 143242219.36890373}, + {Nyxus::Feature2D::IMOM_WRM_12, "IMOM_WRM_12", 2266614348.930891}, + {Nyxus::Feature2D::IMOM_WRM_20, "IMOM_WRM_20", 177278715.03645942}, + {Nyxus::Feature2D::IMOM_WRM_21, "IMOM_WRM_21", 3041066152.5966654}, + {Nyxus::Feature2D::IMOM_WRM_30, "IMOM_WRM_30", 3674357692.8810434}, + {Nyxus::Feature2D::IMOM_WCM_02, "IMOM_WCM_02", -9753096.9883113019}, + {Nyxus::Feature2D::IMOM_WCM_03, "IMOM_WCM_03", -382371564.23019207}, + {Nyxus::Feature2D::IMOM_WCM_11, "IMOM_WCM_11", 563829.80796688376}, + {Nyxus::Feature2D::IMOM_WCM_12, "IMOM_WCM_12", -154478453.88649949}, + {Nyxus::Feature2D::IMOM_WCM_20, "IMOM_WCM_20", -2034085.8344459396}, + {Nyxus::Feature2D::IMOM_WCM_21, "IMOM_WCM_21", -247748623.04706782}, + {Nyxus::Feature2D::IMOM_WCM_30, "IMOM_WCM_30", -332022960.85621363}, + {Nyxus::Feature2D::IMOM_WNCM_02, "IMOM_WNCM_02", -8.6822342330091672e-05}, + {Nyxus::Feature2D::IMOM_WNCM_03, "IMOM_WNCM_03", -5.8795864683994083e-06}, + {Nyxus::Feature2D::IMOM_WNCM_11, "IMOM_WNCM_11", 5.0192287292824919e-06}, + {Nyxus::Feature2D::IMOM_WNCM_12, "IMOM_WNCM_12", -2.375358191080171e-06}, + {Nyxus::Feature2D::IMOM_WNCM_20, "IMOM_WNCM_20", -1.8107489022072529e-05}, + {Nyxus::Feature2D::IMOM_WNCM_21, "IMOM_WNCM_21", -3.8095391705309974e-06}, + {Nyxus::Feature2D::IMOM_WNCM_30, "IMOM_WNCM_30", -5.1053945702742657e-06}, + {Nyxus::Feature2D::IMOM_WHU1, "IMOM_WHU1", -0.0001049298313521642}, + {Nyxus::Feature2D::IMOM_WHU2, "IMOM_WHU2", 4.8225016932900072e-09}, + {Nyxus::Feature2D::IMOM_WHU3, "IMOM_WHU3", 3.4874893192713753e-11}, + {Nyxus::Feature2D::IMOM_WHU4, "IMOM_WHU4", 1.4984081752349058e-10}, + {Nyxus::Feature2D::IMOM_WHU5, "IMOM_WHU5", 7.3903049127494041e-21}, // skimage moments_hu over the pinned IMOM_WNCM_* (gen_moments_skimage.py) + {Nyxus::Feature2D::IMOM_WHU6, "IMOM_WHU6", -1.1502810112370345e-15}, // distinct from IMOM_WNCM_03; equality there means h6 lost the (eta21+eta03) grouping + {Nyxus::Feature2D::IMOM_WHU7, "IMOM_WHU7", -7.9190801861085271e-21}, }; void test_2d_moments_shape_regression() diff --git a/tests/test_2d_morphology_imea.h b/tests/test_2d_morphology_imea.h index c15f665cd..ea8c04414 100644 --- a/tests/test_2d_morphology_imea.h +++ b/tests/test_2d_morphology_imea.h @@ -20,9 +20,9 @@ // they are regression snapshots in test_2d_morphology_regression.h and the diameters are vetted // against imea on the clean ellipse below. Measured in audit/morphology_2d_imea_vetting_report.md. static const ref_vals_map morphology_2d_imea_shape2d_ref_vals{ - {"DIAMETER_EQUAL_PERIMETER", 8.573658094355881}, - {"GEODETIC_LENGTH", 11.13182483477333}, - {"THICKNESS", 2.3356458070362205} + {"DIAMETER_EQUAL_PERIMETER", 7.311757214045382}, + {"GEODETIC_LENGTH", 8.384216364883956}, + {"THICKNESS", 3.101065009354617} }; // The caliper benchmark: a filled ellipse a=20, b=10 (calculate_ellipse_caliper_values), clean @@ -58,8 +58,8 @@ static const ref_vals_map morphology_2d_imea_ellipse_ref_vals{ // imea/OpenCV to 8 digits: for the ellipse a=20 its diameter is the major axis, 2a = 40. (The // circle fixture's value 30 is asserted inline.) NOTE: DIAMETER_CIRCUMSCRIBING_CIRCLE and // DIAMETER_INSCRIBING_CIRCLE are NOT here -- they are imea's crude max/min centroid-to-contour - // distance approximation (not a true geometric circle), sensitive to Nyxus's contour convention + - // the centroid-1 offset (a symmetric circle yields 35.6/23.3, not ~30/~30), so they stay regression. + // distance approximation (not a true geometric circle). On the r=15 circle Nyxus gives 30 and + // 28.07, twice the largest and smallest distance from the centre to an edge pixel. {"DIAMETER_MIN_ENCLOSING_CIRCLE", 40.00019836425781}, }; @@ -112,21 +112,22 @@ void test_2d_morphology_caliper_spread_imea() // // imea.measure_2d.macro.perimeter_equal_diameter(perimeter) is a third-party implementation of the // DIN ISO 9276-6 perimeter-equal diameter (perimeter/pi) -- the same documented transform Nyxus -// applies in contour.cpp. Fed the Nyxus PERIMETER (26.9349412836191) it returns 8.57365809435588, -// matching the Nyxus DIAMETER_EQUAL_PERIMETER to double precision (measured |diff| = 1.07e-14), so -// this is asserted at the SPEC 7 "exact" tier. +// applies in contour.cpp. Fed the Nyxus PERIMETER (22.970562748477146) it returns 7.311757214045382, +// matching the Nyxus DIAMETER_EQUAL_PERIMETER to double precision, so this is asserted at the SPEC 7 +// "exact" tier. // -// SCOPE: this vets the TRANSFORM, not the perimeter it consumes. Nyxus walks its chain-code contour -// (26.9349) while imea takes cv2.arcLength over the OpenCV contour (12.6569), so imea's END-TO-END -// diameter_equal_perimeter on this fixture is 4.0288 -- it does NOT agree with Nyxus, and the whole -// gap is inherited from PERIMETER, which stays a regression row of its own. What is pinned here is -// that Nyxus' derived diameter is the ISO quantity a third-party package computes from the same input. +// SCOPE: this vets the TRANSFORM, not the perimeter it consumes. Nyxus sums the closed walks of every +// border, the hole's included (22.9706), while imea takes cv2.arcLength over the OpenCV contour +// (12.6569), so imea's END-TO-END diameter_equal_perimeter on this fixture is 4.0288 -- it does NOT +// agree with Nyxus, and the whole gap is inherited from PERIMETER, which is vetted against OpenCV's +// border following in test_2d_contour_opencv.h. What is pinned here is that Nyxus' derived diameter +// is the ISO quantity a third-party package computes from the same input. void test_2d_morphology_diameter_equal_perimeter_imea() { std::vector> fvals; calculate_shape2d_feature_values(fvals); - // imea 0.3.5: macro.perimeter_equal_diameter(26.9349412836191). Read from the table rather than + // imea 0.3.5: macro.perimeter_equal_diameter(22.970562748477146). Read from the table rather than // repeated inline -- the two copies had already drifted (8.57365809435587 vs ...881), and only // the copy an assertion reads is under test. assert_iso_transform_imea(fvals, Nyxus::Feature2D::DIAMETER_EQUAL_PERIMETER, "DIAMETER_EQUAL_PERIMETER"); @@ -138,15 +139,15 @@ void test_2d_morphology_diameter_equal_perimeter_imea() // of the DIN ISO 9276-6 rectangle model -- the rectangle with the same area and perimeter as the // object, whose side lengths are the roots of x^2 - (P/2)x + A = 0, i.e. P/4 +- sqrt(P^2/16 - A). // That is the same documented model Nyxus applies in geo_len_thickness.cpp. Fed the Nyxus -// AREA_PIXELS_COUNT (26) and PERIMETER (26.9349412836191) it returns 11.13182483477333 / -// 2.3356458070362205, which are the Nyxus values bit for bit (measured |diff| = 0 for both), so this -// is asserted at the SPEC 7 "exact" tier. +// AREA_PIXELS_COUNT (26) and PERIMETER (22.970562748477146) it returns 8.384216364883956 / +// 3.101065009354617, which are the Nyxus values to double precision, so this is asserted at the +// SPEC 7 "exact" tier. // // SCOPE: this vets the TRANSFORM, not the area and perimeter it consumes. imea derives its own -// perimeter from cv2.arcLength (12.6569 on this fixture) instead of a chain-code contour walk, and -// with that perimeter (P/4)^2 - A goes negative and imea clamps the root, so its END-TO-END -// geodeticlength and thickness both collapse to 3.1642 -- neither agrees with Nyxus (differences 7.97 -// and 0.83). The whole gap is inherited from PERIMETER, which stays a regression row of its own. What +// perimeter from cv2.arcLength (12.6569 on this fixture) instead of summing the closed walks of every +// border, and with that perimeter (P/4)^2 - A goes negative and imea clamps the root, so its +// END-TO-END geodeticlength and thickness both collapse to 3.1642 -- neither agrees with Nyxus. The +// whole gap is inherited from PERIMETER, which is vetted against OpenCV in test_2d_contour_opencv.h. What // is pinned here is that Nyxus' two side lengths are the ISO quantities a third-party package computes // from the same two inputs -- the same scope as the DIAMETER_EQUAL_PERIMETER claim above. void test_2d_morphology_geodetic_length_thickness_imea() @@ -154,7 +155,7 @@ void test_2d_morphology_geodetic_length_thickness_imea() std::vector> fvals; calculate_shape2d_feature_values(fvals); - // imea 0.3.5: macro.geodeticlength_and_thickness(26.0, 26.9349412836191) + // imea 0.3.5: macro.geodeticlength_and_thickness(26.0, 22.970562748477146) assert_iso_transform_imea(fvals, Nyxus::Feature2D::GEODETIC_LENGTH, "GEODETIC_LENGTH"); assert_iso_transform_imea(fvals, Nyxus::Feature2D::THICKNESS, "THICKNESS"); } diff --git a/tests/test_2d_morphology_invariant.h b/tests/test_2d_morphology_invariant.h index 35d59fba1..7c20883c7 100644 --- a/tests/test_2d_morphology_invariant.h +++ b/tests/test_2d_morphology_invariant.h @@ -90,8 +90,8 @@ namespace nyxus_ut_edt return from_mask ("ring R=" + std::to_string(R) + " w=" + std::to_string(w), m, S, S, 100, 100); } - // A square with a square hole: two contours, so merge_multicontour's flattening is exercised - // and the transform is seeded from a non-simply-connected site set. + // A square with a square hole: two borders, so the contour the radius is measured to holds the + // pixels of both and the transform is seeded from a non-simply-connected site set. inline Shape square_with_hole (int S, int hole) { std::vector m ((size_t)S * S, 1); @@ -320,6 +320,7 @@ inline void test_2d_morphology_roi_radius_paths_agree_invariant() roi.initialize_fvals(); roi.raw_pixels = shape.cloud; roi.multicontour_.push_back (shape.contour); + roi.merge_multicontour(); RoiRadiusFeature f; f.calculate (roi, s); @@ -335,6 +336,7 @@ inline void test_2d_morphology_roi_radius_paths_agree_invariant() LR roi (1); roi.initialize_fvals(); roi.multicontour_.push_back (shape.contour); + roi.merge_multicontour(); roi.raw_pixels_NT.init (1, "nyxus_ut_paths_agree"); for (const auto& p : shape.cloud) diff --git a/tests/test_2d_morphology_mechanics.h b/tests/test_2d_morphology_mechanics.h index 77446adeb..ae20f1304 100644 --- a/tests/test_2d_morphology_mechanics.h +++ b/tests/test_2d_morphology_mechanics.h @@ -3,6 +3,7 @@ #include #include "../src/nyx/features/edt.h" +#include "../src/nyx/features/erosion.h" #include "../src/nyx/features/roi_radius.h" #include "test_main_nyxus.h" // roi_cache.h / featureset.h / pixel.h @@ -149,6 +150,7 @@ inline void test_2d_roi_radius_single_pixel_mechanics() const Pixel2 p (17L, 23L, (PixIntens)1); roi.raw_pixels.push_back (p); roi.multicontour_.push_back ({ p }); + roi.merge_multicontour(); roi.make_nonanisotropic_aabb(); RoiRadiusFeature f; @@ -159,9 +161,9 @@ inline void test_2d_roi_radius_single_pixel_mechanics() ASSERT_EQ (0.0, roi.fvals[(int)Nyxus::Feature2D::ROI_RADIUS_MEDIAN][0]); } -// A contour lying outside the ROI's own bounding box. Contours are traced on a padded image and come -// back offset from the pixel cloud, so the raster is sized to hold both; sizing it to the cloud -// alone would index past the buffer here. +// A contour lying partly outside the ROI's own bounding box, set on the ROI directly rather than +// traced. RoiRadiusFeature sizes its raster to hold both; sizing it to the cloud alone would index +// past the buffer here. inline void test_2d_roi_radius_contour_outside_bbox_mechanics() { Fsettings s; @@ -174,9 +176,10 @@ inline void test_2d_roi_radius_contour_outside_bbox_mechanics() { roi.raw_pixels.push_back (Pixel2(x, y, (PixIntens)1)); if (x == 0 || y == 0 || x == 4 || y == 4) - contour.push_back (Pixel2(x + 1, y + 1, (PixIntens)1)); // the (+1,+1) offset + contour.push_back (Pixel2(x + 1, y + 1, (PixIntens)1)); // shifted one pixel off the cloud } roi.multicontour_.push_back (contour); + roi.merge_multicontour(); roi.make_nonanisotropic_aabb(); RoiRadiusFeature f; @@ -189,3 +192,44 @@ inline void test_2d_roi_radius_contour_outside_bbox_mechanics() ASSERT_GE (mx, 0.0); ASSERT_LE (mx, bound) << "a distance past the union's diagonal means the raster was mis-sized"; } + +// The GPU erosion counts what the CPU counts, through a cache allocated for the ROI and through one +// reallocated for larger ROIs after an allocation for 3 x 3 ones, whose matrices then have the new +// size. With the GPU on and its cache not ready -- never allocated, or after an allocation or a free +// that failed part way -- the erosions are computed on the CPU. +void test_2d_morphology_gpu_erosion_mechanics() +{ +#ifdef USE_GPU + const Fsettings s = make_shape2d_settings(); + for (int radius : { 5, 20 }) + { + SCOPED_TRACE("disk of radius " + std::to_string(radius)); + std::unordered_map rois; + gpu_test_roi (rois, 1, gpu_test_disk_rows (radius), 2, 2); + const size_t side = 2 * radius + 1; + GpuCacheTestRig cache; + ASSERT_FALSE(cache.allocate (1, 9, 3, 3, 6, true).has_value()); + ASSERT_FALSE(cache.allocate (1, side * side, side, side, 2 * side, true).has_value()); + const std::vector labels { 1 }; + cache.send (labels, rois, 0, 1); + ErosionPixelsFeature::gpu_process_all_rois (labels, rois, 0, 1, cache.g); + const double gpu = rois[1].fvals[(int)Nyxus::Feature2D::EROSIONS_2_VANISH][0]; + ErosionPixelsFeature cpu; + cpu.calculate (rois[1], s); + cpu.save_value (rois[1].fvals); + ASSERT_EQ(gpu, rois[1].fvals[(int)Nyxus::Feature2D::EROSIONS_2_VANISH][0]); + ASSERT_GT(gpu, 0.0); + } + + for (GpuUnready why : { GpuUnready::never_allocated, GpuUnready::allocation_failed_part_way, GpuUnready::free_failed_part_way }) + { + SCOPED_TRACE(gpu_unready_name (why)); + std::vector> on, off; + ASSERT_NO_FATAL_FAILURE(gpu_unready_vs_cpu ({ Nyxus::Feature2D::EROSIONS_2_VANISH, Nyxus::Feature2D::EROSIONS_2_VANISH_COMPLEMENT }, why, on, off)); + ASSERT_EQ(on[(int)Nyxus::Feature2D::EROSIONS_2_VANISH][0], off[(int)Nyxus::Feature2D::EROSIONS_2_VANISH][0]); + ASSERT_GT(off[(int)Nyxus::Feature2D::EROSIONS_2_VANISH][0], 0.0); + } +#else + GTEST_SKIP() << "built without USE_GPU"; +#endif +} diff --git a/tests/test_2d_morphology_regression.h b/tests/test_2d_morphology_regression.h index f032b2351..24fbca943 100644 --- a/tests/test_2d_morphology_regression.h +++ b/tests/test_2d_morphology_regression.h @@ -195,26 +195,25 @@ static const ref_vals_map morphology_2d_regression_ref_vals {"ECCENTRICITY", 0.616173960820708}, {"ORIENTATION", 70.4173944984207}, {"ROUNDNESS", 0.681656295209303}, - {"PERIMETER", 26.9349412836191}, + // The outer border and the border of the one-pixel hole, summed. OpenCV's border following gives + // the same number (test_2d_contour_opencv.h), which is where PERIMETER is vetted. + {"PERIMETER", 22.970562748477146}, {"CIRCULARITY", 0.671081973229055}, {"EULER_NUMBER", 0.0}, {"DIAMETER_MIN_ENCLOSING_CIRCLE", 6.32475519180298}, // The three ROI_RADIUS_* statistics on this ROI. RoiRadiusFeature measures every ROI pixel's // distance to the ROI's contour and reports the mean, maximum and median of those distances. // - // MAX and MEDIAN are vetted on filled disks -- against scikit-image in + // The statistics are vetted on filled disks -- against scikit-image in // test_2d_morphology_skimage.h and, for MAX, against the closed form in - // test_2d_morphology_analytic.h. These three rows are drift guards on shape2d, where the - // reference disagrees for a reason outside this family: the traced contour is returned one pixel - // right and one pixel down of where it is, so the distances are measured against a shifted - // boundary. See the disk table in test_2d_morphology_skimage.h. + // test_2d_morphology_analytic.h. These three rows are drift guards on shape2d. // - // MEDIAN is 1 here and would also be 1 if the feature reported squared distances, this ROI being - // small enough that its median distance is exactly one pixel. That is part of why the vetting - // claim lives on disks rather than on this fixture. - {"ROI_RADIUS_MEAN", 0.62471694950458789}, - {"ROI_RADIUS_MAX", 1.4142135623730951}, - {"ROI_RADIUS_MEDIAN", 1.0}, + // 18 of the 26 pixels are edge pixels, at distance 0, and the other 8 are one pixel from the + // edge: MEAN = 8/26, MAX = 1 and MEDIAN = 0. A ROI this small cannot tell a distance from its + // square, which is part of why the vetting claim lives on disks rather than on this fixture. + {"ROI_RADIUS_MEAN", 0.30769230769230771}, + {"ROI_RADIUS_MAX", 1.0}, + {"ROI_RADIUS_MEDIAN", 0.0}, // Its five siblings are vetted against CellProfiler in test_2d_morphology_cellprofiler.h; this // one cannot be. Both tools take the same 18 edge pixels, then Nyxus divides the variance by // n-1 and CellProfiler by n, so the two answers differ by exactly sqrt(18/17) = 1.0289915: @@ -226,8 +225,11 @@ static const ref_vals_map morphology_2d_regression_ref_vals }; static const ref_vals_map morphology_2d_regression_diameters_ref_vals{ - {"DIAMETER_CIRCUMSCRIBING_CIRCLE", 12.3317073399088}, - {"DIAMETER_INSCRIBING_CIRCLE", 0.828486893405308}, + // Both measured from the centroid to the outer border; the hole's border is not part of it. The + // centroid and the points cv2.findContours(RETR_EXTERNAL) returns for this mask give the same two + // numbers. + {"DIAMETER_CIRCUMSCRIBING_CIRCLE", 6.8888040545348037}, + {"DIAMETER_INSCRIBING_CIRCLE", 3.6047306196492248}, }; static void assert_morphology_feature_regression(const std::vector>& fvals, diff --git a/tests/test_2d_morphology_skimage.h b/tests/test_2d_morphology_skimage.h index 7f5ef9bfa..e25f02696 100644 --- a/tests/test_2d_morphology_skimage.h +++ b/tests/test_2d_morphology_skimage.h @@ -59,12 +59,7 @@ static const ref_vals_map morphology_2d_skimage_circles_ref_vals{ // offset (1, R-1) -- which test_2d_morphology_analytic.h asserts as a second oracle on this same // fixture. // -// ROI_RADIUS_MEAN is deliberately absent, because of a defect outside this family: -// `buildRegularContour` reports every contour pixel one pixel right and one pixel down of where it -// is, so Nyxus measures its pixels against a shifted boundary. Shifting the reference boundary the -// same way reproduces all three Nyxus values exactly. On a disk MAX and MEDIAN are unmoved by that -// shift -- the pixel attaining them moves with it -- while MEAN is not, so MEAN is a regression row -// until the contour offset is corrected. +// ROI_RADIUS_MEAN is a regression row. static const ref_vals_map morphology_2d_skimage_radius_disks_ref_vals{ // R=10: 317 pixels, 56 boundary pixels; MAX/(R-1) = 1.006154 {"ROI_RADIUS_MAX_R10", 9.055385138137417}, diff --git a/tests/test_2d_neighbor_cellprofiler.h b/tests/test_2d_neighbor_cellprofiler.h index 5c553dc19..59a7aba12 100644 --- a/tests/test_2d_neighbor_cellprofiler.h +++ b/tests/test_2d_neighbor_cellprofiler.h @@ -16,11 +16,11 @@ // exactly 0, not merely small -- so these assert at the SPEC 7 exact tier, which is an ABSOLUTE // 1e-9 band. Per label, not aggregated: each ROI carries its own value and every one is asserted. // -// PERCENT_TOUCHING is NOT vetted here: CP and Nyxus use different definitions (Nyxus = contour -// pixels 8-adjacent to a neighbour / contour length; CP = object outline pixels overlapping a -// disk(distance+0.5)-dilated neighbour / perimeter), and no CP distance method reproduces Nyxus -- -// it diverges on 3 of the 5 ROIs. It is drift-pinned in test_2d_neighbor_regression.h and its -// required bounds are asserted in test_2d_neighbor_invariant.h. Same for CLOSEST_NEIGHBOR2_DIST: +// PERCENT_TOUCHING is NOT vetted here: CP and Nyxus state it with different definitions (Nyxus = +// contour pixels 8-adjacent to a neighbour / contour length; CP = object outline pixels overlapping +// a disk(distance+0.5)-dilated neighbour / perimeter). It is drift-pinned in +// test_2d_neighbor_regression.h and its required bounds are asserted in +// test_2d_neighbor_invariant.h. CLOSEST_NEIGHBOR2_DIST is not vetted here either: // CP measures the second-closest of ANY object, Nyxus the second-closest neighbour within the // search radius, so CP's column is a different quantity. See // tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md. diff --git a/tests/test_2d_neighbor_regression.h b/tests/test_2d_neighbor_regression.h index 12e68f2f7..eb84b53e6 100644 --- a/tests/test_2d_neighbor_regression.h +++ b/tests/test_2d_neighbor_regression.h @@ -10,12 +10,11 @@ // PIXELDISTANCE=1 -- the same recipe the oracle files use. SPEC 2 regression tier: a drift guard, // no oracle claim. // -// PERCENT_TOUCHING is the one feature in this family with no promotable oracle. Nyxus counts contour -// pixels 8-adjacent to a neighbour over contour length; CellProfiler counts object outline pixels -// overlapping a disk(distance+0.5)-dilated neighbour over perimeter, and no CP distance method -// reproduces Nyxus -- the two disagree on 3 of the 5 ROIs. That is a definition difference, not a -// defect, so the values are pinned rather than compared. The bounds the feature must obey regardless -// are asserted in test_2d_neighbor_invariant.h. +// Nyxus counts contour pixels 8-adjacent to a neighbour over contour length; CellProfiler counts +// object outline pixels overlapping a disk(distance+0.5)-dilated neighbour over perimeter. Each of +// the four outer ROIs here touches the centre along one side of its 2x2 or 2x3 border: 2 of its 4, +// or 3 of its 6, edge pixels, hence 50. The bounds the feature must obey regardless are asserted in +// test_2d_neighbor_invariant.h. // // The other eight features in the family are vetted at the exact tier against CellProfiler // (test_2d_neighbor_cellprofiler.h) and the analytic recomputation (test_2d_neighbor_analytic.h) on @@ -23,10 +22,10 @@ // value at a looser band adds no coverage. static const ref_vals_map_by_label neighbor_2d_regression_ref_vals_by_label{ {1, {{"PERCENT_TOUCHING", 100.0}}}, - {2, {{"PERCENT_TOUCHING", 66.666666666666671}}}, - {3, {{"PERCENT_TOUCHING", 66.666666666666671}}}, + {2, {{"PERCENT_TOUCHING", 50.0}}}, + {3, {{"PERCENT_TOUCHING", 50.0}}}, {4, {{"PERCENT_TOUCHING", 50.0}}}, - {5, {{"PERCENT_TOUCHING", 33.333333333333336}}} + {5, {{"PERCENT_TOUCHING", 50.0}}} }; static void assert_neighbor2d_regression( diff --git a/tests/test_2d_radial_mechanics.h b/tests/test_2d_radial_mechanics.h index bf4dd2996..b3e4a0244 100644 --- a/tests/test_2d_radial_mechanics.h +++ b/tests/test_2d_radial_mechanics.h @@ -1,17 +1,21 @@ #pragma once -// KNOWN-DEFECT CHARACTERIZATION for the 2D radial intensity distribution: the coordinate frame the +// CHARACTERIZATION of the 2D radial intensity distribution's plumbing: the coordinate frame the // traced contour comes back in, the centre pixel the feature measures from, and the radius it // normalises by. The first two cases assert that plumbing; the third asserts its effect on the -// feature outputs. The file is diagnostic known-defect characterization and claims no oracle. +// feature outputs; the last three cover the one- and two-pixel ROIs, whose radii are 0 and 1, and a +// ROI of two fragments, whose borders are searched one at a time. The file is diagnostic +// characterization and claims no oracle. // -// Read the numbers here as a record of what the tree does today, NOT as acceptance criteria. All -// three cases pin behaviour that -// tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md §6 argues is wrong - the contour's -// one-pixel offset (defect 1), the non-extremal centre (3,4) and short radius^2 10 (defect 2), and -// the num_bins-1 bin scaling (defect 3). A correction should invalidate the assertions tied to the -// defect it fixes; the corrective work and its preconditions are tracked in -// tests/vetting/audit/radial_2d_golden_regen.md §5, which is the fix branch's checklist. +// The first case and the last three are requirements: the contour is in the frame of the ROI's own +// pixels, the tiny ROIs land in the first and last bins with finite values, and the centre and +// radius of the two fragments are the ones exhaustive scans give. The second and third case +// pin behaviour that tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md §6 argues is wrong +// - the short radius^2 10 (defect 2) and the num_bins-1 bin scaling (defect 3) - so read their +// numbers as a record of what the tree does today, NOT as acceptance criteria. A correction should +// invalidate the assertions tied to the defect it fixes; the corrective work and its preconditions +// are tracked in tests/vetting/audit/radial_2d_golden_regen.md §5, which is the fix branch's +// checklist. // // The point of pinning them is that the 24 public values in test_2d_radial_regression.h are decided // entirely by these three pieces of wiring and nothing else in the tree looks at them, so without @@ -29,21 +33,18 @@ #include "test_2d_radial_common.h" // build_radial_2d_roi, LR, ; // for std::sqrt via test_main_nyxus.h -// Defect 1. ContourFeature traces on an image padded by one pixel on every side and adds the ROI's -// bounding-box origin back afterwards, but never subtracts that one-pixel pad. Every traced contour -// pixel is therefore reported one pixel right and one pixel down of where it is, which puts 7 of this -// ROI's 18 contour pixels outside the ROI altogether. RadialDistributionFeature measures distances -// from raw_pixels (untranslated) to this contour, so it mixes the two frames. -// The `outside == 7` below is the defect's current size, not a requirement: unpadding the contour -// takes it to 0 and fails this case, which is the intended outcome of fixing it. +// The contour is in the frame of the ROI's own pixels: RadialDistributionFeature measures distances +// from raw_pixels to the contour, so every contour pixel has to be one of them. Here they are the +// 18 edge pixels of the outer border and the border of the one-pixel hole. void test_2d_radial_contour_frame_mechanics() { LR roidata(101); build_radial_2d_roi(roidata); - std::vector K; - roidata.merge_multicontour(K); + const std::vector& K = roidata.contour_pixels_; ASSERT_EQ(roidata.raw_pixels.size(), (size_t)26); + ASSERT_EQ(roidata.multicontour_.size(), (size_t)2); + ASSERT_EQ(roidata.contour_border_ends_.size(), (size_t)2); ASSERT_EQ(K.size(), (size_t)18); auto in_roi = [&roidata](StatsInt x, StatsInt y) { @@ -53,46 +54,35 @@ void test_2d_radial_contour_frame_mechanics() return false; }; - size_t outside = 0, inside_after_unpadding = 0; for (const auto& c : K) - { - if (!in_roi(c.x, c.y)) - outside++; - if (in_roi(c.x - 1, c.y - 1)) - inside_after_unpadding++; - } - - // undoing the one-pixel pad puts every contour pixel back onto a ROI pixel - ASSERT_EQ(inside_after_unpadding, K.size()) - << "the contour is no longer uniformly offset by (+1,+1)"; - ASSERT_EQ(outside, (size_t)7) - << "known-defect pin (report §6 defect 1): a fix must change this number"; + ASSERT_TRUE(in_roi(c.x, c.y)) << "contour pixel (" << c.x << "," << c.y << ") is not a ROI pixel"; } -// Defect 2. Pixel2::find_center and Pixel2::max_sqdist are coarse-to-fine searches that assume an -// ordered contour; merge_multicontour concatenates the outer contour and the hole's, so the sequence -// they walk is not one. On this ROI both return a non-extremal answer, and the radius the feature -// normalises by is 23% short of the largest distance actually present. -// (3,4) and radius^2 10 below are the searches' current wrong answers; the exact scans in the same -// case compute (4,4) and 13. A fix makes the two halves agree and fails this case. +// Defect 2. Pixel2::find_center and Pixel2::max_sqdist search each border of the contour coarse to +// fine, and the coarse pass can step over a border's extreme. On this ROI the centre search lands +// on (2,3), the pixel the exact scan picks too, but the radius the feature normalises by is 23% +// short of the largest distance actually present: the outer border is a walk of 14 pixels, sampled +// every 5th from its first, and the farthest pixel, (4,6), lies between the samples. +// radius^2 10 below is the search's current wrong answer; the exact scan in the same case computes +// 13. A fix makes the two agree and fails this case. void test_2d_radial_center_and_radius_mechanics() { LR roidata(101); build_radial_2d_roi(roidata); - std::vector K; - roidata.merge_multicontour(K); + const std::vector& K = roidata.contour_pixels_; + const BorderedContour Kb(K, roidata.contour_border_ends_); - int idxO = Pixel2::find_center(roidata.raw_pixels, K); + int idxO = Pixel2::find_center(roidata.raw_pixels, Kb); const Pixel2& pxO = roidata.raw_pixels[idxO]; - ASSERT_EQ(pxO.x, 3) << "known-defect pin (report §6 defect 2): the exact scan below picks (4,4)"; - ASSERT_EQ(pxO.y, 4) << "known-defect pin (report §6 defect 2): the exact scan below picks (4,4)"; + ASSERT_EQ(pxO.x, 2) << "the exact scan below picks (2,3)"; + ASSERT_EQ(pxO.y, 3) << "the exact scan below picks (2,3)"; // the searched maximum against a full linear scan of the same contour double exact_max = 0.0; for (const auto& p : K) exact_max = std::max(exact_max, pxO.sqdist(p)); - ASSERT_DOUBLE_EQ(pxO.max_sqdist(K), 10.0) + ASSERT_DOUBLE_EQ(pxO.max_sqdist(Kb), 10.0) << "known-defect pin (report §6 defect 2): the exact scan of the same contour gives 13"; ASSERT_DOUBLE_EQ(exact_max, 13.0); @@ -115,25 +105,21 @@ void test_2d_radial_center_and_radius_mechanics() exact_idx = (int)i; } } - ASSERT_EQ(roidata.raw_pixels[exact_idx].x, 4); - ASSERT_EQ(roidata.raw_pixels[exact_idx].y, 4); + ASSERT_EQ(roidata.raw_pixels[exact_idx].x, 2); + ASSERT_EQ(roidata.raw_pixels[exact_idx].y, 3); } // Defect 3. The bin index is int(r / r_max * (num_bins - 1)), so the 8 bins are 7 equal-width rings // plus a last bin that only r >= r_max reaches. Here r_max is the short radius pinned above, so the -// last bin holds the 3 pixels sitting exactly on it plus the 4 that lie beyond it - 7 of the 26, more -// than any of the 7 real rings. Scaling by num_bins instead also places 3 pixels with -// 7/8 <= r/r_max < 1 in the last bin, changing its occupancy from 7 to 10; the at/past-r_max counts -// themselves remain 3 and 4. +// last bin holds the 3 pixels sitting exactly on it plus the 1 that lies beyond it - 4 of the 26. void test_2d_radial_bin_index_mechanics() { LR roidata(101); build_radial_2d_roi(roidata); - std::vector K; - roidata.merge_multicontour(K); - const Pixel2& pxO = roidata.raw_pixels[Pixel2::find_center(roidata.raw_pixels, K)]; - const double r_max = std::sqrt(pxO.max_sqdist(K)); + const BorderedContour Kb(roidata.contour_pixels_, roidata.contour_border_ends_); + const Pixel2& pxO = roidata.raw_pixels[Pixel2::find_center(roidata.raw_pixels, Kb)]; + const double r_max = std::sqrt(pxO.max_sqdist(Kb)); size_t at_r_max = 0, past_r_max = 0; for (const auto& p : roidata.raw_pixels) @@ -144,7 +130,7 @@ void test_2d_radial_bin_index_mechanics() else if (rat == 1.0) at_r_max++; } - ASSERT_EQ(past_r_max, (size_t)4) + ASSERT_EQ(past_r_max, (size_t)1) << "known-defect pin (report §6 defects 2-3): a correct radius leaves no pixel past r_max"; ASSERT_EQ(at_r_max, (size_t)3) << "known-defect pin (report §6 defects 2-3)"; @@ -158,3 +144,142 @@ void test_2d_radial_bin_index_mechanics() ASSERT_GT(mean.back(), 0.0) << "MEAN_FRAC last bin is fed by the clamped pixels"; ASSERT_GT(cv.back(), 0.0) << "RADIAL_CV last bin is fed by the clamped pixels"; } + +// A one-pixel ROI is its own contour, so the radius the feature normalises by is 0. The pixel is the +// centre, at distance 0, and lands in the first bin: FRAC_AT_D is 1 there and 0 elsewhere, and every +// value is finite. +void test_2d_radial_single_pixel_roi_mechanics() +{ + const NyxusPixel px[] = { { 5, 7, 42 } }; + LR roidata(102); + load_masked_test_roi_data(roidata, px, px, 1); + roidata.initialize_fvals(); + + Fsettings s = make_shape2d_settings(); + ContourFeature contour; + contour.calculate(roidata, s); + contour.save_value(roidata.fvals); + ASSERT_EQ(roidata.multicontour_.size(), (size_t)1); + ASSERT_EQ(roidata.multicontour_[0].size(), (size_t)1); + + RadialDistributionFeature radial; + radial.calculate(roidata, s); + radial.save_value(roidata.fvals); + + const auto& frac = roidata.fvals[(int)Nyxus::Feature2D::FRAC_AT_D]; + const auto& mean = roidata.fvals[(int)Nyxus::Feature2D::MEAN_FRAC]; + const auto& cv = roidata.fvals[(int)Nyxus::Feature2D::RADIAL_CV]; + ASSERT_EQ(frac.size(), (size_t)RadialDistributionFeature::num_bins); + for (size_t i = 0; i < frac.size(); i++) + { + ASSERT_TRUE(std::isfinite(frac[i]) && std::isfinite(mean[i]) && std::isfinite(cv[i])) << "bin " << i; + ASSERT_NEAR(frac[i], i == 0 ? 1.0 : 0.0, 1e-6) << "FRAC_AT_D[" << i << "]"; + } + ASSERT_NEAR(mean[0], 42.0, 1e-6); +} + +// A two-pixel ROI: both pixels are the contour, a border of two points. Each pixel is 1 from the +// other, so whichever is the centre, the radius is 1, not 0: the centre lands in the first bin and +// the other pixel, at r == r_max, in the last. +void test_2d_radial_two_pixel_roi_mechanics() +{ + const NyxusPixel px[] = { { 5, 7, 40 }, { 6, 7, 60 } }; + LR roidata(103); + load_masked_test_roi_data(roidata, px, px, 2); + roidata.initialize_fvals(); + + Fsettings s = make_shape2d_settings(); + ContourFeature contour; + contour.calculate(roidata, s); + contour.save_value(roidata.fvals); + ASSERT_EQ(roidata.multicontour_.size(), (size_t)1); + + RadialDistributionFeature radial; + radial.calculate(roidata, s); + radial.save_value(roidata.fvals); + + const auto& frac = roidata.fvals[(int)Nyxus::Feature2D::FRAC_AT_D]; + const auto& mean = roidata.fvals[(int)Nyxus::Feature2D::MEAN_FRAC]; + const int n = RadialDistributionFeature::num_bins; + ASSERT_EQ(frac.size(), (size_t)n); + for (int i = 0; i < n; i++) + ASSERT_NEAR(frac[i], i == 0 || i == n - 1 ? 0.5 : 0.0, 1e-6) << "FRAC_AT_D[" << i << "]"; + ASSERT_NEAR(mean[0] + mean[n - 1], 100.0, 1e-6) << "one pixel in each of the two bins"; + ASSERT_NEAR(std::abs(mean[0] - mean[n - 1]), 20.0, 1e-6); +} + +// Two 3x3 squares 4 pixels apart under one label: two borders of 8 pixels each, the outer borders of +// the two fragments. Pixel2::find_center and Pixel2::max_sqdist search every border on its own, and +// a border of up to 10 pixels is scanned whole, so here both return exactly what exhaustive scans of +// the contour give: the centre (3,2), the middle of the left square's right column, and the squared +// radius 50, to the far corners of the right square. All 8 FRAC_AT_D entries are rebuilt from that +// centre and radius. Searched as one 16-pixel sequence, the contour gives the centre (8,1) and 49. +void test_2d_radial_two_fragments_mechanics() +{ + std::vector px; + for (StatsInt y = 1; y <= 3; y++) + for (StatsInt x : { 1, 2, 3, 8, 9, 10 }) + px.push_back({ (size_t)x, (size_t)y, (unsigned int)(10 + x + 3 * y) }); + LR roidata(104); + load_masked_test_roi_data(roidata, px.data(), px.data(), px.size()); + roidata.initialize_fvals(); + + Fsettings s = make_shape2d_settings(); + ContourFeature contour; + contour.calculate(roidata, s); + contour.save_value(roidata.fvals); + ASSERT_EQ(roidata.multicontour_.size(), (size_t)2); + ASSERT_EQ(roidata.contour_border_ends_, (std::vector{ 8, 16 })); + + RadialDistributionFeature radial; + radial.calculate(roidata, s); + radial.save_value(roidata.fvals); + + // the centre by the feature's own criterion, the least (max - min) squared distance to the + // contour, by exhaustive scans; the first such pixel wins a tie, as it does in the feature + const auto& K = roidata.contour_pixels_; + const auto& cloud = roidata.raw_pixels; + size_t idxO = 0; + double best = -1.0; + for (size_t i = 0; i < cloud.size(); i++) + { + double mn = -1.0, mx = 0.0; + for (const auto& p : K) + { + const double d = cloud[i].sqdist(p); + mx = std::max(mx, d); + mn = (mn < 0.0 || d < mn) ? d : mn; + } + if (best < 0.0 || mx - mn < best) + { + best = mx - mn; + idxO = i; + } + } + const Pixel2& pxO = cloud[idxO]; + double r_max2 = 0.0; + for (const auto& p : K) + r_max2 = std::max(r_max2, pxO.sqdist(p)); + ASSERT_EQ(pxO.x, 3); + ASSERT_EQ(pxO.y, 2); + ASSERT_EQ(r_max2, 50.0); + + // the searches the feature runs + const BorderedContour Kb(K, roidata.contour_border_ends_); + const Pixel2& pxS = cloud[Pixel2::find_center(cloud, Kb)]; + ASSERT_EQ(pxS.x, 3); + ASSERT_EQ(pxS.y, 2); + ASSERT_EQ(pxS.max_sqdist(Kb), 50.0); + + const int n = RadialDistributionFeature::num_bins; + std::vector counts(n, 0.0); + for (const auto& p : cloud) + { + int bi = int(std::sqrt(p.sqdist(pxO)) / std::sqrt(r_max2) * (n - 1)); + counts[std::min(bi, n - 1)] += 1.0; + } + const auto& frac = roidata.fvals[(int)Nyxus::Feature2D::FRAC_AT_D]; + ASSERT_EQ(frac.size(), (size_t)n); + for (int i = 0; i < n; i++) + ASSERT_NEAR(frac[i] * double(cloud.size()), counts[i], 1e-6) << "FRAC_AT_D[" << i << "]"; +} diff --git a/tests/test_2d_radial_regression.h b/tests/test_2d_radial_regression.h index 36b3cd8d6..f9ec560d6 100644 --- a/tests/test_2d_radial_regression.h +++ b/tests/test_2d_radial_regression.h @@ -34,15 +34,15 @@ static const ref_vals_map> radial_2d_regression_ref_vals{ {"FRAC_AT_D", { 0.038461538460059175, 0.0, 0.11538461538017751, 0.1538461538402367, - 0.3076923076804734, 0.0, 0.11538461538017751, 0.26923076922041422, + 0.38461538460059169, 0.0, 0.1538461538402367, 0.1538461538402367, }}, {"MEAN_FRAC", { - 50.999999948999999, 0.0, 53.333333315555556, 50.749999987312499, - 47.374999994078124, 0.0, 33.666666655444445, 21.999999996857142, + 39.999999959999997, 0.0, 39.666666653444445, 41.249999989687502, + 38.199999996179997, 0.0, 37.999999990500001, 47.499999988124998, }}, {"RADIAL_CV", { - 2.6457513106495707, 0.0, 1.298797520721114, 1.024429214739045, - 0.64750329537582818, 0.0, 1.3575192606324717, 1.3284260624865412, + 2.6457513105354402, 0.0, 1.3089567218937439, 1.0287867630146279, + 0.60432094511523338, 0.0, 1.2454904805869986, 1.8152630815185364, }}, }; diff --git a/tests/test_all.cc b/tests/test_all.cc index c5f11b7b6..8438fc938 100644 --- a/tests/test_all.cc +++ b/tests/test_all.cc @@ -17,6 +17,9 @@ #include "../src/nyx/ome/format_detect.h" // detect_container_family #include "../src/nyx/raw_image_loader.h" // RawImageLoader, the prescan-side dispatch #include "test_2d_contour_analytic.h" +#include "test_2d_contour_invariant.h" +#include "test_2d_contour_mechanics.h" +#include "test_2d_contour_opencv.h" #include "test_ome_meta_mechanics.h" // native-OME metadata parsers / OmeAxes descriptor #include "test_ometiff_mechanics.h" // OME-TIFF native (z,c,t)->IFD read (core; no USE_Z5) #include "test_2d_firstorder_common.h" @@ -45,6 +48,8 @@ #include "test_2d_morphology_mechanics.h" #include "test_2d_moments_skimage.h" #include "test_2d_moments_regression.h" +#include "test_2d_moments_invariant.h" +#include "test_2d_moments_mechanics.h" #include "test_2d_zernike_analytic.h" #include "test_2d_zernike_invariant.h" #include "test_2d_zernike_mechanics.h" @@ -163,6 +168,74 @@ TEST(TEST_NYXUS, TEST_2D_CONTOUR_MULTI_CONNECTED_ANALYTIC) { ASSERT_NO_THROW(test_2d_contour_multi_connected_analytic()); } +TEST(TEST_NYXUS, TEST_2D_CONTOUR_SQUARE_ANALYTIC) { + ASSERT_NO_THROW(test_2d_contour_square_analytic()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_DISK_ANALYTIC) { + ASSERT_NO_THROW(test_2d_contour_disk_analytic()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_RING_ANALYTIC) { + ASSERT_NO_THROW(test_2d_contour_ring_analytic()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_TWO_FRAGMENTS_ANALYTIC) { + ASSERT_NO_THROW(test_2d_contour_two_fragments_analytic()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_LINE_ANALYTIC) { + ASSERT_NO_THROW(test_2d_contour_line_analytic()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_SINGLE_PIXEL_ANALYTIC) { + ASSERT_NO_THROW(test_2d_contour_single_pixel_analytic()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_NOISY_ROIS_BOUNDARY_RULE_INVARIANT) { + ASSERT_NO_THROW(test_2d_contour_noisy_rois_boundary_rule_invariant()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_BLOB97_BOUNDARY_RULE_INVARIANT) { + ASSERT_NO_THROW(test_2d_contour_blob97_boundary_rule_invariant()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_DISTANCE_SEARCH_SHORT_WALK_INVARIANT) { + ASSERT_NO_THROW(test_2d_contour_distance_search_short_walk_invariant()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_DISTANCE_SEARCH_BORDERS_INVARIANT) { + ASSERT_NO_THROW(test_2d_contour_distance_search_borders_invariant()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_BORDERED_CONTOUR_BINDS_LVALUES_MECHANICS) { + ASSERT_NO_THROW(test_2d_contour_bordered_contour_binds_lvalues_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_BORDERED_CONTOUR_LAYOUT_MECHANICS) { + ASSERT_NO_THROW(test_2d_contour_bordered_contour_layout_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_MERGE_SKIPS_COVERED_BORDER_MECHANICS) { + ASSERT_NO_THROW(test_2d_contour_merge_skips_covered_border_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_BORDERS_ROW_RUNS_BOUND_MECHANICS) { + ASSERT_NO_THROW(test_2d_contour_borders_row_runs_bound_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_PRESCAN_ROW_RUNS_MECHANICS) { + ASSERT_NO_THROW(test_2d_contour_prescan_row_runs_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_BLOB97_OPENCV) { + ASSERT_NO_THROW(test_2d_contour_blob97_opencv()); +} + +TEST(TEST_NYXUS, TEST_2D_CONTOUR_SHAPE2D_OPENCV) { + ASSERT_NO_THROW(test_2d_contour_shape2d_opencv()); +} + //***** first-order compatibility ***** TEST(TEST_NYXUS, TEST_3D_FIRSTORDER_P10_PYRADIOMICS) { @@ -2145,6 +2218,86 @@ TEST(TEST_NYXUS, TEST_2D_MOMENTS_INTENSITY_REGRESSION) ASSERT_NO_THROW(test_2d_moments_intensity_regression()); } +TEST(TEST_NYXUS, TEST_2D_MOMENTS_WEIGHTED_DISK_COPIES_INVARIANT) +{ + ASSERT_NO_THROW(test_2d_moments_weighted_disk_copies_invariant()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_WHOLESLIDE_THIN_SLIDE_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_wholeslide_thin_slide_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_CACHE_CLEAR_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_cache_clear_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_BORDER_UPLOAD_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_border_upload_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_CPU_PARITY_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_cpu_parity_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_CACHE_SIZES_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_cache_sizes_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_ANISOTROPIC_BATCH_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_anisotropic_batch_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_CACHE_REALLOCATE_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_cache_reallocate_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_UPLOAD_AFTER_FREE_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_upload_after_free_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_CACHE_FAMILIES_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_cache_families_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_CACHE_REGROW_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_cache_regrow_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_CACHE_DROPS_FAMILIES_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_cache_drops_families_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_CACHE_ALLOC_CLEAR_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_cache_alloc_clear_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MOMENTS_GPU_UNREADY_CACHE_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_moments_gpu_unready_cache_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_GPU_EROSION_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_morphology_gpu_erosion_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_GABOR_GPU_UNREADY_CACHE_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_gabor_gpu_unready_cache_mechanics()); +} + TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_EROSIONS_REGRESSION) { ASSERT_NO_THROW(test_2d_morphology_erosions_regression()); @@ -2255,6 +2408,21 @@ TEST(TEST_NYXUS, TEST_2D_RADIAL_BIN_INDEX_MECHANICS) ASSERT_NO_THROW(test_2d_radial_bin_index_mechanics()); } +TEST(TEST_NYXUS, TEST_2D_RADIAL_SINGLE_PIXEL_ROI_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_radial_single_pixel_roi_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_RADIAL_TWO_PIXEL_ROI_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_radial_two_pixel_roi_mechanics()); +} + +TEST(TEST_NYXUS, TEST_2D_RADIAL_TWO_FRAGMENTS_MECHANICS) +{ + ASSERT_NO_THROW(test_2d_radial_two_fragments_mechanics()); +} + TEST(TEST_NYXUS, TEST_2D_ZERNIKE_MOMENTS_REGRESSION) { ASSERT_NO_THROW(test_2d_zernike_moments_regression()); diff --git a/tests/test_main_nyxus.h b/tests/test_main_nyxus.h index ac02c2274..6f628d87e 100644 --- a/tests/test_main_nyxus.h +++ b/tests/test_main_nyxus.h @@ -212,3 +212,161 @@ inline void write_tiled_plane_u16 (const fs::path& f, uint32_t W, uint32_t H, ui ASSERT_EQ(TIFFWriteDirectory (t), 1) << f.string(); TIFFClose (t); } + +#ifdef USE_GPU +#include "../src/nyx/environment.h" +#include "../src/nyx/features/contour.h" + +// GPU-cache fixtures. They live here rather than in a family header because the moments, +// morphology (erosion) and Gabor families each check their own GPU path through them (SPEC 6.3.1). + +// A GPU cache of its own, allocated the way the segmented workflow allocates GpusideCache: contour +// and moments buffers unless told otherwise, erosion and Gabor (4 filters of 16 x 16 taps) if asked. +// Freed when it goes out of scope. +struct GpuCacheTestRig +{ + GpusideCache g; + + // 'borders': the most contour borders the batch can have, the plan's bound on border ends + std::optional allocate (size_t n_rois, size_t roi_area, size_t roi_w, size_t roi_h, size_t borders, + bool erosion = false, bool gabor = false, bool contour_and_moments = true) + { + return g.allocate_gpu_cache( + g.gpu_roiclouds_2d, g.gpu_roicontours_2d, g.gpu_roicontourborders_2d, &g.dev_realintens, &g.dev_prereduce, + g.gpu_featurestatebuf, g.devicereduce_temp_storage_szb, &g.dev_devicereduce_temp_storage, g.gpu_batch_len, + &g.dev_imat1, &g.dev_imat2, g.gabor_linear_image, g.gabor_result, g.gabor_linear_kernel, g.gabor_energy_image, + contour_and_moments, erosion, gabor, contour_and_moments, + roi_area * n_rois, roi_area * n_rois, borders, n_rois, roi_area, roi_w, roi_h, 4, 16); + } + + bool free() + { + return g.free_gpu_cache(g.gpu_roiclouds_2d, g.gpu_roicontours_2d, g.gpu_roicontourborders_2d, g.dev_realintens, g.dev_prereduce, + g.gpu_featurestatebuf, g.dev_devicereduce_temp_storage, g.dev_imat1, g.dev_imat2, + g.gabor_linear_image, g.gabor_result, g.gabor_linear_kernel, g.gabor_energy_image); + } + + // sends ROIs batch_offset .. batch_offset + batch_len - 1 of 'labels' + void send (const std::vector& labels, std::unordered_map& rois, size_t batch_offset, size_t batch_len) + { + g.send_roi_batch_data_2_gpu(g.gpu_roiclouds_2d, g.gpu_roicontours_2d, g.gpu_roicontourborders_2d, &g.dev_realintens, + &g.dev_prereduce, g.gpu_featurestatebuf, g.devicereduce_temp_storage_szb, &g.dev_devicereduce_temp_storage, + labels, rois, batch_offset, batch_len); + } + + ~GpuCacheTestRig() { free(); } +}; + +// ROI 'label' over the pixels of 'rows' ('1' = ROI) placed at (x0, y0), with a non-constant intensity, +// its contour traced: what the GPU upload reads +static LR& gpu_test_roi (std::unordered_map& rois, int label, const std::vector& rows, int x0, int y0) +{ + LR& r = rois[label]; + r.label = label; + for (int y = 0; y < (int)rows.size(); y++) + for (int x = 0; x < (int)rows[y].size(); x++) + { + if (rows[y][x] != '1') + continue; + const int ax = x0 + x, ay = y0 + y; + const auto intensity = static_cast(10 + 3 * ax + 5 * ay + (ax * ay) % 7); + if (r.aux_area == 0) + Nyxus::init_label_record_3(r, ax, ay, intensity); + else + Nyxus::update_label_record_3(r, ax, ay, intensity); + r.raw_pixels.push_back(Pixel2(ax, ay, intensity)); + } + r.make_nonanisotropic_aabb(); + r.aux_image_matrix.allocate(r.aabb.get_width(), r.aabb.get_height()); + r.aux_image_matrix.calculate_from_pixelcloud(r.raw_pixels, r.aabb); + r.initialize_fvals(); + ContourFeature contour; + contour.calculate(r, make_shape2d_settings()); + return r; +} + +// a filled disk of radius 'r' as mask rows +static std::vector gpu_test_disk_rows (int r) +{ + std::vector rows (2 * r + 1, std::string (2 * r + 1, '.')); + for (int y = -r; y <= r; y++) + for (int x = -r; x <= r; x++) + if (x * x + y * y <= r * r) + rows[y + r][x + r] = '1'; + return rows; +} + +// Why a GPU cache is not ready: nothing allocated it, as on the 2.5D, 3D and array paths; an +// allocation failed part way, after every family but Gabor, and freed what it had allocated; or a free +// failed part way, one of its device frees refused. +enum class GpuUnready { never_allocated, allocation_failed_part_way, free_failed_part_way }; + +static const char* gpu_unready_name (GpuUnready why) +{ + switch (why) + { + case GpuUnready::never_allocated: return "never allocated"; + case GpuUnready::allocation_failed_part_way: return "an allocation that failed part way and freed what it had allocated"; + default: return "a free that failed part way"; + } +} + +// The values reduce_trivial_rois_manual gives a disk of radius 5 with 'features' enabled, the GPU on +// and its cache not ready for the reason 'why', against those it gives with the GPU off. Callers wrap +// it in ASSERT_NO_FATAL_FAILURE. +static void gpu_unready_vs_cpu (const std::initializer_list& features, GpuUnready why, + std::vector>& gpu_on, std::vector>& gpu_off) +{ + for (bool on : { true, false }) + { + Environment e; + e.set_dim (2); + e.theFeatureSet.enableAll (false); + e.theFeatureSet.enableFeatures (features, true); + ASSERT_TRUE(e.theFeatureMgr.compile()); + e.theFeatureMgr.apply_user_selection (e.theFeatureSet); + ASSERT_TRUE(e.theFeatureMgr.init_feature_classes()); + e.compile_feature_settings(); + e.set_using_gpu (on); + GpusideCache& g = e.devCache; + auto allocate = [&g] () + { + return g.allocate_gpu_cache( + g.gpu_roiclouds_2d, g.gpu_roicontours_2d, g.gpu_roicontourborders_2d, &g.dev_realintens, &g.dev_prereduce, + g.gpu_featurestatebuf, g.devicereduce_temp_storage_szb, &g.dev_devicereduce_temp_storage, g.gpu_batch_len, + &g.dev_imat1, &g.dev_imat2, g.gabor_linear_image, g.gabor_result, g.gabor_linear_kernel, g.gabor_energy_image, + true, true, true, true, 121, 121, 22, 1, 121, 11, 11, 4, 16); + }; + if (on && why == GpuUnready::allocation_failed_part_way) + { + struct Reset { ~Reset() { GpusideCache::test_fail_before_gabor = false; } } reset; + GpusideCache::test_fail_before_gabor = true; + ASSERT_TRUE(allocate().has_value()); + ASSERT_FALSE(g.is_ready()); + ASSERT_EQ(g.gpu_roiclouds_2d.hobuffer, nullptr); + ASSERT_EQ(g.dev_realintens, nullptr); + ASSERT_EQ(g.dev_imat2, nullptr); + } + if (on && why == GpuUnready::free_failed_part_way) + { + ASSERT_FALSE(allocate().has_value()); + ASSERT_TRUE(g.is_ready()); + // a device pointer cudaFree refuses, in place of one of the Gabor buffers + ASSERT_TRUE(GpusideCache::gpu_delete (g.gabor_result.devbuffer)); + static cufftDoubleComplex not_on_device; + g.gabor_result.devbuffer = ¬_on_device; + ASSERT_FALSE(g.free_gpu_cache(g.gpu_roiclouds_2d, g.gpu_roicontours_2d, g.gpu_roicontourborders_2d, g.dev_realintens, + g.dev_prereduce, g.gpu_featurestatebuf, g.dev_devicereduce_temp_storage, g.dev_imat1, g.dev_imat2, + g.gabor_linear_image, g.gabor_result, g.gabor_linear_kernel, g.gabor_energy_image)); + cudaGetLastError(); // the refused free is CUDA's last error, which a later CUB call would report + ASSERT_FALSE(g.is_ready()); + ASSERT_EQ(g.gabor_result.devbuffer, nullptr); + ASSERT_EQ(g.gabor_energy_image.devbuffer, nullptr); + } + gpu_test_roi (e.roiData, 1, gpu_test_disk_rows (5), 2, 2); + std::vector labels { 1 }; + ASSERT_NO_THROW(Nyxus::reduce_trivial_rois_manual (labels, e)); + (on ? gpu_on : gpu_off) = e.roiData[1].fvals; + } +} +#endif diff --git a/tests/vetting/TOOLS.md b/tests/vetting/TOOLS.md index 3531c9d8b..4a129c215 100644 --- a/tests/vetting/TOOLS.md +++ b/tests/vetting/TOOLS.md @@ -21,7 +21,7 @@ research pass per tool; see per-tool detail below and the setup matrix first. | `matlab` | R2026a | **licensed install** with Image Processing Toolbox 26.1 | high (offline) | native first-order and morphology oracle generators; never a CI dependency | | `octave` | 10.3.0 / 11.3.0 | **conda** `conda install -c conda-forge octave octave-statistics` (no Docker, no license) | high | license-free MATLAB substitute (MIGRATION 5.13); `mean`/`median`/`std`/`var`/`skewness`/`kurtosis`/`prctile`/`quantile` all present. Add `octave-image` for `regionprops`/`bweuler` (2D morphology) | | `skimage` | 0.26.0 | **conda** — already present in `nyxus_mirp` (mirp pulls scikit-image, scipy, numpy) | high | no separate env needed; `gabor_kernel` + `regionprops` + `moments*`; see the skimage note below | -| `opencv` | 4.13.0 | **conda** — already present in `nyxus_mirp` (`opencv-python`) | high | no separate env needed; `cv2.Laplacian` (IMQ focus scores) and `cv2.medianBlur`; see the opencv note below | +| `opencv` | 4.13.0 | **conda** — already present in `nyxus_mirp` (`opencv-python`) | high | no separate env needed; `cv2.Laplacian` (IMQ focus scores), `cv2.medianBlur`, and `cv2.findContours` (2D contour borders and `PERIMETER`, `gen_contour_opencv.py`); see the opencv note below | ## conda route (verified 2026-08, 2D GLCM vetting) diff --git a/tests/vetting/audit/moments_2d_skimage_vetting_report.md b/tests/vetting/audit/moments_2d_skimage_vetting_report.md index 207424894..af8970d54 100644 --- a/tests/vetting/audit/moments_2d_skimage_vetting_report.md +++ b/tests/vetting/audit/moments_2d_skimage_vetting_report.md @@ -79,8 +79,8 @@ the outermost ring, so the exact Euclidean distance from a pixel to the contour | | `WEIGHTED_SPAT_MOMENT_00` | |---|---| | exact distance to contour | 1821.28398639964 | -| Nyxus (pinned) | 2498.7137504625134 | -| ratio | **1.372x**, i.e. a 37.2% overestimate | +| Nyxus (pinned) | 1895.0047386658844 | +| ratio | **1.040x**, i.e. a 4.0% overestimate | These values therefore match no external tool by construction, which is why they are pinned as drift guards and claim no oracle. **Fixing it is deliberately out of scope here**: it changes 62 published diff --git a/tests/vetting/audit/scan_morphology_coverage.py b/tests/vetting/audit/scan_morphology_coverage.py index e35673a76..473fd6d95 100644 --- a/tests/vetting/audit/scan_morphology_coverage.py +++ b/tests/vetting/audit/scan_morphology_coverage.py @@ -20,11 +20,15 @@ "test_2d_morphology_fraclac.h", "test_2d_morphology_analytic.h", "test_2d_morphology_regression.h", + # the contour tests assert PERIMETER and EDGE_INTEGRATED_INTENSITY on the traced borders + "test_2d_contour_analytic.h", + "test_2d_contour_invariant.h", + "test_2d_contour_opencv.h", os.path.join("python", "test_2d_morphology_fraclac.py"), os.path.join("python", "test_2d_morphology_hull_invariant.py"), os.path.join("python", "test_2d_morphology_invariant.py"), # the registry names this file for PERIMETER, DIAMETER_EQUAL_PERIMETER and EDGE_MEAN_INTENSITY; - # it asserts they are 0 on a contourless speckle mask, which is edge-case coverage, not vetting + # it asserts their values on a mask of isolated speckles, which is edge-case coverage, not vetting os.path.join("python", "test_nyxus.py"), # added with the config-matrix cells: the whole-slide snapshot and the two out-of-core # assertions cover contour features, so the scanner has to see them or their rows read as @@ -57,8 +61,11 @@ def note(feature, cov): dim="2D", family="morphology", sources=SOURCES, oracle_suffix={"matlab": "matlab", "skimage": "skimage", "imea": "imea", - "cellprofiler": "cellprofiler", "fraclac": "fraclac", "analytic": "analytic"}, + "cellprofiler": "cellprofiler", "fraclac": "fraclac", "analytic": "analytic", + "opencv": "opencv"}, notes=note, + # the contour tests are registered under their own family stem + fn_prefix=r"test_2d_(?:morphology|contour)", scan_helpers=True, # test_2d_ooc_invariant.py names the six ellipse features in a local tuple and EROSIONS_2_VANISH # in the comprehension that builds the compared column list, then loops that list while diff --git a/tests/vetting/benchmarks.md b/tests/vetting/benchmarks.md index 819f5b74a..c706481c4 100644 --- a/tests/vetting/benchmarks.md +++ b/tests/vetting/benchmarks.md @@ -354,9 +354,10 @@ Tests reaching it today: `test_2d_radial_{regression,invariant,mechanics}.h` (th `test_2d_radial_common.h`), `test_2d_morphology_common.h` and the morphology files that include it, `test_2d_zernike_{analytic,regression,invariant,mechanics}.h` (through `test_2d_zernike_common.h`). -**The interior hole is the load-bearing property, and it is also what limits this fixture.** It is -what makes `LR::merge_multicontour` concatenate two contours rather than return one, which is the -condition under which `Pixel2::find_center` and `Pixel2::max_sqdist` return non-extremal answers +**The interior hole is the load-bearing property, and it is also what limits this fixture.** It +gives the ROI two borders, the outer one and the hole's, which every contour consumer has to take +apart rather than read as one walk. The concave outer border is also where `Pixel2::max_sqdist` +returns a non-extremal answer, 10 where the largest squared distance is 13 (`audit/radial_2d_cellprofiler_vetting_report.md` §6 defect 2, pinned in `test_2d_radial_mechanics.h`). For the same reason it is **not** a fixture the radial family can ever be vetted on: its distance-to-edge maximum is attained by 8 of its 26 pixels, so CellProfiler's own @@ -469,3 +470,23 @@ definition rather than of accumulation, and it is why `matrix/morphology.md` mar **It is built rather than added to `test_data.h`** because it is a one-line closed form a reader can evaluate by hand, and because a 4096-pixel literal table would be far less checkable than the two lines that generate it. + +--- + +## `bench_blob97_filled` — one filled 9066-pixel blob spanning a 97×97 frame + +| | | +|---|---| +| Data | `bench_blob97_filled_rows` in `tests/test_2d_contour_common.h`, 97 text rows (`1` = ROI), placed at (113, 41) by `contour_mask_pixels()` | +| ROI | one — **9066 pixels**, one 8-connected component, no holes; **495** of them are edge pixels under the 4-neighbour inner boundary | +| Shape | a random filled blob whose ragged boundary meets the frame on all four sides, with notches up to 13 pixels deep | +| Why it exists | 79 of its 495 edge pixels have three or more edge pixels among their 8 neighbours, which is where reconstructing a border's order from an unordered set of edge pixels goes wrong; a border follower has to keep all 495 | + +Recipes: `contour.opencv_borders`. + +Tests reaching it today: `test_2d_contour_invariant.h` +(`test_2d_contour_blob97_boundary_rule_invariant`) and `test_2d_contour_opencv.h` +(`test_2d_contour_blob97_opencv`). + +**It is a literal table rather than a closed form** because nothing about it is regular: it is the +mask the defect was first measured on, kept as found. diff --git a/tests/vetting/config_recipes.md b/tests/vetting/config_recipes.md index 0214fda69..be91d1772 100644 --- a/tests/vetting/config_recipes.md +++ b/tests/vetting/config_recipes.md @@ -285,6 +285,30 @@ oracle for the Nyxus-original features); it is not built in this tree, and the g agree to 3.8e-15. Note `nnz(bwperim(...))` counts perimeter *pixels* (846 here) and MATLAB's `regionprops('Perimeter')` returns 952.848 - neither is this quantity. +## contour.closed_forms +- ROIs built from pixel sets by `contour_roi_from_pixels()` (`test_2d_contour_common.h`) and placed + away from the image origin, at `make_shape2d_settings()`: a 10x10 square, a disk R=20, a 12x12 + square with a centred 4x4 hole, two separate squares 6x6 and 8x8 under one label, a one-pixel-wide + line of 5 and a single pixel. Oracle: `analytic`. Used by: `test_2d_contour_analytic.h`. +- Each has a closed form for its borders and PERIMETER: one border of 4(n-1) unit steps for the + square; the disk pixels with a 4-neighbour outside it, each at a squared distance in ((R-1)^2, R^2] + from the centre; 44 + 12 + 4*sqrt(2) for the ring, the hole's border taking one diagonal step at + each corner; 4*5 + 4*7 = 48 for the two squares, with no step between them; 2(n-1) for the line, + whose border goes out and back; 0 for the pixel. Placed off the origin so a contour in any frame + but the ROI's own absolute pixel coordinates cannot match. + +## contour.opencv_borders +- `bench_blob97_filled` at `make_shape2d_settings()`, and the `shape2d_morphology_{mask,intensity}` + fixture under `morphology.shape2d_native`. Oracle: `opencv` + `cv2.findContours(mask, RETR_LIST, CHAIN_APPROX_NONE)` on the mask padded by one background pixel. + Used by: `test_2d_contour_opencv.h`. Generator: `oracles/gen_contour_opencv.py`, which reads both + fixtures from their headers. +- RETR_LIST returns every border, holes included, as the closed walk Suzuki and Abe's border + following produces; Nyxus traces the same borders the same way, so the number of borders, the set + of pixels on them and PERIMETER (each walk's closed length, summed) agree exactly. The reference + length is accumulated in double from OpenCV's integer points, because `cv2.arcLength` sums in + float32. + ## morphology.radius_disks - Three filled digital disks, R = 10, 20 and 40, built by `calculate_disk_radius_values()` (`test_2d_morphology_common.h`) at `make_shape2d_settings()` (`test_main_nyxus.h`): diff --git a/tests/vetting/oracle_coverage.csv b/tests/vetting/oracle_coverage.csv index 9e97f5ec5..d9a5abaac 100644 --- a/tests/vetting/oracle_coverage.csv +++ b/tests/vetting/oracle_coverage.csv @@ -84,13 +84,13 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,ECCENTRICITY,morphology,vetted,matlab,agreed,morphology.shape2d_native,rel=1e-3,test_2d_morphology_matlab.h;test_2d_morphology_regression.h;test_2d_ooc_invariant.py,test_2d_morphology_matlab.h,,,in-tree,MATLAB R2026a + Image Processing Toolbox 26.1; gen_morphology_matlab.m calls regionprops/bweuler on the morphology.shape2d_native fixture from PolusAI/nyxus main and verifies all 33 C++ pins; max rel difference from the prior Octave literals is 3.40e-16.,TEST_NYXUS.TEST_2D_MORPHOLOGY_ELLIPSE_MATLAB,bench_shape8_concave_holed 2D,ORIENTATION,morphology,vetted,skimage,agreed,morphology.shape2d_native,,test_2d_morphology_skimage.h;test_2d_ooc_invariant.py,,,,audit,Vetted vs scikit-image regionprops: NYXUS == 90 - degrees(skimage.orientation) = 70.4174 (row-axis vs x-axis convention); matches to 10 decimals because the ellipse angle is invariant to the pixel-size second-moment correction that makes MAJOR/MINOR/ECCENTRICITY differ ~1.4%. gen_morphology_skimage.py,, 2D,ROUNDNESS,morphology,vetted,analytic,agreed,morphology.shape2d_native,,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_ooc_invariant.py,,,no-external-oracle,audit,"Tier-2 documented-formula conformance, NO external oracle. Nyxus computes ROUNDNESS = 4*A/(pi*major^2) (ellipse_fitting.cpp) with A = ROI pixel count and major = moment-fit MAJOR_AXIS_LENGTH. TEST_2D_MORPHOLOGY_DOCUMENTED_FORMULA_CONFORMANCE_ANALYTIC (test_2d_morphology_analytic.h) recomputes the formula and requires an exact match (1e-9). Implementation bug-free, but the value depends on Nyxus' pixel-area + moment-fit conventions; no third-party tool reproduces it. Formula-vetted only.",, -2D,PERIMETER,morphology,vetted,skimage,agreed,morphology.perimeter_circles,rel=1e-3,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,,,,audit,"vetted vs scikit-image 0.26.0 measure.perimeter on the circles fixture, recipe morphology.perimeter_circles; agreement 3.8e-15. Previously asserted in test_2d_morphology_matlab.h under a bwperim() recipe that returns a different quantity (846 perimeter pixels; regionprops('Perimeter') 952.848).",, -2D,DIAMETER_EQUAL_PERIMETER,morphology,vetted,imea,agreed,morphology.shape2d_native,exact,test_2d_morphology_imea.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py;test_nyxus.py,,,,audit,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 26.935), a gap inherited from PERIMETER.",, +2D,PERIMETER,morphology,vetted,skimage,agreed,morphology.perimeter_circles,rel=1e-3,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_contour_opencv.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,,,,audit,"vetted vs scikit-image 0.26.0 measure.perimeter on the circles fixture, recipe morphology.perimeter_circles; agreement 3.8e-15. Previously asserted in test_2d_morphology_matlab.h under a bwperim() recipe that returns a different quantity (846 perimeter pixels; regionprops('Perimeter') 952.848).",, +2D,DIAMETER_EQUAL_PERIMETER,morphology,vetted,imea,agreed,morphology.shape2d_native,exact,test_2d_morphology_imea.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py;test_nyxus.py,,,,audit,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 22.971), a gap inherited from PERIMETER.",, 2D,EDGE_MEAN_INTENSITY,morphology,vetted,cellprofiler,agreed,morphology.cellprofiler_edge_intensity,rel=1e-6,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py;test_nyxus.py,test_2d_morphology_cellprofiler.h,,,in-tree,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, set from that measurement rather than left at the shared helper's 0.1%. Offline generator tests/vetting/oracles/gen_morphology_cellprofiler.py reads the fixture from test_data.h and the pins from the header, so an edited, missing or extra pin fails it. SCOPE: the in-RAM segmented path only (ContourFeature::calculate at SINGLEROI=false); the whole-slide and osized_calculate paths are not covered, see matrix/morphology.md. Report: audit/morphology_2d_cellprofiler_vetting_report.md.",TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,bench_shape8_concave_holed 2D,EDGE_STDDEV_INTENSITY,morphology,regression,,disagrees,morphology.cellprofiler_edge_intensity,rel=1e-3,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.h,cellprofiler (MeasureObjectIntensity),estimator-divergence,in-tree,"NOT vetted. Same 18 edge pixels as CellProfiler -- the integrated sum agrees to the digit -- but a different estimator: Nyxus divides the variance by n-1 (Moments4::std, a helper shared across features) and CellProfiler by n, so the two differ by exactly sqrt(n/(n-1))=1.0289915 at n=18, 16.769194 against 16.296728. That is 2.9% relative, not a tolerance question: the family's 0.1% band would have caught it, and what let the previous cellprofiler-labelled assertion pass is that its golden was the Nyxus number. Snapshot-pinned in test_2d_morphology_regression.h. Re-vettable against a tool using the n-1 estimator, or after Nyxus decides which it means to report -- a src/nyx question, see not_covered.md section C. gen_morphology_cellprofiler.py reads this pin from the regression header and checks the ratio as an identity, so neither side can drift unnoticed. SCOPE: as for its five siblings, the in-RAM segmented path only. Report: audit/morphology_2d_cellprofiler_vetting_report.md.",TEST_NYXUS.TEST_2D_MORPHOLOGY_CONTOUR_REGRESSION,bench_shape8_concave_holed 2D,EDGE_MAX_INTENSITY,morphology,vetted,cellprofiler,agreed,morphology.cellprofiler_edge_intensity,rel=1e-6,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_cellprofiler.h,,,in-tree,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, set from that measurement rather than left at the shared helper's 0.1%. Offline generator tests/vetting/oracles/gen_morphology_cellprofiler.py reads the fixture from test_data.h and the pins from the header, so an edited, missing or extra pin fails it. SCOPE: the in-RAM segmented path only (ContourFeature::calculate at SINGLEROI=false); the whole-slide and osized_calculate paths are not covered, see matrix/morphology.md. Report: audit/morphology_2d_cellprofiler_vetting_report.md.",TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,bench_shape8_concave_holed 2D,EDGE_MIN_INTENSITY,morphology,vetted,cellprofiler,agreed,morphology.cellprofiler_edge_intensity,rel=1e-6,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_cellprofiler.h,,,in-tree,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, set from that measurement rather than left at the shared helper's 0.1%. Offline generator tests/vetting/oracles/gen_morphology_cellprofiler.py reads the fixture from test_data.h and the pins from the header, so an edited, missing or extra pin fails it. SCOPE: the in-RAM segmented path only (ContourFeature::calculate at SINGLEROI=false); the whole-slide and osized_calculate paths are not covered, see matrix/morphology.md. Report: audit/morphology_2d_cellprofiler_vetting_report.md.",TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,bench_shape8_concave_holed -2D,EDGE_INTEGRATED_INTENSITY,morphology,vetted,cellprofiler,agreed,morphology.cellprofiler_edge_intensity,rel=1e-6,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_cellprofiler.h,,,in-tree,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, set from that measurement rather than left at the shared helper's 0.1%. Offline generator tests/vetting/oracles/gen_morphology_cellprofiler.py reads the fixture from test_data.h and the pins from the header, so an edited, missing or extra pin fails it. SCOPE: the in-RAM segmented path only (ContourFeature::calculate at SINGLEROI=false); the whole-slide and osized_calculate paths are not covered, see matrix/morphology.md. Report: audit/morphology_2d_cellprofiler_vetting_report.md.",TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,bench_shape8_concave_holed +2D,EDGE_INTEGRATED_INTENSITY,morphology,vetted,cellprofiler,agreed,morphology.cellprofiler_edge_intensity,rel=1e-6,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_cellprofiler.h,,,in-tree,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, set from that measurement rather than left at the shared helper's 0.1%. Offline generator tests/vetting/oracles/gen_morphology_cellprofiler.py reads the fixture from test_data.h and the pins from the header, so an edited, missing or extra pin fails it. SCOPE: the in-RAM segmented path only (ContourFeature::calculate at SINGLEROI=false); the whole-slide and osized_calculate paths are not covered, see matrix/morphology.md. Report: audit/morphology_2d_cellprofiler_vetting_report.md.",TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,bench_shape8_concave_holed 2D,CIRCULARITY,morphology,vetted,analytic,agreed,morphology.shape2d_native,,test_2d_morphology_analytic.h,,,no-external-oracle,audit,"Tier-2 documented-formula conformance, NO external oracle. Nyxus computes CIRCULARITY = sqrt(4*pi*A)/P (convex_hull_nontriv.cpp) with A = ROI pixel count and P = contour perimeter. TEST_2D_MORPHOLOGY_DOCUMENTED_FORMULA_CONFORMANCE_ANALYTIC (test_2d_morphology_analytic.h) recomputes the formula from AREA_PIXELS_COUNT/PERIMETER and requires an exact match (1e-9), so the implementation is bug-free -- but the value is convention-specific (pixel-area + contour-perimeter) and no third-party tool reproduces it (pyradiomics/skimage use mesh area/perimeter). Formula-vetted only.",, 2D,CONVEX_HULL_AREA,morphology,vetted,skimage,agreed,morphology.shape2d_native,,test_2d_morphology_skimage.h,,Document Nyxus point-hull convention or revise expected value.,,audit,"vetted vs scikit-image (convex_hull_image offset_coordinates=False, Pick's-theorem convex_area); test_2d_morphology_skimage.h TEST_2D_MORPHOLOGY_CONVEX_HULL_SKIMAGE passes. The prior matlab-based 'suspected-bug' (SOLIDITY 1.0 vs matlab 0.97) was a wrong-oracle artifact: Nyxus deliberately matches skimage, not matlab regionprops ConvexArea.",, 2D,SOLIDITY,morphology,vetted,skimage,agreed,morphology.shape2d_native,,test_2d_morphology_hull_invariant.py;test_2d_morphology_skimage.h,,Document Nyxus point-hull convention or revise expected value.,,audit,"vetted vs scikit-image (convex_hull_image offset_coordinates=False, Pick's-theorem convex_area); test_2d_morphology_skimage.h TEST_2D_MORPHOLOGY_CONVEX_HULL_SKIMAGE passes. The prior matlab-based 'suspected-bug' (SOLIDITY 1.0 vs matlab 0.97) was a wrong-oracle artifact: Nyxus deliberately matches skimage, not matlab regionprops ConvexArea.",, @@ -157,43 +157,47 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,DIAMETER_MIN_ENCLOSING_CIRCLE,morphology,vetted,imea,agreed,morphology.caliper_ellipse,rel=6e-2,test_2d_morphology_imea.h;test_2d_morphology_regression.h,,,,audit,"vetted vs imea 0.3.5 shape_measurements_2d(dalpha=10) on the a=20 b=10 ellipse, recipe morphology.caliper_ellipse. Goldens regenerated at the matched angular step (the previous set mixed dalpha 9/10 and Nassenstein MIN matched no run), which let the tolerance tighten 10% -> 6%. The shape2d-raster values are regression snapshots, not imea numbers.",, 2D,DIAMETER_CIRCUMSCRIBING_CIRCLE,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_regression.h,,,convention-mismatch,tracker,"Left regression: Nyxus (circle.cpp, ported from imea macro.py) computes this as imea's max/min centroid-to-contour distance approximation, NOT a true geometric circle. It is convention-sensitive (Nyxus contour tracing + the centroid-1 offset): a symmetric circle r=15 yields circ=35.6/insc=23.3 instead of ~30/~30, so no external numeric oracle agrees -> documented-convention, not promoted. (DIAMETER_MIN_ENCLOSING_CIRCLE IS vetted vs cv2/imea.)",, 2D,DIAMETER_INSCRIBING_CIRCLE,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_regression.h,,,convention-mismatch,tracker,"Left regression: Nyxus (circle.cpp, ported from imea macro.py) computes this as imea's max/min centroid-to-contour distance approximation, NOT a true geometric circle. It is convention-sensitive (Nyxus contour tracing + the centroid-1 offset): a symmetric circle r=15 yields circ=35.6/insc=23.3 instead of ~30/~30, so no external numeric oracle agrees -> documented-convention, not promoted. (DIAMETER_MIN_ENCLOSING_CIRCLE IS vetted vs cv2/imea.)",, -2D,GEODETIC_LENGTH,morphology,vetted,imea,agreed,morphology.shape2d_native,exact,test_2d_morphology_imea.h,,,,audit,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 26.935), a gap inherited from PERIMETER.",, -2D,THICKNESS,morphology,vetted,imea,agreed,morphology.shape2d_native,exact,test_2d_morphology_imea.h,,,,audit,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 26.935), a gap inherited from PERIMETER.",, -2D,ROI_RADIUS_MEAN,morphology,regression,,disagrees,morphology.shape2d_native,rel=1e-3,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py,,skimage find_boundaries(mode='inner') + a minimum over it (gen_morphology_radius_skimage.py),blocked-by-contour-offset,in-tree,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. ROI_RADIUS_MEAN is the one of the three no oracle reproduces, for a reason outside this family. ContourFeature::buildRegularContour reports every contour pixel one pixel right and one pixel down of where it is -- it never subtracts the pad it traces in -- so the distances are measured against a shifted boundary. Shifting the skimage reference boundary the same way reproduces the Nyxus values exactly: 2.7517 -> 2.8334 at R=10, 6.0470 -> 6.0871 at R=20, 0.3077 -> 0.6247 on shape2d, and on the 8x8 mask the traced contour IS that boundary shifted, all 18 coordinates. gen_morphology_radius_skimage.py prints the comparison on every run. MAX and MEDIAN are unmoved by that shift on a disk, which is why they carry oracle rows and MEAN does not; MEAN becomes vettable against the same reference once the contour offset is corrected.",TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,bench_shape8_concave_holed +2D,GEODETIC_LENGTH,morphology,vetted,imea,agreed,morphology.shape2d_native,exact,test_2d_morphology_imea.h,,,,audit,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 22.971), a gap inherited from PERIMETER.",, +2D,THICKNESS,morphology,vetted,imea,agreed,morphology.shape2d_native,exact,test_2d_morphology_imea.h,,,,audit,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 22.971), a gap inherited from PERIMETER.",, +2D,ROI_RADIUS_MEAN,morphology,regression,,disagrees,morphology.shape2d_native,rel=1e-3,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py,,skimage find_boundaries(mode='inner') + a minimum over it (gen_morphology_radius_skimage.py),,in-tree,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances. Drift guard on shape2d: 18 of the 26 pixels are edge pixels at distance 0 and the other 8 are one pixel from the edge, so MEAN = 8/26 = 0.3077. The scikit-image reference (find_boundaries(connectivity=1, mode='inner') plus a minimum over it) gives the same value; gen_morphology_radius_skimage.py prints it on every run.",TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,bench_shape8_concave_holed 2D,ROI_RADIUS_MAX,morphology,regression,,na,morphology.shape2d_native,rel=1e-3,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row.",TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,bench_shape8_concave_holed 2D,ROI_RADIUS_MEDIAN,morphology,regression,,na,morphology.shape2d_native,rel=1e-3,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row.",TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,bench_shape8_concave_holed 2D,ROI_RADIUS_MAX,morphology,vetted,skimage,agreed,morphology.radius_disks,rel=1e-3,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d for the reason PERIMETER is vetted on the circles benchmark: on 26 pixels with a hole the two boundary conventions have nothing to converge to. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py.",TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,bench_radius_disks 2D,ROI_RADIUS_MEDIAN,morphology,vetted,skimage,agreed,morphology.radius_disks,rel=1e-3,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d for the reason PERIMETER is vetted on the circles benchmark: on 26 pixels with a hole the two boundary conventions have nothing to converge to. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py. Unmoved by the contour offset on a disk, unlike ROI_RADIUS_MEAN: the pixel attaining it moves with the shift.",TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,bench_radius_disks 2D,ROI_RADIUS_MAX,morphology,vetted,analytic,agreed,morphology.radius_disks,exact,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"vetted against an exact closed form for a filled digital disk, not a band: ROI_RADIUS_MAX is the centre's distance to the nearest boundary pixel, and that pixel is at offset (1, R-1) -- inside since 1+(R-1)^2 <= R^2, boundary since its neighbour (1, R) is not, and nearer than the axial (R, 0) since R^2-2R+2 < R^2. So MAX == sqrt((R-1)^2+1), matched to 1e-12 relative at R = 10, 20, 40; the (1, R-1) pixel was confirmed nearest at R = 5, 10, 20, 40, 80 and 160. A second oracle alongside the skimage row (SPEC 3.1 redundancy), checking what that row cannot: that the value obeys the geometry rather than merely agreeing with a reference implementation that could share a mistake. The same form gives MAX/(R-1) = sqrt(1+1/(R-1)^2) -> 1, i.e. linear growth in R, where a squared distance would give ratios 9.1, 19.1, 39.0 and growing. Asserted in test_2d_morphology_analytic.h; no generator, the reference is geometry.",TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISK_CLOSED_FORM_ANALYTIC,bench_radius_disks -2D,ROI_RADIUS_MEAN,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return 112 pixels on this fixture and they are not the same 112. Measured MEAN 6.087109772358636 -> 7.169174091182726 (+17.8%) and MEDIAN 5.0 -> 6.708203932499369 (+34%), while MAX is bit-identical at 19.026297590440446 on both paths -- so some ROI pixels are farther from the out-of-core contour and the pixel attaining the maximum is not among them. No translation of the inner boundary reproduces the out-of-core values, so this is pinned as a characterization rather than explained as an offset. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,ROI_RADIUS_MEDIAN,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return 112 pixels on this fixture and they are not the same 112. Measured MEAN 6.087109772358636 -> 7.169174091182726 (+17.8%) and MEDIAN 5.0 -> 6.708203932499369 (+34%), while MAX is bit-identical at 19.026297590440446 on both paths -- so some ROI pixels are farther from the out-of-core contour and the pixel attaining the maximum is not among them. No translation of the inner boundary reproduces the out-of-core values, so this is pinned as a characterization rather than explained as an offset. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,ROI_RADIUS_MAX,morphology,regression,,agrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return 112 pixels on this fixture and they are not the same 112. Measured MEAN 6.087109772358636 -> 7.169174091182726 (+17.8%) and MEDIAN 5.0 -> 6.708203932499369 (+34%), while MAX is bit-identical at 19.026297590440446 on both paths -- so some ROI pixels are farther from the out-of-core contour and the pixel attaining the maximum is not among them. No translation of the inner boundary reproduces the out-of-core values, so this is pinned as a characterization rather than explained as an offset. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest. This feature is the one of the three that AGREES across the two paths here; the row exists so the agreement is pinned and a fix that moved the whole contour could not break it silently.",,bench_disk64_diagonal_boundary +2D,ROI_RADIUS_MEAN,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,ROI_RADIUS_MEDIAN,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,ROI_RADIUS_MAX,morphology,regression,,agrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest. This feature is the one of the three that AGREES across the two paths here; the row exists so the agreement is pinned and a fix that moved the whole contour could not break it silently.",,bench_disk64_diagonal_boundary 2D,ROI_RADIUS_MEAN,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 24.490797504013532 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,ROI_RADIUS_MAX,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 45.254833995939045 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly 32*sqrt(2), the frame centre to a corner. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,ROI_RADIUS_MEDIAN,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 25.495097567963924 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly sqrt(650). No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,PERIMETER,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,PERIMETER,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_contour_opencv.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,DIAMETER_EQUAL_PERIMETER,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_morphology_imea.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py;test_nyxus.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,MASS_DISPLACEMENT,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_MEAN_INTENSITY,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py;test_nyxus.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_STDDEV_INTENSITY,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_MAX_INTENSITY,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_MIN_INTENSITY,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,EDGE_INTEGRATED_INTENSITY,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,EDGE_INTEGRATED_INTENSITY,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,MASS_DISPLACEMENT,morphology,invariant,,agreed,morphology.disk64_forced_ooc,rel=1e-6,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_ooc_invariant.py,,,in-tree,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_MEAN_INTENSITY,morphology,invariant,,agreed,morphology.disk64_forced_ooc,rel=1e-6,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py;test_nyxus.py,test_2d_ooc_invariant.py,,,in-tree,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_STDDEV_INTENSITY,morphology,invariant,,agreed,morphology.disk64_forced_ooc,rel=1e-6,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_ooc_invariant.py,,,in-tree,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_MAX_INTENSITY,morphology,invariant,,agreed,morphology.disk64_forced_ooc,rel=1e-6,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_ooc_invariant.py,,,in-tree,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_MIN_INTENSITY,morphology,invariant,,agreed,morphology.disk64_forced_ooc,rel=1e-6,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_ooc_invariant.py,,,in-tree,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,EDGE_INTEGRATED_INTENSITY,morphology,invariant,,agreed,morphology.disk64_forced_ooc,rel=1e-6,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_ooc_invariant.py,,,in-tree,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,PERIMETER,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc ContourFeature::osized_calculate sets fval_PERIMETER = K.size(), the contour pixel count, where calculate() sums Euclidean step lengths: 112.0 against 131.88225099390849 on the disk, ~15%, and 112 is exactly the fixture's edge-pixel count, which identifies the definition rather than accumulation as the cause. DIAMETER_EQUAL_PERIMETER is PERIMETER/pi on both paths (contour.cpp 976 and 1000) and inherits the same ratio exactly: 35.65070725258456 against 41.97942430353313. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Not caught before because the existing equality test's fixture is a full-image rectangle, where the pixel count and the Euclidean sum coincide. not_covered.md section G. Asserted by test_2d_ooc_perimeter_diverges_from_in_ram_regression / test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,EDGE_INTEGRATED_INTENSITY,morphology,invariant,,agreed,morphology.disk64_forced_ooc,rel=1e-6,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_ooc_invariant.py,,,in-tree,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,PERIMETER,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_contour_opencv.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc ContourFeature::osized_calculate sets fval_PERIMETER = K.size(), the contour pixel count, where calculate() sums Euclidean step lengths: 112.0 against 131.88225099390849 on the disk, ~15%, and 112 is exactly the fixture's edge-pixel count, which identifies the definition rather than accumulation as the cause. DIAMETER_EQUAL_PERIMETER is PERIMETER/pi on both paths (contour.cpp 976 and 1000) and inherits the same ratio exactly: 35.65070725258456 against 41.97942430353313. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Not caught before because the existing equality test's fixture is a full-image rectangle, where the pixel count and the Euclidean sum coincide. not_covered.md section G. Asserted by test_2d_ooc_perimeter_diverges_from_in_ram_regression / test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,DIAMETER_EQUAL_PERIMETER,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_imea.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py;test_nyxus.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc ContourFeature::osized_calculate sets fval_PERIMETER = K.size(), the contour pixel count, where calculate() sums Euclidean step lengths: 112.0 against 131.88225099390849 on the disk, ~15%, and 112 is exactly the fixture's edge-pixel count, which identifies the definition rather than accumulation as the cause. DIAMETER_EQUAL_PERIMETER is PERIMETER/pi on both paths (contour.cpp 976 and 1000) and inherits the same ratio exactly: 35.65070725258456 against 41.97942430353313. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Not caught before because the existing equality test's fixture is a full-image rectangle, where the pixel count and the Euclidean sum coincide. not_covered.md section G. Asserted by test_2d_ooc_perimeter_diverges_from_in_ram_regression / test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,MASS_DISPLACEMENT,morphology,vetted,cellprofiler,agreed,morphology.cellprofiler_wholeslide_massdisp,rel=1e-6,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,,in-tree,"vetted vs CellProfiler 4.2.8 MeasureObjectIntensity with ONE object over the full 64x64 frame, recipe morphology.cellprofiler_wholeslide_massdisp -- the configuration that matches SINGLEROI=true. Measured 3.345311793150965 against 3.3453118163885427, rel 7e-9. MASS_DISPLACEMENT is computed by BasicMorphologyFeatures::calculate() from the geometric and intensity-weighted centroids and reads no contour, so it never reaches buildWholeSlideContour() and is the same quantity in both tools whether the object is a disk or a full frame -- which is why this cell is oracle-backed while its EDGE_* siblings are not. Generator oracles/gen_morphology_wholeslide_cellprofiler.py reads the pins from the test. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_MEAN_INTENSITY,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py;test_nyxus.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_STDDEV_INTENSITY,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_MAX_INTENSITY,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,EDGE_MIN_INTENSITY,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,EDGE_INTEGRATED_INTENSITY,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,PERIMETER,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,EDGE_INTEGRATED_INTENSITY,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,EDGE_INTEGRATED_INTENSITY,morphology,vetted,analytic,agreed,contour.closed_forms,exact,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_contour_analytic.h,,,in-tree,"closed form, recipe contour.closed_forms: on a one-pixel-wide line the border walk passes the inner pixels twice, and the statistic is the intensity summed over the 5 edge pixels once each.",TEST_NYXUS.TEST_2D_CONTOUR_LINE_ANALYTIC, +2D,PERIMETER,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_contour_opencv.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,PERIMETER,morphology,vetted,opencv,agreed,contour.opencv_borders,exact,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_contour_opencv.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_contour_opencv.h,,,in-tree,"vetted vs OpenCV 4.13.0 cv2.findContours(RETR_LIST, CHAIN_APPROX_NONE), recipe contour.opencv_borders: the summed closed length of every border walk, 537.6639969244295 on the 9066-pixel blob, which has one border of 495 pixels; exact, the same border following on both sides. gen_contour_opencv.py.",TEST_NYXUS.TEST_2D_CONTOUR_BLOB97_OPENCV,bench_blob97_filled +2D,PERIMETER,morphology,vetted,opencv,agreed,morphology.shape2d_native,exact,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_contour_opencv.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_contour_opencv.h,,,in-tree,"vetted vs OpenCV 4.13.0 cv2.findContours(RETR_LIST, CHAIN_APPROX_NONE) on the shape2d mask: an outer border and the border of the one-pixel hole, 18 edge pixels, PERIMETER 22.970562748477146; exact. gen_contour_opencv.py, recipe contour.opencv_borders.",TEST_NYXUS.TEST_2D_CONTOUR_SHAPE2D_OPENCV,bench_shape8_concave_holed +2D,PERIMETER,morphology,vetted,analytic,agreed,contour.closed_forms,exact,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_contour_opencv.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_contour_analytic.h,,,in-tree,"closed forms, recipe contour.closed_forms: 4(n-1) for an n x n square, 44 + 12 + 4*sqrt(2) for a 12x12 square with a centred 4x4 hole, 48 for two separate squares 6x6 and 8x8 under one label, 2(n-1) for a one-pixel line, 0 for a single pixel. The ring is the assertion test_name names; the other shapes are asserted beside it in the same file.",TEST_NYXUS.TEST_2D_CONTOUR_RING_ANALYTIC, 2D,DIAMETER_EQUAL_PERIMETER,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_imea.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py;test_nyxus.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,NUM_NEIGHBORS,neighbor,vetted,cellprofiler,agreed,neighbor.scene2d_radius1,abs=1e-9,test_2d_morphology_regression.h;test_2d_neighbor_cellprofiler.h;test_2d_neighbor_invariant.py,test_2d_neighbor_cellprofiler.h,,,audit,"vetted vs CellProfiler 4.2.8 MeasureObjectNeighbors (Adjacent method, neighbors_are_objects) on the neighborhood2d_scene_labels fixture at PIXELDISTANCE=1, recipe neighbor.scene2d_radius1; agreement is EXACTLY 0 -- CP reproduces Nyxus bit for bit on all five ROIs -- asserted per ROI at abs=1e-9, SPEC 7's exact tier in test_2d_neighbor_cellprofiler.h. Re-measured 2026-08-20: the previous assertion was ASSERT_NEAR at 1e-4 absolute with no tolerance recorded here; an intermediate rel=1e-9 was corrected to the absolute band SPEC 7 defines. gen_neighbor_cellprofiler.py re-verifies every pin in the header it feeds; tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md",TEST_NYXUS.TEST_2D_NEIGHBOR_COUNTS_AND_FIRST_DISTANCE_CELLPROFILER,bench_scene7_5roi_enclosed 2D,PERCENT_TOUCHING,neighbor,regression,,disagrees,neighbor.scene2d_radius1,rel=1e-3,test_2d_neighbor_regression.h;test_2d_neighbor_invariant.h;test_2d_neighbor_invariant.py,test_2d_neighbor_regression.h,none (CP definition differs; empirically 3/5 ROIs),documented-convention,audit,"no promotable oracle, by definition rather than by defect. Nyxus counts distinct contour pixels 8-adjacent to a neighbour over contour length; CellProfiler counts object outline pixels overlapping a disk(distance+0.5)-dilated neighbour over perimeter, and no CP distance method (Adjacent/Expand/Within) reproduces it -- measured divergence on 3 of the 5 ROIs, up to 33.3 percentage points. Drift-pinned per ROI at rel=1e-3 in test_2d_neighbor_regression.h; the bounds it must obey by construction (0 <= PT <= 100, and exactly 100 for a fully enclosed ROI) are asserted in test_2d_neighbor_invariant.h. Those bound assertions previously carried an _analytic suffix, which credited this feature with an oracle it does not have. tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md",TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_REGRESSION;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_BOUNDED_INVARIANT;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_ENCLOSED_INVARIANT,bench_scene7_5roi_enclosed @@ -437,37 +441,37 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,HU_M5,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, 2D,HU_M6,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, 2D,HU_M7,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, -2D,WEIGHTED_SPAT_MOMENT_00,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_SPAT_MOMENT_01,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_SPAT_MOMENT_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_SPAT_MOMENT_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_SPAT_MOMENT_10,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_SPAT_MOMENT_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_SPAT_MOMENT_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_SPAT_MOMENT_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_SPAT_MOMENT_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_SPAT_MOMENT_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_CENTRAL_MOMENT_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_CENTRAL_MOMENT_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_CENTRAL_MOMENT_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_CENTRAL_MOMENT_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_CENTRAL_MOMENT_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_CENTRAL_MOMENT_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_CENTRAL_MOMENT_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WT_NORM_CTR_MOM_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WT_NORM_CTR_MOM_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WT_NORM_CTR_MOM_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WT_NORM_CTR_MOM_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WT_NORM_CTR_MOM_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WT_NORM_CTR_MOM_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WT_NORM_CTR_MOM_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_HU_M1,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_HU_M2,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_HU_M3,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_HU_M4,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_HU_M5,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_HU_M6,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,WEIGHTED_HU_M7,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_00,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_01,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_10,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_CENTRAL_MOMENT_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_CENTRAL_MOMENT_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_CENTRAL_MOMENT_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_CENTRAL_MOMENT_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_CENTRAL_MOMENT_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_CENTRAL_MOMENT_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_CENTRAL_MOMENT_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WT_NORM_CTR_MOM_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WT_NORM_CTR_MOM_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WT_NORM_CTR_MOM_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WT_NORM_CTR_MOM_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WT_NORM_CTR_MOM_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WT_NORM_CTR_MOM_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WT_NORM_CTR_MOM_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_HU_M1,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_HU_M2,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_HU_M3,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_HU_M4,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_HU_M5,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_HU_M6,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_HU_M7,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, 2D,IMOM_RM_00,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, 2D,IMOM_RM_01,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, 2D,IMOM_RM_02,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, @@ -527,37 +531,37 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,IMOM_HU5,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, 2D,IMOM_HU6,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, 2D,IMOM_HU7,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, -2D,IMOM_WRM_00,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WRM_01,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WRM_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WRM_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WRM_10,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WRM_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WRM_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WRM_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WRM_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WRM_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WCM_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WCM_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WCM_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WCM_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WCM_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WCM_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WCM_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WNCM_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WNCM_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WNCM_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WNCM_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WNCM_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WNCM_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WNCM_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WHU1,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WHU2,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WHU3,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WHU4,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WHU5,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WHU6,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, -2D,IMOM_WHU7,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_00,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_01,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_10,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WCM_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WCM_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WCM_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WCM_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WCM_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WCM_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WCM_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WNCM_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WNCM_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WNCM_11,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WNCM_12,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WNCM_20,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WNCM_21,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WNCM_30,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WHU1,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WHU2,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WHU3,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WHU4,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WHU5,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WHU6,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WHU7,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, 2D,HISTOGRAM,intensity_histogram,vetted,analytic,agreed,,rel=1e-4,test_2d_intensity_histogram_analytic.h,,,,in-tree,"Analytic vetting: PixelIntensityFeatures::val_HISTOGRAM = TrivialHistogram::get_cust_frequencies(n_greybins), raw per-bin counts over N equal-width bins spanning [min,max] (top-inclusive, Nyxus::to_grayscale binning). Hand-derived expected vector [2,1,2] for fixture {1,1,3,5,7} with N=3 bins, asserted in test_2d_intensity_histogram_bin_counts_analytic. Distinct feature class from the scalar IH_* family: opt-in via *ALL*, not IBSI-gated, hence no ih.ibsi_fbn recipe.",, 2D,IH_MEAN_VAL,intensity_histogram,vetted,analytic,agreed,ih.ibsi_fbn,rel=1e-9,test_2d_intensity_histogram_regression.h;test_2d_intensity_histogram_analytic.py;test_2d_intensity_histogram_analytic.h,,,,in-tree,"No tool reports this quantity - MIRP and PyRadiomics both stay in the discretised (_IDX) domain - so the oracle is the closed form, evaluated by tests/vetting/oracles/gen_intensity_histogram_analytic.py from the published definitions rather than from intensity_histogram.cpp, and asserted at rel<=1e-9 in test_2d_intensity_histogram_analytic.h (worst measured 7.5e-15). Evidence: tests/vetting/audit/intensity_histogram_2d_*_vetting_report.md.",, 2D,IH_VARIANCE_VAL,intensity_histogram,vetted,analytic,agreed,ih.ibsi_fbn,rel=1e-9,test_2d_intensity_histogram_regression.h;test_2d_intensity_histogram_analytic.py;test_2d_intensity_histogram_analytic.h,,,,in-tree,"No tool reports this quantity - MIRP and PyRadiomics both stay in the discretised (_IDX) domain - so the oracle is the closed form, evaluated by tests/vetting/oracles/gen_intensity_histogram_analytic.py from the published definitions rather than from intensity_histogram.cpp, and asserted at rel<=1e-9 in test_2d_intensity_histogram_analytic.h (worst measured 7.5e-15). Evidence: tests/vetting/audit/intensity_histogram_2d_*_vetting_report.md.",, diff --git a/tests/vetting/oracles/gen_contour_opencv.py b/tests/vetting/oracles/gen_contour_opencv.py new file mode 100644 index 000000000..93a2c60f3 --- /dev/null +++ b/tests/vetting/oracles/gen_contour_opencv.py @@ -0,0 +1,133 @@ +"""OFFLINE OpenCV oracle for the 2D ROI contour and PERIMETER. + + python tests/vetting/oracles/gen_contour_opencv.py (from the repository root) + +Prints the paste-ready goldens AND re-verifies every golden pinned in contour_2d_opencv_ref_vals +(tests/test_2d_contour_opencv.h), exiting non-zero on any mismatch or on a pin it cannot produce. + +WHAT IS COMPARED. ContourFeature::buildRegularContour traces every border of the ROI -- the outer +border of each 8-connected fragment and the border of each hole -- as a closed walk in 8-connected +steps, and PERIMETER is the sum of the walks' Euclidean lengths. That is the border following of +Suzuki and Abe (1985), which OpenCV implements as + + cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE) + +RETR_LIST returns every border, holes included, and CHAIN_APPROX_NONE keeps every pixel of each +walk. The reference PERIMETER is the sum over those walks of the closed length, accumulated here in +double from OpenCV's integer points: cv2.arcLength sums in float32 and is off by ~1e-8 relative. +The mask is padded by one background pixel so a border on the frame edge is traced like any other. + +Fixtures are read from the headers that define them, never copied: bench_blob97_filled from +tests/test_2d_contour_common.h and shape2d_morphology_mask from tests/test_data.h. + +Provenance: tool=OpenCV (cv2) 4.13.0, numpy 2.4.6; env=nyxus_mirp (conda). OpenCV 5.0.0 reproduces +every pin too; +generator=tests/vetting/oracles/gen_contour_opencv.py. Run offline; CI never invokes it. +""" +import math +import os +import re + +import cv2 +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +TESTS = os.path.dirname(os.path.dirname(HERE)) +DATA_H = os.path.join(TESTS, "test_data.h") +COMMON_H = os.path.join(TESTS, "test_2d_contour_common.h") +TEST_H = os.path.join(TESTS, "test_2d_contour_opencv.h") + +RELTOL = 1e-9 # same algorithm on both sides: integer walks, only the summation order differs + + +def parse_rows(txt, name): + """The text-row mask `name` from test_2d_contour_common.h, '1' = ROI.""" + body = txt.split(name + " = {", 1)[1].split("};", 1)[0] + rows = re.findall(r'"([^"]*)"', body) + return np.array([[c == "1" for c in r] for r in rows], dtype=bool) + + +def parse_fixture(txt, name): + """The {x, y, value} pixel array `name` from test_data.h, as a 2-D numpy array.""" + body = txt.split(name + "[] = {", 1)[1].split("};", 1)[0] + tok = [(int(x), int(y), int(v)) for x, y, v in + re.findall(r"\{\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\}", body)] + w = max(t[0] for t in tok) + 1 + h = max(t[1] for t in tok) + 1 + a = np.zeros((h, w), dtype=np.int64) + for x, y, v in tok: + a[y, x] = v + return a + + +def parse_pins(txt, table): + """Every {"NAME", value} entry of one named ref_vals_map in a test header.""" + body = txt.split(table + "{", 1)[1].split("};", 1)[0] + body = re.sub(r"//[^\n]*", "", body) # a commented-out golden is not a pin + return {n: float(v) for n, v in + re.findall(r'\{\s*"([A-Z0-9_]+)"\s*,\s*([-0-9.eE+]+)\s*\}', body)} + + +def opencv_borders(mask): + """Number of borders, distinct border pixels and summed closed-walk length of a boolean mask.""" + padded = np.pad(mask.astype(np.uint8), 1) + contours, _ = cv2.findContours(padded, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE) + pixels = set() + length = 0.0 + for c in contours: + pts = [(int(p[0][0]), int(p[0][1])) for p in c] + pixels.update(pts) + if len(pts) > 1: + for i in range(len(pts)): + (ax, ay), (bx, by) = pts[i - 1], pts[i] + length += math.hypot(ax - bx, ay - by) + return len(contours), len(pixels), length + + +def main(): + print(f"# opencv {cv2.__version__}, numpy {np.__version__}") + shapes = { + "BLOB97": parse_rows(open(COMMON_H, encoding="utf-8").read(), "bench_blob97_filled_rows"), + "SHAPE2D": parse_fixture(open(DATA_H, encoding="utf-8", errors="replace").read(), + "shape2d_morphology_mask") > 0, + } + got = {} + print("\n# paste-ready goldens (17 significant digits)") + for tag, m in shapes.items(): + n, npx, length = opencv_borders(m) + got[f"NUM_CONTOURS_{tag}"] = float(n) + got[f"CONTOUR_PIXELS_{tag}"] = float(npx) + got[f"PERIMETER_{tag}"] = length + print(f"\t// {tag}: {int(m.sum())} ROI pixels") + print(f'\t{{"NUM_CONTOURS_{tag}", {n}}},') + print(f'\t{{"CONTOUR_PIXELS_{tag}", {npx}}},') + print(f'\t{{"PERIMETER_{tag}", {length!r}}},') + + pins = parse_pins(open(TEST_H, encoding="utf-8").read(), "contour_2d_opencv_ref_vals") + print(f"\n# verifying {len(pins)} pinned goldens against this run") + if not pins: + print("NO PINS FOUND in contour_2d_opencv_ref_vals -- nothing is covered") + return 1 + nok = nfail = nmiss = 0 + for name in sorted(pins): + want = pins[name] + if name not in got: + print(f" MISSING {name}: pinned {want!r} but this generator produces no such value") + nmiss += 1 + continue + have = got[name] + rel = abs(have - want) / max(abs(want), 1e-12) + ok = rel <= RELTOL + print(f" {'OK ' if ok else 'FAIL'} {name}: opencv={have!r} pinned={want!r} rel={rel:.3g}") + nok, nfail = (nok + 1, nfail) if ok else (nok, nfail + 1) + + print(f"\n{nok} verified, {nfail} failed, {nmiss} unproducible") + if nfail or nmiss: + print("SOME CHECKS FAILED -- do not promote") + return 1 + print("ALL CHECKS PASSED") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/vetting/oracles/gen_morphology_imea.py b/tests/vetting/oracles/gen_morphology_imea.py index abdd3e4a8..ed5c578c2 100644 --- a/tests/vetting/oracles/gen_morphology_imea.py +++ b/tests/vetting/oracles/gen_morphology_imea.py @@ -13,9 +13,10 @@ P/4 +- sqrt(P^2/16 - A). imea implements both in imea.measure_2d.macro, and fed the NYXUS area and perimeter it reproduces the Nyxus values to double precision. SCOPE: this vets the TRANSFORM, not the area and perimeter it consumes. imea derives its own - perimeter from cv2.arcLength (12.657 here) rather than a chain-code contour walk (26.935), so - imea's END-TO-END values on this mask do not agree with Nyxus; the whole gap is inherited from - PERIMETER, which is vetted separately against scikit-image on the circles benchmark. + perimeter from cv2.arcLength (12.657 here) rather than the summed closed walks of every border, + outer and hole (22.971), so imea's END-TO-END values on this mask do not agree with Nyxus; the + whole gap is inherited from PERIMETER, which is vetted separately against OpenCV's border + following on this mask and against scikit-image on the circles benchmark. The 19 caliper/chord statistics that used to be pinned in this file's imea table were never imea values at all -- imea's own numbers on this 8x8 raster differ by 12-79%, printed below as the `raster gap` block. They are now regression snapshots in test_2d_morphology_regression.h. @@ -53,7 +54,7 @@ # Nyxus inputs to the ISO transforms, from the shape2d fixture (test_2d_morphology_regression.h) NYX_AREA = 26.0 -NYX_PERIMETER = 26.9349412836191 +NYX_PERIMETER = 22.970562748477146 CALIPER_KEYS = { "STAT_MARTIN_DIAM_MIN": "martin_min", "STAT_MARTIN_DIAM_MAX": "martin_max", diff --git a/tests/vetting/oracles/gen_morphology_radius_skimage.py b/tests/vetting/oracles/gen_morphology_radius_skimage.py index b7025b02d..73efa5453 100644 --- a/tests/vetting/oracles/gen_morphology_radius_skimage.py +++ b/tests/vetting/oracles/gen_morphology_radius_skimage.py @@ -17,10 +17,9 @@ find_boundaries(connectivity=1, mode='inner') is the whole of the convention: the foreground pixels 4-adjacent to background. It is CellProfiler's own edge definition, which is why gen_morphology_cellprofiler.py and gen_morphology_wholeslide_cellprofiler.py already reproduce the -EDGE_* statistics through it. On the 8x8 shape2d mask it does not merely agree in COUNT with the -Nyxus chain-code contour: measured pixel by pixel, the Nyxus contour IS this boundary shifted by -(+1,+1) -- all 18 coordinates, exactly. On the r=20 disk it returns 112, the edge-pixel count -benchmarks.md records for `bench_disk64_diagonal_boundary`, the same 1257-pixel shape. The distance step carries no +EDGE_* statistics through it, and the set of pixels on the Nyxus contour: every border of the ROI, +traced as a closed walk over exactly these pixels. On the r=20 disk it returns 112, the edge-pixel +count benchmarks.md records for `bench_disk64_diagonal_boundary`, the same 1257-pixel shape. The distance step carries no convention at all, and is computed here twice -- an exhaustive numpy minimum and scipy.ndimage.distance_transform_edt over the complement of the boundary -- with the two required to agree, so neither implementation is taken on trust. @@ -32,14 +31,9 @@ MAX/(R-1) = sqrt(1+1/(R-1)^2) then converges to 1 as 1/(2(R-1)^2): 0.62%, 0.14%, 0.03% at R = 10, 20, 40, i.e. LINEAR growth in R. -WHAT IS VETTED AND WHAT IS NOT. MAX and MEDIAN agree with this reference to double precision. -ROI_RADIUS_MEAN does not, for a reason outside this family: ContourFeature::buildRegularContour -reports every contour pixel one pixel right and one pixel down of where it is (it never subtracts the -pad it traces in), so Nyxus measures its pixels against a shifted boundary. Shifting the reference -boundary by (+1, +1) reproduces all three Nyxus values exactly, on the disks and on the shape2d -raster alike -- this script prints that comparison on every run as the standing measurement. MAX and -MEDIAN are unmoved by the shift on a disk because the pixel attaining them moves with it; MEAN is -not, and is a regression row until the contour offset is corrected. +WHAT IS VETTED. MAX and MEDIAN agree with this reference to double precision and are pinned below. +The script also prints the reference's MEAN, MAX and MEDIAN on the three disks and on the shape2d +raster, which are the values Nyxus reports there. Provenance: tool=scikit-image 0.26.0, scipy 1.17.1, numpy 2.4.6; env=nyxus_mirp (conda); generator=tests/vetting/oracles/gen_morphology_radius_skimage.py. Run offline; CI never invokes it. @@ -91,20 +85,18 @@ def parse_pins(txt, table): re.findall(r'\{\s*"([A-Z0-9_]+)"\s*,\s*([-0-9.eE+]+)\s*\}', body)} -def radius_stats(mask, shift=0): - """Mean/max/median distance from each mask pixel to the inner boundary, optionally shifted.""" +def radius_stats(mask): + """Mean/max/median distance from each mask pixel to the inner boundary.""" b = find_boundaries(mask, connectivity=1, mode="inner") by, bx = np.nonzero(b) - by, bx = by + shift, bx + shift py, px = np.nonzero(mask) d = np.sqrt(np.min((py[:, None] - by) ** 2 + (px[:, None] - bx) ** 2, axis=1).astype(float)) - if shift == 0: - # Second, independent implementation of the same distance: an EDT over everything that is - # not boundary. Only the boundary definition above may carry a convention; this catches a - # mistake in the exhaustive minimum rather than trusting it. - edt = distance_transform_edt(~b)[mask] - assert np.allclose(d, edt, rtol=0, atol=1e-12), "numpy minimum and scipy EDT disagree" + # Second, independent implementation of the same distance: an EDT over everything that is not + # boundary. Only the boundary definition above may carry a convention; this catches a mistake in + # the exhaustive minimum rather than trusting it. + edt = distance_transform_edt(~b)[mask] + assert np.allclose(d, edt, rtol=0, atol=1e-12), "numpy minimum and scipy EDT disagree" return {"MEAN": float(d.mean()), "MAX": float(d.max()), "MEDIAN": float(np.median(d))}, int(b.sum()) @@ -124,18 +116,13 @@ def main(): print(f"\t# R={R}: {int(m.sum())} pixels, {nb} boundary pixels, " f"MAX/(R-1) = {ref['MAX'] / (R - 1):.6f}") - print("\n# the contour offset, measured: reference vs reference shifted by (+1,+1)") + print("\n# the reference on every fixture, the values Nyxus reports") data = open(DATA_H, encoding="utf-8", errors="replace").read() shapes = [(f"disk_r{R}", disk(R)) for R in RADII] shapes.append(("shape2d", parse_fixture(data, "shape2d_morphology_mask") > 0)) for name, m in shapes: - a, _ = radius_stats(m, shift=0) - b, _ = radius_stats(m, shift=1) - print(f" {name:9s} MEAN {a['MEAN']!r} -> {b['MEAN']!r} " - f"MAX {a['MAX']!r} -> {b['MAX']!r} " - f"MEDIAN {a['MEDIAN']!r} -> {b['MEDIAN']!r}") - print(" (the shifted column is what Nyxus reports today; MAX and MEDIAN are unmoved by the") - print(" shift on a disk, MEAN is not, which is why only the first two are vetted)") + a, _ = radius_stats(m) + print(f" {name:9s} MEAN {a['MEAN']!r} MAX {a['MAX']!r} MEDIAN {a['MEDIAN']!r}") test = open(TEST_H, encoding="utf-8", errors="replace").read() pins = parse_pins(test, "morphology_2d_skimage_radius_disks_ref_vals") diff --git a/tests/vetting/oracles/gen_radial_cellprofiler.py b/tests/vetting/oracles/gen_radial_cellprofiler.py index 5671bb956..015c8c112 100644 --- a/tests/vetting/oracles/gen_radial_cellprofiler.py +++ b/tests/vetting/oracles/gen_radial_cellprofiler.py @@ -131,7 +131,7 @@ def parse_mechanics(): # message naming the defect they pin, so anchoring on ");" stops finding them cx = re.search(r"ASSERT_EQ\(pxO\.x,\s*(\d+)\)", src) cy = re.search(r"ASSERT_EQ\(pxO\.y,\s*(\d+)\)", src) - r2 = re.search(r"ASSERT_DOUBLE_EQ\(pxO\.max_sqdist\(K\),\s*([0-9.]+)\)", src) + r2 = re.search(r"ASSERT_DOUBLE_EQ\(pxO\.max_sqdist\(Kb\),\s*([0-9.]+)\)", src) if not (cx and cy and r2): raise SystemExit("cannot find the centre / radius pins in test_2d_radial_mechanics.h") return int(cx.group(1)), int(cy.group(1)), float(r2.group(1)) @@ -145,8 +145,8 @@ def nyxus_model(pixels, cx, cy, r_max, n=NBINS, epsilon=1e-9): Follows src/nyx/features/radial_distribution.cpp: the bin index is scaled by n-1 and clamped, FRAC_AT_D is a pixel-count fraction, MEAN_FRAC is a raw bin mean intensity, and RADIAL_CV is the population CV of the eight wedge SUMS including empty wedges. - The centre and r_max are inputs because the feature obtains them from two approximate - searches over a concatenated contour, which no clean reimplementation reproduces. + The centre and r_max are inputs, read from the mechanics header, where they are pinned as + the feature's coarse-to-fine searches over the contour's borders find them. """ cnt = [0] * n isum = [0.0] * n diff --git a/tests/vetting/report_output.csv b/tests/vetting/report_output.csv index 98804755e..8fb8e1677 100644 --- a/tests/vetting/report_output.csv +++ b/tests/vetting/report_output.csv @@ -84,13 +84,13 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,morphology,ECCENTRICITY,vetted,matlab,morphology.shape2d_native,rel=1e-3,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_ELLIPSE_MATLAB,,,in-tree,matlab,test_2d_morphology_ellipse_matlab,test_2d_morphology_ellipse_regression,test_2d_ooc_2d_two_rois_no_state_leak_invariant,1,1,licensed MATLAB R2026a + Image Processing Toolbox 26.1 (gen_morphology_matlab.m); all 33 C++ pins verified; see morphology_2d_matlab_vetting_report.md,test_2d_morphology_ellipse_matlab,agree,row,audit/morphology_2d_matlab_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,MATLAB R2026a + Image Processing Toolbox 26.1; gen_morphology_matlab.m calls regionprops/bweuler on the morphology.shape2d_native fixture from PolusAI/nyxus main and verifies all 33 C++ pins; max rel difference from the prior Octave literals is 3.40e-16. 2D,morphology,ORIENTATION,vetted,skimage,morphology.shape2d_native,,agreed,,,,,audit,skimage,test_2d_morphology_orientation_skimage,,test_2d_ooc_2d_two_rois_no_state_leak_invariant,1,0,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,Vetted vs scikit-image regionprops: NYXUS == 90 - degrees(skimage.orientation) = 70.4174 (row-axis vs x-axis convention); matches to 10 decimals because the ellipse angle is invariant to the pixel-size second-moment correction that makes MAJOR/MINOR/ECCENTRICITY differ ~1.4%. gen_morphology_skimage.py 2D,morphology,ROUNDNESS,vetted,analytic,morphology.shape2d_native,,agreed,,,no-external-oracle,,audit,analytic,test_2d_morphology_documented_formula_conformance_analytic,test_2d_morphology_ellipse_regression,test_2d_ooc_2d_two_rois_no_state_leak_invariant,1,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Tier-2 documented-formula conformance, NO external oracle. Nyxus computes ROUNDNESS = 4*A/(pi*major^2) (ellipse_fitting.cpp) with A = ROI pixel count and major = moment-fit MAJOR_AXIS_LENGTH. TEST_2D_MORPHOLOGY_DOCUMENTED_FORMULA_CONFORMANCE_ANALYTIC (test_2d_morphology_analytic.h) recomputes the formula and requires an exact match (1e-9). Implementation bug-free, but the value depends on Nyxus' pixel-area + moment-fit conventions; no third-party tool reproduces it. Formula-vetted only." -2D,morphology,PERIMETER,vetted,skimage,morphology.perimeter_circles,rel=1e-3,agreed,,,,,audit,skimage,test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_bad_contour,1,6,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image 0.26.0 measure.perimeter on the circles fixture, recipe morphology.perimeter_circles; agreement 3.8e-15. Previously asserted in test_2d_morphology_matlab.h under a bwperim() recipe that returns a different quantity (846 perimeter pixels; regionprops('Perimeter') 952.848)." -2D,morphology,DIAMETER_EQUAL_PERIMETER,vetted,imea,morphology.shape2d_native,exact,agreed,,,,,audit,imea,test_2d_morphology_diameter_equal_perimeter_imea,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression,test_bad_contour,1,4,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 26.935), a gap inherited from PERIMETER." +2D,morphology,PERIMETER,vetted,skimage,morphology.perimeter_circles,rel=1e-3,agreed,,,,,audit,analytic;opencv;skimage,test_2d_contour_blob97_opencv;test_2d_contour_line_analytic;test_2d_contour_ring_analytic;test_2d_contour_shape2d_opencv;test_2d_contour_single_pixel_analytic;test_2d_contour_square_analytic;test_2d_contour_two_fragments_analytic;test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_bad_contour,8,6,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image 0.26.0 measure.perimeter on the circles fixture, recipe morphology.perimeter_circles; agreement 3.8e-15. Previously asserted in test_2d_morphology_matlab.h under a bwperim() recipe that returns a different quantity (846 perimeter pixels; regionprops('Perimeter') 952.848)." +2D,morphology,DIAMETER_EQUAL_PERIMETER,vetted,imea,morphology.shape2d_native,exact,agreed,,,,,audit,imea,test_2d_morphology_diameter_equal_perimeter_imea,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression,test_bad_contour,1,4,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 22.971), a gap inherited from PERIMETER." 2D,morphology,EDGE_MEAN_INTENSITY,vetted,cellprofiler,morphology.cellprofiler_edge_intensity,rel=1e-6,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant;test_bad_contour,1,2,,test_2d_morphology_edge_intensity_cellprofiler,agree,row,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, set from that measurement rather than left at the shared helper's 0.1%. Offline generator tests/vetting/oracles/gen_morphology_cellprofiler.py reads the fixture from test_data.h and the pins from the header, so an edited, missing or extra pin fails it. SCOPE: the in-RAM segmented path only (ContourFeature::calculate at SINGLEROI=false); the whole-slide and osized_calculate paths are not covered, see matrix/morphology.md. Report: audit/morphology_2d_cellprofiler_vetting_report.md." 2D,morphology,EDGE_STDDEV_INTENSITY,regression,,morphology.cellprofiler_edge_intensity,rel=1e-3,disagrees,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_CONTOUR_REGRESSION,estimator-divergence,cellprofiler (MeasureObjectIntensity),in-tree,,,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,0,3,,test_2d_morphology_contour_regression,agree,row,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted. Same 18 edge pixels as CellProfiler -- the integrated sum agrees to the digit -- but a different estimator: Nyxus divides the variance by n-1 (Moments4::std, a helper shared across features) and CellProfiler by n, so the two differ by exactly sqrt(n/(n-1))=1.0289915 at n=18, 16.769194 against 16.296728. That is 2.9% relative, not a tolerance question: the family's 0.1% band would have caught it, and what let the previous cellprofiler-labelled assertion pass is that its golden was the Nyxus number. Snapshot-pinned in test_2d_morphology_regression.h. Re-vettable against a tool using the n-1 estimator, or after Nyxus decides which it means to report -- a src/nyx question, see not_covered.md section C. gen_morphology_cellprofiler.py reads this pin from the regression header and checks the ratio as an identity, so neither side can drift unnoticed. SCOPE: as for its five siblings, the in-RAM segmented path only. Report: audit/morphology_2d_cellprofiler_vetting_report.md." 2D,morphology,EDGE_MAX_INTENSITY,vetted,cellprofiler,morphology.cellprofiler_edge_intensity,rel=1e-6,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,test_2d_morphology_edge_intensity_cellprofiler,agree,row,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, set from that measurement rather than left at the shared helper's 0.1%. Offline generator tests/vetting/oracles/gen_morphology_cellprofiler.py reads the fixture from test_data.h and the pins from the header, so an edited, missing or extra pin fails it. SCOPE: the in-RAM segmented path only (ContourFeature::calculate at SINGLEROI=false); the whole-slide and osized_calculate paths are not covered, see matrix/morphology.md. Report: audit/morphology_2d_cellprofiler_vetting_report.md." 2D,morphology,EDGE_MIN_INTENSITY,vetted,cellprofiler,morphology.cellprofiler_edge_intensity,rel=1e-6,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,test_2d_morphology_edge_intensity_cellprofiler,agree,row,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, set from that measurement rather than left at the shared helper's 0.1%. Offline generator tests/vetting/oracles/gen_morphology_cellprofiler.py reads the fixture from test_data.h and the pins from the header, so an edited, missing or extra pin fails it. SCOPE: the in-RAM segmented path only (ContourFeature::calculate at SINGLEROI=false); the whole-slide and osized_calculate paths are not covered, see matrix/morphology.md. Report: audit/morphology_2d_cellprofiler_vetting_report.md." -2D,morphology,EDGE_INTEGRATED_INTENSITY,vetted,cellprofiler,morphology.cellprofiler_edge_intensity,rel=1e-6,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,test_2d_morphology_edge_intensity_cellprofiler,agree,row,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, set from that measurement rather than left at the shared helper's 0.1%. Offline generator tests/vetting/oracles/gen_morphology_cellprofiler.py reads the fixture from test_data.h and the pins from the header, so an edited, missing or extra pin fails it. SCOPE: the in-RAM segmented path only (ContourFeature::calculate at SINGLEROI=false); the whole-slide and osized_calculate paths are not covered, see matrix/morphology.md. Report: audit/morphology_2d_cellprofiler_vetting_report.md." +2D,morphology,EDGE_INTEGRATED_INTENSITY,vetted,cellprofiler,morphology.cellprofiler_edge_intensity,rel=1e-6,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_EDGE_INTENSITY_CELLPROFILER,,,in-tree,analytic;cellprofiler,test_2d_contour_line_analytic;test_2d_contour_single_pixel_analytic;test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,3,2,,test_2d_morphology_edge_intensity_cellprofiler,agree,row,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"CellProfiler 4.2.8 MeasureObjectIntensity on the shape2d fixture, recipe morphology.cellprofiler_edge_intensity; the edge set is find_boundaries(mode=inner), 18 of the 26 ROI pixels summing to 753 against the ROI's 1048 -- the same pixels Nyxus walks, which is what makes this an exact comparison. Measured agreement 5.2e-8 relative, the residual being CellProfiler's float32 image storage rather than a disagreement; asserted at 1e-6 relative in test_2d_morphology_cellprofiler.h, set from that measurement rather than left at the shared helper's 0.1%. Offline generator tests/vetting/oracles/gen_morphology_cellprofiler.py reads the fixture from test_data.h and the pins from the header, so an edited, missing or extra pin fails it. SCOPE: the in-RAM segmented path only (ContourFeature::calculate at SINGLEROI=false); the whole-slide and osized_calculate paths are not covered, see matrix/morphology.md. Report: audit/morphology_2d_cellprofiler_vetting_report.md." 2D,morphology,CIRCULARITY,vetted,analytic,morphology.shape2d_native,,agreed,,,no-external-oracle,,audit,analytic,test_2d_morphology_documented_formula_conformance_analytic,,,1,0,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Tier-2 documented-formula conformance, NO external oracle. Nyxus computes CIRCULARITY = sqrt(4*pi*A)/P (convex_hull_nontriv.cpp) with A = ROI pixel count and P = contour perimeter. TEST_2D_MORPHOLOGY_DOCUMENTED_FORMULA_CONFORMANCE_ANALYTIC (test_2d_morphology_analytic.h) recomputes the formula from AREA_PIXELS_COUNT/PERIMETER and requires an exact match (1e-9), so the implementation is bug-free -- but the value is convention-specific (pixel-area + contour-perimeter) and no third-party tool reproduces it (pyradiomics/skimage use mesh area/perimeter). Formula-vetted only." 2D,morphology,CONVEX_HULL_AREA,vetted,skimage,morphology.shape2d_native,,agreed,,,,Document Nyxus point-hull convention or revise expected value.,audit,skimage,test_2d_morphology_convex_hull_skimage,,,1,0,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image (convex_hull_image offset_coordinates=False, Pick's-theorem convex_area); test_2d_morphology_skimage.h TEST_2D_MORPHOLOGY_CONVEX_HULL_SKIMAGE passes. The prior matlab-based 'suspected-bug' (SOLIDITY 1.0 vs matlab 0.97) was a wrong-oracle artifact: Nyxus deliberately matches skimage, not matlab regionprops ConvexArea." 2D,morphology,SOLIDITY,vetted,skimage,morphology.shape2d_native,,agreed,,,,Document Nyxus point-hull convention or revise expected value.,audit,skimage,test_2d_morphology_convex_hull_skimage,,test_2d_morphology_solidity_le_one_invariant,1,0,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image (convex_hull_image offset_coordinates=False, Pick's-theorem convex_area); test_2d_morphology_skimage.h TEST_2D_MORPHOLOGY_CONVEX_HULL_SKIMAGE passes. The prior matlab-based 'suspected-bug' (SOLIDITY 1.0 vs matlab 0.97) was a wrong-oracle artifact: Nyxus deliberately matches skimage, not matlab regionprops ConvexArea." @@ -157,43 +157,47 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,morphology,DIAMETER_MIN_ENCLOSING_CIRCLE,vetted,imea,morphology.caliper_ellipse,rel=6e-2,agreed,,,,,audit,imea,test_2d_morphology_min_enclosing_circle_imea,test_2d_morphology_misc_regression,,1,1,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 shape_measurements_2d(dalpha=10) on the a=20 b=10 ellipse, recipe morphology.caliper_ellipse. Goldens regenerated at the matched angular step (the previous set mixed dalpha 9/10 and Nassenstein MIN matched no run), which let the tolerance tighten 10% -> 6%. The shape2d-raster values are regression snapshots, not imea numbers." 2D,morphology,DIAMETER_CIRCUMSCRIBING_CIRCLE,regression,,morphology.shape2d_native,,benign,,,convention-mismatch,,tracker,,,test_2d_morphology_fractal_circle_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Left regression: Nyxus (circle.cpp, ported from imea macro.py) computes this as imea's max/min centroid-to-contour distance approximation, NOT a true geometric circle. It is convention-sensitive (Nyxus contour tracing + the centroid-1 offset): a symmetric circle r=15 yields circ=35.6/insc=23.3 instead of ~30/~30, so no external numeric oracle agrees -> documented-convention, not promoted. (DIAMETER_MIN_ENCLOSING_CIRCLE IS vetted vs cv2/imea.)" 2D,morphology,DIAMETER_INSCRIBING_CIRCLE,regression,,morphology.shape2d_native,,benign,,,convention-mismatch,,tracker,,,test_2d_morphology_fractal_circle_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Left regression: Nyxus (circle.cpp, ported from imea macro.py) computes this as imea's max/min centroid-to-contour distance approximation, NOT a true geometric circle. It is convention-sensitive (Nyxus contour tracing + the centroid-1 offset): a symmetric circle r=15 yields circ=35.6/insc=23.3 instead of ~30/~30, so no external numeric oracle agrees -> documented-convention, not promoted. (DIAMETER_MIN_ENCLOSING_CIRCLE IS vetted vs cv2/imea.)" -2D,morphology,GEODETIC_LENGTH,vetted,imea,morphology.shape2d_native,exact,agreed,,,,,audit,imea,test_2d_morphology_geodetic_length_thickness_imea,,,1,0,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 26.935), a gap inherited from PERIMETER." -2D,morphology,THICKNESS,vetted,imea,morphology.shape2d_native,exact,agreed,,,,,audit,imea,test_2d_morphology_geodetic_length_thickness_imea,,,1,0,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 26.935), a gap inherited from PERIMETER." -2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.shape2d_native,rel=1e-3,disagrees,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,blocked-by-contour-offset,skimage find_boundaries(mode='inner') + a minimum over it (gen_morphology_radius_skimage.py),in-tree,,,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,0,3,,test_2d_morphology_radius_regression,agree,row,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. ROI_RADIUS_MEAN is the one of the three no oracle reproduces, for a reason outside this family. ContourFeature::buildRegularContour reports every contour pixel one pixel right and one pixel down of where it is -- it never subtracts the pad it traces in -- so the distances are measured against a shifted boundary. Shifting the skimage reference boundary the same way reproduces the Nyxus values exactly: 2.7517 -> 2.8334 at R=10, 6.0470 -> 6.0871 at R=20, 0.3077 -> 0.6247 on shape2d, and on the 8x8 mask the traced contour IS that boundary shifted, all 18 coordinates. gen_morphology_radius_skimage.py prints the comparison on every run. MAX and MEDIAN are unmoved by that shift on a disk, which is why they carry oracle rows and MEAN does not; MEAN becomes vettable against the same reference once the contour offset is corrected." +2D,morphology,GEODETIC_LENGTH,vetted,imea,morphology.shape2d_native,exact,agreed,,,,,audit,imea,test_2d_morphology_geodetic_length_thickness_imea,,,1,0,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 22.971), a gap inherited from PERIMETER." +2D,morphology,THICKNESS,vetted,imea,morphology.shape2d_native,exact,agreed,,,,,audit,imea,test_2d_morphology_geodetic_length_thickness_imea,,,1,0,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 22.971), a gap inherited from PERIMETER." +2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.shape2d_native,rel=1e-3,disagrees,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,,skimage find_boundaries(mode='inner') + a minimum over it (gen_morphology_radius_skimage.py),in-tree,,,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,0,3,,test_2d_morphology_radius_regression,agree,row,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances. Drift guard on shape2d: 18 of the 26 pixels are edge pixels at distance 0 and the other 8 are one pixel from the edge, so MEAN = 8/26 = 0.3077. The scikit-image reference (find_boundaries(connectivity=1, mode='inner') plus a minimum over it) gives the same value; gen_morphology_radius_skimage.py prints it on every run." 2D,morphology,ROI_RADIUS_MAX,regression,,morphology.shape2d_native,rel=1e-3,na,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,test_2d_morphology_radius_regression,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row." 2D,morphology,ROI_RADIUS_MEDIAN,regression,,morphology.shape2d_native,rel=1e-3,na,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,1,3,,test_2d_morphology_radius_regression,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row." 2D,morphology,ROI_RADIUS_MAX,vetted,skimage,morphology.radius_disks,rel=1e-3,agreed,bench_radius_disks,TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,test_2d_morphology_roi_radius_disks_skimage,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d for the reason PERIMETER is vetted on the circles benchmark: on 26 pixels with a hole the two boundary conventions have nothing to converge to. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py." 2D,morphology,ROI_RADIUS_MEDIAN,vetted,skimage,morphology.radius_disks,rel=1e-3,agreed,bench_radius_disks,TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,1,3,,test_2d_morphology_roi_radius_disks_skimage,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d for the reason PERIMETER is vetted on the circles benchmark: on 26 pixels with a hole the two boundary conventions have nothing to converge to. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py. Unmoved by the contour offset on a disk, unlike ROI_RADIUS_MEAN: the pixel attaining it moves with the shift." 2D,morphology,ROI_RADIUS_MAX,vetted,analytic,morphology.radius_disks,exact,agreed,bench_radius_disks,TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISK_CLOSED_FORM_ANALYTIC,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,test_2d_morphology_roi_radius_disk_closed_form_analytic,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted against an exact closed form for a filled digital disk, not a band: ROI_RADIUS_MAX is the centre's distance to the nearest boundary pixel, and that pixel is at offset (1, R-1) -- inside since 1+(R-1)^2 <= R^2, boundary since its neighbour (1, R) is not, and nearer than the axial (R, 0) since R^2-2R+2 < R^2. So MAX == sqrt((R-1)^2+1), matched to 1e-12 relative at R = 10, 20, 40; the (1, R-1) pixel was confirmed nearest at R = 5, 10, 20, 40, 80 and 160. A second oracle alongside the skimage row (SPEC 3.1 redundancy), checking what that row cannot: that the value obeys the geometry rather than merely agreeing with a reference implementation that could share a mistake. The same form gives MAX/(R-1) = sqrt(1+1/(R-1)^2) -> 1, i.e. linear growth in R, where a squared distance would give ratios 9.1, 19.1, 39.0 and growing. Asserted in test_2d_morphology_analytic.h; no generator, the reference is geometry." -2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,,,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,0,3,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return 112 pixels on this fixture and they are not the same 112. Measured MEAN 6.087109772358636 -> 7.169174091182726 (+17.8%) and MEDIAN 5.0 -> 6.708203932499369 (+34%), while MAX is bit-identical at 19.026297590440446 on both paths -- so some ROI pixels are farther from the out-of-core contour and the pixel attaining the maximum is not among them. No translation of the inner boundary reproduces the out-of-core values, so this is pinned as a characterization rather than explained as an offset. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,ROI_RADIUS_MEDIAN,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,1,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return 112 pixels on this fixture and they are not the same 112. Measured MEAN 6.087109772358636 -> 7.169174091182726 (+17.8%) and MEDIAN 5.0 -> 6.708203932499369 (+34%), while MAX is bit-identical at 19.026297590440446 on both paths -- so some ROI pixels are farther from the out-of-core contour and the pixel attaining the maximum is not among them. No translation of the inner boundary reproduces the out-of-core values, so this is pinned as a characterization rather than explained as an offset. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,ROI_RADIUS_MAX,regression,,morphology.disk64_forced_ooc,rel=1e-9,agrees,bench_disk64_diagonal_boundary,,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return 112 pixels on this fixture and they are not the same 112. Measured MEAN 6.087109772358636 -> 7.169174091182726 (+17.8%) and MEDIAN 5.0 -> 6.708203932499369 (+34%), while MAX is bit-identical at 19.026297590440446 on both paths -- so some ROI pixels are farther from the out-of-core contour and the pixel attaining the maximum is not among them. No translation of the inner boundary reproduces the out-of-core values, so this is pinned as a characterization rather than explained as an offset. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest. This feature is the one of the three that AGREES across the two paths here; the row exists so the agreement is pinned and a fix that moved the whole contour could not break it silently." +2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,,,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,0,3,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,ROI_RADIUS_MEDIAN,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,1,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,ROI_RADIUS_MAX,regression,,morphology.disk64_forced_ooc,rel=1e-9,agrees,bench_disk64_diagonal_boundary,,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest. This feature is the one of the three that AGREES across the two paths here; the row exists so the agreement is pinned and a fix that moved the whole contour could not break it silently." 2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,,,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,0,3,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 24.490797504013532 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,ROI_RADIUS_MAX,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 45.254833995939045 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly 32*sqrt(2), the frame centre to a corner. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,ROI_RADIUS_MEDIAN,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,1,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 25.495097567963924 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly sqrt(650). No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,PERIMETER,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,skimage,test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_bad_contour,1,6,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,PERIMETER,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,analytic;opencv;skimage,test_2d_contour_blob97_opencv;test_2d_contour_line_analytic;test_2d_contour_ring_analytic;test_2d_contour_shape2d_opencv;test_2d_contour_single_pixel_analytic;test_2d_contour_square_analytic;test_2d_contour_two_fragments_analytic;test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_bad_contour,8,6,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,DIAMETER_EQUAL_PERIMETER,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,imea,test_2d_morphology_diameter_equal_perimeter_imea,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression,test_bad_contour,1,4,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,MASS_DISPLACEMENT,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_MEAN_INTENSITY,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant;test_bad_contour,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_STDDEV_INTENSITY,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,,,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,0,3,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_MAX_INTENSITY,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_MIN_INTENSITY,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,EDGE_INTEGRATED_INTENSITY,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,EDGE_INTEGRATED_INTENSITY,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,analytic;cellprofiler,test_2d_contour_line_analytic;test_2d_contour_single_pixel_analytic;test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,3,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,MASS_DISPLACEMENT,invariant,,morphology.disk64_forced_ooc,rel=1e-6,agreed,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_MEAN_INTENSITY,invariant,,morphology.disk64_forced_ooc,rel=1e-6,agreed,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant;test_bad_contour,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_STDDEV_INTENSITY,invariant,,morphology.disk64_forced_ooc,rel=1e-6,agreed,bench_disk64_diagonal_boundary,,,,in-tree,,,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,0,3,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_MAX_INTENSITY,invariant,,morphology.disk64_forced_ooc,rel=1e-6,agreed,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_MIN_INTENSITY,invariant,,morphology.disk64_forced_ooc,rel=1e-6,agreed,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,EDGE_INTEGRATED_INTENSITY,invariant,,morphology.disk64_forced_ooc,rel=1e-6,agreed,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,PERIMETER,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,skimage,test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_bad_contour,1,6,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc ContourFeature::osized_calculate sets fval_PERIMETER = K.size(), the contour pixel count, where calculate() sums Euclidean step lengths: 112.0 against 131.88225099390849 on the disk, ~15%, and 112 is exactly the fixture's edge-pixel count, which identifies the definition rather than accumulation as the cause. DIAMETER_EQUAL_PERIMETER is PERIMETER/pi on both paths (contour.cpp 976 and 1000) and inherits the same ratio exactly: 35.65070725258456 against 41.97942430353313. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Not caught before because the existing equality test's fixture is a full-image rectangle, where the pixel count and the Euclidean sum coincide. not_covered.md section G. Asserted by test_2d_ooc_perimeter_diverges_from_in_ram_regression / test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,EDGE_INTEGRATED_INTENSITY,invariant,,morphology.disk64_forced_ooc,rel=1e-6,agreed,bench_disk64_diagonal_boundary,,,,in-tree,analytic;cellprofiler,test_2d_contour_line_analytic;test_2d_contour_single_pixel_analytic;test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,3,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Out-of-core equals the in-RAM segmented cell exactly on a shape with a DIAGONAL boundary, recipe morphology.disk64_forced_ooc. status=invariant, not vetted: this asserts a required relation between two Nyxus code paths (CLAUDE.md requires the in-RAM and out-of-core paths to produce identical values), and no external tool is involved. The correct reading is 'out-of-core equals in-RAM, which is separately vetted', never 'out-of-core is correct'. The pre-existing *ALL_MORPHOLOGY* equality test could not establish this: its fixture is a full-image rectangle. Asserted by test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant in test_2d_ooc_invariant.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,PERIMETER,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,analytic;opencv;skimage,test_2d_contour_blob97_opencv;test_2d_contour_line_analytic;test_2d_contour_ring_analytic;test_2d_contour_shape2d_opencv;test_2d_contour_single_pixel_analytic;test_2d_contour_square_analytic;test_2d_contour_two_fragments_analytic;test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_bad_contour,8,6,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc ContourFeature::osized_calculate sets fval_PERIMETER = K.size(), the contour pixel count, where calculate() sums Euclidean step lengths: 112.0 against 131.88225099390849 on the disk, ~15%, and 112 is exactly the fixture's edge-pixel count, which identifies the definition rather than accumulation as the cause. DIAMETER_EQUAL_PERIMETER is PERIMETER/pi on both paths (contour.cpp 976 and 1000) and inherits the same ratio exactly: 35.65070725258456 against 41.97942430353313. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Not caught before because the existing equality test's fixture is a full-image rectangle, where the pixel count and the Euclidean sum coincide. not_covered.md section G. Asserted by test_2d_ooc_perimeter_diverges_from_in_ram_regression / test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,DIAMETER_EQUAL_PERIMETER,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,imea,test_2d_morphology_diameter_equal_perimeter_imea,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression,test_bad_contour,1,4,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc ContourFeature::osized_calculate sets fval_PERIMETER = K.size(), the contour pixel count, where calculate() sums Euclidean step lengths: 112.0 against 131.88225099390849 on the disk, ~15%, and 112 is exactly the fixture's edge-pixel count, which identifies the definition rather than accumulation as the cause. DIAMETER_EQUAL_PERIMETER is PERIMETER/pi on both paths (contour.cpp 976 and 1000) and inherits the same ratio exactly: 35.65070725258456 against 41.97942430353313. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Not caught before because the existing equality test's fixture is a full-image rectangle, where the pixel count and the Euclidean sum coincide. not_covered.md section G. Asserted by test_2d_ooc_perimeter_diverges_from_in_ram_regression / test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,MASS_DISPLACEMENT,vetted,cellprofiler,morphology.cellprofiler_wholeslide_massdisp,rel=1e-6,agreed,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs CellProfiler 4.2.8 MeasureObjectIntensity with ONE object over the full 64x64 frame, recipe morphology.cellprofiler_wholeslide_massdisp -- the configuration that matches SINGLEROI=true. Measured 3.345311793150965 against 3.3453118163885427, rel 7e-9. MASS_DISPLACEMENT is computed by BasicMorphologyFeatures::calculate() from the geometric and intensity-weighted centroids and reads no contour, so it never reaches buildWholeSlideContour() and is the same quantity in both tools whether the object is a disk or a full frame -- which is why this cell is oracle-backed while its EDGE_* siblings are not. Generator oracles/gen_morphology_wholeslide_cellprofiler.py reads the pins from the test. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_MEAN_INTENSITY,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant;test_bad_contour,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_STDDEV_INTENSITY,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,,,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,0,3,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_MAX_INTENSITY,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,EDGE_MIN_INTENSITY,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,EDGE_INTEGRATED_INTENSITY,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,PERIMETER,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,skimage,test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_bad_contour,1,6,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,EDGE_INTEGRATED_INTENSITY,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,analytic;cellprofiler,test_2d_contour_line_analytic;test_2d_contour_single_pixel_analytic;test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,3,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,EDGE_INTEGRATED_INTENSITY,vetted,analytic,contour.closed_forms,exact,agreed,,TEST_NYXUS.TEST_2D_CONTOUR_LINE_ANALYTIC,,,in-tree,analytic;cellprofiler,test_2d_contour_line_analytic;test_2d_contour_single_pixel_analytic;test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,3,2,,test_2d_contour_line_analytic,agree,row,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"closed form, recipe contour.closed_forms: on a one-pixel-wide line the border walk passes the inner pixels twice, and the statistic is the intensity summed over the 5 edge pixels once each." +2D,morphology,PERIMETER,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,analytic;opencv;skimage,test_2d_contour_blob97_opencv;test_2d_contour_line_analytic;test_2d_contour_ring_analytic;test_2d_contour_shape2d_opencv;test_2d_contour_single_pixel_analytic;test_2d_contour_square_analytic;test_2d_contour_two_fragments_analytic;test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_bad_contour,8,6,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,PERIMETER,vetted,opencv,contour.opencv_borders,exact,agreed,bench_blob97_filled,TEST_NYXUS.TEST_2D_CONTOUR_BLOB97_OPENCV,,,in-tree,analytic;opencv;skimage,test_2d_contour_blob97_opencv;test_2d_contour_line_analytic;test_2d_contour_ring_analytic;test_2d_contour_shape2d_opencv;test_2d_contour_single_pixel_analytic;test_2d_contour_square_analytic;test_2d_contour_two_fragments_analytic;test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_bad_contour,8,6,,test_2d_contour_blob97_opencv,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs OpenCV 4.13.0 cv2.findContours(RETR_LIST, CHAIN_APPROX_NONE), recipe contour.opencv_borders: the summed closed length of every border walk, 537.6639969244295 on the 9066-pixel blob, which has one border of 495 pixels; exact, the same border following on both sides. gen_contour_opencv.py." +2D,morphology,PERIMETER,vetted,opencv,morphology.shape2d_native,exact,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_CONTOUR_SHAPE2D_OPENCV,,,in-tree,analytic;opencv;skimage,test_2d_contour_blob97_opencv;test_2d_contour_line_analytic;test_2d_contour_ring_analytic;test_2d_contour_shape2d_opencv;test_2d_contour_single_pixel_analytic;test_2d_contour_square_analytic;test_2d_contour_two_fragments_analytic;test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_bad_contour,8,6,,test_2d_contour_shape2d_opencv,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs OpenCV 4.13.0 cv2.findContours(RETR_LIST, CHAIN_APPROX_NONE) on the shape2d mask: an outer border and the border of the one-pixel hole, 18 edge pixels, PERIMETER 22.970562748477146; exact. gen_contour_opencv.py, recipe contour.opencv_borders." +2D,morphology,PERIMETER,vetted,analytic,contour.closed_forms,exact,agreed,,TEST_NYXUS.TEST_2D_CONTOUR_RING_ANALYTIC,,,in-tree,analytic;opencv;skimage,test_2d_contour_blob97_opencv;test_2d_contour_line_analytic;test_2d_contour_ring_analytic;test_2d_contour_shape2d_opencv;test_2d_contour_single_pixel_analytic;test_2d_contour_square_analytic;test_2d_contour_two_fragments_analytic;test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_bad_contour,8,6,,test_2d_contour_ring_analytic,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"closed forms, recipe contour.closed_forms: 4(n-1) for an n x n square, 44 + 12 + 4*sqrt(2) for a 12x12 square with a centred 4x4 hole, 48 for two separate squares 6x6 and 8x8 under one label, 2(n-1) for a one-pixel line, 0 for a single pixel. The ring is the assertion test_name names; the other shapes are asserted beside it in the same file." 2D,morphology,DIAMETER_EQUAL_PERIMETER,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,imea,test_2d_morphology_diameter_equal_perimeter_imea,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression,test_bad_contour,1,4,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,neighbor,NUM_NEIGHBORS,vetted,cellprofiler,neighbor.scene2d_radius1,abs=1e-9,agreed,bench_scene7_5roi_enclosed,TEST_NYXUS.TEST_2D_NEIGHBOR_COUNTS_AND_FIRST_DISTANCE_CELLPROFILER,,,audit,cellprofiler,test_2d_neighbor_counts_and_first_distance_cellprofiler,test_2d_morphology_polygonality_hexagonality_regression,test_2d_neighbor_distance_and_percent_touching_invariant,1,1,"also pinned by test_2d_morphology_regression.h, which asserts it for label 1 while exercising an unrelated family",test_2d_neighbor_counts_and_first_distance_cellprofiler,agree,row,audit/neighbor_2d_cellprofiler_vetting_report.md,audit/neighbor_2d_golden_regen.md,matrix/neighbor.md,"vetted vs CellProfiler 4.2.8 MeasureObjectNeighbors (Adjacent method, neighbors_are_objects) on the neighborhood2d_scene_labels fixture at PIXELDISTANCE=1, recipe neighbor.scene2d_radius1; agreement is EXACTLY 0 -- CP reproduces Nyxus bit for bit on all five ROIs -- asserted per ROI at abs=1e-9, SPEC 7's exact tier in test_2d_neighbor_cellprofiler.h. Re-measured 2026-08-20: the previous assertion was ASSERT_NEAR at 1e-4 absolute with no tolerance recorded here; an intermediate rel=1e-9 was corrected to the absolute band SPEC 7 defines. gen_neighbor_cellprofiler.py re-verifies every pin in the header it feeds; tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md" 2D,neighbor,PERCENT_TOUCHING,regression,,neighbor.scene2d_radius1,rel=1e-3,disagrees,bench_scene7_5roi_enclosed,TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_REGRESSION;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_BOUNDED_INVARIANT;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_ENCLOSED_INVARIANT,documented-convention,none (CP definition differs; empirically 3/5 ROIs),audit,,,test_2d_neighbor_percent_touching_regression,test_2d_neighbor_distance_and_percent_touching_invariant;test_2d_neighbor_percent_touching_bounded_invariant;test_2d_neighbor_percent_touching_enclosed_invariant;test_2d_neighbor_percent_touching_fully_enclosed_invariant,0,1,"no oracle by definition, not by defect: CellProfiler measures a different quantity and diverges on 3/5 ROIs. Drift-pinned, with its construction bounds asserted as invariants",test_2d_neighbor_percent_touching_bounded_invariant;test_2d_neighbor_percent_touching_enclosed_invariant;test_2d_neighbor_percent_touching_regression,agree,row,,audit/neighbor_2d_golden_regen.md,matrix/neighbor.md,"no promotable oracle, by definition rather than by defect. Nyxus counts distinct contour pixels 8-adjacent to a neighbour over contour length; CellProfiler counts object outline pixels overlapping a disk(distance+0.5)-dilated neighbour over perimeter, and no CP distance method (Adjacent/Expand/Within) reproduces it -- measured divergence on 3 of the 5 ROIs, up to 33.3 percentage points. Drift-pinned per ROI at rel=1e-3 in test_2d_neighbor_regression.h; the bounds it must obey by construction (0 <= PT <= 100, and exactly 100 for a fully enclosed ROI) are asserted in test_2d_neighbor_invariant.h. Those bound assertions previously carried an _analytic suffix, which credited this feature with an oracle it does not have. tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md" @@ -372,9 +376,9 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,ngtdm,NGTDM_BUSYNESS,regression,,ngtdm.default_fbn100,rel=1e-3,na,ibsi_digital_phantom,TEST_NYXUS.TEST_2D_NGTDM_BUSYNESS_REGRESSION,,,audit,ibsi;mirp,test_2d_ngtdm_busyness_ibsi;test_2d_ngtdm_busyness_mirp,test_2d_ngtdm_busyness_regression,test_2d_ngtdm_ibsi_mode_ignores_n_levels_mechanics,2,1,"asserted against both oracles by design: IBSI fixes the definition at its published 3-significant-figure precision, mirp fixes the digits at 3.2e-16, with a PyRadiomics run corroborating mirp to 1.6e-16",test_2d_ngtdm_busyness_regression,agree,row,audit/ngtdm_2d_ibsi_vetting_report.md,audit/ngtdm_2d_golden_regen.md,matrix/ngtdm.md,"Nyxus' default mode (IBSI=false) with NGTDMFeature::n_levels=100, recipe ngtdm.default_fbn100 -- a different config point from the vetted row above, not a second assertion on the same one. Default mode bins the phantom to a fixed grey count instead of using its own levels, which no reference reproduces: NGTDM_CONTRAST is 0.925 in IBSI mode and 3169.93 here. Drift-pinned only, in test_2d_ngtdm_regression.h at rel=1e-3, with no oracle claim. The grey count is part of the recipe and is passed explicitly: at the default n_levels=0 the same fixture gives NGTDM_CONTRAST 6634.50. tests/vetting/audit/ngtdm_2d_mirp_vetting_report.md" 2D,ngtdm,NGTDM_COMPLEXITY,regression,,ngtdm.default_fbn100,rel=1e-3,na,ibsi_digital_phantom,TEST_NYXUS.TEST_2D_NGTDM_COMPLEXITY_REGRESSION,,,audit,ibsi;mirp,test_2d_ngtdm_complexity_ibsi;test_2d_ngtdm_complexity_mirp,test_2d_ngtdm_complexity_regression,test_2d_ngtdm_ibsi_mode_ignores_n_levels_mechanics,2,1,"asserted against both oracles by design: IBSI fixes the definition at its published 3-significant-figure precision, mirp fixes the digits at 3.2e-16, with a PyRadiomics run corroborating mirp to 1.6e-16",test_2d_ngtdm_complexity_regression,agree,row,audit/ngtdm_2d_ibsi_vetting_report.md,audit/ngtdm_2d_golden_regen.md,matrix/ngtdm.md,"Nyxus' default mode (IBSI=false) with NGTDMFeature::n_levels=100, recipe ngtdm.default_fbn100 -- a different config point from the vetted row above, not a second assertion on the same one. Default mode bins the phantom to a fixed grey count instead of using its own levels, which no reference reproduces: NGTDM_CONTRAST is 0.925 in IBSI mode and 3169.93 here. Drift-pinned only, in test_2d_ngtdm_regression.h at rel=1e-3, with no oracle claim. The grey count is part of the recipe and is passed explicitly: at the default n_levels=0 the same fixture gives NGTDM_CONTRAST 6634.50. tests/vetting/audit/ngtdm_2d_mirp_vetting_report.md" 2D,ngtdm,NGTDM_STRENGTH,regression,,ngtdm.default_fbn100,rel=1e-3,na,ibsi_digital_phantom,TEST_NYXUS.TEST_2D_NGTDM_STRENGTH_REGRESSION,,,audit,ibsi;mirp,test_2d_ngtdm_strength_ibsi;test_2d_ngtdm_strength_mirp,test_2d_ngtdm_strength_regression,test_2d_ngtdm_ibsi_mode_ignores_n_levels_mechanics,2,1,"asserted against both oracles by design: IBSI fixes the definition at its published 3-significant-figure precision, mirp fixes the digits at 3.2e-16, with a PyRadiomics run corroborating mirp to 1.6e-16",test_2d_ngtdm_strength_regression,agree,row,audit/ngtdm_2d_ibsi_vetting_report.md,audit/ngtdm_2d_golden_regen.md,matrix/ngtdm.md,"Nyxus' default mode (IBSI=false) with NGTDMFeature::n_levels=100, recipe ngtdm.default_fbn100 -- a different config point from the vetted row above, not a second assertion on the same one. Default mode bins the phantom to a fixed grey count instead of using its own levels, which no reference reproduces: NGTDM_CONTRAST is 0.925 in IBSI mode and 3169.93 here. Drift-pinned only, in test_2d_ngtdm_regression.h at rel=1e-3, with no oracle claim. The grey count is part of the recipe and is passed explicitly: at the default n_levels=0 the same fixture gives NGTDM_CONTRAST 6634.50. tests/vetting/audit/ngtdm_2d_mirp_vetting_report.md" -2D,radial,FRAC_AT_D,regression,,radial.shape2d_native,rel=1e-9,disagreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_RADIAL_DISTRIBUTION_REGRESSION;TEST_NYXUS.TEST_2D_RADIAL_BIN_CONVENTIONS_REGRESSION;TEST_NYXUS.TEST_2D_RADIAL_FRAC_AT_D_IS_A_PARTITION_INVARIANT;TEST_NYXUS.TEST_2D_RADIAL_EMPTY_BINS_ARE_ZERO_INVARIANT,convention-mismatch,"cellprofiler:MeasureObjectIntensityDistribution (RadialDistribution_*) -- run 2026-08-20, does not vet",in-tree,,,test_2d_radial_bin_conventions_regression;test_2d_radial_distribution_regression,test_2d_radial_bin_index_mechanics;test_2d_radial_empty_bins_are_zero_invariant;test_2d_radial_frac_at_d_is_a_partition_invariant,0,2,"8-bin vector, every bin asserted separately. Not CellProfiler-vettable: CP's FracAtD is the fraction of the ROI's INTENSITY in a bin, Nyxus' is the fraction of its PIXEL COUNT and never reads the image.",test_2d_radial_bin_conventions_regression;test_2d_radial_distribution_regression;test_2d_radial_empty_bins_are_zero_invariant;test_2d_radial_frac_at_d_is_a_partition_invariant,agree,row,audit/radial_2d_cellprofiler_vetting_report.md,audit/radial_2d_golden_regen.md,matrix/radial.md,"Not CellProfiler-vettable: CP's FracAtD is the fraction of the ROI's intensity in a bin, Nyxus' is the fraction of its pixel count and never reads the image. Full run, numbers and the six divergences in tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md; generator tests/vetting/oracles/gen_radial_cellprofiler.py. 8-bin vector, every bin asserted separately at rel=1e-9 by TEST_2D_RADIAL_DISTRIBUTION_REGRESSION against a measured residual of 0; the pixel-count convention that makes each entry a whole-count fraction is characterized by TEST_2D_RADIAL_BIN_CONVENTIONS_REGRESSION; the definition-independent partition and empty-bin properties by the two invariants named in test_name. test_2d_radial_mechanics.h pins the contour frame, centre and radius the table depends on and is deliberately credited to no feature - it characterizes known defects (report §6) that a fix must change, see not_covered.md §A.1." -2D,gabor,GABOR,vetted,skimage,gabor.cpp_static_defaults,rel=1e-3,agreed,bench_dsb2018_2d,TEST_NYXUS.TEST_2D_GABOR_CPP_STATIC_DEFAULTS_SKIMAGE,,,in-tree,skimage,test_2d_gabor_cpp_static_defaults_skimage;test_2d_gabor_python_raw_defaults_skimage,,test_2d_gabor_gpu_runs_mechanics,2,0,"two config points, both oracle-asserted on bench_dsb2018_2d. skimage supplies the KERNEL; the f0=0 member and the WND-CHARM count-ratio SCORE are analytic (skimage has no native equivalent of the score, and gabor_kernel cannot express frequency 0). No regression file exists: every pinned value carries an oracle claim. The GPU path carries no oracle claim and is guarded size-only by the mechanics case",test_2d_gabor_cpp_static_defaults_skimage,agree,row+config,audit/gabor_2d_skimage_vetting_report.md,audit/gabor_2d_golden_regen.md,matrix/gabor.md,"Vetted vs scikit-image 0.26.0 at the compiled-in GaborFeature::f0_theta_pairs default (f0={0,pi/4,pi/2,3pi/4}, theta={4,16,32,64} rad) - what a run that sets no Gabor options computes, including the gtest fixture and a CLI run without --gaborfreqs/--gabortheta. gen_gabor_skimage.py re-verifies all 16 pins in this table (max |diff| 0.000e+00) and cross-checks the kernel mapping against the closed form (<=2.6e-18). SCOPE 1: the f0=0 baseline filter is the one kernel not taken from skimage - gabor_kernel cannot take frequency 0, and there the envelope and carrier are both identically 1, so it is the flat window in closed form (oracle=analytic for that member). SCOPE of oracle=skimage (SPEC 4 allows one token per row; the claim is narrower than the token): skimage supplies the KERNEL at the non-degenerate (f0,theta) points - gabor_kernel(frequency=f0/2pi, sigma_x=sig2lam*2pi/f0, sigma_y=sigma_x/gamma, offset=0) cropped to the Nyxus 16x16 grid - while the count-ratio SCORE is Nyxus' own WND-CHARM definition reproduced in the generator, because skimage has no native equivalent to compare the whole feature against. A full-feature oracle would be wndcharm; not built in this tree, recorded in not_covered.md. SCOPE 2: the GPU path is NOT vetted - calculate_gpu (FFT convolution) diverges from the direct-convolution CPU path on these ROIs; the assertion covers the in-RAM CPU path only, and the GPU path's plumbing guard is TEST_NYXUS.TEST_2D_GABOR_GPU_RUNS_MECHANICS. SCOPE 3: the 16x16 kernel crop is part of the recipe rather than a detail - at gamma=0.1 only 7.9-47.6 percent of the kernel's L1 mass falls inside that window at the low frequencies, and scoring off skimage's own filtering (skimage.filters.gabor, untruncated support, zero-padded border) moves values by up to 1.005; generator part D2 re-runs that control. See audit/gabor_2d_skimage_vetting_report.md." -2D,gabor,GABOR,vetted,skimage,gabor.python_raw_defaults,rel=1e-3,agreed,bench_dsb2018_2d,TEST_NYXUS.TEST_2D_GABOR_PYTHON_RAW_DEFAULTS_SKIMAGE,,,in-tree,skimage,test_2d_gabor_cpp_static_defaults_skimage;test_2d_gabor_python_raw_defaults_skimage,,test_2d_gabor_gpu_runs_mechanics,2,0,"two config points, both oracle-asserted on bench_dsb2018_2d. skimage supplies the KERNEL; the f0=0 member and the WND-CHARM count-ratio SCORE are analytic (skimage has no native equivalent of the score, and gabor_kernel cannot express frequency 0). No regression file exists: every pinned value carries an oracle claim. The GPU path carries no oracle claim and is guarded size-only by the mechanics case",test_2d_gabor_python_raw_defaults_skimage,agree,row+config,audit/gabor_2d_skimage_vetting_report.md,audit/gabor_2d_golden_regen.md,matrix/gabor.md,"Vetted vs scikit-image 0.26.0 at the default lists gabor_freqs=[4,16,32,64], gabor_thetas=[0,45,90,135] deg as GaborOptions::parse_input builds them (f0={4,16,32,64}, theta={0,pi/4,pi/2,3pi/4} rad) - the config the Python API always runs and a CLI run that passes both flags. All 16 pins reproduced exactly by gen_gabor_skimage.py (max |diff| 0.000e+00). This config point was added 2026-08 because the family's only vetted row covered gabor.cpp_static_defaults, a different frequency and angle set whose values differ from this one by up to 0.84 absolute on the same ROI - so no in-tree assertion covered what users compute. UNITS: the recipe is named 'python_raw' because parse_input converts the angles (deg->rad) and passes the frequencies through untouched, while nyxus.py and the CLI help both document gabor_freqs as denominators of pi and nothing in the tree divides by pi; the assertion covers the numbers that reach the filter, not the documented units contract, which is an unresolved source question (vetting report section 8 item 4). SCOPE of oracle=skimage (SPEC 4 allows one token per row; the claim is narrower than the token): skimage supplies the KERNEL at the non-degenerate (f0,theta) points - gabor_kernel(frequency=f0/2pi, sigma_x=sig2lam*2pi/f0, sigma_y=sigma_x/gamma, offset=0) cropped to the Nyxus 16x16 grid - while the count-ratio SCORE is Nyxus' own WND-CHARM definition reproduced in the generator, because skimage has no native equivalent to compare the whole feature against. A full-feature oracle would be wndcharm; not built in this tree, recorded in not_covered.md. SCOPE 2: the GPU path is NOT vetted - calculate_gpu (FFT convolution) diverges from the direct-convolution CPU path on these ROIs; the assertion covers the in-RAM CPU path only, and the GPU path's plumbing guard is TEST_NYXUS.TEST_2D_GABOR_GPU_RUNS_MECHANICS. SCOPE 3: the 16x16 kernel crop is part of the recipe rather than a detail - at gamma=0.1 only 7.9-47.6 percent of the kernel's L1 mass falls inside that window at the low frequencies, and scoring off skimage's own filtering (skimage.filters.gabor, untruncated support, zero-padded border) moves values by up to 1.005; generator part D2 re-runs that control. See audit/gabor_2d_skimage_vetting_report.md." +2D,radial,FRAC_AT_D,regression,,radial.shape2d_native,rel=1e-9,disagreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_RADIAL_DISTRIBUTION_REGRESSION;TEST_NYXUS.TEST_2D_RADIAL_BIN_CONVENTIONS_REGRESSION;TEST_NYXUS.TEST_2D_RADIAL_FRAC_AT_D_IS_A_PARTITION_INVARIANT;TEST_NYXUS.TEST_2D_RADIAL_EMPTY_BINS_ARE_ZERO_INVARIANT,convention-mismatch,"cellprofiler:MeasureObjectIntensityDistribution (RadialDistribution_*) -- run 2026-08-20, does not vet",in-tree,,,test_2d_radial_bin_conventions_regression;test_2d_radial_distribution_regression,test_2d_radial_bin_index_mechanics;test_2d_radial_empty_bins_are_zero_invariant;test_2d_radial_frac_at_d_is_a_partition_invariant;test_2d_radial_single_pixel_roi_mechanics;test_2d_radial_two_fragments_mechanics;test_2d_radial_two_pixel_roi_mechanics,0,2,"8-bin vector, every bin asserted separately. Not CellProfiler-vettable: CP's FracAtD is the fraction of the ROI's INTENSITY in a bin, Nyxus' is the fraction of its PIXEL COUNT and never reads the image.",test_2d_radial_bin_conventions_regression;test_2d_radial_distribution_regression;test_2d_radial_empty_bins_are_zero_invariant;test_2d_radial_frac_at_d_is_a_partition_invariant,agree,row,audit/radial_2d_cellprofiler_vetting_report.md,audit/radial_2d_golden_regen.md,matrix/radial.md,"Not CellProfiler-vettable: CP's FracAtD is the fraction of the ROI's intensity in a bin, Nyxus' is the fraction of its pixel count and never reads the image. Full run, numbers and the six divergences in tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md; generator tests/vetting/oracles/gen_radial_cellprofiler.py. 8-bin vector, every bin asserted separately at rel=1e-9 by TEST_2D_RADIAL_DISTRIBUTION_REGRESSION against a measured residual of 0; the pixel-count convention that makes each entry a whole-count fraction is characterized by TEST_2D_RADIAL_BIN_CONVENTIONS_REGRESSION; the definition-independent partition and empty-bin properties by the two invariants named in test_name. test_2d_radial_mechanics.h pins the contour frame, centre and radius the table depends on and is deliberately credited to no feature - it characterizes known defects (report §6) that a fix must change, see not_covered.md §A.1." +2D,gabor,GABOR,vetted,skimage,gabor.cpp_static_defaults,rel=1e-3,agreed,bench_dsb2018_2d,TEST_NYXUS.TEST_2D_GABOR_CPP_STATIC_DEFAULTS_SKIMAGE,,,in-tree,skimage,test_2d_gabor_cpp_static_defaults_skimage;test_2d_gabor_python_raw_defaults_skimage,,test_2d_gabor_gpu_runs_mechanics;test_2d_gabor_gpu_unready_cache_mechanics,2,0,"two config points, both oracle-asserted on bench_dsb2018_2d. skimage supplies the KERNEL; the f0=0 member and the WND-CHARM count-ratio SCORE are analytic (skimage has no native equivalent of the score, and gabor_kernel cannot express frequency 0). No regression file exists: every pinned value carries an oracle claim. The GPU path carries no oracle claim and is guarded size-only by the mechanics case",test_2d_gabor_cpp_static_defaults_skimage,agree,row+config,audit/gabor_2d_skimage_vetting_report.md,audit/gabor_2d_golden_regen.md,matrix/gabor.md,"Vetted vs scikit-image 0.26.0 at the compiled-in GaborFeature::f0_theta_pairs default (f0={0,pi/4,pi/2,3pi/4}, theta={4,16,32,64} rad) - what a run that sets no Gabor options computes, including the gtest fixture and a CLI run without --gaborfreqs/--gabortheta. gen_gabor_skimage.py re-verifies all 16 pins in this table (max |diff| 0.000e+00) and cross-checks the kernel mapping against the closed form (<=2.6e-18). SCOPE 1: the f0=0 baseline filter is the one kernel not taken from skimage - gabor_kernel cannot take frequency 0, and there the envelope and carrier are both identically 1, so it is the flat window in closed form (oracle=analytic for that member). SCOPE of oracle=skimage (SPEC 4 allows one token per row; the claim is narrower than the token): skimage supplies the KERNEL at the non-degenerate (f0,theta) points - gabor_kernel(frequency=f0/2pi, sigma_x=sig2lam*2pi/f0, sigma_y=sigma_x/gamma, offset=0) cropped to the Nyxus 16x16 grid - while the count-ratio SCORE is Nyxus' own WND-CHARM definition reproduced in the generator, because skimage has no native equivalent to compare the whole feature against. A full-feature oracle would be wndcharm; not built in this tree, recorded in not_covered.md. SCOPE 2: the GPU path is NOT vetted - calculate_gpu (FFT convolution) diverges from the direct-convolution CPU path on these ROIs; the assertion covers the in-RAM CPU path only, and the GPU path's plumbing guard is TEST_NYXUS.TEST_2D_GABOR_GPU_RUNS_MECHANICS. SCOPE 3: the 16x16 kernel crop is part of the recipe rather than a detail - at gamma=0.1 only 7.9-47.6 percent of the kernel's L1 mass falls inside that window at the low frequencies, and scoring off skimage's own filtering (skimage.filters.gabor, untruncated support, zero-padded border) moves values by up to 1.005; generator part D2 re-runs that control. See audit/gabor_2d_skimage_vetting_report.md." +2D,gabor,GABOR,vetted,skimage,gabor.python_raw_defaults,rel=1e-3,agreed,bench_dsb2018_2d,TEST_NYXUS.TEST_2D_GABOR_PYTHON_RAW_DEFAULTS_SKIMAGE,,,in-tree,skimage,test_2d_gabor_cpp_static_defaults_skimage;test_2d_gabor_python_raw_defaults_skimage,,test_2d_gabor_gpu_runs_mechanics;test_2d_gabor_gpu_unready_cache_mechanics,2,0,"two config points, both oracle-asserted on bench_dsb2018_2d. skimage supplies the KERNEL; the f0=0 member and the WND-CHARM count-ratio SCORE are analytic (skimage has no native equivalent of the score, and gabor_kernel cannot express frequency 0). No regression file exists: every pinned value carries an oracle claim. The GPU path carries no oracle claim and is guarded size-only by the mechanics case",test_2d_gabor_python_raw_defaults_skimage,agree,row+config,audit/gabor_2d_skimage_vetting_report.md,audit/gabor_2d_golden_regen.md,matrix/gabor.md,"Vetted vs scikit-image 0.26.0 at the default lists gabor_freqs=[4,16,32,64], gabor_thetas=[0,45,90,135] deg as GaborOptions::parse_input builds them (f0={4,16,32,64}, theta={0,pi/4,pi/2,3pi/4} rad) - the config the Python API always runs and a CLI run that passes both flags. All 16 pins reproduced exactly by gen_gabor_skimage.py (max |diff| 0.000e+00). This config point was added 2026-08 because the family's only vetted row covered gabor.cpp_static_defaults, a different frequency and angle set whose values differ from this one by up to 0.84 absolute on the same ROI - so no in-tree assertion covered what users compute. UNITS: the recipe is named 'python_raw' because parse_input converts the angles (deg->rad) and passes the frequencies through untouched, while nyxus.py and the CLI help both document gabor_freqs as denominators of pi and nothing in the tree divides by pi; the assertion covers the numbers that reach the filter, not the documented units contract, which is an unresolved source question (vetting report section 8 item 4). SCOPE of oracle=skimage (SPEC 4 allows one token per row; the claim is narrower than the token): skimage supplies the KERNEL at the non-degenerate (f0,theta) points - gabor_kernel(frequency=f0/2pi, sigma_x=sig2lam*2pi/f0, sigma_y=sigma_x/gamma, offset=0) cropped to the Nyxus 16x16 grid - while the count-ratio SCORE is Nyxus' own WND-CHARM definition reproduced in the generator, because skimage has no native equivalent to compare the whole feature against. A full-feature oracle would be wndcharm; not built in this tree, recorded in not_covered.md. SCOPE 2: the GPU path is NOT vetted - calculate_gpu (FFT convolution) diverges from the direct-convolution CPU path on these ROIs; the assertion covers the in-RAM CPU path only, and the GPU path's plumbing guard is TEST_NYXUS.TEST_2D_GABOR_GPU_RUNS_MECHANICS. SCOPE 3: the 16x16 kernel crop is part of the recipe rather than a detail - at gamma=0.1 only 7.9-47.6 percent of the kernel's L1 mass falls inside that window at the low frequencies, and scoring off skimage's own filtering (skimage.filters.gabor, untruncated support, zero-padded border) moves values by up to 1.005; generator part D2 re-runs that control. See audit/gabor_2d_skimage_vetting_report.md." 2D,radial,MEAN_FRAC,regression,,radial.shape2d_native,rel=1e-9,disagreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_RADIAL_DISTRIBUTION_REGRESSION;TEST_NYXUS.TEST_2D_RADIAL_BIN_CONVENTIONS_REGRESSION;TEST_NYXUS.TEST_2D_RADIAL_EMPTY_BINS_ARE_ZERO_INVARIANT,convention-mismatch,"cellprofiler:MeasureObjectIntensityDistribution (RadialDistribution_*) -- run 2026-08-20, does not vet",in-tree,,,test_2d_radial_bin_conventions_regression;test_2d_radial_distribution_regression,test_2d_radial_bin_index_mechanics;test_2d_radial_empty_bins_are_zero_invariant,0,2,"8-bin vector, every bin asserted separately. Not CellProfiler-vettable: CP's MeanFrac is dimensionless (bin mean over ROI mean, ~1), Nyxus returns the raw bin mean intensity.",test_2d_radial_bin_conventions_regression;test_2d_radial_distribution_regression;test_2d_radial_empty_bins_are_zero_invariant,agree,row,audit/radial_2d_cellprofiler_vetting_report.md,audit/radial_2d_golden_regen.md,matrix/radial.md,"Not CellProfiler-vettable: CP's MeanFrac is normalised by the ROI mean intensity and lands near 1, Nyxus' is the bin's raw mean intensity. Full run, numbers and the six divergences in tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md; generator tests/vetting/oracles/gen_radial_cellprofiler.py. 8-bin vector, every bin asserted separately at rel=1e-9 by TEST_2D_RADIAL_DISTRIBUTION_REGRESSION against a measured residual of 0; the raw-bin-mean convention (intensity range, and the reconstruction of the ROI total together with FRAC_AT_D) is characterized by TEST_2D_RADIAL_BIN_CONVENTIONS_REGRESSION, not asserted as an invariant, because CellProfiler's normalised MeanFrac satisfies neither. test_2d_radial_mechanics.h pins the contour frame, centre and radius the table depends on and is deliberately credited to no feature - it characterizes known defects (report §6) that a fix must change, see not_covered.md §A.1." 2D,radial,RADIAL_CV,regression,,radial.shape2d_native,rel=1e-9,disagreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_RADIAL_DISTRIBUTION_REGRESSION;TEST_NYXUS.TEST_2D_RADIAL_CV_IS_WITHIN_ITS_BOUND_INVARIANT;TEST_NYXUS.TEST_2D_RADIAL_EMPTY_BINS_ARE_ZERO_INVARIANT,convention-mismatch,"cellprofiler:MeasureObjectIntensityDistribution (RadialDistribution_*) -- run 2026-08-20, does not vet",in-tree,,,test_2d_radial_distribution_regression,test_2d_radial_bin_index_mechanics;test_2d_radial_cv_is_within_its_bound_invariant;test_2d_radial_empty_bins_are_zero_invariant,0,1,"8-bin vector, every bin asserted separately. Not CellProfiler-vettable: CP takes the CV of the eight wedge MEANS over the NON-EMPTY wedges, Nyxus the CV of the eight wedge SUMS over all eight.",test_2d_radial_cv_is_within_its_bound_invariant;test_2d_radial_distribution_regression;test_2d_radial_empty_bins_are_zero_invariant,agree,row,audit/radial_2d_cellprofiler_vetting_report.md,audit/radial_2d_golden_regen.md,matrix/radial.md,"Not CellProfiler-vettable: CP takes the CV of the 8 wedge means over the non-empty wedges, Nyxus the CV of the 8 wedge sums over all 8. Full run, numbers and the six divergences in tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md; generator tests/vetting/oracles/gen_radial_cellprofiler.py. 8-bin vector, every bin asserted separately at rel=1e-9 by TEST_2D_RADIAL_DISTRIBUTION_REGRESSION against a measured residual of 0; the sqrt(num_bins-1) bound and the empty-bin zero hold under either wedge convention and are asserted by the two invariants named in test_name. test_2d_radial_mechanics.h pins the contour frame, centre and radius the table depends on and is deliberately credited to no feature - it characterizes known defects (report §6) that a fix must change, see not_covered.md §A.1." 2D,zernike,ZERNIKE2D,vetted,analytic,zernike.shape2d_native,rel=1e-12,agreed,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_ZERNIKE_MOMENTS_ANALYTIC,,"cellprofiler:MeasureObjectIntensityDistribution (ZernikeMagnitude) -- run 2026-08-20, different disk and normalisation, not comparable",in-tree,analytic,test_2d_zernike_moments_analytic,test_2d_zernike_moments_regression,test_2d_zernike_every_pixel_is_inside_the_unit_disk_mechanics;test_2d_zernike_first_moment_about_the_centroid_vanishes_invariant;test_2d_zernike_geometry_mechanics;test_2d_zernike_index_set_matches_the_declared_count_invariant;test_2d_zernike_magnitudes_are_within_their_bound_invariant;test_2d_zernike_zeroth_moment_is_one_over_pi_invariant,1,1,"30 magnitudes, one per (n,m) with n<=9 and n-m even; every entry asserted separately. Vetted against the closed form (factorial series for R_nm) at ZernikeFeature's own geometry, which tests the Singh & Walia recurrence rather than restating it; centrosome's polynomials corroborate to 4.9e-15. NOT comparable to CellProfiler's Zernikes -- different disk and normalisation.",test_2d_zernike_moments_analytic,agree,row,audit/zernike_2d_analytic_vetting_report.md,audit/zernike_2d_golden_regen.md,,"Vetted against the closed form: the factorial series for R_nm evaluated at ZernikeFeature's own geometry (unit disk on the intensity centroid, radius min(bbox w,h)). zernike.cpp uses the Singh & Walia recurrence instead, so the agreement tests the recurrence rather than restating it; centrosome's own polynomials corroborate to 4.9e-15. Measured residual 3.4e-15 worst, banded rel=1e-12 with a 1e-15 floor because (n=1,m=1) is a mathematical zero. This PR fixes a defect that made all 30 values wrong -- the angular tables were seeded at cos(theta) instead of cos(0), so every moment of repetition m used cos((m+1)*theta); see tests/vetting/audit/zernike_2d_analytic_vetting_report.md. CellProfiler's own Zernikes are NOT comparable (minimum-enclosing-circle disk, normalised by pixel count)." @@ -437,37 +441,37 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,moments,HU_M5,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_hu_wedge_skimage;test_2d_moments_shape_skimage,,,2,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." 2D,moments,HU_M6,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_hu_wedge_skimage;test_2d_moments_shape_skimage,,,2,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." 2D,moments,HU_M7,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_hu_wedge_skimage;test_2d_moments_shape_skimage,,,2,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." -2D,moments,WEIGHTED_SPAT_MOMENT_00,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_SPAT_MOMENT_01,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_SPAT_MOMENT_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_SPAT_MOMENT_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_SPAT_MOMENT_10,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_SPAT_MOMENT_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_SPAT_MOMENT_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_SPAT_MOMENT_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_SPAT_MOMENT_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_SPAT_MOMENT_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_CENTRAL_MOMENT_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_CENTRAL_MOMENT_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_CENTRAL_MOMENT_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_CENTRAL_MOMENT_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_CENTRAL_MOMENT_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_CENTRAL_MOMENT_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_CENTRAL_MOMENT_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WT_NORM_CTR_MOM_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WT_NORM_CTR_MOM_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WT_NORM_CTR_MOM_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WT_NORM_CTR_MOM_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WT_NORM_CTR_MOM_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WT_NORM_CTR_MOM_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WT_NORM_CTR_MOM_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_HU_M1,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_HU_M2,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_HU_M3,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_HU_M4,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_HU_M5,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_HU_M6,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,WEIGHTED_HU_M7,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_00,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_01,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_10,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_CENTRAL_MOMENT_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_CENTRAL_MOMENT_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_CENTRAL_MOMENT_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_CENTRAL_MOMENT_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_CENTRAL_MOMENT_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_CENTRAL_MOMENT_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_CENTRAL_MOMENT_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WT_NORM_CTR_MOM_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WT_NORM_CTR_MOM_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WT_NORM_CTR_MOM_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WT_NORM_CTR_MOM_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WT_NORM_CTR_MOM_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WT_NORM_CTR_MOM_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WT_NORM_CTR_MOM_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_HU_M1,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_HU_M2,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_HU_M3,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_HU_M4,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_HU_M5,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_HU_M6,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_HU_M7,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." 2D,moments,IMOM_RM_00,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_intensity_skimage,,,1,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." 2D,moments,IMOM_RM_01,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_intensity_skimage,,,1,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." 2D,moments,IMOM_RM_02,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_intensity_skimage,,,1,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." @@ -527,37 +531,37 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,moments,IMOM_HU5,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_intensity_skimage,,,1,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." 2D,moments,IMOM_HU6,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_intensity_skimage,,,1,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." 2D,moments,IMOM_HU7,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_intensity_skimage,,,1,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." -2D,moments,IMOM_WRM_00,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WRM_01,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WRM_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WRM_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WRM_10,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WRM_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WRM_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WRM_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WRM_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WRM_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WCM_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WCM_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WCM_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WCM_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WCM_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WCM_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WCM_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WNCM_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WNCM_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WNCM_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WNCM_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WNCM_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WNCM_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WNCM_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WHU1,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WHU2,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WHU3,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WHU4,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WHU5,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WHU6,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." -2D,moments,IMOM_WHU7,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 2498.7137504625134 - a 1.372x / 37.2% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_00,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_01,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_10,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WCM_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WCM_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WCM_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WCM_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WCM_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WCM_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WCM_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WNCM_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WNCM_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WNCM_11,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WNCM_12,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WNCM_20,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WNCM_21,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WNCM_30,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WHU1,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WHU2,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WHU3,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WHU4,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WHU5,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WHU6,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WHU7,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." 2D,intensity_histogram,HISTOGRAM,vetted,analytic,,rel=1e-4,agreed,,,,,in-tree,analytic,test_2d_intensity_histogram_bin_counts_analytic,,,1,0,"PixelIntensityFeatures, not the scalar IH_* class; opt-in via *ALL* and not IBSI-gated, so it sits outside recipe ih.ibsi_fbn",,agree,feature,audit/intensity_histogram_2d_analytic_vetting_report.md,audit/intensity_histogram_2d_golden_regen.md,,"Analytic vetting: PixelIntensityFeatures::val_HISTOGRAM = TrivialHistogram::get_cust_frequencies(n_greybins), raw per-bin counts over N equal-width bins spanning [min,max] (top-inclusive, Nyxus::to_grayscale binning). Hand-derived expected vector [2,1,2] for fixture {1,1,3,5,7} with N=3 bins, asserted in test_2d_intensity_histogram_bin_counts_analytic. Distinct feature class from the scalar IH_* family: opt-in via *ALL*, not IBSI-gated, hence no ih.ibsi_fbn recipe." 2D,intensity_histogram,IH_MEAN_VAL,vetted,analytic,ih.ibsi_fbn,rel=1e-9,agreed,,,,,in-tree,analytic,test_2d_intensity_histogram_phantom_analytic;test_2d_intensity_histogram_values_integer_domain_analytic,test_2d_intensity_histogram_integer_domain_values_regression,,2,1,,,agree,feature,audit/intensity_histogram_2d_analytic_vetting_report.md,audit/intensity_histogram_2d_golden_regen.md,,"No tool reports this quantity - MIRP and PyRadiomics both stay in the discretised (_IDX) domain - so the oracle is the closed form, evaluated by tests/vetting/oracles/gen_intensity_histogram_analytic.py from the published definitions rather than from intensity_histogram.cpp, and asserted at rel<=1e-9 in test_2d_intensity_histogram_analytic.h (worst measured 7.5e-15). Evidence: tests/vetting/audit/intensity_histogram_2d_*_vetting_report.md." 2D,intensity_histogram,IH_VARIANCE_VAL,vetted,analytic,ih.ibsi_fbn,rel=1e-9,agreed,,,,,in-tree,analytic,test_2d_intensity_histogram_phantom_analytic;test_2d_intensity_histogram_values_integer_domain_analytic,test_2d_intensity_histogram_integer_domain_values_regression,,2,1,"bin-centre domain, squared scale: VAL = binWidth^2 * IDX",,agree,feature,audit/intensity_histogram_2d_analytic_vetting_report.md,audit/intensity_histogram_2d_golden_regen.md,,"No tool reports this quantity - MIRP and PyRadiomics both stay in the discretised (_IDX) domain - so the oracle is the closed form, evaluated by tests/vetting/oracles/gen_intensity_histogram_analytic.py from the published definitions rather than from intensity_histogram.cpp, and asserted at rel<=1e-9 in test_2d_intensity_histogram_analytic.h (worst measured 7.5e-15). Evidence: tests/vetting/audit/intensity_histogram_2d_*_vetting_report.md." diff --git a/tests/vetting/report_output.md b/tests/vetting/report_output.md index 4c1baa186..3b065e393 100644 --- a/tests/vetting/report_output.md +++ b/tests/vetting/report_output.md @@ -32,11 +32,11 @@ regression: 122 invariant: 0 untested: 0 ## Verdicts -1020 of 1020 assertion rows agree with the tree. +1024 of 1024 assertion rows agree with the tree. | verdict | rows | meaning | |---|---:|---| -| `agree` | 1020 | the claim and the tree say the same thing | +| `agree` | 1024 | the claim and the tree say the same thing | ### How far each verdict reaches @@ -46,7 +46,7 @@ regression: 122 invariant: 0 untested: 0 | scope | rows | what was compared | |---|---:|---| | `feature` | 533 | `test_name` is empty, so the tree was read for the feature and not for this row's own assertion | -| `row` | 280 | the case named in `test_name` asserts this feature, at this row's kind; its configuration is unchecked, the family declaring no recipe reader | +| `row` | 284 | the case named in `test_name` asserts this feature, at this row's kind; its configuration is unchecked, the family declaring no recipe reader | | `row+config` | 207 | the case named in `test_name` asserts this feature at this `config_recipe` | ## Every oracle a feature was matched against @@ -66,7 +66,7 @@ that alone understates every family that corroborates. | 2D | glszm | . | . | . | 16 | . | . | 16 | . | . | . | . | | 2D | intensity_histogram | 26 | . | . | 16 | . | . | 23 | . | . | . | 4 | | 2D | moments | . | . | . | . | . | . | . | . | . | 118 | 62 | -| 2D | morphology | 5 | 5 | 2 | . | 20 | 33 | . | . | . | 7 | 30 | +| 2D | morphology | 7 | 5 | 2 | . | 20 | 33 | . | 1 | . | 7 | 30 | | 2D | neighbor | 6 | 2 | . | . | . | . | . | . | . | . | 1 | | 2D | ngldm | . | . | . | 17 | . | . | 17 | . | . | . | 2 | | 2D | ngtdm | . | . | . | 5 | . | . | 5 | . | . | . | . | @@ -82,7 +82,7 @@ that alone understates every family that corroborates. | 3D | ngldm | . | . | . | . | . | . | 16 | . | . | . | 3 | | 3D | ngtdm | . | . | . | . | . | . | . | . | 5 | . | . | | IMQ | imq | 1 | 2 | . | . | . | . | . | 2 | . | . | 2 | -| | **all** | **41** | **9** | **2** | **181** | **20** | **95** | **208** | **2** | **272** | **126** | **122** | +| | **all** | **43** | **9** | **2** | **181** | **20** | **95** | **208** | **3** | **272** | **126** | **122** | ## By dimensionality From 6e8c96fe16135611e593a9d700de018968e3e73a Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Fri, 23 Oct 2026 17:55:19 -0700 Subject: [PATCH 2/3] fix(fractal): walk each border separately for FRACT_DIM_PERIMETER 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. --- docs/examples/fractal_worked_example.py | 39 ++++--- docs/feature_descriptions.toml | 2 +- docs/source/Math/f_fractal_dimension.rst | 124 +++++++++++++---------- docs/source/featurelist.rst | 2 +- docs/source/large_images.rst | 2 +- src/nyx/features/fractal_dim.cpp | 59 +++++++---- tests/test_2d_morphology_invariant.h | 97 ++++++++++++++++++ tests/test_all.cc | 10 ++ 8 files changed, 248 insertions(+), 87 deletions(-) diff --git a/docs/examples/fractal_worked_example.py b/docs/examples/fractal_worked_example.py index 6093491f8..69bf5ffcc 100644 --- a/docs/examples/fractal_worked_example.py +++ b/docs/examples/fractal_worked_example.py @@ -78,18 +78,27 @@ def disk(r): check(fd(disk(40)), [1.686, 1.032], "disk 40") # One-pixel-wide straight lines: box counting reaches 1 only at power-of-two lengths, and the -# divider walk reads above 1 because the closing chord spans the whole line -check(fd(np.ones((1, 16))), [1.0, 1.190], "line 16") -check(fd(np.ones((1, 10))), [0.7551, 1.205], "line 10") +# divider walk reads above 1 because the walk runs along the line and back, and the chords that +# span an end fold back +check(fd(np.ones((1, 16))), [1.0, 1.035], "line 16") +check(fd(np.ones((1, 10))), [0.7551, 1.101], "line 10") check(fd(np.ones((1, 100)))[:1], [0.9257], "line 100") -# Two separate squares form one ROI with two contours; the divider walk joins them +# Two separate squares form one ROI with two borders, each walked on its own: the divider value is +# that of one square two = np.zeros((40, 80), np.uint32) two[5:30, 5:30] = 1 two[5:30, 45:70] = 1 -check(fd(two), [1.423, 1.107], "two squares") +check(fd(two), [1.423, 1.0], "two squares") check(fd(square(25)), [1.842, 1.0], "one square") +# A hole's border, shorter than the large strides, is measured whole +holed = np.zeros((30, 30), np.uint32) +holed[3:27, 3:27] = 1 +check(fd(holed)[1:], [1.005], "24 x 24 square") +holed[10:20, 10:20] = 0 +check(fd(holed)[1:], [1.045], "24 x 24 square with a 10 x 10 hole") + # A 40 x 40 square and one separate pixel. In memory an intensity of 4294967295 makes the box # count's marker overflow its 32-bit conversion, which is undefined, so only 100 is checked here lone = np.zeros((80, 80), np.uint32) @@ -143,31 +152,37 @@ def from_files(m, intensity=None, single_roi=False, codes=F + ["PERIMETER"], **k check(from_files(mask, ram_limit=0)[0], [1.3966, 1.1311, 26.0], "example out of core") # pins known defect https://github.com/PolusAI/nyxus/issues/509 -check(from_files(two, ram_limit=0)[0][1:2], [1.0], "two squares out of core") +check(from_files(holed, ram_limit=0)[0][1:2], [1.005], "square with a hole out of core") for value in (100, 4294967295): inten = lone * 100 inten[70, 70] = value check(from_files(lone, inten, ram_limit=0)[0][:1], [1.4156], f"lone pixel {value} out of core") check(from_files(mask)[0][:2], [1.3732, 1.1311], "example, no anisotropy") -check(from_files(mask, anisotropy_x=2)[0][:2], [1.2459, 1.1717], "anisotropy_x=2") +check(from_files(mask, anisotropy_x=2)[0][:2], [1.2459, 1.1566], "anisotropy_x=2") check(from_files(mask, anisotropy_y=3)[0][:2], [1.4458, 1.1635], "anisotropy_y=3") # With anisotropy the out-of-core contour differs, so a single-contour disk differs too -check(from_files(disk(8), anisotropy_x=2)[0][1:2], [1.0771], "disk 8, anisotropy_x=2") +check(from_files(disk(8), anisotropy_x=2)[0][1:2], [1.0917], "disk 8, anisotropy_x=2") # pins known defect https://github.com/PolusAI/nyxus/issues/509 check(from_files(disk(8), anisotropy_x=2, ram_limit=0)[0][1:2], [1.0482], "disk 8, anisotropy_x=2, out of core") -# Without anisotropy a single contour can still be listed in a different order out of core: an 8 x 8 -# square with a one-pixel-wide branch, at the image origin +# Without anisotropy a single contour can still differ out of core, which walks a one-pixel-wide +# part once where the in-memory walk goes along and back: a 1 x 16 line and an 8 x 8 square with a +# one-pixel-wide branch, at the image origin +line16 = np.zeros((4, 19), np.uint32) +line16[0, 0:16] = 1 +check(from_files(line16)[0][1:2], [1.035], "line 16") +# pins known defect https://github.com/PolusAI/nyxus/issues/509 +check(from_files(line16, ram_limit=0)[0][1:2], [1.190], "line 16, out of core") branch = np.zeros((18, 18), np.uint32) branch[0:8, 0:8] = 1 branch[3, 8:16] = 1 -check(from_files(branch)[0][1:2], [1.131], "square with a branch") +check(from_files(branch)[0][1:2], [1.190], "square with a branch") # pins known defect https://github.com/PolusAI/nyxus/issues/509 check(from_files(branch, ram_limit=0)[0][1:2], [1.102], "square with a branch, out of core") labels = two.copy() labels[5:30, 45:70] = 2 for row in from_files(labels, (labels > 0) * 100): check(row[:2], [1.8416, 1.0], "two labels") -check(from_files(labels, (labels > 0) * 100, mergerois=True)[0][:2], [1.4231, 1.107], "merged") +check(from_files(labels, (labels > 0) * 100, mergerois=True)[0][:2], [1.4231, 1.0], "merged") # pins known defect https://github.com/PolusAI/nyxus/issues/493: the box is 201 x 201, which rounds # up to the same 256 canvas as 200 x 200 check(from_files(np.ones((200, 200)), single_roi=True)[0][:2], [1.8703, 1.0], "whole slide") diff --git a/docs/feature_descriptions.toml b/docs/feature_descriptions.toml index f825b8206..65581be31 100644 --- a/docs/feature_descriptions.toml +++ b/docs/feature_descriptions.toml @@ -193,7 +193,7 @@ EROSIONS_2_VANISH_COMPLEMENT = "Not computed: the column is never assigned and k [fractal] FRACT_DIM_BOXCOUNT = "Box-counting fractal dimension of the ROI mask: minus the least-squares slope of log box count versus log box size over power-of-two box sizes; ROIs whose padded side is 32 pixels or less take the smallest count over four grid offsets." -FRACT_DIM_PERIMETER = "Divider (Richardson) fractal dimension of the ROI's contour: 1 minus the least-squares slope of log perimeter versus log mean ruler length, where the contour is walked with strides of decreasing numbers of contour pixels." +FRACT_DIM_PERIMETER = "Divider (Richardson) fractal dimension of the ROI's contour: 1 minus the least-squares slope of log perimeter versus log mean ruler length, where each border of the contour (the outside of each separate part and each hole) is walked on its own with strides of decreasing numbers of contour pixels, and a border no longer than the stride counts its full length." [geodetic] GEODETIC_LENGTH = "Length of a rectangle with the same area and perimeter as the ROI: PERIMETER divided by 4 plus the square root of (PERIMETER squared divided by 16 minus the area), in pixels; when that difference is negative, as for a disk, no such rectangle exists and the value is PERIMETER divided by 4." diff --git a/docs/source/Math/f_fractal_dimension.rst b/docs/source/Math/f_fractal_dimension.rst index b0f454756..804836710 100644 --- a/docs/source/Math/f_fractal_dimension.rst +++ b/docs/source/Math/f_fractal_dimension.rst @@ -21,9 +21,11 @@ rough or ragged shapes give larger values. * - **Out-of-core** - Partly. Out of core, box counting always uses the unshifted grid, so ROIs whose padded side :math:`S` is 32 or less can read differently (the worked example gives 1.3966 instead - of 1.3732). The divider walks only the first loop of the contour, so ROIs with holes or - several parts differ (two separate 25 × 25 squares give 1.000 instead of 1.107); with - anisotropy every ROI's divider value can differ. These contour differences are a known + of 1.3732). The divider walks only the first loop of the contour, and walks a one-pixel-wide + part once instead of along and back, so ROIs with holes, several parts or one-pixel-wide parts + can differ (a 24 × 24 square with a 10 × 10 hole gives 1.005 instead of 1.045, while identical + separate parts read the same on both paths); with anisotropy every ROI's divider value can + differ. These contour differences are a known defect (`#509 `__). On an image that Nyxus reads as several tiles out-of-core processing raises an error or returns wrong values, depending on the ROI (see :ref:`large-images-tiles` @@ -64,19 +66,19 @@ half a box, along :math:`x`, along :math:`y` and along both, and the smallest of kept. When :math:`S > 32` only the unshifted grid is used. The dimension is minus the least-squares slope of :math:`\ln N(s)` against :math:`\ln s`. -**Divider walk.** For a contour of :math:`n` pixels, Nyxus walks the contour with strides of -:math:`d = \lfloor n/4 \rfloor, \lfloor d/2 \rfloor, \ldots, 1` pixels. Each walk starts at the -first contour pixel and jumps :math:`d` pixels at a time while a full stride remains, adding the -straight-line length of each jump. It then closes the loop with one chord from the last pixel -reached back to the first pixel; this chord spans the remaining :math:`n - qd` pixels, which can be -fewer than :math:`d`. The walk's length :math:`P(d)` and its mean chord, the ruler -:math:`r(d) = P(d)/m(d)` over its :math:`m(d)` chords, give one point. The dimension is 1 minus the -least-squares slope of :math:`\ln P` against :math:`\ln r`. At stride 1 the walk is the contour's -``PERIMETER``. - -A ROI with holes or separate parts has several borders. Nyxus lists their pixels one border after -another, in the order it found the borders, each pixel once, and walks that list as a single closed -contour (see :ref:`fractal-possible-issues`). +**Divider walk.** The contour is one closed walk per border of the ROI: around the outside of each +separate part and around each hole, as the :ref:`contour features ` trace it. +With :math:`n` the length of the longest walk, Nyxus uses strides of +:math:`d = \lfloor n/4 \rfloor, \lfloor d/2 \rfloor, \ldots, 1` pixels. At each stride every +border is walked on its own: it starts at the border's first pixel and jumps :math:`d` pixels at a +time while a full stride remains, adding the straight-line length of each jump, then closes the +loop with one chord from the last pixel reached back to the first; this chord spans the remaining +:math:`n_b - qd` pixels of the border, which can be fewer than :math:`d`. A border of at most +:math:`d` pixels is not walked: it adds its closed length, as measured at stride 1, and no chord. +The summed length :math:`P(d)` and the mean chord, the ruler :math:`r(d) = P(d)/m(d)` over the +:math:`m(d)` chords of the walked borders, give one point. The dimension is 1 minus the +least-squares slope of :math:`\ln P` against :math:`\ln r`. At stride 1 the walks are the +contour's ``PERIMETER``. No chord joins one border to another. Worked example @@ -140,7 +142,8 @@ The least-squares slope of :math:`\ln(1, 3, 7, 18)` against :math:`\ln(16, 8, 4, :math:`-1.3732`, so ``FRACT_DIM_BOXCOUNT`` is 1.3732. The unshifted grid alone, with counts 1, 4, 8 and 20, would give 1.3966. -**Divider walk.** The contour has :math:`n = 26` pixels, so the strides are 6, 3 and 1. +**Divider walk.** The contour is one border, a walk of :math:`n = 26` pixels, so the strides are +6, 3 and 1. .. list-table:: :widths: 14 18 34 34 @@ -209,15 +212,21 @@ Notation - Box size, one of :math:`S, S/2, \ldots, 2`. * - :math:`N(s)` - Number of boxes of size :math:`s` that hold at least one ROI pixel. - * - :math:`K_0, \ldots, K_{n-1}` - - Contour pixels in walking order; :math:`n` is their number. + * - :math:`K^b_0, \ldots, K^b_{n_b-1}` + - The walk around border :math:`b`, in walking order; :math:`n_b` is its length and + :math:`n = \max_b n_b`. + * - :math:`L_b` + - Closed length of border :math:`b`'s walk at stride 1. * - :math:`d` - Stride of the divider walk, in contour pixels. - * - :math:`q` - - Number of full strides in a walk, :math:`q = \lceil n/d \rceil - 1`. + * - :math:`q_b` + - Number of full strides along border :math:`b`, :math:`q_b = \lceil n_b/d \rceil - 1`. + * - :math:`B(d)` + - The borders walked at stride :math:`d`, those with :math:`n_b > d`. * - :math:`P(d),\; m(d),\; r(d)` - - Length of the walk with stride :math:`d`, its number of chords :math:`m(d) = q + 1`, and its - mean chord :math:`r(d) = P(d)/m(d)`. + - Summed length of the borders at stride :math:`d`, the number of chords of the walked + borders :math:`m(d) = \sum_{b \in B(d)} (q_b + 1)`, and the mean chord + :math:`r(d) = P(d)/m(d)`. * - :math:`\beta\{(a_j, b_j)\}` - Least-squares slope of :math:`\ln b_j` against :math:`\ln a_j`, defined below. @@ -262,13 +271,13 @@ Settings - ``Nyxus(..., anisotropy_x=..., anisotropy_y=...)``, with ``featurize_files`` or ``featurize_directory``; arrays passed to ``Nyxus.featurize`` are not rescaled - The image is resampled before the ROIs are measured, which changes both features. The - worked example gives 1.3732 and 1.1311 without anisotropy, 1.2459 and 1.1717 with + worked example gives 1.3732 and 1.1311 without anisotropy, 1.2459 and 1.1566 with ``anisotropy_x=2``, and 1.4458 and 1.1635 with ``anisotropy_y=3``. * - Merged ROIs - ``--mergerois=true`` - ``Nyxus(..., mergerois=True)`` - All labels of a mask form one ROI. Two 25 × 25 squares with labels 1 and 2 give 1.8416 and - 1.000 each; merged, they give 1.4231 and 1.107. + 1.000 each; merged, they give 1.4231 and 1.000. No other setting changes these features: not ``--coarseGrayDepth``, not ``--ibsi`` and not the image intensity map. All options are listed in the :ref:`cli-reference`. @@ -338,31 +347,35 @@ Perimeter (divider) dimension :Code: ``FRACT_DIM_PERIMETER`` :Range: about :math:`[1, 2)` in practice, dimensionless; smooth outlines can read slightly below 1 - (a 6 × 6 square gives 0.9947); 0 for a contour of fewer than 3 pixels + (a 6 × 6 square gives 0.9947); 0 when the longest border walk has fewer than 3 pixels How fast the measured length of the contour grows as the ruler shrinks, after Richardson. Values near 1 mean a smooth outline; larger values mean a rough or wiggly one. .. math:: - P(d) = \sum_{j=0}^{q-1} \left\| K_{(j+1)d} - K_{jd} \right\| + \left\| K_0 - K_{qd} \right\|, + P(d) = \sum_{b \in B(d)} \left( \sum_{j=0}^{q_b-1} \left\| K^b_{(j+1)d} - K^b_{jd} \right\| + + \left\| K^b_0 - K^b_{q_b d} \right\| \right) + \sum_{b \notin B(d)} L_b, \qquad - r(d) = \frac{P(d)}{q + 1} + r(d) = \frac{P(d)}{m(d)} .. math:: \mathrm{FRACT\_DIM\_PERIMETER} = 1 - \beta\left\{ \left(r(d), P(d)\right) : d = \lfloor n/4 \rfloor, \lfloor d/2 \rfloor, \ldots, 1 \right\} -The value is 0 when the contour has fewer than 3 pixels. A contour of 3 to 7 pixels gives at most one -point, so :math:`\beta = 0` and the value is exactly 1. +The value is 0 when the longest border walk has fewer than 3 pixels. A longest walk of 3 to 7 +pixels gives at most one point, so :math:`\beta = 0` and the value is exactly 1. -**Relations.** In memory, :math:`P(1)` is ``PERIMETER`` for a ROI with a single border that its -walk passes only once, such as the worked example; out of core, ``PERIMETER`` is the number of -contour pixels instead (26 on the worked example). The fit runs against the mean chord -:math:`r(d)`, not against the stride :math:`d`. +**Relations.** In memory, :math:`P(1)` is ``PERIMETER``; out of core, ``PERIMETER`` is the number +of contour pixels instead (26 on the worked example). The fit runs against the mean chord +:math:`r(d)`, not against the stride :math:`d`. Copies of one shape under one label read the +dimension of a single copy: at every stride both :math:`P(d)` and :math:`m(d)` scale with the +number of copies. -**Possible issues.** For a ROI with several contours the walk includes the jumps between them (see +**Possible issues.** A border no longer than the stride adds its full length and no chord, so a +ROI with small holes or small separate parts can read higher than its outer border alone: a +24 × 24 square with a 10 × 10 hole gives 1.045 against 1.005 for the square (see :ref:`fractal-possible-issues`). @@ -380,9 +393,9 @@ Box counting depends on the ROI's size 32 but 0.755 at length 10 and 0.926 at length 100, below the dimension of a line. One-pixel-wide parts in the divider walk - The contour of a one-pixel-wide line visits each pixel once, from one end to the other, and the - closing chord jumps back across the whole length. That long chord enters every ruler - :math:`r(d)`, and a straight line reads above 1: 1.190 at length 16, 1.205 at length 10. Thin + The walk around a one-pixel-wide line runs along it and back. A chord that spans an end of the + line folds back on itself and is shorter than its stride, which shortens the walk at the larger + strides, so a straight line reads above 1: 1.035 at length 16, 1.101 at length 10. Thin branches of larger ROIs are affected in the same way. Shifted grids only for small ROIs @@ -397,26 +410,28 @@ Out-of-core ROIs An out-of-core ROI uses the unshifted grid whatever its size, so a ROI with :math:`S \le 32` can read a different ``FRACT_DIM_BOXCOUNT``: the worked example gives 1.3966 out of core and 1.3732 in memory. The out-of-core contour holds only the first loop of the ROI's boundary, so - the divider walk ignores holes and separate parts: two separate 25 × 25 squares in one ROI give - 1.000 out of core and 1.107 in memory. Without anisotropy, a ROI with a single contour gives the - same ``FRACT_DIM_PERIMETER`` on both paths as long as both tracers list its pixels in the same - order, as on the worked example and on straight lines; on a ROI with one-pixel-wide parts they - can differ, and an 8 × 8 square with a one-pixel-wide branch gives 1.131 in memory and 1.102 - out of core. With anisotropy the out-of-core contour is wrong, so single-contour ROIs differ - too: a disk of radius 8 with ``anisotropy_x=2`` gives 1.0771 in - memory and 1.0482 out of core. The out-of-core contour is a known defect (`#509 `__). Images that Nyxus reads as several tiles have further + the divider walk ignores holes and separate parts: a 24 × 24 square with a 10 × 10 hole gives + 1.005 out of core, the value of the square without its hole, and 1.045 in memory. Without + anisotropy, a ROI with a single contour can differ too: in memory the walk passes a + one-pixel-wide part along and back, out of core once, so a 1 × 16 line gives 1.035 in memory + and 1.190 out of core, and an 8 × 8 square with a one-pixel-wide branch 1.190 and 1.102. The + worked example, whose border has no one-pixel-wide part, gives the same value on both paths. + With anisotropy the out-of-core contour is wrong, so single-contour ROIs differ + too: a disk of radius 8 with ``anisotropy_x=2`` gives 1.0917 in memory and 1.0482 out of core. + The out-of-core contour is a known defect (`#509 `__). Images that Nyxus reads as several tiles have further out-of-core differences (see :ref:`large-images-tiles`). -Several contours are walked as one - A ROI with holes, or with parts that do not touch, has several contours. The divider walk joins - them into one chain, so the chords between the end of one contour and the start of the next are - part of the walk. Two separate 25 × 25 squares in one ROI give 1.107; one such square gives - 1.000. +Short borders are measured whole + A ROI with holes, or with parts that do not touch, has several borders, each walked on its own. + At a stride longer than a border, that border adds its full length and no chord, so it raises + both :math:`P(d)` and the ruler :math:`r(d)` at the large strides. A 24 × 24 square with a 10 × 10 + hole gives 1.045, where the square alone gives 1.005. Two separate 25 × 25 squares in one ROI + give 1.000, as one such square does. Small ROIs ``FRACT_DIM_BOXCOUNT`` is 0 for fewer than 2 pixels and for bounding boxes no larger than - 2 × 2. ``FRACT_DIM_PERIMETER`` is 0 for contours of fewer than 3 pixels and exactly 1 for - contours of 3 to 7 pixels. A 3 × 3 square gives 2 and 1. + 2 × 2. ``FRACT_DIM_PERIMETER`` is 0 when the longest border walk has fewer than 3 pixels and + exactly 1 when it has 3 to 7. A 3 × 3 square gives 2 and 1. Largest 32-bit intensity This is a known defect (`#515 `__). In memory, on ROIs whose padded side :math:`S` exceeds 32, box @@ -440,7 +455,8 @@ image: The tests also check shapes with a known dimension: box counting gives 2 for a 256 × 256 square, 1 for a straight line and :math:`\log_2 3 = 1.585` for a Sierpinski triangle, to an absolute 0.1; the divider gives 1 for a disk and :math:`\log 4 / \log 3 = 1.262` for a Koch snowflake, to an absolute -0.05. The feature values quoted above Validation, and the box counts of the worked example and of +0.05, and two identical separate disks under one label give exactly the divider dimension of one. +The feature values quoted above Validation, and the box counts of the worked example and of the 20 × 20 square, are asserted by the worked-example script; the divider-walk table of the worked example is not. None is compared with a reference tool. diff --git a/docs/source/featurelist.rst b/docs/source/featurelist.rst index b10c1d2cf..c8499ad1a 100644 --- a/docs/source/featurelist.rst +++ b/docs/source/featurelist.rst @@ -633,7 +633,7 @@ Definitions, formulas and settings: :doc:`/Math/f_fractal_dimension`. - Box-counting fractal dimension of the ROI mask: minus the least-squares slope of log box count versus log box size over power-of-two box sizes; ROIs whose padded side is 32 pixels or less take the smallest count over four grid offsets. - vetted: FracLac * - ``FRACT_DIM_PERIMETER`` - - Divider (Richardson) fractal dimension of the ROI's contour: 1 minus the least-squares slope of log perimeter versus log mean ruler length, where the contour is walked with strides of decreasing numbers of contour pixels. + - Divider (Richardson) fractal dimension of the ROI's contour: 1 minus the least-squares slope of log perimeter versus log mean ruler length, where each border of the contour (the outside of each separate part and each hole) is walked on its own with strides of decreasing numbers of contour pixels, and a border no longer than the stride counts its full length. - vetted: FracLac .. _features-geodetic: diff --git a/docs/source/large_images.rst b/docs/source/large_images.rst index 522171169..922c781b3 100644 --- a/docs/source/large_images.rst +++ b/docs/source/large_images.rst @@ -239,7 +239,7 @@ different order. On a 340 x 340 image, a 300 x 300 square with a 100 x 100 hole - Box counting uses a single unshifted grid for every out-of-core ROI, so ROIs whose padded side is 32 or less can read differently (the fractal dimension worked example gives 1.3966 instead of 1.3732). The divider walks the out-of-core contour, which holds one - loop in a different order, so it can differ too. ``FRACT_DIM_BOXCOUNT`` differs on 38 of + loop, in a different order and with one-pixel-wide parts walked once, so it can differ too. ``FRACT_DIM_BOXCOUNT`` differs on 38 of the 66 ROIs and ``FRACT_DIM_PERIMETER`` on 17. * - Neighbor features (``NUM_NEIGHBORS``, ``PERCENT_TOUCHING``, ``CLOSEST_NEIGHBOR*``, ``ANG_BW_NEIGHBORS_*``), ``POLYGONALITY_AVE``, ``HEXAGONALITY_*`` diff --git a/src/nyx/features/fractal_dim.cpp b/src/nyx/features/fractal_dim.cpp index 39ca1f254..439eb9a58 100644 --- a/src/nyx/features/fractal_dim.cpp +++ b/src/nyx/features/fractal_dim.cpp @@ -126,36 +126,59 @@ void FractalDimensionFeature::calculate_boxcount_fdim_oversized (LR & r) void FractalDimensionFeature::calculate_perimeter_fdim (LR& r) { - // The divider walks ONE closed contour. The merged contour lists every border of the ROI one - // after another, so a multi-contour ROI (holes / disconnected fragments) injects spurious seam - // chords between them that bias the dimension; this path assumes a single-contour ROI. - const std::vector& K = r.contour_pixels_; - - auto conLen = K.size(); - if (conLen < 3) + // The divider walks each closed border of the ROI -- the outer border of every fragment and the + // border of every hole -- on its own, and the borders' lengths and steps are summed per stride. + // No step joins one border to another. + size_t longest = 0; + for (const std::vector& K : r.multicontour_) + longest = std::max (longest, K.size()); + if (longest < 3) { perim_fd = 0.; return; } - // Richardson "structured walk" (divider) method on the CLOSED contour K. - // For each stride s, walk the contour in steps of s vertices, summing Euclidean chord - // lengths and closing the loop back to the start. Record x = mean ruler (chord) length - // and y = perimeter estimate. Since log(perimeter) ~ (1 - D) log(ruler), D = 1 - slope. + // Richardson "structured walk" (divider) method on the CLOSED borders. + // For each stride s, walk every border in steps of s vertices, summing Euclidean chord + // lengths and closing each loop back to its start. A border no longer than the stride cannot + // have a ruler that long laid along it and is measured whole instead: it adds its closed length, + // the length stride 1 measures it at, and no ruler, so the mean ruler is that of the borders the + // stride walks. Record x = mean ruler (chord) length and y = perimeter estimate. Since + // log(perimeter) ~ (1 - D) log(ruler), D = 1 - slope. // Validated against the analytic Koch snowflake (D = log4/log3 = 1.2619) and a circle (D = 1). + std::vector closedLen; + for (const std::vector& K : r.multicontour_) + { + double len = 0.; + for (size_t i = 0, n = K.size(); n > 1 && i < n; i++) + len += std::sqrt(K[i].sqdist(K[(i + 1) % n])); + closedLen.push_back(len); + } + std::vector> coverage; - for (size_t s = conLen / 4; s > 0; s /= 2) + for (size_t s = longest / 4; s > 0; s /= 2) { double perim = 0.; - size_t nsteps = 0, i = 0; - for (; i + s < conLen; i += s) + size_t nsteps = 0; + for (size_t k = 0; k < r.multicontour_.size(); k++) { - perim += std::sqrt(K[i].sqdist(K[i + s])); + const std::vector& K = r.multicontour_[k]; + const size_t conLen = K.size(); + if (conLen <= s) + { + perim += closedLen[k]; + continue; + } + size_t i = 0; + for (; i + s < conLen; i += s) + { + perim += std::sqrt(K[i].sqdist(K[i + s])); + nsteps++; + } + // close the loop from the last visited vertex back to the start + perim += std::sqrt(K[i].sqdist(K[0])); nsteps++; } - // close the loop from the last visited vertex back to the start - perim += std::sqrt(K[i].sqdist(K[0])); - nsteps++; double ruler = perim / double(nsteps); // mean chord (ruler) length at this stride coverage.push_back({ ruler, perim }); diff --git a/tests/test_2d_morphology_invariant.h b/tests/test_2d_morphology_invariant.h index 7c20883c7..37d1150ec 100644 --- a/tests/test_2d_morphology_invariant.h +++ b/tests/test_2d_morphology_invariant.h @@ -2,7 +2,9 @@ #include +#include "../src/nyx/features/contour.h" #include "../src/nyx/features/edt.h" +#include "../src/nyx/features/fractal_dim.h" #include "../src/nyx/features/roi_radius.h" #include "test_main_nyxus.h" // roi_cache.h / featureset.h / pixel.h @@ -373,3 +375,98 @@ inline void test_2d_morphology_roi_radius_paths_agree_invariant() << "no shape in the battery routed the in-RAM path through the distance transform, so this " "test no longer compares the two engines"; } + +// FRACT_DIM_PERIMETER walks every closed border of a ROI separately and sums the borders per +// stride. Two identical, separate disks under one label therefore have exactly the dimension of +// one: at every stride both the summed length and the number of steps double, so the mean ruler is +// the same and log(perimeter) shifts by log 2 at every point, which leaves the slope unchanged. +// Walking the two borders as one sequence would measure chords between the disks and break this. +inline void test_2d_morphology_fractal_perimeter_two_copies_invariant() +{ + auto fractal_perimeter = [] (int n_copies) + { + const int R = 12, cx = 20, cy = 20, gap = 50; + LR roi (1); + bool first = true; + for (int k = 0; k < n_copies; k++) + for (int y = cy - R; y <= cy + R; y++) + for (int x = cx - R + k * gap; x <= cx + R + k * gap; x++) + { + const int dx = x - (cx + k * gap), dy = y - cy; + if (dx * dx + dy * dy > R * R) + continue; + if (first) { Nyxus::init_label_record_3 (roi, x, y, 1); first = false; } + else Nyxus::update_label_record_3 (roi, x, y, 1); + roi.raw_pixels.push_back (Pixel2 (x, y, (PixIntens) 1)); + } + roi.make_nonanisotropic_aabb(); + roi.aux_image_matrix = ImageMatrix (roi.raw_pixels); + roi.initialize_fvals(); + + Fsettings s = make_shape2d_settings(); + ContourFeature contour; + contour.calculate (roi, s); + contour.save_value (roi.fvals); + FractalDimensionFeature fractal; + fractal.calculate (roi, s); + fractal.save_value (roi.fvals); + + EXPECT_EQ (roi.multicontour_.size(), (size_t) n_copies); + return roi.fvals; + }; + + const auto one = fractal_perimeter (1), two = fractal_perimeter (2); + ASSERT_GT (one[(int)Nyxus::Feature2D::FRACT_DIM_PERIMETER][0], 0.0); + ASSERT_NEAR (two[(int)Nyxus::Feature2D::FRACT_DIM_PERIMETER][0], one[(int)Nyxus::Feature2D::FRACT_DIM_PERIMETER][0], 1e-12) + << "FRACT_DIM_PERIMETER of two separate copies differs from one copy's"; +} + +// A border no longer than the divider's stride is measured whole, at its closed length, rather than +// dropped. Here every border is measured the same at every stride: a 17x17 square, whose 64-pixel +// walk the strides 16, 8, 4, 2 and 1 all cut at its corners, so they lay 64 along it, and a separate +// 3-pixel line, whose 4-pixel walk out and back strides 1 and 2 measure at its closed length 4 and +// the larger strides take whole. The perimeter is 68 at every ruler, and the dimension exactly 1. +// Dropping the line at the strides it is too short for would leave 64 at the larger rulers and +// lift the dimension above 1. +inline void test_2d_morphology_fractal_perimeter_short_border_invariant() +{ + auto fractal_perimeter = [] (bool with_line, size_t& n_borders) + { + LR roi (1); + bool first = true; + auto add = [&] (int x, int y) + { + if (first) { Nyxus::init_label_record_3 (roi, x, y, 1); first = false; } + else Nyxus::update_label_record_3 (roi, x, y, 1); + roi.raw_pixels.push_back (Pixel2 (x, y, (PixIntens) 1)); + }; + for (int y = 10; y <= 26; y++) + for (int x = 10; x <= 26; x++) + add (x, y); + if (with_line) + for (int x = 40; x <= 42; x++) + add (x, 15); + roi.make_nonanisotropic_aabb(); + roi.aux_image_matrix = ImageMatrix (roi.raw_pixels); + roi.initialize_fvals(); + + Fsettings s = make_shape2d_settings(); + ContourFeature contour; + contour.calculate (roi, s); + contour.save_value (roi.fvals); + FractalDimensionFeature fractal; + fractal.calculate (roi, s); + fractal.save_value (roi.fvals); + + n_borders = roi.multicontour_.size(); + return roi.fvals; + }; + + size_t n_square = 0, n_both = 0; + const auto square = fractal_perimeter (false, n_square), both = fractal_perimeter (true, n_both); + ASSERT_EQ (n_square, (size_t) 1); + ASSERT_EQ (n_both, (size_t) 2); + ASSERT_NEAR (square[(int)Nyxus::Feature2D::FRACT_DIM_PERIMETER][0], 1.0, 1e-12) << "the square alone is measured 64 at every ruler"; + ASSERT_NEAR (both[(int)Nyxus::Feature2D::FRACT_DIM_PERIMETER][0], 1.0, 1e-12) + << "the 3-pixel line was not measured whole at the strides it is too short for"; +} diff --git a/tests/test_all.cc b/tests/test_all.cc index 8438fc938..8244e5539 100644 --- a/tests/test_all.cc +++ b/tests/test_all.cc @@ -2118,6 +2118,16 @@ TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_ROI_RADIUS_PATHS_AGREE_INVARIANT) ASSERT_NO_THROW(test_2d_morphology_roi_radius_paths_agree_invariant()); } +TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_FRACTAL_PERIMETER_TWO_COPIES_INVARIANT) +{ + ASSERT_NO_THROW(test_2d_morphology_fractal_perimeter_two_copies_invariant()); +} + +TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_FRACTAL_PERIMETER_SHORT_BORDER_INVARIANT) +{ + ASSERT_NO_THROW(test_2d_morphology_fractal_perimeter_short_border_invariant()); +} + TEST(TEST_NYXUS, TEST_2D_EDT_NO_SITES_MECHANICS) { ASSERT_NO_THROW(test_2d_edt_no_sites_mechanics()); From ff4435fec5c0edf8f5afc7b917f77577d42945d7 Mon Sep 17 00:00:00 2001 From: Demian Vladi Date: Fri, 23 Oct 2026 17:55:19 -0700 Subject: [PATCH 3/3] test(vetting): vet the contour consumers the corrected contour lets an 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. --- docs/source/Math/circles_index.inc | 4 +- docs/source/Math/f_2dmoments.rst | 5 +- docs/source/Math/f_morphology.rst | 25 ++++--- docs/source/Math/intensity_moments_index.inc | 2 +- docs/source/Math/neighbors_index.inc | 2 +- docs/source/Math/roi_radius_index.inc | 2 +- docs/source/Math/shape_moments_index.inc | 2 +- docs/source/featurelist.rst | 12 +-- tests/test_2d_morphology_analytic.h | 75 +++++++++++++++++++ tests/test_2d_morphology_common.h | 48 ++++++++++++ tests/test_2d_morphology_skimage.h | 11 ++- tests/test_2d_neighbor_cellprofiler.h | 33 +++++--- tests/test_2d_neighbor_regression.h | 12 ++- tests/test_all.cc | 20 +++++ .../audit/morphology_2d_golden_regen.md | 4 + .../morphology_2d_imea_vetting_report.md | 5 ++ ...hology_2d_radius_skimage_vetting_report.md | 5 ++ .../morphology_2d_skimage_vetting_report.md | 4 + ...neighbor_2d_cellprofiler_vetting_report.md | 5 ++ .../vetting/audit/neighbor_2d_golden_regen.md | 3 + .../radial_2d_cellprofiler_vetting_report.md | 28 ++++--- tests/vetting/audit/radial_2d_golden_regen.md | 3 + tests/vetting/audit/scan_moments_coverage.py | 19 +++-- .../vetting/audit/scan_morphology_coverage.py | 1 + tests/vetting/audit/scan_neighbor_coverage.py | 3 - tests/vetting/config_recipes.md | 63 ++++++++++++---- tests/vetting/coverage_report.md | 8 +- tests/vetting/matrix/morphology.md | 36 ++++----- tests/vetting/matrix/neighbor.md | 2 +- tests/vetting/not_covered.md | 24 ++++-- tests/vetting/oracle_coverage.csv | 42 ++++++----- .../oracles/gen_morphology_radius_skimage.py | 18 +++-- .../vetting/oracles/gen_morphology_skimage.py | 2 +- .../vetting/oracles/gen_neighbor_analytic.py | 6 +- .../oracles/gen_neighbor_cellprofiler.py | 32 +++----- tests/vetting/report_output.csv | 42 ++++++----- tests/vetting/report_output.md | 22 +++--- 37 files changed, 436 insertions(+), 194 deletions(-) diff --git a/docs/source/Math/circles_index.inc b/docs/source/Math/circles_index.inc index 8c0156769..704dba700 100644 --- a/docs/source/Math/circles_index.inc +++ b/docs/source/Math/circles_index.inc @@ -18,10 +18,10 @@ * - :ref:`DIAMETER_CIRCUMSCRIBING_CIRCLE ` - Circumscribing circle diameter - — - - regression + - analytic (exact) - 14.72 * - :ref:`DIAMETER_INSCRIBING_CIRCLE ` - Inscribing circle diameter - — - - regression + - analytic (exact) - 1.705 diff --git a/docs/source/Math/f_2dmoments.rst b/docs/source/Math/f_2dmoments.rst index cb9f81f98..8010c340a 100644 --- a/docs/source/Math/f_2dmoments.rst +++ b/docs/source/Math/f_2dmoments.rst @@ -717,7 +717,10 @@ The test suite checks the 118 unweighted moments of both families (raw, central, normalized central and Hu) against scikit-image to a relative :math:`10^{-6}`. scikit-image has no function for the normalized raw moment, so ``NORM_SPAT_MOMENT_pq`` and ``IMOM_NRM_pq`` are checked against its raw moments divided by the same power of :math:`m_{00}`. The 62 weighted -moments are covered by regression tests only: no reference tool computes the distance weight. +moments have no reference tool, since none computes the distance weight; they are covered by +regression tests, and ``WEIGHTED_SPAT_MOMENT_00`` and ``IMOM_WRM_00`` also by an invariant: two +separate copies of a disk under one label weigh exactly twice one copy, to a relative +:math:`10^{-12}`. The worked-example script asserts every feature value of the worked example and the feature values quoted under Possible issues, including the out-of-core ones. It does not assert the distance matrix of the worked example, the counts of pixels given too large a distance, the diff --git a/docs/source/Math/f_morphology.rst b/docs/source/Math/f_morphology.rst index 459301e2e..6d564e00d 100644 --- a/docs/source/Math/f_morphology.rst +++ b/docs/source/Math/f_morphology.rst @@ -1204,8 +1204,11 @@ another ROI: those within :math:`\sqrt{2}` of one. A pixel touching several ROIs counts once. The value does not depend on :math:`D`. -**Possible issues.** CellProfiler's ``MeasureObjectNeighbors`` defines its percent touching -differently, so the two values differ; Nyxus counts direct contact between contour pixels only. +**Possible issues.** CellProfiler's ``MeasureObjectNeighbors`` counts the outline pixels that a +neighbour dilated by a disk of radius :math:`D + 0.5` overlaps. At :math:`D = 1` that disk is the +3 × 3 neighbourhood and the two counts are the same; at larger distances CellProfiler also counts +outline pixels farther from the neighbour, while Nyxus counts direct contact between contour +pixels whatever :math:`D` is. .. _feature-closest-neighbor1-dist: @@ -1363,10 +1366,11 @@ The test suite checks the morphology features against six references: `#496 `__). - **CellProfiler** 4.2.8: ``MASS_DISPLACEMENT`` and ``EDGE_MEAN_INTENSITY``, ``EDGE_MAX_INTENSITY``, ``EDGE_MIN_INTENSITY`` and ``EDGE_INTEGRATED_INTENSITY`` to a relative 10\ :sup:`-6`; - ``NUM_NEIGHBORS`` and ``CLOSEST_NEIGHBOR1_DIST`` to an absolute 10\ :sup:`-9`. -- **scikit-image**: ``DIAMETER_EQUAL_AREA``, ``PERIMETER`` (on digital circles), ``ROI_RADIUS_MAX`` - and ``ROI_RADIUS_MEDIAN`` to a relative 10\ :sup:`-3`; ``CONVEX_HULL_AREA``, ``SOLIDITY`` and - ``ORIENTATION``. + ``NUM_NEIGHBORS`` and ``CLOSEST_NEIGHBOR1_DIST`` to an absolute 10\ :sup:`-9`; + ``PERCENT_TOUCHING`` exactly, on a scene of five ROIs with ``--pixelDistance=1``. +- **scikit-image**: ``DIAMETER_EQUAL_AREA``, ``PERIMETER`` (on digital circles), ``ROI_RADIUS_MAX``, + ``ROI_RADIUS_MEAN`` and ``ROI_RADIUS_MEDIAN`` to a relative 10\ :sup:`-3`; ``CONVEX_HULL_AREA``, + ``SOLIDITY`` and ``ORIENTATION``. - **OpenCV** 4.13.0 (``cv2.findContours`` with ``RETR_LIST`` and ``CHAIN_APPROX_NONE``): ``PERIMETER`` exactly, the summed closed length of every border, on an irregular blob and on a ROI with a hole. @@ -1377,14 +1381,15 @@ The test suite checks the morphology features against six references: - **Analytic** values: ``PERIMETER`` exactly on squares, a square with a hole, two separate squares under one label, one-pixel-wide lines and a single pixel; ``EDGE_INTEGRATED_INTENSITY`` exactly on a one-pixel-wide line, whose walk passes its inner pixels twice, each counted once; - ``ROI_RADIUS_MAX`` exactly on digital disks; ``CLOSEST_NEIGHBOR1_ANG``, + ``ROI_RADIUS_MAX`` exactly on digital disks; ``DIAMETER_CIRCUMSCRIBING_CIRCLE`` and + ``DIAMETER_INSCRIBING_CIRCLE`` exactly on a digital circle, the same circle with a hole and two + separate disks under one label; ``CLOSEST_NEIGHBOR1_ANG``, ``CLOSEST_NEIGHBOR2_DIST``, ``CLOSEST_NEIGHBOR2_ANG`` and the three ``ANG_BW_NEIGHBORS_*`` codes to an absolute 10\ :sup:`-9`; ``CIRCULARITY`` and ``ROUNDNESS`` against their formulas. ``COMPACTNESS``, ``EDGE_STDDEV_INTENSITY``, ``POLYGONALITY_AVE``, ``HEXAGONALITY_AVE``, -``HEXAGONALITY_STDDEV``, ``DIAMETER_CIRCUMSCRIBING_CIRCLE``, ``DIAMETER_INSCRIBING_CIRCLE``, -``ROI_RADIUS_MEAN``, ``MIN_FERET_ANGLE``, ``MAX_FERET_ANGLE``, the three ``*_DIAM_MODE`` codes, -``PERCENT_TOUCHING`` and every chord code except ``ALLCHORDS_MIN`` are regression-only. The +``HEXAGONALITY_STDDEV``, ``MIN_FERET_ANGLE``, ``MAX_FERET_ANGLE``, the three ``*_DIAM_MODE`` +codes and every chord code except ``ALLCHORDS_MIN`` are regression-only. The out-of-core values of the ellipse family, the ``EDGE_*`` features and ``MASS_DISPLACEMENT`` are checked against the in-memory values of the same build. The *Example* values and the values the worked-example script checks beside them are pinned by that script. The comparisons with diff --git a/docs/source/Math/intensity_moments_index.inc b/docs/source/Math/intensity_moments_index.inc index 55354f812..25f5de6df 100644 --- a/docs/source/Math/intensity_moments_index.inc +++ b/docs/source/Math/intensity_moments_index.inc @@ -308,7 +308,7 @@ * - :ref:`IMOM_WRM_00 ` - Intensity weighted raw moment (0, 0) - — - - regression + - invariant - −331.7 * - :ref:`IMOM_WRM_01 ` - Intensity weighted raw moment (0, 1) diff --git a/docs/source/Math/neighbors_index.inc b/docs/source/Math/neighbors_index.inc index f651a7db8..b880622a8 100644 --- a/docs/source/Math/neighbors_index.inc +++ b/docs/source/Math/neighbors_index.inc @@ -18,7 +18,7 @@ * - :ref:`PERCENT_TOUCHING ` - Percent of the contour touching - — - - regression + - CellProfiler (exact) - 15.91 * - :ref:`CLOSEST_NEIGHBOR1_DIST ` - Distance to the closest neighbour diff --git a/docs/source/Math/roi_radius_index.inc b/docs/source/Math/roi_radius_index.inc index 1bc152bd0..5b0039337 100644 --- a/docs/source/Math/roi_radius_index.inc +++ b/docs/source/Math/roi_radius_index.inc @@ -13,7 +13,7 @@ * - :ref:`ROI_RADIUS_MEAN ` - Mean distance to the contour - — - - regression + - scikit-image (rel. 1e-3) - 0.9288 * - :ref:`ROI_RADIUS_MAX ` - Largest distance to the contour diff --git a/docs/source/Math/shape_moments_index.inc b/docs/source/Math/shape_moments_index.inc index 5d2c0f546..4bfdd2c53 100644 --- a/docs/source/Math/shape_moments_index.inc +++ b/docs/source/Math/shape_moments_index.inc @@ -308,7 +308,7 @@ * - :ref:`WEIGHTED_SPAT_MOMENT_00 ` - Weighted raw moment (0, 0) - — - - regression + - invariant - −95.4 * - :ref:`WEIGHTED_SPAT_MOMENT_01 ` - Weighted raw moment (0, 1) diff --git a/docs/source/featurelist.rst b/docs/source/featurelist.rst index c8499ad1a..bfa6f7201 100644 --- a/docs/source/featurelist.rst +++ b/docs/source/featurelist.rst @@ -484,10 +484,10 @@ Definitions, formulas and settings: :doc:`/Math/f_morphology`. - vetted: IMEA * - ``DIAMETER_CIRCUMSCRIBING_CIRCLE`` - Twice the largest distance from the ROI's centroid to the pixels of its outer borders, in pixels; the borders of holes are left out. - - regression + - vetted: analytic * - ``DIAMETER_INSCRIBING_CIRCLE`` - Twice the smallest distance from the ROI's centroid to the pixels of its outer borders, in pixels; the borders of holes are left out. - - regression + - vetted: analytic .. _features-roi-radius: @@ -505,7 +505,7 @@ Definitions, formulas and settings: :doc:`/Math/f_morphology`. - Validation * - ``ROI_RADIUS_MEAN`` - Mean over ROI pixels of the distance from a pixel to the nearest contour pixel, in pixels; contour pixels are at distance 0. - - regression + - vetted: scikit-image * - ``ROI_RADIUS_MAX`` - Maximum over ROI pixels of the distance from a pixel to the nearest contour pixel, in pixels: the radius of the largest disk inside the ROI, measured from a pixel centre. - vetted: analytic, scikit-image @@ -846,7 +846,7 @@ Definitions, formulas and settings: :doc:`/Math/f_morphology`. - vetted: CellProfiler * - ``PERCENT_TOUCHING`` - Percentage of the ROI's contour pixels that are adjacent, including diagonally, to a contour pixel of another ROI. - - regression + - vetted: CellProfiler * - ``CLOSEST_NEIGHBOR1_DIST`` - Distance in pixels from the ROI's centroid to the centroid of its nearest neighbor among the ROIs counted in NUM_NEIGHBORS. - vetted: CellProfiler @@ -1672,7 +1672,7 @@ Definitions, formulas and settings: :doc:`/Math/f_2dmoments`. - vetted: scikit-image * - ``WEIGHTED_SPAT_MOMENT_00`` - Raw moment of order (0, 0) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. - - regression + - invariant * - ``WEIGHTED_SPAT_MOMENT_01`` - Raw moment of order (0, 1) of the ROI shape with each pixel weighted by ln(d + 0.001), where d is as in the family note. - regression @@ -1959,7 +1959,7 @@ Definitions, formulas and settings: :doc:`/Math/f_2dmoments`. - vetted: scikit-image * - ``IMOM_WRM_00`` - Raw intensity moment of order (0, 0) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. - - regression + - invariant * - ``IMOM_WRM_01`` - Raw intensity moment of order (0, 1) with each pixel's intensity multiplied by ln(d + 0.001), where d is as in the family note. - regression diff --git a/tests/test_2d_morphology_analytic.h b/tests/test_2d_morphology_analytic.h index 4e624493a..2f9937451 100644 --- a/tests/test_2d_morphology_analytic.h +++ b/tests/test_2d_morphology_analytic.h @@ -84,3 +84,78 @@ void test_2d_morphology_roi_radius_disk_closed_form_analytic() << " -- it is not the distance from the disk's centre to its boundary"; } } + +// DIAMETER_INSCRIBING_CIRCLE and DIAMETER_CIRCUMSCRIBING_CIRCLE on the r=15 circle +// (calculate_circle_shape_values): twice the smallest and the largest distance from the centroid to +// a contour pixel. The centroid is the centre (21, 21) by symmetry, the contour pixels are the disk's +// edge pixels, and both extremes have closed forms: +// +// farthest the axial pixel (R, 0) is inside (R^2 <= R^2) and on the edge, and no disk pixel is +// farther than R, so the circumscribing diameter is 2R +// nearest the edge pixel at offset (1, R-1), derived for ROI_RADIUS_MAX above, so the +// inscribing diameter is 2 sqrt((R-1)^2 + 1) +// +// Both depend on the contour and the centroid being in one frame: shifting either by a pixel moves +// the two diameters apart by up to 2 sqrt(2). +void test_2d_morphology_circle_diameters_closed_form_analytic() +{ + const double R = 15.0; + std::vector> fvals; + calculate_circle_shape_values(fvals); + + // the precondition the closed forms rest on, not a claim about the centroid features + const double cx = fvals[static_cast(Nyxus::Feature2D::CENTROID_X)][0], + cy = fvals[static_cast(Nyxus::Feature2D::CENTROID_Y)][0]; + ASSERT_EQ(cx, 21.0); + ASSERT_EQ(cy, 21.0); + + SCOPED_TRACE("ANALYTIC_ORACLE__CIRCLE_DIAMETERS__R15"); + const double circ = 2.0 * R, insc = 2.0 * std::sqrt((R - 1.0) * (R - 1.0) + 1.0); + ASSERT_NEAR(fvals[static_cast(Nyxus::Feature2D::DIAMETER_CIRCUMSCRIBING_CIRCLE)][0], circ, 1e-12 * circ); + ASSERT_NEAR(fvals[static_cast(Nyxus::Feature2D::DIAMETER_INSCRIBING_CIRCLE)][0], insc, 1e-12 * insc); +} + +// The two diameters are measured to the ROI's outer borders only, the external contour imea reads: +// a hole's border is not part of the outline. The r=15 disk of the case above with a centred 3x3 +// hole keeps its centroid at the centre by symmetry and its outer border unchanged, so both closed +// forms hold as they do for the solid disk. The hole's border is 2 from the centre, and measuring +// to it would give an inscribing diameter of 4. +void test_2d_morphology_circle_diameters_holed_disk_analytic() +{ + const double R = 15.0; + std::vector> fvals; + calculate_disks_circle_values({ { 21, 21 } }, R, 1, fvals); + + // the precondition the closed forms rest on, not a claim about the centroid features + const double cx = fvals[static_cast(Nyxus::Feature2D::CENTROID_X)][0], + cy = fvals[static_cast(Nyxus::Feature2D::CENTROID_Y)][0]; + ASSERT_EQ(cx, 21.0); + ASSERT_EQ(cy, 21.0); + + SCOPED_TRACE("ANALYTIC_ORACLE__CIRCLE_DIAMETERS__R15_HOLED"); + const double circ = 2.0 * R, insc = 2.0 * std::sqrt((R - 1.0) * (R - 1.0) + 1.0); + ASSERT_NEAR(fvals[static_cast(Nyxus::Feature2D::DIAMETER_CIRCUMSCRIBING_CIRCLE)][0], circ, 1e-12 * circ); + ASSERT_NEAR(fvals[static_cast(Nyxus::Feature2D::DIAMETER_INSCRIBING_CIRCLE)][0], insc, 1e-12 * insc); +} + +// A ROI of several fragments has one outer border per fragment, and the diameters are measured from +// the ROI's centroid to all of them. Two disks of radius 10 with centres D = 40 apart have their +// centroid midway; the nearest outer-border pixel is the axial one facing it, D/2 - R away, and the +// farthest the axial one opposite, D/2 + R away, so the diameters are D - 2R = 20 and D + 2R = 60. +void test_2d_morphology_circle_diameters_two_fragments_analytic() +{ + const double R = 10.0, D = 40.0; + std::vector> fvals; + calculate_disks_circle_values({ { 13, 13 }, { 53, 13 } }, R, -1, fvals); + + // the precondition the closed forms rest on, not a claim about the centroid features + const double cx = fvals[static_cast(Nyxus::Feature2D::CENTROID_X)][0], + cy = fvals[static_cast(Nyxus::Feature2D::CENTROID_Y)][0]; + ASSERT_EQ(cx, 33.0); + ASSERT_EQ(cy, 13.0); + + SCOPED_TRACE("ANALYTIC_ORACLE__CIRCLE_DIAMETERS__TWO_R10_DISKS"); + const double circ = D + 2.0 * R, insc = D - 2.0 * R; + ASSERT_NEAR(fvals[static_cast(Nyxus::Feature2D::DIAMETER_CIRCUMSCRIBING_CIRCLE)][0], circ, 1e-12 * circ); + ASSERT_NEAR(fvals[static_cast(Nyxus::Feature2D::DIAMETER_INSCRIBING_CIRCLE)][0], insc, 1e-12 * insc); +} diff --git a/tests/test_2d_morphology_common.h b/tests/test_2d_morphology_common.h index dc2954723..a06289936 100644 --- a/tests/test_2d_morphology_common.h +++ b/tests/test_2d_morphology_common.h @@ -274,6 +274,54 @@ static void calculate_circle_shape_values(std::vector>& fval fvals = roi.fvals; } +// Filled digital disks of radius R centred at 'centres' under one label, each with a centred square +// hole of side 2 * hole_half + 1 when hole_half >= 0, and basic morphology + contour + the 3 circle +// diameters on them. The disks are scanned in raster order over a frame padded by 3. +static void calculate_disks_circle_values (const std::vector>& centres, double R, int hole_half, std::vector>& fvals) +{ + Fsettings s = make_shape2d_settings(); + + int xmax = 0, ymax = 0; + for (const auto& c : centres) + { + xmax = std::max(xmax, c.first + int(R) + 3); + ymax = std::max(ymax, c.second + int(R) + 3); + } + + LR roi(4); + bool first = true; + for (int y = 0; y <= ymax; y++) + for (int x = 0; x <= xmax; x++) + for (const auto& c : centres) + { + const int dx = x - c.first, dy = y - c.second; + if (dx * dx + dy * dy > R * R) + continue; + if (std::abs(dx) <= hole_half && std::abs(dy) <= hole_half) + continue; + if (first) { init_label_record_3(roi, x, y, 1); first = false; } + else update_label_record_3(roi, x, y, 1); + roi.raw_pixels.push_back(Pixel2(static_cast(x), static_cast(y), static_cast(1))); + } + roi.make_nonanisotropic_aabb(); + roi.aux_image_matrix = ImageMatrix(roi.raw_pixels); + roi.initialize_fvals(); + + BasicMorphologyFeatures basic; + basic.calculate(roi, s); + basic.save_value(roi.fvals); + + ContourFeature contour; + contour.calculate(roi, s); + contour.save_value(roi.fvals); + + EnclosingInscribingCircumscribingCircleFeature circle; + circle.calculate(roi, s); + circle.save_value(roi.fvals); + + fvals = roi.fvals; +} + // A filled digital disk of radius R -- {(x,y) : (x-c)^2 + (y-c)^2 <= R^2} on a frame padded by 3 on // every side -- with its contour and the three ROI_RADIUS_* statistics. The radius features are // vetted on disks rather than on the 8x8 shape2d raster for the reason PERIMETER is vetted on the diff --git a/tests/test_2d_morphology_skimage.h b/tests/test_2d_morphology_skimage.h index e25f02696..be5ef9f9f 100644 --- a/tests/test_2d_morphology_skimage.h +++ b/tests/test_2d_morphology_skimage.h @@ -45,7 +45,7 @@ static const ref_vals_map morphology_2d_skimage_circles_ref_vals{ {"PERIMETER", 999.25901807804496}, }; -// ROI_RADIUS_MAX / ROI_RADIUS_MEDIAN on filled digital disks, a third benchmark and so a third table +// ROI_RADIUS_MEAN / MAX / MEDIAN on filled digital disks, a third benchmark and so a third table // (SPEC 6.3.1). RoiRadiusFeature measures every ROI pixel against the ROI's own contour and reports // the mean/max/median of those distances; the reference is // skimage.segmentation.find_boundaries(connectivity=1, mode='inner') -- the foreground pixels @@ -59,15 +59,19 @@ static const ref_vals_map morphology_2d_skimage_circles_ref_vals{ // offset (1, R-1) -- which test_2d_morphology_analytic.h asserts as a second oracle on this same // fixture. // -// ROI_RADIUS_MEAN is a regression row. +// MEAN is the one of the three that depends on every contour pixel being where it is: a contour +// shifted by a pixel leaves MAX and MEDIAN of a disk where they are and moves MEAN. static const ref_vals_map morphology_2d_skimage_radius_disks_ref_vals{ // R=10: 317 pixels, 56 boundary pixels; MAX/(R-1) = 1.006154 + {"ROI_RADIUS_MEAN_R10", 2.7517095857010947}, {"ROI_RADIUS_MAX_R10", 9.055385138137417}, {"ROI_RADIUS_MEDIAN_R10", 2.23606797749979}, // R=20: 1257 pixels, 112 boundary pixels; MAX/(R-1) = 1.001384 + {"ROI_RADIUS_MEAN_R20", 6.047026418093844}, {"ROI_RADIUS_MAX_R20", 19.026297590440446}, {"ROI_RADIUS_MEDIAN_R20", 5.0}, // R=40: 5025 pixels, 224 boundary pixels; MAX/(R-1) = 1.000329 + {"ROI_RADIUS_MEAN_R40", 12.686020245442752}, {"ROI_RADIUS_MAX_R40", 39.01281840626232}, {"ROI_RADIUS_MEDIAN_R40", 11.045361017187261}, }; @@ -123,7 +127,7 @@ void test_2d_morphology_diameter_equal_area_skimage() assert_morphology_shape2d_skimage(fvals, Nyxus::Feature2D::DIAMETER_EQUAL_AREA, "DIAMETER_EQUAL_AREA"); } -// ROI_RADIUS_MAX / ROI_RADIUS_MEDIAN vs the skimage inner boundary on three filled disks +// ROI_RADIUS_MEAN / MAX / MEDIAN vs the skimage inner boundary on three filled disks // (tests/vetting/oracles/gen_morphology_radius_skimage.py). Three radii rather than one because a // single disk cannot distinguish a distance from its square -- 9.055 and 82 are each just a number // on one fixture. Across R = 10, 20, 40 a distance grows by 2.1x and 2.05x and a square by 4.4x and @@ -137,6 +141,7 @@ void test_2d_morphology_roi_radius_disks_skimage() std::vector> fvals; calculate_disk_radius_values(R, fvals); + assert_morphology_radius_disks_skimage(fvals, Nyxus::Feature2D::ROI_RADIUS_MEAN, "ROI_RADIUS_MEAN" + tag); assert_morphology_radius_disks_skimage(fvals, Nyxus::Feature2D::ROI_RADIUS_MAX, "ROI_RADIUS_MAX" + tag); assert_morphology_radius_disks_skimage(fvals, Nyxus::Feature2D::ROI_RADIUS_MEDIAN, "ROI_RADIUS_MEDIAN" + tag); } diff --git a/tests/test_2d_neighbor_cellprofiler.h b/tests/test_2d_neighbor_cellprofiler.h index 59a7aba12..a264d89e4 100644 --- a/tests/test_2d_neighbor_cellprofiler.h +++ b/tests/test_2d_neighbor_cellprofiler.h @@ -12,28 +12,28 @@ // neighborhood2d_scene_labels fixture from test_data.h at PIXELDISTANCE=1; // generator=tests/vetting/oracles/gen_neighbor_cellprofiler.py. // -// CellProfiler reproduces Nyxus BIT-IDENTICALLY on both features across all five ROIs -- residual +// CellProfiler reproduces Nyxus BIT-IDENTICALLY on all three features across all five ROIs -- residual // exactly 0, not merely small -- so these assert at the SPEC 7 exact tier, which is an ABSOLUTE // 1e-9 band. Per label, not aggregated: each ROI carries its own value and every one is asserted. // -// PERCENT_TOUCHING is NOT vetted here: CP and Nyxus state it with different definitions (Nyxus = -// contour pixels 8-adjacent to a neighbour / contour length; CP = object outline pixels overlapping -// a disk(distance+0.5)-dilated neighbour / perimeter). It is drift-pinned in -// test_2d_neighbor_regression.h and its required bounds are asserted in -// test_2d_neighbor_invariant.h. CLOSEST_NEIGHBOR2_DIST is not vetted here either: +// PERCENT_TOUCHING is vetted here too, at this recipe's distance of one pixel. Nyxus counts the +// contour pixels 8-adjacent to a neighbour's contour over the number of contour pixels; CellProfiler +// counts the outline pixels a neighbour, dilated by a disk of radius distance + 0.5, overlaps over +// the perimeter. At distance 1 that disk is the 3x3 neighbourhood and the two are the same count, so +// CellProfiler reproduces all five values exactly. CLOSEST_NEIGHBOR2_DIST is not vetted here: // CP measures the second-closest of ANY object, Nyxus the second-closest neighbour within the // search radius, so CP's column is a different quantity. See // tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md. static const ref_vals_map_by_label neighbor_2d_cellprofiler_ref_vals_by_label{ - {1, {{"NUM_NEIGHBORS", 4.0}, {"CLOSEST_NEIGHBOR1_DIST", 2.5}}}, - {2, {{"NUM_NEIGHBORS", 1.0}, {"CLOSEST_NEIGHBOR1_DIST", 2.5495097567963922}}}, - {3, {{"NUM_NEIGHBORS", 1.0}, {"CLOSEST_NEIGHBOR1_DIST", 2.5495097567963922}}}, - {4, {{"NUM_NEIGHBORS", 1.0}, {"CLOSEST_NEIGHBOR1_DIST", 2.5}}}, - {5, {{"NUM_NEIGHBORS", 1.0}, {"CLOSEST_NEIGHBOR1_DIST", 2.5495097567963922}}} + {1, {{"NUM_NEIGHBORS", 4.0}, {"CLOSEST_NEIGHBOR1_DIST", 2.5}, {"PERCENT_TOUCHING", 100.0}}}, + {2, {{"NUM_NEIGHBORS", 1.0}, {"CLOSEST_NEIGHBOR1_DIST", 2.5495097567963922}, {"PERCENT_TOUCHING", 50.0}}}, + {3, {{"NUM_NEIGHBORS", 1.0}, {"CLOSEST_NEIGHBOR1_DIST", 2.5495097567963922}, {"PERCENT_TOUCHING", 50.0}}}, + {4, {{"NUM_NEIGHBORS", 1.0}, {"CLOSEST_NEIGHBOR1_DIST", 2.5}, {"PERCENT_TOUCHING", 50.0}}}, + {5, {{"NUM_NEIGHBORS", 1.0}, {"CLOSEST_NEIGHBOR1_DIST", 2.5495097567963922}, {"PERCENT_TOUCHING", 50.0}}} }; // SPEC 7's exact tier verbatim: an absolute band, so ASSERT_NEAR rather than the relative agrees_gt -// the looser-tiered files use. The measured residual over the ten comparisons below is exactly 0 -- +// the looser-tiered files use. The measured residual over the fifteen comparisons below is exactly 0 -- // CellProfiler returns the same double Nyxus does -- so the band is what SPEC 7 sets rather than // what the measurement needs. A relative 1e-9 would also have been the looser of the two here, at // 2.5e-9 on CLOSEST_NEIGHBOR1_DIST. @@ -68,3 +68,12 @@ void test_2d_neighbor_counts_and_first_distance_cellprofiler() assert_neighbor2d_cellprofiler(roiData, label, Nyxus::Feature2D::CLOSEST_NEIGHBOR1_DIST, "CLOSEST_NEIGHBOR1_DIST"); } } + +void test_2d_neighbor_percent_touching_cellprofiler() +{ + std::unordered_map roiData; + calculate_neighbor_feature_values(roiData); + + for (int label : {1, 2, 3, 4, 5}) + assert_neighbor2d_cellprofiler(roiData, label, Nyxus::Feature2D::PERCENT_TOUCHING, "PERCENT_TOUCHING"); +} diff --git a/tests/test_2d_neighbor_regression.h b/tests/test_2d_neighbor_regression.h index eb84b53e6..107b58bec 100644 --- a/tests/test_2d_neighbor_regression.h +++ b/tests/test_2d_neighbor_regression.h @@ -10,16 +10,14 @@ // PIXELDISTANCE=1 -- the same recipe the oracle files use. SPEC 2 regression tier: a drift guard, // no oracle claim. // -// Nyxus counts contour pixels 8-adjacent to a neighbour over contour length; CellProfiler counts -// object outline pixels overlapping a disk(distance+0.5)-dilated neighbour over perimeter. Each of -// the four outer ROIs here touches the centre along one side of its 2x2 or 2x3 border: 2 of its 4, -// or 3 of its 6, edge pixels, hence 50. The bounds the feature must obey regardless are asserted in -// test_2d_neighbor_invariant.h. +// PERCENT_TOUCHING is vetted against CellProfiler on this fixture in test_2d_neighbor_cellprofiler.h; +// these pins are its drift guard. Each of the four outer ROIs here touches the centre along one side +// of its 2x2 or 2x3 border: 2 of its 4, or 3 of its 6, edge pixels, hence 50. The bounds the feature +// must obey regardless are asserted in test_2d_neighbor_invariant.h. // // The other eight features in the family are vetted at the exact tier against CellProfiler // (test_2d_neighbor_cellprofiler.h) and the analytic recomputation (test_2d_neighbor_analytic.h) on -// this same fixture, so they are deliberately not duplicated here: a second assertion of the same -// value at a looser band adds no coverage. +// this same fixture, and are not duplicated here. static const ref_vals_map_by_label neighbor_2d_regression_ref_vals_by_label{ {1, {{"PERCENT_TOUCHING", 100.0}}}, {2, {{"PERCENT_TOUCHING", 50.0}}}, diff --git a/tests/test_all.cc b/tests/test_all.cc index 8244e5539..743340ee1 100644 --- a/tests/test_all.cc +++ b/tests/test_all.cc @@ -2093,6 +2093,21 @@ TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_ROI_RADIUS_DISK_CLOSED_FORM_ANALYTIC) ASSERT_NO_THROW(test_2d_morphology_roi_radius_disk_closed_form_analytic()); } +TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_CLOSED_FORM_ANALYTIC) +{ + ASSERT_NO_THROW(test_2d_morphology_circle_diameters_closed_form_analytic()); +} + +TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_HOLED_DISK_ANALYTIC) +{ + ASSERT_NO_THROW(test_2d_morphology_circle_diameters_holed_disk_analytic()); +} + +TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_TWO_FRAGMENTS_ANALYTIC) +{ + ASSERT_NO_THROW(test_2d_morphology_circle_diameters_two_fragments_analytic()); +} + TEST(TEST_NYXUS, TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE) { ASSERT_NO_THROW(test_2d_morphology_roi_radius_disks_skimage()); @@ -2493,6 +2508,11 @@ TEST(TEST_NYXUS, TEST_2D_NEIGHBOR_COUNTS_AND_FIRST_DISTANCE_CELLPROFILER) ASSERT_NO_THROW(test_2d_neighbor_counts_and_first_distance_cellprofiler()); } +TEST(TEST_NYXUS, TEST_2D_NEIGHBOR_PERCENT_TOUCHING_CELLPROFILER) +{ + ASSERT_NO_THROW(test_2d_neighbor_percent_touching_cellprofiler()); +} + TEST(TEST_NYXUS, TEST_2D_NEIGHBOR_SECOND_DISTANCE_AND_ANGLES_ANALYTIC) { ASSERT_NO_THROW(test_2d_neighbor_second_distance_and_angles_analytic()); diff --git a/tests/vetting/audit/morphology_2d_golden_regen.md b/tests/vetting/audit/morphology_2d_golden_regen.md index 487c4f786..ef9f71e82 100644 --- a/tests/vetting/audit/morphology_2d_golden_regen.md +++ b/tests/vetting/audit/morphology_2d_golden_regen.md @@ -1,5 +1,9 @@ # Regenerating the 2D morphology oracle goldens +> **Current state.** The shape2d `PERIMETER` is 22.970562748477146, the summed closed walks of the +> outer border and the hole's, and is vetted against OpenCV's border following +> (`test_2d_contour_opencv.h`). The 26.935 quoted below is the earlier value. + Every generator here is offline; CI never invokes one. Each prints a paste-ready table **and** re-verifies every golden currently pinned in the header it feeds, exiting non-zero on any mismatch or on any pin it cannot produce. Run them from the repository root. diff --git a/tests/vetting/audit/morphology_2d_imea_vetting_report.md b/tests/vetting/audit/morphology_2d_imea_vetting_report.md index 79a9f3038..cdeca7055 100644 --- a/tests/vetting/audit/morphology_2d_imea_vetting_report.md +++ b/tests/vetting/audit/morphology_2d_imea_vetting_report.md @@ -1,5 +1,10 @@ # 2D morphology vs imea — vetting report +> **Current state.** The Nyxus shape2d `PERIMETER` the ISO transforms are fed is 22.970562748477146 +> (vetted against OpenCV in `test_2d_contour_opencv.h`), and the three transform pins are +> regenerated from it: 7.311757214045382, 8.384216364883956, 3.101065009354617. The 26.935 below is +> the earlier value. + This family's imea rows were not part of the planned gap, but running the oracle showed the tree disagreed with the registry in a way no naming check could catch. This report records what was measured and what changed. diff --git a/tests/vetting/audit/morphology_2d_radius_skimage_vetting_report.md b/tests/vetting/audit/morphology_2d_radius_skimage_vetting_report.md index 31f56ecd0..48bc5203f 100644 --- a/tests/vetting/audit/morphology_2d_radius_skimage_vetting_report.md +++ b/tests/vetting/audit/morphology_2d_radius_skimage_vetting_report.md @@ -1,5 +1,10 @@ # 2D ROI_RADIUS vs scikit-image — vetting report +> **Current state.** The traced contour now walks exactly the scikit-image inner-boundary pixels in +> absolute coordinates, so the (+1, +1) shift measured below no longer exists. `ROI_RADIUS_MEAN` +> agrees with the same reference on the three disks and is vetted alongside `MAX` and `MEDIAN` +> (`morphology.radius_disks`); on shape2d the three values are 0.3077, 1 and 0, the reference's. + Vetting record for the three `ROI_RADIUS_*` features. Two of them carry oracle rows; the third does not, and the last section measures exactly what stands in its way. diff --git a/tests/vetting/audit/morphology_2d_skimage_vetting_report.md b/tests/vetting/audit/morphology_2d_skimage_vetting_report.md index 4797ba36e..14b2a4b87 100644 --- a/tests/vetting/audit/morphology_2d_skimage_vetting_report.md +++ b/tests/vetting/audit/morphology_2d_skimage_vetting_report.md @@ -1,5 +1,9 @@ # 2D morphology vs scikit-image — vetting report +> **Current state.** The shape2d `PERIMETER` is 22.970562748477146 and is vetted against OpenCV's +> border following (`test_2d_contour_opencv.h`); scikit-image's `measure.perimeter` still gives +> 12.657 there. The 26.935 below is the earlier value. + Closes the two `oracle=skimage` gap rows (`DIAMETER_EQUAL_AREA`, `ROI_RADIUS_MEDIAN`), re-homes `PERIMETER` to the oracle that actually backs it, and puts the previously inline-pinned `ORIENTATION` / `EROSIONS_2_VANISH` goldens under generator verification. diff --git a/tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md b/tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md index 79bd25c7a..1d5c3f065 100644 --- a/tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md +++ b/tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md @@ -1,5 +1,10 @@ # 2D neighbour vs CellProfiler — vetting report +> **Current state.** `PERCENT_TOUCHING` agrees with CellProfiler on all five ROIs and is vetted +> (`test_2d_neighbor_percent_touching_cellprofiler`). The divergence recorded below came from the +> contour, which kept only 3 of a 2x2 square's 4 edge pixels; with all four, Nyxus gives 50 where +> CellProfiler does. + `NUM_NEIGHBORS` and `CLOSEST_NEIGHBOR1_DIST` have been marked `vetted` against CellProfiler since PR #390. This re-vet re-ran the tool rather than trusting that label, and the values hold — **exactly**, not approximately. What did not hold up was everything around them: the assertion band, the diff --git a/tests/vetting/audit/neighbor_2d_golden_regen.md b/tests/vetting/audit/neighbor_2d_golden_regen.md index 300e814e0..dc09e405d 100644 --- a/tests/vetting/audit/neighbor_2d_golden_regen.md +++ b/tests/vetting/audit/neighbor_2d_golden_regen.md @@ -1,5 +1,8 @@ # Regenerating the 2D neighbour goldens +> **Current state.** `PERCENT_TOUCHING` is vetted against CellProfiler; the generator no longer +> prints a divergence table for it, only for `CLOSEST_NEIGHBOR2_DIST`. + Concrete steps for each of the family's three tables. Everything runs offline; CI never invokes a reference tool. diff --git a/tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md b/tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md index 8244ec7b9..242fe3da0 100644 --- a/tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md +++ b/tests/vetting/audit/radial_2d_cellprofiler_vetting_report.md @@ -1,5 +1,11 @@ # 2D radial intensity distribution vs CellProfiler — vetting report +> **Current state.** Defect 1 below, the contour's one-pixel offset, no longer exists: contour +> pixels are ROI pixels in absolute coordinates. Defects 2 and 3 stand, and the 24 pins and the +> centre/radius pins in `test_2d_radial_mechanics.h` are re-measured on the corrected contour +> (centre (2,3), radius^2 10 against 13). Defect 2 below describes the searches as they are: each +> border of the contour searched on its own, the short radius coming from the outer border alone. + **Verdict: not vetted, and not vettable as implemented.** `FRAC_AT_D`, `MEAN_FRAC` and `RADIAL_CV` carry CellProfiler's three `RadialDistribution_*` names and CellProfiler's three help strings, and compute a different quantity under each of them. 21 of the 24 (feature × bin) values disagree by @@ -168,19 +174,21 @@ which weights each raw pixel by its distance to the contour). `cache.cpp:300-330 cloud and the contour onto the ROI bounding box separately for the GPU path and inherits the same one-pixel gap between them. -**2. `Pixel2::find_center` and `Pixel2::max_sqdist` return non-extremal answers here.** -Both are coarse-to-fine searches (`src/nyx/features/pixel.cpp:13-141`) that step through the contour -assuming it is an ordered walk. `LR::merge_multicontour` concatenates the outer contour and the -hole's, so the sequence they walk is two walks joined end to end and the descent settles in the wrong -basin. Measured on this ROI against a full linear scan of the same contour: +**2. `Pixel2::max_sqdist` returns a non-extremal answer here.** +`Pixel2::find_center` and `Pixel2::max_sqdist` are coarse-to-fine searches (`search_sqdist()` in +`src/nyx/features/pixel.cpp`). They search each border of the contour on its own, the outer border and +the hole's, and on a border longer than 10 pixels the coarse pass samples about every n/ln n-th pixel +and refines only around the best sample. The outer border here is a 14-pixel walk, sampled every 5th +pixel from its first, and the farthest pixel, (4,6), lies between the samples. Measured on this ROI +against a full linear scan of the same contour: | quantity | searched | exact scan | |---|---|---| -| centre pixel | (3,4) | (4,4) | +| centre pixel | (2,3) | (2,3) | | max squared distance from the centre | 10 | 13 | -The radius the feature normalises by is 23% short of the largest distance actually present, so **4 of -the 26 pixels have `r/r_max > 1`**. `exact_min_sqdist()` already exists beside `min_sqdist()` for +The radius the feature normalises by is 23% short of the largest distance actually present, so **1 of +the 26 pixels has `r/r_max > 1`**. `exact_min_sqdist()` already exists beside `min_sqdist()` for exactly this reason and is used by the neighbour features; there is no `exact_max_sqdist`. The `ROI_RADIUS_MEDIAN` demotion records the same approximation as a second defect underneath its units bug (`morphology_2d_skimage_vetting_report.md`). @@ -188,8 +196,8 @@ bug (`morphology_2d_skimage_vetting_report.md`). **3. The bin index is scaled by `num_bins - 1`.** `radial_distribution.cpp:81` computes `int(rat * (n-1))` and clamps to `n-1`. With `n = 8` that is 7 rings of width 1/7 plus a last bin nothing reaches unless `r >= r_max`. Combined with defect 2 the -last bin ends up holding 7 of the 26 pixels — 3 sitting exactly on `r_max` and the 4 beyond it — -more than any of the 7 real rings. CellProfiler scales by `bin_count` and gets 8 equal rings. +last bin ends up holding 4 of the 26 pixels — 3 sitting exactly on `r_max` and the 1 beyond it. +CellProfiler scales by `bin_count` and gets 8 equal rings. **4. `FRAC_AT_D` returns a pixel-count fraction.** `get_FracAtD()` is `radial_count_bins[i] / cached_num_pixels`. The name, the header comment and diff --git a/tests/vetting/audit/radial_2d_golden_regen.md b/tests/vetting/audit/radial_2d_golden_regen.md index 1bce179e0..90ee03ee2 100644 --- a/tests/vetting/audit/radial_2d_golden_regen.md +++ b/tests/vetting/audit/radial_2d_golden_regen.md @@ -1,5 +1,8 @@ # Regenerating the 2D radial goldens +> **Current state.** The contour offset listed below as a precondition no longer exists; the radial +> goldens are pinned on the corrected contour. + `FRAC_AT_D`, `MEAN_FRAC`, `RADIAL_CV` — 8 radial bins each, 24 pinned values in `tests/test_2d_radial_regression.h`. diff --git a/tests/vetting/audit/scan_moments_coverage.py b/tests/vetting/audit/scan_moments_coverage.py index 82747d915..e39145791 100644 --- a/tests/vetting/audit/scan_moments_coverage.py +++ b/tests/vetting/audit/scan_moments_coverage.py @@ -6,11 +6,12 @@ tree, and `report_features.py` joins it into `report_output.csv`. `--check` runs the acceptance checks, which live in scanlib.py. -This family reads the tree its own way, so it overrides scanlib's collect. Nothing here names a -feature on an assertion line: every case passes a golden TABLE to the looping helper -`assert_2d_geomoment_features(...)`, so coverage is resolved table by table -- which function uses -which table, and which features that table holds. Applying the shared line rule would find nothing -at all. +This family reads the tree its own way, so it overrides scanlib's collect. The oracle and +regression cases name no feature on an assertion line: each passes a golden TABLE to the looping +helper `assert_2d_geomoment_features(...)`, so their coverage is resolved table by table -- which +function uses which table, and which features that table holds. The invariant cases assert a +relation between two computed values and name the feature on the assertion line, which the shared +line rule reads. """ import os import re @@ -18,7 +19,8 @@ import scanlib -SOURCES = ["test_2d_moments_skimage.h", "test_2d_moments_regression.h", "test_2d_moments_common.h"] +SOURCES = ["test_2d_moments_skimage.h", "test_2d_moments_regression.h", "test_2d_moments_common.h", + "test_2d_moments_invariant.h"] ORACLE_SUFFIX = {"skimage": "skimage", "pyradiomics": "pyradiomics", "mirp": "mirp", "ibsi": "ibsi", "analytic": "analytic"} @@ -63,6 +65,11 @@ def collect(fam, feat_re): for tbl in USES.findall(block): for feat in tables.get(tbl, ()): covers.setdefault(feat, set()).add(fn) + # the shared rule: a feature named on an assertion line + for line in block.splitlines(): + if scanlib.ASSERTION.search(line): + for feat in feat_re.findall(line): + covers.setdefault(feat, set()).add(fn) asserted, oracles, regression, other = {}, {}, {}, {} for feat, fns in covers.items(): diff --git a/tests/vetting/audit/scan_morphology_coverage.py b/tests/vetting/audit/scan_morphology_coverage.py index 473fd6d95..b177cab9f 100644 --- a/tests/vetting/audit/scan_morphology_coverage.py +++ b/tests/vetting/audit/scan_morphology_coverage.py @@ -20,6 +20,7 @@ "test_2d_morphology_fraclac.h", "test_2d_morphology_analytic.h", "test_2d_morphology_regression.h", + "test_2d_morphology_invariant.h", # the contour tests assert PERIMETER and EDGE_INTEGRATED_INTENSITY on the traced borders "test_2d_contour_analytic.h", "test_2d_contour_invariant.h", diff --git a/tests/vetting/audit/scan_neighbor_coverage.py b/tests/vetting/audit/scan_neighbor_coverage.py index 36a685c2e..d4887820f 100644 --- a/tests/vetting/audit/scan_neighbor_coverage.py +++ b/tests/vetting/audit/scan_neighbor_coverage.py @@ -26,9 +26,6 @@ ] NOTE = { - "PERCENT_TOUCHING": ("no oracle by definition, not by defect: CellProfiler measures a different " - "quantity and diverges on 3/5 ROIs. Drift-pinned, with its construction " - "bounds asserted as invariants"), "NUM_NEIGHBORS": ("also pinned by test_2d_morphology_regression.h, which asserts it for label 1 " "while exercising an unrelated family"), } diff --git a/tests/vetting/config_recipes.md b/tests/vetting/config_recipes.md index be91d1772..1f58d591b 100644 --- a/tests/vetting/config_recipes.md +++ b/tests/vetting/config_recipes.md @@ -128,6 +128,14 @@ chosen reference tool (SPEC 5). Oracle tests reference a recipe by id; this file Nyxus m_{j,i}); weighted moments center on the intensity-weighted centroid; Hu returned raw (not log), 2D only; normalized moments NaN for order < 2. Used by: `test_2d_moments_skimage.h`. +## moments.disk_copies +- One r=12 disk, and two copies of it 50 pixels apart under one label, intensity a function of the + offset from each disk's own centre (`calculate_2d_moments_disk_copies_values()`, + `test_2d_moments_invariant.h`), at `make_2d_geomoment_settings()`. No oracle: the weighted moments + weigh a pixel by its distance to the contour, searched on each border separately, so every pixel of + the pair carries the weight it carries in one disk, and the zeroth weighted moments of the pair are + exactly twice those of one disk. + ## gabor.cpp_static_defaults - The `(frequency, angle)` set compiled into `GaborFeature::f0_theta_pairs` (`gabor.cpp`): f0 = {0, pi/4, pi/2, 3pi/4}, theta = {4, 16, 32, 64} **radians**. Every consumer of that vector @@ -318,17 +326,16 @@ oracle for the Nyxus-original features); it is not built in this tree, and the g `oracles/gen_morphology_radius_skimage.py`. - `find_boundaries(connectivity=1, mode='inner')` is the whole of the convention -- the foreground pixels 4-adjacent to background, and it is CellProfiler's own edge definition, which is why - `gen_morphology_{,wholeslide_}cellprofiler.py` already reproduce the `EDGE_*` statistics through it. - On the 8x8 mask the Nyxus contour **is** this boundary shifted by (+1,+1) -- all 18 coordinates, - measured pixel by pixel, not merely the same count. On the r=20 disk it returns 112 boundary + `gen_morphology_{,wholeslide_}cellprofiler.py` already reproduce the `EDGE_*` statistics through it, + and it is the set of pixels the Nyxus contour walks. On the r=20 disk it returns 112 boundary pixels, the count `bench_disk64_diagonal_boundary` records for the same 1257-pixel shape. The distance step carries no convention; the generator computes it twice, as an exhaustive minimum and as `scipy.ndimage.distance_transform_edt` over the complement of the boundary, and requires the two to agree, so no single implementation is taken on trust. -- Disks and not `morphology.shape2d_native` for the reason `morphology.perimeter_circles` exists: on - a 26-pixel object with a hole the two boundary conventions have nothing to converge to. Three radii - and not one because a single disk cannot distinguish a distance from its square -- either is just a - number on one fixture, while across a doubling of R a distance grows 2.1x and a square 4.2x. +- Disks and not `morphology.shape2d_native` because on 26 pixels, whose distances to the edge are all + 0 or 1, a distance and its square are the same number. Three radii and not one because a single + disk cannot distinguish a distance from its square either -- it is just a number on one fixture, + while across a doubling of R a distance grows 2.1x and a square 4.2x. - The same recipe also carries an `analytic` row for `ROI_RADIUS_MAX`, asserted in `test_2d_morphology_analytic.h`, and it is an **exact** closed form rather than a band: `MAX == sqrt((R-1)^2 + 1)`, the centre's distance to the boundary pixel at offset `(1, R-1)` -- that @@ -337,11 +344,34 @@ oracle for the Nyxus-original features); it is not built in this tree, and the g redundancy, and they check different things -- the skimage row that Nyxus agrees with a reference implementation of the definition, the analytic row that the value obeys the geometry with no tool involved and so could not be fooled by a reference sharing a mistake. -- `ROI_RADIUS_MAX` and `ROI_RADIUS_MEDIAN` agree to double precision. `ROI_RADIUS_MEAN` does **not**, - and stays a `regression` row: `ContourFeature::buildRegularContour` never subtracts the one-pixel - pad it traces in, so Nyxus measures against a boundary shifted (+1,+1). Shifting the reference the - same way reproduces all three values exactly; on a disk MAX and MEDIAN are unmoved by that shift - because the pixel attaining them moves with it, and MEAN is not. +- `ROI_RADIUS_MEAN`, `ROI_RADIUS_MAX` and `ROI_RADIUS_MEDIAN` agree to double precision. MEAN is the + one that needs every contour pixel to be where it is: a contour shifted by a pixel leaves MAX and + MEDIAN of a disk where they are, because the pixel attaining them moves with it, and moves MEAN. + +## morphology.circle_r15 +- The filled circle r=15 built by `calculate_circle_shape_values()` (`test_2d_morphology_common.h`): + `((x-21)/15)^2 + ((y-21)/15)^2 <= 1` on a 43x43 frame, at `make_shape2d_settings()`. Oracle: + `analytic`. Used by: `test_2d_morphology_analytic.h` for `DIAMETER_INSCRIBING_CIRCLE` and + `DIAMETER_CIRCUMSCRIBING_CIRCLE`, and `test_2d_morphology_imea.h` for + `DIAMETER_MIN_ENCLOSING_CIRCLE`. +- The two diameters are twice the smallest and the largest distance from the centroid to a pixel + of the ROI's outer borders, the external contour imea reads (circle.cpp, after imea's macro); a + hole's border is not measured, and a ROI of several fragments is measured to every fragment's + outer border. The same disk with a centred 3x3 hole, and two r=10 disks 40 apart + (`calculate_disks_circle_values()`), are asserted beside it. The centroid is the centre by symmetry; the farthest edge + pixel is the axial `(R, 0)` and the nearest the one at offset `(1, R-1)` derived under + `morphology.radius_disks`, so the diameters are `2R = 30` and `2 sqrt((R-1)^2 + 1) = 28.0713`, + exactly. Both depend on the contour and the centroid being in one frame. +- imea is not an oracle for these two: its own `shape_measurements_2d` reports 46.86 and 12.65 on + this circle, values no circle of radius 15 can have. + +## morphology.fractal_perimeter_borders +- ROIs of several borders built in `test_2d_morphology_invariant.h`, at `make_shape2d_settings()`. + No oracle: `FRACT_DIM_PERIMETER` walks each border separately and sums the borders per stride, and + the assertions are relations that summing has to keep. Two separate copies of an r=12 disk have + exactly the dimension of one copy. A 17x17 square, whose walk every stride cuts at its corners, + with a separate 3-pixel line no longer than the larger strides, which takes its closed length + there, has dimension exactly 1. ## morphology.caliper_ellipse - The filled ellipse a=20, b=10 built by `calculate_ellipse_caliper_values()` @@ -514,10 +544,11 @@ oracle for the Nyxus-original features); it is not built in this tree, and the g direction angle; CP's `SecondClosestDistance` ranges over ANY object, whereas Nyxus reports the second-closest neighbour *within the search radius*. Different quantities, so they go to the analytic oracle. -- **Outside this recipe:** `PERCENT_TOUCHING`. Nyxus counts distinct contour pixels 8-adjacent to a - neighbour over contour length; CP counts outline pixels overlapping a `disk(distance+0.5)`-dilated - neighbour over perimeter. No CP distance method reproduces it -- measured divergence on 3 of the 5 - ROIs, up to 33.3 percentage points. Drift-pinned in `test_2d_neighbor_regression.h`, with its +- **`PERCENT_TOUCHING` is CP-vetted at this recipe's distance of 1.** Nyxus counts distinct contour + pixels 8-adjacent to a neighbour's contour over the number of contour pixels; CP counts outline + pixels a `disk(distance+0.5)`-dilated neighbour overlaps over the perimeter. At distance 1 that disk + is the 3x3 neighbourhood, the same count, and CP reproduces all five ROIs exactly. At a larger + distance the two definitions part. Also drift-pinned in `test_2d_neighbor_regression.h`, with its construction bounds in `test_2d_neighbor_invariant.h`. ## ngtdm.ibsi_phantom_2d diff --git a/tests/vetting/coverage_report.md b/tests/vetting/coverage_report.md index 1b58b3a37..a821ec7a8 100644 --- a/tests/vetting/coverage_report.md +++ b/tests/vetting/coverage_report.md @@ -2,8 +2,8 @@ _Generated by check_coverage.py from oracle_coverage.csv. Do not edit by hand._ -Features vetted by >=1 oracle: 636/758 (84%) -regression: 122 invariant: 0 untested: 0 +Features vetted by >=1 oracle: 640/758 (84%) +regression: 118 invariant: 0 untested: 0 | family | total | vetted | regression | invariant | untested | |---|---|---|---|---|---| @@ -17,8 +17,8 @@ regression: 122 invariant: 0 untested: 0 | imq | 6 | 4 | 2 | 0 | 0 | | intensity_histogram | 47 | 43 | 4 | 0 | 0 | | moments | 180 | 118 | 62 | 0 | 0 | -| morphology | 113 | 77 | 36 | 0 | 0 | -| neighbor | 9 | 8 | 1 | 0 | 0 | +| morphology | 113 | 80 | 33 | 0 | 0 | +| neighbor | 9 | 9 | 0 | 0 | 0 | | ngldm | 38 | 33 | 5 | 0 | 0 | | ngtdm | 10 | 10 | 0 | 0 | 0 | | radial | 3 | 0 | 3 | 0 | 0 | diff --git a/tests/vetting/matrix/morphology.md b/tests/vetting/matrix/morphology.md index c48406b8c..63004b504 100644 --- a/tests/vetting/matrix/morphology.md +++ b/tests/vetting/matrix/morphology.md @@ -94,19 +94,16 @@ oracle cell, which fixture it is measured on. | run mode | fixture | verdict | oracle / reason | |---|---|---|---| -| in-RAM (`calculate`) | disks R = 10, 20, 40 | **VALID** | `skimage` on `morphology.radius_disks`, and `analytic` for `MAX` on the same recipe | -| in-RAM (`calculate`) | 8×8 `shape2d` | **VALID-BUT-PRODUCTION-ONLY** | no tool reproduces it *on this fixture*; recipe `morphology.shape2d_native`, drift guard only | +| in-RAM (`calculate`) | disks R = 10, 20, 40 | **VALID** | `skimage` on `morphology.radius_disks` for all three, and `analytic` for `MAX` on the same recipe | +| in-RAM (`calculate`) | 8×8 `shape2d` | drift guard | recipe `morphology.shape2d_native`; the skimage reference gives the same three values, but on 26 pixels a distance and its square coincide, so the vetting claim lives on the disks | | out-of-core (`osized_calculate`) | disk64 | **impl-defect** | the two paths disagree; recipe `morphology.disk64_forced_ooc` | | whole-slide (`SINGLEROI=true`) | disk64 frame | **VALID-BUT-PRODUCTION-ONLY** | a real config no tool reproduces; recipe `morphology.disk64_wholeslide` | -**Why the shape2d cell is production-only and the disk cell is not.** The reference — -`find_boundaries(connectivity=1, mode='inner')` plus a minimum over it — reproduces `MAX` and -`MEDIAN` on a disk to double precision and reproduces nothing on the 8×8 raster. The difference is -`buildRegularContour`, which reports every contour pixel one pixel right and one pixel down of where -it is: on the 8×8 mask the traced contour **is** the skimage inner boundary shifted by (+1, +1), all -18 pixels, and shifting the reference the same way reproduces all three Nyxus values. On a disk -`MAX` and `MEDIAN` are unmoved by that shift because the pixel attaining them moves with it, so they -promote there and `MEAN` does not, at either fixture. +**Why the vetting claim lives on the disks.** The reference — `find_boundaries(connectivity=1, +mode='inner')` plus a minimum over it — reproduces all three statistics to double precision on the +disks and on the 8×8 raster alike, because the traced contour walks exactly the reference's +boundary pixels. On the raster every distance is 0 or 1 (`MEAN` = 8/26, `MAX` = 1, `MEDIAN` = 0), so +a squared distance would read the same; across three radii it would not. ### The whole-slide cell is reached, and it is a distance-to-corner map @@ -122,7 +119,7 @@ image: | | whole-slide | segmented | |---|---|---| | pixels measured | 4096 (the frame) | 1257 (the disk) | -| `ROI_RADIUS_MEAN` | 24.490797504013532 | 6.087109772358636 | +| `ROI_RADIUS_MEAN` | 24.490797504013532 | 6.047026418093836 | | `ROI_RADIUS_MAX` | 45.254833995939045 | 19.026297590440446 | | `ROI_RADIUS_MEDIAN` | 25.495097567963924 | 5.0 | @@ -147,17 +144,16 @@ differ is `K`. Measured on `bench_disk64_diagonal_boundary`: | | in-RAM | out-of-core | | |---|---|---|---| -| contour pixels | 112 | 112 | same size | -| `ROI_RADIUS_MEAN` | 6.087109772358636 | 7.169174091182726 | **+17.8%** | -| `ROI_RADIUS_MEDIAN` | 5.0 | 6.708203932499369 | **+34%** | +| contour pixels | 112 | 112 | the same 112 edge pixels | +| `ROI_RADIUS_MEAN` | 6.047026418093836 | 7.423593657857253 | **+22.8%** | +| `ROI_RADIUS_MEDIAN` | 5.0 | 7.0 | **+40%** | | `ROI_RADIUS_MAX` | 19.026297590440446 | 19.026297590440446 | bit-identical | -Same count, different pixels: `buildRegularContour_nontriv` and `buildRegularContour` do not return -the same 112. `MEAN` and `MEDIAN` rise, so some ROI pixels are farther from the out-of-core contour, -and `MAX` is unchanged, so the pixel attaining it is not among them. No translation of the inner -boundary reproduces the out-of-core numbers — searched over every shift in ±4 — so this is pinned as -a characterization rather than explained as an offset, and the explanation is left to whoever -corrects the builder. +Same pixels, different frame: `buildRegularContour_nontriv` returns the disk's 112 edge pixels +relative to the ROI's bounding box, whose origin is (12, 12), and `buildRegularContour` returns them +in absolute coordinates, the frame of the ROI pixels both paths measure. The scikit-image inner +boundary shifted by (−12, −12) reproduces all three out-of-core values exactly, and unshifted all +three in-RAM ones. `MAX` happens to come out the same in both frames. `impl-defect` rather than `INVALID`, for the reason the `PERIMETER` cell already gives: the config is legitimate and reachable production code, and `CLAUDE.md` requires the two paths to return identical diff --git a/tests/vetting/matrix/neighbor.md b/tests/vetting/matrix/neighbor.md index e56e679dc..59ca7cd24 100644 --- a/tests/vetting/matrix/neighbor.md +++ b/tests/vetting/matrix/neighbor.md @@ -12,7 +12,7 @@ is one axis wide, and the matrix is short for a real reason rather than for want | pixel distance | verdict | recipe / oracle | |---|---|---| -| 1 | VALID | `neighbor.scene2d_radius1` — cellprofiler (2 features) + analytic (6 features) | +| 1 | VALID | `neighbor.scene2d_radius1` — cellprofiler (3 features) + analytic (6 features) | | ≥ 2 | NOT MEASURED | reachable and meaningful; see below | | 0 | INVALID | degenerate — the radius gate is `mind > radius²`, so nothing is anyone's neighbour | diff --git a/tests/vetting/not_covered.md b/tests/vetting/not_covered.md index 582009db8..2405aaf7e 100644 --- a/tests/vetting/not_covered.md +++ b/tests/vetting/not_covered.md @@ -22,7 +22,7 @@ Regenerate by re-deriving both sets; keep this file in step with each reorg wave Both totals are the sum of the two tables below, counting each distinct file once and skipping any row struck through as closed, so they can be recomputed from the tables. -### A.1 Correctly absent — plumbing, fixtures and framework self-tests (24 files) +### A.1 Correctly absent — plumbing, fixtures and framework self-tests (26 files) These assert no feature value, so they have no `(feature × config × oracle)` row by construction (SPEC §1). Recording them here so their absence is a documented decision, not an oversight. @@ -33,7 +33,7 @@ These assert no feature value, so they have no `(feature × config × oracle)` r | `test_arrow_mechanics.h` | 2 | Arrow + Parquet writer plumbing | | `test_arrow_file_name_mechanics.h` | 1 | output-file naming rules | | `test_2d_glcm_mechanics.h` | 1 | guards the `GLCM_OFFSET` default (a setting, not a value) | -| `test_2d_radial_mechanics.h` | 3 | contour frame, centre pixel and normalising radius — the wiring the radial goldens depend on. **Uncredited by decision, not by construction:** see the note below the table | +| `test_2d_radial_mechanics.h` | 6 | contour frame, centre pixel and normalising radius — the wiring the radial goldens depend on — and the one-pixel, two-pixel and two-fragment ROIs. **Uncredited by decision, not by construction:** see the note below the table | | `test_initialization_mechanics.h` | 1 | environment init | | `test_2d_omezarr_mechanics.h` | 14 | OME-Zarr tile/raw loader, including the load-time intensity map on signed and real-valued stores, and the unsigned accessor on every sample class it cannot convert | | `test_2d_intensity_degenerate_roi_mechanics.h` | 3 | the intensity ratios — `QCOD`, `UNIFORMITY_PIU` and `COV` — on a populated ROI of one grey level, read before the output NaN substitution, and the empty-ROI guard | @@ -42,6 +42,8 @@ These assert no feature value, so they have no `(feature × config × oracle)` r | `test_2d_intensity_constant_roi_mechanics.py` | 4 | an ROI of one intensity end to end, on a mapped and on an identity-map slide, through the in-RAM and the out-of-core paths; pins the reported values, not an oracle | | `test_3d_nifti_offset_map_mechanics.py` | 4 | the NIfTI load-time maps on a fractional `scl_slope` — rounded on the offset map under `--preserve-hu`, exact on the stored map without it — and a non-finite voxel taking grey level 0 either way; loader plumbing, no oracle | | `test_roi_blacklist_mechanics.h` | 1 | ROI blacklisting | +| `test_2d_moments_mechanics.h` | 1 | the whole-slide weighting on a slide one pixel wide or high, whose contour has fewer than 4 distinct corners; a degenerate-input guard | +| `test_2d_contour_mechanics.h` | 1 | `BorderedContour` refuses to bind a temporary pixel vector; a compile-time property of the type | | `test_2d_tiff_loader_mechanics.h` | 1 | uint32 strip loader | | `test_2d_ooc_invariant.py` | 9 | out-of-core == in-RAM equality; spans all features, per-feature rows would be meaningless | | `test_2d_ooc_mechanics.py` | 1 | oversized-montage failure path | @@ -52,14 +54,20 @@ These assert no feature value, so they have no `(feature × config × oracle)` r **`test_2d_radial_mechanics.h` is the one row here that is a judgement rather than a construction.** Its third case does read three feature values, so the rule the rest of this table rests on — asserts no feature value, therefore no `(feature × config × oracle)` row — does not by itself place it. It is -uncredited for a different reason: all three cases pin behaviour that -`audit/radial_2d_cellprofiler_vetting_report.md` §6 shows is wrong (defects 1-3), so each correction -should invalidate the assertions that characterize the defect it fixes. Listing it in `current_test` +uncredited for a different reason: its second and third case pin behaviour that +`audit/radial_2d_cellprofiler_vetting_report.md` §6 shows is wrong (defects 2-3), so each correction +should invalidate the assertions that characterize the defect it fixes. The other cases are +requirements, kept beside the wiring they exercise and under the same exclusion. Listing it in `current_test` for `FRAC_AT_D`, `MEAN_FRAC` and `RADIAL_CV` would make those defects acceptance criteria for the three features (PR #444 review, item 4). `audit/scan_radial_coverage.py` carries the exclusion as a declared `UNCREDITED` entry and fails in both directions, so the decision cannot be reversed by editing only the registry. +`test_2d_contour_invariant.h` is credited on the `PERIMETER` and `EDGE_*` rows, and two of its +functions sit outside them: the distance-search invariants, which assert `Pixel2::min_sqdist` and +`max_sqdist` against exhaustive scans rather than a feature value, and so have no row for the reason +this table gives. + ### A.2 Assert feature values but no row lists them — real gaps (7 files) These do produce feature numbers, so a `current_test` entry is missing. Ordered by how much the @@ -361,7 +369,7 @@ carry numbers. **An oracle-suffixed test asserted a feature the registry says has no oracle (2D neighbour).** `test_2d_neighbor_percent_touching_enclosed_analytic` lived in `test_2d_neighbor_analytic.h` and -asserted `PERCENT_TOUCHING`, whose registry row correctly reads `status=regression` with an empty +asserted `PERCENT_TOUCHING`, whose registry row then read `status=regression` with an empty oracle. Every coverage scanner in this tree -- `check_test_names.py`, `report_feature_tests.py`, the per-family `scan_*` scripts -- attributes an oracle from the test function's **name suffix**, so a scan would have credited the feature with `oracle=analytic`. What the function actually asserts are @@ -540,8 +548,8 @@ convention, and the registry says so in `flag=impl-defect`. |---|---:|---:|---| | 2D `PERIMETER` | 131.88225099390849 | 112.0 | 15%, and the out-of-core value is an integer | | 2D `DIAMETER_EQUAL_PERIMETER` | 41.97942430353313 | 35.65070725258456 | the same ratio, inherited | -| 2D `ROI_RADIUS_MEAN` | 6.087109772358636 | 7.169174091182726 | +17.8% | -| 2D `ROI_RADIUS_MEDIAN` | 5.0 | 6.708203932499369 | +34% | +| 2D `ROI_RADIUS_MEAN` | 6.047026418093836 | 7.423593657857253 | +22.8%; the same 112 edge pixels, out of core relative to the bounding box | +| 2D `ROI_RADIUS_MEDIAN` | 5.0 | 7.0 | +40%, same cause | `ContourFeature::calculate()` sums Euclidean step lengths around the contour; `ContourFeature::osized_calculate()` sets `fval_PERIMETER = (StatsInt) K.size()`, the contour pixel diff --git a/tests/vetting/oracle_coverage.csv b/tests/vetting/oracle_coverage.csv index d9a5abaac..1c87d7a1d 100644 --- a/tests/vetting/oracle_coverage.csv +++ b/tests/vetting/oracle_coverage.csv @@ -97,7 +97,8 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,EROSIONS_2_VANISH,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_regression.h;test_2d_ooc_invariant.py,,"An implementation of the Nyxus definition (4-connected cross, count minus one) on a fixture where it and scikit-image's square-footprint count differ.",convention-mismatch,audit,"Regression pin. scikit-image's erosion with a 3x3 square, counting every erosion, also gives 1 on this shape, but Nyxus erodes with the 4-connected 3x3 cross (erosion.h) and counts one fewer, so the agreement confirms neither the structuring element nor the counting convention. gen_morphology_skimage.py",, 2D,EROSIONS_2_VANISH_COMPLEMENT,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_regression.h,,Degenerate on the shape2d fixture (golden 0 -- the complement erodes away immediately); needs a fixture with an interior hole to vet the count vs skimage.,convention-mismatch,tracker,,, 2D,FRACT_DIM_BOXCOUNT,morphology,vetted,fraclac,agreed,morphology.fractal_blob512,,test_2d_morphology_fraclac.h;test_2d_morphology_fraclac.py,,,,audit,"vetted vs ImageJ/FracLac box-count on the 512x512 blob fixture (tests/data/fractal_blob512_seg.ome.tif) = 1.8706, same-method direct match at 1% (test_2d_morphology_fraclac.h). Also test_2d_morphology_fraclac.py recovers analytic ground-truth dimensions: filled square -> 2.0, straight line -> 1.0, Sierpinski triangle -> 1.585.",, -2D,FRACT_DIM_PERIMETER,morphology,vetted,fraclac,agreed,morphology.fractal_blob512,,test_2d_morphology_fraclac.h;test_2d_morphology_fraclac.py,,,,audit,"vetted vs ImageJ/FracLac edge box-count on the blob512 fixture = 1.0493, cross-method vs Nyxus' Richardson divider (1.0572, 0.8% gap), asserted at 3% (test_2d_morphology_fraclac.h). Also test_2d_morphology_fraclac.py: Koch snowflake and smooth-disk perimeter dimensions match.",, +2D,FRACT_DIM_PERIMETER,morphology,vetted,fraclac,agreed,morphology.fractal_blob512,,test_2d_morphology_fraclac.h;test_2d_morphology_fraclac.py;test_2d_morphology_invariant.h,,,,audit,"vetted vs ImageJ/FracLac edge box-count on the blob512 fixture = 1.0493, cross-method vs Nyxus' Richardson divider (1.0572, 0.8% gap), asserted at 3% (test_2d_morphology_fraclac.h). Also test_2d_morphology_fraclac.py: Koch snowflake and smooth-disk perimeter dimensions match.",, +2D,FRACT_DIM_PERIMETER,morphology,invariant,,agreed,morphology.fractal_perimeter_borders,exact,test_2d_morphology_fraclac.h;test_2d_morphology_fraclac.py;test_2d_morphology_invariant.h,test_2d_morphology_invariant.h,,,in-tree,"Relations the per-border divider walk has to keep, recipe morphology.fractal_perimeter_borders: two separate copies of a disk have exactly the dimension of one copy (both the summed length and the step count double at every stride), and a border no longer than the stride is measured at its closed length, so a square plus a separate 3-pixel line, every border measured the same at every stride, has dimension exactly 1. status=invariant, not vetted: no tool computes the divider dimension; it is vetted cross-method against FracLac in the fraclac row.",TEST_NYXUS.TEST_2D_MORPHOLOGY_FRACTAL_PERIMETER_TWO_COPIES_INVARIANT;TEST_NYXUS.TEST_2D_MORPHOLOGY_FRACTAL_PERIMETER_SHORT_BORDER_INVARIANT, 2D,MIN_FERET_ANGLE,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_regression.h,,Definition review; do not mark agreed unless a tool matches within 5%.,promote-after-deepdive;convention-mismatch,tracker,,, 2D,MAX_FERET_ANGLE,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_regression.h,,Definition review; do not mark agreed unless a tool matches within 5%.,promote-after-deepdive;convention-mismatch,tracker,,, 2D,STAT_FERET_DIAM_MIN,morphology,vetted,imea,agreed,morphology.caliper_ellipse,rel=6e-2,test_2d_morphology_imea.h;test_2d_morphology_regression.h,,,,audit,"vetted vs imea 0.3.5 shape_measurements_2d(dalpha=10) on the a=20 b=10 ellipse, recipe morphology.caliper_ellipse. Goldens regenerated at the matched angular step (the previous set mixed dalpha 9/10 and Nassenstein MIN matched no run), which let the tolerance tighten 10% -> 6%. The shape2d-raster values are regression snapshots, not imea numbers.",, @@ -155,22 +156,25 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,HEXAGONALITY_AVE,morphology,regression,,na,morphology.shape2d_native,,test_2d_morphology_regression.h,,none (Nyxus/WIPP-unique),no-mainstream-oracle,tracker,,, 2D,HEXAGONALITY_STDDEV,morphology,regression,,na,morphology.shape2d_native,,test_2d_morphology_regression.h,,none (Nyxus/WIPP-unique),no-mainstream-oracle,tracker,,, 2D,DIAMETER_MIN_ENCLOSING_CIRCLE,morphology,vetted,imea,agreed,morphology.caliper_ellipse,rel=6e-2,test_2d_morphology_imea.h;test_2d_morphology_regression.h,,,,audit,"vetted vs imea 0.3.5 shape_measurements_2d(dalpha=10) on the a=20 b=10 ellipse, recipe morphology.caliper_ellipse. Goldens regenerated at the matched angular step (the previous set mixed dalpha 9/10 and Nassenstein MIN matched no run), which let the tolerance tighten 10% -> 6%. The shape2d-raster values are regression snapshots, not imea numbers.",, -2D,DIAMETER_CIRCUMSCRIBING_CIRCLE,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_regression.h,,,convention-mismatch,tracker,"Left regression: Nyxus (circle.cpp, ported from imea macro.py) computes this as imea's max/min centroid-to-contour distance approximation, NOT a true geometric circle. It is convention-sensitive (Nyxus contour tracing + the centroid-1 offset): a symmetric circle r=15 yields circ=35.6/insc=23.3 instead of ~30/~30, so no external numeric oracle agrees -> documented-convention, not promoted. (DIAMETER_MIN_ENCLOSING_CIRCLE IS vetted vs cv2/imea.)",, -2D,DIAMETER_INSCRIBING_CIRCLE,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_regression.h,,,convention-mismatch,tracker,"Left regression: Nyxus (circle.cpp, ported from imea macro.py) computes this as imea's max/min centroid-to-contour distance approximation, NOT a true geometric circle. It is convention-sensitive (Nyxus contour tracing + the centroid-1 offset): a symmetric circle r=15 yields circ=35.6/insc=23.3 instead of ~30/~30, so no external numeric oracle agrees -> documented-convention, not promoted. (DIAMETER_MIN_ENCLOSING_CIRCLE IS vetted vs cv2/imea.)",, +2D,DIAMETER_CIRCUMSCRIBING_CIRCLE,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_analytic.h;test_2d_morphology_regression.h,,,,tracker,"Drift guard on shape2d. Nyxus (circle.cpp, ported from imea macro.py) computes this as twice the largest / smallest distance from the centroid to a pixel of the ROI's outer borders (holes excluded, as imea's RETR_EXTERNAL contour), not a true geometric circle. Vetted by closed form on the r=15 circle, recipe morphology.circle_r15. imea's own end-to-end values are not an oracle for it: on the r=15 circle imea 0.3.5 reports 46.86 / 12.65.",, +2D,DIAMETER_CIRCUMSCRIBING_CIRCLE,morphology,vetted,analytic,agreed,morphology.circle_r15,exact,test_2d_morphology_analytic.h;test_2d_morphology_regression.h,test_2d_morphology_analytic.h,,,in-tree,"closed form on the r=15 circle, recipe morphology.circle_r15: the centroid is the centre by symmetry, the farthest edge pixel is the axial (R, 0) and the nearest is the one at offset (1, R-1), so DIAMETER_CIRCUMSCRIBING_CIRCLE = 2R = 30 and DIAMETER_INSCRIBING_CIRCLE = 2 sqrt((R-1)^2 + 1) = 28.0713; exact. The same disk with a centred 3x3 hole gives the same two values, the hole's border not being measured; two r=10 disks 40 apart, one outer border per fragment and all of them measured from the midway centroid, give D - 2R = 20 and D + 2R = 60.",TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_CLOSED_FORM_ANALYTIC;TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_HOLED_DISK_ANALYTIC;TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_TWO_FRAGMENTS_ANALYTIC, +2D,DIAMETER_INSCRIBING_CIRCLE,morphology,regression,,benign,morphology.shape2d_native,,test_2d_morphology_analytic.h;test_2d_morphology_regression.h,,,,tracker,"Drift guard on shape2d. Nyxus (circle.cpp, ported from imea macro.py) computes this as twice the largest / smallest distance from the centroid to a pixel of the ROI's outer borders (holes excluded, as imea's RETR_EXTERNAL contour), not a true geometric circle. Vetted by closed form on the r=15 circle, recipe morphology.circle_r15. imea's own end-to-end values are not an oracle for it: on the r=15 circle imea 0.3.5 reports 46.86 / 12.65.",, +2D,DIAMETER_INSCRIBING_CIRCLE,morphology,vetted,analytic,agreed,morphology.circle_r15,exact,test_2d_morphology_analytic.h;test_2d_morphology_regression.h,test_2d_morphology_analytic.h,,,in-tree,"closed form on the r=15 circle, recipe morphology.circle_r15: the centroid is the centre by symmetry, the farthest edge pixel is the axial (R, 0) and the nearest is the one at offset (1, R-1), so DIAMETER_CIRCUMSCRIBING_CIRCLE = 2R = 30 and DIAMETER_INSCRIBING_CIRCLE = 2 sqrt((R-1)^2 + 1) = 28.0713; exact. The same disk with a centred 3x3 hole gives the same two values, the hole's border not being measured; two r=10 disks 40 apart, one outer border per fragment and all of them measured from the midway centroid, give D - 2R = 20 and D + 2R = 60.",TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_CLOSED_FORM_ANALYTIC;TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_HOLED_DISK_ANALYTIC;TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_TWO_FRAGMENTS_ANALYTIC, 2D,GEODETIC_LENGTH,morphology,vetted,imea,agreed,morphology.shape2d_native,exact,test_2d_morphology_imea.h,,,,audit,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 22.971), a gap inherited from PERIMETER.",, 2D,THICKNESS,morphology,vetted,imea,agreed,morphology.shape2d_native,exact,test_2d_morphology_imea.h,,,,audit,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 22.971), a gap inherited from PERIMETER.",, -2D,ROI_RADIUS_MEAN,morphology,regression,,disagrees,morphology.shape2d_native,rel=1e-3,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py,,skimage find_boundaries(mode='inner') + a minimum over it (gen_morphology_radius_skimage.py),,in-tree,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances. Drift guard on shape2d: 18 of the 26 pixels are edge pixels at distance 0 and the other 8 are one pixel from the edge, so MEAN = 8/26 = 0.3077. The scikit-image reference (find_boundaries(connectivity=1, mode='inner') plus a minimum over it) gives the same value; gen_morphology_radius_skimage.py prints it on every run.",TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,bench_shape8_concave_holed -2D,ROI_RADIUS_MAX,morphology,regression,,na,morphology.shape2d_native,rel=1e-3,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row.",TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,bench_shape8_concave_holed -2D,ROI_RADIUS_MEDIAN,morphology,regression,,na,morphology.shape2d_native,rel=1e-3,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row.",TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,bench_shape8_concave_holed -2D,ROI_RADIUS_MAX,morphology,vetted,skimage,agreed,morphology.radius_disks,rel=1e-3,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d for the reason PERIMETER is vetted on the circles benchmark: on 26 pixels with a hole the two boundary conventions have nothing to converge to. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py.",TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,bench_radius_disks -2D,ROI_RADIUS_MEDIAN,morphology,vetted,skimage,agreed,morphology.radius_disks,rel=1e-3,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d for the reason PERIMETER is vetted on the circles benchmark: on 26 pixels with a hole the two boundary conventions have nothing to converge to. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py. Unmoved by the contour offset on a disk, unlike ROI_RADIUS_MEAN: the pixel attaining it moves with the shift.",TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,bench_radius_disks -2D,ROI_RADIUS_MAX,morphology,vetted,analytic,agreed,morphology.radius_disks,exact,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"vetted against an exact closed form for a filled digital disk, not a band: ROI_RADIUS_MAX is the centre's distance to the nearest boundary pixel, and that pixel is at offset (1, R-1) -- inside since 1+(R-1)^2 <= R^2, boundary since its neighbour (1, R) is not, and nearer than the axial (R, 0) since R^2-2R+2 < R^2. So MAX == sqrt((R-1)^2+1), matched to 1e-12 relative at R = 10, 20, 40; the (1, R-1) pixel was confirmed nearest at R = 5, 10, 20, 40, 80 and 160. A second oracle alongside the skimage row (SPEC 3.1 redundancy), checking what that row cannot: that the value obeys the geometry rather than merely agreeing with a reference implementation that could share a mistake. The same form gives MAX/(R-1) = sqrt(1+1/(R-1)^2) -> 1, i.e. linear growth in R, where a squared distance would give ratios 9.1, 19.1, 39.0 and growing. Asserted in test_2d_morphology_analytic.h; no generator, the reference is geometry.",TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISK_CLOSED_FORM_ANALYTIC,bench_radius_disks -2D,ROI_RADIUS_MEAN,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,ROI_RADIUS_MEDIAN,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,ROI_RADIUS_MAX,morphology,regression,,agrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest. This feature is the one of the three that AGREES across the two paths here; the row exists so the agreement is pinned and a fix that moved the whole contour could not break it silently.",,bench_disk64_diagonal_boundary -2D,ROI_RADIUS_MEAN,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 24.490797504013532 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,ROI_RADIUS_MAX,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_analytic.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 45.254833995939045 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly 32*sqrt(2), the frame centre to a corner. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary -2D,ROI_RADIUS_MEDIAN,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 25.495097567963924 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly sqrt(650). No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,ROI_RADIUS_MEAN,morphology,regression,,disagrees,morphology.shape2d_native,rel=1e-3,test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,skimage find_boundaries(mode='inner') + a minimum over it (gen_morphology_radius_skimage.py),,in-tree,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances. Drift guard on shape2d: 18 of the 26 pixels are edge pixels at distance 0 and the other 8 are one pixel from the edge, so MEAN = 8/26 = 0.3077. The scikit-image reference (find_boundaries(connectivity=1, mode='inner') plus a minimum over it) gives the same value; gen_morphology_radius_skimage.py prints it on every run.",TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,bench_shape8_concave_holed +2D,ROI_RADIUS_MAX,morphology,regression,,na,morphology.shape2d_native,rel=1e-3,test_2d_morphology_analytic.h;test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row.",TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,bench_shape8_concave_holed +2D,ROI_RADIUS_MEDIAN,morphology,regression,,na,morphology.shape2d_native,rel=1e-3,test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row.",TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,bench_shape8_concave_holed +2D,ROI_RADIUS_MAX,morphology,vetted,skimage,agreed,morphology.radius_disks,rel=1e-3,test_2d_morphology_analytic.h;test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d because on 26 pixels, whose distances to the edge are all 0 or 1, a distance and its square are the same number. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py.",TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,bench_radius_disks +2D,ROI_RADIUS_MEAN,morphology,vetted,skimage,agreed,morphology.radius_disks,rel=1e-3,test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d because on 26 pixels, whose distances to the edge are all 0 or 1, a distance and its square are the same number. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py.",TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,bench_radius_disks +2D,ROI_RADIUS_MEDIAN,morphology,vetted,skimage,agreed,morphology.radius_disks,rel=1e-3,test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d because on 26 pixels, whose distances to the edge are all 0 or 1, a distance and its square are the same number. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py. Unmoved by the contour offset on a disk, unlike ROI_RADIUS_MEAN: the pixel attaining it moves with the shift.",TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,bench_radius_disks +2D,ROI_RADIUS_MAX,morphology,vetted,analytic,agreed,morphology.radius_disks,exact,test_2d_morphology_analytic.h;test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,,,,in-tree,"vetted against an exact closed form for a filled digital disk, not a band: ROI_RADIUS_MAX is the centre's distance to the nearest boundary pixel, and that pixel is at offset (1, R-1) -- inside since 1+(R-1)^2 <= R^2, boundary since its neighbour (1, R) is not, and nearer than the axial (R, 0) since R^2-2R+2 < R^2. So MAX == sqrt((R-1)^2+1), matched to 1e-12 relative at R = 10, 20, 40; the (1, R-1) pixel was confirmed nearest at R = 5, 10, 20, 40, 80 and 160. A second oracle alongside the skimage row (SPEC 3.1 redundancy), checking what that row cannot: that the value obeys the geometry rather than merely agreeing with a reference implementation that could share a mistake. The same form gives MAX/(R-1) = sqrt(1+1/(R-1)^2) -> 1, i.e. linear growth in R, where a squared distance would give ratios 9.1, 19.1, 39.0 and growing. Asserted in test_2d_morphology_analytic.h; no generator, the reference is geometry.",TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISK_CLOSED_FORM_ANALYTIC,bench_radius_disks +2D,ROI_RADIUS_MEAN,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,ROI_RADIUS_MEDIAN,morphology,regression,,disagrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,impl-defect,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,ROI_RADIUS_MAX,morphology,regression,,agrees,morphology.disk64_forced_ooc,rel=1e-9,test_2d_morphology_analytic.h;test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_ooc_regression.py,,,in-tree,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest. This feature is the one of the three that AGREES across the two paths here; the row exists so the agreement is pinned and a fix that moved the whole contour could not break it silently.",,bench_disk64_diagonal_boundary +2D,ROI_RADIUS_MEAN,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 24.490797504013532 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,ROI_RADIUS_MAX,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_analytic.h;test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 45.254833995939045 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly 32*sqrt(2), the frame centre to a corner. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary +2D,ROI_RADIUS_MEDIAN,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_invariant.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 25.495097567963924 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly sqrt(650). No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,PERIMETER,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_contour_opencv.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,DIAMETER_EQUAL_PERIMETER,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_morphology_imea.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py;test_nyxus.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,MASS_DISPLACEMENT,morphology,regression,,na,morphology.disk64_segmented_inram,rel=1e-9,test_2d_morphology_cellprofiler.h;test_2d_morphology_regression.py;test_2d_ooc_invariant.py,test_2d_morphology_regression.py,,,in-tree,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary @@ -200,7 +204,7 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,PERIMETER,morphology,vetted,analytic,agreed,contour.closed_forms,exact,test_2d_contour_analytic.h;test_2d_contour_invariant.h;test_2d_contour_opencv.h;test_2d_morphology_regression.h;test_2d_morphology_regression.py;test_2d_morphology_skimage.h;test_2d_ooc_regression.py;test_nyxus.py,test_2d_contour_analytic.h,,,in-tree,"closed forms, recipe contour.closed_forms: 4(n-1) for an n x n square, 44 + 12 + 4*sqrt(2) for a 12x12 square with a centred 4x4 hole, 48 for two separate squares 6x6 and 8x8 under one label, 2(n-1) for a one-pixel line, 0 for a single pixel. The ring is the assertion test_name names; the other shapes are asserted beside it in the same file.",TEST_NYXUS.TEST_2D_CONTOUR_RING_ANALYTIC, 2D,DIAMETER_EQUAL_PERIMETER,morphology,regression,,na,morphology.disk64_wholeslide,rel=1e-9,test_2d_morphology_imea.h;test_2d_morphology_regression.py;test_2d_ooc_regression.py;test_nyxus.py,test_2d_morphology_regression.py,,production-only-cell,in-tree,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest.",,bench_disk64_diagonal_boundary 2D,NUM_NEIGHBORS,neighbor,vetted,cellprofiler,agreed,neighbor.scene2d_radius1,abs=1e-9,test_2d_morphology_regression.h;test_2d_neighbor_cellprofiler.h;test_2d_neighbor_invariant.py,test_2d_neighbor_cellprofiler.h,,,audit,"vetted vs CellProfiler 4.2.8 MeasureObjectNeighbors (Adjacent method, neighbors_are_objects) on the neighborhood2d_scene_labels fixture at PIXELDISTANCE=1, recipe neighbor.scene2d_radius1; agreement is EXACTLY 0 -- CP reproduces Nyxus bit for bit on all five ROIs -- asserted per ROI at abs=1e-9, SPEC 7's exact tier in test_2d_neighbor_cellprofiler.h. Re-measured 2026-08-20: the previous assertion was ASSERT_NEAR at 1e-4 absolute with no tolerance recorded here; an intermediate rel=1e-9 was corrected to the absolute band SPEC 7 defines. gen_neighbor_cellprofiler.py re-verifies every pin in the header it feeds; tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md",TEST_NYXUS.TEST_2D_NEIGHBOR_COUNTS_AND_FIRST_DISTANCE_CELLPROFILER,bench_scene7_5roi_enclosed -2D,PERCENT_TOUCHING,neighbor,regression,,disagrees,neighbor.scene2d_radius1,rel=1e-3,test_2d_neighbor_regression.h;test_2d_neighbor_invariant.h;test_2d_neighbor_invariant.py,test_2d_neighbor_regression.h,none (CP definition differs; empirically 3/5 ROIs),documented-convention,audit,"no promotable oracle, by definition rather than by defect. Nyxus counts distinct contour pixels 8-adjacent to a neighbour over contour length; CellProfiler counts object outline pixels overlapping a disk(distance+0.5)-dilated neighbour over perimeter, and no CP distance method (Adjacent/Expand/Within) reproduces it -- measured divergence on 3 of the 5 ROIs, up to 33.3 percentage points. Drift-pinned per ROI at rel=1e-3 in test_2d_neighbor_regression.h; the bounds it must obey by construction (0 <= PT <= 100, and exactly 100 for a fully enclosed ROI) are asserted in test_2d_neighbor_invariant.h. Those bound assertions previously carried an _analytic suffix, which credited this feature with an oracle it does not have. tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md",TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_REGRESSION;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_BOUNDED_INVARIANT;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_ENCLOSED_INVARIANT,bench_scene7_5roi_enclosed +2D,PERCENT_TOUCHING,neighbor,vetted,cellprofiler,agreed,neighbor.scene2d_radius1,exact,test_2d_neighbor_cellprofiler.h;test_2d_neighbor_invariant.h;test_2d_neighbor_invariant.py;test_2d_neighbor_regression.h,test_2d_neighbor_regression.h,none (CP definition differs; empirically 3/5 ROIs),,audit,"vetted vs CellProfiler 4.2.8 MeasureObjectNeighbors on the 5-ROI scene, residual exactly 0 on all five ROIs. Nyxus counts the contour pixels 8-adjacent to a neighbour's contour over the number of contour pixels; CellProfiler counts the outline pixels a neighbour dilated by disk(distance+0.5) overlaps over the perimeter. At this recipe's distance of 1 that disk is the 3x3 neighbourhood, so the two are the same count. Also drift-pinned per ROI in test_2d_neighbor_regression.h; the bounds it must obey by construction (0 <= PT <= 100, and exactly 100 for a fully enclosed ROI) are asserted in test_2d_neighbor_invariant.h. gen_neighbor_cellprofiler.py.",TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_CELLPROFILER;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_REGRESSION;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_BOUNDED_INVARIANT;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_ENCLOSED_INVARIANT,bench_scene7_5roi_enclosed 2D,CLOSEST_NEIGHBOR1_DIST,neighbor,vetted,cellprofiler,agreed,neighbor.scene2d_radius1,abs=1e-9,test_2d_neighbor_cellprofiler.h;test_2d_neighbor_invariant.py,test_2d_neighbor_cellprofiler.h,,,audit,"vetted vs CellProfiler 4.2.8 MeasureObjectNeighbors (Adjacent method, neighbors_are_objects) on the neighborhood2d_scene_labels fixture at PIXELDISTANCE=1, recipe neighbor.scene2d_radius1; agreement is EXACTLY 0 -- CP reproduces Nyxus bit for bit on all five ROIs -- asserted per ROI at abs=1e-9, SPEC 7's exact tier in test_2d_neighbor_cellprofiler.h. Re-measured 2026-08-20: the previous assertion was ASSERT_NEAR at 1e-4 absolute with no tolerance recorded here; an intermediate rel=1e-9 was corrected to the absolute band SPEC 7 defines. gen_neighbor_cellprofiler.py re-verifies every pin in the header it feeds; tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md",TEST_NYXUS.TEST_2D_NEIGHBOR_COUNTS_AND_FIRST_DISTANCE_CELLPROFILER,bench_scene7_5roi_enclosed 2D,CLOSEST_NEIGHBOR1_ANG,neighbor,vetted,analytic,agreed,neighbor.scene2d_radius1,abs=1e-9,test_2d_neighbor_analytic.h,test_2d_neighbor_analytic.h,,,audit,"vetted vs an independent numpy recomputation of the documented closed form on the neighborhood2d_scene_labels fixture at PIXELDISTANCE=1, recipe neighbor.scene2d_radius1; agreement 1.2e-16 worst case across the family (29 of 30 values bit-identical), asserted per ROI at abs=1e-9, SPEC 7's exact tier in test_2d_neighbor_analytic.h. The neighbour graph the closed form is evaluated on is itself vetted against CellProfiler, so this is not circular. CP cannot vet these: its AngleBetweenNeighbors is the SUBTENDED angle and its SecondClosestDistance ranges over ANY object, both different quantities. Re-measured 2026-08-20: the previous assertion was ASSERT_NEAR at 1e-4 absolute with no tolerance recorded here; an intermediate rel=1e-9 was corrected to the absolute band SPEC 7 defines. gen_neighbor_analytic.py re-verifies every pin in the header it feeds; tests/vetting/audit/neighbor_2d_analytic_vetting_report.md",TEST_NYXUS.TEST_2D_NEIGHBOR_SECOND_DISTANCE_AND_ANGLES_ANALYTIC,bench_scene7_5roi_enclosed 2D,CLOSEST_NEIGHBOR2_DIST,neighbor,vetted,analytic,agreed,neighbor.scene2d_radius1,abs=1e-9,test_2d_neighbor_analytic.h,test_2d_neighbor_analytic.h,,,audit,"vetted vs an independent numpy recomputation of the documented closed form on the neighborhood2d_scene_labels fixture at PIXELDISTANCE=1, recipe neighbor.scene2d_radius1; agreement 1.2e-16 worst case across the family (29 of 30 values bit-identical), asserted per ROI at abs=1e-9, SPEC 7's exact tier in test_2d_neighbor_analytic.h. The neighbour graph the closed form is evaluated on is itself vetted against CellProfiler, so this is not circular. CP cannot vet these: its AngleBetweenNeighbors is the SUBTENDED angle and its SecondClosestDistance ranges over ANY object, both different quantities. Re-measured 2026-08-20: the previous assertion was ASSERT_NEAR at 1e-4 absolute with no tolerance recorded here; an intermediate rel=1e-9 was corrected to the absolute band SPEC 7 defines. gen_neighbor_analytic.py re-verifies every pin in the header it feeds; tests/vetting/audit/neighbor_2d_analytic_vetting_report.md",TEST_NYXUS.TEST_2D_NEIGHBOR_SECOND_DISTANCE_AND_ANGLES_ANALYTIC,bench_scene7_5roi_enclosed @@ -441,7 +445,8 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,HU_M5,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, 2D,HU_M6,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, 2D,HU_M7,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, -2D,WEIGHTED_SPAT_MOMENT_00,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_00,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_invariant.h;test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,WEIGHTED_SPAT_MOMENT_00,moments,invariant,,agreed,moments.disk_copies,rel=1e-12,test_2d_moments_invariant.h;test_2d_moments_regression.h,test_2d_moments_invariant.h,,,in-tree,"Two separate copies of a disk weigh exactly twice one copy, recipe moments.disk_copies: the distance to the contour is searched on each border separately, so no pixel's weight depends on the other copy. rel=1e-12 because the two sums add the same terms in a different order. status=invariant, not vetted: the distance itself is approximate (see the regression row), and this relation holds for it all the same.",TEST_NYXUS.TEST_2D_MOMENTS_WEIGHTED_DISK_COPIES_INVARIANT, 2D,WEIGHTED_SPAT_MOMENT_01,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, 2D,WEIGHTED_SPAT_MOMENT_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, 2D,WEIGHTED_SPAT_MOMENT_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, @@ -531,7 +536,8 @@ dim,feature,family,status,oracle,agreement,config_recipe,tolerance,current_test, 2D,IMOM_HU5,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, 2D,IMOM_HU6,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, 2D,IMOM_HU7,moments,vetted,skimage,agreed,moments.skimage_regionprops,rel=1e-6,test_2d_moments_skimage.h,,,,in-tree,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md.",, -2D,IMOM_WRM_00,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_00,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_invariant.h;test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, +2D,IMOM_WRM_00,moments,invariant,,agreed,moments.disk_copies,rel=1e-12,test_2d_moments_invariant.h;test_2d_moments_regression.h,test_2d_moments_invariant.h,,,in-tree,"Two separate copies of a disk weigh exactly twice one copy, recipe moments.disk_copies: the distance to the contour is searched on each border separately, so no pixel's weight depends on the other copy. rel=1e-12 because the two sums add the same terms in a different order. status=invariant, not vetted: the distance itself is approximate (see the regression row), and this relation holds for it all the same.",TEST_NYXUS.TEST_2D_MOMENTS_WEIGHTED_DISK_COPIES_INVARIANT, 2D,IMOM_WRM_01,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, 2D,IMOM_WRM_02,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, 2D,IMOM_WRM_03,moments,regression,,,moments.skimage_regionprops,rel=1e-6,test_2d_moments_regression.h,,,,in-tree,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md.",, diff --git a/tests/vetting/oracles/gen_morphology_radius_skimage.py b/tests/vetting/oracles/gen_morphology_radius_skimage.py index 73efa5453..737b192d4 100644 --- a/tests/vetting/oracles/gen_morphology_radius_skimage.py +++ b/tests/vetting/oracles/gen_morphology_radius_skimage.py @@ -24,16 +24,16 @@ scipy.ndimage.distance_transform_edt over the complement of the boundary -- with the two required to agree, so neither implementation is taken on trust. -WHY DISKS AND NOT THE 8x8 shape2d RASTER. Same reason PERIMETER is vetted on the circles benchmark: -on a 26-pixel object with a hole the two boundary conventions have nothing to converge to. A disk -also has an EXACT closed form for the maximum -- sqrt((R-1)^2+1), the centre's distance to the -boundary pixel at offset (1, R-1) -- which test_2d_morphology_analytic.h asserts as a second oracle. -MAX/(R-1) = sqrt(1+1/(R-1)^2) then converges to 1 as 1/(2(R-1)^2): 0.62%, 0.14%, 0.03% at +WHY DISKS AND NOT THE 8x8 shape2d RASTER. On 26 pixels, whose distances to the edge are all 0 or 1, +a distance and its square are the same number, so the raster cannot catch a units error; three disks +can. A disk also has an EXACT closed form for the maximum -- sqrt((R-1)^2+1), the centre's distance +to the boundary pixel at offset (1, R-1) -- which test_2d_morphology_analytic.h asserts as a second +oracle. MAX/(R-1) = sqrt(1+1/(R-1)^2) then converges to 1 as 1/(2(R-1)^2): 0.62%, 0.14%, 0.03% at R = 10, 20, 40, i.e. LINEAR growth in R. -WHAT IS VETTED. MAX and MEDIAN agree with this reference to double precision and are pinned below. -The script also prints the reference's MEAN, MAX and MEDIAN on the three disks and on the shape2d -raster, which are the values Nyxus reports there. +WHAT IS VETTED. MEAN, MAX and MEDIAN agree with this reference to double precision on the three +disks and are pinned below. The script also prints the reference on the shape2d raster, where it +equals the Nyxus drift guards in test_2d_morphology_regression.h. Provenance: tool=scikit-image 0.26.0, scipy 1.17.1, numpy 2.4.6; env=nyxus_mirp (conda); generator=tests/vetting/oracles/gen_morphology_radius_skimage.py. Run offline; CI never invokes it. @@ -109,8 +109,10 @@ def main(): for R in RADII: m = disk(R) ref, nb = radius_stats(m) + got[f"ROI_RADIUS_MEAN_R{R}"] = ref["MEAN"] got[f"ROI_RADIUS_MAX_R{R}"] = ref["MAX"] got[f"ROI_RADIUS_MEDIAN_R{R}"] = ref["MEDIAN"] + print(f'\t{{"ROI_RADIUS_MEAN_R{R}", {ref["MEAN"]!r}}},') print(f'\t{{"ROI_RADIUS_MAX_R{R}", {ref["MAX"]!r}}},') print(f'\t{{"ROI_RADIUS_MEDIAN_R{R}", {ref["MEDIAN"]!r}}},') print(f"\t# R={R}: {int(m.sum())} pixels, {nb} boundary pixels, " diff --git a/tests/vetting/oracles/gen_morphology_skimage.py b/tests/vetting/oracles/gen_morphology_skimage.py index c7ae31de2..b72e31c9c 100644 --- a/tests/vetting/oracles/gen_morphology_skimage.py +++ b/tests/vetting/oracles/gen_morphology_skimage.py @@ -36,7 +36,7 @@ PERIMETER -- skimage.measure.perimeter (the 4-neighbourhood boundary walk regionprops.perimeter uses) on the circles benchmark, where it and the Nyxus chain-code contour walk agree to ~4e-15. - They do NOT agree on the small shape2d mask (12.657 vs 26.935), so PERIMETER is vetted on the + They do NOT agree on the small shape2d mask (12.657 vs 22.971), so PERIMETER is vetted on the circles benchmark only. Provenance: tool=scikit-image 0.26.0; numpy; env=nyxus_mirp (conda); diff --git a/tests/vetting/oracles/gen_neighbor_analytic.py b/tests/vetting/oracles/gen_neighbor_analytic.py index c962d6dbb..80e638a75 100644 --- a/tests/vetting/oracles/gen_neighbor_analytic.py +++ b/tests/vetting/oracles/gen_neighbor_analytic.py @@ -21,9 +21,9 @@ Agreement with Nyxus is 1.2e-16 worst case (29 of the 30 values are bit-identical; the exception is ANG_BW_NEIGHBORS_STDDEV on label 1, one ulp), so the header asserts at the SPEC 7 exact tier. -NOT covered here: PERCENT_TOUCHING, which is not a centroid closed form. It has no promotable oracle --- CellProfiler uses a different definition -- and is drift-pinned in test_2d_neighbor_regression.h, -with its required bounds asserted in test_2d_neighbor_invariant.h. +NOT covered here: PERCENT_TOUCHING, which is not a centroid closed form. It is vetted against +CellProfiler (gen_neighbor_cellprofiler.py), drift-pinned in test_2d_neighbor_regression.h, and its +required bounds are asserted in test_2d_neighbor_invariant.h. Provenance: tool=analytic (numpy, version printed by this script); generator= tests/vetting/oracles/gen_neighbor_analytic.py. Run offline; CI never invokes it. diff --git a/tests/vetting/oracles/gen_neighbor_cellprofiler.py b/tests/vetting/oracles/gen_neighbor_cellprofiler.py index da076a4f7..159ad3c4b 100644 --- a/tests/vetting/oracles/gen_neighbor_cellprofiler.py +++ b/tests/vetting/oracles/gen_neighbor_cellprofiler.py @@ -6,7 +6,7 @@ parsed out of the header: a validation list kept here would only ever compare this script against itself, so a hand-edited header would go unnoticed. -CellProfiler reproduces Nyxus BIT-IDENTICALLY on both vetted features across all five ROIs -- +CellProfiler reproduces Nyxus BIT-IDENTICALLY on all three vetted features across all five ROIs -- residual exactly 0 -- which is what lets the header assert at the SPEC 7 exact tier. Result of this comparison (see the printed table): @@ -16,11 +16,10 @@ of ANY object (global); Nyxus reports the 2nd-closest *neighbor within the search radius*, i.e. 0 when an ROI has <2 in-radius neighbors. Not CP-vettable; vetted analytically instead (gen_neighbor_analytic.py). - PERCENT_TOUCHING -- CP != Nyxus (convention gap, 3/5 ROIs). Nyxus counts - contour pixels 8-adjacent to a neighbor / contour length; CP counts object - outline pixels overlapping a disk(distance+0.5)-dilated neighbor / perimeter. - No CP distance method (Adjacent/Expand/Within) reproduces Nyxus. Stays - regression with a documented convention divergence. + PERCENT_TOUCHING -- CP == Nyxus (VETS it). Nyxus counts contour pixels + 8-adjacent to a neighbor / contour length; CP counts object outline pixels a + neighbor dilated by disk(distance+0.5) overlaps / perimeter. At distance 1 that + disk is the 3x3 neighbourhood, so the two are the same count. Environment: a dedicated CellProfiler env is required to RUN this generator (cellprofiler-core + centrosome + the cellprofiler module package, headless). @@ -54,11 +53,8 @@ SCENE_FIXTURE = "neighborhood2d_scene_labels" TEST_H = os.path.join(TESTS, "test_2d_neighbor_cellprofiler.h") TABLE = "neighbor_2d_cellprofiler_ref_vals_by_label" -REGRESSION_H = os.path.join(TESTS, "test_2d_neighbor_regression.h") -REGRESSION_TABLE = "neighbor_2d_regression_ref_vals_by_label" - -# the two features CP and Nyxus compute the same way; the header pins CP's own digits for them -CP_VETTED = ("NUM_NEIGHBORS", "CLOSEST_NEIGHBOR1_DIST") +# the features CP and Nyxus compute the same way; the header pins CP's own digits for them +CP_VETTED = ("NUM_NEIGHBORS", "CLOSEST_NEIGHBOR1_DIST", "PERCENT_TOUCHING") ABSTOL = 1e-9 # SPEC 7 exact tier, an ABSOLUTE band; the measured residual is exactly 0 @@ -196,18 +192,10 @@ def main(): print(" UNPINNED %s: CP vets it but the header pins nothing" % gap) print("") - print("# documented divergences -- NOT vetted against CP, recorded so the gap stays measured") - reg = parse_pins(REGRESSION_H, REGRESSION_TABLE) - print(" %2s %9s %9s %8s" % ("L", "PT_cp", "PT_nyxus", "D2_cp")) - pt_div = 0 + print("# documented divergence -- NOT vetted against CP, recorded so the gap stays measured") + print(" %2s %8s" % ("L", "D2_cp")) for l in LABELS: - pt_cp = float(cp["PERCENT_TOUCHING"][l - 1]) - pt_ny = reg.get(l, {}).get("PERCENT_TOUCHING", float("nan")) - if abs(pt_cp - pt_ny) > 1e-9 * max(abs(pt_ny), 1.0): - pt_div += 1 - print(" %2d %9.4f %9.4f %8.4f" - % (l, pt_cp, pt_ny, float(cp["CLOSEST_NEIGHBOR2_DIST"][l - 1]))) - print(" PERCENT_TOUCHING diverges on %d/5 ROIs -> stays regression (convention gap)" % pt_div) + print(" %2d %8.4f" % (l, float(cp["CLOSEST_NEIGHBOR2_DIST"][l - 1]))) print("") print("%d verified, %d failed, %d unproducible, %d unpinned" % (nok, nfail, nmiss, len(missing))) diff --git a/tests/vetting/report_output.csv b/tests/vetting/report_output.csv index 8fb8e1677..7f3d1ac98 100644 --- a/tests/vetting/report_output.csv +++ b/tests/vetting/report_output.csv @@ -97,7 +97,8 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,morphology,EROSIONS_2_VANISH,regression,,morphology.shape2d_native,,benign,,,convention-mismatch,"An implementation of the Nyxus definition (4-connected cross, count minus one) on a fixture where it and scikit-image's square-footprint count differ.",audit,,,test_2d_morphology_erosions_regression,test_2d_ooc_2d_two_rois_no_state_leak_invariant,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Regression pin. scikit-image's erosion with a 3x3 square, counting every erosion, also gives 1 on this shape, but Nyxus erodes with the 4-connected 3x3 cross (erosion.h) and counts one fewer, so the agreement confirms neither the structuring element nor the counting convention. gen_morphology_skimage.py" 2D,morphology,EROSIONS_2_VANISH_COMPLEMENT,regression,,morphology.shape2d_native,,benign,,,convention-mismatch,Degenerate on the shape2d fixture (golden 0 -- the complement erodes away immediately); needs a fixture with an interior hole to vet the count vs skimage.,tracker,,,test_2d_morphology_erosions_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md, 2D,morphology,FRACT_DIM_BOXCOUNT,vetted,fraclac,morphology.fractal_blob512,,agreed,,,,,audit,analytic;fraclac,test_2d_morphology_boxcount_known_dimension_analytic;test_2d_morphology_fractal_dimension_blob512_fraclac,,,2,0,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs ImageJ/FracLac box-count on the 512x512 blob fixture (tests/data/fractal_blob512_seg.ome.tif) = 1.8706, same-method direct match at 1% (test_2d_morphology_fraclac.h). Also test_2d_morphology_fraclac.py recovers analytic ground-truth dimensions: filled square -> 2.0, straight line -> 1.0, Sierpinski triangle -> 1.585." -2D,morphology,FRACT_DIM_PERIMETER,vetted,fraclac,morphology.fractal_blob512,,agreed,,,,,audit,analytic;fraclac,test_2d_morphology_fractal_dimension_blob512_fraclac;test_2d_morphology_perimeter_disk_analytic;test_2d_morphology_perimeter_koch_snowflake_analytic,,,3,0,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs ImageJ/FracLac edge box-count on the blob512 fixture = 1.0493, cross-method vs Nyxus' Richardson divider (1.0572, 0.8% gap), asserted at 3% (test_2d_morphology_fraclac.h). Also test_2d_morphology_fraclac.py: Koch snowflake and smooth-disk perimeter dimensions match." +2D,morphology,FRACT_DIM_PERIMETER,vetted,fraclac,morphology.fractal_blob512,,agreed,,,,,audit,analytic;fraclac,test_2d_morphology_fractal_dimension_blob512_fraclac;test_2d_morphology_perimeter_disk_analytic;test_2d_morphology_perimeter_koch_snowflake_analytic,,test_2d_morphology_fractal_perimeter_short_border_invariant;test_2d_morphology_fractal_perimeter_two_copies_invariant,3,0,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs ImageJ/FracLac edge box-count on the blob512 fixture = 1.0493, cross-method vs Nyxus' Richardson divider (1.0572, 0.8% gap), asserted at 3% (test_2d_morphology_fraclac.h). Also test_2d_morphology_fraclac.py: Koch snowflake and smooth-disk perimeter dimensions match." +2D,morphology,FRACT_DIM_PERIMETER,invariant,,morphology.fractal_perimeter_borders,exact,agreed,,TEST_NYXUS.TEST_2D_MORPHOLOGY_FRACTAL_PERIMETER_TWO_COPIES_INVARIANT;TEST_NYXUS.TEST_2D_MORPHOLOGY_FRACTAL_PERIMETER_SHORT_BORDER_INVARIANT,,,in-tree,analytic;fraclac,test_2d_morphology_fractal_dimension_blob512_fraclac;test_2d_morphology_perimeter_disk_analytic;test_2d_morphology_perimeter_koch_snowflake_analytic,,test_2d_morphology_fractal_perimeter_short_border_invariant;test_2d_morphology_fractal_perimeter_two_copies_invariant,3,0,,test_2d_morphology_fractal_perimeter_short_border_invariant;test_2d_morphology_fractal_perimeter_two_copies_invariant,agree,row,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Relations the per-border divider walk has to keep, recipe morphology.fractal_perimeter_borders: two separate copies of a disk have exactly the dimension of one copy (both the summed length and the step count double at every stride), and a border no longer than the stride is measured at its closed length, so a square plus a separate 3-pixel line, every border measured the same at every stride, has dimension exactly 1. status=invariant, not vetted: no tool computes the divider dimension; it is vetted cross-method against FracLac in the fraclac row." 2D,morphology,MIN_FERET_ANGLE,regression,,morphology.shape2d_native,,benign,,,promote-after-deepdive;convention-mismatch,Definition review; do not mark agreed unless a tool matches within 5%.,tracker,,,test_2d_morphology_caliper_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md, 2D,morphology,MAX_FERET_ANGLE,regression,,morphology.shape2d_native,,benign,,,promote-after-deepdive;convention-mismatch,Definition review; do not mark agreed unless a tool matches within 5%.,tracker,,,test_2d_morphology_caliper_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md, 2D,morphology,STAT_FERET_DIAM_MIN,vetted,imea,morphology.caliper_ellipse,rel=6e-2,agreed,,,,,audit,imea,test_2d_morphology_caliper_feret_imea,test_2d_morphology_caliper_shape2d_regression,,1,1,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 shape_measurements_2d(dalpha=10) on the a=20 b=10 ellipse, recipe morphology.caliper_ellipse. Goldens regenerated at the matched angular step (the previous set mixed dalpha 9/10 and Nassenstein MIN matched no run), which let the tolerance tighten 10% -> 6%. The shape2d-raster values are regression snapshots, not imea numbers." @@ -155,22 +156,25 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,morphology,HEXAGONALITY_AVE,regression,,morphology.shape2d_native,,na,,,no-mainstream-oracle,none (Nyxus/WIPP-unique),tracker,,,test_2d_morphology_polygonality_hexagonality_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md, 2D,morphology,HEXAGONALITY_STDDEV,regression,,morphology.shape2d_native,,na,,,no-mainstream-oracle,none (Nyxus/WIPP-unique),tracker,,,test_2d_morphology_polygonality_hexagonality_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md, 2D,morphology,DIAMETER_MIN_ENCLOSING_CIRCLE,vetted,imea,morphology.caliper_ellipse,rel=6e-2,agreed,,,,,audit,imea,test_2d_morphology_min_enclosing_circle_imea,test_2d_morphology_misc_regression,,1,1,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 shape_measurements_2d(dalpha=10) on the a=20 b=10 ellipse, recipe morphology.caliper_ellipse. Goldens regenerated at the matched angular step (the previous set mixed dalpha 9/10 and Nassenstein MIN matched no run), which let the tolerance tighten 10% -> 6%. The shape2d-raster values are regression snapshots, not imea numbers." -2D,morphology,DIAMETER_CIRCUMSCRIBING_CIRCLE,regression,,morphology.shape2d_native,,benign,,,convention-mismatch,,tracker,,,test_2d_morphology_fractal_circle_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Left regression: Nyxus (circle.cpp, ported from imea macro.py) computes this as imea's max/min centroid-to-contour distance approximation, NOT a true geometric circle. It is convention-sensitive (Nyxus contour tracing + the centroid-1 offset): a symmetric circle r=15 yields circ=35.6/insc=23.3 instead of ~30/~30, so no external numeric oracle agrees -> documented-convention, not promoted. (DIAMETER_MIN_ENCLOSING_CIRCLE IS vetted vs cv2/imea.)" -2D,morphology,DIAMETER_INSCRIBING_CIRCLE,regression,,morphology.shape2d_native,,benign,,,convention-mismatch,,tracker,,,test_2d_morphology_fractal_circle_regression,,0,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Left regression: Nyxus (circle.cpp, ported from imea macro.py) computes this as imea's max/min centroid-to-contour distance approximation, NOT a true geometric circle. It is convention-sensitive (Nyxus contour tracing + the centroid-1 offset): a symmetric circle r=15 yields circ=35.6/insc=23.3 instead of ~30/~30, so no external numeric oracle agrees -> documented-convention, not promoted. (DIAMETER_MIN_ENCLOSING_CIRCLE IS vetted vs cv2/imea.)" +2D,morphology,DIAMETER_CIRCUMSCRIBING_CIRCLE,regression,,morphology.shape2d_native,,benign,,,,,tracker,analytic,test_2d_morphology_circle_diameters_closed_form_analytic;test_2d_morphology_circle_diameters_holed_disk_analytic;test_2d_morphology_circle_diameters_two_fragments_analytic,test_2d_morphology_fractal_circle_regression,,3,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Drift guard on shape2d. Nyxus (circle.cpp, ported from imea macro.py) computes this as twice the largest / smallest distance from the centroid to a pixel of the ROI's outer borders (holes excluded, as imea's RETR_EXTERNAL contour), not a true geometric circle. Vetted by closed form on the r=15 circle, recipe morphology.circle_r15. imea's own end-to-end values are not an oracle for it: on the r=15 circle imea 0.3.5 reports 46.86 / 12.65." +2D,morphology,DIAMETER_CIRCUMSCRIBING_CIRCLE,vetted,analytic,morphology.circle_r15,exact,agreed,,TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_CLOSED_FORM_ANALYTIC;TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_HOLED_DISK_ANALYTIC;TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_TWO_FRAGMENTS_ANALYTIC,,,in-tree,analytic,test_2d_morphology_circle_diameters_closed_form_analytic;test_2d_morphology_circle_diameters_holed_disk_analytic;test_2d_morphology_circle_diameters_two_fragments_analytic,test_2d_morphology_fractal_circle_regression,,3,1,,test_2d_morphology_circle_diameters_closed_form_analytic;test_2d_morphology_circle_diameters_holed_disk_analytic;test_2d_morphology_circle_diameters_two_fragments_analytic,agree,row,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"closed form on the r=15 circle, recipe morphology.circle_r15: the centroid is the centre by symmetry, the farthest edge pixel is the axial (R, 0) and the nearest is the one at offset (1, R-1), so DIAMETER_CIRCUMSCRIBING_CIRCLE = 2R = 30 and DIAMETER_INSCRIBING_CIRCLE = 2 sqrt((R-1)^2 + 1) = 28.0713; exact. The same disk with a centred 3x3 hole gives the same two values, the hole's border not being measured; two r=10 disks 40 apart, one outer border per fragment and all of them measured from the midway centroid, give D - 2R = 20 and D + 2R = 60." +2D,morphology,DIAMETER_INSCRIBING_CIRCLE,regression,,morphology.shape2d_native,,benign,,,,,tracker,analytic,test_2d_morphology_circle_diameters_closed_form_analytic;test_2d_morphology_circle_diameters_holed_disk_analytic;test_2d_morphology_circle_diameters_two_fragments_analytic,test_2d_morphology_fractal_circle_regression,,3,1,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Drift guard on shape2d. Nyxus (circle.cpp, ported from imea macro.py) computes this as twice the largest / smallest distance from the centroid to a pixel of the ROI's outer borders (holes excluded, as imea's RETR_EXTERNAL contour), not a true geometric circle. Vetted by closed form on the r=15 circle, recipe morphology.circle_r15. imea's own end-to-end values are not an oracle for it: on the r=15 circle imea 0.3.5 reports 46.86 / 12.65." +2D,morphology,DIAMETER_INSCRIBING_CIRCLE,vetted,analytic,morphology.circle_r15,exact,agreed,,TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_CLOSED_FORM_ANALYTIC;TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_HOLED_DISK_ANALYTIC;TEST_NYXUS.TEST_2D_MORPHOLOGY_CIRCLE_DIAMETERS_TWO_FRAGMENTS_ANALYTIC,,,in-tree,analytic,test_2d_morphology_circle_diameters_closed_form_analytic;test_2d_morphology_circle_diameters_holed_disk_analytic;test_2d_morphology_circle_diameters_two_fragments_analytic,test_2d_morphology_fractal_circle_regression,,3,1,,test_2d_morphology_circle_diameters_closed_form_analytic;test_2d_morphology_circle_diameters_holed_disk_analytic;test_2d_morphology_circle_diameters_two_fragments_analytic,agree,row,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"closed form on the r=15 circle, recipe morphology.circle_r15: the centroid is the centre by symmetry, the farthest edge pixel is the axial (R, 0) and the nearest is the one at offset (1, R-1), so DIAMETER_CIRCUMSCRIBING_CIRCLE = 2R = 30 and DIAMETER_INSCRIBING_CIRCLE = 2 sqrt((R-1)^2 + 1) = 28.0713; exact. The same disk with a centred 3x3 hole gives the same two values, the hole's border not being measured; two r=10 disks 40 apart, one outer border per fragment and all of them measured from the midway centroid, give D - 2R = 20 and D + 2R = 60." 2D,morphology,GEODETIC_LENGTH,vetted,imea,morphology.shape2d_native,exact,agreed,,,,,audit,imea,test_2d_morphology_geodetic_length_thickness_imea,,,1,0,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 22.971), a gap inherited from PERIMETER." 2D,morphology,THICKNESS,vetted,imea,morphology.shape2d_native,exact,agreed,,,,,audit,imea,test_2d_morphology_geodetic_length_thickness_imea,,,1,0,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs imea 0.3.5 macro (DIN ISO 9276-6) fed the Nyxus AREA and PERIMETER; agreement 0. Vets the TRANSFORM only - imea's end-to-end value uses cv2.arcLength (12.657 vs 22.971), a gap inherited from PERIMETER." -2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.shape2d_native,rel=1e-3,disagrees,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,,skimage find_boundaries(mode='inner') + a minimum over it (gen_morphology_radius_skimage.py),in-tree,,,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,0,3,,test_2d_morphology_radius_regression,agree,row,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances. Drift guard on shape2d: 18 of the 26 pixels are edge pixels at distance 0 and the other 8 are one pixel from the edge, so MEAN = 8/26 = 0.3077. The scikit-image reference (find_boundaries(connectivity=1, mode='inner') plus a minimum over it) gives the same value; gen_morphology_radius_skimage.py prints it on every run." -2D,morphology,ROI_RADIUS_MAX,regression,,morphology.shape2d_native,rel=1e-3,na,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,test_2d_morphology_radius_regression,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row." -2D,morphology,ROI_RADIUS_MEDIAN,regression,,morphology.shape2d_native,rel=1e-3,na,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,1,3,,test_2d_morphology_radius_regression,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row." -2D,morphology,ROI_RADIUS_MAX,vetted,skimage,morphology.radius_disks,rel=1e-3,agreed,bench_radius_disks,TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,test_2d_morphology_roi_radius_disks_skimage,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d for the reason PERIMETER is vetted on the circles benchmark: on 26 pixels with a hole the two boundary conventions have nothing to converge to. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py." -2D,morphology,ROI_RADIUS_MEDIAN,vetted,skimage,morphology.radius_disks,rel=1e-3,agreed,bench_radius_disks,TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,1,3,,test_2d_morphology_roi_radius_disks_skimage,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d for the reason PERIMETER is vetted on the circles benchmark: on 26 pixels with a hole the two boundary conventions have nothing to converge to. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py. Unmoved by the contour offset on a disk, unlike ROI_RADIUS_MEAN: the pixel attaining it moves with the shift." -2D,morphology,ROI_RADIUS_MAX,vetted,analytic,morphology.radius_disks,exact,agreed,bench_radius_disks,TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISK_CLOSED_FORM_ANALYTIC,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,test_2d_morphology_roi_radius_disk_closed_form_analytic,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted against an exact closed form for a filled digital disk, not a band: ROI_RADIUS_MAX is the centre's distance to the nearest boundary pixel, and that pixel is at offset (1, R-1) -- inside since 1+(R-1)^2 <= R^2, boundary since its neighbour (1, R) is not, and nearer than the axial (R, 0) since R^2-2R+2 < R^2. So MAX == sqrt((R-1)^2+1), matched to 1e-12 relative at R = 10, 20, 40; the (1, R-1) pixel was confirmed nearest at R = 5, 10, 20, 40, 80 and 160. A second oracle alongside the skimage row (SPEC 3.1 redundancy), checking what that row cannot: that the value obeys the geometry rather than merely agreeing with a reference implementation that could share a mistake. The same form gives MAX/(R-1) = sqrt(1+1/(R-1)^2) -> 1, i.e. linear growth in R, where a squared distance would give ratios 9.1, 19.1, 39.0 and growing. Asserted in test_2d_morphology_analytic.h; no generator, the reference is geometry." -2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,,,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,0,3,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,ROI_RADIUS_MEDIAN,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,1,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,ROI_RADIUS_MAX,regression,,morphology.disk64_forced_ooc,rel=1e-9,agrees,bench_disk64_diagonal_boundary,,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest. This feature is the one of the three that AGREES across the two paths here; the row exists so the agreement is pinned and a fix that moved the whole contour could not break it silently." -2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,,,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,0,3,,,agree,feature,,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 24.490797504013532 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,ROI_RADIUS_MAX,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,2,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 45.254833995939045 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly 32*sqrt(2), the frame centre to a corner. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." -2D,morphology,ROI_RADIUS_MEDIAN,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,,1,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 25.495097567963924 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly sqrt(650). No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.shape2d_native,rel=1e-3,disagrees,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,,skimage find_boundaries(mode='inner') + a minimum over it (gen_morphology_radius_skimage.py),in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,1,3,,test_2d_morphology_radius_regression,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances. Drift guard on shape2d: 18 of the 26 pixels are edge pixels at distance 0 and the other 8 are one pixel from the edge, so MEAN = 8/26 = 0.3077. The scikit-image reference (find_boundaries(connectivity=1, mode='inner') plus a minimum over it) gives the same value; gen_morphology_radius_skimage.py prints it on every run." +2D,morphology,ROI_RADIUS_MAX,regression,,morphology.shape2d_native,rel=1e-3,na,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,2,3,,test_2d_morphology_radius_regression,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row." +2D,morphology,ROI_RADIUS_MEDIAN,regression,,morphology.shape2d_native,rel=1e-3,na,bench_shape8_concave_holed,TEST_NYXUS.TEST_2D_MORPHOLOGY_RADIUS_REGRESSION,,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,1,3,,test_2d_morphology_radius_regression,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"RoiRadiusFeature measures every ROI pixel's distance to the ROI's own contour and reports the mean, maximum and median of those distances, on both the in-RAM and out-of-core paths. This row is the shape2d drift guard and claims nothing: the skimage reference disagrees on shape2d for the contour-offset reason recorded on the ROI_RADIUS_MEAN row. The vetting claim for this feature is its morphology.radius_disks row." +2D,morphology,ROI_RADIUS_MAX,vetted,skimage,morphology.radius_disks,rel=1e-3,agreed,bench_radius_disks,TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,2,3,,test_2d_morphology_roi_radius_disks_skimage,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d because on 26 pixels, whose distances to the edge are all 0 or 1, a distance and its square are the same number. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py." +2D,morphology,ROI_RADIUS_MEAN,vetted,skimage,morphology.radius_disks,rel=1e-3,agreed,bench_radius_disks,TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,1,3,,test_2d_morphology_roi_radius_disks_skimage,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d because on 26 pixels, whose distances to the edge are all 0 or 1, a distance and its square are the same number. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py." +2D,morphology,ROI_RADIUS_MEDIAN,vetted,skimage,morphology.radius_disks,rel=1e-3,agreed,bench_radius_disks,TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISKS_SKIMAGE,,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,1,3,,test_2d_morphology_roi_radius_disks_skimage,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted vs scikit-image 0.26.0 find_boundaries(connectivity=1, mode='inner') -- the foreground pixels 4-adjacent to background, which is the only convention in the quantity and is CellProfiler's own edge definition -- plus a minimum over those pixels, cross-checked in the generator against scipy.ndimage.distance_transform_edt over the complement. Agreement is to double precision on all three disks. Disks rather than shape2d because on 26 pixels, whose distances to the edge are all 0 or 1, a distance and its square are the same number. Three radii because a single disk cannot distinguish a distance from its square -- across a doubling of R a distance grows 2.1x and a square 4.2x. gen_morphology_radius_skimage.py. Unmoved by the contour offset on a disk, unlike ROI_RADIUS_MEAN: the pixel attaining it moves with the shift." +2D,morphology,ROI_RADIUS_MAX,vetted,analytic,morphology.radius_disks,exact,agreed,bench_radius_disks,TEST_NYXUS.TEST_2D_MORPHOLOGY_ROI_RADIUS_DISK_CLOSED_FORM_ANALYTIC,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,2,3,,test_2d_morphology_roi_radius_disk_closed_form_analytic,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"vetted against an exact closed form for a filled digital disk, not a band: ROI_RADIUS_MAX is the centre's distance to the nearest boundary pixel, and that pixel is at offset (1, R-1) -- inside since 1+(R-1)^2 <= R^2, boundary since its neighbour (1, R) is not, and nearer than the axial (R, 0) since R^2-2R+2 < R^2. So MAX == sqrt((R-1)^2+1), matched to 1e-12 relative at R = 10, 20, 40; the (1, R-1) pixel was confirmed nearest at R = 5, 10, 20, 40, 80 and 160. A second oracle alongside the skimage row (SPEC 3.1 redundancy), checking what that row cannot: that the value obeys the geometry rather than merely agreeing with a reference implementation that could share a mistake. The same form gives MAX/(R-1) = sqrt(1+1/(R-1)^2) -> 1, i.e. linear growth in R, where a squared distance would give ratios 9.1, 19.1, 39.0 and growing. Asserted in test_2d_morphology_analytic.h; no generator, the reference is geometry." +2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,1,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,ROI_RADIUS_MEDIAN,regression,,morphology.disk64_forced_ooc,rel=1e-9,disagrees,bench_disk64_diagonal_boundary,,impl-defect,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,1,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,ROI_RADIUS_MAX,regression,,morphology.disk64_forced_ooc,rel=1e-9,agrees,bench_disk64_diagonal_boundary,,,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,2,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"NOT vetted -- known DEFECT, characterized. At recipe morphology.disk64_forced_ooc RoiRadiusFeature::osized_calculate takes the same sqrt(exact_min_sqdist(K)) over the same ROI pixels that calculate() does, so the only input that can differ is the contour K, and it does: buildRegularContour_nontriv and buildRegularContour both return the fixture's 112 edge pixels, but the out-of-core contour is relative to the ROI's bounding box, whose origin here is (12, 12), while the ROI pixels it is measured against are absolute. Measured MEAN 6.047026418093836 -> 7.423593657857253 and MEDIAN 5.0 -> 7.0, while MAX is bit-identical at 19.026297590440446 on both paths; the scikit-image inner boundary shifted by (-12, -12) reproduces all three out-of-core values, and unshifted all three in-RAM ones. CLAUDE.md requires the two paths to be identical, so this is a defect and not a convention; flag=impl-defect rather than a dropped INVALID cell, because the config is real production code. Invisible to test_2d_ooc_invariant.py because its fixture is a full-image rectangle, the same blind spot that hid the PERIMETER divergence. Asserted by test_2d_ooc_roi_radius_diverges_from_in_ram_regression in test_2d_ooc_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest. This feature is the one of the three that AGREES across the two paths here; the row exists so the agreement is pinned and a fix that moved the whole contour could not break it silently." +2D,morphology,ROI_RADIUS_MEAN,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,1,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 24.490797504013532 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,ROI_RADIUS_MAX,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,analytic;skimage,test_2d_morphology_roi_radius_disk_closed_form_analytic;test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,2,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 45.254833995939045 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly 32*sqrt(2), the frame centre to a corner. No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." +2D,morphology,ROI_RADIUS_MEDIAN,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,skimage,test_2d_morphology_roi_radius_disks_skimage,test_2d_morphology_radius_regression;test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_morphology_roi_radius_engines_agree_invariant,1,3,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. The path is production: reduce_trivial_2d_wholeslide() (reduce_trivial_rois.cpp:415) calls ContourFeature::extract() whenever RoiRadiusFeature::required() holds and then RoiRadiusFeature::extract(). At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners instead of tracing a boundary, so RoiRadiusFeature measures every pixel of the frame against four points and the inradius map degenerates into a distance-to-nearest-corner map. Measured on the 64x64 bench_disk64_diagonal_boundary frame: 25.495097567963924 over 4096 pixels, against the segmented cell's value on the same image. The AABB spans 0..64 rather than 0..63 -- BBOX_WIDTH/HEIGHT report 65 -- and recomputing all three statistics from corners at 0..64 reproduces them exactly while corners at 0..63 do not, which identifies the geometry rather than assuming it. This one is exactly sqrt(650). No external tool computes distance to the four corners of a bounding box as a shape descriptor, so nothing can vet this and it is kept as a snapshot -- the same disposition the EDGE_* and PERIMETER whole-slide cells carry. Asserted by test_2d_morphology_whole_slide_roi_radius_is_corner_distance_regression in test_2d_morphology_regression.py, which pins the closed forms beside the literals; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,PERIMETER,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,analytic;opencv;skimage,test_2d_contour_blob97_opencv;test_2d_contour_line_analytic;test_2d_contour_ring_analytic;test_2d_contour_shape2d_opencv;test_2d_contour_single_pixel_analytic;test_2d_contour_square_analytic;test_2d_contour_two_fragments_analytic;test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_bad_contour,8,6,,,agree,feature,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,DIAMETER_EQUAL_PERIMETER,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,imea,test_2d_morphology_diameter_equal_perimeter_imea,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression,test_bad_contour,1,4,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,morphology,MASS_DISPLACEMENT,regression,,morphology.disk64_segmented_inram,rel=1e-9,na,bench_disk64_diagonal_boundary,,,,in-tree,cellprofiler,test_2d_morphology_edge_intensity_cellprofiler,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression,test_2d_ooc_2d_contour_intensity_matches_in_ram_on_diagonal_boundary_invariant,1,2,,,agree,feature,audit/morphology_2d_cellprofiler_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"Snapshot of the in-RAM segmented cell on the disk fixture, recipe morphology.disk64_segmented_inram. Not a vetting claim: it is the reference the out-of-core invariant compares against and the value the whole-slide cell is asserted to differ from. The shape2d oracle rows carry this family's cellprofiler evidence. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." @@ -200,7 +204,7 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,morphology,PERIMETER,vetted,analytic,contour.closed_forms,exact,agreed,,TEST_NYXUS.TEST_2D_CONTOUR_RING_ANALYTIC,,,in-tree,analytic;opencv;skimage,test_2d_contour_blob97_opencv;test_2d_contour_line_analytic;test_2d_contour_ring_analytic;test_2d_contour_shape2d_opencv;test_2d_contour_single_pixel_analytic;test_2d_contour_square_analytic;test_2d_contour_two_fragments_analytic;test_2d_morphology_perimeter_skimage,test_2d_morphology_contour_regression;test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression;test_2d_ooc_roi_radius_diverges_from_in_ram_regression,test_2d_contour_blob97_boundary_rule_invariant;test_2d_contour_noisy_rois_boundary_rule_invariant;test_bad_contour,8,6,,test_2d_contour_ring_analytic,agree,row,audit/morphology_2d_skimage_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"closed forms, recipe contour.closed_forms: 4(n-1) for an n x n square, 44 + 12 + 4*sqrt(2) for a 12x12 square with a centred 4x4 hole, 48 for two separate squares 6x6 and 8x8 under one label, 2(n-1) for a one-pixel line, 0 for a single pixel. The ring is the assertion test_name names; the other shapes are asserted beside it in the same file." 2D,morphology,DIAMETER_EQUAL_PERIMETER,regression,,morphology.disk64_wholeslide,rel=1e-9,na,bench_disk64_diagonal_boundary,,production-only-cell,,in-tree,imea,test_2d_morphology_diameter_equal_perimeter_imea,test_2d_morphology_whole_slide_differs_from_segmented_regression;test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression;test_2d_ooc_diameter_equal_perimeter_inherits_the_divergence_regression;test_2d_ooc_perimeter_diverges_from_in_ram_regression,test_bad_contour,1,4,,,agree,feature,audit/morphology_2d_imea_vetting_report.md,audit/morphology_2d_golden_regen.md,matrix/morphology.md,"VALID-BUT-PRODUCTION-ONLY cell, NOT vetted, recipe morphology.disk64_wholeslide. At SINGLEROI=true ContourFeature::calculate() takes the buildWholeSlideContour() branch, which pushes the four AABB corners each carrying aux_max instead of tracing a boundary, so every edge statistic is degenerate by construction: min=max=mean=the image maximum (397), stddev=0, integrated=4*maximum (1588); PERIMETER is the AABB walk (256) and DIAMETER_EQUAL_PERIMETER is that over pi. MEASURED not assumed: CellProfiler with a full-frame object returns exactly 0 for every EDGE_* statistic, because an all-ones label image has an empty find_boundaries(mode=inner) set -- a different quantity, not a disagreement about one, so no oracle promotes these. gen_morphology_wholeslide_cellprofiler.py asserts that zero. Asserted by test_2d_morphology_whole_slide_edge_intensity_is_degenerate_regression / test_2d_morphology_whole_slide_differs_from_segmented_regression in test_2d_morphology_regression.py; test_name is empty because that column takes a gtest SUITE.CASE and this assertion is pytest." 2D,neighbor,NUM_NEIGHBORS,vetted,cellprofiler,neighbor.scene2d_radius1,abs=1e-9,agreed,bench_scene7_5roi_enclosed,TEST_NYXUS.TEST_2D_NEIGHBOR_COUNTS_AND_FIRST_DISTANCE_CELLPROFILER,,,audit,cellprofiler,test_2d_neighbor_counts_and_first_distance_cellprofiler,test_2d_morphology_polygonality_hexagonality_regression,test_2d_neighbor_distance_and_percent_touching_invariant,1,1,"also pinned by test_2d_morphology_regression.h, which asserts it for label 1 while exercising an unrelated family",test_2d_neighbor_counts_and_first_distance_cellprofiler,agree,row,audit/neighbor_2d_cellprofiler_vetting_report.md,audit/neighbor_2d_golden_regen.md,matrix/neighbor.md,"vetted vs CellProfiler 4.2.8 MeasureObjectNeighbors (Adjacent method, neighbors_are_objects) on the neighborhood2d_scene_labels fixture at PIXELDISTANCE=1, recipe neighbor.scene2d_radius1; agreement is EXACTLY 0 -- CP reproduces Nyxus bit for bit on all five ROIs -- asserted per ROI at abs=1e-9, SPEC 7's exact tier in test_2d_neighbor_cellprofiler.h. Re-measured 2026-08-20: the previous assertion was ASSERT_NEAR at 1e-4 absolute with no tolerance recorded here; an intermediate rel=1e-9 was corrected to the absolute band SPEC 7 defines. gen_neighbor_cellprofiler.py re-verifies every pin in the header it feeds; tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md" -2D,neighbor,PERCENT_TOUCHING,regression,,neighbor.scene2d_radius1,rel=1e-3,disagrees,bench_scene7_5roi_enclosed,TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_REGRESSION;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_BOUNDED_INVARIANT;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_ENCLOSED_INVARIANT,documented-convention,none (CP definition differs; empirically 3/5 ROIs),audit,,,test_2d_neighbor_percent_touching_regression,test_2d_neighbor_distance_and_percent_touching_invariant;test_2d_neighbor_percent_touching_bounded_invariant;test_2d_neighbor_percent_touching_enclosed_invariant;test_2d_neighbor_percent_touching_fully_enclosed_invariant,0,1,"no oracle by definition, not by defect: CellProfiler measures a different quantity and diverges on 3/5 ROIs. Drift-pinned, with its construction bounds asserted as invariants",test_2d_neighbor_percent_touching_bounded_invariant;test_2d_neighbor_percent_touching_enclosed_invariant;test_2d_neighbor_percent_touching_regression,agree,row,,audit/neighbor_2d_golden_regen.md,matrix/neighbor.md,"no promotable oracle, by definition rather than by defect. Nyxus counts distinct contour pixels 8-adjacent to a neighbour over contour length; CellProfiler counts object outline pixels overlapping a disk(distance+0.5)-dilated neighbour over perimeter, and no CP distance method (Adjacent/Expand/Within) reproduces it -- measured divergence on 3 of the 5 ROIs, up to 33.3 percentage points. Drift-pinned per ROI at rel=1e-3 in test_2d_neighbor_regression.h; the bounds it must obey by construction (0 <= PT <= 100, and exactly 100 for a fully enclosed ROI) are asserted in test_2d_neighbor_invariant.h. Those bound assertions previously carried an _analytic suffix, which credited this feature with an oracle it does not have. tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md" +2D,neighbor,PERCENT_TOUCHING,vetted,cellprofiler,neighbor.scene2d_radius1,exact,agreed,bench_scene7_5roi_enclosed,TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_CELLPROFILER;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_REGRESSION;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_BOUNDED_INVARIANT;TEST_NYXUS.TEST_2D_NEIGHBOR_PERCENT_TOUCHING_ENCLOSED_INVARIANT,,none (CP definition differs; empirically 3/5 ROIs),audit,cellprofiler,test_2d_neighbor_percent_touching_cellprofiler,test_2d_neighbor_percent_touching_regression,test_2d_neighbor_distance_and_percent_touching_invariant;test_2d_neighbor_percent_touching_bounded_invariant;test_2d_neighbor_percent_touching_enclosed_invariant;test_2d_neighbor_percent_touching_fully_enclosed_invariant,1,1,,test_2d_neighbor_percent_touching_bounded_invariant;test_2d_neighbor_percent_touching_cellprofiler;test_2d_neighbor_percent_touching_enclosed_invariant;test_2d_neighbor_percent_touching_regression,agree,row,audit/neighbor_2d_cellprofiler_vetting_report.md,audit/neighbor_2d_golden_regen.md,matrix/neighbor.md,"vetted vs CellProfiler 4.2.8 MeasureObjectNeighbors on the 5-ROI scene, residual exactly 0 on all five ROIs. Nyxus counts the contour pixels 8-adjacent to a neighbour's contour over the number of contour pixels; CellProfiler counts the outline pixels a neighbour dilated by disk(distance+0.5) overlaps over the perimeter. At this recipe's distance of 1 that disk is the 3x3 neighbourhood, so the two are the same count. Also drift-pinned per ROI in test_2d_neighbor_regression.h; the bounds it must obey by construction (0 <= PT <= 100, and exactly 100 for a fully enclosed ROI) are asserted in test_2d_neighbor_invariant.h. gen_neighbor_cellprofiler.py." 2D,neighbor,CLOSEST_NEIGHBOR1_DIST,vetted,cellprofiler,neighbor.scene2d_radius1,abs=1e-9,agreed,bench_scene7_5roi_enclosed,TEST_NYXUS.TEST_2D_NEIGHBOR_COUNTS_AND_FIRST_DISTANCE_CELLPROFILER,,,audit,cellprofiler,test_2d_neighbor_counts_and_first_distance_cellprofiler,,test_2d_neighbor_distance_and_percent_touching_invariant,1,0,,test_2d_neighbor_counts_and_first_distance_cellprofiler,agree,row,audit/neighbor_2d_cellprofiler_vetting_report.md,audit/neighbor_2d_golden_regen.md,matrix/neighbor.md,"vetted vs CellProfiler 4.2.8 MeasureObjectNeighbors (Adjacent method, neighbors_are_objects) on the neighborhood2d_scene_labels fixture at PIXELDISTANCE=1, recipe neighbor.scene2d_radius1; agreement is EXACTLY 0 -- CP reproduces Nyxus bit for bit on all five ROIs -- asserted per ROI at abs=1e-9, SPEC 7's exact tier in test_2d_neighbor_cellprofiler.h. Re-measured 2026-08-20: the previous assertion was ASSERT_NEAR at 1e-4 absolute with no tolerance recorded here; an intermediate rel=1e-9 was corrected to the absolute band SPEC 7 defines. gen_neighbor_cellprofiler.py re-verifies every pin in the header it feeds; tests/vetting/audit/neighbor_2d_cellprofiler_vetting_report.md" 2D,neighbor,CLOSEST_NEIGHBOR1_ANG,vetted,analytic,neighbor.scene2d_radius1,abs=1e-9,agreed,bench_scene7_5roi_enclosed,TEST_NYXUS.TEST_2D_NEIGHBOR_SECOND_DISTANCE_AND_ANGLES_ANALYTIC,,,audit,analytic,test_2d_neighbor_second_distance_and_angles_analytic,,,1,0,,test_2d_neighbor_second_distance_and_angles_analytic,agree,row,audit/neighbor_2d_analytic_vetting_report.md,audit/neighbor_2d_golden_regen.md,matrix/neighbor.md,"vetted vs an independent numpy recomputation of the documented closed form on the neighborhood2d_scene_labels fixture at PIXELDISTANCE=1, recipe neighbor.scene2d_radius1; agreement 1.2e-16 worst case across the family (29 of 30 values bit-identical), asserted per ROI at abs=1e-9, SPEC 7's exact tier in test_2d_neighbor_analytic.h. The neighbour graph the closed form is evaluated on is itself vetted against CellProfiler, so this is not circular. CP cannot vet these: its AngleBetweenNeighbors is the SUBTENDED angle and its SecondClosestDistance ranges over ANY object, both different quantities. Re-measured 2026-08-20: the previous assertion was ASSERT_NEAR at 1e-4 absolute with no tolerance recorded here; an intermediate rel=1e-9 was corrected to the absolute band SPEC 7 defines. gen_neighbor_analytic.py re-verifies every pin in the header it feeds; tests/vetting/audit/neighbor_2d_analytic_vetting_report.md" 2D,neighbor,CLOSEST_NEIGHBOR2_DIST,vetted,analytic,neighbor.scene2d_radius1,abs=1e-9,agreed,bench_scene7_5roi_enclosed,TEST_NYXUS.TEST_2D_NEIGHBOR_SECOND_DISTANCE_AND_ANGLES_ANALYTIC,,,audit,analytic,test_2d_neighbor_second_distance_and_angles_analytic,,,1,0,,test_2d_neighbor_second_distance_and_angles_analytic,agree,row,audit/neighbor_2d_analytic_vetting_report.md,audit/neighbor_2d_golden_regen.md,matrix/neighbor.md,"vetted vs an independent numpy recomputation of the documented closed form on the neighborhood2d_scene_labels fixture at PIXELDISTANCE=1, recipe neighbor.scene2d_radius1; agreement 1.2e-16 worst case across the family (29 of 30 values bit-identical), asserted per ROI at abs=1e-9, SPEC 7's exact tier in test_2d_neighbor_analytic.h. The neighbour graph the closed form is evaluated on is itself vetted against CellProfiler, so this is not circular. CP cannot vet these: its AngleBetweenNeighbors is the SUBTENDED angle and its SecondClosestDistance ranges over ANY object, both different quantities. Re-measured 2026-08-20: the previous assertion was ASSERT_NEAR at 1e-4 absolute with no tolerance recorded here; an intermediate rel=1e-9 was corrected to the absolute band SPEC 7 defines. gen_neighbor_analytic.py re-verifies every pin in the header it feeds; tests/vetting/audit/neighbor_2d_analytic_vetting_report.md" @@ -441,7 +445,8 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,moments,HU_M5,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_hu_wedge_skimage;test_2d_moments_shape_skimage,,,2,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." 2D,moments,HU_M6,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_hu_wedge_skimage;test_2d_moments_shape_skimage,,,2,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." 2D,moments,HU_M7,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_hu_wedge_skimage;test_2d_moments_shape_skimage,,,2,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." -2D,moments,WEIGHTED_SPAT_MOMENT_00,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_00,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,test_2d_moments_weighted_disk_copies_invariant,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,WEIGHTED_SPAT_MOMENT_00,invariant,,moments.disk_copies,rel=1e-12,agreed,,TEST_NYXUS.TEST_2D_MOMENTS_WEIGHTED_DISK_COPIES_INVARIANT,,,in-tree,,,test_2d_moments_shape_regression,test_2d_moments_weighted_disk_copies_invariant,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",test_2d_moments_weighted_disk_copies_invariant,agree,row,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Two separate copies of a disk weigh exactly twice one copy, recipe moments.disk_copies: the distance to the contour is searched on each border separately, so no pixel's weight depends on the other copy. rel=1e-12 because the two sums add the same terms in a different order. status=invariant, not vetted: the distance itself is approximate (see the regression row), and this relation holds for it all the same." 2D,moments,WEIGHTED_SPAT_MOMENT_01,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." 2D,moments,WEIGHTED_SPAT_MOMENT_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." 2D,moments,WEIGHTED_SPAT_MOMENT_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_shape_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." @@ -531,7 +536,8 @@ dim,family,feature,claim_status,claim_oracle,claim_recipe,claim_tolerance,claim_ 2D,moments,IMOM_HU5,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_intensity_skimage,,,1,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." 2D,moments,IMOM_HU6,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_intensity_skimage,,,1,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." 2D,moments,IMOM_HU7,vetted,skimage,moments.skimage_regionprops,rel=1e-6,agreed,,,,,in-tree,skimage,test_2d_moments_intensity_skimage,,,1,0,,,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Asserted against scikit-image 0.26.0 on the 48x40 fixture of test_2d_moments_common.h at rel<=1e-6, goldens from tests/vetting/oracles/gen_moments_skimage.py. Was vetted from an offline harness run only (source=tracker). Evidence: tests/vetting/audit/moments_2d_skimage_vetting_report.md." -2D,moments,IMOM_WRM_00,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_00,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,test_2d_moments_weighted_disk_copies_invariant,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." +2D,moments,IMOM_WRM_00,invariant,,moments.disk_copies,rel=1e-12,agreed,,TEST_NYXUS.TEST_2D_MOMENTS_WEIGHTED_DISK_COPIES_INVARIANT,,,in-tree,,,test_2d_moments_intensity_regression,test_2d_moments_weighted_disk_copies_invariant,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",test_2d_moments_weighted_disk_copies_invariant,agree,row,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Two separate copies of a disk weigh exactly twice one copy, recipe moments.disk_copies: the distance to the contour is searched on each border separately, so no pixel's weight depends on the other copy. rel=1e-12 because the two sums add the same terms in a different order. status=invariant, not vetted: the distance itself is approximate (see the regression row), and this relation holds for it all the same." 2D,moments,IMOM_WRM_01,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." 2D,moments,IMOM_WRM_02,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." 2D,moments,IMOM_WRM_03,regression,,moments.skimage_regionprops,rel=1e-6,,,,,,in-tree,,,test_2d_moments_intensity_regression,,0,1,"distance-to-contour weighted; dist comes from the approximate min_sqdist, measured 1.372x over the exact distance, so no tool reproduces it",,agree,feature,audit/moments_2d_skimage_vetting_report.md,audit/moments_2d_golden_regen.md,,"Regression, not vetted, and the reason is measured rather than assumed: the distance-to-contour weighting I*log(dist+eps) takes dist from Pixel2::min_sqdist, an approximate hill-descent whose own doc comment warns it overestimates. On the pinned 48x40 fixture the exact distance to the contour gives WEIGHTED_SPAT_MOMENT_00 = 1821.28398639964 against the pinned 1895.0047386658844 - a 1.040x / 4.0% overestimate - so these values match no external tool by construction. Fixing it changes 62 feature values and is deliberately out of scope here; see tests/vetting/audit/moments_2d_skimage_vetting_report.md and Deferred-Moments.md." diff --git a/tests/vetting/report_output.md b/tests/vetting/report_output.md index 3b065e393..d39fcbb46 100644 --- a/tests/vetting/report_output.md +++ b/tests/vetting/report_output.md @@ -8,8 +8,8 @@ what [`oracle_coverage.csv`](oracle_coverage.csv) CLAIMS to what the test tree A ## Coverage -Features vetted by >=1 oracle: 636/758 (84%) -regression: 122 invariant: 0 untested: 0 +Features vetted by >=1 oracle: 640/758 (84%) +regression: 118 invariant: 0 untested: 0 | family | total | vetted | regression | invariant | untested | |---|---|---|---|---|---| @@ -23,8 +23,8 @@ regression: 122 invariant: 0 untested: 0 | imq | 6 | 4 | 2 | 0 | 0 | | intensity_histogram | 47 | 43 | 4 | 0 | 0 | | moments | 180 | 118 | 62 | 0 | 0 | -| morphology | 113 | 77 | 36 | 0 | 0 | -| neighbor | 9 | 8 | 1 | 0 | 0 | +| morphology | 113 | 80 | 33 | 0 | 0 | +| neighbor | 9 | 9 | 0 | 0 | 0 | | ngldm | 38 | 33 | 5 | 0 | 0 | | ngtdm | 10 | 10 | 0 | 0 | 0 | | radial | 3 | 0 | 3 | 0 | 0 | @@ -32,11 +32,11 @@ regression: 122 invariant: 0 untested: 0 ## Verdicts -1024 of 1024 assertion rows agree with the tree. +1030 of 1030 assertion rows agree with the tree. | verdict | rows | meaning | |---|---:|---| -| `agree` | 1024 | the claim and the tree say the same thing | +| `agree` | 1030 | the claim and the tree say the same thing | ### How far each verdict reaches @@ -46,7 +46,7 @@ regression: 122 invariant: 0 untested: 0 | scope | rows | what was compared | |---|---:|---| | `feature` | 533 | `test_name` is empty, so the tree was read for the feature and not for this row's own assertion | -| `row` | 284 | the case named in `test_name` asserts this feature, at this row's kind; its configuration is unchecked, the family declaring no recipe reader | +| `row` | 290 | the case named in `test_name` asserts this feature, at this row's kind; its configuration is unchecked, the family declaring no recipe reader | | `row+config` | 207 | the case named in `test_name` asserts this feature at this `config_recipe` | ## Every oracle a feature was matched against @@ -66,8 +66,8 @@ that alone understates every family that corroborates. | 2D | glszm | . | . | . | 16 | . | . | 16 | . | . | . | . | | 2D | intensity_histogram | 26 | . | . | 16 | . | . | 23 | . | . | . | 4 | | 2D | moments | . | . | . | . | . | . | . | . | . | 118 | 62 | -| 2D | morphology | 7 | 5 | 2 | . | 20 | 33 | . | 1 | . | 7 | 30 | -| 2D | neighbor | 6 | 2 | . | . | . | . | . | . | . | . | 1 | +| 2D | morphology | 9 | 5 | 2 | . | 20 | 33 | . | 1 | . | 8 | 27 | +| 2D | neighbor | 6 | 3 | . | . | . | . | . | . | . | . | . | | 2D | ngldm | . | . | . | 17 | . | . | 17 | . | . | . | 2 | | 2D | ngtdm | . | . | . | 5 | . | . | 5 | . | . | . | . | | 2D | radial | . | . | . | . | . | . | . | . | . | . | 3 | @@ -82,13 +82,13 @@ that alone understates every family that corroborates. | 3D | ngldm | . | . | . | . | . | . | 16 | . | . | . | 3 | | 3D | ngtdm | . | . | . | . | . | . | . | . | 5 | . | . | | IMQ | imq | 1 | 2 | . | . | . | . | . | 2 | . | . | 2 | -| | **all** | **43** | **9** | **2** | **181** | **20** | **95** | **208** | **3** | **272** | **126** | **122** | +| | **all** | **45** | **10** | **2** | **181** | **20** | **95** | **208** | **3** | **272** | **127** | **118** | ## By dimensionality | dim | features | vetted | scanned | no oracle assertion | unscanned | |---|---:|---:|---:|---:|---:| -| 2D | 539 | 433 | 539 | 106 | 0 | +| 2D | 539 | 437 | 539 | 102 | 0 | | 3D | 213 | 199 | 213 | 14 | 0 | | IMQ | 6 | 4 | 6 | 2 | 0 |