Spaces:
Running
Running
File size: 10,298 Bytes
ea4c81b a77c861 ceb4ba7 ea4c81b a77c861 ea4c81b a77c861 ceb4ba7 ea4c81b a77c861 ea4c81b a77c861 ea4c81b eca43d9 ceb4ba7 a77c861 ceb4ba7 a77c861 ea4c81b a77c861 ea4c81b a77c861 ea4c81b a77c861 ea4c81b a77c861 ea4c81b a77c861 ea4c81b a77c861 ceb4ba7 a77c861 ceb4ba7 a77c861 ceb4ba7 a77c861 ceb4ba7 a77c861 ceb4ba7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | """Adaptateur OCR — Azure Document Intelligence (anciennement Form Recognizer).
Utilise l'API Azure Document Intelligence pour la reconnaissance de texte
dans des documents historiques.
Variables d'environnement requises :
- ``AZURE_DOC_INTEL_KEY`` : clé API Azure
- ``AZURE_DOC_INTEL_ENDPOINT`` : URL de l'endpoint (ex : https://moninstance.cognitiveservices.azure.com/)
Documentation : https://learn.microsoft.com/azure/ai-services/document-intelligence/
Sprint 51 — exposition des token_confidences
---------------------------------------------
La réponse Azure expose ``analyzeResult.pages[].words[]`` avec
``content`` et ``confidence`` (∈ [0, 1]). L'adapter parcourt cette
hiérarchie et émet une entrée par mot au format Sprint 42.
Le texte ``EngineResult.text`` est extrait depuis ``pages[].lines[]``
(préservation rétrocompat octet par octet). Les deux chemins (SDK et
REST) sont normalisés vers une représentation dict unifiée.
Refactor du chantier 1 (post-Sprint 97)
---------------------------------------
L'adapter ne surcharge plus ``run()`` — il implémente ``_run_with_native``
et ``_extract_raw_confidences`` (les hooks factorisés dans ``BaseOCREngine``).
Comportement externe et octets de sortie strictement identiques.
"""
from __future__ import annotations
import json
import logging
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Optional
from picarones.engines.base import BaseOCREngine
logger = logging.getLogger(__name__)
class AzureDocIntelEngine(BaseOCREngine):
"""Moteur OCR via Azure Document Intelligence.
Configuration
-------------
model_id : str
Modèle Azure à utiliser. Défaut : ``"prebuilt-read"`` (lecture générique).
Alternatives : ``"prebuilt-document"``, ``"prebuilt-layout"``
ou un modèle entraîné personnalisé.
locale : str
Paramètre de locale pour améliorer la précision (ex : ``"fr-FR"``).
api_version : str
Version de l'API Azure (défaut : ``"2024-02-29-preview"``).
expose_confidences : bool
``True`` (défaut) : extrait ``Word.confidence`` de la réponse
Azure (Sprint 51).
"""
@property
def name(self) -> str:
return "azure_doc_intel"
def version(self) -> str:
return self.config.get("api_version", "2024-02-29-preview")
def __init__(self, config: Optional[dict] = None) -> None:
super().__init__(config)
self._api_key = os.environ.get("AZURE_DOC_INTEL_KEY")
self._endpoint = (
os.environ.get("AZURE_DOC_INTEL_ENDPOINT", "").rstrip("/")
or self.config.get("endpoint", "").rstrip("/")
)
self._model_id: str = self.config.get("model_id", "prebuilt-read")
self._locale: str = self.config.get("locale", "fr-FR")
self._api_version: str = self.config.get("api_version", "2024-02-29-preview")
def _run_ocr(self, image_path: Path) -> str:
"""Retourne uniquement le texte (interface ``BaseOCREngine``)."""
text, _result = self._run_with_native(image_path)
return text
def _run_with_native(
self, image_path: Path,
) -> tuple[str, Optional[dict]]:
"""Exécute l'OCR et retourne ``(text, analyze_result_dict)``.
``analyze_result_dict`` est la sous-structure
``analyzeResult`` (avec ``pages[].words[]`` portant les
confidences) — normalisée entre les chemins SDK et REST.
"""
if not self._api_key:
raise RuntimeError(
"Clé API Azure manquante — définissez la variable d'environnement AZURE_DOC_INTEL_KEY"
)
if not self._endpoint:
raise RuntimeError(
"Endpoint Azure manquant — définissez la variable d'environnement AZURE_DOC_INTEL_ENDPOINT"
)
try:
return self._run_via_sdk(image_path)
except ImportError:
return self._run_via_rest(image_path)
def _run_via_sdk(self, image_path: Path) -> tuple[str, dict]:
from azure.ai.documentintelligence import DocumentIntelligenceClient
from azure.core.credentials import AzureKeyCredential
client = DocumentIntelligenceClient(
endpoint=self._endpoint,
credential=AzureKeyCredential(self._api_key),
)
with open(image_path, "rb") as f:
poller = client.begin_analyze_document(
model_id=self._model_id,
body=f,
locale=self._locale,
content_type="application/octet-stream",
)
result = poller.result()
text = "\n".join(
line.content
for page in result.pages
for line in (page.lines or [])
)
analyze_result = self._sdk_result_to_dict(result)
return text, analyze_result
def _run_via_rest(self, image_path: Path) -> tuple[str, Optional[dict]]:
"""Appel REST direct (sans SDK Azure)."""
image_bytes = image_path.read_bytes()
analyze_url = (
f"{self._endpoint}/documentintelligence/documentModels/"
f"{self._model_id}:analyze"
f"?api-version={self._api_version}&locale={self._locale}"
)
# Soumettre l'image
req = urllib.request.Request(
analyze_url,
data=image_bytes,
headers={
"Ocp-Apim-Subscription-Key": self._api_key,
"Content-Type": "application/octet-stream",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
operation_url = resp.headers.get("Operation-Location", "")
except urllib.error.HTTPError as exc:
raise RuntimeError(
f"Azure Document Intelligence erreur {exc.code}: {exc.read().decode()}"
) from exc
if not operation_url:
raise RuntimeError("Azure : pas d'Operation-Location dans la réponse")
# Polling du résultat (Azure est asynchrone)
headers = {"Ocp-Apim-Subscription-Key": self._api_key}
for attempt in range(30):
time.sleep(1 + attempt * 0.5)
poll_req = urllib.request.Request(operation_url, headers=headers)
with urllib.request.urlopen(poll_req, timeout=30) as resp:
result = json.loads(resp.read().decode("utf-8"))
status = result.get("status", "")
if status == "succeeded":
text = self._extract_text_from_result(result)
analyze_result = result.get("analyzeResult") or None
return text, analyze_result
if status in {"failed", "canceled"}:
raise RuntimeError(f"Azure Document Intelligence : analyse {status}")
# status == "running" → continuer à attendre
raise RuntimeError("Azure Document Intelligence : timeout — analyse trop longue")
@staticmethod
def _extract_text_from_result(result: dict) -> str:
"""Extrait le texte brut depuis la réponse JSON Azure."""
pages = result.get("analyzeResult", {}).get("pages", [])
lines: list[str] = []
for page in pages:
for line in page.get("lines", []):
content = line.get("content", "")
if content:
lines.append(content)
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────
# Conversion SDK → dict normalisé
# ──────────────────────────────────────────────────────────────────
@staticmethod
def _sdk_result_to_dict(result: Any) -> dict:
"""Convertit l'objet SDK en dict ``{"pages": [{"words":
[{"content", "confidence"}]}]}`` pour traitement uniforme avec
le chemin REST."""
pages = []
for page in getattr(result, "pages", []) or []:
words = []
for word in getattr(page, "words", []) or []:
content = getattr(word, "content", "") or ""
conf = getattr(word, "confidence", None)
words.append({
"content": content,
"confidence": float(conf) if conf is not None else None,
})
pages.append({"words": words})
return {"pages": pages}
# ──────────────────────────────────────────────────────────────────
# Extraction des token_confidences au format Sprint 42
# ──────────────────────────────────────────────────────────────────
def _extract_raw_confidences(
self, native: Any,
) -> Optional[list[dict[str, Any]]]:
"""Parcourt ``pages[].words[]`` et émet
``{"token": str, "confidence": float}`` par mot.
Filtrage cohérent avec les autres adapters : confidence None /
négative ignorée, contenu vide ignoré (filtrage final assuré
par ``BaseOCREngine._normalize_token_confidences``).
"""
if not self.config.get("expose_confidences", True):
return None
if not native or not isinstance(native, dict):
return None
out: list[dict[str, Any]] = []
for page in native.get("pages") or []:
if not isinstance(page, dict):
continue
for word in page.get("words") or []:
if not isinstance(word, dict):
continue
content = (word.get("content") or "").strip()
conf = word.get("confidence")
if not content or conf is None:
continue
out.append({"token": content, "confidence": conf})
return out or None
|