Refine DICOM classification labels and loading
This commit is contained in:
@@ -22,6 +22,9 @@ const app = {
|
||||
drag: null,
|
||||
seriesSort: "timeAsc",
|
||||
showOverlay: true,
|
||||
thumbUrls: new Map(),
|
||||
thumbObserver: null,
|
||||
thumbSeries: new WeakMap(),
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -97,7 +100,11 @@ function timeKey(series) {
|
||||
}
|
||||
|
||||
function phaseLabel(value) {
|
||||
return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || "";
|
||||
return { arterial: "动脉期", portal_venous: "门静脉期", delayed: "延迟期", unknown: "无法判别" }[value] || "";
|
||||
}
|
||||
|
||||
function chestWindowLabel(value) {
|
||||
return { lung: "肺窗", mediastinal: "纵隔窗", unknown: "无法判别" }[value] || "";
|
||||
}
|
||||
|
||||
function partLabel(value) {
|
||||
@@ -254,7 +261,6 @@ async function selectStudy(ctNumber) {
|
||||
setSeries(data.series || []);
|
||||
refreshStudyAnnotationSummary();
|
||||
renderStudies();
|
||||
if (app.series.length) selectSeries(app.series[0].series_uid);
|
||||
} catch (err) {
|
||||
$("seriesGrid").innerHTML = `<p class="error-line">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
@@ -270,7 +276,7 @@ function seriesTags(series) {
|
||||
const annotation = series.annotation || {};
|
||||
const tags = [];
|
||||
if (annotation.skipped) {
|
||||
tags.push({ label: "略过", source: annotation.ai_skipped ? "ai" : "manual" });
|
||||
tags.push({ label: "略过/不采用", source: annotation.ai_skipped ? "ai" : "manual" });
|
||||
}
|
||||
if (annotation.plain_ct) {
|
||||
tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" });
|
||||
@@ -280,7 +286,19 @@ function seriesTags(series) {
|
||||
}
|
||||
const phase = phaseLabel(annotation.upper_abdomen_phase);
|
||||
if (phase) {
|
||||
tags.push({ label: phase, source: annotation.manual_upper_abdomen_phase ? "manual" : "ai" });
|
||||
tags.push({
|
||||
label: phase,
|
||||
source: annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "",
|
||||
warn: annotation.upper_abdomen_phase === "unknown",
|
||||
});
|
||||
}
|
||||
const chestWindow = chestWindowLabel(annotation.chest_window);
|
||||
if (chestWindow) {
|
||||
tags.push({
|
||||
label: chestWindow,
|
||||
source: annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : "",
|
||||
warn: annotation.chest_window === "unknown",
|
||||
});
|
||||
}
|
||||
if (series.body_part_dicom) tags.unshift({ label: series.body_part_dicom, source: "" });
|
||||
return tags;
|
||||
@@ -288,6 +306,7 @@ function seriesTags(series) {
|
||||
|
||||
function renderSeries() {
|
||||
const grid = $("seriesGrid");
|
||||
if (app.thumbObserver) app.thumbObserver.disconnect();
|
||||
grid.innerHTML = "";
|
||||
for (const series of app.series) {
|
||||
const skipped = Boolean(series.annotation?.skipped);
|
||||
@@ -300,18 +319,18 @@ function renderSeries() {
|
||||
<div class="thumb">
|
||||
<img alt="" />
|
||||
<b>${Number(series.count || 0)} 张</b>
|
||||
${skipped ? "<i>略过</i>" : ""}
|
||||
${skipped ? "<i>略过/不采用</i>" : ""}
|
||||
</div>
|
||||
<div class="series-copy">
|
||||
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
|
||||
<span class="shot-time">拍摄 ${escapeHtml(timeRange(series))}</span>
|
||||
<small>序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}</small>
|
||||
<div class="tag-line">${tags.length ? tags.map((tag) => `<em class="${tag.source ? `tag-${tag.source}` : ""}">${escapeHtml(tag.label)}</em>`).join("") : "<em>未标注</em>"}</div>
|
||||
<div class="tag-line">${tags.length ? tags.map((tag) => `<em class="${[tag.source ? `tag-${tag.source}` : "", tag.warn ? "tag-warn" : ""].filter(Boolean).join(" ")}">${escapeHtml(tag.label)}</em>`).join("") : "<em>未标注</em>"}</div>
|
||||
</div>
|
||||
`;
|
||||
card.onclick = () => selectSeries(series.series_uid);
|
||||
grid.appendChild(card);
|
||||
loadThumb(series, card.querySelector("img"));
|
||||
queueThumb(series, card.querySelector("img"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,14 +340,53 @@ function imageUrlFor(series = app.activeSeries, index = app.slice, windowName =
|
||||
|
||||
async function loadThumb(series, img) {
|
||||
try {
|
||||
const key = thumbKey(series);
|
||||
if (app.thumbUrls.has(key)) {
|
||||
img.src = app.thumbUrls.get(key);
|
||||
return;
|
||||
}
|
||||
const index = Math.floor((Number(series.count) || 1) / 2);
|
||||
const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob());
|
||||
img.src = URL.createObjectURL(blob);
|
||||
const url = URL.createObjectURL(blob);
|
||||
app.thumbUrls.set(key, url);
|
||||
img.src = url;
|
||||
} catch (_) {
|
||||
img.removeAttribute("src");
|
||||
}
|
||||
}
|
||||
|
||||
function thumbKey(series) {
|
||||
return `${series.ct_number || app.study?.ct_number || ""}:${series.series_uid}`;
|
||||
}
|
||||
|
||||
function queueThumb(series, img) {
|
||||
const key = thumbKey(series);
|
||||
if (app.thumbUrls.has(key)) {
|
||||
img.src = app.thumbUrls.get(key);
|
||||
return;
|
||||
}
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
loadThumb(series, img);
|
||||
return;
|
||||
}
|
||||
if (!app.thumbObserver) {
|
||||
app.thumbObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const target = entry.target;
|
||||
app.thumbObserver.unobserve(target);
|
||||
const queuedSeries = app.thumbSeries.get(target);
|
||||
if (queuedSeries) loadThumb(queuedSeries, target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "320px 0px" },
|
||||
);
|
||||
}
|
||||
app.thumbSeries.set(img, series);
|
||||
app.thumbObserver.observe(img);
|
||||
}
|
||||
|
||||
function maxSlice() {
|
||||
if (!app.activeSeries) return 0;
|
||||
if (app.plane === "sagittal") return Math.max(0, Number(app.activeSeries.columns || 1) - 1);
|
||||
@@ -367,6 +425,7 @@ function resetViewer() {
|
||||
$("imageEmpty").classList.remove("hidden");
|
||||
$("sliceText").textContent = "0 / 0";
|
||||
$("saveState").textContent = "已保存";
|
||||
clearAnnotationControls();
|
||||
app.dirty = false;
|
||||
app.saving = false;
|
||||
resetViewState();
|
||||
@@ -386,19 +445,12 @@ function windowInfo() {
|
||||
};
|
||||
}
|
||||
|
||||
function orientationLabels() {
|
||||
if (app.plane === "sagittal") return { top: "H", bottom: "F", left: "A", right: "P" };
|
||||
if (app.plane === "coronal") return { top: "H", bottom: "F", left: "R", right: "L" };
|
||||
return { top: "A", bottom: "P", left: "R", right: "L" };
|
||||
}
|
||||
|
||||
function updateOverlay() {
|
||||
const overlay = $("imageOverlay");
|
||||
overlay.classList.toggle("hidden", !app.showOverlay || !app.activeSeries);
|
||||
$("overlayToggle").textContent = app.showOverlay ? "隐藏信息" : "显示信息";
|
||||
if (!app.activeSeries) return;
|
||||
const s = app.activeSeries;
|
||||
const orient = orientationLabels();
|
||||
const win = windowInfo();
|
||||
const age = ageText(s.patient_birth_date, s.study_date || app.study?.study_date);
|
||||
const sex = s.patient_sex || "";
|
||||
@@ -412,10 +464,6 @@ function updateOverlay() {
|
||||
s.institution_name || "",
|
||||
s.manufacturer || "",
|
||||
].filter(Boolean).map(escapeHtml).join("<br />");
|
||||
document.querySelector(".ov-left-mid").textContent = orient.left;
|
||||
document.querySelector(".ov-right-mid").textContent = orient.right;
|
||||
document.querySelector(".ov-top-mid").textContent = orient.top;
|
||||
document.querySelector(".ov-bottom-mid").textContent = orient.bottom;
|
||||
document.querySelector(".ov-left-bottom").textContent = `Img:${app.slice + 1}/${maxSlice() + 1}`;
|
||||
document.querySelector(".ov-right-bottom").innerHTML = `WW:${Math.round(win.ww)}<br />WL:${Math.round(win.wl)}<br />Zoom:${app.zoom.toFixed(2)}`;
|
||||
}
|
||||
@@ -451,6 +499,9 @@ function cloneAnnotation(annotation = {}) {
|
||||
upper_abdomen_phase: annotation.upper_abdomen_phase || "",
|
||||
manual_upper_abdomen_phase: annotation.manual_upper_abdomen_phase || "",
|
||||
ai_upper_abdomen_phase: annotation.ai_upper_abdomen_phase || "",
|
||||
chest_window: annotation.chest_window || "",
|
||||
manual_chest_window: annotation.manual_chest_window || "",
|
||||
ai_chest_window: annotation.ai_chest_window || "",
|
||||
plain_ct: Boolean(annotation.plain_ct),
|
||||
manual_plain_ct: annotation.manual_plain_ct ?? null,
|
||||
ai_plain_ct: annotation.ai_plain_ct ?? null,
|
||||
@@ -471,6 +522,11 @@ function effectivePhase() {
|
||||
return app.draft.manual_upper_abdomen_phase || app.draft.ai_upper_abdomen_phase || "";
|
||||
}
|
||||
|
||||
function effectiveChestWindow() {
|
||||
if (!effectiveParts().includes("chest")) return "";
|
||||
return app.draft.manual_chest_window || app.draft.ai_chest_window || "unknown";
|
||||
}
|
||||
|
||||
function effectivePlainCt() {
|
||||
if (!app.draft) return false;
|
||||
if (app.draft.manual_plain_ct !== null && app.draft.manual_plain_ct !== undefined) return Boolean(app.draft.manual_plain_ct);
|
||||
@@ -484,6 +540,17 @@ function setLabelSource(input, source) {
|
||||
label.classList.toggle("ai-selected", source === "ai");
|
||||
}
|
||||
|
||||
function clearAnnotationControls() {
|
||||
$("annotationNotes").value = "";
|
||||
document.querySelectorAll(".part-grid input, input[name=phase], input[name=chestWindow]").forEach((input) => {
|
||||
input.checked = false;
|
||||
input.disabled = true;
|
||||
setLabelSource(input, "");
|
||||
});
|
||||
$("phaseBox").classList.remove("visible");
|
||||
$("chestWindowBox").classList.remove("visible");
|
||||
}
|
||||
|
||||
function applyAnnotationControls() {
|
||||
if (!app.draft) return;
|
||||
const parts = new Set(effectiveParts());
|
||||
@@ -513,6 +580,14 @@ function applyAnnotationControls() {
|
||||
setLabelSource(input, app.draft.manual_upper_abdomen_phase === input.value ? "manual" : app.draft.ai_upper_abdomen_phase === input.value ? "ai" : "");
|
||||
});
|
||||
$("phaseBox").classList.toggle("visible", parts.has("upper_abdomen"));
|
||||
|
||||
const chestWindow = effectiveChestWindow();
|
||||
document.querySelectorAll("input[name=chestWindow]").forEach((input) => {
|
||||
input.checked = input.value === chestWindow;
|
||||
input.disabled = false;
|
||||
setLabelSource(input, app.draft.manual_chest_window === input.value ? "manual" : app.draft.ai_chest_window === input.value ? "ai" : "");
|
||||
});
|
||||
$("chestWindowBox").classList.toggle("visible", parts.has("chest"));
|
||||
}
|
||||
|
||||
function hydrateAnnotation() {
|
||||
@@ -525,6 +600,7 @@ function updateLocalAnnotation(annotation) {
|
||||
const normalized = cloneAnnotation(annotation);
|
||||
normalized.body_parts = asList(annotation.body_parts);
|
||||
normalized.upper_abdomen_phase = annotation.upper_abdomen_phase || "";
|
||||
normalized.chest_window = annotation.chest_window || "";
|
||||
app.draft = normalized;
|
||||
app.activeSeries.annotation = normalized;
|
||||
const index = app.series.findIndex((item) => item.series_uid === app.activeSeries.series_uid);
|
||||
@@ -545,6 +621,9 @@ function annotationPayload() {
|
||||
upper_abdomen_phase: effectivePhase(),
|
||||
manual_upper_abdomen_phase: app.draft.manual_upper_abdomen_phase,
|
||||
ai_upper_abdomen_phase: app.draft.ai_upper_abdomen_phase,
|
||||
chest_window: effectiveChestWindow(),
|
||||
manual_chest_window: app.draft.manual_chest_window,
|
||||
ai_chest_window: app.draft.ai_chest_window,
|
||||
plain_ct: effectivePlainCt(),
|
||||
manual_plain_ct: app.draft.manual_plain_ct,
|
||||
ai_plain_ct: app.draft.ai_plain_ct,
|
||||
@@ -610,6 +689,9 @@ function handlePartChange(event) {
|
||||
} else if (BODY_PARTS.includes(value)) {
|
||||
if (input.checked) {
|
||||
app.draft.manual_body_parts = uniq([...app.draft.manual_body_parts, value]);
|
||||
if (value === "chest" && !app.draft.manual_chest_window && !app.draft.ai_chest_window) {
|
||||
app.draft.manual_chest_window = "unknown";
|
||||
}
|
||||
} else {
|
||||
app.draft.manual_body_parts = app.draft.manual_body_parts.filter((part) => part !== value);
|
||||
app.draft.ai_body_parts = app.draft.ai_body_parts.filter((part) => part !== value);
|
||||
@@ -617,10 +699,15 @@ function handlePartChange(event) {
|
||||
app.draft.manual_upper_abdomen_phase = "";
|
||||
app.draft.ai_upper_abdomen_phase = "";
|
||||
}
|
||||
if (value === "chest") {
|
||||
app.draft.manual_chest_window = "";
|
||||
app.draft.ai_chest_window = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
app.draft.body_parts = effectiveParts();
|
||||
app.draft.upper_abdomen_phase = effectivePhase();
|
||||
app.draft.chest_window = effectiveChestWindow();
|
||||
app.draft.plain_ct = effectivePlainCt();
|
||||
applyAnnotationControls();
|
||||
markDirty();
|
||||
@@ -635,6 +722,15 @@ function handlePhaseChange(event) {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function handleChestWindowChange(event) {
|
||||
if (!app.draft) return;
|
||||
app.draft.manual_chest_window = event.target.value;
|
||||
if (app.draft.ai_chest_window !== event.target.value) app.draft.ai_chest_window = "";
|
||||
app.draft.chest_window = effectiveChestWindow();
|
||||
applyAnnotationControls();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
async function runAI() {
|
||||
if (!app.study || !app.activeSeries) return;
|
||||
const button = $("aiClassify");
|
||||
@@ -866,6 +962,7 @@ function wire() {
|
||||
$("resetView").addEventListener("click", resetViewState);
|
||||
document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", handlePartChange));
|
||||
document.querySelectorAll("input[name=phase]").forEach((input) => input.addEventListener("change", handlePhaseChange));
|
||||
document.querySelectorAll("input[name=chestWindow]").forEach((input) => input.addEventListener("change", handleChestWindowChange));
|
||||
$("annotationNotes").addEventListener("input", () => {
|
||||
if (!app.draft) return;
|
||||
app.draft.notes = $("annotationNotes").value;
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PACS DICOM Viewer</title>
|
||||
<title>DICOM 阅片分类系统</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="loginOverlay" class="login-overlay">
|
||||
<form id="loginForm" class="login-panel">
|
||||
<div class="brand-mark">PACS</div>
|
||||
<h1>DICOM 阅片标注</h1>
|
||||
<h1>DICOM 阅片分类系统</h1>
|
||||
<label>
|
||||
<span>账号</span>
|
||||
<input id="username" autocomplete="username" value="admin" />
|
||||
@@ -29,7 +29,7 @@
|
||||
<div class="product">
|
||||
<div class="logo-dot"></div>
|
||||
<div>
|
||||
<strong>PACS DICOM Viewer</strong>
|
||||
<strong>DICOM 阅片分类系统</strong>
|
||||
<span id="activeStudyLabel">未选择检查</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,14 +92,8 @@
|
||||
<div class="image-wrap">
|
||||
<img id="dicomImage" alt="" />
|
||||
<div id="imageOverlay" class="image-overlay">
|
||||
<div class="crosshair crosshair-v"></div>
|
||||
<div class="crosshair crosshair-h"></div>
|
||||
<div class="ov ov-left-top"></div>
|
||||
<div class="ov ov-right-top"></div>
|
||||
<div class="ov ov-left-mid"></div>
|
||||
<div class="ov ov-right-mid"></div>
|
||||
<div class="ov ov-top-mid"></div>
|
||||
<div class="ov ov-bottom-mid"></div>
|
||||
<div class="ov ov-left-bottom"></div>
|
||||
<div class="ov ov-right-bottom"></div>
|
||||
</div>
|
||||
@@ -123,7 +117,7 @@
|
||||
<span><i class="ai-dot"></i>AI</span>
|
||||
</div>
|
||||
<div class="part-grid">
|
||||
<label class="skip-option"><input type="checkbox" value="skip" />略过</label>
|
||||
<label class="skip-option"><input type="checkbox" value="skip" />略过/不采用</label>
|
||||
<label class="plain-option"><input type="checkbox" value="plain_ct" />平扫CT</label>
|
||||
<label><input type="checkbox" value="head_neck" />头颈部</label>
|
||||
<label><input type="checkbox" value="chest" />胸部</label>
|
||||
@@ -136,7 +130,16 @@
|
||||
<div class="phase-options">
|
||||
<label><input name="phase" type="radio" value="arterial" />动脉期</label>
|
||||
<label><input name="phase" type="radio" value="portal_venous" />门静脉期</label>
|
||||
<label><input name="phase" type="radio" value="unknown" />无法判别</label>
|
||||
<label><input name="phase" type="radio" value="delayed" />延迟期</label>
|
||||
<label class="warn-option"><input name="phase" type="radio" value="unknown" />无法判别</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chestWindowBox" class="phase-box chest-window-box">
|
||||
<span>胸部窗位</span>
|
||||
<div class="phase-options chest-window-options">
|
||||
<label><input name="chestWindow" type="radio" value="lung" />肺窗</label>
|
||||
<label><input name="chestWindow" type="radio" value="mediastinal" />纵隔窗</label>
|
||||
<label class="warn-option"><input name="chestWindow" type="radio" value="unknown" />无法判别</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation-actions">
|
||||
|
||||
@@ -471,6 +471,12 @@ button:disabled {
|
||||
background: rgba(240, 181, 78, 0.1);
|
||||
}
|
||||
|
||||
.tag-line .tag-warn {
|
||||
border-color: rgba(251, 113, 133, 0.72);
|
||||
color: #fecdd3;
|
||||
background: rgba(251, 113, 133, 0.1);
|
||||
}
|
||||
|
||||
.viewer-pane {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -556,27 +562,6 @@ button:disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.crosshair {
|
||||
position: absolute;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.crosshair-v {
|
||||
top: 4%;
|
||||
bottom: 4%;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: #ff3b30;
|
||||
}
|
||||
|
||||
.crosshair-h {
|
||||
left: 3%;
|
||||
right: 3%;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
background: #21c55d;
|
||||
}
|
||||
|
||||
.ov {
|
||||
position: absolute;
|
||||
max-width: 42%;
|
||||
@@ -594,30 +579,6 @@ button:disabled {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ov-left-mid {
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.ov-right-mid {
|
||||
right: 54px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.ov-top-mid {
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.ov-bottom-mid {
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.ov-left-bottom {
|
||||
left: 14px;
|
||||
bottom: 12px;
|
||||
@@ -747,6 +708,12 @@ button:disabled {
|
||||
box-shadow: inset 3px 0 0 rgba(240, 181, 78, 0.95);
|
||||
}
|
||||
|
||||
.phase-options label.warn-option:has(input:checked) {
|
||||
border-color: rgba(251, 113, 133, 0.78);
|
||||
color: #fecdd3;
|
||||
background: rgba(251, 113, 133, 0.12);
|
||||
}
|
||||
|
||||
.part-grid .skip-option:has(input:checked) {
|
||||
border-color: rgba(240, 181, 78, 0.72);
|
||||
background: rgba(240, 181, 78, 0.12);
|
||||
@@ -780,10 +747,14 @@ button:disabled {
|
||||
|
||||
.phase-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chest-window-options {
|
||||
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
||||
}
|
||||
|
||||
.annotation-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 118px minmax(0, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user