Dermatolog-AI-Scan / tests /unit /test_image_preprocess_service.py
mstepien's picture
Dermatolog AI Scan
a63cedf
import unittest
from unittest.mock import MagicMock, patch
import os
import io
from PIL import Image
import numpy as np
from app.services.image_preprocess_service import image_preprocess_service, PreprocessStrategy
class TestImagePreprocessService(unittest.TestCase):
def setUp(self):
self.image_path = "tests/data/Melanoma1280x891.jpg"
self.small_image_path = "tests/data/Melanoma400x278.jpg"
self.melanoma_path = "tests/data/melanoma.jpg"
self.mole_path = "tests/data/mole.jpg"
# Verify all files exist
for p in [self.image_path, self.small_image_path, self.melanoma_path, self.mole_path]:
if not os.path.exists(p):
print(f"DEBUG: Missing test file {p}")
# We skip instead of failing to avoid breaking CI if files are partially missing
# though they should be in the repo.
self.skipTest(f"Missing required test data: {p}")
image_preprocess_service.recommend_prep_strategy.cache_clear()
def test_melanoma_crop_vs_pad_logic(self):
"""
Test the logic that decides between cropping and padding based on detection.
Using Melanoma1280×891.jpg (Landscape: 1280x891).
Center crop window would be [194.5, 0, 1085.5, 891].
"""
with open(self.image_path, "rb") as f:
content = f.read()
# Verify image dimensions first
img = Image.open(io.BytesIO(content))
self.assertEqual(img.size, (1280, 891))
# Case 1: Lesion is centered -> Strategy: CROP
with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(500, 300, 700, 500)):
image_preprocess_service.recommend_prep_strategy.cache_clear()
res = image_preprocess_service.recommend_prep_strategy(content)
self.assertEqual(res["strategy"], PreprocessStrategy.CROP)
self.assertIn("fully contained", res["reason"])
# Case 2: Lesion is at the far left edge (x=50) -> Strategy: PAD
with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(50, 300, 200, 500)):
image_preprocess_service.recommend_prep_strategy.cache_clear()
res = image_preprocess_service.recommend_prep_strategy(content)
self.assertEqual(res["strategy"], PreprocessStrategy.PAD)
self.assertIn("extends beyond", res["reason"])
# Case 3: Lesion is at the far right edge (x=1200) -> Strategy: PAD
with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(1100, 300, 1250, 500)):
image_preprocess_service.recommend_prep_strategy.cache_clear()
res = image_preprocess_service.recommend_prep_strategy(content)
self.assertEqual(res["strategy"], PreprocessStrategy.PAD)
self.assertIn("extends beyond", res["reason"])
def test_melanoma_real_image_strategy(self):
"""
Test that the real Melanoma1280×891.jpg results in PAD strategy.
This image has the melanoma near the edge, so cropping would cut it.
We mock the detection bbox to represent this edge-positioning.
"""
with open(self.image_path, "rb") as f:
content = f.read()
# Mocking the detection result for this specific file:
# For a 1280 wide image, center crop starts at 194.5.
# We mock a lesion at the far left edge (x=50) to verify PAD logic.
with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(50, 400, 250, 600)):
image_preprocess_service.recommend_prep_strategy.cache_clear()
res = image_preprocess_service.recommend_prep_strategy(content)
self.assertEqual(res["strategy"], PreprocessStrategy.PAD)
self.assertIn("extends beyond", res["reason"])
def test_mole_crop_vs_pad_strategy(self):
"""
Test logic for mole.jpg (670x442).
Center crop x-range is [114, 556].
"""
path = "tests/data/mole.jpg"
if not os.path.exists(path):
self.skipTest("mole.jpg not found")
with open(path, "rb") as f:
content = f.read()
# Case 1: Centered mole -> CROP
with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(200, 100, 400, 300)):
image_preprocess_service.recommend_prep_strategy.cache_clear()
res = image_preprocess_service.recommend_prep_strategy(content)
self.assertEqual(res["strategy"], PreprocessStrategy.CROP)
# Case 2: Mole at left edge (x=50) -> PAD (Cutoff is at x=114)
with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(50, 100, 150, 300)):
image_preprocess_service.recommend_prep_strategy.cache_clear()
res = image_preprocess_service.recommend_prep_strategy(content)
self.assertEqual(res["strategy"], PreprocessStrategy.PAD)
def test_prepare_image_basic(self):
"""Test basic crop/resize via prepare_image."""
# Force a crop strategy by mocking get_lesion_bbox to return centered result
# Use existing melanoma.jpg (224x224)
with Image.open(self.melanoma_path) as img:
with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(90, 90, 130, 130)):
image_preprocess_service.recommend_prep_strategy.cache_clear()
prepared = image_preprocess_service.prepare_image(img, (50, 50))
self.assertEqual(prepared.size, (50, 50))
def test_prepare_image_pad(self):
"""Test padding via prepare_image."""
# Force a pad strategy by using a LARGE image that exceeds 448x448
# Use existing Melanoma1280x891.jpg
with Image.open(self.image_path) as img:
# Mock lesion at the very left (x=50) so it's outside center crop
with patch.object(image_preprocess_service, 'get_lesion_bbox', return_value=(50, 200, 150, 300)):
image_preprocess_service.recommend_prep_strategy.cache_clear()
prepared = image_preprocess_service.prepare_image(img, (50, 50))
self.assertEqual(prepared.size, (50, 50))
# Resize to 50x50 -> should have black bars if PAD was used
pixels = list(prepared.getdata())
top_pixel = pixels[0]
self.assertEqual(top_pixel, (0, 0, 0)) # Should be black padding
def test_small_image_bypass_logic(self):
"""Test that images <= 448x448 return PAD strategy to avoid cropping/scaling down."""
# Use Melanoma400x278.jpg as the small rectangular image
with open(self.small_image_path, "rb") as f:
content = f.read()
image_preprocess_service.recommend_prep_strategy.cache_clear()
res = image_preprocess_service.recommend_prep_strategy(content)
self.assertEqual(res["strategy"], PreprocessStrategy.PAD)
self.assertIn("padding to square to avoid any data loss", res["reason"])
def test_melanoma_small_padding_real_flow(self):
"""
Test that Melanoma400x278.jpg is padded to 400x400.
It should not be scaled down (kept at 400 max dim).
"""
with open(self.small_image_path, "rb") as f:
content = f.read()
# 1. Check recommendation
res = image_preprocess_service.recommend_prep_strategy(content)
self.assertEqual(res["strategy"], PreprocessStrategy.PAD)
# 2. Check preparation result
# We specify target_size=(400, 400) to verify it stays at that size
img = Image.open(io.BytesIO(content))
prepared = image_preprocess_service.prepare_image(img, target_size=(400, 400))
self.assertEqual(prepared.size, (400, 400))
# Verify symmetric padding (top and bottom should be black)
# 400x278 -> 400x400 square. Padding = (400-278)/2 = 61 pixels top and bottom
pixels = list(prepared.getdata())
# Top-left pixel should be black padding (0,0,0)
self.assertEqual(pixels[0], (0, 0, 0))
# Top-middle pixel should be black padding
self.assertEqual(pixels[200], (0, 0, 0))
# Center pixel (200, 200) should be the original image content (not black)
center_pixel = pixels[200 * 400 + 200]
self.assertNotEqual(center_pixel, (0, 0, 0))
if __name__ == '__main__':
unittest.main()