diff --git a/.gitignore b/.gitignore index 72d6d57..e2d2e48 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,9 @@ video.mp4 *.mov # Документация стороннего SDK -*.pdf \ No newline at end of file +*.pdf + +# Отдельные репозитории/проекты (не часть sber_med.git) +server/ +wordpress/ +wordpress-plugin/ \ No newline at end of file diff --git a/README.md b/README.md index f4a5454..829cbf6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Диагностика по видео (ФПГ GigaDoc) +# Med Diagnostics — API-сервер (ФПГ GigaDoc) Веб-приложение: пользователь загружает или записывает видео с лицом → система извлекает кадры → отправляет в SDK ФПГ (GigaDoc) → показывает понятный диагноз с сохранением в SQLite, share-ссылками, экспортом PDF/DOCX и скачиванием записанного видео. @@ -15,16 +15,34 @@ | reportlab | 5.0.1 (PDF-экспорт, кириллица через DejaVu) | | python-docx | 1.2.0 (DOCX-экспорт) | -Установка зависимостей (на сервере venv могут быть сломаны — ставится через `pip install --user`): +## Быстрый запуск (локально) ```bash -pip install --user flask waitress websockets requests reportlab python-docx +# 1. Клонировать +git clone https://git.itguild.info/apuc/sber-med-server.git med-server +cd med-server + +# 2. Зависимости (рекомендуется venv) +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# 3. Шрифты DejaVu для кириллицы в PDF (обычно уже есть в системе) +sudo apt install -y fonts-dejavu + +# 4. Секреты SDK ФПГ (обязательно!) +export FPG_CLIENT_ID=client_fpg_front_dnr +export FPG_CLIENT_SECRET=ВАШ_СЕКРЕТ + +# 5. Запуск (dev-режим, порт 5000) +python3 app.py +# или через waitress (как на проде): +python3 -c "from waitress import serve; from app import app; serve(app, host='127.0.0.1', port=18018, threads=8)" ``` -Шрифты DejaVu для кириллицы в PDF (обычно уже есть в системе): -```bash -apt list --installed 2>/dev/null | grep fonts-dejavu || sudo apt install -y fonts-dejavu -``` +Приложение само создаёт при старте: +- `videos/` — сохранённые записи сканирования (рядом с app.py) +- `results.db` — SQLite с результатами (share_id начиная с 10005) ## Переменные окружения (секреты!) @@ -32,24 +50,28 @@ apt list --installed 2>/dev/null | grep fonts-dejavu || sudo apt install -y font | Переменная | Описание | |---|---| -| `FPG_CLIENT_ID` | client_id для OAuth SDK ФПГ (по умолчанию `client_fpg_front`) | +| `FPG_CLIENT_ID` | client_id для OAuth SDK ФПГ (по умолчанию `client_fpg_front_dnr` — персональный стенд) | | `FPG_CLIENT_SECRET` | client_secret для OAuth SDK ФПГ (**обязательна**, без неё авторизация не работает) | +| `CORS_ORIGINS` | (необязательно) список разрешённых Origin для CORS через запятую, например `https://site1.ru,https://site2.ru`. По умолчанию `*` | +| `FPG_OAUTH_URL` | (необязательно) URL OAuth-токена (по умолчанию `https://oauth.sbermed.ai/token`) | +| `FPG_REST_HOST` | (необязательно) REST-хост SDK (по умолчанию `gigadoc-dnr-sdk.sbermed.ai` — персональный стенд) | +| `FPG_WS_HOST` | (необязательно) WebSocket-хост SDK (по умолчанию `gigadoc-dnr-sdk.sbermed.ai`) | +| `FPG_WS_PORT` | (необязательно) WebSocket-порт SDK (по умолчанию `8443`) | +| `FPG_WS_ORIGIN` | (необязательно) Origin для WebSocket (по умолчанию `https://gigadoc-dnr-sdk.sbermed.ai`) | -## Развёртывание +**Хосты с путём.** `FPG_REST_HOST` и `FPG_WS_HOST` могут содержать не только домен, но и путь — например `domain.com/v1/giga` (или полностью `https://domain.com/v1/giga`). Путь автоматически подставляется в REST-базу и в WebSocket-URI (`wss://domain.com:8443/v1/giga/api/v2/ws/predict/...`). + +## Развёртывание на сервере ### 1. Клонирование и структура ```bash sudo mkdir -p /opt/med && sudo chown $USER:$USER /opt/med -git clone https://git.itguild.info/apuc/sber_med.git /opt/med +git clone https://git.itguild.info/apuc/sber-med-server.git /opt/med cd /opt/med mkdir -p templates static videos ``` -Приложение само создаёт при старте: -- `videos/` — сохранённые записи сканирования (рядом с app.py) -- `results.db` — SQLite с результатами (share_id начиная с 10005) - ### 2. Systemd-сервис `/etc/systemd/system/med.service`: @@ -64,9 +86,9 @@ Type=simple User=apuc WorkingDirectory=/opt/med Environment=PYTHONUNBUFFERED=1 -Environment=FPG_CLIENT_ID=client_fpg_front +Environment=FPG_CLIENT_ID=client_fpg_front_dnr Environment=FPG_CLIENT_SECRET=ВАШ_СЕКРЕТ -ExecStart=/usr/bin/python3 -c "from waitress import serve; from app import app; serve(app, host='127.0.0.1', port=8063, threads=8)" +ExecStart=/usr/bin/python3 -c "from waitress import serve; from app import app; serve(app, host='127.0.0.1', port=18018, threads=8)" Restart=always RestartSec=5 @@ -91,7 +113,7 @@ server { client_max_body_size 100M; # видео до 100 МБ location / { - proxy_pass http://127.0.0.1:8063; + proxy_pass http://127.0.0.1:18018; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -133,14 +155,32 @@ sudo systemctl restart med.service ## Как работает анализ 1. Видео сохраняется в `videos/` (копия записи — доступна для скачивания) -2. `ffmpeg` извлекает 300 кадров 1280×720 (bt709 + yuv420p — важно для iPhone MOV с P3/HDR) -3. OAuth-токен → создание сессии → кадры по WebSocket (`wss://gigadoc-sdk.sbermed.ai:8443`, subprotocol `bearer.`, Origin `https://gigadoc.sbermed.ai`) +2. `ffmpeg` извлекает до 300 кадров 1280×720 (первые 20 секунд, fps=30) с нормализацией: HDR→bt709 (iPhone P3/HDR), поворот по метаданным, yuv420p +3. OAuth-токен → создание сессии → кадры по WebSocket (`wss://gigadoc-dnr-sdk.sbermed.ai:8443`, subprotocol `bearer.`, Origin `https://gigadoc-dnr-sdk.sbermed.ai`) 4. Интерпретация JSON SDK → summary/metrics/warnings/advice → сохранение в SQLite ## Важные нюансы - **SDK ФПГ не находит лицо на кадрах 640×480** — нужны кадры 1280×720. -- **Камерное сканирование** (мобильные): canvas 720×720 (квадрат, без обрезки головы), MediaRecorder с битрейтом 1.5 Мбит/с (~1–2 МБ за 310 кадров), автоповтор fetch до 3 попыток при обрыве сети. +- **Камерное сканирование** (мобильные): canvas 1080×1080 (квадрат), овал-подсказка для позиционирования лица, запас 10% при захвате (голова не обрезается), MediaRecorder с битрейтом 1.5 Мбит/с (~1–2 МБ за 310 кадров), автоповтор fetch до 3 попыток при обрыве сети. +- **Логирование прогресса SDK**: в логах сервера пишется `progress` и `face_ok` при каждом изменении; если финальный результат не пришёл — предупреждение в лог. - **iOS MediaRecorder** пишет mp4 (webm может не поддерживаться) — расширение выбирается из `blob.type`. -- **Порт 5000** на сервере занят другим сервисом — приложение слушает 8063. -- Дисклеймер «не медицинский диагноз» — во всех отчётах и на страницах. \ No newline at end of file +- **Порт 5000** на сервере занят другим сервисом — приложение слушает 18018. +- Дисклеймер «не медицинский диагноз» — во всех отчётах и на страницах. + +## Структура + +``` +server/ +├── app.py — Flask-приложение (маршруты, CORS, анализ) +├── storage.py — SQLite (share_id, результаты) +├── export.py — PDF/DOCX-экспорт +├── interpret.py — интерпретация JSON SDK → понятный результат +├── fpg_stream.py — утилита: потоковая отправка кадров в SDK +├── fpg_connect.py — утилита: проверка подключения к SDK +├── gigadoc_ws.py — утилита: низкоуровневый WebSocket-клиент SDK (нужен aiohttp) +├── test_fpg.py — тестовая отправка кадров +├── templates/ — index.html, result.html +├── static/ — og-image.png +└── requirements.txt +``` \ No newline at end of file diff --git a/app.py b/app.py index c4c6ff3..4373d79 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,7 @@ import glob import json import logging import os +import re import shutil import subprocess import sys @@ -33,16 +34,39 @@ 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") +# Хосты стенда можно переопределить через env (FPG_REST_HOST и т.д.). +# ВАЖНО: хост может содержать не только домен, но и путь +# (например "domain.com/v1/giga") — это учитывается при сборке URL. +OAUTH_URL = os.environ.get("FPG_OAUTH_URL", "https://oauth.sbermed.ai/token") +CLIENT_ID = os.environ.get("FPG_CLIENT_ID", "client_fpg_front_dnr") 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" +def split_host_path(host: str) -> tuple: + """Разделить 'host/path' на (scheme, hostname, base_path). + + Поддерживает варианты: "domain.com", "domain.com/v1/giga", + "https://domain.com/v1/giga". Схема возвращается без "://". + """ + host = host.strip().rstrip("/") + scheme = "" + if "://" in host: + scheme, host = host.split("://", 1) + parts = host.split("/", 1) + hostname = parts[0] + base_path = "/" + parts[1] if len(parts) > 1 else "" + return scheme, hostname, base_path + + +REST_HOST = os.environ.get("FPG_REST_HOST", "gigadoc-dnr-sdk.sbermed.ai") +_rest_scheme, _rest_hostname, _rest_base_path = split_host_path(REST_HOST) +REST_BASE = f"{_rest_scheme or 'https'}://{_rest_hostname}{_rest_base_path}" + +WS_HOST = os.environ.get("FPG_WS_HOST", "gigadoc-dnr-sdk.sbermed.ai") +WS_PORT = int(os.environ.get("FPG_WS_PORT", "8443")) +_ws_scheme, WS_HOSTNAME, WS_BASE_PATH = split_host_path(WS_HOST) +WS_PATH_TEMPLATE = f"{WS_BASE_PATH}/api/v2/ws/predict/{{uid}}/image" +WS_ORIGIN = os.environ.get("FPG_WS_ORIGIN", "https://gigadoc-dnr-sdk.sbermed.ai") DEVICE_ID = "web_prod" FORM_FACTOR = "web_prod" @@ -75,6 +99,23 @@ VIDEO_MIME = { app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 МБ +# CORS для интеграции с WordPress (кросс-доменные запросы с сайта на API). +# Список разрешённых origin через запятую, по умолчанию "*" (любой). +CORS_ORIGINS = [o.strip() for o in os.environ.get("CORS_ORIGINS", "*").split(",") if o.strip()] + + +@app.after_request +def add_cors_headers(response): + origin = request.headers.get("Origin") + if "*" in CORS_ORIGINS: + response.headers["Access-Control-Allow-Origin"] = "*" + elif origin and origin in CORS_ORIGINS: + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Vary"] = "Origin" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + return response + # ─── Логирование ────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, @@ -134,7 +175,7 @@ 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)}" + uri = f"wss://{WS_HOSTNAME}:{WS_PORT}{WS_PATH_TEMPLATE.format(uid=uid)}" interval = 1.0 / fps try: @@ -142,7 +183,7 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS): uri, open_timeout=10, subprotocols=[f"bearer.{token}"], - origin="https://gigadoc.sbermed.ai", + origin=WS_ORIGIN, extra_headers={ "Cache-Control": "no-cache", "Pragma": "no-cache", @@ -153,6 +194,7 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS): max_size=None, ) as ws: result = None + last_progress = -1 for i, frame_file in enumerate(frame_files): frame_start = time.time() with open(frame_file, "rb") as f: @@ -162,7 +204,16 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS): try: resp = await asyncio.wait_for(ws.recv(), timeout=2.0) data = json.loads(resp) - if data.get("progress", 0) >= 1.0: + progress = data.get("progress", 0.0) + # Логируем прогресс при изменении (для диагностики) + pct = int(progress * 100) + if pct != last_progress: + logger.info( + f" [{i+1}/{len(frame_files)}] progress={progress:.2f} " + f"face_ok={data.get('face_ok')}" + ) + last_progress = pct + if progress >= 1.0: result = data break except (asyncio.TimeoutError, json.JSONDecodeError): @@ -172,34 +223,139 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS): if elapsed < interval: await asyncio.sleep(interval - elapsed) + if result is None: + logger.warning( + f"Финальный результат не получен после {len(frame_files)} кадров" + ) return result except Exception as e: logger.error(f"WebSocket error: {e}") return None +def probe_video(video_path: str) -> dict: + """Получить информацию о видео через ffprobe (кодек, цвета, поворот, FPS).""" + cmd = [ + "ffprobe", "-v", "error", + "-select_streams", "v:0", + "-show_entries", + "stream=codec_name,width,height,pix_fmt,avg_frame_rate,color_space,color_transfer,color_primaries", + "-of", "json", + video_path, + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + raise RuntimeError(f"Ошибка чтения видео: {result.stderr[-300:]}") + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + return {} + streams = data.get("streams") or [{}] + info = streams[0] if streams else {} + + # Поворот: в ffmpeg 5+ это stream_side_data (Display Matrix), + # в 4.x — тег rotate. Пробуем оба варианта. + try: + r = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream_side_data=rotation", "-of", "json", video_path], + capture_output=True, text=True, timeout=30, + ) + if r.returncode == 0: + sd = (json.loads(r.stdout).get("streams") or [{}])[0].get("side_data_list") or [] + for item in sd: + if "rotation" in item: + info["rotation"] = item["rotation"] + except Exception: + pass + if "rotation" not in info: + try: + r = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream_tags=rotate", "-of", "json", video_path], + capture_output=True, text=True, timeout=30, + ) + if r.returncode == 0: + tags = (json.loads(r.stdout).get("streams") or [{}])[0].get("tags") or {} + if "rotate" in tags: + info["rotation"] = tags["rotate"] + except Exception: + pass + return info + + +_FFMPEG_MAJOR = None + + +def ffmpeg_major_version() -> int: + """Версия ffmpeg (кэшируется). Нужна для выбора параметров colorspace.""" + global _FFMPEG_MAJOR + if _FFMPEG_MAJOR is None: + try: + r = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True, timeout=10) + m = re.search(r"ffmpeg version (\d+)\.", r.stdout) + _FFMPEG_MAJOR = int(m.group(1)) if m else 0 + except Exception: + _FFMPEG_MAJOR = 0 + return _FFMPEG_MAJOR + + def extract_frames(video_path: str, work_dir: str) -> list: - """Извлечь кадры из видео в JPEG.""" + """Извлечь кадры из видео в JPEG с нормализацией. + + Телефонные видео отличаются от записанных через сайт: + - поворот (rotation metadata) — ffmpeg применяет autorotate по умолчанию; + - HDR/P3 (iPhone: bt2020 + smpte2084) — конвертируем в bt709, иначе кадры + выходят с искажёнными цветами и SDK не находит лицо; + - переменный FPS — нормализуем до 30. + """ frames_dir = os.path.join(work_dir, "frames") os.makedirs(frames_dir, exist_ok=True) + info = probe_video(video_path) + color_space = info.get("color_space") or "" + color_transfer = info.get("color_transfer") or "" + color_primaries = info.get("color_primaries") or "" + + filters = [] + + # HDR/P3 → SDR (bt709): реальная конверсия цветов (не только теги). + # Без неё кадры из iPhone-видео (bt2020/P3, HDR) выглядят «выцветшими» + # и SDK не находит лицо. + is_hdr = color_transfer in ("smpte2084", "arib-std-b67") or color_primaries in ("bt2020", "smpte432") + if is_hdr: + if ffmpeg_major_version() >= 5: + # ffmpeg 5+ знает smpte2084/arib-std-b67 — точная конверсия. + ispace = color_space if color_space in ("bt2020nc", "bt2020c") else "bt2020nc" + itrc = color_transfer if color_transfer in ("smpte2084", "arib-std-b67") else "bt709" + iprimaries = color_primaries if color_primaries in ("bt2020", "smpte432") else "bt2020" + else: + # ffmpeg 4.x: нет smpte2084/arib-std-b67 в colorspace (tonemap даёт + # чёрный кадр) — используем приблизительные значения bt2020. + ispace, itrc, iprimaries = "bt2020nc", "bt2020-10", "bt2020" + filters.append(f"colorspace=bt709:ispace={ispace}:itrc={itrc}:iprimaries={iprimaries}") + # Масштабируем до 1280x720 (с сохранением пропорций и добавлением полей). - # format=yuv420p + bt709 стандартизируют цвета (важно для iPhone MOV с P3/HDR, - # иначе кадры получаются с искажёнными цветами и SDK не находит лицо). - vf = ( + filters.append( 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" + f"pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2" ) + # Нормализуем FPS (телефонные видео часто с переменным FPS). + filters.append("fps=30") + + # 8-бит yuv420p (SDK ожидает такой формат). + filters.append("format=yuv420p") + cmd = [ "ffmpeg", "-y", "-i", video_path, "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709", - "-vf", vf, + "-vf", ",".join(filters), "-q:v", str(JPEG_QUALITY), + "-t", "20", # берём первые 20 секунд (достаточно для 300 кадров) "-frames:v", str(FRAMES_COUNT), os.path.join(frames_dir, "frame_%04d.jpg"), ] @@ -284,8 +440,12 @@ def download_video(video_id): ) -@app.route("/analyze", methods=["POST"]) +@app.route("/analyze", methods=["POST", "OPTIONS"]) def analyze(): + # Preflight-запрос CORS (браузер шлёт OPTIONS перед кросс-доменным POST) + if request.method == "OPTIONS": + return "", 204 + if "video" not in request.files: return jsonify({"error": "Видео не загружено"}), 400 @@ -391,4 +551,4 @@ if __name__ == "__main__": print("🩺 Диагностика по видео — ФПГ") print(f"Откройте в браузере: http://localhost:5000") print("=" * 60) - app.run(host="0.0.0.0", port=8063, debug=False) + app.run(host="0.0.0.0", port=18018, debug=False) diff --git a/fpg_connect.py b/fpg_connect.py index f911493..a0565bd 100644 --- a/fpg_connect.py +++ b/fpg_connect.py @@ -27,17 +27,35 @@ from pathlib import Path import requests # ─── Конфигурация ───────────────────────────────────────────── -OAUTH_URL = "https://oauth.sbermed.ai/token" -CLIENT_ID = "client_fpg_front" +# ВАЖНО: хост может содержать не только домен, но и путь +# (например "domain.com/v1/giga") — это учитывается при сборке URL. +OAUTH_URL = os.environ.get("FPG_OAUTH_URL", "https://oauth.sbermed.ai/token") +CLIENT_ID = os.environ.get("FPG_CLIENT_ID", "client_fpg_front_dnr") 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" +def split_host_path(host: str) -> tuple: + """Разделить 'host/path' на (scheme, hostname, base_path).""" + host = host.strip().rstrip("/") + scheme = "" + if "://" in host: + scheme, host = host.split("://", 1) + parts = host.split("/", 1) + hostname = parts[0] + base_path = "/" + parts[1] if len(parts) > 1 else "" + return scheme, hostname, base_path + + +REST_HOST = os.environ.get("FPG_REST_HOST", "gigadoc-dnr-sdk.sbermed.ai") +_rest_scheme, _rest_hostname, _rest_base_path = split_host_path(REST_HOST) +REST_BASE = f"{_rest_scheme or 'https'}://{_rest_hostname}{_rest_base_path}" + +# WebSocket (рабочий хост подтверждён: gigadoc-dnr-sdk.sbermed.ai:8443) +WS_HOST = os.environ.get("FPG_WS_HOST", "gigadoc-dnr-sdk.sbermed.ai") +WS_PORT = int(os.environ.get("FPG_WS_PORT", "8443")) +_ws_scheme, WS_HOSTNAME, WS_BASE_PATH = split_host_path(WS_HOST) +WS_PATH_TEMPLATE = f"{WS_BASE_PATH}/api/v2/ws/predict/{{uid}}/image" +WS_ORIGIN = os.environ.get("FPG_WS_ORIGIN", "https://gigadoc-dnr-sdk.sbermed.ai") DEVICE_ID = "web_prod" FORM_FACTOR = "web_prod" @@ -238,10 +256,14 @@ async def ws_scan(token: str, uid: str, ws_host: str, ws_port: int, image_dir: s logger.debug(f" {img} ({size} bytes)") # ── Пробуем несколько вариантов URI ── + # ws_host может быть с путём (domain.com/v1/giga) — разделяем. + _scheme, _h, _p = split_host_path(ws_host) + _ws_hostname = _h + _ws_path = _p or WS_BASE_PATH 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)}", + f"wss://{_ws_hostname}:{ws_port}{_ws_path}/api/v2/ws/predict/{uid}/image", + f"wss://{WS_HOSTNAME}:{ws_port}{WS_PATH_TEMPLATE.format(uid=uid)}", + f"wss://{WS_HOSTNAME}:443{WS_PATH_TEMPLATE.format(uid=uid)}", ] last_error = None @@ -263,7 +285,7 @@ async def ws_scan(token: str, uid: str, ws_host: str, ws_port: int, image_dir: s uri, open_timeout=10, subprotocols=[f"bearer.{token}"], - origin="https://gigadoc.sbermed.ai", + origin=WS_ORIGIN, extra_headers={ "Cache-Control": "no-cache", "Pragma": "no-cache", diff --git a/fpg_stream.py b/fpg_stream.py index 94b8a67..d12b92b 100644 --- a/fpg_stream.py +++ b/fpg_stream.py @@ -19,16 +19,34 @@ import requests import websockets # ─── Конфигурация ───────────────────────────────────────────── -OAUTH_URL = "https://oauth.sbermed.ai/token" -CLIENT_ID = "client_fpg_front" +# ВАЖНО: хост может содержать не только домен, но и путь +# (например "domain.com/v1/giga") — это учитывается при сборке URL. +OAUTH_URL = os.environ.get("FPG_OAUTH_URL", "https://oauth.sbermed.ai/token") +CLIENT_ID = os.environ.get("FPG_CLIENT_ID", "client_fpg_front_dnr") 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" +def split_host_path(host: str) -> tuple: + """Разделить 'host/path' на (scheme, hostname, base_path).""" + host = host.strip().rstrip("/") + scheme = "" + if "://" in host: + scheme, host = host.split("://", 1) + parts = host.split("/", 1) + hostname = parts[0] + base_path = "/" + parts[1] if len(parts) > 1 else "" + return scheme, hostname, base_path + + +REST_HOST = os.environ.get("FPG_REST_HOST", "gigadoc-dnr-sdk.sbermed.ai") +_rest_scheme, _rest_hostname, _rest_base_path = split_host_path(REST_HOST) +REST_BASE = f"{_rest_scheme or 'https'}://{_rest_hostname}{_rest_base_path}" + +WS_HOST = os.environ.get("FPG_WS_HOST", "gigadoc-dnr-sdk.sbermed.ai") +WS_PORT = int(os.environ.get("FPG_WS_PORT", "8443")) +_ws_scheme, WS_HOSTNAME, WS_BASE_PATH = split_host_path(WS_HOST) +WS_PATH_TEMPLATE = f"{WS_BASE_PATH}/api/v2/ws/predict/{{uid}}/image" +WS_ORIGIN = os.environ.get("FPG_WS_ORIGIN", "https://gigadoc-dnr-sdk.sbermed.ai") DEVICE_ID = "web_prod" FORM_FACTOR = "web_prod" @@ -121,7 +139,7 @@ async def ws_stream(token: str, uid: str, image_path: str, frames: int, fps: flo logger.info("ЭТАП 3: WebSocket подключение") logger.info("=" * 60) - uri = f"wss://{WS_HOST}:{WS_PORT}{WS_PATH_TEMPLATE.format(uid=uid)}" + uri = f"wss://{WS_HOSTNAME}:{WS_PORT}{WS_PATH_TEMPLATE.format(uid=uid)}" logger.info(f"URL: {uri}") # Читаем изображение (или директорию с кадрами) @@ -155,7 +173,7 @@ async def ws_stream(token: str, uid: str, image_path: str, frames: int, fps: flo uri, open_timeout=10, subprotocols=[f"bearer.{token}"], - origin="https://gigadoc.sbermed.ai", + origin=WS_ORIGIN, extra_headers={ "Cache-Control": "no-cache", "Pragma": "no-cache", diff --git a/gigadoc_ws.py b/gigadoc_ws.py index 6bdcbd4..5f60706 100644 --- a/gigadoc_ws.py +++ b/gigadoc_ws.py @@ -66,7 +66,7 @@ def parse_args(): parser.add_argument( "--sdk-host", required=True, - help="SDK hostname, например gigadoc-sdk.sbermed.ai", + help="SDK hostname, например gigadoc-dnr-sdk.sbermed.ai", ) parser.add_argument( @@ -94,10 +94,10 @@ def parse_args(): parser.add_argument( "--origin", - default="https://gigadoc.sbermed.ai", + default="https://gigadoc-dnr-sdk.sbermed.ai", help=( "Origin для WebSocket " - "(default: https://gigadoc.sbermed.ai)" + "(default: https://gigadoc-dnr-sdk.sbermed.ai)" ), ) @@ -211,6 +211,22 @@ def normalize_prefix(prefix: str) -> str: return prefix.rstrip("/") +def split_host_path(host: str) -> tuple: + """Разделить 'host/path' на (scheme, hostname, base_path). + + Поддерживает варианты: "domain.com", "domain.com/v1/giga", + "https://domain.com/v1/giga". Схема возвращается без "://". + """ + host = host.strip().rstrip("/") + scheme = "" + if "://" in host: + scheme, host = host.split("://", 1) + parts = host.split("/", 1) + hostname = parts[0] + base_path = "/" + parts[1] if len(parts) > 1 else "" + return scheme, hostname, base_path + + def build_ssl_context( insecure: bool, ) -> Optional[ssl.SSLContext]: @@ -329,7 +345,8 @@ async def create_predict_session( ) url = ( - f"https://{args.sdk_host}" + f"https://{args.sdk_hostname}" + f"{args.sdk_base_path}" f"{rest_prefix}/predict/new" ) @@ -494,7 +511,8 @@ async def websocket_predict( for port in ports: ws_url = ( - f"wss://{args.sdk_host}:{port}" + f"wss://{args.sdk_hostname}:{port}" + f"{args.sdk_base_path}" f"{ws_prefix}/{uid}/image" ) @@ -918,7 +936,8 @@ async def delete_predict_session( ) url = ( - f"https://{args.sdk_host}" + f"https://{args.sdk_hostname}" + f"{args.sdk_base_path}" f"{rest_prefix}/predict/{uid}" ) @@ -993,6 +1012,12 @@ async def main(): args = parse_args() + # sdk_host может содержать путь (domain.com/v1/giga) — + # разделяем на hostname и base_path. + _scheme, args.sdk_hostname, args.sdk_base_path = split_host_path( + args.sdk_host + ) + setup_logging( args.verbose ) diff --git a/templates/index.html b/templates/index.html index cbbb793..5030c4b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -47,7 +47,7 @@ margin: 0 auto; } .card { - background: #fff; + background: #faf6ec; border-radius: 16px; padding: 30px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); @@ -190,6 +190,16 @@ object-fit: cover; transform: scaleX(-1); } + /* Овал — безопасная зона: лицо должно помещаться в него целиком */ + .scan-oval { + position: absolute; + inset: 12%; + border: 3px solid rgba(255, 255, 255, 0.9); + border-radius: 50%; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.4); + pointer-events: none; + z-index: 2; + } .scan-progress { color: #fff; margin-top: 20px; font-size: 16px; text-align: center; } .scan-progress-bar { width: 280px; @@ -249,8 +259,8 @@ /* Результат */ .result { display: none; } .summary-box { - background: #f0f4ff; - border-left: 4px solid #0014FC; + background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB); + color: #fff; padding: 15px 20px; border-radius: 8px; margin-bottom: 20px; @@ -281,20 +291,20 @@ font-weight: 600; } .status-ok { background: #d4edda; color: #155724; } - .status-warning { background: #fff3cd; color: #856404; } + .status-warning { background: #ffe0d6; color: #a04a2f; } .status-danger { background: #f8d7da; color: #721c24; } - .status-info { background: #d1ecf1; color: #0c5460; } + .status-info { background: #dbe9ff; color: #1e4a8a; } .warnings-box { - background: #fff3cd; - border-left: 4px solid #ffc107; + background: #ffe0d6; + border-left: 4px solid #ff7f50; padding: 15px 20px; border-radius: 8px; margin-bottom: 20px; } - .warnings-box h3 { color: #856404; margin-bottom: 10px; font-size: 16px; } + .warnings-box h3 { color: #a04a2f; margin-bottom: 10px; font-size: 16px; } .warnings-box ul { padding-left: 20px; } - .warnings-box li { margin: 5px 0; color: #856404; } + .warnings-box li { margin: 5px 0; color: #a04a2f; } .advice-box { background: #d4edda; @@ -414,6 +424,7 @@
+
Кадров: 0 / 300 (0%)
@@ -422,7 +433,7 @@
-
Держите лицо в круге, смотрите прямо в камеру, не двигайтесь
+
Поместите лицо в овал целиком: лоб, глаза и подбородок — с запасом. Если голова не помещается — отодвиньте телефон