Spaces:
Running
sprint47: Tesseract — exposition des token_confidences natifs
Browse filesPremier des engines adaptés au câblage calibration du Sprint 42.
L'utilisateur qui benchmarke avec Tesseract obtient désormais
automatiquement ECE/MCE et reliability diagram dans le rapport, sans
configuration supplémentaire.
TesseractEngine.run() surchargé
- Appelle image_to_string pour le texte (rétrocompat octet par octet)
ET image_to_data pour les confidences mot par mot.
- Retourne EngineResult avec token_confidences = [{"token": str,
"confidence": float}, ...] (confidence ∈ [0, 100], le runner
Sprint 42 normalise en [0, 1]).
- Helper _extract_token_confidences séparé du chemin OCR principal :
si image_to_data lève, l'OCR continue normalement et
token_confidences = None (warning explicite, pas except: pass).
- Filtrage à la source : non-mots Tesseract (conf = -1), tokens
vides, longueurs incompatibles → ignorés.
- Nouveau paramètre config expose_confidences: false pour désactiver
le second appel Tesseract (économie d'un appel par image).
Coût additionnel : un appel image_to_data par image. Le texte
d'image_to_string n'est jamais reconstruit depuis image_to_data —
préservation stricte du comportement historique.
Tests : +9 dans test_sprint47_tesseract_confidences.py couvrant (avec
mock pytesseract) :
- exposition des token_confidences quand pytesseract présent
- préservation octet par octet du texte (rétrocompat)
- flag expose_confidences=False désactive le second appel
- fallback gracieux quand image_to_data lève (warning + None)
- échec d'image_to_string : OCR.error renseigné, pas de tentative
d'extraction
- filtrage des non-mots (conf = -1) et tokens vides
- format inattendu (longueurs incompatibles) → None
- intégration bout-en-bout avec _compute_document_result :
calibration_metrics calculée correctement
- pytesseract absent → None sans crash
Suite complète : 1864 → 1873 passed, 2 skipped, 0 failed.
Reste à adapter Pero, Mistral OCR, Google Vision et Azure DI sur le
même pattern.
- CHANGELOG.md +42 -8
- CLAUDE.md +2 -1
- picarones/engines/tesseract.py +130 -7
- tests/test_sprint47_tesseract_confidences.py +293 -0
|
@@ -16,6 +16,38 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 46 — A.III stratification par `script_type` : vue HTML +
|
| 20 |
détecteur narratif (clôture A.III)**. Suite directe du Sprint 45
|
| 21 |
(couche backend). La vue stratifiée est désormais rendue dans le
|
|
@@ -479,16 +511,18 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 479 |
|
| 480 |
### Tests
|
| 481 |
|
| 482 |
-
- 1478 →
|
| 483 |
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
|
| 484 |
+32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
|
| 485 |
-
+43 Sprint 43, +15 Sprint 44, +16 Sprint 45, +38 Sprint 46
|
| 486 |
-
Aucune régression. **Phase 0 close ; Étape 2 du
|
| 487 |
-
inter-moteurs (A.II.1.c), NER (A.II.1.a),
|
| 488 |
-
et stratification (A.III) livrés
|
| 489 |
-
HTML ; A.I.2 médiane par défaut
|
| 490 |
-
|
| 491 |
-
|
|
|
|
|
|
|
| 492 |
|
| 493 |
---
|
| 494 |
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 47 — Adapter Tesseract : exposition des `token_confidences`
|
| 20 |
+
natifs.** Premier des engines adaptés au câblage calibration
|
| 21 |
+
(Sprint 42). L'utilisateur qui benchmarke avec Tesseract obtient
|
| 22 |
+
désormais automatiquement ECE/MCE et reliability diagram dans le
|
| 23 |
+
rapport, sans configuration supplémentaire.
|
| 24 |
+
- `TesseractEngine.run()` est surchargé : appelle `image_to_string`
|
| 25 |
+
pour le texte (rétrocompat octet par octet) **et** `image_to_data`
|
| 26 |
+
pour les confidences mot par mot, retourne un `EngineResult` avec
|
| 27 |
+
`token_confidences = [{"token": str, "confidence": float}, …]`
|
| 28 |
+
(confidence ∈ [0, 100], le runner Sprint 42 normalise en [0, 1]).
|
| 29 |
+
- Helper `_extract_token_confidences()` séparé du chemin OCR
|
| 30 |
+
principal : si `image_to_data` lève, l'OCR continue normalement
|
| 31 |
+
et `token_confidences = None` (warning explicite, pas
|
| 32 |
+
`except: pass`).
|
| 33 |
+
- Filtrage à la source : non-mots Tesseract (conf = -1), tokens
|
| 34 |
+
vides, longueurs incompatibles → ignorés.
|
| 35 |
+
- Nouveau paramètre config `expose_confidences: false` pour
|
| 36 |
+
désactiver le second appel Tesseract (économie d'un appel par
|
| 37 |
+
image en cas de besoin).
|
| 38 |
+
- Coût additionnel : un appel `image_to_data` par image. Le texte
|
| 39 |
+
de `image_to_string` n'est jamais reconstruit depuis
|
| 40 |
+
`image_to_data` — préservation stricte du comportement
|
| 41 |
+
historique.
|
| 42 |
+
- +9 tests dans `test_sprint47_tesseract_confidences.py` couvrant
|
| 43 |
+
l'exposition des confidences (avec mock pytesseract), la
|
| 44 |
+
préservation octet par octet du texte, le flag
|
| 45 |
+
`expose_confidences=False`, le fallback gracieux quand
|
| 46 |
+
`image_to_data` lève (warning + `None`), le filtrage des
|
| 47 |
+
non-mots/longueurs incompatibles, l'intégration bout-en-bout
|
| 48 |
+
avec le runner (`calibration_metrics` calculé), et le cas
|
| 49 |
+
pytesseract absent.
|
| 50 |
+
|
| 51 |
- **Sprint 46 — A.III stratification par `script_type` : vue HTML +
|
| 52 |
détecteur narratif (clôture A.III)**. Suite directe du Sprint 45
|
| 53 |
(couche backend). La vue stratifiée est désormais rendue dans le
|
|
|
|
| 511 |
|
| 512 |
### Tests
|
| 513 |
|
| 514 |
+
- 1478 → 1873 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
|
| 515 |
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
|
| 516 |
+32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
|
| 517 |
+
+43 Sprint 43, +15 Sprint 44, +16 Sprint 45, +38 Sprint 46,
|
| 518 |
+
+9 Sprint 47). Aucune régression. **Phase 0 close ; Étape 2 du
|
| 519 |
+
plan d'évolution : inter-moteurs (A.II.1.c), NER (A.II.1.a),
|
| 520 |
+
calibration (A.II.1.b) et stratification (A.III) livrés
|
| 521 |
+
bout-en-bout calcul → runner → HTML ; A.I.2 médiane par défaut
|
| 522 |
+
livré (Sprint 44) ; Tesseract adapté pour exposer ses
|
| 523 |
+
`token_confidences` natifs (Sprint 47, première brique de
|
| 524 |
+
l'adaptation engines). Reste à adapter Pero, Mistral OCR, Google
|
| 525 |
+
Vision et Azure DI (un sprint par adapter).**
|
| 526 |
|
| 527 |
---
|
| 528 |
|
|
@@ -207,6 +207,7 @@ AZURE_DOC_INTEL_KEY=...
|
|
| 207 |
| 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
|
| 208 |
| 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
|
| 209 |
| 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
|
|
|
|
| 210 |
| 46 | **Sprint 15 du plan d'évolution 2026 — Étape 2 / axe A.III : vue HTML stratifiée + détecteur narratif (clôture A.III)**. Suite directe du Sprint 45 (couche backend). Nouveau module `picarones/report/stratification_render.py` : `build_stratified_ranking_html` rend un `<details>` natif (collapsible sans JS) par strate avec tableau moteur × (médiane, moyenne, docs), cellule médiane colorée par gradient vert→rouge, premier `<details>` ouvert par défaut, bandeau d'avertissement en tête si `corpus_homogeneity` fourni. `_build_report_data` expose `available_strata`/`stratified_ranking`/`corpus_homogeneity` au top-level ; `view_ranking.html` insère le bloc après le tableau principal **uniquement si stratification disponible**. Nouveau `FactType.STRATIFICATION_RECOMMENDED` (priority 45, importance MEDIUM ou HIGH selon le gap) + détecteur `detect_stratification_recommended` (seuil 5 points / 10 points de CER inter-strate). Templates FR/EN sans nombres en dur. L'arbitre marque la paire `{GLOBAL_LEADER_CER, STRATIFICATION_RECOMMENDED}` comme complémentaire. +8 clés i18n FR/EN. Anti-injection HTML via `html.escape`. +38 tests dans `test_sprint46_stratification_html.py`. **Verrou levé** : A.III (stratification) est désormais livré bout-en-bout — couche backend (Sprint 45) + vue HTML + détecteur narratif (Sprint 46) ; le lecteur du rapport voit immédiatement quand le corpus est hétérogène et est invité à consulter la vue stratifiée. |
|
| 211 |
| 45 | **Sprint 14 du plan d'évolution 2026 — Étape 2 / axe A.III : stratification par `script_type` (couche backend)**. Première brique de la « plus haute valeur ajoutée transversale » du plan. `BenchmarkResult.doc_strata: Optional[dict[str, str]]` ajouté (map `{doc_id: script_type}` capturée par le runner avant `compact()` qui efface `image_quality`). Trois nouvelles méthodes : `available_strata()` (liste triée des strates distinctes, ignore les vides) ; `stratified_ranking()` qui retourne `{stratum: [ranking_entry]}` avec mean/median CER recalculés par strate, tri par médiane (Sprint 44), inclut les moteurs absents d'une strate sous forme d'entrée dégénérée (mean/median = None) ; `corpus_homogeneity()` qui pour le moteur leader global retourne l'écart inter-strate de la médiane CER et la paire min/max — base du futur avertissement « ce corpus est hétérogène ». `as_dict()` expose les nouveaux champs quand renseignés (rétrocompat stricte sinon). +16 tests dans `test_sprint45_stratification.py` couvrant champ, available_strata, stratified_ranking (1 entrée/moteur/strate, métriques per-strate, tri par médiane, moteurs absents), corpus_homogeneity, sérialisation, et un **test propriété réaliste** : le leader global peut perdre sur une strate (Tesseract domine globalement mais Pero gagne sur le manuscrit). **Verrou levé** : la couche d'agrégation par strate est en place ; la vue HTML stratifiée + toggle UI viendront dans un sprint dédié, et un détecteur narratif `STRATIFICATION_RECOMMENDED` peut maintenant lire `corpus_homogeneity()` pour suggérer la vue stratifiée. |
|
| 212 |
| 44 | **Sprint 13 du plan d'évolution 2026 — Étape 2 / axe A.I.2 : tri par médiane par défaut + détecteur d'asymétrie**. Réponse à la critique structurelle 2 du plan : sur les corpus patrimoniaux, la moyenne est tirée par quelques documents catastrophiques et masque les performances réelles. `EngineReport.median_cer` ajouté (lit `aggregated_metrics["cer"]["median"]`). `BenchmarkResult.ranking()` inclut désormais `median_cer` dans chaque entrée et **trie par médiane CER croissante par défaut** (fallback sur `mean_cer` si médiane absente). Nouveau `FactType.MEDIAN_MEAN_GAP_WARNING` + détecteur `detect_median_mean_gap_warning` (priority 140) : émet un Fact quand `\|mean - median\| / median > 30 %` pour le moteur leader, importance HIGH si gap relatif ≥ 100 % (sinon MEDIUM). Garde-fou : ne déclenche pas si médiane nulle. Templates FR/EN sans nombres en dur (vérifié). L'arbitre marque la paire `{GLOBAL_LEADER_CER, MEDIAN_MEAN_GAP_WARNING}` comme **complémentaire** : les deux phrases peuvent coexister dans la synthèse pour nuancer le leader. +15 tests dans `test_sprint44_median_default.py` (propriété, tri sur cas asymétrique réaliste, fallback, déclenchement détecteur sur 4 cas dégénérés, importance, traçabilité anti-hallucination FR + EN, intégration build_synthesis). **Verrou levé** : la critique « le rapport classe sur la moyenne alors que les distributions patrimoniales sont asymétriques » est résolue ; le lecteur voit immédiatement le moteur le plus représentatif et est averti quand l'écart médiane/moyenne est suspect. |
|
|
@@ -264,7 +265,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 264 |
## Contexte développement
|
| 265 |
|
| 266 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 267 |
-
- **Tests** :
|
| 268 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 269 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 270 |
- **Transcript de la conversation de développement** :
|
|
|
|
| 207 |
| 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
|
| 208 |
| 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
|
| 209 |
| 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
|
| 210 |
+
| 47 | **Sprint 16 du plan d'évolution 2026 — Étape 2 / adaptation engines : Tesseract expose ses `token_confidences` natifs**. Premier des engines adaptés au câblage calibration du Sprint 42. `TesseractEngine.run()` est surchargé : appelle `image_to_string` pour le texte (rétrocompat octet par octet) **et** `image_to_data` pour les confidences mot par mot, retourne un `EngineResult` avec `token_confidences = [{"token": str, "confidence": float}, …]` (confidence ∈ [0, 100], le runner Sprint 42 normalise en [0, 1]). Helper `_extract_token_confidences` séparé : si `image_to_data` lève, l'OCR continue et `token_confidences = None` (warning explicite). Filtrage à la source des non-mots Tesseract (conf = -1), tokens vides, longueurs incompatibles. Nouveau paramètre config `expose_confidences: false` pour désactiver le second appel. Coût additionnel : un appel `image_to_data` par image — le texte d'`image_to_string` n'est jamais reconstruit depuis `image_to_data` (préservation stricte du comportement historique). +9 tests dans `test_sprint47_tesseract_confidences.py` (mock pytesseract, exposition, rétrocompat texte, flag `expose_confidences=False`, fallback gracieux, filtrage, intégration runner). **Verrou levé** : un benchmark Tesseract produit désormais automatiquement ECE/MCE/reliability diagram dans le rapport, sans configuration. Reste Pero, Mistral OCR, Google Vision, Azure DI à adapter. |
|
| 211 |
| 46 | **Sprint 15 du plan d'évolution 2026 — Étape 2 / axe A.III : vue HTML stratifiée + détecteur narratif (clôture A.III)**. Suite directe du Sprint 45 (couche backend). Nouveau module `picarones/report/stratification_render.py` : `build_stratified_ranking_html` rend un `<details>` natif (collapsible sans JS) par strate avec tableau moteur × (médiane, moyenne, docs), cellule médiane colorée par gradient vert→rouge, premier `<details>` ouvert par défaut, bandeau d'avertissement en tête si `corpus_homogeneity` fourni. `_build_report_data` expose `available_strata`/`stratified_ranking`/`corpus_homogeneity` au top-level ; `view_ranking.html` insère le bloc après le tableau principal **uniquement si stratification disponible**. Nouveau `FactType.STRATIFICATION_RECOMMENDED` (priority 45, importance MEDIUM ou HIGH selon le gap) + détecteur `detect_stratification_recommended` (seuil 5 points / 10 points de CER inter-strate). Templates FR/EN sans nombres en dur. L'arbitre marque la paire `{GLOBAL_LEADER_CER, STRATIFICATION_RECOMMENDED}` comme complémentaire. +8 clés i18n FR/EN. Anti-injection HTML via `html.escape`. +38 tests dans `test_sprint46_stratification_html.py`. **Verrou levé** : A.III (stratification) est désormais livré bout-en-bout — couche backend (Sprint 45) + vue HTML + détecteur narratif (Sprint 46) ; le lecteur du rapport voit immédiatement quand le corpus est hétérogène et est invité à consulter la vue stratifiée. |
|
| 212 |
| 45 | **Sprint 14 du plan d'évolution 2026 — Étape 2 / axe A.III : stratification par `script_type` (couche backend)**. Première brique de la « plus haute valeur ajoutée transversale » du plan. `BenchmarkResult.doc_strata: Optional[dict[str, str]]` ajouté (map `{doc_id: script_type}` capturée par le runner avant `compact()` qui efface `image_quality`). Trois nouvelles méthodes : `available_strata()` (liste triée des strates distinctes, ignore les vides) ; `stratified_ranking()` qui retourne `{stratum: [ranking_entry]}` avec mean/median CER recalculés par strate, tri par médiane (Sprint 44), inclut les moteurs absents d'une strate sous forme d'entrée dégénérée (mean/median = None) ; `corpus_homogeneity()` qui pour le moteur leader global retourne l'écart inter-strate de la médiane CER et la paire min/max — base du futur avertissement « ce corpus est hétérogène ». `as_dict()` expose les nouveaux champs quand renseignés (rétrocompat stricte sinon). +16 tests dans `test_sprint45_stratification.py` couvrant champ, available_strata, stratified_ranking (1 entrée/moteur/strate, métriques per-strate, tri par médiane, moteurs absents), corpus_homogeneity, sérialisation, et un **test propriété réaliste** : le leader global peut perdre sur une strate (Tesseract domine globalement mais Pero gagne sur le manuscrit). **Verrou levé** : la couche d'agrégation par strate est en place ; la vue HTML stratifiée + toggle UI viendront dans un sprint dédié, et un détecteur narratif `STRATIFICATION_RECOMMENDED` peut maintenant lire `corpus_homogeneity()` pour suggérer la vue stratifiée. |
|
| 213 |
| 44 | **Sprint 13 du plan d'évolution 2026 — Étape 2 / axe A.I.2 : tri par médiane par défaut + détecteur d'asymétrie**. Réponse à la critique structurelle 2 du plan : sur les corpus patrimoniaux, la moyenne est tirée par quelques documents catastrophiques et masque les performances réelles. `EngineReport.median_cer` ajouté (lit `aggregated_metrics["cer"]["median"]`). `BenchmarkResult.ranking()` inclut désormais `median_cer` dans chaque entrée et **trie par médiane CER croissante par défaut** (fallback sur `mean_cer` si médiane absente). Nouveau `FactType.MEDIAN_MEAN_GAP_WARNING` + détecteur `detect_median_mean_gap_warning` (priority 140) : émet un Fact quand `\|mean - median\| / median > 30 %` pour le moteur leader, importance HIGH si gap relatif ≥ 100 % (sinon MEDIUM). Garde-fou : ne déclenche pas si médiane nulle. Templates FR/EN sans nombres en dur (vérifié). L'arbitre marque la paire `{GLOBAL_LEADER_CER, MEDIAN_MEAN_GAP_WARNING}` comme **complémentaire** : les deux phrases peuvent coexister dans la synthèse pour nuancer le leader. +15 tests dans `test_sprint44_median_default.py` (propriété, tri sur cas asymétrique réaliste, fallback, déclenchement détecteur sur 4 cas dégénérés, importance, traçabilité anti-hallucination FR + EN, intégration build_synthesis). **Verrou levé** : la critique « le rapport classe sur la moyenne alors que les distributions patrimoniales sont asymétriques » est résolue ; le lecteur voit immédiatement le moteur le plus représentatif et est averti quand l'écart médiane/moyenne est suspect. |
|
|
|
|
| 265 |
## Contexte développement
|
| 266 |
|
| 267 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 268 |
+
- **Tests** : 1873 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprints 38+40+41 = NER livré bout-en-bout ; Sprints 39+42+43 = calibration livrée bout-en-bout côté rapport ; Sprint 44 = médiane par défaut ; Sprints 45+46 = stratification A.III livrée bout-en-bout ; Sprint 47 = Tesseract adapté pour confidences natives)
|
| 269 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 270 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 271 |
- **Transcript de la conversation de développement** :
|
|
@@ -2,10 +2,12 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
-
from typing import Optional
|
| 7 |
|
| 8 |
-
from picarones.engines.base import BaseOCREngine
|
| 9 |
|
| 10 |
try:
|
| 11 |
import pytesseract
|
|
@@ -16,6 +18,9 @@ except ImportError:
|
|
| 16 |
_PYTESSERACT_AVAILABLE = False
|
| 17 |
|
| 18 |
|
|
|
|
|
|
|
|
|
|
| 19 |
# Correspondance des valeurs PSM acceptées en argument YAML/CLI
|
| 20 |
_PSM_LABELS = {
|
| 21 |
0: "Orientation and script detection only",
|
|
@@ -47,7 +52,23 @@ class TesseractEngine(BaseOCREngine):
|
|
| 47 |
psm: 6 # Page Segmentation Mode (0-13)
|
| 48 |
oem: 3 # OCR Engine Mode (0=legacy, 3=LSTM, 3=default)
|
| 49 |
tesseract_cmd: tesseract # chemin vers l'exécutable si non standard
|
|
|
|
|
|
|
| 50 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
"""
|
| 52 |
|
| 53 |
execution_mode = "cpu"
|
|
@@ -61,6 +82,17 @@ class TesseractEngine(BaseOCREngine):
|
|
| 61 |
raise RuntimeError("pytesseract n'est pas installé.")
|
| 62 |
return pytesseract.get_tesseract_version().vstring
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
def _run_ocr(self, image_path: Path) -> str:
|
| 65 |
if not _PYTESSERACT_AVAILABLE:
|
| 66 |
raise RuntimeError(
|
|
@@ -73,16 +105,107 @@ class TesseractEngine(BaseOCREngine):
|
|
| 73 |
if tesseract_cmd:
|
| 74 |
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
| 75 |
|
| 76 |
-
lang = self.
|
| 77 |
-
psm = int(self.config.get("psm", 6))
|
| 78 |
-
oem = int(self.config.get("oem", 3))
|
| 79 |
-
|
| 80 |
-
custom_config = f"--oem {oem} --psm {psm}"
|
| 81 |
|
| 82 |
image = Image.open(image_path)
|
| 83 |
text: str = pytesseract.image_to_string(image, lang=lang, config=custom_config)
|
| 84 |
return text.strip()
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
@classmethod
|
| 87 |
def from_config(cls, config: Optional[dict] = None) -> "TesseractEngine":
|
| 88 |
return cls(config=config or {})
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import logging
|
| 6 |
+
import time
|
| 7 |
from pathlib import Path
|
| 8 |
+
from typing import Any, Optional
|
| 9 |
|
| 10 |
+
from picarones.engines.base import BaseOCREngine, EngineResult
|
| 11 |
|
| 12 |
try:
|
| 13 |
import pytesseract
|
|
|
|
| 18 |
_PYTESSERACT_AVAILABLE = False
|
| 19 |
|
| 20 |
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
# Correspondance des valeurs PSM acceptées en argument YAML/CLI
|
| 25 |
_PSM_LABELS = {
|
| 26 |
0: "Orientation and script detection only",
|
|
|
|
| 52 |
psm: 6 # Page Segmentation Mode (0-13)
|
| 53 |
oem: 3 # OCR Engine Mode (0=legacy, 3=LSTM, 3=default)
|
| 54 |
tesseract_cmd: tesseract # chemin vers l'exécutable si non standard
|
| 55 |
+
expose_confidences: true # défaut ; mettre à false pour économiser
|
| 56 |
+
# un appel image_to_data par document
|
| 57 |
```
|
| 58 |
+
|
| 59 |
+
Sprint 47 — exposition des token_confidences
|
| 60 |
+
--------------------------------------------
|
| 61 |
+
L'adapter appelle ``image_to_data`` en parallèle de
|
| 62 |
+
``image_to_string`` pour produire ``EngineResult.token_confidences``
|
| 63 |
+
(liste de ``{"token": str, "confidence": float}``). Le runner
|
| 64 |
+
Sprint 42 calcule alors automatiquement la calibration ECE/MCE.
|
| 65 |
+
|
| 66 |
+
Le texte ``EngineResult.text`` reste **strictement identique** à
|
| 67 |
+
celui produit par ``image_to_string`` (pas de reconstruction depuis
|
| 68 |
+
``image_to_data``) — rétrocompatibilité octet par octet.
|
| 69 |
+
|
| 70 |
+
Le coût supplémentaire est d'un second appel Tesseract par image.
|
| 71 |
+
Pour le désactiver : ``expose_confidences: false`` dans la config.
|
| 72 |
"""
|
| 73 |
|
| 74 |
execution_mode = "cpu"
|
|
|
|
| 82 |
raise RuntimeError("pytesseract n'est pas installé.")
|
| 83 |
return pytesseract.get_tesseract_version().vstring
|
| 84 |
|
| 85 |
+
def _tesseract_args(self) -> tuple[str, str]:
|
| 86 |
+
"""Retourne ``(lang, custom_config)`` selon la config courante.
|
| 87 |
+
|
| 88 |
+
Centralisé pour rester cohérent entre ``_run_ocr`` et
|
| 89 |
+
``_extract_token_confidences``.
|
| 90 |
+
"""
|
| 91 |
+
lang = self.config.get("lang", "fra")
|
| 92 |
+
psm = int(self.config.get("psm", 6))
|
| 93 |
+
oem = int(self.config.get("oem", 3))
|
| 94 |
+
return lang, f"--oem {oem} --psm {psm}"
|
| 95 |
+
|
| 96 |
def _run_ocr(self, image_path: Path) -> str:
|
| 97 |
if not _PYTESSERACT_AVAILABLE:
|
| 98 |
raise RuntimeError(
|
|
|
|
| 105 |
if tesseract_cmd:
|
| 106 |
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
| 107 |
|
| 108 |
+
lang, custom_config = self._tesseract_args()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
image = Image.open(image_path)
|
| 111 |
text: str = pytesseract.image_to_string(image, lang=lang, config=custom_config)
|
| 112 |
return text.strip()
|
| 113 |
|
| 114 |
+
def _extract_token_confidences(
|
| 115 |
+
self, image_path: Path,
|
| 116 |
+
) -> Optional[list[dict[str, Any]]]:
|
| 117 |
+
"""Extrait les confidences mot par mot via ``image_to_data``.
|
| 118 |
+
|
| 119 |
+
Retourne ``None`` quand pytesseract n'est pas disponible OU si
|
| 120 |
+
l'extraction échoue (best-effort — on ne casse pas l'OCR si
|
| 121 |
+
seule la calibration est indisponible).
|
| 122 |
+
|
| 123 |
+
Format de sortie compatible Sprint 42 : liste de dicts
|
| 124 |
+
``{"token": str, "confidence": float}`` avec confidence ∈
|
| 125 |
+
[0, 100] (Tesseract). Les non-mots (conf = -1) et tokens
|
| 126 |
+
vides sont ignorés.
|
| 127 |
+
"""
|
| 128 |
+
if not _PYTESSERACT_AVAILABLE:
|
| 129 |
+
return None
|
| 130 |
+
if not self.config.get("expose_confidences", True):
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
try:
|
| 134 |
+
tesseract_cmd = self.config.get("tesseract_cmd")
|
| 135 |
+
if tesseract_cmd:
|
| 136 |
+
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
| 137 |
+
|
| 138 |
+
lang, custom_config = self._tesseract_args()
|
| 139 |
+
image = Image.open(image_path)
|
| 140 |
+
data = pytesseract.image_to_data(
|
| 141 |
+
image,
|
| 142 |
+
lang=lang,
|
| 143 |
+
config=custom_config,
|
| 144 |
+
output_type=pytesseract.Output.DICT,
|
| 145 |
+
)
|
| 146 |
+
except Exception as exc: # noqa: BLE001
|
| 147 |
+
logger.warning(
|
| 148 |
+
"[tesseract] extraction des token_confidences dégradée : %s",
|
| 149 |
+
exc,
|
| 150 |
+
)
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
texts = data.get("text") or []
|
| 154 |
+
confs = data.get("conf") or []
|
| 155 |
+
if not texts or len(texts) != len(confs):
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
out: list[dict[str, Any]] = []
|
| 159 |
+
for tok_text, conf in zip(texts, confs):
|
| 160 |
+
tok_text = (tok_text or "").strip()
|
| 161 |
+
if not tok_text:
|
| 162 |
+
continue
|
| 163 |
+
try:
|
| 164 |
+
conf_val = float(conf)
|
| 165 |
+
except (TypeError, ValueError):
|
| 166 |
+
continue
|
| 167 |
+
# Tesseract met -1 pour les segments non-mots ; le runner
|
| 168 |
+
# Sprint 42 les filtre aussi mais on les écarte ici pour
|
| 169 |
+
# éviter le bruit dans les diagnostics.
|
| 170 |
+
if conf_val < 0:
|
| 171 |
+
continue
|
| 172 |
+
out.append({"token": tok_text, "confidence": conf_val})
|
| 173 |
+
return out or None
|
| 174 |
+
|
| 175 |
+
def run(self, image_path: str | Path) -> EngineResult:
|
| 176 |
+
"""Exécute Tesseract et expose les ``token_confidences`` natifs
|
| 177 |
+
(via ``image_to_data``) en plus du texte.
|
| 178 |
+
|
| 179 |
+
Surcharge du ``BaseOCREngine.run()`` (Sprint 33) qui ne
|
| 180 |
+
mettait pas de confidences. On garde la mesure du temps et la
|
| 181 |
+
gestion des erreurs. Si l'extraction des confidences échoue,
|
| 182 |
+
on retourne quand même le texte avec ``token_confidences =
|
| 183 |
+
None`` — le runner saute simplement le calcul de calibration
|
| 184 |
+
sur ce document.
|
| 185 |
+
"""
|
| 186 |
+
image_path = Path(image_path)
|
| 187 |
+
start = time.perf_counter()
|
| 188 |
+
text = ""
|
| 189 |
+
error: Optional[str] = None
|
| 190 |
+
token_confidences: Optional[list[dict[str, Any]]] = None
|
| 191 |
+
try:
|
| 192 |
+
text = self._run_ocr(image_path)
|
| 193 |
+
except Exception as exc: # noqa: BLE001
|
| 194 |
+
error = str(exc)
|
| 195 |
+
else:
|
| 196 |
+
# On n'extrait les confidences que si l'OCR de base a réussi
|
| 197 |
+
token_confidences = self._extract_token_confidences(image_path)
|
| 198 |
+
duration = time.perf_counter() - start
|
| 199 |
+
return EngineResult(
|
| 200 |
+
engine_name=self.name,
|
| 201 |
+
image_path=str(image_path),
|
| 202 |
+
text=text,
|
| 203 |
+
duration_seconds=round(duration, 4),
|
| 204 |
+
error=error,
|
| 205 |
+
metadata={"engine_version": self._safe_version()},
|
| 206 |
+
token_confidences=token_confidences,
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
@classmethod
|
| 210 |
def from_config(cls, config: Optional[dict] = None) -> "TesseractEngine":
|
| 211 |
return cls(config=config or {})
|
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 47 — adaptation Tesseract pour exposer token_confidences.
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``run()`` retourne ``EngineResult.token_confidences`` non-vide
|
| 6 |
+
quand pytesseract est disponible et qu'``image_to_data`` produit
|
| 7 |
+
des confidences.
|
| 8 |
+
2. Le ``text`` retourné reste **strictement identique** à ce que
|
| 9 |
+
produit ``image_to_string`` (rétrocompat octet par octet —
|
| 10 |
+
l'extraction des confidences n'altère jamais le texte).
|
| 11 |
+
3. ``expose_confidences=False`` désactive l'extraction (économie
|
| 12 |
+
d'un appel Tesseract par image).
|
| 13 |
+
4. Si ``image_to_data`` lève, l'OCR continue : ``text`` retourné,
|
| 14 |
+
``token_confidences = None``, warning loggé.
|
| 15 |
+
5. Les non-mots (conf = -1) et tokens vides sont filtrés.
|
| 16 |
+
6. Les confidences passent le runner Sprint 42 et alimentent
|
| 17 |
+
``DocumentResult.calibration_metrics``.
|
| 18 |
+
7. Si pytesseract n'est pas installé, ``token_confidences = None``
|
| 19 |
+
sans crash (fallback gracieux).
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import pytest
|
| 27 |
+
|
| 28 |
+
import picarones.engines.tesseract as tesseract_module
|
| 29 |
+
from picarones.engines.tesseract import TesseractEngine
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 33 |
+
# Mocks
|
| 34 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class _MockPytesseract:
|
| 38 |
+
"""Mock minimal de pytesseract qui simule une réponse réaliste."""
|
| 39 |
+
|
| 40 |
+
class Output:
|
| 41 |
+
DICT = "DICT"
|
| 42 |
+
|
| 43 |
+
class pytesseract: # noqa: N801 (imite le namespace réel)
|
| 44 |
+
tesseract_cmd: str = "tesseract"
|
| 45 |
+
|
| 46 |
+
def __init__(
|
| 47 |
+
self,
|
| 48 |
+
text: str = "Bonjour le monde",
|
| 49 |
+
data: dict | None = None,
|
| 50 |
+
raise_on_data: bool = False,
|
| 51 |
+
raise_on_string: bool = False,
|
| 52 |
+
) -> None:
|
| 53 |
+
self._text = text
|
| 54 |
+
self._data = data or {
|
| 55 |
+
"text": ["Bonjour", "le", "monde"],
|
| 56 |
+
"conf": [95.5, 88.0, 91.3],
|
| 57 |
+
}
|
| 58 |
+
self.raise_on_data = raise_on_data
|
| 59 |
+
self.raise_on_string = raise_on_string
|
| 60 |
+
|
| 61 |
+
def image_to_string(self, image, lang=None, config=None) -> str:
|
| 62 |
+
if self.raise_on_string:
|
| 63 |
+
raise RuntimeError("simulated OCR failure")
|
| 64 |
+
return self._text
|
| 65 |
+
|
| 66 |
+
def image_to_data(self, image, lang=None, config=None, output_type=None) -> dict:
|
| 67 |
+
if self.raise_on_data:
|
| 68 |
+
raise RuntimeError("simulated image_to_data failure")
|
| 69 |
+
return self._data
|
| 70 |
+
|
| 71 |
+
def get_tesseract_version(self):
|
| 72 |
+
class _V:
|
| 73 |
+
vstring = "5.0.0-mock"
|
| 74 |
+
return _V()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class _MockImage:
|
| 78 |
+
@staticmethod
|
| 79 |
+
def open(path):
|
| 80 |
+
return object() # placeholder
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@pytest.fixture
|
| 84 |
+
def patched_tesseract(monkeypatch: pytest.MonkeyPatch) -> _MockPytesseract:
|
| 85 |
+
"""Patche le module pour utiliser le mock."""
|
| 86 |
+
mock = _MockPytesseract()
|
| 87 |
+
monkeypatch.setattr(tesseract_module, "pytesseract", mock)
|
| 88 |
+
monkeypatch.setattr(tesseract_module, "Image", _MockImage)
|
| 89 |
+
monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", True)
|
| 90 |
+
return mock
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 94 |
+
# 1-2. run() expose token_confidences sans modifier le texte
|
| 95 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class TestRunExposesConfidences:
|
| 99 |
+
def test_run_returns_token_confidences(
|
| 100 |
+
self, patched_tesseract: _MockPytesseract, tmp_path: Path,
|
| 101 |
+
) -> None:
|
| 102 |
+
img = tmp_path / "p.png"
|
| 103 |
+
img.write_bytes(b"x")
|
| 104 |
+
engine = TesseractEngine()
|
| 105 |
+
result = engine.run(img)
|
| 106 |
+
|
| 107 |
+
assert result.token_confidences is not None
|
| 108 |
+
assert len(result.token_confidences) == 3
|
| 109 |
+
assert result.token_confidences[0] == {
|
| 110 |
+
"token": "Bonjour", "confidence": pytest.approx(95.5),
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
def test_text_matches_image_to_string(
|
| 114 |
+
self, patched_tesseract: _MockPytesseract, tmp_path: Path,
|
| 115 |
+
) -> None:
|
| 116 |
+
"""Le texte de l'EngineResult doit être strictement celui de
|
| 117 |
+
image_to_string, pas une reconstruction depuis image_to_data."""
|
| 118 |
+
img = tmp_path / "p.png"
|
| 119 |
+
img.write_bytes(b"x")
|
| 120 |
+
engine = TesseractEngine()
|
| 121 |
+
result = engine.run(img)
|
| 122 |
+
|
| 123 |
+
assert result.text == "Bonjour le monde"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 127 |
+
# 3. expose_confidences=False désactive
|
| 128 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class TestExposeConfidencesFlag:
|
| 132 |
+
def test_disabled_returns_no_confidences(
|
| 133 |
+
self, patched_tesseract: _MockPytesseract, tmp_path: Path,
|
| 134 |
+
) -> None:
|
| 135 |
+
img = tmp_path / "p.png"
|
| 136 |
+
img.write_bytes(b"x")
|
| 137 |
+
engine = TesseractEngine(config={"expose_confidences": False})
|
| 138 |
+
result = engine.run(img)
|
| 139 |
+
|
| 140 |
+
assert result.text == "Bonjour le monde"
|
| 141 |
+
assert result.token_confidences is None
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 145 |
+
# 4. image_to_data échoue → fallback gracieux
|
| 146 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class TestExtractionFailureFallback:
|
| 150 |
+
def test_image_to_data_failure_returns_none_confidences(
|
| 151 |
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
| 152 |
+
caplog: pytest.LogCaptureFixture,
|
| 153 |
+
) -> None:
|
| 154 |
+
mock = _MockPytesseract(raise_on_data=True)
|
| 155 |
+
monkeypatch.setattr(tesseract_module, "pytesseract", mock)
|
| 156 |
+
monkeypatch.setattr(tesseract_module, "Image", _MockImage)
|
| 157 |
+
monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", True)
|
| 158 |
+
|
| 159 |
+
img = tmp_path / "p.png"
|
| 160 |
+
img.write_bytes(b"x")
|
| 161 |
+
engine = TesseractEngine()
|
| 162 |
+
with caplog.at_level("WARNING", logger="picarones.engines.tesseract"):
|
| 163 |
+
result = engine.run(img)
|
| 164 |
+
|
| 165 |
+
# OCR a réussi sur le texte
|
| 166 |
+
assert result.text == "Bonjour le monde"
|
| 167 |
+
assert result.error is None
|
| 168 |
+
# Mais les confidences sont None
|
| 169 |
+
assert result.token_confidences is None
|
| 170 |
+
# Et un warning explicite a été émis
|
| 171 |
+
assert any("token_confidences" in rec.message for rec in caplog.records)
|
| 172 |
+
|
| 173 |
+
def test_image_to_string_failure_keeps_error(
|
| 174 |
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
| 175 |
+
) -> None:
|
| 176 |
+
"""Si l'OCR principal lève, on n'essaie même pas d'extraire les
|
| 177 |
+
confidences (cohérent avec le contrat de BaseOCREngine.run)."""
|
| 178 |
+
mock = _MockPytesseract(raise_on_string=True)
|
| 179 |
+
monkeypatch.setattr(tesseract_module, "pytesseract", mock)
|
| 180 |
+
monkeypatch.setattr(tesseract_module, "Image", _MockImage)
|
| 181 |
+
monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", True)
|
| 182 |
+
|
| 183 |
+
img = tmp_path / "p.png"
|
| 184 |
+
img.write_bytes(b"x")
|
| 185 |
+
engine = TesseractEngine()
|
| 186 |
+
result = engine.run(img)
|
| 187 |
+
|
| 188 |
+
assert result.error == "simulated OCR failure"
|
| 189 |
+
assert result.text == ""
|
| 190 |
+
assert result.token_confidences is None
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 194 |
+
# 5. Filtrage des non-mots et tokens vides
|
| 195 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class TestTokenFiltering:
|
| 199 |
+
def test_negative_conf_filtered(
|
| 200 |
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
| 201 |
+
) -> None:
|
| 202 |
+
mock = _MockPytesseract(
|
| 203 |
+
text="Bonjour monde",
|
| 204 |
+
data={
|
| 205 |
+
"text": ["Bonjour", "", "monde", "."],
|
| 206 |
+
"conf": [95.0, -1.0, 88.0, -1.0],
|
| 207 |
+
},
|
| 208 |
+
)
|
| 209 |
+
monkeypatch.setattr(tesseract_module, "pytesseract", mock)
|
| 210 |
+
monkeypatch.setattr(tesseract_module, "Image", _MockImage)
|
| 211 |
+
monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", True)
|
| 212 |
+
|
| 213 |
+
img = tmp_path / "p.png"
|
| 214 |
+
img.write_bytes(b"x")
|
| 215 |
+
engine = TesseractEngine()
|
| 216 |
+
result = engine.run(img)
|
| 217 |
+
|
| 218 |
+
assert result.token_confidences is not None
|
| 219 |
+
# Seuls "Bonjour" et "monde" sont retenus (conf > 0 et token non vide)
|
| 220 |
+
tokens = [tc["token"] for tc in result.token_confidences]
|
| 221 |
+
assert tokens == ["Bonjour", "monde"]
|
| 222 |
+
|
| 223 |
+
def test_mismatched_lengths_returns_none(
|
| 224 |
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
| 225 |
+
) -> None:
|
| 226 |
+
# text et conf de longueurs différentes → format inattendu
|
| 227 |
+
mock = _MockPytesseract(
|
| 228 |
+
text="Bonjour",
|
| 229 |
+
data={"text": ["Bonjour", "le"], "conf": [95.0]},
|
| 230 |
+
)
|
| 231 |
+
monkeypatch.setattr(tesseract_module, "pytesseract", mock)
|
| 232 |
+
monkeypatch.setattr(tesseract_module, "Image", _MockImage)
|
| 233 |
+
monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", True)
|
| 234 |
+
|
| 235 |
+
img = tmp_path / "p.png"
|
| 236 |
+
img.write_bytes(b"x")
|
| 237 |
+
engine = TesseractEngine()
|
| 238 |
+
result = engine.run(img)
|
| 239 |
+
|
| 240 |
+
assert result.token_confidences is None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 244 |
+
# 6. Bout-en-bout avec le runner : calibration_metrics calculée
|
| 245 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
class TestEndToEndWithRunner:
|
| 249 |
+
def test_runner_picks_up_confidences_and_computes_calibration(
|
| 250 |
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
| 251 |
+
) -> None:
|
| 252 |
+
from picarones.core.runner import _compute_document_result
|
| 253 |
+
from picarones.engines.base import EngineResult
|
| 254 |
+
|
| 255 |
+
# Simulation : on appelle directement _compute_document_result
|
| 256 |
+
# avec un EngineResult mocké qui porte des confidences. On
|
| 257 |
+
# vérifie que la calibration_metrics est bien attachée.
|
| 258 |
+
ocr = EngineResult(
|
| 259 |
+
engine_name="tess",
|
| 260 |
+
image_path="/tmp/x.png",
|
| 261 |
+
text="alpha beta gamma",
|
| 262 |
+
duration_seconds=0.1,
|
| 263 |
+
token_confidences=[
|
| 264 |
+
{"token": "alpha", "confidence": 95.0},
|
| 265 |
+
{"token": "beta", "confidence": 95.0},
|
| 266 |
+
{"token": "gamma", "confidence": 95.0},
|
| 267 |
+
],
|
| 268 |
+
)
|
| 269 |
+
dr = _compute_document_result(
|
| 270 |
+
doc_id="d1", image_path="/tmp/x.png",
|
| 271 |
+
ground_truth="alpha beta gamma",
|
| 272 |
+
ocr_result=ocr, char_exclude=None,
|
| 273 |
+
)
|
| 274 |
+
assert dr.calibration_metrics is not None
|
| 275 |
+
# 3 tokens, tous corrects → accuracy = 1, conf = 0.95
|
| 276 |
+
assert dr.calibration_metrics["overall_accuracy"] == 1.0
|
| 277 |
+
assert dr.calibration_metrics["overall_confidence"] == pytest.approx(0.95)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 281 |
+
# 7. pytesseract absent → fallback gracieux
|
| 282 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
class TestPytesseractAbsent:
|
| 286 |
+
def test_extraction_returns_none_without_pytesseract(
|
| 287 |
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
| 288 |
+
) -> None:
|
| 289 |
+
monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", False)
|
| 290 |
+
|
| 291 |
+
engine = TesseractEngine()
|
| 292 |
+
result = engine._extract_token_confidences(tmp_path / "p.png")
|
| 293 |
+
assert result is None
|