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

给一张图,告诉我它的"灵魂颜色"是什么。 音乐 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×

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

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

往期推荐:

经济机器是怎样运行的?

解决AI焦虑的唯一办法,建议收藏

图解Otsu算法

AI工业革命,有哪些能力最稀缺?

为什么很多创业者做出来的产品没人买?------读《The Mom Test》有感

巴菲特和芒格没有说的那些话

『纳瓦尔宝典』长期主义才是财富密码

『纳瓦尔宝典』独特知识才是真正资产

『纳瓦尔宝典』不要竞争,要变得不可替代!

我们终将成为自己选择的样子!!

你的环境,正在替你做决定!!

芒格的人生操作系统,构建复利人生的基石

如何选择真正适合你的职业?

决策矩阵

二阶思维

决策日志

AI时代,软件工程师必备概念全景图

相关推荐
码途漫谈1 小时前
Harness:让 Claude Code 先组队,再开工
开源·ai编程
wuhen_n1 小时前
RAG 入门:检索增强生成核心原理
前端·人工智能·typescript·langchain·ai编程
winlife_2 小时前
全程用 AI 做一款商业级手游 · EP0 立项:能做到吗、怎么做、边界在哪
人工智能·unity·ai编程·游戏开发·商业化·mcp·funplay
一条咸鱼_SaltyFish2 小时前
Agent 工程化避坑指南——从实践看常见反模式
ai·agent·ai编程·memory·obsidian·harness·llm-wiki
嘟嘟MD3 小时前
程序员副业 | 2026年5月复盘
ai编程·创业
linge_sun3 小时前
SpringAI SQL 智能助手实战:用自然语言查询数据库
java·人工智能·ai编程
难以触及的高度3 小时前
Dify 本地部署实操全教程:零基础快速搭建私有化 AI 应用
人工智能·ai·github·ai编程·dify
boonya7 小时前
Winter is Coming:当AI疯王们举起屠刀,弑君者已在路上
ai·ai编程
Jartto11 小时前
手搓一个 Claude Code 硬件副屏:3D 打印外壳 + 本地状态机实现 AI 任务可视化
aigc·ai编程·claude