Spaces:
Running
sprint93: A.II.7 - métriques d'image prédictives (calcul + HTML)
Browse filesimage_quality.py (Sprint 5) mesurait des features indépendamment ;
ce module les combine en deux indicateurs corpus-level qui
répondent à des questions de diagnostic distinctes.
picarones/core/image_predictive.py :
- compute_paleographic_complexity(quality, weights=None) :
combinaison pondérée éditoriale 0.30 noise / 0.30 blur /
0.20 low_contrast / 0.20 rotation, bornes [0, 1] forcées,
poids surchargeables. Retourne {score, components, weights_used}.
- compute_corpus_homogeneity(image_qualities) : moyenne des
écart-types normalisés sur 4 features (plage 0.5 / 10°).
0 = uniforme, 1 = très hétérogène. Retourne {score, n_docs,
per_feature{mean, stdev, normalised}}.
- aggregate_corpus_predictive : synthèse complexité (mean/median/
min/max/stdev) + homogeneity.
Pas de prédiction CER absolue (philosophie banc d'essai exclut
un modèle entraîné par moteur).
picarones/report/image_predictive_render.py : 2 blocs - tableau
résumé complexité (mean coloré vert → rouge) + tableau
homogénéité (score coloré + détail par feature). Adaptive
masking, anti-injection.
20 clés i18n FR/EN (imgpred_*). 21 tests dans
test_sprint93_image_predictive.py incluant cas trivial → ≈0,
extrême → ≈1, bornes, poids custom, corpus uniforme/hétérogène,
cas réaliste BnF, vue HTML.
Tests : 3017 passed, 2 skipped.
https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7
- CHANGELOG.md +58 -0
- CLAUDE.md +2 -1
- picarones/core/image_predictive.py +283 -0
- picarones/report/i18n/en.json +20 -1
- picarones/report/i18n/fr.json +20 -1
- picarones/report/image_predictive_render.py +221 -0
- tests/test_sprint93_image_predictive.py +259 -0
|
@@ -16,6 +16,64 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 92 — A.II.9 : métriques longitudinales (régression
|
| 20 |
linéaire + change-point + détecteur narratif + vue HTML).**
|
| 21 |
L'historique SQLite (`core/history.py`, Sprint 8) collectait
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 93 — A.II.7 : métriques d'image prédictives (couche
|
| 20 |
+
calcul + vue HTML).** ``image_quality.py`` (Sprint 5)
|
| 21 |
+
mesurait des features indépendamment ; ce module les
|
| 22 |
+
**combine** en deux indicateurs corpus-level qui répondent
|
| 23 |
+
à des questions de diagnostic distinctes.
|
| 24 |
+
|
| 25 |
+
- `picarones/core/image_predictive.py` :
|
| 26 |
+
`compute_paleographic_complexity(quality, weights=None)`
|
| 27 |
+
retourne ``{score ∈ [0,1], components, weights_used}`` —
|
| 28 |
+
combinaison pondérée éditoriale du bruit (0,30), du flou
|
| 29 |
+
`1 - sharpness` (0,30), du faible contraste
|
| 30 |
+
`1 - contrast` (0,20) et de la rotation
|
| 31 |
+
`|degrees| / 30` (0,20). Bornes [0, 1] forcées par
|
| 32 |
+
clamping. Poids surchargeables. Garde-fous : `None` si
|
| 33 |
+
quality vide ou poids tous nuls.
|
| 34 |
+
`compute_corpus_homogeneity(image_qualities)` retourne
|
| 35 |
+
``{score ∈ [0,1], n_docs, per_feature{mean, stdev,
|
| 36 |
+
normalised}}`` — moyenne des écart-types normalisés sur
|
| 37 |
+
4 features (plage 0,5 pour [0,1] et 10° pour rotation).
|
| 38 |
+
0 = corpus uniforme (la moyenne globale est fiable),
|
| 39 |
+
1 = corpus très hétérogène (la moyenne ment).
|
| 40 |
+
`aggregate_corpus_predictive(image_qualities)` synthétise
|
| 41 |
+
complexité (mean/median/min/max/stdev) + homogeneity.
|
| 42 |
+
|
| 43 |
+
- `picarones/report/image_predictive_render.py` :
|
| 44 |
+
`build_image_predictive_html(aggregated, labels)` produit
|
| 45 |
+
deux blocs : tableau résumé complexité (mean coloré
|
| 46 |
+
gradient vert → rouge, median, min, max, stdev, n_docs) +
|
| 47 |
+
tableau homogénéité (score coloré + détail par feature
|
| 48 |
+
avec mean, stdev, contribution normalisée colorée).
|
| 49 |
+
Adaptive : `""` si pas de données. Module pur —
|
| 50 |
+
l'utilisateur compose
|
| 51 |
+
`[doc.image_quality.as_dict() for ...]` →
|
| 52 |
+
`aggregate_corpus_predictive` → `build_image_predictive_html`.
|
| 53 |
+
|
| 54 |
+
- **Pas de prédiction CER absolue** : on ne prétend pas
|
| 55 |
+
fournir une valeur CER en pourcentage (demanderait un
|
| 56 |
+
modèle entraîné par moteur, contraire à la philosophie
|
| 57 |
+
banc d'essai). Le score est relatif, pour une lecture
|
| 58 |
+
diagnostique : *« le doc A est ~3× plus complexe que le
|
| 59 |
+
doc B, ce qui est cohérent avec le CER observé »*.
|
| 60 |
+
|
| 61 |
+
+20 clés i18n FR/EN (`imgpred_*`). +21 tests dans
|
| 62 |
+
`test_sprint93_image_predictive.py` (cas trivial → ≈0, cas
|
| 63 |
+
extrême → ≈1, bornes [0,1] respectées sur valeurs hors
|
| 64 |
+
plage, components retournés, poids custom (tout sur le
|
| 65 |
+
bruit → score = noise_level), poids défaut sommant à 1,
|
| 66 |
+
None sur empty et poids nuls ; corpus uniforme → 0,
|
| 67 |
+
hétérogène → > 0.5, lt 2 docs → None, per_feature
|
| 68 |
+
structurée ; **cas réaliste BnF** mix trivial/difficile,
|
| 69 |
+
empty, single doc no homogeneity ; vue HTML 4 cas dont
|
| 70 |
+
anti-injection sur titre custom + FR + EN ; complétude i18n
|
| 71 |
+
19 clés). **Verrou levé** : un benchmark BnF voit désormais
|
| 72 |
+
*« corpus-wide complexity 0,42 (modérée), homogeneity 0,18
|
| 73 |
+
(uniforme — moyenne fiable) »* dans la vue Analyses, ce qui
|
| 74 |
+
permet d'expliquer une partie du CER observé sans tomber
|
| 75 |
+
dans la prédiction prescriptive.
|
| 76 |
+
|
| 77 |
- **Sprint 92 — A.II.9 : métriques longitudinales (régression
|
| 78 |
linéaire + change-point + détecteur narratif + vue HTML).**
|
| 79 |
L'historique SQLite (`core/history.py`, Sprint 8) collectait
|
|
@@ -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 |
| 92 | **Sprint 61 du plan d'évolution 2026 — A.II.9 : métriques longitudinales (régression linéaire + change-point + détecteur narratif + vue HTML)**. L'historique SQLite (Sprint 8) collectait sans qu'aucune métrique n'en sorte. Complémentaire à A.I.3 qui dit *« écart anormal sur ce corpus »* sans caractériser la dynamique. Nouveau module `picarones/core/longitudinal.py` : `compute_linear_trend` régression OLS pure Python sans scipy retourne `LinearTrend(slope, intercept, r_squared, n_runs)` ; `detect_change_point(series, min_segment_size=3)` balayage exhaustif (Pettitt simplifié) retourne `ChangePointResult(index, timestamp, mean_before, mean_after, delta, n_before, n_after)` ; `compute_engine_longitudinal` combine les deux avec garde-fou `min_runs_for_trend=3` et seuil `change_point_threshold=0.01` (1 pt CER) ; `compute_corpus_longitudinal` agrège tous les moteurs. Nouveau `FactType.REGRESSION_IN_HISTORY` (priority 170, MEDIUM par défaut, HIGH si `|absolute_delta| ≥ 0.05`) + détecteur lit `benchmark_data["longitudinal_trends"]`, déclenche si pente > +1 pt CER/an **ou** change-point delta > 1 pt CER, payload trace `pattern in {"trend", "change_point", "trend_and_change_point"}`. Templates FR/EN sans chiffres en dur. Ajout aux paires complémentaires : `(GLOBAL_LEADER_CER, REGRESSION_IN_HISTORY)` et `(ENGINE_OFF_BASELINE, REGRESSION_IN_HISTORY)`. Module de rendu `picarones/report/longitudinal_render.py` : tableau moteur × {n_runs, premier CER, dernier CER, Δ cumulé coloré (vert→orange→rouge sur ±5 pts ; bleu si amélioration), pente annualisée, R², point de rupture avec timestamp + delta}. Tri par Δ décroissant. Adaptive masking. +10 clés i18n FR/EN (`longitudinal_*`). +28 tests (régression OLS, change-point, intégration entries + filtre corpus + min_runs + threshold, multi-moteurs, détecteur 6 cas, **traçabilité anti-hallucination FR + EN** sur sentences de `build_synthesis`, vue HTML 4 cas dont anti-injection, complétude i18n 10 clés). **Verrou levé** : un benchmark voit désormais *« sur les 8 runs historiques pour tess, le CER moyen est passé de 4 % à 7 % (variation cumulée 3 points) »* — permet de relier une régression à un changement de pipeline. |
|
| 211 |
| 91 | **Sprint 60 du plan d'évolution 2026 — A.II.6 : métriques économiques (throughput effectif + coût marginal par erreur évitée, couche calcul + vue HTML throughput)**. Le throughput brut ment quand un moteur est rapide mais imprécis : la correction humaine *post hoc* absorbe le gain. Discrimine fortement entre cloud rapide à 30 % de timeouts et local lent à 100 % de fiabilité. Nouveau module `picarones/core/throughput.py` : `compute_effective_throughput(n_pages, duration_seconds, n_errors, time_per_error_seconds=5.0)` retourne `{n_pages, duration_seconds, n_errors, time_per_error_seconds, correction_time_seconds, total_seconds, pages_per_hour_raw, pages_per_hour_effective, drag_ratio}`. Constante HTR-United (5 s/erreur) surchargeable. Garde-fous : `None` si `n_pages = 0` ou `total_seconds = 0`, `ValueError` sur valeurs négatives. `aggregate_effective_throughput(per_engine)` agrège par moteur. Nouveau module `picarones/core/marginal_cost.py` : `compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)` retourne `{cost_per_avoided_error, n_errors_avoided, cost_delta, dominated}` ou `None` si `errors_b ≥ errors_a`. `dominated=True` quand B moins cher ET plus précis. `compute_marginal_cost_matrix(per_engine)` retourne paires ordonnées (A → B) triées par coût marginal croissant. Nouveau module `picarones/report/throughput_render.py` : `build_throughput_html(aggregated, labels)` produit tableau résumé moteur × {pages/h brut, pages/h **utilisable** (gradient rouge → vert sur le max observé), % drag (gradient vert → rouge), pages, erreurs}, tri par pages/h utilisable décroissant. Adaptive : `""` si pas de données. Module pur — l'utilisateur compose la liste `per_engine`. Vue HTML coût marginal couplée à la vue Pareto reportée à un sprint ultérieur. +9 clés i18n FR/EN (`throughput_*`). +27 tests dans `test_sprint91_throughput.py` (formule effective avec/sans erreurs, custom time_per_error, garde-fous, drag_ratio élevé, agrégation 3 cas, marginal cost 5 cas dont dominé/non comparable, matrice tri ascendant + lt 2 + données invalides, **cas réaliste BnF** Tesseract local 600 p/h brut → 423 p/h effectif vs GPT-4o cloud 1800 p/h brut → 300 p/h effectif, vue HTML 4 cas dont anti-injection + tri descendant, complétude i18n 9 clés). **Verrou levé** : un archiviste BnF qui pondère un budget contre une exigence de délai voit immédiatement *« Tesseract local 423 p/h utilisable, GPT-4o cloud 300 p/h utilisable malgré son apparente vitesse de 1800 p/h brut »* — la décision business s'aligne sur la réalité opérationnelle. |
|
| 212 |
| 90 | **Sprint 59 du plan d'évolution 2026 — A.II.4 finition : détecteur narratif `engine_unstable` + vue HTML stabilité multi-runs**. Le module `picarones/core/reliability.py` (Sprint 83) livrait la couche de calcul ; aucun détecteur ni vue ne consommaient les données. Critique pour les moteurs LLM/VLM dont la non-déterministie sape la reproductibilité scientifique. Nouveau `FactType.ENGINE_UNSTABLE` (priority 160, importance HIGH) + détecteur `detect_engine_unstable` qui lit `benchmark_data["multirun_stability"]` (liste enrichie d'`engine_name` + sortie de `compute_multirun_stability`). Garde-fous : `n_runs ≥ 2`, déclenche si `cer_cv > 0.10` **ou** `identical_run_rate < 0.50`. Templates FR/EN sans chiffres en dur. Ajout du couple `(GLOBAL_LEADER_CER, ENGINE_UNSTABLE)` à `_COMPLEMENTARY_PAIRS` de l'arbitre — un moteur peut être leader **et** instable, et c'est précisément l'information critique à remonter ensemble. Nouveau module `picarones/report/multirun_stability_render.py` : `build_multirun_stability_html(stability, labels)` rend un tableau moteur × {n_runs, CER moyen ± σ, CV (gradient vert→orange→rouge sur 0–25 %), % runs identiques, sorties distinctes}. Adaptive : `""` si liste vide ou tous `cer_cv` None. Note d'intégration : la vue est un module pur (l'utilisateur exécute lui-même les N runs ; option runner `--repeats N` reportée à un sprint dédié). +8 clés i18n FR/EN (`stability_*`). +18 tests dans `test_sprint90_engine_unstable.py` (FactType + arbiter, détecteur 6 cas, **traçabilité anti-hallucination FR + EN** sur les sentences de `build_synthesis`, vue HTML 4 cas dont anti-injection, complétude i18n 8 clés). **Verrou levé** : un papier scientifique qui rapporte un CER LLM voit désormais *« sur 4 runs successifs, gpt-4o produit des sorties variables (CV 24,3 %) — interpréter avec prudence »* dans la synthèse + le tableau de stabilité dans la vue. |
|
|
@@ -310,7 +311,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 310 |
## Contexte développement
|
| 311 |
|
| 312 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 313 |
-
- **Tests** :
|
| 314 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 315 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 316 |
- **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 |
+
| 93 | **Sprint 62 du plan d'évolution 2026 — A.II.7 : métriques d'image prédictives (couche calcul + vue HTML)**. `image_quality.py` (Sprint 5) mesurait des features indépendamment ; ce module les combine en deux indicateurs corpus-level. Nouveau module `picarones/core/image_predictive.py` : `compute_paleographic_complexity(quality, weights)` retourne score ∈ [0,1] + components + weights_used (combinaison pondérée éditoriale 0.30 noise / 0.30 blur / 0.20 low_contrast / 0.20 rotation, bornes forcées) ; `compute_corpus_homogeneity(image_qualities)` retourne score ∈ [0,1] (moyenne des écart-types normalisés sur 4 features) + n_docs + per_feature, 0 = uniforme (moyenne globale fiable), 1 = très hétérogène ; `aggregate_corpus_predictive` synthétise complexité (mean/median/min/max/stdev) + homogeneity. Pas de prédiction CER absolue (philosophie banc d'essai exclut un modèle entraîné par moteur). Module de rendu `picarones/report/image_predictive_render.py` : 2 blocs — tableau résumé complexité (mean coloré gradient vert → rouge, médiane, min, max, stdev, docs) + tableau homogénéité (score coloré + détail par feature mean/stdev/contribution normalisée). Adaptive masking. Module pur — l'utilisateur compose. +20 clés i18n FR/EN (`imgpred_*`). +21 tests dans `test_sprint93_image_predictive.py` (cas trivial → ≈0, cas extrême → ≈1, bornes [0,1], poids custom, défauts somment à 1, garde-fous None ; corpus uniforme → 0, hétérogène > 0.5, lt 2 → None ; cas réaliste BnF mix trivial/difficile ; vue HTML 4 cas dont anti-injection FR + EN ; complétude i18n 19 clés). **Verrou levé** : un benchmark BnF voit désormais *« corpus-wide complexity 0,42 (modérée), homogeneity 0,18 (uniforme — moyenne fiable) »* dans la vue Analyses — explique une partie du CER observé sans prédiction prescriptive. |
|
| 211 |
| 92 | **Sprint 61 du plan d'évolution 2026 — A.II.9 : métriques longitudinales (régression linéaire + change-point + détecteur narratif + vue HTML)**. L'historique SQLite (Sprint 8) collectait sans qu'aucune métrique n'en sorte. Complémentaire à A.I.3 qui dit *« écart anormal sur ce corpus »* sans caractériser la dynamique. Nouveau module `picarones/core/longitudinal.py` : `compute_linear_trend` régression OLS pure Python sans scipy retourne `LinearTrend(slope, intercept, r_squared, n_runs)` ; `detect_change_point(series, min_segment_size=3)` balayage exhaustif (Pettitt simplifié) retourne `ChangePointResult(index, timestamp, mean_before, mean_after, delta, n_before, n_after)` ; `compute_engine_longitudinal` combine les deux avec garde-fou `min_runs_for_trend=3` et seuil `change_point_threshold=0.01` (1 pt CER) ; `compute_corpus_longitudinal` agrège tous les moteurs. Nouveau `FactType.REGRESSION_IN_HISTORY` (priority 170, MEDIUM par défaut, HIGH si `|absolute_delta| ≥ 0.05`) + détecteur lit `benchmark_data["longitudinal_trends"]`, déclenche si pente > +1 pt CER/an **ou** change-point delta > 1 pt CER, payload trace `pattern in {"trend", "change_point", "trend_and_change_point"}`. Templates FR/EN sans chiffres en dur. Ajout aux paires complémentaires : `(GLOBAL_LEADER_CER, REGRESSION_IN_HISTORY)` et `(ENGINE_OFF_BASELINE, REGRESSION_IN_HISTORY)`. Module de rendu `picarones/report/longitudinal_render.py` : tableau moteur × {n_runs, premier CER, dernier CER, Δ cumulé coloré (vert→orange→rouge sur ±5 pts ; bleu si amélioration), pente annualisée, R², point de rupture avec timestamp + delta}. Tri par Δ décroissant. Adaptive masking. +10 clés i18n FR/EN (`longitudinal_*`). +28 tests (régression OLS, change-point, intégration entries + filtre corpus + min_runs + threshold, multi-moteurs, détecteur 6 cas, **traçabilité anti-hallucination FR + EN** sur sentences de `build_synthesis`, vue HTML 4 cas dont anti-injection, complétude i18n 10 clés). **Verrou levé** : un benchmark voit désormais *« sur les 8 runs historiques pour tess, le CER moyen est passé de 4 % à 7 % (variation cumulée 3 points) »* — permet de relier une régression à un changement de pipeline. |
|
| 212 |
| 91 | **Sprint 60 du plan d'évolution 2026 — A.II.6 : métriques économiques (throughput effectif + coût marginal par erreur évitée, couche calcul + vue HTML throughput)**. Le throughput brut ment quand un moteur est rapide mais imprécis : la correction humaine *post hoc* absorbe le gain. Discrimine fortement entre cloud rapide à 30 % de timeouts et local lent à 100 % de fiabilité. Nouveau module `picarones/core/throughput.py` : `compute_effective_throughput(n_pages, duration_seconds, n_errors, time_per_error_seconds=5.0)` retourne `{n_pages, duration_seconds, n_errors, time_per_error_seconds, correction_time_seconds, total_seconds, pages_per_hour_raw, pages_per_hour_effective, drag_ratio}`. Constante HTR-United (5 s/erreur) surchargeable. Garde-fous : `None` si `n_pages = 0` ou `total_seconds = 0`, `ValueError` sur valeurs négatives. `aggregate_effective_throughput(per_engine)` agrège par moteur. Nouveau module `picarones/core/marginal_cost.py` : `compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)` retourne `{cost_per_avoided_error, n_errors_avoided, cost_delta, dominated}` ou `None` si `errors_b ≥ errors_a`. `dominated=True` quand B moins cher ET plus précis. `compute_marginal_cost_matrix(per_engine)` retourne paires ordonnées (A → B) triées par coût marginal croissant. Nouveau module `picarones/report/throughput_render.py` : `build_throughput_html(aggregated, labels)` produit tableau résumé moteur × {pages/h brut, pages/h **utilisable** (gradient rouge → vert sur le max observé), % drag (gradient vert → rouge), pages, erreurs}, tri par pages/h utilisable décroissant. Adaptive : `""` si pas de données. Module pur — l'utilisateur compose la liste `per_engine`. Vue HTML coût marginal couplée à la vue Pareto reportée à un sprint ultérieur. +9 clés i18n FR/EN (`throughput_*`). +27 tests dans `test_sprint91_throughput.py` (formule effective avec/sans erreurs, custom time_per_error, garde-fous, drag_ratio élevé, agrégation 3 cas, marginal cost 5 cas dont dominé/non comparable, matrice tri ascendant + lt 2 + données invalides, **cas réaliste BnF** Tesseract local 600 p/h brut → 423 p/h effectif vs GPT-4o cloud 1800 p/h brut → 300 p/h effectif, vue HTML 4 cas dont anti-injection + tri descendant, complétude i18n 9 clés). **Verrou levé** : un archiviste BnF qui pondère un budget contre une exigence de délai voit immédiatement *« Tesseract local 423 p/h utilisable, GPT-4o cloud 300 p/h utilisable malgré son apparente vitesse de 1800 p/h brut »* — la décision business s'aligne sur la réalité opérationnelle. |
|
| 213 |
| 90 | **Sprint 59 du plan d'évolution 2026 — A.II.4 finition : détecteur narratif `engine_unstable` + vue HTML stabilité multi-runs**. Le module `picarones/core/reliability.py` (Sprint 83) livrait la couche de calcul ; aucun détecteur ni vue ne consommaient les données. Critique pour les moteurs LLM/VLM dont la non-déterministie sape la reproductibilité scientifique. Nouveau `FactType.ENGINE_UNSTABLE` (priority 160, importance HIGH) + détecteur `detect_engine_unstable` qui lit `benchmark_data["multirun_stability"]` (liste enrichie d'`engine_name` + sortie de `compute_multirun_stability`). Garde-fous : `n_runs ≥ 2`, déclenche si `cer_cv > 0.10` **ou** `identical_run_rate < 0.50`. Templates FR/EN sans chiffres en dur. Ajout du couple `(GLOBAL_LEADER_CER, ENGINE_UNSTABLE)` à `_COMPLEMENTARY_PAIRS` de l'arbitre — un moteur peut être leader **et** instable, et c'est précisément l'information critique à remonter ensemble. Nouveau module `picarones/report/multirun_stability_render.py` : `build_multirun_stability_html(stability, labels)` rend un tableau moteur × {n_runs, CER moyen ± σ, CV (gradient vert→orange→rouge sur 0–25 %), % runs identiques, sorties distinctes}. Adaptive : `""` si liste vide ou tous `cer_cv` None. Note d'intégration : la vue est un module pur (l'utilisateur exécute lui-même les N runs ; option runner `--repeats N` reportée à un sprint dédié). +8 clés i18n FR/EN (`stability_*`). +18 tests dans `test_sprint90_engine_unstable.py` (FactType + arbiter, détecteur 6 cas, **traçabilité anti-hallucination FR + EN** sur les sentences de `build_synthesis`, vue HTML 4 cas dont anti-injection, complétude i18n 8 clés). **Verrou levé** : un papier scientifique qui rapporte un CER LLM voit désormais *« sur 4 runs successifs, gpt-4o produit des sorties variables (CV 24,3 %) — interpréter avec prudence »* dans la synthèse + le tableau de stabilité dans la vue. |
|
|
|
|
| 311 |
## Contexte développement
|
| 312 |
|
| 313 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 314 |
+
- **Tests** : 3017 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 ; Sprints 55-62 = extension philologique livrée bout-en-bout sur trois périodes + numéraux romains transversaux + câblage runner adaptive + vue HTML « Profil philologique » ; Sprints 63-70 = axe B livré bout-en-bout ; Sprints 71-72 = A.I.1 livré bout-en-bout ; Sprints 73-74 = A.I.3 livré bout-en-bout ; Sprints 75-77 = A.I.4 livré bout-en-bout ; Sprint 78 = A.I.5 couche calcul ; Sprint 79 = A.I.6 couche calcul ; Sprint 80 = A.I.7 ; Sprint 81 = A.I.8 couche calcul ; Sprint 82 = A.I.9 — « Leviers d'amélioration » bout-en-bout ; Sprint 83 = A.II.4 — métriques de fiabilité (IAA Cohen κ + Krippendorff α + stabilité multi-runs, couche calcul) ; Sprint 84 = A.II.5a — recherchabilité fuzzy ; Sprint 85 = A.II.5b — précision séquences numériques ; Sprint 86 = A.II.5 bout-en-bout (câblage runner + vues HTML) ; Sprint 87 = A.II.2 (delta Flesch) câblé bout-en-bout ; Sprint 88 = A.I.8 — vue HTML « Déficit projeté de robustesse » bout-en-bout ; Sprint 89 = A.II.8b — score de spécialisation inter-moteurs (couche calcul + vue HTML « Top paires spécialisées ») ; Sprint 90 = A.II.4 finition — détecteur narratif `engine_unstable` + vue HTML stabilité multi-runs ; Sprint 91 = A.II.6 — métriques économiques (throughput effectif + coût marginal par erreur évitée, couche calcul + vue HTML throughput) ; Sprint 92 = A.II.9 — métriques longitudinales (régression linéaire + change-point + détecteur narratif + vue HTML) ; **Sprint 93 = A.II.7 — métriques d'image prédictives (complexité paléographique + homogénéité corpus, couche calcul + vue HTML)**)
|
| 315 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 316 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 317 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Métriques d'image prédictives — Sprint 93 (A.II.7).
|
| 2 |
+
|
| 3 |
+
Sprint 93 — A.II.7 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
``image_quality`` (Sprint 5) mesure des features d'image
|
| 8 |
+
indépendamment ; ce module **les combine** pour produire deux
|
| 9 |
+
indicateurs corpus-level :
|
| 10 |
+
|
| 11 |
+
1. **Score de complexité paléographique** ∈ [0, 1]. Combine
|
| 12 |
+
bruit, faible netteté, faible contraste et rotation en un
|
| 13 |
+
indicateur unique de la difficulté intrinsèque pour un OCR.
|
| 14 |
+
0 = document trivial, 1 = document extrême. Permet
|
| 15 |
+
d'expliquer une partie du CER observé.
|
| 16 |
+
|
| 17 |
+
2. **Score d'homogénéité du corpus** ∈ [0, 1]. Variance des
|
| 18 |
+
features entre documents. 0 = corpus uniforme (la moyenne
|
| 19 |
+
globale du benchmark est fiable), 1 = corpus hétérogène
|
| 20 |
+
(la moyenne ment, il faut stratifier). Couplé au détecteur
|
| 21 |
+
``stratification_recommended`` (Sprint 46) qui agit sur
|
| 22 |
+
``script_type``.
|
| 23 |
+
|
| 24 |
+
Pondérations
|
| 25 |
+
------------
|
| 26 |
+
La roadmap propose une combinaison **pondérée** sans fixer les
|
| 27 |
+
poids — on adopte une convention éditoriale documentée :
|
| 28 |
+
|
| 29 |
+
- ``noise_level`` : poids 0.30 (bruit franc → CER ↑)
|
| 30 |
+
- ``1 - sharpness_score`` : poids 0.30 (flou → CER ↑)
|
| 31 |
+
- ``1 - contrast_score`` : poids 0.20 (faible contraste → CER ↑)
|
| 32 |
+
- ``|rotation_degrees|/30`` : poids 0.20 (rotation > 30° = pire)
|
| 33 |
+
|
| 34 |
+
Les poids somment à 1. L'utilisateur peut surcharger via
|
| 35 |
+
``weights={...}``.
|
| 36 |
+
|
| 37 |
+
Pas de prédiction CER absolue
|
| 38 |
+
-----------------------------
|
| 39 |
+
On ne prétend **pas** prédire une valeur CER en pourcentage —
|
| 40 |
+
ça demanderait un modèle entraîné par moteur, ce que la
|
| 41 |
+
philosophie banc d'essai exclut. On fournit un score relatif
|
| 42 |
+
qui se corrèle au CER observé pour une **lecture
|
| 43 |
+
diagnostique** : *« le document A est ~3× plus complexe que le
|
| 44 |
+
document B, ce qui est cohérent avec le CER observé. »*
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import logging
|
| 50 |
+
import math
|
| 51 |
+
import statistics
|
| 52 |
+
from typing import Iterable, Optional
|
| 53 |
+
|
| 54 |
+
logger = logging.getLogger(__name__)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# Poids éditoriaux par défaut.
|
| 58 |
+
DEFAULT_COMPLEXITY_WEIGHTS = {
|
| 59 |
+
"noise_level": 0.30,
|
| 60 |
+
"blur": 0.30, # 1 - sharpness_score
|
| 61 |
+
"low_contrast": 0.20, # 1 - contrast_score
|
| 62 |
+
"rotation": 0.20, # |rotation_degrees| / 30
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# Plage de saturation pour la rotation. Au-delà de 30°, on
|
| 67 |
+
# considère que c'est aussi pire que pire.
|
| 68 |
+
_ROTATION_SATURATION_DEG = 30.0
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _clip01(x: float) -> float:
|
| 72 |
+
return max(0.0, min(1.0, x))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _extract_feature(
|
| 76 |
+
quality: dict, key: str, default: float = 0.0,
|
| 77 |
+
) -> float:
|
| 78 |
+
val = quality.get(key, default)
|
| 79 |
+
if val is None:
|
| 80 |
+
return default
|
| 81 |
+
try:
|
| 82 |
+
return float(val)
|
| 83 |
+
except (TypeError, ValueError):
|
| 84 |
+
return default
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def compute_paleographic_complexity(
|
| 88 |
+
quality: dict,
|
| 89 |
+
*,
|
| 90 |
+
weights: Optional[dict[str, float]] = None,
|
| 91 |
+
) -> Optional[dict]:
|
| 92 |
+
"""Score de complexité paléographique d'une image.
|
| 93 |
+
|
| 94 |
+
Parameters
|
| 95 |
+
----------
|
| 96 |
+
quality:
|
| 97 |
+
Dict ``ImageQualityResult.as_dict()`` ou compatible.
|
| 98 |
+
Champs lus : ``noise_level``, ``sharpness_score``,
|
| 99 |
+
``contrast_score``, ``rotation_degrees``.
|
| 100 |
+
weights:
|
| 101 |
+
Poids surchargeant les défauts. Doit contenir les
|
| 102 |
+
4 clés ``noise_level``, ``blur``, ``low_contrast``,
|
| 103 |
+
``rotation``. Les poids sont normalisés (somme = 1).
|
| 104 |
+
|
| 105 |
+
Returns
|
| 106 |
+
-------
|
| 107 |
+
dict | None
|
| 108 |
+
``{
|
| 109 |
+
"score": float, # ∈ [0, 1]
|
| 110 |
+
"components": {
|
| 111 |
+
"noise": float, "blur": float,
|
| 112 |
+
"low_contrast": float, "rotation": float,
|
| 113 |
+
},
|
| 114 |
+
"weights_used": dict,
|
| 115 |
+
}`` ou ``None`` si ``quality`` est falsy.
|
| 116 |
+
"""
|
| 117 |
+
if not quality:
|
| 118 |
+
return None
|
| 119 |
+
w = dict(DEFAULT_COMPLEXITY_WEIGHTS)
|
| 120 |
+
if weights:
|
| 121 |
+
for k in w:
|
| 122 |
+
if k in weights:
|
| 123 |
+
w[k] = float(weights[k])
|
| 124 |
+
total = sum(w.values())
|
| 125 |
+
if total <= 0:
|
| 126 |
+
return None
|
| 127 |
+
w = {k: v / total for k, v in w.items()}
|
| 128 |
+
noise = _clip01(_extract_feature(quality, "noise_level"))
|
| 129 |
+
sharpness = _clip01(_extract_feature(quality, "sharpness_score"))
|
| 130 |
+
contrast = _clip01(_extract_feature(quality, "contrast_score"))
|
| 131 |
+
rotation_deg = abs(_extract_feature(quality, "rotation_degrees"))
|
| 132 |
+
blur = 1.0 - sharpness
|
| 133 |
+
low_contrast = 1.0 - contrast
|
| 134 |
+
rotation = _clip01(rotation_deg / _ROTATION_SATURATION_DEG)
|
| 135 |
+
score = (
|
| 136 |
+
w["noise_level"] * noise
|
| 137 |
+
+ w["blur"] * blur
|
| 138 |
+
+ w["low_contrast"] * low_contrast
|
| 139 |
+
+ w["rotation"] * rotation
|
| 140 |
+
)
|
| 141 |
+
return {
|
| 142 |
+
"score": _clip01(score),
|
| 143 |
+
"components": {
|
| 144 |
+
"noise": noise,
|
| 145 |
+
"blur": blur,
|
| 146 |
+
"low_contrast": low_contrast,
|
| 147 |
+
"rotation": rotation,
|
| 148 |
+
},
|
| 149 |
+
"weights_used": w,
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def compute_corpus_homogeneity(
|
| 154 |
+
image_qualities: Iterable[dict],
|
| 155 |
+
) -> Optional[dict]:
|
| 156 |
+
"""Score d'homogénéité du corpus ∈ [0, 1].
|
| 157 |
+
|
| 158 |
+
0 = corpus uniforme (faible variance entre documents),
|
| 159 |
+
1 = corpus hétérogène.
|
| 160 |
+
|
| 161 |
+
Méthode : pour chaque feature dans ``noise_level``,
|
| 162 |
+
``sharpness_score``, ``contrast_score``, ``rotation_degrees``,
|
| 163 |
+
on calcule l'écart-type *normalisé* sur les documents (par
|
| 164 |
+
une plage de référence), puis on prend la moyenne des 4.
|
| 165 |
+
|
| 166 |
+
Plages de normalisation :
|
| 167 |
+
- ``noise_level``, ``sharpness_score``, ``contrast_score``
|
| 168 |
+
∈ [0, 1] → écart-type / 0.5 (max théorique de l'écart-type
|
| 169 |
+
d'une distribution sur [0,1]) borné à 1.
|
| 170 |
+
- ``rotation_degrees`` → écart-type / 10°.
|
| 171 |
+
|
| 172 |
+
Parameters
|
| 173 |
+
----------
|
| 174 |
+
image_qualities:
|
| 175 |
+
Itérable de dicts ``ImageQualityResult.as_dict()``.
|
| 176 |
+
|
| 177 |
+
Returns
|
| 178 |
+
-------
|
| 179 |
+
dict | None
|
| 180 |
+
``{
|
| 181 |
+
"score": float, # ∈ [0, 1]
|
| 182 |
+
"n_docs": int,
|
| 183 |
+
"per_feature": {
|
| 184 |
+
feature: {"mean": float, "stdev": float,
|
| 185 |
+
"normalised": float},
|
| 186 |
+
},
|
| 187 |
+
}`` ou ``None`` si moins de 2 documents.
|
| 188 |
+
"""
|
| 189 |
+
docs = [q for q in image_qualities if q]
|
| 190 |
+
if len(docs) < 2:
|
| 191 |
+
return None
|
| 192 |
+
features = (
|
| 193 |
+
("noise_level", 0.5),
|
| 194 |
+
("sharpness_score", 0.5),
|
| 195 |
+
("contrast_score", 0.5),
|
| 196 |
+
("rotation_degrees", 10.0),
|
| 197 |
+
)
|
| 198 |
+
per_feature: dict[str, dict] = {}
|
| 199 |
+
norm_stdevs: list[float] = []
|
| 200 |
+
for key, divisor in features:
|
| 201 |
+
values = [
|
| 202 |
+
_extract_feature(q, key)
|
| 203 |
+
for q in docs
|
| 204 |
+
]
|
| 205 |
+
if not values:
|
| 206 |
+
continue
|
| 207 |
+
mean = statistics.fmean(values)
|
| 208 |
+
try:
|
| 209 |
+
stdev = statistics.stdev(values) if len(values) >= 2 else 0.0
|
| 210 |
+
except statistics.StatisticsError:
|
| 211 |
+
stdev = 0.0
|
| 212 |
+
normalised = _clip01(stdev / divisor) if divisor > 0 else 0.0
|
| 213 |
+
per_feature[key] = {
|
| 214 |
+
"mean": mean,
|
| 215 |
+
"stdev": stdev,
|
| 216 |
+
"normalised": normalised,
|
| 217 |
+
}
|
| 218 |
+
norm_stdevs.append(normalised)
|
| 219 |
+
if not norm_stdevs:
|
| 220 |
+
return None
|
| 221 |
+
score = statistics.fmean(norm_stdevs)
|
| 222 |
+
return {
|
| 223 |
+
"score": _clip01(score),
|
| 224 |
+
"n_docs": len(docs),
|
| 225 |
+
"per_feature": per_feature,
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def aggregate_corpus_predictive(
|
| 230 |
+
image_qualities: Iterable[dict],
|
| 231 |
+
*,
|
| 232 |
+
weights: Optional[dict[str, float]] = None,
|
| 233 |
+
) -> Optional[dict]:
|
| 234 |
+
"""Synthèse corpus-wide : complexité moyenne + homogénéité.
|
| 235 |
+
|
| 236 |
+
Returns
|
| 237 |
+
-------
|
| 238 |
+
dict | None
|
| 239 |
+
``{
|
| 240 |
+
"n_docs": int,
|
| 241 |
+
"complexity_mean": float,
|
| 242 |
+
"complexity_median": float,
|
| 243 |
+
"complexity_min": float,
|
| 244 |
+
"complexity_max": float,
|
| 245 |
+
"complexity_stdev": float,
|
| 246 |
+
"homogeneity": dict, # sortie de
|
| 247 |
+
# compute_corpus_homogeneity
|
| 248 |
+
}`` ou ``None`` si moins d'un document.
|
| 249 |
+
"""
|
| 250 |
+
docs = [q for q in image_qualities if q]
|
| 251 |
+
if not docs:
|
| 252 |
+
return None
|
| 253 |
+
scores: list[float] = []
|
| 254 |
+
for q in docs:
|
| 255 |
+
result = compute_paleographic_complexity(q, weights=weights)
|
| 256 |
+
if result is not None:
|
| 257 |
+
scores.append(float(result["score"]))
|
| 258 |
+
if not scores:
|
| 259 |
+
return None
|
| 260 |
+
homogeneity = compute_corpus_homogeneity(docs)
|
| 261 |
+
return {
|
| 262 |
+
"n_docs": len(docs),
|
| 263 |
+
"complexity_mean": statistics.fmean(scores),
|
| 264 |
+
"complexity_median": statistics.median(scores),
|
| 265 |
+
"complexity_min": min(scores),
|
| 266 |
+
"complexity_max": max(scores),
|
| 267 |
+
"complexity_stdev": (
|
| 268 |
+
statistics.stdev(scores) if len(scores) >= 2 else 0.0
|
| 269 |
+
),
|
| 270 |
+
"homogeneity": homogeneity,
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
__all__ = [
|
| 275 |
+
"DEFAULT_COMPLEXITY_WEIGHTS",
|
| 276 |
+
"compute_paleographic_complexity",
|
| 277 |
+
"compute_corpus_homogeneity",
|
| 278 |
+
"aggregate_corpus_predictive",
|
| 279 |
+
]
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
# Évite warning import inutilisé
|
| 283 |
+
_ = math
|
|
@@ -351,5 +351,24 @@
|
|
| 351 |
"longitudinal_delta": "Cumulative Δ (pts)",
|
| 352 |
"longitudinal_slope": "Annual slope (pts/yr)",
|
| 353 |
"longitudinal_r2": "R²",
|
| 354 |
-
"longitudinal_change": "Change-point"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
}
|
|
|
|
| 351 |
"longitudinal_delta": "Cumulative Δ (pts)",
|
| 352 |
"longitudinal_slope": "Annual slope (pts/yr)",
|
| 353 |
"longitudinal_r2": "R²",
|
| 354 |
+
"longitudinal_change": "Change-point",
|
| 355 |
+
"imgpred_title": "Corpus image profile",
|
| 356 |
+
"imgpred_note": "Palaeographic complexity score combining noise, blur, low contrast and rotation. The homogeneity score signals whether the global average is reliable (uniform corpus) or misleading (heterogeneous corpus — then see the stratified view).",
|
| 357 |
+
"imgpred_complexity": "Palaeographic complexity",
|
| 358 |
+
"imgpred_homogeneity": "Corpus homogeneity",
|
| 359 |
+
"imgpred_score": "Score",
|
| 360 |
+
"imgpred_mean": "Mean",
|
| 361 |
+
"imgpred_median": "Median",
|
| 362 |
+
"imgpred_min": "Min",
|
| 363 |
+
"imgpred_max": "Max",
|
| 364 |
+
"imgpred_stdev": "Stdev",
|
| 365 |
+
"imgpred_docs": "Docs",
|
| 366 |
+
"imgpred_feature": "Feature",
|
| 367 |
+
"imgpred_feat_mean": "Mean",
|
| 368 |
+
"imgpred_feat_stdev": "Stdev",
|
| 369 |
+
"imgpred_feat_norm": "Normalised contribution",
|
| 370 |
+
"imgpred_feat_noise": "Noise level",
|
| 371 |
+
"imgpred_feat_sharpness": "Sharpness",
|
| 372 |
+
"imgpred_feat_contrast": "Contrast",
|
| 373 |
+
"imgpred_feat_rotation": "Rotation"
|
| 374 |
}
|
|
@@ -351,5 +351,24 @@
|
|
| 351 |
"longitudinal_delta": "Δ cumulé (pts)",
|
| 352 |
"longitudinal_slope": "Pente annuelle (pts/an)",
|
| 353 |
"longitudinal_r2": "R²",
|
| 354 |
-
"longitudinal_change": "Rupture"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
}
|
|
|
|
| 351 |
"longitudinal_delta": "Δ cumulé (pts)",
|
| 352 |
"longitudinal_slope": "Pente annuelle (pts/an)",
|
| 353 |
"longitudinal_r2": "R²",
|
| 354 |
+
"longitudinal_change": "Rupture",
|
| 355 |
+
"imgpred_title": "Profil d'image du corpus",
|
| 356 |
+
"imgpred_note": "Score de complexité paléographique combinant bruit, flou, faible contraste et rotation. Le score d'homogénéité signale si la moyenne globale est fiable (corpus uniforme) ou trompeuse (corpus hétérogène — voir alors la vue stratifiée).",
|
| 357 |
+
"imgpred_complexity": "Complexité paléographique",
|
| 358 |
+
"imgpred_homogeneity": "Homogénéité du corpus",
|
| 359 |
+
"imgpred_score": "Score",
|
| 360 |
+
"imgpred_mean": "Moyenne",
|
| 361 |
+
"imgpred_median": "Médiane",
|
| 362 |
+
"imgpred_min": "Min",
|
| 363 |
+
"imgpred_max": "Max",
|
| 364 |
+
"imgpred_stdev": "Écart-type",
|
| 365 |
+
"imgpred_docs": "Docs",
|
| 366 |
+
"imgpred_feature": "Feature",
|
| 367 |
+
"imgpred_feat_mean": "Moyenne",
|
| 368 |
+
"imgpred_feat_stdev": "Écart-type",
|
| 369 |
+
"imgpred_feat_norm": "Contribution normalisée",
|
| 370 |
+
"imgpred_feat_noise": "Niveau de bruit",
|
| 371 |
+
"imgpred_feat_sharpness": "Netteté",
|
| 372 |
+
"imgpred_feat_contrast": "Contraste",
|
| 373 |
+
"imgpred_feat_rotation": "Rotation"
|
| 374 |
}
|
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML « Profil d'image du corpus » — Sprint 93 (A.II.7).
|
| 2 |
+
|
| 3 |
+
Suite directe ``picarones/core/image_predictive.py``. Pattern
|
| 4 |
+
identique aux autres rendus : server-side, pas de JS, anti-
|
| 5 |
+
injection systématique.
|
| 6 |
+
|
| 7 |
+
Vue
|
| 8 |
+
---
|
| 9 |
+
Deux blocs dans une section unique :
|
| 10 |
+
|
| 11 |
+
1. **Complexité paléographique** : moyenne, médiane, min, max,
|
| 12 |
+
écart-type sur l'ensemble du corpus.
|
| 13 |
+
2. **Homogénéité du corpus** : score combiné + détail par
|
| 14 |
+
feature (mean, stdev, contribution normalisée).
|
| 15 |
+
|
| 16 |
+
Adaptive : ``""`` si pas de données.
|
| 17 |
+
|
| 18 |
+
Note d'intégration
|
| 19 |
+
------------------
|
| 20 |
+
Module pur — l'utilisateur compose :
|
| 21 |
+
|
| 22 |
+
.. code-block:: python
|
| 23 |
+
|
| 24 |
+
from picarones.core.image_predictive import aggregate_corpus_predictive
|
| 25 |
+
from picarones.report.image_predictive_render import (
|
| 26 |
+
build_image_predictive_html,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
qualities = [doc.image_quality.as_dict() for doc in benchmark.docs]
|
| 30 |
+
agg = aggregate_corpus_predictive(qualities)
|
| 31 |
+
html = build_image_predictive_html(agg, labels)
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
from html import escape as _e
|
| 37 |
+
from typing import Optional
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _color_for_score(score: float) -> str:
|
| 41 |
+
"""Vert (faible) → orange → rouge (élevé)."""
|
| 42 |
+
f = max(0.0, min(1.0, score))
|
| 43 |
+
if f < 0.5:
|
| 44 |
+
t = f / 0.5
|
| 45 |
+
r = int(167 + (235 - 167) * t)
|
| 46 |
+
g = int(240 + (180 - 240) * t)
|
| 47 |
+
b = int(167 + (60 - 167) * t)
|
| 48 |
+
else:
|
| 49 |
+
t = (f - 0.5) / 0.5
|
| 50 |
+
r = int(235 + (220 - 235) * t)
|
| 51 |
+
g = int(180 + (50 - 180) * t)
|
| 52 |
+
b = int(60 + (50 - 60) * t)
|
| 53 |
+
return f"#{r:02x}{g:02x}{b:02x}"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
_FEATURE_LABEL_KEYS = {
|
| 57 |
+
"noise_level": "imgpred_feat_noise",
|
| 58 |
+
"sharpness_score": "imgpred_feat_sharpness",
|
| 59 |
+
"contrast_score": "imgpred_feat_contrast",
|
| 60 |
+
"rotation_degrees": "imgpred_feat_rotation",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _render_complexity_block(
|
| 65 |
+
aggregated: dict, labels: dict[str, str],
|
| 66 |
+
) -> str:
|
| 67 |
+
h_complex = labels.get(
|
| 68 |
+
"imgpred_complexity", "Complexité paléographique",
|
| 69 |
+
)
|
| 70 |
+
h_mean = labels.get("imgpred_mean", "Moyenne")
|
| 71 |
+
h_median = labels.get("imgpred_median", "Médiane")
|
| 72 |
+
h_min = labels.get("imgpred_min", "Min")
|
| 73 |
+
h_max = labels.get("imgpred_max", "Max")
|
| 74 |
+
h_stdev = labels.get("imgpred_stdev", "Écart-type")
|
| 75 |
+
h_docs = labels.get("imgpred_docs", "Docs")
|
| 76 |
+
mean = float(aggregated.get("complexity_mean") or 0.0)
|
| 77 |
+
median = float(aggregated.get("complexity_median") or 0.0)
|
| 78 |
+
mn = float(aggregated.get("complexity_min") or 0.0)
|
| 79 |
+
mx = float(aggregated.get("complexity_max") or 0.0)
|
| 80 |
+
sd = float(aggregated.get("complexity_stdev") or 0.0)
|
| 81 |
+
n_docs = int(aggregated.get("n_docs") or 0)
|
| 82 |
+
color_mean = _color_for_score(mean)
|
| 83 |
+
return (
|
| 84 |
+
f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
|
| 85 |
+
f'{_e(h_complex)}</div>'
|
| 86 |
+
'<table style="border-collapse:collapse;width:100%;'
|
| 87 |
+
'font-size:.9rem;margin-bottom:.8rem">'
|
| 88 |
+
f'<thead><tr>'
|
| 89 |
+
f'<th style="padding:.4rem .6rem;text-align:right;'
|
| 90 |
+
f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_mean)}</th>'
|
| 91 |
+
f'<th style="padding:.4rem .6rem;text-align:right;'
|
| 92 |
+
f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_median)}</th>'
|
| 93 |
+
f'<th style="padding:.4rem .6rem;text-align:right;'
|
| 94 |
+
f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_min)}</th>'
|
| 95 |
+
f'<th style="padding:.4rem .6rem;text-align:right;'
|
| 96 |
+
f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_max)}</th>'
|
| 97 |
+
f'<th style="padding:.4rem .6rem;text-align:right;'
|
| 98 |
+
f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_stdev)}</th>'
|
| 99 |
+
f'<th style="padding:.4rem .6rem;text-align:right;'
|
| 100 |
+
f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_docs)}</th>'
|
| 101 |
+
f'</tr></thead>'
|
| 102 |
+
f'<tbody><tr>'
|
| 103 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 104 |
+
f'background:{color_mean};font-family:monospace;font-weight:600">'
|
| 105 |
+
f'{mean:.3f}</td>'
|
| 106 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 107 |
+
f'font-family:monospace">{median:.3f}</td>'
|
| 108 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 109 |
+
f'font-family:monospace">{mn:.3f}</td>'
|
| 110 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 111 |
+
f'font-family:monospace">{mx:.3f}</td>'
|
| 112 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 113 |
+
f'font-family:monospace">{sd:.3f}</td>'
|
| 114 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 115 |
+
f'font-family:monospace">{n_docs}</td>'
|
| 116 |
+
f'</tr></tbody></table>'
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _render_homogeneity_block(
|
| 121 |
+
homogeneity: dict, labels: dict[str, str],
|
| 122 |
+
) -> str:
|
| 123 |
+
h_homo = labels.get(
|
| 124 |
+
"imgpred_homogeneity", "Homogénéité du corpus",
|
| 125 |
+
)
|
| 126 |
+
h_feat = labels.get("imgpred_feature", "Feature")
|
| 127 |
+
h_mean = labels.get("imgpred_feat_mean", "Moyenne")
|
| 128 |
+
h_stdev = labels.get("imgpred_feat_stdev", "Écart-type")
|
| 129 |
+
h_norm = labels.get(
|
| 130 |
+
"imgpred_feat_norm", "Contribution normalisée",
|
| 131 |
+
)
|
| 132 |
+
score = float(homogeneity.get("score") or 0.0)
|
| 133 |
+
color = _color_for_score(score)
|
| 134 |
+
parts = [
|
| 135 |
+
f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
|
| 136 |
+
f'{_e(h_homo)} : '
|
| 137 |
+
f'<span style="background:{color};padding:.1rem .4rem;'
|
| 138 |
+
f'border-radius:.3rem;font-family:monospace">{score:.3f}</span>'
|
| 139 |
+
f'</div>',
|
| 140 |
+
'<table style="border-collapse:collapse;width:100%;'
|
| 141 |
+
'font-size:.9rem">',
|
| 142 |
+
'<thead><tr>',
|
| 143 |
+
]
|
| 144 |
+
for col in (h_feat, h_mean, h_stdev, h_norm):
|
| 145 |
+
parts.append(
|
| 146 |
+
f'<th style="padding:.4rem .6rem;text-align:left;'
|
| 147 |
+
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 148 |
+
f'{_e(col)}</th>'
|
| 149 |
+
)
|
| 150 |
+
parts.append("</tr></thead><tbody>")
|
| 151 |
+
per_feat = homogeneity.get("per_feature") or {}
|
| 152 |
+
for key, label_key in _FEATURE_LABEL_KEYS.items():
|
| 153 |
+
if key not in per_feat:
|
| 154 |
+
continue
|
| 155 |
+
slot = per_feat[key]
|
| 156 |
+
feat_label = labels.get(label_key, key)
|
| 157 |
+
feat_mean = float(slot.get("mean") or 0.0)
|
| 158 |
+
feat_stdev = float(slot.get("stdev") or 0.0)
|
| 159 |
+
feat_norm = float(slot.get("normalised") or 0.0)
|
| 160 |
+
norm_color = _color_for_score(feat_norm)
|
| 161 |
+
parts.append(
|
| 162 |
+
f'<tr>'
|
| 163 |
+
f'<td style="padding:.4rem .6rem">{_e(feat_label)}</td>'
|
| 164 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 165 |
+
f'font-family:monospace">{feat_mean:.3f}</td>'
|
| 166 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 167 |
+
f'font-family:monospace">{feat_stdev:.3f}</td>'
|
| 168 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 169 |
+
f'background:{norm_color};font-family:monospace">'
|
| 170 |
+
f'{feat_norm:.3f}</td>'
|
| 171 |
+
f'</tr>'
|
| 172 |
+
)
|
| 173 |
+
parts.append("</tbody></table>")
|
| 174 |
+
return "".join(parts)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def build_image_predictive_html(
|
| 178 |
+
aggregated: Optional[dict],
|
| 179 |
+
labels: Optional[dict[str, str]] = None,
|
| 180 |
+
) -> str:
|
| 181 |
+
"""Construit la vue HTML « Profil d'image du corpus ».
|
| 182 |
+
|
| 183 |
+
Parameters
|
| 184 |
+
----------
|
| 185 |
+
aggregated:
|
| 186 |
+
Sortie de ``aggregate_corpus_predictive``. Si ``None``
|
| 187 |
+
ou ``n_docs == 0``, retourne ``""``.
|
| 188 |
+
labels:
|
| 189 |
+
Dict i18n. Clés sous le préfixe ``imgpred_*``.
|
| 190 |
+
"""
|
| 191 |
+
if not aggregated:
|
| 192 |
+
return ""
|
| 193 |
+
if not aggregated.get("n_docs"):
|
| 194 |
+
return ""
|
| 195 |
+
labels = labels or {}
|
| 196 |
+
title = labels.get(
|
| 197 |
+
"imgpred_title", "Profil d'image du corpus",
|
| 198 |
+
)
|
| 199 |
+
note = labels.get(
|
| 200 |
+
"imgpred_note",
|
| 201 |
+
"Score de complexité paléographique combinant bruit, "
|
| 202 |
+
"flou, faible contraste et rotation. Le score "
|
| 203 |
+
"d'homogénéité signale si la moyenne globale est fiable "
|
| 204 |
+
"(corpus uniforme) ou trompeuse (corpus hétérogène — "
|
| 205 |
+
"voir alors la vue stratifiée).",
|
| 206 |
+
)
|
| 207 |
+
parts = [
|
| 208 |
+
'<section class="imgpred-section" style="margin:1rem 0">',
|
| 209 |
+
f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
|
| 210 |
+
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
|
| 211 |
+
f'{_e(note)}</div>',
|
| 212 |
+
]
|
| 213 |
+
parts.append(_render_complexity_block(aggregated, labels))
|
| 214 |
+
homo = aggregated.get("homogeneity")
|
| 215 |
+
if isinstance(homo, dict):
|
| 216 |
+
parts.append(_render_homogeneity_block(homo, labels))
|
| 217 |
+
parts.append("</section>")
|
| 218 |
+
return "".join(parts)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
__all__ = ["build_image_predictive_html"]
|
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 93 — A.II.7 : métriques d'image prédictives.
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``compute_paleographic_complexity`` :
|
| 6 |
+
- cas trivial → score ≈ 0
|
| 7 |
+
- cas extrême → score ≈ 1
|
| 8 |
+
- poids surchargés
|
| 9 |
+
- bornes [0, 1]
|
| 10 |
+
- garde-fous (None, weights nuls)
|
| 11 |
+
2. ``compute_corpus_homogeneity`` :
|
| 12 |
+
- corpus uniforme → score ≈ 0
|
| 13 |
+
- corpus hétérogène → score haut
|
| 14 |
+
- lt 2 docs → None
|
| 15 |
+
3. ``aggregate_corpus_predictive`` :
|
| 16 |
+
- cas réaliste BnF
|
| 17 |
+
- empty
|
| 18 |
+
4. Vue HTML :
|
| 19 |
+
- adaptive
|
| 20 |
+
- anti-injection
|
| 21 |
+
- FR + EN
|
| 22 |
+
5. Complétude i18n FR/EN.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import json
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import pytest
|
| 31 |
+
|
| 32 |
+
from picarones.core.image_predictive import (
|
| 33 |
+
DEFAULT_COMPLEXITY_WEIGHTS,
|
| 34 |
+
aggregate_corpus_predictive,
|
| 35 |
+
compute_corpus_homogeneity,
|
| 36 |
+
compute_paleographic_complexity,
|
| 37 |
+
)
|
| 38 |
+
from picarones.report.image_predictive_render import (
|
| 39 |
+
build_image_predictive_html,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _load_labels(lang: str) -> dict:
|
| 44 |
+
p = (
|
| 45 |
+
Path(__file__).parent.parent
|
| 46 |
+
/ "picarones" / "report" / "i18n" / f"{lang}.json"
|
| 47 |
+
)
|
| 48 |
+
return json.loads(p.read_text(encoding="utf-8"))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 52 |
+
# 1. compute_paleographic_complexity
|
| 53 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class TestComplexity:
|
| 57 |
+
def test_trivial_document(self) -> None:
|
| 58 |
+
q = {"noise_level": 0.05, "sharpness_score": 0.95,
|
| 59 |
+
"contrast_score": 0.9, "rotation_degrees": 0.0}
|
| 60 |
+
r = compute_paleographic_complexity(q)
|
| 61 |
+
# Très faible : ≤ 0.1
|
| 62 |
+
assert r["score"] < 0.1
|
| 63 |
+
|
| 64 |
+
def test_extreme_document(self) -> None:
|
| 65 |
+
q = {"noise_level": 0.95, "sharpness_score": 0.05,
|
| 66 |
+
"contrast_score": 0.05, "rotation_degrees": 30.0}
|
| 67 |
+
r = compute_paleographic_complexity(q)
|
| 68 |
+
# Très élevé : ≥ 0.9
|
| 69 |
+
assert r["score"] > 0.9
|
| 70 |
+
|
| 71 |
+
def test_score_bounds(self) -> None:
|
| 72 |
+
# Valeurs fantaisistes hors plage → clip
|
| 73 |
+
q = {"noise_level": 5.0, "sharpness_score": -1.0,
|
| 74 |
+
"contrast_score": 2.0, "rotation_degrees": 1000.0}
|
| 75 |
+
r = compute_paleographic_complexity(q)
|
| 76 |
+
assert 0.0 <= r["score"] <= 1.0
|
| 77 |
+
|
| 78 |
+
def test_components_returned(self) -> None:
|
| 79 |
+
q = {"noise_level": 0.5, "sharpness_score": 0.5,
|
| 80 |
+
"contrast_score": 0.5, "rotation_degrees": 15.0}
|
| 81 |
+
r = compute_paleographic_complexity(q)
|
| 82 |
+
assert set(r["components"].keys()) == {
|
| 83 |
+
"noise", "blur", "low_contrast", "rotation",
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
def test_custom_weights(self) -> None:
|
| 87 |
+
# Tout poids sur le bruit → score = noise_level
|
| 88 |
+
q = {"noise_level": 0.7, "sharpness_score": 1.0,
|
| 89 |
+
"contrast_score": 1.0, "rotation_degrees": 0}
|
| 90 |
+
r = compute_paleographic_complexity(q, weights={
|
| 91 |
+
"noise_level": 1.0, "blur": 0.0,
|
| 92 |
+
"low_contrast": 0.0, "rotation": 0.0,
|
| 93 |
+
})
|
| 94 |
+
assert r["score"] == pytest.approx(0.7)
|
| 95 |
+
|
| 96 |
+
def test_default_weights_sum_to_one(self) -> None:
|
| 97 |
+
assert sum(DEFAULT_COMPLEXITY_WEIGHTS.values()) == pytest.approx(
|
| 98 |
+
1.0,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
def test_none_returns_none(self) -> None:
|
| 102 |
+
assert compute_paleographic_complexity(None) is None
|
| 103 |
+
assert compute_paleographic_complexity({}) is None
|
| 104 |
+
|
| 105 |
+
def test_zero_weights_returns_none(self) -> None:
|
| 106 |
+
q = {"noise_level": 0.5, "sharpness_score": 0.5,
|
| 107 |
+
"contrast_score": 0.5, "rotation_degrees": 5}
|
| 108 |
+
assert compute_paleographic_complexity(
|
| 109 |
+
q, weights={"noise_level": 0, "blur": 0,
|
| 110 |
+
"low_contrast": 0, "rotation": 0},
|
| 111 |
+
) is None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 115 |
+
# 2. compute_corpus_homogeneity
|
| 116 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class TestHomogeneity:
|
| 120 |
+
def test_uniform_corpus(self) -> None:
|
| 121 |
+
q = {"noise_level": 0.1, "sharpness_score": 0.8,
|
| 122 |
+
"contrast_score": 0.7, "rotation_degrees": 1.0}
|
| 123 |
+
r = compute_corpus_homogeneity([q, q, q])
|
| 124 |
+
# Variance nulle sur tous les docs
|
| 125 |
+
assert r["score"] == 0.0
|
| 126 |
+
|
| 127 |
+
def test_heterogeneous_corpus(self) -> None:
|
| 128 |
+
a = {"noise_level": 0.05, "sharpness_score": 0.95,
|
| 129 |
+
"contrast_score": 0.9, "rotation_degrees": 0.0}
|
| 130 |
+
b = {"noise_level": 0.95, "sharpness_score": 0.05,
|
| 131 |
+
"contrast_score": 0.05, "rotation_degrees": 30.0}
|
| 132 |
+
r = compute_corpus_homogeneity([a, b, a, b])
|
| 133 |
+
assert r["score"] > 0.5
|
| 134 |
+
|
| 135 |
+
def test_lt_two_returns_none(self) -> None:
|
| 136 |
+
assert compute_corpus_homogeneity([]) is None
|
| 137 |
+
assert compute_corpus_homogeneity([{"noise_level": 0.5}]) is None
|
| 138 |
+
|
| 139 |
+
def test_per_feature_keys(self) -> None:
|
| 140 |
+
q1 = {"noise_level": 0.1, "sharpness_score": 0.8,
|
| 141 |
+
"contrast_score": 0.7, "rotation_degrees": 0}
|
| 142 |
+
q2 = {"noise_level": 0.5, "sharpness_score": 0.4,
|
| 143 |
+
"contrast_score": 0.3, "rotation_degrees": 5}
|
| 144 |
+
r = compute_corpus_homogeneity([q1, q2])
|
| 145 |
+
assert "noise_level" in r["per_feature"]
|
| 146 |
+
for slot in r["per_feature"].values():
|
| 147 |
+
assert "mean" in slot and "stdev" in slot and "normalised" in slot
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 151 |
+
# 3. aggregate_corpus_predictive
|
| 152 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class TestAggregate:
|
| 156 |
+
def test_realistic_bnf(self) -> None:
|
| 157 |
+
# Mélange de docs trivial et difficile
|
| 158 |
+
docs = [
|
| 159 |
+
{"noise_level": 0.1, "sharpness_score": 0.9,
|
| 160 |
+
"contrast_score": 0.85, "rotation_degrees": 0},
|
| 161 |
+
{"noise_level": 0.6, "sharpness_score": 0.3,
|
| 162 |
+
"contrast_score": 0.4, "rotation_degrees": 12},
|
| 163 |
+
{"noise_level": 0.15, "sharpness_score": 0.85,
|
| 164 |
+
"contrast_score": 0.8, "rotation_degrees": 1},
|
| 165 |
+
]
|
| 166 |
+
agg = aggregate_corpus_predictive(docs)
|
| 167 |
+
assert agg["n_docs"] == 3
|
| 168 |
+
# Min < mean < max
|
| 169 |
+
assert agg["complexity_min"] < agg["complexity_mean"]
|
| 170 |
+
assert agg["complexity_mean"] < agg["complexity_max"]
|
| 171 |
+
assert agg["homogeneity"] is not None
|
| 172 |
+
|
| 173 |
+
def test_empty_returns_none(self) -> None:
|
| 174 |
+
assert aggregate_corpus_predictive([]) is None
|
| 175 |
+
|
| 176 |
+
def test_single_doc_no_homogeneity(self) -> None:
|
| 177 |
+
# 1 doc → complexity OK mais homogeneity None
|
| 178 |
+
agg = aggregate_corpus_predictive([
|
| 179 |
+
{"noise_level": 0.1, "sharpness_score": 0.8,
|
| 180 |
+
"contrast_score": 0.7, "rotation_degrees": 0},
|
| 181 |
+
])
|
| 182 |
+
assert agg["n_docs"] == 1
|
| 183 |
+
assert agg["homogeneity"] is None
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 187 |
+
# 4. Vue HTML
|
| 188 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class TestRender:
|
| 192 |
+
def test_empty_returns_empty(self) -> None:
|
| 193 |
+
assert build_image_predictive_html(None) == ""
|
| 194 |
+
assert build_image_predictive_html({"n_docs": 0}) == ""
|
| 195 |
+
|
| 196 |
+
def test_renders_complete(self) -> None:
|
| 197 |
+
agg = aggregate_corpus_predictive([
|
| 198 |
+
{"noise_level": 0.1, "sharpness_score": 0.9,
|
| 199 |
+
"contrast_score": 0.85, "rotation_degrees": 0},
|
| 200 |
+
{"noise_level": 0.6, "sharpness_score": 0.3,
|
| 201 |
+
"contrast_score": 0.4, "rotation_degrees": 12},
|
| 202 |
+
])
|
| 203 |
+
html = build_image_predictive_html(agg, _load_labels("fr"))
|
| 204 |
+
assert "<table" in html
|
| 205 |
+
assert "Complexité paléographique" in html
|
| 206 |
+
assert "Homogénéité" in html
|
| 207 |
+
|
| 208 |
+
def test_anti_injection(self) -> None:
|
| 209 |
+
# On ne peut pas injecter via image_quality (champs
|
| 210 |
+
# numériques) mais on vérifie tout de même qu'on n'expose
|
| 211 |
+
# pas de label brut. Testons via une labels personnalisée.
|
| 212 |
+
agg = aggregate_corpus_predictive([
|
| 213 |
+
{"noise_level": 0.1, "sharpness_score": 0.8,
|
| 214 |
+
"contrast_score": 0.7, "rotation_degrees": 0},
|
| 215 |
+
{"noise_level": 0.5, "sharpness_score": 0.4,
|
| 216 |
+
"contrast_score": 0.3, "rotation_degrees": 5},
|
| 217 |
+
])
|
| 218 |
+
html = build_image_predictive_html(
|
| 219 |
+
agg,
|
| 220 |
+
{"imgpred_title": "<script>alert(1)</script>"},
|
| 221 |
+
)
|
| 222 |
+
assert "<script>alert" not in html
|
| 223 |
+
assert "<script>" in html
|
| 224 |
+
|
| 225 |
+
def test_renders_in_english(self) -> None:
|
| 226 |
+
agg = aggregate_corpus_predictive([
|
| 227 |
+
{"noise_level": 0.1, "sharpness_score": 0.8,
|
| 228 |
+
"contrast_score": 0.7, "rotation_degrees": 0},
|
| 229 |
+
{"noise_level": 0.5, "sharpness_score": 0.4,
|
| 230 |
+
"contrast_score": 0.3, "rotation_degrees": 5},
|
| 231 |
+
])
|
| 232 |
+
html = build_image_predictive_html(agg, _load_labels("en"))
|
| 233 |
+
assert "Corpus image profile" in html
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ──────────────────────────────��───────────────────────────────────────────
|
| 237 |
+
# 5. Complétude i18n
|
| 238 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
_KEYS = {
|
| 242 |
+
"imgpred_title", "imgpred_note", "imgpred_complexity",
|
| 243 |
+
"imgpred_homogeneity", "imgpred_score", "imgpred_mean",
|
| 244 |
+
"imgpred_median", "imgpred_min", "imgpred_max", "imgpred_stdev",
|
| 245 |
+
"imgpred_docs", "imgpred_feature", "imgpred_feat_mean",
|
| 246 |
+
"imgpred_feat_stdev", "imgpred_feat_norm",
|
| 247 |
+
"imgpred_feat_noise", "imgpred_feat_sharpness",
|
| 248 |
+
"imgpred_feat_contrast", "imgpred_feat_rotation",
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class TestI18n:
|
| 253 |
+
def test_fr(self) -> None:
|
| 254 |
+
d = _load_labels("fr")
|
| 255 |
+
assert not _KEYS - d.keys()
|
| 256 |
+
|
| 257 |
+
def test_en(self) -> None:
|
| 258 |
+
d = _load_labels("en")
|
| 259 |
+
assert not _KEYS - d.keys()
|