diff --git a/src/nyx/features/chords.cpp b/src/nyx/features/chords.cpp index 0896a2d7..73ce59e3 100644 --- a/src/nyx/features/chords.cpp +++ b/src/nyx/features/chords.cpp @@ -78,7 +78,9 @@ void ChordsFeature::calculate (LR & r, const Fsettings& s) maxchords_min_angle = MCang[idxmin]; auto iteMax = std::max_element(MC.begin(), MC.end()); - auto idxmax = std::distance(MC.begin(), iteMin); + // FIX: index the LONGEST max-chord (iteMax). Previously used iteMin, so + // maxchords_max_angle always equalled maxchords_min_angle. + auto idxmax = std::distance(MC.begin(), iteMax); maxchords_max_angle = MCang[idxmax]; // Analyze all chords @@ -91,16 +93,20 @@ void ChordsFeature::calculate (LR & r, const Fsettings& s) allchords_mean = mom2.mean(); allchords_stddev = mom2.std(); - histo.initialize_uniques(MC); - allchords_mode = histo.get_mode(); - allchords_median = histo.get_median(); + // FIX: build the all-chords histogram from AC (all chords). Previously it + // reused MC (max-chords), so all-chords mode/median were the max-chords ones. + histo.initialize_uniques(AC); + allchords_mode = histo.get_mode(); + allchords_median = histo.get_median(); iteMin = std::min_element(AC.begin(), AC.end()); idxmin = std::distance(AC.begin(), iteMin); allchords_min_angle = ACang[idxmin]; iteMax = std::max_element(AC.begin(), AC.end()); - idxmax = std::distance(AC.begin(), iteMin); + // FIX: index the LONGEST all-chord (iteMax). Previously used iteMin, so + // allchords_max_angle always equalled allchords_min_angle. + idxmax = std::distance(AC.begin(), iteMax); allchords_max_angle = ACang[idxmax]; } diff --git a/tests/python/test_feature_oracle.py b/tests/python/test_feature_oracle.py new file mode 100644 index 00000000..9391d55b --- /dev/null +++ b/tests/python/test_feature_oracle.py @@ -0,0 +1,68 @@ +"""Regression / bug-exposure tests for 2D feature defects found during oracle validation +(2026-06). These exercise the PRODUCTION featurize() path on ROIs *with background* and at +the DEFAULT settings - the conditions the C++ unit tests miss. + +This module covers the chords max-angle / AC-vs-MC defect (bug #16). +""" +import re +from pathlib import Path +import numpy as np +import pytest +import nyxus + + +# ----------------------------- helpers -------------------------------------- +def _canonical_roi(): + """The pixelIntensityFeaturesTestData ROI from tests/test_data.h - the same irregular + 154-px region the C++ tests use, which reproduces the shape/moment defects. Falls back to + an L-shape if the header can't be read.""" + hdr = Path(__file__).resolve().parent.parent / "test_data.h" + try: + txt = hdr.read_text(encoding="utf-8", errors="replace") + body = re.search(r"pixelIntensityFeaturesTestData\[\]\s*=\s*\{(.*?)\};", txt, re.S).group(1) + pts = [(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(p[0] for p in pts) + 2 + H = max(p[1] for p in pts) + 2 + inten = np.zeros((H, W), np.uint32) + label = np.zeros((H, W), np.uint32) + for x, y, v in pts: + inten[y, x] = v + label[y, x] = 1 + return inten, label + except Exception: + return None + + +def _run(features, inten, label, **kw): + nyx = nyxus.Nyxus(features=features, n_feature_calc_threads=1, **kw) + df = nyx.featurize(inten.astype(np.float64), label.astype(np.uint32), + intensity_names=["i"], label_names=["l"]) + return df # one row per label + + +def _one(features, inten, label, **kw): + return _run(features, inten, label, **kw).iloc[0] + + +# ============================ CHORDS ======================================== +def test_chord_max_angle_distinct_from_min(): + """Bug #16 (FIXED): chords.cpp computed the max-chord angle index from iteMin + (`idxmax = distance(begin, iteMin)`), so MAXCHORDS_MAX_ANG was ALWAYS equal to + MAXCHORDS_MIN_ANG (same copy-paste error for ALLCHORDS). When the longest and shortest + chords have different lengths they occur at different orientations, so the max- and + min-angle must differ. Also exercised: ALLCHORDS mode/median were built from the + max-chords histogram (MC) instead of all chords (AC).""" + c = _canonical_roi() + if c is None: + pytest.skip("canonical ROI (tests/test_data.h) not available") + inten, label = c + row = _one(["MAXCHORDS_MAX", "MAXCHORDS_MIN", "MAXCHORDS_MAX_ANG", "MAXCHORDS_MIN_ANG", + "ALLCHORDS_MAX", "ALLCHORDS_MIN", "ALLCHORDS_MAX_ANG", "ALLCHORDS_MIN_ANG"], + inten, label) + if row["MAXCHORDS_MAX"] != row["MAXCHORDS_MIN"]: + assert row["MAXCHORDS_MAX_ANG"] != row["MAXCHORDS_MIN_ANG"], \ + "MAXCHORDS max-angle == min-angle (idxmax=iteMin copy-paste bug regressed)" + if row["ALLCHORDS_MAX"] != row["ALLCHORDS_MIN"]: + assert row["ALLCHORDS_MAX_ANG"] != row["ALLCHORDS_MIN_ANG"], \ + "ALLCHORDS max-angle == min-angle (idxmax=iteMin copy-paste bug regressed)" diff --git a/tests/test_2d_remaining_features.h b/tests/test_2d_remaining_features.h index 8496c9ae..1d659fc3 100644 --- a/tests/test_2d_remaining_features.h +++ b/tests/test_2d_remaining_features.h @@ -51,9 +51,10 @@ static std::unordered_map oracle_3p_remaining2d_feature_gol {"MAXCHORDS_STDDEV", 0.94451324138833304}, {"ALLCHORDS_MAX", 6.0}, {"ALLCHORDS_MIN", 1.0}, - {"ALLCHORDS_MEDIAN", 4.0}, + // FIXED (chords.cpp histo built from MC): all-chords median/mode now computed over ALL chords, not max-chords + {"ALLCHORDS_MEDIAN", 3.0}, {"ALLCHORDS_MEAN", 2.9134615384615379}, - {"ALLCHORDS_MODE", 4.0}, + {"ALLCHORDS_MODE", 3.0}, {"ALLCHORDS_STDDEV", 1.3446086298393252}, }; @@ -61,9 +62,10 @@ static std::unordered_map unvetted_nyxus_regression_remaini {"POLYGONALITY_AVE", 2.0833333333333357}, {"HEXAGONALITY_AVE", 6.4262878058432173}, {"HEXAGONALITY_STDDEV", 0.31438281411429858}, - {"MAXCHORDS_MAX_ANG", 0.94247779607693793}, + // FIXED (chords.cpp idxmax used iteMin): max-angle now indexes the longest chord (angle 0), not the min + {"MAXCHORDS_MAX_ANG", 0.0}, {"MAXCHORDS_MIN_ANG", 0.94247779607693793}, - {"ALLCHORDS_MAX_ANG", 0.15707963267948966}, + {"ALLCHORDS_MAX_ANG", 0.0}, {"ALLCHORDS_MIN_ANG", 0.15707963267948966}, };