ASP.NET Core Library – Magick.NET

前言

之前介绍过《ImageSharp》,这篇来介绍另一个类似的库 —— Magick.NET

其实我早年就是用 Magick.NET 的,后来才换去使用 ImageSharp。

ImageSharp 的特色是比较轻量,用来做简单的图片处理时,性能会更好。

反观 Magick.NET 就比较笨重,在处理简单图片时,性能比 ImageSharp 慢一些。

但是,它更适合复杂的图片处理,比如处理 GIF,绝对比 ImageSharp 好很多。

另外,ImageSharp 支持的图片格式比较少,比如现在很流行的 AVIF 就还不支持。

Magick.NET 则不用说,几乎什么格式都支持。

Magick.NET 之所以这么强大,是因为它底层其实是用 C++ 写的 ImageMagick,而 Magick.NET 只是它的 .NET 版本而已。

至于 ImageSharp,则是完全由 C# 编写的。

 

安装

Magick.NET 底层是 C++ 写的 ImageMagick,我们知道 C++ 没有虚拟机概念,不像 C# 和 Java 那样可以 Write once, run anywhere

因此,Magick.NET 有很多不同 CPU 的版本:

image

如果不确定程序运行的环境,那也可以选 AnyCPU 版本:

image

至于 Q8、Q16、Q16-HDRI,指的是图像处理的精度。

一般来说,如果只是处理网站上的图片,Q8 就已经足够了。

Q16 相比 Q8 会占用更多内存,性能也会更慢,但优点是可以支持更高质量的图像处理,适合专业修图场景。

NuGet command:

dotnet add package Magick.NET-Q8-x64

 

查看图片信息

想获取图片信息主要靠 MagickImageInfoMagickImage

查看基础信息

如果只是想知道图片的 width、height、format、quality,可以使用 MagickImageInfo

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");
var fileRootFullPath = Path.Combine(rootPath, @"wwwroot\yangmi.jpg");

var imageInfo = new MagickImageInfo(fileRootFullPath);
Console.WriteLine(imageInfo.Width);  // 3840
Console.WriteLine(imageInfo.Height); // 2160

Console.WriteLine(imageInfo.Format); // Jpeg
Console.WriteLine(MagickFormatInfo.Create(imageInfo.Format)!.MimeType); // image/jpeg

Console.WriteLine(imageInfo.Quality); // 87

它不会把整张图片加载到内存,而是只读取一些头信息。

Quality 指的是图片保存时的质量,像 JPEG、WebP、AVIF 支持有损压缩,保存时都可以选 Quality。

gif

提醒1:手机拍照也会有损压缩哦。比如 Android 一般会保存为 JPEG Quality 87。如果想要真正的原图,可以开启 Pro 模式并选择 RAW 格式。

提醒2:Quality 是通过信息头里的量化表推算出来的,不一定精准哦。

查看 Exif

如果想获取 Exif 信息就不能只靠 MagickImageInfo,需要使用 MagickImage

using var image = new MagickImage(fileRootFullPath);
var profile = image.GetExifProfile();
var orientation = profile?.Values.SingleOrDefault(e => e.Tag == ExifTag.Orientation);
Console.WriteLine(orientation?.GetValue()); // 1

MagickImage 会将整张图片读入内存;而 MagickImageInfo 则只读取图片的头部信息。

提醒:记得在前方加上 using 哦。

orientation 1 就是这个方向:

image

注:现在的手机在拍照保存时,通常会自动转换成 orientation 1(除非我们选择 Pro RAW 模式)。

查看是不是 progressive

using var image = new MagickImage(fileRootFullPath);
Console.WriteLine(image.Interlace == Interlace.Jpeg); // true

progressive 指的是图片显示的方式。

如果是 non-progressive:它是从上到下,慢慢加载显示出来,虽然是部分部分出现,但一出现就是清晰的图。

如果是 progressive:它会有两轮显示,第一轮上到下,但出现的是朦图,第二轮也是上到下,这一次是清晰的图。

对比起来,progressive 的体验通常会更好。

注:JPEG、PNG、GIF 有 progressive 概念;WebP、AVIF 则没有。

 

简单修图

一些常规的修图操作:CloneAutoOrientResizeCropRotateFlip

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");
var fileRootFullPath = Path.Combine(rootPath, @"wwwroot\yangmi.jpg");

using var image = new MagickImage(fileRootFullPath);

// 如果只是要修改一张图,那不需要 Clone
// 直接改 image 保存就可以了
// 它不会影响到原图,因为 Magick 是把原图读到内存,这等于是拷贝了一份。
// 接着修改的是这一份拷贝的,最后保存也是这一份拷贝的
using var newImage = image.Clone();

// 把图片的 Exif Orientation 设置成 1,然后做相应的 rotate/flip/flop
newImage.AutoOrient();

// 修改图片的 width height
newImage.Resize(
  (uint)Math.Round(image.Width / 2m, MidpointRounding.AwayFromZero),
  (uint)Math.Round(image.Height / 2m, MidpointRounding.AwayFromZero)
);

// 裁剪一部分的图片,从 coordinate x,y 坐标开始,然后依据 width,height 剪一个四角形
newImage.Crop(new MagickGeometry(x: 100, y: 100, width: 500, height: 500));

newImage.Rotate(90); // 旋转图片 90 度
newImage.Flip();     // 上下翻转(vertical flip)
newImage.Flop();     // 左右翻转(horizontal flip)

// 保存一张新图片
await newImage.WriteAsync(fileRootFullPath.Replace("yangmi.jpg", "yangmi-edited.jpg"), MagickFormat.Jpeg);

提醒:

new MagickImage 会把文件拷贝一份到内存,我们修改的不是原图,而是拷贝的。

接下来的每一个操作会直接修改拷贝图。

比如:我们 Resize 100px,这图立刻就缩小到 100px,如果再 Resize2000px,图会朦掉(不管我们间中有没有做保存)。

因为这操作相等于把一张 100px 的图拉大到 2000px

因此在操作前要想好是否需要先 Clone 一份。

另外,图片保存时,一定要确保路径 folder 已存在,如果 file 已存在它会自动覆盖哦。

关于 Resize

Resize 不是说把图修改成我们传入的 width height 哦。

传入的 width、height 会被当作一个 box。

图片会以 object-fit contain 的方式(保持原图比例 aspect ratio)放入 box。

这才是最终的 image size。

举例:

原图 3840 x 2160

Resize(1600, 1200)

最终图片是 1600 x 900。

比例依旧是 16:9,它压缩宽度,然后用比例计算出高度(如果有小数会 round MidpointRounding.AwayFromZero 四舍五入)

Resize(1600, 500)

最终图片是 889 x 500。

比例依旧是 16:9,它压缩高度,然后用比例计算出宽度。

如果我们只想压其中一边,也可以写 Resize(1600, 0) 或者 Resize(0, 500),那和上面是等价的。

如果我们想 Resize 但不维持原图比例,那可以这样配置:

image.Resize(new MagickGeometry(1600, 1200) { IgnoreAspectRatio = true });

最终图片是 1600 x 1200,图片会走样哦。

关于图片格式与 Quality

它有一些潜规则:

await newImage.WriteAsync(fileRootFullPath.Replace("yangmi.jpg", "yangmi-edited.jpg"), MagickFormat.Jpeg);
await newImage.WriteAsync(fileRootFullPath.Replace("yangmi.jpg", "yangmi-edited.webp"), MagickFormat.Webp);
await newImage.WriteAsync(fileRootFullPath.Replace("yangmi.jpg", "yangmi-edited.avif"), MagickFormat.Avif);

假设原图的 Quality 是 87,然后我们不去做任何 Quality 设置,直接保存为 JPEG、WebP、AVIF。

结果是:JPEG Quality 87、WebP Quality 87、AVIF Quality 50。

AVIF 的 Quality 被自动调节了,因为不同格式 Quality 指标不同,AVIF Quality 50 差不都等于 WebP Quality 87。

因此,我觉得比较好的做法是,我们自己明确设置 Quality,这样可以阻止它自动转换。

newImage.Quality = 75;
await newImage.WriteAsync(fileRootFullPath.Replace("yangmi.jpg", "yangmi-edited.jpg"), MagickFormat.Jpeg);

newImage.Quality = 75;
magickNet.Settings.SetDefine(MagickFormat.WebP, "method", "4"); // 0-6 可选,越高图就越小,但 convert 越耗时
await newImage.WriteAsync(fileRootFullPath.Replace("yangmi.jpg", "yangmi-edited.webp"), MagickFormat.Webp);

newImage.Quality = 50;
magickNet.Settings.SetDefine(MagickFormat.Avif, "speed", "6"); // 0-9 可选,越小图越小,但 convert 越耗时 
await newImage.WriteAsync(fileRootFullPath.Replace("yangmi.jpg", "yangmi-edited.avif"), MagickFormat.Avif);

WebP 和 AVIF 虽然体积小,但转换格式非常耗时,method 和 speed 是拿来调整做 trade-off 的。

method 越高越耗时,效果越好,speed 越高效果越差,但转换省时。

我的建议是,WebP 和 AVIF 使用 background job 来处理,method: 4,speed: 6 是不错的 balance。

题外话:

Google Lighthouse 的标注是 Quality 85,超过它就会提示。

依照 Google 的压图工具 squoosh.app 默认配置:

  • JPEG 是 Quality 75

  • WebP 是 Quality 75,effort 4(也就是 method 4)

  • AVIF 是 Quality 50,effort 4(也就是 speed 6 左右)

lossless 无损压缩

PNG 是无损压缩,JPEG 是有损压缩,WebP 和 AVIF 则支持有损和无损。

PNG 有一个 compression-level 的配置,越高体积越小。(类似 WebP 的 method 和 AVIF 的 speed)

image.Settings.SetDefine(MagickFormat.Png, "compression-level", "10"); // 可选 1-10,默认是 10

WebP 默认是有损压缩,要无损需要配置

image.Settings.SetDefine(MagickFormat.WebP, "lossless", "true");

Magick.NET 的 AVIF 好像还不支持无损,只能靠 Quality 100 来充当。

相关 Issue:

Github Issue – magick avif is not always lossless

Github Issue – Increase quality for conversion PNG to AVIF

Progressive

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");
var fileRootFullPath = Path.Combine(rootPath, @"wwwroot\yangmi.jpg");

using var image = new MagickImage(fileRootFullPath);
image.Settings.Interlace = Interlace.NoInterlace; // 没有 progressive
image.Settings.Interlace = Interlace.Jpeg;        // progressive for JPEG
image.Settings.Interlace = Interlace.Gif;         // progressive for GIF
image.Settings.Interlace = Interlace.Png;         // progressive for PNG

await image.WriteAsync(Path.Combine(rootPath, @"wwwroot\yangmi.png"), MagickFormat.Png);

// 或者先设定图片格式再保存也是一样的效果
// image.Format = MagickFormat.Png;
// await image.WriteAsync(Path.Combine(rootPath, @"wwwroot\yangmi.png"));

PNG transparency to JPEG with background color

PNG 有透明的话,强行转成 JPEG 会出事。

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");
var fileRootFullPath = Path.Combine(rootPath, @"wwwroot\logo.png");

using var image = new MagickImage(fileRootFullPath);
await image.WriteAsync(Path.Combine(rootPath, @"wwwroot\logo.jpg"), MagickFormat.Jpeg);

效果

image

需要这样设定

using var image = new MagickImage(fileRootFullPath);

image.BackgroundColor = MagickColors.Black; // 默认是白色背景
image.Alpha(AlphaOption.Remove);            // 把透明移除
                                            // 提醒:一定要先设置背景才 remove alpha 哦

await image.WriteAsync(Path.Combine(rootPath, @"wwwroot\logo.jpg"), MagickFormat.Jpeg);

效果

image

 

水印 Watermark

水印的具体做法,是在一张图片上叠加另一张图片,同时第二张图片通常会带有 opacity 效果。

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");

using var yangmi = new MagickImage(Path.Combine(rootPath, @"wwwroot\yangmi.jpg"));
using var logo = new MagickImage(Path.Combine(rootPath, @"wwwroot\logo.png"));

logo.Evaluate(Channels.Alpha, EvaluateOperator.Multiply, 0.5); // opacity 0.5

// 我的 logo 太小了,所以这里做一个 resize
logo.Resize((uint)Math.Round(yangmi.Width * 0.2, MidpointRounding.AwayFromZero), 0);

// 把 logo 覆盖到 yangmi 上,并设置 logo 的坐标位置
yangmi.Composite(logo, x: 10, y: 10, compose: CompositeOperator.Over);

await yangmi.WriteAsync(Path.Combine(rootPath, @"wwwroot\yangmi-with-logo.jpg"));

效果

 image

 

New Image for object-fit: content 效果

CSS object-fit 效果:把一张大图按比例缩小到框框里,并且保留所有图片信息。

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");

using var canvas = new MagickImage(MagickColors.White, 1000, 1000);                // 1000x1000, 1:1
using var yangmi = new MagickImage(Path.Combine(rootPath, @"wwwroot\yangmi.jpg")); // 3840x2160, 16:9

yangmi.Resize(canvas.Width, 0); // 把 yangmi resize 到画布
// 把 yangmi 重叠进画布并剧中
canvas.Composite(
  yangmi,
  x: 0,
  y: (int)Math.Round((1000 / 2m) - (yangmi.Height / 2m), MidpointRounding.AwayFromZero),
  compose: CompositeOperator.Over
);

await canvas.WriteAsync(Path.Combine(rootPath, @"wwwroot\yangmi-contain.jpg"));

效果

image

上面是 new 一张新图,还有一种方式是修改原图

using var image = new MagickImage(Path.Combine(rootPath, @"wwwroot\yangmi1.jpg")); // 原图 3840x2160

// 把原图放入 1000 x 1000 的 box 里,并且是以 object-fit contain 的方式
image.Resize(1000, 1000); // 图变成 1000x563

// Extent 等价于开一个新画布 1000 x 1000 然后把图搬过去
// Gravity.Center 就是搬去中间
image.Extent(1000, 1000, Gravity.Center, backgroundColor: MagickColors.White);

 

在图上写字 DrawText

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");

using var yangmi = new MagickImage(Path.Combine(rootPath, @"wwwroot\yangmi.jpg"));

yangmi.Resize(500, 0);

var text = new Drawables()
  // 设置 font-family, font-style, font-weight, font-stretch
  .Font("Times New Roman", FontStyleType.Normal, FontWeight.Heavy, FontStretch.Normal)
  // 设置 font-size
  .FontPointSize(48)
  // 设置 color
  .FillColor(MagickColors.Blue)
  // 设置 text-align
  .TextAlignment(TextAlignment.Left)
  .Text(
    // 绘画到图片的坐标位置
    x: 100,
    y: 100,
    value: "Hello World !!"
  );

// 把字绘画到图片
text.Draw(yangmi);

await yangmi.WriteAsync(Path.Combine(rootPath, @"wwwroot\yangmi-with-text.jpg"));

效果

image

注意看它 text-align 的位置。

如果是 TextAlignment.CenterTextAlignment.Right 位置是这样:

image

左上角的配置

.TextAlignment(TextAlignment.Left)
.Text(
  x: 0,
  y: 48,
  value: "Hello World !!"
);

效果

image

如果想用特别的 font family 可以传入一个 .ttf 的 file path

var fontPath = Path.Combine(rootPath, @"wwwroot\Montserrat-ExtraBold.ttf");
var text = new Drawables()
  .Font(fontPath)

 

在图上写字 DrawText 2.0

上一个方式有一些局限,比如无法设置背景色,不会 wrap 等等。

这里还有一个更好的方式:

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");

using var yangmi = new MagickImage(Path.Combine(rootPath, @"wwwroot\yangmi.jpg"));

yangmi.Resize(1000, 0);

var settings = new MagickReadSettings
{
  FontFamily = "Times New Roman",       // font-family
  FontWeight = FontWeight.Bold,         // font-weight
  FontStyle = FontStyleType.Italic,     // font-style

  // 如果是用 .ttf 文件,那上面 family, weight, style 就不需要了
  Font = Path.Combine(rootPath, @"wwwroot\Montserrat-ExtraBold.ttf"),

  FontPointsize = 16,                                 // font-size
  TextGravity = Gravity.West,                         // text-alight: left (它是用东南西北中, west 就代表 left)
  BackgroundColor = new MagickColor(255, 0, 0, 128),  // background-color: rgba(255 0 0 / 0.5) 半透明红色
  FillColor = MagickColors.White,                     // color
  Width = 250                                         // 限制 width,字超过就会 wrap 掉下去
};

// 创建一张字体图
using var caption = new MagickImage(
  // 关键就在 starts with "caption:"
  // 后面放我们要写的 text
  "caption:Yang Mi is a Chinese actress, singer, and model known for her roles in popular Chinese TV dramas and films.",
  settings
);

// 把 caption 重叠到图片上,坐标 0,0 位置
yangmi.Composite(caption, x: 0, y: 0, compose: CompositeOperator.Over);

await yangmi.WriteAsync(Path.Combine(rootPath, @"wwwroot\yangmi-with-text.jpg"));

效果

image

除了 caption:,还有一个是 label:,它的特色是一定是一排,这种情况也不需要指定宽度。

 

WebP/AVIF fallback to JPEG/PNG/GIF

用户上传一张 WebP or AVIF,浏览器不一定支持,因此我们需要 fallback to JPEG or PNG or GIF。

首先判断 WebP or AVIF 内容有没有透明,有的话就 fallback to PNG。

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");

using var yangmi = new MagickImage(Path.Combine(rootPath, @"wwwroot\yangmi.jpg"));
using var logo = new MagickImage(Path.Combine(rootPath, @"wwwroot\logo.png"));
using var logoWebp = new MagickImage(Path.Combine(rootPath, @"wwwroot\logo.webp"));
using var yangmiWebp = new MagickImage(Path.Combine(rootPath, @"wwwroot\yangmi.webp"));

Console.WriteLine(yangmi.HasAlpha);     // false
Console.WriteLine(logo.HasAlpha);       // true
Console.WriteLine(logoWebp.HasAlpha);   // true
Console.WriteLine(yangmiWebp.HasAlpha); // false

最快速的判断方式是 HasAlpha,但它不是很准。

因为它的意思是图片支不支持透明 transparency(有没有 Alpha 通道),而不是图片内容到底有没有 transparency。

所以,最精准的做法是检查每一个像素。

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");

using var logoWebp = new MagickImage(Path.Combine(rootPath, @"wwwroot\logo.webp"));

bool hasTransparency = false;

using var pixels = logoWebp.GetPixels(); // 记得前面要加 using
foreach (var pixel in pixels)
{
  var color = pixel.ToColor();
  if (color is { A: < 1 }) // A 就是 alpha,它的值在 0 - 1 之间,0 是完全透明,1 就是完全不透明。
  {
    hasTransparency = true;
    break;
  }
}

Console.WriteLine(hasTransparency); // true

提醒:如果 HasAlphafalse(连 Alpha 通道都不存在),那就一定不会有透明,因此也不需要再检查像素了。

判断有没有透明之后,再判断 WebP 是不是动图。

注释:所谓动图,就是由很多张图 delay 轮播而成。

var rootPath = Path.Combine(AppContext.BaseDirectory, @"..\..\..\");

using var images = new MagickImageCollection(Path.Combine(rootPath, @"wwwroot\animated.webp"));

Console.WriteLine(images.Count); // 72

关键是用了 MagickImageCollection

Count 代表有 72 张 MagickImage

超过 1 张就代表是动图,需要 fallback to GIF。

最后,如果 WebP 不是 PNG、不是 GIF、那就 fallback to JPEG。

 

posted @ 2025-09-18 00:34  兴杰  阅读(0)  评论(0)    收藏  举报