Нормализация видео при извлечении кадров (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 json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -172,6 +173,7 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS):
|
|||||||
max_size=None,
|
max_size=None,
|
||||||
) as ws:
|
) as ws:
|
||||||
result = None
|
result = None
|
||||||
|
last_progress = -1
|
||||||
for i, frame_file in enumerate(frame_files):
|
for i, frame_file in enumerate(frame_files):
|
||||||
frame_start = time.time()
|
frame_start = time.time()
|
||||||
with open(frame_file, "rb") as f:
|
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:
|
try:
|
||||||
resp = await asyncio.wait_for(ws.recv(), timeout=2.0)
|
resp = await asyncio.wait_for(ws.recv(), timeout=2.0)
|
||||||
data = json.loads(resp)
|
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
|
result = data
|
||||||
break
|
break
|
||||||
except (asyncio.TimeoutError, json.JSONDecodeError):
|
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:
|
if elapsed < interval:
|
||||||
await asyncio.sleep(interval - elapsed)
|
await asyncio.sleep(interval - elapsed)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Финальный результат не получен после {len(frame_files)} кадров"
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WebSocket error: {e}")
|
logger.error(f"WebSocket error: {e}")
|
||||||
return None
|
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:
|
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")
|
frames_dir = os.path.join(work_dir, "frames")
|
||||||
os.makedirs(frames_dir, exist_ok=True)
|
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 (с сохранением пропорций и добавлением полей).
|
# Масштабируем до 1280x720 (с сохранением пропорций и добавлением полей).
|
||||||
# format=yuv420p + bt709 стандартизируют цвета (важно для iPhone MOV с P3/HDR,
|
filters.append(
|
||||||
# иначе кадры получаются с искажёнными цветами и SDK не находит лицо).
|
|
||||||
vf = (
|
|
||||||
f"scale={FRAME_WIDTH}:{FRAME_HEIGHT}:force_original_aspect_ratio=decrease,"
|
f"scale={FRAME_WIDTH}:{FRAME_HEIGHT}:force_original_aspect_ratio=decrease,"
|
||||||
f"pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2,"
|
f"pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2"
|
||||||
f"format=yuv420p"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Нормализуем FPS (телефонные видео часто с переменным FPS).
|
||||||
|
filters.append("fps=30")
|
||||||
|
|
||||||
|
# 8-бит yuv420p (SDK ожидает такой формат).
|
||||||
|
filters.append("format=yuv420p")
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg", "-y",
|
"ffmpeg", "-y",
|
||||||
"-i", video_path,
|
"-i", video_path,
|
||||||
"-color_primaries", "bt709",
|
"-color_primaries", "bt709",
|
||||||
"-color_trc", "bt709",
|
"-color_trc", "bt709",
|
||||||
"-colorspace", "bt709",
|
"-colorspace", "bt709",
|
||||||
"-vf", vf,
|
"-vf", ",".join(filters),
|
||||||
"-q:v", str(JPEG_QUALITY),
|
"-q:v", str(JPEG_QUALITY),
|
||||||
|
"-t", "20", # берём первые 20 секунд (достаточно для 300 кадров)
|
||||||
"-frames:v", str(FRAMES_COUNT),
|
"-frames:v", str(FRAMES_COUNT),
|
||||||
os.path.join(frames_dir, "frame_%04d.jpg"),
|
os.path.join(frames_dir, "frame_%04d.jpg"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -656,7 +656,7 @@
|
|||||||
video: {
|
video: {
|
||||||
facingMode: 'user',
|
facingMode: 'user',
|
||||||
width: { ideal: 1280 },
|
width: { ideal: 1280 },
|
||||||
height: { ideal: 720 }
|
height: { ideal: 1280 }
|
||||||
},
|
},
|
||||||
audio: false
|
audio: false
|
||||||
});
|
});
|
||||||
@@ -676,11 +676,11 @@
|
|||||||
scanLastFrameTime = 0;
|
scanLastFrameTime = 0;
|
||||||
updateScanProgress(0);
|
updateScanProgress(0);
|
||||||
|
|
||||||
// Canvas 720×720 — квадрат, лицо целиком влезает无论是 portrait или landscape.
|
// Canvas 1080×1080 — квадрат, лицо целиком влезает в portrait и landscape.
|
||||||
// Серверное extract_frames масштабирует до 1280×720 для SDK.
|
// Серверное extract_frames масштабирует до 1280×720 для SDK.
|
||||||
scanCanvas = document.createElement('canvas');
|
scanCanvas = document.createElement('canvas');
|
||||||
scanCanvas.width = 720;
|
scanCanvas.width = 1080;
|
||||||
scanCanvas.height = 720;
|
scanCanvas.height = 1080;
|
||||||
scanCtx = scanCanvas.getContext('2d');
|
scanCtx = scanCanvas.getContext('2d');
|
||||||
|
|
||||||
let captureStream;
|
let captureStream;
|
||||||
@@ -726,9 +726,9 @@
|
|||||||
const side = Math.min(vw, vh);
|
const side = Math.min(vw, vh);
|
||||||
const sx = (vw - side) / 2;
|
const sx = (vw - side) / 2;
|
||||||
const sy = (vh - 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 {
|
} else {
|
||||||
scanCtx.drawImage(scanVideo, 0, 0, 720, 720);
|
scanCtx.drawImage(scanVideo, 0, 0, 1080, 1080);
|
||||||
}
|
}
|
||||||
scanFrameCount++;
|
scanFrameCount++;
|
||||||
scanLastFrameTime = ts;
|
scanLastFrameTime = ts;
|
||||||
|
|||||||
Reference in new issue
Block a user