- Загрузка/сканирование видео, извлечение кадров, анализ через SDK ФПГ - Понятный диагноз с сохранением в SQLite и share-ссылками - Экспорт PDF/DOCX, скачивание записанного видео - Камерное сканирование на мобильных (canvas + MediaRecorder) - Секреты вынесены в переменные окружения
395 lines
15 KiB
Python
395 lines
15 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 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)
|