【CMake】《CMake构建实战:项目开发卷》笔记-Chapter11-实践:基于onnxruntime的手写数字识别库

项目开发 - CMake教程+实战 通过Cmake 搭建一个demo,已熟悉cmake 的相关命令。 本文需要达到的目标 构建多个文件的demo vs 工程,生成exe 界面采用Qt 依赖三方库 自定义配置一些宏 1、 cmake 介绍 CMake 是一个开源、跨平台的工具系列,旨在构建、测试和打包软件 CMake是一个跨平台的安装(编译)工具,可以用简单的语句来描述所有平台的安装。也就是项目或者工程对的搭建,例如:windows 的vs , linux 下 makefile 。要知道这些工程的搭建是非常麻烦的。 CMake 功能强大。 阅读详情

第11章 实践:基于onnxruntime的手写数字识别库

读者已经跟着本书实践了很多零零散散的实例,应该能够熟练使用CMake来构建C和C++程序了吧!不过,前面的实例往往都是针对某个特定功能编写的,我们可能很难将它们综合起来实现一个完成度较高的项目。不必担心,本章就带领大家使用C++语言实现一个完整的动态库,以及调用该库的可执行文件——手写数字识别库和手写数字识别命令行工具。相信经过本章的实践,读者一定可以将前面所学的知识融会贯通,应用于中大型项目中了!

11.1 前期设计

11.1.1 模块设计

本章不仅要实现一个手写数字识别库,还会同时编写一个recognize命令行工具,用户可以在命令行中调用该工具以识别图片中的手写数字。因此,项目中需要定义两个构建目标,分别是动态库目标num_recognizer和可执行文件目标recognize。其中,可执行文件目标recognize将链接动态库目标num_recognizer。

另外,我们希望构建的手写数字识别库是一个通用库,使其能够被C++语言之外的其他编程语言调用,如C语言等。这就要求手写数字识别动态库在暴露API时,必须仅暴露符合C语言应用程序二进制接口(Application Binary Interface,ABI)的应用程序编程接口(Application Programming Interface,API)。简言之,就是暴露的接口都只能是纯C函数,函数的参数、返回值等都必须是C语言中支持的数据类型。

在C++编程语言中若想将一个函数定义为C语言函数,将一个结构体定义为C语言结构体,需要在函数定义和结构体定义前指定extern "C"修饰符,或将其置于extern "C"代码块中。

11.1.2 项目目录结构

设计好需要的模块后,就可以开始着手建立项目的目录结构了。本项目目录结构如下:

ch011
├── CMakeLists.txt (目录程序)
├── cli (命令行工具的源文件目录)
│   └── recognize.c (命令行工具的源文件)
├── cmake (自定义CMake模块目录)
│   └── ...
├── include (头文件目录)
│   └── num_recognizer.h (手写数字识别库的头文件)
├── models (onnx模型文件目录)
│   └── mnist.onnx (手写数字识别模型文件)
└── src (手写数字识别库的源文件目录)
    └── num_recognizer.cpp (手写数字识别库的源文件)

命令行工具的源文件目录名称为cli,这是命令行接口的英文commandline interface的缩写。另外,这里用到的mnist.onnx神经网络模型文件可以在GitHub的onnx/models代码仓库中下载。

11.1.3 接口设计

在实现之前,还需要对手写数字识别库具体提供什么功能作出定义,并将接口设计出来。

作为一个实践案例,该库不会涉及过于复杂的技术。尽管如此,笔者也希望这个手写数字识别库仍然是实用的:既支持用户传入二值化的图片像素数组,也支持用户传入一个PNG图片文件的路径来进行识别。可以说是麻雀虽小,五脏俱全!

初始化

使用onnxruntime库,需要先初始化一个onnx环境(Ort::Env)供onnx会话(Ort::Session)使用。因此,手写数字识别库应当首先提供一个初始化的接口,如下所示。

//! @brief 初始化手写数字识别库
//! @return void
NUM_RECOGNIZER_EXPORT void num_recognizer_init();

接口函数最前面的NUM_RECOGNIZER_EXPORT是导出宏,后面会在CMake目录程序中使用GenerateExportHeader这一CMake模块定义它们。该CMake模块的具体用法参见9.2.3小节。

创建和析构识别器

手写数字识别模型文件也许会更新迭代,因此接口应当能够灵活地根据用户指定的模型文件来创建识别器对象,同时提供用于析构识别器对象的接口,如下所示。

//! @brief 创建识别器
//! @param model_path 模型文件路径
//! @param[out] out_recognizer 接受初始化的识别器指针的指针
NUM_RECOGNIZER_EXPORT void num_recognizer_create(const char *model_path,
                                                 Recognizer **out_recognizer);

//! @brief 析构识别器
//! @param recognizer 识别器的指针
NUM_RECOGNIZER_EXPORT void num_recognizer_delete(Recognizer *recognizer);

注意,num_recognizer_create接口的第二个参数类型是Recognizer**,即Recognizer结构体指针的指针。调用该接口后,程序会将创建好的识别器对象的指针赋值到该参数指向的变量中。

目前Recognizer类尚未定义,可以先在头文件中写一个前向声明,如下所示。

struct Recognizer;
识别二值化图片像素数组

手写数字识别库可以接受一个代表各像素颜色的数组作为被识别的图片对象。该数组是一个按行存储的28×28的float数组,即第一维索引对应列号,第二维索引对应行号。其中,元素的值若为0,则代表白色,为1则代表黑色,因此它实际上表示了一个二值化后的图片。该接口如下所示。

//! @brief 识别图片数据中的手写数字
//! @param recognizer 识别器的指针
//! @param input_image
//! 模型接受的输入图片数据(28×28的float数值数组,0代表白色,1代表黑色)
//! @param result 接受识别结果的数值的指针
//! @return 错误值,成功返回0
NUM_RECOGNIZER_EXPORT int num_recognizer_recognize(Recognizer *recognizer,
                                                  float *input_image,
                                                  int *result);
识别PNG图片

当然,只提供接受数组参数的接口并不便于用户调用。这里还提供了一个可以直接识别指定路径的PNG图片中手写数字的接口,如下所示。

//! @brief 识别PNG图片中的手写数字
//! @param recognizer 识别器的指针
//! @param png_path PNG图片文件路径
//! @param result 接受识别结果的数值的指针
//! @return 错误值,成功返回0
NUM_RECOGNIZER_EXPORT int num_recognizer_recognize_png(Recognizer *recognizer,
                                                      const char *png_path,
                                                      int *result);

至此,手写数字识别库的全部接口声明完毕。

接口功能实现思路

接口设计好后,不妨总结一下如果要实现这些接口的功能,需要有哪些具体的行为,借助哪些工具。

  • 对二值化图片数组进行手写数字识别可以借助onnxruntime库来完成。

  • 读取PNG图片像素可以借助libpng库来完成。

  • 将PNG图片像素数据转换为28×28的二值化图片数组,即图片缩放及二值化算法。该功能由我们自行实现。

看起来我们能够站在巨人的肩膀上来完成这件事,应该能简单不少!

11.2 第三方库

正式编写程序之前,首先需要安装刚刚提到的第三方库。onnxruntime库的安装已经在9.4.9节讲过,因此本节重点关注其他第三方库的安装:libpng库及libpng依赖的zlib库。那么,首先一起来安装zlib库吧!

Linux操作系统通常预装了zlib库,读者可以先尝试跳过zlib库的安装,看能否直接成功构建并安装libpng库。

11.2.1 安装zlib库

zlib库的源程序可以从它的GitHub代码仓库中获取。将代码克隆或下载到本地后,按照以下步骤构建并安装:

> cd zlib
> mkdir build
> cd build
> cmake -DCMAKE_BUILD_TYPE=Release ..
> cmake --build . --config Release
> cmake --install . # 需要管理员权限

在执行cmake --install命令安装CMake项目时,CMake会默认将其安装到系统目录中:在Windows中,默认安装目录前缀一般是C:\Program Files (x86)\zlib;在Linux中,默认安装目录前缀一般是/usr/local。如果想使用默认的安装目录,执行该命令时需要提供管理员权限。

当然,为cmake --install命令指定--prefix <安装目录>的参数,也可以自定义安装目录。不过采用这种方式,使用find_package命令查找第三方库时,通常需要手动指定用于提示安装目录的参数或变量。

11.2.2 安装libpng库

libpng库的源程序同样可以从其GitHub代码仓库中获取(本例采用v1.6.40版本)。其构建和安装步骤与构建和安装zlib库的步骤几乎完全相同:

> cd libpng
> mkdir build
> cd build
> cmake -DCMAKE_BUILD_TYPE=Release ..
> cmake --build . --config Release
> cmake --install . # 需要管理员权限

11.2.3 libpng的查找模块

CMake预置了zlib库的查找模块,不必自行实现;onnxruntime库的查找模块在9.4.9小节中已经实现过,因此本小节也不再重复,这里仅介绍如何实现libpng的查找模块。

libpng库自带了用于配置模式下的find_package命令的配置文件,但其中缺失关于头文件目录等属性的设置,因此需要对其进行二次包装,编写一个自定义查找模块。模块程序的核心部分如下所示。

# 调用libpng库自带的配置文件来查找软件包,其自带配置文件会创建两个导入库目标:
# 1. 动态库导入目标png_shared
# 2. 静态库导入目标png_static
find_package(libpng CONFIG CONFIGS libpng16.cmake)

# 若成功查找,为两个库目标补上缺失的头文件目录属性
if(libpng_FOUND)
  # 获取png动态库导入目标对应动态库文件的路径,首先尝试其IMPORTED_LOCATION属性
  get_target_property(libpng_LIBRARY png_shared IMPORTED_LOCATION)
  # 若未能获得动态库文件路径,再尝试其IMPORTED_LOCATION_RELEASE属性
  if(NOT libpng_LIBRARY)
    get_target_property(libpng_LIBRARY png_shared IMPORTED_LOCATION_RELEASE)
  endif()
  # 根据png动态库的路径,设置libpng的根目录
  set(_png_root "${libpng_LIBRARY}/../..")

  # 查找png.h头文件所在目录的路径
  find_path(libpng_INCLUDE_DIR png.h
    HINTS ${_png_root}
    PATH_SUFFIXES include)
  # 为png_shared和png_static导入库目标设置头文件目录属性
  target_include_directories(png_shared INTERFACE ${libpng_INCLUDE_DIR})
  target_include_directories(png_static INTERFACE ${libpng_INCLUDE_DIR})
endif()

include(FindPackageHandleStandardArgs)

# 检查变量是否有效以及配置文件是否成功执行
find_package_handle_standard_args(libpng 
  REQUIRED_VARS libpng_LIBRARY libpng_INCLUDE_DIR
  CONFIG_MODE)

# 若一切成功,设置结果变量
if(libpng_FOUND)
  set(libpng_INCLUDE_DIRS ${libpng_INCLUDE_DIR})
  set(libpng_LIBRARIES ${libpng_LIBRARY})
endif()

本书暂未涉及find_package命令配置模式的内容,因此没有对该查找模块的原理做更多解释,感兴趣的读者可以试着结合程序注释和官方文档自行理解。

11.3 CMake目录程序

终于完成了准备工作,可以开始手写数字识别库的部分了。首先编写好CMake目录程序,在项目根目录中创建CMakeLists.txt,并把按照惯例要写的代码先写上去,如下所示。

cmake_minimum_required(VERSION 3.20)
project(num_recognizer)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
set(CMAKE_CXX_STANDARD 11) # 设置C++标准为11

11.3.1 查找软件包

接着,查找即将用到的两个软件包,如下所示。

set(onnx_version 1.10.0) # 根据下载的版本进行设置,本例使用1.10.0版本
# 请下载onnxruntime库的压缩包,并解压至该目录中
if("$ENV{onnxruntime_ROOT}" STREQUAL "")
  if(WIN32)
      set(ENV{onnxruntime_ROOT} "${CMAKE_CURRENT_LIST_DIR}/onnxruntime-win-x64-${onnx_version}")
  elseif(APPLE)
      set(ENV{onnxruntime_ROOT} "${CMAKE_CURRENT_LIST_DIR}/onnxruntime-osx-universal2-${onnx_version}")
  else()
      set(ENV{onnxruntime_ROOT} "${CMAKE_CURRENT_LIST_DIR}/onnxruntime-linux-x64-${onnx_version}")
  endif()
endif()

find_package(onnxruntime 1.10 REQUIRED) # 指定依赖的最小版本
find_package(libpng REQUIRED)

其中的if条件只是为了设置onnxruntime_ROOT环境变量的值为onnxruntime软件包的安装目录,用于提示查找模块查找的路径。这里无须为libpng的查找模块提示查找的路径,因为我们将libpng安装到了默认的安装路径,而且libpng的查找模块能够在默认安装路径中找到它。

11.3.2 num_recognizer动态库目标

下面创建本实例的第一个构建目标:num_recognizer动态库目标。对应的CMake目录程序片段如下所示。

add_library(num_recognizer SHARED src/num_recognizer.cpp)

include(GenerateExportHeader)
generate_export_header(num_recognizer)
set_target_properties(num_recognizer PROPERTIES
  CXX_VISIBILITY_PRESET hidden
  VISIBILITY_INLINES_HIDDEN 1
)

target_include_directories(num_recognizer PUBLIC include ${CMAKE_BINARY_DIR})
target_link_libraries(num_recognizer PRIVATE onnxruntime::onnxruntime png_shared)
target_compile_definitions(num_recognizer PRIVATE ORT_NO_EXCEPTIONS num_recognizer_EXPORTS)

这里除了调用add_library命令创建动态库构建目标,还引用了GenerateExportHeader模块,并调用了它提供的generate_export_header命令来为动态库生成导出头文件。同时,设置了该动态库目标的两个属性,用于默认隐藏符号并仅导出显式指定的符号。

为了让该库能够直接引用刚刚生成的导出头文件,这里要将当前二进制目录${CMAKE_ BINARY_DIR}加入库目标的头文件搜索目录。另外,为该库定义num_recognizer_EXPORTS宏以表示当前正在构建该库而非使用该库,确保导出头文件中的宏定义正确。

这里还将include目录加入该库的头文件搜索目录,并将onnxruntime和libpng第三方库目标链接到该库目标中。由于我们封装的是符合C语言ABI的动态库,不希望程序中有异常抛出,这里还定义了ORT_NO_EXCEPTIONS宏以禁用onnxruntime库中的异常。

11.3.3 recognize可执行文件目标

配置好动态库目标后,创建recognize命令行工具的可执行文件目标。这非常简单,只需创建目标、指定源文件并链接刚刚创建好的动态库目标。CMake目录程序片段如下所示。

add_executable(recognize cli/recognize.c)
target_link_libraries(recognize PRIVATE num_recognizer)

11.4 代码实现

拖了这么久,下面就要施展真正的“魔法”了。不过这也体现了一个项目的成功,只靠代码写得漂亮还远远不够,还要依赖井井有条的项目结构和完善的基础设施。

11.4.1 全局常量和全局变量

首先,在手写数字识别库的源文件中定义一些全局的常量和变量,如下所示。

static const char *INPUT_NAMES[] = {"Input3"}; // 模型输入参数名
static const char *OUTPUT_NAMES[] = {"Plus214_Output_0"}; // 模型输出参数名
static constexpr int64_t INPUT_WIDTH = 28;  // 模型输入图片宽度
static constexpr int64_t INPUT_HEIGHT = 28; // 模型输入图片高度
static const std::array<int64_t, 4> input_shape{
    1, 1, INPUT_WIDTH, INPUT_HEIGHT}; // 输入数据的形状(各维度大小)
static const std::array<int64_t, 2> output_shape{
    1, 10}; // 输出数据的形状(各维度大小)

static Ort::Env env{nullptr};                // onnxruntime环境
static Ort::MemoryInfo memory_info{nullptr}; // onnxruntime内存信息

其中的常量都是由手写数字识别库的onnx模型的神经网络结构决定的,如果要切换到具有不同神经网络结构的模型,可能需要做出相应修改。

其中env变量即onnxruntime的环境,由于它不能在静态初始化时构造,这里暂且将它定义为未初始化的状态(即使用nullptr初始化)。它会在暴露给用户的num_recognizer_init接口函数中初始化。表示onnxruntime内存信息的memory_info变量也是同理。

11.4.2 手写数字识别类

接下来编写一个手写数字识别类Recognizer,用于封装onnxruntime会话,CMake目录程序片段如下所示。

//! @brief 手写数字识别类
struct Recognizer {
    //! @brief onnxruntime会话
    Ort::Session session;
};

每一个手写数字识别类都对应一个onnxruntime会话,每一个会话都可以加载一个onnx模型。

11.4.3 初始化接口实现

初始化接口是实现的第一个接口函数,在实现之前,先来编写一个extern "C"代码块,用于将其中的函数定义为C语言函数,如下所示。

extern "C" {
void num_recognizer_init() {
    env = Ort::Env{static_cast<const OrtThreadingOptions *>(nullptr)};
    memory_info = Ort::MemoryInfo::CreateCpu(OrtDeviceAllocator, OrtMemTypeCPU);
}

初始化接口的实现就是初始化两个与onnxruntime相关的全局变量:env和memory_info。

11.4.4 构造识别器接口实现

构造识别器接口的实现如下所示。

void num_recognizer_create(const char *model_path,
                           Recognizer **out_recognizer) {
    Ort::Session session{nullptr};
#if _WIN32
    // Windows中,onnxruntime的Session接受模型文件路径时需使用const
    // wchar_t*,即宽字符串。因此在这里做一下转换。
    wchar_t wpath[256];
    MultiByteToWideChar(CP_ACP, MB_PRECOMPOSED, model_path, -1, wpath, 256);
    session = Ort::Session(env, wpath, Ort::SessionOptions(nullptr));
#else
    session = Ort::Session(env, model_path, Ort::SessionOptions(nullptr));
#endif
    *out_recognizer = new Recognizer{std::move(session)};
}

这里的主要逻辑就是通过模型文件路径来构造onnxruntime会话,并将其赋值给在堆上创建的手写数字识别类,将这个类的指针作为结果传给用户。

这里有一处麻烦需要处理:Windows中onnxruntime会话构造时接受的模型文件路径的编码不同,需要对传入的参数进行编码转换。为了使用Windows的编码转换API,源文件最开始也做了条件编译以引用Windows.h,如下所示。

#ifdef _WIN32
// 在Windows操作系统中,我们需要使用Windows API来帮助完成const char*到const
// wchar_t*的编码转换。因此需要引用Windows.h。
#include <Windows.h>
#endif

11.4.5 析构识别器接口实现

析构很简单,直接delete即可,如下所示。

void num_recognizer_delete(Recognizer *recognizer) { delete recognizer; }

11.4.6 识别二值化图片像素数组接口实现

我们使用的神经网络模型mnist.onnx本身就是接受一个28×28的float型数组作为输入,然后分别输出结果为0到10的可能性权重,因此只需在实现识别二值化图片像素数组的接口时通过onnxruntime库运行该模型的推理过程,最后取可能性最大的数值作为预测结果。CMake目录程序片段如下所示。

int num_recognizer_recognize(Recognizer *recognizer, float *input_image,
                             int *result) {

    std::array<float, 10> results{};

    auto input_tensor = Ort::Value::CreateTensor<float>(
        memory_info, input_image, INPUT_WIDTH * INPUT_HEIGHT,
        input_shape.data(), input_shape.size());

    auto output_tensor = Ort::Value::CreateTensor<float>(
        memory_info, results.data(), results.size(), output_shape.data(),
        output_shape.size());

    recognizer->session.Run(Ort::RunOptions{nullptr}, INPUT_NAMES,
                            &input_tensor, 1, OUTPUT_NAMES, &output_tensor, 1);

    *result = static_cast<int>(std::distance(
        results.begin(), std::max_element(results.begin(), results.end())));

    return 0;
}

11.4.7 识别PNG图片接口实现

识别PNG图片的重点,就是如何将PNG图片读取并转换为28×28的二值化的图片像素数组。首先,借助libpng第三方库来完成PNG图片的读取,如下所示。

int num_recognizer_recognize_png(Recognizer *recognizer, const char *png_path,
                                 int *result) {
    int ret = 0;
    std::array<float, INPUT_WIDTH * INPUT_HEIGHT> input_image;
    FILE *fp;
    unsigned char header[8];
    png_structp png_ptr;
    png_infop info_ptr;
    png_uint_32 png_width, png_height;
    png_byte color_type;
    png_bytep *png_data;

    // 打开PNG图片文件
    fp = fopen(png_path, "rb");
    if (!fp) {
        ret = -2;
        goto exit3;
    }

    // 读取PNG图片文件头
    fread(header, 1, 8, fp);
    // 验证文件头确实是PNG格式的文件头
    if (png_sig_cmp(reinterpret_cast<unsigned char *>(header), 0, 8)) {
        ret = -3;
        goto exit2;
    }

    // 创建PNG指针数据结构
    png_ptr = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr,
                                     nullptr);
    if (!png_ptr) {
        ret = -4;
        goto exit2;
    }

    // 创建PNG信息指针数据结构
    info_ptr = png_create_info_struct(png_ptr);
    if (!info_ptr) {
        ret = -5;
        goto exit2;
    }

    // 设置跳转以处理异常
    if (setjmp(png_jmpbuf(png_ptr))) {
        ret = -6;
        goto exit2;
    }

    // 初始化PNG文件
    png_init_io(png_ptr, fp);
    png_set_sig_bytes(png_ptr, 8);

    // 读取PNG信息
    png_read_info(png_ptr, info_ptr);
    png_width = png_get_image_width(png_ptr, info_ptr);   // PNG图片宽度
    png_height = png_get_image_height(png_ptr, info_ptr); // PNG图片高度
    color_type = png_get_color_type(png_ptr, info_ptr); // PNG图片颜色类型

    // 设置跳转以处理异常
    if (setjmp(png_jmpbuf(png_ptr))) {
        ret = -7;
        goto exit2;
    }

    // 读取PNG的数据
    png_data = (png_bytep *)malloc(sizeof(png_bytep) * png_height);
    for (unsigned int y = 0; y < png_height; ++y) {
        png_data[y] = (png_byte *)malloc(png_get_rowbytes(png_ptr, info_ptr));
    }
    png_read_image(png_ptr, png_data);
    
    // 未完待续

代码执行到这就已经成功将PNG图片中每个像素的颜色值读取到了png_data这个png_bytep *类型,也就是字节指针的指针类型的变量中,它用于表示一个字节类型的二维数组。

至于这些字节是如何表示图片各个像素的颜色值的,需要根据PNG图片采用的颜色类型灵活判断:若图片采用RGB颜色类型,那么文件中每三个字节表示一个颜色值,这三个字节分别对应颜色的RGB值;若图片采用RGBA颜色类型,那么它就需要四个字节表示一个颜色值。为了方便地获取PNG图片数据中指定像素的颜色值,并将其二值化,不妨在源文件中创建一些帮助函数,如下所示。

//! @brief 将byte类型的颜色值转换为模型接受的二值化后的float类型数值
//! @param b byte类型的颜色值
//! @return 模型接受的二值化后的float类型值,0代表白色,1代表黑色。
static float byte2float(png_byte b) { return b < 128 ? 1 : 0; }

//! @brief 获取PNG图片指定像素的二值化后的float类型颜色值
//! @param x 像素横坐标
//! @param y 像素纵坐标
//! @param png_width 图片宽度
//! @param png_height 图片高度
//! @param color_type 图片颜色类型
//! @param png_data 图片数据
//! @return 对应像素的二值化后的float类型颜色值
static float get_float_color_in_png(unsigned int x, unsigned int y,
                                    png_uint_32 png_width,
                                    png_uint_32 png_height, png_byte color_type,
                                    png_bytepp png_data) {
    if (x >= png_width || x < 0)
        return 0;
    if (y >= png_height || y < 0)
        return 0;
        
    switch (color_type) {
    case PNG_COLOR_TYPE_RGB: {
        auto p = png_data[y] + x * 3;
        return byte2float((p[0] + p[1] + p[2]) / 3);
    } break;
    case PNG_COLOR_TYPE_RGBA: {
        auto p = png_data[y] + x * 4; 
    } break;
    default: 
        return 0; 
    } 
}; 

get_float_color_in_png函数仅支持了较为常见的两种PNG图片颜色类型:RGB和RGBA。感兴趣的读者可以自行扩充其支持的颜色类型。有了该函数的帮助,再回到刚才num_recognizer_recognize_png函数中尚未完成的接口实现,其后续CMake目录程序片段如下所示。

    // 将PNG图片重新采样,缩放到模型接受的输入图片大小
    for (unsigned int y = 0; y < INPUT_HEIGHT; ++y) {
        for (unsigned int x = 0; x < INPUT_WIDTH; ++x) {
            float res = 0;
            int n = 0;
            for (unsigned int png_y = y * png_height / INPUT_HEIGHT;
                 png_y < (y + 1) * png_height / INPUT_HEIGHT; ++png_y) {
                for (unsigned int png_x = x * png_width / INPUT_WIDTH;
                     png_x < (x + 1) * png_width / INPUT_WIDTH; ++png_x) {
                    res += get_float_color_in_png(png_x, png_y, png_width,
                                                  png_height, color_type,
                                                  png_data);
                    ++n;
                }
            }
            input_image[y * INPUT_HEIGHT + x] = res / n;
        }
    }

    // 识别图片数据中的手写数字
    ret = num_recognizer_recognize(recognizer, input_image.data(), result);

这里完成了对PNG图片的重采样,即缩放图片到28×28这个尺寸,并将最终满足输入要求的数据存入input_image数组。到此,如果未发生错误,程序将通过复用num_recognizer_recognize接口来完成最终的识别。

除了正常的代码路径,代码中还有一些异常处理的分支,用于分别跳转到不同的标签。这些标签对应不同的退出路径,它们的代码就在函数的末尾做一些资源清理的工作,如下所示。

exit1:
    // 释放存放PNG图片数据的内存空间
    for (unsigned int y = 0; y < png_height; ++y) {
        free(png_data[y]);
    }
    free(png_data);

exit2:
    // 关闭文件
    fclose(fp);

exit3:
    return ret;
}

不同的退出路径需要对应不同程度的清理工作。而如果程序正常退出,那么全部退出路径都会执行到,也就会对全部使用过的资源进行清理释放。

至此,全部接口实现完毕,最后不要忘记用于结束extern "C"代码块的花括号。

11.4.8 完善手写数字识别库的头文件(以同时支持C语言)

在进行接口设计时,实际上就是在编写头文件的核心部分——接口函数。不过,只是声明这些函数,并不足以构成一个完善的公开头文件。另外,手写数字库暴露的都是C语言接口,这个头文件应当能够同时被C++语言和C语言引用。下面一起来看看应当如何完善这个头文件。

首先,头文件引用卫哨必不可少,如下所示。

#ifndef NUM_RECOGNIZER_H
#define NUM_RECOGNIZER_H

其次,要引用导出头文件,这样才能使用num_recognizer_EXPORTS等宏定义,以便为动态库导出符号,如下所示。

#include "num_recognizer_export.h"

再次是声明接口相关的类和函数。由于接口函数都是C语言的函数,当采用C++编译器时需要将接口涉及的结构体和函数声明用extern "C"包括起来。这里借助__cplusplus宏来判断是否采用C++编译器,如下所示。

#ifdef __cplusplus
extern "C" {
#endif

下面开始声明涉及的类或结构体,如下所示。

#ifdef num_recognizer_EXPORTS
struct Recognizer;
#else
typedef struct _Recognizer Recognizer;
#endif

这里涉及两种情况:该头文件被实现该库的源文件(即num_recognizer.cpp)引用,以及该头文件被用户的外部程序(包括即将编写的recognize命令行工具的源文件)引用。num_recognizer_EXPORTS宏就可以用于判断当前是在构建还是使用该库。还记得吗?它是在### 11.3.2 num_recognizer动态库目标所示的目录程序中定义的。

当该宏被定义,也就意味着当前正在构建该库。此时会前向声明Recognizer结构体,以避免声明接口函数时编译器不认识这个结构体。这个结构体的具体定义会在源文件中给出。

当该宏未被定义,也就是说该库被用户使用时,这里不能仅包含一个前向声明,否则编译器会报告找不到定义的错误。我们需要将Recognizer结构体定义为一个不透明结构体(opaque structure),即没有具体定义的结构体,这种结构体仅能出现在指针类型中,正符合接口中Recognizer类的使用场景。

接下来是在头文件中声明最开始设计的接口函数,如下所示。

//! @brief 初始化手写数字识别库
//! @return void
NUM_RECOGNIZER_EXPORT void num_recognizer_init();

//! @brief 创建识别器
//! @param model_path 模型文件路径
//! @param[out] out_recognizer 接受初始化的识别器指针的指针
NUM_RECOGNIZER_EXPORT void num_recognizer_create(const char *model_path,
                                                 Recognizer **out_recognizer);

//! @brief 析构识别器
//! @param recognizer 识别器的指针
NUM_RECOGNIZER_EXPORT void num_recognizer_delete(Recognizer *recognizer);

//! @brief 识别图片数据中的手写数字
//! @param recognizer 识别器的指针
//! @param input_image
//! 模型接受的输入图片数据(28×28的float数值数组,0代表白色,1代表黑色)
//! @param result 接受识别结果的数值的指针
//! @return 错误值,成功返回0
NUM_RECOGNIZER_EXPORT int num_recognizer_recognize(Recognizer *recognizer,
                                                   float *input_image,
                                                   int *result);

//! @brief 识别PNG图片中的手写数字
//! @param recognizer 识别器的指针
//! @param png_path PNG图片文件路径
//! @param result 接受识别结果的数值的指针
//! @return 错误值,成功返回0
NUM_RECOGNIZER_EXPORT int num_recognizer_recognize_png(Recognizer *recognizer,
                                                       const char *png_path,
                                                       int *result);

最后,还要记得将前面的extern "C"代码块,以及头文件引用卫哨的#if闭合!如下所示。

#ifdef __cplusplus
} // extern "C"
#endif

#endif // NUM_RECOGNIZER_H

现在,这个头文件已经相当完善了。不论用户采用C语言还是C++语言,不论是用于构建手写数字识别库,还是分发给用户使用,它都能够胜任。

11.4.9 命令行工具的实现

手写数字识别库的代码实现已经完成,下面着手命令行工具的编写。在此之前,我们需要确定命令行工具的调用方式。

命令行接口的设计,也就是命令行的参数设计十分重要。友好的参数设计可以极大地方便用户。本例配套提供的recognize命令行工具的参数设计十分简单,只需依次接收两个参数:模型文件路径和PNG图片文件路径。调用示例如下:

recognize models/mnist.onnx 2.png

为了展现C语言接口作为编程界“通用语言”的魅力,该命令行工具将采用C语言而非C++语言编写,它会调用C++编写的手写数字识别库。这也能验证刚刚编写的手写数字识别库的头文件是否完善。

代码实现相当简单,完整的源文件如下所示。

#include <num_recognizer.h>
#include <stdio.h>

//! @brief 主函数
//!
//!   命令行参数应有3个:
//!   1. 命令行程序本身的文件名,即recognize;
//!   2. 模型文件路径;
//!   3. 将要识别的PNG图片文件路径。
//!
//!   例如:recognize models/mnist.onnx 2.png
//!
//! @param argc 命令行参数个数
//! @param argv 命令行参数值数组
//! @return 返回码,0表示正常退出
int main(int argc, const char **argv) {
    // 检查命令行参数个数是否为3个
    if (argc != 3) {
        printf("Usage: recognize mnist.onnx 3.png\n");
        return -1; // 返回错误码-1
    }

    int ret = 0;            // 返回码
    int result = -1;        // 识别结果
    Recognizer *recognizer; // 识别器指针

    num_recognizer_init(); // 初始化识别器

    // 使用模型文件创建识别器,argv[1]即模型文件路径
    num_recognizer_create(argv[1], &recognizer);

    // 识别图片文件中的手写数字,argv[2]即图片文件路径
    if (ret = num_recognizer_recognize_png(recognizer, argv[2], &result)) {
        // 返回值非0,识别过程发生错误
        printf("Failed to recognize\n");
        goto exit_main;
    }

    printf("%d\n", result); // 输出识别结果

exit_main:
    num_recognizer_delete(recognizer); // 析构识别器
    return ret;                        // 返回正常退出的返回码0
}

11.5 构建和运行

代码实现终于告一段落,是不是迫不及待地想要构建并运行它了呢?跟着下面的步骤开始构建吧!

> cd CMake-Book/src/ch011
> mkdir build
> cd build
> cmake -DCMAKE_BUILD_TYPE=Debug ..
...
> cmake --build . --config Debug
...

构建成功后,画一张手写数字的图片。然后,调用构建好的recognize命令行工具尝试识别这幅图,命令调用方式如下:

> ./build/recognize ../models/mnist.onnx ../2.png 
2

成功识别!

如果使用MSVC构建,recognize应该在Debug子目录中,即.\Debug\recognize.exe。另外,在Windows中执行recognize.exe前,还要记得复制zlib.dll、libpng16d.dll(如果采用Release构建模式,则应复制libpng16.dll)、onnxruntime.dll到recognize.exe的同一目录中。

11.6 小结

本章借助CMake组织项目结构和构建流程,引入了多个第三方库,使用C和C++语言实现了一个完整且实用的手写数字识别库项目。相信读者通过本章的实践过程,对CMake的能力有了更加深入的理解,同时也对C和C++程序从设计到实现的完整流程有了一定把握。

本书内容已近尾声,但尚未涉及的CMake相关内容其实还有很多。在项目测试、安装、打包发布等流程中,都可以有CMake发光的地方。希望本书能够作为读者学习使用CMake的一个开始,带领读者踏进C和C++主流开发实践的大门。同时也衷心祝愿读者在将来的学习和工作中,能够用好CMake这一利器,共同建设更加高效的C和C++编程社区!

Linux使用CMake构建OpenCV、onnxruntime模型部署项目 Linux使用CMake构建需要用到OpenCV、onnxruntime库的模型部署项目,可省去添加lib、include路径,编译时也不用添加动态连接库。 阅读详情

相关推荐

本地部署证件照生成平台:Gradio+ONNXRuntime+OpenCV实战指南

证件照生成是典型的轻量级计算机视觉落地场景,涉及人脸检测、几何对齐、背景分割、光照归一化与合规校验等基础图像处理流程。其技术本质是将传统依赖云端API的AI服务,重构为本地可运行、隐私可控、开箱即用的端侧应用。核心依赖包括Gradio提供零门槛交互界面,ONNXRuntime实现跨平台高效推理,OpenCV承担底层图像操作与几何校正,三者协同构成稳定、可复现、易定制的技术栈。该方案广泛适用于行政办公、校园服务、政务自助终端及金融开户等对数据隐私与批量处理有强需求的场景,真正实现‘证件照自由’。

weixin_34021089的博客 226

make -C M选项

modules:         $(MAKE) -C $(KERNELDIR) M=$(PWD) modules 这句是Makefile的规则:这里的$(MAKE)就相当于make,-C 选项的作用是指将当前工作目录转移到你所指定的位置。“M=”选项的作用是,当用户需要以某个内核为基础编译一个外部模块的话,需要在make modules 命令中加入“M=dir”,程序会自动到你所指定的d

程序狗的成长之路 1万+

六个被低估的Python幽灵库:解决真实世界脏数据与边缘AI部署难题

在Python工程实践中,标准库和主流框架常在非结构化数据清洗、轻量化AI部署、系统级可靠性等场景暴露结构性短板。dateparser通过多语言状态机与模糊词典,实现自然语言时间表达式的鲁棒解析;pix2text将PDF智能分类、自适应OCR增强与表格点云聚类封装为端到端工作流,攻克扫描件表格提取最后一公里。这类‘幽灵库’不追求通用性,而专注解决教科书不写、但产线天天遭遇的脏活——如Excel混排日期转换、树莓派语音转文字、邮件服务器可达性验证。它们的价值在于用极简接口封装高复杂度边缘case处理逻辑,成为

weixin_30240349的博客 367

make命令

代码变成可执行文件,叫做编译(compile);先编译这个,还是先编译那个(即编译的安排),叫做构建(build)。Make是最常用的构建工具,诞生于1977年,主要用于C语言的项目。但是实际上 ,任何只要某个文件有变化,就要重新构建的项目,都可以用Make构建。 1、一个简单 makefile例子 Make这个词,英语的意思是"制作"。Make命令直接用了这个意思,就是要做出某个文件。比如,要做出文件a.txt,就可以通过以下步骤实现: 1)书写规则,...

alex1801 1万+

C++部署MNIST模型实战:基于ONNX Runtime的推理引擎应用

模型部署是将训练好的深度学习模型应用于生产环境的关键环节,其核心在于实现高效、稳定的推理服务。ONNX(Open Neural Network Exchange)作为一种开放的模型格式,解决了不同框架间的互操作性问题,为跨平台部署提供了统一标准。ONNX Runtime则是一个专注于高性能推理的引擎,通过剥离训练逻辑、优化计算图,在C++等原生环境中实现了低延迟、高吞吐的模型执行。其技术价值在于平衡了易用性与性能,尤其适用于对资源敏感、要求极致响应的场景,如嵌入式设备、工业控制和服务器后端。本文以经典的手写

weixin_34348174的博客 786

纯C++实现OCR:从原理到部署的完整实践指南

OCR(光学字符识别)是计算机视觉领域的关键技术,其核心原理在于将图像中的文本区域检测并转换为可编辑的字符序列。传统方法依赖图像处理和特征提取,而现代技术则基于深度学习模型,通过卷积神经网络(CNN)提取特征,并结合循环神经网络(RNN)或CTC损失进行序列识别,以实现更高的精度和鲁棒性。这项技术的价值在于将非结构化的图像信息转化为结构化文本,极大地提升了数据处理的自动化程度。其应用场景广泛,从文档数字化、车牌识别到工业自动化和移动端应用,无处不在。本文聚焦于利用纯C++实现OCR系统,通过集成ONNX R

weixin_30660027的博客 393

Kornia sensors 传感器模块实战:可微相机模型(Pinhole、Brown-Conrady、Kannala-Brandt K3 与 Orthographic)完全指南

Kornia 的 `kornia.sensors` 子模块为空间 AI 应用提供传感器数据建模接口,当前聚焦于相机(Camera)模型,未来规划覆盖 IMU 与 GNSS 接收机。本文以 [docs/source/sensors.rst](https://pre-link.gitcode.com/i/d51057c2e8fac3bc46785b654998d726) 与 [docs/source/

gitblog_00739的博客 482

YOLO11模型加密部署实战:保护AI知识产权,防止模型泄露与盗用

在AI模型商业化部署中,模型知识产权保护是核心技术挑战之一。模型加密部署通过切断从模型文件到可执行推理之间的明文通路,确保核心算法和权重参数的安全。其技术原理通常涉及对模型文件进行加密处理,并在推理引擎中集成解密模块,实现内存中解密、数据不落地的安全加载。这一技术对于依赖算法作为核心产品的团队具有重要价值,能有效防止模型被逆向、窃取或滥用。在工业质检、安防监控、智能硬件等应用场景中,结合ONNX Runtime或TensorRT等推理引擎,可以实现高性能且安全的部署。本文以YOLO11模型为例,详细解析了从

weixin_30505225的博客 419

RapidOCR C++核心库开发指南:跨平台移植实践

OCR(Optical Character Recognition,光学字符识别)技术在文档数字化、信息提取等领域应用广泛,但跨平台部署一直是开发痛点。RapidOCR作为基于PaddleOCR与多推理引擎(OnnxRuntime/OpenVINO)的跨平台OCR库,其C++核心库为嵌入式、工业控制等高性能场景提供了关键支持。本文将从环境配置、编译流程、平台适配三方面,详解RapidOCR C++

gitblog_00840的博客 636

第9.3章 从CMake基础到自动驾驶实战:构建跨平台C/C++项目的全能指南

《CMake构建实战:项目开发卷》精要指南 本文系统梳理了许宏旭《CMake构建实战》核心内容,涵盖C++构建全流程: 基础构建:详解MSVC/GCC编译器使用、Makefile编写及静态/动态库构建方法 CMake核心:解析语法结构、变量控制、文件操作等关键命令,阐述项目全生命周期管理 实战应用:通过快速排序、手写数字识别项目演示现代CMake实践,特别针对自动驾驶领域给出多传感器融合、SLAM等场景的CMake配置方案 特色价值:本书填补了官方文档实例不足的空白,提供300+代码示例,是掌握工业级C++

行知SLAM 1344

【agent】AI 数字人构建10:FunASR 1:语音识别C++服务/客户端构建

FunASR已转由ModelScope维护,提供了C++客户端的构建教程。构建过程需要依赖OpenSSL、FFmpeg和ONNX Runtime库,通过CMake进行配置。官方指令和本地构建命令均需指定这些依赖库的路径。构建过程中出现了CMake语法警告和OpenSSL未找到的问题,导致构建失败。日志显示OpenSSL相关路径未正确配置,需检查依赖库路径设置。该问题可能与环境变量或库版本兼容性有关。

突围 735

practical-aspnetcore 实战:ASP.NET Core Minimal API 命名请求超时策略(Named Request Timeout Policy)完整指南

本文基于 practical-aspnetcore 仓库中的 `request-timeout-2` 示例,深入讲解如何在 ASP.NET Core Minimal API 中通过**命名超时策略(Named Timeout Policy)**为端点配置请求超时:当请求执行超过预设时限时,由超时中间件自动取消请求,业务代码通过 `HttpContext.RequestAborted.ThrowIf

gitblog_00032的博客 263

RapidOcr+Onnxruntime:离线OCR部署实战与踩坑复盘

OCR(光学字符识别)技术让机器从图像中提取文字,在票据识别、RPA自动化和车牌提取等场景中不可或缺。传统云OCR服务依赖网络接口,一旦断网便无法工作,且敏感数据出镜存在合规风险。而基于Onnxruntime的本地推理引擎,将模型与计算全部部署在本地,彻底实现离线运行。RapidOcr作为轻量级OCR框架,在保持PP-OCR模型精度的同时,替换为onnxruntime推理引擎,依赖更干净、跨平台性更佳,特别适合对数据隐私和部署体积有严格要求的内部系统。无论是身份证扫描、表格批量识别,还是边缘设备上的文档数字

weixin_29035405的博客 450

C++与ONNX Runtime部署YOLOv11图像分类模型实战指南

模型部署是计算机视觉应用落地的关键环节,其核心在于将训练好的深度学习模型高效、稳定地集成到生产环境中。ONNX Runtime作为一个跨平台的高性能推理引擎,通过统一的中间表示格式ONNX,实现了模型在不同硬件和框架间的无缝迁移与加速。在C++工程实践中,结合OpenCV进行图像预处理,能够为边缘计算和嵌入式设备提供低延迟、高吞吐的推理解决方案。本文聚焦于YOLOv11-CLS这一专为分类任务优化的模型,详细拆解了从环境配置、模型解析、数据预处理到ONNX Runtime C++ API调用的完整流程,并分

weixin_34248023的博客 393

CMake实战-20200708.pdf

CMake实战-20200708.pdf

售货机非现金支付开发指南:MDB协议下VPOS刷卡器的那些坑

本文深入探讨了MDB协议下非现金支付设备的开发实践,重点解析了NAYAX VPOS刷卡器的集成难点与解决方案。从RS232通信参数配置到Always idle功能实现,提供了一套完整的售货机支付系统开发指南,帮助开发者规避常见陷阱,提升设备兼容性与交易稳定性。

weixin_29258297的博客 137

Ultralytics YOLO 全任务 ONNX Runtime C++ 推理实战:零配置识别任意模型与任务

在 C++ 生产环境中部署 YOLO 模型时,最常见的痛点是:每换一个任务(检测、分割、姿态、旋转框……)就要改一套后处理代码,每换一个模型就要重新硬编码类别名和输入尺寸。本仓库提供的 [examples/cpp/ONNXRuntime](https://link.gitcode.com/i/467794b2c98c385c3562a09d3b965361) 示例给出了一个更优雅的方案——**一个

gitblog_00723的博客 353

VINS-Fusion实战:从零搭建无人机视觉惯性导航系统(附ROS配置指南)

本文提供了一份详尽的VINS-Fusion实战指南,涵盖从硬件选型、传感器标定到ROS节点配置与参数调优的全过程。重点解析了如何将VINS-Fusion视觉惯性里程计系统与PX4飞控深度集成,并实现室外GPS融合,旨在帮助开发者从零搭建稳定、高精度的无人机视觉惯性导航系统。

lll78的博客 867

LeetCode 10 正则表达式匹配:从记忆化搜索到动态规划的完整题解(doocs/leetcode)

本篇基于 doocs/leetcode 仓库中 [LeetCode 10. Regular Expression Matching 英文题解](https://link.gitcode.com/i/7ddec8143f4a42bde70d1829520e1e6a) 展开,系统讲解「带 `.` 与 `*` 的极简正则匹配」这道 Hard 题的两种经典解法:记忆化搜索与自底向上动态规划。读完后你将掌握

gitblog_01045的博客 788

Gitee自动创建PR之——评审模式

一 创建PR的常规操作 本地更新主干(一般为master分支),基于主干新建一个分支并(如Dev分支)切换; 进行新的需求开发; 将这个分支推送到远端; 打开 Gitee ,进入创建 Pull Request 界面; 选定目标分支(及我们需要合并到主干分支的分支,一般即为我们的开发分支); 填写 Title 以及 Description; 点击提交 Pull Request。 二 更快的常见PR的方式— — 评审模式 现在 Gitee.

calm_encode的博客 3005

mapbox 从入门到精通 - 目录

欢迎大家来到gis分享者的《mapbox 从入门到精通》专栏。博主列出学习大纲,如果有什么问题,欢迎留言。

我哎GIS博客 5852

InsightFace深度解析:如何用开源工具箱构建企业级人脸识别系统

InsightFace是一个基于深度学习的开源人脸分析工具箱,提供最先进的2D/3D人脸识别、检测和对齐算法实现。作为计算机视觉领域的专业解决方案,它集成了从基础人脸检测到复杂3D重建的全链路技术栈,为开发者和企业提供了一套完整、高效、可扩展的人脸分析框架。 ## 🔍 技术架构全景:从算法到应用 InsightFace的技术架构采用分层设计,确保各模块高度解耦且可独立使用: ### 基础层

gitblog_00912的博客 376

matlab 点云泊松盘采样

本文介绍了泊松盘采样算法在三维点云处理中的应用。该算法通过最小间距约束(任意两点距离≥r)和均匀分布特性,生成高质量、均匀分布的点集。文章详细阐述了参数设置(如采样半径、候选点数),并与体素栅格、随机采样等方法进行了对比。代码实现部分展示了MATLAB版本的泊松盘采样算法,包括KD树加速和候选点生成机制。实验结果表明,该方法能有效保持点云特征,适用于点云简化、曲面重建等场景。文中还提供了参数调整建议和不同应用场景下的使用指导。

点云侠的博客 538
上一篇: 【CMake】《CMake构建实战:项目开发卷》笔记-Chapter10-策略与向后兼容
下一篇: 【Code】《代码整洁之道》笔记-Chapter1-整洁代码
江湖人称菠萝包
博客等级 码龄10年 762粉丝 314原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值