ASP.NET Core Library – ImageSharp
前言
2021 年我就写过一篇《ASP.NET Core 学习笔记:Image Processing (ImageSharp)》,只是当时还是旧的写法。这篇作为翻新版,并作为以后继续增加新功能的介绍。
ImageSharp 是 .NET 平台的开源图片处理库,完全用 C# 从零开始编写,历经多年开发,目前已经相对成熟。自 2022 年起,也开始支持 WebP 格式了。
另外,.NET 还有一个库也很不错 —— Magick.NET。
它的底层是 C++,速度比 ImageSharp 通常会慢一点,但是功能比 ImageSharp 齐全太多了,支持非常多种图片格式,比如:AVIF、HEIF(iPhone 拍照默认输出的格式),这些 ImageSharp 都不支持。
参考
安装
dotnet add package SixLabors.ImageSharp
查看图片信息
var fileFullPath = @"C:\keatkeat\projects\stooges-lib\stooges-aspnetcore\Project\wwwroot\uploaded-files\vertical-huawei.jpg";
using var image = Image.Load(fileFullPath);
var w = image.Width;
var h = image.Height;
var exifOrientation = image.Metadata.ExifProfile.GetValue(ExifTag.Orientation);
手机拍摄的图片经常会出现方向反转的问题,可以通过查看 Exif Orientation 信息来解决。关于这个问题,可以参考我之前写的文章:《Image Exif Orientation 图片方向信息》。
Get image format (MIME Types)
有时候我们只有一个图片的 URL 下载路径,下载后得到的只是 bytes,无法确定图片的类型(MIME Type)。这种情况下,可以这样处理:
var bytes = await response.Content.ReadAsByteArrayAsync(); // read bytes from http response
var format = Image.DetectFormat(bytes);
Console.WriteLine(format.Name); // "PNG"
Console.WriteLine(format.DefaultMimeType); // "image/png"
Console.WriteLine(format.MimeTypes); // ["image/png", "image.apng"]
Console.WriteLine(format.FileExtensions); // ["png", "apng"]
// 或者
using var image = await Image.LoadAsync("yangmi.jpg");
Console.WriteLine(image.Metadata.DecodedImageFormat); // "PNG"
注:extension 没有 starts with 点(dot)哦。
修改图片
using var newImage = image.Clone(imageProcessing =>
{
imageProcessing.AutoOrient();
imageProcessing.Resize((int)Decimal.Divide(image.Width, 2), (int)Decimal.Divide(image.Width, 2));
imageProcessing.Crop(new Rectangle(x: 150, y: 150, width: 150, height: 150));
imageProcessing.Rotate(RotateMode.Rotate90);
imageProcessing.Flip(FlipMode.Vertical);
});
newImage.SaveAsJpeg(
@"C:\keatkeat\projects\stooges-lib\stooges-aspnetcore\Project\wwwroot\uploaded-files\vertical-huawei-croped.jpg",
new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder {
Quality = 85
}
);
Clone()
是复制一张图片来进行修改,如果想直接修改原图,就使用 Mutate()
。注:Clone
需要配合 using
一起使用。
AutoOrient
会根据 Exif Orientation 来自动旋转和翻转图片,非常方便,不需要自己额外处理。
转换完成后,Exif Orientation 会被重置为 1(即使之前是 0)。
Resize
、Crop
、Rotate
、Flip
也是常见的图片操作。另外,GitHub 上还有一个 Crop Avatar 的示例可以参考。
修改 background color
PNG 转 JPG 时,默认的背景颜色是黑色。参考:Converting PNG to JPG give a color background and not white (ASP.NET Core)
var clonedImage = image.Clone(imageProcessing =>
{
imageProcessing.BackgroundColor(Color.White); // 设置背景为白色
});
想保存为 webp,调用 SaveAsWebp
就可以了。
await image.SaveAsWebpAsync(Path.Combine(rootPath, "tifa2.webp"));
注:SaveAs... 是可以多次调用的,它内部有处理好 stream reading 了。
水印 Watermark
水印的做法是在一张图上面添加上另一张图,同时第二张图需要带有点 opacity 的效果。
using var tifa = await Image.LoadAsync(@"C:\Users\keatk\Desktop\temp\review-avatar\tifa.jpg"); // 原图
using var logo = await Image.LoadAsync(@"C:\Users\keatk\Desktop\temp\review-avatar\Stooges Logo.png"); // 水印
// Clone 做出 new image
using var newImage = tifa.Clone(imageProcessing =>
{
logo.Mutate(x => x.StgResizeWidth(200)); // 缩小 logo (这个不是必须的, 刚巧我的图片比较大而已)
imageProcessing.DrawImage(
logo,
opacity: 0.5f,
backgroundLocation: new Point(100, 100)
);
// imageProcessing 就是原图
// DrawImage 就是在图上画画
// logo 就是把水印画上去的意思
// opacity 给 logo 加上透明度
// location 是 x,y 坐标, 看你想把水印打到哪里
});
await newImage.SaveAsJpegAsync(@"C:\Users\keatk\Desktop\temp\review-avatar\new-image.jpg"); // 保存
效果
New Image for object-fit: content 效果
CSS object-fit 效果:把一张大图按比例缩小到框框里,并且保留所有图片信息 (留白)
using var tifa = await Image.LoadAsync(@"C:\Users\keatk\Desktop\temp\review-avatar\tifa.jpg");
using var newImage = new Image<Rgba32>(500, 500, backgroundColor: Rgba32.ParseHex("#fff")); // hex 或者 Color.White 都可以
tifa.Mutate(imageProcessing =>
{
imageProcessing.Resize(width: 500, height: 500 * tifa.Height / tifa.Width); // 按比例缩小
});
newImage.Mutate(imageProcessing =>
{
// 画到中间去
imageProcessing.DrawImage(tifa, backgroundLocation: new Point(0, (500 / 2) - (tifa.Height / 2)), opacity: 1);
});
await newImage.SaveAsJpegAsync(@"C:\Users\keatk\Desktop\temp\review-avatar\tifa-contain.jpg");
效果
扩展 ImageProcessing
虽然 imageProcessing 已经有很多好用的接口了,但是不够上层。
resize width keep aspect ratio
比如我有一张图, dimension 是 1349 x 761
我想把它 resize to width 500,aspect ratio 保持。
那可以这样写
var clonedImage = image.Clone(imageProcessing =>
{
imageProcessing.Resize(width: 500, height: 500 * image.Height / image.Width);
});
它的缺点就是要写计算,这就是所谓的不够上层,那我们自己封装一下。
封装 extensions
调用
var clonedImage = image.Clone(imageProcessing =>
{
imageProcessing.MyResizeWidth(500);
});
extensions
public static class ImageProcessingExtensions
{
public static IImageProcessingContext MyResizeWidth(this IImageProcessingContext imageProcessing, int width)
{
imageProcessing.Resize(width, height: width * originalImage.Height / originalImage.Width);
return imageProcessing;
}
}
这里遇到一个问题:算法需要原始图片的尺寸。上面我们是通过闭包才拿到的,但封装之后就无法获取了。
难道要通过参数传进来吗?(这样接口调用就不够优雅了…)还是说可以直接从 IImageProcessingContext
里获取呢?
在 IImageProcessingContext 如何获取 Image
源码追踪
首先去翻了下文档,没有发现类似的案例,这也不奇怪,毕竟大部分文档对扩展都不算友好。
那就只能去看看源码了。
GetCurrentSize
最接近了,嗯……通常当前尺寸已经足够我们使用,但既然都看到这里了,就再看看能不能拿到原始图片吧。
接下来,我们继续翻看 Clone
的源码。
AcceptVisitor 调用了 Accept
Accept 又调用了 visitor 的 Visit 并把 Image 自己传进去.
继续看 Visitor 的 visit
到这里还算顺利, 我们要的 Image 最终有传入到 ImageProcessing 里头. 那样我们就可以通过 ImageProcessing 找回 Image 了.
这个 DefaultImageProcessorContext 就是我们最终使用的 ProcessingImage 了, 反射来确认一下
最后就是看这个 ProcessingImage 初始化, 看它把 Image 藏去哪里了.
很遗憾,我们要的 Image 被放到了 private field,没有任何接口可以拿到。
黑科技 – 反射获取 private field
虽然没有接口没有公开, 但可以通过反射, 强制去获取 private field
public static class ImageProcessingExtensions
{
public static Image MyGetOriginalImage(this IImageProcessingContext imageProcessing)
{
var sourceField = imageProcessing.GetType().GetField("source", BindingFlags.NonPublic | BindingFlags.Instance)!;
return (sourceField.GetValue(imageProcessing) as Image)!;
}
public static IImageProcessingContext ResizeWidth(this IImageProcessingContext imageProcessing, int width)
{
var originalImage = imageProcessing.MyGetOriginalImage();
imageProcessing.Resize(width, height: width * originalImage.Height / originalImage.Width);
return imageProcessing;
}
}
这样就可以了。但要特别注意,这是一个 hacking 的做法,每次升级都有可能引发未知的 breaking change,一定要谨慎使用。
P.S. Resize
时使用 current size 通常才是正确的需求,上面获取 original size 只是随便举的例子而已。
写字 DrawText
参考:
How to add text to an image with C# in dotnet
写字功能目前还处于 beta version, 需要用到 2 个的插件, SixLabors.Fonts 和 SixLabors.ImageSharp.Drawing
dotnet add package SixLabors.Fonts --version 1.0.0-beta19
dotnet add package SixLabors.ImageSharp.Drawing --version 1.0.0-beta15
下载字体
.ttf 肯定可以, .otf 我没有测试. 我是用 Google Font 做测试.
Load Image
public class Program
{
public static void Main()
{
var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");
var yangmi = new FileInfo(Path.Combine(rootPath, "yangmi.jpg"));
using var image = Image.Load(yangmi.FullName);
}
}
Setup Font
var collection = new FontCollection();
var family = collection.Add(Path.Combine(rootPath, "Caveat-Bold.ttf"));
var font = family.CreateFont(128, FontStyle.Bold);
导入字体, 并且设置 font style
{
font-family: 'Caveat';
font-size: 128px;
font-weight: 700;
}
不想导入, 也可以用 system font
var font = SystemFonts.CreateFont("Times New Roman", 128, FontStyle.Bold);
Setup Font Drawing Options
var textOptions = new TextOptions(font)
{
Origin = new PointF(64, 64),
WrappingLength = 3840f * 0.25f,
HorizontalAlignment = HorizontalAlignment.Left,
};
Origin 是要 draw 的 coordinate (x,y)
WrappingLength 是 frame 的 width. 多少之后要 wrap (字掉去下一行)
HorizontalAlignment 是从左写到右
Setup Brush
var brush = Brushes.Solid(Color.White);
Brush 负责调颜色
DrawText
string text = "Yang Mi is a Chinese actress, singer, and model known for her roles in popular Chinese TV dramas and films.";
using var newImage = image.Clone(ctx =>
{
ctx.DrawText(options, text, brush);
});
await newImage.SaveAsJpegAsync(Path.Combine(rootPath, "yangmi-name.jpg"));
把字和 setting 丢进去就可以了。
效果
漂亮吧。
Text Background Color
继续上一部分的例子,我们要在文字上添加一个背景叠层(overlay)。
首先需要绘制出这个 overlay。
Overlay 的宽度和高度取决于文字的内容,因此需要先进行计算。
Calc Text Szie
var font = SystemFonts.CreateFont("Times New Roman", 128, FontStyle.Bold);
var text = "Yang Mi is a Chinese actress, singer, and model known for her roles in popular Chinese TV dramas and films.";
var textOptions = new TextOptions(font)
{
Origin = new PointF(16, 16),
WrappingLength = 3840f * 0.25f,
HorizontalAlignment = HorizontalAlignment.Left,
};
var textSize = TextMeasurer.MeasureSize(text, textOptions);
Console.Write(textSize.Width + "x" + textSize.Height);
Draw Text Overlay
using var overlay = new Image<Rgba32>((int)textSize.Width + 64, (int)textSize.Height + 64);
overlay.Mutate(ctx =>
{
ctx.Fill(Color.Red.WithAlpha(0.5f));
var textBrush = Brushes.Solid(Color.White);
ctx.DrawText(textOptions, text, textBrush);
});
创建一张图片,通过 Fill
设置背景颜色,然后再把文字绘制上去。
注:背景是带透明度(opacity)的,但文字则没有。
Draw into Image
最后,将生成的 overlay 图片叠加回原图,也就是和上面添加水印时使用的方法相同。
using var newImage = image.Clone(ctx =>
{
ctx.DrawImage(overlay, location: new Point(64, 64), opacity: 1);
});
await newImage.SaveAsJpegAsync(Path.Combine(rootPath, "yangmi-name.jpg"));
效果
注:我们不能直接在原图上使用 Fill
,否则会把原图整个覆盖掉,而且也无法实现透明度(opacity)效果。
using var newImage = image.Clone(ctx =>
{
ctx.Fill(Color.Red.WithAlpha(0.5f));
var textBrush = Brushes.Solid(Color.White);
ctx.DrawText(textOptions, text, textBrush);
});
上面的做法不可行,必须通过 DrawImage
的方式来实现。(原因我也还不太清楚……)
WebP fallback to JPEG/PNG/GIF
用户上传一张 WebP,浏览器不一定支持,因此我们需要 fallback to JPEG or PNG or GIF。
首先判断 WebP 内容有没有透明,有的话就 fallback to PNG。
var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");
using var image = await Image.LoadAsync<Rgba32>(Path.Combine(rootPath, @"wwwroot\example.webp"));
var hasTransparency = HasTransparency(image);
bool HasTransparency(Image<Rgba32> image)
{
bool hasAlpha = false;
image.ProcessPixelRows(accessor =>
{
for (int y = 0; y < accessor.Height; y++)
{
var row = accessor.GetRowSpan(y);
for (int x = 0; x < row.Length; x++)
{
if (row[x].A < 255)
{
hasAlpha = true;
return;
}
}
if (hasAlpha) break;
}
});
return hasAlpha;
}
查看每一个像素,A 就是 alpha,255 代表完全不透明,小于 255 则代表有透明。
注:需要 LoadAsync<Rgba32>
或者 CloneAs<Rgba32>
得到 Image<Rgba32>
。
另外,判断是不是动图:
var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");
using var image = await Image.LoadAsync(Path.Combine(rootPath, @"wwwroot\example.webp"));
var isAnimated = image.Frames.Count > 1;
是的话就 fallback to GIF。
没有透明又不是动图,那就可以 fallback to JPEG。
支持 AVIF
AVIF 是一个比 WebP 更先进的格式(体积更小),是未来网站首选的图片格式。
现代浏览器基本都支持
IOS 16.4 大概 iPhone 8 就支持了。
但 ImageSharp 目前(2025-09-17)还不支持。相关 Github Issue – Add support for HEIF based images
只能暂时借助 Magick.NET 来完成了。
dotnet add package Magick.NET-Q8-AnyCPU
它其实有很多个版本,Q8 是比较普通用的,AnyCPU 兼容性比较好,所以通常用这款就对了。
var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");
using var image = await Image.LoadAsync(Path.Combine(rootPath, @"wwwroot\example.jpg"));
await using var memory = new MemoryStream();
await image.SaveAsPngAsync(memory);
memory.Position = 0;
using var magickImage = new MagickImage(memory);
await magickImage.WriteAsync(Path.Combine(rootPath, @"wwwroot\example.avif"), MagickFormat.Avif);
先把 ImageSharp 的图保存到 memory 中,用无损的 PNG 作为两个库沟通的格式(注:这个写法不支持动图 GIF 哦,要处理动图建建议不要使用 ImageSharp,完全用 Magick.NET 会更方便)。
Magick.NET 接手后保存为 AVIF 即可。
注:AVIF 用 windows 11 的 Photos 打开观察,我发现它的颜色会偏亮,但用浏览器打开对比又和 JPEG 一样,真奇怪。