Spaces:
Running
sprint54: A.II.2.2 Layout F1 par type — couche de calcul (clôture A.II.2)
Browse filesDerniè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 +40 -6
- CLAUDE.md +2 -1
- picarones/core/layout.py +280 -0
- tests/test_sprint54_layout.py +254 -0
|
@@ -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 →
|
| 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
|
| 693 |
-
**Phase 0 close ; Étape 2 du plan d'évolution
|
| 694 |
-
livrée ; Étape 3
|
| 695 |
-
|
| 696 |
-
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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** :
|
| 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** :
|
|
@@ -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 |
+
]
|
|
@@ -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"])
|