给一张图,告诉我它的"灵魂颜色"是什么。 音乐 App 动态配色、电商颜色标注、UI 自动主题------背后都是同一个问题: 从百万个像素中,找出最能代表这张图的 6 种颜色。
一、问题定义
主色提取(Dominant Color Extraction) :给定一张图像,输出一组颜色及其各自代表的像素占比,形成调色板(Color Palette)。
yaml
输入:一张 1200 万像素的照片
██████████████████████████
████ 天空(蓝)█████████
████████████████████████
████ 山体(绿褐)████████
████████████████████████
输出:ColorPalette
[#3B82F6 (蓝) 42%]
[#6B7280 (灰) 28%]
[#84CC16 (黄绿) 18%]
[#92400E (深棕) 12%]
为什么难?
1200 万像素 = 1200 万个三维颜色点(R, G, B 各 0--255)。直接统计会得到 256³ = 1680 万个可能的颜色桶,大多数为空,有意义的桶需要合并才能得到代表色。
二、应用场景
| 场景 | 用途 | 具体应用 |
|---|---|---|
| 音乐 App | 专辑封面 → 播放器背景渐变配色 | Apple Music、Spotify |
| 电商平台 | 商品图 → 自动标注"颜色"属性 | 淘宝颜色筛选 |
| UI 动态主题 | 用户头像 → App 主题色 | 个性化 Profile 页面 |
| 图像搜索 | 按颜色检索照片 | Google Photos 颜色过滤 |
| 内容分析 | 品牌色合规检测 | 广告审核 |
三、算法对比
| 算法 | 速度 | 质量 | 确定性 | 适合场景 |
|---|---|---|---|---|
| 量化直方图(颜色化简) | 极快(O(N)) | 差(颜色不自然) | 是 | 快速缩略 |
| Median Cut(本实现) | 快(O(N log k)) | 中等,感知好 | 是 | 通用,实时 |
| k-means 聚类 | 慢(O(N×k×iter)) | 最准确 | 否(随机初始化) | 离线处理 |
| Octree 量化 | 快 | 中等 | 是 | 内存受限设备 |
| DBSCAN 聚类 | 极慢 | 好(自动 k) | 是 | 研究场景 |
选择 Median Cut 的原因:
- O(N log k) 复杂度,对 1200 万像素仍可在 < 50ms 完成(含降采样)
- 输出确定,相同输入永远得到相同输出(便于缓存和测试)
- 结果颜色在感知上自然(不会出现 k-means 随机初始化导致的偶发错误结果)
四、Median Cut 算法详解
4.1 颜色空间视为 3D 立方体
每个像素是 RGB 空间中的一个点(x=R, y=G, z=B),范围均为 0, 255。
Median Cut 把这个 3D 点云递归地切割成 k 个子立方体,每个子立方体取其中所有点的均值作为代表色。
markdown
初始:所有像素点在一个大立方体中
┌──────────────────┐
│ 所有颜色(N个点)│
└──────────────────┘
第 1 次切割(找最长轴,从中位数切割):
┌──────┐ ┌──────┐
│ 一半 │ │ 一半 │
└──────┘ └──────┘
第 2 次切割(对各子组分别切割最长轴):
┌──┐ ┌──┐ ┌──┐ ┌──┐
│ │ │ │ │ │ │ │
└──┘ └──┘ └──┘ └──┘
持续直到子组数量 = 目标颜色数 k(如 6)
4.2 为什么沿最长轴切割?
最长轴是当前颜色集合中方差最大的通道(R/G/B 中范围最宽的那个)。
yaml
例:某子组的颜色范围:
R: 50--200 (范围 150) ← 最长轴
G: 80--120 (范围 40)
B: 90--110 (范围 20)
沿 R 轴切割的好处:
→ 切割后两个子组内的颜色差异最小(各组内颜色更相似)
→ 这正是量化误差最小化的贪心策略
→ 相当于每次"解决最大的问题"
如果总沿同一轴切割,会忽略其他通道的颜色差异,导致调色板颜色在某个维度上过于密集。
4.3 Swift 实现
swift
public struct MedianCutQuantizer {
struct ColorBox {
var pixels: [(r: UInt8, g: UInt8, b: UInt8)]
// 三通道的范围(用于选择最长轴)
var rRange: Int { Int(pixels.map(\.r).max()!) - Int(pixels.map(\.r).min()!) }
var gRange: Int { Int(pixels.map(\.g).max()!) - Int(pixels.map(\.g).min()!) }
var bRange: Int { Int(pixels.map(\.b).max()!) - Int(pixels.map(\.b).min()!) }
// 体积 = 三通道范围之积(体积越大,颜色越多样,应优先切割)
var colorVolume: Int { max(1, rRange) * max(1, gRange) * max(1, bRange) }
// 沿最长轴从中位数切割
func split() -> (ColorBox, ColorBox) {
let rR = rRange, gR = gRange, bR = bRange
let sortedPixels: [(r: UInt8, g: UInt8, b: UInt8)]
if rR >= gR && rR >= bR {
sortedPixels = pixels.sorted { $0.r < $1.r }
} else if gR >= rR && gR >= bR {
sortedPixels = pixels.sorted { $0.g < $1.g }
} else {
sortedPixels = pixels.sorted { $0.b < $1.b }
}
let mid = sortedPixels.count / 2
return (
ColorBox(pixels: Array(sortedPixels[..<mid])),
ColorBox(pixels: Array(sortedPixels[mid...]))
)
}
// 代表色 = 组内所有像素的算术均值
var representativeColor: (r: UInt8, g: UInt8, b: UInt8) {
let n = pixels.count
guard n > 0 else { return (128, 128, 128) }
let rAvg = pixels.reduce(0) { $0 + Int($1.r) } / n
let gAvg = pixels.reduce(0) { $0 + Int($1.g) } / n
let bAvg = pixels.reduce(0) { $0 + Int($1.b) } / n
return (UInt8(rAvg), UInt8(gAvg), UInt8(bAvg))
}
}
public static func extract(from bitmap: MLBitmap,
count: Int = 6) -> ColorPalette {
// Step 1:采样(降采样到最多 10000 像素,加速计算)
let pixels = samplePixels(from: bitmap, maxCount: 10_000)
// Step 2:过滤无效像素
let validPixels = pixels.filter { pixel in
pixel.alpha >= 200 && // 过滤透明像素
!isNearGray(r: pixel.r, g: pixel.g, b: pixel.b) // 过滤近灰色
}
guard !validPixels.isEmpty else {
return ColorPalette(colors: [(128, 128, 128, 1.0)])
}
// Step 3:Median Cut 迭代
var boxes = [ColorBox(pixels: validPixels.map { ($0.r, $0.g, $0.b) })]
while boxes.count < count {
// 选体积最大的 box 进行切割
guard let maxIdx = boxes.indices.max(by: { boxes[$0].colorVolume < boxes[$1].colorVolume }),
boxes[maxIdx].colorVolume > 0
else { break }
let (left, right) = boxes[maxIdx].split()
boxes.remove(at: maxIdx)
boxes.append(left)
boxes.append(right)
}
// Step 4:计算各颜色占比
let totalValid = Double(validPixels.count)
let colors = boxes.map { box -> (r: UInt8, g: UInt8, b: UInt8, fraction: Double) in
let rep = box.representativeColor
let fraction = Double(box.pixels.count) / totalValid
return (rep.r, rep.g, rep.b, fraction)
}
.sorted { $0.fraction > $1.fraction } // 按占比降序
return ColorPalette(colors: colors)
}
}
五、过滤近灰色像素
问题:图像中的灰色(深灰、浅灰、白、黑)通常是背景或阴影,不是"主色"。如果不过滤,调色板可能全是灰色,毫无意义。
饱和度代理(Saturation Proxy):
真正的 HSV 饱和度计算需要先做浮点转换,成本较高。用一个快速近似:
swift
func isNearGray(r: UInt8, g: UInt8, b: UInt8) -> Bool {
let rI = Int(r), gI = Int(g), bI = Int(b)
let maxC = max(rI, gI, bI)
let minC = min(rI, gI, bI)
// 饱和度代理 = (max - min),若 max > 0 则可进一步归一化
// 阈值 25:若三通道最大差异 < 25,认为是近灰色
let saturationProxy = maxC - minC
return saturationProxy < 25
}
阈值 25 的选择依据:
- 纯灰(R=G=B=128):saturationProxy = 0
- 浅粉(R=255, G=230, B=230):saturationProxy = 25,临界值
- 纯红(R=255, G=0, B=0):saturationProxy = 255
25 这个阈值在实践中经过调优,可过滤明显的灰调同时保留绝大多数有色像素。
六、透明像素过滤
对于 PNG 图像(有 Alpha 通道),透明区域的像素值无意义(通常为 0 或者残留数据)。
swift
// alpha < 200 视为"基本透明",过滤掉
// 200 而不是 255 的原因:抗锯齿边缘的像素 alpha 可能是 200--254,
// 这些像素的颜色仍然有效,应该保留
pixel.alpha >= 200
七、ColorPalette 输出设计
swift
public struct ColorPalette {
public struct Color {
public let r, g, b: UInt8
public let fraction: Double // 该颜色占有效像素的比例(0.0--1.0)
/// 十六进制字符串(#RRGGBB)
public var hexString: String {
String(format: "#%02X%02X%02X", r, g, b)
}
/// 感知亮度(BT.709)
public var luminance: Double {
(0.2126 * Double(r) + 0.7152 * Double(g) + 0.0722 * Double(b)) / 255.0
}
/// 判断是否适合叠加白色文字(亮度 < 0.5 时白字可读)
public var isDark: Bool { luminance < 0.5 }
}
public let colors: [Color]
/// 主色(占比最大的那个)
public var dominant: Color { colors[0] }
/// 所有颜色的十六进制字符串数组
public var hexStrings: [String] { colors.map(\.hexString) }
}
fraction 字段的用途:
swift
// 生成渐变背景时按占比分配渐变区段
let gradient = palette.colors.prefix(3).map { color in
(color: UIColor(r: color.r, g: color.g, b: color.b),
location: CGFloat(color.fraction))
}
八、Median Cut vs k-means 的本质区别
| 维度 | Median Cut | k-means |
|---|---|---|
| 初始化 | 确定性(从全集递归分割) | 随机初始化 k 个中心点 |
| 迭代 | 无迭代(一次性分割) | 多轮迭代直到收敛(通常 10--50 轮) |
| 复杂度 | O(N log k) | O(N × k × 迭代次数) |
| 结果稳定性 | 完全确定 | 受随机种子影响,不同运行可能不同 |
| 颜色感知质量 | 中等(均值代表色可能不是"感知中心") | 更好(均值在感知上更接近簇中心) |
| 空簇问题 | 不存在(每次切割保证两半非空) | 存在(某个中心可能吸引到 0 个点) |
关键差异的可视化:
css
数据分布(二维简化):
●● ●●● ★★★★★★★★
●●● Gap ★★★★★
Median Cut:
沿最长轴(水平)从中位数切割
→ 左半(●)、右半(★)
→ 精确分离两簇
k-means(随机初始化运气差):
初始中心落在两簇各自的极端
→ 第 1 轮:中心 A 移到 ● 中,中心 B 移到 ★ 中
→ 但若初始中心都落在 ★ 一侧:可能需要多轮才收敛
九、性能优化:降采样
对 1200 万像素做 Median Cut,排序操作的代价是 O(N log N),耗时约 2--5 秒(不可接受)。
解决方案:先降采样到约 10000 像素,再做 Median Cut。
swift
func samplePixels(from bitmap: MLBitmap, maxCount: Int) -> [(r: UInt8, g: UInt8, b: UInt8, alpha: UInt8)] {
let total = bitmap.width * bitmap.height
guard total > maxCount else {
// 小图直接全部使用
return (0..<total).map { i in
let base = i * 4
return (bitmap.pixels[base], bitmap.pixels[base+1],
bitmap.pixels[base+2], bitmap.pixels[base+3])
}
}
// 均匀步进采样(不是随机采样,保证覆盖全图)
let step = total / maxCount
return stride(from: 0, to: total, by: step).map { i in
let base = i * 4
return (bitmap.pixels[base], bitmap.pixels[base+1],
bitmap.pixels[base+2], bitmap.pixels[base+3])
}
}
10000 像素的经验依据:
- 10000 样本点对颜色分布的误差约为 1--2%(统计学抽样定理)
- 排序 10000 点耗时 < 1ms,vs 排序 1200 万点耗时约 3--5 秒
- Apple Music 等系统框架也使用类似的降采样策略
十、小结
| 概念 | 核心内容 |
|---|---|
| 问题定义 | 从百万像素中提取 k 个代表色及其占比 |
| Median Cut | 递归沿最长轴切割 3D 颜色立方体;确定性,O(N log k) |
| 最长轴选择 | 最大化各组内颜色相似度,贪心最优策略 |
| colorVolume | 三通道范围之积,用于选择优先切割的子组 |
| 代表色 = 均值 | 每组所有像素 RGB 的算术均值 |
| 近灰色过滤 | saturationProxy = max-min < 25,排除背景色 |
| 透明像素过滤 | alpha < 200 视为无效,不参与计算 |
| fraction | 每种颜色的像素占比,用于渐变、权重等下游应用 |
| vs k-means | Median Cut 确定性好、速度快;k-means 质量略好但随机 |
| 降采样 | 先采 10000 点再 Median Cut,误差 1--2%,速度提升 1000× |
♥️喜欢我的内容,欢迎大家点赞、转发、关注。
♥️专注于技术+投资+认知三位一体的内容分享。
往期推荐: