第 1 章 · 数字图像与二进制(第 1 周)#

动手做| 本章目标:把“图像 = 数字矩阵”的直觉焊死;会手算二进制与字节;能说出为什么人眼和统计都能容忍一点“像素级扰动”。我们用项目真实载体(img/cover.png)亲眼拆开一张图。

1.1 一张数字图像,其实就是一堆数字#

把一张8bit 灰度图放大到像素级,你会看到它是一张由 0-255 整数组成的二维表格。0 代表全黑,255 代表全白,中间是不同深浅的灰。计算机只保存这张表格的“尺寸”和“数字”,不保存你肉眼看到的画面。

彩色图则是三张这样的表格叠在一起:R(红)、G(绿)、B(蓝)三个通道。常见的 RGB 图每个像素有 3 个 0-255 的值。比如一个橙色像素可以写成 R=255、G=128、B=0。

在代码里,灰度图就是一个二维数组,彩色图就是一个三维数组。项目的 src/image_io.py 把这件事封装成了两个函数:load_as_gray() 负责把任意支持的图片读成灰度数组,save_image() 负责把数组保存成 PNG。

新手提示| 数学里我们用“行、列”描述矩阵,图像处理里则常说“高、宽”:一张 512×512 图就是 512 行、512 列。数组形状 (512, 512) 的写法正是“行数在前”。

1.2 二进制、字节与位#

计算机底层只认识 0 和 1。一个 0 或 1 叫 1 bit(位),8 个 bit 组成 1 byte(字节)。8 个 bit 一共能表示 2⁸=256 种组合,正好对应灰度值 0-255。

例如十进制 200 = 128+64+8,写成 8 位二进制是 11001000;它的**最低有效位(LSB)**就是最右边的那个 0。把 200 的 LSB 改成 1,得到 201——在人眼里,灰度 200 和 201 几乎无法区分。

“最低有效位”是整本手册最重要的概念之一:LSB 隐写,就是偷偷修改像素的 LSB,把秘密比特藏进去。

动手做| 打开一个 Python 交互环境(或写个小脚本),验证下面这段:

体验:十进制、二进制与 LSB

v = 200
print(bin(v))            # 0b11001000
print(v & 1)             # 最低位 = 0
print(v ^ 1)             # 翻转最低位 = 201
print(v & 0b11111110)    # 把最低位清零 = 200
print(v & 0b11111110 | 1)# 先清零再置1 = 201

1.3 为什么图像里“有空地”可以藏东西#

图像里藏着大量冗余,这要从两个层面理解:

  • 视觉冗余:人眼对微小亮度差、高频细节不敏感。把某个像素从 128 改成 129,几乎没人看得出来;

  • 统计冗余:自然图像相邻像素高度相关,大部分信息集中在低频。JPEG 等压缩格式就是靠去掉这些冗余才把文件变小;

  • LSB 平面近似随机:自然照片的最低几位看起来“像噪声”,这恰好提供了天然的“掩护环境”。

于是出现了两类使用冗余的方式:压缩想把冗余去掉让文件变小,隐写想在冗余里塞秘密而尽量不破坏视觉与统计特征。二者争夺的是同一片“可修改空间”,这也是为什么很多隐写算法要针对特定压缩格式设计。

避坑提醒| 不要以为“看不见 = 安全”。肉眼看不见只是最弱的要求;真正专业的检测者用统计工具,LSB 直接替换会留下非常明显的统计痕迹,这正是第 3 章卡方检验与 RS 分析能抓住它的原因。

1.4 位平面:把一张图拆成 8 层#

把灰度值写成 8 位二进制后,可以按“第几位”把图像拆成 8 张二值图,称为 位平面。最高位(MSB)平面决定画面大轮廓;最低位(LSB)平面几乎全是细碎的噪声点。

先用项目真实载体(img/cover.png)亲手拆开:

图 1-1 位平面分解

图 1-1(真实数据:最左是原图(0-255);右边 8 格依次是 bit 7(最高位,×128)到 bit 0(最低位/LSB,×1)的位平面。bit 7 决定大轮廓;bit 0 几乎全是细碎噪声)

读图要点:

  • 越高的位平面越“有结构”(bit 7、6、5 很像原图的轮廓);

  • 越低的位平面越“像噪声”(bit 0、1、2 布满随机斑点);

  • 正因为 LSB 平面本就接近随机,往里面再塞伪随机秘密比特,肉眼和简单直方图都很难察觉——但统计手段能测出“它被随机化过”(第 3 章)。

再把“叠加还原”也亲手做一遍,理解“每一层必须乘它的权重再相加”:

图 1-2 逐层累加还原

图 1-2(真实数据:从最高位开始逐层累加——先只有 bit7,画面是灰暗的块状轮廓;每加一层越清晰;加满 8 层就精确等于原图)

这就是“叠加”最关键的一句话:

\[ \text{原图} = \sum_{k=0}^{7} \text{bit plane}_k \times 2^{k} \]

位平面本身只是 0/1,必须乘以它所在的权重 \(2^k\) 之后才能相加。如果只是把 8 层“或”起来或无视权重直接相加,得到的根本不是原图。这也是为什么项目里“改 LSB”用的是 & 0xFE | bit(把最低位清零再置位)这样的位运算,而不是做整数的加减。

新手提示| 图 1-1 里你看到“高位置轮廓、低位像噪声”。这个直觉非常重要:修改高位=肉眼可见;修改低位=肉眼不可见但统计可测。隐写只碰低位,恰恰是因为“低位本身就像噪声,改了也不显眼”——这既是隐写的机会,也是检测的突破口。

动手做| 打开互动实验室 (webapp/index.html) 的第 1 个区块(位平面实验室):用“抽看单层”把 bit 0、bit 4、bit 7 分别拖出来对比;再切到“叠加还原”,关掉 bit 0、1、2 看画面怎样变“平”,把 8 层全开看它怎样精确还原原图。

数一数像素与 LSB 的取值(项目里到处是这样的 numpy 操作)

import numpy as np
img = np.asarray([[200, 201], [128, 129]], dtype=np.uint8)
print(img.shape, img.dtype)   # (2, 2) uint8
print(img & 1)                # LSB 位平面 [[0,1],[0,1]]
print(np.unpackbits(np.array([200], dtype=np.uint8)))
# [1 1 0 0 1 0 0 0]  ← 最高位在前

1.5 回到项目:先认识 cover 与 stego#

隐写领域里有两个固定称呼:没有秘密的原始载体叫 cover(载体),藏入秘密后的图像叫 stego(含密图)。项目里这两个词随处可见:img/cover.png 是演示用载体,output/stego_*.png 是嵌入后的含密图。

回到项目看代码| 打开 src/run_e2e.py 第 1 步:它用 numpy 生成一张 256×256 的平滑“封面图”并保存到 img/cover.png。仔细看生成方式—— np.linspace 制造渐变背景,再加一点小噪声。理解这段代码,你就理解了一张自然感图像是如何从数字“长”出来的。(我们 图 1-1/1-2 用的正是这张图。)

1.6 小结与自我检查#

  • 灰度图 = 0-255 整数矩阵;彩色图 = 三个通道矩阵;

  • LSB 是像素值二进制的最低位,翻转它对视觉影响极小(图 1-1);

  • 压缩消除冗余,隐写利用冗余,二者针锋相对;

  • cover = 原始载体,stego = 含密图;

  • 高位置轮廓、低位像噪声——这是“隐写藏低位、检测测低位”的根源(图 1-2)。

想一想| 一张纯黑色(全 0)的图适不适合做 LSB 隐写的载体?一张纯随机噪声图呢?把想法写下来,学完第 3 章再回来对照。(提示:LSB 平面是不是“接近随机”决定了它好不好藏、好不好被抓。)