diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index d8888c6b1..67cd04716 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -51,6 +51,15 @@ interface PipeMetadata { diveParams?: DiveParam[]; } +interface PipelineRuntimeParams { + frameRange?: [number, number] | null; +} + +interface PipelineParams { + kwiverParams?: Record; + runtimeParams?: PipelineRuntimeParams; +} + interface Pipe { name: string; pipe: string; @@ -161,12 +170,13 @@ interface DatasetMetaMutable { customTypeStyling?: Record; customGroupStyling?: Record; confidenceFilters?: Record; + timeFilters?: [number, number] | null; imageEnhancements?: ImageEnhancements; attributes?: Readonly>; attributeTrackFilters?: Readonly>; 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; @@ -183,7 +193,7 @@ interface DatasetMeta extends DatasetMetaMutable { interface Api { getPipelineList(): Promise; - runPipeline(itemId: string, pipeline: Pipe, additionalConfig?: Record): Promise; + runPipeline(itemId: string, pipeline: Pipe, pipelineParams?: PipelineParams): Promise; deleteTrainedPipeline(pipeline: Pipe): Promise; exportTrainedPipeline(path: string, pipeline: Pipe): Promise; @@ -257,6 +267,8 @@ export { MultiTrackRecord, MultiGroupRecord, Pipe, + PipelineParams, + PipelineRuntimeParams, PipeMetadata, Pipelines, SaveDetectionsArgs, diff --git a/client/dive-common/components/RunPipelineMenu.vue b/client/dive-common/components/RunPipelineMenu.vue index d8ee333ff..7dfb22588 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,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, + }); }), )); } diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 1f8305173..0616321cc 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -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); @@ -461,6 +462,12 @@ export default defineComponent({ }); } + function saveTimeFilter() { + saveMetadata(datasetId.value, { + timeFilters: trackFilters.timeFilters.value, + }); + } + function saveImageEnhancements() { saveMetadata(datasetId.value, { imageEnhancements: imageEnhancements.value, @@ -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); } @@ -993,6 +1001,7 @@ export default defineComponent({ handler: globalHandler, save, saveThreshold, + saveTimeFilter, updateTime, // multicam multiCamList, diff --git a/client/platform/desktop/backend/native/common.ts b/client/platform/desktop/backend/native/common.ts index 3068bc910..845f053fd 100644 --- a/client/platform/desktop/backend/native/common.ts +++ b/client/platform/desktop/backend/native/common.ts @@ -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; } diff --git a/client/platform/desktop/backend/native/viame.ts b/client/platform/desktop/backend/native/viame.ts index d9c88ef13..9c83fdbef 100644 --- a/client/platform/desktop/backend/native/viame.ts +++ b/client/platform/desktop/backend/native/viame.ts @@ -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 { + 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`). */ @@ -109,6 +157,7 @@ async function runPipeline( forceTranscodedVideo?: boolean, ): Promise { const { datasetId, pipeline } = runPipelineArgs; + const frameRange = runPipelineArgs.pipelineParams?.runtimeParams?.frameRange ?? undefined; const isValid = await validateViamePath(settings); if (isValid !== true) { @@ -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}"`); @@ -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'); @@ -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)}"`); }); } @@ -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); diff --git a/client/platform/desktop/constants.ts b/client/platform/desktop/constants.ts index 4210319bb..3d5f00508 100644 --- a/client/platform/desktop/constants.ts +++ b/client/platform/desktop/constants.ts @@ -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'; @@ -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; + pipelineParams?: PipelineParams; outputDatasetName?: string; } diff --git a/client/platform/desktop/frontend/api.ts b/client/platform/desktop/frontend/api.ts index 49a26a709..16c144450 100644 --- a/client/platform/desktop/frontend/api.ts +++ b/client/platform/desktop/frontend/api.ts @@ -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'; @@ -118,12 +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, pipelineParams?: PipelineParams): Promise { const args: RunPipeline = { type: JobType.RunPipeline, pipeline, datasetId: itemId, - pipelineParams: additionalConfig, + pipelineParams, }; 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 @@