Update PACS DICOM navigation and summaries
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +198,13 @@ 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:
|
||||||
status = "error"
|
if fallback_ct_numbers:
|
||||||
notes.append("missing_accession_number")
|
notes.append("missing_accession_number_used_folder_or_dicom_fallback")
|
||||||
ct_number = source_ct_number
|
ct_number = fallback_ct_numbers[0]
|
||||||
|
else:
|
||||||
|
status = "error"
|
||||||
|
notes.append("missing_accession_number")
|
||||||
|
ct_number = source_ct_number
|
||||||
elif len(accession_numbers) > 1:
|
elif len(accession_numbers) > 1:
|
||||||
status = "error"
|
status = "error"
|
||||||
notes.append("multiple_accession_numbers")
|
notes.append("multiple_accession_numbers")
|
||||||
@@ -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
|
raise
|
||||||
shutil.copy2(source, target)
|
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
|
||||||
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",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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 标注分别记录。
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,10 +684,13 @@ 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")
|
||||||
clauses.append(f"body_parts ? {sql_literal(dicom_part)}")
|
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)}")
|
||||||
return "WHERE " + " AND ".join(clauses) if clauses else ""
|
return "WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user