From 04a7b31a7566c901e8b4f95e2f52edfb864439aa Mon Sep 17 00:00:00 2001 From: apuc Date: Mon, 7 Sep 2026 19:01:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1-=D0=BF=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B8=D0=B0=D0=B3?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=20(=D0=A4=D0=9F=D0=93=20GigaDoc)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Загрузка/сканирование видео, извлечение кадров, анализ через SDK ФПГ - Понятный диагноз с сохранением в SQLite и share-ссылками - Экспорт PDF/DOCX, скачивание записанного видео - Камерное сканирование на мобильных (canvas + MediaRecorder) - Секреты вынесены в переменные окружения --- .gitignore | 22 + app.py | 394 ++++++++++++++ export.py | 268 ++++++++++ fpg_connect.py | 449 ++++++++++++++++ fpg_stream.py | 314 +++++++++++ gigadoc_ws.py | 1172 +++++++++++++++++++++++++++++++++++++++++ interpret.py | 366 +++++++++++++ static/og-image.png | Bin 0 -> 24778 bytes storage.py | 124 +++++ templates/index.html | 770 +++++++++++++++++++++++++++ templates/result.html | 345 ++++++++++++ test_fpg.py | 195 +++++++ 12 files changed, 4419 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 export.py create mode 100644 fpg_connect.py create mode 100644 fpg_stream.py create mode 100644 gigadoc_ws.py create mode 100644 interpret.py create mode 100644 static/og-image.png create mode 100644 storage.py create mode 100644 templates/index.html create mode 100644 templates/result.html create mode 100644 test_fpg.py 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 0000000000000000000000000000000000000000..07f830d21822a865dd75a66c7326ea098771f7ab GIT binary patch literal 24778 zcmd42WmH_-(l**50tq2#Ah^2)ch`pC?!kk*YmfvdxVvj7xRc=S5*&gw?yleJ?7h$3 zcaL-4^M3c&?a^cOvRSic&8nxKnmt#rysQM$3!E1q5C{n@DXItpJ^2a(!8s$q0aw10 z>>`3d@1ww?LdveGdkZe9Mk;BC4_fslB@RPw`5x{j?)FCSKfM%rMMFgr5<>L8&FP|c z7-wq1E8R&<>?sBlQU41Pso0l~AIBKV$?d*Pa~RS&x{#=paZ+)bEGyPB8-m^yV=-== zmpKf}6zpH5=UyMXc#s1)@ z{Hb_s{EI&{LjOy{GMX_i*drj(Cx2b~r);-B4fem(MXH}D2PTk+!u%hWrpSkf-4Id` z`QOdVKNb0FI^yu&y+ejcBFX;re>d0vkSIk9{%?}ZwuFBxYw(}CGE;FR`R!oD{~w1X z)zA8ew@{RcivI3PIJ=h$HoSj1;6MH4Ke_T>!$Ti`3^e!6EF}J|nUGo0A2Go6f2-m@ z1^u(*SxHl%pD!M#62>3Bj`n>%D6&%muJn3Rt!3d2EHpC{eGLV!g3um;pf4xY2#5eZ zI=)Ig(5|6oy14kHT%v$bN?-xWME%PylZQkEBsiepwf^ywBoy#}4T3w!v81U`dE-{p zBT&%OCjlVeHz*=mnJjI>Ai-EtK$s}xP59#lRBSjGV1DH& zVT1q*LMv{34s@ijjLIY|WO{*u2?rtx2RsTzAMTUi5CY;O5UMDE7(s7-hzOYm1cH2n z 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 ZsmoVCn{R2I8#*XQSNrC*GR=FB{|mlnZe#!e literal 0 HcmV?d00001 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()