文章目录
- [OpenCV 入门(二):像素访问、ROI 与深浅拷贝](#OpenCV 入门(二):像素访问、ROI 与深浅拷贝)
-
- 前言
- 一、坐标系统:先行后列
- [二、读一个像素:`at<T>(y, x)`](#二、读一个像素:
at<T>(y, x)) -
- [2.1 类型参数 T 怎么选](#2.1 类型参数 T 怎么选)
- [2.2 读取并打印](#2.2 读取并打印)
- [2.3 `Vec3b` 是什么](#2.3
Vec3b是什么)
- [三、改一个像素:`at` 返回的是引用](#三、改一个像素:
at返回的是引用) -
- [3.1 改一块区域:双重循环](#3.1 改一块区域:双重循环)
- [3.2 验证坐标方向:画一条对角线](#3.2 验证坐标方向:画一条对角线)
- 四、ROI:从原图上"开一扇窗"
-
- [4.1 `Rect(x, y, width, height)`](#4.1
Rect(x, y, width, height)) - [4.2 ROI 不是复制,是"视图"](#4.2 ROI 不是复制,是"视图")
- [4.3 ROI 的典型用途](#4.3 ROI 的典型用途)
- [4.1 `Rect(x, y, width, height)`](#4.1
- [五、浅拷贝 vs 深拷贝:本篇最重要的部分](#五、浅拷贝 vs 深拷贝:本篇最重要的部分)
-
- [5.1 `Mat` 的赋值是浅拷贝](#5.1
Mat的赋值是浅拷贝) - [5.2 引用计数:为什么不会 double free](#5.2 引用计数:为什么不会 double free)
- [5.3 一个关键区别:Mat 能表示"子区域"](#5.3 一个关键区别:Mat 能表示"子区域")
- [5.4 深拷贝:`clone()` 和 `copyTo()`](#5.4 深拷贝:
clone()和copyTo()) - [5.5 一个常见的误解:`img(rect).clone()` 是多大?](#5.5 一个常见的误解:
img(rect).clone()是多大?) - [5.6 函数传参的坑](#5.6 函数传参的坑)
- [5.7 没有写时复制(COW)](#5.7 没有写时复制(COW))
- [5.1 `Mat` 的赋值是浅拷贝](#5.1
- [六、性能提示:`at` 不便宜](#六、性能提示:
at不便宜) - 七、常见坑
- 八、小结
OpenCV 入门(二):像素访问、ROI 与深浅拷贝
环境:Windows + Visual Studio 2022 + OpenCV 4.x,语言 C++17。
第一篇讲了图像是一张数字表格、
Mat是什么、怎么读图/显示/存图。这一篇进入真正动手的部分:怎么读写单个像素 ,以及Mat最反直觉的一点------赋值到底是复制数据,还是共享内存。
前言
上一篇结束时留下一个问题:图像既然是一张表格,那"改一个格子"该怎么写?
答案就是 at<T>(y, x)。但比 at 本身更重要的是它背后那套坐标约定和内存模型------坐标系反人类、赋值不复制数据,这两个坑几乎每个新手都踩过,而且踩了不一定报错,只会得到一张莫名其妙的图。
这一篇就把这两件事讲透。
一、坐标系统:先行后列
图像的行列和数学坐标系是反的:
x →(列,向右)
┌──────────────┐
y │ (0,0) │
↓ │ │
(行,│ │
向下)│ │
└──────────────┘
- 原点在左上角
- x 向右(列方向)
- y 向下(行方向)
而 at 的参数顺序是 (y, x) ------先行后列:
cpp
img.at<Vec3b>(y, x) // 第 y 行、第 x 列
数学里写 (x, y),OpenCV 写 (y, x)。写反了不会报错,只会取到另一个像素(相当于沿对角线翻转),调半天才发现。
记忆方法:
Mat(rows, cols)也是先高后宽,和at(y, x)一致。OpenCV 整体就是"先行后列"的风格。
二、读一个像素:at<T>(y, x)
2.1 类型参数 T 怎么选
| 图像类型 | 用哪个 T | 说明 |
|---|---|---|
彩色 CV_8UC3 |
Vec3b |
3 个 uchar,顺序 B、G、R |
灰度 CV_8UC1 |
uchar |
1 个字节,0~255 |
浮点单通道 CV_32F |
float |
|
浮点三通道 CV_32FC3 |
Vec3f |
T 写错(比如彩色图用 uchar)不会报错,只会读到第一个通道的一个字节 ------结果是垃圾值。所以 T 必须和 Mat::type() 对上。
2.2 读取并打印
cpp
cv::Mat img = cv::imread("data/lena.jpg", cv::IMREAD_COLOR);
if (img.empty()) {
std::cout << "读图失败" << std::endl;
return -1;
}
int x = 200, y = 100;
cv::Vec3b p = img.at<cv::Vec3b>(y, x); // 注意是 (y, x)
std::cout << "BGR = ("
<< (int)p[0] << ", "
<< (int)p[1] << ", "
<< (int)p[2] << ")" << std::endl;
输出:
BGR = (118, 119, 209)
两个要点:
p[0]是 B,p[2]是 R(BGR 顺序,第一篇讲过)。这组数里 R 最大,所以这个点是偏红的肤色。- 打印必须
(int)强转 。p[0]是uchar,直接cout会当成字符输出(118对应字符v)。
2.3 Vec3b 是什么
cpp
typedef Vec<uchar, 3> Vec3b; // 3 个 uchar 的小数组
它就是个定长 3 的数组,支持 p[0]、p[1]、p[2],也支持 p.val[0]。命名规则:
Vec3b= 3 通道,b yte(uchar)Vec3f= 3 通道,floatVec4b= 4 通道(带 alpha)
三、改一个像素:at 返回的是引用
cpp
img.at<cv::Vec3b>(y, x) = cv::Vec3b(0, 0, 255); // 纯红
这里能直接赋值,是因为 at 返回的是 Vec3b&(引用) ,不是值。这一点和 std::vector::operator[] 完全一样:
cpp
std::vector<int> v(10);
v[0] = 1; // 改的是容器里的元素
img.at<cv::Vec3b>(y, x) = ...; // 改的是图像里的像素
注意和
std::vector<bool>、std::map::operator[]这类返回代理对象的坑不同,Mat::at返回的是货真价实的引用,没有隐藏行为。
3.1 改一块区域:双重循环
cpp
// 左上角 40x40 涂成绿色
for (int yy = 0; yy < 40; ++yy) {
for (int xx = 0; xx < 40; ++xx) {
img.at<cv::Vec3b>(yy, xx) = cv::Vec3b(0, 255, 0);
}
}
- 外层是
y(行),内层是x(列)------和内存布局一致,缓存友好 - 内层循环变量变化最快,对应内存里连续的一段
如果只是想整块涂色,不用循环:
img(cv::Rect(0, 0, 40, 40)).setTo(cv::Scalar(0, 255, 0));。后面讲 ROI 时会说到。
3.2 验证坐标方向:画一条对角线
cpp
for (int i = 0; i < 200; ++i) {
img.at<cv::Vec3b>(i, i) = cv::Vec3b(255, 255, 255);
}
(i, i) 意味着 x 和 y 同步增长,画出来的是从左上角往右下角的斜线。
如果这条线从右上往左下 ,说明你的 at 参数写反了。
四、ROI:从原图上"开一扇窗"
4.1 Rect(x, y, width, height)
cpp
cv::Rect face(200, 150, 100, 100); // 左上角 (200,150),宽 100,高 100
cv::Mat roi = img(face);
注意 Rect 的参数顺序是 (x, y, 宽, 高) ,和 at(y, x) 又反过来了------Rect 用的是数学习惯。这是 OpenCV 里另一处容易写反的地方。
4.2 ROI 不是复制,是"视图"
cpp
roi.setTo(cv::Scalar(255, 0, 0)); // 把 roi 涂蓝
此时 img 上对应那块区域也会变蓝。
因为 img(face) 只创建了一个新的表头,里面记录:
- 数据指针 → 指向原图那块内存的中间某个位置
- rows / cols → 100 / 100
- step → 仍然是原图的行字节数
像素数据一个字节都没复制。所以 roi 就是 img 的一个"窗口",改窗口就是改原图。
4.3 ROI 的典型用途
| 用途 | 写法 |
|---|---|
| 只看某个区域 | cv::imshow("face", img(cv::Rect(...))) |
| 只处理某个区域 | cv::GaussianBlur(img(rect), dst, ...) |
| 往原图贴东西 | small.copyTo(img(rect)); |
这也是 OpenCV 高效的原因之一:切 ROI 是 O(1) 的,move 一个表头就行,不用拷贝几十万像素。
五、浅拷贝 vs 深拷贝:本篇最重要的部分
5.1 Mat 的赋值是浅拷贝
cpp
cv::Mat a = img; // 浅拷贝:共享同一块像素内存
a.setTo(0); // img 也全黑了
Mat 内部结构是 "表头 + 数据块":
a ──┐
├──→ [表头: rows/cols/step/type] ──→ [数据块], 引用计数 = 2
img ┘
赋值只复制表头,引用计数 +1,像素数据一个字节都不动。
5.2 引用计数:为什么不会 double free
数据块旁边有个计数器:
- 拷贝一次 → +1
- 析构一个
Mat→ -1 - 减到 0 才真正释放内存
所以浅拷贝是安全的,不需要你手动管理。这一点和 std::shared_ptr 完全一致:
std::shared_ptr<T> |
cv::Mat |
|
|---|---|---|
| 拷贝 | 引用计数 +1 | 引用计数 +1,只复制表头 |
| 最后一个析构 | 释放对象 | 引用计数归零才 delete[] |
| 改所指内容 | *p = x 影响所有持有者 |
img.at<>(...) = x 影响所有持有者 |
| 改指向 | p = other 不影响别人 |
img = other 不影响别人 |
5.3 一个关键区别:Mat 能表示"子区域"
shared_ptr 只能指向同一个对象 。Mat 是"表头 + 数据",所以多个 Mat 可以是同一块内存的不同视图:
cpp
cv::Mat roi = img(cv::Rect(200, 150, 100, 100));
// 同一个数据块,但 rows/cols/数据指针都不同
这就是 Mat 必须自己实现引用计数、而不能直接套 shared_ptr 的原因。
5.4 深拷贝:clone() 和 copyTo()
想真正独立,必须显式拷贝:
cpp
cv::Mat b = img.clone(); // 深拷贝,新建一块内存
cv::Mat c;
img.copyTo(c); // 深拷贝,等价于 clone()
cv::Mat d;
img.copyTo(d, mask); // 深拷贝 + 掩码,只复制 mask 非 0 的位置
两者的区别很小:
clone():永远新分配内存copyTo():如果目标Mat的尺寸和类型正好合适 ,会复用已有内存,省一次分配。视频循环里每帧尺寸固定,用copyTo更省
5.5 一个常见的误解:img(rect).clone() 是多大?
cpp
cv::Mat face = img(cv::Rect(200, 150, 100, 100)).clone();
// face 是 100x100,不是整图
因为 clone() 复制的是"你手上这个 Mat 的尺寸" ,而 img(rect) 本身就已经是 100×100 的视图了。
想把整图复制一份、再改其中一块,顺序要反过来:
cpp
cv::Mat full = img.clone(); // ① 整图深拷贝
full(cv::Rect(200, 150, 100, 100)).setTo(cv::Scalar(0, 0, 255)); // ② 在副本上开窗改
打印出来验证一下最直观:
cpp
std::cout << img.cols << " x " << img.rows << std::endl; // 512 x 512
std::cout << face.cols << " x " << face.rows << std::endl; // 100 x 100
5.6 函数传参的坑
Mat 按值传参很便宜(只复制表头),但函数里改像素会直接影响调用者:
cpp
void paintRed(cv::Mat m) // 按值传,只拷表头
{
m.setTo(cv::Scalar(0, 0, 255)); // 改的是共享像素 -> 调用者的图也变红
}
void repoint(cv::Mat m)
{
m = cv::Mat::zeros(10, 10, CV_8UC3); // 只改自己这个表头 -> 调用者毫无影响
}
规则和 shared_ptr 一样:
改内容会传染,改指向不会。
如果不想让函数改到原图,函数内部先 clone(),或者干脆用 const cv::Mat& 并在接口约定里说明"不修改像素"(注意 const 只保证不能重新赋值表头,不阻止 setTo,所以约定仍然重要)。
5.7 没有写时复制(COW)
和 Qt 的 QImage、QString 不同,OpenCV 的 Mat 不做写时复制:
Qt: 拷贝 -> 共享 -> 一写就偷偷复制一份(COW)
OpenCV:拷贝 -> 共享 -> 一写就真的改到共享内存
从 Qt 转过来的人特别容易在这里翻车------以为改的是副本,其实改的是原图。
六、性能提示:at 不便宜
at<T>(y, x) 每次调用都要算地址,Debug 下还有边界检查。单点读写完全没问题,但逐像素遍历整张图时,它可能是瓶颈。
需要极致性能时,用行指针:
cpp
for (int y = 0; y < img.rows; ++y) {
cv::Vec3b* row = img.ptr<cv::Vec3b>(y); // 取第 y 行的首地址
for (int x = 0; x < img.cols; ++x) {
row[x][0] = 0; // 直接按指针偏移访问
}
}
三种访问方式的速度大致是:
ptr<T>() > at<T>() >> 迭代器(实际上迭代器也很快,但灵活性差)
原则:先用 at 把逻辑写对,确认是性能热点再换 ptr。 不要过早优化。
更省事的做法是用 OpenCV 自带的函数(
cv::LUT、cv::threshold、cv::convertTo),它们的循环是 SIMD 优化的,通常比手写循环快一个数量级。
七、常见坑
at(y, x)先行后列。 写成at(x, y)不报错,只是取到对角线上的另一个像素。Rect(x, y, w, h)反过来是(x, y, 宽, 高)。 和at的顺序不一致,靠Rect用的是数学习惯。T必须和type()对应。 彩色图用uchar不报错,只读到第一个通道的一个字节。at返回引用,赋值直接改原图。 想保留原图先clone()。Mat a = b是浅拷贝。 改a会改b,和shared_ptr一样。- ROI 是视图,不是复制。 改 ROI 就是改原图。
img(rect).clone()得到的是rect大小,不是整图。Mat没有 COW。 别拿 Qt 的QImage经验套。- 函数按值收
Mat,改像素会传染给调用者。 - 打印
uchar要(int)强转,否则输出字符。
八、小结
| 概念 | 一句话 |
|---|---|
| 坐标 | 原点左上,x 向右,y 向下 |
at<T>(y, x) |
先行后列;T 要匹配通道类型 |
Vec3b |
3 个 uchar,顺序 B、G、R |
at 的返回值 |
引用,赋值即改原图 |
Rect(x,y,w,h) |
先坐标后宽高,和 at 顺序相反 |
| ROI | 原图上的窗口,共享内存,O(1) |
| 浅拷贝 | = / (rect),共享内存,引用计数管理 |
| 深拷贝 | clone() / copyTo(),独立内存 |
copyTo(dst, mask) |
只复制 mask 非 0 的像素 |
| 性能 | 先用 at,热点再换 ptr |