Compare commits
3 Commits
30e6acea70
...
caf62f232c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf62f232c | ||
|
|
be24fd77f4 | ||
|
|
b8bf31c23c |
@@ -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
|
||||
|
||||
@@ -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__)"
|
||||
|
||||
@@ -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
|
||||
|
||||
51
backend/tests/test_rtsp_segment_recorder.py
Normal file
51
backend/tests/test_rtsp_segment_recorder.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user