1.6 通用图像去水印:颜色聚类分离与 Alpha 反演修复

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'):
        ...

三条策略:

  1. 颜色聚类:找整张图的灰色半透明覆盖层(典型水印特征)。
  2. OCR 文字检测:识别图中的文字,灰色低饱和的加分。
  3. 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

对每个聚类中心:

  1. 饱和度检查 :sat < 0.15 且三通道差异小,是灰色。
  2. 亮度检查 :80 < c.mean() < 220,避免纯黑(正文)和纯白(背景)。
  3. 像素到中心距离:在原图上算每个像素到聚类中心的欧氏距离。
  4. 自适应阈值 :t = max(15.0, std * 2.5)------阈值取该聚类内像素标准差的 2.5 倍,至少 15。这保证不同方差的聚类有不同阈值,方差大的(颜色分散)阈值高,方差小的(颜色集中)阈值低。
  5. 面积过滤 :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)
    ...

四个关键决策:

  1. mask 构建:把所有候选 bbox 画到 mask 上,255 表示待修复。
  2. 修复策略选择 :有 color + alpha 的候选走 inpaint_transparent(颜色反演),否则走 inpaint(标准 Telea)。
  3. alpha 通道还原:原图如果是 RGBA,修复结果(BGR)转回 RGBA 时,alpha 通道用原图的------修复不应改变透明度。
  4. 保存格式 :调 _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)

两步:

  1. 统一单通道:mask 可能是 3 通道或 4 通道,统一转单通道 0/255。
  2. 椭圆膨胀 :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 能用周围像素平滑这些伪影。

这一步是 "先用物理模型反演,再用图像修复收尾" 的两阶段策略。物理模型处理主体,图像修复处理边缘。

八、本篇总结

这一篇我们讲了通用图像去水印的完整流程:

  1. ImageInpaintProcessor 设计:9 种图片格式,PIL 打开 + OpenCV 处理,原格式 + 原 quality 保存。
  2. PIL/OpenCV 桥接 :_to_cv 处理 6 种 PIL mode,缓存避免重复转换。
  3. 三条检测策略:颜色聚类 + OCR + visual_feature(可选)。
  4. 颜色聚类:K-Means 5 类,灰色低饱和 + 中等亮度筛选,自适应阈值,面积过滤。
  5. Alpha 估算:颜色混合公式反推,三通道中位数,钳制 0.1, 0.95。
  6. OCR 加权:OCR 文字 + 区域灰色加权 +0.2,标记 alpha=0.6。
  7. inpaint 标准 Telea:mask 预处理 + 椭圆膨胀 + alpha 通道分离保留。
  8. inpaint_transparent Alpha 反演:四步流程------颜色聚类分离 + alpha 估算 + 反演混合 + Telea 收尾。
  9. 保存策略:原格式 + 原 quality + EXIF + ICC profile 保留。

下一篇我们终于要进入 UI 层------PySide6 三栏工作台、QPainter 渲染、坐标契约、框选/涂抹交互。这是用户体验的核心,也是踩坑最多的一层。

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