Skip to content

Commit 844d180

Browse files
authored
Merge branch 'dev' into vikash/NaViT
2 parents 5a0873f + 1a165c9 commit 844d180

11 files changed

Lines changed: 183 additions & 96 deletions

File tree

monai/apps/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,13 @@
1313

1414
from .datasets import CrossValidation, DecathlonDataset, MedNISTDataset, TciaDataset
1515
from .mmars import MODEL_DESC, RemoteMMARKeys, download_mmar, get_model_spec, load_from_mmar
16-
from .utils import SUPPORTED_HASH_TYPES, check_hash, download_and_extract, download_url, extractall, get_logger, logger
16+
from .utils import (
17+
SUPPORTED_HASH_TYPES,
18+
HashCheckError,
19+
check_hash,
20+
download_and_extract,
21+
download_url,
22+
extractall,
23+
get_logger,
24+
logger,
25+
)

monai/apps/utils.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,24 @@
4242
else:
4343
tqdm, has_tqdm = optional_import("tqdm", "4.47.0", min_version, "tqdm")
4444

45-
__all__ = ["check_hash", "download_url", "extractall", "download_and_extract", "get_logger", "SUPPORTED_HASH_TYPES"]
45+
__all__ = [
46+
"HashCheckError",
47+
"check_hash",
48+
"download_url",
49+
"extractall",
50+
"download_and_extract",
51+
"get_logger",
52+
"SUPPORTED_HASH_TYPES",
53+
]
4654

4755
DEFAULT_FMT = "%(asctime)s - %(levelname)s - %(message)s"
4856
SUPPORTED_HASH_TYPES = {"md5": hashlib.md5, "sha1": hashlib.sha1, "sha256": hashlib.sha256, "sha512": hashlib.sha512}
4957

5058

59+
class HashCheckError(ValueError):
60+
pass
61+
62+
5163
def get_logger(
5264
module_name: str = "monai.apps",
5365
fmt: str = DEFAULT_FMT,
@@ -220,18 +232,15 @@ def download_url(
220232
HTTPError: See urllib.request.urlretrieve.
221233
ContentTooShortError: See urllib.request.urlretrieve.
222234
IOError: See urllib.request.urlretrieve.
223-
RuntimeError: When the hash validation of the ``url`` downloaded file fails.
224-
235+
HashCheckError: When the hash validation of the ``url`` downloaded file fails.
225236
"""
226237
if not filepath:
227238
filepath = Path(".", _basename(url)).resolve()
228239
logger.info(f"Default downloading to '{filepath}'")
229240
filepath = Path(filepath)
230241
if filepath.exists():
231242
if not check_hash(filepath, hash_val, hash_type):
232-
raise RuntimeError(
233-
f"{hash_type} check of existing file failed: filepath={filepath}, expected {hash_type}={hash_val}."
234-
)
243+
raise HashCheckError(f"{hash_type} hash check of existing file failed: {filepath=}, expected {hash_type=}.")
235244
logger.info(f"File exists: {filepath}, skipped downloading.")
236245
return
237246
try:
@@ -260,18 +269,20 @@ def download_url(
260269
raise RuntimeError(
261270
f"Download of file from {url} to {filepath} failed due to network issue or denied permission."
262271
)
272+
if not check_hash(tmp_name, hash_val, hash_type):
273+
raise HashCheckError(
274+
f"{hash_type} hash check of downloaded file failed: {url=}, "
275+
f"{filepath=}, expected {hash_type}={hash_val}, "
276+
f"The file may be corrupted or tampered with. "
277+
"Please retry the download or verify the source."
278+
)
263279
file_dir = filepath.parent
264280
if file_dir:
265281
os.makedirs(file_dir, exist_ok=True)
266282
shutil.move(f"{tmp_name}", f"{filepath}") # copy the downloaded to a user-specified cache.
267283
except (PermissionError, NotADirectoryError): # project-monai/monai issue #3613 #3757 for windows
268284
pass
269285
logger.info(f"Downloaded: {filepath}")
270-
if not check_hash(filepath, hash_val, hash_type):
271-
raise RuntimeError(
272-
f"{hash_type} check of downloaded file failed: URL={url}, "
273-
f"filepath={filepath}, expected {hash_type}={hash_val}."
274-
)
275286

276287

277288
def _extract_zip(filepath, output_dir):
@@ -325,10 +336,15 @@ def extractall(
325336
be False.
326337
327338
Raises:
328-
RuntimeError: When the hash validation of the ``filepath`` compressed file fails.
339+
HashCheckError: When the hash validation of the ``filepath`` compressed file fails.
329340
NotImplementedError: When the ``filepath`` file extension is not one of [zip", "tar.gz", "tar"].
330341
331342
"""
343+
filepath = Path(filepath)
344+
if hash_val and not check_hash(filepath, hash_val, hash_type):
345+
raise HashCheckError(
346+
f"{hash_type} hash check of compressed file failed: " f"{filepath=}, expected {hash_type}={hash_val}."
347+
)
332348
if has_base:
333349
# the extracted files will be in this folder
334350
cache_dir = Path(output_dir, _basename(filepath).split(".")[0])
@@ -337,11 +353,6 @@ def extractall(
337353
if cache_dir.exists() and next(cache_dir.iterdir(), None) is not None:
338354
logger.info(f"Non-empty folder exists in {cache_dir}, skipped extracting.")
339355
return
340-
filepath = Path(filepath)
341-
if hash_val and not check_hash(filepath, hash_val, hash_type):
342-
raise RuntimeError(
343-
f"{hash_type} check of compressed file failed: " f"filepath={filepath}, expected {hash_type}={hash_val}."
344-
)
345356
logger.info(f"Writing into directory: {output_dir}.")
346357
_file_type = file_type.lower().strip()
347358
if filepath.name.endswith("zip") or _file_type == "zip":

monai/bundle/utils.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from monai.utils import optional_import
2222

2323
yaml, _ = optional_import("yaml")
24-
2524
__all__ = [
2625
"ID_REF_KEY",
2726
"ID_SEP_KEY",
@@ -39,7 +38,6 @@
3938
MERGE_KEY = "+" # prefix indicating merge instead of override in case of multiple configs.
4039

4140
_conf_values = get_config_values()
42-
4341
DEFAULT_METADATA = {
4442
"version": "0.0.1",
4543
"changelog": {"0.0.1": "Initial version"},
@@ -211,20 +209,15 @@ def load_bundle_config(bundle_path: str, *config_names: str, **load_kw_args: Any
211209
name, _ = os.path.splitext(os.path.basename(bundle_path))
212210

213211
archive = zipfile.ZipFile(bundle_path, "r")
214-
215212
all_files = archive.namelist()
216-
217213
zip_meta_name = f"{name}/configs/metadata.json"
218-
219214
if zip_meta_name in all_files:
220215
prefix = f"{name}/configs/" # zipped directory location for files
221216
else:
222217
zip_meta_name = f"{name}/extra/metadata.json"
223218
prefix = f"{name}/extra/" # Torchscript location for files
224-
225219
meta_json = json.loads(archive.read(zip_meta_name))
226220
parser.read_meta(f=meta_json)
227-
228221
for cname in config_names:
229222
full_cname = prefix + cname
230223
if full_cname not in all_files:

monai/data/box_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,8 +1035,8 @@ def spatial_crop_boxes(
10351035
# convert to float32 since torch.clamp_ does not support float16
10361036
boxes_t = boxes_t.to(dtype=COMPUTE_DTYPE)
10371037

1038-
roi_start_t = convert_to_dst_type(src=roi_start, dst=boxes_t, wrap_sequence=True)[0].to(torch.int16)
1039-
roi_end_t = convert_to_dst_type(src=roi_end, dst=boxes_t, wrap_sequence=True)[0].to(torch.int16)
1038+
roi_start_t = convert_to_dst_type(src=roi_start, dst=boxes_t, wrap_sequence=True)[0]
1039+
roi_end_t = convert_to_dst_type(src=roi_end, dst=boxes_t, wrap_sequence=True)[0]
10401040
roi_end_t = torch.maximum(roi_end_t, roi_start_t)
10411041

10421042
# makes sure the bounding boxes are within the patch

monai/transforms/io/array.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,7 @@ def __init__(
210210
try:
211211
self.register(the_reader(*args, **kwargs))
212212
except OptionalImportError:
213-
warnings.warn(
214-
f"required package for reader {_r} is not installed, or the version doesn't match requirement."
215-
)
213+
raise
216214
except TypeError: # the reader doesn't have the corresponding args/kwargs
217215
warnings.warn(f"{_r} is not supported with the given parameters {args} {kwargs}.")
218216
self.register(the_reader())

tests/apps/test_download_and_extract.py

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,49 +16,68 @@
1616
import unittest
1717
import zipfile
1818
from pathlib import Path
19-
from urllib.error import ContentTooShortError, HTTPError
2019

2120
from parameterized import parameterized
2221

2322
from monai.apps import download_and_extract, download_url, extractall
23+
from monai.apps.utils import HashCheckError
2424
from tests.test_utils import SkipIfNoModule, skip_if_downloading_fails, skip_if_quick, testing_data_config
2525

2626

2727
@SkipIfNoModule("requests")
2828
class TestDownloadAndExtract(unittest.TestCase):
29+
def setUp(self):
30+
self.testing_dir = Path(__file__).parents[1] / "testing_data"
31+
self.config = testing_data_config("images", "mednist")
32+
self.url = self.config["url"]
33+
self.hash_val = self.config["hash_val"]
34+
self.hash_type = self.config["hash_type"]
35+
2936
@skip_if_quick
30-
def test_actions(self):
31-
testing_dir = Path(__file__).parents[1] / "testing_data"
32-
config_dict = testing_data_config("images", "mednist")
33-
url = config_dict["url"]
34-
filepath = Path(testing_dir) / "MedNIST.tar.gz"
35-
output_dir = Path(testing_dir)
36-
hash_val, hash_type = config_dict["hash_val"], config_dict["hash_type"]
37+
def test_download_and_extract_success(self):
38+
"""End-to-end: download and extract should succeed with correct hash."""
39+
filepath = self.testing_dir / "MedNIST.tar.gz"
40+
output_dir = self.testing_dir
41+
3742
with skip_if_downloading_fails():
38-
download_and_extract(url, filepath, output_dir, hash_val=hash_val, hash_type=hash_type)
39-
download_and_extract(url, filepath, output_dir, hash_val=hash_val, hash_type=hash_type)
43+
download_and_extract(self.url, filepath, output_dir, hash_val=self.hash_val, hash_type=self.hash_type)
4044

41-
wrong_md5 = "0"
42-
with self.assertLogs(logger="monai.apps", level="ERROR"):
43-
try:
44-
download_url(url, filepath, wrong_md5)
45-
except (ContentTooShortError, HTTPError, RuntimeError) as e:
46-
if isinstance(e, RuntimeError):
47-
# FIXME: skip MD5 check as current downloading method may fail
48-
self.assertTrue(str(e).startswith("md5 check"))
49-
return # skipping this test due the network connection errors
50-
51-
try:
52-
extractall(filepath, output_dir, wrong_md5)
53-
except RuntimeError as e:
54-
self.assertTrue(str(e).startswith("md5 check"))
45+
self.assertTrue(filepath.exists(), "Downloaded file does not exist")
46+
self.assertTrue(any(output_dir.iterdir()), "Extraction output is empty")
47+
48+
@skip_if_quick
49+
def test_download_url_hash_mismatch(self):
50+
"""download_url should raise HashCheckError on hash mismatch."""
51+
filepath = self.testing_dir / "MedNIST.tar.gz"
52+
53+
with skip_if_downloading_fails():
54+
# First ensure file is downloaded correctly
55+
download_url(self.url, filepath, hash_val=self.hash_val, hash_type=self.hash_type)
56+
57+
# Now test incorrect hash
58+
with self.assertRaises(HashCheckError):
59+
download_url(self.url, filepath, hash_val="0" * len(self.hash_val), hash_type=self.hash_type)
5560

5661
@skip_if_quick
57-
@parameterized.expand((("icon", "tar"), ("favicon", "zip")))
58-
def test_default(self, key, file_type):
62+
def test_extractall_hash_mismatch(self):
63+
"""extractall should raise HashCheckError when hash is incorrect."""
64+
filepath = self.testing_dir / "MedNIST.tar.gz"
65+
output_dir = self.testing_dir
66+
67+
with skip_if_downloading_fails():
68+
download_url(self.url, filepath, hash_val=self.hash_val, hash_type=self.hash_type)
69+
70+
with self.assertRaises(HashCheckError):
71+
extractall(filepath, output_dir, hash_val="0" * len(self.hash_val), hash_type=self.hash_type)
72+
73+
@skip_if_quick
74+
@parameterized.expand([("icon", "tar"), ("favicon", "zip")])
75+
def test_download_and_extract_various_formats(self, key, file_type):
76+
"""Verify different archive formats download and extract correctly."""
5977
with tempfile.TemporaryDirectory() as tmp_dir:
78+
img_spec = testing_data_config("images", key)
79+
6080
with skip_if_downloading_fails():
61-
img_spec = testing_data_config("images", key)
6281
download_and_extract(
6382
img_spec["url"],
6483
output_dir=tmp_dir,
@@ -67,6 +86,8 @@ def test_default(self, key, file_type):
6786
file_type=file_type,
6887
)
6988

89+
self.assertTrue(any(Path(tmp_dir).iterdir()), f"Extraction failed for format: {file_type}")
90+
7091

7192
class TestPathTraversalProtection(unittest.TestCase):
7293
"""Test cases for path traversal attack protection in extractall function."""

tests/data/test_box_utils.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
convert_box_mode,
3636
convert_box_to_standard_mode,
3737
non_max_suppression,
38+
spatial_crop_boxes,
3839
)
3940
from monai.utils.type_conversion import convert_data_type
4041
from tests.test_utils import TEST_NDARRAYS, assert_allclose
@@ -269,6 +270,20 @@ def test_integer_truncation_bug(self):
269270
self.assertTrue(np.issubdtype(iou.dtype, np.floating))
270271
self.assertGreater(iou[0, 0], 0.0, "IoU should not be truncated to 0")
271272

273+
def test_large_coordinates_are_not_dropped(self):
274+
"""Verify large-coordinate boxes are preserved by cropping and clipping."""
275+
boxes = torch.tensor([[41000.0, 5000.0, 45000.0, 15000.0]], dtype=torch.float32)
276+
277+
cropped_boxes, keep = spatial_crop_boxes(
278+
boxes=boxes, roi_start=[40000, 0], roi_end=[50000, 20000], remove_empty=True
279+
)
280+
assert_allclose(keep, torch.tensor([True]))
281+
assert_allclose(cropped_boxes, torch.tensor([[1000.0, 5000.0, 5000.0, 15000.0]]))
282+
283+
clipped_boxes, keep = clip_boxes_to_image(boxes=boxes, spatial_size=[50000, 50000], remove_empty=True)
284+
assert_allclose(keep, torch.tensor([True]))
285+
assert_allclose(clipped_boxes, boxes)
286+
272287

273288
class TestBatchedNms(unittest.TestCase):
274289
@parameterized.expand(TEST_NDARRAYS)

tests/data/test_init_reader.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from monai.data import ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader, PydicomReader
2121
from monai.transforms import LoadImage, LoadImaged
22-
from monai.utils import MetaKeys
22+
from monai.utils import MetaKeys, OptionalImportError, optional_import
2323
from tests.test_utils import SkipIfNoModule
2424

2525

@@ -30,9 +30,27 @@ def test_load_image(self):
3030
self.assertIsInstance(instance1, LoadImage)
3131
self.assertIsInstance(instance2, LoadImage)
3232

33-
for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", "PydicomReader", None]:
34-
inst = LoadImaged("image", reader=r)
35-
self.assertIsInstance(inst, LoadImaged)
33+
optional_readers = {
34+
"NibabelReader": "nibabel",
35+
"PILReader": "PIL",
36+
"ITKReader": "itk",
37+
"NrrdReader": "nrrd",
38+
"PydicomReader": "pydicom",
39+
}
40+
for r, module in optional_readers.items():
41+
with self.subTest(reader=r):
42+
_, has_module = optional_import(module, allow_namespace_pkg=module in ("itk", "nrrd"))
43+
if has_module:
44+
inst = LoadImaged("image", reader=r)
45+
self.assertIsInstance(inst, LoadImaged)
46+
else:
47+
with self.assertRaises(OptionalImportError):
48+
LoadImaged("image", reader=r)
49+
50+
inst = LoadImaged("image", reader="NumpyReader")
51+
self.assertIsInstance(inst, LoadImaged)
52+
inst = LoadImaged("image", reader=None)
53+
self.assertIsInstance(inst, LoadImaged)
3654

3755
@SkipIfNoModule("nibabel")
3856
@SkipIfNoModule("cupy")

tests/test_timedcall_dist.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from tests.test_utils import TimedCall
2020

2121

22-
@TimedCall(seconds=20 if sys.platform == "linux" else 60, force_quit=False)
22+
@TimedCall(seconds=20 if sys.platform == "linux" else 60, force_quit=True)
2323
def case_1_seconds(arg=None):
2424
time.sleep(1)
2525
return "good" if not arg else arg

0 commit comments

Comments
 (0)