diff --git a/client/dive-common/components/Types/TypeSettingsPanel.spec.ts b/client/dive-common/components/Types/TypeSettingsPanel.spec.ts index 1ff7363f3..4991a2c40 100644 --- a/client/dive-common/components/Types/TypeSettingsPanel.spec.ts +++ b/client/dive-common/components/Types/TypeSettingsPanel.spec.ts @@ -88,3 +88,14 @@ it('closes the settings popup before opening the import dialog', async () => { expect(panel.importDialog).toBe(true); wrapper.destroy(); }); + +it('opens type management from settings and closes the popup', async () => { + const { wrapper, panel } = mountPanel({ allTypes: [], hierarchyActive: false }); + panel.active = true; + const manage = wrapper.findAll('button').wrappers.find((button) => button.text().includes('Manage Types')); + if (!manage) throw new Error('Manage Types button is missing'); + await manage.trigger('click'); + expect(panel.active).toBe(false); + expect(panel.manageDialog).toBe(true); + wrapper.destroy(); +}); diff --git a/client/dive-common/components/Types/TypeSettingsPanel.vue b/client/dive-common/components/Types/TypeSettingsPanel.vue index 637a48df3..c8350b35f 100644 --- a/client/dive-common/components/Types/TypeSettingsPanel.vue +++ b/client/dive-common/components/Types/TypeSettingsPanel.vue @@ -8,11 +8,12 @@ import { } from 'vue'; import { useReadOnlyMode } from 'vue-media-annotator/provides'; import { clientSettings } from 'dive-common/store/settings'; +import TypeManagementDialog from 'vue-media-annotator/components/Types/TypeManagementDialog.vue'; import CategoryImportDialog from './CategoryImportDialog.vue'; export default defineComponent({ name: 'TypeSettingsPanel', - components: { CategoryImportDialog }, + components: { CategoryImportDialog, TypeManagementDialog }, props: { allTypes: { @@ -40,6 +41,7 @@ export default defineComponent({ suppressionThreshold: 'Minimum percent of a detection that must lie under suppression regions for it to be hidden (default 99).', }); const importDialog = ref(false); + const manageDialog = ref(false); const active = ref(false); // Always offer the default suppression type even when no annotations use it yet. const suppressionTypeItems = computed(() => ( @@ -54,6 +56,7 @@ export default defineComponent({ itemHeight, help, importDialog, + manageDialog, readOnlyMode, suppressionTypeItems, }; @@ -97,41 +100,30 @@ export default defineComponent({ - + - + mdi-plus - Types + Add Types - - - - - {{ help.import }} - + + mdi-file-tree + + Manage Types + @@ -436,6 +428,9 @@ export default defineComponent({ + + + (); + Object.entries(index.hierarchy).forEach(([child, parent]) => { + const list = childrenByParent.get(parent) ?? []; + list.push(child); + childrenByParent.set(parent, list); + }); + const result: string[] = []; + const walk = (node: string) => { + (childrenByParent.get(node) ?? []).forEach((child) => { + result.push(child); + walk(child); + }); + }; + walk(type); + return result; +} + function sortPairsByConfidence( pairs: readonly (readonly [string, number])[], ): [string, number][] { diff --git a/client/src/TrackFilterControls.spec.ts b/client/src/TrackFilterControls.spec.ts index 87260cb00..3f2794a4e 100644 --- a/client/src/TrackFilterControls.spec.ts +++ b/client/src/TrackFilterControls.spec.ts @@ -874,12 +874,12 @@ describe('useAnnotationFilters', () => { expect(groupFilters.checkedTypes.value).toEqual([]); }); - it('preserves flat track and group configured-only rename behavior', () => { + it('retains renamed configured track types without changing group rename behavior', () => { const { filters } = makePairFixture([[['used', 0.8]]]); filters.importTypes(['unused'], false); filters.updateTypeName({ currentType: 'unused', newType: 'renamed' }); expect(filters.configuredTypes.value).not.toContain('unused'); - expect(filters.configuredTypes.value).not.toContain('renamed'); + expect(filters.configuredTypes.value).toContain('renamed'); const cameraStore = makeCameraStore(); const groupFilters = makeGroupFilterControls(cameraStore); @@ -1443,3 +1443,150 @@ describe('WoRMS import provenance', () => { expect(filters.taxonomySavePatch()).toEqual({}); }); }); + +describe('type management deletion', () => { + it('transfers hidden tracks to unknown while keeping other labels and promoting children', () => { + const { filters, cameraStore, markPending } = makePairFixture([ + [['parent', 0.8], ['unknown', 0.9], ['other', 0.4]], + [['child', 1]], + ]); + filters.setTypeHierarchy({ parent: 'root', child: 'parent' }); + filters.importTypes(['parent']); + filters.checkedTypes.value = []; + filters.setTimeFilters([100, 200]); + expect(filters.typeTrackIds().get('parent')?.size).toBe(1); + expect(filters.deleteTypeWithTracks('parent', 'unknown')).toBe(true); + expect(cameraStore.getTrack(0).confidencePairs).toEqual([['unknown', 0.9], ['other', 0.4]]); + expect(cameraStore.getTrack(1).confidencePairs).toEqual([['child', 1]]); + expect(filters.typeHierarchy.value).toEqual({ child: 'root' }); + expect(filters.allTypes.value).not.toContain('parent'); + expect(filters.typeHierarchySavePatch()).toEqual({ typeHierarchy: { child: 'root' } }); + expect(markPending).toHaveBeenCalled(); + }); + + it('counts secondary-camera labels and preserves each camera vector on transfer', () => { + const { filters, cameraStore } = makePairFixture([[['other', 1]]]); + cameraStore.addCamera('right'); + const store = cameraStore.camMap.value.get('right')!.trackStore; + store.insert(new Track(0, { confidencePairs: [['fish', 0.7]], features })); + store.setEnableSorting(); + expect(filters.typeTrackIds().get('fish')?.size).toBe(1); + expect(filters.deleteTypeWithTracks('fish', 'unknown')).toBe(true); + expect(cameraStore.getTrack(0).confidencePairs).toEqual([['other', 1]]); + expect(store.get(0).confidencePairs).toEqual([['unknown', 0.7]]); + expect(filters.typeTrackIds().has('fish')).toBe(false); + }); + + it('deletes whole matching tracks across cameras but keeps descendant-only tracks', () => { + const { filters, cameraStore } = makePairFixture([ + [['parent', 0.2], ['other', 1]], [['child', 1]], + ]); + cameraStore.addCamera('right'); + const store = cameraStore.camMap.value.get('right')!.trackStore; + store.insert(new Track(0, { confidencePairs: [['other', 1]], features })); + store.setEnableSorting(); + filters.setTypeHierarchy({ child: 'parent' }); + filters.checkedTypes.value = []; + expect(filters.deleteTypeWithTracks('parent', 'delete')).toBe(true); + expect(cameraStore.getTrackAll(0)).toEqual([]); + expect(cameraStore.getTrackAll(1)).toHaveLength(1); + expect(filters.allTypes.value).toContain('child'); + expect(filters.allTypes.value).not.toContain('parent'); + }); + + it('does not allow transferring unknown to itself', () => { + const { filters, cameraStore } = makePairFixture([[['unknown', 1]]]); + expect(filters.deleteTypeWithTracks('unknown', 'unknown')).toBe(false); + expect(cameraStore.getTrack(0).confidencePairs).toEqual([['unknown', 1]]); + }); + + it('keeps unused flat types when renamed', () => { + const { filters } = makePairFixture([]); + filters.importTypes(['old']); + filters.updateTypeDefinition({ currentType: 'old', newType: 'new', parent: undefined }); + expect(filters.allTypes.value).toEqual(['new']); + }); +}); + +describe('optional empty parent deletion', () => { + it('keeps empty ancestors by default', () => { + const { filters } = makePairFixture([]); + filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' }); + filters.deleteTypeWithTracks('leaf', 'delete'); + expect(filters.allTypes.value).toEqual(expect.arrayContaining(['parent', 'root'])); + }); + + it.each(['unknown', 'delete'] as const)('removes the empty ancestor chain after %s', (mode) => { + const { filters, cameraStore } = makePairFixture([[['leaf', 1]]]); + filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' }); + filters.importTypes(['leaf', 'parent', 'root']); + filters.deleteTypeWithTracks('leaf', mode, true); + expect(filters.typeHierarchySavePatch()).toEqual({ typeHierarchy: null }); + expect(filters.allTypes.value).toEqual(mode === 'unknown' ? ['unknown'] : []); + expect(cameraStore.getTrackAll(0)).toHaveLength(mode === 'unknown' ? 1 : 0); + }); + + it('stops at parents with other children, including unused siblings', () => { + const { filters } = makePairFixture([]); + filters.setTypeHierarchy({ leaf: 'parent', sibling: 'parent', parent: 'root' }); + filters.deleteTypeWithTracks('leaf', 'delete', true); + expect(filters.typeHierarchy.value).toEqual({ sibling: 'parent', parent: 'root' }); + }); + + it('keeps parents with usage on a secondary camera', () => { + const { filters, cameraStore } = makePairFixture([[['other', 1]]]); + cameraStore.addCamera('right'); + const store = cameraStore.camMap.value.get('right')!.trackStore; + store.insert(new Track(0, { confidencePairs: [['parent', 1]], features })); + store.setEnableSorting(); + filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' }); + filters.deleteTypeWithTracks('leaf', 'delete', true); + expect(filters.typeHierarchy.value).toEqual({ parent: 'root' }); + expect(store.get(0).confidencePairs).toEqual([['parent', 1]]); + }); + + it('keeps ancestors receiving promoted children', () => { + const { filters } = makePairFixture([]); + filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' }); + filters.deleteTypeWithTracks('parent', 'delete', true); + expect(filters.typeHierarchy.value).toEqual({ leaf: 'root' }); + }); +}); + +describe('optional empty child deletion', () => { + it('keeps empty descendants by default', () => { + const { filters } = makePairFixture([]); + filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' }); + filters.deleteTypeWithTracks('parent', 'delete'); + expect(filters.allTypes.value).toEqual(expect.arrayContaining(['leaf', 'root'])); + expect(filters.typeHierarchy.value).toEqual({ leaf: 'root' }); + }); + + it.each(['unknown', 'delete'] as const)('removes empty descendants after %s', (mode) => { + const { filters, cameraStore } = makePairFixture([[['parent', 1]]]); + filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' }); + filters.importTypes(['parent', 'leaf', 'root']); + filters.deleteTypeWithTracks('parent', mode, false, true); + expect(filters.typeHierarchySavePatch()).toEqual({ typeHierarchy: null }); + expect(filters.allTypes.value).toEqual(mode === 'unknown' ? ['unknown', 'root'] : ['root']); + expect(cameraStore.getTrackAll(0)).toHaveLength(mode === 'unknown' ? 1 : 0); + }); + + it('stops at descendants with child types still in the hierarchy', () => { + const { filters } = makePairFixture([[['heavy', 1]]]); + filters.setTypeHierarchy({ + empty: 'mid', heavy: 'mid', mid: 'parent', parent: 'root', + }); + filters.deleteTypeWithTracks('parent', 'delete', false, true); + expect(filters.typeHierarchy.value).toEqual({ heavy: 'mid', mid: 'root' }); + expect(filters.allTypes.value).not.toContain('empty'); + }); + + it('keeps descendants with direct track usage', () => { + const { filters } = makePairFixture([[['leaf', 1]]]); + filters.setTypeHierarchy({ leaf: 'parent', parent: 'root' }); + filters.deleteTypeWithTracks('parent', 'delete', false, true); + expect(filters.typeHierarchy.value).toEqual({ leaf: 'root' }); + expect(filters.allTypes.value).toContain('leaf'); + }); +}); diff --git a/client/src/TrackFilterControls.ts b/client/src/TrackFilterControls.ts index 00119937e..fc6f75533 100644 --- a/client/src/TrackFilterControls.ts +++ b/client/src/TrackFilterControls.ts @@ -5,6 +5,8 @@ import { import { cloneDeep, isEqual } from 'lodash'; import { clientSettings } from 'dive-common/store/settings'; import { + ancestorsOf, + descendantsOf, compileHierarchy, normalizeTypeHierarchy, removeHierarchyType, @@ -13,6 +15,7 @@ import { rewriteHierarchyType, selectFlatPairIndex, selectPairIndex, + setPairConfidence, TypeHierarchy, TypeHierarchyError, TypeHierarchyIndex, @@ -360,6 +363,77 @@ export default class TrackFilterControls extends BaseFilterControls { .some((track) => track.confidencePairs.some(([name]) => name === type))); } + /** Unfiltered usage, deduplicated by track ID across all cameras. */ + typeTrackIds(): Map> { + const result = new Map>(); + this.sorted.value.forEach(({ id }) => { + this.getTracks(id).forEach((track) => { + track.confidencePairs.forEach(([type]) => { + const ids = result.get(type) ?? new Set(); + ids.add(id); + result.set(type, ids); + }); + }); + }); + return result; + } + + /** Children are promoted; optional cleanup stops at any remaining usage or child types. */ + deleteTypeWithTracks( + type: string, + disposition: 'unknown' | 'delete', + deleteEmptyParents = false, + deleteEmptyChildren = false, + ): boolean { + const parents = deleteEmptyParents && this.hierarchyIndex.value + ? ancestorsOf(this.hierarchyIndex.value, type) : []; + const descendants = deleteEmptyChildren && this.hierarchyIndex.value + ? [...descendantsOf(this.hierarchyIndex.value, type)].reverse() : []; + const ids = this.typeTrackIds().get(type) ?? new Set(); + if (type === 'unknown' && disposition === 'unknown' && ids.size) return false; + ids.forEach((id) => { + if (disposition === 'delete') { + this.remove(id); + } else { + // Work on each camera's own vector so camera-specific labels survive. + this.getTracks(id).forEach((track) => { + const removed = track.confidencePairs.find(([name]) => name === type); + if (!removed) return; + const unknown = track.confidencePairs.find(([name]) => name === 'unknown'); + const pairs = track.confidencePairs.filter(([name]) => name !== type && name !== 'unknown'); + track.setConfidencePairs(setPairConfidence(pairs, 'unknown', Math.max(removed[1], unknown?.[1] ?? 0))); + }); + } + }); + const deleted = this.deleteType(type); + if (deleted) { + this.checkedTypes.value = this.checkedTypes.value.filter((name) => name !== type); + if (ids.size && disposition === 'unknown' && !this.checkedTypes.value.includes('unknown')) { + this.checkedTypes.value.push('unknown'); + } + } + if (deleted && deleteEmptyChildren) { + const remainingUsage = this.typeTrackIds(); + descendants.forEach((descendant) => { + if (remainingUsage.get(descendant)?.size + || Object.values(this.typeHierarchy.value ?? {}).includes(descendant)) return; + if (!this.deleteType(descendant)) return; + this.checkedTypes.value = this.checkedTypes.value.filter((name) => name !== descendant); + }); + } + if (deleted && deleteEmptyParents) { + const remainingUsage = this.typeTrackIds(); + parents.every((parent) => { + if (remainingUsage.get(parent)?.size + || Object.values(this.typeHierarchy.value ?? {}).includes(parent)) return false; + if (!this.deleteType(parent)) return false; + this.checkedTypes.value = this.checkedTypes.value.filter((name) => name !== parent); + return true; + }); + } + return deleted; + } + private configureStandaloneTypes( types: ReadonlySet, hierarchy: TypeHierarchy | undefined, @@ -381,6 +455,9 @@ export default class TrackFilterControls extends BaseFilterControls { this.renameTrackPair(annotation.id, currentType, newType); } }); + if (this.configuredTypes.value.includes(currentType)) { + this.importTypes([newType], false); + } this.carryConfidenceFilter(currentType, newType); this.deleteType(currentType); return; diff --git a/client/src/components/Types/TypeManagementDialog.spec.ts b/client/src/components/Types/TypeManagementDialog.spec.ts new file mode 100644 index 000000000..195c00f14 --- /dev/null +++ b/client/src/components/Types/TypeManagementDialog.spec.ts @@ -0,0 +1,170 @@ +import { defineComponent, h, nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { dummyState, State } from '../../provides'; +import Track from '../../track'; +import TypeManagementDialog from './TypeManagementDialog.vue'; + +const injected = vi.hoisted(() => ({ state: undefined as State | undefined })); +vi.mock('../../provides', async (importOriginal) => ({ + ...await importOriginal(), + useTrackFilters: () => injected.state!.trackFilters, + useTrackStyleManager: () => injected.state!.trackStyleManager, + useReadOnlyMode: () => injected.state!.readOnlyMode, +})); + +function mountManager() { + const state = dummyState(); + injected.state = state; + state.trackFilters.remove = (id) => state.cameraStore.removeTracks(id); + const store = state.cameraStore.camMap.value.get('singleCam')!.trackStore; + store.insert(new Track(0, { + confidencePairs: [['parent', 0.8], ['child', 0.7]], + features: [{ frame: 0, bounds: [0, 0, 10, 10], keyframe: true }], + })); + store.setEnableSorting(); + state.trackFilters.setTypeHierarchy({ child: 'parent' }); + let manager: InstanceType | undefined; + const Host = defineComponent({ + setup: () => () => h(TypeManagementDialog, { + ref: (instance) => { manager = instance as InstanceType; }, + }), + }); + const wrapper = shallowMount(Host, { + stubs: { + TypeManagementDialog: false, + ...Object.fromEntries([ + 'v-card', 'v-card-title', 'v-card-text', 'v-card-actions', 'v-spacer', + 'v-btn', 'v-icon', 'v-text-field', 'v-simple-table', 'v-dialog', + 'v-radio-group', 'v-radio', 'v-alert', 'v-checkbox', + ].map((tag) => [tag, true])), + }, + }); + if (!manager) throw new Error('Manager did not mount'); + return { state, wrapper, manager }; +} + +it('shows each type annotation color from the style manager', () => { + const { state, wrapper, manager } = mountManager(); + state.trackStyleManager.updateTypeStyle({ type: 'parent', value: { color: '#ff0000' } }); + expect(manager.rows.find(({ type }) => type === 'parent')?.color).toBe('#ff0000'); + wrapper.destroy(); +}); + +it('shows unfiltered deduplicated direct and branch counts and searchable hierarchy', async () => { + const { state, wrapper, manager } = mountManager(); + state.trackFilters.checkedTypes.value = []; + expect(manager.rows.map(({ type, direct, total }) => ({ type, direct, total }))).toEqual([ + { type: 'parent', direct: 1, total: 1 }, + { type: 'child', direct: 1, total: 1 }, + ]); + manager.query = 'child'; + await nextTick(); + expect(manager.rows.map(({ type }) => type)).toEqual(['parent', 'child']); + manager.query = 'missing'; + await nextTick(); + expect(manager.rows).toEqual([]); + wrapper.destroy(); +}); + +it('requires a disposition, allows cancellation, and promotes children on transfer', async () => { + const { state, wrapper, manager } = mountManager(); + manager.requestDelete('parent'); + expect(manager.childMessage).toContain('top-level'); + expect(manager.deleteCount).toBe(1); + manager.confirmDelete(); + expect(state.trackFilters.allTypes.value).toContain('parent'); + manager.deleting = null; + expect(state.cameraStore.getTrack(0).confidencePairs[0][0]).toBe('parent'); + manager.requestDelete('parent'); + manager.disposition = 'unknown'; + manager.confirmDelete(); + await nextTick(); + expect(manager.deleting).toBe(null); + expect(state.trackFilters.allTypes.value).not.toContain('parent'); + expect(state.trackFilters.allTypes.value).toContain('child'); + expect(state.cameraStore.getTrack(0).confidencePairs).toContainEqual(['unknown', 0.8]); + wrapper.destroy(); +}); + +it('deletes tracks only after an explicit choice and resets that choice for each type', () => { + const { state, wrapper, manager } = mountManager(); + manager.requestDelete('parent'); + manager.disposition = 'delete'; + manager.requestDelete('child'); + expect(manager.disposition).toBe(null); + manager.disposition = 'delete'; + manager.confirmDelete(); + expect(state.cameraStore.getTrackAll(0)).toEqual([]); + wrapper.destroy(); +}); + +it('guards mutations in read-only mode even with a confirmation already open', () => { + const { state, wrapper, manager } = mountManager(); + manager.requestDelete('parent'); + manager.disposition = 'delete'; + Object.assign(state.readOnlyMode, { value: true }); + manager.confirmDelete(); + expect(state.cameraStore.getTrackAll(0)).toHaveLength(1); + manager.deleting = null; + manager.requestDelete('child'); + expect(manager.deleting).toBe(null); + wrapper.destroy(); +}); + +it('collapses branches without changing counts and restores their nested expansion state', () => { + const { state, wrapper, manager } = mountManager(); + state.trackFilters.setTypeHierarchy({ child: 'parent', grandchild: 'child' }); + manager.toggleBranch('child'); + expect(manager.rows.map(({ type }) => type)).toEqual(['parent', 'child']); + manager.toggleBranch('parent'); + expect(manager.rows.map(({ type }) => type)).toEqual(['parent']); + expect(manager.rows[0].total).toBe(1); + manager.toggleBranch('parent'); + expect(manager.rows.map(({ type }) => type)).toEqual(['parent', 'child']); + manager.expandAll(); + expect(manager.rows.map(({ type }) => type)).toEqual(['parent', 'child', 'grandchild']); + manager.collapseAll(); + expect(manager.rows.map(({ type }) => type)).toEqual(['parent']); + manager.query = 'grandchild'; + expect(manager.rows.map(({ type }) => type)).toEqual(['parent', 'child', 'grandchild']); + manager.toggleBranch('parent'); + manager.query = ''; + expect(manager.rows.map(({ type }) => type)).toEqual(['parent']); + wrapper.destroy(); +}); + +it('offers ancestor cleanup only for children, applies it, and resets it between confirmations', () => { + const { state, wrapper, manager } = mountManager(); + manager.requestDelete('parent'); + expect(manager.hasParent).toBe(false); + manager.requestDelete('child'); + expect(manager.hasParent).toBe(true); + expect(manager.deleteEmptyParents).toBe(false); + manager.deleteEmptyParents = true; + manager.deleting = null; + manager.requestDelete('child'); + expect(manager.deleteEmptyParents).toBe(false); + manager.deleteEmptyParents = true; + manager.disposition = 'delete'; + manager.confirmDelete(); + expect(state.trackFilters.allTypes.value).toEqual([]); + wrapper.destroy(); +}); + +it('offers descendant cleanup only for parents, applies it, and resets it between confirmations', () => { + const { state, wrapper, manager } = mountManager(); + manager.requestDelete('child'); + expect(manager.hasChildren).toBe(false); + manager.requestDelete('parent'); + expect(manager.hasChildren).toBe(true); + expect(manager.deleteEmptyChildren).toBe(false); + manager.deleteEmptyChildren = true; + manager.deleting = null; + manager.requestDelete('parent'); + expect(manager.deleteEmptyChildren).toBe(false); + manager.deleteEmptyChildren = true; + manager.disposition = 'delete'; + manager.confirmDelete(); + expect(state.trackFilters.allTypes.value).toEqual([]); + wrapper.destroy(); +}); diff --git a/client/src/components/Types/TypeManagementDialog.vue b/client/src/components/Types/TypeManagementDialog.vue new file mode 100644 index 000000000..2faefb15e --- /dev/null +++ b/client/src/components/Types/TypeManagementDialog.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/docs/UI-Type-List.md b/docs/UI-Type-List.md index 976035765..23295358c 100644 --- a/docs/UI-Type-List.md +++ b/docs/UI-Type-List.md @@ -48,6 +48,17 @@ A parent row's checkbox always toggles its complete subtree. The heading checkbo While a hierarchy is active, **Prevent Cascade Types** is disabled and shows: `Not applicable to hierarchical types; DIVE selects the deepest qualifying type.` +## Manage Types + +Open the Type List settings cog and choose **Manage Types** to see every type, including empty types and parents hidden by the list's filters. Use the arrow beside a parent to collapse or expand its branch, or use **Expand All** / **Collapse All**. Search temporarily expands matching branches and restores the collapsed state when cleared. Branch counts remain visible when collapsed. + +* Use the pencil to rename a type, change its parent, or adjust its appearance. Clearing **Parent Type** moves the type to the top level. Children follow their parent when it moves. +* **Direct tracks** counts tracks that explicitly carry that label. **Branch tracks** includes descendants, counting each track only once. These counts include all cameras and ignore visibility, confidence, frame, and attribute filters. +* Use the trash button to delete a type. Its children move to its parent, or to the top level if it has no parent. For types with a parent, optionally check **Delete empty parents too** to remove ancestors left without child types or directly assigned tracks. For types with children, optionally check **Delete empty children too** to remove descendant types left without child types or directly assigned tracks. Cleanup stops at the first type still in use; both options start unchecked. +* If tracks directly carry the deleted type, choose either to transfer that label to `unknown` (keeping other labels and annotations) or to delete those entire tracks from every camera. Tracks carrying only descendant labels are kept. Cancel leaves the type and tracks unchanged. + +Changes apply immediately and are persisted when you save the dataset. Read-only mode allows inspecting counts but disables editing and deletion. + ## Type Style Editor ![Type Editor](images/TypeEditor.png){ align=right loading=lazy width=260 }