commit 04a7b31a7566c901e8b4f95e2f52edfb864439aa Author: apuc Date: Mon Sep 7 19:01:06 2026 +0300 Веб-приложение диагностики по видео (ФПГ GigaDoc) - Загрузка/сканирование видео, извлечение кадров, анализ через SDK ФПГ - Понятный диагноз с сохранением в SQLite и share-ссылками - Экспорт PDF/DOCX, скачивание записанного видео - Камерное сканирование на мобильных (canvas + MediaRecorder) - Секреты вынесены в переменные окружения diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72d6d57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Секреты и локальные данные +.env +*.db +videos/ + +# Логи (могут содержать токены SDK) +*.log + +# Временные и рабочие файлы +__pycache__/ +*.pyc +frames/ +.opencode/ + +# Тестовые медиафайлы +video.mp4 +*.webm +*.mp4 +*.mov + +# Документация стороннего SDK +*.pdf \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..c4c6ff3 --- /dev/null +++ b/app.py @@ -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/") +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//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//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") +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) diff --git a/export.py b/export.py new file mode 100644 index 0000000..bee713d --- /dev/null +++ b/export.py @@ -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() \ No newline at end of file diff --git a/fpg_connect.py b/fpg_connect.py new file mode 100644 index 0000000..f911493 --- /dev/null +++ b/fpg_connect.py @@ -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. (авторизация через 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()) diff --git a/fpg_stream.py b/fpg_stream.py new file mode 100644 index 0000000..94b8a67 --- /dev/null +++ b/fpg_stream.py @@ -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()) diff --git a/gigadoc_ws.py b/gigadoc_ws.py new file mode 100644 index 0000000..6bdcbd4 --- /dev/null +++ b/gigadoc_ws.py @@ -0,0 +1,1172 @@ +#!/usr/bin/env python3 + +import argparse +import asyncio +import json +import logging +import ssl +import sys +from pathlib import Path +from typing import Optional + +import aiohttp +import websockets +from websockets.exceptions import ( + ConnectionClosed, + InvalidMessage, + InvalidStatus, +) + + +LOG = logging.getLogger("gigadoc-client") + + +# ============================================================ +# CLI +# ============================================================ + +def parse_args(): + parser = argparse.ArgumentParser( + description=( + "GigaDoc SDK client: " + "Keycloak -> REST -> WebSocket -> DELETE" + ) + ) + + # -------------------------------------------------------- + # Keycloak + # -------------------------------------------------------- + + parser.add_argument( + "--keycloak-url", + required=True, + help=( + "Keycloak token URL, например: " + "https://keycloak.example.com/realms/REALM/" + "protocol/openid-connect/token" + ), + ) + + parser.add_argument( + "--client-id", + required=True, + help="Keycloak client_id", + ) + + parser.add_argument( + "--client-secret", + required=True, + help="Keycloak client_secret", + ) + + # -------------------------------------------------------- + # SDK + # -------------------------------------------------------- + + parser.add_argument( + "--sdk-host", + required=True, + help="SDK hostname, например gigadoc-sdk.sbermed.ai", + ) + + parser.add_argument( + "--rest-prefix", + default="/api/v1", + help="REST API prefix (default: /api/v1)", + ) + + parser.add_argument( + "--ws-prefix", + default="/api/v2/ws/predict", + help="WebSocket API prefix (default: /api/v2/ws/predict)", + ) + + parser.add_argument( + "--ws-port", + type=int, + action="append", + default=None, + help=( + "WebSocket port. Можно указать несколько раз. " + "По умолчанию: 8443,443" + ), + ) + + parser.add_argument( + "--origin", + default="https://gigadoc.sbermed.ai", + help=( + "Origin для WebSocket " + "(default: https://gigadoc.sbermed.ai)" + ), + ) + + # -------------------------------------------------------- + # Predict + # -------------------------------------------------------- + + parser.add_argument( + "--device-id", + required=True, + help="device_id", + ) + + parser.add_argument( + "--form-factor", + required=True, + help="form_factor", + ) + + # -------------------------------------------------------- + # Image + # -------------------------------------------------------- + + parser.add_argument( + "--image", + required=True, + help="Путь к JPEG изображению", + ) + + # -------------------------------------------------------- + # Timeouts + # -------------------------------------------------------- + + parser.add_argument( + "--http-timeout", + type=float, + default=30.0, + help="HTTP timeout в секундах (default: 30)", + ) + + parser.add_argument( + "--ws-open-timeout", + type=float, + default=30.0, + help="WebSocket handshake timeout (default: 30)", + ) + + parser.add_argument( + "--ws-message-timeout", + type=float, + default=60.0, + help=( + "Timeout ожидания следующего WS сообщения " + "(default: 60)" + ), + ) + + # -------------------------------------------------------- + # Options + # -------------------------------------------------------- + + parser.add_argument( + "--insecure", + action="store_true", + help="Отключить проверку TLS сертификата", + ) + + parser.add_argument( + "--keep-alive", + action="store_true", + help=( + "Не закрывать WS сразу после progress=1.0; " + "продолжать читать сообщения" + ), + ) + + parser.add_argument( + "--no-delete", + action="store_true", + help="Не удалять predict session после завершения", + ) + + parser.add_argument( + "--verbose", + action="store_true", + help="DEBUG logging", + ) + + return parser.parse_args() + + +# ============================================================ +# Logging +# ============================================================ + +def setup_logging(verbose: bool): + logging.basicConfig( + level=logging.DEBUG if verbose else logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + ) + + +# ============================================================ +# Helpers +# ============================================================ + +def normalize_prefix(prefix: str) -> str: + if not prefix.startswith("/"): + prefix = "/" + prefix + + return prefix.rstrip("/") + + +def build_ssl_context( + insecure: bool, +) -> Optional[ssl.SSLContext]: + """ + Для обычного wss:// возвращаем None. + + В websockets 17.1 параметр ssl=None нельзя явно + передавать вместе с wss://. + + Поэтому SSL context добавляется только при --insecure. + """ + + if not insecure: + return None + + context = ssl.create_default_context() + + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + return context + + +# ============================================================ +# Keycloak +# ============================================================ + +async def get_access_token( + session: aiohttp.ClientSession, + args, +) -> str: + + LOG.info("") + LOG.info("=" * 70) + LOG.info("KEYCLOAK") + LOG.info("=" * 70) + + LOG.info( + "POST %s", + args.keycloak_url, + ) + + data = { + "grant_type": "client_credentials", + "client_id": args.client_id, + "client_secret": args.client_secret, + } + + try: + async with session.post( + args.keycloak_url, + data=data, + ) as response: + + body = await response.text() + + LOG.info( + "Keycloak -> HTTP %s", + response.status, + ) + + if args.verbose: + LOG.debug( + "Keycloak response body: %s", + body, + ) + + if response.status != 200: + raise RuntimeError( + "Keycloak returned HTTP " + f"{response.status}: {body}" + ) + + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + raise RuntimeError( + "Keycloak returned invalid JSON" + ) from exc + + access_token = payload.get( + "access_token" + ) + + if not access_token: + raise RuntimeError( + "Keycloak response does not contain " + "access_token" + ) + + LOG.info( + "Access token получен" + ) + + return access_token + + except Exception: + LOG.exception( + "Ошибка получения access token" + ) + raise + + +# ============================================================ +# Create session +# ============================================================ + +async def create_predict_session( + session: aiohttp.ClientSession, + args, + access_token: str, +) -> str: + + rest_prefix = normalize_prefix( + args.rest_prefix + ) + + url = ( + f"https://{args.sdk_host}" + f"{rest_prefix}/predict/new" + ) + + params = { + "device_id": args.device_id, + "form_factor": args.form_factor, + } + + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + } + + LOG.info("") + LOG.info("=" * 70) + LOG.info("CREATE PREDICT SESSION") + LOG.info("=" * 70) + + LOG.info( + "POST %s", + url, + ) + + LOG.info( + "device_id=%s", + args.device_id, + ) + + LOG.info( + "form_factor=%s", + args.form_factor, + ) + + try: + async with session.post( + url, + params=params, + headers=headers, + ) as response: + + body = await response.text() + + LOG.info( + "POST /predict/new -> HTTP %s", + response.status, + ) + + if args.verbose: + LOG.debug( + "Response body: %s", + body, + ) + + if not ( + 200 <= response.status < 300 + ): + raise RuntimeError( + f"POST {url} returned HTTP " + f"{response.status}: {body}" + ) + + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + raise RuntimeError( + "Invalid JSON from predict/new: " + f"{body}" + ) from exc + + uid = payload.get("uid") + + if not uid: + raise RuntimeError( + "Response does not contain uid: " + f"{payload}" + ) + + LOG.info( + "UID = %s", + uid, + ) + + return uid + + except Exception: + LOG.exception( + "Ошибка создания predict session" + ) + raise + + +# ============================================================ +# WebSocket +# ============================================================ + +async def websocket_predict( + args, + access_token: str, + uid: str, +) -> bool: + + ws_prefix = normalize_prefix( + args.ws_prefix + ) + + # Если --ws-port не задан: + # сначала 8443, затем 443. + ports = args.ws_port + + if not ports: + ports = [ + 8443, + 443, + ] + + image_path = Path( + args.image + ) + + if not image_path.is_file(): + raise FileNotFoundError( + f"Image not found: {image_path}" + ) + + image_data = image_path.read_bytes() + + if not image_data: + raise RuntimeError( + f"Image is empty: {image_path}" + ) + + LOG.info("") + LOG.info( + "JPEG: %s", + image_path, + ) + + LOG.info( + "JPEG size: %d bytes", + len(image_data), + ) + + # -------------------------------------------------------- + # ВАЖНО: + # + # Фронт подключается примерно так: + # + # Sec-WebSocket-Protocol: + # bearer. + # + # Поэтому делаем абсолютно то же самое. + # -------------------------------------------------------- + + bearer_protocol = ( + f"bearer.{access_token}" + ) + + ssl_context = build_ssl_context( + args.insecure + ) + + for port in ports: + + ws_url = ( + f"wss://{args.sdk_host}:{port}" + f"{ws_prefix}/{uid}/image" + ) + + LOG.info("") + LOG.info("=" * 70) + LOG.info( + "WEBSOCKET :%d", + port, + ) + LOG.info("=" * 70) + + LOG.info( + "URL: %s", + ws_url, + ) + + LOG.debug( + "Origin: %s", + args.origin, + ) + + LOG.debug( + "Subprotocol: bearer.", + ) + + # ---------------------------------------------------- + # Формируем kwargs отдельно. + # + # Нельзя передавать ssl=None в websockets 17.1 + # для wss://. + # ---------------------------------------------------- + + connect_kwargs = { + "subprotocols": [ + bearer_protocol + ], + + "additional_headers": { + "Origin": args.origin, + "Cache-Control": "no-cache", + "Pragma": "no-cache", + }, + + "compression": "deflate", + + "open_timeout": ( + args.ws_open_timeout + ), + + "ping_interval": 20, + + "ping_timeout": 20, + + "max_size": None, + } + + # Только при --insecure. + if ssl_context is not None: + connect_kwargs["ssl"] = ( + ssl_context + ) + + try: + + LOG.debug( + "Открываем WebSocket..." + ) + + async with websockets.connect( + ws_url, + **connect_kwargs, + ) as ws: + + LOG.info("") + LOG.info( + "✅ WEBSOCKET :%d CONNECTED", + port, + ) + + LOG.info( + "Negotiated subprotocol: %s", + ws.subprotocol, + ) + + # ------------------------------------------------ + # Проверяем, что сервер согласовал bearer protocol. + # ------------------------------------------------ + + if ws.subprotocol != bearer_protocol: + + LOG.warning( + "⚠️ Server did not negotiate " + "expected bearer subprotocol" + ) + + LOG.warning( + "Expected: bearer." + ) + + LOG.warning( + "Received: %s", + ws.subprotocol, + ) + + else: + LOG.info( + "✅ Bearer subprotocol accepted" + ) + + # ------------------------------------------------ + # Отправляем JPEG как binary frame + # ------------------------------------------------ + + LOG.info("") + LOG.info( + "Отправка JPEG..." + ) + + await ws.send( + image_data + ) + + LOG.info( + "✅ JPEG отправлен: %d bytes", + len(image_data), + ) + + # ------------------------------------------------ + # Читаем ответы + # ------------------------------------------------ + + while True: + + try: + message = await asyncio.wait_for( + ws.recv(), + timeout=( + args.ws_message_timeout + ), + ) + + except asyncio.TimeoutError: + + LOG.error( + "⏱ Таймаут ожидания " + "сообщения от WebSocket" + ) + + return False + + except ConnectionClosed as exc: + + LOG.warning( + "WebSocket закрыт сервером: %s", + exc, + ) + + return False + + if message is None: + + LOG.warning( + "WebSocket returned None" + ) + + return False + + # ------------------------------------------------ + # Binary response + # ------------------------------------------------ + + if isinstance( + message, + bytes, + ): + + LOG.info( + "WS <- binary: %d bytes", + len(message), + ) + + continue + + # ------------------------------------------------ + # JSON response + # ------------------------------------------------ + + LOG.debug( + "WS <- %s", + message, + ) + + try: + payload = json.loads( + message + ) + + except json.JSONDecodeError: + + LOG.warning( + "Получено не-JSON " + "сообщение:" + ) + + LOG.warning( + "%s", + message, + ) + + continue + + LOG.info( + "WS result: %s", + json.dumps( + payload, + ensure_ascii=False, + ), + ) + + # ------------------------------------------------ + # Progress + # + # Согласно API: + # + # 0.0 ... 1.0 + # + # 1.0 = завершено + # ------------------------------------------------ + + progress = payload.get( + "progress" + ) + + if progress is None: + continue + + try: + + progress_float = float( + progress + ) + + except ( + TypeError, + ValueError, + ): + + LOG.warning( + "Некорректный progress: %r", + progress, + ) + + continue + + LOG.info( + "Progress: %.1f%%", + progress_float * 100, + ) + + if progress_float >= 1.0: + + LOG.info("") + LOG.info( + "✅ PREDICT ЗАВЕРШЁН" + ) + + # ------------------------------------------------ + # Если keep-alive не задан — сразу выходим. + # ------------------------------------------------ + + if not args.keep_alive: + return True + + LOG.info( + "--keep-alive задан, " + "оставляем WebSocket открытым" + ) + + while True: + + try: + + message = ( + await asyncio.wait_for( + ws.recv(), + timeout=( + args.ws_message_timeout + ), + ) + ) + + LOG.info( + "WS <- %s", + message, + ) + + except asyncio.TimeoutError: + + LOG.info( + "Keep-alive timeout" + ) + + return True + + except ConnectionClosed as exc: + + LOG.info( + "Server closed WS: %s", + exc, + ) + + return True + + # -------------------------------------------------------- + # HTTP status ошибки handshake + # -------------------------------------------------------- + + except InvalidStatus as exc: + + LOG.error("") + LOG.error( + "❌ WEBSOCKET :%d FAILED", + port, + ) + + LOG.error( + "Exception type: %s", + type(exc).__name__, + ) + + LOG.error( + "Exception: %s", + exc, + ) + + continue + + # -------------------------------------------------------- + # Сервер закрыл соединение без HTTP response. + # -------------------------------------------------------- + + except InvalidMessage as exc: + + LOG.error("") + LOG.error( + "❌ WEBSOCKET :%d FAILED", + port, + ) + + LOG.error( + "Exception type: %s", + type(exc).__name__, + ) + + LOG.error( + "Exception: %s", + exc, + ) + + continue + + # -------------------------------------------------------- + # WS закрылся после подключения + # -------------------------------------------------------- + + except ConnectionClosed as exc: + + LOG.error("") + LOG.error( + "❌ WEBSOCKET :%d CLOSED", + port, + ) + + LOG.error( + "Exception: %s", + exc, + ) + + continue + + # -------------------------------------------------------- + # Остальные ошибки + # -------------------------------------------------------- + + except Exception as exc: + + LOG.error("") + LOG.error( + "❌ WEBSOCKET :%d FAILED", + port, + ) + + LOG.error( + "Exception type: %s", + type(exc).__name__, + ) + + LOG.error( + "Exception: %s", + exc, + ) + + continue + + return False + + +# ============================================================ +# DELETE +# ============================================================ + +async def delete_predict_session( + session: aiohttp.ClientSession, + args, + access_token: str, + uid: str, +): + + rest_prefix = normalize_prefix( + args.rest_prefix + ) + + url = ( + f"https://{args.sdk_host}" + f"{rest_prefix}/predict/{uid}" + ) + + headers = { + "Authorization": f"Bearer {access_token}", + } + + LOG.info("") + LOG.info("=" * 70) + LOG.info("DELETE PREDICT SESSION") + LOG.info("=" * 70) + + LOG.info( + "DELETE %s", + url, + ) + + try: + + async with session.delete( + url, + headers=headers, + ) as response: + + body = await response.text() + + LOG.info( + "DELETE /predict/%s -> HTTP %s", + uid, + response.status, + ) + + if args.verbose and body: + LOG.debug( + "Delete response: %s", + body, + ) + + if not ( + 200 <= response.status < 300 + ): + + LOG.warning( + "⚠️ DELETE failed: HTTP %s", + response.status, + ) + + if body: + LOG.warning( + "Response: %s", + body, + ) + + else: + + LOG.info( + "✅ Session удалена" + ) + + except Exception: + + LOG.exception( + "Ошибка удаления predict session" + ) + + +# ============================================================ +# Main +# ============================================================ + +async def main(): + + args = parse_args() + + setup_logging( + args.verbose + ) + + LOG.info("") + LOG.info("=" * 70) + LOG.info("GIGADOC SDK PYTHON CLIENT") + LOG.info("=" * 70) + + LOG.info( + "SDK host: %s", + args.sdk_host, + ) + + LOG.info( + "REST prefix: %s", + args.rest_prefix, + ) + + LOG.info( + "WS prefix: %s", + args.ws_prefix, + ) + + # -------------------------------------------------------- + # Проверяем изображение + # -------------------------------------------------------- + + image_path = Path( + args.image + ) + + if not image_path.is_file(): + + LOG.error( + "❌ Файл изображения не найден: %s", + image_path, + ) + + return 1 + + # -------------------------------------------------------- + # HTTP connector + # -------------------------------------------------------- + + connector = aiohttp.TCPConnector( + ssl=False if args.insecure else None + ) + + timeout = aiohttp.ClientTimeout( + total=args.http_timeout + ) + + uid = None + access_token = None + + async with aiohttp.ClientSession( + connector=connector, + timeout=timeout, + ) as session: + + try: + + # ==================================================== + # 1. Keycloak + # ==================================================== + + access_token = await get_access_token( + session, + args, + ) + + # ==================================================== + # 2. POST /api/v1/predict/new + # ==================================================== + + uid = await create_predict_session( + session, + args, + access_token, + ) + + # ==================================================== + # 3. WebSocket + # ==================================================== + + success = await websocket_predict( + args, + access_token, + uid, + ) + + if success: + + LOG.info("") + LOG.info("=" * 70) + LOG.info( + "✅ УСПЕШНО" + ) + LOG.info("=" * 70) + + else: + + LOG.error("") + LOG.error("=" * 70) + LOG.error( + "❌ WEBSOCKET НЕДОСТУПЕН" + ) + LOG.error("=" * 70) + + return 2 + + except KeyboardInterrupt: + + LOG.warning( + "Получен Ctrl+C" + ) + + return 130 + + except Exception: + + LOG.exception( + "❌ Критическая ошибка" + ) + + return 1 + + finally: + + # ==================================================== + # 4. DELETE /api/v1/predict/{uid} + # ==================================================== + + if ( + uid + and access_token + and not args.no_delete + ): + + await delete_predict_session( + session, + args, + access_token, + uid, + ) + + elif ( + uid + and args.no_delete + ): + + LOG.info( + "Session %s оставлена " + "(--no-delete)", + uid, + ) + + return 0 + + +# ============================================================ +# Entry point +# ============================================================ + +if __name__ == "__main__": + + try: + exit_code = asyncio.run( + main() + ) + + except KeyboardInterrupt: + exit_code = 130 + + sys.exit(exit_code) + diff --git a/interpret.py b/interpret.py new file mode 100644 index 0000000..4e27d66 --- /dev/null +++ b/interpret.py @@ -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, + } diff --git a/static/og-image.png b/static/og-image.png new file mode 100644 index 0000000..07f830d Binary files /dev/null and b/static/og-image.png differ diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..34ec05c --- /dev/null +++ b/storage.py @@ -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] \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..cbbb793 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,770 @@ + + + + + + + + Диагностика по видео — оценка здоровья по лицу | med.my-dpr.ru + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

🩺 Диагностика по видео

+

Загрузите видео с лицом — получите оценку состояния здоровья

+ +
+

📷 Сканирование

+

Наведите камеру на лицо — мы сделаем 300 кадров и проведём анализ

+ +
+ +
+

Загрузка видео

+
+
📹
+

Нажмите или перетащите видео сюда

+

MP4, AVI, MOV, MKV • до 100 МБ • 10–15 секунд • лицо анфас

+ +
+
+ +
+ +
+
+
+
+
Подготовка...
+
+
+ +
+
+

📋 Результат анализа

+ +
+
+
+
+ + +
+
+ +
+
+
Кадров: 0 / 300 (0%)
+
+
+
+
+ +
Держите лицо в круге, смотрите прямо в камеру, не двигайтесь
+
+ + + + \ No newline at end of file diff --git a/templates/result.html b/templates/result.html new file mode 100644 index 0000000..34d978c --- /dev/null +++ b/templates/result.html @@ -0,0 +1,345 @@ + + + + + + + + {% if result %}Результат диагностики #{{ result.share_id }} | med.my-dpr.ru{% else %}Результат не найден | med.my-dpr.ru{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

🩺 Диагностика по видео

+

Оценка состояния здоровья по лицу

+ + {% if error %} +
+
+ Ошибка: {{ error }} +
+ +
+ {% else %} +
+
+ Результат #{{ result.share_id }} + 📅 {{ result.created_at[:10] }} {{ result.created_at[11:16] }} +
+ + + +
{{ result.summary }}
+ + {% if result.warnings %} +
+

⚠️ Требует внимания

+
    + {% for w in result.warnings %} +
  • {{ w }}
  • + {% endfor %} +
+
+ {% endif %} + +

Показатели

+ + + + + + {% for m in result.metrics %} + + + + + + {% endfor %} + +
ПоказательЗначениеОценка
{{ m.name }} + {{ m.value }} + {% if m.comment %}
{{ m.comment }}{% endif %} +
+ + {% if m.status == 'ok' %}В норме + {% elif m.status == 'warning' %}Обратить внимание + {% elif m.status == 'danger' %}Отклонение + {% else %}Информация{% endif %} + +
+ + {% if result.advice %} +
+

💡 Рекомендации

+
    + {% for a in result.advice %} +
  • {{ a }}
  • + {% endfor %} +
+
+ {% endif %} + + + +
⚠️ Результат получен автоматически по анализу видео и не является медицинским диагнозом. Для точной оценки обратитесь к врачу.
+ + +
+ {% endif %} +
+ +
Ссылка скопирована!
+ + + + \ No newline at end of file diff --git a/test_fpg.py b/test_fpg.py new file mode 100644 index 0000000..6ffdb93 --- /dev/null +++ b/test_fpg.py @@ -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()