# PP-StructureV3 完整版面資訊利用計劃 ## 📋 執行摘要 ### 問題診斷 目前實作**嚴重低估了 PP-StructureV3 的能力**,只使用了 `page_result.markdown` 屬性,完全忽略了核心的版面資訊 `page_result.json`。 ### 核心發現 1. **PP-StructureV3 提供完整的版面解析資訊**,包括: - `parsing_res_list`: 按閱讀順序排列的版面元素列表 - `layout_bbox`: 每個元素的精確座標 - `layout_det_res`: 版面檢測結果(區域類型、置信度) - `overall_ocr_res`: 完整的 OCR 結果(包含所有文字的 bbox) - `layout`: 版面類型(單欄/雙欄/多欄) 2. **目前實作的缺陷**: ```python # ❌ 目前做法 (ocr_service.py:615-646) markdown_dict = page_result.markdown # 只獲取 markdown 和圖片 markdown_texts = markdown_dict.get('markdown_texts', '') # bbox 被設為空列表 'bbox': [], # PP-StructureV3 doesn't provide individual bbox in this format ``` 3. **應該這樣做**: ```python # ✅ 正確做法 json_data = page_result.json # 獲取完整的結構化資訊 parsing_list = json_data.get('parsing_res_list', []) # 閱讀順序 + bbox layout_det = json_data.get('layout_det_res', {}) # 版面檢測 overall_ocr = json_data.get('overall_ocr_res', {}) # 所有文字的座標 ``` --- ## 🎯 規劃目標 ### 階段 1: 提取完整版面資訊(高優先級) **目標**: 修改 `analyze_layout()` 以使用 PP-StructureV3 的完整能力 **預期效果**: - ✅ 每個版面元素都有精確的 `layout_bbox` - ✅ 保留原始閱讀順序(`parsing_res_list` 的順序) - ✅ 獲取版面類型資訊(單欄/雙欄) - ✅ 提取區域分類(text/table/figure/title/formula) - ✅ 零資訊損失(不需要過濾重疊文字) ### 階段 2: 實作雙模式 PDF 生成(中優先級) **目標**: 提供兩種 PDF 生成模式 **模式 A: 精確座標定位模式** - 使用 `layout_bbox` 精確定位每個元素 - 保留原始文件的視覺外觀 - 適用於需要精確還原版面的場景 **模式 B: 流式排版模式** - 按 `parsing_res_list` 順序流式排版 - 使用 ReportLab Platypus 高階 API - 零資訊損失,所有內容都可搜尋 - 適用於需要翻譯或內容處理的場景 ### 階段 3: 多欄版面處理(低優先級) **目標**: 利用 PP-StructureV3 的多欄識別能力 --- ## 📊 PP-StructureV3 完整資料結構 ### 1. `page_result.json` 完整結構 ```python { # 基本資訊 "input_path": str, # 源文件路徑 "page_index": int, # 頁碼(PDF 專用) # 版面檢測結果 "layout_det_res": { "boxes": [ { "cls_id": int, # 類別 ID "label": str, # 區域類型: text/table/figure/title/formula/seal "score": float, # 置信度 0-1 "coordinate": [x1, y1, x2, y2] # 矩形座標 }, ... ] }, # 完整 OCR 結果 "overall_ocr_res": { "dt_polys": np.ndarray, # 文字檢測多邊形 "rec_polys": np.ndarray, # 文字識別多邊形 "rec_boxes": np.ndarray, # 文字識別矩形框 (n, 4, 2) int16 "rec_texts": List[str], # 識別的文字 "rec_scores": np.ndarray # 識別置信度 }, # **核心版面解析結果(按閱讀順序)** "parsing_res_list": [ { "layout_bbox": np.ndarray, # 區域邊界框 [x1, y1, x2, y2] "layout": str, # 版面類型: single/double/multi-column "text": str, # 文字內容(如果是文字區域) "table": str, # 表格 HTML(如果是表格區域) "image": str, # 圖片路徑(如果是圖片區域) "formula": str, # 公式 LaTeX(如果是公式區域) # ... 其他區域類型 }, ... # 順序 = 閱讀順序 ], # 文字段落 OCR(按閱讀順序) "text_paragraphs_ocr_res": { "rec_polys": np.ndarray, "rec_texts": List[str], "rec_scores": np.ndarray }, # 可選模組結果 "formula_res_region1": {...}, # 公式識別結果 "table_cell_img": {...}, # 表格儲存格圖片 "seal_res_region1": {...} # 印章識別結果 } ``` ### 2. 關鍵欄位說明 | 欄位 | 用途 | 資料格式 | 重要性 | |------|------|---------|--------| | `parsing_res_list` | **核心資料**,包含按閱讀順序排列的所有版面元素 | List[Dict] | ⭐⭐⭐⭐⭐ | | `layout_bbox` | 每個元素的精確座標 | np.ndarray [x1,y1,x2,y2] | ⭐⭐⭐⭐⭐ | | `layout` | 版面類型(單欄/雙欄/多欄) | str: single/double/multi | ⭐⭐⭐⭐ | | `layout_det_res` | 版面檢測詳細結果(包含區域分類) | Dict with boxes list | ⭐⭐⭐⭐ | | `overall_ocr_res` | 所有文字的 OCR 結果和座標 | Dict with np.ndarray | ⭐⭐⭐⭐ | | `markdown` | 簡化的 Markdown 輸出 | Dict with texts/images | ⭐⭐ | --- ## 🔧 實作計劃 ### 任務 1: 重構 `analyze_layout()` 函數 **檔案**: `/backend/app/services/ocr_service.py` **修改範圍**: Lines 590-710 **核心改動**: ```python def analyze_layout(self, image_path: Path, output_dir: Optional[Path] = None, current_page: int = 0) -> Tuple[Optional[Dict], List[Dict]]: """ Analyze document layout using PP-StructureV3 (使用完整的 JSON 資訊) """ try: structure_engine = self.get_structure_engine() results = structure_engine.predict(str(image_path)) layout_elements = [] images_metadata = [] for page_idx, page_result in enumerate(results): # ✅ 修改 1: 使用完整的 JSON 資料而非只用 markdown json_data = page_result.json # ✅ 修改 2: 提取版面檢測結果 layout_det_res = json_data.get('layout_det_res', {}) layout_boxes = layout_det_res.get('boxes', []) # ✅ 修改 3: 提取核心的 parsing_res_list(包含閱讀順序 + bbox) parsing_res_list = json_data.get('parsing_res_list', []) if parsing_res_list: # *** 核心邏輯:使用 parsing_res_list *** for idx, item in enumerate(parsing_res_list): # 提取 bbox(不再是空列表!) layout_bbox = item.get('layout_bbox') if layout_bbox is not None: # 轉換 numpy array 為標準格式 if hasattr(layout_bbox, 'tolist'): bbox = layout_bbox.tolist() else: bbox = list(layout_bbox) # 轉換為 4-point 格式: [[x1,y1], [x2,y1], [x2,y2], [x1,y2]] if len(bbox) == 4: # [x1, y1, x2, y2] x1, y1, x2, y2 = bbox bbox = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] else: bbox = [] # 提取版面類型 layout_type = item.get('layout', 'single') # 創建元素(包含所有資訊) element = { 'element_id': idx, 'page': current_page, 'bbox': bbox, # ✅ 不再是空列表! 'layout_type': layout_type, # ✅ 新增版面類型 'reading_order': idx, # ✅ 新增閱讀順序 } # 根據內容類型提取資料 if 'table' in item: element['type'] = 'table' element['content'] = item['table'] # 提取表格純文字(用於翻譯) element['extracted_text'] = self._extract_table_text(item['table']) elif 'text' in item: element['type'] = 'text' element['content'] = item['text'] elif 'figure' in item or 'image' in item: element['type'] = 'image' element['content'] = item.get('figure') or item.get('image') elif 'formula' in item: element['type'] = 'formula' element['content'] = item['formula'] elif 'title' in item: element['type'] = 'title' element['content'] = item['title'] else: # 未知類型,記錄所有非系統欄位 for key, value in item.items(): if key not in ['layout_bbox', 'layout']: element['type'] = key element['content'] = value break layout_elements.append(element) else: # 回退到 markdown 方式(向後相容) logger.warning("No parsing_res_list found, falling back to markdown parsing") markdown_dict = page_result.markdown # ... 原有的 markdown 解析邏輯 ... # ✅ 修改 4: 同時處理提取的圖片(仍需保存到磁碟) markdown_dict = page_result.markdown markdown_images = markdown_dict.get('markdown_images', {}) for img_idx, (img_path, img_obj) in enumerate(markdown_images.items()): # 保存圖片到磁碟 try: base_dir = output_dir if output_dir else image_path.parent full_img_path = base_dir / img_path full_img_path.parent.mkdir(parents=True, exist_ok=True) if hasattr(img_obj, 'save'): img_obj.save(str(full_img_path)) logger.info(f"Saved extracted image to {full_img_path}") except Exception as e: logger.warning(f"Failed to save image {img_path}: {e}") # 提取 bbox(從檔名或從 parsing_res_list 匹配) bbox = self._find_image_bbox(img_path, parsing_res_list, layout_boxes) images_metadata.append({ 'element_id': len(layout_elements) + img_idx, 'image_path': img_path, 'type': 'image', 'page': current_page, 'bbox': bbox, }) if layout_elements: layout_data = { 'elements': layout_elements, 'total_elements': len(layout_elements), 'reading_order': [e['reading_order'] for e in layout_elements], # ✅ 保留閱讀順序 'layout_types': list(set(e.get('layout_type') for e in layout_elements)), # ✅ 版面類型統計 } logger.info(f"Detected {len(layout_elements)} layout elements (with bbox and reading order)") return layout_data, images_metadata else: logger.warning("No layout elements detected") return None, [] except Exception as e: import traceback logger.error(f"Layout analysis error: {str(e)}\n{traceback.format_exc()}") return None, [] def _find_image_bbox(self, img_path: str, parsing_res_list: List[Dict], layout_boxes: List[Dict]) -> List: """ 從 parsing_res_list 或 layout_det_res 中查找圖片的 bbox """ # 方法 1: 從檔名提取(現有方法) import re match = re.search(r'box_(\d+)_(\d+)_(\d+)_(\d+)', img_path) if match: x1, y1, x2, y2 = map(int, match.groups()) return [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] # 方法 2: 從 parsing_res_list 匹配(如果包含圖片路徑資訊) for item in parsing_res_list: if 'image' in item or 'figure' in item: content = item.get('image') or item.get('figure') if img_path in str(content): bbox = item.get('layout_bbox') if bbox is not None: if hasattr(bbox, 'tolist'): bbox_list = bbox.tolist() else: bbox_list = list(bbox) if len(bbox_list) == 4: x1, y1, x2, y2 = bbox_list return [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] # 方法 3: 從 layout_det_res 匹配(根據類型) for box in layout_boxes: if box.get('label') in ['figure', 'image']: coord = box.get('coordinate', []) if len(coord) == 4: x1, y1, x2, y2 = coord return [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] logger.warning(f"Could not find bbox for image {img_path}") return [] ``` --- ### 任務 2: 更新 PDF 生成器使用新資訊 **檔案**: `/backend/app/services/pdf_generator_service.py` **核心改動**: 1. **移除文字過濾邏輯**(不再需要!) - 因為 `parsing_res_list` 已經按閱讀順序排列 - 表格/圖片有自己的區域,文字有自己的區域 - 不會有重疊問題 2. **按 `reading_order` 渲染元素** ```python def generate_layout_pdf(self, json_path: Path, output_path: Path, mode: str = 'coordinate') -> bool: """ mode: 'coordinate' 或 'flow' """ # 載入資料 ocr_data = self.load_ocr_json(json_path) layout_data = ocr_data.get('layout_data', {}) elements = layout_data.get('elements', []) if mode == 'coordinate': # 模式 A: 座標定位模式 return self._generate_coordinate_pdf(elements, output_path, ocr_data) else: # 模式 B: 流式排版模式 return self._generate_flow_pdf(elements, output_path, ocr_data) def _generate_coordinate_pdf(self, elements: List[Dict], output_path: Path, ocr_data: Dict) -> bool: """座標定位模式 - 精確還原版面""" # 按 reading_order 排序元素 sorted_elements = sorted(elements, key=lambda x: x.get('reading_order', 0)) # 按頁碼分組 pages = {} for elem in sorted_elements: page = elem.get('page', 0) if page not in pages: pages[page] = [] pages[page].append(elem) # 渲染每頁 for page_num, page_elements in sorted(pages.items()): for elem in page_elements: bbox = elem.get('bbox', []) elem_type = elem.get('type') content = elem.get('content', '') if not bbox: logger.warning(f"Element {elem['element_id']} has no bbox, skipping") continue # 使用精確座標渲染 if elem_type == 'table': self.draw_table_at_bbox(pdf_canvas, content, bbox, page_height, scale_w, scale_h) elif elem_type == 'text': self.draw_text_at_bbox(pdf_canvas, content, bbox, page_height, scale_w, scale_h) elif elem_type == 'image': self.draw_image_at_bbox(pdf_canvas, content, bbox, page_height, scale_w, scale_h) # ... 其他類型 def _generate_flow_pdf(self, elements: List[Dict], output_path: Path, ocr_data: Dict) -> bool: """流式排版模式 - 零資訊損失""" from reportlab.platypus import SimpleDocTemplate, Paragraph, Table, Image, Spacer from reportlab.lib.styles import getSampleStyleSheet # 按 reading_order 排序元素 sorted_elements = sorted(elements, key=lambda x: x.get('reading_order', 0)) # 創建 Story(流式內容) story = [] styles = getSampleStyleSheet() for elem in sorted_elements: elem_type = elem.get('type') content = elem.get('content', '') if elem_type == 'title': story.append(Paragraph(content, styles['Title'])) elif elem_type == 'text': story.append(Paragraph(content, styles['Normal'])) elif elem_type == 'table': # 解析 HTML 表格為 ReportLab Table table_obj = self._html_to_reportlab_table(content) story.append(table_obj) elif elem_type == 'image': # 嵌入圖片 img_path = json_path.parent / content if img_path.exists(): story.append(Image(str(img_path), width=400, height=300)) story.append(Spacer(1, 12)) # 間距 # 生成 PDF doc = SimpleDocTemplate(str(output_path)) doc.build(story) return True ``` --- ## 📈 預期效果對比 ### 目前實作 vs 新實作 | 指標 | 目前實作 ❌ | 新實作 ✅ | 改善 | |------|-----------|----------|------| | **bbox 資訊** | 空列表 `[]` | 精確座標 `[x1,y1,x2,y2]` | ✅ 100% | | **閱讀順序** | 無(混合 HTML) | `reading_order` 欄位 | ✅ 100% | | **版面類型** | 無 | `layout_type`(單欄/雙欄) | ✅ 100% | | **元素分類** | 簡單判斷 `