# -*- coding: utf-8 -*- """ Form-aware parser for Korean Vehicle Registration Certificate. Uses PP-Structure layout analysis results (tables, text regions) to extract fields based on document structure rather than regex-only parsing. 차량등록증 양식 구조: 상단: 제목 "자동차등록증" 기본정보 테이블 (①-⑩): 차량번호, 차종, 용도, 차명, 형식, 차대번호, 원동기, 소유자 등 제원 테이블: 길이, 너비, 높이, 총중량, 승차정원 하단: 등록일, 주소 등 """ import re import logging logger = logging.getLogger(__name__) # 차량등록증 테이블 라벨 → 필드명 매핑 # 라벨은 OCR 변형을 고려하여 여러 패턴 포함 LABEL_FIELD_MAP = { 'vehicle_no': ['자동차등록번호', '등록번호', '차량번호'], 'vehicle_type': ['차종', '차 종'], 'model_name': ['차명', '차 명'], 'model_year': ['연식', '모델연도', '연 식'], 'vin': ['차대번호', '차대 번호'], 'engine_type': ['원동기형식', '원동기 형식', '원동기'], 'owner_name': ['성명', '명칭', '소유자', '성명(명칭)'], 'registration_date': ['최초등록일', '등록일', '최초 등록일'], 'fuel_type': ['연료', '연료의종류', '연료의 종류'], 'usage': ['용도', '용 도'], 'length_mm': ['길이', '길 이'], 'width_mm': ['너비', '너 비'], 'height_mm': ['높이', '높 이'], 'total_weight_kg': ['총중량', '총 중량'], 'passenger_capacity': ['승차정원', '승차 정원', '정원'], 'purchase_price': ['출고가격', '취득가격', '출고취득가격'], } # 역방향 매핑: 라벨 텍스트 → 필드명 _REVERSE_MAP = {} for field, labels in LABEL_FIELD_MAP.items(): for label in labels: _REVERSE_MAP[label] = field class FormParser: """ Parse PP-Structure layout analysis results into structured vehicle registration fields. Works with table HTML cells and text region detections. """ def parse_layout(self, layout_result, img_height=None, img_width=None): """ Extract vehicle registration fields from PP-Structure layout result. Args: layout_result: dict from LayoutEngine.analyze() with 'tables', 'text_regions' img_height: image height in pixels (for zone-based extraction) img_width: image width in pixels Returns: dict: field_name → {'value': str, 'confidence': float, 'source': str} """ fields = {} # Strategy 1: Extract from table cells (most reliable for structured forms) tables = layout_result.get('tables', []) for table in tables: table_fields = self._extract_from_table(table) self._merge_fields(fields, table_fields, source_name='table') # Strategy 2: Extract from text regions with spatial proximity text_regions = layout_result.get('text_regions', []) if text_regions and img_height and img_width: region_fields = self._extract_from_regions( text_regions, img_height, img_width ) self._merge_fields(fields, region_fields, source_name='text_region') logger.info( f"FormParser extracted {len(fields)} fields: " f"{[f for f in fields]}" ) return fields def _extract_from_table(self, table): """ Extract fields from a single table's cells. Looks for label-value pairs in adjacent cells (same row or label above value). """ cells = table.get('cells', []) if not cells: # Try parsing HTML directly html = table.get('html', '') if html: cells = self._cells_from_html(html) if not cells: return {} fields = {} # Build row-based structure rows = {} for cell in cells: row = cell.get('row', 0) rows.setdefault(row, []).append(cell) # Sort cells within each row by column for row_cells in rows.values(): row_cells.sort(key=lambda c: c.get('col', 0)) # Strategy A: Adjacent cells in same row (label | value) for _, row_cells in sorted(rows.items()): for i, cell in enumerate(row_cells): cell_text = self._normalize_label(cell.get('text', '')) field_name = self._match_label(cell_text) if field_name and i + 1 < len(row_cells): value = row_cells[i + 1].get('text', '').strip() if value and not self._match_label(self._normalize_label(value)): fields[field_name] = { 'value': self._clean_value(field_name, value), 'confidence': 0.85, } # Strategy B: Label in one row, value in next row same column sorted_rows = sorted(rows.items()) for idx, (_, row_cells) in enumerate(sorted_rows): for cell in row_cells: cell_text = self._normalize_label(cell.get('text', '')) field_name = self._match_label(cell_text) if field_name and field_name not in fields: col = cell.get('col', 0) # Look in next row for same column if idx + 1 < len(sorted_rows): next_row_cells = sorted_rows[idx + 1][1] for nc in next_row_cells: if nc.get('col', -1) == col: value = nc.get('text', '').strip() if value and not self._match_label( self._normalize_label(value) ): fields[field_name] = { 'value': self._clean_value( field_name, value ), 'confidence': 0.75, } return fields def _extract_from_regions(self, text_regions, img_height, img_width): """ Extract fields from text regions using spatial proximity. A text region containing a known label → the nearest region to its right or below is likely the value. """ fields = {} # Classify each region as label or value label_regions = [] value_regions = [] for region in text_regions: text = region.get('text', '').strip() if not text: continue normalized = self._normalize_label(text) field_name = self._match_label(normalized) if field_name: label_regions.append((field_name, region)) else: value_regions.append(region) # For each label, find nearest value region to the right or below for field_name, label_reg in label_regions: if field_name in fields: continue lx1, ly1, lx2, ly2 = label_reg.get('bbox', (0, 0, 0, 0)) label_cx = (lx1 + lx2) / 2 label_cy = (ly1 + ly2) / 2 label_right = lx2 best_value = None best_dist = float('inf') for vreg in value_regions: vx1, vy1, vx2, vy2 = vreg.get('bbox', (0, 0, 0, 0)) vcx = (vx1 + vx2) / 2 vcy = (vy1 + vy2) / 2 # Value should be to the right of or below the label if vcx < label_cx - img_width * 0.05: continue # Prefer same-row (similar Y) then below dy = abs(vcy - label_cy) dx = vcx - label_right if dx < 0: dx = 0 # Weight: horizontal proximity matters more for same-row dist = (dx / img_width) + (dy / img_height) * 2 if dist < best_dist: best_dist = dist best_value = vreg if best_value and best_dist < 0.3: value = best_value.get('text', '').strip() conf = best_value.get('confidence', 0.5) if value: fields[field_name] = { 'value': self._clean_value(field_name, value), 'confidence': conf * 0.9, # Slight discount for spatial match } return fields def _cells_from_html(self, html): """Parse HTML table into cell list.""" cells = [] rows = re.findall(r'