反照率估计这个词听着学术,落到工程里干的活其实很朴素:把一张照片里被光照“污染”过的颜色剥掉,还原出物体本来该有的那层底色。Marigold v2 就是干这件事的一个方案——输入一张普通 RGB 图,输出一张去掉了直射光、软阴影和大部分高光影响的 albedo 图。它最吸引人的地方是不需要多视角、不需要闪光灯阵列、不需要深度相机,单张图就能跑。这篇指南面向的是想把这套东西真正塞进自己管线里的人:做重打光、材质替换、电商图规范化、AR 试穿、影视预演的工程师,以及想把 intrinsics 模块接进下游模型的研究型开发者。我会把原理、参数、代码骨架、后处理和踩过的坑一次讲透,读完之后你应该能独立跑通一条可用的反照率估计流水线,并且知道它在哪些场景下会翻车。
1. 先把问题定义清楚:反照率不是“调亮一点的照片”
1.1 一张图里到底混了几层信息
很多人第一次听到反照率,会把它理解成“去掉阴影的图”。这个理解只对了一半。一张照片的像素值,粗略可以写成I = A × S + 高光 + 间接光,其中 A 是反照率,S 是着色项,包含光源方向、强度、距离衰减和可见性。阴影、明暗过渡、环境光遮蔽,全都藏在 S 里面。除此之外还有相机响应曲线、白平衡增益、镜头暗角、传感器噪声和 JPEG 压缩。
拿一面白墙举例。夕阳斜照的时候,它在照片里是橙红色的,但没有人会说这面墙本身是橙色的。人眼有色彩恒常性,大脑会自动把光源颜色除掉,所以我们看到的是“白墙被夕阳染红”。模型没有这套先验,它看到的只是像素。反照率估计要做的,就是把那层橙色光源剥掉,还原出接近中性的墙面色。这件事对人类来说几乎无意识,对算法来说却是一个欠定问题。
1.2 单张图分解为什么注定不唯一
从方程角度看,问题一目了然。每个像素我们只知道 3 个通道的观测值,却要解出 3 通道反照率加 3 通道着色,未知数是观测数的两倍。数学上这叫欠定,解有无穷多个。你可以把整体调暗一半、把光照调亮一倍,乘积完全不变。
传统方法靠假设来补足约束。Retinex 系列假设光照在空间上是平滑的,所以把图像做高斯模糊当成光照估计,原图除以这个模糊版本就得到反射分量。这个假设在室内白墙场景勉强能用,一遇到条纹阴影、栅栏投影、彩色霓虹灯就彻底失效,因为光照根本不平滑。后来的优化方法加入稀疏性、颜色先验、纹理先验,效果提升有限,根本原因还是信息量不够。
深度学习的思路换了赛道:既然从单张图解不出来,那就从数据里学一个“什么样的反照率图看起来是合理的”的先验。这本质上是用训练集里的统计规律去填补缺失的约束,而不是真的把欠定问题变成确定问题。理解了这一点,你就能接受它的输出必然是“一个合理猜测”,而不是“唯一正确答案”。
1.3 Marigold v2 的定位和能力边界
Marigold 这一系列最早是以单目深度估计出名的,核心思路是把预训练的文生图扩散模型改造成几何估计器,冻结大部分权重,只加轻量条件输入,用合成数据微调。v2 把输出模态从深度扩展到了反照率,共享同一套“扩散先验 + 合成数据”的方法论。
需要提前说清楚它的能力边界,免得后面踩坑时怀疑人生。第一,它输出的是相对反照率,带全局尺度和白平衡模糊,同一张图跑两次,整体色温可能略有差异,绝对数值没有物理意义。第二,它对金属、镜面、透明材质、自发光物体基本无能为力,这些材质不满足朗伯假设,模型会把高光“抹”成漫反射色,看起来像塑料。第三,它擅长的是高频纹理和中等尺度结构,对大面积低频色偏的校正依赖后处理。第四,输入分辨率偏离训练分辨率太多会明显掉点。
把这几条记在心里,后面调参和后处理的所有决策都会顺理成章。
2. 方案拆解:为什么用扩散先验来做反照率
2.1 回归网络的困境:平均值就是模糊
最直觉的做法是训一个 U-Net 做端到端回归,输入 RGB,输出 albedo,损失用 L1 或 L2。这条路很多人走过,结果高度一致:输出发灰、纹理发糊、细节丢失。
原因不复杂。当同一个输入对应多个合理解时,L2 损失的最优解是这些解的均值,L1 损失的最优解是中位数。均值和中位数都不会是任何一张锐利的图。你给模型看十张同一场景、不同光照的图,它们的反照率应该一致,但渲染噪声、材质细节的微小差异会让标签本身就有分歧,模型学到的是这些分歧的平均。表现出来就是“整体颜色对了,但布料的编织纹理没了,木头的年轮糊成一片”。
有人用对抗损失、感知损失去补救,能改善锐度,但容易引入伪影,训练也不稳定。根本矛盾在于:回归框架假设答案是单峰的,而实际问题天然是多峰的。
2.2 生成式先验怎么补上这块
扩散模型的思路完全不同。它不直接预测答案,而是学着从噪声里一步步去噪出一张“像真实反照率图”的图。因为它在训练时见过海量图像分布,所以当条件信息不足以确定唯一解时,它会倾向于采样一个落在真实图像流形上的解,而不是数学期望那个模糊的平均值。
这带来的直接好处是纹理锐利。布料还是布料,砖缝还是砖缝,不会因为光照歧义被抹平。代价是引入了随机性:同一个输入,换一个随机种子,输出会有细微差别。工程上的应对办法是集成,跑多次取平均,用多次采样的方差换稳定性。这也是为什么后面参数章节里ensemble这个旋钮这么重要。
从系统设计角度看,这套方案还有个隐性优势:底座模型是通用的,反照率头是轻量的。如果你已经在用同一套底座做深度或法线,模型加载、显存管理、前后处理都能复用,工程成本比再引入一个独立网络低得多。
2.3 合成数据训练的域差与缓解
训练数据基本来自渲染引擎:搭大量三维场景,用材质库赋材质,随机打光,渲染出图,同时保存无光照的反照率作为标签。这条路能拿到像素级完美的监督信号,是真实拍摄数据给不了的。
但域差是实打实的。渲染图没有传感器噪声,没有运动模糊,没有镜头色散,没有 JPEG 块效应,材质库的分布也和真实世界有偏差,比如真实布料的次表面散射、皮肤的通透感、旧墙面的污渍层次,渲染器很难完全还原。模型在合成数据上训出来,遇到真实照片会出现两类问题:一是把噪声当成纹理放大,二是对训练集里没见过的材质给出过度平滑的结果。
实际缓解手段有几个。输入端做轻度去噪,但要用保边滤波器,双边滤波或者引导滤波,别用高斯模糊,否则纹理先没了。缩放时用 Lanczos 或面积平均,避免最近邻带来的锯齿。测试时增强,把图水平翻转再跑一遍,两次结果平均,能明显降低方差。如果目标场景很固定,比如全是室内家具,拿几百张真实图做一次轻量微调,收益比调任何推理参数都大。
2.4 和深度、法线估计的联动关系
反照率、深度、法线是同一套 intrinsics 分解的不同切片,物理上是互相约束的。有了法线,你能更准地判断哪些亮度变化来自朝向变化而不是材质变化;有了深度,你能区分遮挡阴影和材质暗部。
Marigold v2 共享底座的架构意味着你可以一次加载、多次前向,分别拿不同模态。但要注意一致性:分开跑的三次前向,反照率和法线可能在边缘处对不齐,因为每次采样都有随机性,集成的随机种子也不同。如果你的下游任务对几何和材质的一致性要求高,比如重打光,建议用同一组种子跑多个模态,或者在法线边缘处对反照率做引导滤波,把边缘对齐过去。这一条在后面重打光章节还会展开。
3. 环境搭建与最小可运行管线
3.1 依赖清单与显存测算
软件栈建议 PyTorch 2.1 以上配 CUDA 12.x,diffusers、transformers、accelerate、safetensors都用较新版本,老版本的调度器接口变动比较大,容易在采样器参数上出问题。Python 3.10 是比较稳的选择,3.11 也能跑,但要确认个别编译依赖有对应 wheel。
显存方面算笔账。UNet 主体按 8.6 亿参数、fp16 存储算,大约 1.7GB;VAE 解码器 160MB 左右;文本编码器如果管线里带了但推理不用,可以直接不加载。真正吃显存的是激活值:768×768 输入经 VAE 编码成 96×96×4 的 latent,UNet 在这个分辨率上做自注意力,序列长度 9216,注意力矩阵如果不做分块优化,单头就是 9216² 个元素,fp16 下约 170MB,多头叠加就很可观。开了 FlashAttention 或 PyTorch 2.x 的 SDPA 之后这部分会降一个量级。
结论:单张 768 分辨率、batch size 1、fp16,8GB 显存的卡可以跑,12GB 很宽裕。集成十次是串行执行的,显存不叠加,只增加时间。如果显存只有 6GB,把分辨率降到 640 或者开enable_attention_slicing(),速度慢一点但能跑通。
3.2 权重目录与加载方式
权重通常包含 UNet、VAE 和配置文件三部分,目录结构大致如下。不同发布批次的命名可能有差异,以你拿到的仓库说明为准,别硬套。
weights/ marigold-albedo-v2/ unet/ diffusion_pytorch_model.safetensors vae/ diffusion_pytorch_model.safetensors scheduler/ scheduler_config.json model_index.json加载时优先用 fp16,除非你发现输出出现明显的色带或者 NaN。有些权重是在 bf16 下训的,用 fp16 加载在极端亮部会有轻微溢出,遇到这种情况换 bf16,需要 Ampere 以上架构支持。
3.3 推理骨架代码
下面这段是管线骨架,重点是流程和接口边界,具体的 pipeline 类名和返回字段请以你本地的实现为准,不要照抄类名。
import torch import numpy as np from PIL import Image def build_pipeline(weight_dir, device="cuda", dtype=torch.float16): # 类名按实际仓库替换,这里只示意调用形态 from pipeline_marigold_albedo import MarigoldAlbedoPipeline pipe = MarigoldAlbedoPipeline.from_pretrained( weight_dir, torch_dtype=dtype, safety_checker=None ) pipe = pipe.to(device) pipe.set_progress_bar_config(disable=True) return pipe def resize_keep_ratio(img: Image.Image, base=768, multiple=64): w, h = img.size scale = base / min(w, h) nw = max(multiple, int(round(w * scale / multiple)) * multiple) nh = max(multiple, int(round(h * scale / multiple)) * multiple) return img.resize((nw, nh), Image.LANCZOS), (w, h) def pad_to_multiple(img: Image.Image, multiple=64): w, h = img.size pw = (multiple - w % multiple) % multiple ph = (multiple - h % multiple) % multiple canvas = Image.new("RGB", (w + pw, h + ph), (128, 128, 128)) canvas.paste(img, (0, 0)) return canvas, (w, h)预处理有两个原则要守住。第一,保持长宽比,别直接拉伸成正方形,拉伸会让法线和材质的各向异性全乱,输出纹理会被压扁。第二,尺寸对齐到 64 的倍数,VAE 是 8 倍下采样,UNet 内部还会再下采样,不对齐会在边缘产生半像素错位,表现为一圈细黑边。
集成推理的写法:
@torch.no_grad() def predict_albedo(pipe, img: Image.Image, steps=30, ensemble=8, seed=42): outs = [] for i in range(ensemble): gen = torch.Generator(device=pipe.device).manual_seed(seed + i) res = pipe(image=img, num_inference_steps=steps, generator=gen) outs.append(res.albedo) # 期望是 [1,3,H,W],范围 [-1,1] 或 [0,1] stacked = torch.stack(outs, dim=0) return stacked.mean(dim=0)这里有个细节值得强调:集成平均放在像素域做,不要放在 latent 域。latent 空间的线性平均对应到像素空间不是线性关系,VAE 解码器的非线性会让平均后的结果偏向某个模态,反而更容易出伪影。像素域平均虽然要多解码几次,但结果稳定得多。
3.4 输出还原与尺寸回贴
模型输出的尺寸是你 padding 之后的尺寸,要裁回原始比例再缩放回原图大小。缩放用 Lanczos,别用双线性,双线性会让反照率的边缘变软,后续做材质替换时边界会糊。
数值范围也要确认清楚。有的实现输出[0,1],有的输出[-1,1],混淆了会得到一张全黑或者全白的图。判断方法很简单:打印min和max,如果负值明显存在就是[-1,1],如果全在 0 到 1 之间就是[0,1]。这一步花十秒钟,能省掉半小时的排查。
4. 参数怎么调:每个旋钮背后的逻辑
4.1 分辨率:贴着训练分布走
扩散模型对分辨率很敏感,因为噪声调度、注意力感受野、纹理尺度都是在特定分辨率上训出来的。Marigold 系列的主训练分辨率是 768,你在 768 附近跑效果最好。往上走到 1024,模型没见过那么多像素,容易出现重复纹理和局部过锐;往下走到 512,细节直接丢,纹理变成色块。
大图怎么办?分块推理。把原图切成带重叠的块,每块缩到 768,分别跑,再拼回去。重叠区域建议不少于 128 像素,拼接时用羽化权重,中心权重高、边缘权重低,避免接缝。分块有个副作用:每一块的光照估计是独立的,块与块之间的整体色温可能不一致,拼完会看到方块状色差。解决办法是拼完之后做一次低频对齐,把每个块的低频分量往全局均值拉,只拉低频,不动高频纹理。
4.2 采样步数与集成规模
采样步数用 DDIM 的话,20 步基本收敛,30 步是稳妥值,50 步以上收益递减,时间却线性增长。判断收敛的土办法是跑 20 步和 40 步各一次,看输出的平均绝对差,如果差异在 1 个灰阶以内,就不用再往上加了。
集成规模是稳定性的主要来源。实测下来,1 次的结果随机性肉眼可见,连续跑三次能看出明显色差;5 次之后基本稳定;8 到 10 次是性价比比较高的区间,再往上提升有限。集成不是免费的,10 次就是 10 倍时间,768 分辨率在 4090 上单次大约 1 到 2 秒,10 次就是十几秒,批量处理上千张图时这个成本要算进去。折中方案是先用小集成跑一遍全量,挑出明显异常的样本,再对大集成重跑异常样本。
引导系数这块要特别注意,反照率估计不是文本引导任务,没有 prompt,所以 CFG 的 guidance scale 通常设成 1.0,也就是不启用无分类器引导。有些人习惯性填 7.5,结果输出饱和度爆炸、颜色失真,这是把文生图的默认参数直接搬过来了。
4.3 色彩空间与 gamma:最容易翻车的一环
这一节我建议你读两遍。反照率的物理意义是线性光强下的反射率,而绝大多数图片、绝大多数模型输出,都是在 sRGB 编码空间里的。这两者差一个 gamma 曲线,混用会让重打光的结果偏得离谱。
具体来说,sRGB 的编码是分段函数,暗部近似线性,亮部接近 2.2 次幂。你在 sRGB 空间里把反照率乘以一个光照系数,等价于在物理空间里做了一个错误的非线性运算,结果是暗部变化过小、亮部变化过大,看起来就是“一打光就过曝,一压暗就死黑”。
正确的顺序是:拿到 sRGB 反照率,先转线性,乘光照,再转回 sRGB 显示。转换代码如下,别用简单的x ** 2.2近似,暗部误差会有几个百分点,在阴影区域很明显。
import numpy as np def srgb_to_linear(x): x = np.asarray(x, dtype=np.float32) return np.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4) def linear_to_srgb(x): x = np.clip(np.asarray(x, dtype=np.float32), 0.0, None) return np.where(x <= 0.0031308, x * 12.92, 1.055 * np.power(x, 1.0 / 2.4) - 0.055)还有一个隐性问题:模型输出的反照率带有全局白平衡偏移。它不知道你的拍摄光源色温,也没法从单张图里唯一确定,所以输出可能整体偏暖或偏冷。工程上的处理是做一个弱约束对齐,用输入图里最亮的低饱和区域当作中性参考,求一个每通道的增益,把反照率拉回中性。注意是弱约束,别拉太狠,否则会把本来就有色调的材质,比如木地板、土墙,也拉成灰的。
4.4 参数速查表
| 参数 | 推荐值 | 作用 | 偏离后果 |
|---|---|---|---|
| 推理分辨率 | 768 | 匹配训练分布 | 低于 512 纹理丢失,高于 1024 出现重复纹理 |
| 采样步数 | 30 | 去噪收敛 | 低于 15 局部噪点残留,高于 50 收益极小 |
| 集成次数 | 8 | 降低随机方差 | 少于 3 色温漂移明显,多于 12 时间成本过高 |
| guidance scale | 1.0 | 无文本引导 | 误设 7.5 会导致饱和度爆炸 |
| 数据类型 | fp16 | 显存与速度平衡 | bf16 更稳但需硬件支持,fp32 显存翻倍 |
| 分块重叠 | 128 px | 消除接缝 | 小于 64 接缝明显,大于 256 时间浪费 |
| 输入尺寸对齐 | 64 的倍数 | 避免半像素错位 | 不对齐会出现边缘黑边 |
5. 后处理:把“能看”变成“能用”
5.1 高光与镜面残留清理
扩散模型倾向于把高光区域“解释”成浅色漫反射材质,因为它的训练数据里,大部分高光确实出现在白色或浅色物体上。结果就是瓷器、不锈钢、汽车漆面这些区域,反照率被填成一片浅灰白,纹理全丢。拿去重打光,这些地方会变成死板的塑料感。
处理思路是先检测再修补。检测可以用简单规则:在 HSV 空间里,明度高于 0.92 且饱和度低于 0.15 的像素,大概率是高光。这个规则会误伤纯白背景和浅色布料,所以还要加一个条件:高光区域的梯度应该比较平缓,而白色布料的纹理梯度更丰富。两个条件一起用,误检率能压下去不少。
import numpy as np def specular_mask(img_srgb, v_th=0.92, s_th=0.15): r, g, b = img_srgb[..., 0], img_srgb[..., 1], img_srgb[..., 2] mx = np.max(img_srgb, axis=-1) mn = np.min(img_srgb, axis=-1) v = mx s = np.where(mx > 1e-6, (mx - mn) / np.maximum(mx, 1e-6), 0.0) return (v > v_th) & (s < s_th)检测出 mask 之后,修补方式有两种。轻量的做法是用邻域的中值或者引导滤波结果填进去,适合小面积高光。重一点的做法是把 mask 区域当作缺失,用图像修复算法补,适合大面积镜面。修补完记得做一次边缘羽化,硬边会在重打光时暴露出来。
5.2 阴影残留与低频色偏分离
模型对软阴影的处理不够干净。大面积的环境光遮蔽、物体底部的接触阴影,经常会以一层淡淡的暗部的形式残留在反照率里。这层残留是低频的,空间上变化缓慢,和真正的材质暗部(比如深色木纹)混在一起,很难直接区分。
实用的分离手段是引导滤波或者双边滤波,把反照率分解成低频基频加高频细节。低频部分里,如果某个区域的亮度显著低于全局均值,而且这个区域和输入图的阴影区域高度重合,就认为它是阴影残留,把它往全局均值拉一部分,拉的比例控制在 50% 到 70%,别拉满,拉满会让真实的深色材质变浅。高频细节原样保留,这样纹理不会受影响。
这里有个经验值:拉回的强度用输入图的亮度做权重,输入图越暗的地方,说明阴影越重,拉回力度越大;输入图本身就不暗的地方,说明那里的暗是材质本身,不拉。这个启发式规则简单,但在室内场景上很管用。
5.3 色卡校准与定量评估
如果你能控制拍摄条件,放一张色卡是最省事的。拿到反照率之后,在色卡区域采样,和色卡的标准值做最小二乘拟合,求一个 3×3 的色彩校正矩阵,把这个矩阵应用到整张图。这一步能把系统性色偏压到很低,代价是拍摄时要多带一张卡。
没有色卡的时候,只能做弱约束。灰世界假设是一个可用的起点:假设整张图的平均颜色是中性的,求每通道增益。这个假设在彩色场景里会失效,比如整片草地、整面红墙,所以用之前先看一眼场景,别盲用。
定量评估方面,合成数据上可以算 PSNR、SSIM、LPIPS,但要注意尺度模糊的问题。反照率有全局尺度不确定性,直接算 PSNR 会低估性能,业内常用尺度不变 RMSE,也就是先求一个最优的全局缩放再算误差。真实数据上没有真值,只能靠下游任务间接评估:把反照率拿去重打光,看结果自不自然;或者拿去做材质替换,看边缘融不融合。这两种主观评估在实际项目里比指标更有说服力。
6. 踩坑实录与排查表
6.1 六个高频问题与成因
输出整体偏暗。八成是数值范围搞错了。模型输出[-1,1],你当成[0,1]保存,所有负值被截断成 0,整张图暗一大截。检查方法是看直方图,如果 0 处有一个巨大的尖峰,就是这个原因。
颜色发灰、饱和度不足。两个可能。一是集成次数太多而且用了 latent 域平均,把颜色平均掉了;二是后处理里白平衡拉得太狠。先关掉白平衡再跑一次,能快速定位。
纹理糊成一片。检查缩放方式,用了双线性或者最近邻就会这样;也可能是输入分辨率太低,低于 512 时模型确实给不出细节。
拼接处有方块接缝。重叠区域不够,或者拼接用的是硬切而不是羽化。把重叠加到 128 以上,权重改成余弦窗。
高光区域变成白斑。这是模型的固有倾向,不是 bug。用 5.1 的 mask 加修补处理。
同一张图跑两次结果不一样。正常现象,扩散采样本身有随机性。固定随机种子、加集成次数就能稳定。
6.2 排查顺序:从外到内
遇到问题别一上来就怀疑模型。按这个顺序查,能省很多时间。
第一步查预处理:颜色空间对不对,是不是把 BGR 当成 RGB 传进去了,归一化范围对不对,尺寸有没有对齐。这三项能解释一半以上的“模型效果差”。
第二步查模型加载:权重是不是完整下载,dtype 是不是一致,有没有混用 fp16 和 fp32 导致某些层被静默转成 fp32 再转回来。加载完打印一下各模块的 dtype,一眼就能看出来。
第三步查后处理:色彩空间转换有没有做反,白平衡增益是不是大于 1 太多,裁剪区域对不对。
第四步才是模型能力边界:这个材质是不是金属、透明、自发光,这个场景是不是训练集里完全没有的极端光照。如果是,调参没用,只能换方案或者加微调。
6.3 问题速查表
| 现象 | 最可能原因 | 快速验证 | 处理 |
|---|---|---|---|
| 整图偏暗 | 数值范围误判 | 看直方图 0 处尖峰 | 正确反归一化到 [0,1] |
| 饱和度异常高 | guidance 误设 | 检查参数是否 7.5 | 改回 1.0 |
| 边缘一圈黑边 | 尺寸未对齐 64 | 检查输入宽高 | padding 到 64 倍数 |
| 方块接缝 | 重叠不足 | 看接缝位置是否等距 | 重叠加到 128,羽化拼接 |
| 高光变白斑 | 模型固有倾向 | 对比输入高光位置 | mask 检测加修补 |
| 结果不稳定 | 集成次数少 | 连跑三次对比 | 集成加到 8,固定种子 |
| 金属材质失真 | 不满足朗伯假设 | 看是否有强反射 | 单独标记,不走此管线 |
7. 落地场景:反照率拿到手之后干什么
7.1 重打光:最容易验证价值的场景
重打光是最直接的下游应用。拿到反照率和法线,给一个新的光照方向和颜色,就能合成一张新图。公式很朴素:线性空间下,I_new = A_linear × max(0, N·L) × light_color,再转回 sRGB。阴影部分需要额外的阴影贴图或者屏幕空间环境光遮蔽来补,否则新图会显得“飘”,物体和地面没有接触感。
法线从哪来?可以用同一套底座的法线模型,也可以用单目深度求梯度近似。前者质量更好,后者胜在一致性容易控制。要注意的是,反照率和法线的边缘必须对齐,否则重打光后物体轮廓会出现一圈亮边或者暗边。对齐的办法前面提过:用法线的边缘做引导,对反照率做引导滤波。
实测下来,室内场景、家具、静物这几类效果最好,因为训练数据里这类场景占比高,材质也以漫反射为主。室外大场景、植物、水面效果一般,植物有透光和次表面散射,水面根本不满足朗伯假设。
7.2 材质替换与电商图规范化
电商场景有个刚需:同一件商品,不同拍摄批次的光线不一样,主图颜色不一致,用户投诉色差。用反照率做中间表示,可以把所有图先归一到“无光照”状态,再统一打一个标准光,输出风格一致的主图。这个流程比人工调色快得多,而且一致性更好。
材质替换的流程是:分割出目标区域,取该区域的反照率,用新材质的反照率替换,保留原有的着色项,重新合成。关键在于保留着色项,也就是I_new = A_new × S_old,其中S_old = I_old / A_old。这样新材质的明暗关系、阴影走向和原图完全一致,看起来才自然。直接贴图会显得很假,因为没有光影。
7.3 数据增强与合成数据生产
还有一个容易被忽略的用法:把反照率当作解耦的中间表示,做光照增广。同一张图,分解出反照率之后,随机换光源方向、颜色、强度,能合成出海量不同光照下的样本,用来训练对光照鲁棒的下游模型,比如检测、分割、检索。这比传统的色彩抖动高明得多,因为色彩抖动是全局的,而重打光是空间变化的,更接近真实的光照变化。
这条路的前提是反照率质量要够,尤其是阴影残留要干净。如果反照率里还带着原来的阴影,重打光之后会出现“双重阴影”,新旧两套阴影叠在一起,反而污染训练数据。所以做增广之前,5.2 的阴影清理这一步不能省。
我个人在实际项目里的体会是,别把反照率估计当成一个追求物理正确的科学问题,把它当成一个工程中间件来用会更顺。判断标准不是“这张 albedo 和真值差多少”,而是“拿它做重打光、做材质替换,下游用户能不能接受”。很多时候一张在指标上不完美、但阴影干净、色温中性的反照率,比一张指标漂亮但高光糊成白斑的图更有用。调参的优先级也该按这个逻辑排:先把预处理和色彩空间搞对,再把集成稳定住,最后才去抠模型细节。至于金属和透明材质,与其硬调,不如在管线里加一个材质分类的前置判断,把不适合的样本分流出去,用专门的方案处理,这样整条流水线的稳定性反而更高。