☰
深度学习老照片修复系统:退化建模、人脸先验与分块推理
2026/10/27 23:18:54 网站建设 项目流程

老照片修复这件事,我从三年前第一次动手做就栽过跟头。当时拿着一堆泛黄、带折痕、还有霉斑的家庭相片,心想不就是去噪加超分再加个上色吗,几个开源模型串起来就完事了。结果第一版跑出来的图,人脸被修成了塑料娃娃,衣服纹理全糊成一片,划痕倒是没了,因为整体都被抹平了。后来慢慢摸索,才把基于深度学习的老照片修复系统拆成一条可控的流水线:退化建模、分任务处理、人脸先验约束、工程侧分块推理,每一环都得单独调。这篇文章就把这套系统的设计与实现过程完整摊开讲,包括数据怎么造、模型怎么选、损失怎么配、大图怎么跑、出问题怎么查。适合已经懂一点PyTorch、想把这套东西真正落地成可用工具的朋友,也适合刚入门、想搞清楚"为什么我的修复结果总是糊"的新手对照着看。

1. 从需求倒推:老照片修复系统到底要解决什么问题

动手写第一行代码之前,我把手上那批样本摊在桌上一张张看,这个动作比看任何论文都管用。很多人一上来就冲着"修复"两个字去,直接找个图像复原网络开训,最后发现模型在合成测试集上PSNR很漂亮,一上真实老照片就翻车。根本原因在于"老照片退化"不是一个单一退化,它是好几种损伤叠在一起,而且每种损伤的物理成因、空间分布、可恢复程度完全不同。

1.1 用户手里的老照片,坏在哪里

我习惯把退化分成四类,这个分类直接决定了后面系统的模块划分。

第一类是结构性损伤,包括折痕、撕裂、划痕、霉斑、水渍、装订孔。这类损伤的特点是局部像素信息被彻底破坏,不是"变模糊",而是"没了"。你不可能从周围像素推断出一条划痕底下原本是什么,只能靠语义先验去"猜"。这也是为什么纯回归模型处理划痕总是糊——它在做插值,而人眼期待的是生成。

第二类是光学与传感器退化,包括失焦模糊、镜头畸变、胶片颗粒、扫描噪声。这类是全局性的,可以用反卷积或者去噪网络处理,相对好搞。

第三类是分辨率与压缩退化,老照片扫描件常见的问题,尤其是早期数码化时为了省空间压成了低质量JPEG,块效应明显。这块本质上是超分+去压缩伪影的联合任务。

第四类是色彩退化,泛黄、偏色、褪色、对比度丢失。黑白照片还涉及上色,这是另一个维度的任务,因为上色本身是病态问题——同一件衣服可以是任何颜色,模型只能给出"统计上最合理"的答案。

我在系统里给这四类分别留了处理位,而不是指望一个网络全包。原因很实在:一个网络同时学四件事,容量会被互相挤占,而且训练时的梯度方向会打架。结构损伤需要生成能力强的分支,色彩退化需要色彩空间约束强的分支,硬塞在一起,最后往往是两边都不讨好。

1.2 三种技术路线的取舍:端到端黑盒、分治流水线、先验增强

市面上能见到的实现思路基本就三条,我把当时的对比整理成了下面这张表:

路线做法优势硬伤适用场景
端到端黑盒一个网络输入退化图、输出干净图结构简单、推理一次搞定需要海量成对数据;多任务冲突严重;不可调试退化类型单一、有真实成对数据的场景
分治流水线划痕修复、去噪、超分、上色分模块串行每模块可单独优化和替换;便于定位问题误差累积;模块间色彩/亮度不一致退化类型复杂、需要可控输出的工程场景
先验增强在主干上挂人脸/语义/结构先验分支关键区域质量高;小样本下效果好依赖检测精度;先验错位会引入伪影人像占比高的老照片

我最终选的是分治流水线打底 + 关键环节挂先验的混合方案。理由有三个:一是老照片的退化组合太随机,单一网络很难覆盖;二是工程上我需要能单独替换某个模块,比如上色模型换一版不影响超分;三是用户反馈的badcase往往集中在人脸,挂一个人脸先验分支的收益远大于整体加容量。

1.3 系统边界与效果预期管理

这一点特别想强调,因为它决定了你会不会白干三个月。深度学习修复的上限是"合理重建",不是"还原真相"。一条贯穿人脸的划痕,模型补出来的鼻子可能形状对,但绝对不可能和历史上一模一样。所以我在系统里做了两件事:一是输出置信度提示,对损伤面积超过阈值的区域标注"重建结果,仅供参考";二是保留原图对照,让用户自己判断。

注意:不要向使用者承诺"完全还原"。我见过有团队把修复结果直接当档案原件用,这在实际业务里是会出问题的。

预期管理也影响技术选型:如果只是社交场景分享,色彩讨喜、皮肤干净比PSNR重要;如果是档案数字化,那必须优先保真,宁可保留一点噪点也不能生成不存在的细节。这两条路的技术方案差别很大,动手前必须问清楚。

2. 数据是命门:退化合成流水线的设计

做过图像复原的都知道,这类任务七分靠数据、三分靠模型。老照片修复最难受的地方在于:你几乎不可能拿到"同一张照片的损坏版和完好版"这样的成对数据。真实的档案里,完好版早就没了,剩下的只有坏的那张。

2.1 为什么必须自己造数据

我试过三条路。

第一条是找公开的成对老照片数据集,能找到的规模都很小,几百到几千对,而且退化类型集中在某一两种,训出来的模型泛化很差,换个扫描仪、换个年代就崩。

第二条是拿无配对数据做循环一致性训练,这条路能跑通,但训练不稳定,而且循环一致性约束只能保证"风格像",保证不了"内容对"。实测下来,人脸五官容易漂移,眼睛位置会挪。

第三条是自己写退化合成器,拿高清干净图当"真值",按物理模型往上面施加退化,造出成对样本。这条路我最后走了,因为它可控、可扩展、可以针对badcase定向补充。代价是你得把退化模型建得足够真,否则就是自欺欺人——模型学会的只是你写的那个退化函数,不是真实世界的退化。

2.2 退化因子的建模与参数区间

合成器的核心是把每种退化写成带随机参数的算子,参数区间必须贴近真实分布。我这里是按大量真实样本统计出来的经验区间,可以直接参考,但强烈建议你用自己的样本重标一遍。

退化类型建模方式参数区间(经验值)说明
划痕随机贝塞尔曲线 + 形态学膨胀曲线1~6条,宽度1~5px,随机亮度偏移±40曲线控制点要跨越多行多列,避免过短
霉斑随机椭圆 + 高斯模糊边缘半径2~15px,数量0~8个颜色偏青绿或灰白,透明度0.3~0.8
折痕直线 + 轻微弯曲 + 两侧亮度梯度长度占比30%~90%,宽度2~8px关键是要有阴影,纯白线不真实
高斯噪声加性高斯sigma 3~25按图像尺寸缩放,大图适当降低
胶片颗粒泊松噪声 + 低频调制强度0.02~0.1颗粒要有空间相关性,纯白噪声不像
失焦模糊高斯核卷积kernel 3~15,随机各向异性各向异性很重要,老镜头像差不是圆的
运动模糊线性核长度5~25,角度0~180扫描时手抖造成的方向性模糊
下采样双三次/Bilinear缩放0.3~0.8注意要用抗锯齿,否则产生假细节
JPEG压缩标准量化表quality 20~75二次压缩更真实
泛黄褪色通道增益 + 伽马 + 饱和度衰减R增益1.0~1.15,B增益0.75~1.0,gamma 0.8~1.2泛黄本质是蓝色通道衰减

这里有个细节值得单拎出来说:退化必须按顺序叠加,而且顺序有讲究。真实物理过程是先有光学模糊,再有胶片颗粒,然后化学老化褪色,最后扫描时产生噪声和压缩。如果你把顺序打乱,比如先加划痕再模糊,划痕边缘会被糊掉,和真实样本的锐利边缘对不上,模型学到的特征就是错的。我第一版就是随机打乱顺序,训出来的模型对划痕边缘特别不敏感,实测才发现问题。

2.3 合成脚本实现与踩坑

核心的合成函数我简化成了下面这样,可以直接拿去改:

import random import cv2 import numpy as np def random_scratch(h, w, n_curves=3): mask = np.zeros((h, w), dtype=np.uint8) for _ in range(n_curves): # 曲线起点终点随机分布在边缘附近 p0 = (random.randint(0, w - 1), random.choice([0, h - 1]) if random.random() < 0.5 else random.randint(0, h - 1)) p3 = (random.randint(0, w - 1), random.randint(0, h - 1)) p1 = (random.randint(0, w - 1), random.randint(0, h - 1)) p2 = (random.randint(0, w - 1), random.randint(0, h - 1)) pts = np.array([p0, p1, p2, p3], dtype=np.int32) width = random.randint(1, 5) cv2.polylines(mask, [pts], False, 255, width, cv2.LINE_AA) # 膨胀让边缘不那么整齐 k = random.choice([1, 3]) mask = cv2.dilate(mask, np.ones((k, k), np.uint8), iterations=1) return mask def apply_scratch(img, mask): out = img.astype(np.float32).copy() # 亮度偏移而不是纯白,纯白太假 offset = random.uniform(-40, 40) out[mask > 0] = np.clip(out[mask > 0] + offset, 0, 255) return out.astype(np.uint8) def color_fade(img): # 通道增益模拟泛黄 gains = np.array([random.uniform(1.0, 1.15), random.uniform(0.95, 1.05), random.uniform(0.75, 1.0)], dtype=np.float32) out = img.astype(np.float32) * gains # 伽马 gamma = random.uniform(0.8, 1.2) out = 255.0 * np.power(np.clip(out, 0, 255) / 255.0, gamma) # 饱和度衰减 hsv = cv2.cvtColor(out.astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32) hsv[..., 1] *= random.uniform(0.5, 1.0) out = cv2.cvtColor(np.clip(hsv, 0, 255).astype(np.uint8), cv2.COLOR_HSV2RGB) return out def degrade(img): h, w = img.shape[:2] img = apply_scratch(img, random_scratch(h, w, random.randint(1, 6))) if random.random() < 0.6: img = color_fade(img) if random.random() < 0.7: k = random.choice([3, 5, 7]) img = cv2.GaussianBlur(img, (k, k), random.uniform(0.3, 1.5)) if random.random() < 0.5: scale = random.uniform(0.4, 0.8) img = cv2.resize(img, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) img = cv2.resize(img, (w, h), interpolation=cv2.INTER_CUBIC) if random.random() < 0.8: sigma = random.uniform(3, 15) img = np.clip(img.astype(np.float32) + np.random.randn(h, w, 3) * sigma, 0, 255).astype(np.uint8) if random.random() < 0.6: img = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, random.randint(25, 75)])[1] img = cv2.imdecode(img, cv2.IMREAD_COLOR) return img

踩过的坑里最值得一提的两个。第一个是用cv2.polylines画直线划痕太整齐,模型很快就能学到"直线=划痕"这个捷径,结果真实照片里弯曲的折痕完全去不掉。后来加了贝塞尔曲线控制点随机,并且把线宽做成渐变,情况才好转。第二个是下采样时的插值方式,一开始用INTER_LINEAR,产生大量摩尔纹,模型把这些伪影也当成要修复的目标,最后输出的图全是振铃。换成INTER_AREA做缩小、INTER_CUBIC做放大之后,伪影明显减少。

2.4 真实样本的少量标注与微调

合成数据训练出来的模型,直接上真实样本一定有域差。我的做法是收集200~500张真实老照片,请人做粗标注——不需要像素级,只需要"这里要修好",用低质量的真值图(比如人工粗修的版本)做微调。

微调阶段有个技巧:冻结主干的前几层,只调后几层和先验分支。原因是合成数据学到的基础特征(边缘、纹理)是通用的,真实数据量太小,全量微调容易过拟合到那几百张图上。我实测冻结前1/3层,微调学习率设成预训练的1/10,效果最稳。

实操心得:微调集一定要包含"极差样本"——比如整张脸被划痕覆盖的、严重霉斑的。这类样本在训练里不能太多,否则模型会退化成"什么都往人脸方向生成",但完全没有,模型在真实badcase上就会暴露。

3. 模型选型与结构拆解

结构这块我没有追新,选的是成熟可用的组合,重点在于怎么把它们粘起来不打架。

3.1 主干网络:U-Net还是Transformer

先说结论:图像尺寸大、纹理复杂的老照片,用带注意力机制的Transformer结构更稳;追求推理速度和中等质量,U-Net变体够用。

我做过对比实验,同一批合成数据,U-Net在256×256 patch上训练,PSNR能到28dB左右,但一到512以上分辨率,细节就明显塌陷,主要原因是卷积的感受野有限,全局色彩一致性差,会出现局部色块。换成基于窗口注意力的Transformer主干后,同等数据下PSNR提升约1.2dB,纹理连贯性肉眼可见地好,代价是显存占用翻了三倍多。

对老照片这个场景,我的建议是分阶段用不同主干:划痕修复和去噪用U-Net,因为这两件事更依赖局部感受野,且需要快速推理;超分和上色用Transformer或者带全局注意力的混合结构,因为它们需要理解整体语义。

这里有个必须注意的点:Transformer类结构对训练数据量要求高,如果你的合成数据只有几万张,很容易欠拟合。我当时是用20万张合成patch起步的,少于这个量级,注意力分支基本学不出有意义的东西。

3.2 划痕去除分支的设计

划痕去除是整条流水线里最需要"生成能力"的一环。我的做法是把它当成条件生成 + mask辅助来做,而不是纯回归。

具体实现:输入除了退化图,还并联一个预测出来的损伤mask。这个mask可以由一个轻量分割网络给出,也可以由训练时的真值mask监督出来。有了mask之后,主干网络只需要关注mask区域,非损伤区域的像素直接shortcut保留。这样做的好处非常直接——非损伤区域不会被无谓地重绘,细节得以完整保留。

损失函数上,损伤区域用L1 + 感知损失,非损伤区域用强约束(直接保留),再加上一个对抗损失提升真实感。对抗损失的权重我压在0.02~0.05之间,太高会引入伪纹理,太低边缘发糊。

有个反直觉的经验:划痕分支不要追求把所有划痕都去掉。真实老照片里有些折痕是照片的"历史痕迹",全部抹掉反而显得假。我在系统里加了一个强度参数,用户可以选择"轻度修补"和"彻底修复",前者只处理高置信度的大面积损伤,保留浅表折痕。

3.3 人脸先验模块的接入方式

人像占了老照片的绝大部分,而人眼对人脸的瑕疵极其敏感。合成数据训出来的模型修人脸,最常见的问题是五官漂移、皮肤过度平滑、眼睛失去高光。

我试过三种接入方式。

第一种是串联:整图修完之后,检测人脸,裁剪出来送进人脸复原模型,再贴回去。实现简单,但贴回时的色彩和边缘融合是噩梦,接缝处会出现色差。

第二种是并联分支:主干网络在编码器某一层引出人脸区域的特征,单独过一个重建分支,解码时融合。这种方式融合自然,但训练复杂,得保证两个分支的梯度尺度匹配。

第三种是两阶段:第一阶段用主模型出粗修结果,第二阶段用人脸先验做精修,并且用泊松融合或者注意力融合贴回。我最后用的是这个,因为工程上最可控,出错时容易定位是哪一阶段的问题。

贴回时的融合是个关键细节。直接覆盖会在边缘产生硬边,我用的做法是:以人脸框为中心生成一个高斯权重图,在边缘区域做加权混合,权重图的sigma设成人脸框短边的0.1~0.15倍。这个参数调小了接缝明显,调大了人脸区域被原图污染,实测0.12左右比较平衡。

注意:人脸检测失败的图,一定要走fallback路径,不能让流程中断。老照片里侧脸、遮挡、低分辨率的情况太多了,检测器召回率远低于常规场景。

3.4 上色分支的色彩约束

黑白照片上色是另一个独立模块,它的难点不在模型结构,而在色彩不可判定。一件衣服是红的还是蓝的,模型没有任何依据。

我在上色上做了三条约束。

第一条是语义引导:先跑一个语义分割,把天空、植被、皮肤、衣物分开,然后对不同类别施加不同的色彩先验。比如天空偏蓝、植被偏绿,肤色限制在一个较窄的色域内。这个做法能显著减少"绿脸""紫天空"这类离谱结果。

第二条是色彩空间选择:不在RGB上做回归,转成Lab,只预测ab两个通道,L通道保留原图亮度。这样亮度信息完全不会丢失,避免了上色之后发灰的问题。

第三条是多点提示:给用户一个可选操作,在图上点几个颜色点,作为条件输入。技术上就是把点坐标和颜色编码成稀疏的condition map,和图像一起送进网络。这个功能在真实使用中反馈极好,因为用户自己知道照片里的人穿的是什么颜色。

4. 损失函数与训练策略

损失配比这块,我调了大概两周,试了十几个组合。这部分没法完全照搬别人的参数,因为你的数据分布、任务侧重都不一样,但下面这套思路和量级可以作为起点。

4.1 多任务损失的配比

我最终用的组合是五项:

损失项作用权重(经验值)说明
L1 重建损失保证像素级接近真值1.0作为基准尺度,其他项都相对它调
感知损失保持高层语义和纹理0.1用预训练VGG的relu3_3和relu4_3特征
对抗损失提升真实感和细节锐度0.03用PatchGAN判别器,感受野70×70
身份损失约束人脸身份一致0.5只在人脸区域内计算
色彩损失约束上色结果的色相分布0.05在ab通道上算,缓解偏色

先说权重的确定逻辑。L1作为基准设为1.0,是因为它数值量级最容易控制,其他项都按"比L1小一到两个数量级"来配。感知损失定0.1,是我实测下能明显改善纹理又不至于引入伪影的位置;再往上加会出现"纹理堆砌",平坦区域被填上莫名其妙的细节。对抗损失压到0.03,是因为老照片修复的判别器太容易训强,权重一高,生成器就开始往判别器的偏好上偏,输出塑料感极重的图。

身份损失值得多说一句。这个损失的作用是让修复前后的人脸在特征空间里保持接近,防止模型把张三修成李四。实现上就是把人脸裁剪出来,过一个人脸特征提取网络,算余弦距离。权重0.5听起来很大,但因为它的数值范围在0~2之间,实际影响没想象中那么强。注意这个损失只在检测到人脸时计算,没有人脸的patch要跳过,不然会引入噪声梯度。

4.2 两阶段训练与梯度平衡

直接带对抗损失从零训,十次有八次崩。我的做法是分两阶段。

第一阶段:纯重建预训练。只开L1 + 感知损失,训到收敛。这个阶段的目标是让模型学会"退化图到干净图"的基本映射,输出虽然糊但结构正确。学习率1e-4,Adam优化器,余弦退火,batch size 8,patch 256。这一步大概需要80~120个epoch,具体看数据量。

第二阶段:加入对抗损失和身份损失微调。生成器和判别器交替训练,生成器学习率降到2e-5,判别器用5e-5。这里有个关键技巧:判别器不能训太强。我的做法是给判别器加一个梯度惩罚项,同时监控判别器的准确率,一旦超过85%就暂停判别器更新几轮。判别器太强,生成器的梯度就全是噪声,输出会变得花里胡哨。

梯度平衡上,我一开始用的是固定权重,结果发现训练到中后期,对抗损失会突然主导,L1的贡献被淹没。后来改成自适应权重——按各项损失相对于其历史均值的比值动态缩放,让各项的有效梯度量级接近。这套机制实现起来不复杂,但对训练稳定性的提升非常明显。

4.3 训练中的稳定性技巧

几个血泪教训。

第一个是数据加载的随机性。退化合成器里的随机数种子必须每个epoch都变,但如果用多进程DataLoader,要注意每个worker的种子不能相同,否则八个worker生成的退化样本一模一样,等于数据量缩水八倍。我在这上面浪费了一整天才发现loss曲线异常地平滑。

第二个是混合精度训练的陷阱。用AMP能省一半显存,但感知损失和对抗损失在fp16下容易溢出,尤其是对抗损失的log项。我的处理是把这两项的计算强制转成fp32,只让主干前向走fp16,速度损失不大,稳定性提升明显。

第三个是验证集的选择。验证集一定要包含两类:合成退化的(看基础能力)和真实老照片的(看泛化)。只有合成验证集的话,你会被虚高的指标骗过去。我一般按8:1:1划分训练/合成验证/真实验证,真实验证集虽然小,但每次看它的结果最有参考价值。

5. 工程化落地:从单张推理到批量服务

模型训出来只是开始,真正难的是让它在普通设备上跑得动、跑得快、跑得稳。

5.1 分块推理与大图显存控制

老照片扫描件动辄4000×6000,直接送进网络必然爆显存。分块推理是标准解法,但细节很多。

我的分块策略是:块大小512,重叠64像素,用余弦窗做加权融合。重叠区不能省,因为卷积和注意力的边界效应会导致块边缘质量下降,重叠64能覆盖大部分感受野。融合时不能用简单平均,否则重叠区的纹理会有明显的重影,用余弦窗(中间权重高、边缘权重低)能平滑过渡。

def tile_inference(model, img, tile=512, overlap=64, scale=1): h, w = img.shape[:2] out = np.zeros((h * scale, w * scale, 3), dtype=np.float32) weight = np.zeros((h * scale, w * scale, 1), dtype=np.float32) # 余弦窗 wy = np.hanning(tile * scale).reshape(-1, 1) wx = np.hanning(tile * scale).reshape(1, -1) win = (wy * wx)[..., None].astype(np.float32) + 1e-6 step = tile - overlap for y in range(0, h, step): for x in range(0, w, step): y2 = min(y + tile, h) x2 = min(x + tile, w) patch = img[y:y2, x:x2] # 边缘补齐到固定尺寸,否则模型输入尺寸不一致 ph, pw = patch.shape[:2] pad = np.zeros((tile, tile, 3), dtype=patch.dtype) pad[:ph, :pw] = patch pred = model(pad[None])[0] pred = pred[:ph * scale, :pw * scale] out[y*scale:y2*scale, x*scale:x2*scale] += pred * win[:ph*scale, :pw*scale] weight[y*scale:y2*scale, x*scale:x2*scale] += win[:ph*scale, :pw*scale] return (out / np.maximum(weight, 1e-6)).clip(0, 255).astype(np.uint8)

这里有个坑:tile尺寸必须是模型下采样倍数的整数倍。如果你的主干有四次下采样,tile必须能被16整除,否则特征图尺寸对不齐,要么报错要么静默出错。我一开始用500,调试了半天才发现问题。

还有个优化点:分块推理时,如果相邻块的内容高度相似(比如大片天空),可以跳过后面的计算。但这个优化要谨慎,容易在纹理交界处留下痕迹,我最后没上。

5.2 模型压缩与推理加速

部署到普通设备上,原始模型太大了。我做了三步压缩。

第一步是剪枝。对卷积通道做结构化剪枝,按BN层的缩放因子排序,剪掉最小的20%。这一步模型小了约25%,PSNR掉不到0.3dB,性价比很高。剪完要微调10个epoch恢复性能。

第二步是量化。导出成ONNX之后做INT8量化,配合校准集。这里要注意校准集必须覆盖你的真实数据分布,用纯合成数据校准,量化后的模型在真实老照片上掉点会很明显,可能掉2dB以上。INT8之后模型体积大概是fp32的四分之一,速度提升两倍左右。

第三步是推理引擎适配。把量化后的模型转到TensorRT,用FP16模式跑。实测在中等配置的显卡上,512×512的patch推理时间从180ms降到45ms左右。如果目标设备没有独立显卡,那就退回ONNX Runtime的CPU推理,配合多线程,一张2000×3000的图大概需要6~10秒,也能接受。

提示:量化之后一定要做一次全量回归测试。我遇到过一个很隐蔽的问题——INT8量化后,模型对浅色划痕的敏感度下降,导致浅色背景上的划痕修不掉。这种问题在单个样本上看不出来,必须批量测。

5.3 前后端接口与批处理队列

系统接口我设计成异步任务制,而不是同步请求。原因很简单,一张大图处理可能要十几秒,同步请求必然超时。

流程是这样:前端上传图片,后端返回一个task_id,把任务丢进队列;工作进程从队列取任务,依次跑划痕修复、去噪、超分、上色、人脸精修,每完成一步更新进度;前端轮询或者用长连接拿进度,完成后拿结果URL。

队列我用了两种优先级:单张交互式的走高速队列,批量任务走普通队列。批量任务的并发度要控制,因为每个任务都吃显存,开太多会OOM。我的经验值是单卡并发不超过2个任务,再高就排队等着,不然全是超时重试,整体吞吐反而下降。

结果存储上,原图和修复图都要留,并且存一份处理参数(用了哪些模块、强度多少)。这个日志在后期排查问题时价值极大——用户投诉"这张修坏了",你能立刻复现出当时的处理链路。

6. 效果评估与常见问题排查

6.1 指标之外的主观评估

PSNR和SSIM在合成测试集上很有用,但它们和人的主观感受相关性很差。一张PSNR 30dB的图可能看起来很糊,一张PSNR 26dB的图可能看起来很自然,因为GAN类的输出本来就不追求像素级对齐。

我建了一套自己的评估流程,分三层。

第一层是客观指标,在合成测试集上跑PSNR、SSIM、LPIPS。LPIPS比前两个更贴近人眼判断,我一般以LPIPS为主。真实数据没有真值,就跑无参考指标,比如NIQE和BRISQUE,但这俩指标对GAN输出不太友好,参考价值有限。

第二层是人工打分。我组织了5个人,每人看100组图,从"清晰度""自然度""保真度"三个维度打1~5分。样本要盲测,不能让人知道哪个是模型输出。这一步最费时间,但最准。

第三层是A/B对比。拿新版本和上一版本各修同一批图,让人二选一。这个方法特别适合判断"改动到底有没有用",比绝对打分更敏感。

有个数据可以分享:在我的测试集上,单纯优化PSNR从27.5提到28.2,人工打分几乎没有变化;但把对抗损失权重从0.01调到0.03,PSNR反而降了0.4,人工打分的"自然度"提升了0.6分。这说明什么,不言而喻。

6.2 典型问题速查表

下面这张表是我这两年攒下来的,遇到问题基本能对号入座:

现象可能原因排查方法解决方向
输出整体发灰、对比度低上色模块L通道被改动;对抗损失权重过低单独跑上色模块看L通道差异锁定L通道;提高对抗权重到0.03
人脸变成"塑料娃娃"身份损失或对抗损失过强;感知损失权重过高关掉对抗损失看是否恢复降低对抗权重;加肤色纹理约束
划痕去不干净,尤其是浅色划痕合成数据里划痕对比度太高;量化掉点检查合成器亮度偏移范围扩大偏移范围到±40;重新校准量化
大图出现明显方块接缝tile重叠不足;融合权重不合理可视化weight图重叠加到64以上;用余弦窗
天空、墙面出现莫名纹理对抗损失导致纹理幻觉对比有无对抗损失的输出降低对抗权重;对平坦区域加平滑约束
上色后颜色离谱(绿脸、紫天)缺少语义引导;色彩损失权重不足检查分割模块输出加语义先验;提高色彩损失
推理速度突然变慢输入尺寸触发非最优tile;显存不足触发换页打点记录每个模块耗时调整tile尺寸;限制并发
部分图片处理到一半失败人脸检测异常;图像通道数不为3加异常捕获和日志加fallback;统一预处理为RGB

6.3 实操心得

最后分享几条不太好写在文档里、但实际很管用的经验。

关于迭代顺序。不要试图一次把所有模块都做到最好。我的做法是先把"去噪+超分"这条最基础的链路跑通,做到能出图;再加划痕修复;最后加上色和人脸精修。每加一个模块,都做一次完整的回归测试,确保没有破坏已有能力。我见过有团队并行开发四个模块,最后集成时互相冲突,返工了两个月。

关于badcase的收集。系统上线后,一定要有便捷的badcase反馈通道。用户点一下"这张修得不好",图和参数就自动进到一个待分析池里。我每周会花半天看这些样本,按退化类型归类,然后定向往合成器里补充对应的退化。这个闭环跑起来之后,模型效果的提升比盲目加数据快得多。

关于参数的暴露程度。一开始我把所有参数都藏在后台,用一个"智能模式"应付所有图。结果用户抱怨很多,因为不同类型的照片需求完全不同——证件照希望保守,艺术照希望鲜艳。后来我把"修复强度""上色饱和度""人脸精修程度"三个滑块放出来,投诉率降了一大半。让用户参与控制,比让模型猜要靠谱。

关于色彩的一致性。分模块处理最大的副作用是色彩不统一,比如划痕修复模块输出的偏冷,上色模块又偏暖,拼在一起很怪。我的解法是在流水线末端加一个轻量的色彩对齐模块,以原图的整体色调为参考,对结果做一次全局的色彩迁移。这个模块很小,就是在Lab空间做均值方差匹配,但效果立竿见影。

关于长尾退化。真实老照片里总有一些你没想到的退化,比如照片背面透过来的字迹、相纸的纹理、装订孔的阴影。这些在合成数据里没有,模型处理起来会当成正常内容保留下来。我的处理是加一个"异常检测"的旁路,用无监督方法找出与整体分布差异大的区域,标记出来提示用户。不一定要自动修,能提示就已经很有价值了。

这套系统我从最初的一版跑通到现在,前后迭代了十几个版本,最大的感受是:老照片修复的难点从来不在模型结构上,而在数据、在退化建模、在对"什么算修好了"的判断标准上。模型可以换,流水线的设计思路和对真实场景的理解才是真正决定成败的东西。

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

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

立即咨询