C# 中图像和 OnnxRuntime.Tensor 的互转
因毕设需要,尝试了将 PyTorch 模型转为 ONNX 然后用 C# 做推理,记录一下经验。
总的来说,C# 对深度学习的支持远不如 Python,缺少很多必要的库,不少代码需要自己编写。
思路
毕设做的是 image-to-image 的 low-level 视觉任务,因此需要 3 个主要步骤:
- 从硬盘读取图片并转成张量(image to tensor)
- 使用
OnnxRuntime
完成推理(tensor to tensor) - 将张量转回图片并保存(tensor to image)
找了一圈居然没发现 C# 的官方图像库。System.Drawing
勉强算一个,但这个玩意是 Windows only 的,.NET Core 不支持。那我们自然不要。
在第三方图像库里,SixLabors.ImageSharp 可能是最流行的那个,在网上能找到的一些 C# 做视觉相关的 ONNX 推理任务的 demo 中,这个库也比较常用。因此选定这个库作为图像库。
但是,ImageSharp 并没有提供到 Microsoft.ML.OnnxRuntime.Tensor
的转换接口,反之 Microsoft.ML.OnnxRuntime.Tensor
也没有提供来自 ImageSharp 的转换接口,因此转换的代码必须自己编写。(吐槽:在 Python 中,就是一句 np.array()
的事)
编写转换代码
在使用以下代码之前,确保你安装了 Microsoft.ML.OnnxRuntime
和 SixLabors.ImageSharp
这两个 NuGet 包。
Image to Tensor
其实就是遍历 Image 的所有像素,然后按顺序拷贝到一个新的 DenseTensor
中。注意使用 DenseTensor
,因为它在内存中是连续存储的。此外还要注意除以 255 和数据类型转换(隐式完成),因为 Image
对象的像素值是 的 8 位整型,而绝大多数深度学习模型接受的张量值范围是 的 32 位浮点型。
public static DenseTensor<float> RgbImageToTensor(Image<Rgb24> image) { // 大多数深度学习视觉模型接受的输入格式是 BCHW,即 batch-channel-height-width。 // 在这里,batch 固定为1(在模型训练时它们可能是一个较大的值) // channel 因为是 RGB 的关系所以固定为 3 // height 和 width 则可以变动。请注意,这种可变输入尺寸需要你的 ONNX 模型在导出时显式指定。 var tensor = new DenseTensor<float>(new[] {1, 3, image.Height, image.Width}); image.ProcessPixelRows(accessor => { for (var y = 0; y < image.Height; y++) { var pixelSpan = accessor.GetRowSpan(y); for (var x = 0; x < image.Width; x++) { tensor[0, 0, y, x] = pixelSpan[x].R / 255f; tensor[0, 1, y, x] = pixelSpan[x].G / 255f; tensor[0, 2, y, x] = pixelSpan[x].B / 255f; } } }); return tensor; }
上面的代码适用于 RGB 图像。如果是灰度图像,那么只需要拷贝一个通道即可。
Clamp
在图像通过深度学习模型之后,图像张量中的一些值可能会超过 的范围。在 PyTorch 中,一般都会在模型推理之后紧跟一个 clamp 操作:
pred = model(input_img) # 计算 PSNR/SSIM 等评价指标:略 # ... pred = torch.clamp(pred, 0, 1) # 转成图像格式后保存:略 # ...
torch.clamp
可以将图像的值限制在指定的区间之内。对于上面的示例代码, torch.clamp
将
问了一下 GPT,说是 C# 里没有类似的操作,以 Microsoft.ML.OnnxRuntime
和 SixLabors.ImageSharp
为关键字搜索了一下,也没找到。所以只好自己写了。对性能要求不太高的话很好写:
private static float Clamp(float value, float min, float max) { return (value < min) ? min : (value > max) ? max : value; }
在绝大多数情况下,value
的值都会介于 min
和 max
之间。对于这种情况,上面的代码会执行两次比较。我尝试了下面这种写法:
private static float Clamp(float value, float min, float max) { if (value < min || value > max) { return (value < min) ? min : max; } return value; }
经过实测,性能没有明显的提升。可能编译器替我们做了某些优化。
Tensor to Image
有了 Clamp 之后,可以编写 Tensor to Image 的代码了:
public static Image<Rgb24> TensorToRgbImage(DenseTensor<float> tensor) { var height = tensor.Dimensions[2]; var width = tensor.Dimensions[3]; var image = new Image<Rgb24>(width, height); image.ProcessPixelRows(pixelAccessor => { for (var y = 0; y < height; y++) { var rowSpan = pixelAccessor.GetRowSpan(y); for (var x = 0; x < width; x++) { var r = Clamp(tensor[0, 0, y, x] * 255, 0, 255); var g = Clamp(tensor[0, 1, y, x] * 255, 0, 255); var b = Clamp(tensor[0, 2, y, x] * 255, 0, 255); rowSpan[x] = new Rgb24((byte)r, (byte)g, (byte)b); } } }); return image; }
合起来
把以上三部分代码合起来:
using Microsoft.ML.OnnxRuntime.Tensors; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; public static class Convert { // 把上面的三个函数塞里面 }
可能的优化
很容易想到,上面的代码的性能是很差的。
可以参考 此处。
实际上,上面的代码就是根据这里的代码改来的,但是这份代码我看不太懂。
测试
自己准备一个 image-to-image 的 ONNX 模型,这一类模型里最好找的应该是做超分辨率任务的。你可以在 此处 找到许多用于超分辨率的预训练模型。你也可以使用自己的模型。
然后,编写执行 ONNX 推理的代码:
using Microsoft.ML.OnnxRuntime; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using System.Diagnostics; public class OnnxInference { private readonly InferenceSession _session; public OnnxInference(string modelPath) { // check the path if (!File.Exists(modelPath)) { throw new FileNotFoundException("Model file not found", modelPath); } // create inference session _session = new InferenceSession(modelPath); } public void Inference(string inputImagePath, string outputImagePath) { long startingTime; long endingTime; // check the path if (!File.Exists(inputImagePath)) { throw new FileNotFoundException("Image file not found", inputImagePath); } // read the image using var image = Image.Load<Rgb24>(inputImagePath); var origHeight = image.Height; var origWidth = image.Width; var height = origHeight; var width = origWidth; // 在这里,你需要根据自己模型的要求将输入图像的尺寸调整到合适大小。 // 例如,对于我的模型,长和宽需要是16的整数倍。 // 你应该记录原始的图像尺寸,这样在得到结果图像之后可以 resize 回去。 if (origHeight % 16 != 0 || origWidth % 16 != 0) { height = origHeight + 16 - origHeight % 16; width = origWidth + 16 - origWidth % 16; // Resize image image.Mutate(x => { x.Resize(new ResizeOptions { Size = new Size(width, height), Mode = ResizeMode.Stretch }); }); } // 我在测试时记录了一些关键操作的耗时,你可以根据需要删去这些代码。 startingTime = Stopwatch.GetTimestamp(); // convert the image to tensor var tensor = Convert.RgbImageToTensor(image); endingTime = Stopwatch.GetTimestamp(); Console.WriteLine($"Convert image to tensor: {(endingTime - startingTime) / (double)Stopwatch.Frequency}"); // 这里的输入,需要根据模型导出时指定的输入来写。 // create input container var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("data_with_est", tensor), NamedOnnxValue.CreateFromTensor("data", tensor), }; startingTime = Stopwatch.GetTimestamp(); // run the inference using IDisposableReadOnlyCollection<DisposableNamedOnnxValue> results = _session.Run(inputs); endingTime = Stopwatch.GetTimestamp(); Console.WriteLine($"Run inference: {(endingTime - startingTime) / (double)Stopwatch.Frequency}"); // get the output tensor var output = results[0].AsTensor<float>(); // print the shape of the output tensor Console.WriteLine("Output tensor shape:"); foreach (var dimension in output.Dimensions) { Console.Write($"{dimension} "); } startingTime = Stopwatch.GetTimestamp(); // convert the tensor to image var resultImage = Convert.TensorToRgbImage(output.ToDenseTensor()); endingTime = Stopwatch.GetTimestamp(); Console.WriteLine($"Convert tensor to image: {(endingTime - startingTime) / (double)Stopwatch.Frequency}"); // 如果图像的尺寸修改过(意味着在推理之前执行了 resize), // 那么就要 resize 回去 if (height != origHeight || width != origWidth) { // Resize image resultImage.Mutate(x => { x.Resize(new ResizeOptions { Size = new Size(origWidth, origHeight), Mode = ResizeMode.Stretch }); }); } // save the image resultImage.Save(outputImagePath); } }
然后使用以下的代码来使用上面的工具代码:
const string modelPath = @"D:\Path\to\your\onnx\model.onnx"; const string inputImagePath = @"D:\path\to\your\image.png"; var model = new OnnxInference(modelPath); model.Inference(inputImagePath, "output.png");
上面的代码在我的电脑上针对一张 256 * 464 的图像的执行时间如下:
- image to tensor: 47ms
- inference: 616ms(取决于模型)
- tensor to image: 75ms
绝对算不上快,但对我来说已经够了。因为我只需要推理单张图像。
tensor to image 过程稍慢,可能是因为 clamp 操作。
晚些日子我会试着用 Avalonia、ImageSharp 和 OnnxRuntime 写一个手写数字识别的程序,然后公开出来。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix