用直方图读一组夜间照片

从两张明暗差异明显的照片出发,用亮度分位数和裁切比例检查曝光,而不是只凭屏幕观感判断。

skqfly

skqfly

4/28/2024 · 891 words

skqfly 标志

同一张照片在手机、显示器和浏览器里可能看起来完全不同。为了把“太暗了”这种主观描述变得可比较,我保留视觉判断,同时增加三个简单指标:亮度的第 5、50、95 百分位数,以及黑白两端的裁切比例。

下面两张样片都拍摄于低照度环境,但亮部与暗部承担的作用并不相同。

夜晚的和平饭店门头,紫色立面、暖色钟面与白色招牌灯同时出现在画面中

第一张照片同时包含深色夜空、紫色建筑立面和高亮招牌。平均亮度无法说明招牌文字是否保留细节,因此更值得检查高光端是否裁切,同时观察紫色区域有没有因为压暗而丢失层次。

手持一只带红色吸管的透明饮料杯,浅色杯身位于灰色电梯背景前

第二张照片的主体是浅色透明杯,背景则是低饱和度的金属表面。这里要同时关注杯身高光和背景分离度:过度提亮会让透明材质发白,过度压暗又会让手部与电梯背景黏在一起。

把 RGB 转成可比较的亮度

我先把像素归一化到 [0,1][0,1],再按 sRGB 的线性亮度近似计算:

Y=0.2126R+0.7152G+0.0722B.Y=0.2126R+0.7152G+0.0722B.

绿色通道权重较高,符合人眼对不同波段敏感度不同这一事实。它仍然不是完整的色彩管理流程,但比直接对三个通道取平均更适合做快速检查。

from pathlib import Path

import numpy as np
from PIL import Image


def exposure_summary(path: str) -> dict[str, float]:
    image = Image.open(Path(path)).convert("RGB")
    rgb = np.asarray(image, dtype=np.float32) / 255.0
    luminance = (
        0.2126 * rgb[..., 0]
        + 0.7152 * rgb[..., 1]
        + 0.0722 * rgb[..., 2]
    )

    p05, p50, p95 = np.quantile(luminance, [0.05, 0.50, 0.95])
    return {
        "p05": float(p05),
        "p50": float(p50),
        "p95": float(p95),
        "black_clip": float(np.mean(luminance <= 1 / 255)),
        "white_clip": float(np.mean(luminance >= 254 / 255)),
    }

black_clipwhite_clip 分别统计接近纯黑、纯白的像素比例。阈值只是检查工具,不是审美规则:夜景允许有纯黑区域,逆光也不必保住每一处太阳高光。

数据只回答具体问题

样片主要观察区域优先检查的指标视觉判断
夜间门头招牌文字、紫色立面P95 与白端裁切保护灯牌细节,同时保留立面颜色层次
手持饮料杯透明杯身、手部与灰色背景P05、P50 与局部高光保留透明质感,让主体与背景清楚分离

这张表没有给照片打统一分数,因为两张照片的目标不同。统计量用于定位问题:P05 能告诉我暗部是否大面积挤在一起,P95 能提醒我亮部余量,裁切比例则帮助判断是否存在不可恢复的区域。

实际比较时还要固定三个条件:使用同一色彩空间导出、按同样的最长边缩放、去掉边框和水印。否则直方图的变化可能来自处理流程,而不是来自曝光本身。

最后仍然回到画面

数据能指出“哪里值得再看一眼”,却不能替我决定阴影是否应该保留。我的顺序是:先看照片表达什么,再用分位数检查技术风险,最后回到校准过的屏幕做局部调整。这样既不会让统计量支配审美,也不会把所有曝光问题都解释成个人风格。