头图
本文作者:苯酚

0x01 前言

iOS 16 系统为我们带来了比较惊艳的桌面锁屏效果:Depth Effect。它可以使用一张普通图片当背景,同时可以在适当的地方遮拦住部分桌面组件,形成一种景深的效果(如下图)。

那么我们可以在自己的 App 实现类似的效果吗?一开始我以为 iOS 16 新增了新的的 UIKit 控件,可以像 UIVisualEffectView 一样几行简单的 API 就可以实现,但最后发现没有。如果给的图片是已经分好层的多张图,那么实现就是简单的将时钟控件像夹心饼干一样夹在中间即可。然而实践中发现,网上随便下载的单张图片设置为锁屏背景它也可以达到这种效果。联想到 iOS 16 的系统相册在重按后可以将照片中的主体直接分割拖拽出来,于是认为它一定是利用了某些图像分割算法将前景和背景分离开来,这样就得到了多层的图像

0x02 图像分割(Image Segmentation)

比较经典的图像分割算法是 分水岭算法(Watershed),它分割出来的图像很精准,且边缘处理非常好,但它要求人工在前景和背景的大概位置上分别画上一笔(仅一笔就好,后面算法将自动分离出前景和背景),并不适用本文全自动的要求。最近几年机器学习涌现出了不少的成果,其中之一就是全自动化的图像分割。果然在经过简单的搜索后,发现苹果已经提供预训练好的模型。

访问苹果机器学习官网 https://developer.apple.com/m... 下载训练好的模型 DeeplabV3。将模型文件拖到 Xcode 工程中,选中后可以查看它的一些信息:

image

这里其实我们主要关注模型的输入、输出就好,点击 Predictions 标签页,可以看到,模型要求输入 513x513 的图片,输出是成员类型为 Int32,大小 513x513 的二维数组,每个数值表示对应图像像素点的分类。这里的成员之所以是 Int32 而不是简单的 Bool,是因为该模型可以将图像分割为多个不同的部分,不只是前景和背景。实践中我们发现,数值为 0 可以认为是背景,非 0 值为前景。

image

下面是一张样例图片运行分割之后得到的结果:

image

它被分为了 0 和 15 两个值,分别就是背景和前景了。

0x03 实践

模型已经有了,实现方案也差不多了,接下来就是具体的实践了。

模型拖到 Xcode 工程中后,Xcode 将自动为我们生成一个类:DeepLabV3。我们可以直接创建它的实例而无需任何的 import

    lazy var model = try! DeepLabV3(configuration: {
        let config = MLModelConfiguration()
        config.allowLowPrecisionAccumulationOnGPU = true
        config.computeUnits = .cpuAndNeuralEngine
        return config
    }())

然后,用这个实例创建一个 VNCoreMLRequest,请求通过机器学习引擎来分析图片,并在回调中得到结果:

    lazy var request = VNCoreMLRequest(model: try! VNCoreMLModel(for: model.model)) { [unowned self] request, error in
        if let results = request.results as? [VNCoreMLFeatureValueObservation] {
            // 最终的分割结果在 arrayValue 中
            if let feature = results.first?.featureValue, let arrayValue = feature.multiArrayValue {
                let width = arrayValue.shape[0].intValue
                let height = arrayValue.shape[1].intValue
                let stride = arrayValue.strides[0].intValue
                // ...
            }
            
        }
    }

最后在合适的地方创建 VNImageRequestHandler 发起请求:

    private func segment() {
        if let image = self.imageView.image {
            imageSize = image.size
            DispatchQueue.global().async { [unowned self] in
                self.request.imageCropAndScaleOption = .scaleFill
                let handler = VNImageRequestHandler(cgImage: image.resize(to: .init(width: 513, height: 513)).cgImage!)
                try? handler.perform([self.request])
            }
        }
    }

注意:

  1. request 的回调和 handler 发起请求的代码在同一个线程中,同步等待结果,所以这里最好 dispatch 到子线程操作
  2. request 需要设置 imageCropAndScaleOption 为 .scallFill,否则它默认将自动裁切中间部分,将得到不符合预期的结果

输入以下样例图片,

将返回的结果 arrayValue 处理成为黑白图片后的结果:

image

发现它分割的还是挺精准的。当然,如果要在代码中当掩码图(mask)来使用,应当将它处理为背景全透明,而前景不透的图片:

image

最后,我们将原图放最下层,其它控件放中间,原图 + mask 的视图放最上层,就形成了最终的效果:

image

实际背后的原理就是夹心饼干:

再多来几张效果图:


0x04 后记

当然该模型并不是万能的,在具体的应用中还存在局限性,对于有人物的照片分割得较好,但是对于类似大场景的风景照这种可能出现完全无法分割的情况。本文的 Demo 可以在 Github 上找到。

参考资料

  1. https://developer.apple.com/d...
  2. https://www.appcoda.com.tw/vi...
  3. https://enlight.nyc/projects/...
本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!

云音乐技术团队
3.6k 声望3.5k 粉丝

网易云音乐技术团队