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

sprint54: A.II.2.2 Layout F1 par type — couche de calcul (clôture A.II.2)

Browse files

Dernière brique de l'axe A.II.2 (métriques structurelles). Pour les
manuscrits glosés ou les journaux multi-colonnes, c'est la métrique
qui répond à "le moteur sépare-t-il bien le texte principal de la
glose ?".

Nouveau picarones/core/layout.py
- dataclass Region(id, type, bbox) avec validation (bbox strictement
positive). bbox = (x, y, width, height), origine en haut à gauche
(convention ALTO/PAGE).
- _iou_bbox calcule l'IoU de deux rectangles.
- _align_regions apparie GT ↔ hypothèse en greedy par IoU
décroissant, same type required (case-insensitive). Pattern
identique au NER (Sprint 38).
- compute_layout_metrics(refs, hyps, iou_threshold=0.5) retourne :
- global : precision/recall/F1
- per_type : breakdown par type de région
- missed_regions (FN GT non matchées)
- hallucinated_regions (FP hyp non matchées)
- layout_f1 : raccourci pour le F1 global.

Conventions ICDAR : seuil IoU 0.5 par défaut, comparaison de type
insensible à la casse, coercion dict → Region acceptée pour les
utilisateurs qui parsent eux-mêmes ALTO/PAGE.

Pas d'enregistrement registre typé pour ce sprint — la métrique
suppose un parsing préalable (extraction des régions avec types et
bbox depuis l'ALTO/PAGE) qui ne s'inscrit pas directement dans le
pattern (ArtifactType, ArtifactType). L'enregistrement suivra quand
le parser ALTO/PAGE standard sera livré.

Tests : +20 dans test_sprint54_layout.py couvrant :
- validation Region (bbox invalide → ValueError, area)
- IoU mathématique (identité, disjoint, partiel)
- cas standards : layout parfait, mauvais type sur même bbox,
hallucination, région ratée, IoU sous le seuil, IoU au-dessus
- multi-type breakdown (TextRegion, MarginNote, Header, Footer)
- alignement greedy : 2 hyps pour 1 GT → best-IoU wins, l'autre FP
- dégénérés : 2 vides, 1 vide, None, dict input coerced
- type case-insensitive (TextRegion vs textregion match)
- shortcut layout_f1 équivalent
Suite complète : 1978 → 1998 passed, 2 skipped, 0 failed.

L'axe A.II.2 (métriques structurelles) du plan d'évolution est
intégralement livré côté couche de calcul :
- A.II.2.1 Reading order F1 ICDAR (Sprint 53)
- A.II.2.2 Layout F1 par type (Sprint 54)
- A.II.2.3 Différence Flesch (Sprint 52)

CHANGELOG.md CHANGED
@@ -16,6 +16,38 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
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,
@@ -684,16 +716,18 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
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
 
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 54 — A.II.2.2 Layout F1 par type de région : couche de
20
+ calcul (clôture A.II.2 côté calcul).** Dernière brique de l'axe
21
+ A.II.2 (métriques structurelles). Pour les manuscrits glosés
22
+ (texte principal vs glose) ou les journaux multi-colonnes, c'est
23
+ la métrique qui répond à *« le moteur sépare-t-il bien le texte
24
+ principal de la glose ? »*.
25
+ - Nouveau module `picarones/core/layout.py` :
26
+ - dataclass `Region(id, type, bbox)` avec validation (bbox
27
+ strictement positive)
28
+ - `_iou_bbox` calcule l'IoU de deux rectangles (origine en haut
29
+ à gauche, convention ALTO/PAGE)
30
+ - `_align_regions` apparie GT ↔ hypothèse en greedy par IoU
31
+ décroissant, **same type required** (case-insensitive),
32
+ pattern identique au NER (Sprint 38)
33
+ - `compute_layout_metrics(refs, hyps, iou_threshold=0.5)`
34
+ retourne global F1 + per_type + listes
35
+ ``missed_regions`` (FN) et ``hallucinated_regions`` (FP)
36
+ - `layout_f1` : raccourci pour le F1 global
37
+ - Conventions : seuil IoU par défaut à 0,5 (convention ICDAR),
38
+ coercion automatique dict → ``Region``, comparaison de type
39
+ insensible à la casse.
40
+ - Pas d'enregistrement registre typé pour ce sprint — la métrique
41
+ suppose un parsing préalable (extraction des régions avec types
42
+ et bbox depuis l'ALTO/PAGE) qui ne s'inscrit pas directement
43
+ dans le pattern `(ArtifactType, ArtifactType)`. L'enregistrement
44
+ suivra quand le parser ALTO standard sera livré.
45
+ - +20 tests dans `test_sprint54_layout.py` (validation Region,
46
+ IoU mathématique, cas standards : parfait, mauvais type,
47
+ hallucination, FN, IoU sous/sur seuil, multi-type breakdown,
48
+ alignement greedy avec best-IoU wins, dégénérés, type
49
+ case-insensitive, shortcut).
50
+
51
  - **Sprint 53 — A.II.2.1 Reading order F1 (ICDAR 2015) : couche de
52
  calcul.** Suite du Sprint 52 dans l'axe A.II.2 (métriques
53
  structurelles). Sur un manuscrit glosé ou un journal multi-colonnes,
 
716
 
717
  ### Tests
718
 
719
+ - 1478 → 1998 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
720
  +27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
721
  +32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
722
  +43 Sprint 43, +15 Sprint 44, +16 Sprint 45, +38 Sprint 46,
723
  +9 Sprint 47, +14 Sprint 48, +17 Sprint 49, +17 Sprint 50,
724
+ +16 Sprint 51, +25 Sprint 52, +16 Sprint 53, +20 Sprint 54).
725
+ Aucune régression. **Phase 0 close ; Étape 2 du plan d'évolution
726
+ intégralement livrée ; Étape 3 / axe A.II.2 (métriques
727
+ structurelles) couches de calcul intégralement livrées :**
728
+ Flesch (A.II.2.3, Sprint 52), Reading order F1 ICDAR 2015
729
+ (A.II.2.1, Sprint 53) et Layout F1 par type (A.II.2.2,
730
+ Sprint 54).
731
 
732
  ---
733
 
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
  | 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.** |
@@ -271,7 +272,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
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** :
 
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
+ | 54 | **Sprint 23 du plan d'évolution 2026 — Étape 3 / axe A.II.2.2 : Layout F1 par type de région (couche de calcul, clôture A.II.2 côté calcul)**. Dernière brique de l'axe A.II.2. Pour les manuscrits glosés ou journaux multi-colonnes, répond à « le moteur sépare-t-il bien texte principal et glose ? ». Module `picarones/core/layout.py` : dataclass `Region(id, type, bbox)` avec validation, `_iou_bbox` (IoU de rectangles), `_align_regions` greedy par IoU décroissant avec same-type-required (pattern identique au NER Sprint 38), `compute_layout_metrics(refs, hyps, iou_threshold=0.5)` retourne global F1 + per_type + `missed_regions` (FN) + `hallucinated_regions` (FP). Type case-insensitive, coercion dict → Region, seuil ICDAR 0.5 par défaut. Pas d'enregistrement registre typé : la métrique suppose un parser ALTO/PAGE en amont (qui suivra dans un sprint dédié). +20 tests (validation Region, IoU math, cas standards : parfait, type incorrect, hallucination, FN, IoU sous/sur seuil, multi-type, greedy best-IoU wins, dégénérés, case-insensitive, shortcut). **Verrou levé** : un benchmark dont le corpus a une GT ALTO/PAGE peut désormais classer les moteurs sur leur fidélité au layout par type — métrique critique pour les médiévistes (séparation texte/glose) et les journaux multi-colonnes. |
211
  | 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. |
212
  | 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). |
213
  | 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.** |
 
272
  ## Contexte développement
273
 
274
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
275
+ - **Tests** : 1998 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-54 = axe A.II.2 (métriques structurelles) couches de calcul intégralement livrées : Flesch + Reading order F1 ICDAR + Layout F1 par type)
276
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
277
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
278
  - **Transcript de la conversation de développement** :
picarones/core/layout.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layout F1 par type de région — Sprint 54.
2
+
3
+ Sprint 54 — A.II.2.2 du plan d'évolution 2026.
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Un médiéviste qui édite un manuscrit glosé veut savoir : *« le moteur
8
+ sépare-t-il bien le texte principal de la glose ? »*. Le score de
9
+ structure global de Picarones (Sprint 5) agrège fusion/fragmentation
10
+ de lignes en un seul nombre — utile mais non typé. Ce module
11
+ discrimine par **type de région** ALTO/PAGE (``TextRegion``,
12
+ ``MarginNote``, ``Header``, ``Footer``, ``Drop-Cap``...) en
13
+ appliquant le pattern ICDAR layout standard :
14
+
15
+ - **TP** : région GT et région hypothèse de **même type** avec
16
+ chevauchement IoU ≥ seuil (alignement greedy par IoU décroissant),
17
+ - **FN** : région GT non matchée,
18
+ - **FP** : région hypothèse non matchée,
19
+ - F1 calculé global et par type.
20
+
21
+ Le pattern d'alignement est le même que pour le NER (Sprint 38) — on
22
+ réutilise une approche éprouvée plutôt que d'en inventer une nouvelle.
23
+
24
+ Stratégie de découpage
25
+ ----------------------
26
+ Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1
27
+ (Sprint 53) : couche de calcul pure d'abord. L'utilisateur fournit
28
+ deux listes de ``Region`` (typiquement extraites de ALTO/PAGE par un
29
+ parser amont — le parser ALTO/PAGE standard de Picarones suivra
30
+ dans un sprint dédié). Pas de câblage runner ni de vue HTML ici.
31
+
32
+ Convention de coordonnées
33
+ -------------------------
34
+ Une bbox est un tuple ``(x, y, width, height)`` en pixels (origine
35
+ en haut à gauche, axe y vers le bas — convention ALTO et PAGE
36
+ standard). L'IoU est calculée sur l'aire d'intersection / union des
37
+ rectangles.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ from dataclasses import dataclass
44
+ from typing import Iterable
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ # ──────────────────────────────────────────────────────────────────────────
50
+ # Modèle de données
51
+ # ──────────────────────────────────────────────────────────────────────────
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Region:
56
+ """Une région ALTO/PAGE alignable sur sa GT.
57
+
58
+ Attributs
59
+ ---------
60
+ id:
61
+ Identifiant unique au sein de la séquence (ex. ``"r_1"``,
62
+ ``"region_main"``). Informatif — l'alignement se fait par IoU,
63
+ pas par ID.
64
+ type:
65
+ Catégorie de la région (``"TextRegion"``, ``"MarginNote"``,
66
+ ``"Header"``, etc.). Comparaison **case-insensitive**.
67
+ bbox:
68
+ Rectangle ``(x, y, width, height)`` en pixels, origine en haut
69
+ à gauche. Doit avoir width > 0 et height > 0.
70
+ """
71
+
72
+ id: str
73
+ type: str
74
+ bbox: tuple[int, int, int, int]
75
+
76
+ def __post_init__(self) -> None:
77
+ x, y, w, h = self.bbox
78
+ if w <= 0 or h <= 0:
79
+ raise ValueError(
80
+ f"Region {self.id!r} : bbox invalide (w={w}, h={h}). "
81
+ "width et height doivent être strictement positifs."
82
+ )
83
+
84
+ @property
85
+ def area(self) -> int:
86
+ _, _, w, h = self.bbox
87
+ return w * h
88
+
89
+
90
+ def _to_region(obj: Region | dict) -> Region:
91
+ """Coerce un dict en ``Region`` (clés ``id``, ``type``, ``bbox``)."""
92
+ if isinstance(obj, Region):
93
+ return obj
94
+ return Region(
95
+ id=str(obj["id"]),
96
+ type=str(obj["type"]),
97
+ bbox=tuple(obj["bbox"]), # type: ignore[arg-type]
98
+ )
99
+
100
+
101
+ # ──────────────────────────────────────────────────────────────────────────
102
+ # IoU + alignement greedy
103
+ # ──────────────────────────────────────────────────────────────────────────
104
+
105
+
106
+ def _iou_bbox(a: Region, b: Region) -> float:
107
+ """Intersection-over-Union de deux bboxes ``(x, y, w, h)``."""
108
+ ax, ay, aw, ah = a.bbox
109
+ bx, by, bw, bh = b.bbox
110
+ inter_x = max(ax, bx)
111
+ inter_y = max(ay, by)
112
+ inter_x_end = min(ax + aw, bx + bw)
113
+ inter_y_end = min(ay + ah, by + bh)
114
+ inter_w = max(0, inter_x_end - inter_x)
115
+ inter_h = max(0, inter_y_end - inter_y)
116
+ inter = inter_w * inter_h
117
+ if inter == 0:
118
+ return 0.0
119
+ union = a.area + b.area - inter
120
+ if union <= 0:
121
+ return 0.0
122
+ return inter / union
123
+
124
+
125
+ def _align_regions(
126
+ references: list[Region],
127
+ hypotheses: list[Region],
128
+ iou_threshold: float,
129
+ ) -> tuple[list[tuple[int, int, float]], set[int], set[int]]:
130
+ """Appareillage greedy par IoU décroissant ; same type requis.
131
+
132
+ Renvoie ``(matches, unmatched_refs, unmatched_hyps)`` —
133
+ ``matches`` est une liste de ``(idx_ref, idx_hyp, iou)``.
134
+ """
135
+ candidates: list[tuple[float, int, int]] = []
136
+ for i, r in enumerate(references):
137
+ for j, h in enumerate(hypotheses):
138
+ if r.type.casefold() != h.type.casefold():
139
+ continue
140
+ iou = _iou_bbox(r, h)
141
+ if iou >= iou_threshold:
142
+ candidates.append((iou, i, j))
143
+
144
+ # Tri stable : IoU décroissant, puis indices croissants pour
145
+ # déterminisme sur égalités.
146
+ candidates.sort(key=lambda t: (-t[0], t[1], t[2]))
147
+
148
+ matched_refs: set[int] = set()
149
+ matched_hyps: set[int] = set()
150
+ matches: list[tuple[int, int, float]] = []
151
+ for iou, i, j in candidates:
152
+ if i in matched_refs or j in matched_hyps:
153
+ continue
154
+ matched_refs.add(i)
155
+ matched_hyps.add(j)
156
+ matches.append((i, j, iou))
157
+
158
+ unmatched_refs = set(range(len(references))) - matched_refs
159
+ unmatched_hyps = set(range(len(hypotheses))) - matched_hyps
160
+ return matches, unmatched_refs, unmatched_hyps
161
+
162
+
163
+ # ──────────────────────────────────────────────────────────────────────────
164
+ # Métrique principale
165
+ # ──────────────────────────────────────────────────────────────────────────
166
+
167
+
168
+ def _prf(tp: int, fp: int, fn: int) -> dict[str, float]:
169
+ p = tp / (tp + fp) if (tp + fp) > 0 else 0.0
170
+ r = tp / (tp + fn) if (tp + fn) > 0 else 0.0
171
+ f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0
172
+ return {"precision": p, "recall": r, "f1": f1, "support": tp + fn}
173
+
174
+
175
+ def compute_layout_metrics(
176
+ reference_regions: Iterable[Region | dict] | None,
177
+ hypothesis_regions: Iterable[Region | dict] | None,
178
+ iou_threshold: float = 0.5,
179
+ ) -> dict:
180
+ """Calcule precision/recall/F1 sur le layout par type de région.
181
+
182
+ Parameters
183
+ ----------
184
+ reference_regions:
185
+ Liste de régions GT (``Region`` ou dict ``{id, type, bbox}``).
186
+ hypothesis_regions:
187
+ Liste de régions produites par le moteur OCR/HTR ou un
188
+ layout-detector.
189
+ iou_threshold:
190
+ Seuil de chevauchement minimal pour déclarer un appariement
191
+ (défaut : 0,5 — convention ICDAR).
192
+
193
+ Returns
194
+ -------
195
+ dict
196
+ ``{
197
+ "global": {"precision", "recall", "f1", "support"},
198
+ "per_type": {type_name: {"precision", ...}},
199
+ "true_positives": int,
200
+ "false_positives": int,
201
+ "false_negatives": int,
202
+ "missed_regions": list[dict], # GT non matchées
203
+ "hallucinated_regions": list[dict], # hyp non matchées
204
+ "iou_threshold": float,
205
+ }``
206
+
207
+ Cas dégénérés
208
+ -------------
209
+ - Deux listes vides → F1 = 0 et tous compteurs à 0.
210
+ - GT vide + hyp non-vide → F1 = 0 (toutes hyp = FP).
211
+ - hyp vide + GT non-vide → F1 = 0 (toutes GT = FN).
212
+ """
213
+ refs = [_to_region(r) for r in (reference_regions or [])]
214
+ hyps = [_to_region(h) for h in (hypothesis_regions or [])]
215
+
216
+ matches, unmatched_refs, unmatched_hyps = _align_regions(
217
+ refs, hyps, iou_threshold,
218
+ )
219
+
220
+ tp = len(matches)
221
+ fn = len(unmatched_refs)
222
+ fp = len(unmatched_hyps)
223
+
224
+ cat_tp: dict[str, int] = {}
225
+ cat_fn: dict[str, int] = {}
226
+ cat_fp: dict[str, int] = {}
227
+ for i, _j, _iou in matches:
228
+ cat = refs[i].type
229
+ cat_tp[cat] = cat_tp.get(cat, 0) + 1
230
+ for i in unmatched_refs:
231
+ cat = refs[i].type
232
+ cat_fn[cat] = cat_fn.get(cat, 0) + 1
233
+ for j in unmatched_hyps:
234
+ cat = hyps[j].type
235
+ cat_fp[cat] = cat_fp.get(cat, 0) + 1
236
+
237
+ all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp))
238
+ per_type = {
239
+ cat: _prf(
240
+ cat_tp.get(cat, 0),
241
+ cat_fp.get(cat, 0),
242
+ cat_fn.get(cat, 0),
243
+ )
244
+ for cat in all_categories
245
+ }
246
+
247
+ return {
248
+ "global": _prf(tp, fp, fn),
249
+ "per_type": per_type,
250
+ "true_positives": tp,
251
+ "false_positives": fp,
252
+ "false_negatives": fn,
253
+ "missed_regions": [
254
+ {"id": refs[i].id, "type": refs[i].type, "bbox": list(refs[i].bbox)}
255
+ for i in sorted(unmatched_refs)
256
+ ],
257
+ "hallucinated_regions": [
258
+ {"id": hyps[j].id, "type": hyps[j].type, "bbox": list(hyps[j].bbox)}
259
+ for j in sorted(unmatched_hyps)
260
+ ],
261
+ "iou_threshold": iou_threshold,
262
+ }
263
+
264
+
265
+ def layout_f1(
266
+ reference_regions: Iterable[Region | dict] | None,
267
+ hypothesis_regions: Iterable[Region | dict] | None,
268
+ iou_threshold: float = 0.5,
269
+ ) -> float:
270
+ """Raccourci : F1 global du layout."""
271
+ return compute_layout_metrics(
272
+ reference_regions, hypothesis_regions, iou_threshold,
273
+ )["global"]["f1"]
274
+
275
+
276
+ __all__ = [
277
+ "Region",
278
+ "compute_layout_metrics",
279
+ "layout_f1",
280
+ ]
tests/test_sprint54_layout.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 54 — Layout F1 par type de région.
2
+
3
+ Couvre :
4
+
5
+ 1. ``Region`` validation (bbox invalide → ValueError, area calculée).
6
+ 2. ``_iou_bbox`` mathématique (identité, disjoint, partiel).
7
+ 3. **Cas standards** :
8
+ - Layout parfait → F1 = 1
9
+ - Mauvais type sur la même bbox → 0 TP pour ce type
10
+ - Hallucination (région inventée) → FP
11
+ - Région ratée (manquante) → FN
12
+ - IoU sous le seuil → pas d'appariement
13
+ 4. **Multi-type** : breakdown per_type cohérent avec les comptages
14
+ globaux.
15
+ 5. **Alignement greedy** : 2 hypothèses pour 1 GT → la meilleure
16
+ gagne, l'autre devient FP.
17
+ 6. **Cas dégénérés** : listes vides, None, IoU custom.
18
+ 7. ``layout_f1`` raccourci équivalent à ``compute_layout_metrics["f1"]``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import pytest
24
+
25
+ from picarones.core.layout import (
26
+ Region,
27
+ _iou_bbox,
28
+ compute_layout_metrics,
29
+ layout_f1,
30
+ )
31
+
32
+
33
+ # ──────────────────────────────────────────────────────────────────────────
34
+ # 1. Region validation
35
+ # ──────────────────────────────────────────────────────────────────────────
36
+
37
+
38
+ class TestRegionDataclass:
39
+ def test_valid_construction(self) -> None:
40
+ r = Region("r1", "TextRegion", (0, 0, 100, 200))
41
+ assert r.id == "r1"
42
+ assert r.area == 20_000
43
+
44
+ def test_invalid_bbox_raises(self) -> None:
45
+ with pytest.raises(ValueError, match="bbox invalide"):
46
+ Region("r1", "TextRegion", (0, 0, 0, 100))
47
+ with pytest.raises(ValueError, match="bbox invalide"):
48
+ Region("r1", "TextRegion", (0, 0, 100, -5))
49
+
50
+
51
+ # ──────────────────────────────────────────────────────────────────────────
52
+ # 2. IoU bbox
53
+ # ──────────────────────────────────────────────────────────────────────────
54
+
55
+
56
+ class TestIouBbox:
57
+ def test_identical_bbox_iou_one(self) -> None:
58
+ a = Region("a", "X", (0, 0, 100, 100))
59
+ assert _iou_bbox(a, a) == pytest.approx(1.0)
60
+
61
+ def test_disjoint_bbox_iou_zero(self) -> None:
62
+ a = Region("a", "X", (0, 0, 100, 100))
63
+ b = Region("b", "X", (200, 200, 50, 50))
64
+ assert _iou_bbox(a, b) == 0.0
65
+
66
+ def test_partial_overlap(self) -> None:
67
+ # a = [0,0,100,100], b = [50,50,100,100]
68
+ # intersection : 50x50 = 2500
69
+ # union : 10000 + 10000 - 2500 = 17500
70
+ # iou = 2500/17500 ≈ 0.143
71
+ a = Region("a", "X", (0, 0, 100, 100))
72
+ b = Region("b", "X", (50, 50, 100, 100))
73
+ assert _iou_bbox(a, b) == pytest.approx(2500 / 17500)
74
+
75
+
76
+ # ──────────────────────────────────────────────────────────────────────────
77
+ # 3. Cas standards
78
+ # ──────────────────────────────────────────────────────────────────────────
79
+
80
+
81
+ class TestStandardCases:
82
+ def test_perfect_layout(self) -> None:
83
+ ref = [
84
+ Region("r1", "TextRegion", (0, 0, 100, 100)),
85
+ Region("r2", "MarginNote", (200, 0, 50, 100)),
86
+ ]
87
+ m = compute_layout_metrics(ref, list(ref))
88
+ assert m["global"]["f1"] == pytest.approx(1.0)
89
+ assert m["true_positives"] == 2
90
+ assert m["false_positives"] == 0
91
+ assert m["false_negatives"] == 0
92
+
93
+ def test_wrong_type_breaks_match(self) -> None:
94
+ # Même bbox mais type différent → pas d'appariement
95
+ ref = [Region("r1", "TextRegion", (0, 0, 100, 100))]
96
+ hyp = [Region("r1", "MarginNote", (0, 0, 100, 100))]
97
+ m = compute_layout_metrics(ref, hyp)
98
+ assert m["true_positives"] == 0
99
+ assert m["false_negatives"] == 1
100
+ assert m["false_positives"] == 1
101
+
102
+ def test_hallucinated_region_is_fp(self) -> None:
103
+ ref = [Region("r1", "TextRegion", (0, 0, 100, 100))]
104
+ hyp = [
105
+ Region("r1", "TextRegion", (0, 0, 100, 100)),
106
+ Region("rX", "TextRegion", (500, 500, 50, 50)), # inventée
107
+ ]
108
+ m = compute_layout_metrics(ref, hyp)
109
+ assert m["true_positives"] == 1
110
+ assert m["false_positives"] == 1
111
+ assert m["hallucinated_regions"][0]["id"] == "rX"
112
+
113
+ def test_missing_region_is_fn(self) -> None:
114
+ ref = [
115
+ Region("r1", "TextRegion", (0, 0, 100, 100)),
116
+ Region("r2", "TextRegion", (200, 0, 100, 100)),
117
+ ]
118
+ hyp = [Region("r1", "TextRegion", (0, 0, 100, 100))]
119
+ m = compute_layout_metrics(ref, hyp)
120
+ assert m["true_positives"] == 1
121
+ assert m["false_negatives"] == 1
122
+ assert m["missed_regions"][0]["id"] == "r2"
123
+
124
+ def test_iou_below_threshold_no_match(self) -> None:
125
+ # Recouvrement IoU = 2500/17500 ≈ 0.14 < 0.5
126
+ ref = [Region("r1", "TextRegion", (0, 0, 100, 100))]
127
+ hyp = [Region("r1", "TextRegion", (50, 50, 100, 100))]
128
+ m = compute_layout_metrics(ref, hyp, iou_threshold=0.5)
129
+ assert m["true_positives"] == 0
130
+
131
+ def test_iou_above_threshold_matches(self) -> None:
132
+ # Recouvrement IoU = 6400/13600 ≈ 0.47, sous 0.5 mais sur 0.4
133
+ ref = [Region("r1", "TextRegion", (0, 0, 100, 100))]
134
+ hyp = [Region("r1", "TextRegion", (20, 20, 100, 100))]
135
+ m_strict = compute_layout_metrics(ref, hyp, iou_threshold=0.5)
136
+ m_loose = compute_layout_metrics(ref, hyp, iou_threshold=0.4)
137
+ assert m_strict["true_positives"] == 0
138
+ assert m_loose["true_positives"] == 1
139
+
140
+
141
+ # ──────────────────────────────────────────────────────────────────────────
142
+ # 4. Multi-type breakdown
143
+ # ──────────────────────────────────────────────────────────────────────────
144
+
145
+
146
+ class TestPerTypeBreakdown:
147
+ def test_per_type_metrics(self) -> None:
148
+ ref = [
149
+ Region("r1", "TextRegion", (0, 0, 100, 100)),
150
+ Region("r2", "TextRegion", (200, 0, 100, 100)),
151
+ Region("r3", "MarginNote", (0, 200, 100, 50)),
152
+ Region("r4", "Header", (0, 300, 200, 30)),
153
+ ]
154
+ hyp = [
155
+ Region("r1", "TextRegion", (0, 0, 100, 100)), # match
156
+ # r2 manquante → FN TextRegion
157
+ Region("r3", "MarginNote", (0, 200, 100, 50)), # match
158
+ Region("rX", "Footer", (0, 400, 200, 30)), # FP Footer
159
+ # r4 Header manquante → FN Header
160
+ ]
161
+ m = compute_layout_metrics(ref, hyp)
162
+ per_type = m["per_type"]
163
+ # TextRegion : 1 TP + 1 FN → P=1, R=0.5, F1=2/3
164
+ assert per_type["TextRegion"]["true_positives" if False else "f1"] == pytest.approx(2 / 3)
165
+ # MarginNote : 1 TP, parfait
166
+ assert per_type["MarginNote"]["f1"] == pytest.approx(1.0)
167
+ # Header : 1 FN → P=0, R=0, F1=0
168
+ assert per_type["Header"]["f1"] == 0.0
169
+ # Footer : 1 FP → P=0, R=0
170
+ assert per_type["Footer"]["f1"] == 0.0
171
+
172
+
173
+ # ──────────────────────────────────────────────────────────────────────────
174
+ # 5. Alignement greedy
175
+ # ──────────────────────────────────────────────────────────────────────────
176
+
177
+
178
+ class TestGreedyAlignment:
179
+ def test_best_iou_wins(self) -> None:
180
+ # GT : 1 région. Hypothèse : 2 régions, l'une parfaite,
181
+ # l'autre faiblement chevauchante. La meilleure gagne.
182
+ ref = [Region("r1", "TextRegion", (0, 0, 100, 100))]
183
+ hyp = [
184
+ Region("h_weak", "TextRegion", (60, 60, 100, 100)), # faible IoU
185
+ Region("h_strong", "TextRegion", (0, 0, 100, 100)), # parfait
186
+ ]
187
+ m = compute_layout_metrics(ref, hyp, iou_threshold=0.1)
188
+ # Le strong gagne, le weak devient FP
189
+ assert m["true_positives"] == 1
190
+ assert m["false_positives"] == 1
191
+ assert m["hallucinated_regions"][0]["id"] == "h_weak"
192
+
193
+
194
+ # ──────────────────────────────────────────────────────────────────────────
195
+ # 6. Cas dégénérés
196
+ # ──────────────────────────────────────────────────────────────────────────
197
+
198
+
199
+ class TestDegenerateCases:
200
+ def test_both_empty(self) -> None:
201
+ m = compute_layout_metrics([], [])
202
+ assert m["global"]["f1"] == 0.0
203
+ assert m["per_type"] == {}
204
+
205
+ def test_only_reference_empty(self) -> None:
206
+ m = compute_layout_metrics([], [Region("r1", "X", (0, 0, 10, 10))])
207
+ assert m["false_positives"] == 1
208
+ assert m["true_positives"] == 0
209
+
210
+ def test_only_hypothesis_empty(self) -> None:
211
+ m = compute_layout_metrics([Region("r1", "X", (0, 0, 10, 10))], [])
212
+ assert m["false_negatives"] == 1
213
+ assert m["true_positives"] == 0
214
+
215
+ def test_none_inputs(self) -> None:
216
+ m = compute_layout_metrics(None, None)
217
+ assert m["global"]["f1"] == 0.0
218
+
219
+ def test_dict_input_coerced(self) -> None:
220
+ # L'utilisateur peut passer des dicts au format {id, type, bbox}
221
+ ref = [{"id": "r1", "type": "TextRegion", "bbox": (0, 0, 100, 100)}]
222
+ hyp = [{"id": "r1", "type": "TextRegion", "bbox": (0, 0, 100, 100)}]
223
+ assert layout_f1(ref, hyp) == pytest.approx(1.0)
224
+
225
+
226
+ # ──────────────────────────────────────────────────────────────────────────
227
+ # 7. Type matching case-insensitive
228
+ # ──────────────────────────────────────────────────────────────────────────
229
+
230
+
231
+ class TestTypeNormalization:
232
+ def test_type_case_insensitive(self) -> None:
233
+ ref = [Region("r1", "TextRegion", (0, 0, 100, 100))]
234
+ hyp = [Region("r1", "textregion", (0, 0, 100, 100))]
235
+ assert layout_f1(ref, hyp) == pytest.approx(1.0)
236
+
237
+
238
+ # ──────────────────────────────────────────────────────────────────────────
239
+ # 8. Shortcut layout_f1
240
+ # ──────────────────────────────────────────────────────────────────────────
241
+
242
+
243
+ class TestShortcut:
244
+ def test_shortcut_matches_full_call(self) -> None:
245
+ ref = [
246
+ Region("r1", "TextRegion", (0, 0, 100, 100)),
247
+ Region("r2", "MarginNote", (200, 0, 50, 100)),
248
+ ]
249
+ hyp = [
250
+ Region("r1", "TextRegion", (0, 0, 100, 100)),
251
+ # r2 manquante
252
+ ]
253
+ full = compute_layout_metrics(ref, hyp)
254
+ assert layout_f1(ref, hyp) == pytest.approx(full["global"]["f1"])