7 Revize 48440201a0 ... 4f888dc393

Autor SHA1 Zpráva Datum
  zhch158_admin 4f888dc393 feat: Add rotation adjustment feature to the adjustment section, enabling fine-tuning of image skew and automatic analysis trigger after adjustments před 1 dnem
  zhch158_admin 5c8b27eb5c feat: Update apply_template_fixed to support both List and Dict for OCR data, enabling skew correction and angle retrieval před 1 dnem
  zhch158_admin 1172c53f35 feat: Implement skew correction functionality in TableLineGenerator, enhancing image processing and OCR data handling před 1 dnem
  zhch158_admin 2c401275a4 fix: Correct skew angle calculation for box rotation in TableCellMatcher před 1 dnem
  zhch158_admin 1cc6140bfc refactor: Update default configuration for PaddleOCR with new file paths and parameters před 1 dnem
  zhch158_admin 4c7aee8d8e feat: Add new markdown file for user and cursor analysis on skew correction logic před 1 dnem
  zhch158_admin 843cbc19fd fix: Update bbox rotation logic and improve poly handling for skew correction před 1 dnem

+ 26 - 13
merger/bbox_extractor.py

@@ -536,7 +536,7 @@ class BBoxExtractor:
                     angle_deg: float, 
                     center: Tuple[float, float] = (0, 0)) -> Tuple[float, float]:
         """
-        旋转点坐标
+        旋转点坐标 (图像坐标系:Y轴向下)
         
         Args:
             point: 原始点 (x, y)
@@ -554,8 +554,11 @@ class BBoxExtractor:
         x -= cx
         y -= cy
         
-        x_new = x * np.cos(angle_rad) - y * np.sin(angle_rad)
-        y_new = x * np.sin(angle_rad) + y * np.cos(angle_rad)
+        # 图像坐标系(Y轴向下)下的逆时针旋转公式
+        # x' = x cosθ + y sinθ
+        # y' = -x sinθ + y cosθ
+        x_new = x * np.cos(angle_rad) + y * np.sin(angle_rad)
+        y_new = -x * np.sin(angle_rad) + y * np.cos(angle_rad)
         
         x_new += cx
         y_new += cy
@@ -564,20 +567,21 @@ class BBoxExtractor:
     
     @staticmethod
     def correct_boxes_skew(paddle_boxes: List[Dict], 
-                          rotation_angle: float,
+                          correction_angle: float,
                           image_size: Tuple[int, int]) -> List[Dict]:
         """
         校正文本框的倾斜
         
         Args:
             paddle_boxes: Paddle OCR 结果
-            rotation_angle: 倾斜角度(度数)
+            correction_angle: 校正旋转角度(度数,正值=逆时针,负值=顺时针)
+                              注意:这里直接传入需要旋转的角度,不再自动取反
             image_size: 图像尺寸 (width, height)
         
         Returns:
             校正后的文本框列表
         """
-        if abs(rotation_angle) < 0.1:
+        if abs(correction_angle) < 0.01:
             return paddle_boxes
         
         width, height = image_size
@@ -587,15 +591,24 @@ class BBoxExtractor:
         
         for box in paddle_boxes:
             poly = box.get('poly', [])
-            if len(poly) < 4:
-                corrected_boxes.append(box)
-                continue
+            
+            # 🆕 修复:如果没有 poly,尝试从 bbox 生成
+            # 这是为了兼容 MinerU 或其他没有 poly 的数据源
+            if not poly or len(poly) < 4:
+                if 'bbox' in box and len(box['bbox']) == 4:
+                    poly = BBoxExtractor._bbox_to_poly(box['bbox'])
+                else:
+                    corrected_boxes.append(box)
+                    continue
             
             # 旋转多边形
-            rotated_poly = [
-                BBoxExtractor.rotate_point(point, -rotation_angle, center)
-                for point in poly
-            ]
+            rotated_poly = []
+            for point in poly:
+                # 确保点是 tuple 或 list,并只有 2 个坐标
+                p = (point[0], point[1]) if isinstance(point, (list, tuple)) and len(point) >= 2 else (0.0, 0.0)
+                # 直接使用 correction_angle 进行旋转
+                rotated_point = BBoxExtractor.rotate_point(p, correction_angle, center)
+                rotated_poly.append([rotated_point[0], rotated_point[1]]) # 转换回 list 以匹配 _poly_to_bbox 类型
             
             # 重新计算 bbox
             corrected_bbox = BBoxExtractor._poly_to_bbox(rotated_poly)

+ 14 - 14
merger/merge_paddleocr_vl_paddleocr.py

@@ -284,30 +284,30 @@ if __name__ == "__main__":
     
     if len(sys.argv) == 1:
         # 默认配置
-        default_config = {
-            "paddleocr-vl-file": "/Users/zhch158/workspace/data/流水分析/A用户_单元格扫描流水/paddleocr_vl_results/A用户_单元格扫描流水_page_007.json",
-            "paddle-file": "/Users/zhch158/workspace/data/流水分析/A用户_单元格扫描流水/ppstructurev3_client_results/A用户_单元格扫描流水_page_007.json",
-            "output-dir": "/Users/zhch158/workspace/data/流水分析/A用户_单元格扫描流水/paddleocr_vl_results_cell_bbox",
-            "output-type": "both",
-            "window": "15",
-            "threshold": "85"
-        }
         # default_config = {
-        #     "paddleocr-vl-file": "/Users/zhch158/workspace/data/流水分析/B用户_扫描流水/paddleocr_vl_results/B用户_扫描流水_page_001.json",
-        #     "paddle-file": "/Users/zhch158/workspace/data/流水分析/B用户_扫描流水/ppstructurev3_client_results/B用户_扫描流水_page_001.json",
-        #     "output-dir": "/Users/zhch158/workspace/data/流水分析/B用户_扫描流水/paddleocr_vl_results_cell_bbox",
+        #     "paddleocr-vl-file": "/Users/zhch158/workspace/data/流水分析/A用户_单元格扫描流水/paddleocr_vl_results/A用户_单元格扫描流水_page_007.json",
+        #     "paddle-file": "/Users/zhch158/workspace/data/流水分析/A用户_单元格扫描流水/ppstructurev3_client_results/A用户_单元格扫描流水_page_007.json",
+        #     "output-dir": "/Users/zhch158/workspace/data/流水分析/A用户_单元格扫描流水/paddleocr_vl_results_cell_bbox",
         #     "output-type": "both",
         #     "window": "15",
         #     "threshold": "85"
         # }
         # default_config = {
-        #     "paddleocr-vl-file": "/Users/zhch158/workspace/data/流水分析/2023年度报告母公司/paddleocr_vl_results/2023年度报告母公司_page_007.json",
-        #     "paddle-file": "/Users/zhch158/workspace/data/流水分析/2023年度报告母公司/ppstructurev3_client_results/2023年度报告母公司_page_007.json",
-        #     "output-dir": "/Users/zhch158/workspace/data/流水分析/2023年度报告母公司/paddleocr_vl_results_cell_bbox",
+        #     "paddleocr-vl-file": "/Users/zhch158/workspace/data/流水分析/B用户_扫描流水/paddleocr_vl_results/B用户_扫描流水_page_001.json",
+        #     "paddle-file": "/Users/zhch158/workspace/data/流水分析/B用户_扫描流水/ppstructurev3_client_results/B用户_扫描流水_page_001.json",
+        #     "output-dir": "/Users/zhch158/workspace/data/流水分析/B用户_扫描流水/paddleocr_vl_results_cell_bbox",
         #     "output-type": "both",
         #     "window": "15",
         #     "threshold": "85"
         # }
+        default_config = {
+            "paddleocr-vl-file": "/Users/zhch158/workspace/data/流水分析/2023年度报告母公司/paddleocr_vl_results/2023年度报告母公司_page_006.json",
+            "paddle-file": "/Users/zhch158/workspace/data/流水分析/2023年度报告母公司/ppstructurev3_client_results/2023年度报告母公司_page_006.json",
+            "output-dir": "/Users/zhch158/workspace/data/流水分析/2023年度报告母公司/paddleocr_vl_results_cell_bbox",
+            "output-type": "both",
+            "window": "15",
+            "threshold": "85"
+        }
         # default_config = {
         #     "paddleocr-vl-file": "/Users/zhch158/workspace/data/流水分析/康强_北京农村商业银行/paddleocr_vl_results/康强_北京农村商业银行_page_001.json",
         #     "paddle-file": "/Users/zhch158/workspace/data/流水分析/康强_北京农村商业银行/ppstructurev3_client_results/康强_北京农村商业银行_page_001.json",

+ 5 - 1
merger/table_cell_matcher.py

@@ -501,8 +501,12 @@ class TableCellMatcher:
                 image_size = (max_x, max_y)
                 
                 print(f"   🔧 校正倾斜角度: {skew_angle:.2f}°")
+                
+                # 计算校正角度 (顺时针旋转)
+                correction_angle = -skew_angle
+                
                 paddle_boxes = BBoxExtractor.correct_boxes_skew(
-                    paddle_boxes, -skew_angle, image_size
+                    paddle_boxes, correction_angle, image_size
                 )
         
         # 🎯 步骤 2: 按校正后的 y 坐标分组

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 4 - 0
merger/坐标系变换.md


+ 49 - 2
table_line_generator/editor/adjustments.py

@@ -22,7 +22,7 @@ def create_adjustment_section(structure):
     # 行操作, 列操作
     adjustment_action = st.radio(
         "行&列操作",
-        ["调整横线", "添加横线", "删除横线", "调整竖线", "添加竖线", "删除竖线"],
+        ["调整横线", "添加横线", "删除横线", "调整竖线", "添加竖线", "删除竖线", "微调旋转"],
         horizontal=True,
         index=None,
         label_visibility="collapsed",
@@ -132,7 +132,6 @@ def create_adjustment_section(structure):
             if idx not in structure['modified_v_lines']:
                 structure['modified_v_lines'].append(idx)
             _update_column_intervals(structure)
-            _update_column_intervals(structure)
             clear_table_image_cache()
             adjusted = True
             st.success(f"✅ 新增竖线 X={new_v_x}")
@@ -153,6 +152,54 @@ def create_adjustment_section(structure):
             adjusted = True
             st.success(f"✅ 已删除 {len(to_delete)} 条竖线")
 
+    elif adjustment_action == "微调旋转":
+        st.info("📐 微调图片的旋转角度 (基于当前角度), 正值逆时针旋转,负值顺时针旋转")
+        
+        if 'generator' in st.session_state:
+            generator = st.session_state.generator
+            current_skew = generator.ocr_data.get('skew_angle', 0.0)
+            original_skew = generator.ocr_data.get('original_skew_angle', 0.0)
+            
+            st.markdown(f"""
+            **当前总倾斜校正:** `{current_skew:.2f}°`  --   *(原始文件读取: `{original_skew:.2f}°`)*
+            """)
+            
+            col1, col2 = st.columns([1, 1], width=300, gap="small")
+            with col1:
+                delta_angle = st.number_input(
+                    "旋转偏移量 (度)",
+                    min_value=-10.0,
+                    max_value=10.0,
+                    value=0.0,
+                    step=0.01,
+                    format="%.2f",
+                    help="正值逆时针旋转,负值顺时针旋转",
+                    label_visibility="collapsed",
+                    key="rotate_delta_input"
+                )
+            with col2:
+                if st.button("🔄 应用", key="apply_rotate_btn"):
+                    if delta_angle != 0:
+                        # 更新 skew_angle
+                        generator.ocr_data['skew_angle'] = current_skew + delta_angle
+                        
+                        with st.spinner("正在重新校正图片..."):
+                            # 强制重新校正
+                            corrected_image, _ = generator.correct_skew(force=True)
+                            st.session_state.image = corrected_image
+                            
+                            # 清除缓存
+                            clear_table_image_cache()
+                            adjusted = True
+                            
+                            # 标记需要重新分析结构
+                            st.session_state.need_reanalysis = True
+                            
+                            st.success(f"✅ 已应用旋转 {delta_angle}°")
+                            st.rerun()
+        else:
+            st.warning("⚠️ 无法获取 TableLineGenerator 实例")
+
     return adjusted
 
 

+ 13 - 22
table_line_generator/editor/analysis_controls.py

@@ -19,35 +19,26 @@ def create_analysis_section(generator, tool: str = "ppstructv3") -> Optional[Dic
     """
     st.sidebar.subheader("🔍 表格结构分析")
     
+    # 检查是否需要自动触发分析(例如旋转微调后)
+    auto_analyze = st.session_state.get('need_reanalysis', False)
+    if auto_analyze:
+        st.session_state.need_reanalysis = False
+    
     # 🔑 根据工具类型显示不同的参数
     if tool.lower() == "mineru":
         st.sidebar.info("📋 MinerU 格式:直接使用 table_cells 生成结构")
         
-        if st.sidebar.button("🚀 生成表格结构", type="primary"):
+        if st.sidebar.button("🚀 生成表格结构", type="primary") or auto_analyze:
             with st.spinner("正在分析表格结构..."):
                 try:
-                    # 🔑 MinerU 格式:从原始 JSON 重新解析
-                    current_catalog = st.session_state.get('current_catalog', [])
-                    current_index = st.session_state.get('current_catalog_index', 0)
-                    
-                    if not current_catalog or current_index >= len(current_catalog):
-                        st.error("❌ 未找到当前文件")
-                        return None
-                    
-                    entry = current_catalog[current_index]
-                    
-                    # 加载原始 JSON
-                    with open(entry["json"], "r", encoding="utf-8") as fp:
-                        raw = json.load(fp)
-                    
-                    # 重新解析获取完整结构
-                    from .data_processor import get_structure_from_ocr
-                    
-                    table_bbox, structure = get_structure_from_ocr(raw, tool)
+                    # 🔑 使用 generator 分析 (支持旋转后的坐标)
+                    structure = generator.analyze_table_structure(method="mineru")
                     
                     # 保存到 session_state
                     st.session_state.structure = structure
-                    st.session_state.table_bbox = table_bbox
+                    if 'table_bbox' in structure:
+                        st.session_state.table_bbox = structure['table_bbox']
+                    
                     st.session_state.undo_stack = []
                     st.session_state.redo_stack = []
                     
@@ -57,7 +48,7 @@ def create_analysis_section(generator, tool: str = "ppstructv3") -> Optional[Dic
                     
                     st.success(
                         f"✅ 表格结构生成成功!\n\n"
-                        f"检测到 {structure['total_rows']} 行,{structure['total_cols']} 列"
+                        f"检测到 {len(structure.get('rows', []))} 行,{len(structure.get('columns', []))} 列"
                     )
                     return structure
                     
@@ -93,7 +84,7 @@ def create_analysis_section(generator, tool: str = "ppstructv3") -> Optional[Dic
             help="行高小于此值的将被过滤"
         )
         
-        if st.sidebar.button("🚀 分析表格结构", type="primary"):
+        if st.sidebar.button("🚀 分析表格结构", type="primary") or auto_analyze:
             with st.spinner("正在分析表格结构..."):
                 try:
                     structure = generator.analyze_table_structure(

+ 13 - 0
table_line_generator/editor/mode_setup.py

@@ -36,6 +36,19 @@ def setup_new_annotation_mode(
     # 初始化生成器
     if 'generator' not in st.session_state:
         st.session_state.generator = TableLineGenerator(image, ocr_data)
+        
+        # 🆕 自动校正倾斜和旋转
+        corrected_image, angle = st.session_state.generator.correct_skew()
+        
+        # 获取角度信息
+        ocr_data_dict = st.session_state.generator.ocr_data
+        image_rotation_angle = ocr_data_dict.get('image_rotation_angle', 0.0) if isinstance(ocr_data_dict, dict) else 0.0
+        skew_angle = ocr_data_dict.get('skew_angle', 0.0) if isinstance(ocr_data_dict, dict) else 0.0
+        
+        if abs(skew_angle) > 0.1 or image_rotation_angle != 0:
+            st.info(f"🔄 自动校正: 旋转={image_rotation_angle}°, 倾斜={skew_angle:.2f}°")
+            # 更新 session_state 中的图片
+            st.session_state.image = corrected_image
     
     # 分析控件
     structure = create_analysis_section(

+ 145 - 4
table_line_generator/table_line_generator.py

@@ -10,6 +10,16 @@ from pathlib import Path
 from typing import List, Dict, Tuple, Optional, Union
 import json
 from bs4 import BeautifulSoup
+import sys
+
+# 添加父目录到路径,以便导入 merger 模块
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+try:
+    from merger.bbox_extractor import BBoxExtractor
+except ImportError:
+    # 尝试相对导入 (当作为包安装时)
+    from ..merger.bbox_extractor import BBoxExtractor
 
 
 class TableLineGenerator:
@@ -46,6 +56,9 @@ class TableLineGenerator:
         self.columns = []
         self.row_height = 0
         self.col_widths = []
+        
+        self.is_skew_corrected = False # 是否已经校正过倾斜(默认 False)
+        self.original_image = None
 
 
     @staticmethod
@@ -125,10 +138,17 @@ class TableLineGenerator:
             'table_bbox': table_bbox,
             'actual_rows': actual_rows,
             'actual_cols': actual_cols,
-            'text_boxes': table_cells
+            'text_boxes': table_cells,
+            'image_rotation_angle': table_data.get('image_rotation_angle', 0.0),
+            'skew_angle': table_data.get('skew_angle', 0.0),
+            'original_skew_angle': table_data.get('skew_angle', 0.0)
         }
         
         print(f"📊 MinerU 数据解析完成: {len(table_cells)} 个文本框")
+        if ocr_data['image_rotation_angle'] != 0:
+            print(f"   🔄 读取到图片旋转角度: {ocr_data['image_rotation_angle']}°")
+        if ocr_data['skew_angle'] != 0:
+            print(f"   📐 读取到倾斜角度: {ocr_data['skew_angle']:.2f}°")
         
         return table_bbox, ocr_data
 
@@ -221,6 +241,115 @@ class TableLineGenerator:
         else:
             return self._analyze_by_clustering(y_tolerance, x_tolerance, min_row_height)
 
+    def correct_skew(self, force: bool = False) -> Tuple[Optional[Image.Image], float]:
+        """
+        检测并校正图片倾斜(包含整图旋转和微小倾斜校正)
+        同时会更新 self.ocr_data 中的 bbox 坐标以匹配新图片
+        
+        Args:
+            force: 是否强制重新校正
+            
+        Returns:
+            (corrected_image, total_angle): 校正后的图片和总旋转角度
+        """
+        if self.is_skew_corrected and not force:
+            # 如果已经校正过且不强制更新,直接返回当前状态
+            return self.image, 0.0
+            
+        if not self.ocr_data or 'text_boxes' not in self.ocr_data:
+            return self.image, 0.0
+            
+        text_boxes = self.ocr_data['text_boxes']
+        
+        # 1. 获取旋转和倾斜角度
+        image_rotation_angle = self.ocr_data.get('image_rotation_angle', 0.0)
+        skew_angle = self.ocr_data.get('skew_angle', 0.0)
+        
+        # 如果没有角度需要调整,且没有原始图片备份(说明没做过调整),则直接返回
+        if image_rotation_angle == 0 and abs(skew_angle) < 0.1 and not self.original_image:
+            return self.image, 0.0
+
+        # 准备源图片
+        if self.original_image:
+            # 如果有原始图片备份,从原始图片开始
+            current_image = self.original_image.copy()
+            # 恢复 text_boxes 到原始状态 (这里假设 original_bbox 存储了最初的坐标)
+            # 但实际上我们在 rotate_box_coordinates 时并没有保存 original_bbox 到 list 中
+            # 这是一个问题。如果是多次旋转,坐标会乱。
+            # 简单的做法:如果不复杂的逻辑,我们假设 self.ocr_data['text_boxes'] 里的 bbox 是相对于 self.image 的。
+            # 如果我们要重做,我们需要原始的 bbox。
+            # 在第一次 correct_skew 时,我们应该保存原始 bbox。
+            
+            # 让我们检查一下第一次 correct_skew 的逻辑。
+            # 如果是第一次,我们用 self.image。
+            pass
+        elif self.image:
+            self.original_image = self.image.copy()
+            current_image = self.image
+        else:
+            return None, 0.0
+
+        # 为了支持重做,我们需要保存原始的 OCR 数据。
+        if 'original_text_boxes' not in self.ocr_data:
+             # 深拷贝 text_boxes
+             import copy
+             self.ocr_data['original_text_boxes'] = copy.deepcopy(text_boxes)
+             # 同时保存原始 table_bbox
+             if 'table_bbox' in self.ocr_data:
+                 self.ocr_data['original_table_bbox'] = list(self.ocr_data['table_bbox'])
+        
+        # 使用原始数据进行计算
+        working_text_boxes = [box.copy() for box in self.ocr_data['original_text_boxes']]
+        original_size = self.original_image.size
+        
+        # 2. 执行图片旋转 (image_rotation_angle)
+        if image_rotation_angle != 0:
+            print(f"   🔄 执行图片旋转: {image_rotation_angle}°")
+            current_image = current_image.rotate(image_rotation_angle, expand=True)
+            
+            # 更新 bbox 坐标 (原图坐标 -> 旋转后坐标)
+            for box in working_text_boxes:
+                if 'bbox' in box:
+                    box['bbox'] = BBoxExtractor.rotate_box_coordinates(
+                        box['bbox'], image_rotation_angle, original_size
+                    )
+            
+            # 更新 table_bbox
+            if 'original_table_bbox' in self.ocr_data:
+                self.ocr_data['table_bbox'] = BBoxExtractor.rotate_box_coordinates(
+                    self.ocr_data['original_table_bbox'], image_rotation_angle, original_size
+                )
+        else:
+            # 如果没有旋转,恢复 table_bbox
+             if 'original_table_bbox' in self.ocr_data:
+                 self.ocr_data['table_bbox'] = list(self.ocr_data['original_table_bbox'])
+        
+        # 3. 执行倾斜校正 (skew_angle)
+        if abs(skew_angle) > 0.1:
+            print(f"   📐 执行倾斜校正: {skew_angle:.2f}°")
+            # 图片逆时针歪了 skew_angle 度,需要顺时针转 skew_angle 度校正
+            correction_angle = -skew_angle
+            current_image = current_image.rotate(correction_angle, expand=False, fillcolor='white')
+            
+            # 更新 bbox 坐标
+            working_text_boxes = BBoxExtractor.correct_boxes_skew(
+                working_text_boxes, 
+                correction_angle, 
+                current_image.size
+            )
+            
+            # 更新 table_bbox
+            if 'table_bbox' in self.ocr_data:
+                dummy_box = [{'bbox': self.ocr_data['table_bbox'], 'poly': BBoxExtractor._bbox_to_poly(self.ocr_data['table_bbox'])}]
+                corrected_dummy = BBoxExtractor.correct_boxes_skew(dummy_box, correction_angle, current_image.size)
+                self.ocr_data['table_bbox'] = corrected_dummy[0]['bbox']
+
+        self.image = current_image
+        self.ocr_data['text_boxes'] = working_text_boxes
+            
+        self.is_skew_corrected = True
+        return self.image, image_rotation_angle + skew_angle
+
     def _analyze_by_cell_index(self) -> Dict:
         """
         基于单元格的 row/col 索引分析(MinerU 专用)
@@ -328,6 +457,10 @@ class TableLineGenerator:
         self.row_height = int(np.median([r['y_end'] - r['y_start'] for r in self.rows])) if self.rows else 0
         self.col_widths = [c['x_end'] - c['x_start'] for c in self.columns]
         
+        # 获取角度信息
+        image_rotation_angle = self.ocr_data.get('image_rotation_angle', 0.0)
+        skew_angle = self.ocr_data.get('skew_angle', 0.0)
+        
         return {
             'rows': self.rows,
             'columns': self.columns,
@@ -340,7 +473,10 @@ class TableLineGenerator:
             'total_cols': actual_cols,
             'mode': 'hybrid',  # ✅ 添加 mode 字段
             'modified_h_lines': [],  # ✅ 添加修改记录字段
-            'modified_v_lines': []   # ✅ 添加修改记录字段
+            'modified_v_lines': [],   # ✅ 添加修改记录字段
+            'image_rotation_angle': image_rotation_angle,
+            'skew_angle': skew_angle,
+            'is_skew_corrected': self.is_skew_corrected
         }
     
     def _analyze_by_clustering(self, y_tolerance: int, x_tolerance: int, min_row_height: int) -> Dict:
@@ -415,7 +551,10 @@ class TableLineGenerator:
             'table_bbox': self._get_table_bbox(),
             'mode': 'fixed',  # ✅ 添加 mode 字段
             'modified_h_lines': [],  # ✅ 添加修改记录字段
-            'modified_v_lines': []   # ✅ 添加修改记录字段
+            'modified_v_lines': [],   # ✅ 添加修改记录字段
+            'image_rotation_angle': self.ocr_data.get('image_rotation_angle', 0.0),
+            'skew_angle': self.ocr_data.get('skew_angle', 0.0),
+            'is_skew_corrected': self.is_skew_corrected
         }
 
     @staticmethod
@@ -510,7 +649,9 @@ class TableLineGenerator:
     def _get_table_bbox(self) -> List[int]:
         """获取表格整体边界框"""
         if not self.rows or not self.columns:
-            return [0, 0, self.image.width, self.image.height]
+            if self.image:
+                return [0, 0, self.image.width, self.image.height]
+            return [0, 0, 0, 0]
         
         y_min = min(row['y_start'] for row in self.rows)
         y_max = max(row['y_end'] for row in self.rows)

+ 106 - 10
table_line_generator/table_template_applier.py

@@ -6,7 +6,7 @@
 import json
 from pathlib import Path
 from PIL import Image, ImageDraw
-from typing import Dict, List, Tuple
+from typing import Dict, List, Tuple, Union, Optional
 import numpy as np
 import argparse
 import sys
@@ -127,7 +127,7 @@ class TableTemplateApplier:
     
     def apply_template_fixed(self, 
                        image: Image.Image,
-                       ocr_data: List[Dict],
+                       ocr_data: Union[List[Dict], Dict],  # 🆕 支持 Dict
                        anchor_x: int = None,
                        anchor_y: int = None,
                        num_rows: int = None,
@@ -138,7 +138,7 @@ class TableTemplateApplier:
         
         Args:
             image: 目标图片
-            ocr_data: OCR识别结果(用于自动检测锚点)
+            ocr_data: OCR识别结果(用于自动检测锚点),可以是列表或完整字典
             anchor_x: 表格起始X坐标(None=自动检测)
             anchor_y: 表头起始Y坐标(None=自动检测)
             num_rows: 总行数(None=自动检测)
@@ -148,7 +148,78 @@ class TableTemplateApplier:
         Returns:
             绘制了表格线的图片
         """
-        img_with_lines = image.copy()
+        # 🆕 1. 实例化生成器并进行倾斜校正
+        ocr_data_dict = {'text_boxes': ocr_data}
+        # 尝试从 ocr_data 列表中获取角度信息(如果它是从 ocr_data 字典中提取出来的 list)
+        # 但通常 ocr_data 这里只是 text_boxes 列表。
+        # 我们需要传递包含 image_rotation_angle 和 skew_angle 的字典。
+        # 由于调用者可能会传入 list,我们需要检查是否有更多信息。
+        # 这里假设调用者会在传入 list 前处理好,或者我们在这里无法获取。
+        # 不过,如果是从 parse_ocr_data 获取的 ocr_data,它应该是个 dict。
+        # apply_template_fixed 的签名是 ocr_data: List[Dict],这意味着它只接收 text_boxes。
+        # 这可能是一个问题。我们需要修改调用处或者在这里处理。
+        # 看看 apply_template_to_single_file 是怎么调用的。
+        
+        # apply_template_to_single_file:
+        # text_boxes = ocr_data.get('text_boxes', [])
+        # applier.apply_template_fixed(image, text_boxes, ...)
+        
+        # 这样我们就丢失了角度信息。
+        # 我应该修改 apply_template_fixed 的签名,让它接收 Dict 类型的 ocr_data,或者单独传递角度。
+        # 为了保持兼容性,我可以修改 apply_template_fixed 内部处理。
+        # 但最好的方式是让它接收整个 ocr_data 字典,就像 apply_template_hybrid 一样。
+        
+        # 不过,为了最小化修改,我可以在 apply_template_to_single_file 里把角度传进来?
+        # 不,那得改很多。
+        
+        # 让我们看看能不能在 apply_template_fixed 里重新构造 ocr_data_dict。
+        # 如果传入的 ocr_data 是 list,那我们确实没法知道角度。
+        # 除非我们改变 apply_template_to_single_file 的调用方式。
+        
+        # 让我们先修改 apply_template_to_single_file 的调用方式,传整个 ocr_data 进去。
+        # 但是 apply_template_fixed 的签名明确写了 ocr_data: List[Dict]。
+        
+        # 既然我正在修改这个文件,我可以改变它的签名。
+        # 或者,我可以像 apply_template_hybrid 一样,增加一个参数 ocr_data_full: Dict = None
+        
+        # 实际上,apply_template_hybrid 已经接收 ocr_data_dict: Dict。
+        # apply_template_fixed 接收 List[Dict]。
+        # 这是一个不一致的地方。
+        
+        # 我决定修改 apply_template_fixed 的参数,让它也能利用 TableLineGenerator 进行校正。
+        # 但是 TableLineGenerator 需要完整的 ocr_data 字典才能读取角度。
+        
+        # 方案:修改 apply_template_fixed 接收 ocr_data_dict。
+        # 为了兼容旧代码,如果传入的是 list,就包装一下。
+        
+        # 但是 Python 类型提示 List[Dict] 和 Dict 是不一样的。
+        # 我可以把参数名改成 ocr_input,类型 Union[List[Dict], Dict]。
+        
+        # 或者,既然这是内部使用的工具,我直接修改签名,让它接收 Dict。
+        # 检查一下是否有其他地方调用这个方法。
+        # 只在 apply_template_to_single_file 调用了。
+        
+        # 所以我将修改 apply_template_fixed 接收 ocr_data_dict: Dict。
+        
+        generator = TableLineGenerator(image, {'text_boxes': ocr_data} if isinstance(ocr_data, list) else ocr_data)
+        corrected_image, angle = generator.correct_skew()
+        
+        # 获取角度信息
+        image_rotation_angle = generator.ocr_data.get('image_rotation_angle', 0.0)
+        skew_angle = generator.ocr_data.get('skew_angle', 0.0)
+        
+        if abs(angle) > 0.1 or image_rotation_angle != 0:
+            print(f"🔄 [TemplateApplier] 自动校正: 旋转={image_rotation_angle}°, 倾斜={skew_angle:.2f}°")
+            # 更新 OCR 数据(generator 内部已经更新了)
+            ocr_data = generator.ocr_data.get('text_boxes', [])
+            # 使用校正后的图片
+            img_with_lines = corrected_image.copy()
+        else:
+            img_with_lines = image.copy()
+            # 如果是字典,提取 list
+            if isinstance(ocr_data, dict):
+                ocr_data = ocr_data.get('text_boxes', [])
+            
         draw = ImageDraw.Draw(img_with_lines)
         
         # 🔍 自动检测锚点
@@ -202,13 +273,15 @@ class TableTemplateApplier:
 
         print(f"✅ 表格绘制完成: {len(horizontal_lines)}行 × {len(vertical_lines)-1}列")
 
-                # 🔑 生成结构信息
+        # 🔑 生成结构信息
         structure = self._build_structure(
             horizontal_lines, 
             vertical_lines, 
             anchor_x, 
             anchor_y,
-            mode='fixed'
+            mode='fixed',
+            image_rotation_angle=image_rotation_angle,
+            skew_angle=skew_angle
         )
         
         return img_with_lines, structure
@@ -238,7 +311,23 @@ class TableTemplateApplier:
         Returns:
             绘制了表格线的图片, 结构信息
         """
-        img_with_lines = image.copy()
+        # 🆕 1. 实例化生成器并进行倾斜校正
+        generator = TableLineGenerator(image, ocr_data_dict)
+        corrected_image, angle = generator.correct_skew()
+        
+        # 🆕 获取图片旋转角度
+        image_rotation_angle = ocr_data_dict.get('image_rotation_angle', 0.0)
+        skew_angle = ocr_data_dict.get('skew_angle', 0.0)
+        
+        if abs(angle) > 0.1 or image_rotation_angle != 0:
+            print(f"🔄 [TemplateApplier] 自动校正: 旋转={image_rotation_angle}°, 倾斜={skew_angle:.2f}°")
+            # 更新 OCR 数据
+            ocr_data_dict = generator.ocr_data
+            # 使用校正后的图片
+            img_with_lines = corrected_image.copy()
+        else:
+            img_with_lines = image.copy()
+            
         draw = ImageDraw.Draw(img_with_lines)
         
         ocr_data = ocr_data_dict.get('text_boxes', [])
@@ -286,7 +375,9 @@ class TableTemplateApplier:
             vertical_lines, 
             anchor_x, 
             anchor_y,
-            mode='hybrid'
+            mode='hybrid',
+            image_rotation_angle=image_rotation_angle,
+            skew_angle=skew_angle
         )
         
         return img_with_lines, structure
@@ -389,7 +480,9 @@ class TableTemplateApplier:
                         vertical_lines: List[int],
                         anchor_x: int,
                         anchor_y: int,
-                        mode: str = 'fixed') -> Dict:
+                        mode: str = 'fixed',
+                        image_rotation_angle: float = 0.0,
+                        skew_angle: float = 0.0) -> Dict:
         """构建表格结构信息(统一)"""
         # 生成行区间
         rows = []
@@ -432,7 +525,10 @@ class TableTemplateApplier:
             'mode': mode_value,  # ✅ 确保有 mode 字段
             'anchor': {'x': anchor_x, 'y': anchor_y},
             'modified_h_lines': [],  # ✅ 添加修改记录字段
-            'modified_v_lines': []   # ✅ 添加修改记录字段
+            'modified_v_lines': [],   # ✅ 添加修改记录字段
+            'image_rotation_angle': image_rotation_angle,
+            'skew_angle': skew_angle,
+            'is_skew_corrected': abs(skew_angle) > 0.1 or image_rotation_angle != 0
         }
 
 def apply_template_to_single_file(

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů