Python SDK · browser and agent automationProduct docsComplete MarkdownAPI signaturesHTTP schema

AlgoFly Python SDK

Python client for automating AlgoFly DataHub workflows.

Use it to:

Projects are type-agnostic. You can keep classification, boxes, polygons, masks, points, lines, OCR text, and custom metadata in the same project.

Install

From this repository:

pip install https://app.algofly.ai/api/install/sdk.tar.gz

Import the client:

from algofly import AlgoFly

CLI Upload

The SDK installs an algofly command. Dataset upload requires API key, workspace id, and project id:

algofly -m upload ./dataset.zip \
  --api-key af_... \
  --workspace-id WORKSPACE_ID \
  --project-id PROJECT_ID

Equivalent subcommand form:

algofly upload ./dataset-dir \
  --api-key af_... \
  --workspace-id WORKSPACE_ID \
  --project-id PROJECT_ID \
  --format auto \
  --split-name auto \
  --limit 1000

./dataset-dir can be an image, directory, ZIP, TAR, TAR.GZ, or TGZ. The importer auto-discovers common CV layouts and labels, including YOLO, COCO, Pascal VOC, LabelMe, CreateML, TensorFlow CSV, RetinaNet CSV, KITTI, WIDERPerson-style text labels, classification folders/CSV, OCR metadata, and native AlgoFly annotation JSON.

Authenticate

from algofly import AlgoFly

af = AlgoFly(
    api_key="af_...",
    api_url="https://app.algofly.ai",
)

print(af.verify())

You can also use an environment variable:

export ALGOFLY_API_KEY="af_..."
export ALGOFLY_API_URL="https://app.algofly.ai"
af = AlgoFly()

api_url overrides ALGOFLY_API_URL; otherwise the SDK defaults to https://app.algofly.ai. URLs may include or omit /api.

Quickstart

from algofly import AlgoFly

# Authenticate
af = AlgoFly(api_key="af_...", api_url="https://app.algofly.ai")

# Pick your workspace and project
workspace = af.workspace()                       # first accessible workspace
project = workspace.project("PROJECT_ID")         # by id or name

# Upload labeled images
project.upload(
    "images/frame-001.jpg",
    labels=[{"type": "bbox", "label": "player", "left": 0.1, "top": 0.2, "width": 0.3, "height": 0.5}],
    batch_name="match-day-1",
    tags=["camera-1", "raw"],
)

project.upload("images/frame-002.jpg")           # image-only, no labels

# Upload a folder (auto-discovers YOLO / COCO / VOC)
project.upload_dataset("./dataset", batch_name="import-1", batch_size=10)

# Iterate and inspect
for image in project.live(page_size=50, include_labels=True):
    print(image.filename, image.verification_status, image.model_agreement)
    print([l.label for l in image.labels])

# Review model output
image = project.first_image(verification_status="unverified", model_agreement="disagree")
if image:
    image.mark_model_agree()
    image.mark_verified()

# Create a dataset version and export
version = project.create_version("v1", split=(80, 10, 10))
version.download("yolo", location="./exports")    # exports a ready-to-train zip

Use normalize_image=True only when you want the SDK to re-encode the source image before upload, for example for MPO files saved with a .JPG extension. Install the optional image dependency first:

pip install "algofly[image]"

Common Workflows

Training data pipeline

Collect images, upload with labels, create a version, and export for training:

project = af.workspace().project("PROJECT_ID")

# Upload raw images with batch metadata
project.upload_dataset("./field-images", batch_name="drone-run-1", tags=["drone"])

# Upload predictions from a model
for image in project.live(page_size=100, tags=["drone"], exclude_tags=["labeled"]):
    predictions = my_model(image.download(as_bytes=True))
    image.update_labels(predictions)
    project.update_images_metadata([image], add_tags=["model-labeled"])

# Freeze a dataset version and export
version = project.create_version("drone-v1", split=(80, 10, 10))
path = version.download("yolo", location="./exports")
print(f"Exported to {path}")

Model inference pipeline

Fetch images from a version, run inference, upload results:

version = project.version("drone-v1")

for item, _ in version.iter_labels(split_name="test", page_size=50):
    image_bytes = item.download(as_bytes=True)
    predictions = my_model.predict(image_bytes)

    labels = [
        {"type": "bbox", "label": p["class"], **p["bbox"], "confidence": p["score"]}
        for p in predictions
    ]
    project.update_labels(item.id, labels, verification_status="unverified")

Label review and QC pipeline

Iterate images, review labels, mark human verification and model agreement:

for image in project.live(
    page_size=50,
    verification_status="unverified",
    model_agreement="disagree",
):
    labels = image.refresh_labels()
    reviewed = [l for l in labels if l.get("confidence", 1) > 0.5]
    image.write_labels(
        reviewed,
        verification_status="verified",
        model_agreement="agree",
    )
    print(f"Reviewed {image.filename}: {len(reviewed)} labels kept")

Bulk label rename across a project

registry = project.class_registry()
receipt = project.rename_class(
    "person", "player", expected_class_revision=registry["class_revision"]
)

This changes the project class and its stored labels together. Keep any unsaved drafts; reload their canonical class names before deciding how to reapply edits.

Workspaces

List workspaces:

for workspace in af.workspaces():
    print(workspace.id, workspace.slug, workspace.name, workspace.storage)

Select the first accessible workspace:

workspace = af.workspace()

Select by id or slug:

workspace = af.workspace("WORKSPACE_ID")
workspace = af.workspace("my-workspace-slug")

Create a workspace:

workspace = af.create_workspace(
    name="Automation Lab",
    slug="automation-lab",
    description="Workspace created from Python",
)

Storage observations

usage = workspace.storage_summary()  # Last confirmed values and current progress.
scan = workspace.storage_summary(refresh=True)  # Explicitly start or resume.
scan_id = scan["storage_scan"]["id"]
# Poll storage_summary() without refresh=True while the scan is active.
# Cancel only the captured scan; this never deletes account files:
workspace.cancel_storage_scan(scan_id)

Physical usage is observed over an interval while files may change. A pending or failed scan retains the previous confirmed measurement; missing values are not zero. Search-index byte counts are estimates. Refresh and cancellation are single-attempt requests: inspect the current status after an uncertain response.

Projects

The current SDK release is 0.3.13. Install the matching SDK from your DataHub deployment for current project controls and retained export recovery errors.

List projects:

for project in workspace.projects():
    print(project.id, project.name, project.storage)

Select by id or name:

project = workspace.project("PROJECT_ID")
project = workspace.project("Football Dataset")

Create a project:

project = workspace.create_project(
    name="SDK Experiments",
    description="Images and labels uploaded from Python",
)

Clone and monitor a project

Cloning runs in the background and copies project data into a new project. Original video sources and registered frames retain their project-owned provenance; deleting the source project after a successful clone does not remove the clone's data.

copy = project.clone("Experiment copy")  # submits once; returns a Project
print(copy.id, copy.status, copy.clone_status)

copy.refresh()  # inexpensive operational status; does not load image previews/counts
print(copy.data.get("clone_progress"), copy.data.get("clone_message"))
copy.wait_for_clone(timeout=900, poll_interval=2)
print(copy.data.get("data_available"))

# Optional destination workspace; description=None copies the source description.
copy = project.clone(
    "Another experiment",
    target_workspace_id="DESTINATION_WORKSPACE_ID",
    description="",
    wait=True,
    timeout=900,
)

The server checks access to both workspaces, project permissions, and quotas. Status and cancellation recheck current permissions. refresh() merges status into the existing metadata snapshot; it does not recalculate storage totals. An older server without lightweight status support returns its HTTP error; the SDK does not fall back to repeatedly listing every project and preview.

from algofly import AlgoflyHTTPError, ProjectCloneFailed, ProjectCloneTimeout

try:
    copy = project.clone("Tracked experiment", wait=True, timeout=900)
except ProjectCloneTimeout as error:
    copy = error.project  # exact target is retained; timeout never cancels it
    print(copy.id, copy.clone_status)
    copy.wait_for_clone(timeout=900)  # resume waiting, not another submission
except ProjectCloneFailed as error:
    copy = error.project
    print(copy.id, copy.clone_status, error.message)
except AlgoflyHTTPError as error:
    # A 503 can mean the target exists but delivery was not acknowledged.
    detail = error.message if isinstance(error.message, dict) else {}
    target_id = getattr(error, "project_id", None) or detail.get("project_id")
    print(error.status_code, detail.get("code"), target_id)
    # Inspect target_id in the destination workspace before taking another action.
    # error.response remains available; never blindly resubmit clone().

To stop an inspected target explicitly, select its exact ID in the destination workspace and call target.cancel_clone(). This server-idempotent action does not start a replacement project.

timeout is positive and bounded to 86,400 seconds; poll_interval is positive and at most 60 seconds. HTTP errors preserve their original response. A failed or cancelled clone raises ProjectCloneFailed; a wait timeout raises ProjectCloneTimeout with its inspectable .project and .project_id.

Once a target is known, errors during waiting also retain .project and .project_id, including an unchanged AlgoflyHTTPError or a transport error such as requests.Timeout. Inspect that exact target or resume its wait_for_clone(); do not submit another clone. If a transport timeout returns after the polling deadline, ProjectCloneTimeout chains that original error. The polling deadline is checked between HTTP calls; transport retries may make one call exceed it, so it is not a strict end-to-end wall-clock limit.

Labels

Labels are plain dictionaries. Every label object must include type or label_type so the SDK can choose the right geometry and validate the required fields. For new labels, you can omit id; the SDK creates the correct internal id before sending data to the API. If you pass a custom id, the SDK rewrites its internal prefix from type while preserving your custom suffix.

Coordinates should be normalized from 0.0 to 1.0.

Image Classification

image_classification = {"type": "classification", "label": "empty-court"}

Multiple image labels are supported:

multi_label_classification = [
    {"type": "classification", "label": "indoor"},
    {"type": "classification", "label": "broadcast-camera"},
    {"type": "classification", "label": "night-game"},
]

Box

box = {
    "type": "bbox",
    "label": "player",
    "left": 0.34,
    "top": 0.12,
    "width": 0.18,
    "height": 0.62,
    "confidence": 0.94,
}

Equivalent normalized corner coordinates are also accepted. The SDK translates them to left/top/width/height before upload:

box = {
    "type": "bbox",
    "label": "player",
    "x1": 0.34,
    "y1": 0.12,
    "x2": 0.52,
    "y2": 0.74,
}

Use one box coordinate style per label. Box values must be normalized between 0.0 and 1.0, and left + width / top + height must stay within 1.0.

Rotated Box

Rotated boxes are stored as rectangle annotations with angle in degrees. Use type="rotated_bbox" (aliases: rbbox, obb, oriented_bbox) when you want the SDK to make intent explicit. Center form is accepted:

rotated_box = {
    "type": "rotated_bbox",
    "label": "ship",
    "cx": 0.50,
    "cy": 0.44,
    "width": 0.28,
    "height": 0.10,
    "angle": -32,
}

Four normalized corner points are also accepted. The SDK derives left/top/width/height, cx/cy, and angle before upload:

rotated_box = {
    "type": "obb",
    "label": "ship",
    "points": [
        {"x": 0.36, "y": 0.31},
        {"x": 0.61, "y": 0.45},
        {"x": 0.56, "y": 0.54},
        {"x": 0.31, "y": 0.40},
    ],
}

Polygon

polygon = {
    "type": "polygon",
    "label": "player",
    "points": [
        {"x": 0.30, "y": 0.20},
        {"x": 0.50, "y": 0.25},
        {"x": 0.48, "y": 0.60},
        {"x": 0.28, "y": 0.55},
    ],
}

Mask

mask = {
    "type": "mask",
    "label": "court",
    "rle": {"counts": "encoded-mask", "size": [720, 1280]},
}

Point

point = {
    "type": "point",
    "label": "left-ankle",
    "x": 0.42,
    "y": 0.37,
}

Line

line = {
    "label": "baseline",
    "type": "line",
    "x1": 0.10,
    "y1": 0.80,
    "x2": 0.90,
    "y2": 0.80,
}

OCR For The Whole Image

image_ocr = {
    "label": "document-text",
    "type": "image_ocr",
    "text": "Invoice #1007\nTotal: $42.00",
    "language": "en",
}

Text In A Box

jersey_number = {
    "type": "bbox",
    "label": "jersey-number",
    "text": "23",
    "left": 0.44,
    "top": 0.20,
    "width": 0.08,
    "height": 0.12,
}

Text In A Polygon

license_plate = {
    "type": "polygon",
    "label": "license-plate",
    "text": "MH12AB1234",
    "points": [
        {"x": 0.61, "y": 0.71},
        {"x": 0.78, "y": 0.70},
        {"x": 0.79, "y": 0.76},
        {"x": 0.62, "y": 0.77},
    ],
}

Custom Metadata

metadata = {
    "type": "classification",
    "label": "needs-review",
    "source": "my-model-v3",
    "reason": "low-light",
    "confidence": 0.91,
}

Mixed Labels

labels = [
    image_classification,
    image_ocr,
    box,
    polygon,
    jersey_number,
    metadata,
]

Upload Images

Upload without labels:

project.upload(
    "images/frame-001.jpg",
    batch_name="camera-run-1",
    tags=["camera-1", "raw"],
    split_name="train",
)

Upload with labels:

project.upload(
    "images/frame-002.jpg",
    labels=[
        {"type": "bbox", "label": "ball", "left": 0.42, "top": 0.51, "width": 0.06, "height": 0.06},
        {"type": "classification", "label": "indoor"},
    ],
)

Upload bytes or a file-like object without writing a temporary file:

success, encoded = cv2.imencode(".jpg", crop)
if success:
    project.upload(
        encoded.tobytes(),
        batch_name="pipe crops",
        tags=image.data["tags"],
        annotations=[{"type": "classification", "label": str(count)}],
    )

When uploading bytes without filename, the SDK generates a unique filename from the MD5 hash of the image bytes.

upload_bytes is a convenience wrapper around upload for byte payloads:

project.upload_bytes(
    image_bytes,
    filename="frame-003.jpg",
    batch_name="pipe crops",
    tags=["crop"],
    labels=[{"type": "classification", "label": "12"}],
)

Upload a PIL image or an OpenCV image directly:

project.upload(
    pil_image,
    batch_name="pipe crops",
    tags=["crop"],
    annotations=[{"type": "classification", "label": str(count)}],
)

project.upload(
    cv2_image,  # NumPy array in OpenCV BGR format
    batch_name="pipe crops",
    tags=["crop"],
    annotations=[{"type": "classification", "label": str(count)}],
)

PIL/OpenCV uploads are encoded in memory. If filename is omitted, the SDK generates an MD5-based filename after encoding.

Upload from an image URL:

project.upload(
    "https://example.com/images/frame-001.jpg",
    batch_name="url imports",
    tags=["url"],
)

For URL uploads, filename is optional. The SDK uses filename if provided, otherwise it uses the URL basename when it has an image extension, otherwise it generates an MD5-based filename from the downloaded bytes.

Upload OCR data:

project.upload(
    "images/receipt.jpg",
    labels=[
        image_ocr,
        {"type": "bbox", "label": "total", "text": "$42.00", "left": 0.60, "top": 0.74, "width": 0.22, "height": 0.05},
    ],
)

Upload Folders

Upload a folder or common dataset layout. upload_dataset auto-discovers labels for layouts such as YOLO, COCO, Pascal VOC, LabelMe, and sidecar JSON/TXT files:

LabelMe imports read DataHub metadata from algofly, with per-field fallback to legacy flags. Multipart polygons, holes, masks, image classifications, and OCR remain associated with their image; ordinary boolean flags are not labels.

from datetime import datetime

project.upload_dataset(
    "./images",
    batch_name="dataset-import",
    tags=["sdk", "unlabeled"],
    capture_date=datetime(2026, 5, 29, 14, 30),
    batch_size=25,
    show_progress=True,
)

capture_date accepts a datetime or ISO string. Omit it to use upload time. Folder and dataset uploads show a tqdm progress bar by default. Use show_progress=False to hide it.

Uploads run concurrently using a thread pool. batch_size controls how many images are uploaded in parallel: set it higher (e.g. batch_size=50) for large datasets on fast connections, or lower (e.g. batch_size=5) to stay within API rate limits. Transient failures (429 rate limits, 5xx server errors) are retried automatically with exponential backoff.

Upload a folder with labels keyed by filename:

labels_by_filename = {
    "frame-001.jpg": [
        {"type": "classification", "label": "empty-court"},
    ],
    "frame-002.jpg": [
        {"type": "bbox", "label": "ball", "left": 0.1, "top": 0.2, "width": 0.1, "height": 0.1},
    ],
}

project.upload_dataset(
    "./images",
    labels=labels_by_filename,
    batch_name="folder-import",
)

Load labels from sidecar JSON files:

import json

def load_labels(image_path):
    sidecar = image_path.with_suffix(".json")
    if not sidecar.exists():
        return None
    return json.loads(sidecar.read_text())

project.upload_dataset(
    "./images",
    label_loader=load_labels,
    batch_name="sidecar-import",
)

Upload YOLO Data

For folders with images/, labels/, and classes.txt or data.yaml:

project.upload_yolo_dataset(
    "./dataset",
    dataset_format="yolov8",
    annotation_format="bbox",
    split_name="auto",
    tags=["training"],
    batch_size=25,
)

For YOLO segmentation:

project.upload_yolo_dataset(
    "./segmentation-dataset",
    dataset_format="yolov8-seg",
    annotation_format="polygon",
    split_name="auto",
)

For YOLO OBB/rotated boxes, use annotation_format="obb":

project.upload_yolo_dataset(
    "./obb-dataset",
    dataset_format="yolov8",
    annotation_format="obb",
    split_name="auto",
)

CLI examples for detection and segmentation uploads are available in sdk/examples. They use argparse and accept --api-url, so they can target different AlgoFly deployments.

Iterate Project Data

for image in project.live(page_size=100):
    print(image.id, image.filename)

If you need labels for every image, include them in each page instead of fetching labels image-by-image:

for image in project.live(page_size=100, include_labels=True):
    print(image.filename, image.labels)

SDK records support dict and dot access:

image = next(project.live(include_labels=True))
print(image.data.filename)
print(image.labels[0]["label"], image.labels[0].label)
print(image.labels[0].ocr.text)
plain_labels = image.labels_dicts  # JSON-safe list[dict]

Convert normalized labels to absolute pixel coordinates:

from algofly import label_to_absolute, labels_to_absolute

for image in project.live(page_size=100, include_labels=True):
    for label in image.absolute_labels:
        if label.get("label") == "pipe":
            crop = cv2_image[label["y1"]:label["y2"], label["x1"]:label["x2"]]

absolute_label = label_to_absolute(label, image_width=3840, image_height=2160)
absolute_labels = labels_to_absolute(image.labels, image.width, image.height)

Bounding boxes include both corner and size forms in normalized and absolute helpers: left, top, width, height, x1, y1, x2, y2, cx, cy, area, and aspect.

Rotated boxes preserve angle, cx/cy, and corner points. Label records also expose rotation, is_rotated_bbox, rotated_points, obb, and enclosing_bbox:

label = image.labels[0]
if label.is_rotated_bbox:
    print(label.rotation, label.rotated_points)
    x1, y1, x2, y2 = label.enclosing_bbox

Label records also expose convenience geometry as properties:

label = image.absolute_labels[0]
print(label.x1, label.y1, label.x2, label.y2)
print(label.center, label.area, label.aspect)
plain_label = label.to_dict()

Convert absolute pixel labels back to normalized labels for upload:

from algofly import label_to_normalized, labels_to_normalized

pixel_labels = [
    {"type": "bbox", "label": "pipe", "left": 120, "top": 80, "width": 420, "height": 360}
]

project.upload(
    cv2_image,
    annotations=pixel_labels,
    normalize_labels_from_pixels=True,
)

normalized = labels_to_normalized(pixel_labels, image_width=3840, image_height=2160)

With filters:

for image in project.live(
    page_size=50,
    split_name="train",
    classes=["ball"],
    exclude_classes=["ignore"],
    tags=["camera-1", "model-labeled"],
    exclude_tags=["rejected"],
    batch_names=["run-1", "run-2"],
    exclude_batch_names=["old-run"],
    ocr_values=["Invoice #1007"],
    exclude_roi_ocr_values=["REJECTED"],
    verification_status="unverified",
    model_agreement="disagree",
    capture_date_start="2026-05-01T00:00:00",
    capture_date_end="2026-05-31T23:59:59",
    filename_query="frame",
    sort_by="label_count_desc",
):
    print(image.filename, image.labels)

By default, live() and list_images() fetch metadata only. live() still fetches metadata in pages, then yields images one by one. Labels and image bytes are loaded lazily when accessed:

for image in project.live(page_size=100):
    print(image.filename)
    labels = image.labels      # fetched on first access
    image_bytes = image.image  # downloaded on first access

You can eager-load either when you need it. With live(), eager data is loaded for each page-sized batch before records from that batch are yielded:

for image in project.live(page_size=100, load_labels=True, load_image=True):
    print(image.filename, image.labels, len(image.image_bytes))

page = project.list_images(page_size=500, load_labels=True)

The same loading behavior applies when iterating page-sized batches:

for batch in project.live_batches(page_size=500):
    for image in batch:
        print(image.filename)

for batch in project.live_batches(page_size=500, load_labels=True):
    print([image.labels for image in batch])

Use count() for progress bars without loading labels or image bytes:

images = project.live(
    page_size=100,
    tags=["reviewed"],
    exclude_tags=["rejected"],
)

for image in tqdm(images, total=images.count()):
    print(image.filename)

tqdm can also infer the total automatically:

for image in tqdm(project.live(page_size=100, tags=["reviewed"])):
    print(image.filename)

Common automation helpers:

total = project.count_images(tags=["reviewed"])
image = project.first_image(filename_query="frame-004")
page = project.find_images(page_size=500, batch_names=["run-1"])

image.add_labels([{"type": "classification", "label": "reviewed"}])
image.set_labels([{"type": "classification", "label": "approved"}])
image.mark_verified()
image.mark_model_agree()

# Flag only incorrect model annotations for review. The image can be marked
# unverified separately; annotation agreement does not imply verification.
incorrect = [label for label in image.labels if label.get("ocr", {}).get("text") == "2"]
image.mark_annotations_model_disagree(incorrect)
image.mark_unverified()

# Alternatively, flag only the OCR transcription while leaving box geometry unset.
image.clear_annotations_model_agreement(incorrect)
image.mark_annotation_ocr_model_disagree(incorrect)

# After correction, either record agreement or remove the annotation status.
image.mark_annotations_model_agree(incorrect)
image.clear_annotations_model_agreement(incorrect)
image.clear_annotation_ocr_model_agreement(incorrect)
image.reload()
image.delete()

Bulk metadata updates and deletes can be previewed before applying:

preview = project.update_matching(tags=["raw"], add_tags=["reviewed"])
# dry-run emits a warning and returns matched_count, would_update_count, and next_step
result = project.update_matching(
    tags=["raw"],
    add_tags=["reviewed"],
    model_agreement="agree",
    dry_run=False,
)

preview = project.delete_matching(tags=["bad"])
# dry-run emits a warning and returns matched_count, would_delete_count, and next_step
result = project.delete_matching(tags=["bad"], dry_run=False)

Structured filters support multiple tags/classes and validate before the first page request:

project.live(tags=["cvat", "reviewed"])
project.live(tags=["cvat"], exclude_tags=["rejected"])
project.live(batch_names=["run-1", "run-2"])
project.live(batch_names=["run-1"], exclude_batch_names=["old-run"])
project.live(classes=["pipe", "ring"])
project.live(exclude_classes=["ignore"])
project.live(ocr_values=["MH12AB1234"])
project.live(image_ocr_values=["Invoice #1007"])
project.live(roi_ocr_values=["23"], exclude_roi_ocr_values=["0"])
project.live(ocr_values=["invoice"], ocr_match="contains")
project.live(ocr_values=["MH12AB1234"], ocr_match="fuzzy", ocr_fuzzy_threshold=0.8)
project.live(no_annotations=True)
project.live(has_image_classification=True)
project.live(has_image_ocr=True)
project.live(has_roi_ocr=True)
project.live(verification_status="verified")
project.live(verified=False)  # alias for verification_status="unverified"
project.live(model_agreement="agree")
project.live(model_agreement="disagree")
project.live(model_agreement="none")
project.live(model_agree=True)   # alias for model_agreement="agree"
project.live(model_agree=False)  # alias for model_agreement="disagree"
project.live(resolution={"field": "width", "operator": ">", "value": 1920})
project.live(
    filters={
        "tags": ["cvat", "reviewed"],
        "exclude_tags": ["rejected"],
        "batch_names": ["run-1"],
        "classes": ["pipe"],
        "exclude_classes": ["ignore"],
        "verification_status": "unverified",
        "model_agreement": "disagree",
        "resolution": [
            {"field": "width", "operator": ">", "value": 1920},
            {"field": "aspect", "operator": ">", "value": 1.3},
        ],
    }
)

Unsupported filters fail immediately:

project.live(filters={"made_up_filter": "x"})
# ValueError: Unsupported image filter(s): made_up_filter ...

OCR value filters support ocr_match="exact" by default, ocr_match="contains" for partial text, and ocr_match="fuzzy" for approximate text matching. Exact matching trims surrounding whitespace. Contains and fuzzy matching are case-insensitive. Use ocr_values for either image-level or ROI OCR, or the scoped image_ocr_values / roi_ocr_values filters when you need a specific source. Each has a matching exclude_* form.

OCR include groups are ANDed together, and exclude groups reject matching images:

project.live(
    image_ocr_values=["Invoice #1007"],      # must exist at image level
    roi_ocr_values=["$42.00"],               # must exist inside some ROI
    exclude_roi_ocr_values=["VOID"],         # must not exist inside any ROI
)

project.live(
    ocr_values=["MH12"],                     # image-level or ROI OCR may match
    exclude_image_ocr_values=["draft"],      # but image-level OCR cannot be draft
    ocr_match="contains",
)

Fetch a single page. By default, data contains ImageRecord objects; pass as_dict=True for raw backend rows:

page = project.list_images(page_number=0, page_size=500, tags=["reviewed"])
for image in page["data"]:
    print(image.filename, image.batch_name, image.tags)

raw_page = project.list_images(page_number=0, page_size=500, as_dict=True)

Iterate image and label pairs:

for image, labels in project.iter_labels(page_size=100):
    print(image.filename, labels)

Download images:

image = next(project.live())
image.download("./downloads")
image.download("./thumbnails", thumbnail=True)
image_bytes = image.download(as_bytes=True)
pil_image = image.download(as_pil=True)
cv2_image = image.download(as_cv2=True)
data_uri = image.download(as_base64=True)  # data:image/...;base64,...

Only one output mode can be enabled at once.

Delete images:

project.delete_image(image)
project.delete_image(image.id)
project.delete_images([image_1.id, image_2.id])

Update Labels

update_labels merges labels by id. Existing labels with matching ids are replaced; labels with other ids stay untouched. Use write_labels or overwrite=True / override=True when you want to replace all labels.

image = next(project.live(filename_query="frame-004", page_size=1))

labels = image.labels.as_dict()

for label in labels:
    if label.get("label") == "ball":
        label["label"] = "basketball"
        label["confidence"] = 0.97

image.update_labels(labels)
image.write_labels(labels)  # replaces all labels

SDK label writes preserve the existing verification status by default. Pass verification_status only when the write should explicitly mark images verified or unverified. SDK label writes also preserve model agreement status by default. Pass model_agreement or the boolean alias model_agree only when the write should explicitly change model agreement. Use model_agreement="none" to clear agree/disagree and return the image to the unset state:

project.upload("images/frame-001.jpg", verification_status="verified")
image.update_labels(labels, verification_status="verified")
project.update_labels("IMAGE_ID", labels, verification_status="unverified")
project.update_image_verification(["IMAGE_ID"], "verified")
project.mark_images_unverified(["IMAGE_ID_1", "IMAGE_ID_2"])

image.update_labels(labels, model_agreement="agree")
image.write_labels(labels, model_agree=False)
project.update_labels("IMAGE_ID", labels, model_agreement="disagree")
project.update_image_model_agreement(["IMAGE_ID"], "agree")
project.update_image_model_agreement(["IMAGE_ID"], "none")
project.mark_images_model_agree(["IMAGE_ID_1", "IMAGE_ID_2"])
project.mark_images_model_disagree(["IMAGE_ID_1", "IMAGE_ID_2"])
project.clear_images_model_agreement(["IMAGE_ID_1", "IMAGE_ID_2"])

Image records expose review status helpers:

print(image.verification_status)  # verified or unverified
print(image.is_verified)          # bool
print(image.model_agreement)      # agree, disagree, or None
print(image.model_agree)          # True, False, or None

image.set_verification_status("verified")
image.mark_unverified()
image.set_model_agreement(False)
image.mark_model_agree()
image.mark_model_disagree()
image.clear_model_agreement()

Append or replace matching ids without fetching and resending existing labels:

image.update_labels([new_label])

Bulk update labels:

project.update_labels_bulk(
    [
        {"image_id": image_id_1, "labels": labels_1},
        {"image_id": image_id_2, "labels": labels_2},
    ],
    overwrite=True,
)

Bulk append labels:

project.update_labels_bulk(
    [
        {"image_id": image_id_1, "labels": new_labels_1},
        {"image_id": image_id_2, "labels": new_labels_2},
    ],
    overwrite=False,
)

Add a new label without removing existing labels:

image = next(project.live(filename_query="frame-004", page_size=1))
labels = image.refresh_labels()

labels.append({
    "type": "classification",
    "label": "reviewed",
    "source": "human-qc",
})

image.update_labels(labels)

Replace all labels:

project.update_labels(
    "IMAGE_ID",
    [
        {"type": "classification", "label": "verified"},
        {"type": "bbox", "label": "ball", "left": 0.1, "top": 0.2, "width": 0.2, "height": 0.2},
    ],
)

Clear all labels:

project.update_labels("IMAGE_ID", [])

Classes

for class_item in project.classes():
    print(class_item)

Read the full registry when you need colors and a revision for an edit:

registry = project.class_registry()
print(registry["classes"], registry["class_colors"])
receipt = project.rename_class(
    "old label", "new label", expected_class_revision=registry["class_revision"]
)
print(receipt["previous_class_revision"], receipt["class_revision"])

Other class controls use the same explicit revision check:

registry = project.class_registry()
receipt = project.update_classes(
    ["cat", {"label": "dog", "color": "#336699"}],
    expected_class_revision=registry["class_revision"],
)
receipt = project.set_class_color(
    "dog", "#224466", expected_class_revision=receipt["class_revision"]
)
receipt = project.remove_classes(
    ["cat"], expected_class_revision=receipt["class_revision"]
)

Class updates merge the supplied labels; remove_classes explicitly removes labels. Mutations return the server receipt and make one transport attempt. On HTTP 409 class_revision_changed, keep the draft and review a fresh registry before deciding what to change. An uncertain connection or server response must also be inspected before another mutation. These methods never fetch a newer revision automatically or advance the revision of existing annotation snapshots. Omitting expected_class_revision retains the server's unversioned compatibility behavior. classes() continues to return only the class list.

Dataset Versions

Version sources include images with annotations and images explicitly saved with an empty annotation set. Untouched uploads and annotation reads do not mark an image as a negative. Check the counts before creating a version:

readiness = project.version_source_readiness()
selection_readiness = project.version_source_readiness(image_ids=[first_id, second_id])
# positive_image_count, explicit_negative_image_count, ready_image_count,
# unreviewed_image_count, total_image_count
project.update_annotations(image_id, [], overwrite=True)  # explicit negative

An explicit negative has no objects; it is not an invented classification label. Supervised versions still require at least one positive source; inspect can_create_version and requires_positive_image in the readiness response. Version metadata records selected positive and explicit-negative source counts, separately from post-augmentation output image counts. Version image records expose source_negative and source_annotation_count from before class filtering. An empty exported annotation array alone does not establish that its original source was an explicit negative.

Create a version:

version = project.create_version(
    name="baseline-v1",
    notes="Created from SDK",
    split=(70, 20, 10),
)

List versions:

for version in project.versions():
    print(version.id, version.number, version.status)

Get a version by id or number:

version = project.version("VERSION_ID")
version = project.version(1)

Refresh status:

version.refresh()
print(version.status)

Iterate Version Data

for item in version.live(split_name="all", page_size=100):
    print(item.id, item.split_name, item.filename, item.labels)
for item, labels in version.iter_labels(split_name="val", page_size=100):
    print(item.filename, len(labels))

Download version images:

version_image = next(version.live(split_name="train"))
version_image.download("./version-images")
version_image.download("./version-thumbnails", thumbnail=True)
version_image_bytes = version_image.download(as_bytes=True)

Download Exports

If publication is uncertain, download() stops with the retained export's key and recovery details instead of continuing to poll or downloading incomplete data. generate_export() returns the server's status unchanged.

DatasetExportBlocked stops waiting immediately for credit, permission, allowance, or uncertain-execution blocks. Its export, key, code, and action describe the retained attempt. Ordinary queued exports and scheduled dispatch retries keep polling. Resolve the reported cause before explicitly retrying; the SDK never resubmits automatically. Publication recovery takes precedence over these blocks.

from algofly import DatasetExportRecoveryRequired

try:
    path = version.download("COCO", location="./exports")
except DatasetExportRecoveryRequired as error:
    print(error.key, error.code, error.action)
    version.refresh()  # Inspect the retained label_formats status.

Refresh status or contact the administrator to reconcile the retained attempt. The SDK does not automatically resubmit, cancel, delete, or refund it.

To explicitly verify an existing retained publication, use its export key:

status = version.reconcile_export(error.key, wait=True, timeout=300)
print(status["status"], status.get("verified_objects"), status.get("total_objects"))

This sends one verification request, then GET-only polls while prepared or verifying. It returns committed or blocked status unchanged. It never creates an export, charges credits, or consumes another generation allowance. A timeout leaves the verification request retained; inspect status rather than regenerating.

To explicitly remove one export, use its existing key:

updated_version = version.delete_export(export_key)

The server fences active producers and requires sealed proof before deleting a retained publication. An unconfirmed producer may still refuse deletion. Completed processing charges and generation usage remain. This method sends one DELETE; it does not generate another export or retry automatically.

path = version.download("yolo", location="./exports")
print(path)

Other export names depend on the backend export registry:

version.download("COCO", location="./exports")
version.download("COCO RLE", location="./exports")
version.download("LabelMe", location="./exports")
version.download("florence2-od", location="./exports")
version.download("CSV Classification Manifest", location="./exports")
version.download("ImageFolder Classification", location="./exports")
version.download("Multi-label Classification CSV", location="./exports")
version.download("Multiclass Classification CSV", location="./exports")
version.download("OCR Images + TXT Labels", location="./exports")
version.download("Segment Anything 2", location="./exports")
version.download("OpenImages CSV", location="./exports")
version.download("VGG Image Annotator JSON", location="./exports")
version.download("VoTT JSON", location="./exports")

Automation Examples

Attach model predictions:

for image in project.live(page_size=100, split_name="test"):
    local_path = image.download("./tmp")
    predictions = my_model_predict(local_path)

    labels = []
    for prediction in predictions:
        labels.append({
            "type": "bbox",
            "label": prediction["class"],
            "left": prediction["left"],
            "top": prediction["top"],
            "width": prediction["width"],
            "height": prediction["height"],
            "confidence": prediction["confidence"],
            "source": "my-model",
        })

    image.update_labels(labels)

Add review metadata:

for image in project.live(page_size=100):
    labels = image.refresh_labels()
    labels.append({
        "type": "classification",
        "label": "needs-review",
        "reason": "automation-check",
    })
    image.update_labels(labels)

Project Artifacts and Model Graphs

Upload arbitrary reference files or model weights to a project. Downloads preserve the original bytes. Uploading a model does not load or execute it.

artifact = project.artifacts.upload("best.pt", description="Training weights")
print(project.artifacts.list(search="best", limit=50))
print(project.artifacts.get(artifact["_id"]))
project.artifacts.download(artifact["_id"], "downloads/best.pt")
# Explicit permanent deletion:
project.artifacts.delete(artifact["_id"])

For an ONNX artifact, project.artifacts.inspect(artifact_id) returns graph nodes, tensor shapes (including symbolic dimensions), inputs, outputs and metadata. Inspection is bounded to 128 MiB models and does not execute inference.

Files larger than 8 MiB use resumable, checksum-verified upload parts, including through public gateways with request-size limits. Supply the same explicit key to recover an upload across process restarts:

artifact = project.artifacts.upload(
    "large-model.pt", idempotency_key="experiment-42-final-weights"
)

The SDK reads bounded chunks from disk. It returns only after the server verifies the whole file. A deleted artifact's old key returns 410; use a new key to upload it again. Account files use client.artifacts.upload(..., billing_workspace_id=...) and need no project. client.artifacts.protect(artifact_id) locks deletion and preserves a project file when its project is removed; unprotect() unlocks it.

For a completed tracked DINOv3 metric best.pt or last.pt, protection also retains the verified model-metadata.json. A required companion reports required_by_protected_model=True; deletion and explicit unlock are blocked until every dependent checkpoint is unprotected. This does not change the companion's independent protected setting or charge its bytes twice. If deletion returns model_protection_refresh_required, the original owner should call client.artifacts.protect(checkpoint_id) again before retrying. An explicit unprotect(checkpoint_id) can release a quiescent pending package after deletion failed, without a billing move. Uncertain COPY receipts still require inspection. Retained downloads and metadata do not enable inference or conversion after the source project is deleted; those operations currently require that live project.

Retained account files remain listable and downloadable after their last workspace is deleted. Their workspace_id and billing_workspace_id may be None; new uploads still require a valid chosen billing workspace. Protection alone does not move storage billing. Use list()["account_storage_usage"] for detached account-library bytes, including pending reservations.

files = af.artifacts.list(search="best")
status = af.artifacts.retention_status("ARTIFACT_ID")

An artifact_retention_recovery_required error means a file transfer needs inspection. Retention status exposes receipt states without private object keys; it never retries an uncertain storage copy automatically.

Workers and Training Runs

Workers connect outbound to your DataHub URL over HTTPS. They need Docker and, for CUDA recipes, a compatible NVIDIA driver and NVIDIA Container Toolkit. Register a credential once and keep it on that worker; do not embed it in notebooks.

New workers expose a shared allocation snapshot in worker["capabilities"]["host_resource_admission"]. CPU and RAM values describe reserved container limits within one Docker daemon, rather than current CPU use or resident memory. Check status, stale and observed_at before using its budget_cpus, reserved_cpus, budget_memory_mb and reserved_memory_mb values. An absent or null value is unknown, including on older workers. Separate Docker daemons are separate resource pools, even when they share a physical machine.

Worker compute billing

Compute on registered workers costs 0 platform credits. Dataset and artifact storage still counts toward the workspace plan. Read the current workspace run limits before submitting a batch:

policy = client.training.billing(project.id)
print(policy["backend"], policy["compute_charge_credits"])
print(policy["limits"])  # max_active_byo_runs / max_queued_byo_runs
print(policy["usage"])   # active_byo_runs / queued_byo_runs (pending jobs)

Concurrency limits keep admitted jobs queued until a slot is available. max_queued_byo_runs and queued_byo_runs count preparing, queued and paused jobs, including planned sweep jobs. A full pending-job allowance rejects new submissions before compute starts. Enterprise has null commercial run limits; worker availability, GPU memory and other hardware safeguards still apply. Each new run records its own billing policy snapshot. Historical runs without a snapshot have no recorded billing policy; do not infer charges from the current plan or from the run's wall-clock duration.

usage = client.training.compute_usage(job_id)
if usage["duration_status"] == "measured":
    print(usage["total_compute_seconds"])
else:
    print(usage["duration_status"], usage.get("known_compute_seconds"))

Compute receipts measure each worker container attempt and exclude queue time, dataset staging and artifact delivery. total_compute_seconds is null when any attempt is unknown; known_compute_seconds is only the measured portion. Recovery and delivery retries do not create another charge. The policy endpoint reports workspace admission counts but limits its measured usage aggregate to the selected project; read compute_usage_scope and admission_usage_scope explicitly.

unrecorded_observations=true means some compute telemetry could not be recorded. Treat the total as incomplete and retain any known measured portion. The run may also include compute_usage_warning with code invalid_compute_usage; inspect it through client.training.get(job_id). This warning does not invent a duration or charge and does not discard the run's other outputs.

When original compute identity or resource admission cannot be verified, a running run can expose a recovery warning through both training.get() and training.list():

run = client.training.get(job_id)
progress = run.get("progress") or {}
if progress.get("code") == "worker_compute_identity_unverified":
    print(progress["reason"])  # Inspect the worker; local state is retained.

This warning does not mean compute has stopped. Do not automatically resubmit the job or delete retained containers. Use the current run response for this hint; events can arrive out of order. The worker clears it after verifying the original compute and resource admission. A terminal run has no active recovery hint.

Recent run events

Use a bounded snapshot to inspect a large run without paging through its entire history first:

snapshot = client.training.event_snapshot(job_id)
windows = snapshot["windows"]
updated = client.training.event_snapshot(job_id, if_snapshot=snapshot["snapshot_id"])
if not updated["unchanged"]:
    windows = updated["windows"]  # Replace all windows; do not append.

truncated_by_kind and oversized_omitted_by_kind disclose omitted history for each window. These are recent samples ordered by retained event ID, rather than a complete training curve or delivery-order stream. Use training.events() with pagination or log search for earlier events; full logs remain downloadable from the run page. Recovery warnings come from training.get(job_id).get("progress").

DataHub CPU fallback allowance

CPU inference on DataHub itself uses a separate included-time allowance. It does not consume the zero-credit registered-worker quota and has no credit overage. Read the current policy instead of assuming a fixed subscription limit:

cpu = client.training.billing(project.id)["platform_cpu_compute"]
print(cpu["allowance_seconds"], cpu["remaining_seconds"])
print(cpu["known_used_seconds"], cpu["reserved_seconds"])
print(cpu["unmeasured_committed_seconds"])
print(cpu["period_start"], cpu["period_end"])

The default period allowance is 300 seconds for Free, 1,800 for Starter and 7,200 for Team. Enterprise has no commercial allowance cap (null), while memory, execution deadlines and operational queue limits remain enforced. Admin-configured limits may differ.

DataHub reserves a bounded execution budget before dispatch. Measured execution consumes actual time and releases the unused reservation. An interrupted attempt without a trustworthy duration conservatively consumes its reservation, reported as unmeasured_committed_seconds; never present it as measured compute time. Queue wait, input staging and artifact delivery do not count as measured execution. If the allowance cannot cover a new reservation, choose a registered worker, wait for the next period or upgrade the workspace. The rejection does not deduct credits or silently switch the selected execution backend.

For a smaller CPU request, set an explicit execution deadline without changing the model's input/output options:

job = client.inference.start(
    project_id=project.id,
    source_artifact_id=model_artifact_id,
    image="example.jpg",
    cpu_time_limit_seconds=5,
)

The advertised range is 1–60 seconds, with 60 seconds as the omitted default. The reservation adds a 6-second guard (the example reserves 11 seconds). A short deadline may fail if the model cannot load and execute in time; it never silently increases the limit. This field cannot be used with worker_id and is sent as a separate multipart field, not inside model options.

When admission exceeds the remaining allowance, HTTP 402 reports platform_cpu_allowance_exhausted, required_seconds, and action wait_choose_worker_or_upgrade. Choose an explicitly registered worker, wait for the next period, or change the workspace plan; automatic retries cannot create more allowance. A released initial admission returns HTTP 409 platform_cpu_admission_expired; a deliberate resubmission needs a new idempotency_key.

Register a worker

registration = client.workers.create("Luna", allowed_project_ids=[project.id])
worker_id = registration["worker"]["_id"]
# registration["token"] is returned once. Store it securely on the worker.
workers = client.workers.list(project_id=project.id)
catalog = client.training.trainers()
assessment = client.training.assess(
    "ultralytics-detect", worker_id, project_id=project.id,
    version_id=version_id, export_key=export_key,
    parameters={"family": "yolo11", "size": "n", "batch": 1, "imgsz": 640},
)

Supply both selection fields to assess an existing completed export. Its disk_assessment.dataset_size_bytes is known when dataset_estimate_kind is sealed_manifest; legacy exports may report unknown with a null size. Disk reports and estimates are advisory, and the worker rechecks space before writing. Work-directory and Docker filesystem checks remain separate.

The gateway also serves /api/install/worker-install.py. Its installer creates an isolated Python environment and a user systemd service with automatic restart. Use a persistent disk for --prefix; active datasets and checkpoints do not use RAM-backed storage. Reviewed environment recipes prefer available prebuilt images and can build a verified context on first use after checking disk capacity.

Use catalog IDs, parameter schemas and compatible export formats rather than guessing model or environment names. Each trainer includes its official GitHub URL. Memory estimates are heuristics; a model copy must fit each selected GPU, and multiple GPUs do not combine their memory for data parallel training.

job = client.training.start(
    project_id=project.id, version_id=version_id, export_key=export_key,
    trainer_id="ultralytics-detect", worker_id=worker_id,
    parameters={"family": "yolo11", "size": "n", "epochs": 10, "batch": 1},
    gpu_ids=[], name="detector-baseline", priority=0,
    idempotency_key="my-experiment-unique-submission-id",
)
print(client.training.list(project.id, search="baseline"))
page = client.training.events(job["_id"], kind="log", limit=200)
next_page = client.training.events(job["_id"], after=page["cursor"])
result = client.training.wait(job["_id"], timeout=7200)
outputs = project.artifacts.list(run_id=job["_id"])

Pass execution_device="cpu" to start() or version.train() for a recipe that advertises CPU support. This reserves no GPU and explicitly hides GPUs from its container. CPU training can be substantially slower; host RAM and disk checks still apply. The default uses the recipe's preferred device.

Available training recipes

Query the live catalog for current parameter schemas and capabilities. The initial additional recipes have deliberately specific supervision contracts:

Recipe ID Training Dataset export
ultralytics-detect, -segment, -classify, -obb, -pose YOLO task-specific training; YOLO11 remains available Catalog-declared YOLO, ImageFolder or pose format
rfdetr-detect RF-DETR detection from scratch COCO
sam2-image Pretrained SAM 2.1 Tiny mask-decoder fine-tuning; image encoder frozen Segment Anything 2
openclip-linear-probe Pretrained SigLIP2 image encoder frozen; classification head trained ImageFolder Classification
dinov3-linear-probe Pretrained DINOv3 ViT-S/16 encoder frozen; classification head trained ImageFolder Classification
dinov3-metric Fine-tune selected DINOv3 encoder blocks for normalized retrieval embeddings ImageFolder Classification
anomalib-patchcore Fit a bounded normal-feature memory bank; no gradient optimization ImageFolder Classification with an explicit normal class
paddleocr-recognition Pretrained PP-OCRv6 Tiny recognizer with CTC supervision PaddleOCR Recognition
paddleocr-detection Pretrained PP-OCRv6 Small text detector probability-head/backbone fine-tuning PaddleOCR Detection

SAM evaluation uses ground-truth box prompts; it is not automatic segmentation AP. The OpenCLIP recipe does not train a text tower or contrastive encoder. The OCR recognition recipe handles crops; detection is a separate recipe, and document layout is not included. Native checkpoints, metrics and package/source provenance are retained as artifacts.

Reuse the same idempotency key and identical inputs after a network error. scheduled_at accepts an ISO timestamp with a timezone. A busy or offline worker queues jobs; resource checks run again before allocation. priority ranges from 0 to 100. training.update(run_id, priority=50) changes a queued job's priority. training.cancel(run_id) requests cancellation. Single-GPU Ultralytics training can use training.pause(run_id) and training.resume(run_id) when the worker advertises checkpoint support. Pause is asynchronous at an epoch checkpoint; the status becomes paused after the checkpoint is uploaded and resources are released. Resume requires the same worker and retained working data.

Run status, retained events, metrics and artifacts remain available after worker disconnection. Inspect status and error on the returned run; wait() returns terminal failed and cancelled runs as well as completed ones.

Retrieval embedding training

dinov3-metric trains image embeddings with class-based triplet supervision. Prepare at least two classes, with at least two distinct images per class in both train and validation. A batch contains samples_per_class distinct images from each chosen class; batch must be divisible by that number and include at least two classes. Increasing samples_per_class raises the training-data minimum. Class labels define which images should be similar.

job = version.train(
    "dinov3-metric", worker_id,
    export_key=imagefolder_export_key,
    parameters={"batch": 4, "samples_per_class": 2, "last_n_blocks": 1},
    idempotency_key="retrieval-experiment-001",
)
result = client.training.wait(job["_id"], timeout=7200)
outputs = project.artifacts.list(run_id=job["_id"])

The output is an encoder producing L2-normalized 384-dimensional CLS vectors. It is separate from frozen classifier probes and DreamSim preference training. Validation uses every validation image as a query, excludes itself from the gallery, and reports Recall@1/5/10 hit rates plus exact full mAP. Validation embeddings are stored on disk; distance ranking uses linear memory, while computation remains quadratic. The explicit max_eval_samples budget defaults to 5,000; larger versions require raising it rather than silently omitting data.

Worker 0.2.14 or later must advertise the metric recipe. Checkpoints, metrics, preprocessing, source hashes, and frozen/trainable weight checksums are retained. A valid zero-loss run may need no data-driven updates; inspect encoder_update_verified and optimization_outcome in model metadata. Checkpoint optimizer/RNG state is saved; pause/resume is not yet exposed for this recipe. Classifier converters reject these checkpoints. The separate metric converter exports the trained encoder as normalized vectors.

Convert retrieval embeddings to ONNX

Choose a worker advertising dinov3-metric-converter (worker 0.2.15 or later). The converter uses CPU, fixed 256×256 RGB input, FP32 and ONNX opset 18. Its 6 GiB host-RAM estimate is a planning heuristic. Completed tracked best.pt and last.pt checkpoints up to 512 MiB are accepted; their original files stay intact. The selected checkpoint's own checksums are verified, including when best.pt precedes the final epoch.

weights = next(a for a in outputs["data"] if a["filename"] == "best.pt")
parameters = {
    "format": "onnx", "imgsz": [256, 256], "opset": 18,
    "precision": "fp32", "dynamic": True, "batch": 4,
}
assessment = client.training.assess_conversion(
    project_id=project.id, source_artifact_id=weights["_id"],
    worker_id=conversion_worker_id, parameters=parameters,
)
conversion = client.training.convert(
    project_id=project.id, source_artifact_id=weights["_id"],
    worker_id=conversion_worker_id, parameters=parameters,
    idempotency_key="retrieval-onnx-001",
)
client.training.wait(conversion["_id"], timeout=600)
converted = project.artifacts.list(run_id=conversion["_id"])["data"]
model = next(a for a in converted if a["filename"] == "model.onnx")
project.artifacts.download(model["_id"], "retrieval.onnx")
graph = project.artifacts.inspect(model["_id"])

Use dynamic=False, batch=1 for a static graph. Dynamic exports have a symbolic batch axis, with nominal batches 1–4 reviewed by DataHub; height and width stay 256. A symbolic ONNX axis does not itself impose a maximum batch size. Conversion metadata retains source lineage, preprocessing, graph constraints and numerical parity results. These results verify conversion, not model accuracy. TensorRT conversion for this embedding adapter is not yet available.

Browser and SDK inference process one image at a time, including dynamic-batch exports. They resolve raw output, RGB/255 and bicubic stretch to 256; ImageNet and L2 normalization are already in the graph. The output is a 384-value unit vector, not class probabilities. Explicitly incompatible preprocessing or confidence/OCR/pose controls are rejected.

inference = client.inference.start(
    project_id=project.id, source_artifact_id=model["_id"],
    image="query.jpg", idempotency_key="retrieval-query-001",
)
client.inference.wait(inference["_id"])
vector = client.inference.result(inference["_id"])["tensors"][0]["values"]

Omitting a worker uses the platform's bounded CPU inference allowance. Supplying a compatible worker uses its CPU. Generated ONNX files remain in the platform after the conversion worker disconnects.

Reusable YAML or JSON configurations

In the dataset version's training dialog, open Reusable configuration to download YAML or import settings for review. Browser imports prepare a new run; they do not submit the job. The SDK uses the document's explicit idempotency key, so retrying the unchanged document returns the same run.

from pathlib import Path

source = Path("datahub-training.yaml").read_text(encoding="utf-8")
validated = client.training.validate_config(source)  # no allocation or dispatch
print(validated["assessment"])
job = client.training.start_config(source)

The document contains schema_version: 1, kind: training, and run, whose fields match training.start(). Include project/version/export/worker/trainer IDs and an idempotency_key. Change the key to intentionally create another run. Trainer defaults and parameter validation are shared with normal UI/SDK jobs; authorization, export readiness and resource checks run again on submission. Documents are limited to 64 KiB. Duplicate keys, YAML anchors/aliases, executable tags, unknown run fields and unsupported schema versions are rejected. This is declarative training configuration; custom Python execution uses its own job API.

Custom Python, evaluation and benchmark jobs

The worker owner enables Allow custom Python jobs in account worker settings, or calls client.workers.update(worker_id, allow_custom_jobs=True). The connected agent must advertise custom protocol 1. Project members need dataset export access and the worker must belong to them or be explicitly shared with the project.

job = client.training.custom(
    project_id=project.id, version_id=version.id, export_key=export_key,
    worker_id=worker_id, environment_id="ultralytics", kind="custom",
    script='''import json, os
from pathlib import Path
config = json.loads(Path(os.environ["DATAHUB_CONFIG"]).read_text())
outputs = Path(os.environ["DATAHUB_OUTPUTS"])
outputs.joinpath("metrics.jsonl").write_text(
    json.dumps({"epoch": 1, "score": config["parameters"]["score"]}) + "\\n")
outputs.joinpath("result.json").write_text(json.dumps({"dataset": config["dataset"]}))
''',
    parameters={"score": 0.7}, execution_device="cpu",
    estimated_host_ram_mb=2048, time_limit_seconds=300,
    idempotency_key="my-custom-experiment-001",
)

Query GET /training/custom/environments?project_id=... for reviewed environment IDs. Scripts run with the selected environment's installed Python dependencies, read-only /dataset, code and configuration, and writable /outputs. Optional source_artifact_id pins a project model by SHA; its path appears in the JSON configuration. Worker control files, credentials and the Docker socket are not mounted. Containers use a nonroot user, read-only root filesystem, disabled network, bounded scratch space and CPU/RAM/GPU/process limits.

kind="evaluation" and kind="benchmark" organize user-defined protocols; their metrics are labelled Custom script metrics. Record dataset splits, preprocessing, warmup, iteration counts and task definitions in parameters and output reports for meaningful comparisons. Built-in canonical evaluation and benchmark adapters are separate work. Scripts are limited to 64 KiB, parameters to 32 KiB, and execution to 24 hours. Output file/byte limits and periodic disk checks apply; aggregate filesystem quotas are not assumed. Custom jobs support cancellation and retained logs/artifacts; checkpoint pause is unavailable.

Parameter sweeps across workers

sweep = client.training.sweeps.create(
    project_id=project.id, version_id=version.id, export_key=export_key,
    trainer_id="ultralytics-detect", worker_ids=[worker_a_id, worker_b_id],
    base_parameters={"family": "yolo11", "size": "n", "epochs": 50},
    space={"lr0": {"type": "choice", "values": [0.001, 0.005, 0.01]}},
    method="grid", max_trials=3, max_parallelism=2,
    objective_metric="metrics/mAP50-95(B)", objective_mode="max",
    idempotency_key="detector-lr-comparison-001",
)
print(client.training.sweeps.get(sweep["_id"]))
print(client.training.sweeps.list(project.id, search="comparison"))
# client.training.sweeps.cancel(sweep["_id"])

Each trial is an ordinary, independently inspectable run with metrics, logs and model artifacts. Parameters are validated before materialization. Up to 128 trials and 16 workers are supported; parallelism is enforced across the parent sweep. The worker agent must advertise sweep claim protocol 1. Assignments are fixed among compatible selected workers. This is independent trial execution; gradient synchronization and adaptive worker reassignment are separate features. Seeded method="random" also accepts uniform, loguniform and inclusive int dimensions with low/high. scheduled_at schedules a sweep; per-trial wall-clock limits are not currently supported. Cancel stops future dispatch and requests cancellation of existing children. Comparison uses each trial's reported metric under the shared dataset/configuration; it is not a universal model ranking.

Model Conversion

Convert a completed, tracked Ultralytics checkpoint using an independently chosen worker. Original weights remain unchanged. ONNX and TensorRT outputs become downloadable project artifacts, with source checksums and environment provenance.

Inspect a proposed conversion before queuing it:

settings = {"format": "onnx", "imgsz": [640, 640], "dynamic": True}
preview = client.training.assess_conversion(
    project_id=project.id, source_artifact_id=weight_artifact_id,
    worker_id=conversion_worker_id, parameters=settings,
    # gpu_ids=["GPU-..."]  # Optional explicit target device.
)
print(preview["requirements"])
print(preview["assessment"])

This read-only request creates no run or billing admission. A worker that cannot run the converter returns assessment["compatible"] == False with reasons. Review warnings about current resources or unverified capabilities as well. Shape, model, and workspace memory estimates are planning heuristics, not guaranteed minima. Submission and worker launch perform fresh checks.

Submit the reviewed settings explicitly:

conversion = client.training.convert(
    project_id=project.id, source_artifact_id=weight_artifact_id,
    worker_id=conversion_worker_id,
    parameters=settings,
)
converted = client.training.wait(conversion["_id"])
files = project.artifacts.list(run_id=converted["_id"])

Native checkpoint conversion requires CUDA Linux x86_64. A completed verified model.onnx can instead target a separate Linux x86_64 worker or the validated ARM64 NVIDIA GB10/CDI cohort (driver 580+, TensorRT 10.14.1). Select its worker ID with the same method and parameters={"format": "tensorrt", "imgsz": [64, 64], "batch": 1, "workspace_gib": 1, "time_limit_seconds": 360}. Source dimensions must match the verified ONNX graph. Other ARM devices and Jetson/L4T remain unsupported. The ARM runtime uses a pinned official base plus reviewed offline dependencies; trusted_python_overlay (persistent) or trusted_tmpfs (ephemeral) and recipe_verified=false distinguish this from a verified derived image. Normal installed worker storage remains persistent; explicit ephemeral pilots warn that worker loss can lose unuploaded outputs. TensorRT engines are specific to the target GPU and runtime; choose the worker where you intend to use the engine. Unsupported hardware or conversion settings return an explicit error. Export parity checks compare tensor outputs, not dataset accuracy.

Tracked PaddleOCR detector selected model.safetensors checkpoints use an independent Linux x86_64 CPU worker. They support static FP32 ONNX, batch 1, opset 18, with a square input side from 64 through 1024 in multiples of 32:

conversion = client.training.convert(
    project_id=project.id, source_artifact_id=detector_selected_weight_id,
    worker_id=conversion_worker_id,
    parameters={"format": "onnx", "precision": "fp32", "imgsz": [128, 128],
                "batch": 1, "dynamic": False, "opset": 18},
)

The server verifies the selected checkpoint, configuration, processor and training lineage. last.safetensors, arbitrary uploads, TensorRT, FP16 and dynamic detector conversion are unsupported. Detection conversion preserves the original weights and uploads ONNX plus provenance/parity artifacts. The worker's supported_environment_recipes, when advertised, must include paddleocr-detection-converter. A legacy worker without that list can use an exact cached datahub-converter-paddleocr-detection:0.1.0 alias or datahub-recipe-paddleocr-detection-converter:<64-hex-recipe-hash> image. Otherwise update the worker or choose one with the recipe already cached. This software check is separate from hardware fit and from the ONNX inference protocol: detector conversion does not require inference protocol 2.

On-Demand Inference

capabilities = client.inference.capabilities(project.id)
defaults = client.inference.defaults(project.id, onnx_artifact_id)  # read-only
job = client.inference.start(
    project_id=project.id, source_artifact_id=onnx_artifact_id,
    image="example.jpg", worker_id=None,
    parser="auto", normalization="0_1", input_size=640,
)
finished = client.inference.wait(job["_id"])
predictions = client.inference.result(job["_id"])
original_image = client.inference.download_image(job["_id"], "./history/input.jpg")

For a historical run, get(), result(), and download_image() read retained records and bytes using GET requests; they never restart inference or queue compute. The worker and original model need not remain available. Project access and the retained input/result files are still required. download_image() streams the original uploaded image bytes, including EXIF, to the destination and returns its Path; it does not return a resized preview or re-encode the image.

Without a worker, DataHub executes bounded ONNX CPU inference on demand. Query capabilities for supported shapes, limits, parsers and execution targets. Unknown output signatures return bounded raw tensors rather than guessed labels. Checkpoint inference requires an explicitly selected worker and a supported, tracked model origin. Ultralytics checkpoints support detection, classification, instance masks, 2D pose and oriented boxes. Pose returns normalized keypoints with confidence and trained skeleton metadata; masks retain holes through COCO RLE. These outputs have explicit image, prediction-count and result-size limits. The initial worker ONNX runtime supports CPU execution; ONNX GPU requests are rejected until a compatible CUDA runtime is available.

Native pose inference uses the reviewed inference-native-pose-v1 worker recipe when capabilities.native_pose.enabled is true. Position-only (XY) models return keypoint confidence=None and visible=None; keep these unavailable values distinct from zero confidence or certainty. Instance confidence remains separate. XY + visibility models retain reported keypoint probabilities. Their optional pose_visibility_confidence override defaults to the runtime threshold when omitted; position-only models reject an explicit threshold. Native checkpoints use scalar stride-32 input_size, not the ONNX/engine pose_input_shape or pose_max_candidates settings. Saved legacy results are not rewritten.

Reviewed tracked pose ONNX conversions support CPU keypoint inference when the server advertises inference_capabilities.pose_onnx. The default parser="auto" resolves the tracked pose model; explicit parser="ultralytics_pose" also works. Use the exact model.onnx artifact from the reviewed pose converter. Older generic conversions and arbitrary uploaded pose graphs are not admitted by this adapter.

defaults = client.inference.defaults(project.id, pose_onnx_artifact_id)
print(defaults["parameters"]["pose_input_shape"])  # [height, width]
job = client.inference.start(
    project_id=project.id, source_artifact_id=pose_onnx_artifact_id,
    image="test.png",  # DataHub CPU, or pass an eligible worker_id
    pose_max_candidates=1000,
)

Omit input_size to use source-resolved dimensions (generic models default to 640). An explicitly supplied scalar, including 640, is rejected for pose; use pose_input_shape=[height, width] for dynamic models. Static model shapes cannot be changed. Pose uses RGB/255 and centered letterbox preprocessing; confidence, iou, and max_predictions control instance filtering. pose_visibility_confidence controls keypoint confidence filtering for models that report it. Position-only models return confidence=None and visible=None for keypoints; these values are unavailable, not zero confidence or certainty. Coordinates remain relative to the original image with tracked names and topology. Registered CPU workers need the inference-pose-v1 recipe in their supported list; DataHub CPU remains subject to its included execution allowance.

Deployments advertising capabilities.pose_tensorrt.enabled also accept reviewed, tracked pose model.engine artifacts. Read defaults() to inspect the saved profile and model_metadata.source_target_gpu_uuid. Select a registered Linux x86_64 worker supporting inference-pose-tensorrt-v1 and that exact physical GPU:

defaults = client.inference.defaults(project.id, pose_engine_artifact_id)
job = client.inference.start(
    project_id=project.id, source_artifact_id=pose_engine_artifact_id,
    image="test.png", worker_id=worker_id,
    gpu_ids=[defaults["model_metadata"]["source_target_gpu_uuid"]],
)

These engines have no CPU fallback. The worker verifies the engine checksum, GPU identity and reviewed runtime before loading. Re-registering the same GPU is supported; another GPU with the same model name is insufficient. Static dimensions remain fixed; dynamic pose_input_shape overrides must fit the recorded profile. Position-only keypoints retain null confidence and visibility. An engine artifact's presence alone does not establish inference compatibility; the server validates its completed conversion provenance.

PaddleOCR detector ONNX inference returns text-region polygons and confidence, without recognizing or inventing a transcript. A tracked conversion resolves its static model side, graph-embedded normalization and saved detector defaults automatically. Its preprocessing preserves aspect ratio, rounds dimensions to stride 32 and pads at the bottom/right; incompatible transforms are rejected. client.inference.defaults(project.id, artifact_id) returns source identity, canonical parameters and environment without executing inference, reading model weights or consuming compute allowance. Use its resolved size and thresholds when building configurable clients.

job = client.inference.start(
    project_id=project.id, source_artifact_id=detector_onnx_artifact_id,
    image="page.png", worker_id=inference_worker_id,  # omit for DataHub CPU
    parser="ocr_detection", normalization="0_1",
    resize_mode="ocr_stride32_top_left", input_size=128,
    det_threshold=0.3, box_threshold=0.6, unclip_ratio=1.5,
    max_candidates=1000, max_predictions=50,
)

The four detector settings are optional flat options and are omitted from the request when None, allowing tracked training defaults to resolve on the server. max_candidates is bounded at 10,000 and returned max_predictions at 100. OCR uses det_threshold/box_threshold; generic confidence and iou must retain their defaults rather than silently becoming ignored controls. Static tracked detector raw output uses the same correct preprocessing. OCR worker inference requires a Linux x86_64 Docker worker advertising capabilities.onnx_cpu_inference_protocol == 2. Update an older worker when the server reports an upgrade requirement; this is distinct from insufficient memory. The capability is reported by the worker, not supplied in an inference request. Current workers use the thin CPU ONNX environment for all new ONNX inference; older workers retain the legacy environment for non-OCR models. If supported_environment_recipes is advertised, it must also include inference-ocr-v1; an explicit empty list denies eligibility even with a cached image. A missing list remains compatible when protocol 2 is advertised. DataHub CPU fallback does not require a worker capability.

SDK Function Index

Use this as the compact map of the public SDK surface when writing automation or using this document as LLM context.

Client

af.verify()
af.workspaces()
af.workspace()
af.workspace("WORKSPACE_ID_OR_SLUG")
af.create_workspace(name, slug, description="", logo=None)

Workspace

workspace.projects()
workspace.project("PROJECT_ID_OR_NAME")
workspace.create_project(name, description="")
workspace.upload_dataset("./dataset", project_name="Project Name", **upload_options)

Project image lookup and iteration

project.list_images(page_number=0, page_size=100, include_labels=False, include_image=False, as_dict=False, **filters)
project.count_images(**filters)
project.find_images(page_size=500, **filters)
project.first_image(**filters)
project.live(page_size=100, include_labels=False, include_image=False, **filters)
project.iter_images(...)          # alias for live
project.live_batches(page_size=500, **filters)
project.iter_image_batches(...)   # alias for live_batches
project.matching_image_ids(**filters)
project.image("IMAGE_ID")

Project upload, download, and delete

project.upload(image, labels=None, annotations=None, filename=None, batch_name=None, tags=None, split_name=None)
project.upload_bytes(image_bytes, filename=None, **upload_options)
project.upload_dataset("./dataset", batch_name=None, tags=None, batch_size=25, show_progress=True)
project.upload_auto_dataset("./dataset", **upload_options)
project.upload_yolo_dataset("./dataset", dataset_format="yolov8", annotation_format="bbox", split_name="auto")
project.download_image("IMAGE_ID", location="./downloads")
project.delete_image("IMAGE_ID")
project.delete_images(["IMAGE_ID_1", "IMAGE_ID_2"])
project.delete_matching(dry_run=True, **filters)

Project labels and review status

Annotation reads return a list that also carries image_revision, source_write_sequence, and class_revision. Keep that list when editing: the SDK sends the captured source and class identities on save and in each bulk item. Its .copy() and slices retain those identities. A concurrent image transform or class rename produces a conflict so you can review the draft.

labels = project.get_annotations("IMAGE_ID")
pixels = project.download_image(
    "IMAGE_ID", as_bytes=True, image_revision=labels.image_revision
)
# Edit labels using these pixels, then save the same snapshot.
project.update_annotations("IMAGE_ID", labels, overwrite=True)

For an ImageRecord, refresh_annotations() captures the source and canonical dimensions together; image.download() pins that source. A source change clears its cached pixels. On a conflict, keep the draft and explicitly reload the image before deciding how to reapply edits. Do not fetch a newer revision solely to retry old coordinates or old class names. When storing labels as plain JSON, store all three revision fields too and pass them back explicitly:

project.update_annotations(
    "IMAGE_ID", stored_labels, overwrite=True,
    expected_image_revision=stored_image_revision,
    expected_source_write_sequence=stored_source_sequence,
    expected_class_revision=stored_class_revision,
)
project.get_annotations("IMAGE_ID")
project.update_annotations("IMAGE_ID", annotations, overwrite=False, verification_status=None, model_agreement=None)
project.update_labels(...)        # alias for update_annotations
project.write_annotations("IMAGE_ID", annotations, verification_status=None, model_agreement=None)
project.write_labels(...)         # alias for write_annotations
project.update_annotations_bulk([{"image_id": "IMAGE_ID", "labels": labels}], overwrite=False)
project.update_labels_bulk(...)   # alias for update_annotations_bulk

project.update_image_verification(["IMAGE_ID"], "verified")
project.set_image_verification(...)      # alias
project.mark_images_verified(["IMAGE_ID"])
project.mark_images_unverified(["IMAGE_ID"])

project.update_image_model_agreement(["IMAGE_ID"], "agree")
project.set_image_model_agreement(...)   # alias
project.mark_images_model_agree(["IMAGE_ID"])
project.mark_images_model_disagree(["IMAGE_ID"])
project.clear_images_model_agreement(["IMAGE_ID"])
project.mark_model_agree(...)            # alias for mark_images_model_agree
project.mark_model_disagree(...)         # alias for mark_images_model_disagree
project.clear_model_agreement(...)       # alias for clear_images_model_agreement

project.update_images_metadata(["IMAGE_ID"], add_tags=["reviewed"], split_name="train", model_agreement="none")
project.update_matching(dry_run=True, add_tags=["reviewed"], model_agreement="agree", **filters)
project.iter_labels(page_size=100, **filters)

Project metadata and versions

project.classes()
project.tags()
project.versions()
project.version("VERSION_ID_OR_NUMBER")
project.create_version(name, notes="", split=(80, 10, 10))
project.version_source_readiness(image_ids=None)
project.status
project.clone_status
project.clone(name, target_workspace_id=None, description=None, wait=False, timeout=600, poll_interval=1)
project.refresh()
project.wait_for_clone(timeout=600, poll_interval=1)
project.cancel_clone()

ImageRecord

image.id
image.filename
image.tags
image.batch_name
image.split_name
image.width
image.height
image.verification_status
image.is_verified
image.model_agreement
image.model_agree
image.labels
image.labels_dicts
image.absolute_labels

image.refresh_labels()
image.reload()
image.update_labels(labels, overwrite=False, verification_status=None, model_agreement=None)
image.write_labels(labels, verification_status=None, model_agreement=None)
image.add_labels(labels, verification_status=None, model_agreement=None)
image.set_labels(labels, verification_status=None, model_agreement=None)
image.set_verification_status("verified")
image.mark_verified()
image.mark_unverified()
image.set_model_agreement("agree")
image.mark_model_agree()
image.mark_model_disagree()
image.clear_model_agreement()
image.download("./downloads")
image.download(as_bytes=True)
image.download(as_pil=True)
image.download(as_cv2=True)
image.download(as_base64=True)
image.delete()

Version

version.refresh()
version.live(split_name="all", page_size=100)
version.iter_images(...)          # alias for live
version.iter_labels(split_name="val", page_size=100)
version.download_image("IMAGE_ID", split_name="train", location="./downloads")
version.generate_export("yolo", export_options=None)
version.download("yolo", location="./exports")

Label helpers

from algofly import (
    LabelRecord,
    labels_to_dicts,
    label_to_absolute,
    labels_to_absolute,
    label_to_normalized,
    labels_to_normalized,
)

Practical Notes

Read annotations by id or custom fields

If you upload crops with custom annotation fields, you can fetch the crop annotation directly by id or search by one of those fields:

crop = pipes_counting_project.annotation(crop_id)
print(crop.image_id, crop.annotation.label)

matches = pipes_counting_project.annotations_by_metadata(
    "source_image_id",
    original_image.id,
)
for match in matches:
    print(match.image_id, match.annotation.source_label_id, match.annotation.label)

Find an uploaded crop image from crop pixels

Use an explicit match mode:

crop_image = pipes_counting_project.find_image_by_crop(
    crop,
    match="hash",
    load_labels=True,
)

best_crop_image = pipes_counting_project.find_image_by_crop(
    crop,
    match="embedding",
    load_labels=True,
)
print(best_crop_image.id, best_crop_image.match_score)

When uploading crops, put source mapping on the crop image too:

classification_project.upload(
    crop,
    tags=image.tags,
    metadata={
        "source_image_id": image.id,
        "source_image_filename": image.filename,
        "source_label_id": label["id"],
    },
    annotations=[
        {
            "id": "image-classification",
            "type": "image_classification",
            "label": str(count),
        }
    ],
)

Pose and skeletons

Discover built-in and immutable project templates with project.skeletons.list(). Create a custom version with project.skeletons.create(name, keypoint_names, edges=[[0, 1]], flip_pairs=[[0, 1]], previous_id=template_id). Edge and flip indices are zero based; annotation snapshots retain their original name order.

skeleton = next(item for item in project.skeletons.list() if item["id"] == "coco17")
pose = {
    "type": "pose", "label": "person", "instance_id": "person-1",
    "skeleton": skeleton,
    "keypoints": [{"x": 0.5, "y": 0.5, "visibility": 2}
                  for _ in skeleton["keypoints"]],
}
project.update_annotations(image.id, [pose])

Coordinates are normalized image coordinates. Visibility is 0 unlabelled, 1 occluded, or 2 visible; unlabelled coordinates become zero. Update each landmark to its actual position before exporting. label_to_absolute and label_to_normalized preserve visibility and skeleton metadata.

Use version.download("COCO Keypoints", location="./pose") or version.download("YOLO Pose", location="./pose"). COCO requires a consistent skeleton within each class; YOLO requires one common skeleton across classes and a nonzero box derived from labelled landmarks. Exports reject incompatible orders instead of truncating keypoints. YOLO includes kpt_shape, flip_idx, skeleton.json, and instance metadata sidecars.

The ultralytics-pose trainer defaults to keypoint_dimensions=3 (XY + visibility). Choose the integer 2 for a trained XY-only head; every source keypoint must be labelled (visibility greater than zero). The adapter derives training labels without changing source annotations. nbs is the nominal batch size used for gradient accumulation and defaults to 64; change it deliberately for the desired training configuration. A completed short run establishes execution, not model accuracy.

OCR recognition crops

version.download("PaddleOCR Recognition", location="./recognition") downloads rectified word/line crops, exact UTF-8 transcripts and original ROI provenance. Each split has crops/, metadata/ and labels.txt (path<TAB>transcription). Unsupported geometry or multiline text is counted as skipped. This export does not turn image-level page transcripts into line supervision.

The paddleocr-recognition trainer fine-tunes the pretrained PP-OCRv6 Tiny recognizer backbone and CTC head. It preserves the pretrained dictionary and rejects unknown characters. This trains recognition only; text detection, layout and end-to-end OCR training are separate capabilities.

Raster training chips

TIFF/GeoTIFF sources require explicit chip mode. The original scientific raster stays unchanged; training chips are RGB uint8 PNGs using selected native-resolution bands and one fixed range per output channel. Omitted bands/ranges are frozen from each source's display metadata. Only page zero is currently annotated.

chips = {"width": 512, "height": 512, "overlap_x": 0, "overlap_y": 0,
         "windows": [{"x": 4000, "y": 3600, "width": 1024, "height": 512}],
         "classification_policy": "drop", "nodata": "skip_all"}
preview = project.preview_raster_chips(raster_a, chips)
version = project.create_version("raster-chips", image_ids=[raster_a, raster_b],
                                 split=(50, 50, 0), raster_chips=chips)
# Poll version.refresh().status until completed before generating an export.
archive = version.download("yolo", location="./chips",
                          export_options={"convert_to_bounding_boxes": True})

Every chip from a source inherits the same split, including overlapping chips. One source cannot supply independent training and validation data. Source image classifications are dropped unless classification_policy="inherit" is chosen; global OCR transcripts are omitted. Partial OCR regions lose their transcript with an explicit warning. Regional geometry is cropped while source annotations remain unchanged. Supported exports include per-chip source SHA, native pixel window, selected bands/ranges, CRS and affine provenance sidecars; SDK version records expose record.raster_chip. Native-dtype scientific TIFF chips and additional-page annotation are separate pending capabilities.

Derived storage accounting

New dataset versions, generated format files and cached ZIP archives count toward workspace storage. Writes reserve bytes before upload; deletion releases bytes after storage confirms removal. Chip base images use their existing base ledger and are not charged again as ordinary version objects.

Historical files can be measured and accounted one bounded page at a time. This requires the workspace billing owner and defaults to a read-only dry run:

audit = version.audit_storage(wait=True, limit=500)
report = audit["result"]
applied = version.audit_storage(apply=True, wait=True, limit=500)
# Repeat with report["next_cursor"] until it is None; inspect blocked_objects.

Backfill records existing bytes without changing or deleting dataset files. It is not an automatic global migration. Existing data remains downloadable by normal streaming when storage is full; creating a new cached archive may return 402 workspace_storage_limit. An interrupted deletion can return version_object_delete_pending; create a new version or ask an operator for explicit scoped reconciliation. Missing object HEAD alone does not clear that safety fence.

DICOM artifact viewing

Upload a DICOM file as a project artifact, or as an independent account artifact. The browser's DICOM action opens lazy frame navigation and window controls. The SDK exposes the same private metadata and frame access:

source = project.artifacts.upload("series.dcm")
metadata = project.artifacts.medical.metadata(source["_id"])
print(metadata["frame_count"], metadata["series_digest"])
project.artifacts.medical.frame(source["_id"], 0, "first-frame.png")
project.artifacts.medical.frame(source["_id"], 12, "windowed-frame.png",
                               center=40, width=400, function="LINEAR")
# For account artifacts, use client.artifacts.medical instead.

SDK frame indices start at zero; the browser shows frame numbers starting at one. Frames are display PNGs: modality rescale/LUT precedes VOI window/LUT, including MONOCHROME1 inversion and pixel padding. Original stored pixels remain unchanged. Technical metadata hashes study/series/instance identifiers and omits patient tags; this does not de-identify the original file or alter burned-in pixels.

The current verified subset is native explicit/implicit little-endian and explicit big-endian monochrome 8/16-bit images. Reads use revision-pinned storage ranges, bounded native decoding and a small private browser cache. Unsupported compressed, color and floating-point layouts fail explicitly. Frame-scoped source imports and native version materialization are available below; medical segmentation formats and full study-aware workflows remain pending.

DICOM sources and virtual annotation frames

Move an owned, unlocked project artifact into a retained original source. Frame indices are zero-based and stop_exclusive is exclusive; an omitted stop selects through the last frame. Original bytes are stored once; registered virtual frames each count toward the image quota. Repeating the same selection reuses frame identities. Account Library artifacts cannot be moved directly.

uploaded = project.artifacts.upload("series.dcm")
job = project.medical_sources.import_artifact(
    uploaded["_id"], start=0, stop_exclusive=2, wait=True)
if job.get("recovery_required"):
    # Inspect job["error"] and retry the stored immutable selection explicitly.
    job = project.medical_sources.retry_import(job["_id"], wait=True)
if job["status"] != "completed":
    raise RuntimeError(job.get("error") or job["status"])
source_id = job["source_id"]
source = project.medical_sources.get(source_id)
sources = project.medical_sources.list(search="series")
remaining = project.medical_sources.add_frames(source_id, start=2, wait=True)
project.medical_sources.download(source_id, "original-series.dcm")
# Explicitly remove the source and its frames/annotations; snapshots stay separate.
deleted = project.medical_sources.delete(source_id, wait=True)

The staging artifact entry is consumed after a successful move. The immutable original remains downloadable through medical_sources.download; its SHA is unchanged. Annotation and version preparation use project frame identities. Native version creation requires explicit medical_frames window and padding settings; browser display windows never silently set training pixels.

Wait helpers return completed, failed, cancelled, or recovery_required jobs. They never automatically restart exhausted publication. Asynchronous calls return a durable job immediately; inspect it with import_status(job["_id"]). An SDK timeout leaves that server-side job running. Deletion is asynchronous too; use delete(..., wait=True) before assuming the source has disappeared.

Native model evaluation and warm inference benchmarks

training.evaluate and training.benchmark create reviewed worker runs with kinds model_evaluation and inference_benchmark. They are separate from training.custom(kind="evaluation" | "benchmark"), whose metrics follow user scripts. Native tasks cover single-label classification or axis-aligned bounding-box detection on a frozen version's validation or test split. CPU is the default; tracked Ultralytics PT also has the guarded CUDA path described below. A generated COCO/ImageFolder export is not required; export_key is optional provenance. No model fitting or data augmentation occurs.

Discover the exact deployed schema with client.training.evaluation_capabilities(). Use client.training.evaluation_defaults(project_id=..., version_id=..., source_artifact_id=...) for verified tracked-model input/output metadata and unique exact-label mapping recommendations. Check review_required and reasons before using them. Uncertain, renamed or user-declared inputs require review; this read never starts a run or invents training lineage. Supply explicit input size, resize/interpolation, normalization, output parser, score semantics and a complete class mapping. Model output indices must match the evaluated frozen version classes. Unused version classes may remain unmapped; every selected ground-truth annotation must map, and ambiguous labels reject. Supported parser/layout pairs are classification/NC, yolo_v8_detect/BCN, and end_to_end_detect/BNC. Detection scores are probabilities. The server never guesses a parser from tensor dimensions.

# Example: a reviewed DINOv3-converted classification ONNX model.
# Use the actual frozen labels and model output order, not alphabetic guesses.
parameters = {
    "model_contract": {
        "input": {"size": [256, 256], "resize": "stretch",
                  "interpolation": "bicubic", "normalization": "embedded"},
        "output": {"parser": "classification", "layout": "NC",
                   "score_semantics": "logits"},
    },
    "class_mapping": {"Actual first model class": 0, "Actual second model class": 1},
}
evaluation = client.training.evaluate(
    project_id=project.id, version_id="FROZEN_VERSION_ID",
    source_artifact_id="MODEL_ARTIFACT_ID", worker_id="WORKER_ID",
    task="classification", split="test", parameters=parameters,
    idempotency_key="evaluate-model-version-test-v1",
)
finished = client.training.wait(evaluation["_id"])
if finished["status"] != "completed":
    raise RuntimeError(finished.get("error") or finished["status"])
result = client.training.evaluation_results(evaluation["_id"])
print(result["summary"]["metrics"])
project.artifacts.download(
    result["artifacts"]["confusion-matrix.json"]["id"], "confusion-matrix.json")

benchmark = client.training.benchmark(
    project_id=project.id, version_id="FROZEN_VERSION_ID",
    source_artifact_id="MODEL_ARTIFACT_ID", worker_id="WORKER_ID",
    task="classification", split="test",
    parameters={**parameters, "scope": "end_to_end", "batch": 1,
                "warmup_iterations": 10, "measured_iterations": 100,
                "sample_images": 32, "cpu_threads": 1,
                "max_duration_seconds": 300},
    idempotency_key="benchmark-model-version-test-v1",
)

Reviewed tracked Ultralytics classification/detection checkpoints and supported ONNX models are accepted. DINOv3 conversion uses the exact fixed contract above; ImageNet normalization is already in its graph. A generic uploaded ONNX model also needs ordered model_contract.class_names; its contract is user-declared, with no implied training lineage. Arbitrary uploaded pickle checkpoints are not accepted. External tensors, custom operators or incompatible signatures fail rather than execute API-host code. Workers need native protocol support; custom-Python owner opt-in is not required for reviewed native adapters.

Classification reports accuracy, confusion counts and per-class/aggregate precision, recall and F1. Detection reports the pinned COCO-style bbox protocol, not official leaderboard accuracy. Verified negative images participate; filtered-empty or unsupported ground truth fails closed. Results and prediction artifacts retain model SHA, version/split, source/GT and class-mapping provenance. Default evaluation processes the whole split up to the current 1,000-image capacity; larger splits require an explicit seeded max_images subset.

Benchmarks load the model once, exclude warmups, and retain measured samples, p50/p90/p95/p99, throughput and runtime/hardware metadata. model_only excludes decode/preprocessing/postprocessing; end_to_end includes them. Cold loading is reported separately. Incomplete measurements do not become completed benchmarks. parameters.time_limit_seconds bounds the whole execution (default and maximum 3,600 seconds); max_duration_seconds separately limits measured benchmark work. Compare completed native runs with client.training.compare(project.id, run_ids); incompatible cohorts include reasons and are never automatically ranked.

Native CUDA is a separately gated path for tracked Ultralytics PT models only. CPU remains the default, and every ONNX model remains CPU-only. Check evaluation_capabilities()["execution_devices"] and conditional_execution_devices["cuda"] before exposing CUDA to users. Its status="validated_subset" and validated_hardware describe the tested matrix; validation_pending still means that deployment has no completed physical proof. Select execution_device="cuda" and exactly one canonical lowercase GPU-... UUID in gpu_ids; use the selected worker's reported UUID, never an automatic device index. The server validates tracked PT lineage, worker capability, fresh capacity and the GPU lease. Failure never silently switches the request to CPU. The native CUDA recipe preserves a 2,048 MiB free GPU reserve beyond its model estimate and monitors it during execution. Its PyTorch allocator limit and cooperative monitor are not a hard GPU memory quota.

Physical classification and bounding-box detection pilots passed on Luna's Linux x86_64 NVIDIA RTX 3060 (compute capability 8.6), driver 595.99.02 and PyTorch CUDA 13.2. Their small test fixtures matched CPU predictions, including nonempty detector output, with FP32 inputs, synchronization and TF32 disabled. This verifies that tested combination, not every model, GPU, driver or CUDA version. Other supported combinations remain subject to the worker's device, compiled-architecture, runtime and resource guards and need their own validation.

CUDA result provenance records actual provider/device, synchronization, precision, TF32 policy and GPU/runtime characteristics. CPU/CUDA or different runtime/hardware cohorts remain incompatible for automatic timing comparisons. These pilots do not establish model accuracy on production data or controlled hardware rankings.

Original video sources and exact frames

In the browser, open the project's Videos tab to upload an original, follow indexing, search sources and inspect or download exact frames. Project data managers can cancel, retry or delete sources there. Live updates refresh an open tab when another client registers or changes a source.

Upload the original once as a project artifact, then register it for background indexing. Registration resolves the stored checksum and size on the server. Reusing an idempotency key preserves the original request; a retired key cannot recreate a deleted source.

artifact = project.artifacts.upload("clip.mp4")
source = project.video_sources.register(
    artifact["_id"], title="Inspection clip", idempotency_key="inspection-clip-1"
)
source = project.video_sources.get(source["_id"])

Poll get() until status is ready, failed or cancelled. A polling timeout does not stop the server job. indexed_frames reports progress; errors include a code, message and retryability. Use cancel() to request a stop and retry() to retry a failed or cancelled source. Indexing uses bounded CPU, memory and disk; busy capacity stays queued. Unsupported codecs and resource limits are explicit.

Once ready, retain the returned revision for every timeline and PNG request:

revision = source["index_revision"]
timeline = project.video_sources.frames(
    source["_id"], index_revision=revision, offset=0, limit=100
)
project.video_sources.frame(
    source["_id"], 5, "frame-000005.png", index_revision=revision
)

Ordinals are zero-based presentation-frame identities, including variable-rate video. Original PTS values remain integer strings, with missing and best-effort timestamp evidence kept separate. The server rejects stale revisions. PNGs use square pixels and the supported display rotation; the original artifact remains unchanged. Initial decoding starts from the beginning, with an explicit ordinal and execution budget; certified random-access seeking is not yet available.

list(search="Inspection", status="ready") returns bounded pages. delete() removes the source and its derived indexes after processing stops, preserving the original artifact. Artifact deletion is blocked while a source references it. Uncertain uploads retain their storage reservation until verified recovery.

Register a bounded range as ordinary annotatable dataset images when source["features"]["frame_registration"]["available"] is true:

intent = project.video_sources.register_frames(
    source["_id"], index_revision=source["index_revision"],
    start=0, stop_exclusive=120, stride=10, wait=True,
)
history = project.video_sources.registrations(source_id=source["_id"])
intent = project.video_sources.registration(intent["_id"])

Ordinals are zero-based and the stop is exclusive. Follow the advertised max_selected_frames and max_ordinal limits. Registration consumes image slots, stores the original once, and keeps annotations image-relative. Registered frames support dataset augmentation and image-format exports with temporal provenance sidecars. Pixel transforms and full-image SAM are disabled for them.

cancel_registration(id) requests cancellation before publication; retry_registration(id, wait=True) explicitly retries a clean failed/cancelled operation. Repeating a completed selection returns its historical operation. To restore logically deleted frames, send the same frozen selection with restore_missing=True and a new idempotency_key; reuse that key if delivery is uncertain. Existing published frames and their annotations remain unchanged. current_visible_count reports the frames currently present.

Video tracks, native temporal annotation formats, and source-preserving project cloning remain separate capabilities and are not enabled by frame registration.