导语 :你有没有过这种经历------高铁上、机场里、甚至客户公司楼下,对方突然发来一张产品图,配一句「帮我抠个透明背景,着急用」。打开在线抠图网站,页面转圈 ,没网,神仙也难救。
上传云端要网,装 Python + PyTorch 要好几个 G,Photoshop 又贵又重。一气之下,我自己写了一个全离线 的 AI 图片工具箱 PicTools :模型全部本地跑,不联网、不上传、拷个文件夹就能用,抠图秒级出结果。
从这篇开始,我会把 PicTools 的功能模块一个个拆开讲。第一篇聊最刚需的抠图:模型怎么选、数据怎么喂、结果怎么变成透明底,以及手动抠图的交互细节。全程 C++ 代码 + 数学推导,看完可以直接抄作业。
文章目录
-
- 系列导航
- 一、先说痛点:为什么需要离线抠图
- 二、看看效果:两种抠图演示
-
- [2.1 AI 一键抠图](#2.1 AI 一键抠图)
- [2.2 手动多边形抠图](#2.2 手动多边形抠图)
- 三、整体架构:一张图的「传送带」之旅
-
- [3.1 一条重要的架构决策:抠图内存化](#3.1 一条重要的架构决策:抠图内存化)
- [3.2 异步执行:界面永远不冻住](#3.2 异步执行:界面永远不冻住)
- [四、AI 抠图:U2Net 从原理到落地](#四、AI 抠图:U2Net 从原理到落地)
-
- [4.1 模型选型:U 中套 U 的 U2Net](#4.1 模型选型:U 中套 U 的 U2Net)
- [4.2 预处理:给模型「备菜」](#4.2 预处理:给模型「备菜」)
- [4.3 后处理:从「概率图」到「透明图」](#4.3 后处理:从「概率图」到「透明图」)
- [4.4 软边还是硬边?一个勾选框的学问](#4.4 软边还是硬边?一个勾选框的学问)
- [4.5 复杂度分析:为什么输入图多大都不慌](#4.5 复杂度分析:为什么输入图多大都不慌)
- [4.6 Python 对照:换个语言再看一遍](#4.6 Python 对照:换个语言再看一遍)
- [五、手动抠图:AI 翻车时的兜底方案](#五、手动抠图:AI 翻车时的兜底方案)
-
- [5.1 交互设计:光标就是说明书](#5.1 交互设计:光标就是说明书)
- [5.2 命中检测:鼠标与几何的数学](#5.2 命中检测:鼠标与几何的数学)
- [5.3 从轮廓到掩码:一行调用完成栅格化](#5.3 从轮廓到掩码:一行调用完成栅格化)
- 六、批量抠图:一百张图的流水线
- 七、性能对比:三个真实的工程权衡
-
- [7.1 内存版 vs 文件版](#7.1 内存版 vs 文件版)
- [7.2 软边 vs 硬边](#7.2 软边 vs 硬边)
- [7.3 CPU vs CUDA](#7.3 CPU vs CUDA)
- 八、踩坑实录:五个差点让人头秃的坑
-
- [8.1 坑一:onnxruntime.dll 版本错乱导致 ABI 崩溃](#8.1 坑一:onnxruntime.dll 版本错乱导致 ABI 崩溃)
- [8.2 坑二:桌面残留临时文件](#8.2 坑二:桌面残留临时文件)
- [8.3 坑三:错误分类与用户提示](#8.3 坑三:错误分类与用户提示)
- [8.4 坑四:矩形手柄拖拽越界](#8.4 坑四:矩形手柄拖拽越界)
- [8.5 坑五:模式切换的橡皮筋残留](#8.5 坑五:模式切换的橡皮筋残留)
- 九、写在最后
- 参考文献
系列导航
本系列共五篇,覆盖 图片处理助手全部功能模块,建议按顺序阅读:
| 篇目 | 主题 | 核心技术 | 状态 |
|---|---|---|---|
| (一)抠图篇 | AI 智能抠图 + 手动多边形抠图 + 形状剪裁 + 批量抠图 | U2Net / ONNX Runtime / 遮罩交互设计 | ✅ 本文 |
| (二)消除篇 | AI 修复与瑕疵消除 | MiGan 分块推理(tile-based) | 🔜 即将发布 |
| (三)涂鸦工坊篇 | 人脸分割 → 风格化涂鸦 → 贴纸画布 + 泊松融合 | BiSeNet / cv::seamlessClone | 🔜 即将发布 |
| (四)压缩与格式工具篇 | 图片压缩 / 格式转换 / 尺寸调整 | QImageWriter / CompressDialog | 🔜 即将发布 |
| (五)工程化篇 | 整体架构、错误分类体系、撤销栈、样式系统 | Qt 工程实践 | 🔜 即将发布 |
一、先说痛点:为什么需要离线抠图
抠图是图片处理里最高频的需求,但现成的方案各有各的难受:
| 方案 | 痛点 |
|---|---|
| 在线抠图网站 | 图片要上传到第三方服务器,证件照、涉密文件、内网办公的场景根本不敢用 |
| rembg / Python 生态 | 得装 Python + PyTorch,体积几个 G,普通用户看了安装教程就跑了 |
| Photoshop | 强大但贵,普通人只会用魔棒戳两下,批量处理更无从谈起 |
| 桌面端云抠图 | AI 功能必须联网,出差、内网、弱网环境直接瘫痪 |
所以图片处理助手的定位就一句话:AI 全部本地跑,不上传、不断网。目标人群是政企内网、隐私敏感、经常出差没网的人。整个工具箱的技术栈只有三层,第三方依赖就两个动态库,真正做到「拷一个文件夹就能跑」:
| 层 | 技术 |
|---|---|
| GUI | Qt 5.12 Widgets(QMainWindow / QGraphicsView / QStackedWidget) |
| 图像处理 | OpenCV 4.12(滤波 / 形态学 / 色彩空间 / 距离变换) |
| AI 推理 | ONNX Runtime 1.24.4(U2Net / MiGan / BiSeNet) |
抠图模块一共四种玩法,从「全自动」到「全手动」都覆盖:
| 模式 | 实现方式 | 适合什么场景 |
|---|---|---|
| AI 智能抠图 | U2Net 显著性检测(168 MB ONNX 模型) | 主体突出、背景杂乱,一键出图 |
| 手动抠图 | 多边形 / 矩形遮罩(顶点编辑、边线插点、手柄缩放) | AI 抠不干净的边缘,人工精修 |
| 形状剪裁 | QPainterPath 圆形 / 圆角矩形 / 矩形 | 头像裁圆、装饰性裁切 |
| 批量抠图 | QtConcurrent 并发 | 上百张产品图自动化处理 |
二、看看效果:两种抠图演示
2.1 AI 一键抠图
点一下「AI 抠图」,等一两秒(320×320 输入的 CPU 推理,具体耗时看 CPU 型号),一张带透明背景的 PNG 就出来了:

注意上图结果的边缘半透明渐变------这就是软边 alpha。发丝、衣服毛边这类细节,靠的就是这个渐变过渡;如果边缘是硬邦邦的一条线,抠出来的图贴到任何背景上都会显得假。
2.2 手动多边形抠图
AI 也会翻车:主体不明显、只想抠画面里某个局部、边缘不满意。这时候切到手动模式,右键逐点圈轮廓、拖顶点微调、边线上插点,想抠哪抠哪:

一个细节:所有顶点/边线的命中阈值都按屏幕空间换算,无论你把图放大到 10 倍还是缩到 0.1 倍,鼠标的「手感」完全一致------这一点后面 5.2 节会讲透。
三、整体架构:一张图的「传送带」之旅
抠图这件事,本质上是给图片加一条 alpha 通道 ------告诉每个像素「你要不要透明、透明多少」。智能抠图和手动抠图的区别,只在于这条通道从哪来:AI 算出来的,还是你画出来的。
#mermaid-svg-q2bnSmrWf4l5rJFs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-q2bnSmrWf4l5rJFs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-q2bnSmrWf4l5rJFs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-q2bnSmrWf4l5rJFs .error-icon{fill:#552222;}#mermaid-svg-q2bnSmrWf4l5rJFs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-q2bnSmrWf4l5rJFs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-q2bnSmrWf4l5rJFs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-q2bnSmrWf4l5rJFs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-q2bnSmrWf4l5rJFs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-q2bnSmrWf4l5rJFs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-q2bnSmrWf4l5rJFs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-q2bnSmrWf4l5rJFs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-q2bnSmrWf4l5rJFs .marker.cross{stroke:#333333;}#mermaid-svg-q2bnSmrWf4l5rJFs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-q2bnSmrWf4l5rJFs p{margin:0;}#mermaid-svg-q2bnSmrWf4l5rJFs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-q2bnSmrWf4l5rJFs .cluster-label text{fill:#333;}#mermaid-svg-q2bnSmrWf4l5rJFs .cluster-label span{color:#333;}#mermaid-svg-q2bnSmrWf4l5rJFs .cluster-label span p{background-color:transparent;}#mermaid-svg-q2bnSmrWf4l5rJFs .label text,#mermaid-svg-q2bnSmrWf4l5rJFs span{fill:#333;color:#333;}#mermaid-svg-q2bnSmrWf4l5rJFs .node rect,#mermaid-svg-q2bnSmrWf4l5rJFs .node circle,#mermaid-svg-q2bnSmrWf4l5rJFs .node ellipse,#mermaid-svg-q2bnSmrWf4l5rJFs .node polygon,#mermaid-svg-q2bnSmrWf4l5rJFs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-q2bnSmrWf4l5rJFs .rough-node .label text,#mermaid-svg-q2bnSmrWf4l5rJFs .node .label text,#mermaid-svg-q2bnSmrWf4l5rJFs .image-shape .label,#mermaid-svg-q2bnSmrWf4l5rJFs .icon-shape .label{text-anchor:middle;}#mermaid-svg-q2bnSmrWf4l5rJFs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-q2bnSmrWf4l5rJFs .rough-node .label,#mermaid-svg-q2bnSmrWf4l5rJFs .node .label,#mermaid-svg-q2bnSmrWf4l5rJFs .image-shape .label,#mermaid-svg-q2bnSmrWf4l5rJFs .icon-shape .label{text-align:center;}#mermaid-svg-q2bnSmrWf4l5rJFs .node.clickable{cursor:pointer;}#mermaid-svg-q2bnSmrWf4l5rJFs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-q2bnSmrWf4l5rJFs .arrowheadPath{fill:#333333;}#mermaid-svg-q2bnSmrWf4l5rJFs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-q2bnSmrWf4l5rJFs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-q2bnSmrWf4l5rJFs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-q2bnSmrWf4l5rJFs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-q2bnSmrWf4l5rJFs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-q2bnSmrWf4l5rJFs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-q2bnSmrWf4l5rJFs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-q2bnSmrWf4l5rJFs .cluster text{fill:#333;}#mermaid-svg-q2bnSmrWf4l5rJFs .cluster span{color:#333;}#mermaid-svg-q2bnSmrWf4l5rJFs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-q2bnSmrWf4l5rJFs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-q2bnSmrWf4l5rJFs rect.text{fill:none;stroke-width:0;}#mermaid-svg-q2bnSmrWf4l5rJFs .icon-shape,#mermaid-svg-q2bnSmrWf4l5rJFs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-q2bnSmrWf4l5rJFs .icon-shape p,#mermaid-svg-q2bnSmrWf4l5rJFs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-q2bnSmrWf4l5rJFs .icon-shape .label rect,#mermaid-svg-q2bnSmrWf4l5rJFs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-q2bnSmrWf4l5rJFs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-q2bnSmrWf4l5rJFs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-q2bnSmrWf4l5rJFs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 推理层
服务层
UI层
EditorCutoutPanel
抠图面板
EditorHubWindow
编辑工作台
DefectMaskLabel
遮罩绘制控件
createCutout
纯内存管线
maskToTransparentCutout
遮罩合成
runCutoutInMemory
U2NetOnnxInfer
preprocess / infer / postprocess
u2net_320_main.onnx
3.1 一条重要的架构决策:抠图内存化
早期版本走过弯路:先把图存成临时 PNG,推理完再存一张,最后读回来。一张图要 4 次磁盘读写 ,桌面上还残留一堆 cutout_input_*.png、cutout_output_*.png 垃圾文件。后来主流程改造成纯内存管线,像传送带一样,图片从头走到尾不落地:
QImage → qImageToBgraMat() → runCutoutInMemory()
→ ONNX 推理 → composeTransparentCutout() → QImage
零磁盘 I/O,零临时文件 。旧的 runCutout() 文件版保留下来,专门给批量抠图用------批量场景本来就是「文件进、文件出」,不需要内存往返。
3.2 异步执行:界面永远不冻住
推理哪怕只要 1 秒,同步写也会把界面冻住 1 秒,用户的感觉就是「卡了」。所以推理丢进线程池,主线程立刻返回:
cpp
// EditorHubWindow::onCutoutRequested(bool binaryCutout) 关键片段
void EditorHubWindow::onCutoutRequested(bool binaryCutout)
{
if (m_busy || !m_workspace.hasImage()) return; // 忙状态互斥
setBusy(true);
showStatus(QStringLiteral("正在抠图..."));
const QImage img = m_workspace.currentImage; // 按值捕获,跨线程安全
const QString modelPath = u2netModelPath();
// 推理丢进全局线程池,主线程立即返回,UI 保持响应
m_processWatcher.setFuture(QtConcurrent::run([img, modelPath, binaryCutout]()
{
AsyncProcessResult r;
auto cr = EditorImageServices::createCutout(img, modelPath, workDir, binaryCutout);
r.success = cr.success; r.image = cr.image; r.errorMessage = cr.errorMessage;
return r;
}));
}
两个细节值得抄:QImage 按值捕获 (Qt 隐式共享,拷贝零成本,还避免跨线程引用悬空);m_busy 互斥(推理期间禁止重复触发,防止线程池堆积)。
四、AI 抠图:U2Net 从原理到落地
4.1 模型选型:U 中套 U 的 U2Net
AI 抠图的第一步,是让模型判断「这张图谁是主角」。这活儿在学术界叫显著性目标检测(Salient Object Detection):输出一张灰度图,像素越亮,代表越「该留下」。
U²-Net 是 2020 年这个领域的代表作,rembg 就是拿它当底层模型。名字可以理解为「U 形网络里套 U 形网络」:每个 RSU(ReSidual U-block)块内部是一个小 U-Net,负责抓多尺度上下文;多个 RSU 块再拼成一个大的 U 形,还在多个深度层做监督,训练更稳。
工程上选它,理由很实际:320×320 的小输入就能拿到质量不错的显著图 。不管用户丢进来的是 800 万像素的相机原片还是 10 万像素的截图,模型只认 320------推理成本固定、可预期。图片处理助手用的是导出的 u2net_320_main.onnx(约 168 MB,fp32 权重)。
4.2 预处理:给模型「备菜」
模型是个挑食的家伙:要 NCHW 布局 、要 RGB 顺序 、还要按 ImageNet 的均值和方差调过味。少一步,出来的显著图就偏色、发虚:
cpp
PreprocessResult U2NetOnnxInfer::preprocess(const cv::Mat& image)
{
// 第1步:统一为三通道 BGR(兼容灰度/带 alpha 的输入)
const cv::Mat bgr = ensureThreeChannelsBgr(image);
// 第2步:BGR → RGB(模型在 RGB 域训练)
cv::Mat rgb;
cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB);
// 第3步:双线性缩放到固定输入 320×320
cv::Mat resized;
cv::resize(rgb, resized, cv::Size(kU2NetInputWidth, kU2NetInputHeight),
0.0, 0.0, cv::INTER_LINEAR);
// 第4步:转 float 并归一化到 [0,1]
cv::Mat floatImage;
resized.convertTo(floatImage, CV_32FC3);
double maxValue = 0.0;
cv::minMaxLoc(floatImage.reshape(1), nullptr, &maxValue);
if (maxValue <= 0.0) maxValue = 1.0; // 防全黑图除零
floatImage /= static_cast<float>(maxValue);
// 第5步:逐通道做 ImageNet 均值/方差归一化
std::vector<cv::Mat> channels;
cv::split(floatImage, channels);
channels[0] = (channels[0] - 0.485f) / 0.229f; // R 通道
channels[1] = (channels[1] - 0.456f) / 0.224f; // G 通道
channels[2] = (channels[2] - 0.406f) / 0.225f; // B 通道
// NCHW 布局:三个平面依次拷贝进一维缓冲区
result.inputTensor.resize(3 * kU2NetInputHeight * kU2NetInputWidth);
const size_t planeSize = kU2NetInputHeight * kU2NetInputWidth;
for (size_t c = 0; c < 3; ++c)
std::memcpy(result.inputTensor.data() + c * planeSize,
channels[c].data, planeSize * sizeof(float));
return result;
}
归一化的数学表达( c ∈ { R , G , B } c \in \{R,G,B\} c∈{R,G,B}):
x ^ c = x c / 255 − μ c σ c , ( μ R , μ G , μ B ) = ( 0.485 , 0.456 , 0.406 ) , ( σ R , σ G , σ B ) = ( 0.229 , 0.224 , 0.225 ) \hat{x}_c = \frac{x_c / 255 - \mu_c}{\sigma_c}, \qquad (\mu_R, \mu_G, \mu_B) = (0.485, 0.456, 0.406), \quad (\sigma_R, \sigma_G, \sigma_B) = (0.229, 0.224, 0.225) x^c=σcxc/255−μc,(μR,μG,μB)=(0.485,0.456,0.406),(σR,σG,σB)=(0.229,0.224,0.225)
最容易翻车的一行:OpenCV 默认 BGR,PyTorch 模型默认 RGB 。漏掉 cvtColor,模型照样跑、照样出图,但显著图质量肉眼可见地掉一档------这种 bug 不报错,最难查。
4.3 后处理:从「概率图」到「透明图」
模型吐出来的不是图,而是一张 1 × 1 × 320 × 320 1 \times 1 \times 320 \times 320 1×1×320×320 的灰度图,数值 0~1,代表每个像素「是前景的概率」。要变成能用的 alpha 通道,还差三步:拉对比度 → 放大回原分辨率 → 转 8 位:
cpp
cv::Mat U2NetOnnxInfer::postprocess(const InferenceOutput& output,
int targetWidth, int targetHeight)
{
// 校验输出形状必须是 1×1×H×W
if (output.shape.size() != 4 || output.shape[0] != 1 || output.shape[1] != 1)
throw std::runtime_error("Unexpected output shape.");
cv::Mat mask(height, width, CV_32FC1, const_cast<float*>(output.data.data()));
cv::Mat normalized = mask.clone();
// min-max 归一化:把显著图拉伸到 [0,1],边缘过渡更锐利
double minValue = 0.0, maxValue = 0.0;
cv::minMaxLoc(normalized, &minValue, &maxValue);
if (maxValue > minValue)
normalized = (normalized - minValue) / (maxValue - minValue);
else
normalized = cv::Mat::zeros(normalized.size(), normalized.type()); // 退化保护
// 双线性放大回原始分辨率,保证 alpha 与 RGB 逐像素对齐
cv::Mat resized;
cv::resize(normalized, resized, cv::Size(targetWidth, targetHeight),
0.0, 0.0, cv::INTER_LINEAR);
// float [0,1] → 8bit [0,255] 的 alpha 通道
cv::Mat outputMask;
resized.convertTo(outputMask, CV_8UC1, 255.0);
return outputMask;
}
α ′ = α − α min α max − α min × 255 \alpha' = \frac{\alpha - \alpha_{\min}}{\alpha_{\max} - \alpha_{\min}} \times 255 α′=αmax−αminα−αmin×255
min-max 这一步是点睛之笔:模型原始输出常常整体偏灰(最大值可能只有 0.7 左右),直接当 alpha 用,前景会半透明、像蒙了层纱;拉伸对比度之后,该实的地方实、该透的地方透,边缘还保留灰度渐变------软边 alpha 就是这么来的。
4.4 软边还是硬边?一个勾选框的学问
面板上的「二值化」勾选框,控制最终 alpha 的形态:
cpp
// 软边(默认):alpha 保留 0~255 全范围,边缘半透明渐变
// 硬边:阈值 128 二值化,边缘锐利,适合平面设计排版
cv::Mat applyBinaryMask(const cv::Mat& mask)
{
cv::Mat binaryMask;
cv::threshold(mask, binaryMask, 128.0, 255.0, cv::THRESH_BINARY);
return binaryMask;
}
一句话区别:软边像毛笔描的边,硬边像剪刀剪的边。照片合成选软边,发丝、绒毛过渡自然;产品图、图标素材选硬边,边缘干净利落,方便二次排版。
4.5 复杂度分析:为什么输入图多大都不慌
整条管线的计算量可以精确拆解(原图分辨率 H × W H \times W H×W):
| 阶段 | 操作 | 复杂度 |
|---|---|---|
| 预处理缩放 | 双线性插值 | O ( H W ) O(HW) O(HW) |
| 归一化 + NCHW 拷贝 | 逐像素 | O ( 320 2 ) O(320^2) O(3202) |
| U2Net 推理 | 多层卷积 | O ( ∑ l C i n l ⋅ C o u t l ⋅ K 2 ⋅ H l W l ) O\left(\sum_l C_{in}^{l} \cdot C_{out}^{l} \cdot K^2 \cdot H_l W_l\right) O(∑lCinl⋅Coutl⋅K2⋅HlWl) |
| 后处理缩放 | 双线性插值 | O ( H W ) O(HW) O(HW) |
| alpha 合成 | 通道合并 | O ( H W ) O(HW) O(HW) |
结论一句话:推理耗时和输入图片分辨率无关------模型只认 320×320,800 万像素的原片和 10 万像素的截图,推理时间基本一样。总复杂度:
T ( H , W ) = O ( H W ) + O ( ∑ l C i n l C o u t l K 2 H l W l ) T(H, W) = O(HW) + O\left(\sum_l C_{in}^{l} C_{out}^{l} K^2 H_l W_l\right) T(H,W)=O(HW)+O(l∑CinlCoutlK2HlWl)
真正随图变大的是缩放那两步 O ( H W ) O(HW) O(HW),但和卷积项比是小头,超大图才会逐渐显形。
4.6 Python 对照:换个语言再看一遍
同一套预处理用 Python + NumPy 写,十行搞定(rembg 的底层也是这套),对照着看更容易吃透 C++ 版:
python
import cv2
import numpy as np
MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
def preprocess(image_bgr):
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
resized = cv2.resize(rgb, (320, 320), interpolation=cv2.INTER_LINEAR)
x = resized.astype(np.float32) / 255.0 # 归一化到 [0,1]
x = (x - MEAN) / STD # ImageNet 标准化
return np.transpose(x, (2, 0, 1))[None, ...] # HWC → NCHW
五、手动抠图:AI 翻车时的兜底方案
AI 不是万能的:主体不明显、只想抠局部、边缘不满意,这三种情况都得人工介入。手动抠图的核心是一个自绘控件 DefectMaskLabel(继承 QLabel),支持多边形 和矩形两种绘制模式。
5.1 交互设计:光标就是说明书
| 操作 | 多边形模式 | 矩形模式 |
|---|---|---|
| 右键空白 | 添加轮廓点 | 拖拽绘制新矩形 |
| 右键首点 | 闭合多边形 | --- |
| 右键拖拽顶点 | 调整顶点位置 | 角手柄:约束缩放(对边不动,最小 5px) |
| 右键边线 | 插入新顶点(投影到边) | 边中点手柄:单向缩放 |
| 右键内部 | --- | 平移矩形 |
| 悬停顶点 | 橙色光环 + SizeAllCursor | 对应 resize 光标 |
| 悬停边线 | 橙色高亮 + CrossCursor | --- |
| Delete / Backspace | 删除顶点(不足 3 点则删整个选区) | 删除矩形 |
| Ctrl+Z | 绘制中撤销上一个点 | --- |
| 左键拖拽 | 平移画布 | 平移画布 |
| Ctrl+滚轮 | 缩放 0.1×--10× | 缩放 0.1×--10× |
一个容易被忽略的细节:光标就是说明书 。悬停顶点显示 SizeAllCursor(可拖动)、绘制中悬停首点显示 PointingHandCursor(可闭合)、悬停边线显示 CrossCursor(可插点)------用户不用记任何快捷键,鼠标移到哪,光标就告诉他能干什么。
5.2 命中检测:鼠标与几何的数学
顶点/边线的命中阈值必须和缩放级别解耦:否则放大后顶点「变小」,缩小后边线「变粗」,手感全变。做法是屏幕空间恒定阈值除以显示缩放比:
t i m g = t p x s , s = W d r a w W i m g t_{img} = \frac{t_{px}}{s}, \qquad s = \frac{W_{draw}}{W_{img}} timg=stpx,s=WimgWdraw
t p x t_{px} tpx 是屏幕像素阈值(顶点 8px、边线 6px、矩形手柄 10px), s s s 是当前显示缩放比。对应代码:
cpp
// 顶点命中阈值:屏幕空间 8px 折算到图像坐标
const double displayScale = m_image.width() <= 0 ? 1.0
: static_cast<double>(drawRect.width()) / m_image.width();
const double vertexThreshold = 8.0 / qMax(displayScale, 0.001);
点到线段的最短距离用投影法, O ( 1 ) O(1) O(1) 一次搞定:
t = c l a m p ( ( p − a ) ⋅ ( b − a ) ∥ b − a ∥ 2 , 0 , 1 ) , d = ∥ p − ( a + t ( b − a ) ) ∥ t = \mathrm{clamp}\left(\frac{(\mathbf{p}-\mathbf{a})\cdot(\mathbf{b}-\mathbf{a})}{\|\mathbf{b}-\mathbf{a}\|^2},\ 0,\ 1\right), \qquad d = \left\|\mathbf{p} - (\mathbf{a} + t(\mathbf{b}-\mathbf{a}))\right\| t=clamp(∥b−a∥2(p−a)⋅(b−a), 0, 1),d=∥p−(a+t(b−a))∥
cpp
// 点到线段的最短距离:先投影求参数 t,再求垂足距离
double DefectMaskLabel::pointToSegmentDistance(const QPointF& p,
const QPointF& a,
const QPointF& b) const
{
const QPointF ab = b - a;
const double abLenSq = ab.x() * ab.x() + ab.y() * ab.y();
if (abLenSq < 1e-9) // 退化线段(a≈b),退化为点距
return QLineF(p, a).length();
const QPointF ap = p - a;
// 投影参数 t 钳制到 [0,1],保证垂足落在线段上
const double t = qBound(0.0, (ap.x() * ab.x() + ap.y() * ab.y()) / abLenSq, 1.0);
const QPointF proj = a + t * ab;
return QLineF(p, proj).length();
}
鼠标移动时逐个区域、逐个顶点、逐条边线性扫描,总复杂度 O ( V + E ) O(V + E) O(V+E)( V V V 顶点数、 E E E 边数)。手画的多边形顶点几十个封顶,离 16ms 的帧预算差着好几个数量级,放心扫。
5.3 从轮廓到掩码:一行调用完成栅格化
点「生成」时,把画好的轮廓栅格化成和原图等大的 8 位掩码。cv::fillPoly 一行搞定(内部是扫描线填充,复杂度 O ( A ) O(A) O(A), A A A 为轮廓包围盒面积):
cpp
cv::Mat DefectMaskLabel::maskMat() const
{
cv::Mat mask(m_image.height(), m_image.width(), CV_8UC1, cv::Scalar(0));
std::vector<std::vector<cv::Point>> contours;
// 只收集已闭合且点数 ≥ 3 的区域
for (const DefectContour& region : m_regions)
{
if (region.drawing || region.points.size() < 3) continue;
std::vector<cv::Point> contour;
contour.reserve(region.points.size());
for (const QPointF& point : region.points)
contour.emplace_back(static_cast<int>(point.x()),
static_cast<int>(point.y()));
contours.push_back(contour);
}
if (!contours.empty())
cv::fillPoly(mask, contours, cv::Scalar(255)); // 多轮廓一次性填充
return mask;
}
最后把掩码合成进 alpha 通道(尺寸不一致时自动双线性对齐):
cpp
// 掩码 → 透明抠图:RGB 三通道 + mask 作为 alpha 通道
cv::Mat composeTransparentCutout(const cv::Mat& image, const cv::Mat& mask)
{
cv::Mat bgr = image;
if (image.channels() == 4) cv::cvtColor(image, bgr, cv::COLOR_BGRA2BGR);
else if (image.channels() == 1) cv::cvtColor(image, bgr, cv::COLOR_GRAY2BGR);
cv::Mat alpha;
if (mask.size() != bgr.size())
cv::resize(mask, alpha, bgr.size(), 0.0, 0.0, cv::INTER_LINEAR);
else
alpha = mask;
std::vector<cv::Mat> channels;
cv::split(bgr, channels);
channels.push_back(alpha); // BGR + A → BGRA
cv::Mat bgra;
cv::merge(channels, bgra);
return bgra;
}
六、批量抠图:一百张图的流水线
批量场景是「文件进、文件出」,内存版反而多余,所以走文件版 runCutout():
cpp
// EditorHubWindow 批量抠图关键片段
m_cutoutBatchWatcher.setFuture(QtConcurrent::run(
[imagePaths, modelPath, binaryCutout, batchOutputDir, this]()
{
// 逐张执行文件版抠图:输入路径 → 推理 → 输出路径
for (const QString& path : imagePaths)
{
if (cancelRequested) break; // 支持中途取消
auto r = U2NetProcessRunner::runCutout(modelPath, path,
outputFilePath(path, batchOutputDir),
QStringLiteral("cpu"), binaryCutout);
...
}
}));
两个取舍说明一下:批量用文件版 ,是避免每张结果都驻留内存(一百张原图级 PNG 叠起来内存就爆了);模型 Session 不跨图复用,是拿一点初始化开销换可靠性,避免长任务里的状态耦合。
七、性能对比:三个真实的工程权衡
7.1 内存版 vs 文件版
| 维度 | 内存版(交互抠图) | 文件版(批量抠图) |
|---|---|---|
| 临时文件数 | 0 | 每张 2 个(输入/输出) |
| 磁盘 I/O | 0 次 | 每张 4 次(2 写 2 读) |
| 桌面残留 | 无 | 早期版本曾残留 cutout_input_*.png |
| 内存占用 | 结果驻留内存(可撤销) | 逐张释放 |
7.2 软边 vs 硬边
| 维度 | 软边 alpha | 硬边 alpha |
|---|---|---|
| 边缘形态 | 0~255 渐变,半透明过渡 | 阈值 128 二值,锐利边缘 |
| 适合场景 | 照片合成、发丝/绒毛 | 产品图、图标、排版素材 |
| 实现成本 | min-max 归一化后直接转 8bit | 多一步 threshold(), O ( H W ) O(HW) O(HW) |
7.3 CPU vs CUDA
推理层预留了 CUDA Execution Provider(OrtCUDAProviderOptions,device 0),解析 "cuda" 参数即可切换;但发布包默认只带 CPU 版运行时(onnxruntime.dll),内存版管线固定 CPU。这个取舍是刻意的:320×320 输入下 CPU 已经秒级出图,而 CUDA 要额外附带几百 MB 的 provider DLL,还得要求用户装显卡驱动------对「拷文件夹就能跑」的离线工具来说,得不偿失。
八、踩坑实录:五个差点让人头秃的坑
8.1 坑一:onnxruntime.dll 版本错乱导致 ABI 崩溃
现象:部分用户机器上一启动就崩,崩点在 ONNX Runtime 初始化。
原因 :Windows 的 DLL 搜索顺序里,系统目录 System32 优先级很高。如果 exe 同目录没有 onnxruntime.dll,会回退加载 System32 里的旧版,和 1.24.4 的 ABI 对不上,直接崩。
解决 :PostBuildEvent 自动把 onnxruntime.dll 复制到 exe 输出目录,保证永远同目录加载。
8.2 坑二:桌面残留临时文件
现象 :每次抠图,桌面多出 cutout_input_*.png / cutout_output_*.png 垃圾文件。
原因:早期文件版管线把中间 PNG 写到了工作目录(默认桌面)。
解决:主流程全面内存化(见 3.1 节),临时文件从 2 个直接归零。
8.3 坑三:错误分类与用户提示
推理失败的原因五花八门:模型缺失、DLL 缺失、图片损坏......统一提示「推理失败」等于没提示,用户只能干瞪眼。于是建立了一套错误分类体系 AppErrorKind,并对错误文本做启发式归类:
cpp
// 根据错误文本做启发式分类:DLL/模块类错误单独归类
AppErrorKind inferInitErrorKind(const QString& detail)
{
if (detail.contains(QStringLiteral("dll"), Qt::CaseInsensitive)
|| detail.contains(QStringLiteral("LoadLibrary"), Qt::CaseInsensitive)
|| detail.contains(QStringLiteral("module"), Qt::CaseInsensitive)
|| detail.contains(QStringLiteral("找不到指定的模块")))
{
return AppErrorKind::RuntimeDependencyMissing; // → 提示用户重装运行库
}
return AppErrorKind::InferenceFailed; // → 提示用户检查模型
}
8.4 坑四:矩形手柄拖拽越界
现象:手柄拖出图像边界后,矩形出现负坐标,栅格化直接报错。
解决 :所有手柄变换统一走「约束缩放 + 边界钳制」------角/边手柄保持对边不动且最小 5px,平移手柄 qBound 钳到图像范围,变换结束再全量钳一遍:
cpp
// 矩形变换后的统一边界钳制(截取自 applyRectTransform)
const double maxX = static_cast<double>(qMax(0, m_image.width() - 1));
const double maxY = static_cast<double>(qMax(0, m_image.height() - 1));
nx1 = qBound(0.0, nx1, maxX);
ny1 = qBound(0.0, ny1, maxY);
nx2 = qBound(0.0, nx2, maxX);
ny2 = qBound(0.0, ny2, maxY);
8.5 坑五:模式切换的橡皮筋残留
现象:多边形画到一半切到矩形模式再切回来,半成品多边形的橡皮筋预览「跳」了一下。
解决 :setDrawMode() 切换时主动丢弃未完成的多边形(drawing == true 的区域不入库),未闭合轮廓不在模式间串场。
九、写在最后
回头看,抠图模块的核心就五件事:
- 离线优先:168 MB 模型 + 两个依赖 DLL 即可交付,内网、飞机上、涉密环境都能用;
- 内存化管线 :
QImage → cv::Mat → ONNX → mask → QImage全程不落地,临时文件从根源消灭; - 软边 alpha:min-max 拉伸对比度,边缘半透明渐变,贴到任何背景都不违和;
- 交互细节:屏幕空间恒定阈值让手感与缩放无关,光标即说明书;
- 错误分类:DLL 缺失、模型缺失、输入损坏分门别类提示,用户能自救。
后续展望:换更高精度的抠图模型(如 RMBG-2.0 系列);软边 alpha 加参数化的边缘羽毛化(feather);再和泊松融合(Poisson Blending)打通,凑成「抠图 → 合成」一站式工作流。
能力所限,如有错漏欢迎评论区指正。
参考文献
1 Qin X, Zhang Z, Huang C, et al. U²-Net: Going Deeper with Nested U-Structure for Salient Object DetectionJ. Pattern Recognition, 2020, 106: 107404. arXiv:2005.09007
2 U-2-Net 官方 PyTorch 实现: https://github.com/xuebinqin/U-2-Net
3 ONNX Runtime 官方文档: https://onnxruntime.ai/docs/
4 OpenCV 4.x 官方文档: https://docs.opencv.org/4.x/
5 Qt 5 官方文档: https://doc.qt.io/qt-5/
6 rembg(U2Net 抠图在 Python 生态的代表实现): https://github.com/danielgatis/rembg