visualization_utils.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. """
  2. 可视化工具模块
  3. 提供文档处理结果的可视化功能:
  4. - Layout 布局可视化
  5. - OCR 结果可视化
  6. - 图片元素保存
  7. """
  8. from pathlib import Path
  9. from typing import Dict, Any, List, Tuple
  10. import numpy as np
  11. from PIL import Image, ImageDraw, ImageFont
  12. import cv2
  13. from loguru import logger
  14. class VisualizationUtils:
  15. """可视化工具类"""
  16. # 颜色映射(与 MinerU BlockType / EnhancedDocPipeline 类别保持一致)
  17. COLOR_MAP = {
  18. # 文本类元素 (TEXT_CATEGORIES)
  19. 'title': (102, 102, 255), # 蓝色
  20. 'text': (153, 0, 76), # 深红
  21. 'ocr_text': (153, 0, 76), # 深红(同 text)
  22. 'low_score_text': (200, 100, 100), # 浅红
  23. 'header': (128, 128, 128), # 灰色
  24. 'footer': (128, 128, 128), # 灰色
  25. 'page_number': (160, 160, 160), # 浅灰
  26. 'ref_text': (180, 180, 180), # 浅灰
  27. 'aside_text': (180, 180, 180), # 浅灰
  28. 'page_footnote': (200, 200, 200), # 浅灰
  29. # 表格相关元素
  30. 'table': (204, 204, 0), # 黄色
  31. 'table_body': (204, 204, 0), # 黄色
  32. 'table_caption': (255, 255, 102), # 浅黄
  33. 'table_footnote': (229, 255, 204), # 浅黄绿
  34. # 图片相关元素
  35. 'image': (153, 255, 51), # 绿色
  36. 'image_body': (153, 255, 51), # 绿色
  37. 'figure': (153, 255, 51), # 绿色
  38. 'image_caption': (102, 178, 255), # 浅蓝
  39. 'image_footnote': (255, 178, 102), # 橙色
  40. # 公式类元素
  41. 'interline_equation': (0, 255, 0), # 亮绿
  42. 'inline_equation': (0, 200, 0), # 绿色
  43. 'equation': (0, 220, 0), # 绿色
  44. 'interline_equation_yolo': (0, 180, 0),
  45. 'interline_equation_number': (0, 160, 0),
  46. # 代码类元素
  47. 'code': (102, 0, 204), # 紫色
  48. 'code_body': (102, 0, 204), # 紫色
  49. 'code_caption': (153, 51, 255), # 浅紫
  50. 'algorithm': (128, 0, 255), # 紫色
  51. # 列表类元素
  52. 'list': (40, 169, 92), # 青绿
  53. 'index': (60, 180, 100), # 青绿
  54. # 图表 / 印章
  55. 'chart': (0, 200, 200),
  56. 'seal': (255, 140, 0), # 亮橙(RGB),debug 与最终 layout 图一致
  57. # 丢弃类元素
  58. 'abandon': (100, 100, 100), # 深灰
  59. 'discarded': (100, 100, 100), # 深灰
  60. # 错误
  61. 'error': (255, 0, 0), # 红色
  62. # --- 通用工具颜色(非元素类别,供 module_debug_viz / ocr_validator 引用) ---
  63. # OCR 文字框:亮蓝(白底/浅灰上比黄/红色易辨认)
  64. 'ocr_box': (0, 0, 255),
  65. # 印章 OCR 框:亮橙(独立管线,与 layout seal 颜色一致,审计时区分)
  66. 'seal_ocr_box': (255, 140, 0),
  67. # 表格单元格框:与 ocr_box 同色
  68. 'cell_box': (0, 0, 255),
  69. # 丢弃/废弃元素框
  70. 'discard': (128, 128, 128),
  71. }
  72. @staticmethod
  73. def rgb_to_bgr(rgb: tuple) -> tuple:
  74. """RGB → BGR(供 OpenCV 模块使用)。"""
  75. return tuple(rgb[i] for i in (2, 1, 0)) if len(rgb) >= 3 else rgb
  76. # --- 向后兼容别名(推荐使用 COLOR_MAP['ocr_box'] 等) ---
  77. OCR_BOX_COLOR = (0, 0, 255)
  78. CELL_BOX_COLOR = (0, 0, 255)
  79. DISCARD_COLOR = (128, 128, 128) # 灰色
  80. @staticmethod
  81. def save_image_elements(
  82. results: Dict[str, Any],
  83. images_dir: Path,
  84. doc_name: str,
  85. is_pdf: bool = True
  86. ) -> List[str]:
  87. """
  88. 保存图片元素
  89. 命名规则:
  90. - PDF输入: 文件名_page_001_image_1.png
  91. - 图片输入(单页): 文件名_image_1.png
  92. Args:
  93. results: 处理结果
  94. images_dir: 图片输出目录
  95. doc_name: 文档名称
  96. is_pdf: 是否为 PDF 输入
  97. Returns:
  98. 保存的图片路径列表
  99. """
  100. saved_paths = []
  101. image_count = 0
  102. total_pages = len(results.get('pages', []))
  103. for page in results.get('pages', []):
  104. page_idx = page.get('page_idx', 0)
  105. for element in page.get('elements', []):
  106. if element.get('type') in ['image', 'image_body', 'figure']:
  107. content = element.get('content', {})
  108. image_data = content.get('image_data')
  109. if image_data is not None:
  110. image_count += 1
  111. # 根据输入类型决定命名
  112. if is_pdf or total_pages > 1:
  113. image_filename = f"{doc_name}_page_{page_idx + 1}_image_{image_count}.png"
  114. else:
  115. image_filename = f"{doc_name}_image_{image_count}.png"
  116. image_path = images_dir / image_filename
  117. try:
  118. if isinstance(image_data, np.ndarray):
  119. cv2.imwrite(str(image_path), image_data)
  120. else:
  121. Image.fromarray(image_data).save(image_path)
  122. # 更新路径(只保存文件名)
  123. content['image_path'] = image_filename
  124. content.pop('image_data', None)
  125. saved_paths.append(str(image_path))
  126. logger.debug(f"🖼️ Image saved: {image_path}")
  127. except Exception as e:
  128. logger.warning(f"Failed to save image: {e}")
  129. if image_count > 0:
  130. logger.info(f"🖼️ {image_count} images saved to: {images_dir}")
  131. return saved_paths
  132. @staticmethod
  133. def save_layout_images(
  134. results: Dict[str, Any],
  135. output_dir: Path,
  136. doc_name: str,
  137. draw_type_label: bool = True,
  138. draw_bbox_number: bool = True,
  139. is_pdf: bool = True
  140. ) -> List[str]:
  141. """
  142. 保存 Layout 可视化图片
  143. 命名规则:
  144. - PDF输入: 文件名_page_001_layout.png
  145. - 图片输入(单页): 文件名_layout.png
  146. Args:
  147. results: 处理结果
  148. output_dir: 输出目录
  149. doc_name: 文档名称
  150. draw_type_label: 是否绘制类型标签
  151. draw_bbox_number: 是否绘制序号
  152. is_pdf: 是否为 PDF 输入
  153. Returns:
  154. 保存的图片路径列表
  155. """
  156. layout_paths = []
  157. total_pages = len(results.get('pages', []))
  158. for page in results.get('pages', []):
  159. page_idx = page.get('page_idx', 0)
  160. processed_image = page.get('original_image')
  161. if processed_image is None:
  162. processed_image = page.get('processed_image')
  163. if processed_image is None:
  164. logger.warning(f"Page {page_idx}: No image data found for layout visualization")
  165. continue
  166. if isinstance(processed_image, np.ndarray):
  167. image = Image.fromarray(processed_image).convert('RGB')
  168. elif isinstance(processed_image, Image.Image):
  169. image = processed_image.convert('RGB')
  170. else:
  171. continue
  172. draw = ImageDraw.Draw(image, 'RGBA')
  173. font = VisualizationUtils._get_font(14)
  174. # 绘制普通元素
  175. for idx, element in enumerate(page.get('elements', []), 1):
  176. elem_type = element.get('type', '')
  177. bbox = element.get('bbox', [0, 0, 0, 0])
  178. if len(bbox) < 4:
  179. continue
  180. x0, y0, x1, y1 = map(int, bbox[:4])
  181. color = VisualizationUtils.COLOR_MAP.get(elem_type, (255, 0, 0))
  182. # 半透明填充
  183. overlay = Image.new('RGBA', image.size, (255, 255, 255, 0))
  184. overlay_draw = ImageDraw.Draw(overlay)
  185. overlay_draw.rectangle([x0, y0, x1, y1], fill=(*color, 50))
  186. image = Image.alpha_composite(image.convert('RGBA'), overlay).convert('RGB')
  187. draw = ImageDraw.Draw(image)
  188. # 边框
  189. draw.rectangle([x0, y0, x1, y1], outline=color, width=2)
  190. # 类型标签
  191. if draw_type_label:
  192. label = elem_type.replace('_', ' ').title()
  193. bbox_label = draw.textbbox((x0 + 2, y0 + 2), label, font=font)
  194. draw.rectangle(bbox_label, fill=color)
  195. draw.text((x0 + 2, y0 + 2), label, fill='white', font=font)
  196. # 序号
  197. if draw_bbox_number:
  198. number_text = str(idx)
  199. bbox_number = draw.textbbox((x1 - 25, y0 + 2), number_text, font=font)
  200. draw.rectangle(bbox_number, fill=(255, 0, 0))
  201. draw.text((x1 - 25, y0 + 2), number_text, fill='white', font=font)
  202. # 绘制丢弃元素(灰色样式)
  203. for idx, element in enumerate(page.get('discarded_blocks', []), 1):
  204. original_category = element.get('original_category', 'unknown')
  205. bbox = element.get('bbox', [0, 0, 0, 0])
  206. if len(bbox) < 4:
  207. continue
  208. x0, y0, x1, y1 = map(int, bbox[:4])
  209. # 半透明填充
  210. overlay = Image.new('RGBA', image.size, (255, 255, 255, 0))
  211. overlay_draw = ImageDraw.Draw(overlay)
  212. overlay_draw.rectangle([x0, y0, x1, y1], fill=(*VisualizationUtils.COLOR_MAP['discard'], 30))
  213. image = Image.alpha_composite(image.convert('RGBA'), overlay).convert('RGB')
  214. draw = ImageDraw.Draw(image)
  215. # 灰色边框
  216. draw.rectangle([x0, y0, x1, y1], outline=VisualizationUtils.COLOR_MAP['discard'], width=1)
  217. # 类型标签
  218. if draw_type_label:
  219. label = f"D:{original_category}"
  220. bbox_label = draw.textbbox((x0 + 2, y0 + 2), label, font=font)
  221. draw.rectangle(bbox_label, fill=VisualizationUtils.COLOR_MAP['discard'])
  222. draw.text((x0 + 2, y0 + 2), label, fill='white', font=font)
  223. # 根据输入类型决定命名
  224. if is_pdf or total_pages > 1:
  225. layout_path = output_dir / f"{doc_name}_page_{page_idx + 1:03d}_layout.png"
  226. else:
  227. layout_path = output_dir / f"{doc_name}_layout.png"
  228. image.save(layout_path)
  229. layout_paths.append(str(layout_path))
  230. logger.info(f"🖼️ Layout image saved: {layout_path}")
  231. return layout_paths
  232. @staticmethod
  233. def save_ocr_images(
  234. results: Dict[str, Any],
  235. output_dir: Path,
  236. doc_name: str,
  237. is_pdf: bool = True
  238. ) -> List[str]:
  239. """
  240. 保存 OCR 可视化图片(与 *_page_001.json 同源同构)。
  241. 数据源为 JSONFormatters._element_to_cell_bbox_format 转换后的扁平格式
  242. (与 save_page_jsons 输出的 JSON 一致);
  243. 绘制样式与 debug/ocr_recognition 一致:亮蓝实线=有文字,虚线=仅框无字。
  244. 命名规则:
  245. - PDF输入: 文件名_page_001_ocr.png
  246. - 图片输入(单页): 文件名_ocr.png
  247. """
  248. from ocr_utils.json_formatters import JSONFormatters
  249. from ocr_utils.module_debug_viz import draw_ocr_spans_cv2
  250. ocr_paths = []
  251. total_pages = len(results.get('pages', []))
  252. for page in results.get('pages', []):
  253. page_idx = page.get('page_idx', 0)
  254. processed_image = page.get('original_image')
  255. if processed_image is None:
  256. processed_image = page.get('processed_image')
  257. if processed_image is None:
  258. logger.warning(f"Page {page_idx}: No image data found for OCR visualization")
  259. continue
  260. page_rotation_angle = float(page.get('angle', 0))
  261. flat_elements = []
  262. for element in (page.get('elements') or []):
  263. converted = JSONFormatters._element_to_cell_bbox_format(
  264. element, page_idx, page_rotation_angle
  265. )
  266. if converted:
  267. flat_elements.append(converted)
  268. for element in (page.get('discarded_blocks') or []):
  269. converted = JSONFormatters._element_to_cell_bbox_format(
  270. element, page_idx, page_rotation_angle
  271. )
  272. if converted:
  273. flat_elements.append(converted)
  274. spans = []
  275. for elem in flat_elements:
  276. bbox = elem.get('bbox', [])
  277. if not bbox or len(bbox) < 4:
  278. continue
  279. elem_type = elem.get('type', '')
  280. if 'table_cells' in elem:
  281. for cell in elem['table_cells']:
  282. cell_bbox = cell.get('bbox', [])
  283. if cell_bbox and len(cell_bbox) >= 4:
  284. spans.append({
  285. 'bbox': cell_bbox[:4],
  286. 'text': cell.get('text', '').strip(),
  287. })
  288. elif elem.get('text') is not None:
  289. spans.append({
  290. 'bbox': bbox[:4],
  291. 'text': str(elem.get('text', '')).strip(),
  292. 'category': 'seal' if elem_type == 'seal' else None,
  293. })
  294. else:
  295. spans.append({
  296. 'bbox': bbox[:4],
  297. 'text': '',
  298. })
  299. vis_bgr = draw_ocr_spans_cv2(processed_image, spans)
  300. vis_rgb = cv2.cvtColor(vis_bgr, cv2.COLOR_BGR2RGB)
  301. image = Image.fromarray(vis_rgb)
  302. if is_pdf or total_pages > 1:
  303. ocr_path = output_dir / f"{doc_name}_page_{page_idx + 1:03d}_ocr.png"
  304. else:
  305. ocr_path = output_dir / f"{doc_name}_ocr.png"
  306. image.save(ocr_path)
  307. ocr_paths.append(str(ocr_path))
  308. logger.info(f"🖼️ OCR image saved: {ocr_path}")
  309. return ocr_paths
  310. @staticmethod
  311. def _draw_polygon(
  312. draw: ImageDraw.Draw,
  313. bbox: List,
  314. color: Tuple[int, int, int],
  315. width: int = 1
  316. ):
  317. """
  318. 绘制多边形或矩形
  319. Args:
  320. draw: ImageDraw 对象
  321. bbox: 坐标(4点多边形或矩形)
  322. color: 颜色
  323. width: 线宽
  324. """
  325. if isinstance(bbox[0], (list, tuple)):
  326. points = [(int(p[0]), int(p[1])) for p in bbox]
  327. points.append(points[0])
  328. draw.line(points, fill=color, width=width)
  329. elif len(bbox) >= 4:
  330. x0, y0, x1, y1 = map(int, bbox[:4])
  331. draw.rectangle([x0, y0, x1, y1], outline=color, width=width)
  332. @staticmethod
  333. def _get_font(size: int) -> ImageFont.FreeTypeFont:
  334. """
  335. 获取字体
  336. Args:
  337. size: 字体大小
  338. Returns:
  339. 字体对象
  340. """
  341. font_paths = [
  342. "/System/Library/Fonts/Helvetica.ttc",
  343. "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
  344. "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
  345. ]
  346. for font_path in font_paths:
  347. try:
  348. return ImageFont.truetype(font_path, size)
  349. except:
  350. continue
  351. return ImageFont.load_default()
  352. @staticmethod
  353. def draw_bbox_on_image(image: Image.Image, bbox: List[int], color: str = "red", width: int = 3) -> Image.Image:
  354. """
  355. 在图片上绘制bbox框
  356. Args:
  357. image: PIL Image 对象
  358. bbox: 边界框坐标 [x1, y1, x2, y2]
  359. color: 边框颜色(字符串,如 "red", "blue", "green")
  360. width: 边框宽度
  361. Returns:
  362. 绘制了 bbox 的图像副本
  363. """
  364. img_copy = image.copy()
  365. draw = ImageDraw.Draw(img_copy)
  366. x1, y1, x2, y2 = bbox
  367. # 绘制矩形框
  368. draw.rectangle([x1, y1, x2, y2], outline=color, width=width)
  369. # 添加半透明填充
  370. overlay = Image.new('RGBA', img_copy.size, (0, 0, 0, 0))
  371. overlay_draw = ImageDraw.Draw(overlay)
  372. color_map = {
  373. "red": (255, 0, 0, 30),
  374. "blue": (0, 0, 255, 30),
  375. "green": (0, 255, 0, 30)
  376. }
  377. fill_color = color_map.get(color, (255, 255, 0, 30))
  378. overlay_draw.rectangle([x1, y1, x2, y2], fill=fill_color)
  379. img_copy = Image.alpha_composite(img_copy.convert('RGBA'), overlay).convert('RGB')
  380. return img_copy