Skip to content
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,42 @@

All notable changes to this project will be documented in this file.

## 1.4.0

### Added — Support for multiple models per version

A dataset version can now own many trainings, and a training can produce many
models (e.g. a NAS sweep). New object types expose this:

**SDK (`roboflow/core/training.py`, `roboflow/core/version.py`):**
- `Version.trainings()` — list the version's training runs as `Training` objects.
- `Version.models()` — every trained model for the version (the union across its
trainings), as `TrainedModel` objects. This is now the canonical way to get a
version's models.
- `Version.create_training(speed=, model_type=, checkpoint=, epochs=)` — launch a
run without blocking, returning a `Training`.
- `Training` — `.models`, `.refresh()`, `.cancel()`, `.stop()`, plus
`.training_id` / `.status` / `.model_type`.
- `TrainedModel` — `.predict()`, `.predict_video()`, `.download()`, plus
`.model_id` / `.model_type` / `.metrics`. A `TrainedModel` does everything the
old `version.model` could; you just reach it through `version.models()`.

**Adapters (`roboflow/adapters/rfapi.py`):** v2 trainings endpoints —
`list_trainings_for_version`, `get_training`, `create_training_v2`,
`cancel_training_v2`, `stop_training_v2`, `get_model_weights_url`.

### Changed

- Keypoint detection inference now reports its prediction type correctly
(previously mislabeled as classification), fixing rendering/plotting of
keypoint predictions.

### Deprecated

- `version.model` (the singular attribute) is deprecated and emits a
`DeprecationWarning`. It cannot represent a version with multiple models;
use `version.models()` instead.

## 1.3.10

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ version = project.version("VERSION_NUMBER")
# upload model weights - yolov10
version.deploy(model_type="yolov10", model_path=f”{HOME}/runs/detect/train/”, filename="weights.pt")

# run inference
model = version.model
# run inference (a version may own several trained models; models() returns all of them)
model = version.models()[0]

img_url = "https://media.roboflow.com/quickstart/aerial_drone.jpeg"

Expand Down
1 change: 1 addition & 0 deletions docs/core/training.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:::roboflow.core.training
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ version = project.version("VERSION_NUMBER")
# upload model weights - yolov10
version.deploy(model_type="yolov10", model_path=f”{HOME}/runs/detect/train/”, filename="weights.pt")

# run inference
model = version.model
# run inference (a version may own several trained models; models() returns all of them)
model = version.models()[0]

img_url = "https://media.roboflow.com/quickstart/aerial_drone.jpeg"

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ nav:
- Projects: core/project.md
- Workspaces: core/workspace.md
- Versions: core/version.md
- Trainings: core/training.md
- Models:
- Object Detection: models/object-detection.md
- Classification: models/classification.md
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ banned-module-level-imports = [
python_version = "3.10"
exclude = ["^build/"]

# numpy's bundled stubs use PEP 695 `type` statements, which mypy rejects when
# checking against python_version 3.10. Skip following them so the type checker
# doesn't choke on numpy's own stub syntax.
[[tool.mypy.overrides]]
module = ["numpy", "numpy.*"]
follow_imports = "skip"
follow_imports_for_stubs = true

[[tool.mypy.overrides]]
module = [
"_datetime.*",
Expand Down
6 changes: 4 additions & 2 deletions roboflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
CLIPModel = None # type: ignore[assignment,misc]
GazeModel = None # type: ignore[assignment,misc]

__version__ = "1.3.10"
__version__ = "1.4.0"


def check_key(api_key, model, notebook, num_retries=0):
Expand Down Expand Up @@ -168,7 +168,9 @@ def load_model(model_url):

project = operate_workspace.project(project)
version = project.version(version)
model = version.model
# version.model is deprecated; read the underlying legacy model directly so
# load_model keeps its single-model return contract without emitting the warning.
model = getattr(version, "_model", None)
return model


Expand Down
133 changes: 133 additions & 0 deletions roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,139 @@ def get_training_results(api_key: str, workspace_url: str, project_url: str, ver
return response.json()


# ---------------------------------------------------------------------------
# DNA v2 trainings surface (MMPV-aware). Mirrors the MCP's rf_api.py 1:1: a
# version owns many trainings, each owning one or more models (a NAS run owns
# many). trainingId rides in the query/body, never the path, because legacy ids
# contain slashes. The legacy-vs-MMPV branch lives entirely on the backend.
# ---------------------------------------------------------------------------


def list_trainings_for_version(api_key: str, workspace_url: str, project_url: str, version: str):
"""List a version's trainings (DNA ``trainings.list``).

GET /{ws}/{proj}/{version}/v2/trainings. MMPV versions return every run;
SMPV versions return a single entry synthesized from ``version.train``.
Returns the raw ``trainings`` array — each entry carries
``{trainingId, status, modelType, modelGroup, modelIds, start}``.
"""
url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings?api_key={api_key}"
response = requests.get(url)
if not response.ok:
raise RoboflowError(response.text)
data = response.json()
return data.get("trainings", []) or []


def get_training(api_key: str, workspace_url: str, project_url: str, version: str, training_id=None):
"""A single run's results bundle (DNA ``trainings.get``).

GET /{ws}/{proj}/{version}/v2/trainings/get[?trainingId=]. Omitting
``training_id`` targets the version's sole run; a version that owns several
runs responds 409 (list them and pass a specific id). Returns
``{trainingId, status, modelType, modelGroup, modelCount, models: [...]}``,
each model carrying an inference-style ``modelId`` (``<workspace>/<slug>``).
"""
url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/get?api_key={api_key}"
if training_id:
url += f"&trainingId={quote(str(training_id), safe='')}"
response = requests.get(url)
if not response.ok:
raise RoboflowError(response.text)
return response.json()


def create_training_v2(
api_key: str,
workspace_url: str,
project_url: str,
version: str,
*,
speed: Optional[str] = None,
checkpoint: Optional[str] = None,
model_type: Optional[str] = None,
epochs: Optional[int] = None,
):
"""Create a training on a version (DNA ``trainings.create``).

POST /{ws}/{proj}/{version}/v2/trainings. A version may own many trainings,
so repeated/concurrent runs are allowed; the backend rejects a second run on
a legacy (SMPV) version. Returns ``{trainingId, status, jobId}``.
"""
url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings?api_key={api_key}"
data: Dict[str, Union[str, int]] = {}
if speed is not None:
data["speed"] = speed
if checkpoint is not None:
data["checkpoint"] = checkpoint
if model_type is not None:
data["modelType"] = model_type
if epochs is not None:
data["epochs"] = epochs
response = requests.post(url, json=data)
if not response.ok:
raise RoboflowError(response.text)
return response.json() if response.content else {"status": "training_started"}


def cancel_training_v2(
api_key: str,
workspace_url: str,
project_url: str,
version: str,
training_id=None,
continue_if_no_refund: bool = False,
):
"""Cancel an in-flight run (DNA ``trainings.cancel``).

POST /{ws}/{proj}/{version}/v2/trainings/cancel. ``training_id`` selects a
specific run; omit it to target the version's sole run.
"""
url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/cancel?api_key={api_key}"
body: Dict[str, Union[str, bool]] = {}
if training_id:
body["trainingId"] = training_id
if continue_if_no_refund:
body["continueIfNoRefund"] = True
response = requests.post(url, json=body)
if not response.ok:
raise RoboflowError(response.text)
return response.json() if response.content else {"success": True}


def stop_training_v2(api_key: str, workspace_url: str, project_url: str, version: str, training_id=None):
"""Request an early stop on an in-flight run (DNA ``trainings.stop``).

POST /{ws}/{proj}/{version}/v2/trainings/stop. ``training_id`` selects a
specific run; omit it to target the version's sole run.
"""
url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/stop?api_key={api_key}"
body: Dict[str, str] = {}
if training_id:
body["trainingId"] = training_id
response = requests.post(url, json=body)
if not response.ok:
raise RoboflowError(response.text)
return response.json() if response.content else {"success": True}


def get_model_weights_url(api_key: str, workspace_url: str, project_url: str, model_id: str, model_format: str = "pt"):
"""Resolve a signed PyTorch weights URL for a single trained model.

GET /{ws}/{proj}/{model_id}/ptFile, where ``model_id`` is the addressable
segment of an inference-style id — a model slug (MMPV) or a version number
(SMPV). Returns the signed ``weightsUrl``.
"""
if model_format != "pt":
raise RoboflowError(f"Unsupported weights format '{model_format}'. Only 'pt' is supported.")
encoded = quote(str(model_id), safe="")
url = f"{API_URL}/{workspace_url}/{project_url}/{encoded}/ptFile?api_key={api_key}"
response = requests.get(url)
if not response.ok:
raise RoboflowError(response.text)
return response.json()["weightsUrl"]


def list_project_models(
api_key: str,
workspace_url: str,
Expand Down
8 changes: 4 additions & 4 deletions roboflow/cli/handlers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,15 +196,15 @@ def _list_models(args): # noqa: ANN001

models = []
for v in versions:
if v.model:
# version.model is deprecated; read the underlying legacy model directly.
v_model = getattr(v, "_model", None)
if v_model:
models.append(
{
"version": v.version,
"id": v.id,
"model": getattr(v, "model_format", ""),
"map": getattr(v, "model", {}).get("map", "")
if isinstance(getattr(v, "model", None), dict)
else "",
"map": v_model.get("map", "") if isinstance(v_model, dict) else "",
}
)

Expand Down
10 changes: 9 additions & 1 deletion roboflow/cli/handlers/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,15 @@ def _video_infer(args) -> None: # noqa: ANN001
rf = roboflow.Roboflow(api_key)
project = rf.workspace().project(args.project)
version = project.version(args.version_number)
model = version.model
model = getattr(version, "_model", None)
if model is None:
output_error(
args,
f"No model found for project '{args.project}' version {args.version_number}.",
hint="Train or deploy a model for this version before running video inference.",
exit_code=3,
)
return

job_id, _signed_url, _expire_time = model.predict_video(
args.video_file,
Expand Down
1 change: 1 addition & 0 deletions roboflow/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def get_conditional_configuration_variable(key, default):

CLASSIFICATION_MODEL = os.getenv("CLASSIFICATION_MODEL", "ClassificationModel")
INSTANCE_SEGMENTATION_MODEL = "InstanceSegmentationModel"
KEYPOINT_DETECTION_MODEL = "KeypointDetectionModel"
OBJECT_DETECTION_MODEL = os.getenv("OBJECT_DETECTION_MODEL", "ObjectDetectionModel")
SEMANTIC_SEGMENTATION_MODEL = "SemanticSegmentationModel"
PREDICTION_OBJECT = os.getenv("PREDICTION_OBJECT", "Prediction")
Expand Down
Loading
Loading