330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""System-friendly multiprocessing batch OCR with structured timing logs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import random
|
|
import sys
|
|
import time
|
|
from logging.handlers import QueueHandler, QueueListener
|
|
from multiprocessing import Manager, Pool, cpu_count
|
|
from pathlib import Path
|
|
|
|
from ocr_logging import default_log_path, setup_run_logger
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
|
_WORKER_LOG_QUEUE = None
|
|
_WORKER_INIT_METRICS: dict = {}
|
|
|
|
|
|
def _worker_logger() -> logging.Logger:
|
|
logger = logging.getLogger(f"ocr.batch.worker.{os.getpid()}")
|
|
if logger.handlers:
|
|
return logger
|
|
logger.setLevel(logging.INFO)
|
|
logger.propagate = False
|
|
if _WORKER_LOG_QUEUE is not None:
|
|
logger.addHandler(QueueHandler(_WORKER_LOG_QUEUE))
|
|
return logger
|
|
|
|
|
|
def _init_worker(threads: int, stagger_max: float, log_queue) -> None:
|
|
"""Stagger startup, lower process priority, and load one model per worker."""
|
|
global _pipeline, _WORKER_LOG_QUEUE, _WORKER_INIT_METRICS
|
|
_WORKER_LOG_QUEUE = log_queue
|
|
logger = _worker_logger()
|
|
worker_started = time.perf_counter()
|
|
delay = random.uniform(0, stagger_max)
|
|
logger.info("WORKER_START threads=%d stagger_delay_seconds=%.3f", threads, delay)
|
|
time.sleep(delay)
|
|
|
|
import_started = time.perf_counter()
|
|
from paddle import core
|
|
core.set_num_threads(threads)
|
|
import_seconds = time.perf_counter() - import_started
|
|
|
|
try:
|
|
import psutil
|
|
|
|
process = psutil.Process()
|
|
if sys.platform == "win32":
|
|
process.nice(psutil.BELOW_NORMAL_PRIORITY_CLASS)
|
|
else:
|
|
process.nice(10)
|
|
priority_status = "lowered"
|
|
except Exception as exc:
|
|
priority_status = f"unchanged:{type(exc).__name__}"
|
|
|
|
model_started = time.perf_counter()
|
|
from paddleocr import PaddleOCRVL
|
|
|
|
_pipeline = PaddleOCRVL(pipeline_version="v1.6", device="cpu")
|
|
model_seconds = time.perf_counter() - model_started
|
|
startup_total = time.perf_counter() - worker_started
|
|
_WORKER_INIT_METRICS = {
|
|
"pid": os.getpid(),
|
|
"threads": threads,
|
|
"stagger_delay_seconds": round(delay, 3),
|
|
"import_seconds": round(import_seconds, 3),
|
|
"model_init_seconds": round(model_seconds, 3),
|
|
"startup_total_seconds": round(startup_total, 3),
|
|
"priority": priority_status,
|
|
}
|
|
logger.info(
|
|
"WORKER_READY threads=%d import_seconds=%.3f model_init_seconds=%.3f startup_total_seconds=%.3f priority=%s",
|
|
threads,
|
|
import_seconds,
|
|
model_seconds,
|
|
startup_total,
|
|
priority_status,
|
|
)
|
|
|
|
|
|
def _ocr_task(image_path: str) -> dict:
|
|
global _pipeline, _WORKER_INIT_METRICS
|
|
logger = _worker_logger()
|
|
started = time.perf_counter()
|
|
logger.info("IMAGE_START path=%s", image_path)
|
|
try:
|
|
result = _pipeline.predict(image_path)
|
|
elapsed = time.perf_counter() - started
|
|
first = result[0]
|
|
blocks = [
|
|
{"label": block.label, "bbox": block.bbox, "content": block.content}
|
|
for block in first["parsing_res_list"]
|
|
if block.content.strip()
|
|
]
|
|
response = {
|
|
"path": image_path,
|
|
"status": "completed",
|
|
"elapsed": round(elapsed, 3),
|
|
"width": first.get("width"),
|
|
"height": first.get("height"),
|
|
"layout_boxes": len(first["layout_det_res"]["boxes"]),
|
|
"parsed_blocks": len(first["parsing_res_list"]),
|
|
"blocks": blocks,
|
|
"worker_pid": os.getpid(),
|
|
"worker_init": _WORKER_INIT_METRICS,
|
|
}
|
|
logger.info(
|
|
"IMAGE_COMPLETED path=%s seconds=%.3f width=%s height=%s layout_boxes=%d parsed_blocks=%d non_empty_blocks=%d",
|
|
image_path,
|
|
elapsed,
|
|
response["width"],
|
|
response["height"],
|
|
response["layout_boxes"],
|
|
response["parsed_blocks"],
|
|
len(blocks),
|
|
)
|
|
return response
|
|
except Exception as exc:
|
|
elapsed = time.perf_counter() - started
|
|
logger.exception("IMAGE_FAILED path=%s seconds=%.3f error=%s", image_path, elapsed, exc)
|
|
return {
|
|
"path": image_path,
|
|
"status": "failed",
|
|
"elapsed": round(elapsed, 3),
|
|
"error": f"{type(exc).__name__}: {exc}",
|
|
"blocks": [],
|
|
"worker_pid": os.getpid(),
|
|
"worker_init": _WORKER_INIT_METRICS,
|
|
}
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="批量 OCR — 多进程并行(系统友好版)",
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
)
|
|
parser.add_argument("dir", type=Path, help="图片目录")
|
|
parser.add_argument("--workers", type=int, default=2, help="并行进程数")
|
|
parser.add_argument("--threads", type=int, default=None, help="每进程线程数")
|
|
parser.add_argument("--stagger", type=float, default=15.0, help="Worker 启动错峰窗口秒数")
|
|
parser.add_argument("--log-file", type=Path, default=None, help="日志文件路径")
|
|
parser.add_argument("--verbose", action="store_true", help="输出详细日志")
|
|
parser.add_argument("--no-result", action="store_true", help="不记录 OCR 文本块")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
program_started = time.perf_counter()
|
|
args = parse_args()
|
|
image_dir = args.dir.expanduser().resolve()
|
|
log_file = args.log_file or default_log_path(PROJECT_ROOT, "batch", image_dir.name, device="cpu")
|
|
logger = setup_run_logger("ocr.batch.main", log_file, verbose=args.verbose)
|
|
|
|
if not image_dir.is_dir():
|
|
logger.error("INPUT_DIRECTORY_NOT_FOUND path=%s", image_dir)
|
|
return 1
|
|
if args.workers < 1 or args.stagger < 0:
|
|
logger.error("INVALID_ARGUMENT workers=%d stagger=%.3f", args.workers, args.stagger)
|
|
return 2
|
|
|
|
scan_started = time.perf_counter()
|
|
extensions = ("*.png", "*.jpg", "*.jpeg", "*.bmp", "*.tiff", "*.tif", "*.webp")
|
|
images = sorted(path for extension in extensions for path in image_dir.glob(extension))
|
|
scan_seconds = time.perf_counter() - scan_started
|
|
if not images:
|
|
logger.error("NO_IMAGES_FOUND path=%s scan_seconds=%.3f", image_dir, scan_seconds)
|
|
return 1
|
|
|
|
total_cores = cpu_count()
|
|
workers = min(args.workers, len(images))
|
|
threads = args.threads or max(1, (total_cores - 1) // workers)
|
|
if threads < 1:
|
|
logger.error("INVALID_ARGUMENT threads=%d", threads)
|
|
return 2
|
|
total_cpu_used = workers * threads
|
|
estimated_mem = workers * 2.0 + 2
|
|
|
|
try:
|
|
import psutil
|
|
|
|
available_gb = psutil.virtual_memory().available / (1024**3)
|
|
except ImportError:
|
|
available_gb = None
|
|
|
|
logger.info(
|
|
"PROGRAM_STARTED directory=%s image_count=%d scan_seconds=%.3f workers=%d threads_per_worker=%d total_cores=%d planned_threads=%d reserved_cores=%d stagger_seconds=%.3f estimated_memory_gb=%.1f available_memory_gb=%s",
|
|
image_dir,
|
|
len(images),
|
|
scan_seconds,
|
|
workers,
|
|
threads,
|
|
total_cores,
|
|
total_cpu_used,
|
|
max(0, total_cores - total_cpu_used),
|
|
args.stagger,
|
|
estimated_mem,
|
|
f"{available_gb:.1f}" if available_gb is not None else "unknown",
|
|
)
|
|
|
|
if available_gb is not None and available_gb <= estimated_mem:
|
|
logger.warning(
|
|
"MEMORY_PRESSURE estimated_memory_gb=%.1f available_memory_gb=%.1f recommendation=reduce_workers",
|
|
estimated_mem,
|
|
available_gb,
|
|
)
|
|
response = input("可用内存可能不足,是否继续?[y/N] ").strip().lower()
|
|
if response != "y":
|
|
logger.warning("PROGRAM_CANCELLED_BY_USER")
|
|
return 0
|
|
|
|
pool_started = time.perf_counter()
|
|
results: list[dict] = []
|
|
try:
|
|
with Manager() as manager:
|
|
log_queue = manager.Queue()
|
|
listener_handlers = tuple(logger.handlers)
|
|
listener = QueueListener(log_queue, *listener_handlers, respect_handler_level=True)
|
|
listener.start()
|
|
try:
|
|
with Pool(
|
|
processes=workers,
|
|
initializer=_init_worker,
|
|
initargs=(threads, args.stagger, log_queue),
|
|
) as pool:
|
|
for completed, result in enumerate(
|
|
pool.imap_unordered(_ocr_task, [str(path) for path in images], chunksize=1),
|
|
start=1,
|
|
):
|
|
results.append(result)
|
|
logger.info(
|
|
"BATCH_PROGRESS completed=%d total=%d path=%s status=%s image_seconds=%.3f worker_pid=%s",
|
|
completed,
|
|
len(images),
|
|
result["path"],
|
|
result["status"],
|
|
result["elapsed"],
|
|
result.get("worker_pid"),
|
|
)
|
|
finally:
|
|
listener.stop()
|
|
except KeyboardInterrupt:
|
|
logger.warning("PROGRAM_INTERRUPTED elapsed_seconds=%.3f", time.perf_counter() - program_started)
|
|
return 130
|
|
except Exception as exc:
|
|
logger.exception("POOL_FAILED error=%s elapsed_seconds=%.3f", exc, time.perf_counter() - program_started)
|
|
return 1
|
|
|
|
pool_seconds = time.perf_counter() - pool_started
|
|
completed_results = [result for result in results if result["status"] == "completed"]
|
|
failed_results = [result for result in results if result["status"] == "failed"]
|
|
worker_metrics = {
|
|
result["worker_pid"]: result.get("worker_init", {})
|
|
for result in results
|
|
if result.get("worker_pid") is not None
|
|
}
|
|
worker_model_init_total = sum(
|
|
metrics.get("model_init_seconds", 0.0) for metrics in worker_metrics.values()
|
|
)
|
|
worker_model_init_average = (
|
|
worker_model_init_total / len(worker_metrics) if worker_metrics else 0.0
|
|
)
|
|
serial_estimate = sum(result["elapsed"] for result in results)
|
|
average = serial_estimate / len(results) if results else 0.0
|
|
speedup = serial_estimate / pool_seconds if pool_seconds else 0.0
|
|
program_total = time.perf_counter() - program_started
|
|
|
|
for worker_pid, metrics in sorted(worker_metrics.items()):
|
|
logger.info(
|
|
"WORKER_SUMMARY pid=%s threads=%s stagger_delay_seconds=%s import_seconds=%s model_init_seconds=%s startup_total_seconds=%s priority=%s",
|
|
worker_pid,
|
|
metrics.get("threads"),
|
|
metrics.get("stagger_delay_seconds"),
|
|
metrics.get("import_seconds"),
|
|
metrics.get("model_init_seconds"),
|
|
metrics.get("startup_total_seconds"),
|
|
metrics.get("priority"),
|
|
)
|
|
|
|
for result in sorted(results, key=lambda item: item["path"]):
|
|
if result["status"] == "completed":
|
|
logger.info(
|
|
"IMAGE_SUMMARY path=%s seconds=%.3f width=%s height=%s layout_boxes=%d parsed_blocks=%d",
|
|
result["path"],
|
|
result["elapsed"],
|
|
result["width"],
|
|
result["height"],
|
|
result["layout_boxes"],
|
|
result["parsed_blocks"],
|
|
)
|
|
if not args.no_result:
|
|
for index, block in enumerate(result["blocks"], start=1):
|
|
logger.info(
|
|
"OCR_BLOCK path=%s index=%d label=%s bbox=%s content=%s",
|
|
result["path"],
|
|
index,
|
|
block["label"],
|
|
block["bbox"],
|
|
block["content"].replace("\r", "").replace("\n", "\\n"),
|
|
)
|
|
else:
|
|
logger.error("IMAGE_SUMMARY path=%s status=failed error=%s", result["path"], result["error"])
|
|
|
|
logger.info(
|
|
"BATCH_SUMMARY image_count=%d completed=%d failed=%d scan_seconds=%.3f pool_seconds=%.3f worker_count=%d worker_model_init_total_seconds=%.3f worker_model_init_average_seconds=%.3f serial_estimate_seconds=%.3f average_image_seconds=%.3f speedup=%.3f program_total_seconds=%.3f workers=%d threads_per_worker=%d log=%s",
|
|
len(images),
|
|
len(completed_results),
|
|
len(failed_results),
|
|
scan_seconds,
|
|
pool_seconds,
|
|
len(worker_metrics),
|
|
worker_model_init_total,
|
|
worker_model_init_average,
|
|
serial_estimate,
|
|
average,
|
|
speedup,
|
|
program_total,
|
|
workers,
|
|
threads,
|
|
log_file.resolve(),
|
|
)
|
|
return 0 if not failed_results else 3
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|