Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
3d97480
Add a Review page that audits annotations as a grid of chips
mattdawkins Sep 9, 2026
a60cc1a
Crop review chips to the grid cell's aspect ratio
mattdawkins Sep 9, 2026
03a2d44
Share grid paging, zoom and controls between chip grids
mattdawkins Sep 9, 2026
08c6edc
Add desktop video search / IQR backend
mattdawkins Jul 7, 2026
a34becd
Add Video Search side panel with IQR refinement loop
mattdawkins Jul 7, 2026
92af418
Search across all indexed datasets via one shared index
mattdawkins Jul 7, 2026
4be8723
Add full-window results grid for rapid search adjudication
mattdawkins Jul 11, 2026
cb85c94
Cycle track results through sampled frames in the results grid
mattdawkins Jul 11, 2026
729e172
Adapt video search to DIVE main after the rebase
mattdawkins Sep 9, 2026
903d191
Show video search results in the shared review grid
mattdawkins Sep 9, 2026
83f87da
Build and query file-backed search indexes by default
mattdawkins Sep 9, 2026
41f9514
Drive search index builds and removals through viame index
mattdawkins Sep 9, 2026
b874917
Review grid: larger cell text, Results first, in-place geometry editi…
mattdawkins Sep 9, 2026
701e445
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 9, 2026
99c5ad9
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 9, 2026
44a7b6e
Review grid: annotator-style editing handles, right-click confirm, fr…
mattdawkins Sep 9, 2026
0c74832
Close the negative-highlight rule left open by the review grid merge
mattdawkins Sep 9, 2026
048385a
Review grid: settle the cell text between the old and enlarged sizes
mattdawkins Sep 9, 2026
c8a57e3
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 9, 2026
1394ec6
Review grid: pause on edit, real-time cycling, overlaid boxes, wheel …
mattdawkins Sep 9, 2026
5159903
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 9, 2026
ebb1cb6
Review grid: keep editing handles a constant screen size while zoomed
mattdawkins Sep 9, 2026
d1345f9
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 9, 2026
e7dbfbf
Review grid: one entry per track across stereo and multi-camera rigs,…
mattdawkins Sep 9, 2026
f91427a
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 9, 2026
78aef86
Review grid: cameras of an entry cycle in step, with frame controls k…
mattdawkins Sep 9, 2026
8d2133f
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 9, 2026
8cf98b9
Review grid: keep multi-camera frame controls clear of the camera label
mattdawkins Sep 9, 2026
13e7299
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 9, 2026
571a34c
Merge branch 'main' into dev/review-grid
mattdawkins Sep 10, 2026
fafcd13
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 10, 2026
e75347e
Add a Query page: index datasets and search them by image, video fram…
mattdawkins Sep 10, 2026
f182032
Add a shared DatasetPicker component
mattdawkins Sep 10, 2026
055661a
Use DatasetPicker on the Training and Pipelines pages
mattdawkins Sep 10, 2026
6d850ed
DatasetPicker: remove all, per-row remove, and hold the list in place
mattdawkins Sep 10, 2026
9e70358
Pipelines: list datasets on entry and put the selection under the picker
mattdawkins Sep 10, 2026
193622f
Pipelines: list every dataset before a pipeline is chosen
mattdawkins Sep 10, 2026
80262f9
Training: put the selected datasets under the available list
mattdawkins Sep 10, 2026
67a39e6
Merge branch 'dev/dataset-picker' into dev/review-grid
mattdawkins Sep 10, 2026
f8470b1
Review: choose datasets with the shared DatasetPicker
mattdawkins Sep 10, 2026
d31adbe
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 10, 2026
9e9fc9c
Query: choose datasets with the shared DatasetPicker
mattdawkins Sep 10, 2026
e01c1db
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 10, 2026
40ceabc
Review: queue datasets picked on the Datasets view until Results opens
mattdawkins Sep 10, 2026
296e77c
Query: build indexes for the selected datasets without a second selec…
mattdawkins Sep 10, 2026
f4f4eb8
Review: wheel zoom and drag pan at any time, shared across an entry's…
mattdawkins Sep 10, 2026
b47f3ab
Review: offer only types present at the current threshold
mattdawkins Sep 10, 2026
7e1a1a1
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 10, 2026
fd60685
Merge remote-tracking branch 'ssh/main' into dev/review-grid
mattdawkins Sep 10, 2026
f9ced4d
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 10, 2026
b7ee475
Review: own type dropdown per entry, and middle-button panning
mattdawkins Sep 10, 2026
1105e8e
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 10, 2026
ea119cf
Review: resume the session when coming back to the page
mattdawkins Sep 11, 2026
7c1b146
Merge branch 'dev/review-grid' into dev/video-search-review
mattdawkins Sep 11, 2026
e416080
Merge main into video search while preserving Review fixes
mattdawkins Sep 12, 2026
fa9ded4
Track indexing preparation and logs in Jobs and index first stereo ca…
mattdawkins Sep 12, 2026
c1fbe27
Reliably show indexing failure dialogs including preparation errors
mattdawkins Sep 12, 2026
3d1d47f
Centralize index creation on Query and select sequence search indexes
mattdawkins Sep 12, 2026
992337c
Retain index rows and job outcomes across Query navigation
mattdawkins Sep 12, 2026
e0e119d
Separate successful index entries from pending builds and expose dele…
mattdawkins Sep 12, 2026
5f58dc0
Launch sidebar video searches on the Query page
mattdawkins Sep 12, 2026
65f2926
Query page: whole-image searches, Index panel cleanup, whole-frame in…
mattdawkins Sep 13, 2026
7d14f2e
Left-align the Index panel tables with their section titles
mattdawkins Sep 13, 2026
b7ca2b2
Edit search results as annotations and hide reviewed ones
mattdawkins Sep 13, 2026
00429d4
Keep the accept and reject actions green and red on search results
mattdawkins Sep 13, 2026
fb2738c
Resume the Query page where it was left
mattdawkins Sep 13, 2026
31e0afb
Save search results by adjudication and resolve overlaps with originals
mattdawkins Sep 13, 2026
697a6c9
Lay the overlap dialog out as a 2x2 grid of captioned choices
mattdawkins Sep 13, 2026
05e0044
Wrap the Video Search launch buttons and move Save model beside Save …
mattdawkins Sep 13, 2026
a44a800
Stack the Query page toggles in the controls column so results start …
mattdawkins Sep 13, 2026
51272db
Give text query hits the same results card and toolbar as image and v…
mattdawkins Sep 13, 2026
e95587f
Merge branch 'main' into dev/video-search-review
BryonLewis Sep 13, 2026
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
90 changes: 90 additions & 0 deletions client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,96 @@ export interface RefineDetectionsResponse {
detections?: TextQueryDetection[];
}

/**
* Video Search / IQR (rapid model generation) Types
*/

export type VideoSearchIndexMethod = 'detections' | 'tracking' | 'existing' | 'frames';

/**
* One indexed media stream (video/sequence identifier) in the shared search
* database. All database rows key on this identifier, so a dataset can be
* added, updated, or removed from the index independently.
*/
export interface VideoSearchStreamEntry {
datasetId: string;
method: VideoSearchIndexMethod;
// frame rate the media was indexed at (video only; must match dataset fps)
fps?: number;
createdAt: string;
}

/** How a search index stores descriptors: per-stream files or an embedded PostgreSQL database. */
export type SearchIndexBackend = 'files' | 'postgres';

/** Sidecar metadata describing the shared search index. */
export interface VideoSearchIndexMeta {
version: number;
/** Storage backend the index was built with; absent on indexes from before it was recorded (postgres). */
backend?: SearchIndexBackend;
// stream identifier (as reported in query results) -> source dataset
streams: Record<string, VideoSearchStreamEntry>;
}

export interface VideoSearchIndexStatus {
/** The shared index exists and is queryable (ITQ files present). */
built: boolean;
/** This dataset has been ingested into the index. */
indexed: boolean;
/** The stream entry for this dataset, when indexed. */
stream?: VideoSearchStreamEntry & { streamName: string };
/** Total datasets in the shared index. */
datasetCount: number;
meta?: VideoSearchIndexMeta;
}

export interface VideoSearchTrackState {
frame: number;
bbox?: [number, number, number, number];
}

export interface VideoSearchResultTrack {
id: number;
states: VideoSearchTrackState[];
}

/** One ranked similarity result returned by the query service. */
export interface VideoSearchResult {
/** Unique reference for adjudication: "<session>:<instance_id>" */
ref: string;
/** Federated session index this result came from */
session: number;
/** Index directory this result came from */
index_dir: string;
instance_id: number;
query_id: string;
stream_id: string;
relevancy_score: number;
start_frame: number | null;
end_frame: number | null;
tracks: VideoSearchResultTrack[];
}

/** One dataset present in the shared search index. */
export interface VideoSearchIndexInfo {
/** Stream identifier query results report for this dataset */
streamName: string;
datasetId: string;
/** Dataset display name */
name: string;
/** How the entry was built. */
method: VideoSearchIndexMethod;
}

export interface VideoSearchQueryResponse {
success: boolean;
error?: string;
descriptor_count?: number;
model_available?: boolean;
results?: VideoSearchResult[];
feedback_requests?: VideoSearchResult[];
}

export {
provideApi,
useApi,
Expand Down
4 changes: 2 additions & 2 deletions client/dive-common/components/DatasetPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export default defineComponent({
</v-btn>
<v-btn
small
color="success"
color="grey lighten-1"
outlined
:disabled="addable.length === 0"
class="ml-2"
Expand All @@ -193,7 +193,7 @@ export default defineComponent({
small
left
>
mdi-check-all
mdi-select-all
</v-icon>
Select all{{ addable.length ? ` (${addable.length})` : '' }}
</v-btn>
Expand Down
27 changes: 24 additions & 3 deletions client/dive-common/components/Review/ReviewCell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ export default defineComponent({
type: String,
default: '#00e5ff',
},
/** Outline the cell as accepted or rejected (e.g. search adjudication). */
highlight: {
type: String as PropType<'' | 'positive' | 'negative'>,
default: '',
},
},
setup(props, { emit }) {
/** Chips currently in edit mode, to outline the whole entry. */
Expand Down Expand Up @@ -244,7 +249,12 @@ export default defineComponent({
<template>
<div
class="review-cell"
:class="{ 'cell-pending': pending, 'cell-editing': editingCount > 0 }"
:class="{
'cell-pending': pending,
'cell-editing': editingCount > 0,
'cell-positive': highlight === 'positive',
'cell-negative': highlight === 'negative',
}"
:style="{ '--cell-scale': scale }"
>
<div class="cell-views">
Expand Down Expand Up @@ -286,9 +296,12 @@ export default defineComponent({
>
<template
v-if="$scopedSlots.actions"
#actions
#actions="scope"
>
<slot name="actions" />
<slot
name="actions"
v-bind="scope"
/>
</template>
</ReviewChip>
</div>
Expand Down Expand Up @@ -331,6 +344,14 @@ export default defineComponent({
&.cell-editing {
border-color: #90caf9;
}

&.cell-positive {
border-color: #4caf50;
}

&.cell-negative {
border-color: #f44336;
}
}

.cell-views {
Expand Down
19 changes: 12 additions & 7 deletions client/dive-common/components/Review/ReviewChip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import type { ReviewFrameRef, ReviewPolygon } from 'dive-common/review/types';

/** Geometry of one frame while it is being edited, in image coordinates. */
interface GeometryDraft {
bounds: RectBounds;
/** Null for entries without a box (whole-frame results). */
bounds: RectBounds | null;
polygons: ReviewPolygon[];
head: [number, number] | null;
tail: [number, number] | null;
Expand All @@ -26,7 +27,7 @@ export interface ReviewChipGeometryEdit {
/** Sequence slot edited (0 is the primary frame). */
slot: number;
frame: number;
bounds: RectBounds;
bounds: RectBounds | null;
polygons: ReviewPolygon[];
head: [number, number] | null;
tail: [number, number] | null;
Expand Down Expand Up @@ -339,7 +340,7 @@ export default defineComponent({

const boxRect = computed(() => {
const g = shownGeometry.value;
if (!g) return null;
if (!g || !g.bounds) return null;
const [x1, y1, x2, y2] = g.bounds;
const a = chip([Math.min(x1, x2), Math.min(y1, y2)]);
const b = chip([Math.max(x1, x2), Math.max(y1, y2)]);
Expand Down Expand Up @@ -399,7 +400,7 @@ export default defineComponent({

function cloneDraft(g: GeometryDraft): GeometryDraft {
return {
bounds: [...g.bounds] as RectBounds,
bounds: g.bounds ? [...g.bounds] as RectBounds : null,
polygons: g.polygons.map((polygon) => polygon.map((p) => [p[0], p[1]] as [number, number])),
head: g.head ? [g.head[0], g.head[1]] : null,
tail: g.tail ? [g.tail[0], g.tail[1]] : null,
Expand All @@ -409,7 +410,7 @@ export default defineComponent({
/** Turn the interpolated position into a real box the user can then adjust. */
function addBox() {
const frame = currentFrame.value;
if (!props.editable || !frame || !frame.missing) return;
if (!props.editable || !frame || !frame.missing || !frame.bounds) return;
emit('add-box', { frame: frame.frame, bounds: frame.bounds });
}

Expand Down Expand Up @@ -605,7 +606,7 @@ export default defineComponent({
const dy = current[1] - drag.startImage[1];
const { target, startDraft } = drag;
if (target.kind === 'box') {
draft.value.bounds = moveBox(target.handle, startDraft.bounds, dx, dy);
if (startDraft.bounds) draft.value.bounds = moveBox(target.handle, startDraft.bounds, dx, dy);
} else if (target.kind === 'vertex') {
const origin = startDraft.polygons[target.polygon]?.[target.vertex];
if (origin) {
Expand Down Expand Up @@ -967,7 +968,11 @@ export default defineComponent({
class="cell-actions"
@click.stop
>
<slot name="actions">
<slot
name="actions"
:begin-edit="beginEdit"
:frame="currentFrame ? currentFrame.frame : undefined"
>
<v-tooltip
bottom
open-delay="600"
Expand Down
2 changes: 1 addition & 1 deletion client/dive-common/components/Review/ReviewPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export default defineComponent({
const item = entry.items[viewIndex];
if (!item) return;
review.updateGeometry(item, edit.frame, {
bounds: edit.bounds,
bounds: edit.bounds ?? undefined,
polygons: edit.polygons,
head: edit.head,
tail: edit.tail,
Expand Down
16 changes: 15 additions & 1 deletion client/dive-common/review/chipRenderer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
chipRegion, chipScale, chipSizeFor, toChipPoint, toImagePoint,
chipRegion, chipScale, chipSizeFor, frameRegion, toChipPoint, toImagePoint,
} from './chipRenderer';

describe('chipRegion', () => {
Expand Down Expand Up @@ -39,6 +39,20 @@ describe('chipSizeFor', () => {
});
});

describe('frameRegion', () => {
it('covers the whole frame, padding the shorter direction to the aspect', () => {
expect(frameRegion(100, 50, 2)).toEqual({
x: 0, y: 0, width: 100, height: 50,
});
expect(frameRegion(100, 50, 1)).toEqual({
x: 0, y: -25, width: 100, height: 100,
});
expect(frameRegion(100, 50, 4)).toEqual({
x: -50, y: 0, width: 200, height: 50,
});
});
});

describe('chipScale', () => {
it('fills the requested size, upscaling small crops and downscaling large ones', () => {
expect(chipScale({
Expand Down
25 changes: 22 additions & 3 deletions client/dive-common/review/chipRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ export function chipSizeFor(cellPixels: number): number {
return CHIP_SIZE_BUCKETS.find((size) => size >= wanted) ?? CHIP_SIZE_BUCKETS[CHIP_SIZE_BUCKETS.length - 1];
}

/** Crop region covering a whole frame at the requested aspect ratio, centred. */
export function frameRegion(width: number, height: number, aspect = 1): ChipRegion {
const ratio = aspect > 0 && Number.isFinite(aspect) ? aspect : 1;
const w = Math.max(1, width);
const h = Math.max(1, height);
const regionWidth = w / h >= ratio ? w : h * ratio;
const regionHeight = w / h >= ratio ? w / ratio : h;
return {
x: (w - regionWidth) / 2, y: (h - regionHeight) / 2, width: regionWidth, height: regionHeight,
};
}

/**
* Scale from frame pixels to chip pixels: the crop's longer side fills the
* requested size. Small crops are upscaled so the chip is rendered once at
Expand All @@ -90,8 +102,15 @@ export function chipScale(region: ChipRegion, size: number): number {
return Math.max(16, size) / longest;
}

export function renderChip(frame: DecodedFrame, bounds: RectBounds, options: ChipRenderOptions): RenderedChip {
const region = chipRegion(bounds, options.padding, options.aspect);
/** Render a box crop, or the whole frame when no box is given. */
export function renderChip(
frame: DecodedFrame,
bounds: RectBounds | null,
options: ChipRenderOptions,
): RenderedChip {
const region = bounds
? chipRegion(bounds, options.padding, options.aspect)
: frameRegion(frame.width, frame.height, options.aspect);
const scale = chipScale(region, options.size);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(region.width * scale));
Expand Down Expand Up @@ -122,7 +141,7 @@ export function renderChip(frame: DecodedFrame, bounds: RectBounds, options: Chi
sh * scale,
);
}
if (options.outline) {
if (options.outline && bounds) {
const [x1, y1, x2, y2] = bounds;
ctx.strokeStyle = options.outline;
ctx.lineWidth = Math.max(1, Math.round(Math.max(canvas.width, canvas.height) / 160));
Expand Down
4 changes: 2 additions & 2 deletions client/dive-common/review/chipStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface ChipStoreOptions {

export interface ChipStoreDeps {
/** Frame access for a dataset; null when the dataset cannot be cropped. */
frameSourceFor(datasetId: string): FrameSource | null;
frameSourceFor(datasetId: string): FrameSource | null | Promise<FrameSource | null>;
concurrency?: number;
/** Retain recent rendered items when paging; visible items are always kept. */
cacheSize?: number;
Expand Down Expand Up @@ -80,7 +80,7 @@ export function createChipStore(deps: ChipStoreDeps, initial: ChipStoreOptions)
}

async function render(job: ChipJob): Promise<RenderedChip> {
const source = deps.frameSourceFor(job.item.datasetId);
const source = await deps.frameSourceFor(job.item.datasetId);
if (!source) throw new Error('Media for this dataset cannot be cropped');
const frameRef = job.slot === null ? job.item.primary : job.item.frames[job.slot];
const frame = await source.getFrame(frameRef.frame);
Expand Down
42 changes: 42 additions & 0 deletions client/dive-common/review/frameSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,45 @@ export function createFrameSource(config: DatasetConfig, options: FrameSourceOpt
return entry.url;
}, imageData.length, cacheSize, cacheBytes);
}

/**
* Lazily creates one frame source per dataset from its config, for chip
* stores whose items can name any dataset (e.g. cross-dataset search
* results). A dataset whose config cannot be loaded or whose media cannot
* be cropped resolves to null.
*/
export function createFrameSourceRegistry(
loadConfig: (datasetId: string) => Promise<DatasetConfig>,
options: FrameSourceOptions = {},
) {
const sources = new Map<string, Promise<FrameSource | null>>();

function frameSourceFor(datasetId: string): Promise<FrameSource | null> {
if (!datasetId) return Promise.resolve(null);
let pending = sources.get(datasetId);
if (!pending) {
pending = loadConfig(datasetId)
.then((config) => {
try {
return createFrameSource(config, options);
} catch {
return null;
}
})
.catch(() => null);
sources.set(datasetId, pending);
}
return pending;
}

function dispose() {
sources.forEach((pending) => {
pending.then((source) => source?.dispose()).catch(() => undefined);
});
sources.clear();
}

return { frameSourceFor, dispose };
}

export type FrameSourceRegistry = ReturnType<typeof createFrameSourceRegistry>;
Loading
Loading