VictorStudios commited on
Commit
b031866
verified
1 Parent(s): 5cd212f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +221 -284
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, ImageEnhance, ImageFilter
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.10):
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
- # GEOMETR脥A / RECTIFICACI脫N DE ROI
170
  # ============================================================
171
- def order_points(pts):
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
- Intenta corregir perspectiva e inclinaci贸n dentro de la ROI.
221
  """
222
- img = np.array(pil_img.convert("RGB"))
223
- if img.size == 0:
224
- return pil_img
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
- for c in contours[:8]:
241
- area = cv2.contourArea(c)
242
- if area < min_area:
243
- continue
244
 
245
- peri = cv2.arcLength(c, True)
246
- approx = cv2.approxPolyDP(c, 0.02 * peri, True)
247
 
248
- if len(approx) == 4:
249
- pts = approx.reshape(4, 2).astype("float32")
250
- warped = four_point_transform(img, pts)
251
- out = Image.fromarray(warped)
252
- return rotate_if_needed(out)
 
253
 
254
- # Fallback: correcci贸n de inclinaci贸n con minAreaRect
255
- gray2 = cv2.GaussianBlur(gray, (3, 3), 0)
256
  thresh = cv2.adaptiveThreshold(
257
- gray2,
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 rotate_if_needed(pil_img)
267
 
268
  angle = cv2.minAreaRect(coords)[-1]
269
  if angle < -45:
270
  angle = 90 + angle
271
 
272
  if abs(angle) < 1.0:
273
- return rotate_if_needed(pil_img)
274
 
275
- h, w = img.shape[:2]
276
  center = (w // 2, h // 2)
277
  M = cv2.getRotationMatrix2D(center, angle, 1.0)
 
278
  rotated = cv2.warpAffine(
279
- img,
280
  M,
281
  (w, h),
282
  flags=cv2.INTER_CUBIC,
283
  borderMode=cv2.BORDER_REPLICATE
284
  )
285
- return rotate_if_needed(Image.fromarray(rotated))
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
- OCR m谩s robusto:
306
- - rectificaci贸n de perspectiva/inclinaci贸n
307
- - redimensionamiento
308
- - denoise
309
- - contraste local
310
  - nitidez
311
  """
312
- img = rectify_plate_roi(pil_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.2, tileGridSize=(8, 8))
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
- Variantes orientadas a placas con fondo amarillo / blanco y texto oscuro.
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
- # Sharpen
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(adaptive).convert("RGB"))
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
- # FORMATO DE PLACA Y CONSENSO OCR
381
  # ============================================================
 
 
 
382
  LETTER_CANDIDATES = {
383
  "0": ("O",),
384
  "1": ("I", "L"),
385
  "2": ("Z",),
386
- "3": ("B", "E"),
387
  "4": ("A",),
388
  "5": ("S",),
389
- "6": ("G", "Q"),
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
- STRICT_PATTERNS = [
458
- re.compile(r"^[A-Z]{3}\d{2}[A-Z]$"), # MAN54G
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 len(text) != 6:
493
- return 0.0
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 any(p.fullmatch(text) for p in STRICT_PATTERNS):
500
- best = max(best, 1.0)
 
 
 
 
 
 
501
 
502
- return best
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
- # 煤ltimo car谩cter: permitir letra o d铆gito seg煤n contexto
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))[:128]
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
- Selecciona la mejor hip贸tesis por consenso entre variantes y beams.
539
- Devuelve: texto, confianza OCR calibrada, diagn贸stico.
 
 
540
  """
541
  if not candidates:
542
  return "", 0.0, {"n_candidates": 0}
543
 
544
- aggregated = defaultdict(lambda: {"score_sum": 0.0, "votes": 0})
 
 
 
 
545
 
546
- for raw_text, raw_score in candidates:
547
- cleaned = normalize_plate_candidate(raw_text)
548
- if not cleaned:
549
- continue
550
 
551
- expanded = expand_plate_candidate(cleaned)
552
- if not expanded:
553
- expanded = [cleaned]
554
 
555
- for cand in expanded:
556
- # Penaliza ediciones frente al texto base y premia el formato plausible
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
- if not aggregated:
564
- fallback = normalize_plate_candidate(candidates[0][0])
565
- return fallback, 0.0, {"n_candidates": 0}
 
 
 
566
 
567
  scored = []
568
- for cand, data in aggregated.items():
569
- votes = data["votes"]
570
- mean_score = data["score_sum"] / max(votes, 1)
571
- consensus = np.log1p(votes)
572
- fmt = plate_format_score(cand)
573
- final_score = mean_score + 0.45 * consensus + 0.80 * fmt
574
- scored.append((cand, final_score, votes, fmt))
 
 
 
 
 
 
 
 
 
 
 
575
 
576
  scored.sort(key=lambda x: x[1], reverse=True)
577
 
578
- scores = np.array([s for _, s, _, _ in scored], dtype=np.float32)
579
- probs = np.exp(scores - scores.max())
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(aggregated),
595
- "top_score": round(float(scored[0][1]), 4),
596
- "top_format_score": round(best_fmt, 4),
597
- "top_soft_prob": round(best_soft, 4),
598
- "top_votes": int(scored[0][2]),
599
  }
600
 
601
- return best_text, ocr_conf, diagnostics
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=8,
668
- num_beams=8,
669
- num_return_sequences=8,
670
  do_sample=False,
671
  early_stopping=True,
672
- length_penalty=-0.2,
673
- repetition_penalty=1.10,
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.12)
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.12
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,