Files
sber-med-server/app.py
T

622 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
# Загружаем переменные из .env (если файл есть рядом с app.py).
# Значения из реального окружения (systemd Environment=) имеют приоритет.
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
except ImportError:
pass
# ─── Конфигурация ─────────────────────────────────────────────
# Секреты читаются из переменных окружения (не хранятся в репозитории).
# На сервере задаются в /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 МБ
# Базовый URL для share-ссылок и OG-тегов.
# По умолчанию — прод-домен; для локального запуска задайте BASE_URL,
# например https://192.168.31.248:18018
BASE_URL = os.environ.get("BASE_URL", "https://med.my-dpr.ru").rstrip("/")
# CORS для интеграции с WordPress (кросс-доменные запросы с сайта на API).
# Список разрешённых origin через запятую, по умолчанию "*" (любой).
CORS_ORIGINS = [o.strip() for o in os.environ.get("CORS_ORIGINS", "*").split(",") if o.strip()]
# Обработчики ошибок: всегда возвращаем JSON, чтобы фронтенд
# не получал HTML-страницу (иначе response.json() падает с "Unexpected token '<'").
@app.errorhandler(413)
def too_large(e):
return jsonify({"error": "Видео слишком большое (максимум 100 МБ)"}), 413
@app.errorhandler(404)
def not_found(e):
return jsonify({"error": "Не найдено"}), 404
@app.errorhandler(500)
def server_error(e):
logger.error(f"Необработанная ошибка: {e}")
return jsonify({"error": "Внутренняя ошибка сервера"}), 500
@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, base_url=BASE_URL), 404
if storage.is_expired(result["created_at"]):
return render_template("result.html", result=None, error="Ссылка устарела. Результат доступен в течение 30 минут после сканирования.", video_exists=False, base_url=BASE_URL), 410
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, base_url=BASE_URL)
@app.route("/download/<share_id>/pdf")
def download_pdf(share_id):
"""Скачать результат в PDF."""
result = storage.get_result(share_id)
if result is None:
return "Результат не найден", 404
if storage.is_expired(result["created_at"]):
return "Ссылка устарела. Результат доступен в течение 30 минут.", 410
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."""
result = storage.get_result(share_id)
if result is None:
return "Результат не найден", 404
if storage.is_expired(result["created_at"]):
return "Ссылка устарела. Результат доступен в течение 30 минут.", 410
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):
"""Скачать видео, записанное при сканировании (то, что было в кружке)."""
# Если video_id соответствует результату в БД — проверяем срок действия.
result = storage.get_result(video_id)
if result is not None and storage.is_expired(result["created_at"]):
return "Ссылка устарела. Результат доступен в течение 30 минут.", 410
matches = glob.glob(os.path.join(VIDEO_DIR, f"{video_id}.*"))
if not matches:
return "Видео не найдено", 404
video_path = matches[0]
# Если результата в БД нет (неудачный анализ) — проверяем время создания файла.
if result is None:
mtime = datetime.fromtimestamp(os.path.getmtime(video_path))
if datetime.now() - mtime > storage.RESULT_TTL:
return "Ссылка устарела. Видео доступно в течение 30 минут.", 410
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}")
try:
video.save(video_path)
except Exception as e:
logger.error(f"[{job_id}] Ошибка сохранения видео: {e}")
return jsonify({"error": f"Не удалось сохранить видео: {e}"}), 500
# Сохраняем копию записанного видео (то, что было в кружке) — даже если анализ упадёт
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"{BASE_URL}/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)
# HTTPS (для доступа к камере из локальной сети нужен безопасный контекст).
# Задайте CERT_FILE и KEY_FILE (самоподписанный сертификат) — сервер поднимется по HTTPS.
cert_file = os.environ.get("CERT_FILE")
key_file = os.environ.get("KEY_FILE")
ssl_context = None
if cert_file and key_file:
ssl_context = (cert_file, key_file)
print(f"HTTPS включён: {cert_file}")
app.run(host="0.0.0.0", port=18018, debug=False, ssl_context=ssl_context)