Добавлено описание и документация для HVideoTool, включая функционал, требования, установку и запуск приложения для обнаружения цензуры на изображениях.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""Extract frames from a video into a folder of JPGs.
|
||||
|
||||
Two engines:
|
||||
|
||||
* **ffmpeg** (preferred, used when the ``ffmpeg`` binary is on PATH) — one
|
||||
subprocess does decode + sampling + optional downscale + JPEG encode, which is
|
||||
faster than pulling frames into Python one by one, and unlocks the big win:
|
||||
*keyframe-only* extraction (``-skip_frame nokey`` decodes only I-frames, ~10×
|
||||
faster than decoding every frame).
|
||||
* **OpenCV** fallback (``cv2.VideoCapture``) when ffmpeg is absent.
|
||||
|
||||
Decoding H.264/HEVC frame-by-frame is inherently the cost; hardware accel doesn't
|
||||
help for this (GPU transfer overhead). The only way to be dramatically faster is
|
||||
to decode fewer frames — hence the keyframe mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import cv2
|
||||
|
||||
from ..imageio import imwrite_unicode
|
||||
|
||||
# progress(done_seconds, total_seconds) -> return False to cancel.
|
||||
Progress = Callable[[float, float], bool]
|
||||
|
||||
|
||||
def _find_ffmpeg() -> str | None:
|
||||
"""ffmpeg on PATH, else the binary bundled with imageio-ffmpeg, else None."""
|
||||
exe = shutil.which("ffmpeg")
|
||||
if exe:
|
||||
return exe
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
|
||||
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||
except Exception: # noqa: BLE001 - package missing or no bundled binary
|
||||
return None
|
||||
|
||||
|
||||
def _video_duration_seconds(video_path: str) -> float:
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
try:
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
|
||||
frames = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0
|
||||
return frames / fps if fps > 0 else 0.0
|
||||
finally:
|
||||
cap.release()
|
||||
|
||||
|
||||
def _quality_to_qscale(jpg_quality: int) -> int:
|
||||
"""Map JPEG quality 0..100 to ffmpeg -q:v (2 best .. 31 worst)."""
|
||||
q = round(2 + (100 - max(0, min(100, jpg_quality))) / 100 * 29)
|
||||
return max(2, min(31, q))
|
||||
|
||||
|
||||
def extract_frames(
|
||||
video_path: str,
|
||||
out_dir: str,
|
||||
step: int = 15,
|
||||
keyframes_only: bool = False,
|
||||
max_dim: int = 0,
|
||||
jpg_quality: int = 92,
|
||||
progress: Progress | None = None,
|
||||
) -> int:
|
||||
"""Save sampled frames of ``video_path`` into ``out_dir`` as JPGs.
|
||||
|
||||
``keyframes_only`` decodes only keyframes (fast). Otherwise keeps every
|
||||
``step``-th frame. ``max_dim`` (>0) caps the longest side. ``progress`` is
|
||||
called with (done_seconds, total_seconds); returning ``False`` cancels.
|
||||
Returns the number of frames written.
|
||||
"""
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
ffmpeg = _find_ffmpeg()
|
||||
if ffmpeg:
|
||||
return _extract_ffmpeg(ffmpeg, video_path, out, step, keyframes_only, max_dim, jpg_quality, progress)
|
||||
return _extract_cv2(video_path, out, step, max_dim, jpg_quality, progress)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- ffmpeg
|
||||
def _build_vf(step: int, keyframes_only: bool, max_dim: int) -> str | None:
|
||||
filters: list[str] = []
|
||||
if not keyframes_only and step > 1:
|
||||
filters.append(f"select=not(mod(n\\,{int(step)}))")
|
||||
if max_dim and max_dim > 0:
|
||||
# Cap the longest side to max_dim, preserve aspect, never upscale.
|
||||
filters.append(f"scale='min({max_dim},iw)':'min({max_dim},ih)':force_original_aspect_ratio=decrease")
|
||||
return ",".join(filters) if filters else None
|
||||
|
||||
|
||||
def _extract_ffmpeg(
|
||||
ffmpeg: str, video_path: str, out: Path, step: int,
|
||||
keyframes_only: bool, max_dim: int, jpg_quality: int, progress: Progress | None,
|
||||
) -> int:
|
||||
total = _video_duration_seconds(video_path)
|
||||
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
|
||||
if keyframes_only:
|
||||
cmd += ["-skip_frame", "nokey"] # input option: decode only keyframes
|
||||
cmd += ["-i", video_path]
|
||||
vf = _build_vf(step, keyframes_only, max_dim)
|
||||
if vf:
|
||||
cmd += ["-vf", vf]
|
||||
cmd += ["-vsync", "0", "-q:v", str(_quality_to_qscale(jpg_quality)),
|
||||
"-progress", "pipe:1", str(out / "%06d.jpg")]
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL, text=True, bufsize=1,
|
||||
)
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
if progress is None:
|
||||
continue
|
||||
line = line.strip()
|
||||
if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
|
||||
raw = line.split("=", 1)[1]
|
||||
try:
|
||||
# out_time_us is microseconds; out_time_ms is *also* microseconds
|
||||
# in ffmpeg (historical misnomer). Both -> seconds via /1e6.
|
||||
done = int(raw) / 1_000_000 if raw.isdigit() else 0.0
|
||||
except ValueError:
|
||||
done = 0.0
|
||||
if progress(done, total) is False:
|
||||
proc.terminate()
|
||||
break
|
||||
finally:
|
||||
proc.wait()
|
||||
return len(list(out.glob("*.jpg")))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- opencv
|
||||
def _extract_cv2(
|
||||
video_path: str, out: Path, step: int, max_dim: int, jpg_quality: int, progress: Progress | None,
|
||||
) -> int:
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
if not cap.isOpened():
|
||||
raise RuntimeError(f"Не удалось открыть видео: {video_path}")
|
||||
step = max(1, int(step))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
|
||||
total = (cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0) / fps if fps > 0 else 0.0
|
||||
params = [cv2.IMWRITE_JPEG_QUALITY, int(jpg_quality)]
|
||||
idx = saved = 0
|
||||
try:
|
||||
while True:
|
||||
if not cap.grab():
|
||||
break
|
||||
if idx % step == 0:
|
||||
ok, frame = cap.retrieve()
|
||||
if ok:
|
||||
if max_dim and max(frame.shape[:2]) > max_dim:
|
||||
s = max_dim / max(frame.shape[:2])
|
||||
frame = cv2.resize(frame, None, fx=s, fy=s, interpolation=cv2.INTER_AREA)
|
||||
imwrite_unicode(str(out / f"{idx:06d}.jpg"), frame, params)
|
||||
saved += 1
|
||||
idx += 1
|
||||
if progress is not None and idx % 30 == 0:
|
||||
done = idx / fps if fps > 0 else 0.0
|
||||
if progress(done, total) is False:
|
||||
break
|
||||
finally:
|
||||
cap.release()
|
||||
return saved
|
||||
Reference in New Issue
Block a user