Skip to content

Commit 74db9e2

Browse files
Bordacodex
andauthored
fix(utils): normalize timm confidences and verify assets (roboflow#2414)
- Convert timm classification logits with softmax so confidence values match the normalized scale used by other classification adapters. - Verify asset MD5 hashes after fresh downloads and retry once when a payload is corrupted. - Add focused regressions for timm confidence scaling and asset download integrity paths. - Convert from_timm outputs to probabilities before applying thresholds and document that existing thresholds may need retuning. - Add downloader regression coverage for repeated MD5 mismatches so exhausted retries now raise ValueError. --------- Co-authored-by: Codex <codex@openai.com>
1 parent dde4227 commit 74db9e2

5 files changed

Lines changed: 220 additions & 21 deletions

File tree

docs/changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ date_modified: 2026-07-06
1818
- `sv.mask_non_max_merge` now computes exact mask overlap at the original mask resolution and ignores the deprecated `mask_dimension` parameter. Code that relied on downscaled mask overlap should recalibrate thresholds; passing `mask_dimension` positionally now emits a deprecation warning, and the parameter is scheduled for removal in `0.33.0` ([#2400](https://github.com/roboflow/supervision/pull/2400)).
1919

2020
### Fixed
21+
- `sv.Classifications.from_timm` now softmaxes model logits before exposing confidence scores, matching `sv.Classifications.from_clip` and keeping timm confidences on a normalized probability scale. Thresholds calibrated against raw logits may need retuning.
22+
- `sv.download_assets` now verifies MD5 hashes after fresh downloads and retries once when the downloaded payload is corrupted instead of accepting a bad file.
2123
- Fixed metrics scoring edge cases: legacy `sv.MeanAveragePrecision` now uses COCO 101-point AP averaging, `sv.ConfusionMatrix` rejects invalid class ids instead of wrapping them through `int16`/negative indexing, `sv.MeanAveragePrecision` preserves user-provided target `ignore` flags, and `sv.MeanAverageRecallResult.recall_per_class` now exposes per-class recall for each max-detection cutoff.
2224
- `sv.ByteTrack` no longer mutates input `Detections` while assigning tracker IDs. It now keeps detections at the activation-threshold boundary eligible for matching, avoids impossible new-track thresholds above score `1.0`, ignores invalid zero-area/non-finite tensor boxes before Kalman updates, and does not emit unconfirmed `-1` IDs from first-frame tensor updates.
2325
- Fixed [#2402](https://github.com/roboflow/supervision/pull/2402): `sv.KeyPoints.as_detections` now accepts NumPy arrays, tuples, and generators in `selected_keypoint_indices` without ambiguous truth-value errors; empty index iterables select all keypoints. Valid zero-area skeletons are preserved, while all-zero and non-finite-only skeletons are filtered out.

src/supervision/assets/downloader.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,51 @@ def is_md5_hash_matching(filename: str, original_md5_hash: str) -> bool:
3636
return computed_md5_hash.hexdigest() == original_md5_hash
3737

3838

39+
def _download_asset(filename: str) -> None:
40+
"""
41+
Download asset bytes to the target filename.
42+
"""
43+
response = get(
44+
MEDIA_ASSETS[filename][0], stream=True, allow_redirects=True, timeout=30
45+
)
46+
response.raise_for_status()
47+
48+
file_size = int(response.headers.get("Content-Length", 0))
49+
folder_path = Path(filename).expanduser().resolve()
50+
folder_path.parent.mkdir(parents=True, exist_ok=True)
51+
52+
with tqdm.wrapattr(
53+
response.raw, "read", total=file_size, desc="", colour="#a351fb"
54+
) as raw_resp:
55+
with folder_path.open("wb") as file:
56+
copyfileobj(raw_resp, file)
57+
58+
59+
def _download_verified_asset(
60+
filename: str, original_md5_hash: str, retry_on_mismatch: bool = True
61+
) -> None:
62+
"""
63+
Download an asset and reject payloads whose MD5 does not match the catalog.
64+
"""
65+
_download_asset(filename)
66+
67+
if is_md5_hash_matching(filename, original_md5_hash):
68+
return
69+
70+
logger.warning("File corrupted. Re-downloading...")
71+
os.remove(filename)
72+
73+
if retry_on_mismatch:
74+
_download_verified_asset(
75+
filename=filename,
76+
original_md5_hash=original_md5_hash,
77+
retry_on_mismatch=False,
78+
)
79+
return
80+
81+
raise ValueError(f"Downloaded asset {filename!r} failed MD5 verification.")
82+
83+
3984
def download_assets(asset_name: Assets | str) -> str:
4085
"""
4186
Download a specified asset if it doesn't already exist or is corrupted.
@@ -61,27 +106,15 @@ def download_assets(asset_name: Assets | str) -> str:
61106
filename = asset_name.filename if isinstance(asset_name, Assets) else asset_name
62107

63108
if filename in MEDIA_ASSETS:
109+
original_md5_hash = MEDIA_ASSETS[filename][1]
64110
if not Path(filename).exists():
65111
logger.info("Downloading %s assets", filename)
66-
response = get(
67-
MEDIA_ASSETS[filename][0], stream=True, allow_redirects=True, timeout=30
68-
)
69-
response.raise_for_status()
70-
71-
file_size = int(response.headers.get("Content-Length", 0))
72-
folder_path = Path(filename).expanduser().resolve()
73-
folder_path.parent.mkdir(parents=True, exist_ok=True)
74-
75-
with tqdm.wrapattr(
76-
response.raw, "read", total=file_size, desc="", colour="#a351fb"
77-
) as raw_resp:
78-
with folder_path.open("wb") as file:
79-
copyfileobj(raw_resp, file)
112+
_download_verified_asset(filename, original_md5_hash)
80113
else:
81-
if not is_md5_hash_matching(filename, MEDIA_ASSETS[filename][1]):
114+
if not is_md5_hash_matching(filename, original_md5_hash):
82115
logger.warning("File corrupted. Re-downloading...")
83116
os.remove(filename)
84-
return download_assets(filename)
117+
_download_verified_asset(filename, original_md5_hash)
85118

86119
logger.info("%s asset download complete.", filename)
87120
else:

src/supervision/classification/core.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import numpy.typing as npt
88

99
if TYPE_CHECKING:
10-
import torch
10+
import torch # type: ignore[import-not-found, unused-ignore]
1111

1212

1313
def _validate_class_ids(class_id: Any, n: int) -> None:
@@ -123,6 +123,10 @@ def from_timm(cls, timm_results: Any) -> Classifications:
123123
Creates a Classifications instance from a
124124
[timm](https://huggingface.co/docs/hub/timm) inference result.
125125
126+
Note:
127+
Returned confidences are softmax-normalized probabilities, so
128+
thresholds calibrated against raw logits may need recalibration.
129+
126130
Args:
127131
timm_results: The inference result from timm model.
128132
@@ -152,7 +156,7 @@ def from_timm(cls, timm_results: Any) -> Classifications:
152156
classifications = sv.Classifications.from_timm(output)
153157
```
154158
"""
155-
confidence = timm_results.cpu().detach().numpy()[0]
159+
confidence = timm_results.softmax(dim=-1).cpu().detach().numpy()[0]
156160

157161
if len(confidence) == 0:
158162
return cls(

tests/assets/test_downloader.py

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,48 @@ def test_already_exists_and_valid(self, mock_exists, mock_md5, mock_logger) -> N
5252
"supervision.assets.downloader.is_md5_hash_matching",
5353
side_effect=[False, True],
5454
)
55+
@patch("pathlib.Path.open", new_callable=mock_open)
56+
@patch("pathlib.Path.mkdir")
5557
@patch("pathlib.Path.exists", return_value=True)
58+
@patch("supervision.assets.downloader.copyfileobj")
59+
@patch("supervision.assets.downloader.tqdm")
60+
@patch("supervision.assets.downloader.get")
5661
def test_already_exists_but_corrupted(
57-
self, mock_exists, mock_md5, mock_remove, mock_logger
62+
self,
63+
mock_get,
64+
mock_tqdm,
65+
mock_copyfileobj,
66+
mock_exists,
67+
mock_mkdir,
68+
mock_open_file,
69+
mock_md5,
70+
mock_remove,
71+
mock_logger,
5872
) -> None:
5973
"""Test download_assets when file exists but is corrupted (re-downloads)."""
6074
filename = "vehicles.mp4"
75+
76+
mock_response = MagicMock()
77+
mock_response.headers = {"Content-Length": "100"}
78+
mock_response.raw = MagicMock()
79+
mock_response.raise_for_status = MagicMock()
80+
mock_get.return_value = mock_response
81+
82+
mock_tqdm.wrapattr.return_value.__enter__ = MagicMock(
83+
return_value=mock_response.raw
84+
)
85+
mock_tqdm.wrapattr.return_value.__exit__ = MagicMock()
86+
6187
result = download_assets(filename)
88+
6289
assert result == filename
90+
mock_get.assert_called_once()
91+
mock_copyfileobj.assert_called_once()
6392
mock_logger.warning.assert_called_once_with("File corrupted. Re-downloading...")
6493
mock_remove.assert_called_once_with(filename)
6594

6695
@patch("supervision.assets.downloader.logger")
96+
@patch("supervision.assets.downloader.is_md5_hash_matching", return_value=True)
6797
@patch("pathlib.Path.open", new_callable=mock_open)
6898
@patch("pathlib.Path.mkdir")
6999
@patch("pathlib.Path.exists", return_value=False)
@@ -78,9 +108,10 @@ def test_download_new_file(
78108
mock_exists,
79109
mock_mkdir,
80110
mock_open_file,
111+
mock_md5,
81112
mock_logger,
82113
) -> None:
83-
"""Test download_assets downloading a new file."""
114+
"""Test download_assets verifies a freshly downloaded file."""
84115
filename = "vehicles.mp4"
85116

86117
mock_response = MagicMock()
@@ -100,6 +131,99 @@ def test_download_new_file(
100131
mock_get.assert_called_once()
101132
mock_response.raise_for_status.assert_called_once_with()
102133
mock_copyfileobj.assert_called_once()
134+
mock_md5.assert_called_once_with(filename, "8155ff4e4de08cfa25f39de96483f918")
135+
136+
@patch("supervision.assets.downloader.logger")
137+
@patch("os.remove")
138+
@patch(
139+
"supervision.assets.downloader.is_md5_hash_matching",
140+
side_effect=[False, True],
141+
)
142+
@patch("pathlib.Path.open", new_callable=mock_open)
143+
@patch("pathlib.Path.mkdir")
144+
@patch("pathlib.Path.exists", return_value=False)
145+
@patch("supervision.assets.downloader.copyfileobj")
146+
@patch("supervision.assets.downloader.tqdm")
147+
@patch("supervision.assets.downloader.get")
148+
def test_download_new_file_retries_corrupted_payload(
149+
self,
150+
mock_get,
151+
mock_tqdm,
152+
mock_copyfileobj,
153+
mock_exists,
154+
mock_mkdir,
155+
mock_open_file,
156+
mock_md5,
157+
mock_remove,
158+
mock_logger,
159+
) -> None:
160+
"""Test download_assets retries once when a fresh payload fails MD5."""
161+
filename = "vehicles.mp4"
162+
163+
mock_response = MagicMock()
164+
mock_response.headers = {"Content-Length": "100"}
165+
mock_response.raw = MagicMock()
166+
mock_response.raise_for_status = MagicMock()
167+
mock_get.return_value = mock_response
168+
169+
mock_tqdm.wrapattr.return_value.__enter__ = MagicMock(
170+
return_value=mock_response.raw
171+
)
172+
mock_tqdm.wrapattr.return_value.__exit__ = MagicMock()
173+
174+
result = download_assets(filename)
175+
176+
assert result == filename
177+
assert mock_get.call_count == 2
178+
assert mock_copyfileobj.call_count == 2
179+
mock_remove.assert_called_once_with(filename)
180+
mock_logger.warning.assert_called_once_with("File corrupted. Re-downloading...")
181+
182+
@patch("supervision.assets.downloader.logger")
183+
@patch("os.remove")
184+
@patch(
185+
"supervision.assets.downloader.is_md5_hash_matching",
186+
side_effect=[False, False],
187+
)
188+
@patch("pathlib.Path.open", new_callable=mock_open)
189+
@patch("pathlib.Path.mkdir")
190+
@patch("pathlib.Path.exists", return_value=False)
191+
@patch("supervision.assets.downloader.copyfileobj")
192+
@patch("supervision.assets.downloader.tqdm")
193+
@patch("supervision.assets.downloader.get")
194+
def test_download_new_file_raises_after_second_md5_mismatch(
195+
self,
196+
mock_get,
197+
mock_tqdm,
198+
mock_copyfileobj,
199+
mock_exists,
200+
mock_mkdir,
201+
mock_open_file,
202+
mock_md5,
203+
mock_remove,
204+
mock_logger,
205+
) -> None:
206+
"""Test download_assets fails after the verified retry is also corrupted."""
207+
filename = "vehicles.mp4"
208+
209+
mock_response = MagicMock()
210+
mock_response.headers = {"Content-Length": "100"}
211+
mock_response.raw = MagicMock()
212+
mock_response.raise_for_status = MagicMock()
213+
mock_get.return_value = mock_response
214+
215+
mock_tqdm.wrapattr.return_value.__enter__ = MagicMock(
216+
return_value=mock_response.raw
217+
)
218+
mock_tqdm.wrapattr.return_value.__exit__ = MagicMock()
219+
220+
with pytest.raises(ValueError, match="failed MD5 verification"):
221+
download_assets(filename)
222+
223+
assert mock_get.call_count == 2
224+
assert mock_copyfileobj.call_count == 2
225+
assert mock_remove.call_count == 2
226+
assert mock_logger.warning.call_count == 2
103227

104228
@patch("pathlib.Path.exists", return_value=False)
105229
def test_invalid_asset(self, mock_exists) -> None:
@@ -124,6 +248,7 @@ def test_invalid_asset_when_file_exists(self, mock_exists) -> None:
124248
assert "vehicles.mp4" in str(exc_info.value)
125249

126250
@patch("supervision.assets.downloader.logger")
251+
@patch("supervision.assets.downloader.is_md5_hash_matching", return_value=True)
127252
@patch("pathlib.Path.open", new_callable=mock_open)
128253
@patch("pathlib.Path.mkdir")
129254
@patch("supervision.assets.downloader.copyfileobj")
@@ -138,6 +263,7 @@ def test_with_video_enum(
138263
mock_copyfileobj,
139264
mock_mkdir,
140265
mock_open_file,
266+
mock_md5,
141267
mock_logger,
142268
) -> None:
143269
"""Test download_assets with VideoAssets enum."""
@@ -155,8 +281,12 @@ def test_with_video_enum(
155281
result = download_assets(asset)
156282
assert result == asset.filename
157283
mock_logger.info.assert_called_with("Downloading %s assets", asset.filename)
284+
mock_md5.assert_called_once_with(
285+
asset.filename, "8155ff4e4de08cfa25f39de96483f918"
286+
)
158287

159288
@patch("supervision.assets.downloader.logger")
289+
@patch("supervision.assets.downloader.is_md5_hash_matching", return_value=True)
160290
@patch("pathlib.Path.open", new_callable=mock_open)
161291
@patch("pathlib.Path.mkdir")
162292
@patch("supervision.assets.downloader.copyfileobj")
@@ -171,6 +301,7 @@ def test_with_image_enum(
171301
mock_copyfileobj,
172302
mock_mkdir,
173303
mock_open_file,
304+
mock_md5,
174305
mock_logger,
175306
) -> None:
176307
"""Test download_assets with ImageAssets enum."""
@@ -188,3 +319,6 @@ def test_with_image_enum(
188319
result = download_assets(asset)
189320
assert result == asset.filename
190321
mock_logger.info.assert_called_with("Downloading %s assets", asset.filename)
322+
mock_md5.assert_called_once_with(
323+
asset.filename, "0f5a4b98abf3e3973faf9e9260a7d876"
324+
)

tests/classification/test_core.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,29 @@
99

1010

1111
class _MockTensor:
12+
"""Minimal tensor double that supports the tensor chain used by adapters."""
13+
1214
def __init__(self, value: np.ndarray) -> None:
1315
self.value = value
1416

1517
def softmax(self, dim: int) -> _MockTensor:
16-
return self
18+
"""Return a tensor double with softmax applied along the requested axis."""
19+
if self.value.shape[dim] == 0:
20+
return _MockTensor(self.value)
21+
22+
exp = np.exp(self.value - np.max(self.value, axis=dim, keepdims=True))
23+
return _MockTensor(exp / np.sum(exp, axis=dim, keepdims=True))
1724

1825
def cpu(self) -> _MockTensor:
26+
"""Return the tensor double for chained CPU conversion calls."""
1927
return self
2028

2129
def detach(self) -> _MockTensor:
30+
"""Return the tensor double for chained graph-detach calls."""
2231
return self
2332

2433
def numpy(self) -> np.ndarray:
34+
"""Return the wrapped NumPy array."""
2535
return self.value
2636

2737

@@ -72,6 +82,7 @@ def test_top_k(
7282
expected_result: tuple[np.ndarray, np.ndarray] | None,
7383
exception: Exception,
7484
) -> None:
85+
"""Retrieves requested top-k values or raises for malformed confidence input."""
7586
with exception:
7687
result = Classifications(
7788
class_id=np.array(class_id), confidence=np.array(confidence)
@@ -82,6 +93,7 @@ def test_top_k(
8293

8394

8495
def test_from_clip_empty_output_dtypes() -> None:
96+
"""Empty CLIP logits produce typed empty classification arrays."""
8597
result = Classifications.from_clip(_MockTensor(np.empty((1, 0), dtype=np.float32)))
8698

8799
assert result.class_id.dtype == np.int_
@@ -90,8 +102,22 @@ def test_from_clip_empty_output_dtypes() -> None:
90102

91103

92104
def test_from_timm_empty_output_dtypes() -> None:
105+
"""Empty timm logits produce typed empty classification arrays."""
93106
result = Classifications.from_timm(_MockTensor(np.empty((1, 0), dtype=np.float32)))
94107

95108
assert result.class_id.dtype == np.int_
96109
assert result.confidence is not None
97110
assert result.confidence.dtype == np.float32
111+
112+
113+
def test_from_timm_softmaxes_logits() -> None:
114+
"""Timm logits are converted to normalized confidence scores."""
115+
logits = np.array([[0.0, 1.0, 2.0]], dtype=np.float32)
116+
117+
result = Classifications.from_timm(_MockTensor(logits))
118+
119+
assert result.confidence is not None
120+
assert np.allclose(
121+
result.confidence, _MockTensor(logits).softmax(dim=-1).numpy()[0]
122+
)
123+
assert np.isclose(np.sum(result.confidence), 1.0)

0 commit comments

Comments
 (0)