Commit 95f55843 authored by 唐永康's avatar 唐永康

添加给浩天的注释,后续对接

parent d7c16b1f
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
# 使用传统计算机视觉读取仪表和表盘的主 C++ 项目。
project(instrument_reader_cpp VERSION 0.1.0 LANGUAGES CXX) project(instrument_reader_cpp VERSION 0.1.0 LANGUAGES CXX)
# C++17 已支持 filesystem,同时可保持代码兼容 Ubuntu 22.04。
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
# OpenCV 是唯一的大型依赖,用于图像读写和基础视觉运算。
find_package(OpenCV REQUIRED) find_package(OpenCV REQUIRED)
# 演示目标使用的可选样例根目录,可通过 CMake、环境变量或仓库布局设置。
set(INSTRUMENT_READER_SAMPLE_ROOT "" CACHE PATH "Sample data root containing original_crops and generated_scales") set(INSTRUMENT_READER_SAMPLE_ROOT "" CACHE PATH "Sample data root containing original_crops and generated_scales")
if(NOT INSTRUMENT_READER_SAMPLE_ROOT) if(NOT INSTRUMENT_READER_SAMPLE_ROOT)
if(DEFINED ENV{INSTRUMENT_READER_SAMPLE_ROOT}) if(DEFINED ENV{INSTRUMENT_READER_SAMPLE_ROOT})
...@@ -17,6 +21,7 @@ if(NOT INSTRUMENT_READER_SAMPLE_ROOT) ...@@ -17,6 +21,7 @@ if(NOT INSTRUMENT_READER_SAMPLE_ROOT)
endif() endif()
endif() endif()
# CLI 工具和演示程序共用的核心库。
add_library(instrument_reader_core add_library(instrument_reader_core
src/airspeed_reader.cpp src/airspeed_reader.cpp
src/dispatcher.cpp src/dispatcher.cpp
...@@ -26,35 +31,41 @@ add_library(instrument_reader_core ...@@ -26,35 +31,41 @@ add_library(instrument_reader_core
src/types.cpp src/types.cpp
) )
# 导出公共头文件,便于未来集成到 ROS 2 节点。
target_include_directories(instrument_reader_core target_include_directories(instrument_reader_core
PUBLIC PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/include
${OpenCV_INCLUDE_DIRS} ${OpenCV_INCLUDE_DIRS}
) )
# 公共头文件暴露了 cv::Mat/cv::Point 类型,因此以传递方式链接 OpenCV。
target_link_libraries(instrument_reader_core target_link_libraries(instrument_reader_core
PUBLIC PUBLIC
${OpenCV_LIBS} ${OpenCV_LIBS}
) )
# 启用严格警告,并为边缘设备吞吐量优化 Release 构建。
if(MSVC) if(MSVC)
target_compile_options(instrument_reader_core PRIVATE /EHsc /W4 $<$<CONFIG:Release>:/O2>) target_compile_options(instrument_reader_core PRIVATE /EHsc /W4 $<$<CONFIG:Release>:/O2>)
else() else()
target_compile_options(instrument_reader_core PRIVATE -Wall -Wextra -Wpedantic $<$<CONFIG:Release>:-O3>) target_compile_options(instrument_reader_core PRIVATE -Wall -Wextra -Wpedantic $<$<CONFIG:Release>:-O3>)
endif() endif()
# 批量图像处理的主入口程序。
add_executable(instrument_reader_cli apps/instrument_reader_cli.cpp) add_executable(instrument_reader_cli apps/instrument_reader_cli.cpp)
target_link_libraries(instrument_reader_cli PRIVATE instrument_reader_core) target_link_libraries(instrument_reader_cli PRIVATE instrument_reader_core)
add_executable(airspeed_lut_cli apps/airspeed_lut_cli.cpp) add_executable(airspeed_lut_cli apps/airspeed_lut_cli.cpp)
target_link_libraries(airspeed_lut_cli PRIVATE instrument_reader_core) target_link_libraries(airspeed_lut_cli PRIVATE instrument_reader_core)
# 通用非线性分段插值的独立演示程序。
add_executable(non_linear_gauge_demo apps/non_linear_gauge_demo.cpp) add_executable(non_linear_gauge_demo apps/non_linear_gauge_demo.cpp)
target_link_libraries(non_linear_gauge_demo PRIVATE instrument_reader_core) target_link_libraries(non_linear_gauge_demo PRIVATE instrument_reader_core)
add_executable(clion_demo apps/clion_demo.cpp) add_executable(clion_demo apps/clion_demo.cpp)
target_link_libraries(clion_demo PRIVATE instrument_reader_core) target_link_libraries(clion_demo PRIVATE instrument_reader_core)
# Windows 下从 CLion/Visual Studio 启动调试时,需要在 PATH 中加入 OpenCV DLL。
if(WIN32 AND OpenCV_DIR) if(WIN32 AND OpenCV_DIR)
get_filename_component(INSTRUMENT_READER_OPENCV_BIN "${OpenCV_DIR}/../bin" ABSOLUTE) get_filename_component(INSTRUMENT_READER_OPENCV_BIN "${OpenCV_DIR}/../bin" ABSOLUTE)
set(INSTRUMENT_READER_RUN_ENV "PATH=${INSTRUMENT_READER_OPENCV_BIN};$ENV{PATH}") set(INSTRUMENT_READER_RUN_ENV "PATH=${INSTRUMENT_READER_OPENCV_BIN};$ENV{PATH}")
...@@ -67,11 +78,14 @@ else() ...@@ -67,11 +78,14 @@ else()
set(INSTRUMENT_READER_RUN_ENV "PATH=$ENV{PATH}") set(INSTRUMENT_READER_RUN_ENV "PATH=$ENV{PATH}")
endif() endif()
# 运行目标在此根目录下创建 output/outputN 目录。
set(INSTRUMENT_READER_OUTPUT_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/output" CACHE PATH "Output root for CLion run targets") set(INSTRUMENT_READER_OUTPUT_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/output" CACHE PATH "Output root for CLion run targets")
if(INSTRUMENT_READER_SAMPLE_ROOT AND EXISTS "${INSTRUMENT_READER_SAMPLE_ROOT}/original_crops") if(INSTRUMENT_READER_SAMPLE_ROOT AND EXISTS "${INSTRUMENT_READER_SAMPLE_ROOT}/original_crops")
# 空速表底图用于在指针检测时去除静态表盘标记。
set(INSTRUMENT_READER_AIRSPEED_BASE "${INSTRUMENT_READER_SAMPLE_ROOT}/generated_scales/01_airspeed_indicator/_restored_base_no_pointer.png" CACHE FILEPATH "Airspeed no-pointer base/template image") set(INSTRUMENT_READER_AIRSPEED_BASE "${INSTRUMENT_READER_SAMPLE_ROOT}/generated_scales/01_airspeed_indicator/_restored_base_no_pointer.png" CACHE FILEPATH "Airspeed no-pointer base/template image")
# 完整演示会写出叠加图和 dispatch_results.json。
add_custom_target(run_demo add_custom_target(run_demo
COMMAND ${CMAKE_COMMAND} -E make_directory "${INSTRUMENT_READER_OUTPUT_ROOT}" COMMAND ${CMAKE_COMMAND} -E make_directory "${INSTRUMENT_READER_OUTPUT_ROOT}"
COMMAND ${CMAKE_COMMAND} -E env "${INSTRUMENT_READER_RUN_ENV}" COMMAND ${CMAKE_COMMAND} -E env "${INSTRUMENT_READER_RUN_ENV}"
...@@ -86,6 +100,7 @@ if(INSTRUMENT_READER_SAMPLE_ROOT AND EXISTS "${INSTRUMENT_READER_SAMPLE_ROOT}/or ...@@ -86,6 +100,7 @@ if(INSTRUMENT_READER_SAMPLE_ROOT AND EXISTS "${INSTRUMENT_READER_SAMPLE_ROOT}/or
VERBATIM VERBATIM
) )
# 无叠加图目标用于将算法耗时与 PNG 写出耗时分离。
add_custom_target(run_demo_no_overlay add_custom_target(run_demo_no_overlay
COMMAND ${CMAKE_COMMAND} -E make_directory "${INSTRUMENT_READER_OUTPUT_ROOT}" COMMAND ${CMAKE_COMMAND} -E make_directory "${INSTRUMENT_READER_OUTPUT_ROOT}"
COMMAND ${CMAKE_COMMAND} -E env "${INSTRUMENT_READER_RUN_ENV}" COMMAND ${CMAKE_COMMAND} -E env "${INSTRUMENT_READER_RUN_ENV}"
...@@ -102,6 +117,7 @@ if(INSTRUMENT_READER_SAMPLE_ROOT AND EXISTS "${INSTRUMENT_READER_SAMPLE_ROOT}/or ...@@ -102,6 +117,7 @@ if(INSTRUMENT_READER_SAMPLE_ROOT AND EXISTS "${INSTRUMENT_READER_SAMPLE_ROOT}/or
) )
endif() endif()
# 用于验证角度到物理值转换的 LUT 冒烟测试目标。
add_custom_target(run_lut_example add_custom_target(run_lut_example
COMMAND ${CMAKE_COMMAND} -E env "${INSTRUMENT_READER_RUN_ENV}" COMMAND ${CMAKE_COMMAND} -E env "${INSTRUMENT_READER_RUN_ENV}"
$<TARGET_FILE:airspeed_lut_cli> $<TARGET_FILE:airspeed_lut_cli>
...@@ -111,6 +127,7 @@ add_custom_target(run_lut_example ...@@ -111,6 +127,7 @@ add_custom_target(run_lut_example
VERBATIM VERBATIM
) )
# 小型合成非线性插值可视化的演示目标。
add_custom_target(run_non_linear_gauge_demo add_custom_target(run_non_linear_gauge_demo
COMMAND ${CMAKE_COMMAND} -E make_directory "${INSTRUMENT_READER_OUTPUT_ROOT}" COMMAND ${CMAKE_COMMAND} -E make_directory "${INSTRUMENT_READER_OUTPUT_ROOT}"
COMMAND ${CMAKE_COMMAND} -E env "${INSTRUMENT_READER_RUN_ENV}" COMMAND ${CMAKE_COMMAND} -E env "${INSTRUMENT_READER_RUN_ENV}"
...@@ -120,10 +137,12 @@ add_custom_target(run_non_linear_gauge_demo ...@@ -120,10 +137,12 @@ add_custom_target(run_non_linear_gauge_demo
VERBATIM VERBATIM
) )
# 安装库和可执行工具,供外部项目使用。
install(TARGETS instrument_reader_core instrument_reader_cli airspeed_lut_cli non_linear_gauge_demo install(TARGETS instrument_reader_core instrument_reader_cli airspeed_lut_cli non_linear_gauge_demo
RUNTIME DESTINATION bin RUNTIME DESTINATION bin
LIBRARY DESTINATION lib LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib ARCHIVE DESTINATION lib
) )
# 将公共头文件安装到标准 include 目录树。
install(DIRECTORY include/ DESTINATION include) install(DIRECTORY include/ DESTINATION include)
#include "instrument_reader/lut_mapper.hpp" #include "instrument_reader/lut_mapper.hpp"
#include <filesystem> #include <filesystem>
#include <iomanip> #include <iomanip>
...@@ -10,6 +10,7 @@ using namespace instrument_reader; ...@@ -10,6 +10,7 @@ using namespace instrument_reader;
namespace { namespace {
// 使用说明同时覆盖交互式标定和单次映射模式。
void printUsage() { void printUsage() {
std::cout std::cout
<< "usage:\n" << "usage:\n"
...@@ -17,6 +18,7 @@ void printUsage() { ...@@ -17,6 +18,7 @@ void printUsage() {
<< " airspeed_lut_cli --map <lut.json> --theta <deg> [--boundary reject|clamp]\n"; << " airspeed_lut_cli --map <lut.json> --theta <deg> [--boundary reject|clamp]\n";
} }
// 无参数模式是默认 LUT 的轻量冒烟测试。
int runDefaultExample() { int runDefaultExample() {
LutMapper mapper; LutMapper mapper;
std::string error; std::string error;
...@@ -27,6 +29,8 @@ int runDefaultExample() { ...@@ -27,6 +29,8 @@ int runDefaultExample() {
} }
MapResult r = mapper.map(4.5); MapResult r = mapper.map(4.5);
// 输出类似 JSON 的结果,便于脚本解析。
std::cout << "{\n"; std::cout << "{\n";
std::cout << " \"valid\": " << (r.valid ? "true" : "false") << ",\n"; std::cout << " \"valid\": " << (r.valid ? "true" : "false") << ",\n";
if (r.valid) std::cout << " \"value\": " << std::fixed << std::setprecision(6) << r.value << ",\n"; if (r.valid) std::cout << " \"value\": " << std::fixed << std::setprecision(6) << r.value << ",\n";
...@@ -52,6 +56,7 @@ int main(int argc, char** argv) { ...@@ -52,6 +56,7 @@ int main(int argc, char** argv) {
bool hasTheta = false; bool hasTheta = false;
BoundaryPolicy policy = BoundaryPolicy::Reject; BoundaryPolicy policy = BoundaryPolicy::Reject;
// 手工解析参数,使该工具保持可移植且无额外依赖。
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
std::string arg = argv[i]; std::string arg = argv[i];
auto value = [&](const char* name) -> std::string { auto value = [&](const char* name) -> std::string {
...@@ -79,6 +84,7 @@ int main(int argc, char** argv) { ...@@ -79,6 +84,7 @@ int main(int argc, char** argv) {
} }
if (!calibrateOut.empty()) { if (!calibrateOut.empty()) {
// 交互式标定用于收集物理值与绝对角度配对。
LutMapper mapper(startAngle); LutMapper mapper(startAngle);
std::cout << "Enter calibration points. Empty value line finishes.\n"; std::cout << "Enter calibration points. Empty value line finishes.\n";
while (true) { while (true) {
...@@ -95,6 +101,8 @@ int main(int argc, char** argv) { ...@@ -95,6 +101,8 @@ int main(int argc, char** argv) {
mapper.addPoint(std::stod(valueLine), std::stod(thetaLine)); mapper.addPoint(std::stod(valueLine), std::stod(thetaLine));
} }
std::string error; std::string error;
// 拒绝保存非单调 LUT,因为它会导致插值结果不安全。
if (!mapper.validate(&error)) { if (!mapper.validate(&error)) {
std::cerr << "bad calibration: " << error << "\n"; std::cerr << "bad calibration: " << error << "\n";
return 1; return 1;
...@@ -108,6 +116,7 @@ int main(int argc, char** argv) { ...@@ -108,6 +116,7 @@ int main(int argc, char** argv) {
} }
if (!mapPath.empty() && hasTheta) { if (!mapPath.empty() && hasTheta) {
// 单次映射模式适合验证实测仪表角度。
LutMapper mapper; LutMapper mapper;
std::string error; std::string error;
if (!mapper.loadJson(mapPath, &error)) { if (!mapper.loadJson(mapPath, &error)) {
...@@ -116,6 +125,8 @@ int main(int argc, char** argv) { ...@@ -116,6 +125,8 @@ int main(int argc, char** argv) {
} }
mapper.setBoundaryPolicy(policy); mapper.setBoundaryPolicy(policy);
MapResult r = mapper.map(theta); MapResult r = mapper.map(theta);
// 输出结构与默认示例保持一致。
std::cout << "{\n"; std::cout << "{\n";
std::cout << " \"valid\": " << (r.valid ? "true" : "false") << ",\n"; std::cout << " \"valid\": " << (r.valid ? "true" : "false") << ",\n";
if (r.valid) std::cout << " \"value\": " << std::fixed << std::setprecision(6) << r.value << ",\n"; if (r.valid) std::cout << " \"value\": " << std::fixed << std::setprecision(6) << r.value << ",\n";
......
#include "instrument_reader/dispatcher.hpp" #include "instrument_reader/dispatcher.hpp"
#include "instrument_reader/lut_mapper.hpp" #include "instrument_reader/lut_mapper.hpp"
#include <filesystem> #include <filesystem>
...@@ -11,6 +11,7 @@ using namespace instrument_reader; ...@@ -11,6 +11,7 @@ using namespace instrument_reader;
namespace { namespace {
// 无论 CLion 从仓库根目录还是构建目录启动,都能解析源代码目录。
fs::path projectRootFromCurrentWorkingDirectory() { fs::path projectRootFromCurrentWorkingDirectory() {
fs::path cwd = fs::current_path(); fs::path cwd = fs::current_path();
if (fs::exists(cwd / "CMakeLists.txt") && fs::exists(cwd / "include" / "instrument_reader")) { if (fs::exists(cwd / "CMakeLists.txt") && fs::exists(cwd / "include" / "instrument_reader")) {
...@@ -22,6 +23,7 @@ fs::path projectRootFromCurrentWorkingDirectory() { ...@@ -22,6 +23,7 @@ fs::path projectRootFromCurrentWorkingDirectory() {
return fs::absolute(cwd); return fs::absolute(cwd);
} }
// 优先通过环境变量定位样例数据集,随后尝试常见仓库目录布局。
fs::path findSampleRoot(const fs::path& projectRoot) { fs::path findSampleRoot(const fs::path& projectRoot) {
if (const char* env = std::getenv("INSTRUMENT_READER_SAMPLE_ROOT")) { if (const char* env = std::getenv("INSTRUMENT_READER_SAMPLE_ROOT")) {
fs::path p(env); fs::path p(env);
...@@ -39,9 +41,11 @@ fs::path findSampleRoot(const fs::path& projectRoot) { ...@@ -39,9 +41,11 @@ fs::path findSampleRoot(const fs::path& projectRoot) {
throw std::runtime_error("sample root not found; set INSTRUMENT_READER_SAMPLE_ROOT"); throw std::runtime_error("sample root not found; set INSTRUMENT_READER_SAMPLE_ROOT");
} }
// 端到端视觉演示:分类、读取空速表角度、映射物理值并写出叠加图。
void runVisionDemo(const fs::path& projectRoot, const fs::path& sampleRoot) { void runVisionDemo(const fs::path& projectRoot, const fs::path& sampleRoot) {
fs::path airspeedBase = sampleRoot / "generated_scales" / "01_airspeed_indicator" / "_restored_base_no_pointer.png"; fs::path airspeedBase = sampleRoot / "generated_scales" / "01_airspeed_indicator" / "_restored_base_no_pointer.png";
// 使用明确路径,使演示程序在 CLion 目标中保持一致行为。
InstrumentDispatcher::Options options; InstrumentDispatcher::Options options;
options.outputDir = projectRoot / "output"; options.outputDir = projectRoot / "output";
options.airspeedTemplatePath = airspeedBase; options.airspeedTemplatePath = airspeedBase;
...@@ -51,6 +55,10 @@ void runVisionDemo(const fs::path& projectRoot, const fs::path& sampleRoot) { ...@@ -51,6 +55,10 @@ void runVisionDemo(const fs::path& projectRoot, const fs::path& sampleRoot) {
options.writeJson = true; options.writeJson = true;
InstrumentDispatcher dispatcher(options); InstrumentDispatcher dispatcher(options);
// 同时处理原始裁剪图和生成的空速表样例。
// 调度器每次只加载一张图像,因此该演示不会在内存中同时持有
// 整个数据集解码后的像素。
DispatchSummary summary = dispatcher.run({ DispatchSummary summary = dispatcher.run({
sampleRoot / "original_crops", sampleRoot / "original_crops",
sampleRoot / "generated_scales" / "01_airspeed_indicator", sampleRoot / "generated_scales" / "01_airspeed_indicator",
...@@ -65,6 +73,7 @@ void runVisionDemo(const fs::path& projectRoot, const fs::path& sampleRoot) { ...@@ -65,6 +73,7 @@ void runVisionDemo(const fs::path& projectRoot, const fs::path& sampleRoot) {
std::cout << "output_dir=" << summary.outputDir.string() << "\n"; std::cout << "output_dir=" << summary.outputDir.string() << "\n";
} }
// 小型 LUT 冒烟测试,通过默认 LUT 验证角度跨越 360 度零点的处理。
void runLutDemo(const fs::path& projectRoot) { void runLutDemo(const fs::path& projectRoot) {
LutMapper mapper; LutMapper mapper;
std::string error; std::string error;
...@@ -89,6 +98,7 @@ void runLutDemo(const fs::path& projectRoot) { ...@@ -89,6 +98,7 @@ void runLutDemo(const fs::path& projectRoot) {
int main() { int main() {
try { try {
// 输出解析后的路径,便于 CLion 用户诊断错误的工作目录。
fs::path projectRoot = projectRootFromCurrentWorkingDirectory(); fs::path projectRoot = projectRootFromCurrentWorkingDirectory();
fs::path sampleRoot = findSampleRoot(projectRoot); fs::path sampleRoot = findSampleRoot(projectRoot);
......
#include "instrument_reader/dispatcher.hpp" #include "instrument_reader/dispatcher.hpp"
#include <filesystem> #include <filesystem>
#include <initializer_list> #include <initializer_list>
...@@ -12,6 +12,7 @@ using namespace instrument_reader; ...@@ -12,6 +12,7 @@ using namespace instrument_reader;
namespace { namespace {
// 无需任何项目状态即可输出 CLI 使用约定。
void printUsage() { void printUsage() {
std::cout std::cout
<< "usage: instrument_reader_cli --input <file_or_dir> [--input <file_or_dir> ...]\n" << "usage: instrument_reader_cli --input <file_or_dir> [--input <file_or_dir> ...]\n"
...@@ -20,6 +21,7 @@ void printUsage() { ...@@ -20,6 +21,7 @@ void printUsage() {
<< " [--visual-threshold 0.40] [--no-recursive] [--no-overlays]\n"; << " [--visual-threshold 0.40] [--no-recursive] [--no-overlays]\n";
} }
// 从按优先级排序的候选列表中返回第一个存在的路径。
fs::path firstExisting(std::initializer_list<fs::path> paths) { fs::path firstExisting(std::initializer_list<fs::path> paths) {
for (const fs::path& p : paths) { for (const fs::path& p : paths) {
if (fs::exists(p)) return p; if (fs::exists(p)) return p;
...@@ -27,6 +29,8 @@ fs::path firstExisting(std::initializer_list<fs::path> paths) { ...@@ -27,6 +29,8 @@ fs::path firstExisting(std::initializer_list<fs::path> paths) {
return {}; return {};
} }
// 优先使用用户输入目录附近的标定资源,使生成数据集可以携带
// 自己的无指针底图和模板。
fs::path findAirspeedAssetNearInputs(const std::vector<fs::path>& inputs, std::initializer_list<fs::path> relativePaths) { fs::path findAirspeedAssetNearInputs(const std::vector<fs::path>& inputs, std::initializer_list<fs::path> relativePaths) {
std::vector<fs::path> roots; std::vector<fs::path> roots;
for (const fs::path& input : inputs) { for (const fs::path& input : inputs) {
...@@ -36,6 +40,8 @@ fs::path findAirspeedAssetNearInputs(const std::vector<fs::path>& inputs, std::i ...@@ -36,6 +40,8 @@ fs::path findAirspeedAssetNearInputs(const std::vector<fs::path>& inputs, std::i
if (fs::is_regular_file(root)) root = root.parent_path(); if (fs::is_regular_file(root)) root = root.parent_path();
if (!fs::is_directory(root)) continue; if (!fs::is_directory(root)) continue;
// 同时搜索目录本身及其父目录,以支持类似
// root/01_airspeed_indicator/_base_no_pointer.png 的布局。
roots.push_back(root); roots.push_back(root);
if (root.has_parent_path()) roots.push_back(root.parent_path()); if (root.has_parent_path()) roots.push_back(root.parent_path());
} }
...@@ -52,9 +58,12 @@ fs::path findAirspeedAssetNearInputs(const std::vector<fs::path>& inputs, std::i ...@@ -52,9 +58,12 @@ fs::path findAirspeedAssetNearInputs(const std::vector<fs::path>& inputs, std::i
} // namespace } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
// 此处 CLI 只拥有路径和配置向量。解码后的图像内存稍后才会在
// InstrumentDispatcher::processOne() 内部分配。
std::vector<fs::path> inputs; std::vector<fs::path> inputs;
InstrumentDispatcher::Options options; InstrumentDispatcher::Options options;
// 简单手工解析使二进制程序保持无额外依赖。
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
std::string arg = argv[i]; std::string arg = argv[i];
auto value = [&](const char* name) -> std::string { auto value = [&](const char* name) -> std::string {
...@@ -81,9 +90,12 @@ int main(int argc, char** argv) { ...@@ -81,9 +90,12 @@ int main(int argc, char** argv) {
} }
if (inputs.empty()) { if (inputs.empty()) {
// 适合当前仓库布局的开发者默认值。
inputs.push_back(firstExisting({"../2/original_crops", "D:/chuav/gague/2/original_crops"})); inputs.push_back(firstExisting({"../2/original_crops", "D:/chuav/gague/2/original_crops"}));
} }
// 与全局回退资源相比,这些本地资源更安全,因为不同生成数据集之间
// 的指针和底图几何参数可能不同。
const fs::path localAirspeedBase = findAirspeedAssetNearInputs(inputs, { const fs::path localAirspeedBase = findAirspeedAssetNearInputs(inputs, {
"_base_no_pointer.png", "_base_no_pointer.png",
"_restored_base_no_pointer.png", "_restored_base_no_pointer.png",
...@@ -93,6 +105,7 @@ int main(int argc, char** argv) { ...@@ -93,6 +105,7 @@ int main(int argc, char** argv) {
}); });
if (options.airspeedTemplatePath.empty()) { if (options.airspeedTemplatePath.empty()) {
// 模板供视觉分类器使用。
options.airspeedTemplatePath = firstExisting({ options.airspeedTemplatePath = firstExisting({
localAirspeedBase, localAirspeedBase,
"../2/generated_scales/01_airspeed_indicator/_restored_base_no_pointer.png", "../2/generated_scales/01_airspeed_indicator/_restored_base_no_pointer.png",
...@@ -100,9 +113,11 @@ int main(int argc, char** argv) { ...@@ -100,9 +113,11 @@ int main(int argc, char** argv) {
}); });
} }
if (options.airspeedBasePath.empty()) { if (options.airspeedBasePath.empty()) {
// 底图供指针读数器抑制静态表盘标记。
options.airspeedBasePath = firstExisting({localAirspeedBase, options.airspeedTemplatePath}); options.airspeedBasePath = firstExisting({localAirspeedBase, options.airspeedTemplatePath});
} }
if (options.airspeedLutPath.empty()) { if (options.airspeedLutPath.empty()) {
// 默认 LUT 包含当前实测的非线性空速刻度。
options.airspeedLutPath = firstExisting({ options.airspeedLutPath = firstExisting({
"configs/airspeed_lut_example.json", "configs/airspeed_lut_example.json",
"../configs/airspeed_lut_example.json", "../configs/airspeed_lut_example.json",
...@@ -111,6 +126,8 @@ int main(int argc, char** argv) { ...@@ -111,6 +126,8 @@ int main(int argc, char** argv) {
} }
try { try {
// 调度器负责实际图像循环和输出写入。
// 它还拥有空速表射线、模板 cv::Mat 等读数器缓存状态。
InstrumentDispatcher dispatcher(options); InstrumentDispatcher dispatcher(options);
DispatchSummary summary = dispatcher.run(inputs); DispatchSummary summary = dispatcher.run(inputs);
std::cout << "processed=" << summary.results.size() << "\n"; std::cout << "processed=" << summary.results.size() << "\n";
......
#include "instrument_reader/non_linear_gauge_reader.hpp" #include "instrument_reader/non_linear_gauge_reader.hpp"
#include <opencv2/imgcodecs.hpp> #include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp> #include <opencv2/imgproc.hpp>
...@@ -15,8 +15,10 @@ ...@@ -15,8 +15,10 @@
namespace { namespace {
// 使用局部常量,使该独立演示不依赖 angle_math.hpp。
constexpr float kPi = 3.14159265358979323846f; constexpr float kPi = 3.14159265358979323846f;
// 为演示产物创建下一个编号的输出目录。
std::filesystem::path nextOutputDir(const std::filesystem::path& outputRoot) { std::filesystem::path nextOutputDir(const std::filesystem::path& outputRoot) {
std::filesystem::create_directories(outputRoot); std::filesystem::create_directories(outputRoot);
...@@ -28,12 +30,16 @@ std::filesystem::path nextOutputDir(const std::filesystem::path& outputRoot) { ...@@ -28,12 +30,16 @@ std::filesystem::path nextOutputDir(const std::filesystem::path& outputRoot) {
int runNumber = 0; int runNumber = 0;
bool valid = true; bool valid = true;
// 只解析严格符合 outputN 形式的目录名。
for (std::size_t i = std::char_traits<char>::length(prefix); i < name.size(); ++i) { for (std::size_t i = std::char_traits<char>::length(prefix); i < name.size(); ++i) {
if (name[i] < '0' || name[i] > '9') { if (name[i] < '0' || name[i] > '9') {
valid = false; valid = false;
break; break;
} }
const int digit = name[i] - '0'; const int digit = name[i] - '0';
// 异常目录名包含过多数字时,避免发生整数溢出。
if (runNumber > (std::numeric_limits<int>::max() - digit) / 10) { if (runNumber > (std::numeric_limits<int>::max() - digit) / 10) {
valid = false; valid = false;
break; break;
...@@ -43,12 +49,14 @@ std::filesystem::path nextOutputDir(const std::filesystem::path& outputRoot) { ...@@ -43,12 +49,14 @@ std::filesystem::path nextOutputDir(const std::filesystem::path& outputRoot) {
if (valid && runNumber > maxRunNumber) maxRunNumber = runNumber; if (valid && runNumber > maxRunNumber) maxRunNumber = runNumber;
} }
// 依次尝试下一个整数编号,直到目录创建成功。
for (int runNumber = maxRunNumber + 1;; ++runNumber) { for (int runNumber = maxRunNumber + 1;; ++runNumber) {
const std::filesystem::path outputDir = outputRoot / ("output" + std::to_string(runNumber)); const std::filesystem::path outputDir = outputRoot / ("output" + std::to_string(runNumber));
if (std::filesystem::create_directory(outputDir)) return outputDir; if (std::filesystem::create_directory(outputDir)) return outputDir;
} }
} }
// 将图像坐标系角度转换为合成表盘上的点。
cv::Point pointOnDial(const cv::Point2f& center, float radius, float angleDeg) { cv::Point pointOnDial(const cv::Point2f& center, float radius, float angleDeg) {
const float radians = angleDeg * kPi / 180.0f; const float radians = angleDeg * kPi / 180.0f;
return { return {
...@@ -57,6 +65,7 @@ cv::Point pointOnDial(const cv::Point2f& center, float radius, float angleDeg) { ...@@ -57,6 +65,7 @@ cv::Point pointOnDial(const cv::Point2f& center, float radius, float angleDeg) {
}; };
} }
// 为生成 PNG 中的标签提供格式化辅助。
void putCenteredText(cv::Mat& image, const std::string& text, const cv::Point& anchor, double scale, cv::Scalar color, void putCenteredText(cv::Mat& image, const std::string& text, const cv::Point& anchor, double scale, cv::Scalar color,
int thickness = 1) { int thickness = 1) {
int baseline = 0; int baseline = 0;
...@@ -65,6 +74,7 @@ void putCenteredText(cv::Mat& image, const std::string& text, const cv::Point& a ...@@ -65,6 +74,7 @@ void putCenteredText(cv::Mat& image, const std::string& text, const cv::Point& a
color, thickness, cv::LINE_AA); color, thickness, cv::LINE_AA);
} }
// 绘制小型合成仪表,展示标定节点和被测试指针。
bool writeResultImage(const std::filesystem::path& imagePath, bool writeResultImage(const std::filesystem::path& imagePath,
const std::vector<instrument_reader::nonlinear::CalibrationPoint>& calibration, const std::vector<instrument_reader::nonlinear::CalibrationPoint>& calibration,
float currentAngle, float currentValue) { float currentAngle, float currentValue) {
...@@ -74,11 +84,14 @@ bool writeResultImage(const std::filesystem::path& imagePath, ...@@ -74,11 +84,14 @@ bool writeResultImage(const std::filesystem::path& imagePath,
const cv::Point centerPoint(cvRound(center.x), cvRound(center.y)); const cv::Point centerPoint(cvRound(center.x), cvRound(center.y));
constexpr float radius = 210.0f; constexpr float radius = 210.0f;
// 浅色背景便于脱离真实图像集检查演示输出。
// 这是该演示唯一分配的完整图像缓冲区。
cv::Mat image(height, width, CV_8UC3, cv::Scalar(248, 249, 250)); cv::Mat image(height, width, CV_8UC3, cv::Scalar(248, 249, 250));
cv::circle(image, centerPoint, cvRound(radius), cv::Scalar(40, 45, 52), 3, cv::LINE_AA); cv::circle(image, centerPoint, cvRound(radius), cv::Scalar(40, 45, 52), 3, cv::LINE_AA);
cv::circle(image, centerPoint, 8, cv::Scalar(40, 45, 52), -1, cv::LINE_AA); cv::circle(image, centerPoint, 8, cv::Scalar(40, 45, 52), -1, cv::LINE_AA);
// 绘制标定刻度及其物理值。
for (const auto& point : calibration) { for (const auto& point : calibration) {
const cv::Point outer = pointOnDial(center, radius, point.angle); const cv::Point outer = pointOnDial(center, radius, point.angle);
const cv::Point inner = pointOnDial(center, radius - 22.0f, point.angle); const cv::Point inner = pointOnDial(center, radius - 22.0f, point.angle);
...@@ -90,6 +103,7 @@ bool writeResultImage(const std::filesystem::path& imagePath, ...@@ -90,6 +103,7 @@ bool writeResultImage(const std::filesystem::path& imagePath,
putCenteredText(image, valueLabel.str(), labelPoint, 0.55, cv::Scalar(35, 40, 48), 1); putCenteredText(image, valueLabel.str(), labelPoint, 0.55, cv::Scalar(35, 40, 48), 1);
} }
// 使用红色绘制被查询的指针角度。
const cv::Point pointerTip = pointOnDial(center, radius - 28.0f, currentAngle); const cv::Point pointerTip = pointOnDial(center, radius - 28.0f, currentAngle);
cv::line(image, centerPoint, pointerTip, cv::Scalar(20, 20, 210), 5, cv::LINE_AA); cv::line(image, centerPoint, pointerTip, cv::Scalar(20, 20, 210), 5, cv::LINE_AA);
cv::circle(image, centerPoint, 13, cv::Scalar(20, 20, 210), -1, cv::LINE_AA); cv::circle(image, centerPoint, 13, cv::Scalar(20, 20, 210), -1, cv::LINE_AA);
...@@ -111,6 +125,8 @@ int main() { ...@@ -111,6 +125,8 @@ int main() {
using instrument_reader::nonlinear::CalibrationPoint; using instrument_reader::nonlinear::CalibrationPoint;
using instrument_reader::nonlinear::NonLinearGaugeReader; using instrument_reader::nonlinear::NonLinearGaugeReader;
// 真实测量得到的空速刻度:80-140、140-200 和 200-300
// 是展开角度坐标系中的三个独立线性分段。
const std::vector<CalibrationPoint> airspeedCalibration = { const std::vector<CalibrationPoint> airspeedCalibration = {
{334.1f, 80.0f}, {334.1f, 80.0f},
{406.1f, 140.0f}, {406.1f, 140.0f},
...@@ -118,8 +134,11 @@ int main() { ...@@ -118,8 +134,11 @@ int main() {
{586.5f, 300.0f}, {586.5f, 300.0f},
}; };
// 读数器复制或移动该小型标定表并预计算斜率,此过程不涉及图像内存。
const NonLinearGaugeReader reader(airspeedCalibration, BoundaryMode::Clamp); const NonLinearGaugeReader reader(airspeedCalibration, BoundaryMode::Clamp);
const float thetaFromYoloDeg = 35.99f; const float thetaFromYoloDeg = 35.99f;
// 80-300 的扫描范围跨越 360 度零点,因此需要展开 YOLO 输出的模 360 角度。
const float thetaUnwrappedDeg = const float thetaUnwrappedDeg =
thetaFromYoloDeg < airspeedCalibration.front().angle ? thetaFromYoloDeg + 360.0f : thetaFromYoloDeg; thetaFromYoloDeg < airspeedCalibration.front().angle ? thetaFromYoloDeg + 360.0f : thetaFromYoloDeg;
const float airspeedKmh = reader.calculate_value(thetaUnwrappedDeg); const float airspeedKmh = reader.calculate_value(thetaUnwrappedDeg);
...@@ -128,6 +147,7 @@ int main() { ...@@ -128,6 +147,7 @@ int main() {
const std::filesystem::path outputPath = outputDir / "non_linear_gauge_demo_result.json"; const std::filesystem::path outputPath = outputDir / "non_linear_gauge_demo_result.json";
const std::filesystem::path imagePath = outputDir / "non_linear_gauge_demo_result.png"; const std::filesystem::path imagePath = outputDir / "non_linear_gauge_demo_result.png";
// 先写出可视化产物,再写 JSON,确保 JSON 不会指向缺失图像。
if (!writeResultImage(imagePath, airspeedCalibration, thetaUnwrappedDeg, airspeedKmh)) { if (!writeResultImage(imagePath, airspeedCalibration, thetaUnwrappedDeg, airspeedKmh)) {
std::cerr << "failed_to_write_image=" << std::filesystem::absolute(imagePath).string() << "\n"; std::cerr << "failed_to_write_image=" << std::filesystem::absolute(imagePath).string() << "\n";
return 1; return 1;
...@@ -139,6 +159,7 @@ int main() { ...@@ -139,6 +159,7 @@ int main() {
return 1; return 1;
} }
// 最小 JSON 报告沿用主调度器的输出风格。
out << std::fixed << std::setprecision(3) << "{\n" out << std::fixed << std::setprecision(3) << "{\n"
<< " \"valid\": true,\n" << " \"valid\": true,\n"
<< " \"theta_deg\": " << thetaFromYoloDeg << ",\n" << " \"theta_deg\": " << thetaFromYoloDeg << ",\n"
......
#pragma once #pragma once
#include "instrument_reader/types.hpp" #include "instrument_reader/types.hpp"
...@@ -10,59 +10,118 @@ ...@@ -10,59 +10,118 @@
namespace instrument_reader { namespace instrument_reader {
// 使用传统计算机视觉方法,从 BGR 裁剪图中估计空速表指针角度。
// 该类会按图像尺寸缓存预计算射线,从而降低连续帧处理开销。
class AirspeedReader { class AirspeedReader {
public: public:
// 射线采样和有效性判定所使用的运行时配置。
struct Config { struct Config {
// Config 由固定大小的标量和值类型组成,通常直接内联存放在
// AirspeedReader 对象中,不需要单独分配堆内存。
// 在参考裁剪图上测量的仪表盘几何参数,运行时会按输入尺寸缩放。
ReaderGeometry geometry; ReaderGeometry geometry;
// 图像坐标系中的角度搜索范围和搜索分辨率。
float startAngleDeg = 0.0f; float startAngleDeg = 0.0f;
float endAngleDeg = 360.0f; float endAngleDeg = 360.0f;
float angleStepDeg = 0.5f; float angleStepDeg = 0.5f;
// 射线采样点从 innerRadius 沿半径方向延伸到 radius。
int radialSamples = 128; int radialSamples = 128;
// 横向采样使每条射线能够容忍指针宽度。
int lateralSamples = 5; int lateralSamples = 5;
float rayWidthPx = 14.0f; float rayWidthPx = 14.0f;
// 单个采样点被视为指针证据时所需的最低信号阈值。
float signalThreshold = 0.42f; float signalThreshold = 0.42f;
// 拒绝过短或过于稀疏、无法构成真实指针的检测结果。
float minContinuousFraction = 0.30f; float minContinuousFraction = 0.30f;
float minCoverageFraction = 0.18f; float minCoverageFraction = 0.18f;
// 为候选不明确情况保留的诊断阈值。
float minDominanceGap = 0.004f; float minDominanceGap = 0.004f;
// 允许文字、刻度等造成少量间断,而不立即切断整条指针线。
int maxGapSamples = 3; int maxGapSamples = 3;
// 与无指针底图做差,用于抑制静态刻度和文字。
bool useBaseDifference = true; bool useBaseDifference = true;
}; };
AirspeedReader(); AirspeedReader();
explicit AirspeedReader(Config config); explicit AirspeedReader(Config config);
// 可选的同一仪表无移动指针底图。
void setNoPointerBase(const cv::Mat& bgrBase); void setNoPointerBase(const cv::Mat& bgrBase);
const Config& config() const { return config_; } const Config& config() const { return config_; }
// 单帧处理热路径;通过 ERR_* 状态码返回错误,而不是抛出异常。
AngleReading read(const cv::Mat& bgr); AngleReading read(const cv::Mat& bgr);
// 绘制选中的角度和状态文字,用于调试输出图。
cv::Mat drawOverlay(const cv::Mat& bgr, const AngleReading& reading) const; cv::Mat drawOverlay(const cv::Mat& bgr, const AngleReading& reading) const;
private: private:
// 单个候选角度所对应的预计算像素坐标。
struct RaySamples { struct RaySamples {
float angleDeg = 0.0f; float angleDeg = 0.0f;
// 在堆上拥有预计算像素坐标。该数据会被缓存,避免每帧重复分配
// 内存并重新计算射线几何。
// 单条射线的近似数据量:
// 数据量公式:radialSamples * lateralSamples * sizeof(cv::Point)。
std::vector<cv::Point> points; std::vector<cv::Point> points;
}; };
// 按图像尺寸划分的缓存,使射线生成工作不进入逐帧循环。
struct RayCacheEntry { struct RayCacheEntry {
// 缩放到特定图像尺寸后的 Config 副本。
Config scaledConfig; Config scaledConfig;
// 保存该尺寸全部采样射线的堆内存。
// 默认配置约为:721 个角度 * 128 个径向采样 * 5 个横向采样
// * 每个 cv::Point 8 字节,约占 3.5 MiB,另加 vector/容器开销。
std::vector<RaySamples> rays; std::vector<RaySamples> rays;
// 与缓存射线一起复用的小型堆向量。
std::vector<float> lateralWeights; std::vector<float> lateralWeights;
}; };
// 将参考几何参数缩放到当前图像尺寸。
Config scaledConfigForImage(cv::Size imageSize) const; Config scaledConfigForImage(cv::Size imageSize) const;
// 延迟创建或复用当前帧尺寸对应的射线缓存。
RayCacheEntry& cacheForSize(cv::Size imageSize); RayCacheEntry& cacheForSize(cv::Size imageSize);
// 类高斯横向权重,使射线中心线拥有更高权重。
static std::vector<float> makeLateralWeights(int count); static std::vector<float> makeLateralWeights(int count);
// 将角度、半径和横向偏移转换为限制在图像范围内的像素采样点。
static std::vector<RaySamples> precomputeRays(const Config& cfg, cv::Size imageSize); static std::vector<RaySamples> precomputeRays(const Config& cfg, cv::Size imageSize);
// 构建浮点信号图,使高亮、低饱和度的移动指针像素获得较高分数。
static cv::Mat makeWhitePointerSignal(const cv::Mat& bgr, const cv::Mat* noPointerBase, const Config& cfg); static cv::Mat makeWhitePointerSignal(const cv::Mat& bgr, const cv::Mat* noPointerBase, const Config& cfg);
// 在允许少量间断的情况下,测量最长的径向连续支持区间。
static float longestRunWithGaps(const std::vector<float>& radial, float threshold, int maxGapSamples, int* coveredOut); static float longestRunWithGaps(const std::vector<float>& radial, float threshold, int maxGapSamples, int* coveredOut);
// 使用预计算射线执行核心评分循环。
AngleReading readWithCache(const cv::Mat& bgr, const RayCacheEntry& cache) const; AngleReading readWithCache(const cv::Mat& bgr, const RayCacheEntry& cache) const;
Config config_; Config config_;
// 静态仪表盘底图的深拷贝,用于指针差分评分。
// cv::Mat 使用引用计数;此处 clone 后,本对象拥有独立像素缓冲区,
// 不再与调用方内存共享。
cv::Mat noPointerBaseBgr_; cv::Mat noPointerBaseBgr_;
// 以“宽x高”为键的小型缓存;典型管线通常只使用一种尺寸。
// 缓存项会在读数器整个生命周期内常驻,以内存换取速度。
// 当前实现不会淘汰旧缓存,因此持续输入大量不同分辨率时,
// 该缓存的内存占用会单调增长。
std::map<std::string, RayCacheEntry> rayCache_; std::map<std::string, RayCacheEntry> rayCache_;
}; };
......
#pragma once #pragma once
#include <cmath> #include <cmath>
namespace instrument_reader { namespace instrument_reader {
// 角度转换共用常量,避免依赖非标准的 M_PI。
constexpr double kPi = 3.14159265358979323846; constexpr double kPi = 3.14159265358979323846;
// 将角度归一化到 [0, 360),同时正确处理负数输入。
inline double normalize360(double deg) { inline double normalize360(double deg) {
double out = std::fmod(deg, 360.0); double out = std::fmod(deg, 360.0);
if (out < 0.0) out += 360.0; if (out < 0.0) out += 360.0;
...@@ -13,6 +15,7 @@ inline double normalize360(double deg) { ...@@ -13,6 +15,7 @@ inline double normalize360(double deg) {
return out; return out;
} }
// 面向 OpenCV 高频热路径的 float 版本。
inline float normalize360f(float deg) { inline float normalize360f(float deg) {
float out = std::fmod(deg, 360.0f); float out = std::fmod(deg, 360.0f);
if (out < 0.0f) out += 360.0f; if (out < 0.0f) out += 360.0f;
...@@ -20,10 +23,12 @@ inline float normalize360f(float deg) { ...@@ -20,10 +23,12 @@ inline float normalize360f(float deg) {
return out; return out;
} }
// 将图像坐标系中的绝对角度转换为相对 LUT 起点的扫描角度。
inline double relativeFromStart(double thetaAbsDeg, double startAngleDeg) { inline double relativeFromStart(double thetaAbsDeg, double startAngleDeg) {
return normalize360(thetaAbsDeg - startAngleDeg); return normalize360(thetaAbsDeg - startAngleDeg);
} }
// 将角度转换为 std::sin/cos 使用的弧度。
inline float degreesToRadians(float deg) { inline float degreesToRadians(float deg) {
return deg * static_cast<float>(kPi / 180.0); return deg * static_cast<float>(kPi / 180.0);
} }
......
#pragma once #pragma once
#include "instrument_reader/airspeed_reader.hpp" #include "instrument_reader/airspeed_reader.hpp"
#include "instrument_reader/instrument_classifier.hpp" #include "instrument_reader/instrument_classifier.hpp"
...@@ -10,40 +10,68 @@ ...@@ -10,40 +10,68 @@
namespace instrument_reader { namespace instrument_reader {
// 负责文件扫描、仪表分类、读数、叠加图绘制和 JSON 输出的总调度器。
class InstrumentDispatcher { class InstrumentDispatcher {
public: public:
// CLI 工具、演示程序和未来 ROS 2 集成共同使用的运行时选项。
struct Options { struct Options {
// 输出根目录。每次运行通常会在其下创建 output/outputN。
std::filesystem::path outputDir = "output"; std::filesystem::path outputDir = "output";
// 可选的空速表资源路径;为空时由 CLI/演示程序选择默认值。
std::filesystem::path airspeedTemplatePath; std::filesystem::path airspeedTemplatePath;
std::filesystem::path airspeedBasePath; std::filesystem::path airspeedBasePath;
std::filesystem::path airspeedLutPath; std::filesystem::path airspeedLutPath;
// 将图像路由到空速表读数器时使用的视觉分类阈值。
double airspeedVisualThreshold = 0.40; double airspeedVisualThreshold = 0.40;
// 输入扫描和输出行为设置。
bool recursive = true; bool recursive = true;
bool writeOverlays = true; bool writeOverlays = true;
bool writeJson = true; bool writeJson = true;
// 为 true 时,每次运行创建下一个编号的 output/outputN 目录。
bool createIncrementalOutputDir = true; bool createIncrementalOutputDir = true;
}; };
InstrumentDispatcher(); InstrumentDispatcher();
explicit InstrumentDispatcher(Options options); explicit InstrumentDispatcher(Options options);
// 处理全部输入文件或目录,并返回批处理汇总结果。
DispatchSummary run(const std::vector<std::filesystem::path>& inputs); DispatchSummary run(const std::vector<std::filesystem::path>& inputs);
private: private:
// 将文件和目录展开为稳定排序、去重后的图像列表。
std::vector<std::filesystem::path> collectImages(const std::vector<std::filesystem::path>& inputs) const; std::vector<std::filesystem::path> collectImages(const std::vector<std::filesystem::path>& inputs) const;
// 处理单张图像,并按配置选择是否写出叠加图。
ProcessResult processOne(const std::filesystem::path& path, int index); ProcessResult processOne(const std::filesystem::path& path, int index);
// 输出机器可读的 dispatch_results.json。
void writeResultsJson(const DispatchSummary& summary) const; void writeResultsJson(const DispatchSummary& summary) const;
// 目录遍历时使用的文件过滤器。
static bool isImageFile(const std::filesystem::path& path); static bool isImageFile(const std::filesystem::path& path);
static bool isHelperImage(const std::filesystem::path& path); static bool isHelperImage(const std::filesystem::path& path);
// 创建 output/outputN,其中 N 比现有最大运行编号大 1。
static std::filesystem::path nextIncrementalOutputDir(const std::filesystem::path& outputRoot); static std::filesystem::path nextIncrementalOutputDir(const std::filesystem::path& outputRoot);
// 生成稳定且不含不安全字符的叠加图文件名。
static std::string safeOutputName(const std::filesystem::path& path, int index); static std::string safeOutputName(const std::filesystem::path& path, int index);
Options options_; Options options_;
// 完成递增编号解析后,本次运行实际使用的输出目录。
std::filesystem::path activeOutputDir_; std::filesystem::path activeOutputDir_;
// 核心处理管线组件。
InstrumentClassifier classifier_; InstrumentClassifier classifier_;
AirspeedReader airspeedReader_; AirspeedReader airspeedReader_;
LutMapper airspeedLut_; LutMapper airspeedLut_;
// 为 false 时,即使角度有效,也应报告 ERR_NO_AIRSPEED_LUT。
bool hasAirspeedLut_ = false; bool hasAirspeedLut_ = false;
}; };
......
#pragma once #pragma once
#include "instrument_reader/types.hpp" #include "instrument_reader/types.hpp"
...@@ -8,29 +8,51 @@ ...@@ -8,29 +8,51 @@
namespace instrument_reader { namespace instrument_reader {
// 轻量视觉路由器,用于判断输入裁剪图是否为已配置的空速表。
class InstrumentClassifier { class InstrumentClassifier {
public: public:
// 分类配置刻意保持轻量,适合边缘设备部署。
struct Config { struct Config {
// 几何参数定义比较表盘区域时使用的掩码。
ReaderGeometry airspeedGeometry; ReaderGeometry airspeedGeometry;
// 视觉匹配所需的最低掩码 NCC 分数。
double airspeedVisualThreshold = 0.40; double airspeedVisualThreshold = 0.40;
// 当路径强烈提示图像为空速表时,允许使用更低的视觉分数阈值。
double pathHintThresholdFactor = 0.50; double pathHintThresholdFactor = 0.50;
}; };
InstrumentClassifier(); InstrumentClassifier();
explicit InstrumentClassifier(Config config); explicit InstrumentClassifier(Config config);
// 从磁盘加载模板,并预计算模板边缘图。
bool loadAirspeedTemplate(const std::filesystem::path& templatePath); bool loadAirspeedTemplate(const std::filesystem::path& templatePath);
// 直接设置模板,便于测试或相机标定管线使用。
void setAirspeedTemplate(const cv::Mat& bgrTemplate); void setAirspeedTemplate(const cv::Mat& bgrTemplate);
// 使用模板 NCC 分数与路径提示,对单张裁剪图进行分类。
ClassificationResult classify(const std::filesystem::path& path, const cv::Mat& bgr) const; ClassificationResult classify(const std::filesystem::path& path, const cv::Mat& bgr) const;
private: private:
// 对图像执行缩放、灰度化、模糊和边缘检测,以获得稳定的比较结果。
static cv::Mat prepareEdgeForClassification(const cv::Mat& bgr, cv::Size targetSize); static cv::Mat prepareEdgeForClassification(const cv::Mat& bgr, cv::Size targetSize);
// 为圆形表盘建立掩码,忽略背景和图像四角。
static cv::Mat makeClassificationMask(cv::Size size, const ReaderGeometry& geometry); static cv::Mat makeClassificationMask(cv::Size size, const ReaderGeometry& geometry);
// 仅在 mask8 覆盖区域内计算归一化互相关。
static double maskedNcc(const cv::Mat& a32, const cv::Mat& b32, const cv::Mat& mask8); static double maskedNcc(const cv::Mat& a32, const cv::Mat& b32, const cv::Mat& mask8);
Config config_; Config config_;
// templateEdge32_ 和 mask8_ 初始化完成后为 true。
bool ready_ = false; bool ready_ = false;
cv::Size targetSize_; cv::Size targetSize_;
// cv::Mat 头部直接存放在本对象中;调用 setAirspeedTemplate() 后,
// 像素数据位于引用计数管理的堆内存中,并由这些成员持有。
cv::Mat templateEdge32_; cv::Mat templateEdge32_;
cv::Mat mask8_; cv::Mat mask8_;
}; };
......
#pragma once #pragma once
#include <filesystem> #include <filesystem>
#include <string> #include <string>
...@@ -6,52 +6,76 @@ ...@@ -6,52 +6,76 @@
namespace instrument_reader { namespace instrument_reader {
// 指针角度落在已标定扫描范围之外时的边界处理策略。
enum class BoundaryPolicy { enum class BoundaryPolicy {
// 返回无效结果,防止下游控制逻辑使用虚假的物理读数。
Reject, Reject,
// 将结果饱和到最近的标定端点。
Clamp, Clamp,
}; };
// 单个标定节点,同时保存绝对角度和相对起点角度。
struct CalibrationPoint { struct CalibrationPoint {
double value = 0.0; double value = 0.0;
double thetaAbsDeg = 0.0; double thetaAbsDeg = 0.0;
double thetaRelDeg = 0.0; double thetaRelDeg = 0.0;
}; };
// 将指针角度转换为仪表物理读数后的结果。
struct MapResult { struct MapResult {
bool valid = false; bool valid = false;
double value = 0.0; double value = 0.0;
// 回传本次查询角度的绝对形式和 LUT 相对形式。
double thetaAbsDeg = 0.0; double thetaAbsDeg = 0.0;
double thetaRelDeg = 0.0; double thetaRelDeg = 0.0;
// 机器可读状态码,以及可选的人类可读详情。
std::string code; std::string code;
std::string message; std::string message;
// 本次插值选中的分段左右端点。
CalibrationPoint left; CalibrationPoint left;
CalibrationPoint right; CalibrationPoint right;
}; };
// 非线性仪表刻度使用的分段线性查找表。
class LutMapper { class LutMapper {
public: public:
// startAngleDeg 定义 theta_rel=0,使标定表能够安全跨越 360 度零点。
explicit LutMapper(double startAngleDeg = 270.0); explicit LutMapper(double startAngleDeg = 270.0);
// 改变角度原点后,重新计算全部相对角度。
void setStartAngle(double startAngleDeg); void setStartAngle(double startAngleDeg);
void setBoundaryPolicy(BoundaryPolicy policy) { boundaryPolicy_ = policy; } void setBoundaryPolicy(BoundaryPolicy policy) { boundaryPolicy_ = policy; }
// 供标定工具修改 LUT 使用的辅助接口。
void clear(); void clear();
void addPoint(double value, double thetaAbsDeg); void addPoint(double value, double thetaAbsDeg);
// 验证相对角度和物理值均保持单调。
bool validate(std::string* errorMessage = nullptr) const; bool validate(std::string* errorMessage = nullptr) const;
// 部署配置使用的轻量 JSON 读写,避免引入大型 JSON 依赖。
bool saveJson(const std::filesystem::path& path) const; bool saveJson(const std::filesystem::path& path) const;
bool loadJson(const std::filesystem::path& path, std::string* errorMessage = nullptr); bool loadJson(const std::filesystem::path& path, std::string* errorMessage = nullptr);
// 高频热路径:归一化角度、二分查找分段、执行插值。
MapResult map(double thetaAbsDeg) const; MapResult map(double thetaAbsDeg) const;
double startAngleDeg() const { return startAngleDeg_; } double startAngleDeg() const { return startAngleDeg_; }
const std::vector<CalibrationPoint>& points() const { return points_; } const std::vector<CalibrationPoint>& points() const { return points_; }
private: private:
// 保持节点按 thetaRelDeg 排序,以供 lower_bound 二分查找。
void sortByRelativeAngle(); void sortByRelativeAngle();
double startAngleDeg_ = 270.0; double startAngleDeg_ = 270.0;
BoundaryPolicy boundaryPolicy_ = BoundaryPolicy::Reject; BoundaryPolicy boundaryPolicy_ = BoundaryPolicy::Reject;
// 本对象拥有完整标定表的堆内存。标定表很小,只排序一次;
// 随后的高频 map() 调用只读访问它。
std::vector<CalibrationPoint> points_; std::vector<CalibrationPoint> points_;
}; };
......
#pragma once #pragma once
#include <cstddef> #include <cstddef>
#include <vector> #include <vector>
namespace instrument_reader::nonlinear { namespace instrument_reader::nonlinear {
// 通用非线性仪表使用的最小标定节点。
struct CalibrationPoint { struct CalibrationPoint {
// 调用方所选连续角度坐标系中的角度。
float angle = 0.0f; float angle = 0.0f;
// 该角度对应的物理读数。
float value = 0.0f; float value = 0.0f;
}; };
// 通用插值器处理超出标定范围角度时的行为。
enum class BoundaryMode { enum class BoundaryMode {
// Saturate to the nearest calibrated endpoint. This is the safest default // 饱和到最近的标定端点。当检测器短暂偏离表盘时,
// for real-time control loops when the detector briefly leaves the dial. // 这是实时控制循环中最安全的默认策略。
Clamp, Clamp,
// Extend the first or last calibrated segment beyond the table range. // 使用第一段或最后一段的斜率,将读数外推到标定表范围之外。
Extrapolate, Extrapolate,
}; };
// 无第三方依赖、适用于高频循环的分段线性插值器。
class NonLinearGaugeReader { class NonLinearGaugeReader {
public: public:
// The input table is copied, sorted by angle once, and validated for // 输入表按值传入并由本对象接管,构造时只排序一次,
// finite values plus strictly increasing angles. // 同时验证所有数值有限且角度严格递增。
explicit NonLinearGaugeReader(std::vector<CalibrationPoint> calibration, BoundaryMode boundaryMode = BoundaryMode::Clamp); explicit NonLinearGaugeReader(std::vector<CalibrationPoint> calibration, BoundaryMode boundaryMode = BoundaryMode::Clamp);
// Hot path: no allocation, no exception, O(log N) segment lookup. // 高频热路径:不分配内存、不抛出异常,使用 O(log N) 查找目标分段。
// Returns NaN when the upstream angle itself is NaN or infinity. // 上游角度本身为 NaN 或无穷大时返回 NaN。
[[nodiscard]] float calculate_value(float current_angle) const noexcept; [[nodiscard]] float calculate_value(float current_angle) const noexcept;
[[nodiscard]] const std::vector<CalibrationPoint>& calibration() const noexcept { return calibration_; } [[nodiscard]] const std::vector<CalibrationPoint>& calibration() const noexcept { return calibration_; }
[[nodiscard]] BoundaryMode boundaryMode() const noexcept { return boundaryMode_; } [[nodiscard]] BoundaryMode boundaryMode() const noexcept { return boundaryMode_; }
private: private:
// 使用预计算的分段斜率,避免 calculate_value() 在热路径中执行除法。
[[nodiscard]] float interpolateSegment(std::size_t leftIndex, float angle) const noexcept; [[nodiscard]] float interpolateSegment(std::size_t leftIndex, float angle) const noexcept;
// 已按角度排序的标定表。
// 构造完成后,读数器拥有该堆向量,调用方原始输入可以安全销毁。
std::vector<CalibrationPoint> calibration_; std::vector<CalibrationPoint> calibration_;
// 每个分段保存一个斜率:
// 斜率公式:(right.value - left.value) / (right.angle - left.angle)。
// 这个额外的堆向量以少量内存换取速度,使高频 calculate_value() 路径不再执行除法。
std::vector<float> slopes_; std::vector<float> slopes_;
BoundaryMode boundaryMode_ = BoundaryMode::Clamp; BoundaryMode boundaryMode_ = BoundaryMode::Clamp;
}; };
......
#pragma once #pragma once
#include <opencv2/core.hpp> #include <opencv2/core.hpp>
...@@ -8,12 +8,14 @@ ...@@ -8,12 +8,14 @@
namespace instrument_reader { namespace instrument_reader {
// 高层分发类型,用于将输入仪表裁剪图路由到对应的读数器。
enum class InstrumentType { enum class InstrumentType {
Unknown, Unknown,
OtherInstrument, OtherInstrument,
AirspeedIndicator, AirspeedIndicator,
}; };
// 稳定的字符串形式,用于 JSON 报告和控制台诊断信息。
inline const char* toString(InstrumentType type) { inline const char* toString(InstrumentType type) {
switch (type) { switch (type) {
case InstrumentType::AirspeedIndicator: return "airspeed_indicator"; case InstrumentType::AirspeedIndicator: return "airspeed_indicator";
...@@ -22,63 +24,131 @@ inline const char* toString(InstrumentType type) { ...@@ -22,63 +24,131 @@ inline const char* toString(InstrumentType type) {
} }
} }
// 仪表盘共享几何参数。角度采用图像坐标系:
// 0 度向右、90 度向下、180 度向左、270 度向上。
struct ReaderGeometry { struct ReaderGeometry {
// Image coordinates: 0 deg points right, 90 deg points down. // 此结构体只包含固定大小的数值和 OpenCV 值类型。
// 每个实例自身不会进行堆内存分配,跟随 Config 复制时开销很小。
// 参考裁剪图中的仪表盘中心点。
cv::Point2f center = cv::Point2f(198.0f, 248.0f); cv::Point2f center = cv::Point2f(198.0f, 248.0f);
// 测量几何参数时使用的参考图像尺寸。
// 运行时会先按输入裁剪图尺寸进行等比例缩放,再执行射线采样。
cv::Size2f referenceSize = cv::Size2f(400.0f, 445.0f); cv::Size2f referenceSize = cv::Size2f(400.0f, 445.0f);
// 搜索指针和刻度线时使用的外部采样半径。
float radius = 210.0f; float radius = 210.0f;
// 内部半径用于避开中心轴、螺钉和文字等干扰。
float innerRadius = 18.0f; float innerRadius = 18.0f;
}; };
// 指针角度检测结果,尚未将角度转换成物理读数。
struct AngleReading { struct AngleReading {
// 大多数数值字段直接内联存放在对象本体中。
// 两个 std::string 在内容超过标准库的小字符串优化容量时,才会在堆上分配内存。
// 只有检测到足够连续的指针证据时才为 true。
bool valid = false; bool valid = false;
// 字符串内部可能拥有堆内存,但正常成功读数时通常为空或很短,
// 不会成为每帧内存占用的主要部分。
// 可供下游控制逻辑和回归测试读取的机器状态码。
std::string errorCode = "ERR_UNSET"; std::string errorCode = "ERR_UNSET";
// 面向人的错误详情;与状态码分开,便于控制逻辑只判断稳定状态码。
std::string errorMessage; std::string errorMessage;
// 归一化到 [0, 360) 的图像坐标系指针角度。
float angleDegMod360 = 0.0f; float angleDegMod360 = 0.0f;
// 最优和次优射线分数,用于诊断指针候选不明确的情况。
float score = 0.0f; float score = 0.0f;
float secondAngleDegMod360 = 0.0f; float secondAngleDegMod360 = 0.0f;
float secondScore = 0.0f; float secondScore = 0.0f;
float confidenceGap = 0.0f; float confidenceGap = 0.0f;
// 连续性指标用于排除不是真实指针的短高亮线段。
float continuousLineLengthPx = 0.0f; float continuousLineLengthPx = 0.0f;
float continuousLineFraction = 0.0f; float continuousLineFraction = 0.0f;
float coverageFraction = 0.0f; float coverageFraction = 0.0f;
}; };
// 轻量分类结果,在选择具体仪表读数器之前使用。
struct ClassificationResult { struct ClassificationResult {
// 只包含标量数据;复制它不会复制图像缓冲区,也不会分配大块内存。
InstrumentType type = InstrumentType::Unknown; InstrumentType type = InstrumentType::Unknown;
// 当前已完整实现的空速表读数器所使用的便捷布尔值。
bool isAirspeed = false; bool isAirspeed = false;
// 文件名或路径提示当前图像为空速表时为 true。
bool pathHint = false; bool pathHint = false;
// 在仪表盘掩码区域内计算得到的模板匹配分数。
double visualScore = 0.0; double visualScore = 0.0;
// 用于报告的综合置信度,不是经过概率校准的真实概率。
double confidence = 0.0; double confidence = 0.0;
}; };
// 每张图像的处理结果记录,最终写入 dispatch_results.json。
struct ProcessResult { struct ProcessResult {
// 此记录特意只保存元数据。可变大小内存仅来自路径和字符串文本;
// 图像像素永远不会成为该对象的成员。
// filesystem::path 只保存路径文本,不会延长图像像素缓冲区的生命周期。
std::filesystem::path inputPath; std::filesystem::path inputPath;
std::filesystem::path outputPath; std::filesystem::path outputPath;
// 即使物理值映射失败,也会保留分类结果和原始角度。
ClassificationResult classification; ClassificationResult classification;
AngleReading angle; AngleReading angle;
// LUT 映射后的物理空速读数;false 表示读数为空或被拒绝。
bool airspeedValueValid = false; bool airspeedValueValid = false;
double airspeedValue = 0.0; double airspeedValue = 0.0;
// 相对角度以 LUT 起始角度为零点,可正确处理跨越 360 度的情况。
bool airspeedThetaRelValid = false; bool airspeedThetaRelValid = false;
double airspeedThetaRelDeg = 0.0; double airspeedThetaRelDeg = 0.0;
// 分段字段用于展示 lower_bound 选择了哪一段非线性区间。
bool airspeedSegmentValid = false; bool airspeedSegmentValid = false;
double airspeedSegmentStartValue = 0.0; double airspeedSegmentStartValue = 0.0;
double airspeedSegmentEndValue = 0.0; double airspeedSegmentEndValue = 0.0;
double airspeedSegmentStartThetaRelDeg = 0.0; double airspeedSegmentStartThetaRelDeg = 0.0;
double airspeedSegmentEndThetaRelDeg = 0.0; double airspeedSegmentEndThetaRelDeg = 0.0;
// 映射器状态,例如 OK_INTERPOLATED、ERR_ABOVE_MAX 或 ERR_BAD_LUT。
std::string airspeedValueCode; std::string airspeedValueCode;
std::string airspeedValueMessage; std::string airspeedValueMessage;
// 调度器内部测量的单张图像墙钟耗时。
double elapsedMs = 0.0; double elapsedMs = 0.0;
}; };
// 调度器返回并由 CLI 输出的批处理汇总结果。
struct DispatchSummary { struct DispatchSummary {
std::filesystem::path outputDir; std::filesystem::path outputDir;
// results 为每张输入图像保存一个 ProcessResult。
// 此处不保存像素缓冲区;图像在 processOne() 内加载、处理、写出并释放。
// 因此批处理内存主要随元数据数量增长,而不是随解码后的图像总数增长。
std::vector<ProcessResult> results; std::vector<ProcessResult> results;
// 调度器处理循环耗时,不包含进程启动时间。
double totalElapsedMs = 0.0; double totalElapsedMs = 0.0;
// 统计被分类为空速表的图像数量,不要求物理值映射成功。
int airspeedCount() const; int airspeedCount() const;
// 统计具有有效物理读数的空速表图像,而不仅仅是角度有效。
int validAirspeedCount() const; int validAirspeedCount() const;
// 统计未被路由到空速表读数器的图像数量。
int skippedCount() const; int skippedCount() const;
}; };
......
param( param(
# 与 MSVC 兼容的 OpenCV 包根目录。
[string]$OpenCvDir = "C:\opencv\build", [string]$OpenCvDir = "C:\opencv\build",
# CMake 构建目录;如果不是绝对路径,则相对于项目根目录。
[string]$BuildDir = "build", [string]$BuildDir = "build",
# NMake 单配置构建类型。
[string]$Configuration = "Release" [string]$Configuration = "Release"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
# CMake 负责驱动配置和构建两个步骤。
if (-not (Get-Command cmake.exe -ErrorAction SilentlyContinue)) { if (-not (Get-Command cmake.exe -ErrorAction SilentlyContinue)) {
throw "cmake.exe not found" throw "cmake.exe not found"
} }
$ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
# OpenCV_DIR 指向本次安装中包含 OpenCVConfig.cmake 的目录。
$opencvConfig = Join-Path $OpenCvDir "x64\vc16\lib" $opencvConfig = Join-Path $OpenCvDir "x64\vc16\lib"
if (-not (Test-Path $opencvConfig)) { if (-not (Test-Path $opencvConfig)) {
throw "OpenCV MSVC library folder not found: $opencvConfig" throw "OpenCV MSVC library folder not found: $opencvConfig"
} }
# vswhere 用于可靠定位最新的 Visual Studio Build Tools 安装。
$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
if (-not (Test-Path $vswhere)) { if (-not (Test-Path $vswhere)) {
throw "vswhere.exe not found. Install Visual Studio Build Tools 2022." throw "vswhere.exe not found. Install Visual Studio Build Tools 2022."
...@@ -26,18 +35,22 @@ if (-not $vsInstall) { ...@@ -26,18 +35,22 @@ if (-not $vsInstall) {
throw "MSVC C++ tools not found. Install workload Microsoft.VisualStudio.Workload.VCTools." throw "MSVC C++ tools not found. Install workload Microsoft.VisualStudio.Workload.VCTools."
} }
# VsDevCmd 为 NMake 设置编译器、链接器、SDK 和 PATH 环境变量。
$vsDevCmd = Join-Path $vsInstall "Common7\Tools\VsDevCmd.bat" $vsDevCmd = Join-Path $vsInstall "Common7\Tools\VsDevCmd.bat"
if (-not (Test-Path $vsDevCmd)) { if (-not (Test-Path $vsDevCmd)) {
throw "VsDevCmd.bat not found: $vsDevCmd" throw "VsDevCmd.bat not found: $vsDevCmd"
} }
# 同时支持绝对构建路径和相对项目根目录的构建路径。
$buildPath = if ([System.IO.Path]::IsPathRooted($BuildDir)) { $buildPath = if ([System.IO.Path]::IsPathRooted($BuildDir)) {
$BuildDir $BuildDir
} else { } else {
Join-Path $ProjectRoot $BuildDir Join-Path $ProjectRoot $BuildDir
} }
# 命令字符串会在 MSVC 开发环境中执行,因此保持其内容明确可见。
$configure = "cmake -S `"$ProjectRoot`" -B `"$buildPath`" -G `"NMake Makefiles`" -DOpenCV_DIR=`"$opencvConfig`" -DCMAKE_BUILD_TYPE=$Configuration" $configure = "cmake -S `"$ProjectRoot`" -B `"$buildPath`" -G `"NMake Makefiles`" -DOpenCV_DIR=`"$opencvConfig`" -DCMAKE_BUILD_TYPE=$Configuration"
$build = "cmake --build `"$buildPath`" --config $Configuration" $build = "cmake --build `"$buildPath`" --config $Configuration"
# 在同一个 cmd 中执行,确保配置和构建阶段都保留 VsDevCmd 环境。
cmd.exe /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 && $configure && $build" cmd.exe /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 && $configure && $build"
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# 根据脚本位置解析项目路径,使脚本可从任意当前目录运行。
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# 允许显式指定构建目录,否则使用文档约定的 Release 目录。
BUILD_DIR="${1:-${PROJECT_ROOT}/cmake-build-ubuntu22-release}" BUILD_DIR="${1:-${PROJECT_ROOT}/cmake-build-ubuntu22-release}"
# 使用 CMake/pkg-config 找到的系统 OpenCV 包完成配置。
cmake -S "${PROJECT_ROOT}" -B "${BUILD_DIR}" \ cmake -S "${PROJECT_ROOT}" -B "${BUILD_DIR}" \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_BUILD_TYPE=Release
# 并行构建,以加快开发迭代。
cmake --build "${BUILD_DIR}" --parallel cmake --build "${BUILD_DIR}" --parallel
# 输出生成的二进制文件路径,便于直接运行。
echo "Built binaries:" echo "Built binaries:"
find "${BUILD_DIR}" -maxdepth 1 -type f -executable -print find "${BUILD_DIR}" -maxdepth 1 -type f -executable -print
param( param(
# 要打开的项目目录,默认使用当前仓库根目录。
[string]$ProjectDir = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path [string]$ProjectDir = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
# 当前开发机及 Windows 默认位置中常见的 CLion 安装路径。
$candidates = @( $candidates = @(
"D:\works\Clion\CLion 2025.2.5\bin\clion64.exe", "D:\works\Clion\CLion 2025.2.5\bin\clion64.exe",
"$env:LOCALAPPDATA\Programs\CLion\bin\clion64.exe", "$env:LOCALAPPDATA\Programs\CLion\bin\clion64.exe",
...@@ -11,6 +13,8 @@ $candidates = @( ...@@ -11,6 +13,8 @@ $candidates = @(
) )
$clion = $null $clion = $null
# 优先使用已知绝对路径,以可靠启动桌面程序。
foreach ($candidate in $candidates) { foreach ($candidate in $candidates) {
if (Test-Path $candidate) { if (Test-Path $candidate) {
$clion = $candidate $clion = $candidate
...@@ -18,6 +22,7 @@ foreach ($candidate in $candidates) { ...@@ -18,6 +22,7 @@ foreach ($candidate in $candidates) {
} }
} }
# 如果 CLion 安装了启动器,则回退到 PATH 查找。
if (-not $clion) { if (-not $clion) {
$cmd = Get-Command clion64.exe -ErrorAction SilentlyContinue $cmd = Get-Command clion64.exe -ErrorAction SilentlyContinue
if ($cmd) { if ($cmd) {
...@@ -29,6 +34,7 @@ if (-not $clion) { ...@@ -29,6 +34,7 @@ if (-not $clion) {
throw "CLion executable not found." throw "CLion executable not found."
} }
# 如果存在 MSVC 工具,则从 x64 开发环境启动 CLion。
$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vswhere) { if (Test-Path $vswhere) {
$vsInstall = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath $vsInstall = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
...@@ -42,5 +48,6 @@ if (Test-Path $vswhere) { ...@@ -42,5 +48,6 @@ if (Test-Path $vswhere) {
} }
} }
# 回退启动仍会打开项目,但 CLion 需要自行定位工具链。
Start-Process -FilePath $clion -ArgumentList "`"$ProjectDir`"" Start-Process -FilePath $clion -ArgumentList "`"$ProjectDir`""
Write-Host "Opened CLion project: $ProjectDir" Write-Host "Opened CLion project: $ProjectDir"
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# 根据脚本位置解析仓库根目录。
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# 先尝试标准启动器,再尝试 JetBrains Toolbox。
if command -v clion >/dev/null 2>&1; then if command -v clion >/dev/null 2>&1; then
clion "${PROJECT_ROOT}" >/dev/null 2>&1 & clion "${PROJECT_ROOT}" >/dev/null 2>&1 &
elif command -v jetbrains-toolbox >/dev/null 2>&1; then elif command -v jetbrains-toolbox >/dev/null 2>&1; then
jetbrains-toolbox "${PROJECT_ROOT}" >/dev/null 2>&1 & jetbrains-toolbox "${PROJECT_ROOT}" >/dev/null 2>&1 &
else else
# 未安装启动器时输出项目路径,供手工打开。
echo "CLion launcher not found. Open this folder manually:" >&2 echo "CLion launcher not found. Open this folder manually:" >&2
echo "${PROJECT_ROOT}" >&2 echo "${PROJECT_ROOT}" >&2
exit 1 exit 1
......
param( param(
# scripts/build_msvc.ps1 创建的构建目录。
[string]$BuildDir = "build", [string]$BuildDir = "build",
# 包含 original_crops 和 generated_scales 的数据集根目录。
[string]$DataRoot = "..\2", [string]$DataRoot = "..\2",
# 用于定位运行时 DLL 的 OpenCV 根目录。
[string]$OpenCvDir = "C:\opencv\build" [string]$OpenCvDir = "C:\opencv\build"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
# 优先使用 Visual Studio 多配置目录布局,其次使用 NMake 单配置布局。
$exe = Join-Path $BuildDir "Release\instrument_reader_cli.exe" $exe = Join-Path $BuildDir "Release\instrument_reader_cli.exe"
if (-not (Test-Path $exe)) { if (-not (Test-Path $exe)) {
$exe = Join-Path $BuildDir "instrument_reader_cli.exe" $exe = Join-Path $BuildDir "instrument_reader_cli.exe"
...@@ -14,11 +20,13 @@ if (-not (Test-Path $exe)) { ...@@ -14,11 +20,13 @@ if (-not (Test-Path $exe)) {
throw "instrument_reader_cli.exe not found. Build first." throw "instrument_reader_cli.exe not found. Build first."
} }
# 将 OpenCV DLL 所在目录加入子进程的 PATH。
$opencvBin = Join-Path $OpenCvDir "x64\vc16\bin" $opencvBin = Join-Path $OpenCvDir "x64\vc16\bin"
if (Test-Path $opencvBin) { if (Test-Path $opencvBin) {
$env:Path = "$opencvBin;$env:Path" $env:Path = "$opencvBin;$env:Path"
} }
# 处理原始裁剪图和生成的空速表样例,并写入 output/outputN。
& $exe ` & $exe `
--input (Join-Path $DataRoot "original_crops") ` --input (Join-Path $DataRoot "original_crops") `
--input (Join-Path $DataRoot "generated_scales\01_airspeed_indicator") ` --input (Join-Path $DataRoot "generated_scales\01_airspeed_indicator") `
......
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# 根据脚本位置解析路径,使其可从任意当前目录启动。
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# 位置参数允许在不修改脚本的情况下覆盖构建目录和数据根目录。
BUILD_DIR="${1:-${PROJECT_ROOT}/cmake-build-ubuntu22-release}" BUILD_DIR="${1:-${PROJECT_ROOT}/cmake-build-ubuntu22-release}"
DATA_ROOT="${2:-${PROJECT_ROOT}/../2}" DATA_ROOT="${2:-${PROJECT_ROOT}/../2}"
# CLI 会自行在该根目录下创建 output/outputN。
OUT_DIR="${PROJECT_ROOT}/output" OUT_DIR="${PROJECT_ROOT}/output"
EXE="${BUILD_DIR}/instrument_reader_cli" EXE="${BUILD_DIR}/instrument_reader_cli"
# 二进制文件缺失时立即失败,并给出构建命令。
if [[ ! -x "${EXE}" ]]; then if [[ ! -x "${EXE}" ]]; then
echo "instrument_reader_cli not found. Build first:" >&2 echo "instrument_reader_cli not found. Build first:" >&2
echo " ${SCRIPT_DIR}/build_ubuntu22.sh" >&2 echo " ${SCRIPT_DIR}/build_ubuntu22.sh" >&2
exit 1 exit 1
fi fi
# 使用数据集中的空速表无指针底图进行分类和差分评分。
"${EXE}" \ "${EXE}" \
--input "${DATA_ROOT}/original_crops" \ --input "${DATA_ROOT}/original_crops" \
--input "${DATA_ROOT}/generated_scales/01_airspeed_indicator" \ --input "${DATA_ROOT}/generated_scales/01_airspeed_indicator" \
......
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# 安装最小原生工具链和 OpenCV 开发包。
sudo apt update sudo apt update
sudo apt install -y \ sudo apt install -y \
build-essential \ build-essential \
...@@ -10,6 +11,7 @@ sudo apt install -y \ ...@@ -10,6 +11,7 @@ sudo apt install -y \
pkg-config \ pkg-config \
libopencv-dev libopencv-dev
# 输出工具版本,便于从安装日志中发现环境问题。
echo "Ubuntu 22.04 build dependencies are ready." echo "Ubuntu 22.04 build dependencies are ready."
cmake --version cmake --version
pkg-config --modversion opencv4 || true pkg-config --modversion opencv4 || true
This diff is collapsed.
This diff is collapsed.
#include "instrument_reader/instrument_classifier.hpp" #include "instrument_reader/instrument_classifier.hpp"
#include <opencv2/imgcodecs.hpp> #include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp> #include <opencv2/imgproc.hpp>
...@@ -11,6 +11,7 @@ namespace instrument_reader { ...@@ -11,6 +11,7 @@ namespace instrument_reader {
namespace { namespace {
// 检查关键词前,先将路径字符串统一转换为小写。
std::string lowerCopy(std::string s) { std::string lowerCopy(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c)); return static_cast<char>(std::tolower(c));
...@@ -27,6 +28,9 @@ InstrumentClassifier::InstrumentClassifier(Config config) ...@@ -27,6 +28,9 @@ InstrumentClassifier::InstrumentClassifier(Config config)
: config_(config) {} : config_(config) {}
bool InstrumentClassifier::loadAirspeedTemplate(const std::filesystem::path& templatePath) { bool InstrumentClassifier::loadAirspeedTemplate(const std::filesystem::path& templatePath) {
// 模板加载是可选步骤;加载失败时,调用方仍可回退到路径提示。
// imread 会为解码后的模板分配临时像素缓冲区;setAirspeedTemplate()
// 提取紧凑的边缘图和掩码状态,并让它们在本函数返回后继续常驻。
cv::Mat img = cv::imread(templatePath.string(), cv::IMREAD_COLOR); cv::Mat img = cv::imread(templatePath.string(), cv::IMREAD_COLOR);
if (img.empty()) return false; if (img.empty()) return false;
setAirspeedTemplate(img); setAirspeedTemplate(img);
...@@ -35,6 +39,7 @@ bool InstrumentClassifier::loadAirspeedTemplate(const std::filesystem::path& tem ...@@ -35,6 +39,7 @@ bool InstrumentClassifier::loadAirspeedTemplate(const std::filesystem::path& tem
void InstrumentClassifier::setAirspeedTemplate(const cv::Mat& bgrTemplate) { void InstrumentClassifier::setAirspeedTemplate(const cv::Mat& bgrTemplate) {
if (bgrTemplate.empty() || bgrTemplate.type() != CV_8UC3) { if (bgrTemplate.empty() || bgrTemplate.type() != CV_8UC3) {
// 模板无效时重置到已知的回退状态。
ready_ = false; ready_ = false;
templateEdge32_.release(); templateEdge32_.release();
mask8_.release(); mask8_.release();
...@@ -42,26 +47,35 @@ void InstrumentClassifier::setAirspeedTemplate(const cv::Mat& bgrTemplate) { ...@@ -42,26 +47,35 @@ void InstrumentClassifier::setAirspeedTemplate(const cv::Mat& bgrTemplate) {
} }
targetSize_ = bgrTemplate.size(); targetSize_ = bgrTemplate.size();
// 只预计算一次边缘模板和掩码,使 classify() 保持轻量。
// 这些 cv::Mat 在分类器生命周期内持有各自的堆像素缓冲区。
templateEdge32_ = prepareEdgeForClassification(bgrTemplate, targetSize_); templateEdge32_ = prepareEdgeForClassification(bgrTemplate, targetSize_);
mask8_ = makeClassificationMask(targetSize_, config_.airspeedGeometry); mask8_ = makeClassificationMask(targetSize_, config_.airspeedGeometry);
ready_ = true; ready_ = true;
} }
ClassificationResult InstrumentClassifier::classify(const std::filesystem::path& path, const cv::Mat& bgr) const { ClassificationResult InstrumentClassifier::classify(const std::filesystem::path& path, const cv::Mat& bgr) const {
// ClassificationResult 只保存标量元数据,不会让图像数据离开本函数。
ClassificationResult result; ClassificationResult result;
result.type = InstrumentType::OtherInstrument; result.type = InstrumentType::OtherInstrument;
// 即使视觉分数处于阈值边缘,文件名提示仍可让生成数据集正常使用。
std::string pathLower = lowerCopy(path.string()); std::string pathLower = lowerCopy(path.string());
result.pathHint = pathLower.find("airspeed") != std::string::npos || result.pathHint = pathLower.find("airspeed") != std::string::npos ||
pathLower.find("01_airspeed_indicator") != std::string::npos; pathLower.find("01_airspeed_indicator") != std::string::npos;
if (ready_ && !bgr.empty()) { if (ready_ && !bgr.empty()) {
// candidateEdge 是逐图像临时缓冲区,分类完成后即被释放。
cv::Mat candidateEdge = prepareEdgeForClassification(bgr, targetSize_); cv::Mat candidateEdge = prepareEdgeForClassification(bgr, targetSize_);
result.visualScore = maskedNcc(candidateEdge, templateEdge32_, mask8_); result.visualScore = maskedNcc(candidateEdge, templateEdge32_, mask8_);
// 强视觉匹配可以直接通过;路径提示只降低阈值,并不会跳过视觉匹配。
result.isAirspeed = result.visualScore >= config_.airspeedVisualThreshold || result.isAirspeed = result.visualScore >= config_.airspeedVisualThreshold ||
(result.pathHint && result.visualScore >= config_.airspeedVisualThreshold * config_.pathHintThresholdFactor); (result.pathHint && result.visualScore >= config_.airspeedVisualThreshold * config_.pathHintThresholdFactor);
result.confidence = std::max(result.visualScore, result.pathHint ? 0.55 : 0.0); result.confidence = std::max(result.visualScore, result.pathHint ? 0.55 : 0.0);
} else { } else {
// 没有模板时只根据路径提示进行路由,并将置信度标记为启发式结果。
result.isAirspeed = result.pathHint; result.isAirspeed = result.pathHint;
result.confidence = result.pathHint ? 0.55 : 0.0; result.confidence = result.pathHint ? 0.55 : 0.0;
} }
...@@ -71,15 +85,19 @@ ClassificationResult InstrumentClassifier::classify(const std::filesystem::path& ...@@ -71,15 +85,19 @@ ClassificationResult InstrumentClassifier::classify(const std::filesystem::path&
} }
cv::Mat InstrumentClassifier::prepareEdgeForClassification(const cv::Mat& bgr, cv::Size targetSize) { cv::Mat InstrumentClassifier::prepareEdgeForClassification(const cv::Mat& bgr, cv::Size targetSize) {
// 以下每个阶段都拥有临时像素缓冲区。按值返回 edge 的成本很低,
// 因为 cv::Mat 会移动或共享引用计数管理的存储。
cv::Mat resized; cv::Mat resized;
cv::resize(bgr, resized, targetSize, 0, 0, cv::INTER_AREA); cv::resize(bgr, resized, targetSize, 0, 0, cv::INTER_AREA);
// 提取边缘前使用 CLAHE 归一化照明差异。
cv::Mat gray; cv::Mat gray;
cv::cvtColor(resized, gray, cv::COLOR_BGR2GRAY); cv::cvtColor(resized, gray, cv::COLOR_BGR2GRAY);
cv::Ptr<cv::CLAHE> clahe = cv::createCLAHE(2.0, cv::Size(8, 8)); cv::Ptr<cv::CLAHE> clahe = cv::createCLAHE(2.0, cv::Size(8, 8));
clahe->apply(gray, gray); clahe->apply(gray, gray);
cv::GaussianBlur(gray, gray, cv::Size(3, 3), 0); cv::GaussianBlur(gray, gray, cv::Size(3, 3), 0);
// 对同一表盘布局进行匹配时,边缘特征比原始亮度更稳健。
cv::Mat edge; cv::Mat edge;
cv::Canny(gray, edge, 60, 160); cv::Canny(gray, edge, 60, 160);
edge.convertTo(edge, CV_32F, 1.0 / 255.0); edge.convertTo(edge, CV_32F, 1.0 / 255.0);
...@@ -87,6 +105,7 @@ cv::Mat InstrumentClassifier::prepareEdgeForClassification(const cv::Mat& bgr, c ...@@ -87,6 +105,7 @@ cv::Mat InstrumentClassifier::prepareEdgeForClassification(const cv::Mat& bgr, c
} }
cv::Mat InstrumentClassifier::makeClassificationMask(cv::Size size, const ReaderGeometry& geometry) { cv::Mat InstrumentClassifier::makeClassificationMask(cv::Size size, const ReaderGeometry& geometry) {
// 将参考几何参数缩放到模板或候选图像尺寸。
float sx = static_cast<float>(size.width) / std::max(1.0f, geometry.referenceSize.width); float sx = static_cast<float>(size.width) / std::max(1.0f, geometry.referenceSize.width);
float sy = static_cast<float>(size.height) / std::max(1.0f, geometry.referenceSize.height); float sy = static_cast<float>(size.height) / std::max(1.0f, geometry.referenceSize.height);
float s = 0.5f * (sx + sy); float s = 0.5f * (sx + sy);
...@@ -94,7 +113,10 @@ cv::Mat InstrumentClassifier::makeClassificationMask(cv::Size size, const Reader ...@@ -94,7 +113,10 @@ cv::Mat InstrumentClassifier::makeClassificationMask(cv::Size size, const Reader
int radius = std::max(1, cvRound(geometry.radius * s)); int radius = std::max(1, cvRound(geometry.radius * s));
int inner = std::max(1, cvRound(geometry.radius * 0.18f * s)); int inner = std::max(1, cvRound(geometry.radius * 0.18f * s));
// 掩码每个像素占 1 字节,并按模板尺寸进行缓存。
cv::Mat mask(size, CV_8U, cv::Scalar(0)); cv::Mat mask(size, CV_8U, cv::Scalar(0));
// 保留表盘圆环区域,去除指针位置会变化的中心轴区域。
cv::circle(mask, center, radius, cv::Scalar(255), -1, cv::LINE_AA); cv::circle(mask, center, radius, cv::Scalar(255), -1, cv::LINE_AA);
cv::circle(mask, center, inner, cv::Scalar(0), -1, cv::LINE_AA); cv::circle(mask, center, inner, cv::Scalar(0), -1, cv::LINE_AA);
cv::erode(mask, mask, cv::Mat(), cv::Point(-1, -1), 1); cv::erode(mask, mask, cv::Mat(), cv::Point(-1, -1), 1);
...@@ -106,6 +128,7 @@ double InstrumentClassifier::maskedNcc(const cv::Mat& a32, const cv::Mat& b32, c ...@@ -106,6 +128,7 @@ double InstrumentClassifier::maskedNcc(const cv::Mat& a32, const cv::Mat& b32, c
double sumB = 0.0; double sumB = 0.0;
int n = 0; int n = 0;
// 第一遍扫描计算掩码区域内的均值。
for (int y = 0; y < a32.rows; ++y) { for (int y = 0; y < a32.rows; ++y) {
const float* a = a32.ptr<float>(y); const float* a = a32.ptr<float>(y);
const float* b = b32.ptr<float>(y); const float* b = b32.ptr<float>(y);
...@@ -124,6 +147,8 @@ double InstrumentClassifier::maskedNcc(const cv::Mat& a32, const cv::Mat& b32, c ...@@ -124,6 +147,8 @@ double InstrumentClassifier::maskedNcc(const cv::Mat& a32, const cv::Mat& b32, c
double dot = 0.0; double dot = 0.0;
double normA = 0.0; double normA = 0.0;
double normB = 0.0; double normB = 0.0;
// 第二遍扫描在同一掩码区域内计算归一化互相关。
for (int y = 0; y < a32.rows; ++y) { for (int y = 0; y < a32.rows; ++y) {
const float* a = a32.ptr<float>(y); const float* a = a32.ptr<float>(y);
const float* b = b32.ptr<float>(y); const float* b = b32.ptr<float>(y);
......
#include "instrument_reader/lut_mapper.hpp" #include "instrument_reader/lut_mapper.hpp"
#include "instrument_reader/angle_math.hpp" #include "instrument_reader/angle_math.hpp"
...@@ -14,6 +14,7 @@ namespace instrument_reader { ...@@ -14,6 +14,7 @@ namespace instrument_reader {
namespace { namespace {
// 保持各工具之间序列化边界策略名称的稳定性。
std::string policyToString(BoundaryPolicy policy) { std::string policyToString(BoundaryPolicy policy) {
return policy == BoundaryPolicy::Clamp ? "clamp" : "reject"; return policy == BoundaryPolicy::Clamp ? "clamp" : "reject";
} }
...@@ -25,6 +26,8 @@ LutMapper::LutMapper(double startAngleDeg) ...@@ -25,6 +26,8 @@ LutMapper::LutMapper(double startAngleDeg)
void LutMapper::setStartAngle(double startAngleDeg) { void LutMapper::setStartAngle(double startAngleDeg) {
startAngleDeg_ = normalize360(startAngleDeg); startAngleDeg_ = normalize360(startAngleDeg);
// 相对角度依赖角度原点,因此需要重新计算所有缓存值。
for (CalibrationPoint& p : points_) { for (CalibrationPoint& p : points_) {
p.thetaRelDeg = relativeFromStart(p.thetaAbsDeg, startAngleDeg_); p.thetaRelDeg = relativeFromStart(p.thetaAbsDeg, startAngleDeg_);
} }
...@@ -39,6 +42,8 @@ void LutMapper::addPoint(double value, double thetaAbsDeg) { ...@@ -39,6 +42,8 @@ void LutMapper::addPoint(double value, double thetaAbsDeg) {
CalibrationPoint p; CalibrationPoint p;
p.value = value; p.value = value;
p.thetaAbsDeg = normalize360(thetaAbsDeg); p.thetaAbsDeg = normalize360(thetaAbsDeg);
// thetaRelDeg 是插值时使用的排序键和查找键。
p.thetaRelDeg = relativeFromStart(thetaAbsDeg, startAngleDeg_); p.thetaRelDeg = relativeFromStart(thetaAbsDeg, startAngleDeg_);
points_.push_back(p); points_.push_back(p);
sortByRelativeAngle(); sortByRelativeAngle();
...@@ -49,6 +54,8 @@ bool LutMapper::validate(std::string* errorMessage) const { ...@@ -49,6 +54,8 @@ bool LutMapper::validate(std::string* errorMessage) const {
if (errorMessage) *errorMessage = "LUT needs at least two points"; if (errorMessage) *errorMessage = "LUT needs at least two points";
return false; return false;
} }
// 为保证插值安全,物理值和相对角度都必须严格递增。
for (size_t i = 1; i < points_.size(); ++i) { for (size_t i = 1; i < points_.size(); ++i) {
if (points_[i].thetaRelDeg <= points_[i - 1].thetaRelDeg) { if (points_[i].thetaRelDeg <= points_[i - 1].thetaRelDeg) {
if (errorMessage) *errorMessage = "theta_rel_deg must be strictly increasing"; if (errorMessage) *errorMessage = "theta_rel_deg must be strictly increasing";
...@@ -66,6 +73,7 @@ bool LutMapper::saveJson(const std::filesystem::path& path) const { ...@@ -66,6 +73,7 @@ bool LutMapper::saveJson(const std::filesystem::path& path) const {
std::ofstream out(path, std::ios::binary); std::ofstream out(path, std::ios::binary);
if (!out) return false; if (!out) return false;
// 输出足够的小数精度,以保存手工测量的标定节点。
out << "{\n"; out << "{\n";
out << " \"type\": \"instrument_lut_v1\",\n"; out << " \"type\": \"instrument_lut_v1\",\n";
out << " \"start_angle_deg\": " << std::fixed << std::setprecision(10) << startAngleDeg_ << ",\n"; out << " \"start_angle_deg\": " << std::fixed << std::setprecision(10) << startAngleDeg_ << ",\n";
...@@ -92,8 +100,12 @@ bool LutMapper::loadJson(const std::filesystem::path& path, std::string* errorMe ...@@ -92,8 +100,12 @@ bool LutMapper::loadJson(const std::filesystem::path& path, std::string* errorMe
} }
std::stringstream buffer; std::stringstream buffer;
buffer << in.rdbuf(); buffer << in.rdbuf();
// 将整个 LUT 文件读入一个字符串。LUT 是很小的标定文件,
// 与逐个令牌流式解析相比,这种方式更简单,开销也更低。
std::string text = buffer.str(); std::string text = buffer.str();
// 正则解析刻意只匹配本项目的 LUT 结构,避免接受模糊格式。
std::smatch match; std::smatch match;
std::regex startRegex(R"json("start_angle_deg"\s*:\s*([-+0-9.eE]+))json"); std::regex startRegex(R"json("start_angle_deg"\s*:\s*([-+0-9.eE]+))json");
if (std::regex_search(text, match, startRegex)) { if (std::regex_search(text, match, startRegex)) {
...@@ -105,12 +117,17 @@ bool LutMapper::loadJson(const std::filesystem::path& path, std::string* errorMe ...@@ -105,12 +117,17 @@ bool LutMapper::loadJson(const std::filesystem::path& path, std::string* errorMe
std::regex policyRegex(R"json("boundary_policy"\s*:\s*"([^"]+)")json"); std::regex policyRegex(R"json("boundary_policy"\s*:\s*"([^"]+)")json");
if (std::regex_search(text, match, policyRegex)) { if (std::regex_search(text, match, policyRegex)) {
// 未知策略字符串为保证安全,统一回退为 Reject。
boundaryPolicy_ = match[1].str() == "clamp" ? BoundaryPolicy::Clamp : BoundaryPolicy::Reject; boundaryPolicy_ = match[1].str() == "clamp" ? BoundaryPolicy::Clamp : BoundaryPolicy::Reject;
} }
std::vector<CalibrationPoint> loaded; std::vector<CalibrationPoint> loaded;
// 同时兼容旧版 "airspeed" 字段名和当前通用的 "value" 字段名。
std::regex pointRegex( std::regex pointRegex(
R"json(\{[^\}]*"(?:value|airspeed)"\s*:\s*([-+0-9.eE]+)[^\}]*"theta_abs_deg"\s*:\s*([-+0-9.eE]+)[^\}]*\})json"); R"json(\{[^\}]*"(?:value|airspeed)"\s*:\s*([-+0-9.eE]+)[^\}]*"theta_abs_deg"\s*:\s*([-+0-9.eE]+)[^\}]*\})json");
// loaded 是临时堆存储;完成验证准备后,由 points_ 接管其内存。
for (std::sregex_iterator it(text.begin(), text.end(), pointRegex), end; it != end; ++it) { for (std::sregex_iterator it(text.begin(), text.end(), pointRegex), end; it != end; ++it) {
CalibrationPoint p; CalibrationPoint p;
p.value = std::stod((*it)[1].str()); p.value = std::stod((*it)[1].str());
...@@ -120,12 +137,17 @@ bool LutMapper::loadJson(const std::filesystem::path& path, std::string* errorMe ...@@ -120,12 +137,17 @@ bool LutMapper::loadJson(const std::filesystem::path& path, std::string* errorMe
} }
points_ = std::move(loaded); points_ = std::move(loaded);
// 加载后再排序,因此 JSON 中的标定节点可以按任意顺序书写。
sortByRelativeAngle(); sortByRelativeAngle();
return validate(errorMessage); return validate(errorMessage);
} }
MapResult LutMapper::map(double thetaAbsDeg) const { MapResult LutMapper::map(double thetaAbsDeg) const {
// MapResult 按值返回,只包含少量 double 和字符串字段。
MapResult result; MapResult result;
// 查找前,先将指针绝对角度转换为相对 LUT 起点的扫描角度。
result.thetaAbsDeg = normalize360(thetaAbsDeg); result.thetaAbsDeg = normalize360(thetaAbsDeg);
result.thetaRelDeg = relativeFromStart(thetaAbsDeg, startAngleDeg_); result.thetaRelDeg = relativeFromStart(thetaAbsDeg, startAngleDeg_);
...@@ -140,6 +162,8 @@ MapResult LutMapper::map(double thetaAbsDeg) const { ...@@ -140,6 +162,8 @@ MapResult LutMapper::map(double thetaAbsDeg) const {
const CalibrationPoint& last = points_.back(); const CalibrationPoint& last = points_.back();
if (result.thetaRelDeg < first.thetaRelDeg) { if (result.thetaRelDeg < first.thetaRelDeg) {
// first.thetaRelDeg 为 0 时通常不会进入此分支,
// 但保留它可以让映射器正确支持任意起始角度。
result.left = first; result.left = first;
result.right = first; result.right = first;
if (boundaryPolicy_ == BoundaryPolicy::Clamp) { if (boundaryPolicy_ == BoundaryPolicy::Clamp) {
...@@ -156,6 +180,8 @@ MapResult LutMapper::map(double thetaAbsDeg) const { ...@@ -156,6 +180,8 @@ MapResult LutMapper::map(double thetaAbsDeg) const {
if (result.thetaRelDeg > last.thetaRelDeg) { if (result.thetaRelDeg > last.thetaRelDeg) {
if (boundaryPolicy_ == BoundaryPolicy::Clamp) { if (boundaryPolicy_ == BoundaryPolicy::Clamp) {
result.valid = true; result.valid = true;
// 在跨越 360 度零点附近钳制时,选择距离最近的标定扫描端点。
const double distanceToMax = result.thetaRelDeg - last.thetaRelDeg; const double distanceToMax = result.thetaRelDeg - last.thetaRelDeg;
const double distanceToMinAcrossStart = 360.0 - result.thetaRelDeg + first.thetaRelDeg; const double distanceToMinAcrossStart = 360.0 - result.thetaRelDeg + first.thetaRelDeg;
if (distanceToMinAcrossStart < distanceToMax) { if (distanceToMinAcrossStart < distanceToMax) {
...@@ -178,6 +204,7 @@ MapResult LutMapper::map(double thetaAbsDeg) const { ...@@ -178,6 +204,7 @@ MapResult LutMapper::map(double thetaAbsDeg) const {
return result; return result;
} }
// 使用二分查找定位当前分段线性区间的右端点。
auto rightIt = std::lower_bound(points_.begin(), points_.end(), result.thetaRelDeg, auto rightIt = std::lower_bound(points_.begin(), points_.end(), result.thetaRelDeg,
[](const CalibrationPoint& point, double thetaRelDeg) { [](const CalibrationPoint& point, double thetaRelDeg) {
return point.thetaRelDeg < thetaRelDeg; return point.thetaRelDeg < thetaRelDeg;
...@@ -201,6 +228,8 @@ MapResult LutMapper::map(double thetaAbsDeg) const { ...@@ -201,6 +228,8 @@ MapResult LutMapper::map(double thetaAbsDeg) const {
const CalibrationPoint& left = *std::prev(rightIt); const CalibrationPoint& left = *std::prev(rightIt);
const CalibrationPoint& right = *rightIt; const CalibrationPoint& right = *rightIt;
const double denom = right.thetaRelDeg - left.thetaRelDeg; const double denom = right.thetaRelDeg - left.thetaRelDeg;
// 线性插值公式:value = left + t * (right - left)。
const double t = denom > 0.0 ? (result.thetaRelDeg - left.thetaRelDeg) / denom : 0.0; const double t = denom > 0.0 ? (result.thetaRelDeg - left.thetaRelDeg) / denom : 0.0;
result.valid = true; result.valid = true;
result.value = left.value + t * (right.value - left.value); result.value = left.value + t * (right.value - left.value);
...@@ -211,6 +240,7 @@ MapResult LutMapper::map(double thetaAbsDeg) const { ...@@ -211,6 +240,7 @@ MapResult LutMapper::map(double thetaAbsDeg) const {
} }
void LutMapper::sortByRelativeAngle() { void LutMapper::sortByRelativeAngle() {
// map() 中的 lower_bound 依赖此排序顺序。
std::sort(points_.begin(), points_.end(), [](const CalibrationPoint& a, const CalibrationPoint& b) { std::sort(points_.begin(), points_.end(), [](const CalibrationPoint& a, const CalibrationPoint& b) {
return a.thetaRelDeg < b.thetaRelDeg; return a.thetaRelDeg < b.thetaRelDeg;
}); });
......
#include "instrument_reader/non_linear_gauge_reader.hpp" #include "instrument_reader/non_linear_gauge_reader.hpp"
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
...@@ -10,12 +10,15 @@ namespace instrument_reader::nonlinear { ...@@ -10,12 +10,15 @@ namespace instrument_reader::nonlinear {
namespace { namespace {
// 标定节点必须是有限数值,才能安全排序和预计算斜率。
bool isFinite(CalibrationPoint point) noexcept { return std::isfinite(point.angle) && std::isfinite(point.value); } bool isFinite(CalibrationPoint point) noexcept { return std::isfinite(point.angle) && std::isfinite(point.value); }
} // namespace } // namespace
NonLinearGaugeReader::NonLinearGaugeReader(std::vector<CalibrationPoint> calibration, BoundaryMode boundaryMode) NonLinearGaugeReader::NonLinearGaugeReader(std::vector<CalibrationPoint> calibration, BoundaryMode boundaryMode)
: calibration_(std::move(calibration)), boundaryMode_(boundaryMode) { : calibration_(std::move(calibration)), boundaryMode_(boundaryMode) {
// calibration 按值传入,因此调用方可以传入临时对象;在条件允许时,
// move() 会将 vector 的堆存储直接转移给读数器,避免再次复制。
if (calibration_.size() < 2) { if (calibration_.size() < 2) {
throw std::invalid_argument("NonLinearGaugeReader requires at least two calibration points"); throw std::invalid_argument("NonLinearGaugeReader requires at least two calibration points");
} }
...@@ -24,7 +27,7 @@ NonLinearGaugeReader::NonLinearGaugeReader(std::vector<CalibrationPoint> calibra ...@@ -24,7 +27,7 @@ NonLinearGaugeReader::NonLinearGaugeReader(std::vector<CalibrationPoint> calibra
throw std::invalid_argument("NonLinearGaugeReader calibration contains non-finite data"); throw std::invalid_argument("NonLinearGaugeReader calibration contains non-finite data");
} }
// Sorting once at construction keeps the high-frequency read path branch-light. // 构造时只排序一次,使高频读数路径保持较少分支。
std::sort(calibration_.begin(), calibration_.end(), std::sort(calibration_.begin(), calibration_.end(),
[](const CalibrationPoint& a, const CalibrationPoint& b) { return a.angle < b.angle; }); [](const CalibrationPoint& a, const CalibrationPoint& b) { return a.angle < b.angle; });
...@@ -33,14 +36,20 @@ NonLinearGaugeReader::NonLinearGaugeReader(std::vector<CalibrationPoint> calibra ...@@ -33,14 +36,20 @@ NonLinearGaugeReader::NonLinearGaugeReader(std::vector<CalibrationPoint> calibra
const CalibrationPoint& left = calibration_[i - 1]; const CalibrationPoint& left = calibration_[i - 1];
const CalibrationPoint& right = calibration_[i]; const CalibrationPoint& right = calibration_[i];
const float deltaAngle = right.angle - left.angle; const float deltaAngle = right.angle - left.angle;
// 重复或反向角度会使该分段的斜率无定义。
if (deltaAngle <= 0.0f) { if (deltaAngle <= 0.0f) {
throw std::invalid_argument("NonLinearGaugeReader calibration angles must be strictly increasing"); throw std::invalid_argument("NonLinearGaugeReader calibration angles must be strictly increasing");
} }
// 提前完成除法,使 calculate_value() 热路径只需要执行乘法。
slopes_.push_back((right.value - left.value) / deltaAngle); slopes_.push_back((right.value - left.value) / deltaAngle);
} }
} }
float NonLinearGaugeReader::calculate_value(float current_angle) const noexcept { float NonLinearGaugeReader::calculate_value(float current_angle) const noexcept {
// 此热路径不会分配堆内存,只读取 calibration_ 和 slopes_。
// 检测器输出非有限值时应继续返回 NaN,而不是错误地钳制为端点值。
if (!std::isfinite(current_angle)) { if (!std::isfinite(current_angle)) {
return std::numeric_limits<float>::quiet_NaN(); return std::numeric_limits<float>::quiet_NaN();
} }
...@@ -49,18 +58,22 @@ float NonLinearGaugeReader::calculate_value(float current_angle) const noexcept ...@@ -49,18 +58,22 @@ float NonLinearGaugeReader::calculate_value(float current_angle) const noexcept
const CalibrationPoint& last = calibration_.back(); const CalibrationPoint& last = calibration_.back();
if (current_angle <= first.angle) { if (current_angle <= first.angle) {
// 钳制对控制循环最安全;外推模式保留给标定和分析工具使用。
return boundaryMode_ == BoundaryMode::Clamp ? first.value : interpolateSegment(0, current_angle); return boundaryMode_ == BoundaryMode::Clamp ? first.value : interpolateSegment(0, current_angle);
} }
if (current_angle >= last.angle) { if (current_angle >= last.angle) {
// 仅在调用方明确要求时,使用最后一个标定分段的斜率向外推算。
return boundaryMode_ == BoundaryMode::Clamp ? last.value : interpolateSegment(slopes_.size() - 1, current_angle); return boundaryMode_ == BoundaryMode::Clamp ? last.value : interpolateSegment(slopes_.size() - 1, current_angle);
} }
// 查找第一个角度不小于 current_angle 的标定节点。
const auto upper = std::lower_bound(calibration_.begin(), calibration_.end(), current_angle, const auto upper = std::lower_bound(calibration_.begin(), calibration_.end(), current_angle,
[](const CalibrationPoint& point, float angle) { return point.angle < angle; }); [](const CalibrationPoint& point, float angle) { return point.angle < angle; });
const std::size_t rightIndex = static_cast<std::size_t>(upper - calibration_.begin()); const std::size_t rightIndex = static_cast<std::size_t>(upper - calibration_.begin());
if (upper->angle == current_angle) { if (upper->angle == current_angle) {
// 精确命中标定节点时直接返回,避免产生细小的浮点插值漂移。
return upper->value; return upper->value;
} }
...@@ -69,6 +82,8 @@ float NonLinearGaugeReader::calculate_value(float current_angle) const noexcept ...@@ -69,6 +82,8 @@ float NonLinearGaugeReader::calculate_value(float current_angle) const noexcept
float NonLinearGaugeReader::interpolateSegment(std::size_t leftIndex, float angle) const noexcept { float NonLinearGaugeReader::interpolateSegment(std::size_t leftIndex, float angle) const noexcept {
const CalibrationPoint& left = calibration_[leftIndex]; const CalibrationPoint& left = calibration_[leftIndex];
// 公式:value = left.value + (angle - left.angle) * 预计算斜率
return left.value + (angle - left.angle) * slopes_[leftIndex]; return left.value + (angle - left.angle) * slopes_[leftIndex];
} }
......
#include "instrument_reader/types.hpp" #include "instrument_reader/types.hpp"
namespace instrument_reader { namespace instrument_reader {
int DispatchSummary::airspeedCount() const { int DispatchSummary::airspeedCount() const {
int count = 0; int count = 0;
// 统计被路由到空速表读数器的图像,不要求物理值有效。
for (const ProcessResult& r : results) { for (const ProcessResult& r : results) {
if (r.classification.type == InstrumentType::AirspeedIndicator) ++count; if (r.classification.type == InstrumentType::AirspeedIndicator) ++count;
} }
...@@ -12,6 +14,8 @@ int DispatchSummary::airspeedCount() const { ...@@ -12,6 +14,8 @@ int DispatchSummary::airspeedCount() const {
int DispatchSummary::validAirspeedCount() const { int DispatchSummary::validAirspeedCount() const {
int count = 0; int count = 0;
// 有效空速要求物理值映射成功,不能只有角度检测成功。
for (const ProcessResult& r : results) { for (const ProcessResult& r : results) {
if (r.classification.type == InstrumentType::AirspeedIndicator && r.airspeedValueValid) ++count; if (r.classification.type == InstrumentType::AirspeedIndicator && r.airspeedValueValid) ++count;
} }
...@@ -20,6 +24,8 @@ int DispatchSummary::validAirspeedCount() const { ...@@ -20,6 +24,8 @@ int DispatchSummary::validAirspeedCount() const {
int DispatchSummary::skippedCount() const { int DispatchSummary::skippedCount() const {
int count = 0; int count = 0;
// 跳过表示图像被分类为其他仪表类型。
for (const ProcessResult& r : results) { for (const ProcessResult& r : results) {
if (r.classification.type != InstrumentType::AirspeedIndicator) ++count; if (r.classification.type != InstrumentType::AirspeedIndicator) ++count;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment