5959# Maximum number of frames to extract (safety limit for very long recordings).
6060MAX_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
208237def 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