Skip to content

Commit 96c0dd1

Browse files
authored
Merge pull request #496 from roboflow/sb/rfdetr-keypoint-preview-deploy
Support deployment of RFDETRKeypointPreview weights
2 parents 07cb3d1 + 48f9274 commit 96c0dd1

3 files changed

Lines changed: 103 additions & 20 deletions

File tree

requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ idna>=3.7
33
cycler
44
kiwisolver>=1.3.1
55
matplotlib
6-
numpy>=1.18.5,<2.5 # 2.5.0 ships type stubs using 3.12+ `type` syntax that mypy (pinned to 3.10) rejects
6+
# numpy 2.4 ships PEP 695 `type` statements in its stubs, which mypy rejects
7+
# under python_version=3.10 (see [tool.mypy] in pyproject.toml). Cap below 2.4,
8+
# matching rf-detr's typing constraint.
9+
numpy>=1.18.5,<2.4
710
opencv-python-headless==4.10.0.84
811
Pillow>=7.1.2
912
# https://github.com/roboflow/roboflow-python/issues/390

roboflow/util/model_processor.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@
107107
"rfdetr-seg-large": "RFDETRSegLarge",
108108
"rfdetr-seg-xlarge": "RFDETRSegXLarge",
109109
"rfdetr-seg-2xlarge": "RFDETRSeg2XLarge",
110+
# Keypoint detection
111+
"rfdetr-keypoint-preview": "RFDETRKeypointPreview",
110112
}
111113

112114
SUPPORTED_RFDETR_TYPES = tuple(_RFDETR_MODEL_TYPE_TO_CLASS)
@@ -149,6 +151,8 @@
149151
"rfdetr-seg-large": 42,
150152
"rfdetr-seg-xlarge": 52,
151153
"rfdetr-seg-2xlarge": 64,
154+
# Keypoint (576 / patch_size 12 = 48)
155+
"rfdetr-keypoint-preview": 48,
152156
}
153157

154158

@@ -240,8 +244,13 @@ def task_of_model_type(model_type: str) -> str:
240244
241245
Non-detect tasks double as the model_type suffix token
242246
(e.g. 'yolov11-seg' -> TASK_SEG). Plain 'yolov11' / 'rfdetr-base' -> TASK_DET.
247+
248+
Keypoint/pose models may spell the token as either 'pose' (Ultralytics) or
249+
'keypoint' (rf-detr, e.g. 'rfdetr-keypoint-preview'); both map to TASK_POSE.
243250
"""
244251
s = model_type.lower()
252+
if "keypoint" in s:
253+
return TASK_POSE
245254
for task in (TASK_SEM, TASK_SEG, TASK_POSE, TASK_CLS, TASK_OBB):
246255
if task in s:
247256
return task
@@ -813,27 +822,32 @@ def _process_yolo(
813822
def _detect_rfdetr_task(checkpoint: Any) -> str | None:
814823
"""Detect the training task of an rf-detr checkpoint.
815824
816-
rf-detr currently only supports weight upload for detection and instance
817-
segmentation. Modern checkpoints (rf-detr v1.7+) store the Python class
818-
name at `checkpoint["model_name"]` (e.g. 'RFDETRNano' vs 'RFDETRSegNano');
819-
older checkpoints — including those downloaded from Roboflow — lack that
820-
field but always carry `args.segmentation_head: bool`.
825+
rf-detr supports weight upload for detection, instance segmentation, and
826+
keypoint detection. Modern checkpoints (rf-detr v1.7+) store the Python
827+
class name at `checkpoint["model_name"]` (e.g. 'RFDETRNano' vs
828+
'RFDETRSegNano' vs 'RFDETRKeypointPreview').
829+
830+
The deploy bundle written by rf-detr's `export_for_roboflow` only serialises
831+
`{"model", "args"}` — it drops `model_name` — so detection must also work
832+
from `args`: keypoint checkpoints carry a non-empty `args.num_keypoints_per_class`,
833+
and detection/segmentation checkpoints carry `args.segmentation_head: bool`.
821834
"""
822835
if not isinstance(checkpoint, dict):
823836
return None
824837
model_name = checkpoint.get("model_name")
825838
if isinstance(model_name, str):
826839
name = model_name.lower()
827-
# Keypoint rf-detr checkpoints (e.g. 'RFDETRKeypointPreview') are not a
828-
# supported upload type; classify them as pose so the task check rejects
829-
# them instead of silently uploading a keypoint model as detection.
830840
if "keypoint" in name:
831841
return TASK_POSE
832842
return TASK_SEG if TASK_SEG in name else TASK_DET
833843
raw_args = checkpoint.get("args")
834844
if raw_args is None:
835845
return None
836846
args = _checkpoint_args_as_dict(raw_args)
847+
# Keypoint checkpoints carry num_keypoints_per_class; classify them as pose so it agrees
848+
# with task_of_model_type('rfdetr-keypoint-preview') == TASK_POSE and the upload proceeds.
849+
if args.get("num_keypoints_per_class"):
850+
return TASK_POSE
837851
segmentation_head = args.get("segmentation_head")
838852
if segmentation_head is True:
839853
return TASK_SEG
@@ -1050,8 +1064,9 @@ def _process_rfdetr(
10501064
checkpoint_path = _find_rfdetr_checkpoint(model_path, filename, warnings)
10511065
checkpoint = _load_checkpoint(torch, checkpoint_path, map_location="cpu")
10521066

1053-
# Task detection + mismatch runs for every checkpoint shape (it also rejects
1054-
# keypoint rf-detr, which is not a supported upload type).
1067+
# Task detection + mismatch runs for every checkpoint shape, so a checkpoint whose
1068+
# task disagrees with model_type (e.g. a keypoint checkpoint uploaded as 'rfdetr-base')
1069+
# is rejected instead of packaged under the wrong task.
10551070
detected_task = _detect_rfdetr_task(checkpoint)
10561071
if detected_task and detected_task != task_of_model_type(model_type):
10571072
raise TaskMismatchError(

tests/util/test_model_processor.py

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def test_segment(self):
6262

6363
def test_pose(self):
6464
self.assertEqual(task_of_model_type("yolov11-pose"), TASK_POSE)
65+
self.assertEqual(task_of_model_type("rfdetr-keypoint-preview"), TASK_POSE)
6566

6667
def test_classify(self):
6768
self.assertEqual(task_of_model_type("yolov11-cls"), TASK_CLS)
@@ -102,11 +103,19 @@ def test_detection_model_names(self):
102103
for name in ("RFDETRNano", "RFDETRSmall", "RFDETRMedium", "RFDETRLarge", "RFDETRXLarge"):
103104
self.assertEqual(_detect_rfdetr_task({"model_name": name}), TASK_DET, name)
104105

105-
def test_keypoint_model_name_returns_pose(self):
106-
# Keypoint checkpoints are unsupported; classifying them as pose lets the
107-
# model_type task check reject them instead of uploading them as detection.
106+
def test_keypoint_model_names(self):
108107
self.assertEqual(_detect_rfdetr_task({"model_name": "RFDETRKeypointPreview"}), TASK_POSE)
109108

109+
def test_keypoint_args_fallback(self):
110+
# The deploy bundle from export_for_roboflow carries `args` but not
111+
# `model_name`; a non-empty `num_keypoints_per_class` marks a keypoint model.
112+
self.assertEqual(_detect_rfdetr_task({"args": SimpleNamespace(num_keypoints_per_class=[0, 17])}), TASK_POSE)
113+
self.assertEqual(_detect_rfdetr_task({"args": {"num_keypoints_per_class": [0, 17]}}), TASK_POSE)
114+
# Empty / absent keypoint schema must NOT be treated as a keypoint model.
115+
self.assertEqual(
116+
_detect_rfdetr_task({"args": {"num_keypoints_per_class": [], "segmentation_head": False}}), TASK_DET
117+
)
118+
110119
def test_segmentation_head_fallback(self):
111120
# Roboflow-hosted rf-detr .pt downloads lack `model_name` but always carry
112121
# `args.segmentation_head`. Cover both namespace and dict shapes.
@@ -454,6 +463,37 @@ def test_rfdetr_falls_back_to_discovered_checkpoint(self):
454463
finally:
455464
bundle.cleanup()
456465

466+
def test_rfdetr_keypoint_exported_checkpoint_packages(self):
467+
# The primary feature: an exported keypoint deploy-checkpoint (non-PTL shape,
468+
# args carries class_names + num_keypoints_per_class) packages successfully as
469+
# 'rfdetr-keypoint-preview'. num_keypoints_per_class marks it pose, which matches
470+
# the model_type's task, so it passes the task check and copies weights.pt.
471+
with tempfile.TemporaryDirectory() as tmp:
472+
model_dir = Path(tmp)
473+
(model_dir / "weights.pt").write_bytes(b"checkpoint")
474+
torch = _fake_torch({"args": {"class_names": ["goal"], "num_keypoints_per_class": [0, 17]}})
475+
with _import_patch({"torch": torch}):
476+
bundle = package_custom_weights("rfdetr-keypoint-preview", str(model_dir), filename="weights.pt")
477+
try:
478+
self.assertEqual(bundle.model_type, "rfdetr-keypoint-preview")
479+
with zipfile.ZipFile(bundle.archive_path) as archive:
480+
names = archive.namelist()
481+
self.assertIn("weights.pt", names)
482+
self.assertIn("class_names.txt", names)
483+
finally:
484+
bundle.cleanup()
485+
486+
def test_rfdetr_keypoint_checkpoint_rejected_as_detection_type(self):
487+
# The same keypoint checkpoint uploaded under a detection model_type is a task
488+
# mismatch (pose != detect) and must be rejected, not silently packaged.
489+
with tempfile.TemporaryDirectory() as tmp:
490+
model_dir = Path(tmp)
491+
(model_dir / "weights.pt").write_bytes(b"checkpoint")
492+
torch = _fake_torch({"args": {"class_names": ["goal"], "num_keypoints_per_class": [0, 17]}})
493+
with _import_patch({"torch": torch}):
494+
with self.assertRaises(TaskMismatchError):
495+
package_custom_weights("rfdetr-base", str(model_dir), filename="weights.pt")
496+
457497
def test_rfdetr_without_any_checkpoint_raises(self):
458498
with tempfile.TemporaryDirectory() as tmp:
459499
torch = _fake_torch({})
@@ -824,6 +864,7 @@ class RfdetrModelTypeToClassTest(unittest.TestCase):
824864
def test_representative_mappings(self):
825865
self.assertEqual(_RFDETR_MODEL_TYPE_TO_CLASS["rfdetr-seg-medium"], "RFDETRSegMedium")
826866
self.assertEqual(_RFDETR_MODEL_TYPE_TO_CLASS["rfdetr-base"], "RFDETRBase")
867+
self.assertEqual(_RFDETR_MODEL_TYPE_TO_CLASS["rfdetr-keypoint-preview"], "RFDETRKeypointPreview")
827868

828869
def test_keys_are_rfdetr_types_and_values_are_class_names(self):
829870
for model_type, class_name in _RFDETR_MODEL_TYPE_TO_CLASS.items():
@@ -878,8 +919,10 @@ def __init__(self, *, pretrain_weights):
878919
module = SimpleNamespace()
879920
module.RFDETR = _RFDETR
880921
# The fallback resolves the subclass by name via _RFDETR_MODEL_TYPE_TO_CLASS,
881-
# e.g. "rfdetr-seg-medium" -> getattr(rfdetr, "RFDETRSegMedium").
922+
# e.g. "rfdetr-seg-medium" -> getattr(rfdetr, "RFDETRSegMedium") and
923+
# "rfdetr-keypoint-preview" -> getattr(rfdetr, "RFDETRKeypointPreview").
882924
module.RFDETRSegMedium = _SizedModel
925+
module.RFDETRKeypointPreview = _SizedModel
883926
if capabilities:
884927
_RFDETR.export_for_roboflow = _StubBundleModel.export_for_roboflow # capability marker
885928
module._calls = calls
@@ -910,13 +953,13 @@ def test_returns_module_when_capable(self):
910953
class PackageRfdetrPtlTest(unittest.TestCase):
911954
"""PyTorch-Lightning rf-detr checkpoints are rebuilt via rfdetr into build_dir."""
912955

913-
def _package(self, model_type, fake_rfdetr, *, segmentation_head=False):
956+
def _package(self, model_type, fake_rfdetr, *, segmentation_head=False, num_keypoints_per_class=None):
914957
with tempfile.TemporaryDirectory() as model_dir:
915958
(Path(model_dir) / "checkpoint_best_ema.pth").write_bytes(b"raw-ptl")
916-
ckpt = {
917-
"pytorch-lightning_version": "2.1.0",
918-
"args": {"segmentation_head": segmentation_head, "class_names": ["cat", "dog"]},
919-
}
959+
args = {"segmentation_head": segmentation_head, "class_names": ["cat", "dog"]}
960+
if num_keypoints_per_class is not None:
961+
args["num_keypoints_per_class"] = num_keypoints_per_class
962+
ckpt = {"pytorch-lightning_version": "2.1.0", "args": args}
920963
torch = _fake_torch(ckpt)
921964
with _import_patch({"torch": torch}), mock.patch.dict(sys.modules, {"rfdetr": fake_rfdetr}):
922965
bundle = package_custom_weights(model_type, model_dir, filename="checkpoint_best_ema.pth")
@@ -944,6 +987,28 @@ def test_from_checkpoint_valueerror_falls_back_to_model_type(self):
944987
self.assertEqual(fake._calls["fallback_constructed"], 1)
945988
self.assertIn("weights.pt", names)
946989

990+
def test_keypoint_from_checkpoint_success_produces_bundle(self):
991+
# A keypoint PTL checkpoint (args.num_keypoints_per_class marks pose, matching
992+
# the 'rfdetr-keypoint-preview' model_type) rebuilds via rfdetr.from_checkpoint.
993+
fake = _make_fake_rfdetr()
994+
bundle, names = self._package("rfdetr-keypoint-preview", fake, num_keypoints_per_class=[0, 17])
995+
self.assertEqual(bundle.model_type, "rfdetr-keypoint-preview")
996+
self.assertEqual(fake._calls["from_checkpoint"], 1)
997+
self.assertEqual(fake._calls["fallback_constructed"], 0)
998+
self.assertIn("weights.pt", names)
999+
self.assertIn("class_names.txt", names)
1000+
1001+
def test_keypoint_from_checkpoint_valueerror_falls_back_to_model_type(self):
1002+
# When from_checkpoint can't infer the class, the fallback resolves the
1003+
# RFDETRKeypointPreview subclass from _RFDETR_MODEL_TYPE_TO_CLASS and rebuilds.
1004+
fake = _make_fake_rfdetr(from_checkpoint_raises=True)
1005+
bundle, names = self._package("rfdetr-keypoint-preview", fake, num_keypoints_per_class=[0, 17])
1006+
self.assertEqual(bundle.model_type, "rfdetr-keypoint-preview")
1007+
self.assertEqual(fake._calls["from_checkpoint"], 1)
1008+
self.assertEqual(fake._calls["fallback_constructed"], 1)
1009+
self.assertIn("weights.pt", names)
1010+
self.assertIn("class_names.txt", names)
1011+
9471012
def test_ptl_path_raises_when_rfdetr_absent(self):
9481013
with tempfile.TemporaryDirectory() as model_dir:
9491014
(Path(model_dir) / "checkpoint_best_ema.pth").write_bytes(b"raw-ptl")

0 commit comments

Comments
 (0)