Нормализация видео при извлечении кадров (HDR→bt709, поворот, FPS) + логирование progress SDK; камера 1080×1080
This commit is contained in:
1 parent
93c9d88897
commit
e7f13f1c83
2 files changed
+130
-14
No files matched your search
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user