table_line_generator.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921
  1. """
  2. 基于 OCR bbox 的表格线生成模块
  3. 自动分析无线表格的行列结构,生成表格线
  4. """
  5. import cv2
  6. import numpy as np
  7. from PIL import Image, ImageDraw
  8. from pathlib import Path
  9. from typing import List, Dict, Tuple, Optional, Union
  10. import json
  11. from bs4 import BeautifulSoup
  12. import sys
  13. # 添加父目录到路径,以便导入 merger 模块
  14. sys.path.insert(0, str(Path(__file__).parent.parent))
  15. try:
  16. from merger.bbox_extractor import BBoxExtractor
  17. except ImportError:
  18. # 尝试相对导入 (当作为包安装时)
  19. from ..merger.bbox_extractor import BBoxExtractor
  20. class TableLineGenerator:
  21. """表格线生成器"""
  22. def __init__(self, image: Union[str, Image.Image, None], ocr_data: Dict):
  23. """
  24. 初始化表格线生成器
  25. Args:
  26. image: 图片路径(str) 或 PIL.Image 对象,或 None(仅分析结构时)
  27. ocr_data: OCR识别结果(包含bbox)
  28. """
  29. if image is None:
  30. # 🆕 无图片模式:仅用于结构分析
  31. self.image_path = None
  32. self.image = None
  33. elif isinstance(image, str):
  34. self.image_path = image
  35. self.image = Image.open(image)
  36. elif isinstance(image, Image.Image):
  37. self.image_path = None
  38. self.image = image
  39. else:
  40. raise TypeError(
  41. f"image 参数必须是 str (路径)、PIL.Image.Image 对象或 None,"
  42. f"实际类型: {type(image)}"
  43. )
  44. self.ocr_data = ocr_data
  45. # 表格结构参数
  46. self.rows = []
  47. self.columns = []
  48. self.row_height = 0
  49. self.col_widths = []
  50. self.is_skew_corrected = False # 是否已经校正过倾斜(默认 False)
  51. self.original_image = None
  52. @staticmethod
  53. def parse_ocr_data(ocr_result: Dict, tool: str = "ppstructv3") -> Tuple[List[int], Dict]:
  54. """
  55. 统一的 OCR 数据解析接口(第一步:仅读取数据)
  56. Args:
  57. ocr_result: OCR 识别结果(完整 JSON)
  58. tool: 工具类型 ("ppstructv3" / "mineru")
  59. Returns:
  60. (table_bbox, ocr_data): 表格边界框和文本框列表
  61. """
  62. if tool.lower() == "mineru":
  63. return TableLineGenerator._parse_mineru_data(ocr_result)
  64. elif tool.lower() in ["ppstructv3", "ppstructure"]:
  65. return TableLineGenerator._parse_ppstructure_data(ocr_result)
  66. else:
  67. raise ValueError(f"不支持的工具类型: {tool}")
  68. @staticmethod
  69. def _parse_mineru_data(mineru_result: Union[Dict, List]) -> Tuple[List[int], Dict]:
  70. """
  71. 解析 MinerU 格式数据(仅提取数据,不分析结构)
  72. Args:
  73. mineru_result: MinerU 的完整 JSON 结果
  74. Returns:
  75. (table_bbox, ocr_data): 表格边界框和文本框列表
  76. """
  77. # 🔑 提取 table 数据
  78. table_data = _extract_table_data(mineru_result)
  79. if not table_data:
  80. raise ValueError("未找到 MinerU 格式的表格数据 (type='table')")
  81. # 验证必要字段
  82. if 'table_cells' not in table_data:
  83. raise ValueError("表格数据中未找到 table_cells 字段")
  84. table_cells = table_data['table_cells']
  85. if not table_cells:
  86. raise ValueError("table_cells 为空")
  87. # 🔑 优先使用 table_body 确定准确的行列数
  88. if 'table_body' in table_data:
  89. actual_rows, actual_cols = _parse_table_body_structure(table_data['table_body'])
  90. print(f"📋 从 table_body 解析: {actual_rows} 行 × {actual_cols} 列")
  91. else:
  92. # 回退:从 table_cells 推断
  93. actual_rows = max(cell.get('row', 0) for cell in table_cells if 'row' in cell)
  94. actual_cols = max(cell.get('col', 0) for cell in table_cells if 'col' in cell)
  95. print(f"📋 从 table_cells 推断: {actual_rows} 行 × {actual_cols} 列")
  96. if not table_data or 'table_cells' not in table_data:
  97. raise ValueError("未找到有效的 MinerU 表格数据")
  98. table_cells = table_data['table_cells']
  99. # 🔑 计算表格边界框
  100. all_bboxes = [cell['bbox'] for cell in table_cells if 'bbox' in cell]
  101. if all_bboxes:
  102. x_min = min(bbox[0] for bbox in all_bboxes)
  103. y_min = min(bbox[1] for bbox in all_bboxes)
  104. x_max = max(bbox[2] for bbox in all_bboxes)
  105. y_max = max(bbox[3] for bbox in all_bboxes)
  106. table_bbox = [x_min, y_min, x_max, y_max]
  107. else:
  108. table_bbox = table_data.get('bbox', [0, 0, 2000, 2000])
  109. # 按位置排序(从上到下,从左到右)
  110. table_cells.sort(key=lambda x: (x['bbox'][1], x['bbox'][0]))
  111. # 🔑 转换为统一的 ocr_data 格式
  112. ocr_data = {
  113. 'table_bbox': table_bbox,
  114. 'actual_rows': actual_rows,
  115. 'actual_cols': actual_cols,
  116. 'text_boxes': table_cells,
  117. 'image_rotation_angle': table_data.get('image_rotation_angle', 0.0),
  118. 'skew_angle': table_data.get('skew_angle', 0.0),
  119. 'original_skew_angle': table_data.get('skew_angle', 0.0)
  120. }
  121. print(f"📊 MinerU 数据解析完成: {len(table_cells)} 个文本框")
  122. if ocr_data['image_rotation_angle'] != 0:
  123. print(f" 🔄 读取到图片旋转角度: {ocr_data['image_rotation_angle']}°")
  124. if ocr_data['skew_angle'] != 0:
  125. print(f" 📐 读取到倾斜角度: {ocr_data['skew_angle']:.2f}°")
  126. return table_bbox, ocr_data
  127. @staticmethod
  128. def _parse_ppstructure_data(ocr_result: Dict) -> Tuple[List[int], Dict]:
  129. """
  130. 解析 PPStructure V3 格式数据
  131. Args:
  132. ocr_result: PPStructure V3 的完整 JSON 结果
  133. Returns:
  134. (table_bbox, ocr_data): 表格边界框和文本框列表
  135. """
  136. # 1. 从 parsing_res_list 中找到 table 区域
  137. table_bbox = None
  138. if 'parsing_res_list' in ocr_result:
  139. for block in ocr_result['parsing_res_list']:
  140. if block.get('block_label') == 'table':
  141. table_bbox = block.get('block_bbox')
  142. break
  143. if not table_bbox:
  144. raise ValueError("未找到表格区域 (block_label='table')")
  145. # 2. 从 overall_ocr_res 中提取文本框
  146. text_boxes = []
  147. if 'overall_ocr_res' in ocr_result:
  148. rec_boxes = ocr_result['overall_ocr_res'].get('rec_boxes', [])
  149. rec_texts = ocr_result['overall_ocr_res'].get('rec_texts', [])
  150. # 过滤出表格区域内的文本框
  151. for i, bbox in enumerate(rec_boxes):
  152. if len(bbox) >= 4:
  153. x1, y1, x2, y2 = bbox[:4]
  154. # 判断文本框是否在表格区域内
  155. if (x1 >= table_bbox[0] and y1 >= table_bbox[1] and
  156. x2 <= table_bbox[2] and y2 <= table_bbox[3]):
  157. text_boxes.append({
  158. 'bbox': [int(x1), int(y1), int(x2), int(y2)],
  159. 'text': rec_texts[i] if i < len(rec_texts) else ''
  160. })
  161. # 按位置排序
  162. text_boxes.sort(key=lambda x: (x['bbox'][1], x['bbox'][0]))
  163. print(f"📊 PPStructure 数据解析完成: {len(text_boxes)} 个文本框")
  164. ocr_data = {
  165. 'table_bbox': table_bbox,
  166. 'text_boxes': text_boxes
  167. }
  168. return table_bbox, ocr_data
  169. # ==================== 统一接口:第二步 - 分析结构 ====================
  170. def analyze_table_structure(self,
  171. y_tolerance: int = 5,
  172. x_tolerance: int = 10,
  173. min_row_height: int = 20,
  174. method: str = "auto",
  175. ) -> Dict:
  176. """
  177. 分析表格结构(支持多种算法)
  178. Args:
  179. y_tolerance: Y轴聚类容差(像素)
  180. x_tolerance: X轴聚类容差(像素)
  181. min_row_height: 最小行高(像素)
  182. method: 分析方法 ("auto" / "cluster" / "mineru")
  183. use_table_body: 是否使用 table_body(仅 mineru 方法有效)
  184. Returns:
  185. 表格结构信息
  186. """
  187. if not self.ocr_data:
  188. return {}
  189. # 🔑 自动选择方法
  190. if method == "auto":
  191. # 根据数据特征自动选择
  192. has_cell_index = any('row' in item and 'col' in item for item in self.ocr_data.get('text_boxes', []))
  193. method = "mineru" if has_cell_index else "cluster"
  194. print(f"🤖 自动选择分析方法: {method}")
  195. # 🔑 根据方法选择算法
  196. if method == "mineru":
  197. return self._analyze_by_cell_index()
  198. else:
  199. return self._analyze_by_clustering(y_tolerance, x_tolerance, min_row_height)
  200. def correct_skew(self, force: bool = False) -> Tuple[Optional[Image.Image], float]:
  201. """
  202. 检测并校正图片倾斜(包含整图旋转和微小倾斜校正)
  203. 同时会更新 self.ocr_data 中的 bbox 坐标以匹配新图片
  204. Args:
  205. force: 是否强制重新校正
  206. Returns:
  207. (corrected_image, total_angle): 校正后的图片和总旋转角度
  208. """
  209. if self.is_skew_corrected and not force:
  210. # 如果已经校正过且不强制更新,直接返回当前状态
  211. return self.image, 0.0
  212. if not self.ocr_data or 'text_boxes' not in self.ocr_data:
  213. return self.image, 0.0
  214. text_boxes = self.ocr_data['text_boxes']
  215. # 1. 获取旋转和倾斜角度
  216. image_rotation_angle = self.ocr_data.get('image_rotation_angle', 0.0)
  217. skew_angle = self.ocr_data.get('skew_angle', 0.0)
  218. # 如果没有角度需要调整,且没有原始图片备份(说明没做过调整),则直接返回
  219. if image_rotation_angle == 0 and abs(skew_angle) < 0.1 and not self.original_image:
  220. return self.image, 0.0
  221. # 准备源图片
  222. if self.original_image:
  223. # 如果有原始图片备份,从原始图片开始
  224. current_image = self.original_image.copy()
  225. # 恢复 text_boxes 到原始状态 (这里假设 original_bbox 存储了最初的坐标)
  226. # 但实际上我们在 rotate_box_coordinates 时并没有保存 original_bbox 到 list 中
  227. # 这是一个问题。如果是多次旋转,坐标会乱。
  228. # 简单的做法:如果不复杂的逻辑,我们假设 self.ocr_data['text_boxes'] 里的 bbox 是相对于 self.image 的。
  229. # 如果我们要重做,我们需要原始的 bbox。
  230. # 在第一次 correct_skew 时,我们应该保存原始 bbox。
  231. # 让我们检查一下第一次 correct_skew 的逻辑。
  232. # 如果是第一次,我们用 self.image。
  233. pass
  234. elif self.image:
  235. self.original_image = self.image.copy()
  236. current_image = self.image
  237. else:
  238. return None, 0.0
  239. # 为了支持重做,我们需要保存原始的 OCR 数据。
  240. if 'original_text_boxes' not in self.ocr_data:
  241. # 深拷贝 text_boxes
  242. import copy
  243. self.ocr_data['original_text_boxes'] = copy.deepcopy(text_boxes)
  244. # 同时保存原始 table_bbox
  245. if 'table_bbox' in self.ocr_data:
  246. self.ocr_data['original_table_bbox'] = list(self.ocr_data['table_bbox'])
  247. # 使用原始数据进行计算
  248. working_text_boxes = [box.copy() for box in self.ocr_data['original_text_boxes']]
  249. original_size = self.original_image.size
  250. # 2. 执行图片旋转 (image_rotation_angle)
  251. if image_rotation_angle != 0:
  252. print(f" 🔄 执行图片旋转: {image_rotation_angle}°")
  253. current_image = current_image.rotate(image_rotation_angle, expand=True)
  254. # 更新 bbox 坐标 (原图坐标 -> 旋转后坐标)
  255. for box in working_text_boxes:
  256. if 'bbox' in box:
  257. box['bbox'] = BBoxExtractor.rotate_box_coordinates(
  258. box['bbox'], image_rotation_angle, original_size
  259. )
  260. # 更新 table_bbox
  261. if 'original_table_bbox' in self.ocr_data:
  262. self.ocr_data['table_bbox'] = BBoxExtractor.rotate_box_coordinates(
  263. self.ocr_data['original_table_bbox'], image_rotation_angle, original_size
  264. )
  265. else:
  266. # 如果没有旋转,恢复 table_bbox
  267. if 'original_table_bbox' in self.ocr_data:
  268. self.ocr_data['table_bbox'] = list(self.ocr_data['original_table_bbox'])
  269. # 3. 执行倾斜校正 (skew_angle)
  270. if abs(skew_angle) > 0.1:
  271. print(f" 📐 执行倾斜校正: {skew_angle:.2f}°")
  272. # 图片逆时针歪了 skew_angle 度,需要顺时针转 skew_angle 度校正
  273. correction_angle = -skew_angle
  274. current_image = current_image.rotate(correction_angle, expand=False, fillcolor='white')
  275. # 更新 bbox 坐标
  276. working_text_boxes = BBoxExtractor.correct_boxes_skew(
  277. working_text_boxes,
  278. correction_angle,
  279. current_image.size
  280. )
  281. # 更新 table_bbox
  282. if 'table_bbox' in self.ocr_data:
  283. dummy_box = [{'bbox': self.ocr_data['table_bbox'], 'poly': BBoxExtractor._bbox_to_poly(self.ocr_data['table_bbox'])}]
  284. corrected_dummy = BBoxExtractor.correct_boxes_skew(dummy_box, correction_angle, current_image.size)
  285. self.ocr_data['table_bbox'] = corrected_dummy[0]['bbox']
  286. self.image = current_image
  287. self.ocr_data['text_boxes'] = working_text_boxes
  288. self.is_skew_corrected = True
  289. return self.image, image_rotation_angle + skew_angle
  290. def _analyze_by_cell_index(self) -> Dict:
  291. """
  292. 基于单元格的 row/col 索引分析(MinerU 专用)
  293. Returns:
  294. 表格结构信息
  295. """
  296. if not self.ocr_data:
  297. return {}
  298. # 🔑 确定实际行列数
  299. actual_rows = self.ocr_data.get('actual_rows', 0)
  300. actual_cols = self.ocr_data.get('actual_cols', 0)
  301. print(f"📋 检测到: {actual_rows} 行 × {actual_cols} 列")
  302. ocr_data = self.ocr_data.get('text_boxes', [])
  303. # 🔑 按行列索引分组单元格
  304. cells_by_row = {}
  305. cells_by_col = {}
  306. for item in ocr_data:
  307. if 'row' not in item or 'col' not in item:
  308. continue
  309. row = item['row']
  310. col = item['col']
  311. bbox = item['bbox']
  312. if row <= actual_rows and col <= actual_cols:
  313. if row not in cells_by_row:
  314. cells_by_row[row] = []
  315. cells_by_row[row].append(bbox)
  316. if col not in cells_by_col:
  317. cells_by_col[col] = []
  318. cells_by_col[col].append(bbox)
  319. # 🔑 计算每行的 y 边界
  320. row_boundaries = {}
  321. for row_num in range(1, actual_rows + 1):
  322. if row_num in cells_by_row:
  323. bboxes = cells_by_row[row_num]
  324. y_min = min(bbox[1] for bbox in bboxes)
  325. y_max = max(bbox[3] for bbox in bboxes)
  326. row_boundaries[row_num] = (y_min, y_max)
  327. # 🔑 计算横线(现在使用的是过滤后的数据)
  328. horizontal_lines = _calculate_horizontal_lines_with_spacing(row_boundaries)
  329. # 🔑 列边界计算(同样需要过滤异常值)
  330. col_boundaries = {}
  331. for col_num in range(1, actual_cols + 1):
  332. if col_num in cells_by_col:
  333. bboxes = cells_by_col[col_num]
  334. # 🎯 过滤 x 方向的异常值(使用 IQR)
  335. if len(bboxes) > 1:
  336. x_centers = [(bbox[0] + bbox[2]) / 2 for bbox in bboxes]
  337. x_center_q1 = np.percentile(x_centers, 25)
  338. x_center_q3 = np.percentile(x_centers, 75)
  339. x_center_iqr = x_center_q3 - x_center_q1
  340. x_center_median = np.median(x_centers)
  341. # 允许偏移 3 倍 IQR 或至少 100px
  342. x_threshold = max(3 * x_center_iqr, 100)
  343. valid_bboxes = [
  344. bbox for bbox in bboxes
  345. if abs((bbox[0] + bbox[2]) / 2 - x_center_median) <= x_threshold
  346. ]
  347. else:
  348. valid_bboxes = bboxes
  349. if valid_bboxes:
  350. x_min = min(bbox[0] for bbox in valid_bboxes)
  351. x_max = max(bbox[2] for bbox in valid_bboxes)
  352. col_boundaries[col_num] = (x_min, x_max)
  353. # 🔑 计算竖线
  354. vertical_lines = _calculate_vertical_lines_with_spacing(col_boundaries)
  355. # 🔑 生成行区间
  356. self.rows = []
  357. for row_num in sorted(row_boundaries.keys()):
  358. y_min, y_max = row_boundaries[row_num]
  359. self.rows.append({
  360. 'y_start': y_min,
  361. 'y_end': y_max,
  362. 'bboxes': cells_by_row.get(row_num, []),
  363. 'row_index': row_num
  364. })
  365. # 🔑 生成列区间
  366. self.columns = []
  367. for col_num in sorted(col_boundaries.keys()):
  368. x_min, x_max = col_boundaries[col_num]
  369. self.columns.append({
  370. 'x_start': x_min,
  371. 'x_end': x_max,
  372. 'col_index': col_num
  373. })
  374. # 计算行高和列宽
  375. self.row_height = int(np.median([r['y_end'] - r['y_start'] for r in self.rows])) if self.rows else 0
  376. self.col_widths = [c['x_end'] - c['x_start'] for c in self.columns]
  377. # 获取角度信息
  378. image_rotation_angle = self.ocr_data.get('image_rotation_angle', 0.0)
  379. skew_angle = self.ocr_data.get('skew_angle', 0.0)
  380. return {
  381. 'rows': self.rows,
  382. 'columns': self.columns,
  383. 'horizontal_lines': horizontal_lines,
  384. 'vertical_lines': vertical_lines,
  385. 'row_height': self.row_height,
  386. 'col_widths': self.col_widths,
  387. 'table_bbox': self._get_table_bbox(),
  388. 'total_rows': actual_rows,
  389. 'total_cols': actual_cols,
  390. 'mode': 'hybrid', # ✅ 添加 mode 字段
  391. 'modified_h_lines': [], # ✅ 添加修改记录字段
  392. 'modified_v_lines': [], # ✅ 添加修改记录字段
  393. 'image_rotation_angle': image_rotation_angle,
  394. 'skew_angle': skew_angle,
  395. 'is_skew_corrected': self.is_skew_corrected
  396. }
  397. def _analyze_by_clustering(self, y_tolerance: int, x_tolerance: int, min_row_height: int) -> Dict:
  398. """
  399. 基于坐标聚类分析(通用方法)
  400. Args:
  401. y_tolerance: Y轴聚类容差
  402. x_tolerance: X轴聚类容差
  403. min_row_height: 最小行高
  404. Returns:
  405. 表格结构信息
  406. """
  407. if not self.ocr_data:
  408. return {}
  409. ocr_data = self.ocr_data.get('text_boxes', [])
  410. # 1. 提取所有bbox的Y坐标(用于行检测)
  411. y_coords = []
  412. for item in ocr_data:
  413. bbox = item.get('bbox', [])
  414. if len(bbox) >= 4:
  415. y1, y2 = bbox[1], bbox[3]
  416. y_coords.append((y1, y2, bbox))
  417. # 按Y坐标排序
  418. y_coords.sort(key=lambda x: x[0])
  419. # 2. 聚类检测行
  420. self.rows = self._cluster_rows(y_coords, y_tolerance, min_row_height)
  421. # 3. 计算标准行高
  422. row_heights = [row['y_end'] - row['y_start'] for row in self.rows]
  423. self.row_height = int(np.median(row_heights)) if row_heights else 30
  424. # 4. 提取所有bbox的X坐标(用于列检测)
  425. x_coords = []
  426. for item in ocr_data:
  427. bbox = item.get('bbox', [])
  428. if len(bbox) >= 4:
  429. x1, x2 = bbox[0], bbox[2]
  430. x_coords.append((x1, x2))
  431. # 5. 聚类检测列
  432. self.columns = self._cluster_columns(x_coords, x_tolerance)
  433. # 6. 计算列宽
  434. self.col_widths = [col['x_end'] - col['x_start'] for col in self.columns]
  435. # 7. 生成横线坐标
  436. horizontal_lines = []
  437. for row in self.rows:
  438. horizontal_lines.append(row['y_start'])
  439. if self.rows:
  440. horizontal_lines.append(self.rows[-1]['y_end'])
  441. # 8. 生成竖线坐标
  442. vertical_lines = []
  443. for col in self.columns:
  444. vertical_lines.append(col['x_start'])
  445. if self.columns:
  446. vertical_lines.append(self.columns[-1]['x_end'])
  447. return {
  448. 'rows': self.rows,
  449. 'columns': self.columns,
  450. 'horizontal_lines': horizontal_lines,
  451. 'vertical_lines': vertical_lines,
  452. 'row_height': self.row_height,
  453. 'col_widths': self.col_widths,
  454. 'table_bbox': self._get_table_bbox(),
  455. 'mode': 'fixed', # ✅ 添加 mode 字段
  456. 'modified_h_lines': [], # ✅ 添加修改记录字段
  457. 'modified_v_lines': [], # ✅ 添加修改记录字段
  458. 'image_rotation_angle': self.ocr_data.get('image_rotation_angle', 0.0),
  459. 'skew_angle': self.ocr_data.get('skew_angle', 0.0),
  460. 'is_skew_corrected': self.is_skew_corrected
  461. }
  462. @staticmethod
  463. def parse_mineru_table_result(mineru_result: Union[Dict, List], use_table_body: bool = True) -> Tuple[List[int], Dict]:
  464. """
  465. [已弃用] 建议使用 parse_ocr_data() + analyze_table_structure()
  466. 保留此方法是为了向后兼容
  467. """
  468. import warnings
  469. warnings.warn(
  470. "parse_mineru_table_result() 已弃用,请使用 "
  471. "parse_ocr_data() + analyze_table_structure()",
  472. DeprecationWarning
  473. )
  474. raise NotImplementedError( "parse_mineru_table_result() 已弃用,请使用 " "parse_ocr_data() + analyze_table_structure()")
  475. @staticmethod
  476. def parse_ppstructure_result(ocr_result: Dict) -> Tuple[List[int], Dict]:
  477. """
  478. [推荐] 解析 PPStructure V3 的 OCR 结果
  479. 这是第一步操作,建议继续使用
  480. """
  481. return TableLineGenerator._parse_ppstructure_data(ocr_result)
  482. def _cluster_rows(self, y_coords: List[Tuple], tolerance: int, min_height: int) -> List[Dict]:
  483. """聚类检测行"""
  484. if not y_coords:
  485. return []
  486. rows = []
  487. current_row = {
  488. 'y_start': y_coords[0][0],
  489. 'y_end': y_coords[0][1],
  490. 'bboxes': [y_coords[0][2]]
  491. }
  492. for i in range(1, len(y_coords)):
  493. y1, y2, bbox = y_coords[i]
  494. if abs(y1 - current_row['y_start']) <= tolerance:
  495. current_row['y_start'] = min(current_row['y_start'], y1)
  496. current_row['y_end'] = max(current_row['y_end'], y2)
  497. current_row['bboxes'].append(bbox)
  498. else:
  499. if current_row['y_end'] - current_row['y_start'] >= min_height:
  500. rows.append(current_row)
  501. current_row = {
  502. 'y_start': y1,
  503. 'y_end': y2,
  504. 'bboxes': [bbox]
  505. }
  506. if current_row['y_end'] - current_row['y_start'] >= min_height:
  507. rows.append(current_row)
  508. return rows
  509. def _cluster_columns(self, x_coords: List[Tuple], tolerance: int) -> List[Dict]:
  510. """聚类检测列"""
  511. if not x_coords:
  512. return []
  513. all_x = []
  514. for x1, x2 in x_coords:
  515. all_x.append(x1)
  516. all_x.append(x2)
  517. all_x = sorted(set(all_x))
  518. columns = []
  519. current_x = all_x[0]
  520. for x in all_x[1:]:
  521. if x - current_x > tolerance:
  522. columns.append(current_x)
  523. current_x = x
  524. columns.append(current_x)
  525. column_regions = []
  526. for i in range(len(columns) - 1):
  527. column_regions.append({
  528. 'x_start': columns[i],
  529. 'x_end': columns[i + 1]
  530. })
  531. return column_regions
  532. def _get_table_bbox(self) -> List[int]:
  533. """获取表格整体边界框"""
  534. if not self.rows or not self.columns:
  535. if self.image:
  536. return [0, 0, self.image.width, self.image.height]
  537. return [0, 0, 0, 0]
  538. y_min = min(row['y_start'] for row in self.rows)
  539. y_max = max(row['y_end'] for row in self.rows)
  540. x_min = min(col['x_start'] for col in self.columns)
  541. x_max = max(col['x_end'] for col in self.columns)
  542. return [x_min, y_min, x_max, y_max]
  543. def generate_table_lines(self,
  544. line_color: Tuple[int, int, int] = (0, 0, 255),
  545. line_width: int = 2) -> Image.Image:
  546. """在原图上绘制表格线"""
  547. if self.image is None:
  548. raise ValueError(
  549. "无图片模式下不能调用 generate_table_lines(),"
  550. "请在初始化时提供图片"
  551. )
  552. img_with_lines = self.image.copy()
  553. draw = ImageDraw.Draw(img_with_lines)
  554. x_start = self.columns[0]['x_start'] if self.columns else 0
  555. x_end = self.columns[-1]['x_end'] if self.columns else img_with_lines.width
  556. y_start = self.rows[0]['y_start'] if self.rows else 0
  557. y_end = self.rows[-1]['y_end'] if self.rows else img_with_lines.height
  558. # 绘制横线
  559. for row in self.rows:
  560. y = row['y_start']
  561. draw.line([(x_start, y), (x_end, y)], fill=line_color, width=line_width)
  562. if self.rows:
  563. y = self.rows[-1]['y_end']
  564. draw.line([(x_start, y), (x_end, y)], fill=line_color, width=line_width)
  565. # 绘制竖线
  566. for col in self.columns:
  567. x = col['x_start']
  568. draw.line([(x, y_start), (x, y_end)], fill=line_color, width=line_width)
  569. if self.columns:
  570. x = self.columns[-1]['x_end']
  571. draw.line([(x, y_start), (x, y_end)], fill=line_color, width=line_width)
  572. return img_with_lines
  573. @staticmethod
  574. def analyze_structure_only(
  575. ocr_data: Dict,
  576. y_tolerance: int = 5,
  577. x_tolerance: int = 10,
  578. min_row_height: int = 20,
  579. method: str = "auto"
  580. ) -> Dict:
  581. """
  582. 仅分析表格结构(无需图片)
  583. Args:
  584. ocr_data: OCR识别结果
  585. y_tolerance: Y轴聚类容差(像素)
  586. x_tolerance: X轴聚类容差(像素)
  587. min_row_height: 最小行高(像素)
  588. method: 分析方法 ("auto" / "cluster" / "mineru")
  589. Returns:
  590. 表格结构信息
  591. """
  592. # 🔑 创建无图片模式的生成器
  593. temp_generator = TableLineGenerator(None, ocr_data)
  594. # 🔑 分析结构
  595. return temp_generator.analyze_table_structure(
  596. y_tolerance=y_tolerance,
  597. x_tolerance=x_tolerance,
  598. min_row_height=min_row_height,
  599. method=method
  600. )
  601. def _calculate_horizontal_lines_with_spacing(row_boundaries: Dict[int, Tuple[int, int]]) -> List[int]:
  602. """
  603. 计算横线位置(考虑行间距)
  604. Args:
  605. row_boundaries: {row_num: (y_min, y_max)}
  606. Returns:
  607. 横线 y 坐标列表
  608. """
  609. if not row_boundaries:
  610. return []
  611. sorted_rows = sorted(row_boundaries.items())
  612. # 🔑 分析相邻行之间的间隔
  613. gaps = []
  614. gap_info = [] # 保存详细信息用于调试
  615. for i in range(len(sorted_rows) - 1):
  616. row_num1, (y_min1, y_max1) = sorted_rows[i]
  617. row_num2, (y_min2, y_max2) = sorted_rows[i + 1]
  618. gap = y_min2 - y_max1 # 行间距(可能为负,表示重叠)
  619. gaps.append(gap)
  620. gap_info.append({
  621. 'row1': row_num1,
  622. 'row2': row_num2,
  623. 'gap': gap
  624. })
  625. print(f"📏 行间距详情:")
  626. for info in gap_info:
  627. status = "重叠" if info['gap'] < 0 else "正常"
  628. print(f" 行 {info['row1']} → {info['row2']}: {info['gap']:.1f}px ({status})")
  629. # 🔑 过滤掉负数 gap(重叠情况)和极小的 gap
  630. valid_gaps = [g for g in gaps if g > 2] # 至少 2px 间隔才算有效
  631. if valid_gaps:
  632. gap_median = np.median(valid_gaps)
  633. gap_std = np.std(valid_gaps)
  634. print(f"📏 行间距统计: 中位数={gap_median:.1f}px, 标准差={gap_std:.1f}px")
  635. print(f" 有效间隔数: {len(valid_gaps)}/{len(gaps)}")
  636. # 🔑 生成横线坐标(在相邻行中间)
  637. horizontal_lines = []
  638. for i, (row_num, (y_min, y_max)) in enumerate(sorted_rows):
  639. if i == 0:
  640. # 第一行的上边界
  641. horizontal_lines.append(y_min)
  642. if i < len(sorted_rows) - 1:
  643. next_row_num, (next_y_min, next_y_max) = sorted_rows[i + 1]
  644. gap = next_y_min - y_max
  645. if gap > 0:
  646. # 有间隔:在间隔中间画线
  647. # separator_y = int((y_max + next_y_min) / 2)
  648. # 有间隔:更靠近下一行的位置
  649. separator_y = int(next_y_min) - max(int(gap / 4), 2)
  650. horizontal_lines.append(separator_y)
  651. else:
  652. # 重叠或紧贴:在当前行的下边界画线
  653. separator_y = int(next_y_min) - max(int(gap / 4), 2)
  654. horizontal_lines.append(separator_y)
  655. else:
  656. # 最后一行的下边界
  657. horizontal_lines.append(y_max)
  658. return sorted(set(horizontal_lines))
  659. def _calculate_vertical_lines_with_spacing(col_boundaries: Dict[int, Tuple[int, int]]) -> List[int]:
  660. """
  661. 计算竖线位置(考虑列间距和重叠)
  662. Args:
  663. col_boundaries: {col_num: (x_min, x_max)}
  664. Returns:
  665. 竖线 x 坐标列表
  666. """
  667. if not col_boundaries:
  668. return []
  669. sorted_cols = sorted(col_boundaries.items())
  670. # 🔑 分析相邻列之间的间隔
  671. gaps = []
  672. gap_info = []
  673. for i in range(len(sorted_cols) - 1):
  674. col_num1, (x_min1, x_max1) = sorted_cols[i]
  675. col_num2, (x_min2, x_max2) = sorted_cols[i + 1]
  676. gap = x_min2 - x_max1 # 列间距(可能为负)
  677. gaps.append(gap)
  678. gap_info.append({
  679. 'col1': col_num1,
  680. 'col2': col_num2,
  681. 'gap': gap
  682. })
  683. print(f"📏 列间距详情:")
  684. for info in gap_info:
  685. status = "重叠" if info['gap'] < 0 else "正常"
  686. print(f" 列 {info['col1']} → {info['col2']}: {info['gap']:.1f}px ({status})")
  687. # 🔑 过滤掉负数 gap
  688. valid_gaps = [g for g in gaps if g > 2]
  689. if valid_gaps:
  690. gap_median = np.median(valid_gaps)
  691. gap_std = np.std(valid_gaps)
  692. print(f"📏 列间距统计: 中位数={gap_median:.1f}px, 标准差={gap_std:.1f}px")
  693. # 🔑 生成竖线坐标(在相邻列中间)
  694. vertical_lines = []
  695. for i, (col_num, (x_min, x_max)) in enumerate(sorted_cols):
  696. if i == 0:
  697. # 第一列的左边界
  698. vertical_lines.append(x_min)
  699. if i < len(sorted_cols) - 1:
  700. next_col_num, (next_x_min, next_x_max) = sorted_cols[i + 1]
  701. gap = next_x_min - x_max
  702. if gap > 0:
  703. # 有间隔:在间隔中间画线
  704. separator_x = int((x_max + next_x_min) / 2)
  705. vertical_lines.append(separator_x)
  706. else:
  707. # 重叠或紧贴:在当前列的右边界画线
  708. vertical_lines.append(x_max)
  709. else:
  710. # 最后一列的右边界
  711. vertical_lines.append(x_max)
  712. return sorted(set(vertical_lines))
  713. def _extract_table_data(mineru_result: Union[Dict, List]) -> Optional[Dict]:
  714. """提取 table 数据"""
  715. if isinstance(mineru_result, list):
  716. for item in mineru_result:
  717. if isinstance(item, dict) and item.get('type') == 'table':
  718. return item
  719. elif isinstance(mineru_result, dict):
  720. if mineru_result.get('type') == 'table':
  721. return mineru_result
  722. # 递归查找
  723. for value in mineru_result.values():
  724. if isinstance(value, dict) and value.get('type') == 'table':
  725. return value
  726. elif isinstance(value, list):
  727. result = _extract_table_data(value)
  728. if result:
  729. return result
  730. return None
  731. def _parse_table_body_structure(table_body: str) -> Tuple[int, int]:
  732. """从 table_body HTML 中解析准确的行列数"""
  733. try:
  734. soup = BeautifulSoup(table_body, 'html.parser')
  735. table = soup.find('table')
  736. if not table:
  737. raise ValueError("未找到 <table> 标签")
  738. rows = table.find_all('tr')
  739. if not rows:
  740. raise ValueError("未找到 <tr> 标签")
  741. num_rows = len(rows)
  742. first_row = rows[0]
  743. num_cols = len(first_row.find_all(['td', 'th']))
  744. return num_rows, num_cols
  745. except Exception as e:
  746. print(f"⚠️ 解析 table_body 失败: {e}")
  747. return 0, 0