# 第 2 章 · 准备工具：Python、NumPy 与图像读写（第 2 周）

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




> **动手做｜** 本章目标：装好环境、跑通端到端脚本，会用 NumPy 读写图像数组。从此以后，你做的每个实验都能立刻在项目里验证。我们先用一张真实照片，把「图像 = 数组」焊进脑子。

## 2.1 安装环境

项目依赖很轻：核心只需要 NumPy 与 Pillow，机器学习部分再用 scikit-learn、joblib 等。建议用较新的 Python 3.9+（README 示例使用 3.14，低版本同样可用）。

*在项目根目录安装并做第一次自检*

```python
cd F:\Steganography
pip install numpy pillow
python src\test_core.py      # 核心算法自测
python src\test_steg.py      # 隐写分析自测
python src\run_e2e.py        # 端到端：嵌入→解码→分析→绘图
```

> **避坑提醒｜** 如果系统默认 `python` 没有 tkinter，GUI 无法启动。README 给出的办法是使用带 tkinter 的解释器（例如 `C:\Python314\python.exe`）；只跑命令行脚本则不受影响。遇到 `ModuleNotFoundError` 时先确认你安装到了“运行它的那个解释器”里：`python -m pip install ...` 更稳妥。

## 2.2 用 NumPy 思考图像

NumPy 数组的核心概念只有四个：**形状（shape）**、**类型（dtype）**、**切片（slice）** 与**向量化运算**。隐写算法本质上都在做：取出数组 → 按某种顺序改写部分元素的 LSB → 存回数组。

*先用一张真实照片看“图=数组”：*

![图 2-1 图像=数组](../assets/py_image_array.png)

*图 2-1（真实相机照片：彩色是 (H,W,3) 的三通道数组，转灰度是 (H,W) 的单通道，类型都是 uint8。R/G/B 三个通道分别是一张“这个颜色有多亮”的灰度图；灰度则是按人类亮度感知加权的结果）*

**读图要点**：
- 彩色图：三个 0-255 的数字叠在每个像素上，形状 (H,W,3)；
- 灰度图：每个像素只一个 0-255，形状 (H,W)；
- 类型都是 `uint8`（0-255 的 8 位无符号整数）——这也是为什么“改 LSB”是位运算而不是 ±1。

*四个核心概念的 10 行速览*

```python
import numpy as np
a = np.arange(12).reshape(3, 4)     # 3 行 4 列
print(a.shape, a.dtype)             # (3,4) int64
b = a[:, 1]                          # 取第 2 列
c = a[0:2, 0:3]                      # 左上角 2×3 块
x = np.zeros((64, 64), dtype=np.uint8)
x[10:20, 5:15] = 255                 # 画一个白色方块
print((x & 1).sum())                 # 统计 LSB=1 的数量
```

注意 `dtype=np.uint8`：图像像素是 0-255 的 8 位无符号整数。做加减时要小心**溢出回绕**：`np.uint8(200) + 100` 会得到 44 而不是 300。项目在翻转 LSB 时用“异或 1”而不是“±1”，正是为了避免把 0 减成负数或把 255 加溢出。

![图 2-2 dtype 与内存布局](../assets/py_dtype_culprit.png)

*图 2-2（真实计算：① 一个 uint8 数组；② uint8 溢出回绕——200+100=44 而非 300，所以改位用 ^1；③ 转置/切片得到的是“视图”，内存不连续，保存给底层库前常要 `np.ascontiguousarray` 转一下）*

> **动手做｜** 在交互环境里敲这段，亲眼看到 `np.uint8(200)+np.uint8(100)==44`，以及 `a.T` 与 `np.ascontiguousarray(a.T)` 的 `flags` 差异。第 2 周的“会读代码”就从读懂这些小坑开始。

## 2.3 图像读写：认识 image_io.py

*project/src/image_io.py 的核心接口*

```python
from PIL import Image
import numpy as np
 
def load_as_gray(path):
    im = Image.open(path).convert("L")   # 强制转灰度
    return np.asarray(im, dtype=np.uint8)
 
def save_image(image, path):
    im = Image.fromarray(np.ascontiguousarray(image))
    im.save(path)
```

> **回到项目看代码｜** 自己打开 `src/image_io.py` 全文，注意 `load_image()` 与 `load_as_gray()` 的差异，以及 `SUPPORTED` 支持的扩展名。然后运行下面这段，读入项目自带的封面图：

*动手：读图、看形状、存灰度版*

```python
import sys; sys.path.insert(0, "src")
import image_io as IO
img = IO.load_as_gray("img/cover.png")
print(img.shape, img.dtype)          # (256,256) uint8
print(img.min(), img.max(), img.mean())
import os; os.makedirs("output", exist_ok=True)   # 仓库约定: 脚本产物写这里
IO.save_image(img, "output/my_copy.png")
```

注意 `load_as_gray` 里 `.convert("L")`：它把一切输入强制成灰度；而 `save_image` 里 `np.ascontiguousarray` 保证内存连续。这一“读成灰度、写成连续”的封装，是后面所有算法（嵌入/分析/ML）统一的输入接口——保证大家拿到的都是同一规格的数组。

## 2.4 第一次端到端运行：run_e2e.py

运行 `python src/run_e2e.py`，脚本会依次完成五件事：生成封面图 → 以 nsF5 p=3 嵌入一段英文消息（分别用空口令与口令）→ 解码比对 → 对干净图与含密图做盲隐写分析 → 绘制码族/效率图。

> **动手做｜** 仔细阅读终端输出，找到三组数字并抄下来：嵌入比特数、改动像素数、干净图与含密图的分析概率。下一章你就能解释它们为什么不同。

你现在还看不懂嵌入内部不要紧——第 2 周的目标只是“让代码在你机器上跑起来”，以及建立“数组改 LSB”的直觉。等你学完第 8 章回来看 `train_model.py`，会发现这里的每个“数组操作”都在为“特征向量”服务——这就是本手册“跟着代码学”的主线。

## 2.5 常见报错速查

| **报错 / 现象** | **原因** | **处理** |
| --- | --- | --- |
| ModuleNotFoundError | 包装到了别的解释器 | 用运行脚本的解释器执行 python -m pip install |
| 图像太小, 无法容纳头部+正文 | 像素数少于消息容量 | 换更大的图，或调小 p / 缩短消息 |
| UnicodeDecodeError / 乱码 | v1.8 起原生支持 UTF-8（中文可直接嵌入） | 若遇乱码，先确认解码端使用了相同的方法/p/口令 |
| GUI 秒退 / TclError | 解释器无 tkinter | 换带 tkinter 的 Python 运行 src/gui.py |

> **想一想｜** 为什么 `image_io` 保存时要把数组转成“连续数组”（`np.ascontiguousarray`）？提示：切片与转置可能产生非连续内存视图，很多底层库不喜欢它们。再想：既然灰度是 (H,W) 单通道，为什么第 8 章的“特征”能到 143 维？（提示：那些是“统计量”，不是像素本身。）
