Spaces:
Running
Running
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) | |
| ) | |
| 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 | |