Skip to content

Commit 1b395e3

Browse files
committed
feat: Add RT-DETR models to the pipeline
Signed-off-by: dronefreak <kumaar324@gmail.com>
1 parent a870388 commit 1b395e3

10 files changed

Lines changed: 624 additions & 24 deletions

File tree

scripts/convert_to_safetensors.py

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
r"""Convert VisDrone model checkpoints to safetensors format for HuggingFace upload.
2+
3+
Supports:
4+
- Torchvision models (.pt with model_state_dict key)
5+
- Ultralytics YOLO models (.pt Ultralytics format)
6+
7+
Output: a .safetensors file plus a metadata.json sidecar.
8+
9+
Usage:
10+
# Torchvision
11+
python scripts/convert_to_safetensors.py \\
12+
--checkpoint outputs/fasterrcnn_200ep/best.pt \\
13+
--model fasterrcnn_resnet50 \\
14+
--output-dir hf_upload/fasterrcnn_resnet50
15+
16+
# YOLO
17+
python scripts/convert_to_safetensors.py \\
18+
--checkpoint outputs/yolov8n_200ep/yolov8n/weights/best.pt \\
19+
--model yolov8n \\
20+
--output-dir hf_upload/yolov8n
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import argparse
26+
import json
27+
import sys
28+
from pathlib import Path
29+
30+
import torch
31+
32+
_VISDRONE_CLASSES = [
33+
"pedestrian",
34+
"people",
35+
"bicycle",
36+
"car",
37+
"van",
38+
"truck",
39+
"tricycle",
40+
"awning-tricycle",
41+
"bus",
42+
"motor",
43+
]
44+
45+
_YOLO_ARCHITECTURES = {
46+
"yolov8": "Ultralytics YOLOv8",
47+
"yolov9": "Ultralytics YOLOv9",
48+
"yolov10": "Ultralytics YOLOv10",
49+
"yolo11": "Ultralytics YOLO11",
50+
"yolo26": "Ultralytics YOLO26",
51+
}
52+
53+
_TORCHVISION_ARCHITECTURES = {
54+
"fasterrcnn_resnet50": "Faster R-CNN ResNet50 FPN",
55+
"fasterrcnn_mobilenet": "Faster R-CNN MobileNetV3 Large FPN",
56+
"fcos_resnet50": "FCOS ResNet50 FPN",
57+
"retinanet_resnet50": "RetinaNet ResNet50 FPN",
58+
}
59+
60+
61+
def _is_yolo(model_name: str) -> bool:
62+
return model_name.lower().startswith("yolo")
63+
64+
65+
def parse_args() -> argparse.Namespace:
66+
parser = argparse.ArgumentParser(
67+
description="Convert VisDrone checkpoint → safetensors",
68+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
69+
)
70+
parser.add_argument("--checkpoint", required=True, help="Path to .pt checkpoint")
71+
parser.add_argument(
72+
"--model", required=True, help="Model name (e.g. yolov8n, fasterrcnn_resnet50)"
73+
)
74+
parser.add_argument("--output-dir", default="hf_upload", help="Output directory")
75+
parser.add_argument(
76+
"--num-classes",
77+
type=int,
78+
default=11,
79+
help="Number of VisDrone classes (default 11, ignoring 'ignored-regions')",
80+
)
81+
parser.add_argument(
82+
"--extra-meta",
83+
nargs="*",
84+
metavar="KEY=VALUE",
85+
help="Extra metadata pairs, e.g. --extra-meta epochs=200 f1=0.667",
86+
)
87+
return parser.parse_args()
88+
89+
90+
# ---------------------------------------------------------------------------
91+
# State-dict extraction
92+
# ---------------------------------------------------------------------------
93+
94+
95+
def _load_torchvision_state_dict(checkpoint_path: Path) -> tuple[dict[str, torch.Tensor], dict]:
96+
"""Load a torchvision checkpoint and return (state_dict, training_meta)."""
97+
ckpt = torch.load(str(checkpoint_path), map_location="cpu", weights_only=False)
98+
99+
if isinstance(ckpt, dict) and "model_state_dict" in ckpt:
100+
state_dict = ckpt["model_state_dict"]
101+
meta = {
102+
k: v
103+
for k, v in ckpt.items()
104+
if k not in ("model_state_dict", "optimizer_state_dict")
105+
and not isinstance(v, (torch.Tensor, dict))
106+
}
107+
elif isinstance(ckpt, dict):
108+
# Raw state dict saved directly
109+
state_dict = ckpt
110+
meta = {}
111+
else:
112+
raise ValueError(
113+
f"Unrecognised torchvision checkpoint format in {checkpoint_path}.\n"
114+
"Expected a dict with 'model_state_dict' key."
115+
)
116+
117+
# Verify all values are tensors
118+
bad = [k for k, v in state_dict.items() if not isinstance(v, torch.Tensor)]
119+
if bad:
120+
raise ValueError(f"State dict contains non-tensor values for keys: {bad[:5]}")
121+
122+
return state_dict, meta
123+
124+
125+
def _load_yolo_state_dict(checkpoint_path: Path) -> tuple[dict[str, torch.Tensor], dict]:
126+
"""Load an Ultralytics YOLO checkpoint and return (state_dict, training_meta)."""
127+
ckpt = torch.load(str(checkpoint_path), map_location="cpu", weights_only=False)
128+
129+
if not isinstance(ckpt, dict):
130+
raise ValueError(
131+
f"Unrecognised YOLO checkpoint format in {checkpoint_path}.\n"
132+
f"Expected a dict, got {type(ckpt).__name__}."
133+
)
134+
135+
# Prefer EMA weights (more accurate) over the last-epoch weights
136+
model_obj = ckpt.get("ema") or ckpt.get("model")
137+
if model_obj is None:
138+
raise ValueError(
139+
f"Could not find 'model' or 'ema' key in {checkpoint_path}.\n"
140+
f"Available keys: {list(ckpt.keys())}"
141+
)
142+
143+
# model_obj may be wrapped; unwrap to nn.Module if needed
144+
if hasattr(model_obj, "module"):
145+
model_obj = model_obj.module
146+
147+
state_dict = model_obj.float().state_dict()
148+
149+
meta = {}
150+
for k in ("epoch", "best_fitness", "date", "version"):
151+
if k in ckpt and ckpt[k] is not None:
152+
meta[k] = ckpt[k]
153+
# Extract training args subset
154+
if "train_args" in ckpt and isinstance(ckpt["train_args"], dict):
155+
wanted = ("imgsz", "batch", "lr0", "epochs", "device", "amp")
156+
for k in wanted:
157+
if k in ckpt["train_args"]:
158+
meta[f"train_{k}"] = ckpt["train_args"][k]
159+
160+
return state_dict, meta
161+
162+
163+
# ---------------------------------------------------------------------------
164+
# Main conversion
165+
# ---------------------------------------------------------------------------
166+
167+
168+
def convert(
169+
checkpoint_path: Path,
170+
model_name: str,
171+
output_dir: Path,
172+
num_classes: int,
173+
extra_meta: dict[str, str],
174+
) -> None:
175+
from safetensors.torch import save_file # noqa: PLC0415 (lazy import)
176+
177+
print(f"Loading checkpoint: {checkpoint_path}")
178+
179+
if _is_yolo(model_name):
180+
state_dict, training_meta = _load_yolo_state_dict(checkpoint_path)
181+
arch_family = next(
182+
(v for k, v in _YOLO_ARCHITECTURES.items() if model_name.lower().startswith(k)),
183+
f"Ultralytics YOLO ({model_name})",
184+
)
185+
else:
186+
state_dict, training_meta = _load_torchvision_state_dict(checkpoint_path)
187+
arch_family = _TORCHVISION_ARCHITECTURES.get(model_name, model_name)
188+
189+
print(f" Architecture : {arch_family}")
190+
print(f" Tensors : {len(state_dict)}")
191+
total_params = sum(t.numel() for t in state_dict.values())
192+
print(f" Parameters : {total_params:,}")
193+
194+
# Safetensors requires all tensors to be contiguous
195+
state_dict = {k: v.contiguous() for k, v in state_dict.items()}
196+
197+
# Build metadata (all values must be strings)
198+
meta: dict[str, str] = {
199+
"model_name": model_name,
200+
"architecture": arch_family,
201+
"dataset": "VisDrone2019-DET",
202+
"num_classes": str(num_classes),
203+
"class_names": ",".join(_VISDRONE_CLASSES[:num_classes]),
204+
"total_params": str(total_params),
205+
"source_file": checkpoint_path.name,
206+
"framework": "pytorch",
207+
"task": "object-detection",
208+
}
209+
for k, v in training_meta.items():
210+
meta[str(k)] = str(v)
211+
meta.update(extra_meta)
212+
213+
# Write outputs
214+
output_dir.mkdir(parents=True, exist_ok=True)
215+
sf_path = output_dir / f"{model_name}.safetensors"
216+
meta_path = output_dir / "metadata.json"
217+
218+
save_file(state_dict, str(sf_path), metadata=meta)
219+
print(f"\n✓ Saved: {sf_path} ({sf_path.stat().st_size / 1e6:.1f} MB)")
220+
221+
# Write a richer sidecar JSON (safetensors metadata values are limited to strings)
222+
with open(meta_path, "w") as f:
223+
json.dump(
224+
{
225+
"model_name": model_name,
226+
"architecture": arch_family,
227+
"dataset": "VisDrone2019-DET",
228+
"num_classes": num_classes,
229+
"class_names": _VISDRONE_CLASSES[:num_classes],
230+
"total_params": total_params,
231+
"training": training_meta,
232+
"extra": extra_meta,
233+
},
234+
f,
235+
indent=2,
236+
default=str,
237+
)
238+
print(f"✓ Saved: {meta_path}")
239+
240+
# Smoke-test: re-load and verify tensor count
241+
_verify(sf_path, len(state_dict))
242+
243+
244+
def _verify(sf_path: Path, expected_count: int) -> None:
245+
"""Reload the safetensors file and assert tensor count matches."""
246+
from safetensors import safe_open # noqa: PLC0415
247+
248+
with safe_open(str(sf_path), framework="pt", device="cpu") as f:
249+
keys = list(f.keys())
250+
251+
if len(keys) != expected_count:
252+
print(
253+
f" [WARN] Expected {expected_count} tensors, found {len(keys)} after reload.",
254+
file=sys.stderr,
255+
)
256+
else:
257+
print(f"✓ Verified: {len(keys)} tensors round-trip OK")
258+
259+
260+
# ---------------------------------------------------------------------------
261+
# Entry point
262+
# ---------------------------------------------------------------------------
263+
264+
265+
def main() -> None:
266+
args = parse_args()
267+
268+
checkpoint_path = Path(args.checkpoint)
269+
if not checkpoint_path.exists():
270+
print(f"Error: checkpoint not found: {checkpoint_path}", file=sys.stderr)
271+
sys.exit(1)
272+
273+
# Parse --extra-meta KEY=VALUE pairs
274+
extra_meta: dict[str, str] = {}
275+
for pair in args.extra_meta or []:
276+
if "=" not in pair:
277+
print(f"Warning: ignoring malformed --extra-meta entry {pair!r} (no '=')")
278+
continue
279+
k, _, v = pair.partition("=")
280+
extra_meta[k.strip()] = v.strip()
281+
282+
convert(
283+
checkpoint_path=checkpoint_path,
284+
model_name=args.model,
285+
output_dir=Path(args.output_dir),
286+
num_classes=args.num_classes,
287+
extra_meta=extra_meta,
288+
)
289+
290+
print(f"\nOutput ready in: {args.output_dir}/")
291+
print("Next step: see docs/HF_UPLOAD_GUIDE.md")
292+
293+
294+
if __name__ == "__main__":
295+
main()

scripts/evaluate.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@
3838

3939
console = Console()
4040

41-
_YOLO_PREFIXES = ("yolo",)
41+
_ULTRALYTICS_PREFIXES = ("yolo", "rtdetr")
4242

4343

4444
def _is_yolo_model(name: str) -> bool:
45-
return name.lower().startswith(_YOLO_PREFIXES)
45+
return name.lower().startswith(_ULTRALYTICS_PREFIXES)
4646

4747

4848
def parse_args() -> argparse.Namespace:
@@ -87,8 +87,9 @@ def evaluate_yolo(
8787
num_classes: int,
8888
device: str,
8989
output_dir: Path,
90+
model_name: str = "",
9091
) -> dict[str, Any]:
91-
"""Evaluate a YOLO model using the Ultralytics val engine.
92+
"""Evaluate a YOLO or RT-DETR model using the Ultralytics val engine.
9293
9394
Converts VisDrone annotations to YOLO format on-the-fly, runs
9495
``model.val()``, and returns the standard Ultralytics metrics dict.
@@ -102,7 +103,20 @@ def evaluate_yolo(
102103

103104
from visdrone_toolkit.yolo_trainer import _VISDRONE_CLASSES, YOLOTrainer
104105

105-
console.print("\n[bold cyan]YOLO evaluation — using Ultralytics val engine[/bold cyan]")
106+
is_rtdetr = model_name.lower().startswith("rtdetr")
107+
if is_rtdetr:
108+
try:
109+
from ultralytics import RTDETR as _LoadClass
110+
except ImportError as err:
111+
raise ImportError("pip install -U ultralytics") from err
112+
family_label = "RT-DETR"
113+
else:
114+
_LoadClass = UltralyticsYOLO
115+
family_label = "YOLO"
116+
117+
console.print(
118+
f"\n[bold cyan]{family_label} evaluation — using Ultralytics val engine[/bold cyan]"
119+
)
106120

107121
names = _VISDRONE_CLASSES[: min(num_classes, len(_VISDRONE_CLASSES))]
108122
trainer = YOLOTrainer.__new__(YOLOTrainer)
@@ -119,7 +133,7 @@ def evaluate_yolo(
119133
annotation_dir,
120134
)
121135

122-
model = UltralyticsYOLO(str(checkpoint_path))
136+
model = _LoadClass(str(checkpoint_path))
123137
results = model.val(
124138
data=str(dataset_yaml),
125139
device=device,
@@ -495,6 +509,7 @@ def main() -> None:
495509
num_classes=args.num_classes,
496510
device=device_str,
497511
output_dir=output_dir,
512+
model_name=args.model,
498513
)
499514
else:
500515
model = load_torchvision_model(

0 commit comments

Comments
 (0)