673 lines
26 KiB
Python
Executable File
673 lines
26 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""整理UPP STL目录,并把CT号唯一资产索引同步到PostgreSQL。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
import unicodedata
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
BASE_DIR = Path(__file__).resolve().parents[1]
|
||
DEFAULT_STL_ROOT = BASE_DIR / "UPP_STL处理" / "待处理STL数据"
|
||
DEFAULT_PROCESSED_ROOT = BASE_DIR / "UPP_STL处理" / "已处理STL数据"
|
||
DEFAULT_LIST_JSON = BASE_DIR / "UPP列表处理" / "数据处理结果区" / "全量分片结果" / "合并_图片表格_结构化.json"
|
||
DEFAULT_SCHEMA = BASE_DIR / "UPP_数据库构建" / "01_UPP资产索引建表.sql"
|
||
DEFAULT_REPORT = BASE_DIR / "UPP_数据库构建" / "UPP_STL资产同步报告.json"
|
||
CT_PATTERN = re.compile(r"(D?CT\d{8,})", re.IGNORECASE)
|
||
VALID_CT_PATTERN = re.compile(r"^D?CT\d{8,}$")
|
||
|
||
|
||
ASSET_FIELDS = [
|
||
"ct_number",
|
||
"list_present",
|
||
"stl_present",
|
||
"patient_name",
|
||
"patient_sex",
|
||
"patient_age",
|
||
"patient_id_masked",
|
||
"exam_date",
|
||
"task_created_at",
|
||
"exam_description",
|
||
"exam_device",
|
||
"algorithm_model",
|
||
"upp_status",
|
||
"list_record_count",
|
||
"selected_list_record",
|
||
"list_records",
|
||
"selected_source_case_dir",
|
||
"selected_source_stl_dir",
|
||
"processed_stl_dir",
|
||
"stl_case_name",
|
||
"stl_sequence_no",
|
||
"stl_file_count",
|
||
"stl_total_bytes",
|
||
"stl_files",
|
||
"stl_candidates",
|
||
]
|
||
|
||
STL_FIELDS = [
|
||
"ct_number",
|
||
"file_count",
|
||
"total_bytes",
|
||
"segment_names",
|
||
"segment_families",
|
||
"segment_categories",
|
||
"file_names",
|
||
"source_file_paths",
|
||
"processed_file_paths",
|
||
"files",
|
||
]
|
||
|
||
MODEL_VALUES = {"肝胆模型", "泌尿模型", "胸外模型"}
|
||
DEVICE_VALUES = {"CT", "MR", "DR", "CR", "DX", "US", "XA", "NM", "PT"}
|
||
|
||
|
||
def normalize_text(value: Any) -> str:
|
||
if value is None:
|
||
return ""
|
||
text = unicodedata.normalize("NFKC", str(value)).replace("\u3000", " ")
|
||
return re.sub(r"\s+", " ", text).strip()
|
||
|
||
|
||
def normalize_ct(value: Any) -> str:
|
||
return re.sub(r"\s+", "", normalize_text(value)).upper()
|
||
|
||
|
||
def valid_ct(value: str) -> bool:
|
||
return bool(VALID_CT_PATTERN.fullmatch(value))
|
||
|
||
|
||
def extract_ct(text: str) -> str:
|
||
match = CT_PATTERN.search(text)
|
||
if not match:
|
||
return ""
|
||
ct_number = normalize_ct(match.group(1))
|
||
return ct_number if valid_ct(ct_number) else ""
|
||
|
||
|
||
def json_dump(value: Any) -> str:
|
||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||
|
||
|
||
def path_text(path: Path) -> str:
|
||
return str(path.expanduser().absolute())
|
||
|
||
|
||
def parse_time(value: Any) -> datetime | None:
|
||
text = normalize_text(value)
|
||
if not text:
|
||
return None
|
||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d"):
|
||
try:
|
||
return datetime.strptime(text, fmt)
|
||
except ValueError:
|
||
pass
|
||
return None
|
||
|
||
|
||
def normalize_status(value: Any) -> str:
|
||
text = normalize_text(value).replace("\ufe0f", "")
|
||
compact = re.sub(r"\s+", "", text)
|
||
if not compact:
|
||
return ""
|
||
if "部分重建成功" in compact:
|
||
return "√ 部分重建成功"
|
||
if "重建失败" in compact or compact.startswith(("×", "✗")):
|
||
return "× 重建失败"
|
||
if "重建成功" in compact:
|
||
return "√ 重建成功"
|
||
if "已报告" in compact:
|
||
return "√ 已报告"
|
||
return text
|
||
|
||
|
||
def status_like(value: Any) -> bool:
|
||
text = normalize_text(value)
|
||
return bool(re.search(r"(重建成功|重建失败|部分重建成功|已报告)", text))
|
||
|
||
|
||
def classify_segment(segment_name: str) -> tuple[str, str]:
|
||
if segment_name in {"liver", "liver_left", "liver_right"}:
|
||
return "肝脏主体", segment_name
|
||
if re.fullmatch(r"liver_segment_S[1-8]", segment_name):
|
||
return "肝段", segment_name
|
||
if segment_name in {"liver_artery", "liver_vein", "portal_vein", "bile_duct"}:
|
||
return "血管胆管", segment_name
|
||
if segment_name in {"pancreas", "spleen", "cholecyst"}:
|
||
return "腹部脏器", segment_name
|
||
if segment_name in {"skin", "rib", "vertebrae", "sternum", "hipbone", "sacrum"}:
|
||
return "体表骨骼", segment_name
|
||
if re.fullmatch(r"liver_tumor_\d+", segment_name):
|
||
return "肝脏肿瘤", "liver_tumor_*"
|
||
if re.fullmatch(r"liver_cyst_\d+", segment_name):
|
||
return "肝囊肿", "liver_cyst_*"
|
||
if re.fullmatch(r"liver_hemangioma_\d+", segment_name):
|
||
return "肝血管瘤", "liver_hemangioma_*"
|
||
if re.fullmatch(r"pancreas_tumor_\d+", segment_name):
|
||
return "胰腺肿瘤", "pancreas_tumor_*"
|
||
if re.fullmatch(r"Segment_\d+", segment_name):
|
||
return "未命名分割", "Segment_*"
|
||
return "其他", segment_name
|
||
|
||
|
||
def clean_record_info(info: dict[str, Any]) -> dict[str, Any]:
|
||
cleaned = dict(info)
|
||
exam_description = normalize_text(cleaned.get("检查描述"))
|
||
exam_device = normalize_text(cleaned.get("检查设备"))
|
||
algorithm_model = normalize_text(cleaned.get("算法模型"))
|
||
if exam_description in DEVICE_VALUES and exam_device in MODEL_VALUES and status_like(algorithm_model):
|
||
cleaned["检查描述"] = ""
|
||
cleaned["检查设备"] = exam_description
|
||
cleaned["算法模型"] = exam_device
|
||
cleaned["状态"] = normalize_status(algorithm_model)
|
||
elif exam_device in MODEL_VALUES and status_like(algorithm_model) and normalize_text(cleaned.get("状态")) in {"土", "士", "↓"}:
|
||
cleaned["检查设备"] = "CT"
|
||
cleaned["算法模型"] = exam_device
|
||
cleaned["状态"] = normalize_status(algorithm_model)
|
||
else:
|
||
cleaned["状态"] = normalize_status(cleaned.get("状态"))
|
||
return cleaned
|
||
|
||
|
||
def clean_record(record: dict[str, Any]) -> dict[str, Any]:
|
||
cleaned = dict(record)
|
||
info = cleaned.get("记录信息")
|
||
if isinstance(info, dict):
|
||
cleaned["记录信息"] = clean_record_info(info)
|
||
return cleaned
|
||
|
||
|
||
def sql_quote_path(path: Path) -> str:
|
||
return "'" + str(path.expanduser().absolute()).replace("'", "''") + "'"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class StlCandidate:
|
||
ct_number: str
|
||
source_case_dir: Path
|
||
source_stl_dir: Path
|
||
sequence_no: int | None
|
||
files: tuple[Path, ...]
|
||
|
||
@property
|
||
def file_count(self) -> int:
|
||
return len(self.files)
|
||
|
||
@property
|
||
def total_bytes(self) -> int:
|
||
return sum(file.stat().st_size for file in self.files)
|
||
|
||
def score(self) -> tuple[int, int, str, str]:
|
||
return (
|
||
self.file_count,
|
||
self.sequence_no if self.sequence_no is not None else -1,
|
||
self.source_case_dir.name,
|
||
self.source_stl_dir.name,
|
||
)
|
||
|
||
def summary(self) -> dict[str, Any]:
|
||
return {
|
||
"source_case_dir": path_text(self.source_case_dir),
|
||
"source_stl_dir": path_text(self.source_stl_dir),
|
||
"case_name": self.source_case_dir.name,
|
||
"sequence_no": self.sequence_no,
|
||
"file_count": self.file_count,
|
||
"total_bytes": self.total_bytes,
|
||
}
|
||
|
||
|
||
def sequence_no(case_dir: Path, stl_dir: Path) -> int | None:
|
||
values: list[int] = []
|
||
for pattern in (r"-(\d{3,6})_D?CT", r"^(\d{3,6})-STL$"):
|
||
for text in (case_dir.name, stl_dir.name):
|
||
values.extend(int(item) for item in re.findall(pattern, text, re.IGNORECASE))
|
||
return max(values) if values else None
|
||
|
||
|
||
def scan_stl_candidates(stl_root: Path) -> tuple[dict[str, list[StlCandidate]], list[str]]:
|
||
candidates: dict[str, list[StlCandidate]] = {}
|
||
no_ct_dirs: list[str] = []
|
||
for directory in sorted(stl_root.rglob("*")):
|
||
if not directory.is_dir():
|
||
continue
|
||
files = tuple(sorted([item for item in directory.iterdir() if item.is_file() and item.suffix.lower() == ".stl"]))
|
||
if not files:
|
||
continue
|
||
ct_number = extract_ct("/".join(directory.relative_to(stl_root).parts))
|
||
if not ct_number:
|
||
no_ct_dirs.append(path_text(directory))
|
||
continue
|
||
candidate = StlCandidate(
|
||
ct_number=ct_number,
|
||
source_case_dir=directory.parent,
|
||
source_stl_dir=directory,
|
||
sequence_no=sequence_no(directory.parent, directory),
|
||
files=files,
|
||
)
|
||
candidates.setdefault(ct_number, []).append(candidate)
|
||
return candidates, no_ct_dirs
|
||
|
||
|
||
def choose_candidates(candidates: dict[str, list[StlCandidate]]) -> dict[str, StlCandidate]:
|
||
return {ct_number: max(items, key=lambda item: item.score()) for ct_number, items in candidates.items()}
|
||
|
||
|
||
def clear_directory(directory: Path) -> None:
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
for child in directory.iterdir():
|
||
if child.is_dir() and not child.is_symlink():
|
||
shutil.rmtree(child)
|
||
else:
|
||
child.unlink()
|
||
|
||
|
||
def place_file(source: Path, destination: Path, mode: str) -> str:
|
||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||
if mode == "symlink":
|
||
destination.symlink_to(source.expanduser().absolute())
|
||
return "symlink"
|
||
if mode == "copy":
|
||
shutil.copy2(source, destination)
|
||
return "copy"
|
||
try:
|
||
os.link(source, destination)
|
||
return "hardlink"
|
||
except OSError:
|
||
shutil.copy2(source, destination)
|
||
return "copy"
|
||
|
||
|
||
def materialize_selected_stl(
|
||
selected: dict[str, StlCandidate],
|
||
processed_root: Path,
|
||
link_mode: str,
|
||
refresh_files: bool,
|
||
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, str]]:
|
||
processed_files: dict[str, list[dict[str, Any]]] = {}
|
||
link_methods: dict[str, str] = {}
|
||
processed_root.mkdir(parents=True, exist_ok=True)
|
||
for ct_number, candidate in sorted(selected.items()):
|
||
ct_dir = processed_root / ct_number
|
||
if refresh_files:
|
||
clear_directory(ct_dir)
|
||
else:
|
||
ct_dir.mkdir(parents=True, exist_ok=True)
|
||
file_rows: list[dict[str, Any]] = []
|
||
used_methods: set[str] = set()
|
||
for source in candidate.files:
|
||
destination = ct_dir / source.name
|
||
if destination.exists() or destination.is_symlink():
|
||
destination.unlink()
|
||
method = place_file(source, destination, link_mode)
|
||
used_methods.add(method)
|
||
category, family = classify_segment(source.stem)
|
||
file_rows.append(
|
||
{
|
||
"segment_name": source.stem,
|
||
"family": family,
|
||
"category": category,
|
||
"file_name": source.name,
|
||
"source_file_path": path_text(source),
|
||
"processed_file_path": path_text(destination),
|
||
"size_bytes": source.stat().st_size,
|
||
}
|
||
)
|
||
manifest = {
|
||
"ct_number": ct_number,
|
||
"selected_source_case_dir": path_text(candidate.source_case_dir),
|
||
"selected_source_stl_dir": path_text(candidate.source_stl_dir),
|
||
"processed_stl_dir": path_text(ct_dir),
|
||
"selection_rule": "file_count_desc_then_sequence_no_desc",
|
||
"stl_file_count": candidate.file_count,
|
||
"stl_total_bytes": candidate.total_bytes,
|
||
"files": file_rows,
|
||
}
|
||
(ct_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
processed_files[ct_number] = file_rows
|
||
link_methods[ct_number] = "+".join(sorted(used_methods))
|
||
return processed_files, link_methods
|
||
|
||
|
||
def load_list_records(list_json: Path) -> tuple[dict[str, list[dict[str, Any]]], list[dict[str, Any]]]:
|
||
data = json.loads(list_json.read_text(encoding="utf-8"))
|
||
records_by_ct: dict[str, list[dict[str, Any]]] = {}
|
||
invalid: list[dict[str, Any]] = []
|
||
for index, record in enumerate(data.get("图片表格记录", []), start=1):
|
||
record = clean_record(record)
|
||
info = record.get("记录信息") or {}
|
||
ct_number = normalize_ct(info.get("检查号", ""))
|
||
if not valid_ct(ct_number):
|
||
invalid.append({"row_index": index, "ct_number": ct_number, "record": record})
|
||
continue
|
||
records_by_ct.setdefault(ct_number, []).append(record)
|
||
return records_by_ct, invalid
|
||
|
||
|
||
def select_list_record(records: list[dict[str, Any]]) -> dict[str, Any]:
|
||
def key(record: dict[str, Any]) -> tuple[datetime, datetime, int, int]:
|
||
info = record.get("记录信息") or {}
|
||
image = record.get("图片信息") or {}
|
||
task_time = parse_time(info.get("任务创建时间")) or datetime.min
|
||
exam_time = parse_time(info.get("检查日期")) or datetime.min
|
||
page_no = -1
|
||
seq = image.get("图片序号")
|
||
if isinstance(seq, list):
|
||
numbers = [item for item in seq if isinstance(item, int)]
|
||
if numbers:
|
||
page_no = numbers[0]
|
||
row_no = int(image.get("图片内行号") or -1)
|
||
return (task_time, exam_time, page_no, row_no)
|
||
|
||
return max(records, key=key)
|
||
|
||
|
||
def build_rows(
|
||
selected: dict[str, StlCandidate],
|
||
all_candidates: dict[str, list[StlCandidate]],
|
||
processed_root: Path,
|
||
processed_files: dict[str, list[dict[str, Any]]],
|
||
list_records: dict[str, list[dict[str, Any]]],
|
||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], int]:
|
||
asset_rows: list[dict[str, Any]] = []
|
||
stl_rows: list[dict[str, Any]] = []
|
||
stl_file_count = 0
|
||
for ct_number in sorted(set(selected) | set(list_records)):
|
||
candidate = selected.get(ct_number)
|
||
records = list_records.get(ct_number, [])
|
||
chosen_record = select_list_record(records) if records else None
|
||
info = (chosen_record or {}).get("记录信息") or {}
|
||
files = processed_files.get(ct_number, [])
|
||
processed_dir = processed_root / ct_number
|
||
asset_rows.append(
|
||
{
|
||
"ct_number": ct_number,
|
||
"list_present": bool(records),
|
||
"stl_present": candidate is not None,
|
||
"patient_name": normalize_text(info.get("姓名")),
|
||
"patient_sex": normalize_text(info.get("性别")),
|
||
"patient_age": normalize_text(info.get("年龄")),
|
||
"patient_id_masked": normalize_text(info.get("患者号")),
|
||
"exam_date": normalize_text(info.get("检查日期")),
|
||
"task_created_at": normalize_text(info.get("任务创建时间")),
|
||
"exam_description": normalize_text(info.get("检查描述")),
|
||
"exam_device": normalize_text(info.get("检查设备")),
|
||
"algorithm_model": normalize_text(info.get("算法模型")),
|
||
"upp_status": normalize_text(info.get("状态")),
|
||
"list_record_count": len(records),
|
||
"selected_list_record": json_dump(chosen_record) if chosen_record else "",
|
||
"list_records": json_dump(records),
|
||
"selected_source_case_dir": path_text(candidate.source_case_dir) if candidate else "",
|
||
"selected_source_stl_dir": path_text(candidate.source_stl_dir) if candidate else "",
|
||
"processed_stl_dir": path_text(processed_dir) if candidate else "",
|
||
"stl_case_name": candidate.source_case_dir.name if candidate else "",
|
||
"stl_sequence_no": candidate.sequence_no if candidate and candidate.sequence_no is not None else "",
|
||
"stl_file_count": candidate.file_count if candidate else 0,
|
||
"stl_total_bytes": candidate.total_bytes if candidate else 0,
|
||
"stl_files": json_dump(files),
|
||
"stl_candidates": json_dump([item.summary() for item in sorted(all_candidates.get(ct_number, []), key=lambda item: item.score(), reverse=True)]),
|
||
}
|
||
)
|
||
if files:
|
||
stl_file_count += len(files)
|
||
stl_rows.append(
|
||
{
|
||
"ct_number": ct_number,
|
||
"file_count": len(files),
|
||
"total_bytes": sum(int(file_info.get("size_bytes") or 0) for file_info in files),
|
||
"segment_names": json_dump([file_info["segment_name"] for file_info in files]),
|
||
"segment_families": json_dump([file_info["family"] for file_info in files]),
|
||
"segment_categories": json_dump([file_info["category"] for file_info in files]),
|
||
"file_names": json_dump([file_info["file_name"] for file_info in files]),
|
||
"source_file_paths": json_dump([file_info["source_file_path"] for file_info in files]),
|
||
"processed_file_paths": json_dump([file_info["processed_file_path"] for file_info in files]),
|
||
"files": json_dump(files),
|
||
}
|
||
)
|
||
return asset_rows, stl_rows, stl_file_count
|
||
|
||
|
||
def write_csv(rows: list[dict[str, Any]], fields: list[str], suffix: str) -> Path:
|
||
temp_file = tempfile.NamedTemporaryFile("w", encoding="utf-8", newline="", suffix=suffix, delete=False)
|
||
with temp_file:
|
||
writer = csv.DictWriter(temp_file, fieldnames=fields)
|
||
writer.writeheader()
|
||
for row in rows:
|
||
writer.writerow({field: row.get(field, "") for field in fields})
|
||
return Path(temp_file.name)
|
||
|
||
|
||
def run_psql(args: argparse.Namespace, asset_csv: Path, stl_csv: Path) -> None:
|
||
sql = f"""
|
||
\\set ON_ERROR_STOP on
|
||
\\i {sql_quote_path(Path(args.schema))}
|
||
CREATE TEMP TABLE stg_upp_exam_assets (
|
||
ct_number text,
|
||
list_present boolean,
|
||
stl_present boolean,
|
||
patient_name text,
|
||
patient_sex text,
|
||
patient_age text,
|
||
patient_id_masked text,
|
||
exam_date timestamptz,
|
||
task_created_at timestamptz,
|
||
exam_description text,
|
||
exam_device text,
|
||
algorithm_model text,
|
||
upp_status text,
|
||
list_record_count integer,
|
||
selected_list_record text,
|
||
list_records text,
|
||
selected_source_case_dir text,
|
||
selected_source_stl_dir text,
|
||
processed_stl_dir text,
|
||
stl_case_name text,
|
||
stl_sequence_no integer,
|
||
stl_file_count integer,
|
||
stl_total_bytes bigint,
|
||
stl_files text,
|
||
stl_candidates text
|
||
);
|
||
CREATE TEMP TABLE stg_upp_stl_files (
|
||
ct_number text,
|
||
file_count integer,
|
||
total_bytes bigint,
|
||
segment_names text,
|
||
segment_families text,
|
||
segment_categories text,
|
||
file_names text,
|
||
source_file_paths text,
|
||
processed_file_paths text,
|
||
files text
|
||
);
|
||
\\copy stg_upp_exam_assets({",".join(ASSET_FIELDS)}) FROM {sql_quote_path(asset_csv)} WITH (FORMAT csv, HEADER true, NULL '')
|
||
\\copy stg_upp_stl_files({",".join(STL_FIELDS)}) FROM {sql_quote_path(stl_csv)} WITH (FORMAT csv, HEADER true, NULL '')
|
||
INSERT INTO upp_exam_assets (
|
||
ct_number, list_present, stl_present, patient_name, patient_sex, patient_age, patient_id_masked,
|
||
exam_date, task_created_at, exam_description, exam_device, algorithm_model, upp_status,
|
||
list_record_count, selected_list_record, list_records, selected_source_case_dir, selected_source_stl_dir,
|
||
processed_stl_dir, stl_case_name, stl_sequence_no, stl_file_count, stl_total_bytes, stl_files, stl_candidates, updated_at
|
||
)
|
||
SELECT
|
||
ct_number, list_present, stl_present, patient_name, patient_sex, patient_age, patient_id_masked,
|
||
exam_date, task_created_at, exam_description, exam_device, algorithm_model, upp_status,
|
||
COALESCE(list_record_count, 0),
|
||
NULLIF(selected_list_record, '')::jsonb,
|
||
COALESCE(NULLIF(list_records, ''), '[]')::jsonb,
|
||
selected_source_case_dir, selected_source_stl_dir, processed_stl_dir, stl_case_name, stl_sequence_no,
|
||
COALESCE(stl_file_count, 0), COALESCE(stl_total_bytes, 0),
|
||
COALESCE(NULLIF(stl_files, ''), '[]')::jsonb,
|
||
COALESCE(NULLIF(stl_candidates, ''), '[]')::jsonb,
|
||
now()
|
||
FROM stg_upp_exam_assets
|
||
ON CONFLICT (ct_number) DO UPDATE SET
|
||
list_present = EXCLUDED.list_present,
|
||
stl_present = EXCLUDED.stl_present,
|
||
patient_name = EXCLUDED.patient_name,
|
||
patient_sex = EXCLUDED.patient_sex,
|
||
patient_age = EXCLUDED.patient_age,
|
||
patient_id_masked = EXCLUDED.patient_id_masked,
|
||
exam_date = EXCLUDED.exam_date,
|
||
task_created_at = EXCLUDED.task_created_at,
|
||
exam_description = EXCLUDED.exam_description,
|
||
exam_device = EXCLUDED.exam_device,
|
||
algorithm_model = EXCLUDED.algorithm_model,
|
||
upp_status = EXCLUDED.upp_status,
|
||
list_record_count = EXCLUDED.list_record_count,
|
||
selected_list_record = EXCLUDED.selected_list_record,
|
||
list_records = EXCLUDED.list_records,
|
||
selected_source_case_dir = EXCLUDED.selected_source_case_dir,
|
||
selected_source_stl_dir = EXCLUDED.selected_source_stl_dir,
|
||
processed_stl_dir = EXCLUDED.processed_stl_dir,
|
||
stl_case_name = EXCLUDED.stl_case_name,
|
||
stl_sequence_no = EXCLUDED.stl_sequence_no,
|
||
stl_file_count = EXCLUDED.stl_file_count,
|
||
stl_total_bytes = EXCLUDED.stl_total_bytes,
|
||
stl_files = EXCLUDED.stl_files,
|
||
stl_candidates = EXCLUDED.stl_candidates,
|
||
updated_at = now();
|
||
DELETE FROM upp_stl_files WHERE ct_number IN (SELECT ct_number FROM stg_upp_exam_assets);
|
||
INSERT INTO upp_stl_files (
|
||
ct_number, file_count, total_bytes, segment_names, segment_families, segment_categories, file_names, source_file_paths, processed_file_paths, files, updated_at
|
||
)
|
||
SELECT
|
||
ct_number,
|
||
COALESCE(file_count, 0),
|
||
COALESCE(total_bytes, 0),
|
||
COALESCE(NULLIF(segment_names, ''), '[]')::jsonb,
|
||
COALESCE(NULLIF(segment_families, ''), '[]')::jsonb,
|
||
COALESCE(NULLIF(segment_categories, ''), '[]')::jsonb,
|
||
COALESCE(NULLIF(file_names, ''), '[]')::jsonb,
|
||
COALESCE(NULLIF(source_file_paths, ''), '[]')::jsonb,
|
||
COALESCE(NULLIF(processed_file_paths, ''), '[]')::jsonb,
|
||
COALESCE(NULLIF(files, ''), '[]')::jsonb,
|
||
now()
|
||
FROM stg_upp_stl_files;
|
||
"""
|
||
env = os.environ.copy()
|
||
password = args.password or env.get("PGPASSWORD")
|
||
if password:
|
||
env["PGPASSWORD"] = password
|
||
command = ["psql"]
|
||
if args.host:
|
||
command.extend(["-h", args.host])
|
||
if args.port:
|
||
command.extend(["-p", str(args.port)])
|
||
if args.user:
|
||
command.extend(["-U", args.user])
|
||
if args.dbname:
|
||
command.extend(["-d", args.dbname])
|
||
command.extend(["-v", "ON_ERROR_STOP=1"])
|
||
completed = subprocess.run(command, input=sql, text=True, env=env, capture_output=True)
|
||
print(completed.stdout, end="")
|
||
if completed.returncode != 0:
|
||
print(completed.stderr, end="")
|
||
completed.check_returncode()
|
||
|
||
|
||
def write_report(path: Path, report: dict[str, Any]) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--stl-root", default=str(DEFAULT_STL_ROOT), help="待处理STL数据根目录")
|
||
parser.add_argument("--processed-root", default=str(DEFAULT_PROCESSED_ROOT), help="规范化后的STL输出目录")
|
||
parser.add_argument("--list-json", default=str(DEFAULT_LIST_JSON), help="UPP列表合并结构化JSON")
|
||
parser.add_argument("--schema", default=str(DEFAULT_SCHEMA), help="PostgreSQL建表SQL")
|
||
parser.add_argument("--report", default=str(DEFAULT_REPORT), help="同步报告JSON")
|
||
parser.add_argument("--link-mode", choices=["hardlink", "copy", "symlink"], default="hardlink", help="输出STL文件方式")
|
||
parser.add_argument("--no-refresh-files", action="store_true", help="不清空已处理CT目录中的旧文件")
|
||
parser.add_argument("--dry-run", action="store_true", help="只整理文件和生成报告,不写数据库")
|
||
parser.add_argument("--host", default=os.getenv("PGHOST", ""))
|
||
parser.add_argument("--port", default=os.getenv("PGPORT", ""))
|
||
parser.add_argument("--dbname", default=os.getenv("PGDATABASE", ""))
|
||
parser.add_argument("--user", default=os.getenv("PGUSER", ""))
|
||
parser.add_argument("--password", default=os.getenv("PGPASSWORD", ""), help="数据库密码;也可用PGPASSWORD环境变量")
|
||
return parser.parse_args()
|
||
|
||
|
||
def main() -> None:
|
||
args = parse_args()
|
||
stl_root = Path(args.stl_root)
|
||
processed_root = Path(args.processed_root)
|
||
all_candidates, no_ct_dirs = scan_stl_candidates(stl_root)
|
||
selected = choose_candidates(all_candidates)
|
||
processed_files, link_methods = materialize_selected_stl(
|
||
selected=selected,
|
||
processed_root=processed_root,
|
||
link_mode=args.link_mode,
|
||
refresh_files=not args.no_refresh_files,
|
||
)
|
||
list_records, invalid_list_records = load_list_records(Path(args.list_json))
|
||
asset_rows, stl_rows, stl_file_count = build_rows(
|
||
selected=selected,
|
||
all_candidates=all_candidates,
|
||
processed_root=processed_root,
|
||
processed_files=processed_files,
|
||
list_records=list_records,
|
||
)
|
||
duplicate_ct_numbers = sorted(ct_number for ct_number, items in all_candidates.items() if len(items) > 1)
|
||
report = {
|
||
"stl_candidate_dirs": sum(len(items) for items in all_candidates.values()),
|
||
"stl_unique_ct": len(selected),
|
||
"stl_no_ct_dirs": len(no_ct_dirs),
|
||
"stl_duplicate_ct": len(duplicate_ct_numbers),
|
||
"list_unique_valid_ct": len(list_records),
|
||
"list_invalid_ct_records": len(invalid_list_records),
|
||
"asset_rows": len(asset_rows),
|
||
"stl_table_rows": len(stl_rows),
|
||
"stl_file_rows": stl_file_count,
|
||
"matched_list_and_stl_ct": len(set(selected) & set(list_records)),
|
||
"list_without_stl_ct": len(set(list_records) - set(selected)),
|
||
"stl_without_list_ct": len(set(selected) - set(list_records)),
|
||
"duplicate_ct_examples": {
|
||
ct_number: [item.summary() for item in sorted(all_candidates[ct_number], key=lambda item: item.score(), reverse=True)]
|
||
for ct_number in duplicate_ct_numbers[:20]
|
||
},
|
||
"no_ct_dir_examples": no_ct_dirs[:20],
|
||
"invalid_list_ct_examples": invalid_list_records[:20],
|
||
"link_methods": dict(sorted(link_methods.items())[:20]),
|
||
"processed_root": path_text(processed_root),
|
||
"selection_rule": "同一CT号优先选择STL文件数更多;文件数相同选择目录/病例名中的较大序号",
|
||
"dry_run": args.dry_run,
|
||
}
|
||
write_report(Path(args.report), report)
|
||
asset_csv = write_csv(asset_rows, ASSET_FIELDS, "_upp_exam_assets.csv")
|
||
stl_csv = write_csv(stl_rows, STL_FIELDS, "_upp_stl_files.csv")
|
||
try:
|
||
if not args.dry_run:
|
||
run_psql(args, asset_csv, stl_csv)
|
||
finally:
|
||
asset_csv.unlink(missing_ok=True)
|
||
stl_csv.unlink(missing_ok=True)
|
||
print(json.dumps({k: report[k] for k in [
|
||
"stl_candidate_dirs",
|
||
"stl_unique_ct",
|
||
"stl_no_ct_dirs",
|
||
"stl_duplicate_ct",
|
||
"list_unique_valid_ct",
|
||
"asset_rows",
|
||
"stl_table_rows",
|
||
"stl_file_rows",
|
||
"matched_list_and_stl_ct",
|
||
"list_without_stl_ct",
|
||
"stl_without_list_ct",
|
||
]}, ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|