Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
I
instrument_reader_cpp
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
唐永康
instrument_reader_cpp
Commits
95f55843
Commit
95f55843
authored
Jun 05, 2026
by
唐永康
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
添加给浩天的注释,后续对接
parent
d7c16b1f
Show whitespace changes
Inline
Side-by-side
Showing
25 changed files
with
567 additions
and
29 deletions
+567
-29
CMakeLists.txt
CMakeLists.txt
+19
-0
airspeed_lut_cli.cpp
apps/airspeed_lut_cli.cpp
+12
-1
clion_demo.cpp
apps/clion_demo.cpp
+11
-1
instrument_reader_cli.cpp
apps/instrument_reader_cli.cpp
+18
-1
non_linear_gauge_demo.cpp
apps/non_linear_gauge_demo.cpp
+22
-1
airspeed_reader.hpp
include/instrument_reader/airspeed_reader.hpp
+60
-1
angle_math.hpp
include/instrument_reader/angle_math.hpp
+6
-1
dispatcher.hpp
include/instrument_reader/dispatcher.hpp
+29
-1
instrument_classifier.hpp
include/instrument_reader/instrument_classifier.hpp
+23
-1
lut_mapper.hpp
include/instrument_reader/lut_mapper.hpp
+25
-1
non_linear_gauge_reader.hpp
include/instrument_reader/non_linear_gauge_reader.hpp
+21
-8
types.hpp
include/instrument_reader/types.hpp
+72
-2
build_msvc.ps1
scripts/build_msvc.ps1
+14
-1
build_ubuntu22.sh
scripts/build_ubuntu22.sh
+6
-0
open_clion.ps1
scripts/open_clion.ps1
+8
-1
open_clion.sh
scripts/open_clion.sh
+3
-0
run_demo.ps1
scripts/run_demo.ps1
+9
-1
run_demo_ubuntu22.sh
scripts/run_demo_ubuntu22.sh
+7
-0
setup_ubuntu22.sh
scripts/setup_ubuntu22.sh
+2
-0
airspeed_reader.cpp
src/airspeed_reader.cpp
+68
-1
dispatcher.cpp
src/dispatcher.cpp
+51
-1
instrument_classifier.cpp
src/instrument_classifier.cpp
+26
-1
lut_mapper.cpp
src/lut_mapper.cpp
+31
-1
non_linear_gauge_reader.cpp
src/non_linear_gauge_reader.cpp
+17
-2
types.cpp
src/types.cpp
+7
-1
No files found.
CMakeLists.txt
View file @
95f55843
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
)
apps/airspeed_lut_cli.cpp
View file @
95f55843
#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
"
;
...
...
apps/clion_demo.cpp
View file @
95f55843
#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
);
...
...
apps/instrument_reader_cli.cpp
View file @
95f55843
#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
"
;
...
...
apps/non_linear_gauge_demo.cpp
View file @
95f55843
#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.14159265358979323846
f
;
// 为演示产物创建下一个编号的输出目录。
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.0
f
;
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.0
f
;
// 浅色背景便于脱离真实图像集检查演示输出。
// 这是该演示唯一分配的完整图像缓冲区。
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.0
f
,
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.0
f
,
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.1
f
,
80.0
f
},
{
406.1
f
,
140.0
f
},
...
...
@@ -118,8 +134,11 @@ int main() {
{
586.5
f
,
300.0
f
},
};
// 读数器复制或移动该小型标定表并预计算斜率,此过程不涉及图像内存。
const
NonLinearGaugeReader
reader
(
airspeedCalibration
,
BoundaryMode
::
Clamp
);
const
float
thetaFromYoloDeg
=
35.99
f
;
// 80-300 的扫描范围跨越 360 度零点,因此需要展开 YOLO 输出的模 360 角度。
const
float
thetaUnwrappedDeg
=
thetaFromYoloDeg
<
airspeedCalibration
.
front
().
angle
?
thetaFromYoloDeg
+
360.0
f
:
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
"
...
...
include/instrument_reader/airspeed_reader.hpp
View file @
95f55843
#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.0
f
;
float
endAngleDeg
=
360.0
f
;
float
angleStepDeg
=
0.5
f
;
// 射线采样点从 innerRadius 沿半径方向延伸到 radius。
int
radialSamples
=
128
;
// 横向采样使每条射线能够容忍指针宽度。
int
lateralSamples
=
5
;
float
rayWidthPx
=
14.0
f
;
// 单个采样点被视为指针证据时所需的最低信号阈值。
float
signalThreshold
=
0.42
f
;
// 拒绝过短或过于稀疏、无法构成真实指针的检测结果。
float
minContinuousFraction
=
0.30
f
;
float
minCoverageFraction
=
0.18
f
;
// 为候选不明确情况保留的诊断阈值。
float
minDominanceGap
=
0.004
f
;
// 允许文字、刻度等造成少量间断,而不立即切断整条指针线。
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.0
f
;
// 在堆上拥有预计算像素坐标。该数据会被缓存,避免每帧重复分配
// 内存并重新计算射线几何。
// 单条射线的近似数据量:
// 数据量公式: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_
;
};
...
...
include/instrument_reader/angle_math.hpp
View file @
95f55843
#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.0
f
);
if
(
out
<
0.0
f
)
out
+=
360.0
f
;
...
...
@@ -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
);
}
...
...
include/instrument_reader/dispatcher.hpp
View file @
95f55843
#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
;
};
...
...
include/instrument_reader/instrument_classifier.hpp
View file @
95f55843
#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_
;
};
...
...
include/instrument_reader/lut_mapper.hpp
View file @
95f55843
#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_
;
};
...
...
include/instrument_reader/non_linear_gauge_reader.hpp
View file @
95f55843
#pragma once
#
pragma
once
#include <cstddef>
#include <vector>
namespace
instrument_reader
::
nonlinear
{
// 通用非线性仪表使用的最小标定节点。
struct
CalibrationPoint
{
// 调用方所选连续角度坐标系中的角度。
float
angle
=
0.0
f
;
// 该角度对应的物理读数。
float
value
=
0.0
f
;
};
// 通用插值器处理超出标定范围角度时的行为。
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
;
};
...
...
include/instrument_reader/types.hpp
View file @
95f55843
#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.0
f
,
248.0
f
);
// 测量几何参数时使用的参考图像尺寸。
// 运行时会先按输入裁剪图尺寸进行等比例缩放,再执行射线采样。
cv
::
Size2f
referenceSize
=
cv
::
Size2f
(
400.0
f
,
445.0
f
);
// 搜索指针和刻度线时使用的外部采样半径。
float
radius
=
210.0
f
;
// 内部半径用于避开中心轴、螺钉和文字等干扰。
float
innerRadius
=
18.0
f
;
};
// 指针角度检测结果,尚未将角度转换成物理读数。
struct
AngleReading
{
// 大多数数值字段直接内联存放在对象本体中。
// 两个 std::string 在内容超过标准库的小字符串优化容量时,才会在堆上分配内存。
// 只有检测到足够连续的指针证据时才为 true。
bool
valid
=
false
;
// 字符串内部可能拥有堆内存,但正常成功读数时通常为空或很短,
// 不会成为每帧内存占用的主要部分。
// 可供下游控制逻辑和回归测试读取的机器状态码。
std
::
string
errorCode
=
"ERR_UNSET"
;
// 面向人的错误详情;与状态码分开,便于控制逻辑只判断稳定状态码。
std
::
string
errorMessage
;
// 归一化到 [0, 360) 的图像坐标系指针角度。
float
angleDegMod360
=
0.0
f
;
// 最优和次优射线分数,用于诊断指针候选不明确的情况。
float
score
=
0.0
f
;
float
secondAngleDegMod360
=
0.0
f
;
float
secondScore
=
0.0
f
;
float
confidenceGap
=
0.0
f
;
// 连续性指标用于排除不是真实指针的短高亮线段。
float
continuousLineLengthPx
=
0.0
f
;
float
continuousLineFraction
=
0.0
f
;
float
coverageFraction
=
0.0
f
;
};
// 轻量分类结果,在选择具体仪表读数器之前使用。
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
;
};
...
...
scripts/build_msvc.ps1
View file @
95f55843
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
"
scripts/build_ubuntu22.sh
View file @
95f55843
#!/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
scripts/open_clion.ps1
View file @
95f55843
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
"
scripts/open_clion.sh
View file @
95f55843
#!/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
...
...
scripts/run_demo.ps1
View file @
95f55843
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"
)
`
...
...
scripts/run_demo_ubuntu22.sh
View file @
95f55843
#!/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"
\
...
...
scripts/setup_ubuntu22.sh
View file @
95f55843
#!/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
src/airspeed_reader.cpp
View file @
95f55843
#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.0
f
,
std
::
min
(
1.0
f
,
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.0
f
,
g
.
referenceSize
.
width
);
float
sy
=
static_cast
<
float
>
(
imageSize
.
height
)
/
std
::
max
(
1.0
f
,
g
.
referenceSize
.
height
);
float
s
=
0.5
f
*
(
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.0
f
);
float
sum
=
0.0
f
;
// 中心加权采样优先强调指针中心线,同时仍允许数个像素的指针宽度
// 或少量标定误差。
for
(
int
i
=
0
;
i
<
count
;
++
i
)
{
float
t
=
count
>
1
?
-
1.0
f
+
2.0
f
*
static_cast
<
float
>
(
i
)
/
(
count
-
1
)
:
0.0
f
;
weights
[
static_cast
<
size_t
>
(
i
)]
=
std
::
exp
(
-
(
t
*
t
)
/
0.35
f
);
...
...
@@ -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.5
f
))
+
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.5
f
;
float
offset
=
(
lt
-
0.5
f
)
*
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
=
-
1e30
f
;
...
...
@@ -207,8 +252,11 @@ AngleReading AirspeedReader::readWithCache(const cv::Mat& bgr, const RayCacheEnt
int
secondIndex
=
-
1
;
float
bestRunSamples
=
0.0
f
;
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.0
f
;
...
...
@@ -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.90
f
*
(
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.70
f
*
coverageFraction
+
0.65
f
*
runFraction
+
0.15
f
*
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.0
f
;
...
...
@@ -283,11 +341,13 @@ AngleReading AirspeedReader::readWithCache(const cv::Mat& bgr, const RayCacheEnt
result
.
continuousLineFraction
=
result
.
continuousLineLengthPx
/
std
::
max
(
1.0
f
,
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
);
...
...
src/dispatcher.cpp
View file @
95f55843
#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
=
'_'
;
}
...
...
src/instrument_classifier.cpp
View file @
95f55843
#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.0
f
,
geometry
.
referenceSize
.
width
);
float
sy
=
static_cast
<
float
>
(
size
.
height
)
/
std
::
max
(
1.0
f
,
geometry
.
referenceSize
.
height
);
float
s
=
0.5
f
*
(
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.18
f
*
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
);
...
...
src/lut_mapper.cpp
View file @
95f55843
#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
;
});
...
...
src/non_linear_gauge_reader.cpp
View file @
95f55843
#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.0
f
)
{
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
];
}
...
...
src/types.cpp
View file @
95f55843
#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
;
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment