第 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 图像=数组

图 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 dtype 与内存布局

图 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 维?(提示:那些是“统计量”,不是像素本身。)