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