如何清理 OCR 输出中的 mojibake、控制字符和异常重复?
可以在不更换模型的情况下改进后处理,但当前实现还不适合直接用作通用的生产清理器。建议受控地修复 mojibake,明确字符处理规则,并在改写前隔离关键字段、处理后再次校验。任何清理规则都不能保证恢复 OCR 已经识别错误或丢失的信息。
当前实现有哪些具体问题?
控制字符正则会删除换行,却漏掉零宽字符
[\x00-\x1F\x7F-\x9F] 覆盖 C0、DEL 和 C1 范围,会删除 \n、\r、\t,却不匹配 U+200B 零宽空格、U+00AD soft hyphen 或多数 RTL 格式标记。
删除换行可能导致多行文本拼接,后面的 re.sub(r'\n{3,}', ...) 也就没有换行可处理了。直接删除两个字段之间的控制字符,还可能把字段值连在一起。
unicodedata.category(ch).startswith('C') 的范围又过大:C 包含 Cc、Cf、Cs、Co、Cn,分别涉及控制、格式、代理、私用和未分配码点,不能统一视为垃圾字符。Unicode 分类定义
手工替换表已经存在货币误改
€ 是 € 的一种常见 mojibake 表现,不能直接替换成 ₹。这样会改变币种,即使金额数字完全没变,也属于业务数据损坏。
同样,不能仅凭出现 Â、â 或特殊标点,就判断附近文本一定有编码错误。这些字符也可能是合法内容。
NFKC 和重复压缩都可能丢失有效信息
NFKC 可以把 fl 转为 fl,但也会把 10³ 转为 103,把 ① 转为 1。它不会判断这些区别是否有业务意义,也不会把不同币种转换成同一种货币。一般正文可默认使用 NFC,只有明确接受兼容性折叠的字段才启用 NFKC。Unicode 规范化说明
重复 50 次并不一定意味着 OCR 错误:长数字、URL 路径、令牌和分隔线都可能合法。原正则中的 \s* 还允许跨空格、制表符和换行匹配,容易吞掉文本结构。
ftfy 应该怎样使用?
如果只想修复编码错误,可以优先考虑 fix_encoding(),再单独控制其余清理步骤。fix_text() 默认还会处理 HTML entities、引号、连字和字符宽度等内容,直接使用可能扩大改写范围。ftfy 配置文档
fix_encoding() 仍依赖启发式判断。可以关闭部分较宽松的修复选项,以减少某些误改的机会,但要接受漏修更多问题,也不能保证结果正确。不要对整段文本盲目调用 encode('latin-1').decode('utf-8'),也不要用 errors='ignore' 静默丢弃无法转换的数据。
如果内容已经变成 �,或者模型直接生成了错误数字、币种或文字,通常无法仅靠 Unicode 后处理恢复原值,应保留原始输出并回查图像。
一个保守的正文清理示例
下面的函数用于已经排除关键字段的普通正文。默认保留格式字符,不启用 NFKC,也不压缩重复字符。调用方应另外保存原始文本。
import re
import unicodedata
from typing import Optional
from ftfy import fix_encoding
# Only selected punctuation; never cross whitespace.
_LONG_PUNCTUATION_RUN = re.compile(r'([.!?_*~=\-])\1{49,}')
def normalize_ocr_text(
text: Optional[str],
*,
drop_format_chars=(),
compatibility: bool = False,
collapse_punctuation: bool = False,
) -> Optional[str]:
if text is None:
return None
if not isinstance(text, str):
raise TypeError('OCR info must be a string or None')
if not text:
return text
drop_format_chars = set(drop_format_chars)
for ch in drop_format_chars:
if len(ch) != 1 or unicodedata.category(ch) != 'Cf':
raise ValueError('Only individual Cf characters may be configured')
text = fix_encoding(
text,
restore_byte_a0=False,
replace_lossy_sequences=False,
decode_inconsistent_utf8=False,
)
# Preserve document boundaries before handling remaining controls.
text = text.replace('\r\n', '\n').replace('\r', '\n')
for separator in ('\x85', '\u2028', '\u2029', '\f'):
text = text.replace(separator, '\n')
text = unicodedata.normalize(
'NFKC' if compatibility else 'NFC', text
)
cleaned = []
for ch in text:
category = unicodedata.category(ch)
if category == 'Cs':
raise ValueError('Unpaired surrogate in OCR text')
if ch in drop_format_chars:
continue
if category == 'Cc' and ch not in ('\n', '\t'):
# Avoid silently joining neighboring tokens.
cleaned.append(' ')
else:
cleaned.append(ch)
text = ''.join(cleaned)
if collapse_punctuation:
text = _LONG_PUNCTUATION_RUN.sub(
lambda match: match.group(1) * 3 + '…', text
)
return text
函数把剩余控制字符替换为空格,以减少相邻词直接拼接的情况。这个操作仍可能改变内容,因此不能直接用于金额、邮箱或 URL 字段。若文档包含表格,连续空格和制表符也可能代表列位置,不宜默认折叠。
确认某类文档中的零宽空格和 soft hyphen 都属于噪声后,才可显式启用:
cleaned = normalize_ocr_text(
prose_text,
drop_format_chars=('\u200b', '\u00ad'),
)
不要默认删除全部 Cf。例如 ZWJ、ZWNJ 可能参与文字连接或 emoji 组合,RTL 标记也可能有有效的排版作用。无法判断的格式字符可以先标记,再按语言和文档类型制定规则。
重复压缩会丢失精确内容,适合单独用于生成展示文本。若需要 fl → fl,可为允许改写的正文增加受控连字映射,或单独启用连字修复,不必因此对所有字段使用 NFKC。
关键字段应该先保护,还是后校验?
两步都要做。前置保护可以减少误改,后置校验可以发现异常。仅做后置格式校验无法发现所有语义损坏,例如 ₹66,499 被误改成 €66,499 后,仍可能符合金额格式。
如果已有结构化字段,优先按字段处理:
- 保存每个字段的原始字符串。
- 普通正文进入通用清理器。
- 金额、URL、邮箱和时间戳进入各自的解析与校验流程。
- 保存解析结果、原始值及发生的变更;无法确认的值进入异常处理流程。
如果只有一个 info 字符串,可以先识别需要保护的片段并记录原始区间,再把文本拆成受保护片段和普通片段。只清理普通片段,最后按顺序拼接。这样不依赖可能发生冲突的占位符。
前置识别也会漏掉已经损坏的字段,因此清理后仍需再次识别和校验。不要在修改文本后继续使用原来的字符偏移。
| 字段 | 建议处理方式 |
|---|---|
| 金额 | 分别保存币种、原始数值文本和解析值;明确千位符、小数点及地区规则,使用 Decimal,不要自动替换币种 |
| URL | 保留原始路径、query、大小写和百分号编码;不要整体小写、自动 HTML 解码或压缩重复字符 |
| 邮箱 | 使用适合业务范围的解析和校验规则,保留原值;不要对整个地址任意小写或做兼容性折叠 |
| 时间戳 | 按预期格式解析,保留时区、偏移量和精度;不要删除无法识别的字符后假装解析成功 |
允许列表应描述某个字段可以接受的内容,不能用于过滤整篇多语言正文。格式合法只说明可以解析,不能证明 OCR 识别正确。
接入和上线时还应检查什么?
JSON 解析成功,不代表 info 的类型或内容正确。先确认解析结果是对象,再明确要求 info 为字符串或 None;原来的真值判断会直接跳过 0、False 等不符合预期的值。
不要使用 except Exception: pass 隐藏修复失败。应由调用方明确选择报错,或返回带异常状态的原始结果。变更检测使用 cleaned != original,只比较长度会漏掉等长替换。
验证时应覆盖正常文本保持不变、已知 mojibake、换行与表格、多语言格式字符,以及 49、50、51 次重复的边界。另加含长重复路径的 URL、不同币种金额和 Unicode 邮箱等样本,检查受保护字段是否完整保留。也应检查重复执行清理的稳定性,但不能用它代替内容正确性验证。
备注:内容仅供参考。
