Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, TrackData>;
Expand Down Expand Up @@ -304,6 +305,7 @@ type DatasetInfoFields = Record<string, unknown>;
* The parts of dataset config a user should be able to modify.
*/
interface DatasetConfigMutable {
taxonomySources?: TaxonomySources;
typeHierarchy?: Record<string, string> | null;
customTypeStyling?: Record<string, CustomStyle>;
customGroupStyling?: Record<string, CustomStyle>;
Expand Down Expand Up @@ -332,7 +334,7 @@ interface DatasetConfigMutable {
cameraRoles?: Record<string, CameraRole>;
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).
Expand Down
91 changes: 91 additions & 0 deletions client/dive-common/categoryImport.spec.ts
Original file line number Diff line number Diff line change
@@ -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']]);
});
});
183 changes: 183 additions & 0 deletions client/dive-common/categoryImport.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string>();
const aliases = new Map<string, string>();
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<string, string>();
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.']
: [],
};
}
10 changes: 7 additions & 3 deletions client/dive-common/components/BottomPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export default defineComponent({
</div>

<template v-if="bottomRightPanelView === 'filters'">
<div class="flex-grow-1 bottom-filter-list" style="overflow-y: auto; overflow-x: hidden;">
<div class="bottom-filter-list">
<FilterList
:show-empty-types="clientSettings.typeSettings.showEmptyTypes"
:height="130"
Expand All @@ -185,13 +185,12 @@ export default defineComponent({
:style-manager="trackStyleManager"
:filter-controls="trackFilters"
:disabled="disableAnnotationFilters"
class="fill-height"
class="flex-grow-1"
>
<template #settings>
<TypeSettingsPanel
:all-types="trackFilters.allTypes.value"
:hierarchy-active="trackFilters.hierarchyActive.value"
@import-types="trackFilters.importTypes($event)"
/>
</template>
</FilterList>
Expand Down Expand Up @@ -301,6 +300,11 @@ export default defineComponent({
}

.bottom-filter-list {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
overflow: hidden;
#type-header b {
font-size: 14px;
font-weight: 600;
Expand Down
Loading
Loading