简介:面向图像处理爱好者与打码素材细节复原需求的「马赛克去除」工具包,围绕去马赛克这一图像复原话题,聚焦超分辨率重建、频域分析与深度学习三类思路的落地实践。压缩包共5个文件,以2个可执行程序为主体,配合1个动态链接库承担图像解码与处理调用,另附说明文档与页面说明各一份,便于快速了解参数调整方式与使用边界;整包仅267KB,体积轻量,解压即可运行。已有2725人学习下载,可见图像清晰化、老照片细节还原等场景的关注度。读者可从中获得一套可直接运行的去马赛克处理环境,理解像素块混色导致细节丢失的成因,并借说明文字体会不同强度马赛克下的效果差异与局限——当马赛克程度过高或原始信息缺失严重时,完全复原并不现实,需合理看待。
1. 马赛克去除:被平均掉的信息不会自己长回来
一张十年前的家庭合影,人脸那一小块被压缩成一格一格的色块,很多人第一反应是找个“马赛克去除”工具点一下。真动手就会发现,市面上多数号称能去马赛克的模型,输出要么糊成一团油画,要么在脸上糊出一张完全不属于那个人的五官。原因不玄学:常见的马赛克是块内均值 + 无重叠下采样,每个 p×p 的格子里只剩一个数,另外 p²−1 个像素被加和平均掉了。这是信息销毁,不是信息隐藏。
所以这篇文章讲的是“在已知退化算子的约束下做最大后验重建”,不是“还原真相”。它解决的是结构、边缘、纹理统计的恢复,能做到让一张被压缩到看不清的旧照重新具备可读性;做不到的是把陌生人的脸“认”回来。适合三类人看:手里握着原始素材、有权处理的影像修复从业者;做超分/复原方向、想搞清楚退化建模的算法工程师;以及需要验证“去标识化后的数据能不能被反推”的安全评估人员。
2. 先估块再谈还原:马赛克退化模型的三个参数
整个流程里最容易翻车、也最容易被跳过的一步,是把“打码”这件事写成一个数学算子。绝大多数开源超分模型在这一步就已经错了,它们假设输入是双三次下采样加高斯模糊,而真实马赛克是块内常量。这两个退化在频域上的表现完全不同,用错算子,后面所有步骤都是在错误的地基上盖楼。
2.1 把“打码”写成可求逆的退化算子
先把两种常见的“马赛克”分开。第一种是均值池化,每个 p×p 块输出一个平均数,这是大多数打码工具和视频平台隐私处理的默认行为,信息不可逆。第二种是像素化后最近邻放大,块内所有像素值完全相同,这种其实是可逆的——只要网格对齐,取每块左上角一个像素就能无损退回到 1/p 分辨率,剩下的所谓“去除”只是普通的超分放大。很多人把第二种当第一种处理,白白喂给扩散模型,结果反而比直接取点更差。
对第一种,退化模型写成:
y = A_p(x) + n, A_p = AvgPool(p, stride=p) # 无重叠,可加偏移 (ox, oy)三个参数决定这条式子准不准:块大小 p、块对齐偏移 (ox, oy)、以及是否叠加了二次有损压缩(JPEG 质量、色度下采样)。p 差一个像素,A 就错了,后面的投影会把错误结构一次次写回图像,输出出现网格重影——这个坑后面单独讲。
2.2 用梯度自相关估计块大小 p
块内像素相同意味着梯度为零,块边界意味着梯度跳变。所以对图像沿水平方向求一阶差分、再沿垂直方向取平均,会得到一条“大部分是零、每隔 p 个位置冒一个尖峰”的曲线。对这条曲线做自相关,第一个显著的非零峰就落在 p 上。
import numpy as np def edge_profile(gray, axis=1): """axis=1 检测竖直块边界(沿宽度方向的周期),axis=0 检测水平边界""" d = np.abs(np.diff(gray.astype(np.float32), axis=axis)) return d.mean(axis=1 - axis) # 压成一维曲线,长度 = W-1 或 H-1 def estimate_period(prof, p_min=2, p_max=32): prof = prof - prof.mean() # 去直流,否则自相关被均值主导 ac = np.correlate(prof, prof, mode='full')[len(prof) - 1:] ac = ac / (ac[0] + 1e-8) # 归一化到 1 seg = ac[p_min:p_max + 1] p = int(np.argmax(seg)) + p_min return p, float(seg.max()) # 峰值高度可当置信度 def estimate_offset(gray, p, axis=1): """块边界应落在 ox + k*p 处,逐偏移求边界梯度均值,取最大""" prof = edge_profile(gray, axis=axis) scores = [prof[o::p].mean() for o in range(p)] return int(np.argmax(scores))p_min/p_max是搜索范围,人脸打码常见 8~24,重度打码会到 32 以上,扫描范围开大一点只会多花几毫秒。seg.max()返回的置信度很有用:低于 0.3 基本说明这张图不是规整块状马赛克,可能是模糊、涂抹或者不规则的遮挡,不要再往下走条块状流程。estimate_offset里prof[o::p]是取所有落在o, o+p, o+2p...位置的梯度值,偏移对的那一组均值会明显高出一截。
实际使用要对两个方向分别估计,取两者一致的 p;如果水平方向估出 12、垂直方向估出 9,多半是图像被非等比缩放过,需要先按比例还原到原始网格尺度再重估。
注意:截图、二次缩放、UI 里的缩放预览都会破坏块网格。尽量拿原始文件,不要用截图工具另存。
2.3 块对齐偏移与色度平面的两倍陷阱
偏移 (ox, oy) 是最隐蔽的参数。裁剪、加边框、视频编码器对齐,任何一个环节挪动几个像素,网格就整体平移,模型照样能生成一张看起来“还行”的图,但块内均值约束全部错位,边缘会出现规律的锯齿状重影。做法很直接:在estimate_offset的候选里取分数最高的那个,并且在整条流水线上保持不变——预处理裁剪的尺寸取 p 的整数倍,数据增强禁用随机裁剪,否则前面估计的偏移到推理时又变了。
彩色图像还有第二个坑。JPEG 和多数视频编码使用 YCbCr 4:2:0,色度平面在水平和垂直方向都被下采样了 2 倍。这意味着在 Cb/Cr 平面上估计出来的块大小是亮度平面的一半。如果直接在 RGB 三通道上做平均估计,得到的 p 会夹在两者之间,看起来“差不多对”,实际两个平面都不对。正确做法是把图转到 YCbCr,只在 Y 通道估计 p 和偏移,重建时也只在 Y 通道施加均值约束,色度平面用重建后的亮度引导上采样,或者干脆在色度上单独跑一遍超分。
还有一个常被忽略的检查:把估计出的 A 作用到你自己的重建结果上,A(x_hat)应该和观测y几乎完全一致,残差在块均值意义下接近零。这个自检三行代码就能写,很多人跳过它,结果在错误的 p 上跑了半小时采样。
3. 扩散先验 + 观测投影:把平均池化塞进反向采样
拿到 A 之后,问题变成一个标准的线性逆问题:已知 y = A x + n,求 x。这是一个严重欠定的方程组,一个 p×p 块只有一个方程却有 p² 个未知数,解空间巨大。要挑出“合理”的那一个,必须引入先验。经典做法是 TV 正则或者 BM3D,只能补出平滑边缘;真正能让脸重新长回来的,是预训练扩散模型里那份关于自然图像和人类面孔的强先验。
3.1 为什么通用超分模型在马赛克上会翻车
Real-ESRGAN 一类模型在训练时用的是“模糊核 + 随机缩放 + 噪声 + JPEG”的复合退化,学的是从模糊低频里猜高频。它们从没见过“块内所有像素完全相同”这种极端结构,于是要么把块边界当成真实边缘保留下来,输出一层洗不掉的格子纹理;要么把格子当作噪声强行抹平,人物五官跟着一起糊掉。
更麻烦的是这类模型是端到端黑匣子,你没法在推理时告诉它“这块区域的平均值必须等于 137.4”。扩散模型的优势就在这里:它把生成过程拆成几十步,每一步都能插入一个显式的投影操作,把观测约束重新焊回去。
3.2 range-null 分解:一致性投影的数学形式
设 A 是均值池化算子。对于无重叠均值池化有一个很好的性质:
A A^T = I => A† = A^T其中 A^T 是把每个块的值广播回该块所有像素(最近邻上采样)。因为对常量块再求一次均值还是它自己,所以这个等式成立。于是给定扩散模型在某一步预测出的 x0,可以把解拆成两部分:
x0_hat = A† y + (I - A† A) x0 = A^T y + (x0 - A^T A x0)第一项A^T y是“观测决定的、必须满足的部分”,第二项是“观测管不着的部分”(零空间分量),完全交给生成先验去填。A^T A的作用是把 x0 做一次块内平均再广播回去,x0 - A^T A x0正好是每个像素减去它所在块的均值,也就是纯高频细节。这个分解的好处是严格满足观测一致性,不像 DPS 那样用梯度近似、需要调一个 guidance 权重去平衡。
3.3 一个 40 行的 DDNM 采样循环
import torch def make_ops(p, device): pool = torch.nn.AvgPool2d(p, stride=p) A = lambda x: pool(x) At = lambda y: y.repeat_interleave(p, -2).repeat_interleave(p, -1) return A, At @torch.no_grad() def restore(unet, sched, y, p, prompt_emb, steps=40, t_start=400, eta=0.0, device='cuda'): A, At = make_ops(p, device) x = torch.randn_like(At(y)) # 在图像空间初始化 ts = torch.linspace(sched.num_train_timesteps - 1, 0, steps).long() ts = ts[ts <= t_start] # 只在前段注入观测 for i, t in enumerate(ts): a_bar = sched.alphas_cumprod[t].to(device) eps = unet(x, t, encoder_hidden_states=prompt_emb).sample x0 = ((x - (1 - a_bar).sqrt() * eps) / a_bar.sqrt()).clamp(-1, 1) x0 = x0 + At(y - A(x0)) # 一致性投影,AT 即 A† if i == len(ts) - 1: return x0 t_next = ts[i + 1] a_next = sched.alphas_cumprod[t_next].to(device) # DDIM 确定性回加噪;eta>0 时混入随机项,用于增加多样性 sigma = eta * ((1 - a_next) / (1 - a_bar)).sqrt() * (1 - a_bar / a_next).sqrt() x = a_next.sqrt() * x0 + (1 - a_next - sigma**2).clamp(min=0).sqrt() * eps if eta > 0: x = x + sigma * torch.randn_like(x) return x0逻辑上是三步循环:unet预测噪声 → 换算成当前步的 x0 估计 → 做一次 range-null 投影把观测按回去。x0 + At(y - A(x0))这一行是整个方法的核心,y - A(x0)是当前重建结果在块均值上与观测的偏差,At把偏差均匀分摊回块内每个像素。因为A At = I,一次投影之后偏差严格归零,不需要迭代收敛。
t_start=400控制从调度表的哪一步开始注入观测。数值越大(越靠近纯噪声端)注入越晚,生成先验发挥的空间越大,细节更丰富但也更容易“编”;数值越小注入越早,输出更贴观测但容易保留块状痕迹。500 步调度下我一般在 300~500 之间试三档。p必须和第二章估计出来的完全一致,这是唯一一个“错一点都不行”的参数。
3.4 四个要手动调的量
| 参数 | 常用区间 | 调大之后 | 调小之后 |
|---|---|---|---|
t_start | 300–500(1000 步调度) | 细节更丰富,身份漂移风险上升 | 更贴观测,格子残留明显 |
steps | 30–50 | 收益快速递减,耗时线性增长 | 低于 20 出现明显条纹伪影 |
eta | 0–0.2 | 纹理多样,帧间不稳定 | 完全确定,同一输入必得同一输出 |
p | 由估计给出 | 网格重影、块边界错位 | 过度平滑、细节被抹掉 |
eta在单张图上无所谓,做视频必须设 0,否则每帧的随机项不同,肉眼看到的就是闪烁。steps有个经验拐点:超过 50 步之后 PSNR 基本不动了,但观感还在缓慢变好,因为后期步数主要在修高频纹理。
4. 造数据与微调:让模型认识“块内均值”这种退化
zero-shot 加投影能得到结构正确的图,但质感往往偏“油画”——皮肤纹理过于均匀,头发变成一坨。原因是底座模型的训练分布里没有块内均值这种极端退化,它只能靠通用先验硬猜。想让它真正认识这种退化,最有效的办法是自己造配对数据做轻量微调。
4.1 合成退化对:p、JPEG、噪声三件套
import torch, torch.nn.functional as F, io from PIL import Image def degrade(hr, p, q=75, sigma=1.0, gen=None): """hr: (3,H,W) in [-1,1] -> lr(块均值图,保持原分辨率,值域一致)""" h, w = hr.shape[-2:] ph, pw = (p - h % p) % p, (p - w % p) % p x = F.pad(hr, (0, pw, 0, ph), mode='reflect') y = F.avg_pool2d(x, p, p) # 块内均值,无重叠 y = y.repeat_interleave(p, -2).repeat_interleave(p, -1) # 广播回原尺寸 if q is not None: # 模拟二次 JPEG buf = io.BytesIO() arr = ((y.clamp(-1, 1) + 1) * 127.5).byte().permute(1, 2, 0).cpu().numpy() Image.fromarray(arr).save(buf, format='JPEG', quality=q) y = torch.from_numpy( np.array(Image.open(buf)).astype('float32') / 127.5 - 1 ).permute(2, 0, 1) if sigma > 0: y = y + sigma / 255.0 * torch.randn_like(y) return y.clamp(-1, 1)关键在avg_pool2d之后立刻repeat_interleave还原尺寸——这样输出的空间尺寸和原图一致,训练时不用处理分辨率变化,但也意味着模型必须学会忽略块内那个恒定的值、只在块边界上做决策。reflectpadding 是为了让块网格从左上角 (0,0) 开始对齐;如果训练数据里有随机裁剪,务必裁成 p 的整数倍,否则每个样本的网格相位都不同,模型会学出一个“平均意义上”的模糊解。
p的采样分布必须覆盖推理时可能遇到的范围。人脸打码集中在 8~24,那就按{8, 10, 12, 16, 20, 24}均匀采样,再混入少量 4~6 的轻度和 28~32 的重度。只用一个固定 p 训练,模型会把那个块尺寸刻进权重,换个 p 就崩。JPEG 质量q在 35~95 之间随机,sigma在 0~3 之间随机——真实素材几乎不可能没有二次压缩。
4.2 条件注入与 LoRA 微调
有两种把 p 告诉模型的方式。省事的做法是按 p 分桶训练多个 LoRA,推理时按估计出的 p 选对应的那个,权重量小、互不干扰,缺点是 p 落在桶之间时效果会掉。灵活的做法是把 p 编码成一个 embedding 加到时间步 embedding 上,或者拼一张块尺寸指示图(把 p 值归一化后铺成一张常量通道)作为额外条件输入,一个模型覆盖所有 p,代价是要把 p 也当成训练随机变量喂进去。
微调范围建议只动 cross-attention 的投影层和 decoder 的后几层,学习率 1e-4,batch 8,分辨率 512 随机裁。步数不用多,几千步就能看到质感变化——这是在微调一个已经很强的先验去适配一个新的退化算子,不是在从零学图像分布。步数上去之后容易过拟合到合成数据的 JPEG 伪影上,真实素材反而更差,这个后悔药不好吃。
4.3 损失函数:L1 保底、LPIPS 管观感、频域管边缘
loss = 1.0 * F.l1_loss(pred, gt) \ + 0.1 * lpips(pred, gt) \ + 0.05 * F.l1_loss(torch.fft.rfft2(pred).abs(), torch.fft.rfft2(gt).abs())L1 负责像素级的整体一致性,防止颜色漂移。LPIPS 拉观感,让皮肤和头发不那么塑料,权重超过 0.3 就开始“编”五官了。频域幅度损失补的是边缘——纯空域损失在高频上权重天然偏低,模型倾向于输出平滑解,加上 FFT 幅度约束能把块边界处的锐度拉回来,同时不引入振铃。
身份损失(ArcFace embedding 距离)不建议在通用流程里加。它确实能让脸更“像”,但前提是训练对里的 gt 和输入是同一个人,而你手上真实的打码素材根本没有 gt。在纯合成数据上加身份损失,模型学到的是“把 embedding 拉向训练集里的人脸中心”,泛化到真实素材上会把人脸拉成一张平均脸。
5. 避坑与排查:五条踩过的记录
这一节是按“现象 → 原因 → 解决”记的,都是跑真实素材时反复撞上的。
5.1 输出整体偏移半格,边缘出现规律锯齿
现象:重建结果看着清楚,但叠加原图对比时,所有结构都往同一个方向偏了一到几个像素,块边界处有锯齿状重影。
原因:偏移 (ox, oy) 估错,或者预处理链路里某个环节动了裁剪。最常见的是先用center_crop裁到 512 再估计 p,但裁剪起点不是 p 的整数倍,网格相位就变了。
解决:在未裁剪的原始图上估计 p 和偏移,然后把裁剪起点对齐到ox + k*p。整条链路里禁止随机裁剪、禁止 resize 到非整数倍。跑之前做一次自检:A(At(y))应该等于y,A作用在重建结果上与y的残差应该接近零。
5.2 合成测试集 PSNR 32dB,真实素材上一塌糊涂
现象:在自己的合成验证集上指标很好看,换到真实素材,人脸结构和纹理全崩。
原因:合成数据的退化和真实退化不匹配。真实素材除了均值池化,还经过了视频编码、色度下采样、多次转码,块网格可能被轻微缩放破坏;合成数据里这些都没有。
解决:合成流程里必须加 JPEG 二次压缩和色度下采样模拟,并且预留一组“脏”数据(截图、转码过的)做验证。指标只在同源数据上有意义,跨分布的 PSNR 数字不用看。
5.3 彩色素材出现横向色带和斑马纹
现象:灰度图正常,彩色图重建后沿水平方向出现规则色带,人脸肤色一块一块的。
原因:4:2:0 色度下采样,亮度平面的块大小是 p,色度平面上是 p/2。如果在 RGB 上统一估计 p,色度平面会用一个错误的、大约两倍的块去做投影。
解决:转 YCbCr,只在 Y 通道估计 p 和偏移并施加约束,Cb/Cr 用重建后的 Y 引导上采样。或者更简单:色度平面单独用双三次放大后做轻微高斯平滑,人眼对色度的高频不敏感,省下来的算力全给 Y 通道。
5.4 网格重影越迭代越重,最后整张图全是格子
现象:跑到 20 步之后,格状纹理不减反增,越往后越明显。
原因有两个。一是 p 估错,A和真实退化差一两个像素,每次投影都把错误结构强行写回去,误差累积。二是t_start设得过大,注入太晚,此时 x0 的估计本身就偏离很远,投影把大量错误的高频塞进零空间。
解决:先验证 p——把估计出的A作用到观测上,看残差是否近似为零;不满足就重估。然后t_start降到 200~300 试一档。如果仍然有格子,说明模型先验本身不够强,先做微调再回来。
5.5 视频逐帧处理,播放起来疯狂闪烁
现象:单帧看都能接受,连起来播放像在闪,块边界一跳一跳。
原因:每帧独立初始化噪声,eta > 0时还引入额外随机性,帧间的高频细节完全不同。
解决:三步。固定初始噪声 latent(同一个 seed 生成,或用上一帧的 latent);用光流把上一帧的重建结果 warp 到当前帧,作为额外约束项加入损失(权重 0.1 左右);eta设为 0,保证确定性和可复现性。这套下来闪烁能压到可接受范围,剩下的靠时域后处理(小幅时域滤波)兜底。
6. 验收与进阶:一致性、时序和一套能复用的检查表
跑通不代表做对。我现在的习惯是先跑三行自检,再看图。
# 1) 算子自洽:A(At(y)) 必须等于 y assert torch.allclose(A(At(y)), y, atol=1e-5) # 2) 观测一致性:重建结果再池化一次,应与观测吻合 res = (A(x_hat) - y).abs().mean().item() # 应 < 1e-3(8bit 尺度归一化后) # 3) 退化不可逆性检查:块内方差应接近 0,否则说明不是均值池化 blk = x_hat.unfold(-2, p, p).unfold(-1, p, p) print(blk.var(dim=(-1, -2)).mean().item()) # 远大于 0 说明模型在“编”块内细节第三行是判断输出可信度的实用指标。均值池化意味着块内方差在原始观测里恒为 0,一个健康的重建结果,块内方差应该低但不是 0——完全为 0 说明模型没在填细节(退化成最近邻上采样),高得离谱说明它把块内当成了自由发挥的画布,那块区域的输出基本不可信。这个数字可以画成热力图,直接标出图里哪些地方是“猜的”,做安全评估时特别有用。
视频的时域一致性我在上一节提了三步,这里补一个细节:光流 warp 用重建后的结果算,不要用带马赛克的原始帧算。块状马赛克的梯度在光流里会被当成强纹理,算出来的运动场全是噪声。
推荐的起始参数,按 1000 步调度:
| 场景 | p来源 | t_start | steps | eta | 备注 |
|---|---|---|---|---|---|
| 单张人脸重码 | Y 通道估计 | 400 | 40 | 0 | 先微调再跑,差距明显 |
| 轻码旧照 | Y 通道估计 | 300 | 30 | 0 | p≤8 可直接用超分打底 |
| 视频逐帧 | 首帧估计后固定 | 350 | 40 | 0 | 固定噪声 + 光流约束 |
| 色度平面 | 亮度给的一半 | 250 | 20 | 0 | 或直接双三次 |
我自己的习惯是,任何一次重建都先把“可信度热力图”和原图并排放着看,只看 PSNR 会骗自己——指标涨了但脸不像本人,这种事发生过不止一次。宁可交一张结构保守、能看出哪里是猜的图,也不要交一张漂亮但身份是编出来的图。这个方向值得投入,但投入的前提是始终清楚自己在做的是“有约束的重建”,不是“还原”。希望帮到你。
本文还有配套的精品资源,点击获取