Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5b51898
feat: add image URL loader
Erol444 Jun 29, 2026
92b049b
Merge origin/develop into image URL loader
Erol444 Jun 29, 2026
f2bd8a3
docs: update image URL loader example
Erol444 Jun 29, 2026
51e6ffe
feat: cache image URL loads
Erol444 Jun 29, 2026
e8ebe9e
docs: use quickstart dog image URL
Erol444 Jun 29, 2026
4887d76
feat: add image URL cache toggle
Erol444 Jun 29, 2026
447f439
Merge branch 'develop' into codex/add-image-url-loader
Erol444 Jun 29, 2026
9838186
Merge origin/develop into image URL loader
Erol444 Jun 29, 2026
9c617e9
perf: lazy import requests in URL validation
Erol444 Jun 29, 2026
60b0cb2
Merge branch 'develop' into codex/add-image-url-loader
Erol444 Jun 29, 2026
ae88bfd
Merge branch 'develop' into codex/add-image-url-loader
Erol444 Jul 2, 2026
71758ce
Merge remote-tracking branch 'origin/develop' into codex/add-image-ur…
Erol444 Jul 3, 2026
5b3d4ac
Merge branch 'develop' into codex/add-image-url-loader
Erol444 Jul 7, 2026
0896393
refactor: share download primitive between assets and image URL loader
Erol444 Jul 9, 2026
f678e0a
Merge remote-tracking branch 'origin/develop' into codex/add-image-ur…
Erol444 Jul 9, 2026
35ea100
fix: address review feedback on image URL loader
Erol444 Jul 9, 2026
124c366
Merge branch 'develop' into codex/add-image-url-loader
Borda Jul 9, 2026
b2f4e46
Merge remote-tracking branch 'origin/develop' into codex/add-image-ur…
Erol444 Jul 10, 2026
8197247
Merge branch 'codex/add-image-url-loader' of https://github.com/robof…
Erol444 Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -46,6 +46,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))
- `KeyPoints.merge` — combine a list of `KeyPoints` objects into one, mirroring `Detections.merge`. Empty inputs are ignored; all non-empty inputs must share the same number of keypoints per skeleton. Completes the merge-then-suppress workflow introduced by `KeyPoints.with_nms` ([#2412](https://github.com/roboflow/supervision/pull/2412))
- `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))
Expand Down
6 changes: 6 additions & 0 deletions docs/utils/image.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ status: new

:::supervision.utils.image.crop_image

<div class="md-typeset">
<h2><a href="#supervision.utils.image.load_image_from_url">load_image_from_url</a></h2>
</div>

:::supervision.utils.image.load_image_from_url

<div class="md-typeset">
<h2><a href="#supervision.utils.image.scale_image">scale_image</a></h2>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/supervision/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
get_image_resolution_wh,
grayscale_image,
letterbox_image,
load_image_from_url,
overlay_image,
resize_image,
scale_image,
Expand Down Expand Up @@ -263,6 +264,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",
Expand Down
29 changes: 2 additions & 27 deletions src/supervision/assets/downloader.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand Down Expand Up @@ -40,29 +37,7 @@ def _download_asset(filename: str, destination: Path) -> None:
"""
Download asset bytes to the target destination via a temporary file.
"""
response = get(
MEDIA_ASSETS[filename][0], stream=True, allow_redirects=True, timeout=30
)
response.raise_for_status()

file_size = int(response.headers.get("Content-Length", 0))
destination.parent.mkdir(parents=True, exist_ok=True)
temp_path = destination.with_name(f"{destination.name}.part")

try:
with tqdm.wrapattr(
response.raw, "read", total=file_size, desc="", colour="#a351fb"
) as raw_resp:
with temp_path.open("wb") as file:
copyfileobj(raw_resp, file)
except Exception:
temp_path.unlink(missing_ok=True)
raise

try:
os.replace(temp_path, destination)
finally:
temp_path.unlink(missing_ok=True)
_download_to_file(MEDIA_ASSETS[filename][0], destination, timeout=30.0, stream=True)


def _download_verified_asset(
Expand Down
94 changes: 94 additions & 0 deletions src/supervision/utils/file.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,104 @@
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

SUPERVISION_CACHE_DIR = Path(tempfile.gettempdir()) / "supervision"


def _normalize_http_url(url: str) -> str:
"""
Validate and normalize an HTTP(S) URL.

Args:
url: URL to validate.

Returns:
Normalized URL string.

Raises:
ValueError: If the URL is invalid or uses an unsupported scheme.
"""
try:
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=url).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(f"Invalid URL {url!r}: {error}") from error

if parsed_url.scheme not in {"http", "https"}:
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(f"Invalid URL {url!r}: no host supplied.")

return prepared_url


def _download_to_file(
Comment thread
Erol444 marked this conversation as resolved.
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()

try:
os.replace(temp_path, target)
finally:
temp_path.unlink(missing_ok=True)


class NumpyJsonEncoder(json.JSONEncoder):
def default(self, obj: Any) -> Any:
Expand Down
118 changes: 118 additions & 0 deletions src/supervision/utils/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 md5
from pathlib import Path
from types import TracebackType
from typing import Literal, cast

Expand All @@ -27,12 +31,126 @@
ensure_cv2_image_for_standalone_function,
images_to_cv2,
)
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 = SUPERVISION_CACHE_DIR / "image-url"


def _get_image_url_cache_path(value: str, cache_dir: str | Path | None) -> Path:
Comment thread
Erol444 marked this conversation as resolved.
"""
Build the cache file path for a URL: `<cache root>/<md5(url)><suffix>`.
"""
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 = md5(value.encode("utf-8"), usedforsecurity=False).hexdigest()
return cache_root / f"{url_hash}{suffix}"


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,
)
if image is None:
raise ValueError("Data pointed by URL could not be decoded into image.")

return cast(npt.NDArray[np.uint8], image)


def load_image_from_url(
Comment thread
Erol444 marked this conversation as resolved.
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]:
"""
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`.
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.
Defaults to `False`.

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:
```python
import supervision as sv

image = sv.load_image_from_url(
"https://media.roboflow.com/quickstart/dog.jpeg"
)
image.shape

```
Comment thread
Erol444 marked this conversation as resolved.
"""
prepared_url = _normalize_http_url(url=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 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)

_download_to_file(prepared_url, cache_path, timeout=timeout)
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)
raise


@ensure_cv2_image_for_standalone_function
def crop_image(
Expand Down
Loading
Loading