Spaces:
Sleeping
Sleeping
File size: 12,165 Bytes
a6c2f2d e10c4da a6c2f2d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | # -*- 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'<tr>(.*?)</tr>', html, re.DOTALL)
for row_idx, row_html in enumerate(rows):
col_idx = 0
for cell_match in re.finditer(
r'<t[dh][^>]*>(.*?)</t[dh]>', row_html, re.DOTALL
):
cell_text = re.sub(r'<[^>]+>', '', cell_match.group(1)).strip()
cells.append({
'row': row_idx,
'col': col_idx,
'text': cell_text,
})
col_idx += 1
return cells
@staticmethod
def _normalize_label(text):
"""Normalize label text: remove spaces, parentheses, circled numbers."""
text = re.sub(r'[\u2460-\u2469]', '', text) # Remove ①-⑩
text = re.sub(r'[()()\s]', '', text)
return text.strip()
@staticmethod
def _match_label(normalized_text):
"""Match normalized text against known field labels. Returns field name or None."""
if not normalized_text or len(normalized_text) < 2:
return None
# Direct match (highest priority)
if normalized_text in _REVERSE_MAP:
return _REVERSE_MAP[normalized_text]
# Contained-label match: known label is fully contained in the text
# e.g., "②차종" contains "차종" → match
# Only match when the label covers most of the text (>=50%) to avoid false positives
best_match = None
best_ratio = 0.0
for label, field in _REVERSE_MAP.items():
if label in normalized_text:
ratio = len(label) / len(normalized_text)
if ratio > best_ratio and ratio >= 0.5:
best_ratio = ratio
best_match = field
return best_match
@staticmethod
def _clean_value(field_name, value):
"""Clean extracted value based on field type."""
if not value:
return value
# Remove leading/trailing noise
value = re.sub(r'^[\u2460-\u2469\s:]+', '', value)
value = value.strip()
if field_name == 'vin':
# VIN should be uppercase alphanumeric only
value = re.sub(r'[^A-Z0-9]', '', value.upper())
elif field_name in ('length_mm', 'width_mm', 'height_mm', 'total_weight_kg'):
# Numeric fields: extract digits
match = re.search(r'(\d[\d,]+)', value)
if match:
value = match.group(1).replace(',', '')
elif field_name == 'passenger_capacity':
match = re.search(r'(\d+)', value)
if match:
value = match.group(1)
elif field_name == 'registration_date':
value = re.sub(r'[년월일\s]+', '-', value).strip('-')
elif field_name == 'model_year':
match = re.search(r'((?:19|20)\d{2})', value)
if match:
value = match.group(1)
return value
@staticmethod
def _merge_fields(target, source, source_name='unknown'):
"""Merge source fields into target. Higher confidence wins."""
for field, info in source.items():
if field not in target:
info['source'] = source_name
target[field] = info
elif info.get('confidence', 0) > target[field].get('confidence', 0):
info['source'] = source_name
target[field] = info
|