二色反射模型(Dichromatic Reflectance Model)是计算机视觉和图形学中描述物体表面光线反射的经典模型,它认为物体表面某一点的颜色由两个部分线性叠加而成:一部分是漫反射分量,代表光线进入物体内部经过多次散射后均匀射出的颜色;另一部分是镜面反射分量,代表光线在物体表面直接反射形成的白色高光。所谓 Dichromatic2Image,就是利用这个模型,从给定的漫反射颜色出发,反向合成出一张带有真实高光效果的图像。这在材质编辑、训练数据增强、去高光算法验证等场景中非常实用。本文将详细讲解模型的数学原理,并用 Node.js 从零实现一个可运行的图像合成工具。

二色反射模型的数学原理
在RGB颜色空间中,二色反射模型可以简化成一个非常直观的线性公式。对于图像中的任意一个像素,其观测颜色 I 等于漫反射项与镜面反射项的加权和:
// 二色反射模型核心公式
// I(x, y) = m_d(x, y) * C_d + m_s(x, y) * C_s
// m_d: 漫反射强度系数(与光照角度、表面朝向有关)
// m_s: 镜面反射强度系数(与视角、光源位置有关)
// C_d: 漫反射颜色(物体本身的固有色,RGB三维向量)
// C_s: 镜面反射颜色(通常接近光源颜色,多为白色)
function dichromaticPixel(m_d, m_s, Cd, Cs) {
const r = m_d * Cd[0] + m_s * Cs[0];
const g = m_d * Cd[1] + m_s * Cs[1];
const b = m_d * Cd[2] + m_s * Cs[2];
// 钳制到 [0, 255] 区间,防止溢出
return [
Math.min(255, Math.max(0, Math.round(r))),
Math.min(255, Math.max(0, Math.round(g))),
Math.min(255, Math.max(0, Math.round(b)))
];
}这个公式的关键点在于两个系数的空间分布规律完全不同。漫反射强度 m_d 主要受表面法线与光源方向夹角的影响,变化缓慢且连续;而镜面反射强度 m_s 高度集中,只有在法线接近光源反射方向的小区域里才会出现明显数值,形成我们肉眼看到的高光斑。理解了这个差异,合成图像时就有了明确思路:用平滑函数生成 m_d 的分布图,用高斯函数生成 m_s 的分布图,两者叠加即可。
镜面反射颜色的设定也有讲究。对于塑料、陶瓷这类绝缘体材料,镜面反射光是光源颜色的直接反映,基本不携带物体本身的颜色信息,所以 C_s 通常设为白色或略带光源色温的颜色。而金属材料的镜面反射则会染上材料本身的颜色,这时候 C_s 需要与 C_d 同色系。本文实现以绝缘体为例,这也是绝大多数去高光算法的假设前提。
用Node.js实现图像合成
Node.js 本身没有内置的像素级图像操作API,但我们可以借助 sharp 库读写 PNG 或 JPEG 文件,中间的像素计算完全用纯 JavaScript 完成。下面实现一个完整示例:生成一张球体的二色反射图像,球体表面使用固定的漫反射颜色,光源位于左上方。
const fs = require('fs');
const sharp = require('sharp');
const WIDTH = 512;
const HEIGHT = 512;
// 漫反射颜色:一种偏暖的橙红色
const Cd = [200, 90, 60];
// 镜面反射颜色:接近纯白的光源色
const Cs = [255, 250, 240];
// 球体参数
const cx = WIDTH / 2, cy = HEIGHT / 2, radius = 200;
// 光源方向(已归一化)
const L = normalize([-0.5, -0.6, 0.7]);
function normalize(v) {
const len = Math.hypot(v[0], v[1], v[2]);
return [v[0] / len, v[1] / len, v[2] / len];
}
function render() {
const buf = Buffer.alloc(WIDTH * HEIGHT * 3);
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
const idx = (y * WIDTH + x) * 3;
// 计算球面法线,球外区域法线朝向观察者
const dx = (x - cx) / radius;
const dy = (y - cy) / radius;
const inside = dx * dx + dy * dy <= 1;
const nz = inside ? Math.sqrt(1 - dx * dx - dy * dy) : 0;
const N = normalize([dx, dy, nz === 0 ? 1 : nz]);
// Lambert 漫反射系数
const ndotl = Math.max(0, N[0] * L[0] + N[1] * L[1] + N[2] * L[2]);
const m_d = inside ? 0.15 + 0.85 * ndotl : 0;
// Blinn-Phong 镜面反射系数,高光指数控制光斑大小
const V = [0, 0, 1];
const H = normalize([L[0], L[1], L[2] + 1]);
const ndoth = Math.max(0, N[0] * H[0] + N[1] * H[1] + N[2] * H[2]);
const shininess = 80;
const m_s = inside ? Math.pow(ndoth, shininess) * 255 : 0;
const pixel = dichromaticPixel(m_d, m_s, Cd, Cs);
buf[idx] = pixel[0];
buf[idx + 1] = pixel[1];
buf[idx + 2] = pixel[2];
}
}
return sharp(buf, { raw: { width: WIDTH, height: HEIGHT, channels: 3 } })
.png()
.toFile('dichromatic.png');
}
render().then(() => console.log('已生成 dichromatic.png'));代码中有几个细节值得展开。首先是环境光项,公式中的 0.15 是给背光面保留的最低亮度,如果没有这一项,球体背光部分会纯黑,与真实照片差异较大。其次是 shininess 参数,它直接控制高光斑的锐利程度:数值为 20 时高光弥散成一大片,数值为 200 时则收缩成刺眼的小点,塑料材质一般取 50 到 100 之间。最后 dichromaticPixel 函数就是上一节公式直接翻译成代码,权重放大到 0 到 255 范围后做钳制。
性能方面,512 乘 512 的双循环在 Node.js 中大约几十毫秒就能完成,对于单张合成完全够用。如果要批量生成训练数据,建议把内层循环提取出来,或者改用 Float32Array 缓存中间结果,避免频繁的数组分配带来的GC压力。另外一个实用技巧是给 m_s 和 m_d 分别叠加少量高斯噪声,可以让合成结果更接近真实相机的成像质感,用于数据增强时效果尤其明显。
参数调节与反向应用:从图像中分离反射分量
掌握了正向合成之后,更有价值的应用是反向分解,即从真实图像中估计漫反射颜色 Cd,并分离出高光区域。在RGB空间里,一个没有高光的像素集合会在颜色空间中聚成一个紧凑的色块,而高光像素会沿着一条从物体颜色指向白色方向的直线漂移,这条轨迹被称为二色线。检测这条线就能反推出物体的本征颜色。
// 简化的二色线估计:对图像像素做主成分分析
// 高光像素会沿最大特征向量方向偏离漫反射色块中心
function estimateDichromaticLine(pixels) {
// pixels: [[r,g,b], ...] 采样像素
const n = pixels.length;
const mean = [0, 0, 0];
for (const p of pixels) {
mean[0] += p[0] / n;
mean[1] += p[1] / n;
mean[2] += p[2] / n;
}
// 计算协方差矩阵(3x3)
const cov = [[0,0,0],[0,0,0],[0,0,0]];
for (const p of pixels) {
const d = [p[0]-mean[0], p[1]-mean[1], p[2]-mean[2]];
for (let i = 0; i < 3; i++)
for (let j = 0; j < 3; j++)
cov[i][j] += d[i] * d[j] / n;
}
// 幂迭代求最大特征向量
let v = [1, 1, 1];
for (let iter = 0; iter < 50; iter++) {
const w = [
cov[0][0]*v[0]+cov[0][1]*v[1]+cov[0][2]*v[2],
cov[1][0]*v[0]+cov[1][1]*v[1]+cov[1][2]*v[2],
cov[2][0]*v[0]+cov[2][1]*v[1]+cov[2][2]*v[2]
];
v = normalize(w);
}
return { mean, direction: v }; // direction 即二色线方向
}得到二色线方向后,去高光的思路就清晰了:把每个像素沿着这条线向漫反射色块中心投影,投影后的坐标即为去除镜面分量后的估计值。这个方法对单张图、单一材质的场景效果不错,但如果画面中有多种颜色,需要先做颜色聚类再分组估计二色线,否则不同材质的像素混在一起会严重干扰主成分方向。
结合正向合成与反向分解,可以构建一个自验证的流程:先用本文的合成器生成已知 Cd 和 m_s 分布的图像,再跑分解算法,对比估计值与真实值的误差。这种闭环测试对调参非常有帮助,也是不少去高光论文使用的标准评测方式。把这个流程接入 Node.js 的命令行工具或 HTTP 服务,就能方便地集成到现有的图像处理流水线中。