Update PACS DICOM navigation and summaries

This commit is contained in:
Codex
2026-05-30 13:29:31 +08:00
parent c1fbdd8bac
commit 40e18ed45d
10 changed files with 307 additions and 57 deletions

View File

@@ -15,7 +15,7 @@ import urllib.error
import urllib.request
import zipfile
from collections import defaultdict
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
@@ -32,6 +32,15 @@ from starlette.background import BackgroundTask
APP_DIR = Path(__file__).resolve().parent
PACS_ROOT = APP_DIR.parent
STATIC_DIR = APP_DIR / "static"
LOCAL_TZ = timezone(timedelta(hours=8), "Asia/Shanghai")
def local_now() -> datetime:
return datetime.now(LOCAL_TZ)
def local_time_text() -> str:
return local_now().strftime("%Y-%m-%d %H:%M:%S")
def load_env_file() -> None:
@@ -68,16 +77,16 @@ WINDOWS = {
"contrast": (90.0, 140.0),
}
BODY_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"]
BODY_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "abdomen_pelvis"]
BODY_PARTS = set(BODY_PART_ORDER)
BODY_PART_ALIASES = {"lower_abdomen": "abdomen_pelvis", "pelvis": "abdomen_pelvis"}
PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""}
CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""}
BODY_PART_LABELS = {
"head_neck": "头颈部",
"chest": "胸部",
"upper_abdomen": "上腹部",
"lower_abdomen": "腹部",
"pelvis": "盆腔",
"abdomen_pelvis": "",
}
PHASE_LABELS = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门静脉期", "delayed": "延迟期", "unknown": "无法判别"}
CHEST_WINDOW_LABELS = {"lung": "肺窗", "mediastinal": "纵隔窗", "unknown": "无法判别"}
@@ -471,6 +480,11 @@ def settings_page() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/favicon.ico")
def favicon() -> Response:
return Response(status_code=204)
@app.post("/api/auth/login")
def login(data: LoginIn) -> dict[str, str]:
if authenticate_web_user(data.username, data.password):
@@ -498,7 +512,7 @@ def status() -> dict[str, Any]:
"database": {"ok": db_ok, "message": db_message, "host": PGHOST, "database": PGDATABASE, "table": PGTABLE, "rows": table_count},
"dicom": {"processed_root": str(PROCESSED_ROOT), "exists": PROCESSED_ROOT.exists()},
"ai": {"configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL},
"server_time": time.strftime("%Y-%m-%d %H:%M:%S"),
"server_time": local_time_text(),
}
@@ -646,6 +660,9 @@ def resolve_study_root(study: dict[str, Any]) -> Path:
target_folder = str(study.get("target_folder_name") or "")
if target_folder:
direct = PROCESSED_ROOT / target_folder
if direct.exists():
return direct
direct_matches = list(PROCESSED_ROOT.glob(f"*/{target_folder}"))
if direct_matches:
return direct_matches[0]
@@ -751,7 +768,7 @@ def build_series_export_zip(ct_number: str, series_uids: list[str]) -> tuple[str
remove_file(temp_path)
raise HTTPException(status_code=500, detail=f"导出失败:{exc}") from exc
filename = f"PACS_DICOM_{safe_archive_name(ct_number)}_{time.strftime('%Y%m%d_%H%M%S')}.zip"
filename = f"PACS_DICOM_{safe_archive_name(ct_number)}_{local_now().strftime('%Y%m%d_%H%M%S')}.zip"
return temp_path, filename
@@ -811,7 +828,12 @@ def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]:
def valid_parts(parts: Any) -> list[str]:
if not isinstance(parts, list):
return []
return [part for part in parts if part in BODY_PARTS]
normalized: list[str] = []
for value in parts:
part = BODY_PART_ALIASES.get(str(value), str(value))
if part in BODY_PARTS and part not in normalized:
normalized.append(part)
return normalized
def valid_phase(value: Any) -> str:
@@ -1162,7 +1184,7 @@ def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]:
"unannotated_series": max(0, total - annotated),
"undetermined_series": undetermined,
"body_parts": body_parts,
"summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"summary_updated_at": local_time_text(),
}
@@ -1197,12 +1219,17 @@ def save_study_summary(summary: dict[str, Any]) -> None:
pass
def all_ct_numbers() -> list[str]:
def all_ct_numbers(missing_summary_only: bool = False) -> list[str]:
where = ""
if missing_summary_only:
where = "WHERE s.ct_number IS NULL OR COALESCE(s.series_count, 0) = 0"
rows = pg_json_rows(
f"""
SELECT ct_number
FROM public.{PGTABLE}
ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number
SELECT p.ct_number
FROM public.{PGTABLE} p
LEFT JOIN public.pacs_dicom_study_summaries s USING (ct_number)
{where}
ORDER BY p.study_date DESC NULLS LAST, p.study_time DESC NULLS LAST, p.ct_number
""",
timeout=20,
)
@@ -1212,7 +1239,7 @@ def all_ct_numbers() -> list[str]:
def refresh_all_study_summaries(reason: str = "manual") -> None:
if not SUMMARY_REFRESH_LOCK.acquire(blocking=False):
return
SUMMARY_REFRESH_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "message": f"{reason} 刷新中"})
SUMMARY_REFRESH_STATE.update({"running": True, "started_at": local_time_text(), "finished_at": "", "message": f"{reason} 刷新中"})
try:
numbers = all_ct_numbers()
for index, ct_number in enumerate(numbers, start=1):
@@ -1223,16 +1250,16 @@ def refresh_all_study_summaries(reason: str = "manual") -> None:
SUMMARY_REFRESH_STATE["message"] = f"{reason} 刷新中:{index}/{len(numbers)}"
except Exception as exc: # noqa: BLE001
SUMMARY_REFRESH_STATE["message"] = f"{ct_number} 刷新失败:{exc}"
SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新完成:{len(numbers)} 个检查"})
SUMMARY_REFRESH_STATE.update({"finished_at": local_time_text(), "message": f"{reason} 刷新完成:{len(numbers)} 个检查"})
except Exception as exc: # noqa: BLE001
SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新失败:{exc}"})
SUMMARY_REFRESH_STATE.update({"finished_at": local_time_text(), "message": f"{reason} 刷新失败:{exc}"})
finally:
SUMMARY_REFRESH_STATE["running"] = False
SUMMARY_REFRESH_LOCK.release()
def next_four_am() -> datetime:
now = datetime.now()
now = local_now()
target = now.replace(hour=4, minute=0, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
@@ -1241,7 +1268,7 @@ def next_four_am() -> datetime:
def summary_scheduler_loop() -> None:
while True:
sleep_seconds = max(60.0, (next_four_am() - datetime.now()).total_seconds())
sleep_seconds = max(60.0, (next_four_am() - local_now()).total_seconds())
time.sleep(sleep_seconds)
refresh_all_study_summaries("凌晨4点定时")
@@ -1642,7 +1669,7 @@ def save_annotation_payload(
"skipped": skipped,
"ai_skipped": ai_skipped,
"notes": notes,
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"updated_at": local_now().strftime("%Y-%m-%dT%H:%M:%S"),
"ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""),
"updated_by": user,
}
@@ -1741,7 +1768,7 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
"text": (
"请根据这组CT序列的代表图像判断该序列所属部位。"
"可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), "
"lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。"
"abdomen_pelvis(腹盆部)。一个序列可包含多个部位。"
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断请 skipped=true。"
"如果包含上腹部,请判断期相: plain(平扫)、arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。"
"如果包含胸部,请判断窗位: lung(肺窗)、mediastinal(纵隔窗)、unknown(无法判别)。"
@@ -1824,13 +1851,18 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
@app.get("/api/settings")
def settings(_: str = Depends(require_admin)) -> dict[str, Any]:
summary_refresh = {
**SUMMARY_REFRESH_STATE,
"timezone": "Asia/Shanghai",
"next_scheduled_at": next_four_am().strftime("%Y-%m-%d %H:%M:%S"),
}
return {
"users": web_users(),
"roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()],
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE},
"ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "enabled": ai_enabled(), "url": KIMI_API_URL},
"dicom": {"processed_root": str(PROCESSED_ROOT)},
"summary_refresh": SUMMARY_REFRESH_STATE,
"summary_refresh": summary_refresh,
}