Нормализация видео при извлечении кадров (HDR→bt709, поворот, FPS) + логирование progress SDK; камера 1080×1080

This commit is contained in:
apuc committed 2026-09-17 14:58:44 +03:00
1 parent 93c9d88897
commit e7f13f1c83
2 files changed
+130 -14

No files matched your search

+124 -8
View File
@@ -14,6 +14,7 @@ import glob
import json
import logging
import os
import re
import shutil
import subprocess
import sys
@@ -172,6 +173,7 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS):
max_size=None,
) as ws:
result = None
last_progress = -1
for i, frame_file in enumerate(frame_files):
frame_start = time.time()
with open(frame_file, "rb") as f:
@@ -181,7 +183,16 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS):
try:
resp = await asyncio.wait_for(ws.recv(), timeout=2.0)
data = json.loads(resp)
if data.get("progress", 0) >= 1.0:
progress = data.get("progress", 0.0)
# Логируем прогресс при изменении (для диагностики)
pct = int(progress * 100)
if pct != last_progress:
logger.info(
f" [{i+1}/{len(frame_files)}] progress={progress:.2f} "
f"face_ok={data.get('face_ok')}"
)
last_progress = pct
if progress >= 1.0:
result = data
break
except (asyncio.TimeoutError, json.JSONDecodeError):
@@ -191,34 +202,139 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS):
if elapsed < interval:
await asyncio.sleep(interval - elapsed)
if result is None:
logger.warning(
f"Финальный результат не получен после {len(frame_files)} кадров"
)
return result
except Exception as e:
logger.error(f"WebSocket error: {e}")
return None
def probe_video(video_path: str) -> dict:
"""Получить информацию о видео через ffprobe (кодек, цвета, поворот, FPS)."""
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries",
"stream=codec_name,width,height,pix_fmt,avg_frame_rate,color_space,color_transfer,color_primaries",
"-of", "json",
video_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"Ошибка чтения видео: {result.stderr[-300:]}")
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return {}
streams = data.get("streams") or [{}]
info = streams[0] if streams else {}
# Поворот: в ffmpeg 5+ это stream_side_data (Display Matrix),
# в 4.x — тег rotate. Пробуем оба варианта.
try:
r = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream_side_data=rotation", "-of", "json", video_path],
capture_output=True, text=True, timeout=30,
)
if r.returncode == 0:
sd = (json.loads(r.stdout).get("streams") or [{}])[0].get("side_data_list") or []
for item in sd:
if "rotation" in item:
info["rotation"] = item["rotation"]
except Exception:
pass
if "rotation" not in info:
try:
r = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream_tags=rotate", "-of", "json", video_path],
capture_output=True, text=True, timeout=30,
)
if r.returncode == 0:
tags = (json.loads(r.stdout).get("streams") or [{}])[0].get("tags") or {}
if "rotate" in tags:
info["rotation"] = tags["rotate"]
except Exception:
pass
return info
_FFMPEG_MAJOR = None
def ffmpeg_major_version() -> int:
"""Версия ffmpeg (кэшируется). Нужна для выбора параметров colorspace."""
global _FFMPEG_MAJOR
if _FFMPEG_MAJOR is None:
try:
r = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True, timeout=10)
m = re.search(r"ffmpeg version (\d+)\.", r.stdout)
_FFMPEG_MAJOR = int(m.group(1)) if m else 0
except Exception:
_FFMPEG_MAJOR = 0
return _FFMPEG_MAJOR
def extract_frames(video_path: str, work_dir: str) -> list:
"""Извлечь кадры из видео в JPEG."""
"""Извлечь кадры из видео в JPEG с нормализацией.
Телефонные видео отличаются от записанных через сайт:
- поворот (rotation metadata) — ffmpeg применяет autorotate по умолчанию;
- HDR/P3 (iPhone: bt2020 + smpte2084) — конвертируем в bt709, иначе кадры
выходят с искажёнными цветами и SDK не находит лицо;
- переменный FPS — нормализуем до 30.
"""
frames_dir = os.path.join(work_dir, "frames")
os.makedirs(frames_dir, exist_ok=True)
info = probe_video(video_path)
color_space = info.get("color_space") or ""
color_transfer = info.get("color_transfer") or ""
color_primaries = info.get("color_primaries") or ""
filters = []
# HDR/P3 → SDR (bt709): реальная конверсия цветов (не только теги).
# Без неё кадры из iPhone-видео (bt2020/P3, HDR) выглядят «выцветшими»
# и SDK не находит лицо.
is_hdr = color_transfer in ("smpte2084", "arib-std-b67") or color_primaries in ("bt2020", "smpte432")
if is_hdr:
if ffmpeg_major_version() >= 5:
# ffmpeg 5+ знает smpte2084/arib-std-b67 — точная конверсия.
ispace = color_space if color_space in ("bt2020nc", "bt2020c") else "bt2020nc"
itrc = color_transfer if color_transfer in ("smpte2084", "arib-std-b67") else "bt709"
iprimaries = color_primaries if color_primaries in ("bt2020", "smpte432") else "bt2020"
else:
# ffmpeg 4.x: нет smpte2084/arib-std-b67 в colorspace (tonemap даёт
# чёрный кадр) — используем приблизительные значения bt2020.
ispace, itrc, iprimaries = "bt2020nc", "bt2020-10", "bt2020"
filters.append(f"colorspace=bt709:ispace={ispace}:itrc={itrc}:iprimaries={iprimaries}")
# Масштабируем до 1280x720 (с сохранением пропорций и добавлением полей).
# format=yuv420p + bt709 стандартизируют цвета (важно для iPhone MOV с P3/HDR,
# иначе кадры получаются с искажёнными цветами и SDK не находит лицо).
vf = (
filters.append(
f"scale={FRAME_WIDTH}:{FRAME_HEIGHT}:force_original_aspect_ratio=decrease,"
f"pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2,"
f"format=yuv420p"
f"pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2"
)
# Нормализуем FPS (телефонные видео часто с переменным FPS).
filters.append("fps=30")
# 8-бит yuv420p (SDK ожидает такой формат).
filters.append("format=yuv420p")
cmd = [
"ffmpeg", "-y",
"-i", video_path,
"-color_primaries", "bt709",
"-color_trc", "bt709",
"-colorspace", "bt709",
"-vf", vf,
"-vf", ",".join(filters),
"-q:v", str(JPEG_QUALITY),
"-t", "20", # берём первые 20 секунд (достаточно для 300 кадров)
"-frames:v", str(FRAMES_COUNT),
os.path.join(frames_dir, "frame_%04d.jpg"),
]
+6 -6
View File
@@ -656,7 +656,7 @@
video: {
facingMode: 'user',
width: { ideal: 1280 },
height: { ideal: 720 }
height: { ideal: 1280 }
},
audio: false
});
@@ -676,11 +676,11 @@
scanLastFrameTime = 0;
updateScanProgress(0);
// Canvas 720×720 — квадрат, лицо целиком влезает无论是 portrait или landscape.
// Canvas 1080×1080 — квадрат, лицо целиком влезает в portrait и landscape.
// Серверное extract_frames масштабирует до 1280×720 для SDK.
scanCanvas = document.createElement('canvas');
scanCanvas.width = 720;
scanCanvas.height = 720;
scanCanvas.width = 1080;
scanCanvas.height = 1080;
scanCtx = scanCanvas.getContext('2d');
let captureStream;
@@ -726,9 +726,9 @@
const side = Math.min(vw, vh);
const sx = (vw - side) / 2;
const sy = (vh - side) / 2;
scanCtx.drawImage(scanVideo, sx, sy, side, side, 0, 0, 720, 720);
scanCtx.drawImage(scanVideo, sx, sy, side, side, 0, 0, 1080, 1080);
} else {
scanCtx.drawImage(scanVideo, 0, 0, 720, 720);
scanCtx.drawImage(scanVideo, 0, 0, 1080, 1080);
}
scanFrameCount++;
scanLastFrameTime = ts;