From e7f13f1c83537aac6f318e40939b2c4f931f5e3b Mon Sep 17 00:00:00 2001 From: Kavalar Date: Thu, 17 Sep 2026 14:58:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=B8=D0=B7=D0=B2=D0=BB=D0=B5=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=B0=D0=B4=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=20(HDR=E2=86=92bt709,=20=D0=BF=D0=BE=D0=B2=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D1=82,=20FPS)=20+=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20progress=20SDK;=20=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=201080=C3=971080?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 132 ++++++++++++++++++++++++++++++++++++++++--- templates/index.html | 12 ++-- 2 files changed, 130 insertions(+), 14 deletions(-) diff --git a/app.py b/app.py index 4feebdc..43b2a47 100644 --- a/app.py +++ b/app.py @@ -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"), ] diff --git a/templates/index.html b/templates/index.html index 2bdd536..fb571b0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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;