Picarones / picarones /core /difficulty.py
Claude
fix: Sprint 2 — bugs logiques, architecture runner, qualité code (16 issues)
974df5a unverified
"""Score de difficulté intrinsèque par document.
Le score est indépendant des moteurs OCR : il mesure la difficulté
*objective* d'un document, indépendamment de la qualité des transcriptions.
Formule
-------
difficulty = w_variance * variance_norm
+ w_quality * (1 - image_quality_score)
+ w_density * special_char_density
où :
- variance_norm : variance inter-moteurs du CER, normalisée [0, 1]
- image_quality : score de qualité image [0, 1] (netteté, contraste…)
- special_chars : densité de caractères spéciaux dans la GT [0, 1]
Les poids sont configurables (défaut : 0.4 / 0.35 / 0.25).
Score final : [0, 1] — 0 = document facile, 1 = très difficile.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
# Poids par défaut
_W_VARIANCE = 0.40
_W_QUALITY = 0.35
_W_DENSITY = 0.25
# Caractères spéciaux patrimoniaux (ligatures, abréviations, diacritiques rares)
_SPECIAL_CHARS_RE = re.compile(
r"[ſœæꝑꝓ&]" # ligatures / abréviations médiévales
r"|[ḁ-ỿ]" # Latin Étendu Additionnel (diacritiques rares)
r"|[\u0300-\u036f]" # Diacritiques combinants
r"|[\ufb00-\ufb06]" # Formes de présentation latines (fi, fl…)
r"|[IVXLCDM]{3,}" # Chiffres romains (3+ caractères)
)
@dataclass
class DifficultyScore:
"""Score de difficulté intrinsèque d'un document."""
doc_id: str
score: float
"""Score global [0, 1] — plus élevé = plus difficile."""
variance_component: float
"""Composante variance inter-moteurs [0, 1]."""
quality_component: float
"""Composante qualité image inversée [0, 1]."""
density_component: float
"""Composante densité caractères spéciaux [0, 1]."""
cer_variance: float
"""Variance brute du CER entre moteurs."""
image_quality_score: float
"""Score de qualité image (si disponible, sinon 0.5)."""
special_char_ratio: float
"""Ratio caractères spéciaux / longueur GT."""
def as_dict(self) -> dict:
return {
"doc_id": self.doc_id,
"score": round(self.score, 4),
"variance_component": round(self.variance_component, 4),
"quality_component": round(self.quality_component, 4),
"density_component": round(self.density_component, 4),
"cer_variance": round(self.cer_variance, 6),
"image_quality_score": round(self.image_quality_score, 4),
"special_char_ratio": round(self.special_char_ratio, 4),
}
def _special_char_density(text: str) -> float:
"""Ratio de caractères spéciaux patrimoniaux dans le texte."""
if not text:
return 0.0
matches = len(_SPECIAL_CHARS_RE.findall(text))
return min(1.0, matches / len(text))
def _variance(values: list[float]) -> float:
"""Variance d'une liste de valeurs."""
if len(values) < 2:
return 0.0
mu = sum(values) / len(values)
return sum((v - mu) ** 2 for v in values) / len(values)
def compute_difficulty_score(
doc_id: str,
ground_truth: str,
cer_per_engine: list[float],
image_quality_score: Optional[float] = None,
weights: tuple[float, float, float] = (_W_VARIANCE, _W_QUALITY, _W_DENSITY),
) -> DifficultyScore:
"""Calcule le score de difficulté intrinsèque pour un document.
Parameters
----------
doc_id : identifiant du document
ground_truth : texte de référence
cer_per_engine : liste des CER (un par moteur concurrent)
image_quality_score: score de qualité image [0, 1] (None → 0.5 neutre)
weights : (w_variance, w_quality, w_density)
Returns
-------
DifficultyScore
"""
w_var, w_qual, w_den = weights
# 1. Variance inter-moteurs (normalisée sur [0, 1] — variance max ≈ 0.25)
cer_var = _variance(cer_per_engine)
variance_norm = min(1.0, cer_var / 0.25)
# 2. Qualité image inversée
iq = image_quality_score if image_quality_score is not None else 0.5
iq = max(0.0, min(1.0, iq))
quality_component = 1.0 - iq
# 3. Densité de caractères spéciaux
density = _special_char_density(ground_truth)
# Amplifier légèrement (la densité brute est souvent faible)
density_component = min(1.0, density * 3.0)
# Score combiné
score = (
w_var * variance_norm
+ w_qual * quality_component
+ w_den * density_component
)
score = max(0.0, min(1.0, score))
return DifficultyScore(
doc_id=doc_id,
score=score,
variance_component=variance_norm,
quality_component=quality_component,
density_component=density_component,
cer_variance=cer_var,
image_quality_score=iq,
special_char_ratio=density,
)
def compute_all_difficulties(
doc_ids: list[str],
ground_truths: dict[str, str],
cer_map: dict[str, dict[str, float]],
image_quality_map: Optional[dict[str, float]] = None,
) -> dict[str, DifficultyScore]:
"""Calcule les scores de difficulté pour tous les documents d'un corpus.
Parameters
----------
doc_ids : liste des identifiants de documents
ground_truths : {doc_id → gt_text}
cer_map : {doc_id → {engine_name → cer}}
image_quality_map : {doc_id → quality_score} (facultatif)
Returns
-------
{doc_id → DifficultyScore}
"""
result = {}
for doc_id in doc_ids:
gt = ground_truths.get(doc_id, "")
engine_cers = list(cer_map.get(doc_id, {}).values())
iq = (image_quality_map or {}).get(doc_id)
result[doc_id] = compute_difficulty_score(
doc_id=doc_id,
ground_truth=gt,
cer_per_engine=engine_cers,
image_quality_score=iq,
)
return result
def difficulty_label(score: float) -> str:
"""Retourne un label lisible pour un score de difficulté."""
if score < 0.25:
return "Facile"
if score < 0.50:
return "Modéré"
if score < 0.75:
return "Difficile"
return "Très difficile"
def difficulty_color(score: float) -> str:
"""Retourne une couleur CSS pour un score de difficulté."""
from picarones.core.colors import COLOR_GREEN, COLOR_YELLOW, COLOR_ORANGE, COLOR_RED
if score < 0.25:
return COLOR_GREEN
if score < 0.50:
return COLOR_YELLOW
if score < 0.75:
return COLOR_ORANGE
return COLOR_RED