# -*- 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