1.5 扫描件浅色斜铺文字水印:像素级 OCR 验证与掩膜修复

1.5 扫描件浅色斜铺文字水印:像素级 OCR 验证与掩膜修复

清印 ClearMark 系列第 5 篇 · 共 12 篇

前两篇我们讲了矢量 PDF 的内容流解析与多策略检测。矢量 PDF 的水印是"可分离"的------文字有 BT...ET 容器,图片有 XObject 引用,擦除干净就行。

但扫描件不一样。扫描件的水印是 烤进图片像素 的------文字被光栅化成像素,浅灰色、半透明、45° 斜铺,整页覆盖。它既不是 PDF 注释,也不是 XObject,是图片像素本身。矢量 PDF 那套"擦除字节区间"的招数在这里完全失效。

这一篇我们讲怎么对抗这种水印。整个项目最难的算法就在这里------PdfScannedProcessor._tiled_light_text_detect。我会从图像分割、形态学操作、整页旋转 OCR、像素掩膜修复,一步步拆开讲。

一、问题的本质

先看一个典型场景:用户用扫描全能王扫描了一份合同,导出 PDF。每页右下角有"由 CamScanner 扫描"------这个还能用规则库检测。但更恶心的场景是:整页覆盖一层 45° 斜铺的浅灰色"机密""内部使用"字样,每个字大约 60pt,间距 200pt,覆盖整张扫描图。

这种水印的特征:

  1. 像素级:文字已经光栅化,不是 PDF 文字指令,无法用内容流解析。
  2. 浅色:灰度值通常 180~220,对比白纸(255)只差 30~50,肉眼可见但不强。
  3. 半透明:水印颜色和正文颜色叠加,水印下方可能有正文黑字。
  4. 斜铺:45° 或 30° 旋转,覆盖整页。
  5. 重复:整页多处出现相同文字(水印本来就是平铺的)。

对抗它的策略必须是 像素级 的:

  • 检测:分割浅色像素 → 形态学分组 → 旋转 OCR 验证 → 生成像素掩膜
  • 修复:只修复掩膜标记的像素,不动其他像素

二、整体流程

PdfScannedProcessor._tiled_light_text_detect 是核心入口:

python 复制代码
def _tiled_light_text_detect(self, bgr: np.ndarray, page_idx: int,
                             xref: int,
                             keywords: Optional[List[str]] = None
                             ) -> List[WatermarkCandidate]:
    """检测烤入扫描图的浅色斜铺文字水印

    流程:浅灰笔画分割 → 形态学文字行分组(要求角度一致、
    多处重复)→ 整页旋转一次 OCR 验证 → 每个确认实例生成
    携带像素掩膜的候选,移除时只修复笔画像素。
    """
    if bgr.ndim == 3:
        gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
        hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
        sat = hsv[:, :, 1]
    else:
        gray = bgr
        sat = None

    bg, groups, core = self._light_text_geometry(gray, sat)
    if not groups or core is None:
        return []

    angle = groups[0][4]
    dark_raw = (gray < bg - 100).astype(np.uint8)
    phrase, hit = self._verify_tiled_text_ocr(
        bgr, gray, bg, angle, groups, core, dark_raw, keywords)

    if keywords:
        accepted = [(gi, 0.9) for gi, ok in enumerate(hit) if ok]
        if not accepted:
            return []
    else:
        hit_n = sum(hit)
        if hit_n >= 2:
            accepted = [(gi, 0.85 if ok else 0.68)
                        for gi, ok in enumerate(hit)]
        elif hit_n == 1:
            accepted = [(gi, 0.55) for gi in range(len(groups))]
        elif len(groups) >= 3:
            # OCR 没读出来,但 ≥3 组同方向、尺寸接近:
            # 低置信度列出待人工确认
            sizes = np.array([g[2] * g[3] for g in groups])
            if (max(g[4] for g in groups)
                    - min(g[4] for g in groups) <= 6
                    and sizes.std() / max(1.0, sizes.mean()) < 0.5):
                accepted = [(gi, 0.45) for gi in range(len(groups))]
            else:
                return []
        else:
            return []

    # 修复掩膜:比检测带稍宽,覆盖抗锯齿边缘
    ...

主流程六步:

  1. 灰度化 + HSV 分离饱和度:浅色水印的特征是低饱和度。
  2. _light_text_geometry:分割浅色笔画,形态学分组。
  3. _verify_tiled_text_ocr:整页旋转一次,OCR 验证是否真是水印文字。
  4. 置信度分级:≥2 个分组命中→高置信度;1 个命中→中;OCR 失败但 ≥3 个分组同方向同尺寸→低置信度待确认。
  5. 生成像素掩膜:每个分组生成一个掩膜,标记待修复像素。
  6. 输出候选:每个候选携带掩膜,移除时只修复掩膜像素。

下面逐步拆解。

三、笔画分割:_light_text_geometry

这是整个算法的几何引擎:

python 复制代码
def _light_text_geometry(self, gray: np.ndarray,
                         sat: Optional[np.ndarray]):
    """浅色文字笔画分割 → 形态学分组

    返回 (bg, groups, core)
    - bg: 估计的纸张白度
    - groups: [(x,y,w,h,angle), ...] 文字行级分组
    - core: 笔画级掩膜(含正文邻域排除/噪点清理)
    无法构成"重复斜铺"结构时返回 (None, [], None)
    """
    h, w = gray.shape
    light_pixels = gray[gray >= 200]
    if light_pixels.size < h * w * 0.2:
        return None, [], None
    # 纸张白度:亮像素直方图(4 级分箱)的众数
    hist = np.bincount((light_pixels // 4).astype(np.int64),
                       minlength=64)
    bg = float(np.argmax(hist) * 4 + 2)

    lo, hi = bg - 30, bg - 6            # 笔画核心带
    core = (gray >= lo) & (gray <= hi)
    if sat is not None:
        core &= sat < 32                # 低饱和(灰色)

    # 排除正文黑字及其抗锯齿邻域
    dark = (gray < bg - 100).astype(np.uint8)
    dark_d = cv2.dilate(
        dark, cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)))
    core = (core & (dark_d == 0)).astype(np.uint8)
    core = cv2.morphologyEx(
        core, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))

    # 笔画 → 文字行分组
    grouped = cv2.dilate(
        core, cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)))
    n, labels, stats, _ = cv2.connectedComponentsWithStats(grouped, 8)
    groups = []
    for i in range(1, n):
        x, y, gw, gh, area = stats[i]
        if area < 2000 or max(gw, gh) < 70:
            continue
        pts = np.column_stack(
            np.where((labels == i) & (core > 0))
        )[:, ::-1].astype(np.float32)
        if len(pts) < 20:
            continue
        (_, _), (rw, rh), ang = cv2.minAreaRect(pts)
        if rw < rh:
            ang += 90
        ang = self._normalize_angle(ang)
        groups.append((int(x), int(y), int(gw), int(gh), float(ang)))

    if len(groups) < 2:
        return bg, [], core
    # 角度一致性:取中位数,剔除离群方向
    angs = np.array([g[4] for g in groups])
    dom = float(np.median(angs))
    groups = [g for g in groups if abs(g[4] - dom) <= 12]
    if len(groups) < 2:
        return bg, [], core

    # 合并被正文隔断的同一行分组
    merged = []
    used = [False] * len(groups)
    for i, gi in enumerate(groups):
        if used[i]:
            continue
        x, y, x1, y1 = gi[0], gi[1], gi[0] + gi[2], gi[1] + gi[3]
        used[i] = True
        changed = True
        while changed:
            changed = False
            for j, gj in enumerate(groups):
                if used[j] or abs(gj[4] - dom) > 10:
                    continue
                gx0, gy0 = gj[0], gj[1]
                gx1, gy1 = gj[0] + gj[2], gj[1] + gj[3]
                gap = max(0, x - gx1, gx0 - x1) \
                    + max(0, y - gy1, gy0 - y1)
                if gap < 70:
                    x, y = min(x, gx0), min(y, gy0)
                    x1, y1 = max(x1, gx1), max(y1, gy1)
                    used[j] = True
                    changed = True
        merged.append((x, y, x1 - x, y1 - y, dom))
    return bg, merged, core

3.1 估计纸张白度

python 复制代码
light_pixels = gray[gray >= 200]
if light_pixels.size < h * w * 0.2:
    return None, [], None
hist = np.bincount((light_pixels // 4).astype(np.int64), minlength=64)
bg = float(np.argmax(hist) * 4 + 2)

为什么要估计纸张白度?因为不同扫描件的纸张颜色不一样------有些白纸是 250,有些泛黄是 220,有些扫描仪自动增强后是 255。不能硬编码一个阈值,必须从图像本身估计。

策略:取所有 ≥200 的像素(亮像素),按 4 级分箱做直方图,众数就是纸张白度。这个值会被后续用作笔画分割的基准。

如果亮像素占比 < 20%,说明这张图整体偏暗(可能是黑底白字扫描件),不是常规扫描件,直接返回空。

3.2 笔画核心带分割

python 复制代码
lo, hi = bg - 30, bg - 6            # 笔画核心带
core = (gray >= lo) & (gray <= hi)
if sat is not None:
    core &= sat < 32                # 低饱和(灰色)

笔画核心带是 [bg-30, bg-6]------比白纸暗 6~30 的灰度。比 bg-6 更接近白纸的是抗锯齿边缘,比 bg-30 更暗的可能是正文黑字的边缘。中间这 24 级灰度就是水印笔画的"核心"。

加上 sat < 32 的低饱和度约束------扫描件正文如果是黑色,饱和度也低;但彩色水印(如蓝色公司 logo)饱和度高,会被过滤掉。

3.3 排除正文黑字邻域

python 复制代码
dark = (gray < bg - 100).astype(np.uint8)
dark_d = cv2.dilate(
    dark, cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)))
core = (core & (dark_d == 0)).astype(np.uint8)

这是关键的 正文保护 策略。水印文字可能和正文文字交叠------水印斜穿过正文时,笔画下方可能有正文黑字。如果我们直接修复所有浅灰像素,会把正文黑字的抗锯齿边缘一起涂白,导致正文模糊甚至断裂。

策略:先找出"明显是正文"的深色像素(gray < bg-100,即比白纸暗 100 以上),用 7×7 矩形核膨胀得到正文区域 + 7 像素 padding。然后从 core 里 减去 这个区域。

结果:core 只包含"浅灰且不在正文附近"的像素。这是水印笔画的纯净集合。

3.4 形态学开运算清理噪点

python 复制代码
core = cv2.morphologyEx(
    core, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))

2×2 开运算(先腐蚀后膨胀)去除孤立噪点。扫描件常有 JPEG 块效应产生的零散浅灰像素,这一步清理掉。

3.5 笔画 → 文字行分组

python 复制代码
grouped = cv2.dilate(
    core, cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)))
n, labels, stats, _ = cv2.connectedComponentsWithStats(grouped, 8)
groups = []
for i in range(1, n):
    x, y, gw, gh, area = stats[i]
    if area < 2000 or max(gw, gh) < 70:
        continue
    pts = np.column_stack(
        np.where((labels == i) & (core > 0))
    )[:, ::-1].astype(np.float32)
    if len(pts) < 20:
        continue
    (_, _), (rw, rh), ang = cv2.minAreaRect(pts)
    if rw < rh:
        ang += 90
    ang = self._normalize_angle(ang)
    groups.append((int(x), int(y), int(gw), int(gh), float(ang)))

笔画级掩码 core 是离散的------每个汉字的笔画之间有空隙,不会自然连成"行"。我们用 15×15 矩形核膨胀,把同一行/同一组的笔画连成一个连通域。

然后对每个连通域:

  1. 用 cv2.minAreaRect 求最小外接矩形,得到旋转角。
  2. 如果宽度 < 高度,说明矩形是竖向的,旋转角加 90° 归一化到水平方向。
  3. _normalize_angle 把任意角度归一到 -45, 45。
python 复制代码
@staticmethod
def _normalize_angle(ang: float) -> float:
    """把任意角度归一化到 [-45, 45]"""
    while ang > 45:
        ang -= 90
    while ang < -45:
        ang += 90
    return ang

为什么要归一到 -45, 45?因为文字行的"方向"是周期 90° 的------0° 和 90° 是同一行(横排 vs 竖排),45° 和 -45° 也是同一方向(45° 斜铺和 -45° 斜铺其实是同一种水印,只是镜像)。归一化后方便做角度一致性比较。

3.6 角度一致性筛选

python 复制代码
if len(groups) < 2:
    return bg, [], core
# 角度一致性:取中位数,剔除离群方向
angs = np.array([g[4] for g in groups])
dom = float(np.median(angs))
groups = [g for g in groups if abs(g[4] - dom) <= 12]
if len(groups) < 2:
    return bg, [], core

要求至少 2 个分组(单个浅色区域不构成"斜铺水印")。取所有分组角度的中位数作为主方向,剔除偏离主方向 >12° 的分组------这些可能是误判(比如表格线条、扫描歪斜的正文)。

这一步把"散落的浅色区域"过滤成"方向一致的多个分组",是水印识别的关键几何特征。

3.7 合并被正文隔断的同一行

最后一步合并:

python 复制代码
merged = []
used = [False] * len(groups)
for i, gi in enumerate(groups):
    if used[i]:
        continue
    x, y, x1, y1 = gi[0], gi[1], gi[0] + gi[2], gi[1] + gi[3]
    used[i] = True
    changed = True
    while changed:
        changed = False
        for j, gj in enumerate(groups):
            if used[j] or abs(gj[4] - dom) > 10:
                continue
            ...
            gap = max(0, x - gx1, gx0 - x1) \
                + max(0, y - gy1, gy0 - y1)
            if gap < 70:
                x, y = min(x, gx0), min(y, gy0)
                x1, y1 = max(x1, gx1), max(y1, gy1)
                used[j] = True
                changed = True
    merged.append((x, y, x1 - x, y1 - y, dom))

水印文字一行可能被正文隔断成多个分组------比如"机密"两字中间刚好有正文穿过,被分成两段。这一步把同方向、间距 <70pt 的分组合并成一个完整的行。

while changed 循环是关键------合并一次后,新合并的分组边界变大,可能又能合并新的分组,必须循环到没有变化为止。

四、整页旋转 OCR 验证

几何分组完成后,必须用 OCR 验证这些浅色区域真的是文字,而不是图表线条或者扫描污渍。

但直接对原图 OCR 不行------水印是斜的,OCR 引擎识别斜文字的准确率极低。必须先把整页按主角度旋转到正向,再做 OCR。

4.1 增强 + 旋转

python 复制代码
def _verify_tiled_text_ocr(self, bgr, gray, bg, angle, groups,
                           core, dark, keywords):
    """整页按主角度旋转增强后做一次 OCR,映射回各分组"""
    lo, hi = bg - 45, bg - 3
    enh = np.clip((gray.astype(np.float32) - lo) * 255.0
                  / max(1.0, hi - lo), 0, 255).astype(np.uint8)
    h, w = enh.shape
    m_fwd = cv2.getRotationMatrix2D((w / 2, h / 2), -angle, 1.0)
    rot = cv2.warpAffine(enh, m_fwd, (w, h), borderValue=255)
    rot_bgr = cv2.cvtColor(rot, cv2.COLOR_GRAY2BGR)
    ocr_items = self._run_ocr(rot_bgr)
    ...

两步预处理:

  1. 增强对比度 :把笔画核心带 [bg-45, bg-3] 线性映射到 0, 255。水印本来浅色对比度低,OCR 几乎读不出来;增强后笔画变成深色(接近 0),背景变成白色(255),OCR 才能识别。
  2. 整页旋转 :用 cv2.getRotationMatrix2D 得到旋转矩阵,cv2.warpAffine 应用旋转。-angle 是反向旋转------水印角度是 +45°,旋转 -45° 就把水印转正。

borderValue=255 表示旋转后图像外的区域填充白色(与纸张一致),避免黑色边框干扰 OCR。

4.2 OCR 框映射回原图

OCR 在旋转后的图像上识别出文字框,但要映射回原始图像坐标才能和几何分组对应。需要计算旋转的逆变换:

python 复制代码
m_inv = cv2.invertAffineTransform(m_fwd)

# 每个分组内的 CJK 片段 / 关键词命中
group_frags: List[List[str]] = [[] for _ in groups]
group_kw = [False] * len(groups)
for (bx0, by0, bx1, by1), text, conf in ocr_items:
    if conf < 0.45:
        continue
    cx_r, cy_r = (bx0 + bx1) / 2, (by0 + by1) / 2
    ox = m_inv[0, 0] * cx_r + m_inv[0, 1] * cy_r + m_inv[0, 2]
    oy = m_inv[1, 0] * cx_r + m_inv[1, 1] * cy_r + m_inv[0, 2]
    for gi, (gx, gy, gw, gh, _) in enumerate(groups):
        if not (gx - 20 <= ox <= gx + gw + 20
                and gy - 20 <= oy <= gy + gh + 20):
            continue
        # 映射框内的浅灰笔画密度 / 深色正文密度
        ...
        if light_dens < 0.015 or dark_dens > 0.04:
            break
        if keywords and any(k in text for k in keywords):
            group_kw[gi] = True
        group_frags[gi].extend(self._cjk_runs(text))
        break

对每个 OCR 识别框:

  1. 用逆变换矩阵把旋转后图像坐标映射回原图坐标。
  2. 找到原图中包含该坐标的分组。
  3. 关键过滤:要求 OCR 框内"浅灰笔画密度 ≥0.015 且深色正文密度 ≤0.04"------这是为了避免把斜穿水位的正文文字误识别为水印。

这一步过滤极重要。OCR 在旋转后的图像上可能识别到正文的斜文字(因为正文也被旋转了)。但如果一个 OCR 框里几乎没有浅灰像素(说明它不含水印笔画),或者深色正文像素太多(说明它主要是正文),都不要。

4.3 水印短语拼合

最后,把分散在多个分组里的 OCR 片段拼合成完整的水印文字:

python 复制代码
# 贪心最短超串拼合水印短语
frags = sorted({f for fl in group_frags for f in fl},
               key=len, reverse=True)

def overlap(a: str, b: str) -> int:
    k = min(len(a), len(b))
    while k >= 2:
        if a[-k:] == b[:k]:
            return k
        if b[-k:] == a[:k]:
            return -k
        k -= 1
    return 0

merged = list(frags)
while len(merged) > 1:
    best = None
    for i in range(len(merged)):
        for j in range(len(merged)):
            if i == j:
                continue
            ov = overlap(merged[i], merged[j])
            if ov:
                best = (abs(ov), i, j, ov)
                break
        if best:
            break
    if not best:
        break
    _, i, j, ov = best
    a, b = merged[i], merged[j]
    comb = a + b[ov:] if ov > 0 else b + a[-ov:]
    merged = [x for k, x in enumerate(merged)
              if k not in (i, j)] + [comb]
phrase = max(merged, key=len) if merged else ''

这是 贪心最短超串(Shortest Common Superstring) 算法的水印版本。多个分组可能各自识别出"机"、"机密"、"密内"、"内部使用"------把它们按重叠部分拼成"机密内部使用"。

overlap 函数检查两个片段的后缀/前缀是否匹配,返回重叠长度(正数表示 a 的后缀和 b 的前缀重叠,负数反之)。每次找重叠最长的两个片段合并,直到没有重叠可合并。

这个算法不是完美的------它假设水印文字是连续的,且 OCR 识别正确。实际中 OCR 可能识别错字(比如"机密"识别成"机蜜"),拼合会失败。所以 phrase 是"尽力而为"的产物,不命中也不影响移除(移除靠的是几何分组和像素掩膜,不靠 OCR 文本)。

4.4 多分组共享 2-gram 验证

为了进一步避免误判,我们要求 OCR 片段在 ≥2 个分组 出现(共享 2-gram)才算水印证据:

python 复制代码
# 片段需在 ≥2 个分组出现(共享 2-gram)才算水印证据
n = len(groups)
grams = [set() for _ in range(n)]
for gi, frags in enumerate(group_frags):
    for f in frags:
        grams[gi].update(f[i:i + 2] for i in range(len(f) - 1))
hit = [False] * n
for i in range(n):
    for j in range(i + 1, n):
        shared = grams[i] & grams[j]
        if shared:
            hit[i] = hit[j] = True

每个分组把自己的 OCR 片段拆成 2-gram("机密" → {"机密"},"内部使用" → {"内部", "部使", "使用"})。如果两个分组有共同的 2-gram,说明它们识别到了相同的文字------这是水印"重复"特征的强证据。

单个分组识别到文字不算数------可能是偶然的正文文字。两个以上分组识别到相同文字才算水印。

五、置信度分级

OCR 验证完成后,根据命中情况分级:

python 复制代码
if keywords:
    # 规则库模式:只要命中关键词就高置信度
    accepted = [(gi, 0.9) for gi, ok in enumerate(hit) if ok]
    if not accepted:
        return []
else:
    # 自动模式:
    hit_n = sum(hit)
    if hit_n >= 2:
        # ≥2 个分组命中:命中者高置信度,未命中者中置信度
        accepted = [(gi, 0.85 if ok else 0.68)
                    for gi, ok in enumerate(hit)]
    elif hit_n == 1:
        # 1 个分组命中:所有分组中置信度(可能 OCR 漏检)
        accepted = [(gi, 0.55) for gi in range(len(groups))]
    elif len(groups) >= 3:
        # OCR 没读出来,但 ≥3 组同方向、尺寸接近:
        # 低置信度列出待人工确认
        sizes = np.array([g[2] * g[3] for g in groups])
        if (max(g[4] for g in groups)
                - min(g[4] for g in groups) <= 6
                and sizes.std() / max(1.0, sizes.mean()) < 0.5):
            accepted = [(gi, 0.45) for gi in range(len(groups))]
        else:
            return []
    else:
        return []

四个分支:

命中数 置信度 含义
≥2 个分组命中 0.85 (命中) / 0.68 (未命中) 强水印证据
1 个分组命中 0.55 可能水印,OCR 可能漏检其他分组
0 命中但 ≥3 组同方向同尺寸 0.45 几何强特征,OCR 失败,待人工确认
其他 不输出 不构成水印

第三档(0.45)是兜底------有些水印因为字体太特殊、对比度太低,OCR 完全读不出来,但几何特征(≥3 个分组、同方向、同尺寸)已经强烈暗示是水印。这种情况输出低置信度候选,让用户决定。

六、生成像素掩膜

最后一步是生成每个分组的像素掩膜:

python 复制代码
# 修复掩膜:比检测带稍宽,覆盖抗锯齿边缘
lo2, hi2 = bg - 34, bg - 3
band = (gray >= lo2) & (gray <= hi2)
if sat is not None:
    band &= sat < 32
dark_d = cv2.dilate(
    (gray < bg - 100).astype(np.uint8),
    cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)))
band = (band & (dark_d == 0)).astype(np.uint8)

h, w = gray.shape
stroke_rgb = None
out: List[WatermarkCandidate] = []
for gi, conf in accepted:
    gx, gy, gw, gh, ang = groups[gi]
    pad = 14
    x0 = max(0, gx - pad)
    y0 = max(0, gy - pad)
    x1 = min(w, gx + gw + pad)
    y1 = min(h, gy + gh + pad)
    pm = np.zeros((h, w), np.uint8)
    region = band[y0:y1, x0:x1]
    region = cv2.morphologyEx(
        region, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))
    pm[y0:y1, x0:x1] = region
    pm = cv2.dilate(
        pm, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)))
    # 去掉孤立噪点
    ncc, lcc, stcc, _ = \
        cv2.connectedComponentsWithStats((pm > 0).astype(np.uint8), 8)
    keep = np.zeros_like(pm)
    for ci in range(1, ncc):
        if stcc[ci, cv2.CC_STAT_AREA] >= 4:
            keep[lcc == ci] = 255
    pm = keep
    if pm.sum() < 200 * 255:
        continue
    ...

掩膜带 [bg-34, bg-3] 比检测带 [bg-30, bg-6] 稍宽------上下各扩 4 级灰度,覆盖笔画的抗锯齿边缘。否则修复后边缘会留下浅色边框。

掩膜生成步骤:

  1. 限定到分组区域 :pm = np.zeros((h, w)),只在 [y0:y1, x0:x1] 范围内取值。
  2. 2×2 开运算 清理噪点。
  3. 3×3 膨胀 扩大掩膜覆盖区域,确保修复完整。
  4. 连通域面积过滤:去掉面积 <4 的孤立噪点。
  5. 面积阈值 :pm.sum() < 200 * 255 跳过------掩膜像素太少说明这一组没有实际笔画,可能是误判。

最终候选携带 mask=pm:

python 复制代码
cand = WatermarkCandidate(
    kind=WatermarkKind.TEXT,
    page_index=page_idx,
    bbox=(float(gx), float(gy),
          float(gx + gw), float(gy + gh)),
    origin=(float(gx), float(gy)),
    detail=f'{name}({ang:.0f}°)',
    confidence=conf,
    rotation=ang,
    text=phrase,
    color=stroke_rgb,
    alpha=1.0,              # 走像素级 inpaint,不做颜色反演
    xref=xref,
    mask=pm,
)

alpha=1.0 是个重要标记------告诉移除阶段"这个候选走像素级修复,不要做颜色反演"。颜色反演适合纯色覆盖层(如灰色半透明矩形),但浅色斜铺文字是离散笔画,颜色反演会破坏笔画之外的区域。

七、移除:邻域最大亮度填充

最后讲移除策略。PdfScannedProcessor.remove 收到带掩膜的候选后:

python 复制代码
def remove(self, candidates, output_path, progress_callback=None) -> int:
    ...
    by_xref: Dict[int, List[WatermarkCandidate]] = defaultdict(list)
    for c in candidates:
        xref = getattr(c, 'xref', -1)
        by_xref[xref].append(c)

    removed = 0
    for xref, cands in by_xref.items():
        if xref < 0:
            continue
        try:
            bgr = self._extract_image_bgr(xref)
        except Exception:
            continue
        h, w = bgr.shape[:2]
        fill_mask = np.zeros((h, w), dtype=np.uint8)
        mask = np.zeros((h, w), dtype=np.uint8)
        has_fill = False
        for c in cands:
            pm = getattr(c, 'mask', None)
            if pm is not None and getattr(pm, 'shape', None) == (h, w):
                # 像素级掩膜(浅色斜铺文字水印):只修复笔画像素
                fill_mask = np.maximum(
                    fill_mask, (pm > 0).astype(np.uint8) * 255)
                has_fill = True
                continue
            x0, y0, x1, y1 = c.bbox
            ...
            mask[iy0:iy1, ix0:ix1] = 255
        if not mask.any() and not has_fill:
            continue

        result = bgr
        # 浅色斜铺文字:掩膜像素周围均为白纸
        if has_fill:
            result = self._fill_with_local_background(result, fill_mask)

        # 矩形区域(用户框选/聚类)走图像修复
        if mask.any():
            ...
            result = inpaint(result, mask, method='telea', expand=3)

        self._replace_image(xref, result)
        removed += len(cands)

按 xref 分组(同一图片的水印一起处理),构建两种掩膜:

  • fill_mask:像素级掩膜(来自浅色斜铺文字检测)。
  • mask:矩形区域掩膜(来自用户框选/颜色聚类)。

两者用不同修复策略。

7.1 浅色斜铺文字:邻域最大亮度填充

python 复制代码
@staticmethod
def _fill_with_local_background(bgr: np.ndarray,
                                fill_mask: np.ndarray) -> np.ndarray:
    """用掩膜邻域的最大亮度(白纸色)填充笔画像素

    浅色水印笔画已确保与正文黑字保持间距,邻域最亮像素即为
    纸张底色;逐通道形态学膨胀取邻域最大值。比 Telea inpaint
    快两个数量级,且不会把邻近黑字拉成墨痕。
    """
    if not fill_mask.any():
        return bgr
    k = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9))
    m = fill_mask > 0
    if bgr.ndim == 2:
        local_max = cv2.dilate(bgr, k)
        out = bgr.copy()
        out[m] = local_max[m]
        return out
    out = bgr.copy()
    for ch in range(bgr.shape[2]):
        local_max = cv2.dilate(bgr[:, :, ch], k)
        out[:, :, ch] = np.where(m, local_max, bgr[:, :, ch])
    return out

策略:对每个待修复像素,取它 9×9 邻域内的最大亮度(即最白的像素),填到该像素。

为什么这么简单?因为:

  1. 浅色水印笔画周围 9 像素内基本都是白纸------前面检测时已经排除了正文邻域(7×7 膨胀),所以邻域最亮像素就是纸张底色。
  2. 比 Telea 快两个数量级------Telea 算法解 Poisson 方程,复杂度高;9×9 膨胀是 O(n) 操作。
  3. 不会把黑字拉成墨痕------Telea 修复会把邻近的黑色正文"扩散"到修复区域,造成墨迹污染;邻域最大值只会取白纸,不会污染。

这个策略是踩过坑后才确定的。早期版本用 Telea,结果修复区域里出现"墨迹晕染"------因为 Telea 会沿着像素梯度传播,把正文黑字的边缘拉到水印区域。改用邻域最大值后,又快又干净。

7.2 矩形区域:Telea 修复

矩形区域(用户框选/颜色聚类)走标准 Telea:

python 复制代码
if mask.any():
    # 颜色反演 (灰色半透明水印)
    colors = [c.color for c in cands
              if c.color is not None and _is_gray_rgb(c.color)]
    alphas = [c.alpha for c in cands
              if 0 < c.alpha < 1.0]
    if colors or alphas:
        try:
            from ..inpaint.cv_engine import inpaint_transparent
            if colors:
                avg_rgb = np.array(colors).mean(axis=0)
            else:
                avg_rgb = np.array([0.5, 0.5, 0.5])
            target = tuple(float(v) for v in avg_rgb)
            result = inpaint_transparent(result, mask, target)
        except Exception:
            result = inpaint(result, mask,
                             method='telea', expand=3)
    else:
        result = inpaint(result, mask, method='telea', expand=3)

如果是灰色半透明水印(有 color 且 alpha<1),走 inpaint_transparent(Alpha 反演,下一篇讲)。否则走标准 Telea。

7.3 替换 XObject:必须用 page.replace_image

修复完后,要把新图替换回 PDF:

python 复制代码
def _replace_image(self, xref: int, bgr: np.ndarray):
    """用新图替换 xref 引用的图像 XObject

    必须使用 page.replace_image:它会同步更新 /Filter、/ColorSpace、
    /Width/Height 等整个 XObject 字典。
    不能用 doc.update_stream 直接写编码字节------实测会产出损坏的
    图像流(整页黑块/花屏),尤其原图为 DCTDecode 时。
    """
    # 统一输出 3 通道 BGR -> JPEG
    if bgr.ndim == 2:
        bgr = cv2.cvtColor(bgr, cv2.COLOR_GRAY2BGR)
    elif bgr.ndim == 3 and bgr.shape[2] == 4:
        bgr = cv2.cvtColor(bgr, cv2.COLOR_BGRA2BGR)

    ok, buf = cv2.imencode(
        '.jpg', bgr, [int(cv2.IMWRITE_JPEG_QUALITY), 95])
    if not ok:
        return
    data = buf.tobytes()

    # 同一 XObject 可能被多页引用,逐页替换
    for page_idx, imgs in self._page_images.items():
        if not any(im['xref'] == xref for im in imgs):
            continue
        try:
            self._doc[page_idx].replace_image(xref, stream=data)
            ...

注释里强调的 不能用 doc.update_stream 是踩过的坑。早期版本直接用 update_stream 写 JPEG 字节流到 XObject,结果输出 PDF 整页黑块------因为 XObject 字典里 /Filter 还是原图的(可能是 FlateDecode),但字节流是 JPEG(DCTDecode),格式不匹配,解码器报错。

page.replace_image 是 PyMuPDF 提供的高层 API,它会 同步更新整个 XObject 字典 ------/Filter、/ColorSpace、/Width、/Height、/BitsPerComponent 都会按新图设置。这是写扫描件修复的必备知识。

八、本篇总结

这一篇我们讲了扫描件浅色斜铺文字水印的完整对抗策略:

  1. 问题本质:水印烤进像素,浅色半透明斜铺,矢量 PDF 那套无效。
  2. 整体流程:几何分割 → OCR 验证 → 像素掩膜生成 → 邻域填充修复。
  3. 纸张白度估计:亮像素直方图众数。
  4. 笔画分割 :[bg-30, bg-6] 核心带 + 低饱和度约束 + 排除正文邻域。
  5. 形态学分组 :15×15 膨胀连通 → minAreaRect 求旋转角 → 归一到 -45, 45。
  6. 角度一致性:中位数主方向 + 12° 容差,剔除离群。
  7. 合并隔断分组:同方向 + 间距 <70pt 的贪心合并。
  8. 整页旋转 OCR :增强对比度 → 反向旋转 → invertAffineTransform 映射回原图。
  9. 多分组 2-gram 验证:要求 ≥2 个分组共享 2-gram 才算水印证据。
  10. 置信度分级:≥2 命中 0.85、1 命中 0.55、0 命中但 ≥3 组同方向同尺寸 0.45。
  11. 像素掩膜生成 :[bg-34, bg-3] 比检测带稍宽 + 2×2 开运算 + 3×3 膨胀 + 连通域面积过滤。
  12. 修复策略:邻域最大亮度填充(9×9 膨胀),比 Telea 快两个数量级且不污染正文。
  13. 替换 XObject :必须用 page.replace_image 而不是 doc.update_stream,避免格式不匹配导致黑块。

下一篇我们继续在像素空间里探险,但转向 通用图像去水印------颜色聚类分离 + Alpha 反演修复。这是处理纯图片水印(不是扫描件)的核心算法。

📥 完整源码与可执行程序已上传 CSDN 资源,搜索"清印 ClearMark 智能文档去水印工作台"。本系列代码片段对应文件 core/processor/pdf_scanned.py,全文 970 行。

相关推荐
楚识科技5 小时前
移动端OCR SDK实战接入:实现身份证/银行卡离线识别全流程
ocr
海宇数据1 天前
零信任架构实战:基于海宇身份证OCR构建自动化证照采集网关
人工智能·架构·自动化·ocr
开开心心就好1 天前
图片白底怎么去掉?抠图工具抠完背景透明
java·服务器·开发语言·pdf·ocr·散列表·启发式算法
AI人工智能+1 天前
行驶证识别技术融合计算机视觉与自然语言处理,实现对模糊、倾斜、反光等复杂场景下行驶证的高精度结构化数据提取
人工智能·深度学习·自然语言处理·ocr·行驶证识别
开开心心就好2 天前
超市定时播音软件,免费版支持循环播放
智能手机·ffmpeg·ocr·word·vim·音视频·visual studio
AI视觉网奇3 天前
手写ocr 算法合集
ocr
楚识科技5 天前
合同比对全流程自动化:OCR识别+差异比对+法律风险字段抽取实战指南
运维·自动化·ocr
楚识科技5 天前
手写表格OCR接口接入全指南:实战调用与结构化字段解析
ocr
VidDown5 天前
从视频画面里提取文字:OCR、文字检测与结构化输出
python·网络协议·ocr·音视频·视频编解码·视频