File size: 17,598 Bytes
bce6acc
 
 
 
99bdf7f
 
 
 
 
a6c2f2d
bce6acc
 
 
63491a7
bce6acc
 
 
a6c2f2d
fe7f243
99bdf7f
babe376
 
99bdf7f
bce6acc
 
 
 
 
9d3eed6
a6c2f2d
bce6acc
a6c2f2d
bce6acc
 
 
9d3eed6
 
 
 
 
 
 
bce6acc
 
a6c2f2d
9d3eed6
a6c2f2d
 
 
 
 
 
 
 
 
 
 
 
 
2a75ff5
9d3eed6
 
2a75ff5
9d3eed6
 
2a75ff5
9d3eed6
 
2a75ff5
 
99bdf7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bce6acc
 
babe376
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99bdf7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bce6acc
99bdf7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b821cb1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99bdf7f
 
 
a6c2f2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b821cb1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99bdf7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d3eed6
bce6acc
9d3eed6
bce6acc
99bdf7f
bce6acc
 
 
 
 
 
 
9d3eed6
 
 
 
 
 
bce6acc
 
9d3eed6
 
63d0d3e
bce6acc
 
 
4629f65
99bdf7f
 
bce6acc
9d3eed6
 
bce6acc
99bdf7f
a6c2f2d
99bdf7f
 
 
b821cb1
 
 
 
 
 
99bdf7f
 
 
 
 
a6c2f2d
 
 
 
 
babe376
 
 
 
99bdf7f
 
 
babe376
99bdf7f
 
babe376
bce6acc
 
 
 
 
babe376
fe7f243
 
 
 
 
 
5d1a454
 
 
bce6acc
 
 
 
99bdf7f
 
bce6acc
 
63491a7
bce6acc
 
 
 
99bdf7f
bce6acc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99bdf7f
bce6acc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e10c4da
bce6acc
 
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
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# -*- coding: utf-8 -*-
"""
Core processing pipeline for vehicle registration OCR.
Orchestrates: image preprocessing -> PaddleOCR -> parsing -> validation -> results.

Enhanced with OCR_Vehicle_02 algorithms:
- Image preprocessing (CLAHE, deskew, denoise)
- Coordinate-based field extraction (bbox Y%/X% zones)
- Standards-based validation (자동차관리법 규격)
- PP-Structure layout analysis (form-aware extraction + ensemble)
"""
import gc
import logging
import traceback
from src.ocr.paddle_engine import LocalPaddleEngine
from src.ocr.preprocessor import ImagePreprocessor
from src.parser.car_registration import CarRegistrationParser
from src.parser.form_parser import FormParser
from src.validator.vin_validator import VINValidator, decode_model_year
from src.validator.standards import (
    FUEL_TYPE_CORRECTIONS, DIMENSION_RANGES, UNIVERSAL_RANGES, NOISE_PATTERNS,
    lookup_model_specs,
)
from src.config import Config

logger = logging.getLogger(__name__)

# Module-level instances (initialized lazily)
_ocr_engine = None
_layout_engine = None
_parser = CarRegistrationParser()
_form_parser = FormParser()
_validator = VINValidator()
_preprocessor = ImagePreprocessor(dpi=Config.PDF_DPI, max_size=Config.MAX_IMAGE_SIZE)


def get_ocr_engine():
    """Get or create the PaddleOCR engine singleton."""
    global _ocr_engine
    if _ocr_engine is None:
        _ocr_engine = LocalPaddleEngine(lang=Config.OCR_LANGUAGE, enable_paddle=True)
    return _ocr_engine


def get_layout_engine():
    """Get or create the PP-Structure layout engine singleton (lazy)."""
    global _layout_engine
    if _layout_engine is None and Config.ENABLE_LAYOUT_ANALYSIS:
        try:
            from src.ocr.layout_engine import LayoutEngine
            _layout_engine = LayoutEngine()
            if not _layout_engine.enabled:
                _layout_engine = None
        except Exception as e:
            logger.warning(f"Layout engine init failed, continuing without it: {e}")
            _layout_engine = None
    return _layout_engine


def warmup():
    """Pre-initialize Korean OCR engine only. Layout engine loads lazily."""
    logger.info("Warming up Korean OCR engine...")
    try:
        get_ocr_engine()
        logger.info("PaddleOCR engine ready.")
    except Exception as e:
        logger.warning(f"PaddleOCR warmup failed: {e}")
    logger.info("Warmup complete.")


def _correct_fuel_type_ocr(fuel_type):
    """Correct OCR misread fuel types using standards mapping."""
    if not fuel_type:
        return fuel_type

    # Direct correction
    if fuel_type in FUEL_TYPE_CORRECTIONS:
        corrected = FUEL_TYPE_CORRECTIONS[fuel_type]
        logger.info(f"Fuel type OCR correction: '{fuel_type}' → '{corrected}'")
        return corrected

    # Substring match
    for wrong, correct in FUEL_TYPE_CORRECTIONS.items():
        if wrong in fuel_type:
            logger.info(f"Fuel type OCR correction (substring): '{fuel_type}' → '{correct}'")
            return correct

    return fuel_type


def _apply_model_specs(parsed_data):
    """차명 기반 표준 규격 조회 → 빈 필드 보충 + 오인식 교정.

    차명+차종이 같으면 길이/너비/높이/총중량/승차정원이 동일하므로,
    OCR 누락 필드를 표준값으로 채우고, 범위 벗어나는 값을 교정.
    """
    model_name = parsed_data.get('model_name')
    specs = lookup_model_specs(model_name)
    if not specs:
        return

    spec_fields = ['vehicle_type', 'length_mm', 'width_mm', 'height_mm',
                    'total_weight_kg', 'passenger_capacity', 'fuel_type']

    filled = []
    corrected = []
    for field in spec_fields:
        spec_val = specs.get(field)
        if not spec_val:
            continue

        current = parsed_data.get(field)

        # Fill missing fields
        if not current:
            parsed_data[field] = spec_val
            filled.append(f"{field}={spec_val}")
            continue

        # Correct numeric fields that deviate >20% from spec
        if field in ('length_mm', 'width_mm', 'height_mm', 'total_weight_kg', 'passenger_capacity'):
            try:
                cur_num = int(current)
                spec_num = int(spec_val)
                if spec_num > 0 and abs(cur_num - spec_num) / spec_num > 0.2:
                    parsed_data[field] = spec_val
                    corrected.append(f"{field}: {current}→{spec_val}")
            except (ValueError, TypeError):
                parsed_data[field] = spec_val
                corrected.append(f"{field}: '{current}'→{spec_val}")

    if filled:
        logger.info(f"Model specs filled [{model_name}]: {', '.join(filled)}")
    if corrected:
        logger.info(f"Model specs corrected [{model_name}]: {', '.join(corrected)}")


def _validate_dimensions_by_type(parsed_data):
    """Validate dimensions against vehicle type standards (자동차관리법)."""
    vehicle_type = parsed_data.get('vehicle_type', '')

    # Normalize vehicle type (remove spaces)
    if vehicle_type:
        vehicle_type_clean = vehicle_type.replace(' ', '')
    else:
        vehicle_type_clean = ''

    # Get ranges for this vehicle type, fallback to universal
    ranges = DIMENSION_RANGES.get(vehicle_type_clean, UNIVERSAL_RANGES)

    field_map = {
        'length_mm': 'length_mm',
        'width_mm': 'width_mm',
        'height_mm': 'height_mm',
        'total_weight_kg': 'weight_kg',
        'passenger_capacity': 'capacity',
    }

    for data_field, range_key in field_map.items():
        value = parsed_data.get(data_field)
        if not value or range_key not in ranges:
            continue

        try:
            v = int(value)
            min_val, max_val = ranges[range_key]
            if v < min_val or v > max_val:
                logger.info(
                    f"Dimension out of range: {data_field}={v} "
                    f"(allowed {min_val}-{max_val} for '{vehicle_type_clean or 'universal'}')"
                )
                parsed_data[data_field] = None
        except (ValueError, TypeError):
            pass


def _extract_bbox_fields(ocr_results, img_height, img_width):
    """Extract fields using coordinate-based Y%/X% zone mapping.

    Zone layout from OCR_Vehicle_02 architecture:
        Y: 12-31% → Basic info (①-⑩)
        Y: 53-67% → Specifications table

    Returns dict of extracted fields (may be partial).
    """
    if not ocr_results or not img_height or not img_width:
        return {}

    def in_zone(r, y_min, y_max, x_min=0, x_max=100):
        """Check if OCR result bbox center is within percentage zone."""
        x1, y1, x2, y2 = r['bbox']
        cy = ((y1 + y2) / 2) / img_height * 100
        cx = ((x1 + x2) / 2) / img_width * 100
        return y_min <= cy <= y_max and x_min <= cx <= x_max

    def best_in_zone(y_min, y_max, x_min=0, x_max=100, min_conf=0.5):
        """Get highest-confidence text in a zone."""
        candidates = [
            r for r in ocr_results
            if in_zone(r, y_min, y_max, x_min, x_max)
            and r['confidence'] >= min_conf
            and not _is_noise(r['text'])
        ]
        if not candidates:
            return None
        return max(candidates, key=lambda r: r['confidence'])['text']

    result = {}

    # Basic info zone (Y: 14-28%)
    # ④차명 (Y: 16.5-19.5%, X: 17-42%)
    model_name = best_in_zone(16.5, 19.5, 17, 42)
    if model_name:
        result['model_name'] = model_name

    # ⑧소유자 (Y: 25-28.5%, X: 17-30%)
    owner = best_in_zone(25, 28.5, 17, 35)
    if owner:
        result['owner_name'] = owner

    # ②차종 (Y: 14-16.5%, X: 63-76%)
    vehicle_type = best_in_zone(14, 16.5, 63, 76)
    if vehicle_type:
        result['vehicle_type'] = vehicle_type

    # ⑥차대번호 VIN (Y: 19.5-23%, X: 17-70%)
    # Collect ALL text in the VIN zone and concatenate for VIN extraction
    vin_zone_results = [
        r for r in ocr_results
        if in_zone(r, 19.5, 23, 17, 70)
        and r['confidence'] >= 0.3  # Lower threshold for VIN
    ]
    if vin_zone_results:
        # Sort by X position (left to right)
        vin_zone_results.sort(key=lambda r: r['bbox'][0])
        vin_zone_text = ' '.join(r['text'] for r in vin_zone_results)
        # Store raw zone text for the parser to process
        result['_vin_zone_text'] = vin_zone_text
        logger.info(f"VIN zone bbox text: {vin_zone_text}")

    return result


def _apply_layout_ensemble(parsed_data, layout_engine, image_path, img_h, img_w):
    """Apply PP-Structure layout analysis results to supplement text-based parsing.

    Only fills in fields that text-based parsing missed or has low confidence on.
    Layout engine failure does not affect the existing pipeline.
    """
    try:
        layout_result = layout_engine.analyze(image_path)
        if not layout_result.get('tables') and not layout_result.get('text_regions'):
            return

        form_fields = _form_parser.parse_layout(layout_result, img_h, img_w)

        filled = []
        for field_name, field_info in form_fields.items():
            value = field_info.get('value', '')
            if not value:
                continue

            existing = parsed_data.get(field_name)
            if not existing:
                parsed_data[field_name] = value
                filled.append(field_name)

        if filled:
            logger.info(f"Layout ensemble filled {len(filled)} fields: {filled}")

    except Exception as e:
        logger.warning(f"Layout ensemble failed (non-fatal): {e}")


def _try_extract_vin_from_zone(parsed_data, vin_zone_text):
    """Try to extract VIN from bbox-detected VIN zone text.

    Uses aggressive transliteration and cleanup since the zone is spatially
    confirmed to be the VIN area on the registration certificate.
    """
    import re
    from src.validator.vin_validator import correct_vin_ocr, is_valid_structure

    # Korean char → Latin mapping for OCR misreads
    korean_to_latin = _parser.KOREAN_TO_LATIN

    # Transliterate Korean chars to Latin
    trans = []
    for ch in vin_zone_text:
        if ch in korean_to_latin:
            trans.append(korean_to_latin[ch])
        elif '\uAC00' <= ch <= '\uD7A3':
            # Skip full Korean syllables
            continue
        else:
            trans.append(ch)
    zone_clean = ''.join(trans)

    # Strip to alphanumeric only
    zone_alpha = re.sub(r'[^A-Za-z0-9]', '', zone_clean).upper()
    logger.info(f"VIN zone alpha: {zone_alpha}")

    if len(zone_alpha) < 17:
        return

    # Try to find valid 17-char VIN in the concatenated alpha text
    for i in range(len(zone_alpha) - 16):
        candidate = zone_alpha[i:i+17]
        vin = correct_vin_ocr(candidate)
        valid, _ = is_valid_structure(vin)
        if valid:
            parsed_data['vin'] = vin
            logger.info(f"VIN via bbox zone extraction: {vin}")
            return

    # Try known Korean WMI prefixes
    korean_prefixes = ['KMJ', 'KMH', 'KME', 'KMF', 'KMK', 'KNA', 'KNC', 'KND',
                       'KPT', 'KPA', 'KL1', 'KLA', 'KLB', 'KNM']
    for prefix in korean_prefixes:
        idx = zone_alpha.find(prefix)
        if idx >= 0 and idx + 17 <= len(zone_alpha):
            candidate = zone_alpha[idx:idx+17]
            vin = correct_vin_ocr(candidate)
            if len(vin) == 17 and vin.isalnum() and not any(c in vin for c in 'IOQ'):
                parsed_data['vin'] = vin
                logger.info(f"VIN via bbox zone prefix match ({prefix}): {vin}")
                return


def _is_noise(text):
    """Check if text is noise (legal/warning text)."""
    for pattern in NOISE_PATTERNS:
        if pattern in text:
            return True
    return False


def _get_image_dimensions(image_path):
    """Get image dimensions for coordinate-based extraction."""
    try:
        from PIL import Image
        with Image.open(image_path) as img:
            return img.size  # (width, height)
    except Exception:
        return None, None


def process_single_file(file_path, filename):
    """Process a single image/PDF file through the OCR pipeline."""
    try:
        engine = get_ocr_engine()

        # 1. Preprocess image/PDF (now with CLAHE, deskew, denoise)
        image_paths = _preprocessor.load_image(file_path)
        if not image_paths:
            return {'status': 'error', 'filename': filename, 'data': {}, 'message': 'Failed to load image'}

        image_path = image_paths[0]

        try:
            # 2. Run PaddleOCR (Korean)
            logger.info(f"Running OCR on: {image_path}")
            ocr_result = engine.detect_text(image_path)
            ocr_text = ocr_result.get('text', '')
            ocr_results = ocr_result.get('ocr_results', [])
            logger.info(f"OCR text length: {len(ocr_text)}, bbox results: {len(ocr_results)}")

            if not ocr_text:
                debug = ocr_result.get('debug', '')
                msg = f'No text detected [API:{engine._api_version}, debug:{debug}]'
                return {'status': 'error', 'filename': filename, 'data': {}, 'message': msg}

            # 3. Verify document type
            if not _parser.verify_document_type(ocr_text):
                preview = ocr_text[:200].replace('\n', ' | ')
                return {'status': 'skipped', 'filename': filename, 'data': {},
                        'message': f'Not a vehicle registration certificate. OCR preview: {preview}'}

            # 4. Parse (text-based)
            parsed_data = _parser.parse_single(ocr_text, filename=filename)

            # 5. Coordinate-based extraction (supplement text-based results)
            img_w, img_h = _get_image_dimensions(image_path)
            if ocr_results:
                if img_w and img_h:
                    bbox_fields = _extract_bbox_fields(ocr_results, img_h, img_w)

                    # Special handling: VIN zone text for aggressive VIN extraction
                    vin_zone_text = bbox_fields.pop('_vin_zone_text', None)
                    if vin_zone_text and not parsed_data.get('vin'):
                        _try_extract_vin_from_zone(parsed_data, vin_zone_text)

                    for field, value in bbox_fields.items():
                        if not parsed_data.get(field):
                            parsed_data[field] = value
                            logger.info(f"Field '{field}' filled by bbox extraction: {value}")

            # 5.5. PP-Structure layout analysis (ensemble with text-based results)
            layout_engine = get_layout_engine()
            if layout_engine:
                _apply_layout_ensemble(parsed_data, layout_engine, image_path, img_h, img_w)

            # 6. Model spec lookup: fill missing fields from known model specs
            _apply_model_specs(parsed_data)

            # 7. Apply fuel type OCR corrections (자동차관리법)
            if parsed_data.get('fuel_type'):
                parsed_data['fuel_type'] = _correct_fuel_type_ocr(parsed_data['fuel_type'])

            # 8. Validate dimensions against vehicle type standards
            _validate_dimensions_by_type(parsed_data)

            # 9. Validate VIN
            vin = parsed_data.get('vin')
            is_valid, validation_msg = _validator.validate(vin)
            parsed_data['vin_valid'] = is_valid
            parsed_data['vin_message'] = validation_msg

            # 10. Decode model year from VIN if not already extracted
            if vin and not parsed_data.get('model_year'):
                vin_year = decode_model_year(vin)
                if vin_year:
                    parsed_data['model_year'] = str(vin_year)
                    logger.info(f"Model year from VIN: {vin_year}")

            # Include OCR text preview for debugging
            parsed_data['_ocr_preview'] = ocr_text[:300].replace('\n', ' | ')

            logger.info(f"Successfully processed: {filename}")
            return {'status': 'success', 'filename': filename, 'data': parsed_data, 'message': 'OK'}

        finally:
            # Cleanup temp files
            ImagePreprocessor.cleanup_temp_files(image_paths, file_path)

    except Exception as e:
        logger.error(f"Error processing {filename}: {e}\n{traceback.format_exc()}")
        return {'status': 'error', 'filename': filename, 'data': {}, 'message': str(e)}


def process_batch(file_list, progress_callback=None):
    """Process a batch of files."""
    results = []
    total = len(file_list)

    for i, (file_path, filename) in enumerate(file_list):
        result = process_single_file(file_path, filename)
        results.append(result)

        if progress_callback:
            progress_callback(i + 1, total)

        if (i + 1) % Config.GC_INTERVAL == 0:
            gc.collect()

    return results


def results_to_rows(results):
    """Convert processing results to rows for Excel output."""
    rows = []
    for r in results:
        if r['status'] != 'success':
            continue
        d = r['data']
        rows.append([
            d.get('vehicle_no', ''),
            d.get('owner_name', ''),
            d.get('vin', ''),
            d.get('model_name', ''),
            d.get('model_year', ''),
            d.get('registration_date', ''),
            d.get('vehicle_type', ''),
            d.get('length_mm', ''),
            d.get('width_mm', ''),
            d.get('height_mm', ''),
            d.get('total_weight_kg', ''),
            d.get('passenger_capacity', ''),
            d.get('fuel_type', ''),
            d.get('purchase_price', ''),
        ])
    return rows