前言
由于工作需要,最近学习了盲水印相关的知识,本文对学习过程中做一个整理和总结。主要内容包括:
- 对盲水印相关概念做基本介绍
- 对开源的 python 算法 blind_watermark 进行解析,给出算法流程
- 基于 blind_watermark,给出了对应的 C/C++ 实现代码,你可以在 cpp_blind_watermark 找到所有代码
一、Blind Watermark 简介
盲水印(blind watermark)算法是一种将数字水印嵌入到数字媒体中的技术,而不需要原始媒体文件。与传统的数字水印技术不同,盲水印算法不需要原始媒体文件来提取数字水印,因此更加安全和隐私保护。
盲水印算法的基本原理是将数字水印嵌入到数字媒体的频域或空域中,使得数字水印能够在不影响原始媒体质量的情况下被提取出来。盲水印算法通常包括两个主要步骤:嵌入和提取。
在嵌入阶段,数字水印被嵌入到数字媒体中。这通常涉及到将数字水印转换为频域或空域信号,并将其嵌入到数字媒体中。嵌入过程需要考虑数字水印的鲁棒性和不可见性,以确保数字水印能够在不影响原始媒体质量的情况下被提取出来。
在提取阶段,数字水印被提取出来。这通常涉及到对数字媒体进行一些处理,以提取数字水印。提取过程需要考虑数字水印的鲁棒性和准确性,以确保数字水印能够被正确地提取出来。
盲水印算法在数字版权保护、数字身份认证和数字隐私保护等领域具有广泛的应用。它可以帮助数字内容提供商保护其版权,防止盗版和侵权行为;也可以帮助用户保护其数字身份和隐私,防止个人信息被泄露。
二、算法流程
本章分析 blind_watermark 中算法逻辑
2.1 水印嵌入
整体流程如下图,接下来对各个阶段做详细的解释。
2.1.1 水印二进制化
水印可以是各种形式的信息,包括字符串、图片等。为了嵌入这些信息,我们首先需要将水印转换为二进制形式,即由0和1组成的数组。例如,我们可以将字符串转换为二进制数组。以下是一种实现方法:
byte = bin(int(wm_content.encode('utf-8').hex(), base=16))[2:]
self.wm_bit = (np.array(list(byte)) == '1')
在上面的代码中,首先将字符串类型的水印信息 wm_content 转换为十六进制格式,然后使用 int() 函数将其转换为整数。接着,使用 bin() 函数将整数转换为二进制格式,并去掉前缀 ‘0b’,得到一个字符串类型的二进制数。最后,使用 np.array() 函数将字符串转换为一个由 ‘0’ 和 ‘1’ 组成的数组,并将其与 ‘1’ 做比较,得到一个布尔类型的数组 self.wm_bit,其中 True 表示对应位置为 ‘1’,False 表示对应位置为 ‘0’。
除了上述方法外,如果水印信息由 ASCII 码组成,也可以直接将每个字符转换为 8 位的二进制字符串,然后将这些字符串拼接成一个二进制数组。这个过程可以使用 Python 内置的 bin() 函数和字符串操作来实现。
2.1.2 图像预处理
读取图片后,我们进行图像预处理,包括:
- 如果图片包含 Alpha 通道,那么忽略它,只保留 RGB 颜色通道
- 如果图片的大小不是偶数的,那么对图片进行填充,使其大小为偶数。做这一步是因为后续需要对图片进行分块处理,块大小是 4x4,如果图片大小不是偶数的话,处理起来比较麻烦
- 将 RGB 转换为 YUV444 格式
2.1.3 YUV 数据进行小波变换(DTW)
得到 YUV 数据后,接着对各通道进行二维小波变换。
for channel in range(3):
self.ca[channel], self.hvd[channel] = dwt2(self.img_YUV[:, :, channel], 'haar')
对于小波变换,具体原理我们暂时不进行深究,只需要了解:
- 在二维离散小波变换中,将图像分解为四个子图像,分别表示原图像的近似部分(Approximation,简称 cA)和水平(Horizontal,简称 cH)、垂直(Vertical,简称 cV)以及对角线(Diagonal,简称 cD)方向的细节部分。具体地,cA 表示原图像的低频部分,即近似部分,包含了图像的大部分能量;cH、cV 和 cD 分别表示原图像在水平、垂直和对角线方向上的高频部分,包含了图像的细节信息。这些子图像可以通过多级小波分解得到,其中每一级分解都将近似部分进一步分解为更低频的近似部分和更高频的细节部分。
- 如果输入图像为 M x N,那么四个子图像大小为 M/2 * N/2。
- 通过二维离散小波逆变换,将四个子图像恢复为原来图像。
2.1.4 在 cA 图像中分块嵌入数据
YUV 通道进行小波变换后得到 cA 子图像,接着对 cA 矩阵进行分块处理(block),块大小为 4x4。对于每个 block,我们嵌入一个 bit 的信息。
在 blind_watermark 中,提供了两种嵌入 bit 的算法:
def block_add_wm_slow(self, arg):
block, shuffler, i = arg
# dct->(flatten->加密->逆flatten)->svd->打水印->逆svd->(flatten->解密->逆flatten)->逆dct
wm_1 = self.wm_bit[i % self.wm_size]
block_dct = dct(block)
# 加密(打乱顺序)
block_dct_shuffled = block_dct.flatten()[shuffler].reshape(self.block_shape)
u, s, v = svd(block_dct_shuffled)
s[0] = (s[0] // self.d1 + 1 / 4 + 1 / 2 * wm_1) * self.d1
if self.d2:
s[1] = (s[1] // self.d2 + 1 / 4 + 1 / 2 * wm_1) * self.d2
block_dct_flatten = np.dot(u, np.dot(np.diag(s), v)).flatten()
block_dct_flatten[shuffler] = block_dct_flatten.copy()
return idct(block_dct_flatten.reshape(self.block_shape))
def block_add_wm_fast(self, arg):
# dct->svd->打水印->逆svd->逆dct
block, shuffler, i = arg
wm_1 = self.wm_bit[i % self.wm_size]
u, s, v = svd(dct(block))
s[0] = (s[0] // self.d1 + 1 / 4 + 1 / 2 * wm_1) * self.d1
return idct(np.dot(u, np.dot(np.diag(s), v)))
block_add_wm_fast
更简单,我们做一个简短的说明:
- 首先对 block 进行 DCT(离散余弦变换),block 大小为 4x4,DCT 输出大小也是 4x4。
- 对 DCT 输出矩阵进行奇异值分解(SVD),返回值 S 中包含奇异值。通过
(s[0] // self.d1 + 1 / 4 + 1 / 2 * wm_1) * self.d1
将 1bit 数据嵌入第一个奇异值中。 - 嵌入数据后,恢复 block。 将 SVD 分解后的矩阵重新构造为原始矩阵。 接着,使用 idct() 函数对这个矩阵进行逆离散余弦变换(IDCT),得到重构后的矩阵。
重复上述过程,将每一个 bit 都嵌入到对应 block 中,则完成了数据嵌入,伪代码如下:
# Y_ca,U_ca 和 V_ca 即 YUV 经过小波变换后得到的 ca 矩阵
for ca in (Y_ca, U_ca, V_ca):
i = 0
# 遍历每一个 block
for block_index in range(0, ca.get_block_count()):
block = ca.getBlock(block_index) # 获取 block 数据
bit_data = wm_bit[i % wm_size] # 获取 1 bit数据
embeded_block = block_add_wm(block, bit_data) # 在当前 block 上嵌入 1 bit 数据
ca.setBlock(block_index, embeded_block) # 将嵌入后的 block 写入 ca 矩阵中
根据这个嵌入的逻辑,我们可以计算下一张图片最多能够嵌入多少 bit 数据:
- 假设输入图片大小为 W x H,图片矩阵则是 H x W,转换为 YUV 后其矩阵大小仍然是 H x W
- 进行小波变换后,ca 矩阵大小是输入矩阵的一半,即 (H/2) x (W/2)
- 假设 block 大小为 (4, 4),那么 ca 矩阵上一共有 ( H / 2 ∗ W / 2 ) 4 ∗ 4 = H ∗ W 64 \frac{(H/2 * W/2)}{4*4} = \frac{H*W}{64} 4∗4(H/2∗W/2)=64H∗W 个 block;每个 block 嵌入 1 bit 数据,那么最多能够嵌入 H ∗ W 64 \frac{H*W}{64} 64H∗W 个 bit
- 以 512 x 512 图片大小为例,最多嵌入 4096 个 bit,也就是一张 64x64 的黑白图片,或者 512 个 ASCII 字符
2.1.5 恢复为 RGB 数据
在 ca 矩阵上嵌入数据后,通过逆小波变换转换为 YUV 数据,接着 YUV 数据可以转为 RGB 以便保存为图片文件,或者用于其他处理,伪代码如下:
for channel in range(3):
embed_YUV[channel] = idwt2(embed_ca[channel], hvd[channel])
embed_img = yuv2rgb(embed_YUV)
save_image("embed.jpg", embed_img)
至此,我们完成了盲水印的嵌入。
2.1.6 乱序
值得注意的是,我在这里省略了blind_watermark中关于password参数的描述。这个参数用于设置一个随机种子,以便在嵌入过程中打乱嵌入数据的顺序。我认为这是一个可选的部分,对于不熟悉该算法的人来说,这部分代码可能会让人感到困惑。因此,我想先把其他核心流程讲清楚,而password的乱序功能只是对整个过程的一个补充。
2.2 水印提取
水印的提取过程是嵌入的逆过程。整体流程如下图,对各流程做详细的解释。
2.2.1 图片数据预处理
与嵌入流程类似,读取图片进行预处理:
- RGB 转到 YUV
- 对 YUV 数据进行二维小波变换,得到四个子图像
2.2.2 在 cA 图像分块中提取数据
接着对 cA 矩阵进行分块处理(block),块大小为 4x4。对于每个 block,我们提取一个 bit 的信息。提取的算法为嵌入的逆过程:
def block_get_wm_slow(self, args):
block, shuffler = args
# dct->flatten->加密->逆flatten->svd->解水印
block_dct_shuffled = dct(block).flatten()[shuffler].reshape(self.block_shape)
u, s, v = svd(block_dct_shuffled)
wm = (s[0] % self.d1 > self.d1 / 2) * 1
if self.d2:
tmp = (s[1] % self.d2 > self.d2 / 2) * 1
wm = (wm * 3 + tmp * 1) / 4
return wm
def block_get_wm_fast(self, args):
block, shuffler = args
# dct->flatten->加密->逆flatten->svd->解水印
u, s, v = svd(dct(block))
wm = (s[0] % self.d1 > self.d1 / 2) * 1
2.2.3 取平均
在嵌入过程中,我们实际上是在冗余地嵌入数据。这包括在YUV的三个通道中嵌入相同的数据,以及在数据长度较短的情况下,例如只有4个比特,我们会重复地将这4个比特写入到块中。这种冗余性可以增强盲水印的抵抗攻击能力。因此,在提取数据时,我们可以采取取平均值的方法来获取实际的数据。伪代码如下:
# 从 YUV 数据中,提取 01 数组
for channel in range(0):
wm_block_bit[channel] = get_wm_block_bit(embed_YUV[channel])
# wm_block_bit 是一个 3xN 的数组,其中 N 为 bit 的个数
# 我们对它取平均,得到 N 个数据
wm_block_channel_avg = np.average(wm_block_bit, axis=0)
# 此时 wm_block_channel_avg 长度为 N,假设 wm_bit_size = m
# 对于循环嵌入的 bit 数据取平均
wm_avg = np.zeros(shape=self.wm_size)
for i in range(self.wm_size):
wm_avg[i] = wm_block_bit[:, i::self.wm_size].mean()
# 得到 wm_avg 是一个长度为 m 的数组
2.2.4 Kmeans 聚类
wm_avg 是一个浮点数组,如果你要完全地恢复数据到 01 的状态,那么需要做进一步计算。blind_watermark 作者给出的方法是使用 kmeans 进行二分类。本人实测这种方法确实比较准确。
但如果你嵌入的是图片,那么可以不用做 kmeans,因为浮点值可以对应亮度。
三、其他问题
3.1 关于水印长度的问题
可以看到在提取水印时,我们需要知道水印的长度,以便对 01 数组去平均值。这就带来一个问题:在提取水印时,你无法知道该图片水印的长度。例如 A 图片嵌入 4 bit 数据,B 图片嵌入了 5 bit 数据,当你要提取 A 和 B 图片暗水印时,要如何处理?
我们可以固定水印长度,例如约定都是 64 个 bit,如果水印数据超过了这个长度,那么对不起,算法没法嵌入;如果不足 64 bit,那么剩余 bit 全部置为 0 即可。
3.2 关于抗攻击的说明
在 example_str 中,作者展示了 blind_watermark 抗攻击的能力,包括截屏、缩放等等。
但需要说明的是,这里的测试是有要求的:无论做了哪种攻击,在提取水印前你需要将图片位置正确的排放。例如:
- 缩放攻击。假设图片从 512 * 512 缩放到 256 * 256,那么首先将图片恢复至 512 * 512,再送给算法
- 截屏攻击。从 (x, y) 点截取 200*200 的图片,你不能直接用着 200 * 200 的数据送给提取水印的算法,而是创建一个与原图一致的矩阵,将 200 * 200 的数据填充回 (x, y) 点,矩阵其他地方数据可以为 0。完成了这项操作后,再把数据输入到算法
之所以有这种要求,算法在嵌入和提取的过程中,隐式地依赖了 block 的位置信息。在实际使用中,上述条件比较难满足,因为你不知道用户会对图片做什么操作。这也是盲水印算法的某种局限和难点。
四、关于 C/C++ 算法实现
你可以在 cpp_blind_watermark 仓库中看到对应 C/C++ 版本,它的依赖项目有:
- opencv,提供了处理图片、矩阵操作、离散余弦变换等能力
- wavelib,提供了小波变换能力
在 main.cpp 中有具体的代码示例;在 tests 下有各个模块的单元测试,如果你对某个接口不了解,可以在这里找到一些信息。