
C#图像像素放大:邻域平均、双线性插值实现像素放大的C#实现代码
C#图像像素放大:邻域平均、双线性插值实现像素放大的C#实现代码
前阵子做一个项目,需要把相机拍到的ROI区域放大4倍显示在界面上。直接拉伸,边缘全是锯齿,跟马赛克一样。
用了双线性插值之后,平滑多了。但代价是计算量翻了好几倍。
图像放大这事,不存在"最好"的算法,只有"最合适"的场景。下面把两种常用的方法------最近邻插值(邻域平均)和双线性插值------的C#实现贴出来,顺便说说什么时候该用哪个。
一、最近邻插值:简单粗暴,速度最快
原理四个字:找最近的。
目标图像里每个像素,映射回原图坐标,四舍五入取整,直接把最近的那个像素值拿过来。不计算、不插值,纯粹是"复制粘贴"。
csharp
/// <summary>
/// 最近邻插值放大(邻域平均)
/// </summary>
/// <param name="src">原始图像数据(8位灰度)</param>
/// <param name="srcWidth">原图宽度</param>
/// <param name="srcHeight">原图高度</param>
/// <param name="scaleX">水平放大倍数</param>
/// <param name="scaleY">垂直放大倍数</param>
/// <returns>放大后的图像数据</returns>
public byte[] ResizeNearestNeighbor(byte[] src, int srcWidth, int srcHeight,
float scaleX, float scaleY)
{
int dstWidth = (int)(srcWidth * scaleX);
int dstHeight = (int)(srcHeight * scaleY);
byte[] dst = new byte[dstWidth * dstHeight];
for (int y = 0; y < dstHeight; y++)
{
// 目标像素映射回原图坐标,四舍五入取整
int srcY = (int)Math.Round(y / scaleY);
if (srcY >= srcHeight) srcY = srcHeight - 1;
int rowOffset = y * dstWidth;
int srcRowOffset = srcY * srcWidth;
for (int x = 0; x < dstWidth; x++)
{
int srcX = (int)Math.Round(x / scaleX);
if (srcX >= srcWidth) srcX = srcWidth - 1;
dst[rowOffset + x] = src[srcRowOffset + srcX];
}
}
return dst;
}
优点:快,极快。纯内存复制操作,不涉及浮点运算。
缺点:放大后边缘全是锯齿,缩小时会有摩尔纹。
适合场景:实时预览、对画面质量要求不高的缩略图、像素艺术风格(游戏像素风放大就是用的这个)
二、双线性插值:平滑过渡,质量高
原理稍微复杂一点。
目标像素映射回原图,坐标大概率不是整数(比如原图的(2.3, 1.7)位置)。双线性插值取这个点周围4个最近像素,按距离加权平均。
先水平插两次,再垂直插一次,一共三次线性插值。
csharp
/// <summary>
/// 双线性插值放大
/// </summary>
public byte[] ResizeBilinear(byte[] src, int srcWidth, int srcHeight,
float scaleX, float scaleY)
{
int dstWidth = (int)(srcWidth * scaleX);
int dstHeight = (int)(srcHeight * scaleY);
byte[] dst = new byte[dstWidth * dstHeight];
for (int y = 0; y < dstHeight; y++)
{
// 目标像素映射回原图的浮点坐标
float srcY = y / scaleY;
// 取整(向下)和分数部分
int y0 = (int)Math.Floor(srcY);
float yFrac = srcY - y0;
// 边界处理
if (y0 < 0) y0 = 0;
if (y0 >= srcHeight - 1) y0 = srcHeight - 2;
int rowOffset = y * dstWidth;
int srcRow0 = y0 * srcWidth;
int srcRow1 = (y0 + 1) * srcWidth;
for (int x = 0; x < dstWidth; x++)
{
float srcX = x / scaleX;
int x0 = (int)Math.Floor(srcX);
float xFrac = srcX - x0;
if (x0 < 0) x0 = 0;
if (x0 >= srcWidth - 1) x0 = srcWidth - 2;
// 四个相邻像素的值
float p00 = src[srcRow0 + x0];
float p10 = src[srcRow0 + x0 + 1];
float p01 = src[srcRow1 + x0];
float p11 = src[srcRow1 + x0 + 1];
// 水平方向插值(先算上边一行,再算下边一行)
float top = p00 + (p10 - p00) * xFrac;
float bottom = p01 + (p11 - p01) * xFrac;
// 垂直方向插值
float value = top + (bottom - top) * yFrac;
dst[rowOffset + x] = (byte)Math.Round(Math.Clamp(value, 0, 255));
}
}
return dst;
}
计算过程拆解:
- 目标像素
(x,y)映射回原图坐标(srcX, srcY),是浮点数 - 取
srcX的整数部分x0和小数部分xFrac - 取
srcY的整数部分y0和小数部分yFrac - 取原图四个点:
(x0,y0)、(x0+1,y0)、(x0,y0+1)、(x0+1,y0+1) - 水平方向插两次:
top = lerp(p00, p10, xFrac),bottom = lerp(p01, p11, xFrac) - 垂直方向插一次:
result = lerp(top, bottom, yFrac)
优点:放大后边缘平滑,没有锯齿,视觉质量好得多
缺点:计算量大(每个像素要做6次浮点乘加),比最近邻慢3-5倍
适合场景:最终出图、对画质有要求的场合、缩小时抗锯齿
三、实际测试对比
用一张500万像素的灰度图,放大2倍:
| 算法 | 耗时(ms) | 内存分配 | 视觉质量 |
|---|---|---|---|
| 最近邻 | 12 | 一次 | 边缘锯齿明显 |
| 双线性 | 45 | 一次 | 边缘平滑,稍模糊 |
双线性插值慢一些,但画质差距肉眼可见。如果放大倍数超过3倍,双线性也会变模糊,这时候可以考虑更高级的双三次插值 (Bicubic)或者深度学习超分,但那是另一篇文章的内容了。
四、性能优化建议
1. 预计算映射表
如果同一个图像要反复放大(比如视频流每一帧都显示在界面上),可以提前算好每个目标像素对应的原图坐标和权重,存下来复用。这样每次只需要做内存拷贝,不用重复计算浮点数。
2. 使用unsafe代码
上面的代码为了可读性用了数组索引。如果对性能有极致要求,可以用指针+Span改写成unsafe版本,省掉边界检查的开销。
3. 并行化
每个目标像素的计算是独立的,可以用Parallel.For并行处理行,在多核CPU上能再快不少。
4. 分块处理
大图放大时,可以按行分块,每块单独计算,降低缓存miss率。
五、什么时候用哪个
只追求速度、对画质没要求:最近邻。比如实时预览、缩略图、嵌入式设备。
画质优先、速度能接受:双线性。比如缺陷检测的放大查看、最终出图。
既想快又想好:先用最近邻做粗放大,再加一层后处理平滑(比如高斯模糊边缘)。效果不如双线性,但速度快不少。
极致画质:双三次插值(Bicubic)或Lanczos。但计算量更大,一般的工业场景用不上。
六、总结
最近邻和双线性插值,本质上是速度和质量的取舍。
最近邻"拿来就用",适合实时场景;双线性"加权平均",适合出图场景。没有哪个更好,看你的项目更在乎帧率还是画质。
代码已经贴出来了,复制过去改改就能用。如果需要在界面上显示放大后的图像,配合上一篇文章的ArrayPool和SkiaSharp,做成零GC的实时放大显示,效果会更好。
小提示:双线性插值对彩色图像(BGR/RGB)的处理,需要对三个通道分别做插值,不能直接把24位数据当8位处理。上面的代码是灰度图版本,彩色图把三个通道拆开分别跑一遍就行了。