Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -14,7 +14,7 @@ import cv2
|
|
| 14 |
import gradio as gr
|
| 15 |
import numpy as np
|
| 16 |
import torch
|
| 17 |
-
from PIL import Image, ImageOps
|
| 18 |
from huggingface_hub import snapshot_download
|
| 19 |
from transformers import AutoImageProcessor, AutoTokenizer, VisionEncoderDecoderModel
|
| 20 |
from ultralytics import YOLO
|
|
@@ -47,6 +47,12 @@ except AttributeError:
|
|
| 47 |
# UTILIDADES GENERALES
|
| 48 |
# ============================================================
|
| 49 |
def clean_plate_text(text: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
if not text:
|
| 51 |
return ""
|
| 52 |
text = text.upper().strip()
|
|
@@ -61,7 +67,7 @@ def save_pil_image(pil_img: Image.Image, suffix: str = ".png") -> str:
|
|
| 61 |
return tmp.name
|
| 62 |
|
| 63 |
|
| 64 |
-
def pad_box(x1, y1, x2, y2, w, h, pad_ratio=0.
|
| 65 |
bw = x2 - x1
|
| 66 |
bh = y2 - y1
|
| 67 |
pad_x = int(bw * pad_ratio)
|
|
@@ -94,6 +100,9 @@ def draw_box_and_label(pil_img: Image.Image, box, label: str) -> Image.Image:
|
|
| 94 |
|
| 95 |
|
| 96 |
def levenshtein_distance(a: str, b: str) -> int:
|
|
|
|
|
|
|
|
|
|
| 97 |
a = a or ""
|
| 98 |
b = b or ""
|
| 99 |
if a == b:
|
|
@@ -116,6 +125,9 @@ def levenshtein_distance(a: str, b: str) -> int:
|
|
| 116 |
|
| 117 |
|
| 118 |
def image_hash(pil_img: Image.Image) -> str:
|
|
|
|
|
|
|
|
|
|
| 119 |
arr = np.array(pil_img.convert("RGB"))
|
| 120 |
return str(hash(arr.tobytes()))
|
| 121 |
|
|
@@ -125,10 +137,6 @@ def safe_mean(values):
|
|
| 125 |
return float(np.mean(values)) if values else 0.0
|
| 126 |
|
| 127 |
|
| 128 |
-
def clamp01(x: float) -> float:
|
| 129 |
-
return float(max(0.0, min(1.0, x)))
|
| 130 |
-
|
| 131 |
-
|
| 132 |
def try_save_histogram(values, title, out_path, bins=20):
|
| 133 |
try:
|
| 134 |
import matplotlib.pyplot as plt
|
|
@@ -166,150 +174,75 @@ def try_save_bar(counter_obj, title, out_path, max_items=20):
|
|
| 166 |
|
| 167 |
|
| 168 |
# ============================================================
|
| 169 |
-
#
|
| 170 |
# ============================================================
|
| 171 |
-
def
|
| 172 |
-
rect = np.zeros((4, 2), dtype="float32")
|
| 173 |
-
s = pts.sum(axis=1)
|
| 174 |
-
diff = np.diff(pts, axis=1)
|
| 175 |
-
|
| 176 |
-
rect[0] = pts[np.argmin(s)] # top-left
|
| 177 |
-
rect[2] = pts[np.argmax(s)] # bottom-right
|
| 178 |
-
rect[1] = pts[np.argmin(diff)] # top-right
|
| 179 |
-
rect[3] = pts[np.argmax(diff)] # bottom-left
|
| 180 |
-
return rect
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
def four_point_transform(image, pts):
|
| 184 |
-
rect = order_points(pts)
|
| 185 |
-
(tl, tr, br, bl) = rect
|
| 186 |
-
|
| 187 |
-
width_a = np.linalg.norm(br - bl)
|
| 188 |
-
width_b = np.linalg.norm(tr - tl)
|
| 189 |
-
max_width = int(max(width_a, width_b))
|
| 190 |
-
|
| 191 |
-
height_a = np.linalg.norm(tr - br)
|
| 192 |
-
height_b = np.linalg.norm(tl - bl)
|
| 193 |
-
max_height = int(max(height_a, height_b))
|
| 194 |
-
|
| 195 |
-
if max_width < 2 or max_height < 2:
|
| 196 |
-
return image
|
| 197 |
-
|
| 198 |
-
dst = np.array([
|
| 199 |
-
[0, 0],
|
| 200 |
-
[max_width - 1, 0],
|
| 201 |
-
[max_width - 1, max_height - 1],
|
| 202 |
-
[0, max_height - 1]
|
| 203 |
-
], dtype="float32")
|
| 204 |
-
|
| 205 |
-
M = cv2.getPerspectiveTransform(rect, dst)
|
| 206 |
-
warped = cv2.warpPerspective(image, M, (max_width, max_height))
|
| 207 |
-
return warped
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
def rotate_if_needed(pil_img: Image.Image) -> Image.Image:
|
| 211 |
-
arr = np.array(pil_img.convert("RGB"))
|
| 212 |
-
h, w = arr.shape[:2]
|
| 213 |
-
if h > w * 1.2:
|
| 214 |
-
arr = cv2.rotate(arr, cv2.ROTATE_90_CLOCKWISE)
|
| 215 |
-
return Image.fromarray(arr)
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
def rectify_plate_roi(pil_img: Image.Image) -> Image.Image:
|
| 219 |
"""
|
| 220 |
-
|
| 221 |
"""
|
| 222 |
-
img =
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
| 227 |
-
gray = cv2.bilateralFilter(gray, 7, 50, 50)
|
| 228 |
-
|
| 229 |
-
# Resalta bordes y contornos de la placa
|
| 230 |
-
edges = cv2.Canny(gray, 40, 140)
|
| 231 |
-
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
| 232 |
-
edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel, iterations=2)
|
| 233 |
-
|
| 234 |
-
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 235 |
-
if contours:
|
| 236 |
-
contours = sorted(contours, key=cv2.contourArea, reverse=True)
|
| 237 |
-
h, w = img.shape[:2]
|
| 238 |
-
min_area = max(500, 0.08 * (h * w))
|
| 239 |
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
|
| 245 |
-
peri = cv2.arcLength(c, True)
|
| 246 |
-
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
|
| 247 |
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
|
|
|
| 253 |
|
| 254 |
-
|
| 255 |
-
gray2 = cv2.GaussianBlur(gray, (3, 3), 0)
|
| 256 |
thresh = cv2.adaptiveThreshold(
|
| 257 |
-
|
| 258 |
255,
|
| 259 |
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 260 |
cv2.THRESH_BINARY_INV,
|
| 261 |
31,
|
| 262 |
11
|
| 263 |
)
|
|
|
|
| 264 |
coords = cv2.findNonZero(thresh)
|
| 265 |
if coords is None:
|
| 266 |
-
return
|
| 267 |
|
| 268 |
angle = cv2.minAreaRect(coords)[-1]
|
| 269 |
if angle < -45:
|
| 270 |
angle = 90 + angle
|
| 271 |
|
| 272 |
if abs(angle) < 1.0:
|
| 273 |
-
return
|
| 274 |
|
| 275 |
-
h, w =
|
| 276 |
center = (w // 2, h // 2)
|
| 277 |
M = cv2.getRotationMatrix2D(center, angle, 1.0)
|
|
|
|
| 278 |
rotated = cv2.warpAffine(
|
| 279 |
-
|
| 280 |
M,
|
| 281 |
(w, h),
|
| 282 |
flags=cv2.INTER_CUBIC,
|
| 283 |
borderMode=cv2.BORDER_REPLICATE
|
| 284 |
)
|
| 285 |
-
return
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
# ============================================================
|
| 289 |
-
# PREPROCESADO OCR
|
| 290 |
-
# ============================================================
|
| 291 |
-
def resize_keep_aspect(pil_img: Image.Image, target_height: int = 448) -> Image.Image:
|
| 292 |
-
img = pil_img.convert("RGB")
|
| 293 |
-
w, h = img.size
|
| 294 |
-
if w == 0 or h == 0:
|
| 295 |
-
return img
|
| 296 |
-
|
| 297 |
-
scale = target_height / float(h)
|
| 298 |
-
new_w = max(1, int(round(w * scale)))
|
| 299 |
-
new_h = target_height
|
| 300 |
-
return img.resize((new_w, new_h), RESAMPLE_LANCZOS)
|
| 301 |
|
| 302 |
|
| 303 |
def preprocess_for_ocr(pil_img: Image.Image, target_height: int = 448) -> Image.Image:
|
| 304 |
"""
|
| 305 |
-
|
| 306 |
-
-
|
| 307 |
-
- redimensionamiento
|
| 308 |
-
-
|
| 309 |
-
-
|
| 310 |
- nitidez
|
| 311 |
"""
|
| 312 |
-
img =
|
| 313 |
img = resize_keep_aspect(img, target_height=target_height)
|
| 314 |
|
| 315 |
arr = np.array(img.convert("RGB"))
|
|
@@ -317,7 +250,7 @@ def preprocess_for_ocr(pil_img: Image.Image, target_height: int = 448) -> Image.
|
|
| 317 |
|
| 318 |
gray = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
| 319 |
|
| 320 |
-
clahe = cv2.createCLAHE(clipLimit=2.
|
| 321 |
gray = clahe.apply(gray)
|
| 322 |
|
| 323 |
gray = cv2.GaussianBlur(gray, (3, 3), 0)
|
|
@@ -334,12 +267,15 @@ def preprocess_for_ocr(pil_img: Image.Image, target_height: int = 448) -> Image.
|
|
| 334 |
|
| 335 |
|
| 336 |
def enhance_for_ocr(pil_img: Image.Image) -> Image.Image:
|
|
|
|
|
|
|
|
|
|
| 337 |
return preprocess_for_ocr(pil_img)
|
| 338 |
|
| 339 |
|
| 340 |
def build_ocr_variants(pil_img: Image.Image):
|
| 341 |
"""
|
| 342 |
-
|
| 343 |
"""
|
| 344 |
base = preprocess_for_ocr(pil_img)
|
| 345 |
arr = np.array(base.convert("RGB"))
|
|
@@ -347,19 +283,9 @@ def build_ocr_variants(pil_img: Image.Image):
|
|
| 347 |
|
| 348 |
variants = [base]
|
| 349 |
|
| 350 |
-
# Autocontraste suave
|
| 351 |
variants.append(ImageOps.autocontrast(base))
|
| 352 |
|
| 353 |
-
|
| 354 |
-
sharp = ImageEnhance.Sharpness(base).enhance(1.8)
|
| 355 |
-
variants.append(sharp)
|
| 356 |
-
|
| 357 |
-
# Otsu
|
| 358 |
-
_, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 359 |
-
variants.append(Image.fromarray(otsu).convert("RGB"))
|
| 360 |
-
|
| 361 |
-
# Adaptive threshold
|
| 362 |
-
adaptive = cv2.adaptiveThreshold(
|
| 363 |
gray,
|
| 364 |
255,
|
| 365 |
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
@@ -367,29 +293,28 @@ def build_ocr_variants(pil_img: Image.Image):
|
|
| 367 |
31,
|
| 368 |
11
|
| 369 |
)
|
| 370 |
-
variants.append(Image.fromarray(
|
| 371 |
-
|
| 372 |
-
# Invertida por si el contraste viene al rev茅s
|
| 373 |
-
inv = cv2.bitwise_not(otsu)
|
| 374 |
-
variants.append(Image.fromarray(inv).convert("RGB"))
|
| 375 |
|
| 376 |
return variants
|
| 377 |
|
| 378 |
|
| 379 |
# ============================================================
|
| 380 |
-
#
|
| 381 |
# ============================================================
|
|
|
|
|
|
|
|
|
|
| 382 |
LETTER_CANDIDATES = {
|
| 383 |
"0": ("O",),
|
| 384 |
"1": ("I", "L"),
|
| 385 |
"2": ("Z",),
|
| 386 |
-
"3": ("B",
|
| 387 |
"4": ("A",),
|
| 388 |
"5": ("S",),
|
| 389 |
-
"6": ("
|
| 390 |
"7": ("T",),
|
| 391 |
"8": ("B",),
|
| 392 |
-
"9": ("P",),
|
| 393 |
"A": ("A",),
|
| 394 |
"B": ("B",),
|
| 395 |
"C": ("C",),
|
|
@@ -447,62 +372,44 @@ DIGIT_CANDIDATES = {
|
|
| 447 |
"9": ("9",),
|
| 448 |
}
|
| 449 |
|
| 450 |
-
# Formatos comunes para placas colombianas y motos
|
| 451 |
-
PLATE_TEMPLATES = [
|
| 452 |
-
("LLLDDL", 1.35), # como MAN54G
|
| 453 |
-
("LLLDDD", 1.20), # placas de 3 letras + 3 d铆gitos
|
| 454 |
-
("LLLDDL", 1.10), # variantes cercanas
|
| 455 |
-
]
|
| 456 |
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
re.compile(r"^[A-Z]{3}\d{3}$"), # ABC123
|
| 460 |
-
re.compile(r"^[A-Z]{3}\d{2}[A-Z0-9]$"),
|
| 461 |
-
]
|
| 462 |
|
| 463 |
|
| 464 |
def normalize_plate_candidate(text: str) -> str:
|
| 465 |
return clean_plate_text(text)
|
| 466 |
|
| 467 |
|
| 468 |
-
def candidate_matches_template(text: str, template: str) -> float:
|
| 469 |
-
if len(text) != len(template):
|
| 470 |
-
return 0.0
|
| 471 |
-
|
| 472 |
-
score = 0.0
|
| 473 |
-
for ch, t in zip(text, template):
|
| 474 |
-
if t == "L":
|
| 475 |
-
if ch.isalpha():
|
| 476 |
-
score += 1.0
|
| 477 |
-
elif ch in LETTER_CANDIDATES:
|
| 478 |
-
score += 0.45
|
| 479 |
-
elif t == "D":
|
| 480 |
-
if ch.isdigit():
|
| 481 |
-
score += 1.0
|
| 482 |
-
elif ch in DIGIT_CANDIDATES:
|
| 483 |
-
score += 0.45
|
| 484 |
-
|
| 485 |
-
return score / float(len(template))
|
| 486 |
-
|
| 487 |
-
|
| 488 |
def plate_format_score(text: str) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
if not text:
|
| 490 |
return 0.0
|
| 491 |
|
| 492 |
-
if
|
| 493 |
-
return
|
| 494 |
-
|
| 495 |
-
best = 0.0
|
| 496 |
-
for template, weight in PLATE_TEMPLATES:
|
| 497 |
-
best = max(best, weight * candidate_matches_template(text, template))
|
| 498 |
|
| 499 |
-
if
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
|
| 502 |
-
return
|
| 503 |
|
| 504 |
|
| 505 |
def expand_plate_candidate(text: str):
|
|
|
|
|
|
|
|
|
|
| 506 |
text = clean_plate_text(text)
|
| 507 |
if not text:
|
| 508 |
return []
|
|
@@ -512,93 +419,82 @@ def expand_plate_candidate(text: str):
|
|
| 512 |
|
| 513 |
options = []
|
| 514 |
for i, ch in enumerate(text):
|
| 515 |
-
# Para placas tipo MAN54G, el 煤ltimo caracter puede ser letra
|
| 516 |
if i < 3:
|
| 517 |
opts = LETTER_CANDIDATES.get(ch, (ch,))
|
| 518 |
-
elif i in (3, 4):
|
| 519 |
-
opts = DIGIT_CANDIDATES.get(ch, (ch,))
|
| 520 |
else:
|
| 521 |
-
|
| 522 |
-
opts = tuple(sorted(set(LETTER_CANDIDATES.get(ch, (ch,)) + DIGIT_CANDIDATES.get(ch, (ch,)))))
|
| 523 |
options.append(opts)
|
| 524 |
|
| 525 |
variants = {"".join(chars) for chars in product(*options)}
|
| 526 |
-
return sorted(list(variants))[:
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
def template_bonus_for_text(text: str) -> float:
|
| 530 |
-
best = 0.0
|
| 531 |
-
for template, weight in PLATE_TEMPLATES:
|
| 532 |
-
best = max(best, weight * candidate_matches_template(text, template))
|
| 533 |
-
return best
|
| 534 |
|
| 535 |
|
| 536 |
def choose_best_plate(candidates):
|
| 537 |
"""
|
| 538 |
-
|
| 539 |
-
|
|
|
|
|
|
|
| 540 |
"""
|
| 541 |
if not candidates:
|
| 542 |
return "", 0.0, {"n_candidates": 0}
|
| 543 |
|
| 544 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
continue
|
| 550 |
|
| 551 |
-
|
| 552 |
-
if not expanded:
|
| 553 |
-
expanded = [cleaned]
|
| 554 |
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
edits = sum(a != b for a, b in zip(cleaned, cand))
|
| 558 |
-
fmt = plate_format_score(cand)
|
| 559 |
-
adjusted = float(raw_score) + (1.40 * fmt) - (0.18 * edits)
|
| 560 |
-
aggregated[cand]["score_sum"] += adjusted
|
| 561 |
-
aggregated[cand]["votes"] += 1
|
| 562 |
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
| 566 |
|
| 567 |
scored = []
|
| 568 |
-
for
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
|
| 576 |
scored.sort(key=lambda x: x[1], reverse=True)
|
| 577 |
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
probs = probs / max(1e-8, probs.sum())
|
| 581 |
-
|
| 582 |
-
best_text = scored[0][0]
|
| 583 |
-
best_soft = float(probs[0])
|
| 584 |
-
best_fmt = float(scored[0][3])
|
| 585 |
-
|
| 586 |
-
# Confianza OCR calibrada
|
| 587 |
-
ocr_conf = clamp01(
|
| 588 |
-
0.42 * best_soft +
|
| 589 |
-
0.28 * best_fmt +
|
| 590 |
-
0.30 * min(1.0, np.log1p(scored[0][2]) / 2.5)
|
| 591 |
-
)
|
| 592 |
|
| 593 |
diagnostics = {
|
| 594 |
-
"n_candidates": len(
|
| 595 |
-
"
|
| 596 |
-
"
|
| 597 |
-
"
|
| 598 |
-
"
|
| 599 |
}
|
| 600 |
|
| 601 |
-
return best_text,
|
| 602 |
|
| 603 |
|
| 604 |
# ============================================================
|
|
@@ -628,26 +524,17 @@ def load_ocr():
|
|
| 628 |
try:
|
| 629 |
image_processor = AutoImageProcessor.from_pretrained(OCR_REPO_ID, token=HF_TOKEN)
|
| 630 |
except Exception:
|
| 631 |
-
image_processor = AutoImageProcessor.from_pretrained(
|
| 632 |
-
"microsoft/trocr-base-printed",
|
| 633 |
-
token=HF_TOKEN
|
| 634 |
-
)
|
| 635 |
|
| 636 |
try:
|
| 637 |
tokenizer = AutoTokenizer.from_pretrained(OCR_REPO_ID, token=HF_TOKEN)
|
| 638 |
except Exception:
|
| 639 |
-
tokenizer = AutoTokenizer.from_pretrained(
|
| 640 |
-
"microsoft/trocr-base-printed",
|
| 641 |
-
token=HF_TOKEN
|
| 642 |
-
)
|
| 643 |
|
| 644 |
try:
|
| 645 |
model = VisionEncoderDecoderModel.from_pretrained(OCR_REPO_ID, token=HF_TOKEN)
|
| 646 |
except Exception:
|
| 647 |
-
model = VisionEncoderDecoderModel.from_pretrained(
|
| 648 |
-
"microsoft/trocr-base-printed",
|
| 649 |
-
token=HF_TOKEN
|
| 650 |
-
)
|
| 651 |
|
| 652 |
model.to(DEVICE)
|
| 653 |
model.eval()
|
|
@@ -659,18 +546,21 @@ def load_ocr():
|
|
| 659 |
# OCR ROBUSTO
|
| 660 |
# ============================================================
|
| 661 |
def ocr_variant_candidates(variant: Image.Image, image_processor, tokenizer, ocr_model):
|
|
|
|
|
|
|
|
|
|
| 662 |
pixel_values = image_processor(images=variant, return_tensors="pt").pixel_values.to(DEVICE)
|
| 663 |
|
| 664 |
with torch.inference_mode():
|
| 665 |
generated = ocr_model.generate(
|
| 666 |
pixel_values,
|
| 667 |
-
max_new_tokens=
|
| 668 |
-
num_beams=
|
| 669 |
-
num_return_sequences=
|
| 670 |
do_sample=False,
|
| 671 |
early_stopping=True,
|
| 672 |
-
length_penalty=
|
| 673 |
-
repetition_penalty=1.
|
| 674 |
no_repeat_ngram_size=0,
|
| 675 |
return_dict_in_generate=True,
|
| 676 |
output_scores=True,
|
|
@@ -692,6 +582,9 @@ def ocr_variant_candidates(variant: Image.Image, image_processor, tokenizer, ocr
|
|
| 692 |
|
| 693 |
|
| 694 |
def ocr_plate_from_roi(roi: Image.Image, image_processor, tokenizer, ocr_model):
|
|
|
|
|
|
|
|
|
|
| 695 |
variants = build_ocr_variants(roi)
|
| 696 |
all_candidates = []
|
| 697 |
|
|
@@ -711,46 +604,25 @@ def ocr_plate_from_roi(roi: Image.Image, image_processor, tokenizer, ocr_model):
|
|
| 711 |
|
| 712 |
|
| 713 |
def calibrate_total_confidence(det_conf: float, ocr_conf: float) -> float:
|
|
|
|
|
|
|
|
|
|
| 714 |
det_conf = clamp01(det_conf)
|
| 715 |
ocr_conf = clamp01(ocr_conf)
|
| 716 |
|
| 717 |
-
# OCR pesa m谩s porque es lo que valida el serial final
|
| 718 |
total = 0.35 * det_conf + 0.65 * ocr_conf
|
| 719 |
return round(clamp01(total), 4)
|
| 720 |
|
| 721 |
|
| 722 |
-
# ============================================================
|
| 723 |
-
# JUSTIFICACI脫N EXPERIMENTAL
|
| 724 |
-
# ============================================================
|
| 725 |
-
def build_experimental_justification():
|
| 726 |
-
return {
|
| 727 |
-
"detector": "YOLO porque es r谩pido, pr谩ctico y adecuado para localizar placas en im谩genes completas.",
|
| 728 |
-
"ocr": "TrOCR porque trabaja bien sobre recortes de texto y permite consenso mediante beam search.",
|
| 729 |
-
"preprocessing": [
|
| 730 |
-
"Rectificaci贸n de perspectiva e inclinaci贸n para estabilizar caracteres.",
|
| 731 |
-
"Redimensionamiento manteniendo aspect ratio para estabilizar la entrada del OCR.",
|
| 732 |
-
"Reducci贸n de ruido sin destruir bordes de caracteres.",
|
| 733 |
-
"CLAHE para mejorar contraste local.",
|
| 734 |
-
"Variantes con Otsu, binarizaci贸n adaptativa, autocontraste e inversi贸n para consenso OCR."
|
| 735 |
-
],
|
| 736 |
-
"decision_rules": [
|
| 737 |
-
"Se usa un umbral de detecci贸n moderado para equilibrar precisi贸n y recall.",
|
| 738 |
-
"Se aplica padding al bounding box para no recortar caracteres perif茅ricos.",
|
| 739 |
-
"Se calcula una confianza aproximada combinando la confianza del detector y el consenso OCR.",
|
| 740 |
-
"Se priorizan formatos de 6 caracteres como LLLDDD y LLLDDL, 煤tiles para placas vistas en la actividad."
|
| 741 |
-
],
|
| 742 |
-
"limitations": [
|
| 743 |
-
"La confianza total es heur铆stica y no sustituye una calibraci贸n formal.",
|
| 744 |
-
"La lectura final depende de la calidad de la detecci贸n y del recorte.",
|
| 745 |
-
"La evaluaci贸n real requiere ground truth para detecci贸n y OCR."
|
| 746 |
-
]
|
| 747 |
-
}
|
| 748 |
-
|
| 749 |
-
|
| 750 |
# ============================================================
|
| 751 |
# AN脕LISIS DE DATOS
|
| 752 |
# ============================================================
|
| 753 |
def parse_yolo_label_file(label_path: Path, img_w: int, img_h: int):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
boxes = []
|
| 755 |
if not label_path.exists():
|
| 756 |
return boxes
|
|
@@ -784,6 +656,9 @@ def parse_yolo_label_file(label_path: Path, img_w: int, img_h: int):
|
|
| 784 |
|
| 785 |
|
| 786 |
def analyze_detection_dataset(images_dir, labels_dir, output_dir="analysis_out"):
|
|
|
|
|
|
|
|
|
|
| 787 |
images_dir = Path(images_dir)
|
| 788 |
labels_dir = Path(labels_dir)
|
| 789 |
output_dir = Path(output_dir)
|
|
@@ -887,6 +762,12 @@ def analyze_detection_dataset(images_dir, labels_dir, output_dir="analysis_out")
|
|
| 887 |
|
| 888 |
|
| 889 |
def analyze_ocr_dataset(csv_path, image_col="image_path", text_col="text", output_dir="analysis_out"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
output_dir = Path(output_dir)
|
| 891 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 892 |
|
|
@@ -964,6 +845,34 @@ def analyze_ocr_dataset(csv_path, image_col="image_path", text_col="text", outpu
|
|
| 964 |
return report
|
| 965 |
|
| 966 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
# ============================================================
|
| 968 |
# ENTRENAMIENTO DEL DETECTOR
|
| 969 |
# ============================================================
|
|
@@ -979,6 +888,10 @@ def train_detector(
|
|
| 979 |
project: str = "runs/train",
|
| 980 |
name: str = "plates_detector"
|
| 981 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 982 |
model = YOLO(base_model)
|
| 983 |
results = model.train(
|
| 984 |
data=data_yaml,
|
|
@@ -1007,6 +920,9 @@ def xywh_to_xyxy(xc, yc, bw, bh, img_w, img_h):
|
|
| 1007 |
|
| 1008 |
|
| 1009 |
def compute_iou(box_a, box_b):
|
|
|
|
|
|
|
|
|
|
| 1010 |
ax1, ay1, ax2, ay2 = box_a
|
| 1011 |
bx1, by1, bx2, by2 = box_b
|
| 1012 |
|
|
@@ -1036,6 +952,14 @@ def evaluate_detector_on_yolo_dataset(
|
|
| 1036 |
iou_threshold=0.5,
|
| 1037 |
max_images=None
|
| 1038 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1039 |
images_dir = Path(images_dir)
|
| 1040 |
labels_dir = Path(labels_dir)
|
| 1041 |
|
|
@@ -1139,6 +1063,9 @@ def evaluate_detector_on_yolo_dataset(
|
|
| 1139 |
|
| 1140 |
|
| 1141 |
def ultralytics_val_metrics(model, data_yaml):
|
|
|
|
|
|
|
|
|
|
| 1142 |
try:
|
| 1143 |
val_res = model.val(data=data_yaml, verbose=False)
|
| 1144 |
box = getattr(val_res, "box", None)
|
|
@@ -1165,6 +1092,12 @@ def evaluate_ocr_csv(
|
|
| 1165 |
ocr_model=None,
|
| 1166 |
max_rows=None
|
| 1167 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1168 |
if image_processor is None or tokenizer is None or ocr_model is None:
|
| 1169 |
image_processor, tokenizer, ocr_model = load_ocr()
|
| 1170 |
|
|
@@ -1236,6 +1169,11 @@ def evaluate_end_to_end_csv(
|
|
| 1236 |
iou_threshold=0.5,
|
| 1237 |
max_rows=None
|
| 1238 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1239 |
if detector is None:
|
| 1240 |
detector = load_detector()
|
| 1241 |
if image_processor is None or tokenizer is None or ocr_model is None:
|
|
@@ -1305,7 +1243,7 @@ def evaluate_end_to_end_csv(
|
|
| 1305 |
det_fp += 1
|
| 1306 |
det_fn += 1
|
| 1307 |
|
| 1308 |
-
x1, y1, x2, y2 = pad_box(*pred_box, w, h, pad_ratio=0.
|
| 1309 |
roi = img.crop((x1, y1, x2, y2))
|
| 1310 |
roi = enhance_for_ocr(roi)
|
| 1311 |
|
|
@@ -1382,13 +1320,12 @@ def process_image(image: Image.Image):
|
|
| 1382 |
int(y2),
|
| 1383 |
w,
|
| 1384 |
h,
|
| 1385 |
-
pad_ratio=0.
|
| 1386 |
)
|
| 1387 |
|
| 1388 |
roi = image.crop((x1, y1, x2, y2))
|
| 1389 |
roi_display = enhance_for_ocr(roi)
|
| 1390 |
|
| 1391 |
-
# OCR sobre la ROI mejorada
|
| 1392 |
plate_text, ocr_conf, ocr_diag = ocr_plate_from_roi(
|
| 1393 |
roi_display,
|
| 1394 |
image_processor,
|
|
|
|
| 14 |
import gradio as gr
|
| 15 |
import numpy as np
|
| 16 |
import torch
|
| 17 |
+
from PIL import Image, ImageOps
|
| 18 |
from huggingface_hub import snapshot_download
|
| 19 |
from transformers import AutoImageProcessor, AutoTokenizer, VisionEncoderDecoderModel
|
| 20 |
from ultralytics import YOLO
|
|
|
|
| 47 |
# UTILIDADES GENERALES
|
| 48 |
# ============================================================
|
| 49 |
def clean_plate_text(text: str) -> str:
|
| 50 |
+
"""
|
| 51 |
+
Normaliza texto de placa:
|
| 52 |
+
- may煤sculas
|
| 53 |
+
- sin espacios
|
| 54 |
+
- sin s铆mbolos
|
| 55 |
+
"""
|
| 56 |
if not text:
|
| 57 |
return ""
|
| 58 |
text = text.upper().strip()
|
|
|
|
| 67 |
return tmp.name
|
| 68 |
|
| 69 |
|
| 70 |
+
def pad_box(x1, y1, x2, y2, w, h, pad_ratio=0.08):
|
| 71 |
bw = x2 - x1
|
| 72 |
bh = y2 - y1
|
| 73 |
pad_x = int(bw * pad_ratio)
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
def levenshtein_distance(a: str, b: str) -> int:
|
| 103 |
+
"""
|
| 104 |
+
Distancia de Levenshtein cl谩sica para CER y exactitud parcial.
|
| 105 |
+
"""
|
| 106 |
a = a or ""
|
| 107 |
b = b or ""
|
| 108 |
if a == b:
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
def image_hash(pil_img: Image.Image) -> str:
|
| 128 |
+
"""
|
| 129 |
+
Hash simple para detectar duplicados de im谩genes.
|
| 130 |
+
"""
|
| 131 |
arr = np.array(pil_img.convert("RGB"))
|
| 132 |
return str(hash(arr.tobytes()))
|
| 133 |
|
|
|
|
| 137 |
return float(np.mean(values)) if values else 0.0
|
| 138 |
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
def try_save_histogram(values, title, out_path, bins=20):
|
| 141 |
try:
|
| 142 |
import matplotlib.pyplot as plt
|
|
|
|
| 174 |
|
| 175 |
|
| 176 |
# ============================================================
|
| 177 |
+
# PREPROCESADO PARA OCR
|
| 178 |
# ============================================================
|
| 179 |
+
def resize_keep_aspect(pil_img: Image.Image, target_height: int = 448) -> Image.Image:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
"""
|
| 181 |
+
Agranda la ROI manteniendo relaci贸n de aspecto.
|
| 182 |
"""
|
| 183 |
+
img = pil_img.convert("RGB")
|
| 184 |
+
w, h = img.size
|
| 185 |
+
if w == 0 or h == 0:
|
| 186 |
+
return img
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
+
scale = target_height / float(h)
|
| 189 |
+
new_w = max(1, int(round(w * scale)))
|
| 190 |
+
new_h = target_height
|
| 191 |
+
return img.resize((new_w, new_h), RESAMPLE_LANCZOS)
|
| 192 |
|
|
|
|
|
|
|
| 193 |
|
| 194 |
+
def deskew_roi(pil_img: Image.Image) -> Image.Image:
|
| 195 |
+
"""
|
| 196 |
+
Corrige inclinaci贸n leve de la ROI usando minAreaRect.
|
| 197 |
+
"""
|
| 198 |
+
img_rgb = np.array(pil_img.convert("RGB"))
|
| 199 |
+
gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
|
| 200 |
|
| 201 |
+
gray_blur = cv2.GaussianBlur(gray, (3, 3), 0)
|
|
|
|
| 202 |
thresh = cv2.adaptiveThreshold(
|
| 203 |
+
gray_blur,
|
| 204 |
255,
|
| 205 |
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 206 |
cv2.THRESH_BINARY_INV,
|
| 207 |
31,
|
| 208 |
11
|
| 209 |
)
|
| 210 |
+
|
| 211 |
coords = cv2.findNonZero(thresh)
|
| 212 |
if coords is None:
|
| 213 |
+
return pil_img
|
| 214 |
|
| 215 |
angle = cv2.minAreaRect(coords)[-1]
|
| 216 |
if angle < -45:
|
| 217 |
angle = 90 + angle
|
| 218 |
|
| 219 |
if abs(angle) < 1.0:
|
| 220 |
+
return pil_img
|
| 221 |
|
| 222 |
+
h, w = img_rgb.shape[:2]
|
| 223 |
center = (w // 2, h // 2)
|
| 224 |
M = cv2.getRotationMatrix2D(center, angle, 1.0)
|
| 225 |
+
|
| 226 |
rotated = cv2.warpAffine(
|
| 227 |
+
img_rgb,
|
| 228 |
M,
|
| 229 |
(w, h),
|
| 230 |
flags=cv2.INTER_CUBIC,
|
| 231 |
borderMode=cv2.BORDER_REPLICATE
|
| 232 |
)
|
| 233 |
+
return Image.fromarray(rotated)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
|
| 236 |
def preprocess_for_ocr(pil_img: Image.Image, target_height: int = 448) -> Image.Image:
|
| 237 |
"""
|
| 238 |
+
Preprocesado principal para OCR:
|
| 239 |
+
- correcci贸n leve de inclinaci贸n
|
| 240 |
+
- redimensionamiento manteniendo aspecto
|
| 241 |
+
- reducci贸n de ruido
|
| 242 |
+
- mejora de contraste
|
| 243 |
- nitidez
|
| 244 |
"""
|
| 245 |
+
img = deskew_roi(pil_img)
|
| 246 |
img = resize_keep_aspect(img, target_height=target_height)
|
| 247 |
|
| 248 |
arr = np.array(img.convert("RGB"))
|
|
|
|
| 250 |
|
| 251 |
gray = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
| 252 |
|
| 253 |
+
clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8))
|
| 254 |
gray = clahe.apply(gray)
|
| 255 |
|
| 256 |
gray = cv2.GaussianBlur(gray, (3, 3), 0)
|
|
|
|
| 267 |
|
| 268 |
|
| 269 |
def enhance_for_ocr(pil_img: Image.Image) -> Image.Image:
|
| 270 |
+
"""
|
| 271 |
+
Se mantiene el nombre original para no alterar el resto del c贸digo.
|
| 272 |
+
"""
|
| 273 |
return preprocess_for_ocr(pil_img)
|
| 274 |
|
| 275 |
|
| 276 |
def build_ocr_variants(pil_img: Image.Image):
|
| 277 |
"""
|
| 278 |
+
Genera pocas variantes, pero m谩s 煤tiles y m谩s r谩pidas.
|
| 279 |
"""
|
| 280 |
base = preprocess_for_ocr(pil_img)
|
| 281 |
arr = np.array(base.convert("RGB"))
|
|
|
|
| 283 |
|
| 284 |
variants = [base]
|
| 285 |
|
|
|
|
| 286 |
variants.append(ImageOps.autocontrast(base))
|
| 287 |
|
| 288 |
+
th = cv2.adaptiveThreshold(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
gray,
|
| 290 |
255,
|
| 291 |
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
|
|
| 293 |
31,
|
| 294 |
11
|
| 295 |
)
|
| 296 |
+
variants.append(Image.fromarray(th).convert("RGB"))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
return variants
|
| 299 |
|
| 300 |
|
| 301 |
# ============================================================
|
| 302 |
+
# CONSENSO OCR Y REGLAS DE FORMATO
|
| 303 |
# ============================================================
|
| 304 |
+
PLATE_PATTERN_STRICT = re.compile(r"^[A-Z]{3}\d{3}$")
|
| 305 |
+
PLATE_PATTERN_LOOSE = re.compile(r"^[A-Z0-9]{6}$")
|
| 306 |
+
|
| 307 |
LETTER_CANDIDATES = {
|
| 308 |
"0": ("O",),
|
| 309 |
"1": ("I", "L"),
|
| 310 |
"2": ("Z",),
|
| 311 |
+
"3": ("B",),
|
| 312 |
"4": ("A",),
|
| 313 |
"5": ("S",),
|
| 314 |
+
"6": ("Q", "G"),
|
| 315 |
"7": ("T",),
|
| 316 |
"8": ("B",),
|
| 317 |
+
"9": ("P", "G"),
|
| 318 |
"A": ("A",),
|
| 319 |
"B": ("B",),
|
| 320 |
"C": ("C",),
|
|
|
|
| 372 |
"9": ("9",),
|
| 373 |
}
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
| 376 |
+
def clamp01(x: float) -> float:
|
| 377 |
+
return float(max(0.0, min(1.0, x)))
|
|
|
|
|
|
|
|
|
|
| 378 |
|
| 379 |
|
| 380 |
def normalize_plate_candidate(text: str) -> str:
|
| 381 |
return clean_plate_text(text)
|
| 382 |
|
| 383 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
def plate_format_score(text: str) -> float:
|
| 385 |
+
"""
|
| 386 |
+
Puntaje simple por formato de placa.
|
| 387 |
+
- 1.0 si cumple exactamente LLLDDD
|
| 388 |
+
- valor intermedio si tiene 6 caracteres alfanum茅ricos
|
| 389 |
+
- 0.0 en otros casos
|
| 390 |
+
"""
|
| 391 |
if not text:
|
| 392 |
return 0.0
|
| 393 |
|
| 394 |
+
if PLATE_PATTERN_STRICT.fullmatch(text):
|
| 395 |
+
return 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
|
| 397 |
+
if len(text) == 6 and PLATE_PATTERN_LOOSE.fullmatch(text):
|
| 398 |
+
score = 0.0
|
| 399 |
+
for i, ch in enumerate(text):
|
| 400 |
+
if i < 3 and ch.isalpha():
|
| 401 |
+
score += 1.0 / 6.0
|
| 402 |
+
elif i >= 3 and ch.isdigit():
|
| 403 |
+
score += 1.0 / 6.0
|
| 404 |
+
return score
|
| 405 |
|
| 406 |
+
return 0.0
|
| 407 |
|
| 408 |
|
| 409 |
def expand_plate_candidate(text: str):
|
| 410 |
+
"""
|
| 411 |
+
Expande un OCR candidato a variantes plausibles.
|
| 412 |
+
"""
|
| 413 |
text = clean_plate_text(text)
|
| 414 |
if not text:
|
| 415 |
return []
|
|
|
|
| 419 |
|
| 420 |
options = []
|
| 421 |
for i, ch in enumerate(text):
|
|
|
|
| 422 |
if i < 3:
|
| 423 |
opts = LETTER_CANDIDATES.get(ch, (ch,))
|
|
|
|
|
|
|
| 424 |
else:
|
| 425 |
+
opts = DIGIT_CANDIDATES.get(ch, (ch,))
|
|
|
|
| 426 |
options.append(opts)
|
| 427 |
|
| 428 |
variants = {"".join(chars) for chars in product(*options)}
|
| 429 |
+
return sorted(list(variants))[:64]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
|
| 432 |
def choose_best_plate(candidates):
|
| 433 |
"""
|
| 434 |
+
Combina hip贸tesis OCR y devuelve:
|
| 435 |
+
- mejor placa
|
| 436 |
+
- confianza relativa normalizada
|
| 437 |
+
- diagn贸stico
|
| 438 |
"""
|
| 439 |
if not candidates:
|
| 440 |
return "", 0.0, {"n_candidates": 0}
|
| 441 |
|
| 442 |
+
filtered = []
|
| 443 |
+
for raw_text, raw_weight in candidates:
|
| 444 |
+
text = normalize_plate_candidate(raw_text)
|
| 445 |
+
if len(text) == 6 and PLATE_PATTERN_LOOSE.fullmatch(text):
|
| 446 |
+
filtered.append((text, float(raw_weight)))
|
| 447 |
|
| 448 |
+
if not filtered:
|
| 449 |
+
fallback = normalize_plate_candidate(candidates[0][0])
|
| 450 |
+
return fallback, 0.0, {"n_candidates": 0}
|
|
|
|
| 451 |
|
| 452 |
+
total_weight = sum(w for _, w in filtered) + 1e-8
|
|
|
|
|
|
|
| 453 |
|
| 454 |
+
pos_votes = [defaultdict(float) for _ in range(6)]
|
| 455 |
+
text_votes = defaultdict(float)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
|
| 457 |
+
for text, weight in filtered:
|
| 458 |
+
format_bonus = 1.0 if PLATE_PATTERN_STRICT.fullmatch(text) else 0.65
|
| 459 |
+
w = weight * format_bonus
|
| 460 |
+
text_votes[text] += w
|
| 461 |
+
for i, ch in enumerate(text):
|
| 462 |
+
pos_votes[i][ch] += w
|
| 463 |
|
| 464 |
scored = []
|
| 465 |
+
for text, weight_sum in text_votes.items():
|
| 466 |
+
pos_score = 0.0
|
| 467 |
+
for i in range(6):
|
| 468 |
+
dist = pos_votes[i]
|
| 469 |
+
if not dist:
|
| 470 |
+
continue
|
| 471 |
+
best_ch, best_w = max(dist.items(), key=lambda kv: kv[1])
|
| 472 |
+
pos_total = sum(dist.values()) + 1e-8
|
| 473 |
+
pos_score += best_w / pos_total
|
| 474 |
+
|
| 475 |
+
consensus = weight_sum / total_weight
|
| 476 |
+
format_score = plate_format_score(text)
|
| 477 |
+
score = (
|
| 478 |
+
0.50 * consensus +
|
| 479 |
+
0.25 * (pos_score / 6.0) +
|
| 480 |
+
0.25 * format_score
|
| 481 |
+
)
|
| 482 |
+
scored.append((text, score, consensus, format_score))
|
| 483 |
|
| 484 |
scored.sort(key=lambda x: x[1], reverse=True)
|
| 485 |
|
| 486 |
+
best_text, best_score, best_consensus, best_format = scored[0]
|
| 487 |
+
best_conf = clamp01(best_score)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
|
| 489 |
diagnostics = {
|
| 490 |
+
"n_candidates": len(filtered),
|
| 491 |
+
"best_consensus": round(float(best_consensus), 4),
|
| 492 |
+
"best_format_score": round(float(best_format), 4),
|
| 493 |
+
"top_score": round(float(best_score), 4),
|
| 494 |
+
"top_text": best_text,
|
| 495 |
}
|
| 496 |
|
| 497 |
+
return best_text, best_conf, diagnostics
|
| 498 |
|
| 499 |
|
| 500 |
# ============================================================
|
|
|
|
| 524 |
try:
|
| 525 |
image_processor = AutoImageProcessor.from_pretrained(OCR_REPO_ID, token=HF_TOKEN)
|
| 526 |
except Exception:
|
| 527 |
+
image_processor = AutoImageProcessor.from_pretrained("microsoft/trocr-base-printed", token=HF_TOKEN)
|
|
|
|
|
|
|
|
|
|
| 528 |
|
| 529 |
try:
|
| 530 |
tokenizer = AutoTokenizer.from_pretrained(OCR_REPO_ID, token=HF_TOKEN)
|
| 531 |
except Exception:
|
| 532 |
+
tokenizer = AutoTokenizer.from_pretrained("microsoft/trocr-base-printed", token=HF_TOKEN)
|
|
|
|
|
|
|
|
|
|
| 533 |
|
| 534 |
try:
|
| 535 |
model = VisionEncoderDecoderModel.from_pretrained(OCR_REPO_ID, token=HF_TOKEN)
|
| 536 |
except Exception:
|
| 537 |
+
model = VisionEncoderDecoderModel.from_pretrained("microsoft/trocr-base-printed", token=HF_TOKEN)
|
|
|
|
|
|
|
|
|
|
| 538 |
|
| 539 |
model.to(DEVICE)
|
| 540 |
model.eval()
|
|
|
|
| 546 |
# OCR ROBUSTO
|
| 547 |
# ============================================================
|
| 548 |
def ocr_variant_candidates(variant: Image.Image, image_processor, tokenizer, ocr_model):
|
| 549 |
+
"""
|
| 550 |
+
Genera varias hip贸tesis OCR sobre una misma variante.
|
| 551 |
+
"""
|
| 552 |
pixel_values = image_processor(images=variant, return_tensors="pt").pixel_values.to(DEVICE)
|
| 553 |
|
| 554 |
with torch.inference_mode():
|
| 555 |
generated = ocr_model.generate(
|
| 556 |
pixel_values,
|
| 557 |
+
max_new_tokens=10,
|
| 558 |
+
num_beams=3,
|
| 559 |
+
num_return_sequences=3,
|
| 560 |
do_sample=False,
|
| 561 |
early_stopping=True,
|
| 562 |
+
length_penalty=0.0,
|
| 563 |
+
repetition_penalty=1.05,
|
| 564 |
no_repeat_ngram_size=0,
|
| 565 |
return_dict_in_generate=True,
|
| 566 |
output_scores=True,
|
|
|
|
| 582 |
|
| 583 |
|
| 584 |
def ocr_plate_from_roi(roi: Image.Image, image_processor, tokenizer, ocr_model):
|
| 585 |
+
"""
|
| 586 |
+
OCR robusto sobre la ROI mejorada.
|
| 587 |
+
"""
|
| 588 |
variants = build_ocr_variants(roi)
|
| 589 |
all_candidates = []
|
| 590 |
|
|
|
|
| 604 |
|
| 605 |
|
| 606 |
def calibrate_total_confidence(det_conf: float, ocr_conf: float) -> float:
|
| 607 |
+
"""
|
| 608 |
+
Combina detector + OCR de forma m谩s estable que el producto directo.
|
| 609 |
+
"""
|
| 610 |
det_conf = clamp01(det_conf)
|
| 611 |
ocr_conf = clamp01(ocr_conf)
|
| 612 |
|
|
|
|
| 613 |
total = 0.35 * det_conf + 0.65 * ocr_conf
|
| 614 |
return round(clamp01(total), 4)
|
| 615 |
|
| 616 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
# ============================================================
|
| 618 |
# AN脕LISIS DE DATOS
|
| 619 |
# ============================================================
|
| 620 |
def parse_yolo_label_file(label_path: Path, img_w: int, img_h: int):
|
| 621 |
+
"""
|
| 622 |
+
Lee etiquetas YOLO y las convierte a coordenadas absolutas.
|
| 623 |
+
Formato esperado por l铆nea:
|
| 624 |
+
class x_center y_center width height
|
| 625 |
+
"""
|
| 626 |
boxes = []
|
| 627 |
if not label_path.exists():
|
| 628 |
return boxes
|
|
|
|
| 656 |
|
| 657 |
|
| 658 |
def analyze_detection_dataset(images_dir, labels_dir, output_dir="analysis_out"):
|
| 659 |
+
"""
|
| 660 |
+
Analiza dataset de detecci贸n en formato YOLO.
|
| 661 |
+
"""
|
| 662 |
images_dir = Path(images_dir)
|
| 663 |
labels_dir = Path(labels_dir)
|
| 664 |
output_dir = Path(output_dir)
|
|
|
|
| 762 |
|
| 763 |
|
| 764 |
def analyze_ocr_dataset(csv_path, image_col="image_path", text_col="text", output_dir="analysis_out"):
|
| 765 |
+
"""
|
| 766 |
+
Analiza dataset OCR en CSV.
|
| 767 |
+
Se espera m铆nimo:
|
| 768 |
+
- image_path
|
| 769 |
+
- text
|
| 770 |
+
"""
|
| 771 |
output_dir = Path(output_dir)
|
| 772 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 773 |
|
|
|
|
| 845 |
return report
|
| 846 |
|
| 847 |
|
| 848 |
+
def build_experimental_justification():
|
| 849 |
+
"""
|
| 850 |
+
Justificaci贸n experimental para el informe.
|
| 851 |
+
"""
|
| 852 |
+
return {
|
| 853 |
+
"detector": "YOLO porque es r谩pido, pr谩ctico y adecuado para localizar placas en im谩genes completas.",
|
| 854 |
+
"ocr": "TrOCR porque trabaja bien sobre recortes de texto y admite inferencia robusta con beam search.",
|
| 855 |
+
"preprocessing": [
|
| 856 |
+
"Redimensionamiento manteniendo aspect ratio para estabilizar la entrada del OCR.",
|
| 857 |
+
"Reducci贸n de ruido para evitar perder bordes de caracteres.",
|
| 858 |
+
"CLAHE para mejorar contraste local.",
|
| 859 |
+
"Correcci贸n leve de inclinaci贸n para ayudar al OCR.",
|
| 860 |
+
"Variantes adicionales con binarizaci贸n adaptativa y autocontraste para consenso OCR."
|
| 861 |
+
],
|
| 862 |
+
"decision_rules": [
|
| 863 |
+
"Se usa un umbral de detecci贸n moderado para equilibrar precisi贸n y recall.",
|
| 864 |
+
"Se aplica padding al bounding box para no recortar caracteres perif茅ricos.",
|
| 865 |
+
"Se calcula una confianza aproximada combinando la confianza del detector y la estabilidad OCR.",
|
| 866 |
+
"Se prioriza el patr贸n LLLDDD cuando el candidato tiene 6 caracteres."
|
| 867 |
+
],
|
| 868 |
+
"limitations": [
|
| 869 |
+
"La confianza total es heur铆stica y no sustituye una calibraci贸n formal.",
|
| 870 |
+
"El patr贸n LLLDDD puede no ser v谩lido para todas las regiones.",
|
| 871 |
+
"La evaluaci贸n real requiere ground truth para detecci贸n y OCR."
|
| 872 |
+
]
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
|
| 876 |
# ============================================================
|
| 877 |
# ENTRENAMIENTO DEL DETECTOR
|
| 878 |
# ============================================================
|
|
|
|
| 888 |
project: str = "runs/train",
|
| 889 |
name: str = "plates_detector"
|
| 890 |
):
|
| 891 |
+
"""
|
| 892 |
+
Entrenamiento del detector YOLO sobre un dataset propio.
|
| 893 |
+
Requiere data.yaml en formato Ultralytics.
|
| 894 |
+
"""
|
| 895 |
model = YOLO(base_model)
|
| 896 |
results = model.train(
|
| 897 |
data=data_yaml,
|
|
|
|
| 920 |
|
| 921 |
|
| 922 |
def compute_iou(box_a, box_b):
|
| 923 |
+
"""
|
| 924 |
+
box = (x1, y1, x2, y2)
|
| 925 |
+
"""
|
| 926 |
ax1, ay1, ax2, ay2 = box_a
|
| 927 |
bx1, by1, bx2, by2 = box_b
|
| 928 |
|
|
|
|
| 952 |
iou_threshold=0.5,
|
| 953 |
max_images=None
|
| 954 |
):
|
| 955 |
+
"""
|
| 956 |
+
Eval煤a detecci贸n con m茅tricas tipo:
|
| 957 |
+
- precision
|
| 958 |
+
- recall
|
| 959 |
+
- F1
|
| 960 |
+
- IoU promedio
|
| 961 |
+
- tiempo medio de inferencia
|
| 962 |
+
"""
|
| 963 |
images_dir = Path(images_dir)
|
| 964 |
labels_dir = Path(labels_dir)
|
| 965 |
|
|
|
|
| 1063 |
|
| 1064 |
|
| 1065 |
def ultralytics_val_metrics(model, data_yaml):
|
| 1066 |
+
"""
|
| 1067 |
+
Intenta extraer mAP50 y mAP50-95 usando la validaci贸n de Ultralytics.
|
| 1068 |
+
"""
|
| 1069 |
try:
|
| 1070 |
val_res = model.val(data=data_yaml, verbose=False)
|
| 1071 |
box = getattr(val_res, "box", None)
|
|
|
|
| 1092 |
ocr_model=None,
|
| 1093 |
max_rows=None
|
| 1094 |
):
|
| 1095 |
+
"""
|
| 1096 |
+
Eval煤a OCR con m茅tricas:
|
| 1097 |
+
- exact match rate
|
| 1098 |
+
- CER
|
| 1099 |
+
- accuracy por caracteres
|
| 1100 |
+
"""
|
| 1101 |
if image_processor is None or tokenizer is None or ocr_model is None:
|
| 1102 |
image_processor, tokenizer, ocr_model = load_ocr()
|
| 1103 |
|
|
|
|
| 1169 |
iou_threshold=0.5,
|
| 1170 |
max_rows=None
|
| 1171 |
):
|
| 1172 |
+
"""
|
| 1173 |
+
Evaluaci贸n integral:
|
| 1174 |
+
- detecci贸n: IoU, precision, recall
|
| 1175 |
+
- OCR: exact match, CER
|
| 1176 |
+
"""
|
| 1177 |
if detector is None:
|
| 1178 |
detector = load_detector()
|
| 1179 |
if image_processor is None or tokenizer is None or ocr_model is None:
|
|
|
|
| 1243 |
det_fp += 1
|
| 1244 |
det_fn += 1
|
| 1245 |
|
| 1246 |
+
x1, y1, x2, y2 = pad_box(*pred_box, w, h, pad_ratio=0.10)
|
| 1247 |
roi = img.crop((x1, y1, x2, y2))
|
| 1248 |
roi = enhance_for_ocr(roi)
|
| 1249 |
|
|
|
|
| 1320 |
int(y2),
|
| 1321 |
w,
|
| 1322 |
h,
|
| 1323 |
+
pad_ratio=0.10
|
| 1324 |
)
|
| 1325 |
|
| 1326 |
roi = image.crop((x1, y1, x2, y2))
|
| 1327 |
roi_display = enhance_for_ocr(roi)
|
| 1328 |
|
|
|
|
| 1329 |
plate_text, ocr_conf, ocr_diag = ocr_plate_from_roi(
|
| 1330 |
roi_display,
|
| 1331 |
image_processor,
|