compare_pdf_renderers.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. #!/usr/bin/env python3
  2. """
  3. 对比分析 fitz (PyMuPDF) 和 pypdfium2 渲染 PDF 的差异
  4. 用于诊断 UNet 表格识别结果不一致的问题
  5. """
  6. import os
  7. import sys
  8. from pathlib import Path
  9. from PIL import Image
  10. import numpy as np
  11. import cv2
  12. # 图片路径
  13. FITZ_IMAGE = Path("/Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/universal_doc_parser/tests/2023年度报告母公司_page_003.png")
  14. PYPDFIUM2_IMAGE = Path("/Users/zhch158/workspace/repository.git/ocr_platform/ocr_tools/universal_doc_parser/output/2023年度报告母公司/bank_statement_wired_unet/2023年度报告母公司/2023年度报告母公司_page_003.png")
  15. def analyze_image(image_path: Path, label: str):
  16. """分析图像的详细属性"""
  17. print(f"\n{'='*70}")
  18. print(f"分析: {label}")
  19. print(f"{'='*70}")
  20. if not image_path.exists():
  21. print(f"❌ 文件不存在: {image_path}")
  22. return None, None, None
  23. # 文件大小
  24. file_size = os.path.getsize(image_path) / 1024 # KB
  25. print(f"📦 文件大小: {file_size:.2f} KB")
  26. # PIL 加载
  27. img_pil = Image.open(image_path)
  28. print(f"📐 图像尺寸: {img_pil.size[0]} × {img_pil.size[1]} (宽×高)")
  29. print(f"🎨 颜色模式: {img_pil.mode}")
  30. print(f"📊 格式: {img_pil.format}")
  31. # 获取 DPI 信息
  32. dpi = img_pil.info.get('dpi', 'N/A')
  33. print(f"🔍 DPI 信息: {dpi}")
  34. # OpenCV 加载
  35. img_cv = cv2.imread(str(image_path))
  36. if img_cv is not None:
  37. print(f"📏 OpenCV 尺寸: {img_cv.shape[0]} × {img_cv.shape[1]} × {img_cv.shape[2]} (高×宽×通道)")
  38. print(f"📈 数据类型: {img_cv.dtype}")
  39. # 统计信息
  40. print(f"🔢 像素值范围: [{img_cv.min()}, {img_cv.max()}]")
  41. print(f"📊 平均值: {img_cv.mean():.2f}")
  42. print(f"📊 标准差: {img_cv.std():.2f}")
  43. # 检查是否有纯黑/纯白区域
  44. black_pixels = np.all(img_cv == 0, axis=-1).sum()
  45. white_pixels = np.all(img_cv == 255, axis=-1).sum()
  46. total_pixels = img_cv.shape[0] * img_cv.shape[1]
  47. print(f"⚫ 纯黑像素: {black_pixels} ({black_pixels/total_pixels*100:.2f}%)")
  48. print(f"⚪ 纯白像素: {white_pixels} ({white_pixels/total_pixels*100:.2f}%)")
  49. # NumPy 数组
  50. img_np = np.array(img_pil)
  51. print(f"🧮 NumPy shape: {img_np.shape}")
  52. print(f"🧮 NumPy dtype: {img_np.dtype}")
  53. return img_pil, img_cv, img_np
  54. def compare_images(img1_pil: Image.Image, img2_pil: Image.Image, label1: str, label2: str):
  55. """对比两张图像的差异"""
  56. print(f"\n{'='*70}")
  57. print(f"对比: {label1} vs {label2}")
  58. print(f"{'='*70}")
  59. # 尺寸对比
  60. if img1_pil.size == img2_pil.size:
  61. print(f"✅ 尺寸一致: {img1_pil.size[0]} × {img1_pil.size[1]}")
  62. else:
  63. print(f"❌ 尺寸不一致:")
  64. print(f" {label1}: {img1_pil.size[0]} × {img1_pil.size[1]}")
  65. print(f" {label2}: {img2_pil.size[0]} × {img2_pil.size[1]}")
  66. print(f"\n⚠️ 尺寸不同,无法进行像素级对比")
  67. return
  68. # 转换为 numpy 数组
  69. arr1 = np.array(img1_pil)
  70. arr2 = np.array(img2_pil)
  71. # 像素差异
  72. diff = np.abs(arr1.astype(np.float32) - arr2.astype(np.float32))
  73. print(f"\n📊 像素差异统计:")
  74. print(f" 最大差异: {diff.max():.2f} (0-255 范围)")
  75. print(f" 平均差异: {diff.mean():.2f}")
  76. print(f" 中位数差异: {np.median(diff):.2f}")
  77. print(f" 差异标准差: {diff.std():.2f}")
  78. # 相同像素百分比
  79. identical_pixels = np.all(arr1 == arr2, axis=-1).sum()
  80. total_pixels = arr1.shape[0] * arr1.shape[1]
  81. identical_ratio = identical_pixels / total_pixels * 100
  82. print(f"\n✓ 完全相同的像素: {identical_pixels:,} / {total_pixels:,} ({identical_ratio:.2f}%)")
  83. # 差异分布
  84. diff_1px = np.sum(np.any(diff <= 1, axis=-1))
  85. diff_5px = np.sum(np.any(diff <= 5, axis=-1))
  86. diff_10px = np.sum(np.any(diff <= 10, axis=-1))
  87. print(f"\n📈 差异分布 (有差异的像素):")
  88. print(f" ≤ 1 灰度级: {diff_1px:,} ({diff_1px/total_pixels*100:.2f}%)")
  89. print(f" ≤ 5 灰度级: {diff_5px:,} ({diff_5px/total_pixels*100:.2f}%)")
  90. print(f" ≤ 10 灰度级: {diff_10px:,} ({diff_10px/total_pixels*100:.2f}%)")
  91. print(f" > 10 灰度级: {(total_pixels - diff_10px):,} ({(total_pixels - diff_10px)/total_pixels*100:.2f}%)")
  92. # 颜色通道差异
  93. if len(arr1.shape) == 3:
  94. print(f"\n🎨 各颜色通道差异:")
  95. for i, channel in enumerate(['红色 (R)', '绿色 (G)', '蓝色 (B)']):
  96. channel_diff = np.abs(arr1[:,:,i].astype(np.float32) - arr2[:,:,i].astype(np.float32))
  97. print(f" {channel}: 平均 {channel_diff.mean():.2f}, 最大 {channel_diff.max():.0f}")
  98. # 生成差异热图
  99. diff_map = diff.mean(axis=-1) if len(diff.shape) == 3 else diff
  100. max_diff_loc = np.unravel_index(diff_map.argmax(), diff_map.shape)
  101. print(f"\n🔥 最大差异位置:")
  102. print(f" 坐标: (y={max_diff_loc[0]}, x={max_diff_loc[1]})")
  103. print(f" 差异值: {diff_map[max_diff_loc]:.2f}")
  104. print(f" {label1} 像素值: {arr1[max_diff_loc]}")
  105. print(f" {label2} 像素值: {arr2[max_diff_loc]}")
  106. # SSIM 结构相似性
  107. try:
  108. from skimage.metrics import structural_similarity as ssim
  109. # 转换为灰度
  110. gray1 = cv2.cvtColor(arr1, cv2.COLOR_RGB2GRAY) if len(arr1.shape) == 3 else arr1
  111. gray2 = cv2.cvtColor(arr2, cv2.COLOR_RGB2GRAY) if len(arr2.shape) == 3 else arr2
  112. ssim_value = ssim(gray1, gray2)
  113. print(f"\n📏 SSIM 结构相似性: {ssim_value:.6f}")
  114. print(f" (1.0 = 完全相同, >0.95 = 几乎相同, <0.9 = 有明显差异)")
  115. except ImportError:
  116. print(f"\n⚠️ 未安装 scikit-image,跳过 SSIM 计算")
  117. print(f" 安装: pip install scikit-image")
  118. # 保存差异图
  119. output_dir = Path(__file__).parent / "analysis_output"
  120. output_dir.mkdir(exist_ok=True)
  121. # 差异热图 (归一化到 0-255)
  122. diff_visual = (diff_map / diff_map.max() * 255).astype(np.uint8) if diff_map.max() > 0 else diff_map.astype(np.uint8)
  123. diff_colored = cv2.applyColorMap(diff_visual, cv2.COLORMAP_JET)
  124. cv2.imwrite(str(output_dir / "diff_heatmap.png"), diff_colored)
  125. # 保存原始差异图(未归一化)
  126. diff_raw = diff_map.astype(np.uint8)
  127. cv2.imwrite(str(output_dir / "diff_raw.png"), diff_raw)
  128. # 保存二值化差异(差异 > 5 的区域)
  129. diff_binary = (diff_map > 5).astype(np.uint8) * 255
  130. cv2.imwrite(str(output_dir / "diff_binary_5px.png"), diff_binary)
  131. print(f"\n💾 差异图已保存到: {output_dir}")
  132. print(f" - diff_heatmap.png (彩色热图)")
  133. print(f" - diff_raw.png (原始差异)")
  134. print(f" - diff_binary_5px.png (差异>5的区域)")
  135. def analyze_rendering_differences():
  136. """分析渲染差异的根本原因"""
  137. print(f"\n{'='*70}")
  138. print("🔬 渲染差异根本原因分析")
  139. print(f"{'='*70}")
  140. print("""
  141. ## 主要差异来源:
  142. ### 1. 抗锯齿算法 (Anti-aliasing)
  143. • PyMuPDF (fitz): 使用 MuPDF 渲染引擎,默认启用抗锯齿
  144. • pypdfium2: 使用 PDFium 渲染引擎(Chrome PDF 引擎)
  145. 影响: 边缘平滑度不同,细线条的像素值会有 1-3 灰度级差异
  146. ### 2. 颜色空间处理
  147. • PyMuPDF: MuPDF 内部颜色管理
  148. • pypdfium2: Chromium 颜色管理系统
  149. 影响: RGB 值可能有 1-2 个灰度级的系统性偏差
  150. ### 3. 字体渲染引擎
  151. • PyMuPDF: FreeType 字体渲染
  152. • pypdfium2: PDFium/Skia 字体渲染
  153. 影响: 文字边缘、字形细节略有不同,影响 OCR 识别
  154. ### 4. DPI 缩放算法
  155. • PyMuPDF: fitz.Matrix() 矩阵变换
  156. • pypdfium2: bitmap.render(scale=) 缩放
  157. 影响: 插值算法不同,导致边缘像素值差异
  158. ### 5. 尺寸限制策略
  159. • PyMuPDF: >4500px → 降为 72 DPI
  160. • pypdfium2: >3500px → 动态调整 scale
  161. 影响: 大尺寸 PDF 可能产生不同分辨率的图像
  162. ## 对 UNet 表格识别的影响:
  163. ### 直接影响:
  164. ✗ 线条边缘抗锯齿差异 → UNet 检测线条位置有 1-2 像素偏移
  165. ✗ 文字清晰度差异 → 影响单元格文本区域识别
  166. ✗ 整体对比度差异 → 影响表格线检测阈值
  167. ### 建议解决方案:
  168. 1. 统一渲染引擎: 全部使用 pypdfium2 (更稳定、更快)
  169. 2. 保存调试图像: 保存 UNet 输入图像以便排查
  170. 3. 调整检测阈值: 考虑渲染差异,适当放宽容差
  171. 4. 使用相同测试数据: 确保 test 和 production 使用同一渲染方法
  172. """)
  173. def main():
  174. print("="*70)
  175. print("PDF 渲染引擎对比分析工具")
  176. print("fitz (PyMuPDF) vs pypdfium2")
  177. print("="*70)
  178. # 检查文件是否存在
  179. if not FITZ_IMAGE.exists():
  180. print(f"\n❌ fitz 图像不存在: {FITZ_IMAGE}")
  181. print(f" 请确保已使用 fitz 渲染 PDF 并保存图像")
  182. return 1
  183. if not PYPDFIUM2_IMAGE.exists():
  184. print(f"\n❌ pypdfium2 图像不存在: {PYPDFIUM2_IMAGE}")
  185. print(f" 请运行 pipeline 生成输出图像")
  186. return 1
  187. # 分析两张图片
  188. print("\n" + "🔍 第一步: 分析各自的图像属性")
  189. fitz_pil, fitz_cv, fitz_np = analyze_image(FITZ_IMAGE, "PyMuPDF (fitz)")
  190. if fitz_pil is None:
  191. return 1
  192. pypdfium2_pil, pypdfium2_cv, pypdfium2_np = analyze_image(PYPDFIUM2_IMAGE, "pypdfium2")
  193. if pypdfium2_pil is None:
  194. return 1
  195. # 对比差异
  196. print("\n" + "📊 第二步: 对比两张图像的差异")
  197. compare_images(fitz_pil, pypdfium2_pil, "PyMuPDF", "pypdfium2")
  198. # 分析根本原因
  199. print("\n" + "💡 第三步: 分析差异的根本原因")
  200. analyze_rendering_differences()
  201. print(f"\n{'='*70}")
  202. print("✅ 分析完成")
  203. print(f"{'='*70}")
  204. print(f"\n查看输出目录: {Path(__file__).parent / 'analysis_output'}")
  205. return 0
  206. if __name__ == "__main__":
  207. sys.exit(main())