Dataset recipes


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.