Spaces:
Running
Running
File size: 10,213 Bytes
f593a34 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 | """Couverture MUFI — Sprint 57.
Sprint 57 — A.II.3.3 du plan d'évolution 2026 (clôture axe A.II.3
philologique).
Pourquoi ce module
------------------
La **Medieval Unicode Font Initiative** (MUFI v4.0) standardise les
caractères médiévaux que les éditeurs critiques attendent dans une
transcription fidèle : signes d'abréviation, ligatures, lettres
spéciales (ƿ wynn, þ thorn), ponctuation médiévale, marques
diacritiques rares, etc. Pour les médiévistes, la **couverture
MUFI** d'un moteur OCR/HTR est un critère éditorial central.
Ce module mesure le taux de **caractères MUFI de la GT
correctement restitués** dans l'OCR, après alignement caractère par
caractère (même approche que la précision par bloc Unicode du
Sprint 55).
Détection des caractères MUFI
-----------------------------
La spécification MUFI v4.0 référence ~1300 caractères dans plusieurs
plages Unicode. Plutôt que d'embarquer la liste exhaustive (qui
évolue), on utilise un **set de plages caractéristiques** suffisant
pour les corpus patrimoniaux européens courants :
- PUA principal (U+E000–U+F8FF) : zone usuelle des glyphes MUFI
qui n'ont pas (encore) de point de code Unicode standard.
- Latin Extended-D (U+A720–U+A7FF) : abréviations latines
médiévales (ꝑ, ꝓ, ꝗ, etc.).
- Combining Diacritical Marks Supplement (U+1DC0–U+1DFF) :
diacritiques médiévaux rares (macron suscript, etc.).
- Alphabetic Presentation Forms (U+FB00–U+FB4F) : ligatures
(fi, fl, ff).
- Une **liste explicite** de caractères médiévaux dans les blocs
Latin Extended-A/B/Additional (þ, ð, ƿ, ſ, æ, œ, etc.)
L'utilisateur peut personnaliser via le paramètre ``custom_chars``
de ``compute_mufi_coverage`` pour étendre ou restreindre.
Stratégie de découpage
----------------------
Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
Layout F1 (54), Bloc Unicode (55), Abréviations (56) : couche de
calcul pure d'abord. Le câblage runner et la vue HTML suivent dans
des sprints dédiés.
"""
from __future__ import annotations
import logging
from difflib import SequenceMatcher
from typing import Iterable, Optional
from picarones.core.metric_registry import register_metric
from picarones.core.modules import ArtifactType
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────
# Plages Unicode considérées comme MUFI
# ──────────────────────────────────────────────────────────────────────────
# Triplets (nom, lo, hi) inclusifs. Source : MUFI v4.0 spec
# (https://mufi.info/) + revue manuelle des caractères patrimoniaux
# courants.
_MUFI_RANGES: tuple[tuple[str, int, int], ...] = (
("Private Use Area", 0xE000, 0xF8FF),
("Latin Extended-D", 0xA720, 0xA7FF),
("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
("Alphabetic Presentation Forms", 0xFB00, 0xFB4F),
)
# Caractères MUFI explicites hors plages couvertes par les ranges.
# Surtout des glyphes médiévaux standardisés en Unicode mais qui ne
# sont pas dans le PUA ni dans Latin Extended-D : þ, ð, ƿ, ſ, æ, œ,
# ø, ƀ, ƕ, etc. Liste raisonnée pour les corpus européens médiévaux.
_MUFI_EXPLICIT_CHARS: frozenset[str] = frozenset(
[
# Lettres médiévales standard
"þ", "Þ", # thorn — vieil anglais, islandais
"ð", "Ð", # eth — vieil anglais, islandais
"ƿ", "Ƿ", # wynn — vieil anglais
"ſ", # s long médiéval (déjà U+017F)
"æ", "Æ", # ash
"œ", "Œ", # ethel
"ø", "Ø", # o barré
# Lettres rares avec barré (pour préfixes abréviés)
"ƀ", # b barré
"ŧ", # t barré
"đ", # d barré
"ħ", # h barré
# Yogh
"ȝ", "Ȝ",
# Autres signes médiévaux courants
"ꜿ", # con
# Note : la liste est volontairement courte ; pour étendre,
# l'utilisateur peut passer ``custom_chars`` à
# ``compute_mufi_coverage``.
]
)
def is_mufi_char(char: str, custom_chars: Optional[frozenset[str]] = None) -> bool:
"""Retourne ``True`` si ``char`` est considéré comme MUFI.
Reconnaît :
- les caractères dans les plages Unicode MUFI (``_MUFI_RANGES``),
- les caractères de la liste explicite (``_MUFI_EXPLICIT_CHARS``),
- tout caractère supplémentaire fourni via ``custom_chars``.
Pour une chaîne multi-caractères, seul le premier code-point
est considéré.
"""
if not char:
return False
cp = ord(char[0])
for _name, lo, hi in _MUFI_RANGES:
if lo <= cp <= hi:
return True
if char[0] in _MUFI_EXPLICIT_CHARS:
return True
if custom_chars and char[0] in custom_chars:
return True
return False
# ──────────────────────────────────────────────────────────────────────────
# Calcul de couverture MUFI
# ──────────────────────────────────────────────────────────────────────────
def compute_mufi_coverage(
reference: Optional[str],
hypothesis: Optional[str],
custom_chars: Optional[Iterable[str]] = None,
) -> dict:
"""Calcule la couverture MUFI : taux de caractères MUFI de la GT
correctement restitués dans l'hypothèse.
Parameters
----------
reference:
Texte GT.
hypothesis:
Texte produit par l'OCR.
custom_chars:
Itérable optionnel de caractères supplémentaires à considérer
comme MUFI (utile pour les éditeurs ayant une convention
propre). Chaque entrée doit être un caractère unique.
Returns
-------
dict
``{
"n_mufi_chars_reference": int, # caractères MUFI dans la GT
"n_mufi_chars_preserved": int, # MUFI restitués correctement
"coverage": float, # ∈ [0, 1] ou 0 si N=0
"per_char": {char: {"total", "preserved", "coverage"}},
"missed_chars": list[str], # caractères MUFI ratés
}``
Cas dégénérés
-------------
- GT vide ou sans caractère MUFI → ``coverage = 0`` (convention :
pas de récompense gratuite).
- Hyp vide + MUFI dans GT → ``coverage = 0``.
- GT et hyp identiques avec MUFI → ``coverage = 1``.
"""
ref = reference or ""
hyp = hypothesis or ""
extra: Optional[frozenset[str]] = (
frozenset(c for c in custom_chars if c) if custom_chars else None
)
# 1. Identifier les positions MUFI dans la GT
mufi_positions = [i for i, ch in enumerate(ref) if is_mufi_char(ch, extra)]
n_total = len(mufi_positions)
if n_total == 0:
return {
"n_mufi_chars_reference": 0,
"n_mufi_chars_preserved": 0,
"coverage": 0.0,
"per_char": {},
"missed_chars": [],
}
# 2. Aligner via SequenceMatcher (même méthode que Sprint 55)
matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
correct_positions: set[int] = set()
for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
if op == "equal":
correct_positions.update(range(i1, i2))
# 3. Compter par caractère
per_char_total: dict[str, int] = {}
per_char_preserved: dict[str, int] = {}
missed: list[str] = []
for i in mufi_positions:
ch = ref[i]
per_char_total[ch] = per_char_total.get(ch, 0) + 1
if i in correct_positions:
per_char_preserved[ch] = per_char_preserved.get(ch, 0) + 1
else:
missed.append(ch)
n_preserved = sum(per_char_preserved.values())
per_char = {
ch: {
"total": per_char_total[ch],
"preserved": per_char_preserved.get(ch, 0),
"coverage": (
per_char_preserved.get(ch, 0) / per_char_total[ch]
if per_char_total[ch] > 0
else 0.0
),
}
for ch in sorted(per_char_total)
}
return {
"n_mufi_chars_reference": n_total,
"n_mufi_chars_preserved": n_preserved,
"coverage": n_preserved / n_total,
"per_char": per_char,
"missed_chars": missed,
}
def mufi_coverage(
reference: Optional[str], hypothesis: Optional[str],
) -> float:
"""Raccourci : retourne la couverture MUFI globale ∈ [0, 1]."""
return compute_mufi_coverage(reference, hypothesis)["coverage"]
# ──────────────────────────────────────────────────────────────────────────
# Enregistrement dans le registre typé (Sprint 34)
# ──────────────────────────────────────────────────────────────────────────
@register_metric(
name="mufi_coverage",
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
description=(
"Taux de caractères MUFI (Medieval Unicode Font Initiative) "
"de la GT correctement restitués dans l'OCR. Critère "
"éditorial central pour les médiévistes."
),
higher_is_better=True,
tags={"text", "mufi", "philology", "medieval"},
)
def _registered_mufi_coverage(reference: str, hypothesis: str) -> float:
return mufi_coverage(reference, hypothesis)
__all__ = [
"is_mufi_char",
"compute_mufi_coverage",
"mufi_coverage",
]
|