AI-深度学习

从零搭建视觉模型 03:从像素到张量的基础概念

展开合集目录
  1. 从零搭建视觉模型 01:图片进入模型后发生了什么
  2. 从零搭建视觉模型 02:Python 起步,写给 C/C++ 开发者
  3. 从零搭建视觉模型 03:从像素到张量的基础概念
  4. 从零搭建视觉模型 04:图片如何变成张量
  5. 从零搭建视觉模型 05:MLP 入门,从手算到图片输入
  6. 从零搭建视觉模型 06:亲手搭建一个小型 CNN
  7. 从零搭建视觉模型 07:模型怎样预测类别:分数、概率与决策
  8. 从零搭建视觉模型 08:让模型开始学习,损失、梯度与训练循环
  9. 从零搭建视觉模型 09:数据集、标签与数据划分
  10. 从零搭建视觉模型 10:损失函数与优化器:怎样把错误变成更新
  11. 从零搭建视觉模型 11:批次、训练轮次与学习率
  12. 从零搭建视觉模型 12:训练第一个真实图片分类器
  13. 从零搭建视觉模型 13:过拟合、数据增强与正则化
  14. 从零搭建视觉模型 14:验证、测试与错误分析
  15. 从零搭建视觉模型 15:保存模型、恢复训练与独立推理
  16. 从零搭建视觉模型 · 综合实践:实现 MLP,从损失计算、训练到 ONNX 推理
  17. 从零搭建视觉模型 16:ResNet 与残差连接
  18. 从零搭建视觉模型 17:预训练、迁移学习与微调
  19. 从零搭建视觉模型 18:轻量模型与计算成本
  20. 从零搭建视觉模型 19:目标检测与 YOLO:从类别到目标框
  21. 从零搭建视觉模型 20:图像分割与 U-Net:给每个像素一个判断
  22. 从零搭建视觉模型 21:注意力与视觉 Transformer
  23. 从零搭建视觉模型 22:从训练模型到 ONNX
  24. 从零搭建视觉模型 23:量化与精度对比
  25. 从零搭建视觉模型 24:从摄像头输入到部署验收
  26. 从零搭建视觉模型 25:发展历史、主流分类与当前格局
从零搭建视觉模型 03:从像素到张量的基础概念的封面

完成《Python 起步》后,已经能读图片、取像素、复制数组。但看到 permute、float32、/ 255.0 和 unsqueeze,还会问:为什么需要这些步骤?

本篇放在 Python 起步与《图片如何变成张量》实验之间。只需要乘除法、下标和数组基础,先理解数据如何表示,不急着引入严格的数学定义。卷积、损失与梯度留到搭建和训练模型时再补。

本课已于 2026-09-28 完成学习。文末四道练习已补齐答案与易错总结,默认折叠,可先独立思考再展开对照。

1. 电脑怎样表示一张图片

先用这张宽 3、高 2 的图片贯穿全文。下标从 0 开始。

位置 第 0 列 第 1 列 第 2 列
第 0 行 红 [255, 0, 0] 绿 [0, 255, 0] 蓝 [0, 0, 255]
第 1 行 白 [255, 255, 255] 黑 [0, 0, 0] 橙 [255, 128, 0]

一个像素对应网格中的一个位置。RGB 表示这个位置用红、绿、蓝三个通道值描述颜色;它们属于同一个像素,并不是三个像素。

易错记录(2026-09-28):把橙色误认为红色。

我看到 [255, 128, 0] 的 R 通道是 255,就以为它应该是红色。这里忽略了 G 通道还有 128:像素颜色由 R、G、B 三个值共同决定,不能只看最大通道。

对照记忆:[255, 0, 0] 是纯红色,[255, 128, 0] 是橙色,[255, 255, 0] 是黄色。保持 R 为 255、B 为 0,逐渐增加 G,颜色就会从红色经过橙色向黄色变化。这是 RGB 光的加色混合,不是颜料混色。

因此,这张图片有:

像素数 = 宽 × 高 = 3 × 2 = 6
通道值总数 = 像素数 × 通道数 = 6 × 3 = 18

这也解释了为什么“像素数量与通道数无关”,但像素数据的大小与通道数有关。一张同样尺寸的单通道灰度图仍有 6 个像素,只需 6 个通道值。

文件大小与像素数据大小为什么不同

本例使用 uint8,即 8 位无符号整数。8 位共有 2⁸ = 256 种组合,从 0 编号到 255,每个通道值占 1 字节。

紧密排列的像素数据占 18 × 1 = 18 字节。如果将同样的 18 个值改成 float32,每个占 4 字节,就需要 72 字节。这里不计算数组对象、文件头或其他开销。

磁盘上的 PNG、JPEG 有自己的编码和压缩方式;本课的 P3 PPM 还把数字写成文本,例如 255 对应三个字符。因此,不能直接用文件大小推导解码后有多少像素。

用 C/C++ 的经验理解:读取一个文本文件得到的是字符,要经过解析才能得到数值;图片解码也是把文件表示转换成可计算的数据表示。

2. 标量、向量、矩阵和张量是什么

先按需要多少个下标,区分这些数据:

表示 本例中的对象 形状 取出一个数需要几个下标
标量 橙色像素的 G 值 128 () 0
一维数组,可用来表示向量 一个 RGB 像素 [255, 128, 0] (3,) 1
二维数组,可用来表示矩阵 整张图的 G 通道 (2, 3) 2
三维数组 一张 HWC 格式的 RGB 图片 (2, 3, 3) 3
四维数组 一批 NCHW 格式的 RGB 图片 (N, 3, 2, 3) 4

G 通道单独拿出来,是这样一个矩阵:

  0  255    0
255    0  128

在这门编程入门课里,可以先把 PyTorch 张量理解为能够参与张量运算、带有形状与数据类型等信息的多维数组。它也可以是零维、一维或二维,不是只有三维以上才叫张量。数学中张量还有更严格的定义,当前的图片输入实验不需要先掌握它。

张量不是图片专用格式,也可以装声音、坐标或其他数字。NumPy 已经能装这些像素;转换成 PyTorch 张量,是为了接入后面的模型计算工具,并不是转换后数据才突然具有“智能”。

“三维向量”和“三维张量”不要混淆

线性代数里说 RGB 是“三维向量”,通常指它有三个分量。程序中的形状却是 (3,),只有一个轴,ndim 为 1。

这里说图片是“三维张量”,指它有三个轴,形状是 (H, W, C)。它不是只有三个数,也不是说图片变成了立体场景。

3. 形状、维度、轴和索引分别描述什么

用 (2, 3, 3) 举例:

  • 轴:访问数据的方向,本例约定为行、列、通道。
  • 轴编号:从 0 开始,本例轴 0 是 H,轴 1 是 W,轴 2 是 C。
  • 形状 shape:每个轴的长度,即 (2, 3, 3)。
  • 维度数 ndim:轴的数量,这里是 3。
  • 索引:在某个轴上选择的位置,例如第二行、第三列、第二个通道(下标依次为 1、2、1)。

因此,橙色像素的绿色分量写成:

pixels[1, 2, 1]  # 128

取出整个橙色像素,保留通道轴:

pixels[1, 2, :]  # [255, 128, 0]

取出整张图的 G 通道,保留行和列:

pixels[:, :, 1]  # 形状为 (2, 3)

其中 : 表示保留这个轴上的全部位置。张量的形状本身不会标注“这是行”“这是通道”,这些含义来自我们与接口的约定。

为什么宽高顺序常常反过来

Pillow 的图片尺寸用 (宽, 高),对应这里的 (3, 2);我们得到的数组按“行、列、通道”访问,所以是 (高, 宽, 通道),即 (2, 3, 3)。

图片没有旋转。这是尺寸接口和数组索引使用了不同的描述顺序。用宽高不相等的图片练习,更容易发现把它们写反的问题。

4. 为什么调整为 CHW,还要增加 N

HWC 可以理解为:先找到某行某列的像素,再选其中的通道。CHW 则是:先选中一个通道平面,再找该平面的行和列。

同一个橙色像素的 G 值:

HWC:hwc[1, 2, 1] = 128
CHW:chw[1, 1, 2] = 128
         C  H  W

后面的 PyTorch 卷积实验使用 NCHW:N 是一批中的图片数量,C 是通道数,H、W 是高度和宽度。Conv2d 接受这种带批次的输入,也支持没有批次维的 CHW;本课程统一使用 NCHW,方便从一张扩展到多张。见 Conv2d 官方输入说明。

因此,使用 CHW 是为了匹配后续接口约定,不是说 HWC 表示错误,也不是所有模型都必须用同一种布局。RGB 与 BGR 则是通道内部的颜色顺序,是另一个问题;把 HWC 调成 CHW 不会自动把 RGB 变成 BGR。

permute(2, 0, 1) 的数字从哪里来

原来的轴编号是 0=H、1=W、2=C。新顺序要 C、H、W,就依次填写原来的轴编号:

chw = hwc.permute(2, 0, 1)

2 不是“取第三个颜色值”,而是“把原来的第 2 号轴放在最前面”。permute 返回按新顺序解释数据的视图,像素对应关系保持不变。见 permute 官方说明。

为什么不能只用 reshape

把一份按 HWC 排列的数据直接 reshape(3, 2, 3),只是按元素顺序重新分组,没有完成所需的按轴重排。本例虽然形状也变成 (3, 2, 3),取出的“橙色位置”却会变成 [0, 255, 0]。

可以类比 C 的连续数组:同一串数字按不同长度切成几段,不等于已经把所有 R 值、G 值、B 值分别归组。形状相同只是检查的一部分,还需要检查像素的对应关系。

一张图片为什么也有批次维度

一张图片的 CHW 是 (3, 2, 3),增加一个长度为 1 的 N 轴后是 (1, 3, 2, 3)。它表达“这一批包含一张图片”,没有复制像素,也没有产生新图片。

两张同尺寸图片组成一批时,形状可以是 (2, 3, 2, 3)。增加批次轴有助于统一处理一张与多张输入;它本身不是训练,也不保证加速。

5. 为什么转成浮点数,再除以 255

先把这两个操作拆开理解。

转换类型:让数据能够表示小数

uint8 能表示 0~255 的整数,不能保存 0.5。模型计算中会遇到非整数权重、加权结果和梯度,后续课程因此采用 float32。这里只是选择一种常用的学习配置,实际模型也可能使用其他浮点或量化类型。

类型转换前:255,uint8
类型转换后:255.0,float32

单纯转换类型不会自动把 255 变成 1,也不会改变图片的形状。输入采用浮点数不等于开启自动求导,当前实验还没有进行训练。

缩放数值:使用一个明确的范围约定

本例的 8 位通道值范围是 0~255,除以 255 就得到 0~1:

0 / 255 = 0
128 / 255 ≈ 0.501961
255 / 255 = 1

例如,一个通道值乘以 0.2:原尺度是 255 × 0.2 = 51,缩放后是 1 × 0.2 = 0.2。输入尺度会影响后续计算的数值尺度,所以训练和使用模型时必须采用一致的预处理约定。

这不是“神经网络只能接收 0~1”,也不是“这样一定能训练得更好”。对于已有模型,应该按它训练时要求的范围处理;本课程从头做简单实验,选用这个容易观察的约定。

为什么不除以 256,或者当前图片的最大值

256 是可能取值的数量,255 才是本例的最大允许值。若想把 255 映射为 1,应除以 255。

也不要把这一步换成“除以当前图片中的最大值”。假设两张图片的像素值分别全为 64 和 128,各自除以自身最大值后都会变成全 1,原本不同的数值会被映射成相同结果。

按固定的 255 缩放,它们分别约为 0.251 和 0.502,保留了原来强度的差别。图片未必包含 0 或 255,所以缩放后实际最小值、最大值也不必恰好为 0、1。

这里没有做减均值、除标准差。以后遇到“归一化”这个词,要看实际公式,而不能只凭名字判断做了哪种处理。

6. 为什么要关心数据是否共享

当一个 NumPy 数组接入 PyTorch 中:

hwc = torch.from_numpy(pixels)

两者共享底层内存,对一方元素的修改会反映到另一方。它没有自动复制像素,也没有自动缩放数值。见 from_numpy 官方说明。

这类似于 C/C++ 中多个访问入口引用同一块内存的情况:变量名不同,不代表数据有两份。

需要独立修改时,张量可以用 clone() 复制;本课的 NumPy 数组可以用 copy() 复制。相反,普通赋值 other = hwc 只是给同一个张量对象增加一个名字。

contiguous() 是否一定复制

维度重排后,张量可以利用形状和步长信息,以新的顺序访问原来的内存。步长描述沿某个轴移动一个位置时,底层存储需要跨过多少个元素。

contiguous() 在需要时把数据整理成指定的连续布局;如果已经符合要求,可能直接返回原张量。它不是“无论如何都创建独立副本”的接口。见 contiguous 官方说明。

初学时先区分三个问题:像素数值是否变了、轴的顺序是否变了、数据是否复制了。这三件事不能相互推断。

7. 跟着橙色像素走一遍

将整张图作为输入,下面追踪位于第 1 行、第 2 列的橙色像素:

阶段 整份数据的形状 类型 如何取橙色像素 通道值
NumPy HWC (2, 3, 3) uint8 pixels[1, 2] [255, 128, 0]
PyTorch HWC (2, 3, 3) torch.uint8 hwc[1, 2] [255, 128, 0]
PyTorch CHW (3, 2, 3) torch.uint8 chw[:, 1, 2] [255, 128, 0]
转为浮点数 (3, 2, 3) torch.float32 floating[:, 1, 2] [255.0, 128.0, 0.0]
除以 255 (3, 2, 3) torch.float32 scaled[:, 1, 2] 约 [1.0, 0.501961, 0.0]
增加批次轴 (1, 3, 2, 3) torch.float32 batch[0, :, 1, 2] 约 [1.0, 0.501961, 0.0]

每一步中通道值的总数量仍然是 18。增加轴不一定增加元素,转换类型可能增加内存占用,而调整轴顺序并不意味着改变了颜色。

8. 先手算,再判断

这些题不需要运行代码,先写下结果即可。做完再结合实验验证,各题的参考答案与练习总结放在题目下方,默认折叠,点击后展开。

练习 1:数清数据

一张宽 5、高 4 的 RGB 图片,通道类型为 uint8:

  1. 有多少像素、多少通道值?
  2. HWC、CHW 和单张 NCHW 的形状分别是什么?
  3. 紧密排列的像素数据占多少字节?改成 float32 后是多少?不计额外开销。
点击查看第一题参考答案与本次练习总结

参考答案

第一问:图片宽 5、高 4,每个像素有 R、G、B 三个通道。

像素数 = 宽 × 高 = 5 × 4 = 20
通道值总数 = 像素数 × 通道数 = 20 × 3 = 60

第二问:不仅要说明字母含义,还要代入具体数值,写出形状。

布局 各轴含义 本题形状
HWC 高、宽、通道 (4, 5, 3)
CHW 通道、高、宽 (3, 4, 5)
NCHW 批次中的图片数量、通道、高、宽 (1, 3, 4, 5)

N 表示当前这一批的图片数量。本题是一张图片,所以 N 为 1;它不是整个数据集的图片总数。

第三问:只计算紧密排列的像素数据,不计其他开销。

uint8:每个通道值占 8 ÷ 8 = 1 字节
像素数据大小 = 60 × 1 = 60 字节

float32:每个通道值占 32 ÷ 8 = 4 字节
像素数据大小 = 60 × 4 = 240 字节

本次练习总结

本次回答中的 20 个像素、60 个通道值、60 字节和 240 字节均正确,HWC、CHW、NCHW 的含义也解释正确。需要补充的是第二问的具体形状,不能只写字母的含义。

转换成 float32 后,仍然有 20 个像素、60 个通道值,变化的是每个值占用的字节数。调整轴顺序也不会改变元素总数:4 × 5 × 3、3 × 4 × 5、1 × 3 × 4 × 5 都等于 60,可以用来检查形状中的数量是否一致,但仅凭总数相同还不能证明轴顺序正确。

练习 2:写出索引

对本文 3×2 示例图,分别写出 HWC、CHW、NCHW 三种格式下取得橙色像素全部通道的表达式。NCHW 的批次中只有一张图片。

再写出取得橙色像素 G 值的表达式,结果应为 128(尚未缩放)。

点击查看第二题参考答案与本次易错点

参考答案

橙色像素位于第二行、第三列,下标从 0 开始,所以行下标为 1,列下标为 2。RGB 的通道下标依次是 0、1、2,G 对应 1。批次中只有一张图片,其下标为 0。

布局 取橙色像素的全部通道 直接取橙色像素的 G 值
HWC hwc[1, 2, :] hwc[1, 2, 1]
CHW chw[:, 1, 2] chw[1, 1, 2]
NCHW batch[0, :, 1, 2] batch[0, 1, 1, 2]

全部通道的结果均为 [255, 128, 0],G 值均为 128。这里的 batch 表示尚未缩放的 NCHW 数据;如果已经除以 255,G 值就约为 0.501961。

HWC 的 hwc[1, 2, :] 可以简写为 hwc[1, 2]。也可以先取出整个像素,再用 [1] 选择 G:

orange = chw[:, 1, 2]
green = orange[1]  # 128

本次易错点

本次回答为:HWC [2, 1:],CHW [:, 1, 2],NCHW [1, :, 1, 2],再用 [1] 取 G。其中 CHW 正确;取 G 的思路在已经取出 RGB 像素的前提下也正确。

  1. HWC 混淆了行列顺序与切片。 [2, 1:] 的第一个下标 2 表示第三行,但本例只有两行,会越界。后面的 1: 表示该轴从下标 1 取到末尾,不是分别指定列和通道。应写成 [1, 2, :]:选第 1 行、第 2 列,保留全部通道。
  2. N 的长度不等于图片下标。 N 为 1 表示这一批只有一张图片,可用下标只有 0。batch[1, :, 1, 2] 会尝试取第二张图片,因而越界;应改为 batch[0, :, 1, 2]。
  3. [1] 的含义取决于当前数据。 对已经取出的 RGB 像素,orange[1] 是 G 值;对完整 HWC 张量,hwc[1] 取的是第二行,并非某个像素的 G 值。要先确认正在索引哪个对象。

记住:形状说明每个轴有几个位置,索引说明选择哪个位置。下标从 0 开始,长度为 1 的轴只能取下标 0;逗号分隔不同轴的索引,冒号表示在一个轴上取切片。

练习 3:辨别操作

对每句话判断对错,错误的改成一句正确表述:

  1. 形状 (3,) 的 RGB 向量,在 PyTorch 中是三维张量。
  2. HWC 改成 CHW 后,RGB 会自动变成 BGR。
  3. 转成 float32 就会自动将 255 缩放成 1。
  4. 一张全为 128 的图除以 255 后,最大值一定为 1。
  5. (1, 3, 2, 3) 中的 1 表示这一批有一张图片。
  6. contiguous() 可以保证获得一份不共享内存的副本。
点击查看第三题参考答案与本次练习总结

参考答案

六个判断依次为:错误、错误、错误、错误、正确、错误。

  1. 错误:(3,) 是一维张量。 它只有一个轴,这个轴上有三个元素。RGB 可以作为有三个分量的向量,但不能因此说它在 PyTorch 中有三个轴。
  2. 错误:调整的是轴的排列顺序。 HWC 改成 CHW 后,行、列、通道这三个轴的位置改变,RGB 通道的颜色顺序和对应像素值保持不变,不会自动变成 BGR。
  3. 错误:转换类型不等于缩放数值。 转成 float32 后,255 变为 255.0;再除以 255 才得到 1.0。
  4. 错误:本题的最大值约为 0.501961。 整张图的值全为 128,除以 255 后每个值都约为 128 / 255 ≈ 0.501961,所以最小值和最大值都等于这个值,而不是 1。
  5. 正确:N 为 1 表示这一批只有一张图片。 后面的 3、2、3 分别表示通道数、高度和宽度。
  6. 错误:contiguous() 不保证独立副本。 如果张量已经符合所要求的连续内存布局,会返回原张量;否则才复制数据,整理成所要求的布局。明确需要独立副本时应使用 clone()。

本次练习总结

本次六道题的对错判断全部正确,已经能区分轴顺序、数据类型和数值缩放。几处解释补充得更精确后如下:

  • 第一题原回答“一维向量”的理解正确,这里用“一维张量,包含三个元素”更贴合 PyTorch 的语境。
  • 第二题原回答“只改变顺序,不改变像素”,需要明确是改变轴的顺序,避免误解成 RGB 颜色顺序也发生了改变。
  • 第四题原回答“不一定为 1”,对一般图片成立;但本题明确给出全为 128,因此可以直接算出最大值约为 0.501961。
  • 第六题原回答“各种参数一致,就可能共享”,需要把条件具体化为是否已经符合所要求的连续内存布局。这里不是按形状、类型等参数是否相同来判断是否复制。

可以这样区分:contiguous() 的目的是整理内存布局,clone() 的目的是复制数据。不要用前者代替“保证修改副本不影响原数据”的需求。

练习 4:检查共享关系

假设 pixels 是本例的 NumPy 数组,依次执行:

hwc = torch.from_numpy(pixels)
saved = hwc.clone()
hwc[0, 0, 0] = 0

pixels[0, 0]、hwc[0, 0]、saved[0, 0] 分别是什么?写出结果并说明哪两者共享数据。

点击查看第四题参考答案与本次练习总结

参考答案

左上角原来是红色像素 [255, 0, 0]。执行 hwc[0, 0, 0] = 0 后,三个结果分别为:

pixels[0, 0] → [0, 0, 0]
hwc[0, 0]   → [0, 0, 0]
saved[0, 0] → [255, 0, 0]

torch.from_numpy(pixels) 让 hwc 与 pixels 共享底层数据,因此通过 hwc 将左上角的 R 值改为 0 时,pixels 中对应的值也会改变,像素由红色变为黑色。

saved = hwc.clone() 在修改前创建了独立副本,保存当时的像素数据。后续修改 hwc 不会影响 saved,所以它的左上角仍为红色 [255, 0, 0]。复制发生的时机也很重要:如果在修改之后才调用 clone(),复制到的就会是修改后的值。

本次练习总结

本次通过口述思路完成:hwc 和 pixels 共用数据,修改左上角 R 值会一起变化;saved 是修改前创建的副本,保持不变。理解完全正确,已能区分共享数据与独立副本,无需额外写代码才能完成这道判断题。

9. 接下来,用代码验证这些概念

本课的四道练习已经完成:计算像素与存储大小,区分轴长度与索引,辨别类型转换和数值缩放,判断数据共享与复制。需要特别记住的易错点是:RGB 颜色由三个通道共同决定;长度为 1 的批次轴只能用下标 0;contiguous() 不保证独立副本。

下一步进入《图片如何变成张量》的实验,把本文的橙色像素沿着 HWC → CHW → NCHW 逐步追踪,并检查每一步的形状、类型与数值。能够解释这些变化,比记住一串 API 名称更有用。后续再学习卷积与模型训练。

配套代码练习

实验:跟踪像素、轴与拷贝

查看完整代码:main.py
"""跟踪像素、轴与拷贝。运行方式与练习见 README.md。"""
import copy
# 两行三列,每个像素含 RGB 三个通道。
hwc = [[[255,0,0],[0,255,0],[0,0,255]],
       [[255,255,255],[0,0,0],[255,128,0]]]
chw = [[[hwc[y][x][c]/255 for x in range(3)] for y in range(2)] for c in range(3)]
nchw = [chw]
print('HWC=(2,3,3), CHW=(3,2,3), NCHW=(1,3,2,3)')
print('橙色像素:', [nchw[0][c][1][2] for c in range(3)])
assert nchw[0][1][1][2] == 128/255
alias = hwc
clone = copy.deepcopy(hwc)
alias[0][0][0] = 0
assert hwc[0][0][0] == 0 and clone[0][0][0] == 255
print('共享引用会修改原对象,deepcopy 保留原始值')
下载代码

在项目根目录执行;下载包解压后,在解压目录执行 python3 main.py。

cd 'content/posts/AI-深度学习/从零搭建视觉模型/03-基础概念/code' && python3 main.py

本篇已附独立实验包,在代码卡片中查看源码、下载或复制运行命令。解压后执行 python3 main.py;依赖安装见包内 README.md。

观察目标: 橙色归一化结果为 [1.0, 0.50196…, 0.0]。这里用列表理解轴;张量视图与连续内存在第 4 课验证。

动手修改: 将橙色像素移到第一行第一列,写出 HWC 和 NCHW 的访问下标,再用代码验证。

所有输入均在本篇代码中构造;运行范围和验证记录见包内 verification.md。