Files
HIS/患者列表处理/数据处理工作区/02_患者列表OCR归档.py

1347 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Archive HIS patient-list screenshots into structured JSON.
Tencent OCR credentials are read from environment variables:
TENCENTCLOUD_SECRET_ID and TENCENTCLOUD_SECRET_KEY.
"""
from __future__ import annotations
import argparse
import base64
import concurrent.futures
import csv
import datetime as dt
import hashlib
import hmac
import json
import os
import re
import signal
import subprocess
import threading
import time
import unicodedata
import urllib.error
import urllib.request
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from PIL import Image
COLUMNS = [
"姓名",
"性别",
"年龄",
"住院号",
"诊断",
"入院时间",
"最后书写时间",
"住院天数",
"出院时间",
"手术后天数",
]
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp"}
OCR_ENGINE_LABELS = {
"table-v3": "腾讯云 RecognizeTableAccurateOCR 表格识别 V3",
}
GENERAL_COLUMN_ANCHORS = [0, 96, 160, 230, 450, 820, 1075, 1305, 1395, 1590]
GENERAL_REFERENCE_WIDTH = 1842
GENERAL_ROW_Y_THRESHOLD = 18
class NonRetryableOcrError(RuntimeError):
pass
@dataclass(frozen=True)
class Department:
major: str
sub: str
raw_name: str
def natural_key(path: Path) -> tuple[Any, ...]:
parts = re.split(r"(\d+)", path.stem)
key: list[Any] = []
for part in parts:
if part.isdigit():
key.append(int(part))
else:
key.append(part)
return tuple(key)
def normalize_text(value: Any) -> str:
if value is None:
return ""
text = unicodedata.normalize("NFKC", str(value))
text = text.replace("\u3000", " ")
return re.sub(r"\s+", " ", text).strip()
def normalize_folder_name(folder_name: str) -> str:
name = re.sub(r"^\d{4}[_-]\d{1,2}[_-]\d{1,2}~\d{4}[_-]\d{1,2}[_-]\d{1,2}_", "", folder_name)
name = re.sub(r"^\d{4}[_-]\d{1,2}[_-]\d{1,2}_", "", name)
name = re.sub(r"^\d{4}\d{1,2}月\d{1,2}日[_-]?", "", name)
name = re.sub(r"病房|病区", "", name)
return normalize_text(name)
def load_departments(path: Path) -> tuple[dict[str, str], dict[str, str]]:
data = json.loads(path.read_text(encoding="utf-8"))
sub_to_major: dict[str, str] = {}
for group in data["大科室列表"]:
major = group["大科室"]
for sub in group["子科室"]:
if sub in sub_to_major:
raise ValueError(f"重复子科室: {sub}")
sub_to_major[sub] = major
aliases: dict[str, str] = {}
for alias, sub in data.get("aliases", {}).items():
if sub not in sub_to_major:
raise ValueError(f"别名 {alias} 指向不存在的子科室 {sub}")
aliases[normalize_text(alias)] = sub
for sub in sub_to_major:
aliases[normalize_text(sub)] = sub
return sub_to_major, aliases
def classify_department(folder_name: str, sub_to_major: dict[str, str], aliases: dict[str, str]) -> Department:
raw = normalize_folder_name(folder_name)
if raw in aliases:
sub = aliases[raw]
return Department(sub_to_major[sub], sub, raw)
compact = raw.replace("", "").replace("", "")
for alias, sub in sorted(aliases.items(), key=lambda item: len(item[0]), reverse=True):
alias_compact = alias.replace("", "")
if alias and (alias in raw or alias_compact in compact):
return Department(sub_to_major[sub], sub, raw)
return Department("未分类", "未分类", raw)
def batched(items: list[Path], size: int) -> list[list[Path]]:
return [items[i : i + size] for i in range(0, len(items), size)]
def merge_images(image_paths: list[Path], output_path: Path, padding_y: int = 0) -> dict[str, Any]:
padding_y = max(0, int(padding_y))
opened = [Image.open(path).convert("RGB") for path in image_paths]
try:
width = max(image.width for image in opened)
height = sum(image.height + padding_y * 2 for image in opened)
merged = Image.new("RGB", (width, height), "white")
y = 0
source_images = []
for path, image in zip(image_paths, opened):
image_y = y + padding_y
merged.paste(image, (0, image_y))
source_images.append(
{
"path": str(path),
"name": path.name,
"width": image.width,
"height": image.height,
"y_offset": image_y,
"block_y_offset": y,
"block_height": image.height + padding_y * 2,
"padding_top": padding_y,
"padding_bottom": padding_y,
}
)
y += image.height + padding_y * 2
output_path.parent.mkdir(parents=True, exist_ok=True)
merged.save(output_path)
return {
"path": str(output_path),
"width": width,
"height": height,
"padding_y": padding_y,
"source_images": source_images,
}
finally:
for image in opened:
image.close()
def infer_rows_for_image(image_path: Path, override_rows: int = 0) -> int:
if override_rows > 0:
return override_rows
with Image.open(image_path) as image:
# HIS list screenshots in this batch use about 39 px per row:
# 700 px -> 18 rows, 784 px -> 20 rows.
return max(1, round(image.height / 39.0))
def locate_source_row(table_row_index: int, row_counts: list[int]) -> tuple[int, int]:
offset = 0
for image_index, row_count in enumerate(row_counts):
if table_row_index < offset + row_count:
return image_index, table_row_index - offset
offset += row_count
return len(row_counts) - 1, max(0, table_row_index - sum(row_counts[:-1]))
def get_credentials() -> tuple[str, str]:
secret_id = os.getenv("TENCENTCLOUD_SECRET_ID") or os.getenv("TENCENT_SECRET_ID")
secret_key = os.getenv("TENCENTCLOUD_SECRET_KEY") or os.getenv("TENCENT_SECRET_KEY")
if not secret_id or not secret_key:
raise RuntimeError("请先设置 TENCENTCLOUD_SECRET_ID 和 TENCENTCLOUD_SECRET_KEY 环境变量")
return secret_id, secret_key
def tc3_request(
action: str,
payload: dict[str, Any],
secret_id: str,
secret_key: str,
region: str,
timeout: int,
) -> dict[str, Any]:
service = "ocr"
host = "ocr.tencentcloudapi.com"
endpoint = f"https://{host}"
version = "2018-11-19"
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
algorithm = "TC3-HMAC-SHA256"
timestamp = int(dt.datetime.now(dt.timezone.utc).timestamp())
date = dt.datetime.fromtimestamp(timestamp, dt.timezone.utc).strftime("%Y-%m-%d")
content_type = "application/json; charset=utf-8"
canonical_headers = f"content-type:{content_type}\nhost:{host}\n"
signed_headers = "content-type;host"
hashed_payload = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical_request = "\n".join(["POST", "/", "", canonical_headers, signed_headers, hashed_payload])
credential_scope = f"{date}/{service}/tc3_request"
string_to_sign = "\n".join(
[
algorithm,
str(timestamp),
credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
]
)
def sign(key: bytes, message: str) -> bytes:
return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()
secret_date = sign(("TC3" + secret_key).encode("utf-8"), date)
secret_service = sign(secret_date, service)
secret_signing = sign(secret_service, "tc3_request")
signature = hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (
f"{algorithm} Credential={secret_id}/{credential_scope}, "
f"SignedHeaders={signed_headers}, Signature={signature}"
)
headers = {
"Authorization": authorization,
"Content-Type": content_type,
"Host": host,
"X-TC-Action": action,
"X-TC-Timestamp": str(timestamp),
"X-TC-Version": version,
"X-TC-Region": region,
}
command = [
"curl",
"-sS",
"--connect-timeout",
str(min(10, max(1, timeout))),
"--max-time",
str(max(1, timeout)),
"-X",
"POST",
endpoint,
]
for key, value in headers.items():
command.extend(["-H", f"{key}: {value}"])
command.extend(["--data-binary", "@-"])
completed = subprocess.run(
command,
input=body.encode("utf-8"),
capture_output=True,
timeout=max(1, timeout) + 5,
check=False,
)
if completed.returncode != 0:
error_text = completed.stderr.decode("utf-8", errors="replace").strip()
raise urllib.error.URLError(error_text or f"curl return code {completed.returncode}")
return json.loads(completed.stdout.decode("utf-8"))
@contextmanager
def wall_clock_timeout(seconds: int):
if seconds <= 0 or threading.current_thread() is not threading.main_thread():
yield
return
def handle_timeout(_signum: int, _frame: Any) -> None:
raise TimeoutError(f"OCR请求超过 {seconds}")
previous_handler = signal.getsignal(signal.SIGALRM)
previous_timer = signal.setitimer(signal.ITIMER_REAL, 0)
signal.signal(signal.SIGALRM, handle_timeout)
signal.setitimer(signal.ITIMER_REAL, seconds)
try:
yield
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
signal.signal(signal.SIGALRM, previous_handler)
if previous_timer[0] > 0:
signal.setitimer(signal.ITIMER_REAL, previous_timer[0], previous_timer[1])
def call_tencent_ocr(
action: str,
image_path: Path,
cache_path: Path,
secret_id: str,
secret_key: str,
region: str,
timeout: int,
force: bool,
max_retries: int,
) -> dict[str, Any]:
if cache_path.exists() and not force:
return json.loads(cache_path.read_text(encoding="utf-8"))
if not secret_id or not secret_key:
raise RuntimeError(f"OCR缓存不存在且当前为仅重建模式: {cache_path}")
image_base64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
payload = {"ImageBase64": image_base64, "UseNewModel": True}
if action == "GeneralAccurateOCR":
payload = {"ImageBase64": image_base64}
last_error: str | None = None
for attempt in range(max_retries + 1):
try:
with wall_clock_timeout(timeout):
data = tc3_request(action, payload, secret_id, secret_key, region, timeout)
response = data.get("Response", {})
if "Error" in response:
error_text = json.dumps(response["Error"], ensure_ascii=False)
if response["Error"].get("Code") == "FailedOperation.OcrFailed":
raise NonRetryableOcrError(error_text)
raise RuntimeError(error_text)
response.pop("Data", None)
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps(response, ensure_ascii=False, indent=2), encoding="utf-8")
return response
except NonRetryableOcrError:
raise
except (urllib.error.URLError, TimeoutError, OSError, RuntimeError) as exc:
last_error = str(exc)
if attempt >= max_retries:
break
time.sleep(2 ** attempt)
raise RuntimeError(f"OCR 调用失败: {image_path} {last_error}")
def call_table_v3(
image_path: Path,
cache_path: Path,
secret_id: str,
secret_key: str,
region: str,
timeout: int,
force: bool,
max_retries: int,
) -> dict[str, Any]:
return call_tencent_ocr(
"RecognizeTableAccurateOCR",
image_path,
cache_path,
secret_id,
secret_key,
region,
timeout,
force,
max_retries,
)
def call_general_accurate(
image_path: Path,
cache_path: Path,
secret_id: str,
secret_key: str,
region: str,
timeout: int,
force: bool,
max_retries: int,
) -> dict[str, Any]:
return call_tencent_ocr(
"GeneralAccurateOCR",
image_path,
cache_path,
secret_id,
secret_key,
region,
timeout,
force,
max_retries,
)
def cells_to_rows(table_response: dict[str, Any]) -> list[list[str]]:
tables = table_response.get("TableDetections") or []
cells: list[dict[str, Any]] = []
for table in tables:
cells.extend(table.get("Cells") or [])
if not cells:
return []
max_row = max(int(cell.get("RowTl", 0)) for cell in cells)
max_col = max(int(cell.get("ColTl", 0)) for cell in cells)
rows = [["" for _ in range(max(max_col + 1, len(COLUMNS)))] for _ in range(max_row + 1)]
for cell in cells:
row = int(cell.get("RowTl", 0))
col = int(cell.get("ColTl", 0))
text = normalize_text(cell.get("Text", ""))
if col >= len(rows[row]):
rows[row].extend([""] * (col - len(rows[row]) + 1))
if rows[row][col]:
rows[row][col] = normalize_text(rows[row][col] + " " + text)
else:
rows[row][col] = text
return [row[: len(COLUMNS)] + [""] * max(0, len(COLUMNS) - len(row)) for row in rows]
def general_column_index(x: float, image_width: int) -> int:
scale = image_width / GENERAL_REFERENCE_WIDTH if image_width else 1.0
anchors = [value * scale for value in GENERAL_COLUMN_ANCHORS]
boundaries = [(anchors[index] + anchors[index + 1]) / 2 for index in range(len(anchors) - 1)]
for index, boundary in enumerate(boundaries):
if x < boundary:
return index
return len(anchors) - 1
def general_detections_to_rows(general_response: dict[str, Any], image_width: int) -> list[list[str]]:
detections: list[dict[str, Any]] = []
for item in general_response.get("TextDetections") or []:
text = normalize_text(item.get("DetectedText", ""))
if not text:
continue
polygon = item.get("ItemPolygon") or {}
x = float(polygon.get("X", 0) or 0)
y = float(polygon.get("Y", 0) or 0)
height = float(polygon.get("Height", 0) or 0)
detections.append({"x": x, "y_center": y + height / 2, "text": text})
detections.sort(key=lambda item: (item["y_center"], item["x"]))
grouped_rows: list[dict[str, Any]] = []
for detection in detections:
if not grouped_rows or abs(detection["y_center"] - grouped_rows[-1]["y_center"]) > GENERAL_ROW_Y_THRESHOLD:
grouped_rows.append({"y_center": detection["y_center"], "items": []})
else:
count = len(grouped_rows[-1]["items"])
grouped_rows[-1]["y_center"] = (grouped_rows[-1]["y_center"] * count + detection["y_center"]) / (count + 1)
grouped_rows[-1]["items"].append(detection)
rows: list[list[str]] = []
for grouped_row in grouped_rows:
columns: list[list[str]] = [[] for _ in COLUMNS]
for detection in sorted(grouped_row["items"], key=lambda item: item["x"]):
columns[general_column_index(detection["x"], image_width)].append(detection["text"])
rows.append([normalize_text(" ".join(values)) for values in columns])
return rows
def ocr_response_to_rows(response: dict[str, Any], engine: str, image_width: int) -> list[list[str]]:
if engine == "general-accurate":
return general_detections_to_rows(response, image_width)
return cells_to_rows(response)
def normalize_date(text: str) -> str:
text = normalize_text(text).replace("/", "-").replace(".", "-")
text = re.sub(r"(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})", date_repl, text)
return text
def date_repl(match: re.Match[str]) -> str:
year, month, day, hour, minute, second = match.groups()
return f"{int(year):04d}-{int(month):02d}-{int(day):02d} {int(hour):02d}:{int(minute):02d}:{int(second):02d}"
def clean_patient_row(row: list[str]) -> dict[str, Any]:
values = {column: normalize_text(row[index]) for index, column in enumerate(COLUMNS)}
if values["姓名"] and not values["性别"]:
name_gender = re.fullmatch(r"(.+?)(男|女)", values["姓名"])
if name_gender:
values["姓名"] = normalize_text(name_gender.group(1))
values["性别"] = name_gender.group(2)
if values["性别"] not in {"", ""}:
sex_age = re.fullmatch(r"(男|女)\s*(\d{1,3}岁)", values["性别"])
if sex_age:
values["性别"] = sex_age.group(1)
if not values["年龄"]:
values["年龄"] = sex_age.group(2)
values["住院号"] = re.sub(r"\s+", "", values["住院号"]).upper()
if values["住院号"].startswith("ZV"):
values["住院号"] = "ZY" + values["住院号"][2:]
if values["住院号"].startswith("ZYS"):
values["住院号"] = "ZY5" + values["住院号"][3:]
if len(values["住院号"]) > 2:
prefix, number = values["住院号"][:2], values["住院号"][2:]
values["住院号"] = prefix + number.translate(str.maketrans({"O": "0", "I": "1", "L": "1", "S": "5"}))
def looks_datetime(value: Any) -> bool:
return bool(
re.fullmatch(
r"\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2}",
str(value),
)
)
def looks_days(value: Any) -> bool:
return bool(re.fullmatch(r"\d+", str(value)))
def looks_postop(value: Any) -> bool:
return bool(re.fullmatch(r"\d+天", str(value)))
values["入院时间"] = normalize_date(values["入院时间"])
values["最后书写时间"] = normalize_date(values["最后书写时间"])
values["出院时间"] = normalize_date(values["出院时间"])
shifted_by_long_diagnosis = re.search(
r"(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2})$",
values["诊断"],
)
if (
shifted_by_long_diagnosis
and looks_datetime(values["入院时间"])
and looks_days(values["最后书写时间"])
and (not values["住院天数"] or looks_datetime(values["住院天数"]) or looks_postop(values["住院天数"]))
):
shifted_admission = normalize_date(shifted_by_long_diagnosis.group(1))
old_last_write = values["入院时间"]
old_hospital_days = values["最后书写时间"]
old_discharge_or_postop = values["住院天数"]
old_postop = values["出院时间"]
values["诊断"] = values["诊断"][: shifted_by_long_diagnosis.start()].strip()
values["入院时间"] = shifted_admission
values["最后书写时间"] = normalize_date(old_last_write)
values["住院天数"] = old_hospital_days
values["出院时间"] = normalize_date(old_discharge_or_postop) if looks_datetime(old_discharge_or_postop) else ""
if not values["手术后天数"]:
if looks_postop(old_discharge_or_postop):
values["手术后天数"] = old_discharge_or_postop
elif looks_postop(old_postop):
values["手术后天数"] = old_postop
if shifted_by_long_diagnosis and not values["入院时间"]:
values["诊断"] = values["诊断"][: shifted_by_long_diagnosis.start()].strip()
values["入院时间"] = normalize_date(shifted_by_long_diagnosis.group(1))
days_with_discharge = re.fullmatch(
r"(\d+)\s+(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2})",
str(values["住院天数"]),
)
if days_with_discharge:
values["住院天数"] = days_with_discharge.group(1)
if not values["出院时间"]:
values["出院时间"] = normalize_date(days_with_discharge.group(2))
if looks_postop(values["出院时间"]) and not values["手术后天数"]:
values["手术后天数"] = values["出院时间"]
values["出院时间"] = ""
if looks_postop(values["住院天数"]) and not values["手术后天数"]:
values["手术后天数"] = values["住院天数"]
values["住院天数"] = ""
if looks_days(values["最后书写时间"]) and looks_datetime(values["住院天数"]):
old_days = values["最后书写时间"]
old_discharge = values["住院天数"]
old_postop = values["出院时间"]
values["最后书写时间"] = ""
values["住院天数"] = old_days
values["出院时间"] = normalize_date(old_discharge)
if looks_postop(old_postop) and not values["手术后天数"]:
values["手术后天数"] = old_postop
elif looks_datetime(values["住院天数"]) and (not values["出院时间"] or looks_postop(values["出院时间"])):
old_discharge = values["住院天数"]
old_postop = values["出院时间"]
values["住院天数"] = ""
values["出院时间"] = normalize_date(old_discharge)
if looks_postop(old_postop) and not values["手术后天数"]:
values["手术后天数"] = old_postop
last_write_with_postop = re.fullmatch(r"(.+?)\s+(后\d+天)", values["最后书写时间"])
if last_write_with_postop and not values["手术后天数"]:
values["最后书写时间"] = last_write_with_postop.group(1)
values["手术后天数"] = last_write_with_postop.group(2)
if re.fullmatch(r"\d+天", values["最后书写时间"]) and not values["手术后天数"]:
values["手术后天数"] = values["最后书写时间"]
values["最后书写时间"] = ""
if values["住院天数"].isdigit():
values["住院天数"] = int(values["住院天数"])
return values
def validate_patient_row(values: dict[str, Any]) -> list[str]:
warnings: list[str] = []
if not values.get("姓名"):
warnings.append("缺少姓名")
if values.get("性别") not in {"", ""}:
warnings.append("性别异常")
if values.get("年龄") and not re.fullmatch(r"\d{1,3}岁", str(values["年龄"])):
warnings.append("年龄格式异常")
if not normalize_text(values.get("住院号", "")):
warnings.append("缺少住院号")
if not values.get("入院时间"):
warnings.append("缺少入院时间")
elif not re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(values["入院时间"])):
warnings.append("入院时间格式异常")
if values.get("出院时间") and not re.fullmatch(
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(values["出院时间"])
):
warnings.append("出院时间格式异常")
if (
re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(values.get("入院时间", "")))
and re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(values.get("出院时间", "")))
and str(values["入院时间"]) > str(values["出院时间"])
):
warnings.append("出院时间早于入院时间")
if values.get("最后书写时间") and not re.fullmatch(
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", str(values["最后书写时间"])
):
warnings.append("最后书写时间格式异常")
if values.get("住院天数") != "" and not isinstance(values.get("住院天数"), int):
warnings.append("住院天数格式异常")
return warnings
def is_blank_or_footer(row: dict[str, Any]) -> bool:
if row.get("住院号"):
return False
filled = [value for value in row.values() if value not in ("", None)]
return len(filled) == 0
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def write_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as file:
for record in records:
file.write(json.dumps(record, ensure_ascii=False) + "\n")
def write_csv(path: Path, records: list[dict[str, Any]]) -> None:
fieldnames = [
"大科室",
"子科室",
"来源文件夹",
"图片名",
"图片内行号",
*COLUMNS,
"复核状态",
"复核提示",
]
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
for record in records:
patient = record["患者信息"]
writer.writerow(
{
"大科室": record["大科室"],
"子科室": record["子科室"],
"来源文件夹": record["来源文件夹"],
"图片名": record["图片信息"]["图片名"],
"图片内行号": record["图片信息"]["图片内行号"],
**patient,
"复核状态": record["复核"]["状态"],
"复核提示": "".join(record["复核"]["提示"]),
}
)
def load_corrections(path: Path) -> dict[tuple[str, int], dict[str, Any]]:
if not path.exists():
return {}
data = json.loads(path.read_text(encoding="utf-8"))
corrections: dict[tuple[str, int], dict[str, Any]] = {}
for item in data:
image_path = item["图片路径"]
row_no = int(item["图片内行号"])
corrections[(image_path, row_no)] = item
for prefix in ["已处理-患者目录图片集群/", "待处理-患者目录图片集群/"]:
corrections[(prefix + image_path, row_no)] = item
return corrections
def apply_review_options(warnings: list[str], correction: dict[str, Any]) -> list[str]:
return warnings
def apply_corrections(records: list[dict[str, Any]], corrections: dict[tuple[str, int], dict[str, Any]]) -> None:
for record in records:
key = (record["图片信息"]["图片路径"], int(record["图片信息"]["图片内行号"]))
if key not in corrections:
record["复核"]["提示"] = validate_patient_row(record["患者信息"])
record["复核"]["状态"] = "需人工复核" if record["复核"]["提示"] else "自动复核通过"
continue
correction = corrections[key]
record["患者信息"].update(correction.get("患者信息", {}))
record["复核"]["人工修正"] = True
record["复核"]["复核选项"] = correction.get("复核选项", {})
if correction.get("复核备注"):
record["复核"]["人工备注"] = correction.get("复核备注", "")
record["复核"]["提示"] = apply_review_options(validate_patient_row(record["患者信息"]), correction)
record["复核"]["状态"] = "需人工复核" if record["复核"]["提示"] else "人工复核通过"
def record_quality_rank(record: dict[str, Any]) -> tuple[int, int, int, int]:
patient = record["患者信息"]
review = record["复核"]
review_ok = 0 if review.get("状态") == "需人工复核" else 1
manual_corrected = 1 if review.get("人工修正") else 0
date_count = int(bool(patient.get("入院时间"))) + int(bool(patient.get("出院时间")))
filled_count = sum(1 for column in COLUMNS if patient.get(column) not in ("", None))
return (review_ok, manual_corrected, date_count, filled_count)
def summarize_record_for_duplicate(record: dict[str, Any]) -> dict[str, Any]:
patient = record["患者信息"]
image = record["图片信息"]
return {
"大科室": record["大科室"],
"子科室": record["子科室"],
"来源文件夹": record["来源文件夹"],
"图片路径": image["图片路径"],
"图片名": image["图片名"],
"图片内行号": image["图片内行号"],
"姓名": patient.get("姓名", ""),
"住院号": patient.get("住院号", ""),
"入院时间": patient.get("入院时间", ""),
"出院时间": patient.get("出院时间", ""),
"复核状态": record["复核"].get("状态", ""),
}
def deduplicate_records_by_inpatient_no(
records: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
kept_by_no: dict[str, dict[str, Any]] = {}
kept_order: list[str] = []
dropped: list[dict[str, Any]] = []
for record in records:
inpatient_no = normalize_text(record["患者信息"].get("住院号", ""))
if not inpatient_no:
dropped.append(
{
"住院号": "",
"保留记录": {},
"剔除记录": summarize_record_for_duplicate(record),
"规则": "住院号为空,未纳入归档结果和数据库",
}
)
continue
if inpatient_no not in kept_by_no:
kept_order.append(inpatient_no)
else:
dropped.append(
{
"住院号": inpatient_no,
"保留记录": summarize_record_for_duplicate(record),
"剔除记录": summarize_record_for_duplicate(kept_by_no[inpatient_no]),
"规则": "住院号重复,后出现记录覆盖先出现记录",
}
)
kept_by_no[inpatient_no] = record
return [kept_by_no[inpatient_no] for inpatient_no in kept_order], dropped
def process_folder(
folder: Path,
output_root: Path,
department: Department,
args: argparse.Namespace,
secret_id: str,
secret_key: str,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
images = sorted(
[path for path in folder.iterdir() if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS],
key=natural_key,
)
folder_key = folder.name
composites_dir = output_root / "merged_images" / folder_key
raw_dir = output_root / "raw_ocr" / folder_key
records: list[dict[str, Any]] = []
folder_warnings: list[str] = []
group_infos: list[dict[str, Any]] = []
def cache_label(label: str) -> str:
if args.ocr_engine != "table-v3":
label = f"{args.ocr_engine.replace('-', '_')}_{label}"
if args.image_padding_y > 0:
return f"{label}_pady{args.image_padding_y}"
return label
def records_from_rows(
rows: list[list[str]],
group_paths: list[Path],
group_index_value: int,
composite_path_value: Path,
cache_path_value: Path,
request_id: str | None,
) -> list[dict[str, Any]]:
built_records: list[dict[str, Any]] = []
row_counts = [infer_rows_for_image(path, args.rows_per_image) for path in group_paths]
for table_row_index, row in enumerate(rows):
patient = clean_patient_row(row)
if is_blank_or_footer(patient):
continue
source_index, image_row = locate_source_row(table_row_index, row_counts)
source_path = group_paths[source_index]
warnings = validate_patient_row(patient)
record = {
"大科室": department.major,
"子科室": department.sub,
"来源文件夹": folder.name,
"标准化文件夹科室名": department.raw_name,
"患者信息": patient,
"图片信息": {
"图片路径": str(source_path),
"图片名": source_path.name,
"图片序号": natural_key(source_path),
"图片内行号": image_row + 1,
"拼接组序号": group_index_value,
"拼接图片路径": str(composite_path_value),
"OCR缓存路径": str(cache_path_value),
"OCR请求ID": request_id,
},
"复核": {
"状态": "需人工复核" if warnings else "自动复核通过",
"提示": warnings,
},
}
built_records.append(record)
return built_records
def expected_row_count(group_paths: list[Path]) -> int:
return sum(infer_rows_for_image(path, args.rows_per_image) for path in group_paths)
def attempt_ocr_group(
group_paths: list[Path],
group_index_value: int,
label: str,
display_label: str,
prebuilt_merge_info: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
composite_path = composites_dir / f"{label}.png"
merge_info = prebuilt_merge_info or merge_images(group_paths, composite_path, args.image_padding_y)
cache_path = raw_dir / f"{label}.json"
print(f" {display_label}: {len(group_paths)} images -> {composite_path}", flush=True)
if args.ocr_engine == "general-accurate":
response = call_general_accurate(
composite_path,
cache_path,
secret_id,
secret_key,
args.region,
args.timeout,
args.force,
args.max_retries,
)
else:
response = call_table_v3(
composite_path,
cache_path,
secret_id,
secret_key,
args.region,
args.timeout,
args.force,
args.max_retries,
)
rows = ocr_response_to_rows(response, args.ocr_engine, int(merge_info.get("width", 0)))
expected_rows = expected_row_count(group_paths)
if len(group_paths) > 1 and len(rows) < expected_rows:
raise RuntimeError(f"识别行数偏少: {len(rows)} / {expected_rows}")
request_id = response.get("RequestId")
print(f" rows: {len(rows)} request: {request_id}", flush=True)
info = {
"label": label,
"merged_image": merge_info,
"ocr_cache": str(cache_path),
"ocr_request_id": request_id,
"row_count": len(rows),
"expected_row_count": expected_rows,
"image_count": len(group_paths),
}
return info, records_from_rows(rows, group_paths, group_index_value, composite_path, cache_path, request_id)
def run_chunked_fallback(
group: list[Path],
group_index_value: int,
main_label: str,
fallback_size: int,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
candidate_infos: list[dict[str, Any]] = []
candidate_records: list[dict[str, Any]] = []
chunks = list(enumerate(batched(group, fallback_size)))
def run_chunk(item: tuple[int, list[Path]]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
chunk_index, chunk = item
chunk_label = f"{main_label}_fallback{fallback_size}_{chunk_index:02d}"
chunk_info, chunk_records = attempt_ocr_group(
chunk,
group_index_value,
chunk_label,
chunk_label,
)
chunk_info["part_index"] = chunk_index
return chunk_info, chunk_records
if args.workers > 1 and len(chunks) > 1:
with concurrent.futures.ThreadPoolExecutor(max_workers=min(args.workers, len(chunks))) as executor:
results = list(executor.map(run_chunk, chunks))
else:
results = [run_chunk(item) for item in chunks]
for chunk_info, chunk_records in results:
candidate_infos.append(chunk_info)
candidate_records.extend(chunk_records)
return candidate_infos, candidate_records
def single_cache_paths(main_label: str, group: list[Path]) -> list[Path]:
return [raw_dir / f"{main_label}_part_{part_index:02d}.json" for part_index in range(len(group))]
def chunk_cache_paths(main_label: str, group: list[Path], fallback_size: int) -> list[Path]:
chunks = batched(group, fallback_size)
return [raw_dir / f"{main_label}_fallback{fallback_size}_{chunk_index:02d}.json" for chunk_index in range(len(chunks))]
def run_single_fallback(
group: list[Path],
group_index_value: int,
main_label: str,
main_merge_info: dict[str, Any],
main_cache_path: Path,
initial_error: str,
fallback_errors: list[str],
adaptive_from_previous: bool = False,
) -> None:
print(f" 尝试单张OCR回退", flush=True)
single_infos: list[dict[str, Any]] = []
for part_index, single_path in enumerate(group):
single_label = f"{main_label}_part_{part_index:02d}"
single_composite_path = composites_dir / f"{single_label}.png"
single_cache_path = raw_dir / f"{single_label}.json"
single_merge_info = merge_images([single_path], single_composite_path, args.image_padding_y)
try:
single_info, single_records = attempt_ocr_group(
[single_path],
group_index_value,
single_label,
single_label,
single_merge_info,
)
single_info["part_index"] = part_index
records.extend(single_records)
single_infos.append(single_info)
except RuntimeError as single_exc:
single_message = f"{single_label} OCR失败: {single_exc}"
print(f" {single_message}", flush=True)
folder_warnings.append(single_message)
single_infos.append(
{
"label": single_label,
"part_index": part_index,
"merged_image": single_merge_info,
"ocr_cache": str(single_cache_path),
"ocr_request_id": None,
"row_count": 0,
"expected_row_count": expected_row_count([single_path]),
"image_count": 1,
"error": str(single_exc),
}
)
group_infos.append(
{
"group_index": group_index_value,
"label": main_label,
"merged_image": main_merge_info,
"ocr_cache": str(main_cache_path),
"ocr_request_id": None,
"row_count": sum(item["row_count"] for item in single_infos),
"expected_row_count": expected_row_count(group),
"fallback": True,
"fallback_strategy": "single_image",
"fallback_parts": single_infos,
"initial_error": initial_error,
"fallback_errors": fallback_errors,
"adaptive_from_previous": adaptive_from_previous,
}
)
groups = batched(images, args.batch_size)
if args.limit_groups_per_folder:
groups = groups[: args.limit_groups_per_folder]
preferred_fallback_size: int | None = None
preferred_fallback_failures = 0
for group_index, group in enumerate(groups):
main_label = cache_label(f"group_{group_index:04d}")
main_composite_path = composites_dir / f"{main_label}.png"
main_cache_path = raw_dir / f"{main_label}.json"
main_merge_info = merge_images(group, main_composite_path, args.image_padding_y)
part_cache_paths = single_cache_paths(main_label, group)
if not main_cache_path.exists() and all(path.exists() for path in part_cache_paths):
message = f"{main_label} 使用已有单图缓存重建"
print(f" {message}", flush=True)
run_single_fallback(
group,
group_index,
main_label,
main_merge_info,
main_cache_path,
message,
[],
adaptive_from_previous=True,
)
time.sleep(args.sleep)
continue
if preferred_fallback_size and len(group) > 1:
use_preferred_fallback = True
if args.rebuild_from_cache:
if preferred_fallback_size == 1:
preferred_paths = single_cache_paths(main_label, group)
else:
preferred_paths = chunk_cache_paths(main_label, group, int(preferred_fallback_size))
use_preferred_fallback = all(path.exists() for path in preferred_paths)
if not use_preferred_fallback:
print(f" {main_label} 当前回退缓存不完整,先尝试主缓存", flush=True)
if not use_preferred_fallback:
preferred_fallback_size = None
elif preferred_fallback_size == 1:
message = f"{main_label} 依据前序组结果直接使用单张OCR"
print(f" {message}", flush=True)
run_single_fallback(
group,
group_index,
main_label,
main_merge_info,
main_cache_path,
message,
[],
adaptive_from_previous=True,
)
time.sleep(args.sleep)
continue
elif 1 < preferred_fallback_size < len(group):
message = f"{main_label} 依据前序组结果,直接使用 {preferred_fallback_size} 张拼接"
print(f" {message}", flush=True)
try:
candidate_infos, candidate_records = run_chunked_fallback(
group,
group_index,
main_label,
int(preferred_fallback_size),
)
records.extend(candidate_records)
group_infos.append(
{
"group_index": group_index,
"label": main_label,
"merged_image": main_merge_info,
"ocr_cache": str(main_cache_path),
"ocr_request_id": None,
"row_count": sum(item["row_count"] for item in candidate_infos),
"expected_row_count": expected_row_count(group),
"fallback": True,
"fallback_strategy": f"{preferred_fallback_size}_images",
"fallback_parts": candidate_infos,
"initial_error": message,
"fallback_errors": [],
"adaptive_from_previous": True,
}
)
time.sleep(args.sleep)
preferred_fallback_failures = 0
continue
except RuntimeError as adaptive_exc:
failed_size = int(preferred_fallback_size)
preferred_fallback_failures += 1
adaptive_message = f"{main_label} 前序 {failed_size} 张策略未通过本组改用单张OCR: {adaptive_exc}"
print(f" {adaptive_message}", flush=True)
folder_warnings.append(adaptive_message)
if preferred_fallback_failures >= args.fallback_demote_threshold:
preferred_fallback_size = 1
demote_message = (
f"{main_label} 连续 {preferred_fallback_failures} 个拼接组未通过,"
"后续直接使用单张OCR"
)
print(f" {demote_message}", flush=True)
folder_warnings.append(demote_message)
else:
preferred_fallback_size = failed_size
run_single_fallback(
group,
group_index,
main_label,
main_merge_info,
main_cache_path,
adaptive_message,
[],
adaptive_from_previous=True,
)
time.sleep(args.sleep)
continue
try:
main_info, main_records = attempt_ocr_group(
group,
group_index,
main_label,
main_label,
main_merge_info,
)
main_info.update(
{
"group_index": group_index,
"fallback": False,
"fallback_strategy": None,
}
)
group_infos.append(main_info)
records.extend(main_records)
except RuntimeError as exc:
if len(group) == 1:
message = f"{main_label} OCR失败: {exc}"
print(f" {message}", flush=True)
folder_warnings.append(message)
group_infos.append(
{
"group_index": group_index,
"label": main_label,
"merged_image": main_merge_info,
"ocr_cache": str(main_cache_path),
"ocr_request_id": None,
"row_count": 0,
"expected_row_count": expected_row_count(group),
"fallback": False,
"fallback_strategy": None,
"error": str(exc),
}
)
time.sleep(args.sleep)
continue
message = f"{main_label} {len(group)}张拼接OCR未通过开始降档: {exc}"
print(f" {message}", flush=True)
folder_warnings.append(message)
fallback_errors: list[str] = []
fallback_success = False
for fallback_size in (4, 3, 2):
if not 1 < fallback_size < len(group):
continue
print(f" 尝试 {fallback_size} 张拼接回退", flush=True)
try:
candidate_infos, candidate_records = run_chunked_fallback(group, group_index, main_label, fallback_size)
except RuntimeError as fallback_exc:
fallback_message = f"{main_label} {fallback_size}张拼接仍未通过: {fallback_exc}"
print(f" {fallback_message}", flush=True)
folder_warnings.append(fallback_message)
fallback_errors.append(fallback_message)
continue
records.extend(candidate_records)
preferred_fallback_size = fallback_size
preferred_fallback_failures = 0
group_infos.append(
{
"group_index": group_index,
"label": main_label,
"merged_image": main_merge_info,
"ocr_cache": str(main_cache_path),
"ocr_request_id": None,
"row_count": sum(item["row_count"] for item in candidate_infos),
"expected_row_count": expected_row_count(group),
"fallback": True,
"fallback_strategy": f"{fallback_size}_images",
"fallback_parts": candidate_infos,
"initial_error": str(exc),
"fallback_errors": fallback_errors,
}
)
fallback_success = True
break
if fallback_success:
time.sleep(args.sleep)
continue
preferred_fallback_size = 1
run_single_fallback(group, group_index, main_label, main_merge_info, main_cache_path, str(exc), fallback_errors)
time.sleep(args.sleep)
summary = {
"来源文件夹": folder.name,
"大科室": department.major,
"子科室": department.sub,
"图片数": len(images),
"记录数": len(records),
"拼接组数": len(group_infos),
"拼接组": group_infos,
"提示": folder_warnings,
}
return records, summary
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", default="列表", help="患者列表图片根目录")
parser.add_argument("--output", default="数据处理工作区/列表归档结果", help="输出目录")
parser.add_argument("--departments", default="数据处理工作区/01_科室分类规则.json", help="科室分类 JSON")
parser.add_argument("--batch-size", type=int, default=3, help="每次拼接图片数量,建议 3 或 4")
parser.add_argument("--image-padding-y", type=int, default=24, help="每张图拼接前添加的上下白色边界像素数")
parser.add_argument("--rows-per-image", type=int, default=0, help="每张 HIS 列表截图的行数0 表示按图片高度自动推断")
parser.add_argument("--corrections", default="数据处理工作区/03_人工复核修正.json", help="人工复核修正 JSON")
parser.add_argument(
"--ocr-engine",
choices=sorted(OCR_ENGINE_LABELS),
default="table-v3",
help="OCR 引擎table-v3 表格识别 V3",
)
parser.add_argument("--region", default="ap-shanghai", help="腾讯云 OCR 地域")
parser.add_argument("--timeout", type=int, default=60, help="单次 OCR 超时秒数")
parser.add_argument("--sleep", type=float, default=0.2, help="OCR 调用间隔秒数")
parser.add_argument("--max-retries", type=int, default=0, help="OCR 调用失败重试次数")
parser.add_argument(
"--fallback-demote-threshold",
type=int,
default=3,
help="同一科室连续多少个拼接组失败后才把后续组整体降为单张OCR",
)
parser.add_argument("--workers", type=int, default=1, help="同一拼接组内并发OCR分片数建议 1-2")
parser.add_argument("--folder-workers", type=int, default=1, help="并发处理科室目录数OCR接口稳定时可设为 2-4")
parser.add_argument("--force", action="store_true", help="忽略 OCR 缓存重新识别")
parser.add_argument("--limit-folders", type=int, default=0, help="调试用:只处理前 N 个科室目录")
parser.add_argument("--limit-groups-per-folder", type=int, default=0, help="调试用:每个科室只处理前 N 个拼接组")
parser.add_argument("--rebuild-from-cache", action="store_true", help="只用已有 OCR 缓存重建结果,不发起 OCR 请求")
return parser.parse_args()
def main() -> None:
args = parse_args()
if args.batch_size < 1:
raise ValueError("--batch-size 必须大于 0")
if args.workers < 1:
raise ValueError("--workers 必须大于 0")
if args.folder_workers < 1:
raise ValueError("--folder-workers 必须大于 0")
if args.image_padding_y < 0:
raise ValueError("--image-padding-y 不能小于 0")
input_root = Path(args.input)
output_root = Path(args.output)
sub_to_major, aliases = load_departments(Path(args.departments))
if args.rebuild_from_cache:
secret_id, secret_key = "", ""
else:
secret_id, secret_key = get_credentials()
corrections = load_corrections(Path(args.corrections))
folders = sorted([path for path in input_root.iterdir() if path.is_dir()], key=lambda path: path.name)
if args.limit_folders:
folders = folders[: args.limit_folders]
all_records: list[dict[str, Any]] = []
folder_summaries: list[dict[str, Any]] = []
classifications: list[dict[str, Any]] = []
folder_jobs: list[tuple[Path, Department]] = []
for folder in folders:
department = classify_department(folder.name, sub_to_major, aliases)
classifications.append(
{
"来源文件夹": folder.name,
"标准化文件夹科室名": department.raw_name,
"大科室": department.major,
"子科室": department.sub,
}
)
folder_jobs.append((folder, department))
def run_folder_job(item: tuple[Path, Department]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
folder, department = item
print(f"[{folder.name}] -> {department.major} / {department.sub}", flush=True)
return process_folder(folder, output_root, department, args, secret_id, secret_key)
if args.folder_workers > 1 and len(folder_jobs) > 1:
with concurrent.futures.ThreadPoolExecutor(max_workers=min(args.folder_workers, len(folder_jobs))) as executor:
folder_results = list(executor.map(run_folder_job, folder_jobs))
else:
folder_results = [run_folder_job(item) for item in folder_jobs]
for records, summary in folder_results:
all_records.extend(records)
folder_summaries.append(summary)
apply_corrections(all_records, corrections)
record_count_before_dedup = len(all_records)
all_records, duplicate_records = deduplicate_records_by_inpatient_no(all_records)
issue_records = [record for record in all_records if record["复核"]["状态"] == "需人工复核"]
corrected_records = [record for record in all_records if record["复核"].get("人工修正")]
archive = {
"生成时间": dt.datetime.now().isoformat(timespec="seconds"),
"输入目录": str(input_root),
"OCR引擎": OCR_ENGINE_LABELS[args.ocr_engine],
"拼接设置": {
"batch_size": args.batch_size,
"rows_per_image": args.rows_per_image,
"image_padding_y": args.image_padding_y,
},
"科室归类": classifications,
"汇总": {
"科室目录数": len(folders),
"图片数": sum(item["图片数"] for item in folder_summaries),
"去重前患者记录数": record_count_before_dedup,
"患者记录数": len(all_records),
"需人工复核记录数": len(issue_records),
"人工修正记录数": len(corrected_records),
"重复住院号剔除记录数": len(duplicate_records),
},
"科室汇总": folder_summaries,
"重复住院号剔除记录": duplicate_records,
"患者记录": all_records,
}
review_report = {
"生成时间": archive["生成时间"],
"汇总": archive["汇总"],
"需人工复核记录": issue_records,
"人工修正记录": corrected_records,
"重复住院号剔除记录": duplicate_records,
"科室级提示": [
{"来源文件夹": item["来源文件夹"], "提示": item["提示"]}
for item in folder_summaries
if item["提示"]
],
}
write_json(output_root / "列表_科室归类.json", classifications)
write_json(output_root / "患者列表_结构化.json", archive)
write_jsonl(output_root / "患者列表_记录.jsonl", all_records)
write_csv(output_root / "患者列表_记录.csv", all_records)
write_json(output_root / "复核报告.json", review_report)
write_json(output_root / "重复住院号报告.json", duplicate_records)
print(json.dumps(archive["汇总"], ensure_ascii=False, indent=2), flush=True)
if __name__ == "__main__":
main()