/** * Annotation Exporter * * Flexible annotation export system with pluggable format converters. * Supports custom export formats for different use cases. */ export interface LabelStudioAnnotation { id: number task: number project: number completed_by: { id: number email: string first_name: string last_name: string } result: Array<{ from_name: string to_name: string type: string value: any }> created_at: string updated_at: string lead_time?: number } export interface ExportContext { projectId: number projectTitle?: string exportedAt: string metadata?: any } /** * Export format converter function type * Converts Label Studio annotations to custom format */ export type FormatConverter = ( annotations: LabelStudioAnnotation[], context: ExportContext ) => T | Promise /** * Registry for custom export formats */ const formatConverters: Map = new Map() /** * Register a custom export format converter * * @param formatName - Unique format identifier (e.g., "csv", "coco", "yolo") * @param converter - Converter function * * @example * registerExportFormat('csv', (annotations, context) => { * const rows = annotations.map(a => ({ * id: a.id, * task: a.task, * result: JSON.stringify(a.result) * })) * return Papa.unparse(rows) * }) */ export function registerExportFormat(formatName: string, converter: FormatConverter): void { formatConverters.set(formatName.toLowerCase(), converter) } /** * Unregister an export format */ export function unregisterExportFormat(formatName: string): void { formatConverters.delete(formatName.toLowerCase()) } /** * Get all registered format names */ export function getRegisteredFormats(): string[] { return Array.from(formatConverters.keys()) } /** * Export annotations to specified format * * @param annotations - Label Studio annotations * @param format - Export format name * @param context - Export context * @returns Converted data in requested format */ export async function exportAnnotations( annotations: LabelStudioAnnotation[], format: string, context: ExportContext ): Promise { const converter = formatConverters.get(format.toLowerCase()) if (!converter) { throw new Error(`Unknown export format: ${format}. Available formats: ${getRegisteredFormats().join(', ')}`) } return await converter(annotations, context) } /** * Pre-built export formats */ export class ExportFormats { /** * Export as JSON (default Label Studio format) */ static json(): FormatConverter { return (annotations, context) => { return JSON.stringify( { meta: context, annotations }, null, 2 ) } } /** * Export as JSON Lines (one annotation per line) */ static jsonl(): FormatConverter { return annotations => { return annotations.map(a => JSON.stringify(a)).join('\n') } } /** * Export image classification results as CSV */ static imageClassificationCSV(): FormatConverter { return annotations => { const rows: string[] = ['task_id,annotation_id,label,annotator,lead_time,created_at'] for (const annotation of annotations) { const label = annotation.result.find(r => r.type === 'choices') if (label && label.value.choices) { const choice = label.value.choices[0] rows.push( [ annotation.task, annotation.id, choice, annotation.completed_by?.email || 'unknown', annotation.lead_time || 0, annotation.created_at ].join(',') ) } } return rows.join('\n') } } /** * Export Rank N classification results */ static rankNClassificationCSV(ranks: number = 3): FormatConverter { return annotations => { const rankHeaders = Array.from({ length: ranks }, (_, i) => `rank${i + 1}`).join(',') const rows: string[] = [`task_id,annotation_id,${rankHeaders},annotator,lead_time,created_at`] for (const annotation of annotations) { const rankValues: string[] = [] for (let i = 1; i <= ranks; i++) { const rankResult = annotation.result.find(r => r.from_name === `rank${i}`) rankValues.push(rankResult?.value?.choices?.[0] || '') } rows.push( [ annotation.task, annotation.id, ...rankValues, annotation.completed_by?.email || 'unknown', annotation.lead_time || 0, annotation.created_at ].join(',') ) } return rows.join('\n') } } /** * Export object detection results as COCO format */ static coco(): FormatConverter { return (annotations, context) => { const images: any[] = [] const cocoAnnotations: any[] = [] const categories: Map = new Map() let imageId = 1 let annotationId = 1 let categoryId = 1 for (const annotation of annotations) { // Add image images.push({ id: imageId, file_name: `task_${annotation.task}.jpg`, width: 0, // TODO: extract from annotation if available height: 0 }) // Process bounding boxes for (const result of annotation.result) { if (result.type === 'rectanglelabels') { const label = result.value.rectanglelabels[0] // Register category if (!categories.has(label)) { categories.set(label, categoryId++) } // Add annotation cocoAnnotations.push({ id: annotationId++, image_id: imageId, category_id: categories.get(label), bbox: [result.value.x, result.value.y, result.value.width, result.value.height], area: result.value.width * result.value.height, iscrowd: 0 }) } } imageId++ } return { info: { description: context.projectTitle || 'Label Studio Export', date_created: context.exportedAt }, images, annotations: cocoAnnotations, categories: Array.from(categories.entries()).map(([name, id]) => ({ id, name, supercategory: 'none' })) } } } /** * Export object detection results as YOLO format */ static yolo(): FormatConverter> { return annotations => { const taskAnnotations: Map = new Map() const categoryIndex: Map = new Map() let nextCategoryId = 0 for (const annotation of annotations) { const lines: string[] = [] for (const result of annotation.result) { if (result.type === 'rectanglelabels') { const label = result.value.rectanglelabels[0] // Register category if (!categoryIndex.has(label)) { categoryIndex.set(label, nextCategoryId++) } const classId = categoryIndex.get(label)! // Convert to YOLO format (normalized center x, center y, width, height) const x = result.value.x / 100 const y = result.value.y / 100 const w = result.value.width / 100 const h = result.value.height / 100 const centerX = x + w / 2 const centerY = y + h / 2 lines.push(`${classId} ${centerX} ${centerY} ${w} ${h}`) } } if (lines.length > 0) { taskAnnotations.set(annotation.task, lines.join('\n')) } } return taskAnnotations } } /** * Export NER (Named Entity Recognition) results */ static nerJSON(): FormatConverter { return annotations => { return annotations.map(annotation => { const entities: any[] = [] for (const result of annotation.result) { if (result.type === 'labels') { entities.push({ start: result.value.start, end: result.value.end, text: result.value.text, label: result.value.labels[0] }) } } return { task_id: annotation.task, annotation_id: annotation.id, entities, annotator: annotation.completed_by?.email } }) } } } // Register default formats registerExportFormat('json', ExportFormats.json()) registerExportFormat('jsonl', ExportFormats.jsonl()) registerExportFormat('csv', ExportFormats.imageClassificationCSV()) registerExportFormat('rank-csv', ExportFormats.rankNClassificationCSV()) registerExportFormat('coco', ExportFormats.coco()) registerExportFormat('yolo', ExportFormats.yolo()) registerExportFormat('ner-json', ExportFormats.nerJSON())