第一步——项目骨架与构建系统
在我们动手写任何图像处理代码之前,得先把项目的骨架搭好。我见过太多人上来就写算法,写完发现连个像样的构建脚本都没有,最后只能手动拼 g++ 命令——这在小 demo 里当然没问题,但一旦你要上 CI、要交叉编译、要管理第三方依赖,就会非常痛苦。所以我们这一章的目标很明确:搭建一个完整的 CMake 构建系统,让它成功编译并运行第一个测试。这个测试本身什么都不做,它唯一的意义就是证明你的头文件搜索路径是对的、依赖链接是对的、工具链配置是对的——但恰恰是这些"无聊"的基础设施,决定了后续开发是顺畅还是一步一个坑。
目录结构:先规划好再动手
我们先把目录结构定下来,后面的文件都知道该往哪里放。edgecv 的根目录长这样:
edgecv/
├── CMakeLists.txt # 根构建文件
├── cmake/ # CMake 辅助脚本(OpenCV/Qt 封装)
├── include/cvw/ # 核心 header-only 头文件
├── src/ # 编译型源文件(嵌入式模块等)
├── test/ # 单元测试
├── examples/ # 示例程序
├── third_party/ # 子模块(OpenCV 源码等)
├── .gitignore
└── .clang-format这个结构的关键决策是 include/cvw/ 作为公开头文件目录——因为 edgecv 核心是 header-only 的,用户只需要这个目录里的 .hpp 文件。cmake/ 目录用来放 OpenCV 和 Qt 的查找/封装脚本,它们把第三方依赖包装成统一的 cvw::opencv 和 cvw::qt 别名目标,这样主 CMakeLists.txt 就不需要关心底层用的是哪种 OpenCV。third_party/ 用来放 git 子模块,目前只有一个 OpenCV。
根 CMakeLists.txt:从一个最小的骨架开始
我们先把根 CMakeLists.txt 搭起来。它需要声明项目、设置 C++20 标准、定义构建选项、引入依赖、创建库目标。让我们一步步来看。
首先是项目声明和标准设置:
cmake_minimum_required(VERSION 3.22)
project(edgecv VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)这几行没什么花哨的。CMAKE_EXPORT_COMPILE_COMMANDS ON 是给 clangd 用的——如果你用 VSCode + clangd 开发,这个选项会生成 compile_commands.json,让代码补全和跳转正常工作。CXX_EXTENSIONS OFF 禁用 GNU 扩展(__gnu_cxx 那些),确保我们写的是标准 C++20 而不是什么奇奇怪怪的方言。
接下来是构建选项,我们用 option() 定义了一组开关:
option(CVW_BUILD_TESTS "Enable Testing" ON)
option(CVW_BUILD_EXAMPLES "Enable Example" ON)
option(CVW_WITH_QT "Setup Qt Adapters" ON)
option(CVW_NO_EXCEPTIONS "Disabled Exception" OFF)
option(CVW_NO_RTTI "Disabled RTTI" OFF)
option(CVW_BUILD_OPENCV "Build OpenCV from submodule" OFF)你会发现这些选项的命名都以 CVW_ 为前缀——这是为了避免和 OpenCV、Qt 自己的 CMake 变量冲突,算是构建系统里一个基本但容易被忽略的卫生习惯。默认开启的有测试和示例构建,默认关闭的有从子模块编译 OpenCV(因为这个操作很重)、禁用异常和 RTTI(这些是给嵌入式场景准备的)。CVW_WITH_QT 默认开启,但 Qt 找不到时不会报错而是静默禁用——后面讲到 CvwQt.cmake 的时候你会看到怎么实现的。
选项声明完了之后就是引入依赖和创建核心库目标:
# ── Third-party dependencies ──────────────────────────────────
include(cmake/CvwOpenCV.cmake)
# ── Optional Qt dependency (Sprint 1) ────────────────────────
if(CVW_WITH_QT)
include(cmake/CvwQt.cmake)
endif()
add_library(edgecv INTERFACE)
target_include_directories(edgecv INTERFACE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
target_compile_features(edgecv INTERFACE cxx_std_20)
target_link_libraries(edgecv INTERFACE cvw::opencv)这里有几个值得展开说的点。add_library(edgecv INTERFACE) 创建了一个 INTERFACE 库——这类库没有源文件、不产生任何编译产物,它只携带编译要求(头文件路径、编译选项、链接依赖)。所有依赖 edgecv 的目标会自动继承这些要求。这正适合我们的 header-only 设计:用户只要 target_link_libraries(他的目标 PRIVATE edgecv) 就能拿到头文件路径、C++20 标准要求和 OpenCV 链接,不需要任何额外配置。
$<BUILD_INTERFACE:...> 和 $<INSTALL_INTERFACE:...> 是 CMake 的生成器表达式,区分了"从源码树构建"和"安装后使用"两种场景的头文件路径。目前我们主要关注 BUILD 阶段就好。
如果你的项目需要 Qt 并且系统里装了 Qt,edgecv 会额外链接 cvw::qt:
if(CVW_WITH_QT AND TARGET cvw::qt)
target_link_libraries(edgecv INTERFACE cvw::qt)
endif()这里用 TARGET cvw::qt 做了一个条件检查——因为 Qt 是可选的,CvwQt.cmake 在找不到 Qt 时不会创建这个目标,所以这里用 TARGET 判断比检查变量更可靠。这种"目标存在则链接"的模式在 CMake 里非常实用,建议你记住它。
紧接着是嵌入式模块的条件编译和编译选项的传递:
# ── Embedded module (Linux-only, compiled) ────────────────────
if(UNIX)
add_library(edgecv_embedded STATIC)
# ...(省略部分)
endif()
# ── Embedded compatibility ──────────────────────────────────
if(CVW_NO_EXCEPTIONS)
target_compile_options(edgecv INTERFACE -fno-exceptions)
target_compile_definitions(edgecv INTERFACE CVW_NO_EXCEPTIONS)
endif()
if(CVW_NO_RTTI)
target_compile_options(edgecv INTERFACE -fno-rtti)
target_compile_definitions(edgecv INTERFACE CVW_NO_RTTI)
endif()因为 edgecv 核心是 header-only 的,所以 -fno-exceptions 和 -fno-rtti 通过 INTERFACE 属性传递给所有依赖方——你只要链接了 edgecv,你的代码就会自动带上这些编译选项。嵌入式模块是个例外,它有真正的 .cpp 文件,所以我们单独创建了 edgecv_embedded 静态库,目前仅在 Linux 下编译。
最后是测试和示例的引入,非常直观:
if(CVW_BUILD_TESTS)
enable_testing()
add_subdirectory(test)
endif()
if(CVW_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()用 enable_testing() 开启 CTest 支持,然后 add_subdirectory 把子目录的构建逻辑引进来。开关控制在根目录的 option() 里,用户可以随时用 -DCVW_BUILD_TESTS=OFF 来关掉。
很好,根 CMakeLists.txt 到这里就完整了。接下来我们深入看三个关键的 cmake 辅助脚本。
cmake/CvwOpenCV.cmake:三种模式封装 OpenCV
OpenCV 的依赖管理是整个构建系统里最复杂的部分,因为我们要支持三种完全不同的使用场景,而这个脚本就是处理所有这些差异的地方。它的三种模式按照优先级从高到低排列:子模块源码编译、交叉编译指定路径、系统自动查找。
先看第一种模式——从 submodule 编译 OpenCV。这在你需要精确控制 OpenCV 版本或做交叉编译但没有预编译 OpenCV 的时候很有用。当你打开 -DCVW_BUILD_OPENCV=ON 时,CMake 会从 third_party/opencv 目录拉取 OpenCV 源码,以最小配置编译出只包含 core、imgproc、imgcodecs 三个模块的静态库。脚本里那一长串 set(... CACHE BOOL "" FORCE) 全部都是关闭不需要的功能——FFmpeg、GTK、CUDA、Python 绑定、测试、文档等等。这一步编译很慢(即使是 minimal build 也要好几分钟),所以默认关闭。
第二种模式用于交叉编译场景。当你设置了 CMAKE_CROSSCOMPILING 并且提供了 -DCVW_OPENCV_ROOT=/path/to/opencv,CMake 会从指定路径去找 OpenCV 的 CMake 配置文件。这种模式下你通常已经在别处为目标平台编译好了 OpenCV,这里只是告诉 CMake 去哪里找。
第三种模式是默认行为:直接 find_package(OpenCV 4.0 REQUIRED),从系统中查找已安装的 OpenCV。
不管走哪种模式,最终都会创建一个统一的 cvw::opencv INTERFACE 别名目标:
add_library(cvw_opencv INTERFACE)
add_library(cvw::opencv ALIAS cvw_opencv)
target_link_libraries(cvw_opencv INTERFACE ${OpenCV_LIBS})
target_include_directories(cvw_opencv INTERFACE ${OpenCV_INCLUDE_DIRS})
target_compile_definitions(cvw_opencv INTERFACE
CVW_OPENCV_VERSION_MAJOR=${OpenCV_VERSION_MAJOR}
CVW_OPENCV_VERSION_MINOR=${OpenCV_VERSION_MINOR}
)这个封装的意义在于:根 CMakeLists.txt 只需要 target_link_libraries(edgecv INTERFACE cvw::opencv),完全不用关心底层用的是哪种 OpenCV。版本信息通过编译定义暴露出来,你可以在代码里用 #ifdef CVW_OPENCV_VERSION_MAJOR 做条件编译。这就是所谓的"依赖抽象"——虽然实现起来就是几行 CMake 胶水代码,但对使用者来说省心多了。
另外值得注意的是,在交叉编译但既没有 CVW_OPENCV_ROOT 也没有 CVW_BUILD_OPENCV 的情况下,脚本会创建一个空的 stub 目标并打印提示,而不是直接报错。这样你可以先编译 header-only 的部分,OpenCV 留到后面再配置。
cmake/CvwQt.cmake:可选的 Qt 依赖
Qt 的处理思路和 OpenCV 类似,但有一个关键区别:Qt 是完全可选的,找不到就直接跳过,不会影响核心功能的编译。脚本先尝试 Qt6,找不到再试 Qt5,两个都找不到就打印一条 STATUS 消息然后 return()——注意这里的 return() 是从 CMake 脚本文件级别返回,不会影响主构建流程,也不会创建 cvw::qt 目标。这就是为什么根 CMakeLists.txt 里用 TARGET cvw::qt 来判断 Qt 是否可用的原因。
交叉编译模式下的处理逻辑和 OpenCV 一样:提供了 CVW_QT_ROOT 就从指定路径找,没提供就跳过。版本信息同样通过编译定义暴露(CVW_QT_VERSION_MAJOR、CVW_QT6 等),方便代码里做条件编译。
cmake/CvwOptions.cmake:为将来预留
这个文件目前是空的,只有一行注释说"Sprint 2 会在这里加 expected<> 的选择逻辑"。放在这里是为了提前占位——我们不想等后面需要加编译选项的时候才发现没有统一的地方放它们,临时到处塞 set() 和 add_definitions() 会让构建脚本变得很难维护。
头文件占位:验证 include 路径
现在构建脚本都就位了,我们该创建头文件了。但目前我们还没有任何实质性的代码可以放进去,所以先创建两个占位文件,目的是验证整个 include 路径链路是通的。
include/cvw/core.hpp 的内容非常简洁:
#pragma once
// cvw core types — placeholder for Sprint 3
// Currently provides nothing; exists to verify include paths work.对,就这么点东西——一个 include guard 加两行注释。它的全部意义就是"存在"。但这个存在很重要:如果 #include <cvw/core.hpp> 能编译通过,说明我们的 target_include_directories 配置是正确的,include/cvw/ 目录确实在头文件搜索路径里。
然后是 include/cvw/cvw.hpp,我们的伞头文件:
#pragma once
#include <cvw/algorithms.hpp>
#include <cvw/core.hpp>
#include <cvw/embedded.hpp>
#include <cvw/pipeline.hpp>它把所有公开头文件聚合到一起,用户只需要 #include <cvw/cvw.hpp> 就能拿到所有功能。虽然现在这些子头文件大部分还是占位的,但伞头文件的结构已经反映了最终的项目布局。这是一个很好的实践:先确定公共 API 的"形状",再逐步填充实现。
第一个测试:证明一切都能工作
终于到了见证成果的时候。我们来写第一个测试——test/test_sanity.cpp:
#include <cvw/core.hpp>
#include <cvw/cvw.hpp>
#include <gtest/gtest.h>
TEST(Sanity, IncludePaths) {
SUCCEED();
}这个测试唯一的断言是 SUCCEED(),意思是"什么都不检查,直接通过"。听起来很无聊,但它实际上验证了两件关键的事:一是 #include <cvw/core.hpp> 和 #include <cvw/cvw.hpp> 能正常编译,说明头文件路径配置正确;二是测试框架本身能正常链接和运行。这两个基础一旦确认没问题,后面写真正的测试就有了可靠的地基。
为了让这个测试能跑起来,我们需要在 test/CMakeLists.txt 里配置 Google Test。我们使用 FetchContent 从 GitHub 拉取 Google Test,这样用户不需要预先安装它:
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
add_executable(test_sanity test_sanity.cpp)
target_link_libraries(test_sanity PRIVATE edgecv GTest::gtest_main)
add_test(NAME sanity COMMAND test_sanity)gtest_force_shared_crt ON 这行在 Windows 上很重要——它让 Google Test 使用动态 CRT 而不是静态的,避免链接冲突。在 Linux 上这行没什么效果但也不会有害,保留着无妨。GTest::gtest_main 提供了 main() 函数的默认实现,这样我们不用自己写。
add_test() 把这个可执行文件注册到 CTest 里,后面你就可以用 ctest --test-dir build 来运行所有测试。
.gitignore 和 .clang-format:项目卫生
最后我们来处理两个经常被忽略但实际很重要的配置文件。
.gitignore 的内容很直接:
build*/
examples/data/*.png
examples/data/*.jpg
examples/data/*.jpeg
.cache/忽略 build*/ 匹配所有构建目录(你可能会有 build、build-release、build-arm 等),忽略 examples/data/ 下的图片文件是因为测试数据通常很大且不适合放进 git,.cache/ 是 clangd 的缓存目录。
.clang-format 我们选择了以 LLVM 风格为基础,4 空格缩进、80 列宽限制、指针靠左(int* p 而不是 int *p)。这里不逐行解释了,但有一件事值得提:我们用 SortIncludes: true 让 clang-format 自动排序 #include,配合 IncludeBlocks: Preserve 保持 include 块之间的空行分组——这样头文件顺序就不会成为 code review 里的争论点了。
验证:让一切跑起来
现在所有文件都就位了,让我们验证一下整个构建链路是否通畅:
cmake -B build
cmake --build build
cd build && ctest如果一切顺利,你会看到 CMake 配置阶段的输出里包含 [cvw::opencv] OpenCV x.x found (system) 之类的信息,编译成功后会输出一个 test_sanity 可执行文件,运行测试时应该看到:
Test project /path/to/edgecv/build
Start 1: sanity
1/1 Test #1: sanity ........................ Passed 0.00 sec
100% tests passed, 0 tests failed到这一步,项目的骨架就完全立起来了。虽然我们还没写任何"真正的"代码,但构建系统、依赖管理、测试框架、代码格式化——这些基础设施全部就绪了。有了这个底子,后续每一章加新模块的时候,你只需要在 include/cvw/ 里加头文件、在 test/ 里加测试,不用再操心构建配置的事情。
⚠️ 一个常见的坑:如果你在配置 CMake 时遇到找不到 OpenCV 的错误,首先确认你确实安装了 libopencv-dev(Debian/Ubuntu)或 opencv-devel(Fedora)。如果确认安装了但 CMake 还是找不到,检查 OpenCV_DIR 环境变量或者用 -DOpenCV_DIR=/usr/lib/cmake/opencv4 显式指定。另外,如果你用的是子模块方式编译 OpenCV,第一次 configure 会很慢(可能要十几分钟),耐心等就好。
下一章我们就开始写真正的代码了——编译期像素格式标签系统。那是整个 edgecv 类型安全的基石,也是最体现"C++20 怎么帮我们堵坑"的地方。