在iOS平台上用Core Image做图像二值化,看起来简单,实际上要处理清楚阈值来源和边缘细节并不容易。系统自带的CIColorMonochrome只是把颜色映射成单色,并不会把像素压成标准的0或255两级;CIPhotoEffectNoir虽然接近黑白风格,但输出仍然保留大量灰阶。要想得到真正的二值图,需要自己编写Core Image内核,在GPU中逐像素判断亮度。下面从全局阈值、自适应阈值、OTSU计算以及漫画化合成四个方面拆解完整实现。

一、Core Image自定义内核与全局阈值
Core Image提供了一套可编程滤镜接口,其中最常用的是CIColorKernel。它允许你传入一段内核源码,在渲染时由Core Image编译并放到GPU上执行。和传统CPU逐像素循环不同,这种方案天然适合实时预览和高分辨率图像,因为像素计算是并行的。
全局阈值是最基础的二值化方式:先计算每个像素的亮度值,再拿它和一个固定阈值比较。小于阈值就输出黑色,大于阈值就输出白色。亮度计算一般采用Rec. 601系数,也就是0.299R + 0.587G + 0.114B,这样得到的灰度值更接近人眼感知。
下面是一段完整的全局阈值内核代码。需要注意的是,Core Image内核语言不是Metal Shading Language,而是类似旧版GLSL的Core Image Shading Language,所以变量类型要用vec4、float等写法。
let thresholdKernelSource = """
kernel vec4 thresholdKernel(sampler image, float threshold) {
vec4 color = sample(image, samplerCoord(image));
float luma = dot(color.rgb, vec3(0.299, 0.587, 0.114));
float value = luma > threshold ? 1.0 : 0.0;
return vec4(value, value, value, 1.0);
}
"""
guard let kernel = CIColorKernel(source: thresholdKernelSource) else { return }
let output = kernel.apply(extent: input.extent, arguments: [input, 0.5])
这里的threshold参数传入0到1之间的浮点数,0.5代表中等亮度。实际应用中可以把阈值做成滑块,让用户在预览界面里手动调整。由于内核在GPU上运行,拖动滑块时也能保持流畅。
二、自适应阈值:高斯模糊与局部比较
全局阈值有个明显问题:当图片存在不均匀光照时,同一个物体在暗部和亮部可能得到完全不同的灰度值,固定阈值很难同时处理高光和阴影。比如一张拍摄文档的照片,左边靠近窗户偏亮,右边偏暗,如果阈值固定在中间,暗部的文字就可能全部变成黑块或丢失。
自适应阈值的基本思想是把每个像素和它周围邻域的平均亮度做比较,而不是和一个全局常数比较。邻域亮的地方阈值自动抬高,邻域暗的地方阈值自动降低。在Core Image中直接采样邻域需要处理ROI回调,但可以走一个更简单的路线:使用CIGaussianBlur对原图做模糊,把模糊结果作为局部背景亮度估计。
下面这段内核接收两个输入图,一张是原图,一张是经过高斯模糊后的背景图。原图像素亮度和背景图像素亮度比较,如果原图明显亮于背景,就判定为前景白色,否则为黑色。bias参数用来控制敏感度,防止噪声把暗部细节吃光。
let background = input.applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: 8.0])
let adaptiveSource = """
kernel vec4 adaptiveThreshold(sampler image, sampler background, float bias) {
vec4 src = sample(image, samplerCoord(image));
vec4 bg = sample(background, samplerCoord(background));
float srcLuma = dot(src.rgb, vec3(0.299, 0.587, 0.114));
float bgLuma = dot(bg.rgb, vec3(0.299, 0.587, 0.114));
float value = srcLuma > (bgLuma + bias) ? 1.0 : 0.0;
return vec4(value, value, value, 1.0);
}
"""
guard let adaptiveKernel = CIColorKernel(source: adaptiveSource) else { return }
let adaptiveOutput = adaptiveKernel.apply(extent: input.extent, arguments: [input, background, 0.03])
模糊半径越大,局部窗口就越大,对缓慢光照变化越鲁棒,但同时也会丢失更细的文字笔画。实际项目中通常把半径设为图像短边的1/20到1/10之间,再根据预览效果微调。对于文档增强类功能,模糊半径8到12像素已经能应付大部分手机拍摄场景。
三、OTSU算法:CPU端统计直方图求最佳阈值
OTSU算法也叫最大类间方差法,它能自动找到一个阈值,让前景和背景两类的类间方差最大。这个阈值不是拍脑袋定的,而是从图像灰度直方图中推导出来的。对于前景和背景灰度差异明显的图片,OTSU通常比手动阈值更稳定。
由于Core Image的内核无法直接扫描整幅图像做全局统计,OTSU阈值需要在CPU端完成。简单流程是:先把CIImage渲染到RGBA8位图缓冲,接着统计0到255每个灰度值的像素数量,然后遍历256个灰度级计算最大类间方差。
下面代码展示如何把CIImage渲染成可遍历的像素数组。这里使用CIContext时要绑定Metal设备,否则在真机上可能走软件渲染,效率会明显下降。
let extent = input.extent
let width = Int(extent.width)
let height = Int(extent.height)
var pixels = [UInt8](repeating: 0, count: width * height * 4)
let colorSpace = CGColorSpaceCreateDeviceRGB()
pixels.withUnsafeMutableBytes { buffer in
context.render(input, toBitmap: buffer.baseAddress!,
rowBytes: width * 4,
bounds: extent,
format: .RGBA8,
colorSpace: colorSpace)
}
获取像素后可以统计直方图。因为二值化前通常会先转成灰度图,所以取第一个颜色通道即可,红绿蓝三个通道数值基本一致。如果直接处理彩色图,可以先计算亮度再四舍五入到0到255。
var histogram = [Int](repeating: 0, count: 256)
for y in 0..<height {
for x in 0..<width {
let index = (y * width + x) * 4
let gray = pixels[index]
histogram[Int(gray)] += 1
}
}
接下来实现OTSU核心计算。类间方差定义为wB * wF * (mB - mF) * (mB - mF),其中wB和wF分别是背景与前景的像素占比,mB和mF是两类的平均灰度。遍历256个灰度级,找到使该值最大的灰度级作为阈值。
let total = width * height
var sum = 0
for i in 0..<256 {
sum += i * histogram[i]
}
var sumB = 0
var wB = 0
var maxVariance: Double = 0
var threshold: Float = 0
for i in 0..<256 {
wB += histogram[i]
if wB == 0 { continue }
let wF = total - wB
if wF == 0 { break }
sumB += i * histogram[i]
let mB = Double(sumB) / Double(wB)
let mF = Double(sum - sumB) / Double(wF)
let variance = Double(wB) * Double(wF) * (mB - mF) * (mB - mF)
if variance > maxVariance {
maxVariance = variance
threshold = Float(i) / 255.0
}
}
计算出threshold后,再把它回传给前面写的全局阈值内核即可。这个阈值范围已经归一化到0到1,正好符合内核要求。对于相机预览或视频流,不必每一帧都在全分辨率上计算OTSU,可以先缩放到128×128左右的缩略图,算好阈值后再应用到原始分辨率,这样CPU统计工作量可以忽略不计。
四、黑白漫画效果:边缘、二值化和半调网屏
单纯二值化虽然能出黑白图,但距离漫画风格还差两个关键元素:清晰轮廓线和网点质感。漫画通常不是照片直接变黑白,而是先强化边缘线条,再对明暗区域做网屏填充。利用Core Image可以从三方面组合出这种效果。
第一步是生成灰度图并增强对比度。可以用CIPhotoEffectNoir快速得到灰度风格,再用CIColorControls拉高对比度。第二步是提取边缘,CIEdges滤镜可以生成黑底白线的边缘图,反转后得到白底黑线。第三步是把线条图与二值图合成,让黑色线条压到二值结果上。
func comicEffect(for image: CIImage, threshold: Float) -> CIImage? {
let gray = image.applyingFilter("CIPhotoEffectNoir")
let dualTone = applyThreshold(to: gray, threshold: threshold)
let edges = gray.applyingFilter("CIEdges", parameters: [kCIInputIntensityKey: 1.2])
let invertedEdges = edges.applyingFilter("CIColorInvert")
let combined = dualTone.applyingFilter("CIMinimumCompositing", parameters: [
kCIInputBackgroundImageKey: invertedEdges
])
let dotScreen = combined.applyingFilter("CIDotScreen", parameters: [
kCIInputWidthKey: 4.0,
kCIInputSharpnessKey: 0.6
])
return dotScreen
}
上面的合成使用了CIMinimumCompositing,它会取两个输入图像中较暗的像素,因此白色背景上的黑色线条会覆盖到二值图上。CIDotScreen则负责产生半调网点,通过kCIInputWidthKey调整网点间距,kCIInputSharpnessKey控制网点边缘锐利程度。如果希望出现传统漫画的斜线网点,也可以替换成CIHatchedScreen。
这种处理链不会改变图像尺寸,所有滤镜都保留原始extent,因此可以直接输出到UIImageView或导出为JPEG/PNG。需要注意CIEdges对噪声比较敏感,如果原图噪点较多,可以先做一次轻量中值模糊,避免线条出现碎点。
五、性能优化与常见问题
Core Image的关键优势在GPU,但前提是避免无谓的CPU回读和重复编译内核。CIColorKernel(source:)编译有一定成本,应该在初始化阶段创建一次并缓存,之后只调用apply(extent:arguments:)。同样,CIContext也需要复用,最好使用CIContext(mtlDevice:)显式绑定Metal设备。
自适应阈值中的CIGaussianBlur半径直接影响GPU耗时。在实时预览时可以先降低输入分辨率,或者把模糊半径缩放到与预览分辨率匹配,导出时再切回全分辨率和更大半径。iOS设备的Core Image支持MTLTexture高速缓存,只要不频繁改变滤镜链结构,性能通常足够。
OTSU是少数需要把像素拉回CPU的环节,因此要严格控制执行频率。对视频类场景,建议在缩略图上每10到15帧重新计算一次阈值,中间帧沿用上一次结果。这样做既避免了画面亮度突变时的阈值跳变,也把CPU统计开销控制在很低的水平。另一个常见问题是渲染到RGBA8位图时字节顺序不对,导致偏色或阈值偏差,务必使用.RGBA8格式和premultipliedLast的位图信息。
最后一个小坑是内核语言差异。Core Image自定义内核不是Metal Shading Language,不能写float4、texture2d这些Metal类型。如果从Metal项目直接复制内核代码过来,通常会编译失败。正确做法是保持Core Image Shading Language风格,把采样器声明为sampler,颜色用vec4,数学常量使用float即可。
Core Image图像二值化OTSU算法修改时间:2026-09-19 10:09:46