1.6 通用图像去水印:颜色聚类分离与 Alpha 反演修复
清印 ClearMark 系列第 6 篇 · 共 12 篇
前一篇我们讲了扫描件浅色斜铺文字水印------那是像素空间的硬骨头。这一篇我们继续在像素空间里,但转向 通用图像水印 。这是
ImageInpaintProcessor与core/inpaint/cv_engine.py的领域。通用图像水印的特征比扫描件更杂:可能是 Logo 涂抹、可能是整页灰色覆盖、可能是 OCR 能识别的文字、可能是任意颜色的半透明区域。我们没法用"浅色 + 斜铺 + 重复"那套几何特征,必须换思路------颜色聚类分离水印层 + Alpha 反演修复。这套算法对纯色半透明覆盖特别有效。
一、ImageInpaintProcessor 的整体设计
core/processor/image_inpaint.py 是图片处理的总入口。它支持 9 种图片格式:
python
_SUPPORTED_EXT = {'.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff',
'.webp', '.heic', '.heif'}
HEIC 通过 pillow_heif 库支持,OCR 通过 rapidocr_onnxruntime 支持。这两个是可选依赖,缺失时只影响对应格式/功能,不影响其他功能。
1.1 文件打开与缓存
python
def open(self, path: str):
ext = os.path.splitext(path)[1].lower()
if ext not in _SUPPORTED_EXT:
raise ValueError(f"不支持的图片格式: {ext}")
if ext in ('.heic', '.heif') and not _HEIF_OK:
raise RuntimeError("未安装 pillow_heif,无法处理 HEIC 格式")
img = Image.open(path)
img.load()
self._image = img
self._format = (img.format or 'PNG').upper()
self._info = dict(img.info or {})
self.file_path = path
self.result = None
self._invalidate_cache()
打开时记录三件事:
self._image:PIL Image 对象(保留原 mode)。self._format:原始格式名(如 'JPEG'),保存时按此格式。self._info:原始信息(EXIF、quality 参数等),保存时回写。
保留原始格式和质量 是图片处理的关键。如果原图是 JPEG quality=85,去水印后保存成 PNG,体积可能膨胀 5 倍;如果保存成 JPEG quality=95,又有质量损失。最佳策略是 同格式 + 同 quality 参数,让输出图与原图视觉一致。
1.2 PIL 与 OpenCV 的桥接
PIL 是图片 I/O 标准库,OpenCV 是图像处理标准库。两者数据格式不同:
- PIL:
Image.Image对象,mode 标识(RGB/RGBA/L/LA/P/CMYK)。 - OpenCV:
numpy.ndarray,BGR/BGRA/灰度,uint8。
_to_cv 方法负责转换:
python
def _to_cv(self, keep_alpha: bool = False) -> np.ndarray:
"""将 PIL 图像转为 OpenCV 数组"""
img = self._image
if img is None:
raise RuntimeError("图片未打开")
mode = img.mode
if mode == 'RGB':
arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
elif mode == 'RGBA':
if keep_alpha:
arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGBA2BGRA)
else:
arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGBA2BGR)
elif mode == 'L':
arr = np.array(img)
if keep_alpha:
rgba = cv2.cvtColor(arr, cv2.COLOR_GRAY2BGRA)
rgba[:, :, 3] = 255
arr = rgba
elif mode == 'LA':
if keep_alpha:
arr = cv2.cvtColor(np.array(img.convert('RGBA')),
cv2.COLOR_RGBA2BGRA)
else:
arr = cv2.cvtColor(np.array(img.convert('RGBA')),
cv2.COLOR_RGBA2BGR)
elif mode == 'P':
if 'transparency' in img.info:
rgba = np.array(img.convert('RGBA'))
if keep_alpha:
arr = cv2.cvtColor(rgba, cv2.COLOR_RGBA2BGRA)
else:
arr = cv2.cvtColor(rgba, cv2.COLOR_RGBA2BGR)
else:
arr = cv2.cvtColor(np.array(img.convert('RGB')),
cv2.COLOR_RGB2BGR)
else:
# CMYK / YCbCr / 其它
arr = cv2.cvtColor(np.array(img.convert('RGB' if not keep_alpha else 'RGBA')),
cv2.COLOR_RGB2BGR if not keep_alpha
else cv2.COLOR_RGBA2BGRA)
...
处理 6 种 PIL mode,每种都要考虑 alpha 保留与否。几个关键点:
- PIL 是 RGB 顺序,OpenCV 是 BGR 顺序 ------必须
cvtColor转换。 - L(灰度)+ keep_alpha:灰度图没有 alpha,但 BGRA 需要 4 通道,所以把灰度复制到 BGR 三通道,alpha 设 255。
- P(调色板):可能带透明色('transparency' in info),必须先转 RGBA 再处理。
- CMYK/YCbCr:直接转 RGB/RGBA 兜底。
_to_cv 还有缓存机制------同一图多次调用 _to_cv(keep_alpha=False) 只计算一次:
python
if keep_alpha and self._cv_cache_with_alpha is not None:
return self._cv_cache_with_alpha
if not keep_alpha and self._cv_cache is not None:
return self._cv_cache
...
if keep_alpha:
self._cv_cache_with_alpha = arr
else:
self._cv_cache = arr
return arr
检测时调一次 _to_cv(),移除时再调一次,如果没有缓存就要做两次 PIL→OpenCV 转换。缓存能省一半时间。
二、检测策略:颜色聚类 + OCR
ImageInpaintProcessor.detect_auto 走三条腿:
python
def detect_auto(self) -> DetectionResult:
"""智能检测: 颜色聚类 + OCR + (可选) visual_feature"""
candidates: List[WatermarkCandidate] = []
if not self.is_open:
return ...
bgr = self._to_cv()
# 1. 颜色聚类 - 找灰色半透明覆盖层
cluster_results = self._cluster_watermark_layer(bgr)
for m, color_bgr, alpha_est in cluster_results:
...
candidates.append(cand)
# 2. OCR 文字检测
ocr_results = self._ocr_detect(bgr)
for box, text, conf in ocr_results:
...
# 灰色低饱和 + 中等亮度 → 提升置信度并标记颜色
sub = bgr[int(y0):int(y1), int(x0):int(x1)]
if sub.size > 0:
mean_color = sub.mean(axis=(0, 1))
is_gray = (abs(mean_color[0] - mean_color[1]) < 15 and
abs(mean_color[1] - mean_color[2]) < 15)
if is_gray and 80 < mean_color[0] < 220:
cand.confidence = min(1.0, cand.confidence + 0.2)
cand.color = tuple(float(c) / 255.0
for c in mean_color[::-1])
cand.alpha = 0.6 # 推断为半透明
candidates.append(cand)
# 3. 可选: 调用 visual_feature 模块
vf = _get_visual_feature()
if vf is not None and hasattr(vf, 'analyze_image'):
...
三条策略:
- 颜色聚类:找整张图的灰色半透明覆盖层(典型水印特征)。
- OCR 文字检测:识别图中的文字,灰色低饱和的加分。
- visual_feature:可选模块,做更高级的视觉特征分析(V2.0 尚未实施)。
下面重点讲颜色聚类。
三、颜色聚类:分离灰色半透明水印层
_cluster_watermark_layer 是检测的核心:
python
def _cluster_watermark_layer(self, bgr: np.ndarray) \
-> List[Tuple[np.ndarray, np.ndarray, float]]:
"""对图像做颜色聚类,识别可能的灰色半透明水印层
Returns:
[(mask, color_bgr, alpha_est), ...]
- mask: 与图像同尺寸的 0/255 数组
- color_bgr: 聚类中心 BGR float32
- alpha_est: 估算的 alpha 透明度
"""
h, w = bgr.shape[:2]
# 大图采样加速
step = max(1, max(h, w) // 500)
sample = bgr[::step, ::step].reshape(-1, 3).astype(np.float32)
if len(sample) < 10:
return []
n_clusters = min(5, len(sample))
try:
_, labels, centers = cv2.kmeans(
sample, n_clusters, None,
(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 15, 1.0),
3, cv2.KMEANS_PP_CENTERS,
)
except cv2.error:
return []
...
3.1 大图采样加速
python
step = max(1, max(h, w) // 500)
sample = bgr[::step, ::step].reshape(-1, 3).astype(np.float32)
K-Means 复杂度是 O(n × k × iterations),n 是像素数。一张 4000×3000 的图有 1200 万像素,直接聚类要几十秒。
策略:采样 ------按 step = max(h, w) // 500 步长采样,把 1200 万像素降到约 25 万像素。500 是经验值,再小聚类慢,再大可能漏掉小水印。
3.2 K-Means 聚类
python
n_clusters = min(5, len(sample))
_, labels, centers = cv2.kmeans(
sample, n_clusters, None,
(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 15, 1.0),
3, cv2.KMEANS_PP_CENTERS,
)
cv2.kmeans 参数:
K=5:聚成 5 类。一张图通常有背景、正文、插图、水印等 5 种主色。criteria:迭代 15 次或误差小于 1.0 终止。attempts=3:K-Means 跑 3 次取最优(避免局部最优)。KMEANS_PP_CENTERS:用 PP 算法选初始中心,比随机初始化更稳定。
3.3 灰色聚类筛选
python
results: List[Tuple[np.ndarray, np.ndarray, float]] = []
for i, c in enumerate(centers):
max_c = float(c.max())
min_c = float(c.min())
sat = (max_c - min_c) / max_c if max_c > 0 else 0.0
# 灰色 (低饱和度) 且中等亮度 → 可能是水印
if sat < 0.15 and 80 < c.mean() < 220:
# 在原图上计算每个像素到中心的距离
diff = bgr.astype(np.float32) - c.reshape(1, 1, 3)
dist = np.sqrt((diff ** 2).sum(axis=2))
# 阈值取该聚类中心的标准差 * 2
cluster_pixels = sample[labels.flatten() == i]
std = float(cluster_pixels.std()) if len(cluster_pixels) else 15.0
t = max(15.0, std * 2.5)
m = (dist < t).astype(np.uint8) * 255
# 面积过滤: 过小或过大都排除
ratio = float(m.sum()) / 255.0 / (h * w)
if 0.005 < ratio < 0.5:
...
results.append((m, c, alpha_est))
return results
对每个聚类中心:
- 饱和度检查 :
sat < 0.15且三通道差异小,是灰色。 - 亮度检查 :
80 < c.mean() < 220,避免纯黑(正文)和纯白(背景)。 - 像素到中心距离:在原图上算每个像素到聚类中心的欧氏距离。
- 自适应阈值 :
t = max(15.0, std * 2.5)------阈值取该聚类内像素标准差的 2.5 倍,至少 15。这保证不同方差的聚类有不同阈值,方差大的(颜色分散)阈值高,方差小的(颜色集中)阈值低。 - 面积过滤 :
0.005 < ratio < 0.5------面积 <0.5% 太小(噪点),>50% 太大(背景),都不要。
3.4 Alpha 估算
python
from ..inpaint.cv_engine import _get_border_color
try:
bg = _get_border_color(bgr.astype(np.float32), m)
denom = c - bg
denom = np.where(np.abs(denom) < 1.0, 1.0, denom)
alpha_per_ch = (c - bg) / denom
alpha_est = float(np.median(alpha_per_ch))
alpha_est = max(0.1, min(0.95, alpha_est))
except Exception:
alpha_est = 0.5
Alpha 估算用到颜色混合公式:
watermark_color = alpha * watermark_color + (1 - alpha) * background
反推 alpha:
alpha = (watermark_color - background) / (watermark_pure_color - background)
watermark_color:聚类中心 c(即混合后的颜色)。background:mask 外环的平均色(用_get_border_color取)。watermark_pure_color:理论上水印本身的纯色,未知,这里近似取c(假设水印颜色就是聚类中心)。
这是个粗糙近似------实际水印纯色不等于聚类中心。但估算的 alpha 用在后续反演里足够稳定。如果估算失败,回退到 0.5(中性猜测)。
四、OCR 检测与灰色加权
第二条策略是 OCR:
python
def _ocr_detect(self, bgr: np.ndarray):
"""对图像做 OCR 文字检测"""
ocr_cls = _get_ocr_class()
if ocr_cls is None:
return []
if self._ocr is None:
try:
self._ocr = ocr_cls()
except Exception:
self._ocr = False
if self._ocr is False or self._ocr is None:
return []
try:
ret = self._ocr(bgr)
except Exception:
return []
...
OCR 引擎 RapidOCR 是懒加载的------第一次调用时才实例化。rapidocr_onnxruntime 不在时 _get_ocr_class() 返回 None,OCR 检测自动跳过。
OCR 结果加上灰色加权:
python
for box, text, conf in ocr_results:
x0, y0, x1, y1 = box
cand = WatermarkCandidate(
kind=WatermarkKind.TEXT,
...
text=text,
confidence=min(1.0, float(conf) / 100.0),
)
# 灰色低饱和 + 中等亮度 → 提升置信度并标记颜色
sub = bgr[int(y0):int(y1), int(x0):int(x1)]
if sub.size > 0:
mean_color = sub.mean(axis=(0, 1)) # BGR
is_gray = (abs(mean_color[0] - mean_color[1]) < 15 and
abs(mean_color[1] - mean_color[2]) < 15)
if is_gray and 80 < mean_color[0] < 220:
cand.confidence = min(1.0, cand.confidence + 0.2)
cand.color = tuple(float(c) / 255.0
for c in mean_color[::-1])
cand.alpha = 0.6 # 推断为半透明
candidates.append(cand)
OCR 找到的文字不一定都是水印------可能是图中的标题、说明文字。所以置信度从 OCR 的 confidence 初始化(0~1),如果文字所在区域的平均颜色是灰色低饱和,+0.2 加权 ,并标记 alpha=0.6(推断为半透明)。
这种"OCR + 颜色加权"的组合策略在图片水印场景里效果不错------大多数图片水印都是灰色半透明文字。
五、移除:构建 mask → 修复 → 保存
remove 方法是检测的逆过程:
python
def remove(self, candidates, output_path, progress_callback=None) -> int:
if not self.is_open:
raise RuntimeError("图片未打开")
if not candidates:
self._save_image(self._image, output_path)
return 0
# 1. 构建 mask
bgr = self._to_cv()
h, w = bgr.shape[:2]
mask = np.zeros((h, w), dtype=np.uint8)
for cand in candidates:
self._draw_candidate_to_mask(cand, mask, w, h)
# 2. 决定修复策略
colors = [c.color for c in candidates
if c.color is not None and _is_gray_rgb(c.color)]
alphas = [c.alpha for c in candidates if c.alpha is not None
and 0 < c.alpha < 1.0]
if colors or alphas:
# 半透明灰色水印 → 颜色反演修复
if colors:
avg_rgb = np.array(colors).mean(axis=0)
else:
avg_rgb = np.array([0.5, 0.5, 0.5])
target_color = tuple(float(v) for v in avg_rgb)
result = inpaint_transparent(bgr, mask, target_color)
else:
# 标准修复
result = inpaint(bgr, mask, method='telea', expand=3)
# 3. 还原 alpha (如果原图带 alpha)
src_mode = self._image.mode
if result.ndim == 2:
out_pil = Image.fromarray(result, mode='L')
elif src_mode == 'RGBA':
rgba = cv2.cvtColor(result, cv2.COLOR_BGR2RGBA)
rgba[:, :, 3] = np.array(self._image)[:, :, 3]
out_pil = Image.fromarray(rgba, mode='RGBA')
else:
rgb = cv2.cvtColor(result, cv2.COLOR_BGR2RGB)
out_pil = Image.fromarray(rgb, mode='RGB')
# 4. 保存 (保留原格式 + 质量参数)
self._save_image(out_pil, output_path)
...
四个关键决策:
- mask 构建:把所有候选 bbox 画到 mask 上,255 表示待修复。
- 修复策略选择 :有
color + alpha的候选走inpaint_transparent(颜色反演),否则走inpaint(标准 Telea)。 - alpha 通道还原:原图如果是 RGBA,修复结果(BGR)转回 RGBA 时,alpha 通道用原图的------修复不应改变透明度。
- 保存格式 :调
_save_image按原格式 + 原 quality 参数保存。
5.1 _save_image:保留原格式与质量
python
def _save_image(self, pil_img: Image.Image, output_path: str):
"""按原格式 + 原质量参数保存"""
fmt_name = self._format or 'PNG'
pil_fmt = _SAVE_FORMAT_MAP.get(fmt_name, 'PNG')
save_kwargs = {}
# 质量参数
if pil_fmt == 'JPEG':
save_kwargs['quality'] = int(self._info.get('quality', 95))
save_kwargs['subsampling'] = 0
# JPEG 不支持 alpha,必要时转 RGB
if pil_img.mode != 'RGB':
pil_img = pil_img.convert('RGB')
elif pil_fmt == 'WEBP':
save_kwargs['quality'] = int(self._info.get('quality', 95))
elif pil_fmt == 'TIFF':
save_kwargs['compression'] = self._info.get('compression', 'tiff_lzw')
# 保留 EXIF
if 'exif' in self._info and pil_fmt in ('JPEG', 'WEBP', 'TIFF', 'PNG'):
save_kwargs['exif'] = self._info['exif']
# 保留 ICC profile
if 'icc_profile' in self._info:
save_kwargs['icc_profile'] = self._info['icc_profile']
out_dir = os.path.dirname(os.path.abspath(output_path))
os.makedirs(out_dir, exist_ok=True)
pil_img.save(output_path, format=pil_fmt, **save_kwargs)
关键细节:
- JPEG quality :从
self._info.get('quality', 95)取原 quality。如果原图 quality=80,输出也 quality=80,避免质量变化。 - JPEG subsampling=0:4:4:4 采样,比默认 4:2:0 清晰。JPEG 文字水印修复后,subsampling=0 能保留更多细节。
- EXIF 保留:相机拍摄的图常有 EXIF(拍摄时间、GPS、相机型号),必须保留。
- ICC profile 保留:颜色配置文件,影响图片在不同设备的颜色显示。
这套"原格式 + 原 quality + EXIF + ICC"的保存策略,保证输出图视觉上与原图一致,除了水印被擦除外没有其他变化。
六、cv_engine.inpaint:标准 Telea 修复
core/inpaint/cv_engine.py 是修复引擎。先看标准 inpaint:
python
def inpaint(image: np.ndarray,
mask: np.ndarray,
method: str = 'telea',
expand: int = 3) -> np.ndarray:
"""标准修复 --- cv2.inpaint (Telea / NS)"""
if image is None or mask is None:
raise ValueError("image 和 mask 不能为 None")
is_gray = image.ndim == 2
work, alpha_ch = _split_alpha(image)
m = _to_gray_mask(mask)
m = expand_mask(m, expand)
flag = _METHOD_MAP.get(method.lower(), cv2.INPAINT_TELEA)
if m.any():
radius = max(3, int(expand))
result = cv2.inpaint(work, m, radius, flag)
else:
result = work
if alpha_ch is not None:
result = cv2.cvtColor(result, cv2.COLOR_BGR2BGRA)
result[:, :, 3] = alpha_ch
elif is_gray:
pass
return result
封装 OpenCV 的 cv2.inpaint,支持两种算法:
- Telea (
cv2.INPAINT_TELEA):基于快速行进法(Fast Marching Method),从 mask 边界向内填充,速度快。 - NavierStokes (
cv2.INPAINT_NS):基于 Navier-Stokes 方程,效果更自然但稍慢。
默认 Telea,因为速度优先。
6.1 mask 预处理
python
def _to_gray_mask(mask: np.ndarray) -> np.ndarray:
"""将任意 mask 转为单通道 uint8 (0/255)"""
if mask is None:
raise ValueError("mask 不能为 None")
if mask.ndim == 3:
if mask.shape[2] == 4:
mask = cv2.cvtColor(mask, cv2.COLOR_BGRA2GRAY)
else:
mask = cv2.cvtColor(mask, cv2.COLOR_BGR2GRAY)
m = mask.astype(np.uint8, copy=False)
m = np.where(m > 0, 255, 0).astype(np.uint8)
return m
def expand_mask(mask: np.ndarray, expand: int = 3) -> np.ndarray:
"""扩展 mask 外扩 expand 像素 (椭圆核膨胀)"""
if expand <= 0:
return mask
k = max(3, expand * 2 + 1)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
return cv2.dilate(mask, kernel, iterations=1)
两步:
- 统一单通道:mask 可能是 3 通道或 4 通道,统一转单通道 0/255。
- 椭圆膨胀 :mask 外扩
expand像素,用椭圆核(比矩形核更自然)。expand=3是经验值,覆盖抗锯齿边缘。
6.2 alpha 通道分离
python
def _split_alpha(image: np.ndarray) -> Tuple[np.ndarray, Optional[np.ndarray]]:
"""分离 alpha 通道;返回 (work_bgr_or_gray, alpha_or_None)"""
if image.ndim == 3 and image.shape[2] == 4:
alpha = image[:, :, 3].copy()
work = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
return work, alpha
return image.copy(), None
关键设计:修复时只对 BGR 三通道做,alpha 通道原样保留。如果对 BGRA 一起修复,alpha 通道会被 Telea 拉成中间值(半透明),破坏原图的透明度信息。
修复完后再把 alpha 通道贴回去:
python
if alpha_ch is not None:
result = cv2.cvtColor(result, cv2.COLOR_BGR2BGRA)
result[:, :, 3] = alpha_ch
七、inpaint_transparent:Alpha 反演修复
这是 cv_engine 的精华------专门处理灰色半透明覆盖层:
python
def inpaint_transparent(image: np.ndarray,
mask: np.ndarray,
target_color) -> np.ndarray:
"""半透明水印颜色反演修复
流程:
1. 在 mask 内做颜色聚类,分离出真正接近 target_color 的水印像素
2. 通过水印层内外色差估算 alpha 透明度
3. 反演 alpha 混合公式: original = (watermark - alpha * color) / (1 - alpha)
4. 调用标准 inpaint 清理边缘伪影
"""
理论依据:半透明水印的像素值是水印颜色和背景颜色的线性混合:
watermark_pixel = alpha * watermark_color + (1 - alpha) * background
反推原图:
background = (watermark_pixel - alpha * watermark_color) / (1 - alpha)
只要知道 alpha 和 watermark_color,就能反演每个像素的原始背景。
7.1 完整流程
python
def inpaint_transparent(image, mask, target_color):
# 归一化 target_color 为 BGR float32 0~255
color_arr = np.asarray(target_color, dtype=np.float32).flatten()
if color_arr.size < 3:
color_arr = np.resize(color_arr, 3)
if color_arr.size > 3:
color_arr = color_arr[:3]
if color_arr.max() <= 1.0:
color_arr = color_arr * 255.0
color_bgr = color_arr[::-1].astype(np.float32)
color_vec = color_bgr.reshape(1, 1, 3)
# 通道处理
is_gray = image.ndim == 2
has_alpha = (image.ndim == 3 and image.shape[2] == 4)
if has_alpha:
alpha_ch = image[:, :, 3].copy()
work = cv2.cvtColor(image, cv2.COLOR_BGA2BGR)
elif is_gray:
work = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
else:
work = image.copy()
work = work.astype(np.float32)
m = _to_gray_mask(mask)
if not m.any():
return image.copy()
# ── 步骤 1: 颜色聚类分离水印层 ──
diff = work - color_vec
dist = np.sqrt((diff ** 2).sum(axis=2))
mask_dist = dist[m > 0]
if len(mask_dist) > 10:
t = float(np.percentile(mask_dist, 25))
t = max(t, 15.0)
else:
t = 30.0
watermark_mask = ((dist < t) & (m > 0)).astype(np.uint8) * 255
if not watermark_mask.any():
watermark_mask = m
# ── 步骤 2: 估算 alpha 透明度 ──
bg_color = _get_border_color(work, watermark_mask)
wm_pixels = work[watermark_mask > 0]
if len(wm_pixels) > 0:
wm_mean = wm_pixels.mean(axis=0)
denom = color_bgr - bg_color
denom = np.where(np.abs(denom) < 1.0, 1.0, denom)
alpha_per_ch = (wm_mean - bg_color) / denom
alpha_est = float(np.median(alpha_per_ch))
alpha_est = max(0.1, min(0.95, alpha_est))
else:
alpha_est = 0.5
# ── 步骤 3: 反演 alpha 混合 ──
inv_alpha = max(0.05, 1.0 - alpha_est)
recovered = (work - alpha_est * color_vec) / inv_alpha
recovered = np.clip(recovered, 0, 255)
# 仅在 watermark_mask 内应用反演
mask_3c = np.repeat(watermark_mask[:, :, None], 3, axis=2)
result = np.where(mask_3c > 0, recovered, work).astype(np.uint8)
# ── 步骤 4: 标准修复清理边缘 ──
result = inpaint(result, watermark_mask, method='telea', expand=2)
# 还原通道
if is_gray:
return cv2.cvtColor(result, cv2.COLOR_BGR2GRAY)
if has_alpha:
result = cv2.cvtColor(result, cv2.COLOR_BGR2BGRA)
result[:, :, 3] = alpha_ch
return result
四步详解:
7.2 步骤 1:颜色聚类分离水印层
mask 区域里不一定全是水印像素------用户框选的区域可能包含正文、插图等。需要进一步过滤出 真正接近 target_color 的像素:
python
diff = work - color_vec
dist = np.sqrt((diff ** 2).sum(axis=2))
mask_dist = dist[m > 0]
if len(mask_dist) > 10:
t = float(np.percentile(mask_dist, 25))
t = max(t, 15.0)
else:
t = 30.0
watermark_mask = ((dist < t) & (m > 0)).astype(np.uint8) * 255
每个像素到 target_color 的欧氏距离,取 mask 内 25% 分位数作为阈值------即 mask 内 25% 最接近 target_color 的像素才被认作水印。这个自适应阈值比固定值更鲁棒。
7.3 步骤 2:估算 alpha
python
bg_color = _get_border_color(work, watermark_mask)
wm_pixels = work[watermark_mask > 0]
if len(wm_pixels) > 0:
wm_mean = wm_pixels.mean(axis=0)
denom = color_bgr - bg_color
denom = np.where(np.abs(denom) < 1.0, 1.0, denom)
alpha_per_ch = (wm_mean - bg_color) / denom
alpha_est = float(np.median(alpha_per_ch))
alpha_est = max(0.1, min(0.95, alpha_est))
_get_border_color 取 mask 外环的平均色作为背景参考:
python
def _get_border_color(image: np.ndarray, mask: np.ndarray) -> np.ndarray:
"""获取 mask 外环的平均颜色作为背景参考"""
h, w = mask.shape[:2]
k = max(3, max(h, w) // 20)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k * 2 + 1, k * 2 + 1))
dilated = cv2.dilate(mask, kernel, iterations=2)
border = cv2.subtract(dilated, mask)
border_pixels = image[border > 0]
if len(border_pixels) == 0:
return image.mean(axis=(0, 1)).astype(np.float32)[:3]
return border_pixels.mean(axis=0).astype(np.float32)
策略:先膨胀 mask,再减去原 mask,得到 外环(border)。外环像素的平均色就是背景参考。
denom = np.where(np.abs(denom) < 1.0, 1.0, denom) 防止除零------如果 color_bgr 和 bg_color 某通道差异 <1,把分母设为 1。
alpha_per_ch = (wm_mean - bg_color) / denom 用三通道分别估算 alpha,取中位数。中位数比平均数更抗异常值。
alpha_est 钳制到 [0.1, 0.95]------alpha <0.1 几乎透明(不像水印),>0.95 几乎不透明(应该用标准修复)。
7.4 步骤 3:反演 alpha 混合
python
inv_alpha = max(0.05, 1.0 - alpha_est)
recovered = (work - alpha_est * color_vec) / inv_alpha
recovered = np.clip(recovered, 0, 255)
mask_3c = np.repeat(watermark_mask[:, :, None], 3, axis=2)
result = np.where(mask_3c > 0, recovered, work).astype(np.uint8)
反演公式:
recovered = (watermark_pixel - alpha * watermark_color) / (1 - alpha)
inv_alpha = max(0.05, 1.0 - alpha_est) 防止除零------即使 alpha 估算到 0.99,分母也不会小于 0.05。
np.clip(recovered, 0, 255) 钳制到合法像素值范围。反演可能产生超出 0, 255 的值(如果 alpha 估算不准),必须 clip。
np.where(mask_3c > 0, recovered, work) 只在 watermark_mask 内应用反演,其他区域保留原图。不能整图反演,否则会破坏非水印区域。
7.5 步骤 4:标准修复清理边缘
python
result = inpaint(result, watermark_mask, method='telea', expand=2)
最后用 Telea 修复一遍 watermark_mask 区域。原因:反演后水印边缘会有伪影(alpha 估算在边缘不准确),Telea 能用周围像素平滑这些伪影。
这一步是 "先用物理模型反演,再用图像修复收尾" 的两阶段策略。物理模型处理主体,图像修复处理边缘。
八、本篇总结
这一篇我们讲了通用图像去水印的完整流程:
ImageInpaintProcessor设计:9 种图片格式,PIL 打开 + OpenCV 处理,原格式 + 原 quality 保存。- PIL/OpenCV 桥接 :
_to_cv处理 6 种 PIL mode,缓存避免重复转换。 - 三条检测策略:颜色聚类 + OCR + visual_feature(可选)。
- 颜色聚类:K-Means 5 类,灰色低饱和 + 中等亮度筛选,自适应阈值,面积过滤。
- Alpha 估算:颜色混合公式反推,三通道中位数,钳制 0.1, 0.95。
- OCR 加权:OCR 文字 + 区域灰色加权 +0.2,标记 alpha=0.6。
inpaint标准 Telea:mask 预处理 + 椭圆膨胀 + alpha 通道分离保留。inpaint_transparentAlpha 反演:四步流程------颜色聚类分离 + alpha 估算 + 反演混合 + Telea 收尾。- 保存策略:原格式 + 原 quality + EXIF + ICC profile 保留。
下一篇我们终于要进入 UI 层------PySide6 三栏工作台、QPainter 渲染、坐标契约、框选/涂抹交互。这是用户体验的核心,也是踩坑最多的一层。
📥 完整源码与可执行程序已上传 CSDN 资源,搜索"清印 ClearMark 智能文档去水印工作台"。本系列代码片段对应文件
core/processor/image_inpaint.py(677 行)和core/inpaint/cv_engine.py(228 行)。