Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
b874917
Review grid: larger cell text, Results first, in-place geometry editi…
mattdawkins Sep 9, 2026
44a7b6e
Review grid: annotator-style editing handles, right-click confirm, fr…
mattdawkins Sep 9, 2026
048385a
Review grid: settle the cell text between the old and enlarged sizes
mattdawkins Sep 9, 2026
1394ec6
Review grid: pause on edit, real-time cycling, overlaid boxes, wheel …
mattdawkins Sep 9, 2026
ebb1cb6
Review grid: keep editing handles a constant screen size while zoomed
mattdawkins Sep 9, 2026
e7dbfbf
Review grid: one entry per track across stereo and multi-camera rigs,…
mattdawkins Sep 9, 2026
78aef86
Review grid: cameras of an entry cycle in step, with frame controls k…
mattdawkins Sep 9, 2026
8cf98b9
Review grid: keep multi-camera frame controls clear of the camera label
mattdawkins Sep 9, 2026
571a34c
Merge branch 'main' into dev/review-grid
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
40ceabc
Review: queue datasets picked on the Datasets view until Results opens
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
fd60685
Merge remote-tracking branch 'ssh/main' into dev/review-grid
mattdawkins Sep 10, 2026
b7ee475
Review: own type dropdown per entry, and middle-button panning
mattdawkins Sep 10, 2026
ea119cf
Review: resume the session when coming back to the page
mattdawkins Sep 11, 2026
bbb7f2b
add save count and guarding
BryonLewis Sep 11, 2026
d4b0bd5
better defaults for the review page
BryonLewis Sep 11, 2026
32c5c4a
allow for going back to review from within a dataset launched by review
BryonLewis Sep 11, 2026
c001245
Fix review session lifecycle, save guards, and multicamera edits
BryonLewis Sep 11, 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
16 changes: 14 additions & 2 deletions client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,15 @@ interface Api {
deleteScoringResult?(datasetId: string, resultId: string): Promise<void>;
/** Annotation sets, revisions or on-disk files a source on this dataset can point at. */
listScoringSources?(datasetId: string): Promise<ScoringSourceOptions>;
/** Datasets that may be named as the other side of a comparison. */
/**
* Datasets that may be named as the other side of a comparison; also the
* dataset list the review page offers.
*/
listScoringDatasets?(): Promise<ScoringDatasetSummary[]>;
/** Open a platform dataset picker; returns null when the user cancels. */
/**
* Open a platform dataset picker; returns null when the user cancels.
* Shared by the scoring and review pages.
*/
pickScoringDataset?(excludeIds: string[]): Promise<ScoringDatasetSummary | null>;
/** Save a text export where the user chooses; resolves false when they cancel. */
saveScoringExport?(args: { filename: string; mime: string; content: string }): Promise<boolean>;
Expand All @@ -487,6 +493,12 @@ interface Api {
): Promise<boolean>;

loadConfig(datasetId: string): Promise<DatasetConfig>;
/**
* loadConfig without the platform's viewer bookkeeping (desktop recents,
* web browse location), for pages that read many datasets at once such as
* Review. Callers fall back to loadConfig when absent.
*/
peekConfig?(datasetId: string): Promise<DatasetConfig>;
loadDetections(datasetId: string, revision?: number, set?: string): Promise<AnnotationSchemaList>;
loadFrameMetadata(datasetId: string): Promise<FrameMetadataSourcesResponse>;

Expand Down
359 changes: 359 additions & 0 deletions client/dive-common/components/Review/ReviewCell.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,359 @@
<script lang="ts">
import {
computed, defineComponent, onBeforeUnmount, PropType, ref, watch,
} from 'vue';
import type { ChipTransform } from 'dive-common/review/chipRenderer';
import type { ReviewFrameRef } from 'dive-common/review/types';
import ReviewChip, { ChipView, ReviewChipGeometryEdit } from './ReviewChip.vue';
import ReviewTypeField from './ReviewTypeField.vue';

/** One chip of an entry: a track in one camera. */
export interface ReviewCellView {
key: string;
src: string | null;
srcs: (string | null)[] | null;
transform: ChipTransform | null;
transforms: (ChipTransform | null)[] | null;
frames: ReviewFrameRef[];
failure: string | null;
frameCount: number;
/** Camera name, empty for single-camera entries. */
label: string;
}

export type ReviewCellGeometryEdit = ReviewChipGeometryEdit;

/**
* One grid entry: the track's chips (one per camera it appears in, side by
* side) with the annotation's type editable underneath. Presentation-only;
* the page supplies the images and applies edits, so other item sources
* (e.g. search results) can reuse it with their own actions through the
* `actions` slot. Single-chip users may pass the chip props directly
* instead of `views`.
*/
export default defineComponent({
name: 'ReviewCell',
components: { ReviewChip, ReviewTypeField },
props: {
/** Chips of the entry, one per camera. Overrides the single-chip props. */
views: {
type: Array as PropType<ReviewCellView[] | null>,
default: null,
},
/** Primary chip data URL, null while loading (single-chip form). */
src: {
type: String as PropType<string | null>,
default: null,
},
/** Sampled track frames to cycle through; null slots are still loading. */
srcs: {
type: Array as PropType<(string | null)[] | null>,
default: null,
},
/** How the primary chip maps to the frame, once rendered. */
transform: {
type: Object as PropType<ChipTransform | null>,
default: null,
},
/** Per-slot transforms of the sampled frames. */
transforms: {
type: Array as PropType<(ChipTransform | null)[] | null>,
default: null,
},
/** The frames shown, primary first, with their boxes and geometry. */
frames: {
type: Array as PropType<ReviewFrameRef[]>,
default: () => [],
},
/** Why the chip could not be rendered, or null. */
failure: {
type: String as PropType<string | null>,
default: null,
},
/** Frames in the track, shown as a badge when animated. */
frameCount: {
type: Number,
default: 1,
},
animate: {
type: Boolean,
default: true,
},
cycleIntervalMs: {
type: Number,
default: 400,
},
/** Live type of the annotation. */
type: {
type: String,
default: '',
},
/** Confidence of the shown pair; null hides the badge. */
confidence: {
type: Number as PropType<number | null>,
default: null,
},
/** Types offered by the type field's dropdown. */
typeOptions: {
type: Array as PropType<string[]>,
default: () => [],
},
/** Accessible name for the entry (not shown as a tooltip). */
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
/** Edited but not yet saved. */
pending: {
type: Boolean,
default: false,
},
editable: {
type: Boolean,
default: true,
},
/** Offer the delete action on the chips. */
deletable: {
type: Boolean,
default: true,
},
/** Attribute mode: what matched, shown in place of nothing. */
attributeText: {
type: String,
default: '',
},
/** Size factor for the footer text, 1 being the base size. */
scale: {
type: Number,
default: 1,
},
/** Draw polygons and head/tail points over the chips. */
showGeometry: {
type: Boolean,
default: true,
},
/** The annotation type's colour, used for boxes and editing handles. */
color: {
type: String,
default: '#00e5ff',
},
},
setup(props, { emit }) {
/** Chips currently in edit mode, to outline the whole entry. */
const editingCount = ref(0);

const viewList = computed<ReviewCellView[]>(() => props.views ?? [{
key: 'single',
src: props.src,
srcs: props.srcs,
transform: props.transform,
transforms: props.transforms,
frames: props.frames,
failure: props.failure,
frameCount: props.frameCount,
label: '',
}]);

// ---- shared cycling: every camera of an entry shows the same frame ----

const shared = computed(() => viewList.value.length > 1);
const sharedSlot = ref(0);
const sharedPaused = ref(false);
/** One zoom and pan for every camera of the entry. */
const sharedView = ref<ChipView>({ scale: 1, x: 0, y: 0 });
let timer: number | null = null;

const sequenceLength = computed(() => (
viewList.value.reduce((longest, view) => Math.max(longest, view.srcs?.length ?? 0), 0)
));

/** Whether any camera has the frame for a slot, so the cycle never stalls on a gap. */
function slotLoaded(slot: number) {
return viewList.value.some((view) => Boolean(view.srcs?.[slot]));
}

function advanceShared(direction: 1 | -1 = 1) {
const length = sequenceLength.value;
if (length < 2) return;
for (let count = 1; count <= length; count += 1) {
const next = (sharedSlot.value + direction * count + length * count) % length;
if (slotLoaded(next)) {
sharedSlot.value = next;
return;
}
}
}

function syncSharedTimer() {
const shouldRun = shared.value && props.animate && sequenceLength.value > 1
&& !sharedPaused.value && editingCount.value === 0;
if (shouldRun && timer === null) {
timer = window.setInterval(() => advanceShared(1), props.cycleIntervalMs);
} else if (!shouldRun && timer !== null) {
window.clearInterval(timer);
timer = null;
}
}
watch(
[shared, () => props.animate, sequenceLength, sharedPaused, editingCount, () => props.cycleIntervalMs],
() => {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
syncSharedTimer();
},
{ immediate: true },
);
onBeforeUnmount(() => {
if (timer !== null) window.clearInterval(timer);
});

function stepShared(direction: 1 | -1) {
sharedPaused.value = true;
advanceShared(direction);
}

function toggleSharedPaused() {
sharedPaused.value = !sharedPaused.value;
}

function commitType(next: string) {
if (next !== props.type) emit('assign', next);
}

return {
viewList,
editingCount,
commitType,
shared,
sharedSlot,
sharedPaused,
sharedView,
stepShared,
toggleSharedPaused,
};
},
});
</script>

<template>
<div
class="review-cell"
:class="{ 'cell-pending': pending, 'cell-editing': editingCount > 0 }"
:style="{ '--cell-scale': scale }"
>
<div class="cell-views">
<ReviewChip
v-for="(view, index) in viewList"
:key="view.key"
:src="view.src"
:srcs="view.srcs"
:transform="view.transform"
:transforms="view.transforms"
:frames="view.frames"
:failure="view.failure"
:frame-count="view.frameCount"
:label="view.label"
:animate="animate"
:cycle-interval-ms="cycleIntervalMs"
:type="type"
:confidence="index === 0 ? confidence : null"
:title="title"
:pending="pending"
:editable="editable"
:deletable="deletable"
:show-geometry="showGeometry"
:color="color"
:controlled-slot="shared ? sharedSlot : null"
:controlled-paused="sharedPaused"
:controlled-view="shared ? sharedView : null"
@view-change="sharedView = $event"
@step="stepShared"
@toggle-paused="toggleSharedPaused"
@pause="sharedPaused = true"
@accept="$emit('accept')"
@delete="$emit('delete')"
@open="$emit('open', $event, index)"
@edit-geometry="$emit('edit-geometry', $event, index)"
@add-box="$emit('add-box', $event, index)"
@edit-start="editingCount += 1"
@edit-end="editingCount = Math.max(0, editingCount - 1)"
>
<template
v-if="$scopedSlots.actions"
#actions
>
<slot name="actions" />
</template>
</ReviewChip>
</div>
<div class="cell-footer">
<slot name="footer">
<ReviewTypeField
:value="type"
:options="typeOptions"
:disabled="!editable"
@commit="commitType"
/>
<div
v-if="subtitle || attributeText"
class="grey--text cell-caption-line"
:title="attributeText || subtitle"
>
{{ attributeText || subtitle }}
</div>
</slot>
</div>
</div>
</template>

<style lang="scss" scoped>
.review-cell {
--cell-scale: 1;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
border: 1px solid #3a3a3a;
border-radius: 4px;
overflow: hidden;
background: #1e1e1e;

&.cell-pending {
border-color: #ffb300;
}

&.cell-editing {
border-color: #90caf9;
}
}

.cell-views {
display: flex;
flex: 1 1 auto;
min-height: 32px;
min-width: 0;
gap: 2px;
background: #101010;
}

.cell-footer {
flex: 0 0 auto;
padding: calc(3px * var(--cell-scale)) calc(4px * var(--cell-scale));
min-width: 0;
}

.cell-caption-line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: calc(11.5px * var(--cell-scale));
line-height: 1.3;
margin-top: calc(2px * var(--cell-scale));
}
</style>
Loading
Loading