Ground truth recipes
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:
gtmode moves the ground truth frames into a separate ground truth job;gt_poolmode copies pool frames into the annotation jobs. Onlygt_poolmakes annotators encounter ground truth frames while working.- Ground truth frames are referenced by file name in
validation_paramsand by frame index inJobWriteRequestand 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.