Spaces:
Running
Running
| """Video frame sampling utilities for Small Cuts.""" | |
| from __future__ import annotations | |
| from pathlib import Path | |
| from PIL import Image, ImageFilter, ImageStat | |
| def sample_frames( | |
| path: str | Path, | |
| every_n_seconds: float = 3.0, | |
| max_frames: int | None = None, | |
| ) -> list[Image.Image]: | |
| """Decode *path* with PyAV and return a list of RGB PIL Images. | |
| Every ``int(fps * every_n_seconds)``-th frame is kept (indices 0, step, | |
| 2*step, …). No files are written. Decoding stops as soon as | |
| *max_frames* images have been collected (when *max_frames* is not None). | |
| """ | |
| import av # PyAV — ffmpeg-backed, reliable ARM64 wheels | |
| kept: list[Image.Image] = [] | |
| container = av.open(str(path)) | |
| try: | |
| stream = container.streams.video[0] | |
| fps = float(stream.average_rate or stream.guessed_rate or 30) | |
| step = max(1, int(fps * every_n_seconds)) | |
| for i, frame in enumerate(container.decode(stream)): | |
| if i % step == 0: | |
| img = frame.to_image().convert("RGB") | |
| kept.append(img) | |
| if max_frames is not None and len(kept) >= max_frames: | |
| break | |
| finally: | |
| container.close() | |
| return kept | |
| def pick_frame(frames: list[Image.Image]) -> Image.Image: | |
| """Return the middle frame (``frames[len(frames) // 2]``). | |
| Raises ``ValueError`` when *frames* is empty. | |
| """ | |
| if not frames: | |
| raise ValueError("frames list is empty") | |
| return frames[len(frames) // 2] | |
| def pick_key_frame(frames: list[Image.Image]) -> Image.Image: | |
| """Return the most useful library/poster frame from sampled video frames. | |
| The score is intentionally deterministic and dependency-light: prefer frames | |
| that are exposed near mid-brightness, have contrast, and have visible edges. | |
| A centrality bonus breaks near-ties toward the middle of the clip, which is | |
| usually more representative than the capture start or the trailing frame. | |
| """ | |
| if not frames: | |
| raise ValueError("frames list is empty") | |
| middle = (len(frames) - 1) / 2 | |
| best_index, _best_score = max( | |
| enumerate(frames), | |
| key=lambda item: (_frame_quality(item[1]) + _centrality(item[0], middle), -item[0]), | |
| ) | |
| return frames[best_index] | |
| def _frame_quality(frame: Image.Image) -> float: | |
| gray = frame.convert("L") | |
| gray.thumbnail((160, 160), Image.Resampling.LANCZOS) | |
| stats = ImageStat.Stat(gray) | |
| brightness = stats.mean[0] / 255.0 | |
| contrast = min(stats.stddev[0] / 96.0, 1.0) | |
| exposure = 1.0 - min(abs(brightness - 0.5) / 0.5, 1.0) | |
| edges = ImageStat.Stat(gray.filter(ImageFilter.FIND_EDGES)).mean[0] / 255.0 | |
| return exposure * 0.45 + contrast * 0.35 + min(edges * 3.0, 1.0) * 0.20 | |
| def _centrality(index: int, middle: float) -> float: | |
| if middle <= 0: | |
| return 0.0 | |
| return (1.0 - min(abs(index - middle) / middle, 1.0)) * 0.08 | |