File size: 9,611 Bytes
d756039
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
266
267
268
269
"""Matrice de confusion unicode pour l'analyse fine des erreurs OCR.

Pour chaque moteur, on calcule quels caractères du GT sont transcrits par
quels caractères OCR (substitutions). Cette "empreinte d'erreur" est
caractéristique de chaque moteur ou pipeline.

Méthode
-------
L'alignement caractère par caractère utilise les opérations d'édition
de la distance de Levenshtein (via difflib.SequenceMatcher), ce qui permet
d'identifier les substitutions, insertions et suppressions.

La matrice est stockée comme un dict de dict :
    ``{gt_char: {ocr_char: count}}``

La valeur spéciale ``"∅"`` (U+2205) représente un caractère vide :
- ``{"a": {"∅": 3}}`` → 'a' supprimé 3 fois dans l'OCR
- ``{"∅": {"x": 2}}`` → 'x' inséré 2 fois dans l'OCR (absent du GT)
"""

from __future__ import annotations

import difflib
from collections import defaultdict
from dataclasses import dataclass, field

# Symbole représentant un caractère absent (insertion / suppression)
EMPTY_CHAR = "∅"

# Caractères non pertinents à ignorer dans la matrice (espaces, sauts de ligne)
_WHITESPACE = set(" \t\n\r")


@dataclass
class ConfusionMatrix:
    """Matrice de confusion unicode pour une paire (GT, OCR)."""

    matrix: dict[str, dict[str, int]] = field(default_factory=dict)
    """Clé externe = char GT ; clé interne = char OCR ; valeur = count."""

    total_substitutions: int = 0
    total_insertions: int = 0
    total_deletions: int = 0

    @property
    def total_errors(self) -> int:
        return self.total_substitutions + self.total_insertions + self.total_deletions

    def top_confusions(self, n: int = 20) -> list[dict]:
        """Retourne les n confusions les plus fréquentes (substitutions uniquement)."""
        pairs: list[tuple[str, str, int]] = []
        for gt_char, ocr_counts in self.matrix.items():
            if gt_char == EMPTY_CHAR:
                continue  # insertions
            for ocr_char, count in ocr_counts.items():
                if ocr_char == EMPTY_CHAR:
                    continue  # suppressions
                if gt_char != ocr_char:
                    pairs.append((gt_char, ocr_char, count))
        pairs.sort(key=lambda x: -x[2])
        return [
            {"gt": gt, "ocr": ocr, "count": cnt}
            for gt, ocr, cnt in pairs[:n]
        ]

    def as_compact_dict(self, min_count: int = 1) -> dict:
        """Sérialise la matrice en éliminant les entrées rares."""
        compact: dict[str, dict[str, int]] = {}
        for gt_char, ocr_counts in self.matrix.items():
            filtered = {
                oc: cnt for oc, cnt in ocr_counts.items()
                if cnt >= min_count
            }
            if filtered:
                compact[gt_char] = filtered
        return {
            "matrix": compact,
            "total_substitutions": self.total_substitutions,
            "total_insertions": self.total_insertions,
            "total_deletions": self.total_deletions,
        }

    def as_dict(self) -> dict:
        return self.as_compact_dict(min_count=1)


def build_confusion_matrix(
    ground_truth: str,
    hypothesis: str,
    ignore_whitespace: bool = True,
    ignore_correct: bool = True,
) -> ConfusionMatrix:
    """Construit la matrice de confusion unicode pour une paire GT/OCR.

    Parameters
    ----------
    ground_truth:
        Texte de référence (vérité terrain).
    hypothesis:
        Texte produit par l'OCR.
    ignore_whitespace:
        Si True, ignore les espaces, tabulations et sauts de ligne.
    ignore_correct:
        Si True, n'enregistre pas les paires identiques (gt_char == ocr_char).
        Par défaut True pour réduire la taille de la matrice.

    Returns
    -------
    ConfusionMatrix
    """
    matrix: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
    n_subs = n_ins = n_dels = 0

    if not ground_truth and not hypothesis:
        return ConfusionMatrix(dict(matrix), 0, 0, 0)

    # SequenceMatcher sur listes de chars pour un alignement précis
    matcher = difflib.SequenceMatcher(None, ground_truth, hypothesis, autojunk=False)

    for tag, i1, i2, j1, j2 in matcher.get_opcodes():
        if tag == "equal":
            if not ignore_correct:
                for ch in ground_truth[i1:i2]:
                    if ignore_whitespace and ch in _WHITESPACE:
                        continue
                    matrix[ch][ch] += 1
        elif tag == "replace":
            # Aligner char par char les séquences de longueurs différentes
            gt_seg = ground_truth[i1:i2]
            oc_seg = hypothesis[j1:j2]
            _align_segments(gt_seg, oc_seg, matrix, ignore_whitespace)
            # Substitutions = longueur commune, surplus = insertions ou suppressions
            n_subs += min(len(gt_seg), len(oc_seg))
            surplus = abs(len(gt_seg) - len(oc_seg))
            if len(gt_seg) > len(oc_seg):
                n_dels += surplus
            else:
                n_ins += surplus
        elif tag == "delete":
            for ch in ground_truth[i1:i2]:
                if ignore_whitespace and ch in _WHITESPACE:
                    continue
                matrix[ch][EMPTY_CHAR] += 1
                n_dels += 1
        elif tag == "insert":
            for ch in hypothesis[j1:j2]:
                if ignore_whitespace and ch in _WHITESPACE:
                    continue
                matrix[EMPTY_CHAR][ch] += 1
                n_ins += 1

    # Convertir defaultdict en dict normal
    result_matrix: dict[str, dict[str, int]] = {
        k: dict(v) for k, v in matrix.items()
    }

    return ConfusionMatrix(
        matrix=result_matrix,
        total_substitutions=n_subs,
        total_insertions=n_ins,
        total_deletions=n_dels,
    )


def _align_segments(
    gt_seg: str,
    oc_seg: str,
    matrix: dict,
    ignore_whitespace: bool,
) -> None:
    """Aligne deux segments de longueurs potentiellement différentes."""
    if not gt_seg:
        for ch in oc_seg:
            if ignore_whitespace and ch in _WHITESPACE:
                continue
            matrix[EMPTY_CHAR][ch] += 1
        return
    if not oc_seg:
        for ch in gt_seg:
            if ignore_whitespace and ch in _WHITESPACE:
                continue
            matrix[ch][EMPTY_CHAR] += 1
        return

    if len(gt_seg) == len(oc_seg):
        # Substitutions 1-pour-1
        for g, o in zip(gt_seg, oc_seg):
            if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE):
                continue
            matrix[g][o] += 1
    else:
        # Longueurs différentes : utiliser SequenceMatcher récursif sur segments courts
        sub = difflib.SequenceMatcher(None, gt_seg, oc_seg, autojunk=False)
        for tag2, i1, i2, j1, j2 in sub.get_opcodes():
            if tag2 == "equal":
                pass
            elif tag2 == "replace":
                # Régression simple : aligner par troncature
                for g, o in zip(gt_seg[i1:i2], oc_seg[j1:j2]):
                    if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE):
                        continue
                    matrix[g][o] += 1
            elif tag2 == "delete":
                for g in gt_seg[i1:i2]:
                    if ignore_whitespace and g in _WHITESPACE:
                        continue
                    matrix[g][EMPTY_CHAR] += 1
            elif tag2 == "insert":
                for o in oc_seg[j1:j2]:
                    if ignore_whitespace and o in _WHITESPACE:
                        continue
                    matrix[EMPTY_CHAR][o] += 1


def aggregate_confusion_matrices(matrices: list[ConfusionMatrix]) -> ConfusionMatrix:
    """Agrège plusieurs matrices de confusion en une seule.

    Utile pour obtenir la matrice agrégée sur l'ensemble du corpus.
    """
    combined: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
    total_subs = total_ins = total_dels = 0

    for cm in matrices:
        for gt_char, ocr_counts in cm.matrix.items():
            for ocr_char, count in ocr_counts.items():
                combined[gt_char][ocr_char] += count
        total_subs += cm.total_substitutions
        total_ins += cm.total_insertions
        total_dels += cm.total_deletions

    return ConfusionMatrix(
        matrix={k: dict(v) for k, v in combined.items()},
        total_substitutions=total_subs,
        total_insertions=total_ins,
        total_deletions=total_dels,
    )


def top_confused_chars(
    matrix: ConfusionMatrix,
    n: int = 15,
    exclude_empty: bool = True,
) -> list[dict]:
    """Retourne les caractères GT les plus souvent confondus.

    Retourne une liste triée par nombre total d'erreurs décroissant :
    ``[{"char": "ſ", "total_errors": 47, "top_substitutes": [...]}, ...]``
    """
    char_stats: dict[str, dict] = {}
    for gt_char, ocr_counts in matrix.matrix.items():
        if exclude_empty and gt_char == EMPTY_CHAR:
            continue
        error_count = sum(
            cnt for oc, cnt in ocr_counts.items()
            if (oc != gt_char) and (not exclude_empty or oc != EMPTY_CHAR)
        )
        if error_count > 0:
            top_subs = sorted(
                [{"ocr": oc, "count": cnt} for oc, cnt in ocr_counts.items() if oc != gt_char],
                key=lambda x: -x["count"],
            )[:5]
            char_stats[gt_char] = {
                "char": gt_char,
                "total_errors": error_count,
                "top_substitutes": top_subs,
            }

    return sorted(char_stats.values(), key=lambda x: -x["total_errors"])[:n]