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