PAYATHON 2026

如何清理 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 后,仍可能符合金额格式。

如果已有结构化字段,优先按字段处理:

  1. 保存每个字段的原始字符串。
  2. 普通正文进入通用清理器。
  3. 金额、URL、邮箱和时间戳进入各自的解析与校验流程。
  4. 保存解析结果、原始值及发生的变更;无法确认的值进入异常处理流程。

如果只有一个 info 字符串,可以先识别需要保护的片段并记录原始区间,再把文本拆成受保护片段和普通片段。只清理普通片段,最后按顺序拼接。这样不依赖可能发生冲突的占位符。

前置识别也会漏掉已经损坏的字段,因此清理后仍需再次识别和校验。不要在修改文本后继续使用原来的字符偏移。

字段建议处理方式
金额分别保存币种、原始数值文本和解析值;明确千位符、小数点及地区规则,使用 Decimal,不要自动替换币种
URL保留原始路径、query、大小写和百分号编码;不要整体小写、自动 HTML 解码或压缩重复字符
邮箱使用适合业务范围的解析和校验规则,保留原值;不要对整个地址任意小写或做兼容性折叠
时间戳按预期格式解析,保留时区、偏移量和精度;不要删除无法识别的字符后假装解析成功

允许列表应描述某个字段可以接受的内容,不能用于过滤整篇多语言正文。格式合法只说明可以解析,不能证明 OCR 识别正确。

接入和上线时还应检查什么?

JSON 解析成功,不代表 info 的类型或内容正确。先确认解析结果是对象,再明确要求 info 为字符串或 None;原来的真值判断会直接跳过 0、False 等不符合预期的值。

不要使用 except Exception: pass 隐藏修复失败。应由调用方明确选择报错,或返回带异常状态的原始结果。变更检测使用 cleaned != original,只比较长度会漏掉等长替换。

验证时应覆盖正常文本保持不变、已知 mojibake、换行与表格、多语言格式字符,以及 49、50、51 次重复的边界。另加含长重复路径的 URL、不同币种金额和 Unicode 邮箱等样本,检查受保护字段是否完整保留。也应检查重复执行清理的稳定性,但不能用它代替内容正确性验证。

备注:内容仅供参考。