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 都不支持。

 

参考

官网 Docs

 

安装

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)。

ResizeCropRotateFlip 也是常见的图片操作。另外,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

参考:

Docs – Fonts

Github – Sample

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 更先进的格式(体积更小),是未来网站首选的图片格式。

现代浏览器基本都支持

image

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 一样,真奇怪。

 

posted @ 2022-02-21 14:12  兴杰  阅读(4674)  评论(0)    收藏  举报