第 2 章 · 准备工具:Python、NumPy 与图像读写(第 2 周)#
动手做| 本章目标:装好环境、跑通端到端脚本,会用 NumPy 读写图像数组。从此以后,你做的每个实验都能立刻在项目里验证。我们先用一张真实照片,把「图像 = 数组」焊进脑子。
2.1 安装环境#
项目依赖很轻:核心只需要 NumPy 与 Pillow,机器学习部分再用 scikit-learn、joblib 等。建议用较新的 Python 3.9+(README 示例使用 3.14,低版本同样可用)。
在项目根目录安装并做第一次自检
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(真实相机照片:彩色是 (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 行速览
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(真实计算:① 一个 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 的核心接口
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支持的扩展名。然后运行下面这段,读入项目自带的封面图:
动手:读图、看形状、存灰度版
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 维?(提示:那些是“统计量”,不是像素本身。)