导出格式
将 Potato 标注导出为 JSON、JSONL、CSV、CoNLL、HuggingFace Datasets、COCO、YOLO、Pascal VOC 和 Apache Parquet,供下游机器学习流程使用。
Potato 提供两个层次的导出:
- 原生导出 - 每个标注者的工作以 JSON 形式保存在
output_annotation_dir下,export_annotation_format会另外生成 csv、tsv、jsonl、parquet 或任何其他已注册格式的定期导出 - 导出 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。只保留标注数据部分的话,文件是这样的:
{
"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:
export_annotation_format: "jsonl"
output_annotation_dir: "output/"JSON Lines (JSONL)
每个标注者-实例对一条记录,写入 <output_annotation_dir>/exports/jsonl/annotations.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": {}}export_annotation_format: "jsonl"CSV
每个标注者-实例对一行,instance_id 和 user_id 排在最前,每个 schema.label 占一列:
instance_id,user_id,sentiment.negative,sentiment.positive
doc_001,user_1,,true
doc_002,user_1,true,export_annotation_format: "csv"TSV
制表符分隔值:
export_annotation_format: "tsv"导出 CLI
v2.2.0 新增
导出 CLI 用一条命令将 Potato 标注转换为专用格式:
# 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=falseCLI 选项
| 选项 | 描述 |
|---|---|
--config, -c | Potato 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 | 最适用于 |
|---|---|---|
| COCO | coco | 目标检测、实例分割 |
| YOLO | yolo | YOLO 模型训练 |
| Pascal VOC | pascal_voc | 目标检测(XML) |
| CoNLL-2003 | conll_2003 | 命名实体识别、序列标注 |
| CoNLL-U | conll_u | 词性标注、依存句法分析 |
| 分割掩码 | mask | 语义/实例分割 |
| Apache Parquet | parquet | 大型数据集的列式存储 |
| ELAN EAF | eaf | 语音/视频标注时间轴 |
| Praat TextGrid | textgrid | 语音学/语音标注 |
格式兼容性矩阵
| 标注类型 | COCO | YOLO | Pascal VOC | CoNLL-2003 | CoNLL-U | Mask |
|---|---|---|---|---|---|---|
| 边界框 | 是 | 是 | 是 | - | - | - |
| 多边形 | 是 | - | - | - | - | 是 |
| 关键点 | 是 | - | - | - | - | - |
| 文本片段 | - | - | - | 是 | 是 | - |
| 分类 | 部分 | - | - | - | - | - |
编程导出
在 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 创建自定义导出器:
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、词性标注)的标准格式:
The O
quick B-ADJ
brown I-ADJ
fox B-NOUN
jumps B-VERB
Potato 自己就能写出这种格式,不需要转换脚本:
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 标注:
John B-PER
Smith I-PER
works O
at O
Google B-ORG
spaCy 格式
没有 spaCy 导出器,所以这种格式需要写脚本。脚本从 jsonl 导出里读取片段,从数据文件里读取文本,因为导出中带的是偏移量而不是条目文本:
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:
python -m spacy train config.cfg --paths.train ./annotations.spacyHuggingFace Datasets
Potato 自带的 HuggingFace 导出器可以直接推送到 Hub:
python -m potato.export --config config.yaml --format huggingface \
--output your-org/my-annotations --option token=hf_xxx如果要改为构建本地数据集,读取 jsonl 导出并与数据文件连接以取得文本:
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"]
)在训练脚本中加载:
from datasets import load_from_disk
dataset = load_from_disk("hf_dataset/")计算机视觉导出格式
COCO 格式
目标检测和分割的标准格式:
{
"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"}
]
}请使用导出器,而不是自己写脚本:
python -m potato.export --config config.yaml --format coco --output ./export/浏览器存储的每个形状坐标都是相对图像归一化的,放在 coordinates 键下,所以把存储的数字当成像素来读的脚本会让每个框都落在错误的比例上。导出器会把它们乘以图像尺寸,把多边形、椭圆和画笔掩码转换成 COCO 分割数据,并设置 iscrowd。像素尺寸取自条目上的 image_width 和 image_height、掩码自身的 RLE 尺寸,或图像文件本身;取不到时会给出警告。
YOLO 格式
每张图片一个文本文件,用于 YOLO 训练:
# 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 替你写出:
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 格式:
<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 文件:
python -m potato.export --config config.yaml --format pascal_voc --output ./export/坐标输出为绝对像素值 xmin、ymin、xmax、ymax,由浏览器存储的归一化值换算而来。
标注引擎导出
v2.11.0 新增
labeling_engine 格式会写出标注引擎为每个条目给出的最终标签(机器标签和人工标签都包括),附上每个标签的来源记录,另外还有质量证书、数据集卡片和模型调用日志。除非配置中的 labeling_engine.enabled 为 true,否则它会拒绝运行:
python -m potato.export --config config.yaml --format labeling_engine --output ./export/potato label export 写出的是同样的文件。导出写出的文件列出了各列。开启引擎时,人工标注的 csv、parquet 和 huggingface 导出还会多出 engine_purpose 和 engine_request 两列,说明每条标注回答的是引擎的哪个请求。按 engine_purpose 筛选,就能把盲态随机样本与人们看过机器标签之后给出的答案分开。
自定义导出脚本
基本导出脚本
jsonl 导出已经把所有标注者合并到一个文件里,所以脚本从加载它开始。每一行是一个标注者对一条实例的一条记录。
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" 的那个键。
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 分组,然后对两位标注者都标过的条目算分。
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 自己就会周期性地写出导出文件,所以备份任务是把已有的文件复制走,而不是自己生成:
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. 包含元数据
在导出中保留上下文:
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. 验证导出
检查导出完整性:
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. 版本化导出
使用时间戳或版本号:
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 连接回来:
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 所标注的条目上切出来的:
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 训练
# After exporting to YOLO format
yolo train data=dataset.yaml model=yolov8n.pt epochs=100dataset.yaml:
train: ./images/train
val: ./images/val
nc: 3
names: ['person', 'car', 'dog']