Files
sber-med-server/app.py
T
apuc 5331730433 Поддержка хостов с путём (domain.com/v1/giga) в конфигурации
- split_host_path(): разделяет host/path на (scheme, hostname, base_path),
  поддерживает варианты без схемы и с ней
- REST_BASE собирается с учётом пути
- WS: hostname и base_path разделяются, WS_PATH_TEMPLATE включает base_path
- FPG_WS_PORT теперь настраивается через env (было захардкожено 8443)
- fpg_connect.py: варианты URI переписаны на WS_HOSTNAME/WS_BASE_PATH
2026-09-17 15:22:06 +03:00

555 lines
22 KiB
Python

#!/usr/bin/env python3
"""
Веб-интерфейс для анализа видео через SDK ФПГ (GigaDoc).
Пользователь загружает видео, система извлекает кадры,
отправляет их в SDK и показывает понятный диагноз.
Запуск: python3 app.py
Открыть: http://localhost:5000
"""
import asyncio
import glob
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from datetime import datetime
from flask import Flask, Response, jsonify, render_template, request, send_file
# Импортируем модули интерпретации и хранения
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from interpret import interpret_result
import storage
import export
# ─── Конфигурация ─────────────────────────────────────────────
# Секреты читаются из переменных окружения (не хранятся в репозитории).
# На сервере задаются в /etc/systemd/system/med.service (Environment=).
# Хосты стенда можно переопределить через 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", "")
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"
# Параметры обработки видео
FRAMES_COUNT = 300 # количество кадров для анализа
FPS = 30 # частота кадров
FRAME_WIDTH = 1280 # ширина кадра
FRAME_HEIGHT = 720 # высота кадра
JPEG_QUALITY = 5 # качество JPEG (ffmpeg q:v, 2-31, меньше = лучше)
# Временная директория для загрузок
UPLOAD_DIR = os.path.join(tempfile.gettempdir(), "fpg_uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Директория для сохранённых видео (то, что записалось в кружок при сканировании)
VIDEO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "videos")
os.makedirs(VIDEO_DIR, exist_ok=True)
# MIME-типы для скачивания видео
VIDEO_MIME = {
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
".m4v": "video/mp4",
}
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,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("fpg_app")
# ─── HTML-шаблон ──────────────────────────────────────────────
# ─── SDK-функции ──────────────────────────────────────────────
def get_token():
"""Получить access token."""
import requests
resp = requests.post(
OAUTH_URL,
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
timeout=15,
)
if resp.status_code != 200:
raise RuntimeError(f"Ошибка авторизации: {resp.status_code}")
return resp.json()["access_token"]
def create_session(token: str) -> str:
"""Создать сессию, вернуть uid."""
import requests
resp = requests.post(
f"{REST_BASE}/api/v1/predict/new",
params={"device_id": DEVICE_ID, "form_factor": FORM_FACTOR},
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
if resp.status_code != 200:
raise RuntimeError(f"Ошибка создания сессии: {resp.status_code}")
return resp.json()["uid"]
def delete_session(token: str, uid: str):
"""Удалить сессию."""
import requests
try:
requests.delete(
f"{REST_BASE}/api/v1/predict/{uid}",
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
except Exception:
pass
async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS):
"""Отправить кадры через WebSocket, вернуть результат."""
import websockets
uri = f"wss://{WS_HOSTNAME}:{WS_PORT}{WS_PATH_TEMPLATE.format(uid=uid)}"
interval = 1.0 / fps
try:
async with websockets.connect(
uri,
open_timeout=10,
subprotocols=[f"bearer.{token}"],
origin=WS_ORIGIN,
extra_headers={
"Cache-Control": "no-cache",
"Pragma": "no-cache",
},
compression="deflate",
ping_interval=20,
ping_timeout=20,
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:
img_data = f.read()
await ws.send(img_data)
try:
resp = await asyncio.wait_for(ws.recv(), timeout=2.0)
data = json.loads(resp)
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):
pass
elapsed = time.time() - frame_start
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 с нормализацией.
Телефонные видео отличаются от записанных через сайт:
- поворот (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 (с сохранением пропорций и добавлением полей).
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"
)
# Нормализуем 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", ",".join(filters),
"-q:v", str(JPEG_QUALITY),
"-t", "20", # берём первые 20 секунд (достаточно для 300 кадров)
"-frames:v", str(FRAMES_COUNT),
os.path.join(frames_dir, "frame_%04d.jpg"),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
raise RuntimeError(f"Ошибка извлечения кадров: {result.stderr[-500:]}")
frames = sorted(glob.glob(os.path.join(frames_dir, "*.jpg")))
if not frames:
raise RuntimeError("Не удалось извлечь кадры из видео")
return frames
# ─── Flask-маршруты ───────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html")
@app.route("/r/<share_id>")
def shared_result(share_id):
"""Публичная страница результата по ссылке (с OG-тегами для соцсетей)."""
result = storage.get_result(share_id)
if result is None:
return render_template("result.html", result=None, error="Результат не найден", video_exists=False), 404
video_exists = bool(glob.glob(os.path.join(VIDEO_DIR, f"{share_id}.*")))
return render_template("result.html", result=result, error=None, video_exists=video_exists)
@app.route("/download/<share_id>/pdf")
def download_pdf(share_id):
"""Скачать результат в PDF."""
try:
data = export.generate_pdf(share_id)
except ValueError:
return "Результат не найден", 404
except Exception as e:
logger.error(f"Ошибка генерации PDF для {share_id}: {e}")
return "Ошибка генерации PDF", 500
return Response(
data,
mimetype="application/pdf",
headers={"Content-Disposition": f'attachment; filename="diagnostic_{share_id}.pdf"'},
)
@app.route("/download/<share_id>/docx")
def download_docx(share_id):
"""Скачать результат в DOCX."""
try:
data = export.generate_docx(share_id)
except ValueError:
return "Результат не найден", 404
except Exception as e:
logger.error(f"Ошибка генерации DOCX для {share_id}: {e}")
return "Ошибка генерации DOCX", 500
return Response(
data,
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f'attachment; filename="diagnostic_{share_id}.docx"'},
)
@app.route("/download/<video_id>/video")
def download_video(video_id):
"""Скачать видео, записанное при сканировании (то, что было в кружке)."""
matches = glob.glob(os.path.join(VIDEO_DIR, f"{video_id}.*"))
if not matches:
return "Видео не найдено", 404
video_path = matches[0]
ext = os.path.splitext(video_path)[1].lower()
mime = VIDEO_MIME.get(ext, "application/octet-stream")
return send_file(
video_path,
mimetype=mime,
as_attachment=True,
download_name=f"scan_{video_id}{ext}",
)
@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
video = request.files["video"]
if video.filename == "":
return jsonify({"error": "Файл не выбран"}), 400
# Проверяем расширение
allowed = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v"}
ext = os.path.splitext(video.filename)[1].lower()
if ext not in allowed:
return jsonify({"error": f"Неподдерживаемый формат. Разрешены: {', '.join(sorted(allowed))}"}), 400
# Создаём рабочую директорию
job_id = uuid.uuid4().hex[:8]
work_dir = os.path.join(UPLOAD_DIR, job_id)
os.makedirs(work_dir, exist_ok=True)
video_path = os.path.join(work_dir, f"input{ext}")
video.save(video_path)
# Сохраняем копию записанного видео (то, что было в кружке) — даже если анализ упадёт
saved_video = os.path.join(VIDEO_DIR, f"{job_id}{ext}")
try:
shutil.copy2(video_path, saved_video)
except Exception as e:
logger.warning(f"[{job_id}] Не удалось сохранить видео: {e}")
saved_video = None
token = None
uid = None
try:
# 1. Извлекаем кадры
logger.info(f"[{job_id}] Извлечение кадров из {video.filename}")
frames = extract_frames(video_path, work_dir)
logger.info(f"[{job_id}] Извлечено кадров: {len(frames)}")
# 2. Авторизация
logger.info(f"[{job_id}] Авторизация")
token = get_token()
# 3. Создание сессии
logger.info(f"[{job_id}] Создание сессии")
uid = create_session(token)
# 4. Отправка кадров через WebSocket
logger.info(f"[{job_id}] Отправка кадров через WebSocket")
result = asyncio.run(ws_stream(token, uid, frames))
if not result:
video_url = f"/download/{job_id}/video" if saved_video else None
return jsonify({
"error": "Не удалось получить результат. Убедитесь, что на видео чётко видно лицо.",
"video_url": video_url,
}), 500
# 5. Интерпретация
logger.info(f"[{job_id}] Интерпретация результата")
interpretation = interpret_result(result)
# 6. Сохранение результата
logger.info(f"[{job_id}] Сохранение результата")
share_id = storage.save_result(interpretation, result)
share_url = f"https://med.my-dpr.ru/r/{share_id}"
logger.info(f"[{job_id}] Результат сохранён: {share_url}")
# Переименовываем видео под share_id (чтобы ссылка была стабильной)
if saved_video:
final_video = os.path.join(VIDEO_DIR, f"{share_id}{ext}")
try:
os.rename(saved_video, final_video)
saved_video = final_video
except Exception as e:
logger.warning(f"[{job_id}] Не удалось переименовать видео: {e}")
interpretation["share_id"] = share_id
interpretation["share_url"] = share_url
interpretation["video_url"] = f"/download/{share_id}/video" if saved_video else None
return jsonify(interpretation)
except Exception as e:
logger.error(f"[{job_id}] Ошибка: {e}")
video_url = f"/download/{job_id}/video" if saved_video else None
return jsonify({"error": str(e), "video_url": video_url}), 500
finally:
# 6. Удаление сессии
if token and uid:
try:
delete_session(token, uid)
except Exception:
pass
# Очистка временных файлов
try:
shutil.rmtree(work_dir, ignore_errors=True)
except Exception:
pass
if __name__ == "__main__":
print("=" * 60)
print("🩺 Диагностика по видео — ФПГ")
print(f"Откройте в браузере: http://localhost:5000")
print("=" * 60)
app.run(host="0.0.0.0", port=18018, debug=False)