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