Skip to content

匯出格式

將 Potato 標註匯出為 JSON、JSONL、CSV、CoNLL、HuggingFace Datasets、COCO、YOLO、Pascal VOC 和 Apache Parquet,供下游機器學習流程使用。

Potato 提供兩個層次的匯出:

  1. 原生匯出 - 每個標註者的工作以 JSON 形式儲存在 output_annotation_dir 下,export_annotation_format 會另外生成 csv、tsv、jsonl、parquet 或任何其他已註冊格式的定期匯出
  2. 匯出 CLI(v2.2.0 新增)- python -m potato.export 將標註轉換為專用格式(COCO、YOLO、Pascal VOC、CoNLL-2003、CoNLL-U、Mask PNG、Parquet、EAF、TextGrid)

本頁涵蓋內建格式和匯出 CLI,以及常見目標格式的示例轉換指令碼。

基本匯出格式

JSON

無論 export_annotation_format 設成什麼,每個標註者的工作都會儲存到 <output_annotation_dir>/<user>/user_state.json。只保留標註資料部分的話,檔案是這樣的:

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": {}
}

匯出登錄檔中沒有 json 格式。已廢棄的 output_annotation_format: "json" 會被當作 jsonl 讀取並給出警告,所以請把定期匯出配置為 jsonl:

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

JSON Lines (JSONL)

每個標註者-實例對一條記錄,寫入 <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

每個標註者-實例對一行,instance_id 和 user_id 排在最前,每個 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

製表符分隔值:

yaml
export_annotation_format: "tsv"

匯出 CLI

v2.2.0 新增

匯出 CLI 用一條命令將 Potato 標註轉換為專用格式:

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 選項

選項描述
--config, -cPotato YAML 配置檔案路徑
--format, -f匯出格式(coco、yolo、pascal_voc、conll_2003、conll_u、mask、parquet、eaf、textgrid)
--output, -o輸出目錄(預設:./export_output)
--option格式特定選項,寫成 key=value(可重複)
--list-formats列出可用格式並退出
--verbose, -v啟用詳細日誌

支援的匯出格式

格式ID最適用於
COCOcoco目標檢測、實例分割
YOLOyoloYOLO 模型訓練
Pascal VOCpascal_voc目標檢測(XML)
CoNLL-2003conll_2003命名實體識別、序列標註
CoNLL-Uconll_u詞性標註、依存句法分析
分割掩碼mask語義/實例分割
Apache Parquetparquet大型資料集的列式儲存
ELAN EAFeaf語音/影片標註時間軸
Praat TextGridtextgrid語音學/語音標註

格式相容性矩陣

標註類型COCOYOLOPascal VOCCoNLL-2003CoNLL-UMask
邊界框是是是---
多邊形是----是
關鍵點是-----
文本片段---是是-
分類部分-----

程式設計匯出

在 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")

自定義匯出器

通過繼承 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 匯出格式

CoNLL 格式

序列標註任務(NER、詞性標註)的標準格式:

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

Potato 自己就能寫出這種格式,不需要轉換指令碼:

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

匯出器會對每條資料的文本分詞,把每個片段的字元偏移轉換成 BIO 標籤,寫出 annotations.conll,文件之間用 -DOCSTART- 標記分隔。用 --option schema_name=entities 指定片段方案,用 --option tokenization=whitespace 指定分詞器。

IOB2 格式

用於實體識別的 Inside-Outside-Beginning 標註:

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

spaCy 格式

沒有 spaCy 匯出器,所以這種格式需要寫指令碼。指令碼從 jsonl 匯出裡讀取片段,從資料檔案裡讀取文本,因為匯出中帶的是偏移量而不是條目文本:

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"
)

匯出中每個標註者和每條實例各佔一條記錄,所以當多個標註者標註了同一條資料時,要按 user_id 過濾。

輸出可以直接用於 spacy train:

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

HuggingFace Datasets

Potato 自帶的 HuggingFace 匯出器可以直接推送到 Hub:

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

如果要改為構建本地資料集,讀取 jsonl 匯出並與資料檔案連線以取得文本:

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"]
)

在訓練指令碼中載入:

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

計算機視覺匯出格式

COCO 格式

目標檢測和分割的標準格式:

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"}
  ]
}

請使用匯出器,而不是自己寫指令碼:

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

瀏覽器儲存的每個形狀座標都是相對影像歸一化的,放在 coordinates 鍵下,所以把儲存的數字當成畫素來讀的指令碼會讓每個框都落在錯誤的比例上。匯出器會把它們乘以影像尺寸,把多邊形、橢圓和畫筆掩碼轉換成 COCO 分割資料,並設定 iscrowd。畫素尺寸取自條目上的 image_width 和 image_height、掩碼自身的 RLE 尺寸,或影像檔案本身;取不到時會給出警告。

YOLO 格式

每張圖片一個文本檔案,用於 YOLO 訓練:

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 替你寫出:

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

它為每張圖片寫出一個 labels/<image>.txt,內容是 class_id cx cy w h,另外寫出按 id 順序列出類名的 classes.txt,以及給 Ultralytics 用的 data.yaml。YOLO 需要每張圖片的畫素尺寸,所以缺少某個尺寸時匯出會直接拒絕執行,而不是對著零尺寸的畫面寫座標。

Pascal VOC 格式

許多檢測框架使用的 XML 格式:

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>

pascal_voc 格式為每張圖片寫出一個 XML 檔案:

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

座標輸出為絕對畫素值 xmin、ymin、xmax、ymax,由瀏覽器儲存的歸一化值換算而來。

自定義匯出指令碼

基本匯出指令碼

jsonl 匯出已經把所有標註者合併到一個檔案裡,所以指令碼從載入它開始。每一行是一個標註者對一條實例的一條記錄。

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")

聚合多個標註者

對上面載入的記錄做多數投票。單選方案把答案存成 {label_name: "true"},所以被選中的標籤就是值為 "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()}

計算標註者間一致性

把同一批記錄按 user_id 分組,然後對兩位標註者都標過的條目算分。

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])

最佳實踐

1. 定期匯出

Potato 自己就會週期性地寫出匯出檔案,所以備份任務是把已有的檔案複製走,而不是自己生成:

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. 包含後設資料

在匯出中保留上下文:

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. 驗證匯出

檢查匯出完整性:

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. 版本化匯出

使用時間戳或版本號:

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

整合示例

訓練 HuggingFace 模型

一條記錄帶的是 instance_id、user_id、labels、spans 和 links,並不帶條目正文,所以文本要從你交給 Potato 的資料檔案裡按 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)

訓練 spaCy NER

record["spans"] 按 scheme 名稱分組。每個 span 帶 start、end 和 name——標籤在 name 裡,不是 label——jsonl 匯出器還會補上它覆蓋的 text,是從這個 span 所標註的條目上切出來的:

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 訓練

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']