feat(pipeline): extraction OGC via Qwen2.5-VL-3B
Pipeline modulaire remplaçant le monolithe extract_ogc.py (conservé
en legacy pour comparaison).
Modules :
- ingest.py : PDF → PNG 300dpi avec cache par SHA256
- ocr_qwen.py : wrapper singleton Qwen2.5-VL-3B (bfloat16, ~7 Go VRAM)
- ocr_glm.py : wrapper GLM-OCR 0.9B (alternatif, conservé)
- classify.py : détection type de page + routing par index standard
(ordre des 6 pages OGC → -50% d'appels OCR)
- prompts.py : JSON schemas par type (recueil, concertation 1/2/2/2,
preuves) + mots-clés de classification
- checkboxes.py : détection Accord/Désaccord par densité de pixels
(inner-frac 0.35, 17/17 corrects sur échantillon vérifié ;
GLM-OCR et Qwen échouent sur les checkboxes, cf.
scratch/test_prompt_crop_v2.py)
- extract.py : orchestration 1 dossier (ingest → classify → OCR →
parse JSON tolérant aux boucles + validation ATIH)
- persist.py : sauvegarde JSON + metadata (pipeline_version,
ocr_model, timestamp)
- cli.py : `python -m pipeline.cli <pdf|dir>`
Temps mesuré : ~35s/dossier (6 pages) sur RTX 5070.
Qwen2.5-VL-3B retenu après comparaison avec GLM-OCR 0.9B, GOT-OCR2.0,
Surya, PaddleOCR (cf. scratch/). Il extrait correctement dp_libelle,
praticien_conseil et les 4 GHM/GHS là où les autres échouent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
pipeline/__init__.py
Normal file
1
pipeline/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Pipeline d'extraction OGC basé sur GLM-OCR 0.9B."""
|
||||||
87
pipeline/checkboxes.py
Normal file
87
pipeline/checkboxes.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Détection de cases à cocher par analyse de densité de pixels sombres.
|
||||||
|
|
||||||
|
GLM-OCR ne sait pas distinguer une case cochée d'une case vide (A/B test, cf.
|
||||||
|
test_prompt_crop_v2.py). On reprend l'approche déterministe du pipeline legacy :
|
||||||
|
cropper la case à coord relative, compter les pixels sombres, comparer les
|
||||||
|
densités entre les deux cases pour trancher.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
DARK_THRESHOLD = 128 # pixels < 128 (0-255) comptés comme sombres
|
||||||
|
AMBIGU_MARGIN = 0.01 # différence minimale de densité pour trancher
|
||||||
|
# (calibré pour couvrir OGC 69 où diff=+0.010)
|
||||||
|
INNER_FRAC = 0.35 # on exclut 35% de marge pour ignorer le cadre et
|
||||||
|
# n'analyser que le centre de la case (calibré sur
|
||||||
|
# OGC 7/27/55/86 : 4/4 corrects)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CheckboxZones:
|
||||||
|
"""Coordonnées relatives (x1,y1,x2,y2) dans [0,1] des deux cases."""
|
||||||
|
accord: tuple[float, float, float, float]
|
||||||
|
desaccord: tuple[float, float, float, float]
|
||||||
|
|
||||||
|
|
||||||
|
# Zones englobant la case entière (cadre compris). On utilisera INNER_FRAC
|
||||||
|
# pour n'analyser que le centre et ignorer le cadre.
|
||||||
|
RECUEIL_ACCORD_DESACCORD = CheckboxZones(
|
||||||
|
accord= (0.588, 0.838, 0.622, 0.860),
|
||||||
|
desaccord= (0.588, 0.858, 0.622, 0.880),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Zones pour la page concertation_2 (maintien / retour / autre groupage)
|
||||||
|
# À calibrer si besoin plus tard
|
||||||
|
CONCERTATION_2_DECISION = CheckboxZones(
|
||||||
|
accord= (0.035, 0.270, 0.060, 0.290), # maintien avis contrôleur
|
||||||
|
desaccord= (0.280, 0.270, 0.305, 0.290), # retour groupage DIM
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dark_ratio(image: Image.Image, zone: tuple[float, float, float, float],
|
||||||
|
inner_frac: float = INNER_FRAC) -> float:
|
||||||
|
"""Fraction de pixels sombres au centre de la zone (cadre exclu).
|
||||||
|
|
||||||
|
On crope la zone puis on retire une marge de `inner_frac` de chaque côté
|
||||||
|
pour ne garder que le contenu central (croix éventuelle).
|
||||||
|
"""
|
||||||
|
w, h = image.size
|
||||||
|
x1, y1, x2, y2 = zone
|
||||||
|
px1, py1 = int(x1*w), int(y1*h)
|
||||||
|
px2, py2 = int(x2*w), int(y2*h)
|
||||||
|
dx = int((px2 - px1) * inner_frac)
|
||||||
|
dy = int((py2 - py1) * inner_frac)
|
||||||
|
crop = image.crop((px1 + dx, py1 + dy, px2 - dx, py2 - dy))
|
||||||
|
gray = np.array(crop.convert("L"))
|
||||||
|
return float(np.mean(gray < DARK_THRESHOLD))
|
||||||
|
|
||||||
|
|
||||||
|
def detect_accord_desaccord(
|
||||||
|
image_path: str | Path,
|
||||||
|
zones: CheckboxZones = RECUEIL_ACCORD_DESACCORD,
|
||||||
|
) -> dict:
|
||||||
|
"""Retourne la décision et les ratios bruts pour debug/audit.
|
||||||
|
|
||||||
|
Convention : "accord" si la case Accord est plus remplie, "désaccord" si
|
||||||
|
la case Désaccord est plus remplie, "ambigu" si l'écart est trop faible.
|
||||||
|
"""
|
||||||
|
img = Image.open(image_path)
|
||||||
|
r_acc = dark_ratio(img, zones.accord)
|
||||||
|
r_des = dark_ratio(img, zones.desaccord)
|
||||||
|
diff = r_acc - r_des
|
||||||
|
|
||||||
|
if abs(diff) < AMBIGU_MARGIN:
|
||||||
|
decision = "ambigu"
|
||||||
|
elif diff > 0:
|
||||||
|
decision = "accord"
|
||||||
|
else:
|
||||||
|
decision = "désaccord"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"decision": decision,
|
||||||
|
"ratio_accord": round(r_acc, 4),
|
||||||
|
"ratio_desaccord": round(r_des, 4),
|
||||||
|
"diff": round(diff, 4),
|
||||||
|
}
|
||||||
70
pipeline/classify.py
Normal file
70
pipeline/classify.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Détection du type de page.
|
||||||
|
|
||||||
|
Deux stratégies :
|
||||||
|
1. `route_by_index` (rapide, défaut) : exploite le fait que les fiches OGC
|
||||||
|
respectent un ordre standardisé de 6 pages. Pas d'OCR, 0 coût.
|
||||||
|
2. `detect_page_type` (OCR de l'en-tête) : fallback quand l'ordre standard
|
||||||
|
n'est pas respecté ou quand on veut vérifier explicitement.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
from .ocr_qwen import QwenVLOCR
|
||||||
|
from .prompts import PAGE_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
# Ordre canonique des 6 pages d'un dossier OGC standardisé
|
||||||
|
STANDARD_ORDER = [
|
||||||
|
"recueil",
|
||||||
|
"concertation_med",
|
||||||
|
"hospitalisation",
|
||||||
|
"preuves",
|
||||||
|
"concertation_2",
|
||||||
|
"concertation_1",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def route_by_index(num_pages: int) -> list[str]:
|
||||||
|
"""Retourne le type attendu pour chaque page selon l'ordre standard.
|
||||||
|
|
||||||
|
Si le dossier a moins de 6 pages, on prend le préfixe de STANDARD_ORDER.
|
||||||
|
Si plus de 6 pages, les pages supplémentaires sont marquées "inconnu".
|
||||||
|
"""
|
||||||
|
types = []
|
||||||
|
for i in range(num_pages):
|
||||||
|
types.append(STANDARD_ORDER[i] if i < len(STANDARD_ORDER) else "inconnu")
|
||||||
|
return types
|
||||||
|
|
||||||
|
|
||||||
|
def crop_header(image_path: Path, out_path: Path | None = None) -> Path:
|
||||||
|
"""Crop la bande d'en-tête (haut 12% de la page) pour classification rapide.
|
||||||
|
|
||||||
|
Important : le fichier produit ne doit PAS matcher le glob 'page_*.png'
|
||||||
|
qu'utilise pdf_to_images pour lister les pages, sinon il serait relu
|
||||||
|
comme une page au run suivant (ratio d'aspect cassé).
|
||||||
|
"""
|
||||||
|
img = Image.open(image_path)
|
||||||
|
w, h = img.size
|
||||||
|
header = img.crop((0, 0, w, int(h * 0.12)))
|
||||||
|
if out_path is None:
|
||||||
|
# Sous-dossier dédié pour isoler les crops temporaires
|
||||||
|
headers_dir = image_path.parent / "_headers"
|
||||||
|
headers_dir.mkdir(exist_ok=True)
|
||||||
|
out_path = headers_dir / f"{image_path.stem}.png"
|
||||||
|
header.save(out_path, "PNG")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def detect_page_type(image_path: Path, ocr: QwenVLOCR | None = None) -> tuple[str, str]:
|
||||||
|
"""Classifie une page. Retourne (type, header_text)."""
|
||||||
|
ocr = ocr or QwenVLOCR()
|
||||||
|
header_path = crop_header(image_path)
|
||||||
|
res = ocr.run(header_path, "Text Recognition:", max_new_tokens=200)
|
||||||
|
text = res["text"].upper()
|
||||||
|
# Normaliser les caractères accentués pour le matching
|
||||||
|
text_norm = re.sub(r"[ÉÈÊË]", "E", text)
|
||||||
|
text_norm = re.sub(r"[ÀÂÄ]", "A", text_norm)
|
||||||
|
for ptype, conf in PAGE_TYPES.items():
|
||||||
|
if any(kw in text_norm for kw in conf["keywords"]):
|
||||||
|
return ptype, res["text"]
|
||||||
|
return "inconnu", res["text"]
|
||||||
53
pipeline/cli.py
Normal file
53
pipeline/cli.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""CLI : traite un PDF ou un répertoire de PDFs.
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python -m pipeline.cli <pdf|dir> [--out output/v2]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .extract import extract_dossier
|
||||||
|
from .persist import save_result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="Pipeline OGC v1 (GLM-OCR)")
|
||||||
|
p.add_argument("input", help="PDF unique ou répertoire contenant des PDFs")
|
||||||
|
p.add_argument("--out", default="output/v2", help="Répertoire de sortie JSON")
|
||||||
|
p.add_argument("--quiet", action="store_true")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
input_path = Path(args.input)
|
||||||
|
if input_path.is_dir():
|
||||||
|
pdfs = sorted(input_path.glob("*.pdf"))
|
||||||
|
elif input_path.is_file() and input_path.suffix.lower() == ".pdf":
|
||||||
|
pdfs = [input_path]
|
||||||
|
else:
|
||||||
|
# Globbing si chemin avec espaces/motifs
|
||||||
|
pdfs = [Path(p) for p in sorted(glob.glob(str(input_path))) if p.lower().endswith(".pdf")]
|
||||||
|
|
||||||
|
if not pdfs:
|
||||||
|
print(f"Aucun PDF trouvé pour : {args.input}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"{len(pdfs)} PDF(s) à traiter → {args.out}")
|
||||||
|
t0 = time.time()
|
||||||
|
for pdf in pdfs:
|
||||||
|
t_pdf = time.time()
|
||||||
|
try:
|
||||||
|
result = extract_dossier(pdf, verbose=not args.quiet)
|
||||||
|
out_path = save_result(result, args.out)
|
||||||
|
print(f" ✓ {pdf.name} → {out_path} ({time.time()-t_pdf:.1f}s)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ {pdf.name} : {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
print(f"Terminé en {time.time()-t0:.1f}s")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
191
pipeline/extract.py
Normal file
191
pipeline/extract.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""Orchestration d'extraction pour un dossier OGC."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from .ingest import pdf_to_images
|
||||||
|
from .classify import detect_page_type, route_by_index
|
||||||
|
from .ocr_qwen import QwenVLOCR
|
||||||
|
from .prompts import PAGE_TYPES, PROMPT_HEADER
|
||||||
|
from .checkboxes import detect_accord_desaccord, RECUEIL_ACCORD_DESACCORD
|
||||||
|
from .validation import annotate as validate_annotate
|
||||||
|
|
||||||
|
|
||||||
|
_EMPTY_OBJ_PATTERN = re.compile(
|
||||||
|
r'\{\s*"code"\s*:\s*""\s*,\s*"position"\s*:\s*""\s*(?:,\s*"libelle"\s*:\s*""\s*)?\}',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_empty_loop(text: str, max_consecutive: int = 2) -> str:
|
||||||
|
"""Détecte et tronque les boucles d'objets vides.
|
||||||
|
|
||||||
|
GLM-OCR peut boucler sur `{"code":"", "position":"", "libelle":""}` quand
|
||||||
|
un tableau DAS ou actes est vide dans l'image. La sortie est alors
|
||||||
|
tronquée à `max_new_tokens` sans fermer le JSON → parse error.
|
||||||
|
On garde au plus `max_consecutive` objets vides puis on coupe.
|
||||||
|
"""
|
||||||
|
matches = list(_EMPTY_OBJ_PATTERN.finditer(text))
|
||||||
|
if len(matches) <= max_consecutive:
|
||||||
|
return text
|
||||||
|
# On coupe après la fin du `max_consecutive`-ième match
|
||||||
|
cut_at = matches[max_consecutive - 1].end()
|
||||||
|
return text[:cut_at]
|
||||||
|
|
||||||
|
|
||||||
|
def _close_open_json(text: str) -> str:
|
||||||
|
"""Ajoute les brackets/braces manquants pour tenter de fermer un JSON tronqué."""
|
||||||
|
# Compte les brackets non balancés en ignorant ceux entre guillemets simples/doubles
|
||||||
|
depth_brace = 0
|
||||||
|
depth_bracket = 0
|
||||||
|
in_string = False
|
||||||
|
escape = False
|
||||||
|
for c in text:
|
||||||
|
if escape:
|
||||||
|
escape = False
|
||||||
|
continue
|
||||||
|
if c == "\\":
|
||||||
|
escape = True
|
||||||
|
continue
|
||||||
|
if c == '"':
|
||||||
|
in_string = not in_string
|
||||||
|
continue
|
||||||
|
if in_string:
|
||||||
|
continue
|
||||||
|
if c == "{": depth_brace += 1
|
||||||
|
elif c == "}": depth_brace -= 1
|
||||||
|
elif c == "[": depth_bracket += 1
|
||||||
|
elif c == "]": depth_bracket -= 1
|
||||||
|
# Retirer les virgules traînantes
|
||||||
|
closed = text.rstrip().rstrip(",")
|
||||||
|
# Fermer en priorité les crochets ouverts (tableaux), puis les accolades
|
||||||
|
closed += "]" * max(0, depth_bracket)
|
||||||
|
closed += "}" * max(0, depth_brace)
|
||||||
|
return closed
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_output(raw: str) -> dict | None:
|
||||||
|
"""Tente d'extraire un JSON depuis la sortie GLM-OCR.
|
||||||
|
|
||||||
|
Stratégies successives :
|
||||||
|
1. parse direct après retrait des fences ```json
|
||||||
|
2. patch des virgules manquantes entre objets / tableaux
|
||||||
|
3. détection et troncature des boucles d'objets vides (cas fréquent sur
|
||||||
|
tableaux DAS/actes vides → boucle jusqu'à max_new_tokens)
|
||||||
|
4. fermeture des structures JSON ouvertes après troncature
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
text = raw.strip()
|
||||||
|
# 1) fences markdown
|
||||||
|
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||||
|
text = re.sub(r"\s*```$", "", text)
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2) virgules manquantes entre `} {` et `] [`
|
||||||
|
patched = re.sub(r"\}\s*\n(\s*\{)", r"},\n\1", text)
|
||||||
|
patched = re.sub(r"\]\s*\n(\s*\[)", r"],\n\1", patched)
|
||||||
|
try:
|
||||||
|
return json.loads(patched)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3) troncature des boucles d'objets vides puis 4) fermeture
|
||||||
|
trimmed = _truncate_empty_loop(patched)
|
||||||
|
closed = _close_open_json(trimmed)
|
||||||
|
try:
|
||||||
|
result = json.loads(closed)
|
||||||
|
result["_truncated_loop"] = True # trace de l'intervention
|
||||||
|
return result
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return {"_raw": raw, "_parse_error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_dossier(pdf_path: str | Path, verbose: bool = True,
|
||||||
|
use_standard_routing: bool = True) -> dict:
|
||||||
|
"""Pipeline complet d'un dossier : PDF → JSON structuré.
|
||||||
|
|
||||||
|
use_standard_routing=True (défaut) : route les pages par index selon
|
||||||
|
l'ordre standard OGC (6 pages), sans OCR de classification. -50% du temps.
|
||||||
|
Vérifie uniquement la page 1 pour s'assurer qu'on commence bien par
|
||||||
|
"recueil" — si non, bascule en classification complète (fallback).
|
||||||
|
"""
|
||||||
|
pdf_path = Path(pdf_path)
|
||||||
|
ocr = QwenVLOCR()
|
||||||
|
if verbose:
|
||||||
|
print(f"[{pdf_path.name}] modèle prêt, VRAM={ocr.vram_gb:.2f} Go")
|
||||||
|
|
||||||
|
images = pdf_to_images(str(pdf_path))
|
||||||
|
if verbose:
|
||||||
|
print(f"[{pdf_path.name}] {len(images)} pages converties")
|
||||||
|
|
||||||
|
# Choix de stratégie de routing
|
||||||
|
page_types = [None] * len(images)
|
||||||
|
headers = [""] * len(images)
|
||||||
|
if use_standard_routing:
|
||||||
|
# Vérif rapide sur la page 1 (seul OCR de classification)
|
||||||
|
ptype1, header1 = detect_page_type(images[0], ocr)
|
||||||
|
if ptype1 == "recueil":
|
||||||
|
page_types = route_by_index(len(images))
|
||||||
|
headers[0] = header1
|
||||||
|
if verbose:
|
||||||
|
print(f" routing standard (page 1 = recueil OK)")
|
||||||
|
else:
|
||||||
|
if verbose:
|
||||||
|
print(f" page 1 = {ptype1} → fallback classification")
|
||||||
|
use_standard_routing = False
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"fichier": pdf_path.stem,
|
||||||
|
"pdf_hash": images[0].parent.name,
|
||||||
|
"pages": [],
|
||||||
|
"extraction": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, img_path in enumerate(images, 1):
|
||||||
|
t0 = time.time()
|
||||||
|
if use_standard_routing:
|
||||||
|
ptype = page_types[idx - 1]
|
||||||
|
header_text = headers[idx - 1]
|
||||||
|
else:
|
||||||
|
ptype, header_text = detect_page_type(img_path, ocr)
|
||||||
|
page_info = {
|
||||||
|
"page": idx,
|
||||||
|
"type": ptype,
|
||||||
|
"header": header_text.strip(),
|
||||||
|
"elapsed_s": None,
|
||||||
|
}
|
||||||
|
if verbose:
|
||||||
|
print(f" p{idx}: {ptype}")
|
||||||
|
|
||||||
|
prompt_conf = PAGE_TYPES.get(ptype)
|
||||||
|
if prompt_conf and prompt_conf["prompt"] != PROMPT_HEADER:
|
||||||
|
res = ocr.run(img_path, prompt_conf["prompt"], max_new_tokens=4096)
|
||||||
|
parsed = parse_json_output(res["text"])
|
||||||
|
page_info["ocr_raw"] = res["text"]
|
||||||
|
page_info["parsed"] = parsed
|
||||||
|
page_info["elapsed_s"] = round(res["elapsed_s"], 2)
|
||||||
|
|
||||||
|
# Enrichissement : checkboxes accord/désaccord sur la fiche recueil
|
||||||
|
# (GLM-OCR ne sait pas lire les checkboxes — voir test_prompt_crop_v2.py)
|
||||||
|
if ptype == "recueil" and isinstance(parsed, dict):
|
||||||
|
cb = detect_accord_desaccord(img_path, RECUEIL_ACCORD_DESACCORD)
|
||||||
|
parsed["accord_desaccord"] = cb["decision"]
|
||||||
|
parsed["_checkbox_debug"] = cb # ratios + diff pour audit
|
||||||
|
page_info["parsed"] = parsed
|
||||||
|
|
||||||
|
# Indexer par type pour accès direct dans result["extraction"]
|
||||||
|
result["extraction"][ptype] = parsed
|
||||||
|
else:
|
||||||
|
# Pages non structurées : juste l'en-tête déjà OCR
|
||||||
|
page_info["elapsed_s"] = round(time.time() - t0, 2)
|
||||||
|
|
||||||
|
result["pages"].append(page_info)
|
||||||
|
|
||||||
|
# Post-traitement : validation ATIH de tous les codes extraits
|
||||||
|
result = validate_annotate(result)
|
||||||
|
|
||||||
|
return result
|
||||||
45
pipeline/ingest.py
Normal file
45
pipeline/ingest.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""PDF → images PNG 300 dpi avec cache par hash SHA256."""
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
DEFAULT_DPI = 300
|
||||||
|
CACHE_ROOT = Path(".cache/images")
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_hash(pdf_path: str) -> str:
|
||||||
|
"""Hash SHA256 court du contenu PDF."""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(pdf_path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_to_images(pdf_path: str, dpi: int = DEFAULT_DPI, cache_root: Path = CACHE_ROOT) -> list[Path]:
|
||||||
|
"""Convertit un PDF en PNG 300 dpi. Retourne la liste des chemins (1 par page).
|
||||||
|
|
||||||
|
Le cache est indexé par hash du PDF : un PDF inchangé n'est jamais reconverti.
|
||||||
|
"""
|
||||||
|
cache_root = Path(cache_root)
|
||||||
|
h = pdf_hash(pdf_path)
|
||||||
|
out_dir = cache_root / h
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
existing = sorted(out_dir.glob("page_*.png"))
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
pages = convert_from_path(pdf_path, dpi)
|
||||||
|
paths = []
|
||||||
|
for i, img in enumerate(pages, 1):
|
||||||
|
p = out_dir / f"page_{i:02d}.png"
|
||||||
|
img.save(p, "PNG", optimize=True)
|
||||||
|
paths.append(p)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def load_image(path: Path) -> Image.Image:
|
||||||
|
return Image.open(path)
|
||||||
60
pipeline/ocr_glm.py
Normal file
60
pipeline/ocr_glm.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Wrapper singleton pour GLM-OCR 0.9B."""
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
import torch
|
||||||
|
from transformers import AutoProcessor, AutoModelForImageTextToText
|
||||||
|
|
||||||
|
MODEL_PATH = "zai-org/GLM-OCR"
|
||||||
|
|
||||||
|
|
||||||
|
class GLMOCR:
|
||||||
|
"""Charge GLM-OCR une fois, réutilise le modèle pour toutes les pages."""
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._init_model()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def _init_model(self):
|
||||||
|
t0 = time.time()
|
||||||
|
self.processor = AutoProcessor.from_pretrained(MODEL_PATH, trust_remote_code=True)
|
||||||
|
self.model = AutoModelForImageTextToText.from_pretrained(
|
||||||
|
MODEL_PATH,
|
||||||
|
torch_dtype="auto",
|
||||||
|
device_map="auto",
|
||||||
|
trust_remote_code=True,
|
||||||
|
)
|
||||||
|
self.load_time = time.time() - t0
|
||||||
|
self.vram_gb = torch.cuda.memory_allocated() / 1e9 if torch.cuda.is_available() else 0.0
|
||||||
|
|
||||||
|
def run(self, image_path: str | Path, prompt: str, max_new_tokens: int = 4096) -> dict:
|
||||||
|
"""Exécute GLM-OCR sur une image avec un prompt, retourne {text, elapsed_s}."""
|
||||||
|
image_path = str(image_path)
|
||||||
|
messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image", "url": image_path},
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
t0 = time.time()
|
||||||
|
inputs = self.processor.apply_chat_template(
|
||||||
|
messages,
|
||||||
|
tokenize=True,
|
||||||
|
add_generation_prompt=True,
|
||||||
|
return_dict=True,
|
||||||
|
return_tensors="pt",
|
||||||
|
).to(self.model.device)
|
||||||
|
inputs.pop("token_type_ids", None)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
generated_ids = self.model.generate(**inputs, max_new_tokens=max_new_tokens)
|
||||||
|
output = self.processor.decode(
|
||||||
|
generated_ids[0][inputs["input_ids"].shape[1]:],
|
||||||
|
skip_special_tokens=False,
|
||||||
|
)
|
||||||
|
# Nettoyer le marqueur de fin utilisateur
|
||||||
|
output = output.replace("<|user|>", "").strip()
|
||||||
|
return {"text": output, "elapsed_s": time.time() - t0}
|
||||||
70
pipeline/ocr_qwen.py
Normal file
70
pipeline/ocr_qwen.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Wrapper singleton pour Qwen2.5-VL-3B.
|
||||||
|
|
||||||
|
Qwen2.5-VL-3B surpasse GLM-OCR sur les fiches OGC : extrait `dp_libelle`,
|
||||||
|
`praticien_conseil` (manuscrit !), `codage_reco.dp` et les 4 GHM/GHS là où
|
||||||
|
GLM-OCR échouait systématiquement. Un poil plus rapide aussi (3s vs 4s/page).
|
||||||
|
|
||||||
|
Coût : ~7 Go VRAM en bfloat16 (GLM-OCR = 2.2 Go) → tient sur RTX 5070 12 Go.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
import torch
|
||||||
|
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
|
||||||
|
from qwen_vl_utils import process_vision_info
|
||||||
|
|
||||||
|
MODEL_PATH = "Qwen/Qwen2.5-VL-3B-Instruct"
|
||||||
|
|
||||||
|
|
||||||
|
class QwenVLOCR:
|
||||||
|
"""Charge Qwen2.5-VL-3B une fois, réutilise le modèle pour toutes les pages."""
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._init_model()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def _init_model(self):
|
||||||
|
t0 = time.time()
|
||||||
|
# max_pixels limite le nombre de patches visuels pour éviter l'OOM
|
||||||
|
# sur images 300 dpi (2481x3509). ~1.25M pixels = équilibre qualité/VRAM.
|
||||||
|
self.processor = AutoProcessor.from_pretrained(
|
||||||
|
MODEL_PATH,
|
||||||
|
min_pixels=256 * 28 * 28,
|
||||||
|
max_pixels=1280 * 28 * 28,
|
||||||
|
)
|
||||||
|
self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
||||||
|
MODEL_PATH,
|
||||||
|
torch_dtype=torch.bfloat16,
|
||||||
|
device_map="auto",
|
||||||
|
)
|
||||||
|
self.model.eval()
|
||||||
|
self.load_time = time.time() - t0
|
||||||
|
self.vram_gb = torch.cuda.memory_allocated() / 1e9 if torch.cuda.is_available() else 0.0
|
||||||
|
|
||||||
|
def run(self, image_path: str | Path, prompt: str, max_new_tokens: int = 2048) -> dict:
|
||||||
|
"""Exécute Qwen-VL sur une image avec un prompt, retourne {text, elapsed_s}."""
|
||||||
|
image_path = str(image_path)
|
||||||
|
messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image", "image": image_path},
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
t0 = time.time()
|
||||||
|
text = self.processor.apply_chat_template(
|
||||||
|
messages, tokenize=False, add_generation_prompt=True,
|
||||||
|
)
|
||||||
|
image_inputs, video_inputs = process_vision_info(messages)
|
||||||
|
inputs = self.processor(
|
||||||
|
text=[text], images=image_inputs, videos=video_inputs,
|
||||||
|
padding=True, return_tensors="pt",
|
||||||
|
).to(self.model.device)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
generated_ids = self.model.generate(**inputs, max_new_tokens=max_new_tokens)
|
||||||
|
out_ids = generated_ids[:, inputs.input_ids.shape[1]:]
|
||||||
|
output = self.processor.batch_decode(out_ids, skip_special_tokens=True)[0]
|
||||||
|
return {"text": output.strip(), "elapsed_s": time.time() - t0}
|
||||||
19
pipeline/persist.py
Normal file
19
pipeline/persist.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Sauvegarde JSON + journal d'exécution."""
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_OUT = Path("output/v2")
|
||||||
|
|
||||||
|
|
||||||
|
def save_result(result: dict, out_dir: Path | str = DEFAULT_OUT) -> Path:
|
||||||
|
out_dir = Path(out_dir)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
result["_meta"] = {
|
||||||
|
"pipeline_version": "v1",
|
||||||
|
"ocr_model": "zai-org/GLM-OCR",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
out_path = out_dir / f"{result['fichier']}.json"
|
||||||
|
out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return out_path
|
||||||
108
pipeline/prompts.py
Normal file
108
pipeline/prompts.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Prompts GLM-OCR par type de page OGC.
|
||||||
|
|
||||||
|
Pour chaque type structuré, on demande un JSON strict. Pour les pages libres
|
||||||
|
(concertation 2, éléments de preuve…) on sort du texte brut.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Prompt court pour détecter le type de page depuis l'en-tête.
|
||||||
|
PROMPT_HEADER = "Text Recognition:"
|
||||||
|
|
||||||
|
# --- Page 1 : Fiche médicale de recueil du praticien conseil ---
|
||||||
|
# Note : accord_desaccord retiré du prompt — géré par pipeline.checkboxes
|
||||||
|
# (densité de pixels) car GLM-OCR ne sait pas lire les checkboxes
|
||||||
|
# (cf. test_prompt_crop_v2.py).
|
||||||
|
#
|
||||||
|
SCHEMA_RECUEIL = """Lis la fiche médicale OGC et renvoie STRICTEMENT le JSON suivant, sans commentaire ni markdown.
|
||||||
|
Les codes CIM-10 sont au format lettre + 2 à 4 chiffres (ex: K650, T814).
|
||||||
|
Les codes CCAM sont au format 4 lettres + 3 chiffres (ex: EBFA012).
|
||||||
|
Les codes GHM sont au format 2 chiffres + lettre + 3 chiffres (ex: 11M122).
|
||||||
|
Les codes GHS sont des nombres à 3-5 chiffres (ex: 4323).
|
||||||
|
Si un champ est illisible, laisse une chaîne vide. Ne devine pas.
|
||||||
|
|
||||||
|
{
|
||||||
|
"etablissement": "",
|
||||||
|
"finess": "",
|
||||||
|
"date_debut_controle": "",
|
||||||
|
"n_ogc": "",
|
||||||
|
"n_champ": "",
|
||||||
|
"dates_sejour": "",
|
||||||
|
"sejour_etab": {
|
||||||
|
"age": "", "sexe": "", "duree_sejour": "",
|
||||||
|
"mode_entree": "", "provenance": "",
|
||||||
|
"mode_sortie": "", "destination": ""
|
||||||
|
},
|
||||||
|
"sejour_reco": {
|
||||||
|
"age": "", "sexe": "", "duree_sejour": "",
|
||||||
|
"mode_entree": "", "provenance": "",
|
||||||
|
"mode_sortie": "", "destination": ""
|
||||||
|
},
|
||||||
|
"rum_etab": {"um": "", "igs": "", "duree": "", "dates": ""},
|
||||||
|
"codage_etab": {
|
||||||
|
"dp": "", "dp_libelle": "", "dr": "",
|
||||||
|
"das": [{"code": "", "position": "", "libelle": ""}]
|
||||||
|
},
|
||||||
|
"codage_reco": {
|
||||||
|
"dp": "", "dr": "",
|
||||||
|
"das": [{"code": "", "position": ""}]
|
||||||
|
},
|
||||||
|
"actes_etab": [{"code": "", "position": "", "libelle": ""}],
|
||||||
|
"actes_reco": [{"code": "", "position": ""}],
|
||||||
|
"ghm_etab": "", "ghs_etab": "",
|
||||||
|
"ghm_reco": "", "ghs_reco": "",
|
||||||
|
"recodage_impactant": "",
|
||||||
|
"ghs_injustifie": "",
|
||||||
|
"praticien_conseil": ""
|
||||||
|
}"""
|
||||||
|
|
||||||
|
# --- 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.
|
||||||
|
Si un champ est illisible, laisse une chaîne vide.
|
||||||
|
|
||||||
|
{
|
||||||
|
"ghs_initial": "",
|
||||||
|
"ghs_avant_concertation": "",
|
||||||
|
"ghs_final": "",
|
||||||
|
"decision": "",
|
||||||
|
"date_concertation": "",
|
||||||
|
"praticien_controleur": "",
|
||||||
|
"medecin_dim": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Pour "decision", choisis UNIQUEMENT une de ces valeurs selon la case cochée :
|
||||||
|
- "maintien_avis_controleur" si "Maintien de l'avis initial" est coché
|
||||||
|
- "retour_groupage_dim" si "Retour groupage initial DIM" est coché
|
||||||
|
- "autre_groupage" si "Autre groupage" est coché
|
||||||
|
- "" si rien n'est coché"""
|
||||||
|
|
||||||
|
# --- Page 6 : Fiche administrative de concertation 1/2 (argumentaire) ---
|
||||||
|
SCHEMA_CONCERTATION_1 = """Lis la fiche d'argumentaire du médecin contrôleur et renvoie STRICTEMENT le JSON suivant, sans commentaire ni markdown.
|
||||||
|
|
||||||
|
{
|
||||||
|
"date_concertation": "",
|
||||||
|
"argumentaire": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Pour "argumentaire", transcris TOUT le paragraphe sous "ARGUMENTAIRE DU MEDECIN CONTROLEUR" tel quel, sans reformuler."""
|
||||||
|
|
||||||
|
# --- Page 4 : Éléments de preuve ---
|
||||||
|
SCHEMA_PREUVES = """Lis le tableau des éléments de preuve et renvoie STRICTEMENT le JSON suivant, sans commentaire ni markdown.
|
||||||
|
Pour chaque ligne, indique si la case "Présent" ou "Photocopié" est cochée (true/false).
|
||||||
|
|
||||||
|
{
|
||||||
|
"date": "",
|
||||||
|
"praticien_controleur": "",
|
||||||
|
"medecin_dim": "",
|
||||||
|
"pieces": [
|
||||||
|
{"intitule": "", "present": false, "photocopie": false, "absent_date": "", "date_obtention": ""}
|
||||||
|
]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
# Types de page reconnus
|
||||||
|
PAGE_TYPES = {
|
||||||
|
"recueil": {"keywords": ["RECUEIL DU PRATICIEN"], "prompt": SCHEMA_RECUEIL},
|
||||||
|
"concertation_2": {"keywords": ["CONCERTATION 2/2"], "prompt": SCHEMA_CONCERTATION_2},
|
||||||
|
"concertation_1": {"keywords": ["CONCERTATION 1/2"], "prompt": SCHEMA_CONCERTATION_1},
|
||||||
|
"preuves": {"keywords": ["ELEMENTS DE PREUVE", "PREUVE"], "prompt": SCHEMA_PREUVES},
|
||||||
|
"concertation_med": {"keywords": ["FICHE MEDICALE DE CONCERTATION"], "prompt": PROMPT_HEADER},
|
||||||
|
"hospitalisation":{"keywords": ["SEJOUR D'HOSPITALISATION", "HOSPITALISATION COMPLETE"], "prompt": PROMPT_HEADER},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user