| """ |
| tests/test_classifier.py |
| ββββββββββββββββββββββββ |
| Unit and integration tests for the medical-ai module. |
| |
| Run from the repo root: |
| pytest tests/ -v |
| |
| Import paths assume the project root (medical-ai/) is the working directory. |
| """ |
|
|
| import sys |
| import os |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) |
|
|
| import numpy as np |
| import pytest |
| import torch |
| from PIL import Image |
|
|
| from model import BreastCancerClassifier, BreastCancerInferencePipeline |
| from utils import ImagePreprocessor |
|
|
|
|
| |
|
|
| @pytest.fixture(scope="module") |
| def dummy_pil(): |
| """224Γ224 RGB PIL image filled with mid-grey.""" |
| return Image.fromarray( |
| np.full((224, 224, 3), 128, dtype=np.uint8), mode="RGB" |
| ) |
|
|
| @pytest.fixture(scope="module") |
| def dummy_np(): |
| """224Γ224Γ3 uint8 numpy array.""" |
| return np.random.randint(0, 255, (224, 224, 3), dtype=np.uint8) |
|
|
| @pytest.fixture(scope="module") |
| def dummy_tensor(): |
| """Properly-shaped (1, 3, 224, 224) float32 tensor.""" |
| return torch.rand(1, 3, 224, 224) |
|
|
| @pytest.fixture(scope="module") |
| def model(): |
| return BreastCancerClassifier(pretrained=False) |
|
|
| @pytest.fixture(scope="module") |
| def pipeline(): |
| return BreastCancerInferencePipeline(weights_path=None, device="cpu") |
|
|
|
|
| |
|
|
| class TestBreastCancerClassifier: |
|
|
| def test_output_keys(self, model): |
| x = torch.rand(1, 3, 224, 224) |
| out = model(x) |
| assert "logits" in out and "probs" in out |
|
|
| def test_logits_shape(self, model): |
| x = torch.rand(2, 3, 224, 224) |
| out = model(x) |
| assert out["logits"].shape == (2, 2), "Logits must be (B, 2)" |
|
|
| def test_probs_sum_to_one(self, model): |
| x = torch.rand(4, 3, 224, 224) |
| out = model(x) |
| sums = out["probs"].sum(dim=1) |
| assert torch.allclose(sums, torch.ones(4), atol=1e-5) |
|
|
| def test_probs_in_range(self, model): |
| x = torch.rand(1, 3, 224, 224) |
| out = model(x) |
| assert out["probs"].min() >= 0.0 |
| assert out["probs"].max() <= 1.0 |
|
|
| def test_feature_maps_shape(self, model): |
| """Validates Grad-CAM hook compatibility.""" |
| x = torch.rand(1, 3, 224, 224) |
| fm = model.get_feature_maps(x) |
| assert fm.shape == (1, 1024, 7, 7) |
|
|
| def test_deterministic_output(self, model): |
| """Identical inputs must produce identical outputs at eval time.""" |
| model.eval() |
| x = torch.rand(1, 3, 224, 224) |
| with torch.no_grad(): |
| out_a = model(x) |
| out_b = model(x) |
| assert torch.allclose(out_a["logits"], out_b["logits"]) |
|
|
| def test_batch_inference(self, model): |
| x = torch.rand(8, 3, 224, 224) |
| out = model(x) |
| assert out["logits"].shape == (8, 2) |
|
|
| def test_freeze_backbone(self): |
| m = BreastCancerClassifier(pretrained=False, freeze_backbone=True) |
| for param in m.features.parameters(): |
| assert not param.requires_grad, "Backbone params should be frozen" |
|
|
|
|
| |
|
|
| class TestImagePreprocessor: |
|
|
| def test_pil_input(self, dummy_pil): |
| t = ImagePreprocessor()(dummy_pil) |
| assert t.shape == (1, 3, 224, 224) |
| assert t.dtype == torch.float32 |
|
|
| def test_numpy_input(self, dummy_np): |
| t = ImagePreprocessor()(dummy_np) |
| assert t.shape == (1, 3, 224, 224) |
|
|
| def test_tensor_input(self, dummy_tensor): |
| t = ImagePreprocessor()(dummy_tensor) |
| assert t.shape == (1, 3, 224, 224) |
|
|
| def test_normalization_shifts_range(self, dummy_pil): |
| """ImageNet normalization should shift values outside raw [0,1].""" |
| t = ImagePreprocessor()(dummy_pil) |
| assert not (t.min() >= 0 and t.max() <= 1) |
|
|
| def test_invalid_type_raises(self): |
| with pytest.raises(TypeError): |
| ImagePreprocessor()({"not": "an image"}) |
|
|
| def test_grayscale_numpy_converted_to_rgb(self): |
| grey = np.full((224, 224), 128, dtype=np.uint8) |
| t = ImagePreprocessor()(grey) |
| assert t.shape == (1, 3, 224, 224) |
|
|
|
|
| |
|
|
| class TestInferencePipeline: |
|
|
| def test_output_schema(self, pipeline, dummy_pil): |
| result = pipeline.predict(dummy_pil) |
| assert "prediction" in result |
| assert "confidence" in result |
| assert "logits" in result |
|
|
| def test_prediction_is_valid_label(self, pipeline, dummy_pil): |
| result = pipeline.predict(dummy_pil) |
| assert result["prediction"] in ("benign", "malignant") |
|
|
| def test_confidence_range(self, pipeline, dummy_pil): |
| result = pipeline.predict(dummy_pil) |
| assert 0.0 <= result["confidence"] <= 1.0 |
|
|
| def test_logits_shape(self, pipeline, dummy_pil): |
| result = pipeline.predict(dummy_pil) |
| assert result["logits"].shape == (1, 2) |
|
|
| def test_batch_predict_length(self, pipeline, dummy_pil, dummy_np): |
| results = pipeline.predict_batch([dummy_pil, dummy_np]) |
| assert len(results) == 2 |
|
|
| def test_deterministic_inference(self, pipeline, dummy_pil): |
| r1 = pipeline.predict(dummy_pil) |
| r2 = pipeline.predict(dummy_pil) |
| assert r1["prediction"] == r2["prediction"] |
| assert r1["confidence"] == r2["confidence"] |
| assert torch.allclose(r1["logits"], r2["logits"]) |
|
|
| def test_numpy_input_accepted(self, pipeline, dummy_np): |
| result = pipeline.predict(dummy_np) |
| assert result["prediction"] in ("benign", "malignant") |
|
|
| def test_logits_detached_from_graph(self, pipeline, dummy_pil): |
| result = pipeline.predict(dummy_pil) |
| assert not result["logits"].requires_grad |
|
|
| def test_missing_weights_raises(self): |
| with pytest.raises(FileNotFoundError): |
| BreastCancerInferencePipeline( |
| weights_path="model/nonexistent_weights.pth", |
| device="cpu", |
| ) |
|
|