Синхронизация с server/: нормализация видео, овал-подсказка, хосты с путём, DNR-стенд
- app.py: probe_video(), HDR→bt709, fps=30, -t 20, логирование progress/face_ok, split_host_path() для хостов с путём (domain.com/v1/giga), FPG_WS_PORT - templates/index.html: овал-подсказка, canvas 1080×1080, запас 10% - templates/result.html: стилевые правки (цвета) - gigadoc_ws.py: поддержка пути в --sdk-host - test_fpg.py: переведён на DNR-стенд (bearer subprotocol, Origin) - README: хосты с путём, FPG_WS_PORT, нормализация - .gitignore: server/, wordpress/, wordpress-plugin/ (отдельные репозитории)
This commit is contained in:
1 parent
421183b7e3
commit
7375618b1d
9 files changed
+423
-108
No files matched your search
+6
-1
@@ -19,4 +19,9 @@ video.mp4
|
|||||||
*.mov
|
*.mov
|
||||||
|
|
||||||
# Документация стороннего SDK
|
# Документация стороннего SDK
|
||||||
*.pdf
|
*.pdf
|
||||||
|
|
||||||
|
# Отдельные репозитории/проекты (не часть sber_med.git)
|
||||||
|
server/
|
||||||
|
wordpress/
|
||||||
|
wordpress-plugin/
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Диагностика по видео (ФПГ GigaDoc)
|
# Med Diagnostics — API-сервер (ФПГ GigaDoc)
|
||||||
|
|
||||||
Веб-приложение: пользователь загружает или записывает видео с лицом → система извлекает кадры → отправляет в SDK ФПГ (GigaDoc) → показывает понятный диагноз с сохранением в SQLite, share-ссылками, экспортом PDF/DOCX и скачиванием записанного видео.
|
Веб-приложение: пользователь загружает или записывает видео с лицом → система извлекает кадры → отправляет в SDK ФПГ (GigaDoc) → показывает понятный диагноз с сохранением в SQLite, share-ссылками, экспортом PDF/DOCX и скачиванием записанного видео.
|
||||||
|
|
||||||
@@ -15,16 +15,34 @@
|
|||||||
| reportlab | 5.0.1 (PDF-экспорт, кириллица через DejaVu) |
|
| reportlab | 5.0.1 (PDF-экспорт, кириллица через DejaVu) |
|
||||||
| python-docx | 1.2.0 (DOCX-экспорт) |
|
| python-docx | 1.2.0 (DOCX-экспорт) |
|
||||||
|
|
||||||
Установка зависимостей (на сервере venv могут быть сломаны — ставится через `pip install --user`):
|
## Быстрый запуск (локально)
|
||||||
|
|
||||||
```bash
|
```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
|
- `videos/` — сохранённые записи сканирования (рядом с app.py)
|
||||||
apt list --installed 2>/dev/null | grep fonts-dejavu || sudo apt install -y fonts-dejavu
|
- `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 ФПГ (**обязательна**, без неё авторизация не работает) |
|
| `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. Клонирование и структура
|
### 1. Клонирование и структура
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /opt/med && sudo chown $USER:$USER /opt/med
|
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
|
cd /opt/med
|
||||||
mkdir -p templates static videos
|
mkdir -p templates static videos
|
||||||
```
|
```
|
||||||
|
|
||||||
Приложение само создаёт при старте:
|
|
||||||
- `videos/` — сохранённые записи сканирования (рядом с app.py)
|
|
||||||
- `results.db` — SQLite с результатами (share_id начиная с 10005)
|
|
||||||
|
|
||||||
### 2. Systemd-сервис
|
### 2. Systemd-сервис
|
||||||
|
|
||||||
`/etc/systemd/system/med.service`:
|
`/etc/systemd/system/med.service`:
|
||||||
@@ -64,9 +86,9 @@ Type=simple
|
|||||||
User=apuc
|
User=apuc
|
||||||
WorkingDirectory=/opt/med
|
WorkingDirectory=/opt/med
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
Environment=FPG_CLIENT_ID=client_fpg_front
|
Environment=FPG_CLIENT_ID=client_fpg_front_dnr
|
||||||
Environment=FPG_CLIENT_SECRET=ВАШ_СЕКРЕТ
|
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
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -91,7 +113,7 @@ server {
|
|||||||
client_max_body_size 100M; # видео до 100 МБ
|
client_max_body_size 100M; # видео до 100 МБ
|
||||||
|
|
||||||
location / {
|
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 Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -133,14 +155,32 @@ sudo systemctl restart med.service
|
|||||||
## Как работает анализ
|
## Как работает анализ
|
||||||
|
|
||||||
1. Видео сохраняется в `videos/` (копия записи — доступна для скачивания)
|
1. Видео сохраняется в `videos/` (копия записи — доступна для скачивания)
|
||||||
2. `ffmpeg` извлекает 300 кадров 1280×720 (bt709 + yuv420p — важно для iPhone MOV с P3/HDR)
|
2. `ffmpeg` извлекает до 300 кадров 1280×720 (первые 20 секунд, fps=30) с нормализацией: HDR→bt709 (iPhone P3/HDR), поворот по метаданным, yuv420p
|
||||||
3. OAuth-токен → создание сессии → кадры по WebSocket (`wss://gigadoc-sdk.sbermed.ai:8443`, subprotocol `bearer.<token>`, Origin `https://gigadoc.sbermed.ai`)
|
3. OAuth-токен → создание сессии → кадры по WebSocket (`wss://gigadoc-dnr-sdk.sbermed.ai:8443`, subprotocol `bearer.<token>`, Origin `https://gigadoc-dnr-sdk.sbermed.ai`)
|
||||||
4. Интерпретация JSON SDK → summary/metrics/warnings/advice → сохранение в SQLite
|
4. Интерпретация JSON SDK → summary/metrics/warnings/advice → сохранение в SQLite
|
||||||
|
|
||||||
## Важные нюансы
|
## Важные нюансы
|
||||||
|
|
||||||
- **SDK ФПГ не находит лицо на кадрах 640×480** — нужны кадры 1280×720.
|
- **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`.
|
- **iOS MediaRecorder** пишет mp4 (webm может не поддерживаться) — расширение выбирается из `blob.type`.
|
||||||
- **Порт 5000** на сервере занят другим сервисом — приложение слушает 8063.
|
- **Порт 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
|
||||||
|
```
|
||||||
@@ -14,6 +14,7 @@ import glob
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -33,16 +34,39 @@ import export
|
|||||||
# ─── Конфигурация ─────────────────────────────────────────────
|
# ─── Конфигурация ─────────────────────────────────────────────
|
||||||
# Секреты читаются из переменных окружения (не хранятся в репозитории).
|
# Секреты читаются из переменных окружения (не хранятся в репозитории).
|
||||||
# На сервере задаются в /etc/systemd/system/med.service (Environment=).
|
# На сервере задаются в /etc/systemd/system/med.service (Environment=).
|
||||||
OAUTH_URL = "https://oauth.sbermed.ai/token"
|
# Хосты стенда можно переопределить через env (FPG_REST_HOST и т.д.).
|
||||||
CLIENT_ID = os.environ.get("FPG_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", "")
|
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"
|
def split_host_path(host: str) -> tuple:
|
||||||
WS_PORT = 8443
|
"""Разделить 'host/path' на (scheme, hostname, base_path).
|
||||||
WS_PATH_TEMPLATE = "/api/v2/ws/predict/{uid}/image"
|
|
||||||
|
Поддерживает варианты: "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"
|
DEVICE_ID = "web_prod"
|
||||||
FORM_FACTOR = "web_prod"
|
FORM_FACTOR = "web_prod"
|
||||||
@@ -75,6 +99,23 @@ VIDEO_MIME = {
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 МБ
|
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(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -134,7 +175,7 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS):
|
|||||||
"""Отправить кадры через WebSocket, вернуть результат."""
|
"""Отправить кадры через WebSocket, вернуть результат."""
|
||||||
import websockets
|
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
|
interval = 1.0 / fps
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -142,7 +183,7 @@ async def ws_stream(token: str, uid: str, frame_files: list, fps: float = FPS):
|
|||||||
uri,
|
uri,
|
||||||
open_timeout=10,
|
open_timeout=10,
|
||||||
subprotocols=[f"bearer.{token}"],
|
subprotocols=[f"bearer.{token}"],
|
||||||
origin="https://gigadoc.sbermed.ai",
|
origin=WS_ORIGIN,
|
||||||
extra_headers={
|
extra_headers={
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Pragma": "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,
|
max_size=None,
|
||||||
) as ws:
|
) as ws:
|
||||||
result = None
|
result = None
|
||||||
|
last_progress = -1
|
||||||
for i, frame_file in enumerate(frame_files):
|
for i, frame_file in enumerate(frame_files):
|
||||||
frame_start = time.time()
|
frame_start = time.time()
|
||||||
with open(frame_file, "rb") as f:
|
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:
|
try:
|
||||||
resp = await asyncio.wait_for(ws.recv(), timeout=2.0)
|
resp = await asyncio.wait_for(ws.recv(), timeout=2.0)
|
||||||
data = json.loads(resp)
|
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
|
result = data
|
||||||
break
|
break
|
||||||
except (asyncio.TimeoutError, json.JSONDecodeError):
|
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:
|
if elapsed < interval:
|
||||||
await asyncio.sleep(interval - elapsed)
|
await asyncio.sleep(interval - elapsed)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Финальный результат не получен после {len(frame_files)} кадров"
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WebSocket error: {e}")
|
logger.error(f"WebSocket error: {e}")
|
||||||
return None
|
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:
|
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")
|
frames_dir = os.path.join(work_dir, "frames")
|
||||||
os.makedirs(frames_dir, exist_ok=True)
|
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 (с сохранением пропорций и добавлением полей).
|
# Масштабируем до 1280x720 (с сохранением пропорций и добавлением полей).
|
||||||
# format=yuv420p + bt709 стандартизируют цвета (важно для iPhone MOV с P3/HDR,
|
filters.append(
|
||||||
# иначе кадры получаются с искажёнными цветами и SDK не находит лицо).
|
|
||||||
vf = (
|
|
||||||
f"scale={FRAME_WIDTH}:{FRAME_HEIGHT}:force_original_aspect_ratio=decrease,"
|
f"scale={FRAME_WIDTH}:{FRAME_HEIGHT}:force_original_aspect_ratio=decrease,"
|
||||||
f"pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2,"
|
f"pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2"
|
||||||
f"format=yuv420p"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Нормализуем FPS (телефонные видео часто с переменным FPS).
|
||||||
|
filters.append("fps=30")
|
||||||
|
|
||||||
|
# 8-бит yuv420p (SDK ожидает такой формат).
|
||||||
|
filters.append("format=yuv420p")
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg", "-y",
|
"ffmpeg", "-y",
|
||||||
"-i", video_path,
|
"-i", video_path,
|
||||||
"-color_primaries", "bt709",
|
"-color_primaries", "bt709",
|
||||||
"-color_trc", "bt709",
|
"-color_trc", "bt709",
|
||||||
"-colorspace", "bt709",
|
"-colorspace", "bt709",
|
||||||
"-vf", vf,
|
"-vf", ",".join(filters),
|
||||||
"-q:v", str(JPEG_QUALITY),
|
"-q:v", str(JPEG_QUALITY),
|
||||||
|
"-t", "20", # берём первые 20 секунд (достаточно для 300 кадров)
|
||||||
"-frames:v", str(FRAMES_COUNT),
|
"-frames:v", str(FRAMES_COUNT),
|
||||||
os.path.join(frames_dir, "frame_%04d.jpg"),
|
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():
|
def analyze():
|
||||||
|
# Preflight-запрос CORS (браузер шлёт OPTIONS перед кросс-доменным POST)
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return "", 204
|
||||||
|
|
||||||
if "video" not in request.files:
|
if "video" not in request.files:
|
||||||
return jsonify({"error": "Видео не загружено"}), 400
|
return jsonify({"error": "Видео не загружено"}), 400
|
||||||
|
|
||||||
@@ -391,4 +551,4 @@ if __name__ == "__main__":
|
|||||||
print("🩺 Диагностика по видео — ФПГ")
|
print("🩺 Диагностика по видео — ФПГ")
|
||||||
print(f"Откройте в браузере: http://localhost:5000")
|
print(f"Откройте в браузере: http://localhost:5000")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
app.run(host="0.0.0.0", port=8063, debug=False)
|
app.run(host="0.0.0.0", port=18018, debug=False)
|
||||||
+34
-12
@@ -27,17 +27,35 @@ from pathlib import Path
|
|||||||
import requests
|
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", "")
|
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)
|
def split_host_path(host: str) -> tuple:
|
||||||
WS_HOST = "gigadoc-sdk.sbermed.ai"
|
"""Разделить 'host/path' на (scheme, hostname, base_path)."""
|
||||||
WS_PORT = 8443
|
host = host.strip().rstrip("/")
|
||||||
WS_PATH_TEMPLATE = "/api/v2/ws/predict/{uid}/image"
|
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"
|
DEVICE_ID = "web_prod"
|
||||||
FORM_FACTOR = "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)")
|
logger.debug(f" {img} ({size} bytes)")
|
||||||
|
|
||||||
# ── Пробуем несколько вариантов URI ──
|
# ── Пробуем несколько вариантов 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 = [
|
uris = [
|
||||||
f"wss://{ws_host}:{ws_port}{WS_PATH_TEMPLATE.format(uid=uid)}",
|
f"wss://{_ws_hostname}:{ws_port}{_ws_path}/api/v2/ws/predict/{uid}/image",
|
||||||
f"wss://{REST_HOST}:{ws_port}{WS_PATH_TEMPLATE.format(uid=uid)}",
|
f"wss://{WS_HOSTNAME}:{ws_port}{WS_PATH_TEMPLATE.format(uid=uid)}",
|
||||||
f"wss://{REST_HOST}:443{WS_PATH_TEMPLATE.format(uid=uid)}",
|
f"wss://{WS_HOSTNAME}:443{WS_PATH_TEMPLATE.format(uid=uid)}",
|
||||||
]
|
]
|
||||||
|
|
||||||
last_error = None
|
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,
|
uri,
|
||||||
open_timeout=10,
|
open_timeout=10,
|
||||||
subprotocols=[f"bearer.{token}"],
|
subprotocols=[f"bearer.{token}"],
|
||||||
origin="https://gigadoc.sbermed.ai",
|
origin=WS_ORIGIN,
|
||||||
extra_headers={
|
extra_headers={
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Pragma": "no-cache",
|
"Pragma": "no-cache",
|
||||||
|
|||||||
+27
-9
@@ -19,16 +19,34 @@ import requests
|
|||||||
import websockets
|
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", "")
|
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"
|
def split_host_path(host: str) -> tuple:
|
||||||
WS_PORT = 8443
|
"""Разделить 'host/path' на (scheme, hostname, base_path)."""
|
||||||
WS_PATH_TEMPLATE = "/api/v2/ws/predict/{uid}/image"
|
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"
|
DEVICE_ID = "web_prod"
|
||||||
FORM_FACTOR = "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("ЭТАП 3: WebSocket подключение")
|
||||||
logger.info("=" * 60)
|
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}")
|
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,
|
uri,
|
||||||
open_timeout=10,
|
open_timeout=10,
|
||||||
subprotocols=[f"bearer.{token}"],
|
subprotocols=[f"bearer.{token}"],
|
||||||
origin="https://gigadoc.sbermed.ai",
|
origin=WS_ORIGIN,
|
||||||
extra_headers={
|
extra_headers={
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Pragma": "no-cache",
|
"Pragma": "no-cache",
|
||||||
|
|||||||
+31
-6
@@ -66,7 +66,7 @@ def parse_args():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--sdk-host",
|
"--sdk-host",
|
||||||
required=True,
|
required=True,
|
||||||
help="SDK hostname, например gigadoc-sdk.sbermed.ai",
|
help="SDK hostname, например gigadoc-dnr-sdk.sbermed.ai",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -94,10 +94,10 @@ def parse_args():
|
|||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--origin",
|
"--origin",
|
||||||
default="https://gigadoc.sbermed.ai",
|
default="https://gigadoc-dnr-sdk.sbermed.ai",
|
||||||
help=(
|
help=(
|
||||||
"Origin для WebSocket "
|
"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("/")
|
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(
|
def build_ssl_context(
|
||||||
insecure: bool,
|
insecure: bool,
|
||||||
) -> Optional[ssl.SSLContext]:
|
) -> Optional[ssl.SSLContext]:
|
||||||
@@ -329,7 +345,8 @@ async def create_predict_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
f"https://{args.sdk_host}"
|
f"https://{args.sdk_hostname}"
|
||||||
|
f"{args.sdk_base_path}"
|
||||||
f"{rest_prefix}/predict/new"
|
f"{rest_prefix}/predict/new"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -494,7 +511,8 @@ async def websocket_predict(
|
|||||||
for port in ports:
|
for port in ports:
|
||||||
|
|
||||||
ws_url = (
|
ws_url = (
|
||||||
f"wss://{args.sdk_host}:{port}"
|
f"wss://{args.sdk_hostname}:{port}"
|
||||||
|
f"{args.sdk_base_path}"
|
||||||
f"{ws_prefix}/{uid}/image"
|
f"{ws_prefix}/{uid}/image"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -918,7 +936,8 @@ async def delete_predict_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
f"https://{args.sdk_host}"
|
f"https://{args.sdk_hostname}"
|
||||||
|
f"{args.sdk_base_path}"
|
||||||
f"{rest_prefix}/predict/{uid}"
|
f"{rest_prefix}/predict/{uid}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -993,6 +1012,12 @@ async def main():
|
|||||||
|
|
||||||
args = parse_args()
|
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(
|
setup_logging(
|
||||||
args.verbose
|
args.verbose
|
||||||
)
|
)
|
||||||
|
|||||||
+31
-18
@@ -47,7 +47,7 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
background: #fff;
|
background: #faf6ec;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
@@ -190,6 +190,16 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
transform: scaleX(-1);
|
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 { color: #fff; margin-top: 20px; font-size: 16px; text-align: center; }
|
||||||
.scan-progress-bar {
|
.scan-progress-bar {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
@@ -249,8 +259,8 @@
|
|||||||
/* Результат */
|
/* Результат */
|
||||||
.result { display: none; }
|
.result { display: none; }
|
||||||
.summary-box {
|
.summary-box {
|
||||||
background: #f0f4ff;
|
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
|
||||||
border-left: 4px solid #0014FC;
|
color: #fff;
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -281,20 +291,20 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.status-ok { background: #d4edda; color: #155724; }
|
.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-danger { background: #f8d7da; color: #721c24; }
|
||||||
.status-info { background: #d1ecf1; color: #0c5460; }
|
.status-info { background: #dbe9ff; color: #1e4a8a; }
|
||||||
|
|
||||||
.warnings-box {
|
.warnings-box {
|
||||||
background: #fff3cd;
|
background: #ffe0d6;
|
||||||
border-left: 4px solid #ffc107;
|
border-left: 4px solid #ff7f50;
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 20px;
|
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 ul { padding-left: 20px; }
|
||||||
.warnings-box li { margin: 5px 0; color: #856404; }
|
.warnings-box li { margin: 5px 0; color: #a04a2f; }
|
||||||
|
|
||||||
.advice-box {
|
.advice-box {
|
||||||
background: #d4edda;
|
background: #d4edda;
|
||||||
@@ -414,6 +424,7 @@
|
|||||||
<div class="scan-modal" id="scanModal">
|
<div class="scan-modal" id="scanModal">
|
||||||
<div class="scan-video-wrap">
|
<div class="scan-video-wrap">
|
||||||
<video class="scan-video" id="scanVideo" autoplay playsinline muted></video>
|
<video class="scan-video" id="scanVideo" autoplay playsinline muted></video>
|
||||||
|
<div class="scan-oval"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="scan-progress">
|
<div class="scan-progress">
|
||||||
<div id="scanProgressText">Кадров: 0 / 300 (0%)</div>
|
<div id="scanProgressText">Кадров: 0 / 300 (0%)</div>
|
||||||
@@ -422,7 +433,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="scan-cancel" id="scanCancelBtn">✖ Отмена</button>
|
<button class="scan-cancel" id="scanCancelBtn">✖ Отмена</button>
|
||||||
<div class="scan-hint">Держите лицо в круге, смотрите прямо в камеру, не двигайтесь</div>
|
<div class="scan-hint">Поместите лицо в овал целиком: лоб, глаза и подбородок — с запасом. Если голова не помещается — отодвиньте телефон</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -656,7 +667,7 @@
|
|||||||
video: {
|
video: {
|
||||||
facingMode: 'user',
|
facingMode: 'user',
|
||||||
width: { ideal: 1280 },
|
width: { ideal: 1280 },
|
||||||
height: { ideal: 720 }
|
height: { ideal: 1280 }
|
||||||
},
|
},
|
||||||
audio: false
|
audio: false
|
||||||
});
|
});
|
||||||
@@ -676,11 +687,11 @@
|
|||||||
scanLastFrameTime = 0;
|
scanLastFrameTime = 0;
|
||||||
updateScanProgress(0);
|
updateScanProgress(0);
|
||||||
|
|
||||||
// Canvas 720×720 — квадрат, лицо целиком влезает无论是 portrait или landscape.
|
// Canvas 1080×1080 — квадрат, лицо целиком влезает в portrait и landscape.
|
||||||
// Серверное extract_frames масштабирует до 1280×720 для SDK.
|
// Серверное extract_frames масштабирует до 1280×720 для SDK.
|
||||||
scanCanvas = document.createElement('canvas');
|
scanCanvas = document.createElement('canvas');
|
||||||
scanCanvas.width = 720;
|
scanCanvas.width = 1080;
|
||||||
scanCanvas.height = 720;
|
scanCanvas.height = 1080;
|
||||||
scanCtx = scanCanvas.getContext('2d');
|
scanCtx = scanCanvas.getContext('2d');
|
||||||
|
|
||||||
let captureStream;
|
let captureStream;
|
||||||
@@ -719,16 +730,18 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ts - scanLastFrameTime >= FRAME_INTERVAL) {
|
if (ts - scanLastFrameTime >= FRAME_INTERVAL) {
|
||||||
// Квадратный center-crop: берём центральную квадратную область из видео.
|
// Квадратный center-crop с запасом 10%: берём чуть меньший
|
||||||
|
// квадрат, чтобы голова гарантированно помещалась целиком
|
||||||
|
// (иначе SDK не находит лицо из-за обрезанной головы).
|
||||||
const vw = scanVideo.videoWidth;
|
const vw = scanVideo.videoWidth;
|
||||||
const vh = scanVideo.videoHeight;
|
const vh = scanVideo.videoHeight;
|
||||||
if (vw && vh) {
|
if (vw && vh) {
|
||||||
const side = Math.min(vw, vh);
|
const side = Math.min(vw, vh) * 0.9;
|
||||||
const sx = (vw - side) / 2;
|
const sx = (vw - side) / 2;
|
||||||
const sy = (vh - side) / 2;
|
const sy = (vh - side) / 2;
|
||||||
scanCtx.drawImage(scanVideo, sx, sy, side, side, 0, 0, 720, 720);
|
scanCtx.drawImage(scanVideo, sx, sy, side, side, 0, 0, 1080, 1080);
|
||||||
} else {
|
} else {
|
||||||
scanCtx.drawImage(scanVideo, 0, 0, 720, 720);
|
scanCtx.drawImage(scanVideo, 0, 0, 1080, 1080);
|
||||||
}
|
}
|
||||||
scanFrameCount++;
|
scanFrameCount++;
|
||||||
scanLastFrameTime = ts;
|
scanLastFrameTime = ts;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
.container { max-width: 900px; margin: 0 auto; }
|
.container { max-width: 900px; margin: 0 auto; }
|
||||||
.card {
|
.card {
|
||||||
background: #fff;
|
background: #faf6ec;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
.btn-download:active { transform: scale(0.98); }
|
.btn-download:active { transform: scale(0.98); }
|
||||||
|
|
||||||
.summary-box {
|
.summary-box {
|
||||||
background: #f0f4ff;
|
background: linear-gradient(135deg, #061A40, #0B4F9C, #2D9CDB);
|
||||||
border-left: 4px solid #0014FC;
|
color: #fff;
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -109,20 +109,20 @@
|
|||||||
.metrics-table tr:hover { background: #fafafa; }
|
.metrics-table tr:hover { background: #fafafa; }
|
||||||
.status-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
.status-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||||
.status-ok { background: #d4edda; color: #155724; }
|
.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-danger { background: #f8d7da; color: #721c24; }
|
||||||
.status-info { background: #d1ecf1; color: #0c5460; }
|
.status-info { background: #dbe9ff; color: #1e4a8a; }
|
||||||
|
|
||||||
.warnings-box {
|
.warnings-box {
|
||||||
background: #fff3cd;
|
background: #ffe0d6;
|
||||||
border-left: 4px solid #ffc107;
|
border-left: 4px solid #ff7f50;
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 20px;
|
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 ul { padding-left: 20px; }
|
||||||
.warnings-box li { margin: 5px 0; color: #856404; }
|
.warnings-box li { margin: 5px 0; color: #a04a2f; }
|
||||||
|
|
||||||
.advice-box {
|
.advice-box {
|
||||||
background: #d4edda;
|
background: #d4edda;
|
||||||
|
|||||||
+44
-12
@@ -1,9 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Тестовый скрипт для SDK ФПГ (GigaDoc).
|
Тестовый скрипт для SDK ФПГ (GigaDoc, персональный стенд DNR).
|
||||||
Покрывает: авторизацию → создание сессии → WebSocket (изображения) → удаление сессии.
|
Покрывает: авторизацию → создание сессии → WebSocket (изображения) → удаление сессии.
|
||||||
|
|
||||||
Использование:
|
Использование:
|
||||||
python3 test_fpg.py [--ws-host HOST] [--ws-port PORT] [--image-dir DIR]
|
python3 test_fpg.py [--ws-host HOST] [--ws-port PORT] [--image-dir DIR]
|
||||||
|
|
||||||
|
Хосты можно переопределить через env: FPG_REST_HOST, FPG_WS_HOST, FPG_WS_PORT.
|
||||||
|
Хост может содержать путь (domain.com/v1/giga) — он учитывается при сборке URL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -17,17 +20,37 @@ import time
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
# ─── Конфигурация ─────────────────────────────────────────────
|
# ─── Конфигурация ─────────────────────────────────────────────
|
||||||
OAUTH_URL = "https://oauth.sbermed.ai/token"
|
OAUTH_URL = os.environ.get("FPG_OAUTH_URL", "https://oauth.sbermed.ai/token")
|
||||||
CLIENT_ID = "client_fpg_front"
|
CLIENT_ID = os.environ.get("FPG_CLIENT_ID", "client_fpg_front_dnr")
|
||||||
CLIENT_SECRET = os.environ.get("FPG_CLIENT_SECRET", "")
|
CLIENT_SECRET = os.environ.get("FPG_CLIENT_SECRET", "")
|
||||||
|
|
||||||
REST_HOST = "gigadoc-sdk.sbermed.ai"
|
REST_HOST = os.environ.get("FPG_REST_HOST", "gigadoc-dnr-sdk.sbermed.ai")
|
||||||
REST_BASE = f"https://{REST_HOST}"
|
WS_HOST = os.environ.get("FPG_WS_HOST", "gigadoc-dnr-sdk.sbermed.ai")
|
||||||
|
WS_PORT = int(os.environ.get("FPG_WS_PORT", "8443"))
|
||||||
|
WS_ORIGIN = os.environ.get("FPG_WS_ORIGIN", "https://gigadoc-dnr-sdk.sbermed.ai")
|
||||||
|
|
||||||
DEVICE_ID = "web_prod"
|
DEVICE_ID = "web_prod"
|
||||||
FORM_FACTOR = "web_prod"
|
FORM_FACTOR = "web_prod"
|
||||||
|
|
||||||
|
|
||||||
|
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_scheme, _rest_hostname, _rest_base_path = split_host_path(REST_HOST)
|
||||||
|
REST_BASE = f"{_rest_scheme or 'https'}://{_rest_hostname}{_rest_base_path}"
|
||||||
|
|
||||||
|
_ws_scheme, WS_HOSTNAME, WS_BASE_PATH = split_host_path(WS_HOST)
|
||||||
|
|
||||||
|
|
||||||
def get_token() -> str:
|
def get_token() -> str:
|
||||||
"""Получить access token через client_credentials."""
|
"""Получить access token через client_credentials."""
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
@@ -76,13 +99,20 @@ async def ws_scan(token: str, uid: str, ws_host: str, ws_port: int, image_dir: s
|
|||||||
"""Подключиться по WebSocket, отправить изображения, получить результат."""
|
"""Подключиться по WebSocket, отправить изображения, получить результат."""
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
uri = f"wss://{ws_host}:{ws_port}/api/v2/ws/predict/{uid}/image"
|
# ws_host может быть с путём (domain.com/v1/giga) — разделяем.
|
||||||
|
_scheme, _hostname, _base_path = split_host_path(ws_host)
|
||||||
|
_base_path = _base_path or WS_BASE_PATH
|
||||||
|
|
||||||
|
uri = f"wss://{_hostname}:{ws_port}{_base_path}/api/v2/ws/predict/{uid}/image"
|
||||||
print(f"[WS] Подключение: {uri}")
|
print(f"[WS] Подключение: {uri}")
|
||||||
|
|
||||||
|
# Авторизация через subprotocol bearer.<token> (как фронт),
|
||||||
|
# Origin — обязательный заголовок.
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
uri,
|
uri,
|
||||||
open_timeout=10,
|
open_timeout=10,
|
||||||
additional_headers={"Authorization": f"Bearer {token}"},
|
subprotocols=[f"bearer.{token}"],
|
||||||
|
extra_headers={"Origin": WS_ORIGIN},
|
||||||
) as ws:
|
) as ws:
|
||||||
print("[WS] Соединение установлено")
|
print("[WS] Соединение установлено")
|
||||||
|
|
||||||
@@ -145,16 +175,18 @@ def create_test_image():
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Тест SDK ФПГ")
|
parser = argparse.ArgumentParser(description="Тест SDK ФПГ (DNR)")
|
||||||
parser.add_argument("--ws-host", default="sdk.sbermed.ai", help="WebSocket host")
|
parser.add_argument("--ws-host", default=WS_HOSTNAME, help="WebSocket host (может быть с путём)")
|
||||||
parser.add_argument("--ws-port", type=int, default=8443, help="WebSocket port")
|
parser.add_argument("--ws-port", type=int, default=WS_PORT, help="WebSocket port")
|
||||||
parser.add_argument("--image-dir", default=".", help="Директория с JPEG-файлами")
|
parser.add_argument("--image-dir", default=".", help="Директория с JPEG-файлами")
|
||||||
parser.add_argument("--rest-only", action="store_true", help="Только REST API (без WS)")
|
parser.add_argument("--rest-only", action="store_true", help="Только REST API (без WS)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("🏥 Тестирование SDK ФПГ (GigaDoc)")
|
print("🏥 Тестирование SDK ФПГ (GigaDoc DNR)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
print(f"[CONFIG] REST: {REST_BASE}")
|
||||||
|
print(f"[CONFIG] WS: wss://{args.ws_host}:{args.ws_port}")
|
||||||
|
|
||||||
# 0. Тестовое изображение
|
# 0. Тестовое изображение
|
||||||
create_test_image()
|
create_test_image()
|
||||||
@@ -192,4 +224,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
Reference in new issue
Block a user