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

@@ -10,6 +10,7 @@ from __future__ import annotations
import argparse
import csv
import errno
import json
import os
import re
@@ -42,6 +43,8 @@ DICOM_TAGS = [
"SOPInstanceUID",
]
CT_NUMBER_RE = re.compile(r"\bD?CT\d{6,}\b", re.IGNORECASE)
@dataclass
class StudyRow:
@@ -100,8 +103,22 @@ def parse_export_folder_name(name: str) -> tuple[str, str]:
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:
accession_number = accession_number.strip()
accession_number = normalize_ct_text(accession_number)
match = re.fullmatch(r"((?:D)?CT\d+)-\d+", accession_number)
if match:
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"])
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"])
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:
notes.append("normalized_accession_suffixes")
if not accession_numbers:
status = "error"
notes.append("missing_accession_number")
ct_number = source_ct_number
if fallback_ct_numbers:
notes.append("missing_accession_number_used_folder_or_dicom_fallback")
ct_number = fallback_ct_numbers[0]
else:
status = "error"
notes.append("missing_accession_number")
ct_number = source_ct_number
elif len(accession_numbers) > 1:
status = "error"
notes.append("multiple_accession_numbers")
@@ -251,19 +279,153 @@ def hardlink_tree(source_folder: Path, target_folder: Path) -> tuple[int, int]:
continue
except OSError:
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}")
target.unlink()
try:
os.link(source, target)
except OSError as exc:
if exc.errno != 18: # EXDEV
if exc.errno not in {errno.EXDEV, errno.EPERM, errno.EOPNOTSUPP}:
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
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:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
@@ -272,11 +434,12 @@ def main() -> int:
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")
parser.add_argument("--legacy-batch-layout", action="store_true", help="store processed studies under processed-root/batch-name")
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()
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.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__)
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(
result_dir / "rename_plan.csv",
[