色彩空间管理

图片水印引发的问题

最近遇到了一个线上问题,在此记录一下解决过程。

用户反馈拍摄的照片在发布时出现了偏色,经过排查,定位到是图片叠加水印导致的偏色。

业务逻辑

先看看原来是怎么处理的,这里涉及到一些业务需求:

水印为 “logo @username”,除了文字字体和字号要满足要求,为了增强水印在浅色图片下的辨识度,白色文字下方要有阴影,且阴影是半透明的。从业务要求上来讲,水印是有 z 轴的,从上到下依次是:

  1. 白色文字
  2. 半透明文字阴影
  3. 底层图片

因此绘制顺序应该是:阴影 -> 白色文字。

技术实现

为了保证文字阴影呈现半透明效果,即拥有 alpha 通道,需要先将阴影绘制在一张透明图片上,以 Python 的 PIL 库为例,先生成一张透明图片:

from PIL import Image

# alpha 通道为 0,全透明
logo = Image.new('RGBA', (width, height), (0, 0, 0, 0))

为了能在透明图层上绘制,先获取一个可绘制对象,使用 ImageDraw.Draw.text 进行文字阴影绘制:

logo_draw = ImageDraw.Draw(logo)

logo_draw.text((logo_x, logo_y, logo_text,
                font=logo_font, fill=SHADOW_COLOR)

到了这里,一般会认为可以继续把白色文字继续绘制在透明图层上,但经过实际测试发现,在透明图层上直接绘制白色文字,文字的边缘会出现黑色锯齿,效果较差。

此外,白色文字不需要透明效果,这里可以先将透明图层和原图进行合成,再在合成图上绘制白色文字:

original_image = Image.open(StringIO(original_image_content))
original_image = original_image.convert('RGBA')

# image 和 logo 都要是 RGBA,否则会提示 mode 参数错误
combined = Image.alpha_composite(image, logo)

# 绘制白色文字
logo_draw = ImageDraw.Draw(combined) 
logo_draw.text((logo_x, logo_y), logo_text, font=logo_font, fill=TEXT_COLOR)

至此,一张带有用户水印的图片便生产出来了,通过测试 Prophoto RGB 描述的图片,发现图片出现了色差。

将水印图导入 Photoshop,发现通过指定颜色描述文件,可以正常显示图片,没有偏色,看来问题出在颜色描述文件上。

color-space-config

如何获取图片准确的颜色描述呢?这需要了解一些图像显示的基本原理。


色彩空间及颜色描述

几种颜色描述的色域范围:

color-space

这张图在 3C 测评圈子应该很常见。
可以发现颜色空间范围:Prophoto RGB > Adobe RGB > sRGB

JPEG 图片每个像素使用 RGB 各 8bit 表示颜色,用一个简化模型来表示:

color-space-model

因此,在同样空间限制下:sRGB 色彩空间小,但能表达比较细微的变化,过渡较为自然,aRGB 和 Prophoto RGB 色彩空间大,相邻色彩变化更剧烈,即:

颜色分辨率:Prophoto RGB < Adobe RGB < sRGB

如果原始图片使用 Prophoto RGB/aRGB 作为颜色描述,但是我们却把它按照 sRGB 进行展示,就会出现偏色。如何尽可能的还原出原始的色彩呢?

一张手机拍摄的 JPEG 图片从生产到显示的链路如下:
pic-produce-consume

考虑到很多显示设备采用 sRGB 模式进行显示,所以我们做一个转换,将 Prophoto RGB 转换为 sRGB,尽量使颜色准确:

color-space-model-convert

为此我们要解决两个问题:

  1. 什么情况下需要做转换
  2. 如何做转换

这里给出一种简单处理方式,判断有 icc_profile 就转换为 sRGB profile ,可通过 image.info.get('icc_profile') 来获取颜色描述文件,通过 ImageCms.profileToProfile 做颜色描述的转换:

import io

from PIL import Image, ImageCms

SRGB = ImageCms.createProfile('sRGB')

def get_profile(img):
    output = io.BytesIO()   
    output.write(img.info.get('icc_profile'))
    output.seek(0)
    raw_profile = ImageCms.getOpenProfile(output)     
    return raw_profile


def convert_to_srgb(image):
    if image.info.get('icc_profile', ''):
        raw_profile= get_profile(image)
        image = ImageCms.profileToProfile(image, raw_profile, SRGB)
    return image

original_image = Image.open(StringIO(original_image_content))
original_image = convert_to_srgb(original_image)

original_image = original_image.convert('RGBA')

当然,更高效的方式是:判断不是 sRGB 的 icc_profile 才进行转换。