Annotations
Read, save, and delete annotations on individual images. Save is a full overwrite - pass the complete list every time.
Annotations follow the canonical Pictograph JSON schema. See
Annotation format for the full spec. The class
label field is name (not class). Polygons use multi-ring paths, not
flat coordinate arrays.
An image is addressed by its dataset plus its filename on all three surfaces.
In REST the two are path segments, and the image’s directory is part of the
filename segment: an image at /val/img-001.jpg in road-signs is
.../annotations/road-signs/val/img-001.jpg. In the SDK and the CLI the
directory is a separate optional argument, needed only when the same filename
appears in more than one directory.
get
Fetch the typed annotation list attached to an image.
Source: Annotations.get
| Arg | Type | Default | Notes |
|---|---|---|---|
dataset_name |
str |
required | Dataset name. |
image |
str |
required | Image filename (a UUID also works) |
directory_path |
str | None |
None |
Disambiguates a filename that appears in more than one directory |
annotations = client.annotations.get(
dataset_name="road-signs",
image="img-001.jpg",
)
for ann in annotations:
print(ann.name, ann.type)
pictograph annotations get road-signs img-001.jpg
curl -s "https://api.pictograph.io/api/v1/developer/annotations/road-signs/img-001.jpg" \
-H "X-API-Key: $PICTOGRAPH_API_KEY"
Returns list[Annotation]
Annotation
Annotation = BBoxAnnotation | PolygonAnnotation | PolylineAnnotation | KeypointAnnotation
An image with no annotations returns [] (never raises for the “no annotations” case, only for “no such image”). The REST response is {"success": true, "image_id": …, "filename": …, "annotations": [...], "annotation_count": N}.
save
Replace the image’s annotations with the supplied list. Full overwrite:
existing annotations are dropped. Requires member+ role.
Source: Annotations.save
| Arg | Type | Default | Notes |
|---|---|---|---|
dataset_name |
str |
required | Dataset name. |
image |
str |
required | Image filename (a UUID also works) |
annotations |
Sequence[Annotation] |
required | Validated client-side; the server re-validates |
directory_path |
str | None |
None |
Disambiguates a repeated filename |
from pictograph import BBoxAnnotation, BoundingBox, PolygonAnnotation, PolygonGeometry, Point
result = client.annotations.save(
dataset_name="road-signs",
image="img-001.jpg",
annotations=[
BBoxAnnotation(
id="b41e7d90-5c62-4a38-8e15-9d3f2a7c6b81",
name="person",
bounding_box=BoundingBox(x=100, y=200, w=50, h=80),
),
PolygonAnnotation(
id="c52f8ea1-6d73-4b49-9f26-ae4038d7c592",
name="car",
polygon=PolygonGeometry(paths=[
[Point(x=0, y=0), Point(x=10, y=0), Point(x=10, y=10)],
]),
),
],
)
print(result.previous_count, "to", result.new_count, result.status)
pictograph annotations save road-signs img-001.jpg --file annotations.json
The CLI reads a JSON file holding the annotation list - the same array the
SDK builds and the same array REST puts under annotations.
curl -s -X POST "https://api.pictograph.io/api/v1/developer/annotations/road-signs/img-001.jpg" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"annotations": [
{"id": "b41e7d90-5c62-4a38-8e15-9d3f2a7c6b81", "name": "person", "type": "bbox",
"bounding_box": {"x": 100, "y": 200, "w": 50, "h": 80}},
{"id": "c52f8ea1-6d73-4b49-9f26-ae4038d7c592", "name": "car", "type": "polygon",
"polygon": {"paths": [[{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 10, "y": 10}]]}}
]
}'
Returns SaveResult
SaveResult · 4 fields
class SaveResult(BaseModel):
"""Outcome of Annotations.save."""
image_id: str
previous_count: int
new_count: int
status: str
annotations is the only body field the endpoint accepts - the image is identified entirely by the URL. Polygons may omit bounding_box on save: the server computes the enclosing rectangle. Saving never changes the image’s workflow stage.
bulk_save
Save annotations for many images in one call. Same full-overwrite semantics as
save - each image’s existing annotations are dropped. Up to 200 images per
call. A bad image id lands in failed rather than failing the whole batch.
Requires member+ role.
Source: Annotations.bulk_save
| Arg | Type | Default | Notes |
|---|---|---|---|
saves |
Mapping[str, Sequence[Annotation]] |
required | Mapping of image_id → the annotations to set on it (canonical Pictograph JSON Annotation objects). At most 200 entries; a larger map raises ValidationError (the backend caps the batch). |
from pictograph import BBoxAnnotation, BoundingBox
# bulk_save is the one annotation call keyed by image UUID rather than
# filename: it writes many images at once, and an id needs no lookup.
result = client.annotations.bulk_save(
saves={
"a1b2c3d4-5e6f-4708-9a1b-2c3d4e5f6a7b": [
BBoxAnnotation(id="b41e7d90-5c62-4a38-8e15-9d3f2a7c6b81", name="person",
bounding_box=BoundingBox(x=100, y=200, w=50, h=80)),
],
"b2c3d4e5-6f70-4819-a2bc-3d4e5f6a7b8c": [
BBoxAnnotation(id="c52f8ea1-6d73-4b49-9f26-ae4038d7c592", name="car",
bounding_box=BoundingBox(x=10, y=20, w=30, h=40)),
],
},
)
print(result.saved_count, "saved,", len(result.failed), "failed")
for fail in result.failed:
print(fail.image_id, fail.error)
pictograph annotations bulk-save --file saves.json
The CLI file is a JSON object mapping image UUID to its annotation list, the
same shape as the saves argument.
curl -s -X POST "https://api.pictograph.io/api/v1/developer/annotations/bulk" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"saves": [
{"image_id": "a1b2c3d4-5e6f-4708-9a1b-2c3d4e5f6a7b", "annotations": [
{"id": "b41e7d90-5c62-4a38-8e15-9d3f2a7c6b81", "name": "person", "type": "bbox",
"bounding_box": {"x": 100, "y": 200, "w": 50, "h": 80}}
]},
{"image_id": "b2c3d4e5-6f70-4819-a2bc-3d4e5f6a7b8c", "annotations": [
{"id": "c52f8ea1-6d73-4b49-9f26-ae4038d7c592", "name": "car", "type": "bbox",
"bounding_box": {"x": 10, "y": 20, "w": 30, "h": 40}}
]}
]
}'
Returns BulkSaveResult
BulkSaveResult · 2 fields
class BulkSaveResult(BaseModel):
"""Outcome of Annotations.bulk_save."""
saved: list[SaveResult]
failed: list[BulkSaveFailure]
delete
Remove every annotation from the image. Equivalent to save with an empty
list, but uses DELETE and requires admin+ role.
Source: Annotations.delete
| Arg | Type | Default | Notes |
|---|---|---|---|
dataset_name |
str |
required | Dataset name. |
image |
str |
required | Image filename (a UUID also works) |
directory_path |
str | None |
None |
Disambiguates a repeated filename |
result = client.annotations.delete(
dataset_name="road-signs",
image="img-001.jpg",
)
print(result.deleted_count)
pictograph annotations delete road-signs img-001.jpg --yes
curl -s -X DELETE "https://api.pictograph.io/api/v1/developer/annotations/road-signs/img-001.jpg" \
-H "X-API-Key: $PICTOGRAPH_API_KEY"
Returns DeleteResult
DeleteResult · 2 fields
class DeleteResult(BaseModel):
"""Outcome of Annotations.delete."""
image_id: str
deleted_count: int
rename_class
Rename an annotation class across a whole dataset in one call - both the
class ontology entry (the class list the editor shows) and every stored
annotation carrying the old name. One set-based server-side statement, not a
per-image loop - use it for label-taxonomy cleanup (car to vehicle).
Requires member+ role.
Source: Annotations.rename_class
| Arg | Type | Default | Notes |
|---|---|---|---|
dataset_name |
str |
required | The dataset’s name (a UUID also works). |
old_name |
str |
required | The class name to replace. |
new_name |
str |
required | The replacement name. A collision with an existing class of the same annotation type raises ConflictError. |
result = client.annotations.rename_class(
dataset_name="road-signs",
old_name="car",
new_name="vehicle",
)
print(result.annotations_updated, "annotations across", result.images_updated, "images")
# result.config_updated is True when the ontology entry was renamed too
pictograph annotations rename-class road-signs car vehicle
curl -s -X POST "https://api.pictograph.io/api/v1/developer/annotations/rename-class" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset": "road-signs", "old_name": "car", "new_name": "vehicle"}'
Returns RenameClassResult
RenameClassResult · 6 fields
class RenameClassResult(BaseModel):
"""Outcome of Annotations.rename_class."""
dataset_id: str
old_name: str
new_name: str
images_updated: int
annotations_updated: int
config_updated: bool
The response is {"data": {dataset_id, old_name, new_name, images_updated, annotations_updated, config_updated}}. Renaming onto a class name that
already exists for the same annotation type returns 409 ConflictError
(merge the classes deliberately instead); a class that only exists on
annotations, not in the ontology, still renames, with
config_updated: false.
merge_class
Merge one class into another across a whole dataset: every annotation
labeled source_name is reassigned to target_name and the source class is
dropped from the ontology (the target is kept). This is the deliberate
counterpart to the rename_class 409 - use it to combine two classes
(car plus auto into vehicle). One set-based server-side statement.
Requires member+ role.
Source: Annotations.merge_class
| Arg | Type | Default | Notes |
|---|---|---|---|
dataset_name |
str |
required | The dataset’s name (a UUID also works). |
source_name |
str |
required | The class merged away. |
target_name |
str |
required | The class kept; source annotations now carry it. |
result = client.annotations.merge_class(
dataset_name="road-signs",
source_name="auto",
target_name="vehicle",
)
print(result.annotations_updated, "annotations reassigned to", result.target_name)
pictograph annotations merge-class road-signs auto vehicle
curl -s -X POST "https://api.pictograph.io/api/v1/developer/annotations/merge-class" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset": "road-signs", "source_name": "auto", "target_name": "vehicle"}'
Returns MergeClassResult
MergeClassResult · 6 fields
class MergeClassResult(BaseModel):
"""Outcome of Annotations.merge_class."""
dataset_id: str
source_name: str
target_name: str
images_updated: int
annotations_updated: int
config_updated: bool
The response is {"data": {dataset_id, source_name, target_name, images_updated, annotations_updated, config_updated}}.
delete_class
Delete a class from a dataset’s ontology, optionally also removing every
annotation of that class in one set-based statement. With
delete_annotations=False (the default) only the ontology entry is removed and
existing annotations are left in place; True strips them too. Requires
member+ role.
Source: Annotations.delete_class
| Arg | Type | Default | Notes |
|---|---|---|---|
dataset_name |
str |
required | Dataset name. |
name |
str |
required | The class name to delete |
class_type |
str | None |
None |
Narrow the removal to one (name, type) ontology entry |
delete_annotations |
bool |
False |
Also strip every annotation of the class |
# ontology only (annotations left as-is)
client.annotations.delete_class(
dataset_name="road-signs",
name="obsolete",
)
# also strip every annotation of the class
result = client.annotations.delete_class(
dataset_name="road-signs",
name="obsolete",
delete_annotations=True,
)
print(result.annotations_removed, "annotations removed")
pictograph annotations delete-class road-signs obsolete --with-annotations --yes
curl -s -X POST "https://api.pictograph.io/api/v1/developer/annotations/delete-class" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset": "road-signs", "name": "obsolete", "delete_annotations": true}'
Returns DeleteClassResult
DeleteClassResult · 5 fields
class DeleteClassResult(BaseModel):
"""Outcome of Annotations.delete_class."""
dataset_id: str
name: str
config_updated: bool
images_updated: int
annotations_removed: int
The response is {"data": {dataset_id, name, config_updated, images_updated, annotations_removed}}.
Validation
The SDK Pydantic models reject malformed payloads at construction:
from pictograph import PolygonAnnotation, PolygonGeometry, Point
PolygonGeometry(paths=[[Point(x=0, y=0)]])
# ValidationError: paths[0] has 1 point(s); polygon ring requires >= 3
The server re-validates on save as defense in depth: callers that construct
dicts directly hit 422 ValidationError for the same class of mistakes.
import_coco
Parse a COCO dataset and save its annotations onto a Pictograph dataset.
Source: Annotations.import_coco
| Arg | Type | Default | Notes |
|---|---|---|---|
dataset_name |
str |
required | Dataset name. |
coco |
dict[str, Any] | str | Path |
required | A parsed COCO dict, or a path / JSON string to one. |
create_missing_classes |
bool |
True |
When True (default), add any class the COCO categories reference but the dataset doesn’t yet define, inferring each class’s annotation type from its first annotation. |
save_chunk |
int |
200 |
Images per bulk_save call (backend cap 200). |
report = client.annotations.import_coco(
dataset_name="road-signs",
coco="./instances_train.json",
)
print(report.images_matched, "matched;", report.unmatched_files, "unmatched")
# No `import-coco` command; use the SDK or REST.
# No import command - the SDK converts and saves in one call.
pictograph annotations save road-signs img-001.jpg --file annotations.json
# `import_coco` parses the COCO file on your machine, matches each
# record to an image by filename, then writes them in one call:
curl -s -X POST "https://api.pictograph.io/api/v1/developer/annotations/bulk" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"saves": [{"image_id": "$IMAGE_ID", "annotations": [ ... ]}]}'
Returns AnnotationImportReport
AnnotationImportReport · 6 fields
class AnnotationImportReport(BaseModel):
"""Outcome of an Annotations.import_coco / `import_pascal_voc` / `import_yolo` call."""
dataset_name: str
images_matched: int = 0
images_saved: int = 0
annotations_saved: int = 0
unmatched_files: list[str] = []
failures: list[AnnotationImportFailure] = []
import_pascal_voc
Parse per-image Pascal VOC XML and save the annotations onto a dataset.
Source: Annotations.import_pascal_voc
| Arg | Type | Default | Notes |
|---|---|---|---|
dataset_name |
str |
required | Dataset name. |
xml_by_filename |
Mapping[str, str] |
required | file_name → that image’s Pascal VOC .xml contents. |
create_missing_classes |
bool |
True |
Add referenced-but-undefined classes (default True). |
save_chunk |
int |
200 |
Images per bulk_save call (backend cap 200). |
report = client.annotations.import_pascal_voc(
dataset_name="road-signs",
xml_by_filename={"img-001.jpg": Path("./img-001.xml").read_text()},
)
print(report.images_matched, "matched;", report.unmatched_files, "unmatched")
# No `import-pascal-voc` command; use the SDK or REST.
# No import command - the SDK converts and saves in one call.
pictograph annotations save road-signs img-001.jpg --file annotations.json
# `import_pascal_voc` parses the PASCAL VOC file on your machine, matches each
# record to an image by filename, then writes them in one call:
curl -s -X POST "https://api.pictograph.io/api/v1/developer/annotations/bulk" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"saves": [{"image_id": "$IMAGE_ID", "annotations": [ ... ]}]}'
Returns AnnotationImportReport
AnnotationImportReport · 6 fields
class AnnotationImportReport(BaseModel):
"""Outcome of an Annotations.import_coco / `import_pascal_voc` / `import_yolo` call."""
dataset_name: str
images_matched: int = 0
images_saved: int = 0
annotations_saved: int = 0
unmatched_files: list[str] = []
failures: list[AnnotationImportFailure] = []
import_yolo
Parse YOLO label text per image and save the annotations onto a dataset.
Source: Annotations.import_yolo
| Arg | Type | Default | Notes |
|---|---|---|---|
dataset_name |
str |
required | Dataset name. |
labels |
Mapping[str, str] |
required | file_name → that image’s YOLO .txt contents. |
class_names |
Sequence[str] |
required | Ordered class names - YOLO’s integer class index maps here. |
create_missing_classes |
bool |
True |
Add referenced-but-undefined classes (default True). |
save_chunk |
int |
200 |
Images per bulk_save call (backend cap 200). |
report = client.annotations.import_yolo(
dataset_name="road-signs",
labels={"img-001.jpg": Path("./img-001.txt").read_text()},
class_names=["stop", "yield", "speed-limit"],
)
print(report.images_matched, "matched;", report.unmatched_files, "unmatched")
# No `import-yolo` command; use the SDK or REST.
# No import command - the SDK converts and saves in one call.
pictograph annotations save road-signs img-001.jpg --file annotations.json
# `import_yolo` parses the YOLO file on your machine, matches each
# record to an image by filename, then writes them in one call:
curl -s -X POST "https://api.pictograph.io/api/v1/developer/annotations/bulk" \
-H "X-API-Key: $PICTOGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"saves": [{"image_id": "$IMAGE_ID", "annotations": [ ... ]}]}'
Returns AnnotationImportReport
AnnotationImportReport · 6 fields
class AnnotationImportReport(BaseModel):
"""Outcome of an Annotations.import_coco / `import_pascal_voc` / `import_yolo` call."""
dataset_name: str
images_matched: int = 0
images_saved: int = 0
annotations_saved: int = 0
unmatched_files: list[str] = []
failures: list[AnnotationImportFailure] = []
Common errors
| Status | Exception | Cause |
|---|---|---|
| 404 | NotFoundError |
No such dataset, or no such image in it; also raised when the image belongs to another organization |
| 422 | ValidationError |
class instead of name, a flat polygon array, or an unrecognized body field |
| 409 | ConflictError |
rename_class onto a name that already exists for the same annotation type |
| 403 | ForbiddenError |
save, bulk_save and the class operations require member+; delete requires admin+ |
Auto-annotate
If you want SAM3 to generate annotations rather than write them by hand,
see the auto-annotate resource.
client.auto_annotate.dataset() saves annotations automatically;
the single-prompt methods return a PromptResult and you call save
yourself.