Spaces:
Sleeping
Sleeping
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
|