Добавлено описание и документация для HVideoTool, включая функционал, требования, установку и запуск приложения для обнаружения цензуры на изображениях.

This commit is contained in:
Leonid Pershin
2026-06-06 15:11:53 +03:00
parent 10bf87aa47
commit 33f20fe681
28 changed files with 2256 additions and 0 deletions
+168
View File
@@ -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