Skip to content

Commit bb81889

Browse files
nodeeeeeeclaude
andcommitted
Deduplicate same-page frames; keep most informative version
When extracting frames from screen-share videos, multiple scene-change frames can come from the same slide page (e.g. incremental bullet reveals, animations, cursor movements). Previously only compared each frame to the last kept frame, which let duplicates slip through. New approach in detect_scenes() Pass 3: - Group consecutive frames into slide pages using perceptual hash similarity (dHash Hamming distance < 45 bits = same page) - Compare against both the group anchor AND the previous frame to handle gradual reveals without drift - Score each frame by visual information content (edge density on a 160x120 grayscale thumbnail) - Keep only the highest-scoring frame per page group — typically the most complete version with all content revealed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8db7dff commit bb81889

1 file changed

Lines changed: 79 additions & 17 deletions

File tree

frame_extractor.py

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@
5959
# Maximum number of frames to extract (safety limit for very long recordings).
6060
MAX_FRAMES = 500
6161

62+
# Perceptual-hash threshold for considering two frames as the same slide page.
63+
# dHash is 16×16 = 256 bits; incremental reveals (bullet-by-bullet, animation
64+
# steps) typically differ by 10–35 bits, while genuine slide transitions differ
65+
# by 60+ bits. 45 comfortably separates the two distributions.
66+
PAGE_SIMILARITY_THRESHOLD = 45
67+
6268

6369
# ── Screen vs Camera auto-detection ──────────────────────────────────────────
6470

@@ -203,21 +209,46 @@ def _hamming(a: int, b: int) -> int:
203209
return bin(a ^ b).count("1")
204210

205211

212+
def _information_score(img) -> int:
213+
"""Score an image by visual information content (edge/detail density).
214+
215+
Computes the sum of horizontal and vertical pixel-intensity gradients on a
216+
small grayscale thumbnail. Frames with more text, diagrams, or revealed
217+
bullets score higher than sparse or blank versions of the same slide.
218+
"""
219+
from PIL import Image as PILImage
220+
small = img.convert("L").resize((160, 120), PILImage.LANCZOS)
221+
pixels = list(small.getdata())
222+
w, h = 160, 120
223+
score = 0
224+
for y in range(h):
225+
row = y * w
226+
for x in range(w - 1):
227+
score += abs(pixels[row + x] - pixels[row + x + 1])
228+
for y in range(h - 1):
229+
row = y * w
230+
for x in range(w):
231+
score += abs(pixels[row + x] - pixels[row + w + x])
232+
return score
233+
234+
206235
# ── Intelligent scene detection ──────────────────────────────────────────────
207236

208237
def detect_scenes(video_path: Path, threshold: float = SCENE_THRESHOLD,
209238
min_gap: float = MIN_SCENE_GAP) -> list[float]:
210239
"""Detect unique slide/screen changes in a video.
211240
212-
Strategy (two-pass):
241+
Strategy (three-pass):
213242
1. Use ffmpeg scene filter to find raw scene-change timestamps
214243
2. If too few detected (common with camera/lecture videos), fall back
215244
to periodic sampling every 10 seconds
216-
3. Extract a candidate frame at each timestamp
217-
4. Compute perceptual hashes (dHash) to deduplicate — only keep frames
218-
that differ significantly from the previous kept frame
245+
3. Extract a candidate frame at each timestamp, compute perceptual
246+
hashes (dHash) and information scores. Group consecutive frames
247+
that belong to the same slide page (incremental reveals, animations)
248+
and keep only the most informative frame from each group.
219249
220-
This prevents both missing slides and keeping duplicate frames.
250+
This prevents both missing slides and keeping duplicate frames from
251+
the same slide page (e.g. bullet-by-bullet reveals).
221252
"""
222253
duration = get_video_duration(video_path)
223254

@@ -262,18 +293,23 @@ def detect_scenes(video_path: Path, threshold: float = SCENE_THRESHOLD,
262293
raw_timestamps = merged
263294
print(f" After merge: {len(raw_timestamps)} candidates")
264295

265-
# ── Pass 3: extract candidate frames and deduplicate via perceptual hash ─
296+
# ── Pass 3: group frames by slide page, pick the most informative frame ──
297+
#
298+
# Multiple scene-change frames may come from the same slide page (e.g.
299+
# incremental bullet reveals, animations, cursor movements). We cluster
300+
# consecutive frames whose perceptual hashes are similar (same page) and
301+
# keep only the frame with the highest visual information score — typically
302+
# the most "complete" version of that slide.
266303
import tempfile
267304
tmp_dir = Path(tempfile.mkdtemp(prefix="scene_dedup_"))
268-
HASH_THRESHOLD = 30 # dHash bits that must differ to consider "new slide"
269305

270306
try:
271307
from PIL import Image as PILImage
272308

273-
unique_timestamps: list[float] = []
274-
prev_hash: int | None = None
275-
309+
# Extract candidate frames and compute hashes + info scores
310+
candidates: list[tuple[float, int, int]] = [] # (timestamp, hash, info_score)
276311
print(f" Deduplicating {len(raw_timestamps)} candidates via perceptual hash...")
312+
277313
for ts in raw_timestamps[:MAX_FRAMES * 2]:
278314
png = tmp_dir / f"cand_{ts:.1f}.png"
279315
subprocess.run(
@@ -287,15 +323,41 @@ def detect_scenes(video_path: Path, threshold: float = SCENE_THRESHOLD,
287323

288324
img = PILImage.open(png)
289325
h = _perceptual_hash(img)
290-
291-
if prev_hash is None or _hamming(h, prev_hash) >= HASH_THRESHOLD:
292-
unique_timestamps.append(ts)
293-
prev_hash = h
294-
# else: duplicate frame, skip
295-
326+
score = _information_score(img)
327+
candidates.append((ts, h, score))
296328
png.unlink() # free disk space
297329

298-
print(f" Unique frames after dedup: {len(unique_timestamps)}")
330+
# Group consecutive frames into slide pages. A new page starts when
331+
# the frame differs from BOTH the group anchor (first frame) and the
332+
# previous frame by >= PAGE_SIMILARITY_THRESHOLD bits.
333+
groups: list[list[tuple[float, int, int]]] = []
334+
current: list[tuple[float, int, int]] = []
335+
336+
for cand in candidates:
337+
ts, h, score = cand
338+
if not current:
339+
current.append(cand)
340+
else:
341+
anchor_h = current[0][1]
342+
prev_h = current[-1][1]
343+
# Same page if similar to anchor OR similar to previous frame
344+
if (_hamming(h, anchor_h) < PAGE_SIMILARITY_THRESHOLD or
345+
_hamming(h, prev_h) < PAGE_SIMILARITY_THRESHOLD):
346+
current.append(cand)
347+
else:
348+
groups.append(current)
349+
current = [cand]
350+
if current:
351+
groups.append(current)
352+
353+
# From each page group, pick the frame with the highest info score
354+
unique_timestamps: list[float] = []
355+
for grp in groups:
356+
best = max(grp, key=lambda c: c[2]) # highest info score
357+
unique_timestamps.append(best[0])
358+
359+
print(f" {len(candidates)} candidates → {len(groups)} slide page(s) "
360+
f"(threshold={PAGE_SIMILARITY_THRESHOLD})")
299361

300362
except ImportError:
301363
print(" [warn] PIL not available — skipping deduplication")

0 commit comments

Comments
 (0)