Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/nyx/features/chords.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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];
}

Expand Down
68 changes: 68 additions & 0 deletions tests/python/test_feature_oracle.py
Original file line number Diff line number Diff line change
@@ -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)"
10 changes: 6 additions & 4 deletions tests/test_2d_remaining_features.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,21 @@ static std::unordered_map<std::string, double> 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},
};

static std::unordered_map<std::string, double> unvetted_nyxus_regression_remaining2d_feature_golden_values{
{"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},
};

Expand Down
Loading