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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

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

## 1.4.1

### Added

- Custom train recipes on v2 trainings
([#510](https://github.com/roboflow/roboflow-python/pull/510)):
- `Version.describe_train_recipe(model_type)` — fetch the tunable
hyperparameter schema, allowed online augmentation/preprocessing steps,
and a ready-to-edit recipe `template` for a model type.
- `train_recipe` on `Version.create_training(...)` —
pass an edited `describe_train_recipe` template for custom
hyperparameters/online augmentation (the server dense-fills omitted
defaults). A top-level `epochs` is folded into the recipe's
hyperparameters (the server resolves recipe epochs ahead of the body
value). `train_recipe` requires `model_type` — recipes are minted per
model type, and without one the platform would train the project's
default architecture.
- `roboflow train recipe -p <project> -v <N> -m <model_type>` — print the
recipe schema and template as JSON.
- `roboflow train start --train-recipe '<json>'` — create the training
through the v2 API and print the new `trainingId`. Accepts inline JSON
or a curl-style file reference (`--train-recipe @train_recipe.json`).

## 1.4.0

### Added — Support for multiple models per version
Expand Down
21 changes: 21 additions & 0 deletions CLI-COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ roboflow train results my-project/3
NAS sweeps require the version's validation split to have at least 15 images;
the server returns `code: "insufficient_validation_images_for_nas"` otherwise.

### Train recipes — custom hyperparameters & augmentation (v2)

```bash
# Inspect a model type's tunable hyperparameter schema, allowed online
# augmentation/preprocessing steps, and a ready-to-edit recipe template:
roboflow train recipe -p my-project -v 3 -m rfdetr-medium

# Start a training from an edited recipe: take the `template` field, tweak
# it (hyperparameters, online augmentation), and submit it. The server
# dense-fills any defaults the recipe leaves out:
roboflow --json train recipe -p my-project -v 3 -m rfdetr-medium | jq .template > recipe.json
# ... edit recipe.json (e.g. set .hyperparameters.lr) ...
roboflow train start -p my-project -v 3 -t rfdetr-medium --train-recipe @recipe.json
```

--train-recipe accepts inline JSON or a curl-style @file reference; it creates
the training through the v2 trainings API and prints
the new `trainingId` instead of blocking — handy for launching sweeps and
polling status separately. --epochs is folded into the recipe's
hyperparameters unless the recipe already sets epochs.

### NAS models — list, star, deploy

```bash
Expand Down
35 changes: 34 additions & 1 deletion roboflow/adapters/rfapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,31 @@ def get_training(api_key: str, workspace_url: str, project_url: str, version: st
return response.json()


def get_train_recipe(
api_key: str,
workspace_url: str,
project_url: str,
version: str,
*,
model_type: str,
):
"""GET /{ws}/{proj}/{version}/v2/trainings/recipe — training schema for a model type.

Returns the tunable-hyperparameter schema, the allowed online
augmentation/preprocessing steps, and a ready-to-submit ``template``
that can be edited and passed to ``create_training_v2`` as ``train_recipe``.
"""
encoded_model_type = quote(model_type, safe="")
url = (
f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/recipe"
f"?api_key={api_key}&modelType={encoded_model_type}"
)
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,
Expand All @@ -207,15 +232,21 @@ def create_training_v2(
checkpoint: Optional[str] = None,
model_type: Optional[str] = None,
epochs: Optional[int] = None,
train_recipe: Optional[Dict] = 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}``.

``train_recipe`` submits a full recipe (camelCase ``trainRecipe`` body
key) — typically the ``template`` from ``get_train_recipe`` with edited
hyperparameters/online augmentation; the server dense-fills omitted
defaults. Only non-None arguments are sent.
"""
url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings?api_key={api_key}"
data: Dict[str, Union[str, int]] = {}
data: Dict[str, Union[str, int, Dict]] = {}
if speed is not None:
data["speed"] = speed
if checkpoint is not None:
Expand All @@ -224,6 +255,8 @@ def create_training_v2(
data["modelType"] = model_type
if epochs is not None:
data["epochs"] = epochs
if train_recipe is not None:
data["trainRecipe"] = train_recipe
response = requests.post(url, json=data)
if not response.ok:
raise RoboflowError(response.text)
Expand Down
191 changes: 190 additions & 1 deletion roboflow/cli/handlers/train.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ def _train_callback(
checkpoint: Annotated[Optional[str], typer.Option(help="Checkpoint to resume training from")] = None,
speed: Annotated[Optional[str], typer.Option(help="Training speed preset")] = None,
epochs: Annotated[Optional[int], typer.Option(help="Number of training epochs")] = None,
train_recipe: Annotated[
Optional[str],
typer.Option(
"--train-recipe",
help=(
"Full trainRecipe as inline JSON or @path/to/file.json (see 'roboflow train "
"recipe'); --epochs is folded into its hyperparameters unless the recipe "
"already sets epochs"
),
),
] = None,
) -> None:
"""Train a model. When invoked without a subcommand, behaves like ``train start``."""
if ctx.invoked_subcommand is not None:
Expand All @@ -47,6 +58,7 @@ def _train_callback(
checkpoint=checkpoint,
speed=speed,
epochs=epochs,
train_recipe=train_recipe,
)
_start(args)

Expand All @@ -62,8 +74,26 @@ def start_training(
checkpoint: Annotated[Optional[str], typer.Option(help="Checkpoint to resume training from")] = None,
speed: Annotated[Optional[str], typer.Option(help="Training speed preset")] = None,
epochs: Annotated[Optional[int], typer.Option(help="Number of training epochs")] = None,
train_recipe: Annotated[
Optional[str],
typer.Option(
"--train-recipe",
help=(
"Full trainRecipe as inline JSON or @path/to/file.json (see 'roboflow train "
"recipe'); --epochs is folded into its hyperparameters unless the recipe "
"already sets epochs"
),
),
] = None,
) -> None:
"""Start training for a dataset version."""
"""Start training for a dataset version.

With --train-recipe, the training is created via the v2 trainings API
and the new trainingId is printed. Start from the ``template`` field of
``roboflow train recipe`` output, edit it (hyperparameters, online
augmentation), and pass it inline or as ``@path/to/file.json``; --epochs is folded into its
hyperparameters unless the recipe already sets epochs.
"""
args = ctx_to_args(
ctx,
project=project,
Expand All @@ -72,10 +102,31 @@ def start_training(
checkpoint=checkpoint,
speed=speed,
epochs=epochs,
train_recipe=train_recipe,
)
_start(args)


@train_app.command("recipe")
def describe_train_recipe(
ctx: typer.Context,
project: Annotated[str, typer.Option("-p", "--project", help="Project ID")],
version_number: Annotated[int, typer.Option("-v", "--version", help="Version number")],
model_type: Annotated[
str,
typer.Option("-m", "--model-type", "-t", "--type", help="Model type to describe (e.g. rfdetr-medium)"),
],
) -> None:
"""Show the training recipe schema and template for a model type.

Prints the tunable hyperparameter schema, the allowed online
augmentation/preprocessing steps, and a ready-to-submit ``template``
that can be edited and passed to ``roboflow train start --train-recipe``.
"""
args = ctx_to_args(ctx, project=project, version_number=version_number, model_type=model_type)
_recipe(args)


@train_app.command("cancel")
def cancel_training(
ctx: typer.Context,
Expand Down Expand Up @@ -174,6 +225,14 @@ def _start(args): # noqa: ANN001
output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2)
return

# Custom recipes go through the v2 trainings API. Presence, not
# truthiness: an explicitly supplied empty value (e.g. an unset shell
# variable) must fail JSON validation, not fall through and start a
# legacy training.
if getattr(args, "train_recipe", None) is not None:
_start_v2(args, api_key, workspace_url, project_slug)
return

# Ensure the version has the required export format before training
if args.model_type:
_ensure_export(args, api_key, workspace_url, project_slug, str(args.version_number), args.model_type)
Expand Down Expand Up @@ -210,6 +269,136 @@ def _start(args): # noqa: ANN001
output(args, data, text=f"Training started for {project_slug} version {args.version_number}.")


def _parse_json_flag(args, raw, flag):
"""Parse a JSON-object CLI flag value; exits with a clean error on invalid input.

Accepts inline JSON, or ``@path/to/file.json`` to read the JSON from a
file (curl-style; unambiguous because ``@`` can never start valid JSON).
"""
import json
import os

from roboflow.cli._output import output_error

source = "string"
if raw.startswith("@"):
path = os.path.expanduser(raw[1:])
try:
with open(path, encoding="utf-8") as f:
raw = f.read()
except OSError as exc:
output_error(
args,
f"Cannot read {flag} file {path}: {exc.strerror or exc}",
hint="Pass inline JSON, or @<path> pointing to a readable JSON file.",
)
return None # unreachable: output_error sys.exits
source = "file"

try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
output_error(args, f"Invalid JSON in {flag} {source}: {exc}", hint="Pass a valid JSON string.")
return None # unreachable: output_error sys.exits
if not isinstance(parsed, dict):
output_error(
args,
f"{flag} must be a JSON object, got {type(parsed).__name__}",
hint="Pass a JSON object string, e.g. '{\"lr\": 0.0002}'.",
)
return None # unreachable: output_error sys.exits
return parsed


def _start_v2(args, api_key, workspace_url, project_slug):
"""Create a training via the v2 trainings API with a custom trainRecipe."""
from roboflow.adapters import rfapi
from roboflow.cli._output import output, output_error
from roboflow.util.train_recipe import fold_epochs_into_recipe

version_str = str(args.version_number)
if not args.model_type:
output_error(
args,
"--train-recipe requires a model type.",
hint=(
"Recipes are minted per model type; without -t/--type the platform "
"would train the project's default architecture. Pass the model type "
"the recipe was described for (e.g. -t rfdetr-medium)."
),
)
return
train_recipe = _parse_json_flag(args, args.train_recipe, "--train-recipe")
if args.epochs is not None:
# Fold --epochs into the recipe: the server dense-fills recipe
# hyperparameters (including a default epochs) and resolves them
# ahead of the body's top-level value, which would otherwise be
# silently ignored. An epochs set in the recipe wins.
train_recipe = fold_epochs_into_recipe(train_recipe, args.epochs)

# Ensure the version has the required export format before training
if args.model_type:
_ensure_export(args, api_key, workspace_url, project_slug, version_str, args.model_type)

try:
result = rfapi.create_training_v2(
api_key,
workspace_url,
project_slug,
version_str,
model_type=args.model_type,
speed=args.speed,
checkpoint=args.checkpoint,
epochs=args.epochs,
train_recipe=train_recipe,
)
except rfapi.RoboflowError as exc:
output_error(args, str(exc))
return

data = {
"status": "training_created",
"project": project_slug,
"version": args.version_number,
**result,
}
training_id = result.get("trainingId")
output(
args,
data,
text=f"Training created for {project_slug} version {args.version_number}. trainingId: {training_id}",
)


def _recipe(args): # noqa: ANN001
from roboflow.adapters import rfapi
from roboflow.cli._output import output, output_error
from roboflow.cli._resolver import resolve_resource
from roboflow.config import load_roboflow_api_key

try:
workspace_url, project_slug, _version = resolve_resource(args.project, workspace_override=args.workspace)
except ValueError as exc:
output_error(args, str(exc))
return

api_key = args.api_key or load_roboflow_api_key(workspace_url)
if not api_key:
output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2)
return

try:
result = rfapi.get_train_recipe(
api_key, workspace_url, project_slug, str(args.version_number), model_type=args.model_type
)
except rfapi.RoboflowError as exc:
output_error(args, str(exc))
return

# No text form — the recipe is structured data; print JSON in both modes.
output(args, result)


def _ensure_export(args, api_key, workspace_url, project_slug, version_str, model_type):
"""Check if the version has the required export format; trigger and poll if not."""
import sys
Expand Down
Loading
Loading