【图像处理】一文带你窥探近期火热图像App的主要实现原理:主色提取——从图像到调色板

0 阅读9分钟

给一张图,告诉我它的"灵魂颜色"是什么。 音乐 App 动态配色、电商颜色标注、UI 自动主题——背后都是同一个问题: 从百万个像素中,找出最能代表这张图的 6 种颜色。


一、问题定义

主色提取(Dominant Color Extraction):给定一张图像,输出一组颜色及其各自代表的像素占比,形成调色板(Color Palette)

输入:一张 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 个子立方体,每个子立方体取其中所有点的均值作为代表色。

初始:所有像素点在一个大立方体中
                ┌──────────────────┐
                │   所有颜色(N个点)│
                └──────────────────┘

第 1 次切割(找最长轴,从中位数切割):
                ┌──────┐ ┌──────┐
                │ 一半  │ │ 一半  │
                └──────┘ └──────┘

第 2 次切割(对各子组分别切割最长轴):
           ┌──┐ ┌──┐ ┌──┐ ┌──┐
           │  │ │  │ │  │ │  │
           └──┘ └──┘ └──┘ └──┘

持续直到子组数量 = 目标颜色数 k(如 6)

4.2 为什么沿最长轴切割?

最长轴是当前颜色集合中方差最大的通道(R/G/B 中范围最宽的那个)。

例:某子组的颜色范围:
  R: 50–200  (范围 150)  最长轴
  G: 80–120  (范围 40)
  B: 90–110  (范围 20)

沿 R 轴切割的好处:
   切割后两个子组内的颜色差异最小(各组内颜色更相似)
   这正是量化误差最小化的贪心策略
   相当于每次"解决最大的问题"

如果总沿同一轴切割,会忽略其他通道的颜色差异,导致调色板颜色在某个维度上过于密集。

4.3 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 饱和度计算需要先做浮点转换,成本较高。用一个快速近似:

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 或者残留数据)。

// alpha < 200 视为"基本透明",过滤掉
// 200 而不是 255 的原因:抗锯齿边缘的像素 alpha 可能是 200–254,
// 这些像素的颜色仍然有效,应该保留
pixel.alpha >= 200

七、ColorPalette 输出设计

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 字段的用途

// 生成渐变背景时按占比分配渐变区段
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 Cutk-means
初始化确定性(从全集递归分割)随机初始化 k 个中心点
迭代无迭代(一次性分割)多轮迭代直到收敛(通常 10–50 轮)
复杂度O(N log k)O(N × k × 迭代次数)
结果稳定性完全确定受随机种子影响,不同运行可能不同
颜色感知质量中等(均值代表色可能不是"感知中心")更好(均值在感知上更接近簇中心)
空簇问题不存在(每次切割保证两半非空)存在(某个中心可能吸引到 0 个点)

关键差异的可视化

数据分布(二维简化):
  ●● ●●●              ★★★★★★★★
  ●●●        Gap       ★★★★★

Median Cut:
  沿最长轴(水平)从中位数切割
  → 左半(●)、右半(★)
  → 精确分离两簇

k-means(随机初始化运气差):
  初始中心落在两簇各自的极端
  → 第 1 轮:中心 A 移到 ● 中,中心 B 移到 ★ 中
  → 但若初始中心都落在 ★ 一侧:可能需要多轮才收敛

九、性能优化:降采样

对 1200 万像素做 Median Cut,排序操作的代价是 O(N log N),耗时约 2–5 秒(不可接受)。

解决方案:先降采样到约 10000 像素,再做 Median Cut。

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-meansMedian Cut 确定性好、速度快;k-means 质量略好但随机
降采样先采 10000 点再 Median Cut,误差 1–2%,速度提升 1000×

♥️喜欢我的内容,欢迎大家点赞、转发、关注。

♥️专注于技术+投资+认知三位一体的内容分享。

往期推荐:

经济机器是怎样运行的?
解决AI焦虑的唯一办法,建议收藏
图解Otsu算法
AI工业革命,有哪些能力最稀缺?
为什么很多创业者做出来的产品没人买?——读《The Mom Test》有感
巴菲特和芒格没有说的那些话
『纳瓦尔宝典』长期主义才是财富密码
『纳瓦尔宝典』独特知识才是真正资产
『纳瓦尔宝典』不要竞争,要变得不可替代!
我们终将成为自己选择的样子!!
你的环境,正在替你做决定!!
芒格的人生操作系统,构建复利人生的基石
如何选择真正适合你的职业?
决策矩阵
二阶思维
决策日志
AI时代,软件工程师必备概念全景图