Update PACS DICOM navigation and summaries
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user