☰
Marigold v2单图反照率估计:扩散先验与落地实践
2026/10/28 11:35:03 网站建设 项目流程

拍一张照片,把里面的光照整个抽走,只留下物体本来的颜色——这件事听起来像修图软件里的一个滑块,真做起来却是个典型的病态逆问题。反照率估计(albedo estimation)要解的方程只有一行:I = A ⊙ S,观测到的像素值 I 是物体固有反射率 A 和光照着色 S 的乘积,而未知数有两个、方程只有一个。同一个灰色像素,既可能是白墙被暗光照,也可能是黑墙被强光照,数学上无法区分。传统做法靠先验、靠多张图、靠人工标注,走得很辛苦。Marigold v2 换了个思路:把大规模文生图扩散模型里沉淀下来的“自然图像常识”当成先验,在合成数据上只微调一小块适配参数,就能从单张 RGB 图里估出一张色彩相对干净的反照率图。

这篇内容写给三类人看。第一类是做过图像处理、想把反照率接进自己管线的工程同学,你需要知道每个环节为什么会出问题、参数怎么定;第二类是想复现论文结果的研究向读者,你需要一个能跑通的最小骨架和验收标准;第三类是做三维重建、材质扫描、AR 虚实融合、影视合成或者电商修图的朋友,你未必关心扩散模型内部,只想知道这东西的输入输出边界在哪、什么时候会翻车。我会从原理讲到环境搭建,再到脚本骨架、参数表、自查清单和一堆踩过的坑,尽量把每一步“为什么这么做”讲透,而不是丢一段代码让你自己猜。

1. 先把问题说清楚:反照率究竟是什么,单张图凭什么能估

1.1 一张照片里的三个“图层”

任何一张有光照的实拍图,都可以粗暴地拆成三部分的叠加:物体固有的漫反射颜色(albedo)、光照在几何表面上形成的明暗分布(shading),以及镜面高光、间接反弹、相机 ISP 带来的色调映射等“杂质”。前两项的乘积构成了我们看到的大部分像素,后一项则是让问题变脏的元凶。做反照率估计,本质上就是把第一项从乘积里剥出来。

这里有个容易被忽略的物理事实:反照率只定义到一个全局尺度。你把整张图的反照率乘 0.8、把光照强度乘 1.25,渲染出来的结果完全一样。这意味着任何单目方法都不可能输出“绝对正确”的亮度值,只能输出相对分布正确的色彩。所以工程上衡量结果时,一定要先做一次全局对齐,再谈误差,否则你会被一个纯粹的尺度因子骗得很惨。我见过有人直接算 PSNR 得到 12dB 就以为模型坏了,其实只是没对齐而已。

理解了这一点,后面的很多设计就有了解释:为什么损失函数里常有尺度不变的项、为什么后处理一定要做一次最小二乘拟合、为什么评估指标在合成数据集上好看而在真实图上只能靠人眼。反照率估计从来不是“回归一张图”这么简单,它是在一个欠定的空间里挑一个符合人类直觉的解。

1.2 Marigold v2 换了什么思路:把生成先验当成“物理常识库”

早几年做 intrinsic image decomposition 的主流路线是训练一个端到端的回归网络,输入 RGB、输出 albedo 和 shading,损失函数堆一堆。这条路的天花板很明显:合成训练集和真实世界之间存在巨大的域差异,网络学到的是数据集的偏置,而不是物理规律。换个场景、换个白平衡,输出就崩。

Marigold 系列的核心洞察是:一个在海量自然图像上训练过的扩散模型,它的去噪过程本身就编码了“什么样的图像是合理的”这一先验。阴影的软硬、材质的粗糙度、室内外色温的分布,这些统计规律已经被压缩进了模型的权重里。我们不需要从零教网络什么是反照率,只需要告诉它“往反照率这个方向去噪”。v2 相对初代的改动,主要在于把“全量微调整个 UNet”换成了轻量的任务适配——冻结主干,只训练一小部分任务相关的参数和条件注入路径。这样做的直接好处是训练成本大幅下降,同时一个主干能挂多个任务头(深度、法线、反照率、语义),互不干扰。

这个设计选择的背后逻辑值得多说一句。全量微调会让模型逐渐忘掉通用先验,退化成“另一个回归网络”;而完全冻结、只做零样本推理又没法把输出约束到反照率的分布上。轻量适配正好卡在中间:主干的先验保住了,任务的方向性也有了。代价是任务之间的解耦不如独立模型彻底,同一个主干在不同任务上的表现会有强弱之分,反照率恰恰是其中比较难的一个。

1.3 为什么不能简单地“去阴影”或者“调白平衡”

很多人第一反应是:反照率不就是把阴影去掉、把色偏拉回来吗?用 Retinex、用同态滤波、用自适应直方图均衡不就行了。这类传统方法在少数场景下确实能出效果,思路是先估计一个低频的光照场,再用原图除以它。问题在于,低频假设本身就不成立。光照的频率成分和材质颜色的频率成分高度重叠:一件红白条纹的衬衫,红色区域的低频分量和侧光造成的明暗渐变在实际频谱上几乎无法分离,除掉光照的同时也把条纹的颜色抹平了。

更麻烦的是彩色光照和相互反射。室内场景里,一面木色柜子会把暖光反弹到旁边的白墙上,白墙那一块的像素确实偏黄,但它的反照率是白的。传统方法看到“偏黄的低频”,会把它当成光照除掉,结果白墙变得更白——看起来对了;但如果是一面真正的米色墙,它就会被错误地漂白。这个歧义只能靠语义理解来消解:模型得知道“这是墙、那是木头”,才能判断哪部分黄是材质、哪部分黄是光。扩散先验提供的正是这种带语义的先验知识,这也是它比纯信号处理方法强的地方。

顺带说一个常见误解:有人觉得反照率图应该看起来像“没有阴影的原始照片”。不是的。反照率图里不应该有光照造成的明暗,但应该有材质本身的图案——木纹、布纹、锈迹、印刷字,这些都必须保留。区分标准很简单:如果你把这张图重新打一个方向光,它应该还能渲染出合理的立体感;如果它已经平得像一张色块图,说明细节被过度平滑了。

1.4 任务的难度分级与预期管理

实际项目里,我会把输入场景按难度分三档,提前和需求方对齐预期。低难度:漫反射为主、光照均匀、无明显高光的物体特写,比如布料、纸张、哑光塑料、食物。这类图的反照率估计结果相当可靠,直接拿去重打光基本能用。中难度:室内场景、有明确方向光但材质以漫反射为主,比如家具、墙面、地板。结果整体可信,但阴影边缘附近会有轻微渗色,需要后处理。高难度:强镜面反射(金属、玻璃、水面)、复杂相互反射、大面积过曝或欠曝、强彩色光源(舞台灯、霓虹)。这类输入下模型会明显退化,高光容易残留进反照率,暗部容易糊成一片。

把这条分级写进文档里非常有必要,因为它决定了你要不要上后处理、要不要人工修、要不要干脆换采集方案(比如补拍一张交叉偏振的照片)。指望一个模型在所有场景下都给出可直接商用的结果,是不现实的。

2. 环境搭建:跑起来之前要决定的四件事

2.1 硬件与精度的取舍

反照率推理对显存的要求主要来自两个方面:扩散主干本身和 VAE 编解码。以 512×512 输入、fp16 精度为例,主干加 VAE 常驻显存大概在 5 到 7 GB 量级,留出中间激活的空间,一张 12 GB 的卡跑单图很轻松,8 GB 的卡需要开注意力切片(attention slicing)和 VAE 分块解码。如果你的目标是批量处理几千张图,瓶颈会从显存转移到吞吐,这时候用 24 GB 卡配合 batch size 4 到 8 更划算。

精度方面,fp16 是默认选择,但要留意两个细节。第一,某些层的数值范围在 fp16 下会溢出,表现为输出出现 NaN 或者整片纯色,解决办法是把 VAE 单独保持 fp32,或者对关键层做 autocast 白名单。第二,fp16 和 fp32 的输出并不是完全一致的,色彩上可能有极细微的偏移,如果你要做 A/B 对比实验,务必固定精度,别一次 fp16 一次 fp32,那样比出来的差异说明不了任何问题。

还有一点常被忽略:CPU 推理在技术上可行,但一张 512 图大概要几分钟,实在没有实用价值。如果你只有 CPU,建议直接用云端的按量实例跑一次性任务,成本比折腾本地环境低得多。

2.2 依赖版本锁定:别让环境成为最大的坑

扩散模型生态的版本兼容性是出了名的脆弱。同一个脚本,diffusers从 0.24 升到 0.30 就可能因为调度器参数改名而报错;transformers和torch的版本组合也常常出问题。我的做法是用锁文件固定全部版本,并且在项目根目录写一个requirements-lock.txt,把torch、torchvision、diffusers、transformers、accelerate、safetensors、Pillow、numpy的精确版本都钉死。

python -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install torch==2.4.1 torchvision==0.19.1 --index-url https://download.pytorch.org/whl/cu121 pip install diffusers==0.30.3 transformers==4.44.2 accelerate==0.34.2 safetensors==0.4.5 pip install numpy==1.26.4 Pillow==10.4.0 opencv-python==4.10.0.84 pip freeze > requirements-lock.txt

注意:numpy尽量停在 1.x。2.x 之后不少老代码里的np.float、np.int之类的别名彻底移除了,虽然和你自己的脚本无关,但依赖链里只要有一个库没跟上,就会在 import 阶段直接炸掉,而且报错信息往往指向一个你根本没直接引用的包,排查起来很费时间。

虚拟环境之外,我建议再准备一个env_check.py,启动时打印 torch 版本、CUDA 是否可用、显存总量、以及 fp16 是否被硬件支持(torch.cuda.is_bf16_supported())。把这段检查放在所有脚本的最前面,能省掉大量“为什么别人跑得通我这里报错”的来回。

2.3 权重放置与目录约定

主干权重、VAE、任务适配模块通常是分开的几个文件。目录结构建议固定下来,别让脚本里去猜路径:

project/ ├── weights/ │ ├── base/ # 扩散主干 │ ├── vae/ # 编解码器 │ └── task_albedo/ # 反照率任务适配参数 ├── data/ │ ├── input/ │ └── output/ ├── configs/ │ └── infer_albedo.yaml └── scripts/ ├── infer_single.py └── infer_batch.py

权重下载完成后务必校验文件大小和哈希。断点续传中断导致 safetensors 文件截断,是新手最常遇到的玄学错误之一,报错信息通常是“unexpected end of file”或者某个张量 shape 对不上,看起来像代码问题,实际是文件没下完。另外,如果你的磁盘是大小写不敏感的文件系统,注意别把Base和base当成两个不同目录用,部署到 Linux 服务器上会直接找不到路径。

3. 核心管线拆解:从 RGB 到反照率的每一步

3.1 预处理:尺寸、动态范围与色彩空间

预处理看着琐碎,但它对最终结果的影响比很多人想象的大。第一步是尺寸。扩散主干在固定的分辨率上训练,输入尺寸偏离太多,先验就会失配。常见做法是把短边缩放到 768 或 1024 这个量级,保持长宽比,然后中心裁剪或者填充到主干支持的尺寸。我不建议直接暴力 resize 到方形,因为非等比缩放会改变物体的几何比例,从而影响模型对“这张图里光从哪来”的判断,输出的着色分量会跟着变形。

第二步是动态范围。如果你处理的是 HDR 图或者 RAW,必须先做色调映射压到 [0,1],而且映射曲线要固定。用自动曝光的映射会导致同一个场景在不同图上的反照率尺度不一致,批量处理时就会出现“有的图偏亮有的偏暗”,非常难对齐。我一般用固定的 Reinhard 或者简单的 gamma 2.2 加固定曝光增益,把整批数据统一到同一个标准下。

第三步是色彩空间。模型的训练数据基本是 sRGB 编码的图,所以输入也应该是 sRGB。如果你的源图是 Adobe RGB 或者 ProPhoto,必须先转回 sRGB,否则模型看到的是“被压扁的饱和度”,输出的反照率会整体偏灰。这一点在专业摄影素材上特别容易翻车,因为肉眼很难分辨一张图到底是过饱和还是色彩空间标错了。

import numpy as np from PIL import Image, ImageOps def preprocess(path, target_short=768, multiple_of=64): img = Image.open(path) img = ImageOps.exif_transpose(img) # 先修正 EXIF 方向 img = img.convert("RGB") # 统一到 sRGB w, h = img.size scale = target_short / min(w, h) new_w, new_h = int(round(w * scale)), int(round(h * scale)) img = img.resize((new_w, new_h), Image.LANCZOS) # 对齐到主干要求的倍数,避免 latent 尺寸除不尽 pad_w = (-new_w) % multiple_of pad_h = (-new_h) % multiple_of img = ImageOps.expand(img, (0, 0, pad_w, pad_h), fill=(0, 0, 0)) arr = np.asarray(img).astype(np.float32) / 255.0 return arr, (w, h), (pad_w, pad_h)

ImageOps.exif_transpose这一行千万别省。手机拍的竖图在文件里往往存成横向像素加一个旋转标记,很多库读进来不会自动处理,结果模型拿到的是一张躺倒的图。模型当然还是能输出反照率,但光照方向的语义就全乱了,而且你很难从结果上看出是这个问题。

3.2 条件注入与去噪调度:扩散先验是怎么被“借”过来的

推理阶段的核心流程可以概括成四步:把输入图编码进 latent 空间;构造一个纯噪声 latent 作为起点;在去噪网络里以“输入图的 latent”作为条件,逐步去噪;把最终 latent 解码回像素空间。这里每一步都有需要理解的取舍。

为什么走 latent 而不是像素空间:直接在像素上做扩散,计算量是 latent 空间的几十倍,没有实用价值。代价是 VAE 的编解码本身会损失一部分高频信息,尤其是小字、细纹理这类内容,解码后容易糊。所以如果你的下游任务对细节敏感,要么提高分辨率,要么后处理里补一次细节回贴。

条件怎么注入:常见做法是把输入图的 latent 和当前噪声 latent 在通道维拼接,或者用额外的 cross-attention 层做条件调制,或者两者兼用。对反照率这种“输入输出强相关”的任务,拼接式条件更直接,收敛也更快。文本条件这一路一般置空(用空提示嵌入),因为这个任务不需要语义引导,加文本反而引入不稳定性。

步数怎么定:10 到 50 步都有人用。步数太少,去噪不充分,输出会残留噪声纹理;步数太多,收益迅速递减,还容易因为调度器累积误差导致色彩漂移。我实测下来 20 到 30 步是个甜点区,配合 DPM-Solver 这类高阶求解器,20 步就能得到相当稳定的结果。用 DDIM 的话建议 30 步以上。

测试时集成:扩散推理有随机性,同一张图跑两次结果不完全一样。工程上一般跑 3 到 8 次,取均值或中位数。均值能降噪但会略微钝化细节,中位数对离群值更稳健,我通常用中位数,然后再做一次引导滤波把边缘找回来。这个技巧在多噪声种子下对抑制局部色斑特别有效,成本只是推理时间线性增长。

3.3 解码与后处理里的三个“隐形坑”

第一个坑是数值范围。VAE 解码出来的张量通常在 [-1, 1] 区间,需要先(x + 1) / 2映射到 [0,1] 再 clamp。如果你忘了这一步,直接当 [0,1] 用,得到的图会整体偏暗、对比度异常,而且你会发现“怎么调参数都救不回来”。同理,如果模型输出的是 log 空间或者做了某种归一化,一定要去查清楚具体的约定,不要凭直觉假设。

第二个坑是全局尺度对齐。前面说过,反照率只定义到全局尺度。模型输出的亮度分布未必和输入图的整体曝光匹配,直接对比会显得“整体偏灰”或“整体偏亮”。标准做法是解一个最小二乘:找标量 s 使得||I - s·A||²最小,闭式解是s = <I, A> / <A, A>,逐通道做就得到每通道增益。

def global_align(albedo, image, per_channel=True): # albedo, image: HxWx3, 值域 [0,1] if per_channel: s = (albedo * image).sum(axis=(0, 1)) / (albedo ** 2).sum(axis=(0, 1) + tuple()) s = s.reshape(1, 1, 3) else: s = (albedo * image).sum() / (albedo ** 2).sum() return np.clip(albedo * s, 0, 1)

逐通道对齐要谨慎:它能修掉白平衡偏差,但也可能把模型本来估对的色偏给“修正”错。我的经验是,如果输入图的白平衡本身可信,用单标量对齐;如果输入图有明显色偏(比如暖光灯下的室内照),用逐通道对齐,但要限制增益范围在 0.8 到 1.25 之间,超出就截断,避免把真实材质颜色也拉偏。

第三个坑是保存环节的二次损伤。存 JPEG 时质量低于 90 会产生块效应,尤其在平滑的渐变区域非常明显,而这些区域恰恰是反照率图的主体。训练数据、下游处理、对比实验,一律存 PNG 16 位或者无损 WebP。另外注意别在保存时又做一次隐式的 gamma 转换,有些库的imwrite对 float 输入有默认行为,保险起见先显式转成uint8或uint16再写。

4. 手把手实操:一套可复现的推理脚本

4.1 单图推理骨架

下面这段是结构示意,具体接口名请按你手里的实现替换。我把每个环节的意图写在注释里,重点是让你看清数据在每一步长什么样,而不是照抄某个仓库的 API。

import torch from PIL import Image import numpy as np class AlbedoPipeline: def __init__(self, base_ckpt, vae_ckpt, task_ckpt, device="cuda", dtype=torch.float16): self.device, self.dtype = device, dtype # 1) 主干:只做推理,eval + 冻结 self.unet = load_unet(base_ckpt).to(device, dtype).eval() # 2) VAE:建议 fp32,避免解码色彩漂移 self.vae = load_vae(vae_ckpt).to(device, torch.float32).eval() # 3) 任务适配参数:轻量,可热插拔 self.task = load_task_adapter(task_ckpt).to(device, dtype).eval() self.scheduler = build_scheduler(num_train_timesteps=1000, solver="dpm") @torch.no_grad() def encode(self, x): # x: 1x3xHxW, [-1,1] return self.vae.encode(x).latent_dist.mode() * self.vae.config.scaling_factor @torch.no_grad() def decode(self, z): z = z / self.vae.config.scaling_factor return self.vae.decode(z).sample # 1x3xHxW, [-1,1] @torch.no_grad() def __call__(self, image_tensor, steps=25, ensemble=5, seed=0): cond = self.encode(image_tensor) outputs = [] for i in range(ensemble): g = torch.Generator(device=self.device).manual_seed(seed + i) z = torch.randn_like(cond, generator=g) self.scheduler.set_timesteps(steps) for t in self.scheduler.timesteps: noise_pred = self.unet( torch.cat([z, cond], dim=1), # 通道拼接式条件 t, encoder_hidden_states=self.task.empty_embed(1).to(self.dtype), ).sample z = self.scheduler.step(noise_pred, t, z).prev_sample outputs.append(self.decode(z.float())) out = torch.stack(outputs, 0).median(dim=0).values return (out.clamp(-1, 1) + 1) / 2 # -> [0,1]

几个实操要点。第一,self.vae保持 fp32,unet用 fp16,这个混合精度组合在多数卡上既快又稳。第二,ensemble的随机种子要记录进日志,否则复现不了;我习惯把seed写进输出文件名。第三,median在显存里 stacking 会额外占用ensemble倍的显存,如果显存吃紧,改成逐个累积到 CPU 列表再求中位数,慢一点但省得多。

4.2 批量处理与显存控制

批量跑的难点不在推理本身,在于失败重试和显存碎片。长跑任务里偶尔会遇到某张图特别大或者有异常通道导致 OOM,一个未捕获的异常会让整个 batch 挂掉。我的写法是逐图 try/except,失败写日志并跳过,同时每处理 N 张就torch.cuda.empty_cache()一次。

import traceback, json, gc, torch def run_batch(paths, pipe, out_dir, cfg): log = [] for idx, p in enumerate(paths): try: arr, orig_size, pad = preprocess(p, cfg["target_short"]) x = torch.from_numpy(arr).permute(2, 0, 1)[None] x = (x * 2 - 1).to(pipe.device, pipe.dtype) out = pipe(x, steps=cfg["steps"], ensemble=cfg["ensemble"], seed=cfg["seed"]) save_png(out, f"{out_dir}/{idx:05d}_s{cfg['seed']}.png") log.append({"file": p, "status": "ok"}) except torch.cuda.OutOfMemoryError: torch.cuda.empty_cache(); gc.collect() log.append({"file": p, "status": "oom"}) except Exception: log.append({"file": p, "status": "error", "trace": traceback.format_exc()}) finally: if idx % cfg["empty_every"] == 0: torch.cuda.empty_cache() json.dump(log, open(f"{out_dir}/_log.json", "w"), ensure_ascii=False, indent=2)

注意:empty_cache()只是把缓存还给驱动,并不能解决真正的显存不足。如果你的 batch 里有一半图都 OOM,说明该降分辨率或者减小ensemble,而不是频繁清缓存。另外,PyTorch 的显存碎片化在长时间运行后确实会导致“明明够用却分配失败”,这时设置环境变量PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True通常能缓解。

批处理的另一个细节是输出尺寸要和输入对齐。前面预处理时做了 padding,输出要裁回去,否则下游拿到的图和原图坐标对不上,做贴图重投影时会整体偏移几个像素,很难排查。

4.3 关键参数速查表

参数常用取值影响调整建议
短边分辨率768 / 1024过低丢失纹理,过高显存翻倍且收益递减从 768 起,细节不足再上 1024
去噪步数20~30(DPM-Solver)步数少残留噪声,步数多色彩漂移先固定 25,再上下微调对比
集成次数 ensemble3~8抑制随机色斑,成本线性增长生产环境取 5,快速预览取 1
引导强度通常关闭或很低过高会让结果过饱和、失去真实感反照率任务建议关闭文本引导
VAE 精度fp32fp16 偶发色彩偏移显存允许就用 fp32
输出位深16 bit PNG8 bit 在平滑区域出现色阶中间产物一律 16 bit
对齐方式单标量 / 逐通道影响整体亮度与白平衡源图白平衡可信则用单标量

这张表是我自己调参时的备忘录,其中“引导强度关闭”这一条很多人会忽略。扩散模型默认带分类器无关引导,引导系数调高会让结果更“典型”、更讨喜,但对反照率这种要求忠实还原的任务反而是灾难——它会把颜色往模型认为“更常见”的方向拉,比如把一件少见的墨绿色外套推成更常见的深灰绿。忠实性优先的场景,宁可要保守的解,也不要漂亮的错。

5. 结果对不对:评估方法与自查清单

5.1 用 shading 反推来自检

在真实照片上没有真值,怎么判断结果靠不靠谱?我的主力方法是反推着色图:拿到反照率 A 后,用输入 I 除以 A,得到一张单通道的亮度图 S。这张图应该满足几个特征:整体是个平滑的光照场、阴影边界清晰但不刺眼、没有残留的材质图案。如果 S 里能明显看出木纹、布纹,说明这些图案被错分到了光照里,反照率那边一定丢失了细节;如果 S 里出现反色的图案(原本暗的纹理在 S 里变亮),说明反照率估反了,通常是高光区域的误判。

这个自检法的好处是零成本、不需要真值、而且非常直观。我做完一批图之后会随机抽十张,把 I、A、S 三张并排看,翻车的一眼就能看出来。再补一个更严格的检查:把 A 乘上一个虚拟的方向光,看能不能渲染出合理的立体感。如果乘完之后的图完全没有明暗变化,说明 A 太平了;如果出现奇怪的亮斑,说明高光没被剥离干净。这一步相当于把下游任务(重打光)提前跑一遍,用结果倒推中间产物质量。

5.2 合成数据上的量化验证

如果有真值(合成数据集、渲染引擎出的图、或者手工精修的少量样本),就可以上量化指标。常用的是 PSNR、SSIM 和 LMSE(local mean squared error),其中 LMSE 对局部一致性更敏感,比全局 PSNR 更能反映“色块有没有串”。

但要注意一个陷阱:所有指标都必须在对齐之后算。前面提过的全局尺度问题会直接让 PSNR 掉十几个 dB。另外,做对比实验时一定要固定随机种子和 ensemble 次数,扩散模型的方差足以让两次运行的结果差异超过两个方法之间的差异,那样比出来的结论毫无意义。我的做法是每个配置跑 3 个种子取平均,同时记录标准差,如果标准差和均值差在同一量级,说明这个对比不可信。

还有一个务实的建议:别迷信公开基准上的排名。反照率任务的评测集大多是合成室内场景,材质简单、光照干净。真实业务里的图往往有噪点、有压缩伪影、有复杂光源,模型在基准上的 0.5 dB 优势到真实数据上可能完全体现不出来。花时间在自己的数据上做小规模人工评测,比刷榜有用得多。

5.3 一份可以直接用的验收清单

我给团队定的验收标准是这几条,逐条过一遍,全部通过才算可用:

  • 平坦区域(白墙、桌面、天空)在反照率图里应该基本均匀,允许轻微噪声,不允许出现明显的明暗渐变
  • 物体本色是否正确:红色的苹果不能变成橙色,蓝色的布不能发紫
  • 材质纹理是否保留:木纹、砖缝、印刷文字要清晰可见,不能被平滑掉
  • 阴影边缘是否干净:阴影区域不应该有硬边残留,也不应该出现反色的“鬼影”
  • 高光区域是否收敛:镜面高光位置的反照率应该接近周围材质的颜色,而不是接近白色
  • 整体对比度是否合理:反照率图的动态范围通常比原图小,但也不能压成一片灰

这六条里最容易被忽略的是第一条和第五条。平坦区域的渐变残留往往来自模型对低频光照的欠拟合,高光残留则是因为训练数据里高光样本不足,模型倾向于把它当成材质的一部分。两者都会在下游重打光时暴露得很明显——残留渐变的墙面在换光照后会变成“脏墙”,残留高光的物体会在暗环境下发光。

6. 踩坑实录:高频问题与排查路径

6.1 色彩类问题:偏灰、偏色、发绿

整体偏灰、饱和度不足是最高频的抱怨。成因通常有三个:一是输入图本身经过了强烈的去饱和处理(某些相机的“自然”色彩模式会主动降饱和),模型忠实还原了这个偏灰的源;二是后处理里做了逐通道全局对齐,而模型的输出其实是准的,被“修正”过头了;三是 VAE 解码的色彩空间约定没对上,导致通道顺序或者 gamma 错位。排查顺序建议从后往前:先关掉对齐看原输出,再检查 VAE 的输入输出范围,最后怀疑输入本身。

整体发绿或者发紫这种特定色调的偏移,八成是通道顺序问题。RGB 和 BGR 搞反了,红色物体会变蓝,绿色会偏紫,而且这种错误在灰度图上完全看不出来,很容易漏掉。快速验证方法:拿一张纯红色块图跑一遍,看输出还是不是红色。

局部色斑、随机噪声块,尤其是在阴影和大面积平滑区域,基本可以确定是去噪步数不够或者 ensemble 次数太少。扩散模型在低步数下对低频区域的估计方差很大,多跑几次取中位数能显著压制。如果加了 ensemble 还有,那就要怀疑是不是调度器的配置有问题,比如set_timesteps之后再手动改了num_inference_steps,导致时间步和数据不匹配。

6.2 结构类问题:糊、锯齿、拼接缝

细节糊掉最直接的原因是分辨率不够。768 的短边对于小物体特写是够的,但对于大场景里的远处细节就很勉强。另一个原因是 VAE 的压缩损失,8 倍下采样意味着 8 像素以下的结构基本保不住。解决办法是分块推理:把大图切成带重叠的块,每块单独估反照率,再在重叠区做加权融合。重叠率建议不低于 25%,融合权重用余弦窗或者简单的线性斜坡,能有效避免拼接缝。

拼接缝的典型表现是块与块交界处出现一条明显的亮度台阶。除了加大重叠,还有一个技巧是在推理前对每块图做一致性归一化:用整张图的全局统计量(均值、方差)来归一化每一块,而不是用块内的统计量。这样各块之间的色彩基准就是统一的,融合时不会打架。代价是如果原图本身有强烈的光照不均,全局归一化会让某些块过曝,需要权衡。

边缘锯齿通常来自上采样环节。VAE 解码出来的 latent 分辨率是像素的 1/8,解码器内部会做几次上采样,如果用的是最近邻或者双线性,边缘就会发虚或者出锯齿。这个属于模型结构问题,工程上只能靠后处理补:用引导滤波以原图为引导做一次边缘对齐(guided filter),能明显改善边缘锐度,同时不会引入光晕。

6.3 性能类问题:OOM、慢、卡死

OOM 的排查顺序:先降分辨率,再减 ensemble,再开 attention slicing 和 VAE tiling,最后才考虑换卡。很多人一上来就调empty_cache,其实那是治标。真正的显存大户是中间激活,torch.no_grad()一定要加,否则 PyTorch 会为你保留整个计算图,显存占用直接翻几倍。

推理慢要分清是 GPU 利用率低还是单步耗时长。用nvidia-smi观察 GPU 利用率,如果低于 50%,说明瓶颈在数据加载或者 CPU 预处理上,num_workers调大、预处理改成多进程能解决;如果利用率接近 100% 但吞吐仍然低,那就是算力不够,只能降分辨率或者换精度。还有个小技巧:把同一批图 padding 到相同尺寸再一起送进模型,能避免因尺寸变化触发的 kernel 重编译,长跑任务里能省下可观的时间。

卡死不动最诡异,通常出现在长时间运行之后。常见原因是显存碎片累积、或者某个线程死锁在数据加载上。定位方法是加心跳日志,每处理一张打一行带时间戳,看卡在哪张图、卡了多久。

现象最可能原因首选排查动作
全图纯色 / NaNfp16 溢出VAE 换 fp32,检查 autocast 范围
整体偏灰未做对齐或对齐过度关闭对齐看原始输出
颜色通道错乱RGB/BGR 混淆用纯色块图验证
阴影残留硬边步数不足 / 训练分布差异步数加至 30,换 ensemble 中位数
平滑区色斑随机性未抑制ensemble 提到 5~8
细节模糊分辨率不足 / VAE 压缩上 1024 或分块推理
块间亮度台阶分块统计不一致用全局统计归一化并加大重叠
长跑后 OOM显存碎片设 expandable_segments,定期清缓存

7. 落地场景:拿到反照率之后能做什么

7.1 重打光与虚实融合

反照率最直接的用途就是重打光。拿到 A 之后,用一个新的光照场 S' 去乘,就能得到同一物体在新光照下的样子。这在电商图批量换背景、影视后期调整场景氛围、AR 里让虚拟物体和实拍环境的光照匹配,都是刚需。做实时光照匹配时,通常还会配合估计出的法线图,用法线和光源方向算 Lambert 项,再乘反照率,这套组合拳的效果比单纯调色要真实得多。

这里有个实践中的坑:重打光之后,原来的阴影位置会暴露。因为反照率图里其实还残留了一点点柔和的明暗(模型没法完全剥离),换光照后这部分残留会变成“脏影”。解决办法是在重打光前对 A 做一次低频抑制(高通滤波保留细节、压掉低频),或者干脆用估计出的 shading 做一个软掩膜,把原阴影区域的权重降下来。我一般用后者,效果更自然。

AR 虚实融合场景还有额外要求:反照率的时间一致性。视频序列逐帧估计,即使每帧都准,帧与帧之间的微小抖动也会让虚拟物体闪烁。工程上要么固定随机种子加时序平滑,要么用光流把上一帧的结果 warp 到当前帧作为条件的初始化。这个细节在静态图上完全看不出来,一上视频就原形毕露。

7.2 贴图重投影与材质库建设

做三维重建的朋友会把反照率直接当纹理贴图用:把多视角的反照率图投影回网格,做加权融合,得到一张干净的无光照贴图。相比直接用原始照片投影,反照率贴图没有烘死的阴影,重新打光时不会出现“阴影套阴影”的脏东西。这条路径在做数字资产、文物数字化、建筑扫描时特别有用。

融合的时候要注意曝光一致性。不同视角的图可能曝光不同,各自的全局对齐系数也不一样,直接平均会出现明暗斑块。正确做法是先把所有视角的对齐系数解出来,统一到同一个基准,再融合。另外,反照率图里的高光残留会在多视角融合时形成“亮斑环”,因为高光位置随视角变化,融合后会在物体表面留下一圈异常亮的区域。这个问题只能靠剔除高光视角或者人工修补,没有全自动的完美方案。

顺着这条路还可以做材质库:把估计出的反照率裁成小块,按材质类型(木、布、金属、石材)分类归档,配上粗糙度、法线等信息,就形成了一个可复用的 PBR 材质库。实际用下来,从实拍照片提取的材质比纯程序化生成的更耐看,因为真实世界的瑕疵和不规则性很难用手工参数模拟出来。

8. 我在实际项目里攒下的几条经验

第一条是关于什么时候该放弃自动结果。反照率估计的下游往往对错误很敏感,比如重打光之后客户一眼就能看出“这个木头颜色不对”。与其花两周调参数把错误率从 15% 降到 12%,不如做一个快速的人工筛选界面,把明显翻车的图挑出来手动修或者重拍。工程上,标注成本往往比模型优化成本低得多。

第二条是关于输入的预处理比模型调参重要。我做过一组对比:同一批图,一组直接送进模型,另一组先做白平衡校正、去噪、限制动态范围。后者的结果肉眼可见地更稳,尤其是暖光室内场景。模型再强也架不住输入本身的信息已经烂掉了。

第三条是别把反照率和去光照混为一谈。有些需求方要的其实是“去阴影”的图,不是物理意义上的反照率。这两个东西看着像,实际差别很大:去阴影保留了材质的明暗变化,反照率则要求把光照成分尽可能剥干净。搞错交付物,后面全是返工。开工前一定要拿几张样例图对齐效果预期,让对方指着说“我要的是这种”,比写十页需求文档都管用。

最后分享一个调试用的小工具,我几乎每个项目都会先写它:合成测试图生成器。用简单的几何体加已知反照率、已知方向光渲染出几张图,拿去做端到端测试。因为真值已知,任何环节出问题都能立刻定位——是预处理把颜色搞错了,还是模型输出范围没映射对,还是后处理对齐过头了。这个工具不到一百行代码,但省下的排查时间是以天计的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询