Improve study status and sequence loading

This commit is contained in:
Codex
2026-05-27 12:52:03 +08:00
parent ddfee82b0b
commit cfd2b379b5
5 changed files with 305 additions and 22 deletions

View File

@@ -36,8 +36,8 @@ uvicorn app:app --host 127.0.0.1 --port 8107
## 功能
- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`,支持按检查时间或未标注序列数升降序排序。
- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息,支持按时间或张数升降序排序。
- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`,支持按检查时间或未标注序列数升降序排序、按处理状态筛选,并在后台逐项刷新摘要
- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息,支持按时间或张数升降序排序;序列读取和缩略图未完成时显示加载状态
- 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。
- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。
- 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。

View File

@@ -793,12 +793,38 @@ def scan_study(ct_number: str) -> dict[str, Any]:
return cached
def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]:
series_list = data.get("series", [])
total = len(series_list)
annotated = sum(
1
for row in series_list
if row.get("annotation", {}).get("skipped")
or row.get("annotation", {}).get("plain_ct")
or row.get("annotation", {}).get("body_parts")
or row.get("annotation", {}).get("notes")
or row.get("annotation", {}).get("ai_model")
)
return {
"ct_number": data.get("study", {}).get("ct_number", ""),
"series_count": total,
"annotated_series": annotated,
"unannotated_series": max(0, total - annotated),
"summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
@app.get("/api/studies/{ct_number}/series")
def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
data = scan_study(ct_number)
return {"study": data["study"], "series": data["series"]}
@app.get("/api/studies/{ct_number}/summary")
def study_summary(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
return study_summary_payload(scan_study(ct_number))
def get_series_files(ct_number: str, series_uid: str) -> list[Path]:
data = scan_study(ct_number)
files = data["files"].get(series_uid)

View File

@@ -22,10 +22,13 @@ const app = {
drag: null,
studySortField: "time",
studySortDir: "desc",
studyFilter: "all",
summaryRun: 0,
seriesSortField: "time",
seriesSortDir: "asc",
showOverlay: true,
thumbUrls: new Map(),
imageCache: new Map(),
thumbObserver: null,
thumbSeries: new WeakMap(),
};
@@ -214,6 +217,27 @@ function sortStudies(list) {
});
}
function studyStatus(study) {
if (study.summary_loading) return "loading";
const total = Number(study.series_count || 0);
const unannotated = Number(study.unannotated_series || 0);
if (total > 0 && unannotated === 0) return "complete";
return "incomplete";
}
function studyStatusLabel(status) {
return { complete: "完成", incomplete: "待", loading: "同步" }[status] || "待";
}
function visibleStudies() {
return sortStudies(app.studies).filter((study) => {
const status = studyStatus(study);
if (app.studyFilter === "complete") return status === "complete";
if (app.studyFilter === "incomplete") return status !== "complete";
return true;
});
}
function updateSortButtons() {
document.querySelectorAll("[data-study-sort]").forEach((button) => {
const active = button.dataset.studySort === app.studySortField;
@@ -227,6 +251,9 @@ function updateSortButtons() {
const arrow = button.querySelector(".sort-arrow");
if (arrow) arrow.textContent = sortArrow(active ? app.seriesSortDir : "asc");
});
document.querySelectorAll("[data-study-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.studyFilter === app.studyFilter);
});
}
function setSeries(list) {
@@ -256,24 +283,31 @@ function refreshStudyAnnotationSummary() {
}
async function loadStudies() {
app.summaryRun += 1;
const q = encodeURIComponent($("studySearch").value.trim());
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
$("studyCount").textContent = String(app.studies.length);
renderStudies();
if (!app.study && app.studies.length) selectStudy(app.studies[0].ct_number);
if (!app.study && app.studies.length) await selectStudy(app.studies[0].ct_number);
refreshStudySummaries();
}
function renderStudies() {
const list = $("studyList");
app.studies = sortStudies(app.studies);
updateSortButtons();
const studies = visibleStudies();
$("studyCount").textContent = studies.length === app.studies.length ? String(studies.length) : `${studies.length}/${app.studies.length}`;
list.innerHTML = "";
for (const study of app.studies) {
for (const study of studies) {
const button = document.createElement("button");
const status = studyStatus(study);
button.className = "study-card";
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
button.classList.toggle("complete", status === "complete");
button.classList.toggle("loading", status === "loading");
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
button.innerHTML = `
<i class="study-status-badge status-${status}" title="${status === "complete" ? "已处理完毕" : status === "loading" ? "正在同步摘要" : "仍有序列未标注"}">${studyStatusLabel(status)}</i>
<strong>${escapeHtml(study.ct_number)}</strong>
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
<span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
@@ -284,6 +318,25 @@ function renderStudies() {
}
}
async function refreshStudySummaries() {
const run = app.summaryRun;
for (const study of [...app.studies]) {
if (run !== app.summaryRun) return;
study.summary_loading = true;
renderStudies();
try {
const summary = await json(`/api/studies/${encodeURIComponent(study.ct_number)}/summary`);
if (run !== app.summaryRun) return;
Object.assign(study, summary, { summary_loading: false, summary_loaded: true });
if (app.study?.ct_number === study.ct_number) Object.assign(app.study, summary);
} catch (_) {
study.summary_loading = false;
study.summary_error = true;
}
renderStudies();
}
}
async function selectStudy(ctNumber) {
if (app.study?.ct_number && app.study.ct_number !== ctNumber) await confirmSaveIfDirty();
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
@@ -292,7 +345,7 @@ async function selectStudy(ctNumber) {
app.draft = null;
$("activeStudyLabel").textContent = ctNumber;
$("seriesCount").textContent = "读取中";
$("seriesGrid").innerHTML = "";
renderSeriesLoading();
resetViewer();
renderStudies();
try {
@@ -312,6 +365,12 @@ function sourceTag(value, annotation) {
return "";
}
function mergeTagSource(...sources) {
if (sources.includes("manual")) return "manual";
if (sources.includes("ai")) return "ai";
return "";
}
function seriesTags(series) {
const annotation = series.annotation || {};
const tags = [];
@@ -321,22 +380,33 @@ function seriesTags(series) {
if (annotation.plain_ct) {
tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" });
}
for (const part of asList(annotation.body_parts)) {
tags.push({ label: partLabel(part), source: sourceTag(part, annotation) });
}
const phase = phaseLabel(annotation.upper_abdomen_phase);
if (phase) {
const phaseSource = annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "";
const chestWindow = chestWindowLabel(annotation.chest_window);
const chestWindowSource = annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : "";
for (const part of asList(annotation.body_parts)) {
const partSource = sourceTag(part, annotation);
if (part === "upper_abdomen" && phase && annotation.upper_abdomen_phase !== "unknown") {
tags.push({ label: `${partLabel(part)}-${phase}`, source: mergeTagSource(partSource, phaseSource) });
continue;
}
if (part === "chest" && chestWindow && annotation.chest_window !== "unknown") {
tags.push({ label: `${partLabel(part)}-${chestWindow}`, source: mergeTagSource(partSource, chestWindowSource) });
continue;
}
tags.push({ label: partLabel(part), source: partSource });
}
if (phase && annotation.upper_abdomen_phase === "unknown") {
tags.push({
label: phase,
source: annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "",
source: phaseSource,
warn: annotation.upper_abdomen_phase === "unknown",
});
}
const chestWindow = chestWindowLabel(annotation.chest_window);
if (chestWindow) {
if (chestWindow && annotation.chest_window === "unknown") {
tags.push({
label: chestWindow,
source: annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : "",
source: chestWindowSource,
warn: annotation.chest_window === "unknown",
});
}
@@ -358,6 +428,7 @@ function renderSeries() {
card.innerHTML = `
<div class="thumb">
<img alt="" />
<span class="thumb-state">加载中</span>
<b>${Number(series.count || 0)} 张</b>
${skipped ? "<i>略过/不采用</i>" : ""}
</div>
@@ -374,24 +445,71 @@ function renderSeries() {
}
}
function renderSeriesLoading() {
$("seriesGrid").innerHTML = Array.from({ length: 4 })
.map(
() => `
<div class="series-card loading-card">
<div class="thumb"><span class="thumb-state">序列读取中</span></div>
<div class="series-copy">
<strong>读取中</strong>
<span class="shot-time">正在同步序列信息</span>
<small>请稍候</small>
</div>
</div>
`,
)
.join("");
}
function imageUrlFor(series = app.activeSeries, index = app.slice, windowName = app.window, plane = app.plane) {
return `/api/image?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(series.series_uid)}&index=${index}&plane=${plane}&window=${windowName}&rotate=0`;
}
function cacheImageUrl(key, url) {
app.imageCache.set(key, url);
while (app.imageCache.size > 90) {
const oldestKey = app.imageCache.keys().next().value;
const oldestUrl = app.imageCache.get(oldestKey);
URL.revokeObjectURL(oldestUrl);
app.imageCache.delete(oldestKey);
}
}
function showImageMessage(message) {
const empty = $("imageEmpty");
empty.textContent = message;
empty.classList.remove("hidden");
}
function hideImageMessage() {
$("imageEmpty").classList.add("hidden");
}
async function loadThumb(series, img) {
const thumb = img.closest(".thumb");
const state = thumb?.querySelector(".thumb-state");
try {
const key = thumbKey(series);
if (app.thumbUrls.has(key)) {
img.src = app.thumbUrls.get(key);
thumb?.classList.add("thumb-ready");
return;
}
thumb?.classList.add("thumb-loading");
if (state) state.textContent = "加载中";
const index = Math.floor((Number(series.count) || 1) / 2);
const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob());
const url = URL.createObjectURL(blob);
app.thumbUrls.set(key, url);
img.src = url;
thumb?.classList.remove("thumb-loading");
thumb?.classList.add("thumb-ready");
} catch (_) {
img.removeAttribute("src");
thumb?.classList.remove("thumb-loading");
thumb?.classList.add("thumb-failed");
if (state) state.textContent = "未加载";
}
}
@@ -403,6 +521,7 @@ function queueThumb(series, img) {
const key = thumbKey(series);
if (app.thumbUrls.has(key)) {
img.src = app.thumbUrls.get(key);
img.closest(".thumb")?.classList.add("thumb-ready");
return;
}
if (!("IntersectionObserver" in window)) {
@@ -448,6 +567,9 @@ async function selectSeries(seriesUid) {
if (!series) return;
app.activeSeries = series;
app.slice = Math.floor(maxSlice() / 2);
app.pendingImage = null;
$("dicomImage").removeAttribute("src");
showImageMessage("图像加载中");
resetViewState();
$("sliceSlider").max = String(maxSlice());
$("sliceSlider").value = String(app.slice);
@@ -457,12 +579,11 @@ async function selectSeries(seriesUid) {
}
function resetViewer() {
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
app.imageUrl = "";
app.pendingImage = null;
app.activeSeries = null;
$("dicomImage").removeAttribute("src");
$("imageEmpty").classList.remove("hidden");
hideImageMessage();
$("sliceText").textContent = "0 / 0";
$("saveState").textContent = "已保存";
clearAnnotationControls();
@@ -515,22 +636,49 @@ async function updateImage() {
$("sliceSlider").max = String(max);
$("sliceSlider").value = String(app.slice);
$("sliceText").textContent = `${app.slice + 1} / ${max + 1}`;
$("imageEmpty").classList.add("hidden");
const url = imageUrlFor();
if (app.imageCache.has(url)) {
app.imageUrl = app.imageCache.get(url);
$("dicomImage").src = app.imageUrl;
hideImageMessage();
updateOverlay();
applyTransform();
preloadNeighborImages();
return;
}
showImageMessage("图像加载中");
updateOverlay();
const ticket = Symbol("image");
app.pendingImage = ticket;
try {
const blob = await request(imageUrlFor()).then((res) => res.blob());
const blob = await request(url).then((res) => res.blob());
if (app.pendingImage !== ticket) return;
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
app.imageUrl = URL.createObjectURL(blob);
cacheImageUrl(url, app.imageUrl);
$("dicomImage").src = app.imageUrl;
hideImageMessage();
applyTransform();
preloadNeighborImages();
} catch (err) {
showImageMessage("图像读取失败");
$("saveState").textContent = `图像读取失败:${err.message}`;
}
}
function preloadNeighborImages() {
if (!app.study || !app.activeSeries) return;
const max = maxSlice();
const indexes = [app.slice - 2, app.slice - 1, app.slice + 1, app.slice + 2].filter((index) => index >= 0 && index <= max);
for (const index of indexes) {
const url = imageUrlFor(app.activeSeries, index, app.window, app.plane);
if (app.imageCache.has(url)) continue;
request(url)
.then((res) => res.blob())
.then((blob) => cacheImageUrl(url, URL.createObjectURL(blob)))
.catch(() => {});
}
}
function cloneAnnotation(annotation = {}) {
return {
body_parts: asList(annotation.body_parts),
@@ -981,6 +1129,12 @@ function wire() {
document.querySelectorAll("[data-study-sort]").forEach((button) => {
button.addEventListener("click", () => setStudySort(button.dataset.studySort));
});
document.querySelectorAll("[data-study-filter]").forEach((button) => {
button.addEventListener("click", () => {
app.studyFilter = button.dataset.studyFilter;
renderStudies();
});
});
document.querySelectorAll("[data-series-sort]").forEach((button) => {
button.addEventListener("click", () => setSeriesSort(button.dataset.seriesSort));
});

View File

@@ -50,6 +50,11 @@
<span id="studyCount">0</span>
</div>
</div>
<div class="study-filter">
<button class="active" data-study-filter="all">全部</button>
<button data-study-filter="incomplete">待处理</button>
<button data-study-filter="complete">已完成</button>
</div>
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
<div id="studyList" class="study-list"></div>
</aside>
@@ -99,7 +104,7 @@
<div class="ov ov-left-bottom"></div>
<div class="ov ov-right-bottom"></div>
</div>
<div id="imageEmpty" class="image-empty"></div>
<div id="imageEmpty" class="image-empty hidden"></div>
<div class="slice-rail">
<input id="sliceSlider" type="range" min="0" max="0" value="0" orient="vertical" />
<span id="sliceText">0 / 0</span>

View File

@@ -258,10 +258,14 @@ button:disabled {
.series-pane {
padding: 14px;
display: grid;
grid-template-rows: auto auto 1fr;
grid-template-rows: auto auto auto 1fr;
gap: 12px;
}
.series-pane {
grid-template-rows: auto 1fr;
}
.pane-head,
.annotation-head,
.settings-title {
@@ -322,6 +326,27 @@ button:disabled {
color: var(--text);
}
.study-filter {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.study-filter button {
height: 30px;
border: 1px solid var(--stroke);
border-radius: 7px;
background: #0b0f16;
color: var(--muted);
font-size: 12px;
}
.study-filter button.active {
border-color: rgba(25, 212, 194, 0.62);
color: #baf8ee;
background: rgba(25, 212, 194, 0.08);
}
.study-list,
.series-grid {
min-height: 0;
@@ -330,8 +355,9 @@ button:disabled {
}
.study-card {
position: relative;
width: 100%;
padding: 12px;
padding: 12px 42px 12px 12px;
margin-bottom: 10px;
border: 1px solid transparent;
border-radius: 8px;
@@ -340,6 +366,42 @@ button:disabled {
text-align: left;
}
.study-status-badge {
position: absolute;
top: 10px;
right: 10px;
min-width: 24px;
height: 22px;
display: inline-grid;
place-items: center;
padding: 0 6px;
border: 1px solid rgba(146, 163, 186, 0.25);
border-radius: 999px;
color: var(--muted);
font-size: 10px;
font-style: normal;
font-weight: 800;
}
.study-status-badge.status-complete {
border-color: rgba(18, 185, 129, 0.62);
color: #9af4cf;
background: rgba(18, 185, 129, 0.09);
}
.study-status-badge.status-incomplete {
border-color: rgba(240, 181, 78, 0.56);
color: #ffe0a3;
background: rgba(240, 181, 78, 0.09);
}
.study-status-badge.status-loading {
min-width: 34px;
border-color: rgba(52, 116, 246, 0.56);
color: #bfd5ff;
background: rgba(52, 116, 246, 0.1);
}
.study-card.active {
border-color: rgba(52, 116, 246, 0.82);
background: linear-gradient(180deg, rgba(52, 116, 246, 0.22), #0b0f16);
@@ -410,6 +472,24 @@ button:disabled {
object-fit: contain;
}
.thumb-state {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
font-size: 12px;
background: rgba(2, 3, 6, 0.62);
}
.thumb-ready .thumb-state {
display: none;
}
.thumb-failed .thumb-state {
color: #fecdd3;
}
.thumb b,
.thumb i {
position: absolute;
@@ -440,6 +520,11 @@ button:disabled {
gap: 7px;
}
.loading-card {
pointer-events: none;
opacity: 0.78;
}
.series-copy strong {
overflow: hidden;
text-overflow: ellipsis;
@@ -555,6 +640,19 @@ button:disabled {
}
.image-empty {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-items: center;
color: var(--muted);
font-size: 13px;
letter-spacing: 0;
pointer-events: none;
background: rgba(0, 0, 0, 0.22);
}
.image-empty.hidden {
display: none;
}