AlgoFly Python SDK
Python client for automating AlgoFly DataHub workflows.
Use it to:
- list workspaces and projects
- upload images with or without labels
- upload folders and YOLO datasets
- iterate project data
- fetch and update labels
- mark human verification and model agreement status
- mark individual annotations as model-agree, model-disagree, or unset
- filter review queues by verification and model agreement
- create dataset versions
- download exported datasets
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
- Use
from algofly import AlgoFly. - API URLs may include or omit
/api; the SDK appends it when missing. - Omit
idfor new labels. The SDK generates a stable id from the image id and label data. - Custom ids must include an AlgoFly prefix, or include
type/label_typeso the SDK can add the prefix. - Preserve existing label ids when editing labels returned by the API.
update_labelsmerges labels by id; usewrite_labelsoroverwrite=Trueto replace all labels.- Box coordinates use
left,top,width, andheight. - Rotated boxes use bbox geometry plus
angle;cx/cycenter form and four-cornerpointsare accepted. - Polygon coordinates use
points. - Point coordinates use
xandy. - Line coordinates use
x1,y1,x2, andy2. - OCR with no ROI geometry is stored as whole-image OCR.
- OCR with ROI geometry is stored on that annotation under
ocr.text. - Multi-label image classification is supported for storage, web annotation, and export. Enable the Multi-label toggle in the classification label panel to select more than one class for an image.
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.