diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index 43c146267..aee4f03ab 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -21,6 +21,7 @@ import type { ScoringResultSummary, ScoringSourceOptions, } from 'dive-common/scoring/types'; +import type { TaxonomySources } from './worms'; type DatasetType = 'image-sequence' | 'video' | 'multi' | 'large-image'; type MultiTrackRecord = Record; @@ -304,6 +305,7 @@ type DatasetInfoFields = Record; * The parts of dataset config a user should be able to modify. */ interface DatasetConfigMutable { + taxonomySources?: TaxonomySources; typeHierarchy?: Record | null; customTypeStyling?: Record; customGroupStyling?: Record; @@ -332,7 +334,7 @@ interface DatasetConfigMutable { cameraRoles?: Record; error?: string; } -const DatasetConfigMutableKeys = ['attributes', 'confidenceFilters', 'timeFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters', 'datasetInfo', 'cameraHomographies', 'cameraCorrespondences', 'cameraTransformTypes', 'cameraRegistrationSource', 'typeHierarchy', 'cameraRoles']; +const DatasetConfigMutableKeys = ['attributes', 'confidenceFilters', 'timeFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters', 'datasetInfo', 'cameraHomographies', 'cameraCorrespondences', 'cameraTransformTypes', 'cameraRegistrationSource', 'typeHierarchy', 'taxonomySources', 'cameraRoles']; /** * Cross-dataset color/style overrides, reused across every dataset when the * "shared" color scope is enabled (see clientSettings.typeSettings.colorScope). diff --git a/client/dive-common/categoryImport.spec.ts b/client/dive-common/categoryImport.spec.ts new file mode 100644 index 000000000..0c60499ce --- /dev/null +++ b/client/dive-common/categoryImport.spec.ts @@ -0,0 +1,91 @@ +import { parseCategoryFile } from './categoryImport'; + +const parseJson = (value: unknown) => parseCategoryFile(JSON.stringify(value), 'categories.json'); + +describe('category file import', () => { + it('imports COCO categories without importing annotations and preserves hierarchy-only parents', () => { + expect(parseJson({ + categories: [{ + id: 2, name: 'salmon', supercategory: 'fish', parents: ['ignored'], + }], + annotations: [{ category_id: 2 }], + })).toEqual({ types: ['salmon'], typeHierarchy: { salmon: 'fish' }, warnings: [] }); + }); + + it('accepts COCO top-level self supercategories and null supercategories', () => { + expect(parseJson({ + categories: [ + { name: 'fish', supercategory: 'fish' }, + { name: 'animal', supercategory: null }, + { name: 'shark', supercategory: 'fish' }, + ], + })).toEqual({ types: ['fish', 'animal', 'shark'], typeHierarchy: { shark: 'fish' }, warnings: [] }); + }); + + it('supports JSON strings, category objects, parents, and DIVE hierarchy maps', () => { + expect(parseJson(['fish', 'shark', 'fish']).types).toEqual(['fish', 'shark']); + expect(parseJson({ categories: [{ name: 'shark', parents: ['fish'] }], typeHierarchy: { fish: 'animal' } })) + .toEqual({ types: ['shark'], typeHierarchy: { shark: 'fish', fish: 'animal' }, warnings: [] }); + expect(parseJson({ typeHierarchy: { fish: 'animal' } }).typeHierarchy).toEqual({ fish: 'animal' }); + }); + + it('reads VIAME TXT comments, quoted names, synonyms, BOM, CRLF, and parent references', () => { + const result = parseCategoryFile('\uFEFF# labels\r\n"sea life" marine\r\n"red fish" rf :parent=marine # comment\r\nshark :parent="sea life"', 'LABELS.TXT'); + expect(result.types).toEqual(['sea life', 'red fish', 'shark']); + expect(result.typeHierarchy).toEqual({ 'red fish': 'sea life', shark: 'sea life' }); + expect(result.warnings).toHaveLength(1); + }); + + it('reads VIAME CSV quoted commas, escaped quotes, newlines, and multiword names', () => { + const result = parseCategoryFile('sea life,marine\r\n"fish, red",rf,:parent=marine\r\n"a ""quoted"" fish",,:parent=sea life\n"multi\nline"', 'labels.csv'); + expect(result.types).toEqual(['sea life', 'fish, red', 'a "quoted" fish', 'multi\nline']); + expect(result.typeHierarchy).toEqual({ 'fish, red': 'sea life', 'a "quoted" fish': 'sea life' }); + }); + + it('supports escaped TXT quotes and single quotes', () => { + expect(parseCategoryFile("'fish species'\n\"a \\\"fish\\\"\"", 'labels.txt').types) + .toEqual(['fish species', 'a "fish"']); + }); + + it('resolves JSON synonyms without creating extra types', () => { + const result = parseJson([{ name: 'fish', synonyms: ['f'] }, { name: 'shark', parents: ['f'] }]); + expect(result.types).toEqual(['fish', 'shark']); + expect(result.typeHierarchy).toEqual({ shark: 'fish' }); + expect(result.warnings).toHaveLength(1); + }); + + it.each([ + {}, { categories: {} }, { categories: null, typeHierarchy: { fish: 'animal' } }, { categories: [1] }, { categories: [{ name: ' ' }] }, + { categories: [{ name: 'fish', id: '1' }] }, + { categories: [{ name: 'fish', parents: 'animal' }] }, + { categories: [{ name: 'fish', synonyms: 'f' }] }, + { categories: [{ name: 'fish', supercategory: 4 }] }, + { typeHierarchy: [] }, { typeHierarchy: { fish: 2 } }, + ])('rejects malformed JSON definitions: %j', (value) => { + expect(() => parseJson(value)).toThrow(); + }); + + it.each([ + { categories: [{ name: 'fish', parents: ['a', 'b'] }] }, + { categories: [{ name: 'fish', supercategory: 'a' }], typeHierarchy: { fish: 'b' } }, + { typeHierarchy: { a: 'b', b: 'a' } }, + { typeHierarchy: { a: 'a' } }, + [{ name: 'fish', synonyms: ['shark'] }, 'shark'], + [{ name: 'fish', synonyms: ['f'] }, { name: 'shark', synonyms: ['f'] }], + ])('rejects ambiguous or invalid relationships: %j', (value) => { + expect(() => parseJson(value)).toThrow(); + }); + + it.each([ + ['"unterminated', 'txt'], ['"unterminated', 'csv'], + ['"fish"x', 'csv'], [',fish', 'csv'], ['fish :parent=', 'txt'], + ['# only comments', 'txt'], ['', 'csv'], ['[]', 'json'], ['{', 'json'], ['fish', 'xml'], + ])('rejects invalid or empty files (%s, %s)', (text, extension) => { + expect(() => parseCategoryFile(text, `labels.${extension}`)).toThrow(); + }); + + it('handles prototype property names safely', () => { + const result = parseJson(JSON.parse('{"typeHierarchy":{"__proto__":"constructor"}}')); + expect(Object.entries(result.typeHierarchy!)).toEqual([['__proto__', 'constructor']]); + }); +}); diff --git a/client/dive-common/categoryImport.ts b/client/dive-common/categoryImport.ts new file mode 100644 index 000000000..274a2380c --- /dev/null +++ b/client/dive-common/categoryImport.ts @@ -0,0 +1,183 @@ +import type { SynonymRemap, TaxonomySources } from './worms'; +import { normalizeTypeHierarchy, TypeHierarchy } from './typeHierarchy'; + +export interface CategoryImport { + types: string[]; + taxonomySources?: TaxonomySources; + typeHierarchy?: TypeHierarchy; + /** Synonym → accepted remaps from WoRMS (shown grouped in the import preview). */ + synonymRemaps?: SynonymRemap[]; + warnings: string[]; +} + +function label(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error('Category names and parents must be nonempty strings.'); + } + return value; +} + +/** Match VIAME's label rows: TXT uses whitespace, CSV uses commas, extras are aliases. */ +function labelRows(text: string, csv: boolean): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let quote = ''; + let started = false; + let closed = false; + let quoted = false; + const finishField = () => { + if (csv && !quoted) field = field.replace(/[ \t]+$/, ''); + if (started) row.push(label(field)); + field = ''; + started = false; + closed = false; + quoted = false; + }; + const finishRow = () => { + finishField(); + if (row.length) rows.push(row); + row = []; + }; + for (let i = 0; i < text.length; i += 1) { + const c = text[i]; + if (quote) { + if (c === quote) { + if (text[i + 1] === quote) { + field += c; + i += 1; + } else { + quote = ''; + closed = true; + } + } else if (!csv && c === '\\' && [quote, '\\'].includes(text[i + 1])) { + i += 1; + field += text[i]; + } else { + if (!csv && /[\r\n]/.test(c)) throw new Error('Unterminated quoted label.'); + field += c; + } + } else if (/[\r\n]/.test(c)) { + if (c === '\r' && text[i + 1] === '\n') i += 1; + finishRow(); + } else if (!csv && c === '#') { + while (i + 1 < text.length && !/[\r\n]/.test(text[i + 1])) i += 1; + finishRow(); + } else if (csv && c === ',') { + if (!started && !row.length) throw new Error('Empty category name.'); + finishField(); + } else if (/\s/.test(c)) { + if (!csv) finishField(); + else if (started && !closed) field += c; + } else if ((c === '"' || (!csv && c === "'")) + && (!started || (!csv && field === ':parent='))) { + quote = c; + started = true; + quoted = true; + } else { + if (closed) throw new Error('Expected a separator after quoted label.'); + field += c; + started = true; + } + } + if (quote) throw new Error('Unterminated quoted label.'); + finishRow(); + return rows; +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Parse category definitions only; COCO images and annotations are deliberately ignored. */ +export function parseCategoryFile(contents: string, filename: string): CategoryImport { + const text = contents.replace(/^\uFEFF/, ''); + const extension = filename.split('.').pop()?.toLowerCase(); + const names = new Set(); + const aliases = new Map(); + const edges: [string, string][] = []; + const addAlias = (name: string, value: unknown) => { + const alias = label(value); + if (aliases.has(alias) && aliases.get(alias) !== name) { + throw new Error(`Synonym "${alias}" refers to multiple categories.`); + } + aliases.set(alias, name); + }; + const addName = (value: unknown) => { + const name = label(value); + names.add(name); + return name; + }; + if (extension === 'json') { + const doc: unknown = JSON.parse(text); + let categories: unknown = doc; + if (isObject(doc)) { + categories = Object.prototype.hasOwnProperty.call(doc, 'typeHierarchy') ? [] : undefined; + if (Object.prototype.hasOwnProperty.call(doc, 'categories')) categories = doc.categories; + } + if (!Array.isArray(categories)) { + throw new Error('JSON must be a category array or contain categories or typeHierarchy.'); + } + categories.forEach((category: unknown) => { + const name = addName(isObject(category) ? category.name : category); + if (!isObject(category)) return; + if (category.id !== undefined && !Number.isInteger(category.id)) { + throw new Error(`Category id for "${name}" must be an integer.`); + } + if (category.synonyms !== undefined) { + if (!Array.isArray(category.synonyms)) throw new Error('synonyms must be an array.'); + category.synonyms.forEach((alias) => addAlias(name, alias)); + } + if (category.supercategory !== undefined && category.supercategory !== null + && typeof category.supercategory !== 'string') { + throw new Error('supercategory must be a string.'); + } + if (category.supercategory) { + // COCO often repeats the category name to designate a top-level category. + if (category.supercategory !== name) edges.push([name, label(category.supercategory)]); + } else if (category.parents !== undefined) { + if (!Array.isArray(category.parents)) throw new Error('parents must be an array.'); + category.parents.forEach((parent) => edges.push([name, label(parent)])); + } + }); + if (isObject(doc) && doc.typeHierarchy !== undefined && doc.typeHierarchy !== null) { + if (!isObject(doc.typeHierarchy)) throw new Error('typeHierarchy must be a child-to-parent object.'); + Object.entries(doc.typeHierarchy).forEach(([child, parent]) => { + edges.push([label(child), label(parent)]); + }); + } + } else if (extension === 'txt' || extension === 'csv') { + labelRows(text, extension === 'csv').forEach(([value, ...extras]) => { + const name = addName(value); + extras.forEach((extra) => { + if (extra.startsWith(':parent=')) edges.push([name, label(extra.slice(8))]); + else addAlias(name, extra); + }); + }); + } else { + throw new Error('Choose a .txt, .csv, or .json category file.'); + } + aliases.forEach((name, alias) => { + if (names.has(alias) && alias !== name) { + throw new Error(`Synonym "${alias}" is also a category name.`); + } + }); + const hierarchy = new Map(); + edges.forEach(([rawChild, rawParent]) => { + const child = aliases.get(rawChild) ?? rawChild; + const parent = aliases.get(rawParent) ?? rawParent; + if (hierarchy.has(child) && hierarchy.get(child) !== parent) { + throw new Error(`Category "${child}" has multiple parents. DIVE supports one parent per type.`); + } + hierarchy.set(child, parent); + }); + const typeHierarchy = normalizeTypeHierarchy(Object.fromEntries(hierarchy)); + if (!names.size && !hierarchy.size) throw new Error('The file contains no categories.'); + return { + types: [...names], + typeHierarchy, + warnings: aliases.size + ? ['Synonyms resolve parent references during import. DIVE stores canonical category names, not synonym aliases.'] + : [], + }; +} diff --git a/client/dive-common/components/BottomPanel.vue b/client/dive-common/components/BottomPanel.vue index 0b158cfec..9d6740791 100644 --- a/client/dive-common/components/BottomPanel.vue +++ b/client/dive-common/components/BottomPanel.vue @@ -176,7 +176,7 @@ export default defineComponent({