一、uint8 和 float32 到底是什么?
先不急着看 OpenCV。我们从最基础的 C++ 数据类型开始。
1. uint8:8 位无符号整数
例如:
#include <cstdint>
std::uint8_t pixel = 180;
这里的 uint8_t 可以理解为:
使用 8 个二进制位存储一个非负整数。
8 位可以表示:

种不同的数值,因此范围是:

这也是为什么普通 8 位灰度图像的像素值通常为 0~255。
2. float32:32 位浮点数
例如:
float pixel = 0.705882f;
在常见的平台上,C++ 的 float 使用 IEEE 754 32 位浮点格式,占 4 字节。
它可以表示小数,也可以表示 180.0 这样的大数值。
| 对比 | uint8 | float32 |
|---|---|---|
| 类型 | 无符号整数 | 浮点数 |
| 每个数值占用 | 1 字节 | 4 字节 |
| 常见数值 | 0、128、255 |
0.0、0.5、1.0 |
| OpenCV 深度 | CV_8U |
CV_32F |
| 典型用途 | 普通图片数据 | 图像计算、神经网络输入 |
注意一个容易误解的问题:
float32 不是只能存储 0~1,而是可以存储大量不同范围的浮点数。
例如:
float a = 180.0f;
float b = 0.705882f;
两者都是 float32。
因此数据类型和数值范围,是两个不同概念。
二、理解 CV_8UC3 和 CV_32FC3
昨天已经学了:
CV_8UC3
它表示:
8U → 8 位无符号整数
C3 → 三个通道
而:
CV_32FC3
表示:
32F → 32 位浮点数
C3 → 三个通道
两种格式的每像素内存差异:

所以对于同一张三通道图像:

换句话说,转换成 float32 后,同样数量的像素数据通常需要 4 倍内存。
这也是为什么你以后进行模型预处理时,要关注数据类型,不能觉得只是改了一个名字。
三、convertTo():数据类型转换不等于归一化
这是今天非常重要的一个知识点。
假设某个像素原来是:
B = 30
G = 180
R = 50
原始类型:
CV_8UC3
现在执行:
cv::Mat imageFloat;
image.convertTo(
imageFloat,
CV_32F
);
注意,转换后:
B = 30.0
G = 180.0
R = 50.0
它只是从整数变成浮点数。
数值本身并没有自动除以 255。
如果想同时归一化,需要:
image.convertTo(
imageFloat,
CV_32F,
1.0 / 255.0
);
此时:
B = 30 / 255 ≈ 0.1176
G = 180 / 255 ≈ 0.7059
R = 50 / 255 ≈ 0.1961
这里为什么写 CV_32F 而不是 CV_32FC3?
你可能会问:
image.convertTo(imageFloat, CV_32F);
原图是三通道,为什么不是 CV_32FC3?
因为 convertTo() 会保留原图的通道数,主要改变每个通道的数据深度。
所以:
输入:CV_8UC3
↓
convertTo(CV_32F)
↓
输出:CV_32FC3
这里不用额外告诉它 C3。
四、图像在连续内存里到底怎么排列?
现在进入今天最关键的部分。
假设我们有一张 2×2 的 BGR 图像:

虽然看起来是二维图像,但计算机内存的地址是一维排列的。
对于连续 CV_8UC3 图像,数据按行存储:
第 0 行:
10,20,30, 40,50,60
第 1 行:
70,80,90, 100,110,120
将两行连接起来:
10,20,30,40,50,60,70,80,90,100,110,120
总共有:
Photo/Pasted image 20261009093010.png
假设从地址 0x1000 开始:
地址 内容
0x1000 10 ← (0,0) 的 B
0x1001 20 ← (0,0) 的 G
0x1002 30 ← (0,0) 的 R
0x1003 40 ← (0,1) 的 B
0x1004 50 ← (0,1) 的 G
0x1005 60 ← (0,1) 的 R
0x1006 70 ← (1,0) 的 B
...
于是一个问题就出现了:
既然内存是一维的,为什么你可以直接通过
(row, col)找到二维图像中的像素?
因为 OpenCV 记录了图像的形状、单像素大小和行跨度。
五、step 和 stride 到底是什么?
对于这张 2×2 的连续 CV_8UC3 图像:
每行像素数:2
每像素字节数:3
因此每行数据占:

所以:
image.step[0]
通常是:
6
这就是行跨度(row stride)。
更准确地说,step[0] 表示相邻两行起始数据地址的字节距离。
内存布局图
但是,如果图像是 ROI(感兴趣区域),情况就不同了。
假设原图宽度是 6 像素,每像素占 3 字节:
原图每行:
6 × 3 = 18 Bytes
从中裁出宽度为 3 像素的 ROI:
ROI 每行有效数据:
3 × 3 = 9 Bytes
但 ROI 依然引用原图的内存,因此:
ROI step[0] = 18 Bytes
而不是 9。
这就解释了:
image.step[0]
为什么不能永远直接写成:
image.cols * image.channels()
更一般地说,计算每行有效像素字节数应使用:
image.cols * image.elemSize()
但它也不一定等于 step[0]。
六、亲手通过 at() 访问像素
现在我们已经知道图像的内存排列,再来理解:
image.at<cv::Vec3b>(row, col)
就容易了。
这里:
cv::Vec3b
表示由三个 uchar(8 位无符号整数)组成的向量,非常适合表示一个 CV_8UC3 像素。
例如:
cv::Vec3b pixel =
image.at<cv::Vec3b>(0, 0);
代表读取第 0 行、第 0 列的像素。
pixel[0] // B
pixel[1] // G
pixel[2] // R
注意:
image.at<cv::Vec3b>(0, 0)
的两个位置参数是:
(row, col)
也就是:
(y, x)
而不是 (x, y)。(笛卡尔坐标系)
今天的完整编程实验
建议新建建一个独立的 mat_memory_demo.cpp,不要直接破坏之前已经跑通的 ImageProcessor 类。
#include <opencv2/opencv.hpp>
#include <iostream>
int main() {
// 1. 创建2行2列、3通道的8位图像
cv::Mat image(2, 2, CV_8UC3);
// 2. 人工设置四个像素的 BGR 值
image.at<cv::Vec3b>(0, 0) = cv::Vec3b(10, 20, 30);
image.at<cv::Vec3b>(0, 1) = cv::Vec3b(40, 50, 60);
image.at<cv::Vec3b>(1, 0) = cv::Vec3b(70, 80, 90);
image.at<cv::Vec3b>(1, 1) = cv::Vec3b(100, 110, 120);
// 3. 逐个打印像素
for (int row = 0; row < image.rows; ++row) {
for (int col = 0; col < image.cols; ++col) {
cv::Vec3b pixel = image.at<cv::Vec3b>(row, col);
std::cout << "(" << row << "," << col << ") "
<< "B=" << static_cast<int>(pixel[0])
<< " G=" << static_cast<int>(pixel[1])
<< " R=" << static_cast<int>(pixel[2])
<< '\n';
}
}
// 4. 打印内存信息
std::cout << "\n--- uint8 image ---\n";
std::cout << "Data address: "
<< static_cast<const void*>(image.data) << '\n';
std::cout << "Step: " << image.step[0] << '\n';
std::cout << "Elem size: " << image.elemSize() << '\n';
std::cout << "Total bytes: "
<< image.total() * image.elemSize() << '\n';
// 5. 转换为 float32,不归一化
cv::Mat floatImage;
image.convertTo(floatImage, CV_32F);
// 6. 转换为 float32,同时归一化
cv::Mat normalizedImage;
image.convertTo(normalizedImage, CV_32F, 1.0 / 255.0);
std::cout << "\n--- float32 image ---\n";
std::cout << "Elem size: " << floatImage.elemSize() << '\n';
std::cout << "Step: " << floatImage.step[0] << '\n';
cv::Vec3f fp = floatImage.at<cv::Vec3f>(0, 0);
cv::Vec3f np = normalizedImage.at<cv::Vec3f>(0, 0);
std::cout << "Float BGR: "
<< fp[0] << ", " << fp[1] << ", " << fp[2] << '\n';
std::cout << "Normalized BGR: "
<< np[0] << ", " << np[1] << ", " << np[2] << '\n';
// 7. BGR 转 RGB
cv::Mat rgb;
cv::cvtColor(image, rgb, cv::COLOR_BGR2RGB);
cv::Vec3b rgbPixel = rgb.at<cv::Vec3b>(0, 0);
std::cout << "\nRGB: "
<< static_cast<int>(rgbPixel[0]) << ", "
<< static_cast<int>(rgbPixel[1]) << ", "
<< static_cast<int>(rgbPixel[2]) << '\n';
return 0;
}
因为这里 floatImage 是 CV_32FC3,所以访问像素时使用:
floatImage.at<cv::Vec3f>(0, 0)
而不是继续使用:
floatImage.at<cv::Vec3b>(0, 0)
一定要让像素访问的数据类型与 Mat 实际存储类型匹配。
你应该观察到这些关键结果:
| 对比项目 | CV_8UC3 | CV_32FC3 |
|---|---|---|
channels() |
3 | 3 |
elemSize() |
3 | 12 |
step[0] |
6 | 24 |
| 像素总字节数 | 12 | 48 |
| 第一个像素的 B 值(未归一化) | 10 | 10.0 |
另外,cv::COLOR_BGR2RGB 会交换蓝、红通道:
BGR:(10, 20, 30)
↓
RGB:(30, 20, 10)
注意 cvtColor() 改变了通道的含义和数值排列,但结果通常仍然是 CV_8UC3。
七、为什么模型推理还需要 HWC → CHW?
这是今天和未来 TensorRT 部署关系最密切的部分。
假设你现在有一张 2×2 的三通道图像。
OpenCV 默认常见的三通道图像采用 HWC、通道交错(interleaved) 排列:
H = Height
W = Width
C = Channels
比如:
像素1:R1 G1 B1
像素2:R2 G2 B2
像素3:R3 G3 B3
像素4:R4 G4 B4
那么 HWC 的连续内存顺序是:
R1 G1 B1 | R2 G2 B2 | R3 G3 B3 | R4 G4 B4
但许多 CNN 模型的输入采用 CHW(通常再加一个批次维度 N,形成 NCHW):
先放完整的 R 通道
再放完整的 G 通道
最后放完整的 B 通道
因此内存排列变成:
R1 R2 R3 R4 | G1 G2 G3 G4 | B1 B2 B3 B4
对比 HWC 和 CHW

请特别注意:HWC → CHW 不是简单把 BGR 转成 RGB。
这两个操作解决的问题不一样。
| 操作 | 改变什么 |
|---|---|
| BGR → RGB | 颜色通道的顺序 |
| uint8 → float32 | 数据类型 |
/255.0 |
数值范围 |
| HWC → CHW | 张量的维度和数据组织方式 |
这四件事在模型预处理中经常一起发生,但必须分别理解。
例如一个常见的模型输入处理流程:
OpenCV 读图
H × W × 3,BGR,uint8
↓
resize
↓
BGR → RGB
↓
uint8 → float32
↓
归一化(按模型要求)
↓
HWC → CHW
↓
增加 Batch 维度
↓
N × C × H × W
↓
模型推理
但并不是每个模型都要求 NCHW、RGB 或 0~1 归一化。以后实际部署时,需要以模型的输入契约为准,不能一律套用。