Веб-приложение диагностики по видео (ФПГ GigaDoc)
- Загрузка/сканирование видео, извлечение кадров, анализ через SDK ФПГ - Понятный диагноз с сохранением в SQLite и share-ссылками - Экспорт PDF/DOCX, скачивание записанного видео - Камерное сканирование на мобильных (canvas + MediaRecorder) - Секреты вынесены в переменные окружения
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Секреты и локальные данные
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
videos/
|
||||||
|
|
||||||
|
# Логи (могут содержать токены SDK)
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Временные и рабочие файлы
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
frames/
|
||||||
|
.opencode/
|
||||||
|
|
||||||
|
# Тестовые медиафайлы
|
||||||
|
video.mp4
|
||||||
|
*.webm
|
||||||
|
*.mp4
|
||||||
|
*.mov
|
||||||
|
|
||||||
|
# Документация стороннего SDK
|
||||||
|
*.pdf
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
#!/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)
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Генерация PDF и DOCX отчётов из сохранённого результата диагностики.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.units import mm
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
from reportlab.platypus import (
|
||||||
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
|
||||||
|
)
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.enums import TA_CENTER
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt, Cm, RGBColor
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
|
||||||
|
# --- Шрифты для кириллицы в PDF ---
|
||||||
|
FONT_PATHS = {
|
||||||
|
"DejaVuSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"DejaVuSans-Bold": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
"DejaVuSans-Oblique": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, path in FONT_PATHS.items():
|
||||||
|
try:
|
||||||
|
pdfmetrics.registerFont(TTFont(name, path))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
STATUS_TEXT = {
|
||||||
|
"ok": "В норме",
|
||||||
|
"warning": "Обратить внимание",
|
||||||
|
"danger": "Отклонение",
|
||||||
|
"info": "Информация",
|
||||||
|
}
|
||||||
|
|
||||||
|
STATUS_COLORS = {
|
||||||
|
"ok": colors.HexColor("#155724"),
|
||||||
|
"warning": colors.HexColor("#856404"),
|
||||||
|
"danger": colors.HexColor("#721c24"),
|
||||||
|
"info": colors.HexColor("#0c5460"),
|
||||||
|
}
|
||||||
|
|
||||||
|
STATUS_BG = {
|
||||||
|
"ok": colors.HexColor("#d4edda"),
|
||||||
|
"warning": colors.HexColor("#fff3cd"),
|
||||||
|
"danger": colors.HexColor("#f8d7da"),
|
||||||
|
"info": colors.HexColor("#d1ecf1"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_result(share_id):
|
||||||
|
"""Загрузить результат из БД (импорт на месте, чтобы не было циклических зависимостей)."""
|
||||||
|
import storage
|
||||||
|
return storage.get_result(share_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_list(raw):
|
||||||
|
"""Вернуть список из БД (get_result уже возвращает распарсенные списки)."""
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
return json.loads(raw) if raw else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ PDF ============================
|
||||||
|
|
||||||
|
def generate_pdf(share_id) -> bytes:
|
||||||
|
"""Сгенерировать PDF-отчёт. Возвращает байты."""
|
||||||
|
result = _load_result(share_id)
|
||||||
|
if result is None:
|
||||||
|
raise ValueError("Результат не найден")
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
buf, pagesize=A4,
|
||||||
|
leftMargin=20 * mm, rightMargin=20 * mm,
|
||||||
|
topMargin=18 * mm, bottomMargin=18 * mm,
|
||||||
|
title=f"Результат диагностики #{share_id}",
|
||||||
|
author="med.my-dpr.ru",
|
||||||
|
)
|
||||||
|
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
styles.add(ParagraphStyle(
|
||||||
|
name="TitleRu", fontName="DejaVuSans-Bold", fontSize=18,
|
||||||
|
leading=24, alignment=TA_CENTER, spaceAfter=4,
|
||||||
|
))
|
||||||
|
styles.add(ParagraphStyle(
|
||||||
|
name="SubtitleRu", fontName="DejaVuSans", fontSize=10,
|
||||||
|
leading=14, alignment=TA_CENTER, textColor=colors.grey, spaceAfter=14,
|
||||||
|
))
|
||||||
|
styles.add(ParagraphStyle(
|
||||||
|
name="H2Ru", fontName="DejaVuSans-Bold", fontSize=13,
|
||||||
|
leading=18, spaceBefore=12, spaceAfter=6,
|
||||||
|
))
|
||||||
|
styles.add(ParagraphStyle(
|
||||||
|
name="BodyRu", fontName="DejaVuSans", fontSize=10.5,
|
||||||
|
leading=16, spaceAfter=6,
|
||||||
|
))
|
||||||
|
styles.add(ParagraphStyle(
|
||||||
|
name="SmallRu", fontName="DejaVuSans", fontSize=8.5,
|
||||||
|
leading=12, textColor=colors.grey,
|
||||||
|
))
|
||||||
|
|
||||||
|
story = []
|
||||||
|
|
||||||
|
# Заголовок
|
||||||
|
story.append(Paragraph("🩺 Диагностика по видео", styles["TitleRu"]))
|
||||||
|
story.append(Paragraph(
|
||||||
|
f"Результат #{result['share_id']} • {result['created_at'][:10]} {result['created_at'][11:16]}",
|
||||||
|
styles["SubtitleRu"],
|
||||||
|
))
|
||||||
|
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#667eea"), spaceAfter=12))
|
||||||
|
|
||||||
|
# Сводка
|
||||||
|
story.append(Paragraph("Сводка", styles["H2Ru"]))
|
||||||
|
story.append(Paragraph(result["summary"], styles["BodyRu"]))
|
||||||
|
|
||||||
|
# Предупреждения
|
||||||
|
warnings = _parse_json_list(result["warnings"])
|
||||||
|
if warnings:
|
||||||
|
story.append(Paragraph("⚠️ Требует внимания", styles["H2Ru"]))
|
||||||
|
for w in warnings:
|
||||||
|
story.append(Paragraph(f"• {w}", styles["BodyRu"]))
|
||||||
|
|
||||||
|
# Показатели
|
||||||
|
metrics = _parse_json_list(result["metrics"])
|
||||||
|
if metrics:
|
||||||
|
story.append(Paragraph("Показатели", styles["H2Ru"]))
|
||||||
|
data = [["Показатель", "Значение", "Оценка"]]
|
||||||
|
for m in metrics:
|
||||||
|
value = m.get("value", "")
|
||||||
|
comment = m.get("comment", "")
|
||||||
|
if comment:
|
||||||
|
value = f"{value} ({comment})"
|
||||||
|
status = m.get("status", "info")
|
||||||
|
data.append([
|
||||||
|
m.get("name", ""),
|
||||||
|
value,
|
||||||
|
STATUS_TEXT.get(status, status),
|
||||||
|
])
|
||||||
|
table = Table(data, colWidths=[55 * mm, 75 * mm, 50 * mm])
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
("FONTNAME", (0, 0), (-1, 0), "DejaVuSans-Bold"),
|
||||||
|
("FONTNAME", (0, 1), (-1, -1), "DejaVuSans"),
|
||||||
|
("FONTSIZE", (0, 0), (-1, -1), 9.5),
|
||||||
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#667eea")),
|
||||||
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||||
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f6ff")]),
|
||||||
|
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cccccc")),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
]))
|
||||||
|
story.append(table)
|
||||||
|
|
||||||
|
# Рекомендации
|
||||||
|
advice = _parse_json_list(result["advice"])
|
||||||
|
if advice:
|
||||||
|
story.append(Paragraph("💡 Рекомендации", styles["H2Ru"]))
|
||||||
|
for a in advice:
|
||||||
|
story.append(Paragraph(f"• {a}", styles["BodyRu"]))
|
||||||
|
|
||||||
|
story.append(Spacer(1, 10))
|
||||||
|
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#dddddd")))
|
||||||
|
story.append(Spacer(1, 6))
|
||||||
|
story.append(Paragraph(
|
||||||
|
"⚠️ Результат получен автоматически по анализу видео и не является медицинским диагнозом. "
|
||||||
|
"Для точной оценки обратитесь к врачу.",
|
||||||
|
styles["SmallRu"],
|
||||||
|
))
|
||||||
|
|
||||||
|
doc.build(story)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ DOCX ============================
|
||||||
|
|
||||||
|
def generate_docx(share_id) -> bytes:
|
||||||
|
"""Сгенерировать DOCX-отчёт. Возвращает байты."""
|
||||||
|
result = _load_result(share_id)
|
||||||
|
if result is None:
|
||||||
|
raise ValueError("Результат не найден")
|
||||||
|
|
||||||
|
doc = Document()
|
||||||
|
|
||||||
|
# Стили
|
||||||
|
style = doc.styles["Normal"]
|
||||||
|
style.font.name = "Calibri"
|
||||||
|
style.font.size = Pt(11)
|
||||||
|
|
||||||
|
# Заголовок
|
||||||
|
title = doc.add_heading("🩺 Диагностика по видео", level=0)
|
||||||
|
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
for run in title.runs:
|
||||||
|
run.font.color.rgb = RGBColor(0x66, 0x7E, 0xEA)
|
||||||
|
|
||||||
|
sub = doc.add_paragraph()
|
||||||
|
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = sub.add_run(
|
||||||
|
f"Результат #{result['share_id']} • {result['created_at'][:10]} {result['created_at'][11:16]}"
|
||||||
|
)
|
||||||
|
run.font.size = Pt(10)
|
||||||
|
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
|
||||||
|
|
||||||
|
# Сводка
|
||||||
|
doc.add_heading("Сводка", level=1)
|
||||||
|
doc.add_paragraph(result["summary"])
|
||||||
|
|
||||||
|
# Предупреждения
|
||||||
|
warnings = _parse_json_list(result["warnings"])
|
||||||
|
if warnings:
|
||||||
|
doc.add_heading("⚠️ Требует внимания", level=1)
|
||||||
|
for w in warnings:
|
||||||
|
doc.add_paragraph(w, style="List Bullet")
|
||||||
|
|
||||||
|
# Показатели
|
||||||
|
metrics = _parse_json_list(result["metrics"])
|
||||||
|
if metrics:
|
||||||
|
doc.add_heading("Показатели", level=1)
|
||||||
|
table = doc.add_table(rows=1, cols=3)
|
||||||
|
table.style = "Light Grid Accent 1"
|
||||||
|
hdr = table.rows[0].cells
|
||||||
|
for i, h in enumerate(["Показатель", "Значение", "Оценка"]):
|
||||||
|
hdr[i].text = h
|
||||||
|
for p in hdr[i].paragraphs:
|
||||||
|
for r in p.runs:
|
||||||
|
r.font.bold = True
|
||||||
|
for m in metrics:
|
||||||
|
row = table.add_row().cells
|
||||||
|
value = m.get("value", "")
|
||||||
|
comment = m.get("comment", "")
|
||||||
|
if comment:
|
||||||
|
value = f"{value} ({comment})"
|
||||||
|
row[0].text = m.get("name", "")
|
||||||
|
row[1].text = value
|
||||||
|
row[2].text = STATUS_TEXT.get(m.get("status", "info"), m.get("status", ""))
|
||||||
|
|
||||||
|
# Рекомендации
|
||||||
|
advice = _parse_json_list(result["advice"])
|
||||||
|
if advice:
|
||||||
|
doc.add_heading("💡 Рекомендации", level=1)
|
||||||
|
for a in advice:
|
||||||
|
doc.add_paragraph(a, style="List Bullet")
|
||||||
|
|
||||||
|
# Дисклеймер
|
||||||
|
doc.add_paragraph()
|
||||||
|
disc = doc.add_paragraph()
|
||||||
|
run = disc.add_run(
|
||||||
|
"⚠️ Результат получен автоматически по анализу видео и не является медицинским диагнозом. "
|
||||||
|
"Для точной оценки обратитесь к врачу."
|
||||||
|
)
|
||||||
|
run.font.size = Pt(9)
|
||||||
|
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
doc.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
+449
@@ -0,0 +1,449 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Скрипт для подключения к SDK ФПГ (GigaDoc) и тестирования всех этапов.
|
||||||
|
|
||||||
|
Этапы:
|
||||||
|
1. Авторизация (получение access token)
|
||||||
|
2. Создание сессии
|
||||||
|
3. Подключение к WebSocket
|
||||||
|
4. Отправка изображений
|
||||||
|
5. Получение результатов
|
||||||
|
6. Удаление сессии
|
||||||
|
|
||||||
|
Логирование ведётся в файл fpg_test.log
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# ─── Конфигурация ─────────────────────────────────────────────
|
||||||
|
OAUTH_URL = "https://oauth.sbermed.ai/token"
|
||||||
|
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}"
|
||||||
|
|
||||||
|
# WebSocket (рабочий хост подтверждён: gigadoc-sdk.sbermed.ai:8443)
|
||||||
|
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"
|
||||||
|
|
||||||
|
LOG_FILE = "fpg_test.log"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Логирование ──────────────────────────────────────────────
|
||||||
|
def setup_logging(log_file: str = LOG_FILE):
|
||||||
|
"""Настроить логирование в файл и консоль."""
|
||||||
|
logger = logging.getLogger("fpg")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Формат
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Файловый хендлер
|
||||||
|
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
fh.setLevel(logging.DEBUG)
|
||||||
|
fh.setFormatter(formatter)
|
||||||
|
logger.addHandler(fh)
|
||||||
|
|
||||||
|
# Консольный хендлер
|
||||||
|
ch = logging.StreamHandler(sys.stdout)
|
||||||
|
ch.setLevel(logging.INFO)
|
||||||
|
ch.setFormatter(formatter)
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
||||||
|
# Включаем логирование библиотек (requests, websockets, urllib3)
|
||||||
|
logging.getLogger("urllib3").setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger("urllib3").addHandler(fh)
|
||||||
|
logging.getLogger("websockets").setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger("websockets").addHandler(fh)
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.DEBUG)
|
||||||
|
logging.getLogger("asyncio").addHandler(fh)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("fpg")
|
||||||
|
|
||||||
|
|
||||||
|
def log_headers(headers: dict, prefix: str = " "):
|
||||||
|
"""Залогировать HTTP-заголовки (маскируя секреты)."""
|
||||||
|
for k, v in headers.items():
|
||||||
|
if k.lower() in ("authorization", "client_secret", "cookie", "set-cookie"):
|
||||||
|
# Маскируем секретные заголовки
|
||||||
|
if isinstance(v, str) and len(v) > 20:
|
||||||
|
v = v[:20] + "..." + v[-5:]
|
||||||
|
else:
|
||||||
|
v = "***"
|
||||||
|
logger.debug(f"{prefix}{k}: {v}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_response(resp, prefix: str = " "):
|
||||||
|
"""Залогировать HTTP-ответ подробно."""
|
||||||
|
logger.debug(f"{prefix}HTTP {resp.status_code} {resp.reason}")
|
||||||
|
logger.debug(f"{prefix}Headers:")
|
||||||
|
log_headers(dict(resp.headers), prefix + " ")
|
||||||
|
body = resp.text
|
||||||
|
if len(body) > 2000:
|
||||||
|
logger.debug(f"{prefix}Body ({len(body)} bytes): {body[:2000]}...")
|
||||||
|
else:
|
||||||
|
logger.debug(f"{prefix}Body: {body}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Этап 1: Авторизация ──────────────────────────────────────
|
||||||
|
def get_token() -> str:
|
||||||
|
"""Получить access token через client_credentials."""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 1: Авторизация")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(f"POST {OAUTH_URL}")
|
||||||
|
logger.debug(" Запрос:")
|
||||||
|
logger.debug(f" grant_type=client_credentials")
|
||||||
|
logger.debug(f" client_id={CLIENT_ID}")
|
||||||
|
logger.debug(f" client_secret={'*' * len(CLIENT_SECRET)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
OAUTH_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"client_secret": CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
logger.info(f"HTTP {resp.status_code}")
|
||||||
|
logger.debug(" Ответ:")
|
||||||
|
log_response(resp, " ")
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
token = data["access_token"]
|
||||||
|
expires_in = data.get("expires_in", "?")
|
||||||
|
logger.info(f"✅ Токен получен (действует {expires_in} сек)")
|
||||||
|
logger.debug(f" token_type: {data.get('token_type')}")
|
||||||
|
logger.debug(f" scope: {data.get('scope')}")
|
||||||
|
logger.debug(f" refresh_expires_in: {data.get('refresh_expires_in')}")
|
||||||
|
logger.debug(f" not-before-policy: {data.get('not-before-policy')}")
|
||||||
|
logger.debug(f" access_token: {token[:80]}...")
|
||||||
|
logger.debug(f" access_token (полный, {len(token)} chars): {token}")
|
||||||
|
return token
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"❌ Ошибка авторизации: {e}")
|
||||||
|
if hasattr(e, "response") and e.response is not None:
|
||||||
|
logger.error(f" Статус: {e.response.status_code}")
|
||||||
|
logger.error(f" Ответ: {e.response.text}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Этап 2: Создание сессии ──────────────────────────────────
|
||||||
|
def create_session(token: str) -> str:
|
||||||
|
"""Создать сессию и вернуть uid."""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 2: Создание сессии")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
url = f"{REST_BASE}/api/v1/predict/new"
|
||||||
|
logger.info(f"POST {url}")
|
||||||
|
logger.debug(" Запрос:")
|
||||||
|
logger.debug(f" device_id={DEVICE_ID}")
|
||||||
|
logger.debug(f" form_factor={FORM_FACTOR}")
|
||||||
|
logger.debug(f" Authorization: Bearer {token[:30]}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
url,
|
||||||
|
params={"device_id": DEVICE_ID, "form_factor": FORM_FACTOR},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
logger.info(f"HTTP {resp.status_code}")
|
||||||
|
logger.debug(" Ответ:")
|
||||||
|
log_response(resp, " ")
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
uid = resp.json()["uid"]
|
||||||
|
logger.info(f"✅ Сессия создана: {uid}")
|
||||||
|
return uid
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"❌ Ошибка создания сессии: {e}")
|
||||||
|
if hasattr(e, "response") and e.response is not None:
|
||||||
|
logger.error(f" Статус: {e.response.status_code}")
|
||||||
|
logger.error(f" Ответ: {e.response.text}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Этап 3: WebSocket ────────────────────────────────────────
|
||||||
|
async def ws_scan(token: str, uid: str, ws_host: str, ws_port: int, image_dir: str):
|
||||||
|
"""Подключиться по WebSocket, отправить изображения, получить результат."""
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 3: WebSocket подключение")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
# ── DNS резолв ──
|
||||||
|
logger.info(f"DNS резолв: {ws_host}")
|
||||||
|
try:
|
||||||
|
ips = socket.getaddrinfo(ws_host, ws_port)
|
||||||
|
for addr in ips:
|
||||||
|
logger.debug(f" {addr[0].name} -> {addr[4]}")
|
||||||
|
logger.info(f"✅ DNS резолв успешен: {len(ips)} записей")
|
||||||
|
except socket.gaierror as e:
|
||||||
|
logger.warning(f"⚠️ DNS резолв не удался: {e}")
|
||||||
|
logger.warning(f" Хост {ws_host} не найден. Пробуем другие варианты...")
|
||||||
|
|
||||||
|
# ── Собираем изображения ──
|
||||||
|
patterns = ["*.jpg", "*.jpeg", "*.png"]
|
||||||
|
images = []
|
||||||
|
for p in patterns:
|
||||||
|
images.extend(glob.glob(os.path.join(image_dir, p)))
|
||||||
|
images.sort()
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
logger.warning(f"Нет изображений в {image_dir}, использую тестовое")
|
||||||
|
images = ["/tmp/test_face.jpg"]
|
||||||
|
if not os.path.exists(images[0]):
|
||||||
|
logger.error("Тестовое изображение не найдено")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(f"Найдено изображений: {len(images)}")
|
||||||
|
for img in images:
|
||||||
|
size = os.path.getsize(img)
|
||||||
|
logger.debug(f" {img} ({size} bytes)")
|
||||||
|
|
||||||
|
# ── Пробуем несколько вариантов URI ──
|
||||||
|
uris = [
|
||||||
|
f"wss://{ws_host}:{ws_port}{WS_PATH_TEMPLATE.format(uid=uid)}",
|
||||||
|
f"wss://{REST_HOST}:{ws_port}{WS_PATH_TEMPLATE.format(uid=uid)}",
|
||||||
|
f"wss://{REST_HOST}:443{WS_PATH_TEMPLATE.format(uid=uid)}",
|
||||||
|
]
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for uri in uris:
|
||||||
|
logger.info(f"Попытка подключения: {uri}")
|
||||||
|
logger.info(" ── Заголовки WebSocket-запроса ──")
|
||||||
|
logger.info(f" GET {WS_PATH_TEMPLATE.format(uid=uid)} HTTP/1.1")
|
||||||
|
logger.info(f" Host: {ws_host}")
|
||||||
|
logger.info(f" Upgrade: websocket")
|
||||||
|
logger.info(f" Connection: Upgrade")
|
||||||
|
logger.info(f" Sec-WebSocket-Version: 13")
|
||||||
|
logger.info(f" Sec-WebSocket-Protocol: bearer.<TOKEN> (авторизация через subprotocol)")
|
||||||
|
logger.info(f" Origin: https://gigadoc.sbermed.ai")
|
||||||
|
logger.info(f" (полный токен: {token})")
|
||||||
|
logger.info(" ────────────────────────────────")
|
||||||
|
|
||||||
|
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:
|
||||||
|
logger.info(f"✅ WebSocket подключен: {uri}")
|
||||||
|
logger.debug(f" Протокол: {ws.subprotocol}")
|
||||||
|
logger.debug(f" Расширения: {ws.extensions if hasattr(ws, 'extensions') else 'n/a'}")
|
||||||
|
|
||||||
|
# Отправляем изображения
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 4: Отправка изображений")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
result = None
|
||||||
|
for i, img_path in enumerate(images):
|
||||||
|
with open(img_path, "rb") as f:
|
||||||
|
img_data = f.read()
|
||||||
|
|
||||||
|
logger.info(f" [{i+1}/{len(images)}] WS → отправка изображения: {img_path} ({len(img_data)} bytes, binary)")
|
||||||
|
t_start = time.time()
|
||||||
|
await ws.send(img_data)
|
||||||
|
t_send = time.time() - t_start
|
||||||
|
logger.debug(f" Отправлено за {t_send*1000:.1f} мс")
|
||||||
|
|
||||||
|
# Читаем ответ
|
||||||
|
try:
|
||||||
|
resp = await asyncio.wait_for(ws.recv(), timeout=5)
|
||||||
|
t_recv = time.time() - t_start
|
||||||
|
logger.debug(f" Ответ получен за {t_recv*1000:.1f} мс")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(resp)
|
||||||
|
progress = data.get("progress", "?")
|
||||||
|
face_ok = data.get("face_ok", "?")
|
||||||
|
|
||||||
|
# Логируем прогресс
|
||||||
|
if i % 10 == 0 or (isinstance(progress, (int, float)) and progress >= 1.0):
|
||||||
|
logger.info(f" [{i+1}/{len(images)}] progress={progress} face_ok={face_ok}")
|
||||||
|
|
||||||
|
# Логируем полный ответ на DEBUG
|
||||||
|
logger.debug(f" Полный ответ: {json.dumps(data, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
# Финальный результат
|
||||||
|
if isinstance(progress, (int, float)) and progress >= 1.0:
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 5: ФИНАЛЬНЫЙ РЕЗУЛЬТАТ")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
result = data
|
||||||
|
break
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.debug(f" Ответ не JSON: {resp[:200]}")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug(f" [{i+1}/{len(images)}] Таймаут ожидания ответа (5 сек)")
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
logger.warning("Финальный результат не получен (прогресс не достиг 1.0)")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
logger.warning(f"❌ Не удалось подключиться к {uri}: {type(e).__name__}: {e}")
|
||||||
|
logger.debug(f" Детали ошибки: {e}")
|
||||||
|
|
||||||
|
logger.error(f"❌ Все варианты WebSocket подключения не удались")
|
||||||
|
logger.error(f" Последняя ошибка: {type(last_error).__name__}: {last_error}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Этап 6: Удаление сессии ──────────────────────────────────
|
||||||
|
def delete_session(token: str, uid: str):
|
||||||
|
"""Удалить сессию."""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 6: Удаление сессии")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
url = f"{REST_BASE}/api/v1/predict/{uid}"
|
||||||
|
logger.info(f"DELETE {url}")
|
||||||
|
logger.debug(f" Authorization: Bearer {token[:30]}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.delete(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
logger.info(f"HTTP {resp.status_code}")
|
||||||
|
logger.debug(" Ответ:")
|
||||||
|
log_response(resp, " ")
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
logger.info(f"✅ Сессия {uid} удалена")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Сессия {uid} не удалена: {resp.status_code} {resp.text}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"❌ Ошибка удаления сессии: {e}")
|
||||||
|
if hasattr(e, "response") and e.response is not None:
|
||||||
|
logger.error(f" Статус: {e.response.status_code}")
|
||||||
|
logger.error(f" Ответ: {e.response.text}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Создание тестового изображения ──────────────────────────
|
||||||
|
def create_test_image():
|
||||||
|
"""Создать тестовое JPEG-изображение если нет своих."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = Image.new("RGB", (1280, 720), color=(128, 128, 128))
|
||||||
|
img.save("/tmp/test_face.jpg", "JPEG", quality=80)
|
||||||
|
logger.info("Создано тестовое изображение /tmp/test_face.jpg")
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("PIL не установлен, тестовое изображение не создано")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main ─────────────────────────────────────────────────────
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Тест SDK ФПГ (GigaDoc)")
|
||||||
|
parser.add_argument("--ws-host", default=WS_HOST, help=f"WebSocket host (default: {WS_HOST})")
|
||||||
|
parser.add_argument("--ws-port", type=int, default=WS_PORT, help=f"WebSocket port (default: {WS_PORT})")
|
||||||
|
parser.add_argument("--image-dir", default=".", help="Директория с JPEG-файлами (default: .)")
|
||||||
|
parser.add_argument("--log-file", default=LOG_FILE, help=f"Файл лога (default: {LOG_FILE})")
|
||||||
|
parser.add_argument("--rest-only", action="store_true", help="Только REST API (без WebSocket)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
setup_logging(args.log_file)
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("🏥 Тестирование SDK ФПГ (GigaDoc)")
|
||||||
|
logger.info(f"Дата: {datetime.now().isoformat()}")
|
||||||
|
logger.info(f"REST: {REST_BASE}")
|
||||||
|
logger.info(f"WS: {args.ws_host}:{args.ws_port}")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
# 0. Тестовое изображение
|
||||||
|
create_test_image()
|
||||||
|
|
||||||
|
# 1. Авторизация
|
||||||
|
try:
|
||||||
|
token = get_token()
|
||||||
|
except Exception:
|
||||||
|
logger.error("Тест прерван: не удалось авторизоваться")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 2. Создание сессии
|
||||||
|
try:
|
||||||
|
uid = create_session(token)
|
||||||
|
except Exception:
|
||||||
|
logger.error("Тест прерван: не удалось создать сессию")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 3-5. WebSocket
|
||||||
|
result = None
|
||||||
|
if not args.rest_only:
|
||||||
|
try:
|
||||||
|
result = asyncio.run(
|
||||||
|
ws_scan(token, uid, args.ws_host, args.ws_port, args.image_dir)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка WebSocket: {type(e).__name__}: {e}")
|
||||||
|
else:
|
||||||
|
logger.info("WebSocket тест пропущен (--rest-only)")
|
||||||
|
|
||||||
|
# 6. Удаление сессии
|
||||||
|
delete_session(token, uid)
|
||||||
|
|
||||||
|
# Итог
|
||||||
|
logger.info("=" * 60)
|
||||||
|
if result:
|
||||||
|
logger.info("✅ Тест завершён успешно — результат получен")
|
||||||
|
else:
|
||||||
|
logger.info("⚠️ REST API работает, WebSocket недоступен")
|
||||||
|
logger.info(f"Лог сохранён в: {args.log_file}")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
return 0 if result else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+314
@@ -0,0 +1,314 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Отправка потока кадров (одно изображение многократно) в SDK ФПГ (GigaDoc)
|
||||||
|
для получения полных медицинских показателей.
|
||||||
|
|
||||||
|
По документации: ~300 кадров JPEG при ~30 кадрах/сек (~10 сек видео).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
# ─── Конфигурация ─────────────────────────────────────────────
|
||||||
|
OAUTH_URL = "https://oauth.sbermed.ai/token"
|
||||||
|
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"
|
||||||
|
|
||||||
|
LOG_FILE = "fpg_stream.log"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Логирование ──────────────────────────────────────────────
|
||||||
|
def setup_logging(log_file: str = LOG_FILE):
|
||||||
|
logger = logging.getLogger("fpg_stream")
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
fh.setLevel(logging.DEBUG)
|
||||||
|
fh.setFormatter(formatter)
|
||||||
|
logger.addHandler(fh)
|
||||||
|
|
||||||
|
ch = logging.StreamHandler()
|
||||||
|
ch.setLevel(logging.INFO)
|
||||||
|
ch.setFormatter(formatter)
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
logger = setup_logging()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Этап 1: Авторизация ──────────────────────────────────────
|
||||||
|
def get_token():
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 1: Авторизация")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(f"POST {OAUTH_URL}")
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
OAUTH_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"client_secret": CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
logger.info(f"HTTP {resp.status_code}")
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"Ошибка авторизации: {resp.status_code} {resp.text}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
token = data["access_token"]
|
||||||
|
logger.info(f"✅ Токен получен (действует {data.get('expires_in', '?')} сек)")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Этап 2: Создание сессии ──────────────────────────────────
|
||||||
|
def create_session(token: str):
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 2: Создание сессии")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
url = f"{REST_BASE}/api/v1/predict/new"
|
||||||
|
logger.info(f"POST {url}")
|
||||||
|
logger.info(f" device_id={DEVICE_ID}")
|
||||||
|
logger.info(f" form_factor={FORM_FACTOR}")
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
url,
|
||||||
|
params={"device_id": DEVICE_ID, "form_factor": FORM_FACTOR},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
logger.info(f"HTTP {resp.status_code}")
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"Ошибка создания сессии: {resp.status_code} {resp.text}")
|
||||||
|
|
||||||
|
uid = resp.json()["uid"]
|
||||||
|
logger.info(f"✅ Сессия создана: {uid}")
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Этап 3-5: WebSocket + отправка потока + результат ────────
|
||||||
|
async def ws_stream(token: str, uid: str, image_path: str, frames: int, fps: float):
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 3: WebSocket подключение")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
uri = f"wss://{WS_HOST}:{WS_PORT}{WS_PATH_TEMPLATE.format(uid=uid)}"
|
||||||
|
logger.info(f"URL: {uri}")
|
||||||
|
|
||||||
|
# Читаем изображение (или директорию с кадрами)
|
||||||
|
import glob
|
||||||
|
|
||||||
|
if os.path.isdir(image_path):
|
||||||
|
# Директория с кадрами
|
||||||
|
patterns = ["*.jpg", "*.jpeg", "*.png"]
|
||||||
|
frame_files = []
|
||||||
|
for p in patterns:
|
||||||
|
frame_files.extend(glob.glob(os.path.join(image_path, p)))
|
||||||
|
frame_files.sort()
|
||||||
|
logger.info(f"Директория с кадрами: {image_path} ({len(frame_files)} файлов)")
|
||||||
|
if not frame_files:
|
||||||
|
logger.error("Нет кадров в директории")
|
||||||
|
return None
|
||||||
|
# Ограничиваем количество кадров
|
||||||
|
frame_files = frame_files[:frames]
|
||||||
|
logger.info(f"Будет отправлено кадров: {len(frame_files)}")
|
||||||
|
else:
|
||||||
|
# Один файл — повторяем его frames раз
|
||||||
|
frame_files = [image_path] * frames
|
||||||
|
logger.info(f"Изображение: {image_path} ({os.path.getsize(image_path)} bytes), повторяем {frames} раз")
|
||||||
|
|
||||||
|
logger.info(f"Частота: {fps} fps, интервал: {1000/fps:.0f} мс")
|
||||||
|
|
||||||
|
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:
|
||||||
|
logger.info(f"✅ WebSocket подключен: {uri}")
|
||||||
|
logger.info(f" Subprotocol: {ws.subprotocol}")
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 4: Отправка потока кадров")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
result = None
|
||||||
|
last_progress = -1
|
||||||
|
t_start = time.time()
|
||||||
|
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
data = json.loads(resp)
|
||||||
|
progress = data.get("progress", 0.0)
|
||||||
|
face_ok = data.get("face_ok", False)
|
||||||
|
|
||||||
|
# Логируем прогресс при изменении
|
||||||
|
pct = int(progress * 100)
|
||||||
|
if pct != last_progress:
|
||||||
|
logger.info(f" [{i+1}/{frames}] progress={progress:.2f} ({pct}%) face_ok={face_ok}")
|
||||||
|
last_progress = pct
|
||||||
|
|
||||||
|
# Финальный результат
|
||||||
|
if progress >= 1.0:
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 5: ФИНАЛЬНЫЙ РЕЗУЛЬТАТ")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
result = data
|
||||||
|
break
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.debug(f" Ответ не JSON: {resp[:200]}")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass # нет ответа на этот кадр — нормально
|
||||||
|
|
||||||
|
# Соблюдаем частоту кадров
|
||||||
|
elapsed = time.time() - frame_start
|
||||||
|
if elapsed < interval:
|
||||||
|
await asyncio.sleep(interval - elapsed)
|
||||||
|
|
||||||
|
# Прогресс-индикатор каждые 50 кадров
|
||||||
|
if (i + 1) % 50 == 0:
|
||||||
|
elapsed_total = time.time() - t_start
|
||||||
|
logger.info(f" ... отправлено {i+1}/{frames} кадров за {elapsed_total:.1f} сек")
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
logger.warning(f"Финальный результат не получен после {frames} кадров")
|
||||||
|
logger.info(f" Отправлено кадров: {frames}, время: {time.time()-t_start:.1f} сек")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка WebSocket: {type(e).__name__}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Этап 6: Удаление сессии ──────────────────────────────────
|
||||||
|
def delete_session(token: str, uid: str):
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("ЭТАП 6: Удаление сессии")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
url = f"{REST_BASE}/api/v1/predict/{uid}"
|
||||||
|
logger.info(f"DELETE {url}")
|
||||||
|
|
||||||
|
resp = requests.delete(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
logger.info(f"HTTP {resp.status_code}")
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
logger.info(f"✅ Сессия {uid} удалена")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Сессия {uid} не удалена: {resp.status_code} {resp.text}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main ─────────────────────────────────────────────────────
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Отправка потока кадров в SDK ФПГ (GigaDoc)")
|
||||||
|
parser.add_argument("--source", required=True, help="Путь к JPEG-изображению или директории с кадрами")
|
||||||
|
parser.add_argument("--frames", type=int, default=300, help="Количество кадров (default: 300)")
|
||||||
|
parser.add_argument("--fps", type=float, default=30.0, help="Частота кадров (default: 30)")
|
||||||
|
parser.add_argument("--log-file", default=LOG_FILE, help=f"Файл лога (default: {LOG_FILE})")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
global logger
|
||||||
|
logger = setup_logging(args.log_file)
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("🏥 Отправка потока кадров в SDK ФПГ (GigaDoc)")
|
||||||
|
logger.info(f"Дата: {datetime.now().isoformat()}")
|
||||||
|
logger.info(f"Источник: {args.source}")
|
||||||
|
logger.info(f"Кадры: {args.frames} @ {args.fps} fps")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
if not os.path.exists(args.source):
|
||||||
|
logger.error(f"Файл/директория не найден: {args.source}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 1. Авторизация
|
||||||
|
try:
|
||||||
|
token = get_token()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Тест прерван: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 2. Создание сессии
|
||||||
|
try:
|
||||||
|
uid = create_session(token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Тест прерван: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 3-5. WebSocket + поток
|
||||||
|
result = asyncio.run(ws_stream(token, uid, args.source, args.frames, args.fps))
|
||||||
|
|
||||||
|
# 6. Удаление сессии
|
||||||
|
delete_session(token, uid)
|
||||||
|
|
||||||
|
# Итог
|
||||||
|
logger.info("=" * 60)
|
||||||
|
if result:
|
||||||
|
logger.info("✅ Тест завершён успешно — полный результат получен")
|
||||||
|
else:
|
||||||
|
logger.info("⚠️ Результат не получен (сервер не завершил диагностику)")
|
||||||
|
logger.info(f"Лог сохранён в: {args.log_file}")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
return 0 if result else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+1172
File diff suppressed because it is too large
Load Diff
+366
@@ -0,0 +1,366 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Модуль интерпретации результатов SDK ФПГ (GigaDoc).
|
||||||
|
Переводит JSON-ответ в понятный человеку диагноз.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_result(data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Преобразует JSON-ответ SDK в структурированный, понятный диагноз.
|
||||||
|
|
||||||
|
Возвращает dict с секциями:
|
||||||
|
- summary: краткое резюме
|
||||||
|
- metrics: список показателей с оценкой (норма/отклонение)
|
||||||
|
- warnings: список предупреждений
|
||||||
|
- advice: рекомендации
|
||||||
|
"""
|
||||||
|
if not data or not data.get("face_ok"):
|
||||||
|
return {
|
||||||
|
"summary": "Лицо не было распознано на видео. Убедитесь, что лицо хорошо освещено и находится в кадре.",
|
||||||
|
"metrics": [],
|
||||||
|
"warnings": ["Лицо не распознано — анализ невозможен"],
|
||||||
|
"advice": ["Переснимите видео: лицо анфас, хорошее освещение, без очков и головных уборов"],
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics = []
|
||||||
|
warnings = []
|
||||||
|
advice = []
|
||||||
|
|
||||||
|
# ── Демография ─────────────────────────────────────────────
|
||||||
|
gender = "мужчина" if data.get("is_male") else "женщина"
|
||||||
|
age = data.get("age", 0)
|
||||||
|
ethnicity_map = {
|
||||||
|
"white": "европеоид",
|
||||||
|
"black": "негроид",
|
||||||
|
"asian": "монголоид",
|
||||||
|
"unknown": "не определён",
|
||||||
|
}
|
||||||
|
ethnicity = ethnicity_map.get(data.get("ethnicity", ""), data.get("ethnicity", "не определён"))
|
||||||
|
|
||||||
|
emotion_map = {
|
||||||
|
"happiness": "радость",
|
||||||
|
"sadness": "грусть",
|
||||||
|
"neutral": "спокойствие",
|
||||||
|
"anger": "злость",
|
||||||
|
"surprise": "удивление",
|
||||||
|
"fear": "страх",
|
||||||
|
"disgust": "отвращение",
|
||||||
|
"contempt": "презрение",
|
||||||
|
}
|
||||||
|
emotion = emotion_map.get(data.get("emotion", ""), data.get("emotion", "не определена"))
|
||||||
|
|
||||||
|
metrics.append({
|
||||||
|
"name": "Пол",
|
||||||
|
"value": "Мужской" if data.get("is_male") else "Женский",
|
||||||
|
"status": "info",
|
||||||
|
"comment": "",
|
||||||
|
})
|
||||||
|
metrics.append({
|
||||||
|
"name": "Возраст",
|
||||||
|
"value": f"{age} лет",
|
||||||
|
"status": "info",
|
||||||
|
"comment": "Оценка по лицу",
|
||||||
|
})
|
||||||
|
metrics.append({
|
||||||
|
"name": "Этнос",
|
||||||
|
"value": ethnicity.capitalize(),
|
||||||
|
"status": "info",
|
||||||
|
"comment": "",
|
||||||
|
})
|
||||||
|
metrics.append({
|
||||||
|
"name": "Эмоция",
|
||||||
|
"value": emotion.capitalize(),
|
||||||
|
"status": "info",
|
||||||
|
"comment": "Эмоция в момент съёмки",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── ИМТ ────────────────────────────────────────────────────
|
||||||
|
bmi = data.get("bmi", 0)
|
||||||
|
if bmi > 0:
|
||||||
|
if bmi < 18.5:
|
||||||
|
bmi_status, bmi_comment = "warning", "Недостаточный вес"
|
||||||
|
elif bmi < 25:
|
||||||
|
bmi_status, bmi_comment = "ok", "Нормальный вес"
|
||||||
|
elif bmi < 30:
|
||||||
|
bmi_status, bmi_comment = "warning", "Избыточный вес"
|
||||||
|
else:
|
||||||
|
bmi_status, bmi_comment = "danger", "Ожирение"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Индекс массы тела (ИМТ)",
|
||||||
|
"value": f"{bmi:.1f}",
|
||||||
|
"status": bmi_status,
|
||||||
|
"comment": bmi_comment,
|
||||||
|
})
|
||||||
|
if bmi_status == "warning":
|
||||||
|
warnings.append(f"Избыточный вес (ИМТ {bmi:.1f}) — рекомендуется контроль питания и физическая активность")
|
||||||
|
advice.append("Снизить вес до нормы (ИМТ 18.5–24.9): сбалансированное питание, регулярные нагрузки")
|
||||||
|
elif bmi_status == "danger":
|
||||||
|
warnings.append(f"Ожирение (ИМТ {bmi:.1f}) — рекомендуется консультация врача")
|
||||||
|
advice.append("Обратиться к врачу для разработки программы снижения веса")
|
||||||
|
|
||||||
|
# ── Диабет ─────────────────────────────────────────────────
|
||||||
|
diabet = data.get("diabet", "unknown")
|
||||||
|
diabet_map = {
|
||||||
|
"small": ("ok", "Низкий риск диабета"),
|
||||||
|
"middle": ("warning", "Средний риск диабета"),
|
||||||
|
"big": ("danger", "Высокий риск диабета"),
|
||||||
|
"unknown": ("info", "Не определён"),
|
||||||
|
}
|
||||||
|
d_status, d_comment = diabet_map.get(diabet, ("info", "Не определён"))
|
||||||
|
metrics.append({
|
||||||
|
"name": "Риск диабета",
|
||||||
|
"value": {"small": "Низкий", "middle": "Средний", "big": "Высокий", "unknown": "Не определён"}.get(diabet, "Не определён"),
|
||||||
|
"status": d_status,
|
||||||
|
"comment": d_comment,
|
||||||
|
})
|
||||||
|
if d_status == "warning":
|
||||||
|
warnings.append("Средний риск диабета — рекомендуется проверить уровень сахара в крови")
|
||||||
|
advice.append("Сдать анализ на гликированный гемоглобин и сахар крови натощак")
|
||||||
|
elif d_status == "danger":
|
||||||
|
warnings.append("Высокий риск диабета — необходима консультация врача")
|
||||||
|
advice.append("Срочно обратиться к врачу и сдать анализы на сахар")
|
||||||
|
|
||||||
|
# ── Усталость ──────────────────────────────────────────────
|
||||||
|
fatigue = data.get("fatigue", "small")
|
||||||
|
fatigue_map = {
|
||||||
|
"small": ("ok", "Низкая усталость"),
|
||||||
|
"middle": ("warning", "Средняя усталость"),
|
||||||
|
"big": ("warning", "Высокая усталость"),
|
||||||
|
"over": ("danger", "Переутомление"),
|
||||||
|
}
|
||||||
|
f_status, f_comment = fatigue_map.get(fatigue, ("info", "Не определена"))
|
||||||
|
metrics.append({
|
||||||
|
"name": "Усталость",
|
||||||
|
"value": {"small": "Низкая", "middle": "Средняя", "big": "Высокая", "over": "Переутомление"}.get(fatigue, "Не определена"),
|
||||||
|
"status": f_status,
|
||||||
|
"comment": f_comment,
|
||||||
|
})
|
||||||
|
if f_status == "warning":
|
||||||
|
warnings.append("Повышенная усталость — рекомендуется полноценный отдых")
|
||||||
|
advice.append("Нормализовать сон (7–8 часов), снизить нагрузку")
|
||||||
|
elif f_status == "danger":
|
||||||
|
warnings.append("Переутомление — необходима консультация врача")
|
||||||
|
advice.append("Обратиться к врачу, исключить перегрузки")
|
||||||
|
|
||||||
|
# ── Пульс ──────────────────────────────────────────────────
|
||||||
|
hr = data.get("hr", 0)
|
||||||
|
if hr > 0:
|
||||||
|
if hr < 60:
|
||||||
|
hr_status, hr_comment = "warning", "Замедленный пульс (брадикардия)"
|
||||||
|
elif hr <= 100:
|
||||||
|
hr_status, hr_comment = "ok", "Нормальный пульс"
|
||||||
|
else:
|
||||||
|
hr_status, hr_comment = "warning", "Учащённый пульс (тахикардия)"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Пульс (ЧСС)",
|
||||||
|
"value": f"{hr} уд/мин",
|
||||||
|
"status": hr_status,
|
||||||
|
"comment": hr_comment,
|
||||||
|
})
|
||||||
|
if hr_status == "warning":
|
||||||
|
warnings.append(f"Пульс {hr} уд/мин — вне нормы (60–100)")
|
||||||
|
advice.append("Проконсультироваться с врачом по поводу пульса")
|
||||||
|
|
||||||
|
# ── Давление ───────────────────────────────────────────────
|
||||||
|
upper_ap = data.get("upper_ap", 0)
|
||||||
|
lower_ap = data.get("lower_ap", 0)
|
||||||
|
if upper_ap > 0 and lower_ap > 0:
|
||||||
|
if upper_ap < 120 and lower_ap < 80:
|
||||||
|
ap_status, ap_comment = "ok", "Оптимальное давление"
|
||||||
|
elif upper_ap < 130 and lower_ap < 85:
|
||||||
|
ap_status, ap_comment = "ok", "Нормальное давление"
|
||||||
|
elif upper_ap < 140 or lower_ap < 90:
|
||||||
|
ap_status, ap_comment = "warning", "Повышенное давление"
|
||||||
|
else:
|
||||||
|
ap_status, ap_comment = "danger", "Высокое давление"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Артериальное давление",
|
||||||
|
"value": f"{upper_ap}/{lower_ap} мм рт.ст.",
|
||||||
|
"status": ap_status,
|
||||||
|
"comment": ap_comment,
|
||||||
|
})
|
||||||
|
if ap_status == "warning":
|
||||||
|
warnings.append(f"Повышенное давление {upper_ap}/{lower_ap} — рекомендуется контроль")
|
||||||
|
advice.append("Контролировать давление, ограничить соль, кофеин, алкоголь")
|
||||||
|
elif ap_status == "danger":
|
||||||
|
warnings.append(f"Высокое давление {upper_ap}/{lower_ap} — необходима консультация врача")
|
||||||
|
advice.append("Обратиться к врачу для назначения лечения")
|
||||||
|
|
||||||
|
# ── Гликированный гемоглобин ───────────────────────────────
|
||||||
|
gh = data.get("glycated_hemoglobin", 0)
|
||||||
|
if gh > 0:
|
||||||
|
if gh < 5.7:
|
||||||
|
gh_status, gh_comment = "ok", "Норма (без диабета)"
|
||||||
|
elif gh < 6.5:
|
||||||
|
gh_status, gh_comment = "warning", "Преддиабет"
|
||||||
|
else:
|
||||||
|
gh_status, gh_comment = "danger", "Диабет"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Гликированный гемоглобин",
|
||||||
|
"value": f"{gh:.1f}%",
|
||||||
|
"status": gh_status,
|
||||||
|
"comment": gh_comment,
|
||||||
|
})
|
||||||
|
if gh_status == "warning":
|
||||||
|
warnings.append(f"Гликированный гемоглобин {gh:.1f}% — преддиабет")
|
||||||
|
advice.append("Снизить потребление сахара, проверить сахар крови")
|
||||||
|
elif gh_status == "danger":
|
||||||
|
warnings.append(f"Гликированный гемоглобин {gh:.1f}% — возможен диабет")
|
||||||
|
advice.append("Срочно обратиться к врачу")
|
||||||
|
|
||||||
|
# ── Холестерин ─────────────────────────────────────────────
|
||||||
|
chol = data.get("cholesterol", 0)
|
||||||
|
if chol > 0:
|
||||||
|
if chol < 5.2:
|
||||||
|
chol_status, chol_comment = "ok", "Норма"
|
||||||
|
elif chol < 6.2:
|
||||||
|
chol_status, chol_comment = "warning", "Повышенный"
|
||||||
|
else:
|
||||||
|
chol_status, chol_comment = "danger", "Высокий"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Общий холестерин",
|
||||||
|
"value": f"{chol:.1f} ммоль/л",
|
||||||
|
"status": chol_status,
|
||||||
|
"comment": chol_comment,
|
||||||
|
})
|
||||||
|
if chol_status == "warning":
|
||||||
|
warnings.append(f"Холестерин {chol:.1f} ммоль/л — выше нормы (до 5.2)")
|
||||||
|
advice.append("Ограничить жирную пищу, добавить овощи и физическую активность")
|
||||||
|
|
||||||
|
chol_ld = data.get("cholesterol_ld", 0)
|
||||||
|
if chol_ld > 0:
|
||||||
|
if chol_ld < 3.0:
|
||||||
|
ld_status, ld_comment = "ok", "Норма"
|
||||||
|
elif chol_ld < 4.1:
|
||||||
|
ld_status, ld_comment = "warning", "Повышенный"
|
||||||
|
else:
|
||||||
|
ld_status, ld_comment = "danger", "Высокий"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Холестерин ЛПНП («плохой»)",
|
||||||
|
"value": f"{chol_ld:.1f} ммоль/л",
|
||||||
|
"status": ld_status,
|
||||||
|
"comment": ld_comment,
|
||||||
|
})
|
||||||
|
|
||||||
|
chol_hd = data.get("cholesterol_hd", 0)
|
||||||
|
if chol_hd > 0:
|
||||||
|
if chol_hd >= 1.0:
|
||||||
|
hd_status, hd_comment = "ok", "Норма (защищает сердце)"
|
||||||
|
else:
|
||||||
|
hd_status, hd_comment = "warning", "Пониженный"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Холестерин ЛПВП («хороший»)",
|
||||||
|
"value": f"{chol_hd:.1f} ммоль/л",
|
||||||
|
"status": hd_status,
|
||||||
|
"comment": hd_comment,
|
||||||
|
})
|
||||||
|
|
||||||
|
trig = data.get("triglycerides", 0)
|
||||||
|
if trig > 0:
|
||||||
|
if trig < 1.7:
|
||||||
|
trig_status, trig_comment = "ok", "Норма"
|
||||||
|
elif trig < 2.3:
|
||||||
|
trig_status, trig_comment = "warning", "Повышенные"
|
||||||
|
else:
|
||||||
|
trig_status, trig_comment = "danger", "Высокие"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Триглицериды",
|
||||||
|
"value": f"{trig:.1f} ммоль/л",
|
||||||
|
"status": trig_status,
|
||||||
|
"comment": trig_comment,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Lp(a) ──────────────────────────────────────────────────
|
||||||
|
lpa50 = data.get("lpa50", False)
|
||||||
|
if lpa50:
|
||||||
|
warnings.append("Повышенный уровень липопротеина (а) — фактор риска сердечно-сосудистых заболеваний")
|
||||||
|
advice.append("Проконсультироваться с кардиологом")
|
||||||
|
|
||||||
|
# ── Дыхание ────────────────────────────────────────────────
|
||||||
|
resp = data.get("respiratory", 0)
|
||||||
|
if resp > 0:
|
||||||
|
if 12 <= resp <= 20:
|
||||||
|
resp_status, resp_comment = "ok", "Норма"
|
||||||
|
else:
|
||||||
|
resp_status, resp_comment = "warning", "Вне нормы"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Частота дыхания",
|
||||||
|
"value": f"{resp:.1f} вдохов/мин",
|
||||||
|
"status": resp_status,
|
||||||
|
"comment": resp_comment,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Стресс ─────────────────────────────────────────────────
|
||||||
|
stress = data.get("stress", 0)
|
||||||
|
if stress > 0:
|
||||||
|
if stress < 50:
|
||||||
|
stress_status, stress_comment = "ok", "Низкий уровень стресса"
|
||||||
|
elif stress < 100:
|
||||||
|
stress_status, stress_comment = "ok", "Умеренный уровень стресса"
|
||||||
|
elif stress < 150:
|
||||||
|
stress_status, stress_comment = "warning", "Повышенный уровень стресса"
|
||||||
|
else:
|
||||||
|
stress_status, stress_comment = "danger", "Высокий уровень стресса"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Уровень стресса",
|
||||||
|
"value": f"{stress}",
|
||||||
|
"status": stress_status,
|
||||||
|
"comment": stress_comment,
|
||||||
|
})
|
||||||
|
if stress_status == "warning":
|
||||||
|
warnings.append("Повышенный уровень стресса")
|
||||||
|
advice.append("Практиковать релаксацию, дыхательные упражнения, прогулки")
|
||||||
|
elif stress_status == "danger":
|
||||||
|
warnings.append("Высокий уровень стресса — рекомендуется консультация")
|
||||||
|
advice.append("Обратиться к психологу или психотерапевту")
|
||||||
|
|
||||||
|
# ── Эластичность сосудов ───────────────────────────────────
|
||||||
|
rigidity = data.get("rigidity", 0)
|
||||||
|
if rigidity > 0:
|
||||||
|
if rigidity < 9:
|
||||||
|
rig_status, rig_comment = "ok", "Сосуды эластичные"
|
||||||
|
elif rigidity < 12:
|
||||||
|
rig_status, rig_comment = "warning", "Сниженная эластичность"
|
||||||
|
else:
|
||||||
|
rig_status, rig_comment = "danger", "Жёсткие сосуды"
|
||||||
|
metrics.append({
|
||||||
|
"name": "Эластичность сосудов",
|
||||||
|
"value": f"{rigidity:.1f} м/с",
|
||||||
|
"status": rig_status,
|
||||||
|
"comment": rig_comment,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Моргания ───────────────────────────────────────────────
|
||||||
|
blinks = data.get("blinks", 0)
|
||||||
|
metrics.append({
|
||||||
|
"name": "Моргания",
|
||||||
|
"value": f"{blinks}",
|
||||||
|
"status": "info",
|
||||||
|
"comment": "За время сканирования",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Сводка ─────────────────────────────────────────────────
|
||||||
|
summary = f"{gender.capitalize()}, {age} лет, {ethnicity}. Эмоция: {emotion}."
|
||||||
|
|
||||||
|
# Считаем проблемы
|
||||||
|
problems = [m for m in metrics if m["status"] in ("warning", "danger")]
|
||||||
|
if not problems:
|
||||||
|
summary += " Все основные показатели в норме."
|
||||||
|
else:
|
||||||
|
danger = [m for m in metrics if m["status"] == "danger"]
|
||||||
|
warning = [m for m in metrics if m["status"] == "warning"]
|
||||||
|
if danger:
|
||||||
|
summary += f" Обнаружены серьёзные отклонения: {', '.join(m['name'] for m in danger)}."
|
||||||
|
if warning:
|
||||||
|
summary += f" Требуют внимания: {', '.join(m['name'] for m in warning)}."
|
||||||
|
|
||||||
|
if not advice:
|
||||||
|
advice.append("Продолжайте вести здоровый образ жизни и проходите профилактические осмотры")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"metrics": metrics,
|
||||||
|
"warnings": warnings,
|
||||||
|
"advice": advice,
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Хранение результатов анализов в SQLite.
|
||||||
|
|
||||||
|
Таблица results:
|
||||||
|
- id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||||
|
- share_id TEXT UNIQUE — короткий ID для публичной ссылки
|
||||||
|
- created_at TEXT — дата создания (ISO)
|
||||||
|
- raw_json TEXT — сырой JSON от SDK
|
||||||
|
- summary TEXT — сводка (для OG-тегов)
|
||||||
|
- warnings TEXT — JSON-список предупреждений
|
||||||
|
- metrics TEXT — JSON-список показателей
|
||||||
|
- advice TEXT — JSON-список рекомендаций
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results.db")
|
||||||
|
|
||||||
|
# Первый ID результата
|
||||||
|
FIRST_ID = 10005
|
||||||
|
|
||||||
|
|
||||||
|
def _connect():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Создать таблицу, если её нет."""
|
||||||
|
conn = _connect()
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS results (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
share_id TEXT UNIQUE NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
raw_json TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
warnings TEXT NOT NULL DEFAULT '[]',
|
||||||
|
metrics TEXT NOT NULL DEFAULT '[]',
|
||||||
|
advice TEXT NOT NULL DEFAULT '[]'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_result(interpretation: dict, raw_result: dict) -> str:
|
||||||
|
"""
|
||||||
|
Сохранить результат анализа. Возвращает share_id (числовой, начиная с 10005).
|
||||||
|
"""
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
conn = _connect()
|
||||||
|
|
||||||
|
# Следующий ID: MAX(id) + 1, но не меньше FIRST_ID
|
||||||
|
row = conn.execute("SELECT MAX(id) AS max_id FROM results").fetchone()
|
||||||
|
next_id = (row["max_id"] + 1) if row["max_id"] is not None else FIRST_ID
|
||||||
|
if next_id < FIRST_ID:
|
||||||
|
next_id = FIRST_ID
|
||||||
|
|
||||||
|
share_id = str(next_id)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO results (id, share_id, created_at, raw_json, summary, warnings, metrics, advice)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
next_id,
|
||||||
|
share_id,
|
||||||
|
datetime.now().isoformat(timespec="seconds"),
|
||||||
|
json.dumps(raw_result, ensure_ascii=False),
|
||||||
|
interpretation.get("summary", ""),
|
||||||
|
json.dumps(interpretation.get("warnings", []), ensure_ascii=False),
|
||||||
|
json.dumps(interpretation.get("metrics", []), ensure_ascii=False),
|
||||||
|
json.dumps(interpretation.get("advice", []), ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return share_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_result(share_id: str):
|
||||||
|
"""Получить результат по share_id. Возвращает dict или None."""
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
conn = _connect()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM results WHERE share_id = ?", (share_id,)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"share_id": row["share_id"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"raw_json": json.loads(row["raw_json"]),
|
||||||
|
"summary": row["summary"],
|
||||||
|
"warnings": json.loads(row["warnings"]),
|
||||||
|
"metrics": json.loads(row["metrics"]),
|
||||||
|
"advice": json.loads(row["advice"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_results(limit: int = 50):
|
||||||
|
"""Список последних результатов (для админки/истории)."""
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
conn = _connect()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT share_id, created_at, summary FROM results ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return [dict(r) for r in rows]
|
||||||
@@ -0,0 +1,770 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<!-- SEO -->
|
||||||
|
<title>Диагностика по видео — оценка здоровья по лицу | med.my-dpr.ru</title>
|
||||||
|
<meta name="description" content="Загрузите видео с лицом и получите оценку состояния здоровья: пульс, давление, ИМТ, холестерин, уровень стресса и другие показатели за 30 секунд.">
|
||||||
|
<meta name="keywords" content="диагностика по видео, оценка здоровья, анализ лица, пульс, давление, ИМТ, холестерин, ФПГ, фотоплетизмография">
|
||||||
|
<meta name="author" content="med.my-dpr.ru">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<meta name="theme-color" content="#0014FC">
|
||||||
|
|
||||||
|
<!-- Open Graph (Facebook, VK, Telegram, WhatsApp) -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="Диагностика по видео">
|
||||||
|
<meta property="og:title" content="Диагностика по видео — оценка здоровья по лицу">
|
||||||
|
<meta property="og:description" content="Загрузите видео с лицом и получите оценку состояния здоровья: пульс, давление, ИМТ, холестерин, уровень стресса и другие показатели за 30 секунд.">
|
||||||
|
<meta property="og:url" content="https://med.my-dpr.ru/">
|
||||||
|
<meta property="og:image" content="https://med.my-dpr.ru/static/og-image.png">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="Диагностика по видео — оценка здоровья по лицу">
|
||||||
|
<meta property="og:locale" content="ru_RU">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Диагностика по видео — оценка здоровья по лицу">
|
||||||
|
<meta name="twitter:description" content="Загрузите видео с лицом и получите оценку состояния здоровья: пульс, давление, ИМТ, холестерин, уровень стресса и другие показатели за 30 секунд.">
|
||||||
|
<meta name="twitter:image" content="https://med.my-dpr.ru/static/og-image.png">
|
||||||
|
|
||||||
|
<!-- Favicon (эмодзи-иконка) -->
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🩺</text></svg>">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #061A40 0%, #0B4F9C 50%, #2D9CDB 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.upload-area:hover, .upload-area.dragover {
|
||||||
|
border-color: #0014FC;
|
||||||
|
background: #f0f2ff;
|
||||||
|
}
|
||||||
|
.upload-area .icon { font-size: 48px; margin-bottom: 10px; }
|
||||||
|
.upload-area p { color: #666; margin: 5px 0; }
|
||||||
|
.upload-area .hint { font-size: 13px; color: #999; }
|
||||||
|
input[type="file"] { display: none; }
|
||||||
|
.btn {
|
||||||
|
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.btn:hover { filter: brightness(1.15); }
|
||||||
|
.btn:disabled { background: #ccc; cursor: not-allowed; }
|
||||||
|
.btn-secondary {
|
||||||
|
background: #eee;
|
||||||
|
color: #333;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: #ddd; }
|
||||||
|
|
||||||
|
/* Кнопки скачивания */
|
||||||
|
.download-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn-download {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.btn-download:hover { filter: brightness(1.15); }
|
||||||
|
.btn-download:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
/* Сканирование с камеры */
|
||||||
|
.scan-section { display: none; margin-bottom: 15px; }
|
||||||
|
.scan-desc { color: #666; font-size: 14px; margin-bottom: 15px; }
|
||||||
|
.scan-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 52px;
|
||||||
|
box-shadow: 0 4px 15px rgba(102,126,234,0.4);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.scan-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(102,126,234,0.5); }
|
||||||
|
.scan-btn:active { transform: scale(0.98); }
|
||||||
|
.scan-btn:disabled { background: #ccc; box-shadow: none; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.scan-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 10, 30, 0.92);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.scan-modal.open { display: flex; }
|
||||||
|
.scan-video-wrap {
|
||||||
|
width: 280px;
|
||||||
|
height: 280px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 4px solid #0014FC;
|
||||||
|
background: #000;
|
||||||
|
box-shadow: 0 0 40px rgba(102,126,234,0.5);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.scan-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
.scan-progress { color: #fff; margin-top: 20px; font-size: 16px; text-align: center; }
|
||||||
|
.scan-progress-bar {
|
||||||
|
width: 280px;
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.scan-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, #061A40, #0B4F9C, #2D9CDB);
|
||||||
|
transition: width 0.2s;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.scan-cancel {
|
||||||
|
margin-top: 20px;
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255,255,255,0.35);
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.scan-cancel:hover { background: rgba(255,255,255,0.2); }
|
||||||
|
.scan-hint { color: rgba(255,255,255,0.7); margin-top: 15px; font-size: 13px; text-align: center; max-width: 300px; }
|
||||||
|
|
||||||
|
/* Прогресс */
|
||||||
|
.progress-container {
|
||||||
|
display: none;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
background: #eee;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, #061A40, #0B4F9C, #2D9CDB);
|
||||||
|
transition: width 0.3s;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Результат */
|
||||||
|
.result { display: none; }
|
||||||
|
.summary-box {
|
||||||
|
background: #f0f4ff;
|
||||||
|
border-left: 4px solid #0014FC;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.metrics-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.metrics-table th, .metrics-table td {
|
||||||
|
padding: 10px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.metrics-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.metrics-table tr:hover { background: #fafafa; }
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-ok { background: #d4edda; color: #155724; }
|
||||||
|
.status-warning { background: #fff3cd; color: #856404; }
|
||||||
|
.status-danger { background: #f8d7da; color: #721c24; }
|
||||||
|
.status-info { background: #d1ecf1; color: #0c5460; }
|
||||||
|
|
||||||
|
.warnings-box {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.warnings-box h3 { color: #856404; margin-bottom: 10px; font-size: 16px; }
|
||||||
|
.warnings-box ul { padding-left: 20px; }
|
||||||
|
.warnings-box li { margin: 5px 0; color: #856404; }
|
||||||
|
|
||||||
|
.advice-box {
|
||||||
|
background: #d4edda;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.advice-box h3 { color: #155724; margin-bottom: 10px; font-size: 16px; }
|
||||||
|
.advice-box ul { padding-left: 20px; }
|
||||||
|
.advice-box li { margin: 5px 0; color: #155724; }
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 15px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.error-box {
|
||||||
|
background: #f8d7da;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
.loading-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
/* Заголовок результата */
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Мобильная версия ===== */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body { padding: 12px; }
|
||||||
|
h1 { font-size: 22px; margin-bottom: 12px; }
|
||||||
|
.subtitle { font-size: 14px; margin-bottom: 18px; }
|
||||||
|
.card { padding: 18px; border-radius: 14px; margin-bottom: 14px; }
|
||||||
|
h2 { font-size: 18px; }
|
||||||
|
.upload-area { padding: 25px 15px; }
|
||||||
|
.upload-area .icon { font-size: 40px; }
|
||||||
|
.btn, .btn-secondary { width: 100%; padding: 14px 20px; font-size: 16px; min-height: 48px; }
|
||||||
|
.download-buttons { flex-direction: column; }
|
||||||
|
.btn-download { width: 100%; min-width: 0; padding: 14px 20px; font-size: 16px; min-height: 48px; }
|
||||||
|
.result-header { flex-direction: column; align-items: stretch; }
|
||||||
|
.result-header h2 { text-align: center; }
|
||||||
|
.metrics-table thead { display: none; }
|
||||||
|
.metrics-table, .metrics-table tbody, .metrics-table tr, .metrics-table td { display: block; width: 100%; }
|
||||||
|
.metrics-table tr { border: 1px solid #eee; border-radius: 10px; margin-bottom: 10px; padding: 10px 12px; }
|
||||||
|
.metrics-table td { border: none; padding: 5px 0; }
|
||||||
|
.metrics-table td:first-child { font-weight: 600; color: #555; }
|
||||||
|
.summary-box, .warnings-box, .advice-box { padding: 12px 14px; font-size: 15px; }
|
||||||
|
.progress-text { font-size: 13px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🩺 Диагностика по видео</h1>
|
||||||
|
<p class="subtitle">Загрузите видео с лицом — получите оценку состояния здоровья</p>
|
||||||
|
|
||||||
|
<div class="card scan-section" id="scanSection">
|
||||||
|
<h2>📷 Сканирование</h2>
|
||||||
|
<p class="scan-desc">Наведите камеру на лицо — мы сделаем 300 кадров и проведём анализ</p>
|
||||||
|
<button class="scan-btn" id="scanBtn">📷 Сканировать</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Загрузка видео</h2>
|
||||||
|
<div class="upload-area" id="uploadArea">
|
||||||
|
<div class="icon">📹</div>
|
||||||
|
<p><strong>Нажмите или перетащите видео сюда</strong></p>
|
||||||
|
<p class="hint">MP4, AVI, MOV, MKV • до 100 МБ • 10–15 секунд • лицо анфас</p>
|
||||||
|
<input type="file" id="fileInput" accept="video/*">
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center">
|
||||||
|
<button class="btn" id="analyzeBtn" disabled>🔍 Анализировать</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-container" id="progressContainer">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text" id="progressText">Подготовка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card result" id="resultCard">
|
||||||
|
<div class="result-header">
|
||||||
|
<h2 style="margin:0">📋 Результат анализа</h2>
|
||||||
|
<button class="btn-secondary" onclick="location.reload()">🔄 Новый анализ</button>
|
||||||
|
</div>
|
||||||
|
<div id="resultContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модал сканирования с камеры -->
|
||||||
|
<div class="scan-modal" id="scanModal">
|
||||||
|
<div class="scan-video-wrap">
|
||||||
|
<video class="scan-video" id="scanVideo" autoplay playsinline muted></video>
|
||||||
|
</div>
|
||||||
|
<div class="scan-progress">
|
||||||
|
<div id="scanProgressText">Кадров: 0 / 300 (0%)</div>
|
||||||
|
<div class="scan-progress-bar">
|
||||||
|
<div class="scan-progress-fill" id="scanProgressFill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="scan-cancel" id="scanCancelBtn">✖ Отмена</button>
|
||||||
|
<div class="scan-hint">Держите лицо в круге, смотрите прямо в камеру, не двигайтесь</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const uploadArea = document.getElementById('uploadArea');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const analyzeBtn = document.getElementById('analyzeBtn');
|
||||||
|
const progressContainer = document.getElementById('progressContainer');
|
||||||
|
const progressFill = document.getElementById('progressFill');
|
||||||
|
const progressText = document.getElementById('progressText');
|
||||||
|
const resultCard = document.getElementById('resultCard');
|
||||||
|
const resultContent = document.getElementById('resultContent');
|
||||||
|
|
||||||
|
let selectedFile = null;
|
||||||
|
|
||||||
|
uploadArea.addEventListener('click', () => fileInput.click());
|
||||||
|
uploadArea.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.add('dragover');
|
||||||
|
});
|
||||||
|
uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
|
||||||
|
uploadArea.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
handleFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files.length > 0) handleFile(e.target.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFile(file) {
|
||||||
|
selectedFile = file;
|
||||||
|
uploadArea.querySelector('p strong').textContent = `📁 ${file.name} (${(file.size/1024/1024).toFixed(1)} МБ)`;
|
||||||
|
uploadArea.querySelector('.hint').textContent = 'Готово к анализу';
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzeBtn.addEventListener('click', () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
sendVideo(selectedFile, selectedFile.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sendVideo(blob, filename) {
|
||||||
|
analyzeBtn.disabled = true;
|
||||||
|
progressContainer.style.display = 'block';
|
||||||
|
resultCard.style.display = 'none';
|
||||||
|
setProgress(5, 'Загрузка видео...');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('video', blob, filename);
|
||||||
|
|
||||||
|
// Автоповтор при обрыве соединения (нестабильная мобильная сеть)
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
const e = new Error(err.error || 'Ошибка анализа');
|
||||||
|
e.videoUrl = err.video_url;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(90, 'Формирование результата...');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
showError(data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(100, 'Готово!');
|
||||||
|
setTimeout(() => {
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
renderResult(data);
|
||||||
|
}, 300);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// TypeError "Load failed" = обрыв соединения — пробуем ещё раз
|
||||||
|
if (err instanceof TypeError && attempt < MAX_ATTEMPTS) {
|
||||||
|
setProgress(5, `Соединение прервано, повторная попытка (${attempt}/${MAX_ATTEMPTS - 1})...`);
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (err instanceof TypeError) {
|
||||||
|
showError('Соединение прервано при загрузке видео. Проверьте интернет и попробуйте ещё раз.');
|
||||||
|
} else {
|
||||||
|
showError(err.message, err.videoUrl);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(pct, text) {
|
||||||
|
progressFill.style.width = pct + '%';
|
||||||
|
progressText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg, videoUrl) {
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
resultCard.style.display = 'block';
|
||||||
|
let html = `<div class="error-box"><strong>Ошибка:</strong> ${msg}</div>`;
|
||||||
|
if (videoUrl) {
|
||||||
|
html += `<div style="text-align:center; margin-top:15px">
|
||||||
|
<a class="btn-download" href="${videoUrl}">🎬 Скачать записанное видео</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
resultContent.innerHTML = html;
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResult(data) {
|
||||||
|
resultCard.style.display = 'block';
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Кнопки скачивания (вверху блока результатов)
|
||||||
|
if (data.share_id) {
|
||||||
|
html += `
|
||||||
|
<div class="download-buttons">
|
||||||
|
<a class="btn-download" href="/download/${data.share_id}/pdf">📄 Скачать PDF</a>
|
||||||
|
<a class="btn-download" href="/download/${data.share_id}/docx">📝 Скачать DOCX</a>
|
||||||
|
${data.video_url ? `<a class="btn-download" href="${data.video_url}">🎬 Скачать видео</a>` : ''}
|
||||||
|
<a class="btn-download" href="https://max.ru/miac_dnr_bot" target="_blank" rel="noopener">🏥 Запись на диспанцеризацию</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сводка
|
||||||
|
html += `<div class="summary-box">${data.summary}</div>`;
|
||||||
|
|
||||||
|
// Предупреждения
|
||||||
|
if (data.warnings && data.warnings.length > 0) {
|
||||||
|
html += `<div class="warnings-box"><h3>⚠️ Требует внимания</h3><ul>`;
|
||||||
|
data.warnings.forEach(w => html += `<li>${w}</li>`);
|
||||||
|
html += `</ul></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Таблица показателей
|
||||||
|
html += `<h2>Показатели</h2>`;
|
||||||
|
html += `<table class="metrics-table"><thead><tr><th>Показатель</th><th>Значение</th><th>Оценка</th></tr></thead><tbody>`;
|
||||||
|
data.metrics.forEach(m => {
|
||||||
|
const statusText = {
|
||||||
|
'ok': 'В норме',
|
||||||
|
'warning': 'Обратить внимание',
|
||||||
|
'danger': 'Отклонение',
|
||||||
|
'info': 'Информация'
|
||||||
|
}[m.status] || m.status;
|
||||||
|
html += `<tr>
|
||||||
|
<td>${m.name}</td>
|
||||||
|
<td><strong>${m.value}</strong>${m.comment ? `<br><small style="color:#888">${m.comment}</small>` : ''}</td>
|
||||||
|
<td><span class="status-badge status-${m.status}">${statusText}</span></td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
|
||||||
|
// Рекомендации
|
||||||
|
if (data.advice && data.advice.length > 0) {
|
||||||
|
html += `<div class="advice-box"><h3>💡 Рекомендации</h3><ul>`;
|
||||||
|
data.advice.forEach(a => html += `<li>${a}</li>`);
|
||||||
|
html += `</ul></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div class="disclaimer">⚠️ Результат получен автоматически по анализу видео и не является медицинским диагнозом. Для точной оценки обратитесь к врачу.</div>`;
|
||||||
|
|
||||||
|
// Кнопка "Поделиться"
|
||||||
|
if (data.share_url) {
|
||||||
|
html += `
|
||||||
|
<div style="text-align:center; margin-top:20px">
|
||||||
|
<button class="btn" onclick="shareResult('${data.share_url}', this)">🔗 Поделиться результатом</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultContent.innerHTML = html;
|
||||||
|
resultCard.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareResult(url, btn) {
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
const oldText = btn.textContent;
|
||||||
|
btn.textContent = '✅ Ссылка скопирована!';
|
||||||
|
setTimeout(() => btn.textContent = oldText, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Сканирование с камеры ───
|
||||||
|
const scanSection = document.getElementById('scanSection');
|
||||||
|
const scanBtn = document.getElementById('scanBtn');
|
||||||
|
const scanModal = document.getElementById('scanModal');
|
||||||
|
const scanVideo = document.getElementById('scanVideo');
|
||||||
|
const scanProgressText = document.getElementById('scanProgressText');
|
||||||
|
const scanProgressFill = document.getElementById('scanProgressFill');
|
||||||
|
const scanCancelBtn = document.getElementById('scanCancelBtn');
|
||||||
|
|
||||||
|
const SCAN_FRAMES = 310; // чуть больше 300, чтобы в файле гарантированно было >= 300 кадров
|
||||||
|
const SCAN_FPS = 30;
|
||||||
|
const FRAME_INTERVAL = 1000 / SCAN_FPS;
|
||||||
|
|
||||||
|
let mediaStream = null;
|
||||||
|
let mediaRecorder = null;
|
||||||
|
let scanCanvas = null;
|
||||||
|
let scanCtx = null;
|
||||||
|
let scanFrameCount = 0;
|
||||||
|
let scanRafId = null;
|
||||||
|
let scanLastFrameTime = 0;
|
||||||
|
let scanChunks = [];
|
||||||
|
let scanActive = false;
|
||||||
|
|
||||||
|
// Показываем кнопку сканирования только на мобильных устройствах
|
||||||
|
const isMobile = window.matchMedia('(max-width: 768px)').matches || ('ontouchstart' in window);
|
||||||
|
if (isMobile) {
|
||||||
|
scanSection.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
scanBtn.addEventListener('click', startScan);
|
||||||
|
scanCancelBtn.addEventListener('click', cancelScan);
|
||||||
|
|
||||||
|
async function startScan() {
|
||||||
|
if (scanActive) return;
|
||||||
|
scanActive = true;
|
||||||
|
scanBtn.disabled = true;
|
||||||
|
|
||||||
|
// Включаем фронтальную камеру
|
||||||
|
try {
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: 'user',
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
scanActive = false;
|
||||||
|
scanBtn.disabled = false;
|
||||||
|
showError('Не удалось получить доступ к камере: ' + (err.message || 'разрешите доступ'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanModal.classList.add('open');
|
||||||
|
scanVideo.srcObject = mediaStream;
|
||||||
|
try { await scanVideo.play(); } catch (e) {}
|
||||||
|
|
||||||
|
scanChunks = [];
|
||||||
|
scanFrameCount = 0;
|
||||||
|
scanLastFrameTime = 0;
|
||||||
|
updateScanProgress(0);
|
||||||
|
|
||||||
|
// Canvas 720×720 — квадрат, лицо целиком влезает无论是 portrait или landscape.
|
||||||
|
// Серверное extract_frames масштабирует до 1280×720 для SDK.
|
||||||
|
scanCanvas = document.createElement('canvas');
|
||||||
|
scanCanvas.width = 720;
|
||||||
|
scanCanvas.height = 720;
|
||||||
|
scanCtx = scanCanvas.getContext('2d');
|
||||||
|
|
||||||
|
let captureStream;
|
||||||
|
try {
|
||||||
|
captureStream = scanCanvas.captureStream(SCAN_FPS);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: записываем напрямую с камеры
|
||||||
|
captureStream = mediaStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4']
|
||||||
|
.find(t => MediaRecorder.isTypeSupported(t)) || '';
|
||||||
|
mediaRecorder = new MediaRecorder(captureStream, {
|
||||||
|
mimeType: mimeType || undefined,
|
||||||
|
videoBitsPerSecond: 1500000 // 1.5 Мбит/с — маленький файл, качество достаточное
|
||||||
|
});
|
||||||
|
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) scanChunks.push(e.data); };
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
const blob = new Blob(scanChunks, { type: mediaRecorder.mimeType || 'video/webm' });
|
||||||
|
const ext = blob.type.includes('mp4') ? 'mp4' : 'webm';
|
||||||
|
scanModal.classList.remove('open');
|
||||||
|
stopCamera();
|
||||||
|
scanActive = false;
|
||||||
|
scanBtn.disabled = false;
|
||||||
|
sendVideo(blob, `scan.${ext}`);
|
||||||
|
};
|
||||||
|
mediaRecorder.start();
|
||||||
|
|
||||||
|
// Рисуем кадры с ограничением ~30 fps, считаем до 300
|
||||||
|
const drawFrame = (ts) => {
|
||||||
|
if (!scanActive) return;
|
||||||
|
if (scanFrameCount >= SCAN_FRAMES) {
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ts - scanLastFrameTime >= FRAME_INTERVAL) {
|
||||||
|
// Квадратный center-crop: берём центральную квадратную область из видео.
|
||||||
|
const vw = scanVideo.videoWidth;
|
||||||
|
const vh = scanVideo.videoHeight;
|
||||||
|
if (vw && vh) {
|
||||||
|
const side = Math.min(vw, vh);
|
||||||
|
const sx = (vw - side) / 2;
|
||||||
|
const sy = (vh - side) / 2;
|
||||||
|
scanCtx.drawImage(scanVideo, sx, sy, side, side, 0, 0, 720, 720);
|
||||||
|
} else {
|
||||||
|
scanCtx.drawImage(scanVideo, 0, 0, 720, 720);
|
||||||
|
}
|
||||||
|
scanFrameCount++;
|
||||||
|
scanLastFrameTime = ts;
|
||||||
|
updateScanProgress(scanFrameCount);
|
||||||
|
}
|
||||||
|
scanRafId = requestAnimationFrame(drawFrame);
|
||||||
|
};
|
||||||
|
scanRafId = requestAnimationFrame(drawFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScanProgress(count) {
|
||||||
|
const pct = Math.min(100, Math.round(count / SCAN_FRAMES * 100));
|
||||||
|
scanProgressText.textContent = `Кадров: ${count} / ${SCAN_FRAMES} (${pct}%)`;
|
||||||
|
scanProgressFill.style.width = pct + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCamera() {
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach(t => t.stop());
|
||||||
|
mediaStream = null;
|
||||||
|
}
|
||||||
|
if (scanRafId) cancelAnimationFrame(scanRafId);
|
||||||
|
scanRafId = null;
|
||||||
|
scanVideo.srcObject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelScan() {
|
||||||
|
scanActive = false;
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||||
|
mediaRecorder.onstop = null;
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
stopCamera();
|
||||||
|
scanModal.classList.remove('open');
|
||||||
|
scanBtn.disabled = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
<!-- SEO -->
|
||||||
|
<title>{% if result %}Результат диагностики #{{ result.share_id }} | med.my-dpr.ru{% else %}Результат не найден | med.my-dpr.ru{% endif %}</title>
|
||||||
|
<meta name="description" content="{% if result %}{{ result.summary }}{% else %}Результат анализа не найден.{% endif %}">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<meta name="theme-color" content="#0014FC">
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:site_name" content="Диагностика по видео">
|
||||||
|
<meta property="og:title" content="{% if result %}Результат диагностики #{{ result.share_id }} — оценка здоровья по лицу{% else %}Результат не найден{% endif %}">
|
||||||
|
<meta property="og:description" content="{% if result %}{{ result.summary }}{% else %}Результат анализа не найден.{% endif %}">
|
||||||
|
<meta property="og:url" content="https://med.my-dpr.ru/r/{{ result.share_id if result else '' }}">
|
||||||
|
<meta property="og:image" content="https://med.my-dpr.ru/static/og-image.png">
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
<meta property="og:image:alt" content="Результат диагностики по видео">
|
||||||
|
<meta property="og:locale" content="ru_RU">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="{% if result %}Результат диагностики #{{ result.share_id }}{% else %}Результат не найден{% endif %}">
|
||||||
|
<meta name="twitter:description" content="{% if result %}{{ result.summary }}{% else %}Результат анализа не найден.{% endif %}">
|
||||||
|
<meta name="twitter:image" content="https://med.my-dpr.ru/static/og-image.png">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🩺</text></svg>">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #061A40 0%, #0B4F9C 50%, #2D9CDB 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container { max-width: 900px; margin: 0 auto; }
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h1 { color: #fff; text-align: center; margin-bottom: 20px; font-size: 28px; }
|
||||||
|
h2 { color: #333; margin-bottom: 15px; font-size: 20px; }
|
||||||
|
.subtitle { color: rgba(255,255,255,0.9); text-align: center; margin-bottom: 25px; font-size: 15px; }
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.result-id {
|
||||||
|
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.result-date { color: #888; font-size: 13px; }
|
||||||
|
|
||||||
|
.download-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn-download {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.btn-download:hover { filter: brightness(1.15); }
|
||||||
|
.btn-download:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
.summary-box {
|
||||||
|
background: #f0f4ff;
|
||||||
|
border-left: 4px solid #0014FC;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.metrics-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||||
|
.metrics-table th, .metrics-table td { padding: 10px 15px; text-align: left; border-bottom: 1px solid #eee; }
|
||||||
|
.metrics-table th { background: #f8f9fa; font-weight: 600; color: #555; }
|
||||||
|
.metrics-table tr:hover { background: #fafafa; }
|
||||||
|
.status-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||||
|
.status-ok { background: #d4edda; color: #155724; }
|
||||||
|
.status-warning { background: #fff3cd; color: #856404; }
|
||||||
|
.status-danger { background: #f8d7da; color: #721c24; }
|
||||||
|
.status-info { background: #d1ecf1; color: #0c5460; }
|
||||||
|
|
||||||
|
.warnings-box {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.warnings-box h3 { color: #856404; margin-bottom: 10px; font-size: 16px; }
|
||||||
|
.warnings-box ul { padding-left: 20px; }
|
||||||
|
.warnings-box li { margin: 5px 0; color: #856404; }
|
||||||
|
|
||||||
|
.advice-box {
|
||||||
|
background: #d4edda;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.advice-box h3 { color: #155724; margin-bottom: 10px; font-size: 16px; }
|
||||||
|
.advice-box ul { padding-left: 20px; }
|
||||||
|
.advice-box li { margin: 5px 0; color: #155724; }
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 15px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.error-box {
|
||||||
|
background: #f8d7da;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.btn:hover { filter: brightness(1.15); }
|
||||||
|
.btn-secondary {
|
||||||
|
background: #eee;
|
||||||
|
color: #333;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: #ddd; }
|
||||||
|
.center { text-align: center; }
|
||||||
|
.share-box {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.share-box input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(100px);
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.3s;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||||
|
|
||||||
|
/* ===== Мобильная версия ===== */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body { padding: 12px; }
|
||||||
|
h1 { font-size: 22px; margin-bottom: 12px; }
|
||||||
|
.subtitle { font-size: 14px; margin-bottom: 18px; }
|
||||||
|
.card { padding: 18px; border-radius: 14px; margin-bottom: 14px; }
|
||||||
|
h2 { font-size: 18px; }
|
||||||
|
.result-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||||
|
.metrics-table thead { display: none; }
|
||||||
|
.metrics-table, .metrics-table tbody, .metrics-table tr, .metrics-table td { display: block; width: 100%; }
|
||||||
|
.metrics-table tr { border: 1px solid #eee; border-radius: 10px; margin-bottom: 10px; padding: 10px 12px; }
|
||||||
|
.metrics-table td { border: none; padding: 5px 0; }
|
||||||
|
.metrics-table td:first-child { font-weight: 600; color: #555; }
|
||||||
|
.summary-box, .warnings-box, .advice-box { padding: 12px 14px; font-size: 15px; }
|
||||||
|
.btn, .btn-secondary { width: 100%; padding: 14px 20px; font-size: 16px; min-height: 48px; }
|
||||||
|
.share-box { flex-direction: column; align-items: stretch; }
|
||||||
|
.share-box input { width: 100%; min-width: 0; }
|
||||||
|
.download-buttons { flex-direction: column; }
|
||||||
|
.btn-download { width: 100%; min-width: 0; padding: 14px 20px; font-size: 16px; min-height: 48px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🩺 Диагностика по видео</h1>
|
||||||
|
<p class="subtitle">Оценка состояния здоровья по лицу</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="error-box">
|
||||||
|
<strong>Ошибка:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
<div class="center">
|
||||||
|
<a href="/" class="btn">🔍 Пройти диагностику</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="result-header">
|
||||||
|
<span class="result-id">Результат #{{ result.share_id }}</span>
|
||||||
|
<span class="result-date">📅 {{ result.created_at[:10] }} {{ result.created_at[11:16] }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-buttons">
|
||||||
|
<a class="btn-download" href="/download/{{ result.share_id }}/pdf">📄 Скачать PDF</a>
|
||||||
|
<a class="btn-download" href="/download/{{ result.share_id }}/docx">📝 Скачать DOCX</a>
|
||||||
|
{% if video_exists %}
|
||||||
|
<a class="btn-download" href="/download/{{ result.share_id }}/video">🎬 Скачать видео</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn-download" href="https://max.ru/miac_dnr_bot" target="_blank" rel="noopener">🏥 Запись на диспанцеризацию</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-box">{{ result.summary }}</div>
|
||||||
|
|
||||||
|
{% if result.warnings %}
|
||||||
|
<div class="warnings-box">
|
||||||
|
<h3>⚠️ Требует внимания</h3>
|
||||||
|
<ul>
|
||||||
|
{% for w in result.warnings %}
|
||||||
|
<li>{{ w }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Показатели</h2>
|
||||||
|
<table class="metrics-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Показатель</th><th>Значение</th><th>Оценка</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in result.metrics %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ m.name }}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ m.value }}</strong>
|
||||||
|
{% if m.comment %}<br><small style="color:#888">{{ m.comment }}</small>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge status-{{ m.status }}">
|
||||||
|
{% if m.status == 'ok' %}В норме
|
||||||
|
{% elif m.status == 'warning' %}Обратить внимание
|
||||||
|
{% elif m.status == 'danger' %}Отклонение
|
||||||
|
{% else %}Информация{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if result.advice %}
|
||||||
|
<div class="advice-box">
|
||||||
|
<h3>💡 Рекомендации</h3>
|
||||||
|
<ul>
|
||||||
|
{% for a in result.advice %}
|
||||||
|
<li>{{ a }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="share-box">
|
||||||
|
<input type="text" id="shareLink" value="https://med.my-dpr.ru/r/{{ result.share_id }}" readonly>
|
||||||
|
<button class="btn-secondary" onclick="copyLink()">📋 Копировать ссылку</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="disclaimer">⚠️ Результат получен автоматически по анализу видео и не является медицинским диагнозом. Для точной оценки обратитесь к врачу.</div>
|
||||||
|
|
||||||
|
<div class="center" style="margin-top:20px">
|
||||||
|
<a href="/" class="btn">🔍 Пройти диагностику</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast">Ссылка скопирована!</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyLink() {
|
||||||
|
const input = document.getElementById('shareLink');
|
||||||
|
input.select();
|
||||||
|
input.setSelectionRange(0, 99999);
|
||||||
|
navigator.clipboard.writeText(input.value).then(() => {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(() => toast.classList.remove('show'), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Тестовый скрипт для SDK ФПГ (GigaDoc).
|
||||||
|
Покрывает: авторизацию → создание сессии → WebSocket (изображения) → удаление сессии.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python3 test_fpg.py [--ws-host HOST] [--ws-port PORT] [--image-dir DIR]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# ─── Конфигурация ─────────────────────────────────────────────
|
||||||
|
OAUTH_URL = "https://oauth.sbermed.ai/token"
|
||||||
|
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}"
|
||||||
|
|
||||||
|
DEVICE_ID = "web_prod"
|
||||||
|
FORM_FACTOR = "web_prod"
|
||||||
|
|
||||||
|
|
||||||
|
def get_token() -> str:
|
||||||
|
"""Получить access token через client_credentials."""
|
||||||
|
resp = requests.post(
|
||||||
|
OAUTH_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"client_secret": CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
print(f"[AUTH] Токен получен (действует {data['expires_in']} сек)")
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(token: str) -> str:
|
||||||
|
"""Создать сессию и вернуть uid."""
|
||||||
|
resp = requests.post(
|
||||||
|
f"{REST_BASE}/api/v1/predict/new",
|
||||||
|
params={"device_id": DEVICE_ID, "form_factor": FORM_FACTOR},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
uid = resp.json()["uid"]
|
||||||
|
print(f"[SESSION] Сессия создана: {uid}")
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
def delete_session(token: str, uid: str):
|
||||||
|
"""Удалить сессию."""
|
||||||
|
resp = requests.delete(
|
||||||
|
f"{REST_BASE}/api/v1/predict/{uid}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
print(f"[SESSION] Сессия {uid} удалена ({resp.status_code})")
|
||||||
|
|
||||||
|
|
||||||
|
async def ws_scan(token: str, uid: str, ws_host: str, ws_port: int, image_dir: str):
|
||||||
|
"""Подключиться по WebSocket, отправить изображения, получить результат."""
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
uri = f"wss://{ws_host}:{ws_port}/api/v2/ws/predict/{uid}/image"
|
||||||
|
print(f"[WS] Подключение: {uri}")
|
||||||
|
|
||||||
|
async with websockets.connect(
|
||||||
|
uri,
|
||||||
|
open_timeout=10,
|
||||||
|
additional_headers={"Authorization": f"Bearer {token}"},
|
||||||
|
) as ws:
|
||||||
|
print("[WS] Соединение установлено")
|
||||||
|
|
||||||
|
# Собираем изображения
|
||||||
|
patterns = ["*.jpg", "*.jpeg", "*.png"]
|
||||||
|
images = []
|
||||||
|
for p in patterns:
|
||||||
|
images.extend(glob.glob(os.path.join(image_dir, p)))
|
||||||
|
images.sort()
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
print(f"[WS] Нет изображений в {image_dir}, отправляю тестовое изображение")
|
||||||
|
images = ["/tmp/test_face.jpg"]
|
||||||
|
|
||||||
|
print(f"[WS] Отправляю {len(images)} изображений...")
|
||||||
|
|
||||||
|
for i, img_path in enumerate(images):
|
||||||
|
with open(img_path, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
await ws.send(data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await asyncio.wait_for(ws.recv(), timeout=5)
|
||||||
|
result = json.loads(resp)
|
||||||
|
progress = result.get("progress", "?")
|
||||||
|
|
||||||
|
# Краткий вывод прогресса
|
||||||
|
if i % 30 == 0 or progress == 1.0:
|
||||||
|
print(
|
||||||
|
f" [{i+1}/{len(images)}] progress={progress} "
|
||||||
|
f"face_ok={result.get('face_ok')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Финальный результат
|
||||||
|
if progress == 1.0 or (isinstance(progress, (int, float)) and progress >= 1.0):
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("📊 ФИНАЛЬНЫЙ РЕЗУЛЬТАТ ДИАГНОСТИКИ:")
|
||||||
|
print("=" * 60)
|
||||||
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||||
|
return result
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if i % 50 == 0:
|
||||||
|
print(f" [{i+1}/{len(images)}] ожидание ответа...")
|
||||||
|
|
||||||
|
print("[WS] Соединение закрыто без финального результата")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_image():
|
||||||
|
"""Создать тестовое JPEG-изображение если нет своих."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = Image.new("RGB", (1280, 720), color=(128, 128, 128))
|
||||||
|
img.save("/tmp/test_face.jpg", "JPEG", quality=80)
|
||||||
|
print("[INIT] Создано тестовое изображение /tmp/test_face.jpg")
|
||||||
|
except ImportError:
|
||||||
|
print("[INIT] PIL не установлен, тестовое изображение не создано")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Тест SDK ФПГ")
|
||||||
|
parser.add_argument("--ws-host", default="sdk.sbermed.ai", help="WebSocket host")
|
||||||
|
parser.add_argument("--ws-port", type=int, default=8443, help="WebSocket port")
|
||||||
|
parser.add_argument("--image-dir", default=".", help="Директория с JPEG-файлами")
|
||||||
|
parser.add_argument("--rest-only", action="store_true", help="Только REST API (без WS)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("🏥 Тестирование SDK ФПГ (GigaDoc)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 0. Тестовое изображение
|
||||||
|
create_test_image()
|
||||||
|
|
||||||
|
# 1. Авторизация
|
||||||
|
token = get_token()
|
||||||
|
|
||||||
|
# 2. Создание сессии
|
||||||
|
uid = create_session(token)
|
||||||
|
|
||||||
|
# 3. WebSocket
|
||||||
|
if not args.rest_only:
|
||||||
|
try:
|
||||||
|
result = asyncio.run(
|
||||||
|
ws_scan(token, uid, args.ws_host, args.ws_port, args.image_dir)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[WS] Ошибка: {type(e).__name__}: {e}")
|
||||||
|
print("[WS] Проверьте доступность WebSocket хоста")
|
||||||
|
result = None
|
||||||
|
else:
|
||||||
|
result = None
|
||||||
|
print("[SKIP] WebSocket тест пропущен (--rest-only)")
|
||||||
|
|
||||||
|
# 4. Удаление сессии
|
||||||
|
delete_session(token, uid)
|
||||||
|
|
||||||
|
# Итог
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
if result:
|
||||||
|
print("✅ Тест завершён успешно — результат получен")
|
||||||
|
else:
|
||||||
|
print("⚠️ REST API работает, WebSocket недоступен")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user