摘要本文介绍一种基于棋盘格奇偶相位的图像隐写方法将原图映射到高亮度区间将暗影图映射到低亮度区间再按棋盘格交替写入同一张图。解码时分别提取奇相位和偶相位像素即可还原原图与暗影图。文中包含编码、检测、解码的完整 Python 实现并支持反色、灰度、边缘、模糊等暗影生成方式。关键字图像隐写PILNumPy图像处理信息安全CTF棋盘格隐写奇偶相位前沿在信息安全ctf比赛中misc方向是必考的一个方向其中图片隐写也是最常见的题目类型。我们介绍棋盘格奇偶隐写。其中利用像素的奇偶位置和亮度区间就能把两张图同时藏进一张图里。解码时分别提取奇相位和偶相位像素就能还原出原图和一张“暗影图”。这篇文章会从原理到代码完整讲解如何用 Python PIL NumPy 实现这套方案包括编码把原图和暗影图合成一张棋盘格隐写图检测判断一张图是否使用了这种隐写解码从隐写图中还原出原图和暗影图。代码分为两个文件包括编码、解码、检测以及还原。一、效果预览我们有一张原图对原图进行编码生成一张尺寸为原图两倍的隐写图stego_checkerboard.png。这张图看起来像原图和暗影图的棋盘格交错。解码从隐写图中还原出decoded_origin.png和decoded_shadow.png。如果暗影模式选择invert反色那么解码后会得到原图和它的反色图如果选择gray则得到原图和灰度图edges得到边缘图blur得到模糊图。二、核心原理1. 棋盘格与奇偶相位把一张图的像素按坐标(x, y)分成两类偶相位(x y) % 2 0奇相位(x y) % 2 1在棋盘格中这两类像素交替出现就像国际象棋棋盘的黑白格。如果我们把原图只写入奇相位把暗影图只写入偶相位那么每个像素位置只承载一张图的信息互不干扰。解码时按同样的规则提取即可。2. 暗影图的生成方式暗影图可以外部提供也可以由原图自动生成。代码支持四种模式模式说明invert反色图gray灰度图转 L 再转回 RGBedges边缘图FIND_EDGES 滤波blur高斯模糊图这些模式生成的暗影图与原图差异明显解码后能直观看到两张图。编码解码执行案例原图 暗影图三、编码实现把两张图合成一张代码执行中的encode函数负责编码。1. 生成暗影图def generate_shadow(img_ori: Image.Image, mode: str invert) - Image.Image: img_ori img_ori.convert(RGB) if mode invert: return ImageOps.invert(img_ori) elif mode gray: return img_ori.convert(L).convert(RGB) elif mode edges: return img_ori.filter(ImageFilter.FIND_EDGES).convert(RGB) elif mode blur: return img_ori.filter(ImageFilter.GaussianBlur(radius2)) else: raise ValueError(f不支持的 shadow_mode: {mode})如果shadow_path为None则调用这个函数自动生成否则读取外部暗影图并缩放到原图尺寸。2. 色彩空间映射arr_ori np.array(img_ori).astype(np.float32) arr_sha np.array(img_sha).astype(np.float32) arr_sha_adj (arr_sha / 255.0) * 127.0 arr_ori_adj (arr_ori / 255.0) * 127.0 128.0这样暗影值落在[0, 127]原图值落在[128, 255]。3. 棋盘格填充创建画布然后按棋盘格规则写入canvas np.zeros((2 * H, 2 * W, 3), dtypenp.uint8) ​​​​​​​# 原图奇相位 canvas[1::2, 0::2] np.round(arr_ori_adj).astype(np.uint8) canvas[0::2, 1::2] np.round(arr_ori_adj).astype(np.uint8) # 暗影偶相位 canvas[0::2, 0::2] np.round(arr_sha_adj).astype(np.uint8) canvas[1::2, 1::2] np.round(arr_sha_adj).astype(np.uint8)注意canvas[1::2, 0::2]行索引为奇数、列索引为偶数(xy)为奇数 → 奇相位 → 原图canvas[0::2, 1::2]行偶列奇(xy)奇数 → 奇相位 → 原图canvas[0::2, 0::2]行偶列偶(xy)偶数 → 偶相位 → 暗影canvas[1::2, 1::2]行奇列奇(xy)偶数 → 偶相位 → 暗影每个 2×2 块中两个奇相位像素放原图两个偶相位像素放暗影。这样隐写图在视觉上呈现棋盘格交错。最后保存为output_path尺寸变为2W × 2H。四、检测判断是否为棋盘格隐写代码执行中的detect函数用于检测。1. 生成偶相位掩码def _even_mask(h, w): yy, xx np.mgrid[0:h, 0:w] return (xx yy) % 2 0对于隐写图尺寸是2H × 2W。偶相位包括(0,0), (0,2), (1,1), (1,3)...等位置。2. 统计值域a np.array(Image.open(src).convert(RGB)).astype(np.int16) h, w, _ a.shape even _even_mask(h, w) e, o a[even], a[~even] print(f偶相位值域 {e.min()} ~ {e.max()} 均值 {e.mean():.1f}) print(f奇相位值域 {o.min()} ~ {o.max()} 均值 {o.mean():.1f})在编码时偶相位是暗影值域[0, 127]奇相位是原图值域[128, 255]。因此正常情况下e.max() o.min()即偶相位最大值小于奇相位最小值。如果两张图的值域有重叠比如暗影图恰好有很亮的像素或者原图有很暗的像素映射后仍可能重叠。但编码时已经强制错开区间所以只要没有被有损压缩或调色破坏检测就能通过。五、解码还原原图与暗影图执行代码中的decode函数负责还原。1. 提取奇偶相位a np.array(Image.open(src).convert(RGB)) odd_adj a[1::2, 0::2] # 奇相位 - 原图 even_adj a[0::2, 0::2] # 偶相位 - 暗影这里只取了[1::2, 0::2]和[0::2, 0::2]因为编码时每个相位有两个位置它们写入的值完全相同取其中一个即可。这样得到的数组尺寸是H × W × 3正好是原图尺寸。2. 逆映射odd np.clip((odd_adj - 128.0) / 127.0 * 255.0, 0, 255).astype(np.uint8) even np.clip(even_adj / 127.0 * 255.0, 0, 255).astype(np.uint8)原图先减 128再除以 127 乘 255还原到[0, 255]暗影直接除以 127 乘 255还原到[0, 255]用np.clip防止溢出。六、完整代码编码代码# stego_checkerboard.py import numpy as np from PIL import Image, ImageOps, ImageFilter def generate_shadow(img_ori: Image.Image, mode: str invert) - Image.Image: 根据原图生成暗影图。 img_ori img_ori.convert(RGB) if mode invert: # 反色阴影解码后得到原图 反色图 return ImageOps.invert(img_ori) elif mode gray: # 灰度阴影解码后得到原图 灰度图 return img_ori.convert(L).convert(RGB) elif mode edges: # 边缘阴影解码后得到原图 边缘图 return img_ori.filter(ImageFilter.FIND_EDGES).convert(RGB) elif mode blur: # 模糊阴影解码后得到原图 模糊图 return img_ori.filter(ImageFilter.GaussianBlur(radius2)) else: raise ValueError(f不支持的 shadow_mode: {mode}) def encode(origin_path,output_path,shadow_path,shadow_mode,): 将原图和暗影图合成为棋盘格隐写图。 参数 origin_path : 原图路径 output_path : 输出隐写图路径 shadow_path : 暗影图路径。如果为 None则根据原图自动生成 shadow_mode : 自动生成暗影图的方式invert / gray / edges / blur img_ori Image.open(origin_path).convert(RGB) W, H img_ori.size if shadow_path is not None: img_sha Image.open(shadow_path).convert(RGB) img_sha img_sha.resize((W, H), Image.LANCZOS) else: img_sha generate_shadow(img_ori, shadow_mode) img_sha img_sha.resize((W, H), Image.LANCZOS) # 转为 numpy 数组 arr_ori np.array(img_ori).astype(np.float32) arr_sha np.array(img_sha).astype(np.float32) # 色彩空间映射暗影 - [0, 127]原图 - [128, 255] arr_sha_adj (arr_sha / 255.0) * 127.0 arr_ori_adj (arr_ori / 255.0) * 127.0 128.0 # 创建画布 (2H, 2W, 3) canvas np.zeros((2 * H, 2 * W, 3), dtypenp.uint8) # 棋盘格填充 # 原图奇相位 canvas[1::2, 0::2] np.round(arr_ori_adj).astype(np.uint8) canvas[0::2, 1::2] np.round(arr_ori_adj).astype(np.uint8) # 暗影偶相位 canvas[0::2, 0::2] np.round(arr_sha_adj).astype(np.uint8) canvas[1::2, 1::2] np.round(arr_sha_adj).astype(np.uint8) Image.fromarray(canvas).save(output_path) print(f隐写图已生成: {output_path}) print(f尺寸: {2 * W}x{2 * H}) def decode(stego_path: str, origin_out_path: str, shadow_out_path: str): 从棋盘格隐写图中解码出原图和暗影图。 stego np.array(Image.open(stego_path).convert(RGB)).astype(np.float32) # 取出编码值 arr_ori_adj stego[1::2, 0::2] # 原图 arr_sha_adj stego[0::2, 0::2] # 暗影 # 逆映射回 [0, 255] arr_ori np.clip((arr_ori_adj - 128.0) / 127.0 * 255.0, 0, 255).astype(np.uint8) arr_sha np.clip(arr_sha_adj / 127.0 * 255.0, 0, 255).astype(np.uint8) Image.fromarray(arr_ori).save(origin_out_path) Image.fromarray(arr_sha).save(shadow_out_path) print(f解码原图已保存: {origin_out_path}) print(f解码暗影图已保存: {shadow_out_path}) if __name__ __main__: ORIGIN_IMG origin.png STEGO_IMG stego_checkerboard.png DECODED_ORIGIN decoded_origin.png DECODED_SHADOW decoded_shadow.png # 编码暗影图由原图自动生成反色 encode(ORIGIN_IMG, STEGO_IMG, shadow_pathNone, shadow_modeinvert) # 解码 decode(STEGO_IMG, DECODED_ORIGIN, DECODED_SHADOW)得到隐写文件decoded_shadow.png。解码代码# checkerboard_stego.py — 还原部分 import numpy as np from PIL import Image def _even_mask(h, w): 生成偶数相位的掩码矩阵 yy, xx np.mgrid[0:h, 0:w] return (xx yy) % 2 0 def detect(src): 识别判断一张图是否为棋盘格奇偶隐写 a np.array(Image.open(src).convert(RGB)).astype(np.int16) h, w, _ a.shape even _even_mask(h, w) e, o a[even], a[~even] # 打印像素范围及均值信息 print(f偶相位值域 {e.min()} ~ {e.max()} 均值 {e.mean():.1f}) print(f奇相位值域 {o.min()} ~ {o.max()} 均值 {o.mean():.1f}) if e.max() o.min(): print( 两相位值域完全不重叠 — 判定为棋盘格奇偶隐写) else: print( 两相位值域有重叠 — 需要进一步排查可能是混合图或普通图) def decode(src, prefix): 还原奇相位就是原图偶相位是暗影版 a np.array(Image.open(src).convert(RGB)) h, w, _ a.shape # 半尺寸提取 odd_adj a[1::2, 0::2] # (xy) 为奇数 的像素 - 原图 even_adj a[0::2, 0::2] # (xy) 为偶数 的像素 - 暗影 odd np.clip((odd_adj - 128.0) / 127.0 * 255.0, 0, 255).astype(np.uint8) even np.clip(even_adj / 127.0 * 255.0, 0, 255).astype(np.uint8) # 不放大直接保存半尺寸避免 LANCZOS 引入模糊 Image.fromarray(odd).save(prefix _原图.png) Image.fromarray(even).save(prefix _暗影.png) print(f已输出 {prefix}_原图.png / {prefix}_暗影.png) if __name__ __main__: SRC rstego_checkerboard.png print(--- 开始检测 ---) detect(SRC) print(--- 开始还原 ---) decode(SRC, r还原结果)