Duo 主体识别,Mac 版如何做

iPhone 上的 Duo 主体分离效果惊艳,但 macOS 没有原生入口。sumimakito/Mac-Duo 用 Apple Vision 框架自建一套,从原理解析到可运行代码全程拆解。

源仓库: sumimakito/Mac-Duo

项目是什么,为什么突然火

Duo 是 Apple 在 iOS 16 引入的「主体分离」能力:照片里的人物或物品被精准抠出,背景自动虚化或替换。整套效果依赖 Vision 框架里的 Saliency 与 Instance Segmentation API,并通过 Metal 实时合成。macOS 一直没有官方入口,想在 MacBook 上给设计稿换背景,只能开虚拟机跑 iOS 模拟器。sumimakito/Mac-Duo 用 SwiftUI + Vision 直接把这条链路搬到了 macOS,过去两周 star 从 200 涨到 865,顺势登上了 GitHub Trending。

问题一:Vision 到底返回了什么

现象:调用 VNGenerateImageSaliencyRequest 后拿到一个看上去像 mask 的灰度图,但拼回去却没有任何效果。

根因:Saliency API 只返回显著性热度(saliency heatmap),并不是二值化的 segmentation mask。它告诉你「这里值得关注」,但不告诉你「前景到此为止」。

解法:使用基于注意力的 VNGenerateAttentionBasedSaliencyImageRequest,拿到带 alpha 的 CVPixelBuffer,再交给 CIBlendWithMask 做合成。

import Vision
import CoreImage

func saliencyMask(for image: CIImage) -> CIImage? {
    let request = VNGenerateAttentionBasedSaliencyImageRequest()
    let handler = VNImageRequestHandler(ciImage: image)
    try? handler.perform([request])
    guard let obs = request.results?.first else { return nil }
    return CIImage(cvPixelBuffer: obs.pixelBuffer)
}

问题二:抠图后边缘总有白边

现象:合成结果在主体轮廓外有 1-2 像素的灰白带。

根因:Saliency 的 alpha 是 0-255 全灰阶,不是硬边的二值 mask,边缘抗锯齿直接被当成了半透明背景。

解法:在喂给 CIBlendWithMask 前,对 mask 做一次轻度高斯模糊并提对比度。

let refined = mask
    .applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: 1.5])
    .applyingFilter("CIColorControls", parameters: [
        kCIInputContrastKey: 1.2,
        kCIInputBrightnessKey: 0.0
    ])

问题三:拖入视频或 Live Photo 就崩溃

现象:静态图一切正常,把视频拖进窗口直接闪退或卡死。

根因:Saliency 只接受单帧 CIImage,而视频帧需要从 AVPlayerItemVideoOutput 抽帧,buffer 生命周期与 Vision 期望不一致。

解法:在播放循环里同步抽帧,提交后立即让 Vision 内部 copy 一份,再释放抽帧 buffer。

let output = AVPlayerItemVideoOutput(pixelBufferAttributes: [
    kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
])
player.currentItem?.add(output)

func tick() {
    let t = player.currentTime()
    guard output.hasNewPixelBuffer(forItemTime: t),
          let buf = output.copyPixelBuffer(forItemTime: t,
                                           itemTimeForDisplay: nil)
    else { return }
    let frame = CIImage(cvPixelBuffer: buf)
    if let mask = saliencyMask(for: frame) {
        composite(frame, mask: mask)
    }
}

问题四:Apple Silicon 上跑得很慢

现象:M1 Pro 处理一张 4K 图要 1.8 秒,UI 明显掉帧。

根因CIContext 默认走 CPU,软渲染 4K 完全喂不满 GPU。

解法:用 MTLCreateSystemDefaultDevice 构造 Metal 后端的 CIContext,并禁用中间结果缓存。

let device = MTLCreateSystemDefaultDevice()!
let context = CIContext(mtlDevice: device, options: [
    .cacheIntermediates: false,
    .name: "MacDuoContext"
])

启用后单帧处理时间从 1.8s 降到约 90ms,4K 视频预览也能稳 30fps。

问题五:怎么把它做成一个能 git clone && open 的工程

现象:照着文档拼完代码,发现文件结构混乱、SwiftUI 和 Vision 耦合。

根因:没有把 Vision 调用放到独立 ActorService,导致主线程被 Vision 阻塞。

解法:参考 Mac-Duo 的目录结构——把 Saliency、合成、渲染拆成三个 actor,主线程只负责 SwiftUI 状态。

actor SaliencyService {
    func mask(for image: CIImage) async throws -> CIImage {
        try await withCheckedThrowingContinuation { cont in }
    }
}

@MainActor
final class ViewModel: ObservableObject {
    @Published var preview: CGImage?
    func process(_ url: URL) async {
        let img = CIImage(contentsOf: url)!
        let mask = try? await SaliencyService().mask(for: img)
        // 合成 + 渲染回主线程
    }
}

这套结构也是 Mac-Duo 在 Issue #12 之后重构的方向,star 增长曲线明显加快。

Sources

本文参考:sumimakito/Mac-Duo GitHub README 与 Issue 区、Apple Vision 官方文档、GitHub Trending 2026-09-15 榜单。