Add PACS DICOM preprocessing workflow

This commit is contained in:
Codex
2026-05-27 00:54:14 +08:00
parent 7eebab455d
commit f3e3cfff1d
3 changed files with 429 additions and 0 deletions

7
.gitignore vendored
View File

@@ -21,3 +21,10 @@ UPP列表处理/数据处理工作区/06_PostgreSQL建表结构.sql
UPP_STL处理/ UPP_STL处理/
UPP_数据库构建/UPP_STL资产同步报告.json UPP_数据库构建/UPP_STL资产同步报告.json
UPP_数据库构建/UPP_STL文件family顺序明细.csv UPP_数据库构建/UPP_STL文件family顺序明细.csv
# PACS DICOM 实数据、软链接数据目录和批次处理结果不提交
PACS_DICOM处理/待处理_DICOM数据
PACS_DICOM处理/待处理_DICOM数据/
PACS_DICOM处理/已处理_DICOM数据
PACS_DICOM处理/已处理_DICOM数据/
PACS_DICOM处理/数据处理结果区/

View File

@@ -0,0 +1,19 @@
# PACS DICOM 数据处理
本目录用于 PACS DICOM 批次数据的本地预处理和归档脚本管理。
## 目录约定
- `待处理_DICOM数据/`:本地待处理 DICOM 数据目录,通常为数据盘软链接,不提交到 Git。
- `已处理_DICOM数据/`:本地已处理 DICOM 数据目录,通常为数据盘软链接,不提交到 Git。
- `数据处理工作区/`:可提交的处理脚本、模板和说明。
- `数据处理结果区/`批次处理产生的清单、报告、SQL 输出等结果数据,不提交到 Git。
- `数据处理网页端/`:预留给后续网页端工具。
## 当前预处理逻辑
`数据处理工作区/preprocess_pacs_dicom_batch.py` 会读取 DICOM 元数据中的 `AccessionNumber` 作为真实 `ct_number`,用于修正 PACS 导出顶层目录名中的检查号。
脚本会生成检查级清单、文件级清单和改名计划。实际落地已处理数据时,优先使用硬链接,避免在同一数据盘上重复占用整份 DICOM 空间。
数据目录和处理结果包含 DICOM 或患者相关信息,已在 `.gitignore` 中排除。

View File

@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""Preprocess a PACS DICOM batch by normalizing study folders.
The top-level PACS export folder is assumed to contain one folder per study.
The exported folder name may contain the wrong CT number, so the canonical
ct_number is read from DICOM AccessionNumber (0008,0050).
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import shutil
import sys
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
import pydicom
DICOM_TAGS = [
"AccessionNumber",
"PatientName",
"PatientID",
"PatientBirthDate",
"PatientSex",
"StudyInstanceUID",
"StudyDate",
"StudyTime",
"StudyID",
"Modality",
"BodyPartExamined",
"ProtocolName",
"StudyDescription",
"SeriesInstanceUID",
"SOPInstanceUID",
]
@dataclass
class StudyRow:
batch_name: str
source_folder_name: str
source_ct_number: str
source_patient_name: str
ct_number: str
target_folder_name: str
needs_ct_number_fix: bool
patient_name_dicom: str
patient_id: str
patient_birth_date: str
patient_sex: str
study_date: str
study_time: str
study_id: str
modality: str
body_part_examined: str
protocol_name: str
study_description: str
accession_numbers: str
raw_accession_numbers: str
study_instance_uids: str
series_count: int
dicom_file_count: int
total_file_count: int
total_bytes: int
source_path: str
processed_path: str
status: str
notes: str
def text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def most_common(counter: Counter[str]) -> str:
if not counter:
return ""
return counter.most_common(1)[0][0]
def sanitize_component(name: str) -> str:
cleaned = name.replace("/", "_").replace("\x00", "_").strip()
return cleaned or "UNKNOWN"
def parse_export_folder_name(name: str) -> tuple[str, str]:
if "-" not in name:
return name, ""
ct_number, patient_name = name.split("-", 1)
return ct_number.strip(), patient_name.strip()
def canonical_ct_number(accession_number: str) -> str:
accession_number = accession_number.strip()
match = re.fullmatch(r"((?:D)?CT\d+)-\d+", accession_number)
if match:
return match.group(1)
return accession_number
def iter_files(folder: Path) -> Iterable[Path]:
for path in folder.rglob("*"):
if path.is_file():
yield path
def read_meta(path: Path) -> dict[str, str]:
ds = pydicom.dcmread(
str(path),
stop_before_pixels=True,
force=True,
specific_tags=DICOM_TAGS + ["SpecificCharacterSet"],
)
return {tag: text(getattr(ds, tag, "")) for tag in DICOM_TAGS}
def scan_study_folder(batch_name: str, folder: Path, processed_batch_root: Path) -> tuple[StudyRow, list[dict[str, str]]]:
source_ct_number, source_patient_name = parse_export_folder_name(folder.name)
files = sorted(iter_files(folder))
counters: dict[str, Counter[str]] = defaultdict(Counter)
file_rows: list[dict[str, str]] = []
dicom_file_count = 0
total_bytes = 0
errors = []
for path in files:
try:
size = path.stat().st_size
total_bytes += size
meta = read_meta(path)
dicom_file_count += 1
except Exception as exc: # noqa: BLE001 - keep scanning and report the file.
errors.append(f"{path}: {exc}")
continue
for key in DICOM_TAGS:
value = meta.get(key, "")
if value:
counters[key][value] += 1
file_rows.append(
{
"ct_number": "",
"source_folder_name": folder.name,
"source_relative_path": str(path.relative_to(folder)),
"processed_relative_path": "",
"sop_instance_uid": meta.get("SOPInstanceUID", ""),
"series_instance_uid": meta.get("SeriesInstanceUID", ""),
"study_instance_uid": meta.get("StudyInstanceUID", ""),
"bytes": str(size),
}
)
raw_accession_numbers = sorted(counters["AccessionNumber"])
accession_numbers = sorted({canonical_ct_number(value) for value in raw_accession_numbers if value})
study_instance_uids = sorted(counters["StudyInstanceUID"])
status = "ok"
notes: list[str] = []
if errors:
status = "error"
notes.append(f"metadata_read_errors={len(errors)}")
if raw_accession_numbers and raw_accession_numbers != accession_numbers:
notes.append("normalized_accession_suffixes")
if not accession_numbers:
status = "error"
notes.append("missing_accession_number")
ct_number = source_ct_number
elif len(accession_numbers) > 1:
status = "error"
notes.append("multiple_accession_numbers")
ct_number = most_common(counters["AccessionNumber"])
else:
ct_number = accession_numbers[0]
target_folder_name = sanitize_component(ct_number)
if source_patient_name:
target_folder_name = f"{target_folder_name}-{sanitize_component(source_patient_name)}"
processed_path = processed_batch_root / target_folder_name
needs_ct_number_fix = source_ct_number != ct_number
for row in file_rows:
row["ct_number"] = ct_number
row["processed_relative_path"] = str(Path(target_folder_name) / row["source_relative_path"])
row = StudyRow(
batch_name=batch_name,
source_folder_name=folder.name,
source_ct_number=source_ct_number,
source_patient_name=source_patient_name,
ct_number=ct_number,
target_folder_name=target_folder_name,
needs_ct_number_fix=needs_ct_number_fix,
patient_name_dicom=most_common(counters["PatientName"]),
patient_id=most_common(counters["PatientID"]),
patient_birth_date=most_common(counters["PatientBirthDate"]),
patient_sex=most_common(counters["PatientSex"]),
study_date=most_common(counters["StudyDate"]),
study_time=most_common(counters["StudyTime"]),
study_id=most_common(counters["StudyID"]),
modality=most_common(counters["Modality"]),
body_part_examined=most_common(counters["BodyPartExamined"]),
protocol_name=most_common(counters["ProtocolName"]),
study_description=most_common(counters["StudyDescription"]),
accession_numbers="|".join(accession_numbers),
raw_accession_numbers="|".join(raw_accession_numbers),
study_instance_uids="|".join(study_instance_uids),
series_count=len(counters["SeriesInstanceUID"]),
dicom_file_count=dicom_file_count,
total_file_count=len(files),
total_bytes=total_bytes,
source_path=str(folder),
processed_path=str(processed_path),
status=status,
notes=";".join(notes),
)
return row, file_rows
def write_csv(path: Path, rows: list[dict[str, object]], fieldnames: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def hardlink_tree(source_folder: Path, target_folder: Path) -> tuple[int, int]:
linked = 0
already_ok = 0
for source in iter_files(source_folder):
rel = source.relative_to(source_folder)
target = target_folder / rel
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
try:
if os.path.samefile(source, target):
already_ok += 1
continue
except OSError:
pass
if target.stat().st_size != source.stat().st_size:
raise RuntimeError(f"target exists with different size: {target}")
target.unlink()
try:
os.link(source, target)
except OSError as exc:
if exc.errno != 18: # EXDEV
raise
shutil.copy2(source, target)
linked += 1
return linked, already_ok
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--processed-root", type=Path, required=True)
parser.add_argument("--results-root", type=Path, required=True)
parser.add_argument("--batch-name", default="")
parser.add_argument("--apply", action="store_true", help="create processed hardlink tree")
parser.add_argument("--write-file-manifest", action="store_true")
args = parser.parse_args()
source_root = args.source.resolve()
batch_name = args.batch_name or source_root.name
processed_batch_root = (args.processed_root / batch_name).resolve()
result_dir = (args.results_root / batch_name).resolve()
result_dir.mkdir(parents=True, exist_ok=True)
started_at = datetime.now().isoformat(timespec="seconds")
study_folders = sorted([p for p in source_root.iterdir() if p.is_dir()])
study_rows: list[StudyRow] = []
file_rows_all: list[dict[str, str]] = []
for index, folder in enumerate(study_folders, start=1):
print(f"[{index}/{len(study_folders)}] scan {folder.name}", flush=True)
study_row, file_rows = scan_study_folder(batch_name, folder, processed_batch_root)
study_rows.append(study_row)
if args.write_file_manifest:
file_rows_all.extend(file_rows)
ct_counts = Counter(row.ct_number for row in study_rows if row.ct_number)
target_counts = Counter(row.target_folder_name for row in study_rows if row.target_folder_name)
duplicate_ct_numbers = sorted([ct for ct, count in ct_counts.items() if count > 1])
duplicate_target_folders = sorted([name for name, count in target_counts.items() if count > 1])
for row in study_rows:
notes = [row.notes] if row.notes else []
if row.ct_number in duplicate_ct_numbers:
row.status = "error"
notes.append("duplicate_ct_number")
if row.target_folder_name in duplicate_target_folders:
row.status = "error"
notes.append("duplicate_target_folder")
row.notes = ";".join([note for note in notes if note])
study_dicts = [asdict(row) for row in study_rows]
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 / "rename_plan.csv",
[
{
"source_folder_name": row.source_folder_name,
"source_ct_number": row.source_ct_number,
"ct_number": row.ct_number,
"target_folder_name": row.target_folder_name,
"needs_ct_number_fix": row.needs_ct_number_fix,
"status": row.status,
"notes": row.notes,
}
for row in study_rows
],
[
"source_folder_name",
"source_ct_number",
"ct_number",
"target_folder_name",
"needs_ct_number_fix",
"status",
"notes",
],
)
if args.write_file_manifest:
write_csv(
result_dir / "file_manifest.csv",
file_rows_all,
[
"ct_number",
"source_folder_name",
"source_relative_path",
"processed_relative_path",
"sop_instance_uid",
"series_instance_uid",
"study_instance_uid",
"bytes",
],
)
ok_rows = [row for row in study_rows if row.status == "ok"]
error_rows = [row for row in study_rows if row.status != "ok"]
summary = {
"batch_name": batch_name,
"source_root": str(source_root),
"processed_batch_root": str(processed_batch_root),
"result_dir": str(result_dir),
"started_at": started_at,
"finished_scan_at": datetime.now().isoformat(timespec="seconds"),
"apply": args.apply,
"source_study_folder_count": len(study_folders),
"ok_study_count": len(ok_rows),
"error_study_count": len(error_rows),
"needs_ct_number_fix_count": sum(1 for row in study_rows if row.needs_ct_number_fix),
"duplicate_ct_numbers": duplicate_ct_numbers,
"duplicate_target_folders": duplicate_target_folders,
"dicom_file_count": sum(row.dicom_file_count for row in study_rows),
"total_file_count": sum(row.total_file_count for row in study_rows),
"total_bytes": sum(row.total_bytes for row in study_rows),
"linked_file_count": 0,
"already_linked_file_count": 0,
}
if error_rows:
summary_path = result_dir / "summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"ERROR: found {len(error_rows)} invalid study rows; see {summary_path}", file=sys.stderr)
return 2
if args.apply:
processed_batch_root.mkdir(parents=True, exist_ok=True)
linked_total = 0
already_total = 0
for index, row in enumerate(ok_rows, start=1):
print(f"[{index}/{len(ok_rows)}] link {row.source_folder_name} -> {row.target_folder_name}", flush=True)
linked, already_ok = hardlink_tree(Path(row.source_path), Path(row.processed_path))
linked_total += linked
already_total += already_ok
summary["linked_file_count"] = linked_total
summary["already_linked_file_count"] = already_total
summary["finished_apply_at"] = datetime.now().isoformat(timespec="seconds")
summary_path = result_dir / "summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())