Skip to content
Open
16 changes: 14 additions & 2 deletions client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ interface PipeMetadata {
diveParams?: DiveParam[];
}

interface PipelineRuntimeParams {
frameRange?: [number, number] | null;
}

interface PipelineParams {
kwiverParams?: Record<string, string>;
runtimeParams?: PipelineRuntimeParams;
}

interface Pipe {
name: string;
pipe: string;
Expand Down Expand Up @@ -161,12 +170,13 @@ interface DatasetMetaMutable {
customTypeStyling?: Record<string, CustomStyle>;
customGroupStyling?: Record<string, CustomStyle>;
confidenceFilters?: Record<string, number>;
timeFilters?: [number, number] | null;
imageEnhancements?: ImageEnhancements;
attributes?: Readonly<Record<string, Attribute>>;
attributeTrackFilters?: Readonly<Record<string, AttributeTrackFilter>>;
error?: string;
}
const DatasetMetaMutableKeys = ['attributes', 'confidenceFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters'];
const DatasetMetaMutableKeys = ['attributes', 'confidenceFilters', 'timeFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters'];

interface DatasetMeta extends DatasetMetaMutable {
id: Readonly<string>;
Expand All @@ -183,7 +193,7 @@ interface DatasetMeta extends DatasetMetaMutable {

interface Api {
getPipelineList(): Promise<Pipelines>;
runPipeline(itemId: string, pipeline: Pipe, additionalConfig?: Record<string, string>): Promise<unknown>;
runPipeline(itemId: string, pipeline: Pipe, pipelineParams?: PipelineParams): Promise<unknown>;
deleteTrainedPipeline(pipeline: Pipe): Promise<void>;
exportTrainedPipeline(path: string, pipeline: Pipe): Promise<unknown>;

Expand Down Expand Up @@ -257,6 +267,8 @@ export {
MultiTrackRecord,
MultiGroupRecord,
Pipe,
PipelineParams,
PipelineRuntimeParams,
PipeMetadata,
Pipelines,
SaveDetectionsArgs,
Expand Down
11 changes: 10 additions & 1 deletion client/dive-common/components/RunPipelineMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export default defineComponent({
type: Boolean,
default: false,
},
/* Time filter range from the viewer - [startFrame, endFrame] or null */
timeFilter: {
type: Array as unknown as PropType<[number, number] | null>,
default: null,
},
},

setup(props) {
Expand Down Expand Up @@ -200,10 +205,14 @@ export default defineComponent({
datasetIds = props.selectedDatasetIds.map((item) => item.substring(0, item.lastIndexOf('/')));
}
selectedPipeline.value = pipeline;
const frameRange = props.timeFilter;
await _runPipelineRequest(() => Promise.all(
datasetIds.map((id) => {
const additionalConfig = additionalConfigById ? additionalConfigById[id] : undefined;
return runPipeline(id, pipeline, additionalConfig);
return runPipeline(id, pipeline, {
kwiverParams: additionalConfig,
runtimeParams: frameRange ? { frameRange } : undefined,
});
}),
));
}
Expand Down
9 changes: 9 additions & 0 deletions client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ export default defineComponent({
customTypeStyling: trackStyleManager.getTypeStyles(trackFilters.allTypes),
customGroupStyling: groupStyleManager.getTypeStyles(groupFilters.allTypes),
confidenceFilters: trackFilters.confidenceFilters.value,
timeFilters: trackFilters.timeFilters.value,
imageEnhancements: imageEnhancements.value,
// TODO Group confidence filters are not yet supported.
}, saveSet);
Expand All @@ -461,6 +462,12 @@ export default defineComponent({
});
}

function saveTimeFilter() {
saveMetadata(datasetId.value, {
timeFilters: trackFilters.timeFilters.value,
});
}

function saveImageEnhancements() {
saveMetadata(datasetId.value, {
imageEnhancements: imageEnhancements.value,
Expand Down Expand Up @@ -663,6 +670,7 @@ export default defineComponent({
loadAttributes(meta.attributes);
}
trackFilters.setConfidenceFilters(meta.confidenceFilters);
trackFilters.setTimeFilters(meta.timeFilters ?? null);
if (meta.imageEnhancements) {
setImageEnhancements(meta.imageEnhancements);
}
Expand Down Expand Up @@ -993,6 +1001,7 @@ export default defineComponent({
handler: globalHandler,
save,
saveThreshold,
saveTimeFilter,
updateTime,
// multicam
multiCamList,
Expand Down
3 changes: 3 additions & 0 deletions client/platform/desktop/backend/native/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,9 @@ async function saveMetadata(settings: Settings, datasetId: string, args: Dataset
if (args.attributes) {
existing.attributes = args.attributes;
}
if (args.timeFilters !== undefined) {
existing.timeFilters = args.timeFilters;
}
if (args.error) {
existing.error = args.error;
}
Expand Down
84 changes: 80 additions & 4 deletions client/platform/desktop/backend/native/viame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,54 @@ import {
const PipelineRelativeDir = 'configs/pipelines';
const DiveJobManifestName = 'dive_job_manifest.json';

/**
* Filter an image list to only include images within frame range.
* @param imageList List of image file paths
* @param frameRange Tuple of (start_frame, end_frame) inclusive (0-indexed)
* @returns Filtered list of image file paths
*/
function filterImageListByFrameRange(
imageList: string[],
frameRange: [number, number],
): string[] {
const [startFrame, endFrame] = frameRange;
// Ensure we don't go out of bounds
const safeStart = Math.max(0, startFrame);
const safeEnd = Math.min(endFrame, imageList.length - 1);
return imageList.slice(safeStart, safeEnd + 1);
}

/**
* Filter VIAME CSV to only include detections within frame range.
* @param csvPath Path to the input CSV file
* @param frameRange Tuple of (start_frame, end_frame) inclusive
* @returns Path to the filtered CSV file
*/
async function filterCsvByFrameRange(
csvPath: string,
frameRange: [number, number],
): Promise<string> {
const [startFrame, endFrame] = frameRange;
const filteredPath = csvPath.replace('.csv', '_filtered.csv');

const content = await fs.readFile(csvPath, 'utf-8');
const lines = content.split('\n');
const filteredLines = lines.filter((line) => {
if (line.startsWith('#') || line.trim() === '') {
return true;
}
const parts = line.split(',');
if (parts.length >= 3) {
const frame = parseInt(parts[2], 10);
return !Number.isNaN(frame) && frame >= startFrame && frame <= endFrame;
}
return false;
});

await fs.writeFile(filteredPath, filteredLines.join('\n'));
return filteredPath;
}

export interface ViameConstants {
setupScriptAbs: string; // abs path setup comman
/** Basename of unified VIAME CLI in `bin/` (e.g. `viame` or `viame.exe`). */
Expand Down Expand Up @@ -109,6 +157,7 @@ async function runPipeline(
forceTranscodedVideo?: boolean,
): Promise<DesktopJob> {
const { datasetId, pipeline } = runPipelineArgs;
const frameRange = runPipelineArgs.pipelineParams?.runtimeParams?.frameRange ?? undefined;

const isValid = await validateViamePath(settings);
if (isValid !== true) {
Expand Down Expand Up @@ -186,6 +235,17 @@ async function runPipeline(
`-p "${pipelinePath}"`,
`-s downsampler:target_frame_rate=${meta.fps}`,
];
if (frameRange) {
command.push(`-s downsampler:start_frame=${frameRange[0]}`);
command.push(`-s downsampler:end_frame=${frameRange[1]}`);
const isNative = !meta.originalFps || meta.fps >= meta.originalFps;
command.push(`-s downsampler:frame_range_is_native=${isNative}`);
// Transcode/filter pipes: output frames renumbered relative to new range
// All other pipes: output frames relative to original video
const renumber = pipeline.type === 'transcode' || pipeline.type === 'filter';
command.push(`-s downsampler:renumber_frames=${renumber}`);
command.push(`-s downsampler:adjust_timestamps=${renumber}`);
}
if (!stereoOrMultiCam) {
command.push(`-s input:video_filename="${videoAbsPath}"`);
command.push(`-s detector_writer:file_name="${detectorOutput}"`);
Expand All @@ -199,6 +259,10 @@ async function runPipeline(
if (meta.type === MultiType) {
imageList = getMultiCamImageFiles(meta);
}
// Filter image list by frame range if specified
if (frameRange) {
imageList = filterImageListByFrameRange(imageList, frameRange);
}
const fileData = imageList
.map((f) => npath.join(meta.originalBasePath, f))
.join('\n');
Expand Down Expand Up @@ -254,9 +318,10 @@ async function runPipeline(
}

// Add any custom pipeline parameters
if (runPipelineArgs.pipelineParams) {
const kwiverParams = runPipelineArgs.pipelineParams?.kwiverParams;
if (kwiverParams) {
const escapeValue = (val: string) => val.replace(/["$]/g, '\\$&');
Object.entries(runPipelineArgs.pipelineParams).forEach(([key, value]) => {
Object.entries(kwiverParams).forEach(([key, value]) => {
command.push(`-s ${key}="${escapeValue(value)}"`);
});
}
Expand Down Expand Up @@ -293,8 +358,19 @@ async function runPipeline(
if (code === 0) {
try {
if (!pipelineCreatesDatasetMarkers.includes(runPipelineArgs.pipeline.type)) {
// Filter and transcode pipelines should ensure that detector/track output files are located in the new dataset directory
const { meta: newMeta } = await common.ingestDataFiles(settings, datasetId, [detectorOutput, trackOutput], multiOutFiles);
let finalDetectorOutput = detectorOutput;
let finalTrackOutput = trackOutput;

if (frameRange && metaType === 'video') {
if (await fs.pathExists(trackOutput)) {
finalTrackOutput = await filterCsvByFrameRange(trackOutput, frameRange);
}
if (await fs.pathExists(detectorOutput)) {
finalDetectorOutput = await filterCsvByFrameRange(detectorOutput, frameRange);
}
}

const { meta: newMeta } = await common.ingestDataFiles(settings, datasetId, [finalDetectorOutput, finalTrackOutput], multiOutFiles);
if (newMeta) {
meta.attributes = newMeta.attributes;
await common.saveMetadata(settings, datasetId, meta);
Expand Down
5 changes: 2 additions & 3 deletions client/platform/desktop/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {
DatasetMeta, DatasetMetaMutable, DatasetType,
Pipe, SubType, MediaImportResponse,
Pipe, SubType, MediaImportResponse, PipelineParams,
} from 'dive-common/apispec';
import { Attribute } from 'vue-media-annotator/use/AttributeTypes';
import { AttributeTrackFilter } from 'vue-media-annotator/AttributeTrackFilterControls';
Expand Down Expand Up @@ -175,8 +175,7 @@ export interface RunPipeline extends JobArgs {
type: JobType.RunPipeline;
datasetId: string;
pipeline: Pipe;
/** Optional parameters to pass to the pipeline via -s flags */
pipelineParams?: Record<string, string>;
pipelineParams?: PipelineParams;
outputDatasetName?: string;
}

Expand Down
6 changes: 3 additions & 3 deletions client/platform/desktop/frontend/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import axios, { AxiosInstance } from 'axios';

import type {
DatasetMetaMutable, DatasetType, MultiCamImportArgs,
Pipe, Pipelines, SaveAttributeArgs,
Pipe, Pipelines, PipelineParams, SaveAttributeArgs,
SaveAttributeTrackFilterArgs, SaveDetectionsArgs, TrainingConfigs,
} from 'dive-common/apispec';

Expand Down Expand Up @@ -118,12 +118,12 @@ async function getTrainingConfigurations(): Promise<TrainingConfigs> {
return window.diveDesktop.invoke('get-training-configs');
}

async function runPipeline(itemId: string, pipeline: Pipe, additionalConfig?: Record<string, string>): Promise<void> {
async function runPipeline(itemId: string, pipeline: Pipe, pipelineParams?: PipelineParams): Promise<void> {
const args: RunPipeline = {
type: JobType.RunPipeline,
pipeline,
datasetId: itemId,
pipelineParams: additionalConfig,
pipelineParams,
};
gpuJobQueue.enqueue(args);
}
Expand Down
15 changes: 14 additions & 1 deletion client/platform/desktop/frontend/components/ViewerLoader.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import {
computed, defineComponent, ref, watch,
computed, defineComponent, ref, watch, Ref, watchEffect,
} from 'vue';
import Viewer from 'dive-common/components/Viewer.vue';
import RunPipelineMenu from 'dive-common/components/RunPipelineMenu.vue';
Expand Down Expand Up @@ -85,6 +85,17 @@ export default defineComponent({
return props.id;
});
const readOnlyMode = computed(() => settings.value?.readonlyMode || false);
const timeFilter: Ref<[number, number] | null> = ref(null);

// Watch the viewer's trackFilters.timeFilters and sync to local ref
watchEffect(() => {
if (viewerRef.value?.trackFilters?.timeFilters?.value) {
timeFilter.value = viewerRef.value.trackFilters.timeFilters.value;
} else {
timeFilter.value = null;
}
});

const runningPipelines = computed(() => {
const results: string[] = [];
// Check if any running job contains the root props.id
Expand Down Expand Up @@ -120,6 +131,7 @@ export default defineComponent({
readOnlyMode,
runningPipelines,
largeImageWarning,
timeFilter,
};
},
});
Expand Down Expand Up @@ -159,6 +171,7 @@ export default defineComponent({
:camera-numbers="camNumbers"
:running-pipelines="runningPipelines"
:read-only-mode="readOnlyMode"
:time-filter="timeFilter"
v-bind="{ buttonOptions, menuOptions }"
/>
<ImportAnnotations
Expand Down
19 changes: 10 additions & 9 deletions client/platform/web-girder/api/rpc.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import girderRest from 'platform/web-girder/plugins/girder';
import type { GirderModel } from '@girder/components/src';
import { Pipe } from 'dive-common/apispec';
import type { Pipe, PipelineParams } from 'dive-common/apispec';

function postProcess(folderId: string, skipJobs = false, skipTranscoding = false, additive = false, additivePrepend = '', set: string | undefined = undefined) {
return girderRest.post<{folder: GirderModel, warnings: string[], job_ids: string[]}>(`dive_rpc/postprocess/${folderId}`, null, {
Expand All @@ -10,14 +10,15 @@ function postProcess(folderId: string, skipJobs = false, skipTranscoding = false
});
}

function runPipeline(itemId: string, pipeline: Pipe, additionalConfig?: Record<string, string>) {
return girderRest.post('dive_rpc/pipeline', null, {
params: {
folderId: itemId,
pipeline,
pipelineParams: additionalConfig,
},
});
function runPipeline(itemId: string, pipeline: Pipe, pipelineParams?: PipelineParams) {
const params: Record<string, unknown> = {
folderId: itemId,
pipeline,
};
if (pipelineParams) {
params.pipelineParams = pipelineParams;
}
return girderRest.post('dive_rpc/pipeline', null, { params });
}

function runTraining(
Expand Down
Loading
Loading