Skip to content

Commit 88c44d2

Browse files
authored
Merge pull request #63 from SystemsGenetics/integration-tests
Add integration tests for StarchArea and BlushColor pipelines
2 parents 5464fa6 + 3a76f58 commit 88c44d2

1 file changed

Lines changed: 224 additions & 0 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""
2+
Integration tests for analysis pipelines.
3+
4+
Unlike unit tests that call _calculateStarch / _calculateBlush directly with
5+
in-memory numpy arrays, these tests exercise the full _processImage path:
6+
write a real PNG to disk → create RGBImage from that path → call _preRun +
7+
_processImage → verify the result has the expected structure and values.
8+
9+
This catches bugs in file I/O, Image/ImageIO wiring, and Value assembly that
10+
unit tests miss (e.g. the FileDirValue validation-order bug documented in
11+
BUGFIXES.md would have surfaced here).
12+
"""
13+
14+
import os
15+
16+
import cv2
17+
import numpy as np
18+
import pytest
19+
20+
from Granny.Analyses.BlushColor import BlushColor
21+
from Granny.Analyses.StarchArea import StarchArea, StarchScales
22+
from Granny.Models.Images.RGBImage import RGBImage
23+
24+
25+
# ---------------------------------------------------------------------------
26+
# Helpers
27+
# ---------------------------------------------------------------------------
28+
29+
def _write_synthetic_png(path: str, bgr_value: tuple, size: int = 80) -> None:
30+
"""Write a solid-colour BGR image to *path* as a PNG."""
31+
img = np.full((size, size, 3), bgr_value, dtype=np.uint8)
32+
cv2.imwrite(path, img)
33+
34+
35+
def _write_mixed_starch_png(path: str, dark_fraction: float = 0.5, size: int = 100) -> None:
36+
"""
37+
Write a two-tone image with a controlled dark/bright split.
38+
39+
Uses values 20 (dark) and 200 (bright) so normalization is never degenerate.
40+
After normalizing to [0, 255], the dark pixels land near 0 and the bright
41+
ones near 255, giving predictable starch classification at threshold=140.
42+
"""
43+
img = np.zeros((size, size, 3), dtype=np.uint8)
44+
split = int(size * dark_fraction)
45+
img[:, :split] = 20 # dark → starch after normalisation
46+
img[:, split:] = 200 # bright → no starch
47+
cv2.imwrite(path, img)
48+
49+
50+
# ---------------------------------------------------------------------------
51+
# StarchArea integration
52+
# ---------------------------------------------------------------------------
53+
54+
class TestStarchAreaIntegration:
55+
def test_processimage_returns_rgb_image(self, tmp_path):
56+
"""_processImage must return an Image with the underlying array set."""
57+
img_path = str(tmp_path / "apple_fruit_01.png")
58+
_write_synthetic_png(img_path, bgr_value=(30, 30, 30))
59+
60+
analysis = StarchArea()
61+
analysis._preRun()
62+
result = analysis._processImage(RGBImage(img_path))
63+
64+
assert result is not None
65+
assert result.getImage() is not None
66+
67+
def test_processimage_result_has_rating_value(self, tmp_path):
68+
"""The result Image must expose a 'rating' FloatValue after processing."""
69+
img_path = str(tmp_path / "apple_fruit_01.png")
70+
_write_synthetic_png(img_path, bgr_value=(80, 80, 80))
71+
72+
analysis = StarchArea()
73+
analysis._preRun()
74+
result = analysis._processImage(RGBImage(img_path))
75+
76+
assert "rating" in result.getMetaData(), "result is missing 'rating' key"
77+
rating = result.getValue("rating").getValue()
78+
assert 0.0 <= rating <= 1.0, f"rating {rating} is out of [0, 1] range"
79+
80+
def test_processimage_result_has_all_scale_indices(self, tmp_path):
81+
"""The result must include a starch index for every variety in StarchScales."""
82+
img_path = str(tmp_path / "apple_fruit_01.png")
83+
_write_mixed_starch_png(img_path)
84+
85+
analysis = StarchArea()
86+
analysis._preRun()
87+
result = analysis._processImage(RGBImage(img_path))
88+
89+
varieties = {k for k in vars(StarchScales) if not k.startswith("_")}
90+
metadata_keys = set(result.getMetaData().keys())
91+
missing = varieties - metadata_keys
92+
assert not missing, f"Missing starch scale indices: {missing}"
93+
94+
def test_processimage_higher_threshold_gives_higher_rating(self, tmp_path):
95+
"""Higher threshold should classify more pixels as starch on the same image."""
96+
img_path = str(tmp_path / "apple_fruit_01.png")
97+
_write_mixed_starch_png(img_path, dark_fraction=0.5)
98+
99+
low_analysis = StarchArea()
100+
low_analysis.starch_threshold.setValue(80)
101+
low_analysis._preRun()
102+
low_result = low_analysis._processImage(RGBImage(img_path))
103+
104+
high_analysis = StarchArea()
105+
high_analysis.starch_threshold.setValue(200)
106+
high_analysis._preRun()
107+
high_result = high_analysis._processImage(RGBImage(img_path))
108+
109+
low_rating = low_result.getValue("rating").getValue()
110+
high_rating = high_result.getValue("rating").getValue()
111+
assert high_rating >= low_rating, (
112+
f"Higher threshold should give higher starch ratio: low={low_rating}, high={high_rating}"
113+
)
114+
115+
def test_processimage_result_image_same_shape_as_input(self, tmp_path):
116+
"""The annotated output image must have the same spatial dimensions as the input."""
117+
img_path = str(tmp_path / "apple_fruit_01.png")
118+
size = 64
119+
_write_synthetic_png(img_path, bgr_value=(100, 100, 100), size=size)
120+
121+
analysis = StarchArea()
122+
analysis._preRun()
123+
result = analysis._processImage(RGBImage(img_path))
124+
125+
h, w, _ = result.getImage().shape
126+
assert (h, w) == (size, size)
127+
128+
def test_processimage_preserves_image_name(self, tmp_path):
129+
"""getImageName() on the result should match the input filename."""
130+
img_path = str(tmp_path / "apple_fruit_01.png")
131+
_write_synthetic_png(img_path, bgr_value=(128, 128, 128))
132+
133+
analysis = StarchArea()
134+
analysis._preRun()
135+
result = analysis._processImage(RGBImage(img_path))
136+
137+
assert result.getImageName() == "apple_fruit_01.png"
138+
139+
140+
# ---------------------------------------------------------------------------
141+
# BlushColor integration
142+
# ---------------------------------------------------------------------------
143+
144+
class TestBlushColorIntegration:
145+
def _yellow_pear_image(self, path: str, size: int = 80) -> None:
146+
"""Write a yellow pear image: high B channel (fruit) with some A (blush)."""
147+
# In LAB (OpenCV 0–255 encoding): L~128, A~148 (neutral), B~200 (yellow)
148+
# We create a BGR image that converts to a known LAB range.
149+
# A yellow-ish image: R≈200, G≈180, B≈50 in BGR → yellow pear background
150+
img = np.full((size, size, 3), (50, 180, 200), dtype=np.uint8)
151+
cv2.imwrite(path, img)
152+
153+
def _blush_pear_image(self, path: str, size: int = 80) -> None:
154+
"""Write an image where ~half the pixels will register as blush (high A channel)."""
155+
img = np.zeros((size, size, 3), dtype=np.uint8)
156+
# Left half: reddish (will have high A in LAB) — BGR (0, 80, 200)
157+
img[:, :size // 2] = (0, 80, 200)
158+
# Right half: yellow (low A, high B) — BGR (50, 180, 200)
159+
img[:, size // 2:] = (50, 180, 200)
160+
cv2.imwrite(path, img)
161+
162+
def test_processimage_returns_image(self, tmp_path):
163+
img_path = str(tmp_path / "pear_fruit_01.png")
164+
self._yellow_pear_image(img_path)
165+
166+
analysis = BlushColor()
167+
analysis._preRun()
168+
result = analysis._processImage(RGBImage(img_path))
169+
170+
assert result is not None
171+
assert result.getImage() is not None
172+
173+
def test_processimage_result_has_rating_value(self, tmp_path):
174+
img_path = str(tmp_path / "pear_fruit_01.png")
175+
self._yellow_pear_image(img_path)
176+
177+
analysis = BlushColor()
178+
analysis._preRun()
179+
result = analysis._processImage(RGBImage(img_path))
180+
181+
assert "rating" in result.getMetaData()
182+
rating = result.getValue("rating").getValue()
183+
assert 0.0 <= rating <= 1.0, f"rating {rating} out of range"
184+
185+
def test_processimage_result_image_same_shape(self, tmp_path):
186+
size = 60
187+
img_path = str(tmp_path / "pear_fruit_01.png")
188+
img = np.full((size, size, 3), (50, 180, 200), dtype=np.uint8)
189+
cv2.imwrite(img_path, img)
190+
191+
analysis = BlushColor()
192+
analysis._preRun()
193+
result = analysis._processImage(RGBImage(img_path))
194+
195+
h, w, _ = result.getImage().shape
196+
assert (h, w) == (size, size)
197+
198+
def test_processimage_preserves_image_name(self, tmp_path):
199+
img_path = str(tmp_path / "pear_fruit_01.png")
200+
self._yellow_pear_image(img_path)
201+
202+
analysis = BlushColor()
203+
analysis._preRun()
204+
result = analysis._processImage(RGBImage(img_path))
205+
206+
assert result.getImageName() == "pear_fruit_01.png"
207+
208+
def test_processimage_higher_blush_image_gives_higher_rating(self, tmp_path):
209+
"""An image with more reddish pixels should produce a higher blush rating."""
210+
low_path = str(tmp_path / "low_blush_fruit_01.png")
211+
high_path = str(tmp_path / "high_blush_fruit_01.png")
212+
self._yellow_pear_image(low_path)
213+
self._blush_pear_image(high_path)
214+
215+
analysis = BlushColor()
216+
analysis._preRun()
217+
low_result = analysis._processImage(RGBImage(low_path))
218+
high_result = analysis._processImage(RGBImage(high_path))
219+
220+
low_rating = low_result.getValue("rating").getValue()
221+
high_rating = high_result.getValue("rating").getValue()
222+
assert high_rating >= low_rating, (
223+
f"Expected blush image to score higher: low={low_rating}, high={high_rating}"
224+
)

0 commit comments

Comments
 (0)