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

@@ -594,14 +594,20 @@ def annotation_labels_sql() -> str:
return f""" return f"""
SELECT SELECT
a.ct_number, a.ct_number,
COALESCE(jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_keys, COALESCE(jsonb_agg(DISTINCT
CASE
WHEN part.value IN ('lower_abdomen', 'pelvis') THEN 'abdomen_pelvis'
ELSE part.value
END
) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_keys,
COALESCE(jsonb_agg(DISTINCT COALESCE(jsonb_agg(DISTINCT
CASE part.value CASE part.value
WHEN 'head_neck' THEN '头颈部' WHEN 'head_neck' THEN '头颈部'
WHEN 'chest' THEN '胸部' WHEN 'chest' THEN '胸部'
WHEN 'upper_abdomen' THEN '上腹部' WHEN 'upper_abdomen' THEN '上腹部'
WHEN 'lower_abdomen' THEN '腹部' WHEN 'abdomen_pelvis' THEN ''
WHEN 'pelvis' THEN '盆腔' WHEN 'lower_abdomen' THEN '腹盆部'
WHEN 'pelvis' THEN '腹盆部'
ELSE NULL ELSE NULL
END END
) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_labels ) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_labels
@@ -688,7 +694,7 @@ def cases(
) )
if status_filter in {"registered", "unregistered"}: if status_filter in {"registered", "unregistered"}:
clauses.append(f"registration_status = {sql_literal(status_filter)}") clauses.append(f"registration_status = {sql_literal(status_filter)}")
if body_part in {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}: if body_part in {"head_neck", "chest", "upper_abdomen", "abdomen_pelvis"}:
clauses.append(f"body_part_keys ? {sql_literal(body_part)}") clauses.append(f"body_part_keys ? {sql_literal(body_part)}")
if algorithm_model.strip(): if algorithm_model.strip():
clauses.append(f"algorithm_model ILIKE {sql_literal('%' + algorithm_model.strip().replace('%', '').replace('_', '') + '%')}") clauses.append(f"algorithm_model ILIKE {sql_literal('%' + algorithm_model.strip().replace('%', '').replace('_', '') + '%')}")
@@ -808,6 +814,9 @@ def resolve_study_root(study: dict[str, Any]) -> Path:
return root return root
target_folder = str(study.get("target_folder_name") or "") target_folder = str(study.get("target_folder_name") or "")
if target_folder and PROCESSED_ROOT.exists(): if target_folder and PROCESSED_ROOT.exists():
direct = PROCESSED_ROOT / target_folder
if direct.exists():
return direct
found = next(PROCESSED_ROOT.rglob(target_folder), None) found = next(PROCESSED_ROOT.rglob(target_folder), None)
if found: if found:
return found return found
@@ -843,10 +852,8 @@ def annotation_labels(row: dict[str, Any]) -> list[str]:
elif part == "upper_abdomen": elif part == "upper_abdomen":
phase = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门脉期", "delayed": "延迟期", "unknown": "无法判别"}.get(row.get("upper_abdomen_phase") or "unknown", "无法判别") phase = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门脉期", "delayed": "延迟期", "unknown": "无法判别"}.get(row.get("upper_abdomen_phase") or "unknown", "无法判别")
labels.append(f"上腹部-{phase}") labels.append(f"上腹部-{phase}")
elif part == "lower_abdomen": elif part in {"abdomen_pelvis", "lower_abdomen", "pelvis"}:
labels.append("腹部") labels.append("")
elif part == "pelvis":
labels.append("盆腔")
return labels return labels

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import argparse import argparse
import csv import csv
import errno
import json import json
import os import os
import re import re
@@ -42,6 +43,8 @@ DICOM_TAGS = [
"SOPInstanceUID", "SOPInstanceUID",
] ]
CT_NUMBER_RE = re.compile(r"\bD?CT\d{6,}\b", re.IGNORECASE)
@dataclass @dataclass
class StudyRow: class StudyRow:
@@ -100,8 +103,22 @@ def parse_export_folder_name(name: str) -> tuple[str, str]:
return ct_number.strip(), patient_name.strip() return ct_number.strip(), patient_name.strip()
def normalize_ct_text(value: str) -> str:
return value.strip().upper()
def extract_ct_numbers(*values: object) -> list[str]:
candidates: list[str] = []
for value in values:
for match in CT_NUMBER_RE.findall(text(value)):
normalized = normalize_ct_text(match)
if normalized not in candidates:
candidates.append(normalized)
return candidates
def canonical_ct_number(accession_number: str) -> str: def canonical_ct_number(accession_number: str) -> str:
accession_number = accession_number.strip() accession_number = normalize_ct_text(accession_number)
match = re.fullmatch(r"((?:D)?CT\d+)-\d+", accession_number) match = re.fullmatch(r"((?:D)?CT\d+)-\d+", accession_number)
if match: if match:
return match.group(1) return match.group(1)
@@ -164,6 +181,13 @@ def scan_study_folder(batch_name: str, folder: Path, processed_batch_root: Path)
raw_accession_numbers = sorted(counters["AccessionNumber"]) raw_accession_numbers = sorted(counters["AccessionNumber"])
accession_numbers = sorted({canonical_ct_number(value) for value in raw_accession_numbers if value}) accession_numbers = sorted({canonical_ct_number(value) for value in raw_accession_numbers if value})
fallback_ct_numbers = extract_ct_numbers(
source_ct_number,
folder.name,
*counters["StudyID"].keys(),
*counters["AccessionNumber"].keys(),
*counters["PatientID"].keys(),
)
study_instance_uids = sorted(counters["StudyInstanceUID"]) study_instance_uids = sorted(counters["StudyInstanceUID"])
status = "ok" status = "ok"
@@ -174,6 +198,10 @@ def scan_study_folder(batch_name: str, folder: Path, processed_batch_root: Path)
if raw_accession_numbers and raw_accession_numbers != accession_numbers: if raw_accession_numbers and raw_accession_numbers != accession_numbers:
notes.append("normalized_accession_suffixes") notes.append("normalized_accession_suffixes")
if not accession_numbers: if not accession_numbers:
if fallback_ct_numbers:
notes.append("missing_accession_number_used_folder_or_dicom_fallback")
ct_number = fallback_ct_numbers[0]
else:
status = "error" status = "error"
notes.append("missing_accession_number") notes.append("missing_accession_number")
ct_number = source_ct_number ct_number = source_ct_number
@@ -251,19 +279,153 @@ def hardlink_tree(source_folder: Path, target_folder: Path) -> tuple[int, int]:
continue continue
except OSError: except OSError:
pass pass
if target.stat().st_size != source.stat().st_size: if target.stat().st_size == source.stat().st_size:
already_ok += 1
continue
else:
raise RuntimeError(f"target exists with different size: {target}") raise RuntimeError(f"target exists with different size: {target}")
target.unlink()
try: try:
os.link(source, target) os.link(source, target)
except OSError as exc: except OSError as exc:
if exc.errno != 18: # EXDEV if exc.errno not in {errno.EXDEV, errno.EPERM, errno.EOPNOTSUPP}:
raise
shutil.copyfile(source, target)
try:
shutil.copystat(source, target)
except OSError as stat_exc:
if stat_exc.errno not in {errno.EPERM, errno.EOPNOTSUPP}:
raise raise
shutil.copy2(source, target)
linked += 1 linked += 1
return linked, already_ok return linked, already_ok
def write_sync_sql(path: Path, manifest_path: Path) -> None:
sql = f"""BEGIN;
CREATE TABLE IF NOT EXISTS public.pacs_dicom_files (
ct_number text PRIMARY KEY,
batch_name text NOT NULL,
source_folder_name text,
source_ct_number text,
source_patient_name text,
target_folder_name text,
needs_ct_number_fix boolean NOT NULL DEFAULT false,
patient_name_dicom text,
patient_id text,
patient_birth_date text,
patient_sex text,
study_date text,
study_time text,
study_id text,
modality text,
body_part_examined text,
protocol_name text,
study_description text,
accession_numbers text,
raw_accession_numbers text,
study_instance_uids text,
series_count integer,
dicom_file_count integer,
total_file_count integer,
total_bytes bigint,
source_path text,
processed_path text,
status text,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TEMP TABLE pacs_dicom_files_stage (
batch_name text,
source_folder_name text,
source_ct_number text,
source_patient_name text,
ct_number text,
target_folder_name text,
needs_ct_number_fix text,
patient_name_dicom text,
patient_id text,
patient_birth_date text,
patient_sex text,
study_date text,
study_time text,
study_id text,
modality text,
body_part_examined text,
protocol_name text,
study_description text,
accession_numbers text,
raw_accession_numbers text,
study_instance_uids text,
series_count text,
dicom_file_count text,
total_file_count text,
total_bytes text,
source_path text,
processed_path text,
status text,
notes text
) ON COMMIT DROP;
\\copy pacs_dicom_files_stage FROM '{manifest_path}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
INSERT INTO public.pacs_dicom_files (
ct_number, batch_name, source_folder_name, source_ct_number, source_patient_name,
target_folder_name, needs_ct_number_fix, patient_name_dicom, patient_id,
patient_birth_date, patient_sex, study_date, study_time, study_id, modality,
body_part_examined, protocol_name, study_description, accession_numbers,
raw_accession_numbers, study_instance_uids, series_count, dicom_file_count,
total_file_count, total_bytes, source_path, processed_path, status, notes, updated_at
)
SELECT
ct_number, batch_name, source_folder_name, source_ct_number, source_patient_name,
target_folder_name, COALESCE(NULLIF(needs_ct_number_fix, '')::boolean, false),
patient_name_dicom, patient_id, patient_birth_date, patient_sex, study_date,
study_time, study_id, modality, body_part_examined, protocol_name,
study_description, accession_numbers, raw_accession_numbers, study_instance_uids,
NULLIF(series_count, '')::integer,
NULLIF(dicom_file_count, '')::integer,
NULLIF(total_file_count, '')::integer,
NULLIF(total_bytes, '')::bigint,
source_path, processed_path, status, notes, now()
FROM pacs_dicom_files_stage
ON CONFLICT (ct_number) DO UPDATE SET
batch_name = EXCLUDED.batch_name,
source_folder_name = EXCLUDED.source_folder_name,
source_ct_number = EXCLUDED.source_ct_number,
source_patient_name = EXCLUDED.source_patient_name,
target_folder_name = EXCLUDED.target_folder_name,
needs_ct_number_fix = EXCLUDED.needs_ct_number_fix,
patient_name_dicom = EXCLUDED.patient_name_dicom,
patient_id = EXCLUDED.patient_id,
patient_birth_date = EXCLUDED.patient_birth_date,
patient_sex = EXCLUDED.patient_sex,
study_date = EXCLUDED.study_date,
study_time = EXCLUDED.study_time,
study_id = EXCLUDED.study_id,
modality = EXCLUDED.modality,
body_part_examined = EXCLUDED.body_part_examined,
protocol_name = EXCLUDED.protocol_name,
study_description = EXCLUDED.study_description,
accession_numbers = EXCLUDED.accession_numbers,
raw_accession_numbers = EXCLUDED.raw_accession_numbers,
study_instance_uids = EXCLUDED.study_instance_uids,
series_count = EXCLUDED.series_count,
dicom_file_count = EXCLUDED.dicom_file_count,
total_file_count = EXCLUDED.total_file_count,
total_bytes = EXCLUDED.total_bytes,
source_path = EXCLUDED.source_path,
processed_path = EXCLUDED.processed_path,
status = EXCLUDED.status,
notes = EXCLUDED.notes,
updated_at = now();
COMMIT;
"""
path.write_text(sql, encoding="utf-8")
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True) parser.add_argument("--source", type=Path, required=True)
@@ -272,11 +434,12 @@ def main() -> int:
parser.add_argument("--batch-name", default="") parser.add_argument("--batch-name", default="")
parser.add_argument("--apply", action="store_true", help="create processed hardlink tree") parser.add_argument("--apply", action="store_true", help="create processed hardlink tree")
parser.add_argument("--write-file-manifest", action="store_true") parser.add_argument("--write-file-manifest", action="store_true")
parser.add_argument("--legacy-batch-layout", action="store_true", help="store processed studies under processed-root/batch-name")
args = parser.parse_args() args = parser.parse_args()
source_root = args.source.resolve() source_root = args.source.resolve()
batch_name = args.batch_name or source_root.name batch_name = args.batch_name or source_root.name
processed_batch_root = (args.processed_root / batch_name).resolve() processed_batch_root = (args.processed_root / batch_name if args.legacy_batch_layout else args.processed_root).absolute()
result_dir = (args.results_root / batch_name).resolve() result_dir = (args.results_root / batch_name).resolve()
result_dir.mkdir(parents=True, exist_ok=True) result_dir.mkdir(parents=True, exist_ok=True)
@@ -311,6 +474,7 @@ def main() -> int:
study_fieldnames = list(asdict(study_rows[0]).keys()) if study_rows else list(StudyRow.__dataclass_fields__) study_fieldnames = list(asdict(study_rows[0]).keys()) if study_rows else list(StudyRow.__dataclass_fields__)
write_csv(result_dir / "study_manifest.csv", study_dicts, study_fieldnames) write_csv(result_dir / "study_manifest.csv", study_dicts, study_fieldnames)
write_sync_sql(result_dir / "sync_pacs_dicom_files.sql", (result_dir / "study_manifest.csv").resolve())
write_csv( write_csv(
result_dir / "rename_plan.csv", result_dir / "rename_plan.csv",
[ [

View File

@@ -43,7 +43,7 @@ uvicorn app:app --host 127.0.0.1 --port 8107
- 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。 - 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。 - DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。 - 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。
- 序列标注:选择略过/不采用、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过/不采用可与部位共存DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。 - 序列标注:选择略过/不采用、头颈部、胸部、上腹部、腹盆部;略过/不采用可与部位共存DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。
- AI 识别:配置并启用 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI自动给出部位、期相和备注建议。 - AI 识别:配置并启用 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI自动给出部位、期相和备注建议。
- 设置管理员独立页面支持用户创建、角色权限展示、数据库状态、AI 开关、检查摘要立刻更新。 - 设置管理员独立页面支持用户创建、角色权限展示、数据库状态、AI 开关、检查摘要立刻更新。
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。 - 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。

View File

@@ -15,7 +15,7 @@ import urllib.error
import urllib.request import urllib.request
import zipfile import zipfile
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -32,6 +32,15 @@ from starlette.background import BackgroundTask
APP_DIR = Path(__file__).resolve().parent APP_DIR = Path(__file__).resolve().parent
PACS_ROOT = APP_DIR.parent PACS_ROOT = APP_DIR.parent
STATIC_DIR = APP_DIR / "static" 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: def load_env_file() -> None:
@@ -68,16 +77,16 @@ WINDOWS = {
"contrast": (90.0, 140.0), "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_PARTS = set(BODY_PART_ORDER)
BODY_PART_ALIASES = {"lower_abdomen": "abdomen_pelvis", "pelvis": "abdomen_pelvis"}
PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""} PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""}
CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""} CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""}
BODY_PART_LABELS = { BODY_PART_LABELS = {
"head_neck": "头颈部", "head_neck": "头颈部",
"chest": "胸部", "chest": "胸部",
"upper_abdomen": "上腹部", "upper_abdomen": "上腹部",
"lower_abdomen": "腹部", "abdomen_pelvis": "",
"pelvis": "盆腔",
} }
PHASE_LABELS = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门静脉期", "delayed": "延迟期", "unknown": "无法判别"} PHASE_LABELS = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门静脉期", "delayed": "延迟期", "unknown": "无法判别"}
CHEST_WINDOW_LABELS = {"lung": "肺窗", "mediastinal": "纵隔窗", "unknown": "无法判别"} CHEST_WINDOW_LABELS = {"lung": "肺窗", "mediastinal": "纵隔窗", "unknown": "无法判别"}
@@ -471,6 +480,11 @@ def settings_page() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html") return FileResponse(STATIC_DIR / "index.html")
@app.get("/favicon.ico")
def favicon() -> Response:
return Response(status_code=204)
@app.post("/api/auth/login") @app.post("/api/auth/login")
def login(data: LoginIn) -> dict[str, str]: def login(data: LoginIn) -> dict[str, str]:
if authenticate_web_user(data.username, data.password): 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}, "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()}, "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}, "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 "") target_folder = str(study.get("target_folder_name") or "")
if target_folder: if target_folder:
direct = PROCESSED_ROOT / target_folder
if direct.exists():
return direct
direct_matches = list(PROCESSED_ROOT.glob(f"*/{target_folder}")) direct_matches = list(PROCESSED_ROOT.glob(f"*/{target_folder}"))
if direct_matches: if direct_matches:
return direct_matches[0] 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) remove_file(temp_path)
raise HTTPException(status_code=500, detail=f"导出失败:{exc}") from exc 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 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]: def valid_parts(parts: Any) -> list[str]:
if not isinstance(parts, list): if not isinstance(parts, list):
return [] 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: 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), "unannotated_series": max(0, total - annotated),
"undetermined_series": undetermined, "undetermined_series": undetermined,
"body_parts": body_parts, "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 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( rows = pg_json_rows(
f""" f"""
SELECT ct_number SELECT p.ct_number
FROM public.{PGTABLE} FROM public.{PGTABLE} p
ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number 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, timeout=20,
) )
@@ -1212,7 +1239,7 @@ def all_ct_numbers() -> list[str]:
def refresh_all_study_summaries(reason: str = "manual") -> None: def refresh_all_study_summaries(reason: str = "manual") -> None:
if not SUMMARY_REFRESH_LOCK.acquire(blocking=False): if not SUMMARY_REFRESH_LOCK.acquire(blocking=False):
return 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: try:
numbers = all_ct_numbers() numbers = all_ct_numbers()
for index, ct_number in enumerate(numbers, start=1): 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)}" SUMMARY_REFRESH_STATE["message"] = f"{reason} 刷新中:{index}/{len(numbers)}"
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
SUMMARY_REFRESH_STATE["message"] = f"{ct_number} 刷新失败:{exc}" 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 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: finally:
SUMMARY_REFRESH_STATE["running"] = False SUMMARY_REFRESH_STATE["running"] = False
SUMMARY_REFRESH_LOCK.release() SUMMARY_REFRESH_LOCK.release()
def next_four_am() -> datetime: def next_four_am() -> datetime:
now = datetime.now() now = local_now()
target = now.replace(hour=4, minute=0, second=0, microsecond=0) target = now.replace(hour=4, minute=0, second=0, microsecond=0)
if target <= now: if target <= now:
target += timedelta(days=1) target += timedelta(days=1)
@@ -1241,7 +1268,7 @@ def next_four_am() -> datetime:
def summary_scheduler_loop() -> None: def summary_scheduler_loop() -> None:
while True: 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) time.sleep(sleep_seconds)
refresh_all_study_summaries("凌晨4点定时") refresh_all_study_summaries("凌晨4点定时")
@@ -1642,7 +1669,7 @@ def save_annotation_payload(
"skipped": skipped, "skipped": skipped,
"ai_skipped": ai_skipped, "ai_skipped": ai_skipped,
"notes": notes, "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", ""), "ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""),
"updated_by": user, "updated_by": user,
} }
@@ -1741,7 +1768,7 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen
"text": ( "text": (
"请根据这组CT序列的代表图像判断该序列所属部位。" "请根据这组CT序列的代表图像判断该序列所属部位。"
"可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), " "可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), "
"lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。" "abdomen_pelvis(腹盆部)。一个序列可包含多个部位。"
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断请 skipped=true。" "如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断请 skipped=true。"
"如果包含上腹部,请判断期相: plain(平扫)、arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。" "如果包含上腹部,请判断期相: plain(平扫)、arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。"
"如果包含胸部,请判断窗位: lung(肺窗)、mediastinal(纵隔窗)、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") @app.get("/api/settings")
def settings(_: str = Depends(require_admin)) -> dict[str, Any]: 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 { return {
"users": web_users(), "users": web_users(),
"roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()], "roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()],
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE}, "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}, "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)}, "dicom": {"processed_root": str(PROCESSED_ROOT)},
"summary_refresh": SUMMARY_REFRESH_STATE, "summary_refresh": summary_refresh,
} }

View File

@@ -41,7 +41,8 @@ const app = {
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"]; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "abdomen_pelvis"];
const WINDOW_PRESETS = { const WINDOW_PRESETS = {
bone: { wl: 500, ww: 1800 }, bone: { wl: 500, ww: 1800 },
soft: { wl: 50, ww: 360 }, soft: { wl: 50, ww: 360 },
@@ -139,8 +140,7 @@ function partLabel(value) {
head_neck: "头颈部", head_neck: "头颈部",
chest: "胸部", chest: "胸部",
upper_abdomen: "上腹部", upper_abdomen: "上腹部",
lower_abdomen: "腹部", abdomen_pelvis: "腹部",
pelvis: "盆腔",
}[value] || value; }[value] || value;
} }
@@ -443,6 +443,23 @@ function initialCtNumber() {
return new URLSearchParams(location.search).get("ct_number") || ""; return new URLSearchParams(location.search).get("ct_number") || "";
} }
function siblingSystemUrl(port, params = {}) {
const url = new URL(window.location.href);
url.port = String(port);
url.pathname = "/";
url.search = "";
Object.entries(params).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
});
return url.toString();
}
function openSiblingSystem(kind) {
const ctNumber = app.study?.ct_number || initialCtNumber();
const port = kind === "registration" ? 8109 : 8108;
window.open(siblingSystemUrl(port, { ct_number: ctNumber }), "_blank");
}
async function loadStudies(preferredCtNumber = "") { async function loadStudies(preferredCtNumber = "") {
const q = encodeURIComponent($("studySearch").value.trim()); const q = encodeURIComponent($("studySearch").value.trim());
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`)); app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
@@ -1323,7 +1340,8 @@ async function renderSettingsPage() {
<div> <div>
<div class="settings-title"><h3>检查摘要</h3><span>${refresh.running ? "刷新中" : "待命"}</span></div> <div class="settings-title"><h3>检查摘要</h3><span>${refresh.running ? "刷新中" : "待命"}</span></div>
<dl> <dl>
<dt>计划刷新</dt><dd>每天 04:00</dd> <dt>计划刷新</dt><dd>每天 04:00(北京时间,全量)</dd>
<dt>下次刷新</dt><dd>${escapeHtml(refresh.next_scheduled_at || "-")}</dd>
<dt>最近开始</dt><dd>${escapeHtml(refresh.started_at || "-")}</dd> <dt>最近开始</dt><dd>${escapeHtml(refresh.started_at || "-")}</dd>
<dt>最近完成</dt><dd>${escapeHtml(refresh.finished_at || "-")}</dd> <dt>最近完成</dt><dd>${escapeHtml(refresh.finished_at || "-")}</dd>
<dt>状态</dt><dd>${escapeHtml(refresh.message || "-")}</dd> <dt>状态</dt><dd>${escapeHtml(refresh.message || "-")}</dd>
@@ -1382,6 +1400,15 @@ async function refreshSummariesNow() {
button.disabled = true; button.disabled = true;
button.textContent = "已开始刷新"; button.textContent = "已开始刷新";
await json("/api/settings/refresh-summaries", { method: "POST", body: JSON.stringify({}) }); await json("/api/settings/refresh-summaries", { method: "POST", body: JSON.stringify({}) });
let refresh = {};
for (let i = 0; i < 300; i += 1) {
await sleep(2000);
const settings = await json("/api/settings");
refresh = settings.summary_refresh || {};
button.textContent = refresh.running ? refresh.message || "刷新中" : "刷新完成";
if (!refresh.running) break;
}
await loadStudies(app.study?.ct_number || initialCtNumber());
await renderSettingsPage(); await renderSettingsPage();
} }
@@ -1455,6 +1482,8 @@ function wire() {
$("loginForm").addEventListener("submit", login); $("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout); $("logoutBtn").addEventListener("click", logout);
$("settingsBtn").addEventListener("click", openSettings); $("settingsBtn").addEventListener("click", openSettings);
$("registrationLinkBtn").addEventListener("click", () => openSiblingSystem("registration"));
$("relationLinkBtn").addEventListener("click", () => openSiblingSystem("relation"));
$("backToViewer").addEventListener("click", () => showViewerPage(true)); $("backToViewer").addEventListener("click", () => showViewerPage(true));
$("infoBtn").addEventListener("click", openInfo); $("infoBtn").addEventListener("click", openInfo);
$("aiClassify").addEventListener("click", runAI); $("aiClassify").addEventListener("click", runAI);

View File

@@ -34,6 +34,8 @@
</div> </div>
</div> </div>
<div class="top-actions"> <div class="top-actions">
<button id="registrationLinkBtn" class="ghost-btn nav-btn" type="button">进入配准系统</button>
<button id="relationLinkBtn" class="ghost-btn nav-btn accent" type="button">数据库关联可视化</button>
<div id="dbStatus" class="status-pill offline">数据库</div> <div id="dbStatus" class="status-pill offline">数据库</div>
<button id="settingsBtn" class="ghost-btn">设置</button> <button id="settingsBtn" class="ghost-btn">设置</button>
<button id="logoutBtn" class="dark-btn">退出</button> <button id="logoutBtn" class="dark-btn">退出</button>
@@ -60,8 +62,7 @@
<button data-study-part-filter="head_neck">头颈部</button> <button data-study-part-filter="head_neck">头颈部</button>
<button data-study-part-filter="chest">胸部</button> <button data-study-part-filter="chest">胸部</button>
<button data-study-part-filter="upper_abdomen">上腹部</button> <button data-study-part-filter="upper_abdomen">上腹部</button>
<button data-study-part-filter="lower_abdomen">腹部</button> <button data-study-part-filter="abdomen_pelvis"></button>
<button data-study-part-filter="pelvis">盆腔</button>
</div> </div>
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" /> <input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
<div id="studyList" class="study-list"></div> <div id="studyList" class="study-list"></div>
@@ -136,8 +137,7 @@
<label><input type="checkbox" value="head_neck" />头颈部</label> <label><input type="checkbox" value="head_neck" />头颈部</label>
<label><input type="checkbox" value="chest" />胸部</label> <label><input type="checkbox" value="chest" />胸部</label>
<label><input type="checkbox" value="upper_abdomen" />上腹部</label> <label><input type="checkbox" value="upper_abdomen" />上腹部</label>
<label><input type="checkbox" value="lower_abdomen" />腹部</label> <label><input type="checkbox" value="abdomen_pelvis" /></label>
<label><input type="checkbox" value="pelvis" />盆腔</label>
</div> </div>
<div id="phaseBox" class="phase-box"> <div id="phaseBox" class="phase-box">
<span>上腹部期相</span> <span>上腹部期相</span>

View File

@@ -242,6 +242,22 @@ button:disabled {
background: #070a10; background: #070a10;
} }
.nav-btn {
min-width: 118px;
color: #d8e8ff;
}
.nav-btn.accent {
border-color: rgba(25, 212, 194, 0.42);
color: #c7fff7;
background: rgba(25, 212, 194, 0.08);
}
.nav-btn.accent:hover {
border-color: rgba(25, 212, 194, 0.72);
background: rgba(25, 212, 194, 0.14);
}
.workspace { .workspace {
height: calc(100vh - 66px); height: calc(100vh - 66px);
display: grid; display: grid;

View File

@@ -351,16 +351,18 @@ def relation_cte() -> str:
WHEN 'delayed' THEN '延迟期' WHEN 'delayed' THEN '延迟期'
ELSE '无法判别' ELSE '无法判别'
END END
WHEN 'lower_abdomen' THEN '腹部' WHEN 'abdomen_pelvis' THEN ''
WHEN 'pelvis' THEN '盆腔' WHEN 'lower_abdomen' THEN '腹盆部'
WHEN 'pelvis' THEN '腹盆部'
ELSE NULL ELSE NULL
END AS label, END AS label,
CASE part.value CASE part.value
WHEN 'head_neck' THEN 1 WHEN 'head_neck' THEN 1
WHEN 'chest' THEN 2 WHEN 'chest' THEN 2
WHEN 'upper_abdomen' THEN 3 WHEN 'upper_abdomen' THEN 3
WHEN 'abdomen_pelvis' THEN 4
WHEN 'lower_abdomen' THEN 4 WHEN 'lower_abdomen' THEN 4
WHEN 'pelvis' THEN 5 WHEN 'pelvis' THEN 4
ELSE 99 ELSE 99
END AS sort_key END AS sort_key
FROM public.{PACS_ANNOTATION_TABLE_SQL} a FROM public.{PACS_ANNOTATION_TABLE_SQL} a
@@ -587,8 +589,7 @@ def relation_cte() -> str:
CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END, CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END,
CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END, CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END,
CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '上腹部' END, CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '上腹部' END,
CASE WHEN ps.body_parts ? 'lower_abdomen' THEN '腹部' END, CASE WHEN ps.body_parts ? 'abdomen_pelvis' OR ps.body_parts ? 'lower_abdomen' OR ps.body_parts ? 'pelvis' THEN '' END
CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END
], NULL)) AS dicom_body_parts, ], NULL)) AS dicom_body_parts,
u.patient_name AS upp_patient_name, u.patient_name AS upp_patient_name,
COALESCE(u.patient_names, '[]'::jsonb) AS upp_patient_names, COALESCE(u.patient_names, '[]'::jsonb) AS upp_patient_names,
@@ -683,9 +684,12 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_part: str) -
if algorithm_model: if algorithm_model:
clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}") clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}")
if dicom_part: if dicom_part:
valid_parts = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"} valid_parts = {"head_neck", "chest", "upper_abdomen", "abdomen_pelvis"}
if dicom_part not in valid_parts: if dicom_part not in valid_parts:
raise HTTPException(status_code=400, detail="invalid DICOM part filter") raise HTTPException(status_code=400, detail="invalid DICOM part filter")
if dicom_part == "abdomen_pelvis":
clauses.append("(body_parts ? 'abdomen_pelvis' OR body_parts ? 'lower_abdomen' OR body_parts ? 'pelvis')")
else:
clauses.append(f"body_parts ? {sql_literal(dicom_part)}") clauses.append(f"body_parts ? {sql_literal(dicom_part)}")
return "WHERE " + " AND ".join(clauses) if clauses else "" return "WHERE " + " AND ".join(clauses) if clauses else ""

View File

@@ -130,8 +130,7 @@ function partLabel(value) {
head_neck: "头颈部", head_neck: "头颈部",
chest: "胸部", chest: "胸部",
upper_abdomen: "上腹部", upper_abdomen: "上腹部",
lower_abdomen: "腹部", abdomen_pelvis: "腹部",
pelvis: "盆腔",
}[value] || value; }[value] || value;
} }

View File

@@ -47,8 +47,7 @@
<option value="head_neck">头颈部</option> <option value="head_neck">头颈部</option>
<option value="chest">胸部</option> <option value="chest">胸部</option>
<option value="upper_abdomen">上腹部</option> <option value="upper_abdomen">上腹部</option>
<option value="lower_abdomen">腹部</option> <option value="abdomen_pelvis"></option>
<option value="pelvis">盆腔</option>
</select> </select>
</div> </div>
<div class="filter-grid"> <div class="filter-grid">