Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default defineComponent({

function statusText(status: string) {
if (status === 'ready') return 'Ready';
if (status === 'queued') return 'Loads when Results opens';
if (status === 'queued') return 'Loads when Results or Statistics opens';
return 'Loading…';
}

Expand Down
43 changes: 41 additions & 2 deletions client/dive-common/components/Review/ReviewPage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Vue, { ComponentOptions, CreateElement, nextTick } from 'vue';
import type { NavigationGuard } from 'vue-router';
import type { DatasetConfig } from 'dive-common/apispec';
import type { ReviewService } from 'dive-common/use/useReview';
import { takeReviewSession } from 'dive-common/review/reviewSession';
import { holdReviewSession, takeReviewSession } from 'dive-common/review/reviewSession';
import ReviewPage from './ReviewPage.vue';

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -46,7 +46,8 @@ interface PageState {
review: ReviewService;
grid: { page: { value: number }; goToPage(page: number): void };
deleteEntry(entry: ReviewService['entries']['value'][number]): void;
view: 'results' | 'datasets';
view: 'results' | 'datasets' | 'statistics';
setView(view: 'statistics'): void;
resolveLeave(choice: 'save' | 'discard' | 'cancel'): void;
leaveDialog: boolean;
}
Expand Down Expand Up @@ -259,3 +260,41 @@ it('repopulates a cleared list when returning from the same sequence that starte
expect(page.view).toBe('results');
second.destroy();
});

it('loads queued datasets on Statistics and updates counts after edits, deletion, and removal', async () => {
const wrapper = mountPage({ retainSession: false });
const page = wrapper.vm as unknown as PageState;
await page.review.addDataset('stats', undefined, { defer: true });
expect(page.review.statistics.value.trackCount).toBe(0);
page.setView('statistics');
await page.review.ensureLoaded('stats');
expect(page.view).toBe('statistics');
expect(page.review.statistics.value.categories[0]).toMatchObject({ name: 'fish', count: 1 });
const item = page.review.items.value[0];
page.review.assignType(item, 'shark');
expect(page.review.statistics.value.categories[0]).toMatchObject({ name: 'shark', count: 1 });
page.review.query.threshold = 1;
page.review.query.type = 'absent';
page.review.runQuery();
expect(page.review.statistics.value.trackCount).toBe(1);
page.review.deleteTrack(item);
expect(page.review.statistics.value.trackCount).toBe(0);
page.review.removeDataset('stats');
expect(page.review.statistics.value.timelines).toHaveLength(0);
wrapper.destroy();
});

it('returns to Statistics after visiting a sequence viewer', async () => {
const first = mountPage();
const page = first.vm as unknown as PageState;
await page.review.addDataset('stats');
page.setView('statistics');
holdReviewSession({
review: page.review, view: 'statistics', page: 0, datasetKey: '',
});
first.destroy();
const second = mountPage({ retainSession: false });
expect((second.vm as unknown as PageState).view).toBe('statistics');
await nextTick();
second.destroy();
});
32 changes: 22 additions & 10 deletions client/dive-common/components/Review/ReviewPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { ReviewEntry, ReviewSortOrder } from 'dive-common/review/types';
import type { ViewerFocus } from 'dive-common/review/viewerNavigation';
import UserSettingsDialog from 'dive-common/components/UserSettingsDialog.vue';
import ReviewStatisticsPanel from './ReviewStatisticsPanel.vue';
import ReviewDatasetsPanel from './ReviewDatasetsPanel.vue';
import ReviewGrid from './ReviewGrid.vue';
import ReviewGridControls from './ReviewGridControls.vue';
Expand All @@ -27,7 +28,7 @@ const TYPE_LIST_ID = 'reviewTypeOptions';
/** Base footer height of a cell at scale 1 (type field plus caption). */
const CELL_FOOTER_BASE_PX = 48;

type ReviewView = 'results' | 'datasets';
type ReviewView = 'results' | 'datasets' | 'statistics';

const SORT_OPTIONS: { value: ReviewSortOrder; text: string }[] = [
{ value: 'confidence-desc', text: 'Confidence, high first' },
Expand All @@ -51,7 +52,7 @@ const SCOPE_OPTIONS = [
export default defineComponent({
name: 'ReviewPage',
components: {
ReviewDatasetsPanel, ReviewGrid, ReviewGridControls, ReviewCell, UserSettingsDialog,
ReviewStatisticsPanel, ReviewDatasetsPanel, ReviewGrid, ReviewGridControls, ReviewCell, UserSettingsDialog,
},
props: {
sessionOwner: { type: String, default: '' },
Expand Down Expand Up @@ -85,6 +86,7 @@ export default defineComponent({
// loaded data opens Results.
const hasReady = review.datasets.value.some((d) => d.status === 'ready');
const view = ref<ReviewView>(hasReady || initialIds.length > 0 ? 'results' : 'datasets');
if (resumed?.view === 'statistics') view.value = 'statistics';
const resuming = ref(!!resumed);
// Covers the gap before the initial selection starts loading.
const opening = ref(!hasReady && initialIds.length > 0);
Expand Down Expand Up @@ -181,7 +183,7 @@ export default defineComponent({
function setView(next: ReviewView) {
view.value = next;
// Datasets picked on the Datasets view load only now, when results are wanted.
if (next === 'results') review.loadQueued();
if (next !== 'datasets') review.loadQueued();
}

/** Open the viewer on the frame the chip is showing (its first frame otherwise). */
Expand Down Expand Up @@ -460,28 +462,34 @@ export default defineComponent({
>
<v-btn
small
value="results"
value="datasets"
>
<v-icon
small
left
>
mdi-view-grid
mdi-database
</v-icon>
Results
Datasets
<span class="ml-1 grey--text">({{ review.datasets.value.length }})</span>
</v-btn>
<v-btn small value="statistics">
<v-icon small left>
mdi-chart-timeline-variant
</v-icon>
Statistics
</v-btn>
<v-btn
small
value="datasets"
value="results"
>
<v-icon
small
left
>
mdi-database
mdi-view-grid
</v-icon>
Datasets
<span class="ml-1 grey--text">({{ review.datasets.value.length }})</span>
Results
</v-btn>
</v-btn-toggle>

Expand Down Expand Up @@ -736,6 +744,10 @@ export default defineComponent({
v-if="view === 'datasets'"
@open-dataset="openDataset"
/>
<ReviewStatisticsPanel
v-else-if="view === 'statistics'"
@open-dataset="openDataset"
/>
<template v-else>
<div
v-if="readyDatasets === 0"
Expand Down
142 changes: 142 additions & 0 deletions client/dive-common/components/Review/ReviewStatisticsPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<script lang="ts">
import { computed, defineComponent, ref } from 'vue';
import { useReview } from 'dive-common/use/useReview';
import { timelineStepPath, TimelineRow } from 'dive-common/review/statistics';

export default defineComponent({
name: 'ReviewStatisticsPanel',
setup() {
const review = useReview();
const search = ref('');
const table = ref('categories');
const { statistics } = review;
const incomplete = computed(() => review.datasets.value.filter((dataset) => dataset.status !== 'ready').length);
// A common vertical scale makes counts comparable across sequences.
const peak = computed(() => statistics.value.timelines.reduce((max, row) => Math.max(max, ...row.bins), 1));
function endLabel(row: TimelineRow) {
return `${row.duration.toFixed(row.unit === 's' ? 1 : 0)} ${row.unit}`;
}
const headers = computed(() => (table.value === 'categories'
? [{ text: 'Category', value: 'name' }, { text: 'Tracks', value: 'count' }]
: [{ text: 'Attribute', value: 'name' }, { text: 'Value', value: 'value' },
{ text: 'Scope', value: 'scope' }, { text: 'Occurrences', value: 'count' }]));
return {
review,
statistics,
incomplete,
peak,
timelineStepPath,
endLabel,
search,
table,
headers,
dateLabel: (row: TimelineRow) => (row.timestamp === undefined
? 'Capture time unavailable' : new Date(row.timestamp * 1000).toLocaleString()),
};
},
});
</script>

<template>
<div class="review-statistics">
<v-progress-linear v-if="review.loading.value" indeterminate aria-label="Loading statistics" />
<v-alert v-if="incomplete" dense text type="info">
Statistics include loaded sequences only. {{ incomplete }} selected sequences are loading,
queued, or failed. See Datasets for details and retry failed loads there.
</v-alert>
<p v-if="!review.datasets.value.length">
Select sequences on the Datasets page to see their statistics.
</p>
<template v-else>
<h3>{{ statistics.trackCount }} camera tracks across {{ statistics.timelines.length }} sequences</h3>
<p class="text-caption">
Uses each sequence's saved category thresholds, independently of the Results filters.
A track counts once for each qualifying category. Attributes count occurrences on qualifying
tracks or detections; false and zero are included. Stereo cameras are counted separately.
</p>
<div class="d-flex align-center flex-wrap">
<v-btn-toggle v-model="table" mandatory dense>
<v-btn small value="categories">
Categories
</v-btn>
<v-btn small value="attributes">
Attributes
</v-btn>
</v-btn-toggle>
<v-text-field v-model="search" label="Filter counts" clearable dense hide-details class="ml-4" />
</div>
<v-data-table
class="statistics-counts"
:headers="headers"
:items="table === 'categories' ? statistics.categories : statistics.attributes"
:search="search || ''"
item-key="key"
:items-per-page="10"
:footer-props="{ 'items-per-page-options': [10, 25, 50] }"
sort-by="count"
:sort-desc="true"
dense
no-data-text="No annotations meet the saved thresholds."
/>
<h3 class="mt-4">
Sequence timelines
</h3>
<p class="text-caption">
Newest capture time first; undated sequences follow by name. Each row shows elapsed time
(or frames if any camera lacks FPS), with a shared vertical scale of 0–{{ peak }} tracks.
Colored steps show each type. All cameras share one sequence plot; matching track IDs
are counted once on each timeline.
Tracks span their first through last frame. Video rows use the annotated extent when the
full media length is unavailable. Scroll within the timelines to see more sequences.
</p>
<v-virtual-scroll :items="statistics.timelines" :item-height="180" height="460" class="timeline-list">
<template #default="{ item }">
<div class="timeline-row">
<div class="d-flex align-center">
<v-btn text small class="timeline-name" :title="item.name" @click="$emit('open-dataset', review.parentOf(item.id))">
{{ item.name }}
</v-btn>
<span class="text-caption ml-2">{{ item.count }} tracks · {{ item.cameraCount }} camera{{ item.cameraCount === 1 ? '' : 's' }} · {{ dateLabel(item) }}</span>
</div>
<div class="timeline-legend text-caption">
<span v-for="series in item.series" :key="series.name" :style="{ color: review.colorFor(series.name) }">
{{ series.name }}
</span>
</div>
<svg viewBox="0 0 1000 84" preserveAspectRatio="none" role="img" :aria-label="`${item.name}: timelines by type across all cameras`">
<title>{{ item.name }}: {{ item.count }} tracks across {{ item.cameraCount }} cameras; peak scale {{ peak }}</title>
<line x1="0" y1="80" x2="1000" y2="80" stroke="currentColor" />
<path
v-for="series in item.series"
:key="series.name"
:d="timelineStepPath(series.bins, peak)"
:fill="review.colorFor(series.name)"
:stroke="review.colorFor(series.name)"
fill-opacity="0.12"
stroke-width="1.5"
vector-effect="non-scaling-stroke"
>
<title>{{ series.name }}: peak {{ Math.max(...series.bins) }} tracks</title>
</path>
</svg>
<div class="d-flex justify-space-between text-caption">
<span>0</span>
<span v-if="item.annotatedExtent">Annotated extent</span>
<span>{{ endLabel(item) }}</span>
</div>
</div>
</template>
</v-virtual-scroll>
</template>
</div>
</template>

<style scoped>
.review-statistics { height: 100%; min-height: 0; overflow: auto; padding: 8px; }
.statistics-counts ::v-deep table { color: inherit; }
.timeline-list { border: 1px solid #777; }
.timeline-row { height: 180px; padding: 8px 16px; border-bottom: 1px solid #777; }
.timeline-row svg { display: block; width: 100%; height: 84px; }
.timeline-legend { display: flex; gap: 12px; height: 24px; overflow-x: auto; white-space: nowrap; }
.timeline-name { max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
</style>
2 changes: 1 addition & 1 deletion client/dive-common/review/reviewSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface ReviewSession {
review: ReviewService;
/** Platform account that owns this in-memory session. */
owner?: string;
view: 'results' | 'datasets';
view: 'results' | 'datasets' | 'statistics';
page: number;
/** Key of the datasets the session was started with, see `sessionKey`. */
datasetKey: string;
Expand Down
Loading
Loading