From 0442d7f20d7070d8909a9769ef4d3b9b7ebf54f6 Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:41:01 +0100 Subject: [PATCH 01/11] :white_check_mark: Optimise `test_app_bokeh` --- tests/test_app_bokeh.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_app_bokeh.py b/tests/test_app_bokeh.py index 19029995d..356bd3fee 100644 --- a/tests/test_app_bokeh.py +++ b/tests/test_app_bokeh.py @@ -48,6 +48,7 @@ FILLED = 0 MICRON_FORMATTER = 1 GRIDLINES = 2 +_RUNNING_ON_CI = toolbox_env.running_on_ci() # Helper function @@ -582,6 +583,10 @@ def test_load_img_overlay(doc: Document, data_path: pytest.TempPathFactory) -> N assert full_name in main.UI["vstate"].layer_dict +@pytest.mark.skipif( + _RUNNING_ON_CI, + reason="Local test only.", +) def test_hovernet_on_box(doc: Document, data_path: pytest.TempPathFactory) -> None: """Test running hovernet on a box.""" slide_select = doc.get_model_by_name("slide_select0") @@ -621,6 +626,10 @@ def test_hovernet_on_box(doc: Document, data_path: pytest.TempPathFactory) -> No assert len(main.UI["type_column"].children) == 1 +@pytest.mark.skipif( + _RUNNING_ON_CI, + reason="Local test only.", +) def test_sam_segment(doc: Document, data_path: pytest.TempPathFactory) -> None: """Test running SAM on a box.""" slide_select = doc.get_model_by_name("slide_select0") From 246c96d62bef1828ab846fb8bcf1f43cd30c0a4e Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:05:52 +0100 Subject: [PATCH 02/11] :white_check_mark: Add test for `get_wsi_output_shape` --- tests/engines/test_semantic_segmentor.py | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/engines/test_semantic_segmentor.py b/tests/engines/test_semantic_segmentor.py index 27f401be5..a0f4612f0 100644 --- a/tests/engines/test_semantic_segmentor.py +++ b/tests/engines/test_semantic_segmentor.py @@ -7,6 +7,7 @@ import sqlite3 import tempfile from pathlib import Path +from types import SimpleNamespace from typing import TYPE_CHECKING from unittest import mock @@ -474,6 +475,37 @@ def test_aggregate_vertical_segment_skips_non_overlapping_chunk() -> None: np.testing.assert_allclose(result, included_chunk) +def test_get_wsi_output_shape_uses_existing_reader() -> None: + """Test get_wsi_output_shape when dataset already has a reader.""" + + class FakeReader: + """Minimal reader stub.""" + + def slide_dimensions( + self, + resolution: float, + units: str, + ) -> tuple[int, int]: + """Return fake WSI dimensions.""" + assert resolution == 0.5 + assert units == "mpp" + + return (1000, 500) # width, height + + dataset = SimpleNamespace( + wsi_shape=None, + img_path="dummy.svs", + resolution=0.5, + units="mpp", + reader=FakeReader(), + ) + + result = get_wsi_output_shape(dataset) + + # Function returns (height, width) + assert result == (500, 1000) + + def test_store_vertical_segment_stops_when_output_shape_is_filled() -> None: """Test storing short-circuits after clipping reaches target height.""" existing_da = da.from_array( From 2f89c0d79bc0990e11014f0997b1b5900ff4902f Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:12:15 +0100 Subject: [PATCH 03/11] :white_check_mark: Add test for `_update_run_params` --- tests/engines/test_engine_abc.py | 88 ++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/engines/test_engine_abc.py b/tests/engines/test_engine_abc.py index 31e89ecf9..79b1d2022 100644 --- a/tests/engines/test_engine_abc.py +++ b/tests/engines/test_engine_abc.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Unpack +import dask import dask.array as da import numpy as np import pytest @@ -541,6 +542,93 @@ def test_io_config_delegation( ) +def test_update_run_params_sets_dask_num_workers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Dask configuration when num_workers is greater than zero.""" + eng = TestEngineABC(model="alexnet-kather100k") + + captured: dict[str, object] = {} + + def _fake_dask_config_set(**kwargs: object) -> None: + """Capture Dask configuration.""" + captured.update(kwargs) + + def _fake_validate_input_numbers( + *, + images: object, + masks: object, + labels: object, + ) -> None: + _ = images, masks, labels + + def _fake_validate_images_masks( + *, + images: object, + ) -> list: + _ = images + return [] + + def _fake_load_ioconfig( + *, + ioconfig: object, + ) -> object: + return ioconfig + + def _fake_update_ioconfig( + ioconfig: object, + patch_input_shape: object, + stride_shape: object, + input_resolutions: object, + ) -> object: + _ = patch_input_shape, stride_shape, input_resolutions + return ioconfig + + monkeypatch.setattr( + dask.config, + "set", + _fake_dask_config_set, + ) + + monkeypatch.setattr( + eng, + "_validate_input_numbers", + _fake_validate_input_numbers, + ) + + monkeypatch.setattr( + eng, + "_validate_images_masks", + _fake_validate_images_masks, + ) + + monkeypatch.setattr( + eng, + "_load_ioconfig", + _fake_load_ioconfig, + ) + + monkeypatch.setattr( + eng, + "_update_ioconfig", + _fake_update_ioconfig, + ) + + eng.num_workers = 4 + eng.return_labels = True + eng.drop_keys = [] + + eng._update_run_params( + images=[], + patch_mode=True, + output_type="dict", + save_dir=None, + ) + + assert captured["scheduler"] == "threads" + assert captured["num_workers"] == 4 + + def test_save_predictions_incorrect_output_type() -> None: """Engine should raise TypeError if incorrect output type is requested.""" eng = TestEngineABC(model="alexnet-kather100k") From 4a53f92a242a8aec45352e9a7806a874877ea154 Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:16:09 +0100 Subject: [PATCH 04/11] :white_check_mark: Add test for `process_contours` --- tests/test_utils.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index f27115de5..a91e14fd9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -340,6 +340,35 @@ def test_safe_padded_read_pad_constant_values() -> None: assert np.sum(region == 10) == (4 * side_len) + 4 +def test_process_contours_with_offset() -> None: + """Test contour coordinates are shifted when offset is provided.""" + contours = [ + np.array( + [[[10, 20]]], + dtype=np.int32, + ), + ] + + hierarchy = np.array( + [[[-1, -1, -1, -1]]], + dtype=np.int32, + ) + + annotations = utils.misc.process_contours( + contours=contours, + hierarchy=hierarchy, + scale_factor=(1.0, 1.0), + offset=np.array([5, 7]), + ) + + assert len(annotations) == 1 + + point = annotations[0].geometry + + assert point.x == 15 + assert point.y == 27 + + def test_fuzz_safe_padded_read_edge_padding() -> None: """Fuzz test for padding at edges of an image. From 090c41e526d9f3ead7f47f49bd78e8d2d62bae65 Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:25:26 +0100 Subject: [PATCH 05/11] :bug: Fix deepsource errors --- tests/engines/test_engine_abc.py | 4 ++++ tests/engines/test_semantic_segmentor.py | 1 + 2 files changed, 5 insertions(+) diff --git a/tests/engines/test_engine_abc.py b/tests/engines/test_engine_abc.py index 79b1d2022..a903cd346 100644 --- a/tests/engines/test_engine_abc.py +++ b/tests/engines/test_engine_abc.py @@ -560,12 +560,14 @@ def _fake_validate_input_numbers( masks: object, labels: object, ) -> None: + """Fake validate input numbers.""" _ = images, masks, labels def _fake_validate_images_masks( *, images: object, ) -> list: + """Fake validate images masks.""" _ = images return [] @@ -573,6 +575,7 @@ def _fake_load_ioconfig( *, ioconfig: object, ) -> object: + """Fake load ioconfig.""" return ioconfig def _fake_update_ioconfig( @@ -581,6 +584,7 @@ def _fake_update_ioconfig( stride_shape: object, input_resolutions: object, ) -> object: + """Fake update ioconfig.""" _ = patch_input_shape, stride_shape, input_resolutions return ioconfig diff --git a/tests/engines/test_semantic_segmentor.py b/tests/engines/test_semantic_segmentor.py index a0f4612f0..79628432b 100644 --- a/tests/engines/test_semantic_segmentor.py +++ b/tests/engines/test_semantic_segmentor.py @@ -487,6 +487,7 @@ def slide_dimensions( units: str, ) -> tuple[int, int]: """Return fake WSI dimensions.""" + _ = self assert resolution == 0.5 assert units == "mpp" From 5dcdd6e935ac8c2099f08836354bc7b5f4c4bf75 Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:10:46 +0100 Subject: [PATCH 06/11] :white_check_mark: Add test for `PromptSegmentor` --- tests/engines/test_prompt_segmentor.py | 194 +++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/engines/test_prompt_segmentor.py diff --git a/tests/engines/test_prompt_segmentor.py b/tests/engines/test_prompt_segmentor.py new file mode 100644 index 000000000..b4fbc08f8 --- /dev/null +++ b/tests/engines/test_prompt_segmentor.py @@ -0,0 +1,194 @@ +"""Test PromptSegmentor.""" + +from pathlib import Path + +import numpy as np +import pytest + +from tiatoolbox.models.engine.prompt_segmentor import PromptSegmentor + + +def test_prompt_segmentor_calc_mpp_no_scaling() -> None: + """Test calc_mpp when no scaling is required.""" + segmentor = PromptSegmentor(model=object()) + + mpp, scale = segmentor.calc_mpp( + area_dims=(500, 1000), + base_mpp=0.5, + fixed_size=1500, + ) + + assert mpp == 0.5 + assert scale == 1.0 + assert segmentor.scale == 1.0 + + +def test_prompt_segmentor_calc_mpp_with_scaling() -> None: + """Test calc_mpp when scaling is required.""" + segmentor = PromptSegmentor(model=object()) + + mpp, scale = segmentor.calc_mpp( + area_dims=(3000, 2000), + base_mpp=0.5, + fixed_size=1500, + ) + + assert scale == 2.0 + assert mpp == 1.0 + assert segmentor.scale == 2.0 + + +def test_prompt_segmentor_run( + monkeypatch: pytest.MonkeyPatch, + track_tmp_path: Path, +) -> None: + """Test prompt segmentor inference workflow.""" + called: dict[str, object] = {} + + class FakeSAM: + """Minimal stand-in for SAM.""" + + @staticmethod + def infer_batch( + model: object, + images: list, + point_coords: np.ndarray | None = None, + box_coords: np.ndarray | None = None, + *, + device: str = "cpu", + ) -> tuple[np.ndarray, np.ndarray]: + _ = ( + model, + images, + point_coords, + box_coords, + device, + ) + + masks = np.array( + [ + [ + [ + [True, False], + [False, True], + ], + ], + ], + dtype=bool, + ) + + scores = np.array([1.0]) + + return masks, scores + + def _fake_dict_to_store_semantic_segmentor( + patch_output: dict, + scale_factor: tuple[float, float], + offset: np.ndarray, + save_path: Path, + output_type: str, + ignore_index: int, + ) -> None: + """Capture annotation-store calls.""" + called["patch_output"] = patch_output + called["scale_factor"] = scale_factor + called["offset"] = offset + called["save_path"] = save_path + called["output_type"] = output_type + called["ignore_index"] = ignore_index + + monkeypatch.setattr( + "tiatoolbox.models.engine.prompt_segmentor.dict_to_store_semantic_segmentor", + _fake_dict_to_store_semantic_segmentor, + ) + + segmentor = PromptSegmentor(model=FakeSAM()) + + result = segmentor.run( + images=[np.zeros((10, 10, 3), dtype=np.uint8)], + point_coords=np.array([[[1, 1]]]), + save_dir=track_tmp_path, + ) + + expected_path = track_tmp_path / "0.db" + + assert result == [expected_path] + + assert called["save_path"] == expected_path + assert called["output_type"] == "annotationstore" + assert called["ignore_index"] == 0 + + +def test_prompt_segmentor_run_multiple_masks( + monkeypatch: pytest.MonkeyPatch, + track_tmp_path: Path, +) -> None: + """Test processing multiple returned masks.""" + save_paths: list[Path] = [] + + class FakeSAM: + """Minimal stand-in for SAM.""" + + @staticmethod + def infer_batch( + model: object, + images: list, + point_coords: np.ndarray | None = None, + box_coords: np.ndarray | None = None, + *, + device: str = "cpu", + ) -> tuple[np.ndarray, np.ndarray]: + _ = ( + model, + images, + point_coords, + box_coords, + device, + ) + + masks = np.zeros( + (2, 1, 1, 4, 4), + dtype=bool, + ) + + scores = np.zeros((2,)) + + return masks, scores + + def _fake_dict_to_store_semantic_segmentor( + patch_output: dict, + scale_factor: tuple[float, float], + offset: np.ndarray, + save_path: Path, + output_type: str, + ignore_index: int, + ) -> None: + _ = ( + patch_output, + scale_factor, + offset, + output_type, + ignore_index, + ) + + save_paths.append(save_path) + + monkeypatch.setattr( + "tiatoolbox.models.engine.prompt_segmentor.dict_to_store_semantic_segmentor", + _fake_dict_to_store_semantic_segmentor, + ) + + segmentor = PromptSegmentor(model=FakeSAM()) + + result = segmentor.run( + images=[np.zeros((10, 10, 3), dtype=np.uint8)], + point_coords=np.array([[[1, 1]]]), + save_dir=track_tmp_path, + ) + + assert result == [ + track_tmp_path / "0.db", + track_tmp_path / "1.db", + ] + + assert len(save_paths) == 2 From f50e039751bf9d7e93f667ef653a35a4b6c38b0f Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:18:03 +0100 Subject: [PATCH 07/11] :white_check_mark: Add test for `sam` --- tests/models/test_arch_sam.py | 267 ++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) diff --git a/tests/models/test_arch_sam.py b/tests/models/test_arch_sam.py index 59ace2d47..9aa1889a8 100644 --- a/tests/models/test_arch_sam.py +++ b/tests/models/test_arch_sam.py @@ -16,6 +16,273 @@ _RUNNING_ON_CI = toolbox_env.running_on_ci() +def test_sam_init( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test SAM initialization.""" + fake_model = object() + fake_processor = object() + + class FakeLoadedModel: + """Fake SAM model.""" + + def to(self, device: str) -> object: + """Return fake model.""" + assert device == "cpu" + return fake_model + + def _fake_model_from_pretrained( + model_path: str, + ) -> FakeLoadedModel: + """Return fake SAM model.""" + assert model_path == "fake-model" + return FakeLoadedModel() + + def _fake_processor_from_pretrained( + model_path: str, + ) -> object: + """Return fake SAM processor.""" + assert model_path == "fake-model" + return fake_processor + + monkeypatch.setattr( + "tiatoolbox.models.architecture.sam.SamModel.from_pretrained", + _fake_model_from_pretrained, + ) + + monkeypatch.setattr( + "tiatoolbox.models.architecture.sam.SamProcessor.from_pretrained", + _fake_processor_from_pretrained, + ) + + sam = SAM( + model_path="fake-model", + device="cpu", + ) + + assert sam.net_name == "SAM" + assert sam.device == "cpu" + assert sam.model is fake_model + assert sam.processor is fake_processor + + +def test_sam_encode_image() -> None: + """Test image encoding pipeline.""" + sam = SAM.__new__(SAM) + + sam.device = "cpu" + + processed = { + "original_sizes": torch.tensor([[100, 200]]), + "reshaped_input_sizes": torch.tensor([[64, 64]]), + "pixel_values": torch.ones((1, 3, 64, 64)), + } + + class FakeProcessed(dict): + """Fake processor output.""" + + def to(self, device: str) -> "FakeProcessed": + """Return self after device transfer.""" + assert device == "cpu" + return self + + class FakeProcessor: + """Fake SAM processor.""" + + def __call__( + self, + image: np.ndarray, + return_tensors: str, + ) -> FakeProcessed: + """Return encoded image.""" + _ = image + + assert return_tensors == "pt" + + return FakeProcessed(processed) + + class FakeModel: + """Fake SAM model.""" + + @staticmethod + def get_image_embeddings( + pixel_values: torch.Tensor, + ) -> torch.Tensor: + """Return fake embeddings.""" + assert torch.equal( + pixel_values, + processed["pixel_values"], + ) + + return torch.tensor([[123]]) + + sam.processor = FakeProcessor() + sam.model = FakeModel() + + image = np.zeros( + (32, 32, 3), + dtype=np.uint8, + ) + + embeddings, original_sizes, reshaped_sizes = sam._encode_image( + image, + ) + + assert torch.equal( + embeddings, + torch.tensor([[123]]), + ) + + assert torch.equal( + original_sizes, + processed["original_sizes"], + ) + + assert torch.equal( + reshaped_sizes, + processed["reshaped_input_sizes"], + ) + + +def test_sam_process_prompts() -> None: + """Test SAM prompt processing.""" + sam = SAM.__new__(SAM) + + sam.device = "cpu" + + image_masks = np.array([[1]]) + image_scores = torch.tensor([[0.99]]) + + captured: dict[str, object] = {} + + class FakeInputs(dict): + """Fake processor outputs.""" + + def to(self, device: str) -> "FakeInputs": + """Fake tensor transfer.""" + assert device == "cpu" + return self + + class FakeOutputs: + """Fake SAM outputs.""" + + def __init__(self) -> None: + self.pred_masks = torch.ones((1, 1, 4, 4)) + self.iou_scores = image_scores + + class FakeImageProcessor: + """Fake image processor.""" + + @staticmethod + def post_process_masks( + pred_masks: torch.Tensor, + original_sizes: torch.Tensor, + reshaped_input_sizes: torch.Tensor, + ) -> np.ndarray: + """Return fake masks.""" + _ = ( + pred_masks, + original_sizes, + reshaped_input_sizes, + ) + + return image_masks + + class FakeProcessor: + """Fake SAM processor.""" + + image_processor = FakeImageProcessor() + + def __call__( + self, + image: object, + input_points: list | None = None, + input_labels: list | None = None, + input_boxes: list | None = None, + return_tensors: str = "pt", + ) -> FakeInputs: + """Return fake processor inputs.""" + _ = image + captured["input_points"] = input_points + captured["input_labels"] = input_labels + captured["input_boxes"] = input_boxes + + assert return_tensors == "pt" + + return FakeInputs( + { + "pixel_values": torch.ones((1, 3, 4, 4)), + }, + ) + + class FakeModel: + """Fake SAM model.""" + + def __call__( + self, + **kwargs: object, + ) -> FakeOutputs: + """Capture forwarded kwargs.""" + captured["forward_kwargs"] = kwargs + + assert kwargs["multimask_output"] is False + assert "pixel_values" not in kwargs + assert "image_embeddings" in kwargs + + return FakeOutputs() + + sam.processor = FakeProcessor() + sam.model = FakeModel() + + embeddings = torch.tensor([[1]]) + original_sizes = torch.tensor([[100, 200]]) + reshaped_sizes = torch.tensor([[64, 64]]) + + masks, scores = sam._process_prompts( + image=[np.zeros((4, 4, 3), dtype=np.uint8)], + embeddings=embeddings, + orig_sizes=original_sizes, + reshaped_sizes=reshaped_sizes, + points=[[[1, 1]]], + boxes=None, + point_labels=[[[1]]], + ) + + assert np.array_equal(masks, image_masks) + assert torch.equal(scores, image_scores) + + assert captured["input_points"] == [[[1, 1]]] + assert captured["input_boxes"] is None + + +def test_sam_to_updates_device() -> None: + """Test SAM.to updates device.""" + sam = SAM.__new__(SAM) + torch.nn.Module.__init__(sam) + + class FakeModel: + """Fake model.""" + + def __init__(self) -> None: + self.called_device: str | None = None + + def to(self, device: str) -> "FakeModel": + """Record device.""" + self.called_device = device + return self + + fake_model = FakeModel() + + sam.model = fake_model + sam.device = "cpu" + + result = sam.to(device="cpu") + + assert result is sam + assert sam.device == "cpu" + assert fake_model.called_device == "cpu" + + def test_sam_preproc_torch_tensor() -> None: """Test SAM pre-processing for PyTorch tensor input.""" image = torch.arange( From f29cc36c4445e778064237776b46b639d3ecf7bf Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:31:36 +0100 Subject: [PATCH 08/11] :bug: Fix deepsource errors --- tests/engines/test_prompt_segmentor.py | 3 +++ tests/models/test_arch_sam.py | 1 + 2 files changed, 4 insertions(+) diff --git a/tests/engines/test_prompt_segmentor.py b/tests/engines/test_prompt_segmentor.py index b4fbc08f8..817147762 100644 --- a/tests/engines/test_prompt_segmentor.py +++ b/tests/engines/test_prompt_segmentor.py @@ -57,6 +57,7 @@ def infer_batch( *, device: str = "cpu", ) -> tuple[np.ndarray, np.ndarray]: + """Fake inference logic.""" _ = ( model, images, @@ -138,6 +139,7 @@ def infer_batch( *, device: str = "cpu", ) -> tuple[np.ndarray, np.ndarray]: + """Fake inference logic.""" _ = ( model, images, @@ -163,6 +165,7 @@ def _fake_dict_to_store_semantic_segmentor( output_type: str, ignore_index: int, ) -> None: + """Fake dictionary to store semantic segmentor.""" _ = ( patch_output, scale_factor, diff --git a/tests/models/test_arch_sam.py b/tests/models/test_arch_sam.py index 9aa1889a8..fa02e76ed 100644 --- a/tests/models/test_arch_sam.py +++ b/tests/models/test_arch_sam.py @@ -28,6 +28,7 @@ class FakeLoadedModel: def to(self, device: str) -> object: """Return fake model.""" + _ = self assert device == "cpu" return fake_model From 8f6439e039af6cbf411ced4769d3fb66145bce18 Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:49:25 +0000 Subject: [PATCH 09/11] :white_check_mark: Add tests to `test_ioconfig` --- tests/engines/test_ioconfig.py | 60 +++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/tests/engines/test_ioconfig.py b/tests/engines/test_ioconfig.py index 6b03cbeab..495a587cd 100644 --- a/tests/engines/test_ioconfig.py +++ b/tests/engines/test_ioconfig.py @@ -3,7 +3,11 @@ import numpy as np import pytest -from tiatoolbox.models import IOSegmentorConfig, ModelIOConfigABC +from tiatoolbox.models import ( + IOInstanceSegmentorConfig, + IOSegmentorConfig, + ModelIOConfigABC, +) def test_validation_error_io_config() -> None: @@ -119,3 +123,57 @@ def test_ios_to_baseline_without_save_resolution() -> None: # save_resolution should remain None after conversion assert new_cfg.save_resolution is None + + +def test_model_ioconfig_to_baseline_with_save_resolution() -> None: + """Test baseline conversion when save_resolution is present.""" + config = IOSegmentorConfig( + input_resolutions=[{"units": "mpp", "resolution": 0.25}], + output_resolutions=[{"units": "mpp", "resolution": 0.5}], + patch_input_shape=(256, 256), + patch_output_shape=(128, 128), + stride_shape=(128, 128), + save_resolution={"units": "mpp", "resolution": 1.0}, + ) + + config.to_baseline() + + # Coverage test, no assertion required beyond successful execution. + assert config.save_resolution is not None + + +def test_iosegmentorconfig_to_baseline_with_save_resolution() -> None: + """Test IOSegmentorConfig baseline conversion with save resolution.""" + config = IOSegmentorConfig( + input_resolutions=[{"units": "mpp", "resolution": 0.25}], + output_resolutions=[{"units": "mpp", "resolution": 0.5}], + patch_input_shape=(256, 256), + patch_output_shape=(128, 128), + stride_shape=(128, 128), + save_resolution={"units": "mpp", "resolution": 1.0}, + ) + + baseline = config.to_baseline() + + assert baseline.save_resolution is not None + assert baseline.save_resolution["units"] == "baseline" + + +def test_ioinstancesegmentorconfig_to_baseline() -> None: + """Test IOInstanceSegmentorConfig baseline conversion.""" + config = IOInstanceSegmentorConfig( + input_resolutions=[{"units": "mpp", "resolution": 0.25}], + output_resolutions=[{"units": "mpp", "resolution": 0.5}], + patch_input_shape=(256, 256), + patch_output_shape=(128, 128), + stride_shape=(128, 128), + margin=32, + tile_shape=(512, 512), + ) + + result = config.to_baseline() + + assert isinstance( + result, + IOInstanceSegmentorConfig, + ) From c5829066122631e0dd5800de17d369fe2cc00161 Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:26:32 +0000 Subject: [PATCH 10/11] :white_check_mark: Add tests to `test_arch_sam` --- tests/models/test_arch_sam.py | 229 ++++++++++++++++++++++++++++++++-- 1 file changed, 222 insertions(+), 7 deletions(-) diff --git a/tests/models/test_arch_sam.py b/tests/models/test_arch_sam.py index 007bd1a2d..a9b46e216 100644 --- a/tests/models/test_arch_sam.py +++ b/tests/models/test_arch_sam.py @@ -346,10 +346,14 @@ def test_sam_forward_point_prompts_only( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test SAM forward pass using point prompts only.""" + sam = SAM.__new__(SAM) + + # Initialize PyTorch bookkeeping without loading pretrained SAM weights. + torch.nn.Module.__init__(sam) + captured: dict[str, object] = {} def _fake_encode_image( - _: object, image: list, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Return fake embeddings and size metadata.""" @@ -362,7 +366,6 @@ def _fake_encode_image( ) def _fake_process_prompts( - _: object, image: list, embeddings: torch.Tensor, orig_sizes: torch.Tensor, @@ -389,19 +392,17 @@ def _fake_process_prompts( ) monkeypatch.setattr( - SAM, + sam, "_encode_image", _fake_encode_image, ) monkeypatch.setattr( - SAM, + sam, "_process_prompts", _fake_process_prompts, ) - sam = SAM.__new__(SAM) - image = np.zeros( (8, 8, 3), dtype=np.uint8, @@ -467,7 +468,6 @@ def test_functional_sam(remote_sample: Callable) -> None: patch = model.preproc(patch) # test inference - mask_output, score_output = model.infer_batch( model, patch, points, device=select_device(on_gpu=ON_GPU) ) @@ -489,3 +489,218 @@ def test_functional_sam(remote_sample: Callable) -> None: match=r"At least one of point_coords or box_coords must be provided.", ): _ = model.infer_batch(model, patch, device=select_device(on_gpu=ON_GPU)) + + +def test_sam_forward_box_prompts_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test forward pass using box prompts only.""" + sam = SAM.__new__(SAM) + torch.nn.Module.__init__(sam) + + sam.device = "cpu" + + captured: dict[str, object] = {} + + def _fake_encode_image( + image: list, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + _ = image + + return ( + torch.zeros((1, 1)), + torch.tensor([[8, 8]]), + torch.tensor([[8, 8]]), + ) + + def _fake_process_prompts( + image: list, + embeddings: torch.Tensor, + orig_sizes: torch.Tensor, + reshaped_sizes: torch.Tensor, + points: list | None, + boxes: list | None, + point_labels: list | None, + ) -> tuple[np.ndarray, np.ndarray]: + _ = ( + image, + embeddings, + orig_sizes, + reshaped_sizes, + ) + + captured["points"] = points + captured["boxes"] = boxes + captured["point_labels"] = point_labels + + return ( + np.ones((1, 8, 8), dtype=np.uint8), + np.array([[0.95]], dtype=np.float32), + ) + + monkeypatch.setattr( + sam, + "_encode_image", + _fake_encode_image, + ) + + monkeypatch.setattr( + sam, + "_process_prompts", + _fake_process_prompts, + ) + + image = np.zeros((8, 8, 3), dtype=np.uint8) + + box_coords = [ + np.array( + [[1, 2, 6, 7]], + dtype=np.int32, + ), + ] + + masks, scores = sam.forward( + imgs=[image], + point_coords=None, + box_coords=box_coords, + ) + + assert captured["boxes"] is not None + assert captured["points"] is None + assert captured["point_labels"] is None + + assert isinstance(masks, np.ndarray) + assert isinstance(scores, np.ndarray) + + +def test_sam_infer_batch_with_box_prompts() -> None: + """Test infer_batch with box prompts.""" + + class FakeModel(torch.nn.Module): + """Minimal SAM model.""" + + def forward( + self, + batch_data: list, + point_coords: np.ndarray | None, + box_coords: np.ndarray | None, + ) -> tuple[np.ndarray, np.ndarray]: + _ = batch_data, point_coords + + assert box_coords is not None + + return ( + np.ones((1, 8, 8), dtype=np.uint8), + np.array([[0.9]], dtype=np.float32), + ) + + model = FakeModel() + + batch_data = [ + np.zeros((8, 8, 3), dtype=np.uint8), + ] + + box_coords = np.array( + [[[1, 2, 6, 7]]], + dtype=np.int32, + ) + + masks, scores = SAM.infer_batch( + model=model, + batch_data=batch_data, + box_coords=box_coords, + point_coords=None, + device="cpu", + ) + + assert masks.shape == (1, 8, 8) + assert scores.shape == (1, 1) + + +def test_sam_process_prompts_points_box_none() -> None: + """Test prompt processing pipeline.""" + sam = SAM.__new__(SAM) + sam.device = "cpu" + + class FakeInputs(dict): + """Fake processor output.""" + + def to(self, device: str) -> "FakeInputs": + assert device == "cpu" + return self + + class FakeOutputs: + """Fake SAM outputs.""" + + def __init__(self) -> None: + self.pred_masks = torch.ones((1, 1, 4, 4)) + self.iou_scores = torch.tensor([[0.9]]) + + class FakeImageProcessor: + """Fake image processor.""" + + @staticmethod + def post_process_masks( + pred_masks: torch.Tensor, + original_sizes: torch.Tensor, + reshaped_input_sizes: torch.Tensor, + ) -> np.ndarray: + _ = ( + pred_masks, + original_sizes, + reshaped_input_sizes, + ) + return np.array([[1]]) + + class FakeProcessor: + """Fake processor.""" + + image_processor = FakeImageProcessor() + + def __call__( + self, + image: object, + input_points: list | None = None, + input_labels: list | None = None, + input_boxes: list | None = None, + return_tensors: str = "pt", + ) -> FakeInputs: + _ = ( + image, + input_points, + input_labels, + input_boxes, + ) + + assert return_tensors == "pt" + + return FakeInputs( + { + "pixel_values": torch.ones((1, 3, 4, 4)), + }, + ) + + class FakeModel: + """Fake model.""" + + def __call__( + self, + **kwargs: object, + ) -> FakeOutputs: + assert "pixel_values" not in kwargs + assert "image_embeddings" in kwargs + return FakeOutputs() + + sam.processor = FakeProcessor() + sam.model = FakeModel() + + masks, scores = sam._process_prompts( + image=[np.zeros((4, 4, 3), dtype=np.uint8)], + embeddings=torch.tensor([[1]]), + orig_sizes=torch.tensor([[4, 4]]), + reshaped_sizes=torch.tensor([[4, 4]]), + boxes=[[[[1, 2, 3, 4]]]], + ) + + assert np.array_equal(masks, np.array([[1]])) + assert torch.equal(scores, torch.tensor([[0.9]])) From 6fb0deecfd4e78c59fce6d9f860dc45926a099de Mon Sep 17 00:00:00 2001 From: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:32:42 +0000 Subject: [PATCH 11/11] :pencil2: Fix typo --- tiatoolbox/models/architecture/sam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tiatoolbox/models/architecture/sam.py b/tiatoolbox/models/architecture/sam.py index 8fb73da27..932046ba2 100644 --- a/tiatoolbox/models/architecture/sam.py +++ b/tiatoolbox/models/architecture/sam.py @@ -16,7 +16,7 @@ class SAM(ModelABC): Meta AI's zero-shot segmentation model. SAM is used for interactive general-purpose segmentation. - Currently supports SAM. + Currently, supports SAM. SAM accepts an RGB image patch along with a list of point and bounding box coordinates as prompts.