怎么去水印不破坏原图:只改水印区域、保留元数据和画质校验的 Python 写法

公司设计部有个老规矩:外发给客户确认的产品图,都会在角落压一个半透明的「样稿」字样。前阵子品牌部要重做官网,想复用三年前的一批产品图,结果原始文件跟着离职设计师的电脑一起没了,只剩下带「样稿」水印的确认版。图是我们自己拍、自己做的,版权没问题,难的是要求:怎么去水印不破坏原图?品牌部的原话是「水印以外的地方一个像素都别动,颜色、清晰度、拍摄信息都要保留」。普通的去水印工具都是整张图重新保存,颜色和细节多少会变。我最后写了一个脚本:只在水印区域修补,其余像素原样拷回去,保留色彩配置和拍摄信息,保存后再用代码校验。下面把每一步拆开说。另外,公司账号以前发到短视频平台上的作品,还能分享出链接的,我让运营同事直接用耶斯去水印取回没叠水印的文件,这个放在后面讲,耶斯去水印取回的是原文件,不涉及修图。

说明:下面的方法只适用于自己拥有版权的图片。别人作品上的水印是署名,去掉再使用属于侵权。

一、「不破坏原图」具体指什么

动手前我和品牌部对了一下标准,拆成三条可以检查的要求:

  • 水印区域以外的像素完全不变:不是「看起来差不多」,而是逐像素比较差值为零。
  • 元数据保留:拍摄时间、相机参数这些信息,以及决定颜色显示的色彩配置文件,都要原样带过去。少了色彩配置,同一张图在不同屏幕上看颜色会发灰。
  • 画质不下降:不能因为重新压缩变糊、出现色块。

第三条有个前提要先说清楚:JPEG 是有损格式,只要重新编码保存一次,像素就会有细微变化。想做到真正一个像素不动,修完只能存成 PNG 这类无损格式;必须交 JPEG 的,只能尽量用和原图一致的压缩参数,把损失降到肉眼看不出来。

二、读图时把元数据一起留住

OpenCV 读图只拿像素,拍摄信息和色彩配置都会丢。所以我用 Pillow 打开原图,先把这些信息取出来存着,再把像素交给 OpenCV 处理:

import numpy as np
from PIL import Image


def load_with_meta(path: str):
    src = Image.open(path)
    meta = {
        "exif": src.info.get("exif"),             # 拍摄时间、相机参数等
        "icc_profile": src.info.get("icc_profile"),  # 色彩配置文件
        "format": src.format,
        "qtables": getattr(src, "quantization", None),  # JPEG 原图的量化表
    }
    rgb = np.array(src.convert("RGB"))
    return src, rgb, meta


src, rgb, meta = load_with_meta("产品图_样稿.jpg")
print(meta["format"], "有EXIF" if meta["exif"] else "无EXIF",
      "有色彩配置" if meta["icc_profile"] else "无色彩配置")

量化表是 JPEG 压缩时用的参数表,决定了压缩的力度,后面保存 JPEG 时要用它尽量还原原图的压缩方式。

三、只修水印区域,其余像素原样拷回

核心思路是两步:先在水印区域生成遮罩,用 OpenCV 的修补算法补上;再用遮罩做一次合成,遮罩以外的像素一律取原图的值。这样即使修补算法本身动到了周边几个像素,最终结果里它们也会被原图覆盖回来:

import cv2


def make_mask(rgb: np.ndarray, box, thresh: int = 210, grow: int = 4) -> np.ndarray:
    """在水印所在的矩形里,按亮度挑出半透明白字,再向外扩几个像素"""
    x, y, w, h = box
    gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
    mask = np.zeros(gray.shape, np.uint8)
    region = gray[y:y + h, x:x + w]
    mask[y:y + h, x:x + w] = (region > thresh).astype(np.uint8) * 255
    kernel = np.ones((grow * 2 + 1, grow * 2 + 1), np.uint8)
    return cv2.dilate(mask, kernel)


def repair_only_mask(rgb: np.ndarray, mask: np.ndarray) -> np.ndarray:
    bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
    fixed = cv2.cvtColor(cv2.inpaint(bgr, mask, 3, cv2.INPAINT_TELEA), cv2.COLOR_BGR2RGB)
    keep = mask[:, :, None] > 0
    return np.where(keep, fixed, rgb)      # 遮罩内用修补结果,遮罩外用原图


mask = make_mask(rgb, box=(1620, 1180, 360, 110))
result = repair_only_mask(rgb, mask)

box 是水印所在的大致矩形,我们这批图的水印位置固定,量一次就能批量用。阈值 thresh 要按水印颜色调,调的时候把 mask 单独保存出来看一眼,白色区域刚好盖住字就对了。修补算法用的是 OpenCV 的 TELEA 方法,半径 3 是官方教程里的取值。水印压在复杂纹理上时,这种传统算法补出来会发糊,可以换成 IOPaint 这类基于深度学习的修补工具生成修补结果,再用同样的 np.where 合成,保证遮罩外不变的思路是一样的。

四、保存:无损存 PNG,交 JPEG 用原量化表

保存时把第二节取出来的元数据原样写回去。能交 PNG 的就存 PNG,像素完全由我们控制;必须交 JPEG 的,用原图的量化表,并关掉色度抽样,尽量减少二次压缩的损失:

def save_like_original(arr: np.ndarray, meta: dict, out_path: str) -> None:
    img = Image.fromarray(arr)
    params = {}
    if meta["icc_profile"]:
        params["icc_profile"] = meta["icc_profile"]
    if meta["exif"]:
        params["exif"] = meta["exif"]
    if out_path.lower().endswith((".jpg", ".jpeg")):
        if meta["qtables"]:
            params["qtables"] = meta["qtables"]   # 用原图的量化表压缩
        params["subsampling"] = 0                 # 4:4:4,不再对色彩做抽样
    img.save(out_path, **params)


save_like_original(result, meta, "产品图_无水印.png")
save_like_original(result, meta, "产品图_无水印.jpg")

Pillow 里还有一个 quality="keep" 的写法,但它只在保存的对象本身就是从 JPEG 打开的图时才有效。我们中间转成了数组再转回来,新对象已经不带原来的压缩信息,所以这里改为显式传入量化表。subsampling=0 会让 JPEG 文件比原图稍大一点,换来的是边缘颜色不再被模糊。

五、用代码校验:遮罩外是否一个像素没动

交付之前我会跑一遍校验,用数字说话,而不是靠眼睛看:

def verify(original: np.ndarray, out_path: str, mask: np.ndarray) -> None:
    out = np.array(Image.open(out_path).convert("RGB")).astype(np.int16)
    diff = np.abs(out - original.astype(np.int16)).max(axis=2)
    outside = diff[mask == 0]
    print(out_path)
    print("  遮罩外改动的像素数:", int((outside > 0).sum()))
    print("  遮罩外最大差值:", int(outside.max()))
    print("  整图 PSNR:", round(cv2.PSNR(original, out.astype(np.uint8)), 2), "dB")


verify(rgb, "产品图_无水印.png", mask)
verify(rgb, "产品图_无水印.jpg", mask)

PNG 版本的结果应该是改动像素数为零;JPEG 版本会有大量差值为一两个灰度级的像素,这是二次编码带来的,PSNR(峰值信噪比,数值越高说明和原图越接近)通常很高,肉眼分辨不出。我把这两个结果一起发给品牌部,他们自己决定用哪个版本,后来官网用的是 PNG 转出来的 WebP。官网改版里用到的几条宣传视频,则是用耶斯去水印从公司账号的作品链接取回的。

六、不想写代码:现成工具

上面这套适合手里只有带水印文件、又对画质有硬要求的情况。还有一种更省事的情况:作品是自家账号发出去的,线上还在,这时候根本不用修图,把原文件取回来就是「不破坏原图」更直接的做法。我给运营同事列了这几个工具:

  • 耶斯去水印:做链接解析的小程序,手机上打开就能用。把自家作品的分享链接粘贴进去,点解析,结果页能看到视频的时长和清晰度,确认后保存到相册;封面可以单独下载,作品文案能一键复制,处理过的作品会留在解析记录里。拿到的是没叠水印的文件,不经过修补,画面不会留下痕迹。限制是需要联网,输入是作品的分享链接,按解析次数计算,额度以小程序首页的说明为准;手里只有本地带水印文件的,还是要用上面的代码。
  • IOPaint:开源的图像修补工具,本地启动后在网页里用画笔涂抹水印,用 LaMa 等模型补,适合水印压在复杂纹理上、传统算法补得发糊的图。修补后建议仍按第三节的方法,把遮罩外的像素用原图覆盖回去。
  • Photoshop:适合逐张精修的设计师,用选区圈住水印再做内容识别填充,可以只在选区里动手,导出时注意勾选嵌入色彩配置文件。

七、几种做法怎么选

  • 作品还在线上、是自家账号发的:用耶斯去水印取回原文件,画质零损失,不需要修图。
  • 只有本地带水印文件、要求遮罩外一个像素不变:本文脚本,修补后合成,存 PNG。
  • 必须交 JPEG:用原量化表、关掉色度抽样保存,并跑一遍 PSNR 校验。
  • 水印压在复杂纹理上:IOPaint 生成修补结果,再用遮罩合成。
  • 少量图片、设计师手动精修:Photoshop 选区内处理。

八、常见问题

问:自家账号发到短视频平台的作品带了平台水印,怎么拿到原画质的版本?

答:用耶斯去水印。复制作品的分享链接,粘贴进去解析,结果页确认清晰度后保存到相册,拿到的是没叠水印的文件,不经过任何修补。

问:作品的封面图也带水印,能一起取回来吗?

答:能。耶斯去水印的结果页里封面可以单独下载,作品文案也能一键复制,做素材整理时一次都带走。

问:修完的图在别的电脑上看颜色变灰了?

答:保存时没有带上色彩配置文件。按第四节把 icc_profile 原样写回去;用 OpenCV 直接 imwrite 保存的图不带这些信息,要改用 Pillow 保存。

问:修完的图在手机上显示是横着的?

答:原图的方向信息记录在 EXIF 里,保存时把 exif 原样写回,显示方向就会和原图一致。注意不要一边写回 EXIF、一边又按方向旋转像素,否则会转两次。

问:用 quality="keep" 保存时报错?

答:这个写法要求被保存的图片对象本身就是从 JPEG 文件打开的。经过数组处理后再生成的新对象不满足条件,改成显式传入原图的 quantization 作为 qtables。

问:校验时 PNG 版本遮罩外也有改动?

答:多半是读图时做了格式转换,比如原图是 CMYK 或带透明通道,转成 RGB 时数值变了。校验和保存要用同一份转换后的数组作对比,原图本身是 CMYK 的话,先和交付方确认要哪种色彩模式。

问:修补区域边缘能看到一圈淡淡的轮廓?

答:遮罩没有完全盖住水印的半透明边缘。把 make_mask 的 grow 调大一两个像素,或者把阈值调低一点,让遮罩多覆盖一圈。

我的做法

怎么去水印不破坏原图,我现在先问一句「线上还有没有原作品」:有的话,让运营同事用耶斯去水印把原文件取回来,不碰像素;只剩本地带水印文件的,就跑这套脚本,只修遮罩区域、其余像素拷回、元数据写回,交付前用校验结果说话。那批产品图修完后,品牌部拿去和老官网上的同款图并排比,挑不出差别。

0
0
0
0
评论
未登录
暂无评论