【图像处理】图像直方图——从"频率分布"到"智能决策"

直方图是图像的"体检报告"。 一眼看出:这张图曝光不足、对比度太低、色调偏暖------不用打开图像本身。 掌握直方图,就掌握了对图像质量"量化评估"的能力。


一、直方图的定义与信息密度

图像直方图是像素值的频率分布:统计每个亮度值(0--255)出现了多少次。

arduino 复制代码
一张 640×480(307200 像素)的图像,亮度直方图(横轴 0--255):

计数
 ↑
 █                 假设这是一张低对比度的雾天照片:
 █  ████            - 值集中在 100--180,两端几乎为空
 █ ██████           - 代表:图像"灰蒙蒙",缺少纯黑和纯白
 ███████████████
 0              255 → 像素值

从直方图读出的信息

直方图形态 含义 典型图像
集中在左侧(0--80) 欠曝,图像偏暗 夜景、阴暗室内
集中在右侧(180--255) 过曝,高光溢出 逆光、强烈反光
集中在中间,两端空白 低对比度 雾天、玻璃后拍摄
均匀分布(平坦) 对比度极高 直方图均衡后的图像
双峰 前景/背景分离明显 白纸黑字、蓝天白云
多峰 多个主色区域 复杂场景

二、RGB + 亮度四通道直方图

HistogramAnalyzer 同时计算 R、G、B 三个颜色通道亮度(Luma)通道的直方图,共 4 × 256 = 1024 个计数桶。

BT.709 亮度公式

人眼对绿色最敏感,对蓝色最不敏感。BT.709(HDTV 标准)的加权亮度公式:

ini 复制代码
Y = 0.2126×R + 0.7152×G + 0.0722×B

实现中使用整数近似避免浮点乘法(提速约 3×):

swift 复制代码
// 0.2126 ≈ 54/256, 0.7152 ≈ 183/256, 0.0722 ≈ 18/256
// 注意:54 + 183 + 18 = 255,用 >> 8 代替除以 256
@inline(__always)
func luma(r: UInt8, g: UInt8, b: UInt8) -> UInt8 {
    let y = 54 * UInt16(r) + 183 * UInt16(g) + 18 * UInt16(b)
    return UInt8(y >> 8)   // 等效于除以 256,自动舍入
}

为什么用 >> 8 而不是 / 256

Swift 编译器在 Release 模式下会自动优化,但在 Debug 模式下 >> 更快,且意图更明确:这是位运算的定点数技巧,不是真正的除法。


三、核心统计量的计算

HistogramAnalyzer 对每个通道计算 5 个统计量:

3.1 均值(Mean)

swift 复制代码
func mean(histogram: [Int], totalPixels: Int) -> Double {
    let sum = histogram.enumerated().reduce(0) { acc, pair in
        acc + pair.offset * pair.element
    }
    return Double(sum) / Double(totalPixels)
}

均值代表图像的平均亮度。均值 < 100 通常意味着欠曝,> 160 通常意味着过曝。

3.2 标准差(Std Dev)

swift 复制代码
func stdDev(histogram: [Int], mean: Double, totalPixels: Int) -> Double {
    let variance = histogram.enumerated().reduce(0.0) { acc, pair in
        let diff = Double(pair.offset) - mean
        return acc + diff * diff * Double(pair.element)
    }
    return sqrt(variance / Double(totalPixels))
}

标准差代表对比度。标准差 < 20 = 低对比度;> 60 = 高对比度。

3.3 中位数(Median)------通过累积分布求

swift 复制代码
func median(histogram: [Int], totalPixels: Int) -> Int {
    let target = totalPixels / 2
    var cumulative = 0
    for (value, count) in histogram.enumerated() {
        cumulative += count
        if cumulative >= target {
            return value   // 累积分布超过 50% 的第一个值
        }
    }
    return 255
}

中位数比均值更鲁棒:一小块过曝区域(几百个像素值 = 255)会把均值拉高,但对中位数影响极小。

3.4 香农熵(Shannon Entropy)

swift 复制代码
func entropy(histogram: [Int], totalPixels: Int) -> Double {
    let n = Double(totalPixels)
    return histogram.reduce(0.0) { acc, count in
        guard count > 0 else { return acc }
        let p = Double(count) / n
        return acc - p * log2(p)   // 香农熵公式:-Σ p·log₂(p)
    }
}

熵的单位是 bits,含义是:平均每个像素需要多少位信息来描述其亮度。

熵值范围 含义 典型图像
0 -- 1 bit 极低信息量 纯色图、渐变背景
3 -- 5 bits 中等信息量 简单场景、平坦背景
6 -- 7 bits 高信息量 自然照片、复杂场景
接近 8 bits 近似均匀分布 均衡化后的图像,或白噪声

为什么熵对图像质量评估有用?

压缩效率依赖信息熵。熵 ≈ 8 bits 的图像(接近均匀分布)意味着每个亮度值出现概率相近,JPEG 压缩率会很差(因为无冗余可压缩)。熵 ≈ 3--5 bits 的图像有大量冗余,压缩率高。


四、Otsu 阈值算法完整推导

Otsu 算法自动寻找最佳二值化阈值,目标是最大化类间方差(背景像素组 vs 前景像素组之间的差异)。

4.1 为什么用类间方差,不用类内方差?

scss 复制代码
类内方差 = 每组内部的像素值分散程度
  → 最小化类内方差 = 让每组的像素值尽量相似(聚类)

类间方差 = 两组均值的差异
  → 最大化类间方差 = 让两组尽量分开

数学关系:总方差 = 类内方差 + 类间方差(恒等式)
  ∴ 最大化类间方差 ≡ 最小化类内方差
  但类间方差只用 2 个数(两组的均值和权重),计算 O(256)
  类内方差需要遍历所有像素,计算 O(N)
  → Otsu 选择类间方差,因为计算更高效

4.2 类间方差公式推导

设阈值为 t(0--255),将所有像素分为两类:

  • 背景(Background):像素值 ≤ t
  • 前景(Foreground):像素值 > t

对于阈值 t

css 复制代码
ωB(t) = Σ_{i=0}^{t} p(i)          背景像素的比例
ωF(t) = Σ_{i=t+1}^{255} p(i)     前景像素的比例(= 1 - ωB)

μB(t) = Σ_{i=0}^{t} i·p(i) / ωB  背景像素的均值
μF(t) = Σ_{i=t+1}^{255} i·p(i) / ωF   前景像素的均值

类间方差:
σ²_B(t) = ωB × ωF × (μB - μF)²

Otsu 算法:穷举所有可能的 t(0--254),选择使 σ²_B(t) 最大的那个。

4.3 Swift 实现

swift 复制代码
func otsuThreshold(histogram: [Int], totalPixels: Int) -> Int {
    let n = Double(totalPixels)

    // 预计算:总均值
    let totalMean = (0..<256).reduce(0.0) { $0 + Double($1) * Double(histogram[$1]) / n }

    var maxVariance = 0.0
    var bestThreshold = 128   // 默认值

    var omegaB = 0.0   // 背景比例(累积)
    var muB    = 0.0   // 背景均值×背景比例(累积,方便更新)

    for t in 0..<255 {
        omegaB += Double(histogram[t]) / n
        muB    += Double(t) * Double(histogram[t]) / n

        let omegaF = 1.0 - omegaB
        guard omegaB > 0, omegaF > 0 else { continue }

        // 前景均值 = (总均值×1 - 背景均值×背景比例) / 前景比例
        let meanBg = muB / omegaB
        let meanFg = (totalMean - muB) / omegaF

        let variance = omegaB * omegaF * (meanBg - meanFg) * (meanBg - meanFg)
        if variance > maxVariance {
            maxVariance = variance
            bestThreshold = t
        }
    }
    return bestThreshold
}

时间复杂度:O(N + 256) ≈ O(N)。先扫描一次图像建直方图(O(N)),再穷举 256 个阈值(O(256),常数)。


五、归一化 CDF------手动直方图均衡

**直方图均衡(Histogram Equalization)**的目标:把直方图从任意形状变成均匀分布,最大化对比度。

markdown 复制代码
原始直方图(集中在中间):   均衡后的直方图(近似均匀):
    ████                          ██ ██ ██ ██ ██ ██ ██ ██ ██
 ██████████                      ██ ██ ██ ██ ██ ██ ██ ██ ██
─────────────                   ─────────────────────────────
0          255                  0                           255

均衡变换通过**归一化累积分布函数(CDF)**实现:

swift 复制代码
func buildEqualizedLUT(histogram: [Int], totalPixels: Int) -> [UInt8] {
    // Step 1:构建 CDF
    var cdf = [Int](repeating: 0, count: 256)
    cdf[0] = histogram[0]
    for i in 1..<256 {
        cdf[i] = cdf[i - 1] + histogram[i]
    }

    // Step 2:找到 CDF 的最小非零值(用于归一化)
    let cdfMin = cdf.first(where: { $0 > 0 }) ?? 1

    // Step 3:生成查找表(LUT)
    // 公式:equalized(v) = round((cdf(v) - cdfMin) / (N - cdfMin) × 255)
    return (0..<256).map { v in
        let numerator = cdf[v] - cdfMin
        let denominator = totalPixels - cdfMin
        if denominator <= 0 { return UInt8(v) }
        return UInt8(min(255, numerator * 255 / denominator))
    }
}

// 应用 LUT 到图像
func applyLUT(_ lut: [UInt8], to bitmap: inout MLBitmap) {
    for i in stride(from: 0, to: bitmap.pixels.count, by: 4) {
        bitmap.pixels[i]     = lut[Int(bitmap.pixels[i])]     // R
        bitmap.pixels[i + 1] = lut[Int(bitmap.pixels[i + 1])] // G
        bitmap.pixels[i + 2] = lut[Int(bitmap.pixels[i + 2])] // B
        // Alpha 不变
    }
}

注意 :对 RGB 三通道分别均衡会导致色偏。正确做法是只对亮度通道(如 HSV 的 V、或 Lab 的 L)做均衡,保持色相不变。


六、实际应用:图像质量评估

HistogramAnalyzer 是 Phase 1 estimateQuality(Day 9)的升级版,通过多维度指标评估图像质量:

swift 复制代码
struct ImageQualityReport {
    let lumaEntropy: Double      // 亮度熵:信息量(bits)
    let contrast:    Double      // 对比度(亮度标准差)
    let exposure:    Double      // 曝光偏差(均值偏离 128 的程度)
    let otsuThresh:  Int         // Otsu 阈值(判断前景/背景分界)

    /// 综合质量评分(0--100)
    var qualityScore: Int {
        var score = 100.0

        // 曝光扣分:均值偏离 128 超过 40 开始扣分
        let exposurePenalty = max(0, abs(exposure - 128) - 40) * 0.5
        score -= exposurePenalty

        // 对比度扣分:标准差 < 20 视为低对比度
        if contrast < 20 { score -= (20 - contrast) * 1.5 }

        // 信息量:熵 < 4 视为内容单调
        if lumaEntropy < 4 { score -= (4 - lumaEntropy) * 5 }

        return max(0, min(100, Int(score.rounded())))
    }
}

动态 JPEG 质量(与 Day 9 联动):

swift 复制代码
func recommendJPEGQuality(report: ImageQualityReport) -> Int {
    // 高信息量图像用高质量(信息不能丢失)
    if report.lumaEntropy > 6.5 { return 92 }
    // 低对比度图像(如纯色背景)用低质量(低熵 = 高压缩率)
    if report.contrast < 25 { return 72 }
    return 85   // 默认质量
}

七、HistogramAnalyzer 的完整实现结构

swift 复制代码
public struct HistogramAnalyzer {

    public struct ChannelStats {
        public let histogram: [Int]   // 256 个桶
        public let mean:      Double
        public let stdDev:    Double
        public let median:    Int
        public let entropy:   Double
    }

    public struct Result {
        public let r, g, b, luma: ChannelStats
        public let otsuThreshold: Int   // 基于亮度直方图的 Otsu 阈值
        public let normalizedCDF: [Double]   // 归一化 CDF(用于均衡化)
    }

    public static func analyze(_ bitmap: MLBitmap) -> Result {
        // Phase 1:单次扫描,同时更新 4 个通道的 256 桶计数
        var rHist  = [Int](repeating: 0, count: 256)
        var gHist  = [Int](repeating: 0, count: 256)
        var bHist  = [Int](repeating: 0, count: 256)
        var yHist  = [Int](repeating: 0, count: 256)
        let total  = bitmap.width * bitmap.height

        for i in stride(from: 0, to: bitmap.pixels.count, by: 4) {
            let r = bitmap.pixels[i]
            let g = bitmap.pixels[i + 1]
            let b = bitmap.pixels[i + 2]
            let y = UInt8((54 * UInt16(r) + 183 * UInt16(g) + 18 * UInt16(b)) >> 8)
            rHist[Int(r)] += 1
            gHist[Int(g)] += 1
            bHist[Int(b)] += 1
            yHist[Int(y)] += 1
        }

        // Phase 2:对每个直方图计算统计量(4 次独立计算)
        func stats(_ hist: [Int]) -> ChannelStats { /* ... */ }

        let otsu = otsuThreshold(histogram: yHist, totalPixels: total)
        let cdf  = buildNormalizedCDF(histogram: yHist, totalPixels: total)

        return Result(r: stats(rHist), g: stats(gHist), b: stats(bHist),
                      luma: stats(yHist), otsuThreshold: otsu, normalizedCDF: cdf)
    }
}

单次扫描的重要性:对于 1200 万像素的图像,扫描一次 = 4800 万次内存读取。如果对 R/G/B/Y 分别扫描四次,总读取量翻 4 倍,内存带宽成为瓶颈。单次扫描把 4 个通道的计数合并,内存访问量不变,处理时间减少约 3 倍。


八、小结

概念 核心内容
直方图 像素值的频率分布,从中读出曝光、对比度、色调分布
BT.709 整数亮度 Y = (54R + 183G + 18B) >> 8,避免浮点运算
均值/标准差 曝光程度 / 对比度的量化指标
中位数 比均值鲁棒,通过累积分布函数求取
香农熵 信息量(bits);自然照片 ≈ 6--7,噪点图 ≈ 8
Otsu 阈值 最大化类间方差;O(N+256);不用类内方差(计算量 O(N))
归一化 CDF 直方图均衡的核心变换,对亮度通道应用避免色偏
单次扫描 同时更新 4 通道计数,减少 3 倍内存访问

♥️喜欢我的内容,欢迎大家点赞、转发、关注。 ♥️专注于技术+投资+认知三位一体的内容分享。

往期推荐:

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

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

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

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

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

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

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

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

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

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

决策矩阵

二阶思维

决策日志

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

相关推荐
怕浪猫2 小时前
FDE 如何正确解决现场项目工程性的问题
前端·后端·ai编程
Goboy3 小时前
多模态大模型已经能识别和描述视频,为什么仍然缺乏可靠的时序感知能力?
aigc·ai编程
烽火戏诸诸诸侯4 小时前
AI 让我的独门小工具焕发第二春
后端·ai编程·vibecoding
kyriewen5 小时前
Claude Code 的额度今天缩水了17%——官方公告上写的是"永久提高25%"
前端·ai编程·claude
程语AI8 小时前
RAG 分块策略实测:chunk\_size 和 overlap 怎么调,检索精度差多少
ai编程
ServBay8 小时前
xAI的 Grok Bot发布,AI 已经学会自己上班了
后端·ai编程·grok
zzjyr9 小时前
AI 问答的流式响应是怎么实现的?从 fetch 到 SSE 逐字解析
前端·人工智能·ai编程
挖掘狂人12 小时前
从 LLM 到 Agent Skill:9 个底层概念,一次理清整个 AI 技术栈
llm·agent·ai编程
凭君语未可12 小时前
我把 Codex 变成了一个本地 API:Codex Proxy 配置实战
ai编程
sarasuki12 小时前
Agent 陷入死循环了怎么办?指纹 + 滑动窗口 + 分层熔断
人工智能·ai编程