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,覆盖整张扫描图。
这种水印的特征:
- 像素级:文字已经光栅化,不是 PDF 文字指令,无法用内容流解析。
- 浅色:灰度值通常 180~220,对比白纸(255)只差 30~50,肉眼可见但不强。
- 半透明:水印颜色和正文颜色叠加,水印下方可能有正文黑字。
- 斜铺:45° 或 30° 旋转,覆盖整页。
- 重复:整页多处出现相同文字(水印本来就是平铺的)。
对抗它的策略必须是 像素级 的:
- 检测:分割浅色像素 → 形态学分组 → 旋转 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 []
# 修复掩膜:比检测带稍宽,覆盖抗锯齿边缘
...
主流程六步:
- 灰度化 + HSV 分离饱和度:浅色水印的特征是低饱和度。
_light_text_geometry:分割浅色笔画,形态学分组。_verify_tiled_text_ocr:整页旋转一次,OCR 验证是否真是水印文字。- 置信度分级:≥2 个分组命中→高置信度;1 个命中→中;OCR 失败但 ≥3 个分组同方向同尺寸→低置信度待确认。
- 生成像素掩膜:每个分组生成一个掩膜,标记待修复像素。
- 输出候选:每个候选携带掩膜,移除时只修复掩膜像素。
下面逐步拆解。
三、笔画分割:_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 矩形核膨胀,把同一行/同一组的笔画连成一个连通域。
然后对每个连通域:
- 用
cv2.minAreaRect求最小外接矩形,得到旋转角。 - 如果宽度 < 高度,说明矩形是竖向的,旋转角加 90° 归一化到水平方向。
_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)
...
两步预处理:
- 增强对比度 :把笔画核心带
[bg-45, bg-3]线性映射到 0, 255。水印本来浅色对比度低,OCR 几乎读不出来;增强后笔画变成深色(接近 0),背景变成白色(255),OCR 才能识别。 - 整页旋转 :用
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 识别框:
- 用逆变换矩阵把旋转后图像坐标映射回原图坐标。
- 找到原图中包含该坐标的分组。
- 关键过滤:要求 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 级灰度,覆盖笔画的抗锯齿边缘。否则修复后边缘会留下浅色边框。
掩膜生成步骤:
- 限定到分组区域 :
pm = np.zeros((h, w)),只在[y0:y1, x0:x1]范围内取值。 - 2×2 开运算 清理噪点。
- 3×3 膨胀 扩大掩膜覆盖区域,确保修复完整。
- 连通域面积过滤:去掉面积 <4 的孤立噪点。
- 面积阈值 :
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 邻域内的最大亮度(即最白的像素),填到该像素。
为什么这么简单?因为:
- 浅色水印笔画周围 9 像素内基本都是白纸------前面检测时已经排除了正文邻域(7×7 膨胀),所以邻域最亮像素就是纸张底色。
- 比 Telea 快两个数量级------Telea 算法解 Poisson 方程,复杂度高;9×9 膨胀是 O(n) 操作。
- 不会把黑字拉成墨痕------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 都会按新图设置。这是写扫描件修复的必备知识。
八、本篇总结
这一篇我们讲了扫描件浅色斜铺文字水印的完整对抗策略:
- 问题本质:水印烤进像素,浅色半透明斜铺,矢量 PDF 那套无效。
- 整体流程:几何分割 → OCR 验证 → 像素掩膜生成 → 邻域填充修复。
- 纸张白度估计:亮像素直方图众数。
- 笔画分割 :
[bg-30, bg-6]核心带 + 低饱和度约束 + 排除正文邻域。 - 形态学分组 :15×15 膨胀连通 →
minAreaRect求旋转角 → 归一到 -45, 45。 - 角度一致性:中位数主方向 + 12° 容差,剔除离群。
- 合并隔断分组:同方向 + 间距 <70pt 的贪心合并。
- 整页旋转 OCR :增强对比度 → 反向旋转 →
invertAffineTransform映射回原图。 - 多分组 2-gram 验证:要求 ≥2 个分组共享 2-gram 才算水印证据。
- 置信度分级:≥2 命中 0.85、1 命中 0.55、0 命中但 ≥3 组同方向同尺寸 0.45。
- 像素掩膜生成 :
[bg-34, bg-3]比检测带稍宽 + 2×2 开运算 + 3×3 膨胀 + 连通域面积过滤。 - 修复策略:邻域最大亮度填充(9×9 膨胀),比 Telea 快两个数量级且不污染正文。
- 替换 XObject :必须用
page.replace_image而不是doc.update_stream,避免格式不匹配导致黑块。
下一篇我们继续在像素空间里探险,但转向 通用图像去水印------颜色聚类分离 + Alpha 反演修复。这是处理纯图片水印(不是扫描件)的核心算法。
📥 完整源码与可执行程序已上传 CSDN 资源,搜索"清印 ClearMark 智能文档去水印工作台"。本系列代码片段对应文件
core/processor/pdf_scanned.py,全文 970 行。