From b58d36bf2ec964b172b93d981f1b64396626c0df Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 29 Jan 2026 12:58:46 -0500 Subject: [PATCH 1/8] Allow temporal filter to control what gets run in pipelines --- client/dive-common/apispec.ts | 2 +- .../components/RunPipelineMenu.vue | 8 +- .../platform/desktop/backend/native/viame.ts | 74 ++++++++++++++++++- client/platform/desktop/constants.ts | 2 +- client/platform/desktop/frontend/api.ts | 3 +- .../frontend/components/ViewerLoader.vue | 15 +++- client/platform/web-girder/api/rpc.service.ts | 22 ++++-- .../web-girder/views/ViewerLoader.vue | 13 ++++ server/dive_server/crud_rpc.py | 4 +- server/dive_server/views_rpc.py | 23 +++++- server/dive_tasks/tasks.py | 58 ++++++++++++++- server/dive_utils/types.py | 5 +- 12 files changed, 207 insertions(+), 22 deletions(-) diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index d8888c6b1..6b416378c 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -183,7 +183,7 @@ interface DatasetMeta extends DatasetMetaMutable { interface Api { getPipelineList(): Promise; - runPipeline(itemId: string, pipeline: Pipe, additionalConfig?: Record): Promise; + runPipeline(itemId: string, pipeline: Pipe, frameRange?: [number, number] | null, additionalConfig?: Record): Promise; deleteTrainedPipeline(pipeline: Pipe): Promise; exportTrainedPipeline(path: string, pipeline: Pipe): Promise; diff --git a/client/dive-common/components/RunPipelineMenu.vue b/client/dive-common/components/RunPipelineMenu.vue index d8ee333ff..1f234ae62 100644 --- a/client/dive-common/components/RunPipelineMenu.vue +++ b/client/dive-common/components/RunPipelineMenu.vue @@ -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) { @@ -200,10 +205,11 @@ 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, frameRange, additionalConfig); }), )); } diff --git a/client/platform/desktop/backend/native/viame.ts b/client/platform/desktop/backend/native/viame.ts index 98dd1e8fc..2e506d93c 100644 --- a/client/platform/desktop/backend/native/viame.ts +++ b/client/platform/desktop/backend/native/viame.ts @@ -31,6 +31,59 @@ 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 { + 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: string[] = []; + + for (const line of lines) { + if (line.startsWith('#') || line.trim() === '') { + filteredLines.push(line); + // eslint-disable-next-line no-continue + continue; + } + const parts = line.split(','); + if (parts.length >= 3) { + const frame = parseInt(parts[2], 10); + if (!Number.isNaN(frame) && frame >= startFrame && frame <= endFrame) { + filteredLines.push(line); + } + } + } + + await fs.writeFile(filteredPath, filteredLines.join('\n')); + return filteredPath; +} + export interface ViameConstants { setupScriptAbs: string; // abs path setup comman trainingExe: string; // name of training binary on PATH @@ -108,7 +161,7 @@ async function runPipeline( viameConstants: ViameConstants, forceTranscodedVideo?: boolean, ): Promise { - const { datasetId, pipeline } = runPipelineArgs; + const { datasetId, pipeline, frameRange } = runPipelineArgs; const isValid = await validateViamePath(settings); if (isValid !== true) { @@ -199,6 +252,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'); @@ -293,8 +350,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); diff --git a/client/platform/desktop/constants.ts b/client/platform/desktop/constants.ts index 4210319bb..bda8ff887 100644 --- a/client/platform/desktop/constants.ts +++ b/client/platform/desktop/constants.ts @@ -175,8 +175,8 @@ export interface RunPipeline extends JobArgs { type: JobType.RunPipeline; datasetId: string; pipeline: Pipe; - /** Optional parameters to pass to the pipeline via -s flags */ pipelineParams?: Record; + frameRange?: [number, number] | null; outputDatasetName?: string; } diff --git a/client/platform/desktop/frontend/api.ts b/client/platform/desktop/frontend/api.ts index 49a26a709..81790f6a9 100644 --- a/client/platform/desktop/frontend/api.ts +++ b/client/platform/desktop/frontend/api.ts @@ -118,11 +118,12 @@ async function getTrainingConfigurations(): Promise { return window.diveDesktop.invoke('get-training-configs'); } -async function runPipeline(itemId: string, pipeline: Pipe, additionalConfig?: Record): Promise { +async function runPipeline(itemId: string, pipeline: Pipe, frameRange?: [number, number] | null, additionalConfig?: Record): Promise { const args: RunPipeline = { type: JobType.RunPipeline, pipeline, datasetId: itemId, + frameRange: frameRange || undefined, pipelineParams: additionalConfig, }; gpuJobQueue.enqueue(args); diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index 94ca70d8c..7303d55e4 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -1,6 +1,6 @@