diff --git a/tests/engines/test_engine_abc.py b/tests/engines/test_engine_abc.py index 787b23575..7baf38337 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 @@ -543,6 +544,97 @@ 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: + """Fake validate input numbers.""" + _ = images, masks, labels + + def _fake_validate_images_masks( + *, + images: object, + ) -> list: + """Fake validate images masks.""" + _ = images + return [] + + def _fake_load_ioconfig( + *, + ioconfig: object, + ) -> object: + """Fake load ioconfig.""" + return ioconfig + + def _fake_update_ioconfig( + ioconfig: object, + patch_input_shape: object, + stride_shape: object, + input_resolutions: object, + ) -> object: + """Fake update ioconfig.""" + _ = 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") 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, + ) diff --git a/tests/engines/test_prompt_segmentor.py b/tests/engines/test_prompt_segmentor.py new file mode 100644 index 000000000..817147762 --- /dev/null +++ b/tests/engines/test_prompt_segmentor.py @@ -0,0 +1,197 @@ +"""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]: + """Fake inference logic.""" + _ = ( + 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]: + """Fake inference logic.""" + _ = ( + 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: + """Fake dictionary to store semantic segmentor.""" + _ = ( + 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 diff --git a/tests/engines/test_semantic_segmentor.py b/tests/engines/test_semantic_segmentor.py index 27f401be5..79628432b 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,38 @@ 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.""" + _ = self + 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( diff --git a/tests/models/test_arch_sam.py b/tests/models/test_arch_sam.py index 9e36b9c94..a9b46e216 100644 --- a/tests/models/test_arch_sam.py +++ b/tests/models/test_arch_sam.py @@ -16,6 +16,274 @@ _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.""" + _ = self + 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( @@ -78,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.""" @@ -94,7 +366,6 @@ def _fake_encode_image( ) def _fake_process_prompts( - _: object, image: list, embeddings: torch.Tensor, orig_sizes: torch.Tensor, @@ -121,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, @@ -199,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) ) @@ -221,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]])) diff --git a/tests/test_app_bokeh.py b/tests/test_app_bokeh.py index 18e1d7629..5fac63251 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") 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. 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.