统一 Docker Compose 部署,并将客户端拆分为独立子项目。

移除宿主机/conda 启动脚本与 dev 联调工具,后端仅通过 docker compose 部署并默认启用 GPU。模拟客户端与语音确认页迁入 clients/ 下自包含目录,切断对后端源码路径的依赖。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-21 15:56:53 +08:00
parent c869fcc6b9
commit 6bc6801df9
38 changed files with 478 additions and 1702 deletions

72
clients/demo-client/README.md Executable file
View File

@@ -0,0 +1,72 @@
# Demo Client
独立浏览器联调页,用于手动触发监控 API 的部分 `/client/*` 接口:开始/结束手术、查询结果等。语音待确认、TTS 与麦克风录音请使用同级的 [`../voice-confirmation/`](../voice-confirmation/) 或其它专用客户端。
## 结构
```
clients/demo-client/
start.sh # 启动入口(默认 0.0.0.0:38081
server.py # stdlib 静态服务器;额外暴露 /labels.json
index.html # 单文件页面(原生 JS零构建依赖
labels.yaml # 耗材类名快照(与后端 app/resources/consumable_classifier_labels.yaml 同步)
fake_rtsp_from_file.py # 无真摄像头时:本地视频推 RTSPffmpeg + Docker MediaMTX
```
**`labels.yaml`**:本目录自带副本,与后端解耦。后端类名变更时,请同步更新此文件。
## 运行
```bash
# 1) 在仓库根目录启动 Docker 后端
docker compose up -d --build
# 2) 在本目录启动 Demo 页
./start.sh
# 或python server.py -p 38081 --host 0.0.0.0
# 3) 浏览器访问
open http://127.0.0.1:38081/
```
页面「服务端 Base URL」默认指向同主机 `:38080`;后端在其他机器时手动改为 `http://<GPU服务器IP>:38080`
## 调试:无真实摄像头,用录好的视频模拟 RTSP
监控服务**只从 RTSP URL 拉流**,没有视频上传 HTTP 接口。推荐在宿主机用 **ffmpeg + MediaMTX** 推假流:
**单路**
```bash
python3 fake_rtsp_from_file.py /path/to/recording.mp4 --port 18554 --path demo
```
**两路**
```bash
python3 fake_rtsp_from_file.py --port 18554 \
--stream 'or-cam-01|./a.mp4|demo1' \
--stream 'or-cam-02|./b.mp4|demo2'
```
`--stream` 格式:`CAMERA_ID|文件路径|RTSP_PATH`。脚本会在 stderr 打印站点 JSON 片段,可合并进后端的 `OR_SITE_CONFIG_JSON_FILE`
### API 在 Docker、假 RTSP 在宿主机
容器内访问宿主机 RTSP 应使用 `rtsp://host.docker.internal:<端口>/<路径>`compose 已配置 `extra_hosts`)。详见 [`../../docs/video-backends.md`](../../docs/video-backends.md)。
## 一键开录
Demo 页勾选「一键联调」后上传视频,调用 `POST /internal/demo/orchestrate-and-start`。需后端 `.env``DEMO_ORCHESTRATOR_ENABLED=true`,且 API 容器能执行 `docker`/`ffmpeg`(通常需挂载 docker.sock
## 页面功能
- `GET /health` 连通性检查
- `POST /client/surgeries/start|end``GET /client/surgeries/{id}/result`
- 调试区:多路视频、假 RTSP、`camera_id` 同步
不含语音确认 UI见 [`../voice-confirmation/`](../voice-confirmation/))。
## CORS
跨域访问 API 时,后端需 `DEMO_CORS_ENABLED=true`;生产环境收窄 `DEMO_CORS_ORIGINS`

View File

@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""Publish local video file(s) to RTSP once per file (fake camera) for local dev.
The Operation Room server only opens RTSP URLs (OpenCV); there is no video-upload API.
This script does NOT change the application backend: it runs ffmpeg + a small
RTSP server (MediaMTX); put the printed ``video_rtsp_urls`` into ``OR_SITE_CONFIG_JSON_FILE``.
Requires:
- ffmpeg in PATH
- Docker; 默认拉取 ``MEDIAMTX_DOCKER_IMAGE``DaoCloud 前缀的 bluenviron/mediamtx
或本地已拉取的镜像;也可用 PATH 中的 ``mediamtx`` 二进制(高级)。
Single stream (legacy)::
python3 fake_rtsp_from_file.py /path/to/video.mp4
python3 fake_rtsp_from_file.py video.mp4 --port 18554 --path demo
Multiple streams (one MediaMTX, one ffmpeg per camera; different RTSP path per stream)::
python3 fake_rtsp_from_file.py --port 18554 \\
--stream 'or-cam-01|./a.mp4|demo1' \\
--stream 'or-cam-02|./b.mp4|demo2'
--stream format: ``CAMERA_ID|FILE|RTSP_PATH`` (use quotes in shell; RTSP path is
the last segment, e.g. ``demo1`` -> ``rtsp://127.0.0.1:<port>/demo1``).
"""
from __future__ import annotations
import argparse
import atexit
import json
import os
import signal
import shutil
import subprocess
import sys
import time
from pathlib import Path
# 默认 DaoCloud 镜像前缀;可设 MEDIAMTX_DOCKER_IMAGE=bluenviron/mediamtx:latest 直连 Docker Hub
MEDIAMTX_IMAGE = os.environ.get(
"MEDIAMTX_DOCKER_IMAGE",
"m.daocloud.io/docker.io/bluenviron/mediamtx:latest",
)
CONTAINER_NAME = "orm-fake-rtsp-mediamtx"
def _has_docker() -> bool:
return shutil.which("docker") is not None
def _has_ffmpeg() -> bool:
return shutil.which("ffmpeg") is not None
def _stop_mediamtx_container() -> None:
if not _has_docker():
return
try:
subprocess.run(
["docker", "rm", "-f", CONTAINER_NAME],
capture_output=True,
check=False,
timeout=30,
)
except (OSError, subprocess.SubprocessError):
pass
def _start_mediamtx_docker(host_port: int) -> bool:
_stop_mediamtx_container()
cmd = [
"docker",
"run",
"-d",
"--name",
CONTAINER_NAME,
"-p",
f"127.0.0.1:{host_port}:8554",
MEDIAMTX_IMAGE,
]
print("[fake-rtsp] Starting MediaMTX:", " ".join(cmd), file=sys.stderr)
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except (OSError, subprocess.SubprocessError) as exc:
print(f"[fake-rtsp] docker run failed: {exc}", file=sys.stderr)
return False
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
print(f"[fake-rtsp] docker run exit {proc.returncode}: {err}", file=sys.stderr)
return False
atexit.register(_stop_mediamtx_container)
return True
def _parse_stream_arg(spec: str) -> tuple[str, Path, str]:
parts = spec.split("|", 2)
if len(parts) != 3:
raise ValueError(f"Invalid --stream {spec!r}; expected CAM|FILE|RTSP_PATH (three fields separated by |)")
cam = parts[0].strip()
fpath = Path(parts[1].strip()).expanduser()
rpath = parts[2].strip().strip("/")
if not cam:
raise ValueError("empty camera id in --stream")
if not rpath:
rpath = "demo"
return cam, fpath, rpath
def main() -> int:
parser = argparse.ArgumentParser(
description="Play each video file once to an RTSP URL (dev fake camera; no backend code change).",
)
parser.add_argument(
"video",
nargs="?",
type=Path,
default=None,
help="(single-stream mode) Path to a video file",
)
parser.add_argument(
"--path",
default="demo",
help="(single-stream mode) RTSP path segment (rtsp://host:port/<path>)",
)
parser.add_argument(
"--port",
type=int,
default=18554,
help="Host port mapped to MediaMTX RTSP (container internal 8554). Default: 18554",
)
parser.add_argument(
"--stream",
action="append",
default=None,
help=("Multi-stream mode. Repeat for each camera. Format: CAM|FILE|RTSP_PATH e.g. or-cam-01|./a.mp4|demo1"),
)
parser.add_argument(
"--no-docker",
action="store_true",
help="Do not start Docker; run MediaMTX yourself on the host port mapping.",
)
args = parser.parse_args()
if not _has_ffmpeg():
print("ffmpeg not found in PATH. Install ffmpeg and retry.", file=sys.stderr)
return 1
streams: list[tuple[str, Path, str]] = []
if args.stream:
for s in args.stream:
try:
streams.append(_parse_stream_arg(s))
except ValueError as exc:
print(f"[fake-rtsp] {exc}", file=sys.stderr)
return 1
elif args.video is not None:
fpath = args.video.resolve()
sp = (args.path or "demo").strip().strip("/") or "demo"
streams = [("or-cam-01", fpath, sp)]
else:
parser.error("Provide a video file (single mode) or one or more --stream CAM|FILE|RTSP_PATH")
for cam, fpath, rpath in streams:
rp_file = fpath.resolve()
if not rp_file.is_file():
print(f"File not found: {rp_file} (camera {cam!r})", file=sys.stderr)
return 1
for ch in rpath:
if ch not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-":
print(
f"[fake-rtsp] RTSP path segment {rpath!r} for {cam!r} should be "
r"[a-zA-Z0-9_.-] only; adjust --path/--stream",
file=sys.stderr,
)
return 1
host_port: int = args.port
if not args.no_docker:
if not _has_docker():
print("Docker not found. Use --no-docker and start MediaMTX manually.", file=sys.stderr)
return 1
if not _start_mediamtx_docker(host_port):
return 1
print("[fake-rtsp] MediaMTX container started. Waiting for RTSP…", file=sys.stderr)
time.sleep(1.0)
else:
print(
f"[fake-rtsp] --no-docker: ensure an RTSP server is listening for publish on port {host_port}.",
file=sys.stderr,
)
procs: list[subprocess.Popen] = []
url_map: dict[str, str] = {}
for cam, fpath, stream_path in streams:
fp = fpath.resolve()
dest_url = f"rtsp://127.0.0.1:{host_port}/{stream_path}"
url_map[cam] = dest_url
publish_cmd: list[str] = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"info",
"-re",
"-i",
str(fp),
"-c",
"copy",
"-f",
"rtsp",
"-rtsp_transport",
"tcp",
dest_url,
]
print("---", file=sys.stderr)
print(f"Publish {cam} -> {dest_url}", file=sys.stderr)
print(" " + " ".join(publish_cmd), file=sys.stderr)
p = subprocess.Popen(publish_cmd) # noqa: S603
procs.append(p)
site_doc = {"video_rtsp_urls": url_map, "voice_or_room_bindings": []}
print("---", file=sys.stderr)
print("RTSP mapping (per camera):", file=sys.stderr)
for k, u in url_map.items():
print(f" {k}: {u}", file=sys.stderr)
print("", file=sys.stderr)
print(
"OR site config (merge video_rtsp_urls into OR_SITE_CONFIG_JSON_FILE; add voice_or_room_bindings as needed):",
file=sys.stderr,
)
print(json.dumps(site_doc, ensure_ascii=False, indent=2), file=sys.stderr)
print("", file=sys.stderr)
print("If the server runs in Docker on Mac/Win, use host.docker.internal, e.g.:", file=sys.stderr)
for cam, u in url_map.items():
h = u.replace("127.0.0.1", "host.docker.internal", 1)
print(f" {cam}: {h}", file=sys.stderr)
print("---", file=sys.stderr)
print(
"Fake RTSP running: each file plays once; script exits when ffmpeg ends "
"(Ctrl+C to stop early; MediaMTX container removed on exit).",
file=sys.stderr,
)
def on_sigint(_sig: int, _frame) -> None:
for p in procs:
if p.poll() is None:
p.terminate()
_stop_mediamtx_container()
raise SystemExit(130)
signal.signal(signal.SIGINT, on_sigint)
signal.signal(signal.SIGTERM, on_sigint)
try:
while True:
time.sleep(0.5)
for p in procs:
if p.poll() is not None:
print(
f"[fake-rtsp] ffmpeg ended (code {p.returncode}), stopping all.",
file=sys.stderr,
)
raise KeyboardInterrupt
except KeyboardInterrupt:
pass
finally:
for p in procs:
if p.poll() is None:
p.terminate()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill()
_stop_mediamtx_container()
return 0
if __name__ == "__main__":
raise SystemExit(main())

1070
clients/demo-client/index.html Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
# 与训练 data.yaml 类名一致label_id 来自 refs/商品信息表.xlsx商品名称→产品编码同名多规格以 / 连接)。
# 推理时以权重内嵌 names 为准;本文件供业务 label_id 与开录空 candidate 全量类名。
names:
0: MCuⅡ功能性宫内节育器
1: 一次性中性电极板
2: 一次性使用乳胶导尿管
3: 一次性使用冲洗袋
4: 一次性使用医疗卫生用品
5: 一次性使用单极手术电极
6: 一次性使用导尿管
7: 一次性使用手术单
8: 一次性使用无菌敷贴
9: 一次性使用无菌气管插管Tracheal Tube
10: 一次性使用无菌注射器带针
11: 一次性使用无菌采样拭子
12: 一次性使用气管插管
13: 一次性使用灭菌橡胶外科手套
14: 一次性使用牙垫
15: 一次性使用精密过滤输液器 带针
16: 一次性使用肛门管
17: 一次性使用胃管
18: 一次性使用血液透析管路
19: 一次性使用输卵管导管
20: 一次性使用雾化器
21: 一次性使用静脉留置针
22: 一次性使用静脉输液针
23: 一次性使用麻醉面罩
24: 一次性内窥镜护套
25: 一次性医用灭菌棉签
26: 一次性无菌喉罩
27: 医用凡士林敷料
28: 医用纱布敷料
29: 医用缝合针
30: 医用脱脂棉纱布块
31: 可吸收性外科缝线
32: 密闭式防针刺伤型静脉留置针
33: 导管固定器
34: 气管切开插管
35: 结扎夹Ligating Clips
36: 自粘性薄膜敷料
37: 血液净化装置的体外循环血路
38: 负压引流器
39: 非吸收性外科缝线
40: 非吸收性外科缝线(蚕丝线)
label_id:
0: "740-2-14"
1: "4787-2-55"
2: "7386-10-89"
3: "1644-37-3"
4: "2241272"
5: "4805-2-50"
6: "735592/14556-4-18"
7: "14764-2-4"
8: "215-93-1"
9: "14780-3-5"
10: "1531-3-2/1531-3-1/1174-42-4/1174-42-1"
11: "15026-1-1"
12: "21444-1-2"
13: "10362-1-4"
14: "975961"
15: "2295950"
16: "1518-22-4"
17: "1518-34-17"
18: "14730-10-10"
19: "1380-15-1"
20: "5019-4-43"
21: "12591-1-184"
22: "129-5-30"
23: "2003984"
24: "521-31-1"
25: "2237844/10183-1-29"
26: "7386-61-46"
27: "10870-25-16"
28: "19246-3-14"
29: "583039/11207-1-64"
30: "8028-4-39"
31: "11765-1-101/1330-49-185"
32: "1281-39-3"
33: "1441340"
34: "10869-30-7"
35: "14780-2-12"
36: "1819-4-1"
37: "739-2-1"
38: "1518-20-8"
39: "4142-1-46"
40: "654032"
nc: 41

131
clients/demo-client/server.py Executable file
View File

@@ -0,0 +1,131 @@
"""Tiny stdlib HTTP server for the demo client page.
- Serves `index.html` (and other sibling files) from this directory.
- Exposes `GET /labels.json`, which parses local `labels.yaml` and returns its
label list so the page can prefill the candidate-consumables input.
Run:
python server.py # 0.0.0.0:38081
python server.py -p 9000 # custom port
./start.sh
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
LABELS_YAML = SCRIPT_DIR / "labels.yaml"
def _load_labels_with_pyyaml(path: Path) -> list[str] | None:
try:
import yaml # type: ignore[import-untyped]
except ImportError:
return None
try:
data: Any = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError):
return None
if not isinstance(data, dict):
return None
names = data.get("names")
if isinstance(names, dict):
try:
items = sorted(names.items(), key=lambda kv: int(kv[0]))
except (TypeError, ValueError):
items = list(names.items())
return [str(v) for _, v in items]
if isinstance(names, list):
return [str(v) for v in names]
return None
def _load_labels_fallback(path: Path) -> list[str]:
"""Minimal parser for the known labels.yaml shape: `<int>: <text>` under `names:`."""
labels: list[tuple[int, str]] = []
in_names = False
pattern = re.compile(r"^\s+(\d+)\s*:\s*(.+?)\s*$")
try:
text = path.read_text(encoding="utf-8")
except OSError:
return []
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("names:"):
in_names = True
continue
if in_names and not line.startswith((" ", "\t")):
in_names = False
if not in_names:
continue
match = pattern.match(line)
if match:
labels.append((int(match.group(1)), match.group(2)))
labels.sort(key=lambda kv: kv[0])
return [name for _, name in labels]
def load_labels() -> list[str]:
if not LABELS_YAML.is_file():
return []
labels = _load_labels_with_pyyaml(LABELS_YAML)
if labels is None:
labels = _load_labels_fallback(LABELS_YAML)
return labels
class DemoHandler(SimpleHTTPRequestHandler):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, directory=str(SCRIPT_DIR), **kwargs)
def do_GET(self) -> None: # noqa: N802 (stdlib override)
if self.path.split("?", 1)[0] == "/labels.json":
self._send_labels()
return
super().do_GET()
def _send_labels(self) -> None:
body = json.dumps({"labels": load_labels()}, ensure_ascii=False).encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
sys.stderr.write("[demo-client] %s - %s\n" % (self.address_string(), format % args))
def main() -> None:
parser = argparse.ArgumentParser(description="Operation room demo client server")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("-p", "--port", type=int, default=38081)
args = parser.parse_args()
server = ThreadingHTTPServer((args.host, args.port), DemoHandler)
url = f"http://{args.host}:{args.port}/"
print(f"Demo client serving at {url}")
print(f" static dir : {SCRIPT_DIR}")
print(f" labels yaml: {LABELS_YAML}")
print("Press Ctrl+C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down.")
finally:
server.server_close()
if __name__ == "__main__":
main()

36
clients/demo-client/start.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Start the standalone demo client page for LAN access.
# Backend must be running first: docker compose up -d --build (in repo root)
# Usage: ./start.sh [port]
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
PORT="${1:-${DEMO_PORT:-38081}}"
HOST="${DEMO_HOST:-0.0.0.0}"
API_PORT="${API_PORT:-38080}"
LAN_IP="$(python3 - <<'PY'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
print(s.getsockname()[0])
except OSError:
print("")
finally:
s.close()
PY
)"
echo "Demo client listening on ${HOST}:${PORT}"
echo "Local: http://127.0.0.1:${PORT}/"
if [[ -n "${LAN_IP}" && ( "${HOST}" == "0.0.0.0" || "${HOST}" == "::" ) ]]; then
echo "LAN: http://${LAN_IP}:${PORT}/"
echo "Base URL: http://${LAN_IP}:${API_PORT}"
fi
exec python3 server.py --host "$HOST" -p "$PORT"