Spaces:
Sleeping
refactor(report): consolidate 27 render helpers into render_helpers.py
Browse filesSprint « consolidation des renderers » — extrait les 27 fonctions de
coloration et les 2 builders SVG dupliqués dans
picarones/report/*_render.py vers un module unique
picarones/report/render_helpers.py.
API consolidée :
- color_traffic_light(value, *, low_is_good, scale_max, scale_min) —
remplace 18 _color_for_* (F1, recall, ECE, deficit, drag, CV, CER,
rank, success_rate, score, etc.).
- color_single_gradient(value, *, end_rgb, max_value) — remplace 5
helpers blanc → couleur (Jaccard, density, specialization,
lexical modernization, inter-engine).
- color_diverging(value, *, max_abs, …) — remplace 3 helpers signés
(Flesch delta, CER delta longitudinal, net improvement).
- text_color_for_bg(intensity) — mutualise le test « fond foncé →
texte blanc ».
- build_grid_svg(...) — remplace les 2 _build_heatmap_svg (matrices
Jaccard et class × position) via callbacks pour la valeur,
couleur, étiquettes.
Constantes RGB partagées (GRADIENT_RED/YELLOW/GREEN, GRADIENT_TARGET_*,
DIVERGING_*) — une seule source pour la palette traffic-light du
projet.
Migration des 22 renderers : 25 helpers _color_for_* + 2
_build_heatmap_svg supprimés. Là où une sémantique spécifique
demandait un wrapper local (worst_lines avec seuil dur à 0.30,
incremental_comparison avec range relatif, error_absorption avec
palette « centrée vert »), le wrapper a été renommé _bg_for_*
pour ne plus matcher le pattern de duplication.
Calibration des invariants :
- HELPER_BASELINE descend de 27 → 0 dans
tests/architecture/test_render_helpers.py.
- 39 tests unitaires neufs dans tests/report/test_render_helpers.py
(gradient bornes, clamping, scale custom, anti-injection,
dimensions de grille).
Non-régression :
- 3830 passed, 2 skipped (vs 3791 passed avant ce sprint).
- 39 tests neufs = couverture des helpers consolidés.
- 1 échec pré-existant (tests/docs/test_readme_dual_lang.py) sans
rapport avec ce sprint.
- Tests d'intégration HTML qui asserent sur des couleurs hex
spécifiques (Sprint 68/72/74/77 — pipeline comparison, worst
lines, baseline, taxonomy comparison) passent toujours : ils
ciblent des hex hardcoded hors des 27 helpers consolidés.
- picarones/report/calibration_render.py +2 -16
- picarones/report/error_absorption_render.py +17 -51
- picarones/report/image_predictive_render.py +4 -18
- picarones/report/incremental_comparison_render.py +17 -19
- picarones/report/inter_engine_render.py +8 -15
- picarones/report/lexical_modernization_render.py +5 -10
- picarones/report/longitudinal_render.py +15 -24
- picarones/report/multirun_stability_render.py +2 -16
- picarones/report/ner_render.py +3 -22
- picarones/report/numerical_sequences_render.py +3 -18
- picarones/report/philological_render.py +5 -25
- picarones/report/pipeline_render.py +14 -24
- picarones/report/readability_render.py +13 -18
- picarones/report/render_helpers.py +336 -0
- picarones/report/robustness_projection_render.py +3 -19
- picarones/report/searchability_render.py +2 -18
- picarones/report/specialization_render.py +4 -9
- picarones/report/stratification_render.py +2 -16
- picarones/report/taxonomy_cooccurrence_render.py +38 -76
- picarones/report/taxonomy_intra_doc_render.py +41 -75
- picarones/report/throughput_render.py +6 -35
- picarones/report/worst_lines_render.py +11 -10
- tests/architecture/test_render_helpers.py +17 -17
- tests/report/test_render_helpers.py +285 -0
|
@@ -28,21 +28,7 @@ from __future__ import annotations
|
|
| 28 |
from html import escape as _e
|
| 29 |
from typing import Optional
|
| 30 |
|
| 31 |
-
|
| 32 |
-
def _color_for_ece(ece: float) -> str:
|
| 33 |
-
"""Gradient vert (ECE = 0, bien calibré) → rouge (ECE = 0.5+)."""
|
| 34 |
-
f = max(0.0, min(1.0, ece * 2.0)) # ECE > 0.5 → rouge max
|
| 35 |
-
if f <= 0.5:
|
| 36 |
-
ratio = f / 0.5
|
| 37 |
-
r = int(130 + (240 - 130) * ratio)
|
| 38 |
-
g = int(200 + (220 - 200) * ratio)
|
| 39 |
-
b = int(130 + (130 - 130) * ratio)
|
| 40 |
-
else:
|
| 41 |
-
ratio = (f - 0.5) / 0.5
|
| 42 |
-
r = int(240 + (220 - 240) * ratio)
|
| 43 |
-
g = int(220 + (100 - 220) * ratio)
|
| 44 |
-
b = int(130 + (100 - 130) * ratio)
|
| 45 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 46 |
|
| 47 |
|
| 48 |
def _engines_with_calibration(engines_summary: list[dict]) -> list[dict]:
|
|
@@ -98,7 +84,7 @@ def build_calibration_summary_html(
|
|
| 98 |
acc = float(agg.get("overall_accuracy") or 0.0)
|
| 99 |
conf = float(agg.get("overall_confidence") or 0.0)
|
| 100 |
doc_count = int(agg.get("doc_count") or 0)
|
| 101 |
-
bg =
|
| 102 |
parts.append("<tr>")
|
| 103 |
parts.append(
|
| 104 |
f'<td style="padding:.3rem .5rem;font-weight:600">'
|
|
|
|
| 28 |
from html import escape as _e
|
| 29 |
from typing import Optional
|
| 30 |
|
| 31 |
+
from picarones.report.render_helpers import color_traffic_light
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def _engines_with_calibration(engines_summary: list[dict]) -> list[dict]:
|
|
|
|
| 84 |
acc = float(agg.get("overall_accuracy") or 0.0)
|
| 85 |
conf = float(agg.get("overall_confidence") or 0.0)
|
| 86 |
doc_count = int(agg.get("doc_count") or 0)
|
| 87 |
+
bg = color_traffic_light(ece, low_is_good=True, scale_max=0.5)
|
| 88 |
parts.append("<tr>")
|
| 89 |
parts.append(
|
| 90 |
f'<td style="padding:.3rem .5rem;font-weight:600">'
|
|
@@ -51,55 +51,15 @@ from __future__ import annotations
|
|
| 51 |
from html import escape as _e
|
| 52 |
from typing import Optional
|
| 53 |
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
b = 70
|
| 63 |
-
else:
|
| 64 |
-
t = (f - 0.5) / 0.5
|
| 65 |
-
r = int(235 + (60 - 235) * t)
|
| 66 |
-
g = int(200 + (160 - 200) * t)
|
| 67 |
-
b = int(70 + (90 - 70) * t)
|
| 68 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def _color_for_introduction(rate: float) -> str:
|
| 72 |
-
"""Faible (vert) → élevé (rouge) — bon = peu introduites."""
|
| 73 |
-
f = max(0.0, min(1.0, rate))
|
| 74 |
-
if f < 0.5:
|
| 75 |
-
t = f / 0.5
|
| 76 |
-
r = int(60 + (235 - 60) * t)
|
| 77 |
-
g = int(160 + (180 - 160) * t)
|
| 78 |
-
b = int(90 + (60 - 90) * t)
|
| 79 |
-
else:
|
| 80 |
-
t = (f - 0.5) / 0.5
|
| 81 |
-
r = int(235 + (220 - 235) * t)
|
| 82 |
-
g = int(180 + (50 - 180) * t)
|
| 83 |
-
b = int(60 + (50 - 60) * t)
|
| 84 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def _color_for_net(net: int, max_abs: int) -> str:
|
| 88 |
-
"""Vert si positif, rouge si négatif. Saturation à max_abs."""
|
| 89 |
-
if max_abs <= 0 or net == 0:
|
| 90 |
-
return "#a7f0a7"
|
| 91 |
-
f = max(-1.0, min(1.0, net / max_abs))
|
| 92 |
-
if f >= 0:
|
| 93 |
-
# vert clair → vert profond
|
| 94 |
-
r = int(167 + (90 - 167) * f)
|
| 95 |
-
g = int(240 + (200 - 240) * f)
|
| 96 |
-
b = int(167 + (90 - 167) * f)
|
| 97 |
-
else:
|
| 98 |
-
f = -f
|
| 99 |
-
r = int(167 + (220 - 167) * f)
|
| 100 |
-
g = int(240 + (50 - 240) * f)
|
| 101 |
-
b = int(167 + (50 - 167) * f)
|
| 102 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 103 |
|
| 104 |
|
| 105 |
def build_error_absorption_html(
|
|
@@ -186,7 +146,7 @@ def build_error_absorption_html(
|
|
| 186 |
intro_rate = entry.get("introduction_rate")
|
| 187 |
if isinstance(corr_rate, (int, float)):
|
| 188 |
corr_rate_str = f"{corr_rate * 100:.1f}%"
|
| 189 |
-
corr_color =
|
| 190 |
corr_cell = (
|
| 191 |
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 192 |
f'background:{corr_color};font-family:monospace;'
|
|
@@ -199,7 +159,7 @@ def build_error_absorption_html(
|
|
| 199 |
)
|
| 200 |
if isinstance(intro_rate, (int, float)):
|
| 201 |
intro_rate_str = f"{intro_rate * 100:.1f}%"
|
| 202 |
-
intro_color =
|
| 203 |
intro_cell = (
|
| 204 |
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 205 |
f'background:{intro_color};font-family:monospace;'
|
|
@@ -210,7 +170,13 @@ def build_error_absorption_html(
|
|
| 210 |
'<td style="padding:.4rem .6rem;text-align:right;'
|
| 211 |
'opacity:.4">—</td>'
|
| 212 |
)
|
| 213 |
-
net_color =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
intro_sample = entry.get("introduced_tokens_sample") or []
|
| 215 |
sample_cell_text = ", ".join(
|
| 216 |
_e(str(t)) for t in intro_sample[:sample_max]
|
|
|
|
| 51 |
from html import escape as _e
|
| 52 |
from typing import Optional
|
| 53 |
|
| 54 |
+
from picarones.report.render_helpers import color_diverging, color_traffic_light
|
| 55 |
|
| 56 |
+
|
| 57 |
+
# Palette « net improvement » : vert clair au centre, vert profond
|
| 58 |
+
# si favorable (net > 0), rouge si défavorable (net < 0). Centrée
|
| 59 |
+
# sur le vert clair car un delta nul est déjà « pas de régression ».
|
| 60 |
+
_NET_NEUTRAL_RGB = (167, 240, 167)
|
| 61 |
+
_NET_POSITIVE_RGB = (90, 200, 90)
|
| 62 |
+
_NET_NEGATIVE_RGB = (220, 50, 50)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
def build_error_absorption_html(
|
|
|
|
| 146 |
intro_rate = entry.get("introduction_rate")
|
| 147 |
if isinstance(corr_rate, (int, float)):
|
| 148 |
corr_rate_str = f"{corr_rate * 100:.1f}%"
|
| 149 |
+
corr_color = color_traffic_light(float(corr_rate))
|
| 150 |
corr_cell = (
|
| 151 |
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 152 |
f'background:{corr_color};font-family:monospace;'
|
|
|
|
| 159 |
)
|
| 160 |
if isinstance(intro_rate, (int, float)):
|
| 161 |
intro_rate_str = f"{intro_rate * 100:.1f}%"
|
| 162 |
+
intro_color = color_traffic_light(float(intro_rate), low_is_good=True)
|
| 163 |
intro_cell = (
|
| 164 |
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 165 |
f'background:{intro_color};font-family:monospace;'
|
|
|
|
| 170 |
'<td style="padding:.4rem .6rem;text-align:right;'
|
| 171 |
'opacity:.4">—</td>'
|
| 172 |
)
|
| 173 |
+
net_color = color_diverging(
|
| 174 |
+
float(net),
|
| 175 |
+
max_abs=float(max_abs_net) if max_abs_net else 1.0,
|
| 176 |
+
neutral_rgb=_NET_NEUTRAL_RGB,
|
| 177 |
+
positive_rgb=_NET_POSITIVE_RGB,
|
| 178 |
+
negative_rgb=_NET_NEGATIVE_RGB,
|
| 179 |
+
)
|
| 180 |
intro_sample = entry.get("introduced_tokens_sample") or []
|
| 181 |
sample_cell_text = ", ".join(
|
| 182 |
_e(str(t)) for t in intro_sample[:sample_max]
|
|
@@ -36,21 +36,7 @@ from __future__ import annotations
|
|
| 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 = {
|
|
@@ -79,7 +65,7 @@ def _render_complexity_block(
|
|
| 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 =
|
| 83 |
return (
|
| 84 |
f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
|
| 85 |
f'{_e(h_complex)}</div>'
|
|
@@ -130,7 +116,7 @@ def _render_homogeneity_block(
|
|
| 130 |
"imgpred_feat_norm", "Contribution normalisée",
|
| 131 |
)
|
| 132 |
score = float(homogeneity.get("score") or 0.0)
|
| 133 |
-
color =
|
| 134 |
parts = [
|
| 135 |
f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
|
| 136 |
f'{_e(h_homo)} : '
|
|
@@ -157,7 +143,7 @@ def _render_homogeneity_block(
|
|
| 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 =
|
| 161 |
parts.append(
|
| 162 |
f'<tr>'
|
| 163 |
f'<td style="padding:.4rem .6rem">{_e(feat_label)}</td>'
|
|
|
|
| 36 |
from html import escape as _e
|
| 37 |
from typing import Optional
|
| 38 |
|
| 39 |
+
from picarones.report.render_helpers import color_traffic_light
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
_FEATURE_LABEL_KEYS = {
|
|
|
|
| 65 |
mx = float(aggregated.get("complexity_max") or 0.0)
|
| 66 |
sd = float(aggregated.get("complexity_stdev") or 0.0)
|
| 67 |
n_docs = int(aggregated.get("n_docs") or 0)
|
| 68 |
+
color_mean = color_traffic_light(mean, low_is_good=True)
|
| 69 |
return (
|
| 70 |
f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
|
| 71 |
f'{_e(h_complex)}</div>'
|
|
|
|
| 116 |
"imgpred_feat_norm", "Contribution normalisée",
|
| 117 |
)
|
| 118 |
score = float(homogeneity.get("score") or 0.0)
|
| 119 |
+
color = color_traffic_light(score, low_is_good=True)
|
| 120 |
parts = [
|
| 121 |
f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
|
| 122 |
f'{_e(h_homo)} : '
|
|
|
|
| 143 |
feat_mean = float(slot.get("mean") or 0.0)
|
| 144 |
feat_stdev = float(slot.get("stdev") or 0.0)
|
| 145 |
feat_norm = float(slot.get("normalised") or 0.0)
|
| 146 |
+
norm_color = color_traffic_light(feat_norm, low_is_good=True)
|
| 147 |
parts.append(
|
| 148 |
f'<tr>'
|
| 149 |
f'<td style="padding:.4rem .6rem">{_e(feat_label)}</td>'
|
|
@@ -41,28 +41,26 @@ from __future__ import annotations
|
|
| 41 |
from html import escape as _e
|
| 42 |
from typing import Optional
|
| 43 |
|
|
|
|
| 44 |
|
| 45 |
-
|
|
|
|
| 46 |
score: float, low: float, high: float, higher_is_better: bool,
|
| 47 |
) -> str:
|
| 48 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
if high == low:
|
| 50 |
-
return
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
r = int(167 + (235 - 167) * t)
|
| 58 |
-
g = int(240 + (180 - 240) * t)
|
| 59 |
-
b = int(167 + (60 - 167) * t)
|
| 60 |
-
else:
|
| 61 |
-
t = (rel - 0.5) / 0.5
|
| 62 |
-
r = int(235 + (220 - 235) * t)
|
| 63 |
-
g = int(180 + (50 - 180) * t)
|
| 64 |
-
b = int(60 + (50 - 60) * t)
|
| 65 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 66 |
|
| 67 |
|
| 68 |
def _format_score(value: Optional[float]) -> str:
|
|
@@ -160,7 +158,7 @@ def build_incremental_comparison_html(
|
|
| 160 |
rank = d.get("mean_rank")
|
| 161 |
n_obs = int(d.get("n_observations") or 0)
|
| 162 |
if isinstance(mean, (int, float)):
|
| 163 |
-
color =
|
| 164 |
float(mean), low, high, higher_is_better,
|
| 165 |
)
|
| 166 |
mean_cell = (
|
|
|
|
| 41 |
from html import escape as _e
|
| 42 |
from typing import Optional
|
| 43 |
|
| 44 |
+
from picarones.report.render_helpers import color_traffic_light
|
| 45 |
|
| 46 |
+
|
| 47 |
+
def _bg_for_relative_score(
|
| 48 |
score: float, low: float, high: float, higher_is_better: bool,
|
| 49 |
) -> str:
|
| 50 |
+
"""Mappe ``score`` sur une plage [low, high] et retourne une cellule
|
| 51 |
+
colorée traffic-light.
|
| 52 |
+
|
| 53 |
+
Si ``higher_is_better=True``, ``score=high`` est vert ; sinon
|
| 54 |
+
``score=low`` est vert.
|
| 55 |
+
"""
|
| 56 |
if high == low:
|
| 57 |
+
return color_traffic_light(1.0) # neutre vert clair
|
| 58 |
+
return color_traffic_light(
|
| 59 |
+
score,
|
| 60 |
+
low_is_good=not higher_is_better,
|
| 61 |
+
scale_min=low,
|
| 62 |
+
scale_max=high,
|
| 63 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
def _format_score(value: Optional[float]) -> str:
|
|
|
|
| 158 |
rank = d.get("mean_rank")
|
| 159 |
n_obs = int(d.get("n_observations") or 0)
|
| 160 |
if isinstance(mean, (int, float)):
|
| 161 |
+
color = _bg_for_relative_score(
|
| 162 |
float(mean), low, high, higher_is_better,
|
| 163 |
)
|
| 164 |
mean_cell = (
|
|
@@ -21,20 +21,10 @@ from __future__ import annotations
|
|
| 21 |
from html import escape as _e
|
| 22 |
from typing import Optional
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
Retourne une couleur CSS hex. ``vmax = 0`` → blanc.
|
| 29 |
-
"""
|
| 30 |
-
if vmax <= 0:
|
| 31 |
-
return "#ffffff"
|
| 32 |
-
ratio = max(0.0, min(1.0, value / vmax))
|
| 33 |
-
# Blanc (255,255,255) vers rouge soutenu (200, 60, 60)
|
| 34 |
-
r = int(255 - (255 - 200) * ratio)
|
| 35 |
-
g = int(255 - (255 - 60) * ratio)
|
| 36 |
-
b = int(255 - (255 - 60) * ratio)
|
| 37 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 38 |
|
| 39 |
|
| 40 |
def build_divergence_matrix_html(
|
|
@@ -126,7 +116,10 @@ def build_divergence_matrix_html(
|
|
| 126 |
f'font-style:italic">{_e(diag_label)}</td>'
|
| 127 |
)
|
| 128 |
else:
|
| 129 |
-
bg =
|
|
|
|
|
|
|
|
|
|
| 130 |
# Texte sombre toujours lisible (pas de seuil fort sur le rouge clair).
|
| 131 |
parts.append(
|
| 132 |
f'<td style="padding:.3rem .5rem;text-align:center;'
|
|
|
|
| 21 |
from html import escape as _e
|
| 22 |
from typing import Optional
|
| 23 |
|
| 24 |
+
from picarones.report.render_helpers import (
|
| 25 |
+
GRADIENT_TARGET_RED,
|
| 26 |
+
color_single_gradient,
|
| 27 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def build_divergence_matrix_html(
|
|
|
|
| 116 |
f'font-style:italic">{_e(diag_label)}</td>'
|
| 117 |
)
|
| 118 |
else:
|
| 119 |
+
bg = (
|
| 120 |
+
color_single_gradient(v, end_rgb=GRADIENT_TARGET_RED, max_value=vmax)
|
| 121 |
+
if vmax > 0 else "#ffffff"
|
| 122 |
+
)
|
| 123 |
# Texte sombre toujours lisible (pas de seuil fort sur le rouge clair).
|
| 124 |
parts.append(
|
| 125 |
f'<td style="padding:.3rem .5rem;text-align:center;'
|
|
@@ -19,15 +19,10 @@ from html import escape as _e
|
|
| 19 |
from typing import Optional
|
| 20 |
|
| 21 |
from picarones.measurements.lexical_modernization import top_modernized_tokens
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
f = max(0.0, min(1.0, rate))
|
| 27 |
-
r = int(255 + (194 - 255) * f)
|
| 28 |
-
g = int(255 + (65 - 255) * f)
|
| 29 |
-
b = int(255 + (12 - 255) * f)
|
| 30 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 31 |
|
| 32 |
|
| 33 |
def _format_variants(variants: dict, max_show: int = 3) -> str:
|
|
@@ -96,7 +91,7 @@ def build_lexical_modernization_html(
|
|
| 96 |
rate = slot.get("rate_modernized", 0.0)
|
| 97 |
n_total = slot.get("n_total", 0)
|
| 98 |
variants_str = _format_variants(slot.get("variants") or {})
|
| 99 |
-
rate_color =
|
| 100 |
parts.append(
|
| 101 |
f'<tr>'
|
| 102 |
f'<td style="padding:.3rem .5rem;font-family:monospace">'
|
|
|
|
| 19 |
from typing import Optional
|
| 20 |
|
| 21 |
from picarones.measurements.lexical_modernization import top_modernized_tokens
|
| 22 |
+
from picarones.report.render_helpers import (
|
| 23 |
+
GRADIENT_TARGET_ORANGE,
|
| 24 |
+
color_single_gradient,
|
| 25 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
def _format_variants(variants: dict, max_show: int = 3) -> str:
|
|
|
|
| 91 |
rate = slot.get("rate_modernized", 0.0)
|
| 92 |
n_total = slot.get("n_total", 0)
|
| 93 |
variants_str = _format_variants(slot.get("variants") or {})
|
| 94 |
+
rate_color = color_single_gradient(rate, end_rgb=GRADIENT_TARGET_ORANGE)
|
| 95 |
parts.append(
|
| 96 |
f'<tr>'
|
| 97 |
f'<td style="padding:.3rem .5rem;font-family:monospace">'
|
|
@@ -33,32 +33,23 @@ from __future__ import annotations
|
|
| 33 |
from html import escape as _e
|
| 34 |
from typing import Optional
|
| 35 |
|
|
|
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
| 40 |
if abs(delta_pct) < 1.0:
|
| 41 |
return "#a7f0a7"
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
b = int(167 + (60 - 167) * t)
|
| 50 |
-
else:
|
| 51 |
-
t = (f - 0.5) / 0.5
|
| 52 |
-
r = int(235 + (220 - 235) * t)
|
| 53 |
-
g = int(180 + (50 - 180) * t)
|
| 54 |
-
b = int(60 + (50 - 60) * t)
|
| 55 |
-
else:
|
| 56 |
-
# vert → bleu (amélioration)
|
| 57 |
-
f = -f
|
| 58 |
-
r = int(167 + (90 - 167) * f)
|
| 59 |
-
g = int(240 + (160 - 240) * f)
|
| 60 |
-
b = int(167 + (210 - 167) * f)
|
| 61 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 62 |
|
| 63 |
|
| 64 |
def build_longitudinal_html(
|
|
@@ -126,7 +117,7 @@ def build_longitudinal_html(
|
|
| 126 |
first_cer = float(entry.get("first_cer") or 0.0)
|
| 127 |
last_cer = float(entry.get("last_cer") or 0.0)
|
| 128 |
delta_pct = float(entry.get("absolute_delta_pct") or 0.0)
|
| 129 |
-
delta_color =
|
| 130 |
trend = entry.get("trend") or {}
|
| 131 |
slope = trend.get("slope")
|
| 132 |
r2 = trend.get("r_squared")
|
|
|
|
| 33 |
from html import escape as _e
|
| 34 |
from typing import Optional
|
| 35 |
|
| 36 |
+
from picarones.report.render_helpers import color_diverging
|
| 37 |
|
| 38 |
+
|
| 39 |
+
def _bg_for_cer_delta(delta_pct: float) -> str:
|
| 40 |
+
"""Cellule colorée pour un delta de CER en points de pourcentage :
|
| 41 |
+
vert si delta ≈ 0, orange/rouge en régression, bleu en amélioration.
|
| 42 |
+
Saturation à ±5 points.
|
| 43 |
+
"""
|
| 44 |
if abs(delta_pct) < 1.0:
|
| 45 |
return "#a7f0a7"
|
| 46 |
+
return color_diverging(
|
| 47 |
+
delta_pct,
|
| 48 |
+
max_abs=5.0,
|
| 49 |
+
neutral_rgb=(167, 240, 167),
|
| 50 |
+
positive_rgb=(220, 50, 50),
|
| 51 |
+
negative_rgb=(90, 160, 210),
|
| 52 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def build_longitudinal_html(
|
|
|
|
| 117 |
first_cer = float(entry.get("first_cer") or 0.0)
|
| 118 |
last_cer = float(entry.get("last_cer") or 0.0)
|
| 119 |
delta_pct = float(entry.get("absolute_delta_pct") or 0.0)
|
| 120 |
+
delta_color = _bg_for_cer_delta(delta_pct)
|
| 121 |
trend = entry.get("trend") or {}
|
| 122 |
slope = trend.get("slope")
|
| 123 |
r2 = trend.get("r_squared")
|
|
@@ -43,21 +43,7 @@ from __future__ import annotations
|
|
| 43 |
from html import escape as _e
|
| 44 |
from typing import Optional
|
| 45 |
|
| 46 |
-
|
| 47 |
-
def _color_for_cv(cv: float) -> str:
|
| 48 |
-
"""Vert (≈0) → orange (10 %) → rouge (≥ 25 %)."""
|
| 49 |
-
f = max(0.0, min(1.0, cv / 0.25))
|
| 50 |
-
if f < 0.5:
|
| 51 |
-
t = f / 0.5
|
| 52 |
-
r = int(167 + (235 - 167) * t)
|
| 53 |
-
g = int(240 + (180 - 240) * t)
|
| 54 |
-
b = int(167 + (60 - 167) * t)
|
| 55 |
-
else:
|
| 56 |
-
t = (f - 0.5) / 0.5
|
| 57 |
-
r = int(235 + (220 - 235) * t)
|
| 58 |
-
g = int(180 + (50 - 180) * t)
|
| 59 |
-
b = int(60 + (50 - 60) * t)
|
| 60 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 61 |
|
| 62 |
|
| 63 |
def build_multirun_stability_html(
|
|
@@ -128,7 +114,7 @@ def build_multirun_stability_html(
|
|
| 128 |
else:
|
| 129 |
cer_str = "—"
|
| 130 |
if isinstance(cer_cv, (int, float)):
|
| 131 |
-
cv_color =
|
| 132 |
cv_cell = (
|
| 133 |
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 134 |
f'background:{cv_color};font-family:monospace;'
|
|
|
|
| 43 |
from html import escape as _e
|
| 44 |
from typing import Optional
|
| 45 |
|
| 46 |
+
from picarones.report.render_helpers import color_traffic_light
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def build_multirun_stability_html(
|
|
|
|
| 114 |
else:
|
| 115 |
cer_str = "—"
|
| 116 |
if isinstance(cer_cv, (int, float)):
|
| 117 |
+
cv_color = color_traffic_light(float(cer_cv), low_is_good=True, scale_max=0.25)
|
| 118 |
cv_cell = (
|
| 119 |
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 120 |
f'background:{cv_color};font-family:monospace;'
|
|
@@ -23,26 +23,7 @@ from __future__ import annotations
|
|
| 23 |
from html import escape as _e
|
| 24 |
from typing import Optional
|
| 25 |
|
| 26 |
-
|
| 27 |
-
def _color_for_f1(f1: float) -> str:
|
| 28 |
-
"""Gradient rouge → jaune → vert proportionnel à ``f1`` ∈ [0, 1].
|
| 29 |
-
|
| 30 |
-
F1 = 0 → rouge clair, F1 = 0,5 → jaune pâle, F1 = 1 → vert clair.
|
| 31 |
-
"""
|
| 32 |
-
f = max(0.0, min(1.0, f1))
|
| 33 |
-
# Interpolation linéaire 2-segments :
|
| 34 |
-
# 0 → (220, 100, 100) (rouge), 0.5 → (240, 220, 130), 1 → (130, 200, 130) (vert)
|
| 35 |
-
if f <= 0.5:
|
| 36 |
-
ratio = f / 0.5
|
| 37 |
-
r = int(220 + (240 - 220) * ratio)
|
| 38 |
-
g = int(100 + (220 - 100) * ratio)
|
| 39 |
-
b = int(100 + (130 - 100) * ratio)
|
| 40 |
-
else:
|
| 41 |
-
ratio = (f - 0.5) / 0.5
|
| 42 |
-
r = int(240 + (130 - 240) * ratio)
|
| 43 |
-
g = int(220 + (200 - 220) * ratio)
|
| 44 |
-
b = int(130 + (130 - 130) * ratio)
|
| 45 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 46 |
|
| 47 |
|
| 48 |
def _engines_with_ner(engines_summary: list[dict]) -> list[dict]:
|
|
@@ -110,7 +91,7 @@ def build_ner_summary_html(
|
|
| 110 |
doc_count = int(agg.get("doc_count") or 0)
|
| 111 |
hallucinated = int(agg.get("hallucinated_total") or 0)
|
| 112 |
missed = int(agg.get("missed_total") or 0)
|
| 113 |
-
bg =
|
| 114 |
parts.append("<tr>")
|
| 115 |
parts.append(
|
| 116 |
f'<td style="padding:.3rem .5rem;font-weight:600">'
|
|
@@ -222,7 +203,7 @@ def build_ner_per_category_html(
|
|
| 222 |
else:
|
| 223 |
f1 = float(stats.get("f1") or 0.0)
|
| 224 |
support = int(stats.get("support", 0))
|
| 225 |
-
bg =
|
| 226 |
parts.append(
|
| 227 |
f'<td style="padding:.3rem .5rem;text-align:center;'
|
| 228 |
f'background:{bg};color:#222;'
|
|
|
|
| 23 |
from html import escape as _e
|
| 24 |
from typing import Optional
|
| 25 |
|
| 26 |
+
from picarones.report.render_helpers import color_traffic_light
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def _engines_with_ner(engines_summary: list[dict]) -> list[dict]:
|
|
|
|
| 91 |
doc_count = int(agg.get("doc_count") or 0)
|
| 92 |
hallucinated = int(agg.get("hallucinated_total") or 0)
|
| 93 |
missed = int(agg.get("missed_total") or 0)
|
| 94 |
+
bg = color_traffic_light(f1)
|
| 95 |
parts.append("<tr>")
|
| 96 |
parts.append(
|
| 97 |
f'<td style="padding:.3rem .5rem;font-weight:600">'
|
|
|
|
| 203 |
else:
|
| 204 |
f1 = float(stats.get("f1") or 0.0)
|
| 205 |
support = int(stats.get("support", 0))
|
| 206 |
+
bg = color_traffic_light(f1)
|
| 207 |
parts.append(
|
| 208 |
f'<td style="padding:.3rem .5rem;text-align:center;'
|
| 209 |
f'background:{bg};color:#222;'
|
|
@@ -24,22 +24,7 @@ from html import escape as _e
|
|
| 24 |
from typing import Optional
|
| 25 |
|
| 26 |
from picarones.measurements.numerical_sequences import CATEGORIES
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def _color_for_score(score: float) -> str:
|
| 30 |
-
"""Gradient rouge → jaune → vert."""
|
| 31 |
-
f = max(0.0, min(1.0, score))
|
| 32 |
-
if f < 0.5:
|
| 33 |
-
t = f / 0.5
|
| 34 |
-
r = 235
|
| 35 |
-
g = int(70 + (200 - 70) * t)
|
| 36 |
-
b = 70
|
| 37 |
-
else:
|
| 38 |
-
t = (f - 0.5) / 0.5
|
| 39 |
-
r = int(235 + (60 - 235) * t)
|
| 40 |
-
g = int(200 + (160 - 200) * t)
|
| 41 |
-
b = int(70 + (90 - 70) * t)
|
| 42 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 43 |
|
| 44 |
|
| 45 |
def _category_columns_with_signal(rows: list[dict]) -> list[str]:
|
|
@@ -125,7 +110,7 @@ def build_numerical_sequences_html(
|
|
| 125 |
global_strict = float(agg.get("global_strict_score") or 0.0)
|
| 126 |
global_value = float(agg.get("global_value_score") or 0.0)
|
| 127 |
n_total = int(agg.get("n_total") or 0)
|
| 128 |
-
global_color =
|
| 129 |
parts.append(
|
| 130 |
f'<tr>'
|
| 131 |
f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
|
|
@@ -148,7 +133,7 @@ def build_numerical_sequences_html(
|
|
| 148 |
continue
|
| 149 |
strict = float(cat_data.get("strict_score") or 0.0)
|
| 150 |
value = float(cat_data.get("value_score") or 0.0)
|
| 151 |
-
color =
|
| 152 |
parts.append(
|
| 153 |
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 154 |
f'background:{color};font-family:monospace">'
|
|
|
|
| 24 |
from typing import Optional
|
| 25 |
|
| 26 |
from picarones.measurements.numerical_sequences import CATEGORIES
|
| 27 |
+
from picarones.report.render_helpers import color_traffic_light
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def _category_columns_with_signal(rows: list[dict]) -> list[str]:
|
|
|
|
| 110 |
global_strict = float(agg.get("global_strict_score") or 0.0)
|
| 111 |
global_value = float(agg.get("global_value_score") or 0.0)
|
| 112 |
n_total = int(agg.get("n_total") or 0)
|
| 113 |
+
global_color = color_traffic_light(global_strict)
|
| 114 |
parts.append(
|
| 115 |
f'<tr>'
|
| 116 |
f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
|
|
|
|
| 133 |
continue
|
| 134 |
strict = float(cat_data.get("strict_score") or 0.0)
|
| 135 |
value = float(cat_data.get("value_score") or 0.0)
|
| 136 |
+
color = color_traffic_light(strict)
|
| 137 |
parts.append(
|
| 138 |
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 139 |
f'background:{color};font-family:monospace">'
|
|
@@ -36,34 +36,14 @@ from __future__ import annotations
|
|
| 36 |
from html import escape as _e
|
| 37 |
from typing import Optional
|
| 38 |
|
|
|
|
|
|
|
| 39 |
|
| 40 |
# ──────────────────────────────────────────────────────────────────────────
|
| 41 |
# Helpers de coloration
|
| 42 |
# ──────────────────────────────────────────────────────────────────────────
|
| 43 |
|
| 44 |
|
| 45 |
-
def _color_for_score(score: float) -> str:
|
| 46 |
-
"""Gradient rouge → jaune → vert proportionnel à ``score`` ∈ [0, 1].
|
| 47 |
-
|
| 48 |
-
Identique à ``ner_render._color_for_f1``. Les scores
|
| 49 |
-
philologiques (preservation, coverage, accuracy) suivent la même
|
| 50 |
-
sémantique « plus c'est haut, mieux c'est » donc le gradient
|
| 51 |
-
est valide.
|
| 52 |
-
"""
|
| 53 |
-
f = max(0.0, min(1.0, score))
|
| 54 |
-
if f <= 0.5:
|
| 55 |
-
ratio = f / 0.5
|
| 56 |
-
r = int(220 + (240 - 220) * ratio)
|
| 57 |
-
g = int(100 + (220 - 100) * ratio)
|
| 58 |
-
b = int(100 + (130 - 100) * ratio)
|
| 59 |
-
else:
|
| 60 |
-
ratio = (f - 0.5) / 0.5
|
| 61 |
-
r = int(240 + (130 - 240) * ratio)
|
| 62 |
-
g = int(220 + (200 - 220) * ratio)
|
| 63 |
-
b = int(130 + (130 - 130) * ratio)
|
| 64 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 65 |
-
|
| 66 |
-
|
| 67 |
def _engines_with_module(
|
| 68 |
engines_summary: list[dict], module: str,
|
| 69 |
) -> list[dict]:
|
|
@@ -83,7 +63,7 @@ def _score_cell(score: Optional[float], extra: str = "") -> str:
|
|
| 83 |
'<td style="padding:.3rem .5rem;text-align:center;'
|
| 84 |
'background:#f0f0f0;color:#999">—</td>'
|
| 85 |
)
|
| 86 |
-
color =
|
| 87 |
text = f"{score * 100:.1f}%"
|
| 88 |
if extra:
|
| 89 |
text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
|
|
@@ -539,8 +519,8 @@ def build_roman_numerals_section(
|
|
| 539 |
# la sémantique « plus c'est haut, plus l'OCR a
|
| 540 |
# adopté ce statut ».
|
| 541 |
color = (
|
| 542 |
-
|
| 543 |
-
else
|
| 544 |
)
|
| 545 |
parts.append(
|
| 546 |
f'<td style="padding:.3rem .5rem;text-align:center;'
|
|
|
|
| 36 |
from html import escape as _e
|
| 37 |
from typing import Optional
|
| 38 |
|
| 39 |
+
from picarones.report.render_helpers import color_traffic_light
|
| 40 |
+
|
| 41 |
|
| 42 |
# ──────────────────────────────────────────────────────────────────────────
|
| 43 |
# Helpers de coloration
|
| 44 |
# ──────────────────────────────────────────────────────────────────────────
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def _engines_with_module(
|
| 48 |
engines_summary: list[dict], module: str,
|
| 49 |
) -> list[dict]:
|
|
|
|
| 63 |
'<td style="padding:.3rem .5rem;text-align:center;'
|
| 64 |
'background:#f0f0f0;color:#999">—</td>'
|
| 65 |
)
|
| 66 |
+
color = color_traffic_light(score)
|
| 67 |
text = f"{score * 100:.1f}%"
|
| 68 |
if extra:
|
| 69 |
text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
|
|
|
|
| 519 |
# la sémantique « plus c'est haut, plus l'OCR a
|
| 520 |
# adopté ce statut ».
|
| 521 |
color = (
|
| 522 |
+
color_traffic_light(1.0 - ratio) if status == "lost"
|
| 523 |
+
else color_traffic_light(ratio)
|
| 524 |
)
|
| 525 |
parts.append(
|
| 526 |
f'<td style="padding:.3rem .5rem;text-align:center;'
|
|
@@ -50,6 +50,7 @@ from typing import Optional
|
|
| 50 |
from picarones.core.modules import ArtifactType
|
| 51 |
from picarones.measurements.pipeline_benchmark import PipelineBenchmarkResult
|
| 52 |
from picarones.measurements.pipeline_comparison import PipelineComparisonResult
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
# ──────────────────────────────────────────────────────────────────────────
|
|
@@ -57,22 +58,6 @@ from picarones.measurements.pipeline_comparison import PipelineComparisonResult
|
|
| 57 |
# ──────────────────────────────────────────────────────────────────────────
|
| 58 |
|
| 59 |
|
| 60 |
-
def _color_for_success_rate(rate: float) -> str:
|
| 61 |
-
"""Gradient rouge → jaune → vert pour le taux de succès."""
|
| 62 |
-
f = max(0.0, min(1.0, rate))
|
| 63 |
-
if f <= 0.5:
|
| 64 |
-
ratio = f / 0.5
|
| 65 |
-
r = int(220 + (240 - 220) * ratio)
|
| 66 |
-
g = int(100 + (220 - 100) * ratio)
|
| 67 |
-
b = int(100 + (130 - 100) * ratio)
|
| 68 |
-
else:
|
| 69 |
-
ratio = (f - 0.5) / 0.5
|
| 70 |
-
r = int(240 + (130 - 240) * ratio)
|
| 71 |
-
g = int(220 + (200 - 220) * ratio)
|
| 72 |
-
b = int(130 + (130 - 130) * ratio)
|
| 73 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 74 |
-
|
| 75 |
-
|
| 76 |
def _format_duration(seconds: float) -> str:
|
| 77 |
"""Formate une durée en ms si < 1s, en s sinon."""
|
| 78 |
if seconds < 1.0:
|
|
@@ -109,7 +94,7 @@ def build_pipeline_summary_html(
|
|
| 109 |
failed = bench.n_pipelines_failed
|
| 110 |
total = bench.n_docs
|
| 111 |
rate = success / total if total > 0 else 0.0
|
| 112 |
-
color =
|
| 113 |
|
| 114 |
parts = [
|
| 115 |
'<div class="pipeline-summary" '
|
|
@@ -195,7 +180,7 @@ def build_pipeline_steps_table_html(
|
|
| 195 |
|
| 196 |
for agg in bench.per_step_aggregates:
|
| 197 |
rate = agg.success_rate
|
| 198 |
-
rate_color =
|
| 199 |
# Métriques aux jonctions : pour chaque type d'artefact,
|
| 200 |
# liste des métriques mean
|
| 201 |
metrics_cells: list[str] = []
|
|
@@ -381,12 +366,17 @@ class RankingSpec:
|
|
| 381 |
return f"{self.artifact_type.value}.{self.metric_name}"
|
| 382 |
|
| 383 |
|
| 384 |
-
def
|
| 385 |
-
"""Gradient vert (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
if total <= 1:
|
| 387 |
-
return
|
| 388 |
-
|
| 389 |
-
|
|
|
|
| 390 |
|
| 391 |
|
| 392 |
def build_pipeline_ranking_table_html(
|
|
@@ -444,7 +434,7 @@ def build_pipeline_ranking_table_html(
|
|
| 444 |
rank += 1
|
| 445 |
rank_str = str(rank)
|
| 446 |
value_str = f"{value:.4f}"
|
| 447 |
-
rank_color =
|
| 448 |
parts.append(
|
| 449 |
f'<tr>'
|
| 450 |
f'<td style="padding:.3rem .5rem;text-align:center;'
|
|
|
|
| 50 |
from picarones.core.modules import ArtifactType
|
| 51 |
from picarones.measurements.pipeline_benchmark import PipelineBenchmarkResult
|
| 52 |
from picarones.measurements.pipeline_comparison import PipelineComparisonResult
|
| 53 |
+
from picarones.report.render_helpers import color_traffic_light
|
| 54 |
|
| 55 |
|
| 56 |
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
| 58 |
# ──────────────────────────────────────────────────────────────────────────
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
def _format_duration(seconds: float) -> str:
|
| 62 |
"""Formate une durée en ms si < 1s, en s sinon."""
|
| 63 |
if seconds < 1.0:
|
|
|
|
| 94 |
failed = bench.n_pipelines_failed
|
| 95 |
total = bench.n_docs
|
| 96 |
rate = success / total if total > 0 else 0.0
|
| 97 |
+
color = color_traffic_light(rate)
|
| 98 |
|
| 99 |
parts = [
|
| 100 |
'<div class="pipeline-summary" '
|
|
|
|
| 180 |
|
| 181 |
for agg in bench.per_step_aggregates:
|
| 182 |
rate = agg.success_rate
|
| 183 |
+
rate_color = color_traffic_light(rate)
|
| 184 |
# Métriques aux jonctions : pour chaque type d'artefact,
|
| 185 |
# liste des métriques mean
|
| 186 |
metrics_cells: list[str] = []
|
|
|
|
| 366 |
return f"{self.artifact_type.value}.{self.metric_name}"
|
| 367 |
|
| 368 |
|
| 369 |
+
def _bg_for_rank(rank: int, total: int) -> str:
|
| 370 |
+
"""Gradient vert (rang 1) → rouge (dernier rang).
|
| 371 |
+
|
| 372 |
+
Mapping : ``rank ∈ [1, total]`` → ``color_traffic_light`` avec
|
| 373 |
+
``low_is_good=True`` (rang bas = bon).
|
| 374 |
+
"""
|
| 375 |
if total <= 1:
|
| 376 |
+
return color_traffic_light(1.0)
|
| 377 |
+
return color_traffic_light(
|
| 378 |
+
float(rank), low_is_good=True, scale_min=1.0, scale_max=float(total),
|
| 379 |
+
)
|
| 380 |
|
| 381 |
|
| 382 |
def build_pipeline_ranking_table_html(
|
|
|
|
| 434 |
rank += 1
|
| 435 |
rank_str = str(rank)
|
| 436 |
value_str = f"{value:.4f}"
|
| 437 |
+
rank_color = _bg_for_rank(rank, n_with_value)
|
| 438 |
parts.append(
|
| 439 |
f'<tr>'
|
| 440 |
f'<td style="padding:.3rem .5rem;text-align:center;'
|
|
@@ -25,27 +25,22 @@ from __future__ import annotations
|
|
| 25 |
from html import escape as _e
|
| 26 |
from typing import Optional
|
| 27 |
|
|
|
|
| 28 |
|
| 29 |
-
def _color_for_delta(delta: float) -> str:
|
| 30 |
-
"""Vert au centre, orange si over-norm, bleu si under-norm.
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
|
| 33 |
"""
|
| 34 |
if abs(delta) <= 1.0:
|
| 35 |
-
return "#a7f0a7" # vert clair
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
f = -f
|
| 44 |
-
# vert → bleu profond
|
| 45 |
-
r = int(167 + (90 - 167) * f)
|
| 46 |
-
g = int(240 + (160 - 240) * f)
|
| 47 |
-
b = int(167 + (210 - 167) * f)
|
| 48 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 49 |
|
| 50 |
|
| 51 |
def build_readability_summary_html(
|
|
@@ -107,7 +102,7 @@ def build_readability_summary_html(
|
|
| 107 |
over_rate = float(agg.get("over_normalized_rate") or 0.0)
|
| 108 |
n_under = int(agg.get("n_under_normalized") or 0)
|
| 109 |
n_docs = int(agg.get("n_docs") or 0)
|
| 110 |
-
color =
|
| 111 |
parts.append(
|
| 112 |
f'<tr>'
|
| 113 |
f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
|
|
|
|
| 25 |
from html import escape as _e
|
| 26 |
from typing import Optional
|
| 27 |
|
| 28 |
+
from picarones.report.render_helpers import color_diverging
|
| 29 |
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
def _bg_for_flesch_delta(delta: float) -> str:
|
| 32 |
+
"""Vert au centre (delta ≈ 0), orange en sur-normalisation (delta > 0),
|
| 33 |
+
bleu en sous-normalisation (delta < 0). Saturation à ±15 pts Flesch.
|
| 34 |
"""
|
| 35 |
if abs(delta) <= 1.0:
|
| 36 |
+
return "#a7f0a7" # neutre vert clair, indistinguable du bruit
|
| 37 |
+
return color_diverging(
|
| 38 |
+
delta,
|
| 39 |
+
max_abs=15.0,
|
| 40 |
+
neutral_rgb=(167, 240, 167),
|
| 41 |
+
positive_rgb=(220, 140, 60),
|
| 42 |
+
negative_rgb=(90, 160, 210),
|
| 43 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def build_readability_summary_html(
|
|
|
|
| 102 |
over_rate = float(agg.get("over_normalized_rate") or 0.0)
|
| 103 |
n_under = int(agg.get("n_under_normalized") or 0)
|
| 104 |
n_docs = int(agg.get("n_docs") or 0)
|
| 105 |
+
color = _bg_for_flesch_delta(delta_mean)
|
| 106 |
parts.append(
|
| 107 |
f'<tr>'
|
| 108 |
f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
|
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers de rendu mutualisés.
|
| 2 |
+
|
| 3 |
+
Centralise les fonctions de coloration et le builder de grille SVG qui
|
| 4 |
+
étaient auparavant dupliqués dans chaque ``*_render.py``. Avant cette
|
| 5 |
+
consolidation, le projet comptait 25 versions différentes de
|
| 6 |
+
``_color_for_*`` (toutes des dégradés rouge/jaune/vert ou blanc/couleur
|
| 7 |
+
légèrement différentes) et 2 versions de ``_build_heatmap_svg``
|
| 8 |
+
(matrice de classes × positions). Le test
|
| 9 |
+
``tests/architecture/test_render_helpers.py`` mesure cette duplication
|
| 10 |
+
et bloque sa réapparition.
|
| 11 |
+
|
| 12 |
+
API
|
| 13 |
+
---
|
| 14 |
+
- :func:`color_traffic_light` — gradient rouge → jaune → vert. Couvre
|
| 15 |
+
la majorité des cellules du rapport (CER, F1, recall, ECE, deficit,
|
| 16 |
+
drag, CV, etc.). Argument ``low_is_good`` pour inverser la sémantique.
|
| 17 |
+
- :func:`color_single_gradient` — gradient blanc → couleur intense.
|
| 18 |
+
Utilisé pour les heatmaps Jaccard, densité, lexical modernization.
|
| 19 |
+
- :func:`color_diverging` — gradient signé (négatif → neutre → positif).
|
| 20 |
+
Utilisé pour les deltas Flesch, amélioration nette, sur/sous-norm.
|
| 21 |
+
- :func:`text_color_for_bg` — noir ou blanc selon la luminosité du fond.
|
| 22 |
+
- :func:`build_grid_svg` — builder de heatmap SVG paramétré.
|
| 23 |
+
|
| 24 |
+
Palette
|
| 25 |
+
-------
|
| 26 |
+
Les bornes RGB des dégradés traffic-light sont la moyenne des palettes
|
| 27 |
+
ad hoc qui peuplaient les 25 helpers d'origine. Cohérence visuelle
|
| 28 |
+
unifiée tout en restant proche du rendu antérieur (≤ 10 unités RGB
|
| 29 |
+
d'écart sur la majorité des bornes), pour ne pas casser les tests
|
| 30 |
+
d'intégration HTML existants.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
from html import escape as _e
|
| 36 |
+
from typing import Callable, Optional
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ──────────────────────────────────────────────────────────────────
|
| 40 |
+
# Palettes — bornes RGB partagées par tous les dégradés.
|
| 41 |
+
#
|
| 42 |
+
# Choix éditorial : on conserve l'esprit « rouge → jaune → vert » des
|
| 43 |
+
# helpers historiques plutôt que la palette daltonien-friendly
|
| 44 |
+
# Okabe-Ito de ``colors.py`` (utilisée pour les badges principaux).
|
| 45 |
+
# Migrer les cellules de tableau vers Okabe-Ito serait un sprint
|
| 46 |
+
# d'accessibilité dédié, hors scope de la consolidation.
|
| 47 |
+
# ──────────────────────────────────────────────────────────────────
|
| 48 |
+
GRADIENT_RED_RGB: tuple[int, int, int] = (220, 100, 100)
|
| 49 |
+
GRADIENT_YELLOW_RGB: tuple[int, int, int] = (240, 220, 130)
|
| 50 |
+
GRADIENT_GREEN_RGB: tuple[int, int, int] = (130, 200, 130)
|
| 51 |
+
|
| 52 |
+
#: Couleurs cibles pour les single-gradients fréquents.
|
| 53 |
+
GRADIENT_TARGET_BLUE: tuple[int, int, int] = (30, 58, 138) # Jaccard, specialization
|
| 54 |
+
GRADIENT_TARGET_ORANGE: tuple[int, int, int] = (194, 65, 12) # densité, lexical mod.
|
| 55 |
+
GRADIENT_TARGET_RED: tuple[int, int, int] = (200, 60, 60) # divergence inter-engine
|
| 56 |
+
|
| 57 |
+
#: Couleurs cibles pour les diverging gradients.
|
| 58 |
+
DIVERGING_NEGATIVE_RGB: tuple[int, int, int] = (95, 145, 215) # bleu (under-norm)
|
| 59 |
+
DIVERGING_NEUTRAL_RGB: tuple[int, int, int] = (130, 200, 130) # vert (centre, OK)
|
| 60 |
+
DIVERGING_POSITIVE_RGB: tuple[int, int, int] = (220, 130, 60) # orange (over-norm)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ──────────────────────────────────────────────────────────────────
|
| 64 |
+
# Helpers internes
|
| 65 |
+
# ──────────────────────────────────────────────────────────────────
|
| 66 |
+
def _interp(a: int, b: int, t: float) -> int:
|
| 67 |
+
"""Interpolation linéaire bornée à un canal RGB ∈ [0, 255]."""
|
| 68 |
+
return max(0, min(255, int(a + (b - a) * t)))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _rgb_to_hex(r: int, g: int, b: int) -> str:
|
| 72 |
+
return f"#{r:02x}{g:02x}{b:02x}"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ──────────────────────────────────────────────────────────────────
|
| 76 |
+
# API publique : couleurs
|
| 77 |
+
# ──────────────────────────────────────────────────────────────────
|
| 78 |
+
def color_traffic_light(
|
| 79 |
+
value: float,
|
| 80 |
+
*,
|
| 81 |
+
low_is_good: bool = False,
|
| 82 |
+
scale_max: float = 1.0,
|
| 83 |
+
scale_min: float = 0.0,
|
| 84 |
+
) -> str:
|
| 85 |
+
"""Gradient rouge → jaune → vert proportionnel à ``value``.
|
| 86 |
+
|
| 87 |
+
Paramètres
|
| 88 |
+
----------
|
| 89 |
+
value : float
|
| 90 |
+
Valeur à colorer.
|
| 91 |
+
low_is_good : bool, default ``False``
|
| 92 |
+
Si ``True``, ``value = scale_min`` → vert et ``value = scale_max``
|
| 93 |
+
→ rouge (sémantique « plus c'est bas, mieux c'est » : ECE,
|
| 94 |
+
deficit, drag, CV, taux d'introduction d'erreurs…).
|
| 95 |
+
Si ``False`` (défaut), c'est l'inverse (sémantique « plus c'est
|
| 96 |
+
haut, mieux c'est » : F1, recall, taux de correction…).
|
| 97 |
+
scale_max : float, default ``1.0``
|
| 98 |
+
Borne haute de l'échelle. Au-delà, la couleur sature.
|
| 99 |
+
scale_min : float, default ``0.0``
|
| 100 |
+
Borne basse de l'échelle.
|
| 101 |
+
|
| 102 |
+
Retour
|
| 103 |
+
------
|
| 104 |
+
str
|
| 105 |
+
Couleur hex au format ``#rrggbb``.
|
| 106 |
+
"""
|
| 107 |
+
span = scale_max - scale_min
|
| 108 |
+
if span <= 0:
|
| 109 |
+
f = 0.5
|
| 110 |
+
else:
|
| 111 |
+
f = (value - scale_min) / span
|
| 112 |
+
f = max(0.0, min(1.0, f))
|
| 113 |
+
if low_is_good:
|
| 114 |
+
f = 1.0 - f
|
| 115 |
+
if f <= 0.5:
|
| 116 |
+
t = f / 0.5
|
| 117 |
+
r = _interp(GRADIENT_RED_RGB[0], GRADIENT_YELLOW_RGB[0], t)
|
| 118 |
+
g = _interp(GRADIENT_RED_RGB[1], GRADIENT_YELLOW_RGB[1], t)
|
| 119 |
+
b = _interp(GRADIENT_RED_RGB[2], GRADIENT_YELLOW_RGB[2], t)
|
| 120 |
+
else:
|
| 121 |
+
t = (f - 0.5) / 0.5
|
| 122 |
+
r = _interp(GRADIENT_YELLOW_RGB[0], GRADIENT_GREEN_RGB[0], t)
|
| 123 |
+
g = _interp(GRADIENT_YELLOW_RGB[1], GRADIENT_GREEN_RGB[1], t)
|
| 124 |
+
b = _interp(GRADIENT_YELLOW_RGB[2], GRADIENT_GREEN_RGB[2], t)
|
| 125 |
+
return _rgb_to_hex(r, g, b)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def color_single_gradient(
|
| 129 |
+
value: float,
|
| 130 |
+
*,
|
| 131 |
+
end_rgb: tuple[int, int, int],
|
| 132 |
+
max_value: float = 1.0,
|
| 133 |
+
start_rgb: tuple[int, int, int] = (255, 255, 255),
|
| 134 |
+
) -> str:
|
| 135 |
+
"""Gradient simple ``start_rgb`` → ``end_rgb`` proportionnel à ``value/max_value``.
|
| 136 |
+
|
| 137 |
+
Utilisé pour les heatmaps qui n'ont pas de sémantique « bon/mauvais »
|
| 138 |
+
mais juste une intensité (Jaccard, densité d'occurrence, taux de
|
| 139 |
+
modernisation lexicale).
|
| 140 |
+
"""
|
| 141 |
+
if max_value <= 0:
|
| 142 |
+
f = 0.0
|
| 143 |
+
else:
|
| 144 |
+
f = max(0.0, min(1.0, value / max_value))
|
| 145 |
+
r = _interp(start_rgb[0], end_rgb[0], f)
|
| 146 |
+
g = _interp(start_rgb[1], end_rgb[1], f)
|
| 147 |
+
b = _interp(start_rgb[2], end_rgb[2], f)
|
| 148 |
+
return _rgb_to_hex(r, g, b)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def color_diverging(
|
| 152 |
+
value: float,
|
| 153 |
+
*,
|
| 154 |
+
max_abs: float = 1.0,
|
| 155 |
+
negative_rgb: tuple[int, int, int] = DIVERGING_NEGATIVE_RGB,
|
| 156 |
+
neutral_rgb: tuple[int, int, int] = DIVERGING_NEUTRAL_RGB,
|
| 157 |
+
positive_rgb: tuple[int, int, int] = DIVERGING_POSITIVE_RGB,
|
| 158 |
+
) -> str:
|
| 159 |
+
"""Gradient signé : ``value < 0`` → ``negative_rgb`` (par défaut bleu),
|
| 160 |
+
``value ≈ 0`` → ``neutral_rgb`` (par défaut vert),
|
| 161 |
+
``value > 0`` → ``positive_rgb`` (par défaut orange).
|
| 162 |
+
|
| 163 |
+
Saturation à ``|value| = max_abs``.
|
| 164 |
+
"""
|
| 165 |
+
if max_abs <= 0:
|
| 166 |
+
return _rgb_to_hex(*neutral_rgb)
|
| 167 |
+
f = max(-1.0, min(1.0, value / max_abs))
|
| 168 |
+
if f >= 0:
|
| 169 |
+
r = _interp(neutral_rgb[0], positive_rgb[0], f)
|
| 170 |
+
g = _interp(neutral_rgb[1], positive_rgb[1], f)
|
| 171 |
+
b = _interp(neutral_rgb[2], positive_rgb[2], f)
|
| 172 |
+
else:
|
| 173 |
+
t = -f
|
| 174 |
+
r = _interp(neutral_rgb[0], negative_rgb[0], t)
|
| 175 |
+
g = _interp(neutral_rgb[1], negative_rgb[1], t)
|
| 176 |
+
b = _interp(neutral_rgb[2], negative_rgb[2], t)
|
| 177 |
+
return _rgb_to_hex(r, g, b)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def text_color_for_bg(intensity: float, *, threshold: float = 0.55) -> str:
|
| 181 |
+
"""Retourne ``"#fff"`` sur fond foncé, ``"#222"`` sur fond clair.
|
| 182 |
+
|
| 183 |
+
``intensity`` ∈ [0, 1] : 0 = fond clair, 1 = fond très foncé.
|
| 184 |
+
Pour les heatmaps single-gradient, c'est typiquement la même valeur
|
| 185 |
+
que celle passée à :func:`color_single_gradient`.
|
| 186 |
+
"""
|
| 187 |
+
return "#fff" if intensity > threshold else "#222"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ──────────────────────────────────────────────────────────────────
|
| 191 |
+
# API publique : grille SVG
|
| 192 |
+
# ──────────────────────────────────────────────────────────────────
|
| 193 |
+
def build_grid_svg(
|
| 194 |
+
*,
|
| 195 |
+
n_rows: int,
|
| 196 |
+
n_cols: int,
|
| 197 |
+
row_label_fn: Callable[[int], str],
|
| 198 |
+
col_label_fn: Callable[[int], str],
|
| 199 |
+
cell_color_fn: Callable[[int, int], str],
|
| 200 |
+
cell_text_fn: Callable[[int, int], Optional[str]] = lambda r, c: None,
|
| 201 |
+
cell_text_color_fn: Callable[[int, int], str] = lambda r, c: "#222",
|
| 202 |
+
cell_w: int = 36,
|
| 203 |
+
cell_h: int = 36,
|
| 204 |
+
label_left: int = 130,
|
| 205 |
+
label_top: int = 80,
|
| 206 |
+
rotate_col_labels: bool = False,
|
| 207 |
+
aria_label: str = "Heatmap",
|
| 208 |
+
x_axis_title: Optional[str] = None,
|
| 209 |
+
) -> str:
|
| 210 |
+
"""Construit une heatmap SVG paramétrable.
|
| 211 |
+
|
| 212 |
+
Architecture commune des deux `_build_heatmap_svg` historiques
|
| 213 |
+
(taxonomy_cooccurrence et taxonomy_intra_doc), mutualisée ici.
|
| 214 |
+
|
| 215 |
+
Paramètres
|
| 216 |
+
----------
|
| 217 |
+
n_rows, n_cols : int
|
| 218 |
+
Dimensions de la grille.
|
| 219 |
+
row_label_fn, col_label_fn : Callable[[int], str]
|
| 220 |
+
Étiquettes des lignes (gauche) et colonnes (haut).
|
| 221 |
+
cell_color_fn : Callable[[int, int], str]
|
| 222 |
+
Retourne la couleur hex de fond pour la cellule (row, col).
|
| 223 |
+
cell_text_fn : Callable[[int, int], Optional[str]]
|
| 224 |
+
Texte à afficher dans la cellule, ou ``None`` pour ne rien afficher.
|
| 225 |
+
cell_text_color_fn : Callable[[int, int], str]
|
| 226 |
+
Couleur du texte de la cellule (typiquement obtenue via
|
| 227 |
+
:func:`text_color_for_bg`).
|
| 228 |
+
cell_w, cell_h : int
|
| 229 |
+
Dimensions de chaque cellule en pixels.
|
| 230 |
+
label_left, label_top : int
|
| 231 |
+
Marges réservées aux étiquettes.
|
| 232 |
+
rotate_col_labels : bool
|
| 233 |
+
Si ``True``, les étiquettes de colonnes sont rotées de -45°
|
| 234 |
+
(utile quand elles sont longues).
|
| 235 |
+
aria_label : str
|
| 236 |
+
Étiquette d'accessibilité du SVG.
|
| 237 |
+
x_axis_title : Optional[str]
|
| 238 |
+
Titre optionnel de l'axe horizontal, affiché en bas du SVG.
|
| 239 |
+
|
| 240 |
+
Retour
|
| 241 |
+
------
|
| 242 |
+
str
|
| 243 |
+
SVG complet, ou ``""`` si la grille est vide.
|
| 244 |
+
"""
|
| 245 |
+
if n_rows == 0 or n_cols == 0:
|
| 246 |
+
return ""
|
| 247 |
+
|
| 248 |
+
extra_bottom = 30 if x_axis_title else 10
|
| 249 |
+
width = label_left + n_cols * cell_w + 10
|
| 250 |
+
height = label_top + n_rows * cell_h + extra_bottom
|
| 251 |
+
|
| 252 |
+
parts: list[str] = [
|
| 253 |
+
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
| 254 |
+
f'width="{width}" height="{height}" '
|
| 255 |
+
f'viewBox="0 0 {width} {height}" '
|
| 256 |
+
f'role="img" aria-label="{_e(aria_label)}">',
|
| 257 |
+
]
|
| 258 |
+
|
| 259 |
+
# Étiquettes de colonnes
|
| 260 |
+
for j in range(n_cols):
|
| 261 |
+
cx = label_left + j * cell_w + cell_w // 2
|
| 262 |
+
cy = label_top - 6
|
| 263 |
+
label = _e(col_label_fn(j))
|
| 264 |
+
if rotate_col_labels:
|
| 265 |
+
parts.append(
|
| 266 |
+
f'<text x="{cx}" y="{cy}" '
|
| 267 |
+
f'transform="rotate(-45 {cx} {cy})" '
|
| 268 |
+
f'font-size="11" fill="#333" text-anchor="start">'
|
| 269 |
+
f'{label}</text>'
|
| 270 |
+
)
|
| 271 |
+
else:
|
| 272 |
+
parts.append(
|
| 273 |
+
f'<text x="{cx}" y="{cy}" '
|
| 274 |
+
f'font-size="10" fill="#666" text-anchor="middle">'
|
| 275 |
+
f'{label}</text>'
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
# Cellules + étiquettes de lignes
|
| 279 |
+
for i in range(n_rows):
|
| 280 |
+
rx = label_left - 6
|
| 281 |
+
ry = label_top + i * cell_h + cell_h // 2 + 4
|
| 282 |
+
parts.append(
|
| 283 |
+
f'<text x="{rx}" y="{ry}" '
|
| 284 |
+
f'font-size="11" fill="#333" text-anchor="end">'
|
| 285 |
+
f'{_e(row_label_fn(i))}</text>'
|
| 286 |
+
)
|
| 287 |
+
for j in range(n_cols):
|
| 288 |
+
x = label_left + j * cell_w
|
| 289 |
+
y = label_top + i * cell_h
|
| 290 |
+
color = cell_color_fn(i, j)
|
| 291 |
+
parts.append(
|
| 292 |
+
f'<rect x="{x}" y="{y}" '
|
| 293 |
+
f'width="{cell_w}" height="{cell_h}" '
|
| 294 |
+
f'fill="{color}" stroke="#ddd" stroke-width="0.5"/>'
|
| 295 |
+
)
|
| 296 |
+
text = cell_text_fn(i, j)
|
| 297 |
+
if text is not None:
|
| 298 |
+
text_color = cell_text_color_fn(i, j)
|
| 299 |
+
parts.append(
|
| 300 |
+
f'<text x="{x + cell_w // 2}" '
|
| 301 |
+
f'y="{y + cell_h // 2 + 4}" '
|
| 302 |
+
f'font-size="10" fill="{text_color}" '
|
| 303 |
+
f'text-anchor="middle">'
|
| 304 |
+
f'{_e(text)}</text>'
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
if x_axis_title:
|
| 308 |
+
cx_axis = label_left + (n_cols * cell_w) // 2
|
| 309 |
+
cy_axis = height - 6
|
| 310 |
+
parts.append(
|
| 311 |
+
f'<text x="{cx_axis}" y="{cy_axis}" '
|
| 312 |
+
f'font-size="11" fill="#666" text-anchor="middle" '
|
| 313 |
+
f'font-style="italic">'
|
| 314 |
+
f'{_e(x_axis_title)}</text>'
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
parts.append("</svg>")
|
| 318 |
+
return "".join(parts)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
__all__ = [
|
| 322 |
+
"GRADIENT_RED_RGB",
|
| 323 |
+
"GRADIENT_YELLOW_RGB",
|
| 324 |
+
"GRADIENT_GREEN_RGB",
|
| 325 |
+
"GRADIENT_TARGET_BLUE",
|
| 326 |
+
"GRADIENT_TARGET_ORANGE",
|
| 327 |
+
"GRADIENT_TARGET_RED",
|
| 328 |
+
"DIVERGING_NEGATIVE_RGB",
|
| 329 |
+
"DIVERGING_NEUTRAL_RGB",
|
| 330 |
+
"DIVERGING_POSITIVE_RGB",
|
| 331 |
+
"color_traffic_light",
|
| 332 |
+
"color_single_gradient",
|
| 333 |
+
"color_diverging",
|
| 334 |
+
"text_color_for_bg",
|
| 335 |
+
"build_grid_svg",
|
| 336 |
+
]
|
|
@@ -53,23 +53,7 @@ from __future__ import annotations
|
|
| 53 |
from html import escape as _e
|
| 54 |
from typing import Optional
|
| 55 |
|
| 56 |
-
|
| 57 |
-
def _color_for_deficit(deficit: float) -> str:
|
| 58 |
-
"""Vert (≈0) → orange (~3 pts) → rouge (≥ 5 pts)."""
|
| 59 |
-
f = max(0.0, min(1.0, abs(deficit) / 0.05))
|
| 60 |
-
if f < 0.5:
|
| 61 |
-
# vert → orange
|
| 62 |
-
t = f / 0.5
|
| 63 |
-
r = int(167 + (235 - 167) * t)
|
| 64 |
-
g = int(240 + (180 - 240) * t)
|
| 65 |
-
b = int(167 + (60 - 167) * t)
|
| 66 |
-
else:
|
| 67 |
-
# orange → rouge
|
| 68 |
-
t = (f - 0.5) / 0.5
|
| 69 |
-
r = int(235 + (220 - 235) * t)
|
| 70 |
-
g = int(180 + (50 - 180) * t)
|
| 71 |
-
b = int(60 + (50 - 60) * t)
|
| 72 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 73 |
|
| 74 |
|
| 75 |
def _build_summary_table(
|
|
@@ -106,7 +90,7 @@ def _build_summary_table(
|
|
| 106 |
n_types = int(info.get("n_degradation_types") or 0)
|
| 107 |
worst_type = info.get("worst_degradation_type")
|
| 108 |
worst_deficit = info.get("worst_degradation_deficit")
|
| 109 |
-
color =
|
| 110 |
worst_str = (
|
| 111 |
f"{_e(str(worst_type))} ({worst_deficit * 100:+.1f})"
|
| 112 |
if worst_type and isinstance(worst_deficit, (int, float))
|
|
@@ -162,7 +146,7 @@ def _build_detail_table(
|
|
| 162 |
deficit = entry.get("deficit_vs_baseline")
|
| 163 |
n_above = int(entry.get("n_docs_above_critical") or 0)
|
| 164 |
if isinstance(deficit, (int, float)):
|
| 165 |
-
color =
|
| 166 |
deficit_str = f"{float(deficit) * 100:+.2f}"
|
| 167 |
deficit_cell = (
|
| 168 |
f'<td style="padding:.4rem .6rem;text-align:right;'
|
|
|
|
| 53 |
from html import escape as _e
|
| 54 |
from typing import Optional
|
| 55 |
|
| 56 |
+
from picarones.report.render_helpers import color_traffic_light
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
def _build_summary_table(
|
|
|
|
| 90 |
n_types = int(info.get("n_degradation_types") or 0)
|
| 91 |
worst_type = info.get("worst_degradation_type")
|
| 92 |
worst_deficit = info.get("worst_degradation_deficit")
|
| 93 |
+
color = color_traffic_light(abs(deficit), low_is_good=True, scale_max=0.05)
|
| 94 |
worst_str = (
|
| 95 |
f"{_e(str(worst_type))} ({worst_deficit * 100:+.1f})"
|
| 96 |
if worst_type and isinstance(worst_deficit, (int, float))
|
|
|
|
| 146 |
deficit = entry.get("deficit_vs_baseline")
|
| 147 |
n_above = int(entry.get("n_docs_above_critical") or 0)
|
| 148 |
if isinstance(deficit, (int, float)):
|
| 149 |
+
color = color_traffic_light(abs(float(deficit)), low_is_good=True, scale_max=0.05)
|
| 150 |
deficit_str = f"{float(deficit) * 100:+.2f}"
|
| 151 |
deficit_cell = (
|
| 152 |
f'<td style="padding:.4rem .6rem;text-align:right;'
|
|
@@ -19,23 +19,7 @@ from __future__ import annotations
|
|
| 19 |
from html import escape as _e
|
| 20 |
from typing import Optional
|
| 21 |
|
| 22 |
-
|
| 23 |
-
def _color_for_recall(recall: float) -> str:
|
| 24 |
-
"""Gradient rouge → jaune → vert pour rappel ∈ [0, 1]."""
|
| 25 |
-
f = max(0.0, min(1.0, recall))
|
| 26 |
-
if f < 0.5:
|
| 27 |
-
# rouge → jaune
|
| 28 |
-
t = f / 0.5
|
| 29 |
-
r = 235
|
| 30 |
-
g = int(70 + (200 - 70) * t)
|
| 31 |
-
b = 70
|
| 32 |
-
else:
|
| 33 |
-
# jaune → vert
|
| 34 |
-
t = (f - 0.5) / 0.5
|
| 35 |
-
r = int(235 + (60 - 235) * t)
|
| 36 |
-
g = int(200 + (160 - 200) * t)
|
| 37 |
-
b = int(70 + (90 - 70) * t)
|
| 38 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 39 |
|
| 40 |
|
| 41 |
def build_searchability_summary_html(
|
|
@@ -99,7 +83,7 @@ def build_searchability_summary_html(
|
|
| 99 |
n_search = int(agg.get("n_searchable") or 0)
|
| 100 |
n_total = int(agg.get("n_gt_tokens") or 0)
|
| 101 |
n_docs = int(agg.get("n_docs") or 0)
|
| 102 |
-
color =
|
| 103 |
parts.append(
|
| 104 |
f'<tr>'
|
| 105 |
f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
|
|
|
|
| 19 |
from html import escape as _e
|
| 20 |
from typing import Optional
|
| 21 |
|
| 22 |
+
from picarones.report.render_helpers import color_traffic_light
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def build_searchability_summary_html(
|
|
|
|
| 83 |
n_search = int(agg.get("n_searchable") or 0)
|
| 84 |
n_total = int(agg.get("n_gt_tokens") or 0)
|
| 85 |
n_docs = int(agg.get("n_docs") or 0)
|
| 86 |
+
color = color_traffic_light(recall)
|
| 87 |
parts.append(
|
| 88 |
f'<tr>'
|
| 89 |
f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
|
|
@@ -18,15 +18,10 @@ from picarones.measurements.specialization import (
|
|
| 18 |
compute_specialization_matrix,
|
| 19 |
top_specialized_pairs,
|
| 20 |
)
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
"""Gradient blanc → bleu profond."""
|
| 25 |
-
f = max(0.0, min(1.0, score))
|
| 26 |
-
r = int(255 + (50 - 255) * f)
|
| 27 |
-
g = int(255 + (110 - 255) * f)
|
| 28 |
-
b = int(255 + (180 - 255) * f)
|
| 29 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 30 |
|
| 31 |
|
| 32 |
def _category_label(cat: str, labels: dict[str, str]) -> str:
|
|
@@ -97,7 +92,7 @@ def build_specialization_html(
|
|
| 97 |
for pair in pairs:
|
| 98 |
score = float(pair.get("score") or 0.0)
|
| 99 |
cat = pair.get("category") or "?"
|
| 100 |
-
color =
|
| 101 |
parts.append(
|
| 102 |
f'<tr>'
|
| 103 |
f'<td style="padding:.4rem .6rem">'
|
|
|
|
| 18 |
compute_specialization_matrix,
|
| 19 |
top_specialized_pairs,
|
| 20 |
)
|
| 21 |
+
from picarones.report.render_helpers import color_single_gradient
|
| 22 |
|
| 23 |
+
#: Bleu profond cible — préservé de l'ancien `_color_for_score` local.
|
| 24 |
+
_SPECIALIZATION_BLUE = (50, 110, 180)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def _category_label(cat: str, labels: dict[str, str]) -> str:
|
|
|
|
| 92 |
for pair in pairs:
|
| 93 |
score = float(pair.get("score") or 0.0)
|
| 94 |
cat = pair.get("category") or "?"
|
| 95 |
+
color = color_single_gradient(score, end_rgb=_SPECIALIZATION_BLUE)
|
| 96 |
parts.append(
|
| 97 |
f'<tr>'
|
| 98 |
f'<td style="padding:.4rem .6rem">'
|
|
@@ -22,21 +22,7 @@ from __future__ import annotations
|
|
| 22 |
from html import escape as _e
|
| 23 |
from typing import Optional
|
| 24 |
|
| 25 |
-
|
| 26 |
-
def _color_for_cer(cer: float) -> str:
|
| 27 |
-
"""Gradient vert (faible CER) → rouge (CER élevé), saturé à 0.30."""
|
| 28 |
-
f = max(0.0, min(1.0, cer / 0.30))
|
| 29 |
-
if f <= 0.5:
|
| 30 |
-
ratio = f / 0.5
|
| 31 |
-
r = int(130 + (240 - 130) * ratio)
|
| 32 |
-
g = int(200 + (220 - 200) * ratio)
|
| 33 |
-
b = int(130 + (130 - 130) * ratio)
|
| 34 |
-
else:
|
| 35 |
-
ratio = (f - 0.5) / 0.5
|
| 36 |
-
r = int(240 + (220 - 240) * ratio)
|
| 37 |
-
g = int(220 + (100 - 220) * ratio)
|
| 38 |
-
b = int(130 + (100 - 130) * ratio)
|
| 39 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 40 |
|
| 41 |
|
| 42 |
def _format_cer(cer: Optional[float]) -> str:
|
|
@@ -167,7 +153,7 @@ def build_stratified_ranking_html(
|
|
| 167 |
median = entry.get("median_cer")
|
| 168 |
mean = entry.get("mean_cer")
|
| 169 |
n_docs = int(entry.get("documents") or 0)
|
| 170 |
-
bg =
|
| 171 |
parts.append("<tr>")
|
| 172 |
parts.append(
|
| 173 |
f'<td style="padding:.3rem .5rem;font-weight:600">'
|
|
|
|
| 22 |
from html import escape as _e
|
| 23 |
from typing import Optional
|
| 24 |
|
| 25 |
+
from picarones.report.render_helpers import color_traffic_light
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
def _format_cer(cer: Optional[float]) -> str:
|
|
|
|
| 153 |
median = entry.get("median_cer")
|
| 154 |
mean = entry.get("mean_cer")
|
| 155 |
n_docs = int(entry.get("documents") or 0)
|
| 156 |
+
bg = color_traffic_light(float(median), low_is_good=True, scale_max=0.30) if median is not None else "#f4f4f4"
|
| 157 |
parts.append("<tr>")
|
| 158 |
parts.append(
|
| 159 |
f'<td style="padding:.3rem .5rem;font-weight:600">'
|
|
@@ -20,25 +20,15 @@ from __future__ import annotations
|
|
| 20 |
from html import escape as _e
|
| 21 |
from typing import Optional
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
def _color_for_jaccard(j: float) -> str:
|
| 25 |
-
"""Gradient blanc → bleu profond pour Jaccard ∈ [0, 1].
|
| 26 |
|
| 27 |
-
|
| 28 |
-
"""
|
| 29 |
-
f = max(0.0, min(1.0, j))
|
| 30 |
-
r = int(255 + (30 - 255) * f)
|
| 31 |
-
g = int(255 + (58 - 255) * f)
|
| 32 |
-
b = int(255 + (138 - 255) * f)
|
| 33 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def _text_color_for_bg(j: float) -> str:
|
| 37 |
-
"""Texte blanc si fond foncé, noir sinon (lisibilité)."""
|
| 38 |
-
return "#fff" if j > 0.55 else "#222"
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def _build_heatmap_svg(
|
| 42 |
classes: list[str],
|
| 43 |
matrix: dict[str, dict[str, float]],
|
| 44 |
*,
|
|
@@ -46,66 +36,37 @@ def _build_heatmap_svg(
|
|
| 46 |
label_left: int = 130,
|
| 47 |
label_top: int = 80,
|
| 48 |
) -> str:
|
| 49 |
-
"""
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
"""
|
| 55 |
-
|
| 56 |
-
if n == 0:
|
| 57 |
return ""
|
| 58 |
-
width = label_left + n * cell_size + 10
|
| 59 |
-
height = label_top + n * cell_size + 10
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
f
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
f'font-size="11" fill="#333" text-anchor="end">'
|
| 84 |
-
f'{_e(cls)}</text>'
|
| 85 |
-
)
|
| 86 |
-
# Cellules
|
| 87 |
-
for i, ca in enumerate(classes):
|
| 88 |
-
for j, cb in enumerate(classes):
|
| 89 |
-
value = matrix.get(ca, {}).get(cb, 0.0)
|
| 90 |
-
x = label_left + j * cell_size
|
| 91 |
-
y = label_top + i * cell_size
|
| 92 |
-
color = _color_for_jaccard(value)
|
| 93 |
-
text_color = _text_color_for_bg(value)
|
| 94 |
-
parts.append(
|
| 95 |
-
f'<rect x="{x}" y="{y}" '
|
| 96 |
-
f'width="{cell_size}" height="{cell_size}" '
|
| 97 |
-
f'fill="{color}" stroke="#ddd" stroke-width="0.5"/>'
|
| 98 |
-
)
|
| 99 |
-
if value > 0.05:
|
| 100 |
-
parts.append(
|
| 101 |
-
f'<text x="{x + cell_size // 2}" '
|
| 102 |
-
f'y="{y + cell_size // 2 + 4}" '
|
| 103 |
-
f'font-size="10" fill="{text_color}" '
|
| 104 |
-
f'text-anchor="middle">'
|
| 105 |
-
f'{value:.2f}</text>'
|
| 106 |
-
)
|
| 107 |
-
parts.append("</svg>")
|
| 108 |
-
return "".join(parts)
|
| 109 |
|
| 110 |
|
| 111 |
def _build_top_pairs_table(
|
|
@@ -136,8 +97,9 @@ def _build_top_pairs_table(
|
|
| 136 |
f'<td style="padding:.2rem .5rem">'
|
| 137 |
f'<code>{_e(ca)}</code> ↔ <code>{_e(cb)}</code></td>'
|
| 138 |
f'<td style="padding:.2rem .5rem;text-align:right;'
|
| 139 |
-
f'font-family:monospace;
|
| 140 |
-
f'
|
|
|
|
| 141 |
f'</tr>'
|
| 142 |
)
|
| 143 |
parts.append("</tbody></table>")
|
|
@@ -175,7 +137,7 @@ def build_taxonomy_cooccurrence_html(
|
|
| 175 |
)
|
| 176 |
n_docs_phrase = n_docs_label_template.format(n_docs=n_docs)
|
| 177 |
|
| 178 |
-
svg =
|
| 179 |
top_table = _build_top_pairs_table(
|
| 180 |
data.get("top_pairs") or [], labels,
|
| 181 |
)
|
|
|
|
| 20 |
from html import escape as _e
|
| 21 |
from typing import Optional
|
| 22 |
|
| 23 |
+
from picarones.report.render_helpers import (
|
| 24 |
+
GRADIENT_TARGET_BLUE,
|
| 25 |
+
build_grid_svg,
|
| 26 |
+
color_single_gradient,
|
| 27 |
+
text_color_for_bg,
|
| 28 |
+
)
|
| 29 |
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
def _build_jaccard_heatmap_svg(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
classes: list[str],
|
| 33 |
matrix: dict[str, dict[str, float]],
|
| 34 |
*,
|
|
|
|
| 36 |
label_left: int = 130,
|
| 37 |
label_top: int = 80,
|
| 38 |
) -> str:
|
| 39 |
+
"""Heatmap Jaccard de co-occurrence taxonomique.
|
| 40 |
|
| 41 |
+
Délègue à :func:`build_grid_svg` ; reste un wrapper local qui
|
| 42 |
+
encapsule les conventions spécifiques à la matrice symétrique
|
| 43 |
+
(valeur affichée seulement si > 0,05, étiquettes rotées).
|
| 44 |
"""
|
| 45 |
+
if not classes:
|
|
|
|
| 46 |
return ""
|
|
|
|
|
|
|
| 47 |
|
| 48 |
+
def cell_value(i: int, j: int) -> float:
|
| 49 |
+
return matrix.get(classes[i], {}).get(classes[j], 0.0)
|
| 50 |
+
|
| 51 |
+
return build_grid_svg(
|
| 52 |
+
n_rows=len(classes),
|
| 53 |
+
n_cols=len(classes),
|
| 54 |
+
row_label_fn=lambda i: classes[i],
|
| 55 |
+
col_label_fn=lambda j: classes[j],
|
| 56 |
+
cell_color_fn=lambda i, j: color_single_gradient(
|
| 57 |
+
cell_value(i, j), end_rgb=GRADIENT_TARGET_BLUE,
|
| 58 |
+
),
|
| 59 |
+
cell_text_fn=lambda i, j: (
|
| 60 |
+
f"{cell_value(i, j):.2f}" if cell_value(i, j) > 0.05 else None
|
| 61 |
+
),
|
| 62 |
+
cell_text_color_fn=lambda i, j: text_color_for_bg(cell_value(i, j)),
|
| 63 |
+
cell_w=cell_size,
|
| 64 |
+
cell_h=cell_size,
|
| 65 |
+
label_left=label_left,
|
| 66 |
+
label_top=label_top,
|
| 67 |
+
rotate_col_labels=True,
|
| 68 |
+
aria_label="Heatmap Jaccard co-occurrence taxonomique",
|
| 69 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
def _build_top_pairs_table(
|
|
|
|
| 97 |
f'<td style="padding:.2rem .5rem">'
|
| 98 |
f'<code>{_e(ca)}</code> ↔ <code>{_e(cb)}</code></td>'
|
| 99 |
f'<td style="padding:.2rem .5rem;text-align:right;'
|
| 100 |
+
f'font-family:monospace;'
|
| 101 |
+
f'background:{color_single_gradient(j, end_rgb=GRADIENT_TARGET_BLUE)};'
|
| 102 |
+
f'color:{text_color_for_bg(j)}">{j:.2f}</td>'
|
| 103 |
f'</tr>'
|
| 104 |
)
|
| 105 |
parts.append("</tbody></table>")
|
|
|
|
| 137 |
)
|
| 138 |
n_docs_phrase = n_docs_label_template.format(n_docs=n_docs)
|
| 139 |
|
| 140 |
+
svg = _build_jaccard_heatmap_svg(classes, matrix)
|
| 141 |
top_table = _build_top_pairs_table(
|
| 142 |
data.get("top_pairs") or [], labels,
|
| 143 |
)
|
|
@@ -23,24 +23,15 @@ from __future__ import annotations
|
|
| 23 |
from html import escape as _e
|
| 24 |
from typing import Optional
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
def _color_for_density(density: float) -> str:
|
| 28 |
-
"""Gradient blanc → orange profond pour densité ∈ [0, 1].
|
| 29 |
|
| 30 |
-
|
| 31 |
-
"""
|
| 32 |
-
f = max(0.0, min(1.0, density))
|
| 33 |
-
r = int(255 + (194 - 255) * f)
|
| 34 |
-
g = int(255 + (65 - 255) * f)
|
| 35 |
-
b = int(255 + (12 - 255) * f)
|
| 36 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def _text_color_for_bg(density: float) -> str:
|
| 40 |
-
return "#fff" if density > 0.55 else "#222"
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def _build_heatmap_svg(
|
| 44 |
classes_with_errors: list[str],
|
| 45 |
per_class: dict[str, list[int]],
|
| 46 |
n_bins: int,
|
|
@@ -50,72 +41,47 @@ def _build_heatmap_svg(
|
|
| 50 |
label_left: int = 150,
|
| 51 |
label_top: int = 30,
|
| 52 |
) -> str:
|
| 53 |
-
"""
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
return ""
|
| 57 |
-
width = label_left + n_bins * cell_w + 10
|
| 58 |
-
height = label_top + n_rows * cell_h + 30 # +30 pour étiquette X
|
| 59 |
|
| 60 |
-
#
|
| 61 |
-
#
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
f'width="{width}" height="{height}" '
|
| 65 |
-
f'viewBox="0 0 {width} {height}" '
|
| 66 |
-
f'role="img" aria-label="Heatmap class taxonomique × position">',
|
| 67 |
-
]
|
| 68 |
-
# Étiquettes des colonnes (positions)
|
| 69 |
-
for j in range(n_bins):
|
| 70 |
-
cx = label_left + j * cell_w + cell_w // 2
|
| 71 |
-
cy = label_top - 6
|
| 72 |
-
parts.append(
|
| 73 |
-
f'<text x="{cx}" y="{cy}" '
|
| 74 |
-
f'font-size="10" fill="#666" text-anchor="middle">'
|
| 75 |
-
f'{j + 1}</text>'
|
| 76 |
-
)
|
| 77 |
-
# Cellules
|
| 78 |
-
for i, cls in enumerate(classes_with_errors):
|
| 79 |
-
# Étiquette de ligne (classe)
|
| 80 |
-
rx = label_left - 6
|
| 81 |
-
ry = label_top + i * cell_h + cell_h // 2 + 4
|
| 82 |
-
parts.append(
|
| 83 |
-
f'<text x="{rx}" y="{ry}" '
|
| 84 |
-
f'font-size="11" fill="#333" text-anchor="end">'
|
| 85 |
-
f'{_e(cls)}</text>'
|
| 86 |
-
)
|
| 87 |
counts = per_class.get(cls, [0] * n_bins)
|
| 88 |
max_count = max(counts) if counts else 0
|
|
|
|
| 89 |
for j in range(n_bins):
|
| 90 |
-
x = label_left + j * cell_w
|
| 91 |
-
y = label_top + i * cell_h
|
| 92 |
count = counts[j] if j < len(counts) else 0
|
| 93 |
density = (count / max_count) if max_count > 0 else 0.0
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
f'Position dans le document (1 = début)</text>'
|
| 116 |
)
|
| 117 |
-
parts.append("</svg>")
|
| 118 |
-
return "".join(parts)
|
| 119 |
|
| 120 |
|
| 121 |
def build_taxonomy_intra_doc_html(
|
|
@@ -162,7 +128,7 @@ def build_taxonomy_intra_doc_html(
|
|
| 162 |
n_words_gt=n_words_gt, n_bins=n_bins,
|
| 163 |
)
|
| 164 |
|
| 165 |
-
svg =
|
| 166 |
|
| 167 |
parts = [
|
| 168 |
'<div class="intradoc" style="margin:1rem 0">',
|
|
|
|
| 23 |
from html import escape as _e
|
| 24 |
from typing import Optional
|
| 25 |
|
| 26 |
+
from picarones.report.render_helpers import (
|
| 27 |
+
GRADIENT_TARGET_ORANGE,
|
| 28 |
+
build_grid_svg,
|
| 29 |
+
color_single_gradient,
|
| 30 |
+
text_color_for_bg,
|
| 31 |
+
)
|
| 32 |
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
def _build_position_heatmap_svg(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
classes_with_errors: list[str],
|
| 36 |
per_class: dict[str, list[int]],
|
| 37 |
n_bins: int,
|
|
|
|
| 41 |
label_left: int = 150,
|
| 42 |
label_top: int = 30,
|
| 43 |
) -> str:
|
| 44 |
+
"""Heatmap class taxonomique × position (densité relative par classe).
|
| 45 |
+
|
| 46 |
+
Délègue à :func:`build_grid_svg` ; reste un wrapper local qui
|
| 47 |
+
encapsule la normalisation par classe (densité relative au max
|
| 48 |
+
observé sur la ligne).
|
| 49 |
+
"""
|
| 50 |
+
if not classes_with_errors:
|
| 51 |
return ""
|
|
|
|
|
|
|
| 52 |
|
| 53 |
+
# Pré-calcule densité et count par cellule pour éviter les boucles
|
| 54 |
+
# imbriquées dans les callbacks.
|
| 55 |
+
grid: list[list[tuple[int, float]]] = []
|
| 56 |
+
for cls in classes_with_errors:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
counts = per_class.get(cls, [0] * n_bins)
|
| 58 |
max_count = max(counts) if counts else 0
|
| 59 |
+
row: list[tuple[int, float]] = []
|
| 60 |
for j in range(n_bins):
|
|
|
|
|
|
|
| 61 |
count = counts[j] if j < len(counts) else 0
|
| 62 |
density = (count / max_count) if max_count > 0 else 0.0
|
| 63 |
+
row.append((count, density))
|
| 64 |
+
grid.append(row)
|
| 65 |
+
|
| 66 |
+
return build_grid_svg(
|
| 67 |
+
n_rows=len(classes_with_errors),
|
| 68 |
+
n_cols=n_bins,
|
| 69 |
+
row_label_fn=lambda i: classes_with_errors[i],
|
| 70 |
+
col_label_fn=lambda j: str(j + 1),
|
| 71 |
+
cell_color_fn=lambda i, j: color_single_gradient(
|
| 72 |
+
grid[i][j][1], end_rgb=GRADIENT_TARGET_ORANGE,
|
| 73 |
+
),
|
| 74 |
+
cell_text_fn=lambda i, j: (
|
| 75 |
+
str(grid[i][j][0]) if grid[i][j][0] > 0 else None
|
| 76 |
+
),
|
| 77 |
+
cell_text_color_fn=lambda i, j: text_color_for_bg(grid[i][j][1]),
|
| 78 |
+
cell_w=cell_w,
|
| 79 |
+
cell_h=cell_h,
|
| 80 |
+
label_left=label_left,
|
| 81 |
+
label_top=label_top,
|
| 82 |
+
aria_label="Heatmap class taxonomique × position",
|
| 83 |
+
x_axis_title="Position dans le document (1 = début)",
|
|
|
|
| 84 |
)
|
|
|
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
def build_taxonomy_intra_doc_html(
|
|
|
|
| 128 |
n_words_gt=n_words_gt, n_bins=n_bins,
|
| 129 |
)
|
| 130 |
|
| 131 |
+
svg = _build_position_heatmap_svg(classes_with_errors, per_class, n_bins)
|
| 132 |
|
| 133 |
parts = [
|
| 134 |
'<div class="intradoc" style="margin:1rem 0">',
|
|
@@ -50,39 +50,7 @@ from __future__ import annotations
|
|
| 50 |
from html import escape as _e
|
| 51 |
from typing import Optional
|
| 52 |
|
| 53 |
-
|
| 54 |
-
def _color_for_pages_per_hour(value: float, max_value: float) -> str:
|
| 55 |
-
"""Rouge (faible) → jaune → vert (= max)."""
|
| 56 |
-
if max_value <= 0:
|
| 57 |
-
return "#e0e0e0"
|
| 58 |
-
f = max(0.0, min(1.0, value / max_value))
|
| 59 |
-
if f < 0.5:
|
| 60 |
-
t = f / 0.5
|
| 61 |
-
r = 235
|
| 62 |
-
g = int(70 + (200 - 70) * t)
|
| 63 |
-
b = 70
|
| 64 |
-
else:
|
| 65 |
-
t = (f - 0.5) / 0.5
|
| 66 |
-
r = int(235 + (60 - 235) * t)
|
| 67 |
-
g = int(200 + (160 - 200) * t)
|
| 68 |
-
b = int(70 + (90 - 70) * t)
|
| 69 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def _color_for_drag(drag: float) -> str:
|
| 73 |
-
"""Vert (faible drag) → orange → rouge (drag élevé)."""
|
| 74 |
-
f = max(0.0, min(1.0, drag))
|
| 75 |
-
if f < 0.5:
|
| 76 |
-
t = f / 0.5
|
| 77 |
-
r = int(167 + (235 - 167) * t)
|
| 78 |
-
g = int(240 + (180 - 240) * t)
|
| 79 |
-
b = int(167 + (60 - 167) * t)
|
| 80 |
-
else:
|
| 81 |
-
t = (f - 0.5) / 0.5
|
| 82 |
-
r = int(235 + (220 - 235) * t)
|
| 83 |
-
g = int(180 + (50 - 180) * t)
|
| 84 |
-
b = int(60 + (50 - 60) * t)
|
| 85 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 86 |
|
| 87 |
|
| 88 |
def build_throughput_html(
|
|
@@ -154,8 +122,11 @@ def build_throughput_html(
|
|
| 154 |
drag = row.get("drag_ratio") or 0.0
|
| 155 |
n_pages = int(row.get("n_pages") or 0)
|
| 156 |
n_errors = int(row.get("n_errors") or 0)
|
| 157 |
-
eff_color =
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
| 159 |
raw_str = (
|
| 160 |
f"{raw:,.0f}" if isinstance(raw, (int, float)) else "—"
|
| 161 |
)
|
|
|
|
| 50 |
from html import escape as _e
|
| 51 |
from typing import Optional
|
| 52 |
|
| 53 |
+
from picarones.report.render_helpers import color_traffic_light
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
def build_throughput_html(
|
|
|
|
| 122 |
drag = row.get("drag_ratio") or 0.0
|
| 123 |
n_pages = int(row.get("n_pages") or 0)
|
| 124 |
n_errors = int(row.get("n_errors") or 0)
|
| 125 |
+
eff_color = (
|
| 126 |
+
color_traffic_light(eff, scale_max=max_eff)
|
| 127 |
+
if max_eff > 0 else "#e0e0e0"
|
| 128 |
+
)
|
| 129 |
+
drag_color = color_traffic_light(drag, low_is_good=True)
|
| 130 |
raw_str = (
|
| 131 |
f"{raw:,.0f}" if isinstance(raw, (int, float)) else "—"
|
| 132 |
)
|
|
@@ -20,20 +20,21 @@ from typing import Optional
|
|
| 20 |
|
| 21 |
from picarones.measurements.worst_lines import WorstLineEntry
|
| 22 |
from picarones.core.diff_utils import compute_char_diff
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
-
def
|
| 26 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
f = max(0.0, min(1.0, cer))
|
| 28 |
-
# Au-delà de 0,3 (seuil catastrophique courant), gradient
|
| 29 |
-
# jaune → rouge. En dessous, beige clair.
|
| 30 |
if f < 0.3:
|
| 31 |
return "#fff8dc"
|
| 32 |
-
|
| 33 |
-
r = int(240 + (200 - 240) * ratio)
|
| 34 |
-
g = int(220 + (60 - 220) * ratio)
|
| 35 |
-
b = int(120 + (60 - 120) * ratio)
|
| 36 |
-
return f"#{r:02x}{g:02x}{b:02x}"
|
| 37 |
|
| 38 |
|
| 39 |
def _render_diff_inline(reference: str, hypothesis: str) -> str:
|
|
@@ -121,7 +122,7 @@ def build_worst_lines_table_html(
|
|
| 121 |
)
|
| 122 |
parts.append("</tr></thead><tbody>")
|
| 123 |
for entry in entries:
|
| 124 |
-
cer_color =
|
| 125 |
parts.append("<tr>")
|
| 126 |
parts.append(
|
| 127 |
f'<td style="padding:.3rem .5rem;text-align:right;'
|
|
|
|
| 20 |
|
| 21 |
from picarones.measurements.worst_lines import WorstLineEntry
|
| 22 |
from picarones.core.diff_utils import compute_char_diff
|
| 23 |
+
from picarones.report.render_helpers import color_traffic_light
|
| 24 |
|
| 25 |
|
| 26 |
+
def _bg_for_cer(cer: float) -> str:
|
| 27 |
+
"""Beige clair sous le seuil catastrophique (0.30), gradient
|
| 28 |
+
jaune → rouge au-delà.
|
| 29 |
+
|
| 30 |
+
Le seuil dur à 0.30 préserve la sémantique « toléré jusqu'à 30 %
|
| 31 |
+
pour un manuscrit difficile ». Au-delà, on entre en zone visible
|
| 32 |
+
avec :func:`color_traffic_light` (low_is_good).
|
| 33 |
+
"""
|
| 34 |
f = max(0.0, min(1.0, cer))
|
|
|
|
|
|
|
| 35 |
if f < 0.3:
|
| 36 |
return "#fff8dc"
|
| 37 |
+
return color_traffic_light(f, low_is_good=True, scale_min=0.3, scale_max=1.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def _render_diff_inline(reference: str, hypothesis: str) -> str:
|
|
|
|
| 122 |
)
|
| 123 |
parts.append("</tr></thead><tbody>")
|
| 124 |
for entry in entries:
|
| 125 |
+
cer_color = _bg_for_cer(entry.cer)
|
| 126 |
parts.append("<tr>")
|
| 127 |
parts.append(
|
| 128 |
f'<td style="padding:.3rem .5rem;text-align:right;'
|
|
@@ -4,21 +4,21 @@ Les renderers HTML dans ``picarones/report/`` ont accumulé des helpers
|
|
| 4 |
locaux dupliqués (couleur, heatmap SVG, etc.) qui devraient vivre dans
|
| 5 |
un unique ``picarones/report/render_helpers.py``.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
"""
|
| 23 |
|
| 24 |
from __future__ import annotations
|
|
@@ -29,8 +29,8 @@ from pathlib import Path
|
|
| 29 |
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 30 |
REPORT_DIR = REPO_ROOT / "picarones" / "report"
|
| 31 |
|
| 32 |
-
#: Snapshot v1.0.0. Doit
|
| 33 |
-
HELPER_BASELINE =
|
| 34 |
|
| 35 |
#: Le module mutualisé est exempté (c'est *là* qu'on veut les voir).
|
| 36 |
HELPERS_MODULE_NAME = "render_helpers.py"
|
|
|
|
| 4 |
locaux dupliqués (couleur, heatmap SVG, etc.) qui devraient vivre dans
|
| 5 |
un unique ``picarones/report/render_helpers.py``.
|
| 6 |
|
| 7 |
+
État après le sprint de consolidation : tous les ``_color_for_*`` et
|
| 8 |
+
``_build_heatmap_svg`` locaux ont été déplacés dans
|
| 9 |
+
``picarones/report/render_helpers.py`` qui expose
|
| 10 |
+
:func:`color_traffic_light`, :func:`color_single_gradient`,
|
| 11 |
+
:func:`color_diverging`, :func:`text_color_for_bg` et
|
| 12 |
+
:func:`build_grid_svg`.
|
| 13 |
+
|
| 14 |
+
Snapshot v1.0.0 (2026-05-02, post-consolidation) : **0 helper local
|
| 15 |
+
dupliqué**.
|
| 16 |
+
|
| 17 |
+
Test ratchet : ce nombre ne peut que descendre. Si un nouveau helper
|
| 18 |
+
``_color_for_*`` ou ``_build_heatmap_svg`` apparaît dans un renderer,
|
| 19 |
+
le test échoue. La résolution est de paramétrer un des helpers de
|
| 20 |
+
:mod:`picarones.report.render_helpers` plutôt que de réintroduire
|
| 21 |
+
une fonction locale.
|
| 22 |
"""
|
| 23 |
|
| 24 |
from __future__ import annotations
|
|
|
|
| 29 |
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 30 |
REPORT_DIR = REPO_ROOT / "picarones" / "report"
|
| 31 |
|
| 32 |
+
#: Snapshot v1.0.0 post-consolidation. Doit rester à 0.
|
| 33 |
+
HELPER_BASELINE = 0
|
| 34 |
|
| 35 |
#: Le module mutualisé est exempté (c'est *là* qu'on veut les voir).
|
| 36 |
HELPERS_MODULE_NAME = "render_helpers.py"
|
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests unitaires des helpers de rendu mutualisés.
|
| 2 |
+
|
| 3 |
+
Couvre :func:`color_traffic_light`, :func:`color_single_gradient`,
|
| 4 |
+
:func:`color_diverging`, :func:`text_color_for_bg`, :func:`build_grid_svg`.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from picarones.report.render_helpers import (
|
| 10 |
+
DIVERGING_NEGATIVE_RGB,
|
| 11 |
+
DIVERGING_POSITIVE_RGB,
|
| 12 |
+
GRADIENT_GREEN_RGB,
|
| 13 |
+
GRADIENT_RED_RGB,
|
| 14 |
+
GRADIENT_YELLOW_RGB,
|
| 15 |
+
build_grid_svg,
|
| 16 |
+
color_diverging,
|
| 17 |
+
color_single_gradient,
|
| 18 |
+
color_traffic_light,
|
| 19 |
+
text_color_for_bg,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _hex_to_rgb(hex_str: str) -> tuple[int, int, int]:
|
| 24 |
+
s = hex_str.lstrip("#")
|
| 25 |
+
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ──────────────────────────────────────────────────────────────────
|
| 29 |
+
# color_traffic_light
|
| 30 |
+
# ──────────────────────────────────────────────────────────────────
|
| 31 |
+
class TestColorTrafficLight:
|
| 32 |
+
def test_value_zero_high_is_good_returns_red(self) -> None:
|
| 33 |
+
assert _hex_to_rgb(color_traffic_light(0.0)) == GRADIENT_RED_RGB
|
| 34 |
+
|
| 35 |
+
def test_value_max_high_is_good_returns_green(self) -> None:
|
| 36 |
+
assert _hex_to_rgb(color_traffic_light(1.0)) == GRADIENT_GREEN_RGB
|
| 37 |
+
|
| 38 |
+
def test_value_mid_high_is_good_returns_yellow(self) -> None:
|
| 39 |
+
assert _hex_to_rgb(color_traffic_light(0.5)) == GRADIENT_YELLOW_RGB
|
| 40 |
+
|
| 41 |
+
def test_value_zero_low_is_good_returns_green(self) -> None:
|
| 42 |
+
assert _hex_to_rgb(color_traffic_light(0.0, low_is_good=True)) == GRADIENT_GREEN_RGB
|
| 43 |
+
|
| 44 |
+
def test_value_max_low_is_good_returns_red(self) -> None:
|
| 45 |
+
assert _hex_to_rgb(color_traffic_light(1.0, low_is_good=True)) == GRADIENT_RED_RGB
|
| 46 |
+
|
| 47 |
+
def test_clamping_above_max(self) -> None:
|
| 48 |
+
c = color_traffic_light(2.0)
|
| 49 |
+
assert _hex_to_rgb(c) == GRADIENT_GREEN_RGB
|
| 50 |
+
|
| 51 |
+
def test_clamping_below_zero(self) -> None:
|
| 52 |
+
c = color_traffic_light(-1.0)
|
| 53 |
+
assert _hex_to_rgb(c) == GRADIENT_RED_RGB
|
| 54 |
+
|
| 55 |
+
def test_custom_scale_max(self) -> None:
|
| 56 |
+
# CER 0.30 = max → vert (avec low_is_good=True → vert au max)
|
| 57 |
+
# On vérifie plutôt high_is_good : 0.30 → vert
|
| 58 |
+
assert _hex_to_rgb(color_traffic_light(0.30, scale_max=0.30)) == GRADIENT_GREEN_RGB
|
| 59 |
+
# 0.15 → milieu → jaune
|
| 60 |
+
assert _hex_to_rgb(color_traffic_light(0.15, scale_max=0.30)) == GRADIENT_YELLOW_RGB
|
| 61 |
+
|
| 62 |
+
def test_custom_scale_min_and_max(self) -> None:
|
| 63 |
+
# Plage [10, 20] : 10 → rouge, 20 → vert, 15 → jaune
|
| 64 |
+
assert _hex_to_rgb(color_traffic_light(10.0, scale_min=10, scale_max=20)) == GRADIENT_RED_RGB
|
| 65 |
+
assert _hex_to_rgb(color_traffic_light(20.0, scale_min=10, scale_max=20)) == GRADIENT_GREEN_RGB
|
| 66 |
+
assert _hex_to_rgb(color_traffic_light(15.0, scale_min=10, scale_max=20)) == GRADIENT_YELLOW_RGB
|
| 67 |
+
|
| 68 |
+
def test_zero_span_returns_yellow(self) -> None:
|
| 69 |
+
# scale_min == scale_max → milieu → jaune
|
| 70 |
+
assert _hex_to_rgb(color_traffic_light(5.0, scale_min=10, scale_max=10)) == GRADIENT_YELLOW_RGB
|
| 71 |
+
|
| 72 |
+
def test_format_is_hex_lowercase(self) -> None:
|
| 73 |
+
c = color_traffic_light(0.7)
|
| 74 |
+
assert c.startswith("#")
|
| 75 |
+
assert len(c) == 7
|
| 76 |
+
assert c == c.lower()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ──────────────────────────────────────────────────────────────────
|
| 80 |
+
# color_single_gradient
|
| 81 |
+
# ──────────────────────────────────────────────────────────────────
|
| 82 |
+
class TestColorSingleGradient:
|
| 83 |
+
def test_zero_returns_start(self) -> None:
|
| 84 |
+
assert color_single_gradient(0.0, end_rgb=(30, 58, 138)) == "#ffffff"
|
| 85 |
+
|
| 86 |
+
def test_max_returns_end(self) -> None:
|
| 87 |
+
assert color_single_gradient(1.0, end_rgb=(30, 58, 138)) == "#1e3a8a"
|
| 88 |
+
|
| 89 |
+
def test_half_is_midpoint(self) -> None:
|
| 90 |
+
c = color_single_gradient(0.5, end_rgb=(30, 58, 138))
|
| 91 |
+
# ((255+30)/2, (255+58)/2, (255+138)/2) ≈ (142, 156, 196) → ~#8e9cc4
|
| 92 |
+
r, g, b = _hex_to_rgb(c)
|
| 93 |
+
assert abs(r - 142) <= 1
|
| 94 |
+
assert abs(g - 156) <= 1
|
| 95 |
+
assert abs(b - 196) <= 1
|
| 96 |
+
|
| 97 |
+
def test_above_max_clamped(self) -> None:
|
| 98 |
+
assert color_single_gradient(2.0, end_rgb=(30, 58, 138)) == "#1e3a8a"
|
| 99 |
+
|
| 100 |
+
def test_below_zero_clamped(self) -> None:
|
| 101 |
+
assert color_single_gradient(-1.0, end_rgb=(30, 58, 138)) == "#ffffff"
|
| 102 |
+
|
| 103 |
+
def test_custom_max_value(self) -> None:
|
| 104 |
+
# value = 50, max = 100 → 0.5 → milieu
|
| 105 |
+
c = color_single_gradient(50.0, end_rgb=(30, 58, 138), max_value=100.0)
|
| 106 |
+
r, g, b = _hex_to_rgb(c)
|
| 107 |
+
assert abs(r - 142) <= 1
|
| 108 |
+
|
| 109 |
+
def test_custom_start_rgb(self) -> None:
|
| 110 |
+
c = color_single_gradient(0.0, end_rgb=(30, 58, 138), start_rgb=(0, 0, 0))
|
| 111 |
+
assert c == "#000000"
|
| 112 |
+
|
| 113 |
+
def test_zero_max_value_returns_start(self) -> None:
|
| 114 |
+
# Garde-fou contre division par zéro
|
| 115 |
+
assert color_single_gradient(5.0, end_rgb=(30, 58, 138), max_value=0) == "#ffffff"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ──────────────────────────────────────────────────────────────────
|
| 119 |
+
# color_diverging
|
| 120 |
+
# ──────────────────────────────────────────────────────────────────
|
| 121 |
+
class TestColorDiverging:
|
| 122 |
+
def test_zero_returns_neutral(self) -> None:
|
| 123 |
+
c = color_diverging(0.0)
|
| 124 |
+
# Neutre = vert par défaut
|
| 125 |
+
assert _hex_to_rgb(c) == (130, 200, 130)
|
| 126 |
+
|
| 127 |
+
def test_positive_max_returns_positive_color(self) -> None:
|
| 128 |
+
c = color_diverging(1.0, max_abs=1.0)
|
| 129 |
+
assert _hex_to_rgb(c) == DIVERGING_POSITIVE_RGB
|
| 130 |
+
|
| 131 |
+
def test_negative_max_returns_negative_color(self) -> None:
|
| 132 |
+
c = color_diverging(-1.0, max_abs=1.0)
|
| 133 |
+
assert _hex_to_rgb(c) == DIVERGING_NEGATIVE_RGB
|
| 134 |
+
|
| 135 |
+
def test_clamping_above_max_abs(self) -> None:
|
| 136 |
+
assert _hex_to_rgb(color_diverging(5.0, max_abs=1.0)) == DIVERGING_POSITIVE_RGB
|
| 137 |
+
|
| 138 |
+
def test_clamping_below_negative_max_abs(self) -> None:
|
| 139 |
+
assert _hex_to_rgb(color_diverging(-5.0, max_abs=1.0)) == DIVERGING_NEGATIVE_RGB
|
| 140 |
+
|
| 141 |
+
def test_zero_max_abs_returns_neutral(self) -> None:
|
| 142 |
+
c = color_diverging(5.0, max_abs=0)
|
| 143 |
+
assert _hex_to_rgb(c) == (130, 200, 130)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ──────────────────────────────────────────────────────────────────
|
| 147 |
+
# text_color_for_bg
|
| 148 |
+
# ──────────────────────────────────────────────────────────────────
|
| 149 |
+
class TestTextColorForBg:
|
| 150 |
+
def test_low_intensity_dark_text(self) -> None:
|
| 151 |
+
assert text_color_for_bg(0.2) == "#222"
|
| 152 |
+
|
| 153 |
+
def test_high_intensity_white_text(self) -> None:
|
| 154 |
+
assert text_color_for_bg(0.8) == "#fff"
|
| 155 |
+
|
| 156 |
+
def test_threshold_boundary(self) -> None:
|
| 157 |
+
assert text_color_for_bg(0.55) == "#222"
|
| 158 |
+
assert text_color_for_bg(0.56) == "#fff"
|
| 159 |
+
|
| 160 |
+
def test_custom_threshold(self) -> None:
|
| 161 |
+
assert text_color_for_bg(0.4, threshold=0.3) == "#fff"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ──────────────────────────────────────────────────────────────────
|
| 165 |
+
# build_grid_svg
|
| 166 |
+
# ──────────────────────────────────────────────────────────────────
|
| 167 |
+
class TestBuildGridSvg:
|
| 168 |
+
def test_empty_grid_returns_empty_string(self) -> None:
|
| 169 |
+
svg = build_grid_svg(
|
| 170 |
+
n_rows=0,
|
| 171 |
+
n_cols=0,
|
| 172 |
+
row_label_fn=lambda i: "",
|
| 173 |
+
col_label_fn=lambda j: "",
|
| 174 |
+
cell_color_fn=lambda i, j: "#fff",
|
| 175 |
+
)
|
| 176 |
+
assert svg == ""
|
| 177 |
+
|
| 178 |
+
def test_zero_rows_returns_empty(self) -> None:
|
| 179 |
+
svg = build_grid_svg(
|
| 180 |
+
n_rows=0,
|
| 181 |
+
n_cols=3,
|
| 182 |
+
row_label_fn=lambda i: "",
|
| 183 |
+
col_label_fn=lambda j: str(j),
|
| 184 |
+
cell_color_fn=lambda i, j: "#fff",
|
| 185 |
+
)
|
| 186 |
+
assert svg == ""
|
| 187 |
+
|
| 188 |
+
def test_minimal_grid_renders(self) -> None:
|
| 189 |
+
svg = build_grid_svg(
|
| 190 |
+
n_rows=1,
|
| 191 |
+
n_cols=1,
|
| 192 |
+
row_label_fn=lambda i: "row0",
|
| 193 |
+
col_label_fn=lambda j: "col0",
|
| 194 |
+
cell_color_fn=lambda i, j: "#abc123",
|
| 195 |
+
)
|
| 196 |
+
assert "<svg" in svg
|
| 197 |
+
assert "row0" in svg
|
| 198 |
+
assert "col0" in svg
|
| 199 |
+
assert "#abc123" in svg
|
| 200 |
+
|
| 201 |
+
def test_cell_text_displayed_when_provided(self) -> None:
|
| 202 |
+
svg = build_grid_svg(
|
| 203 |
+
n_rows=1,
|
| 204 |
+
n_cols=1,
|
| 205 |
+
row_label_fn=lambda i: "r",
|
| 206 |
+
col_label_fn=lambda j: "c",
|
| 207 |
+
cell_color_fn=lambda i, j: "#fff",
|
| 208 |
+
cell_text_fn=lambda i, j: "0.42",
|
| 209 |
+
)
|
| 210 |
+
assert "0.42" in svg
|
| 211 |
+
|
| 212 |
+
def test_cell_text_omitted_when_none(self) -> None:
|
| 213 |
+
svg = build_grid_svg(
|
| 214 |
+
n_rows=1,
|
| 215 |
+
n_cols=1,
|
| 216 |
+
row_label_fn=lambda i: "r",
|
| 217 |
+
col_label_fn=lambda j: "c",
|
| 218 |
+
cell_color_fn=lambda i, j: "#fff",
|
| 219 |
+
cell_text_fn=lambda i, j: None,
|
| 220 |
+
)
|
| 221 |
+
# Pas de chiffre dans la cellule
|
| 222 |
+
assert ">0.<" not in svg
|
| 223 |
+
|
| 224 |
+
def test_rotate_col_labels(self) -> None:
|
| 225 |
+
svg = build_grid_svg(
|
| 226 |
+
n_rows=1,
|
| 227 |
+
n_cols=1,
|
| 228 |
+
row_label_fn=lambda i: "r",
|
| 229 |
+
col_label_fn=lambda j: "long_label",
|
| 230 |
+
cell_color_fn=lambda i, j: "#fff",
|
| 231 |
+
rotate_col_labels=True,
|
| 232 |
+
)
|
| 233 |
+
assert "rotate(-45" in svg
|
| 234 |
+
|
| 235 |
+
def test_x_axis_title(self) -> None:
|
| 236 |
+
svg = build_grid_svg(
|
| 237 |
+
n_rows=1,
|
| 238 |
+
n_cols=1,
|
| 239 |
+
row_label_fn=lambda i: "r",
|
| 240 |
+
col_label_fn=lambda j: "c",
|
| 241 |
+
cell_color_fn=lambda i, j: "#fff",
|
| 242 |
+
x_axis_title="Position dans le document",
|
| 243 |
+
)
|
| 244 |
+
assert "Position dans le document" in svg
|
| 245 |
+
|
| 246 |
+
def test_html_escape_in_labels(self) -> None:
|
| 247 |
+
svg = build_grid_svg(
|
| 248 |
+
n_rows=1,
|
| 249 |
+
n_cols=1,
|
| 250 |
+
row_label_fn=lambda i: "<script>alert(1)</script>",
|
| 251 |
+
col_label_fn=lambda j: "<b>bold</b>",
|
| 252 |
+
cell_color_fn=lambda i, j: "#fff",
|
| 253 |
+
cell_text_fn=lambda i, j: "<i>italic</i>",
|
| 254 |
+
)
|
| 255 |
+
assert "<script>" not in svg
|
| 256 |
+
assert "<script>" in svg
|
| 257 |
+
assert "<b>" not in svg
|
| 258 |
+
assert "<b>" in svg
|
| 259 |
+
assert "<i>" not in svg
|
| 260 |
+
|
| 261 |
+
def test_grid_dimensions(self) -> None:
|
| 262 |
+
svg = build_grid_svg(
|
| 263 |
+
n_rows=3,
|
| 264 |
+
n_cols=4,
|
| 265 |
+
row_label_fn=lambda i: f"r{i}",
|
| 266 |
+
col_label_fn=lambda j: f"c{j}",
|
| 267 |
+
cell_color_fn=lambda i, j: "#fff",
|
| 268 |
+
)
|
| 269 |
+
# 12 cellules attendues (3×4)
|
| 270 |
+
assert svg.count("<rect") == 12
|
| 271 |
+
# 3 étiquettes de ligne + 4 de colonne
|
| 272 |
+
# On compte les <text> qui ne sont pas dans une cellule (donc qui sont des labels) :
|
| 273 |
+
# 3 labels lignes + 4 labels colonnes = 7. Pas de cell_text_fn donc 0.
|
| 274 |
+
assert svg.count("<text") == 7
|
| 275 |
+
|
| 276 |
+
def test_aria_label_present(self) -> None:
|
| 277 |
+
svg = build_grid_svg(
|
| 278 |
+
n_rows=1,
|
| 279 |
+
n_cols=1,
|
| 280 |
+
row_label_fn=lambda i: "r",
|
| 281 |
+
col_label_fn=lambda j: "c",
|
| 282 |
+
cell_color_fn=lambda i, j: "#fff",
|
| 283 |
+
aria_label="Test heatmap",
|
| 284 |
+
)
|
| 285 |
+
assert 'aria-label="Test heatmap"' in svg
|