|
| 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() |
0 commit comments