效果先行:三种克隆模式并排
左起:目标背景 → 克隆落点(红圈)→ Normal → Mixed → Monochrome。

木箱纹理是暖色,背景刻意用青绿冷色------必须制造差异,否则融合成功后根本看不出效果。这点后面有个专门的坑。
一、这个例程解决什么问题
把 A 图的一块贴到 B 图上,怎么让它看起来不像贴的?
直接 copyTo 会留下硬边。原因很直白:两图的绝对亮度不同。源图边界处像素值是 180,目标图是 90,拼一起就是 90 的跃变。
OpenCV 的 seamlessClone 用的是泊松融合 (Poisson blending):不搬像素值,只搬梯度场(相邻像素的差),然后在目标图上解泊松方程。
用一句话概括原理:
梯度场描述「怎么变化」,边界值描述「从哪开始」。
把源图的「变化方式」套在目标图的「起始亮度」上 → 纹理像源图、亮度像目标图 → 无缝。
数学上是:
min_f ∬ |∇f - v|² dx dy 边界条件 f|∂Ω = dst|∂Ω
→ 欧拉-拉格朗日 → 泊松方程 Δf = div v
OpenCV 用 DST(离散正弦变换)求解,复杂度 O(n log n)。640×480 实测 39-56 ms。
二、六个模式,一次跑完
官方样例 cloning_demo.cpp 有 6 个模式,但它是交互式 的------cin >> num,一次只跑一种。而且需要 opencv_extra 的 14 个测试图,本机没有。
我改成批处理:本地合成测试图,一次跑完 6 种,输出对比图。
src : 240x240 ch=3 (crate.jpg 裁块)
dst : 640x480 ch=3 (合成渐变+网格)
mask : 240x240 ch=1 (圆形实心)
NORMAL_CLONE flag=1 正常克隆 43.4 ms 输出 640x480
MIXED_CLONE flag=2 混合克隆 40.5 ms 输出 640x480
MONOCHROME_TRANSFER flag=3 单色迁移 47.2 ms 输出 640x480
三个局部修改 API 也一并测了:
colorChange(src, mask, out, 1.5, 0.5, 0.5) ← Red×1.5, Green×0.5, Blue×0.5
illuminationChange(src, mask, out, 0.2, 0.4) ← alpha/beta 越小越亮越平
textureFlattening(src, mask, out, 30, 45, 3) ← 展平 mask 内高频,只留色彩

三、三种模式的区别,用数字说清楚
肉眼看图不够,我算了每种结果和原始背景的差异:
| 模式 | maxdiff | meandiff | 改动像素数(>5) |
|---|---|---|---|
| NORMAL_CLONE | 240.0 | 1.36 | 22699 |
| MIXED_CLONE | 87.0 | 0.17 | 2268 |
| MONOCHROME_TRANSFER | 230.0 | 1.42 | 22995 |
Mixed 的改动量只有 Normal 的 1/10。
为什么?读源码(seamless_cloning_impl.cpp:340-370),MIXED_CLONE 的判据不是「谁梯度大用谁」,而是:
cpp
if (abs(patchXLinePtr[j] - patchYLinePtr[j]) >
abs(destinationXLinePtr[j] - destinationYLinePtr[j]))
{
// 源图"各向异性更强" → 用源梯度
patchXLinePtr[j] *= binaryMaskLinePtr[maskIndex];
patchYLinePtr[j] *= binaryMaskLinePtr[maskIndex];
}
else
{
// 目标图更"等向" → 用目标梯度
patchXLinePtr[j] = destinationXLinePtr[j] * binaryMaskLinePtr[maskIndex];
patchYLinePtr[j] = destinationYLinePtr[j] * binaryMaskLinePtr[maskIndex];
}
判据是 |gx - gy| (各向异性强度),不是 |gx| + |gy|(梯度幅值)。
这很反直觉。作用:源图纹理方向性强(木纹竖直)时 |gx-gy| 大,保留源纹理;目标图是纯色网格时 gx ≈ gy,判定用目标图------而目标梯度本身接近 0,等于没改。
这就是 Mixed 在这种背景上几乎静默的原因。
四、坑一:掩码不是二值的
官方样例用 imread(mask_path, IMREAD_COLOR) 读掩码,3 通道。那颜色有意义吗?
我做了个实验:用三个不同颜色的圆做掩码。
红色圆(BGR=0,0,255) -> 灰度 = 76
绿色圆(BGR=0,255,0) -> 灰度 = 150
蓝色圆(BGR=255,0,0) -> 灰度 = 29
看源码(seamless_cloning.cpp:50-64):
cpp
static Mat checkMask(InputArray mask, Size size)
{
Mat gray;
if (mask.channels() == 3 || mask.channels() == 4)
cvtColor(mask, gray, COLOR_BGRA2GRAY); // ← 多通道转灰度
else
{
if (mask.empty())
gray = Mat(size.height, size.width, CV_8UC1, Scalar(255)); // 空掩码=全图
else
return mask.getMat(); // 1 通道原样返回
}
return gray;
}
颜色信息被丢掉了。但灰度值没有丢 ------再看下一层(seamless_cloning_impl.cpp:263):
cpp
binaryMask.convertTo(binaryMaskFloat, CV_32FC1, 1.0/255.0); // ← 归一化,不是二值化
是 1.0/255.0 归一化,不是二值化!
那灰度值到底是不是权重?扫一遍:
| mask 灰度 | 与 dst 平均差异 | 相对 255 的比例 |
|---|---|---|
| 0 | 0.000 | 0.0% |
| 29 | 0.151 | 11.1% |
| 76 | 0.395 | 29.1% |
| 128 | 0.672 | 49.6% |
| 150 | 0.790 | 58.3% |
| 255 | 1.356 | 100.0% |
严格单调,灰度 128 恰好落在 49.6%(≈50%)。
这就证明了:掩码灰度值就是融合权重。

实用推论 :想「半透明贴上去」不用调什么 alpha------把掩码画成灰色就行。官方文档完全没提这个用法。
五、坑二:mask 尺寸不对 → 越界读(UB)
这个坑我一开始还判断错了,值得详细说。
我传了一个 src 一半尺寸(120×120)的掩码给 240×240 的 src。结果:
[mask 尺寸不符] 不报错!输出 640x480
不报错。 我第一反应是「OpenCV 内部缩放了掩码」,还在 README 里这么写了。
读完源码才发现完全不是。checkMask() 里没有任何尺寸校验:
cpp
Mat mask = checkMask(_mask, src.size()); // 120×120 返回的就是 120×120
Rect roi_s = boundingRect(mask); // roi_s 在 120×120 坐标系里
Mat sourceROI = Mat::zeros(roi_s.height, roi_s.width, src.type());
src(roi_s).copyTo(sourceROI, mask(roi_s)); // ← 在 240×240 的 src 上按 120×120 坐标裁剪
然后 normalClone 里:
cpp
const int w = destination.cols; // 按 destination 的宽高遍历
const int h = destination.rows;
for (int i = 0; i < h; i++)
float * patchXLinePtr = patchGradientX.ptr<float>(i);
const float * binaryMaskLinePtr = binaryMaskFloat.ptr<float>(i); // ← 越界读
两边的宽高不一致,就会读越界内存。
这是未定义行为,不是"被优雅地缩放了"。
小图可能碰巧读到相邻内存看起来正常,换尺寸、换编译器就可能崩或算出乱码。release 构建下不会触发任何断言------这是最坏的一类 bug。
CV_Assert 只检查了 _src / _dst 非空(:69-70),其他一概不管。
正确做法:调用前自己加:
cpp
CV_Assert(mask.size() == src.size());
CV_Assert(countNonZero(mask) > 0);
六、坑三:flag 写错不报错,但掩码静默失效
seamlessClone(..., 99) 也不报错,正常返回 640×480 图像。
源码(seamless_cloning_impl.cpp:332):
cpp
switch (flag)
{
case NORMAL_CLONE: case NORMAL_CLONE_WIDE: /* 掩码乘梯度 */ break;
case MIXED_CLONE: case MIXED_CLONE_WIDE: /* 比模取大 */ break;
case MONOCHROME_TRANSFER: case MONOCHROME_TRANSFER_WIDE: /* 转单色 */ break;
}
// ← 没有 default
非法 flag 直接跳过整个 switch,patchGradientX/Y 保持原始梯度、不做掩码加权------掩码完全失效。
这不是「退化成 Normal」,是另一个未定义行为。
而且 flags >= NORMAL_CLONE_WIDE(=9)会改走「宽 ROI」路径(seamless_cloning.cpp:88),所以 99 同时触发了 ROI 计算的另一套逻辑。
顺便,源码里给出了全部枚举值(photo.hpp:719-759):
| 常量 | 值 |
|---|---|
| NORMAL_CLONE | 1 |
| MIXED_CLONE | 2 |
| MONOCHROME_TRANSFER | 3 |
| NORMAL_CLONE_WIDE | 9 |
| MIXED_CLONE_WIDE | 10 |
| MONOCHROME_TRANSFER_WIDE | 11 |
后三个是 4.14.0 新增的,官方教程页面上还没写。
七、坑四:mask 全 0 静默返回原图
[mask 全 0] 不报错,与 dst 平均差异 = (0.0,0.0,0.0)
源码里找到了确切原因:
cpp
dest.copyTo(_blend); // ← 先把 dst 拷过去
...
Rect roi_s = boundingRect(mask);
if (roi_s.empty()) return; // ← mask 全 0 → 直接返回原图
boundingRect 对全 0 掩码返回空 Rect,于是提前 return ,返回的就是最开始拷的 dst。
为什么危险 :用 Mat::zeros 建掩码后忘了画东西,或者画了但坐标算错画到图外,跑出来就是"原图"。你会以为是 API 参数不对,实际是掩码空的。
八、坑五:做演示图要刻意制造差异
这个坑不是 OpenCV 的,是我自己的。
第一版我把 dst 做成「棕色渐变 + 网格」,因为觉得木箱纹理贴棕色背景"比较自然"。结果:
panel1 (background) centerStd=31.7 centerRGB=[117.4, 90.5, 63.6]
panel2 (NORMAL) centerStd=28.8 centerRGB=[116.7, 89.4, 62.1]
panel3 (MIXED) centerStd=31.9 centerRGB=[117.1, 90.2, 63.2]
panel4 (MONOCHROME) centerStd=28.8 centerRGB=[116.9, 89.7, 62.5]
四个 panel 的中央区域统计上几乎一模一样。 肉眼完全看不出差别。
这其实是泊松融合成功的证明 ------融合得太好了。但作为演示图毫无信息量。
改成青绿冷色背景后:
| 模式 | maxdiff | 改动像素数 |
|---|---|---|
| NORMAL_CLONE | 240.0 | 22699 |
| MIXED_CLONE | 87.0 | 2268 |
| MONOCHROME_TRANSFER | 230.0 | 22995 |
立刻就看得见了。
教训 :做算法演示图,输入要刻意制造差异。否则"效果好"会变成"看不出效果"。
九、GUI 版:550 行里只有 4 行是算法
cloning_gui.cpp 是第二个样例,550 行。它需要 3 个窗口 + 鼠标交互(左键画点、右键闭合、中键重置),无头环境跑不了。
但读源码后,它的核心只有 4 步:
| 步骤 | 位置 | 代码 |
|---|---|---|
| ① 收集鼠标点 | source() :77-188 |
pts[var++] = Point(x,y) |
| ② 生成掩码 | :144-147 |
fillPoly(res1, pts4, &numpts, 1, Scalar(255,255,255), 8, 0) |
| ③ 取目标点 | destination() :196-201 |
point = Point(x,y) |
| ④ 调算法 | :257 |
seamlessClone(img0, img2, res1, point, blend, num) |
我写了 gui_core.cpp 把 ①②③④ 直接跑一遍,用 5 个硬编码顶点画不规则多边形:
ROI 顶点数: 5
掩码: 240x240 ch=1 唯一值: 0 / 255
minMaxLoc: min=0 max=255 → 二值掩码,融合权重恒为 1.0(无法半透明)
NORMAL_CLONE flag=1 |out-dst|均值=(0.42,0.35,0.66)
MIXED_CLONE flag=2 |out-dst|均值=(0.08,0.09,0.12)
MONOCHROME_TRANSFER flag=3 |out-dst|均值=(0.49,0.36,0.63)

一个新发现 :GUI 版用 fillPoly(..., Scalar(255,255,255), ...) 生成纯二值掩码 ,所以官方 GUI 样例永远做不出半透明融合。
结合第四节的「灰度=权重」,想要渐变融合只能自己准备灰度掩码,不能靠 GUI 款完成。
550 行里,算法调用 4 行,其余 546 行全是鼠标事件状态机 + 窗口管理。
十、边界剖面:无缝到底有多"无缝"
光说"没有硬边"不够,逐像素看过渡带。过圆心(y=240)的水平线,mask 半径 110、圆心 x=320:
x=208 diff=0.0 ← 圆外,完全无改动
x=210 diff=6.0 ← 开始过渡
x=212 diff=47.0
x=214 diff=146.0 ← 完成过渡
x=220 diff=166.0 ← 融合区内部
x=428 diff=44.0 ← 右侧对称
x=430 diff=6.0
x=432 diff=0.0 ← 圆外
两个结论:
- 过渡带只有约 4 像素(210→214)------不是硬切,也不是长距离羽化;
- 圆外 diff 全部 < 1.0,和背景无法区分。
这就是「无缝」的量化定义。
顺便,这个实测还否掉了我一个源码推论:
computeDerivatives 里有 erode(binaryMask, binaryMask, Kernel, Point(-1,-1), 3)------腐蚀 3 次。我一度以为边界会内缩 3 像素。
但实测边界在 x≈210 / x≈430,半径 ≈110,与画的掩码完全一致,没有内缩。
原因:erode 收缩的是权重场 ,但泊松方程求解是全局过程,梯度会在整个 ROI 内扩散、外侧又补回一部分。
读代码推不出最终边界位置,只能实测。
十一、本日 API 速查
| API | 签名 | 用途 |
|---|---|---|
seamlessClone |
(src, dst, mask, p, out, flags) |
把 src 贴到 dst 的 p 点 |
colorChange |
(src, mask, out, red, green, blue) |
局部调 RGB 比例 |
illuminationChange |
(src, mask, out, alpha, beta) |
局部改光照 |
textureFlattening |
(src, mask, out, low_th, high_th, ksize) |
抹高频留色彩 |
参数要点:
| 参数 | 注意 |
|---|---|
mask |
必须与 src 同尺寸(否则 UB);灰度值 = 融合权重 |
p |
src 中心放到 dst 的哪个点;出画布会抛异常 |
flags |
只用官方常量,写数字不检查 |
alpha/beta |
越小越亮越平,官方值 0.2/0.4 |
low_th/high_th/ksize |
官方值 30/45/3 |
十二、踩坑速查表
| # | 坑 | 症状 | 根因 | 解法 |
|---|---|---|---|---|
| 1 | mask 全 0 | 静默返回原图 | if (roi_s.empty()) return; |
断言 countNonZero(mask)>0 |
| 2 | mask 尺寸不符 | 不报错,可能崩 | checkMask 无尺寸校验 |
断言 mask.size()==src.size() |
| 3 | flag 非法 | 不报错,掩码失效 | switch 无 default |
只用官方常量 |
| 4 | mask 贴边 | 外圈 1px 失效 | mask_inner 强制置 0 |
掩码留 ≥1px 边 |
| 5 | mask < 3×3 | 抛异常 | Rect(1,1,cols-2,...) 宽高 ≤0 |
掩码至少 3×3 |
| 6 | putText 中文 |
全 ? |
Hershey 字体只有 ASCII | 用英文 / freetype |
| 7 | 演示图看不出效果 | 统计无差异 | 输入颜色太接近 | dst 用对比色 |
| 8 | 无头环境 imshow |
Gtk/Qt 报错 | 无 DISPLAY | 只用 imwrite |
| 9 | absdiff 返回 Mat |
编译错 | 只有输出参数版 | Mat d; absdiff(a,b,d); |
十三、AI 与 LLM Wiki:这一课沉淀了什么
这一天最值得记的不是「怎么调 seamlessClone」,而是我三次猜错机制。
现象都很容易复现,但机制全部猜错了:
| 我最初的判断 | 源码/实测真相 |
|---|---|
| 掩码会被二值化(非零即 1) | 是 1.0/255.0 归一化,灰度值就是权重 |
| mask 尺寸不符时 OpenCV 会内部缩放 | checkMask 无尺寸校验 ,是越界读 UB |
| 非法 flag 会退化成 NORMAL | switch 无 default ,掩码直接失效 |
erode×3 → 边界内缩 3px |
实测边界没有内缩(泊松求解是全局过程) |
四条全部是「实测现象能对上,机制推错了」。
这印证了一件事:能复现 ≠ 懂原理 。必须回源码对一遍,而且源码结论也要实测验证------因为中间可能隔着全局过程(比如泊松求解)。
Wiki 沉淀的结构:
- API 表格 :4 个核心 API +
seamlessClone六参数逐一 + 6 个 flags 枚举值 - 源码注解 :4 段------
checkMask(50-64 行)、主流程(66-104 行)、normalClone的 switch(323-345 行)、computeDerivatives(249-264 行) - 原理讲解:6 节------为什么不能 copyTo、泊松方程的来源、FFT 求解、为什么 mask 外圈要置 0、为什么只有 p 出画布会报错、无缝效果量化
- 语法分析 :4 点------flags 为什么是 int 不是枚举类、InputArray/OutputArray 隐式转换、
Rect(1,1,cols-2,rows-2)边界含义、absdiff 无返回值重载 - 踩坑表:9 条
最有价值的一条沉淀 :「掩码灰度 = 融合权重」这个用法官方文档完全没提,但它开启了一个实用能力------半透明贴图。以后做水印、做淡化合成、做局部透明度渐变,都可以直接用灰度掩码。
下一个例程轮到 imgproc 组剩下的(cloning 之后按表继续)。
本文代码基于 OpenCV 4.14.0 官方 samples cloning_demo.cpp / cloning_gui.cpp,OpenCV 采用 Apache 2.0 许可。