OpenCV Day39 —— 无缝克隆 seamless cloning(cloning_demo / cloning_gui)

效果先行:三种克隆模式并排

左起:目标背景 → 克隆落点(红圈)→ 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     ← 圆外

两个结论:

  1. 过渡带只有约 4 像素(210→214)------不是硬切,也不是长距离羽化;
  2. 圆外 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 许可。

相关推荐
滼1 小时前
AI 数据安全面试应答框架:从大框架到小点
大数据·人工智能·安全·面试·安全架构
科技云报道1 小时前
科技云报到:AI没有杀死软件,只是重写了它的“收银台“
人工智能·科技
武子康1 小时前
同一台 ESP32,GSM 能消回声,VoIP 为什么不行?
人工智能·后端·agent
微尘hjx1 小时前
【数据集 11】面向城市道路的坑洞检测数据集(含8760张,已按比例划分训练、验证、测试集,支持YOLO全系列训练)
人工智能·yolo·目标检测·数据集·道路坑洞·路坑数据集·路坑
涛声依旧-底层原理研究所1 小时前
AI Agent 运行逻辑深度解析:从感知层到工具调用,再到真实产物
人工智能·架构
云和数据.ChenGuang1 小时前
再论深拷贝和浅拷贝
开发语言·人工智能·python·1024程序员节
七夜zippoe1 小时前
LangChain4j vs Spring AI:Java AI 框架怎么选(对比表)
java·人工智能·spring·ai·langchain4j
逗溜网数智科技1 小时前
逗溜网数智科技:深耕AI应用服务,六大产品矩阵赋能产业数字化转型
人工智能·科技
第九序言T2 小时前
Python 数据处理进阶(二十一)相关性与分组对比:数据里藏着什么关系
大数据·人工智能·python·数据分析·pandas