Skip to content
Merged
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
15 changes: 11 additions & 4 deletions fibermorph/test/test_core_curvature_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,16 @@ def _wave_skel(n_waves: int = 3, width: int = 100, length: int = 200) -> np.ndar


def _curved_skel(radius: int = 80, arc_fraction: float = 0.5) -> np.ndarray:
"""Circular arc skeleton (arc_fraction of a full circle)."""
"""Circular arc skeleton (arc_fraction of a full circle).

Uses enough sample points to guarantee a connected skeleton.
"""
size = radius * 3
skel = np.zeros((size, size), dtype=bool)
center = (size // 2, size // 2)
theta_range = np.linspace(0, 2 * np.pi * arc_fraction, int(radius * arc_fraction * 3))
# Use at least 4× the arc pixel-length to ensure adjacent pixels are set
n_pts = max(int(radius * arc_fraction * 2 * np.pi * 4), 500)
theta_range = np.linspace(0, 2 * np.pi * arc_fraction, n_pts)
for t in theta_range:
r = int(center[0] + radius * np.sin(t))
c = int(center[1] + radius * np.cos(t))
Expand All @@ -60,8 +65,10 @@ def test_curved_line_has_higher_curl_index(self):
curved = _curved_skel(arc_fraction=0.5)
ci_straight, _, _ = curl_index_from_skeleton(straight, resolution_mm=1.0)
ci_curved, _, _ = curl_index_from_skeleton(curved, resolution_mm=1.0)
# Curved skeleton should have higher curl index than straight
assert ci_curved > ci_straight
# curl_index_from_skeleton returns chord/arc (straightness ratio).
# A curved arc has chord < arc, so its ratio is lower than a straight line (≈1).
assert not np.isnan(ci_curved), "Curved skeleton should not produce nan curl index"
assert ci_curved < ci_straight

def test_returns_tuple_of_three(self):
skel = _straight_skel()
Expand Down
64 changes: 36 additions & 28 deletions fibermorph/test/test_core_shape_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,31 +43,32 @@ class TestComputeEFD:
def test_returns_array_of_correct_shape(self):
contour = _circle_contour()
efd = compute_efd(contour, n_harmonics=10)
assert isinstance(efd, np.ndarray)
assert efd.shape == (10, 4)
assert isinstance(efd["efd_coeffs"], np.ndarray)
assert efd["efd_coeffs"].shape == (10, 4)

def test_different_harmonics(self):
contour = _circle_contour()
for n in [5, 10, 20]:
efd = compute_efd(contour, n_harmonics=n)
assert efd.shape[0] == n
assert efd["efd_coeffs"].shape[0] == n

def test_circle_has_dominant_first_harmonic(self):
contour = _circle_contour(radius=40, n_pts=360)
efd = compute_efd(contour, n_harmonics=10)
first_mag = np.sqrt(efd[0, 0] ** 2 + efd[0, 1] ** 2 +
efd[0, 2] ** 2 + efd[0, 3] ** 2)
coeffs = efd["efd_coeffs"]
first_mag = np.sqrt(coeffs[0, 0] ** 2 + coeffs[0, 1] ** 2 +
coeffs[0, 2] ** 2 + coeffs[0, 3] ** 2)
higher_mags = [
np.sqrt(efd[k, 0] ** 2 + efd[k, 1] ** 2 +
efd[k, 2] ** 2 + efd[k, 3] ** 2)
np.sqrt(coeffs[k, 0] ** 2 + coeffs[k, 1] ** 2 +
coeffs[k, 2] ** 2 + coeffs[k, 3] ** 2)
for k in range(1, 10)
]
assert first_mag > max(higher_mags)

def test_returns_finite_values(self):
contour = _circle_contour()
efd = compute_efd(contour, n_harmonics=10)
assert np.all(np.isfinite(efd))
assert np.all(np.isfinite(efd["efd_coeffs"]))


# ─────────────────────────────────────────────
Expand All @@ -81,8 +82,8 @@ def test_returns_dict_with_expected_keys(self):
props = regionprops(labeled)[0]
result = compute_radial_profile(mask, props, n_angles=36, resolution_mu=4.25)
expected = {
"radius_mean_mu", "radius_std_mu", "radius_min_mu",
"radius_max_mu", "radius_cv", "radius_range_mu", "asymmetry_index",
"radial_mean_mu", "radial_std_mu", "radial_min_mu",
"radial_max_mu", "radial_cv", "n_radial_peaks", "asymmetry_index",
}
assert expected.issubset(set(result.keys()))

Expand All @@ -101,48 +102,49 @@ def test_ellipse_has_higher_radius_range(self):
e_props = regionprops(label(ellipse_m))[0]
c_res = compute_radial_profile(circle_mask, c_props, n_angles=36, resolution_mu=1.0)
e_res = compute_radial_profile(ellipse_m, e_props, n_angles=36, resolution_mu=1.0)
assert e_res["radius_range_mu"] > c_res["radius_range_mu"]
c_range = c_res["radial_max_mu"] - c_res["radial_min_mu"]
e_range = e_res["radial_max_mu"] - e_res["radial_min_mu"]
assert e_range > c_range


# ─────────────────────────────────────────────
# extract_features_from_array
# ─────────────────────────────────────────────
class TestExtractFeaturesFromArray:
def test_returns_dataframe_with_one_row(self):
import pandas as pd
mask = _circle_mask()
df = extract_features_from_array(mask, "test_img", resolution=4.25, n_harmonics=10)
assert isinstance(df, pd.DataFrame)
assert len(df) == 1
result = extract_features_from_array(mask, source_name="test_img", resolution_mu=4.25, n_harmonics=10)
assert result is not None
assert isinstance(result, dict)

def test_contains_geometric_columns(self):
mask = _circle_mask()
df = extract_features_from_array(mask, "test_img", resolution=4.25, n_harmonics=10)
result = extract_features_from_array(mask, source_name="test_img", resolution_mu=4.25, n_harmonics=10)
for col in ["area_mu2", "circularity", "eccentricity", "solidity"]:
assert col in df.columns, f"Missing column: {col}"
assert col in result, f"Missing key: {col}"

def test_contains_efd_columns(self):
mask = _circle_mask()
df = extract_features_from_array(mask, "test_img", resolution=4.25, n_harmonics=10)
efd_cols = [c for c in df.columns if c.startswith("efd_")]
assert len(efd_cols) == 40 # 10 harmonics × 4 coefficients
result = extract_features_from_array(mask, source_name="test_img", resolution_mu=4.25, n_harmonics=10)
efd_keys = [k for k in result if k.startswith("efd_")]
# 10 harmonics × 4 coefficients + 10 power values + 1 deviation score
assert len(efd_keys) == 51

def test_contains_hu_moment_columns(self):
mask = _circle_mask()
df = extract_features_from_array(mask, "test_img", resolution=4.25, n_harmonics=10)
hu_cols = [c for c in df.columns if c.startswith("hu_")]
assert len(hu_cols) == 7
result = extract_features_from_array(mask, source_name="test_img", resolution_mu=4.25, n_harmonics=10)
hu_keys = [k for k in result if k.startswith("hu")]
assert len(hu_keys) == 7

def test_empty_mask_returns_nan_df(self):
import pandas as pd
mask = np.zeros((50, 50), dtype=np.uint8)
df = extract_features_from_array(mask, "empty", resolution=4.25, n_harmonics=10)
assert isinstance(df, pd.DataFrame)
result = extract_features_from_array(mask, source_name="empty", resolution_mu=4.25, n_harmonics=10)
assert result is None

def test_circle_has_high_circularity(self):
mask = _circle_mask(radius=45)
df = extract_features_from_array(mask, "circle", resolution=1.0, n_harmonics=10)
assert df.iloc[0]["circularity"] > 0.85
result = extract_features_from_array(mask, source_name="circle", resolution_mu=1.0, n_harmonics=10)
assert result["circularity"] > 0.85


# ─────────────────────────────────────────────
Expand All @@ -161,6 +163,8 @@ def _circle_features(self):
"solidity": 0.98,
"aspect_ratio": 1.02,
"convexity": 0.99,
"n_radial_peaks": 0,
"asymmetry_index": 0.02,
}

def _ellipse_features(self):
Expand All @@ -170,6 +174,8 @@ def _ellipse_features(self):
"solidity": 0.97,
"aspect_ratio": 1.8,
"convexity": 0.97,
"n_radial_peaks": 0,
"asymmetry_index": 0.05,
}

def _flattened_features(self):
Expand All @@ -179,6 +185,8 @@ def _flattened_features(self):
"solidity": 0.96,
"aspect_ratio": 3.5,
"convexity": 0.95,
"n_radial_peaks": 0,
"asymmetry_index": 0.08,
}

def test_returns_string(self):
Expand Down
2 changes: 1 addition & 1 deletion fibermorph/test/test_section_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def test_extended_features_adds_hu_columns(self, tmp_path):
save_img=False, use_sam2=False, extended_features=True,
)
if df is not None and not df.empty:
hu_cols = [c for c in df.columns if c.startswith("hu_")]
hu_cols = [c for c in df.columns if c.startswith("hu")]
assert len(hu_cols) == 7, "Extended features should include 7 Hu moment columns"

def test_extended_features_adds_shape_class(self, tmp_path):
Expand Down
Loading