Skip to content

Commit 74e2ee0

Browse files
leeclemnetclaude
andcommitted
feat(cli): adapt raw rf-detr PyTorch-Lightning checkpoints on upload
Detect a raw PTL rf-detr checkpoint (`pytorch-lightning_version` key) in _process_rfdetr and rebuild it into an upload-ready bundle: lazy-import rfdetr (capability-guarded on RFDETR.export_for_roboflow), load via RFDETR.from_checkpoint, falling back to an SDK-local model_type→class map (_RFDETR_MODEL_TYPE_TO_CLASS — also the single source for the supported-type check) when the checkpoint lacks the metadata rf-detr needs to infer its class, then call export_for_roboflow to write weights.pt (args with resolution) + class_names.txt. The legacy path is unchanged and imports no rfdetr. Depends on rf-detr's export_for_roboflow (roboflow/rf-detr#1086); capability- guarded so it works once that release ships. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 107fb34 commit 74e2ee0

3 files changed

Lines changed: 243 additions & 21 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ module = [
116116
# ipywidgets is an optional dependency
117117
"ipywidgets.*",
118118
"requests_toolbelt.*",
119+
"rfdetr.*",
119120
"torch.*",
120121
"ultralytics.*",
121122
]

roboflow/util/model_processor.py

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,30 @@
2121
)
2222
from roboflow.util.versions import print_warn_for_wrong_dependencies_versions
2323

24+
# Minimum rf-detr release shipping `RFDETR.export_for_roboflow`.
25+
RFDETR_MIN_VERSION = "1.8.0" # TODO: pin once rf-detr release is cut
26+
27+
# rf-detr model_type -> RFDETR subclass name. Single source of truth for both the
28+
# supported-type check and the `from_checkpoint` fallback (used when a raw checkpoint
29+
# lacks the metadata rf-detr needs to infer its own class).
30+
_RFDETR_MODEL_TYPE_TO_CLASS = {
31+
# Detection
32+
"rfdetr-base": "RFDETRBase",
33+
"rfdetr-nano": "RFDETRNano",
34+
"rfdetr-small": "RFDETRSmall",
35+
"rfdetr-medium": "RFDETRMedium",
36+
"rfdetr-large": "RFDETRLarge",
37+
"rfdetr-xlarge": "RFDETRXLarge",
38+
"rfdetr-2xlarge": "RFDETR2XLarge",
39+
# Segmentation
40+
"rfdetr-seg-nano": "RFDETRSegNano",
41+
"rfdetr-seg-small": "RFDETRSegSmall",
42+
"rfdetr-seg-medium": "RFDETRSegMedium",
43+
"rfdetr-seg-large": "RFDETRSegLarge",
44+
"rfdetr-seg-xlarge": "RFDETRSegXLarge",
45+
"rfdetr-seg-2xlarge": "RFDETRSeg2XLarge",
46+
}
47+
2448

2549
def task_of_model_type(model_type: str) -> str:
2650
"""Canonical task for a deploy model_type string.
@@ -339,24 +363,35 @@ def _detect_rfdetr_task(checkpoint) -> Optional[str]:
339363
return None
340364

341365

366+
def _is_ptl_checkpoint(checkpoint) -> bool:
367+
"""True if `checkpoint` is a raw PyTorch-Lightning rf-detr checkpoint dict."""
368+
return isinstance(checkpoint, dict) and "pytorch-lightning_version" in checkpoint
369+
370+
371+
def _require_rfdetr():
372+
"""Lazily import `rfdetr` and verify it ships the upload-bundle helpers.
373+
374+
Raises a RuntimeError with an actionable hint if rfdetr is missing or too old.
375+
"""
376+
try:
377+
import rfdetr
378+
except ImportError:
379+
raise RuntimeError(
380+
"rfdetr is required to upload PyTorch-Lightning rf-detr checkpoints. "
381+
f"Please install it with `pip install 'rfdetr>={RFDETR_MIN_VERSION}'`."
382+
)
383+
384+
if not hasattr(rfdetr.RFDETR, "export_for_roboflow"):
385+
raise RuntimeError(
386+
"The installed rfdetr is too old to upload PyTorch-Lightning rf-detr checkpoints. "
387+
f"Please upgrade it with `pip install --upgrade 'rfdetr>={RFDETR_MIN_VERSION}'`."
388+
)
389+
390+
return rfdetr
391+
392+
342393
def _process_rfdetr(model_type: str, model_path: str, filename: str) -> tuple[str, str]:
343-
_supported_types = [
344-
# Detection models
345-
"rfdetr-base",
346-
"rfdetr-nano",
347-
"rfdetr-small",
348-
"rfdetr-medium",
349-
"rfdetr-large",
350-
"rfdetr-xlarge",
351-
"rfdetr-2xlarge",
352-
# Segmentation models
353-
"rfdetr-seg-nano",
354-
"rfdetr-seg-small",
355-
"rfdetr-seg-medium",
356-
"rfdetr-seg-large",
357-
"rfdetr-seg-xlarge",
358-
"rfdetr-seg-2xlarge",
359-
]
394+
_supported_types = list(_RFDETR_MODEL_TYPE_TO_CLASS)
360395
if model_type not in _supported_types:
361396
raise ValueError(f"Model type {model_type} not supported. Supported types are {_supported_types}")
362397

@@ -382,11 +417,25 @@ def _process_rfdetr(model_type: str, model_path: str, filename: str) -> tuple[st
382417
f".pt is a '{detected_task}' rfdetr checkpoint. Use a matching model_type."
383418
)
384419

385-
get_classnames_txt_for_rfdetr(model_path, pt_file, checkpoint=checkpoint)
420+
if _is_ptl_checkpoint(checkpoint):
421+
# Raw PyTorch-Lightning checkpoint: let rf-detr rebuild a proper upload
422+
# bundle (weights.pt with `args.resolution` + class_names.txt).
423+
rfdetr = _require_rfdetr()
424+
pth = os.path.join(model_path, pt_file)
425+
try:
426+
model = rfdetr.RFDETR.from_checkpoint(pth)
427+
except ValueError:
428+
# Checkpoint lacks model_name/pretrain_weights signals; fall back to
429+
# the already-validated user-provided model_type to pick the subclass.
430+
model_cls = getattr(rfdetr, _RFDETR_MODEL_TYPE_TO_CLASS[model_type])
431+
model = model_cls(pretrain_weights=pth)
432+
model.export_for_roboflow(model_path) # writes weights.pt + class_names.txt
433+
else:
434+
get_classnames_txt_for_rfdetr(model_path, pt_file, checkpoint=checkpoint)
386435

387-
# Copy the .pt file to weights.pt if not already named weights.pt
388-
if pt_file != "weights.pt":
389-
shutil.copy(os.path.join(model_path, pt_file), os.path.join(model_path, "weights.pt"))
436+
# Copy the .pt file to weights.pt if not already named weights.pt
437+
if pt_file != "weights.pt":
438+
shutil.copy(os.path.join(model_path, pt_file), os.path.join(model_path, "weights.pt"))
390439

391440
required_files = ["weights.pt"]
392441

tests/util/test_model_processor.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import os
2+
import sys
23
import tempfile
34
import unittest
5+
import zipfile
46
from types import SimpleNamespace
7+
from unittest import mock
8+
9+
try:
10+
# torch is an optional, lazily-imported SDK dependency; absent in CI. Tests that
11+
# round-trip a real checkpoint through `_process_rfdetr` are skipped without it.
12+
import torch
13+
14+
_HAS_TORCH = True
15+
except ImportError:
16+
_HAS_TORCH = False
517

618
from roboflow.config import TASK_CLS, TASK_DET, TASK_OBB, TASK_POSE, TASK_SEG, TASK_SEM
719
from roboflow.util.model_processor import (
20+
_RFDETR_MODEL_TYPE_TO_CLASS,
821
_detect_rfdetr_task,
922
_detect_yolo_task,
23+
_is_ptl_checkpoint,
24+
_process_rfdetr,
25+
_require_rfdetr,
1026
get_classnames_txt_for_rfdetr,
1127
task_of_model_type,
1228
)
@@ -110,5 +126,161 @@ def test_namespace_args(self):
110126
)
111127

112128

129+
class RfdetrModelTypeToClassTest(unittest.TestCase):
130+
def test_representative_mappings(self):
131+
self.assertEqual(_RFDETR_MODEL_TYPE_TO_CLASS["rfdetr-seg-medium"], "RFDETRSegMedium")
132+
self.assertEqual(_RFDETR_MODEL_TYPE_TO_CLASS["rfdetr-base"], "RFDETRBase")
133+
134+
def test_keys_are_rfdetr_types_and_values_are_class_names(self):
135+
for model_type, class_name in _RFDETR_MODEL_TYPE_TO_CLASS.items():
136+
self.assertTrue(model_type.startswith("rfdetr-"), model_type)
137+
self.assertTrue(class_name.startswith("RFDETR"), class_name)
138+
# Segmentation types must map to Seg classes (and detection types must not).
139+
for model_type, class_name in _RFDETR_MODEL_TYPE_TO_CLASS.items():
140+
self.assertEqual("seg" in model_type, "Seg" in class_name, model_type)
141+
142+
143+
class IsPtlCheckpointTest(unittest.TestCase):
144+
def test_true_when_lightning_version_present(self):
145+
self.assertTrue(_is_ptl_checkpoint({"pytorch-lightning_version": "2.1.0", "args": {}}))
146+
147+
def test_false_for_plain_checkpoint(self):
148+
self.assertFalse(_is_ptl_checkpoint({"args": {}, "model": {}}))
149+
150+
def test_false_for_non_dict(self):
151+
self.assertFalse(_is_ptl_checkpoint(None))
152+
self.assertFalse(_is_ptl_checkpoint(SimpleNamespace(**{"pytorch-lightning_version": "2.1.0"})))
153+
154+
155+
class _StubBundleModel:
156+
"""Stub rf-detr model whose export_for_roboflow writes a dummy bundle on disk."""
157+
158+
def __init__(self, class_names=("cat", "dog")):
159+
self.class_names = list(class_names)
160+
161+
def export_for_roboflow(self, output_dir):
162+
torch.save({"dummy": True}, os.path.join(output_dir, "weights.pt"))
163+
with open(os.path.join(output_dir, "class_names.txt"), "w") as f:
164+
for name in self.class_names:
165+
f.write(name + "\n")
166+
167+
168+
def _make_fake_rfdetr(*, from_checkpoint_raises=False, capabilities=True):
169+
"""Build a fake `rfdetr` module for injection via sys.modules."""
170+
171+
stub_model = _StubBundleModel()
172+
calls = {"from_checkpoint": 0, "fallback_constructed": 0, "constructor_kwargs": None}
173+
174+
class _RFDETR:
175+
@staticmethod
176+
def from_checkpoint(path):
177+
calls["from_checkpoint"] += 1
178+
if from_checkpoint_raises:
179+
raise ValueError("cannot infer model class")
180+
return stub_model
181+
182+
class _SizedModel(_StubBundleModel):
183+
def __init__(self, *, pretrain_weights):
184+
super().__init__()
185+
calls["fallback_constructed"] += 1
186+
calls["constructor_kwargs"] = {"pretrain_weights": pretrain_weights}
187+
188+
module = SimpleNamespace()
189+
module.RFDETR = _RFDETR
190+
# The SDK fallback resolves the subclass by name via _RFDETR_MODEL_TYPE_TO_CLASS,
191+
# e.g. "rfdetr-seg-medium" -> getattr(rfdetr, "RFDETRSegMedium").
192+
module.RFDETRSegMedium = _SizedModel
193+
194+
if capabilities:
195+
_RFDETR.export_for_roboflow = _StubBundleModel.export_for_roboflow # capability marker on class
196+
197+
module._calls = calls
198+
return module
199+
200+
201+
class RequireRfdetrTest(unittest.TestCase):
202+
def test_raises_when_not_installed(self):
203+
with mock.patch.dict(sys.modules, {"rfdetr": None}):
204+
with self.assertRaises(RuntimeError) as ctx:
205+
_require_rfdetr()
206+
self.assertIn("pip install", str(ctx.exception).lower())
207+
self.assertIn("rfdetr", str(ctx.exception).lower())
208+
209+
def test_raises_when_capability_missing(self):
210+
# rfdetr present but RFDETR lacks export_for_roboflow (too old)
211+
fake = SimpleNamespace(RFDETR=type("RFDETR", (), {}))
212+
with mock.patch.dict(sys.modules, {"rfdetr": fake}):
213+
with self.assertRaises(RuntimeError) as ctx:
214+
_require_rfdetr()
215+
self.assertIn("upgrade", str(ctx.exception).lower())
216+
217+
def test_returns_module_when_capable(self):
218+
fake = _make_fake_rfdetr()
219+
with mock.patch.dict(sys.modules, {"rfdetr": fake}):
220+
self.assertIs(_require_rfdetr(), fake)
221+
222+
223+
@unittest.skipUnless(_HAS_TORCH, "requires torch")
224+
class ProcessRfdetrPtlTest(unittest.TestCase):
225+
def _write_ptl_checkpoint(self, model_path, *, segmentation_head=False, class_names=("cat", "dog")):
226+
checkpoint = {
227+
"pytorch-lightning_version": "2.1.0",
228+
"args": {"segmentation_head": segmentation_head, "class_names": list(class_names)},
229+
}
230+
torch.save(checkpoint, os.path.join(model_path, "checkpoint_best_ema.pth"))
231+
232+
def test_from_checkpoint_success_produces_bundle(self):
233+
fake = _make_fake_rfdetr()
234+
with tempfile.TemporaryDirectory() as model_path:
235+
self._write_ptl_checkpoint(model_path)
236+
with mock.patch.dict(sys.modules, {"rfdetr": fake}):
237+
zip_name, model_type = _process_rfdetr("rfdetr-base", model_path, "checkpoint_best_ema.pth")
238+
self.assertEqual(model_type, "rfdetr-base")
239+
self.assertEqual(fake._calls["from_checkpoint"], 1)
240+
self.assertEqual(fake._calls["fallback_constructed"], 0)
241+
with zipfile.ZipFile(os.path.join(model_path, zip_name)) as z:
242+
self.assertIn("weights.pt", z.namelist())
243+
244+
def test_from_checkpoint_valueerror_falls_back_to_model_type(self):
245+
fake = _make_fake_rfdetr(from_checkpoint_raises=True)
246+
with tempfile.TemporaryDirectory() as model_path:
247+
self._write_ptl_checkpoint(model_path, segmentation_head=True)
248+
pth = os.path.join(model_path, "checkpoint_best_ema.pth")
249+
with mock.patch.dict(sys.modules, {"rfdetr": fake}):
250+
zip_name, model_type = _process_rfdetr("rfdetr-seg-medium", model_path, "checkpoint_best_ema.pth")
251+
self.assertEqual(model_type, "rfdetr-seg-medium")
252+
self.assertEqual(fake._calls["from_checkpoint"], 1)
253+
self.assertEqual(fake._calls["fallback_constructed"], 1)
254+
self.assertEqual(fake._calls["constructor_kwargs"], {"pretrain_weights": pth})
255+
with zipfile.ZipFile(os.path.join(model_path, zip_name)) as z:
256+
self.assertIn("weights.pt", z.namelist())
257+
258+
def test_ptl_path_raises_when_rfdetr_absent(self):
259+
with tempfile.TemporaryDirectory() as model_path:
260+
self._write_ptl_checkpoint(model_path)
261+
with mock.patch.dict(sys.modules, {"rfdetr": None}):
262+
with self.assertRaises(RuntimeError):
263+
_process_rfdetr("rfdetr-base", model_path, "checkpoint_best_ema.pth")
264+
265+
266+
@unittest.skipUnless(_HAS_TORCH, "requires torch")
267+
class ProcessRfdetrLegacyTest(unittest.TestCase):
268+
def _write_legacy_checkpoint(self, model_path):
269+
checkpoint = {"args": {"segmentation_head": False, "class_names": ["cat", "dog"]}}
270+
torch.save(checkpoint, os.path.join(model_path, "weights.pt"))
271+
272+
def test_legacy_path_produces_bundle_without_importing_rfdetr(self):
273+
with tempfile.TemporaryDirectory() as model_path:
274+
self._write_legacy_checkpoint(model_path)
275+
# Make any attempt to import rfdetr fail loudly.
276+
with mock.patch.dict(sys.modules, {"rfdetr": None}):
277+
zip_name, model_type = _process_rfdetr("rfdetr-base", model_path, "weights.pt")
278+
self.assertEqual(model_type, "rfdetr-base")
279+
with zipfile.ZipFile(os.path.join(model_path, zip_name)) as z:
280+
names = z.namelist()
281+
self.assertIn("weights.pt", names)
282+
self.assertIn("class_names.txt", names)
283+
284+
113285
if __name__ == "__main__":
114286
unittest.main()

0 commit comments

Comments
 (0)