Skip to content
Open
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
19 changes: 19 additions & 0 deletions client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CustomStyle>;
customGroupStyling?: Record<string, CustomStyle>;
}
/**
* Mutable keys the multicam/stereo viewer loads from the parent dataset.
* Camera-targeted imports sync only these onto the parent — not per-camera
Expand Down Expand Up @@ -397,6 +407,14 @@ interface Api {
downloadCalibration?(datasetId: string): Promise<void>;
/** Remove the calibration file currently associated with the dataset. */
deleteCalibration?(datasetId: string): Promise<void>;
/**
* 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<GlobalStyleSettings>;
/** Persist the cross-dataset "shared" color/style overrides. */
saveGlobalStyleSettings?(settings: GlobalStyleSettings): Promise<unknown>;
}
const ApiSymbol = Symbol('api');

Expand Down Expand Up @@ -592,6 +610,7 @@ export {
DatasetMetaMutable,
DatasetMetaMutableKeys,
MulticamSharedMutableKeys,
GlobalStyleSettings,
DatasetType,
DiveParam,
CameraCalibration,
Expand Down
25 changes: 24 additions & 1 deletion client/dive-common/components/UserSettingsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
},
Expand All @@ -29,11 +34,29 @@ export default defineComponent({
<v-card>
<v-card-title>User Settings</v-card-title>
<v-card-text>
<v-select
v-model="clientSettings.typeSettings.colorScope"
:items="colorScopeItems"
color="primary"
item-color="primary"
class="my-0"
label="Type color scope"
:hint="isDesktopRuntime
? 'Shared: reuse the same type/track colors across every sequence. '
+ 'Per dataset: colors are saved only with the dataset they were set on. '
+ 'Applies when a dataset is opened.'
: 'Shared: reuse your type/track colors across every dataset. '
+ 'Per dataset: colors are saved only with the dataset they were set on. '
+ 'Applies when a dataset is opened.'"
persistent-hint
dense
outlined
/>
<v-switch
v-if="isDesktopRuntime"
v-model="clientSettings.multiCamSettings.showToolbar"
color="primary"
class="my-0"
class="my-0 mt-3"
label="Show multi-camera toolbar"
hint="Show multi-camera tools in the top toolbar when a track is selected."
persistent-hint
Expand Down
89 changes: 87 additions & 2 deletions client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
AlignedViewStore,
StyleManager, TrackFilterControls, GroupFilterControls,
} from 'vue-media-annotator/index';
import type { CustomStyle } from 'vue-media-annotator/StyleManager';
import { resolveToReferenceTransforms, unresolvedCameras } from 'vue-media-annotator/alignedView/alignedView';
import { provideAnnotator, LassoModeSymbol } from 'vue-media-annotator/provides';

Expand Down Expand Up @@ -192,6 +193,7 @@ export default defineComponent({
const videoUrl: Ref<Record<string, string>> = ref({});
const {
loadDetections, loadMetadata, saveMetadata, getTiles, getTileURL, getTileHistogram,
loadGlobalStyleSettings, saveGlobalStyleSettings,
} = useApi();
const progress = reactive({
// Loaded flag prevents annotator window from populating
Expand Down Expand Up @@ -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<Record<string, CustomStyle>> = ref({});
const globalGroupStyles: Ref<Record<string, CustomStyle>> = 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);

Expand Down Expand Up @@ -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' });
}
Expand Down
6 changes: 6 additions & 0 deletions client/dive-common/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -138,6 +143,7 @@ const defaultSettings: AnnotationSettings = {
maxCountButton: false,
suppressionType: 'Suppressed',
suppressionThreshold: 99,
colorScope: 'shared',
},
rowsPerPage: 20,
annotationFPS: 10,
Expand Down
10 changes: 9 additions & 1 deletion client/platform/desktop/backend/ipcService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions client/platform/desktop/backend/native/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1928,3 +1928,8 @@ export {
} from './datasetCalibration';

export { exportMulticamEverything } from './multicamExport';

export {
loadGlobalStyleSettings,
saveGlobalStyleSettings,
} from './globalStyles';
73 changes: 73 additions & 0 deletions client/platform/desktop/backend/native/globalStyles.spec.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
54 changes: 54 additions & 0 deletions client/platform/desktop/backend/native/globalStyles.ts
Original file line number Diff line number Diff line change
@@ -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<GlobalStyleSettings> {
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<void> {
await fs.ensureDir(settings.dataPath);
const payload: GlobalStyleSettings = {
customTypeStyling: styleSettings.customTypeStyling ?? {},
customGroupStyling: styleSettings.customGroupStyling ?? {},
};
await fs.writeFile(globalStylePath(settings), JSON.stringify(payload, null, 2));
}
3 changes: 3 additions & 0 deletions client/platform/desktop/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading