#!/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 # Импортируем модули интерпретации и хранения 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) 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/") def shared_result(share_id): """Публичная страница результата по ссылке (с OG-тегами для соцсетей).""" result = storage.get_result(share_id) if result is None: return render_template("result.html", result=None, error="Результат не найден", base_url=BASE_URL), 404 if storage.is_expired(result["created_at"]): return render_template("result.html", result=None, error="Ссылка устарела. Результат доступен в течение 30 минут после сканирования.", base_url=BASE_URL), 410 return render_template("result.html", result=result, error=None, base_url=BASE_URL) @app.route("/download//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//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("/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 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: return jsonify({ "error": "Не удалось получить результат. Убедитесь, что на видео чётко видно лицо.", }), 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}") interpretation["share_id"] = share_id interpretation["share_url"] = share_url return jsonify(interpretation) except Exception as e: logger.error(f"[{job_id}] Ошибка: {e}") return jsonify({"error": str(e)}), 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)