Skip to content

Commit 08d0543

Browse files
authored
Merge branch 'develop' into feat-tflite-cli
2 parents 7bb2beb + 27b9d23 commit 08d0543

6 files changed

Lines changed: 167 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121

2222
### Fixed
2323

24-
-
24+
- Fixed `import rfdetr` failing on NumPy 2.x when a transitive dependency references the removed `np.complex_` alias ([#1064](https://github.com/roboflow/rf-detr/pull/1064))
2525

2626
### Security
2727

src/rfdetr/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@
3131
import sys
3232
from typing import Any
3333

34+
# np.complex_ was removed in NumPy 2.0 (June 2024). Some transitive dependencies (e.g. older
35+
# tensorflow, chumpy) still reference it, causing AttributeError on import rfdetr. See issue #1061.
36+
# TODO: Remove once all transitive deps support NumPy 2.x natively.
37+
try:
38+
import numpy
39+
40+
_IS_NUMPY_INSTALLED = True
41+
except ImportError:
42+
_IS_NUMPY_INSTALLED = False
43+
44+
if _IS_NUMPY_INSTALLED and not hasattr(numpy, "complex_"):
45+
_complex128 = getattr(numpy, "complex128", None)
46+
if _complex128 is not None:
47+
numpy.complex_ = _complex128
48+
49+
3450
from rfdetr.detr import RFDETR
3551
from rfdetr.inference import ModelContext
3652
from rfdetr.variants import (

src/rfdetr/detr.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,14 @@ def from_checkpoint(cls, path: str | os.PathLike[str], **kwargs: Any) -> RFDETR:
267267
1. ``model_name`` key in the checkpoint (written by the PTL training
268268
stack since v1.7.0).
269269
2. ``pretrain_weights`` field in the checkpoint's ``args`` entry
270-
(legacy fallback).
270+
(legacy fallback for older checkpoints).
271+
3. The **filename** of *path* itself, used as a last resort when
272+
``pretrain_weights`` is absent or an unset-like sentinel value
273+
(empty string, ``"none"``, or ``"null"``). Starter weights
274+
published by Roboflow store ``pretrain_weights="none"`` in their
275+
``args``; passing the canonical filename (e.g.
276+
``rf-detr-small.pth``) lets ``from_checkpoint`` infer the class
277+
automatically.
271278
272279
Both legacy ``argparse.Namespace`` checkpoints (produced by ``engine.py``) and dict-style checkpoints (produced
273280
by the PTL training stack) are supported.
@@ -288,7 +295,8 @@ def from_checkpoint(cls, path: str | os.PathLike[str], **kwargs: Any) -> RFDETR:
288295
FileNotFoundError: If *path* does not exist.
289296
OSError: If *path* exists but cannot be read.
290297
KeyError: If the checkpoint does not contain an ``"args"`` key.
291-
ValueError: If the model class cannot be inferred from ``model_name`` or ``pretrain_weights``.
298+
ValueError: If the model class cannot be inferred from ``model_name``,
299+
``pretrain_weights``, or the checkpoint filename.
292300
293301
Examples:
294302
>>> model = RFDETR.from_checkpoint("checkpoint_best_total.pth") # doctest: +SKIP
@@ -360,11 +368,24 @@ def from_checkpoint(cls, path: str | os.PathLike[str], **kwargs: Any) -> RFDETR:
360368
else:
361369
normalized_name = ""
362370

363-
# Fall back to pretrain_weights filename parsing for older checkpoints.
371+
# Fall back to pretrain_weights (legacy) or, when unset-like, the checkpoint filename.
364372
if isinstance(args, dict):
365-
weights_name = str(args.get("pretrain_weights", "")).lower()
373+
weights_name = str(args.get("pretrain_weights", "")).strip().lower()
366374
else:
367-
weights_name = str(getattr(args, "pretrain_weights", "")).lower()
375+
weights_name = str(getattr(args, "pretrain_weights", "")).strip().lower()
376+
# The sentinel set {"", "none", "null"} covers unset-like checkpoint values:
377+
# "" — pretrain_weights key absent entirely
378+
# "none" — checkpoint value was None or the literal string "none";
379+
# after str(...).strip().lower() both normalize to the same sentinel.
380+
# This is NOT an intentional "no pretraining" flag (see
381+
# test_pretrain_weights_none_warns, which operates at the config
382+
# level, not the checkpoint level)
383+
# "null" — checkpoint stored the literal string "null" (for example from a
384+
# YAML-originated value), which is also treated as unset-like here
385+
_filename_fallback = False
386+
if weights_name in {"", "none", "null"}:
387+
weights_name = os.path.basename(os.fspath(path)).lower()
388+
_filename_fallback = True
368389

369390
if model_cls is None:
370391
# Guard: plus-only checkpoints should raise an actionable install error
@@ -385,6 +406,14 @@ def from_checkpoint(cls, path: str | os.PathLike[str], **kwargs: Any) -> RFDETR:
385406
model_cls = klass
386407
break
387408

409+
if _filename_fallback and model_cls is not None:
410+
logger.info(
411+
"pretrain_weights unset in checkpoint %r; inferred model class %s from filename %r",
412+
path,
413+
getattr(model_cls, "__name__", repr(model_cls)),
414+
weights_name,
415+
)
416+
388417
if model_cls is None:
389418
raise ValueError(
390419
f"Could not infer model class from checkpoint at {path!r} "

tests/models/test_from_checkpoint.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,27 @@ def test_characterization_infers_correct_class_namespace(
9696
assert call_kwargs.get("pretrain_weights") == str(tmp_path / "ckpt.pth")
9797
assert result is mock_cls.return_value
9898

99+
@pytest.mark.parametrize(
100+
"missing_value",
101+
[
102+
pytest.param("none", id="bare-none"),
103+
pytest.param("null", id="bare-null"),
104+
pytest.param("", id="empty"),
105+
pytest.param(" None ", id="whitespace-None"),
106+
pytest.param(" ", id="whitespace-only"),
107+
pytest.param(" null ", id="whitespace-null"),
108+
pytest.param(None, id="python-None"),
109+
],
110+
)
111+
def test_namespace_args_falls_back_to_checkpoint_filename_when_pretrain_weights_missing(
112+
self, tmp_path: Path, missing_value: str | None
113+
) -> None:
114+
"""Namespace args: filename fallback fires when pretrain_weights is unset-like."""
115+
ckpt = _ns(missing_value) # type: ignore[arg-type]
116+
_, mock_cls = _call_from_checkpoint(ckpt, tmp_path / "rf-detr-small.pth", "rfdetr.variants.RFDETRSmall")
117+
mock_cls.assert_called_once()
118+
assert mock_cls.call_args.kwargs["num_classes"] == 80
119+
99120

100121
# ---------------------------------------------------------------------------
101122
# Dict args (PTL / converted checkpoints)
@@ -151,6 +172,21 @@ def test_characterization_unknown_pretrain_weights_raises_value_error(self, tmp_
151172
with pytest.raises(ValueError, match="Could not infer model class"):
152173
RFDETR.from_checkpoint(tmp_path / "ckpt.pth")
153174

175+
def test_filename_fallback_unrecognized_name_raises_value_error(self, tmp_path: Path) -> None:
176+
"""ValueError fires via filename-fallback path when filename has no known model token."""
177+
ckpt = {"args": {"pretrain_weights": "none", "num_classes": 80}}
178+
with patch("rfdetr.detr.torch.load", return_value=ckpt):
179+
with pytest.raises(ValueError, match="Could not infer model class"):
180+
RFDETR.from_checkpoint(tmp_path / "finetuned.pth")
181+
182+
@pytest.mark.skipif(_IS_RFDETR_PLUS_AVAILABLE, reason="rfdetr_plus is installed — guard not active")
183+
def test_filename_fallback_xlarge_without_plus_raises_import_error(self, tmp_path: Path) -> None:
184+
"""ImportError fires via filename-fallback path when rfdetr_plus is absent."""
185+
ckpt = {"args": {"pretrain_weights": "none", "num_classes": 80}}
186+
with patch("rfdetr.detr.torch.load", return_value=ckpt):
187+
with pytest.raises(ImportError):
188+
RFDETR.from_checkpoint(tmp_path / "rf-detr-xlarge-starter.pth")
189+
154190
def test_characterization_missing_args_key_raises_key_error(self, tmp_path: Path) -> None:
155191
"""Checkpoint without 'args' key raises KeyError."""
156192
ckpt = {"model": {}}
@@ -314,6 +350,27 @@ def test_falls_back_to_pretrain_weights_without_model_name(self, tmp_path: Path)
314350
_, mock_cls = _call_from_checkpoint(ckpt, tmp_path / "ckpt.pth", "rfdetr.variants.RFDETRSmall")
315351
mock_cls.assert_called_once()
316352

353+
@pytest.mark.parametrize(
354+
"missing_value",
355+
[
356+
pytest.param("none", id="bare-none"),
357+
pytest.param("null", id="bare-null"),
358+
pytest.param("", id="empty"),
359+
pytest.param(" None ", id="whitespace-None"),
360+
pytest.param(" ", id="whitespace-only"),
361+
pytest.param(" null ", id="whitespace-null"),
362+
pytest.param(None, id="python-None"),
363+
],
364+
)
365+
def test_falls_back_to_checkpoint_filename_when_pretrain_weights_missing(
366+
self, tmp_path: Path, missing_value: str | None
367+
) -> None:
368+
"""When pretrain_weights is missing-like, from_checkpoint infers class from checkpoint filename."""
369+
ckpt = {"args": {"pretrain_weights": missing_value, "num_classes": 80}}
370+
_, mock_cls = _call_from_checkpoint(ckpt, tmp_path / "rf-detr-small.pth", "rfdetr.variants.RFDETRSmall")
371+
mock_cls.assert_called_once()
372+
assert mock_cls.call_args.kwargs["num_classes"] == 80
373+
317374
def test_unknown_model_name_falls_back_to_pretrain_weights(self, tmp_path: Path) -> None:
318375
"""Unrecognised model_name falls back to pretrain_weights parsing."""
319376
ckpt = {

tests/try_instantiate_all_models.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@
9292
}
9393

9494

95-
def _test_from_checkpoint(model_instance: object, actual_cls: type, extra_kwargs: dict) -> None:
95+
def _test_from_checkpoint(
96+
model_instance: object, actual_cls: type, extra_kwargs: dict, *, test_starter: bool = True
97+
) -> None:
9698
"""Round-trip a model through from_checkpoint using a temp training checkpoint.
9799
98100
Saves the instantiated model's weights into a minimal training-style checkpoint (``{"args": ..., "model":
@@ -104,6 +106,9 @@ def _test_from_checkpoint(model_instance: object, actual_cls: type, extra_kwargs
104106
actual_cls: The expected model class (e.g. ``RFDETRSmall``).
105107
extra_kwargs: Extra kwargs to pass to ``from_checkpoint`` (e.g.
106108
``{"accept_platform_model_license": True}`` for plus models).
109+
test_starter: When ``True`` (default) also run the starter-like
110+
checkpoint round-trip. Pass ``False`` for non-default resolutions
111+
to avoid running the same resolution-independent test multiple times.
107112
108113
Raises:
109114
AssertionError: If the recovered model is not an instance of *actual_cls*.
@@ -121,6 +126,13 @@ def _test_from_checkpoint(model_instance: object, actual_cls: type, extra_kwargs
121126
),
122127
"model": model_instance.model.model.state_dict(),
123128
}
129+
starter_like_ckpt = {
130+
"args": argparse.Namespace(
131+
pretrain_weights="none",
132+
num_classes=num_classes,
133+
),
134+
"model": model_instance.model.model.state_dict(),
135+
}
124136

125137
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".pth")
126138
os.close(tmp_fd)
@@ -135,6 +147,21 @@ def _test_from_checkpoint(model_instance: object, actual_cls: type, extra_kwargs
135147
finally:
136148
os.unlink(tmp_path)
137149

150+
if test_starter:
151+
starter_tmp_fd, starter_path = tempfile.mkstemp(prefix=f"{actual_cls.size}-starter-", suffix=".pth")
152+
os.close(starter_tmp_fd)
153+
try:
154+
torch.save(starter_like_ckpt, starter_path)
155+
starter_recovered = _rfdetr.from_checkpoint(starter_path, **extra_kwargs)
156+
assert starter_recovered is not None, "from_checkpoint returned None for starter-like checkpoint"
157+
assert hasattr(starter_recovered, "model"), "starter-like from_checkpoint result missing 'model' attribute"
158+
got = type(starter_recovered).__name__
159+
assert isinstance(starter_recovered, actual_cls), (
160+
f"starter-like from_checkpoint returned {got}, expected {actual_cls.__name__}"
161+
)
162+
finally:
163+
os.unlink(starter_path)
164+
138165

139166
def _test_coco_class_name_mapping(model_instance: object) -> None:
140167
"""Verify predict() uses sparse COCO category-ID → class-name mapping.
@@ -232,7 +259,7 @@ def main() -> None:
232259
# from_checkpoint round-trip: save a training-style checkpoint and reload it.
233260
# Pass the real class (not a partial) so `_test_from_checkpoint` can read
234261
# `.size` and `.__name__` and run `isinstance(recovered, actual_cls)`.
235-
_test_from_checkpoint(model_instance, actual_cls, instantiate_kwargs)
262+
_test_from_checkpoint(model_instance, actual_cls, instantiate_kwargs, test_starter=(res is None))
236263

237264
# Inference class-name regression for issue #988 — run on all
238265
# nano-sized pretrained COCO models at default resolution only.

tests/utilities/test_package.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,36 @@ def test_model_context_importable_from_top_level(self) -> None:
168168

169169
assert ModelContext is not None
170170

171+
def test_top_level_import_sets_numpy_complex_alias(self) -> None:
172+
"""Verify rfdetr shim sets np.complex_ in a fresh interpreter with complex_ absent.
173+
174+
Runs in a subprocess so the shim code path is actually executed — importing rfdetr inside pytest is a no-op
175+
because rfdetr is already cached in sys.modules.
176+
"""
177+
result = subprocess.run(
178+
[
179+
sys.executable,
180+
"-c",
181+
(
182+
"import numpy\n"
183+
"if hasattr(numpy, 'complex_'):\n"
184+
" del numpy.complex_\n"
185+
"import rfdetr\n"
186+
"assert hasattr(numpy, 'complex_'), 'shim did not set numpy.complex_'\n"
187+
"assert numpy.complex_ is numpy.complex128, 'complex_ must alias complex128'\n"
188+
),
189+
],
190+
capture_output=True,
191+
text=True,
192+
check=False,
193+
)
194+
assert result.returncode == 0, (
195+
"Subprocess for NumPy complex_ shim failed:\n"
196+
f"return code: {result.returncode}\n"
197+
f"stdout:\n{result.stdout}\n"
198+
f"stderr:\n{result.stderr}"
199+
)
200+
171201
def test_identity_across_import_paths(self) -> None:
172202
"""The same class object must be returned regardless of import path.
173203

0 commit comments

Comments
 (0)