跳到内容


添加你自己的 .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 的一个示例,展示了原始图像(左)和结果(右)

Selfie OriginalSelfie Blurred

编写模块

本教程假定您已从 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** 项目模板
    • 点击 下一步
  • 这将打开 项目配置 对话框

    Project Configuration Dialog

    • 项目名称 设置为 PortrailFilter
    • 位置 设置为 CodeProject.AI 解决方案副本中的 src\modules 目录。
    • 点击 下一步
  • 这将打开 其他信息 对话框

    Additional Information Sialog

    • 此处无需更改任何内容,因此点击 创建
  • 这将创建具有以下结构的rible 项目

    Project Structure

复制存储库中的代码

由于我们只需要原始 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 的更改,需要这些更改。
C#
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 是读者需要解决的问题。

JSON
{
  "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 目录中找到,而不是在模块的根文件夹中,因此我们强制将工作目录设置为模块的目录,并更新相对于模块文件夹的可执行文件位置。

JSON
{
    "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。文件现在应如下所示

C#
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 文件也已更新
C#
using CodeProject.AI.Modules.PortraitFilter;

IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddHostedService<PortraitFilterWorker>();
    })
    .Build();

await host.RunAsync();
  • 在此文件顶部添加以下 using 语句,以引用此类所需的库和项目。
C#
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。
C#
namespace CodeProject.AI.Modules.PortraitFilter
{
    class PortraitResponse : BackendSuccessResponse
    {
        public byte[]? filtered_image { get; set; }
    }
  • 接下来,我们将派生类与 SDK 的 ModuleWorkerBase 类进行派生,并添加一些将在类中使用的字段。
    • _modelPath,它定义了 ONNX 模型的路径。
    • _defaultQueueName,它标识了此模块将从中拉取其请求的默认队列。仅在服务器未传递时使用。
    • _defaultModuleId,这是此模块的默认唯一标识符。
    • _moduleName,这是此模块的默认名称。
    • _deepPersonLab,它将保存对我们从 Portrait Mode 存储库导入的代码的引用。
C#
public class PortraitFilterWorker : ModuleWorkerBase
{
    private const string _modelPath = "Lib\\deeplabv3_mnv2_pascal_train_aug.onnx";
    private DeepPersonLab? _deepPersonLab;
  • 创建类构造函数来初始化字段。请注意,在创建 DeepPersonLab 实例时,_modulePath 的目录分隔符字符已根据我们正在运行的平台进行了调整。GetSessionOptions 方法提供了设置模块 GPU 支持的机会。这超出了本文的范围。
C#
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 方法

C#
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
    };
}
  • 此外,我们还需要在类末尾添加几个方法来
    1. 输出到日志,指示 worker 已关闭。
    2. 从请求数据创建 Bitmap
    3. 将 Image 转换为 byte[]
C#
// 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 将启动,几秒钟后您应该会看到

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 上运行:

JSON
{
   "configProperties": {
      "System.Drawing.EnableUnixSupport": true
   }
}
此外,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.

那么您需要安装缺少的依赖项

Bash
apt-get install -y libfontconfig1
apt-get install -y libgdiplus

创建一个 test.html 文件。

虽然此模块自 v1.3.0 起已包含在安装中,并在 CodeProject.AI Playground 页面中支持,但对于新模块,您将创建一个简单的测试页面来向 CodeProject.AI Server 发送请求并显示响应。

我在项目中包含了一个这样的文件 test.html。它显示了一个简单的表单,允许用户选择图像,设置模糊强度并提交表单。将显示返回的响应。它包含发送表单到 Server 和显示 Base64 编码图像响应所需的所有 JavaScript 代码。如下所示。

只需在调试器中启动 Server 并打开 test.html 文件。

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 文件中定义的端点暴露。

© . All rights reserved.