简介:基于最小信息熵准则的图像水印与替换Python源码,面向计算机视觉、多媒体安全方向的在校学生、科研人员及企业开发者,可用于数字水印嵌入、图像区域替换等场景的算法验证与二次开发。压缩包共7个文件,以两个Python脚本为核心,实现主流程与功能模块;附带lena等标准测试图像(jpg)、结果示意图(png)及说明文档(md),另含一个rar压缩包,可能存放扩展素材或备份代码,整体大小仅244KB,轻量易部署。已有173人学习下载,适合作为图像处理课程设计、毕设项目或初期立项演示的基础工程。代码经验证可运行,结构简洁,读者可基于最小信息熵准则理解水印嵌入与内容替换的完整逻辑,并根据自身需求修改参数或扩展接口,快速迁移到实际任务中。README文档提供了必要说明,便于快速上手与排错。
1. 最小信息熵准则如何改变图像水印的嵌入策略
图像水印的嵌入位置选择一直是个权衡问题:嵌入强度高则鲁棒性好但视觉失真明显,嵌入强度低则不可见但抗攻击能力弱。传统做法用像素方差或边缘检测来定位纹理区域,但遇到噪声干扰时方差会给出误导性结果。基于最小信息熵准则的做法反其道而行之,它不是在纹理最丰富的区域嵌入,而是先把图像拆成小块,计算每个块的灰度信息熵,选熵值最低的那一批块作为嵌入载体。这个过程对水印方案设计者意味着什么?它直接改变了嵌入位置的选择依据,从“哪里藏得住”变为“哪里变了也看不出来”。这套思路在 Python 里实现并不复杂,核心代码不超过 100 行,但需要理解信息熵的计算边界条件、块尺寸对结果的影响、以及熵值和视觉冗余之间的关系。适合正在做数字水印、图像隐写或版权保护的开发人员,也适合研究信息隐藏算法的学生对照源码梳理细节。
2. 最小信息熵准则的计算逻辑与 Python 表示
2.1 图像信息熵的定义和极值特征
信息熵是香农信息论中的概念,在图像处理中通常用来量化图像的复杂度。对一幅灰度图像,假设每个像素值是一个随机变量,其取值概率为 p(i),那么该图像块的信息熵定义为:
H = -∑ p(i) · log₂ p(i)
其中 i 是灰度级别(通常为 0~255),p(i) 是灰度值 i 在图像块中出现的频率。当图像块中所有像素灰度值相等时,熵为 0;当像素灰度分布完全均匀时,熵达到最大值 8(对于 8 位灰度图)。这就是信息熵的两个极值边界,理解这两个边界对后续选块非常重要,因为熵值本身是一个相对量,只有放到具体图像的具体分布里才有意义。
最小信息熵准则的含义是:在候选图像块中,优先选择信息熵值最小的块。这些块对应的往往是天空、墙面、桌面、未聚焦的背景等平坦区域,它们的像素分布集中,视觉冗余度高。对这类区域进行小幅改动,人眼几乎感知不到差异,这就是水印嵌入的天然载体。但需要注意,熵值低只代表灰度分布集中,不代表该块一定适合嵌入,还要结合图像内容和嵌入算法做二次验证。
2.2 为什么不用方差而用信息熵
很多开发者会问:方差不是也能度量平坦程度吗?为什么要换成信息熵?这两者的关键差异在于对灰度分布形状的敏感度。方差只反映像素值的离散程度,无法区分“两个灰度值各占一半”和“灰度值集中在中间范围”这两种分布;信息熵则同时考虑取值种类和概率分布形态,当概率分布越不均衡时,熵越低。
看一组对比:假设图像块 A 的灰度分布为 {128: 127, 129: 1},方差约为 0.98;图像块 B 的灰度分布为 {100: 64, 200: 64},方差高达 2500。用方差判断,块 B 更“复杂”;用熵判断,块 A 的熵约为 0.08 bits,块 B 的熵刚好为 1 bit,块 A 反而更适合嵌入。这个例子说明最小信息熵准则把“低复杂度”的判定细化到了概率分布层面,更能贴合人类视觉对平坦区域的感知。实际项目中如果你用 OpenCV 的cv2.calcHist配合 numpy 计算,熵值的分布形态会和方差给出完全不同的排序结果,这也是这套准则在工程上被采纳的主要原因之一。
2.3 用 Python 实现信息熵计算
实现时最基础的是直方图统计。下面的函数读取一张灰度图,按指定块大小切分并返回每块的信息熵值:
import numpy as np import cv2 def block_entropy(image, block_size=8): """ 计算图像块的信息熵 :param image: 输入灰度图像 (H, W) uint8 :param block_size: 块边长,默认8 :return: 熵值矩阵 (H/block_size, W/block_size) float """ h, w = image.shape pad_h = h - h % block_size pad_w = w - w % block_size image = image[:pad_h, :pad_w] blocks = image.reshape(pad_h // block_size, block_size, pad_w // block_size, block_size) blocks = blocks.transpose(0, 2, 1, 3).reshape(-1, block_size, block_size) entropies = [] for block in blocks: # 计算灰度直方图,归一化为概率分布 hist = cv2.calcHist([block], [0], None, [256], [0, 256]) hist = hist.ravel() / hist.sum() # 去掉概率为0的项,避免log2出错 hist = hist[hist > 0] ent = -np.sum(hist * np.log2(hist)) entropies.append(ent) return np.array(entropies).reshape(pad_h // block_size, pad_w // block_size)这个函数先裁剪图像使尺寸能被块大小整除,再用 reshape 把每个块独立提出来。cv2.calcHist生成灰度直方图,归一化后得到概率分布。最后对非零概率项求和,得到该块的信息熵。这里的关键点是hist[hist > 0]过滤掉零概率项,否则log2(0)会产生无效值,这在处理纯黑或纯白块时尤其重要,很多直接套公式的源码都会在这里出错。
参数说明:block_size的选择直接影响熵值的离散程度。块越小,熵值波动越大,但嵌入容量也越大;块越大,熵值越平滑,嵌入位置定位越粗糙,但水印的抗几何攻击能力更强。常规做法是 8×8 或 16×16,如果要嵌入长水印序列,可以用 4×4。下面这组对比可以帮助你根据需求选择块尺寸:
| 块尺寸 | 候选块总数(512×512图) | 单块承载力(LSB模式) | 典型用途 |
|---|---|---|---|
| 4×4 | 16384 | 8~16 bits | 大容量、弱鲁棒 |
| 8×8 | 4096 | 32~64 bits | 平衡型 |
| 16×16 | 1024 | 128~256 bits | 高鲁棒、小容量 |
在整张图的计算中,熵值最低的那一批块往往聚集在图像的上半部分天空或纯色背景区域。验证方法是把熵值矩阵二值化后叠加回原图,能清楚看到低熵区域的分布形态,这一步建议在选块之前做一次可视化确认,避免算法在特殊构图的图像上选出不合理的区域。
3. 基于最小信息熵准则嵌入水印的完整实现
3.1 嵌入流程的设计思路
拿到熵值矩阵后,嵌入流程不是直接把水印塞进低熵块,而是要经过三个步骤:排序选块、强度分配、反变换写回。排序的核心是把熵值从小到大排列,取前 N 块作为候选;强度分配是让不同的块使用不同的量化步长,熵值越低,量化步长越小,避免在平坦区域引入可见的块效应。
选择低熵块时还要考虑一个关键约束:避免所有水印集中在一个连通区域。一旦图像被裁剪或局部替换,整段水印可能全部丢失。常见做法是引入随机种子,把选中的块打散分布在图像的不同位置。这里有个细节需要关注,打散选块时不能改变“选取哪些块”的事实,只能改变块的先后顺序,否则水印的提取端无法对齐位置。
3.2 DCT 域嵌入水印的代码
空域 LSB 在低熵块上容易产生斑块噪声,我一般会做 DCT 域的量化嵌入,对视觉质量更友好。核心代码如下:
def embed_watermark(image, watermark_bits, entropy_map, block_size=8, seed=42): """ 在低熵块上按DCT量化方式嵌入水印 :param image: 灰度图 :param watermark_bits: 待嵌入的比特列表,元素为0或1 :param entropy_map: 由block_entropy计算得到的熵矩阵 :param block_size: 块尺寸,需与entropy_map一致 :param seed: 随机种子,用于打散选择顺序 :return: 嵌入水印后的图像 """ h, w = image.shape # 裁剪尺寸 image = image[:h - h % block_size, :w - w % block_size] h, w = image.shape # 选出熵值最小的块索引,数量等于水印比特长度 flat_indices = entropy_map.argsort(axis=None)[:len(watermark_bits)] rng = np.random.RandomState(seed) flat_indices = rng.permutation(flat_indices) # 打散顺序 embedded = image.copy() watermark_bits = np.asarray(watermark_bits, dtype=np.uint8) for bit, idx in zip(watermark_bits, flat_indices): block_y, block_x = np.unravel_index(idx, entropy_map.shape) y0, x0 = block_y * block_size, block_x * block_size block = image[y0:y0+block_size, x0:x0+block_size] - 128.0 # 减均值便于DCT dct_block = cv2.dct(block) # 取(1,0)和(0,1)两个中频系数做差分嵌入 mid_coeff = dct_block[1, 0] quant_step = 8 if bit == 1: target = max(abs(mid_coeff), quant_step) # 保证系数幅度足够大 dct_block[1, 0] = target if mid_coeff >= 0 else -target else: dct_block[1, 0] = mid_coeff if abs(mid_coeff) < quant_step else 0 embedded[y0:y0+block_size, x0:x0+block_size] = cv2.idct(dct_block) + 128.0 return np.clip(embedded, 0, 255).astype(np.uint8)这段代码的关键在于量化步长quant_step和嵌入系数位置的选择。(1,0)是 DCT 系数矩阵中的中频分量,它在平坦区域(低熵块)上的幅值本来就很小,修改它不会引起明显的视觉失真。quant_step设为 8 意味着嵌入 1 时把该系数强制到 8 以上,提取时只要判断系数的绝对值是否大于 8 即可。
参数调整时要注意:quant_step过小会导致提取误码率上升;过大会让低熵块上出现周期性的条纹伪影。一般根据图像的最大允许失真来调,如果 PSNR 目标在 40dB 以上,quant_step建议控制在 4~8 之间。另外,seed参数在嵌入和提取时必须保持一致,否则打散顺序不同,水印提取出来的是一串乱序比特。
3.3 提取时的一致性处理
提取水印必须知道原来的熵值矩阵吗?不需要。提取端只要重新对含水印图像计算熵值矩阵,用相同种子做一次随机排列,就能恢复出嵌入位置。这是因为嵌入强度足够小时,低熵块的位置在嵌入前后基本不发生变化。如果遇到 JPEG 压缩导致熵值排序微变,可以扩大候选块数量,用冗余编码来容错。
def extract_watermark(image, length, block_size=8, seed=42): h, w = image.shape image = image[:h - h % block_size, :w - w % block_size] entropy_map = block_entropy(image, block_size) flat_indices = entropy_map.argsort(axis=None)[:length] rng = np.random.RandomState(seed) flat_indices = rng.permutation(flat_indices) bits = [] for idx in flat_indices: block_y, block_x = np.unravel_index(idx, entropy_map.shape) y0, x0 = block_y * block_size, block_x * block_size block = image[y0:y0+block_size, x0:x0+block_size] - 128.0 dct_block = cv2.dct(block) bits.append(1 if abs(dct_block[1, 0]) > 8 else 0) return bits提取逻辑比嵌入简单,只要做 DCT 变换并判断中频系数是否超过阈值。注意这里abs(mid_coeff)的判断阈值 8 必须与嵌入时的quant_step对应,否则会误判。还有一个容易被忽略的细节:提取端不能先对含水印图像做任何预处理(比如去噪、锐化),这些操作会破坏低熵块上的中频系数,导致提取失败。
4. 最小信息熵准则在图像替换中的应用边界
4.1 替换不是嵌入:目标区域的“重写”规则
标题中的“或替换”指的是另一类问题:某些场景下不是嵌入水印,而是需要用生成内容覆盖掉原区域的像素信息。典型的应用有版权遮挡、敏感区域模糊、图像修复中的内容填充。这时候选区域会用到相反的判断逻辑——嵌入水印找低熵块是为了不可见修改,替换则要根据替换的意图选择不同熵特征的区域。
如果目标是“遮挡信息”(比如盖住人脸、车牌),替换区域本身的熵值无关紧要,关键是替换内容的熵要与周围区域匹配。这里最小信息熵准则用来评估替换内容与背景的一致性:替换块的熵值应低于周边邻域的熵均值,否则视觉上会显露出明显的“补丁感”。实际工程里,替换通常发生在高熵区域(纹理丰富),因为低熵区域直接涂色即可,不需要复杂的内容感知填充。
4.2 基于熵差的内容替换实现
常见做法是先在图像上标记待替换的掩码,然后在候选纹理中搜索与目标区域熵最接近的填充块。下面给出一个用滑窗扫描填充块的示例:
def replace_with_low_entropy_block(image, mask, fill_size=16): """ 使用低熵区域内容替换掩码区域 :param image: 灰度图 :param mask: 二值掩码,1表示待替换 :param fill_size: 填充块边长 :return: 替换后的图像 """ result = image.copy() entropy_map = block_entropy(image, fill_size) # 获取掩码区域覆盖的块坐标 h, w = image.shape mask_blocks = [] for y in range(0, h - fill_size + 1, fill_size): for x in range(0, w - fill_size + 1, fill_size): if mask[y:y+fill_size, x:x+fill_size].mean() > 0.5: mask_blocks.append((y, x)) # 在非掩码区域中寻找熵值最低的块作为填充源 for y, x in mask_blocks: block_y = y // fill_size block_x = x // fill_size # 将当前掩码块的熵置为极大值,避免选中自身 entropy_map[block_y, block_x] = np.inf for y, x in mask_blocks: flat_idx = entropy_map.argmin() src_y, src_x = np.unravel_index(flat_idx, entropy_map.shape) src_block = image[src_y*fill_size:src_y*fill_size+fill_size, src_x*fill_size:src_x*fill_size+fill_size] result[y:y+fill_size, x:x+fill_size] = src_block # 每次填充后发现熵次低的块,需要暂时将已用块排除 entropy_map[src_y, src_x] = np.inf return result这个实现有个值得注意的细节:程序每次填充后把已经用过的源块熵值置为无穷大,避免下一次填充重复使用同一块内容,否则图像会出现周期性重复纹理。实际项目中不会直接用这种贪心策略,而是会配合结构相似度指标来选择,但熵准则负责第一轮筛选,能显著减少候选块数量。这里的np.inf是一种常见的“排除已选项”技巧,比维护一个已用集合更高效。
4.3 水印与替换共用同一套熵图模块
两个场景的代码虽然不同,但对熵图的计算和存储是完全共用的。我在实际项目中会封装一个EntropyIndex类,统一输出熵矩阵、排序索引、以及低熵块的坐标列表。水印嵌入调用get_low_entropy_blocks(ratio=0.1),区域替换调用get_replacement_candidates(target_entropy, tolerance=0.5)。这样把熵计算逻辑收敛到单一模块,后续改用加速方案时不需要动业务代码。
替换场景的一个常见误用是直接对整张图计算熵图,然后挑最低熵块填充,结果发现填充块和周围区域灰度均值相差很大。原因是熵只描述分布形状,不描述灰度位置。正确的做法是加一道均值约束:先筛选熵相近的块,再在其中找均值与目标区域最接近的。这个过程与 DCT 嵌入中的直流系数处理是类似的道理,都是在一维约束之外补充第二维度的度量。我在实际调参时会把熵值权重设为 0.7、灰度均值权重设为 0.3,效果比单用熵值稳定得多。
5. 验证水印质量与排查嵌入失效的 3 个技巧
5.1 用信息熵变化曲线验证嵌入强度
嵌完水印后,第一步不是看 PSNR,而是把嵌入前后的熵值矩阵做差,观察低熵块的熵值偏移方向。做法如下:
original_ent = block_entropy(original_img, 8) watermarked_ent = block_entropy(watermarked_img, 8) delta = watermarked_ent - original_ent # 统计被选中块的平均熵增量 selected_blocks = entropy_map.argsort(axis=None)[:len(watermark_bits)] print("平均熵增量:", delta.ravel()[selected_blocks].mean())当平均熵增量小于 0.15 时,视觉上基本察觉不到改动;超过 0.4 时,平坦区域会出现可感知的颗粒感。如果发现熵增量集中在个别块上,说明quant_step太大或选块逻辑在随机打散后没有做到空间均匀分布。
5.2 提取失败时先检查块对齐
水印嵌入失败或提取全错,80% 的情况是图像尺寸与块尺寸不对齐导致的。很多源码里直接调用block_entropy后不做尺寸裁剪,当图像宽度不能被 8 整除时,最后一列块的像素边界会发生偏移,提取端 DCT 窗口错位。我的验证方法是打印每块坐标,与手工标注的嵌入位置对比,两步就能定位问题。命令行里可以这样快速定位:
python -c "import cv2; img=cv2.imread('test.png',0); print(img.shape[0]%8, img.shape[1]%8)"只要输出不是0 0,就说明尺寸裁切逻辑有遗漏。
5.3 用真实攻击测试替代主观目测
判断最小信息熵准则是否真的有效,需要跑一组标准攻击序列:JPEG 压缩(质量因子 85、70、50)、中值滤波(3×3)、缩放回原尺寸。每轮攻击后提取水印并计算位错误率(BER)。
| 攻击类型 | 参数设置 | 期望BER(<0.15) | 常见失败原因 |
|---|---|---|---|
| JPEG压缩 | QF=70 | <0.10 | quant_step太小 |
| 中值滤波 | 3×3 | <0.05 | 低熵块过密 |
| 缩放攻击 | 0.5→1.0 | <0.12 | 块尺寸用4×4 |
| 亮度调整 | ±10% | <0.03 | 未对DCT系数归一化 |
如果 BER 超标,优先调整quant_step和block_size,而不是更换嵌入系数位置。最小信息熵准则在水印方案里的角色是“定位器”而不是“编码器”,压缩鲁棒性仍然由量化强度决定,这两件事不要混淆。最后一次调优时我建议把熵阈值从固定值改为百分位截断,即每次取熵值最低的 5% 块作为候选,而不是用绝对阈值(比如熵<1.0)。因为不同图像的熵分布差异很大,固定阈值在低对比度图像上会选中过多块。这个改动只影响源码里的一行参数,却能让算法在不同数据集中保持一致的嵌入容量。
本文还有配套的精品资源,点击获取