SimdCodecs 2.0 发布:一套 API 解所有图片,从 ImageSharp 迁过来只改几行

上面是一张 3072×4096 的手机 HEIC 照片,Chrome 打不开。左边的画面是 Sdcb.SimdCodecs 2.0 编译成 WebAssembly 后在浏览器里解出来的。右边的「解码选项」里,我选了 1/4 缩放,所以解码输出直接就是 768×1024,中途不会先生成一张 1200 万像素的整图。

SimdCodecs 2.0.0 已经在 11 月 9 日发布了。这一版把公共 API 重新设计了一遍,所有格式现在都能做同样的事:解码到任意像素布局,缩放、裁剪,按 EXIF 方向转正,带着元数据出来;编码也只有一套入口。

bash 复制代码
dotnet add package Sdcb.SimdCodecs
csharp 复制代码
using PixelBuffer image = ImageCodecs.Decode(file, PixelFormat.Rgba32, new DecodeOptions { ApplyOrientation = true });
ImageCodecs.Encode(image.Pixels, image.Width, image.Height, image.Format, ImageFormat.Jpeg, output,
    stride: image.Stride, options: new JpegEncodeOptions { Quality = 90 }, metadata: image.Metadata);

读一张图,摆正,带着 ICC、Exif、XMP 存成 JPEG,就这两行。

1.4 之后又做了什么

上一篇 文末的「2.0 展望」已经介绍过 PixelFormat、DecodeOptions、元数据、ImageDecoder.Open 和统一编码入口,这里不再重复。从 v1.4.0 到 v2.0.0 一共 137 个提交,新增约 4.6 万行。展望里没提到、之后才合进来的有这些:

日期 新增
11-05 APNG 解码和编码;TIFF 补全:BigTIFF、调色板 / CMYK / YCbCr / alpha、JPEG 压缩、按条带读区域;JPEG 2000 多 tile 并行解码
11-06 JPEG 编码支持 restart 间隔;从 ICC 识别 Display P3 / BT.2020 色域;HEIF 的 Exif 方向重置
11-08 PNG 有损调色板量化(pngquant 那种用法);所有格式的缩放和区域解码
11-09 ApplyOrientation 自动转正;解码失败抛出带原因的异常;JBIG2 多页;JPEG 的 Extended XMP;编码选项类型检查

先说 breaking changes

大版本号,就是因为有不兼容的改动。我先把它们列清楚。

门面类 SimdCodecs 改名成了 ImageCodecs。 这是我设计上的失误:类名和命名空间段同名,只要你项目的命名空间里也带 SimdCodecs,编译器就会把 SimdCodecs.TryReadInfo 当成你自己的命名空间去找,报 CS0234。我用 1.4.0 写了一个最小复现,命名空间叫 MyApp.SimdCodecs,再换成 2.0.0 和 ImageCodecs 对比一下:

这种问题只能靠改名解决,所以 2.0 没有保留旧名字的别名。

其余的不兼容改动:

  • ImagePixelFormat 枚举换成了 PixelFormat 结构,PixelFormat.Rgba32、.Bgra32、.Rgb24、.Bgr24、.Gray8 还是原来的布局;
  • 门面上的 TryDecodeIntoRgba8 删掉了,改用 TryDecodeInto(data, buffer, 0, PixelFormat.Rgba32),或者直接 ImageCodecs.Decode(data, PixelFormat.Rgba32);
  • IDecodedImage 现在是 IDisposable,Dispose 之后再读 Pixels 会抛异常;
  • WebP 不传编码选项时,默认是有损 ;要无损,传 new WebpLosslessEncodeOptions();
  • 给编码器传了别的格式的选项(比如把 JpegEncodeOptions 传给 WebP),以前会悄悄用默认值,现在直接抛 ArgumentException;
  • HEIF Main 10 解码出来是 16 位样本,不再是 8 位。

大多数代码改个名字就行。所有 8 位的解码输出和 1.x 逐字节一致 ,HEIF Main 10 除外。完整清单见 迁移指南。

所有格式都能缩放、裁剪、转正

1.x 里只有 JPEG 能缩放解码。2.0 里,DecodeOptions.ScaleDenominator 对每个格式都接受任意 2 的幂:

  • JPEG 在 DCT 域缩小(1/2 到 1/8),JPEG 2000 直接取对应的分辨率级;
  • PNG(非隔行)、有损 WebP 和 HEIF 边解码边缩放,不会生成整张图;
  • 其余格式先完整解码,再用一个固定的 box filter 缩小(在 gamma 空间、按 alpha 加权)。

不管走哪条路,传一个合法的 2 的幂都不会抛异常。TryGetClosestSize 会告诉你哪些尺寸是原生支持的。

HEIF 的边解边缩,是每个 tile 解完就直接缩小写进输出。和 1.x 里「先解整张、再缩小」相比,3072×4096 的手机照片缩到 1/2,8 核上耗时从 111 ms 降到 76 ms,峰值内存增量从 72 MB 降到 38 MB(记录)。

Region 是裁剪:TIFF 和 HEIF 只读和区域相交的条带或 tile;JPEG、PNG、WebP 只转换区域里的行,PNG 和 WebP 解到区域的最后一行就停。所以一个尾部损坏的文件,整张解码失败,取上半部分的区域却可能成功。

ApplyOrientation = true 会按 EXIF / TIFF 的方向把图转正,区域也按转正后的坐标算,输出元数据里的 Orientation 会重置成 1,下游不会再转一次。手机竖着拍的照片,以前要自己写旋转,现在一个选项就行。

实验台里也能试。下面是一张 1600×1067 的相机 JPEG,选 1/4 缩放,输出 400×267,wasm 里解码只用了 5.5 ms:

从 ImageSharp 迁移

第一篇我说过,做 SimdCodecs 的起因之一,就是我的开源库没法依赖需要 license key 的 ImageSharp 新版本。2.0 的 API 有一部分就是照着「从 ImageSharp 换过来要顺手」去设计的,README 里有一张完整的对照表,项目主页上也放了常用的几行:

两个行为差异要注意:

  • 不设 ApplyOrientation 时,像素按文件里存的方向输出,不会自动转正;
  • 16 位或 HDR 的图转成 8 位 PixelFormat 是普通转换,不做色调映射。浮点、PQ / HLG 的图会直接拒绝 8 位布局,不去猜。

边界也要说清楚:SimdCodecs 只做编解码,不做缩放、画图、滤镜。需要这些,可以解码进 SKBitmap 或 OpenCV 的 Mat(仓库里有 SkiaSharp 和 OpenCvSharp 的 LINQPad 示例),也可以继续用 ImageSharp 做处理,只把读写换掉。

PNG 有损量化,和 pngquant.sdcb.ai

2.0 的 PNG 编码器多了一个有损调色板模式,PngWriteOptions.Quantize,做的就是 pngquant 做的事:把图压到最多 256 色,半透明也保留。

csharp 复制代码
using IDecodedImage image = ImageCodecs.Decode(File.ReadAllBytes("photo.png"));
ImageCodecs.Encode(image, ImageFormat.Png, stream, new PngWriteOptions { Quantize = new() });

调色板在 OKLab 色彩空间里选,抖动会避开边缘。和 pngquant 比(它只当黑盒二进制用,不读源码),按同一个质量参数各自写出文件,结果 是:

同 SSIM 下的体积(BD-rate) 耗时
不抖动 −8.7% 0.28 倍
抖动 −15.4% 0.34 倍

pngquant 的耗时包含了进程启动和 PNG 读写,所以耗时这一列只能看个大概;体积这一列是两边自己写出的文件,比较公平。

为了自己用着方便,我顺手做了一个在线工具 pngquant.sdcb.ai。它用的就是 Sdcb.SimdCodecs.Png,编译成 WebAssembly 在浏览器里跑,图片不上传。默认参数对齐 pngquant --quality=0-20 --nofs,左边调参数时会实时显示等价的 pngquant 命令。下面这张 522×696 的截图,444 KB 压到 64.7 KB,只用了 59 色:

这个工具也是开源的:sdcb/pngquant-web。

修复与验证

2.0 修了一个 1.0 就有的 bug:arm64 上部分无损 WebP 解码出错。VP8L 的 select 预测器要把四个通道的距离加起来,AdvSimd 版本用一个字节存这个和,超过 255 就回绕了,于是在 Apple Silicon、Android、ARM 服务器上,一部分无损 WebP 会解出错误的像素。x64 不受影响。原因是之前 CI 的 SIMD 分档测试没覆盖 WebP。现在 WebP 和 HEIF 的测试在 Vector256、Vector128、标量和 arm64 每一档上都跑。

另外,issue #1 里报告的几个解不开的 JBIG2 和 JPEG 2000 文件,现在和 jbig2dec、OpenJPEG 的输出一致了。

发布前的验证:

  • 7728 个测试,要求所有对拍用的参照程序都在场(REQUIRE_ORACLES=1);
  • NativeAOT 和 Mono AOT(浏览器 wasm)下,每个门面入口的输出都和 JIT 一致;
  • 性能上,和 1.x 同一次运行交替测量,整图解码路径都在噪声范围内。

还有一个数字要更正:第一篇里我写 PNG 照片类解码耗时是 libpng 的 0.61 倍,那是整个测试矩阵的几何平均,里面 1 / 2 / 4 位灰度和调色板图把平均值拉低了。这次对 libpng 1.6.39 重新测,README 改成只引用最常见的 8 位 RGB / RGBA 照片,是 0.88 倍 ,ImageSharp 在同样的图上是 0.91 倍(记录)。这一项上我们和 ImageSharp 差不多。

新的 sdcb.ai/simdcodecs

项目主页 跟着 2.0 改了:代码示例换成 2.0 的 API,格式和性能改成和 README 一致的两张表,加上了 ImageSharp 迁移表。

实验台 也换成了 2.0:打开文件时可以选缩放、勾选按 EXIF 转正,开头那张图就是这么来的;格式擂台里多了一项「PNG 量化」,同一张图过 10 个编码器:

1.4 里的「小工具」页签(GIF 转 WebP、favicon 生成)这次拿掉了,图片压缩现在有 pngquant.sdcb.ai。

还没做的

最大的缺口还是格式:AVIF 和 JPEG XL 目前只能识别,不能解码 。ImageCodecs.DetectFormat 认得出它们,IsDecodable 会返回 false;AVIF 里的 Exif 倒是已经能用 ExifReader 读出来,但图像本身还解不了。

这两个是我接下来最想啃的:

  • AVIF:基于 AV1,免版税,规范公开,最适合按 clean room 的规矩从规范写起。现在 Chrome、Firefox、Safari 都能直接显示 AVIF,不少网站和 CDN 已经在用,.NET 里想读它,还是只能靠 libavif、libdav1d 这些原生库;
  • JPEG XL:同样免版税,但 ISO 规范要花钱买。它能把现有的 JPEG 无损地重新压缩、还能原样还原回去,这一点对存量图片很有吸引力。

和 HEIF 一样,这两个格式的解码器,每一个的工作量都不比 JPEG、PNG、WebP 加起来小,具体什么时候能出来,我现在不敢打包票。HEIF 自己也还有没做完的:图像序列(.heics),以及 Main 12、4:4:4 这些扩展 profile。

API 和性能上,2.x 还要做的,known-gaps 里都写着:

  • 真正的流式输入:传 Stream 进来,目前还是先整个读进内存;
  • 按行流式编码;
  • JPEG 的区域解码:现在每个 MCU 仍要熵解码,耗时接近整张解码,按 restart 间隔跳过要等 2.x;
  • JPEG 2000 单核耗时仍是 libopenjp2 的约 2 倍,多核下是 1.15 到 1.75 倍。

最后

API 设计上有不顺手的地方,欢迎在 GitHub 上提 issue,2.x 里能不破坏兼容就改。

交流

感谢阅读到这里,如果觉得 SimdCodecs 有用,欢迎去 GitHub 点个 Star ,也欢迎评论 和点赞,这是我持续创作的动力!

欢迎关注微信公众号 .NET骚操作 ,或者扫码加入 .NET骚操作 微信交流群:

(如果微信群二维码过期,请加入 .NET骚操作交流QQ群 :495782587 ;做图像和视觉相关开发的朋友,也欢迎加入 C#/.NET计算机视觉技术交流 QQ 群 :579060605)