尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

用LCH颜色空间生成多样化自然肤色的完整算法与Python实现

用LCH颜色空间生成多样化自然肤色的完整算法与Python实现 之前在业务迭代中做智能头像生成与虚拟角色创建功能时一直卡在一个看起来很小的问题上怎么用代码生成一批“看起来是人脸肤色”的颜色网上现成方案要么直接给十几个固定肤色值要么在 RGB 空间里随机采样结果经常出现泛绿、泛灰、冷色过度等一眼假的情况。后来我把颜色空间和采样策略重新梳理了一下用一套很小的算法就稳定产出了一系列多样化且自然的肤色。这篇文章把完整思路、颜色空间选择、可运行代码和常见坑点整理出来适合需要做头像生成、虚拟角色、设计系统、图像算法测试样本的开发者参考。读完这篇文章你会掌握三件事第一为什么不能直接在 RGB 里随机生成肤色第二如何用 LAB/LCH 颜色空间来约束“肤色感”第三一个可复现的 Python 肤色生成器代码以及生产环境下的质量控制思路。1. 为什么需要“生成多样化肤色”这一能力1.1 从实际需求场景说起“生成肤色”并不是一个偏门需求很多场景都会用到。最常见的场景是头像生成与默认头像。系统给用户分配默认头像时如果所有头像都是同一种肤色视觉上会显得单调也缺少包容性。更合理的做法是让头像底色、插画中人像的肤色在一个自然范围内随机变化。第二个场景是虚拟角色创建与游戏捏脸。玩家需要一个肤色选择器选择范围当然不能只是“白、黄、黑”三档而是希望有一块连续的颜色区域让用户可以自由滑动找到自己满意的肤色。第三个场景是图像算法的测试样本生成。我们在做肤色检测、人脸检测、人像分割时需要一批标注样本。与其去互联网上收集大量人脸图片并处理隐私合规问题不如先合成一批肤色色块用来验证算法在不同肤色上的鲁棒性。第四个场景是设计系统与 UI 占位图。设计师做用户列表原型时需要一些不同颜色的人像占位图肤色就是其中的重要变量。1.2 “肤色生成”和“随机颜色生成”完全不是一回事如果只是要随机生成颜色用random.random()为 R、G、B 分别取一个值即可。但肤色完全不同。肤色在色度学上是连续分布的区域但绝对不是 RGB 颜色立方体里的一个大正方体。真实人脸肤色大致满足一个规律R 分量通常大于 G 分量G 分量通常又大于 B 分量。也就是说肤色基本落在“红橙黄”这个象限里而不是均匀分布在整个 RGB 空间。如果我们直接在 RGB 空间里均匀采样大概率得到的是青灰色、墨绿色、亮紫色而不是肤色。即便加上R G B这样的约束生成结果仍然可能非常刺眼因为 RGB 三个通道的取值存在很强的相关性简单地用大小关系约束无法捕捉到肤色那种“温暖、自然”的观感。1.3 对“多样性”的理解很多人提到“多样化肤色”第一反应是在色相上做文章把色相环拉开生成红、黄、绿、蓝各种颜色。这其实是一种误解。从色度学来看人类肤色的色相范围相对集中主要在红橙到黄橙之间的一个扇形区域内。肤色的“多样性”更多体现在两个维度上亮度Lightness从很深的褐色、棕色到中间偏亮的黄褐再到接近浅粉的亮肤色形成连续的明暗梯度。饱和度Saturation同一亮度下有些肤色偏红润有些偏橄榄色有些饱和度偏低这会造成不同观感。所以说一个好的肤色生成算法不应该把色相拉开而应该在窄色相区间内用亮度和饱和度去覆盖尽可能多的变化。这正是后面选择 LAB/LCH 颜色空间的原因。2. 颜色空间怎么选RGB、YCbCr、LAB/LCH2.1 RGB最容易理解但最不适合做肤色约束RGB 是一种面向显示设备的颜色模型R、G、B 三个通道分别代表红、绿、蓝光的强度。它直观但不适合做颜色语义判断。在肤色约束时我们会发现一个尴尬的问题RGB 的三个通道相关性极强修改一个通道人的观感变化并不直观。比如某个肤色是(204, 160, 130)我如果把 B 从 130 加高到 180颜色立刻开始偏紫如果把 G 从 160 降到 120颜色又会发暗发脏。这种“牵一发动全身”的特性让开发者很难为“肤色”定义一个清晰的边界。所以RGB 更适合作为最终输出格式不适合作为生成空间。2.2 YCbCr经典肤色检测空间的启发在做肤色检测时YCbCr 是非常经典的选择。它把颜色拆成Y亮度分量Cb蓝色色度分量Cr红色色度分量肤色检测的研究者发现在 YCbCr 空间中肤色像素的 Cb 和 Cr 会集中在一个相对稳定的范围内。一个常见的工程经验范围是分量常见范围Cr133 ~ 173Cb77 ~ 127这意味着我们可以用 YCbCr 做肤色过滤把像素转换到 YCbCr判断 Cr 和 Cb 是否落在上述范围里如果落在就认为它是肤色候选。这个思路对“生成”也有启发。我们可以先生成 YCbCr 三通道的值再转换回 RGB。但直接用 YCbCr 生成依然有一个问题Cr 和 Cb 的取值范围与最终观感的对应关系不够直觉而且不同人的肤色范围并不完全标准容易生成边界偏色。因此我会把 YCbCr 作为“检验器”而不是主要生成空间。2.3 LAB/LCH更贴近感知的生成空间LAB 是 CIE 制定的一种与设备无关的颜色空间其中L 代表亮度范围通常是 0~100。a 代表从绿色到红色的分量。b 代表从蓝色到黄色的分量。LCH 则是对 LAB 做极坐标变换得到的表示L 仍然是亮度。C 是色度Chrominance离中心越远颜色越浓烈可以理解为饱和度。H 是色相角Hue单位是度数。为什么 LCH 适合生成肤色因为它把“亮不亮”“浓不浓”“偏哪个色相”三个属性拆开了。我们生成肤色时不需要随机散落在一个三维空间里而是可以把色相 H 限制在肤色所在的橙红色区域。让亮度 L 覆盖从深到浅。让色度 C 覆盖低饱和到中等饱和。这种方式生成的颜色要么是深棕要么是浅粉要么是健康的小麦色天然就是“肤色视觉”很少会出现偏蓝偏绿的异常值。3. 生成算法的总体设计思路3.1 范围应该来自数据标定而不是拍脑袋在给出具体算法前必须强调一个原则肤色范围没有绝对标准。不同数据集、不同光照条件、不同显示器下肤色分布会有差异。更好的做法是先收集一批人工确认过的肤色样本把它们从 sRGB 转换到 Lab/LCH然后统计 L、C、H 的分位数把 1%~99% 的范围作为生成范围。这样得到的肤色生成器才是贴合自己业务场景的。本文为了演示给出一个工程经验近似范围你可以直接跑通也可以在项目里用自己的数据重新标定。3.2 参数化用 LCH 三个维度描述肤色我在代码中使用的范围如下参数范围含义L亮度30 ~ 88覆盖深棕到浅粉C色度10 ~ 50低饱和到中等饱和H色相角25° ~ 60°偏橙红到偏黄的色相区域在这个范围内L30 附近会生成比较深的棕褐色肤色L80 以上会生成偏亮的浅肤色C 较小时颜色偏灰偏自然C 较大时颜色偏红润。3.3 生成流程整个生成流程可以拆成五步在 LCH 空间中根据三个维度的范围均匀采样。把 LCH 转换到 LAB。把 LAB 转换到 sRGB。将 sRGB 结果转回 LAB比较原始 LAB 与转换后 LAB 的差异。如果差异在阈值内接受这个颜色否则重新采样。第 4 步很多人会忽略但它是生产环境的关键。因为 LAB 颜色空间的范围大于 sRGB 显示器的色域范围某些 LCH 组合转换到 sRGB 后会被“压缩”或“削波”导致颜色失真。通过转回校验我们可以过滤掉那些超出 sRGB 色域的候选颜色保证输出结果在任何常规屏幕上都能正常显示。3.4 为什么不直接用固定肤色表可能有人会问既然肤色范围这么窄我直接准备 20 个手工挑选的色值够不够在简单场景下这当然够用。但固定肤色表有三个问题离散程度不够用户拿不到连续的颜色选择器手工维护成本高换一个显示器或换一批目标用户表就要重新调无法做无缝的随机变化比如头像系统中每个用户都想要一个“看起来不一样但都自然”的颜色固定表很快就会被重复使用。算法生成的连续分布可以弥补上述不足。4. 完整代码实现4.1 项目结构我们先创建一个干净的示例项目skin-tone-generator/ ├── color_utils.py ├── skin_tone.py ├── visualize.py └── requirements.txt文件作用color_utils.py包含 sRGB、YCbCr、Lab、LCH 的互相转换skin_tone.py包含肤色生成器核心逻辑visualize.py用 matplotlib 可视化生成结果requirements.txt项目依赖4.2 颜色空间转换工具文件color_utils.py颜色空间转换工具sRGB / YCbCr / Lab / LCH import math # ---------- sRGB - YCbCr (BT.601全范围) ---------- def rgb_to_ycbcr(r, g, b): 将 RGB(0-255) 转换为 YCbCr y 0.299 * r 0.587 * g 0.114 * b cb -0.168736 * r - 0.331264 * g 0.5 * b 128.0 cr 0.5 * r - 0.418688 * g - 0.081312 * b 128.0 return y, cb, cr def ycbcr_to_rgb(y, cb, cr): 将 YCbCr 转换回 RGB(0-255) r y 1.402 * (cr - 128.0) g y - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0) b y 1.772 * (cb - 128.0) return _clip_rgb(r, g, b) def _clip_rgb(r, g, b): def _clip(x): return 0.0 if x 0 else (255.0 if x 255 else x) return _clip(r), _clip(g), _clip(b) # ---------- sRGB - XYZ / Lab ---------- def srgb_to_xyz(r, g, b): 将 sRGB(0-255) 转换为 XYZ 空间D65 白点 def _pivot(v): v v / 255.0 if v 0.04045: return ((v 0.055) / 1.055) ** 2.4 return v / 12.92 r1, g1, b1 _pivot(r), _pivot(g), _pivot(b) x 0.4124564 * r1 0.3575761 * g1 0.1804375 * b1 y 0.2126729 * r1 0.7151522 * g1 0.0721750 * b1 z 0.0193339 * r1 0.1191920 * g1 0.9503041 * b1 return x, y, z def xyz_to_lab(x, y, z): 将 XYZ 转换为 Lab xn, yn, zn 0.95047, 1.00000, 1.08883 def _f(t): delta 6.0 / 29.0 if t delta ** 3: return t ** (1.0 / 3.0) return t / (3.0 * delta ** 2) 4.0 / 29.0 fx, fy, fz _f(x / xn), _f(y / yn), _f(z / zn) L 116.0 * fy - 16.0 a 500.0 * (fx - fy) b 200.0 * (fy - fz) return L, a, b def rgb_to_lab(r, g, b): sRGB(0-255) 转 Lab x, y, z srgb_to_xyz(r, g, b) return xyz_to_lab(x, y, z) def lab_to_xyz(L, a, b): Lab 转 XYZ delta 6.0 / 29.0 def _finv(t): if t delta: return t ** 3 return 3.0 * delta ** 2 * (t - 4.0 / 29.0) fy (L 16.0) / 116.0 fx fy a / 500.0 fz fy - b / 200.0 xn, yn, zn 0.95047, 1.00000, 1.08883 return _finv(fx) * xn, _finv(fy) * yn, _finv(fz) * zn def xyz_to_srgb(x, y, z): XYZ 转 sRGB(0-1) def _clip01(v): return 0.0 if v 0 else (1.0 if v 1 else v) r _clip01(3.2404542 * x - 1.5371385 * y - 0.4985314 * z) g _clip01(-0.9692660 * x 1.8760108 * y 0.0415560 * z) b _clip01(0.0556434 * x - 0.2040259 * y 1.0572252 * z) def _gamma(v): if v 0.0031308: return 12.92 * v return 1.055 * (v ** (1.0 / 2.4)) - 0.055 return _gamma(r), _gamma(g), _gamma(b) def lab_to_rgb(L, a, b): Lab 转 sRGB(0-1) x, y, z lab_to_xyz(L, a, b) return xyz_to_srgb(x, y, z) # ---------- Lab - LCH ---------- def lab_to_lch(L, a, b): Lab 转 LCHH 单位为度 c math.hypot(a, b) h math.degrees(math.atan2(b, a)) % 360.0 return L, c, h def lch_to_lab(L, c, h): LCH 转 Lab h_rad math.radians(h) return L, c * math.cos(h_rad), c * math.sin(h_rad)这里每个函数都保留纯函数风格不依赖第三方库。说明rgb_to_ycbcr和ycbcr_to_rgb用于后面做肤色范围校验。rgb_to_lab和lab_to_rgb是核心转换用于 LCH 生成后得到 RGB。lab_to_lch和lch_to_lab用于在极坐标下操作颜色。4.3 肤色生成器文件skin_tone.py肤色生成器在 LCH 颜色空间中采样并转回 sRGB import random from color_utils import lab_to_lch, lab_to_rgb, lch_to_lab, rgb_to_lab class SkinToneGenerator: 生成具有多样性且观感自然的肤色 def __init__(self, seedNone): self.rng random.Random(seed) # LCH 经验范围 self.L_range (30.0, 88.0) self.C_range (10.0, 50.0) self.H_range (25.0, 60.0) # 重采样控制 self.max_retry 60 self.delta_threshold 8.0 def _sample_lch(self): 随机采样一个 LCH 颜色 L self.rng.uniform(*self.L_range) C self.rng.uniform(*self.C_range) H self.rng.uniform(*self.H_range) return L, C, H def _is_valid(self, target_lch, rgb01): 判断 RGB 转回 Lab/LCH 后与目标的偏差是否可接受 r round(rgb01[0] * 255.0) g round(rgb01[1] * 255.0) b round(rgb01[2] * 255.0) L2, a2, b2 rgb_to_lab(r, g, b) L2, c2, h2 lab_to_lch(L2, a2, b2) delta_l abs(target_lch[0] - L2) delta_c abs(target_lch[1] - c2) delta_h abs(target_lch[2] - h2) return max(delta_l, delta_c, delta_h) self.delta_threshold def generate_one(self): 生成一个肤色返回 RGB(0-1) for _ in range(self.max_retry): target_lch self._sample_lch() L, a, b lch_to_lab(*target_lch) rgb lab_to_rgb(L, a, b) if all(0.0 v 1.0 for v in rgb) and self._is_valid(target_lch, rgb): return rgb # 兜底颜色浅棕色避免重试过多时返回 None return (0.85, 0.65, 0.55) def generate(self, n): 批量生成 n 个肤色 return [self.generate_one() for _ in range(n)]这段代码有几个值得注意的地方self.rng使用random.Random(seed)而不是直接用全局random。这样可以通过固定种子复现结果方便测试和 UI 预览。_is_valid方法会执行“RGB 转回 Lab”的校验。因为从 Lab 转到 sRGB 时可能会发生色域压缩校验可以把明显失真的样本筛掉。max_retry控制重试次数。当 LCH 中采样的颜色超出 sRGB 色域太远时继续重试如果始终失败返回一个安全的兜底色。4.4 可视化验证文件visualize.py生成 3x3 肤色色板验证效果 import matplotlib.pyplot as plt from skin_tone import SkinToneGenerator def main(): generator SkinToneGenerator(seed42) colors generator.generate(9) fig, axes plt.subplots(3, 3, figsize(8, 8)) fig.suptitle(Diverse Skin Tones Generated by LCH Sampling, fontsize14) for ax, color in zip(axes.flat, colors): ax.set_facecolor(color) ax.set_xticks([]) ax.set_yticks([]) r, g, b [round(v * 255) for v in color] ax.set_title(fRGB({r}, {g}, {b}), fontsize11) plt.tight_layout() plt.show() if __name__ __main__: main()文件requirements.txtmatplotlib3.5这里没有列 numpy因为我们自己写的转换函数已经够用不依赖额外数值计算库。matplotlib 会自带 numpy 依赖。4.5 运行与效果说明在项目根目录执行pip install -r requirements.txt python visualize.py预期输出是一个 3x3 的色块图每一格的标题显示了对应的 RGB 值。因为使用了seed42每次运行生成的色板完全相同。如果去掉seed42每次运行会得到不同的随机结果但整体观感会保持“看起来都是肤色”的约束。我自己跑这个示例时生成的 9 个颜色的 RGB 大致会分布在以下类型靠近(170, 120, 90)的中等偏暖肤色靠近(230, 190, 160)的浅肤色靠近(90, 55, 30)的深褐色肤色以及少量饱和度偏高的红润肤色。实际值会因为你使用的 LCH 范围和随机种子不同而略有差异。5. 常见问题与排查思路5.1 生成结果偏灰、偏绿问题现象常见原因解决思路颜色偏灰C 值太低提高 C 范围下限比如从 10 调到 15颜色偏绿H 值偏大把 H 范围上限从 60 度降到 50 度颜色偏蓝H 值偏小且超出范围确认 LCH 转换公式中 atan2 的角度范围正确偏灰的本质是饱和度太低导致颜色接近中性灰偏绿则是色相跑到黄色以外。遇到这类问题优先检查 LCH 采样范围。5.2 RGB 转换后越界Lab 空间比 sRGB 色域更大。当我们设置过高的 C 值和较高的 H 值时转换后的 RGB 可能会小于 0 或大于 1。代码中的xyz_to_srgb会做 clip但这会让颜色失真。排查思路在_is_valid中把delta_threshold调小一点比如从 8 调到 5。你会发现生成结果里被丢弃的颜色变多但留下来的颜色更接近真实肤色。如果想提高成功率可以把 C 范围下调或者把 L 范围收窄。5.3 生成结果过于集中如果连续多次生成结果看起来都差不多说明 LCH 参数范围太窄。可以尝试扩大 L 范围从(35, 85)调整为(25, 90)扩大 C 范围覆盖低饱和与中高饱和保持 H 范围不变因为肤色色相范围本来就不宽。注意不要把所有维度都无脑扩大否则生成结果很快会脱离“肤色”语义。5.4 在不同显示器上看起来不一样肤色是高度依赖显示设备的颜色。同一组 RGB 值在广色域屏、普通 sRGB 屏、手机屏上观感会不同。解决思路是在生成环节就明确色彩标准本文采用的 D65 白点 sRGB 色域是目前 Web 和桌面端最通用的组合。如果你的项目会输出印刷品需要把转换函数替换为 CMYK 相关流程如果项目面向 P3 色域则需要重写 RGB 到 XYZ 的矩阵。5.5 其他常见问题汇总问题现象常见原因解决思路固定 seed 下结果不同random 模块被全局干扰检查是否在别处调用了random.seed()生成速度慢校验失败率太高收窄 LCH 范围或调大delta_threshold返回 None重试逻辑没写兜底参考本文代码添加 fallback 颜色6. 最佳实践与工程建议6.1 把颜色空间转换封成独立模块颜色空间转换代码非常容易被多个模块复用。建议像本文一样单独拆成color_utils.py并补充单元测试。测试时可以用一些已知颜色做 round-trip 验证def test_round_trip(): from color_utils import lab_to_rgb, rgb_to_lab rgb (200, 140, 100) L, a, b rgb_to_lab(*rgb) r, g, b lab_to_rgb(L, a, b) r, g, b round(r * 255), round(g * 255), round(b * 255) assert abs(r - rgb[0]) 2 assert abs(g - rgb[1]) 2 assert abs(b - rgb[2]) 2如果可以尽量使用成熟的色彩科学库例如colour-science、colorspacious它们对白点、色域映射、色差公式的实现更完善。本文手写公式是为了让你看清原理生产项目建议在核心流程中使用成熟库。6.2 校验器是生产环境的底线肤色生成器一旦上线用户会随机触发很多组合。如果没有校验器偶尔可能出现一个偏色严重的异常值影响产品质感。我的建议是在生成器对外暴露的接口里不要只返回 RGB而是同时返回“这次生成是否通过了色域校验”。如果校验失败前端可以重新请求或者降级到上次成功的结果。6.3 用固定随机种子支持可复现测试阶段一定要支持传入 seed。否则测试同学一旦发现某个肤色有 bug你无法快速复现。最简单的做法就是构造函数接收seedNone在调试时传入固定值。6.4 与 UI 体系结合对比度与无障碍当肤色作为头像底色或角色皮肤时它往往会和文字、图标叠放。这时要考虑对比度。W3C 的 WCAG 2.1 建议段落文字与背景的对比度至少为 4.5:1大字可以放宽到 3:1。肤色生成器不应只输出颜色还应该提供一个辅助函数计算当前肤色上使用黑字还是白字对比度更高。一个简单的亮度计算公式是def relative_luminance(r, g, b): # sRGB - 线性空间 def linear(c): c c / 255.0 return c / 12.92 if c 0.04045 else ((c 0.055) / 1.055) ** 2.4 r_, g_, b_ linear(r), linear(g), linear(b) return 0.2126 * r_ 0.7152 * g_ 0.0722 * b_对比度公式为(L1 0.05) / (L2 0.05)取较亮的值作为 L1。6.5 注意肤色的表达边界这是一个容易被忽略的工程建议。肤色在物理上是一个连续分布不应该被简单划分为几个固定档位更不应该和身份、地域、人种标签做硬性绑定。在开发肤色选择器时文案上建议用“浅色/中等/深色”这类相对描述不要用带有价值判断的词汇。在算法层面我们生成的是颜色不是身份分类结果。如果后续把肤色生成结果用于训练模型一定要明确数据边界和用途避免因为样本偏差导致算法偏置。7. 总结与下一步学习路线这篇文章从一个具体的工程问题出发讲了三个核心点第一肤色生成不能直接在 RGB 空间里随机采样而应该借助 YCbCr 或 LAB/LCH 这类颜色空间约束语义。第二LCH 空间可以把肤色拆成亮度、饱和度、色相三个独立维度其中亮度和饱和度贡献了主要多样性。第三从 Lab 转到 sRGB 后会遇到色域压缩问题生产环境需要通过转回校验来保证颜色可用。代码层面我们完成了一个可复现的 Python 肤色生成器包含完整的颜色转换工具、生成逻辑和可视化脚本。你可以直接复制运行再根据自己的业务数据标定 LCH 范围。如果接下来想深入可以从这几个方向继续学习色彩管理理解 D65 白点、sRGB、Adobe RGB、Display P3 的矩阵转换色差公式用 CIEDE2000 替代本文简单的 delta 判断让颜色一致性评估更准确肤色检测把生成器反过来用用 YCbCr 肤色范围做真实图片中的肤色区域分割前端集成把生成器逻辑用 TypeScript 重写部署成颜色 API 服务供 H5、小程序、游戏客户端调用。实际项目中优先关注的是范围标定和色域校验这两点做到位生成结果基本就能达到上线标准。如果本文对你有帮助可以收藏备用后续再看代码也不会迷路。
返回列表