最近在 Hacker News 上看到一个标题很短的项目:Simple algorithm and color space to generate diverse skin tones。标题虽然只有几个单词,信息量其实不小——它把"如何生成多样化的肤色"这个问题,干净利落地拆成了两个部分:算法部分,以及色彩空间部分。
如果你做过虚拟形象、游戏捏脸、数字人或者 AI 人像数据集,大概率遇到过同一个尴尬:生成出来的脸都很好看,但肤色却出奇地一致。无论用扩散模型抽卡,还是用自研 GAN 批量出图,结果往往集中在一个狭窄的肤色区间里,深肤色、暖调肤色明显不足。很多人第一反应是"模型有偏见""数据集不够多元"。这确实是原因之一,但更直接的技术原因往往被忽略了:你在控制颜色时,用的色彩空间不对。
本文不逐行复刻那个项目的源码,而是把这类方案背后的通用思路完整拆开:为什么 RGB 不适合做肤色多样性生成,Lab 色彩空间为什么更合适,完整的 Python 实现怎么写,生成结果如何量化验证,以及实际接入项目时会踩到哪些坑。读完你不仅能跑通一个最小示例,还能把它扩展成自己的肤色生成工具。
1. 这篇文章真正要解决的问题
先明确一下这类技术到底解决什么。肤色多样性的需求不只是"政治正确"式的表面功夫,而是真实存在的工程问题:
- 人脸识别模型做测试时,需要覆盖不同肤色的样本,否则测试结论会以偏概全。
- 游戏捏脸或虚拟形象系统里,用户希望看到接近自己肤色的选项,而不是清一色的浅肤色。
- UI 设计系统需要一组协调的肤色色板,覆盖浅色、中等色、深色,并且看起来都"自然"。
- 生成式模型做数据增强时,训练集需要在肤色维度上有足够的方差,否则模型学出来的肤色分布永远是偏的。
这个问题的难点在哪里?如果你天真地在 RGB 空间里随机生成颜色,很快会发现生成结果里大量出现灰色、紫色、绿色这类"不可能肤色"。手动维护一份肤色色板倒是能保证质量,但覆盖度有限,而且扩展速度慢。
这里真正的关键判断是:生成多样化肤色,算法本身可以非常简单,难点在于选对色彩空间,并在正确的空间里定义"采样范围"。一旦把颜色从 RGB 转换到 Lab 这类感知均匀的色彩空间,肤色多样性问题就退化成"在肤色区域里做随机采样"这种几分钟能写完的逻辑。
2. 色彩空间基础:为什么在 RGB 里调肤色容易翻车
2.1 RGB、HSV/HSL 与人类感知的错位
RGB 是给显示设备用的,不是给人眼感知用的。RGB 三个通道里的欧氏距离,和人眼感知到的颜色差异并不成正比。你把 RGB 的 R 通道从 20 改成 120,和从 120 改成 220,人眼感受到的"红色变化程度"是完全不一样的。直接在 R、G、B 上做加减法,属于盲人摸象。
HSV 和 HSL 比 RGB 直观一些,把颜色拆成了色相、饱和度和明度,但仍然不是感知均匀的。举例来说,HSL 里的 L 是"亮度"的某种数学定义,并不是人眼感知的"明度";S 的变化也不会线性地作用于人眼感知的饱和度。用它做颜色生成,虽然不容易出现绿色肤色,但生成出来的颜色深浅变化依然不可控。
如果你要做的是"在某个基准肤色附近生成一组看起来自然、又有差异的肤色",在 RGB 或 HSL 里做扰动,结果很容易变成"五个颜色各不相同,但没一个像人皮肤"。
2.2 Lab 与 LCH:为"感知均匀"而生的色彩空间
Lab(准确说是 CIELAB)色彩空间是国际照明委员会 CIE 提出的,专门用来模拟人眼的颜色感知。它有三个维度:
- L:明度,取值范围 0 到 100,越大越亮。
- a:红绿轴,正数偏红/品红,负数偏绿。
- b:黄蓝轴,正数偏黄,负数偏蓝。
Lab 最重要的性质是感知均匀:两个颜色在 Lab 空间里的欧氏距离,近似等于人眼感知到的差异。这个距离还有一个专门的名字叫 Delta E(色差),是颜色科学的通用度量单位。
LCH 是 Lab 的圆柱坐标版本,把 a、b 两个直角坐标换算成色相 H 和彩度 C。它的语义和 HSL 很像,但底层是感知均匀的,所以比 HSL 更适合做颜色编辑。
从 RGB 到 Lab 的转换路径通常是这样:
sRGB -> 线性 RGB -> XYZ -> Lab这条链路里有一个隐藏变量:白点(白平衡基准)。sRGB 默认使用 D65 白点,但不少颜色科学库默认用 D50。同一个颜色,在不同白点下转换出来的 Lab 坐标会不一样。实际工程里最常踩的坑之一,就是混用了不同白点假设的转换库。
2.3 肤色生成为什么优先选 Lab
回到肤色这个具体场景。皮肤颜色之间的差异,主要就两条轴线:明暗差异(从浅肤色到深肤色),以及冷暖差异(偏红、偏黄、偏中性)。这两条轴线在 Lab 空间里刚好被拆开了:
- 调整 L,就是调整肤色明暗。
- 调整 a、b,就是调整肤色偏红还是偏黄。
在 RGB 空间里,你想把肤色调深一点,需要同时小心翼翼地改三个通道,否则颜色就发青发灰;在 Lab 空间里,只需要把 L 降下来。这种"维度解耦"正是肤色生成算法能保持简洁的根本原因。
| 色彩空间 | 感知均匀性 | 适合做什么 | 不适合做什么 |
|---|---|---|---|
| RGB | 低 | 显示、存储、图像处理 | 按人眼感知做颜色生成 |
| HSV/HSL | 中 | 简单的取色器和调色板 | 精确控制肤色明暗和冷暖 |
| Lab | 高 | 感知均匀采样、色差计算 | 直接作为 UI 颜色输出 |
| LCH | 高 | 更直觉地控制色相和彩度 | 依赖 Lab,转换链路更长 |
3. 肤色在 Lab 空间中的分布与经验取值范围
3.1 肤色在色度空间中是一块区域,不是一个点
经常有人问:"肤色到底对应 Lab 里的哪个值?"这个提问方式本身就是错的。真实人类的肤色,在 Lab 空间里不是散落的几个点,而是一块连续的、形状相对稳定的区域。
如果拿 Fitzpatrick 皮肤分型做参照,I 型到 VI 型覆盖了从极浅肤色到极深肤色的范围。但实际数据集里的肤色分布是连续的,并不存在六条清晰的边界。更符合工程直觉的理解方式是:肤色在 Lab 空间里是一团狭长的"云",L 轴方向宽度很大,a、b 轴方向宽度相对有限。
这意味着一个非常重要的采样策略:不要在整个 Lab 立方体里采样,只在肤色子区域内采样。这是这类"简单算法"之所以能保持简单的核心——大部分"不可能肤色"根本不在采样范围内。
3.2 一组可用的经验采样范围
这里给一组可以作为起点的经验范围,适合在 sRGB 色彩空间下使用:
| Lab 通道 | 建议范围 | 说明 |
|---|---|---|
| L | 30 ~ 85 | 覆盖从深肤色到浅肤色的主要区间 |
| a | 8 ~ 32 | 偏红方向;低于 0 会进入绿色区域 |
| b | 12 ~ 42 | 偏黄方向;低于 0 会偏蓝 |
需要强调:这组数值是经验值,不是权威标准。不同数据集、不同光照假设、不同转换库,得到的范围会有差异。更稳妥的做法是把它作为配置项放在代码里,根据你自己业务的视觉效果反复校准。
还有一个细节:L、a、b 三个通道并不是完全独立的。偏深的肤色往往 a、b 值也偏高,而偏浅肤色的 a、b 分布会更集中。第一版算法可以直接做独立均匀采样,后面如果要提升真实感,再考虑通道之间的相关性。
4. 生成多样化肤色的算法设计思路
4.1 核心思路:把复杂度交给色彩空间
这类算法的核心原则是:能用色彩空间解决的问题,不要写进算法里。算法本身只需要三步:
- 在 Lab 空间里定义一个肤色采样区域(范围或基准点)。
- 在区域内用均匀分布或高斯分布采样 L、a、b。
- 把采样结果从 Lab 转换回 RGB,并做色域裁剪。
因为 Lab 空间解耦了明度和冷暖,第二步的采样逻辑可以极其简单。你不需要写任何复杂的约束条件,采样范围本身已经替你过滤掉了大量无效颜色。
4.2 模式一:范围采样,快速覆盖全肤色域
第一种模式是在整个肤色区域内做随机采样。适合的场景是:你需要一个覆盖面广的肤色色板,比如给 UI 设计系统生成候选色,或者给生成模型做训练数据增强。
范围采样的优点是简单、覆盖广,一次采样能看到从浅到深的大跨度变化;缺点是容易出现个别"不自然"的极端颜色,所以生成后需要人工或算法筛选。采样时也可以使用低差异序列(比如拉丁超立方采样)代替纯随机,让覆盖更均匀。
4.3 模式二:基准扰动,生成同一色系的多个变体
第二种模式是给定一个基准肤色,在它的 Lab 坐标附近做高斯扰动。适合的场景是:游戏角色需要同一角色的多个肤色变体,或者设计稿里已经确定了一个主肤色,需要一组协调的相近色。
扰动参数是标准差:
- sigma_l 控制明暗变化幅度,一般在 3 到 5。
- sigma_a 控制红绿方向的变化,一般在 1 到 2。
- sigma_b 控制黄蓝方向的变化,一般在 2 到 3。
这三组值同样是经验起点。想要生成肉眼可感知的差异,优先放大 sigma_l,因为人眼对明度差异最敏感。
4.4 色域溢出:Lab 里合法,sRGB 里可能越界
Lab 空间设计之初就覆盖了超过任何显示设备能表达的颜色范围,所以"Lab 坐标合法"不等于"转换回 sRGB 后一定正常"。转换后 RGB 值可能出现负数或大于 1 的情况,这就是色域溢出。
最简单的处理方式是直接裁剪到 [0, 1] 区间。但裁剪有一个副作用:它可能轻微改变色相,让个别颜色看起来偏灰或偏脏。第一版算法用裁剪完全够用;如果追求更高质量,可以转到线性 RGB 后再裁剪,或者借助 colour-science 这类库做更精细的色域映射。
5. 完整代码实现:从采样到色卡输出
5.1 环境准备与依赖安装
本文示例使用 Python 3 和三个库:numpy 做数组运算,scikit-image 提供 rgb2lab / lab2rgb 转换,Pillow 负责把生成的颜色保存为图片,方便肉眼检查。
python -m venv .venv source .venv/bin/activate # Windows 下使用 .venv\Scripts\activate pip install numpy scikit-image pillow版本说明:以下代码使用了 scikit-image 多年稳定的 Lab 转换 API,版本 0.19 以上均可运行。如果你的环境里版本较旧导致 API 报错,优先升级 scikit-image。
5.2 代码一:Lab 空间批量生成肤色样本
第一个示例完成范围采样,并把生成结果保存成横向色卡。
# 文件路径:generate_skin_tones.py import numpy as np from skimage import color from PIL import Image def generate_skin_tones(num_samples=24, seed=42): """在 Lab 色彩空间中按肤色经验范围采样,并转回 RGB。""" rng = np.random.default_rng(seed) # L: 明度,覆盖从深肤色到浅肤色 L = rng.uniform(30.0, 85.0, num_samples) # a: 红绿轴,肤色集中在偏红一侧 a = rng.uniform(8.0, 32.0, num_samples) # b: 黄蓝轴,肤色集中在偏黄一侧 b = rng.uniform(12.0, 42.0, num_samples) lab = np.stack([L, a, b], axis=-1) # skimage 的 lab2rgb 输入为 float 数组,输出 RGB 范围 [0, 1] rgb = color.lab2rgb(lab) # 色域裁剪,防止少量采样点超出 sRGB 可表示范围 return np.clip(rgb, 0.0, 1.0) def save_color_strip(rgb, output_path="skin_tones.png", swatch_size=200): """把一组 RGB 颜色保存为横向色卡,便于肉眼检查。""" n = len(rgb) canvas = Image.new("RGB", (n * swatch_size, swatch_size), "white") for i, color_rgb in enumerate(rgb): color_255 = tuple((np.clip(color_rgb, 0, 1) * 255).astype(int)) swatch = Image.new("RGB", (swatch_size, swatch_size), color_255) canvas.paste(swatch, (i * swatch_size, 0)) canvas.save(output_path) if __name__ == "__main__": samples = generate_skin_tones() save_color_strip(samples) print(f"已生成 {len(samples)} 个肤色样本,保存至 skin_tones.png")关键逻辑只有几行:在 Lab 空间里生成三组均匀分布随机数,拼接成坐标数组,一次性转换回 RGB。因为采样范围已经限制在肤色区域,生成结果天然不会出现绿色或紫色。保存色卡这一步不是必需的,但对于快速验证视觉效果非常有用。
5.3 代码二:基于基准肤色生成系列变体
第二个示例演示基准扰动模式。给定一个 RGB 基准肤色,转换到 Lab 后做高斯扰动,生成一组"同一个色系但明暗、冷暖略有不同"的变体。
# 文件路径:perturb_skin_tone.py import numpy as np from skimage import color def generate_variations(base_rgb, num_samples=8, seed=7, sigma_l=4.0, sigma_a=2.0, sigma_b=3.0): """在基准肤色的 Lab 坐标附近做高斯扰动,生成系列变体。""" base_rgb = np.asarray(base_rgb, dtype=np.float64) / 255.0 base_lab = color.rgb2lab(base_rgb.reshape(1, 1, 3))[0, 0] rng = np.random.default_rng(seed) L = rng.normal(base_lab[0], sigma_l, num_samples) a = rng.normal(base_lab[1], sigma_a, num_samples) b = rng.normal(base_lab[2], sigma_b, num_samples) lab = np.stack([L, a, b], axis=-1) rgb = np.clip(color.lab2rgb(lab), 0.0, 1.0) return (rgb * 255).astype(np.uint8) if __name__ == "__main__": base = (224, 172, 138) # 常见的浅肤色基准点 tones = generate_variations(base) for tone in tones: print(f"RGB({tone[0]}, {tone[1]}, {tone[2]}) " f"#{tone[0]:02x}{tone[1]:02x}{tone[2]:02x}")这个模式非常适合"在已有风格体系内做微调"。比如你的产品已经定义了一套肤色规范,现在只想在规范附近生成更多候选,就可以把基准色换成规范色,再微调三个 sigma 参数。输出里同时打印了 RGB 和 hex 色值,方便直接粘贴到设计稿或前端代码里。
5.4 代码三:用 Delta E 做差异度验证
生成一批颜色后,怎么量化判断"这批颜色是否足够多样"?一个常用的客观指标是 Delta E。这里实现最简单的 CIE76 色差公式,也就是 Lab 空间里的欧氏距离。
# 文件路径:validate_skin_tones.py import numpy as np from skimage import color from generate_skin_tones import generate_skin_tones def delta_e_cie76(lab1, lab2): """计算两个 Lab 颜色之间的欧氏距离,即 CIE76 色差。""" return float(np.sqrt(np.sum( (np.asarray(lab1, dtype=np.float64) - np.asarray(lab2, dtype=np.float64)) ** 2))) def validate(rgb): """检查生成样本的 Lab 分布与最小色差。""" lab = color.rgb2lab(rgb.reshape(-1, 1, 3)).reshape(-1, 3) min_gap = float("inf") for i in range(len(lab)): for j in range(i + 1, len(lab)): gap = delta_e_cie76(lab[i], lab[j]) if gap < min_gap: min_gap = gap print("样本数:", len(lab)) print("L 范围: {:.1f} ~ {:.1f}".format(lab[:, 0].min(), lab[:, 0].max())) print("a 范围: {:.1f} ~ {:.1f}".format(lab[:, 1].min(), lab[:, 1].max())) print("b 范围: {:.1f} ~ {:.1f}".format(lab[:, 2].min(), lab[:, 2].max())) print("样本间最小 Delta E: {:.2f}".format(min_gap)) if __name__ == "__main__": samples = generate_skin_tones() validate(samples)Delta E 数值怎么解读?可以参照颜色行业里常用的经验阈值:
| Delta E 范围 | 人眼感受 |
|---|---|
| 小于 1 | 几乎不可感知的差异 |
| 1 ~ 2 | 仔细观察才能发现 |
| 2 ~ 5 | 肉眼可以明确看出区别 |
| 大于 5 | 明显不同的颜色 |
实际使用中,如果一批样本的最小 Delta E 小于 2,说明存在肉眼几乎分不出的重复颜色,可以考虑扩大采样范围或增加样本量。
6. 运行结果与效果验证
6.1 运行命令
先运行第一个脚本生成色卡:
python generate_skin_tones.py正常会输出类似下面一行(具体数值取决于环境和随机种子,不影响判断):
已生成 24 个肤色样本,保存至 skin_tones.png再运行验证脚本:
python validate_skin_tones.py输出类似:
样本数: 24 L 范围: 30.2 ~ 84.6 a 范围: 8.4 ~ 31.7 b 范围: 12.1 ~ 41.5 样本间最小 Delta E: 1.876.2 判断生成结果是否合格
打开生成的 skin_tones.png,重点看三点:
- 色卡是否从深到浅连续铺开,而不是挤在某个明度区间。
- 所有颜色是否都是"像皮肤"的颜色,没有发灰、发绿、发紫的异常色块。
- 相邻色块之间是否肉眼可区分,不存在大片几乎一样的颜色。
如果你发现颜色整体偏亮或偏暗,直接调整代码里的 L 范围。发现颜色"不够红"或"不够黄",调整 a、b 范围。这个迭代过程非常快,因为所有逻辑都在十行代码以内。
6.3 输出 hex 颜色给设计和前端
设计系统和前端组件通常需要 hex 色值。可以给第一个脚本加一个转换函数:
def rgb_to_hex(rgb_colors): rgb_255 = (np.clip(rgb_colors, 0, 1) * 255).astype(int) return ["#{:02x}{:02x}{:02x}".format(*c) for c in rgb_255]把生成结果输出成 JSON 或 Markdown 表格,就能直接作为设计 token 或配置文件的候选值。这也是肤色色板类工具最常见的落地方式:算法产出候选色,设计和业务方再一起确认最终用哪一组。
7. 常见问题与排查思路
从 RGB 切到 Lab 后,大部分"肤色发绿发灰"的问题会消失,但新问题也会冒出来。这里整理了一份高频问题清单:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 生成颜色发灰、发紫 | 采样范围超出了肤色区域,a/b 比例失衡 | 打印 Lab 坐标,与经验范围对比 | 收窄 L/a/b 范围,或改用基准扰动模式 |
| 颜色发绿 | a 值采到了负数,或对库的 a 轴理解错误 | 检查 a 采样下限和转换 API | 把 a 下限设置为正数;统一使用同一个转换库 |
| 色卡出现明显颜色断层 | 色域裁剪导致色相漂移 | 对比裁剪前后 RGB 的差异 | 减少极端采样值,或在线性 RGB 中做裁剪 |
| OpenCV 与 scikit-image 结果对不上 | 两个库对 Lab 的编码范围不同 | 打印两个库输出的 L/a/b 数值范围 | 全流程只使用一个库,避免混合转换 |
| 同色系样本肉眼几乎分不出 | 高斯扰动方差太小 | 计算样本间的 Delta E 分布 | 调大 sigma_l,优先增加明度维度差异 |
| 导出到前端后颜色偏亮或偏暗 | 目标设备色域不同(sRGB 与 P3) | 在目标设备上 |