Spaces:
Running
sprint53: A.II.2.1 Reading order F1 ICDAR 2015 — couche de calcul
Browse filesSuite du Sprint 52 dans l'axe A.II.2 (métriques structurelles).
Sur un manuscrit glosé ou un journal multi-colonnes, un moteur peut
avoir un excellent CER caractère et un ordre de lecture
catastrophique — le résultat est inutilisable pour la recherche
plein texte ou la reconstitution narrative.
Métrique standard ICDAR 2015 (Antonacopoulos et al.) : pour chaque
paire (a, b) où a précède strictement b dans la GT, on vérifie si
a précède aussi b dans l'hypothèse. F1 = harmonic mean de
precision/recall.
Nouveau picarones/core/reading_order.py
- compute_reading_order_metrics(ref_order, hyp_order) retourne
precision/recall/F1 + détails complets : TP, FP, FN, paires
totales, régions communes vs ref_only vs hyp_only.
- reading_order_f1 : raccourci pour récupérer juste le F1.
- Conventions :
- Doublons : première occurrence retenue (cohérent ICDAR, IDs
uniques).
- Vide ou None : F1 = 0 (pas de récompense gratuite).
- Single region : 0 paire → F1 = 0 (convention de bord).
- Format directement compatible avec ReadingOrderGT.region_order
du Sprint 32.
reading_order_f1 enregistré dans le registre typé Sprint 34 pour
la jonction (READING_ORDER, READING_ORDER) — appelable via
compute_at_junction comme les autres métriques.
Tests : +16 dans test_sprint53_reading_order.py couvrant :
- canoniques : identique → F1=1, inversé → F1=0, permutation
locale (b↔c → 5/6), insertion (TP préservés + FP), suppression
- dégénérés : vide bilatéral, vide unilatéral (FP ou FN),
single region, None, doublons (première occurrence)
- comptages : régions communes/ref_only/hyp_only séparés,
n_pairs cohérent avec C(n, 2)
- intégration registre typé : sélection (READING_ORDER,
READING_ORDER), compute_at_junction, équivalence shortcut/full
Suite complète : 1962 → 1978 passed, 2 skipped, 0 failed.
Stratégie de découpage cohérente avec NER (Sprint 38), calibration
(Sprint 39), Flesch (Sprint 52) : couche de calcul pure d'abord,
câblage runner + HTML aux sprints suivants. Reste pour A.II.2 :
A.II.2.2 Layout F1 par type de région.
- CHANGELOG.md +31 -10
- CLAUDE.md +2 -1
- picarones/core/reading_order.py +196 -0
- tests/test_sprint53_reading_order.py +207 -0
|
@@ -16,6 +16,31 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 52 — A.II.2.3 Différence Flesch : couche de calcul
|
| 20 |
(démarrage de l'Étape 3 / axe A — métriques structurelles).**
|
| 21 |
Stratégie identique aux Sprints 35/38/39 (couche pure d'abord,
|
|
@@ -659,20 +684,16 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 659 |
|
| 660 |
### Tests
|
| 661 |
|
| 662 |
-
- 1478 →
|
| 663 |
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
|
| 664 |
+32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
|
| 665 |
+43 Sprint 43, +15 Sprint 44, +16 Sprint 45, +38 Sprint 46,
|
| 666 |
+9 Sprint 47, +14 Sprint 48, +17 Sprint 49, +17 Sprint 50,
|
| 667 |
-
+16 Sprint 51, +25 Sprint 52). Aucune régression.
|
| 668 |
-
close ; Étape 2 du plan d'évolution intégralement
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
adapters OCR (Tesseract, Pero, Mistral, Google Vision, Azure DI)
|
| 673 |
-
exposent désormais leurs `token_confidences` natifs.
|
| 674 |
-
**Étape 3 démarrée :** Flesch (A.II.2.3) couche de calcul livrée
|
| 675 |
-
(Sprint 52).
|
| 676 |
|
| 677 |
---
|
| 678 |
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 53 — A.II.2.1 Reading order F1 (ICDAR 2015) : couche de
|
| 20 |
+
calcul.** Suite du Sprint 52 dans l'axe A.II.2 (métriques
|
| 21 |
+
structurelles). Sur un manuscrit glosé ou un journal multi-colonnes,
|
| 22 |
+
un moteur peut avoir un excellent CER caractère et un ordre de
|
| 23 |
+
lecture catastrophique — le CER seul ne capture pas cette
|
| 24 |
+
dimension.
|
| 25 |
+
- Nouveau module `picarones/core/reading_order.py` :
|
| 26 |
+
- ``compute_reading_order_metrics(ref_order, hyp_order)`` :
|
| 27 |
+
pour chaque paire ``(a, b)`` où ``a`` précède ``b`` dans la GT,
|
| 28 |
+
vérifie si ``a`` précède aussi ``b`` dans l'hypothèse. Retourne
|
| 29 |
+
precision/recall/F1 + détails (TP/FP/FN, paires totales, régions
|
| 30 |
+
communes vs disjointes).
|
| 31 |
+
- ``reading_order_f1`` : raccourci qui retourne juste le F1.
|
| 32 |
+
- Conventions : doublons traités à la première occurrence,
|
| 33 |
+
séquences ``None``/vides → F1 = 0 (pas de récompense gratuite),
|
| 34 |
+
séquence à 1 région → 0 paire émise → F1 = 0 (convention de bord).
|
| 35 |
+
- Format compatible avec ``ReadingOrderGT.region_order`` du
|
| 36 |
+
Sprint 32 — l'utilisateur fournit directement la liste d'IDs.
|
| 37 |
+
- ``reading_order_f1`` enregistré dans le registre typé Sprint 34
|
| 38 |
+
pour la jonction ``(READING_ORDER, READING_ORDER)``.
|
| 39 |
+
- +16 tests dans `test_sprint53_reading_order.py` (cas canoniques :
|
| 40 |
+
identique → F1=1, inversé → F1=0, permutation locale, insertion,
|
| 41 |
+
suppression ; cas dégénérés : vide, single region, doublons,
|
| 42 |
+
None ; comptages détaillés ; intégration registre typé).
|
| 43 |
+
|
| 44 |
- **Sprint 52 — A.II.2.3 Différence Flesch : couche de calcul
|
| 45 |
(démarrage de l'Étape 3 / axe A — métriques structurelles).**
|
| 46 |
Stratégie identique aux Sprints 35/38/39 (couche pure d'abord,
|
|
|
|
| 684 |
|
| 685 |
### Tests
|
| 686 |
|
| 687 |
+
- 1478 → 1978 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
|
| 688 |
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
|
| 689 |
+32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
|
| 690 |
+43 Sprint 43, +15 Sprint 44, +16 Sprint 45, +38 Sprint 46,
|
| 691 |
+9 Sprint 47, +14 Sprint 48, +17 Sprint 49, +17 Sprint 50,
|
| 692 |
+
+16 Sprint 51, +25 Sprint 52, +16 Sprint 53). Aucune régression.
|
| 693 |
+
**Phase 0 close ; Étape 2 du plan d'évolution intégralement
|
| 694 |
+
livrée ; Étape 3 démarrée :** Flesch (A.II.2.3, Sprint 52) et
|
| 695 |
+
Reading order F1 ICDAR 2015 (A.II.2.1, Sprint 53) couches de
|
| 696 |
+
calcul livrées.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
|
| 698 |
---
|
| 699 |
|
|
@@ -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 |
| 52 | **Sprint 21 du plan d'évolution 2026 — Étape 3 / axe A.II.2.3 : différence de score Flesch (couche de calcul)**. Premier sprint de l'Étape 3 (métriques structurelles), démarré après la clôture de l'Étape 2. Nouveau module `picarones/core/readability.py` : `count_syllables_word` (heuristique groupes de voyelles + diacritiques FR/EN), `count_words`/`count_sentences`, `flesch_score(text, lang)` avec coefficients FR (Kandel-Moles 1958) et EN (Flesch 1948), score borné `[0, 100]`. `flesch_delta(reference, hypothesis, lang)` retourne `Flesch(OCR) - Flesch(GT)` — **positif = signal d'over-normalisation LLM**. **Aucun alignement caractère/mot requis** : la métrique reste calculable même quand l'OCR est très dégradé, ce qui en fait l'outil le plus fiable pour repérer les VLM/LLM hallucinant du texte moderne plausible mais déconnecté de la GT. `flesch_delta_fr` et `flesch_delta_en` enregistrés dans le registre typé Sprint 34 pour la jonction `(TEXT, TEXT)`. +25 tests dans `test_sprint52_readability.py` (compteurs avec cas limites, score borné, FR/EN cohérents, **cas réaliste de modernisation LLM → delta > 10 pts**, intégration registre typé). **Verrou levé** : les détecteurs narratifs futurs peuvent maintenant signaler automatiquement une over-normalisation par LLM via le delta Flesch, sans dépendre de l'alignement OCR/GT (métrique robuste aux pires cas). |
|
| 211 |
| 51 | **Sprint 20 du plan d'évolution 2026 — Étape 2 / adaptation engines : Azure DI expose `Word.confidence` (clôture de l'adaptation engines)**. Suite directe des Sprints 47-50. La réponse Azure expose `analyzeResult.pages[].words[]` avec `content` et `confidence` ∈ [0, 1]. Refactor : `_run_ocr_with_result(image_path) → (text, analyze_result_dict)` centralise les deux chemins (SDK `azure-ai-documentintelligence` et REST direct via `urllib` avec polling Azure asynchrone). `_sdk_result_to_dict` convertit l'objet SDK en dict normalisé identique au REST. `_extract_token_confidences_from_result` parcourt `pages[].words[]`, filtre les confidences None/négatives et contenus vides. Texte préservé octet par octet (extraction depuis `pages[].lines[]`). Flag `expose_confidences: false`. API appelée une seule fois. +16 tests dans `test_sprint51_azure_confidences.py` (extraction multi-pages, filtrage 4 cas, cas dégénérés 4 cas, conversion SDK → dict, surcharge `run()` avec mock, échec API, intégration runner). **Verrou levé** : tous les 5 adapters OCR (Tesseract, Pero OCR, Mistral OCR, Google Vision, Azure DI) exposent désormais leurs `token_confidences` natifs — l'utilisateur obtient automatiquement ECE/MCE/reliability dans le rapport quel que soit le moteur. **L'Étape 2 du plan d'évolution 2026 est intégralement livrée bout-en-bout.** |
|
| 212 |
| 50 | **Sprint 19 du plan d'évolution 2026 — Étape 2 / adaptation engines : Google Vision expose `Word.confidence`**. Suite directe des Sprints 47-49. ``DOCUMENT_TEXT_DETECTION`` expose ``Word.confidence`` au niveau mot sur ``page > block > paragraph > word``. Refactor : `_run_ocr_with_full_annotation(image_path) → (text, full_dict)` centralise les deux chemins (SDK `google-cloud-vision` et REST via `urllib`). `_sdk_full_text_to_dict` convertit le proto SDK en dict normalisé identique au REST pour traitement uniforme. `_extract_token_confidences_from_full_text` parcourt la hiérarchie et reconstruit chaque mot par concaténation des `word.symbols[i].text`. Confidence ∈ [0, 1] (format runner Sprint 42 direct). Filtrage cohérent (conf None/négative, mots vides ignorés). `TEXT_DETECTION` (mode court) → `token_confidences = None`. Flag `expose_confidences: false`. API appelée une seule fois. +17 tests dans `test_sprint50_google_vision_confidences.py` (reconstruction depuis symbols, multi-pages/blocks, filtrage 5 cas, conversion SDK → dict, surcharge `run()` avec mock, REST avec urllib mocké, intégration runner). **Verrou levé** : un benchmark Google Vision en mode `DOCUMENT_TEXT_DETECTION` produit automatiquement ECE/MCE/reliability dans le rapport. Reste Azure DI à adapter. |
|
|
@@ -270,7 +271,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 270 |
## Contexte développement
|
| 271 |
|
| 272 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 273 |
-
- **Tests** :
|
| 274 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 275 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 276 |
- **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 |
+
| 53 | **Sprint 22 du plan d'évolution 2026 — Étape 3 / axe A.II.2.1 : Reading order F1 ICDAR 2015 (couche de calcul)**. Métrique standard d'Antonacopoulos et al. : sur un manuscrit glosé ou un journal multi-colonnes, un moteur peut avoir un excellent CER caractère et un ordre de lecture catastrophique. Le module `picarones/core/reading_order.py` expose `compute_reading_order_metrics(ref_order, hyp_order)` qui calcule, pour chaque paire `(a, b)` où `a` précède `b` dans la GT, si `a` précède aussi `b` dans l'hypothèse — retourne precision/recall/F1 + détails (TP/FP/FN, régions communes vs disjointes). Conventions : doublons traités à la première occurrence, vide/None → F1 = 0 (pas de récompense gratuite), single region → 0 paire émise. Format compatible direct avec `ReadingOrderGT.region_order` du Sprint 32. `reading_order_f1` enregistré dans le registre typé Sprint 34 pour la jonction `(READING_ORDER, READING_ORDER)`. +16 tests dans `test_sprint53_reading_order.py` (canoniques : identique→1, inversé→0, permutation locale, insertion, suppression ; dégénérés : vide, single, doublons, None ; comptages ; registre). **Verrou levé** : un benchmark dont le corpus a une GT `reading_order` peut désormais classer les moteurs sur leur fidélité à l'ordre de lecture des régions ALTO/PAGE — métrique critique pour les manuscrits glosés et les journaux multi-colonnes. |
|
| 211 |
| 52 | **Sprint 21 du plan d'évolution 2026 — Étape 3 / axe A.II.2.3 : différence de score Flesch (couche de calcul)**. Premier sprint de l'Étape 3 (métriques structurelles), démarré après la clôture de l'Étape 2. Nouveau module `picarones/core/readability.py` : `count_syllables_word` (heuristique groupes de voyelles + diacritiques FR/EN), `count_words`/`count_sentences`, `flesch_score(text, lang)` avec coefficients FR (Kandel-Moles 1958) et EN (Flesch 1948), score borné `[0, 100]`. `flesch_delta(reference, hypothesis, lang)` retourne `Flesch(OCR) - Flesch(GT)` — **positif = signal d'over-normalisation LLM**. **Aucun alignement caractère/mot requis** : la métrique reste calculable même quand l'OCR est très dégradé, ce qui en fait l'outil le plus fiable pour repérer les VLM/LLM hallucinant du texte moderne plausible mais déconnecté de la GT. `flesch_delta_fr` et `flesch_delta_en` enregistrés dans le registre typé Sprint 34 pour la jonction `(TEXT, TEXT)`. +25 tests dans `test_sprint52_readability.py` (compteurs avec cas limites, score borné, FR/EN cohérents, **cas réaliste de modernisation LLM → delta > 10 pts**, intégration registre typé). **Verrou levé** : les détecteurs narratifs futurs peuvent maintenant signaler automatiquement une over-normalisation par LLM via le delta Flesch, sans dépendre de l'alignement OCR/GT (métrique robuste aux pires cas). |
|
| 212 |
| 51 | **Sprint 20 du plan d'évolution 2026 — Étape 2 / adaptation engines : Azure DI expose `Word.confidence` (clôture de l'adaptation engines)**. Suite directe des Sprints 47-50. La réponse Azure expose `analyzeResult.pages[].words[]` avec `content` et `confidence` ∈ [0, 1]. Refactor : `_run_ocr_with_result(image_path) → (text, analyze_result_dict)` centralise les deux chemins (SDK `azure-ai-documentintelligence` et REST direct via `urllib` avec polling Azure asynchrone). `_sdk_result_to_dict` convertit l'objet SDK en dict normalisé identique au REST. `_extract_token_confidences_from_result` parcourt `pages[].words[]`, filtre les confidences None/négatives et contenus vides. Texte préservé octet par octet (extraction depuis `pages[].lines[]`). Flag `expose_confidences: false`. API appelée une seule fois. +16 tests dans `test_sprint51_azure_confidences.py` (extraction multi-pages, filtrage 4 cas, cas dégénérés 4 cas, conversion SDK → dict, surcharge `run()` avec mock, échec API, intégration runner). **Verrou levé** : tous les 5 adapters OCR (Tesseract, Pero OCR, Mistral OCR, Google Vision, Azure DI) exposent désormais leurs `token_confidences` natifs — l'utilisateur obtient automatiquement ECE/MCE/reliability dans le rapport quel que soit le moteur. **L'Étape 2 du plan d'évolution 2026 est intégralement livrée bout-en-bout.** |
|
| 213 |
| 50 | **Sprint 19 du plan d'évolution 2026 — Étape 2 / adaptation engines : Google Vision expose `Word.confidence`**. Suite directe des Sprints 47-49. ``DOCUMENT_TEXT_DETECTION`` expose ``Word.confidence`` au niveau mot sur ``page > block > paragraph > word``. Refactor : `_run_ocr_with_full_annotation(image_path) → (text, full_dict)` centralise les deux chemins (SDK `google-cloud-vision` et REST via `urllib`). `_sdk_full_text_to_dict` convertit le proto SDK en dict normalisé identique au REST pour traitement uniforme. `_extract_token_confidences_from_full_text` parcourt la hiérarchie et reconstruit chaque mot par concaténation des `word.symbols[i].text`. Confidence ∈ [0, 1] (format runner Sprint 42 direct). Filtrage cohérent (conf None/négative, mots vides ignorés). `TEXT_DETECTION` (mode court) → `token_confidences = None`. Flag `expose_confidences: false`. API appelée une seule fois. +17 tests dans `test_sprint50_google_vision_confidences.py` (reconstruction depuis symbols, multi-pages/blocks, filtrage 5 cas, conversion SDK → dict, surcharge `run()` avec mock, REST avec urllib mocké, intégration runner). **Verrou levé** : un benchmark Google Vision en mode `DOCUMENT_TEXT_DETECTION` produit automatiquement ECE/MCE/reliability dans le rapport. Reste Azure DI à adapter. |
|
|
|
|
| 271 |
## Contexte développement
|
| 272 |
|
| 273 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 274 |
+
- **Tests** : 1978 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 ; Sprints 47-51 = les 5 adapters OCR exposent leurs confidences natives ; **Étape 2 close** ; Sprints 52-53 = Flesch + Reading order F1 ICDAR couches de calcul (Étape 3 / axe A.II.2 démarrée))
|
| 275 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 276 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 277 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reading order F1 (ICDAR 2015, Antonacopoulos) — Sprint 53.
|
| 2 |
+
|
| 3 |
+
Sprint 53 — A.II.2.1 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Sur un manuscrit glosé, un journal multi-colonnes ou un registre
|
| 8 |
+
paroissial complexe, le **classement des moteurs en CER** peut être
|
| 9 |
+
trompeur : un moteur peut avoir un excellent CER caractère et un
|
| 10 |
+
**ordre de lecture catastrophique**. Le résultat est inutilisable
|
| 11 |
+
pour la recherche plein texte (Elastic, Solr) ou pour reconstituer
|
| 12 |
+
une narration linéaire.
|
| 13 |
+
|
| 14 |
+
La métrique standard est définie par Antonacopoulos et al. dans
|
| 15 |
+
ICDAR 2015 — F1 sur les **paires d'ordre relatif** entre régions
|
| 16 |
+
ALTO/PAGE. Pour chaque paire ``(a, b)`` telle que ``a`` précède
|
| 17 |
+
``b`` dans la GT :
|
| 18 |
+
|
| 19 |
+
- **TP** si ``a`` précède aussi ``b`` dans l'hypothèse,
|
| 20 |
+
- **FN** si la paire est manquante (régions absentes ou ordre
|
| 21 |
+
inversé) côté hypothèse,
|
| 22 |
+
- **FP** si une paire ``(a, b)`` apparaît dans l'hypothèse alors que
|
| 23 |
+
la GT n'a pas cet ordre (régions hallucinées ou inversion).
|
| 24 |
+
|
| 25 |
+
Le F1 est la moyenne harmonique des deux.
|
| 26 |
+
|
| 27 |
+
Stratégie de découpage
|
| 28 |
+
----------------------
|
| 29 |
+
Cohérent avec NER (Sprint 38), calibration (Sprint 39), Flesch
|
| 30 |
+
(Sprint 52) : couche de calcul pure d'abord. L'utilisateur fournit
|
| 31 |
+
deux listes ordonnées d'IDs de régions (typiquement extraites de
|
| 32 |
+
ALTO/PAGE par un parser amont). Le câblage runner et la vue HTML
|
| 33 |
+
suivent dans des sprints dédiés.
|
| 34 |
+
|
| 35 |
+
Compatible directement avec ``ReadingOrderGT`` du Sprint 32 :
|
| 36 |
+
``ReadingOrderGT.region_order`` est exactement le format attendu.
|
| 37 |
+
|
| 38 |
+
Convention sur les régions
|
| 39 |
+
--------------------------
|
| 40 |
+
- Les IDs sont des chaînes (``"r_1"``, ``"region_main"``, etc.).
|
| 41 |
+
- Les **doublons** sont ignorés au calcul des paires ordonnées
|
| 42 |
+
(chaque ID compte une fois par séquence).
|
| 43 |
+
- Une région présente dans la GT mais absente de l'hypothèse
|
| 44 |
+
contribue aux paires FN.
|
| 45 |
+
- Une région présente dans l'hypothèse mais absente de la GT
|
| 46 |
+
contribue aux paires FP.
|
| 47 |
+
- Si une séquence a < 2 régions distinctes, aucune paire n'est
|
| 48 |
+
émise — le F1 retourne ``0.0`` ou ``1.0`` selon que les deux
|
| 49 |
+
séquences soient identiques.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
from __future__ import annotations
|
| 53 |
+
|
| 54 |
+
import logging
|
| 55 |
+
from itertools import combinations
|
| 56 |
+
from typing import Iterable
|
| 57 |
+
|
| 58 |
+
from picarones.core.metric_registry import register_metric
|
| 59 |
+
from picarones.core.modules import ArtifactType
|
| 60 |
+
|
| 61 |
+
logger = logging.getLogger(__name__)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 65 |
+
# Helpers
|
| 66 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _ordered_pairs(sequence: list[str]) -> set[tuple[str, str]]:
|
| 70 |
+
"""Retourne l'ensemble des paires ``(a, b)`` telles que ``a``
|
| 71 |
+
précède strictement ``b`` dans ``sequence``.
|
| 72 |
+
|
| 73 |
+
Doublons : chaque ID est traité une seule fois (première occurrence
|
| 74 |
+
dans la séquence). Cohérent avec ICDAR 2015 où les régions ont
|
| 75 |
+
des IDs uniques.
|
| 76 |
+
"""
|
| 77 |
+
seen: list[str] = []
|
| 78 |
+
seen_set: set[str] = set()
|
| 79 |
+
for r in sequence:
|
| 80 |
+
if r not in seen_set:
|
| 81 |
+
seen.append(r)
|
| 82 |
+
seen_set.add(r)
|
| 83 |
+
return set(combinations(seen, 2))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _normalize_input(value: Iterable[str] | None) -> list[str]:
|
| 87 |
+
"""Coerce une entrée en list[str], en filtrant les valeurs vides."""
|
| 88 |
+
if value is None:
|
| 89 |
+
return []
|
| 90 |
+
return [str(v) for v in value if v is not None and str(v).strip()]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 94 |
+
# Métrique principale
|
| 95 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def compute_reading_order_metrics(
|
| 99 |
+
reference_order: Iterable[str] | None,
|
| 100 |
+
hypothesis_order: Iterable[str] | None,
|
| 101 |
+
) -> dict:
|
| 102 |
+
"""Calcule precision / recall / F1 sur l'ordre relatif des régions.
|
| 103 |
+
|
| 104 |
+
Parameters
|
| 105 |
+
----------
|
| 106 |
+
reference_order:
|
| 107 |
+
Séquence ordonnée d'IDs de régions issue de la GT (typiquement
|
| 108 |
+
``ReadingOrderGT.region_order`` du Sprint 32).
|
| 109 |
+
hypothesis_order:
|
| 110 |
+
Séquence ordonnée d'IDs de régions produite par un moteur
|
| 111 |
+
OCR/HTR ou un reconstructeur ALTO.
|
| 112 |
+
|
| 113 |
+
Returns
|
| 114 |
+
-------
|
| 115 |
+
dict
|
| 116 |
+
``{"precision", "recall", "f1", "true_positives",
|
| 117 |
+
"false_positives", "false_negatives", "n_ref_pairs",
|
| 118 |
+
"n_hyp_pairs", "common_regions", "ref_only_regions",
|
| 119 |
+
"hyp_only_regions"}``.
|
| 120 |
+
|
| 121 |
+
Comportements aux bornes
|
| 122 |
+
------------------------
|
| 123 |
+
- Deux s��quences identiques (mêmes régions, même ordre) → F1 = 1.0.
|
| 124 |
+
- Ordre strictement inversé → F1 = 0.0 (toutes les paires
|
| 125 |
+
relatives sont fausses).
|
| 126 |
+
- Une séquence vide vs une séquence non vide → F1 = 0.0.
|
| 127 |
+
- Deux séquences vides → F1 = 0.0 et tous les compteurs à 0
|
| 128 |
+
(convention : on ne récompense pas l'absence).
|
| 129 |
+
"""
|
| 130 |
+
ref = _normalize_input(reference_order)
|
| 131 |
+
hyp = _normalize_input(hypothesis_order)
|
| 132 |
+
|
| 133 |
+
ref_pairs = _ordered_pairs(ref)
|
| 134 |
+
hyp_pairs = _ordered_pairs(hyp)
|
| 135 |
+
|
| 136 |
+
tp = len(ref_pairs & hyp_pairs)
|
| 137 |
+
fn = len(ref_pairs - hyp_pairs)
|
| 138 |
+
fp = len(hyp_pairs - ref_pairs)
|
| 139 |
+
|
| 140 |
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
| 141 |
+
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 142 |
+
f1 = (
|
| 143 |
+
2 * precision * recall / (precision + recall)
|
| 144 |
+
if (precision + recall) > 0
|
| 145 |
+
else 0.0
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
ref_set = set(ref)
|
| 149 |
+
hyp_set = set(hyp)
|
| 150 |
+
return {
|
| 151 |
+
"precision": precision,
|
| 152 |
+
"recall": recall,
|
| 153 |
+
"f1": f1,
|
| 154 |
+
"true_positives": tp,
|
| 155 |
+
"false_positives": fp,
|
| 156 |
+
"false_negatives": fn,
|
| 157 |
+
"n_ref_pairs": len(ref_pairs),
|
| 158 |
+
"n_hyp_pairs": len(hyp_pairs),
|
| 159 |
+
"common_regions": sorted(ref_set & hyp_set),
|
| 160 |
+
"ref_only_regions": sorted(ref_set - hyp_set),
|
| 161 |
+
"hyp_only_regions": sorted(hyp_set - ref_set),
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 166 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 167 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@register_metric(
|
| 171 |
+
name="reading_order_f1",
|
| 172 |
+
input_types=(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER),
|
| 173 |
+
description=(
|
| 174 |
+
"F1 sur l'ordre relatif des régions ALTO/PAGE (ICDAR 2015, "
|
| 175 |
+
"Antonacopoulos). Pour chaque paire (a,b) où a précède b dans "
|
| 176 |
+
"la GT, vérifie que a précède aussi b dans l'hypothèse."
|
| 177 |
+
),
|
| 178 |
+
higher_is_better=True,
|
| 179 |
+
tags={"structure", "icdar", "alto", "page"},
|
| 180 |
+
)
|
| 181 |
+
def reading_order_f1(
|
| 182 |
+
reference: Iterable[str] | None,
|
| 183 |
+
hypothesis: Iterable[str] | None,
|
| 184 |
+
) -> float:
|
| 185 |
+
"""Raccourci : retourne uniquement le F1 global.
|
| 186 |
+
|
| 187 |
+
Pour les détails par paire (TP/FP/FN, régions communes, etc.),
|
| 188 |
+
appeler ``compute_reading_order_metrics`` directement.
|
| 189 |
+
"""
|
| 190 |
+
return compute_reading_order_metrics(reference, hypothesis)["f1"]
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
__all__ = [
|
| 194 |
+
"compute_reading_order_metrics",
|
| 195 |
+
"reading_order_f1",
|
| 196 |
+
]
|
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 53 — Reading order F1 (ICDAR 2015).
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. **Cas canoniques** :
|
| 6 |
+
- Séquences identiques → F1 = 1.0
|
| 7 |
+
- Séquences strictement inversées → F1 = 0.0
|
| 8 |
+
- Permutation locale → F1 calculé sur les paires conservées
|
| 9 |
+
- Insertion d'une région → F1 = recall × precision sur paires
|
| 10 |
+
2. **Cas dégénérés** :
|
| 11 |
+
- Une séquence vide → F1 = 0
|
| 12 |
+
- Deux séquences vides → F1 = 0
|
| 13 |
+
- Une seule région → pas de paire, F1 = 0
|
| 14 |
+
- Doublons dans une séquence → traitement déterministe
|
| 15 |
+
3. **Comptages détaillés** :
|
| 16 |
+
- TP, FP, FN cohérents
|
| 17 |
+
- common/ref_only/hyp_only correctement séparés
|
| 18 |
+
4. **Intégration registre typé** :
|
| 19 |
+
- ``reading_order_f1`` est sélectionné pour la jonction
|
| 20 |
+
``(READING_ORDER, READING_ORDER)``
|
| 21 |
+
- Le shortcut retourne la même valeur que
|
| 22 |
+
``compute_reading_order_metrics["f1"]``
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import pytest
|
| 28 |
+
|
| 29 |
+
from picarones.core.metric_registry import compute_at_junction, select_metrics
|
| 30 |
+
from picarones.core.modules import ArtifactType
|
| 31 |
+
from picarones.core.reading_order import (
|
| 32 |
+
compute_reading_order_metrics,
|
| 33 |
+
reading_order_f1,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 38 |
+
# 1. Cas canoniques
|
| 39 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class TestCanonicalCases:
|
| 43 |
+
def test_identical_sequences_f1_one(self) -> None:
|
| 44 |
+
m = compute_reading_order_metrics(
|
| 45 |
+
["r1", "r2", "r3", "r4"],
|
| 46 |
+
["r1", "r2", "r3", "r4"],
|
| 47 |
+
)
|
| 48 |
+
assert m["f1"] == pytest.approx(1.0)
|
| 49 |
+
assert m["precision"] == pytest.approx(1.0)
|
| 50 |
+
assert m["recall"] == pytest.approx(1.0)
|
| 51 |
+
assert m["false_positives"] == 0
|
| 52 |
+
assert m["false_negatives"] == 0
|
| 53 |
+
|
| 54 |
+
def test_strictly_reversed_f1_zero(self) -> None:
|
| 55 |
+
m = compute_reading_order_metrics(
|
| 56 |
+
["a", "b", "c"],
|
| 57 |
+
["c", "b", "a"],
|
| 58 |
+
)
|
| 59 |
+
# Les 3 paires (a,b), (a,c), (b,c) sont toutes inversées
|
| 60 |
+
# côté hypothèse → 0 TP, 3 FN, 3 FP, F1 = 0
|
| 61 |
+
assert m["f1"] == 0.0
|
| 62 |
+
assert m["true_positives"] == 0
|
| 63 |
+
assert m["false_positives"] == 3
|
| 64 |
+
assert m["false_negatives"] == 3
|
| 65 |
+
|
| 66 |
+
def test_local_permutation(self) -> None:
|
| 67 |
+
# GT : a, b, c, d → 6 paires. Échange interne b↔c → 5 paires
|
| 68 |
+
# préservées (toutes sauf b-c qui devient c-b).
|
| 69 |
+
m = compute_reading_order_metrics(
|
| 70 |
+
["a", "b", "c", "d"],
|
| 71 |
+
["a", "c", "b", "d"],
|
| 72 |
+
)
|
| 73 |
+
assert m["true_positives"] == 5
|
| 74 |
+
assert m["false_negatives"] == 1
|
| 75 |
+
assert m["false_positives"] == 1
|
| 76 |
+
assert m["f1"] == pytest.approx(5 / 6)
|
| 77 |
+
|
| 78 |
+
def test_insertion_preserves_existing_pairs(self) -> None:
|
| 79 |
+
# GT : a, b, c → 3 paires. Hypothèse insère X au milieu :
|
| 80 |
+
# a, X, b, c → 6 paires (a-X, a-b, a-c, X-b, X-c, b-c).
|
| 81 |
+
# 3 TP (paires GT préservées) + 3 FP (paires inventées avec X).
|
| 82 |
+
m = compute_reading_order_metrics(
|
| 83 |
+
["a", "b", "c"],
|
| 84 |
+
["a", "X", "b", "c"],
|
| 85 |
+
)
|
| 86 |
+
assert m["true_positives"] == 3
|
| 87 |
+
assert m["false_negatives"] == 0
|
| 88 |
+
assert m["false_positives"] == 3
|
| 89 |
+
# Recall = 1, precision = 0.5, F1 = 2/3
|
| 90 |
+
assert m["recall"] == pytest.approx(1.0)
|
| 91 |
+
assert m["precision"] == pytest.approx(0.5)
|
| 92 |
+
assert m["f1"] == pytest.approx(2 / 3)
|
| 93 |
+
|
| 94 |
+
def test_deletion_preserves_remaining_pairs(self) -> None:
|
| 95 |
+
# GT : a, b, c → 3 paires. Hypothèse supprime b : a, c → 1 paire.
|
| 96 |
+
m = compute_reading_order_metrics(
|
| 97 |
+
["a", "b", "c"],
|
| 98 |
+
["a", "c"],
|
| 99 |
+
)
|
| 100 |
+
# TP = 1 (paire a-c), FN = 2 (a-b, b-c manquent côté hyp)
|
| 101 |
+
assert m["true_positives"] == 1
|
| 102 |
+
assert m["false_negatives"] == 2
|
| 103 |
+
assert m["false_positives"] == 0
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 107 |
+
# 2. Cas dégénérés
|
| 108 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class TestDegenerateCases:
|
| 112 |
+
def test_both_empty(self) -> None:
|
| 113 |
+
m = compute_reading_order_metrics([], [])
|
| 114 |
+
# Convention : pas de récompense gratuite
|
| 115 |
+
assert m["f1"] == 0.0
|
| 116 |
+
assert m["true_positives"] == 0
|
| 117 |
+
|
| 118 |
+
def test_only_reference_empty(self) -> None:
|
| 119 |
+
m = compute_reading_order_metrics([], ["a", "b"])
|
| 120 |
+
assert m["f1"] == 0.0
|
| 121 |
+
# TP = 0 par construction
|
| 122 |
+
assert m["true_positives"] == 0
|
| 123 |
+
# 1 paire FP côté hypothèse
|
| 124 |
+
assert m["false_positives"] == 1
|
| 125 |
+
|
| 126 |
+
def test_only_hypothesis_empty(self) -> None:
|
| 127 |
+
m = compute_reading_order_metrics(["a", "b"], [])
|
| 128 |
+
assert m["f1"] == 0.0
|
| 129 |
+
# 1 FN côté GT
|
| 130 |
+
assert m["false_negatives"] == 1
|
| 131 |
+
|
| 132 |
+
def test_single_region(self) -> None:
|
| 133 |
+
# Pas de paire possible avec une seule région
|
| 134 |
+
m = compute_reading_order_metrics(["a"], ["a"])
|
| 135 |
+
assert m["n_ref_pairs"] == 0
|
| 136 |
+
assert m["n_hyp_pairs"] == 0
|
| 137 |
+
assert m["f1"] == 0.0 # convention de bord (pas de paire à matcher)
|
| 138 |
+
|
| 139 |
+
def test_none_inputs(self) -> None:
|
| 140 |
+
m = compute_reading_order_metrics(None, None)
|
| 141 |
+
assert m["f1"] == 0.0
|
| 142 |
+
|
| 143 |
+
def test_duplicates_treated_first_occurrence(self) -> None:
|
| 144 |
+
# GT : a, b, a, c → on garde "a, b, c" (première occurrence)
|
| 145 |
+
# → 3 paires. Hypothèse : a, b, c → 3 paires. F1 = 1.
|
| 146 |
+
m = compute_reading_order_metrics(
|
| 147 |
+
["a", "b", "a", "c"],
|
| 148 |
+
["a", "b", "c"],
|
| 149 |
+
)
|
| 150 |
+
assert m["f1"] == pytest.approx(1.0)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 154 |
+
# 3. Comptages détaillés
|
| 155 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class TestDetailedCounts:
|
| 159 |
+
def test_common_and_disjoint_regions(self) -> None:
|
| 160 |
+
m = compute_reading_order_metrics(
|
| 161 |
+
["a", "b", "c"],
|
| 162 |
+
["b", "c", "d"],
|
| 163 |
+
)
|
| 164 |
+
assert m["common_regions"] == ["b", "c"]
|
| 165 |
+
assert m["ref_only_regions"] == ["a"]
|
| 166 |
+
assert m["hyp_only_regions"] == ["d"]
|
| 167 |
+
|
| 168 |
+
def test_n_pairs_consistent(self) -> None:
|
| 169 |
+
m = compute_reading_order_metrics(
|
| 170 |
+
["a", "b", "c", "d"],
|
| 171 |
+
["e", "f"],
|
| 172 |
+
)
|
| 173 |
+
# GT : C(4, 2) = 6 paires
|
| 174 |
+
assert m["n_ref_pairs"] == 6
|
| 175 |
+
# Hyp : C(2, 2) = 1 paire
|
| 176 |
+
assert m["n_hyp_pairs"] == 1
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 180 |
+
# 4. Intégration registre typé
|
| 181 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class TestRegistryIntegration:
|
| 185 |
+
def test_metric_registered_for_reading_order_pair(self) -> None:
|
| 186 |
+
# Force l'import qui peuple le registre
|
| 187 |
+
import picarones.core.reading_order # noqa: F401
|
| 188 |
+
|
| 189 |
+
selected = select_metrics(
|
| 190 |
+
(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER),
|
| 191 |
+
)
|
| 192 |
+
names = {spec.name for spec in selected}
|
| 193 |
+
assert "reading_order_f1" in names
|
| 194 |
+
|
| 195 |
+
def test_compute_at_junction_returns_f1(self) -> None:
|
| 196 |
+
out = compute_at_junction(
|
| 197 |
+
["a", "b", "c"],
|
| 198 |
+
["a", "b", "c"],
|
| 199 |
+
(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER),
|
| 200 |
+
)
|
| 201 |
+
assert out["reading_order_f1"] == pytest.approx(1.0)
|
| 202 |
+
|
| 203 |
+
def test_shortcut_matches_full_call(self) -> None:
|
| 204 |
+
ref = ["r1", "r2", "r3", "r4"]
|
| 205 |
+
hyp = ["r1", "r3", "r2", "r4"]
|
| 206 |
+
full = compute_reading_order_metrics(ref, hyp)
|
| 207 |
+
assert reading_order_f1(ref, hyp) == pytest.approx(full["f1"])
|