SimdCodecs 1.4:纯 C# 解码手机 HEIC,8 核下耗时只有 libheif 的 0.32 倍

上面这张 3072×4096 的 HEIC 照片,Chrome 和 Firefox 都打不开。右边写着"你的浏览器:打不开",左边的画面是 Sdcb.SimdCodecs 编译成 WebAssembly 后在浏览器里解出来的,黄色网格是文件里的 48 个 HEVC tile。

上周我写了《SimdCodecs 开源:纯 .NET 图像编解码,比 C 原生库还快》,文章末尾说 HEIF 是下一个要啃的硬骨头。一周之后,它已经发布了:

bash 复制代码
dotnet add package Sdcb.SimdCodecs.Heif

在 8 核的 Ryzen 7 5800X 上,把手机照片解码成 RGBA,耗时是 libheif 1.23.1 的 0.20 到 0.32 倍 ,峰值内存也比它低。HEVC 解码出来的 Y/Cb/Cr 平面和 ffmpeg 8.1.2 逐字节一致。依然是纯 C#、MIT、能 NativeAOT、能进浏览器。

一周,四个版本

1.0 发布之后,我没停手,一周里又发了四个版本:

版本 日期 新增
1.1 10-30 BMP(全部 Windows 和 OS/2 变体)、ICO / CUR,都能解码和编码
1.2 10-30 QOI、Netpbm(PBM / PGM / PPM / PAM / PFM),都能解码和编码
1.3 11-03 WebP 编码:无损、有损、带 alpha、动画
1.4 11-04 HEIF / HEIC 解码

四个版本加起来大约 3.7 万行新代码,NuGet 上的包从 13 个变成了 18 个,版本号统一是 1.4.0。

1.4:HEIF / HEIC 解码

先说为什么 HEIF 最重要。现在 iPhone 和不少安卓手机,拍照默认就存成 HEIC。我自己的项目里,用户从手机浏览器上传上来的图,就经常是这种格式。而在 .NET 里想读它,以前基本只能靠 libheif、Magick.NET 这些原生库。

Sdcb.SimdCodecs.Heif 支持这些:

  • HEVC Main(8 位)、Main 10(10 位)和 Main Still Picture,4:2:0;
  • 手机相机用的 grid 拼图,也就是上面那张图里的 48 个 tile;
  • clap 裁剪、irot 旋转、imir 镜像,按文件的意思摆正;
  • ICC 配置透传(手机照片常是 Display P3),nclx 色彩信息。

用法是先注册,然后门面的所有入口都认识 HEIC 了:

csharp 复制代码
using Sdcb.SimdCodecs;
using Sdcb.SimdCodecs.Abstractions;
using Sdcb.SimdCodecs.Heif;

SimdCodecs.Register(HeifImageDecoder.Registration);

byte[] data = File.ReadAllBytes("photo.heic");
if (SimdCodecs.TryReadInfo(data, out ImageInfo info))
{
    byte[] bgra = new byte[info.Width * info.Height * 4];
    SimdCodecs.TryDecodeInto(data, bgra, 0, ImagePixelFormat.Bgra32);
}

在 实验台的 HEIC 批量转换 里,可以把一堆 HEIC 拖进去,转成 JPEG、PNG 或 WebP 后打包下载。整个过程都在你的浏览器里完成,照片不会上传:

性能

测量结果 来自 AMD Ryzen 7 5800X、.NET 10,对手 libde265 1.1.1、libheif 1.23.1 和 Magick.NET 14.13.1 都在同一个进程里、吃同一个 byte[]。单位是毫秒,越小越快:

解码到 RGBA,8 核 Sdcb.SimdCodecs.Heif libheif Magick.NET
3072×4096 手机照片(48 tile) 49.6 195.7 443.3
3072×4096 Main 10(48 tile) 65.1 270.2 505.9
8192×6144(192 tile) 166.1 804.9 1770.5
1600×900 单张图(WPP) 11.1 39.5 81.5

多核下 tile 是并行解的;没有 tile 的单张图,就用 HEVC 的 WPP 按 CTB 行并行。这对 libheif 来说不完全公平,所以我也单独比了单核:只看 HEVC 解码这一步,耗时是 libde265 的 0.68 到 0.87 倍。

内存上,每个 tile 一解完就直接转换写进输出缓冲区,任意像素布局、任意 stride 都一样,所以唯一和整张图一样大的缓冲区就是输出本身。12.6 MP 照片峰值内存增加 75 MB(libheif 80 MB,Magick.NET 220 MB),50 MP 的是 230 MB(libheif 373 MB)。

速度是怎么来的

HEVC 的帧内解码,大头在这几步:熵解码(CABAC)、反变换、帧内预测、去块滤波和 SAO,最后是 YCbCr 到 RGB。每一步我都做了 interleaved A/B 测量,归因靠的是逐项关闭的消融实验,不靠 profiler 估算。挑几个变化大的说:

  • 反变换:最早是朴素的 O(N³) 三重循环。改成只算有系数的那块矩形上的 int32 矩阵乘、利用奇偶对称,再把缩放并进解析、残差加法并进内核之后,单核 12.6 MP 从 632 ms 降到 463 ms;
  • CABAC:一次读一位,改成 64 位预读窗口,LPS 一步完成重归一化,状态转移表打包;
  • SAO:逐样本检查邻居、slice 和旁路条件,改成在 CTB 级别一次性证明,再走 int16 的行内核;
  • 帧内预测:每 4 个样本查一次 z-scan 可用性,改成 O(1) 查重建图,planar 和 angular 预测向量化。

每个向量内核都有一个标量版本作对照,AVX2、Vector128、标量、x64 和 arm64 输出完全一致,CI 在每一档上都跑一遍。

安全

HEIC 文件通常来自用户上传,解码器必须扛得住恶意输入。HeifFuzzTests 在 8 个进程里跑了 30 万轮模糊测试,找到并修了 3 个 bug:

  • iloc 的字段读取没做边界检查;
  • SPS 里的图片尺寸不是 MinCbSizeY 的倍数时,会越界写;
  • 一个很小的文件就能让每个 tile 都分配一张巨大的图。现在先校验 grid 和几何信息,再分配,并且限制了编码样本总数。

专利

上一篇我说过,HEIF 最麻烦的不是技术,是 HEVC 的专利池。代码全部自己写、用 MIT 发布,专利问题也绕不过去。

我的做法是:Sdcb.SimdCodecs.Heif 是一个独立的包,门面包 Sdcb.SimdCodecs 不引用它 ,需要你自己安装并调用 Register。包本身是 MIT,但不授予任何 HEVC 专利许可。要不要用 HEVC、在你所在的地区有什么义务,由每个应用自己评估。

1.4 还没做的:10 位的 Main 10 目前输出 8 位;alpha、HDR 增益图、深度图和 EXIF 还不读;图像序列(.heics)和 Main 12、4:4:4 这些扩展 profile 也不支持。

1.3:WebP 编码

1.0 的 WebP 只能解码,1.3 补上了编码,对手是 libwebp 1.6.0 和它自带的 cwebp、img2webp,14 张图共 9.1 MP:

  • 无损 (WebpEncoder.EncodeLossless):默认力度下,文件是 libwebp 默认档的 0.986 倍 ,速度是它的 1.14 倍;
  • 有损 (WebpEncoder.EncodeLossy):同等 PSNR-Y 下 BD-rate −6.22% ,也就是同样画质文件小 6% 左右;q75 耗时是 libwebp 的 0.93 倍;
  • 带 alpha 的有损:alpha 默认无损,BD-rate −4.51%;
  • 动画 (WebpAnimationEncoder):编码器只写变化的矩形、合并不变的帧。和 img2webp 比,无损体积是它的 0.931 倍,有损 BD-rate −10.95%。
csharp 复制代码
byte[] lossless = WebpEncoder.EncodeLossless(rgba, w, h, ImagePixelFormat.Rgba32);
byte[] lossy = WebpEncoder.EncodeLossy(rgba, w, h, ImagePixelFormat.Rgba32,
    new WebpLossyEncodeOptions { Quality = 75 });

顺手还修了两处解码问题:宽度不是 16 倍数时右边缘的预测像素取法,以及半透明动画帧的混合舍入。现在这两种情况也和 libwebp 逐字节一致了。

实验台的 小工具 里有一个 GIF 转动画 WebP,8 帧的示例动画从 15.8 KB 变成 6.7 KB。旁边的 favicon.ico 生成器用的是 1.1 的 ICO 编码器:

1.1 和 1.2:BMP、ICO / CUR、QOI、Netpbm

这几个格式不难,但"冷门格式读不了"是实打实的缺口。比如 OS/2 的 BMP、带 RLE 压缩的 BMP、Netpbm 的 16 位 PGM 和浮点 PFM,很多库只支持一部分。

  • BMP :core 到 V5 各版头部、1 到 32 位、任意通道掩码、RLE4 / RLE8 / RLE24、OS/2 Huffman 1D、内嵌 PNG、ICC。解码和 WIC 逐像素一致,22 种布局的几何平均耗时是 WIC 的 0.20 倍,编码到流是 0.05 到 0.08 倍;
  • ICO / CUR:读出每一张图和光标热点,编码多尺寸图标和光标,每张图可以是 PNG 或 DIB;
  • QOI / Netpbm :和 ImageSharp 3.1.12 比,解码耗时 0.29 倍 / 0.23 倍,编码 0.49 倍 / 0.54 倍,解码和编码两个方向都和 ImageMagick 结果一致。

这些格式都能零分配地解码进你自己的缓冲区,任意布局、任意 stride:

csharp 复制代码
// 32×32 光标,热点在 (4, 2)
byte[] cur = IcoEncoder.EncodeCursor([new IcoFrame(rgba32, 32, 32, HotspotX: 4, HotspotY: 2)]);

// 8 位调色板 + RLE 压缩 + 抖动的 BMP
byte[] bmp = BmpEncoder.Encode(rgba, width, height, ImagePixelFormat.Rgba32,
    new BmpEncodeOptions { Format = BmpEncodeFormat.Indexed8, Rle = true, Dither = true });

新格式和老格式一样,都有模糊测试,也都进了 NativeAOT 和浏览器 WebAssembly 的冒烟测试,CI 在 Vector128、标量和 arm64 上各跑一遍。

新的 sdcb.ai/simdcodecs

跟着 1.4,我把 项目主页 和 实验台 都重做了一遍。

主页的性能一节,新增了 HEIC、BMP、QOI / Netpbm 的卡片:

实验台从三个页签改成了五个:打开并检查、兼容性实测、格式擂台、HEIC 批量转换、小工具。前面已经出现过三个,再看另外两个。

兼容性实测:把 19 个示例文件分别交给浏览器自己的解码器和 SimdCodecs。我用无头 Chrome 145 跑的结果是 7 / 19 对 19 / 19,浏览器打不开 HEIC、TIFF、JPEG 2000、JBIG2、QOI 和 Netpbm:

格式擂台:同一张图过 9 个编码器,看体积、PSNR 和耗时,左边用滑块和原图对比。默认的输入就是那张 HEIC 照片:

浏览器里是单线程 128 位 SIMD,耗时只供参考,桌面上跑 AVX2 要快得多。实验台的数字是 wasm 的,前面那些对比表的数字是桌面的。

2.0 展望

1.4 的宣传慢了一步,2.0 其实已经差不多做完了,代码在 main 分支上。2.0 没有加新格式,主要是重新设计 API,把 1.x 里一些"做得到但不好用"的地方补齐:

  • 16 位和预乘输出 :ImagePixelFormat 枚举换成 PixelFormat 结构,新增 Rgba64、Gray16、Bgra32Premultiplied 等。HEIF 的 Main 10 终于能输出 16 位了;
  • DecodeOptions:区域解码(HEIF 只解码区域涉及的 tile)、JPEG 1/2 到 1/8 缩放解码、池化内存;
  • 元数据和缩略图 :TryReadMetadata 拿到 Exif、XMP、ICC 原始块,TryDecodeThumbnail 取出内嵌的预览图(Exif IFD1、HEIF 的 thmb);
  • ImageDecoder.Open :类似 WIC 的会话,可以打开 Stream、内存映射文件或内存;
  • 统一的编码入口 :SimdCodecs.Encode(image, ImageFormat.Png, stream),9 个编码器一套 API,接收任意 PixelFormat;
  • 所有入口都接收 CancellationToken;
  • JPEG 2000 和 JBIG2 补全 :JPEG 2000 的多 tile、全部 5 种渐进顺序等,16 个测试文件和 opj_decompress 一致;JBIG2 在 114 个文件里有 112 个和 jbig2dec 逐像素一致,另外 2 个两边都判定为损坏;
  • 浏览器里的 JPEG 解码:加了 PackedSimd 内核,示例照片从 22.0 ms 降到 8.2 ms。

API 改名的地方,大多数代码改个名字就行,8 位输出和 1.x 逐字节一致。完整清单见 迁移指南。性能方面,JPEG、PNG、WebP 迁移到新 API 后和 1.x 相比在测量噪声范围内,没有变慢。

也老实说一下 2.x 之后还没做的:

  • 所有格式依然要把整个文件读进内存,真正的流式输入还没做;
  • 除了 HEIF,区域解码目前是先整张解码再裁剪;
  • JPEG 2000 还没调优,耗时约是 libopenjp2 的 2 倍;
  • AVIF 和 JPEG XL 目前只能识别,不能解码;
  • HEIF 编码大概率不会做:HEVC 编码的专利问题比解码更麻烦,而且编码器的工作量也不比解码器小。要输出,用 JPEG、WebP 或 PNG 就好。

最后

交流

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

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

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