diff --git a/PACS_DICOM处理/数据处理网页端/README.md b/PACS_DICOM处理/数据处理网页端/README.md index cd0cf3f..d00c7a4 100644 --- a/PACS_DICOM处理/数据处理网页端/README.md +++ b/PACS_DICOM处理/数据处理网页端/README.md @@ -43,7 +43,7 @@ uvicorn app:app --host 127.0.0.1 --port 8107 - 图像叠层:可显示/隐藏患者、检查、方向、张数、窗宽窗位和缩放信息。 - DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。 - 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过序列固定在最下方,并在略过分组内继续按当前排序规则排列。 -- 序列标注:选择略过、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;点击后自动保存;上腹部需继续选择动脉期、门静脉期或无法判别。 +- 序列标注:选择略过、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期或无法判别。 - AI 识别:配置 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。 - 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。 - 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。 diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index 215155d..45abf8f 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -483,8 +483,6 @@ def bool_or_none(value: Any) -> bool | None: def merge_parts(manual_parts: list[str], ai_parts: list[str], skipped: bool) -> list[str]: - if skipped: - return [] merged: list[str] = [] for part in manual_parts + ai_parts: if part in BODY_PARTS and part not in merged: @@ -493,14 +491,12 @@ def merge_parts(manual_parts: list[str], ai_parts: list[str], skipped: bool) -> def effective_phase(manual_phase: str, ai_phase: str, body_parts: list[str], skipped: bool) -> str: - if skipped or "upper_abdomen" not in body_parts: + if "upper_abdomen" not in body_parts: return "" return valid_phase(manual_phase) or valid_phase(ai_phase) def effective_plain_ct(manual_plain_ct: bool | None, ai_plain_ct: bool | None, fallback: bool, skipped: bool) -> bool: - if skipped: - return False if manual_plain_ct is not None: return manual_plain_ct if ai_plain_ct is not None: @@ -529,22 +525,35 @@ def normalize_annotation(row: dict[str, Any] | None, default_skipped: bool = Fal ) return { "body_parts": body_parts, - "manual_body_parts": [] if skipped else manual_parts, - "ai_body_parts": [] if skipped else ai_parts, + "manual_body_parts": manual_parts, + "ai_body_parts": ai_parts, "upper_abdomen_phase": effective_phase(manual_phase, ai_phase, body_parts, skipped), - "manual_upper_abdomen_phase": "" if skipped else manual_phase, - "ai_upper_abdomen_phase": "" if skipped else ai_phase, + "manual_upper_abdomen_phase": manual_phase, + "ai_upper_abdomen_phase": ai_phase, "plain_ct": plain_ct, - "manual_plain_ct": None if skipped else bool_or_none(row.get("manual_plain_ct")), - "ai_plain_ct": None if skipped else bool_or_none(row.get("ai_plain_ct")), + "manual_plain_ct": bool_or_none(row.get("manual_plain_ct")), + "ai_plain_ct": bool_or_none(row.get("ai_plain_ct")), "skipped": skipped, "ai_skipped": ai_skipped, "notes": row.get("notes", ""), "updated_at": row.get("updated_at", ""), "ai_model": row.get("ai_model", ""), + "updated_by": row.get("updated_by", ""), } +def dicom_auto_annotation(meta: dict[str, str]) -> tuple[list[str], str]: + marker = f"{meta.get('BodyPartExamined', '')} {meta.get('SeriesDescription', '')}".upper() + parts: list[str] = [] + phase = "" + if "CHEST" in marker and "chest" not in parts: + parts.append("chest") + if "ABDOMEN" in marker and "upper_abdomen" not in parts: + parts.append("upper_abdomen") + phase = "unknown" + return parts, phase + + def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]: try: ensure_annotation_table() @@ -553,7 +562,7 @@ def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]: SELECT series_instance_uid, body_parts, manual_body_parts, ai_body_parts, upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase, - plain_ct, manual_plain_ct, ai_plain_ct, skipped, ai_skipped, notes, updated_at, ai_model + plain_ct, manual_plain_ct, ai_plain_ct, skipped, ai_skipped, notes, updated_at, ai_model, updated_by FROM public.pacs_dicom_series_annotations WHERE ct_number = {sql_literal(ct_number)} """ @@ -586,6 +595,7 @@ def scan_study(ct_number: str) -> dict[str, Any]: series_list = [] file_map = {} default_skip_rows = [] + auto_annotation_rows = [] for uid, items in grouped.items(): items.sort(key=sort_key) first = items[0][1] @@ -593,13 +603,18 @@ def scan_study(ct_number: str) -> dict[str, Any]: file_map[uid] = [path for path, _ in items] raw_annotation = annotations.get(uid) annotation = normalize_annotation(raw_annotation) - default_skipped = len(items) < 80 and not annotation.get("body_parts") and not annotation.get("plain_ct") + auto_parts, auto_phase = dicom_auto_annotation(first) + can_apply_auto = bool(auto_parts) and not annotation.get("body_parts") + if can_apply_auto: + annotation["manual_body_parts"] = auto_parts + annotation["body_parts"] = auto_parts + if auto_phase: + annotation["manual_upper_abdomen_phase"] = auto_phase + annotation["upper_abdomen_phase"] = auto_phase + auto_annotation_rows.append((uid, first, auto_parts, auto_phase)) + default_skipped = len(items) < 80 and (not raw_annotation or annotation.get("updated_by") == "system") if default_skipped: annotation["skipped"] = True - annotation["manual_body_parts"] = [] - annotation["ai_body_parts"] = [] - annotation["body_parts"] = [] - annotation["plain_ct"] = False if default_skipped: default_skip_rows.append((uid, first, len(items))) series_list.append( @@ -636,6 +651,42 @@ def scan_study(ct_number: str) -> dict[str, Any]: } ) + for uid, first, auto_parts, auto_phase in auto_annotation_rows: + try: + pg_scalar( + f""" + INSERT INTO public.pacs_dicom_series_annotations ( + ct_number, study_instance_uid, series_instance_uid, series_description, + body_parts, manual_body_parts, upper_abdomen_phase, manual_upper_abdomen_phase, + updated_by, updated_at + ) + VALUES ( + {sql_literal(ct_number)}, + {sql_literal(first.get('StudyInstanceUID', ''))}, + {sql_literal(uid)}, + {sql_literal(first.get('SeriesDescription', '') or '未命名序列')}, + {sql_literal(json.dumps(auto_parts, ensure_ascii=False))}::jsonb, + {sql_literal(json.dumps(auto_parts, ensure_ascii=False))}::jsonb, + {sql_literal(auto_phase)}, + {sql_literal(auto_phase)}, + 'system', + now() + ) + ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET + body_parts = EXCLUDED.body_parts, + manual_body_parts = EXCLUDED.manual_body_parts, + upper_abdomen_phase = EXCLUDED.upper_abdomen_phase, + manual_upper_abdomen_phase = EXCLUDED.manual_upper_abdomen_phase, + updated_by = 'system', + updated_at = now() + WHERE pacs_dicom_series_annotations.updated_by = 'system' + AND jsonb_array_length(pacs_dicom_series_annotations.body_parts) = 0 + """, + timeout=8, + ) + except Exception: + pass + for uid, first, count in default_skip_rows: try: pg_scalar( @@ -662,13 +713,6 @@ def scan_study(ct_number: str) -> dict[str, Any]: ) ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET skipped = true, - body_parts = '[]'::jsonb, - manual_body_parts = '[]'::jsonb, - ai_body_parts = '[]'::jsonb, - upper_abdomen_phase = '', - manual_upper_abdomen_phase = '', - ai_upper_abdomen_phase = '', - plain_ct = false, notes = CASE WHEN pacs_dicom_series_annotations.notes = '' THEN EXCLUDED.notes @@ -676,8 +720,7 @@ def scan_study(ct_number: str) -> dict[str, Any]: END, updated_by = 'system', updated_at = now() - WHERE jsonb_array_length(pacs_dicom_series_annotations.body_parts) = 0 - AND pacs_dicom_series_annotations.plain_ct IS NOT TRUE + WHERE pacs_dicom_series_annotations.updated_by = 'system' """, timeout=8, ) @@ -991,14 +1034,14 @@ def save_annotation_payload( {sql_literal(series_uid)}, {sql_literal(series_row.get('description', ''))}, {sql_literal(json.dumps(body_parts, ensure_ascii=False))}::jsonb, - {sql_literal(json.dumps([] if skipped else manual_parts, ensure_ascii=False))}::jsonb, - {sql_literal(json.dumps([] if skipped else ai_parts, ensure_ascii=False))}::jsonb, + {sql_literal(json.dumps(manual_parts, ensure_ascii=False))}::jsonb, + {sql_literal(json.dumps(ai_parts, ensure_ascii=False))}::jsonb, {sql_literal(phase)}, - {sql_literal('' if skipped else manual_phase)}, - {sql_literal('' if skipped else ai_phase)}, + {sql_literal(manual_phase)}, + {sql_literal(ai_phase)}, {'true' if plain_ct else 'false'}, - {sql_bool_or_null(None if skipped else manual_plain_ct)}, - {sql_bool_or_null(None if skipped else ai_plain_ct)}, + {sql_bool_or_null(manual_plain_ct)}, + {sql_bool_or_null(ai_plain_ct)}, {'true' if skipped else 'false'}, {'true' if ai_skipped else 'false'}, {sql_literal(notes)}, @@ -1031,14 +1074,14 @@ def save_annotation_payload( return normalize_annotation( { "body_parts": body_parts, - "manual_body_parts": [] if skipped else manual_parts, - "ai_body_parts": [] if skipped else ai_parts, + "manual_body_parts": manual_parts, + "ai_body_parts": ai_parts, "upper_abdomen_phase": phase, - "manual_upper_abdomen_phase": "" if skipped else manual_phase, - "ai_upper_abdomen_phase": "" if skipped else ai_phase, + "manual_upper_abdomen_phase": manual_phase, + "ai_upper_abdomen_phase": ai_phase, "plain_ct": plain_ct, - "manual_plain_ct": None if skipped else manual_plain_ct, - "ai_plain_ct": None if skipped else ai_plain_ct, + "manual_plain_ct": manual_plain_ct, + "ai_plain_ct": ai_plain_ct, "skipped": skipped, "ai_skipped": ai_skipped, "notes": notes, diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index 784d17e..0c079fc 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -16,6 +16,8 @@ const app = { searchTimer: null, statusTimer: null, saveTimer: null, + dirty: false, + saving: false, pendingImage: null, drag: null, seriesSort: "timeAsc", @@ -147,7 +149,8 @@ async function login(event) { } } -function logout() { +async function logout() { + await confirmSaveIfDirty(); app.token = ""; localStorage.removeItem("pacs_web_token"); showLogin(true); @@ -216,6 +219,7 @@ function 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 }; app.series = []; app.activeSeries = null; @@ -243,10 +247,10 @@ function sourceTag(value, annotation) { function seriesTags(series) { const annotation = series.annotation || {}; - if (annotation.skipped) { - return [{ label: "略过", source: annotation.ai_skipped ? "ai" : "manual" }]; - } const tags = []; + if (annotation.skipped) { + 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" }); } @@ -319,7 +323,8 @@ function resetViewState() { applyTransform(); } -function selectSeries(seriesUid) { +async function selectSeries(seriesUid) { + if (app.activeSeries?.series_uid && app.activeSeries.series_uid !== seriesUid) await confirmSaveIfDirty(); const series = app.series.find((item) => item.series_uid === seriesUid); if (!series) return; app.activeSeries = series; @@ -340,7 +345,9 @@ function resetViewer() { $("dicomImage").removeAttribute("src"); $("imageEmpty").classList.remove("hidden"); $("sliceText").textContent = "0 / 0"; - $("saveState").textContent = "自动保存"; + $("saveState").textContent = "已保存"; + app.dirty = false; + app.saving = false; resetViewState(); updateOverlay(); } @@ -434,7 +441,7 @@ function cloneAnnotation(annotation = {}) { } function effectiveParts() { - if (!app.draft || app.draft.skipped) return []; + if (!app.draft) return []; return uniq([...app.draft.manual_body_parts, ...app.draft.ai_body_parts]); } @@ -444,7 +451,7 @@ function effectivePhase() { } function effectivePlainCt() { - if (!app.draft || app.draft.skipped) return false; + 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); if (app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined) return Boolean(app.draft.ai_plain_ct); return Boolean(app.draft.plain_ct); @@ -469,22 +476,22 @@ function applyAnnotationControls() { } if (value === "plain_ct") { input.checked = effectivePlainCt(); - input.disabled = app.draft.skipped; + input.disabled = false; setLabelSource(input, app.draft.manual_plain_ct !== null && app.draft.manual_plain_ct !== undefined ? "manual" : app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined ? "ai" : ""); return; } input.checked = parts.has(value); - input.disabled = app.draft.skipped; + input.disabled = false; setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : ""); }); const phase = effectivePhase(); document.querySelectorAll("input[name=phase]").forEach((input) => { input.checked = input.value === phase; - input.disabled = app.draft.skipped; + input.disabled = false; setLabelSource(input, app.draft.manual_upper_abdomen_phase === input.value ? "manual" : app.draft.ai_upper_abdomen_phase === input.value ? "ai" : ""); }); - $("phaseBox").classList.toggle("visible", !app.draft.skipped && parts.has("upper_abdomen")); + $("phaseBox").classList.toggle("visible", parts.has("upper_abdomen")); } function hydrateAnnotation() { @@ -507,17 +514,17 @@ function updateLocalAnnotation(annotation) { } function annotationPayload() { - const bodyParts = app.draft.skipped ? [] : effectiveParts(); + const bodyParts = effectiveParts(); return { body_parts: bodyParts, - manual_body_parts: app.draft.skipped ? [] : app.draft.manual_body_parts, - ai_body_parts: app.draft.skipped ? [] : app.draft.ai_body_parts, - upper_abdomen_phase: app.draft.skipped ? "" : effectivePhase(), - manual_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.manual_upper_abdomen_phase, - ai_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.ai_upper_abdomen_phase, + manual_body_parts: app.draft.manual_body_parts, + ai_body_parts: app.draft.ai_body_parts, + upper_abdomen_phase: effectivePhase(), + manual_upper_abdomen_phase: app.draft.manual_upper_abdomen_phase, + ai_upper_abdomen_phase: app.draft.ai_upper_abdomen_phase, plain_ct: effectivePlainCt(), - manual_plain_ct: app.draft.skipped ? null : app.draft.manual_plain_ct, - ai_plain_ct: app.draft.skipped ? null : app.draft.ai_plain_ct, + manual_plain_ct: app.draft.manual_plain_ct, + ai_plain_ct: app.draft.ai_plain_ct, skipped: app.draft.skipped, ai_skipped: app.draft.ai_skipped, notes: $("annotationNotes").value, @@ -526,6 +533,8 @@ function annotationPayload() { async function saveAnnotationNow() { if (!app.study || !app.activeSeries || !app.draft) return; + if (app.saving) return; + app.saving = true; $("saveState").textContent = "保存中"; const uid = app.activeSeries.series_uid; try { @@ -534,16 +543,30 @@ async function saveAnnotationNow() { body: JSON.stringify(annotationPayload()), }); $("saveState").textContent = "已保存"; + app.dirty = false; updateLocalAnnotation(data); } catch (err) { $("saveState").textContent = err.message; + } finally { + app.saving = false; } } -function queueSave(delay = 450) { +function markDirty() { clearTimeout(app.saveTimer); - $("saveState").textContent = "待保存"; - app.saveTimer = setTimeout(saveAnnotationNow, delay); + app.dirty = true; + $("saveState").textContent = "未保存"; +} + +async function confirmSaveIfDirty() { + if (!app.dirty) return; + const shouldSave = window.confirm("当前标注尚未保存,是否先保存?"); + if (shouldSave) { + await saveAnnotationNow(); + } else { + app.dirty = false; + $("saveState").textContent = "未保存"; + } } function handlePartChange(event) { @@ -553,14 +576,6 @@ function handlePartChange(event) { if (value === "skip") { app.draft.skipped = input.checked; if (!input.checked) app.draft.ai_skipped = false; - if (input.checked) { - app.draft.manual_body_parts = []; - app.draft.ai_body_parts = []; - app.draft.manual_upper_abdomen_phase = ""; - app.draft.ai_upper_abdomen_phase = ""; - app.draft.manual_plain_ct = null; - app.draft.ai_plain_ct = null; - } } else if (value === "plain_ct") { if (input.checked) { app.draft.manual_plain_ct = true; @@ -585,7 +600,7 @@ function handlePartChange(event) { app.draft.upper_abdomen_phase = effectivePhase(); app.draft.plain_ct = effectivePlainCt(); applyAnnotationControls(); - queueSave(); + markDirty(); } function handlePhaseChange(event) { @@ -594,7 +609,7 @@ function handlePhaseChange(event) { if (app.draft.ai_upper_abdomen_phase !== event.target.value) app.draft.ai_upper_abdomen_phase = ""; app.draft.upper_abdomen_phase = effectivePhase(); applyAnnotationControls(); - queueSave(); + markDirty(); } async function runAI() { @@ -610,6 +625,7 @@ async function runAI() { updateLocalAnnotation(data); $("annotationNotes").value = data.notes || ""; $("saveState").textContent = "AI 结果已保存"; + app.dirty = false; } catch (err) { $("saveState").textContent = err.message; } finally { @@ -618,6 +634,7 @@ async function runAI() { } async function openInfo() { + await confirmSaveIfDirty(); if (!app.study || !app.activeSeries) return; const data = await json(`/api/dicom-info?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(app.activeSeries.series_uid)}&index=${app.slice}`); const content = $("infoContent"); @@ -660,6 +677,7 @@ function roleCards(roles) { } async function openSettings() { + await confirmSaveIfDirty(); const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]); $("settingsContent").innerHTML = `
@@ -828,7 +846,13 @@ function wire() { $("annotationNotes").addEventListener("input", () => { if (!app.draft) return; app.draft.notes = $("annotationNotes").value; - queueSave(900); + markDirty(); + }); + $("saveAnnotation").addEventListener("click", saveAnnotationNow); + window.addEventListener("beforeunload", (event) => { + if (!app.dirty) return; + event.preventDefault(); + event.returnValue = ""; }); document.querySelectorAll("[data-close]").forEach((button) => { button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden")); diff --git a/PACS_DICOM处理/数据处理网页端/static/index.html b/PACS_DICOM处理/数据处理网页端/static/index.html index fadcee3..893f043 100644 --- a/PACS_DICOM处理/数据处理网页端/static/index.html +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -114,7 +114,7 @@

部位标注

- 自动保存 + 已保存
@@ -140,6 +140,7 @@
+
diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css index 3d02d4c..dbfa691 100644 --- a/PACS_DICOM处理/数据处理网页端/static/styles.css +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -785,6 +785,9 @@ button:disabled { } .annotation-actions { + display: grid; + grid-template-columns: 118px minmax(0, 1fr); + gap: 10px; margin-top: 10px; } @@ -792,6 +795,10 @@ button:disabled { height: 38px; } +.save-btn { + height: 38px; +} + .modal { position: fixed; inset: 0;