Files
PACS/UPP列表处理/数据处理工作区/02_图片表格OCR归档.py
2026-05-25 12:33:24 +08:00

881 lines
34 KiB
Python
Executable File
Raw Permalink 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 -*-
"""通用图片表格 OCR 归档脚本。
腾讯云密钥通过环境变量读取:
TENCENTCLOUD_SECRET_ID 和 TENCENTCLOUD_SECRET_KEY。
"""
from __future__ import annotations
import argparse
import base64
import csv
import datetime as dt
import hashlib
import hmac
import json
import math
import os
import re
import subprocess
import time
import unicodedata
import urllib.error
from pathlib import Path
from typing import Any
from PIL import Image
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
DEFAULT_CONFIG = Path("数据处理工作区/01_任务配置.json")
TEMPLATE_CONFIG = Path("数据处理工作区/01_任务配置.template.json")
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 natural_key(path: Path) -> tuple[Any, ...]:
parts = re.split(r"(\d+)", path.stem)
key: list[Any] = []
for part in parts:
key.append(int(part) if part.isdigit() else part)
return tuple(key)
def safe_filename(value: str) -> str:
value = normalize_text(value) or "root"
return re.sub(r'[\\/:*?"<>|]+', "_", value)
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
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 load_config(path: Path) -> dict[str, Any]:
if path.exists():
return read_json(path)
if TEMPLATE_CONFIG.exists():
print(f"配置不存在,暂用模板: {TEMPLATE_CONFIG}", flush=True)
return read_json(TEMPLATE_CONFIG)
raise FileNotFoundError(f"找不到配置文件: {path}")
def field_names(config: dict[str, Any]) -> list[str]:
fields = config.get("fields") or []
names = [normalize_text(field.get("name")) for field in fields if normalize_text(field.get("name"))]
if not names:
raise ValueError("配置 fields 不能为空")
return names
def list_images(folder: Path) -> list[Path]:
return sorted(
[path for path in folder.iterdir() if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS],
key=natural_key,
)
def find_source_folders(input_root: Path) -> list[Path]:
if list_images(input_root):
return [input_root]
folders = [path for path in input_root.iterdir() if path.is_dir() and list_images(path)]
if folders:
return sorted(folders, key=lambda item: natural_key(item))
nested = {path.parent for path in input_root.rglob("*") if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS}
return sorted(nested, key=lambda item: str(item))
def batched(items: list[Path], size: int) -> list[list[Path]]:
size = max(1, int(size))
return [items[index : index + size] for index in range(0, len(items), size)]
def merge_images(image_paths: list[Path], output_path: Path, padding_y: int) -> 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: list[dict[str, Any]] = []
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 read_credentials() -> tuple[str, str]:
secret_id = os.getenv("TENCENTCLOUD_SECRET_ID") or os.getenv("TENCENT_SECRET_ID") or ""
secret_key = os.getenv("TENCENTCLOUD_SECRET_KEY") or os.getenv("TENCENT_SECRET_KEY") or ""
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"))
def call_tencent_table_ocr(
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 read_json(cache_path)
if not secret_id or not secret_key:
raise RuntimeError(
f"OCR缓存不存在且未设置 TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY: {cache_path}"
)
image_base64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
payload = {"ImageBase64": image_base64, "UseNewModel": True}
last_error = ""
for attempt in range(max_retries + 1):
try:
data = tc3_request(
"RecognizeTableAccurateOCR",
payload,
secret_id,
secret_key,
region,
timeout,
)
response = data.get("Response", {})
if "Error" in response:
raise RuntimeError(json.dumps(response["Error"], ensure_ascii=False))
cache_path.parent.mkdir(parents=True, exist_ok=True)
write_json(cache_path, response)
return response
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 cells_to_rows(response: dict[str, Any], expected_columns: int) -> list[list[str]]:
cells: list[dict[str, Any]] = []
for table in response.get("TableDetections") or []:
cells.extend(table.get("Cells") or [])
if not cells:
return []
max_row = max(int(cell.get("RowTl", 0) or 0) for cell in cells)
max_col = max(int(cell.get("ColTl", 0) or 0) for cell in cells)
column_count = max(expected_columns, max_col + 1)
rows = [["" for _ in range(column_count)] for _ in range(max_row + 1)]
for cell in cells:
row_index = int(cell.get("RowTl", 0) or 0)
col_index = int(cell.get("ColTl", 0) or 0)
text = normalize_text(cell.get("Text", ""))
if rows[row_index][col_index]:
rows[row_index][col_index] = normalize_text(rows[row_index][col_index] + " " + text)
else:
rows[row_index][col_index] = text
return [row[:expected_columns] + [""] * max(0, expected_columns - len(row)) for row in rows]
def looks_like_header(row: list[str], fields: list[str]) -> bool:
normalized_row = [normalize_text(item) for item in row]
hits = sum(1 for field in fields if field in normalized_row)
return hits >= max(2, math.ceil(len(fields) * 0.5))
def normalize_date_like(text: str, target_type: str) -> str:
text = normalize_text(text).replace("/", "-").replace(".", "-")
match = re.fullmatch(
r"(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?",
text,
)
if not match:
return text
year, month, day, hour, minute, second = match.groups()
date_part = f"{int(year):04d}-{int(month):02d}-{int(day):02d}"
if target_type == "date":
return date_part
if hour is None:
return date_part
return f"{date_part} {int(hour):02d}:{int(minute or 0):02d}:{int(second or 0):02d}"
def clean_field_value(value: Any, field: dict[str, Any]) -> Any:
text = normalize_text(value)
for rule in field.get("clean") or []:
if rule == "remove_spaces":
text = re.sub(r"\s+", "", text)
elif rule == "upper":
text = text.upper()
elif rule == "lower":
text = text.lower()
elif rule == "strip":
text = text.strip()
field_type = normalize_text(field.get("type") or "text").lower()
if field_type in {"date", "datetime"}:
text = normalize_date_like(text, field_type)
if field_type == "integer" and re.fullmatch(r"\d+", text):
return int(text)
if field_type == "number":
try:
return float(text) if text else ""
except ValueError:
return text
return text
def row_to_record_info(row: list[str], fields: list[dict[str, Any]]) -> dict[str, Any]:
values: dict[str, Any] = {}
for index, field in enumerate(fields):
name = normalize_text(field.get("name"))
values[name] = clean_field_value(row[index] if index < len(row) else "", field)
return values
def validate_record_info(
record_info: dict[str, Any],
fields: list[dict[str, Any]],
unique_key: str,
) -> list[str]:
warnings: list[str] = []
for field in fields:
name = normalize_text(field.get("name"))
value = record_info.get(name, "")
text = normalize_text(value)
field_type = normalize_text(field.get("type") or "text").lower()
if field.get("required") and not text:
warnings.append(f"缺少{name}")
if text and field_type == "integer" and not isinstance(value, int):
warnings.append(f"{name}非整数")
if text and field_type == "number":
try:
float(text)
except ValueError:
warnings.append(f"{name}非数字")
if text and field_type == "date" and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
warnings.append(f"{name}日期格式异常")
if text and field_type == "datetime" and not re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", text):
warnings.append(f"{name}时间格式异常")
pattern = normalize_text(field.get("pattern"))
if text and pattern and not re.fullmatch(pattern, text):
warnings.append(f"{name}格式不符合规则")
if unique_key and not normalize_text(record_info.get(unique_key, "")):
warnings.append(f"缺少主唯一键: {unique_key}")
return warnings
def is_blank_record(record_info: dict[str, Any]) -> bool:
return all(value in ("", None) for value in record_info.values())
def classify_folder(folder: Path, config: dict[str, Any]) -> dict[str, str]:
classification = config.get("classification") or {}
default = classification.get("default") or {}
result = {
"业务分类1": normalize_text(default.get("业务分类1") or "未分类"),
"业务分类2": normalize_text(default.get("业务分类2") or "未分类"),
}
if not classification.get("enabled", True):
return result
folder_name = normalize_text(folder.name)
for rule in classification.get("folder_rules") or []:
contains = normalize_text(rule.get("contains"))
equals = normalize_text(rule.get("equals"))
if (contains and contains in folder_name) or (equals and equals == folder_name):
result["业务分类1"] = normalize_text(rule.get("业务分类1") or result["业务分类1"])
result["业务分类2"] = normalize_text(rule.get("业务分类2") or result["业务分类2"])
return result
return result
def infer_rows_for_image(image_path: Path, rows_per_image: int, row_height_px: float) -> int:
if rows_per_image > 0:
return rows_per_image
if row_height_px > 0:
with Image.open(image_path) as image:
return max(1, round(image.height / row_height_px))
return 0
def locate_source_row(
row_index: int,
row_counts: list[int],
total_rows: int,
image_count: int,
) -> tuple[int, int]:
if sum(row_counts) > 0:
offset = 0
for image_index, row_count in enumerate(row_counts):
if row_index < offset + row_count:
return image_index, row_index - offset
offset += row_count
return len(row_counts) - 1, max(0, row_index - sum(row_counts[:-1]))
rows_per_image = max(1, math.ceil(max(1, total_rows) / max(1, image_count)))
image_index = min(image_count - 1, row_index // rows_per_image)
return image_index, row_index - image_index * rows_per_image
def correction_keys(image_path: str, row_no: int) -> list[tuple[str, int]]:
path = normalize_text(image_path)
return [
(path, row_no),
(Path(path).name, row_no),
]
def load_corrections(path: Path) -> dict[tuple[str, int], dict[str, Any]]:
if not path.exists():
return {}
data = read_json(path)
if isinstance(data, dict):
items = data.get("records") or data.get("修正记录") or []
else:
items = data
corrections: dict[tuple[str, int], dict[str, Any]] = {}
for item in items:
image_path = normalize_text(item.get("图片路径"))
row_no = int(item.get("图片内行号") or 0)
if not image_path or row_no <= 0:
continue
for key in correction_keys(image_path, row_no):
corrections[key] = item
return corrections
def apply_corrections(
records: list[dict[str, Any]],
corrections: dict[tuple[str, int], dict[str, Any]],
fields: list[dict[str, Any]],
unique_key: str,
) -> None:
for record in records:
image_path = record["图片信息"]["图片路径"]
row_no = int(record["图片信息"]["图片内行号"])
correction = None
for key in correction_keys(image_path, row_no):
if key in corrections:
correction = corrections[key]
break
if correction:
record_info = correction.get("记录信息") or correction.get("患者信息") or {}
record["记录信息"].update(record_info)
record["复核"]["人工修正"] = True
if correction.get("复核备注"):
record["复核"]["人工备注"] = correction.get("复核备注")
warnings = validate_record_info(record["记录信息"], fields, unique_key)
record["复核"]["提示"] = warnings
if warnings:
record["复核"]["状态"] = "需人工复核"
elif record["复核"].get("人工修正"):
record["复核"]["状态"] = "人工复核通过"
else:
record["复核"]["状态"] = "自动复核通过"
def summarize_record(record: dict[str, Any], unique_key: str) -> dict[str, Any]:
image = record["图片信息"]
return {
"处理批次": record.get("处理批次", ""),
"来源文件夹": record.get("来源文件夹", ""),
"图片路径": image.get("图片路径", ""),
"图片名": image.get("图片名", ""),
"图片内行号": image.get("图片内行号", ""),
"主唯一键字段": unique_key,
"主唯一键值": record.get("记录信息", {}).get(unique_key, ""),
"复核状态": record.get("复核", {}).get("状态", ""),
}
def deduplicate_records(
records: list[dict[str, Any]],
unique_key: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
if not unique_key:
return records, [], []
kept_by_key: dict[str, dict[str, Any]] = {}
order: list[str] = []
missing: list[dict[str, Any]] = []
missing_records: list[dict[str, Any]] = []
duplicates: list[dict[str, Any]] = []
for record in records:
key = normalize_text(record["记录信息"].get(unique_key, ""))
if not key:
missing.append({"记录": summarize_record(record, unique_key), "规则": "主唯一键为空"})
missing_records.append(record)
continue
if key not in kept_by_key:
order.append(key)
else:
duplicates.append(
{
"主唯一键字段": unique_key,
"主唯一键值": key,
"保留记录": summarize_record(record, unique_key),
"剔除记录": summarize_record(kept_by_key[key], unique_key),
"规则": "主唯一键重复,后出现记录覆盖先出现记录",
}
)
kept_by_key[key] = record
return missing_records + [kept_by_key[key] for key in order], duplicates, missing
def records_to_csv(path: Path, records: list[dict[str, Any]], fields: list[str]) -> None:
fieldnames = [
"处理批次",
"业务分类1",
"业务分类2",
"来源文件夹",
"图片路径",
"图片名",
"图片内行号",
*fields,
"复核状态",
"复核提示",
"人工修正",
]
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:
image = record["图片信息"]
review = record["复核"]
writer.writerow(
{
"处理批次": record.get("处理批次", ""),
"业务分类1": record.get("业务分类1", ""),
"业务分类2": record.get("业务分类2", ""),
"来源文件夹": record.get("来源文件夹", ""),
"图片路径": image.get("图片路径", ""),
"图片名": image.get("图片名", ""),
"图片内行号": image.get("图片内行号", ""),
**{field: record["记录信息"].get(field, "") for field in fields},
"复核状态": review.get("状态", ""),
"复核提示": "".join(str(item) for item in review.get("提示", [])),
"人工修正": bool(review.get("人工修正")),
}
)
def build_records_from_rows(
rows: list[list[str]],
group_paths: list[Path],
fields: list[dict[str, Any]],
config: dict[str, Any],
batch_name: str,
source_folder: Path,
categories: dict[str, str],
group_index: int,
merge_info: dict[str, Any],
cache_path: Path,
request_id: str,
) -> list[dict[str, Any]]:
ocr_config = config.get("ocr") or {}
names = field_names(config)
indexed_rows = list(enumerate(rows))
skip_header_rows = int(ocr_config.get("skip_header_rows") or 0)
if skip_header_rows > 0:
indexed_rows = indexed_rows[skip_header_rows:]
elif ocr_config.get("auto_skip_header", True):
indexed_rows = [(index, row) for index, row in indexed_rows if not looks_like_header(row, names)]
rows_per_image = int(ocr_config.get("rows_per_image") or 0)
row_height_px = float(ocr_config.get("row_height_px") or 0)
row_counts = [infer_rows_for_image(path, rows_per_image, row_height_px) for path in group_paths]
total_rows = len(indexed_rows)
records: list[dict[str, Any]] = []
unique_key = normalize_text(config.get("unique_key"))
for output_row_index, (_raw_row_index, row) in enumerate(indexed_rows):
record_info = row_to_record_info(row, fields)
if is_blank_record(record_info):
continue
source_index, image_row = locate_source_row(output_row_index, row_counts, total_rows, len(group_paths))
source_path = group_paths[source_index]
warnings = validate_record_info(record_info, fields, unique_key)
records.append(
{
"处理批次": batch_name,
"业务分类1": categories.get("业务分类1", ""),
"业务分类2": categories.get("业务分类2", ""),
"来源文件夹": source_folder.name,
"记录信息": record_info,
"图片信息": {
"图片路径": str(source_path),
"图片名": source_path.name,
"图片序号": list(natural_key(source_path)),
"图片内行号": image_row + 1,
"拼接组序号": group_index,
"拼接图片路径": merge_info.get("path", ""),
"OCR缓存路径": str(cache_path),
"OCR请求ID": request_id,
},
"复核": {
"状态": "需人工复核" if warnings else "自动复核通过",
"提示": warnings,
"人工修正": False,
},
}
)
return records
def process_folder(
folder: Path,
output_root: Path,
config: dict[str, Any],
args: argparse.Namespace,
secret_id: str,
secret_key: str,
batch_name: str,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
ocr_config = config.get("ocr") or {}
fields = config.get("fields") or []
images = list_images(folder)
batch_size = args.batch_size or int(ocr_config.get("batch_size") or 6)
padding_y = args.image_padding_y if args.image_padding_y is not None else int(ocr_config.get("image_padding_y") or 0)
timeout = args.timeout or int(ocr_config.get("timeout") or 90)
max_retries = args.max_retries if args.max_retries is not None else int(ocr_config.get("max_retries") or 1)
min_row_ratio = float(ocr_config.get("min_row_ratio") or 0)
region = args.region or os.getenv("TENCENTCLOUD_REGION") or ocr_config.get("region") or "ap-shanghai"
sleep_seconds = args.sleep if args.sleep is not None else float(ocr_config.get("sleep") or 0)
folder_key = safe_filename(folder.name)
merged_dir = output_root / "merged_images" / folder_key
raw_dir = output_root / "raw_ocr" / folder_key
categories = classify_folder(folder, config)
records: list[dict[str, Any]] = []
group_infos: list[dict[str, Any]] = []
errors: list[dict[str, Any]] = []
def attempt_group(group: list[Path], group_index: int, label: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
merged_path = merged_dir / f"{label}.png"
cache_path = raw_dir / f"{label}.json"
merge_info = merge_images(group, merged_path, padding_y)
print(f" OCR: {folder.name} / {label} / {len(group)}", flush=True)
response = call_tencent_table_ocr(
merged_path,
cache_path,
secret_id,
secret_key,
region,
timeout,
args.force,
max_retries,
)
rows = cells_to_rows(response, len(fields))
rows_per_image = int(ocr_config.get("rows_per_image") or 0)
row_height_px = float(ocr_config.get("row_height_px") or 0)
expected_rows = sum(infer_rows_for_image(path, rows_per_image, row_height_px) for path in group)
if expected_rows > 0 and len(group) > 1 and len(rows) < expected_rows * min_row_ratio:
raise RuntimeError(f"识别行数偏少: {len(rows)} / {expected_rows}")
request_id = normalize_text(response.get("RequestId"))
built_records = build_records_from_rows(
rows,
group,
fields,
config,
batch_name,
folder,
categories,
group_index,
merge_info,
cache_path,
request_id,
)
info = {
"拼接组序号": group_index,
"标签": label,
"图片数": len(group),
"识别行数": len(rows),
"生成记录数": len(built_records),
"预估行数": expected_rows,
"拼接图片路径": str(merged_path),
"OCR缓存路径": str(cache_path),
"OCR请求ID": request_id,
}
time.sleep(max(0, sleep_seconds))
return info, built_records
groups = batched(images, batch_size)
if args.limit_groups_per_folder:
groups = groups[: args.limit_groups_per_folder]
for group_index, group in enumerate(groups, start=1):
label = f"{folder_key}_group_{group_index:04d}_n{len(group)}_pady{padding_y}"
try:
info, group_records = attempt_group(group, group_index, label)
group_infos.append(info)
records.extend(group_records)
except Exception as exc:
message = str(exc)
errors.append({"拼接组序号": group_index, "标签": label, "错误": message})
print(f" 拼接组失败,尝试单张回退: {message}", flush=True)
if len(group) == 1:
continue
for part_index, image_path in enumerate(group):
single_label = f"{label}_part_{part_index:02d}"
try:
info, single_records = attempt_group([image_path], group_index, single_label)
info["回退"] = "single"
group_infos.append(info)
records.extend(single_records)
except Exception as single_exc:
errors.append(
{
"拼接组序号": group_index,
"标签": single_label,
"图片": str(image_path),
"错误": str(single_exc),
}
)
summary = {
"来源文件夹": folder.name,
"业务分类1": categories.get("业务分类1", ""),
"业务分类2": categories.get("业务分类2", ""),
"图片数": len(images),
"拼接组数": len(group_infos),
"记录数": len(records),
"错误数": len(errors),
"拼接组": group_infos,
"错误": errors,
}
return records, summary
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", default=str(DEFAULT_CONFIG), help="任务配置 JSON")
parser.add_argument("--input", required=True, help="待处理图片批次目录")
parser.add_argument("--output", required=True, help="批次输出目录")
parser.add_argument("--corrections", default="数据处理工作区/03_人工复核修正.json", help="人工修正 JSON")
parser.add_argument("--batch-name", default="", help="处理批次名;默认使用 input 目录名")
parser.add_argument("--ocr-engine", default="", help="当前模板仅支持 table-v3")
parser.add_argument("--region", default="", help="腾讯云 OCR 地域")
parser.add_argument("--batch-size", type=int, default=0, help="每组拼接图片数")
parser.add_argument("--image-padding-y", type=int, default=None, help="每张图上下白边像素")
parser.add_argument("--timeout", type=int, default=0, help="单次 OCR 超时秒数")
parser.add_argument("--sleep", type=float, default=None, help="OCR 调用间隔秒数")
parser.add_argument("--max-retries", type=int, default=None, help="OCR 失败重试次数")
parser.add_argument("--force", action="store_true", help="忽略 OCR 缓存重新识别")
parser.add_argument("--rebuild-from-cache", 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 个拼接组")
return parser.parse_args()
def main() -> None:
args = parse_args()
config = load_config(Path(args.config))
engine = args.ocr_engine or (config.get("ocr") or {}).get("engine", "table-v3")
if engine != "table-v3":
raise ValueError("当前通用模板仅实现 table-v3即腾讯云 RecognizeTableAccurateOCR")
input_root = Path(args.input)
output_root = Path(args.output)
batch_name = args.batch_name or input_root.name
if not input_root.exists():
raise FileNotFoundError(f"输入目录不存在: {input_root}")
output_root.mkdir(parents=True, exist_ok=True)
secret_id, secret_key = ("", "") if args.rebuild_from_cache else read_credentials()
folders = find_source_folders(input_root)
if args.limit_folders:
folders = folders[: args.limit_folders]
if not folders:
raise RuntimeError(f"未发现图片文件: {input_root}")
all_records: list[dict[str, Any]] = []
folder_summaries: list[dict[str, Any]] = []
for folder in folders:
print(f"处理来源文件夹: {folder}", flush=True)
folder_records, summary = process_folder(folder, output_root, config, args, secret_id, secret_key, batch_name)
all_records.extend(folder_records)
folder_summaries.append(summary)
fields = config.get("fields") or []
names = field_names(config)
unique_key = normalize_text(config.get("unique_key"))
corrections = load_corrections(Path(args.corrections))
apply_corrections(all_records, corrections, fields, unique_key)
kept_records, duplicate_records, missing_key_records = deduplicate_records(all_records, unique_key)
need_review = [record for record in kept_records if record["复核"]["状态"] == "需人工复核"]
manual_records = [record for record in kept_records if record["复核"].get("人工修正")]
error_count = sum(int(summary.get("错误数", 0)) for summary in folder_summaries)
summary = {
"项目名称": config.get("project_name", ""),
"记录对象": config.get("record_name", ""),
"处理批次": batch_name,
"来源文件夹数": len(folder_summaries),
"图片数": sum(int(item.get("图片数", 0)) for item in folder_summaries),
"去重前记录数": len(all_records),
"记录数": len(kept_records),
"需人工复核记录数": len(need_review),
"人工修正记录数": len(manual_records),
"重复主键剔除记录数": len(duplicate_records),
"缺少主键记录数": len(missing_key_records),
"错误数": error_count,
"主唯一键": unique_key,
}
archive = {
"任务配置": {
"project_name": config.get("project_name", ""),
"record_name": config.get("record_name", ""),
"unique_key": unique_key,
"fields": fields,
"result_suffix": config.get("result_suffix", "-列表归档结果"),
},
"汇总": summary,
"来源文件夹汇总": folder_summaries,
"重复主键记录": duplicate_records,
"缺少主键记录": missing_key_records,
"图片表格记录": kept_records,
}
review_report = {
"汇总": summary,
"需人工复核记录": need_review,
"人工修正记录": manual_records,
"重复主键记录": duplicate_records,
"缺少主键记录": missing_key_records,
"来源文件夹汇总": folder_summaries,
}
write_json(output_root / "图片表格_结构化.json", archive)
write_jsonl(output_root / "图片表格_记录.jsonl", kept_records)
records_to_csv(output_root / "图片表格_记录.csv", kept_records, names)
write_json(output_root / "复核报告.json", review_report)
write_json(output_root / "重复主键报告.json", duplicate_records)
write_json(output_root / "缺少主键报告.json", missing_key_records)
write_json(output_root / "信息记录" / "汇总.json", summary)
write_json(output_root / "信息记录" / "来源文件夹汇总.json", folder_summaries)
print(json.dumps(summary, ensure_ascii=False, indent=2), flush=True)
if __name__ == "__main__":
main()