
上周接了个帮朋友修图的活儿他做壁纸批量处理想把一批缩略图直接放大成高清预览图结果出来的图片边缘全是锯齿斜线像被狗啃过一样整张图脏兮兮的。他第一反应是库用错了换了好几个图像处理库还是锯齿最后跑来找我。其实问题根本不是库的问题而是他完全没意识到“放大图片时像素位置会对不上”这件事。比如把一张 300x200 的图放大到 900x600目标图第 1 列对应源图的 0.333 列——但图像里哪有“0.333 列”这种东西这个坐标落在一个像素和另一个像素中间你到底取谁的值这就是图像插值算法要解决的核心问题。今天这篇笔记就把两个最基础也最常用的图像插值算法掰开揉碎讲清楚最近邻插值Nearest Neighbor Interpolation和双线性插值Bilinear Interpolation。我会从原理、实现、视觉差异到实际踩坑讲全套读完你就能明白那些图像处理库的 scale 参数背后到底发生了什么以后遇到图片缩放问题也不会再瞎猜。1. 图像缩放时像素位置为什么会“对不上”1.1 一个常见场景把缩略图直接放大我们先拿一个最简单的场景来感受这个问题。假设你手里有一张 2x2 的灰度图像素值长这样10 50 90 30现在你想把它放大到 3 倍也就是变成 6x6。直观一点的思路是把每个源像素“撑大”成 3x3 的小方块四块拼在一起刚好 6x6。这其实是最自然的想法也是最近邻插值的雏形。但如果你换个思路按坐标严格映射呢目标图第 0 行第 0 列对应源图第 0 行第 0 列目标图第 1 行第 1 列对应源图第 0.333 行第 0.333 列。问题立刻来了0.333 行是哪一行第 0 行和第 1 行之间没有“第 0.333 行”这个物理存在。源图像是离散的像素只存在于整数坐标上可你映射过去的目标坐标却是小数。这个矛盾就是插值算法存在的根本原因离散的网格上并没有定义“两个像素之间”的位置但几何变换缩放、旋转、矫正又偏要你给出这个位置的值。于是你只能靠周围已知像素去推测这个“不存在的位置”应该长什么样。1.2 插值的本质用离散像素重建出连续画面理解插值先要建立一个模型图像可以看作对一个连续场景进行采样得到的离散样本。每个像素是某个连续函数 f(x, y) 在整数网格点上的取样值。当你需要求 f(0.333, 0.333) 时你实际在做的事情是假设这个连续函数在已知采样点之间是某种样子的然后看这个点在假设的连续函数上应该取什么值。不同插值算法的本质区别就是对这个“连续函数在像素之间长什么样”的假设不同最近邻插值假设整块区域内值不变四舍五入取最近的采样值。双线性插值假设在水平和垂直方向值都是线性变化的。更高级的双三次插值则假设像素之间的灰度符合三次多项式曲线。所以说白了插值不是去“创造”新信息而是用一个数学模型去“猜测”未知位置上的值。你猜得越精细结果越平滑但计算量也越大而且未必越接近真实——因为源图本身只是采样结果高频信息已经丢了。带着这个认知再看最近邻和双线性思路就非常清晰了。2. 最近邻插值实现最简单效果也最“硬”2.1 原理目标像素映射回源图四舍五入取最近点最近邻插值的规则一句话就能讲完目标像素坐标通过缩放比例映射回源图得到的坐标如果不是整数就四舍五入取最近的整数坐标直接把那个位置的像素值搬过来。用公式表达更清楚。设放大缩小倍数为 scale目标图中坐标为 (dx, dy) 的像素映射回源图的坐标是sx dx / scale sy dy / scale如果 sx、sy 不是整数就做四舍五入sx round(sx) sy round(sy)然后取值dst[dy][dx] src[sy][sx]整个过程没有加权、没有乘法计算就是把一个值原样拷贝过去。再回看我们那个 2x2 的例子目标图 (1,1) 位置映射到源图 (0.333, 0.333)四舍五入后取源图 (0, 0) 的值 10。就这么简单。从信号处理角度看最近邻相当于“零阶保持”加“理想采样”每个源像素被撑成一个同样颜色的小方块方块内部值恒定。所以放大图像时原本一个像素变成了一个方块这也是它产生锯齿的根源。2.2 手写一个最近邻缩放函数知识这东西做一遍才能变成自己的。我建议你拿 Python 手写一遍最近邻缩放别直接用库。原因很简单库函数封装太严实你看不到坐标映射、边界处理、类型转换这些细节出了问题根本不知道去哪儿排查。下面是一段最简实现使用 numpy 完成灰度或彩色图像的最近邻缩放import numpy as np def nearest_interp(src, scale): src_h, src_w src.shape[:2] dst_h int(src_h * scale) dst_w int(src_w * scale) channel 1 if len(src.shape) 2 else src.shape[2] if len(src.shape) 2: src src[:, :, np.newaxis] dst np.zeros((dst_h, dst_w, channel), dtypesrc.dtype) for dy in range(dst_h): for dx in range(dst_w): sy int(round(dy / scale)) sx int(round(dx / scale)) sy min(max(sy, 0), src_h - 1) sx min(max(sx, 0), src_w - 1) dst[dy, dx] src[sy, sx] if channel 1: dst dst[:, :, 0] return dst img np.array([[10, 50], [90, 30]], dtypenp.uint8) out nearest_interp(img, 3) print(out)这段代码有两个细节值得说明。第一坐标映射我用的是round(dy / scale)而不是int(dy / scale)。虽然最终结果差异很小但两者语义不同round 是“找最近点”int 是“向下截断”。按最近邻的定义应该用四舍五入而不是截断。不过在边界处要小心round 可能得到等于宽度的索引所以我加了min(max(...))做边界钳制。第二我声明了 crop 通道数并循环直接写像素而不是用更高级的 scipy.ndimage.map_coordinates。这么写虽然慢但每一步都在“眼前”特别适合初学者理解坐标映射的本质。循环跑完你再看 cv2.resize 的 INTER_NEAREST就能猜到它背后干了什么。2.3 最近邻的优点和明显短板优点非常突出计算量几乎为零。每个输出像素最多做一次除法、一次四舍五入、一次索引没有任何加权乘法。不会产生新颜色值。不管怎么插值输出值一定来自源像素不会混出不存在的中间色。保证边缘锐利。像素艺术、像素字体、调色板图这类物体放大后就是要保持“块状感”双线性反而会糊得像马赛克打了马赛克。短板同样明显锯齿严重。斜线和曲线放大会变成楼梯台阶这在自然图像上非常不美观。缩小图像时问题更大。因为采样点可能跳过很多源像素细节会随机丢失图像出现明显的混叠噪点甚至产生摩尔纹。所以我一般不推荐用最近邻做缩小操作除非是缩小后还要做像素风处理。简单总结最近邻适合“整数倍放大 边缘必须保持极锐利 不介意锯齿”的场景。而一旦涉及照片、视频这类连续影调图像就得看双线性插值了。3. 双线性插值从一维加权平均到二维四邻域3.1 一维线性插值先说清楚双线性插值的核心是线性插值所以先把一维的情况讲明白。假设在一维直线上已知 x0 处值为 f(x0)x1 处值为 f(x1)现在想知道中间任意位置 x 的值是多少。线性插值的做法很简单认为两点之间呈直线变化根据 x 距离两个端点的远近分配权重。公式是f(x) (x1 - x) / (x1 - x0) * f(x0) (x - x0) / (x1 - x0) * f(x1)如果 x0 和 x1 是相邻整数在图像里通常就是这样x1 - x0 1令 fx x - x0公式简化为f(x) (1 - fx) * f(x0) fx * f(x1)这个式子的物理含义很直观靠近谁就多取谁的值离得远贡献就小。权重加起来恒等于 1所以插值结果一定落在两个端点值之间不会超范围。3.2 二维推演横向两次插值竖向一次合成图像是二维的处理思路是把刚才的一维插值沿两个方向各做一次所以叫“双线性”。具体步骤分三步把目标像素坐标映射回源图。假设得到坐标 (sx, sy)它通常落在某个由四个相邻像素围成的格子内。令x0 floor(sx) y0 floor(sy) fx sx - x0 fy sy - y0 x1 x0 1 y1 y0 1沿 x 方向做两次一维线性插值。先把左上角和右上角做插值得到“上边中点”的值再把左下角和右下角做插值得到“下边中点”的值top f(y0, x0) * (1 - fx) f(y0, x1) * fx bottom f(y1, x0) * (1 - fx) f(y1, x1) * fx把 top 和 bottom 再沿 y 方向做一次线性插值result top * (1 - fy) bottom * fy三次插值合在一起本质就是四个相邻像素的加权平均权重取决于小数部分 fx 和 fy。这一步展开成完整公式就是result f(y0,x0)*(1-fx)*(1-fy) f(y0,x1)*fx*(1-fy) f(y1,x0)*(1-fx)*fy f(y1,x1)*fx*fy四个权重是四个角到目标点的“面积占比”所以双线性插值也可以理解为按距离加权平均的二维推广。回到我们那个 2x2 的例子。假设目标图某像素映射到源图坐标 (0.75, 0.25)那么 x00, y00, fx0.75, fy0.25top 10*(1-0.75) 50*0.75 40 bottom 90*(1-0.75) 30*0.75 45 result 40*(1-0.25) 45*0.25 41.25对比最近邻在这个位置取到的值是 50。41.25 更接近周围数值的平均水平这就是双线性插值平滑感的来源。3.3 numpy 实现和 cv2 对照下面是双线性插值的 numpy 实现。为了避免整数乘法溢出整个计算过程中我把像素值提升到 float64最后再转回 uint8import numpy as np def bilinear_interp(src, scale): src_h, src_w src.shape[:2] dst_h int(src_h * scale) dst_w int(src_w * scale) channel 1 if len(src.shape) 2 else src.shape[2] if len(src.shape) 2: src src[:, :, np.newaxis] src src.astype(np.float64) dst np.zeros((dst_h, dst_w, channel), dtypenp.float64) for dy in range(dst_h): for dx in range(dst_w): sy dy / scale sx dx / scale y0 int(np.floor(sy)) x0 int(np.floor(sx)) fy sy - y0 fx sx - x0 y1 min(y0 1, src_h - 1) x1 min(x0 1, src_w - 1) top src[y0, x0] * (1 - fx) src[y0, x1] * fx bottom src[y1, x0] * (1 - fx) src[y1, x1] * fx dst[dy, dx] top * (1 - fy) bottom * fy dst np.clip(dst, 0, 255).astype(np.uint8) if channel 1: dst dst[:, :, 0] return dst img np.array([[10, 50], [90, 30]], dtypenp.uint8) out bilinear_interp(img, 3) print(out)跑这段代码边缘处不会像最近邻那样直接跳变到下一个值而是会出现若干个中间灰阶过渡。比如原本从 10 跳到 50 的边界现在会先经过 20、30、40 这样的值再变到 50看起来柔和得多。用 cv2 对照也很简单import cv2 out_cv cv2.resize(img, (6, 6), interpolationcv2.INTER_LINEAR)不过这里要提醒你如果你的手写实现和 cv2 结果不完全一样别慌不是写错了而是坐标对齐约定不同。这个坑专门放在第 5 部分说。3.4 双线性的特性与代价双线性插值最大的优势是消除了最近邻那种像素块感。因为输出像素的值是相邻像素的连续过渡所以放大照片时看到的不再是“楼梯”而是柔和变化的色块。在多数自然图像任务里这个效果已经足够好了所以它是 OpenCV、各种浏览器、图形库缩放图片时的默认插值。但天下没有白吃的午餐双线性的代价也很明确计算量大一个量级。每个输出像素需要做多次浮点乘法和加法虽然现代 CPU 处理这点运算绰绰有余但换到移动端、嵌入式设备上批量跑差别还是能感觉到的。边缘会被轻微模糊。你想想白色和黑色之间那条锐利的边界经过双线性插值后会变成灰到白的渐变过渡边缘“软”了。放大倍数较大时比如 4 倍以上双线性会明显发糊而且算出来的结果是源像素的加权混合细节已经无法真正还原。一句话概括双线性插值是“性价比适中”的选手——质量比最近邻好得多成本比双三次插值低得多是日常任务最稳妥的默认选择。4. 两种算法的正面 PK视觉、速度与选型4.1 一个白色像素点的真实表现为了直观感受两者差异来看一个极端例子一张纯黑背景上的单个白色亮点放大 4 倍。最近邻插值的结果就是 16 个白色像素组成的一个实心 4x4 方块周围依然是纯黑边界锐利得像用刀切出来的。双线性插值的结果就完全不同了。白色亮点会被“晕开”以原亮点为中心越靠近中心的越亮向外逐渐衰减成灰色最终融进黑色。如果你把输出画成热力图会看到一个锥形的渐变而不是一个方块。这个差异说明了双线性插值的一个根本特性它不是简单复制而是在每两个像素之间都做了过渡。对于亮点这种高频信号过渡就意味着能量的扩散和模糊。所以在图像超分辨率、目标检测这类任务里要不要在预处理阶段放大图像用什么方式放大直接会影响后续算法的效果。4.2 计算量拆解与耗时感受我从指令层面拆一下两者实现一个输出像素各需要做什么你就知道差距在哪了。操作最近邻插值双线性插值坐标换算1 次除法 取整1 次除法 floor加权计算无每个通道约 6 次乘法和 5 次加法插值次数无三次一维线性插值依赖像素1 个4 个支持的颜色范围只能取已有像素值会产生新的中间值典型耗时极低中等约为最近邻的 35 倍实际跑一张 4000x3000 的彩色图放大到 8000x6000我的本机上最近邻几乎是瞬间完成双线性能感觉到略有停顿但也都轻松跑完。只有在批量处理几百张图、循环反复缩放的情况下耗时差距才会显现。所以性能焦虑不用有。在绝大多数场景里双线性的计算成本完全可接受真正需要权衡的是视觉效果。4.3 不同任务怎么选选哪种插值主要看你要处理的内容和最终目的。我平时是这么取舍的照片放大、视频画面缩放用双线性或双三次。照片本来就是连续影调需要平滑过渡最近邻的锯齿不可接受。像素艺术、游戏素材放大用最近邻。放大像素画就是要保持像素的“块状感”一旦用双线性整张图糊成一片风格尽失。图像缩小做缩略图推荐 cv2.INTER_AREA它本质是区域平均对混叠的处理比双线性好。如果只能用双线性也凑合但别用最近邻那会随机丢信息。图像分割的标注图label map标签图里每个像素代表类别 ID不是灰度值做缩放时不能用双线性。因为 0 和 1 插值出 0.5 这种“中间类别”是无意义的。必须用最近邻保持类别纯净。深度特征图的上采样一般用双线性后续第四节细说但也要注意内部实现的对齐方式。另外还有一个很容易被忽略的坑如果你对同一张图做放大再缩小比如先放大 2 倍再缩小回原尺寸用最近邻来回折腾最终结果和原图几乎一致但用双线性来回折腾因为每一轮都会产生加权平均值多轮之后边缘会逐步糊掉、灰度整体被摊平。所以设计图像处理流程时要尽量避免反复插值。5. 实操中的关键坑对齐方式、边界、类型与深度学习差异5.1 坐标对齐约定相差的那半个像素这块是新手最常栽的地方也是最容易让人怀疑自己写错的地方明明原理搞懂了手写实现和 OpenCV 的输出却对不上。原因多半在于坐标对齐coordinate alignment方式不同。教科书公式通常默认这样的约定源图的 (0, 0) 点也就是左上角像素的“左边缘”和目标图的 (0, 0) 点精确对齐。然后把目标坐标直接按比例映射回源坐标即 sx dx / scale。这种方式叫 corner-aligned角对齐。但 OpenCV 的默认实现用的是 center-aligned中心对齐它认为像素坐标指的是像素中心的坐标。也就是说源图的第一个像素中心在 0.5最后一个像素中心在 W-0.5。映射公式就变成sx (dx 0.5) * (src_w / dst_w) - 0.5你可以简单理解成角对齐之后又整体平移了 0.5 像素。像素中心对齐是最自然、也符合相机成像模型的约定所以大多数现代图像库都采用它。对于大尺寸图像0.5 个像素的偏移影响微乎其微肉眼根本看不出来。但如果你在做图像配准、做多尺度特征对齐、或者在强化学习里用像素级操作这 0.5 像素就可能是致命的。所以用 cv2.resize、PyTorch 的 interpolate 这类工具时去看看文档里关于坐标对齐参数的定义能省很多查错时间。5.2 边界处理和越界保护双线性插值需要四个相邻像素。当目标坐标映射到源图边缘附近时x0 或 y0 可能是负数x1 或 y1 可能超出源图宽高范围。这时候怎么处理各库有不同的策略但原则不外乎以下几种clamp钳制把越界的索引限制在合法范围内。我前面的实现用的就是这招x1 最多取到 src_w-1。实现简单但会让边缘像素被重复使用。补零zero padding越界位置当 0 处理。处理自然图像时黑色边缘会和图像内容混起来有副作用。镜像reflect越界像素用图像内部的镜像值填充对边缘的处理更自然OpenCV 部分接口默认就是这种风格。我自己写底层代码时最常用 clamp。原因很简单在插值里越界位置本来就只影响紧贴边界的那一圈像素clamp 造成的误差通常不可见。而 map_coordinates 这类库则偏好 reflect它适合信号处理中避免边界伪影的场景。不必纠结哪种“绝对正确”知道各自存在、知道默认行为会影响结果就可以了。5.3 数据类型与溢出先转 float最后再钳制这是我见过最多的低级错误。很多初学者直接用 uint8 数组做加权计算比如dst (a * 0.3 b * 0.7).astype(np.uint8)在 numpy 里a 和 b 如果都是 uint8乘 0.3 时结果会先变成 float通常没太大问题。但是如果你是整型乘法比如用整数权重或者忘记转 float就很容易出现数值截断、溢出最后得到一堆莫名其妙的灰斑。正确的流程是读取图像后转成 float64 再参与计算所有插值运算在浮点空间完成计算结束后用 np.clip 把结果钳制到 [0, 255] 范围再转回 uint8。为什么必须 clip因为虽然双线性插值的权重和为 1理论上结果不会超范围但浮点计算误差和边界重复取用可能导致极个别像素略低于 0 或略高于 255。相比之下更高阶的双三次插值因为引入了负权重超范围是常态clip 更是必不可少。另外说一个多数人不知道的细节对 float 类型图像做插值和 uint8 完全不是一回事。float 图本身可能包含 HDR 数据像素值超过 [0,1] 很正常插值公式照常计算即可不需要也不会被 clamp。所以如果你的流程里有用 float 深度图做插值的需求别下意识套 uint8 的钳制逻辑。5.4 深度学习任务里用插值要注意的事双线性插值在深度学习里同样无处不在最常见的位置是特征图的上采样。PyTorch 里写一句F.interpolate(x, scale_factor2, modebilinear)底层就是一个双线性插值。但这里有个和其他库不太一样的细节它多了一个align_corners参数。align_cornersTrue把源图和目标图的四个角点对齐映射公式是 sx dx * (src_w - 1) / (dst_w - 1)。align_cornersFalse按像素中心对齐公式带 /- 0.5 的偏移。同一个张量用两种 align 方式上采样结果会有可见的整体平移差异而且这个差异在网络层数叠加后会越放越大。经典分割、姿态估计网络里如果上采样层没有正确设置 align_corners最终预测出来的目标位置就可能整体偏若干个像素。我个人的经验是网络内部的特征图上采样统一用align_cornersFalse这也是 PyTorch 默认值因为在目标检测、语义分割这些任务里像素中心对齐更符合特征图的物理意义只有在特殊需要保持角点时比如把预测图拉回原图大小做逐像素监督才考虑True。关键是统一起见——同一个网络里不要一会儿 True 一会儿 False数值实验很难对齐。另外还有个容易被忽略的点最近邻插值在深度学习里常用于标签图mask的上采样因为标签的类别 ID 不能做平均。但千万不要对 one-hot 编码的标签图做双线性插值它会生成“介于两类之间”的概率分布这在语义上有误导性。标准做法是保持 hard label用最近邻。最后再补一个实战心得做图像预处理时很多人为了省事习惯把所有图片缩放到统一尺寸随手用了默认插值。如果后面接的是文字检测、表格识别这类对边缘敏感的模型建议调查一下你的处理库默认到底用的哪种插值以及是否和你训练时的预处理保持一致。我踩过类似坑训练阶段和推理阶段缩放插值方式不一致导致线上精度莫名其妙掉了两个点排查了三天才定位到是cv2.resize的 interpolation 参数不一致造成的。看完这篇笔记建议你亲手把两个函数跑一遍再用不同图片测试几种插值的输出差异。开始觉得“就这点区别”等你碰上像素级对齐或者标签图处理的时候就会感谢当初花这一小时搞懂了最近邻和双线性到底在做什么。