Commit e5121225 authored by 唐永康's avatar 唐永康

Initial commit

parents
BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 140
DerivePointerAlignment: false
PointerAlignment: Left
SortIncludes: false
build/
build_*/
cmake-build-*/
out/
output/
results/
*.exe
*.dll
*.lib
*.obj
*.pdb
*.ilk
*.log
*.user
*.suo
.vs/
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
<?xml version="1.0" encoding="UTF-8"?>
<module classpath="CMake" type="CPP_MODULE" version="4" />
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CMakePythonSetting">
<option name="pythonIntegrationState" value="YES" />
</component>
<component name="CMakeWorkspace" PROJECT_DIR="$PROJECT_DIR$" />
</project>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/instrument_reader_cpp.iml" filepath="$PROJECT_DIR$/.idea/instrument_reader_cpp.iml" />
</modules>
</component>
</project>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
\ No newline at end of file
cmake_minimum_required(VERSION 3.20)
project(instrument_reader_cpp VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
find_package(OpenCV REQUIRED)
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})
set(INSTRUMENT_READER_SAMPLE_ROOT "$ENV{INSTRUMENT_READER_SAMPLE_ROOT}" CACHE PATH "Sample data root containing original_crops and generated_scales" FORCE)
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/../2/original_crops")
set(INSTRUMENT_READER_SAMPLE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../2" CACHE PATH "Sample data root containing original_crops and generated_scales" FORCE)
endif()
endif()
add_library(instrument_reader_core
src/airspeed_reader.cpp
src/dispatcher.cpp
src/instrument_classifier.cpp
src/lut_mapper.cpp
src/types.cpp
)
target_include_directories(instrument_reader_core
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
${OpenCV_INCLUDE_DIRS}
)
target_link_libraries(instrument_reader_core
PUBLIC
${OpenCV_LIBS}
)
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(clion_demo apps/clion_demo.cpp)
target_link_libraries(clion_demo PRIVATE instrument_reader_core)
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}")
foreach(target_name instrument_reader_cli airspeed_lut_cli clion_demo)
set_target_properties(${target_name} PROPERTIES
VS_DEBUGGER_ENVIRONMENT "${INSTRUMENT_READER_RUN_ENV}"
)
endforeach()
else()
set(INSTRUMENT_READER_RUN_ENV "PATH=$ENV{PATH}")
endif()
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")
add_custom_target(run_demo
COMMAND ${CMAKE_COMMAND} -E make_directory "${INSTRUMENT_READER_OUTPUT_ROOT}"
COMMAND ${CMAKE_COMMAND} -E env "${INSTRUMENT_READER_RUN_ENV}"
$<TARGET_FILE:instrument_reader_cli>
--input "${INSTRUMENT_READER_SAMPLE_ROOT}/original_crops"
--input "${INSTRUMENT_READER_SAMPLE_ROOT}/generated_scales/01_airspeed_indicator"
--airspeed-template "${INSTRUMENT_READER_AIRSPEED_BASE}"
--airspeed-base "${INSTRUMENT_READER_AIRSPEED_BASE}"
--airspeed-lut "${CMAKE_CURRENT_SOURCE_DIR}/configs/airspeed_lut_example.json"
--out-dir "${INSTRUMENT_READER_OUTPUT_ROOT}"
DEPENDS instrument_reader_cli
VERBATIM
)
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}"
$<TARGET_FILE:instrument_reader_cli>
--input "${INSTRUMENT_READER_SAMPLE_ROOT}/original_crops"
--input "${INSTRUMENT_READER_SAMPLE_ROOT}/generated_scales/01_airspeed_indicator"
--airspeed-template "${INSTRUMENT_READER_AIRSPEED_BASE}"
--airspeed-base "${INSTRUMENT_READER_AIRSPEED_BASE}"
--airspeed-lut "${CMAKE_CURRENT_SOURCE_DIR}/configs/airspeed_lut_example.json"
--out-dir "${INSTRUMENT_READER_OUTPUT_ROOT}"
--no-overlays
DEPENDS instrument_reader_cli
VERBATIM
)
endif()
add_custom_target(run_lut_example
COMMAND ${CMAKE_COMMAND} -E env "${INSTRUMENT_READER_RUN_ENV}"
$<TARGET_FILE:airspeed_lut_cli>
--map "${CMAKE_CURRENT_SOURCE_DIR}/configs/airspeed_lut_example.json"
--theta 4.5
DEPENDS airspeed_lut_cli
VERBATIM
)
install(TARGETS instrument_reader_core instrument_reader_cli airspeed_lut_cli
RUNTIME DESTINATION bin
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
)
install(DIRECTORY include/ DESTINATION include)
{
"version": 3,
"cmakeMinimumRequired": {
"major": 3,
"minor": 20,
"patch": 0
},
"configurePresets": [
{
"name": "windows-msvc-debug",
"displayName": "Windows MSVC Debug",
"description": "Visual Studio Build Tools + OpenCV 4.9.0 MSVC build with debug symbols",
"generator": "NMake Makefiles",
"binaryDir": "${sourceDir}/cmake-build-msvc-debug",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"OpenCV_DIR": "C:/opencv/build/x64/vc16/lib"
}
},
{
"name": "windows-msvc-release",
"displayName": "Windows MSVC Release",
"description": "Visual Studio Build Tools + OpenCV 4.9.0 MSVC build",
"generator": "NMake Makefiles",
"binaryDir": "${sourceDir}/cmake-build-msvc-release",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"OpenCV_DIR": "C:/opencv/build/x64/vc16/lib"
}
},
{
"name": "ubuntu22-release",
"displayName": "Ubuntu 22.04 Release",
"description": "Ubuntu 22.04 system OpenCV from libopencv-dev",
"generator": "Unix Makefiles",
"binaryDir": "${sourceDir}/cmake-build-ubuntu22-release",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release"
}
}
],
"buildPresets": [
{
"name": "windows-msvc-debug",
"configurePreset": "windows-msvc-debug"
},
{
"name": "windows-msvc-release",
"configurePreset": "windows-msvc-release"
},
{
"name": "ubuntu22-release",
"configurePreset": "ubuntu22-release"
}
]
}
# Instrument Reader C++ Project
This is the C++ development project for traditional-CV instrument recognition.
The current production branch is:
1. Load images from one or more paths.
2. Classify whether each image is the configured airspeed indicator.
3. If airspeed, run the airspeed pointer-angle reader.
4. Map the airspeed angle to an instrument reading with the airspeed LUT.
5. If not airspeed, skip and never emit a fake angle or reading.
6. Write overlays and a machine-readable JSON report.
The project is intentionally modular so new gauges can be added without changing
the airspeed reader internals.
## Layout
```text
instrument_reader_cpp/
apps/ CLI entry points
configs/ Default reader and LUT examples
docs/ Architecture and integration notes
include/instrument_reader/
Public C++ interfaces
scripts/ Build and demo scripts
src/ Implementations
tests/ Future unit and fixture tests
```
## CLion
Open this folder directly in CLion:
```text
D:\chuav\gague\instrument_reader_cpp
```
CLion will detect `CMakeLists.txt` and `CMakePresets.json`.
Useful CMake targets:
- `clion_demo`
- `instrument_reader_cli`
- `airspeed_lut_cli`
- `run_demo`
- `run_demo_no_overlay`
- `run_lut_example`
More detail: `docs/clion.md`.
## Build
From this directory:
```powershell
.\scripts\build_msvc.ps1
```
The script auto-detects Visual Studio Build Tools with `vswhere`, loads the x64
MSVC environment, and assumes OpenCV is available at:
```text
C:\opencv\build
```
Ubuntu 22.04:
```bash
./scripts/setup_ubuntu22.sh
./scripts/build_ubuntu22.sh
./scripts/run_demo_ubuntu22.sh
```
## Demo
After building:
```powershell
.\scripts\run_demo.ps1
```
Output files are written to:
```text
instrument_reader_cpp/output/output1
```
Each run creates the next available folder under `output`, such as `output1`,
`output2`, and so on.
For airspeed images, the output JSON includes `airspeed_value`, and the overlay
image label shows the computed value.
## Main CLI
```powershell
.\build\Release\instrument_reader_cli.exe `
--input ..\2\original_crops `
--input ..\2\generated_scales\01_airspeed_indicator `
--airspeed-template ..\2\generated_scales\01_airspeed_indicator\_restored_base_no_pointer.png `
--airspeed-base ..\2\generated_scales\01_airspeed_indicator\_restored_base_no_pointer.png `
--airspeed-lut .\configs\airspeed_lut_example.json `
--out-dir .\output
```
## LUT CLI
```powershell
.\build\Release\airspeed_lut_cli.exe --map .\configs\airspeed_lut_example.json --theta 4.5
```
#include "instrument_reader/lut_mapper.hpp"
#include <filesystem>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
using namespace instrument_reader;
namespace {
void printUsage() {
std::cout
<< "usage:\n"
<< " airspeed_lut_cli --calibrate <out.json> [--start-angle 270]\n"
<< " airspeed_lut_cli --map <lut.json> --theta <deg> [--boundary reject|clamp]\n";
}
int runDefaultExample() {
LutMapper mapper;
std::string error;
std::filesystem::path lutPath = "configs/airspeed_lut_example.json";
if (!mapper.loadJson(lutPath, &error)) {
std::cerr << "failed to load default LUT: " << error << "\n";
return 1;
}
MapResult r = mapper.map(4.5);
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";
else std::cout << " \"value\": null,\n";
std::cout << " \"theta_abs_deg\": " << std::fixed << std::setprecision(6) << r.thetaAbsDeg << ",\n";
std::cout << " \"theta_rel_deg\": " << std::fixed << std::setprecision(6) << r.thetaRelDeg << ",\n";
std::cout << " \"code\": \"" << r.code << "\"\n";
std::cout << "}\n";
return r.valid ? 0 : 3;
}
} // namespace
int main(int argc, char** argv) {
if (argc == 1) {
return runDefaultExample();
}
std::filesystem::path calibrateOut;
std::filesystem::path mapPath;
double startAngle = 270.0;
double theta = 0.0;
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 {
if (i + 1 >= argc) throw std::runtime_error(std::string("missing value for ") + name);
return argv[++i];
};
if (arg == "--calibrate") calibrateOut = value("--calibrate");
else if (arg == "--map") mapPath = value("--map");
else if (arg == "--start-angle") startAngle = std::stod(value("--start-angle"));
else if (arg == "--theta") {
theta = std::stod(value("--theta"));
hasTheta = true;
} else if (arg == "--boundary") {
std::string v = value("--boundary");
policy = v == "clamp" ? BoundaryPolicy::Clamp : BoundaryPolicy::Reject;
} else if (arg == "--help" || arg == "-h") {
printUsage();
return 0;
} else {
std::cerr << "unknown argument: " << arg << "\n";
printUsage();
return 2;
}
}
if (!calibrateOut.empty()) {
LutMapper mapper(startAngle);
std::cout << "Enter calibration points. Empty value line finishes.\n";
while (true) {
std::string valueLine;
std::cout << "real value: ";
std::getline(std::cin, valueLine);
if (valueLine.empty()) break;
std::string thetaLine;
std::cout << "theta abs deg: ";
std::getline(std::cin, thetaLine);
if (thetaLine.empty()) break;
mapper.addPoint(std::stod(valueLine), std::stod(thetaLine));
}
std::string error;
if (!mapper.validate(&error)) {
std::cerr << "bad calibration: " << error << "\n";
return 1;
}
if (!mapper.saveJson(calibrateOut)) {
std::cerr << "failed to save LUT\n";
return 1;
}
std::cout << "saved=" << std::filesystem::absolute(calibrateOut).string() << "\n";
return 0;
}
if (!mapPath.empty() && hasTheta) {
LutMapper mapper;
std::string error;
if (!mapper.loadJson(mapPath, &error)) {
std::cerr << "failed to load LUT: " << error << "\n";
return 1;
}
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";
else std::cout << " \"value\": null,\n";
std::cout << " \"theta_abs_deg\": " << std::fixed << std::setprecision(6) << r.thetaAbsDeg << ",\n";
std::cout << " \"theta_rel_deg\": " << std::fixed << std::setprecision(6) << r.thetaRelDeg << ",\n";
std::cout << " \"code\": \"" << r.code << "\"\n";
std::cout << "}\n";
return r.valid ? 0 : 3;
}
printUsage();
return 2;
}
#include "instrument_reader/dispatcher.hpp"
#include "instrument_reader/lut_mapper.hpp"
#include <filesystem>
#include <iostream>
#include <stdexcept>
#include <vector>
namespace fs = std::filesystem;
using namespace instrument_reader;
namespace {
fs::path projectRootFromCurrentWorkingDirectory() {
fs::path cwd = fs::current_path();
if (fs::exists(cwd / "CMakeLists.txt") && fs::exists(cwd / "include" / "instrument_reader")) {
return cwd;
}
if (fs::exists(cwd.parent_path() / "CMakeLists.txt") && fs::exists(cwd.parent_path() / "include" / "instrument_reader")) {
return cwd.parent_path();
}
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);
if (fs::exists(p / "original_crops")) return p;
}
std::vector<fs::path> candidates = {
projectRoot / ".." / "2",
projectRoot / "2",
"D:/chuav/gague/2",
};
for (const fs::path& p : candidates) {
if (fs::exists(p / "original_crops")) return fs::absolute(p);
}
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";
InstrumentDispatcher::Options options;
options.outputDir = projectRoot / "output";
options.airspeedTemplatePath = airspeedBase;
options.airspeedBasePath = airspeedBase;
options.airspeedLutPath = projectRoot / "configs" / "airspeed_lut_example.json";
options.writeOverlays = true;
options.writeJson = true;
InstrumentDispatcher dispatcher(options);
DispatchSummary summary = dispatcher.run({
sampleRoot / "original_crops",
sampleRoot / "generated_scales" / "01_airspeed_indicator",
});
std::cout << "[vision]\n";
std::cout << "processed=" << summary.results.size() << "\n";
std::cout << "airspeed=" << summary.airspeedCount() << "\n";
std::cout << "valid_airspeed=" << summary.validAirspeedCount() << "\n";
std::cout << "skipped=" << summary.skippedCount() << "\n";
std::cout << "elapsed_ms=" << summary.totalElapsedMs << "\n";
std::cout << "output_dir=" << summary.outputDir.string() << "\n";
}
void runLutDemo(const fs::path& projectRoot) {
LutMapper mapper;
std::string error;
fs::path lutPath = projectRoot / "configs" / "airspeed_lut_example.json";
if (!mapper.loadJson(lutPath, &error)) {
throw std::runtime_error("failed to load LUT: " + error);
}
MapResult r = mapper.map(4.5);
std::cout << "[lut]\n";
std::cout << "theta_abs_deg=" << r.thetaAbsDeg << "\n";
std::cout << "theta_rel_deg=" << r.thetaRelDeg << "\n";
if (r.valid) {
std::cout << "value=" << r.value << "\n";
} else {
std::cout << "value=null\n";
}
std::cout << "code=" << r.code << "\n";
}
} // namespace
int main() {
try {
fs::path projectRoot = projectRootFromCurrentWorkingDirectory();
fs::path sampleRoot = findSampleRoot(projectRoot);
std::cout << "project_root=" << projectRoot.string() << "\n";
std::cout << "sample_root=" << sampleRoot.string() << "\n\n";
runVisionDemo(projectRoot, sampleRoot);
std::cout << "\n";
runLutDemo(projectRoot);
return 0;
} catch (const std::exception& ex) {
std::cerr << "error: " << ex.what() << "\n";
return 1;
}
}
#include "instrument_reader/dispatcher.hpp"
#include <filesystem>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
namespace fs = std::filesystem;
using namespace instrument_reader;
namespace {
void printUsage() {
std::cout
<< "usage: instrument_reader_cli --input <file_or_dir> [--input <file_or_dir> ...]\n"
<< " [--out-dir <root_dir>] [--airspeed-template <png>] [--airspeed-base <png>]\n"
<< " [--airspeed-lut <json>]\n"
<< " [--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;
}
return {};
}
} // namespace
int main(int argc, char** argv) {
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 {
if (i + 1 >= argc) throw std::runtime_error(std::string("missing value for ") + name);
return argv[++i];
};
if (arg == "--input") inputs.emplace_back(value("--input"));
else if (arg == "--out-dir") options.outputDir = value("--out-dir");
else if (arg == "--airspeed-template") options.airspeedTemplatePath = value("--airspeed-template");
else if (arg == "--airspeed-base") options.airspeedBasePath = value("--airspeed-base");
else if (arg == "--airspeed-lut") options.airspeedLutPath = value("--airspeed-lut");
else if (arg == "--visual-threshold") options.airspeedVisualThreshold = std::stod(value("--visual-threshold"));
else if (arg == "--no-recursive") options.recursive = false;
else if (arg == "--no-overlays") options.writeOverlays = false;
else if (arg == "--help" || arg == "-h") {
printUsage();
return 0;
} else {
std::cerr << "unknown argument: " << arg << "\n";
printUsage();
return 2;
}
}
if (options.airspeedTemplatePath.empty()) {
options.airspeedTemplatePath = firstExisting({
"../2/generated_scales/01_airspeed_indicator/_restored_base_no_pointer.png",
"D:/chuav/gague/2/generated_scales/01_airspeed_indicator/_restored_base_no_pointer.png",
});
}
if (options.airspeedBasePath.empty()) {
options.airspeedBasePath = options.airspeedTemplatePath;
}
if (options.airspeedLutPath.empty()) {
options.airspeedLutPath = firstExisting({
"configs/airspeed_lut_example.json",
"../configs/airspeed_lut_example.json",
"D:/chuav/gague/instrument_reader_cpp/configs/airspeed_lut_example.json",
});
}
if (inputs.empty()) {
inputs.push_back(firstExisting({"../2/original_crops", "D:/chuav/gague/2/original_crops"}));
}
try {
InstrumentDispatcher dispatcher(options);
DispatchSummary summary = dispatcher.run(inputs);
std::cout << "processed=" << summary.results.size() << "\n";
std::cout << "airspeed=" << summary.airspeedCount() << "\n";
std::cout << "valid_airspeed=" << summary.validAirspeedCount() << "\n";
std::cout << "skipped=" << summary.skippedCount() << "\n";
std::cout << "elapsed_ms=" << summary.totalElapsedMs << "\n";
std::cout << "output_dir=" << summary.outputDir.string() << "\n";
return 0;
} catch (const std::exception& ex) {
std::cerr << "error: " << ex.what() << "\n";
return 1;
}
}
{
"type": "airspeed_reader_config_v1",
"angle_unit": "degrees_image_coords_0_right_90_down",
"geometry": {
"center_x": 198.0,
"center_y": 248.0,
"reference_width": 400.0,
"reference_height": 445.0,
"radius": 210.0,
"inner_radius": 18.0
},
"sampling": {
"start_angle_deg": 0.0,
"end_angle_deg": 360.0,
"angle_step_deg": 0.5,
"radial_samples": 128,
"lateral_samples": 5,
"ray_width_px": 14.0
},
"validity_gates": {
"signal_threshold": 0.42,
"min_continuous_fraction": 0.30,
"min_coverage_fraction": 0.18,
"min_dominance_gap": 0.004,
"max_gap_samples": 3
},
"classification": {
"airspeed_visual_threshold": 0.40,
"path_hint_threshold_factor": 0.50
}
}
{
"type": "instrument_lut_v1",
"start_angle_deg": 270.0000000000,
"angle_unit": "degrees_image_coords_0_right_90_down",
"boundary_policy": "clamp",
"points": [
{"value": 0.0000000000, "theta_abs_deg": 270.0000000000, "theta_rel_deg": 0.0000000000},
{"value": 40.0000000000, "theta_abs_deg": 312.0000000000, "theta_rel_deg": 42.0000000000},
{"value": 80.0000000000, "theta_abs_deg": 354.0000000000, "theta_rel_deg": 84.0000000000},
{"value": 120.0000000000, "theta_abs_deg": 36.0000000000, "theta_rel_deg": 126.0000000000},
{"value": 160.0000000000, "theta_abs_deg": 78.0000000000, "theta_rel_deg": 168.0000000000},
{"value": 200.0000000000, "theta_abs_deg": 120.0000000000, "theta_rel_deg": 210.0000000000},
{"value": 240.0000000000, "theta_abs_deg": 162.0000000000, "theta_rel_deg": 252.0000000000},
{"value": 280.0000000000, "theta_abs_deg": 204.0000000000, "theta_rel_deg": 294.0000000000},
{"value": 300.0000000000, "theta_abs_deg": 225.0000000000, "theta_rel_deg": 315.0000000000}
]
}
# Airspeed Reader Strategy
The airspeed reader is traditional CV only. It does not use OCR, neural
networks, temporal smoothing, or guessed values.
## Steps
1. Scale the configured center/radius to the input image size.
2. Convert BGR to HSV.
3. Apply CLAHE on the V channel to reduce cockpit lighting drift.
4. Build a white-pointer signal from high brightness and low saturation.
5. If a no-pointer base is available, multiply the signal by image difference
evidence to suppress fixed ticks and digits.
6. Sample 0..360 degrees with precomputed radial rays.
7. Score each ray by mean signal, radial coverage, longest continuous run, and
high-percentile signal.
8. Refine the best angle with local quadratic interpolation.
9. Reject if continuous evidence is shorter than 30 percent of the gauge radius.
## Coordinate System
```text
0 degrees = right
90 degrees = down
180 degrees = left
270 degrees = up
```
This matches image coordinates and the generated sample manifests.
## Error Codes
- `ERR_BAD_IMAGE`
- `ERR_NO_CANDIDATE`
- `ERR_INSUFFICIENT_CONTINUOUS_LINE`
- `ERR_LOW_RADIAL_COVERAGE`
- `SKIPPED_NOT_AIRSPEED`
Downstream control code should treat any invalid reading as `null`.
# Architecture
## Modules
`InstrumentDispatcher`
- Owns path scanning, image IO, output overlays, and JSON reporting.
- It does not know gauge-specific image logic beyond routing by type.
`InstrumentClassifier`
- Decides whether an image is the configured airspeed indicator.
- Current method: path hint plus template edge NCC inside the airspeed dial mask.
- This module is the right place to add future gauge-type classifiers.
`AirspeedReader`
- Reads the airspeed pointer angle only.
- Uses white low-saturation signal extraction, optional no-pointer base
subtraction, radial ray integration, and hard validity gates.
- It caches ray coordinates by image size for high-frequency loops.
`LutMapper`
- Converts a visual absolute angle into a physical instrument value.
- Uses start-angle normalization plus piecewise linear interpolation.
- Boundary behavior is explicit: reject or clamp.
## Data Flow
```text
input paths
-> collect image files
-> classify instrument type
-> if airspeed: AirspeedReader::read()
-> if airspeed: LutMapper::map() to airspeed_value
-> if other: skip
-> overlay image in output/outputN
-> dispatch_results.json
```
## Extension Rule
To add a new gauge:
1. Add `include/instrument_reader/<gauge>_reader.hpp`.
2. Add `src/<gauge>_reader.cpp`.
3. Add a classifier route in `InstrumentClassifier`.
4. Add a dispatch branch in `InstrumentDispatcher::processOne`.
5. Add a config file and fixture images.
# Build And Run
## MSVC + OpenCV
Run from `D:\chuav\gague\instrument_reader_cpp`:
```powershell
.\scripts\build_msvc.ps1
```
If OpenCV is installed elsewhere:
```powershell
.\scripts\build_msvc.ps1 -OpenCvDir D:\path\to\opencv\build
```
## Current Machine Note
Visual Studio Build Tools 2022 is installed on this machine:
```text
C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools
```
Detected compiler:
```text
MSVC 19.44.35227
```
The build script calls `VsDevCmd.bat` automatically, so a normal PowerShell is
enough. The available OpenCV library is the MSVC build at:
```text
C:\opencv\build\x64\vc16\lib\opencv_world490.lib
```
Do not link the MSVC OpenCV binary with MinGW.
## Run Current Dataset
```powershell
.\scripts\run_demo.ps1
```
The run script also prepends `C:\opencv\build\x64\vc16\bin` to `PATH` so
`opencv_world490.dll` can be loaded at runtime.
## CLion
See:
```text
docs/clion.md
```
## Ubuntu 22.04
See:
```text
docs/ubuntu22.md
```
## Direct CLI Example
```powershell
.\build\Release\instrument_reader_cli.exe `
--input ..\2\original_crops `
--input ..\2\generated_scales\01_airspeed_indicator `
--airspeed-lut .\configs\airspeed_lut_example.json `
--out-dir .\output
```
## Output
- Overlay images: `output/outputN/*.png`
- Report: `output/outputN/dispatch_results.json`
- Airspeed reading field: `airspeed_value`
Each run creates the next available numbered folder, such as `output/output1`,
`output/output2`, and so on.
The report contains type, confidence score, theta, error code, continuity
metrics, and per-image time.
# CLion Setup
## Open Project
Open this directory in CLion:
```text
D:\chuav\gague\instrument_reader_cpp
```
On Windows you can also run:
```powershell
.\scripts\open_clion.ps1
```
The script starts CLion through the Visual Studio x64 developer environment
when VS Build Tools are available.
## CMake Profiles
CLion can use `CMakePresets.json`.
Recommended Windows profiles:
```text
windows-msvc-debug
windows-msvc-release
```
Recommended Ubuntu 22.04 profile:
```text
ubuntu22-release
```
## Run Targets
After CLion finishes CMake loading, use one of these targets:
```text
clion_demo
run_demo
run_demo_no_overlay
run_lut_example
instrument_reader_cli
airspeed_lut_cli
```
Recommended: run `clion_demo`. It directly calls the C++ API functions with no
manual program arguments.
`run_demo` writes overlay images and JSON to a new numbered folder under:
```text
output
```
For example, consecutive runs create:
```text
output/output1
output/output2
```
`run_demo_no_overlay` runs the same recognition path without debug overlay PNG
writes and still creates a new numbered folder for `dispatch_results.json`.
## Windows Runtime
The `run_demo` CMake target injects the OpenCV DLL path automatically:
```text
C:\opencv\build\x64\vc16\bin
```
If you create a manual CLion Application run configuration, add that folder to
`PATH` or copy `opencv_world490.dll` next to the executable.
## Dataset Root
By default CMake tries:
```text
../2
```
relative to this project. If the dataset is elsewhere, set this CMake cache
variable in CLion:
```text
INSTRUMENT_READER_SAMPLE_ROOT=/path/to/data_root
```
The data root must contain:
```text
original_crops/
generated_scales/01_airspeed_indicator/_restored_base_no_pointer.png
```
# Data Contract
## Angle Reading
Valid reading:
```json
{
"instrument_type": "airspeed_indicator",
"theta_degrees": 276.226,
"error_code": null,
"continuous_line_fraction": 0.669,
"coverage_fraction": 0.703
}
```
Invalid reading:
```json
{
"instrument_type": "airspeed_indicator",
"theta_degrees": null,
"error_code": "ERR_INSUFFICIENT_CONTINUOUS_LINE"
}
```
Skipped non-airspeed instrument:
```json
{
"instrument_type": "other_instrument",
"theta_degrees": null,
"error_code": "SKIPPED_NOT_AIRSPEED"
}
```
## Safety Rule
Never consume `theta_degrees` unless `error_code` is `null`.
## LUT Mapping
The visual angle can be mapped to a physical value with `LutMapper`.
Angles are first normalized relative to `start_angle_deg`, then interpolated
between neighboring calibration points.
# Performance Snapshot
Date: 2026-06-04
## MSVC C++ Project Benchmark
Source files:
```text
D:\chuav\gague\instrument_reader_cpp\results\dispatch_results\dispatch_results.json
D:\chuav\gague\instrument_reader_cpp\results\dispatch_no_overlay\dispatch_results.json
```
Environment:
- Visual Studio Build Tools 2022
- MSVC 19.44.35227
- OpenCV 4.9.0 MSVC build
- Dataset: 53 images, including 32 airspeed images and 21 skipped images
| Stage | Time | Rate |
| --- | ---: | ---: |
| C++ dispatch with overlays and PNG writes | 589.801 ms / 53 images | 89.9 FPS |
| C++ dispatch without overlays | 305.618 ms / 53 images | 173.4 FPS |
| C++ average per mixed image, no overlays | 5.766 ms/image | 173.4 FPS |
| C++ average airspeed image, no overlays | 7.340 ms/airspeed image | 136.2 FPS |
| C++ median airspeed image, no overlays | 7.144 ms/airspeed image | 140.0 FPS |
| C++ average skipped image, no overlays | 3.269 ms/skipped image | 305.9 FPS |
## Current Airspeed Dispatch Reference
Source file:
```text
D:\chuav\gague\2\5\results\dispatch_results\fast_timing_reference.json
```
This is a Python vectorized reference run with precomputed ray coordinates. The
C++ project uses the same ray-cache idea, but this machine cannot link the
OpenCV C++ project yet because MSVC is not available in the current shell.
Dataset:
- 36 images per round
- 11 airspeed images per round
- 25 skipped non-airspeed images per round
- 5 benchmark rounds
Rates:
| Stage | Time | Rate |
| --- | ---: | ---: |
| Mixed dispatch average | 4.815 ms/image | 207.7 FPS |
| Mixed dispatch median | 1.411 ms/image | 708.5 FPS |
| Classification only average | 1.307 ms/image | 765.0 FPS |
| Airspeed angle only average | 11.480 ms/airspeed image | 87.1 FPS |
| Airspeed angle only median | 11.447 ms/airspeed image | 87.4 FPS |
## Existing C++ Fixed-Scan Benchmark
Source file:
```text
D:\chuav\gague\2\cpp_processed_results\benchmark_cpp.json
```
This benchmark is from the earlier in-memory C++ fixed-scan reader. File IO is
not included.
| Stage | Time | Rate |
| --- | ---: | ---: |
| C++ core in-memory scan | 3.233 ms/frame | 309.3 FPS |
## Notes
- Overlay writing and disk IO are intentionally separate from core closed-loop
timing.
- Downstream control should use the in-memory result path, not the debug overlay
path.
- After building `instrument_reader_cpp` with MSVC/OpenCV, rerun the CLI with
`--no-overlays` to measure the clean processing path.
# Ubuntu 22.04
## Install Dependencies
```bash
cd /path/to/instrument_reader_cpp
chmod +x scripts/*.sh
./scripts/setup_ubuntu22.sh
```
This installs:
- build-essential
- cmake
- gdb
- pkg-config
- libopencv-dev
Ubuntu 22.04 ships GCC 11 and OpenCV 4.x packages that are sufficient for this
C++17 project.
## Build
```bash
./scripts/build_ubuntu22.sh
```
Default output:
```text
cmake-build-ubuntu22-release/
```
## Run Demo
If the dataset is next to this project as `../2`:
```bash
./scripts/run_demo_ubuntu22.sh
```
If the dataset is elsewhere:
```bash
./scripts/run_demo_ubuntu22.sh ./cmake-build-ubuntu22-release /path/to/data_root
```
The data root must contain:
```text
original_crops/
generated_scales/01_airspeed_indicator/
```
## CLion On Ubuntu
Open the project folder and select the `ubuntu22-release` preset. Run:
```text
run_demo
run_demo_no_overlay
run_lut_example
```
If your dataset is not in `../2`, set:
```text
INSTRUMENT_READER_SAMPLE_ROOT=/path/to/data_root
```
in the CLion CMake profile.
#pragma once
#include "instrument_reader/types.hpp"
#include <opencv2/core.hpp>
#include <map>
#include <string>
#include <vector>
namespace instrument_reader {
class AirspeedReader {
public:
struct Config {
ReaderGeometry geometry;
float startAngleDeg = 0.0f;
float endAngleDeg = 360.0f;
float angleStepDeg = 0.5f;
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_; }
AngleReading read(const cv::Mat& bgr);
cv::Mat drawOverlay(const cv::Mat& bgr, const AngleReading& reading) const;
private:
struct RaySamples {
float angleDeg = 0.0f;
std::vector<cv::Point> points;
};
struct RayCacheEntry {
Config scaledConfig;
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 noPointerBaseBgr_;
std::map<std::string, RayCacheEntry> rayCache_;
};
} // namespace instrument_reader
#pragma once
#include <cmath>
namespace instrument_reader {
constexpr double kPi = 3.14159265358979323846;
inline double normalize360(double deg) {
double out = std::fmod(deg, 360.0);
if (out < 0.0) out += 360.0;
if (out >= 360.0) out -= 360.0;
return out;
}
inline float normalize360f(float deg) {
float out = std::fmod(deg, 360.0f);
if (out < 0.0f) out += 360.0f;
if (out >= 360.0f) out -= 360.0f;
return out;
}
inline double relativeFromStart(double thetaAbsDeg, double startAngleDeg) {
return normalize360(thetaAbsDeg - startAngleDeg);
}
inline float degreesToRadians(float deg) {
return deg * static_cast<float>(kPi / 180.0);
}
} // namespace instrument_reader
#pragma once
#include "instrument_reader/airspeed_reader.hpp"
#include "instrument_reader/instrument_classifier.hpp"
#include "instrument_reader/lut_mapper.hpp"
#include "instrument_reader/types.hpp"
#include <filesystem>
#include <vector>
namespace instrument_reader {
class InstrumentDispatcher {
public:
struct Options {
std::filesystem::path outputDir = "output";
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;
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);
void writeResultsJson(const DispatchSummary& summary) const;
static bool isImageFile(const std::filesystem::path& path);
static bool isHelperImage(const std::filesystem::path& path);
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_;
bool hasAirspeedLut_ = false;
};
} // namespace instrument_reader
#pragma once
#include "instrument_reader/types.hpp"
#include <opencv2/core.hpp>
#include <filesystem>
namespace instrument_reader {
class InstrumentClassifier {
public:
struct Config {
ReaderGeometry airspeedGeometry;
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);
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);
static double maskedNcc(const cv::Mat& a32, const cv::Mat& b32, const cv::Mat& mask8);
Config config_;
bool ready_ = false;
cv::Size targetSize_;
cv::Mat templateEdge32_;
cv::Mat mask8_;
};
} // namespace instrument_reader
#pragma once
#include <filesystem>
#include <string>
#include <vector>
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;
double thetaAbsDeg = 0.0;
double thetaRelDeg = 0.0;
std::string code;
std::string message;
CalibrationPoint left;
CalibrationPoint right;
};
class LutMapper {
public:
explicit LutMapper(double startAngleDeg = 270.0);
void setStartAngle(double startAngleDeg);
void setBoundaryPolicy(BoundaryPolicy policy) { boundaryPolicy_ = policy; }
void clear();
void addPoint(double value, double thetaAbsDeg);
bool validate(std::string* errorMessage = nullptr) const;
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:
void sortByRelativeAngle();
double startAngleDeg_ = 270.0;
BoundaryPolicy boundaryPolicy_ = BoundaryPolicy::Reject;
std::vector<CalibrationPoint> points_;
};
} // namespace instrument_reader
#pragma once
#include <opencv2/core.hpp>
#include <filesystem>
#include <string>
#include <vector>
namespace instrument_reader {
enum class InstrumentType {
Unknown,
OtherInstrument,
AirspeedIndicator,
};
inline const char* toString(InstrumentType type) {
switch (type) {
case InstrumentType::AirspeedIndicator: return "airspeed_indicator";
case InstrumentType::OtherInstrument: return "other_instrument";
default: return "unknown";
}
}
struct ReaderGeometry {
// Image coordinates: 0 deg points right, 90 deg points down.
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 {
bool valid = false;
std::string errorCode = "ERR_UNSET";
std::string errorMessage;
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;
bool pathHint = false;
double visualScore = 0.0;
double confidence = 0.0;
};
struct ProcessResult {
std::filesystem::path inputPath;
std::filesystem::path outputPath;
ClassificationResult classification;
AngleReading angle;
bool airspeedValueValid = false;
double airspeedValue = 0.0;
bool airspeedThetaRelValid = false;
double airspeedThetaRelDeg = 0.0;
std::string airspeedValueCode;
std::string airspeedValueMessage;
double elapsedMs = 0.0;
};
struct DispatchSummary {
std::filesystem::path outputDir;
std::vector<ProcessResult> results;
double totalElapsedMs = 0.0;
int airspeedCount() const;
int validAirspeedCount() const;
int skippedCount() const;
};
} // namespace instrument_reader
param(
[string]$OpenCvDir = "C:\opencv\build",
[string]$BuildDir = "build",
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
if (-not (Get-Command cmake.exe -ErrorAction SilentlyContinue)) {
throw "cmake.exe not found"
}
$ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$opencvConfig = Join-Path $OpenCvDir "x64\vc16\lib"
if (-not (Test-Path $opencvConfig)) {
throw "OpenCV MSVC library folder not found: $opencvConfig"
}
$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."
}
$vsInstall = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if (-not $vsInstall) {
throw "MSVC C++ tools not found. Install workload Microsoft.VisualStudio.Workload.VCTools."
}
$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
}
$configure = "cmake -S `"$ProjectRoot`" -B `"$buildPath`" -G `"NMake Makefiles`" -DOpenCV_DIR=`"$opencvConfig`" -DCMAKE_BUILD_TYPE=$Configuration"
$build = "cmake --build `"$buildPath`" --config $Configuration"
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)"
BUILD_DIR="${1:-${PROJECT_ROOT}/cmake-build-ubuntu22-release}"
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(
[string]$ProjectDir = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
)
$ErrorActionPreference = "Stop"
$candidates = @(
"D:\works\Clion\CLion 2025.2.5\bin\clion64.exe",
"$env:LOCALAPPDATA\Programs\CLion\bin\clion64.exe",
"$env:ProgramFiles\JetBrains\CLion\bin\clion64.exe"
)
$clion = $null
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
$clion = $candidate
break
}
}
if (-not $clion) {
$cmd = Get-Command clion64.exe -ErrorAction SilentlyContinue
if ($cmd) {
$clion = $cmd.Source
}
}
if (-not $clion) {
throw "CLion executable not found."
}
$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
if ($vsInstall) {
$vsDevCmd = Join-Path $vsInstall "Common7\Tools\VsDevCmd.bat"
if (Test-Path $vsDevCmd) {
cmd.exe /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 && start `"`" `"$clion`" `"$ProjectDir`""
Write-Host "Opened CLion project with MSVC x64 environment: $ProjectDir"
exit 0
}
}
}
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)"
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
fi
echo "Opened CLion project: ${PROJECT_ROOT}"
@echo off
setlocal
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
if not exist "%VSWHERE%" (
echo vswhere.exe not found. Install Visual Studio Build Tools 2022.
exit /b 1
)
for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do (
set "VSINSTALL=%%i"
)
if "%VSINSTALL%"=="" (
echo MSVC C++ tools not found.
exit /b 1
)
call "%VSINSTALL%\Common7\Tools\VsDevCmd.bat" -arch=x64 -host_arch=x64
echo.
echo MSVC x64 environment is ready.
cmd /k
param(
[string]$BuildDir = "build",
[string]$DataRoot = "..\2",
[string]$OpenCvDir = "C:\opencv\build"
)
$ErrorActionPreference = "Stop"
$exe = Join-Path $BuildDir "Release\instrument_reader_cli.exe"
if (-not (Test-Path $exe)) {
$exe = Join-Path $BuildDir "instrument_reader_cli.exe"
}
if (-not (Test-Path $exe)) {
throw "instrument_reader_cli.exe not found. Build first."
}
$opencvBin = Join-Path $OpenCvDir "x64\vc16\bin"
if (Test-Path $opencvBin) {
$env:Path = "$opencvBin;$env:Path"
}
& $exe `
--input (Join-Path $DataRoot "original_crops") `
--input (Join-Path $DataRoot "generated_scales\01_airspeed_indicator") `
--airspeed-template (Join-Path $DataRoot "generated_scales\01_airspeed_indicator\_restored_base_no_pointer.png") `
--airspeed-base (Join-Path $DataRoot "generated_scales\01_airspeed_indicator\_restored_base_no_pointer.png") `
--airspeed-lut ".\configs\airspeed_lut_example.json" `
--out-dir ".\output"
exit $LASTEXITCODE
#!/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}"
OUT_DIR="${PROJECT_ROOT}/results/ubuntu22_dispatch_results"
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" \
--airspeed-template "${DATA_ROOT}/generated_scales/01_airspeed_indicator/_restored_base_no_pointer.png" \
--airspeed-base "${DATA_ROOT}/generated_scales/01_airspeed_indicator/_restored_base_no_pointer.png" \
--out-dir "${OUT_DIR}"
#!/usr/bin/env bash
set -euo pipefail
sudo apt update
sudo apt install -y \
build-essential \
cmake \
gdb \
git \
pkg-config \
libopencv-dev
echo "Ubuntu 22.04 build dependencies are ready."
cmake --version
pkg-config --modversion opencv4 || true
This diff is collapsed.
This diff is collapsed.
#include "instrument_reader/instrument_classifier.hpp"
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include <algorithm>
#include <cctype>
#include <cmath>
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));
});
return s;
}
} // namespace
InstrumentClassifier::InstrumentClassifier()
: InstrumentClassifier(Config{}) {}
InstrumentClassifier::InstrumentClassifier(Config config)
: config_(config) {}
bool InstrumentClassifier::loadAirspeedTemplate(const std::filesystem::path& templatePath) {
cv::Mat img = cv::imread(templatePath.string(), cv::IMREAD_COLOR);
if (img.empty()) return false;
setAirspeedTemplate(img);
return true;
}
void InstrumentClassifier::setAirspeedTemplate(const cv::Mat& bgrTemplate) {
if (bgrTemplate.empty() || bgrTemplate.type() != CV_8UC3) {
ready_ = false;
templateEdge32_.release();
mask8_.release();
return;
}
targetSize_ = bgrTemplate.size();
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 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()) {
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;
}
if (result.isAirspeed) result.type = InstrumentType::AirspeedIndicator;
return result;
}
cv::Mat InstrumentClassifier::prepareEdgeForClassification(const cv::Mat& bgr, cv::Size targetSize) {
cv::Mat resized;
cv::resize(bgr, resized, targetSize, 0, 0, cv::INTER_AREA);
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);
return edge;
}
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);
cv::Point center(cvRound(geometry.center.x * sx), cvRound(geometry.center.y * sy));
int radius = std::max(1, cvRound(geometry.radius * s));
int inner = std::max(1, cvRound(geometry.radius * 0.18f * s));
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);
return mask;
}
double InstrumentClassifier::maskedNcc(const cv::Mat& a32, const cv::Mat& b32, const cv::Mat& mask8) {
double sumA = 0.0;
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);
const uchar* m = mask8.ptr<uchar>(y);
for (int x = 0; x < a32.cols; ++x) {
if (!m[x]) continue;
sumA += a[x];
sumB += b[x];
++n;
}
}
if (n < 32) return 0.0;
double meanA = sumA / n;
double meanB = sumB / n;
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);
const uchar* m = mask8.ptr<uchar>(y);
for (int x = 0; x < a32.cols; ++x) {
if (!m[x]) continue;
double da = static_cast<double>(a[x]) - meanA;
double db = static_cast<double>(b[x]) - meanB;
dot += da * db;
normA += da * da;
normB += db * db;
}
}
return dot / (std::sqrt(normA * normB) + 1e-9);
}
} // namespace instrument_reader
#include "instrument_reader/lut_mapper.hpp"
#include "instrument_reader/angle_math.hpp"
#include <algorithm>
#include <fstream>
#include <iomanip>
#include <regex>
#include <sstream>
#include <utility>
namespace instrument_reader {
namespace {
std::string policyToString(BoundaryPolicy policy) {
return policy == BoundaryPolicy::Clamp ? "clamp" : "reject";
}
} // namespace
LutMapper::LutMapper(double startAngleDeg)
: startAngleDeg_(normalize360(startAngleDeg)) {}
void LutMapper::setStartAngle(double startAngleDeg) {
startAngleDeg_ = normalize360(startAngleDeg);
for (CalibrationPoint& p : points_) {
p.thetaRelDeg = relativeFromStart(p.thetaAbsDeg, startAngleDeg_);
}
sortByRelativeAngle();
}
void LutMapper::clear() {
points_.clear();
}
void LutMapper::addPoint(double value, double thetaAbsDeg) {
CalibrationPoint p;
p.value = value;
p.thetaAbsDeg = normalize360(thetaAbsDeg);
p.thetaRelDeg = relativeFromStart(thetaAbsDeg, startAngleDeg_);
points_.push_back(p);
sortByRelativeAngle();
}
bool LutMapper::validate(std::string* errorMessage) const {
if (points_.size() < 2) {
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";
return false;
}
if (points_[i].value <= points_[i - 1].value) {
if (errorMessage) *errorMessage = "value must be strictly increasing";
return false;
}
}
return true;
}
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";
out << " \"angle_unit\": \"degrees_image_coords_0_right_90_down\",\n";
out << " \"boundary_policy\": \"" << policyToString(boundaryPolicy_) << "\",\n";
out << " \"points\": [\n";
for (size_t i = 0; i < points_.size(); ++i) {
const CalibrationPoint& p = points_[i];
out << " {\"value\": " << std::fixed << std::setprecision(10) << p.value
<< ", \"theta_abs_deg\": " << p.thetaAbsDeg
<< ", \"theta_rel_deg\": " << p.thetaRelDeg << "}";
out << (i + 1 < points_.size() ? "," : "") << "\n";
}
out << " ]\n";
out << "}\n";
return true;
}
bool LutMapper::loadJson(const std::filesystem::path& path, std::string* errorMessage) {
std::ifstream in(path, std::ios::binary);
if (!in) {
if (errorMessage) *errorMessage = "failed to open LUT file";
return false;
}
std::stringstream buffer;
buffer << in.rdbuf();
std::string text = buffer.str();
std::smatch match;
std::regex startRegex(R"json("start_angle_deg"\s*:\s*([-+0-9.eE]+))json");
if (std::regex_search(text, match, startRegex)) {
startAngleDeg_ = normalize360(std::stod(match[1].str()));
} else {
if (errorMessage) *errorMessage = "missing start_angle_deg";
return false;
}
std::regex policyRegex(R"json("boundary_policy"\s*:\s*"([^"]+)")json");
if (std::regex_search(text, match, policyRegex)) {
boundaryPolicy_ = match[1].str() == "clamp" ? BoundaryPolicy::Clamp : BoundaryPolicy::Reject;
}
std::vector<CalibrationPoint> loaded;
std::regex pointRegex(
R"json(\{[^\}]*"(?:value|airspeed)"\s*:\s*([-+0-9.eE]+)[^\}]*"theta_abs_deg"\s*:\s*([-+0-9.eE]+)[^\}]*\})json");
for (std::sregex_iterator it(text.begin(), text.end(), pointRegex), end; it != end; ++it) {
CalibrationPoint p;
p.value = std::stod((*it)[1].str());
p.thetaAbsDeg = normalize360(std::stod((*it)[2].str()));
p.thetaRelDeg = relativeFromStart(p.thetaAbsDeg, startAngleDeg_);
loaded.push_back(p);
}
points_ = std::move(loaded);
sortByRelativeAngle();
return validate(errorMessage);
}
MapResult LutMapper::map(double thetaAbsDeg) const {
MapResult result;
result.thetaAbsDeg = normalize360(thetaAbsDeg);
result.thetaRelDeg = relativeFromStart(thetaAbsDeg, startAngleDeg_);
std::string error;
if (!validate(&error)) {
result.code = "ERR_BAD_LUT";
result.message = error;
return result;
}
const CalibrationPoint& first = points_.front();
const CalibrationPoint& last = points_.back();
if (result.thetaRelDeg < first.thetaRelDeg) {
result.left = first;
result.right = first;
if (boundaryPolicy_ == BoundaryPolicy::Clamp) {
result.valid = true;
result.value = first.value;
result.code = "OK_CLAMPED_BELOW_MIN";
} else {
result.code = "ERR_BELOW_MIN";
result.message = "angle is below the calibrated range";
}
return result;
}
if (result.thetaRelDeg > last.thetaRelDeg) {
if (boundaryPolicy_ == BoundaryPolicy::Clamp) {
result.valid = true;
const double distanceToMax = result.thetaRelDeg - last.thetaRelDeg;
const double distanceToMinAcrossStart = 360.0 - result.thetaRelDeg + first.thetaRelDeg;
if (distanceToMinAcrossStart < distanceToMax) {
result.value = first.value;
result.left = first;
result.right = first;
result.code = "OK_CLAMPED_TO_MIN_WRAP";
} else {
result.value = last.value;
result.left = last;
result.right = last;
result.code = "OK_CLAMPED_ABOVE_MAX";
}
} else {
result.left = last;
result.right = last;
result.code = "ERR_ABOVE_MAX";
result.message = "angle is above the calibrated range";
}
return result;
}
for (size_t i = 1; i < points_.size(); ++i) {
const CalibrationPoint& left = points_[i - 1];
const CalibrationPoint& right = points_[i];
if (result.thetaRelDeg <= right.thetaRelDeg) {
double denom = right.thetaRelDeg - left.thetaRelDeg;
double t = denom > 0.0 ? (result.thetaRelDeg - left.thetaRelDeg) / denom : 0.0;
result.valid = true;
result.value = left.value + t * (right.value - left.value);
result.left = left;
result.right = right;
result.code = "OK_INTERPOLATED";
return result;
}
}
result.code = "ERR_NO_INTERVAL";
result.message = "no interpolation interval found";
return result;
}
void LutMapper::sortByRelativeAngle() {
std::sort(points_.begin(), points_.end(), [](const CalibrationPoint& a, const CalibrationPoint& b) {
return a.thetaRelDeg < b.thetaRelDeg;
});
}
} // namespace instrument_reader
#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;
}
return count;
}
int DispatchSummary::validAirspeedCount() const {
int count = 0;
for (const ProcessResult& r : results) {
if (r.classification.type == InstrumentType::AirspeedIndicator && r.angle.valid) ++count;
}
return count;
}
int DispatchSummary::skippedCount() const {
int count = 0;
for (const ProcessResult& r : results) {
if (r.classification.type != InstrumentType::AirspeedIndicator) ++count;
}
return count;
}
} // namespace instrument_reader
# Tests
Suggested future tests:
1. Classifier regression over `../2/original_crops`.
2. Airspeed angle regression over `../2/generated_scales/01_airspeed_indicator`.
3. LUT boundary tests for reject and clamp behavior.
4. Null-output tests for short or occluded pointer evidence.
Keep image fixtures outside the source tree when they are large. Point tests to
the existing sample folders instead.
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