32 KiB
AXE A4 — OCR, Template matching, pHash : revue 2026 + correctif _resolve_by_ocr_text
Date : 2026-05-23
Auteur : Claude (dispatch recherche)
Périmètre : revue littérature/écosystème 2025-2026 pour la cascade UI OCR → template → VLM + alternatives à pHash pour LoopDetector et VERIFY. Patch ciblé du bug center-of-line de _resolve_by_ocr_text (agent_v0/server_v1/resolve_engine.py:1447-1527).
Lecture pré-requise : docs/SYNTHESE_TECHNOS_REPLAY_2026-05-23.md §2, §4 ; docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md §1.2 et §5 ; docs/BUG_PRECHECK_SPATIAL_BLINDNESS_2026-05-08.md (DETTE-001).
Statut : recherche + propositions. Aucune modification de code. Toute application validée par Dom.
0. TL;DR
- Le bug primaire
center-of-lineest résolvable sans changer d'OCR. docTR expose lesgeometryau niveau duWord, normalisées dans le même repère que la ligne. Le quick fix §5 du diagnostic 8 mai (cf. §5 ci-dessous, code copy-paste-ready) supprime la collision Imagerie/Notes/Synthèse en restant 100 % iso-stack. - OCR : garder docTR comme moteur OCR-DIRECT (mode strict + cascade) car c'est le seul, avec Tesseract et PaddleOCR
return_word_box=True, à exposer des bbox token-level dans le même repère que la ligne. EasyOCR retourne par défaut des bbox merges niveau line/segment et n'est pas adapté à la résolution multi-tokens d'un onglet sur barre. Surya OCR = line-level uniquement, à écarter pour ce besoin. RapidOCR (PaddleOCR ONNX repackagé) → candidat 2026 pour OCR-DIRECT léger sans dépendance Paddle, à valider sur français accentué. - Template matching : remplacer
cv2.matchTemplatemulti-scale par SuperPoint+LightGlue (ONNX, ~50 ms par paire sur RTX 5070). C'est la sortie propre pour la drift exemption≥ 0.95actuelle, qui est un faux positif déguisé (score haut sur région différente). LightGlue est invariant à l'offset/scale/rotation et fournit un score de cohérence géométrique — donc plus de faux positifs « 0.95 sur mauvaise zone ». À encapsuler derrière_resolve_by_templatesans casser la cascade. - pHash : sortir du global. Deux modes complémentaires :
- LoopDetector (QW2) → DINOv2 features sur l'écran entier, cos-sim < 0.99 = écran a bougé. Plus robuste qu'un pHash 64-bit à un curseur clignotant ou à un caret blinking.
- VERIFY post-action → SSIM par ROI (skimage
structural_similarity, ~5-10 ms sur crop 400×200), avec ROI = bbox de la cible cliquée + halo 50 px. C'est la version spatialisée qui résout aussi DETTE-001 (BUG_PRECHECK_SPATIAL_BLINDNESS).
- Dépendances : ce travail est bloquant pour AXE_A5 (tokenisation UI : OmniParser et UI-DETR-1 utilisent in fine un OCR + détection icônes — décider du moteur OCR avant tokenisation). Il alimente AXE_B2 (Validator) qui consommera SSIM-ROI comme signal sémantique de VERIFY.
1. Sous-axe 1 — OCR pour grounding
1.1. Question centrale : bbox token-level dans le même repère que la ligne
Le bug center-of-line apparaît parce que _resolve_by_ocr_text (resolve_engine.py:1486-1519) calcule cx, cy à partir de la line_obj.geometry (bbox de la ligne entière) alors que target_text n'est qu'un sous-fragment. Pour le résoudre sans changer d'OCR, il suffit que l'OCR expose, dans le même repère normalisé que la ligne, les bbox des words qui composent la ligne. C'est le critère discriminant.
1.2. Table comparative (mai 2026)
| OCR | Granularité bbox | Repère | Français/accents | Latence (CPU 2560×1600) | Stack | Licence | Date release majeure |
|---|---|---|---|---|---|---|---|
docTR (python-doctr) |
word + line + block | normalisé [(xmin,ymin),(xmax,ymax)] ∈ [0,1]², commun line/word |
bon (modèle crnn_vgg16_bn français) |
~800 ms CPU, ~150 ms GPU | PyTorch + TF, ONNX optionnel | Apache 2.0 | v0.10 (2026-04, python-doctr PyPI) |
| EasyOCR | line merged (par défaut) + char optionnel via ycenter_ths/width_ths |
pixel absolu | bon | ~1.2 s CPU, ~200 ms GPU | PyTorch, CRNN | Apache 2.0 | v1.7.x (2024) |
RapidOCR (rapidocr) |
line | pixel absolu | bon (modèle PP-OCRv4 fr) | ~200 ms ONNX-CPU, ~80 ms GPU | ONNXRuntime / OpenVINO / MNN / PaddlePaddle, sans dépendance Paddle | Apache 2.0 | v3.x (2026-04-11) |
| PaddleOCR / PP-StructureV3 | line par défaut ; return_word_box=True en option |
pixel absolu | bon | ~250 ms GPU (PP-OCRv4) | PaddlePaddle (lourd) | Apache 2.0 | v3.0 (2025-07) |
Surya OCR (surya-ocr) |
line only | pixel absolu | bon (90+ langues) | ~400 ms GPU (5070-class) | PyTorch | GPL-3.0 (commercial restrictif) | v0.17.x (2025) |
Tesseract (via pytesseract) |
word + line + char via image_to_data / hOCR |
pixel absolu | moyen-bon (modèle fra) |
100-500 ms CPU | C++ LSTM | Apache 2.0 | v5.4 (2024) |
Sources principales : docTR Word/Line geometry — Discussion #570, PaddleOCR return_word_box — Issue #15760, Surya line-level — repo datalab-to/surya, EasyOCR character bbox limitation — Issue #631, RapidOCR releases, pytesseract image_to_data — PyPI, Codesota benchmark PaddleOCR vs EasyOCR 2025, Tildalice benchmark PaddleOCR vs Doctr.
1.3. Analyse du bug center-of-line
Le bug est résolvable nativement avec docTR. L'API expose line_obj.words (List[Word]) avec chaque Word.geometry au même format ((xmin,ymin),(xmax,ymax)) normalisé que line_obj.geometry. Il n'y a aucun changement de repère à faire — c'est le même page-relative ∈ [0,1]². Cf. docTR I/O modules doc.
EasyOCR a la mauvaise granularité par défaut : il merge les détections en segments via ycenter_ths=0.5 et width_ths=0.5, donc une rangée de tabs serrée tombera comme une boîte unique, sans accès aux sous-words. Demander explicitement width_ths=0.0 casserait la fusion mais aussi pour les vrais textes longs (« Justification de la décision »). EasyOCR seul ne résout pas le bug.
Surya OCR est annoncé explicitement comme line-only : « Surya predicts line-level bboxes, while tesseract and others predict word-level or character-level » (cf. datalab-to/surya README). À écarter pour ce besoin.
PaddleOCR return_word_box=True est disponible en v3.0 mais nécessite une dépendance PaddlePaddle ~700 Mo et un init ~8-12 s sur CPU.
RapidOCR repackage les modèles PaddleOCR en ONNX (80 Mo install, init <2 s) ; il faut vérifier en mai 2026 si return_word_box est exposé dans la couche rapidocr.RapidOCR(__call__) ou seulement dans paddleocr.PaddleOCR. À ce jour, la doc publique RapidOCR ne mentionne pas explicitement le mode word-bbox.
1.4. Snippets Python — récupérer bbox word-level
docTR (déjà utilisé en production)
from doctr.models import ocr_predictor
from doctr.io import DocumentFile
predictor = ocr_predictor(pretrained=True)
doc = DocumentFile.from_images("/path/screenshot.png")
result = predictor(doc)
# Navigation hiérarchique : pages -> blocks -> lines -> words
page = result.pages[0]
H, W = page.dimensions # (height, width) pixels
for block in page.blocks:
for line in block.lines:
# line.geometry == ((xmin, ymin), (xmax, ymax)) normalisé [0,1]²
# line.words == List[Word], chaque Word.geometry au même format
for word in line.words:
(xmin_n, ymin_n), (xmax_n, ymax_n) = word.geometry
# Pixels absolus
xmin_px = xmin_n * W
ymax_px = ymax_n * H
print(f"{word.value!r} bbox=({xmin_px:.0f},{ymin_px*H:.0f})-({xmax_px:.0f},{ymax_px:.0f})")
Tesseract (alternative légère, fallback CPU)
import pytesseract
from PIL import Image
img = Image.open("/path/screenshot.png")
data = pytesseract.image_to_data(img, lang="fra", output_type=pytesseract.Output.DICT)
# data == dict with keys 'text','left','top','width','height','conf','line_num','word_num','block_num'
n = len(data['text'])
for i in range(n):
if data['text'][i].strip() and int(data['conf'][i]) > 50:
x, y, w, h = data['left'][i], data['top'][i], data['width'][i], data['height'][i]
# Pixels absolus directement
print(f"{data['text'][i]!r} bbox=({x},{y})-({x+w},{y+h}) line={data['line_num'][i]}")
RapidOCR (candidat migration, ONNX léger)
from rapidocr import RapidOCR
engine = RapidOCR()
result, elapsed = engine("/path/screenshot.png")
# result == [[box, text, score], ...] avec box = [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] pixel absolu
# ⚠ Niveau line par défaut — à valider en mai 2026 si word-level disponible
1.5. Recommandation
Garder docTR pour OCR-DIRECT (mode strict + cascade resolve_engine). C'est l'OCR qui colle déjà aux contraintes du bug. Le quick fix §5 (recalcul cx, cy depuis line.words) ne nécessite ni migration ni changement d'API.
Ne PAS migrer en chaud vers EasyOCR ou Surya : EasyOCR perd le sous-word, Surya est line-only par design.
Évaluation parallèle (post-démo, AXE_A5) :
- RapidOCR sur 10 captures Easily fr — gain potentiel : init 2 s vs 5-8 s docTR, install 80 Mo vs 500 Mo + PyTorch.
- Tesseract
image_to_datalangfra— peut servir de second moteur OCR de vérification (vote OCR à 2 moteurs) pour DETTE-001.
2. Sous-axe 2 — Template matching (étage 2 cascade)
2.1. Question centrale : robustesse à l'offset/scale + élimination des faux positifs 0.95
cv2.matchTemplate multi-scale (range 0.25→2.0, resolve_engine.py:130) calcule un score de corrélation NCC pixel-à-pixel. Limites connues :
- Aucune invariance à la rotation. Easily/Edge sont fixes en rotation, donc OK ici.
- Sensible à l'anti-aliasing : un même bouton scaled 0.95× vs 1.0× peut perdre 0.10 sur le score.
- Le score haut ne garantit pas la bonne région : le match peut être 0.95 sur un patch visuellement similaire (autre bouton de la même barre, même icône de close, etc.). C'est exactement le mécanisme qui force aujourd'hui le
drift exemption ≥ 0.95(resolve_engine.py:2367-2390) à être une rustine — score haut, mauvais endroit. - Cf. PyImageSearch multi-scale template matching et Medium — Template Matching Beyond Basics.
2.2. Table comparative
| Méthode | Invariance | Score géométrique | Latence pair (RTX 5070, 800×500 vs 2560×1600) | Faux positif 0.95 ? | Licence | Maturité 2026 |
|---|---|---|---|---|---|---|
cv2.matchTemplate NCC multi-scale (actuel) |
scale ±20 % (force brute) | non — score pixel | ~50-200 ms CPU (multi-scale loop) | oui (rustine drift exemption) | BSD | mature |
| SIFT / AKAZE / ORB (cv2) | scale + rotation + offset | non — inliers RANSAC | ~30 ms CPU | filtré par RANSAC mais sensible aux UI peu texturées | BSD | mature |
| SuperPoint + LightGlue (ONNX) | scale + rotation + offset + photométrie | oui — score MNN + inliers | ~44 ms (22 FPS) pair complète RTX-class | non si on prend len(matches) > seuil ET cohérence homographique |
Apache 2.0 (modèle), fabio-sim/LightGlue-ONNX | très mature 2024-2026 |
| LoFTR / Efficient LoFTR | id. | id. + dense | ~80 ms pair RTX-class | non | Apache 2.0 | mature, +1-2 pp AUC vs LightGlue mais 2× plus lent |
| DINOv2 patch features + kNN match | id. + sémantique | cosine sim patch | ~150 ms (extract DINOv2 ViT-L) | rare (sémantique > pixel) | CC-BY-NC-4.0 ⚠ | très mature 2024-2026 |
| RoMa / RoMa v2 | id. + dense, sub-pixel | warp + certainties | ~200 ms RTX-class (v2 = 1.7× v1) | non | non-commercial | CVPR 2024, v2 fin 2025 |
| MASt3R-SfM | id. + 3D | grid match | très lourd (~1 s+ par pair) | non | non-commercial | recherche 2024 |
| CLIP visual similarity (global embedding) | id. + sémantique | cos-sim global | ~30 ms ViT-B/32 | échoue : trop global, ne localise pas | MIT | mature |
Sources : LightGlue ICCV 2023 paper, Efficient LoFTR arXiv 2403.04765, RoMa v2 emergent mind, DINOv2 features, Image Matching Challenge 2025 — DINO-RotateMatch arXiv 2512.03715, LightGlue ONNX, LightGlue HF Transformers.
2.3. Critère faux-positif 0.95
C'est le critère discriminant pour sortir de la drift exemption rustine. SuperPoint + LightGlue fournit deux signaux séparables :
n_matches: nombre de keypoints appariés (typique 50-200 pour un widget visible).- Cohérence géométrique : on calcule l'homographie via
cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)sur les matches et on garde le ratio inliers / total. Un faux positif 0.95 sur région différente auran_matches < 10ou un ratio inliers < 0.5.
Cela élimine la classe de bug « score 0.95, mauvais bouton » sans avoir besoin d'un seuil bas qui ferait passer le faux positif.
2.4. Recommandation
Phase 1 (court terme, post-démo) : conserver cv2.matchTemplate mais ajouter une vérification géométrique LightGlue+SuperPoint en ratification quand le score est ∈ [0.80, 0.95] (zone aujourd'hui ambiguë). Si LightGlue confirme la cohérence homographique → garder le match. Sinon → fallback VLM. Cela réduit l'exemption drift de 0.95 vers 0.80.
Phase 2 (moyen terme) : remplacer la boucle multi-scale cv2.matchTemplate par LightGlue+SuperPoint en méthode primaire d'étage 2. Garder un fallback NCC pour les widgets très uniformes/texturés faiblement (icônes monochromes plates où LightGlue manque de keypoints).
Snippet — intégration LightGlue compatible cascade actuelle
# Pseudo-code à brancher dans resolve_engine._resolve_by_template
# Ne PAS appliquer en l'état — validation syntaxique seulement.
from lightglue import LightGlue, SuperPoint
from lightglue.utils import load_image, rbd
import cv2, torch
_LG_DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
_LG_EXTRACTOR = SuperPoint(max_num_keypoints=2048).eval().to(_LG_DEVICE)
_LG_MATCHER = LightGlue(features="superpoint").eval().to(_LG_DEVICE)
def _verify_template_match_with_lightglue(
screenshot_bgr,
template_bgr,
candidate_xy, # (cx, cy) pixel renvoyé par cv2.matchTemplate
inlier_ratio_threshold=0.5,
min_matches=10,
):
"""Confirme géométriquement un match cv2.matchTemplate.
Returns:
dict(confirmed=bool, n_matches=int, inlier_ratio=float)
"""
# Crop autour du candidat (taille du template + halo)
th, tw = template_bgr.shape[:2]
cx, cy = candidate_xy
x0 = max(0, cx - tw)
y0 = max(0, cy - th)
x1 = min(screenshot_bgr.shape[1], cx + tw)
y1 = min(screenshot_bgr.shape[0], cy + th)
crop = screenshot_bgr[y0:y1, x0:x1]
# Tensors LightGlue (1, 1, H, W) float [0,1]
crop_t = torch.from_numpy(cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)).float()[None, None] / 255.0
tpl_t = torch.from_numpy(cv2.cvtColor(template_bgr, cv2.COLOR_BGR2GRAY)).float()[None, None] / 255.0
with torch.no_grad():
feats0 = _LG_EXTRACTOR.extract(tpl_t.to(_LG_DEVICE))
feats1 = _LG_EXTRACTOR.extract(crop_t.to(_LG_DEVICE))
matches01 = _LG_MATCHER({"image0": feats0, "image1": feats1})
feats0, feats1, matches01 = (rbd(x) for x in [feats0, feats1, matches01])
matches = matches01["matches"] # (M, 2)
n_matches = matches.shape[0]
if n_matches < min_matches:
return {"confirmed": False, "n_matches": n_matches, "inlier_ratio": 0.0}
pts0 = feats0["keypoints"][matches[..., 0]].cpu().numpy()
pts1 = feats1["keypoints"][matches[..., 1]].cpu().numpy()
H_, mask = cv2.findHomography(pts0, pts1, cv2.RANSAC, 5.0)
if H_ is None:
return {"confirmed": False, "n_matches": n_matches, "inlier_ratio": 0.0}
inlier_ratio = float(mask.sum()) / n_matches
return {
"confirmed": inlier_ratio >= inlier_ratio_threshold,
"n_matches": n_matches,
"inlier_ratio": inlier_ratio,
}
À brancher en post-process de cv2.matchTemplate : si score ∈ [0.80, 0.95], appel LightGlue. Si confirmé → garder. Cela transforme la rustine drift exemption en vérification ratifiée.
3. Sous-axe 3 — pHash → alternatives 2026
3.1. Usages actuels et limites
| Usage | Implémentation actuelle | Limite documentée |
|---|---|---|
| LoopDetector QW2 | pHash global (screen_static ≥ threshold) + action_repeat + retry_threshold |
un caret blinking ou un curseur sur barre de chargement fait varier le hash → faux négatif (« écran a bougé » alors qu'il n'a rien changé fonctionnellement) |
| VERIFY post-action | pHash global avant/après click | un clic local sur un onglet change ~5 % de l'image (la zone des tabs + le contenu de l'onglet) — peut être absorbé par le hash global → faux négatif (le click n'a rien fait visible). Inversement, popup arrière-plan / curseur souris fait croire à un changement. |
Diagnostic principal : feedback_phash_vs_dialog_in_vm.md (memory) — pHash global est trop grossier pour la cascade VM. DETTE-001 (BUG_PRECHECK_SPATIAL_BLINDNESS) montre que c'est spatialement aveugle : _text_match_fuzzy valide le pré-check OCR au mauvais endroit parce que le radius 280 px englobe plusieurs tabs.
3.2. Table comparative — alternatives 2026
| Méthode | Mode | Latence (crop 400×200) | Mode ROI ? | Robustesse caret/curseur | Distingue mouvement local | Bibliothèque |
|---|---|---|---|---|---|---|
| pHash global 64-bit | actuel | <5 ms | non | mauvaise | non | imagehash |
| pHash par ROI (rolling) | extension simple | ~5 ms × N régions | oui (par tuiles) | OK | oui | imagehash |
| SSIM (skimage) | classique | 5-10 ms CPU | oui native | bonne | oui | skimage.metrics.structural_similarity |
| MS-SSIM | multi-échelle | 15-30 ms | oui | meilleure | oui | pytorch-msssim |
| LPIPS (AlexNet/VGG) | deep | 30-80 ms | oui via crop | excellente (sémantique) | oui | lpips |
| DINOv2 patch features cos-sim | deep semantic | 100-200 ms (ViT-S/14) | oui (patches) | excellente | oui | transformers + dinov2_vits14 |
| CLIP image embedding cos-sim | global semantic | ~30 ms | non (perd info spatiale) | bonne mais pas local | non | open_clip |
Sources : Eureka — SSIM vs LPIPS, SSIM scikit-image doc, Wopee — Screenshot Comparison Algorithms, Medium CLIP vs DINOv2 image similarity, DinoHash arXiv 2503.11195.
3.3. Recommandation par usage
LoopDetector QW2 (écran statique → boucle)
- Adopter : DINOv2 features cos-sim sur frame entière (downscale 224×224 avant). Seuil cos < 0.99 = changement réel. Robuste au caret blinking, au scroll-bar position, à la souris.
- Coût : ~100 ms par frame sur RTX 5070. Acceptable pour un trigger appelé 1×/sec.
- Alternative dégradée : pHash par ROI (grille 4×4 tuiles), ré-utilise
imagehashactuel, sans GPU.
VERIFY post-action (a-t-on cliqué utilement ?)
- Adopter SSIM par ROI :
- ROI = bbox du target résolu + halo 50 px (ou la zone qu'on s'attend à voir changer si elle est connue : par exemple, le contenu d'onglet pour un click sur onglet).
structural_similarity(roi_before, roi_after, multichannel=True).- Seuil empirique à calibrer (0.85 = changement notable, 0.95 = rien n'a changé).
- Coût : ~5 ms CPU sur crop 400×200, négligeable.
- Bénéfice transversal : résout aussi DETTE-001 — au lieu de vérifier que
target_textest présent dans un crop OCR autour du click, on vérifie que la zone elle-même a changé (= un click vraiment effectif déclenche un repaint local).
Snippet — SSIM ROI VERIFY (drop-in dans replay_verifier.py)
from skimage.metrics import structural_similarity as ssim
import cv2, numpy as np
def verify_click_changed_roi(
screenshot_before_path: str,
screenshot_after_path: str,
cx_px: int,
cy_px: int,
roi_w: int = 400,
roi_h: int = 200,
threshold: float = 0.95,
) -> dict:
"""Vérifie qu'un click a effectivement modifié la ROI cible.
Returns:
dict(changed=bool, ssim=float, roi_bbox=(x0,y0,x1,y1))
"""
before = cv2.imread(screenshot_before_path)
after = cv2.imread(screenshot_after_path)
if before is None or after is None or before.shape != after.shape:
return {"changed": False, "ssim": 0.0, "roi_bbox": (0, 0, 0, 0)}
H, W = before.shape[:2]
x0 = max(0, cx_px - roi_w // 2)
y0 = max(0, cy_px - roi_h // 2)
x1 = min(W, cx_px + roi_w // 2)
y1 = min(H, cy_px + roi_h // 2)
crop_b = cv2.cvtColor(before[y0:y1, x0:x1], cv2.COLOR_BGR2GRAY)
crop_a = cv2.cvtColor(after[y0:y1, x0:x1], cv2.COLOR_BGR2GRAY)
score = float(ssim(crop_b, crop_a, data_range=255))
return {
"changed": score < threshold,
"ssim": score,
"roi_bbox": (x0, y0, x1, y1),
}
4. Patch ciblé — bug center-of-line de _resolve_by_ocr_text
4.1. Cible exacte
Fichier : agent_v0/server_v1/resolve_engine.py, fonction _resolve_by_ocr_text, lignes 1486-1519 (référence dans REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md §1.2).
Bloc actuel reconstitué d'après §1.2 du diagnostic 8 mai :
# resolve_engine.py:1486-1519 (état au 8 mai 2026)
# Match exact > contient > mot par mot
score = 0.0
if target_lower == line_lower:
score = 1.0
elif target_lower in line_lower:
score = 0.8
elif any(target_lower == w.value.lower() for w in line_obj.words):
score = 0.9
if score > best_score:
box = line_obj.geometry # ⚠ bbox de la LIGNE ENTIÈRE
cx = (box[0][0] + box[1][0]) / 2
cy = (box[0][1] + box[1][1]) / 2
best_score = score
best_match = {"cx": cx, "cy": cy, "score": score, "line": line_obj.value}
4.2. Patch proposé — center-of-span depuis line.words
Principe : pour les scores 0.8 (substring) et 0.9 (mot exact), recalculer cx, cy à partir des bbox des words qui couvrent le target_text, pas de la ligne entière.
Code copy-paste-ready (validation syntaxique seulement, non exécuté) :
# resolve_engine.py:1486-1519 (proposition)
# Match exact > contient > mot par mot
score = 0.0
matched_words = [] # sous-ensemble de line_obj.words couvrant target_text
target_lower = target_text.lower().strip()
line_lower = line_obj.value.lower().strip()
# 1) Match exact ligne entière
if target_lower == line_lower:
score = 1.0
matched_words = list(line_obj.words)
# 2) Match substring (multi-mots possibles)
elif target_lower in line_lower:
score = 0.8
# Reconstruire le span de words couvrant target_lower par concat séquentielle
target_tokens = target_lower.split()
line_words_lower = [w.value.lower() for w in line_obj.words]
# Recherche d'une fenêtre contiguë qui matche tous les target_tokens dans l'ordre
for start in range(len(line_words_lower) - len(target_tokens) + 1):
window = line_words_lower[start:start + len(target_tokens)]
# Comparaison tolérante : un token cible peut être préfixe/égal au token line
if all(t == w or t in w or w in t for t, w in zip(target_tokens, window)):
matched_words = line_obj.words[start:start + len(target_tokens)]
break
if not matched_words:
# Fallback : tous les words contenant un token cible
matched_words = [w for w in line_obj.words if any(t in w.value.lower() for t in target_tokens)]
# 3) Match mot-exact dans la ligne (single token)
elif any(target_lower == w.value.lower() for w in line_obj.words):
score = 0.9
matched_words = [w for w in line_obj.words if w.value.lower() == target_lower]
if score > best_score:
if matched_words:
# ✅ Centre du SPAN matché, pas de la ligne entière
xs = []
ys = []
for w in matched_words:
(xmin, ymin), (xmax, ymax) = w.geometry
xs.extend([xmin, xmax])
ys.extend([ymin, ymax])
cx = (min(xs) + max(xs)) / 2
cy = (min(ys) + max(ys)) / 2
else:
# Fallback de sécurité : centre de la ligne (comportement actuel)
box = line_obj.geometry
cx = (box[0][0] + box[1][0]) / 2
cy = (box[0][1] + box[1][1]) / 2
best_score = score
best_match = {
"cx": cx,
"cy": cy,
"score": score,
"line": line_obj.value,
"matched_span": " ".join(w.value for w in matched_words) if matched_words else None,
}
4.3. Justification, risques, tests à faire avant merge
Pourquoi ça résout le bug : pour target='Imagerie' dans la ligne "Motif d'admission Examens cliniques Imagerie Notes médicales Synthèse Urgences Codage >", matched_words capturera uniquement le Word "Imagerie" (geometry locale), pas tous les words de la ligne. cx, cy retomberont au centre exact de ce mot. Idem pour 'Notes médicales' (2 words contigus) et 'Synthèse Urgences' (2 words contigus). Plus de collision (0.23, 0.28).
Repère identique : Word.geometry est dans le même repère normalisé que line_obj.geometry (vérifié par doc docTR — cf. Discussion #570 et io modules). Aucune conversion d'échelle requise.
Risques résiduels :
- Casse/accents :
target_lower in line_lowerpuis comparaisont == w or t in w or w in t— il faut normaliser les accents (NFD + strip diacritics) sitarget='Notes médicales'vsWord='médicales'matche, maistarget='Notes medicales'(sans accent venant du JSON workflow) peut rater. Mitigation :unicodedata.normalize('NFKD', s).encode('ascii','ignore').decode()sur les deux côtés avant la comparaison. - Tokenisation docTR ≠ split blancs : docTR sépare typiquement par espace mais peut séparer/grouper différemment des hyphens/apostrophes. Le fallback
matched_words = [w for w in line_obj.words if any(t in w.value.lower() for t in target_tokens)]couvre ce cas mais peut sur-matcher. - Performance : O(n_words × n_target_tokens) — négligeable (n_words < 50 typiquement).
- Régressions cosmétiques :
pre_check_text_match(DETTE-001) actuellement OFF — à re-tester avec ce fix actif.
Tests minimaux avant merge (10 min) :
cd /home/dom/ai/rpa_vision_v3 && source .venv/bin/activate
python -c "
from agent_v0.server_v1.resolve_engine import _resolve_by_ocr_text
img='/home/dom/ai/rpa_vision_v3/visual_workflow_builder/backend/data/anchors/anchor_0438bd2d9bdd_1778161174_full.png'
for t in ['Imagerie','Notes médicales','Synthèse Urgences','Codage','Examens cliniques']:
r = _resolve_by_ocr_text(img, t, 2560, 1600)
print(f'{t:25s} -> cx={r[\"x_pct\"]:.4f} cy={r[\"y_pct\"]:.4f} score={r[\"score\"]:.2f}')
"
Critère succès : Imagerie / Notes médicales / Synthèse Urgences ont des cx séparés d'au moins 0.05 (≈ 130 px à 2560 px).
À NE PAS faire en chaud démo (cf. §5 du diagnostic 8 mai). Le quick fix démo reste le timeout client 5 → 30 s. Ce patch s'applique sur runner 2 (post-démo).
5. Dépendances croisées avec les autres axes
- AXE_A5 (tokenisation UI / OmniParser) : OmniParser utilise PaddleOCR pour l'OCR d'icônes. Si on bascule vers tokenisation OmniParser-style en cascade
1.5(entre OCR et VLM), il faudra décider un seul moteur OCR pour tout le pipeline ou accepter 2 moteurs (docTR pour resolve_engine, PaddleOCR/RapidOCR pour tokenisation). Voir AXE_A5 livrable. - AXE_B2 (Validator) : SSIM-ROI proposé §3 alimente directement le composant Validator du Planner-Actor-Validator (cf. SYNTHESE §5.2). C'est le signal sémantique « le click a fait quelque chose dans la zone attendue » qui élimine la classe de bugs « cliqué quelque part, REPORT success=True ».
- DETTE-001 : le patch §4 + SSIM-ROI §3 referment la dette (le pré-check OCR cesse d'être spatialement aveugle parce qu'il vise un span exact, et la vérification post-click se fait sur ROI ciblée).
- Drift exemption ≥ 0.95 : la ratification LightGlue (§2.4) permet de baisser le seuil vers 0.80 sans réintroduire de faux positifs.
6. Sources (chronologie)
- docTR — Word/Line geometry — Discussion #570 (2022, valide en 2026)
- docTR — I/O modules (doc officielle)
- docTR — repo principal (release v0.10, 2026-04)
- docTR — PyPI python-doctr
- PaddleOCR — return_word_box Issue #15760 (2024)
- PaddleOCR 3.0 Technical Report (2025-07)
- Surya OCR — datalab-to/surya
- Surya OCR — PyPI v0.17.1
- EasyOCR — Character bbox Issue #631
- RapidOCR — releases (v3.x, 2026-04-11)
- RapidOCR — repo
- pytesseract — image_to_data + hOCR (PyPI)
- Codesota — PaddleOCR vs EasyOCR Speed 2025
- Codesota — PaddleOCR vs Tesseract vs EasyOCR 2026
- Buttondown — PaddleOCR vs EasyOCR vs Doctr Memory & Latency
- LightGlue — ICCV 2023 paper
- LightGlue — repo cvg/LightGlue
- LightGlue ONNX — fabio-sim/LightGlue-ONNX
- LightGlue — HuggingFace Transformers integration
- Efficient LoFTR — arXiv 2403.04765 (CVPR 2024)
- RoMa — CVPR 2024
- RoMa v2 — emergent mind 2025-11
- DINOv2 — features tutorial
- DINO-RotateMatch — arXiv 2512.03715 (2025)
- PyImageSearch — Multi-scale Template Matching (2015, ref classique)
- Medium — Template Matching Beyond Basics: Rotation & Scale Invariant
- Eureka — SSIM vs LPIPS
- skimage — structural_similarity doc
- Wopee — Screenshot Comparison Algorithms
- Medium — CLIP vs DINOv2 image similarity
- DinoHash — arXiv 2503.11195
- OmniParser — DeepWiki OCR module
Document de recherche. Aucun code modifié. Toute application validée par Dom au cas par cas.