Add light bar detector and detect_test

- traditional CV pipeline referencing jlu_vision_26:
  HSV mask -> contour LightBar -> PCA corner refine -> pairing gates
- big/small armor classification (bar distance / bar length ratio)
- detect_test with synthetic selftest, 6/6 passed
- measured armor size 131.56x55mm, solver NaN guard + ITERATIVE fallback
This commit is contained in:
2026-09-17 00:34:24 +08:00
parent 19ac43da6f
commit f3b06d6abe
10 changed files with 1063 additions and 77 deletions
+49 -4
View File
@@ -27,13 +27,15 @@ xmake project -k compile_commands .vscode
| `capture_calib` | **相机标定图采集工具** | | `capture_calib` | **相机标定图采集工具** |
| `calibrate` | **标定计算工具**(照片 → 内参/畸变 → `configs/camera.yaml`) | | `calibrate` | **标定计算工具**(照片 → 内参/畸变 → `configs/camera.yaml`) |
| `pose_test` | **几何链路验证工具**(4 角点 → 距离/yaw/pitch,含合成自检) | | `pose_test` | **几何链路验证工具**(4 角点 → 距离/yaw/pitch,含合成自检) |
| `detect_test` | **灯条检测验证工具**(检测 → PnP 一条龙,含合成自检) |
## 配置 ## 配置
``` ```
configs/ configs/
├── camera.yaml # 相机内参 + 畸变 + 标定元信息(由 calibrate 生成,需入库) ├── camera.yaml # 相机内参 + 畸变 + 标定元信息(由 calibrate 生成,需入库)
└── camera_runtime.yaml # 运行时参数:曝光/增益/白平衡/帧率(手工维护,需入库) ├── camera_runtime.yaml # 运行时参数:曝光/增益/白平衡/帧率(手工维护,需入库)
└── light_bar.yaml # 灯条检测参数:HSV 范围/配对门限/PCA(手工维护,需入库)
``` ```
代码里这样用(`inc/camera_calib.hpp`): 代码里这样用(`inc/camera_calib.hpp`):
@@ -76,7 +78,7 @@ cam.setFrameSpeed(2); // 帧率档位 0/1/2
## pose_test:几何链路验证 ## pose_test:几何链路验证
写检测器之前,先用它验证「标定 + 坐标约定 + PnP 解算」: 写检测器之前,先用它验证「标定 + 坐标约定 + PnP 解算」:
拿真的装甲板(或打印 135mm 矩形)摆在相机前,鼠标点 4 个角, 拿真的装甲板(或打印 131.56×55mm 矩形)摆在相机前,鼠标点 4 个角,
输出的距离/yaw/pitch 与卷尺、量角器核对。之后接检测器出问题必是检测的锅。 输出的距离/yaw/pitch 与卷尺、量角器核对。之后接检测器出问题必是检测的锅。
```bash ```bash
@@ -96,12 +98,55 @@ cam.setFrameSpeed(2); // 帧率档位 0/1/2
### 两个踩过的坑(写检测器时别重蹈) ### 两个踩过的坑(写检测器时别重蹈)
1. **IPPE + 畸变 + 板内旋转 = 垃圾解**(OpenCV 4.x/5.x 都中招)。 1. **OpenCV 4.x 的 IPPE 有两个数值雷区**(实测):
解法:角点先去畸变再喂 IPPE,重投影回原图算(`solvePose` 已内置)。 ①「畸变 + 板内旋转」→ 解出垃圾解;②「完美轴对称矩形」→ 返回 NaN。
解法:IPPE 全候选逐个筛(有限值/z>0/重投影),**全被筛掉就自动退回
`SOLVEPNP_ITERATIVE`**——两条退化路径都稳(`solvePose` 已内置)。
2. **4 个共面点的顺序错误查不出来**:循环/镜像顺序 = 同一个四边形 = 2. **4 个共面点的顺序错误查不出来**:循环/镜像顺序 = 同一个四边形 =
同一个解(无害);交叉乱序会被重投影门限拦下。真正需要 roll 门限的是 同一个解(无害);交叉乱序会被重投影门限拦下。真正需要 roll 门限的是
「检测器给出假四边形」场景,等写检测器时照搬 jlu 的 `roll_thres 15°`。 「检测器给出假四边形」场景,等写检测器时照搬 jlu 的 `roll_thres 15°`。
### 装甲板尺寸(2026-09-16 实测)
| 板型 | W 灯条中心距 | L 灯条长度 | 来源 |
|---|---|---|---|
| 小装甲(2/3/4/5 号、哨兵) | **131.56 mm** | **55.0 mm** | 实测 3 号板,与官方规格 131.5mm 一致 |
| 大装甲(英雄 1 号) | 230 mm(名义值) | 55.0 mm | 待实测校正 W |
⚠️ 尺寸误差 1% = 距离误差 1%;拿 135mm 模型解 230mm 大板,距离会差 0.59 倍。
检测器将来必须同时输出「4 角点 + 大小装甲分类」。
## detect_test:灯条检测 + PnP 一条龙
传统 CV 管线(借鉴 jlu_vision_26):HSV 颜色掩膜 → 轮廓 → LightBar
(minAreaRect+质心+端点)→ PCA 角点修正(灰度矩对称轴 + 亮度梯度找端点)
→ 左右配对五道门 → 大小装甲分类 → 4 角点 → `solvePose`。
```bash
./build/linux/x86_64/debug/detect_test # 相机实时
./build/linux/x86_64/debug/detect_test --image 照片.jpg # 静态图
./build/linux/x86_64/debug/detect_test --selftest # 合成自检(无需相机)
```
- 按键:`q` 退出 | `s` 截图 | `c` 切换敌方颜色(红方打蓝,默认蓝) | `r` 重载参数
- 参数都在 `configs/light_bar.yaml`,现场按 `r` 热重载调参
- 敌方颜色默认 `blue`(主人是红方);拿自己红板测试时改 `red` 或现场按 `c`
- `--selftest` 自检已通过 6/6:正前方/右偏/上仰/复合/板内滚转/大装甲,
距离误差 ≤3.1%、yaw/pitch 误差 ≤0.05°,大小分类全部正确
### 调参时踩过的三个坑(已在默认参数里避开)
1. **形态学闭运算别开 2 次**:远景灯条只有 2~3px 宽,腐蚀两轮整个抹掉
2. **长宽比门限别设太高**(默认 1.5):远景灯条模糊+阈值后掩膜被撑到
10px 宽,实测比只剩 1.7;防误检主要靠配对五道门
3. **角点顺序的镜像坑**:板子朝向相机时,板的左条出现在画面右侧
(面对面镜像),输出角点已按模型约定「板左下→板左上→板右上→板右下」处理
### 渲染自检的教训(写自检时别重蹈)
圆管式画灯条会让端弧超出模型端点 4px+,距离直接偏 17%;用填充四边形、
端点严格对齐模型点。
## 相机标定流程 ## 相机标定流程
> **本工程标定板规格**:10 × 7 内角点、**方格 20.0 mm**(卡尺实测值,不是打印标称值) > **本工程标定板规格**:10 × 7 内角点、**方格 20.0 mm**(卡尺实测值,不是打印标称值)
+9 -9
View File
@@ -6,15 +6,15 @@
# ⚠️ M12 镜头的【对焦环位置】会改变有效焦距: # ⚠️ M12 镜头的【对焦环位置】会改变有效焦距:
# 一旦调焦,必须重新标定并重新做卷尺距离验证! # 一旦调焦,必须重新标定并重新做卷尺距离验证!
# ============================================================ # ============================================================
camera_name: "MV-SUA131GC" camera_name: "MV-SUA131GC" # 相机型号
lens: "MV-LD-M12-2.8MM-C" lens: "MV-LD-M12-2.8MM-C" # 镜头型号,焦距 2.8mm
sensor: "1/3\" CMOS, 3.75um pixel" sensor: "1/3\" CMOS, 3.75um pixel" # 传感器类型
resolution: "1280x1024" resolution: "1280x1024" # 分辨率
calib_date: "2026-09-15" calib_date: "2026-09-15" # 标定日期
board_spec: "10x7 inner corners, square=20.0mm" board_spec: "10x7 inner corners, square=20.0mm" # 标定板规格
calib_images: "31 (old_run 12 + new 19)" calib_images: "31 (old_run 12 + new 19)" # 标定图像数量
distance_verify: "ruler 700mm -> computed 687~697mm, error <2%" distance_verify: "ruler 700mm -> computed 687~697mm, error <2%" # 用卷尺验证 PnP 测距的精度
notes: "refocus invalidates this calibration; must re-verify with ruler" notes: "refocus invalidates this calibration; must re-verify with ruler" # 备注
camera_matrix: !!opencv-matrix camera_matrix: !!opencv-matrix
rows: 3 rows: 3
+51
View File
@@ -0,0 +1,51 @@
%YAML:1.0
---
# ============================================================
# 灯条检测参数(27RM)
# 敌方颜色:主人是红方 → 打蓝。用自己红板测试时可改成 red。
# 调参现场:./build/linux/x86_64/debug/detect_test 里按 c 切换颜色、r 重载本文件
# ============================================================
enemy_color: "blue"
# ---------- 蓝色 HSV 范围(OpenCV H∈[0,180], S/V∈[0,255]) ----------
blue_h_min: 100
blue_h_max: 140
blue_s_min: 60
blue_v_min: 60
# ---------- 红色 HSV 范围(H 跨 0 和 180 两端) ----------
red_h_min_1: 0
red_h_max_1: 12
red_h_min_2: 168
red_h_max_2: 180
red_s_min: 60
red_v_min: 60
# ---------- 形态学(闭运算补洞) ----------
# 注意:iters 别开大——远景灯条只有 2~3px 宽,腐蚀两轮会被整个抹掉
morph_kernel: 3
morph_iters: 1
# ---------- 灯条筛选 ----------
min_length: 8.0 # 最小灯条长度(px);8px ≈ 4.5m 处的 55mm 灯条
min_ratio: 1.5 # 长/宽最小比。远景灯条只有2px宽,模糊+阈值会把
# 掩膜撑宽到10px、比降到1.7,别设太高;真正的
# 防误检靠配对五道门
max_tilt_deg: 45.0 # 与竖直方向最大夹角
max_area: 200000.0
# ---------- 配对 ----------
max_angle_diff_deg: 15.0 # 两灯条倾角差上限
max_len_ratio: 2.2 # 两灯条长度比上限
max_center_y_ratio: 0.7 # 中心纵向差 / 平均灯条长 上限
big_small_ratio_thres: 3.0 # 灯条中心距/平均灯条长 > 此值 → 大装甲
# 小: 131.56/55 ≈ 2.4 大: 230/55 ≈ 4.2
# ---------- PCA 角点修正(jlu lightbar_corrector 同款参数) ----------
use_pca: 1 # 是否启用 PCA 角点修正。1 = 启用
pass_optimize_lightbar_width: 3 # 灯条宽度方向的搜索半径,单位像素。
normalize_max_brightness: 25.0 # 亮度归一化的最大值
lightbar_min_mean_brightness: 0.0 # 灯条平均亮度下限
padding_scale: 0.07 # 灯条宽度方向的搜索半径,单位像素。
search_start_ratio: 0.4 # 灯条宽度方向的搜索起始位置,单位像素。
search_end_ratio: 0.6 # 灯条宽度方向的搜索结束位置,单位像素。
+9 -7
View File
@@ -37,10 +37,12 @@ struct ArmorSize {
double width_mm; double width_mm;
double height_mm; double height_mm;
/** 大装甲板:英雄(一号)230mm 间距 */ /** 大装甲板:英雄(一号)。W=230mm 为官方灯条中心距名义值,
static ArmorSize big() { return ArmorSize{230.0, 56.0}; } * 等实测大板后再校正;L=55mm 与小装甲灯条同长 */
/** 小装甲板:2/3/4/5 号、哨兵 135mm 间距 */ static ArmorSize big() { return ArmorSize{230.0, 55.0}; }
static ArmorSize small() { return ArmorSize{135.0, 56.0}; } /** 小装甲板:2/3/4/5 号、哨兵。2026-09-16 实测 3 号板:
* W=131.56mm(官方规格灯条中心距 131.5mm,实测一致),L=55.0mm */
static ArmorSize small() { return ArmorSize{131.56, 55.0}; }
}; };
/** 位姿解算结果(相机坐标系,单位 mm) */ /** 位姿解算结果(相机坐标系,单位 mm) */
@@ -72,9 +74,9 @@ public:
/** /**
* 四个角点 → 装甲板位姿 * 四个角点 → 装甲板位姿
* 内部流程:solvePnPGeneric(IPPE) 取回全部候选解 → * 内部流程:solvePnPGeneric(IPPE) 取回全部候选解 → 筛掉
* 依次筛掉「z<=0(相机后方)」「重投影超差」→ 取重投影最小者。 * 「非有限值」「z<=0(相机后方)」「重投影超差」→ 取重投影最小者;
* 注:角点在解算前先做去畸变(IPPE+畸变+板内旋转组合有数值坑)。 * 若 IPPE 数值退化(全被筛掉),自动退回 SOLVEPNP_ITERATIVE。
* @param corners 图像坐标(原图,含畸变),顺序:左下、左上、右上、右下 * @param corners 图像坐标(原图,含畸变),顺序:左下、左上、右上、右下
* @param size 装甲板物理尺寸 * @param size 装甲板物理尺寸
* @return 成功返回位姿;全部候选被筛掉时返回 std::nullopt * @return 成功返回位姿;全部候选被筛掉时返回 std::nullopt
+126 -7
View File
@@ -1,16 +1,135 @@
#ifndef LIGHT_BAR_HPP #ifndef LIGHT_BAR_HPP
#define LIGHT_BAR_HPP #define LIGHT_BAR_HPP
class LightBar /**
{ * @file light_bar.hpp
public: * @brief 传统 CV 灯条检测器(27RM)
LightBar(); // 构造函数声明 *
~LightBar(); // 析构函数声明 * 管线(借鉴 jlu_vision_26 的方法):
* 1. HSV 颜色掩膜(敌方颜色:默认蓝)→ 闭运算 → 轮廓
* 2. 轮廓 → LightBar(minAreaRect + 质心 + 上下端点 + 倾角)
* 3. PCA 角点修正(jlu lightbar_corrector 同款:
* 灰度矩求对称轴,沿轴找最大亮度梯度 → 精确端点)
* 4. 左右配对(倾角差/长度比/中心高度差)→ 大/小装甲分类
* 5. 输出 4 角点:左下 → 左上 → 右上 → 右下
* (与 CameraCalib::solvePose 的模型点顺序一致)
*/
#include <opencv2/opencv.hpp>
private: #include <array>
#include <optional>
#include <string>
#include <vector>
namespace rm27 {
/** 检测参数(configs/light_bar.yaml 可覆盖) */
struct LightBarConfig {
// ---- 颜色(OpenCV HSV:H∈[0,180],S/V∈[0,255])----
std::string enemy_color = "blue";
int blue_h_min = 100, blue_h_max = 140, blue_s_min = 60, blue_v_min = 60;
int red_h_min_1 = 0, red_h_max_1 = 12; // 红色 H 跨两端
int red_h_min_2 = 168, red_h_max_2 = 180;
int red_s_min = 60, red_v_min = 60;
// ---- 形态学 ----
int morph_kernel = 3;
int morph_iters = 2;
// ---- 灯条筛选 ----
double min_length = 15.0; // 最小灯条长度(px)
double min_ratio = 2.0; // 长/宽最小比(灯条是细长条)
double max_tilt_deg = 45.0; // 与竖直方向最大夹角
double max_area = 200000.0;
// ---- 配对 ----
double max_angle_diff_deg = 15.0; // 两灯条倾角差
double max_len_ratio = 2.2; // 两灯条长度比上限
double max_center_y_ratio = 0.7; // 中心纵向差 / 平均长度
double big_small_ratio_thres = 3.0; // 中心距/平均长度 > 此值 → 大装甲
// (小 131.56/55≈2.4,大 230/55≈4.2)
// ---- PCA 角点修正(jlu lightbar_corrector 同款参数)----
bool use_pca = true;
int pass_optimize_lightbar_width = 3;
float normalize_max_brightness = 25.0f;
float lightbar_min_mean_brightness = 0.0f;
float padding_scale = 0.07f;
float search_start_ratio = 0.4f;
float search_end_ratio = 0.6f;
}; };
#endif /** 一根灯条 */
struct LightBar {
cv::RotatedRect rect; // 轮廓最小外接矩形
cv::Point2f center; // 质心(比矩形对角线交点稳)
cv::Point2f top, bottom; // 端点(图像坐标,y 向下)
float length = 0.f; // 端点距离
float width = 0.f; // 矩形短边
cv::Point2f axis; // bottom→top 单位方向
float tilt_deg = 0.f; // 与竖直方向夹角(无符号)
double signed_tilt = 0.0; // 有符号倾角(正 = 向右倾)
bool pca_ok = false; // PCA 修正成功(端点已精修)
};
/** 一块完整装甲板 */
struct ArmorTarget {
LightBar left_light, right_light;
std::array<cv::Point2f, 4> corners; // 左下 左上 右上 右下
cv::Point2f center;
bool is_big = false;
};
class LightBarDetector {
public:
LightBarDetector() = default;
explicit LightBarDetector(const std::string &yaml_path) {
loadConfig(yaml_path);
}
/** 加载参数;文件不存在时保留默认值并打印警告 */
bool loadConfig(const std::string &yaml_path);
/**
* 检测一帧,返回最优目标(多个候选时取灯条平均长度最大者)。
* @param bgr 原始帧(BGR)
* @return 没找到时 std::nullopt
*/
std::optional<ArmorTarget> detect(const cv::Mat &bgr);
const LightBarConfig &config() const { return cfg_; }
const std::vector<LightBar> &bars() const { return bars_; }
const cv::Mat &mask() const { return mask_debug_; }
/** 调试绘制:灯条(黄)、配对矩形与角点(绿) */
static void drawDebug(cv::Mat &img, const std::vector<LightBar> &bars,
const std::optional<ArmorTarget> &armor);
/** 切换敌方颜色(现场调试用) */
void setEnemyColor(const std::string &color) { cfg_.enemy_color = color; }
private:
std::vector<LightBar> findBars(const cv::Mat &bgr);
std::optional<ArmorTarget> pairBars(const cv::Mat &gray,
std::vector<LightBar> &bars);
/** 从轮廓构造灯条(借鉴 jlu types.cpp LightBar(contour)) */
static LightBar fromContour(const std::vector<cv::Point> &contour);
/** PCA 角点修正(借鉴 jlu lightbar_corrector);成功返回 true */
bool correctBar(const cv::Mat &gray, LightBar &bar);
std::optional<cv::Point2f> findCorner(const cv::Mat &gray,
const LightBar &bar,
const cv::Point2f &axis_dir,
const cv::Point2f &centroid,
float mean_val, bool toward_top);
LightBarConfig cfg_;
std::vector<LightBar> bars_;
cv::Mat mask_debug_;
};
} // namespace rm27
#endif // LIGHT_BAR_HPP
+43 -47
View File
@@ -1,5 +1,6 @@
#include "camera_calib.hpp" #include "camera_calib.hpp"
#include <cmath>
#include <cstdio> #include <cstdio>
#include <vector> #include <vector>
@@ -65,66 +66,61 @@ std::optional<ArmorPose> CameraCalib::solvePose(
const std::vector<cv::Point2f> img_points{corners[0], corners[1], const std::vector<cv::Point2f> img_points{corners[0], corners[1],
corners[2], corners[3]}; corners[2], corners[3]};
// ⚠️ 先把角点去畸变到理想像素坐标,再用 IPPE(D 传空)。 // 单个候选解的验收:有限值 → z>0(相机前方)→ 原图重投影门限
// 实测:IPPE + 畸变系数 + 板内旋转 三者组合会解出垃圾解 auto tryAccept = [&](const cv::Mat &rv,
// (OpenCV 4.x/5.x 都有这坑),去畸变后数值稳定。 const cv::Mat &tv) -> std::optional<ArmorPose> {
std::vector<cv::Point2f> ideal;
cv::undistortPoints(img_points, ideal, camera_matrix_, dist_coeffs_,
cv::noArray(), camera_matrix_);
// 平面靶标有镜像歧义:IPPE 会给出两个候选解,
// solvePnPGeneric 一次性全部取回,逐个筛。
std::vector<cv::Mat> rvecs, tvecs;
const int n = cv::solvePnPGeneric(obj_points, ideal, camera_matrix_,
cv::Mat(), rvecs, tvecs, false,
cv::SOLVEPNP_IPPE);
if (n <= 0) {
return std::nullopt;
}
int best = -1;
double best_err = 1e18;
std::vector<cv::Point2f> proj;
for (int i = 0; i < n; ++i) {
const cv::Mat &rv = rvecs[i];
const cv::Mat &tv = tvecs[i];
// ① 目标必须在相机前方(z > 0)。镜像解常落在相机后方。
const double z = tv.at<double>(2); const double z = tv.at<double>(2);
if (z <= 0.0) { if (!std::isfinite(z) || z <= 0.0) {
continue; return std::nullopt; // 镜像解常落在相机后方;NaN 直接丢
} }
std::vector<cv::Point2f> proj;
// ② 重投影误差在【原图(含畸变)】上计算,与角点像素一致。
// 4 个共面点的循环/镜像顺序给出同一个四边形→同一个解(无害);
// 真正有害的交叉乱序(蝴蝶结)会在这里误差爆炸被拦下。
cv::projectPoints(obj_points, rv, tv, camera_matrix_, dist_coeffs_, cv::projectPoints(obj_points, rv, tv, camera_matrix_, dist_coeffs_,
proj); proj);
double err = 0.0; double err = 0.0;
for (std::size_t k = 0; k < proj.size(); ++k) { for (std::size_t k = 0; k < proj.size(); ++k) {
err += cv::norm(proj[k] - img_points[k]); const double dx = proj[k].x - img_points[k].x;
const double dy = proj[k].y - img_points[k].y;
if (!std::isfinite(dx) || !std::isfinite(dy)) {
return std::nullopt;
}
err += std::sqrt(dx * dx + dy * dy);
} }
err /= static_cast<double>(proj.size()); err /= static_cast<double>(proj.size());
if (err > max_reproj_err_px_) { if (err > max_reproj_err_px_) {
continue; return std::nullopt;
} }
ArmorPose pose;
pose.rvec =
cv::Vec3d(rv.at<double>(0), rv.at<double>(1), rv.at<double>(2));
pose.tvec =
cv::Vec3d(tv.at<double>(0), tv.at<double>(1), tv.at<double>(2));
pose.reproj_error_px = err;
pose.distance_mm = cv::norm(pose.tvec);
return pose;
};
if (err < best_err) { // 路径 A:IPPE 全候选(平面靶标标准做法,一次拿到两个解便于筛镜像)
best_err = err; std::vector<cv::Mat> rvecs, tvecs;
best = i; const int n = cv::solvePnPGeneric(obj_points, img_points, camera_matrix_,
dist_coeffs_, rvecs, tvecs, false,
cv::SOLVEPNP_IPPE);
std::optional<ArmorPose> best;
for (int i = 0; i < n; ++i) {
auto p = tryAccept(rvecs[i], tvecs[i]);
if (p && (!best || p->reproj_error_px < best->reproj_error_px)) {
best = p;
} }
} }
if (best) {
if (best < 0) { return best;
return std::nullopt;
} }
ArmorPose pose; // 路径 B:IPPE 数值退化(实测 OpenCV 4.x 在「完美轴对称矩形」上返回 NaN、
const cv::Mat &rv = rvecs[best]; // 「畸变+板内旋转」上返回垃圾解)时退回 ITERATIVE,两条退化情形都稳。
const cv::Mat &tv = tvecs[best]; cv::Mat rv, tv;
pose.rvec = cv::Vec3d(rv.at<double>(0), rv.at<double>(1), rv.at<double>(2)); if (cv::solvePnP(obj_points, img_points, camera_matrix_, dist_coeffs_, rv,
pose.tvec = cv::Vec3d(tv.at<double>(0), tv.at<double>(1), tv.at<double>(2)); tv, false, cv::SOLVEPNP_ITERATIVE)) {
pose.reproj_error_px = best_err; return tryAccept(rv, tv);
pose.distance_mm = cv::norm(pose.tvec); }
return pose; return std::nullopt;
} }
+367
View File
@@ -0,0 +1,367 @@
/**
* @file detect_test.cpp
* @brief 灯条检测器验证工具(27RM)
*
* 检测 → 4 角点 → PnP → 距离/yaw/pitch,一条龙验证。
*
* 模式:
* ./detect_test 相机实时(按 c 切敌方颜色、r 重载参数)
* ./detect_test --image 照片.jpg 静态图交互
* ./detect_test --image 照片.jpg --once 跑一次就退出(脚本/沙箱用)
* ./detect_test --selftest 合成图自检:用标定内参把已知位姿的
* 装甲板渲染成图 → 检测 → PnP 对比
*/
#include "camera_calib.hpp"
#include "camera_set.hpp"
#include "light_bar.hpp"
#include <opencv2/opencv.hpp>
#include <cstdio>
#include <string>
#include <vector>
namespace {
// ---------------------------------------------------------------
// 合成渲染:按标定内参把已知位姿的装甲板画成发光灯条图
// ---------------------------------------------------------------
void drawBar(cv::Mat &img, const cv::Point2f &a, const cv::Point2f &b,
double half_width, const cv::Scalar &color) {
// 填充四边形,端点严格落在 a/b(与模型点一致,圆管端弧会超出去)
cv::Point2f d = b - a;
const float n = cv::norm(d);
if (n <= 0.f) return;
const cv::Point2f perp(-d.y / n, d.x / n);
const cv::Point2f hw = perp * static_cast<float>(half_width);
std::vector<cv::Point> poly;
poly.push_back(a + hw);
poly.push_back(b + hw);
poly.push_back(b - hw);
poly.push_back(a - hw);
cv::fillConvexPoly(img, poly, color);
}
cv::Mat renderArmor(const CameraCalib &calib, const cv::Vec3d &rvec,
const cv::Vec3d &tvec, const ArmorSize &size,
const std::string &color) {
const double W = size.width_mm, L = size.height_mm;
const std::vector<cv::Point3f> obj{
{0.f, static_cast<float>(W / 2), static_cast<float>(-L / 2)},
{0.f, static_cast<float>(W / 2), static_cast<float>(L / 2)},
{0.f, static_cast<float>(-W / 2), static_cast<float>(L / 2)},
{0.f, static_cast<float>(-W / 2), static_cast<float>(-L / 2)},
};
std::vector<cv::Point2f> pts;
cv::projectPoints(obj, rvec, tvec, calib.K(), calib.D(), pts);
cv::Mat img(calib.imageSize(), CV_8UC3, cv::Scalar(10, 12, 16));
cv::Mat noise(calib.imageSize(), CV_8UC3);
cv::randn(noise, cv::Scalar(0, 0, 0), cv::Scalar(6, 6, 6));
img += noise;
const cv::Scalar c = (color == "blue") ? cv::Scalar(230, 90, 30)
: cv::Scalar(30, 60, 240);
// 灯条宽 ≈ 长度/6.7(jlu 的 ratio_length_width)
const double half_w = L / 6.7 / 2.0;
drawBar(img, pts[0], pts[1], half_w, c); // 左条
drawBar(img, pts[3], pts[2], half_w, c); // 右条
// 干扰:两个小亮斑(检测器应忽略)
cv::circle(img, cv::Point(160, 180), 4, cv::Scalar(180, 180, 180), -1);
cv::circle(img, cv::Point(1120, 860), 5, cv::Scalar(200, 200, 200), -1);
cv::GaussianBlur(img, img, cv::Size(3, 3), 0.8);
return img;
}
// ---------------------------------------------------------------
// 合成自检
// ---------------------------------------------------------------
int runDetectSelftest(CameraCalib &calib, rm27::LightBarDetector &det) {
std::printf("=== 检测器合成自检 ===\n");
std::printf("渲染已知位姿的发光装甲板 → 灯条检测 → PnP → 与真值对比\n\n");
auto tvecOf = [](double yawDeg, double pitchDeg, double d) {
const double y = yawDeg * CV_PI / 180.0, p = pitchDeg * CV_PI / 180.0;
return cv::Vec3d(d * std::sin(y) * std::cos(p), -d * std::sin(p),
d * std::cos(y) * std::cos(p));
};
auto rvecOf = [](double rollDeg, double yawDeg, double pitchDeg) {
const double r = rollDeg * CV_PI / 180.0, y = yawDeg * CV_PI / 180.0,
p = pitchDeg * CV_PI / 180.0;
cv::Mat R = (cv::Mat_<double>(3, 3) << 0, 0, 1, 0, 1, 0, -1, 0, 0);
R = R * (cv::Mat_<double>(3, 3) << 1, 0, 0, 0, 0, -1, 0, 1, 0);
cv::Mat Rz = (cv::Mat_<double>(3, 3) << std::cos(r), -std::sin(r), 0,
std::sin(r), std::cos(r), 0, 0, 0, 1);
cv::Mat Ry = (cv::Mat_<double>(3, 3) << std::cos(y), 0, std::sin(y), 0,
1, 0, -std::sin(y), 0, std::cos(y));
cv::Mat Rx = (cv::Mat_<double>(3, 3) << 1, 0, 0, 0, std::cos(p),
-std::sin(p), 0, std::sin(p), std::cos(p));
const cv::Mat M = Rx * Ry * Rz * R;
cv::Mat rv;
cv::Rodrigues(M, rv);
return cv::Vec3d(rv.at<double>(0), rv.at<double>(1), rv.at<double>(2));
};
struct Case {
const char *name;
cv::Vec3d tvec;
cv::Vec3d rvec;
bool is_big;
cv::Vec3d expect; // (距离m, yaw°, pitch°)
};
const std::vector<Case> cases{
{"正前方 1.5m 小", tvecOf(0, 0, 1500), rvecOf(0, 0, 0), false, {1.5, 0, 0}},
{"右偏 20°, 2.0m 小", tvecOf(20, 0, 2000), rvecOf(0, 20, 0), false, {2.0, 20, 0}},
{"上仰 10°, 2.5m 小", tvecOf(0, 10, 2500), rvecOf(0, 0, 10), false, {2.5, 0, 10}},
{"右偏 15° 上仰 8°, 1.8m 小", tvecOf(15, 8, 1800), rvecOf(0, 15, 8), false, {1.8, 15, 8}},
{"板内 roll 10°, 1.5m 小", tvecOf(0, 0, 1500), rvecOf(10, 0, 0), false, {1.5, 0, 0}},
{"正前方 2.0m 大", tvecOf(0, 0, 2000), rvecOf(0, 0, 0), true, {2.0, 0, 0}},
};
bool all_pass = true;
int idx = 0;
for (const auto &c : cases) {
const ArmorSize size = c.is_big ? ArmorSize::big() : ArmorSize::small();
cv::Mat img = renderArmor(calib, c.rvec, c.tvec, size, det.config().enemy_color);
auto armor = det.detect(img);
cv::Mat dbg = img.clone();
rm27::LightBarDetector::drawDebug(dbg, det.bars(), armor);
char fname[64];
std::snprintf(fname, sizeof(fname), "detect_case%d.jpg", idx);
cv::imwrite(fname, dbg);
bool pass = false;
if (!armor) {
std::printf("%-28s ❌ 未检测到目标(灯条候选 %zu 个)\n", c.name,
det.bars().size());
for (const auto &b : det.bars()) {
std::printf(" bar: len=%.1f w=%.1f tilt=%.1f pca=%d\n",
b.length, b.width, b.tilt_deg, (int)b.pca_ok);
}
} else {
auto pose = calib.solvePose(armor->corners, size);
if (!pose) {
std::printf("%-28s ❌ 检测到但 PnP 失败\n", c.name);
} else {
double yaw, pitch;
CameraCalib::poseToYawPitch(pose->tvec, yaw, pitch);
const double d_err = std::fabs(pose->distance_mm / 1000.0 - c.expect[0]);
const double y_err = std::fabs(yaw * 180.0 / CV_PI - c.expect[1]);
const double p_err = std::fabs(pitch * 180.0 / CV_PI - c.expect[2]);
const bool size_ok = armor->is_big == c.is_big;
pass = d_err < 0.05 * c.expect[0] && y_err < 1.5 && p_err < 1.5 && size_ok;
std::printf(
"%-28s 距离 %.3fm(真 %.3f) | yaw %+.2f°(真 %+.2f) | pitch %+.2f°(真 %+.2f) | "
"%s(真 %s) | 重投影 %.2fpx | 灯条%zu %s\n",
c.name, pose->distance_mm / 1000.0, c.expect[0],
yaw * 180.0 / CV_PI, c.expect[1], pitch * 180.0 / CV_PI,
c.expect[2], armor->is_big ? "大" : "小", c.is_big ? "大" : "小",
pose->reproj_error_px, det.bars().size(),
pass ? "✅" : "❌");
if (idx == 0) {
// 对照真值角点与检测角点(诊断用)
const double W = size.width_mm, L = size.height_mm;
const std::vector<cv::Point3f> obj{
{0.f, (float)(W / 2), (float)(-L / 2)},
{0.f, (float)(W / 2), (float)(L / 2)},
{0.f, (float)(-W / 2), (float)(L / 2)},
{0.f, (float)(-W / 2), (float)(-L / 2)}};
std::vector<cv::Point2f> gt;
cv::projectPoints(obj, c.rvec, c.tvec, calib.K(), calib.D(), gt);
std::printf(" 真值: (%7.1f,%7.1f)(%7.1f,%7.1f)(%7.1f,%7.1f)(%7.1f,%7.1f)\n",
gt[0].x, gt[0].y, gt[1].x, gt[1].y, gt[2].x, gt[2].y, gt[3].x, gt[3].y);
std::printf(" 检测: (%7.1f,%7.1f)(%7.1f,%7.1f)(%7.1f,%7.1f)(%7.1f,%7.1f)\n",
armor->corners[0].x, armor->corners[0].y,
armor->corners[1].x, armor->corners[1].y,
armor->corners[2].x, armor->corners[2].y,
armor->corners[3].x, armor->corners[3].y);
for (const auto &b : det.bars()) {
std::printf(" 灯条: top(%6.1f,%6.1f) bottom(%6.1f,%6.1f) "
"len=%.1f width=%.1f pca=%d\n",
b.top.x, b.top.y, b.bottom.x, b.bottom.y,
b.length, b.width, (int)b.pca_ok);
}
}
}
}
all_pass = all_pass && pass;
++idx;
}
std::printf("\n调试图已存 detect_case0~%d.jpg(工程根目录,.gitignore 已忽略)\n", idx - 1);
std::printf("=== 检测自检%s ===\n", all_pass ? "全部通过 ✅" : "有失败项 ❌");
return all_pass ? 0 : 1;
}
// ---------------------------------------------------------------
// 参数解析与主流程
// ---------------------------------------------------------------
void printUsage() {
std::printf(
"用法:\n"
" detect_test 相机实时\n"
" detect_test --image 照片.jpg 静态图交互\n"
" detect_test --image 照片.jpg --once 跑一次退出(脚本/沙箱用)\n"
" detect_test --selftest 合成图自检\n"
"\n"
" 其他: --calib 标定文件 | --lightbar 检测参数 | --runtime 相机运行时参数\n"
"\n"
"按键: q/ESC 退出 | s 截图 | c 切换敌方颜色 | r 重载检测参数\n");
}
} // namespace
int main(int argc, char **argv) {
std::string image_path, calib_path = "configs/camera.yaml",
lightbar_path = "configs/light_bar.yaml",
runtime_path = "configs/camera_runtime.yaml";
bool once = false, selftest = false;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--image" && i + 1 < argc) {
image_path = argv[++i];
} else if (a == "--calib" && i + 1 < argc) {
calib_path = argv[++i];
} else if (a == "--lightbar" && i + 1 < argc) {
lightbar_path = argv[++i];
} else if (a == "--runtime" && i + 1 < argc) {
runtime_path = argv[++i];
} else if (a == "--once") {
once = true;
} else if (a == "--selftest") {
selftest = true;
} else if (a == "-h" || a == "--help") {
printUsage();
return 0;
} else {
std::fprintf(stderr, "未知参数: %s\n", a.c_str());
printUsage();
return 2;
}
}
CameraCalib calib(calib_path);
if (!calib.valid()) return -1;
rm27::LightBarDetector det(lightbar_path);
if (selftest) {
return runDetectSelftest(calib, det);
}
// ---------- 交互/实时 ----------
const bool use_camera = image_path.empty();
CameraGrabber cam;
cv::Mat raw;
if (use_camera) {
if (!cam.initializeCamera()) {
std::fprintf(stderr, "相机初始化失败\n");
return -1;
}
// 应用曝光/增益(与 pose_test 相同的逻辑)
cv::FileStorage rfs(runtime_path, cv::FileStorage::READ);
if (rfs.isOpened()) {
int ae = 0, autoWb = 0, fspeed = 2;
double exposureUs = 3000.0, gain = 2.0;
rfs["ae_state"] >> ae;
rfs["exposure_time_us"] >> exposureUs;
rfs["analog_gain"] >> gain;
rfs["auto_wb"] >> autoWb;
rfs["frame_speed"] >> fspeed;
rfs.release();
cam.setAeState(ae != 0);
cam.setExposureTime(exposureUs);
cam.setAnalogGainTimes(gain);
cam.setWbMode(autoWb != 0);
cam.setFrameSpeed(fspeed);
std::printf("相机参数: 曝光 %s %.0fus 增益 %.2fx\n", ae ? "自动" : "手动",
exposureUs, gain);
}
if (!cam.startCamera()) {
std::fprintf(stderr, "相机取流失败\n");
return -1;
}
} else {
raw = cv::imread(image_path);
if (raw.empty()) {
std::fprintf(stderr, "打不开图片: %s\n", image_path.c_str());
return -1;
}
}
std::printf("q=退出 s=截图 c=切敌方颜色 r=重载参数\n");
if (!once) {
cv::namedWindow("detect_test", cv::WINDOW_NORMAL);
}
int frame_idx = 0;
bool run_once = true;
while (true) {
if (use_camera && !cam.getFrame(raw)) {
std::printf("取帧失败,退出\n");
break;
}
const int64 t0 = cv::getTickCount();
auto armor = det.detect(raw);
const double dt_ms =
(cv::getTickCount() - t0) * 1000.0 / cv::getTickFrequency();
if (armor) {
const ArmorSize size = armor->is_big ? ArmorSize::big()
: ArmorSize::small();
if (auto pose = calib.solvePose(armor->corners, size)) {
double yaw, pitch;
CameraCalib::poseToYawPitch(pose->tvec, yaw, pitch);
std::printf(
"%s装甲 | 距离 %.3f m | yaw %+.2f° | pitch %+.2f° | "
"重投影 %.2fpx | 检测 %.1fms\n",
armor->is_big ? "大" : "小", pose->distance_mm / 1000.0,
yaw * 180.0 / CV_PI, pitch * 180.0 / CV_PI,
pose->reproj_error_px, dt_ms);
}
} else {
std::printf("无目标(灯条候选 %zu)\n", det.bars().size());
for (const auto &b : det.bars()) {
std::printf(" bar len=%.1f w=%.1f ratio=%.1f tilt=%.1f pca=%d\n",
b.length, b.width, b.length / b.width, b.tilt_deg,
(int)b.pca_ok);
}
}
if (!once) {
cv::Mat view = raw.clone();
rm27::LightBarDetector::drawDebug(view, det.bars(), armor);
cv::putText(view, "q=quit s=save c=color r=reload",
cv::Point(10, 28), cv::FONT_HERSHEY_SIMPLEX, 0.6,
cv::Scalar(0, 255, 0), 2);
cv::imshow("detect_test", view);
const int key = cv::waitKey(5) & 0xFF;
if (key == 27 || key == 'q') break;
if (key == 'c') {
const std::string c = det.config().enemy_color == "blue" ? "red" : "blue";
det.setEnemyColor(c);
std::printf("敌方颜色切换为 %s\n", c.c_str());
}
if (key == 'r') {
det.loadConfig(lightbar_path);
}
if (key == 's') {
char name[64];
std::snprintf(name, sizeof(name), "detect_%04d.jpg", frame_idx);
cv::imwrite(name, view);
std::printf("已保存 %s\n", name);
}
}
if (once) {
break;
}
++frame_idx;
}
if (use_camera) cam.stopCamera();
return 0;
}
+395
View File
@@ -0,0 +1,395 @@
#include "light_bar.hpp"
#include <algorithm>
#include <cmath>
#include <cstdio>
namespace rm27 {
// ------------------------------------------------------------------
// 配置加载
// ------------------------------------------------------------------
bool LightBarDetector::loadConfig(const std::string &yaml_path) {
cv::FileStorage fs(yaml_path, cv::FileStorage::READ);
if (!fs.isOpened()) {
std::fprintf(stderr,
"[LightBar] 打不开 %s,使用内置默认参数(敌方=蓝)\n",
yaml_path.c_str());
return false;
}
auto readI = [&](const char *k, int &v) {
if (!fs[k].isNone()) fs[k] >> v;
};
auto readD = [&](const char *k, double &v) {
if (!fs[k].isNone()) fs[k] >> v;
};
auto readF = [&](const char *k, float &v) {
if (!fs[k].isNone()) fs[k] >> v;
};
int tmp = 0;
fs["enemy_color"] >> cfg_.enemy_color;
readI("blue_h_min", cfg_.blue_h_min);
readI("blue_h_max", cfg_.blue_h_max);
readI("blue_s_min", cfg_.blue_s_min);
readI("blue_v_min", cfg_.blue_v_min);
readI("red_h_min_1", cfg_.red_h_min_1);
readI("red_h_max_1", cfg_.red_h_max_1);
readI("red_h_min_2", cfg_.red_h_min_2);
readI("red_h_max_2", cfg_.red_h_max_2);
readI("red_s_min", cfg_.red_s_min);
readI("red_v_min", cfg_.red_v_min);
readI("morph_kernel", cfg_.morph_kernel);
readI("morph_iters", cfg_.morph_iters);
readD("min_length", cfg_.min_length);
readD("min_ratio", cfg_.min_ratio);
readD("max_tilt_deg", cfg_.max_tilt_deg);
readD("max_area", cfg_.max_area);
readD("max_angle_diff_deg", cfg_.max_angle_diff_deg);
readD("max_len_ratio", cfg_.max_len_ratio);
readD("max_center_y_ratio", cfg_.max_center_y_ratio);
readD("big_small_ratio_thres", cfg_.big_small_ratio_thres);
if (!fs["use_pca"].isNone()) {
fs["use_pca"] >> tmp;
cfg_.use_pca = tmp != 0;
}
readI("pass_optimize_lightbar_width", cfg_.pass_optimize_lightbar_width);
readF("normalize_max_brightness", cfg_.normalize_max_brightness);
readF("lightbar_min_mean_brightness", cfg_.lightbar_min_mean_brightness);
readF("padding_scale", cfg_.padding_scale);
readF("search_start_ratio", cfg_.search_start_ratio);
readF("search_end_ratio", cfg_.search_end_ratio);
fs.release();
std::printf("[LightBar] %s 已加载: 敌方=%s PCA=%s\n", yaml_path.c_str(),
cfg_.enemy_color.c_str(), cfg_.use_pca ? "开" : "关");
return true;
}
// ------------------------------------------------------------------
// 轮廓 → 灯条(借鉴 jlu types.cpp LightBar(contour))
// ------------------------------------------------------------------
LightBar LightBarDetector::fromContour(const std::vector<cv::Point> &contour) {
LightBar bar;
bar.rect = cv::minAreaRect(contour);
// 用轮廓质心当中心(比矩形对角线交点稳)
cv::Point2f c(0.f, 0.f);
for (const auto &p : contour) {
c += cv::Point2f(static_cast<float>(p.x), static_cast<float>(p.y));
}
bar.center = c / static_cast<float>(contour.size());
// 4 角按 y 排序 → 上两点中点=top,下两点中点=bottom
cv::Point2f p[4];
bar.rect.points(p);
std::sort(p, p + 4,
[](const cv::Point2f &a, const cv::Point2f &b) { return a.y < b.y; });
bar.top = (p[0] + p[1]) * 0.5f;
bar.bottom = (p[2] + p[3]) * 0.5f;
bar.length = cv::norm(bar.top - bar.bottom);
bar.width = cv::norm(p[0] - p[1]);
cv::Point2f ax = bar.top - bar.bottom;
const float n = cv::norm(ax);
bar.axis = n > 0.f ? ax / n : cv::Point2f(0.f, -1.f);
bar.tilt_deg = static_cast<float>(
std::atan2(std::fabs(bar.axis.x), std::fabs(bar.axis.y)) * 180.0 /
CV_PI);
bar.signed_tilt = std::atan2(static_cast<double>(bar.axis.x),
-static_cast<double>(bar.axis.y));
return bar;
}
// ------------------------------------------------------------------
// 颜色掩膜 → 灯条候选
// ------------------------------------------------------------------
std::vector<LightBar> LightBarDetector::findBars(const cv::Mat &bgr) {
cv::Mat hsv;
cv::cvtColor(bgr, hsv, cv::COLOR_BGR2HSV);
cv::Mat mask(hsv.size(), CV_8UC1, cv::Scalar(0));
const bool blue = (cfg_.enemy_color == "blue" || cfg_.enemy_color == "Blue");
if (blue) {
cv::inRange(hsv,
cv::Scalar(cfg_.blue_h_min, cfg_.blue_s_min, cfg_.blue_v_min),
cv::Scalar(cfg_.blue_h_max, 255, 255), mask);
} else {
cv::Mat m1, m2;
cv::inRange(hsv,
cv::Scalar(cfg_.red_h_min_1, cfg_.red_s_min, cfg_.red_v_min),
cv::Scalar(cfg_.red_h_max_1, 255, 255), m1);
cv::inRange(hsv,
cv::Scalar(cfg_.red_h_min_2, cfg_.red_s_min, cfg_.red_v_min),
cv::Scalar(cfg_.red_h_max_2, 255, 255), m2);
cv::bitwise_or(m1, m2, mask);
}
if (cfg_.morph_kernel > 0) {
const cv::Mat k = cv::getStructuringElement(
cv::MORPH_RECT, cv::Size(cfg_.morph_kernel, cfg_.morph_kernel));
cv::morphologyEx(mask, mask, cv::MORPH_CLOSE, k, cv::Point(-1, -1),
cfg_.morph_iters);
}
mask_debug_ = mask.clone();
std::vector<std::vector<cv::Point>> contours;
cv::findContours(mask, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_NONE);
std::vector<LightBar> bars;
for (const auto &contour : contours) {
const double area = cv::contourArea(contour);
if (area < 20.0 || area > cfg_.max_area) continue;
LightBar bar = fromContour(contour);
if (bar.length < cfg_.min_length) continue;
if (bar.width < 1.f) continue;
if (bar.length / bar.width < cfg_.min_ratio) continue;
if (bar.tilt_deg > cfg_.max_tilt_deg) continue;
bars.push_back(bar);
}
return bars;
}
// ------------------------------------------------------------------
// PCA 角点修正(借鉴 jlu lightbar_corrector.cpp)
// ------------------------------------------------------------------
std::optional<cv::Point2f> LightBarDetector::findCorner(
const cv::Mat &gray, const LightBar &bar, const cv::Point2f &axis_dir,
const cv::Point2f &centroid, float mean_val, bool toward_top) {
const float L = bar.length;
const float oper = toward_top ? 1.f : -1.f;
const float dx = axis_dir.x * oper;
const float dy = axis_dir.y * oper;
std::vector<cv::Point2f> candidates;
const int n = static_cast<int>(bar.width) - 2;
const int half_n = static_cast<int>(std::round(n / 2.0));
for (int i = -half_n; i <= half_n; ++i) {
const float x0 = centroid.x + L * cfg_.search_start_ratio * dx +
static_cast<float>(i);
const float y0 = centroid.y + L * cfg_.search_start_ratio * dy;
cv::Point2f prev(x0, y0);
cv::Point2f corner(x0, y0);
float max_diff = 0.f;
bool has_corner = false;
for (float x = x0 + dx, y = y0 + dy;
std::sqrt((x - x0) * (x - x0) + (y - y0) * (y - y0)) <
L * (cfg_.search_end_ratio - cfg_.search_start_ratio);
x += dx, y += dy) {
const cv::Point2f cur(x, y);
if (cur.x < 0 || cur.x >= gray.cols || cur.y < 0 ||
cur.y >= gray.rows) {
break;
}
// 沿轴从亮(灯条内)走向暗(背景),亮度骤降处 = 端点
const float diff =
static_cast<float>(gray.at<uchar>(static_cast<int>(prev.y),
static_cast<int>(prev.x))) -
static_cast<float>(gray.at<uchar>(static_cast<int>(cur.y),
static_cast<int>(cur.x)));
if (diff > max_diff &&
static_cast<float>(gray.at<uchar>(static_cast<int>(prev.y),
static_cast<int>(prev.x))) >
mean_val) {
max_diff = diff;
corner = prev;
has_corner = true;
}
prev = cur;
}
if (has_corner) {
candidates.push_back(corner);
}
}
if (candidates.empty()) {
return std::nullopt;
}
cv::Point2f sum(0.f, 0.f);
for (const auto &p : candidates) {
sum += p;
}
return sum / static_cast<float>(candidates.size());
}
bool LightBarDetector::correctBar(const cv::Mat &gray, LightBar &bar) {
if (bar.width <= static_cast<float>(cfg_.pass_optimize_lightbar_width)) {
return false;
}
// 1) 扩大包围盒,留出搜索余量
cv::Rect box = bar.rect.boundingRect();
const int padx = static_cast<int>(box.width * cfg_.padding_scale);
const int pady = static_cast<int>(box.height * cfg_.padding_scale);
box.x -= padx;
box.y -= pady;
box.width += padx * 2;
box.height += pady * 2;
box.x = std::max(box.x, 0);
box.y = std::max(box.y, 0);
box.x = std::min(box.x, gray.cols - 1);
box.y = std::min(box.y, gray.rows - 1);
box.width = std::min(box.width, gray.cols - box.x);
box.height = std::min(box.height, gray.rows - box.y);
if (box.width <= 0 || box.height <= 0) {
return false;
}
cv::Mat roi = gray(box);
const float mean_val = static_cast<float>(cv::mean(roi)[0]);
if (mean_val <= cfg_.lightbar_min_mean_brightness) {
return false; // 太暗,不是灯条
}
cv::Mat roi_f;
roi.convertTo(roi_f, CV_32F);
cv::normalize(roi_f, roi_f, 0, cfg_.normalize_max_brightness, cv::NORM_MINMAX);
// 2) 矩分析求对称轴(jlu:从点云 PCA 换成矩分析,更快)
const cv::Moments m = cv::moments(roi_f, false);
if (m.m00 == 0.0) {
return false;
}
cv::Point2f centroid(static_cast<float>(m.m10 / m.m00),
static_cast<float>(m.m01 / m.m00));
centroid += cv::Point2f(static_cast<float>(box.x),
static_cast<float>(box.y));
const double mu20 = m.mu20, mu11 = m.mu11, mu02 = m.mu02;
if (mu20 == 0.0 && mu11 == 0.0 && mu02 == 0.0) {
return false;
}
const double theta = 0.5 * std::atan2(2.0 * mu11, mu20 - mu02);
cv::Point2f axis(static_cast<float>(std::cos(theta)),
static_cast<float>(std::sin(theta)));
axis /= cv::norm(axis);
if (axis.y > 0) {
axis = -axis; // 指向上方(图像坐标 y 向下)
}
// 3) 沿轴找两端角点(最大亮度下降处)
auto top = findCorner(gray, bar, axis, centroid, mean_val, true);
auto bottom = findCorner(gray, bar, axis, centroid, mean_val, false);
if (!top || !bottom) {
return false;
}
bar.center = centroid;
bar.axis = axis;
bar.top = *top;
bar.bottom = *bottom;
bar.length = cv::norm(bar.top - bar.bottom);
bar.signed_tilt =
std::atan2(static_cast<double>(bar.axis.x), -static_cast<double>(bar.axis.y));
bar.pca_ok = true;
return true;
}
// ------------------------------------------------------------------
// 灯条配对 → 装甲板
// ------------------------------------------------------------------
std::optional<ArmorTarget> LightBarDetector::pairBars(
const cv::Mat & /*gray*/, std::vector<LightBar> &bars) {
ArmorTarget best;
bool found = false;
double best_score = 0.0;
const int n = static_cast<int>(bars.size());
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
LightBar l = bars[i];
LightBar r = bars[j];
if (l.center.x > r.center.x) {
std::swap(l, r); // l = 左条, r = 右条
}
const float avg_len = (l.length + r.length) * 0.5f;
const float dist = std::fabs(l.center.x - r.center.x);
// ① 倾角差
const double angle_diff =
std::fabs(l.signed_tilt - r.signed_tilt) * 180.0 / CV_PI;
if (angle_diff > cfg_.max_angle_diff_deg) continue;
// ② 长度比
const float len_ratio =
std::max(l.length, r.length) / std::max(1e-3f, std::min(l.length, r.length));
if (len_ratio > cfg_.max_len_ratio) continue;
// ③ 中心纵向差
if (std::fabs(l.center.y - r.center.y) >
cfg_.max_center_y_ratio * avg_len) continue;
// ④ 中心横向距离合理(小装甲 ≈2.4 倍灯条长,大装甲 ≈4.2 倍)
if (dist < avg_len * 0.8f || dist > avg_len * 6.5f) continue;
// ⑤ PCA 精修(开了就必须两边都成功,避免半精修导致 yaw 偏差)
if (cfg_.use_pca && !(l.pca_ok && r.pca_ok)) continue;
const double score =
static_cast<double>(avg_len) - std::fabs(l.center.y - r.center.y);
if (!found || score > best_score) {
found = true;
best_score = score;
best.left_light = l;
best.right_light = r;
best.center = (l.center + r.center) * 0.5f;
// 角点顺序:板左下 → 板左上 → 板右上 → 板右下
// ⚠️ 镜像:板子朝向相机时,板的左条(+y)出现在画面右侧,
// 所以「板左下」= 画面右条的底端(r = 画面右条)
best.corners = {r.bottom, r.top, l.top, l.bottom};
best.is_big = (dist / avg_len) > cfg_.big_small_ratio_thres;
}
}
}
if (!found) {
return std::nullopt;
}
return best;
}
// ------------------------------------------------------------------
// 主入口
// ------------------------------------------------------------------
std::optional<ArmorTarget> LightBarDetector::detect(const cv::Mat &bgr) {
if (bgr.empty()) {
return std::nullopt;
}
bars_ = findBars(bgr);
if (cfg_.use_pca) {
cv::Mat gray;
cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY);
for (auto &b : bars_) {
correctBar(gray, b);
}
}
return pairBars(bgr, bars_);
}
// ------------------------------------------------------------------
// 调试绘制
// ------------------------------------------------------------------
void LightBarDetector::drawDebug(cv::Mat &img, const std::vector<LightBar> &bars,
const std::optional<ArmorTarget> &armor) {
for (const auto &b : bars) {
cv::line(img, b.bottom, b.top, cv::Scalar(0, 255, 255), 2);
cv::circle(img, b.center, 3, cv::Scalar(0, 255, 255), -1);
}
if (!armor) {
return;
}
const auto &a = *armor;
for (int k = 0; k < 4; ++k) {
cv::line(img, a.corners[k], a.corners[(k + 1) % 4],
cv::Scalar(0, 255, 0), 2);
}
for (const auto &p : a.corners) {
cv::circle(img, p, 4, cv::Scalar(0, 255, 0), -1);
}
cv::putText(img, a.is_big ? "BIG" : "SMALL",
a.center + cv::Point2f(-30.f, -15.f), cv::FONT_HERSHEY_SIMPLEX,
0.8, cv::Scalar(0, 255, 0), 2);
}
} // namespace rm27
+3 -3
View File
@@ -3,7 +3,7 @@
* @brief 几何链路验证工具:4 角点 → PnP → 距离 / yaw / pitch * @brief 几何链路验证工具:4 角点 → PnP → 距离 / yaw / pitch
* *
* 在写装甲板检测器之前,先用它验证「标定 + 坐标约定 + PnP 解算」 * 在写装甲板检测器之前,先用它验证「标定 + 坐标约定 + PnP 解算」
* 是否正确:拿一块真的装甲板(或打印的 135mm 矩形)摆在相机前, * 是否正确:拿一块真的装甲板(或打印的 131.56×55mm 矩形)摆在相机前,
* 鼠标点 4 个角,用卷尺/量角器核对输出的距离与角度。 * 鼠标点 4 个角,用卷尺/量角器核对输出的距离与角度。
* 之后接任何检测器出问题,都一定是检测的锅。 * 之后接任何检测器出问题,都一定是检测的锅。
* *
@@ -55,7 +55,7 @@ void printUsage()
"\n" "\n"
" 其他: --calib 标定文件(默认 configs/camera.yaml)\n" " 其他: --calib 标定文件(默认 configs/camera.yaml)\n"
" --runtime 相机运行时参数(默认 configs/camera_runtime.yaml)\n" " --runtime 相机运行时参数(默认 configs/camera_runtime.yaml)\n"
" --big 大装甲板 230mm(默认小装甲板 135mm)\n" " --big 大装甲板 230mm(默认小装甲板 131.56mm,实测 3 号板)\n"
" --size WxL 自定义矩形尺寸(毫米),如 --size 180x120\n" " --size WxL 自定义矩形尺寸(毫米),如 --size 180x120\n"
"\n" "\n"
"相机模式按键: r 清除重选 | b 切换大小装甲 | u 切换去畸变显示 | s 截图 | q/ESC 退出\n"); "相机模式按键: r 清除重选 | b 切换大小装甲 | u 切换去畸变显示 | s 截图 | q/ESC 退出\n");
@@ -405,7 +405,7 @@ int main(int argc, char **argv)
} }
} }
std::printf("装甲板: %s (%.0f x %.0f mm)\n", bigArmor ? "大 230mm" : "小 135mm", std::printf("装甲板: %s (%.2f x %.2f mm)\n", bigArmor ? "大 230mm" : "小(实测3号)",
size.width_mm, size.height_mm); size.width_mm, size.height_mm);
std::printf("请按顺序点 4 个角: 左下 → 左上 → 右上 → 右下\n"); std::printf("请按顺序点 4 个角: 左下 → 左上 → 右上 → 右下\n");
std::printf("r=清除 b=切换大小装甲 u=切换去畸变显示 s=截图 q=退出\n"); std::printf("r=清除 b=切换大小装甲 u=切换去畸变显示 s=截图 q=退出\n");
+11
View File
@@ -30,6 +30,7 @@ target("27RM")
remove_files("src/capture_calib.cpp") remove_files("src/capture_calib.cpp")
remove_files("src/calibrate.cpp") remove_files("src/calibrate.cpp")
remove_files("src/pose_test.cpp") remove_files("src/pose_test.cpp")
remove_files("src/detect_test.cpp")
-- 标定图采集工具 -- 标定图采集工具
-- ./build/linux/x86_64/debug/capture_calib (MindVision 相机) -- ./build/linux/x86_64/debug/capture_calib (MindVision 相机)
@@ -53,3 +54,13 @@ target("pose_test")
add_rules("rm_deps") add_rules("rm_deps")
set_kind("binary") set_kind("binary")
add_files("src/pose_test.cpp", "src/camera_calib.cpp", "src/camera_set.cpp") add_files("src/pose_test.cpp", "src/camera_calib.cpp", "src/camera_set.cpp")
-- 灯条检测验证工具(检测 → 4 角点 → PnP → 距离/yaw/pitch)
-- ./build/linux/x86_64/debug/detect_test (相机实时)
-- ./build/linux/x86_64/debug/detect_test --selftest (合成自检,无需相机)
-- ./build/linux/x86_64/debug/detect_test --image x.jpg --once
target("detect_test")
add_rules("rm_deps")
set_kind("binary")
add_files("src/detect_test.cpp", "src/light_bar.cpp",
"src/camera_calib.cpp", "src/camera_set.cpp")