This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Recipes (examples)

Complete, copy-and-run SDK scripts grouped by domain area

Every example in this section is a complete script that uses argparse. Pass --help to any script to see all options. The same files live in cvat-sdk/examples/.

Shared conventions:

  • Every recipe takes --host and --token. Create a token in the CVAT UI under Profile -> Security. Wrap values that contain URL punctuation in single quotes, e.g. --host 'https://app.cvat.ai'. auth_profile.py and auth_cli.py show alternative sign-in flows.
  • Recipes that create resources keep them and print their ids and UI links. Pass --cleanup to delete what the script created (never the sources it read).
  • Recipes that inspect or export take an existing resource id (--project-id, --task-id), so they work directly against your data.
  • List-valued options accept multiple values, e.g. --labels car person.
  • Missing arguments exit with a friendly message; SDK errors surface as normal Python tracebacks.

All examples are tested in the latest SDK version.

Topics

  • Authenticate — auth_token.py, auth_profile.py, auth_cli.py
  • Projects — create/list, backup, restore, dataset export
  • Tasks — create from a bucket, bulk-create in a project, inspect and export, one task per label group, explicit job file mapping
  • Jobs — list jobs, round-robin assignment, batch-advance stages
  • Annotations — import from a file or a bucket, bulk-edit, per-label statistics, duplicate search
  • Ground truth — validation sets, honeypots, specific ground truth frames
  • Datasets — incremental download, bulk export
  • Cloud storage — attach an S3-compatible bucket
  • Webhooks — register and ping a webhook, receive deliveries locally

1 - Authenticate a client

Copy-and-run auth recipes: PAT (recommended), saved profiles, and the CLI-compatible argument set

Three recipes: auth_token.py is the recommended PAT path, auth_profile.py signs in from a saved profile with no secret in your code, and auth_cli.py wires up the shared cvat-cli argument set (--server-host, --auth, --profile, …) so your scripts feel like an extension of the CLI.

Connect with a Personal Access Token

Opens an authenticated client with a PAT, prints the server version, and prints who you are — a quick sanity check any script can copy.

Flag Required Meaning
--host yes Server URL, e.g. 'https://app.cvat.ai'
--token yes Token created in the CVAT UI (Profile -> Security)
python auth_token.py --host 'https://app.cvat.ai' --token '<your token>'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Connect to CVAT with a Personal Access Token (PAT) — the recommended way.

Steps:
  1. Open an authenticated client.
  2. Print the server version.
  3. Print who you are authenticated as (a quick sanity check for scripts).

Usage (run ``python auth_token.py --help`` for the full list of options):
  python auth_token.py --host 'https://app.cvat.ai' --token '<your token>'

Create a token in the CVAT UI under Profile -> Security.
"""

import argparse

from cvat_sdk import make_client


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (create one in the CVAT UI: Profile -> Security)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        print("Server version:", client.get_server_version())
        me = client.users.retrieve_current_user()
        print(f"Authenticated as {me.username} (id={me.id})")


if __name__ == "__main__":
    main()

Sign in from a saved profile

Uses a saved CLI profile so no secret lives in the code. Create a profile once with cvat-cli; then any script can pick it by name or fall back to the default profile.

Create a profile once:

cvat-cli --server-host 'https://app.cvat.ai' profile create --name app --set-default
Flag Required Meaning
--profile no Name of a saved profile; omit to use the default profile
python auth_profile.py --profile app
python auth_profile.py               # uses the default profile

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Authenticate without putting a token in your code: use a saved profile.

Create a profile once on the command line, then any script can use it:

  cvat-cli --server-host 'https://app.cvat.ai' profile create --name app --set-default

Steps:
  1. If --profile is passed, use that profile; otherwise use the default profile.
  2. Print who you are authenticated as.

Usage (run ``python auth_profile.py --help`` for the full list of options):
  python auth_profile.py --profile app
  python auth_profile.py               # uses the default profile
"""

import argparse
import sys

from cvat_sdk import make_client_from_profile
from cvat_sdk.core.auth import AuthStore


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument(
        "--profile", help="name of a saved profile; omit to use the default profile"
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    store = AuthStore()
    if args.profile:
        profile = store.get_profile(args.profile)
        if profile is None:
            sys.exit(f"Profile {args.profile!r} not found. Create it with cvat-cli.")
        print(f"Using profile {args.profile!r}")
    else:
        default = store.get_default_profile()
        if default is None:
            sys.exit(
                "No default profile configured. Create one with:\n"
                "    cvat-cli --server-host 'https://app.cvat.ai' profile create"
                " --name app --set-default"
            )
        name, profile = default
        print(f"Using default profile {name!r}")

    with make_client_from_profile(profile) as client:
        me = client.users.retrieve_current_user()
        print(f"Authenticated as {me.username} (id={me.id})")


if __name__ == "__main__":
    main()

Build a CLI-compatible script

Reuses cvat-cli’s shared auth arguments (--server-host, --server-port, --auth, --profile, --insecure, --organization) with configure_client_auth_arguments, then hands the parsed namespace to make_client_from_cli, which picks the right factory (profile / PAT / password) from the arguments. This is the go-to pattern when your script should feel like an extension of cvat-cli.

Flag Required Meaning
--server-host fallback Server URL when not using a profile
--auth fallback USER:PASS (deprecated password sign-in) or USER — see cvat-cli
--profile fallback Named saved profile; falls back to the default profile if no host/auth
--insecure, --organization, --server-port no Reused from cvat-cli’s shared arg set

Also honors CVAT_ACCESS_TOKEN / CVAT_PASSWORD environment variables the same way cvat-cli does.

python auth_cli.py --profile app
python auth_cli.py --server-host 'https://app.cvat.ai'          # uses CVAT_ACCESS_TOKEN env
python auth_cli.py --server-host 'https://app.cvat.ai' --auth me:secret

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Build a CLI-compatible script that reuses ``cvat-cli``'s auth argument set:
``--server-host`` / ``--server-port`` / ``--auth`` / ``--profile`` / ``--insecure`` / ``--organization``.

This is the go-to pattern when your script should feel like an extension of
``cvat-cli`` — it accepts the same flags, honors the ``CVAT_ACCESS_TOKEN`` and
``PASS`` env variables, and resolves profiles the same way (explicit
``--profile``, else the default profile if no host/auth is passed).

Steps:
  1. Register the shared auth flags with ``configure_client_auth_arguments()``.
  2. Add your own script-specific arguments on top.
  3. Hand the parsed namespace to ``make_client_from_cli()`` to create a server API client object.

Usage (run ``python auth_cli.py --help`` for the full list of options):
  python auth_cli.py --profile app

  # export CVAT_ACCESS_TOKEN='<token>'  # for macOS/Linux
  # $env:CVAT_ACCESS_TOKEN = "<token>"  # for PowerShell
  python auth_cli.py --server-host 'https://app.cvat.ai'

  python auth_cli.py --server-host 'https://app.cvat.ai' --auth me:secret
"""

import argparse

from cvat_sdk import make_client_from_cli
from cvat_sdk.core.auth import configure_client_auth_arguments


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    configure_client_auth_arguments(parser)
    # Add your script's own arguments here, e.g.
    # parser.add_argument("--task-id", type=int, required=True)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client_from_cli(args) as client:
        me = client.users.retrieve_current_user()
        print(f"Authenticated as {me.username} (id={me.id})")


if __name__ == "__main__":
    main()

Notes:

  • Personal Access Tokens are the recommended path. Password sign-in (via --auth USER:PASS) is a deprecated fallback that will be removed in a future release.
  • Full recipes: auth_token.py, auth_profile.py, auth_cli.py.

2 - Project recipes

Create/list, backup, restore, dataset export — one recipe per file

Five recipes cover the project lifecycle: project_create_and_list.py for the common CRUD path, project_add_labels.py for extending an existing project’s label schema, project_backup.py and project_restore.py for portable copies, and project_export_dataset.py for dataset export (local + cloud). For a CSV overview of a project’s jobs, see job_list.py --project-id --csv in the job recipes.

Create, list, filter, retrieve, rename

Creates a project with labels, then lists all projects, filters by name, retrieves by id, and renames it. Pass --cleanup to delete it at the end.

Flag Required Meaning
--host yes Server URL, e.g. 'https://app.cvat.ai'
--token yes Personal Access Token
--name no Project name (default 'Example project')
--labels no Label names, space-separated (default car person)
--cleanup no Delete the created project at the end
python project_create_and_list.py --host 'https://app.cvat.ai' --token '<your token>' \
    --name 'My project' --labels car person

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Create a project with labels, then list, filter, retrieve, and rename it.

Steps:
  1. Create a project with a simple label schema.
  2. List all projects visible to you (pagination is handled by the SDK).
  3. Filter projects by a name substring.
  4. Retrieve one project by id and read its labels.
  5. Rename it.
  6. Optionally delete it (--cleanup).

Usage (run ``python project_create_and_list.py --help`` for the full list of options):
  python project_create_and_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --name 'My project' --labels car person
"""

import argparse

from cvat_sdk import make_client, models
from cvat_sdk.core.filters import F


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--name", default="Example project", help="project name (default: '%(default)s')"
    )
    parser.add_argument(
        "--labels",
        nargs="+",
        default=["car", "person"],
        help="label names (default: %(default)s)",
    )
    parser.add_argument(
        "--cleanup", action="store_true", help="delete the created project at the end"
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        # 1. Create a project with labels
        project = client.projects.create(
            models.ProjectWriteRequest(
                name=args.name,
                labels=[models.PatchedLabelRequest(name=name) for name in args.labels],
            )
        )
        print(f"Created project {project.id}: {args.host}/projects/{project.id}")

        # 2. List all projects
        projects = client.projects.list()
        print(f"Projects visible to you: {len(projects)}")

        # 3. Filter by name substring
        matches = client.projects.list(filter=F.name.contains(args.name))
        print(f"Projects with {args.name!r} in the name: {[p.id for p in matches]}")

        # 4. Retrieve by id
        fetched = client.projects.retrieve(project.id)
        print(f"Project {fetched.id} labels: {[label.name for label in fetched.get_labels()]}")

        # 5. Rename
        renamed = fetched.update(models.PatchedProjectWriteRequest(name=f"{args.name} (renamed)"))
        print(f"Renamed to: {renamed.name}")

        # 6. Opt-in cleanup
        if args.cleanup:
            renamed.remove()
            print(f"Deleted project {project.id}")
        else:
            print("Keeping the project; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Add labels to an existing project

Adds labels — optionally with selectable attributes — to a project that already exists. Labels that are already there are skipped, so the recipe is safe to re-run. The tasks inside the project take their labels from the project itself, so they all pick up the change.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Id of the project to extend
--labels yes Label names to add, space-separated
--attr LABEL NAME VALUE [...] no Selectable attribute for one of the --labels; repeat for more
python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --labels car person
python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --labels car --attr car color red green blue

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Add labels, optionally with selectable attributes, to an existing project.

Steps:
  1. Retrieve the project and read the labels it already has; the requested
     labels that already exist are skipped, so the script is safe to re-run.
  2. Attach the --attr definitions to their new labels.
  3. Send one project update with the new labels. Labels of the tasks inside
     the project come from the project itself, so they all pick up the change.

Usage (run ``python project_add_labels.py --help`` for the full list of options):
  python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --labels car person
  python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --labels car --attr car color red green blue
"""

import argparse

from cvat_sdk import make_client, models


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--project-id", type=int, required=True, help="id of an existing project, e.g. 7"
    )
    parser.add_argument(
        "--labels", nargs="+", metavar="NAME", required=True, help="label names to add"
    )
    parser.add_argument(
        "--attr",
        nargs="+",
        action="append",
        default=[],
        metavar=("LABEL NAME", "VALUE"),
        help="selectable attribute for one of the --labels: label name, attribute "
        "name, then its values (repeat --attr for more attributes)",
    )
    args = parser.parse_args()
    for attr in args.attr:
        if len(attr) < 3:
            parser.error("--attr needs a label, an attribute name, and at least one value")
        if attr[0] not in args.labels:
            parser.error(f"--attr refers to label {attr[0]!r}, which is not in --labels")
    return args


def main() -> None:
    args = parse_args()
    attributes_per_label: dict[str, list[models.AttributeRequest]] = {}
    for label_name, attribute_name, *values in args.attr:
        attributes_per_label.setdefault(label_name, []).append(
            models.AttributeRequest(
                name=attribute_name,
                input_type=models.InputTypeEnum("select"),
                values=values,
                default_value=values[0],
                mutable=True,
            )
        )

    with make_client(args.host, access_token=args.token) as client:
        project = client.projects.retrieve(args.project_id)
        existing = {label.name for label in project.get_labels()}

        new_labels = []
        for name in args.labels:
            if name in existing:
                print(f"Label {name!r} already exists, skipping")
                continue
            new_labels.append(
                models.PatchedLabelRequest(name=name, attributes=attributes_per_label.get(name, []))
            )

        if new_labels:
            project.update(models.PatchedProjectWriteRequest(labels=new_labels))
        print(
            f"Added {len(new_labels)} labels to project {project.id}: "
            f"{', '.join(label.name for label in new_labels) or '-'}"
        )
        print(f"Project {project.id} labels: {[label.name for label in project.get_labels()]}")


if __name__ == "__main__":
    main()

Back up a project

Downloads a full project backup zip — tasks, jobs, annotations, and settings. Pair with project_restore.py to migrate or clone.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Id of the project to back up
--output no Destination file (default project_<id>_backup.zip)
python project_backup.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 42

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Download a backup zip of an existing project.

A backup contains the project's tasks, jobs, annotations, and settings. Pair
this recipe with project_restore.py to migrate or clone a project.

Steps:
  1. Retrieve the project by id.
  2. Download its backup to --output (default: project_<id>_backup.zip).

Usage (run ``python project_backup.py --help`` for the full list of options):
  python project_backup.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 42
"""

import argparse
from pathlib import Path

from cvat_sdk import make_client


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--project-id", type=int, required=True, help="id of an existing project, e.g. 42"
    )
    parser.add_argument(
        "--output",
        type=Path,
        help="destination file path (default: project_<id>_backup.zip)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        project = client.projects.retrieve(args.project_id)
        output = args.output or Path(f"project_{project.id}_backup.zip")
        project.download_backup(output)
        print(f"Backed up project {project.id} to {output.resolve()}")


if __name__ == "__main__":
    main()

Restore a project

Restores a project from a backup zip as a brand-new project. Pass --cleanup to delete the restored copy afterwards — useful when validating a backup file.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--backup yes Path to a project backup zip
--cleanup no Delete the restored copy (never touches the backup file)
python project_restore.py --host 'https://app.cvat.ai' --token '<your token>' \
    --backup './project_42_backup.zip'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Restore a project from a backup zip as a new project.

Pair with project_backup.py to migrate or clone a project.

Steps:
  1. Restore --backup as a brand-new project.
  2. Optionally delete the restored copy (--cleanup) — useful when testing a
     backup file.

Usage (run ``python project_restore.py --help`` for the full list of options):
  python project_restore.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --backup './project_42_backup.zip'
"""

import argparse
import sys
from pathlib import Path

from cvat_sdk import make_client


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument("--backup", type=Path, required=True, help="path to a project backup zip")
    parser.add_argument(
        "--cleanup",
        action="store_true",
        help="delete the restored project at the end (never touches the source backup)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if not args.backup.is_file():
        sys.exit(f"--backup {args.backup} does not exist")

    with make_client(args.host, access_token=args.token) as client:
        restored = client.projects.create_from_backup(args.backup)
        print(f"Restored a copy as project {restored.id}: {args.host}/projects/{restored.id}")

        if args.cleanup:
            restored.remove()
            print(f"Deleted restored project {restored.id}")
        else:
            print("Keeping the restored project; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Export a project’s tasks individually (local + cloud)

Exports each task in a project as its own dataset, both to a local zip and straight to a registered cloud storage. By default every task is exported; pass --task-id to export only a specific subset. Validates the format name against the server’s list before starting.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Id of the project to export
--cloud-storage-id yes Registered cloud storage id (see cloud_storage_register.py)
--export-format no Exporter name (default 'COCO 1.0')
--task-id no Task ids to export, space-separated (default: every task in the project)
python project_export_dataset.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 42 --cloud-storage-id 7 --export-format 'COCO 1.0'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Export a project's tasks individually, without images, to local zips AND to
a registered cloud storage.

By default every task in the project is exported; pass --task-id to export
only a specific subset. This is the SDK-only stand-in for what could become a
bulk per-task export command in cvat-cli.

Steps:
  1. Fetch the server's export format list and validate --export-format.
  2. Resolve which tasks to export: --task-id filters to a subset of the
     project's tasks; omit it to export every task in the project.
  3. For each task: export to task_<id>_dataset.zip in the current directory,
     then export the same dataset straight to the cloud storage (no local
     download).

Usage (run ``python project_export_dataset.py --help`` for the full list of options):
  # every task in the project
  python project_export_dataset.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 42 --cloud-storage-id 7 --export-format 'COCO 1.0'

  # only tasks 10 and 11
  python project_export_dataset.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 42 --cloud-storage-id 7 --task-id 10 11
"""

import argparse
import sys
from pathlib import Path

from cvat_sdk import make_client
from cvat_sdk.core.proxies.types import Location


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--project-id", type=int, required=True, help="id of an existing project, e.g. 42"
    )
    parser.add_argument(
        "--cloud-storage-id",
        type=int,
        required=True,
        help="a registered cloud storage id (see cloud_storage_register.py)",
    )
    parser.add_argument(
        "--export-format",
        default="COCO 1.0",
        help="exporter name, e.g. 'COCO 1.0' (default: '%(default)s')",
    )
    parser.add_argument(
        "--task-id",
        type=int,
        nargs="+",
        metavar="ID",
        help="export only these task ids (must belong to the project); "
        "omit to export every task in the project",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        # 1. Validate the format against the server's list.
        # Low-level API: there is no high-level proxy for the format list yet.
        formats, _ = client.api_client.server_api.retrieve_annotation_formats()
        names = [f.name for f in formats.exporters]
        if args.export_format not in names:
            sys.exit(
                f"Unknown export format {args.export_format!r}. Choose one of: {', '.join(names)}"
            )

        # 2. Resolve which tasks to export.
        project = client.projects.retrieve(args.project_id)
        tasks_by_id = {task.id: task for task in project.get_tasks()}
        if args.task_id:
            missing = [str(tid) for tid in args.task_id if tid not in tasks_by_id]
            if missing:
                sys.exit(f"Task id(s) {', '.join(missing)} not found in project {project.id}")
            tasks = [tasks_by_id[tid] for tid in args.task_id]
        else:
            tasks = list(tasks_by_id.values())
        if not tasks:
            sys.exit(f"Project {project.id} has no tasks to export")

        # 3. Export each task individually: a local zip AND straight to the cloud storage.
        for task in tasks:
            local_path = Path(f"task_{task.id}_dataset.zip")
            task.export_dataset(
                args.export_format, local_path, include_images=False, location=Location.LOCAL
            )
            print(f"Exported {local_path.resolve()}")

            remote_name = f"task_{task.id}_dataset.zip"
            task.export_dataset(
                args.export_format,
                remote_name,
                include_images=False,
                location=Location.CLOUD_STORAGE,
                cloud_storage_id=args.cloud_storage_id,
            )
            print(f"Exported {remote_name} to cloud storage {args.cloud_storage_id}")

        print(f"Exported {len(tasks)} task dataset(s) from project {project.id}")


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
Project.download_backup(..., lightweight=True) Produce a smaller backup that omits media.
client.projects.create_from_dataset(...) Create a project directly from a dataset archive.
Project.import_dataset(format_name, path) Import annotations/data into an existing project - the import counterpart of export_dataset.
Project.get_annotations() Fetch the project’s labeled data.

Notes:

  • list() returns the whole collection; pagination is handled for you.
  • A project backup captures tasks, jobs, users, and settings in a single zip - but no raw media beyond what export_dataset would include.
  • For a CSV overview of a project’s jobs (no annotation geometry), use job_list.py --project-id <id> --csv. For an actual dataset export, use project_export_dataset.py.
  • include_images=False exports annotations only and is much smaller.
  • Full recipes: project_create_and_list.py, project_add_labels.py, project_backup.py, project_restore.py, project_export_dataset.py.

3 - Task recipes

Create one task or a batch of tasks from a bucket; inspect and export existing tasks

Five recipes cover the task lifecycle: task_create_from_cloud.py creates one task from object keys already in a registered bucket, tasks_bulk_from_cloud.py creates a whole batch of tasks in a project from that same bucket, task_inspect_and_export.py inspects an existing task, exports its dataset locally, and reports analytics from its event log, tasks_create_per_label_group.py splits annotation work over one set of images into several tasks, one per shape type, and task_create_job_mapping.py creates a task with an explicit file-to-job mapping.

Create a task from cloud object keys

Creates a task from images that already live in a registered bucket.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--cloud-storage-id yes Registered cloud storage id (see cloud_storage_register.py)
--cloud-keys yes Object keys in the bucket, space-separated
--name no Task name (default 'Task from cloud storage')
--labels no Label names, space-separated (default object)
--cleanup no Delete the created task at the end
python task_create_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
    --cloud-storage-id 7 --cloud-keys 'images/0001.jpg' 'images/0002.jpg' \
    --labels car person

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Create an annotation task from images that already live in a registered
cloud storage.

Steps:
  1. Create a task whose data is a list of object keys in the bucket.
  2. Print the result.
  3. Optionally delete it (--cleanup).

Register a bucket first with cloud_storage_register.py to get the storage id.

Usage (run ``python task_create_from_cloud.py --help`` for the full list of options):
  python task_create_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --cloud-storage-id 7 --cloud-keys 'images/0001.jpg' 'images/0002.jpg' \\
      --labels car person
"""

import argparse

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.tasks import ResourceType


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--cloud-storage-id",
        type=int,
        required=True,
        help="a registered cloud storage id (see cloud_storage_register.py)",
    )
    parser.add_argument(
        "--cloud-keys",
        nargs="+",
        required=True,
        help="object keys in the bucket, e.g. 'images/0001.jpg' 'images/0002.jpg'",
    )
    parser.add_argument(
        "--name",
        default="Task from cloud storage",
        help="task name (default: '%(default)s')",
    )
    parser.add_argument(
        "--labels", nargs="+", default=["object"], help="label names (default: %(default)s)"
    )
    parser.add_argument("--cleanup", action="store_true", help="delete the created task at the end")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        # ResourceType.SHARE + cloud_storage_id = read images from the bucket
        task = client.tasks.create_from_data(
            spec=models.TaskWriteRequest(
                name=args.name,
                labels=[models.PatchedLabelRequest(name=name) for name in args.labels],
            ),
            resource_type=ResourceType.SHARE,
            resources=args.cloud_keys,
            data_params={"cloud_storage_id": args.cloud_storage_id},
        )
        print(f"Created task {task.id} with {task.size} frames: {args.host}/tasks/{task.id}")

        if args.cleanup:
            task.remove()
            print(f"Deleted task {task.id}")
        else:
            print("Keeping the task; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Bulk-create tasks in a project from a bucket

Creates several tasks in one call, all inside the same project, each reading its data from a registered cloud storage. Two ways to spell a task’s data, repeatable and mixable: --task KEY[,KEY,...] lists explicit object keys (a single key makes a video/single-image task; multiple keys make an image task whose frames are those keys in order), and --task-pattern PATTERN makes one task from every bucket file matching a fnmatch wildcard (e.g. 'batch_a/*.jpg'), resolved from the bucket’s manifest instead of listing every key by hand. Because every task belongs to the project, they share its label schema — no --labels here.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--cloud-storage-id yes Registered cloud storage id (see cloud_storage_register.py)
--project-id yes Project the tasks are created in; supplies the labels
--task KEY[,KEY,...] one of --task / --task-pattern One --task per task; repeat the flag for more
--task-pattern PATTERN one of --task / --task-pattern One task per wildcard, matched via the bucket’s manifest; repeat for more
--manifest no Manifest object key used to resolve --task-pattern (default 'manifest.jsonl')
--name-prefix no Task-name prefix; each task is named <prefix> N (default 'Bulk task')
--cleanup no Delete every created task at the end
# three video tasks in project 42
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
    --cloud-storage-id 7 --project-id 42 \
    --task 'videos/clip_01.mp4' --task 'videos/clip_02.mp4' --task 'videos/clip_03.mp4'

# two image-batch tasks in project 42
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
    --cloud-storage-id 7 --project-id 42 \
    --task 'batch_a/img_1.jpg,batch_a/img_2.jpg' \
    --task 'batch_b/img_1.jpg,batch_b/img_2.jpg'

# the same two batches, without listing every key: one task per wildcard match
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
    --cloud-storage-id 7 --project-id 42 --manifest manifest.jsonl \
    --task-pattern 'batch_a/*.jpg' --task-pattern 'batch_b/*.jpg'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Bulk-create tasks inside a project, each task's data read from a registered
cloud storage.

Two ways to spell a task's data, repeatable and mixable:
  --task KEY[,KEY,...]    explicit object keys, in order:
                             * a single key -> a video task (or single-image task);
                             * several keys -> an image task, in the given order.
  --task-pattern PATTERN  every bucket file matching a fnmatch wildcard (e.g.
                           'batch_a/*.jpg'), resolved from the bucket's
                           manifest instead of being listed one by one.

All tasks land in the same project, so they share its label schema.

Steps:
  1. For each --task, create a task in --project-id from its explicit keys.
  2. For each --task-pattern, create a task in --project-id from every bucket
     file the wildcard matches, resolved via the bucket's manifest.
  3. Print the created ids and a summary count.
  4. Optionally delete every created task (--cleanup).

Register a bucket first with cloud_storage_register.py to get the storage id.
A --task-pattern also needs a manifest file already generated for the bucket -
see "How to generate manifest file" in the CVAT docs on attaching cloud storage.

Usage (run ``python tasks_bulk_from_cloud.py --help`` for the full list of options):
  # three video tasks in project 42
  python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --cloud-storage-id 7 --project-id 42 \\
      --task 'videos/clip_01.mp4' --task 'videos/clip_02.mp4' --task 'videos/clip_03.mp4'

  # two image-batch tasks in project 42
  python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --cloud-storage-id 7 --project-id 42 \\
      --task 'batch_a/img_1.jpg,batch_a/img_2.jpg' \\
      --task 'batch_b/img_1.jpg,batch_b/img_2.jpg'

  # the same two batches, without listing every key: one task per wildcard match
  python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --cloud-storage-id 7 --project-id 42 --manifest manifest.jsonl \\
      --task-pattern 'batch_a/*.jpg' --task-pattern 'batch_b/*.jpg'
"""

import argparse

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.tasks import ResourceType


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--cloud-storage-id",
        type=int,
        required=True,
        help="a registered cloud storage id (see cloud_storage_register.py)",
    )
    parser.add_argument(
        "--project-id",
        type=int,
        required=True,
        help="tasks are created in this project and inherit its labels",
    )
    parser.add_argument(
        "--task",
        dest="tasks",
        action="append",
        default=[],
        metavar="KEY[,KEY,...]",
        help="comma-separated object keys for one task; repeat for more tasks",
    )
    parser.add_argument(
        "--task-pattern",
        dest="task_patterns",
        action="append",
        default=[],
        metavar="PATTERN",
        help="one task from every bucket file matching this fnmatch wildcard "
        "(e.g. 'batch_a/*.jpg'); repeat for more tasks. Needs --manifest. "
        "(default: '%(default)s')",
    )
    parser.add_argument(
        "--manifest",
        default="manifest.jsonl",
        help="manifest object key in the bucket, used to resolve --task-pattern "
        "(default: '%(default)s')",
    )
    parser.add_argument(
        "--name-prefix",
        default="Bulk task",
        help="task name prefix; each task is named '<prefix> N' (default: '%(default)s')",
    )
    parser.add_argument(
        "--cleanup", action="store_true", help="delete every created task at the end"
    )
    args = parser.parse_args()
    if not args.tasks and not args.task_patterns:
        parser.error("at least one --task or --task-pattern is required")
    return args


def main() -> None:
    args = parse_args()
    task_key_groups = [
        [key.strip() for key in spec.split(",") if key.strip()] for spec in args.tasks
    ]
    if any(not group for group in task_key_groups):
        raise SystemExit("each --task must contain at least one non-empty key")

    with make_client(args.host, access_token=args.token) as client:
        created = []
        for keys in task_key_groups:
            # Tasks in a project inherit the project's labels — do NOT pass labels.
            # ResourceType.SHARE + cloud_storage_id reads the objects from the bucket.
            task = client.tasks.create_from_data(
                spec=models.TaskWriteRequest(
                    name=f"{args.name_prefix} {len(created) + 1}", project_id=args.project_id
                ),
                resource_type=ResourceType.SHARE,
                resources=keys,
                data_params={"cloud_storage_id": args.cloud_storage_id},
            )
            created.append(task)
            print(f"Created task {task.id} ({task.size} frames): {args.host}/tasks/{task.id}")

        for pattern in args.task_patterns:
            # A wildcard task needs the bucket's manifest as its only resource;
            # the server expands filename_pattern against it (fnmatch syntax).
            # use_cache=True is required to serve data straight from the bucket.
            task = client.tasks.create_from_data(
                spec=models.TaskWriteRequest(
                    name=f"{args.name_prefix} {len(created) + 1}", project_id=args.project_id
                ),
                resource_type=ResourceType.SHARE,
                resources=[args.manifest],
                data_params={
                    "cloud_storage_id": args.cloud_storage_id,
                    "use_cache": True,
                    "filename_pattern": pattern,
                },
            )
            created.append(task)
            print(
                f"Created task {task.id} ({task.size} frames) from pattern {pattern!r}: "
                f"{args.host}/tasks/{task.id}"
            )

        print(f"Created {len(created)} tasks in project {args.project_id}")

        if args.cleanup:
            for task in created:
                task.remove()
            print(f"Deleted {len(created)} tasks")
        else:
            print("Keeping the tasks; pass --cleanup to delete them")


if __name__ == "__main__":
    main()

Inspect a task and export its dataset

Prints a summary of an existing task (labels, jobs, frames), exports its dataset to a local zip, then exports the task’s event log and reports two analytics computed from it: how many people are currently assigned to a job, and how many jobs were rejected in review and sent back for rework.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id yes Id of the task to inspect and export
--export-format no Exporter name (default 'COCO 1.0')
python task_inspect_and_export.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --export-format 'COCO 1.0'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Inspect an existing task (labels, jobs, frames), export its dataset to a
local zip, and export its event log to report quick analytics.

Steps:
  1. Retrieve the task and print a summary: labels, jobs (stage/state), frames.
  2. Fetch the server's export format list and validate --export-format.
  3. Export the dataset to task_<id>_dataset.zip in the current directory.
  4. Export the task's event log to task_<id>_events.csv and report two
     analytics: how many people are currently assigned to a job, and how
     many jobs were rejected in review and sent back for rework - the second
     one needs the log, since a job's current state doesn't show its history.

Usage (run ``python task_inspect_and_export.py --help`` for the full list of options):
  python task_inspect_and_export.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --export-format 'COCO 1.0'
"""

import argparse
import csv
import sys
from pathlib import Path

from cvat_sdk import make_client
from cvat_sdk.core.downloading import Downloader
from cvat_sdk.core.proxies.types import Location


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
    )
    parser.add_argument(
        "--export-format",
        default="COCO 1.0",
        help="exporter name, e.g. 'COCO 1.0' (default: '%(default)s')",
    )
    return parser.parse_args()


def count_reworks(events_path: Path) -> int:
    """Count how many times a job in the log was rejected in review, i.e. sent
    back to the annotator for rework. A job's current state only shows where
    it stands now, not how many times it got there, so this needs the log.
    """
    with events_path.open(newline="") as f:
        return sum(
            1
            for row in csv.DictReader(f)
            if row["scope"] == "update:job"
            and row["obj_name"] == "state"
            and row["obj_val"] == "rejected"
        )


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        # 1. Inspect
        task = client.tasks.retrieve(args.task_id)
        jobs = task.get_jobs()
        print(f"Task {task.id}: {task.name!r}, {task.size} frames")
        print(f"  labels: {[label.name for label in task.get_labels()]}")
        for job in jobs:
            print(f"  job {job.id}: stage={job.stage}, state={job.state}")

        # 2. Validate the export format against the server's list.
        # Low-level API: there is no high-level proxy for the format list yet.
        formats, _ = client.api_client.server_api.retrieve_annotation_formats()
        names = [f.name for f in formats.exporters]
        if args.export_format not in names:
            sys.exit(
                f"Unknown export format {args.export_format!r}. Choose one of: {', '.join(names)}"
            )

        # 3. Export the dataset to a local zip
        local_path = Path(f"task_{task.id}_dataset.zip")
        task.export_dataset(
            args.export_format, local_path, include_images=False, location=Location.LOCAL
        )
        print(f"Exported {local_path.resolve()}")

        # 4. Export the task's event log and report quick analytics.
        events_path = Path(f"task_{task.id}_events.csv")
        Downloader(client).prepare_and_download_file_from_endpoint(
            client.api_client.events_api.create_export_endpoint,
            events_path,
            query_params={"task_id": task.id},
        )
        print(f"Exported {events_path.resolve()}")

        assigned = {job.assignee.id for job in jobs if job.assignee}
        print(f"  {len(assigned)} people currently assigned, {count_reworks(events_path)} reworks")


if __name__ == "__main__":
    main()

Split work into one task per label group

Creates one task per --task 'NAME:TYPE:label1,label2' spec over the same images, with that spec’s labels typed to its shape type. Boxes, polygons, and tags then get annotated in parallel, each in a task that shows only the labels its annotator needs.

The tasks are standalone, not tasks of one project: tasks in a project share the project’s label set, so a per-task label set cannot exist inside a single project — the SDK rejects labels on a task that has a project_id. Every spec is validated before anything is created, and --cleanup deletes the tasks created so far even if a later one fails.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--image-dir yes Directory with the images to annotate; every file in it is uploaded
--task NAME:TYPE:LABELS yes One task; repeat for more
--segment-size no Frames per job, in every task
--cleanup no Delete the created tasks at the end

TYPE is one of rectangle, polygon, polyline, points, ellipse, cuboid, mask, tag, any.

python tasks_create_per_label_group.py --host 'https://app.cvat.ai' --token '<your token>' \
    --image-dir ./images \
    --task 'boxes:rectangle:car,person' \
    --task 'roads:polygon:road,lane' \
    --task 'weather:tag:rain,snow'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Create one task per shape type and label group over the same set of images,
so boxes, polygons, and tags are annotated in parallel by different people
instead of all at once in one crowded task.

Each --task spec is 'NAME:TYPE:label1,label2': the task's name, the shape type
its labels are drawn with, and the labels themselves.

The tasks are standalone, not tasks of one project: tasks in a project share the
project's label set, so a per-task label set cannot exist inside a single
project.

Steps:
  1. Parse and validate every --task spec before creating anything.
  2. Collect the files from --image-dir.
  3. Create one task per spec, with that spec's labels typed to its shape type.
  4. Print a summary of what each task got.

Usage (run ``python tasks_create_per_label_group.py --help`` for the full list of options):
  python tasks_create_per_label_group.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --image-dir ./images \\
      --task 'boxes:rectangle:car,person' \\
      --task 'roads:polygon:road,lane' \\
      --task 'weather:tag:rain,snow'
"""

import argparse
import sys
from pathlib import Path

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.tasks import ResourceType

LABEL_TYPES = {
    "any",
    "cuboid",
    "ellipse",
    "mask",
    "points",
    "polygon",
    "polyline",
    "rectangle",
    "tag",
}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--image-dir",
        type=Path,
        required=True,
        help="directory with the images to annotate; every file in it is uploaded, "
        "and the server decides which media it accepts",
    )
    parser.add_argument(
        "--task",
        action="append",
        required=True,
        metavar="NAME:TYPE:LABELS",
        help="one task, e.g. 'boxes:rectangle:car,person' (repeat for more)",
    )
    parser.add_argument("--segment-size", type=int, help="frames per job, in every task")
    parser.add_argument(
        "--cleanup", action="store_true", help="delete the created tasks at the end"
    )
    return parser.parse_args()


def parse_spec(spec: str) -> tuple[str, str, list[str]]:
    """'boxes:rectangle:car,person' -> ('boxes', 'rectangle', ['car', 'person'])."""
    name, _, rest = spec.partition(":")
    type_, _, labels = rest.partition(":")
    names = [label.strip() for label in labels.split(",") if label.strip()]
    if not (name and type_ and names):
        sys.exit(f"Bad --task {spec!r}: expected 'NAME:TYPE:label1,label2'")
    if type_ not in LABEL_TYPES:
        sys.exit(
            f"Unknown label type {type_!r} in --task {spec!r}. "
            f"Choose one of: {', '.join(sorted(LABEL_TYPES))}"
        )
    return name, type_, names


def main() -> None:
    args = parse_args()
    # 1. Validate every spec first, so a typo in the last one costs nothing.
    specs = [parse_spec(spec) for spec in args.task]

    # 2. The same files go into every task. They are passed as they are found:
    # the server is the authority on which media formats it supports, so
    # filtering by extension here would only reject files CVAT can read.
    images = sorted(p for p in args.image_dir.iterdir() if p.is_file())
    if not images:
        sys.exit(f"No files found in {args.image_dir}")

    created = []
    with make_client(args.host, access_token=args.token) as client:
        try:
            for name, type_, label_names in specs:
                task = client.tasks.create_from_data(
                    spec=models.TaskWriteRequest(
                        name=name,
                        labels=[
                            models.PatchedLabelRequest(name=label, type=type_)
                            for label in label_names
                        ],
                        **({"segment_size": args.segment_size} if args.segment_size else {}),
                    ),
                    resource_type=ResourceType.LOCAL,
                    resources=images,
                )
                created.append(task)
                print(
                    f"Created task {task.id} {name!r} "
                    f"({type_}: {', '.join(label_names)}, {len(task.get_jobs())} job(s)): "
                    f"{args.host}/tasks/{task.id}"
                )
            print(f"Created {len(created)} task(s) from {len(images)} file(s)")
        finally:
            # Clean up whatever was created, including after a mid-run failure.
            if args.cleanup:
                for task in created:
                    task.remove()
                    print(f"Deleted task {task.id}")
            elif created:
                print("Keeping the tasks; pass --cleanup to delete them")


if __name__ == "__main__":
    main()

Decide which files go into which job

Normally CVAT cuts a task into jobs of segment_size frames. The job_file_mapping data parameter replaces that with an explicit grouping: one job per camera, per scene, or per delivery batch. Group the files yourself with repeated --job flags, or chunk the directory with --files-per-job N — a count of files, the explicit equivalent of segment_size.

Every file in --image-dir must belong to exactly one job — unknown files, duplicates, and leftovers are rejected before the task is created. Afterwards the recipe reads the jobs back from the server and writes job_file_mapping.csv, one job_id,frame,file_name row per file: the mapping you see is the one that exists, and every file names the frame it actually landed on. Frame numbers are zero-based indexes within the task.

The example creates one object label so the task is ready for annotation.

job_file_mapping implies predefined file ordering and takes one file per frame, so it applies to image tasks only: a video is a single file the server cuts into frames itself, and there is nothing to map. CVAT rejects the request if the task’s data turns out to be a video.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--image-dir yes Directory with the task’s images, one file per frame
--job FILE [FILE ...] one of --job / --files-per-job The files of one job; repeat for more
--files-per-job N one of --job / --files-per-job Number of files per job: chunk the directory into jobs of N files each
--name no Task name
--output no Mapping CSV path (default job_file_mapping.csv)
--cleanup no Delete the created task at the end
python task_create_job_mapping.py --host 'https://app.cvat.ai' --token '<your token>' \
    --image-dir ./images \
    --job 'cam1_001.png' 'cam1_002.png' --job 'cam2_001.png' 'cam2_002.png'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Create a task whose jobs are defined by you, file by file, instead of by a
frame count: one job per camera, per scene, per delivery batch.

Two ways to group:
  --job FILE [FILE ...]   one job per occurrence, in the given file order
  --files-per-job N       a count, not a file list: chunk the sorted directory
                          listing into jobs of N files each, the explicit
                          equivalent of the task's segment_size

Steps:
  1. Build the file groups and check them against --image-dir: every file must
     exist, appear once, and belong to a job.
  2. Create the task with the job_file_mapping data parameter.
  3. Read the jobs back from the server and print/write the resulting mapping,
     so what you see is what the server built, not what was requested.

job_file_mapping implies predefined file ordering and takes one file per frame,
so it applies to image tasks only: a video is a single file the server cuts into
frames itself, and there is nothing to map. A directory of images is therefore
what this recipe takes, and CVAT rejects the request if the data turns out to be
a video.

Usage (run ``python task_create_job_mapping.py --help`` for the full list of options):
  python task_create_job_mapping.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --image-dir ./images \\
      --job 'cam1_001.png' 'cam1_002.png' --job 'cam2_001.png' 'cam2_002.png'
  python task_create_job_mapping.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --image-dir ./images --files-per-job 50
"""

import argparse
import csv
import sys
from pathlib import Path

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.tasks import ResourceType


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--image-dir",
        type=Path,
        required=True,
        help="directory with the task's images, one file per frame (a video cannot be "
        "mapped to jobs file by file)",
    )
    grouping = parser.add_mutually_exclusive_group(required=True)
    grouping.add_argument(
        "--job",
        action="append",
        nargs="+",
        metavar="FILE",
        help="the files of one job (repeat for more jobs)",
    )
    grouping.add_argument(
        "--files-per-job",
        type=int,
        metavar="N",
        help="number of files per job: chunk the sorted directory listing into jobs of N "
        "files each (the explicit equivalent of the task's segment_size)",
    )
    parser.add_argument("--name", default="Task with a job file mapping", help="task name")
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("job_file_mapping.csv"),
        help="path to write the resulting mapping to (default: %(default)s)",
    )
    parser.add_argument("--cleanup", action="store_true", help="delete the created task at the end")
    return parser.parse_args()


def build_groups(args: argparse.Namespace, available: list[str]) -> list[list[str]]:
    """The file groups to send as job_file_mapping, validated against the directory."""
    if args.files_per_job:
        if args.files_per_job < 1:
            sys.exit("--files-per-job must be at least 1")
        return [
            available[start : start + args.files_per_job]
            for start in range(0, len(available), args.files_per_job)
        ]

    groups = [list(group) for group in args.job]
    known = set(available)
    assigned = []
    for group in groups:
        assigned.extend(group)

    unknown = [name for name in assigned if name not in known]
    if unknown:
        sys.exit(f"File(s) {', '.join(unknown)} not found in {args.image_dir}")

    duplicates = sorted({name for name in assigned if assigned.count(name) > 1})
    if duplicates:
        sys.exit(f"File(s) {', '.join(duplicates)} appear in more than one job")

    unassigned = sorted(known - set(assigned))
    if unassigned:
        sys.exit(
            f"File(s) {', '.join(unassigned)} are not assigned to any job. "
            "Every file in --image-dir must belong to exactly one --job."
        )
    return groups


def main() -> None:
    args = parse_args()
    # The files are taken as they are found: the server is the authority on
    # which media formats it supports, so filtering by extension here would
    # only reject files CVAT can read.
    available = sorted(p.name for p in args.image_dir.iterdir() if p.is_file())
    if not available:
        sys.exit(f"No files found in {args.image_dir}")

    groups = build_groups(args, available)
    resources = [args.image_dir / name for group in groups for name in group]
    print(f"Requesting {len(groups)} job(s) over {len(resources)} file(s)")

    with make_client(args.host, access_token=args.token) as client:
        task = client.tasks.create_from_data(
            spec=models.TaskWriteRequest(
                name=args.name,
                labels=[models.PatchedLabelRequest(name="object")],
            ),
            resource_type=ResourceType.LOCAL,
            resources=resources,
            data_params={"job_file_mapping": groups},
        )
        print(f"Created task {task.id} with {task.size} frames: {args.host}/tasks/{task.id}")

        # 3. The mapping is built using one row per file, carrying
        # the frame that file ended up on. A job's frames come back in order,
        # so the frame is the job's start plus the file's position in it.
        rows = []
        for job in sorted(task.get_jobs(), key=lambda job: job.start_frame):
            names = [frame.name for frame in job.get_frames_info()]
            print(f"  job {job.id} frames {job.start_frame}-{job.stop_frame}: {', '.join(names)}")
            rows.extend(
                {"job_id": job.id, "frame": job.start_frame + offset, "file_name": name}
                for offset, name in enumerate(names)
            )

        with args.output.open("w", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=["job_id", "frame", "file_name"])
            writer.writeheader()
            writer.writerows(rows)
        print(f"Wrote {args.output.resolve()}")

        if args.cleanup:
            task.remove()
            print(f"Deleted task {task.id}")
        else:
            print("Keeping the task; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
client.tasks.create_from_data(..., resource_type=ResourceType.LOCAL | SHARE | REMOTE) Where resources come from: LOCAL (upload local files), SHARE (keys in a cloud storage / mounted share), REMOTE (URLs). Defaults to LOCAL.
client.tasks.create_from_data(..., data_params={...}) Extra data options as a dict, e.g. image_quality (1-100), sorting_method ("lexicographical"/"natural"/"predefined"/"random"), cloud_storage_id (int).
client.tasks.create_from_data(..., annotation_path="path.zip", annotation_format="CVAT XML 1.1") Upload an initial annotations file at creation. annotation_path is a str file path; annotation_format is a str, default "CVAT XML 1.1".
client.tasks.create_from_data(..., status_check_period=<int seconds>, pbar=ProgressReporter()) status_check_period (int, seconds) is the upload status poll interval (defaults to Config.status_check_period); pbar is a cvat_sdk.core.progress.ProgressReporter for upload progress.
client.tasks.list(..., search=, sort=) Free-text search and server-side ordering (sort), in addition to filter.
client.tasks.create_from_backup(path) Recreate a task from a task backup archive.
Task.import_annotations(format_name, path) Load annotations into an existing task - the import counterpart of export_dataset.
Task.get_frame(frame_id: int, *, quality="original" | "compressed") Return a single frame as a file-like object (io.RawIOBase) of image bytes. quality is an optional keyword argument ("original" or "compressed"); if omitted, the server default is used.
Task.download_frames(frame_ids: Sequence[int], outdir=".", quality="original", image_extension=None, filename_pattern="frame_{frame_id:06d}{frame_ext}") Save the given frames to disk under outdir. image_extension (e.g. "png") overrides the auto-detected extension; quality is "original" or "compressed".
Task.get_meta() / Task.get_frames_info() Read frame count, chunk layout, and per-frame metadata.
Task.export_dataset(..., pbar=ProgressReporter()) Report local-download progress (a cvat_sdk.core.progress.ProgressReporter).
Task.export_dataset(..., status_check_period=<int seconds>) Poll interval (int, seconds) between server status checks; defaults to Config.status_check_period.
Task.export_dataset(filename=<directory>) Pass a directory as filename for a local export and the server-generated file name is used.
Task.export_dataset(..., location=Location.CLOUD_STORAGE, cloud_storage_id=<int>) Export straight to a registered cloud storage instead of downloading locally.
client.api_client.events_api.create_export(project_id=, job_id=, user_id=, _from=, to=) Scope or time-bound the event-log export beyond a single task.
data_params={"job_file_mapping": [[...], [...]]} Define each job’s files explicitly instead of using segment_size.
PatchedLabelRequest(name=..., type="polygon") Restrict a label to one shape type, as the per-label-group recipe does.
Job.get_frames_info() The frames a job really holds, names included.

Notes:

4 - Job recipes

List a task’s or project’s jobs, round-robin unassigned jobs, batch-advance completed jobs

Three recipes: job_list.py lists a task’s or project’s jobs with optional stage/state filters and an optional CSV report, job_assign.py round-robins unassigned jobs across a resolved pool of users and writes a CSV report, and job_workflow.py batch-advances every completed job at a given stage to the next stage.

List a task’s or project’s jobs

Queries the jobs of a task or a project (pick one with --task-id or --project-id) with optional server-side --stage / --state filters, ordered by most recently updated. Pass --csv to also write report.csv (project_id, project_name, task_id, task_name, job_id, stage, state, assignee, frames) into the current directory.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id one of --task-id / --project-id Id of the task whose jobs to list
--project-id one of --task-id / --project-id Id of the project whose jobs to list
--stage no Only jobs at this stage, e.g. annotation
--state no Only jobs in this state, e.g. new
--csv no Also write report.csv into the current directory
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --stage annotation --state new
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --csv

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""List the jobs of an existing task or project with their stage, state, and
assignee, optionally as a CSV report.

Steps:
  1. Query jobs scoped to --task-id or --project-id, most recently updated
     first. --stage / --state filter server-side, so large tasks/projects
     stay cheap. The same endpoint also accepts free-text search, e.g.
     search='alice'.
  2. Print one row per job.
  3. If --csv is passed, also write report.csv into the current directory
     (project_id, project_name, task_id, task_name, job_id, stage, state,
     assignee, frames).

Usage (run ``python job_list.py --help`` for the full list of options):
  python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42
  python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --stage annotation --state new
  python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --csv
"""

import argparse
import csv
from collections.abc import Iterable
from pathlib import Path

from cvat_sdk import make_client
from cvat_sdk.core.filters import F, all_
from cvat_sdk.core.proxies.jobs import Job


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    scope = parser.add_mutually_exclusive_group(required=True)
    scope.add_argument("--task-id", type=int, help="id of an existing task, e.g. 42")
    scope.add_argument("--project-id", type=int, help="id of an existing project, e.g. 7")
    parser.add_argument("--stage", help="only jobs at this stage, e.g. 'annotation'")
    parser.add_argument("--state", help="only jobs in this state, e.g. 'new'")
    parser.add_argument(
        "--csv", action="store_true", help="also write report.csv into the current directory"
    )
    return parser.parse_args()


def write_report(jobs: Iterable[Job], path: Path) -> None:
    with path.open("w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(
            [
                "project_id",
                "project_name",
                "task_id",
                "task_name",
                "job_id",
                "stage",
                "state",
                "assignee",
                "frames",
            ]
        )
        for job in jobs:
            assignee = job.assignee.username if job.assignee else ""
            writer.writerow(
                [
                    job.project_id or "",
                    job.project_name or "",
                    job.task_id,
                    job.task_name,
                    job.id,
                    job.stage,
                    job.state,
                    assignee,
                    job.frame_count,
                ]
            )


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        if args.task_id is not None:
            conditions = [F.task_id == args.task_id]
            scope_label = f"Task {args.task_id}"
        else:
            conditions = [F.project_id == args.project_id]
            scope_label = f"Project {args.project_id}"
        if args.stage:
            conditions.append(F.stage == args.stage)
        if args.state:
            conditions.append(F.state == args.state)

        jobs = client.jobs.list(filter=all_(*conditions), sort="-updated_date")
        print(f"{scope_label}: {len(jobs)} matching jobs")
        for job in jobs:
            assignee = job.assignee.username if job.assignee else "-"
            print(f"  job {job.id}: stage={job.stage}, state={job.state}, assignee={assignee}")

        if args.csv:
            report_path = Path("report.csv")
            write_report(jobs, report_path)
            print(f"Wrote {report_path.resolve()}")


if __name__ == "__main__":
    main()

Round-robin assign a task’s jobs

Distributes the unassigned jobs of a task across a resolved user pool and writes assignments.csv (job_id, previous_assignee, new_assignee, new_assignee_id). The pool is resolved by looking up usernames exactly with --assignees, by searching an organization’s members with --search, or self-assigns if neither is passed.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id yes Id of the task
--org SLUG no Organization slug to scope the user and job queries
--org-id ID no Organization id, as an alternative to --org
--assignees USERNAME [...] no Usernames to round-robin (exact match)
--search QUERY no Search the organization’s members; every match becomes an assignee

--assignees and --search are mutually exclusive, and so are --org and --org-id. Omit both --assignees and --search to self-assign.

--search requires an organization, so pass it together with --org or --org-id. Search matches the username, first_name, and last_name fields, which is only meaningful scoped to a team.

# self-assign every unassigned job
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42
# round-robin across an explicit pool
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --assignees alice bob
# pool = every organization member matching the search
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --org 'annotators' --search 'annotator-team'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Round-robin the unassigned jobs of a task across a set of annotators and
write a CSV report of the assignments (job_id, previous_assignee, new_assignee).

The user API supports server-side search within an organization, so you rarely
need to know user ids — pass usernames, or an organization and search query,
and let the recipe resolve them.

Steps:
  1. Resolve the assignee pool:
       --assignees USERNAME [USERNAME ...] : look up each username exactly.
       --search QUERY --org SLUG           : search organization members,
                                             print the matches, use them all.
       --search QUERY --org-id ID          : same, using the organization id.
       neither                             : assign to me (the authenticated user).
  2. Filter the task's unassigned jobs.
  3. Round-robin the jobs across the resolved users.
  4. Write assignments.csv into the current directory.

Usage (run ``python job_assign.py --help`` for the full list of options):
  python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42                              # self-assign
  python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --assignees alice bob
  python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --org 'annotators' --search 'annotator-team'
                                                  # pool = matches in the organization
"""

import argparse
import csv
import sys
from pathlib import Path

from cvat_sdk import make_client, models
from cvat_sdk.core.filters import F, all_, not_
from cvat_sdk.core.proxies.users import User


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
    )
    organization = parser.add_mutually_exclusive_group()
    organization.add_argument(
        "--org", metavar="SLUG", help="organization slug to scope user and job queries"
    )
    organization.add_argument(
        "--org-id", type=int, metavar="ID", help="organization id to scope user and job queries"
    )
    group = parser.add_mutually_exclusive_group()
    group.add_argument(
        "--assignees",
        nargs="+",
        metavar="USERNAME",
        help="usernames to round-robin across (looked up exactly on the server)",
    )
    group.add_argument(
        "--search",
        metavar="QUERY",
        help="server-side search within --org/--org-id; every matching member becomes an assignee",
    )
    args = parser.parse_args()
    if args.search and args.org is None and args.org_id is None:
        parser.error("--search requires --org or --org-id")
    return args


def organization_filters(args: argparse.Namespace) -> dict[str, str | int]:
    if args.org is not None:
        return {"org": args.org}
    if args.org_id is not None:
        return {"org_id": args.org_id}
    return {}


def resolve_pool(client, args: argparse.Namespace) -> list[User]:
    """Resolve --assignees / --search / nothing to a list of User objects."""
    org_filters = organization_filters(args)
    if args.search:
        matches = client.users.list(search=args.search, **org_filters)
        if not matches:
            sys.exit(f"No users matched search {args.search!r}")
        print(f"Users matching {args.search!r}:")
        for user in matches:
            print(f"  {user.id}\t{user.username}")
        return matches

    if args.assignees:
        pool: list[User] = []
        for username in args.assignees:
            found = client.users.list(filter=F.username == username, **org_filters)
            if not found:
                sys.exit(f"User {username!r} not found")
            pool.append(found[0])
        return pool

    me = client.users.retrieve_current_user()
    print(f"No --assignees / --search; self-assigning as {me.username} (id={me.id})")
    return [me]


def main() -> None:
    args = parse_args()
    report_path = Path("assignments.csv")
    with make_client(args.host, access_token=args.token) as client:
        pool = resolve_pool(client, args)

        unassigned = client.jobs.list(
            filter=all_(F.task_id == args.task_id, not_(F.assignee.is_set())),
            **organization_filters(args),
        )
        print(f"Task {args.task_id}: {len(unassigned)} unassigned jobs to distribute")

        with report_path.open("w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["job_id", "previous_assignee", "new_assignee", "new_assignee_id"])
            for i, job in enumerate(unassigned):
                user = pool[i % len(pool)]
                previous = job.assignee.username if job.assignee else ""
                job.update(models.PatchedJobWriteRequest(assignee=user.id))
                writer.writerow([job.id, previous, user.username, user.id])
                print(f"Assigned job {job.id} -> {user.username} (id={user.id})")

        print(f"Wrote {report_path.resolve()}")


if __name__ == "__main__":
    main()

Batch-advance completed jobs

Finds every job whose state is completed at --from-stage and moves each one to the next stage (annotation → validation → acceptance). Optionally restrict the sweep to a single task.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--from-stage yes Advance completed jobs at this stage (annotation or validation)
--task-id no Restrict the sweep to a single task
# send everything annotators finished into review
python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \
    --from-stage annotation
# accept everything that passed review, scoped to one task
python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \
    --from-stage validation --task-id 42

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Batch-advance completed jobs to the next workflow stage

Find every job whose state is 'completed' at --from-stage, move each one to
the next stage, and print the list of modified jobs. Optionally restrict the
sweep to a single task with --task-id.

Steps:
  1. Query jobs matching (stage == --from-stage, state == 'completed').
  2. Update each job's stage to the next one in the workflow.
  3. Print the modified job ids.

Usage (run ``python job_workflow.py --help`` for the full list of options):
  # Send everything annotators finished into review:
  python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --from-stage annotation
  # Accept everything that passed review, scoped to one task:
  python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --from-stage validation --task-id 42
"""

import argparse

from cvat_sdk import make_client, models
from cvat_sdk.core.filters import F, all_

NEXT_STAGE = {"annotation": "validation", "validation": "acceptance"}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--from-stage",
        required=True,
        choices=sorted(NEXT_STAGE),
        help="advance completed jobs currently at this stage",
    )
    parser.add_argument(
        "--task-id",
        type=int,
        help="restrict the sweep to a single task (default: every task you can see)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    to_stage = NEXT_STAGE[args.from_stage]

    with make_client(args.host, access_token=args.token) as client:
        conditions = [F.stage == args.from_stage, F.state == "completed"]
        if args.task_id is not None:
            conditions.append(F.task_id == args.task_id)

        jobs = client.jobs.list(filter=all_(*conditions))
        print(f"Found {len(jobs)} completed jobs at stage {args.from_stage!r}")

        for job in jobs:
            job.update(models.PatchedJobWriteRequest(stage=to_stage))
            print(f"  job {job.id}: {args.from_stage} -> {to_stage}")

        print(f"Moved {len(jobs)} jobs to stage {to_stage!r}")


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
Job.update(models.PatchedJobWriteRequest(stage=...)) Change a job’s stage (retrieve the job, then update). Must be one of: annotation, validation, acceptance.
Job.update(models.PatchedJobWriteRequest(state=...)) Change a job’s state, must be one of these values: new, in progress, rejected, completed.
Job.import_annotations(..., import_mode="replace" | "append") "replace" overwrites the job’s existing annotations (default); "append" merges the imported ones in.
Job.import_annotations(..., conv_mask_to_poly=True | False) Convert imported mask annotations to polygons (bool, server default True).
Job.import_annotations(..., pbar=ProgressReporter()) Report upload progress (a cvat_sdk.core.progress.ProgressReporter).
Job.get_issues() Fetch the review issues raised on a job.
Job.export_dataset(format_name, path) Export a single job’s dataset - the export counterpart of import_annotations.
Job.get_frame(frame_id: int, *, quality="original" | "compressed") Return a single frame as a file-like object (io.RawIOBase) of image bytes. quality is an optional keyword argument ("original" or "compressed"); if omitted, the server default is used.
Job.download_frames(frame_ids: Sequence[int], outdir=".", quality="original", image_extension=None, filename_pattern="frame_{frame_id:06d}{frame_ext}") Save the given frames to disk under outdir. image_extension (e.g. "png") overrides the auto-detected extension; quality is "original" or "compressed".
Job.get_meta() / Job.get_labels() Read a job’s frame metadata and label schema.

Notes:

  • stage is one of annotation, validation, acceptance; state is one of new, in progress, rejected, completed.
  • Jobs are created automatically with their task (controlled by segment_size at task creation) — you can update and assign them, but not create a job on its own.
  • CVAT has no built-in auto-assignment, so job_assign.py is the scripted pattern.
  • Full recipes: job_list.py, job_assign.py, job_workflow.py.

5 - Annotation recipes

Import annotations into a task from a file or a bucket, edit them in bulk, aggregate statistics, and find objects annotated twice

Five recipes: task_import_annotations.py loads an annotation file into an existing task, task_import_annotations_from_cloud.py does the same with a file that stays in a registered cloud storage, task_edit_annotations.py reads a task’s annotations, applies a bulk edit, and writes it back, project_annotation_stats.py walks a project’s tasks and aggregates object counts per label and type into a CSV report, and project_find_duplicates.py finds objects that were annotated twice before you export them.

Import annotations into a task

Uploads a local annotations file (e.g., predictions of a model, or work exported from another server) into an existing task, and shows the object counts before and after, so you can see what the import added. The import format is validated against the server’s importer list.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id yes Id of the task to import into
--annotations-file yes File to import, e.g. 'annotations.zip'
--import-format no Importer name (default 'COCO 1.0')
python task_import_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --annotations-file 'annotations.zip' --import-format 'COCO 1.0'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Import annotations from a local file into an existing task, e.g. to load
predictions of a model or work made on another server.

Steps:
  1. Retrieve the task and count the objects it already has.
  2. Fetch the server's import format list and validate --import-format.
  3. Upload the annotations file and wait for the server to process it.
  4. Count the objects again to show what the import added.

Usage (run ``python task_import_annotations.py --help`` for the full list of options):
  python task_import_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --annotations-file 'annotations.zip' --import-format 'COCO 1.0'
"""

import argparse
import sys
from pathlib import Path

from cvat_sdk import make_client


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
    )
    parser.add_argument(
        "--annotations-file",
        type=Path,
        required=True,
        help="file to import, e.g. 'annotations.zip'",
    )
    parser.add_argument(
        "--import-format",
        default="COCO 1.0",
        help="importer name, e.g. 'COCO 1.0' (default: '%(default)s')",
    )
    return parser.parse_args()


def count_objects(task) -> int:
    """All annotation objects of a task: tags, shapes, and tracks."""
    annotations = task.get_annotations()
    return len(annotations.tags) + len(annotations.shapes) + len(annotations.tracks)


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        task = client.tasks.retrieve(args.task_id)
        print(f"Task {task.id}: {count_objects(task)} objects before import")

        formats, _ = client.api_client.server_api.retrieve_annotation_formats()
        names = [f.name for f in formats.importers]
        if args.import_format not in names:
            sys.exit(
                f"Unknown import format {args.import_format!r}. Choose one of: {', '.join(names)}"
            )

        task.import_annotations(args.import_format, args.annotations_file)
        print(f"Imported {args.annotations_file} as {args.import_format!r}")

        print(f"Task {task.id}: {count_objects(task)} objects after import")


if __name__ == "__main__":
    main()

Import annotations from a cloud storage

Imports an annotation file that is already in a registered bucket: CVAT downloads the object itself, so nothing is uploaded from the machine running the script. Useful when a model writes its predictions to the bucket, or when the archive is too big to push through your own connection.

The high-level Task.import_annotations() always uploads a local file, so this recipe posts the import request through the low-level client.api_client.tasks_api with location=Location.CLOUD_STORAGE and awaits the returned rq_id with client.wait_for_completion().

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id yes Id of the task to import into
--filename yes Object key in the bucket, e.g. 'annotations/task_42.zip'
--cloud-storage-id no Registered cloud storage id; omit to use the task’s own source storage
--import-format no Importer name (default 'COCO 1.0')
--import-mode no append (default) or replace
# explicit bucket
python task_import_annotations_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --cloud-storage-id 7 --filename 'annotations/task_42.zip' \
    --import-format 'COCO 1.0'

# the bucket configured as the task's source storage, replacing what the task has
python task_import_annotations_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --filename 'predictions/task_42.zip' --import-mode replace

Register the bucket first with cloud_storage_register.py to get the storage id.

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Import annotations into an existing task straight from a registered cloud
storage: the server pulls the file out of the bucket itself, nothing is
uploaded from this machine. Handy when a model writes its predictions to a
bucket, or when the annotation archive is too big to push through your own
connection.

The high-level Task.import_annotations() always uploads a local file, so the
import request is made with the low-level API (client.api_client.tasks_api)
and awaited with client.wait_for_completion.

Steps:
  1. Retrieve the task and count the objects it already has.
  2. Fetch the server's import format list and validate --import-format.
  3. Resolve the storage to read from: --cloud-storage-id, or the task's own
     source storage when the flag is omitted.
  4. Start the import and wait for the background request to finish.
  5. Count the objects again to show what the import added.

Register a bucket first with cloud_storage_register.py to get the storage id.

Usage (run ``python task_import_annotations_from_cloud.py --help`` for the full list of options):
  python task_import_annotations_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --cloud-storage-id 7 --filename 'annotations/task_42.zip' \\
      --import-format 'COCO 1.0'
"""

import argparse
import json
import sys

from cvat_sdk import make_client
from cvat_sdk.core.exceptions import BackgroundRequestException
from cvat_sdk.core.proxies.types import Location


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
    )
    parser.add_argument(
        "--filename",
        required=True,
        help="object key of the annotation file in the bucket, e.g. 'annotations/task_42.zip'",
    )
    parser.add_argument(
        "--cloud-storage-id",
        type=int,
        help="a registered cloud storage id (see cloud_storage_register.py); "
        "omit to use the source storage configured in the task",
    )
    parser.add_argument(
        "--import-format",
        default="COCO 1.0",
        help="importer name, e.g. 'COCO 1.0' (default: '%(default)s')",
    )
    parser.add_argument(
        "--import-mode",
        choices=["append", "replace"],
        default="append",
        help="add to the task's annotations or replace them; the default keeps what "
        "the task already has (default: '%(default)s')",
    )
    return parser.parse_args()


def count_objects(task) -> int:
    """All annotation objects of a task: tags, shapes, and tracks."""
    annotations = task.get_annotations()
    return len(annotations.tags) + len(annotations.shapes) + len(annotations.tracks)


def resolve_cloud_storage_id(task, requested_id: int | None) -> int:
    """The explicitly requested storage, or the one configured in the task."""
    if requested_id is not None:
        return requested_id

    storage = task.source_storage
    if not storage or storage.location.value != Location.CLOUD_STORAGE.value:
        sys.exit(f"Task {task.id} has no cloud source storage configured; pass --cloud-storage-id")
    return storage.cloud_storage_id


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        # 1. The state before the import, to compare against.
        task = client.tasks.retrieve(args.task_id)
        print(f"Task {task.id}: {count_objects(task)} objects before import")

        # 2. Validate the format against the server's list.
        formats, _ = client.api_client.server_api.retrieve_annotation_formats()
        names = [f.name for f in formats.importers]
        if args.import_format not in names:
            sys.exit(
                f"Unknown import format {args.import_format!r}. Choose one of: {', '.join(names)}"
            )

        # 3. Where to read from.
        cloud_storage_id = resolve_cloud_storage_id(task, args.cloud_storage_id)
        print(f"Reading {args.filename!r} from cloud storage {cloud_storage_id}")

        # 4. location=cloud_storage makes the server fetch the file itself; the
        # response only starts a background request, whose id is awaited below.
        _, response = client.api_client.tasks_api.create_annotations(
            task.id,
            format=args.import_format,
            filename=args.filename,
            location=Location.CLOUD_STORAGE,
            cloud_storage_id=cloud_storage_id,
            import_mode=args.import_mode,
        )
        rq_id = json.loads(response.data).get("rq_id") if response.data else None
        if not rq_id:
            sys.exit("The server did not return a request id (rq_id) for the import")

        try:
            client.wait_for_completion(rq_id, log_prefix=f"Task {task.id} annotation import")
        except BackgroundRequestException as error:
            sys.exit(
                f"Import of {args.filename!r} from cloud storage {cloud_storage_id} failed: {error}"
            )

        print(f"Imported {args.filename} as {args.import_format!r} ({args.import_mode})")

        # 5. Re-read the annotations, so the count is the server's state.
        print(f"Task {task.id}: {count_objects(task)} objects after import")


if __name__ == "__main__":
    main()

Read, edit, and write back annotations

Reads all of a task’s annotations (tags, shapes, and tracks), applies one bulk edit — move every object from one label to another (--relabel FROM TO) or delete every object with a given label (--delete-label NAME) — and writes the edit back with a partial update, so the untouched objects are not re-uploaded. Prints the per-label object counts before and after.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id yes Id of the task to edit
--relabel FROM TO one of --relabel / --delete-label Move all objects from label FROM to label TO
--delete-label NAME one of --relabel / --delete-label Delete all objects with this label
python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --relabel 'car' 'vehicle'
python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --delete-label 'draft'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Read a task's annotations, edit them, and write the edit back: move every
object from one label to another (--relabel) or delete every object with a
given label (--delete-label).

Steps:
  1. Retrieve the task and map its label names to ids.
  2. Read all annotations (tags, shapes, and tracks) and count objects per label.
  3. Write the edit back with a partial annotation update, so the objects that
     are not affected by it are not re-uploaded.
  4. Re-read the annotations and print the per-label object counts diff.

Usage (run ``python task_edit_annotations.py --help`` for the full list of options):
  python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --relabel 'car' 'vehicle'
  python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --delete-label 'draft'
"""

import argparse
import sys
from collections import Counter

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.annotations import AnnotationUpdateAction


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
    )
    action = parser.add_mutually_exclusive_group(required=True)
    action.add_argument(
        "--relabel",
        nargs=2,
        metavar=("FROM", "TO"),
        help="move all objects from label FROM to label TO",
    )
    action.add_argument("--delete-label", metavar="NAME", help="delete all objects with this label")
    return parser.parse_args()


def label_counts(annotations, label_names: dict[int, str]) -> Counter:
    """Objects per label name, over tags, shapes, and tracks alike."""
    return Counter(
        label_names[obj.label_id]
        for obj in [*annotations.tags, *annotations.shapes, *annotations.tracks]
    )


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        task = client.tasks.retrieve(args.task_id)
        label_ids = {label.name: label.id for label in task.get_labels()}
        label_names = {id: name for name, id in label_ids.items()}

        source = args.relabel[0] if args.relabel else args.delete_label
        affected = set(args.relabel) if args.relabel else {source}
        for name in affected:
            if name not in label_ids:
                sys.exit(f"Label {name!r} not found in task {task.id}")

        annotations = task.get_annotations()
        before = label_counts(annotations, label_names)

        tags = [tag for tag in annotations.tags if tag.label_id == label_ids[source]]
        shapes = [shape for shape in annotations.shapes if shape.label_id == label_ids[source]]
        tracks = [track for track in annotations.tracks if track.label_id == label_ids[source]]
        matched = len(tags) + len(shapes) + len(tracks)

        if args.relabel:
            target_id = label_ids[args.relabel[1]]
            task.update_annotations(
                models.PatchedLabeledDataRequest(
                    tags=[
                        models.LabeledImageRequest(**{**tag.to_dict(), "label_id": target_id})
                        for tag in tags
                    ],
                    shapes=[
                        models.LabeledShapeRequest(**{**shape.to_dict(), "label_id": target_id})
                        for shape in shapes
                    ],
                    tracks=[
                        models.LabeledTrackRequest(**{**track.to_dict(), "label_id": target_id})
                        for track in tracks
                    ],
                ),
                action=AnnotationUpdateAction.UPDATE,
            )
            print(f"Moved {matched} objects from {source!r} to {args.relabel[1]!r}")
        else:
            # An empty id list would make remove_annotations() drop *all* the task
            # annotations, so skip the request when the label has no objects.
            if matched:
                task.remove_annotations(ids=[obj.id for obj in [*tags, *shapes, *tracks]])
            print(f"Deleted {matched} objects with label {source!r}")

        after = label_counts(task.get_annotations(), label_names)
        for name in sorted(affected):
            print(f"  {name}: {before[name]} -> {after[name]}")


if __name__ == "__main__":
    main()

Aggregate annotation statistics over a project

Walks every task of a project and counts the annotated objects per label and per type (a shape type such as rectangle or polygon, tag, or track). Prints a per-task breakdown with per-label project totals and writes annotation_stats.csv into the current directory — one row per (task, label, type).

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Id of the project to aggregate
python project_annotation_stats.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Aggregate what was annotated across a project: object counts per label and
per object type for every task, printed and written to a CSV report.

Steps:
  1. Retrieve the project and its label names.
  2. Walk the project's tasks and read each task's annotations.
  3. Count objects per (label, type), where the type is a shape type such as
     'rectangle' or 'polygon', 'tag' for tags, or 'track' for tracks.
  4. Print a per-task breakdown with per-label project totals, and write
     the CSV report to --output.

Usage (run ``python project_annotation_stats.py --help`` for the full list of options):
  python project_annotation_stats.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --output annotation_stats.csv
"""

import argparse
import csv
from collections import Counter
from pathlib import Path

from cvat_sdk import make_client


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--project-id", type=int, required=True, help="id of an existing project, e.g. 7"
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("annotation_stats.csv"),
        help="path to write the CSV report to (default: %(default)s)",
    )
    return parser.parse_args()


def task_counts(task, label_names: dict[int, str]) -> Counter:
    """Objects per (label name, object type) in one task."""
    annotations = task.get_annotations()
    counts = Counter()
    for tag in annotations.tags:
        counts[(label_names[tag.label_id], "tag")] += 1
    for shape in annotations.shapes:
        counts[(label_names[shape.label_id], str(shape.type))] += 1
    for track in annotations.tracks:
        counts[(label_names[track.label_id], "track")] += 1
    return counts


def main() -> None:
    args = parse_args()
    report_path = args.output
    with make_client(args.host, access_token=args.token) as client:
        project = client.projects.retrieve(args.project_id)
        label_names = {label.id: label.name for label in project.get_labels()}
        tasks = project.get_tasks()

        label_totals = Counter()
        total = 0
        with report_path.open("w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["task_id", "task_name", "label", "type", "count"])
            for task in tasks:
                counts = task_counts(task, label_names)
                print(f"Task {task.id} {task.name!r}: {sum(counts.values())} objects")
                for (label, type_), count in sorted(counts.items()):
                    print(f"  {label}/{type_}: {count}")
                    writer.writerow([task.id, task.name, label, type_, count])
                    label_totals[label] += count
                total += sum(counts.values())

        print(f"Project {project.id}: {total} objects across {len(tasks)} tasks")
        for label, count in sorted(label_totals.items()):
            print(f"  {label}: {count}")
        print(f"Wrote {report_path.resolve()}")


if __name__ == "__main__":
    main()

Find objects annotated twice

Walks a project’s tasks and reports groups of objects that annotate the same thing on the same frame. Duplicates appear when an import runs twice, when two annotators’ job ranges overlap, or after a merge.

Two objects belong to the same group when they sit on the same frame and share the shape type and the coordinates. The comparison is an exact one: an object whose coordinates differ is a different object, not a duplicate, so no similarity threshold is involved. --any-label drops the label condition, which catches the same car annotated once as car and once as vehicle.

The recipe only reports. Groups are printed and written to duplicates.csv with one row per object (task_id, job_id, frame, group, label, type, shape_id, track_id, points). It exits 1 when any group was found, so it can gate an export pipeline — --no-fail turns that off. Fix what it reports with task_edit_annotations.py or in the UI.

Every shape type is compared, because comparing coordinates for equality needs no geometry. Tags are skipped — they have no coordinates. Objects marked outside are skipped too, and track keyframes are compared alongside plain shapes, so a shape duplicating a track is reported. Skeletons are compared using their visible keypoint labels and coordinates, independent of keypoint order. Skeleton track frames are compared only when every element has an explicit keyframe on that frame; the recipe does not interpolate missing keypoints. Skeletons with no visible keypoints are skipped.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Id of the project to inspect
--task-id ID [ID ...] no Inspect only these tasks of the project; they are retrieved by id, so a big project is not listed
--any-label no Also group objects that carry different labels
--output no CSV report path (default duplicates.csv)
--no-fail no Exit 0 even when duplicates were found
python project_find_duplicates.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Find objects annotated twice: objects on the same frame that have the same
label, the same shape type, and exactly the same coordinates.

Duplicates appear when an import runs twice, when two annotators' job ranges
overlap, or after a merge. Shapes whose coordinates differ are different
objects, so the comparison is an exact one and needs no similarity threshold.

The recipe only reports. It exits 1 when it finds a duplicate, so it can gate
an export pipeline; --no-fail turns that off.

Steps:
  1. Retrieve the project, its labels, and the tasks to inspect.
  2. For each task, read the annotations and the job that owns each frame.
  3. Group each frame's objects by (label, type, coordinates) and keep the
     groups with more than one member.
  4. Print the groups, write the CSV report, and set the exit code.

Usage (run ``python project_find_duplicates.py --help`` for the full list of options):
  python project_find_duplicates.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7
"""

import argparse
import csv
import sys
from collections import defaultdict
from dataclasses import asdict, dataclass, fields
from itertools import groupby
from pathlib import Path

from cvat_sdk import make_client, models


@dataclass
class Annotated:
    """One annotated object, reduced to what the duplicate search compares."""

    frame: int
    label_id: int
    type: str
    shape_id: int | str
    track_id: int | str
    points: tuple[float, ...]
    elements: tuple[tuple[int, tuple[float, ...]], ...] = ()


@dataclass
class Duplicate:
    """One member of a duplicate group, as reported and written to the CSV."""

    task_id: int
    job_id: int | str
    frame: int
    group: int
    label: str
    type: str
    shape_id: int | str
    track_id: int | str
    points: str


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--project-id", type=int, required=True, help="id of an existing project, e.g. 7"
    )
    parser.add_argument(
        "--task-id",
        type=int,
        nargs="+",
        metavar="ID",
        help="inspect only these task ids (must belong to the project); "
        "omit to inspect every task in the project",
    )
    parser.add_argument(
        "--any-label",
        action="store_true",
        help="also group objects that carry different labels, e.g. the same car "
        "annotated once as 'car' and once as 'vehicle'",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("duplicates.csv"),
        help="path to write the CSV report to (default: %(default)s)",
    )
    parser.add_argument(
        "--no-fail", action="store_true", help="always exit 0, even when duplicates were found"
    )
    return parser.parse_args()


def iter_objects(annotations):
    """The shapes and the track keyframes the recipe compares.

    Track keyframes and skeleton keypoints marked `outside` are skipped: they
    are intentionally out of view. Tags are skipped too - they have no
    coordinates to compare. Skeleton track frames need explicit keyframes for
    every element; this recipe does not interpolate missing keypoints.
    """
    for shape in annotations.shapes:
        elements = ()
        if str(shape.type) == "skeleton":
            elements = skeleton_coordinates(shape.elements)
            if not elements:
                continue
        yield Annotated(
            frame=shape.frame,
            label_id=shape.label_id,
            type=str(shape.type),
            shape_id=shape.id,
            track_id="",
            points=tuple(shape.points),
            elements=elements,
        )
    for track in annotations.tracks:
        for shape in track.shapes:
            if shape.outside:
                continue
            elements = ()
            if str(shape.type) == "skeleton":
                # Element tracks can have independent keyframes. Comparing a
                # partial pose would require interpolation, outside this recipe.
                keypoints = [
                    (element.label_id, keyframe)
                    for element in track.elements
                    for keyframe in element.shapes
                    if keyframe.frame == shape.frame
                ]
                if len(keypoints) != len(track.elements):
                    continue
                elements = tuple(
                    sorted(
                        (label_id, tuple(keyframe.points))
                        for label_id, keyframe in keypoints
                        if not keyframe.outside
                    )
                )
                if not elements:
                    continue
            yield Annotated(
                frame=shape.frame,
                label_id=track.label_id,
                type=str(shape.type),
                shape_id=shape.id,
                track_id=track.id,
                points=tuple(shape.points),
                elements=elements,
            )


def skeleton_coordinates(
    elements: list[models.SubLabeledShape],
) -> tuple[tuple[int, tuple[float, ...]], ...]:
    """Visible keypoints, keyed by label rather than their serialized order."""
    return tuple(
        sorted(
            (element.label_id, tuple(element.points)) for element in elements if not element.outside
        )
    )


def format_points(points: tuple[float, ...], limit: int = 8) -> str:
    """The coordinates as text, cut short: a mask's points are a whole RLE."""
    head = ",".join(f"{value:.2f}" for value in points[:limit])
    return f"{head},..." if len(points) > limit else head


def find_duplicates(task, label_names: dict[int, str], same_label: bool) -> list[Duplicate]:
    """The duplicate objects of one task, numbered by group.

    Two objects are duplicates when they sit on the same frame and share the
    shape type and the coordinates, so the objects can be bucketed by that key
    in one pass instead of compared pairwise.
    """
    job_of_frame = {}
    for job in task.get_jobs():
        for frame in range(job.start_frame, job.stop_frame + 1):
            job_of_frame.setdefault(frame, job.id)

    groups: dict[tuple, list[Annotated]] = defaultdict(list)
    for obj in iter_objects(task.get_annotations()):
        label_part = obj.label_id if same_label else None
        groups[(obj.frame, label_part, obj.type, obj.points, obj.elements)].append(obj)

    duplicates = []
    group_number = 0
    for key in sorted(groups, key=lambda key: key[0]):
        members = groups[key]
        if len(members) < 2:
            continue
        group_number += 1
        for obj in members:
            duplicates.append(
                Duplicate(
                    task_id=task.id,
                    job_id=job_of_frame.get(obj.frame, ""),
                    frame=obj.frame,
                    group=group_number,
                    label=label_names[obj.label_id],
                    type=obj.type,
                    shape_id=obj.shape_id,
                    track_id=obj.track_id,
                    points=format_points(obj.points),
                )
            )
    return duplicates


def select_tasks(client, project, task_ids: list[int] | None) -> list:
    """The project's tasks, or just the requested ones.

    With --task-id the tasks are retrieved by id: listing every task of a large
    project only to throw most of them away would be a waste.
    """
    if not task_ids:
        return list(project.get_tasks())

    tasks = []
    for task_id in task_ids:
        try:
            task = client.tasks.retrieve(task_id)
        except Exception:
            task = None
        if task is None or task.project_id != project.id:
            sys.exit(f"Task id {task_id} was not found in project {project.id}")
        tasks.append(task)
    return tasks


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        project = client.projects.retrieve(args.project_id)
        label_names = {label.id: label.name for label in project.get_labels()}

        tasks = select_tasks(client, project, args.task_id)
        if not tasks:
            sys.exit(f"Project {project.id} has no tasks to inspect")

        duplicates: list[Duplicate] = []
        for task in tasks:
            duplicates.extend(find_duplicates(task, label_names, not args.any_label))

    groups = 0
    for _, members in groupby(duplicates, key=lambda d: (d.task_id, d.group)):
        members = list(members)
        first = members[0]
        groups += 1
        print(
            f"duplicate group {first.group} in task {first.task_id}, frame {first.frame}: "
            f"{len(members)} objects at {first.points}"
        )
        for member in members:
            origin = (
                f"track {member.track_id}" if member.track_id != "" else f"shape {member.shape_id}"
            )
            print(f"  {member.label} {member.type} {origin}")

    with args.output.open("w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=[field.name for field in fields(Duplicate)])
        writer.writeheader()
        writer.writerows(asdict(duplicate) for duplicate in duplicates)

    print(f"Found {groups} duplicate group(s), {len(duplicates)} object(s)")
    print(f"Wrote {args.output.resolve()}")
    if duplicates and not args.no_fail:
        sys.exit(1)


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
Task.import_annotations(..., import_mode="append") Add the imported objects to the existing annotations instead of replacing them (server support required).
Task.import_annotations(..., conv_mask_to_poly=True) Convert imported masks to polygons on the fly.
Task.import_annotations(..., pbar=ProgressReporter()) Report upload progress (a cvat_sdk.core.progress.ProgressReporter).
Job.import_annotations(format_name, path) The same import scoped to a single job.
jobs_api.create_annotations(id, format=..., filename=..., location=..., cloud_storage_id=...) The bucket import scoped to a single job.
projects_api.create_dataset(id, format=..., filename=..., location=..., cloud_storage_id=...) Import a whole dataset into a project from a bucket.
cloudstorages_api.retrieve_content_v2(id, prefix=...) List a bucket’s objects, to check a key before importing it.
Task.set_annotations(LabeledDataRequest(...)) Replace a task’s annotations with the given objects.
Task.update_annotations(PatchedLabeledDataRequest(...), action=AnnotationUpdateAction.CREATE | UPDATE | DELETE) Partial update: create, update, or delete only the objects in the request.
Task.remove_annotations(ids=[...]) Delete specific objects by id — or all of them when ids is omitted.
Project.get_annotations() Read the annotations of every task in a project in one call.
Task.get_frames_info() Frame names and sizes — useful to report a duplicate by file name rather than by frame index.
Task.get_jobs() Job frame ranges and states, so a reported duplicate can name the job to fix.

Notes:

  • An object’s label_id must be a label of the task (or of its project); task.get_labels() maps names to ids.
  • Both editing recipes re-read the annotations after writing, so the printed “after” counts show the server’s state, not the client’s intention.
  • The duplicate search reads only; Task.remove_annotations(ids=[...]) is what removes the extra objects once you have decided which copy to keep.
  • A bucket import is a background request: the POST only returns an rq_id, and the annotations appear once client.wait_for_completion() returns. A missing key or wrong credentials surface as a failed request, not as an error on the POST, so check the request’s message when an import fails.
  • --filename is the object key as seen from the bucket root, including any “directory” prefix.
  • Full recipes: task_import_annotations.py, task_import_annotations_from_cloud.py, task_edit_annotations.py, project_annotation_stats.py, project_find_duplicates.py.

6 - Ground truth recipes

Create validation sets and honeypots, and choose exactly which frames are ground truth

Three recipes for the quality-control side of a task: task_create_with_validation.py creates a task with a gold set and uploads the ground truth into it, task_create_with_honeypots.py builds a task whose every annotation job carries ground truth frames, and task_create_gt_job.py creates a ground truth job with an exact frame list in a task that already exists.

Create a task with a gold set

Creates the task with validation_params in gt mode, so the validation frames move into a separate ground truth job that annotators never see. Pick the frames by name (--gt-frame) or let the server sample them (--gt-frame-count, reproducible with --random-seed). The recipe then uploads --gt-annotations into that ground truth job and reports how many objects landed — after this, quality reports can compare the annotation jobs against it.

The ground truth job’s own frame list is padded with placeholder entries to mirror the task’s full frame range, so the recipe reads the real validation frames from the task’s validation layout instead of the job’s frame list.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--image-dir yes Directory with the task’s images; every file in it is uploaded
--gt-frame NAME [NAME ...] one of --gt-frame / --gt-frame-count Exact ground truth frames
--gt-frame-count N one of --gt-frame / --gt-frame-count Randomly sample N ground truth frames
--random-seed no Makes --gt-frame-count reproducible
--gt-annotations no Annotations file to upload into the ground truth job
--gt-format no Importer name (default 'COCO 1.0')
--name, --labels, --segment-size no Task name, labels, frames per annotation job
--cleanup no Delete the created task at the end
python task_create_with_validation.py --host 'https://app.cvat.ai' --token '<your token>' \
    --image-dir ./images --gt-frame 'img_001.png' 'img_042.png' \
    --gt-annotations ground_truth.zip --gt-format 'COCO 1.0'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Create a task with a ground truth validation set (a "gold set") and upload
the ground truth annotations into it.

The validation frames are moved into a separate ground truth job, which the
annotators never see. Quality reports compare the annotation jobs against it.

Steps:
  1. Collect the files from --image-dir.
  2. Create the task with validation_params in "gt" mode: either the exact
     frames you name (--gt-frame) or a random sample (--gt-frame-count,
     reproducible with --random-seed).
  3. Find the created ground truth job and print its frames.
  4. Upload --gt-annotations into that job and print how many objects landed.

Usage (run ``python task_create_with_validation.py --help`` for the full list of options):
  python task_create_with_validation.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --image-dir ./images --gt-frame 'img_001.png' 'img_042.png' \\
      --gt-annotations ground_truth.zip --gt-format 'COCO 1.0'
  python task_create_with_validation.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --image-dir ./images --gt-frame-count 20 --random-seed 42
"""

import argparse
import sys
from pathlib import Path

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.tasks import ResourceType


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--image-dir",
        type=Path,
        required=True,
        help="directory with the task's images; every file in it is uploaded, "
        "and the server decides which media it accepts",
    )
    parser.add_argument("--name", default="Task with a validation set", help="task name")
    parser.add_argument(
        "--labels",
        nargs="+",
        default=["object"],
        metavar="NAME",
        help="label names to create (default: %(default)s)",
    )
    parser.add_argument("--segment-size", type=int, help="frames per annotation job")
    # Naming the frames and counting them are two ways to say the same thing,
    # so argparse rejects a command line that passes both.
    frames = parser.add_mutually_exclusive_group(required=True)
    frames.add_argument(
        "--gt-frame",
        nargs="+",
        metavar="NAME",
        help="exact file names to use as ground truth frames",
    )
    frames.add_argument(
        "--gt-frame-count", type=int, help="number of randomly chosen ground truth frames"
    )
    parser.add_argument(
        "--random-seed", type=int, help="seed for --gt-frame-count, for a reproducible split"
    )
    parser.add_argument(
        "--gt-annotations", type=Path, help="annotations file to upload into the ground truth job"
    )
    parser.add_argument(
        "--gt-format",
        default="COCO 1.0",
        help="importer name for --gt-annotations (default: '%(default)s')",
    )
    parser.add_argument("--cleanup", action="store_true", help="delete the created task at the end")
    return parser.parse_args()


def collect_images(image_dir: Path) -> list[Path]:
    """The files to upload, passed as they are found.

    The server is the authority on which media formats it supports, so
    filtering by extension here would only reject files CVAT can read.
    """
    images = sorted(p for p in image_dir.iterdir() if p.is_file())
    if not images:
        sys.exit(f"No files found in {image_dir}")
    return images


def main() -> None:
    args = parse_args()
    images = collect_images(args.image_dir)

    # 2. "gt" mode puts the ground truth frames into a separate ground truth job.
    validation_params = {"mode": "gt"}
    if args.gt_frame:
        available = {path.name for path in images}
        unknown = [name for name in args.gt_frame if name not in available]
        if unknown:
            sys.exit(f"Frame(s) {', '.join(unknown)} not in {args.image_dir}")
        validation_params["frame_selection_method"] = "manual"
        validation_params["frames"] = list(args.gt_frame)
    else:
        if args.gt_frame_count >= len(images):
            sys.exit(f"--gt-frame-count must be smaller than the {len(images)} files available")
        validation_params["frame_selection_method"] = "random_uniform"
        validation_params["frame_count"] = args.gt_frame_count
        if args.random_seed is not None:
            validation_params["random_seed"] = args.random_seed

    with make_client(args.host, access_token=args.token) as client:
        spec = models.TaskWriteRequest(
            name=args.name,
            labels=[models.PatchedLabelRequest(name=name) for name in args.labels],
            **({"segment_size": args.segment_size} if args.segment_size else {}),
        )
        task = client.tasks.create_from_data(
            spec=spec,
            resource_type=ResourceType.LOCAL,
            resources=images,
            data_params={"validation_params": validation_params},
        )
        print(f"Created task {task.id} with {task.size} frames: {args.host}/tasks/{task.id}")

        # 3. The ground truth job the server built from validation_params.
        gt_jobs = client.jobs.list(task_id=task.id, type="ground_truth")
        if not gt_jobs:
            sys.exit(f"Task {task.id} has no ground truth job; check validation_params")
        gt_job = gt_jobs[0]
        layout, _ = client.api_client.tasks_api.retrieve_validation_layout(task.id)
        task_frames = task.get_frames_info()
        frame_names = [task_frames[index].name for index in layout.validation_frames]
        print(f"Ground truth job {gt_job.id}: {len(frame_names)} frames")
        print(f"Ground truth frames: {', '.join(frame_names)}")

        # 4. Upload the ground truth itself.
        if args.gt_annotations:
            formats, _ = client.api_client.server_api.retrieve_annotation_formats()
            importers = [f.name for f in formats.importers]
            if args.gt_format not in importers:
                sys.exit(
                    f"Unknown import format {args.gt_format!r}. "
                    f"Choose one of: {', '.join(importers)}"
                )
            gt_job.import_annotations(args.gt_format, args.gt_annotations)
            annotations = gt_job.get_annotations()
            count = len(annotations.tags) + len(annotations.shapes) + len(annotations.tracks)
            print(f"Imported {count} objects into ground truth job {gt_job.id}")
        else:
            print("No --gt-annotations given; the ground truth job is empty")

        if args.cleanup:
            task.remove()
            print(f"Deleted task {task.id}")
        else:
            print("Keeping the task; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Create a task with honeypots

Creates the task with validation_params in gt_pool mode: a validation pool of ground truth frames, --honeypots-per-job of which are mixed into every annotation job. Then it prints the layout the server actually built — the pool, and per job which frame of the job stands in for which pool frame — so you can see what the annotators will get.

Honeypots need an image task, not a video one. The pool is appended after the task’s own frames, so the task grows by the injected frames. Because the resulting jobs no longer have a common length, CVAT stores the per-job frame lists it built and the task’s segment_size reads back as 0. gt_pool also requires the task’s frames to be laid out with sorting_method: random, so annotators cannot learn “this position is always a honeypot” — the recipe sets this automatically.

To reshuffle the mapping later (useful once annotators start recognizing the honeypots) or to retire a pool frame whose ground truth turned out to be wrong, call tasks_api.partial_update_validation_layout() with frame_selection_method="random_uniform" or with disabled_frames=[...].

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--image-dir yes Directory with the task’s images; every file in it is uploaded
--honeypot-frame NAME [NAME ...] one of --honeypot-frame / --honeypot-frame-count Exact honeypot frames
--honeypot-frame-count N one of --honeypot-frame / --honeypot-frame-count Randomly sample N honeypot frames
--honeypots-per-job yes Honeypot frames mixed into each annotation job
--name, --labels, --segment-size no Task name, labels, frames per annotation job
--cleanup no Delete the created task at the end
python task_create_with_honeypots.py --host 'https://app.cvat.ai' --token '<your token>' \
    --image-dir ./images --honeypot-frame-count 20 --honeypots-per-job 2 --segment-size 50

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Create a task with honeypots: a pool of ground truth frames, a few of which
are mixed into every annotation job, so each annotator's work can be scored
against known answers without a separate review pass.

Steps:
  1. Collect the files from --image-dir.
  2. Create the task with validation_params in "gt_pool" mode: the honeypot
     frames (--honeypot-frame or --honeypot-frame-count) plus how many of them
     each annotation job gets (--honeypots-per-job).
  3. Print the layout the server built: the validation pool, and per annotation
     job which frame of the job stands in for which pool frame.

Honeypots are only supported by image tasks (not video) with randomly sorted
images, so the script always creates the task with sorting_method="random".
Each annotation job becomes --honeypots-per-job frames longer than --segment-size,
since that many honeypots are injected into it.

Usage (run ``python task_create_with_honeypots.py --help`` for the full list of options):
  python task_create_with_honeypots.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --image-dir ./images --honeypot-frame-count 20 --honeypots-per-job 2 --segment-size 50
  python task_create_with_honeypots.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --image-dir ./images --honeypot-frame 'img_001.png' 'img_042.png' --honeypots-per-job 2
"""

import argparse
import sys
from pathlib import Path

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.tasks import ResourceType


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--image-dir",
        type=Path,
        required=True,
        help="directory with the task's images; every file in it is uploaded, "
        "and the server decides which media it accepts",
    )
    parser.add_argument("--name", default="Task with honeypots", help="task name")
    parser.add_argument(
        "--labels", nargs="+", default=["object"], metavar="NAME", help="label names to create"
    )
    parser.add_argument("--segment-size", type=int, help="frames per annotation job")
    # Naming the frames and counting them are two ways to say the same thing,
    # so argparse rejects a command line that passes both.
    pool = parser.add_mutually_exclusive_group(required=True)
    pool.add_argument(
        "--honeypot-frame",
        nargs="+",
        metavar="NAME",
        help="exact file names to use as honeypot frames",
    )
    pool.add_argument(
        "--honeypot-frame-count", type=int, help="number of randomly chosen honeypot frames"
    )
    parser.add_argument(
        "--honeypots-per-job",
        type=int,
        required=True,
        help="honeypot frames mixed into each annotation job",
    )
    parser.add_argument("--cleanup", action="store_true", help="delete the created task at the end")
    return parser.parse_args()


def collect_images(image_dir: Path) -> list[Path]:
    """The files to upload, passed as they are found.

    The server is the authority on which media formats it supports, so
    filtering by extension here would only reject files CVAT can read.
    """
    images = sorted(p for p in image_dir.iterdir() if p.is_file())
    if not images:
        sys.exit(f"No files found in {image_dir}")
    return images


def print_layout(client, task_id: int) -> None:
    """The server's honeypot layout: the pool, and job -> (honeypot <- pool frame)."""
    layout, _ = client.api_client.tasks_api.retrieve_validation_layout(task_id)
    print(f"Validation pool frames: {list(layout.validation_frames)}")

    real_by_honeypot = dict(zip(layout.honeypot_frames, layout.honeypot_real_frames))
    for job in sorted(client.jobs.list(task_id=task_id, type="annotation"), key=lambda j: j.id):
        pairs = [
            f"{honeypot}<-{real}"
            for honeypot, real in real_by_honeypot.items()
            if job.start_frame <= honeypot <= job.stop_frame
        ]
        print(f"  job {job.id} frames {job.start_frame}-{job.stop_frame}: {', '.join(pairs)}")


def main() -> None:
    args = parse_args()
    images = collect_images(args.image_dir)

    # 2. "gt_pool" mode injects pool frames into every annotation job.
    validation_params = {
        "mode": "gt_pool",
        "frames_per_job_count": args.honeypots_per_job,
    }
    if args.honeypot_frame:
        available = {path.name for path in images}
        unknown = [name for name in args.honeypot_frame if name not in available]
        if unknown:
            sys.exit(f"Frame(s) {', '.join(unknown)} not in {args.image_dir}")
        validation_params["frame_selection_method"] = "manual"
        validation_params["frames"] = list(args.honeypot_frame)
    else:
        if args.honeypot_frame_count >= len(images):
            sys.exit(
                f"--honeypot-frame-count must be smaller than the {len(images)} files available"
            )
        validation_params["frame_selection_method"] = "random_uniform"
        validation_params["frame_count"] = args.honeypot_frame_count

    with make_client(args.host, access_token=args.token) as client:
        task = client.tasks.create_from_data(
            spec=models.TaskWriteRequest(
                name=args.name,
                labels=[models.PatchedLabelRequest(name=name) for name in args.labels],
                **({"segment_size": args.segment_size} if args.segment_size else {}),
            ),
            resource_type=ResourceType.LOCAL,
            resources=images,
            # "gt_pool" requires the task's frames to be laid out randomly, so
            # annotators cannot learn "this position is always a honeypot".
            data_params={"validation_params": validation_params, "sorting_method": "random"},
        )
        print(f"Created task {task.id} with {task.size} frames: {args.host}/tasks/{task.id}")

        # 3. What the server actually built.
        print_layout(client, task.id)

        if args.cleanup:
            task.remove()
            print(f"Deleted task {task.id}")
        else:
            print("Keeping the task; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Choose exactly which frames are ground truth

Creates a ground truth job in a task that already exists, with the frames you name — by index (--frame) or by file name (--frame-name, resolved through the task’s frame list). A task can hold one ground truth job, so the recipe refuses to overwrite an existing one unless --replace is passed: deleting a ground truth job discards its annotations. Afterwards it reads the task’s validation layout back, so the printed frame list is the server’s.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id yes Id of the task to create the ground truth job in
--frame N [N ...] one of --frame / --frame-name Frame indexes
--frame-name NAME [NAME ...] one of --frame / --frame-name Frame file names
--replace no Delete an existing ground truth job first
python task_create_gt_job.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --frame 0 17 42

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Create a ground truth job in an existing task from an exact frame list you choose.

Steps:
  1. Retrieve the task and resolve the requested frames: indexes (--frame) or
     file names (--frame-name, matched against the task's frame list).
  2. Refuse to touch an existing ground truth job unless --replace is given,
     because deleting one discards its annotations.
  3. Create the ground truth job with the "manual" frame selection method.
  4. Read the task's validation layout back and print the frames the server
     recorded, so you can see the request landed exactly as asked.

Usage (run ``python task_create_gt_job.py --help`` for the full list of options):
  python task_create_gt_job.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --frame 0 17 42
  python task_create_gt_job.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --frame-name 'img_001.png' 'img_042.png'
"""

import argparse
import sys

from cvat_sdk import make_client, models


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
    )
    frames = parser.add_mutually_exclusive_group(required=True)
    frames.add_argument(
        "--frame", type=int, nargs="+", metavar="N", help="frame indexes to use as ground truth"
    )
    frames.add_argument(
        "--frame-name", nargs="+", metavar="NAME", help="frame file names to use as ground truth"
    )
    parser.add_argument(
        "--replace",
        action="store_true",
        help="delete an existing ground truth job first (discards its annotations)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        task = client.tasks.retrieve(args.task_id)
        frames_info = task.get_frames_info()

        # 1. Resolve the requested frames to task frame indexes.
        if args.frame_name:
            index_by_name = {frame.name: index for index, frame in enumerate(frames_info)}
            unknown = [name for name in args.frame_name if name not in index_by_name]
            if unknown:
                sys.exit(f"Frame name(s) {', '.join(unknown)} not found in task {task.id}")
            frames = sorted({index_by_name[name] for name in args.frame_name})
        else:
            out_of_range = [f for f in args.frame if not 0 <= f < len(frames_info)]
            if out_of_range:
                sys.exit(
                    f"Frame(s) {out_of_range} out of range: task {task.id} "
                    f"has {len(frames_info)} frames"
                )
            frames = sorted(set(args.frame))

        # 2. An existing ground truth job is never replaced silently.
        existing = client.jobs.list(task_id=task.id, type="ground_truth")
        if existing:
            if not args.replace:
                sys.exit(
                    f"Task {task.id} already has a ground truth job ({existing[0].id}). "
                    "Pass --replace to delete it first - its annotations will be lost."
                )
            client.api_client.jobs_api.destroy(existing[0].id)
            print(f"Deleted the previous ground truth job {existing[0].id}")

        # 3. "manual" frame selection means: exactly these frames.
        job, _ = client.api_client.jobs_api.create(
            models.JobWriteRequest(
                type="ground_truth",
                task_id=task.id,
                frame_selection_method="manual",
                frames=frames,
            )
        )
        names = [frames_info[index].name for index in frames]
        print(f"Created ground truth job {job.id} with {len(frames)} frames: {', '.join(names)}")

        # 4. What the server recorded.
        layout, _ = client.api_client.tasks_api.retrieve_validation_layout(task.id)
        print(f"Validation frames: {sorted(layout.validation_frames)}")
        print("Upload the ground truth with: task_create_with_validation.py --gt-annotations ...")


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
validation_params={"mode": "gt", "frame_selection_method": "random_per_job", "frames_per_job_count": N} Sample validation frames per annotation job instead of task-wide.
validation_params={..., "frame_share": 0.1} / "frames_per_job_share" Express the sample as a share instead of a count.
JobWriteRequest(type="ground_truth", frame_selection_method="random_uniform", frame_count=N) Add a ground truth job with a random sample to an existing task.
jobs_api.partial_update_validation_layout(job_id, ...) Change the honeypots of one annotation job instead of the whole task.
tasks_api.retrieve_validation_layout(task_id) Read the pool, honeypots, and disabled frames at any time.
Job.import_annotations(format_name, path) Upload ground truth into a ground truth job.

Notes:

  • gt mode moves the ground truth frames into a separate ground truth job; gt_pool mode copies pool frames into the annotation jobs. Only gt_pool makes annotators encounter ground truth frames while working.
  • Ground truth frames are referenced by file name in validation_params and by frame index in JobWriteRequest and the validation layout API.
  • Quality reports use whatever the ground truth job holds, so upload the ground truth before comparing.
  • Full recipes: task_create_with_validation.py, task_create_with_honeypots.py, task_create_gt_job.py.

7 - Dataset recipes

Download only what changed, and export many tasks in one run

Two recipes for getting data out of CVAT at scale: dataset_incremental_download.py keeps a local cache of a project’s tasks and re-downloads only what the server has changed, and dataset_bulk_export.py exports a given list of tasks as dataset archives in one go, with support for resuming local exports. For exporting a single project’s tasks locally and to a bucket, see project_export_dataset.py.

Export a task or project

The core calls are task.export_dataset(...) and project.export_dataset(...):

from cvat_sdk import make_client
from cvat_sdk.core.proxies.types import Location

with make_client("https://app.cvat.ai", access_token="<your token>") as client:
    task = client.tasks.retrieve(10)
    task.export_dataset("COCO 1.0", "task_10.zip", include_images=False, location=Location.LOCAL)

    project = client.projects.retrieve(7)
    project.export_dataset("COCO 1.0", "project_7.zip", include_images=False, location=Location.LOCAL)

Each call downloads one archive, rebuilt by the server every time — there is no incremental path through export_dataset. The incremental recipe below uses a different part of the SDK; the bulk recipe adds multi-task exports to this basic workflow.

Download only what changed

cvat_sdk.datasets.TaskDataset mirrors a task on the local file system and keeps that copy current. Each time you construct it, the SDK compares the cached task’s updated_date with the server’s: an unchanged task is served from disk, a changed one is fetched again. Chunks already cached are never downloaded twice.

from cvat_sdk.datasets import TaskDataset, UpdatePolicy

dataset = TaskDataset(client, 10, update_policy=UpdatePolicy.IF_MISSING_OR_STALE)
for sample in dataset.samples:
    image = sample.media.load_image()   # PIL.Image, from the cache
    shapes = sample.annotations.shapes

The cache lives under client.config.cache_dir (a per-user directory by default), keyed by server host and task id, so several projects and servers can share one cache without colliding.

Two limits to design around:

  • Staleness is per task, not per frame. Any change to a task — including an annotation edit — purges that task’s whole cache entry, so its media is downloaded again on the next run.
  • Metadata is always re-fetched. Each run asks the server for the task and its labels; that request is how staleness is detected. Only chunks and annotations are skipped when the cache is fresh.

UpdatePolicy.NEVER is the other half of the pair: it reads the cache and performs no network access at all, failing on anything not already cached. The recipe exposes it as --offline, which needs explicit --task-id values, because listing a project’s tasks is itself a server call.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id one of --project-id / --task-id Download every task of this project
--task-id ID [ID ...] one of --project-id / --task-id Download these task ids
--cache-dir no Where the cache goes (default: the SDK’s per-user cache directory)
--offline no Use UpdatePolicy.NEVER — read the cache, contact no server; needs --task-id
--quiet no Hide the SDK’s per-file cache and download log
python dataset_incremental_download.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --cache-dir ./cvat-cache

Run it twice: the second run reports cache grew by 0 B, and the SDK’s log shows the annotations and chunks coming from the cache rather than the network.

Video tasks are skipped with a message — TaskDataset supports tasks whose media can be read as images.

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Download a project's task data incrementally: the SDK keeps a local cache and
re-downloads only what the server has changed since the last run.

``cvat_sdk.datasets.TaskDataset`` stores each task under the client's cache
directory. On every construction it compares the cached copy's ``updated_date``
with the server's: an unchanged task is served entirely from disk, a changed one
is fetched again. Media chunks already on disk are never downloaded twice.

Two things worth knowing before building a pipeline on this:

* Staleness is tracked per task, not per frame. Any change to a task - an
  annotation edit included - invalidates that task's whole cache entry, so its
  media comes down again.
* Every run still asks the server for the task's metadata and labels; that is
  how it notices a change. Only the bulky parts - chunks and annotations - are
  skipped when the cache is fresh.

Steps:
  1. Resolve the tasks to download, from --project-id or from --task-id.
  2. Build a TaskDataset for each one, which fills or reuses the cache.
  3. Report the samples found and how much the cache grew.

Usage (run ``python dataset_incremental_download.py --help`` for the full list of options):
  python dataset_incremental_download.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --cache-dir ./cvat-cache
  # run the same command again: the cache does not grow and no media is fetched
  python dataset_incremental_download.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 10 11 --cache-dir ./cvat-cache --offline
"""

import argparse
import logging
import sys
from pathlib import Path

from cvat_sdk import Client, make_client
from cvat_sdk.core.client import AccessTokenCredentials
from cvat_sdk.datasets import TaskDataset, UnsupportedDatasetError, UpdatePolicy


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    selection = parser.add_mutually_exclusive_group(required=True)
    selection.add_argument(
        "--project-id", type=int, help="download every task of this project, e.g. 7"
    )
    selection.add_argument(
        "--task-id", type=int, nargs="+", metavar="ID", help="download these task ids"
    )
    parser.add_argument(
        "--cache-dir",
        type=Path,
        help="where to keep the downloaded data (default: the SDK's per-user cache directory)",
    )
    parser.add_argument(
        "--offline",
        action="store_true",
        help="read the cache without contacting the server; fails on anything not cached",
    )
    parser.add_argument(
        "--quiet", action="store_true", help="hide the SDK's per-file cache and download messages"
    )
    return parser.parse_args()


def connect(args: argparse.Namespace) -> Client:
    """The client to work through.

    An --offline run must make no requests at all, so it skips the server
    version handshake that Client performs on construction. Applying an access
    token needs no round trip either, which is what makes this possible.
    """
    if not args.offline:
        return make_client(args.host, access_token=args.token)

    client = Client(url=args.host, check_server_version=False)
    client.login(AccessTokenCredentials(args.token))
    return client


def cache_size(path: Path) -> int:
    """Bytes currently cached under path, which need not exist yet."""
    return sum(item.stat().st_size for item in path.rglob("*") if item.is_file())


def main() -> None:
    args = parse_args()
    if args.offline and not args.task_id:
        sys.exit("--offline needs --task-id: listing a project's tasks is itself a server call")

    logging.basicConfig(level=logging.WARNING if args.quiet else logging.INFO, format="%(message)s")

    with connect(args) as client:
        if args.cache_dir:
            client.config.cache_dir = args.cache_dir
        cache_dir = client.config.cache_dir
        print(f"Cache: {cache_dir}")
        size_before = cache_size(cache_dir)

        if args.task_id:
            task_ids = args.task_id
        else:
            task_ids = [task.id for task in client.tasks.list(project_id=args.project_id)]
            if not task_ids:
                sys.exit(f"Project {args.project_id} has no tasks to download")

        policy = UpdatePolicy.NEVER if args.offline else UpdatePolicy.IF_MISSING_OR_STALE
        downloaded = 0
        for task_id in task_ids:
            try:
                dataset = TaskDataset(client, task_id, update_policy=policy)
            except UnsupportedDatasetError as error:
                # A video task, or a task with no data. One such task must not
                # stop the rest of the selection from being downloaded.
                print(f"Skipped task {task_id}: {error}")
                continue
            except FileNotFoundError:
                print(f"Skipped task {task_id}: not in the cache, run without --offline first")
                continue
            downloaded += 1
            print(
                f"Task {task_id}: {len(dataset.samples)} sample(s), {len(dataset.labels)} label(s)"
            )

        grew = cache_size(cache_dir) - size_before
        print(f"{downloaded} of {len(task_ids)} task(s) available locally; cache grew by {grew} B")

    if not downloaded:
        sys.exit(1)


if __name__ == "__main__":
    main()

Export many tasks in one run

Takes an explicit list of task ids, optionally narrowed by status, and exports each one to a local directory, to a registered cloud storage, or both. The script prints each result and a summary of exported, skipped, and failed tasks. One failing task never aborts the run: the script exports the rest and exits 1. --skip-existing makes an interrupted local run resumable — it takes the exported file as proof a task is done, so it needs --output-dir and refuses to pair with --cloud-storage-id, where nothing lands locally to check.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id ID [ID ...] yes Export these task ids
--status no Keep only tasks in annotation, validation, or completed; applied after the ids are resolved, so an id in another status is reported as filtered out rather than as missing
--output-dir one of --output-dir / --cloud-storage-id Local destination
--cloud-storage-id one of --output-dir / --cloud-storage-id Cloud destination; checked for existence and access before the run. Every selected task must belong to the storage’s workspace (the same organization, or both in the personal workspace); a mismatch stops the run before any export.
--export-format no Exporter name (default 'COCO 1.0')
--skip-existing no Skip tasks already exported into --output-dir; local exports only, so it cannot be combined with --cloud-storage-id
--with-images no Include images
python dataset_bulk_export.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 10 11 12 --output-dir datasets

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Export many task datasets in one run: name the tasks by id, optionally
narrowed by status, and write them locally and/or straight to a cloud storage.

A failing task does not stop the run - its error is printed and the script
exits with code 1 at the end, so a pipeline still sees the failure.

Steps:
  1. Check --cloud-storage-id once, so a wrong id fails before any export, and
     resolve the selection (--task-id, --status). Check that every selected
     task belongs to the cloud storage's workspace before exporting anything.
  2. Export each task to --output-dir and/or to --cloud-storage-id, skipping the
     ones already exported when --skip-existing is passed.
  3. Report how many tasks were exported, skipped, and failed.

Usage (run ``python dataset_bulk_export.py --help`` for the full list of options):
  python dataset_bulk_export.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 10 11 12 --output-dir datasets
  python dataset_bulk_export.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 10 11 12 --cloud-storage-id 3 --output-dir datasets
"""

import argparse
import sys
from pathlib import Path

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.types import Location


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id",
        type=int,
        nargs="+",
        metavar="ID",
        required=True,
        help="export these task ids",
    )
    parser.add_argument(
        "--status",
        choices=["annotation", "validation", "completed"],
        help="export only the tasks in this status",
    )
    parser.add_argument(
        "--output-dir", type=Path, help="directory to write the exported datasets to"
    )
    parser.add_argument(
        "--cloud-storage-id",
        type=int,
        help="also export straight to this registered cloud storage, checked before "
        "the run starts (see cloud_storage_register.py)",
    )
    parser.add_argument(
        "--export-format",
        default="COCO 1.0",
        help="exporter name, e.g. 'COCO 1.0' (default: '%(default)s')",
    )
    parser.add_argument(
        "--skip-existing",
        action="store_true",
        help="skip tasks whose output file is already in --output-dir (resume a run); "
        "local exports only",
    )
    parser.add_argument("--with-images", action="store_true", help="include images in the exports")
    return parser.parse_args()


def select_tasks(
    client, args: argparse.Namespace, storage: models.CloudStorageRead | None = None
) -> list:
    """The tasks to export, as (id, name) pairs.

    --status is applied after the ids are resolved, so a task that exists but is
    in another status is reported as filtered out rather than as missing - two
    different mistakes.
    """
    selected = []
    for task_id in args.task_id:
        try:
            task = client.tasks.retrieve(task_id)
        except Exception:
            selected.append((task_id, ""))
            continue
        if args.status and str(task.status) != args.status:
            print(f"Skipping task {task_id}: status is {task.status}, not {args.status}")
            continue
        if storage is not None and task.organization_id != storage.organization:
            sys.exit(
                f"Task {task_id} and cloud storage {storage.id} belong to different workspaces"
            )
        selected.append((task.id, task.name))
    return selected


def export_one(client, args: argparse.Namespace, task_id: int, name: str) -> str:
    local_path = args.output_dir / f"task_{task_id}.zip" if args.output_dir else None

    if args.skip_existing and local_path and local_path.exists():
        print(f"Skipped task {task_id} ({local_path} exists)")
        return "skipped"

    try:
        task = client.tasks.retrieve(task_id)
        destinations = []

        if local_path:
            task.export_dataset(
                args.export_format,
                local_path,
                include_images=args.with_images,
                location=Location.LOCAL,
            )
            destinations.append("local")

        if args.cloud_storage_id:
            task.export_dataset(
                args.export_format,
                f"task_{task_id}.zip",
                include_images=args.with_images,
                location=Location.CLOUD_STORAGE,
                cloud_storage_id=args.cloud_storage_id,
            )
            destinations.append(f"cloud storage {args.cloud_storage_id}")

        print(f"Exported task {task_id} {name!r} -> {', '.join(destinations)}")
    except Exception as error:  # one bad task must not abort the whole run
        print(f"FAILED task {task_id} {name!r}: {type(error).__name__}: {error}")
        return "failed"

    return "exported"


def main() -> None:
    args = parse_args()
    if not args.output_dir and not args.cloud_storage_id:
        sys.exit("Select a destination: pass --output-dir and/or --cloud-storage-id")
    if args.skip_existing and not args.output_dir:
        sys.exit("--skip-existing needs --output-dir: a cloud export leaves nothing local to check")
    if args.skip_existing and args.cloud_storage_id:
        sys.exit("--skip-existing cannot resume a cloud export; drop it or drop --cloud-storage-id")
    if args.output_dir:
        args.output_dir.mkdir(parents=True, exist_ok=True)

    with make_client(args.host, access_token=args.token) as client:
        storage = None
        if args.cloud_storage_id:
            try:
                storage, _ = client.api_client.cloudstorages_api.retrieve(args.cloud_storage_id)
            except Exception as error:
                sys.exit(
                    f"Cloud storage {args.cloud_storage_id} is not available to this user: {error}"
                )
            print(f"Exporting to cloud storage {storage.id} {storage.display_name!r}")

        selection = select_tasks(client, args, storage)
        if not selection:
            sys.exit("The selection is empty; nothing to export")
        print(f"Exporting {len(selection)} task(s)")

        results = [export_one(client, args, *task) for task in selection]

    failed = results.count("failed")
    skipped = results.count("skipped")
    exported = results.count("exported")
    print(f"Exported {exported} of {len(results)} task(s); {skipped} skipped, {failed} failed")
    if failed:
        sys.exit(1)


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
Task.export_dataset(..., include_images=True) Ship the media with the annotations.
Task.export_dataset(..., location=Location.CLOUD_STORAGE, cloud_storage_id=N) Write the result to a bucket instead of downloading it.
Project.export_dataset(format_name, path) One archive for a whole project instead of per-task archives.
Job.export_dataset(format_name, path) The same export scoped to a single job.
Task.download_backup(path) A backup (data + annotations + settings) rather than a dataset.
client.tasks.list(updated_date__gt=..., status=..., name__contains=...) Server-side selection; see the filtering guide.
TaskDataset(..., media_download_policy=MediaDownloadPolicy.FETCH_CHUNKS_ON_DEMAND) Fetch a chunk only when a sample in it is read, instead of preloading every chunk.
TaskDataset.iter_samples(temporary_chunks=True) Stream samples through a temporary directory, leaving the shared cache untouched.
TaskDataset(..., load_annotations=False) Cache media only, when the labels are not needed.
cvat_sdk.pytorch.TaskVisionDataset The same cache behind a torch.utils.data.Dataset; see the PyTorch adapter.

Notes:

  • updated_date changes when a task’s fields, data, or annotations change, so it is what the cache compares against, and why an annotation edit re-downloads that task’s media too.
  • Delete the cache directory to force a full re-download.
  • export_dataset and TaskDataset produce different things: the first a format-converted archive to hand off, the second a live local mirror to read frame by frame.
  • Full recipes: dataset_incremental_download.py, dataset_bulk_export.py.

8 - Cloud storage recipes

Attach an S3-compatible bucket to CVAT via the low-level cloud storages API

One recipe: cloud_storage_register.py registers an S3-compatible bucket (AWS S3, MinIO, DigitalOcean Spaces, …) as a CVAT cloud storage. It uses the low-level client.api_client.cloudstorages_api because there is no high-level proxy for cloud storages yet.

Attach a bucket to CVAT

Registers a bucket by key/secret, lists all registered storages, retrieves the new one, lists the bucket’s actual content, and renames it — a smoke test that the credentials work.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--bucket yes Bucket name
--access-key yes Bucket access key id
--secret-key yes Bucket secret key
--endpoint-url yes Endpoint URL, e.g. 'https://s3.amazonaws.com'
--page-size no Entries per bucket listing request (default: the server maximum, 500)
--cleanup no Detach the bucket from CVAT at the end (data untouched)
python cloud_storage_register.py --host 'https://app.cvat.ai' --token '<your token>' \
    --bucket 'my-bucket' --access-key '<key>' --secret-key '<secret>' \
    --endpoint-url 'https://s3.amazonaws.com'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Attach an S3-compatible bucket to CVAT as a cloud storage, then list,
retrieve, and update it. Any S3-compatible service works (AWS S3, minio, ...)
via the AWS_S3_BUCKET provider and a custom endpoint URL.

There is no high-level proxy for cloud storages yet, so this recipe uses the
low-level API (client.api_client.cloudstorages_api).

Steps:
  1. Attach the bucket with key/secret credentials to CVAT.
  2. List all registered storages.
  3. Retrieve the new one.
  4. List the bucket's content, a page at a time.
  5. Update its display name.
  6. Optionally, detach it from CVAT.

Usage (run ``python cloud_storage_register.py --help`` for the full list of options):
  python cloud_storage_register.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --bucket 'my-bucket' --access-key '<key>' --secret-key '<secret>' \\
      --endpoint-url 'https://s3.amazonaws.com'
"""

import argparse

from cvat_sdk import make_client, models
from cvat_sdk.core.helpers import get_paginated_collection


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument("--bucket", required=True, help="the bucket name, e.g. 'my-bucket'")
    parser.add_argument("--access-key", required=True, help="the bucket's access key id")
    parser.add_argument("--secret-key", required=True, help="the bucket's secret key")
    parser.add_argument(
        "--endpoint-url",
        required=True,
        help="e.g. 'https://s3.amazonaws.com' or 'http://minio:9000'",
    )
    parser.add_argument(
        "--page-size",
        type=int,
        help="entries to fetch per bucket listing request (default: the server's "
        "maximum, 500); a small value makes the pagination loop visible",
    )
    parser.add_argument(
        "--cleanup",
        action="store_true",
        help="detach the storage at the end (data is never touched)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        api = client.api_client.cloudstorages_api

        # 1. Register
        storage, _ = api.create(
            models.CloudStorageWriteRequest(
                provider_type="AWS_S3_BUCKET",  # any S3-compatible service
                resource=args.bucket,
                display_name=args.bucket,
                credentials_type="KEY_SECRET_KEY_PAIR",
                key=args.access_key,
                secret_key=args.secret_key,
                specific_attributes=f"endpoint_url={args.endpoint_url}",
            )
        )
        print(f"Registered cloud storage {storage.id} -> {args.bucket}")

        # 2. List — api.list() returns a single page. Pair it with
        # get_paginated_collection to walk every page of any low-level list
        # endpoint (works for tasks_api.list_endpoint, jobs_api.list_endpoint, ...).
        storages = get_paginated_collection(api.list_endpoint)
        print(f"Registered storages: {[cs.id for cs in storages]}")

        # 3. Retrieve — credentials are never returned, only metadata
        fetched, _ = api.retrieve(storage.id)
        print(f"Storage {fetched.id}: {fetched.display_name!r} ({fetched.provider_type})")

        # 4. List the bucket's content, a page at a time via next_token.
        page_params = {"page_size": args.page_size} if args.page_size else {}
        files = []
        pages = 0
        next_token = None
        while True:
            content, _ = api.retrieve_content_v2(
                storage.id,
                **page_params,
                **({"next_token": next_token} if next_token else {}),
            )
            files.extend(content.content)
            pages += 1
            if not content.next:
                break
            next_token = content.next
        print(f"Bucket {args.bucket!r} contains {len(files)} entries in {pages} page(s):")
        for f in files:
            print(f"  {f.type.value:>3} {f.name}")

        # 5. Update the display name (PATCH — only the passed fields change)
        updated, _ = api.partial_update(
            storage.id,
            patched_cloud_storage_write_request=models.PatchedCloudStorageWriteRequest(
                display_name=f"{args.bucket} (updated)"
            ),
        )
        print(f"Renamed storage {updated.id} to {updated.display_name!r}")

        # 6. Opt-in cleanup: detaches the bucket from CVAT, never deletes data
        if args.cleanup:
            api.destroy(storage.id)
            print(f"Deleted cloud storage {storage.id}")
        else:
            print("Keeping the storage; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Other SDK options:

The recipe uses the low-level client.api_client.cloudstorages_api because there is no high-level proxy for cloud storages yet.

SDK method / parameter What it adds
models.CloudStorageWriteRequest(description=...) Free-text description shown alongside the storage.
models.CloudStorageWriteRequest(manifests=[...]) Attach manifest files so CVAT can index large buckets faster.
CloudStorageWriteRequest(session_token=..., connection_string=..., account_name=...) Alternative credential fields for other providers (e.g. Azure, temporary S3 sessions).
cloudstorages_api.retrieve_status(id=...) Check whether a registered storage is reachable/healthy.
cloudstorages_api.retrieve_actions(id: int) Return the operations the credentials allow on the bucket (e.g. "read" / "read,write") as a string. id is the cloud storage id; the string is the returned data (first tuple element).
cloudstorages_api.retrieve_content_v2(id, prefix=..., manifest_path=..., page_size=...) List the bucket’s actual files/directories. prefix filters to one “directory”; manifest_path lists from a manifest instead of a live bucket scan (faster for large buckets).
cloudstorages_api.retrieve_preview(id: int) Fetch a preview image for the storage. id is the cloud storage id; the image bytes are on the HTTP response (response.data, the second tuple element), not the parsed data.
PatchedCloudStorageWriteRequest(key=..., secret_key=...) Rotate credentials through partial_update (any writable field can be patched).
get_paginated_collection(api.list_endpoint) Walk every page of any low-level *_api.list_endpoint (tasks, jobs, cloud storages, …); returns a flat list.

Notes:

  • The server validates the bucket by connecting to endpoint_url itself, so use an address the server container can reach.
  • Cleanup detaches the bucket from CVAT; the bucket’s contents are never touched.
  • Full recipe: cloud_storage_register.py.

9 - Webhook recipes

Register a webhook for task events and watch new tasks appear live with a local receiver

Two recipes: register_webhook.py creates a webhook for task events on a project or an organization, pings it, and summarizes its recorded deliveries; webhook_resource_monitoring.py is the receiving side — it runs a local HTTP server, registers a webhook pointing at it, verifies each delivery’s signature, and tallies the tasks created in the project as they arrive.

CVAT signs every delivery with the webhook secret: the X-Signature-256 header carries sha256=<HMAC-SHA256 of the request body>. A receiver that recomputes and compares the signature (as webhook_resource_monitoring.py does) can be sure the payload came from the server and not from someone who merely knows the URL.

Register a webhook and inspect its deliveries

Creates a webhook scoped to a project (--project-id) or a whole organization (--org), sends a test ping, then lists all recorded deliveries with get_paginated_collection() and prints how many there are per HTTP status. There is no high-level proxy for webhooks yet, so the recipe shows the low-level client.api_client.webhooks_api.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id one of --project-id / --org Watch one project
--org SLUG one of --project-id / --org Watch a whole organization
--target-url yes Where the server delivers the events
--secret yes Secret the server signs the deliveries with
--events no Events to subscribe to (default: create:task update:task delete:task)
--cleanup no Delete the created webhook at the end
python register_webhook.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'
python register_webhook.py --host 'https://app.cvat.ai' --token '<your token>' \
    --org 'annotators' --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Register a project- or organization-scoped webhook, send a test ping, and
verify the ping shows up in the webhook's recorded deliveries.

The server signs every delivery with the webhook secret (HMAC-SHA256 in the
'X-Signature-256' header), so the receiver can verify the payload really came
from CVAT — see webhook_resource_monitoring.py for the receiving side.

Steps:
  1. Create the webhook: scoped to a project (--project-id) or to a whole
     organization (--org).
  2. Send a ping — the server POSTs a test payload to --target-url and records
     the delivery.
  3. List all recorded deliveries and print how many there are per HTTP status.
  4. Optionally delete the webhook (--cleanup).

Usage (run ``python register_webhook.py --help`` for the full list of options):
  python register_webhook.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'
  python register_webhook.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --org 'annotators' --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'
"""

import argparse
import contextlib
from collections import Counter

from cvat_sdk import make_client, models
from cvat_sdk.core.helpers import get_paginated_collection


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    scope = parser.add_mutually_exclusive_group(required=True)
    scope.add_argument("--project-id", type=int, help="id of an existing project, e.g. 7")
    scope.add_argument("--org", metavar="SLUG", help="organization slug to watch as a whole")
    parser.add_argument(
        "--target-url",
        required=True,
        help="where the server delivers the events, e.g. 'https://ci.example.com/cvat-events'",
    )
    parser.add_argument(
        "--secret", required=True, help="secret the server signs the deliveries with"
    )
    parser.add_argument(
        "--events",
        nargs="+",
        default=["create:task", "update:task", "delete:task"],
        help="events to subscribe to (default: %(default)s)",
    )
    parser.add_argument(
        "--content-type",
        default="application/json",
        choices=["application/json", "application/x-www-form-urlencoded"],
        help="payload content type the server sends (default: %(default)s)",
    )
    parser.add_argument(
        "--cleanup", action="store_true", help="delete the created webhook at the end"
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        webhooks_api = client.api_client.webhooks_api

        if args.org is not None:
            scope_context = client.organization_context(args.org)
            spec = models.WebhookWriteRequest(
                target_url=args.target_url,
                type=models.WebhookType("organization"),
                events=[models.EventsEnum(event) for event in args.events],
                content_type=models.WebhookContentType(args.content_type),
                secret=args.secret,
            )
            scope_label = f"organization {args.org!r}"
        else:
            scope_context = contextlib.nullcontext()
            spec = models.WebhookWriteRequest(
                target_url=args.target_url,
                type=models.WebhookType("project"),
                events=[models.EventsEnum(event) for event in args.events],
                content_type=models.WebhookContentType(args.content_type),
                secret=args.secret,
                project_id=args.project_id,
            )
            scope_label = f"project {args.project_id}"

        with scope_context:
            webhook, _ = webhooks_api.create(spec)
            print(f"Created webhook {webhook.id} for {scope_label} -> {webhook.target_url}")
            print(f"  events: {[str(event) for event in webhook.events]}")

            delivery, _ = webhooks_api.create_ping(webhook.id)
            print(f"Ping delivery: HTTP {delivery.status_code or 'failed'}")

            # A busy webhook accumulates pages of deliveries; the list endpoint
            # is paginated like every list in the API, so walk all the pages.
            deliveries = get_paginated_collection(
                webhooks_api.list_deliveries_endpoint, id=webhook.id
            )
            by_status = Counter(delivery.status_code for delivery in deliveries)
            summary = ", ".join(f"{status} x{count}" for status, count in sorted(by_status.items()))
            print(f"Webhook {webhook.id}: {len(deliveries)} deliveries, by status: {summary}")

            if args.cleanup:
                webhooks_api.destroy(webhook.id)
                print(f"Deleted webhook {webhook.id}")
            else:
                print("Keeping the webhook; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Watch new tasks appear live

Starts a local HTTP server on --port, registers a create:task webhook for the project targeting --public-url (how the CVAT server reaches this machine — a public IP, a DNS name, or a tunnel), and then, for every delivery: verifies the signature, tallies the event, and prints the new task’s id and name. On Ctrl-C — or after --max-events verified events — it prints the tallies and how many deliveries were rejected for a bad signature.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Project whose new tasks to watch
--public-url yes URL under which the CVAT server can reach this machine
--port no Local port to listen on (default 8000)
--secret yes Secret the server signs the deliveries with
--max-events no Stop after this many verified events (default: run until Ctrl-C)
--cleanup no Delete the created webhook at the end
python webhook_resource_monitoring.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --public-url 'https://my-tunnel.example.com/payload' \
    --port 8000 --secret 'w3bh00k'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Watch a project for newly created tasks via a webhook: register the webhook
pointing at this machine, receive the deliveries with a local HTTP server, and
tally each 'create:task' event.

The server signs every delivery with the webhook secret (HMAC-SHA256 of the
request body in the 'X-Signature-256' header). The receiver recomputes the
signature and rejects deliveries that don't match, so nobody who merely knows
the URL can inject fake events. --public-url is how the CVAT server reaches
this machine (a public IP, a DNS name, or a tunnel), while --port is where the
receiver listens locally.

Steps:
  1. Start an HTTP server on --port.
  2. Register a webhook for the project's 'create:task' events, targeting
     --public-url.
  3. For every delivery: verify the signature, then tally the event.
  4. On Ctrl-C (or after --max-events deliveries), print the tallies.
  5. Optionally delete the webhook (--cleanup).

Usage (run ``python webhook_resource_monitoring.py --help`` for the full list of options):
  python webhook_resource_monitoring.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --public-url 'https://my-tunnel.example.com/payload' \\
      --port 8000 --secret 'w3bh00k'
"""

import argparse
import hashlib
import hmac
import json
import urllib.parse
from collections import Counter
from http.server import BaseHTTPRequestHandler, HTTPServer

from cvat_sdk import make_client, models

MONITORING_EVENTS = ["create:task"]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--project-id", type=int, required=True, help="id of an existing project, e.g. 7"
    )
    parser.add_argument(
        "--public-url",
        required=True,
        help="URL under which the CVAT server can reach this machine, "
        "e.g. 'https://my-tunnel.example.com/payload'",
    )
    parser.add_argument(
        "--port", type=int, default=8000, help="local port to listen on (default: %(default)s)"
    )
    parser.add_argument(
        "--secret", required=True, help="secret the server signs the deliveries with"
    )
    parser.add_argument(
        "--content-type",
        default="application/json",
        choices=["application/json", "application/x-www-form-urlencoded"],
        help="payload content type the server sends (default: %(default)s)",
    )
    parser.add_argument(
        "--max-events",
        type=int,
        help="stop after this many verified events (default: run until Ctrl-C)",
    )
    parser.add_argument(
        "--cleanup", action="store_true", help="delete the created webhook at the end"
    )
    return parser.parse_args()


class DeliveryHandler(BaseHTTPRequestHandler):
    """One CVAT delivery per request: verify the signature, tally the event."""

    # Set on the subclass by make_handler()
    secret: bytes
    event_counter: Counter
    rejected: int = 0

    def log_message(self, format: str, *args) -> None:  # pylint: disable=redefined-builtin
        pass  # the tallies below replace the default per-request log line

    def do_POST(self) -> None:
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        expected = "sha256=" + hmac.new(type(self).secret, body, hashlib.sha256).hexdigest()
        provided = self.headers.get("X-Signature-256", "")
        if not hmac.compare_digest(expected, provided):
            type(self).rejected += 1
            self.send_response(403)
            self.end_headers()
            return

        self.send_response(200)
        self.end_headers()

        # For application/x-www-form-urlencoded, CVAT sends the JSON body under
        # the 'payload' form field; for application/json, the body is the JSON.
        content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
        if content_type == "application/x-www-form-urlencoded":
            form = urllib.parse.parse_qs(body.decode("utf-8"))
            payload = json.loads(form["payload"][0])
        else:
            payload = json.loads(body)
        event_type = payload["event"]
        if event_type == "ping":
            print("Ping from the server", flush=True)
            return

        type(self).event_counter[event_type] += 1
        if event_type == "create:task":
            task = payload["task"]
            print(f"  new task {task['id']}: {task.get('name')!r}", flush=True)


def make_handler(secret: str) -> type:
    return type(
        "Handler",
        (DeliveryHandler,),
        {"secret": secret.encode(), "event_counter": Counter()},
    )


def summarize(counter: Counter) -> str:
    return ", ".join(f"{key} x{count}" for key, count in sorted(counter.items())) or "-"


def main() -> None:
    args = parse_args()
    handler = make_handler(args.secret)
    with make_client(args.host, access_token=args.token) as client:
        webhooks_api = client.api_client.webhooks_api
        with HTTPServer(("", args.port), handler) as receiver:
            webhook, _ = webhooks_api.create(
                models.WebhookWriteRequest(
                    target_url=args.public_url,
                    type=models.WebhookType("project"),
                    events=[models.EventsEnum(event) for event in MONITORING_EVENTS],
                    content_type=models.WebhookContentType(args.content_type),
                    secret=args.secret,
                    project_id=args.project_id,
                )
            )
            print(f"Created webhook {webhook.id} -> {webhook.target_url}", flush=True)
            print(f"Listening on port {args.port}; press Ctrl-C to stop", flush=True)

            try:
                while (
                    args.max_events is None or sum(handler.event_counter.values()) < args.max_events
                ):
                    receiver.handle_request()
            except KeyboardInterrupt:
                pass

        print(
            f"Received {sum(handler.event_counter.values())} events: "
            f"{summarize(handler.event_counter)}"
        )
        print(f"Rejected {handler.rejected} deliveries with a bad signature")

        if args.cleanup:
            webhooks_api.destroy(webhook.id)
            print(f"Deleted webhook {webhook.id}")
        else:
            print("Keeping the webhook; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
webhooks_api.list(project_id=, target_url=, type=, ...) Filter the webhook list server-side.
webhooks_api.retrieve_events() The full list of event names a webhook can subscribe to.
webhooks_api.create_deliveries_redelivery(id, delivery_id) Re-send a failed delivery.
webhooks_api.partial_update(id, patched_webhook_write_request=...) Change a webhook’s target, events, or active state in place.
WebhookWriteRequest(..., is_active=False) Create a webhook disabled, to be enabled later.
WebhookWriteRequest(..., enable_ssl=False) Skip TLS certificate verification for self-signed receivers.

Notes:

  • Webhook payloads carry the event name (e.g. update:task), the serialized resource, and the sender.
  • An organization webhook lives in the organization’s scope, so every call about it must be made in that organization’s context (client.organization_context(slug)).
  • A third webhook scope, type="server", exists for server-wide events (user and organization lifecycle events) and is restricted to admin accounts; it isn’t covered by these two project/organization recipes. See the Webhooks guide for details.
  • The delivery list is paginated like every list endpoint; get_paginated_collection() walks all the pages.
  • Full recipes: register_webhook.py, webhook_resource_monitoring.py.