直方图是图像的"体检报告"。 一眼看出:这张图曝光不足、对比度太低、色调偏暖——不用打开图像本身。 掌握直方图,就掌握了对图像质量"量化评估"的能力。
一、直方图的定义与信息密度
图像直方图是像素值的频率分布:统计每个亮度值(0–255)出现了多少次。
一张 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 标准)的加权亮度公式:
Y = 0.2126×R + 0.7152×G + 0.0722×B
实现中使用整数近似避免浮点乘法(提速约 3×):
// 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)
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)
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)——通过累积分布求
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)
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 为什么用类间方差,不用类内方差?
类内方差 = 每组内部的像素值分散程度
→ 最小化类内方差 = 让每组的像素值尽量相似(聚类)
类间方差 = 两组均值的差异
→ 最大化类间方差 = 让两组尽量分开
数学关系:总方差 = 类内方差 + 类间方差(恒等式)
∴ 最大化类间方差 ≡ 最小化类内方差
但类间方差只用 2 个数(两组的均值和权重),计算 O(256)
类内方差需要遍历所有像素,计算 O(N)
→ Otsu 选择类间方差,因为计算更高效
4.2 类间方差公式推导
设阈值为 t(0–255),将所有像素分为两类:
- 背景(Background):像素值 ≤ t
- 前景(Foreground):像素值 > t
对于阈值 t:
ω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 实现
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)**的目标:把直方图从任意形状变成均匀分布,最大化对比度。
原始直方图(集中在中间): 均衡后的直方图(近似均匀):
████ ██ ██ ██ ██ ██ ██ ██ ██ ██
██████████ ██ ██ ██ ██ ██ ██ ██ ██ ██
───────────── ─────────────────────────────
0 255 0 255
均衡变换通过**归一化累积分布函数(CDF)**实现:
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)的升级版,通过多维度指标评估图像质量:
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 联动):
func recommendJPEGQuality(report: ImageQualityReport) -> Int {
// 高信息量图像用高质量(信息不能丢失)
if report.lumaEntropy > 6.5 { return 92 }
// 低对比度图像(如纯色背景)用低质量(低熵 = 高压缩率)
if report.contrast < 25 { return 72 }
return 85 // 默认质量
}
七、HistogramAnalyzer 的完整实现结构
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时代,软件工程师必备概念全景图