Синхронизация с server/: нормализация видео, овал-подсказка, хосты с путём, DNR-стенд
- app.py: probe_video(), HDR→bt709, fps=30, -t 20, логирование progress/face_ok, split_host_path() для хостов с путём (domain.com/v1/giga), FPG_WS_PORT - templates/index.html: овал-подсказка, canvas 1080×1080, запас 10% - templates/result.html: стилевые правки (цвета) - gigadoc_ws.py: поддержка пути в --sdk-host - test_fpg.py: переведён на DNR-стенд (bearer subprotocol, Origin) - README: хосты с путём, FPG_WS_PORT, нормализация - .gitignore: server/, wordpress/, wordpress-plugin/ (отдельные репозитории)
This commit is contained in:
1 parent
421183b7e3
commit
7375618b1d
9 files changed
+423
-108
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
|
||||
@@ -33,16 +34,39 @@ import export
|
||||
# ─── Конфигурация ─────────────────────────────────────────────
|
||||
# Секреты читаются из переменных окружения (не хранятся в репозитории).
|
||||
# На сервере задаются в /etc/systemd/system/med.service (Environment=).
|
||||
OAUTH_URL = "https://oauth.sbermed.ai/token"
|
||||
CLIENT_ID = os.environ.get("FPG_CLIENT_ID", "client_fpg_front")
|
||||
# Хосты стенда можно переопределить через env (FPG_REST_HOST и т.д.).
|
||||
# ВАЖНО: хост может содержать не только домен, но и путь
|
||||
# (например "domain.com/v1/giga") — это учитывается при сборке URL.
|
||||
OAUTH_URL = os.environ.get("FPG_OAUTH_URL", "https://oauth.sbermed.ai/token")
|
||||
CLIENT_ID = os.environ.get("FPG_CLIENT_ID", "client_fpg_front_dnr")
|
||||
CLIENT_SECRET = os.environ.get("FPG_CLIENT_SECRET", "")
|
||||
|
||||
REST_HOST = "gigadoc-sdk.sbermed.ai"
|
||||
REST_BASE = f"https://{REST_HOST}"
|
||||
|
||||
WS_HOST = "gigadoc-sdk.sbermed.ai"
|
||||
WS_PORT = 8443
|
||||
WS_PATH_TEMPLATE = "/api/v2/ws/predict/{uid}/image"
|
||||
def split_host_path(host: str) -> tuple:
|
||||
"""Разделить 'host/path' на (scheme, hostname, base_path).
|
||||
|
||||
Поддерживает варианты: "domain.com", "domain.com/v1/giga",
|
||||
"https://domain.com/v1/giga". Схема возвращается без "://".
|
||||
"""
|
||||
host = host.strip().rstrip("/")
|
||||
scheme = ""
|
||||
if "://" in host:
|
||||
scheme, host = host.split("://", 1)
|
||||
parts = host.split("/", 1)
|
||||
hostname = parts[0]
|
||||
base_path = "/" + parts[1] if len(parts) > 1 else ""
|
||||
return scheme, hostname, base_path
|
||||
|
||||
|
||||
REST_HOST = os.environ.get("FPG_REST_HOST", "gigadoc-dnr-sdk.sbermed.ai")
|
||||
_rest_scheme, _rest_hostname, _rest_base_path = split_host_path(REST_HOST)
|
||||
REST_BASE = f"{_rest_scheme or 'https'}://{_rest_hostname}{_rest_base_path}"
|
||||
|
||||
WS_HOST = os.environ.get("FPG_WS_HOST", "gigadoc-dnr-sdk.sbermed.ai")
|
||||
WS_PORT = int(os.environ.get("FPG_WS_PORT", "8443"))
|
||||
_ws_scheme, WS_HOSTNAME, WS_BASE_PATH = split_host_path(WS_HOST)
|
||||
WS_PATH_TEMPLATE = f"{WS_BASE_PATH}/api/v2/ws/predict/{{uid}}/image"
|
||||
WS_ORIGIN = os.environ.get("FPG_WS_ORIGIN", "https://gigadoc-dnr-sdk.sbermed.ai")
|
||||
|
||||
DEVICE_ID = "web_prod"
|
||||
FORM_FACTOR = "web_prod"
|
||||
@@ -75,6 +99,23 @@ VIDEO_MIME = {
|
||||
app = Flask(__name__)
|
||||
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 МБ
|
||||
|
||||
# CORS для интеграции с WordPress (кросс-доменные запросы с сайта на API).
|
||||
# Список разрешённых origin через запятую, по умолчанию "*" (любой).
|
||||
CORS_ORIGINS = [o.strip() for o in os.environ.get("CORS_ORIGINS", "*").split(",") if o.strip()]
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_cors_headers(response):
|
||||
origin = request.headers.get("Origin")
|
||||
if "*" in CORS_ORIGINS:
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
elif origin and origin in CORS_ORIGINS:
|
||||
response.headers["Access-Control-Allow-Origin"] = origin
|
||||
response.headers["Vary"] = "Origin"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||
return response
|
||||
|
||||
# ─── Логирование ──────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -134,7 +175,7 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS):
|
||||
"""Отправить кадры через WebSocket, вернуть результат."""
|
||||
import websockets
|
||||
|
||||
uri = f"wss://{WS_HOST}:{WS_PORT}{WS_PATH_TEMPLATE.format(uid=uid)}"
|
||||
uri = f"wss://{WS_HOSTNAME}:{WS_PORT}{WS_PATH_TEMPLATE.format(uid=uid)}"
|
||||
interval = 1.0 / fps
|
||||
|
||||
try:
|
||||
@@ -142,7 +183,7 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS):
|
||||
uri,
|
||||
open_timeout=10,
|
||||
subprotocols=[f"bearer.{token}"],
|
||||
origin="https://gigadoc.sbermed.ai",
|
||||
origin=WS_ORIGIN,
|
||||
extra_headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
@@ -153,6 +194,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:
|
||||
@@ -162,7 +204,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):
|
||||
@@ -172,34 +223,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"),
|
||||
]
|
||||
@@ -284,8 +440,12 @@ def download_video(video_id):
|
||||
)
|
||||
|
||||
|
||||
@app.route("/analyze", methods=["POST"])
|
||||
@app.route("/analyze", methods=["POST", "OPTIONS"])
|
||||
def analyze():
|
||||
# Preflight-запрос CORS (браузер шлёт OPTIONS перед кросс-доменным POST)
|
||||
if request.method == "OPTIONS":
|
||||
return "", 204
|
||||
|
||||
if "video" not in request.files:
|
||||
return jsonify({"error": "Видео не загружено"}), 400
|
||||
|
||||
@@ -391,4 +551,4 @@ if __name__ == "__main__":
|
||||
print("🩺 Диагностика по видео — ФПГ")
|
||||
print(f"Откройте в браузере: http://localhost:5000")
|
||||
print("=" * 60)
|
||||
app.run(host="0.0.0.0", port=8063, debug=False)
|
||||
app.run(host="0.0.0.0", port=18018, debug=False)
|
||||
Reference in new issue
Block a user