Skip to content

Commit aab90ce

Browse files
committed
feat(vision): add SceneDetector with histogram-based scene boundary detection
Implements general-purpose visual change detection for video frame sequences: - detectScenes() streams SceneBoundary from AsyncIterable<Frame> - hasSceneChanged() single-shot comparison between two frame buffers - histogramDiff() computes 768-bin RGB chi-squared distance (0-1) - ssimDiff() with sharp fallback to histogramDiff - Configurable thresholds, min scene duration, and cut type classification - 6 unit tests covering hard cuts, identical frames, streaming, and min duration
1 parent 23318a6 commit aab90ce

2 files changed

Lines changed: 583 additions & 0 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* @module core/video/__tests__/SceneDetector.test
3+
*
4+
* Unit tests for the {@link SceneDetector} class.
5+
*
6+
* Tests use synthetic 4x4 RGB frames with known solid colours to verify
7+
* scene boundary detection, histogram computation, and configuration
8+
* behaviour without requiring actual video data or image processing
9+
* libraries.
10+
*
11+
* ## Test categories
12+
*
13+
* 1. **Hard cut detection** — large colour difference triggers scene change
14+
* 2. **Identical frames** — no scene change when frames are the same
15+
* 3. **Streaming detection** — multiple scene boundaries from async frame stream
16+
* 4. **Histogram identity** — histogramDiff returns 0 for identical buffers
17+
* 5. **Histogram divergence** — histogramDiff returns high score for opposite colours
18+
* 6. **Min scene duration** — rapid changes suppressed by minSceneDurationSec
19+
*/
20+
21+
import { describe, it, expect } from 'vitest';
22+
import { SceneDetector } from '../../vision/SceneDetector.js';
23+
import type { Frame } from '../../vision/types.js';
24+
25+
// ---------------------------------------------------------------------------
26+
// Test helper — create a 4x4 solid-colour RGB frame
27+
// ---------------------------------------------------------------------------
28+
29+
/**
30+
* Create a 4x4 pixel RGB frame filled with a single colour.
31+
*
32+
* The buffer contains 48 bytes (4 * 4 * 3 = 48) of raw RGB data
33+
* where every pixel has the specified (r, g, b) values.
34+
*
35+
* @param r - Red channel value (0-255).
36+
* @param g - Green channel value (0-255).
37+
* @param b - Blue channel value (0-255).
38+
* @param timestampSec - Timestamp of this frame in seconds.
39+
* @param index - 0-based frame index.
40+
* @returns A Frame object with the specified colour and metadata.
41+
*/
42+
function makeFrame(r: number, g: number, b: number, timestampSec: number, index: number): Frame {
43+
// 4x4 pixels, 3 bytes per pixel = 48 bytes
44+
const buf = Buffer.alloc(4 * 4 * 3);
45+
for (let i = 0; i < 16; i++) {
46+
buf[i * 3] = r;
47+
buf[i * 3 + 1] = g;
48+
buf[i * 3 + 2] = b;
49+
}
50+
return { buffer: buf, timestampSec, index };
51+
}
52+
53+
/**
54+
* Helper to convert an array of frames into an async iterable.
55+
*/
56+
async function* toAsyncIterable(frames: Frame[]): AsyncGenerator<Frame> {
57+
for (const frame of frames) {
58+
yield frame;
59+
}
60+
}
61+
62+
/**
63+
* Helper to collect all values from an async generator into an array.
64+
*/
65+
async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
66+
const results: T[] = [];
67+
for await (const item of gen) {
68+
results.push(item);
69+
}
70+
return results;
71+
}
72+
73+
// ---------------------------------------------------------------------------
74+
// Tests
75+
// ---------------------------------------------------------------------------
76+
77+
describe('SceneDetector', () => {
78+
// -----------------------------------------------------------------------
79+
// Test 1: detects hard cut between very different frames
80+
// -----------------------------------------------------------------------
81+
82+
it('detects hard cut between very different frames (red vs blue)', () => {
83+
const detector = new SceneDetector({ hardCutThreshold: 0.3 });
84+
85+
const redFrame = makeFrame(255, 0, 0, 0, 0);
86+
const blueFrame = makeFrame(0, 0, 255, 1, 1);
87+
88+
const result = detector.hasSceneChanged(redFrame.buffer, blueFrame.buffer);
89+
90+
expect(result.changed).toBe(true);
91+
expect(result.score).toBeGreaterThan(0.3);
92+
expect(result.type).toBe('hard-cut');
93+
});
94+
95+
// -----------------------------------------------------------------------
96+
// Test 2: no change between identical frames
97+
// -----------------------------------------------------------------------
98+
99+
it('no change between identical frames', () => {
100+
const detector = new SceneDetector();
101+
102+
const frame = makeFrame(128, 128, 128, 0, 0);
103+
104+
const result = detector.hasSceneChanged(frame.buffer, frame.buffer);
105+
106+
expect(result.changed).toBe(false);
107+
expect(result.score).toBe(0);
108+
expect(result.type).toBeUndefined();
109+
});
110+
111+
// -----------------------------------------------------------------------
112+
// Test 3: detects scenes from async frame stream
113+
// -----------------------------------------------------------------------
114+
115+
it('detects scenes from async frame stream (3 colour groups -> 2+ scene boundaries)', async () => {
116+
const detector = new SceneDetector({
117+
hardCutThreshold: 0.3,
118+
gradualThreshold: 0.15,
119+
minSceneDurationSec: 0.5,
120+
});
121+
122+
// Create a sequence: 3 red frames, 3 green frames, 3 blue frames
123+
// Each frame is 1 second apart, so scene boundaries should be detected
124+
// at the colour transitions (frame 3 and frame 6).
125+
const frames: Frame[] = [
126+
// Scene 0: red
127+
makeFrame(255, 0, 0, 0, 0),
128+
makeFrame(255, 0, 0, 1, 1),
129+
makeFrame(255, 0, 0, 2, 2),
130+
// Scene 1: green
131+
makeFrame(0, 255, 0, 3, 3),
132+
makeFrame(0, 255, 0, 4, 4),
133+
makeFrame(0, 255, 0, 5, 5),
134+
// Scene 2: blue
135+
makeFrame(0, 0, 255, 6, 6),
136+
makeFrame(0, 0, 255, 7, 7),
137+
makeFrame(0, 0, 255, 8, 8),
138+
];
139+
140+
const boundaries = await collect(detector.detectScenes(toAsyncIterable(frames)));
141+
142+
// Should have at least 3 boundaries:
143+
// scene 0 (red), scene 1 (green), scene 2 (blue) + final boundary
144+
expect(boundaries.length).toBeGreaterThanOrEqual(3);
145+
146+
// First boundary should be the red scene
147+
expect(boundaries[0].startFrame).toBe(0);
148+
expect(boundaries[0].startTimeSec).toBe(0);
149+
150+
// Verify we detected the colour transitions
151+
const hardCuts = boundaries.filter((b) => b.cutType === 'hard-cut');
152+
expect(hardCuts.length).toBeGreaterThanOrEqual(1);
153+
});
154+
155+
// -----------------------------------------------------------------------
156+
// Test 4: histogramDiff returns 0 for identical buffers
157+
// -----------------------------------------------------------------------
158+
159+
it('histogramDiff returns 0 for identical buffers', () => {
160+
const detector = new SceneDetector();
161+
162+
const frame = makeFrame(100, 150, 200, 0, 0);
163+
164+
const diff = detector.histogramDiff(frame.buffer, frame.buffer);
165+
166+
expect(diff).toBe(0);
167+
});
168+
169+
// -----------------------------------------------------------------------
170+
// Test 5: histogramDiff returns high score for opposite colours
171+
// -----------------------------------------------------------------------
172+
173+
it('histogramDiff returns high score for opposite colours', () => {
174+
const detector = new SceneDetector();
175+
176+
const blackFrame = makeFrame(0, 0, 0, 0, 0);
177+
const whiteFrame = makeFrame(255, 255, 255, 1, 1);
178+
179+
const diff = detector.histogramDiff(blackFrame.buffer, whiteFrame.buffer);
180+
181+
// Black (all pixels at bin 0) vs white (all pixels at bin 255) should
182+
// produce a very high chi-squared distance since the histograms are
183+
// completely disjoint across all channels.
184+
expect(diff).toBeGreaterThan(0.5);
185+
});
186+
187+
// -----------------------------------------------------------------------
188+
// Test 6: respects minSceneDurationSec
189+
// -----------------------------------------------------------------------
190+
191+
it('respects minSceneDurationSec (rapid 1s changes suppressed with 5s min)', async () => {
192+
const detector = new SceneDetector({
193+
hardCutThreshold: 0.3,
194+
gradualThreshold: 0.15,
195+
minSceneDurationSec: 5.0, // Require at least 5 seconds between scenes
196+
});
197+
198+
// Create rapid colour changes every 1 second — all should be suppressed
199+
// because they don't meet the 5-second minimum scene duration.
200+
const frames: Frame[] = [
201+
makeFrame(255, 0, 0, 0, 0), // red
202+
makeFrame(0, 255, 0, 1, 1), // green (1s later — too soon)
203+
makeFrame(0, 0, 255, 2, 2), // blue (2s later — too soon)
204+
makeFrame(255, 255, 0, 3, 3), // yellow (3s later — too soon)
205+
makeFrame(0, 255, 255, 4, 4), // cyan (4s later — too soon)
206+
];
207+
208+
const boundaries = await collect(detector.detectScenes(toAsyncIterable(frames)));
209+
210+
// With 5s minSceneDurationSec and frames spanning only 0-4s,
211+
// no intermediate scene changes should be detected.
212+
// We should only get the final boundary (closing the single scene).
213+
expect(boundaries.length).toBe(1);
214+
expect(boundaries[0].startFrame).toBe(0);
215+
expect(boundaries[0].endFrame).toBe(4);
216+
});
217+
});

0 commit comments

Comments
 (0)