iOS端的非线性色彩分级与普通滤镜链最大的区别在于:它不修改单一颜色通道的线性系数,而是重新定义输入RGB到输出RGB的映射关系。Core Image中的CIColorCube就是为此设计的滤镜。它维护一个三维查找表,把每个颜色通道离散成若干级别,所有组合形成一张表,GPU在采样时通过三线性插值得到平滑的颜色输出。这种结构非常适合实现胶片暗部、日系高光、青橙对比等风格。

下面会先介绍查找表滤镜的参数和数据组织,再说明如何把全局调整约束到局部区域,然后拆解阴影、中间调、高光的分区校正方法,最后给出.cube文件解析和移动端性能优化的建议。
一、CIColorCube的查找表数据组织
CIColorCube需要两个关键参数:inputCubeDimension和inputCubeData。维度表示每个通道的采样点数,常用32或64。数据长度等于维度三次方乘以4,每个像素按RGBA顺序存储浮点值。32维大约需要512KB,64维约4MB,64维会带来更精细的过渡,但在旧设备上也会增加GPU内存压力。
构建查找表时,先用循环生成一个恒等立方体,保证输入颜色不变。随后可以在这个基础上修改指定区间,例如把暗部的蓝色通道提高、把高光的红色通道降低。需要注意Core Image默认使用线性工作颜色空间还是设备颜色空间,如果处理的是sRGB图片,建议使用CIColorCubeWithColorSpace并传入sRGB色彩空间,否则同一组数据在显示时可能出现饱和度偏差。
func makeIdentityCube(dimension: Int = 64) -> Data {
let size = dimension * dimension * dimension
var pixels = [Float](repeating: 0, count: size * 4)
var offset = 0
for b in 0...dimension - 1 {
for g in 0...dimension - 1 {
for r in 0...dimension - 1 {
pixels[offset] = Float(r) / Float(dimension - 1)
pixels[offset + 1] = Float(g) / Float(dimension - 1)
pixels[offset + 2] = Float(b) / Float(dimension - 1)
pixels[offset + 3] = 1.0
offset += 4
}
}
}
return pixels.withUnsafeBufferPointer { Data(buffer: $0) }
}
上述代码生成64维恒等立方体,其中红绿蓝分量按照维度归一化到0至1之间。把它传给滤镜后,输出图像理论上与原图一致。实际调试时可以先跑通这个恒等版本,再逐步修改顶点值验证映射是否符合预期。
二、基于遮罩的局部色彩调整
全局查找表会对整张图像生效,但很多调色需求只集中在局部,比如只给人物肤色增加暖调,或只压暗过曝天空。Core Image提供CIBlendWithMask滤镜,它根据灰度蒙版将背景图像和输入图像混合。蒙版中白色像素显示调整后的背景图,黑色像素显示原始图,灰色则按比例过渡。
蒙版可以来自多种渠道。最简单的是CIRadialGradient,适合模拟径向渐变的局部调整。也可以先用CIDetector检测人脸,再结合CIColorMatrix或形态学滤镜生成粗略皮肤蒙版。下面的代码先对原图做暖调处理,再用中心渐变的蒙版让调整只作用于画面中心区域。
let gradient = CIFilter(name: "CIRadialGradient")! gradient.setValue(CIVector(x: inputImage.extent.midX, y: inputImage.extent.midY), forKey: kCIInputCenterKey) gradient.setValue(0.0, forKey: "inputRadius0") gradient.setValue(inputImage.extent.width * 0.6, forKey: "inputRadius1") gradient.setValue(CIColor.white, forKey: "inputColor0") gradient.setValue(CIColor.black, forKey: "inputColor1") let mask = gradient.outputImage?.cropped(to: inputImage.extent) let blend = CIFilter(name: "CIBlendWithMask")! blend.setValue(inputImage, forKey: kCIInputImageKey) blend.setValue(warmOutput, forKey: kCIInputBackgroundImageKey) blend.setValue(mask, forKey: kCIInputMaskImageKey) let localAdjusted = blend.outputImage?.cropped(to: inputImage.extent)
如果蒙版边缘过硬,可以用CIGaussianBlur对它做轻微模糊,让过渡更自然。对于视频序列,逐帧运行时要注意蒙版生成和模糊的耗时,必要时把蒙版降采样到较低分辨率再应用。
三、阴影、中间调与高光的分区校正
分区校正的核心思想是把画面按照亮度拆开,分别应用不同的LUT或色调映射。阴影区域可能需要提升暗部细节并染色,高光区域则可能需要压缩亮度同时保持中性,中间调负责整体对比度和肤色表现。直接套一个全局LUT很难兼顾这三个范围,因此要生成亮度蒙版。
亮度蒙版可以用CIColorKernel自定义内核生成。例如阴影蒙版用smoothstep把低亮度区域映射为白色,高亮度区域映射为黑色。高光蒙版则反过来。中间调蒙版可以写作1减去阴影和高光的乘积,或使用带通函数。下面是一个阴影蒙版的内核示例。
let shadowMaskKernel = CIColorKernel(source:
"kernel vec4 shadowMask(__sample image) {" +
" float luma = dot(image.rgb, vec3(0.299, 0.587, 0.114));" +
" float w = 1.0 - smoothstep(0.08, 0.45, luma);" +
" return vec4(w, w, w, 1.0);" +
"}"
)
得到三个蒙版后,先把原图分别送入三个调整分支,计算出阴影风格、中间调风格和高光风格,再用CIBlendWithMask依次合成。合成顺序建议先合阴影,再合高光,最后合中间调,避免某个亮度范围被后续遮罩覆盖。调试时可以单独查看每个蒙版,确认它们覆盖的亮度区间是否符合预期。
四、解析.cube文件并转换为Core Image数据
.cube是调色行业常见的LUT交换格式,绝大多数调色软件都能导出。文本文件通常包含LUT_3D_SIZE行,后面跟着维度三次方行数据,每行三个浮点数代表映射后的RGB值。Core Image需要的inputCubeData是RGBA交错的浮点数据,因此解析时要把每行的三个值补上alpha等于1。
解析过程需要注意文件编码、注释行和空行。数值范围一般在0到1之间,有些老版本文件可能使用0到255,需要根据实际情况归一化。解析完成后应校验数据行数是否等于维度的三次方,否则滤镜可能显示异常或崩溃。
func parseCubeFile(_ text: String) -> (dimension: Int, data: Data)? {
var dimension = 0
var values = [Float]()
let lines = text.components(separatedBy: .newlines)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
if trimmed.hasPrefix("LUT_3D_SIZE") {
let parts = trimmed.split(separator: " ")
if let n = Int(parts.last ?? "0"), n > 0 { dimension = n }
continue
}
if dimension == 0 { continue }
let parts = trimmed.split(separator: " ")
if parts.count >= 3,
let r = Float(String(parts[0])),
let g = Float(String(parts[1])),
let b = Float(String(parts[2])) {
values.append(contentsOf: [r, g, b, 1.0])
}
}
guard dimension > 0, values.count == dimension * dimension * dimension * 4 else { return nil }
let data = values.withUnsafeBufferPointer { Data(buffer: $0) }
return (dimension, data)
}
这段代码将.cube文本转换为维度和Data。实际工程中不要在主线程频繁读取文件,应在后台解析并缓存结果。还可以把多个风格文件放在Bundle中,首次启动时异步加载,切换风格时只替换滤镜的inputCubeData属性。
五、艺术风格化的落地与性能优化
艺术风格化通常依赖一组预设的.cube文件,每一种文件对应一种色调风格。相比在代码里逐项调参数,直接加载专业调色师导出的LUT更容易得到稳定效果,也方便美术人员参与。使用时把选择好的cube数据传给CIColorCube,再根据场景叠加颗粒、暗角或柔焦等滤镜。
性能方面,应尽量缓存CIContext和CIFilter对象。对于单张图片处理,可以创建较大的CIContext并调用render输出CGImage。对于视频帧,建议预先创建CIContext并设置workingColorSpace与cube色彩空间一致。移动端如果处理4K视频,64维LUT可能带来明显GPU开销,可以先用32维版本实时预览,导出时再切换到64维。
另外要留意CIColorCube输出的CIImage在滤镜链中可能扩展了无限范围,最后需要调用cropped(to:)裁剪回原始显示区域。色彩空间不一致、遮罩未裁剪、LUT数据长度错误是这类实现中最常见的三个问题。建议先用带有色卡和灰阶的测试图验证每个环节,再进行批量处理。
Core ImageLUT色彩分级修改时间:2026-09-25 06:03:37