batch_process_pdf.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953
  1. #!/usr/bin/env python3
  2. """
  3. PDF 批量处理脚本
  4. 支持多种处理器,配置文件驱动
  5. 支持自动切换虚拟环境
  6. 支持执行器输出日志重定向
  7. """
  8. import os
  9. import sys
  10. import argparse
  11. import subprocess
  12. import json
  13. import yaml
  14. from pathlib import Path
  15. from datetime import datetime
  16. from typing import List, Dict, Optional, Any, Tuple
  17. from dataclasses import dataclass, field
  18. import logging
  19. from tqdm import tqdm
  20. import time
  21. # ============================================================================
  22. # 数据类定义
  23. # ============================================================================
  24. @dataclass
  25. class ProcessorConfig:
  26. """处理器配置"""
  27. name: str
  28. script: str
  29. input_arg: str = "--input_file"
  30. output_arg: str = "--output_dir"
  31. extra_args: List[str] = field(default_factory=list)
  32. output_subdir: str = "results"
  33. log_subdir: str = "logs" # 🎯 新增:日志子目录
  34. scene_arg: Optional[str] = None # 场景参数名(如 --scene)
  35. venv: Optional[str] = None
  36. description: str = ""
  37. @dataclass
  38. class PDFTask:
  39. """PDF 处理任务"""
  40. path: Path
  41. scene: Optional[str] = None
  42. @dataclass
  43. class ProcessResult:
  44. """处理结果"""
  45. pdf_file: str
  46. success: bool
  47. duration: float
  48. error_message: str = ""
  49. log_file: str = "" # 🎯 新增:日志文件路径
  50. # ============================================================================
  51. # 配置管理
  52. # ============================================================================
  53. class ConfigManager:
  54. """配置管理器"""
  55. DEFAULT_CONFIG = {
  56. 'processors': {
  57. 'paddleocr_vl_single_process': {
  58. 'script': '/Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/paddle_vl_tool/main.py',
  59. 'input_arg': '--input',
  60. 'output_arg': '--output_dir',
  61. 'extra_args': [
  62. '--pipeline=/Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/paddle_common/config/PaddleOCR-VL-Client.yaml',
  63. '--no-adapter'
  64. ],
  65. 'output_subdir': 'paddleocr_vl_results',
  66. 'venv': 'source /Users/zhch158/workspace/repository.git/PaddleX/paddle_env/bin/activate',
  67. 'description': 'PaddleOCR-VL 处理器',
  68. 'log_subdir': 'logs/paddleocr_vl_single_process' # 🎯 新增
  69. },
  70. 'ppstructurev3_single_process': {
  71. 'script': '/Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/ppstructure_tool/main.py',
  72. 'input_arg': '--input',
  73. 'output_arg': '--output_dir',
  74. 'extra_args': [
  75. '--pipeline=/Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/paddle_common/config/PP-StructureV3.yaml'
  76. ],
  77. 'output_subdir': 'ppstructurev3_results',
  78. 'venv': 'conda activate paddle',
  79. 'description': 'PP-StructureV3 处理器',
  80. 'log_subdir': 'logs/ppstructurev3_single_process' # 🎯 新增
  81. },
  82. 'ppstructurev3_single_client': {
  83. 'script': '/Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/ppstructure_tool/api_client.py',
  84. 'input_arg': '--input',
  85. 'output_arg': '--output_dir',
  86. 'extra_args': [
  87. '--api_url=http://10.192.72.11:8111/layout-parsing',
  88. '--timeout=300'
  89. ],
  90. 'output_subdir': 'ppstructurev3_client_results',
  91. 'venv': 'source /Users/zhch158/workspace/repository.git/PaddleX/paddle_env/bin/activate',
  92. 'description': 'PP-StructureV3 HTTP API 客户端',
  93. 'log_subdir': 'logs/ppstructurev3_single_client' # 🎯 新增
  94. },
  95. 'mineru_vllm': {
  96. 'script': '/Users/zhch158/workspace/repository.git/MinerU/zhch/mineru2_vllm_multthreads.py',
  97. 'input_arg': '--input_file',
  98. 'output_arg': '--output_dir',
  99. 'extra_args': [
  100. '--server_url=http://10.192.72.11:8121',
  101. '--timeout=300',
  102. '--batch_size=1'
  103. ],
  104. 'output_subdir': 'mineru_vllm_results',
  105. 'venv': 'conda activate mineru2',
  106. 'description': 'MinerU vLLM 处理器',
  107. 'log_subdir': 'logs/mineru_vllm' # 🎯 新增
  108. },
  109. 'dotsocr_vllm': {
  110. 'script': '/Users/zhch158/workspace/repository.git/dots.ocr/zhch/dotsocr_vllm_multthreads.py',
  111. 'input_arg': '--input_file',
  112. 'output_arg': '--output_dir',
  113. 'extra_args': [
  114. '--ip=10.192.72.11',
  115. '--port=8101',
  116. '--model_name=DotsOCR',
  117. '--prompt_mode=prompt_layout_all_en',
  118. '--batch_size=1',
  119. '--max_workers=1',
  120. '--dpi=200'
  121. ],
  122. 'output_subdir': 'dotsocr_vllm_results',
  123. 'venv': 'conda activate py312',
  124. 'description': 'DotsOCR vLLM 处理器 - 支持PDF和图片',
  125. 'log_subdir': 'logs/dotsocr_vllm' # 🎯 新增
  126. }
  127. },
  128. 'global': {
  129. 'base_dir': '/Users/zhch158/workspace/data/流水分析',
  130. 'output_subdir': 'results',
  131. 'log_dir': 'logs',
  132. 'log_retention_days': 30,
  133. 'log_level': 'INFO'
  134. }
  135. }
  136. def __init__(self, config_file: Optional[str] = None):
  137. self.config_file = config_file
  138. self.config = self._load_config()
  139. def _load_config(self) -> Dict:
  140. """加载配置文件"""
  141. if self.config_file and Path(self.config_file).exists():
  142. with open(self.config_file, 'r', encoding='utf-8') as f:
  143. if self.config_file.endswith('.yaml') or self.config_file.endswith('.yml'):
  144. return yaml.safe_load(f)
  145. else:
  146. return json.load(f)
  147. return self.DEFAULT_CONFIG.copy()
  148. def get_processor_config(self, processor_name: str) -> ProcessorConfig:
  149. """获取处理器配置"""
  150. if processor_name not in self.config['processors']:
  151. raise ValueError(f"处理器 '{processor_name}' 不存在")
  152. proc_config = self.config['processors'][processor_name]
  153. return ProcessorConfig(
  154. name=processor_name,
  155. script=proc_config['script'],
  156. input_arg=proc_config.get('input_arg', '--input_file'),
  157. output_arg=proc_config.get('output_arg', '--output_dir'),
  158. extra_args=proc_config.get('extra_args', []),
  159. output_subdir=proc_config.get('output_subdir', processor_name + '_results'),
  160. log_subdir=proc_config.get('log_subdir', f'logs/{processor_name}'), # 🎯 新增
  161. scene_arg=proc_config.get('scene_arg'),
  162. venv=proc_config.get('venv'),
  163. description=proc_config.get('description', '')
  164. )
  165. def get_global_config(self, key: str, default=None):
  166. """获取全局配置"""
  167. return self.config.get('global', {}).get(key, default)
  168. def list_processors(self) -> List[str]:
  169. """列出所有可用的处理器"""
  170. return list(self.config['processors'].keys())
  171. # ============================================================================
  172. # PDF 文件查找器
  173. # ============================================================================
  174. class PDFFileFinder:
  175. """PDF 文件查找器"""
  176. def __init__(self, base_dir: str):
  177. self.base_dir = Path(base_dir)
  178. def from_file_list(self, list_file: str) -> List[PDFTask]:
  179. """从文件列表读取"""
  180. pdf_files: List[PDFTask] = []
  181. with open(list_file, 'r', encoding='utf-8') as f:
  182. for line in f:
  183. # 跳过空行和注释
  184. line = line.strip()
  185. if not line or line.startswith('#'):
  186. continue
  187. file_part, scene = self._parse_list_line(line)
  188. # 构建完整路径
  189. pdf_path = self._resolve_path(file_part)
  190. if pdf_path:
  191. pdf_files.append(PDFTask(path=pdf_path, scene=scene))
  192. return pdf_files
  193. def from_list(self, pdf_list: List[str]) -> List[PDFTask]:
  194. """从列表读取"""
  195. pdf_files: List[PDFTask] = []
  196. for pdf in pdf_list:
  197. file_part, scene = self._parse_list_line(pdf.strip())
  198. pdf_path = self._resolve_path(file_part)
  199. if pdf_path:
  200. pdf_files.append(PDFTask(path=pdf_path, scene=scene))
  201. return pdf_files
  202. def find_all(self) -> List[PDFTask]:
  203. """查找基础目录下所有 PDF"""
  204. return [PDFTask(path=path) for path in sorted(self.base_dir.rglob('*.pdf'))]
  205. def _parse_list_line(self, line: str) -> Tuple[str, Optional[str]]:
  206. """解析列表行(支持 文件<TAB>场景 或 文件,场景)"""
  207. for sep in ["\t", ","]:
  208. if sep in line:
  209. file_part, scene_part = line.split(sep, 1)
  210. file_part = file_part.strip()
  211. scene_part = scene_part.strip()
  212. return file_part, scene_part or None
  213. return line.strip(), None
  214. def _resolve_path(self, path_str: str) -> Optional[Path]:
  215. """解析路径"""
  216. path = Path(path_str)
  217. # 绝对路径
  218. if path.is_absolute():
  219. return path if path.exists() else path # 返回路径,即使不存在
  220. # 相对路径
  221. # 1. 尝试完整相对路径
  222. candidate1 = self.base_dir / path
  223. if candidate1.exists():
  224. return candidate1
  225. # 2. 尝试在同名子目录下查找
  226. if '/' not in path_str:
  227. pdf_name = path.stem
  228. candidate2 = self.base_dir / pdf_name / path.name
  229. if candidate2.exists():
  230. return candidate2
  231. # 3. 使用 glob 搜索
  232. matches = list(self.base_dir.rglob(path.name))
  233. if matches:
  234. return matches[0]
  235. # 返回候选路径(即使不存在)
  236. return candidate1
  237. # ============================================================================
  238. # PDF 批处理器
  239. # ============================================================================
  240. class PDFBatchProcessor:
  241. """PDF 批处理器"""
  242. def __init__(
  243. self,
  244. processor_config: ProcessorConfig,
  245. output_subdir: Optional[str] = None,
  246. log_base_dir: Optional[str] = None, # 🎯 新增:日志基础目录
  247. dry_run: bool = False,
  248. default_scene: Optional[str] = None
  249. ):
  250. self.processor_config = processor_config
  251. # 如果指定了output_subdir,使用指定的;否则使用处理器配置中的
  252. self.output_subdir = output_subdir or processor_config.output_subdir
  253. self.log_base_dir = Path(log_base_dir) if log_base_dir else Path('logs') # 🎯 新增
  254. self.dry_run = dry_run
  255. self.default_scene = default_scene
  256. # 设置日志
  257. self.logger = self._setup_logger()
  258. # 统计信息
  259. self.results: List[ProcessResult] = []
  260. def _setup_logger(self) -> logging.Logger:
  261. """设置日志"""
  262. logger = logging.getLogger('PDFBatchProcessor')
  263. logger.setLevel(logging.INFO)
  264. # 避免重复添加handler
  265. if not logger.handlers:
  266. # 控制台输出
  267. console_handler = logging.StreamHandler()
  268. console_handler.setLevel(logging.INFO)
  269. console_format = logging.Formatter(
  270. '%(asctime)s - %(levelname)s - %(message)s',
  271. datefmt='%Y-%m-%d %H:%M:%S'
  272. )
  273. console_handler.setFormatter(console_format)
  274. logger.addHandler(console_handler)
  275. return logger
  276. def _get_log_file_path(self, pdf_file: Path) -> Path:
  277. """
  278. 🎯 获取日志文件路径
  279. 日志结构:
  280. base_dir/
  281. └── PDF名称/
  282. └── logs/
  283. └── processor_name/
  284. └── PDF名称_YYYYMMDD_HHMMSS.log
  285. """
  286. # PDF 目录
  287. pdf_dir = pdf_file.parent / pdf_file.stem
  288. # 日志目录: pdf_dir / logs / processor_name
  289. log_dir = pdf_dir / self.processor_config.log_subdir
  290. log_dir.mkdir(parents=True, exist_ok=True)
  291. # 日志文件名: PDF名称_时间戳.log
  292. timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
  293. log_file = log_dir / f"{pdf_file.stem}_{timestamp}.log"
  294. return log_file
  295. def process_files(self, pdf_files: List[PDFTask]) -> Dict[str, Any]:
  296. """批量处理文件"""
  297. self.logger.info(f"开始处理 {len(pdf_files)} 个文件")
  298. self.logger.info(f"处理器: {self.processor_config.description}")
  299. self.logger.info(f"脚本: {self.processor_config.script}")
  300. self.logger.info(f"输出目录: {self.output_subdir}")
  301. self.logger.info(f"日志目录: {self.processor_config.log_subdir}")
  302. if self.processor_config.venv:
  303. self.logger.info(f"虚拟环境: {self.processor_config.venv}")
  304. start_time = time.time()
  305. # 使用进度条
  306. with tqdm(total=len(pdf_files), desc="处理进度", unit="file") as pbar:
  307. for task in pdf_files:
  308. result = self._process_single_file(task)
  309. self.results.append(result)
  310. pbar.update(1)
  311. # 更新进度条描述
  312. success_count = sum(1 for r in self.results if r.success)
  313. pbar.set_postfix({
  314. 'success': success_count,
  315. 'failed': len(self.results) - success_count
  316. })
  317. total_duration = time.time() - start_time
  318. # 生成统计信息
  319. stats = self._generate_stats(total_duration)
  320. self._save_summary_log(stats)
  321. return stats
  322. def _process_single_file(self, task: PDFTask) -> ProcessResult:
  323. """🎯 处理单个文件(支持日志重定向)"""
  324. pdf_file = task.path
  325. scene = task.scene or self.default_scene
  326. scene_info = f" (scene: {scene})" if scene else ""
  327. self.logger.info(f"处理: {pdf_file}{scene_info}")
  328. # 检查文件是否存在
  329. if not pdf_file.exists():
  330. self.logger.warning(f"跳过: 文件不存在 - {pdf_file}")
  331. return ProcessResult(
  332. pdf_file=str(pdf_file),
  333. success=False,
  334. duration=0,
  335. error_message="文件不存在"
  336. )
  337. # 确定输出目录
  338. output_dir = pdf_file.parent / pdf_file.stem / self.output_subdir
  339. # 🎯 获取日志文件路径
  340. log_file = self._get_log_file_path(pdf_file)
  341. # 构建命令
  342. cmd = self._build_command(pdf_file, output_dir, scene)
  343. self.logger.debug(f"执行命令: {cmd if isinstance(cmd, str) else ' '.join(cmd)}")
  344. self.logger.info(f"日志输出: {log_file}")
  345. if self.dry_run:
  346. self.logger.info(f"[DRY RUN] 将执行: {cmd if isinstance(cmd, str) else ' '.join(cmd)}")
  347. return ProcessResult(
  348. pdf_file=str(pdf_file),
  349. success=True,
  350. duration=0,
  351. error_message="",
  352. log_file=str(log_file)
  353. )
  354. # 🎯 执行命令并重定向输出到日志文件
  355. start_time = time.time()
  356. try:
  357. with open(log_file, 'w', encoding='utf-8') as log_f:
  358. # 写入日志头
  359. log_f.write(f"{'='*80}\n")
  360. log_f.write(f"处理器: {self.processor_config.description}\n")
  361. log_f.write(f"PDF 文件: {pdf_file}{scene_info}\n")
  362. log_f.write(f"输出目录: {output_dir}\n")
  363. log_f.write(f"开始时间: {datetime.now()}\n")
  364. log_f.write(f"{'='*80}\n\n")
  365. log_f.flush()
  366. # 执行命令
  367. if isinstance(cmd, str):
  368. result = subprocess.run(
  369. cmd,
  370. shell=True,
  371. executable='/bin/bash',
  372. stdout=log_f, # 🎯 重定向 stdout
  373. stderr=subprocess.STDOUT, # 🎯 合并 stderr 到 stdout
  374. text=True,
  375. check=True
  376. )
  377. else:
  378. result = subprocess.run(
  379. cmd,
  380. stdout=log_f, # 🎯 重定向 stdout
  381. stderr=subprocess.STDOUT, # 🎯 合并 stderr
  382. text=True,
  383. check=True
  384. )
  385. # 写入日志尾
  386. log_f.write(f"\n{'='*80}\n")
  387. log_f.write(f"结束时间: {datetime.now()}\n")
  388. log_f.write(f"状态: 成功\n")
  389. log_f.write(f"{'='*80}\n")
  390. duration = time.time() - start_time
  391. self.logger.info(f"✓ 成功 (耗时: {duration:.2f}秒)")
  392. return ProcessResult(
  393. pdf_file=str(pdf_file),
  394. success=True,
  395. duration=duration,
  396. error_message="",
  397. log_file=str(log_file)
  398. )
  399. except subprocess.CalledProcessError as e:
  400. duration = time.time() - start_time
  401. error_msg = f"命令执行失败 (退出码: {e.returncode})"
  402. # 🎯 在日志文件中追加错误信息
  403. with open(log_file, 'a', encoding='utf-8') as log_f:
  404. log_f.write(f"\n{'='*80}\n")
  405. log_f.write(f"结束时间: {datetime.now()}\n")
  406. log_f.write(f"状态: 失败\n")
  407. log_f.write(f"错误: {error_msg}\n")
  408. log_f.write(f"{'='*80}\n")
  409. self.logger.error(f"✗ 失败 (耗时: {duration:.2f}秒)")
  410. self.logger.error(f"错误信息: {error_msg}")
  411. self.logger.error(f"详细日志: {log_file}")
  412. return ProcessResult(
  413. pdf_file=str(pdf_file),
  414. success=False,
  415. duration=duration,
  416. error_message=error_msg,
  417. log_file=str(log_file)
  418. )
  419. except Exception as e:
  420. duration = time.time() - start_time
  421. error_msg = str(e)
  422. with open(log_file, 'a', encoding='utf-8') as log_f:
  423. log_f.write(f"\n{'='*80}\n")
  424. log_f.write(f"结束时间: {datetime.now()}\n")
  425. log_f.write(f"状态: 异常\n")
  426. log_f.write(f"错误: {error_msg}\n")
  427. log_f.write(f"{'='*80}\n")
  428. self.logger.error(f"✗ 异常 (耗时: {duration:.2f}秒)")
  429. self.logger.error(f"错误信息: {error_msg}")
  430. return ProcessResult(
  431. pdf_file=str(pdf_file),
  432. success=False,
  433. duration=duration,
  434. error_message=error_msg,
  435. log_file=str(log_file)
  436. )
  437. def _build_command(self, pdf_file: Path, output_dir: Path, scene: Optional[str]):
  438. """构建执行命令
  439. Returns:
  440. 如果配置了 venv,返回 shell 命令字符串
  441. 否则返回命令列表
  442. """
  443. # 构建基础 Python 命令
  444. base_cmd = [
  445. 'python', # 使用虚拟环境中的 python
  446. self.processor_config.script,
  447. self.processor_config.input_arg, str(pdf_file),
  448. self.processor_config.output_arg, str(output_dir)
  449. ]
  450. # 添加额外参数
  451. base_cmd.extend(self.processor_config.extra_args)
  452. # 添加场景参数(如果配置了scene_arg)
  453. if scene:
  454. if self.processor_config.scene_arg:
  455. base_cmd.extend([self.processor_config.scene_arg, scene])
  456. else:
  457. self.logger.warning("⚠️ 场景已提供但未配置scene_arg,已忽略场景参数")
  458. # 如果配置了虚拟环境,构建 shell 命令
  459. if self.processor_config.venv:
  460. # 转义参数中的特殊字符
  461. escaped_cmd = []
  462. for arg in base_cmd:
  463. if ' ' in arg or '"' in arg or "'" in arg:
  464. # 使用单引号包裹,内部单引号转义
  465. arg = arg.replace("'", "'\\''")
  466. escaped_cmd.append(f"'{arg}'")
  467. else:
  468. escaped_cmd.append(arg)
  469. python_cmd = ' '.join(escaped_cmd)
  470. # 检查是否使用 conda
  471. if 'conda activate' in self.processor_config.venv:
  472. # 获取 conda 基础路径
  473. # 对于 conda,需要先 source conda.sh,然后 conda activate
  474. conda_init = """
  475. eval "$(conda shell.bash hook)"
  476. """.strip()
  477. shell_cmd = f"{conda_init} && {self.processor_config.venv} && {python_cmd}"
  478. else:
  479. # 对于 source 激活的虚拟环境
  480. shell_cmd = f"{self.processor_config.venv} && {python_cmd}"
  481. return shell_cmd
  482. else:
  483. # 没有虚拟环境,使用当前 Python 解释器
  484. base_cmd[0] = sys.executable
  485. return base_cmd
  486. def _generate_stats(self, total_duration: float) -> Dict[str, Any]:
  487. """生成统计信息"""
  488. success_count = sum(1 for r in self.results if r.success)
  489. failed_count = len(self.results) - success_count
  490. failed_files = [
  491. {
  492. 'file': r.pdf_file,
  493. 'error': r.error_message,
  494. 'log': r.log_file
  495. }
  496. for r in self.results if not r.success
  497. ]
  498. stats = {
  499. 'total': len(self.results),
  500. 'success': success_count,
  501. 'failed': failed_count,
  502. 'total_duration': total_duration,
  503. 'failed_files': failed_files,
  504. 'results': [
  505. {
  506. 'file': r.pdf_file,
  507. 'success': r.success,
  508. 'duration': r.duration,
  509. 'error': r.error_message,
  510. 'log': r.log_file
  511. }
  512. for r in self.results
  513. ]
  514. }
  515. return stats
  516. def _save_summary_log(self, stats: Dict[str, Any]):
  517. """🎯 保存汇总日志"""
  518. timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
  519. summary_log_file = self.log_base_dir / f"batch_summary_{self.processor_config.name}_{timestamp}.log"
  520. # 确保目录存在
  521. summary_log_file.parent.mkdir(parents=True, exist_ok=True)
  522. with open(summary_log_file, 'w', encoding='utf-8') as f:
  523. f.write("PDF 批量处理汇总日志\n")
  524. f.write("=" * 80 + "\n\n")
  525. f.write(f"处理器: {self.processor_config.description}\n")
  526. f.write(f"处理器名称: {self.processor_config.name}\n")
  527. f.write(f"脚本: {self.processor_config.script}\n")
  528. f.write(f"输出目录: {self.output_subdir}\n")
  529. f.write(f"日志目录: {self.processor_config.log_subdir}\n")
  530. if self.processor_config.venv:
  531. f.write(f"虚拟环境: {self.processor_config.venv}\n")
  532. f.write(f"开始时间: {datetime.now()}\n")
  533. f.write(f"总耗时: {stats['total_duration']:.2f} 秒\n\n")
  534. f.write("统计信息:\n")
  535. f.write(f" 总文件数: {stats['total']}\n")
  536. f.write(f" 成功: {stats['success']}\n")
  537. f.write(f" 失败: {stats['failed']}\n\n")
  538. if stats['failed_files']:
  539. f.write("失败的文件:\n")
  540. for item in stats['failed_files']:
  541. f.write(f" ✗ {item['file']}\n")
  542. f.write(f" 错误: {item['error']}\n")
  543. f.write(f" 日志: {item['log']}\n\n")
  544. f.write("详细结果:\n")
  545. for result in stats['results']:
  546. status = "✓" if result['success'] else "✗"
  547. f.write(f"{status} {result['file']} ({result['duration']:.2f}s)\n")
  548. f.write(f" 日志: {result['log']}\n")
  549. if result['error']:
  550. f.write(f" 错误: {result['error']}\n")
  551. self.logger.info(f"汇总日志已保存: {summary_log_file}")
  552. # ============================================================================
  553. # 命令行接口
  554. # ============================================================================
  555. def create_parser() -> argparse.ArgumentParser:
  556. """创建命令行参数解析器"""
  557. parser = argparse.ArgumentParser(
  558. description='PDF 批量处理工具 (支持虚拟环境自动切换)',
  559. formatter_class=argparse.RawDescriptionHelpFormatter,
  560. epilog="""
  561. 示例用法:
  562. 1. 使用配置文件中的处理器 (自动切换虚拟环境):
  563. python batch_process_pdf.py -p paddleocr_vl_single_process -f pdf_list.txt
  564. 2. 使用 DotsOCR 处理器 (自动切换到 py312 环境):
  565. python batch_process_pdf.py -p dotsocr_vllm -f pdf_list.txt
  566. 3. 使用 MinerU 处理器 (自动切换到 mineru2 环境):
  567. python batch_process_pdf.py -p mineru_vllm -f pdf_list.txt
  568. 4. 处理指定目录下所有 PDF:
  569. python batch_process_pdf.py -p ppstructurev3_single_client -d /path/to/pdfs
  570. 5. 列出所有可用的处理器:
  571. python batch_process_pdf.py --list-processors
  572. 6. 手动指定虚拟环境:
  573. python batch_process_pdf.py -p paddleocr_vl_single_process -f pdf_list.txt --venv "conda activate paddle"
  574. """
  575. )
  576. # 处理器选择
  577. parser.add_argument(
  578. '-p', '--processor',
  579. help='处理器名称'
  580. )
  581. # 配置文件
  582. parser.add_argument(
  583. '-c', '--config',
  584. default='processor_configs.yaml',
  585. help='配置文件路径 (默认: processor_configs.yaml)'
  586. )
  587. # 手动指定脚本
  588. parser.add_argument(
  589. '-s', '--script',
  590. help='Python 脚本路径 (覆盖配置文件)'
  591. )
  592. # 目录和文件
  593. parser.add_argument(
  594. '-d', '--base-dir',
  595. help='PDF 文件基础目录'
  596. )
  597. parser.add_argument(
  598. '-o', '--output-subdir',
  599. help='输出子目录名称 (覆盖处理器默认配置)'
  600. )
  601. parser.add_argument(
  602. '-f', '--file-list',
  603. help='PDF 文件列表文件路径'
  604. )
  605. parser.add_argument(
  606. '-l', '--pdf-list',
  607. nargs='+',
  608. help='PDF 文件列表 (空格分隔)'
  609. )
  610. # 场景参数
  611. parser.add_argument(
  612. '--scene',
  613. help='默认场景名称(文件列表未提供场景时使用)'
  614. )
  615. parser.add_argument(
  616. '--scene-arg',
  617. default='--scene',
  618. help='场景参数名称 (默认: --scene)'
  619. )
  620. # 额外参数
  621. parser.add_argument(
  622. '-e', '--extra-args',
  623. help='额外参数 (覆盖配置文件)'
  624. )
  625. # 虚拟环境
  626. parser.add_argument(
  627. '--venv',
  628. help='虚拟环境激活命令 (覆盖配置文件)'
  629. )
  630. # 工具选项
  631. parser.add_argument(
  632. '--list-processors',
  633. action='store_true',
  634. help='列出所有可用的处理器'
  635. )
  636. parser.add_argument(
  637. '--show-config',
  638. action='store_true',
  639. help='显示配置文件内容'
  640. )
  641. parser.add_argument(
  642. '--dry-run',
  643. action='store_true',
  644. help='模拟运行,不实际执行'
  645. )
  646. parser.add_argument(
  647. '-v', '--verbose',
  648. action='store_true',
  649. help='详细输出'
  650. )
  651. return parser
  652. def main():
  653. """主函数"""
  654. parser = create_parser()
  655. args = parser.parse_args()
  656. # 设置日志级别
  657. if args.verbose:
  658. logging.getLogger().setLevel(logging.DEBUG)
  659. # 加载配置
  660. config_manager = ConfigManager(args.config if Path(args.config).exists() else None)
  661. # 列出处理器
  662. if args.list_processors:
  663. print("可用的处理器:")
  664. for name in config_manager.list_processors():
  665. proc_config = config_manager.get_processor_config(name)
  666. print(f" • {name}")
  667. print(f" 描述: {proc_config.description}")
  668. print(f" 脚本: {proc_config.script}")
  669. print(f" 输出目录: {proc_config.output_subdir}")
  670. if proc_config.venv:
  671. print(f" 虚拟环境: {proc_config.venv}")
  672. print()
  673. return 0
  674. # 显示配置
  675. if args.show_config:
  676. print(yaml.dump(config_manager.config, allow_unicode=True))
  677. return 0
  678. # 获取处理器配置
  679. if args.processor:
  680. processor_config = config_manager.get_processor_config(args.processor)
  681. elif args.script:
  682. # 手动指定脚本
  683. processor_config = ProcessorConfig(
  684. name='manual',
  685. script=args.script,
  686. extra_args=args.extra_args.split() if args.extra_args else [],
  687. output_subdir=args.output_subdir or 'manual_results',
  688. scene_arg=args.scene_arg,
  689. venv=args.venv
  690. )
  691. # 如果配置中没有scene_arg且用户指定了scene,默认设置为--scene
  692. if args.scene and not processor_config.scene_arg:
  693. print("⚠️ 已指定场景但未配置scene_arg,忽略场景参数")
  694. else:
  695. parser.error("必须指定 -p 或 -s 参数")
  696. # 覆盖额外参数
  697. if args.extra_args and args.processor:
  698. processor_config.extra_args = args.extra_args.split()
  699. # 若处理器未配置scene_arg但用户提供了scene,使用默认场景参数名
  700. if args.scene and not processor_config.scene_arg:
  701. processor_config.scene_arg = args.scene_arg
  702. # 覆盖虚拟环境
  703. if args.venv:
  704. processor_config.venv = args.venv
  705. # 获取基础目录
  706. base_dir = args.base_dir or config_manager.get_global_config('base_dir')
  707. if not base_dir:
  708. parser.error("必须指定 -d 参数或在配置文件中设置 base_dir")
  709. log_base_dir = base_dir + '/' + config_manager.get_global_config('log_dir', 'logs')
  710. # 查找 PDF 文件
  711. finder = PDFFileFinder(base_dir)
  712. if args.file_list:
  713. pdf_files = finder.from_file_list(args.file_list)
  714. elif args.pdf_list:
  715. pdf_files = finder.from_list(args.pdf_list)
  716. else:
  717. pdf_files = finder.find_all()
  718. if not pdf_files:
  719. print("❌ 未找到任何 PDF 文件")
  720. return 1
  721. # 显示找到的文件
  722. valid_file_paths = [f"{t.path.as_posix()}\t{t.scene}" if t.scene else t.path.as_posix()
  723. for t in pdf_files if t.path.exists()]
  724. if valid_file_paths:
  725. print("\n".join(valid_file_paths))
  726. # 验证文件
  727. valid_files = [t for t in pdf_files if t.path.exists()]
  728. invalid_files = [t for t in pdf_files if not t.path.exists()]
  729. if invalid_files:
  730. print(f"\n⚠️ 警告: {len(invalid_files)} 个文件不存在:")
  731. for t in invalid_files[:5]:
  732. scene_suffix = f" (scene: {t.scene})" if t.scene else ""
  733. print(f" - {t.path}{scene_suffix}")
  734. if len(invalid_files) > 5:
  735. print(f" ... 还有 {len(invalid_files) - 5} 个")
  736. # scene 必填校验(当处理器需要scene时)
  737. if processor_config.scene_arg and not args.scene:
  738. missing_scene = [t for t in valid_files if not t.scene]
  739. if missing_scene:
  740. print("\n❌ 当前处理器需要场景参数,但文件列表未提供scene且未指定--scene")
  741. print(" 解决方法: \n1) 在列表中使用 '文件名\t场景' \n2) 或使用命令行 --scene bank_statement|financial_report")
  742. return 1
  743. # 确认执行
  744. if not args.dry_run and valid_files:
  745. venv_info = f" (虚拟环境: {processor_config.venv})" if processor_config.venv else ""
  746. confirm = input(f"\n是否继续处理 {len(valid_files)} 个文件{venv_info}? [Y/n]: ")
  747. if confirm.lower() not in ['', 'y', 'yes']:
  748. print("已取消")
  749. return 0
  750. # 批量处理
  751. processor = PDFBatchProcessor(
  752. processor_config=processor_config,
  753. output_subdir=args.output_subdir,
  754. log_base_dir=log_base_dir, # 🎯 传递日志目录
  755. dry_run=args.dry_run,
  756. default_scene=args.scene
  757. )
  758. stats = processor.process_files(valid_files)
  759. # 显示统计信息
  760. print("\n" + "=" * 80)
  761. print("处理完成")
  762. print("=" * 80)
  763. print(f"\n📊 统计信息:")
  764. print(f" 处理器: {processor_config.description}")
  765. print(f" 输出目录: {processor.output_subdir}")
  766. print(f" 日志目录: {processor.processor_config.log_subdir}")
  767. print(f" 总文件数: {stats['total']}")
  768. print(f" ✓ 成功: {stats['success']}")
  769. print(f" ✗ 失败: {stats['failed']}")
  770. print(f" ⏱️ 总耗时: {stats['total_duration']:.2f} 秒")
  771. if stats['failed_files']:
  772. print(f"\n失败的文件:")
  773. for item in stats['failed_files']:
  774. print(f" ✗ {item['file']}")
  775. print(f" 错误: {item['error']}")
  776. print(f" 日志: {item['log']}")
  777. return 0 if stats['failed'] == 0 else 1
  778. if __name__ == '__main__':
  779. print("🚀 启动批量OCR程序...")
  780. import sys
  781. if len(sys.argv) == 1:
  782. # 如果没有命令行参数,使用默认配置运行
  783. print("ℹ️ 未提供命令行参数,使用默认配置运行...")
  784. # 默认配置
  785. default_config = {
  786. "processor": "mineru_vllm",
  787. "file-list": "pdf_list.txt",
  788. }
  789. print("⚙️ 默认参数:")
  790. for key, value in default_config.items():
  791. print(f" --{key}: {value}")
  792. # 构造参数
  793. sys.argv = [sys.argv[0]]
  794. for key, value in default_config.items():
  795. sys.argv.extend([f"--{key}", str(value)])
  796. sys.argv.append("--dry-run")
  797. sys.argv.append("--verbose") # 添加详细输出参数
  798. sys.exit(main())