대부분의 머신러닝 학습은 PyTorch 또는 Tensorflow 프레임워크를 이용할 것이다. 하지만 실제로 이를 이용해서 어플리케이션을 제공해야 하는 경우 다른 언어환경에서 실행 해야하는 경우가 많다. 그 중에서 최근에 윈도우 어플리케이션을 제공하기 위해 UWP 또는 .NET 프레임워크를 사용해야 했는데 이는 C#으로 구현이 되어 있어 C#에서 파이토치 모델을 실행시킬 방법을 찾아야했다. 이번 포스트에서는 C#에 파이토치 모델을 포팅하는 시행착오와 방법들에 대해서 설명하겠다.
우선 관련된 정보를 수집한 결과 Open Neural Network Exchange (ONNX)에 대해서 알게 되었다.
ONNX는 그 의미처럼 서로다른 실행환경에서 동일한 모델을 학습시키거나 운용하도록 하는 통일된 모델의 표준 형식이었다. 처음 시도한 것은 파이토치에서 ONNX 모델을 Export한 뒤에 이를 UWP 환경에서 실행시키는 것이었다.
이 부분도 생각보다 간단했다. 이미 torch.onnx 라이브러리에 기존 nn.Module로 구성된 파이토치 모델을 .onnx 파일로 내보내는 기능이 구현되어 있었다. 중요한 점은 모델이 Quantized 버전이라면 ONNX로 변환하는 것은 불가능하다. 이를 제외하면 Torch.nn 라이브러리에서 제공하는 대부분의 모델 구성요소는 변환이 가능했다.
#실제 사용했던 코드를 올리고 싶지만 이는 보안상 불가능해서 예시로 대체한다.
import torch
import torchvision
dummy_input = torch.randn(10, 3, 224, 224, device='cuda')
model = torchvision.models.alexnet(pretrained=True).cuda()
input_names = [ "actual_input_1" ] + [ "learned_%d" % i for i in range(16) ]
output_names = [ "output1" ]
torch.onnx.export(model, dummy_input, "alexnet.onnx", verbose=True, input_names=input_names, output_names=output_names)
ONNX로 변환하였으면 당연히 정상적으로 변환이 되었는지 확인하고 싶을 것이다. Netron을 이용하면 onnx 모델을 시각화하여 확인 할 수 있다.
그리고 Python 환경에서 onnx, onnxruntime 패키지를 이용해서 ONNX 모델이 변환 전 모델과 결괏값이 일치하는지 확인 할 수 있다.
import onnx
import onnxruntime
import numpy as np
from PIL import Image
from torchvision.transforms import transforms
onnx_model = onnx.load("post_plus_model.onnx")
onnx.checker.check_model(onnx_model)
ort_session = onnxruntime.InferenceSession("post_plus_model.onnx")
def to_numpy(tensor):
return tensor.detach().cpu().numpy() if tensor.requires_grad else tensor.cpu().numpy()
#get input
image_path="./test_files/effusion_IN_00001_0000007728.png"
img_resize=512
image_init = Image.open(image_path).convert('RGB')
image_np = image_init.resize((img_resize, img_resize))
image_np = np.array(image_np)
#normalize
test_transform_list = []
test_transform_list.append(transforms.Resize((img_resize, img_resize)))
test_transform_list.append(transforms.ToTensor())
test_transform_sequence = transforms.Compose(test_transform_list)
image = test_transform_sequence(image_init)
image = image.unsqueeze(0)
def to_numpy(tensor):
return tensor.detach().cpu().numpy() if tensor.requires_grad else tensor.cpu().numpy()
# compute ONNX Runtime output prediction
ort_inputs = {ort_session.get_inputs()[0].name: to_numpy(image)}
ort_outs = ort_session.run(None, ort_inputs)
print(ort_outs)
위 과정까지 진행하여 ONNX 변환이 정상적으로 이루어 진 것을 확인했다면, 이제 후처리 과정을 통합한 모델을 생성해보자. 후처리 과정을 따로 진행하지 않고 통합하는 이유는 C#환경에서는 ONNX를 다루는 라이브러리가 Python에 비해서 절망적으로 부족하기 때문이다. 그래서 필자는 최대한 후처리 과정을 모델에 통합하도록 하였다. 이렇게 하면 환경이 달라져도 최대한 통일된 값에 가깝게 출력이 가능하며 환경에 따른 후처리 코드량도 줄어들기 때문이다.
class post_plus_model(nn.Module):
def __init__(self, class_names, pool_type,checkpoint_path):
super(post_plus_model,self).__init__()
#기존 모델 생성
self.net = model_func(class_names = class_names, pool_type = pool_type)
self.net.eval()
if checkpoint_path:
net = load_checkpoint(file_name = checkpoint_path, net = self.net)
net.eval()
else:
print("you can't laod the model")
exit()
def forward(self, x):
pred_list, feat_map_list = self.net(x)
#여기서 부터 일부 후처리 과정을 진행합니다.
for idx in range(3):
pred_list[idx]=pred_list[idx]
#make heatmap
params = list(self.net.classifiers[idx][0].parameters())[-2]
feature_map = feat_map_list[idx]
_, c, h, w = feature_map.shape
cam = params[0].matmul(feature_map.reshape(c, h * w))
cam = cam.reshape(h, w)
cam = cam - torch.min(cam)
cam_img = cam / torch.max(cam)
cam_img = 255 * cam_img
feat_map_list[idx]=cam_img
return (pred_list,feat_map_list)
후처리 과정에서 feature map을 생성하는 과정을 추가한 코드이다. 참고로 후처리 과정은 torch 레이어에서만 처리가 가능하다. 만약 중간에 numpy로 결괏값을 변환하는 등 타입 변환과정을 지나면 ONNX로 변환할 때 모델의 결괏값을 인식하지 못하는 경우가 발생한다. 따라서 OpenCV 등의 처리과정은 따로 처리할 수 밖에 없었다.
UWP 환경에서 실행 예제를 통해 모델 실행 방법에 대해서 설명하겠습니다. 아래 파일은 완성된 실행 예제입니다. UWP Prerequisites를 충족하고 내부의 .sln 파일을 실행하면 Visual Studio Code를 통해서 실행 할 수 있습니다.
winver
via the Run command (Windows logo key + R)
.Windows ML code - classifierPyTorchModel.zip
public static SRC_DK_Classifier CreateFromStreamAsync(string modelFilePath)
modelFilePath를 통해서 onnx 모델 파일에 접근하여 추론을 위한 세션을 구축합니다.public Output inference(Image<Rgb24> image, IImageFormat format)
SixLabors.Image 형태의 이미지와 형식을 입력하면 모델 추론과 2차 후처리를 통해서 결과를 반환합니다.class Output
{
public float[] pred=new float[3];
public float[][] feat_map=new float[3][];
public Mat[] colorMap=new Mat[3];
}
추론 시 반환되는 인스턴스의 클래스 pred : Normal, Opacity, Pleural 경우의 예측 확률값이 저장되어 있다. feat_map : ONNX 모델 추론 후, 2차 후처리 이전의 정보가 저장되어 있다. heatMap : 2차 후처리 이후 생성된 이미지가 OpenCvSharp.Mat 형태로 저장되어 있다.using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace classifierPyTorch
{
class Output
{
/*
pred : Normal, Opacity, Pleural 경우의 예측 확률값이 저장되어 있다
feat_map : ONNX 모델 추론 후, 2차 후처리 이전의 정보가 저장되어 있다.
heatMap : 2차 후처리 이후 생성된 이미지가 OpenCvSharp.Mat 형태로 저장되어 있다.
*/
public float[] pred=new float[3];
public float[][] feat_map=new float[3][];
public Mat[] colorMap=new Mat[3];
}
class SRC_DK_Classifier
{
private InferenceSession inferenceSession;
public static SRC_DK_Classifier CreateFromStreamAsync(string modelFilePath)
{
SRC_DK_Classifier sRC_DK_Classifier = new SRC_DK_Classifier();
try
{
sRC_DK_Classifier.inferenceSession = new InferenceSession(modelFilePath);
System.Diagnostics.Debug.WriteLine("SRC_DK_Classifier:CreateFromStreamAsync = Complete loading model" );
}
catch(Exception e)
{
System.Diagnostics.Debug.WriteLine("SRC_DK_Classifier:CreateFromStreamAsync = "+e.Message);
}
return sRC_DK_Classifier;
}
//SixLabors.Image<Rgb24> 형태의 이미지와 형식을 입력하면 모델 추론과 2차 후처리를 통해서 결과를 반환합니다
public Output inference(Image<Rgb24> image, IImageFormat format)
{
resizeImage(image, format);
Tensor<float> input = preprocess(image);
//inference
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("modelInput", input)
};
Output output = new Output();
using (IDisposableReadOnlyCollection<DisposableNamedOnnxValue> results = inferenceSession.Run(inputs))
{
var resultsArray = results.ToArray();
output.pred[0] = resultsArray[0].AsEnumerable<float>().ToArray()[0];
output.pred[1] = resultsArray[1].AsEnumerable<float>().ToArray()[0];
output.pred[2] = resultsArray[2].AsEnumerable<float>().ToArray()[0];
output.feat_map[0] = resultsArray[3].AsEnumerable<float>().ToArray();
output.feat_map[1] = resultsArray[4].AsEnumerable<float>().ToArray();
output.feat_map[2] = resultsArray[5].AsEnumerable<float>().ToArray();
}
return postprocess(input.AsEnumerable<float>().ToArray(),output);
}
//OpenCV를 이용해서 HeatMap을 제작합니다.
private Output postprocess(float[] input,Output output)
{
byte[] byteInput = new byte[256];
for (int j = 0; j < 256; j++)
{
byteInput[j] = (byte)((int)input[j]);
}
Mat origin = new Mat(16, 16, MatType.CV_8UC1, byteInput);
for (int i = 0; i < 3; i++)
{
for(int j = 0; j < 256; j++)
{
output.feat_map[i][j] -= output.feat_map[i][j]%1;
byteInput[j] = (byte)((int)output.feat_map[i][j]);
}
Mat cam = new Mat(16,16,MatType.CV_8UC1, byteInput);
Cv2.Resize(cam, cam, new OpenCvSharp.Size(512, 512), MatType.CV_8UC1);
Cv2.ApplyColorMap(cam, cam, ColormapTypes.Jet);
output.colorMap[i] = cam;
}
return output;
}
//이미지를 512,512사이즈로 변환합니다.
private void resizeImage(Image<Rgb24> image, IImageFormat format)
{
Stream imageStream = new MemoryStream();
image.Mutate(x => x.Resize(512, 512, KnownResamplers.Triangle));
image.Save(imageStream, format);
}
//이미지를 Tensor 형태로 변환하면서 512x512사이즈로 resize 후 0 - 1 scalling을 적용합니다.
private Tensor<float> preprocess(Image<Rgb24> image)
{
Tensor<float> input = new DenseTensor<float>(new[] { 1, 3, 512, 512 });
float max=-1,min=256;
for (int y = 0; y < image.Height; y++)
{
Span<Rgb24> pixelSpan = image.GetPixelRowSpan(y);
for (int x = 0; x < image.Width; x++)
{
max = max < pixelSpan[x].R ? pixelSpan[x].R : max;
max = max < pixelSpan[x].R ? pixelSpan[x].G : max;
max = max < pixelSpan[x].R ? pixelSpan[x].B : max;
}
}
for (int y = 0; y < image.Height; y++)
{
Span<Rgb24> pixelSpan = image.GetPixelRowSpan(y);
for (int x = 0; x < image.Width; x++)
{
input[0, 0, y, x] = (pixelSpan[x].R) / (max);
input[0, 1, y, x] = (pixelSpan[x].G) / (max);
input[0, 2, y, x] = (pixelSpan[x].B) / (max);
}
}
return input;
}
}
}
모델을 추론하기 전 아래 코드를 통해서 assets 폴더에서 ONNX 모델 파일을 가져와 초기화 할 수 있습니다.
private SRC_DK_Classifier sRC_DK_Classifier;
private async Task loadModel()
{
// Get an access the ONNX model and save it in memory.
StorageFolder InstallationFolder = Windows.ApplicationModel.Package.Current.InstalledLocation;
string modelPath = @"Assets\post_plus_model.onnx";
StorageFile file = await InstallationFolder.GetFileAsync(modelPath);
if (File.Exists(file.Path))
{
sRC_DK_Classifier = SRC_DK_Classifier.CreateFromStreamAsync(file.Path);
}
else
{
System.Diagnostics.Debug.Write("Model file not found.");
}
}
FileOpenPicker를 통해서 선택된 이미지를 우선 selectedStorageFile에 저장합니다.
private StorageFile selectedStorageFile;
private async Task<bool> getImage()
{
try
{
// Trigger file picker to select an image file
FileOpenPicker fileOpenPicker = new FileOpenPicker();
fileOpenPicker.SuggestedStartLocation = PickerLocationId.PicturesLibrary;
fileOpenPicker.FileTypeFilter.Add(".jpg");
fileOpenPicker.FileTypeFilter.Add(".png");
fileOpenPicker.ViewMode = PickerViewMode.Thumbnail;
selectedStorageFile = await fileOpenPicker.PickSingleFileAsync();
if (selectedStorageFile == null)
{
return false;
}
}
catch (Exception)
{
return false;
}
return true;
}
SixLabors라이브러리를 이용해서 파일의 스트림을 통해 추론을 위한 이미지 파일을 생성 할 수 있습니다.
public Image<Rgb24> image;
public IImageFormat format;
using (IRandomAccessStream stream = await selectedStorageFile.OpenAsync(FileAccessMode.Read))
{
//load Six.Labors Image for input
image = SixLabors.ImageSharp.Image.Load<Rgb24>(stream.AsStream(), out format);
}
이전에 초기화한 sRC_DK_Classifier 인스턴스를 통해서 이전에 가져온 이미지의 결과를 추론 할 수 있습니다.
public Output inferenceOutput;
private async Task evaluate()
{
inferenceOutput=sRC_DK_Classifier.inference(image, format);
}
추론 결과의 경우 3가지 클래스에 대한 확률값, 피쳐맵 그리고 후처리를 통한 HeatMap 3가지를 얻을 수 있습니다.
class Output
{
public float[] pred=new float[3];
public float[][] feat_map=new float[3][];
public SoftwareBitmap[] heatMap=new SoftwareBitmap[3];
}
결과의 히트맵을 각 뷰의 소스로 설정하여 히트맵 결괏값을 알 수 있습니다.
SoftwareBitmapSource imageSource = new SoftwareBitmapSource();
await imageSource.SetBitmapAsync(inferenceOutput.heatMap[0]);
HeatMap1.Source = imageSource;
SoftwareBitmapSource imageSource1 = new SoftwareBitmapSource();
await imageSource1.SetBitmapAsync(inferenceOutput.heatMap[1]);
HeatMap2.Source = imageSource1;
SoftwareBitmapSource imageSource2 = new SoftwareBitmapSource();
await imageSource2.SetBitmapAsync(inferenceOutput.heatMap[2]);
HeatMap3.Source = imageSource2;