diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index f3757fe09..111160a2a 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -226,6 +226,16 @@ interface DatasetMetaMutable { error?: string; } const DatasetMetaMutableKeys = ['attributes', 'confidenceFilters', 'timeFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters', 'datasetInfo', 'cameraHomographies', 'cameraCorrespondences', 'cameraTransformTypes', 'cameraRegistrationSource']; +/** + * Cross-dataset color/style overrides, reused across every dataset when the + * "shared" color scope is enabled (see clientSettings.typeSettings.colorScope). + * On desktop this is one store shared across all sequences; on web it is + * scoped to the current user/browser. + */ +interface GlobalStyleSettings { + customTypeStyling?: Record; + customGroupStyling?: Record; +} /** * Mutable keys the multicam/stereo viewer loads from the parent dataset. * Camera-targeted imports sync only these onto the parent — not per-camera @@ -397,6 +407,14 @@ interface Api { downloadCalibration?(datasetId: string): Promise; /** Remove the calibration file currently associated with the dataset. */ deleteCalibration?(datasetId: string): Promise; + /** + * Load the cross-dataset "shared" color/style overrides. Desktop reads one + * store shared across all sequences; web reads the current user/browser's + * store. Absent on platforms that don't support shared colors. + */ + loadGlobalStyleSettings?(): Promise; + /** Persist the cross-dataset "shared" color/style overrides. */ + saveGlobalStyleSettings?(settings: GlobalStyleSettings): Promise; } const ApiSymbol = Symbol('api'); @@ -592,6 +610,7 @@ export { DatasetMetaMutable, DatasetMetaMutableKeys, MulticamSharedMutableKeys, + GlobalStyleSettings, DatasetType, DiveParam, CameraCalibration, diff --git a/client/dive-common/components/UserSettingsDialog.vue b/client/dive-common/components/UserSettingsDialog.vue index d5c69d7b1..afb96f0e5 100644 --- a/client/dive-common/components/UserSettingsDialog.vue +++ b/client/dive-common/components/UserSettingsDialog.vue @@ -12,8 +12,13 @@ export default defineComponent({ }, }, setup() { + const colorScopeItems = [ + { text: 'Shared across all data', value: 'shared' }, + { text: 'Per dataset', value: 'dataset' }, + ]; return { clientSettings, + colorScopeItems, isDesktopRuntime: isDesktopRuntime(), }; }, @@ -29,11 +34,29 @@ export default defineComponent({ User Settings + > = ref({}); const { loadDetections, loadMetadata, saveMetadata, getTiles, getTileURL, getTileHistogram, + loadGlobalStyleSettings, saveGlobalStyleSettings, } = useApi(); const progress = reactive({ // Loaded flag prevents annotator window from populating @@ -426,6 +428,38 @@ export default defineComponent({ const trackStyleManager = new StyleManager({ markChangesPending, vuetify }); const groupStyleManager = new StyleManager({ markChangesPending, vuetify }); + /** + * Shared (cross-dataset) color/style overrides. When the "shared" color + * scope is enabled, these are loaded for every dataset and overlaid on top + * of the dataset's own styling, and any style the user edits is mirrored + * back so the same colors follow them to every sequence. + */ + const globalTypeStyles: Ref> = ref({}); + const globalGroupStyles: Ref> = ref({}); + const sharedColorsEnabled = () => ( + clientSettings.typeSettings.colorScope !== 'dataset' && !!saveGlobalStyleSettings + ); + function persistGlobalStyles() { + if (!sharedColorsEnabled() || !saveGlobalStyleSettings) { + return; + } + // Merge current explicit overrides into the shared store so choices made + // here extend rather than replace styles set on other sequences. + globalTypeStyles.value = { + ...globalTypeStyles.value, ...trackStyleManager.customStyles.value, + }; + globalGroupStyles.value = { + ...globalGroupStyles.value, ...groupStyleManager.customStyles.value, + }; + saveGlobalStyleSettings({ + customTypeStyling: globalTypeStyles.value, + customGroupStyling: globalGroupStyles.value, + }); + } + const scheduleGlobalStylePersist = debounce(persistGlobalStyles, 500); + trackStyleManager.onStyleEdit = scheduleGlobalStylePersist; + groupStyleManager.onStyleEdit = scheduleGlobalStylePersist; + const cameraStore = new CameraStore({ markChangesPending }); const isMultiCameraDataset = computed(() => multiCamList.value.length > 1); @@ -1404,14 +1438,65 @@ export default defineComponent({ resetMulticamAlignment(); } /* Otherwise, complete loading of the dataset */ - trackStyleManager.populateTypeStyles(meta.customTypeStyling); - groupStyleManager.populateTypeStyles(meta.customGroupStyling); + /** + * When shared colors are enabled, overlay the cross-dataset styles on + * top of this dataset's own styling (shared wins on conflicts), and + * seed the shared store with any dataset styles it doesn't yet know so + * imported colors propagate to future sequences. + */ + let loadedGlobalStyles = false; + if (sharedColorsEnabled() && loadGlobalStyleSettings) { + try { + const shared = await loadGlobalStyleSettings(); + globalTypeStyles.value = shared.customTypeStyling ?? {}; + globalGroupStyles.value = shared.customGroupStyling ?? {}; + loadedGlobalStyles = true; + } catch (err) { + // Non-fatal: fall back to dataset-only styling. + globalTypeStyles.value = {}; + globalGroupStyles.value = {}; + } + } + trackStyleManager.populateTypeStyles( + loadedGlobalStyles + ? { ...(meta.customTypeStyling ?? {}), ...globalTypeStyles.value } + : meta.customTypeStyling, + ); + groupStyleManager.populateTypeStyles( + loadedGlobalStyles + ? { ...(meta.customGroupStyling ?? {}), ...globalGroupStyles.value } + : meta.customGroupStyling, + ); if (meta.customTypeStyling) { trackFilters.importTypes(Object.keys(meta.customTypeStyling), false); } if (meta.customGroupStyling) { groupFilters.importTypes(Object.keys(meta.customGroupStyling), false); } + if (loadedGlobalStyles) { + trackFilters.importTypes(Object.keys(globalTypeStyles.value), false); + groupFilters.importTypes(Object.keys(globalGroupStyles.value), false); + // Seed the shared store with dataset styles it doesn't already have, + // without overwriting the user's existing shared choices. + const seededType = Object.keys(meta.customTypeStyling ?? {}) + .some((t) => !(t in globalTypeStyles.value)); + const seededGroup = Object.keys(meta.customGroupStyling ?? {}) + .some((t) => !(t in globalGroupStyles.value)); + if (seededType || seededGroup) { + globalTypeStyles.value = { + ...(meta.customTypeStyling ?? {}), ...globalTypeStyles.value, + }; + globalGroupStyles.value = { + ...(meta.customGroupStyling ?? {}), ...globalGroupStyles.value, + }; + if (saveGlobalStyleSettings) { + saveGlobalStyleSettings({ + customTypeStyling: globalTypeStyles.value, + customGroupStyling: globalGroupStyles.value, + }); + } + } + } if (meta.attributes) { loadAttributes(meta.attributes, { enableStereoLengthRender: meta.subType === 'stereo' }); } diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index ee2282bd8..be2035b53 100644 --- a/client/dive-common/store/settings.ts +++ b/client/dive-common/store/settings.ts @@ -31,6 +31,11 @@ interface AnnotationSettings { // Minimum covered percent (0-100] for region suppression; // out-of-range values fall back to the default (99). suppressionThreshold?: number; + // Where per-type/track/group color and style overrides are stored. + // 'shared': one set of colors is reused across every dataset (per user on + // web, across all sequences on desktop). 'dataset': colors are saved only + // with the dataset they were set on (the original behavior). + colorScope?: 'shared' | 'dataset'; }; trackSettings: { newTrackSettings: { @@ -138,6 +143,7 @@ const defaultSettings: AnnotationSettings = { maxCountButton: false, suppressionType: 'Suppressed', suppressionThreshold: 99, + colorScope: 'shared', }, rowsPerPage: 20, annotationFPS: 10, diff --git a/client/platform/desktop/backend/ipcService.ts b/client/platform/desktop/backend/ipcService.ts index b546ac1cc..564c559ee 100644 --- a/client/platform/desktop/backend/ipcService.ts +++ b/client/platform/desktop/backend/ipcService.ts @@ -6,7 +6,7 @@ import { app, ipcMain, dialog, BrowserWindow, } from 'electron'; import { MultiCamImportArgs } from 'dive-common/apispec'; -import type { Pipe } from 'dive-common/apispec'; +import type { Pipe, GlobalStyleSettings } from 'dive-common/apispec'; import { DesktopJobUpdate, RunPipeline, RunTraining, Settings, ExportDatasetArgs, ExportMulticamEverythingArgs, @@ -249,6 +249,14 @@ export default function register() { ipcMain.handle('get-last-calibration', async () => common.getLastCalibrationPath(settings.get())); + ipcMain.handle('load-global-style-settings', async () => ( + common.loadGlobalStyleSettings(settings.get()) + )); + + ipcMain.handle('save-global-style-settings', async (_, styleSettings: GlobalStyleSettings) => { + await common.saveGlobalStyleSettings(settings.get(), styleSettings); + }); + ipcMain.handle('save-calibration', async (_, { path: sourcePath }: { path: string }) => { const savedPath = await common.saveLastCalibration(settings.get(), sourcePath); const updatedIds = await common.applyCalibrationToUncalibratedStereoDatasets( diff --git a/client/platform/desktop/backend/native/common.ts b/client/platform/desktop/backend/native/common.ts index 7695bc5eb..2c0808d7b 100644 --- a/client/platform/desktop/backend/native/common.ts +++ b/client/platform/desktop/backend/native/common.ts @@ -1928,3 +1928,8 @@ export { } from './datasetCalibration'; export { exportMulticamEverything } from './multicamExport'; + +export { + loadGlobalStyleSettings, + saveGlobalStyleSettings, +} from './globalStyles'; diff --git a/client/platform/desktop/backend/native/globalStyles.spec.ts b/client/platform/desktop/backend/native/globalStyles.spec.ts new file mode 100644 index 000000000..78af9caff --- /dev/null +++ b/client/platform/desktop/backend/native/globalStyles.spec.ts @@ -0,0 +1,73 @@ +import mockfs from 'mock-fs'; +import npath from 'path'; +import fs from 'fs-extra'; +import { + afterEach, describe, expect, it, +} from 'vitest'; + +import { Settings, GlobalStyleSettingsFileName } from 'platform/desktop/constants'; +import { loadGlobalStyleSettings, saveGlobalStyleSettings } from './globalStyles'; + +const settings: Settings = { + version: 1, + dataPath: '/home/user/viamedata', + viamePath: '/opt/viame', + readonlyMode: false, + overrides: {}, +}; + +const stylePath = npath.join(settings.dataPath, GlobalStyleSettingsFileName); + +afterEach(() => { + mockfs.restore(); +}); + +describe('native.globalStyles', () => { + it('returns empty overrides when no file exists', async () => { + mockfs({ [settings.dataPath]: {} }); + const result = await loadGlobalStyleSettings(settings); + expect(result).toEqual({}); + }); + + it('returns empty overrides when the data directory is absent', async () => { + mockfs({}); + const result = await loadGlobalStyleSettings(settings); + expect(result).toEqual({}); + }); + + it('round-trips saved type and group styling', async () => { + mockfs({ [settings.dataPath]: {} }); + const styleSettings = { + customTypeStyling: { seal: { color: '#ff0000', opacity: 0.5 } }, + customGroupStyling: { pod: { color: '#00ff00' } }, + }; + await saveGlobalStyleSettings(settings, styleSettings); + const result = await loadGlobalStyleSettings(settings); + expect(result).toEqual(styleSettings); + }); + + it('creates the data directory if it does not yet exist', async () => { + mockfs({}); + await saveGlobalStyleSettings(settings, { + customTypeStyling: { seal: { color: '#ff0000' } }, + }); + expect(await fs.pathExists(stylePath)).toBe(true); + const result = await loadGlobalStyleSettings(settings); + expect(result.customTypeStyling).toEqual({ seal: { color: '#ff0000' } }); + // Missing group styling normalizes to an empty object rather than undefined. + expect(result.customGroupStyling).toEqual({}); + }); + + it('normalizes missing keys to empty objects on save', async () => { + mockfs({ [settings.dataPath]: {} }); + await saveGlobalStyleSettings(settings, {}); + const written = await fs.readJSON(stylePath); + expect(written).toEqual({ customTypeStyling: {}, customGroupStyling: {} }); + }); + + it('degrades to empty overrides when the stored file is corrupt', async () => { + mockfs({ [stylePath]: 'not valid json {' }); + const result = await loadGlobalStyleSettings(settings); + expect(result).toEqual({}); + }); +}); diff --git a/client/platform/desktop/backend/native/globalStyles.ts b/client/platform/desktop/backend/native/globalStyles.ts new file mode 100644 index 000000000..eab1b3ad0 --- /dev/null +++ b/client/platform/desktop/backend/native/globalStyles.ts @@ -0,0 +1,54 @@ +/** + * Cross-dataset "shared" color/style overrides for the desktop backend. + * + * Desktop has no user accounts, so the shared color scope stores one set of + * type/group style overrides per data directory (settings.dataPath) and reuses + * it across every sequence. The file is a small JSON blob written next to the + * DIVE_Projects folder. + */ + +import npath from 'path'; +import fs from 'fs-extra'; + +import { GlobalStyleSettings } from 'dive-common/apispec'; +import { Settings, GlobalStyleSettingsFileName } from 'platform/desktop/constants'; + +function globalStylePath(settings: Settings): string { + return npath.join(settings.dataPath, GlobalStyleSettingsFileName); +} + +/** + * Read the shared style overrides. Returns an empty object when the file is + * absent or unreadable, so a fresh install (or a corrupt file) degrades to "no + * shared overrides" rather than throwing. + */ +export async function loadGlobalStyleSettings(settings: Settings): Promise { + const filePath = globalStylePath(settings); + if (!(await fs.pathExists(filePath))) { + return {}; + } + try { + const data = await fs.readJSON(filePath); + return { + customTypeStyling: data?.customTypeStyling ?? {}, + customGroupStyling: data?.customGroupStyling ?? {}, + }; + } catch { + return {}; + } +} + +/** + * Persist the shared style overrides, creating the data directory if needed. + */ +export async function saveGlobalStyleSettings( + settings: Settings, + styleSettings: GlobalStyleSettings, +): Promise { + await fs.ensureDir(settings.dataPath); + const payload: GlobalStyleSettings = { + customTypeStyling: styleSettings.customTypeStyling ?? {}, + customGroupStyling: styleSettings.customGroupStyling ?? {}, + }; + await fs.writeFile(globalStylePath(settings), JSON.stringify(payload, null, 2)); +} diff --git a/client/platform/desktop/constants.ts b/client/platform/desktop/constants.ts index 09d3f18ef..c83ed8881 100644 --- a/client/platform/desktop/constants.ts +++ b/client/platform/desktop/constants.ts @@ -16,6 +16,9 @@ export const PipelinesFolderName = 'DIVE_Pipelines'; // Basename (without extension) of the saved "most recently used" calibration. // The stored file keeps the source file's real extension (e.g. last_calibration.npz). export const LastCalibrationBaseName = 'last_calibration'; +// Cross-dataset "shared" color/style overrides, stored once per data directory +// and reused across every sequence when the shared color scope is enabled. +export const GlobalStyleSettingsFileName = 'global_style_settings.json'; export interface Settings { // version a schema version diff --git a/client/platform/desktop/frontend/api.ts b/client/platform/desktop/frontend/api.ts index 8b7104843..64bef530a 100644 --- a/client/platform/desktop/frontend/api.ts +++ b/client/platform/desktop/frontend/api.ts @@ -4,7 +4,7 @@ import type { DatasetMetaMutable, DatasetType, MultiCamImportArgs, Pipe, Pipelines, PipelineParams, SaveAttributeArgs, SaveAttributeTrackFilterArgs, SaveDetectionsArgs, TrainingConfigs, - DatasetCalibrationResult, + DatasetCalibrationResult, GlobalStyleSettings, SegmentationPredictRequest, SegmentationPredictResponse, SegmentationStatusResponse, SegmentationStereoSegmentRequest, SegmentationStereoSegmentResponse, TextQueryRequest, TextQueryResponse, RefineDetectionsRequest, RefineDetectionsResponse, @@ -655,6 +655,14 @@ function getLastCalibration(): Promise { return window.diveDesktop.invoke('get-last-calibration'); } +function loadGlobalStyleSettings(): Promise { + return window.diveDesktop.invoke('load-global-style-settings'); +} + +function saveGlobalStyleSettings(settings: GlobalStyleSettings): Promise { + return window.diveDesktop.invoke('save-global-style-settings', settings); +} + function saveCalibration(path: string): Promise<{ savedPath: string; updatedDatasetIds: string[] }> { return window.diveDesktop.invoke('save-calibration', { path }); } @@ -747,6 +755,8 @@ export { cancelJob, getLastCalibration, saveCalibration, + loadGlobalStyleSettings, + saveGlobalStyleSettings, importCalibrationFile, exportCalibrationFile, exportCameraRegistration, diff --git a/client/platform/web-girder/App.vue b/client/platform/web-girder/App.vue index b4f16e2bf..5ef893154 100644 --- a/client/platform/web-girder/App.vue +++ b/client/platform/web-girder/App.vue @@ -20,6 +20,8 @@ import { getTrainingConfigurations, runTraining, saveMetadata, + loadGlobalStyleSettings, + saveGlobalStyleSettings, saveAttributes, saveAttributeTrackFilters, importAnnotationFile, @@ -72,6 +74,8 @@ export default defineComponent({ loadDetections, saveDetections: unwrap(saveDetections), saveMetadata: unwrap(saveMetadata), + loadGlobalStyleSettings, + saveGlobalStyleSettings, saveAttributes: unwrap(saveAttributes), saveAttributeTrackFilters: unwrap(saveAttributeTrackFilters), loadMetadata, diff --git a/client/platform/web-girder/api/dataset.service.ts b/client/platform/web-girder/api/dataset.service.ts index 36cee283d..9acb53fc9 100644 --- a/client/platform/web-girder/api/dataset.service.ts +++ b/client/platform/web-girder/api/dataset.service.ts @@ -5,7 +5,8 @@ import { registrationValuesSummary, filterRegistrationValues, mergeRegistrationValues, } from 'vue-media-annotator/alignedView/cameraRegistrationFiles'; import { - DatasetMetaMutable, FrameImage, SaveAttributeArgs, SaveAttributeTrackFilterArgs, + DatasetMetaMutable, FrameImage, GlobalStyleSettings, + SaveAttributeArgs, SaveAttributeTrackFilterArgs, } from 'dive-common/apispec'; import { calibrationFileMarker, jsonCalibrationFileMarker, metadataFileMarker } from 'dive-common/constants'; import { attachFrameTimestamps } from 'dive-common/frameTimestamp'; @@ -191,6 +192,32 @@ async function saveMetadata(datasetId: string, metadata: DatasetMetaMutable) { return girderRest.patch(`/dive_dataset/${folderId}`, metadata); } +// Cross-dataset "shared" color/style overrides. Persisted in localStorage, +// consistent with how the web client already stores user preferences +// (clientSettings). This scopes shared colors to the current user/browser. +const GlobalStyleSettingsKey = 'DIVE.globalStyleSettings'; + +function loadGlobalStyleSettings(): Promise { + try { + const raw = window.localStorage.getItem(GlobalStyleSettingsKey); + const data = raw ? JSON.parse(raw) : {}; + return Promise.resolve({ + customTypeStyling: data.customTypeStyling ?? {}, + customGroupStyling: data.customGroupStyling ?? {}, + }); + } catch { + return Promise.resolve({}); + } +} + +function saveGlobalStyleSettings(settings: GlobalStyleSettings): Promise { + window.localStorage.setItem(GlobalStyleSettingsKey, JSON.stringify({ + customTypeStyling: settings.customTypeStyling ?? {}, + customGroupStyling: settings.customGroupStyling ?? {}, + })); + return Promise.resolve(); +} + /** * Merge a DIVE registration .json into an existing multicam dataset's saved * camera registration. Parsing, validation, and @@ -442,6 +469,8 @@ export { saveAttributes, saveAttributeTrackFilters, saveMetadata, + loadGlobalStyleSettings, + saveGlobalStyleSettings, uploadCalibrationItem, uploadMetadataFileItem, setDatasetMetadataFile, diff --git a/client/src/StyleManager.ts b/client/src/StyleManager.ts index a4cb35ed5..e0e5c6794 100644 --- a/client/src/StyleManager.ts +++ b/client/src/StyleManager.ts @@ -43,6 +43,12 @@ export interface TypeStyling { interface UseStylingParams { markChangesPending: () => void; vuetify?: Vuetify; + /** + * Invoked after a user (or programmatic) style edit through updateTypeStyle. + * Used to mirror the change into a cross-dataset "shared color" store. Not + * called on populateTypeStyles (dataset load), only on genuine edits. + */ + onStyleEdit?: () => void; } /** @@ -114,7 +120,9 @@ export default class StyleManager { markChangesPending: () => void; - constructor({ markChangesPending, vuetify }: UseStylingParams) { + onStyleEdit?: () => void; + + constructor({ markChangesPending, vuetify, onStyleEdit }: UseStylingParams) { this.revisionCounter = ref(1); this.customStyles = ref({} as Record); this.annotationSetStyles = ref({} as Record); @@ -144,6 +152,7 @@ export default class StyleManager { this.stateStyles = { standard, selected, disabled }; this.typeColors = d3.scaleOrdinal().range(generateColors(10)); this.markChangesPending = markChangesPending; + this.onStyleEdit = onStyleEdit; this.typeStyling = computed(() => { // establish dependency on revision counter if (this.revisionCounter.value) noop(); @@ -247,6 +256,9 @@ export default class StyleManager { VueSet(this.customStyles.value, type, merge(oldValue, value)); this.revisionCounter.value += 1; this.markChangesPending(); + if (this.onStyleEdit) { + this.onStyleEdit(); + } } getTypeStyles(allTypes: Ref) {