#!/usr/bin/env python3 """ Веб-интерфейс для анализа видео через SDK ФПГ (GigaDoc). Пользователь загружает видео, система извлекает кадры, отправляет их в SDK и показывает понятный диагноз. Запуск: python3 app.py Открыть: http://localhost:5000 """ import asyncio import glob import json import logging import os 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=). OAUTH_URL = "https://oauth.sbermed.ai/token" CLIENT_ID = os.environ.get("FPG_CLIENT_ID", "client_fpg_front") 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" 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 МБ # ─── Логирование ────────────────────────────────────────────── 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_HOST}:{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="https://gigadoc.sbermed.ai", extra_headers={ "Cache-Control": "no-cache", "Pragma": "no-cache", }, compression="deflate", ping_interval=20, ping_timeout=20, max_size=None, ) as ws: result = None 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) if data.get("progress", 0) >= 1.0: result = data break except (asyncio.TimeoutError, json.JSONDecodeError): pass elapsed = time.time() - frame_start if elapsed < interval: await asyncio.sleep(interval - elapsed) return result except Exception as e: logger.error(f"WebSocket error: {e}") return None def extract_frames(video_path: str, work_dir: str) -> list: """Извлечь кадры из видео в JPEG.""" frames_dir = os.path.join(work_dir, "frames") os.makedirs(frames_dir, exist_ok=True) # Масштабируем до 1280x720 (с сохранением пропорций и добавлением полей). # format=yuv420p + bt709 стандартизируют цвета (важно для iPhone MOV с P3/HDR, # иначе кадры получаются с искажёнными цветами и SDK не находит лицо). vf = ( 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" ) cmd = [ "ffmpeg", "-y", "-i", video_path, "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709", "-vf", vf, "-q:v", str(JPEG_QUALITY), "-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="Результат не найден", 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//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//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") 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"]) def analyze(): 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=8063, debug=False)