Files
apuc 04a7b31a75 Веб-приложение диагностики по видео (ФПГ GigaDoc)
- Загрузка/сканирование видео, извлечение кадров, анализ через SDK ФПГ
- Понятный диагноз с сохранением в SQLite и share-ссылками
- Экспорт PDF/DOCX, скачивание записанного видео
- Камерное сканирование на мобильных (canvas + MediaRecorder)
- Секреты вынесены в переменные окружения
2026-09-07 19:01:06 +03:00

395 lines
15 KiB
Python
Raw Permalink 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 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/<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"])
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)