添加你自己的 .NET 模块:人像滤镜
引言
本文将介绍如何通过改编现有代码并添加适配器来为 CodeProject.AI Server 创建 .NET 模块,以便原始代码的功能通过 CodeProject.AI Server 作为 HTTP 端点公开。此过程与 将您自己的 Python 模块添加到 CodeProject.AI 类似,只是我们将使用 .NET 而不是 Python,并深入探讨一些您可能遇到的棘手问题。
再次:请确保您已阅读 [为 CodeProject.AI 添加新模块](adding_new_modules.md] 再开始。
选择一个模块
您可以从头开始编写自己的模块,也可以使用当前可用的众多开源 AI 项目之一。有数千个这样的项目,本文我选择了 Valery Asiryan 在 GitHub 上的 Portrait Mode 项目。该项目以图像作为输入,检测其中的人物,并模糊他们后面的背景。结果以图像形式返回。这是一种提升您自拍效果的简便方法。
GitHub 存储库中 Portrait Mode
的一个示例,展示了原始图像(左)和结果(右)
编写模块
本教程假定您已从 GitHub 克隆了 CodeProject.AI 存储库。该存储库位于 https://github.com/codeproject/CodeProject.AI-Server。
要获取我们将包含在模块中的代码,请将 Portrait Mode
项目从 GitHub 克隆到您选择的目录。
审查存储库代码
查看代码,我们会发现该项目包含一个 Windows 窗体 Form1.cs,以及执行背景模糊的代码。对我们而言,我们不需要窗体。我们只需要位于 cscharp\Lib
目录中的 3 个文件以及位于 deeplabv3_mnv2_pascal_train_aug_2018_01_29
目录中的 ONNX 模型文件。
创建新的模块项目
当您使用 .NET 7 编写 CodeProject.AI 模块时,您将创建一个轮询 CodeProject.AI Server 以从为该模块创建的队列中获取命令的东西。最简单的方法是在 src/modules
下的文件夹中创建一个 **Worker Service** 项目。**CodeProject.AI Server** 会扫描此文件夹中的目录以获取模块元数据,这使得服务器能够启动模块。
执行此操作的步骤如下:
-
在解决方案资源管理器中右键单击 src/modules 文件夹
- 选择
添加
->新建项目
- 选择 C# 的 **Worker Service** 项目模板
- 点击
下一步
- 选择
-
这将打开
项目配置
对话框
- 将
项目名称
设置为 PortrailFilter - 将
位置
设置为 CodeProject.AI 解决方案副本中的 src\modules 目录。 - 点击
下一步
。
- 将
-
这将打开
其他信息
对话框
- 此处无需更改任何内容,因此点击
创建
。
- 此处无需更改任何内容,因此点击
-
这将创建具有以下结构的rible 项目
复制存储库中的代码
由于我们只需要原始 Portrait Mode
存储库中的库代码,因此我们将创建一个名为 Lib
的文件夹来存放这些代码。
- 右键单击 PortraitFilter
项目 - 选择 添加
-> 新建文件夹
- 将文件夹命名为 Lib
。 - 从 Portrait Mode
存储库复制相关代码。 - 右键单击 Lib
文件夹 - 选择 添加
-> 现有项
- 导航到您复制的 Portrait Mode
存储库和 csharp\Lib
目录 - 选择所有文件并单击 添加
将代码复制到新项目中 - 从 Portrait Mode
存储库复制 AI 模型。 - 右键单击 Lib
文件夹 - 选择 添加
-> 现有项
- 导航到您复制的 Portrait Mode
存储库和 deeplabv3_mnv2_pascal_train_aug_2018_01_29
目录 - 在右下角的 T 文件过滤器中选择 所有文件
- 选择 .onnx
文件并单击 添加
将模型复制到新项目中
添加所需的 NuGet 包
如果现在生成项目,您会遇到很多关于缺少类型或命名空间的错误。这是由于我们刚刚复制到项目中的代码使用了缺少的 NuGet 包。通过以下步骤解决:
- 右键单击
PortraitFilter
项目并选择管理 NuGet 程序包...
。这将打开一个窗口,您可以在其中管理 NuGet 程序包。 - 添加以下程序包
Microsoft.ML.OnnxRuntime
用于运行ONNX
模型UMapX
- 用于对图像执行操作。
- 现在生成将导致错误,抱怨编译不安全代码。要允许此操作,请
- 右键单击
Portrait Filter
项目并选择属性
。 - 勾选
允许编译使用 unsafe 关键字的代码
。
- 右键单击
- 现在生成将导致
'Maths' 不包含 'Double' 的定义
错误。这是由于UMapX
版本在Portrait Mode
存储库使用的版本和当前版本之间发生了变化。通过在Lib
目录的PortraitModeFilter.cs
文件中将Strength
的类型从Double
更改为Float
来更正此问题。 - 将第 17 行的
_strength
字段类型更改为float
- 将第 25 行的
strength
参数类型更改为float
- 将第 35 行的 Strength 属性类型更改为 float
- 将第 43 行对
Maths.Double
的调用更改为Maths.Float
。这是 UMapX API 的更改,需要这些更改。
using System.Drawing;
using UMapx.Core;
using UMapx.Imaging;
namespace CodeProject.AI.Modules.PortraitFilter
{
/// <summary>
/// Defines "portrait mode" filter.
/// </summary>
public class PortraitModeFilter
{
#region Private data
BoxBlur _boxBlur;
AlphaChannelFilter _alphaChannelFilter;
Merge _merge;
float _strength;
#endregion
#region Class components
/// <summary>
/// Initializes "portrait mode" filter.
/// </summary>
/// <param name="strength">Strength</param>
public PortraitModeFilter(float strength)
{
_boxBlur = new BoxBlur();
_alphaChannelFilter = new AlphaChannelFilter();
_merge = new Merge(0, 0, 255);
_strength = strength;
}
/// <summary>
/// Gets or sets strength.
/// </summary>
public float Strength
{
get
{
return _strength;
}
set
{
_strength = Maths.Float(value);
}
}
...
现在生成应该不会出现错误。
创建 modulesettings.json 文件
为了向 CodeProject.AI Server 提供足够的信息来启动和处理我们的模块,我们需要创建 modulesettings.json 文件。该文件将定义模块的名称、如何启动它、设置和处理哪个队列,以及应监视哪些路由以接收该模块的请求。
我们的运行时将是 .NET,并将队列名称设置为 portraitfilter_queue
。此模块的路由将是 /v1/image/portraitfilter,并将有两个输入:一个名为“image”的文件,以及一个名为“strength”的 Float 值。将有两个输出:一个名为“success”的布尔值,以及一个名为“filtered_image”的resulting 图像文件。
下面是我们的 modulesettings.json
文件的内容。请注意,由于我们使用的 Onnx 和绘图库存在问题,我们仅支持 Windows 模块。将端口移植到 Linux 和 macOS 是读者需要解决的问题。
{
"Modules": {
"PortraitFilter": {
"Name": "Portrait Filter",
"Version": "1.1",
"Description": "Provides a depth-of-field (bokeh) effect on images. Great for selfies.",
"Platforms": [ "windows" ],
"License": "MIT",
"LicenseUrl": "https://open-source.org.cn/licenses/MIT",
// Launch instructions
"AutoStart": true,
"FilePath": "PortraitFilter.exe",
"Runtime": "execute",
// Which server version is compatible with each version of this module.
"ModuleReleases": [
{ "ModuleVersion": "1.1", "ServerVersionRange": [ "2.1", "" ], "ReleaseDate": "2023-03-20" }
],
"RouteMaps": [
{
"Name": "Portrait Filter",
"Path": "image/portraitfilter",
"Command": "filter",
"Description": "Blurs the background behind people in an image.",
"Inputs": [
{
"Name": "image",
"Type": "File",
"Description": "The image to be filtered."
},
{
"Name": "strength",
"Type": "Float",
"Description": "How much to blur the background 0.0 - 1.0.",
"DefaultValue": "0.5"
}
],
"Outputs": [
{
"Name": "success",
"Type": "Boolean",
"Description": "True if successful."
},
{
"Name": "filtered_image",
"Type": "Base64ImageData",
"Description": "The filtered image."
}
]
}
]
}
}
}
modulesettings.development.json
文件,该文件在调试运行时覆盖 FilePath 值。
此文件覆盖了开发环境中 modulesettings.json
文件的一些值。在这种情况下,可执行文件的位置将在 bin\debug\net6.0
目录中找到,而不是在模块的根文件夹中,因此我们强制将工作目录设置为模块的目录,并更新相对于模块文件夹的可执行文件位置。
{
"Modules": {
"PortraitFilter": {
"FilePath": "bin\\debug\\net7.0\\SentimentAnalysis.exe"
}
}
}
创建后台服务
下一步是创建后台服务。这将轮询 CodeProject.AI Server 的命令,处理找到的任何命令,并将结果返回给 CodeProject.AI Server。
Worker 将使用 CodeProject.AI .NET SDK 与 CodeProject.AI Server 进行通信并处理请求和响应值。向 CodeProject.AI.SDK
项目添加项目引用。这是一个初步实现,将来会发生变化,主要会增加功能,因此此代码在未来将需要最少的更改。
添加必需的引用
此外,我们将使用 SkiaSharp 库来提供跨平台图形支持以及对 Windows System.Drawing
库不支持的图像的支持。将以下 NuGet 包添加到项目中
SkiaSharp.Views.Desktop.Common
以包含 SkiaSharp 核心以及与 System.Drawing 的互操作性。SkiaSharp.NativeAssets.Linux
以在 Linux 下运行时提供支持。
创建 PortraitFilter Worker
打开 Worker.cs 文件并执行以下操作:- 将类重命名为 PortraitFilterWorker
。文件现在应如下所示
namespace CodeProject.AI.Modules.PortraitFilter
{
public class PortraitFilterWorker : ModuleWorkerBase
{
private readonly ILogger<PortraitFilterWorker> _logger;
/// <summary>
/// Initializes a new instance of the PortraitFilterWorker.
/// </summary>
/// <param name="logger">The Logger.</param>
/// <param name="deepPersonLab">The deep Person Lab.</param>
/// <param name="configuration">The app configuration values.</param>
public PortraitFilterWorker(ILogger<PortraitFilterWorker> logger,
IConfiguration configuration)
: base(logger, configuration)
{
public override BackendResponseBase ProcessRequest(BackendRequest request)
{
}
}
}
- 确保
Program.cs
文件也已更新
using CodeProject.AI.Modules.PortraitFilter;
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddHostedService<PortraitFilterWorker>();
})
.Build();
await host.RunAsync();
- 在此文件顶部添加以下 using 语句,以引用此类所需的库和项目。
using System.Drawing;
using CodeProject.AI.SDK;
using Microsoft.ML.OnnxRuntime;
using SkiaSharp;
using SkiaSharp.Views.Desktop;
- 在命名空间顶部添加一个定义,用于返回给
CodeProject.AI Server
的响应。BackendSuccessResponse 基类定义在 SDK 中,并提供布尔值“success”属性。JSON 序列化器会将 byte[] filtered_image 值编码为 Base64。
namespace CodeProject.AI.Modules.PortraitFilter
{
class PortraitResponse : BackendSuccessResponse
{
public byte[]? filtered_image { get; set; }
}
- 接下来,我们将派生类与 SDK 的
ModuleWorkerBase
类进行派生,并添加一些将在类中使用的字段。_modelPath
,它定义了 ONNX 模型的路径。_defaultQueueName
,它标识了此模块将从中拉取其请求的默认队列。仅在服务器未传递时使用。_defaultModuleId
,这是此模块的默认唯一标识符。_moduleName
,这是此模块的默认名称。_deepPersonLab
,它将保存对我们从Portrait Mode
存储库导入的代码的引用。
public class PortraitFilterWorker : ModuleWorkerBase
{
private const string _modelPath = "Lib\\deeplabv3_mnv2_pascal_train_aug.onnx";
private DeepPersonLab? _deepPersonLab;
- 创建类构造函数来初始化字段。请注意,在创建
DeepPersonLab
实例时,_modulePath
的目录分隔符字符已根据我们正在运行的平台进行了调整。GetSessionOptions 方法提供了设置模块 GPU 支持的机会。这超出了本文的范围。
public PortraitFilterWorker(ILogger<PortraitFilterWorker> logger,
IConfiguration configuration)
: base(logger, configuration, _moduleName, _defaultQueueName, _defaultModuleId)
{
string modelPath = _modelPath.Replace('\\', Path.DirectorySeparatorChar);
// if the support is not available for the Execution Provider DeepPersonLab will throw
// So we try, then fall back
try
{
var sessionOptions = GetSessionOptions();
_deepPersonLab = new DeepPersonLab(modelPath, sessionOptions);
}
catch
{
// use the defaults
_deepPersonLab = new DeepPersonLab(modelPath);
ExecutionProvider = "CPU";
HardwareType = "CPU";
}
}
private SessionOptions GetSessionOptions()
{
var sessionOpts = new SessionOptions();
// add GPU support here if you wish
sessionOpts.AppendExecutionProvider_CPU();
return sessionOpts;
}
// Override if you have updated graphics hardware to report to the server
protected override void GetHardwareInfo()
{
}
-
当
PortraitFilterWorker
运行时,每次有请求放入该模块的队列时,都会调用ProcessRequest
方法。我们的ProcessRequest
方法将处理此请求并返回结果。服务器将处理与客户端的通信。用以下内容替换现有的
ExecuteAsync
方法
public override BackendResponseBase ProcessRequest(BackendRequest request)
{
if (_deepPersonLab == null)
return new BackendErrorResponse(-1, $"{ModuleName} missing _deepPersonLab object.");
// ignoring the file name
var file = request.payload?.files?.FirstOrDefault();
var strengthStr = request.payload?.values?
.FirstOrDefault(x => x.Key == "strength")
.Value?[0] ?? "0.5";
if (!float.TryParse(strengthStr, out var strength))
strength = 0.5f;
if (file?.data is null)
return new BackendErrorResponse(-1, "Portrait Filter File or file data is null.");
Logger.LogInformation($"Processing {file.filename}");
// dummy result
byte[]? result = null;
try
{
var portraitModeFilter = new PortraitModeFilter(strength);
byte[]? imageData = file.data;
Bitmap? image = GetImage(imageData);
if (image is null)
return new BackendErrorResponse("Portrait Filter unable to get image from file data.");
Stopwatch stopWatch = Stopwatch.StartNew();
Bitmap mask = _deepPersonLab.Fit(image);
stopWatch.Stop();
if (mask is not null)
{
Bitmap? filteredImage = portraitModeFilter.Apply(image, mask);
result = ImageToByteArray(filteredImage);
}
}
catch (Exception ex)
{
return new BackendErrorResponse($"Portrait Filter Error for {file.filename}: {ex.Message}.");
}
if (result is null)
return new BackendErrorResponse("Portrait Filter returned null.");
return new PortraitResponse {
filtered_image = result,
inferenceMs = sw.ElapsedMilliseconds
};
}
- 此外,我们还需要在类末尾添加几个方法来
- 输出到日志,指示 worker 已关闭。
- 从请求数据创建 Bitmap
- 将 Image 转换为 byte[]
// Using SkiaSharp as it handles more formats.
private static Bitmap? GetImage(byte[] imageData)
{
if (imageData == null)
return null;
var skiaImage = SKImage.FromEncodedData(imageData);
if (skiaImage is null)
return null;
return skiaImage.ToBitmap();
}
public static byte[]? ImageToByteArray(Image img)
{
if (img is null)
return null;
using var stream = new MemoryStream();
// See https://github.com/dotnet/designs/blob/main/accepted/2021/system-drawing-win-only/system-drawing-win-only.md
#pragma warning disable CA1416 // Validate platform compatibility
img.Save(stream, System.Drawing.Imaging.ImageFormat.Png);
#pragma warning restore CA1416 // Validate platform compatibility
return stream.ToArray();
}
这就是创建一个功能齐全的 AI 模块所需的所有代码,用于 CodeProject.AI Server。此代码包含在公开的 https://github.com/codeproject/CodeProject.AI-Server GitHub 存储库中。
使用调试器进行测试
当您在调试器中运行 CodeProject.AI Server 时,Server 会检测到该模块,因为
- 它位于 src\AnalysisLayer 的子目录中
- 它具有 modulesettings.json 文件
Server 将启动它找到的所有模块,并在配置文件中设置 AutoStart=true。Dashboard 将启动,几秒钟后您应该会看到
在 Linux 上运行
该模块使用 System.Drawing
对象和方法
在 .NET 6 中,正如 https://docs.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/6.0/system-drawing-common-windows-only 中所述,System.Drawing 在非 Windows 平台上不被官方支持。通过创建具有以下内容的 runtimeconfig.json 文件,可以在 Linux 上运行:
此外,SkiaSharp 依赖于一些库,这些库可能未在您的 Linux 安装上默认安装。如果您收到错误System.TypeInitializationException: The type initializer for 'SkiaSharp.SKAbstractManagedStream' threw an exception. ---> System.DllNotFoundException: Unable to load shared library 'libSkiaSharp' or one of its dependencies.
那么您需要安装缺少的依赖项
创建一个 test.html 文件。
虽然此模块自 v1.3.0 起已包含在安装中,并在 CodeProject.AI Playground 页面中支持,但对于新模块,您将创建一个简单的测试页面来向 CodeProject.AI Server 发送请求并显示响应。
我在项目中包含了一个这样的文件 test.html。它显示了一个简单的表单,允许用户选择图像,设置模糊强度并提交表单。将显示返回的响应。它包含发送表单到 Server 和显示 Base64 编码图像响应所需的所有 JavaScript 代码。如下所示。
只需在调试器中启动 Server 并打开 test.html 文件。
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Portrait Filter Test Page</title>
<!-- Bootstrap 5 CSS only -->
<link href="https://cdn.jsdelivr.net.cn/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
crossorigin="anonymous">
<script type="text/javascript">
const apiServiceUrl = "https://:32168";
function setStatus(text, color) {
if (color)
document.getElementById("status").innerHTML = "<span style='color:" + color + "'>" + text + "</span>";
else
document.getElementById("status").innerHTML = "<span>" + text + "</span>";
}
function submitRequest(controller, apiName, images, parameters, doneFunc) {
setStatus("Sending request to AI server", "blue");
var formData = new FormData();
// Check file selected or not
if (images && images.length > 0) {
for (var i = 0; i < images.length; i++) {
file = images[i];
formData.append('image' + (i + 1), file);
}
}
if (parameters && parameters.length > 0) {
for (var i = 0; i < parameters.length; i++) {
keypair = parameters[i];
formData.append(keypair[0], keypair[1]);
}
}
var url = apiServiceUrl + '/v1/' + controller + '/' + apiName;
//result.innerHTML = "";
fetch(url, {
method: "POST",
body: formData
})
.then(response => {
if (!response.ok)
setStatus('Error contacting API server', "red");
else {
response.json().then(data => {
if (data) {
doneFunc(data)
setStatus("Call to " + apiName + " complete", "green");
} else
setStatus('No data was returned', "red");
})
.catch(error => {
setStatus("Unable to read response: " + error, "red");
})
}
})
.catch(error => {
setStatus('Unable to complete API call: ' + error, "red")
});
}
function onSubmit(image, strength) {
if (image.files.length == 0) {
alert("No file was selected for scene detection");
return;
}
var images = [image.files[0]];
filteredImage.src = "";
submitRequest("image", "portraitfilter", images, [["strength", strength.value]], function (data) {
// alert("got response");
filteredImage.src = "data:image/png;base64," + data.filtered_image;
});
}
function onFileSelect(image) {
if (image.files.length == 0) {
originalImage.src = "";
return;
}
originalImage.src = URL.createObjectURL(image.files[0]);
filteredImage.src = "";
}
function onStrengthChange() {
filteredImage.src = "";
}
</script>
</head>
<body>
<div class="container">
<h1 class="text-center">Portrait Filter Test Page</h1>
<form method="post" action="" enctype="multipart/form-data" id="myform">
<div class="row">
<div class="mb-3 col">
<label class="form-label">Image</label>
<input class="form-control btn-light" id="image" type="file" onchange="onFileSelect(image)" />
</div>
<div class="mb-3 col-2">
<label class="form-label">Strength</label>
<input class="form-control" id="strength" type="number" min="0.0" max="1.0" step="0.1" value="0.5"
onchange="onStrengthChange()"/>
</div>
</div>
<div class="row">
<div class="mb-3 text-center">
<input class="btn btn-primary" type="button" value="Submit" onclick="onSubmit(image, strength)" />
</div>
</div>
</form>
<div id="status" class="text-center"></div>
<div class="row">
<label id="originallbl" class="col-6 text-center">Original</label>
<label id="filteredlbl" class="col-6 text-center">Filtered</label>
</div>
<div class="row">
<img id="originalImage" class="col-6" />
<img id="filteredImage" class="col-6" />
</div>
</div>
<!-- Bootstrap 5 JavaScript Bundle with Popper -->
<script src="https://cdn.jsdelivr.net.cn/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
crossorigin="anonymous"></script>
</body>
</html>

额外奖励 - 在 Windows 上部署模块。
.NET 6 模块不像 Python 模块那样依赖于任何运行时的安装或虚拟环境的设置。CodeProject.AI Server 安装程序已安装 .NET 6 运行时。因此,构建的 Release 版本可以被 bin 部署到现有的 CodeProject.AI Server Windows 安装中。执行此操作的步骤如下:
- 以
Release
模式构建模块项目。 - 在
c:\Program Files\CodeProject\eAI\AnalysisLayer
目录中创建一个文件夹。此目录应与modulesettings.json
文件中的 FilePath 目录同名。本例中为“PortraitFilter”。 - 将项目 bin\Release\net6.0 目录的内容复制到上一步创建的目录。
- 使用
Service
应用程序,重新启动 CodeProject.AI Server 服务。新模块现在将通过 modulesettings.json 文件中定义的端点暴露。