various fix
This commit is contained in:
@@ -16,10 +16,14 @@ RUN sed -i \
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
docker.io \
|
docker.io \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
|
fontconfig \
|
||||||
|
fonts-noto-cjk \
|
||||||
|
fonts-wqy-microhei \
|
||||||
libgl1 \
|
libgl1 \
|
||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libgomp1 \
|
libgomp1 \
|
||||||
libxcb1 \
|
libxcb1 \
|
||||||
|
&& fc-cache -fv \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ghcr.io:「增加前缀」形式(与 kindest/node 示例一致)
|
# ghcr.io:「增加前缀」形式(与 kindest/node 示例一致)
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ class SegmentRow:
|
|||||||
_FONT_CANDIDATES = [
|
_FONT_CANDIDATES = [
|
||||||
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
|
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
|
||||||
Path("/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc"),
|
Path("/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc"),
|
||||||
|
Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
|
||||||
Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"),
|
Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"),
|
||||||
|
Path("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
21
backend/app/algo_host/cjk_font.py
Normal file
21
backend/app/algo_host/cjk_font.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Resolve a CJK-capable font for offline visualization subprocesses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Keep in sync with algorithm_subprocesses/5.15/visualize_result_video.py
|
||||||
|
CJK_FONT_CANDIDATES: tuple[Path, ...] = (
|
||||||
|
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
|
||||||
|
Path("/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc"),
|
||||||
|
Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
|
||||||
|
Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"),
|
||||||
|
Path("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_cjk_font_path() -> Path | None:
|
||||||
|
for candidate in CJK_FONT_CANDIDATES:
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate.resolve()
|
||||||
|
return None
|
||||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from app.algo_host.bundle import load_reference_default_config, resolve_bundle_relative_path
|
from app.algo_host.bundle import load_reference_default_config, resolve_bundle_relative_path
|
||||||
|
from app.algo_host.cjk_font import resolve_cjk_font_path
|
||||||
from app.algo_host.transcode import VISUALIZATION_MAX_WIDTH
|
from app.algo_host.transcode import VISUALIZATION_MAX_WIDTH
|
||||||
|
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ def build_visualization_command(
|
|||||||
device_cfg = cfg.get("device") if isinstance(cfg.get("device"), dict) else {}
|
device_cfg = cfg.get("device") if isinstance(cfg.get("device"), dict) else {}
|
||||||
hand_raw = str((weights or {}).get("hand") or "weights/hand_detect.pt").strip()
|
hand_raw = str((weights or {}).get("hand") or "weights/hand_detect.pt").strip()
|
||||||
hand_model = resolve_bundle_relative_path(bundle_dir, hand_raw)
|
hand_model = resolve_bundle_relative_path(bundle_dir, hand_raw)
|
||||||
return [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-X",
|
"-X",
|
||||||
"faulthandler",
|
"faulthandler",
|
||||||
@@ -64,6 +65,10 @@ def build_visualization_command(
|
|||||||
"--max-width",
|
"--max-width",
|
||||||
str(VISUALIZATION_MAX_WIDTH),
|
str(VISUALIZATION_MAX_WIDTH),
|
||||||
]
|
]
|
||||||
|
font_path = resolve_cjk_font_path()
|
||||||
|
if font_path is not None:
|
||||||
|
cmd.extend(["--font-path", str(font_path)])
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
def _signal_name(signum: int) -> str:
|
def _signal_name(signum: int) -> str:
|
||||||
|
|||||||
@@ -266,6 +266,32 @@ def test_build_visualization_command_uses_hand_model_and_result_tsv(
|
|||||||
assert cmd[cmd.index("--max-width") + 1] == str(VISUALIZATION_MAX_WIDTH)
|
assert cmd[cmd.index("--max-width") + 1] == str(VISUALIZATION_MAX_WIDTH)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_visualization_command_passes_font_path_when_available(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
bundle = tmp_path / "bundle"
|
||||||
|
write_minimal_reference_bundle(bundle)
|
||||||
|
(bundle / "weights").mkdir()
|
||||||
|
(bundle / "weights" / "hand_detect.pt").write_bytes(b"fake")
|
||||||
|
(bundle / "visualize_result_video.py").write_text("# fake\n", encoding="utf-8")
|
||||||
|
font = tmp_path / "NotoSansCJK-Regular.ttc"
|
||||||
|
font.write_bytes(b"fake-font")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.algo_host.subprocess_runner.resolve_cjk_font_path",
|
||||||
|
lambda: font,
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = build_visualization_command(
|
||||||
|
bundle_dir=bundle,
|
||||||
|
video_path=tmp_path / "input.mp4",
|
||||||
|
result_path=tmp_path / "result.tsv",
|
||||||
|
output_video_path=tmp_path / "result_vis.mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cmd[cmd.index("--font-path") + 1] == str(font)
|
||||||
|
|
||||||
|
|
||||||
def test_build_batch_main_command_uses_5_15_main_py(tmp_path: Path) -> None:
|
def test_build_batch_main_command_uses_5_15_main_py(tmp_path: Path) -> None:
|
||||||
cmd = build_batch_main_command(
|
cmd = build_batch_main_command(
|
||||||
bundle_dir=tmp_path / "algorithm_subprocesses" / "5.15",
|
bundle_dir=tmp_path / "algorithm_subprocesses" / "5.15",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
let allLabels = [];
|
let allLabels = [];
|
||||||
let selectedConsumables = new Set();
|
let selectedConsumables = new Set();
|
||||||
let lastVideoBatchDoctorDisplay = "";
|
let lastVideoBatchDoctorDisplay = "";
|
||||||
|
let videoVisToken = 0;
|
||||||
const webcamSlotState = {};
|
const webcamSlotState = {};
|
||||||
|
|
||||||
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
|
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
|
||||||
@@ -299,6 +300,7 @@
|
|||||||
: mode === "live-simulated"
|
: mode === "live-simulated"
|
||||||
? "链路 2 · 模拟实时"
|
? "链路 2 · 模拟实时"
|
||||||
: "链路 3 · 离线精确";
|
: "链路 3 · 离线精确";
|
||||||
|
if (mode !== "offline-batch") hideVideoBatchVisualization();
|
||||||
refreshRecordingModesStatus();
|
refreshRecordingModesStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,39 +360,119 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideVideoBatchVisualization() {
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelVideoBatchVisualization() {
|
||||||
|
videoVisToken += 1;
|
||||||
const wrap = $("video-batch-vis");
|
const wrap = $("video-batch-vis");
|
||||||
const player = $("video-batch-vis-player");
|
const player = $("video-batch-vis-player");
|
||||||
|
const hint = $("video-batch-vis-hint");
|
||||||
if (wrap) wrap.classList.add("hidden");
|
if (wrap) wrap.classList.add("hidden");
|
||||||
if (player) {
|
if (player) {
|
||||||
|
player.onerror = null;
|
||||||
|
player.onloadeddata = null;
|
||||||
player.removeAttribute("src");
|
player.removeAttribute("src");
|
||||||
player.load();
|
player.load();
|
||||||
}
|
}
|
||||||
|
if (hint) hint.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function showVideoBatchVisualization(sid, urlPath, doctorDisplay) {
|
function hideVideoBatchVisualization() {
|
||||||
|
cancelVideoBatchVisualization();
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealVideoBatchVisualization() {
|
||||||
|
const wrap = $("video-batch-vis");
|
||||||
|
if (wrap) wrap.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadVideoWhenReady(player, src, token, timeoutMs = 15000) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (token !== videoVisToken) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let settled = false;
|
||||||
|
const finish = (ok) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
player.onerror = null;
|
||||||
|
player.onloadeddata = null;
|
||||||
|
if (!ok) {
|
||||||
|
player.removeAttribute("src");
|
||||||
|
player.load();
|
||||||
|
}
|
||||||
|
resolve(ok);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||||
|
player.onerror = () => finish(false);
|
||||||
|
player.onloadeddata = () => finish(true);
|
||||||
|
player.src = src;
|
||||||
|
player.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForVideoBatchVisualization(sid, urlPath, doctorDisplay) {
|
||||||
const wrap = $("video-batch-vis");
|
const wrap = $("video-batch-vis");
|
||||||
const player = $("video-batch-vis-player");
|
const player = $("video-batch-vis-player");
|
||||||
const hint = $("video-batch-vis-hint");
|
const hint = $("video-batch-vis-hint");
|
||||||
if (!wrap || !player) return;
|
if (!wrap || !player) return;
|
||||||
|
|
||||||
|
const token = ++videoVisToken;
|
||||||
const path = urlPath || `/internal/demo/offline-batch/${sid}/visualization`;
|
const path = urlPath || `/internal/demo/offline-batch/${sid}/visualization`;
|
||||||
const src = baseUrl() + path + "?t=" + Date.now();
|
const base = baseUrl() + path;
|
||||||
wrap.classList.remove("hidden");
|
const pollIntervalMs = 3000;
|
||||||
const docEl = $("video-batch-doctor-info");
|
const maxAttempts = 120;
|
||||||
const text = (doctorDisplay || lastVideoBatchDoctorDisplay || "").trim();
|
|
||||||
if (docEl) {
|
wrap.classList.add("hidden");
|
||||||
docEl.textContent = text ? "识别医生:" + text : "";
|
showVideoBatchDoctorInfo(doctorDisplay || lastVideoBatchDoctorDisplay);
|
||||||
docEl.style.display = text ? "block" : "none";
|
if (hint) hint.textContent = "";
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
if (token !== videoVisToken) return;
|
||||||
|
const src = base + "?t=" + Date.now();
|
||||||
|
const loaded = await loadVideoWhenReady(player, src, token);
|
||||||
|
if (token !== videoVisToken) return;
|
||||||
|
if (loaded) {
|
||||||
|
revealVideoBatchVisualization();
|
||||||
|
if (hint) hint.textContent = "标注视频已加载";
|
||||||
|
player.onerror = () => {
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = "视频加载失败,请稍后重试或新标签页打开链接。";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
showBanner("标注视频已就绪", "ok");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt === 0) {
|
||||||
|
showBanner("标注视频生成中,就绪后自动展示…", "info");
|
||||||
|
}
|
||||||
|
await sleep(pollIntervalMs);
|
||||||
}
|
}
|
||||||
player.onerror = () => {
|
|
||||||
if (hint) hint.textContent = "视频加载失败,请稍后重试或新标签页打开链接。";
|
if (token === videoVisToken) {
|
||||||
};
|
showBanner("标注视频尚未就绪,请稍后点击「查询结果」重试", "warn");
|
||||||
player.onloadeddata = () => {
|
}
|
||||||
if (hint) hint.textContent = "标注视频已加载";
|
}
|
||||||
};
|
|
||||||
player.src = src;
|
function showVideoBatchDoctorInfo(displayText) {
|
||||||
player.load();
|
const el = $("video-batch-doctor-info");
|
||||||
if (hint) hint.textContent = "正在加载…";
|
if (!el) return;
|
||||||
|
const text = (displayText || "").trim();
|
||||||
|
if (!text) {
|
||||||
|
el.textContent = "";
|
||||||
|
el.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.textContent = "识别医生:" + text;
|
||||||
|
el.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showVideoBatchVisualization(sid, urlPath, doctorDisplay) {
|
||||||
|
void waitForVideoBatchVisualization(sid, urlPath, doctorDisplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDebugStreamCount() {
|
function getDebugStreamCount() {
|
||||||
@@ -563,9 +645,12 @@
|
|||||||
lastVideoBatchDoctorDisplay = body?.doctor_display || "";
|
lastVideoBatchDoctorDisplay = body?.doctor_display || "";
|
||||||
if ($("offline-batch-include-vis")?.checked && body?.visualization_url) {
|
if ($("offline-batch-include-vis")?.checked && body?.visualization_url) {
|
||||||
showVideoBatchVisualization(sid, body.visualization_url, lastVideoBatchDoctorDisplay);
|
showVideoBatchVisualization(sid, body.visualization_url, lastVideoBatchDoctorDisplay);
|
||||||
} else hideVideoBatchVisualization();
|
} else {
|
||||||
|
hideVideoBatchVisualization();
|
||||||
|
showVideoBatchDoctorInfo(lastVideoBatchDoctorDisplay);
|
||||||
|
}
|
||||||
showBanner("离线处理完成,正在查询结果…", "ok");
|
showBanner("离线处理完成,正在查询结果…", "ok");
|
||||||
await handleResult();
|
await handleResult({ skipVisualization: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,10 +732,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResult() {
|
async function handleResult(options = {}) {
|
||||||
const sid = ensureSurgeryId();
|
const sid = ensureSurgeryId();
|
||||||
if (!sid) return;
|
if (!sid) return;
|
||||||
if (getRunMode() === "offline-batch" && $("offline-batch-include-vis")?.checked) {
|
if (
|
||||||
|
!options.skipVisualization &&
|
||||||
|
getRunMode() === "offline-batch" &&
|
||||||
|
$("offline-batch-include-vis")?.checked
|
||||||
|
) {
|
||||||
showVideoBatchVisualization(sid, null);
|
showVideoBatchVisualization(sid, null);
|
||||||
} else if (getRunMode() !== "offline-batch") {
|
} else if (getRunMode() !== "offline-batch") {
|
||||||
hideVideoBatchVisualization();
|
hideVideoBatchVisualization();
|
||||||
@@ -664,6 +753,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { details = [], summary = [] } = body;
|
const { details = [], summary = [] } = body;
|
||||||
|
if (getRunMode() === "offline-batch") {
|
||||||
|
showVideoBatchDoctorInfo(details[0]?.doctor_id || lastVideoBatchDoctorDisplay);
|
||||||
|
}
|
||||||
const renderTable = (title, rows, cols) => {
|
const renderTable = (title, rows, cols) => {
|
||||||
const h = document.createElement("h3");
|
const h = document.createElement("h3");
|
||||||
h.textContent = title;
|
h.textContent = title;
|
||||||
@@ -686,11 +778,13 @@
|
|||||||
};
|
};
|
||||||
renderTable("消耗明细", details, [
|
renderTable("消耗明细", details, [
|
||||||
{ key: "timestamp", label: "时间" },
|
{ key: "timestamp", label: "时间" },
|
||||||
|
{ key: "item_id", label: "耗材 ID" },
|
||||||
{ key: "item_name", label: "耗材" },
|
{ key: "item_name", label: "耗材" },
|
||||||
{ key: "qty", label: "数量" },
|
{ key: "qty", label: "数量" },
|
||||||
{ key: "doctor_id", label: "医生" },
|
{ key: "doctor_id", label: "医生(姓名+ID)" },
|
||||||
]);
|
]);
|
||||||
renderTable("汇总", summary, [
|
renderTable("汇总", summary, [
|
||||||
|
{ key: "item_id", label: "耗材 ID" },
|
||||||
{ key: "item_name", label: "耗材" },
|
{ key: "item_name", label: "耗材" },
|
||||||
{ key: "total_quantity", label: "合计" },
|
{ key: "total_quantity", label: "合计" },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -191,8 +191,8 @@
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>结果</h2>
|
<h2>结果</h2>
|
||||||
|
<p id="video-batch-doctor-info" class="labels-meta"></p>
|
||||||
<div id="video-batch-vis" class="vis-block hidden">
|
<div id="video-batch-vis" class="vis-block hidden">
|
||||||
<p id="video-batch-doctor-info" class="labels-meta"></p>
|
|
||||||
<video id="video-batch-vis-player" controls playsinline></video>
|
<video id="video-batch-vis-player" controls playsinline></video>
|
||||||
<p id="video-batch-vis-hint" class="stream-hint"></p>
|
<p id="video-batch-vis-hint" class="stream-hint"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ operation-room-monitor/
|
|||||||
- Docker Compose V2、NVIDIA 驱动、NVIDIA Container Toolkit
|
- Docker Compose V2、NVIDIA 驱动、NVIDIA Container Toolkit
|
||||||
- 复制 `backend/.env.example` 为 `backend/.env` 并填写
|
- 复制 `backend/.env.example` 为 `backend/.env` 并填写
|
||||||
- 算法子进程包:`backend/algorithm_subprocesses/5.15/`(含 `main.py` 与 `weights/`;镜像构建时会 `COPY` 进容器,勿在 `.dockerignore` 中整目录排除)
|
- 算法子进程包:`backend/algorithm_subprocesses/5.15/`(含 `main.py` 与 `weights/`;镜像构建时会 `COPY` 进容器,勿在 `.dockerignore` 中整目录排除)
|
||||||
|
- 标注视频中文字体:镜像内已安装 `fonts-noto-cjk`、`fonts-wqy-microhei`(供 `visualize_result_video.py` 绘制耗材标签)
|
||||||
- 可选备用权重:`backend/app/resources/actionformer_epoch_045.pth.tar`
|
- 可选备用权重:`backend/app/resources/actionformer_epoch_045.pth.tar`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user