
最近在 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 通道建议范围说明L30 ~ 85覆盖从深肤色到浅肤色的主要区间a8 ~ 32偏红方向低于 0 会进入绿色区域b12 ~ 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_samples24, seed42): 在 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_pathskin_tones.png, swatch_size200): 把一组 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_samples8, seed7, sigma_l4.0, sigma_a2.0, sigma_b3.0): 在基准肤色的 Lab 坐标附近做高斯扰动生成系列变体。 base_rgb np.asarray(base_rgb, dtypenp.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(fRGB({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, dtypenp.float64) - np.asarray(lab2, dtypenp.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在目标设备上