feat(ocr): migrer l'OCR de docTR (PyTorch) vers OnnxTR (ONNX Runtime)

OnnxTR exécute les MÊMES modèles que docTR (db_resnet50 + crnn_vgg16_bn) sur
ONNX Runtime, sans PyTorch. Corrige le crash torch/oneDNN « could not create a
primitive » sur CPU contraint (VM 2 cœurs collaborateur : OCR scan impossible →
quarantaine). Qualité identique validée empiriquement (CER 0,10-0,23 % vs docTR,
2 validations indépendantes Claude+Qwen), OCR ~2-3× plus rapide CPU.

- core : import OnnxTR, _get_ocr_model(), _OCR_AVAILABLE, boucle OCR inchangée
  (API miroir) ; ONNXTR_CACHE_DIR pour le frozen ; bandeau de logs ENV au démarrage
  (OS, CPU+AVX, cœurs, RAM, versions, providers) pour retours terrain auto-suffisants.
- 3 .spec : embarquent les poids ONNX OnnxTR (fail-closed) + hiddenimports onnxtr.
- requirements : onnxtr[cpu] (python-doctr conservé transitoirement).
- inclut le correctif quarantaine-visible du runner (GO Qwen).

Tests : test_ocr_onnxtr.py (RED→GREEN), 95 unit passed, e2e scan client OK
(OCR 5/5, PDF produit, plus de crash). Retrait torch du frozen + rebuild Windows
= étapes suivantes (gates Dom).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 17:07:00 +02:00
parent 80d8cc230b
commit 8d683bc6d8
8 changed files with 323 additions and 21 deletions

View File

@@ -51,6 +51,42 @@ def default_output_dir(input_path) -> Path:
return base / "anonymise"
def _delivered_pdf_paths(result: object) -> list[Path]:
"""Retourne les PDF effectivement produits par le moteur.
Le moteur retourne toujours des clés ``pdf_*`` pour une sortie livrable.
Les tests unitaires historiques injectent souvent ``{}`` comme succès factice ;
on ne les assimile donc pas à un échec ici.
"""
if not isinstance(result, dict):
return []
paths: list[Path] = []
for key, value in result.items():
if not str(key).startswith("pdf") or not isinstance(value, (str, Path)):
continue
path = Path(value)
if path.exists() and path.is_file():
paths.append(path)
return paths
def _engine_result_error(result: object) -> str | None:
"""Traduit un retour moteur non livrable en erreur visible GUI."""
if not isinstance(result, dict):
return None
if result.get("status") == "quarantined":
reason = result.get("reason") or "document mis en quarantaine"
return f"Document mis en quarantaine : {reason}"
has_real_engine_outputs = (
"text" in result
or "audit" in result
or any(str(key).startswith("pdf") for key in result)
)
if has_real_engine_outputs and not _delivered_pdf_paths(result):
return "Aucune sortie PDF anonymisée produite."
return None
def discover_documents(input_path, extensions: Optional[Sequence[str]] = None) -> list[Path]:
"""Liste les documents à traiter (fichier unique ou dossier récursif)."""
path = Path(input_path)
@@ -176,7 +212,10 @@ class ProcessingRunner:
else:
doc_out = out_root
doc_out.mkdir(parents=True, exist_ok=True)
self._process_fn(doc, doc_out)
result = self._process_fn(doc, doc_out)
result_error = _engine_result_error(result)
if result_error is not None:
raise RuntimeError(result_error)
summary.succeeded += 1
log(f"OK : {doc.name}")
except Exception as exc: # un échec n'interrompt pas le lot