Skip to content

Export Formats

Export Potato annotations to JSON, JSONL, CSV, CoNLL, HuggingFace Datasets, COCO, YOLO, Pascal VOC, and Apache Parquet for downstream ML pipelines.

Potato provides two levels of export:

  1. Native Export - Each annotator's work is saved as JSON under output_annotation_dir, and export_annotation_format adds a periodic export in csv, tsv, jsonl, parquet or any other registered format
  2. Export CLI (New in v2.2.0) - python -m potato.export converts annotations to any of 31 registered formats, among them COCO, YOLO, Pascal VOC, CoNLL-2003, CoNLL-U, Mask PNG, Parquet, EAF, TextGrid and HuggingFace. Run python -m potato.export --list-formats for the set your install actually has

This page covers both the built-in formats and the export CLI, plus example conversion scripts for common targets.

Basic Export Formats

JSON

Every annotator's work is saved to <output_annotation_dir>/<user>/user_state.json, whatever export_annotation_format says. Trimmed to the annotation payload, the file looks like this:

json
{
  "user_id": "user_1",
  "instance_id_ordering": ["doc_001", "doc_002"],
  "instance_id_to_label_to_value": {
    "doc_001": [[{"schema": "sentiment", "name": "positive"}, "true"]]
  },
  "instance_id_to_span_to_value": {}
}

The export registry has no json format. The retired output_annotation_format: "json" is read as jsonl with a warning, so configure the periodic export as jsonl:

yaml
export_annotation_format: "jsonl"
output_annotation_dir: "output/"

JSON Lines (JSONL)

One record per annotator-instance pair, written to <output_annotation_dir>/exports/jsonl/annotations.jsonl:

jsonl
{"instance_id": "doc_001", "user_id": "user_1", "labels": {"sentiment": {"positive": "true"}}, "spans": {}, "links": {}}
{"instance_id": "doc_002", "user_id": "user_1", "labels": {"sentiment": {"negative": "true"}}, "spans": {}, "links": {}}
yaml
export_annotation_format: "jsonl"

CSV

One row per annotator-instance pair, with instance_id and user_id first and one column per schema.label:

csv
instance_id,user_id,sentiment.negative,sentiment.positive
doc_001,user_1,,true
doc_002,user_1,true,
yaml
export_annotation_format: "csv"

TSV

Tab-separated values:

yaml
export_annotation_format: "tsv"

Export CLI

New in v2.2.0

The export CLI converts Potato annotations to specialized formats with a single command:

bash
# List available export formats
python -m potato.export --list-formats
 
# Export to COCO format
python -m potato.export --config config.yaml --format coco --output ./export/
 
# Export to YOLO format
python -m potato.export --config config.yaml --format yolo --output ./export/
 
# Export with options
python -m potato.export --config config.yaml --format coco --output ./export/ \
    --option split_ratio=0.8 --option include_unlabeled=false

CLI Options

OptionDescription
--config, -cPath to Potato YAML config file
--format, -fExport format (coco, yolo, pascal_voc, conll_2003, conll_u, mask, parquet, eaf, textgrid)
--output, -oOutput directory (default: ./export_output)
--optionFormat-specific option as key=value (repeatable)
--list-formatsList available formats and exit
--verbose, -vEnable verbose logging

Supported Export Formats

FormatIDBest For
COCOcocoObject detection, instance segmentation
YOLOyoloYOLO model training
Pascal VOCpascal_vocObject detection (XML)
CoNLL-2003conll_2003NER, sequence labeling
CoNLL-Uconll_uPOS tagging, dependency parsing
Segmentation MasksmaskSemantic/instance segmentation
Apache ParquetparquetColumnar storage for large datasets
ELAN EAFeafSpeech/video annotation timelines
Praat TextGridtextgridPhonetic/speech annotation

Format Compatibility Matrix

Annotation TypeCOCOYOLOPascal VOCCoNLL-2003CoNLL-UMask
Bounding boxesYesYesYes---
PolygonsYes----Yes
KeypointsYes-----
Text spans---YesYes-
ClassificationsPartial-----

Programmatic Export

Use the export registry directly in Python:

python
from potato.export.registry import export_registry
from potato.export.cli import build_export_context
 
context = build_export_context("path/to/config.yaml")
result = export_registry.export("coco", context, "./output/")
 
if result.success:
    print(f"Exported {len(result.files_written)} files")

Custom Exporters

Create custom exporters by subclassing BaseExporter:

python
from potato.export.base import BaseExporter, ExportContext, ExportResult
 
class MyExporter(BaseExporter):
    format_name = "my_format"
    description = "My custom export format"
    file_extensions = [".myformat"]
 
    def can_export(self, context: ExportContext) -> tuple:
        has_spans = any(ann.get("spans") for ann in context.annotations)
        if not has_spans:
            return False, "No span annotations found"
        return True, None
 
    def export(self, context: ExportContext, output_path: str,
               options: dict = None) -> ExportResult:
        # Perform the export
        return ExportResult(
            success=True,
            format_name=self.format_name,
            files_written=["output.myformat"],
            stats={"annotations": len(context.annotations)}
        )
 
from potato.export.registry import export_registry
export_registry.register(MyExporter())

NLP Export Formats

CoNLL Format

Standard format for sequence labeling tasks (NER, POS tagging):

text
The     O
quick   B-ADJ
brown   I-ADJ
fox     B-NOUN
jumps   B-VERB

Potato writes this format itself, so no conversion script is needed:

bash
python -m potato.export --config config.yaml --format conll_2003 --output ./export/

The exporter tokenizes each item's text, converts every span's character offsets to BIO tags, and writes annotations.conll with -DOCSTART- markers between documents. Use --option schema_name=entities to pick the span scheme and --option tokenization=whitespace to pick the tokenizer.

IOB2 Format

Inside-Outside-Beginning tagging for entity recognition:

text
John    B-PER
Smith   I-PER
works   O
at      O
Google  B-ORG

spaCy Format

There is no spaCy exporter, so this one needs a script. It reads the jsonl export for the spans and the data file for the text, since the export carries offsets rather than the item text:

python
import json
 
import spacy
from spacy.tokens import DocBin
 
def convert_to_spacy(annotations_file, data_file, output_file, scheme_name="entities"):
    """Convert a Potato jsonl export to a spaCy DocBin for NER training."""
    with open(data_file) as f:
        texts = {item["id"]: item["text"] for item in json.load(f)}
 
    nlp = spacy.blank("en")
    doc_bin = DocBin()
 
    with open(annotations_file) as f:
        for line in f:
            record = json.loads(line)
            text = texts.get(record["instance_id"])
            if text is None:
                continue
 
            doc = nlp.make_doc(text)
            ents = []
            for span in record["spans"].get(scheme_name, []):
                # start and end are character offsets into the item text,
                # and the label name is under "name".
                char_span = doc.char_span(
                    span["start"], span["end"], label=span["name"]
                )
                if char_span is not None:
                    ents.append(char_span)
            doc.ents = ents
            doc_bin.add(doc)
 
    doc_bin.to_disk(output_file)
 
# Usage
convert_to_spacy(
    "annotation_output/exports/jsonl/annotations.jsonl",
    "data/items.json", "train.spacy", "entities"
)

The export holds one record per annotator and instance, so filter on user_id when several annotators labeled the same item.

The output can be directly used with spacy train:

bash
python -m spacy train config.cfg --paths.train ./annotations.spacy

HuggingFace Datasets

Potato's own HuggingFace exporter pushes straight to the Hub:

bash
python -m potato.export --config config.yaml --format huggingface \
    --output your-org/my-annotations --option token=hf_xxx

To build a local dataset instead, read the jsonl export and join it to the data file for the text:

python
import json
from datasets import Dataset
 
def convert_to_huggingface(annotations_file, data_file, output_dir, scheme_names):
    """Convert a Potato jsonl export to a HuggingFace Dataset."""
    with open(data_file) as f:
        texts = {item["id"]: item["text"] for item in json.load(f)}
 
    records = []
    with open(annotations_file) as f:
        for line in f:
            r = json.loads(line)
            record = {
                "id": r["instance_id"],
                "user_id": r["user_id"],
                "text": texts.get(r["instance_id"], ""),
            }
            for scheme in scheme_names:
                # A single-choice scheme stores {label_name: "true"}.
                chosen = [name for name, value in r["labels"].get(scheme, {}).items()
                          if value == "true"]
                record[scheme] = chosen[0] if chosen else None
            records.append(record)
 
    dataset = Dataset.from_list(records)
    dataset.save_to_disk(output_dir)
    print(f"Saved {len(records)} examples to {output_dir}")
 
# Usage
convert_to_huggingface(
    "annotation_output/exports/jsonl/annotations.jsonl",
    "data/items.json", "hf_dataset/", ["sentiment"]
)

Load in your training script:

python
from datasets import load_from_disk
 
dataset = load_from_disk("hf_dataset/")

Computer Vision Export Formats

COCO Format

Standard format for object detection and segmentation:

json
{
  "images": [
    {"id": 1, "file_name": "image_001.jpg", "width": 640, "height": 480}
  ],
  "annotations": [
    {
      "id": 1,
      "image_id": 1,
      "category_id": 1,
      "bbox": [100, 150, 200, 300],
      "area": 60000,
      "segmentation": [[100, 150, 300, 150, 300, 450, 100, 450]]
    }
  ],
  "categories": [
    {"id": 1, "name": "person"}
  ]
}

Use the exporter rather than a script of your own:

bash
python -m potato.export --config config.yaml --format coco --output ./export/

The browser stores each shape's coordinates normalized to the image, under a coordinates key, so a script that reads the stored numbers as pixels puts every box at the wrong scale. The exporter multiplies them by the image size, converts polygons, ellipses and brush masks into COCO segmentation, and sets iscrowd. It takes the pixel size from image_width and image_height on the item, from a mask's own RLE size, or from the image file, and warns when it cannot determine one.

YOLO Format

One text file per image for YOLO training:

text
# class_id center_x center_y width height (normalized 0-1)
0 0.5 0.5 0.3 0.4
1 0.2 0.3 0.1 0.2

Potato writes these files for you:

bash
python -m potato.export --config config.yaml --format yolo --output ./export/

It writes one labels/<image>.txt per image with class_id cx cy w h, a classes.txt listing the class names in id order, and a data.yaml for Ultralytics. YOLO needs the pixel size of every image, so the export is refused outright when a dimension is missing rather than writing coordinates against a zero-sized frame.

Pascal VOC Format

XML format used by many detection frameworks:

xml
<annotation>
  <filename>image_001.jpg</filename>
  <size>
    <width>640</width>
    <height>480</height>
  </size>
  <object>
    <name>person</name>
    <bndbox>
      <xmin>100</xmin>
      <ymin>150</ymin>
      <xmax>300</xmax>
      <ymax>450</ymax>
    </bndbox>
  </object>
</annotation>

The pascal_voc format writes one XML file per image:

bash
python -m potato.export --config config.yaml --format pascal_voc --output ./export/

Coordinates come out as absolute xmin, ymin, xmax, ymax pixels, converted from the normalized values the browser stores.

Labeling Engine Export

New in v2.11.0

The labeling_engine format writes the labeling engine's final labels for every item, machine and human, with the provenance of each label, plus the quality certificate, a dataset card and the model-call log. It refuses to run unless labeling_engine.enabled is true in the config:

bash
python -m potato.export --config config.yaml --format labeling_engine --output ./export/

potato label export writes the same files. Files written by export lists the columns. When the engine is on, the csv, parquet and huggingface exports of people's annotations also gain engine_purpose and engine_request columns, which say which engine request each annotation answered. Filter on engine_purpose to separate the blind random sample from answers people gave after seeing a machine label.

Custom Export Scripts

Basic Export Script

The jsonl export already combines every annotator into one file, so a script starts by loading it. Each line is one record for one annotator and one instance.

python
import json
 
def load_annotations(annotations_file):
    """Load a Potato jsonl export into a list of records."""
    with open(annotations_file) as f:
        return [json.loads(line) for line in f]
 
# Usage
records = load_annotations("annotation_output/exports/jsonl/annotations.jsonl")

Aggregating Multiple Annotators

Majority vote over the records loaded above. A single-choice scheme stores its answer as {label_name: "true"}, so the chosen label is the key whose value is "true".

python
from collections import Counter
 
def chosen_label(record, scheme_name):
    """The label an annotator selected for a single-choice scheme, or None."""
    for name, value in record["labels"].get(scheme_name, {}).items():
        if value == "true":
            return name
    return None
 
def aggregate_labels(records, scheme_name):
    """Majority vote aggregation for classification tasks."""
    item_labels = {}
    for record in records:
        label = chosen_label(record, scheme_name)
        if label is not None:
            item_labels.setdefault(record["instance_id"], []).append(label)
 
    return {item_id: Counter(labels).most_common(1)[0][0]
            for item_id, labels in item_labels.items()}

Computing Inter-Annotator Agreement

Group the same records by user_id, then score the items both annotators labeled.

python
from sklearn.metrics import cohen_kappa_score
 
def compute_agreement(records, scheme_name, user_a, user_b):
    """Compute Cohen's Kappa between two annotators on their shared items."""
    by_user = {}
    for record in records:
        label = chosen_label(record, scheme_name)
        if label is not None:
            by_user.setdefault(record["user_id"], {})[record["instance_id"]] = label
 
    a, b = by_user.get(user_a, {}), by_user.get(user_b, {})
    common_ids = sorted(set(a) & set(b))
 
    return cohen_kappa_score([a[i] for i in common_ids],
                             [b[i] for i in common_ids])

Best Practices

1. Export Regularly

Set up automated exports for backup and analysis:

Potato already writes the periodic export itself, so a backup job copies what is there rather than producing it:

python
import json
import shutil
import schedule
from datetime import date
 
EXPORT = "annotation_output/exports/jsonl/annotations.jsonl"
 
def daily_backup():
    shutil.copy(EXPORT, f"backups/annotations_{date.today()}.jsonl")
 
schedule.every().day.at("18:00").do(daily_backup)

2. Include Metadata

Preserve context in exports:

python
from datetime import datetime
 
records = load_annotations("annotation_output/exports/jsonl/annotations.jsonl")
 
export_data = {
    "metadata": {
        "task": "Sentiment Analysis",
        "exported_at": datetime.now().isoformat(),
        "total_records": len(records),
        "annotators": sorted({r["user_id"] for r in records}),
    },
    "records": records,
}

3. Validate Exports

Check export integrity:

python
def validate_export(export_file, expected_records):
    """The jsonl export is one record per annotator per instance."""
    records = load_annotations(export_file)
 
    assert len(records) == expected_records, "Missing records"
    assert all("instance_id" in r and "user_id" in r for r in records), "Missing keys"
    print(f"Export validated: {len(records)} records")

4. Version Your Exports

Use timestamps or version numbers:

text
exports/
  annotations_v1_2024-01-15.json
  annotations_v2_2024-01-20.json
  annotations_final_2024-01-25.json

Integration Examples

Training a HuggingFace Model

A record carries instance_id, user_id, labels, spans and links. It does not carry the item text, so the text comes from the data file you gave Potato, joined back on the instance id:

python
import json
from datasets import Dataset
from transformers import AutoTokenizer, AutoModelForSequenceClassification
 
records = load_annotations("annotation_output/exports/jsonl/annotations.jsonl")
labels = aggregate_labels(records, "sentiment")
 
with open("data/items.json") as f:
    text_by_id = {item["id"]: item["text"] for item in json.load(f)}
 
dataset = Dataset.from_list([
    {"text": text_by_id[item_id], "label": label}
    for item_id, label in labels.items()
    if item_id in text_by_id
])
 
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
model = AutoModelForSequenceClassification.from_pretrained(
    "bert-base-uncased", num_labels=3)

Training spaCy NER

record["spans"] is keyed by scheme name. Each span carries start, end and name — the label is under name, not label — and the jsonl exporter adds the text it covers, sliced from the item the span was drawn on:

python
import json
import spacy
from spacy.tokens import DocBin
 
records = load_annotations("annotation_output/exports/jsonl/annotations.jsonl")
 
with open("data/items.json") as f:
    text_by_id = {item["id"]: item["text"] for item in json.load(f)}
 
nlp = spacy.blank("en")
doc_bin = DocBin()
 
for record in records:
    text = text_by_id.get(record["instance_id"])
    if text is None:
        continue
    doc = nlp.make_doc(text)
    ents = []
    for span in record["spans"].get("entities", []):
        ent = doc.char_span(span["start"], span["end"], label=span["name"])
        if ent:
            ents.append(ent)
    doc.ents = ents
    doc_bin.add(doc)
 
doc_bin.to_disk("./train.spacy")

YOLO Training

bash
# After exporting to YOLO format
yolo train data=dataset.yaml model=yolov8n.pt epochs=100

dataset.yaml:

yaml
train: ./images/train
val: ./images/val
nc: 3
names: ['person', 'car', 'dog']