摘要:本文介绍一种基于棋盘格奇偶相位的图像隐写方法:将原图映射到高亮度区间,将暗影图映射到低亮度区间,再按棋盘格交替写入同一张图。解码时分别提取奇相位和偶相位像素 ,即可还原原图与暗影图。文中包含编码、检测、解码的完整 Python 实现,并支持反色、灰度、边缘、模糊等暗影生成方式。
关键字:图像隐写,PIL,NumPy,图像处理,信息安全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 再转回 RGB) |
edges |
边缘图(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(radius=2))
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), dtype=np.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]:行索引为奇数、列索引为偶数,(x+y)为奇数 → 奇相位 → 原图canvas[0::2, 1::2]:行偶列奇,(x+y)奇数 → 奇相位 → 原图canvas[0::2, 0::2]:行偶列偶,(x+y)偶数 → 偶相位 → 暗影canvas[1::2, 1::2]:行奇列奇,(x+y)偶数 → 偶相位 → 暗影
每个 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(radius=2))
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), dtype=np.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_path=None, shadow_mode="invert")
# 解码
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] # (x+y) 为奇数 的像素 -> 原图
even_adj = a[0::2, 0::2] # (x+y) 为偶数 的像素 -> 暗影
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 = r"stego_checkerboard.png"
print("--- 开始检测 ---")
detect(SRC)
print("--- 开始还原 ---")
decode(SRC, r"还原结果")