From 5b518981bf3cf15f9c3d6d91ae3a267106847d3a Mon Sep 17 00:00:00 2001 From: Erol444 Date: Mon, 29 Jun 2026 10:29:23 +0200 Subject: [PATCH 1/8] feat: add image URL loader --- docs/utils/image.md | 6 +++ src/supervision/__init__.py | 2 + src/supervision/utils/image.py | 52 ++++++++++++++++++++++ src/supervision/utils/internal.py | 38 ++++++++++++++++ tests/utils/test_image.py | 74 +++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+) diff --git a/docs/utils/image.md b/docs/utils/image.md index 9d1c1895ca..0b61abb3cd 100644 --- a/docs/utils/image.md +++ b/docs/utils/image.md @@ -11,6 +11,12 @@ status: new :::supervision.utils.image.crop_image +
+

load_image_from_url

+
+ +:::supervision.utils.image.load_image_from_url +

scale_image

diff --git a/src/supervision/__init__.py b/src/supervision/__init__.py index a398dc7da2..ddb931f839 100644 --- a/src/supervision/__init__.py +++ b/src/supervision/__init__.py @@ -144,6 +144,7 @@ get_image_resolution_wh, grayscale_image, letterbox_image, + load_image_from_url, overlay_image, resize_image, scale_image, @@ -257,6 +258,7 @@ "is_valid_hex", "letterbox_image", "list_files_with_extensions", + "load_image_from_url", "mask_iou_batch", "mask_non_max_merge", "mask_non_max_suppression", diff --git a/src/supervision/utils/image.py b/src/supervision/utils/image.py index e5cc5cfda3..fe436a3bd5 100644 --- a/src/supervision/utils/image.py +++ b/src/supervision/utils/image.py @@ -11,6 +11,7 @@ import cv2 import numpy as np import numpy.typing as npt +import requests from deprecate import TargetMode, deprecated from PIL import Image @@ -23,6 +24,7 @@ ensure_cv2_image_for_standalone_function, images_to_cv2, ) +from supervision.utils.internal import prepare_url from supervision.utils.iterables import create_batches, fill RelativePosition = Literal["top", "bottom"] @@ -30,6 +32,56 @@ MAX_COLUMNS_FOR_SINGLE_ROW_GRID = 3 +def load_image_from_url( + value: str, + cv_imread_flags: int = cv2.IMREAD_COLOR, + timeout: float = 30.0, +) -> npt.NDArray[np.uint8]: + """ + Load an image from a URL as an OpenCV image. + + Args: + value: HTTP(S) URL of the image. + cv_imread_flags: OpenCV image read flag passed to `cv2.imdecode`. + Defaults to `cv2.IMREAD_COLOR`. + timeout: Request timeout in seconds. Defaults to `30.0`. + + Returns: + Image as a NumPy array in the format selected by `cv_imread_flags`. + + Raises: + ValueError: If the URL is invalid or the downloaded bytes cannot be decoded. + requests.RequestException: If the request fails or returns an error status. + + Examples: + ```pycon + >>> import supervision as sv + >>> image = sv.load_image_from_url( + ... "https://media.roboflow.com/notebooks/examples/dog-9.jpeg" + ... ) # doctest: +SKIP + >>> image.shape # doctest: +SKIP + (576, 768, 3) + + ``` + """ + prepared_url = prepare_url(value=value) + response = requests.get(prepared_url, timeout=timeout) + image: npt.NDArray[np.uint8] | None = None + try: + response.raise_for_status() + + image = cv2.imdecode( + np.frombuffer(response.content, dtype=np.uint8), + cv_imread_flags, + ) + finally: + response.close() + if image is None: + raise ValueError("Data pointed by URL could not be decoded into image.") + + return image + + @ensure_cv2_image_for_standalone_function def crop_image( image: ImageType, diff --git a/src/supervision/utils/internal.py b/src/supervision/utils/internal.py index 2ceddb476b..1bd4dc9b79 100644 --- a/src/supervision/utils/internal.py +++ b/src/supervision/utils/internal.py @@ -3,10 +3,13 @@ import functools import inspect import os +import urllib.parse import warnings from collections.abc import Callable from typing import Any, Generic, TypeVar +import requests + class SupervisionWarnings(Warning): """Supervision warning category. @@ -50,6 +53,41 @@ def warn_deprecated(message: str) -> None: warnings.warn(message, category=SupervisionWarnings, stacklevel=2) +def prepare_url(value: str) -> str: + """ + Validate and normalize an HTTP(S) URL. + + Args: + value: URL to validate. + + Returns: + Prepared URL string. + + Raises: + ValueError: If the URL is invalid or uses an unsupported scheme. + """ + try: + original_parsed_url = urllib.parse.urlparse(value) + if "\\" in original_parsed_url.netloc: + raise ValueError("URL authority contains a backslash") + + prepared_request = requests.Request(method="GET", url=value).prepare() + prepared_url = prepared_request.url + if prepared_url is None: + raise ValueError("Prepared URL is empty") + + parsed_url = urllib.parse.urlparse(prepared_url) + except (requests.RequestException, ValueError) as error: + raise ValueError("Provided URL is invalid") from error + + if parsed_url.scheme not in {"http", "https"}: + raise ValueError("Only HTTP(S) URLs are supported") + if parsed_url.hostname is None: + raise ValueError("Provided URL is invalid") + + return prepared_url + + def deprecated_parameter( old_parameter: str, new_parameter: str, diff --git a/tests/utils/test_image.py b/tests/utils/test_image.py index 1326081efe..06ef7472d1 100644 --- a/tests/utils/test_image.py +++ b/tests/utils/test_image.py @@ -1,17 +1,91 @@ +from unittest.mock import Mock, patch + +import cv2 import numpy as np import pytest +import requests from PIL import Image, ImageChops from supervision.utils.image import ( crop_image, get_image_resolution_wh, letterbox_image, + load_image_from_url, resize_image, scale_image, tint_image, ) +class TestLoadImageFromUrl: + def test_returns_decoded_image(self) -> None: + """Valid image URL returns an OpenCV image.""" + # given + image = np.full((10, 20, 3), 127, dtype=np.uint8) + encoded = cv2.imencode(".jpg", image)[1] + response = Mock() + response.content = encoded.tobytes() + response.raise_for_status.return_value = None + + # when + with patch( + "supervision.utils.image.requests.get", return_value=response + ) as get: + result = load_image_from_url( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg" + ) + + # then + get.assert_called_once_with( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + timeout=30.0, + ) + assert result.shape == image.shape + assert result.dtype == np.uint8 + response.close.assert_called_once() + + def test_raises_when_bytes_are_not_image(self) -> None: + """Invalid image bytes raise ValueError.""" + # given + response = Mock() + response.content = b"not an image" + response.raise_for_status.return_value = None + + # when / then + with ( + patch("supervision.utils.image.requests.get", return_value=response), + pytest.raises(ValueError, match="could not be decoded into image"), + ): + load_image_from_url( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg" + ) + response.close.assert_called_once() + + def test_raises_for_request_error(self) -> None: + """Request failures are propagated.""" + # given + request_error = requests.RequestException("boom") + + # when / then + with ( + patch("supervision.utils.image.requests.get", side_effect=request_error), + pytest.raises(requests.RequestException, match="boom"), + ): + load_image_from_url( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg" + ) + + def test_rejects_non_http_url(self) -> None: + """Non-HTTP URLs are rejected before making a request.""" + # given + with patch("supervision.utils.image.requests.get") as get: + # when / then + with pytest.raises(ValueError, match="HTTP"): + load_image_from_url("file:///tmp/image.jpg") + + get.assert_not_called() + + def test_resize_image_for_opencv_image() -> None: # given image = np.zeros((480, 640, 3), dtype=np.uint8) From f2bd8a306bff5e68b9ad609aa602a6fbf1946248 Mon Sep 17 00:00:00 2001 From: Erol444 Date: Mon, 29 Jun 2026 10:45:14 +0200 Subject: [PATCH 2/8] docs: update image URL loader example --- src/supervision/utils/image.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/supervision/utils/image.py b/src/supervision/utils/image.py index 1ad789522d..8baa3b25c7 100644 --- a/src/supervision/utils/image.py +++ b/src/supervision/utils/image.py @@ -57,13 +57,13 @@ def load_image_from_url( requests.RequestException: If the request fails or returns an error status. Examples: - ```pycon - >>> import supervision as sv - >>> image = sv.load_image_from_url( - ... "https://media.roboflow.com/notebooks/examples/dog-9.jpeg" - ... ) # doctest: +SKIP - >>> image.shape # doctest: +SKIP - (576, 768, 3) + ```python + import supervision as sv + + image = sv.load_image_from_url( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg" + ) + image.shape ``` """ From 51e6ffe3be942c79083fa9def60d5861be6166b9 Mon Sep 17 00:00:00 2001 From: Erol444 Date: Mon, 29 Jun 2026 13:39:12 +0200 Subject: [PATCH 3/8] feat: cache image URL loads --- src/supervision/utils/image.py | 75 ++++++++++++++++++++--- tests/utils/test_image.py | 105 +++++++++++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 13 deletions(-) diff --git a/src/supervision/utils/image.py b/src/supervision/utils/image.py index 8baa3b25c7..f18933d82d 100644 --- a/src/supervision/utils/image.py +++ b/src/supervision/utils/image.py @@ -4,8 +4,12 @@ import math import os import shutil +import tempfile +import urllib.parse from collections.abc import Callable from functools import partial +from hashlib import sha256 +from pathlib import Path from typing import Any, Literal, cast import cv2 @@ -34,11 +38,54 @@ MAX_COLUMNS_FOR_SINGLE_ROW_GRID = 3 +DEFAULT_IMAGE_URL_CACHE_DIR = Path(tempfile.gettempdir()) / "supervision" / "image-url" + + +def _get_image_url_cache_path(value: str, cache_dir: str | Path | None) -> Path: + cache_root = ( + DEFAULT_IMAGE_URL_CACHE_DIR + if cache_dir is None + else Path(cache_dir).expanduser().resolve() + ) + url_path = urllib.parse.urlparse(value).path + suffix = Path(url_path).suffix or ".image" + url_hash = sha256(value.encode("utf-8")).hexdigest() + return cache_root / f"{url_hash}{suffix}" + + +def _decode_image_from_bytes( + value: bytes, + cv_imread_flags: int, +) -> npt.NDArray[np.uint8]: + image = cv2.imdecode( + np.frombuffer(value, dtype=np.uint8), + cv_imread_flags, + ) + if image is None: + raise ValueError("Data pointed by URL could not be decoded into image.") + + return image + + +def _write_image_url_cache(cache_path: Path, value: bytes) -> None: + cache_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + delete=False, + dir=cache_path.parent, + suffix=cache_path.suffix, + ) as file: + temp_path = Path(file.name) + file.write(value) + + os.replace(temp_path, cache_path) + def load_image_from_url( value: str, cv_imread_flags: int = cv2.IMREAD_COLOR, timeout: float = 30.0, + cache_dir: str | Path | None = None, + force_reload: bool = False, ) -> npt.NDArray[np.uint8]: """ Load an image from a URL as an OpenCV image. @@ -48,6 +95,10 @@ def load_image_from_url( cv_imread_flags: OpenCV image read flag passed to `cv2.imdecode`. Defaults to `cv2.IMREAD_COLOR`. timeout: Request timeout in seconds. Defaults to `30.0`. + cache_dir: Directory where downloaded image bytes are cached. If `None`, + uses the system temporary directory. Defaults to `None`. + force_reload: If `True`, re-download the image and refresh the cache. + Defaults to `False`. Returns: Image as a NumPy array in the format selected by `cv_imread_flags`. @@ -68,19 +119,29 @@ def load_image_from_url( ``` """ prepared_url = prepare_url(value=value) + cache_path = _get_image_url_cache_path( + value=prepared_url, + cache_dir=cache_dir, + ) + if cache_path.exists() and not force_reload: + try: + return _decode_image_from_bytes( + value=cache_path.read_bytes(), + cv_imread_flags=cv_imread_flags, + ) + except ValueError: + cache_path.unlink(missing_ok=True) + response = requests.get(prepared_url, timeout=timeout) - image: npt.NDArray[np.uint8] | None = None try: response.raise_for_status() - - image = cv2.imdecode( - np.frombuffer(response.content, dtype=np.uint8), - cv_imread_flags, + image = _decode_image_from_bytes( + value=response.content, + cv_imread_flags=cv_imread_flags, ) + _write_image_url_cache(cache_path=cache_path, value=response.content) finally: response.close() - if image is None: - raise ValueError("Data pointed by URL could not be decoded into image.") return image diff --git a/tests/utils/test_image.py b/tests/utils/test_image.py index ea05a94f49..d28c2b13d8 100644 --- a/tests/utils/test_image.py +++ b/tests/utils/test_image.py @@ -20,7 +20,7 @@ class TestLoadImageFromUrl: - def test_returns_decoded_image(self) -> None: + def test_returns_decoded_image(self, tmp_path) -> None: """Valid image URL returns an OpenCV image.""" # given image = np.full((10, 20, 3), 127, dtype=np.uint8) @@ -34,7 +34,8 @@ def test_returns_decoded_image(self) -> None: "supervision.utils.image.requests.get", return_value=response ) as get: result = load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg" + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + cache_dir=tmp_path, ) # then @@ -46,7 +47,97 @@ def test_returns_decoded_image(self) -> None: assert result.dtype == np.uint8 response.close.assert_called_once() - def test_raises_when_bytes_are_not_image(self) -> None: + def test_uses_cached_image_on_repeated_calls(self, tmp_path) -> None: + """Repeated image URL loads use the local cache.""" + # given + image = np.full((10, 20, 3), 127, dtype=np.uint8) + encoded = cv2.imencode(".jpg", image)[1] + response = Mock() + response.content = encoded.tobytes() + response.raise_for_status.return_value = None + + # when + with patch( + "supervision.utils.image.requests.get", return_value=response + ) as get: + first_result = load_image_from_url( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + cache_dir=tmp_path, + ) + second_result = load_image_from_url( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + cache_dir=tmp_path, + ) + + # then + get.assert_called_once() + assert first_result.shape == image.shape + assert second_result.shape == image.shape + + def test_force_reload_refreshes_cached_image(self, tmp_path) -> None: + """Force reload bypasses the cached image and refreshes it.""" + # given + first_image = np.zeros((10, 20, 3), dtype=np.uint8) + second_image = np.full((12, 22, 3), 127, dtype=np.uint8) + first_response = Mock() + first_response.content = cv2.imencode(".jpg", first_image)[1].tobytes() + first_response.raise_for_status.return_value = None + second_response = Mock() + second_response.content = cv2.imencode(".jpg", second_image)[1].tobytes() + second_response.raise_for_status.return_value = None + + # when + with patch( + "supervision.utils.image.requests.get", + side_effect=[first_response, second_response], + ) as get: + cached_result = load_image_from_url( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + cache_dir=tmp_path, + ) + refreshed_result = load_image_from_url( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + cache_dir=tmp_path, + force_reload=True, + ) + + # then + assert get.call_count == 2 + assert cached_result.shape == first_image.shape + assert refreshed_result.shape == second_image.shape + + def test_redownloads_when_cached_image_is_invalid(self, tmp_path) -> None: + """Invalid cached image bytes are discarded and downloaded again.""" + # given + image = np.full((10, 20, 3), 127, dtype=np.uint8) + first_response = Mock() + first_response.content = cv2.imencode(".jpg", image)[1].tobytes() + first_response.raise_for_status.return_value = None + second_response = Mock() + second_response.content = cv2.imencode(".jpg", image)[1].tobytes() + second_response.raise_for_status.return_value = None + + # when + with patch( + "supervision.utils.image.requests.get", + side_effect=[first_response, second_response], + ) as get: + load_image_from_url( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + cache_dir=tmp_path, + ) + for cache_file in tmp_path.iterdir(): + cache_file.write_bytes(b"not an image") + result = load_image_from_url( + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + cache_dir=tmp_path, + ) + + # then + assert get.call_count == 2 + assert result.shape == image.shape + + def test_raises_when_bytes_are_not_image(self, tmp_path) -> None: """Invalid image bytes raise ValueError.""" # given response = Mock() @@ -59,11 +150,12 @@ def test_raises_when_bytes_are_not_image(self) -> None: pytest.raises(ValueError, match="could not be decoded into image"), ): load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg" + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + cache_dir=tmp_path, ) response.close.assert_called_once() - def test_raises_for_request_error(self) -> None: + def test_raises_for_request_error(self, tmp_path) -> None: """Request failures are propagated.""" # given request_error = requests.RequestException("boom") @@ -74,7 +166,8 @@ def test_raises_for_request_error(self) -> None: pytest.raises(requests.RequestException, match="boom"), ): load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg" + "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + cache_dir=tmp_path, ) def test_rejects_non_http_url(self) -> None: From e8ebe9efd39c0a8c0e23cf2ea3f1b6450152f85f Mon Sep 17 00:00:00 2001 From: Erol444 Date: Mon, 29 Jun 2026 13:48:30 +0200 Subject: [PATCH 4/8] docs: use quickstart dog image URL --- src/supervision/utils/image.py | 2 +- tests/utils/test_image.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/supervision/utils/image.py b/src/supervision/utils/image.py index f18933d82d..4bc741990e 100644 --- a/src/supervision/utils/image.py +++ b/src/supervision/utils/image.py @@ -112,7 +112,7 @@ def load_image_from_url( import supervision as sv image = sv.load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg" + "https://media.roboflow.com/quickstart/dog.jpeg" ) image.shape diff --git a/tests/utils/test_image.py b/tests/utils/test_image.py index d28c2b13d8..fbcc0d2b24 100644 --- a/tests/utils/test_image.py +++ b/tests/utils/test_image.py @@ -34,13 +34,13 @@ def test_returns_decoded_image(self, tmp_path) -> None: "supervision.utils.image.requests.get", return_value=response ) as get: result = load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, ) # then get.assert_called_once_with( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + "https://media.roboflow.com/quickstart/dog.jpeg", timeout=30.0, ) assert result.shape == image.shape @@ -61,11 +61,11 @@ def test_uses_cached_image_on_repeated_calls(self, tmp_path) -> None: "supervision.utils.image.requests.get", return_value=response ) as get: first_result = load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, ) second_result = load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, ) @@ -92,11 +92,11 @@ def test_force_reload_refreshes_cached_image(self, tmp_path) -> None: side_effect=[first_response, second_response], ) as get: cached_result = load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, ) refreshed_result = load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, force_reload=True, ) @@ -123,13 +123,13 @@ def test_redownloads_when_cached_image_is_invalid(self, tmp_path) -> None: side_effect=[first_response, second_response], ) as get: load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, ) for cache_file in tmp_path.iterdir(): cache_file.write_bytes(b"not an image") result = load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, ) @@ -150,7 +150,7 @@ def test_raises_when_bytes_are_not_image(self, tmp_path) -> None: pytest.raises(ValueError, match="could not be decoded into image"), ): load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, ) response.close.assert_called_once() @@ -166,7 +166,7 @@ def test_raises_for_request_error(self, tmp_path) -> None: pytest.raises(requests.RequestException, match="boom"), ): load_image_from_url( - "https://media.roboflow.com/notebooks/examples/dog-9.jpeg", + "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, ) From 4887d76998716d35dd9c0b74561cfafe2abd2103 Mon Sep 17 00:00:00 2001 From: Erol444 Date: Mon, 29 Jun 2026 13:51:59 +0200 Subject: [PATCH 5/8] feat: add image URL cache toggle --- src/supervision/utils/image.py | 8 ++++++-- tests/utils/test_image.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/supervision/utils/image.py b/src/supervision/utils/image.py index 4bc741990e..70c5f98722 100644 --- a/src/supervision/utils/image.py +++ b/src/supervision/utils/image.py @@ -84,6 +84,7 @@ def load_image_from_url( value: str, cv_imread_flags: int = cv2.IMREAD_COLOR, timeout: float = 30.0, + use_cache: bool = True, cache_dir: str | Path | None = None, force_reload: bool = False, ) -> npt.NDArray[np.uint8]: @@ -95,6 +96,8 @@ def load_image_from_url( cv_imread_flags: OpenCV image read flag passed to `cv2.imdecode`. Defaults to `cv2.IMREAD_COLOR`. timeout: Request timeout in seconds. Defaults to `30.0`. + use_cache: If `True`, cache downloaded image bytes locally and reuse them + on repeated calls. Defaults to `True`. cache_dir: Directory where downloaded image bytes are cached. If `None`, uses the system temporary directory. Defaults to `None`. force_reload: If `True`, re-download the image and refresh the cache. @@ -123,7 +126,7 @@ def load_image_from_url( value=prepared_url, cache_dir=cache_dir, ) - if cache_path.exists() and not force_reload: + if use_cache and cache_path.exists() and not force_reload: try: return _decode_image_from_bytes( value=cache_path.read_bytes(), @@ -139,7 +142,8 @@ def load_image_from_url( value=response.content, cv_imread_flags=cv_imread_flags, ) - _write_image_url_cache(cache_path=cache_path, value=response.content) + if use_cache: + _write_image_url_cache(cache_path=cache_path, value=response.content) finally: response.close() diff --git a/tests/utils/test_image.py b/tests/utils/test_image.py index fbcc0d2b24..619f1a4eae 100644 --- a/tests/utils/test_image.py +++ b/tests/utils/test_image.py @@ -74,6 +74,40 @@ def test_uses_cached_image_on_repeated_calls(self, tmp_path) -> None: assert first_result.shape == image.shape assert second_result.shape == image.shape + def test_downloads_each_time_when_cache_is_disabled(self, tmp_path) -> None: + """Disabled cache skips cache reads and writes.""" + # given + image = np.full((10, 20, 3), 127, dtype=np.uint8) + encoded = cv2.imencode(".jpg", image)[1] + first_response = Mock() + first_response.content = encoded.tobytes() + first_response.raise_for_status.return_value = None + second_response = Mock() + second_response.content = encoded.tobytes() + second_response.raise_for_status.return_value = None + + # when + with patch( + "supervision.utils.image.requests.get", + side_effect=[first_response, second_response], + ) as get: + first_result = load_image_from_url( + "https://media.roboflow.com/quickstart/dog.jpeg", + cache_dir=tmp_path, + use_cache=False, + ) + second_result = load_image_from_url( + "https://media.roboflow.com/quickstart/dog.jpeg", + cache_dir=tmp_path, + use_cache=False, + ) + + # then + assert get.call_count == 2 + assert first_result.shape == image.shape + assert second_result.shape == image.shape + assert list(tmp_path.iterdir()) == [] + def test_force_reload_refreshes_cached_image(self, tmp_path) -> None: """Force reload bypasses the cached image and refreshes it.""" # given From 9c617e9823ef8daaf15f71938fb0e572dcafaf6a Mon Sep 17 00:00:00 2001 From: Erol444 Date: Mon, 29 Jun 2026 16:55:00 +0200 Subject: [PATCH 6/8] perf: lazy import requests in URL validation --- src/supervision/utils/internal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/supervision/utils/internal.py b/src/supervision/utils/internal.py index 0542c29661..de707c5980 100644 --- a/src/supervision/utils/internal.py +++ b/src/supervision/utils/internal.py @@ -6,8 +6,6 @@ from collections.abc import Callable from typing import Any, Generic, TypeVar -import requests - class SupervisionWarnings(Warning): """Supervision warning category. @@ -64,6 +62,8 @@ def prepare_url(value: str) -> str: Raises: ValueError: If the URL is invalid or uses an unsupported scheme. """ + import requests + try: original_parsed_url = urllib.parse.urlparse(value) if "\\" in original_parsed_url.netloc: From 08963934a4535ab8e084887dcd1ea7a70008e7b8 Mon Sep 17 00:00:00 2001 From: Erol444 Date: Thu, 9 Jul 2026 10:57:43 +0200 Subject: [PATCH 7/8] refactor: share download primitive between assets and image URL loader Address review feedback from @SkalskiP: - extract _download_to_file into utils/file.py; both download_assets and load_image_from_url now download atomically via a temp file and os.replace - move prepare_url from utils/internal.py to utils/file.py, next to the download primitive - use md5 (usedforsecurity=False) for cache file names, matching the hashing used by the assets flow Co-Authored-By: Claude Fable 5 --- src/supervision/assets/downloader.py | 23 ++----- src/supervision/utils/file.py | 86 ++++++++++++++++++++++++ src/supervision/utils/image.py | 47 ++++++------- src/supervision/utils/internal.py | 38 ----------- tests/assets/test_downloader.py | 99 ++++++---------------------- tests/utils/test_file.py | 81 ++++++++++++++++++++++- tests/utils/test_image.py | 22 +++---- 7 files changed, 221 insertions(+), 175 deletions(-) diff --git a/src/supervision/assets/downloader.py b/src/supervision/assets/downloader.py index 19cd74422d..3689882314 100644 --- a/src/supervision/assets/downloader.py +++ b/src/supervision/assets/downloader.py @@ -1,12 +1,9 @@ import os from hashlib import md5 from pathlib import Path -from shutil import copyfileobj - -from requests import get -from tqdm.auto import tqdm from supervision.assets.list import MEDIA_ASSETS, Assets +from supervision.utils.file import _download_to_file from supervision.utils.logger import _get_logger logger = _get_logger(__name__) @@ -63,20 +60,12 @@ def download_assets(asset_name: Assets | str) -> str: if filename in MEDIA_ASSETS: if not Path(filename).exists(): logger.info("Downloading %s assets", filename) - response = get( - MEDIA_ASSETS[filename][0], stream=True, allow_redirects=True, timeout=30 + _download_to_file( + MEDIA_ASSETS[filename][0], + Path(filename).expanduser().resolve(), + timeout=30.0, + stream=True, ) - response.raise_for_status() - - file_size = int(response.headers.get("Content-Length", 0)) - folder_path = Path(filename).expanduser().resolve() - folder_path.parent.mkdir(parents=True, exist_ok=True) - - with tqdm.wrapattr( - response.raw, "read", total=file_size, desc="", colour="#a351fb" - ) as raw_resp: - with folder_path.open("wb") as file: - copyfileobj(raw_resp, file) else: if not is_md5_hash_matching(filename, MEDIA_ASSETS[filename][1]): logger.warning("File corrupted. Re-downloading...") diff --git a/src/supervision/utils/file.py b/src/supervision/utils/file.py index 00466e5308..d3602a13db 100644 --- a/src/supervision/utils/file.py +++ b/src/supervision/utils/file.py @@ -1,11 +1,97 @@ import json +import os +import tempfile +import urllib.parse from pathlib import Path +from shutil import copyfileobj from typing import Any import numpy as np +import requests import yaml +def prepare_url(value: str) -> str: + """ + Validate and normalize an HTTP(S) URL. + + Args: + value: URL to validate. + + Returns: + Prepared URL string. + + Raises: + ValueError: If the URL is invalid or uses an unsupported scheme. + """ + try: + original_parsed_url = urllib.parse.urlparse(value) + if "\\" in original_parsed_url.netloc: + raise ValueError("URL authority contains a backslash") + + prepared_request = requests.Request(method="GET", url=value).prepare() + prepared_url = prepared_request.url + if prepared_url is None: + raise ValueError("Prepared URL is empty") + + parsed_url = urllib.parse.urlparse(prepared_url) + except (requests.RequestException, ValueError) as error: + raise ValueError("Provided URL is invalid") from error + + if parsed_url.scheme not in {"http", "https"}: + raise ValueError("Only HTTP(S) URLs are supported") + if parsed_url.hostname is None: + raise ValueError("Provided URL is invalid") + + return prepared_url + + +def _download_to_file( + url: str, target: Path, *, timeout: float = 30.0, stream: bool = False +) -> None: + """ + Download `url` to `target` atomically using a temporary file and `os.replace`. + + Args: + url: HTTP(S) URL to download. + target: Destination file path. Parent directories are created as needed. + timeout: Request timeout in seconds. Defaults to `30.0`. + stream: If `True`, stream the response to disk in chunks and display a + progress bar. If `False`, buffer the response in memory before + writing. Defaults to `False`. + + Raises: + requests.RequestException: If the request fails or returns an error status. + """ + response = requests.get(url, stream=stream, allow_redirects=True, timeout=timeout) + try: + response.raise_for_status() + target.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + delete=False, dir=target.parent, suffix=target.suffix + ) as file: + temp_path = Path(file.name) + try: + if stream: + from tqdm.auto import tqdm + + file_size = int(response.headers.get("Content-Length", 0)) + with tqdm.wrapattr( + response.raw, "read", total=file_size, desc="", colour="#a351fb" + ) as raw_response: + copyfileobj(raw_response, file) + else: + file.write(response.content) + except BaseException: + file.close() + temp_path.unlink(missing_ok=True) + raise + finally: + response.close() + + os.replace(temp_path, target) + + class NumpyJsonEncoder(json.JSONEncoder): def default(self, obj: Any) -> Any: if isinstance(obj, np.integer): diff --git a/src/supervision/utils/image.py b/src/supervision/utils/image.py index 27a3bc16b8..57ddfc2a51 100644 --- a/src/supervision/utils/image.py +++ b/src/supervision/utils/image.py @@ -8,7 +8,7 @@ import urllib.parse from collections.abc import Callable from functools import partial -from hashlib import sha256 +from hashlib import md5 from pathlib import Path from types import TracebackType from typing import Literal, cast @@ -16,7 +16,6 @@ import cv2 import numpy as np import numpy.typing as npt -import requests from deprecate import ( # type: ignore[import-untyped,unused-ignore] TargetMode, deprecated, @@ -32,7 +31,7 @@ ensure_cv2_image_for_standalone_function, images_to_cv2, ) -from supervision.utils.internal import prepare_url +from supervision.utils.file import _download_to_file, prepare_url from supervision.utils.iterables import create_batches, fill RelativePosition = Literal["top", "bottom"] @@ -50,7 +49,7 @@ def _get_image_url_cache_path(value: str, cache_dir: str | Path | None) -> Path: ) url_path = urllib.parse.urlparse(value).path suffix = Path(url_path).suffix or ".image" - url_hash = sha256(value.encode("utf-8")).hexdigest() + url_hash = md5(value.encode("utf-8"), usedforsecurity=False).hexdigest() return cache_root / f"{url_hash}{suffix}" @@ -68,19 +67,6 @@ def _decode_image_from_bytes( return cast(npt.NDArray[np.uint8], image) -def _write_image_url_cache(cache_path: Path, value: bytes) -> None: - cache_path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile( - delete=False, - dir=cache_path.parent, - suffix=cache_path.suffix, - ) as file: - temp_path = Path(file.name) - file.write(value) - - os.replace(temp_path, cache_path) - - def load_image_from_url( value: str, cv_imread_flags: int = cv2.IMREAD_COLOR, @@ -123,11 +109,20 @@ def load_image_from_url( ``` """ prepared_url = prepare_url(value=value) + if not use_cache: + with tempfile.TemporaryDirectory() as temp_dir: + temp_target = Path(temp_dir) / "image" + _download_to_file(prepared_url, temp_target, timeout=timeout) + return _decode_image_from_bytes( + value=temp_target.read_bytes(), + cv_imread_flags=cv_imread_flags, + ) + cache_path = _get_image_url_cache_path( value=prepared_url, cache_dir=cache_dir, ) - if use_cache and cache_path.exists() and not force_reload: + if cache_path.exists() and not force_reload: try: return _decode_image_from_bytes( value=cache_path.read_bytes(), @@ -136,19 +131,15 @@ def load_image_from_url( except ValueError: cache_path.unlink(missing_ok=True) - response = requests.get(prepared_url, timeout=timeout) + _download_to_file(prepared_url, cache_path, timeout=timeout) try: - response.raise_for_status() - image = _decode_image_from_bytes( - value=response.content, + return _decode_image_from_bytes( + value=cache_path.read_bytes(), cv_imread_flags=cv_imread_flags, ) - if use_cache: - _write_image_url_cache(cache_path=cache_path, value=response.content) - finally: - response.close() - - return image + except ValueError: + cache_path.unlink(missing_ok=True) + raise @ensure_cv2_image_for_standalone_function diff --git a/src/supervision/utils/internal.py b/src/supervision/utils/internal.py index 9e01f83ed0..17c3eee51e 100644 --- a/src/supervision/utils/internal.py +++ b/src/supervision/utils/internal.py @@ -1,7 +1,6 @@ import functools import inspect import os -import urllib.parse import warnings from collections.abc import Callable from typing import Any, Generic, TypeVar @@ -47,43 +46,6 @@ def warn_deprecated(message: str) -> None: warnings.warn(message, category=SupervisionWarnings, stacklevel=2) -def prepare_url(value: str) -> str: - """ - Validate and normalize an HTTP(S) URL. - - Args: - value: URL to validate. - - Returns: - Prepared URL string. - - Raises: - ValueError: If the URL is invalid or uses an unsupported scheme. - """ - import requests - - try: - original_parsed_url = urllib.parse.urlparse(value) - if "\\" in original_parsed_url.netloc: - raise ValueError("URL authority contains a backslash") - - prepared_request = requests.Request(method="GET", url=value).prepare() - prepared_url = prepared_request.url - if prepared_url is None: - raise ValueError("Prepared URL is empty") - - parsed_url = urllib.parse.urlparse(prepared_url) - except (requests.RequestException, ValueError) as error: - raise ValueError("Provided URL is invalid") from error - - if parsed_url.scheme not in {"http", "https"}: - raise ValueError("Only HTTP(S) URLs are supported") - if parsed_url.hostname is None: - raise ValueError("Provided URL is invalid") - - return prepared_url - - def deprecated_parameter( old_parameter: str, new_parameter: str, diff --git a/tests/assets/test_downloader.py b/tests/assets/test_downloader.py index ca0c677d6e..46e6760ec9 100644 --- a/tests/assets/test_downloader.py +++ b/tests/assets/test_downloader.py @@ -1,9 +1,10 @@ -from unittest.mock import MagicMock, mock_open, patch +from pathlib import Path +from unittest.mock import mock_open, patch import pytest from supervision.assets.downloader import download_assets, is_md5_hash_matching -from supervision.assets.list import ImageAssets, VideoAssets +from supervision.assets.list import MEDIA_ASSETS, ImageAssets, VideoAssets class TestMD5HashMatching: @@ -64,42 +65,22 @@ def test_already_exists_but_corrupted( mock_remove.assert_called_once_with(filename) @patch("supervision.assets.downloader.logger") - @patch("pathlib.Path.open", new_callable=mock_open) - @patch("pathlib.Path.mkdir") + @patch("supervision.assets.downloader._download_to_file") @patch("pathlib.Path.exists", return_value=False) - @patch("supervision.assets.downloader.copyfileobj") - @patch("supervision.assets.downloader.tqdm") - @patch("supervision.assets.downloader.get") - def test_download_new_file( - self, - mock_get, - mock_tqdm, - mock_copyfileobj, - mock_exists, - mock_mkdir, - mock_open_file, - mock_logger, - ) -> None: + def test_download_new_file(self, mock_exists, mock_download, mock_logger) -> None: """Test download_assets downloading a new file.""" filename = "vehicles.mp4" - mock_response = MagicMock() - mock_response.headers = {"Content-Length": "100"} - mock_response.raw = MagicMock() - mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response - - mock_tqdm.wrapattr.return_value.__enter__ = MagicMock( - return_value=mock_response.raw - ) - mock_tqdm.wrapattr.return_value.__exit__ = MagicMock() - result = download_assets(filename) + assert result == filename mock_logger.info.assert_called_with("Downloading %s assets", filename) - mock_get.assert_called_once() - mock_response.raise_for_status.assert_called_once_with() - mock_copyfileobj.assert_called_once() + mock_download.assert_called_once_with( + MEDIA_ASSETS[filename][0], + Path(filename).expanduser().resolve(), + timeout=30.0, + stream=True, + ) @patch("pathlib.Path.exists", return_value=False) def test_invalid_asset(self, mock_exists) -> None: @@ -124,67 +105,27 @@ def test_invalid_asset_when_file_exists(self, mock_exists) -> None: assert "vehicles.mp4" in str(exc_info.value) @patch("supervision.assets.downloader.logger") - @patch("pathlib.Path.open", new_callable=mock_open) - @patch("pathlib.Path.mkdir") - @patch("supervision.assets.downloader.copyfileobj") - @patch("supervision.assets.downloader.tqdm") - @patch("supervision.assets.downloader.get") + @patch("supervision.assets.downloader._download_to_file") @patch("pathlib.Path.exists", return_value=False) - def test_with_video_enum( - self, - mock_exists, - mock_get, - mock_tqdm, - mock_copyfileobj, - mock_mkdir, - mock_open_file, - mock_logger, - ) -> None: + def test_with_video_enum(self, mock_exists, mock_download, mock_logger) -> None: """Test download_assets with VideoAssets enum.""" asset = VideoAssets.VEHICLES - mock_response = MagicMock() - mock_response.headers = {"Content-Length": "100"} - mock_response.raw = MagicMock() - mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response - - mock_tqdm.wrapattr.return_value.__enter__ = MagicMock() - mock_tqdm.wrapattr.return_value.__exit__ = MagicMock() - result = download_assets(asset) + assert result == asset.filename mock_logger.info.assert_called_with("Downloading %s assets", asset.filename) + mock_download.assert_called_once() @patch("supervision.assets.downloader.logger") - @patch("pathlib.Path.open", new_callable=mock_open) - @patch("pathlib.Path.mkdir") - @patch("supervision.assets.downloader.copyfileobj") - @patch("supervision.assets.downloader.tqdm") - @patch("supervision.assets.downloader.get") + @patch("supervision.assets.downloader._download_to_file") @patch("pathlib.Path.exists", return_value=False) - def test_with_image_enum( - self, - mock_exists, - mock_get, - mock_tqdm, - mock_copyfileobj, - mock_mkdir, - mock_open_file, - mock_logger, - ) -> None: + def test_with_image_enum(self, mock_exists, mock_download, mock_logger) -> None: """Test download_assets with ImageAssets enum.""" asset = ImageAssets.SOCCER - mock_response = MagicMock() - mock_response.headers = {"Content-Length": "100"} - mock_response.raw = MagicMock() - mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response - - mock_tqdm.wrapattr.return_value.__enter__ = MagicMock() - mock_tqdm.wrapattr.return_value.__exit__ = MagicMock() - result = download_assets(asset) + assert result == asset.filename mock_logger.info.assert_called_with("Downloading %s assets", asset.filename) + mock_download.assert_called_once() diff --git a/tests/utils/test_file.py b/tests/utils/test_file.py index 62ca0addc2..e9b771e780 100644 --- a/tests/utils/test_file.py +++ b/tests/utils/test_file.py @@ -1,10 +1,89 @@ import os from contextlib import ExitStack as DoesNotRaise from pathlib import Path +from unittest.mock import Mock, patch import pytest +import requests + +from supervision.utils.file import ( + _download_to_file, + list_files_with_extensions, + prepare_url, + read_txt_file, +) + + +class TestPrepareUrl: + def test_returns_prepared_http_url(self) -> None: + """Valid HTTPS URL is returned normalized.""" + # given + url = "https://media.roboflow.com/quickstart/dog.jpeg" + + # when + result = prepare_url(value=url) + + # then + assert result == url + + @pytest.mark.parametrize( + ("url", "match"), + [ + pytest.param("file:///tmp/image.jpg", "HTTP", id="file-scheme"), + pytest.param("not a url", "invalid", id="not-a-url"), + pytest.param("http://", "invalid", id="missing-host"), + pytest.param( + "https://foo\\bar/image.jpg", "invalid", id="backslash-authority" + ), + ], + ) + def test_rejects_invalid_url(self, url: str, match: str) -> None: + """Invalid or non-HTTP(S) URLs raise ValueError.""" + with pytest.raises(ValueError, match=match): + prepare_url(value=url) + + +class TestDownloadToFile: + def test_writes_response_content_to_target(self, tmp_path) -> None: + """Non-streaming download writes response bytes to the target path.""" + # given + target = tmp_path / "subdir" / "file.bin" + response = Mock() + response.content = b"payload" + response.raise_for_status.return_value = None + + # when + with patch("supervision.utils.file.requests.get", return_value=response) as get: + _download_to_file("https://example.com/file.bin", target) + + # then + get.assert_called_once_with( + "https://example.com/file.bin", + stream=False, + allow_redirects=True, + timeout=30.0, + ) + assert target.read_bytes() == b"payload" + assert list(target.parent.iterdir()) == [target] + response.close.assert_called_once() + + def test_raises_and_leaves_no_file_on_http_error(self, tmp_path) -> None: + """HTTP error status raises and leaves no file behind.""" + # given + target = tmp_path / "file.bin" + response = Mock() + response.raise_for_status.side_effect = requests.HTTPError("404") + + # when / then + with ( + patch("supervision.utils.file.requests.get", return_value=response), + pytest.raises(requests.HTTPError, match="404"), + ): + _download_to_file("https://example.com/file.bin", target) + + assert not target.exists() + response.close.assert_called_once() -from supervision.utils.file import list_files_with_extensions, read_txt_file FILE_1_CONTENT = """Line 1 Line 2 diff --git a/tests/utils/test_image.py b/tests/utils/test_image.py index ee373afcc5..ce377aaa41 100644 --- a/tests/utils/test_image.py +++ b/tests/utils/test_image.py @@ -33,9 +33,7 @@ def test_returns_decoded_image(self, tmp_path) -> None: response.raise_for_status.return_value = None # when - with patch( - "supervision.utils.image.requests.get", return_value=response - ) as get: + with patch("supervision.utils.file.requests.get", return_value=response) as get: result = load_image_from_url( "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, @@ -44,6 +42,8 @@ def test_returns_decoded_image(self, tmp_path) -> None: # then get.assert_called_once_with( "https://media.roboflow.com/quickstart/dog.jpeg", + stream=False, + allow_redirects=True, timeout=30.0, ) assert result.shape == image.shape @@ -60,9 +60,7 @@ def test_uses_cached_image_on_repeated_calls(self, tmp_path) -> None: response.raise_for_status.return_value = None # when - with patch( - "supervision.utils.image.requests.get", return_value=response - ) as get: + with patch("supervision.utils.file.requests.get", return_value=response) as get: first_result = load_image_from_url( "https://media.roboflow.com/quickstart/dog.jpeg", cache_dir=tmp_path, @@ -91,7 +89,7 @@ def test_downloads_each_time_when_cache_is_disabled(self, tmp_path) -> None: # when with patch( - "supervision.utils.image.requests.get", + "supervision.utils.file.requests.get", side_effect=[first_response, second_response], ) as get: first_result = load_image_from_url( @@ -125,7 +123,7 @@ def test_force_reload_refreshes_cached_image(self, tmp_path) -> None: # when with patch( - "supervision.utils.image.requests.get", + "supervision.utils.file.requests.get", side_effect=[first_response, second_response], ) as get: cached_result = load_image_from_url( @@ -156,7 +154,7 @@ def test_redownloads_when_cached_image_is_invalid(self, tmp_path) -> None: # when with patch( - "supervision.utils.image.requests.get", + "supervision.utils.file.requests.get", side_effect=[first_response, second_response], ) as get: load_image_from_url( @@ -183,7 +181,7 @@ def test_raises_when_bytes_are_not_image(self, tmp_path) -> None: # when / then with ( - patch("supervision.utils.image.requests.get", return_value=response), + patch("supervision.utils.file.requests.get", return_value=response), pytest.raises(ValueError, match="could not be decoded into image"), ): load_image_from_url( @@ -199,7 +197,7 @@ def test_raises_for_request_error(self, tmp_path) -> None: # when / then with ( - patch("supervision.utils.image.requests.get", side_effect=request_error), + patch("supervision.utils.file.requests.get", side_effect=request_error), pytest.raises(requests.RequestException, match="boom"), ): load_image_from_url( @@ -210,7 +208,7 @@ def test_raises_for_request_error(self, tmp_path) -> None: def test_rejects_non_http_url(self) -> None: """Non-HTTP URLs are rejected before making a request.""" # given - with patch("supervision.utils.image.requests.get") as get: + with patch("supervision.utils.file.requests.get") as get: # when / then with pytest.raises(ValueError, match="HTTP"): load_image_from_url("file:///tmp/image.jpg") From 35ea1004c502604e3494be52058e3968594fa05f Mon Sep 17 00:00:00 2001 From: Erol444 Date: Thu, 9 Jul 2026 16:24:48 +0200 Subject: [PATCH 8/8] fix: address review feedback on image URL loader - rename prepare_url to private _normalize_http_url with url arg - share supervision cache root between assets and image URL loader - make URL validation errors descriptive (scheme, host, cause) - add ftp/javascript/data scheme-rejection tests - add sv.load_image_from_url changelog entry Co-Authored-By: Claude Fable 5 --- docs/changelog.md | 3 ++- src/supervision/utils/file.py | 23 +++++++++++++--------- src/supervision/utils/image.py | 16 +++++++++++++--- tests/utils/test_file.py | 35 +++++++++++++++++++++++++--------- 4 files changed, 55 insertions(+), 22 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index f99b792dba..22f5927ac2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ --- description: "Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version." -date_modified: 2026-07-08 +date_modified: 2026-07-09 --- # Changelog @@ -42,6 +42,7 @@ date_modified: 2026-07-08 - Fixed: dataset IO/export edge cases now avoid mutating caller-owned `Detections` during `DetectionDataset` construction, reject non-integer and out-of-range class ids with a clear `ValueError`, load COCO annotations that omit optional `iscrowd`/`area` fields, expose `DetectionDataset.from_coco(use_iscrowd=...)` without changing the existing positional `show_progress` argument, export mask pixel area to COCO when no stored area is present, ignore folder-structure root clutter and non-image files inside class folders, and accept PIL-readable YOLO images such as RGBA or palette PNGs. ### Added +- `sv.load_image_from_url` — load an image from an HTTP(S) URL as an OpenCV image, with optional on-disk caching under the shared supervision cache directory (`{tmpdir}/supervision/image-url/` by default, configurable via `cache_dir`) ([#2372](https://github.com/roboflow/supervision/pull/2372)) - `BaseAnnotator.requires_mask` — class-level `bool` flag on all annotators; `True` for `MaskAnnotator`, `PolygonAnnotator`, and `HaloAnnotator`; `False` for all others. Integrations can inspect this before materializing expensive mask payloads ([#2370](https://github.com/roboflow/supervision/pull/2370)) - `CompactMask.from_coco_rle` — efficient COCO RLE ingestion into crop-scoped compact mask format without materializing dense `(N, H, W)` arrays ([#2367](https://github.com/roboflow/supervision/pull/2367)) - `Detections.from_inference(compact_masks=True)` — opt-in compact mask representation for Roboflow/Inference segmentation results; masks are cropped to detector bounding boxes ([#2367](https://github.com/roboflow/supervision/pull/2367)) diff --git a/src/supervision/utils/file.py b/src/supervision/utils/file.py index 1833a6a549..9982182043 100644 --- a/src/supervision/utils/file.py +++ b/src/supervision/utils/file.py @@ -10,38 +10,43 @@ import requests import yaml +SUPERVISION_CACHE_DIR = Path(tempfile.gettempdir()) / "supervision" -def prepare_url(value: str) -> str: + +def _normalize_http_url(url: str) -> str: """ Validate and normalize an HTTP(S) URL. Args: - value: URL to validate. + url: URL to validate. Returns: - Prepared URL string. + Normalized URL string. Raises: ValueError: If the URL is invalid or uses an unsupported scheme. """ try: - original_parsed_url = urllib.parse.urlparse(value) + original_parsed_url = urllib.parse.urlparse(url) if "\\" in original_parsed_url.netloc: raise ValueError("URL authority contains a backslash") - prepared_request = requests.Request(method="GET", url=value).prepare() + prepared_request = requests.Request(method="GET", url=url).prepare() prepared_url = prepared_request.url if prepared_url is None: - raise ValueError("Prepared URL is empty") + raise ValueError("prepared URL is empty") parsed_url = urllib.parse.urlparse(prepared_url) except (requests.RequestException, ValueError) as error: - raise ValueError("Provided URL is invalid") from error + raise ValueError(f"Invalid URL {url!r}: {error}") from error if parsed_url.scheme not in {"http", "https"}: - raise ValueError("Only HTTP(S) URLs are supported") + raise ValueError( + f"Unsupported URL scheme {parsed_url.scheme!r} in {url!r}. " + "Only HTTP and HTTPS URLs are supported." + ) if parsed_url.hostname is None: - raise ValueError("Provided URL is invalid") + raise ValueError(f"Invalid URL {url!r}: no host supplied.") return prepared_url diff --git a/src/supervision/utils/image.py b/src/supervision/utils/image.py index be5da41595..d03159b637 100644 --- a/src/supervision/utils/image.py +++ b/src/supervision/utils/image.py @@ -31,17 +31,24 @@ ensure_cv2_image_for_standalone_function, images_to_cv2, ) -from supervision.utils.file import _download_to_file, prepare_url +from supervision.utils.file import ( + SUPERVISION_CACHE_DIR, + _download_to_file, + _normalize_http_url, +) from supervision.utils.iterables import create_batches, fill RelativePosition = Literal["top", "bottom"] MAX_COLUMNS_FOR_SINGLE_ROW_GRID = 3 -DEFAULT_IMAGE_URL_CACHE_DIR = Path(tempfile.gettempdir()) / "supervision" / "image-url" +DEFAULT_IMAGE_URL_CACHE_DIR = SUPERVISION_CACHE_DIR / "image-url" def _get_image_url_cache_path(value: str, cache_dir: str | Path | None) -> Path: + """ + Build the cache file path for a URL: `/`. + """ cache_root = ( DEFAULT_IMAGE_URL_CACHE_DIR if cache_dir is None @@ -57,6 +64,9 @@ def _decode_image_from_bytes( value: bytes, cv_imread_flags: int, ) -> npt.NDArray[np.uint8]: + """ + Decode raw image bytes into an OpenCV image, raising on undecodable data. + """ image = cv2.imdecode( np.frombuffer(value, dtype=np.uint8), cv_imread_flags, @@ -108,7 +118,7 @@ def load_image_from_url( ``` """ - prepared_url = prepare_url(value=value) + prepared_url = _normalize_http_url(url=value) if not use_cache: with tempfile.TemporaryDirectory() as temp_dir: temp_target = Path(temp_dir) / "image" diff --git a/tests/utils/test_file.py b/tests/utils/test_file.py index e9b771e780..b557105b95 100644 --- a/tests/utils/test_file.py +++ b/tests/utils/test_file.py @@ -8,20 +8,20 @@ from supervision.utils.file import ( _download_to_file, + _normalize_http_url, list_files_with_extensions, - prepare_url, read_txt_file, ) -class TestPrepareUrl: - def test_returns_prepared_http_url(self) -> None: +class TestNormalizeHttpUrl: + def test_returns_normalized_http_url(self) -> None: """Valid HTTPS URL is returned normalized.""" # given url = "https://media.roboflow.com/quickstart/dog.jpeg" # when - result = prepare_url(value=url) + result = _normalize_http_url(url=url) # then assert result == url @@ -29,18 +29,35 @@ def test_returns_prepared_http_url(self) -> None: @pytest.mark.parametrize( ("url", "match"), [ - pytest.param("file:///tmp/image.jpg", "HTTP", id="file-scheme"), - pytest.param("not a url", "invalid", id="not-a-url"), - pytest.param("http://", "invalid", id="missing-host"), pytest.param( - "https://foo\\bar/image.jpg", "invalid", id="backslash-authority" + "file:///tmp/image.jpg", "Unsupported URL scheme", id="file-scheme" + ), + pytest.param( + "ftp://example.com/image.jpg", + "Unsupported URL scheme", + id="ftp-scheme", + ), + pytest.param( + "javascript:alert(1)", + "Unsupported URL scheme", + id="javascript-scheme", + ), + pytest.param( + "data:text/plain;base64,aGk=", + "Unsupported URL scheme", + id="data-scheme", + ), + pytest.param("not a url", "Invalid URL", id="not-a-url"), + pytest.param("http://", "Invalid URL", id="missing-host"), + pytest.param( + "https://foo\\bar/image.jpg", "Invalid URL", id="backslash-authority" ), ], ) def test_rejects_invalid_url(self, url: str, match: str) -> None: """Invalid or non-HTTP(S) URLs raise ValueError.""" with pytest.raises(ValueError, match=match): - prepare_url(value=url) + _normalize_http_url(url=url) class TestDownloadToFile: