Skip to content

Commit 2da0002

Browse files
Jiaqi-LvJiaqi Lvshaneahmed
authored
🔨 Improve mypy Workflow and Fix Typing Errors (#1045)
This pull request includes several bug fixes and improvements across utility functions, type checking, and testing in the codebase. The main focus is on improving error handling in image reading, refining type conversions, and enhancing test coverage for edge cases. **Bug fixes and error handling improvements:** * Improved `imread` in `utils/misc.py` to raise a `FileNotFoundError` when the image path does not exist and an `OSError` when OpenCV fails to read the image, ensuring clearer error reporting. * Updated `cast_to_min_dtype` in `utils/misc.py` to use a tuple of (max_value, dtype) pairs for more robust and readable type selection. * Fixed a bug in `dict_to_store_semantic_segmentor` by converting `layer_list` to a Python list before further processing, ensuring compatibility with downstream code. [[1]](diffhunk://#diff-dfa5a361c3c57e515728d23b53be87e0ee8da01ea04ae9cd6c39770fe3f1aaedR1480-R1487) [[2]](diffhunk://#diff-dfa5a361c3c57e515728d23b53be87e0ee8da01ea04ae9cd6c39770fe3f1aaedL1488-R1496) * Refined coordinate type casting in `filter_coordinates` in `tools/patchextraction.py` to use `cast` for explicit type annotation. **Testing improvements:** * Added tests for `imread` to verify correct exceptions are raised for invalid paths and unreadable images, increasing test coverage for error scenarios. * Added a test to ensure `compile_model` raises a `ValueError` when called with `None`, improving robustness of model compilation utilities. **Type checking and code clarity:** * Added a debug step to the MyPy type checking workflow and changed the import following mode to `silent` for better stability. * Added missing imports and cleaned up imports in test files for clarity and correctness. [[1]](diffhunk://#diff-33c13e0b177bacd2f02e29bcb8aea5b49e7ce34901fd8f41fefb65defba1bd33R7) [[2]](diffhunk://#diff-33c13e0b177bacd2f02e29bcb8aea5b49e7ce34901fd8f41fefb65defba1bd33R46) * Minor refactorings for clarity and explicitness, such as assigning the `attention` attribute in model utilities and improving variable naming in `tools/graph.py` and `utils/image.py`. [[1]](diffhunk://#diff-6eae2ccdde40d5d9c4749fb799b7777ceafbf1778f7b7f4b317af2972dde4225R328-R329) [[2]](diffhunk://#diff-a57a552474d66ef5961593e747fd21ba3fe25530b5a694dd5e8e467614bbc816L387-R391) [[3]](diffhunk://#diff-57f834801126808967286c68c43cb74bb25efaab0369210d0184e2576da66f3bL650-R655) [[4]](diffhunk://#diff-7fc02666e28590e37ff11a7ef254c9b2eb52eaf0f08017184d6e614df176ee80L6-R6) **Model utility improvements:** * Added an explicit check in `compile_model` to raise a `ValueError` if `model` is `None`, preventing silent failures. These changes collectively improve code reliability, maintainability, and test coverage. --------- Co-authored-by: Jiaqi Lv <jiaqilv@Jiaqis-MacBook-Pro.local> Co-authored-by: Shan E Ahmed Raza <13048456+shaneahmed@users.noreply.github.com>
1 parent 275e4e9 commit 2da0002

7 files changed

Lines changed: 78 additions & 15 deletions

File tree

.github/workflows/mypy-type-check.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,15 @@ jobs:
3838
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
3939
pip install -r requirements/requirements_dev.txt
4040
41+
- name: Debug typing environment
42+
run: |
43+
python --version
44+
mypy --version
45+
pip freeze | grep -E '^(mypy|types-)'
46+
4147
- name: Perform type checking
4248
run: |
43-
mypy --install-types --non-interactive --follow-imports=skip \
49+
mypy --install-types --non-interactive --follow-imports=silent \
4450
tiatoolbox/__init__.py \
4551
tiatoolbox/__main__.py \
4652
tiatoolbox/type_hints.py \

tests/test_utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import hashlib
66
import json
7+
import re
78
import shutil
89
from pathlib import Path
910
from typing import TYPE_CHECKING, NoReturn
@@ -42,6 +43,7 @@
4243
cast_to_min_dtype,
4344
create_smart_array,
4445
dict_to_store_patch_predictions,
46+
imread,
4547
)
4648
from tiatoolbox.utils.transforms import locsize2bounds
4749

@@ -1877,6 +1879,12 @@ def test_torch_compile_disable() -> None:
18771879
assert model == compiled_model
18781880

18791881

1882+
def test_torch_compile_none() -> None:
1883+
"""Test torch_compile with a non-model input."""
1884+
with pytest.raises(ValueError, match=re.escape("`model` must not be None.")):
1885+
compile_model(model=None)
1886+
1887+
18801888
def test_torch_compile_compatibility(caplog: pytest.LogCaptureFixture) -> None:
18811889
"""Test if torch-compile compatibility is checked correctly."""
18821890
is_torch_compile_compatible()
@@ -2457,3 +2465,29 @@ def test_dict_to_store_patch_predictions_returns_qupath_json() -> None:
24572465
assert feature["class_value"] in class_dict
24582466
assert feature["properties"]["classification"]["name"] in class_dict.values()
24592467
assert feature["properties"]["classification"]["color"] is not None
2468+
2469+
2470+
def test_imread_invalid_path() -> None:
2471+
"""Test imread with an invalid file path."""
2472+
invalid_path = "non_existent_image.jpg"
2473+
with pytest.raises(
2474+
FileNotFoundError, match=re.escape(f"Image path does not exist: {invalid_path}")
2475+
):
2476+
imread(invalid_path)
2477+
2478+
2479+
def test_imread_cv2_fails(track_tmp_path: Path) -> None:
2480+
"""Test imread when cv2 fails to read an existing image file."""
2481+
# Create a temporary file that exists but contains invalid image data
2482+
tmp_image_path = track_tmp_path / "invalid_image.jpg"
2483+
with tmp_image_path.open("wb") as tmp:
2484+
tmp.write(b"invalid image data")
2485+
2486+
try:
2487+
with pytest.raises(
2488+
OSError, match=re.escape(f"Cannot read image: {tmp_image_path}")
2489+
):
2490+
imread(tmp_image_path)
2491+
finally:
2492+
# Clean up the temporary file
2493+
tmp_image_path.unlink()

tiatoolbox/models/architecture/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ def compile_model(
7878
Compiled model.
7979
8080
"""
81+
if model is None:
82+
msg = "`model` must not be None."
83+
raise ValueError(msg)
84+
8185
if mode == "disable":
8286
return model
8387

@@ -321,6 +325,8 @@ def __init__(self, name: str | None, in_channels: int, reduction: int = 16) -> N
321325
"""
322326
super().__init__()
323327

328+
self.attention: nn.Module
329+
324330
if name is None:
325331
self.attention = nn.Identity()
326332
elif name == "scse":

tiatoolbox/tools/graph.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -384,11 +384,11 @@ def build(
384384
i_vs_all_similarities[neighbour_indexes_single_point] = (
385385
neighbour_similarities
386386
)
387-
i_vs_all_similarities = i_vs_all_similarities[i + 1 :]
388-
condensed_distance_matrix[index : index + len(i_vs_all_similarities)] = (
389-
i_vs_all_similarities
390-
)
391-
index = index + len(i_vs_all_similarities)
387+
i_vs_all_similarities_tail = i_vs_all_similarities[i + 1 :]
388+
condensed_distance_matrix[
389+
index : index + len(i_vs_all_similarities_tail)
390+
] = i_vs_all_similarities_tail
391+
index = index + len(i_vs_all_similarities_tail)
392392

393393
# Perform hierarchical clustering (using similarity as distance)
394394
linkage_matrix = hierarchy.linkage(condensed_distance_matrix, method="average")

tiatoolbox/tools/patchextraction.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from abc import ABC, abstractmethod
6-
from typing import TYPE_CHECKING, TypedDict, overload
6+
from typing import TYPE_CHECKING, TypedDict, cast, overload
77

88
import numpy as np
99
from typing_extensions import Unpack
@@ -432,7 +432,10 @@ def filter_coordinates(
432432
0,
433433
tissue_mask.shape[0],
434434
)
435-
scaled_coords_list = list((scaled_coords).astype(np.int32))
435+
scaled_coords_list = cast(
436+
"list[list[int]]",
437+
scaled_coords.astype(np.int32).tolist(),
438+
)
436439

437440
def default_sel_func(
438441
tissue_mask: np.ndarray,

tiatoolbox/utils/image.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,7 +647,7 @@ def sub_pixel_read( # skipcq: PY-R1000 # noqa: C901, PLR0912, PLR0913, PLR0915
647647
],
648648
)
649649
residuals = np.abs(int_read_bounds - read_bounds)
650-
read_bounds = int_read_bounds
650+
read_bounds = tuple(int_read_bounds)
651651
read_location, read_size = bounds2locsize(int_read_bounds)
652652
valid_int_bounds: np.ndarray = find_overlap(
653653
read_location=read_location,

tiatoolbox/utils/misc.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,17 @@ def imread(image_path: PathLike, *, as_uint8: bool | None = None) -> np.ndarray:
197197
if isinstance(image_path, str):
198198
image_path = Path(image_path)
199199

200+
if not image_path.exists():
201+
msg = f"Image path does not exist: {image_path}"
202+
raise FileNotFoundError(msg)
203+
200204
if image_path.suffix == ".npy":
201205
image = np.load(str(image_path))
202206
else:
203207
image = cv2.imread(str(image_path))
208+
if image is None:
209+
msg = f"Cannot read image: {image_path}"
210+
raise OSError(msg)
204211
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
205212
if as_uint8:
206213
return image.astype(np.uint8)
@@ -1468,11 +1475,13 @@ def dict_to_store_semantic_segmentor(
14681475

14691476
ignore_index = -1 if ignore_index is None else ignore_index
14701477
# Get the number of unique predictions
1471-
layer_list = da.unique(preds).compute()
1472-
layer_list = np.delete(layer_list, np.where(layer_list == ignore_index))
1478+
layer_list_np = da.unique(preds).compute()
1479+
layer_list = (
1480+
np.delete(layer_list_np, np.where(layer_list_np == ignore_index))
1481+
).tolist()
14731482

14741483
if class_dict is None:
1475-
class_dict = {int(i): int(i) for i in layer_list.tolist()}
1484+
class_dict = {int(i): int(i) for i in layer_list}
14761485

14771486
if output_type.lower() == "qupath":
14781487
return _semantic_segmentations_as_qupath_json(
@@ -1939,9 +1948,14 @@ def cast_to_min_dtype(array: np.ndarray | da.Array) -> np.ndarray | da.Array:
19391948
if max_value == 1:
19401949
return array.astype(bool)
19411950

1942-
dtypes = [np.uint8, np.uint16, np.uint32, np.uint64]
1943-
for dtype in dtypes:
1944-
if max_value <= np.iinfo(dtype).max:
1951+
dtype_candidates = (
1952+
(np.iinfo(np.uint8).max, np.uint8),
1953+
(np.iinfo(np.uint16).max, np.uint16),
1954+
(np.iinfo(np.uint32).max, np.uint32),
1955+
(np.iinfo(np.uint64).max, np.uint64),
1956+
)
1957+
for max_allowed, dtype in dtype_candidates:
1958+
if max_value <= max_allowed:
19451959
return array.astype(dtype)
19461960

19471961
return array

0 commit comments

Comments
 (0)