Claude commited on
Commit
dbab656
·
unverified ·
1 Parent(s): cf6df23

sprint93: A.II.7 - métriques d'image prédictives (calcul + HTML)

Browse files

image_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 CHANGED
@@ -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
CLAUDE.md CHANGED
@@ -207,6 +207,7 @@ AZURE_DOC_INTEL_KEY=...
207
  | 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
208
  | 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
209
  | 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
 
210
  | 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** : 2996 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)**)
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** :
picarones/core/image_predictive.py ADDED
@@ -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
picarones/report/i18n/en.json CHANGED
@@ -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
  }
picarones/report/i18n/fr.json CHANGED
@@ -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
  }
picarones/report/image_predictive_render.py ADDED
@@ -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"]
tests/test_sprint93_image_predictive.py ADDED
@@ -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 "&lt;script&gt;" 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()