init
This commit is contained in:
126
scripts/demo_client/README.md
Executable file
126
scripts/demo_client/README.md
Executable file
@@ -0,0 +1,126 @@
|
||||
# Demo Client
|
||||
|
||||
一个浏览器里的单页 demo,用于手动触发 `app/api.py` 里部分 `/client/*` 接口:开始/结束手术、查询结果等。语音待确认、TTS 与麦克风录音请使用 **`voice-confirmation-web/`**(独立静态页)或其它专用客户端,或通过 Swagger/`curl` 直接调 API。
|
||||
|
||||
## 结构
|
||||
|
||||
```
|
||||
scripts/demo_client/
|
||||
server.py # 基于 stdlib 的静态服务器;额外暴露 /labels.json
|
||||
index.html # 单文件页面(原生 JS,零构建依赖)
|
||||
fake_rtsp_from_file.py # 无真摄像头时:把本地视频按文件时长推一次到 RTSP(ffmpeg + Docker MediaMTX)
|
||||
```
|
||||
|
||||
## 调试:无真实摄像头,用录好的视频模拟 RTSP
|
||||
|
||||
监控服务**只从 RTSP URL 拉流**(`cv2.VideoCapture`),**没有**「上传视频文件」的 HTTP 接口;在不改 Python 后端的前提下,只能让「摄像头地址」指向一个**真实可连的 RTSP 源。
|
||||
|
||||
推荐做法:在**本机**把视频文件用 **ffmpeg** 推到本机上的 **RTSP 服务**(脚本用 Docker 启动 [MediaMTX](https://github.com/bluenviron/mediamtx)),得到 `rtsp://127.0.0.1:<端口>/<路径>`,再通过**环境变量**告诉后端(**只改配置,不改仓库里的后端代码**):
|
||||
|
||||
**单路**(一个文件、一个 `camera_id`,兼容旧命令):
|
||||
|
||||
```bash
|
||||
# 依赖:ffmpeg、Docker(首次会拉取 bluenviron/mediamtx)
|
||||
cd /path/to/operation-room-monitor-server
|
||||
python3 scripts/demo_client/fake_rtsp_from_file.py /path/to/recording.mp4 --port 18554 --path demo
|
||||
```
|
||||
|
||||
**两路**(两路不同视频、两个 `camera_id`;**一个** MediaMTX、**两路** ffmpeg;每路用不同的 `RTSP_PATH`):
|
||||
|
||||
```bash
|
||||
python3 scripts/demo_client/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 打印含 `video_rtsp_urls` 与 `voice_or_room_bindings: []` 的 **站点 JSON 片段**,可合并进 `OR_SITE_CONFIG_JSON_FILE`。
|
||||
|
||||
在**另一终端**启动监控服务前 `source` 或手动 `export` 上述变量,使 `POST /client/surgeries/start` 里使用的 `camera_ids`(如 `or-cam-01,or-cam-02`)能解析到对应 URL。Demo 页里「将 camera_id 填到开始手术」可一键同步两路 id。
|
||||
|
||||
### 监控在 Docker、假 RTSP 在宿主机(推荐联调拓扑)
|
||||
|
||||
常见安排是:**假摄像头脚本**(`fake_rtsp_from_file.py` + ffmpeg + MediaMTX)在**宿主机**终端里跑,推流地址是 `rtsp://127.0.0.1:<端口>/...`;**监控 API 服务**在 **Docker 容器**里跑,容器里的进程要访问宿主机上的 RTSP,应使用:
|
||||
|
||||
- **macOS / Windows Docker Desktop**:`rtsp://host.docker.internal:<端口>/<路径>`
|
||||
- **Linux**:`host.docker.internal` 可能未预置,可任选其一:
|
||||
- 给该服务容器加 `--add-host=host.docker.internal:host-gateway`(Docker 20.10+),或
|
||||
- 直接把 URL 写成宿主在 **docker0/桥接网** 上可达的局域网 IP(如 `192.168.x.x`),保证从容器内 `curl`/`ffprobe` 能通
|
||||
|
||||
生产/容器环境请使用 `**OR_SITE_CONFIG_JSON_FILE`** 指向完整站点 JSON(含 `video_rtsp_urls` 与 `voice_or_room_bindings`)。**不要**在仅容器可解析的配置里写 `127.0.0.1` 去指宿主机上的 RTSP(`127.0.0.1` 在容器内是容器自己)。
|
||||
|
||||
若监控与假 RTSP **都在宿主机同一系统**里直接跑(非容器),则用 `rtsp://127.0.0.1:...` 即可;否则应使用上面「容器连宿主」的写法。
|
||||
|
||||
发布失败时,可尝试把输入转码后再推流(示例,需自行调整):
|
||||
|
||||
```bash
|
||||
ffmpeg -re -i recording.mp4 -c:v libx264 -pix_fmt yuv420p -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:18554/demo
|
||||
```
|
||||
|
||||
(仍须先自行启动 MediaMTX 或等价 RTSP 服务端;上例为**播完即止**,若要循环请加 `-stream_loop -1`。)
|
||||
|
||||
Demo 页面「调试:两路视频」中可用 **选择视频** / **拖放** 为路1/路2 指定文件,并配合下面 **一键开录** 上传。若必须完全手跑 `fake_rtsp_from_file.py`,请将其打印的站点 JSON 合并进 `OR_SITE_CONFIG_JSON_FILE`。
|
||||
|
||||
## 一键开录(不再手抄命令)
|
||||
|
||||
在 §4.1 勾选 **「一键联调」** 后,在「调试」里为**路1/路2**各选一段视频,再点 **开始手术**,浏览器会把两路视频 **multipart 上传到监控 API**(`POST /internal/demo/orchestrate-and-start`),由服务进程依次:
|
||||
|
||||
1. 落盘两路视频到临时目录
|
||||
2. 用 Docker 起 MediaMTX、两路 ffmpeg 推 RTSP(与 `fake_rtsp_from_file.py` 等效)
|
||||
3. 把当前假流的 **video_rtsp_urls** 合并写入 `OR_SITE_CONFIG_JSON_FILE`(保留已有 `voice_or_room_bindings`;与开录/拉流同进程,固定本机回环)
|
||||
4. 调用与普通开录相同逻辑
|
||||
|
||||
**需同时满足**:
|
||||
|
||||
- `.env` 中 `DEMO_ORCHESTRATOR_ENABLED=true`(并重启 API)
|
||||
- 已设置 `OR_SITE_CONFIG_JSON_FILE` 指向**可写**的站点 JSON;Docker 中请用 **bind-mount** 到容器内同一路径
|
||||
- **运行 `main.py` 的进程**能执行本机 `docker` 与 `ffmpeg`(与手动跑 `fake_rtsp_from_file` 相同)。**仅将 API 放 Docker、且不挂载** `/var/run/docker.sock` 时,容器内往往无法为你在宿主机起 MediaMTX,此时请继续用手动假流方式。
|
||||
|
||||
由于每次解析都会重新读取 `video_rtsp_url_map()`,覆盖 JSON 后**无需重启**主服务即可被下一次开录用到。
|
||||
|
||||
## 运行方式
|
||||
|
||||
```bash
|
||||
# 1) 启动宿主机 API(默认自动使用已激活的非 base conda 环境,否则使用 uv)
|
||||
./deploy.sh
|
||||
|
||||
# 2) 启动 demo 客户端静态服务(默认 0.0.0.0:38081,局域网可访问)
|
||||
./start_demo_client.sh
|
||||
# 或直接:
|
||||
python scripts/demo_client/server.py
|
||||
# 浏览器热重载(需 dev 依赖;会生成本目录 labels.json 供静态托管):
|
||||
uv run --group dev python scripts/demo_client/server.py --live
|
||||
# 或指定端口:
|
||||
python scripts/demo_client/server.py -p 9000 --host 0.0.0.0
|
||||
|
||||
# 3) 浏览器访问:
|
||||
open http://127.0.0.1:38081/
|
||||
```
|
||||
|
||||
页面顶部的「服务端 Base URL」默认指向当前页面同主机的 `:38080`。局域网访问 Demo 页时,通常会自动变成 `http://<宿主机局域网IP>:38080`;如果后端部署在其他主机/端口,直接改这里即可。
|
||||
|
||||
### 与「语音确认」页一致的热重载
|
||||
|
||||
- **API**:默认在宿主机 conda/uv 环境运行;修改后端代码后重启 `./deploy.sh` 进程即可。
|
||||
- **本 Demo**:`--live`(livereload,监视本目录与 `app/resources/consumable_classifier_labels.yaml`)。
|
||||
- **`voice-confirmation-web/`**:部署载体与 **`start_http.*`** 说明见 [voice-confirmation-web/README.md](../../voice-confirmation-web/README.md);仓库根的 `start_voice_confirmation_web.sh|.bat` 为封装入口(`--plain` / `--single` / livereload)。
|
||||
|
||||
## 页面包含什么
|
||||
|
||||
- `GET /health` 连通性检查
|
||||
- §4.1 `POST /client/surgeries/start` — 含 `surgery_id` 校验、`camera_ids` 多值输入、`candidate_consumables` 标签编辑器(初始值从 `/labels.json` 载入,可增删)
|
||||
- §4.2 `POST /client/surgeries/end`
|
||||
- §4.3 `GET /client/surgeries/{id}/result` — 以表格渲染 `details` 与 `summary`
|
||||
- **调试:无摄像头** — 多路视频选择与 `camera_id`;一键联调见上文;手跑假流见 `fake_rtsp_from_file.py` 与本文「调试:无真实摄像头」
|
||||
|
||||
`GET /client/surgeries/{id}/pending-confirmation` 与 `POST .../resolve` 仍由后端提供,本 Demo 页**不包含**相应 UI(无待确认拉取、无 TTS、无麦克风录音)。
|
||||
|
||||
右侧「响应日志」按时间倒序展示每次请求的 method/url/status/body,便于联调截图。
|
||||
|
||||
## 关于 `/labels.json`
|
||||
|
||||
`server.py` 在进程启动时读 `app/resources/consumable_classifier_labels.yaml` 的 `names` 映射并返回 `{"labels": [...]}`。优先用 `PyYAML`(主项目依赖已间接引入),缺失时回退到手写的最小 YAML 解析器(仅兼容该文件的已知形状)。
|
||||
|
||||
## 关闭 CORS(生产环境)
|
||||
|
||||
独立部署的 Demo 页和语音确认页跨域访问 API 时,需要 `DEMO_CORS_ENABLED=true`。正式部署建议在 `.env` 里把 `DEMO_CORS_ORIGINS` 收窄为具体来源,例如 `http://my-host:38081,https://or-demo.example.com`。
|
||||
|
||||
281
scripts/demo_client/fake_rtsp_from_file.py
Executable file
281
scripts/demo_client/fake_rtsp_from_file.py
Executable 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 scripts/demo_client/fake_rtsp_from_file.py /path/to/video.mp4
|
||||
python3 scripts/demo_client/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 scripts/demo_client/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
scripts/demo_client/index.html
Executable file
1070
scripts/demo_client/index.html
Executable file
File diff suppressed because it is too large
Load Diff
211
scripts/demo_client/server.py
Executable file
211
scripts/demo_client/server.py
Executable file
@@ -0,0 +1,211 @@
|
||||
"""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 the repo's
|
||||
`app/resources/consumable_classifier_labels.yaml` and returns its label
|
||||
list so the page can prefill the candidate-consumables input.
|
||||
- ``--live`` 使用 dev 依赖 ``livereload``:修改本目录下 HTML/JS/CSS
|
||||
或更新 ``labels.json``/YAML 后,浏览器自动刷新(需 ``uv run --group dev``)。
|
||||
|
||||
Run:
|
||||
python scripts/demo_client/server.py # 0.0.0.0:38081
|
||||
python scripts/demo_client/server.py -p 9000 # custom port
|
||||
uv run --group dev python scripts/demo_client/server.py --live
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parents[1]
|
||||
LABELS_YAML = REPO_ROOT / "app" / "resources" / "consumable_classifier_labels.yaml"
|
||||
# ``--live`` 模式写入静态 `labels.json`,与 livereload 兼容(勿提交,见 .gitignore)
|
||||
LABELS_JSON_ARTIFACT = SCRIPT_DIR / "labels.json"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def write_labels_json_artifact() -> None:
|
||||
body = json.dumps({"labels": load_labels()}, ensure_ascii=False) + "\n"
|
||||
LABELS_JSON_ARTIFACT.write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
def _spawn_labels_yaml_poll() -> None:
|
||||
p = LABELS_YAML
|
||||
state: list[float] = [0.0]
|
||||
if p.is_file():
|
||||
state[0] = p.stat().st_mtime
|
||||
|
||||
def loop() -> None:
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
if not p.is_file():
|
||||
continue
|
||||
m = p.stat().st_mtime
|
||||
if m > state[0]:
|
||||
state[0] = m
|
||||
try:
|
||||
write_labels_json_artifact()
|
||||
except OSError as e:
|
||||
print(
|
||||
f"[demo-client] labels.json 写入失败: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
threading.Thread(target=loop, daemon=True, name="DemoClientLabelsYamlWatch").start()
|
||||
|
||||
|
||||
def run_livereload(host: str, port: int) -> None:
|
||||
try:
|
||||
from livereload import Server
|
||||
except ImportError as exc: # pragma: no cover
|
||||
print(
|
||||
"``--live`` 需要 dev 依赖。请执行: uv sync --group dev",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
write_labels_json_artifact()
|
||||
_spawn_labels_yaml_poll()
|
||||
server = Server()
|
||||
server.watch(str(SCRIPT_DIR))
|
||||
if LABELS_YAML.is_file():
|
||||
server.watch(str(LABELS_YAML))
|
||||
url = f"http://{host}:{port}/"
|
||||
print(f"Demo client (livereload) at {url}")
|
||||
print(f" static dir : {SCRIPT_DIR}")
|
||||
print(f" labels yaml: {LABELS_YAML} -> {LABELS_JSON_ARTIFACT.name}")
|
||||
print("Press Ctrl+C to stop.")
|
||||
server.serve(
|
||||
root=str(SCRIPT_DIR),
|
||||
host=host,
|
||||
port=port,
|
||||
open_url=False,
|
||||
debug=False,
|
||||
)
|
||||
|
||||
|
||||
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 run_plain(host: str, port: int) -> None:
|
||||
server = ThreadingHTTPServer((host, port), DemoHandler)
|
||||
url = f"http://{host}:{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()
|
||||
|
||||
|
||||
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)
|
||||
parser.add_argument(
|
||||
"--live",
|
||||
action="store_true",
|
||||
help="浏览器热重载(需 uv sync --group dev;会生成同目录 labels.json)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.live:
|
||||
run_livereload(args.host, args.port)
|
||||
else:
|
||||
run_plain(args.host, args.port)
|
||||
|
||||
|
||||
_main = "__main__"
|
||||
if __name__ == _main:
|
||||
main()
|
||||
Reference in New Issue
Block a user