Веб-приложение диагностики по видео (ФПГ GigaDoc)

- Загрузка/сканирование видео, извлечение кадров, анализ через SDK ФПГ
- Понятный диагноз с сохранением в SQLite и share-ссылками
- Экспорт PDF/DOCX, скачивание записанного видео
- Камерное сканирование на мобильных (canvas + MediaRecorder)
- Секреты вынесены в переменные окружения
This commit is contained in:
apuc
2026-09-07 19:01:06 +03:00
commit 04a7b31a75
12 changed files with 4419 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Секреты и локальные данные
.env
*.db
videos/
# Логи (могут содержать токены SDK)
*.log
# Временные и рабочие файлы
__pycache__/
*.pyc
frames/
.opencode/
# Тестовые медиафайлы
video.mp4
*.webm
*.mp4
*.mov
# Документация стороннего SDK
*.pdf
+394
View File
@@ -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)
+268
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+366
View File
@@ -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
View File
@@ -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]
+770
View File
@@ -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>
+345
View File
@@ -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
View File
@@ -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()