广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何在iOS端用Core Image实现暗通道先验去雾与透射率优化?

图像去雾的工程实现中,最耗时的步骤往往不是大气光估计,而是暗通道的最小值滤波和透射率图的引导滤波。Core Image虽然提供了CIMorphologyMinimum,但它的形态学核尺寸很难覆盖15×15以上的窗口,直接用它做暗通道会把去雾半径压得很小,导致远景雾残留或近景色彩发灰。因此,在iOS端落地暗通道先验算法时,需要把Core Image的链式处理能力与Metal或vImage的区域统计能力结合起来。

如何在iOS端用Core Image实现暗通道先验去雾与透射率优化?

暗通道先验与大气光估计

雾天图像通常可以用大气散射模型描述,即I(x)=J(x)t(x)+A(1-t(x))。其中I是有雾图像,J是待恢复的清晰场景,A是全局大气光,t是透射率。暗通道先验的核心观察是:在大多数没有天空的清晰图像块中,至少有一个颜色通道的局部最小值非常接近于零。暗通道定义为:J_dark(x)=min_{y∈Ω(x)}(min_{c∈{r,g,b}} J_c(y))。对有雾图像求暗通道后,其值会被大气光抬升,因此暗通道可以作为雾浓度的估计依据。

估计大气光A时,通常先取暗通道图中亮度最高的前0.1%像素,再回到原始图像中找这些像素位置对应的最大强度。这个值比直接取全图最亮像素更稳定,因为白色物体或高光区域不一定代表真实大气光。工程上不建议在原始分辨率上做CPU回读,尤其视频流。可以先输出暗通道小图,缩小到四分之一甚至八分之一,再用CIContext渲染成位图统计。小图统计结果与全图差异很小,但回读耗时能降低一个数量级。

下面这段Swift代码演示了用CIKernel计算三通道最小值。更完整的暗通道还需要对窗口做最小值滤波,工程中可用Metal Performance Shaders的MPSImageAreaMin,或把图像降采样后使用较小的形态学核近似。

let darkChannelKernel = CIKernel(source: """
    kernel vec4 dark_channel(sampler image) {
        vec4 p = sample(image, samplerCoord(image));
        float minValue = min(p.r, min(p.g, p.b));
        return vec4(minValue, minValue, minValue, 1.0);
    }
""")
guard let darkKernel = darkChannelKernel else { return nil }
let darkImage = darkKernel.apply(extent: input.extent, arguments: [input])

透射率初始估计与导向滤波

得到大气光A后,初始透射率可以按t(x)=1-ω·(I_dark(x)/A)计算。ω通常取0.85到0.95,保留少量雾感避免图像看起来不自然。暗通道越小,说明该像素受雾影响越轻;暗通道越大,透射率越低,场景越远。直接对每个像素使用这个公式得到的t虽然能恢复出轮廓,但窗口滤波会让透射率在边缘附近出现块状,尤其是树枝、建筑轮廓周围会出现明显光晕。

解决这个问题的经典方法是导向滤波。导向滤波假设输出透射率q在局部窗口内与引导图I成线性关系:q_i=a_k I_i+b_k。通过最小化q与初始透射率p之间的均方误差,可以求出每个窗口的a_k和b_k。经过盒式模糊后,重叠窗口的系数被平均,最终输出既能贴合初始透射率,又能保留引导图的边缘。与软抠图相比,导向滤波计算量更小,更适合移动端。引导图一般使用原图的灰度图,窗口半径与暗通道窗口保持同一量级。

导向滤波在iOS端可以不借助Core Image原语完成。通常先用Metal compute shader计算每个像素的I、p、II、Ip,再做两次盒式模糊,最后计算a、b和输出q。下面是一个用于组装盒式模糊输入量的Metal片段,实际工程中还需要处理边界和线性RGB空间。

kernel void guided_filter_box(texture2d<float, access::read> I [[texture(0)]],
                              texture2d<float, access::read> P [[texture(1)]],
                              device float4 *outMean [[buffer(0)]],
                              uint2 gid [[thread_position_in_grid]]) {
    float i = I.read(gid).r;
    float p = P.read(gid).r;
    outMean[gid.y * I.get_width() + gid.x] = float4(i, p, i * i, i * p);
}

Core Image滤镜封装与移动端优化

把整个去雾流程封装成CIFilter后,可以像系统滤镜一样在相机管线或照片编辑里复用。下面给出了一个简化版实现,包含暗通道、初始透射率和场景恢复三个CIKernel。其中透射率精化函数refineTransmission需要替换成实际的导向滤波实现。

class ImageDehazeFilter: CIFilter {
    @objc dynamic var inputImage: CIImage?
    @objc dynamic var inputIntensity: CGFloat = 0.85
    @objc dynamic var inputWindowRadius: CGFloat = 15.0
    private let darkChannelKernel = CIKernel(source: """
        kernel vec4 dark_channel(sampler image) {
            vec4 p = sample(image, samplerCoord(image));
            float minValue = min(p.r, min(p.g, p.b));
            return vec4(minValue, minValue, minValue, 1.0);
        }
    """)
    override var outputImage: CIImage? {
        guard let input = inputImage else { return nil }
        let extent = input.extent
        guard let darkKernel = darkChannelKernel,
              let dark = darkKernel.apply(extent: extent, arguments: [input]) else { return nil }
        let darkScaled = dark.applying(CIAffineTransform(scale: 0.25))
        let atmosphericLight = estimateAtmosphericLight(darkScaled)
        let transmissionKernel = CIKernel(source: """
            kernel vec4 transmission(sampler image, float airLight, float omega) {
                vec4 p = sample(image, samplerCoord(image));
                float minValue = min(p.r, min(p.g, p.b));
                float t = 1.0 - omega * (minValue / airLight);
                return vec4(t, t, t, 1.0);
            }
        """)
        guard let transmissionKernel = transmissionKernel,
              let tImage = transmissionKernel.apply(extent: extent,
                    arguments: [input, atmosphericLight, inputIntensity]) else { return nil }
        guard let refinedT = refineTransmission(tImage, guide: input) else { return nil }
        let sceneKernel = CIKernel(source: """
            kernel vec4 recover(sampler image, sampler transmission, float airLight) {
                vec4 src = sample(image, samplerCoord(image));
                float t = clamp(sample(transmission, samplerCoord(transmission)).r, 0.05, 1.0);
                vec3 j = (src.rgb - airLight) / t + airLight;
                return vec4(j, src.a);
            }
        """)
        return sceneKernel?.apply(extent: extent, arguments: [input, refinedT, atmosphericLight])
    }
    private func estimateAtmosphericLight(_ darkImage: CIImage) -> Float {
        // 实际工程中应从降采样后的位图读取前0.1%最亮像素
        return 0.82
    }
    private func refineTransmission(_ t: CIImage, guide: CIImage) -> CIImage? {
        // 导向滤波实现,可以使用Metal Performance Shaders或vImage
        return t
    }
}

性能上,最容易忽略的是颜色空间。Core Image默认在工作色彩空间下处理,去雾算法本质是线性运算,建议将CIContext的workingColorSpace设置为线性sRGB,否则大气光和透射率会在gamma空间中被错误缩放。其次是分辨率策略:暗通道和透射率图不必保持原始分辨率,可以在四分之一或八分之一尺寸上计算,再放大回原图。透射率图放大时推荐使用双线性或Lanczos插值,避免边缘出现明显锯齿。

另一个常见问题是CPU回读大气光的频率。视频处理时不需要每一帧都重新估计大气光,可以每秒更新一次或只在场景明显变化时更新,这样能把回读开销摊薄到几乎可以忽略。实际测试中,暗通道最小值滤波和导向滤波约占总耗时的八成以上,因此优先用Metal Performance Shaders的均值/最小值核,并缓存预编译的CIKernel,比反复创建内核字符串的收益更明显。整体来看,Core Image适合作为图像组织层,Metal负责区域统计,两者的组合可以在iOS设备上实现实时视频去雾。

广告位 · 文章底部(后台-模块管理替换此区域)