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

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

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