图片处理助手|C++ 手搓离线 AI 抠图工具,U2Net 原理到落地一次讲透

导语 :你有没有过这种经历------高铁上、机场里、甚至客户公司楼下,对方突然发来一张产品图,配一句「帮我抠个透明背景,着急用」。打开在线抠图网站,页面转圈 ,没网,神仙也难救。

上传云端要网,装 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_*.pngcutout_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 的区域不入库),未闭合轮廓不在模式间串场。


九、写在最后

回头看,抠图模块的核心就五件事:

  1. 离线优先:168 MB 模型 + 两个依赖 DLL 即可交付,内网、飞机上、涉密环境都能用;
  2. 内存化管线 :QImage → cv::Mat → ONNX → mask → QImage 全程不落地,临时文件从根源消灭;
  3. 软边 alpha:min-max 拉伸对比度,边缘半透明渐变,贴到任何背景都不违和;
  4. 交互细节:屏幕空间恒定阈值让手感与缩放无关,光标即说明书;
  5. 错误分类: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

相关推荐
三言老师1 小时前
K8s集群运行时异常趋势分析预警实操
java·开发语言·kubernetes
前沿在线1 小时前
视频模型卷向第二个维度,生数科技 Vidu S1 打响第一枪
人工智能·ai·大模型
Kapaseker1 小时前
没想到吧!Skill 也可以测试 — 小白都看得懂的 Skill 教程
android·人工智能·kotlin
Sammyyyyy1 小时前
AI Gateway 与直连 LLM API 的应该怎么选,一篇文章说明白
人工智能·ai·gateway·ai编程·ai-native·servbay
小小龙学IT1 小时前
RTF提示词框架详解:3步写出让AI精准执行的高质量指令
人工智能
玖釉-1 小时前
nvpro_core2 源码与架构解析:NVIDIA Vulkan 图形开发基础框架
c++·windows·图形渲染
武子康1 小时前
Project Trust 不是 Sandbox:Pi Agent 的安全边界怎样补齐
人工智能·llm·agent
Zaimmm1 小时前
临床文献智能检索哪家强?2026年主流AI循证平台深度测评与推荐
大数据·人工智能·microsoft
fthux1 小时前
边缘计算:从概念到实践的全景解读
人工智能·边缘计算