7 Commit-ok 48440201a0 ... 4f888dc393

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

+ 26 - 13
merger/bbox_extractor.py

@@ -536,7 +536,7 @@ class BBoxExtractor:
                     angle_deg: float, 
                     angle_deg: float, 
                     center: Tuple[float, float] = (0, 0)) -> Tuple[float, float]:
                     center: Tuple[float, float] = (0, 0)) -> Tuple[float, float]:
         """
         """
-        旋转点坐标
+        旋转点坐标 (图像坐标系:Y轴向下)
         
         
         Args:
         Args:
             point: 原始点 (x, y)
             point: 原始点 (x, y)
@@ -554,8 +554,11 @@ class BBoxExtractor:
         x -= cx
         x -= cx
         y -= cy
         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
         x_new += cx
         y_new += cy
         y_new += cy
@@ -564,20 +567,21 @@ class BBoxExtractor:
     
     
     @staticmethod
     @staticmethod
     def correct_boxes_skew(paddle_boxes: List[Dict], 
     def correct_boxes_skew(paddle_boxes: List[Dict], 
-                          rotation_angle: float,
+                          correction_angle: float,
                           image_size: Tuple[int, int]) -> List[Dict]:
                           image_size: Tuple[int, int]) -> List[Dict]:
         """
         """
         校正文本框的倾斜
         校正文本框的倾斜
         
         
         Args:
         Args:
             paddle_boxes: Paddle OCR 结果
             paddle_boxes: Paddle OCR 结果
-            rotation_angle: 倾斜角度(度数)
+            correction_angle: 校正旋转角度(度数,正值=逆时针,负值=顺时针)
+                              注意:这里直接传入需要旋转的角度,不再自动取反
             image_size: 图像尺寸 (width, height)
             image_size: 图像尺寸 (width, height)
         
         
         Returns:
         Returns:
             校正后的文本框列表
             校正后的文本框列表
         """
         """
-        if abs(rotation_angle) < 0.1:
+        if abs(correction_angle) < 0.01:
             return paddle_boxes
             return paddle_boxes
         
         
         width, height = image_size
         width, height = image_size
@@ -587,15 +591,24 @@ class BBoxExtractor:
         
         
         for box in paddle_boxes:
         for box in paddle_boxes:
             poly = box.get('poly', [])
             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
             # 重新计算 bbox
             corrected_bbox = BBoxExtractor._poly_to_bbox(rotated_poly)
             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:
     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 = {
         # 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",
         #     "output-type": "both",
         #     "window": "15",
         #     "window": "15",
         #     "threshold": "85"
         #     "threshold": "85"
         # }
         # }
         # default_config = {
         # 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",
         #     "output-type": "both",
         #     "window": "15",
         #     "window": "15",
         #     "threshold": "85"
         #     "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 = {
         # default_config = {
         #     "paddleocr-vl-file": "/Users/zhch158/workspace/data/流水分析/康强_北京农村商业银行/paddleocr_vl_results/康强_北京农村商业银行_page_001.json",
         #     "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",
         #     "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)
                 image_size = (max_x, max_y)
                 
                 
                 print(f"   🔧 校正倾斜角度: {skew_angle:.2f}°")
                 print(f"   🔧 校正倾斜角度: {skew_angle:.2f}°")
+                
+                # 计算校正角度 (顺时针旋转)
+                correction_angle = -skew_angle
+                
                 paddle_boxes = BBoxExtractor.correct_boxes_skew(
                 paddle_boxes = BBoxExtractor.correct_boxes_skew(
-                    paddle_boxes, -skew_angle, image_size
+                    paddle_boxes, correction_angle, image_size
                 )
                 )
         
         
         # 🎯 步骤 2: 按校正后的 y 坐标分组
         # 🎯 步骤 2: 按校正后的 y 坐标分组

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 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(
     adjustment_action = st.radio(
         "行&列操作",
         "行&列操作",
-        ["调整横线", "添加横线", "删除横线", "调整竖线", "添加竖线", "删除竖线"],
+        ["调整横线", "添加横线", "删除横线", "调整竖线", "添加竖线", "删除竖线", "微调旋转"],
         horizontal=True,
         horizontal=True,
         index=None,
         index=None,
         label_visibility="collapsed",
         label_visibility="collapsed",
@@ -132,7 +132,6 @@ def create_adjustment_section(structure):
             if idx not in structure['modified_v_lines']:
             if idx not in structure['modified_v_lines']:
                 structure['modified_v_lines'].append(idx)
                 structure['modified_v_lines'].append(idx)
             _update_column_intervals(structure)
             _update_column_intervals(structure)
-            _update_column_intervals(structure)
             clear_table_image_cache()
             clear_table_image_cache()
             adjusted = True
             adjusted = True
             st.success(f"✅ 新增竖线 X={new_v_x}")
             st.success(f"✅ 新增竖线 X={new_v_x}")
@@ -153,6 +152,54 @@ def create_adjustment_section(structure):
             adjusted = True
             adjusted = True
             st.success(f"✅ 已删除 {len(to_delete)} 条竖线")
             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
     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("🔍 表格结构分析")
     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":
     if tool.lower() == "mineru":
         st.sidebar.info("📋 MinerU 格式:直接使用 table_cells 生成结构")
         st.sidebar.info("📋 MinerU 格式:直接使用 table_cells 生成结构")
         
         
-        if st.sidebar.button("🚀 生成表格结构", type="primary"):
+        if st.sidebar.button("🚀 生成表格结构", type="primary") or auto_analyze:
             with st.spinner("正在分析表格结构..."):
             with st.spinner("正在分析表格结构..."):
                 try:
                 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
                     # 保存到 session_state
                     st.session_state.structure = structure
                     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.undo_stack = []
                     st.session_state.redo_stack = []
                     st.session_state.redo_stack = []
                     
                     
@@ -57,7 +48,7 @@ def create_analysis_section(generator, tool: str = "ppstructv3") -> Optional[Dic
                     
                     
                     st.success(
                     st.success(
                         f"✅ 表格结构生成成功!\n\n"
                         f"✅ 表格结构生成成功!\n\n"
-                        f"检测到 {structure['total_rows']} 行,{structure['total_cols']} 列"
+                        f"检测到 {len(structure.get('rows', []))} 行,{len(structure.get('columns', []))} 列"
                     )
                     )
                     return structure
                     return structure
                     
                     
@@ -93,7 +84,7 @@ def create_analysis_section(generator, tool: str = "ppstructv3") -> Optional[Dic
             help="行高小于此值的将被过滤"
             help="行高小于此值的将被过滤"
         )
         )
         
         
-        if st.sidebar.button("🚀 分析表格结构", type="primary"):
+        if st.sidebar.button("🚀 分析表格结构", type="primary") or auto_analyze:
             with st.spinner("正在分析表格结构..."):
             with st.spinner("正在分析表格结构..."):
                 try:
                 try:
                     structure = generator.analyze_table_structure(
                     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:
     if 'generator' not in st.session_state:
         st.session_state.generator = TableLineGenerator(image, ocr_data)
         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(
     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
 from typing import List, Dict, Tuple, Optional, Union
 import json
 import json
 from bs4 import BeautifulSoup
 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:
 class TableLineGenerator:
@@ -46,6 +56,9 @@ class TableLineGenerator:
         self.columns = []
         self.columns = []
         self.row_height = 0
         self.row_height = 0
         self.col_widths = []
         self.col_widths = []
+        
+        self.is_skew_corrected = False # 是否已经校正过倾斜(默认 False)
+        self.original_image = None
 
 
 
 
     @staticmethod
     @staticmethod
@@ -125,10 +138,17 @@ class TableLineGenerator:
             'table_bbox': table_bbox,
             'table_bbox': table_bbox,
             'actual_rows': actual_rows,
             'actual_rows': actual_rows,
             'actual_cols': actual_cols,
             '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)} 个文本框")
         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
         return table_bbox, ocr_data
 
 
@@ -221,6 +241,115 @@ class TableLineGenerator:
         else:
         else:
             return self._analyze_by_clustering(y_tolerance, x_tolerance, min_row_height)
             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:
     def _analyze_by_cell_index(self) -> Dict:
         """
         """
         基于单元格的 row/col 索引分析(MinerU 专用)
         基于单元格的 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.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]
         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 {
         return {
             'rows': self.rows,
             'rows': self.rows,
             'columns': self.columns,
             'columns': self.columns,
@@ -340,7 +473,10 @@ class TableLineGenerator:
             'total_cols': actual_cols,
             'total_cols': actual_cols,
             'mode': 'hybrid',  # ✅ 添加 mode 字段
             'mode': 'hybrid',  # ✅ 添加 mode 字段
             'modified_h_lines': [],  # ✅ 添加修改记录字段
             '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:
     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(),
             'table_bbox': self._get_table_bbox(),
             'mode': 'fixed',  # ✅ 添加 mode 字段
             'mode': 'fixed',  # ✅ 添加 mode 字段
             'modified_h_lines': [],  # ✅ 添加修改记录字段
             '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
     @staticmethod
@@ -510,7 +649,9 @@ class TableLineGenerator:
     def _get_table_bbox(self) -> List[int]:
     def _get_table_bbox(self) -> List[int]:
         """获取表格整体边界框"""
         """获取表格整体边界框"""
         if not self.rows or not self.columns:
         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_min = min(row['y_start'] for row in self.rows)
         y_max = max(row['y_end'] 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
 import json
 from pathlib import Path
 from pathlib import Path
 from PIL import Image, ImageDraw
 from PIL import Image, ImageDraw
-from typing import Dict, List, Tuple
+from typing import Dict, List, Tuple, Union, Optional
 import numpy as np
 import numpy as np
 import argparse
 import argparse
 import sys
 import sys
@@ -127,7 +127,7 @@ class TableTemplateApplier:
     
     
     def apply_template_fixed(self, 
     def apply_template_fixed(self, 
                        image: Image.Image,
                        image: Image.Image,
-                       ocr_data: List[Dict],
+                       ocr_data: Union[List[Dict], Dict],  # 🆕 支持 Dict
                        anchor_x: int = None,
                        anchor_x: int = None,
                        anchor_y: int = None,
                        anchor_y: int = None,
                        num_rows: int = None,
                        num_rows: int = None,
@@ -138,7 +138,7 @@ class TableTemplateApplier:
         
         
         Args:
         Args:
             image: 目标图片
             image: 目标图片
-            ocr_data: OCR识别结果(用于自动检测锚点)
+            ocr_data: OCR识别结果(用于自动检测锚点),可以是列表或完整字典
             anchor_x: 表格起始X坐标(None=自动检测)
             anchor_x: 表格起始X坐标(None=自动检测)
             anchor_y: 表头起始Y坐标(None=自动检测)
             anchor_y: 表头起始Y坐标(None=自动检测)
             num_rows: 总行数(None=自动检测)
             num_rows: 总行数(None=自动检测)
@@ -148,7 +148,78 @@ class TableTemplateApplier:
         Returns:
         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)
         draw = ImageDraw.Draw(img_with_lines)
         
         
         # 🔍 自动检测锚点
         # 🔍 自动检测锚点
@@ -202,13 +273,15 @@ class TableTemplateApplier:
 
 
         print(f"✅ 表格绘制完成: {len(horizontal_lines)}行 × {len(vertical_lines)-1}列")
         print(f"✅ 表格绘制完成: {len(horizontal_lines)}行 × {len(vertical_lines)-1}列")
 
 
-                # 🔑 生成结构信息
+        # 🔑 生成结构信息
         structure = self._build_structure(
         structure = self._build_structure(
             horizontal_lines, 
             horizontal_lines, 
             vertical_lines, 
             vertical_lines, 
             anchor_x, 
             anchor_x, 
             anchor_y,
             anchor_y,
-            mode='fixed'
+            mode='fixed',
+            image_rotation_angle=image_rotation_angle,
+            skew_angle=skew_angle
         )
         )
         
         
         return img_with_lines, structure
         return img_with_lines, structure
@@ -238,7 +311,23 @@ class TableTemplateApplier:
         Returns:
         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)
         draw = ImageDraw.Draw(img_with_lines)
         
         
         ocr_data = ocr_data_dict.get('text_boxes', [])
         ocr_data = ocr_data_dict.get('text_boxes', [])
@@ -286,7 +375,9 @@ class TableTemplateApplier:
             vertical_lines, 
             vertical_lines, 
             anchor_x, 
             anchor_x, 
             anchor_y,
             anchor_y,
-            mode='hybrid'
+            mode='hybrid',
+            image_rotation_angle=image_rotation_angle,
+            skew_angle=skew_angle
         )
         )
         
         
         return img_with_lines, structure
         return img_with_lines, structure
@@ -389,7 +480,9 @@ class TableTemplateApplier:
                         vertical_lines: List[int],
                         vertical_lines: List[int],
                         anchor_x: int,
                         anchor_x: int,
                         anchor_y: int,
                         anchor_y: int,
-                        mode: str = 'fixed') -> Dict:
+                        mode: str = 'fixed',
+                        image_rotation_angle: float = 0.0,
+                        skew_angle: float = 0.0) -> Dict:
         """构建表格结构信息(统一)"""
         """构建表格结构信息(统一)"""
         # 生成行区间
         # 生成行区间
         rows = []
         rows = []
@@ -432,7 +525,10 @@ class TableTemplateApplier:
             'mode': mode_value,  # ✅ 确保有 mode 字段
             'mode': mode_value,  # ✅ 确保有 mode 字段
             'anchor': {'x': anchor_x, 'y': anchor_y},
             'anchor': {'x': anchor_x, 'y': anchor_y},
             'modified_h_lines': [],  # ✅ 添加修改记录字段
             '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(
 def apply_template_to_single_file(

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott