Skip to content

Commit 0d0bfa6

Browse files
authored
Merge pull request #56 from SystemsGenetics/starch-tests
Add comprehensive unit tests for StarchArea
2 parents f3f9161 + 0d8e24a commit 0d0bfa6

1 file changed

Lines changed: 217 additions & 11 deletions

File tree

Lines changed: 217 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,221 @@
1-
from Granny.Analyses.StarchArea import StarchArea
2-
from Granny.Models.Images.RGBImage import RGBImage
3-
from Granny.Models.IO.RGBImageFile import RGBImageFile
4-
from Granny.Models.Values.StringValue import StringValue
5-
from Granny.Models.Values.ImageListValue import ImageListValue
1+
import numpy as np
2+
import pytest
3+
from Granny.Analyses.StarchArea import StarchArea, StarchScales, load_starch_scales
64

75

8-
def test_StarchAnalyses():
6+
# ---------------------------------------------------------------------------
7+
# load_starch_scales / StarchScales
8+
# ---------------------------------------------------------------------------
9+
10+
def test_load_starch_scales_returns_dict():
11+
"""YAML loads as a non-empty dictionary."""
12+
data = load_starch_scales()
13+
assert isinstance(data, dict)
14+
assert len(data) > 0
15+
16+
17+
def test_load_starch_scales_expected_varieties():
18+
"""All expected varieties are present in the YAML."""
19+
data = load_starch_scales()
20+
expected = {
21+
"HONEYCRISP", "WA38_1", "WA38_2", "ENZA",
22+
"CORNELL", "PURDUE", "DANJOU",
23+
"GOLDEN_DELICIOUS", "GRANNY_SMITH", "JONAGOLD",
24+
}
25+
assert expected.issubset(data.keys())
26+
27+
28+
def test_load_starch_scales_index_rating_same_length():
29+
"""Every variety has equal-length index and rating lists."""
30+
data = load_starch_scales()
31+
for variety, scale in data.items():
32+
assert len(scale["index"]) == len(scale["rating"]), (
33+
f"{variety}: index length {len(scale['index'])} != "
34+
f"rating length {len(scale['rating'])}"
35+
)
36+
37+
38+
def test_load_starch_scales_ratings_between_0_and_1():
39+
"""All rating values are valid percentages (0.0-1.0)."""
40+
data = load_starch_scales()
41+
for variety, scale in data.items():
42+
for r in scale["rating"]:
43+
assert 0.0 <= r <= 1.0, f"{variety} has out-of-range rating: {r}"
44+
45+
46+
def test_starch_scales_class_attributes():
47+
"""StarchScales class exposes variety names as attributes."""
48+
data = load_starch_scales()
49+
for variety in data:
50+
assert hasattr(StarchScales, variety), f"StarchScales missing attribute: {variety}"
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# StarchArea initialisation & parameters
55+
# ---------------------------------------------------------------------------
56+
57+
def test_starch_area_instantiation():
58+
analysis = StarchArea()
59+
assert analysis is not None
60+
61+
62+
def test_default_threshold_is_140():
63+
analysis = StarchArea()
64+
assert analysis.starch_threshold.getValue() == 140
65+
66+
67+
def test_threshold_accepts_boundary_values():
68+
"""Threshold should accept the full 0-255 range without error."""
969
analysis = StarchArea()
10-
# Set up input images from demo directory
11-
analysis.input_images.setValue("demo/cross_section_images/full_masked_images")
70+
analysis.starch_threshold.setValue(0)
71+
assert analysis.starch_threshold.getValue() == 0
72+
analysis.starch_threshold.setValue(255)
73+
assert analysis.starch_threshold.getValue() == 255
74+
75+
76+
def test_default_blur_kernel_is_7():
77+
analysis = StarchArea()
78+
assert analysis.blur_kernel.getValue() == 7
79+
80+
81+
def test_default_mask_alpha_is_0_6():
82+
analysis = StarchArea()
83+
assert analysis.mask_alpha.getValue() == pytest.approx(0.6)
84+
85+
86+
# ---------------------------------------------------------------------------
87+
# _calculateStarch - synthetic image tests
88+
# ---------------------------------------------------------------------------
89+
90+
def _make_bgr(gray_value: int, size: int = 100) -> np.ndarray:
91+
"""Create a solid-colour BGR image with a given grayscale brightness."""
92+
channel = np.full((size, size), gray_value, dtype=np.uint8)
93+
return np.stack([channel, channel, channel], axis=-1)
94+
95+
96+
def test_calculate_starch_all_dark_returns_high_ratio():
97+
"""A very dark image should be almost entirely starch."""
98+
analysis = StarchArea()
99+
img = _make_bgr(10)
100+
ratio, _ = analysis._calculateStarch(img)
101+
assert ratio > 0.8, f"Expected ratio > 0.8 for dark image, got {ratio}"
102+
103+
104+
def test_calculate_starch_bright_dominant_image_returns_low_ratio():
105+
"""An image that is mostly bright should detect low starch."""
106+
size = 100
107+
img = np.full((size, size, 3), 240, dtype=np.uint8)
108+
# Small dark patch (10x10) in the corner - starch
109+
img[:10, :10] = 10
110+
111+
analysis = StarchArea()
112+
ratio, _ = analysis._calculateStarch(img)
113+
assert ratio < 0.2, f"Expected ratio < 0.2 for mostly-bright image, got {ratio}"
114+
12115

13-
# This test just verifies that StarchArea can be instantiated
14-
# and that input_images can be set without errors
15-
assert analysis.input_images.getValue() == "demo/cross_section_images/full_masked_images"
116+
def test_calculate_starch_returns_ratio_between_0_and_1():
117+
"""Starch ratio must always be a valid proportion."""
118+
analysis = StarchArea()
119+
img = _make_bgr(128)
120+
ratio, _ = analysis._calculateStarch(img)
121+
assert 0.0 <= ratio <= 1.0
122+
123+
124+
def test_calculate_starch_returns_image_same_shape():
125+
"""The returned image must have the same shape as the input."""
126+
analysis = StarchArea()
127+
img = _make_bgr(100, size=64)
128+
_, result = analysis._calculateStarch(img)
129+
assert result.shape == img.shape
130+
131+
132+
def test_calculate_starch_higher_threshold_gives_higher_ratio():
133+
"""Raising the threshold should classify more pixels as starch."""
134+
img = _make_bgr(150)
135+
low_analysis = StarchArea()
136+
low_analysis.starch_threshold.setValue(80) # ~31%
137+
high_analysis = StarchArea()
138+
high_analysis.starch_threshold.setValue(200) # ~78%
139+
140+
low_ratio, _ = low_analysis._calculateStarch(img)
141+
high_ratio, _ = high_analysis._calculateStarch(img)
142+
assert high_ratio >= low_ratio
143+
144+
145+
def test_calculate_starch_mixed_image():
146+
"""An image split between dark and bright halves should give ~50% ratio."""
147+
size = 100
148+
img = np.zeros((size, size, 3), dtype=np.uint8)
149+
img[:, :50] = 10 # left half: very dark (starch)
150+
img[:, 50:] = 240 # right half: very bright (no starch)
151+
152+
analysis = StarchArea()
153+
ratio, _ = analysis._calculateStarch(img)
154+
assert 0.3 < ratio < 0.7, f"Expected roughly 0.5, got {ratio}"
155+
156+
157+
# ---------------------------------------------------------------------------
158+
# _calculateIndex
159+
# ---------------------------------------------------------------------------
160+
161+
def test_calculate_index_returns_all_varieties():
162+
"""_calculateIndex should return a key for every loaded variety."""
163+
analysis = StarchArea()
164+
data = load_starch_scales()
165+
results = analysis._calculateIndex(0.5)
166+
assert set(results.keys()) == set(data.keys())
167+
168+
169+
def test_calculate_index_exact_match():
170+
"""When target exactly matches a rating, that index is selected."""
171+
analysis = StarchArea()
172+
# CORNELL index 5 -> rating 0.537191447
173+
target = 0.537191447
174+
results = analysis._calculateIndex(target)
175+
assert results["CORNELL"] == pytest.approx(5.0)
176+
177+
178+
def test_calculate_index_closest_match():
179+
"""A target slightly off a known rating should still pick the nearest index."""
180+
analysis = StarchArea()
181+
# HONEYCRISP index 1 -> rating 0.981640465
182+
results = analysis._calculateIndex(0.982)
183+
assert results["HONEYCRISP"] == pytest.approx(1.0)
184+
185+
186+
def test_calculate_index_values_are_floats():
187+
"""All returned index values should be numeric."""
188+
analysis = StarchArea()
189+
results = analysis._calculateIndex(0.5)
190+
for variety, index in results.items():
191+
assert isinstance(index, (int, float)), f"{variety} index is not numeric"
192+
193+
194+
# ---------------------------------------------------------------------------
195+
# _drawMask
196+
# ---------------------------------------------------------------------------
197+
198+
def test_draw_mask_zeros_darken_pixels():
199+
"""Pixels where mask == 0 should be darkened."""
200+
analysis = StarchArea()
201+
img = _make_bgr(200, size=10)
202+
mask = np.zeros((10, 10), dtype=np.uint8) # all starch
203+
result = analysis._drawMask(img, mask)
204+
assert result.mean() < img.mean()
205+
206+
207+
def test_draw_mask_ones_leave_pixels_unchanged():
208+
"""Pixels where mask == 1 should be unchanged."""
209+
analysis = StarchArea()
210+
img = _make_bgr(200, size=10)
211+
mask = np.ones((10, 10), dtype=np.uint8) # no starch
212+
result = analysis._drawMask(img, mask)
213+
np.testing.assert_array_equal(result, img)
214+
215+
216+
def test_draw_mask_output_shape_matches_input():
217+
analysis = StarchArea()
218+
img = _make_bgr(128, size=50)
219+
mask = np.ones((50, 50), dtype=np.uint8)
220+
result = analysis._drawMask(img, mask)
221+
assert result.shape == img.shape

0 commit comments

Comments
 (0)