diff --git a/lightly_studio_view/src/lib/utils/index.ts b/lightly_studio_view/src/lib/utils/index.ts index be465da7c..bec17bff7 100644 --- a/lightly_studio_view/src/lib/utils/index.ts +++ b/lightly_studio_view/src/lib/utils/index.ts @@ -20,6 +20,12 @@ export { getGridThumbnailRequestSize } from './getGridThumbnailURL/getGridThumbnailURL'; export { getVideoURLById } from './getVideoURLById/getVideoURLById'; +export { + toVideoEvents, + assignEventLanes, + type VideoEvent, + type LaneAssignedEvent +} from './videoEvents/videoEvents'; export { getURL } from './getURL/getURL'; export { fetchCollection } from './fetchCollection'; export { fetchCollectionHierarchy } from './fetchCollectionHierarchy'; diff --git a/lightly_studio_view/src/lib/utils/videoEvents/videoEvents.test.ts b/lightly_studio_view/src/lib/utils/videoEvents/videoEvents.test.ts new file mode 100644 index 000000000..236f1086a --- /dev/null +++ b/lightly_studio_view/src/lib/utils/videoEvents/videoEvents.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import type { AnnotationView } from '$lib/api/lightly_studio_local'; +import { toVideoEvents, assignEventLanes, type VideoEvent } from './videoEvents'; + +function makeAnnotation(overrides: Partial): AnnotationView { + return { + sample_id: 'a1', + parent_sample_id: 'video-1', + annotation_collection_id: 'coll-1', + annotation_type: 'classification', + annotation_label: { annotation_label_name: 'Jump' }, + created_at: new Date(), + temporal_span_details: { start_time_s: 1, end_time_s: 4 }, + ...overrides + } as AnnotationView; +} + +function makeEvent(overrides: Partial): VideoEvent { + return { + id: 'e', + annotationCollectionId: 'coll-1', + label: 'x', + startTimeS: 0, + endTimeS: 1, + color: 'rgba(0,0,0,1)', + contrastColor: 'rgba(255,255,255,1)', + ...overrides + }; +} + +describe('toVideoEvents', () => { + it('keeps only classification annotations with a temporal span', () => { + const events = toVideoEvents([ + makeAnnotation({ sample_id: 'with-span' }), + makeAnnotation({ sample_id: 'no-span', temporal_span_details: null }), + makeAnnotation({ sample_id: 'not-classification', annotation_type: 'object_detection' }) + ]); + + expect(events.map((event) => event.id)).toEqual(['with-span']); + }); + + it('maps span times and label onto the event', () => { + const [event] = toVideoEvents([ + makeAnnotation({ + sample_id: 'e1', + annotation_label: { annotation_label_name: 'Run' }, + temporal_span_details: { start_time_s: 2.5, end_time_s: 7 } + }) + ]); + + expect(event).toMatchObject({ id: 'e1', label: 'Run', startTimeS: 2.5, endTimeS: 7 }); + expect(event.color).toBeTruthy(); + expect(event.contrastColor).toBeTruthy(); + }); + + it('sorts events by start time', () => { + const events = toVideoEvents([ + makeAnnotation({ + sample_id: 'late', + temporal_span_details: { start_time_s: 9, end_time_s: 10 } + }), + makeAnnotation({ + sample_id: 'early', + temporal_span_details: { start_time_s: 1, end_time_s: 2 } + }) + ]); + + expect(events.map((event) => event.id)).toEqual(['early', 'late']); + }); + + it('returns an empty array when annotations are undefined', () => { + expect(toVideoEvents()).toEqual([]); + }); +}); + +describe('assignEventLanes', () => { + it('places non-overlapping events on the same lane', () => { + const { events, laneCount } = assignEventLanes([ + makeEvent({ id: 'a', startTimeS: 0, endTimeS: 2 }), + makeEvent({ id: 'b', startTimeS: 2, endTimeS: 4 }) + ]); + + expect(laneCount).toBe(1); + expect(events.every((event) => event.lane === 0)).toBe(true); + }); + + it('stacks overlapping events onto separate lanes', () => { + const { events, laneCount } = assignEventLanes([ + makeEvent({ id: 'a', startTimeS: 0, endTimeS: 5 }), + makeEvent({ id: 'b', startTimeS: 1, endTimeS: 3 }), + makeEvent({ id: 'c', startTimeS: 2, endTimeS: 4 }) + ]); + + expect(laneCount).toBe(3); + expect(events.find((event) => event.id === 'a')?.lane).toBe(0); + expect(events.find((event) => event.id === 'b')?.lane).toBe(1); + expect(events.find((event) => event.id === 'c')?.lane).toBe(2); + }); + + it('reuses a freed lane once an earlier event has ended', () => { + const { laneCount } = assignEventLanes([ + makeEvent({ id: 'a', startTimeS: 0, endTimeS: 2 }), + makeEvent({ id: 'b', startTimeS: 1, endTimeS: 3 }), + makeEvent({ id: 'c', startTimeS: 2, endTimeS: 4 }) + ]); + + // 'c' can reuse 'a's lane, so only two lanes are needed. + expect(laneCount).toBe(2); + }); +}); diff --git a/lightly_studio_view/src/lib/utils/videoEvents/videoEvents.ts b/lightly_studio_view/src/lib/utils/videoEvents/videoEvents.ts new file mode 100644 index 000000000..8751262fb --- /dev/null +++ b/lightly_studio_view/src/lib/utils/videoEvents/videoEvents.ts @@ -0,0 +1,90 @@ +import { AnnotationType, type AnnotationView } from '$lib/api/lightly_studio_local'; +import { getColorByLabel } from '$lib/utils/getColorByLabel'; + +/** + * A time-bounded "event" on a video timeline. + * + * Events are derived from classification annotations that carry a temporal + * span (start/end time in seconds), e.g. ActivityNet-style event annotations + * imported onto the video sample itself. + */ +export interface VideoEvent { + /** Unique id of the event (the annotation's sample id). */ + id: string; + /** Collection the underlying annotation belongs to (needed to persist edits). */ + annotationCollectionId: string; + /** Human-readable class label. */ + label: string; + /** Event start time, in seconds. */ + startTimeS: number; + /** Event end time, in seconds. */ + endTimeS: number; + /** Fill color derived deterministically from the label. */ + color: string; + /** Contrast color for text/borders on top of {@link color}. */ + contrastColor: string; +} + +/** A {@link VideoEvent} placed on a horizontal lane to avoid visual overlap. */ +export interface LaneAssignedEvent extends VideoEvent { + /** Zero-based lane (row) index the event was placed in. */ + lane: number; +} + +const EVENT_FILL_ALPHA = 0.7; + +/** + * Extracts imported events from a sample's annotations. + * + * Keeps only classification annotations that carry a temporal span and maps + * them into {@link VideoEvent}s sorted by start time. + */ +export function toVideoEvents(annotations: AnnotationView[] = []): VideoEvent[] { + return annotations + .filter( + (annotation) => + annotation.annotation_type === AnnotationType.CLASSIFICATION && + annotation.temporal_span_details != null + ) + .map((annotation) => { + const span = annotation.temporal_span_details!; + const label = annotation.annotation_label.annotation_label_name; + const { color, contrastColor } = getColorByLabel(label, EVENT_FILL_ALPHA); + return { + id: annotation.sample_id, + annotationCollectionId: annotation.annotation_collection_id, + label, + startTimeS: span.start_time_s, + endTimeS: span.end_time_s, + color, + contrastColor + } satisfies VideoEvent; + }) + .sort((a, b) => a.startTimeS - b.startTimeS); +} + +/** + * Greedily assigns each event to the first lane whose previous event has + * already ended, so overlapping events stack into separate rows instead of + * hiding one another. + * + * @returns The events annotated with a `lane` index and the total lane count. + */ +export function assignEventLanes(events: VideoEvent[]): { + events: LaneAssignedEvent[]; + laneCount: number; +} { + const sorted = [...events].sort((a, b) => a.startTimeS - b.startTimeS); + const laneEndTimes: number[] = []; + + const placed = sorted.map((event) => { + let lane = laneEndTimes.findIndex((endTime) => endTime <= event.startTimeS); + if (lane === -1) { + lane = laneEndTimes.length; + } + laneEndTimes[lane] = event.endTimeS; + return { ...event, lane } satisfies LaneAssignedEvent; + }); + + return { events: placed, laneCount: laneEndTimes.length }; +}