Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ All notable changes to this project will be documented in this file.
hidden alias of `--existing-annotations preserve`
([#570](https://github.com/roboflow/roboflow-python/pull/570)).

### Removed

- Custom model weight uploads no longer support YOLOv5, YOLOv7, YOLOv9, or
YOLOv10. Existing models remain available for inference, and YOLO dataset
formats remain supported ([#572](https://github.com/roboflow/roboflow-python/pull/572)).

## 1.7.3

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ workspace.upload_dataset(

version = project.version("VERSION_NUMBER")

# upload model weights - yolov10
version.deploy(model_type="yolov10", model_path=f”{HOME}/runs/detect/train/”, filename="weights.pt")
# upload model weights - YOLOv8 Nano
version.deploy(model_type="yolov8n", model_path=f"{HOME}/runs/detect/train/", filename="weights/best.pt")

# run inference (a version may own several trained models; models() returns all of them)
model = version.models()[0]
Expand Down
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ workspace.upload_dataset(

version = project.version("VERSION_NUMBER")

# upload model weights - yolov10
version.deploy(model_type="yolov10", model_path=f”{HOME}/runs/detect/train/”, filename="weights.pt")
# upload model weights - YOLOv8 Nano
version.deploy(model_type="yolov8n", model_path=f"{HOME}/runs/detect/train/", filename="weights/best.pt")

# run inference (a version may own several trained models; models() returns all of them)
model = version.models()[0]
Expand Down
2 changes: 1 addition & 1 deletion roboflow/cli/handlers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def model_infer(
@model_app.command("upload")
def upload_model(
ctx: typer.Context,
model_type: Annotated[str, typer.Option("-t", "--type", help="Model type (e.g. yolov8, yolov5)")],
model_type: Annotated[str, typer.Option("-t", "--type", help="Model type (e.g. yolov8n, yolo26n, rfdetr-nano)")],
model_path: Annotated[str, typer.Option("-m", "--model-path", help="Path to the trained model file")],
project: Annotated[
Optional[list[str]], typer.Option("-p", "--project", help="Project ID (repeatable for multi-project deploy)")
Expand Down
96 changes: 29 additions & 67 deletions roboflow/util/model_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,7 @@
from roboflow.util.versions import get_wrong_dependencies_versions, normalize_yolo_model_type

SUPPORTED_MODELS = (
"yolov5",
"yolov7",
"yolov7-seg",
"yolov8",
"yolov9",
"yolov10",
"yolov11",
"yolov12",
"yolo26",
Expand Down Expand Up @@ -116,12 +111,11 @@
DEFAULT_WEIGHTS_FILENAME = "weights/best.pt"

# YOLO families Roboflow rejects without a size suffix (e.g. `yolov8` must be
# `yolov8n`/`yolov8s`/...). Legacy yolov5/7/9 go through the opt.yaml path and are
# intentionally excluded.
ULTRALYTICS_YOLO_FAMILIES = ("yolov8", "yolov10", "yolov11", "yolov12", "yolo26")
# `yolov8n`/`yolov8s`/...).
ULTRALYTICS_YOLO_FAMILIES = ("yolov8", "yolov11", "yolov12", "yolo26")

# Canonical (depth_multiple, width_multiple) -> size letter for the classic YOLO
# scaling (v5/v8/v9/v10). Newer families (v11+) instead store an explicit ``scale``
# scaling in v8. Newer families (v11+) instead store an explicit ``scale``
# letter in the model yaml, which is read first.
YOLO_DEPTH_WIDTH_TO_SIZE = {
(0.33, 0.25): "n",
Expand Down Expand Up @@ -552,19 +546,6 @@ def _load_checkpoint(torch_module: Any, checkpoint_path: Path, *, map_location:
return torch_module.load(checkpoint_path, **kwargs)


def _legacy_yolo_args(opts: dict[str, Any], opt_path: Path) -> dict[str, Any]:
"""Return required legacy YOLO upload args from opt.yaml."""
if "imgsz" in opts:
image_size = opts["imgsz"]
elif "img_size" in opts:
image_size = opts["img_size"]
else:
raise ModelPackagingError(f"{opt_path} is missing required key 'imgsz' or 'img_size'.")
if "batch_size" not in opts:
raise ModelPackagingError(f"{opt_path} is missing required key 'batch_size'.")
return {"imgsz": image_size, "batch": opts["batch_size"]}


def _infer_yolo_size(model_instance: Any) -> str | None:
"""Infer a YOLO size letter (n/s/m/l/x/...) from a loaded checkpoint.

Expand Down Expand Up @@ -703,8 +684,6 @@ def _process_yolo(
allow_mismatch=allow_dependency_mismatch,
warnings=warnings,
)
elif "yolov10" in model_type:
ultralytics = _import_required_module("ultralytics", "pip install ultralytics")
elif "yolov11" in model_type:
ultralytics = _import_required_module("ultralytics", "pip install 'ultralytics>=8.3.0'")
_check_dependency_version(
Expand Down Expand Up @@ -758,50 +737,33 @@ def _process_yolo(
)

class_names = _class_names_from_model_instance(model_instance)
if any(name in model_type for name in ULTRALYTICS_YOLO_FAMILIES):
if ultralytics is None:
ultralytics = _import_required_module("ultralytics", "pip install ultralytics")
model_yaml = _require_model_attr(model_instance, "yaml", model_type)
if (
"-cls" in model_type
or model_type.startswith("yolov10")
or model_type.startswith("yolov11")
or model_type.startswith("yolov12")
or model_type.startswith("yolo26")
):
if not isinstance(model_yaml, dict) or "nc" not in model_yaml:
raise ModelPackagingError(
f"The {model_type} checkpoint's model config (model.yaml) is missing 'nc'; "
"it does not look like a complete Ultralytics training checkpoint."
)
nc = model_yaml["nc"]
args = _require_checkpoint_field(checkpoint, "train_args", model_type)
else:
nc = _require_model_attr(model_instance, "nc", model_type)
args = _require_model_attr(model_instance, "args", model_type)
model_artifacts: dict[str, Any] = {
"names": class_names,
"yaml": model_yaml,
"nc": nc,
"args": _filtered_args(args),
"ultralytics_version": ultralytics.__version__,
"model_type": model_type,
}
if ultralytics is None:
ultralytics = _import_required_module("ultralytics", "pip install ultralytics")
model_yaml = _require_model_attr(model_instance, "yaml", model_type)
if (
"-cls" in model_type
or model_type.startswith("yolov11")
or model_type.startswith("yolov12")
or model_type.startswith("yolo26")
):
if not isinstance(model_yaml, dict) or "nc" not in model_yaml:
raise ModelPackagingError(
f"The {model_type} checkpoint's model config (model.yaml) is missing 'nc'; "
"it does not look like a complete Ultralytics training checkpoint."
)
nc = model_yaml["nc"]
args = _require_checkpoint_field(checkpoint, "train_args", model_type)
else:
# yolov5 / yolov7 / yolov9 read their upload args from opt.yaml
opt_path = model_path / "opt.yaml"
if not opt_path.exists():
raise MissingFileError(f"You must provide an opt.yaml file at '{opt_path}' for {model_type} uploads.")
with opt_path.open() as stream:
opts = yaml.safe_load(stream) or {}
model_artifacts = {
"names": class_names,
"nc": _require_model_attr(model_instance, "nc", model_type),
"args": _legacy_yolo_args(opts, opt_path),
"model_type": model_type,
}
if hasattr(model_instance, "yaml"):
model_artifacts["yaml"] = model_instance.yaml
nc = _require_model_attr(model_instance, "nc", model_type)
args = _require_model_attr(model_instance, "args", model_type)
model_artifacts: dict[str, Any] = {
"names": class_names,
"yaml": model_yaml,
"nc": nc,
"args": _filtered_args(args),
"ultralytics_version": ultralytics.__version__,
"model_type": model_type,
}

(build_dir / "model_artifacts.json").write_text(json.dumps(model_artifacts))
torch.save(model_instance.state_dict(), build_dir / "state_dict.pt")
Expand Down
39 changes: 17 additions & 22 deletions tests/util/test_model_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
_filtered_args,
_infer_yolo_size,
_is_ptl_checkpoint,
_legacy_yolo_args,
_require_rfdetr,
_resolve_rfdetr_variant,
_resolve_yolo_size,
Expand Down Expand Up @@ -58,7 +57,7 @@ def test_detect_defaults(self):
def test_segment(self):
self.assertEqual(task_of_model_type("yolov11-seg"), TASK_SEG)
self.assertEqual(task_of_model_type("rfdetr-seg-medium"), TASK_SEG)
self.assertEqual(task_of_model_type("yolov7-seg"), TASK_SEG)
self.assertEqual(task_of_model_type("yolov8-seg"), TASK_SEG)

def test_pose(self):
self.assertEqual(task_of_model_type("yolov11-pose"), TASK_POSE)
Expand Down Expand Up @@ -196,22 +195,6 @@ def test_unknown_project_type_is_ignored(self):
validate_model_type_for_project("yolov8", "some-new-type", "widgets")


class LegacyYoloArgsTest(unittest.TestCase):
def test_reports_missing_batch_size(self):
with self.assertRaises(ModelPackagingError) as ctx:
_legacy_yolo_args({"imgsz": 640}, Path("opt.yaml"))
self.assertIn("batch_size", str(ctx.exception))

def test_reports_missing_image_size(self):
with self.assertRaises(ModelPackagingError) as ctx:
_legacy_yolo_args({"batch_size": 8}, Path("opt.yaml"))
self.assertIn("imgsz", str(ctx.exception))

def test_accepts_either_image_size_key(self):
self.assertEqual(_legacy_yolo_args({"imgsz": 640, "batch_size": 8}, Path("x")), {"imgsz": 640, "batch": 8})
self.assertEqual(_legacy_yolo_args({"img_size": 416, "batch_size": 4}, Path("x")), {"imgsz": 416, "batch": 4})


class _FakeYoloModel:
def __init__(self, yaml):
self.yaml = yaml
Expand Down Expand Up @@ -275,15 +258,15 @@ def test_keeps_matching_sized_type_without_warning(self):

def test_bare_family_uninferable_raises_by_default(self):
with self.assertRaises(SizeMismatchError):
_resolve_yolo_size("yolov10", _FakeYoloModel({}), [])
_resolve_yolo_size("yolov8", _FakeYoloModel({}), [])

def test_bare_family_uninferable_proceeds_under_allow_mismatch(self):
# The interactive retry sets allow_mismatch=True after the user confirms;
# this branch must then converge (return) rather than raise forever.
warnings: list = []
self.assertEqual(
_resolve_yolo_size("yolov10", _FakeYoloModel({}), warnings, allow_mismatch=True),
"yolov10",
_resolve_yolo_size("yolov8", _FakeYoloModel({}), warnings, allow_mismatch=True),
"yolov8",
)
self.assertTrue(warnings and "bare family name" in warnings[0])

Expand Down Expand Up @@ -434,6 +417,18 @@ def test_missing_model_path_raises_missing_file(self):
with self.assertRaises(MissingFileError):
package_custom_weights("yolonas", "/nonexistent/path/for/test")

def test_retired_yolo_families_fail_before_loading_weights(self):
with tempfile.TemporaryDirectory() as tmp:
for family in ("5", "7", "9", "10"):
for model_type in (f"yolov{family}", f"yolov{family}n", f"yolo{family}s-seg"):
with self.subTest(model_type=model_type):
with mock.patch.object(model_processor, "_import_required_module") as import_module:
with mock.patch.object(model_processor, "_load_checkpoint") as load:
with self.assertRaises(UnsupportedModelError):
package_custom_weights(model_type, tmp)
import_module.assert_not_called()
load.assert_not_called()

def test_family_must_be_a_prefix_not_a_substring(self):
# 'foo-yolov8n' merely contains a family token; the backend would
# reject it after upload, so the gate must reject it up front.
Expand Down Expand Up @@ -684,7 +679,7 @@ def state_dict(self):
self.assertIn("yaml", str(ctx.exception))

def test_yolov11_missing_train_args_raises_packaging_error(self):
# yolov10/11/12/26 read args from checkpoint["train_args"]; a checkpoint
# yolov11/12/26 read args from checkpoint["train_args"]; a checkpoint
# without it must fail with the ModelPackagingError contract.
class DetectionModel:
names = {0: "cat"}
Expand Down
Loading