Compare commits

...

3 Commits

Author SHA1 Message Date
op
caf62f232c Fix Docker uv sync flags by dropping UV_COMPILE_BYTECODE env.
uv treats a set UV_COMPILE_BYTECODE (even "0") as compile-bytecode, which conflicts with --no-compile on sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 12:06:42 +08:00
op
be24fd77f4 Fix RTSP segment ffmpeg args for bookworm and video-only MP4.
Parse OpenCV rtsp_transport options correctly, use -timeout instead of removed -stimeout, map only the first video stream with -an, tighten ready detection, and disable uv bytecode compile during Docker build.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 12:05:28 +08:00
op
b8bf31c23c Improve demo client consumable UX and clarify live vs offline flows.
Separate mode-specific steps, show product code with name in chips, validate against mistaken video paths, and load label_id from labels.yaml via server.py.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 10:44:41 +08:00
9 changed files with 534 additions and 122 deletions

View File

@@ -86,6 +86,7 @@ POSTGRES_PORT=45432
# RTSP_RECORD_ALL_CAMERAS=false
# RTSP_SEGMENT_DURATION_SEC=120
# RTSP_SEGMENT_MIN_SEC=10
# RTSP_FFMPEG_SOCKET_TIMEOUT_USEC=5000000
# RTSP_SEGMENT_TTL_HOURS=24
# RTSP_SLICE_BATCH_MAX_CONCURRENT=1
# RTSP_SLICE_BATCH_DRAIN_TIMEOUT_SEC=900

View File

@@ -40,7 +40,6 @@ COPY --from=m.daocloud.io/ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
ENV PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_HTTP_TIMEOUT=600 \
UV_LINK_MODE=copy \
TORCH_HOME=/root/.cache/torch
@@ -63,7 +62,7 @@ RUN sed -i \
ENV UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --refresh-package numpy --refresh-package mediapipe && \
uv sync --frozen --no-dev --no-compile --refresh-package numpy --refresh-package mediapipe && \
.venv/bin/python -c "import alembic" && \
.venv/bin/python -c "import numpy; import numpy.lib._index_tricks_impl" && \
.venv/bin/python -c "import mediapipe as mp; print('mediapipe', mp.__version__)"

View File

@@ -42,6 +42,23 @@ def _ffmpeg_bin() -> str:
return shutil.which("ffmpeg") or "ffmpeg"
def _parse_rtsp_transport(opencv_ffmpeg_opts: str) -> str:
"""Parse ``OPENCV_FFMPEG_CAPTURE_OPTIONS`` for ffmpeg ``-rtsp_transport``.
OpenCV uses semicolon-separated ``key;value`` pairs, e.g. ``rtsp_transport;tcp``.
"""
opts = (opencv_ffmpeg_opts or "").strip()
if not opts:
return "tcp"
parts = [p.strip() for p in opts.split(";") if p.strip()]
for i, part in enumerate(parts):
if part == "rtsp_transport" and i + 1 < len(parts):
return parts[i + 1] or "tcp"
if part.startswith("rtsp_transport:"):
return part.split(":", 1)[1].strip() or "tcp"
return "tcp"
def _build_ffmpeg_cmd(
*,
rtsp_url: str,
@@ -49,27 +66,29 @@ def _build_ffmpeg_cmd(
duration_sec: float,
) -> list[str]:
opts = os.environ.get("OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp")
transport = "tcp"
if "rtsp_transport" in opts:
for part in opts.split(";"):
if part.startswith("rtsp_transport"):
transport = part.split(":", 1)[-1].strip() or "tcp"
break
transport = _parse_rtsp_transport(opts)
timeout_usec = os.environ.get("RTSP_FFMPEG_SOCKET_TIMEOUT_USEC", "5000000").strip()
input_opts: list[str] = ["-rtsp_transport", transport]
if timeout_usec.isdigit() and int(timeout_usec) > 0:
# FFmpeg 5+ (Debian bookworm): RTSP socket I/O timeout is -timeout (microseconds).
# Older builds used -stimeout; set RTSP_FFMPEG_SOCKET_TIMEOUT_USEC=0 to omit.
input_opts.extend(["-timeout", timeout_usec])
return [
_ffmpeg_bin(),
"-hide_banner",
"-loglevel",
"warning",
"-rtsp_transport",
transport,
"-stimeout",
"5000000",
*input_opts,
"-i",
rtsp_url,
"-t",
str(max(1.0, duration_sec)),
"-c",
# OR cameras often expose G.711/AAC audio that cannot be copied into MP4; vision needs video only.
"-map",
"0:v:0",
"-c:v",
"copy",
"-an",
"-movflags",
"+faststart",
"-y",
@@ -109,12 +128,10 @@ class RtspSegmentRecorder:
async def run(self, stop_event: asyncio.Event) -> None:
self._output_dir.mkdir(parents=True, exist_ok=True)
first_segment = True
consecutive_failures = 0
while not stop_event.is_set():
output_path = self._output_dir / f"slice_{self._slice_index:04d}.mp4"
duration = self._segment_duration_sec
if stop_event.is_set():
break
proc, stderr_task = await self._start_ffmpeg(output_path, duration)
try:
await self._wait_ffmpeg(
@@ -138,18 +155,24 @@ class RtspSegmentRecorder:
except asyncio.CancelledError:
pass
if first_segment and self._ready_event is not None and not self._ready_event.is_set():
self._ready_event.set()
actual_duration = await self._probe_duration(output_path)
if actual_duration >= self._segment_min_sec:
consecutive_failures = 0
await self._emit_segment(output_path, actual_duration)
elif output_path.is_file():
output_path.unlink(missing_ok=True)
first_segment = False
if stop_event.is_set():
break
else:
if output_path.is_file():
output_path.unlink(missing_ok=True)
consecutive_failures += 1
if proc.returncode not in (0, None):
logger.warning(
"ffmpeg slice failed surgery={} camera={} slice={} rc={}",
self._surgery_id,
self._camera_id,
self._slice_index,
proc.returncode,
)
if not stop_event.is_set():
await asyncio.sleep(min(0.5 * consecutive_failures, 5.0))
async def _start_ffmpeg(
self,
@@ -200,9 +223,7 @@ class RtspSegmentRecorder:
text,
)
if self._ready_event is not None and not self._ready_event.is_set():
if output_path.is_file() and output_path.stat().st_size > 0:
self._ready_event.set()
elif "Input #" in text or "Stream mapping" in text:
if output_path.is_file() and output_path.stat().st_size >= 4096:
self._ready_event.set()
except asyncio.CancelledError:
pass

View File

@@ -0,0 +1,51 @@
"""RTSP segment recorder helpers."""
from __future__ import annotations
from pathlib import Path
from app.services.video.rtsp_segment_recorder import _build_ffmpeg_cmd, _parse_rtsp_transport
def test_parse_rtsp_transport_opencv_semicolon_format() -> None:
assert _parse_rtsp_transport("rtsp_transport;tcp") == "tcp"
assert _parse_rtsp_transport("rtsp_transport;udp") == "udp"
def test_parse_rtsp_transport_colon_fallback() -> None:
assert _parse_rtsp_transport("rtsp_transport:tcp") == "tcp"
def test_parse_rtsp_transport_defaults_to_tcp() -> None:
assert _parse_rtsp_transport("") == "tcp"
assert _parse_rtsp_transport("other;value") == "tcp"
def test_build_ffmpeg_cmd_uses_tcp_transport(monkeypatch) -> None:
monkeypatch.setenv("OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp")
cmd = _build_ffmpeg_cmd(
rtsp_url="rtsp://example/stream",
output_path=Path("/tmp/slice_0000.mp4"),
duration_sec=120.0,
)
transport_idx = cmd.index("-rtsp_transport")
assert cmd[transport_idx + 1] == "tcp"
timeout_idx = cmd.index("-timeout")
assert cmd[timeout_idx + 1] == "5000000"
assert "-stimeout" not in cmd
map_idx = cmd.index("-map")
assert cmd[map_idx + 1] == "0:v:0"
assert "-an" in cmd
assert "-c:v" in cmd and "copy" in cmd
assert cmd.index("-i") > timeout_idx
def test_build_ffmpeg_cmd_omits_timeout_when_zero(monkeypatch) -> None:
monkeypatch.setenv("RTSP_FFMPEG_SOCKET_TIMEOUT_USEC", "0")
cmd = _build_ffmpeg_cmd(
rtsp_url="rtsp://example/stream",
output_path=Path("/tmp/slice_0000.mp4"),
duration_sec=120.0,
)
assert "-timeout" not in cmd
assert "-stimeout" not in cmd

View File

@@ -13,8 +13,9 @@
## 界面说明
- **链路 1**:填 `camera_ids`(逗号分隔);默认仅 `or-cam-03` 参与 RTSP 录像切片与 batch 算法。服务端落盘 `slice_*.mp4` 仅用于推理,默认 **24 小时**后自动删除(`RTSP_SEGMENT_TTL_HOURS`),不影响已入库消耗明细;页顶「刷新状态」会显示当前切片间隔与保留时长
- **链路 3**:独立 MP4 上传区,可选生成标注视频(独立 TTL`VIDEO_BATCH_VIS_TTL_HOURS`,默认 24 小时)
- **链路 1**只需`camera_ids` 与耗材候选,点「开始手术」;**无需上传视频**(服务端自动 RTSP 拉流切片)
- **链路 1 / 3 共用「步骤 2 · 耗材候选」**:标签区显示「编号 + 名称」;提交时以 `{"消耗品编号","名称"}` 对象数组发送(与医院导出表一致)。
- 误把 `.mp4` 路径填进耗材候选时,页面会提示并阻止提交。
详见 [`docs/video-backends.md`](../../docs/video-backends.md)。
@@ -22,10 +23,10 @@
```bash
cd clients/demo-client
python3 -m http.server 38081
python3 server.py
```
浏览器打开 `http://127.0.0.1:38081/`API 地址填后端(默认 `http://127.0.0.1:38080`)。
浏览器打开 `http://127.0.0.1:38081/`API 地址填后端(默认 `http://127.0.0.1:38080`)。须用 `server.py`(而非裸 `http.server`),才能从 `labels.yaml` 加载编号+名称。
## HLS 预览(链路 1

View File

@@ -7,7 +7,9 @@
const $ = (id) => document.getElementById(id);
let activeMode = "live-rtsp";
let allLabels = [];
let allConsumables = [];
const consumableByName = new Map();
const consumableByCode = new Map();
let selectedConsumables = new Set();
let lastVideoBatchDoctorDisplay = "";
let videoVisToken = 0;
@@ -311,10 +313,71 @@
return sid;
}
function indexConsumables(items) {
allConsumables = items;
consumableByName.clear();
consumableByCode.clear();
for (const item of items) {
consumableByName.set(item.name, item);
const code = (item.code || "").trim();
if (!code) continue;
consumableByCode.set(code, item);
for (const part of code.split("/")) {
const p = part.trim();
if (p) consumableByCode.set(p, item);
}
}
}
function formatConsumableDisplay(item) {
const code = (item.code || "").trim();
const name = (item.name || "").trim();
if (code && name) return code + " · " + name;
return name || code;
}
function resolveConsumableName(raw) {
const s = String(raw || "").trim();
if (!s) return null;
if (consumableByName.has(s)) return s;
const byCode = consumableByCode.get(s);
if (byCode) return byCode.name;
return s;
}
function consumableExportObject(name) {
const item = consumableByName.get(name);
if (item?.code) {
return { 消耗品编号: item.code, 名称: item.name };
}
return name;
}
function chipExportPayload() {
return [...selectedConsumables].map(consumableExportObject);
}
function formatConsumablesJson(arr) {
return JSON.stringify(arr, null, 2);
}
function parseConsumableJsonEntry(x) {
if (typeof x === "string") {
const s = x.trim();
return s ? s : null;
}
if (x !== null && typeof x === "object" && !Array.isArray(x)) {
const name = x["名称"] != null ? x["名称"] : x["name"];
const code = x["消耗品编号"] != null ? x["消耗品编号"] : x["code"] ?? x["label_id"];
const nameStr = typeof name === "string" ? name.trim() : "";
const codeStr = code != null ? String(code).trim() : "";
if (nameStr && codeStr) return { 消耗品编号: codeStr, 名称: nameStr };
if (nameStr) return nameStr;
if (codeStr) return codeStr;
}
return null;
}
function parseConsumablesFromJsonText(raw) {
if (!raw.trim()) return [];
let parsed;
@@ -326,61 +389,109 @@
if (!Array.isArray(parsed)) throw new Error("必须是 JSON 数组");
const out = [];
for (let i = 0; i < parsed.length; i++) {
const x = parsed[i];
if (typeof x === "string") {
const s = x.trim();
if (s) out.push(s);
const entry = parseConsumableJsonEntry(parsed[i]);
if (entry != null) out.push(entry);
}
const warnValues = out.map((entry) =>
typeof entry === "string" ? entry : entry["名称"] || entry["消耗品编号"] || "",
);
updateConsumablesWarning(warnValues);
return out;
}
function selectedNamesFromPayload(payload) {
const names = new Set();
for (const entry of payload) {
if (typeof entry === "string") {
const resolved = resolveConsumableName(entry);
if (resolved) names.add(resolved);
continue;
}
if (x !== null && typeof x === "object" && !Array.isArray(x)) {
const name = x["名称"] != null ? x["名称"] : x["name"];
if (typeof name === "string") {
const s = name.trim();
if (s) out.push(s);
if (entry && typeof entry === "object") {
const name = entry["名称"];
const code = entry["消耗品编号"];
if (typeof name === "string" && name.trim()) {
names.add(resolveConsumableName(name.trim()));
} else if (code) {
const resolved = resolveConsumableName(String(code));
if (resolved) names.add(resolved);
}
}
}
return out;
return names;
}
function getSelectedConsumables() {
const ta = $("candidate-consumables-json");
const advanced = ta?.closest("details.advanced");
let values;
if (ta && (advanced?.open || document.activeElement === ta)) {
return parseConsumablesFromJsonText(ta.value);
values = parseConsumablesFromJsonText(ta.value);
} else {
values = chipExportPayload();
updateConsumablesWarning(
values.map((entry) =>
typeof entry === "string" ? entry : entry["名称"] || entry["消耗品编号"] || "",
),
);
}
return [...selectedConsumables];
return values;
}
function syncJsonFromChips() {
const ta = $("candidate-consumables-json");
if (ta) ta.value = formatConsumablesJson(getSelectedConsumables());
if (ta) ta.value = formatConsumablesJson(chipExportPayload());
}
function syncChipsFromJson() {
const raw = $("candidate-consumables-json")?.value || "";
try {
const arr = parseConsumablesFromJsonText($("candidate-consumables-json").value);
selectedConsumables = new Set(arr);
const arr = parseConsumablesFromJsonText(raw);
selectedConsumables = selectedNamesFromPayload(arr);
renderConsumableChips();
} catch {
/* ignore while typing invalid json */
const rough = [];
for (const m of raw.matchAll(/"([^"\\]*(?:\\.[^"\\]*)*)"/g)) {
const s = m[1].replace(/\\"/g, '"').trim();
if (s) rough.push(s);
}
updateConsumablesWarning(rough);
}
}
function consumableMatchesQuery(item, query) {
if (!query) return true;
const q = query.toLowerCase();
return (
(item.name || "").toLowerCase().includes(q) ||
(item.code || "").toLowerCase().includes(q)
);
}
function renderConsumableChips() {
const container = $("chips");
const q = ($("chip-search")?.value || "").trim().toLowerCase();
const q = ($("chip-search")?.value || "").trim();
if (!container) return;
container.innerHTML = "";
const filtered = allLabels.filter((l) => !q || l.toLowerCase().includes(q));
filtered.forEach((label) => {
const filtered = allConsumables.filter((item) => consumableMatchesQuery(item, q));
filtered.forEach((item) => {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "chip" + (selectedConsumables.has(label) ? " selected" : "");
chip.textContent = label;
chip.className = "chip" + (selectedConsumables.has(item.name) ? " selected" : "");
chip.title = formatConsumableDisplay(item);
const codeEl = document.createElement("span");
codeEl.className = "chip-code";
codeEl.textContent = (item.code || "").trim() || "—";
const nameEl = document.createElement("span");
nameEl.className = "chip-name";
nameEl.textContent = item.name;
chip.append(codeEl, nameEl);
chip.onclick = () => {
if (selectedConsumables.has(label)) selectedConsumables.delete(label);
else selectedConsumables.add(label);
if (selectedConsumables.has(item.name)) selectedConsumables.delete(item.name);
else selectedConsumables.add(item.name);
renderConsumableChips();
syncJsonFromChips();
};
@@ -388,8 +499,14 @@
});
const meta = $("labels-meta");
if (meta) {
meta.textContent = `已选 ${selectedConsumables.size} / 共 ${allLabels.length}标签`;
meta.textContent = `已选 ${selectedConsumables.size} / 共 ${allConsumables.length}耗材`;
}
updateConsumablesWarning(
[...selectedConsumables].map((name) => {
const item = consumableByName.get(name);
return item ? formatConsumableDisplay(item) : name;
}),
);
}
async function loadLabels() {
@@ -397,15 +514,23 @@
const res = await fetch("/labels.json");
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
allLabels = Array.isArray(data.labels) ? data.labels : [];
selectedConsumables = new Set(allLabels.slice(0, 5));
const items = Array.isArray(data.items) ? data.items : [];
indexConsumables(
items
.map((row) => ({
name: String(row?.name || "").trim(),
code: String(row?.code || "").trim(),
}))
.filter((row) => row.name),
);
selectedConsumables = new Set(allConsumables.slice(0, 5).map((item) => item.name));
renderConsumableChips();
syncJsonFromChips();
} catch (e) {
allLabels = [];
indexConsumables([]);
selectedConsumables = new Set();
renderConsumableChips();
$("labels-meta").textContent = "标签加载失败";
$("labels-meta").textContent = "标签加载失败(请用 python server.py 启动以读取 labels.yaml";
console.warn(e);
}
}
@@ -429,6 +554,7 @@
if (btnEnd) btnEnd.disabled = mode === "offline-batch";
$("pill-mode").textContent =
mode === "live-rtsp" ? "链路 1 · 真摄像头" : "链路 3 · 离线精确";
refreshModeHints();
if (mode !== "offline-batch") hideVideoBatchVisualization();
refreshRecordingModesStatus();
if (mode === "live-rtsp") {
@@ -836,12 +962,83 @@
);
}
function looksLikeVideoPath(value) {
const t = String(value || "").trim();
if (!t) return false;
if (/^video\//i.test(t)) return true;
if (/\.(mp4|mov|mkv|avi|webm|m4v)(\?.*)?$/i.test(t)) return true;
if (/^\.?\/?[\w./\\-]+\.(mp4|mov|mkv|avi|webm|m4v)/i.test(t)) return true;
return false;
}
function consumableEntryText(entry) {
if (typeof entry === "string") return entry;
if (entry && typeof entry === "object") {
return String(entry["名称"] || entry["消耗品编号"] || "");
}
return "";
}
function findVideoPathEntries(values) {
return values.filter((entry) => looksLikeVideoPath(consumableEntryText(entry)));
}
function updateConsumablesWarning(values) {
const el = $("consumables-warn");
if (!el) return;
const bad = findVideoPathEntries(values);
if (!bad.length) {
el.textContent = "";
el.classList.add("hidden");
return;
}
el.textContent =
"检测到疑似视频路径(" +
bad
.slice(0, 2)
.map((entry) => consumableEntryText(entry))
.join("、") +
(bad.length > 2 ? " 等" : "") +
")。耗材候选应填耗材名称或产品编码;视频请在「链路 3」上传区选择。";
el.classList.remove("hidden");
}
function validateConsumablesOrThrow(values) {
const bad = findVideoPathEntries(values);
if (bad.length) {
throw new Error(
"耗材候选中不能填视频路径(如 " +
consumableEntryText(bad[0]) +
")。请选耗材标签,或在「链路 3 · 离线精确」中上传 MP4。",
);
}
}
function refreshModeHints() {
const mode = getRunMode();
const consumablesHint = $("consumables-mode-hint");
if (consumablesHint) {
consumablesHint.innerHTML =
mode === "live-rtsp"
? '选择本次手术可能用到的耗材名称或产品编码;留空 <code>[]</code> 表示使用全部标签。<strong>真摄像头链路无需上传视频,此处不是 MP4 路径。</strong>'
: '与链路 1 共用耗材白名单;上方「步骤 1 · 上传 MP4」才是视频入口此处仍只填耗材名称或编码。';
}
const actionHint = $("action-mode-hint");
if (actionHint) {
actionHint.textContent =
mode === "live-rtsp"
? "链路 1开始 → 语音终端确认 → 结束手术 → 查询结果"
: "链路 3上传并处理 → 查询结果(无需结束手术)";
}
}
async function handleStart() {
const sid = ensureSurgeryId();
if (!sid) return;
let candidateConsumables;
try {
candidateConsumables = getSelectedConsumables();
validateConsumablesOrThrow(candidateConsumables);
} catch (e) {
showBanner(e.message || String(e), "err");
return;
@@ -1016,9 +1213,17 @@
}
function initConsumables() {
$("chip-search")?.addEventListener("input", renderConsumableChips);
$("chip-search")?.addEventListener("input", () => {
const q = ($("chip-search")?.value || "").trim();
if (looksLikeVideoPath(q)) {
updateConsumablesWarning([q]);
} else {
updateConsumablesWarning(getSelectedConsumables());
}
renderConsumableChips();
});
$("btn-chips-all")?.addEventListener("click", () => {
allLabels.forEach((l) => selectedConsumables.add(l));
allConsumables.forEach((item) => selectedConsumables.add(item.name));
renderConsumableChips();
syncJsonFromChips();
});
@@ -1027,6 +1232,7 @@
renderConsumableChips();
syncJsonFromChips();
});
$("candidate-consumables-json")?.addEventListener("input", syncChipsFromJson);
$("candidate-consumables-json")?.addEventListener("change", syncChipsFromJson);
$("candidate-consumables-json")?.addEventListener("blur", syncChipsFromJson);
}
@@ -1037,6 +1243,7 @@
initOfflineUpload();
initConsumables();
loadLabels();
refreshModeHints();
$("btn-start")?.addEventListener("click", handleStart);
$("btn-end")?.addEventListener("click", handleEnd);

View File

@@ -10,7 +10,7 @@
<div class="app">
<header class="header">
<h1>手术监控 · 联调台</h1>
<p class="subtitle">两条链路:真摄像头 RTSP 切片 batch、离线精确处理</p>
<p class="subtitle">联调专用页面 · 正式客户端只需调用 <code>/client/surgeries/start</code>,无需上传视频</p>
<div class="top-grid">
<div>
@@ -43,37 +43,23 @@
<div class="main-col">
<section class="card">
<h2>选择运行模式</h2>
<div class="mode-cards">
<div class="mode-cards mode-cards--two">
<button type="button" class="mode-card active" data-mode="live-rtsp">
<div class="title">链路 1 · 真摄像头</div>
<div class="desc">RTSP 录像切片 · batch 算法 · 语音播报 · 需结束手术</div>
<div class="desc">填 camera_id 开录 · 服务端自动拉 RTSP · 无需上传视频</div>
</button>
<button type="button" class="mode-card" data-mode="offline-batch">
<div class="title">链路 3 · 离线精确</div>
<div class="desc">整段 MP4 批处理 · 无语音 · 无需结束 · 可选标注视频</div>
<div class="desc">Demo 回放 · 须上传 MP4 · 无语音 · 不走实时会话</div>
</button>
</div>
</section>
<section class="card">
<h2>耗材候选</h2>
<div class="chip-toolbar">
<input id="chip-search" class="chip-search" type="text" placeholder="搜索标签…" />
<button type="button" class="secondary" id="btn-chips-all">全选</button>
<button type="button" class="secondary" id="btn-chips-clear">清空</button>
</div>
<div id="chips" class="chips"></div>
<p id="labels-meta" class="labels-meta">加载中…</p>
<details class="advanced">
<summary>高级JSON 编辑(与上方标签同步)</summary>
<div class="advanced-body">
<textarea id="candidate-consumables-json" rows="5" spellcheck="false"></textarea>
</div>
</details>
</section>
<section class="card mode-panel active" data-mode="live-rtsp">
<h2>真摄像头配置</h2>
<div class="mode-callout mode-callout--live">
<strong>正式对接链路。</strong>客户端只传 <code>camera_ids</code> 与耗材候选;视频由服务端从 RTSP 拉流并切片,<em>不要</em>在此页上传 MP4。
</div>
<h2>步骤 1 · 摄像头 ID</h2>
<label for="camera-ids">摄像头 ID逗号分隔</label>
<input id="camera-ids" type="text" value="or-cam-01, or-cam-02, or-cam-03, or-cam-04" placeholder="or-cam-01, or-cam-02" />
<p id="live-rtsp-segment-hint" class="labels-meta" style="margin-top:8px">
@@ -82,7 +68,10 @@
</section>
<section class="card mode-panel" data-mode="offline-batch">
<h2>离线精确 · 上传 MP4</h2>
<div class="mode-callout mode-callout--offline">
<strong>Demo / 回归专用。</strong>用历史录像离线跑算法;与链路 1 无关,<em>只有此模式</em>需要上传 MP4。
</div>
<h2>步骤 1 · 上传 MP4</h2>
<p class="labels-meta">不启动实时会话,处理完成后直接查结果。</p>
<div class="upload-zone" id="offline-zone" style="margin-top:12px">
<div class="icon">MP4</div>
@@ -114,8 +103,30 @@
<div id="rtsp-preview-grid" class="preview-grid preview-grid--rtsp"></div>
</section>
<section class="card mode-panel active" data-mode="live-rtsp offline-batch">
<h2>步骤 2 · 耗材候选AI 白名单)</h2>
<p id="consumables-mode-hint" class="field-hint">
选择本次手术可能用到的耗材名称或产品编码;留空表示使用全部标签。<strong>此处不是视频上传区,请勿填写 .mp4 路径。</strong>
</p>
<div id="consumables-warn" class="consumables-warn hidden" role="alert"></div>
<div class="chip-toolbar">
<input id="chip-search" class="chip-search" type="text" placeholder="搜索编号或名称…" autocomplete="off" />
<button type="button" class="secondary" id="btn-chips-all">全选标签</button>
<button type="button" class="secondary" id="btn-chips-clear">清空选择</button>
</div>
<div id="chips" class="chips"></div>
<p id="labels-meta" class="labels-meta">加载中…</p>
<details class="advanced">
<summary>高级JSON 编辑(耗材名称/编码,与上方标签同步)</summary>
<div class="advanced-body">
<textarea id="candidate-consumables-json" rows="5" spellcheck="false" placeholder='[{"消耗品编号": "14764-2-4", "名称": "一次性使用手术单"}]'></textarea>
</div>
</details>
</section>
<section class="card">
<h2>操作</h2>
<p id="action-mode-hint" class="field-hint"></p>
<div class="steps">
<button type="button" class="primary lg" id="btn-start">开始手术</button>
<button type="button" class="warn" id="btn-end">结束手术</button>

View File

@@ -1,8 +1,8 @@
"""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.
- Exposes `GET /labels.json`, which parses local `labels.yaml` and returns
consumable items (name + product code) for the candidate-consumables UI.
Run:
python server.py # 0.0.0.0:38081
@@ -25,7 +25,38 @@ SCRIPT_DIR = Path(__file__).resolve().parent
LABELS_YAML = SCRIPT_DIR / "labels.yaml"
def _load_labels_with_pyyaml(path: Path) -> list[str] | None:
def _label_id_for_index(label_raw: Any, index: int) -> str:
if not isinstance(label_raw, dict):
return ""
lid: Any = label_raw.get(index)
if lid is None:
lid = label_raw.get(str(index))
if lid is None or (isinstance(lid, str) and not lid.strip()):
return ""
return str(lid).strip()
def _items_from_yaml_dict(data: dict[str, Any]) -> list[dict[str, str]]:
names_raw = data.get("names")
if not isinstance(names_raw, dict):
return []
label_raw = data.get("label_id")
items: list[tuple[int, str, str]] = []
for k, v in names_raw.items():
try:
i = int(k)
except (TypeError, ValueError):
continue
name = str(v).strip() if v is not None else ""
if not name:
continue
code = _label_id_for_index(label_raw, i)
items.append((i, name, code))
items.sort(key=lambda row: row[0])
return [{"name": name, "code": code} for _, name, code in items]
def _load_items_with_pyyaml(path: Path) -> list[dict[str, str]] | None:
try:
import yaml # type: ignore[import-untyped]
except ImportError:
@@ -36,23 +67,15 @@ def _load_labels_with_pyyaml(path: Path) -> list[str] | None:
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
return _items_from_yaml_dict(data)
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*$")
def _load_items_fallback(path: Path) -> list[dict[str, str]]:
"""Minimal parser for labels.yaml: ``names:`` and ``label_id:`` int-key maps."""
names: dict[int, str] = {}
label_ids: dict[int, str] = {}
section: str | None = None
key_pattern = re.compile(r"^\s+(\d+)\s*:\s*(.+?)\s*$")
try:
text = path.read_text(encoding="utf-8")
except OSError:
@@ -62,26 +85,40 @@ def _load_labels_fallback(path: Path) -> list[str]:
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("names:"):
in_names = True
section = "names"
continue
if in_names and not line.startswith((" ", "\t")):
in_names = False
if not in_names:
if stripped.startswith("label_id:"):
section = "label_id"
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]
if section and not line.startswith((" ", "\t")):
section = None
if section not in {"names", "label_id"}:
continue
match = key_pattern.match(line)
if not match:
continue
idx = int(match.group(1))
value = match.group(2).strip().strip('"').strip("'")
if section == "names":
names[idx] = value
else:
label_ids[idx] = value
items: list[tuple[int, str, str]] = []
for idx in sorted(names):
name = names[idx]
if not name:
continue
items.append((idx, name, label_ids.get(idx, "")))
return [{"name": name, "code": code} for _, name, code in items]
def load_labels() -> list[str]:
def load_consumable_items() -> list[dict[str, 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
items = _load_items_with_pyyaml(LABELS_YAML)
if items is None:
items = _load_items_fallback(LABELS_YAML)
return items
class DemoHandler(SimpleHTTPRequestHandler):
@@ -101,7 +138,8 @@ class DemoHandler(SimpleHTTPRequestHandler):
super().do_GET()
def _send_labels(self) -> None:
body = json.dumps({"labels": load_labels()}, ensure_ascii=False).encode("utf-8")
items = load_consumable_items()
body = json.dumps({"items": items}, 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)))

View File

@@ -236,8 +236,13 @@ select:focus {
gap: 10px;
}
.mode-cards--two {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 800px) {
.mode-cards {
.mode-cards,
.mode-cards--two {
grid-template-columns: 1fr;
}
}
@@ -284,6 +289,53 @@ select:focus {
display: block;
}
.mode-callout {
margin: 0 0 16px;
padding: 12px 14px;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
line-height: 1.55;
}
.mode-callout code {
font-size: 0.75rem;
}
.mode-callout--live {
background: var(--success-dim);
border: 1px solid rgba(52, 211, 153, 0.35);
color: var(--text);
}
.mode-callout--offline {
background: var(--warn-dim);
border: 1px solid rgba(251, 191, 36, 0.35);
color: var(--text);
}
.field-hint {
margin: -6px 0 14px;
font-size: 0.8125rem;
color: var(--muted);
line-height: 1.5;
}
.field-hint code {
font-size: 0.75rem;
color: var(--accent);
}
.consumables-warn {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
line-height: 1.45;
color: #fecaca;
background: var(--danger-dim);
border: 1px solid rgba(248, 113, 113, 0.45);
}
.chip-toolbar {
display: flex;
flex-wrap: wrap;
@@ -307,8 +359,13 @@ select:focus {
}
.chip {
padding: 6px 12px;
border-radius: 999px;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
max-width: 280px;
padding: 8px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--muted);
@@ -316,16 +373,42 @@ select:focus {
cursor: pointer;
transition: all 0.12s;
user-select: none;
text-align: left;
line-height: 1.35;
}
.chip-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.6875rem;
font-weight: 600;
color: var(--accent);
word-break: break-all;
}
.chip-name {
font-size: 0.8125rem;
color: var(--text);
word-break: break-word;
}
.chip:hover {
border-color: var(--accent);
}
.chip:hover .chip-name {
color: var(--text);
}
.chip.selected {
background: var(--accent-dim);
border-color: var(--accent);
}
.chip.selected .chip-code {
color: #7dd3fc;
}
.chip.selected .chip-name {
color: var(--accent);
font-weight: 500;
}