图像运算、掩膜/ROI 与绘制交互
目标:掌握「在图像上做代数/逻辑运算」和「局部处理」这两类日常操作,
会画标注、会做鼠标交互调试,并且知道
cv::putText画不了中文这件事的真相。写作基线:OpenCV 4.13.0。本篇的数值与配图均为本机实测。
一、算术运算:溢出不报错,这才是最危险的
1.1 一个问题
一张灰度图,你想让整体亮一点。最自然的写法是写个循环:
cpp
cv::Mat img = ...; // CV_8UC1,像素值 0-255
for (int r = 0; r < img.rows; ++r) {
uchar* p = img.ptr<uchar>(r);
for (int c = 0; c < img.cols; ++c) {
p[c] = p[c] + 100; // ← 这里有个严重的 bug
}
}
p[c] 是 unsigned char。当 p[c] 是 200 时会发生什么?
- C++ 的整数提升规则把
p[c]提升为int,加法在int上算,得到 300 - 把 300 赋回
unsigned char------ 这是模 256 的截断,得到 44
这不是未定义行为,是标准明确规定的回绕。 也正因为它是"定义良好的",编译器和运行时都不会给你任何提示。
于是你的图不是变亮了,而是超过 255 的地方全部变成黑色。
⚠️ 顺带说明:
cv::Mat之间的operator+(走MatExpr)是饱和的 ,因为它内部调用了cv::add。所以「用运算符」在 C++ 里通常没问题。真正的危险都在手写循环 里 ------ 也就是上面这种写法。
本文因此不讨论「该不该用运算符」,只讲循环里的溢出。
1.2 实测对比

图 1:cv2.add 与 numpy 加法的区别(OpenCV 4.13.0 实测)。本机实测第 200 列像素:原图 200 | cv2.add 255 | numpy 44。
看中间那张和下面那张的差别 ------ 一个平滑地从白过渡到黑(正确),一个突然跳变成黑色(错误)。这个跳变就是溢出的痕迹。
1.3 正确做法:用 OpenCV 的运算函数
cpp
cv::Mat img = ...; // CV_8UC1
cv::Mat brighter;
cv::add(img, 100, brighter); // ✅ 超过 255 的部分饱和到 255
// 或者用标量
cv::add(img, cv::Scalar(100), brighter);
// 也可以用带缩放的加法(常用于加权叠加)
cv::addWeighted(a, 0.7, b, 0.3, 0.0, dst); // dst = 0.7*a + 0.3*b + 0
cv::add 内部做了 saturate_cast ------ 超出范围的值被截断到边界,而不是回绕。这就是这些函数存在的理由。
1.4 运算函数对照表
| 用途 | C++ 写法 | 等价数学 |
|---|---|---|
| 加 | cv::add(a, b, dst) |
dst = a + b(饱和) |
| 加权和 | cv::addWeighted(a, α, b, β, γ, dst) |
dst = αa + βb + γ |
| 减 | cv::subtract(a, b, dst) |
dst = a - b(饱和到 0) |
| 乘 | cv::multiply(a, b, dst) |
dst = a * b |
| 除 | cv::divide(a, b, dst) |
dst = a / b |
| 带缩放 | cv::divide(a, b, dst, scale) |
dst = scale * a / b |
| 绝对差 | cv::absdiff(a, b, dst) |
`dst = |
| 取反 | cv::bitwise_not(a, dst) |
dst = ~a |
| 逐元素最值 | cv::max(a, b, dst) / cv::min(a, b, dst) |
逐元素取最大/最小 |
cv::absdiff 在缺陷检测里极其常用 ------ 它是「两张图相减找差异」的标准工具,不会因为顺序不同而得到负值。
1.5 手动 saturate_cast
如果你确实要写循环,用 cv::saturate_cast<T>():
cpp
for (int r = 0; r < img.rows; ++r) {
uchar* p = img.ptr<uchar>(r);
for (int c = 0; c < img.cols; ++c) {
p[c] = cv::saturate_cast<uchar>(p[c] + 100); // ✅ 安全
}
}
注意 p[c] + 100 这个表达式本身 :p[c] 会先被提升为 int,加法在 int 上完成(不会溢出),然后 saturate_cast<uchar> 把它截断到 [0, 255]。
但如果两个 uchar 相加再乘,中间结果可能超出 int ------ 那种情况下要显式先转成 int 或 float 再算。
二、掩膜与 ROI:局部处理的两条路
2.1 别名的坑:cv::Rect 是 (x, y, w, h)
cpp
cv::Mat roi = img(cv::Rect(10, 20, 30, 40));
// x y w h
不是 (x1, y1, x2, y2)。写成后者会取到一个位置和大小都不对的区域 ------ 而且不报错。
2.2 掩膜:按「像素条件」选区域
cpp
cv::Mat gray, mask, cut;
cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);
// 阈值生成掩膜:亮于 200 的像素为 255,其余为 0
cv::threshold(gray, mask, 200, 255, cv::THRESH_BINARY);
// 用掩膜取出原图对应像素,其余置 0
cv::bitwise_and(img, img, cut, mask);
实测:本篇测试图中掩膜命中了 6625 / 95000 个像素(7.0%) ,即按 > 200 筛出的亮区。
cv::bitwise_and(src, src, dst, mask) 这个「自己与自己做与运算」的写法看起来奇怪,但它是标准用法 ------ 效果就是「mask 为 0 的地方输出 0,其余保留原值」。
等价的写法是 img.copyTo(cut, mask) ------ 注意 copyTo 带 mask 时,是「只复制 mask 非零处」,目标的其他位置保持原样(不是清零)。两者语义不同:
cpp
cv::Mat dst1 = cv::Mat::zeros(img.size(), img.type());
img.copyTo(dst1, mask); // mask=0 处保持 dst1 的原值(这里是 0)
cv::Mat dst2;
cv::bitwise_and(img, img, dst2, mask); // mask=0 处一定是 0
如果目标图不是全 0 初始化的,这两个写法结果不同。 这是很隐蔽的一类 bug。
2.3 ROI:按「矩形位置」选区域
cpp
cv::Mat roi = img(cv::Rect(10, 20, 30, 40)); // 视图,共享数据
roi = cv::Scalar(0, 0, 255); // 直接改到原图上
或者用 copyTo 把一块贴回去:
cpp
cv::Mat patch = MakePatch(); // 尺寸必须匹配目标 ROI
patch.copyTo(img(cv::Rect(x, y, w, h)));
2.4 水印:ROI + addWeighted
这是 ROI 最典型的用法 ------ 只需要处理一小块区域,就不要对整个图做运算:
cpp
// 做一个半透明的 logo
cv::Mat logo = cv::Mat::zeros(80, 160, CV_8UC3);
cv::circle(logo, cv::Point(40, 40), 32, cv::Scalar(255, 255, 255), cv::FILLED);
cv::putText(logo, "LOGO", cv::Point(52, 48),
cv::FONT_HERSHEY_SIMPLEX, 0.6, cv::Scalar(255, 255, 255), 2);
// 定位到右下角
const int x = img.cols - 170;
const int y = img.rows - 90;
// 只对这块 ROI 做加权叠加
cv::Mat roi = img(cv::Rect(x, y, logo.cols, logo.rows));
cv::addWeighted(roi, 0.5, logo, 0.5, 0.0, roi);
// ↑原图权重 ↑logo权重 ↑加到 ROI 上(视图,直接改原图)
实测中这块 ROI 是 160x80 @ (210,160) ------ 坐标是从图的实际尺寸算出来的,不是写死的。
如果写成 cv::addWeighted(img, ..., logo, ..., dst),会因为尺寸不匹配直接报错。用水印就必须走 ROI。

图 2:水印叠加、掩膜抠图、ROI 局部替换(OpenCV 4.13.0 实测)。第一格是原图,第二格是右下水印的效果。
实测数据 :水印 ROI 尺寸 160×80 @ (210, 160);掩膜(灰度 > 200 的亮区)命中 6625 / 95000 像素(7.0%)。
2.5 创建背景图
cpp
cv::Mat canvas = cv::Mat::zeros(480, 640, CV_8UC3); // 黑底
cv::Mat canvas2(480, 640, CV_8UC3, cv::Scalar(255, 255, 255)); // 白底
cv::Mat::zeros 是静态方法,尺寸在前;cv::Mat(rows, cols, type, value) 是构造函数,尺寸也是 (行, 列) ------ 注意和 cv::Rect 的 (x, y, w, h) 顺序不同,容易混。
三、绘制:往图上画东西
cpp
// 直线
cv::line(img, cv::Point(10, 10), cv::Point(200, 200),
cv::Scalar(0, 0, 255), 2, cv::LINE_AA);
// 矩形
cv::rectangle(img, cv::Rect(50, 50, 120, 80),
cv::Scalar(0, 255, 0), 2);
cv::rectangle(img, cv::Point(50, 50), cv::Point(170, 130),
cv::Scalar(0, 255, 0), 2); // 也可以用两个对角点
// 圆
cv::circle(img, cv::Point(300, 200), 60,
cv::Scalar(255, 0, 0), cv::FILLED); // FILLED 或 -1 表示实心
// 椭圆
cv::ellipse(img, cv::Point(400, 300), cv::Size(80, 50),
30.0, // 旋转角度
0, 360, // 起始/结束角度
cv::Scalar(0, 255, 255), 2);
// 多边形(折线)
std::vector<cv::Point> pts;
pts.push_back(cv::Point(100, 100));
pts.push_back(cv::Point(200, 120));
pts.push_back(cv::Point(150, 220));
cv::polylines(img, pts, /*isClosed=*/true, cv::Scalar(255, 255, 0), 2);
// 实心多边形
std::vector<std::vector<cv::Point> > polys;
polys.push_back(pts);
cv::fillPoly(img, polys, cv::Scalar(255, 255, 0));
// 文字(仅 ASCII,下一节详说)
cv::putText(img, "OK", cv::Point(20, 40),
cv::FONT_HERSHEY_SIMPLEX, 1.0, cv::Scalar(255, 255, 255),
2, cv::LINE_AA);
三个参数细节
thickness 为负(或 cv::FILLED = -1)表示填充。
lineType 用 cv::LINE_AA 开抗锯齿。 默认的 LINE_8 画斜线和圆会有明显锯齿。代价是慢一些 ------ 批量绘制大量图元时可以考虑关掉。
cv::polylines 和 cv::fillPoly 的参数形式不同:
polylines接受std::vector<Point>(一条折线)fillPoly接受std::vector<std::vector<Point>>(多条多边形)
传错了会编译失败,不是运行时问题。
测量文字尺寸
cpp
int baseline = 0;
const cv::Size sz = cv::getTextSize("Result: PASS",
cv::FONT_HERSHEY_SIMPLEX, 1.0, 2,
&baseline);
getTextSize 返回文字的包围盒,baseline 是基线到包围盒底部的距离。要在图上居中或者做背景框时会用到。
注意它只对 ASCII 有意义 ------ 同一个字符串如果含中文,putText 画出来的和 getTextSize 算出来的完全对不上(下一节)。
四、cv::putText 画不了中文:真相与解法
这是中文读者必然撞上、而英文教程完全不会提的问题。
4.1 现象

图 3:cv::putText 画中文的真实结果(OpenCV 4.13.0 实测)。"检测结果:合格" 这 7 个字(UTF-8 共 21 字节)被画成了 21 个问号。
同一个 putText 调用,画英文完全正常,画中文变成一问号串。
实测数据 :字符串 "检测结果:合格" 有 7 个字符 ,UTF-8 编码是 21 字节 ,画出来是 21 个问号。
4.2 为什么
cv::putText 硬编码使用 OpenCV 内置的 Hershey 矢量字体。这是 1967 年 Allen Hershey 为绘图仪设计的一套字体,OpenCV 内置了它的约 95 个 ASCII 字形。
它里面一个 CJK 字形都没有。
而 putText 遇到无法映射的字符时,会画一个 ?。由于它按字节遍历字符串,每个非 ASCII 的 UTF-8 字节都会变成一个 ? ------ 这就是为什么 21 字节变成了 21 个问号(而不是 7 个)。
4.3 两个常见的错误尝试
错误尝试 1:换个 FONT_HERSHEY_* 变体。
cpp
cv::putText(img, "检测结果", pt, cv::FONT_HERSHEY_DUPLEX, 1.0, color);
// 结果一样是问号
没用。 所有 FONT_HERSHEY_* 变体都是 Hershey 字体族的不同字重/风格,全都没有 CJK 字形。
错误尝试 2:转换编码。
cpp
cv::putText(img, "检测结果", pt, cv::FONT_HERSHEY_SIMPLEX, 1.0, color);
// 换成 GBK、UTF-16......都一样是问号
也没用。 问题不在编码,在光栅化器里根本没有那些字形 。Python 侧用 u8"中文" 或 .encode('utf-8') 同样无效。
顺便澄清一个流传很广的说法 :即使你的 OpenCV 编译时开了 -DWITH_FREETYPE=ON,cv::putText 依然走 Hershey 路径 。FreeType 支持并没有暴露给原生 putText API ------ 它只给了 contrib 里的 cv::freetype 模块。很多中文教程在这一点上写错了。
4.4 正确做法
方案 A:contrib 的 freetype 模块(需要 contrib 和 FreeType 库)
cpp
#include <opencv2/freetype.hpp>
cv::Ptr<cv::freetype::FreeType2> ft2 = cv::freetype::createFreeType2();
ft2->loadFontData("C:/Windows/Fonts/msyh.ttc", 0); // 微软雅黑
ft2->putText(img, u8"检测结果:合格",
cv::Point(10, 40),
28, // 字号
cv::Scalar(0, 0, 255),
cv::FILLED, // 负值/填充表示实心字形
cv::LINE_AA,
true); // bottomLeftOrigin
代价:需要自编译带 contrib 的 OpenCV(见 00-02 的 vcpkg 路线),部署时还要带上 FreeType 的 DLL。
方案 B:在图上只写 ASCII,中文交给外部 UI(推荐用于生产)
把中文放在窗口标题、控制台、或者宿主程序的界面元素里,图上只画英文和数字。
对于 NX 插件这类已经有 UI 框架的场景,这是最省事的做法 ------ 你本来就有 NXMessageBox,没必要非把中文画在图里。
方案 C:用别的库渲染后再合成
PIL(Python)、GDI/DirectWrite(Windows)、Qt 都可以。思路都是「先渲染成一张图,再把图贴到 cv::Mat 上」。本系列的配图就是这么做的 ------ tools/figures/_common.py 里的 load_cjk_font() 加载微软雅黑,全部中文标注走 PIL。
如果只是自己调试看效果,Python + PIL 是最快的:
python
from PIL import Image, ImageDraw, ImageFont
import numpy as np, cv2
pil = Image.fromarray(cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB))
ImageDraw.Draw(pil).text((10, 40), "检测结果:合格",
font=ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 28),
fill=(0, 0, 255))
bgr = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
注意 ImageDraw.text 的坐标是左上角 ,而 cv::putText 默认是左下角(基线位置)。换算时容易差一个文字高度。
五、鼠标交互
调试时经常需要「点一下看看这个位置的像素值是多少」。
cpp
struct MouseState {
cv::Point pt;
bool pressed;
MouseState() : pt(-1, -1), pressed(false) {}
};
static MouseState g_state;
static cv::Mat g_img;
void OnMouse(int event, int x, int y, int flags, void* userdata)
{
MouseState* st = static_cast<MouseState*>(userdata);
switch (event) {
case cv::EVENT_LBUTTONDOWN:
st->pressed = true;
st->pt = cv::Point(x, y);
std::cout << "按下 (" << x << ", " << y << ")" << std::endl;
// 打印该点的像素值
if (x >= 0 && x < g_img.cols && y >= 0 && y < g_img.rows) {
const cv::Vec3b px = g_img.at<cv::Vec3b>(y, x);
std::cout << " BGR = (" << static_cast<int>(px[0]) << ", "
<< static_cast<int>(px[1]) << ", "
<< static_cast<int>(px[2]) << ")" << std::endl;
}
break;
case cv::EVENT_MOUSEMOVE:
if (st->pressed) {
st->pt = cv::Point(x, y);
}
break;
case cv::EVENT_LBUTTONUP:
st->pressed = false;
std::cout << "释放 (" << x << ", " << y << ")" << std::endl;
break;
default:
break; // 必须有 default
}
}
int main()
{
g_img = cv::imread("test.png");
if (g_img.empty()) { return -1; }
cv::namedWindow("win");
cv::setMouseCallback("win", OnMouse, &g_state);
cv::imshow("win", g_img);
cv::waitKey(0);
return 0;
}
实时框选 ROI 的常见模式是:按下时记起点,拖动时用 imshow 刷一张带临时矩形的副本,松开时确定最终矩形。注意不要直接画在原图上 ------ 每次刷新都会留下上一次的框。正确做法是保留一份原图副本,每次在副本上重新画:
cpp
cv::Mat display = g_img.clone(); // 每次刷新都从这份干净的副本开始
cv::rectangle(display, rect, cv::Scalar(0, 255, 0), 2);
cv::imshow("win", display);
六、这套方案什么时候会失败
- 整数图像上做加减乘。 溢出会回绕且不报错。一律用
cv::add/cv::subtract/cv::multiply/cv::absdiff。 cv::Rect超出图像边界。 OpenCV 会抛出异常(在 Release 下也可能只是裁剪)。构造前应该做范围检查,尤其是坐标来自外部输入时。copyTo带掩膜时目标图未初始化。 掩膜为 0 的位置保持目标的原值 ------ 如果目标里有垃圾数据,结果里就有垃圾数据。用bitwise_and或者先把目标清零。cv::putText画任何非 ASCII 字符。 不只是中文,日文、韩文、俄文、甚至带重音的拉丁字母全都会变成问号。getTextSize和putText的坐标基准不一致。putText的org是文字左下角 (基线),getTextSize返回的矩形也是相对基线算的。想精确排版要自己减sz.height。- 在
imshow的原图上反复画标注。 标注会累积。始终保留一份干净的副本。 - 用 ROI 视图做长时间存活的对象。 前面说过,ROI 持有父图的整块内存。
七、Python 对照
7.1 算术运算:Python 更容易写出错而不自知
Python 侧的危险在于:numpy 的加法语法上完全合法,运算也照常返回,只是结果不是你要的。
以下是在本机 NumPy 2.4.3 上的实测:
python
import numpy as np, cv2
img = np.array([[200]], dtype=np.uint8)
print(img + 100) # [[ 44]] ← 回绕!不是 300
print(img + np.uint8(100)) # [[ 44]] ← 回绕
print(cv2.add(img, 100)) # [[255]] ← 饱和,正确
三种写法里有两种给出的是错误结果 ,而且一个警告都不会有。
关于 NumPy 的版本差异 :NumPy 1.x 时代,uint8 数组 + Python int 会提升到能容纳结果的类型(得到 300);NumPy 2.0 起收紧了类型提升规则 (NEP 50),Python 的 int 在运算中被视为"弱类型",结果会保持 uint8 ------ 于是变成回绕。
也就是说:同一行 Python 代码,在 NumPy 1.x 和 2.x 上结果不同。 这正是 5.0 迁移里「NumPy 2.x 支持」被列为一项变更的原因。
所以不要靠记忆去区分哪种写法安全。 只要记住一条:
图像像素运算一律用
cv2.add/cv2.subtract/cv2.multiply/cv2.absdiff,不要用 numpy 的+ - * /。
多通道情况也一样(实测):
python
c = np.array([[[200, 200, 200]]], dtype=np.uint8)
c + np.uint8(100) # -> [[44, 44, 44]] 三个通道一起回绕
cv2.add(c, 100) # -> [[255, 255, 255]] 三个通道一起饱和
7.2 绘制函数的差异
python
# C++: cv::rectangle(img, cv::Rect(50, 50, 120, 80), color, 2);
# Python 用两个点:
cv2.rectangle(img, (50, 50), (170, 130), (0, 255, 0), 2)
# ↑pt1 ↑pt2 = pt1 + (w, h)
# C++ 的 cv::Rect 在 Python 里没有直接对应,用 (x, y, w, h) 元组要自己转成两个点
x, y, w, h = 50, 50, 120, 80
cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)
这是很容易出错的一处 :C++ 习惯用 Rect,Python 的绘图函数收的是两个点。直接把 (50, 50, 120, 80) 传给 Python 的 rectangle 会被当成 pt1=(50,50), pt2=120 ------ 参数个数不对,报错还好;要是参数个数刚好对上是更危险的。
常用等价对照:
| 功能 | C++ | Python |
|---|---|---|
| 矩形 | cv::rectangle(img, cv::Rect(x,y,w,h), c, t) |
cv2.rectangle(img, (x,y), (x+w,y+h), c, t) |
| 圆 | cv::circle(img, cv::Point(x,y), r, c, t) |
cv2.circle(img, (x,y), r, c, t) |
| 填充 | cv::FILLED 或 -1 |
cv2.FILLED 或 -1 |
(C++ 的 cv::Rect 在 Python 侧对应的是 cv2.boundingRect 等的返回值,是 (x, y, w, h) 元组,可以解包。)
7.3 掩膜参数的位置
python
# C++ 是位置参数:cv::bitwise_and(a, b, dst, mask)
# Python 是关键字参数:
cv2.bitwise_and(a, b, mask=mask)
Python 侧 bitwise_and 必须用 mask= 关键字。 写成位置参数会报错。
7.4 putText 的中文
Python 侧完全一样画不了中文,原因相同(同一个 Hershey 字体)。备选方案:
python
# 方案 1:PIL(推荐)
from PIL import Image, ImageDraw, ImageFont
pil = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
ImageDraw.Draw(pil).text((10, 40), "检测结果:合格",
font=ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 28),
fill=(0, 0, 255))
img = cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
# 方案 2:opencv-contrib-python 里的 freetype
# (需要先 pip install opencv-contrib-python,
# 注意不能和 opencv-python 同时装)
# 方案 3:图上只写 ASCII,中文走窗口标题或外部 UI
注意 PIL 的坐标是左上角,cv2.putText 是左下角基线 ------ 两者换用时坐标要调整。
八、自测
unsigned char的值是 200,执行p[c] = p[c] + 100;之后p[c]是多少?为什么?img.copyTo(dst, mask)和cv::bitwise_and(img, img, dst, mask)在什么情况下结果不同?- 为什么
cv::putText画中文会变成一串问号?换成FONT_HERSHEY_DUPLEX能解决吗? cv::putText的org参数指的是文字的哪个位置?
答案
- 44 。
200 + 100 = 300,unsigned char只能存 0-255,300 溢出后回绕成300 - 256 = 44。要避免就包一层cv::saturate_cast<uchar>(...),或者直接用cv::add。 - 当
dst不是全 0 初始化时。copyTo带掩膜是「只复制掩膜非零处,其余位置保持 dst 原值」;bitwise_and是「掩膜为 0 处一定输出 0」。如果 dst 里有旧数据,两者结果不同。 - 因为
putText用的内置 Hershey 字体里根本没有 CJK 字形 ,它把每个非 ASCII 的 UTF-8 字节都画成一个?。换成任何FONT_HERSHEY_*变体都没用 ------ 它们都是同一个字体族。 - 文字的左下角(基线位置)。这也是为什么经常要把 y 坐标加上文字高度才能让文字贴合框的上沿。
阶段 1 完成
到这里,OpenCV 的基础部分就走完了。你现在应该能:
- 理解
cv::Mat的指针语义,知道什么时候会共享数据、什么时候需要clone() - 在 Windows 上正确处理中文路径的图像读写
- 用 HSV 做颜色筛选,用直方图判断图像质量
- 写不会溢出的像素运算代码,用掩膜和 ROI 做局部处理
- 知道
cv::putText画不了中文,以及正确的替代路径