Import sanitized HIS processing tools

This commit is contained in:
2026-05-26 08:51:10 +08:00
commit 7e329f1d85
46 changed files with 19175 additions and 0 deletions

View File

@@ -0,0 +1,697 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把患者首页 PDF 转为图片,并生成图片-结构化字段对照核验清单。
依赖系统命令 pdftoppm通常由 poppler-utils 提供。
"""
from __future__ import annotations
import argparse
import csv
import html
import json
import re
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from string import Template
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_INPUT_DIR = PROJECT_ROOT / "待处理-患者首页PDF"
DEFAULT_RESULT_DIR = PROJECT_ROOT / "数据处理结果区"
DEFAULT_IMAGE_REVIEW_DIR = DEFAULT_RESULT_DIR / "06_PDF图片对照"
@dataclass(frozen=True)
class FieldCheck:
group: str
key: str
level: str
location_hint: str
FIELD_CHECKS = [
FieldCheck("基本信息", "医疗机构", "recommended", "首页抬头及组织机构代码附近"),
FieldCheck("基本信息", "医疗付费方式", "recommended", "首页左上付费方式"),
FieldCheck("基本信息", "健康卡号", "recommended", "首页左上健康卡号"),
FieldCheck("基本信息", "住院次数", "recommended", "首页左上住院次数"),
FieldCheck("基本信息", "病案号", "required", "首页左上病案号"),
FieldCheck("基本信息", "姓名", "required", "基本信息行"),
FieldCheck("基本信息", "性别", "required", "基本信息行"),
FieldCheck("基本信息", "出生日期", "required", "基本信息行"),
FieldCheck("基本信息", "年龄", "required", "基本信息行"),
FieldCheck("基本信息", "国籍", "recommended", "基本信息行"),
FieldCheck("基本信息", "身份证号", "required", "基本信息行"),
FieldCheck("基本信息", "职业", "recommended", "基本信息行"),
FieldCheck("基本信息", "婚姻代码", "recommended", "基本信息行"),
FieldCheck("基本信息", "出生地", "recommended", "地址信息区"),
FieldCheck("基本信息", "籍贯", "recommended", "地址信息区"),
FieldCheck("基本信息", "民族", "recommended", "地址信息区"),
FieldCheck("地址联系人", "现住址", "required", "现住址行"),
FieldCheck("地址联系人", "现住址电话", "recommended", "现住址行"),
FieldCheck("地址联系人", "现住址邮编", "recommended", "现住址行"),
FieldCheck("地址联系人", "户口地址", "recommended", "户口地址行"),
FieldCheck("地址联系人", "户口地址邮编", "recommended", "户口地址行"),
FieldCheck("地址联系人", "工作单位及地址", "recommended", "工作单位及地址行"),
FieldCheck("地址联系人", "单位电话", "recommended", "工作单位及地址行"),
FieldCheck("地址联系人", "单位邮编", "recommended", "工作单位及地址行"),
FieldCheck("地址联系人", "联系人姓名", "required", "联系人信息行"),
FieldCheck("地址联系人", "联系人关系", "recommended", "联系人信息行"),
FieldCheck("地址联系人", "联系人地址", "recommended", "联系人信息行"),
FieldCheck("地址联系人", "联系人电话", "required", "联系人信息行"),
FieldCheck("入出院", "入院途径代码", "recommended", "入院途径勾选项"),
FieldCheck("入出院", "入院时间", "required", "入院记录行"),
FieldCheck("入出院", "入院科别", "required", "入院记录行"),
FieldCheck("入出院", "入院病房", "recommended", "入院记录行"),
FieldCheck("入出院", "转科科别", "optional", "转科科别行"),
FieldCheck("入出院", "出院时间", "required", "出院记录行"),
FieldCheck("入出院", "出院科别", "required", "出院记录行"),
FieldCheck("入出院", "出院病房", "recommended", "出院记录行"),
FieldCheck("入出院", "实际住院天数", "required", "出院记录行"),
FieldCheck("入出院", "大科室", "required", "由入院/出院科别映射"),
FieldCheck("诊断手术", "门急诊诊断", "recommended", "诊断区顶部"),
FieldCheck("诊断手术", "门急诊诊断编码", "recommended", "诊断区顶部"),
FieldCheck("诊断手术", "主要诊断名称", "required", "出院诊断表第一行"),
FieldCheck("诊断手术", "主要诊断编码", "required", "出院诊断表第一行"),
FieldCheck("诊断手术", "主要诊断入院病情", "required", "出院诊断表第一行"),
FieldCheck("诊断手术", "出院诊断", "recommended", "出院诊断表全部行"),
FieldCheck("诊断手术", "手术操作", "recommended", "手术及操作表"),
FieldCheck("诊断手术", "损伤中毒外部原因", "optional", "损伤中毒外部原因行"),
FieldCheck("诊断手术", "损伤中毒疾病编码", "optional", "损伤中毒外部原因行"),
FieldCheck("诊断手术", "病理诊断", "optional", "病理诊断行"),
FieldCheck("诊断手术", "病理诊断编码", "optional", "病理诊断行"),
FieldCheck("诊断手术", "病理号", "optional", "病理号"),
FieldCheck("质控信息", "药物过敏代码", "recommended", "药物过敏勾选项"),
FieldCheck("质控信息", "过敏药物", "optional", "过敏药物填写处"),
FieldCheck("质控信息", "死亡患者尸检代码", "recommended", "死亡患者尸检勾选项"),
FieldCheck("质控信息", "血型代码", "recommended", "血型勾选项"),
FieldCheck("质控信息", "Rh代码", "recommended", "Rh勾选项"),
FieldCheck("质控信息", "科主任", "recommended", "医师签名区"),
FieldCheck("质控信息", "主任副主任医师", "recommended", "医师签名区"),
FieldCheck("质控信息", "主治医师", "recommended", "医师签名区"),
FieldCheck("质控信息", "住院医师", "recommended", "医师签名区"),
FieldCheck("质控信息", "责任护士", "recommended", "护理签名区"),
FieldCheck("质控信息", "编码员", "recommended", "编码员签名区"),
FieldCheck("质控信息", "病案质量代码", "recommended", "病案质量勾选项"),
FieldCheck("质控信息", "质控医师", "recommended", "质控签名区"),
FieldCheck("质控信息", "质控护士", "recommended", "质控签名区"),
FieldCheck("质控信息", "质控日期", "recommended", "质控日期"),
FieldCheck("离院费用", "离院方式代码", "recommended", "离院方式勾选项"),
FieldCheck("离院费用", "出院31天内再住院计划代码", "recommended", "再住院计划勾选项"),
FieldCheck("离院费用", "再住院计划目的", "optional", "再住院计划目的"),
FieldCheck("离院费用", "入院前昏迷天数", "optional", "昏迷时间区"),
FieldCheck("离院费用", "入院后昏迷天数", "optional", "昏迷时间区"),
FieldCheck("离院费用", "总费用", "optional", "费用区"),
FieldCheck("离院费用", "自付金额", "optional", "费用区"),
FieldCheck("离院费用", "费用明细", "optional", "费用明细区"),
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="患者首页 PDF 转图片并生成字段对照核验报告")
parser.add_argument("-i", "--input-dir", type=Path, default=DEFAULT_INPUT_DIR, help="PDF 输入目录")
parser.add_argument("-r", "--result-dir", type=Path, default=DEFAULT_RESULT_DIR, help="解析结果根目录")
parser.add_argument("-o", "--output-dir", type=Path, default=DEFAULT_IMAGE_REVIEW_DIR, help="图片对照输出目录")
parser.add_argument("--dpi", type=int, default=180, help="图片分辨率,默认 180")
parser.add_argument("--format", choices=["png", "jpeg"], default="png", help="图片格式,默认 png")
parser.add_argument("--force", action="store_true", help="重新生成已存在图片")
parser.add_argument("--strict-recommended", action="store_true", help="把建议字段缺项也写入对照建议")
return parser.parse_args()
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, check=True, text=True, capture_output=True)
def numeric_page_key(path: Path) -> tuple[int, str]:
match = re.search(r"-(\d+)\.(png|jpe?g)$", path.name, flags=re.IGNORECASE)
if not match:
return (10**9, path.name)
return (int(match.group(1)), path.name)
def convert_pdf_to_images(pdf_path: Path, output_dir: Path, dpi: int, image_format: str, force: bool) -> tuple[list[Path], str]:
pdftoppm = shutil.which("pdftoppm")
if not pdftoppm:
return [], "未找到 pdftoppm请安装 poppler-utils。"
output_dir.mkdir(parents=True, exist_ok=True)
extension = "jpg" if image_format == "jpeg" else "png"
existing = sorted(output_dir.glob(f"page-*.{extension}"), key=numeric_page_key)
if existing and not force:
return existing, ""
if force:
for old_image in output_dir.glob("page-*.*"):
old_image.unlink()
prefix = output_dir / "page"
cmd = [pdftoppm, "-r", str(dpi), f"-{image_format}", str(pdf_path), str(prefix)]
try:
run(cmd)
except subprocess.CalledProcessError as exc:
detail = exc.stderr.strip() or exc.stdout.strip() or str(exc)
return [], f"图片转换失败:{detail}"
images = sorted(output_dir.glob(f"page-*.{extension}"), key=numeric_page_key)
normalized_images: list[Path] = []
for index, image_path in enumerate(images, start=1):
target = output_dir / f"page-{index:03d}.{extension}"
if image_path != target:
if target.exists():
target.unlink()
image_path.rename(target)
normalized_images.append(target)
return normalized_images, "" if normalized_images else "图片转换后未找到输出文件。"
def load_record(result_dir: Path, pdf_path: Path) -> tuple[dict[str, Any], Path | None, str]:
json_path = result_dir / "02_单份JSON" / f"{pdf_path.stem}.json"
if not json_path.exists():
return {}, None, "未找到结构化 JSON请先运行 02_解析入库 步骤。"
try:
return json.loads(json_path.read_text(encoding="utf-8")), json_path, ""
except json.JSONDecodeError as exc:
return {}, json_path, f"结构化 JSON 读取失败:{exc}"
def is_blank(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
text = value.strip()
return text == "" or text in {"-", "--", "null", "None", "[]", "{}"}
if isinstance(value, (list, tuple, set, dict)):
return len(value) == 0
return False
def value_preview(value: Any, max_length: int = 120) -> str:
if value is None:
return ""
if isinstance(value, (list, dict)):
text = json.dumps(value, ensure_ascii=False)
else:
text = str(value)
text = re.sub(r"\s+", " ", text).strip()
if len(text) > max_length:
return text[: max_length - 1] + ""
return text
def build_field_rows(record: dict[str, Any]) -> tuple[list[dict[str, str]], list[str], list[str]]:
rows: list[dict[str, str]] = []
missing_required: list[str] = []
missing_recommended: list[str] = []
for check in FIELD_CHECKS:
value = record.get(check.key)
missing = is_blank(value)
if missing and check.level == "required":
missing_required.append(check.key)
elif missing and check.level == "recommended":
missing_recommended.append(check.key)
rows.append(
{
"group": check.group,
"key": check.key,
"level": check.level,
"location_hint": check.location_hint,
"value": value_preview(value),
"missing": "" if missing else "",
}
)
return rows, missing_required, missing_recommended
def as_csv_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (list, dict)):
return json.dumps(value, ensure_ascii=False)
return str(value)
def relative_to(path: Path, start: Path) -> str:
try:
return path.relative_to(start).as_posix()
except ValueError:
return path.as_posix()
def html_escape(value: Any) -> str:
return html.escape(str(value), quote=True)
def make_case_summary(
pdf_path: Path,
images: list[Path],
image_error: str,
record: dict[str, Any],
json_path: Path | None,
json_error: str,
output_dir: Path,
strict_recommended: bool,
) -> dict[str, Any]:
field_rows, missing_required, missing_recommended = build_field_rows(record) if record else ([], [], [])
review_notes = record.get("复核备注", []) if record else []
if not isinstance(review_notes, list):
review_notes = [review_notes]
suggestions: list[str] = []
if image_error:
suggestions.append(image_error)
if json_error:
suggestions.append(json_error)
if missing_required:
suggestions.append("核心字段缺项,需对照图片补齐:" + "".join(missing_required))
if strict_recommended and missing_recommended:
suggestions.append("建议字段缺项,抽样确认是否首页未填写:" + "".join(missing_recommended))
if record.get("复核状态") and record.get("复核状态") != "auto_pass":
suggestions.append("该记录已有复核状态:" + str(record.get("复核状态")))
if review_notes:
suggestions.append("已有复核备注:" + "".join(value_preview(note, 80) for note in review_notes))
if not suggestions:
suggestions.append("图片与结构化字段抽查一致即可。")
return {
"pdf_path": pdf_path,
"source_file": pdf_path.name,
"images": images,
"image_dir": images[0].parent if images else output_dir / "图片" / pdf_path.stem,
"first_image": images[0] if images else None,
"page_count": len(images),
"image_error": image_error,
"record": record,
"json_path": json_path,
"json_error": json_error,
"field_rows": field_rows,
"missing_required": missing_required,
"missing_recommended": missing_recommended,
"suggestions": suggestions,
}
def write_index_csv(cases: list[dict[str, Any]], output_dir: Path) -> Path:
csv_path = output_dir / "患者首页_PDF图片对照索引.csv"
fieldnames = [
"源文件",
"页数",
"图片目录",
"首页图片",
"结构化JSON",
"病案号",
"姓名",
"复核状态",
"复核备注",
"核心缺项",
"建议核对缺项",
"对照建议",
]
with csv_path.open("w", encoding="utf-8-sig", newline="") as fp:
writer = csv.DictWriter(fp, fieldnames=fieldnames)
writer.writeheader()
for case in cases:
record = case["record"]
writer.writerow(
{
"源文件": case["source_file"],
"页数": case["page_count"],
"图片目录": relative_to(case["image_dir"], output_dir),
"首页图片": relative_to(case["first_image"], output_dir) if case["first_image"] else "",
"结构化JSON": relative_to(case["json_path"], output_dir) if case["json_path"] else "",
"病案号": record.get("病案号", ""),
"姓名": record.get("姓名", ""),
"复核状态": record.get("复核状态", ""),
"复核备注": as_csv_text(record.get("复核备注", [])),
"核心缺项": "".join(case["missing_required"]),
"建议核对缺项": "".join(case["missing_recommended"]),
"对照建议": "".join(case["suggestions"]),
}
)
return csv_path
def render_field_table(field_rows: list[dict[str, str]]) -> str:
if not field_rows:
return '<p class="empty">未生成字段核验表。</p>'
parts = [
'<table class="field-table">',
"<thead><tr><th>组</th><th>字段</th><th>级别</th><th>首页位置</th><th>值</th><th>缺项</th></tr></thead>",
"<tbody>",
]
for row in field_rows:
missing_class = " is-missing" if row["missing"] == "" and row["level"] != "optional" else ""
parts.append(
"<tr class=\"{missing_class}\">"
"<td>{group}</td><td>{key}</td><td>{level}</td><td>{location}</td><td>{value}</td><td>{missing}</td>"
"</tr>".format(
missing_class=missing_class.strip(),
group=html_escape(row["group"]),
key=html_escape(row["key"]),
level=html_escape(row["level"]),
location=html_escape(row["location_hint"]),
value=html_escape(row["value"]),
missing=html_escape(row["missing"]),
)
)
parts.append("</tbody></table>")
return "\n".join(parts)
def render_html(cases: list[dict[str, Any]], output_dir: Path) -> Path:
html_path = output_dir / "患者首页_PDF图片对照.html"
total = len(cases)
with_required_missing = sum(1 for case in cases if case["missing_required"])
with_review_status = sum(1 for case in cases if case["record"].get("复核状态") not in {"", "auto_pass", None})
failed_images = sum(1 for case in cases if case["image_error"])
nav_rows = []
case_sections = []
for index, case in enumerate(cases, start=1):
anchor = f"case-{index}"
record = case["record"]
status = record.get("复核状态", "未解析")
nav_rows.append(
"<tr><td><a href=\"#{anchor}\">{source}</a></td><td>{name}</td><td>{mrn}</td><td>{status}</td><td>{required}</td></tr>".format(
anchor=anchor,
source=html_escape(case["source_file"]),
name=html_escape(record.get("姓名", "")),
mrn=html_escape(record.get("病案号", "")),
status=html_escape(status),
required=html_escape("".join(case["missing_required"]) or ""),
)
)
images_html = []
for image_path in case["images"]:
rel_image = relative_to(image_path, output_dir)
images_html.append(
'<figure><img loading="lazy" src="{src}" alt="{alt}"><figcaption>{caption}</figcaption></figure>'.format(
src=html_escape(rel_image),
alt=html_escape(f"{case['source_file']} {image_path.name}"),
caption=html_escape(image_path.name),
)
)
if not images_html:
images_html.append('<p class="empty">未生成图片。</p>')
suggestion_items = "\n".join(f"<li>{html_escape(note)}</li>" for note in case["suggestions"])
summary_items = [
("病案号", record.get("病案号", "")),
("姓名", record.get("姓名", "")),
("大科室", record.get("大科室", "")),
("出院科别", record.get("出院科别", "")),
("主要诊断", record.get("主要诊断名称", "")),
("主要诊断编码", record.get("主要诊断编码", "")),
("复核状态", status),
]
summary_html = "\n".join(
f"<dt>{html_escape(label)}</dt><dd>{html_escape(value_preview(value, 160))}</dd>" for label, value in summary_items
)
case_sections.append(
"""
<section class="case" id="{anchor}">
<header class="case-header">
<div>
<h2>{source}</h2>
<p>{name} · {mrn} · {status}</p>
</div>
<a class="back-link" href="#top">返回顶部</a>
</header>
<div class="case-grid">
<div class="image-pane">{images}</div>
<div class="data-pane">
<dl class="summary">{summary}</dl>
<div class="suggestions"><h3>对照建议</h3><ul>{suggestions}</ul></div>
{field_table}
</div>
</div>
</section>
""".format(
anchor=anchor,
source=html_escape(case["source_file"]),
name=html_escape(record.get("姓名", "")),
mrn=html_escape(record.get("病案号", "")),
status=html_escape(status),
images="\n".join(images_html),
summary=summary_html,
suggestions=suggestion_items,
field_table=render_field_table(case["field_rows"]),
)
)
page_template = Template(
"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>患者首页 PDF 图片对照核验</title>
<style>
:root {
--ink: #15181c;
--muted: #5f6975;
--line: #d9dde3;
--paper: #f6f7f9;
--panel: #ffffff;
--bad: #b42318;
--bad-bg: #fff2ef;
--warn: #8a5a00;
--warn-bg: #fff7df;
--ok: #1f7a4d;
--ok-bg: #edf8f2;
--accent: #245b8f;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 1.55;
}
header.report-header {
position: sticky;
top: 0;
z-index: 10;
padding: 14px 22px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.96);
}
h1, h2, h3 { margin: 0; letter-spacing: 0; }
h1 { font-size: 20px; }
h2 { font-size: 16px; }
h3 { font-size: 14px; margin-bottom: 8px; }
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.pill {
border: 1px solid var(--line);
background: #fff;
padding: 4px 8px;
border-radius: 6px;
color: var(--muted);
}
main { padding: 18px 22px 32px; }
.index {
overflow: auto;
border: 1px solid var(--line);
background: var(--panel);
border-radius: 8px;
margin-bottom: 18px;
}
table { width: 100%; border-collapse: collapse; }
th, td {
border-bottom: 1px solid var(--line);
padding: 7px 8px;
text-align: left;
vertical-align: top;
white-space: normal;
word-break: break-word;
}
th {
background: #eef1f5;
color: #303844;
font-weight: 700;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.case {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
margin: 18px 0;
overflow: hidden;
}
.case-header {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: #fbfcfd;
}
.case-header p {
margin: 4px 0 0;
color: var(--muted);
}
.back-link { white-space: nowrap; align-self: center; }
.case-grid {
display: grid;
grid-template-columns: minmax(420px, 55%) minmax(360px, 45%);
min-height: 520px;
}
.image-pane {
border-right: 1px solid var(--line);
background: #eceff3;
padding: 12px;
overflow: auto;
max-height: 86vh;
}
figure { margin: 0 0 14px; }
img {
display: block;
width: 100%;
height: auto;
border: 1px solid #c5cbd3;
background: #fff;
}
figcaption {
margin-top: 5px;
color: var(--muted);
font-size: 12px;
}
.data-pane {
padding: 12px;
overflow: auto;
max-height: 86vh;
}
.summary {
display: grid;
grid-template-columns: 108px 1fr;
gap: 5px 10px;
margin: 0 0 12px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfcfd;
}
dt { color: var(--muted); }
dd { margin: 0; font-weight: 600; }
.suggestions {
margin-bottom: 12px;
padding: 10px;
border: 1px solid #ead7a7;
border-radius: 8px;
background: var(--warn-bg);
color: var(--warn);
}
.suggestions ul { margin: 0; padding-left: 18px; }
.field-table td:nth-child(5) { max-width: 360px; }
.field-table .is-missing td {
background: var(--bad-bg);
color: var(--bad);
}
.empty {
color: var(--muted);
padding: 8px;
}
@media (max-width: 980px) {
.case-grid { grid-template-columns: 1fr; }
.image-pane { border-right: 0; border-bottom: 1px solid var(--line); max-height: none; }
.data-pane { max-height: none; }
}
</style>
</head>
<body>
<header class="report-header" id="top">
<h1>患者首页 PDF 图片对照核验</h1>
<div class="meta">
<span class="pill">PDF$total</span>
<span class="pill">核心缺项:$with_required_missing</span>
<span class="pill">需复核状态:$with_review_status</span>
<span class="pill">图片失败:$failed_images</span>
</div>
</header>
<main>
<section class="index">
<table>
<thead><tr><th>源文件</th><th>姓名</th><th>病案号</th><th>复核状态</th><th>核心缺项</th></tr></thead>
<tbody>$nav_rows</tbody>
</table>
</section>
$case_sections
</main>
</body>
</html>
"""
)
html_path.write_text(
page_template.substitute(
total=total,
with_required_missing=with_required_missing,
with_review_status=with_review_status,
failed_images=failed_images,
nav_rows="\n".join(nav_rows),
case_sections="\n".join(case_sections),
),
encoding="utf-8",
)
return html_path
def main() -> int:
args = parse_args()
input_dir = args.input_dir.resolve()
result_dir = args.result_dir.resolve()
output_dir = args.output_dir.resolve()
image_root = output_dir / "图片"
output_dir.mkdir(parents=True, exist_ok=True)
pdf_files = sorted(input_dir.glob("*.pdf"))
if not pdf_files:
raise SystemExit(f"未找到 PDF{input_dir}")
cases: list[dict[str, Any]] = []
for pdf_path in pdf_files:
case_image_dir = image_root / pdf_path.stem
images, image_error = convert_pdf_to_images(pdf_path, case_image_dir, args.dpi, args.format, args.force)
record, json_path, json_error = load_record(result_dir, pdf_path)
cases.append(
make_case_summary(
pdf_path=pdf_path,
images=images,
image_error=image_error,
record=record,
json_path=json_path,
json_error=json_error,
output_dir=output_dir,
strict_recommended=args.strict_recommended,
)
)
index_csv = write_index_csv(cases, output_dir)
html_path = render_html(cases, output_dir)
required_missing_count = sum(1 for case in cases if case["missing_required"])
print(f"PDF 数量:{len(cases)}")
print(f"图片输出:{image_root}")
print(f"对照索引:{index_csv}")
print(f"HTML 对照页:{html_path}")
print(f"核心缺项病例数:{required_missing_count}")
return 0
if __name__ == "__main__":
raise SystemExit(main())