Spaces:
Running
feat(adapters): Sprint A14-S26 — BaseOCRAdapter natif + PrecomputedTextAdapter
Browse filesPremier adapter du nouveau monde, écrit en partant de zéro selon le
contrat propre du rewrite — pas de shim sur le legacy
``picarones.engines.base.BaseOCREngine``, pas de dette technique.
Pourquoi pas de shim
--------------------
Le legacy ``BaseOCREngine`` a un contrat historique
(``run(image_path) → EngineResult``, ``ArtifactType.TEXT``,
``EngineResult`` wrapper) qui ne correspond pas au contrat propre
du nouveau monde (``execute(inputs, params, context) →
dict[ArtifactType, Artifact]``, ``ArtifactType.RAW_TEXT``,
exceptions directes). Pontuiller via un adapter d'adapter
introduirait de la dette : chaque divergence de comportement
entre legacy et nouveau créerait des cas spéciaux.
Le rewrite écrit les adapters OCR depuis zéro, sprint par sprint,
chacun natif au nouveau contrat. Le legacy reste utilisable via
la CLI legacy historique tant qu'il a des consommateurs.
Contrat ``BaseOCRAdapter`` (``picarones/adapters/ocr/base.py``)
--------------------------------------------------------------
ABC du nouveau monde :
- Propriété abstraite ``name`` (identifiant lisible).
- Méthode abstraite
``execute(inputs, params, context) → dict[ArtifactType, Artifact]``.
- Attributs de classe par défaut : ``input_types`` (IMAGE),
``output_types`` (RAW_TEXT), ``execution_mode`` ("io"). Une
sous-classe surcharge si besoin (ex : moteur structuré qui
produit IMAGE → ALTO_XML).
- Erreur typée ``OCRAdapterError`` que le ``PipelineExecutor``
capture en step en échec.
Pas hérité de ``BaseModule`` (legacy core) — le rewrite redéfinit
ses propres abstractions.
Premier adapter livré : ``PrecomputedTextAdapter``
--------------------------------------------------
Lit du texte OCR pré-calculé depuis le filesystem selon une
convention de nommage :
::
folio_001.png
folio_001.tesseract.txt # source 1
folio_001.gpt4v.txt # source 2
folio_001.gt.txt # vérité terrain
Cas d'usage BnF concret : *« j'ai déjà fait tourner Tesseract,
GPT-4v, Pero OCR et un service cloud sur mon corpus. J'ai 4
répertoires de fichiers .txt à côté de mes images. Je veux
comparer ces 4 sorties dans Picarones — je n'ai pas besoin de
re-lancer un OCR. »*
Plusieurs ``PrecomputedTextAdapter`` peuvent coexister dans une
même YAML avec des ``source_label`` distincts ; chacun lit son
propre fichier.
Politique sur fichier manquant :
- ``"raise"`` (défaut) → ``OCRAdapterError``, le step est marqué
failed pour ce document, le benchmark continue.
- ``"empty"`` → fichier vide créé pour cohérence (toujours une
``Artifact.uri`` lisible) ; permet de mesurer ce que produirait
une source partiellement disponible.
Validation stricte du ``source_label`` (alphanumérique + ``_``
``-``), refus encodage non-UTF-8.
Bug fix annexe ``_build_pipelines``
-----------------------------------
La résolution d'``adapter_name`` dans ``picarones/app/cli/run.py``
disambiguait sur la classe seule. Or 3 ``PrecomputedTextAdapter``
instanciés avec des ``source_label`` différents partageaient la
**même** instance d'adapter (cache par nom de step partagé) — toutes
les pipelines recevaient le texte de la première source.
Fix : disambiguation sur ``(class, kwargs_signature)``. Deux
steps avec les mêmes class+kwargs partagent l'instance ; deux
steps avec class identique mais kwargs différents reçoivent des
``adapter_name`` distincts (préfixés par le nom de pipeline).
Tests (20 nouveaux)
-------------------
``tests/adapters/test_sprint_a14_s26_ocr_adapter.py`` :
- **Contrat ``BaseOCRAdapter``** (3 tests) : instanciation directe
rejetée, sous-classe minimale fonctionne, override des
attributs IO mode / types fonctionne.
- **Validation ``PrecomputedTextAdapter`` à l'init** (6 tests) :
source_label vide/whitespace/chars invalides rejetés ; libellés
valides acceptés ; missing_text_policy invalide rejeté ;
défaut ``"raise"``.
- **Exécution** (8 tests) : lecture par convention, fichier
manquant → raise (défaut), policy ``"empty"`` crée fichier vide,
encodage non-UTF-8 rejeté, IMAGE manquant / sans URI rejetés,
isolation 2 sources dans même répertoire, extensions
``.png/.jpg/.jpeg/.tif/.tiff`` toutes gérées.
- **Pipeline executor** (1 test) : adapter consommé directement
par ``PipelineExecutor.run`` — preuve que le contrat
``BaseOCRAdapter`` satisfait le ``StepExecutor``.
- **CLI E2E BnF** (2 tests) : YAML déclarant 3 sources
pré-calculées (tesseract / gpt4v / pero) → 3 pipelines exécutés
via ``picarones-rewrite run`` → 6 ViewResults dans TextView
avec gradient de CER (tesseract 0 < gpt4v < pero) ; cas fichier
manquant produit step en échec sans crash global.
554 tests sprint_a14 passent (534 S1-S25 + 20 S26).
L'utilisateur BnF peut désormais :
``picarones-rewrite run --spec my_run.yaml`` avec ses propres
sorties OCR déjà calculées, **sans aucun OCR engine installé**,
et obtenir un rapport HTML comparant N transcriptions.
https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP
- picarones/adapters/ocr/__init__.py +23 -10
- picarones/adapters/ocr/base.py +167 -0
- picarones/adapters/ocr/precomputed.py +219 -0
- picarones/app/cli/run.py +28 -8
- tests/adapters/__init__.py +0 -0
- tests/adapters/test_sprint_a14_s26_ocr_adapter.py +533 -0
|
@@ -1,16 +1,29 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
``picarones.engines.
|
| 5 |
-
|
| 6 |
-
``
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapters OCR du nouveau monde — Sprint A14-S26.
|
| 2 |
|
| 3 |
+
Contrat ``BaseOCRAdapter`` natif au rewrite : pas hérité du legacy
|
| 4 |
+
``picarones.engines.base.BaseOCREngine``, exprimé directement en
|
| 5 |
+
termes du nouveau ``ArtifactType`` et de l'interface
|
| 6 |
+
``execute(inputs, params, context)`` du ``PipelineExecutor``.
|
| 7 |
|
| 8 |
+
Implémentations livrées
|
| 9 |
+
-----------------------
|
| 10 |
+
- ``PrecomputedTextAdapter`` — lit un texte OCR pré-calculé depuis
|
| 11 |
+
le filesystem. Cas BnF : comparer N transcriptions déjà produites
|
| 12 |
+
par d'autres outils sans relancer d'OCR.
|
| 13 |
+
|
| 14 |
+
Adapters concrets pour Tesseract / Pero OCR / Mistral OCR / Google
|
| 15 |
+
Vision / Azure DI : à écrire au cas par cas dans des sprints
|
| 16 |
+
dédiés, **natifs** au nouveau contrat (pas de shim sur le legacy
|
| 17 |
+
``picarones.engines``).
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
+
from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
|
| 23 |
+
from picarones.adapters.ocr.precomputed import PrecomputedTextAdapter
|
| 24 |
+
|
| 25 |
+
__all__ = [
|
| 26 |
+
"BaseOCRAdapter",
|
| 27 |
+
"OCRAdapterError",
|
| 28 |
+
"PrecomputedTextAdapter",
|
| 29 |
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``BaseOCRAdapter`` — contrat natif du nouveau monde pour un adapter OCR.
|
| 2 |
+
|
| 3 |
+
Sprint A14-S26 du rewrite ciblé.
|
| 4 |
+
|
| 5 |
+
Ce module définit le contrat **propre** auquel un adapter OCR du
|
| 6 |
+
nouveau monde doit se conformer pour être utilisable comme step
|
| 7 |
+
d'une pipeline ``picarones.pipeline``. Pas hérité du legacy
|
| 8 |
+
``picarones.engines.base.BaseOCREngine`` — c'est un nouveau contrat,
|
| 9 |
+
sans dette technique, exprimé en termes du nouveau ``ArtifactType``.
|
| 10 |
+
|
| 11 |
+
Contrat
|
| 12 |
+
-------
|
| 13 |
+
Un adapter OCR :
|
| 14 |
+
|
| 15 |
+
- Déclare ses ``input_types`` (typiquement
|
| 16 |
+
``frozenset({ArtifactType.IMAGE})``).
|
| 17 |
+
- Déclare ses ``output_types`` (typiquement
|
| 18 |
+
``frozenset({ArtifactType.RAW_TEXT})``, ou plus pour les moteurs
|
| 19 |
+
structurés).
|
| 20 |
+
- Déclare son ``execution_mode`` : ``"io"`` (défaut, ThreadPool) ou
|
| 21 |
+
``"cpu"`` (ProcessPool).
|
| 22 |
+
- Implémente
|
| 23 |
+
``execute(inputs, params, context) -> dict[ArtifactType, Artifact]``.
|
| 24 |
+
|
| 25 |
+
Le ``Artifact`` retourné porte une ``uri`` filesystem — c'est la
|
| 26 |
+
convention du nouveau monde pour permettre au ``payload_loader`` de
|
| 27 |
+
le lire ultérieurement (Sprint S25 — la projection a un payload
|
| 28 |
+
direct, mais les artefacts produits par les adapters sont stockés
|
| 29 |
+
sur disque pour traçabilité et streaming).
|
| 30 |
+
|
| 31 |
+
Différences avec le legacy
|
| 32 |
+
--------------------------
|
| 33 |
+
- ``ArtifactType.RAW_TEXT`` (10 valeurs) au lieu de
|
| 34 |
+
``ArtifactType.TEXT`` (6 valeurs legacy).
|
| 35 |
+
- Pas de ``run(image_path)`` historique — un seul point d'entrée
|
| 36 |
+
``execute()``.
|
| 37 |
+
- Pas de wrapper ``EngineResult`` — les erreurs lèvent directement,
|
| 38 |
+
le ``PipelineExecutor`` les capture en step en échec.
|
| 39 |
+
- Pas de ``_run_ocr`` / ``_run_with_native`` / ``_extract_raw_confidences``
|
| 40 |
+
— les confidences (S42 legacy) sont reportées à un sprint dédié
|
| 41 |
+
où l'on définira un ``ConfidenceArtifact`` typé.
|
| 42 |
+
|
| 43 |
+
Anti-sur-ingénierie
|
| 44 |
+
-------------------
|
| 45 |
+
- Pas de hiérarchie d'erreurs. Un adapter qui échoue lève
|
| 46 |
+
``OCRAdapterError`` (ou laisse passer une exception). Le
|
| 47 |
+
``PipelineExecutor`` (S7) catch et marque le step en échec.
|
| 48 |
+
- Pas de cache au niveau de l'ABC. Si un adapter veut cacher ses
|
| 49 |
+
résultats, c'est dans son implémentation (compose ``ArtifactStore``
|
| 50 |
+
S7 si besoin).
|
| 51 |
+
- Pas de retry. Idem.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
from __future__ import annotations
|
| 55 |
+
|
| 56 |
+
from abc import ABC, abstractmethod
|
| 57 |
+
from typing import Any
|
| 58 |
+
|
| 59 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class OCRAdapterError(Exception):
|
| 63 |
+
"""Erreur typée pour un échec d'adapter OCR du nouveau monde.
|
| 64 |
+
|
| 65 |
+
Le ``PipelineExecutor`` capture cette exception (et toute autre)
|
| 66 |
+
et marque le step correspondant comme failed avec
|
| 67 |
+
``StepResult.error`` renseigné. Les callers downstream
|
| 68 |
+
(``BenchmarkService``, vues) verront le pipeline en échec sans
|
| 69 |
+
crash global.
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class BaseOCRAdapter(ABC):
|
| 74 |
+
"""Classe de base pour un adapter OCR du nouveau monde.
|
| 75 |
+
|
| 76 |
+
Toute sous-classe doit :
|
| 77 |
+
|
| 78 |
+
1. Surcharger la propriété ``name`` (identifiant lisible, utilisé
|
| 79 |
+
dans les ``Artifact.id`` et le run_manifest).
|
| 80 |
+
2. Implémenter ``execute(inputs, params, context)``.
|
| 81 |
+
|
| 82 |
+
Les attributs de classe ``input_types`` / ``output_types`` /
|
| 83 |
+
``execution_mode`` sont fournis par défaut pour le cas le plus
|
| 84 |
+
courant (image → texte, IO-bound). Une sous-classe qui produit
|
| 85 |
+
de l'ALTO surcharge ``output_types``, etc.
|
| 86 |
+
|
| 87 |
+
Exemple
|
| 88 |
+
-------
|
| 89 |
+
|
| 90 |
+
::
|
| 91 |
+
|
| 92 |
+
class MyOCRAdapter(BaseOCRAdapter):
|
| 93 |
+
@property
|
| 94 |
+
def name(self) -> str:
|
| 95 |
+
return "my_ocr"
|
| 96 |
+
|
| 97 |
+
def execute(self, inputs, params, context):
|
| 98 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 99 |
+
# ... appel OCR sur image_artifact.uri ...
|
| 100 |
+
# ... écriture du résultat sur disque ...
|
| 101 |
+
return {
|
| 102 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 103 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 104 |
+
document_id=context.document_id,
|
| 105 |
+
type=ArtifactType.RAW_TEXT,
|
| 106 |
+
produced_by_step="ocr",
|
| 107 |
+
uri=str(out_path),
|
| 108 |
+
),
|
| 109 |
+
}
|
| 110 |
+
"""
|
| 111 |
+
|
| 112 |
+
#: Types d'artefacts attendus en entrée. Le ``PipelineExecutor``
|
| 113 |
+
#: utilise cette info pour valider la compatibilité des steps
|
| 114 |
+
#: enchaînés.
|
| 115 |
+
input_types: frozenset[ArtifactType] = frozenset({ArtifactType.IMAGE})
|
| 116 |
+
|
| 117 |
+
#: Types d'artefacts produits. Validés à la sortie de ``execute``.
|
| 118 |
+
output_types: frozenset[ArtifactType] = frozenset({ArtifactType.RAW_TEXT})
|
| 119 |
+
|
| 120 |
+
#: ``"io"`` (ThreadPool) ou ``"cpu"`` (ProcessPool). Indique au
|
| 121 |
+
#: runner quel type de pool utiliser pour la concurrence.
|
| 122 |
+
execution_mode: str = "io"
|
| 123 |
+
|
| 124 |
+
@property
|
| 125 |
+
@abstractmethod
|
| 126 |
+
def name(self) -> str:
|
| 127 |
+
"""Identifiant lisible de l'adapter (ex : ``"tesseract"``,
|
| 128 |
+
``"precomputed_text"``). Utilisé dans les ``Artifact.id`` du
|
| 129 |
+
nouveau monde et dans le ``run_manifest``."""
|
| 130 |
+
|
| 131 |
+
@abstractmethod
|
| 132 |
+
def execute(
|
| 133 |
+
self,
|
| 134 |
+
inputs: dict[ArtifactType, Artifact],
|
| 135 |
+
params: dict[str, Any],
|
| 136 |
+
context: Any,
|
| 137 |
+
) -> dict[ArtifactType, Artifact]:
|
| 138 |
+
"""Exécute l'OCR sur les entrées et retourne les artefacts produits.
|
| 139 |
+
|
| 140 |
+
Parameters
|
| 141 |
+
----------
|
| 142 |
+
inputs:
|
| 143 |
+
Map ``ArtifactType → Artifact`` avec au minimum les types
|
| 144 |
+
déclarés dans ``self.input_types``. L'adapter peut
|
| 145 |
+
ignorer les entrées surnuméraires.
|
| 146 |
+
params:
|
| 147 |
+
Paramètres dynamiques du step (typiquement vides — la
|
| 148 |
+
configuration de l'adapter passe par son constructeur).
|
| 149 |
+
context:
|
| 150 |
+
``RunContext`` du run en cours (porte ``document_id``,
|
| 151 |
+
``code_version``, ``pipeline_name``).
|
| 152 |
+
|
| 153 |
+
Returns
|
| 154 |
+
-------
|
| 155 |
+
dict[ArtifactType, Artifact]
|
| 156 |
+
Map des artefacts produits. Doit contenir au moins les
|
| 157 |
+
types déclarés dans ``self.output_types``.
|
| 158 |
+
|
| 159 |
+
Raises
|
| 160 |
+
------
|
| 161 |
+
OCRAdapterError
|
| 162 |
+
Erreur typée pour signaler un échec côté adapter (input
|
| 163 |
+
invalide, fichier introuvable, etc.).
|
| 164 |
+
"""
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
__all__ = ["BaseOCRAdapter", "OCRAdapterError"]
|
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PrecomputedTextAdapter`` — premier adapter natif du nouveau monde.
|
| 2 |
+
|
| 3 |
+
Sprint A14-S26 du rewrite ciblé.
|
| 4 |
+
|
| 5 |
+
Cas d'usage BnF
|
| 6 |
+
---------------
|
| 7 |
+
*« J'ai déjà fait tourner Tesseract, GPT-4-vision, Pero OCR et un
|
| 8 |
+
service cloud sur mon corpus. J'ai 4 répertoires de fichiers
|
| 9 |
+
``.txt`` à côté de mes images. Je veux comparer ces 4 sorties dans
|
| 10 |
+
Picarones — je n'ai pas besoin de re-lancer un OCR, j'ai juste besoin
|
| 11 |
+
de la machinerie d'évaluation. »*
|
| 12 |
+
|
| 13 |
+
Ce besoin est légitime et fréquent à la BnF : une part importante
|
| 14 |
+
du travail de comparaison se fait sur des transcriptions déjà
|
| 15 |
+
produites par d'autres outils. Ré-exécuter un OCR à chaque
|
| 16 |
+
benchmark est gaspillage.
|
| 17 |
+
|
| 18 |
+
Convention de nommage
|
| 19 |
+
---------------------
|
| 20 |
+
Pour une image ``<stem>.png`` (ou ``.jpg``, ``.tif``, etc.), le
|
| 21 |
+
texte pré-calculé est lu depuis :
|
| 22 |
+
|
| 23 |
+
::
|
| 24 |
+
|
| 25 |
+
<stem>.<source_label>.txt
|
| 26 |
+
|
| 27 |
+
dans le **même répertoire** que l'image. Exemple avec deux
|
| 28 |
+
sources concurrentes :
|
| 29 |
+
|
| 30 |
+
::
|
| 31 |
+
|
| 32 |
+
folio_001.png
|
| 33 |
+
folio_001.tesseract.txt # produit par Tesseract
|
| 34 |
+
folio_001.pero.txt # produit par Pero OCR
|
| 35 |
+
folio_001.gpt4v.txt # produit par GPT-4 Vision
|
| 36 |
+
folio_001.gt.txt # vérité terrain
|
| 37 |
+
|
| 38 |
+
Plusieurs ``PrecomputedTextAdapter`` peuvent coexister dans une
|
| 39 |
+
même YAML avec des ``source_label`` distincts — chacun lit son
|
| 40 |
+
propre fichier, le ``BenchmarkService`` les traite en parallèle.
|
| 41 |
+
|
| 42 |
+
Configuration YAML
|
| 43 |
+
------------------
|
| 44 |
+
|
| 45 |
+
::
|
| 46 |
+
|
| 47 |
+
pipelines:
|
| 48 |
+
- name: tesseract_baseline
|
| 49 |
+
initial_inputs: [image]
|
| 50 |
+
steps:
|
| 51 |
+
- id: ocr
|
| 52 |
+
adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
|
| 53 |
+
adapter_kwargs:
|
| 54 |
+
source_label: tesseract
|
| 55 |
+
input_types: [image]
|
| 56 |
+
output_types: [raw_text]
|
| 57 |
+
|
| 58 |
+
- name: gpt4v_alternative
|
| 59 |
+
initial_inputs: [image]
|
| 60 |
+
steps:
|
| 61 |
+
- id: ocr
|
| 62 |
+
adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
|
| 63 |
+
adapter_kwargs:
|
| 64 |
+
source_label: gpt4v
|
| 65 |
+
input_types: [image]
|
| 66 |
+
output_types: [raw_text]
|
| 67 |
+
|
| 68 |
+
Comportement « fichier manquant »
|
| 69 |
+
---------------------------------
|
| 70 |
+
Par défaut, si le fichier ``<stem>.<source_label>.txt`` est absent,
|
| 71 |
+
l'adapter lève ``OCRAdapterError`` — le pipeline executor marque le
|
| 72 |
+
step comme failed pour ce document, et le ``BenchmarkService`` le
|
| 73 |
+
voit en ``failed_metrics``. Pas de fallback silencieux qui
|
| 74 |
+
mentirait sur la couverture du benchmark.
|
| 75 |
+
|
| 76 |
+
L'option ``missing_text_policy="empty"`` permet, à la demande
|
| 77 |
+
explicite du caller, de remplacer un fichier absent par une chaîne
|
| 78 |
+
vide — utile pour mesurer ce qui se passerait si une source était
|
| 79 |
+
indisponible sur certains documents. Par défaut : ``"raise"``.
|
| 80 |
+
|
| 81 |
+
Anti-sur-ingénierie
|
| 82 |
+
-------------------
|
| 83 |
+
- Pas de découverte automatique de tous les ``source_label``
|
| 84 |
+
présents dans un répertoire. Le caller déclare explicitement
|
| 85 |
+
les sources qu'il veut comparer.
|
| 86 |
+
- Pas de cache. Le filesystem fait son boulot.
|
| 87 |
+
- Pas de validation d'encodage exotique. ``utf-8`` strict ; un
|
| 88 |
+
fichier mal encodé lève une erreur lisible.
|
| 89 |
+
- Pas d'extraction structurelle. Cet adapter sort du ``RAW_TEXT``,
|
| 90 |
+
point. Pour comparer des ALTO_XML pré-calculés, c'est un
|
| 91 |
+
``PrecomputedAltoAdapter`` futur (pattern identique).
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
+
from __future__ import annotations
|
| 95 |
+
|
| 96 |
+
from pathlib import Path
|
| 97 |
+
from typing import Any, Literal
|
| 98 |
+
|
| 99 |
+
from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
|
| 100 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class PrecomputedTextAdapter(BaseOCRAdapter):
|
| 104 |
+
"""Adapter qui lit du texte OCR pré-calculé depuis le filesystem.
|
| 105 |
+
|
| 106 |
+
Parameters
|
| 107 |
+
----------
|
| 108 |
+
source_label:
|
| 109 |
+
Étiquette identifiant la source du texte pré-calculé
|
| 110 |
+
(ex : ``"tesseract"``, ``"gpt4v"``, ``"pero"``). Doit être
|
| 111 |
+
composée uniquement de caractères alphanumériques, ``_`` et
|
| 112 |
+
``-`` — c'est un composant de nom de fichier.
|
| 113 |
+
missing_text_policy:
|
| 114 |
+
``"raise"`` (défaut) → fichier absent lève ``OCRAdapterError``.
|
| 115 |
+
``"empty"`` → fichier absent remplacé par chaîne vide
|
| 116 |
+
(l'adapter produit alors un ``Artifact`` pointant sur un
|
| 117 |
+
fichier vide).
|
| 118 |
+
|
| 119 |
+
Raises
|
| 120 |
+
------
|
| 121 |
+
OCRAdapterError
|
| 122 |
+
Si ``source_label`` est invalide.
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 126 |
+
output_types = frozenset({ArtifactType.RAW_TEXT})
|
| 127 |
+
execution_mode = "io"
|
| 128 |
+
|
| 129 |
+
def __init__(
|
| 130 |
+
self,
|
| 131 |
+
*,
|
| 132 |
+
source_label: str,
|
| 133 |
+
missing_text_policy: Literal["raise", "empty"] = "raise",
|
| 134 |
+
) -> None:
|
| 135 |
+
if not source_label or not source_label.strip():
|
| 136 |
+
raise OCRAdapterError(
|
| 137 |
+
"PrecomputedTextAdapter : source_label vide.",
|
| 138 |
+
)
|
| 139 |
+
if not all(
|
| 140 |
+
c.isalnum() or c in "_-" for c in source_label
|
| 141 |
+
):
|
| 142 |
+
raise OCRAdapterError(
|
| 143 |
+
f"PrecomputedTextAdapter : source_label invalide "
|
| 144 |
+
f"{source_label!r} — alphanumérique + _ - uniquement.",
|
| 145 |
+
)
|
| 146 |
+
if missing_text_policy not in ("raise", "empty"):
|
| 147 |
+
raise OCRAdapterError(
|
| 148 |
+
f"missing_text_policy doit être 'raise' ou 'empty', "
|
| 149 |
+
f"reçu {missing_text_policy!r}.",
|
| 150 |
+
)
|
| 151 |
+
self._source_label = source_label
|
| 152 |
+
self._missing_policy = missing_text_policy
|
| 153 |
+
|
| 154 |
+
@property
|
| 155 |
+
def name(self) -> str:
|
| 156 |
+
return f"precomputed_{self._source_label}"
|
| 157 |
+
|
| 158 |
+
@property
|
| 159 |
+
def source_label(self) -> str:
|
| 160 |
+
return self._source_label
|
| 161 |
+
|
| 162 |
+
def execute(
|
| 163 |
+
self,
|
| 164 |
+
inputs: dict[ArtifactType, Artifact],
|
| 165 |
+
params: dict[str, Any],
|
| 166 |
+
context: Any,
|
| 167 |
+
) -> dict[ArtifactType, Artifact]:
|
| 168 |
+
if ArtifactType.IMAGE not in inputs:
|
| 169 |
+
raise OCRAdapterError(
|
| 170 |
+
f"{self.name} : input IMAGE manquant.",
|
| 171 |
+
)
|
| 172 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 173 |
+
if image_artifact.uri is None:
|
| 174 |
+
raise OCRAdapterError(
|
| 175 |
+
f"{self.name} : artefact image "
|
| 176 |
+
f"{image_artifact.id!r} sans URI.",
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
image_path = Path(image_artifact.uri)
|
| 180 |
+
text_path = (
|
| 181 |
+
image_path.parent / f"{image_path.stem}.{self._source_label}.txt"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
if not text_path.exists():
|
| 185 |
+
if self._missing_policy == "empty":
|
| 186 |
+
# On crée le fichier vide pour rester cohérent : tout
|
| 187 |
+
# ``Artifact`` produit a une URI vers un fichier
|
| 188 |
+
# lisible.
|
| 189 |
+
text_path.write_text("", encoding="utf-8")
|
| 190 |
+
else:
|
| 191 |
+
raise OCRAdapterError(
|
| 192 |
+
f"{self.name} : fichier pré-calculé introuvable "
|
| 193 |
+
f"pour {image_path.name!r} : "
|
| 194 |
+
f"{text_path.name!r} attendu dans "
|
| 195 |
+
f"{image_path.parent!r}.",
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Validation rapide de l'encodage UTF-8 (lecture qui leverait
|
| 199 |
+
# si encodage exotique).
|
| 200 |
+
try:
|
| 201 |
+
text_path.read_text(encoding="utf-8")
|
| 202 |
+
except UnicodeDecodeError as exc:
|
| 203 |
+
raise OCRAdapterError(
|
| 204 |
+
f"{self.name} : {text_path!r} n'est pas en UTF-8 : "
|
| 205 |
+
f"{exc}",
|
| 206 |
+
) from exc
|
| 207 |
+
|
| 208 |
+
return {
|
| 209 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 210 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 211 |
+
document_id=context.document_id,
|
| 212 |
+
type=ArtifactType.RAW_TEXT,
|
| 213 |
+
produced_by_step="ocr",
|
| 214 |
+
uri=str(text_path),
|
| 215 |
+
),
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
__all__ = ["PrecomputedTextAdapter"]
|
|
@@ -248,26 +248,46 @@ def _build_pipelines(spec: RunSpec) -> tuple[
|
|
| 248 |
"""Construit les ``PipelineSpec`` + un ``adapter_resolver`` qui
|
| 249 |
instancie les adapters au besoin.
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
"""
|
| 254 |
instance_cache: dict[str, object] = {}
|
|
|
|
|
|
|
|
|
|
| 255 |
name_to_class: dict[str, type] = {}
|
| 256 |
name_to_kwargs: dict[str, dict] = {}
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
pipeline_specs: list[PipelineSpec] = []
|
| 259 |
for p in spec.pipelines:
|
| 260 |
steps = []
|
| 261 |
for s in p.steps:
|
| 262 |
cls = resolve_adapter_class(s.adapter_class)
|
|
|
|
| 263 |
adapter_name = s.id
|
| 264 |
-
# Si le
|
| 265 |
-
#
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
):
|
| 270 |
adapter_name = f"{p.name}__{s.id}"
|
|
|
|
| 271 |
name_to_class[adapter_name] = cls
|
| 272 |
name_to_kwargs[adapter_name] = s.adapter_kwargs
|
| 273 |
steps.append(PipelineStep(
|
|
|
|
| 248 |
"""Construit les ``PipelineSpec`` + un ``adapter_resolver`` qui
|
| 249 |
instancie les adapters au besoin.
|
| 250 |
|
| 251 |
+
Disambiguation des steps :
|
| 252 |
+
|
| 253 |
+
- Deux steps qui ont la même ``(class, kwargs)`` partagent la
|
| 254 |
+
même instance d'adapter (cache).
|
| 255 |
+
- Deux steps qui ont la même ``id`` mais une ``class`` ou des
|
| 256 |
+
``kwargs`` différents reçoivent des ``adapter_name`` distincts
|
| 257 |
+
(préfixés par le nom de pipeline).
|
| 258 |
+
|
| 259 |
+
C'est essentiel pour le cas BnF où plusieurs pipelines utilisent
|
| 260 |
+
la **même classe** avec des **kwargs différents** (ex :
|
| 261 |
+
``PrecomputedTextAdapter`` instancié 3 fois avec
|
| 262 |
+
``source_label`` distincts).
|
| 263 |
"""
|
| 264 |
instance_cache: dict[str, object] = {}
|
| 265 |
+
# ``adapter_name`` → ``(class, kwargs_signature)`` pour valider
|
| 266 |
+
# qu'on n'écrase pas un adapter avec un autre.
|
| 267 |
+
registered: dict[str, tuple[type, str]] = {}
|
| 268 |
name_to_class: dict[str, type] = {}
|
| 269 |
name_to_kwargs: dict[str, dict] = {}
|
| 270 |
|
| 271 |
+
def _kwargs_signature(kwargs: dict) -> str:
|
| 272 |
+
# Signature stable : ordre de tri sur les clés. ``str(value)``
|
| 273 |
+
# suffit pour comparer — les types JSON-serializable seront
|
| 274 |
+
# déterministes.
|
| 275 |
+
return "|".join(f"{k}={kwargs[k]!r}" for k in sorted(kwargs))
|
| 276 |
+
|
| 277 |
pipeline_specs: list[PipelineSpec] = []
|
| 278 |
for p in spec.pipelines:
|
| 279 |
steps = []
|
| 280 |
for s in p.steps:
|
| 281 |
cls = resolve_adapter_class(s.adapter_class)
|
| 282 |
+
kwargs_sig = _kwargs_signature(s.adapter_kwargs)
|
| 283 |
adapter_name = s.id
|
| 284 |
+
# Si le step.id existe déjà mais avec une autre signature
|
| 285 |
+
# (class ou kwargs distincts), on disambigue en préfixant
|
| 286 |
+
# par le nom de la pipeline.
|
| 287 |
+
existing = registered.get(adapter_name)
|
| 288 |
+
if existing is not None and existing != (cls, kwargs_sig):
|
|
|
|
| 289 |
adapter_name = f"{p.name}__{s.id}"
|
| 290 |
+
registered[adapter_name] = (cls, kwargs_sig)
|
| 291 |
name_to_class[adapter_name] = cls
|
| 292 |
name_to_kwargs[adapter_name] = s.adapter_kwargs
|
| 293 |
steps.append(PipelineStep(
|
|
File without changes
|
|
@@ -0,0 +1,533 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S26 — ``BaseOCRAdapter`` + ``PrecomputedTextAdapter``.
|
| 2 |
+
|
| 3 |
+
Couverture :
|
| 4 |
+
|
| 5 |
+
- **Contrat** : un ``BaseOCRAdapter`` est instanciable, expose
|
| 6 |
+
``name`` / ``input_types`` / ``output_types`` / ``execution_mode``,
|
| 7 |
+
son ``execute()`` est abstrait.
|
| 8 |
+
- **PrecomputedTextAdapter** : validation du ``source_label``,
|
| 9 |
+
lecture filesystem par convention de nommage, politique
|
| 10 |
+
``"raise"`` vs ``"empty"`` sur fichier manquant, validation
|
| 11 |
+
UTF-8, isolation entre instances de sources distinctes.
|
| 12 |
+
- **Pipeline executor** : un ``PrecomputedTextAdapter`` est consommé
|
| 13 |
+
directement par le ``PipelineExecutor`` (S7) — preuve que le
|
| 14 |
+
contrat ``BaseOCRAdapter`` satisfait ``StepExecutor``.
|
| 15 |
+
- **CLI E2E** : YAML déclarant 3 sources pré-calculées différentes
|
| 16 |
+
→ benchmark complet avec 3 pipelines comparés sur TextView,
|
| 17 |
+
sans aucun OCR réel.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import io
|
| 23 |
+
import json
|
| 24 |
+
import textwrap
|
| 25 |
+
import zipfile
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import pytest
|
| 29 |
+
from click.testing import CliRunner
|
| 30 |
+
|
| 31 |
+
from picarones.adapters.ocr import (
|
| 32 |
+
BaseOCRAdapter,
|
| 33 |
+
OCRAdapterError,
|
| 34 |
+
PrecomputedTextAdapter,
|
| 35 |
+
)
|
| 36 |
+
from picarones.app.cli import cli
|
| 37 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 38 |
+
from picarones.pipeline.types import RunContext
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ──────────────────────────────────────────────────────────────────
|
| 42 |
+
# Fixtures
|
| 43 |
+
# ──────────────────────────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _png_bytes() -> bytes:
|
| 47 |
+
return (
|
| 48 |
+
b"\x89PNG\r\n\x1a\n"
|
| 49 |
+
b"\x00\x00\x00\rIHDR"
|
| 50 |
+
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
|
| 51 |
+
b"\x1f\x15\xc4\x89"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _ctx(doc_id: str = "doc01") -> RunContext:
|
| 56 |
+
return RunContext(
|
| 57 |
+
document_id=doc_id,
|
| 58 |
+
code_version="1.0.0-s26-test",
|
| 59 |
+
pipeline_name="test_pipeline",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _image_artifact(doc_id: str, path: Path) -> Artifact:
|
| 64 |
+
return Artifact(
|
| 65 |
+
id=f"{doc_id}:image",
|
| 66 |
+
document_id=doc_id,
|
| 67 |
+
type=ArtifactType.IMAGE,
|
| 68 |
+
uri=str(path),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ──────────────────────────────────────────────────────────────────
|
| 73 |
+
# Contrat BaseOCRAdapter
|
| 74 |
+
# ──────────────────────────────────────────────────────────────────
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class TestBaseOCRAdapterContract:
|
| 78 |
+
def test_cannot_instantiate_abstract_directly(self) -> None:
|
| 79 |
+
with pytest.raises(TypeError):
|
| 80 |
+
BaseOCRAdapter() # type: ignore[abstract]
|
| 81 |
+
|
| 82 |
+
def test_minimal_subclass_with_name_and_execute_works(self) -> None:
|
| 83 |
+
class _Minimal(BaseOCRAdapter):
|
| 84 |
+
@property
|
| 85 |
+
def name(self) -> str:
|
| 86 |
+
return "minimal"
|
| 87 |
+
|
| 88 |
+
def execute(self, inputs, params, context):
|
| 89 |
+
return {}
|
| 90 |
+
|
| 91 |
+
adapter = _Minimal()
|
| 92 |
+
assert adapter.name == "minimal"
|
| 93 |
+
assert ArtifactType.IMAGE in adapter.input_types
|
| 94 |
+
assert ArtifactType.RAW_TEXT in adapter.output_types
|
| 95 |
+
assert adapter.execution_mode == "io"
|
| 96 |
+
|
| 97 |
+
def test_subclass_can_override_io_modes(self) -> None:
|
| 98 |
+
class _CPUBound(BaseOCRAdapter):
|
| 99 |
+
execution_mode = "cpu"
|
| 100 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 101 |
+
output_types = frozenset({
|
| 102 |
+
ArtifactType.RAW_TEXT, ArtifactType.ALTO_XML,
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
@property
|
| 106 |
+
def name(self) -> str:
|
| 107 |
+
return "cpu_bound"
|
| 108 |
+
|
| 109 |
+
def execute(self, inputs, params, context):
|
| 110 |
+
return {}
|
| 111 |
+
|
| 112 |
+
adapter = _CPUBound()
|
| 113 |
+
assert adapter.execution_mode == "cpu"
|
| 114 |
+
assert ArtifactType.ALTO_XML in adapter.output_types
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ──────────────────────────────────────────────────────────────────
|
| 118 |
+
# PrecomputedTextAdapter — validation à l'init
|
| 119 |
+
# ──────────────────────────────────────────────────────────────────
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class TestPrecomputedInitValidation:
|
| 123 |
+
def test_empty_source_label_rejected(self) -> None:
|
| 124 |
+
with pytest.raises(OCRAdapterError, match="vide"):
|
| 125 |
+
PrecomputedTextAdapter(source_label="")
|
| 126 |
+
|
| 127 |
+
def test_whitespace_source_label_rejected(self) -> None:
|
| 128 |
+
with pytest.raises(OCRAdapterError, match="vide"):
|
| 129 |
+
PrecomputedTextAdapter(source_label=" ")
|
| 130 |
+
|
| 131 |
+
def test_invalid_chars_in_source_label_rejected(self) -> None:
|
| 132 |
+
for bad in ("foo/bar", "foo bar", "foo.bar", "foo:bar"):
|
| 133 |
+
with pytest.raises(OCRAdapterError, match="invalide"):
|
| 134 |
+
PrecomputedTextAdapter(source_label=bad)
|
| 135 |
+
|
| 136 |
+
def test_valid_source_labels_accepted(self) -> None:
|
| 137 |
+
for good in ("tesseract", "gpt-4v", "pero_ocr", "ABC123"):
|
| 138 |
+
adapter = PrecomputedTextAdapter(source_label=good)
|
| 139 |
+
assert adapter.source_label == good
|
| 140 |
+
assert adapter.name == f"precomputed_{good}"
|
| 141 |
+
|
| 142 |
+
def test_invalid_missing_text_policy_rejected(self) -> None:
|
| 143 |
+
with pytest.raises(OCRAdapterError, match="missing_text_policy"):
|
| 144 |
+
PrecomputedTextAdapter(
|
| 145 |
+
source_label="tess",
|
| 146 |
+
missing_text_policy="silent", # type: ignore[arg-type]
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
def test_default_missing_text_policy_is_raise(self) -> None:
|
| 150 |
+
adapter = PrecomputedTextAdapter(source_label="tess")
|
| 151 |
+
assert adapter._missing_policy == "raise"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ──────────────────────────────────────────────────────────────────
|
| 155 |
+
# PrecomputedTextAdapter — exécution
|
| 156 |
+
# ──────────────────────────────────────────────────────────────────
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class TestPrecomputedExecute:
|
| 160 |
+
def test_reads_text_file_by_convention(self, tmp_path: Path) -> None:
|
| 161 |
+
# Préparer image + texte pré-calculé.
|
| 162 |
+
image_path = tmp_path / "doc01.png"
|
| 163 |
+
image_path.write_bytes(_png_bytes())
|
| 164 |
+
text_path = tmp_path / "doc01.tesseract.txt"
|
| 165 |
+
text_path.write_text("Bonjour le monde", encoding="utf-8")
|
| 166 |
+
|
| 167 |
+
adapter = PrecomputedTextAdapter(source_label="tesseract")
|
| 168 |
+
outputs = adapter.execute(
|
| 169 |
+
inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
|
| 170 |
+
params={},
|
| 171 |
+
context=_ctx("doc01"),
|
| 172 |
+
)
|
| 173 |
+
art = outputs[ArtifactType.RAW_TEXT]
|
| 174 |
+
assert art.type == ArtifactType.RAW_TEXT
|
| 175 |
+
assert art.document_id == "doc01"
|
| 176 |
+
assert Path(art.uri).read_text(encoding="utf-8") == "Bonjour le monde"
|
| 177 |
+
# Convention <doc_id>:<owner>:<role>.
|
| 178 |
+
assert art.id == "doc01:precomputed_tesseract:raw_text"
|
| 179 |
+
|
| 180 |
+
def test_missing_text_raises_by_default(self, tmp_path: Path) -> None:
|
| 181 |
+
image_path = tmp_path / "doc01.png"
|
| 182 |
+
image_path.write_bytes(_png_bytes())
|
| 183 |
+
# Pas de doc01.tesseract.txt.
|
| 184 |
+
|
| 185 |
+
adapter = PrecomputedTextAdapter(source_label="tesseract")
|
| 186 |
+
with pytest.raises(OCRAdapterError, match="introuvable"):
|
| 187 |
+
adapter.execute(
|
| 188 |
+
inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
|
| 189 |
+
params={},
|
| 190 |
+
context=_ctx("doc01"),
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
def test_missing_text_empty_policy_creates_empty_file(
|
| 194 |
+
self, tmp_path: Path,
|
| 195 |
+
) -> None:
|
| 196 |
+
image_path = tmp_path / "doc01.png"
|
| 197 |
+
image_path.write_bytes(_png_bytes())
|
| 198 |
+
|
| 199 |
+
adapter = PrecomputedTextAdapter(
|
| 200 |
+
source_label="tess",
|
| 201 |
+
missing_text_policy="empty",
|
| 202 |
+
)
|
| 203 |
+
outputs = adapter.execute(
|
| 204 |
+
inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
|
| 205 |
+
params={},
|
| 206 |
+
context=_ctx("doc01"),
|
| 207 |
+
)
|
| 208 |
+
art = outputs[ArtifactType.RAW_TEXT]
|
| 209 |
+
assert Path(art.uri).read_text(encoding="utf-8") == ""
|
| 210 |
+
|
| 211 |
+
def test_non_utf8_file_rejected(self, tmp_path: Path) -> None:
|
| 212 |
+
image_path = tmp_path / "doc01.png"
|
| 213 |
+
image_path.write_bytes(_png_bytes())
|
| 214 |
+
text_path = tmp_path / "doc01.tess.txt"
|
| 215 |
+
# Bytes invalides en UTF-8 (latin-1 avec accent).
|
| 216 |
+
text_path.write_bytes(b"\xe9\xe8")
|
| 217 |
+
|
| 218 |
+
adapter = PrecomputedTextAdapter(source_label="tess")
|
| 219 |
+
with pytest.raises(OCRAdapterError, match="UTF-8"):
|
| 220 |
+
adapter.execute(
|
| 221 |
+
inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
|
| 222 |
+
params={},
|
| 223 |
+
context=_ctx("doc01"),
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
def test_missing_image_input_rejected(self, tmp_path: Path) -> None:
|
| 227 |
+
adapter = PrecomputedTextAdapter(source_label="tess")
|
| 228 |
+
with pytest.raises(OCRAdapterError, match="IMAGE manquant"):
|
| 229 |
+
adapter.execute(inputs={}, params={}, context=_ctx())
|
| 230 |
+
|
| 231 |
+
def test_image_artifact_without_uri_rejected(self) -> None:
|
| 232 |
+
adapter = PrecomputedTextAdapter(source_label="tess")
|
| 233 |
+
with pytest.raises(OCRAdapterError, match="sans URI"):
|
| 234 |
+
adapter.execute(
|
| 235 |
+
inputs={
|
| 236 |
+
ArtifactType.IMAGE: Artifact(
|
| 237 |
+
id="d:image", document_id="d",
|
| 238 |
+
type=ArtifactType.IMAGE,
|
| 239 |
+
),
|
| 240 |
+
},
|
| 241 |
+
params={},
|
| 242 |
+
context=_ctx(),
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
def test_two_sources_isolated_in_same_dir(self, tmp_path: Path) -> None:
|
| 246 |
+
"""Cas BnF central : deux sources pré-calculées dans le même
|
| 247 |
+
répertoire ne se piétinent pas — chaque adapter lit son
|
| 248 |
+
propre fichier."""
|
| 249 |
+
image_path = tmp_path / "doc01.png"
|
| 250 |
+
image_path.write_bytes(_png_bytes())
|
| 251 |
+
(tmp_path / "doc01.tess.txt").write_text(
|
| 252 |
+
"tesseract output", encoding="utf-8",
|
| 253 |
+
)
|
| 254 |
+
(tmp_path / "doc01.gpt4v.txt").write_text(
|
| 255 |
+
"gpt-4 vision output", encoding="utf-8",
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
a_tess = PrecomputedTextAdapter(source_label="tess")
|
| 259 |
+
a_gpt = PrecomputedTextAdapter(source_label="gpt4v")
|
| 260 |
+
|
| 261 |
+
out_tess = a_tess.execute(
|
| 262 |
+
inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
|
| 263 |
+
params={},
|
| 264 |
+
context=_ctx("doc01"),
|
| 265 |
+
)
|
| 266 |
+
out_gpt = a_gpt.execute(
|
| 267 |
+
inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
|
| 268 |
+
params={},
|
| 269 |
+
context=_ctx("doc01"),
|
| 270 |
+
)
|
| 271 |
+
assert Path(out_tess[ArtifactType.RAW_TEXT].uri).read_text() \
|
| 272 |
+
== "tesseract output"
|
| 273 |
+
assert Path(out_gpt[ArtifactType.RAW_TEXT].uri).read_text() \
|
| 274 |
+
== "gpt-4 vision output"
|
| 275 |
+
|
| 276 |
+
def test_image_extension_variations_handled(
|
| 277 |
+
self, tmp_path: Path,
|
| 278 |
+
) -> None:
|
| 279 |
+
"""``stem`` strip toutes les extensions image courantes."""
|
| 280 |
+
for ext in (".png", ".jpg", ".jpeg", ".tif", ".tiff"):
|
| 281 |
+
image_path = tmp_path / f"folio_001{ext}"
|
| 282 |
+
image_path.write_bytes(_png_bytes())
|
| 283 |
+
text_path = tmp_path / "folio_001.src.txt"
|
| 284 |
+
text_path.write_text("ok", encoding="utf-8")
|
| 285 |
+
|
| 286 |
+
adapter = PrecomputedTextAdapter(source_label="src")
|
| 287 |
+
out = adapter.execute(
|
| 288 |
+
inputs={
|
| 289 |
+
ArtifactType.IMAGE: _image_artifact("folio_001", image_path),
|
| 290 |
+
},
|
| 291 |
+
params={},
|
| 292 |
+
context=_ctx("folio_001"),
|
| 293 |
+
)
|
| 294 |
+
assert Path(out[ArtifactType.RAW_TEXT].uri).read_text() == "ok"
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# ──────────────────────────────────────────────────────────────────
|
| 298 |
+
# Smoke pipeline executor
|
| 299 |
+
# ──────────────────────────────────────────────────────────────────
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
class TestPipelineExecutorIntegration:
|
| 303 |
+
def test_adapter_consumed_by_pipeline_executor(
|
| 304 |
+
self, tmp_path: Path,
|
| 305 |
+
) -> None:
|
| 306 |
+
"""Démontre que ``BaseOCRAdapter`` satisfait le contrat
|
| 307 |
+
``StepExecutor`` du nouveau pipeline executor — preuve que
|
| 308 |
+
le contrat propre du nouveau monde est suffisant."""
|
| 309 |
+
from picarones.domain.documents import DocumentRef
|
| 310 |
+
from picarones.pipeline import (
|
| 311 |
+
PipelineExecutor, PipelineSpec, PipelineStep,
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
image_path = tmp_path / "doc01.png"
|
| 315 |
+
image_path.write_bytes(_png_bytes())
|
| 316 |
+
(tmp_path / "doc01.tess.txt").write_text(
|
| 317 |
+
"Bonjour", encoding="utf-8",
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
adapter = PrecomputedTextAdapter(source_label="tess")
|
| 321 |
+
spec = PipelineSpec(
|
| 322 |
+
name="precomputed_smoke",
|
| 323 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 324 |
+
steps=(PipelineStep(
|
| 325 |
+
id="ocr", kind="ocr",
|
| 326 |
+
adapter_name="precomputed",
|
| 327 |
+
input_types=(ArtifactType.IMAGE,),
|
| 328 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 329 |
+
),),
|
| 330 |
+
)
|
| 331 |
+
executor = PipelineExecutor(adapter_resolver=lambda n: adapter)
|
| 332 |
+
result = executor.run(
|
| 333 |
+
spec=spec,
|
| 334 |
+
document=DocumentRef(id="doc01", image_uri=str(image_path)),
|
| 335 |
+
initial_inputs={
|
| 336 |
+
ArtifactType.IMAGE: _image_artifact("doc01", image_path),
|
| 337 |
+
},
|
| 338 |
+
context=_ctx("doc01"),
|
| 339 |
+
)
|
| 340 |
+
assert result.succeeded
|
| 341 |
+
text_arts = result.artifacts_of_type(ArtifactType.RAW_TEXT)
|
| 342 |
+
assert len(text_arts) == 1
|
| 343 |
+
assert Path(text_arts[0].uri).read_text() == "Bonjour"
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
# ──────────────────────────────────────────────────────────────────
|
| 347 |
+
# CLI E2E : 3 sources pré-calculées comparées via picarones-rewrite run
|
| 348 |
+
# ──────────────────────────────────────────────────────────────────
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _make_corpus_zip_with_sources() -> bytes:
|
| 352 |
+
"""Corpus avec image + GT + 3 sources pré-calculées."""
|
| 353 |
+
buf = io.BytesIO()
|
| 354 |
+
with zipfile.ZipFile(buf, mode="w") as zf:
|
| 355 |
+
for doc_id in ("doc01", "doc02"):
|
| 356 |
+
zf.writestr(f"{doc_id}.png", _png_bytes())
|
| 357 |
+
zf.writestr(f"{doc_id}.gt.txt", "Bonjour le monde")
|
| 358 |
+
# Tesseract : copie exacte de la GT (CER 0).
|
| 359 |
+
zf.writestr(
|
| 360 |
+
f"{doc_id}.tesseract.txt",
|
| 361 |
+
"Bonjour le monde",
|
| 362 |
+
)
|
| 363 |
+
# GPT-4v : 1 erreur (CER > 0).
|
| 364 |
+
zf.writestr(
|
| 365 |
+
f"{doc_id}.gpt4v.txt",
|
| 366 |
+
"Bonjur le monde",
|
| 367 |
+
)
|
| 368 |
+
# Pero : très dégradé.
|
| 369 |
+
zf.writestr(
|
| 370 |
+
f"{doc_id}.pero.txt",
|
| 371 |
+
"Bonjour 1e mond",
|
| 372 |
+
)
|
| 373 |
+
return buf.getvalue()
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
class TestCLIComparingPrecomputedSources:
|
| 377 |
+
"""Cas BnF concret : « j'ai 3 transcriptions déjà produites,
|
| 378 |
+
je veux les comparer ».
|
| 379 |
+
|
| 380 |
+
YAML déclare 3 pipelines, chacun pointant sur
|
| 381 |
+
``PrecomputedTextAdapter`` avec un ``source_label`` distinct.
|
| 382 |
+
Le ``BenchmarkService`` les exécute en parallèle, le
|
| 383 |
+
``ReportService`` les compare dans TextView. Aucun OCR réel
|
| 384 |
+
n'est lancé."""
|
| 385 |
+
|
| 386 |
+
def test_three_precomputed_sources_compared_via_cli(
|
| 387 |
+
self, tmp_path: Path,
|
| 388 |
+
) -> None:
|
| 389 |
+
runner = CliRunner()
|
| 390 |
+
corpus_zip = tmp_path / "corpus.zip"
|
| 391 |
+
corpus_zip.write_bytes(_make_corpus_zip_with_sources())
|
| 392 |
+
|
| 393 |
+
spec_path = tmp_path / "run.yaml"
|
| 394 |
+
out_dir = tmp_path / "out"
|
| 395 |
+
report_path = out_dir / "rapport.html"
|
| 396 |
+
spec_path.write_text(textwrap.dedent(f"""
|
| 397 |
+
corpus_zip: {corpus_zip}
|
| 398 |
+
corpus_name: bnf_3sources
|
| 399 |
+
pipelines:
|
| 400 |
+
- name: tesseract_baseline
|
| 401 |
+
initial_inputs: [image]
|
| 402 |
+
steps:
|
| 403 |
+
- id: ocr
|
| 404 |
+
adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
|
| 405 |
+
adapter_kwargs:
|
| 406 |
+
source_label: tesseract
|
| 407 |
+
input_types: [image]
|
| 408 |
+
output_types: [raw_text]
|
| 409 |
+
- name: gpt4v_alternative
|
| 410 |
+
initial_inputs: [image]
|
| 411 |
+
steps:
|
| 412 |
+
- id: ocr
|
| 413 |
+
adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
|
| 414 |
+
adapter_kwargs:
|
| 415 |
+
source_label: gpt4v
|
| 416 |
+
input_types: [image]
|
| 417 |
+
output_types: [raw_text]
|
| 418 |
+
- name: pero_alternative
|
| 419 |
+
initial_inputs: [image]
|
| 420 |
+
steps:
|
| 421 |
+
- id: ocr
|
| 422 |
+
adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
|
| 423 |
+
adapter_kwargs:
|
| 424 |
+
source_label: pero
|
| 425 |
+
input_types: [image]
|
| 426 |
+
output_types: [raw_text]
|
| 427 |
+
views: [text_final]
|
| 428 |
+
output_dir: {out_dir}
|
| 429 |
+
report_html: {report_path}
|
| 430 |
+
code_version: "1.0.0-s26-bnf"
|
| 431 |
+
"""))
|
| 432 |
+
|
| 433 |
+
result = runner.invoke(cli, ["run", "--spec", str(spec_path)])
|
| 434 |
+
assert result.exit_code == 0, result.output
|
| 435 |
+
|
| 436 |
+
# Validation : 2 docs × 3 pipelines × 1 vue = 6 ViewResults.
|
| 437 |
+
results_dir = out_dir / "results"
|
| 438 |
+
view_lines = [
|
| 439 |
+
json.loads(line)
|
| 440 |
+
for line in (results_dir / "view_results.jsonl").read_text().strip().split("\n")
|
| 441 |
+
if line.strip()
|
| 442 |
+
]
|
| 443 |
+
assert len(view_lines) == 6
|
| 444 |
+
|
| 445 |
+
# Tesseract → CER 0 (copie exacte).
|
| 446 |
+
# GPT-4v / Pero → CER > 0.
|
| 447 |
+
cer_by_pipeline: dict[str, list[float]] = {}
|
| 448 |
+
for vr in view_lines:
|
| 449 |
+
cand_id = vr["candidate_artifact_id"]
|
| 450 |
+
if "precomputed_tesseract" in cand_id:
|
| 451 |
+
pipeline = "tesseract"
|
| 452 |
+
elif "precomputed_gpt4v" in cand_id:
|
| 453 |
+
pipeline = "gpt4v"
|
| 454 |
+
elif "precomputed_pero" in cand_id:
|
| 455 |
+
pipeline = "pero"
|
| 456 |
+
else:
|
| 457 |
+
pytest.fail(f"candidate id inattendu : {cand_id}")
|
| 458 |
+
cer_by_pipeline.setdefault(pipeline, []).append(
|
| 459 |
+
vr["metric_values"]["cer"],
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
# Tesseract = 0 sur les 2 docs.
|
| 463 |
+
assert cer_by_pipeline["tesseract"] == [0.0, 0.0]
|
| 464 |
+
# GPT-4v > 0 (1 erreur).
|
| 465 |
+
for cer in cer_by_pipeline["gpt4v"]:
|
| 466 |
+
assert cer > 0.0
|
| 467 |
+
# Pero strictement plus mauvais que GPT-4v.
|
| 468 |
+
for tess, gpt, pero in zip(
|
| 469 |
+
cer_by_pipeline["tesseract"],
|
| 470 |
+
cer_by_pipeline["gpt4v"],
|
| 471 |
+
cer_by_pipeline["pero"],
|
| 472 |
+
):
|
| 473 |
+
assert pero > gpt > tess
|
| 474 |
+
|
| 475 |
+
# Le rapport HTML mentionne les 3 pipelines.
|
| 476 |
+
html = report_path.read_text(encoding="utf-8")
|
| 477 |
+
for name in ("tesseract_baseline", "gpt4v_alternative", "pero_alternative"):
|
| 478 |
+
assert name in html
|
| 479 |
+
|
| 480 |
+
def test_missing_source_file_produces_failed_step(
|
| 481 |
+
self, tmp_path: Path,
|
| 482 |
+
) -> None:
|
| 483 |
+
"""Si un fichier pré-calculé manque, le pipeline du document
|
| 484 |
+
concerné échoue (StepResult.error renseigné), mais les autres
|
| 485 |
+
pipelines/documents continuent — le benchmark ne crash pas
|
| 486 |
+
globalement."""
|
| 487 |
+
runner = CliRunner()
|
| 488 |
+
# Corpus avec 1 doc, mais le fichier .tesseract.txt manque.
|
| 489 |
+
buf = io.BytesIO()
|
| 490 |
+
with zipfile.ZipFile(buf, mode="w") as zf:
|
| 491 |
+
zf.writestr("doc01.png", _png_bytes())
|
| 492 |
+
zf.writestr("doc01.gt.txt", "Bonjour")
|
| 493 |
+
# PAS de doc01.tesseract.txt
|
| 494 |
+
corpus_zip = tmp_path / "corpus.zip"
|
| 495 |
+
corpus_zip.write_bytes(buf.getvalue())
|
| 496 |
+
|
| 497 |
+
spec_path = tmp_path / "run.yaml"
|
| 498 |
+
out_dir = tmp_path / "out"
|
| 499 |
+
spec_path.write_text(textwrap.dedent(f"""
|
| 500 |
+
corpus_zip: {corpus_zip}
|
| 501 |
+
corpus_name: bnf_missing
|
| 502 |
+
pipelines:
|
| 503 |
+
- name: tesseract_baseline
|
| 504 |
+
initial_inputs: [image]
|
| 505 |
+
steps:
|
| 506 |
+
- id: ocr
|
| 507 |
+
adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
|
| 508 |
+
adapter_kwargs:
|
| 509 |
+
source_label: tesseract
|
| 510 |
+
input_types: [image]
|
| 511 |
+
output_types: [raw_text]
|
| 512 |
+
views: [text_final]
|
| 513 |
+
output_dir: {out_dir}
|
| 514 |
+
"""))
|
| 515 |
+
|
| 516 |
+
result = runner.invoke(cli, ["run", "--spec", str(spec_path)])
|
| 517 |
+
# Le run termine — l'erreur est isolée au step.
|
| 518 |
+
assert result.exit_code == 0, result.output
|
| 519 |
+
|
| 520 |
+
# Le PipelineResult reflète l'échec.
|
| 521 |
+
results_dir = out_dir / "results"
|
| 522 |
+
pipeline_lines = [
|
| 523 |
+
json.loads(line)
|
| 524 |
+
for line in (results_dir / "pipeline_results.jsonl").read_text().strip().split("\n")
|
| 525 |
+
if line.strip()
|
| 526 |
+
]
|
| 527 |
+
assert len(pipeline_lines) == 1
|
| 528 |
+
pr = pipeline_lines[0]
|
| 529 |
+
assert pr["succeeded"] is False
|
| 530 |
+
assert any(
|
| 531 |
+
sr.get("error") and "introuvable" in sr["error"]
|
| 532 |
+
for sr in pr["step_results"]
|
| 533 |
+
)
|