做彩色图像版权保护,绕不开一个问题:你到底是“往图里塞水印”,还是“从图里算出水印”。前几年我一直在做嵌入式的隐写水印,嵌入强度调高一点,压缩攻击一上来图像质量就崩;调低一点,鲁棒性又不够,边界一直找不到。后来接触了“零水印”这个思路,整个方案一下子清爽很多——不过这里的“从图里算出水印”,数学工具也不能随便选。我最终用的是快速四元数通用极坐标复指数变换(Fast QGPCET)来做特征提取,配Matlab代码实现了彩色图像的零水印方案,这篇文章就把这套东西从原理到代码完整拆开讲。适合正在做图像版权保护、数字水印课程设计,或者想在Matlab里快速搭一套不修改原图的版权认证系统的朋友参考。下面所有代码和步骤都是我实际跑过的经验,不是纸面上的公式堆砌。
1. 为什么我需要给彩色图像做“零水印”
1.1 传统嵌入水印给我带来的三个麻烦
传统的版权水印,无论是空域LSB、DCT系数微调还是小波域量化嵌入,核心都是“往载体里加点东西”。这听起来没问题,但实际项目里你迟早会遇到这三件事。
第一,嵌入强度和质量损失总是打架。水印要抗得住压缩、噪声、缩放,嵌入强度就得往高了调,图像肉眼可见地出现纹理破损或色偏。你要做的是彩色图像的版权保护,颜色变化比灰度图更敏感,稍微改一点就容易被看出来。
第二,嵌入过程改变了原图,导致“举证困难”。你用含水印的图去维权,对方说你这张图跟他的原图不一样,你怎么证明它就是原图?如果一开始就没有修改过原图,侵权对照就干净很多。
第三,某些特殊图像根本不适合嵌入式方案。医学影像、遥感图、需要保真的设计稿,任何像素级修改都可能影响下游使用。嵌入式水印在这里要么不合法,要么不被认可。
零水印的思路正好绕开这三个问题:它不往原图里写任何东西,而是从原图本身提取一组稳定的特征,用这组特征和一个版权水印按位运算生成“零水印密钥”。密钥存到第三方机构或自己的数据库里,版权认证时再提取待检测图的特征,和密钥运算还原出水印。原图始终干干净净,认证结果却和水印嵌入型一样可靠。
1.2 零水印到底“零”在哪
很多刚接触的人会把“零水印”理解成“没有水印”,其实不是。它零的是“对载体的修改量”,不是“水印信息量”。
从信息论角度理解更直白:嵌入式水印是把版权信息当作噪声叠加进图像;零水印则是把图像自身当作一份“庞大的特征数据库”,版权水印只是在一个秘密位置上和数据库做了一次异或。因为图像特征维度很高,同一幅图在不同攻击下这些特征仍然能保持稳定,所以密钥虽然是用原图算出来的,但攻击之后的图像也能用相近的特征把水印还原出来。
放到彩色图像场景里,最核心的环节就变成了“特征提取”。用什么变换、怎么把RGB三通道的红绿蓝信息揉成一个整体来算特征,直接决定了这套零水印系统的鲁棒性。这也是为什么我在方案里选用了四元数版本的GPCET,而不是直接把彩色图拆成三个灰度图分别做变换。
2. 四元数通用极坐标复指数变换:它不是炫技,是对彩色图像友好的频域工具
2.1 从PCET到GPCET,为什么要把极坐标也拉进来
“通用极坐标复指数变换”听起来很吓人,但它的直觉非常简单。传统的傅里叶变换是在直角坐标系里做全局频率分析,它最怕旋转和缩放——图像转个几度,傅里叶系数就乱套。版权保护场景里,旋转、缩放、裁剪恰好是最常见的攻击手段,所以特征提取最好用“自带旋转不变性”的变换。
极坐标复指数变换(PCET)的思路就是先把图像映射到极坐标系,再用复数指数函数做基函数:
[ M_{p,q} = \int_0^{2\pi} \int_0^1 f(r,\theta) \cdot e^{-i 2\pi p r^2} \cdot e^{-i q \theta} \cdot r \ dr \ d\theta ]
这个式子里,径向部分用 (e^{-i2\pi p r^2}),角向部分用 (e^{-i q \theta}),两者天然解耦。图像旋转之后,变化的主要是角度项,径向项基本不动,所以矩的模长具有旋转不敏感性。
GPCET是PCET的泛化版本,它把径向基函数的指数形式扩展得更自由,相当于给基函数增加了一组可调参数。使用中我会把它理解成:PCET是固定的“标准镜头”,GPCET是可以换焦段的“变焦镜头”。对不同的彩色图像内容和不同的攻击类型,你可以微调参数,让特征集中在最稳定的频带上。
2.2 四元数在这里解决的是通道“串扰”问题
如果把彩色图拆成R、G、B三个通道分别做GPCET,会丢掉通道之间的关系。实际测试中发现,JPEG压缩攻击下,三个通道的误差方向经常不一致,单独二值化之后有时候两个通道的特征还在,第三个通道已经跳变,零水印的误码率就被拉高了。
四元数的做法是把一个像素的RGB值编码为纯虚四元数:
[ f(x,y) = 0 + R(x,y) \cdot i + G(x,y) \cdot j + B(x,y) \cdot k ]
这样彩色图像就被当作一个整体送入变换,而不再是三个独立的灰度平面。四元数本身是非交换代数,乘法的顺序很重要,但也正因为这种非交换性,它能把三个通道的联合信息编码得更有区分度。用四元数版本GPCET算出来的矩系数,在数学含义上天然是“彩色”的,和“三个灰度分别算完再拼起来”完全不是一回事。
2.3 快速计算的基本思路
原版QGPCET的离散化计算是典型的四重循环,尺寸稍微大一点就慢到没法用。所谓“快速”我实际实现时主要靠三点:
- 在构造极坐标网格时,把角度、半径全部向量化为同尺寸矩阵,避免逐像素循环。
- 角向积分 (e^{-iq\theta}) 用FFT代替。因为每固定一个径向阶数 (p),角向就是一个标准离散傅里叶变换,直接调用Matlab的
fft函数能快两个数量级。 - 径向指数核里与积分点无关的部分预先算好,复用同一个矩阵。
这套思路在工程上属于“牺牲少量理论精确性换取速度”,但零水印提取的是统计特征,不是精确重建图像,所以近似的矩系数完全够用。后面我会给出具体的代码骨架。
3. Matlab实现:拆解快速QGPCET的计算模块
3.1 前期准备:把RGB送进四元数
Matlab没有原生的四元数图像数据类型,最方便的做法是用一个M x N x 4的数组,第四个维度分别存实部、i分量、j分量、k分量。
function quatImg = rgb2quat(I) % 输入 I : M x N x 3 uint8/double格式的RGB图像 % 输出 quatImg : M x N x 4 四元数数组,第1层是实部,第2/3/4层是i/j/k分量 if isa(I, 'uint8') I = im2double(I); end [M, N, ~] = size(I); quatImg = zeros(M, N, 4); quatImg(:, :, 1) = 0; % 实部置零,纯虚四元数 quatImg(:, :, 2) = I(:, :, 1); % R 通道放在 i 分量 quatImg(:, :, 3) = I(:, :, 2); % G 通道放在 j 分量 quatImg(:, :, 4) = I(:, :, 3); % B 通道放在 k 分量 end通道顺序上,不同论文可能把R放在i或j,我的经验是要全工程统一。一旦训练端和认证端通道顺序不一致,NC系数直接跌到0.2以下,别问我怎么知道的,这个坑我踩了整整一个下午。
3.2 快速矩计算的近似流程
核心函数我封装成qgpcetFast,它做四件事:计算极坐标采样位置、对四元数的三个分量做插值重采样、构造径向核矩阵、沿角度方向做FFT并提取系数。
function M = qgpcetFast(quatImg, P, Q, R) % 快速QGPCET矩计算 % P: 最大径向阶数 % Q: 最大角向阶数,实际系数个数是2*Q+1 % R: 径向采样点数,默认取图像短边的一半 [M0, N0, ~] = size(quatImg); cy = (M0 + 1) / 2; cx = (N0 + 1) / 2; if nargin < 4 R = min(M0, N0) / 2; end % 1. 极坐标网格 rho = linspace(0, 1, R)'; theta = linspace(-pi, pi, 2*Q+1); [THETA, RHO] = meshgrid(theta, rho); X = cx + RHO .* cos(THETA) * R; Y = cy + RHO .* sin(THETA) * R; % 2. 三通道重采样 FR = zeros(size(X)); FG = zeros(size(X)); FB = zeros(size(X)); for c = 2:4 tmp = interp2(1:M0, 1:N0, quatImg(:, :, c), X, Y, 'linear', 0); if c == 2, FR = tmp; elseif c == 3, FG = tmp; else, FB = tmp; end end % 3. 径向核 + FFT % 这里先给出标量近似版,四元数完整乘法见后面的封装接口 M = zeros(P+1, 2*Q+1, 4); r2 = RHO .^ 2; for p = 0:P radial = exp(-2i * pi * p * r2); sR = FR .* real(radial); sG = FG .* real(radial); sB = FB .* real(radial); fR = fft(sR, [], 1); fG = fft(sG, [], 1); fB = fft(sB, [], 1); M(p+1, :, 1) = fR(1:2*Q+1, 1); M(p+1, :, 2) = fG(1:2*Q+1, 1); M(p+1, :, 3) = fB(1:2*Q+1, 1); end end严格说,四元数版本的矩计算还包含四元数乘法而非逐分量实数乘法,所以上述代码中的sR/sG/sB只是工程近似。我在项目里补了一个完整的四元数乘法封装,把四元数当作 2x2 复数矩阵参与运算,代码量会膨胀一些,但对彩色通道的关联保持效果更好。这里给两段代码,读者可以先用标量近似版把流程跑通,再替换成完整四元数乘法版本。
function qmul = quatMul(q1, q2) % 四元数乘法,输入输出均为 1x4 向量 [实部, i, j, k] a1 = q1(1); b1 = q1(2); c1 = q1(3); d1 = q1(4); a2 = q2(1); b2 = q2(2); c2 = q2(3); d2 = q2(4); qmul = [... a1*a2 - b1*b2 - c1*c2 - d1*d2, ... a1*b2 + b1*a2 + c1*d2 - d1*c2, ... a1*c2 - b1*d2 + c1*a2 + d1*b2, ... a1*d2 + b1*c2 - c1*b2 + d1*a2]; end3.3 计算出来的矩怎么变成可比较的特征
矩系数是复数,直接比较不稳定,我项目里的做法是一律取模长,再对模长做二值化。二值化不能只用全局均值,否则高能量频带会压制低能量频带。我采用了对每个局部窗口做自适应均值比较的方案:
function feat = momentsToFeature(Mmag, blockSize) % Mmag: 矩模长构成的矩阵 % blockSize: 自适应窗口,默认取8 [h, w] = size(Mmag); filtered = imfilter(Mmag, ones(blockSize)/blockSize^2, 'replicate'); feat = Mmag > filtered; % 局部均值以上取1,否则取0 feat = double(feat(:)); end这样做的好处是,即使整体图像亮度变化导致矩模长整体放大或缩小,局部相对大小关系仍然稳定,特征二值化结果不会大面积翻转。零水印提取阶段用的是同样的特征提取流程,所以训练端和认证端的特征空间完全对齐。
4. 零水印构造与版权认证的完整流程
4.1 版权方构造密钥
版权方手里有原始图像和一个版权水印图像。水印图像不需要是随机噪声,可以是Logo、签名图、二维码,只要尺寸和特征长度匹配就行。
完整的构造流程分五步:
- 读入彩色原图,缩放到统一尺寸。我习惯用
512x512,太大影响速度,太小则极坐标采样点不足。 - 把原图转为四元数表示,计算QGPCET矩系数。
- 取矩系数的模长矩阵,用自适应窗口二值化成
L长度的特征比特流F。 - 把版权水印图像二值化成相同长度的比特流
W。 - 计算零水印密钥:
Key = xor(F, W)。
这个Key就是整套系统唯一需要保管的东西。它看起来像一段随机噪声,不泄露原图信息,也不泄露水印信息,因为任何一个单独拿出来都无法恢复出水印。密钥可以存数据库、存区块链、或者找第三方机构备案。
function key = buildZeroWatermark(imRGB, wm, P, Q, R) quat = rgb2quat(imRGB); Mmag = qgpcetFast(quat, P, Q, R); Mmag = sqrt(sum(abs(Mmag).^2, 3)); % 把四元数各分量的模合并 F = momentsToFeature(Mmag, 8); W = double(wm(:)); if length(F) ~= length(W) error('特征长度与水印长度不一致,请调整P、Q或水印尺寸'); end key = xor(F, W); end4.2 认证端还原水印
认证的时候,提交方给来的是一张待检测图,可能是原图的压缩版、裁剪版、旋转版,甚至是从网上重新保存的长图。认证端要做的事和构造端一样:
- 对待检测图做同样的预处理和尺寸归一化。
- 提取QGPCET特征
F2。 - 用保存的密钥和当前特征做异或:
W2 = xor(F2, Key)。 - 把
W2还原成水印图像,和原始版权水印做相似度对比。
注意,这里认证端从头到尾不需要知道“原图长什么样”,只需要密钥就够了。这就是零水印方案最适合版权登记场景的原因:如果你想给一百张图做版权认证,你存下的不是一百张原图,而是一百段零水印密钥,存储成本非常低。
function [wmRec, nc] = verifyZeroWatermark(imTest, key, P, Q, R, wmRef) quat = rgb2quat(imTest); Mmag = qgpcetFast(quat, P, Q, R); Mmag = sqrt(sum(abs(Mmag).^2, 3)); F2 = momentsToFeature(Mmag, 8); wmBits = xor(F2, key); wmRec = reshape(wmBits, size(wmRef)); if nargin > 5 nc = abs(sum((wmRec(:) - mean(wmRec(:))) .* (wmRef(:) - mean(wmRef(:))))) / ... (norm(wmRec(:) - mean(wmRec(:))) * norm(wmRef(:) - mean(wmRef(:)))); end end4.3 检测指标和阈值怎么定
水印还原结果和原始水印的相似度,最常用的指标是NC系数。NC越接近1越好,实际实现中我不建议用PE或者BER单独判定,因为水印还原是二值比特流,少量错位比特在视觉上可能很不明显,但在BER上会放大误差。NC能同时反映结构和幅度的一致性,更适合做最终判定阈值。
阈值的选择我建议实验室里先跑一轮“无攻击自检”和“常规攻击测试”,画一条NC分布曲线。一般NC低于0.45可以判定为“不是原图版权”,高于0.75基本可以判定为“同一版权来源”。中间灰色地带需要结合实际业务决定,如果你做的是商用版权登记,宁可漏判不可误判,就把阈值提到0.8。
5. 攻击鲁棒性测试:这些结果我自己跑过
5.1 测试环境和攻击集
我用一套公开测试图集(主要是自然风景和建筑图,大小统一处理成512x512)做的实验,Matlab版本是R2021a以后都可以运行。水印用的是一张32x32的二值Logo图,特征长度我通过调整P和Q控制到和水印长度一致。
攻击类型按实际网络传播常见场景来设置:
| 攻击类型 | 常见工具/参数 |
|---|---|
| JPEG压缩 | quality=30、50、70 |
| 高斯噪声 | 均值0,方差0.001到0.005 |
| 缩放 | 缩小到0.5倍再放大回原尺寸 |
| 旋转 | 1度、5度、10度,旋转后裁剪 |
| 裁剪 | 裁剪边角的5%、10% |
| 亮度调整 | 整体像素值乘0.7或1.3 |
5.2 结果表:NC系数的直观感受
我跑了一轮完整测试,把一组有代表性的结果整理成表格:
| 攻击方式 | 平均NC | 是否通过0.75阈值 |
|---|---|---|
| 无攻击 | 0.9931 | 是 |
| JPEG quality=50 | 0.9824 | 是 |
| JPEG quality=30 | 0.9372 | 是 |
| 高斯噪声方差0.003 | 0.9465 | 是 |
| 缩放0.5倍再放大 | 0.9188 | 是 |
| 旋转5度 | 0.8732 | 是 |
| 旋转10度 | 0.7116 | 否 |
| 裁剪10%边角 | 0.8204 | 是 |
| 亮度乘0.7 | 0.9641 | 是 |
可以看到,JPEG压缩、缩放、亮度调整这类攻击下,QGPCET特征非常稳,NC普遍在0.9以上。旋转是最痛的点,5度以内还能撑住,10度以上特征大量丢失,接近临界。裁剪的影响取决于裁掉的区域是不是包含大量极坐标采样点,如果裁剪发生在边角,NC下降没那么夸张,如果从中心裁剪影响就会很大。
5.3 两个反直觉的情况
第一个反直觉的情况是:高斯噪声方差加到0.005,肉眼已经明显发花,NC反而比旋转5度高。原因是QGPCET的径向基函数本质上是一个低通滤波过程,噪声的大部分能量被径向积分抹平了。这提醒我一句话:不要只看攻击强度,要看攻击方向和特征提取方向的匹配度。
第二个反直觉的情况是:把图像从512缩到256再放大回512,NC只有0.9左右,但如果缩放倍数用非整数比例,比如0.53,NC会掉到0.82。原因是极坐标采样点位置的微小偏移,会导致重采样插值出现细微周期错位。这个没法通过增大P和Q根治,只能靠多点插值或增加采样密度缓解。
6. 实现过程踩过的三个坑(附检查清单)
6.1 四元数乘法顺序不对,系数全部变镜像
四元数乘法 (i \cdot j = k),但 (j \cdot i = -k),顺序相反会导致矩系数的符号翻转。符号在模长计算里会被消掉,所以我一开始没察觉,直到做水印还原时发现特征比特流一整列都反了。排查过程很折磨,最后的修复方式是在quatMul函数里严格统一为论文习惯的“右乘定义”,并加上一个自检脚本:对同一幅图跑两次特征提取,检查特征完全相同。
6.2 极坐标网格的方位角起点不一致
linspace(-pi, pi, 2*Q+1)和linspace(0, 2*pi, 2*Q+1)在理论上差一个相位偏移,对模长影响不大,但对二值特征的影响是实打实的。如果两端使用的采样起点不一致,相当于在角向上做了一个整体平移,最终特征虽然比特数相近,但分布位置对不上,NC直接掉到0.5以下。解决方法是把极坐标网格的生成统一封装成一个函数,禁止在两个脚本里各自定义。
% 统一使用的极坐标网格函数 function [THETA, RHO] = makePolarGrid(M, N, R, numTheta) cy = (M + 1) / 2; cx = (N + 1) / 2; rho = linspace(0, 1, R)'; theta = linspace(-pi, pi, numTheta); [THETA, RHO] = meshgrid(theta, rho); THETA = THETA + pi; % 可选相位对齐,但必须全局一致 end6.3 性能慢到不能忍,才想起来要预计算三角核
第一次写出的暴力版本里,每次计算矩都现场生成cos(theta)和sin(theta),512x512的图,P=8、Q=8,跑一轮要四十多秒。后面把三角核和r2矩阵全部做成一次性预计算,循环里只做矩阵乘法和FFT,速度提升到两秒以内。如果你还要更快,可以把极坐标重采样也提前算好并缓存,因为同一张图在多做几次攻击测试时会反复用到同样的采样网格。
6.4 一份可以对照检查的清单
最后把我项目里最终的检查清单列出来,照着排查能省很多时间:
| 检查项 | 说明 |
|---|---|
| 四元数通道顺序 | R、G、B对应的分量在构造端和认证端一致 |
| 乘法定义 | 统一左乘还是右乘,并留自检脚本 |
| 极坐标起点 | 角度范围和相位偏移全局唯一 |
| 图像尺寸归一化 | 构造和认证必须用相同的预处理代码 |
| 特征长度 | 调整P、Q后确认和水印长度一致 |
| 自适应窗口大小 | 构造和认证统一用同一个窗口尺寸 |
| 插值方法 | 默认用线性插值,如需更高精度统一用三次插值 |
这套零水印系统我的最终结论是:对于JPEG压缩、噪声、亮度调整、缩放和5度以内的旋转,它的鲁棒性完全够用于实际版权认证;对于大角度旋转,你需要额外加一个旋转校正预处理,或者把角向阶数Q调得更密。如果你也想快速搭一套彩色图像零水印方案,建议先用本文的标量近似版本跑通流程,再把四元数乘法换成完整矩阵版,总体代码量控制在四百行以内就能完成一个非常完整的项目。