# 第 1 章 · 数字图像与二进制（第 1 周）

<!-- lang-switch -->
> [🌐 English version](https://yukinoshita-lin.github.io/nsf5-steganography/en/content/ch01.html)




> **动手做｜** 本章目标：把“图像 = 数字矩阵”的直觉焊死；会手算二进制与字节；能说出为什么人眼和统计都能容忍一点“像素级扰动”。我们用项目真实载体（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*

```python
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 位平面分解](../assets/bit_planes.png)

*图 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 逐层累加还原](../assets/bit_reconstruct.png)

*图 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 操作）*

```python
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 平面是不是“接近随机”决定了它好不好藏、好不好被抓。）
