"""Prétraitement image : détection de l'écran LCD puis découpage en lignes de valeurs (prix / volume / prix du litre), avant reconnaissance CRNN. Cette étape reste indépendante du modèle de reconnaissance : elle correspond au travail de prétraitement/détection d'écran attendu par le cahier des charges (§6.1, §8.2/8.3), et reste identique quel que soit le moteur de lecture des chiffres utilisé en aval. """ import cv2 import numpy as np from typing import List, Tuple FIELD_NAMES = ["prix", "volume", "prix_litre"] def detect_screen_region(img: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int, int, int]]: """Tente de localiser la zone de l'écran LCD dans l'image. Retourne (crop, (x, y, w, h)) ou (img, (0,0,W,H)) si rien de convaincant n'a été trouvé (l'appelant doit alors traiter l'image entière). """ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img.copy() blurred = cv2.GaussianBlur(gray, (5, 5), 0) edges = cv2.Canny(blurred, 30, 100) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) h_img, w_img = img.shape[:2] best_rect, best_area = None, 0 for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) area = w * h if area > 0.02 * h_img * w_img and 1.0 < (w / max(h, 1)) < 8.0: if area > best_area: best_area = area best_rect = (x, y, w, h) if best_rect: x, y, w, h = best_rect pad = 10 x1 = max(0, x - pad); y1 = max(0, y - pad) x2 = min(w_img, x + w + pad); y2 = min(h_img, y + h + pad) return img[y1:y2, x1:x2], (x1, y1, x2 - x1, y2 - y1) return img, (0, 0, w_img, h_img) def split_lcd_lines(lcd_crop: np.ndarray, n_lines: int = 3) -> Tuple[List[np.ndarray], List[Tuple[int, int]]]: """Découpe le crop d'écran en `n_lines` bandes horizontales (une par valeur affichée : prix, volume, prix du litre), par projection horizontale des pixels sombres, avec repli sur un découpage proportionnel si la projection ne trouve pas exactement le bon nombre de bandes. NB : `n_lines=3` par défaut (et non 2) car la majorité des écrans du jeu d'annotation affichent les trois valeurs (prix, volume, prix/litre) — un défaut à 2 aurait systématiquement ignoré la troisième ligne. """ gray = cv2.cvtColor(lcd_crop, cv2.COLOR_BGR2GRAY) h, w = gray.shape clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) enh = clahe.apply(gray) _, bin_img = cv2.threshold(enh, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) if np.mean(bin_img) > 128: bin_img = cv2.bitwise_not(bin_img) row_sums = np.sum(bin_img == 255, axis=1).astype(np.float32) smoothed = cv2.GaussianBlur( row_sums.reshape(-1, 1), (1, max(3, h // 30) * 2 + 1), 0 ).flatten() threshold = max(smoothed.max() * 0.08, 2) active = smoothed > threshold bands: List[Tuple[int, int]] = [] in_band = False start = 0 for y in range(h): if active[y] and not in_band: start, in_band = y, True elif not active[y] and in_band: bands.append((start, y)) in_band = False if in_band: bands.append((start, h)) min_h = h * 0.06 bands = [(s, e) for s, e in bands if e - s >= min_h] merged: List[Tuple[int, int]] = [] for band in bands: if merged and band[0] - merged[-1][1] < h * 0.05: merged[-1] = (merged[-1][0], band[1]) else: merged.append(band) bands = [tuple(band) for band in merged] if len(bands) != n_lines: band_h = h / n_lines bands = [(int(i * band_h), int((i + 1) * band_h)) for i in range(n_lines)] crops: List[np.ndarray] = [] normalized_bands: List[Tuple[int, int]] = [] for start_y, end_y in bands: pad = max(2, int(h * 0.02)) y0 = max(0, start_y - pad) y1 = min(h, end_y + pad) crops.append(lcd_crop[y0:y1, :]) normalized_bands.append((y0, y1 - y0)) return crops, normalized_bands