feat(extract): second passage VLM sur crop colonne Recodage (P0)

Qwen ne lit systématiquement que la colonne de gauche du tableau
Codage quand on lui donne la page recueil entière : la colonne droite
(Recodage) a 27% de couverture en V2.0 avec 100% de validité — une
régression majeure puisque c'est le cœur métier du contrôle T2A.

Solution : après le passage principal, refaire une extraction dédiée
sur un crop zonal de la seule colonne Recodage (y=0.330→0.490 pour
exclure le bloc Actes adjacent). Prompt strict anti-hallucination
("beaucoup de lignes sont vides, n'invente rien"). Le résultat écrase
partiellement `codage_reco` (DP/DR/DAS) dans le JSON principal.

Classification Python par règle métier :
- 1er code sans position  → DP
- 2e code sans position   → DR (ignoré si == DP : Qwen duplique parfois)
- codes avec position     → DAS

Filtre CIM-10 par regex en Python pour retirer les codes CCAM (actes)
qui pourraient rester si le crop déborde.

Ajout d'une env var `QWEN_MAX_PIXELS` (défaut 800) pour ajuster la
consommation VRAM sur machines avec GPU partagé (test sur RTX 5070
avec rpa_vision_v3 en parallèle).

Ajout de `torch.cuda.empty_cache()` après chaque inférence pour
réduire la fragmentation VRAM sur exécutions longues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-24 15:54:35 +02:00
parent 7d45018139
commit 3f2e2ee9f4
2 changed files with 43 additions and 2 deletions

View File

@@ -28,11 +28,15 @@ class QwenVLOCR:
def _init_model(self): def _init_model(self):
t0 = time.time() t0 = time.time()
# max_pixels limite le nombre de patches visuels pour éviter l'OOM # max_pixels limite le nombre de patches visuels pour éviter l'OOM
# sur images 300 dpi (2481x3509). ~1.25M pixels = équilibre qualité/VRAM. # sur images 300 dpi (2481x3509). ~800 patches = équilibre qualité/VRAM,
# tient confortablement dans ~5-6 Go même avec d'autres processus GPU
# en arrière-plan. Configurable via env var QWEN_MAX_PIXELS (en patches).
import os as _os
max_pixels = int(_os.environ.get("QWEN_MAX_PIXELS", 800)) * 28 * 28
self.processor = AutoProcessor.from_pretrained( self.processor = AutoProcessor.from_pretrained(
MODEL_PATH, MODEL_PATH,
min_pixels=256 * 28 * 28, min_pixels=256 * 28 * 28,
max_pixels=1280 * 28 * 28, max_pixels=max_pixels,
) )
self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained( self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
MODEL_PATH, MODEL_PATH,
@@ -67,4 +71,9 @@ class QwenVLOCR:
generated_ids = self.model.generate(**inputs, max_new_tokens=max_new_tokens) generated_ids = self.model.generate(**inputs, max_new_tokens=max_new_tokens)
out_ids = generated_ids[:, inputs.input_ids.shape[1]:] out_ids = generated_ids[:, inputs.input_ids.shape[1]:]
output = self.processor.batch_decode(out_ids, skip_special_tokens=True)[0] output = self.processor.batch_decode(out_ids, skip_special_tokens=True)[0]
# Libérer la VRAM allouée par l'inférence (utile quand d'autres
# processus tournent en parallèle sur le même GPU)
del inputs, generated_ids, out_ids
if torch.cuda.is_available():
torch.cuda.empty_cache()
return {"text": output.strip(), "elapsed_s": time.time() - t0} return {"text": output.strip(), "elapsed_s": time.time() - t0}

View File

@@ -54,6 +54,38 @@ Si un champ est illisible, laisse une chaîne vide. Ne devine pas.
"praticien_conseil": "" "praticien_conseil": ""
}""" }"""
# --- Second passage dédié : colonne Recodage de la page recueil ---
# Qwen-VL sous-extrait la colonne droite du tableau Codage quand on lui donne
# la page entière (27% de couverture sur `codage_reco.dp` en V2.0). En lui
# donnant directement un crop zonal de cette seule colonne, il lit beaucoup
# mieux (la structure à une seule colonne lève l'ambiguïté).
#
# Zone cropée (coordonnées relatives dans l'image complète) :
# Zone restreinte au seul bloc codage (DP/DR/DAS de la colonne Recodage).
# On exclut la partie Actes (qui commence autour de y=0.680) pour éviter que
# Qwen confonde les codes CCAM (actes) avec des codes CIM-10 (DAS).
RECUEIL_RECODAGE_ZONE = (0.77, 0.330, 0.97, 0.490)
SCHEMA_RECUEIL_RECODAGE = """Cette image est un extrait d'une colonne d'un tableau médical.
La colonne peut contenir ZÉRO, UN ou PLUSIEURS codes médicaux CIM-10 (format : 1 lettre majuscule + 2 à 4 chiffres, ex: K650, T810, Z954, R31, I652). Un code peut avoir un suffixe `*`. À droite d'un code, une position numérique (1-9) peut être visible.
IMPORTANT — LIS UNIQUEMENT CE QUI EST PHYSIQUEMENT VISIBLE :
- La plupart des lignes de ce tableau sont VIDES. C'est NORMAL.
- Ne liste QUE les codes effectivement écrits dans l'image. N'INVENTE rien.
- Si l'image ne contient qu'un seul code, ta réponse doit lister exactement un code (pas plusieurs).
- Si l'image ne contient aucun code, renvoie `"codes": []`.
- Ne déduis pas les codes d'autres cases non montrées dans l'image.
Pour chaque code réellement visible, indique sa position à droite si elle est écrite, sinon "".
Renvoie STRICTEMENT ce JSON, sans commentaire ni markdown :
{
"codes": [
{"code": "", "position": ""}
]
}"""
# --- Page 5 : Fiche administrative de concertation 2/2 (décision finale) --- # --- Page 5 : Fiche administrative de concertation 2/2 (décision finale) ---
SCHEMA_CONCERTATION_2 = """Lis la fiche de concertation et renvoie STRICTEMENT le JSON suivant, sans commentaire ni markdown. SCHEMA_CONCERTATION_2 = """Lis la fiche de concertation et renvoie STRICTEMENT le JSON suivant, sans commentaire ni markdown.
Si un champ est illisible, laisse une chaîne vide. Si un champ est illisible, laisse une chaîne vide.