Веб-приложение диагностики по видео (ФПГ 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