Claude commited on
Commit
600ceb4
·
unverified ·
1 Parent(s): f458e33

sprint53: A.II.2.1 Reading order F1 ICDAR 2015 — couche de calcul

Browse files

Suite 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 CHANGED
@@ -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 → 1962 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
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. **Phase 0
668
- close ; Étape 2 du plan d'évolution intégralement livrée :**
669
- inter-moteurs (A.II.1.c), NER (A.II.1.a), calibration (A.II.1.b)
670
- et stratification (A.III) livrés bout-en-bout calcul → runner →
671
- HTML ; A.I.2 médiane par défaut livré (Sprint 44) ; les 5
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
 
CLAUDE.md CHANGED
@@ -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** : 1962 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 — Tesseract, Pero OCR, Mistral OCR, Google Vision et Azure DI — exposent leurs confidences natives ; **Étape 2 close** ; Sprint 52 = Flesch couche de calcul (démarrage Étape 3 / axe A.II.2))
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** :
picarones/core/reading_order.py ADDED
@@ -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
+ ]
tests/test_sprint53_reading_order.py ADDED
@@ -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"])