diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1a8184f..536b20a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -6,7 +6,11 @@ "command": "D:\\Program Files\\mingw64\\bin\\g++.exe", "args": [ "-std=c++11", - "-g", + // 注意: 原来是 -g, 物理内存不足时 as.exe 会因调试信息过大而 + // "Internal error (Segmentation fault)". 降到最小调试信息可稳定通过。 + "-g1", + "-fno-var-tracking", + "-fno-var-tracking-assignments", "-finput-charset=UTF-8", "-fexec-charset=UTF-8", "-I", "PAVO2_SDK\\include", @@ -19,6 +23,7 @@ "detect.cpp", "count.cpp", "tcp_server.cpp", + "crash_handler.cpp", "PAVO2_SDK\\src\\pavo2_driver.cpp", "PAVO2_SDK\\src\\pavo2_net.cpp", "PAVO2_SDK\\src\\pavo2_parser.cpp", @@ -26,6 +31,7 @@ "PAVO2_SDK\\src\\LdrAlgorithm.cpp", "-o", "main.exe", "-lws2_32", + "-luser32", "-lpthread", "-static-libgcc", "-static-libstdc++" diff --git a/PAVO2_SDK/include/pavo2_filter.h b/PAVO2_SDK/include/pavo2_filter.h index ba47431..55013a5 100644 --- a/PAVO2_SDK/include/pavo2_filter.h +++ b/PAVO2_SDK/include/pavo2_filter.h @@ -2,6 +2,7 @@ #define __PAVO2_FILTER_H__ #include +#include #include #include "pavo2_types.h" @@ -47,6 +48,12 @@ private: int m_tailfilter_method; int m_tailfilter_method_angle; int front_filter_level; + + // 保护上面全部状态:SDK 的接收/解析线程在每帧数据里调用 Filter(), + // 而业务线程重连时会调用 EnableTailFilter()/EnableFrontFilter() 改这些状态, + // 两者并发会踩坏 m_window_vector / 滤波方式,实测导致 Filter 内越界读崩溃。 + // 只在 public 接口加锁,私有算法函数内部不再加锁(避免不可重入死锁)。 + std::mutex m_mtx; }; } //namespace pavo2 diff --git a/PAVO2_SDK/src/LdrAlgorithm.cpp b/PAVO2_SDK/src/LdrAlgorithm.cpp index 4919f1f..7e9cf87 100644 --- a/PAVO2_SDK/src/LdrAlgorithm.cpp +++ b/PAVO2_SDK/src/LdrAlgorithm.cpp @@ -78,6 +78,7 @@ void LdrAlgorithm::FarHighRelectivityPreProcess(pavo_response_scan_t* points, in void LdrAlgorithm::FarHighRelectivityFilter(pavo_response_scan_t* points, int size) { + if(!points || size <= 0) return; // 防御:空/异常输入 FarHighRelectivityPreProcess(points, size); #if 1 @@ -116,11 +117,15 @@ void LdrAlgorithm::FarHighRelectivityFilter(pavo_response_scan_t* points, int si if(index_left >= 5) { window_num = 1; - while(window_num < 50) // + // 边界防护(实测崩溃点):index_left - window_num - w 必须 >= 0。 + // 原实现只检查 index_left>=5,但 window_num 会一直加到 50, + // 于是 points[负索引] 越界读写 —— 轻则访问违规,重则破坏堆。 + while(window_num < 50 && (window_num + FILTER_WINDOW_SIZE) <= index_left) { for(int w = 0; w <= FILTER_WINDOW_SIZE; w++) { int index = index_left - window_num - w; + if(index < 0) break; // 双保险 if(points[index].distance > distance_max) distance_max = points[index].distance; if(points[index].distance < distance_min) @@ -154,11 +159,14 @@ void LdrAlgorithm::FarHighRelectivityFilter(pavo_response_scan_t* points, int si if(index_right + 5 < size) { window_num = 1; - while(window_num < 50) // + // 边界防护:index_right + window_num + w 必须 < size。 + // 原实现只检查 index_right+5= size) break; // 双保险 if(points[index].distance > distance_max) distance_max = points[index].distance; if(points[index].distance < distance_min) diff --git a/PAVO2_SDK/src/pavo2_filter.cpp b/PAVO2_SDK/src/pavo2_filter.cpp index 40b50b4..72319c9 100644 --- a/PAVO2_SDK/src/pavo2_filter.cpp +++ b/PAVO2_SDK/src/pavo2_filter.cpp @@ -24,6 +24,11 @@ MADataFilter::MADataFilter() pavo_response_scan_t* MADataFilter::Filter(pavo_response_scan_t* data_buffer, int count) { + // 与 EnableTailFilter()/EnableFrontFilter() 互斥:重连线程改参数 与 解析线程跑滤波 + // 并发时,m_window_vector/m_tailfilter_method 等状态会被踩坏,进而越界崩溃。 + std::lock_guard lk(m_mtx); + + if(!data_buffer || count <= 0) return data_buffer; // 防御:空帧/异常参数 if(m_tailfilter_method_angle) { if(count <= m_window_size) return data_buffer; @@ -417,8 +422,17 @@ uint16_t MADataFilter::SearchSkipPoint(pavo_response_scan_t* data_buffer, int id { int start, count; int window_count; + // 防御:与 SearchContinuityPoint 同一类越界风险 + if(!data_buffer || capacity <= 0) return 0; + if(m_window_size == 0) return 0; + if(m_window_vector.size() < (std::size_t)m_window_size) + m_window_vector.resize(m_window_size); + start = idx; count = capacity; + if(start < 0) start = 0; + if(start >= count) start = count - 1; + if(start < 0) return 0; int i, ret = 0; double distance1; double distance_diff = 0; @@ -449,7 +463,9 @@ uint16_t MADataFilter::SearchSkipPoint(pavo_response_scan_t* data_buffer, int id } else { - for(int j = start - m_window_size; (window_count < m_window_size) && (j < count); j++) + int jbegin = start - (int)m_window_size; + if(jbegin < 0) jbegin = 0; // 钳制起点,避免 j 越界读写 data_buffer + for(int j = jbegin; (window_count < m_window_size) && (j < count); j++) { ret = j; m_window_vector[window_count % m_window_size] = j; @@ -489,8 +505,17 @@ uint16_t MADataFilter::SearchContinuityPoint(pavo_response_scan_t* data_buffer, double distance_diff = 0; double sum, avg; + // 防御(实测崩溃点):畸形帧/参数异常时直接返回,绝不越界读写 data_buffer + if(!data_buffer || capacity <= 0) return 0; + if(m_window_size == 0) return 0; + if(m_window_vector.size() < (std::size_t)m_window_size) + m_window_vector.resize(m_window_size); + start = idx; count = capacity; + if(start < 0) start = 0; // 负起点会让后续 j 越界 + if(start >= count) start = count - 1; + if(start < 0) return 0; //init the window data window_count = 0; @@ -517,7 +542,9 @@ uint16_t MADataFilter::SearchContinuityPoint(pavo_response_scan_t* data_buffer, } else { - for(int j = start - m_window_size; (window_count < m_window_size) && (j < count); j++) + int jbegin = start - (int)m_window_size; + if(jbegin < 0) jbegin = 0; // 钳制起点,避免 j 越界读写 data_buffer + for(int j = jbegin; (window_count < m_window_size) && (j < count); j++) { ret = j; m_window_vector[window_count % m_window_size] = j; @@ -552,6 +579,10 @@ uint16_t MADataFilter::HandlePoint(pavo_response_scan_t* data_buffer, int start, { int i; int count = capacity; + if(!data_buffer || capacity <= 0) return 0; + if(start < 0) start = 0; // 负起点会越界读写 + if(end > count) end = count; + if(start >= end) return start; int half = (start + end) / 2; for(i = start; (i < end) && (i < count); i++) { @@ -613,11 +644,13 @@ void MADataFilter::SetDistance(double size) void MADataFilter::EnableTailFilter(int method) { + std::lock_guard lk(m_mtx); // 与 Filter() 互斥 m_tailfilter_method_angle = method; } void MADataFilter::EnableFrontFilter(int level) { + std::lock_guard lk(m_mtx); // 与 Filter() 互斥 front_filter_level = level; } diff --git a/PAVO2_SDK/src/pavo2_parser.cpp b/PAVO2_SDK/src/pavo2_parser.cpp index 70b7f06..af3cd97 100644 --- a/PAVO2_SDK/src/pavo2_parser.cpp +++ b/PAVO2_SDK/src/pavo2_parser.cpp @@ -672,16 +672,71 @@ bool Pavo2FrameParser::parsePointCloud(char* data_ptr, std::size_t data_size) } } + // 空点云(未知数据类型/畸形帧)不能再取 data[0]:对空 vector 取首元素地址是未定义行为, + // 且会把空帧塞进队列,下游扫描会话拿到 0 点帧还会继续跑滤波。这里直接丢弃该帧。 + if(scan.data.empty()) + return false; + + // ==================== 滤波(带护栏缓冲区) ==================== + // SDK 的滤波算法有"窗口扩展不检查边界"的缺陷:LdrAlgorithm.cpp 会算出负索引 + // (index_left - window_num - w)、pavo2_filter.cpp 会越过上界 —— 已在客户机上 + // 实测触发 0xC0000005 使进程崩溃,越界写还会破坏堆。 + // 这里把点集放进前后各带冗余区的缓冲区再交给 SDK:越界访问只会落在护栏区, + // 不触发访问违规、不破坏堆;滤波后只取回中间的有效数据。 + // 效果:即便 SDK 还有其它未被发现的同类越界,进程也不会再崩,后续计算照常进行。 + const int GUARD = 512; // 前后各冗余 512 点(SDK 窗口最大约 ±70) + const int n = (int)scan.data.size(); + + // 用裸数组而非 std::vector:本文件在物理内存紧张时,vector 模板实例化会让 + // 编译器内部段错误(-g 大 TU 常见)。裸数组既避开该问题,也避免每帧分配。 + static thread_local pavo_response_scan_t* gbuf = nullptr; + static thread_local std::size_t gcap = 0; + const std::size_t need = (std::size_t)n + (std::size_t)GUARD * 2; + if(need > gcap || gbuf == nullptr) + { + delete[] gbuf; + gbuf = nullptr; + gcap = need + 1024; // 预留余量,减少重分配 + gbuf = new (std::nothrow) pavo_response_scan_t[gcap]; + if(!gbuf) { gcap = 0; return false; } // 分配失败:宁可丢弃该帧也不崩 + } + // 护栏区填 0xFF 哨兵:SDK 一旦越界写就会被检测到(越界写是破坏堆的元凶) + std::memset(gbuf, 0xFF, sizeof(pavo_response_scan_t) * (std::size_t)GUARD); + std::memset(gbuf + GUARD + n, 0xFF, sizeof(pavo_response_scan_t) * (std::size_t)GUARD); + std::memcpy(gbuf + GUARD, &scan.data[0], sizeof(pavo_response_scan_t) * (std::size_t)n); + + pavo_response_scan_t* gp = gbuf + GUARD; if(hr_level == 1) - algorithm.FarHighRelectivityPreProcess(&(scan.data[0]), (int)scan.data.size()); + algorithm.FarHighRelectivityPreProcess(gp, n); else if(hr_level == 2) - algorithm.FarHighRelectivityFilter(&(scan.data[0]), (int)scan.data.size()); + algorithm.FarHighRelectivityFilter(gp, n); if(m_pMADataFilter) + m_pMADataFilter->Filter(gp, n); + + // 检查护栏是否被写脏 → SDK 发生了越界写。不崩溃,但要留痕便于定位。 + static thread_local unsigned long oob_hits = 0; // 累计次数 + static thread_local unsigned long oob_printed = 0; + int low_bad = 0, high_bad = 0; + for(int i = 0; i < GUARD; ++i) + if(gbuf[GUARD - 1 - i].distance != 0xFFFF) { low_bad = i + 1; break; } + for(int i = 0; i < GUARD; ++i) + if(gbuf[GUARD + n + i].distance != 0xFFFF) { high_bad = i + 1; break; } + if(low_bad || high_bad) { - m_pMADataFilter->Filter(&(scan.data[0]), (int)scan.data.size()); + ++oob_hits; + if(oob_hits - oob_printed >= 50) // 限流,避免刷屏 + { + oob_printed = oob_hits; + std::cerr << "[SDK防护] 检测到滤波越界写(已隔离,不影响运行): 累计 " + << oob_hits << " 次, 本次点数=" << n + << ", 向前越界=" << low_bad << "点, 向后越界=" << high_bad + << "点 (hr_level=" << hr_level << ")" << std::endl; + } } + std::memcpy(&scan.data[0], gp, sizeof(pavo_response_scan_t) * (std::size_t)n); + m_scans_que.enqueue(scan); return true; diff --git a/config.cpp b/config.cpp index 05e1c6d..99587a6 100644 --- a/config.cpp +++ b/config.cpp @@ -1,6 +1,7 @@ #include "config.h" #include "json_utils.h" +#include #include #include #include @@ -9,6 +10,45 @@ // ==================== 全局配置实例 ==================== AppConfig cfg; +// ==================== 线程级雷达配置绑定 ==================== +// 每个雷达一份参数;业务代码用 radar_cfg() 取"当前线程"该用哪一份。 +// 采集线程/请求线程/异步保存线程在入口用 RadarScope 绑定。 +namespace +{ + thread_local std::shared_ptr tls_rc; // 当前线程绑定的雷达配置 + const RadarConfig g_empty_rc; // 无任何雷达时的空配置(全默认) +} + +const RadarConfig& radar_cfg() +{ + if (tls_rc) return *tls_rc; + std::shared_ptr f = cfg.first(); + return f ? *f : g_empty_rc; +} + +RadarScope::RadarScope(const std::string& radar_id) + : prev_(tls_rc) +{ + std::shared_ptr r = cfg.find(radar_id); + if (r) tls_rc = r; // 未找到时保持原绑定,不使用默认雷达兜底 +} + +RadarScope::~RadarScope() { tls_rc = prev_; } + +std::shared_ptr AppConfig::find(const std::string& id) const +{ + if (id.empty()) return first(); + auto it = radars.find(id); + return (it == radars.end()) ? std::shared_ptr() : it->second; +} + +std::shared_ptr AppConfig::first() const +{ + if (ids.empty()) return std::shared_ptr(); + auto it = radars.find(ids.front()); + return (it == radars.end()) ? std::shared_ptr() : it->second; +} + // 解析 "HH:MM[:SS]" -> 时/分/秒;非法返回 false static bool parse_clean_time(const std::string& s, int& h, int& m, int& sec) { @@ -20,190 +60,260 @@ static bool parse_clean_time(const std::string& s, int& h, int& m, int& sec) return h >= 0 && h <= 23 && m >= 0 && m <= 59 && sec >= 0 && sec <= 59; } -// ==================== 配置加载(JSON + // 注释行,按分组嵌套解析) ==================== -void load_config(const std::string& path) +// 把一份"单雷达参数对象"解析进 rc(缺项保持默认值)。 +// obj 形如 {"radar":{..},"scan":{..},"detect":{..},"count":{..},"filter":{..}} +static void parse_radar_sections(const std::string& obj, RadarConfig& rc) { - std::ifstream fin(path); - if (!fin.is_open()) - { - std::cerr << "[WARN] 未找到配置文件: " << path << std::endl; - exit(-1); - } - - // 1. 剔除行首(忽略空白)以 // 开头的注释行,其余拼接为 JSON 文本 - std::ostringstream json_oss; - std::string line; - while (std::getline(fin, line)) - { - size_t b = line.find_first_not_of(" \t\r\n"); - if (b != std::string::npos && line.compare(b, 2, "//") == 0) - continue; // 整行注释 - json_oss << line << "\n"; - } - fin.close(); - const std::string text = json_oss.str(); - std::string s; double d; bool bv; std::vector> poly; // ---------- radar: 雷达连接与雷达内置滤波 ---------- - if (json_get_string(text, "radar", "ip", s)) cfg.radar.ip = s; - if (json_get_number(text, "radar", "port", d)) cfg.radar.port = (uint16_t)d; - if (json_get_number(text, "radar", "hz", d)) cfg.radar.hz = d; - if (json_get_number(text, "radar", "tail_filter", d)) cfg.radar.tail_filter = (int)d; - if (json_get_number(text, "radar", "front_filter", d)) cfg.radar.front_filter = (int)d; - if (json_get_number(text, "radar", "far_hr_filter", d)) cfg.radar.far_hr_filter = (int)d; - if (json_get_bool(text, "radar", "near_hr_filter", bv)) cfg.radar.near_hr_filter = bv; + if (json_get_string(obj, "radar", "ip", s)) rc.radar.ip = s; + if (json_get_number(obj, "radar", "port", d)) rc.radar.port = (uint16_t)d; + if (json_get_number(obj, "radar", "hz", d)) rc.radar.hz = d; + if (json_get_number(obj, "radar", "tail_filter", d)) rc.radar.tail_filter = (int)d; + if (json_get_number(obj, "radar", "front_filter", d)) rc.radar.front_filter = (int)d; + if (json_get_number(obj, "radar", "far_hr_filter", d)) rc.radar.far_hr_filter = (int)d; + if (json_get_bool(obj, "radar", "near_hr_filter", bv)) rc.radar.near_hr_filter = bv; // ---------- scan: 扫描采集与走行 ---------- - if (json_get_number(text, "scan", "collect_duration_s", d)) cfg.scan.collect_duration_s = (int)d; - if (json_get_number(text, "scan", "z_speed", d)) cfg.scan.z_speed = d; - if (json_get_string(text, "scan", "z_speed_type", s)) + if (json_get_number(obj, "scan", "collect_duration_s", d)) rc.scan.collect_duration_s = (int)d; + if (json_get_number(obj, "scan", "z_speed", d)) rc.scan.z_speed = d; + if (json_get_string(obj, "scan", "z_speed_type", s)) { - if (s == "configuration" || s == "uniformSpeed") cfg.scan.z_speed_type = s; - else cfg.scan.z_speed_type = "uniformSpeed"; + if (s == "configuration" || s == "uniformSpeed") rc.scan.z_speed_type = s; + else rc.scan.z_speed_type = "uniformSpeed"; } // ---------- detect: 截面分析 ---------- - if (json_get_number(text, "detect", "z_range_mm", d)) cfg.detect.z_range_mm = d; - if (json_get_number(text, "detect", "y_range_mm", d)) cfg.detect.y_range_mm = d; - if (json_get_number(text, "detect", "rect_width_px", d)) cfg.detect.rect_width_px = (int)d; - if (json_get_number(text, "detect", "rect_length_ratio", d)) cfg.detect.rect_length_ratio = d; + if (json_get_number(obj, "detect", "z_range_mm", d)) rc.detect.z_range_mm = d; + if (json_get_number(obj, "detect", "y_range_mm", d)) rc.detect.y_range_mm = d; + if (json_get_number(obj, "detect", "rect_width_px", d)) rc.detect.rect_width_px = (int)d; + if (json_get_number(obj, "detect", "rect_length_ratio", d)) rc.detect.rect_length_ratio = d; // ---------- count: 分层计数 ---------- - if (json_get_number(text, "count", "floor_height", d)) cfg.count.floor_height = d; - if (json_get_number(text, "count", "base_tolerance", d)) + if (json_get_number(obj, "count", "floor_height", d)) rc.count.floor_height = d; + if (json_get_number(obj, "count", "base_tolerance", d)) { - cfg.count.base_tolerance = d; - if (cfg.count.base_tolerance < 0) cfg.count.base_tolerance = 0; + rc.count.base_tolerance = d; + if (rc.count.base_tolerance < 0) rc.count.base_tolerance = 0; } - if (json_get_number(text, "count", "per_level_tolerance", d)) + if (json_get_number(obj, "count", "per_level_tolerance", d)) { - cfg.count.per_level_tolerance = d; - if (cfg.count.per_level_tolerance < 0) cfg.count.per_level_tolerance = 0; + rc.count.per_level_tolerance = d; + if (rc.count.per_level_tolerance < 0) rc.count.per_level_tolerance = 0; } - if (json_get_number(text, "count", "point_dilate", d)) + if (json_get_number(obj, "count", "point_dilate", d)) { - cfg.count.point_dilate = (int)d; - if (cfg.count.point_dilate < 1) cfg.count.point_dilate = 1; - if (cfg.count.point_dilate > 64) cfg.count.point_dilate = 64; + rc.count.point_dilate = (int)d; + if (rc.count.point_dilate < 1) rc.count.point_dilate = 1; + if (rc.count.point_dilate > 64) rc.count.point_dilate = 64; } - if (json_get_string(text, "count", "z_interpolate_mode", s)) + if (json_get_number(obj, "count", "interp_bucket_deg", d)) { - if (s == "CopyTranslation" || s == "AngleBucketInterpolation") cfg.count.z_interpolate_mode = s; - else cfg.count.z_interpolate_mode = "none"; + // 角度插值桶宽(度)。应等于雷达实际角度分辨率; 越界时回退 0.08 + rc.count.interp_bucket_deg = d; + if (rc.count.interp_bucket_deg < 0.005) rc.count.interp_bucket_deg = 0.08; + if (rc.count.interp_bucket_deg > 5.0) rc.count.interp_bucket_deg = 0.08; } - if (json_get_string(text, "count", "layer_algorithm", s)) + if (json_get_string(obj, "count", "z_interpolate_mode", s)) { - if (s == "HalfMax" || s == "KdePeak") cfg.count.layer_algorithm = s; - else cfg.count.layer_algorithm = "LevelTolerance"; // 非法值回退默认算法 + if (s == "CopyTranslation" || s == "AngleBucketInterpolation" + || s == "AngleBucketInterpolationByDilate") rc.count.z_interpolate_mode = s; + else rc.count.z_interpolate_mode = "none"; // 未知拼写一律按不插值处理 } - if (json_get_number(text, "count", "kde_bandwidth", d)) + if (json_get_string(obj, "count", "layer_algorithm", s)) { - cfg.count.kde_bandwidth = d; - if (cfg.count.kde_bandwidth < 0) cfg.count.kde_bandwidth = 0; + if (s == "HalfMax" || s == "KdePeak") rc.count.layer_algorithm = s; + else rc.count.layer_algorithm = "LevelTolerance"; // 非法值回退默认算法 } - if (json_get_number(text, "count", "kde_step", d)) + if (json_get_number(obj, "count", "kde_bandwidth", d)) { - cfg.count.kde_step = d; - if (cfg.count.kde_step < 0.1) cfg.count.kde_step = 0.1; // 步长过小无意义且极慢 - if (cfg.count.kde_step > 100.0) cfg.count.kde_step = 100.0; + rc.count.kde_bandwidth = d; + if (rc.count.kde_bandwidth < 0) rc.count.kde_bandwidth = 0; + } + if (json_get_number(obj, "count", "kde_step", d)) + { + rc.count.kde_step = d; + if (rc.count.kde_step < 0.1) rc.count.kde_step = 0.1; // 步长过小无意义且极慢 + if (rc.count.kde_step > 100.0) rc.count.kde_step = 100.0; } // ---------- filter: 多边形过滤 ---------- poly.clear(); - if (json_get_point_array(text, "filter", "polygon", poly) >= 3) + if (json_get_point_array(obj, "filter", "polygon", poly) >= 3) { - cfg.filter.polygon = poly; - cfg.filter.poly_n = (int)poly.size(); + rc.filter.polygon = poly; + rc.filter.poly_n = (int)poly.size(); } - // ---------- filter.tail_cut: 图片级尾部减除(旧方案) ---------- - // 三级取值:filter 组 -> tail_cut 子对象 -> 子对象内字段 + // ---------- filter.tail_cut ---------- { - std::string o = json_get_raw(text, "filter", "tail_cut"); + std::string o = json_get_raw(obj, "filter", "tail_cut"); if (!o.empty()) { - if (json_get_bool(o, "enable", bv)) cfg.filter.tail_cut.enable = bv; - if (json_get_number(o, "max_tail_length", d)) { cfg.filter.tail_cut.max_tail_length = (int)d; if (cfg.filter.tail_cut.max_tail_length < 0) cfg.filter.tail_cut.max_tail_length = 0; } - if (json_get_number(o, "y_delta", d)) { cfg.filter.tail_cut.y_delta = d; if (cfg.filter.tail_cut.y_delta < 0) cfg.filter.tail_cut.y_delta = 0; } - if (json_get_number(o, "min_run_length", d)) { cfg.filter.tail_cut.min_run_length = (int)d; if (cfg.filter.tail_cut.min_run_length < 1) cfg.filter.tail_cut.min_run_length = 1; } - if (json_get_number(o, "core_min_len", d)) { cfg.filter.tail_cut.core_min_len = (int)d; if (cfg.filter.tail_cut.core_min_len < 0) cfg.filter.tail_cut.core_min_len = 0; } - if (json_get_number(o, "support_rows", d)) { cfg.filter.tail_cut.support_rows = (int)d; if (cfg.filter.tail_cut.support_rows < 0) cfg.filter.tail_cut.support_rows = 0; if (cfg.filter.tail_cut.support_rows > 4) cfg.filter.tail_cut.support_rows = 4; } + if (json_get_bool(o, "enable", bv)) rc.filter.tail_cut.enable = bv; + if (json_get_number(o, "max_tail_length", d)) { rc.filter.tail_cut.max_tail_length = (int)d; if (rc.filter.tail_cut.max_tail_length < 0) rc.filter.tail_cut.max_tail_length = 0; } + if (json_get_number(o, "y_delta", d)) { rc.filter.tail_cut.y_delta = d; if (rc.filter.tail_cut.y_delta < 0) rc.filter.tail_cut.y_delta = 0; } + if (json_get_number(o, "min_run_length", d)) { rc.filter.tail_cut.min_run_length = (int)d; if (rc.filter.tail_cut.min_run_length < 1) rc.filter.tail_cut.min_run_length = 1; } + if (json_get_number(o, "core_min_len", d)) { rc.filter.tail_cut.core_min_len = (int)d; if (rc.filter.tail_cut.core_min_len < 0) rc.filter.tail_cut.core_min_len = 0; } + if (json_get_number(o, "support_rows", d)) { rc.filter.tail_cut.support_rows = (int)d; if (rc.filter.tail_cut.support_rows < 0) rc.filter.tail_cut.support_rows = 0; if (rc.filter.tail_cut.support_rows > 4) rc.filter.tail_cut.support_rows = 4; } } } - // ---------- filter.sparse_cut: 点云级稀疏拖影过滤(新方案) ---------- + // ---------- filter.sparse_cut ---------- { - std::string o = json_get_raw(text, "filter", "sparse_cut"); + std::string o = json_get_raw(obj, "filter", "sparse_cut"); if (!o.empty()) { - if (json_get_bool(o, "enable", bv)) cfg.filter.sparse_cut.enable = bv; - if (json_get_number(o, "x_radius", d)) { cfg.filter.sparse_cut.x_radius = d; if (cfg.filter.sparse_cut.x_radius < 0) cfg.filter.sparse_cut.x_radius = 0; } - if (json_get_number(o, "circle_min", d)) { cfg.filter.sparse_cut.circle_min = (int)d; if (cfg.filter.sparse_cut.circle_min < 1) cfg.filter.sparse_cut.circle_min = 1; } - if (json_get_number(o, "run_min", d)) { cfg.filter.sparse_cut.run_min = (int)d; if (cfg.filter.sparse_cut.run_min < 1) cfg.filter.sparse_cut.run_min = 1; } + if (json_get_bool(o, "enable", bv)) rc.filter.sparse_cut.enable = bv; + if (json_get_number(o, "x_radius", d)) { rc.filter.sparse_cut.x_radius = d; if (rc.filter.sparse_cut.x_radius < 0) rc.filter.sparse_cut.x_radius = 0; } + if (json_get_number(o, "circle_min", d)) { rc.filter.sparse_cut.circle_min = (int)d; if (rc.filter.sparse_cut.circle_min < 1) rc.filter.sparse_cut.circle_min = 1; } + if (json_get_number(o, "run_min", d)) { rc.filter.sparse_cut.run_min = (int)d; if (rc.filter.sparse_cut.run_min < 1) rc.filter.sparse_cut.run_min = 1; } } } +} + +// ==================== 配置加载(根级 map 型 JSON + // 注释行) ==================== +// 结构: {"server":{...},"log":{...},"<雷达标识>":{radar/scan/detect/count/filter}, ...} +// 兼容: 旧格式(根下直接 radar/scan/... 段)时整体视为一台雷达,标识 "default"。 +bool load_config(const std::string& path) +{ + std::ifstream fin(path); + if (!fin.is_open()) + { + // 绝不 exit:运行期 config 命令会调用本函数,exit 会让整个服务进程瞬间消失 + std::cerr << "[错误] 未找到配置文件: " << path << " (保留现有配置不变)" << std::endl; + return false; + } + + // 1. 剔除行首(忽略空白)以 // 开头的注释行,其余拼接为 JSON 文本 + std::ostringstream json_oss; + std::string line; + while (std::getline(fin, line)) + { + size_t b = line.find_first_not_of(" \t\r\n"); + if (b != std::string::npos && line.compare(b, 2, "//") == 0) + continue; // 整行注释 + json_oss << line << "\n"; + } + fin.close(); + const std::string text = json_oss.str(); + + std::string s; double d; bool bv; + + // 全程解析到临时对象:解析失败/无雷达时不改动正在使用的 cfg, + // 避免运行期 config 重载把全局配置清成空导致其它线程访问空 map。 + AppConfig new_cfg = cfg; - // ---------- server: 运行模式 ---------- - if (json_get_string(text, "server", "mode", s)) cfg.server.mode = s; - if (json_get_number(text, "server", "port", d)) cfg.server.port = (uint16_t)d; + // 2. 全局段: server / log + if (json_get_string(text, "server", "mode", s)) new_cfg.server.mode = s; + if (json_get_number(text, "server", "port", d)) new_cfg.server.port = (uint16_t)d; - // ---------- log: 日志 ---------- - if (json_get_bool(text, "log", "enable", bv)) cfg.log.enable = bv; + if (json_get_bool(text, "log", "enable", bv)) new_cfg.log.enable = bv; if (json_get_number(text, "log", "retain_days", d)) { - cfg.log.retain_days = (int)d; - if (cfg.log.retain_days < 1) cfg.log.retain_days = 1; + new_cfg.log.retain_days = (int)d; + if (new_cfg.log.retain_days < 1) new_cfg.log.retain_days = 1; } if (json_get_string(text, "log", "clean_time", s)) { int h = -1, m = -1, sec = -1; if (parse_clean_time(s, h, m, sec)) { - cfg.log.clean_hour = h; - cfg.log.clean_minute = m; - cfg.log.clean_sec = sec; + new_cfg.log.clean_hour = h; + new_cfg.log.clean_minute = m; + new_cfg.log.clean_sec = sec; } else { std::cout << "[WARN] log.clean_time 格式应为 \"HH:MM:SS\",已忽略: " << s - << " (仍用 " << cfg.log.clean_hour << ":" - << (cfg.log.clean_minute < 10 ? "0" : "") << cfg.log.clean_minute << ")" + << " (仍用 " << new_cfg.log.clean_hour << ":" + << (new_cfg.log.clean_minute < 10 ? "0" : "") << new_cfg.log.clean_minute << ")" << std::endl; } } + // 3. 雷达段: 除 server/log 外的一级键都是雷达标识 + std::map> new_radars; + std::vector new_ids; + + std::vector keys = json_object_keys(text); + // 旧格式判定:根下(一级键)直接有 "radar" 段且带 ip。 + // 注意不能直接用 json_get_raw(text,"radar") 判定——它是扁平查找,会命中雷达条目内的 "radar" 子对象。 + bool has_top_radar = std::find(keys.begin(), keys.end(), "radar") != keys.end(); + std::string radar_raw = has_top_radar ? json_get_raw(text, "radar") : std::string(); + bool old_format = !radar_raw.empty() && radar_raw[0] == '{' && + json_get_string(radar_raw, "ip", s); + + if (old_format) + { + std::cout << "[WARN] 检测到旧格式配置(根级 radar/scan/... 段),已按单雷达 \"default\" 载入;" + << "建议改为根级 map: {\"<雷达标识>\":{radar,scan,detect,count,filter}}" << std::endl; + std::shared_ptr rc(new RadarConfig()); + rc->id = "default"; + parse_radar_sections(text, *rc); + new_radars[rc->id] = rc; + new_ids.push_back(rc->id); + } + else + { + for (const auto& k : keys) + { + if (k == "server" || k == "log") continue; + std::string obj = json_get_raw(text, k); + if (obj.empty() || obj[0] != '{') continue; // 非对象键忽略 + std::shared_ptr rc(new RadarConfig()); + rc->id = k; + parse_radar_sections(obj, *rc); + new_radars[k] = rc; + new_ids.push_back(k); + } + } + + if (new_ids.empty()) + { + std::cerr << "[错误] 配置中未找到任何雷达(除 server/log 外需至少一个雷达标识对象)!" + << " 保留现有配置不变。" << std::endl; + return false; + } + + // 解析成功:整体提交(旧配置由仍在使用的线程通过 shared_ptr 继续持有,不会悬空) + new_cfg.radars.swap(new_radars); + new_cfg.ids.swap(new_ids); + cfg = std::move(new_cfg); + // ==================== 打印配置摘要 ==================== - std::cout << "========== 配置已加载 ==========" << std::endl; - std::cout << "雷达: " << cfg.radar.ip << ":" << cfg.radar.port - << " 帧率: " << cfg.radar.hz << "Hz" - << " 拖尾滤波: " << cfg.radar.tail_filter - << " 前冲滤波: " << cfg.radar.front_filter << std::endl; - std::cout << "扫描默认时长: " << cfg.scan.collect_duration_s << "s" - << " Z速度模式: " << cfg.scan.z_speed_type - << " Z速度: " << cfg.scan.z_speed << "cm/s(0=时间)" - << " 日志保留: " << cfg.log.retain_days << "天" + std::cout << "========== 配置已加载 (雷达" << cfg.ids.size() << "台) ==========" << std::endl; + for (const auto& id : cfg.ids) + { + const RadarConfig& rc = *cfg.radars[id]; + std::cout << "[" << id << "] 雷达: " << rc.radar.ip << ":" << rc.radar.port + << " 帧率: " << rc.radar.hz << "Hz" + << " 拖尾滤波: " << rc.radar.tail_filter + << " 前冲滤波: " << rc.radar.front_filter << std::endl; + std::cout << "[" << id << "] 扫描: " << rc.scan.collect_duration_s << "s" + << " Z速度: " << rc.scan.z_speed_type << " " << rc.scan.z_speed << "cm/s" + << " 地板高: " << rc.count.floor_height << "mm" + << " 分层: 基准" << rc.count.base_tolerance + << "+每层" << rc.count.per_level_tolerance + << " 层数算法: " << rc.count.layer_algorithm + << " 插值: " << rc.count.z_interpolate_mode + << " 插值桶宽: " << rc.count.interp_bucket_deg << "°" << std::endl; + std::cout << "[" << id << "] 多边形(" << rc.filter.poly_n << "点):"; + for (const auto& p : rc.filter.polygon) + std::cout << " (" << p.first << "," << p.second << ")"; + std::cout << " SparseCut=" << (rc.filter.sparse_cut.enable ? "开" : "关") + << "(X±" << rc.filter.sparse_cut.x_radius << "mm,同圈>=" << rc.filter.sparse_cut.circle_min + << ",跨圈>=" << rc.filter.sparse_cut.run_min << ")" + << " TailCut=" << (rc.filter.tail_cut.enable ? "开" : "关") << std::endl; + } + std::cout << "运行模式: " << cfg.server.mode + << (cfg.server.mode == "tcp" || cfg.server.mode == "http" ? " (端口 " + std::to_string(cfg.server.port) + ")" : "") << std::endl; + std::cout << "日志保留: " << cfg.log.retain_days << "天" << " 每日清理: " << (cfg.log.clean_hour < 10 ? "0" : "") << cfg.log.clean_hour << ":" << (cfg.log.clean_minute < 10 ? "0" : "") << cfg.log.clean_minute << ":" << (cfg.log.clean_sec < 10 ? "0" : "") << cfg.log.clean_sec << std::endl; - std::cout << "运行模式: " << cfg.server.mode - << (cfg.server.mode == "tcp" || cfg.server.mode == "http" ? " (端口 " + std::to_string(cfg.server.port) + ")" : "") << std::endl; - std::cout << "分层冗余: 基准" << cfg.count.base_tolerance - << " + 每层" << cfg.count.per_level_tolerance - << " 层数算法: " << cfg.count.layer_algorithm - << " 膨胀: " << cfg.count.point_dilate - << " 插值模式: " << cfg.count.z_interpolate_mode << std::endl; - if (cfg.count.layer_algorithm == "KdePeak") - std::cout << "KDE峰值: 带宽=" << cfg.count.kde_bandwidth - << "(0=自动)mm 步长=" << cfg.count.kde_step << "mm" << std::endl; - std::cout << "过滤多边形(" << cfg.filter.poly_n << "点):"; - for (const auto& p : cfg.filter.polygon) - std::cout << " (" << p.first << "," << p.second << ")"; - std::cout << std::endl; - std::cout << "拖影过滤: SparseCut=" << (cfg.filter.sparse_cut.enable ? "开" : "关") - << "(X±" << cfg.filter.sparse_cut.x_radius << "mm,同圈>=" << cfg.filter.sparse_cut.circle_min - << ",跨圈>=" << cfg.filter.sparse_cut.run_min << ")" - << " TailCut=" << (cfg.filter.tail_cut.enable ? "开" : "关") << std::endl; std::cout << "================================" << std::endl; + return true; } diff --git a/config.h b/config.h index 220e2dc..8fd8f3d 100644 --- a/config.h +++ b/config.h @@ -1,9 +1,18 @@ // 全局配置:变量声明 + 配置加载接口 -// 运行时配置按功能分组为 AppConfig 结构体,全局实例 cfg 由 config.cpp 定义。 -// 业务代码通过 cfg.radar.ip / cfg.log.enable / cfg.count.base_tolerance 等访问。 +// 运行时配置为"根级 map"结构: +// { +// "server": {...}, "log": {...}, // 全局(进程级) +// "radar1": { "radar":{..}, "scan":{..}, "detect":{..}, "count":{..}, "filter":{..} }, +// "radar2": { ... } // 每个雷达一份完整参数 +// } +// 除 server/log 外的每个一级键都是一个"雷达标识"(通信时用它指定操作哪台雷达)。 +// 业务代码通过 radar_cfg().radar.ip / cfg.log.enable / radar_cfg().count.base_tolerance 访问; +// radar_cfg() 返回"当前线程绑定的雷达配置"(由 RadarScope 绑定,未绑定时为默认雷达)。 #ifndef CONFIG_H #define CONFIG_H +#include +#include #include #include #include @@ -16,10 +25,13 @@ const bool FILTER_ENABLE = true; // 多边形过滤开关 const bool SHIFT_Y_POSITIVE = true; // Y值上移使最小Y≥0 const int SLICE_IMG_SIZE = 800; // 截面图片最大像素尺寸 const double ORIGIN_TOL_MM = 0.5; // 原点无效点判定容差(mm) +const int RECONNECT_INTERVAL_MS = 3000; // 雷达断线重连间隔(ms),未连接时后台一直重试 -// ==================== 运行时配置(load_config 从 config/config 加载) ==================== -struct AppConfig +// ==================== 单台雷达的参数(根级 map 的一个条目) ==================== +struct RadarConfig { + std::string id; // 雷达标识(配置里的一级键名) + // ---------- radar: 雷达连接 + 雷达内置滤波 ---------- struct Radar { @@ -56,7 +68,13 @@ struct AppConfig double base_tolerance = 50.0; // 分层初始冗余(mm) double per_level_tolerance = 20.0; // 每层递增冗余(mm) int point_dilate = 8; // 画layer图每个点膨胀像素 - std::string z_interpolate_mode = "none"; // none/CopyTranslation/AngleBucketInterpolation + double interp_bucket_deg = 0.08; // 角度插值桶宽(度), 应等于雷达角度分辨率 + // (50Hz 常用 0.08; 一圈点数 = 360/桶宽 = 4500) + std::string z_interpolate_mode = "none"; // none/CopyTranslation/AngleBucketInterpolation/AngleBucketInterpolationByDilate + // CopyTranslation : 空隙 > 2×正常圈间隔时, 复制前一圈的点并平移 z + // AngleBucketInterpolation : 同上判定, 按角度分桶对前后圈做线性插值(平滑) + // AngleBucketInterpolationByDilate : 空隙 > point_dilate 时插值; 步长 = point_dilate/2 + // (每 point_dilate/2 补一条, 最多 10 条, 平滑过渡) // 分层容差(三个算法统一): tol(level) = base_tolerance + level×per_level_tolerance // 层数算法: // LevelTolerance = 现有: level=round(高度/层高), 判定 |高度-level×层高| <= tol @@ -102,15 +120,24 @@ struct AppConfig std::vector> polygon = { {-650, 0}, {-650, 670}, {650, 670}, {650, 0} }; // 过滤多边形(mm) int poly_n = (int)polygon.size(); // 多边形顶点数 } filter; +}; + +// ==================== 全局配置(进程级 + 各雷达参数) ==================== +struct AppConfig +{ + std::vector ids; // 雷达标识列表(配置出现顺序,第一个=默认雷达) + // 用 shared_ptr 持有:load_config 重载时会整体替换,而采集/保存线程可能仍持有旧配置, + // 引用计数保证其生命周期延续到线程作用域结束,避免悬空指针。 + std::map> radars; // 雷达标识 -> 该雷达完整参数 - // ---------- server: 运行模式 ---------- + // ---------- server: 运行模式(全局) ---------- struct Server { std::string mode = "exe"; // exe=直接检测一次 / tcp=TCP服务端 / http=HTTP服务端 uint16_t port = 9601; // TCP/HTTP监听端口 } server; - // ---------- log: 日志 ---------- + // ---------- log: 日志(全局) ---------- struct Log { bool enable = true; // 是否写日志文件 @@ -119,13 +146,35 @@ struct AppConfig int clean_minute = 0; // 每日日志定时清理时刻 分(0-59) int clean_sec = 0; // 每日日志定时清理时刻 秒(0-59) } log; + + // 按标识取雷达配置;不存在返回 nullptr。id 为空时返回默认雷达(第一个) + std::shared_ptr find(const std::string& id) const; + // 默认雷达(第一个);无雷达时返回 nullptr + std::shared_ptr first() const; }; // 全局配置实例(config.cpp 中定义) extern AppConfig cfg; +// 当前线程生效的雷达配置: +// - 已用 RadarScope 绑定 → 返回该雷达配置; +// - 未绑定 → 返回默认雷达配置(第一个); +// - 无任何雷达 → 返回一份空配置(字段为默认值)。 +const RadarConfig& radar_cfg(); + +// RAII:把当前线程绑定到某个雷达的配置,出作用域自动恢复上一次绑定。 +// 采集线程/请求处理线程/异步保存线程都应在入口处绑定。 +class RadarScope +{ +public: + explicit RadarScope(const std::string& radar_id); + ~RadarScope(); +private: + std::shared_ptr prev_; +}; + // 加载 JSON 格式配置文件(行首(忽略空白)以 // 开头的注释行会被剔除) -// path 不存在时打印错误并退出 -void load_config(const std::string& path = "./config/config"); +// 返回是否成功;失败时保留原有 cfg 不变(绝不退出进程:运行期 config 重载会调用它)。 +bool load_config(const std::string& path = "./config/config"); #endif // CONFIG_H diff --git a/config/PAVO2_HTTP_API.postman_collection.json b/config/PAVO2_HTTP_API.postman_collection.json index d914410..ebcd4d3 100644 --- a/config/PAVO2_HTTP_API.postman_collection.json +++ b/config/PAVO2_HTTP_API.postman_collection.json @@ -2,13 +2,14 @@ "info": { "_postman_id": "3f0d9a12-7c5e-4b21-9d8a-6e1f2a3b4c5d", "name": "PAVO2 雷达 HTTP API (POST json)", - "description": "HTTP 运行模式接口。运行模式 Run_Mode 需改为 \"http\"。\n用法: 程序启动后向 http://{{host}}:{{port}}/ 发送 POST 请求, body 为与 TCP 协议完全相同的 JSON(不带结尾 '>')。响应 body 与 TCP 相同(不带 '>')。\n提示: 修改 collection 变量 host 为服务程序所在电脑的 IP(默认 127.0.0.1); port 与配置 Tcp_Port 一致(默认 9601)。\n注意: HTTP 无长连接, 建议 count/check 使用 start(不带 scanTime) → 完成后 stop 拿结果; 若使用 scanTime 到时自动停止, 结果不会推送(仅打印在服务端日志)。", + "description": "HTTP 运行模式接口(多雷达版)。运行模式 server.mode 需改为 \"http\"。\n用法: 程序启动后向 http://{{host}}:{{port}}/ 发送 POST 请求, body 为与 TCP 协议完全相同的 JSON(不带结尾 '>')。响应 body 与 TCP 相同(不带 '>')。\n提示: 修改 collection 变量 host 为服务程序所在电脑的 IP(默认 127.0.0.1); port 与配置 server.port 一致(默认 9601); radar 为要操作的雷达标识(配置文件里的一级键名, 默认 radar1, 不带该字段时用第一个雷达)。\n注意: HTTP 无长连接, 建议 count/check 使用 start(不带 scanTime) → 完成后 stop 拿结果; 若使用 scanTime 到时自动停止, 结果不会推送(仅打印在服务端日志)。", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "variable": [ { "key": "host", "value": "127.0.0.1", "type": "string" }, { "key": "port", "value": "9601", "type": "string" }, - { "key": "baseUrl", "value": "http://{{host}}:{{port}}", "type": "string" } + { "key": "baseUrl", "value": "http://{{host}}:{{port}}", "type": "string" }, + { "key": "radar", "value": "radar1", "type": "string" } ], "item": [ { @@ -21,7 +22,7 @@ "url": { "raw": "{{baseUrl}}/", "host": ["{{baseUrl}}"], "path": [""] }, "body": { "mode": "raw", - "raw": "{\n \"function\": \"count\",\n \"scan\": \"start\",\n \"scanTime\": 6,\n \"box\": {\n \"l\": 154,\n \"w\": 153,\n \"h\": 310,\n \"maxPerLayer\": 30\n },\n \"task_id\": \"t1\"\n}", + "raw": "{\n \"radar\": \"{{radar}}\",\n \"function\": \"count\",\n \"scan\": \"start\",\n \"scanTime\": 6,\n \"box\": {\n \"l\": 154,\n \"w\": 153,\n \"h\": 310,\n \"maxPerLayer\": 30\n },\n \"task_id\": \"t1\"\n}", "options": { "raw": { "language": "json" } } } } @@ -36,7 +37,7 @@ "url": { "raw": "{{baseUrl}}/", "host": ["{{baseUrl}}"], "path": [""] }, "body": { "mode": "raw", - "raw": "{\n \"function\": \"count\",\n \"scan\": \"start\",\n \"box\": {\n \"l\": 154,\n \"w\": 153,\n \"h\": 310,\n \"maxPerLayer\": 30\n },\n \"task_id\": \"t2\"\n}", + "raw": "{\n \"radar\": \"{{radar}}\",\n \"function\": \"count\",\n \"scan\": \"start\",\n \"box\": {\n \"l\": 154,\n \"w\": 153,\n \"h\": 310,\n \"maxPerLayer\": 30\n },\n \"task_id\": \"t2\"\n}", "options": { "raw": { "language": "json" } } } } @@ -51,7 +52,7 @@ "url": { "raw": "{{baseUrl}}/", "host": ["{{baseUrl}}"], "path": [""] }, "body": { "mode": "raw", - "raw": "{\n \"function\": \"count\",\n \"scan\": \"stop\",\n \"task_id\": \"t2\"\n}", + "raw": "{\n \"radar\": \"{{radar}}\",\n \"function\": \"count\",\n \"scan\": \"stop\",\n \"task_id\": \"t2\"\n}", "options": { "raw": { "language": "json" } } } } @@ -66,7 +67,7 @@ "url": { "raw": "{{baseUrl}}/", "host": ["{{baseUrl}}"], "path": [""] }, "body": { "mode": "raw", - "raw": "{\n \"function\": \"count\",\n \"scan\": \"stop\",\n \"task_id\": \"none\"\n}", + "raw": "{\n \"radar\": \"{{radar}}\",\n \"function\": \"count\",\n \"scan\": \"stop\",\n \"task_id\": \"none\"\n}", "options": { "raw": { "language": "json" } } } } @@ -81,7 +82,7 @@ "url": { "raw": "{{baseUrl}}/", "host": ["{{baseUrl}}"], "path": [""] }, "body": { "mode": "raw", - "raw": "{\n \"function\": \"check\",\n \"scan\": \"start\",\n \"task_id\": \"c1\"\n}", + "raw": "{\n \"radar\": \"{{radar}}\",\n \"function\": \"check\",\n \"scan\": \"start\",\n \"task_id\": \"c1\"\n}", "options": { "raw": { "language": "json" } } } } @@ -96,7 +97,7 @@ "url": { "raw": "{{baseUrl}}/", "host": ["{{baseUrl}}"], "path": [""] }, "body": { "mode": "raw", - "raw": "{\n \"function\": \"check\",\n \"scan\": \"stop\",\n \"task_id\": \"c1\"\n}", + "raw": "{\n \"radar\": \"{{radar}}\",\n \"function\": \"check\",\n \"scan\": \"stop\",\n \"task_id\": \"c1\"\n}", "options": { "raw": { "language": "json" } } } } @@ -126,7 +127,7 @@ "url": { "raw": "{{baseUrl}}/", "host": ["{{baseUrl}}"], "path": [""] }, "body": { "mode": "raw", - "raw": "{\n \"function\": \"ConfigureSpeed\",\n \"scanTime\": 6\n}", + "raw": "{\n \"radar\": \"{{radar}}\",\n \"function\": \"ConfigureSpeed\",\n \"scanTime\": 6\n}", "options": { "raw": { "language": "json" } } } } diff --git a/config/config b/config/config index 912656e..cea80fb 100644 --- a/config/config +++ b/config/config @@ -1,120 +1,135 @@ -// 全局配置文件(分组嵌套结构) -// 注:注释须整行且以 // 开头;键值保持全局可读 +// 全局配置文件(根级 map 结构) +// 注:注释须整行且以 // 开头;键值保持全局可读(不同雷达里同名 key 不要重复语义) +// 结构: +// "server" / "log" → 进程级全局配置 +// 其它一级键 → 一个"雷达标识"(通信时用 "radar":"xxx" 指定操作哪台雷达), +// 每个标识下是一份完整参数:radar / scan / detect / count / filter { - // ==================== radar: 雷达连接与雷达内置滤波 ==================== - "radar": { - //雷达IP - "ip": "192.168.1.170", - //雷达数据端口 - "port": 2368, - //雷达帧率 Hz (矩形长 = 速度/帧率) - "hz": 50, - //拖尾滤波 0=关, 1=弱, 2=中, 3=强 - "tail_filter": 3, - //前冲滤波 0=关, 1=弱, 2=中, 3=强 - "front_filter": 3, - //远端高反滤波 0=关, 1=弱, 2=强 - "far_hr_filter": 2, - //近端高反识别 true开,false关 - "near_hr_filter": true - }, - - // ==================== scan: 扫描采集与走行 ==================== - "scan": { - //z轴速度模式: uniformSpeed=匀速(用下方z_speed), configuration=变速(用config/speed.txt, 每行"时间ms 距离mm", 无此文件时回退z_speed) - "z_speed_type": "uniformSpeed", - //z轴速度 厘米/秒 (0=z轴为时间ms, >0=z轴为移动距离mm; 仅在z_speed_type=uniformSpeed时直接生效) - "z_speed": 20, - //exe模式/默认扫描时长 秒 (tcp/http 模式可被请求中的 scanTime 覆盖) - "collect_duration_s": 6 - }, - - // ==================== detect: 截面分析 ==================== - "detect": { - //Z截面区间长度 毫米 - "z_range_mm": 50, - //Y截面区间长度 毫米 (只取 Y最小 ~ Y最小+此值 的点参与) - "y_range_mm": 80, - //截面矩形宽度 像素 - "rect_width_px": 12, - //截面矩形长度倍率 (矩形长=速度/帧率×倍率) - "rect_length_ratio": 1.5 - }, - - // ==================== count: 分层计数 ==================== - "count": { - //地板高度 毫米 (物品高度 = floor_height - Y轴, count计数用) - "floor_height": 1600, - //count分层冗余 初始冗余 毫米 (第1层容差)上下各base_tolerance - "base_tolerance": 30, - //count分层冗余 每层增加 毫米 - "per_level_tolerance": 1, - //count画layer图: 每个点膨胀像素 (point_dilate×point_dilate) - "point_dilate": 8, - //count z轴空隙插值模式: none / CopyTranslation / AngleBucketInterpolation - "z_interpolate_mode": "CopyTranslation", - //count层数算法: - // LevelTolerance = 现有: level=round(高度/层高), 判定 |高度-level×层高| <= 冗余(base_tolerance+层号×per_level_tolerance) - // HalfMax = 两步判定: ①本层中间~下一层中间[(level-0.5)层高,(level+0.5)层高]粗筛 ②取①内最高点top, 有效区间=[top-容差, top] - // KdePeak = 两步判定: ①同上粗筛 ②对①内高度做KDE密度曲线取峰值peak, 有效区间=[peak-容差, peak+容差] - // 容差 = base_tolerance + 层号×per_level_tolerance - "layer_algorithm": "KdePeak", - //KDE(KdePeak算法): 高斯核带宽 毫米 (0=按Silverman经验法则自动取) - "kde_bandwidth": 0, - //KDE(KdePeak算法): 密度曲线扫描步长 毫米 (越小越精细但越慢) - "kde_step": 1 - }, - - // ==================== filter: 软件滤波(多边形 + 拖影) ==================== - "filter": { - //过滤多边形(mm), 至少3个顶点 - "polygon": [[-550,0],[-550,1600],[500,1600],[500,0]], - - //连续段尾部减除(图片级旧方案, 已由点云级 sparse_cut 取代, 建议保持 false) - "tail_cut": { - //是否启用尾部减除 - "enable": false, - //每端最多保留的尾部长度(px≈mm): 越小剪得越狠, 太大会误剪真实物体边缘 - "max_tail_length": 30, - //尾部像素Y比段核心Y高出的阈值(mm): 越小越激进剪拖影, 太大剪不干净 - "y_delta": 30, - //连续段长度低于此值(px)整段删除(孤立噪点) - "min_run_length": 5, - //主体核心最小长度(px), 保护小物体不被整体剪掉 - "core_min_len": 5, - //上下几行同列有点视为竖直主体边缘则豁免不剪(0=不使用) - "support_rows": 0 - }, - - //点云级稀疏拖影过滤(推荐, 默认开): 删除每圈边缘外脱离主体的稀疏外伸点(胡须状拖影) - //稀疏点 = 同一扫描圈(z相同)内 X±XRadius mm 无其它点(不同圈同X不算邻居) - "sparse_cut": { - //总开关 - "enable": true, - //同圈X邻域半径mm: 调大→删得狠; 调小→保守 - "x_radius": 30, - //同圈连续: 同一圈内稀疏点数≥此值即删 (对应某圈边缘外伸了N个点的情况) - "circle_min": 2, - //跨圈连续: 同一X位置在z方向连续出现≥此圈即删 (对应每圈外伸1~2个点、跨几十圈稳定) - "run_min": 2 - } - }, - - // ==================== server: 运行模式 ==================== + // ==================== server: 运行模式(全局) ==================== "server": { //运行模式 exe=直接检测 / tcp=服务端模式 / http=HTTP服务端 - "mode": "http", - //TCP/HTTP服务端监听端口 (仅tcp/http模式有效) + "mode": "tcp", + //TCP/HTTP服务端监听端口 "port": 9601 }, - // ==================== log: 日志 ==================== + // ==================== log: 日志(全局) ==================== "log": { //日志打印使能 true/false "enable": true, //日志(任务目录)保留天数, 超出自动删除更早的目录 "retain_days": 1, - //每日日志定时清理时刻, 格式 "HH:MM:SS" (24小时制, 秒可省略, 如 "06:01" 或 "01:00:00") + //每日日志定时清理时刻, 格式 "HH:MM:SS" "clean_time": "06:01:30" + }, + + // ==================== radar1: 第1台雷达(完整参数) ==================== + "radar1": { + // ---- radar: 雷达连接与雷达内置滤波 ---- + "radar": { + //雷达IP + "ip": "192.168.1.170", + //雷达数据端口 + "port": 2368, + //雷达帧率 Hz (矩形长 = 速度/帧率) + "hz": 50, + //拖尾滤波 0=关, 1=弱, 2=中, 3=强 + "tail_filter": 3, + //前冲滤波 0=关, 1=弱, 2=中, 3=强 + "front_filter": 3, + //远端高反滤波 0=关, 1=弱, 2=强 + "far_hr_filter": 2, + //近端高反识别 true开,false关 + "near_hr_filter": true + }, + // ---- scan: 扫描采集与走行 ---- + "scan": { + //z轴速度模式: uniformSpeed=匀速(用下方z_speed), configuration=变速(用config/speed.txt) + "z_speed_type": "uniformSpeed", + //z轴速度 厘米/秒 (0=z轴为时间ms, >0=z轴为移动距离mm) + "z_speed": 50, + //exe模式/默认扫描时长 秒 (tcp/http 模式可被请求中的 scanTime 覆盖) + "collect_duration_s": 6 + }, + + // ---- detect: 截面分析 ---- + "detect": { + //Z截面区间长度 毫米 + "z_range_mm": 50, + //Y截面区间长度 毫米 + "y_range_mm": 80, + //截面矩形宽度 像素 + "rect_width_px": 12, + //截面矩形长度倍率 (矩形长=速度/帧率×倍率) + "rect_length_ratio": 1.5 + }, + + // ---- count: 分层计数 ---- + "count": { + //地板高度 毫米 (物品高度 = floor_height - Y轴, count计数用) + "floor_height": 1600, + //count分层冗余 初始冗余 毫米 (第1层容差)上下各base_tolerance + "base_tolerance": 30, + //count分层冗余 每层增加 毫米 + "per_level_tolerance": 1, + //count画layer图: 每个点膨胀像素 (point_dilate×point_dilate) + "point_dilate": 8, + //count z轴空隙插值模式: none / CopyTranslation / AngleBucketInterpolation/AngleBucketInterpolationByDilate + "z_interpolate_mode": "AngleBucketInterpolationByDilate", + //count层数算法: + // LevelTolerance = level=round(高度/层高), 判定 |高度-level×层高| <= 容差 + // HalfMax = 两步判定: ①本层中间~下一层中间[(level-0.5)层高,(level+0.5)层高]粗筛 ②取①内最高点top, 有效区间=[top-容差, top] + // KdePeak = 两步判定: ①同上粗筛 ②对①内高度做KDE密度曲线取峰值peak, 有效区间=[peak-容差, peak+容差] + // 容差 = base_tolerance + 层号×per_level_tolerance + "layer_algorithm": "KdePeak", + //KDE(KdePeak算法): 高斯核带宽 毫米 (0=按Silverman经验法则自动取) + "kde_bandwidth": 0, + //KDE(KdePeak算法): 密度曲线扫描步长 毫米 (越小越精细但越慢) + "kde_step": 1 + }, + + // ---- filter: 软件滤波(多边形 + 拖影) ---- + "filter": { + //过滤多边形(mm), 至少3个顶点 + "polygon": [[-550,0],[-550,1600],[500,1600],[500,0]], + + //连续段尾部减除(图片级旧方案, 已由点云级 sparse_cut 取代, 建议保持 false) + "tail_cut": { + "enable": false, + "max_tail_length": 30, + "y_delta": 30, + "min_run_length": 5, + "core_min_len": 5, + "support_rows": 0 + }, + + //点云级稀疏拖影过滤(推荐, 默认开) + "sparse_cut": { + "enable": true, + //同圈X邻域半径mm: 调大→删得狠; 调小→保守 + "x_radius": 30, + //同圈连续: 同一圈内稀疏点数≥此值即删 + "circle_min": 2, + //跨圈连续: 同一X位置在z方向连续出现≥此圈即删 + "run_min": 2 + } + } } + + // ==================== 追加更多雷达:复制下面一段并改标识/IP/参数即可 ==================== + // 通信时用 "radar":"radar2" 指定;不带 radar 字段的请求默认操作第一个雷达(radar1) + //, + //"radar2": { + // "radar": { "ip": "192.168.1.171", "port": 2368, "hz": 50, + // "tail_filter": 3, "front_filter": 3, "far_hr_filter": 2, "near_hr_filter": true }, + // "scan": { "z_speed_type": "uniformSpeed", "z_speed": 20, "collect_duration_s": 6 }, + // "detect":{ "z_range_mm": 50, "y_range_mm": 80, "rect_width_px": 12, "rect_length_ratio": 1.5 }, + // "count": { "floor_height": 1600, "base_tolerance": 30, "per_level_tolerance": 1, + // "point_dilate": 8, "z_interpolate_mode": "CopyTranslation", + // "layer_algorithm": "HalfMax", "kde_bandwidth": 0, "kde_step": 1 }, + // "filter":{ "polygon": [[-550,0],[-550,1600],[500,1600],[500,0]], + // "tail_cut": { "enable": false, "max_tail_length": 30, "y_delta": 30, + // "min_run_length": 5, "core_min_len": 5, "support_rows": 0 }, + // "sparse_cut": { "enable": true, "x_radius": 30, "circle_min": 2, "run_min": 2 } } + //} } diff --git a/config/config copy b/config/config copy new file mode 100644 index 0000000..1fc0432 --- /dev/null +++ b/config/config copy @@ -0,0 +1,188 @@ +// 全局配置文件(根级 map 结构) +// 注:注释须整行且以 // 开头;键值保持全局可读(不同雷达里同名 key 不要重复语义) +// 结构: +// "server" / "log" → 进程级全局配置 +// 其它一级键 → 一个"雷达标识"(通信时用 "radar":"xxx" 指定操作哪台雷达), +// 每个标识下是一份完整参数:radar / scan / detect / count / filter +{ + // ==================== server: 运行模式(全局) ==================== + "server": { + //运行模式 exe=直接检测 / tcp=服务端模式 / http=HTTP服务端 + "mode": "http", + //TCP/HTTP服务端监听端口 + "port": 9601 + }, + + // ==================== log: 日志(全局) ==================== + "log": { + //日志打印使能 true/false + "enable": true, + //日志(任务目录)保留天数, 超出自动删除更早的目录 + "retain_days": 1, + //每日日志定时清理时刻, 格式 "HH:MM:SS" + "clean_time": "06:01:30" + }, + + // ==================== radar1: 第1台雷达(完整参数) ==================== + "radar1": { + // ---- radar: 雷达连接与雷达内置滤波 ---- + "10.43.26.114": { + //雷达IP + "ip": "10.43.26.114", + //雷达数据端口 + "port": 2368, + //雷达帧率 Hz (矩形长 = 速度/帧率) + "hz": 50, + //拖尾滤波 0=关, 1=弱, 2=中, 3=强 + "tail_filter": 3, + //前冲滤波 0=关, 1=弱, 2=中, 3=强 + "front_filter": 3, + //远端高反滤波 0=关, 1=弱, 2=强 + "far_hr_filter": 2, + //近端高反识别 true开,false关 + "near_hr_filter": true + }, + + // ---- scan: 扫描采集与走行 ---- + "scan": { + //z轴速度模式: uniformSpeed=匀速(用下方z_speed), configuration=变速(用config/speed.txt) + "z_speed_type": "uniformSpeed", + //z轴速度 厘米/秒 (0=z轴为时间ms, >0=z轴为移动距离mm) + "z_speed": 20, + //exe模式/默认扫描时长 秒 (tcp/http 模式可被请求中的 scanTime 覆盖) + "collect_duration_s": 6 + }, + + // ---- detect: 截面分析 ---- + "detect": { + //Z截面区间长度 毫米 + "z_range_mm": 50, + //Y截面区间长度 毫米 + "y_range_mm": 80, + //截面矩形宽度 像素 + "rect_width_px": 12, + //截面矩形长度倍率 (矩形长=速度/帧率×倍率) + "rect_length_ratio": 1.5 + }, + + // ---- count: 分层计数 ---- + "count": { + //地板高度 毫米 (物品高度 = floor_height - Y轴, count计数用) + "floor_height": 1600, + //count分层冗余 初始冗余 毫米 (第1层容差)上下各base_tolerance + "base_tolerance": 40, + //count分层冗余 每层增加 毫米 + "per_level_tolerance": 1, + //count画layer图: 每个点膨胀像素 (point_dilate×point_dilate) + "point_dilate": 8, + //count z轴空隙插值模式: none / CopyTranslation / AngleBucketInterpolation + "z_interpolate_mode": "CopyTranslation", + //count层数算法: + // LevelTolerance = level=round(高度/层高), 判定 |高度-level×层高| <= 容差 + // HalfMax = 两步判定: ①本层中间~下一层中间[(level-0.5)层高,(level+0.5)层高]粗筛 ②取①内最高点top, 有效区间=[top-容差, top] + // KdePeak = 两步判定: ①同上粗筛 ②对①内高度做KDE密度曲线取峰值peak, 有效区间=[peak-容差, peak+容差] + // 容差 = base_tolerance + 层号×per_level_tolerance + "layer_algorithm": "HalfMax", + //KDE(KdePeak算法): 高斯核带宽 毫米 (0=按Silverman经验法则自动取) + "kde_bandwidth": 0, + //KDE(KdePeak算法): 密度曲线扫描步长 毫米 (越小越精细但越慢) + "kde_step": 1 + }, + + // ---- filter: 软件滤波(多边形 + 拖影) ---- + "filter": { + //过滤多边形(mm), 至少3个顶点 + "polygon": [[-550,0],[-550,1600],[500,1600],[500,0]], + + //连续段尾部减除(图片级旧方案, 已由点云级 sparse_cut 取代, 建议保持 false) + "tail_cut": { + "enable": false, + "max_tail_length": 30, + "y_delta": 30, + "min_run_length": 5, + "core_min_len": 5, + "support_rows": 0 + }, + + //点云级稀疏拖影过滤(推荐, 默认开) + "sparse_cut": { + "enable": true, + //同圈X邻域半径mm: 调大→删得狠; 调小→保守 + "x_radius": 30, + //同圈连续: 同一圈内稀疏点数≥此值即删 + "circle_min": 2, + //跨圈连续: 同一X位置在z方向连续出现≥此圈即删 + "run_min": 2 + } + } + } + + // ==================== 追加更多雷达:复制下面一段并改标识/IP/参数即可 ==================== + // 通信时用 "radar":"radar2" 指定;不带 radar 字段的请求默认操作第一个雷达(radar1) + , + "10.43.26.113": { + "radar": { "ip": "10.43.26.113", "port": 2368, "hz": 50, + "tail_filter": 3, "front_filter": 3, "far_hr_filter": 2, "near_hr_filter": true }, + "scan": { "z_speed_type": "uniformSpeed", "z_speed": 20, "collect_duration_s": 6 }, + "detect":{ "z_range_mm": 50, "y_range_mm": 80, "rect_width_px": 12, "rect_length_ratio": 1.5 }, + "count": { "floor_height": 1600, "base_tolerance": 30, "per_level_tolerance": 1, + "point_dilate": 8, "z_interpolate_mode": "CopyTranslation", + "layer_algorithm": "HalfMax", "kde_bandwidth": 0, "kde_step": 1 }, + "filter":{ "polygon": [[-550,0],[-550,1600],[500,1600],[500,0]], + "tail_cut": { "enable": false, "max_tail_length": 30, "y_delta": 30, + "min_run_length": 5, "core_min_len": 5, "support_rows": 0 }, + "sparse_cut": { "enable": true, "x_radius": 30, "circle_min": 2, "run_min": 2 } } + }, + "10.43.26.110": { + "radar": { "ip": "10.43.26.110", "port": 2368, "hz": 50, + "tail_filter": 3, "front_filter": 3, "far_hr_filter": 2, "near_hr_filter": true }, + "scan": { "z_speed_type": "uniformSpeed", "z_speed": 20, "collect_duration_s": 6 }, + "detect":{ "z_range_mm": 50, "y_range_mm": 80, "rect_width_px": 12, "rect_length_ratio": 1.5 }, + "count": { "floor_height": 1600, "base_tolerance": 30, "per_level_tolerance": 1, + "point_dilate": 8, "z_interpolate_mode": "CopyTranslation", + "layer_algorithm": "HalfMax", "kde_bandwidth": 0, "kde_step": 1 }, + "filter":{ "polygon": [[-550,0],[-550,1600],[500,1600],[500,0]], + "tail_cut": { "enable": false, "max_tail_length": 30, "y_delta": 30, + "min_run_length": 5, "core_min_len": 5, "support_rows": 0 }, + "sparse_cut": { "enable": true, "x_radius": 30, "circle_min": 2, "run_min": 2 } } + }, + "10.43.26.111": { + "radar": { "ip": "10.43.26.111", "port": 2368, "hz": 50, + "tail_filter": 3, "front_filter": 3, "far_hr_filter": 2, "near_hr_filter": true }, + "scan": { "z_speed_type": "uniformSpeed", "z_speed": 20, "collect_duration_s": 6 }, + "detect":{ "z_range_mm": 50, "y_range_mm": 80, "rect_width_px": 12, "rect_length_ratio": 1.5 }, + "count": { "floor_height": 1600, "base_tolerance": 30, "per_level_tolerance": 1, + "point_dilate": 8, "z_interpolate_mode": "CopyTranslation", + "layer_algorithm": "HalfMax", "kde_bandwidth": 0, "kde_step": 1 }, + "filter":{ "polygon": [[-550,0],[-550,1600],[500,1600],[500,0]], + "tail_cut": { "enable": false, "max_tail_length": 30, "y_delta": 30, + "min_run_length": 5, "core_min_len": 5, "support_rows": 0 }, + "sparse_cut": { "enable": true, "x_radius": 30, "circle_min": 2, "run_min": 2 } } + }, + "10.43.26.108": { + "radar": { "ip": "10.43.26.108", "port": 2368, "hz": 50, + "tail_filter": 3, "front_filter": 3, "far_hr_filter": 2, "near_hr_filter": true }, + "scan": { "z_speed_type": "uniformSpeed", "z_speed": 20, "collect_duration_s": 6 }, + "detect":{ "z_range_mm": 50, "y_range_mm": 80, "rect_width_px": 12, "rect_length_ratio": 1.5 }, + "count": { "floor_height": 1600, "base_tolerance": 30, "per_level_tolerance": 1, + "point_dilate": 8, "z_interpolate_mode": "CopyTranslation", + "layer_algorithm": "HalfMax", "kde_bandwidth": 0, "kde_step": 1 }, + "filter":{ "polygon": [[-550,0],[-550,1600],[500,1600],[500,0]], + "tail_cut": { "enable": false, "max_tail_length": 30, "y_delta": 30, + "min_run_length": 5, "core_min_len": 5, "support_rows": 0 }, + "sparse_cut": { "enable": true, "x_radius": 30, "circle_min": 2, "run_min": 2 } } + }, + "10.43.26.107": { + "radar": { "ip": "10.43.26.107", "port": 2368, "hz": 50, + "tail_filter": 3, "front_filter": 3, "far_hr_filter": 2, "near_hr_filter": true }, + "scan": { "z_speed_type": "uniformSpeed", "z_speed": 20, "collect_duration_s": 6 }, + "detect":{ "z_range_mm": 50, "y_range_mm": 80, "rect_width_px": 12, "rect_length_ratio": 1.5 }, + "count": { "floor_height": 1600, "base_tolerance": 30, "per_level_tolerance": 1, + "point_dilate": 8, "z_interpolate_mode": "CopyTranslation", + "layer_algorithm": "HalfMax", "kde_bandwidth": 0, "kde_step": 1 }, + "filter":{ "polygon": [[-550,0],[-550,1600],[500,1600],[500,0]], + "tail_cut": { "enable": false, "max_tail_length": 30, "y_delta": 30, + "min_run_length": 5, "core_min_len": 5, "support_rows": 0 }, + "sparse_cut": { "enable": true, "x_radius": 30, "circle_min": 2, "run_min": 2 } } + } +} diff --git a/count.cpp b/count.cpp index ae73266..1da8745 100644 --- a/count.cpp +++ b/count.cpp @@ -16,21 +16,21 @@ // ==================== 点云级稀疏拖影过滤 ==================== // 现象: 平台/物体左右边缘外伸的"胡须状拖影"= 每圈端部外侧 1~N 个点, -// 圈内 X±cfg.filter.sparse_cut.x_radius 内无同圈邻居(稀疏),且沿 z 方向稳定跨很多圈; +// 圈内 X±radar_cfg().filter.sparse_cut.x_radius 内无同圈邻居(稀疏),且沿 z 方向稳定跨很多圈; // 若放任不管,这些点膨胀后会撑大 count 的面积/个数统计。 // 稀疏点判定: 邻居只在同一圈(z相同)内比较(不同圈同X的点不互为邻居)。 // 删除判据(满足任一即删该稀疏点): -// 1) 同圈连续: 同一圈内稀疏点数 >= cfg.filter.sparse_cut.circle_min; +// 1) 同圈连续: 同一圈内稀疏点数 >= radar_cfg().filter.sparse_cut.circle_min; // 2) 跨圈连续: 相近 X 位置(±2×XRadius 归并, 容忍圈间抖动)的稀疏点, -// z 上连续出现(相邻圈间隔容差=3×中位圈间隔,允许个别缺圈) 圈数 >= cfg.filter.sparse_cut.run_min。 +// z 上连续出现(相邻圈间隔容差=3×中位圈间隔,允许个别缺圈) 圈数 >= radar_cfg().filter.sparse_cut.run_min。 // 原地修改 points;返回删除点数。应在 z_gap_interpolate 之前调用。 int sparse_density_filter(std::vector& points) { - if (!cfg.filter.sparse_cut.enable || points.empty()) return 0; - const double R = std::max(cfg.filter.sparse_cut.x_radius, 0.0); + if (!radar_cfg().filter.sparse_cut.enable || points.empty()) return 0; + const double R = std::max(radar_cfg().filter.sparse_cut.x_radius, 0.0); if (R <= 0.0) return 0; - const int Cm = std::max(cfg.filter.sparse_cut.circle_min, 1); - const int Rm = std::max(cfg.filter.sparse_cut.run_min, 1); + const int Cm = std::max(radar_cfg().filter.sparse_cut.circle_min, 1); + const int Rm = std::max(radar_cfg().filter.sparse_cut.run_min, 1); const size_t n = points.size(); // ---- 1) 按 z 聚类成圈(同一 scan 包的点 z 相同) ---- @@ -157,11 +157,11 @@ int sparse_density_filter(std::vector& points) // ==================== 连续段尾部减除 ==================== // 对膨胀前的 0/1 二值图逐行(X方向)处理连续点段: -// 1) 长度 < cfg.filter.tail_cut.min_run_length 的段视为孤立噪点,整段删除; -// 2) 正常段保留中间核心 + 左右各 cfg.filter.tail_cut.max_tail_length,核心区外为"尾部候选"; -// 3) 尾部候选若满足 (该格最大Y - 段核心中位Y) > cfg.filter.tail_cut.y_delta 则删除 +// 1) 长度 < radar_cfg().filter.tail_cut.min_run_length 的段视为孤立噪点,整段删除; +// 2) 正常段保留中间核心 + 左右各 radar_cfg().filter.tail_cut.max_tail_length,核心区外为"尾部候选"; +// 3) 尾部候选若满足 (该格最大Y - 段核心中位Y) > radar_cfg().filter.tail_cut.y_delta 则删除 // (对应左右边缘 Y 逐渐变大的胡须状拖影);若尾部Y与核心接近则保留,避免误伤真实边缘; -// 4) cfg.filter.tail_cut.support_rows>0 时,上下行同列有点视为竖直主体,豁免不删。 +// 4) radar_cfg().filter.tail_cut.support_rows>0 时,上下行同列有点视为竖直主体,豁免不删。 // cell_y_max 为空数组表示不使用 Y 判据(此时只按长度裁剪)。 static void trim_tail_runs(std::vector& image, int w, int h, const std::vector& cell_y_max) @@ -186,7 +186,7 @@ static void trim_tail_runs(std::vector& image, int w, int h, x = e + 1; int len = e - s + 1; - if (len < cfg.filter.tail_cut.min_run_length) // 孤立短段:整段删除 + if (len < radar_cfg().filter.tail_cut.min_run_length) // 孤立短段:整段删除 { for (int c = s; c <= e; c++) deleted[(size_t)y * w + c] = 1; continue; @@ -209,8 +209,8 @@ static void trim_tail_runs(std::vector& image, int w, int h, } // 核心区:两端各留 MaxTailLength,中间至少要留 CoreMinLen - int left_cut = std::min(s + cfg.filter.tail_cut.max_tail_length, e - cfg.filter.tail_cut.core_min_len + 1); - int right_cut = std::max(e - cfg.filter.tail_cut.max_tail_length, s + cfg.filter.tail_cut.core_min_len - 1); + int left_cut = std::min(s + radar_cfg().filter.tail_cut.max_tail_length, e - radar_cfg().filter.tail_cut.core_min_len + 1); + int right_cut = std::max(e - radar_cfg().filter.tail_cut.max_tail_length, s + radar_cfg().filter.tail_cut.core_min_len - 1); if (left_cut > right_cut) continue; // 段过短:整体视为核心,不裁剪 for (int c = s; c <= e; c++) @@ -221,13 +221,13 @@ static void trim_tail_runs(std::vector& image, int w, int h, if (use_y) { double ym = cell_y_max[(size_t)y * w + c]; - if (ym > -1e8 && ym <= core_y + cfg.filter.tail_cut.y_delta) continue; + if (ym > -1e8 && ym <= core_y + radar_cfg().filter.tail_cut.y_delta) continue; } // 支撑判据:上下行同列有点 → 竖直主体边缘,豁免 - if (cfg.filter.tail_cut.support_rows > 0) + if (radar_cfg().filter.tail_cut.support_rows > 0) { bool supported = false; - for (int r = 1; r <= cfg.filter.tail_cut.support_rows; r++) + for (int r = 1; r <= radar_cfg().filter.tail_cut.support_rows; r++) if (at(c, y - r) || at(c, y + r)) { supported = true; break; } if (supported) continue; } @@ -251,16 +251,16 @@ std::vector points_to_image(const std::vector& points, if (out_h) *out_h = 0; if (points.empty()) return std::vector((size_t)MIN_DIM * MIN_DIM, 0); - // 宽: cfg.filter.polygon 多边形 X 最大最小值直接构成 (1mm=1px) + // 宽: radar_cfg().filter.polygon 多边形 X 最大最小值直接构成 (1mm=1px) double polyXMin = 0.0, polyXMax = 0.0; - if (cfg.filter.poly_n > 0) + if (radar_cfg().filter.poly_n > 0) { - polyXMin = cfg.filter.polygon[0].first; - polyXMax = cfg.filter.polygon[0].first; - for (int i = 1; i < cfg.filter.poly_n; i++) + polyXMin = radar_cfg().filter.polygon[0].first; + polyXMax = radar_cfg().filter.polygon[0].first; + for (int i = 1; i < radar_cfg().filter.poly_n; i++) { - polyXMin = std::min(polyXMin, cfg.filter.polygon[i].first); - polyXMax = std::max(polyXMax, cfg.filter.polygon[i].first); + polyXMin = std::min(polyXMin, radar_cfg().filter.polygon[i].first); + polyXMax = std::max(polyXMax, radar_cfg().filter.polygon[i].first); } } double spanX = polyXMax - polyXMin; @@ -287,11 +287,11 @@ std::vector points_to_image(const std::vector& points, double xOffset = -polyXMin; // 优化:Step1 先按1x1网格标记点位置(重复点只写一次), - // Step2 再对标记位置膨胀 cfg.count.point_dilate×cfg.count.point_dilate + // Step2 再对标记位置膨胀 radar_cfg().count.point_dilate×radar_cfg().count.point_dilate std::vector marks; marks.reserve(points.size() / 2); std::vector cell_y_max; // 每格最大Y(仅尾部减除启用时分配) - if (cfg.filter.tail_cut.enable) cell_y_max.assign((size_t)imageWidth * imageHeight, -1e9); + if (radar_cfg().filter.tail_cut.enable) cell_y_max.assign((size_t)imageWidth * imageHeight, -1e9); for (const auto& p : points) { int px = (int)((p.x_mm + xOffset) * scaleX); @@ -303,12 +303,12 @@ std::vector points_to_image(const std::vector& points, image[idx] = 1; marks.push_back((uint32_t)idx); } - if (cfg.filter.tail_cut.enable && p.y_mm > cell_y_max[idx]) cell_y_max[idx] = p.y_mm; + if (radar_cfg().filter.tail_cut.enable && p.y_mm > cell_y_max[idx]) cell_y_max[idx] = p.y_mm; } // 连续段尾部减除(膨胀前):剪掉左右两侧胡须状拖影, // 避免其经膨胀放大后混入层面积统计 - if (cfg.filter.tail_cut.enable) + if (radar_cfg().filter.tail_cut.enable) { trim_tail_runs(image, imageWidth, imageHeight, cell_y_max); // 剪除会改动 image,需重建 marks,防止被删格经膨胀“复活” @@ -317,12 +317,12 @@ std::vector points_to_image(const std::vector& points, for (size_t i = 0; i < image.size(); i++) if (image[i] == 1) marks.push_back((uint32_t)i); } - for (uint32_t idx : marks) // 膨胀 cfg.count.point_dilate×cfg.count.point_dilate + for (uint32_t idx : marks) // 膨胀 radar_cfg().count.point_dilate×radar_cfg().count.point_dilate { int px = (int)(idx % imageWidth); int py = (int)(idx / imageWidth); - for (int dy = 0; dy < cfg.count.point_dilate; dy++) - for (int dx = 0; dx < cfg.count.point_dilate; dx++) + for (int dy = 0; dy < radar_cfg().count.point_dilate; dy++) + for (int dx = 0; dx < radar_cfg().count.point_dilate; dx++) { int ix = px + dx, iy = py + dy; if (ix < imageWidth && iy < imageHeight) @@ -341,28 +341,60 @@ std::vector points_to_image(const std::vector& points, // ==================== z轴空隙插值 ==================== int z_gap_interpolate(std::vector& points) { - if (points.empty() || cfg.count.z_interpolate_mode == "none") + if (points.empty() || radar_cfg().count.z_interpolate_mode == "none") return 0; // 正常相邻两条(圈)的 z 间隔: 速度模式 = 每圈行进距离(mm); 时间模式 = 每圈耗时(ms) // configuration(变速)模式: 用 speed.txt 剖面平均速度(mm/s)/帧率 作近似; 表无效时回退匀速。 double normal_gap = 0.0; double prof_v = speed_profile_avg_velocity_mm_s(); - if (cfg.scan.z_speed_type == "configuration" && prof_v > 0) - normal_gap = prof_v / cfg.radar.hz; // 平均 mm/圈 - else if (cfg.scan.z_speed > 0) - normal_gap = cfg.scan.z_speed * 10.0 / cfg.radar.hz; // cm/s ×10 = mm/s, ÷Hz = mm/圈 + if (radar_cfg().scan.z_speed_type == "configuration" && prof_v > 0) + normal_gap = prof_v / radar_cfg().radar.hz; // 平均 mm/圈 + else if (radar_cfg().scan.z_speed > 0) + normal_gap = radar_cfg().scan.z_speed * 10.0 / radar_cfg().radar.hz; // cm/s ×10 = mm/s, ÷Hz = mm/圈 else - normal_gap = 1000.0 / cfg.radar.hz; // ms/圈 + normal_gap = 1000.0 / radar_cfg().radar.hz; // ms/圈 if (normal_gap <= 0) return 0; - // 空隙判定: 本条与上一条 z 差 > 2×正常间隔 视为断流空隙 - const double gap_threshold = 2.0 * normal_gap; - // 条件1: 前后两条 Y 平均值差超过30mm 不插(可能跨层/换物体) + // 支持的模式: none / CopyTranslation / AngleBucketInterpolation + // / AngleBucketInterpolationByDilate + const std::string& mode = radar_cfg().count.z_interpolate_mode; + const bool by_dilate = (mode == "AngleBucketInterpolationByDilate"); + const double pd = (double)radar_cfg().count.point_dilate; // 膨胀像素(1单位z=1px) + // 角度插值桶宽(度): 应等于雷达实际角度分辨率。SDK 包头的 angle_resolution + // 单位是 0.01°, 合法值 {1,2,4,8,16,32} → 0.01/0.02/0.04/0.08/0.16/0.32 度。 + // 50Hz 常用 0.08°(一圈 4500 点)。原先写死 1°/桶会把一圈压缩成最多 360 点。 + const double bucket_deg = (radar_cfg().count.interp_bucket_deg > 0.0) + ? radar_cfg().count.interp_bucket_deg : 0.08; + const int nbuckets = (int)(360.0 / bucket_deg + 0.5); + + // ByDilate 补行步长 = point_dilate/2 + const double dilate_step = (by_dilate && pd > 0) ? (pd / 2.0) : normal_gap; + // 空隙判定阈值: + // ByDilate = 步长(即 point_dilate/2): 只要 gap 一超过步长就补行, 语义与 + // "每 point_dilate/2 增加一条"完全一致(门槛 = 步长)。 + // 其它 = 2×正常圈间隔(相对判定, 与速度无关) + const double gap_threshold = by_dilate ? dilate_step : (2.0 * normal_gap); + // 条件1(非 ByDilate 模式): 前后两条 Y 平均值差超过30mm 不插(可能跨层/换物体) const double max_y_mean_diff = 30.0; + // 条件1'(ByDilate 模式): 逐桶比较两端"中位 y", 差超过30mm 才跳过该方向。 + // 用中位数而非均值 —— 均值会被少量离群点(噪点/边缘点)拉偏, 使整个空隙被误判为 + // "跨层/换物体"而整体跳过、留下大间隙; 中位数对离群点鲁棒, 只跳过真正突变的 + // 方向, 其余方向照常平滑补行。 + const double max_y_med_diff = 30.0; // 一个空隙最多补的行数 const int MAX_FILL_LEVELS = 10; + // 取中位数(用于 ByDilate 的逐桶判定): 对离群点鲁棒, 不会被个别噪点拉偏 + auto median_of = [](const std::vector& v) -> double + { + if (v.empty()) return 0.0; + std::vector t(v.begin(), v.end()); + std::sort(t.begin(), t.end()); + const std::size_t m = t.size() / 2; + return (t.size() & 1) ? t[m] : (t[m - 1] + t[m]) * 0.5; + }; + // 1. 按 z 升序排序 std::sort(points.begin(), points.end(), [](const CountPoint& a, const CountPoint& b) { return a.z_val < b.z_val; }); @@ -394,6 +426,11 @@ int z_gap_interpolate(std::vector& points) }; int added = 0; + int gaps_hit = 0; // 超过门槛的空隙数 + int gaps_skip_y = 0; // 因 Y 均值差被跳过 + int gaps_skip_x = 0; // 因 X 范围约束/无公共角度桶 被过滤光 + int bucket_skip_med = 0; // ByDilate: 因逐桶中位 y 差过大被跳过的方向数 + int diag_printed = 0; // 诊断日志限流(最多 20 条) // 3. 逐空隙补行 for (size_t i = 1; i < circles.size(); ++i) { @@ -401,12 +438,31 @@ int z_gap_interpolate(std::vector& points) if (gap <= gap_threshold) // 未超过2×正常间隔, 不算空隙 continue; + ++gaps_hit; const Circle& cA = circles[i - 1]; // 上一条 const Circle& cB = circles[i]; // 本条 - // 条件1: 前后两条的Y平均值几乎相当(差≤30mm)才插值 - if (std::fabs(circle_mean_y(cA) - circle_mean_y(cB)) > max_y_mean_diff) - continue; + // 条件1: 前后两条的 Y 平均值几乎相当(差≤30mm)才插值 + // ByDilate 模式跳过此整圈判定 —— 它改用下面的"逐桶中位数"闸门, + // 避免个别离群点把整个空隙误判成跨层而整体跳过、留下大间隙。 + if (!by_dilate) + { + double ydiff = std::fabs(circle_mean_y(cA) - circle_mean_y(cB)); + if (ydiff > max_y_mean_diff) + { + ++gaps_skip_y; + if (diag_printed < 20) + { + ++diag_printed; + std::cerr << "[插值诊断] 跳过 gap=" << gap + << " (Z " << cA.z << "→" << cB.z << ")" + << ": Y均值差=" << ydiff << "mm > " + << max_y_mean_diff << "mm(疑似跨层/换物体)" + << std::endl; + } + continue; + } + } // 条件2: 生成行的 X 范围 = 这两条的 X 最大和最小 double x_lo = points[cA.begin].x_mm, x_hi = points[cA.begin].x_mm; @@ -421,12 +477,15 @@ int z_gap_interpolate(std::vector& points) if (points[p].x_mm > x_hi) x_hi = points[p].x_mm; } - // 空隙内补行数: 按正常间隔容纳行数-1(空隙两端已有), 最多10行 - int n = (int)(gap / normal_gap + 0.5) - 1; + // 空隙内补行数(最多10行): + // ByDilate = 每 point_dilate/2 补一条(按膨胀尺寸决定密度, 保证平滑连续) + // 其它 = 按正常间隔容纳行数-1(空隙两端已有) + int n = by_dilate ? (int)(gap / dilate_step) + : (int)(gap / normal_gap + 0.5) - 1; if (n < 1) continue; if (n > MAX_FILL_LEVELS) n = MAX_FILL_LEVELS; - if (cfg.count.z_interpolate_mode == "CopyTranslation") + if (mode == "CopyTranslation") { // 复制"上一条"的点, z 均匀平移到空隙内的各补行 for (int k = 1; k <= n; ++k) @@ -443,22 +502,28 @@ int z_gap_interpolate(std::vector& points) } } } - else if (cfg.count.z_interpolate_mode == "AngleBucketInterpolation") + // ByDilate 与 AngleBucketInterpolation 共用同一套角度桶线性插值(平滑过渡) + else if (mode == "AngleBucketInterpolation" || by_dilate) { // 角度桶插值: 每桶用前A/后B平均点线性插值(平滑过渡) - struct Buck { double sx, sy; int cnt; Buck() : sx(0), sy(0), cnt(0) {} }; - std::vector bA(360), bB(360); + // 桶宽沿用雷达实际角度分辨率(见上方 bucket_deg), 与一圈点数基本 1:1 对齐 + // ys: 仅 ByDilate 需要 —— 逐桶取中位 y 做平滑判定 + struct Buck { double sx, sy; int cnt; std::vector ys; + Buck() : sx(0), sy(0), cnt(0) {} }; + std::vector bA((std::size_t)nbuckets), bB((std::size_t)nbuckets); auto fill = [&](const Circle& c, std::vector& out) { for (size_t p = c.begin; p < c.end; ++p) { double deg = std::atan2(points[p].x_mm, -points[p].y_mm) * 57.29577951308232; if (deg < 0) deg += 360.0; - int b = (int)(deg + 0.5); - if (b >= 360) b = 0; - out[b].sx += points[p].x_mm; - out[b].sy += points[p].y_mm; - out[b].cnt++; + int b = (int)(deg / bucket_deg + 0.5); // 四舍五入到最近桶中心 + if (b >= nbuckets) b -= nbuckets; // 359.99° 会取到 nbuckets + if (b < 0) b = 0; + out[(std::size_t)b].sx += points[p].x_mm; + out[(std::size_t)b].sy += points[p].y_mm; + out[(std::size_t)b].cnt++; + if (by_dilate) out[(std::size_t)b].ys.push_back(points[p].y_mm); } }; fill(cA, bA); @@ -467,9 +532,24 @@ int z_gap_interpolate(std::vector& points) { double zk = cA.z + gap * (double)k / (n + 1); // 均匀分布 double t = (zk - cA.z) / gap; // 0~1 - for (int b = 0; b < 360; ++b) + int row_added = 0; + for (int b = 0; b < nbuckets; ++b) { if (bA[b].cnt <= 0 || bB[b].cnt <= 0) continue; + // ByDilate 的平滑闸门: 逐桶比较两端"中位 y"。 + // 原来按整圈均值判定 —— 少量噪点/边缘点就能把整圈均值拉偏, + // 使整个空隙被误判为"跨层/换物体"而整体跳过、留下大间隙。 + // 改成逐桶中位数后: 只跳过确实突变的方向, 其余方向照常平滑补行。 + if (by_dilate) + { + double myA = median_of(bA[(std::size_t)b].ys); + double myB = median_of(bB[(std::size_t)b].ys); + if (std::fabs(myA - myB) > max_y_med_diff) + { + ++bucket_skip_med; + continue; + } + } double ax = bA[b].sx / bA[b].cnt, ay = bA[b].sy / bA[b].cnt; double bx = bB[b].sx / bB[b].cnt, by = bB[b].sy / bB[b].cnt; double nx = ax + (bx - ax) * t; @@ -481,16 +561,37 @@ int z_gap_interpolate(std::vector& points) np.z_val = zk; points.push_back(np); ++added; + ++row_added; + } + if (row_added == 0 && diag_printed < 20) + { + ++diag_printed; + std::cerr << "[插值诊断] 补行 k=" << k << "/" << n + << " 产出 0 点 (无公共角度桶 或 全被 X 范围过滤)" << std::endl; } } + if ((int)(gap / dilate_step) > MAX_FILL_LEVELS && diag_printed < 20) + { + ++diag_printed; + std::cerr << "[插值诊断] 空隙 gap=" << gap << " 触发 10 条上限, 补完行间距=" + << gap / (double)(MAX_FILL_LEVELS + 1) + << "mm (> 步长" << dilate_step << "mm)" << std::endl; + } } } if (added > 0) - std::cout << "[count] z空隙插值: 模式=" << cfg.count.z_interpolate_mode + std::cout << "[count] z空隙插值: 模式=" << mode << ", 正常间隔=" << normal_gap - << ", 判定阈值=2×间隔=" << gap_threshold - << ", 新增" << added << "点" << std::endl; + << ", 判定阈值=" << gap_threshold + << (by_dilate ? "(point_dilate)" : "(2×间隔)") + << ", 插值桶=" << bucket_deg << "°x" << nbuckets + << ", 新增" << added << "点" + << " [空隙统计: 超门槛=" << gaps_hit + << ", 整圈Y均值跳过=" << gaps_skip_y + << ", 逐桶中位y跳过=" << bucket_skip_med + << ", 补行产出0点=" << gaps_skip_x << "]" + << std::endl; return added; } @@ -592,10 +693,10 @@ int calc_item_count(const std::vector& points, const CountParams& bo if (points.empty() || box.l <= 0 || box.w <= 0 || box.h <= 0 || box.maxPerLayer <= 0) return 0; - const bool use_half_max = (cfg.count.layer_algorithm == "HalfMax"); - const bool use_kde_peak = (cfg.count.layer_algorithm == "KdePeak"); + const bool use_half_max = (radar_cfg().count.layer_algorithm == "HalfMax"); + const bool use_kde_peak = (radar_cfg().count.layer_algorithm == "KdePeak"); // 分层容差(三个算法统一): 基准 + 每层递增 - auto layer_tol = [&](int lv) { return cfg.count.base_tolerance + lv * cfg.count.per_level_tolerance; }; + auto layer_tol = [&](int lv) { return radar_cfg().count.base_tolerance + lv * radar_cfg().count.per_level_tolerance; }; std::unordered_map> layer_map; // 每层最终有效点 std::unordered_map layer_max_h; // 每层实际最高点高度(mm) @@ -608,9 +709,9 @@ int calc_item_count(const std::vector& points, const CountParams& bo std::unordered_map> coarse_map; for (const auto& p : points) { - if (FILTER_ENABLE && !point_in_polygon(p.x_mm, p.y_mm, cfg.filter.polygon, cfg.filter.poly_n)) + if (FILTER_ENABLE && !point_in_polygon(p.x_mm, p.y_mm, radar_cfg().filter.polygon, radar_cfg().filter.poly_n)) continue; - double currentHeight = cfg.count.floor_height - p.y_mm; // 高度 = 地板值 - Y轴 + double currentHeight = radar_cfg().count.floor_height - p.y_mm; // 高度 = 地板值 - Y轴 if (currentHeight < 0) continue; int level = (int)std::round(currentHeight / box.h); if (level < 1) continue; // 第1层从半层处开始 @@ -628,9 +729,9 @@ int calc_item_count(const std::vector& points, const CountParams& bo double lo, hi; if (use_half_max) { - double top = cfg.count.floor_height - kv.second.front().y_mm; + double top = radar_cfg().count.floor_height - kv.second.front().y_mm; for (const auto& p : kv.second) - top = std::max(top, cfg.count.floor_height - p.y_mm); + top = std::max(top, radar_cfg().count.floor_height - p.y_mm); lo = top - tol; hi = top; } @@ -638,8 +739,8 @@ int calc_item_count(const std::vector& points, const CountParams& bo { std::vector hs; hs.reserve(kv.second.size()); - for (const auto& p : kv.second) hs.push_back(cfg.count.floor_height - p.y_mm); - double peak = kde_peak_height(hs, cfg.count.kde_bandwidth, cfg.count.kde_step); + for (const auto& p : kv.second) hs.push_back(radar_cfg().count.floor_height - p.y_mm); + double peak = kde_peak_height(hs, radar_cfg().count.kde_bandwidth, radar_cfg().count.kde_step); lo = peak - tol; hi = peak + tol; } @@ -648,7 +749,7 @@ int calc_item_count(const std::vector& points, const CountParams& bo double top = std::numeric_limits::lowest(); for (const auto& p : kv.second) { - double currentHeight = cfg.count.floor_height - p.y_mm; + double currentHeight = radar_cfg().count.floor_height - p.y_mm; if (currentHeight < lo || currentHeight > hi) continue; keep.push_back(p); if (currentHeight > top) top = currentHeight; @@ -664,12 +765,12 @@ int calc_item_count(const std::vector& points, const CountParams& bo { for (const auto& p : points) { - if (FILTER_ENABLE && !point_in_polygon(p.x_mm, p.y_mm, cfg.filter.polygon, cfg.filter.poly_n)) + if (FILTER_ENABLE && !point_in_polygon(p.x_mm, p.y_mm, radar_cfg().filter.polygon, radar_cfg().filter.poly_n)) continue; - double currentHeight = cfg.count.floor_height - p.y_mm; // 高度 = 地板值 - Y轴 + double currentHeight = radar_cfg().count.floor_height - p.y_mm; // 高度 = 地板值 - Y轴 if (currentHeight < 0) continue; int level = (int)std::round(currentHeight / box.h); - double tolerance = cfg.count.base_tolerance + level * cfg.count.per_level_tolerance; + double tolerance = radar_cfg().count.base_tolerance + level * radar_cfg().count.per_level_tolerance; if (std::abs(currentHeight - level * box.h) <= tolerance) { layer_map[level].push_back(p); @@ -763,32 +864,48 @@ int calc_item_count(const std::vector& points, const CountParams& bo } // ==================== count 结果文件后台保存 ==================== -void save_count_files_async(const std::vector& points, +void save_count_files_async(const std::string& radar_id, + const std::vector& points, const std::vector& top_layer_img, int top_img_w, int top_img_h, const CountParams& params, const std::string& layer_detail, - int total, int top_lv, double top_area) + int total, int top_lv, double top_area, + const std::vector* points_before) { // C++11 兼容:按值捕获(拷贝到线程),不阻塞 TCP 响应 - std::thread([points, top_layer_img, params, layer_detail, total, top_lv, top_area, - top_img_w, top_img_h]() { + // 插值前快照按值拷贝进线程,避免 pts 生命周期问题 + std::vector before_copy = + points_before ? *points_before : std::vector(); + std::thread([radar_id, points, before_copy, top_layer_img, params, layer_detail, + total, top_lv, top_area, top_img_w, top_img_h]() { + // 本线程是 detached:异常逃逸会调用 std::terminate 直接杀掉整个进程, + // 因此整个保存流程必须有兜底捕获。 + try + { + RadarScope scope(radar_id); // 线程内绑定该雷达配置(多边形/膨胀/日志等) // 目录: data/日期/时间_taskId(由 io_utils 统一创建) std::string out_dir = make_task_dir(params.task_id); size_t pt_cnt = points.size(); - // PCD:y 换成高度(cfg.count.floor_height - y_mm) - std::vector xs, ys, zs; - xs.reserve(pt_cnt); ys.reserve(pt_cnt); zs.reserve(pt_cnt); - for (const auto& p : points) + // PCD:y 换成高度(radar_cfg().count.floor_height - y_mm) + auto save_pcd = [&](const std::vector& src, const char* name) { - xs.push_back(p.x_mm); - ys.push_back(cfg.count.floor_height - p.y_mm); - zs.push_back(p.z_val); - } - save_pcd_ascii(out_dir + "/count_data.pcd", xs, ys, zs); - std::cout << "[count] 点云已保存: " << out_dir << "/count_data.pcd (" - << pt_cnt << " 点, Y=高度)" << std::endl; + std::vector xs, ys, zs; + xs.reserve(src.size()); ys.reserve(src.size()); zs.reserve(src.size()); + for (const auto& p : src) + { + xs.push_back(p.x_mm); + ys.push_back(radar_cfg().count.floor_height - p.y_mm); + zs.push_back(p.z_val); + } + save_pcd_ascii(out_dir + "/" + name, xs, ys, zs); + std::cout << "[count] 点云已保存: " << out_dir << "/" << name << " (" + << src.size() << " 点, Y=高度)" << std::endl; + }; + save_pcd(points, "count_data.pcd"); // 插值后(原名, 兼容现有流程) + if (!before_copy.empty()) // 插值前快照, 便于对比 + save_pcd(before_copy, "count_data_before.pcd"); // 图片1:全点X-Z平面图 double all_area = 0.0; @@ -823,7 +940,7 @@ void save_count_files_async(const std::vector& points, << ", w=" << params.w << ", h=" << params.h << ", maxPerLayer=" << params.maxPerLayer << "\n"; - sum << "[count] floor_height: " << cfg.count.floor_height << " mm\n"; + sum << "[count] floor_height: " << radar_cfg().count.floor_height << " mm\n"; sum << "[count] 采集点数: " << pt_cnt << "\n"; sum << "[count] 计数层: 层" << top_lv << " (面积=" << std::fixed << std::setprecision(1) @@ -842,5 +959,11 @@ void save_count_files_async(const std::vector& points, } } std::cout << "[count] 输出目录: " << out_dir << std::endl; + } + catch (const std::exception& e) + { + std::cerr << "[count] 后台保存异常: " << e.what() << std::endl; + } + catch (...) { std::cerr << "[count] 后台保存发生未知异常" << std::endl; } }).detach(); } diff --git a/count.h b/count.h index 619f38d..5117d01 100644 --- a/count.h +++ b/count.h @@ -24,7 +24,11 @@ std::vector points_to_image(const std::vector& points, // z轴空隙插值:雷达断流/瞬间丢包导致的 z 空白带,仅在停止采集后调用一次。 // 原理:同一圈(scan包)点 z 相同,正常圈间隔≈normal_gap; // gap>2×normal_gap 且相邻两圈 Y均值差≤30mm 且 X 在相邻圈范围内时,空隙内补行。 -// cfg.count.z_interpolate_mode: none/CopyTranslation/AngleBucketInterpolation(最多补10行)。 +// cfg.count.z_interpolate_mode: none/CopyTranslation/AngleBucketInterpolation +// /AngleBucketInterpolationByDilate(均最多补10行)。 +// AngleBucketInterpolationByDilate: 空隙 > point_dilate 才插值(空隙不超过膨胀尺寸时 +// 画 layer 图膨胀后本来就连上, 无需补), +// 每 point_dilate/2 补一条, 平滑过渡。 // 会原地修改 points(新增点追加到末尾);返回新增点数。 int z_gap_interpolate(std::vector& points); @@ -53,11 +57,17 @@ int calc_item_count(const std::vector& points, const CountParams& bo // count_points.jpg (全部点 X-Z 平面图) // count_layer.jpg (被计数层图像, 有有效层时) // log.txt (cfg.log.enable 开启时) -void save_count_files_async(const std::vector& points, +// radar_id: 该结果所属雷达;后台线程内会绑定该雷达配置。 +// points : 插值后的点集(保存为 count_data.pcd) +// points_before : 仅调试用。非空时另存一份"插值前"快照 count_data_before.pcd, +// 便于对比插值效果;正常运行时传 nullptr 即可。 +void save_count_files_async(const std::string& radar_id, + const std::vector& points, const std::vector& top_layer_img, int top_img_w, int top_img_h, const CountParams& params, const std::string& layer_detail, - int total, int top_lv, double top_area); + int total, int top_lv, double top_area, + const std::vector* points_before = nullptr); #endif // COUNT_H diff --git a/crash_handler.cpp b/crash_handler.cpp new file mode 100644 index 0000000..01cdf4c --- /dev/null +++ b/crash_handler.cpp @@ -0,0 +1,665 @@ +// crash_handler.cpp - 崩溃现场捕获与死因日志实现 +// +// 设计约束:崩溃发生时堆/栈可能已损坏,因此处理路径上 +// 1) 不使用 std::string / iostream / 动态分配; +// 2) 只用栈缓冲 + vsnprintf + Win32 API 直接写文件并 FlushFileBuffers; +// 3) 用 g_in_handler 防止 handler 内部二次崩溃造成无限递归。 +#include "crash_handler.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +// 崩溃日志最大单条长度 +#define CH_MAX_TEXT 8192 + +namespace +{ + char g_log_dir[512] = "data"; // 崩溃日志根目录 + char g_milestone[512] = ""; // 最近一次里程碑说明 + char g_exe_name[64] = "main.exe"; // 本程序文件名(生成 addr2line 命令用) + // PE 文件头里的 ImageBase。addr2line 认的是 ImageBase+RVA,不是运行时的加载基址 + // (ASLR 下两者不同),直接用崩溃时的虚拟地址会全部解析成 "??"。 + uintptr_t g_image_base = 0x140000000; + int g_installed = 0; + int g_in_handler = 0; + + std::mutex& milestone_mtx() + { + static std::mutex m; // 函数内静态,保证初始化顺序安全 + return m; + } + + void now_str(char* buf, size_t cap) + { + std::time_t t = std::time(nullptr); + struct tm tmv; +#ifdef _WIN32 + localtime_s(&tmv, &t); +#else + localtime_r(&t, &tmv); +#endif + std::strftime(buf, cap, "%Y-%m-%d %H:%M:%S", &tmv); + } + + void stamp_str(char* buf, size_t cap) // YYYYMMDD_HHMMSS(文件名用) + { + std::time_t t = std::time(nullptr); + struct tm tmv; +#ifdef _WIN32 + localtime_s(&tmv, &t); +#else + localtime_r(&t, &tmv); +#endif + std::strftime(buf, cap, "%Y%m%d_%H%M%S", &tmv); + } + + void date_str(char* buf, size_t cap) // YYYYMMDD(目录名用) + { + std::time_t t = std::time(nullptr); + struct tm tmv; +#ifdef _WIN32 + localtime_s(&tmv, &t); +#else + localtime_r(&t, &tmv); +#endif + std::strftime(buf, cap, "%Y%m%d", &tmv); + } +} + +// ==================== 日志落盘(多路径回退) ==================== +#ifdef _WIN32 +namespace +{ + // UTF-8 -> UTF-16 + int u8_to_w(const char* s, wchar_t* out, int cap) + { + int n = MultiByteToWideChar(CP_UTF8, 0, s, -1, out, cap); + if (n <= 0 && cap > 0) out[0] = L'\0'; + return n; + } + + bool append_file_w(const wchar_t* wpath, const char* text, size_t len) + { + HANDLE h = CreateFileW(wpath, GENERIC_WRITE, FILE_SHARE_READ, + NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) return false; + LARGE_INTEGER mv; mv.QuadPart = 0; + SetFilePointerEx(h, mv, NULL, FILE_END); + DWORD written = 0; + BOOL ok = WriteFile(h, text, (DWORD)len, &written, NULL); + FlushFileBuffers(h); + CloseHandle(h); + return ok && written > 0; + } + + // 依次尝试: //crash.log -> /crash.log -> /simiradar_crash.log + // 返回实际写入成功的目录(宽字符,供 minidump 复用);失败返回空。 + bool write_crash_text(const char* text, wchar_t* out_dir, int cap) + { + wchar_t path[MAX_PATH * 2]; + char date[16]; + date_str(date, sizeof(date)); + + // 1) data/YYYYMMDD/crash.log + wchar_t wdir[MAX_PATH * 2], wdate[32]; + u8_to_w(g_log_dir, wdir, MAX_PATH * 2); + u8_to_w(date, wdate, 32); + CreateDirectoryW(wdir, NULL); // 已存在则失败,忽略 + _snwprintf(path, MAX_PATH * 2, L"%s\\%s", wdir, wdate); + CreateDirectoryW(path, NULL); + _snwprintf(path, MAX_PATH * 2, L"%s\\%s\\crash.log", wdir, wdate); + if (append_file_w(path, text, std::strlen(text))) + { + _snwprintf(out_dir, cap, L"%s\\%s", wdir, wdate); + return true; + } + + // 2) exe 同目录 crash.log(部署机上工作目录常常不是 exe 目录) + wchar_t exe[MAX_PATH]; + DWORD n = GetModuleFileNameW(NULL, exe, MAX_PATH); + if (n > 0) + { + for (DWORD i = n - 1; i > 0; --i) + if (exe[i] == L'\\' || exe[i] == L'/') { exe[i] = L'\0'; break; } + _snwprintf(path, MAX_PATH * 2, L"%s\\crash.log", exe); + if (append_file_w(path, text, std::strlen(text))) + { + _snwprintf(out_dir, cap, L"%s", exe); + return true; + } + } + + // 3) 系统临时目录 + wchar_t tmp[MAX_PATH]; + if (GetTempPathW(MAX_PATH, tmp) > 0) + { + _snwprintf(path, MAX_PATH * 2, L"%ssimiradar_crash.log", tmp); + if (append_file_w(path, text, std::strlen(text))) + { + _snwprintf(out_dir, cap, L"%s", tmp); + return true; + } + } + return false; + } + + // ---- dbghelp:符号/行号/MiniDump,全部动态加载,缺库也不影响基本记录 ---- + typedef BOOL (WINAPI *SymInitialize_t)(HANDLE, PCSTR, BOOL); + typedef BOOL (WINAPI *SymFromAddr_t)(HANDLE, DWORD64, PDWORD64, PVOID); + typedef BOOL (WINAPI *SymGetLineFromAddr64_t)(HANDLE, DWORD64, PDWORD, PVOID); + typedef DWORD (WINAPI *SymSetOptions_t)(DWORD); + typedef BOOL (WINAPI *MiniDumpWriteDump_t)(HANDLE, DWORD, HANDLE, DWORD, PVOID, PVOID, PVOID); + typedef BOOL (WINAPI *SymCleanup_t)(HANDLE); + + struct SymPkg // dbghelp.h 的 SYMBOL_INFO(自绘,避免include) + { + ULONG SizeOfStruct; + ULONG TypeIndex; + ULONG64 Reserved[2]; + ULONG Index; + ULONG Size; + ULONG64 ModBase; + ULONG Flags; + ULONG64 Value; + ULONG64 Address; + ULONG Register; + ULONG Scope; + ULONG Tag; + ULONG NameLen; + ULONG MaxNameLen; + CHAR Name[1]; + }; + + struct LinePkg // IMAGEHLP_LINE64 + { + DWORD SizeOfStruct; + PVOID Key; + DWORD64 LineNumber; + PCHAR FileName; + DWORD64 Address; + }; + + struct DumpExcInfo // MINIDUMP_EXCEPTION_INFORMATION + { + DWORD ThreadId; + PEXCEPTION_POINTERS ExceptionPointers; + BOOL ClientPointers; + }; + + HMODULE g_dbg = NULL; + SymInitialize_t g_SymInit = NULL; + SymFromAddr_t g_SymAddr = NULL; + SymGetLineFromAddr64_t g_SymLine = NULL; + SymSetOptions_t g_SymOpt = NULL; + MiniDumpWriteDump_t g_Dump = NULL; + + void dbghelp_load() + { + if (g_dbg) return; + g_dbg = LoadLibraryW(L"dbghelp.dll"); + if (!g_dbg) return; + g_SymInit = (SymInitialize_t)GetProcAddress(g_dbg, "SymInitialize"); + g_SymAddr = (SymFromAddr_t)GetProcAddress(g_dbg, "SymFromAddr"); + g_SymLine = (SymGetLineFromAddr64_t)GetProcAddress(g_dbg, "SymGetLineFromAddr64"); + g_SymOpt = (SymSetOptions_t)GetProcAddress(g_dbg, "SymSetOptions"); + g_Dump = (MiniDumpWriteDump_t)GetProcAddress(g_dbg, "MiniDumpWriteDump"); + if (g_SymOpt) g_SymOpt(0x00000002 /*SYMOPT_UNDNAME*/ | 0x00000004 /*SYMOPT_DEFERRED_LOADS*/); + if (g_SymInit) g_SymInit(GetCurrentProcess(), NULL, TRUE); + } + + // 地址 -> "模块名+0x偏移 (符号) [文件:行]" + void describe_addr(void* addr, char* out, size_t cap) + { + out[0] = '\0'; + HMODULE hMod = NULL; + char modname[64] = "?"; + uintptr_t base = 0; + if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + (LPCWSTR)addr, &hMod) && hMod) + { + WCHAR wpath[MAX_PATH]; + DWORD n = GetModuleFileNameW(hMod, wpath, MAX_PATH); + if (n > 0) + { + const WCHAR* fn = wpath; + for (DWORD i = 0; i < n; ++i) + if (wpath[i] == L'\\' || wpath[i] == L'/') fn = wpath + i + 1; + WideCharToMultiByte(CP_UTF8, 0, fn, -1, modname, sizeof(modname), NULL, NULL); + } + base = (uintptr_t)hMod; + } + uintptr_t off = (uintptr_t)addr - base; + + char sym[256] = "", loc[256] = ""; + if (g_SymAddr) + { + char buf[sizeof(SymPkg) + 256]; + std::memset(buf, 0, sizeof(buf)); + SymPkg* si = (SymPkg*)buf; + si->SizeOfStruct = sizeof(SymPkg); + si->MaxNameLen = 256; + DWORD64 disp = 0; + if (g_SymAddr(GetCurrentProcess(), (DWORD64)(uintptr_t)addr, &disp, si)) + _snprintf(sym, sizeof(sym), " %s+0x%llX", si->Name, (unsigned long long)disp); + if (g_SymLine) + { + LinePkg ln; + std::memset(&ln, 0, sizeof(ln)); + ln.SizeOfStruct = sizeof(LinePkg); + DWORD d = 0; + if (g_SymLine(GetCurrentProcess(), (DWORD64)(uintptr_t)addr, &d, &ln) && ln.FileName) + { + const char* f = std::strrchr(ln.FileName, '\\'); + if (!f) f = std::strrchr(ln.FileName, '/'); + _snprintf(loc, sizeof(loc), " [%s:%lu]", f ? f + 1 : ln.FileName, + (unsigned long)ln.LineNumber); + } + } + } + _snprintf(out, cap, " 0x%p %s+0x%llX%s%s", + addr, modname, (unsigned long long)off, sym, loc); + } + + typedef USHORT (WINAPI *CaptureStackBackTrace_t)(ULONG, ULONG, PVOID*, PULONG); + + // 输出调用栈;同时把属于本程序的帧地址收集到 addrs(供 addr2line 还原行号) + void dump_stack(char* out, size_t cap, int skip, uintptr_t* addrs, int* naddr, int maxaddr) + { + HMODULE krnl = GetModuleHandleW(L"kernel32.dll"); + CaptureStackBackTrace_t cap_fn = + krnl ? (CaptureStackBackTrace_t)GetProcAddress(krnl, "RtlCaptureStackBackTrace") : NULL; + if (!cap_fn) + { + std::strncat(out, " (不支持栈回溯)\n", cap - std::strlen(out) - 1); + return; + } + void* frames[64]; + USHORT n = cap_fn(0, 64, frames, NULL); + if (n == 0) { std::strncat(out, " (空)\n", cap - std::strlen(out) - 1); return; } + uintptr_t load_base = (uintptr_t)GetModuleHandleW(NULL); // 运行时加载基址(ASLR) + for (USHORT i = (USHORT)skip; i < n && i < 64; ++i) + { + char line[512]; + describe_addr(frames[i], line, sizeof(line)); + std::strncat(out, line, cap - std::strlen(out) - 1); + std::strncat(out, "\n", cap - std::strlen(out) - 1); + if (addrs && naddr && *naddr < maxaddr && std::strstr(line, g_exe_name)) + { + // 转成 ImageBase+RVA:addr2line 只认这种形式 + uintptr_t rva = (uintptr_t)frames[i] - load_base; + addrs[(*naddr)++] = g_image_base + rva; + } + } + } + + void write_minidump(const wchar_t* dir, PEXCEPTION_POINTERS ep) + { + if (!g_Dump || !dir || !dir[0]) return; + char stamp[32]; + stamp_str(stamp, sizeof(stamp)); + wchar_t wstamp[64], path[MAX_PATH * 2]; + u8_to_w(stamp, wstamp, 64); + _snwprintf(path, MAX_PATH * 2, L"%s\\crash_%s.dmp", dir, wstamp); + HANDLE hf = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, NULL); + if (hf == INVALID_HANDLE_VALUE) return; + DumpExcInfo ei; + ei.ThreadId = GetCurrentThreadId(); + ei.ExceptionPointers = ep; + ei.ClientPointers = FALSE; + BOOL ok = g_Dump(GetCurrentProcess(), GetCurrentProcessId(), hf, 0 /*MiniDumpNormal*/, + &ei, NULL, NULL); + FlushFileBuffers(hf); + DWORD sz = GetFileSize(hf, NULL); + CloseHandle(hf); + // 空的/失败的 dmp 只会误导排查,直接删掉(地址+addr2line 已足够定位) + if (!ok || sz == 0) + { + DeleteFileW(path); + char note[160]; + _snprintf(note, sizeof(note), + "[说明] minidump 生成失败(ok=%d size=%lu err=%lu)," + "请用上面的 addr2line 命令还原行号。\n", + (int)ok, (unsigned long)sz, (unsigned long)GetLastError()); + wchar_t wpath2[MAX_PATH * 2]; + _snwprintf(wpath2, MAX_PATH * 2, L"%s\\crash.log", dir); + append_file_w(wpath2, note, std::strlen(note)); + } + } + + // 异常码 -> 中文说明 + const char* exc_name(DWORD code) + { + switch (code) + { + case 0xC0000005: return "ACCESS_VIOLATION 访问违规(空指针/野指针/越界/已释放内存)"; + case 0xC00000FD: return "STACK_OVERFLOW 栈溢出(递归过深或栈上大数组)"; + case 0xC0000094: return "INT_DIVIDE_BY_ZERO 除零"; + case 0xC0000095: return "INT_OVERFLOW 整数溢出"; + case 0xC000001D: return "ILLEGAL_INSTRUCTION 非法指令"; + case 0xC0000006: return "IN_PAGE_ERROR 内存页错误(读取映射文件失败)"; + case 0xC000008C: return "ARRAY_BOUNDS_EXCEEDED 数组越界"; + case 0xC0000135: return "DLL_NOT_FOUND 依赖的DLL未找到"; + case 0xC0000142: return "DLL_INIT_FAILED DLL初始化失败"; + case 0xC0000409: return "STACK_BUFFER_OVERRUN 栈缓冲区溢出(编译器安全检查触发)"; + case 0xC0000374: return "HEAP_CORRUPTION 堆损坏(重复释放/越界写)"; + case 0xC0000417: return "INVALID_CRUNTIME_PARAMETER CRT非法参数"; + case 0xE06D7363: return "C++ 异常未捕获 (std::terminate)"; + case 0x40010005: return "CTRL_CLOSE / 控制台被关闭"; + case 0x80000003: return "BREAKPOINT 断点"; + default: return "未知异常"; + } + } + + // 组装并落盘一条崩溃记录 + void report_crash(const char* title, PEXCEPTION_POINTERS ep, DWORD code) + { + if (g_in_handler) return; // 防递归 + g_in_handler = 1; + + char text[CH_MAX_TEXT]; + char ts[32]; + now_str(ts, sizeof(ts)); + + int len = _snprintf(text, sizeof(text), + "\n==================== %s ====================\n" + "时间 : %s\n" + "进程/线程 : pid=%lu tid=%lu\n" + "最后里程碑: %s\n", + title, ts, + (unsigned long)GetCurrentProcessId(), (unsigned long)GetCurrentThreadId(), + g_milestone[0] ? g_milestone : "(无)"); + + if (code) + len += _snprintf(text + len, sizeof(text) - len, + "异常码 : 0x%08lX %s\n", (unsigned long)code, exc_name(code)); + + void* fault_addr = NULL; + if (ep && ep->ExceptionRecord) + { + PEXCEPTION_RECORD r = ep->ExceptionRecord; + fault_addr = r->ExceptionAddress; + len += _snprintf(text + len, sizeof(text) - len, + "异常地址 : 0x%p (%s 0x%p)\n" + "异常标志 : 0x%lX 参数个数: %lu\n", + r->ExceptionAddress, + (r->ExceptionCode == 0xC0000005 + ? (r->ExceptionInformation[0] ? "写入" : "读取") : "访问"), + (r->ExceptionCode == 0xC0000005 && r->NumberParameters >= 2) + ? (void*)r->ExceptionInformation[1] : (void*)0, + (unsigned long)r->ExceptionFlags, (unsigned long)r->NumberParameters); + char line[512]; + describe_addr(fault_addr, line, sizeof(line)); + len += _snprintf(text + len, sizeof(text) - len, "出错位置 :%s\n", line); + + if (ep->ContextRecord) + { +#ifdef _WIN64 + len += _snprintf(text + len, sizeof(text) - len, + "寄存器 : RIP=0x%llX RSP=0x%llX RBP=0x%llX RAX=0x%llX\n", + (unsigned long long)ep->ContextRecord->Rip, + (unsigned long long)ep->ContextRecord->Rsp, + (unsigned long long)ep->ContextRecord->Rbp, + (unsigned long long)ep->ContextRecord->Rax); +#else + len += _snprintf(text + len, sizeof(text) - len, + "寄存器 : EIP=0x%lX ESP=0x%lX EBP=0x%lX EAX=0x%lX\n", + (unsigned long)ep->ContextRecord->Eip, + (unsigned long)ep->ContextRecord->Esp, + (unsigned long)ep->ContextRecord->Ebp, + (unsigned long)ep->ContextRecord->Eax); +#endif + } + } + + len += _snprintf(text + len, sizeof(text) - len, "调用栈 :\n"); + uintptr_t addrs[64]; + int naddr = 0; + dump_stack(text + len, sizeof(text) - len, 0, addrs, &naddr, 64); + len = (int)std::strlen(text); + len += _snprintf(text + len, sizeof(text) - len, + "提示 : 同目录下 crash_*.dmp 可用 Visual Studio / WinDbg 打开查看完整现场。\n"); + // MinGW 生成的是 DWARF 调试信息,Windows 的 dbghelp 读不了,用 addr2line 还原: + // addr2line -e main.exe -f -C -i <地址...> + if (naddr > 0 && len < (int)sizeof(text) - 64) + { + len += _snprintf(text + len, sizeof(text) - len, + "还原行号 : addr2line -e %s -f -C -i", g_exe_name); + for (int i = 0; i < naddr && len < (int)sizeof(text) - 24; ++i) + len += _snprintf(text + len, sizeof(text) - len, " 0x%llX", + (unsigned long long)addrs[i]); + len += _snprintf(text + len, sizeof(text) - len, "\n"); + } + + wchar_t dir[MAX_PATH * 2] = { 0 }; + write_crash_text(text, dir, MAX_PATH * 2); + + // 控制台/调试器可见 + fflush(stdout); + std::fwrite(text, 1, std::strlen(text), stderr); + fflush(stderr); + OutputDebugStringA(text); + + write_minidump(dir, ep); + + // 弹窗提示(无人值守部署可设环境变量 SIMIRADAR_NO_CRASH_MSGBOX=1 关闭) + char envbuf[8] = { 0 }; + if (GetEnvironmentVariableA("SIMIRADAR_NO_CRASH_MSGBOX", envbuf, sizeof(envbuf)) == 0) + { + char msg[512]; + _snprintf(msg, sizeof(msg), + "%s\n\n异常码: 0x%08lX (%s)\n\n详细信息已写入:\n%ls\\crash.log", + title, (unsigned long)code, exc_name(code), + dir[0] ? dir : L"(写入失败)"); + MessageBoxA(NULL, msg, "Pavo2 程序异常终止", + MB_OK | MB_ICONERROR | MB_SETFOREGROUND | MB_TOPMOST); + } + g_in_handler = 0; + } + + LONG WINAPI seh_filter(PEXCEPTION_POINTERS ep) + { + DWORD code = ep && ep->ExceptionRecord ? ep->ExceptionRecord->ExceptionCode : 0; + if (code == 0x40010005) return EXCEPTION_CONTINUE_SEARCH; // 控制台关闭:正常退出 + report_crash("崩溃: 未处理的系统异常(SEH)", ep, code); + return EXCEPTION_EXECUTE_HANDLER; // 交给系统终止进程 + } +} +#endif // _WIN32 + +// ==================== 非 Windows:只做 signal 兜底 ==================== +namespace +{ + void report_text(const char* title) + { + char text[CH_MAX_TEXT]; + char ts[32]; + now_str(ts, sizeof(ts)); + _snprintf(text, sizeof(text), + "\n==================== %s ====================\n时间: %s\n最后里程碑: %s\n", + title, ts, g_milestone[0] ? g_milestone : "(无)"); +#ifdef _WIN32 + wchar_t dir[MAX_PATH * 2] = { 0 }; + write_crash_text(text, dir, MAX_PATH * 2); +#endif + std::fwrite(text, 1, std::strlen(text), stderr); + fflush(stderr); + } + + void abort_signal(int sig) + { + if (g_in_handler) { std::signal(sig, SIG_DFL); std::raise(sig); return; } + g_in_handler = 1; + char title[128]; + _snprintf(title, sizeof(title), "崩溃: 收到信号 %d (%s)", sig, + sig == SIGABRT ? "abort()/未处理异常/std::thread未join" + : (sig == SIGSEGV ? "段错误" : + (sig == SIGFPE ? "浮点异常" : "非法指令"))); +#ifdef _WIN32 + report_crash(title, NULL, 0); // 仍记录调用栈与 minidump +#else + report_text(title); +#endif + g_in_handler = 0; + std::signal(sig, SIG_DFL); + std::raise(sig); // 保持默认退出行为,便于外部捕获退出码 + } + + void on_terminate() + { + if (g_in_handler) std::abort(); + g_in_handler = 1; + std::string what; + if (std::exception_ptr ep = std::current_exception()) + { + try { std::rethrow_exception(ep); } + catch (const std::exception& e) { what = e.what(); } + catch (...) { what = "非 std::exception 类型的异常"; } + } + std::string title = "崩溃: std::terminate 被调用"; + if (!what.empty()) + { + title += " (未捕获异常: "; + title += what; + title += ")"; + } + else + { + // terminate 最常见的另一种诱因:std::thread 析构时仍 joinable(未 join/detach) + title += " (无活动异常 —— 极可能是 std::thread 对象析构时线程仍在运行," + "即某处 start 了线程却没有 join/detach)"; + } +#ifdef _WIN32 + report_crash(title.c_str(), NULL, 0); // 记录调用栈 + minidump + fflush(stderr); + ExitProcess(3); +#else + report_text(title.c_str()); + std::_Exit(3); +#endif + } + + void on_new_fail() + { + if (g_in_handler) return; + g_in_handler = 1; + report_text("崩溃: new 失败(std::bad_alloc) —— 内存不足或申请了超大内存"); + g_in_handler = 0; + throw std::bad_alloc(); + } +} + +// ==================== 对外接口 ==================== +void install_crash_handlers(const char* log_dir) +{ + if (g_installed) return; + g_installed = 1; + if (log_dir && log_dir[0]) + std::strncpy(g_log_dir, log_dir, sizeof(g_log_dir) - 1); + +#ifdef _WIN32 + char exe[MAX_PATH]; + if (GetModuleFileNameA(NULL, exe, MAX_PATH) > 0) + { + const char* fn = std::strrchr(exe, '\\'); + if (!fn) fn = std::strrchr(exe, '/'); + std::strncpy(g_exe_name, fn ? fn + 1 : exe, sizeof(g_exe_name) - 1); + g_exe_name[sizeof(g_exe_name) - 1] = '\0'; + } + // 读 PE 头里的 ImageBase,供 addr2line 还原行号 + { + char* base = (char*)GetModuleHandleW(NULL); + uintptr_t load_base = (uintptr_t)base; // 运行时加载基址(ASLR,每次不同) + if (base) + { + IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base; + if (dos->e_magic == IMAGE_DOS_SIGNATURE) + { + IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(base + dos->e_lfanew); + if (nt->Signature == IMAGE_NT_SIGNATURE) + { + uintptr_t ib = (uintptr_t)nt->OptionalHeader.ImageBase; + // 只有读到"合理且与加载基址不同"的静态值才采用; + // 否则保持默认 0x140000000(MinGW 链接默认值) + if (ib >= 0x10000 && ib != load_base) g_image_base = ib; + } + } + } + } + dbghelp_load(); // 提前初始化符号,崩溃时只做查询 + SetUnhandledExceptionFilter(seh_filter); + // 关掉 Windows 的 "程序已停止工作" 系统弹窗,避免覆盖我们的提示 + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); +#endif + std::signal(SIGABRT, abort_signal); + std::set_terminate(on_terminate); +#ifndef _WIN32 + // Windows 上 SEH 已覆盖 SIGSEGV/FPE/ILL:CRT 的 signal 映射会抢在 + // UnhandledExceptionFilter 之前触发,反而拿不到异常地址和调用栈,故不注册。 + std::signal(SIGSEGV, abort_signal); + std::signal(SIGFPE, abort_signal); + std::signal(SIGILL, abort_signal); +#endif + std::set_new_handler(on_new_fail); + + char ts[32]; + now_str(ts, sizeof(ts)); + CRASH_MILESTONE("崩溃日志已安装 (%s)", ts); +} + +void crash_milestone(const char* fmt, ...) +{ + char buf[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + + { + std::lock_guard lk(milestone_mtx()); + std::strncpy(g_milestone, buf, sizeof(g_milestone) - 1); + g_milestone[sizeof(g_milestone) - 1] = '\0'; + } + // 同时进控制台与 run.log(便于对照时间线) + char line[600]; + char ts[32]; + now_str(ts, sizeof(ts)); + snprintf(line, sizeof(line), "[%s][里程碑] %s\n", ts, buf); + std::fwrite(line, 1, std::strlen(line), stdout); + fflush(stdout); +} + +void crash_log(const char* fmt, ...) +{ + char buf[CH_MAX_TEXT]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + + char text[CH_MAX_TEXT + 64]; + char ts[32]; + now_str(ts, sizeof(ts)); + snprintf(text, sizeof(text), "\n===== 严重错误 %s =====\n%s\n", ts, buf); +#ifdef _WIN32 + wchar_t dir[MAX_PATH * 2] = { 0 }; + write_crash_text(text, dir, MAX_PATH * 2); +#endif + std::fwrite(text, 1, std::strlen(text), stderr); + fflush(stderr); +} diff --git a/crash_handler.h b/crash_handler.h new file mode 100644 index 0000000..033477c --- /dev/null +++ b/crash_handler.h @@ -0,0 +1,39 @@ +// crash_handler.h - 崩溃现场捕获与死因日志 +// +// 解决问题:程序在客户机上"双击后无声消失",控制台一闪而过、运行日志(run.log) +// 因缓冲未落盘而什么都不剩,无法定位原因。 +// +// 本模块在 main() 最开头安装以下钩子,任何一条被触发都会把死因写入 +// /YYYYMMDD/crash.log (默认 data/YYYYMMDD/crash.log) +// 写失败时自动回退到 exe 同目录 crash.log,再回退到系统临时目录。 +// +// 捕获范围(Windows): +// - SEH 异常:访问违规 0xC0000005、栈溢出 0xC00000FD、除零 0xC0000094、 +// 非法指令 0xC000001D、DLL 未找到/初始化失败等(SetUnhandledExceptionFilter) +// - abort():未处理异常 / 断言失败 / std::thread 析构时仍 joinable(SIGABRT) +// - std::terminate:未捕获的 C++ 异常(std::set_terminate) +// - new 失败:std::bad_alloc(std::set_new_handler) +// 记录内容:异常码+含义、出错地址(模块+偏移)、符号名与源码行号(有 dbghelp 时)、 +// 完整调用栈、崩溃前最后一个"里程碑"、并生成 .dmp 供事后用 VS/WinDbg 分析。 +// +// 使用: +// install_crash_handlers(); // main() 第一行 +// CRASH_MILESTONE("配置加载完成"); // 关键节点打点,崩溃时一并输出 +#ifndef CRASH_HANDLER_H +#define CRASH_HANDLER_H + +// 安装全部崩溃钩子;可重复调用(幂等)。log_dir 为崩溃日志根目录(相对当前工作目录)。 +void install_crash_handlers(const char* log_dir = "data"); + +// 记录运行里程碑:同时打印到控制台(进 run.log)并记住最后一条, +// 崩溃时一并写入 crash.log,用于判断"程序死在哪一步"。printf 风格。 +void crash_milestone(const char* fmt, ...); + +// 主动写一条严重错误(不终止进程),printf 风格。 +void crash_log(const char* fmt, ...); + +// 便捷宏 +#define CRASH_MILESTONE(...) crash_milestone(__VA_ARGS__) +#define CRASH_LOG(...) crash_log(__VA_ARGS__) + +#endif // CRASH_HANDLER_H diff --git a/detect.cpp b/detect.cpp index b80ba74..40a0987 100644 --- a/detect.cpp +++ b/detect.cpp @@ -93,14 +93,14 @@ void detect_analyze_slices(const std::vector& points, std::to_string(y_max) + " mm\n"); // Step 2: Y截面区间 = [Y最小, Y最小+Y_Range_MM] - double y_slice_max = y_min + cfg.detect.y_range_mm; + double y_slice_max = y_min + radar_cfg().detect.y_range_mm; std::vector y_filtered; y_filtered.reserve(pts.size()); for (const auto& pt : pts) if (pt.y_mm >= y_min && pt.y_mm <= y_slice_max) y_filtered.push_back(&pt); log_msg("[截面] Y截面区间: " + std::to_string(y_min) + " ~ " + - std::to_string(y_slice_max) + " mm (长度=" + std::to_string(cfg.detect.y_range_mm) + + std::to_string(y_slice_max) + " mm (长度=" + std::to_string(radar_cfg().detect.y_range_mm) + "mm, 配置Y_Range_MM)\n"); log_msg("[截面] 参与点数: " + std::to_string(y_filtered.size()) + " / " + std::to_string(pts.size()) + "\n"); @@ -133,9 +133,9 @@ void detect_analyze_slices(const std::vector& points, // Step 4: 两个Z区间 double z_first_start = gz_min; - double z_first_end = gz_min + cfg.detect.z_range_mm; + double z_first_end = gz_min + radar_cfg().detect.z_range_mm; double z_last_end = gz_max; - double z_last_start = gz_max - cfg.detect.z_range_mm; + double z_last_start = gz_max - radar_cfg().detect.z_range_mm; if (z_first_end > gz_max) z_first_end = gz_max; if (z_last_start < gz_min) z_last_start = gz_min; @@ -169,7 +169,7 @@ void detect_analyze_slices(const std::vector& points, double sx_range = gx_max - gx_min; if (sx_range < 1.0) sx_range = 1.0; - const int img_w = std::max(1, (int)std::ceil(cfg.detect.z_range_mm)); // 宽 = Z_Range_MM + const int img_w = std::max(1, (int)std::ceil(radar_cfg().detect.z_range_mm)); // 宽 = Z_Range_MM const double img_scale = 1.0; // 1px = 1mm int img_h = std::max(1, (int)std::ceil(sx_range)); if (out_w) *out_w = img_w; @@ -194,16 +194,16 @@ void detect_analyze_slices(const std::vector& points, log_msg("[文件] " + out_pcd + " (" + std::to_string(slice_xs.size()) + " 点)\n"); // X-Z平面映射:矩形渲染(横轴=Z 左近→右远, 纵轴=X 越大越靠上) - // 速度来源: configuration(变速)模式用剖面平均速度近似; 否则匀速 cfg.scan.z_speed; 时间模式默认10mm + // 速度来源: configuration(变速)模式用剖面平均速度近似; 否则匀速 radar_cfg().scan.z_speed; 时间模式默认10mm std::vector slice_img(img_w * img_h, 0); double rect_len_mm = 10.0; // 时间模式(无速度)默认10mm double prof_v = speed_profile_avg_velocity_mm_s(); - if (cfg.scan.z_speed_type == "configuration" && prof_v > 0) - rect_len_mm = prof_v / cfg.radar.hz * cfg.detect.rect_length_ratio; - else if (cfg.scan.z_speed > 0) - rect_len_mm = cfg.scan.z_speed * 10.0 / cfg.radar.hz * cfg.detect.rect_length_ratio; + if (radar_cfg().scan.z_speed_type == "configuration" && prof_v > 0) + rect_len_mm = prof_v / radar_cfg().radar.hz * radar_cfg().detect.rect_length_ratio; + else if (radar_cfg().scan.z_speed > 0) + rect_len_mm = radar_cfg().scan.z_speed * 10.0 / radar_cfg().radar.hz * radar_cfg().detect.rect_length_ratio; int len_px = std::max(1, (int)(rect_len_mm / img_scale)); - int half_w = cfg.detect.rect_width_px / 2; + int half_w = radar_cfg().detect.rect_width_px / 2; if (half_w < 1) half_w = 1; double z_ref = z0; @@ -279,7 +279,7 @@ void detect_analyze_slices(const std::vector& points, } else { - log_msg("[截面-终点] Z总长不足" + std::to_string(cfg.detect.z_range_mm) + "mm,跳过终点截面。\n"); + log_msg("[截面-终点] Z总长不足" + std::to_string(radar_cfg().detect.z_range_mm) + "mm,跳过终点截面。\n"); } // ====== 合并对比图:start左半区, end右半区 ====== @@ -319,7 +319,7 @@ void detect_analyze_slices(const std::vector& points, sum << "输入点数: " << pts.size() << "\n"; sum << "Y范围(全部点): " << y_min << " ~ " << y_max << " mm\n"; sum << "Y截面参与点数: " << y_filtered.size() << "\n"; - sum << "Z截面区间长度: " << cfg.detect.z_range_mm << " mm\n"; + sum << "Z截面区间长度: " << radar_cfg().detect.z_range_mm << " mm\n"; sum << "全局X范围: " << gx_min << " ~ " << gx_max << " mm\n"; sum << "全局Z范围: " << gz_min << " ~ " << gz_max << " " << z_unit << "\n"; if (start_w > 0) diff --git a/filter.cpp b/filter.cpp index 1cdb52d..dac5f92 100644 --- a/filter.cpp +++ b/filter.cpp @@ -29,5 +29,5 @@ bool point_in_polygon(double px, double py, bool xy_filter_allowed(double x_mm, double y_mm) { if (is_origin_point(x_mm, y_mm)) return false; // 原点噪点丢弃 if (!FILTER_ENABLE) return true; // 未启用多边形过滤则全保留 - return point_in_polygon(x_mm, y_mm, cfg.filter.polygon, cfg.filter.poly_n); + return point_in_polygon(x_mm, y_mm, radar_cfg().filter.polygon, radar_cfg().filter.poly_n); } diff --git a/io_utils.cpp b/io_utils.cpp index 0a4b1f4..4d542fa 100644 --- a/io_utils.cpp +++ b/io_utils.cpp @@ -83,7 +83,13 @@ std::string make_task_dir(const std::string& task_id) std::string base = "data"; make_dirs(base); time_t rawtime = std::time(nullptr); - struct tm* t = std::localtime(&rawtime); + struct tm tmv; +#ifdef _WIN32 + localtime_s(&tmv, &rawtime); // std::localtime 返回静态 tm,多线程并发会互相踩踏 +#else + localtime_r(&rawtime, &tmv); +#endif + struct tm* t = &tmv; char date_buf[16], time_buf[16]; std::strftime(date_buf, sizeof(date_buf), "%Y%m%d", t); std::strftime(time_buf, sizeof(time_buf), "%H%M%S", t); diff --git a/json_utils.cpp b/json_utils.cpp index e57f7b7..0b1bc15 100644 --- a/json_utils.cpp +++ b/json_utils.cpp @@ -147,6 +147,59 @@ int json_get_point_array(const std::string& text, const std::string& key, return cnt; } +// ---- 枚举对象一级键名 ---- +// 逐字符扫描,跟踪字符串与嵌套深度,只收集 depth==1 的键名。 +std::vector json_object_keys(const std::string& text, const std::string& group) +{ + std::string obj = group.empty() ? text : json_get_raw(text, group); + std::vector keys; + size_t p = obj.find('{'); + if (p == std::string::npos) return keys; + int depth = 0; + bool in_str = false; + bool esc = false; + for (size_t i = p; i < obj.size(); ++i) + { + char c = obj[i]; + if (in_str) + { + if (esc) { esc = false; continue; } + if (c == '\\') { esc = true; continue; } + if (c == '"') in_str = false; + continue; + } + if (c == '"') + { + // 字符串开始:若当前在对象层(depth>=1)且前一个非空白字符不是 ':' 或 ',', + // 则它是键名;否则是值(值已在 json_get_raw 中处理,这里跳过)。 + size_t j = i + 1; + size_t q = obj.find('"', j); + while (q != std::string::npos && obj[q - 1] == '\\') q = obj.find('"', q + 1); + if (q == std::string::npos) break; + std::string tok = obj.substr(j, q - j); + size_t k = i; + while (k > p && is_ws(obj[k - 1])) --k; + char prev = (k > p) ? obj[k - 1] : '{'; + if (depth == 1 && (prev == '{' || prev == ',')) keys.push_back(tok); + i = q; // 已整体消费该字符串(键或值),继续下一个字符 + continue; + } + if (c == '{' || c == '[') { ++depth; continue; } + if (c == '}' || c == ']') + { + --depth; + if (depth <= 0) break; + continue; + } + } + return keys; +} + +std::vector json_object_keys(const std::string& text) +{ + return json_object_keys(text, ""); +} + // ---- 分组两级查找实现 ---- std::string json_get_raw(const std::string& text, const std::string& group, const std::string& key) { diff --git a/json_utils.h b/json_utils.h index d447406..46f1ff8 100644 --- a/json_utils.h +++ b/json_utils.h @@ -24,6 +24,14 @@ bool json_get_bool(const std::string& text, const std::string& key, bool& out); int json_get_point_array(const std::string& text, const std::string& key, std::vector>& out); +// 枚举一个 JSON 对象的一级键名(按出现顺序,不含嵌套子对象的键)。 +// 用于"根级 map"型配置:{"server":{...},"log":{...},"radar1":{...},"radar2":{...}} +// 传入整份文本时不要求以 '{' 开头,会自动定位第一个 '{'。 +std::vector json_object_keys(const std::string& text); + +// 分组版本:先取 "group": {...} 对象,再枚举该对象的一级键名。group 空串等价于上面的版本。 +std::vector json_object_keys(const std::string& text, const std::string& group); + // ---- 分组两级查找:先取 "group": {...} 对象,再在该对象内按 key 取 value ---- // 用于嵌套分组配置(如 {"radar": {"ip": ...}} 中取 radar 组内的 ip)。 // group 传空串时等价于不带 group 的版本。 diff --git a/lidar_utils.cpp b/lidar_utils.cpp index 2a6dc65..415c616 100644 --- a/lidar_utils.cpp +++ b/lidar_utils.cpp @@ -10,42 +10,45 @@ #include // ==================== 雷达连接 ==================== -bool connect_lidar(pavo2::pavo2_driver& driver) +bool connect_lidar(pavo2::pavo2_driver& driver, const RadarConfig& rc) { driver.set_conn_protocol(pavo2::PAVO2_PROTO_TYPE::TCP); - std::cout << "[连接] 正在连接雷达 " << cfg.radar.ip << ":" << cfg.radar.port << " ..." << std::endl; + std::cout << "[连接][" << rc.id << "] 正在连接雷达 " + << rc.radar.ip << ":" << rc.radar.port << " ..." << std::endl; - if (!driver.pavo2_open(cfg.radar.ip, cfg.radar.port, OPEN_TIMEOUT_MS)) + if (!driver.pavo2_open(rc.radar.ip, rc.radar.port, OPEN_TIMEOUT_MS)) { - std::cerr << "[错误] 连接雷达失败!请检查IP和网络连接。" << std::endl; + driver.pavo2_close(); // 复位状态,避免半开连接影响下次重试 + std::cout << "[连接][" << rc.id << "] 连接失败,稍后重试 (" + << rc.radar.ip << ":" << rc.radar.port << ")" << std::endl; return false; } - std::cout << "[连接] 雷达连接成功!" << std::endl; + std::cout << "[连接][" << rc.id << "] 雷达连接成功!" << std::endl; - driver.enable_tail_filter(cfg.radar.tail_filter); - driver.enable_front_filter(cfg.radar.front_filter); - driver.enable_far_hr_filter(cfg.radar.far_hr_filter); - driver.enable_near_hr(cfg.radar.near_hr_filter); + driver.enable_tail_filter(rc.radar.tail_filter); + driver.enable_front_filter(rc.radar.front_filter); + driver.enable_far_hr_filter(rc.radar.far_hr_filter); + driver.enable_near_hr(rc.radar.near_hr_filter); std::string sn, pn, sdk_ver, fw_ver; if (driver.get_device_sn(sn)) - std::cout << "[信息] 设备SN: " << sn << std::endl; + std::cout << "[信息][" << rc.id << "] 设备SN: " << sn << std::endl; if (driver.get_device_pn(pn)) - std::cout << "[信息] 设备PN: " << pn << std::endl; + std::cout << "[信息][" << rc.id << "] 设备PN: " << pn << std::endl; if (driver.get_sdk_ver(sdk_ver)) - std::cout << "[信息] SDK版本: " << sdk_ver << std::endl; + std::cout << "[信息][" << rc.id << "] SDK版本: " << sdk_ver << std::endl; if (driver.get_firmware_ver(fw_ver)) - std::cout << "[信息] 固件版本: " << fw_ver << std::endl; + std::cout << "[信息][" << rc.id << "] 固件版本: " << fw_ver << std::endl; if (!driver.enable_data(true)) { - std::cerr << "[错误] 开启数据传输失败!" << std::endl; + std::cerr << "[错误][" << rc.id << "] 开启数据传输失败!" << std::endl; return false; } - std::cout << "[信息] 雷达数据传输已开启。" << std::endl; + std::cout << "[信息][" << rc.id << "] 雷达数据传输已开启。" << std::endl; return true; } @@ -142,7 +145,7 @@ bool load_speed_profile(const std::string& path) else { std::cout << "[speed] 未找到 " << path << ",使用匀速 z_speed=" - << cfg.scan.z_speed << "cm/s" << std::endl; + << radar_cfg().scan.z_speed << "cm/s" << std::endl; } std::lock_guard lk(g_prof_mtx); @@ -157,7 +160,7 @@ bool load_speed_profile(const std::string& path) void reload_speed_profile() { - if (cfg.scan.z_speed_type == "configuration") + if (radar_cfg().scan.z_speed_type == "configuration") { if (load_speed_profile()) { @@ -171,7 +174,7 @@ void reload_speed_profile() } else { - std::cout << "[speed] 剖面无效,回退匀速 z_speed=" << cfg.scan.z_speed + std::cout << "[speed] 剖面无效,回退匀速 z_speed=" << radar_cfg().scan.z_speed << "cm/s(0=时间)" << std::endl; } } @@ -201,24 +204,24 @@ double speed_profile_avg_velocity_mm_s() bool z_is_distance_mode() { - if (cfg.scan.z_speed_type == "configuration") - return speed_profile_loaded() || cfg.scan.z_speed > 0.0; - return cfg.scan.z_speed > 0.0; + if (radar_cfg().scan.z_speed_type == "configuration") + return speed_profile_loaded() || radar_cfg().scan.z_speed > 0.0; + return radar_cfg().scan.z_speed > 0.0; } double z_for_elapsed(double elapsed_ms) { - if (cfg.scan.z_speed_type == "configuration") + if (radar_cfg().scan.z_speed_type == "configuration") { double z = 0.0; { std::lock_guard lk(g_prof_mtx); if (profile_lookup_locked(elapsed_ms, &z)) return z; } - // 表无效/不足两行 → 回退匀速 cfg.scan.z_speed(无 speed.txt 时的兜底) + // 表无效/不足两行 → 回退匀速 radar_cfg().scan.z_speed(无 speed.txt 时的兜底) } - return (cfg.scan.z_speed > 0) - ? (cfg.scan.z_speed * 10.0 * elapsed_ms / 1000.0) // cm/s ×10 = mm/s, ×秒 = mm + return (radar_cfg().scan.z_speed > 0) + ? (radar_cfg().scan.z_speed * 10.0 * elapsed_ms / 1000.0) // cm/s ×10 = mm/s, ×秒 = mm : (double)elapsed_ms; } @@ -281,7 +284,7 @@ int collect_speed_profile(pavo2::pavo2_driver& driver, double seconds, return -1; } f << "# speed.txt - 变速运动z轴速度剖面(每行: 时间ms 距离mm; 可由ConfigureSpeed生成或手工编辑)\n"; - f << "# 首行后请勿保留其他说明; 时间须递增。cfg.scan.z_speed_type=configuration 时生效,\n"; + f << "# 首行后请勿保留其他说明; 时间须递增。radar_cfg().scan.z_speed_type=configuration 时生效,\n"; f << "# 识别时间超出表末按最后两行平均速度延伸。\n"; f << std::fixed << std::setprecision(3); for (const auto& r : rows) @@ -311,13 +314,28 @@ size_t scan_convert_points(const pavo2::pavo_packet_info_scan_t& scan_info, } // ==================== 扫描会话 ==================== -bool ScanSession::start(pavo2::pavo2_driver& driver) +// 析构必须 join:std::thread 在仍 joinable 时析构会调用 std::terminate → abort, +// 进程瞬间消失、无异常、无日志。这是"某台机器上运行一会儿就没了"的典型根因。 +ScanSession::~ScanSession() +{ + { + std::lock_guard lk(mtx_); + stop_req_ = true; + } + if (th_.joinable()) th_.join(); +} + +bool ScanSession::start(pavo2::pavo2_driver& driver, const std::string& radar_id, + std::mutex* io_mtx) { { std::lock_guard lk(mtx_); if (running_) return false; - running_ = true; + if (th_.joinable()) return false; // 上一轮线程尚未回收,禁止叠加启动 + running_ = true; stop_req_ = false; + radar_id_ = radar_id; + io_mtx_ = io_mtx; } try { @@ -327,22 +345,27 @@ bool ScanSession::start(pavo2::pavo2_driver& driver) catch (...) { std::lock_guard lk(mtx_); - running_ = false; + running_ = false; stop_req_ = false; + io_mtx_ = nullptr; + std::cerr << "[错误] 采集线程创建失败(资源不足?)" << std::endl; return false; } } +// 无条件置停止位 + 无条件 join。 +// 原实现 "if (!running_) return;" 会在 running_ 为 false 但线程仍活着时漏掉 join, +// 残留的 joinable 线程随后在析构时触发 std::terminate。 size_t ScanSession::stop() { { std::lock_guard lk(mtx_); - if (!running_) return points_.size(); stop_req_ = true; } if (th_.joinable()) th_.join(); std::lock_guard lk(mtx_); - running_ = false; + running_ = false; + io_mtx_ = nullptr; return points_.size(); } @@ -358,23 +381,40 @@ bool ScanSession::running() const return running_; } +// 复位前必须先让采集线程退出:原实现持锁直接 clear points_, +// 若线程尚在运行会与 points_ 并发读写(vector 重分配时必崩), +// 同时 running_ 被置 false 后 stop() 不再 join,线程变成野线程。 void ScanSession::reset() { + stop(); // 内部持锁 + join,确保线程已结束 std::lock_guard lk(mtx_); points_.clear(); - running_ = false; + running_ = false; stop_req_ = false; + radar_id_.clear(); } void ScanSession::collect_loop(pavo2::pavo2_driver* driver) { + std::string rid; + std::mutex* iom = nullptr; + { + std::lock_guard lk(mtx_); + rid = radar_id_; + iom = io_mtx_; + } + RadarScope scope(rid); // 线程内绑定该雷达的配置(多边形/z轴换算等) + // 排空积压:丢弃 SDK 缓存中 start 之前雷达已转出的旧数据包。 // 否则旧包会在图像开头 z 方向挤成一段密集点;排空完成才开始计时。 - int flushed = drain_stale_scans(*driver); + // 与重连线程互斥(重连时 driver/socket 处于中间态,并发访问会崩)。 + int flushed = 0; + if (iom) { std::lock_guard lk(*iom); flushed = drain_stale_scans(*driver); } + else { flushed = drain_stale_scans(*driver); } { std::lock_guard lk(mtx_); - if (stop_req_) return; // 排空期间被 stop,直接退出 + if (stop_req_) { running_ = false; return; } // 排空期间被 stop,直接退出 start_tp_ = std::chrono::steady_clock::now(); // 排空完成才开始计时 } if (flushed > 0) @@ -383,21 +423,197 @@ void ScanSession::collect_loop(pavo2::pavo2_driver* driver) while (true) { pavo2::pavo_packet_info_scan_t scan_info; - if (driver->get_scanned_data(scan_info, 100)) // 100ms超时轮询,可及时响应stop + bool got = false; + if (iom) + { + // try_lock 而非 lock:重连线程可能正长时间占用(连接超时), + // 采集线程不能因此卡死,等下一轮即可 + if (!iom->try_lock()) + { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + std::lock_guard lk(mtx_); + if (stop_req_) break; + continue; + } + got = driver->get_scanned_data(scan_info, 100); // 100ms超时轮询,可及时响应stop + iom->unlock(); + } + else + { + got = driver->get_scanned_data(scan_info, 100); + } + + std::lock_guard lk(mtx_); + if (stop_req_) break; + if (got) { auto now = std::chrono::steady_clock::now(); double elapsed_ms = (double)std::chrono::duration_cast( now - start_tp_).count(); - double z_val = z_for_elapsed(elapsed_ms); + scan_convert_points(scan_info, z_for_elapsed(elapsed_ms), points_); + } + } + // 线程即将结束:同步状态位,避免外部误判"仍在采集"而跳过重连 + std::lock_guard lk(mtx_); + running_ = false; +} + +// ==================== 多雷达管理实现 ==================== +RadarManager radar_mgr; + +RadarManager::~RadarManager() +{ + if (!stop_.load()) shutdown(); // 兜底:确保连接线程被 join +} + +void RadarManager::init() +{ + sync_config(); +} - std::lock_guard lk(mtx_); - if (stop_req_) break; - scan_convert_points(scan_info, z_val, points_); +// 配置重载后同步节点:新增配置中出现的雷达;配置里删掉的仅置为失效(节点/线程保留,避免悬空) +void RadarManager::sync_config() +{ + std::lock_guard lk(mtx_); + for (const auto& id : cfg.ids) + { + RadarNode* exist = nullptr; + for (auto* n : nodes_) if (n->id == id) { exist = n; break; } + if (exist) { exist->enabled = true; continue; } + + RadarNode* n = new RadarNode(); + n->id = id; + n->driver = new pavo2::pavo2_driver(); + nodes_.push_back(n); + std::cout << "[雷达] 新增雷达节点: " << id << " (后台异步连接中)" << std::endl; + try + { + ths_.push_back(std::thread(&RadarManager::connect_loop, this, n)); } - else + catch (...) + { + std::cerr << "[错误] 雷达 " << id << " 连接线程创建失败!" << std::endl; + } + } + for (auto* n : nodes_) + { + bool keep = std::find(cfg.ids.begin(), cfg.ids.end(), n->id) != cfg.ids.end(); + if (n->enabled && !keep) + { + n->enabled = false; + n->connected = false; + n->session.stop(); + std::cout << "[雷达] 配置中已移除,节点停用: " << n->id << std::endl; + } + } +} + +RadarNode* RadarManager::find(const std::string& id) const +{ + std::lock_guard lk(mtx_); + if (!id.empty()) + { + for (auto* n : nodes_) + if (n->id == id && n->enabled) return n; + return nullptr; // 指定标识但不存在:由调用方报错 + } + for (auto* n : nodes_) + if (n->enabled) return n; // 默认雷达 = 第一个有效节点 + return nullptr; +} + +std::vector RadarManager::nodes() const +{ + std::lock_guard lk(mtx_); + return nodes_; +} + +std::string RadarManager::default_id() const +{ + RadarNode* n = find(""); + return n ? n->id : std::string(); +} + +bool RadarManager::wait_connected(const std::string& id, int timeout_ms) +{ + RadarNode* n = find(id); + if (!n) return false; + auto t0 = std::chrono::steady_clock::now(); + while (!n->connected.load()) + { + if (std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count() > timeout_ms) + return false; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + return true; +} + +// 后台连接线程:未连接就一直重试;已连接则在空闲时探测掉线,掉线后重新进入重试 +void RadarManager::connect_loop(RadarNode* n) +{ + RadarScope scope(n->id); // 线程内绑定该雷达配置(打印/参数用) + int retry = 0; + while (!stop_.load()) + { + if (n->connected.load()) + { + // 扫描中不动 driver,避免与采集线程并发使用同一连接; + // pavo2_is_open() 也必须在 io_mtx 内调用(采集取帧同样持有该锁), + // 否则会出现"重连关闭 socket 的同时采集正在读"的并发访问 → 崩溃。 + if (!n->session.running() && n->io_mtx.try_lock()) + { + bool open = n->driver->pavo2_is_open(); + n->io_mtx.unlock(); + if (!open) + { + n->connected = false; + std::cout << "[连接][" << n->id << "] 连接已断开,准备重连" << std::endl; + } + } + } + else if (n->enabled.load()) + { + std::shared_ptr rc = cfg.find(n->id); + if (rc) + { + RadarConfig copy = *rc; // 拷贝,避免 load_config 重载时引用失效 + std::lock_guard lk(n->io_mtx); + if (connect_lidar(*n->driver, copy)) + { + n->connected = true; + retry = 0; + } + else if (++retry == 1 || retry % 10 == 0) + { + std::cout << "[连接][" << n->id << "] 未连接(第" << retry + << "次重试),将持续尝试..." << std::endl; + } + } + } + // 可中断等待(100ms 粒度) + for (int i = 0; i < RECONNECT_INTERVAL_MS / 100 && !stop_.load(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } +} + +void RadarManager::shutdown() +{ + stop_ = true; + for (auto& t : ths_) if (t.joinable()) t.join(); + ths_.clear(); + + std::lock_guard lk(mtx_); + for (auto* n : nodes_) + { + n->session.stop(); + if (n->driver) { - std::lock_guard lk(mtx_); - if (stop_req_) break; + n->driver->pavo2_close(); + delete n->driver; + n->driver = nullptr; } + delete n; } + nodes_.clear(); } diff --git a/lidar_utils.h b/lidar_utils.h index 261cd92..79eb3d0 100644 --- a/lidar_utils.h +++ b/lidar_utils.h @@ -2,6 +2,7 @@ #ifndef LIDAR_UTILS_H #define LIDAR_UTILS_H +#include #include #include #include @@ -9,11 +10,12 @@ #include #include "PAVO2_SDK/include/pavo2_driver.h" #include "common.h" +#include "config.h" // ==================== 雷达连接 ==================== -// 连接雷达(TCP),配置滤波并开启数据传输;失败返回 false。 -// 注意:Windows 下需在 WSAStartup 之后调用。 -bool connect_lidar(pavo2::pavo2_driver& driver); +// 连接单台雷达(TCP):按 rc.radar 配置 IP/端口与雷达内置滤波,并开启数据传输;失败返回 false。 +// 注意:Windows 下需在 WSAStartup 之后调用;调用方应保证同一 driver 不被并发使用。 +bool connect_lidar(pavo2::pavo2_driver& driver, const RadarConfig& rc); // ==================== 扫描辅助 ==================== // 排空 SDK 队列中积压的旧扫描包(采集 start 之前雷达已缓存的数据)。 @@ -71,12 +73,22 @@ size_t scan_convert_points(const pavo2::pavo_packet_info_scan_t& scan_info, class ScanSession { public: - // 启动后台采集线程。内部先排空 SDK 积压旧包,排空完成才开始 z 计时。 + // 析构时必须 join 采集线程:std::thread 对象在仍 joinable 时析构会调用 + // std::terminate → abort,进程"无声消失"且没有任何日志(历史崩溃主因之一)。 + ~ScanSession(); + + // 启动后台采集线程(radar_id 用于线程内绑定该雷达的配置)。 + // 内部先排空 SDK 积压旧包,排空完成才开始 z 计时。 // 已在运行或线程创建失败时返回 false。 - bool start(pavo2::pavo2_driver& driver); + // io_mtx: 该雷达的 IO 互斥锁(RadarNode::io_mtx)。传入后采集线程取帧时与 + // 后台重连线程互斥,避免"采集进行中掉线→重连"时两个线程并发操作 + // 同一个 socket/driver(网络不好的机器上必崩)。 + bool start(pavo2::pavo2_driver& driver, const std::string& radar_id, + std::mutex* io_mtx = nullptr); // 请求停止并等待采集线程退出,返回当前已采集点数。 - // 会话未运行(未 start 或已 stop)时仅返回现存点数,不做其他事。 + // 无论 running 与否都会置停止位并 join:只靠 running_ 判断会漏掉"线程已启动但 + // 状态位未置"的情况,残留的 joinable 线程会在析构时 terminate。 size_t stop(); // 拷贝当前点集快照(线程安全)。 @@ -97,7 +109,61 @@ private: bool stop_req_ = false; std::thread th_; std::vector points_; + std::string radar_id_; // 本会话所属雷达(线程内绑定配置用) std::chrono::steady_clock::time_point start_tp_; + std::mutex* io_mtx_ = nullptr; // 与重连线程互斥的 driver 访问锁(可为 nullptr) +}; + +// ==================== 多雷达管理 ==================== +// 配置里每个雷达标识对应一个节点:独立 driver、独立扫描会话、独立后台连接线程。 +// 连接策略:未连接/已断开时后台线程按 RECONNECT_INTERVAL_MS 一直重试,不阻塞主程序。 +struct RadarNode +{ + std::string id; // 雷达标识 + pavo2::pavo2_driver* driver = nullptr; // 该雷达独立连接 + std::atomic connected{false}; // 是否已连接(后台线程维护) + std::atomic enabled{true}; // 配置中是否仍存在(重载后可能失效) + ScanSession session; // 该雷达独立扫描会话(可与其他雷达并行) + std::mutex io_mtx; // open/close/enable_data 串行化 +}; + +class RadarManager +{ +public: + // 兜底清理:若 main 未调用 shutdown(),全局对象析构时 ths_ 中的线程仍 joinable, + // std::thread 析构会 std::terminate → abort(进程无声消失)。 + ~RadarManager(); + + // 按 cfg 建立节点并启动后台连接线程(不阻塞:连不上也继续跑,后台一直重试)。 + void init(); + + // 停止后台线程、结束采集、关闭并释放所有连接。 + void shutdown(); + + // 配置重载后同步:新增配置里新出现的雷达;配置中删掉的雷达置为失效(不再可用)。 + void sync_config(); + + // 按标识取节点;id 为空或不存在时返回默认(第一个)有效节点;无节点返回 nullptr。 + RadarNode* find(const std::string& id) const; + + // 所有节点(含失效的,按配置顺序)。 + std::vector nodes() const; + + // 默认雷达标识(配置第一个;无则空串)。 + std::string default_id() const; + + // 等待指定雷达连上(exe 模式用);timeout_ms 内连上返回 true。 + bool wait_connected(const std::string& id, int timeout_ms); + +private: + void connect_loop(RadarNode* n); // 后台:断线自动重连 + mutable std::mutex mtx_; + std::vector nodes_; + std::vector ths_; + std::atomic stop_{false}; }; +// 全局多雷达管理器(lidar_utils.cpp 中定义) +extern RadarManager radar_mgr; + #endif // LIDAR_UTILS_H diff --git a/main.cpp b/main.cpp index ec74eff..7e0b9ac 100644 --- a/main.cpp +++ b/main.cpp @@ -3,6 +3,7 @@ #include #include #include +#include // _getch(退出前暂停,避免双击运行时窗口一闪而过) #include #include #include @@ -20,6 +21,49 @@ #include "lidar_utils.h" #include "detect.h" #include "tcp_server.h" +#include "crash_handler.h" // 崩溃现场捕获(必须最先安装) + +// ==================== 启动环境兜底 ==================== +// 通过快捷方式 / 计划任务 / 其它目录的 cmd 启动时,当前工作目录往往不是 exe 所在目录, +// 会导致 ./config/config 读不到、data/ 日志写到莫名其妙的地方,程序随即"无声退出"。 +// 这里统一把工作目录切到 exe 所在目录。 +static void ensure_exe_working_dir() +{ +#ifdef _WIN32 + if (GetFileAttributesA("./config/config") != INVALID_FILE_ATTRIBUTES) return; // 当前目录正常 + char exe[MAX_PATH]; + DWORD n = GetModuleFileNameA(NULL, exe, MAX_PATH); + if (n == 0 || n >= MAX_PATH) return; + for (DWORD i = n - 1; i > 0; --i) + if (exe[i] == '\\' || exe[i] == '/') { exe[i] = '\0'; break; } + if (SetCurrentDirectoryA(exe)) + std::cout << "[启动] 工作目录已切换到 exe 目录: " << exe << std::endl; +#endif +} + +// 是否由"双击 exe"启动(Windows 会为它新建一个独占控制台;在已有 cmd 里跑则不是) +static bool started_by_double_click() +{ +#ifdef _WIN32 + DWORD pids[2] = { 0, 0 }; + return GetConsoleProcessList(pids, 2) <= 1; +#else + return false; +#endif +} + +// 双击启动时退出前暂停,让使用者能看到最后几行输出/错误,而不是窗口瞬间消失 +static void pause_before_exit(int rc) +{ + if (!started_by_double_click()) return; // 在 cmd/脚本里跑则不打扰 + std::cout << "\n[退出] 程序结束,返回码 " << rc + << "。按任意键关闭窗口..." << std::endl; + std::cout.flush(); +#ifdef _WIN32 + FlushConsoleInputBuffer(GetStdHandle(STD_INPUT_HANDLE)); + _getch(); +#endif +} // ==================== 日志目录清理:启动时 + 每天 log.clean_time ==================== static std::atomic g_cleanup_stop{ false }; @@ -75,21 +119,22 @@ static void stop_daily_cleanup() } // ==================== exe 模式:自动做一次截面检测 ==================== -// 启动统一后台扫描,采 cfg.scan.collect_duration_s 秒后自动停止,做截面检测并保存到 +// 启动统一后台扫描,采 radar_cfg().scan.collect_duration_s 秒后自动停止,做截面检测并保存到 // data/日期/时间_ 目录(无 task_id)。 -static int run_exe_check(pavo2::pavo2_driver& driver) +static int run_exe_check(const std::string& radar_id, RadarNode* node) { - std::cout << "\n[exe] 自动检测开始: 采集 " << cfg.scan.collect_duration_s - << " 秒 ..." << std::endl; + RadarScope scope(radar_id); // 全程使用该雷达配置 + std::cout << "\n[exe] 自动检测开始: 雷达=" << radar_id << ", 采集 " + << radar_cfg().scan.collect_duration_s << " 秒 ..." << std::endl; ScanSession scan; scan.reset(); - if (!scan.start(driver)) + if (!scan.start(*node->driver, radar_id, &node->io_mtx)) { std::cerr << "[错误] 启动扫描会话失败!" << std::endl; return -1; } - std::this_thread::sleep_for(std::chrono::seconds(cfg.scan.collect_duration_s)); + std::this_thread::sleep_for(std::chrono::seconds(radar_cfg().scan.collect_duration_s)); scan.stop(); std::vector pts = scan.snapshot(); scan.reset(); @@ -121,11 +166,24 @@ int main() // 避免中文在 VSCode 调试控制台/终端中乱码(真实控制台也同步切到 65001) SetConsoleOutputCP(CP_UTF8); SetConsoleCP(CP_UTF8); +#endif + + // 工作目录兜底:快捷方式/计划任务启动时当前目录可能不是 exe 目录 + ensure_exe_working_dir(); + // 崩溃捕获必须最先安装:此后任何硬崩溃(访问违规/abort/未捕获异常/new失败) + // 都会写入 data/YYYYMMDD/crash.log 并生成 dmp,而不是"无声消失" + install_crash_handlers("data"); + CRASH_MILESTONE("进程启动"); + +#ifdef _WIN32 WSADATA wsa; - if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) + int wsaret = WSAStartup(MAKEWORD(2, 2), &wsa); + if (wsaret != 0) { - std::cerr << "[错误] WSAStartup 失败!" << std::endl; + std::cerr << "[错误] WSAStartup 失败! 错误码=" << wsaret << std::endl; + CRASH_LOG("WSAStartup 失败, 错误码=%d", wsaret); + pause_before_exit(-1); return -1; } #endif @@ -134,57 +192,89 @@ int main() start_run_log(); struct RunLogGuard { ~RunLogGuard() { stop_run_log(); } } run_log_guard; (void)run_log_guard; - load_config(); // 默认读取 ./config/config (JSON) - reload_speed_profile(); // cfg.scan.z_speed_type=configuration 时加载 config/speed.txt + // 统一退出出口:收后台线程 → 关雷达 → 清 WSA → 记录退出原因 → 双击时暂停 + auto finish = [&](int code, const char* why) -> int { + CRASH_MILESTONE("退出: %s (rc=%d)", why, code); + stop_daily_cleanup(); // 先停清理线程,避免它访问正在析构的对象 + std::cout << "\n[关闭] 正在断开全部雷达连接..." << std::endl; + radar_mgr.shutdown(); // 停采集/重连线程并释放 driver +#ifdef _WIN32 + WSACleanup(); +#endif + std::cout << "[完成] 程序运行结束。" << std::endl; + pause_before_exit(code); + return code; + }; + + if (!load_config()) // 默认读取 ./config/config (JSON) + { + std::cerr << "[错误] 配置文件加载失败,程序退出。" << std::endl; + CRASH_LOG("配置文件加载失败: ./config/config 不存在或格式错误"); + return finish(-1, "配置文件加载失败"); + } + CRASH_MILESTONE("配置加载完成, 共 %d 台雷达", (int)cfg.ids.size()); + reload_speed_profile(); // radar_cfg().scan.z_speed_type=configuration 时加载 config/speed.txt start_daily_cleanup(); std::cout << "==================== 配置文件内容 ====================" << std::endl; - std::cout << "雷达IP: " << cfg.radar.ip << " : " << cfg.radar.port << std::endl; + for (const auto& id : cfg.ids) + { + const RadarConfig& rc = *cfg.radars[id]; + std::cout << "[" << id << "] 雷达IP: " << rc.radar.ip << " : " << rc.radar.port + << " | 帧率: " << rc.radar.hz << "Hz" + << " | 采集: " << rc.scan.collect_duration_s << "s" + << " | Z: " << rc.scan.z_speed_type << " " << rc.scan.z_speed << "cm/s" + << " | 地板高: " << rc.count.floor_height << "mm" << std::endl; + } std::cout << "运行模式: " << cfg.server.mode << " (tcp=TCP服务端, http=HTTP服务端, 其他=自动检测)" << std::endl; std::cout << "TCP/HTTP端口: " << cfg.server.port << std::endl; - std::cout << "采集时长: " << cfg.scan.collect_duration_s << " 秒" << std::endl; - std::cout << "Z轴模式: " << cfg.scan.z_speed_type + std::cout << "采集时长: " << radar_cfg().scan.collect_duration_s << " 秒" << std::endl; + std::cout << "Z轴模式: " << radar_cfg().scan.z_speed_type << " (uniformSpeed=匀速, configuration=config/speed.txt变速)" << std::endl; - std::cout << "移动速度: " << cfg.scan.z_speed << " cm/s (0=时间模式, >0=距离模式)" + std::cout << "移动速度: " << radar_cfg().scan.z_speed << " cm/s (0=时间模式, >0=距离模式)" << std::endl; - std::cout << "Z范围: " << cfg.detect.z_range_mm << " mm | Y范围: " << cfg.detect.y_range_mm - << " mm | 雷达频率: " << cfg.radar.hz << " Hz" << std::endl; - std::cout << "z空隙插值模式: " << cfg.count.z_interpolate_mode << std::endl; + std::cout << "Z范围/Y范围/频率等参数见各雷达配置(多雷达可不同)" << std::endl; + std::cout << "z空隙插值模式(默认雷达): " << radar_cfg().count.z_interpolate_mode << std::endl; std::cout << "日志保留天数: " << cfg.log.retain_days << std::endl; std::cout << "======================================================" << std::endl; std::cout << "======================================================" << std::endl; std::cout << " Pavo2 Lidar 雷达数据采集程序 (模块化)" << std::endl; std::cout << "======================================================" << std::endl; - pavo2::pavo2_driver driver; - if (!connect_lidar(driver)) + // 建立全部雷达节点并启动后台连接线程:连不上也不阻塞主程序(后台一直重试) + radar_mgr.init(); + if (cfg.ids.empty()) { - stop_daily_cleanup(); // 提前退出也要先停掉后台清理线程 -#ifdef _WIN32 - WSACleanup(); -#endif - return -1; + std::cerr << "[错误] 配置中没有雷达,无法启动。" << std::endl; + CRASH_LOG("配置中没有雷达(cfg.ids 为空)"); + return finish(-1, "配置中没有雷达"); } - std::cout << "\n[等待] 等待雷达数据稳定(2秒)..." << std::endl; + std::cout << "\n[等待] 等待雷达数据稳定(2秒,未连上的由后台继续重连)..." << std::endl; std::this_thread::sleep_for(std::chrono::seconds(2)); + CRASH_MILESTONE("进入运行模式: %s", cfg.server.mode.c_str()); int rc = 0; + const char* why = "正常结束"; if (cfg.server.mode == "tcp") - run_tcp_server(driver); + run_tcp_server(); else if (cfg.server.mode == "http") - run_http_server(driver); + run_http_server(); else - rc = run_exe_check(driver); - - stop_daily_cleanup(); // 通知后台清理线程退出并等待结束,防止崩退 + { + // exe 模式:用默认雷达(配置第一个),最多等 10 秒连上 + std::string id = radar_mgr.default_id(); + RadarNode* node = radar_mgr.find(id); + if (!node || !radar_mgr.wait_connected(id, 10000)) + { + std::cerr << "[错误] 默认雷达 " << id << " 未连接,无法自动检测。" << std::endl; + rc = -1; + why = "默认雷达未连接"; + } + else + rc = run_exe_check(id, node); + } - std::cout << "\n[关闭] 正在断开雷达连接..." << std::endl; - driver.pavo2_close(); -#ifdef _WIN32 - WSACleanup(); -#endif - std::cout << "[完成] 程序运行结束。" << std::endl; - return rc; + return finish(rc, why); } diff --git a/main.exe b/main.exe index 1a5b593..c5eb014 100644 Binary files a/main.exe and b/main.exe differ diff --git a/tcp_server.cpp b/tcp_server.cpp index 6dd1415..2075562 100644 --- a/tcp_server.cpp +++ b/tcp_server.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -31,17 +32,20 @@ #include #include #include +#include #include +#include #include #include namespace { - // ==================== 会话状态(全局单雷达) ==================== + // ==================== 会话状态(每雷达一份,各雷达可并行扫描) ==================== struct SessionState { bool active = false; // 是否有扫描在运行 bool auto_stop = false; // 是否到时自动停止 + std::string radar; // 所属雷达标识 std::string function; // "count" / "check" CountParams box; // count 的 box 参数 std::string task_id; @@ -49,14 +53,100 @@ namespace std::chrono::steady_clock::time_point deadline; // 自动停止时刻 }; - static ScanSession g_scan; // 统一采集会话 - static pavo2::pavo2_driver* g_driver = nullptr; - static SessionState g_state; + static std::map g_states; // 雷达标识 -> 会话状态 static std::mutex g_life; // 会话生命周期锁 static std::mutex g_cv_mtx; // 自动停止等待锁 static std::condition_variable g_cv; - static bool g_armed = false; // 是否有到点任务 - static std::chrono::steady_clock::time_point g_deadline; + static std::atomic g_stop{false}; // 服务端退出标志(自动停止线程用) + + // ==================== 连接管理 ==================== + // 不设 recv 超时时,任何"连上但不发数据"的连接(端口探测/异常客户端/旧TCP客户端 + // 连到HTTP端口)都会让处理线程永久阻塞在 recv 上;线程越堆越多,最终 + // std::thread 构造失败抛 std::system_error,未捕获的异常会直接终止整个进程 + // —— 表现为"先卡死、然后进程突然消失且无任何日志"。 + static const int MAX_CONNECTIONS = 64; // 并发连接上限 + static const int TCP_RECV_TIMEOUT_MS = 300000; // TCP 长连接空闲上限 5 分钟 + static const int HTTP_RECV_TIMEOUT_MS = 5000; // HTTP 头部读取超时(超时按TCP协议兜底) + static const int SEND_TIMEOUT_MS = 5000; // 发送超时 + static std::atomic g_conn_count{0}; // 当前活跃连接数 + + // ==================== 工作线程生命周期 ==================== + // auto_stop_loop / 客户端处理线程都是 detach 的。服务端主循环一旦返回,main 会 + // 立刻 radar_mgr.shutdown() 释放 RadarNode 与 driver,而这些线程仍在访问它们 + // —— 典型表现就是"进程突然消失、没有任何日志"。这里统计活跃工作线程, + // 退出前先置停止位、唤醒阻塞在 recv 的连接、再等待线程真正结束。 + static std::atomic g_thread_count{0}; // 活跃工作线程数 + static std::mutex g_sock_mtx; + static std::set g_active_socks; // 活跃客户端 socket + + struct WorkerGuard + { + WorkerGuard() { ++g_thread_count; } + ~WorkerGuard() { --g_thread_count; } + }; + + // 服务端收尾:通知退出 + 唤醒阻塞连接 + 等待线程结束(最多 5 秒,避免卡住退出流程) + static void stop_server_workers() + { + g_stop = true; + { + std::lock_guard lk(g_sock_mtx); + for (SOCKET s : g_active_socks) + { + if (s == INVALID_SOCKET) continue; +#ifdef _WIN32 + shutdown(s, SD_BOTH); // 让阻塞的 recv 立即返回 +#else + shutdown(s, SHUT_RDWR); +#endif + } + } + for (int i = 0; i < 100 && g_thread_count.load() > 0; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + if (g_thread_count.load() > 0) + std::cout << "[警告] 仍有 " << g_thread_count.load() + << " 个工作线程未退出,退出过程可能不稳定" << std::endl; + else + std::cout << "[关闭] 服务端工作线程已全部退出" << std::endl; + } + + static void set_sock_timeouts(SOCKET s, int recv_ms, int send_ms) + { + if (s == INVALID_SOCKET) return; +#ifdef _WIN32 + DWORD r = (recv_ms > 0) ? (DWORD)recv_ms : 0; + DWORD w = (send_ms > 0) ? (DWORD)send_ms : 0; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&r, sizeof(r)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&w, sizeof(w)); +#else + struct timeval rv; rv.tv_sec = recv_ms / 1000; rv.tv_usec = (recv_ms % 1000) * 1000; + struct timeval wv; wv.tv_sec = send_ms / 1000; wv.tv_usec = (send_ms % 1000) * 1000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&rv, sizeof(rv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&wv, sizeof(wv)); +#endif + } + + // 连接句柄守卫:任何退出路径(含异常)都保证关闭 socket 并归还连接配额 + class ConnGuard + { + public: + explicit ConnGuard(SOCKET s) : s_(s) + { + std::lock_guard lk(g_sock_mtx); + g_active_socks.insert(s_); // 登记,供退出时统一唤醒 + } + ~ConnGuard() + { + { + std::lock_guard lk(g_sock_mtx); + g_active_socks.erase(s_); + } + if (s_ != INVALID_SOCKET) closesocket(s_); + --g_conn_count; + } + private: + SOCKET s_; + }; // ==================== 发送 ==================== // 响应统一以 '>' 结尾 @@ -75,42 +165,45 @@ namespace } // ==================== 停止并取回结果 ==================== - // need_auto=true 表示"自动到时"触发,只允许在 auto_stop 仍武装时抢到; + // id: 雷达标识;need_auto=true 表示"自动到时"触发,只允许在 auto_stop 仍武装时抢到; // 否则(手动 stop)只要 active 即可。 - static bool claim_stop_and_fetch(bool need_auto, SessionState* snap, + static bool claim_stop_and_fetch(const std::string& id, bool need_auto, + SessionState* snap, std::vector* pts) { + RadarNode* node = radar_mgr.find(id); std::lock_guard lk(g_life); - if (!g_state.active) return false; - if (need_auto && !g_state.auto_stop) return false; // 已被手动 stop 抢先 - *snap = g_state; - g_state.active = false; - g_state.auto_stop = false; + auto it = g_states.find(id); + if (it == g_states.end() || !it->second.active) return false; + if (need_auto && !it->second.auto_stop) return false; // 已被手动 stop 抢先 + *snap = it->second; + it->second.active = false; + it->second.auto_stop = false; + g_cv.notify_all(); + if (node) { - std::lock_guard ck(g_cv_mtx); - g_armed = false; + node->session.stop(); // 等待采集线程退出(≤100ms轮询粒度) + *pts = node->session.snapshot(); + node->session.reset(); // 清空点集,为下一次 start 准备 } - g_cv.notify_all(); - g_scan.stop(); // 等待采集线程退出(≤100ms轮询粒度) - *pts = g_scan.snapshot(); - g_scan.reset(); // 清空点集,为下一次 start 准备 return true; } - // 仅停止当前扫描,不返回结果(用于 start 时的强制重启)。 + // 仅停止指定雷达的当前扫描,不返回结果(用于 start 时的强制重启)。 // 调用方需已持有 g_life。 - static bool discard_current_locked() + static bool discard_current_locked(const std::string& id) { - if (!g_state.active) return false; - g_state.active = false; - g_state.auto_stop = false; + auto it = g_states.find(id); + if (it == g_states.end() || !it->second.active) return false; + it->second.active = false; + it->second.auto_stop = false; + g_cv.notify_all(); + RadarNode* node = radar_mgr.find(id); + if (node) { - std::lock_guard ck(g_cv_mtx); - g_armed = false; + node->session.stop(); + node->session.reset(); } - g_cv.notify_all(); - g_scan.stop(); - g_scan.reset(); return true; } @@ -119,6 +212,7 @@ namespace const std::vector& pts, SOCKET target, std::string* resp = nullptr) { + RadarScope scope(snap.radar); // 绑定该雷达配置(分层/多边形/滤波) bool valid_box = snap.box.l > 0 && snap.box.w > 0 && snap.box.h > 0 && snap.box.maxPerLayer > 0; std::vector work = pts; @@ -133,26 +227,28 @@ namespace total = calc_item_count(work, snap.box, &detail, &top_img, &tw, &th, &tlv, &tarea); } - std::cout << "[TCP] count结束: task_id=" << snap.task_id + std::cout << "[TCP][" << snap.radar << "] count结束: task_id=" << snap.task_id << ", 点数=" << pts.size() << ", 结果=" << total << std::endl; std::ostringstream r; r << "{\"code\":200,\"message\":\"count done\",\"count\":" << total; if (!snap.task_id.empty()) r << ",\"task_id\":\"" << snap.task_id << "\""; - r << "}"; + r << ",\"radar\":\"" << snap.radar << "\"}"; send_reply(target, resp, r.str()); // 后台保存 PCD/图片/日志(不阻塞响应) + // 最后一个参数 pts 是"插值前"的原始点集,用于另存 count_data_before.pcd 做对比 if (!pts.empty() && valid_box) - save_count_files_async(work, top_img, tw, th, snap.box, detail, - total, tlv, tarea); + save_count_files_async(snap.radar, work, top_img, tw, th, snap.box, detail, + total, tlv, tarea, &pts); } static void finish_check(const SessionState& snap, const std::vector& pts, SOCKET target, std::string* resp = nullptr) { + RadarScope scope(snap.radar); // 绑定该雷达配置(截面参数/多边形) // 输出目录 data/日期/时间_taskId(由 io_utils 统一创建) std::string out_dir = make_task_dir(snap.task_id); DetectResult res; @@ -175,9 +271,9 @@ namespace } if (!snap.task_id.empty()) r << ",\"task_id\":\"" << snap.task_id << "\""; - r << "}"; + r << ",\"radar\":\"" << snap.radar << "\"}"; send_reply(target, resp, r.str()); - std::cout << "[TCP] check结束: task_id=" << snap.task_id + std::cout << "[TCP][" << snap.radar << "] check结束: task_id=" << snap.task_id << ", 点数=" << pts.size() << (res.valid ? ", 检测有效" : ", 检测无效") << std::endl; } @@ -193,45 +289,60 @@ namespace } // ==================== 自动到时停止线程 ==================== + // 多雷达:每 100ms 轮询一次所有雷达的会话,到点的各自独立停止并回包。 static void auto_stop_loop() { - for (;;) + WorkerGuard wg; // 计入活跃工作线程,退出时被等待 + while (!g_stop.load()) { - std::unique_lock lk(g_cv_mtx); - if (!g_armed) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + std::vector due; { - g_cv.wait(lk); - continue; + std::lock_guard lk(g_life); + auto now = std::chrono::steady_clock::now(); + for (const auto& kv : g_states) + { + const SessionState& st = kv.second; + if (st.active && st.auto_stop && now >= st.deadline) + due.push_back(kv.first); + } } - auto deadline = g_deadline; - if (g_cv.wait_until(lk, deadline) != std::cv_status::timeout) - continue; // 被通知:可能已取消/重设,回到顶部 - g_armed = false; // 到点 - lk.unlock(); - - SessionState snap; - std::vector pts; - if (claim_stop_and_fetch(true, &snap, &pts)) + for (const auto& id : due) { - std::cout << "[TCP] 扫描到时自动停止: function=" << snap.function + SessionState snap; + std::vector pts; + if (!claim_stop_and_fetch(id, true, &snap, &pts)) continue; + std::cout << "[TCP][" << id << "] 扫描到时自动停止: function=" << snap.function << ", task_id=" << snap.task_id << std::endl; - if (snap.owner != INVALID_SOCKET) - finish_and_reply(snap, pts, snap.owner); - else + // 异常必须就地吞掉:本线程是 detached,异常逃逸会直接终止进程 + try + { + RadarScope scope(id); + if (snap.owner != INVALID_SOCKET) + finish_and_reply(snap, pts, snap.owner); + else + { + // HTTP 模式无长连接可推送,结果只打印并留痕 + std::string out; + finish_and_reply(snap, pts, INVALID_SOCKET, &out); + std::cout << "[HTTP][" << id << "] scanTime到时自动停止, 无连接可推送, 结果: " + << out << std::endl; + } + } + catch (const std::exception& e) { - // HTTP 模式无长连接可推送,结果只打印并留痕 - std::string out; - finish_and_reply(snap, pts, INVALID_SOCKET, &out); - std::cout << "[HTTP] scanTime到时自动停止, 无连接可推送, 结果: " - << out << std::endl; + std::cerr << "[TCP][" << id << "] 自动停止处理结果异常: " << e.what() << std::endl; } + catch (...) { std::cerr << "[TCP][" << id << "] 自动停止处理结果未知异常" << std::endl; } } } } // ==================== 命令处理 ==================== static void handle_scan_start(const std::string& msg, SOCKET client, - std::string* resp = nullptr) + std::string* resp, const std::string& radar_id, + RadarNode* node) { std::string fn; json_get_string(msg, "function", fn); @@ -256,19 +367,28 @@ namespace ack << ",\"task_id\":\"" << task_id << "\""; if (scan_time > 0) ack << ",\"scanTime\":" << (int)scan_time; - ack << "}"; + ack << ",\"radar\":\"" << radar_id << "\"}"; + + if (!node->connected.load()) + { + std::string fail = "radar not connected: " + radar_id; + std::cerr << "[TCP][" << radar_id << "] " << fail << std::endl; + send_reply(client, resp, "{\"code\":503,\"message\":\"" + fail + "\"}"); + return; + } bool started = false; std::string fail; { std::lock_guard lk(g_life); - // 扫描运行中收到新的 start:强制重启(旧扫描结果丢弃) - if (discard_current_locked()) - std::cout << "[TCP] 扫描运行中被新命令强制重启: function=" << fn + // 该雷达扫描运行中收到新的 start:强制重启(旧扫描结果丢弃) + if (discard_current_locked(radar_id)) + std::cout << "[TCP][" << radar_id << "] 扫描运行中被新命令强制重启: function=" << fn << ", task_id=" << task_id << std::endl; - g_scan.reset(); - if (!g_scan.start(*g_driver)) + node->session.reset(); + // 传入 io_mtx:采集取帧与后台重连互斥,杜绝两线程并发操作同一 driver + if (!node->session.start(*node->driver, radar_id, &node->io_mtx)) { fail = "scan start failed"; } @@ -276,6 +396,7 @@ namespace { SessionState ns; ns.active = true; + ns.radar = radar_id; ns.function = fn; ns.box = box; ns.task_id = task_id; @@ -287,13 +408,7 @@ namespace ns.deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds((long)(scan_time * 1000.0)); } - g_state = ns; - if (ns.auto_stop) - { - std::lock_guard ck(g_cv_mtx); - g_armed = true; - g_deadline = ns.deadline; - } + g_states[radar_id] = ns; g_cv.notify_all(); started = true; } @@ -301,36 +416,36 @@ namespace if (!started) { - std::cerr << "[TCP] " << fail << std::endl; + std::cerr << "[TCP][" << radar_id << "] " << fail << std::endl; send_reply(client, resp, "{\"code\":400,\"message\":\"" + fail + "\"}"); return; } - std::cout << "[TCP] 扫描开始: function=" << fn + std::cout << "[TCP][" << radar_id << "] 扫描开始: function=" << fn << ", task_id=" << task_id << (scan_time > 0 ? ", 到时自动停止" : ", 等待stop") << std::endl; send_reply(client, resp, ack.str()); } static void handle_scan_stop(const std::string& msg, SOCKET client, - std::string* resp = nullptr) + std::string* resp, const std::string& radar_id) { std::string fn; json_get_string(msg, "function", fn); SessionState snap; std::vector pts; - if (!claim_stop_and_fetch(false, &snap, &pts)) + if (!claim_stop_and_fetch(radar_id, false, &snap, &pts)) { // 未在扫描(从未 start / 已停止 / 自动到时已完成) std::ostringstream r; r << "{\"code\":200,\"message\":\"scan not running\""; if (fn == "count") r << ",\"count\":0"; - r << "}"; + r << ",\"radar\":\"" << radar_id << "\"}"; send_reply(client, resp, r.str()); return; } - std::cout << "[TCP] 收到stop: function=" << snap.function + std::cout << "[TCP][" << radar_id << "] 收到stop: function=" << snap.function << ", task_id=" << snap.task_id << std::endl; finish_and_reply(snap, pts, client, resp); } @@ -340,55 +455,75 @@ namespace { (void)msg; std::cout << "[TCP] 收到config命令,重新读取 config/config ..." << std::endl; - load_config(); - reload_speed_profile(); // cfg.scan.z_speed_type=configuration 时重新读取 speed.txt - std::cout << "[TCP] 配置已重载: 采集=" << cfg.scan.collect_duration_s - << "s, 速度模式=" << cfg.scan.z_speed_type - << ", 速度=" << cfg.scan.z_speed << "cm/s, 插值=" << cfg.count.z_interpolate_mode + if (!load_config()) // 失败时保留原配置,绝不让进程退出 + { + std::cerr << "[TCP] 配置重载失败,沿用现有配置" << std::endl; + send_reply(client, resp, + "{\"code\":500,\"message\":\"config reload failed\"}"); + return; + } + reload_speed_profile(); // radar_cfg().scan.z_speed_type=configuration 时重新读取 speed.txt + radar_mgr.sync_config(); // 配置里的雷达增删:新增节点后台连接,删除的节点停用 + std::cout << "[TCP] 配置已重载: 雷达" << cfg.ids.size() << "台:"; + for (const auto& id : cfg.ids) std::cout << " " << id; + std::cout << std::endl; + std::cout << "[TCP] 配置已重载: 采集=" << radar_cfg().scan.collect_duration_s + << "s, 速度模式=" << radar_cfg().scan.z_speed_type + << ", 速度=" << radar_cfg().scan.z_speed << "cm/s, 插值=" << radar_cfg().count.z_interpolate_mode << ", 保留=" << cfg.log.retain_days << "天, 端口=" << cfg.server.port << std::endl; - std::cout << "[TCP] count容差: 基准=" << cfg.count.base_tolerance - << "mm + 每层" << cfg.count.per_level_tolerance << "mm" - << ", 地板高=" << cfg.count.floor_height << "mm" - << ", SparseCut=" << (cfg.filter.sparse_cut.enable ? "开" : "关") - << "(X±" << cfg.filter.sparse_cut.x_radius << "mm,同圈>=" << cfg.filter.sparse_cut.circle_min - << ",跨圈>=" << cfg.filter.sparse_cut.run_min << ")" - << ", TailCut=" << (cfg.filter.tail_cut.enable ? "开" : "关") << std::endl; + std::cout << "[TCP] count容差: 基准=" << radar_cfg().count.base_tolerance + << "mm + 每层" << radar_cfg().count.per_level_tolerance << "mm" + << ", 地板高=" << radar_cfg().count.floor_height << "mm" + << ", SparseCut=" << (radar_cfg().filter.sparse_cut.enable ? "开" : "关") + << "(X±" << radar_cfg().filter.sparse_cut.x_radius << "mm,同圈>=" << radar_cfg().filter.sparse_cut.circle_min + << ",跨圈>=" << radar_cfg().filter.sparse_cut.run_min << ")" + << ", TailCut=" << (radar_cfg().filter.tail_cut.enable ? "开" : "关") << std::endl; send_reply(client, resp, "{\"code\":200,\"message\":\"config reloaded\"}"); } // ConfigureSpeed: 标定生成 config/speed.txt(雷达横放时,每帧Y最大值=行进距离)。 - // 请求: {"function":"ConfigureSpeed","scanTime":N},N缺省用 cfg.scan.collect_duration_s。 + // 请求: {"function":"ConfigureSpeed","scanTime":N},N缺省用 radar_cfg().scan.collect_duration_s。 // 标定期间与扫描会话互斥(全程持 g_life,其他命令排队)。 static void handle_configure_speed(const std::string& msg, SOCKET client, - std::string* resp = nullptr) + std::string* resp, const std::string& radar_id, + RadarNode* node) { double scan_time = 0.0; json_get_number(msg, "scanTime", scan_time); - double seconds = (scan_time > 0) ? scan_time : (double)cfg.scan.collect_duration_s; + double seconds = (scan_time > 0) ? scan_time : (double)radar_cfg().scan.collect_duration_s; if (seconds <= 0.0) seconds = 5.0; - std::cout << "[TCP] 收到ConfigureSpeed: 采集" << seconds + std::cout << "[TCP][" << radar_id << "] 收到ConfigureSpeed: 采集" << seconds << "s 标定变速剖面(每帧Y最大值) ..." << std::endl; + if (!node->connected.load()) + { + std::string fail = "radar not connected: " + radar_id; + std::cerr << "[TCP][" << radar_id << "] " << fail << std::endl; + send_reply(client, resp, "{\"code\":503,\"message\":\"" + fail + "\"}"); + return; + } + int rows = -1; std::string err; { std::lock_guard lk(g_life); - if (discard_current_locked()) // 扫描运行中先强制停止(本函数持锁) - std::cout << "[TCP] 扫描运行中被ConfigureSpeed中断" << std::endl; - rows = collect_speed_profile(*g_driver, seconds, err); + if (discard_current_locked(radar_id)) // 扫描运行中先强制停止(本函数持锁) + std::cout << "[TCP][" << radar_id << "] 扫描运行中被ConfigureSpeed中断" << std::endl; + rows = collect_speed_profile(*node->driver, seconds, err); } if (rows < 0) { - std::cerr << "[TCP] ConfigureSpeed失败: " << err << std::endl; + std::cerr << "[TCP][" << radar_id << "] ConfigureSpeed失败: " << err << std::endl; send_reply(client, resp, "{\"code\":400,\"message\":\"configure speed failed\"}"); return; } reload_speed_profile(); // 重新载入新生成的剖面(若已是configuration则立即生效) - std::cout << "[TCP] speed.txt 已更新: " << rows + std::cout << "[TCP][" << radar_id << "] speed.txt 已更新: " << rows << " 帧样本 (采集" << seconds << "s)" << std::endl; std::ostringstream r; r << "{\"code\":200,\"message\":\"configure speed done\",\"rows\":" << rows << ",\"duration\":" << (int)seconds + << ",\"radar\":\"" << radar_id << "\"" << ",\"file\":\"config/speed.txt\"}"; send_reply(client, resp, r.str()); } @@ -409,9 +544,24 @@ namespace handle_config(msg, client, resp); return true; } + + // 其余命令需要指定雷达:{"radar":"radar1"};缺省用第一个雷达(兼容旧客户端) + std::string req_id; + json_get_string(msg, "radar", req_id); + RadarNode* node = radar_mgr.find(req_id); + if (!node) + { + std::string m = "unknown radar: " + req_id; + std::cerr << "[TCP] " << m << std::endl; + send_reply(client, resp, "{\"code\":400,\"message\":\"" + m + "\"}"); + return true; + } + const std::string radar_id = node->id; + RadarScope scope(radar_id); // 本请求全程使用该雷达的配置 + if (fn == "ConfigureSpeed" || fn == "configurespeed") { - handle_configure_speed(msg, client, resp); + handle_configure_speed(msg, client, resp, radar_id, node); return true; } if (fn != "count" && fn != "check") return false; // 未知功能忽略 @@ -420,12 +570,12 @@ namespace json_get_string(msg, "scan", scan_cmd); if (scan_cmd == "stop") { - handle_scan_stop(msg, client, resp); + handle_scan_stop(msg, client, resp, radar_id); return true; } else if (scan_cmd == "start") { - handle_scan_start(msg, client, resp); + handle_scan_start(msg, client, resp, radar_id, node); return true; } return false; // 无scan/未知scan值忽略 @@ -433,12 +583,16 @@ namespace static void handle_client(SOCKET client) { + WorkerGuard wg; // 计入活跃工作线程,退出时被等待 + ConnGuard guard(client); // 任何路径退出都关闭 socket 并归还配额 + set_sock_timeouts(client, TCP_RECV_TIMEOUT_MS, SEND_TIMEOUT_MS); + char buf[4096]; std::string rbuf; while (true) { int n = recv(client, buf, sizeof(buf) - 1, 0); - if (n <= 0) break; // 连接关闭或出错 + if (n <= 0) break; // 连接关闭 / 超时 / 出错 buf[n] = '\0'; rbuf.append(buf, n); @@ -448,21 +602,29 @@ namespace { std::string raw = rbuf.substr(0, pos); rbuf.erase(0, pos + 1); - dispatch(raw, client); + try { dispatch(raw, client); } + catch (const std::exception& e) + { + std::cerr << "[TCP] 处理命令异常: " << e.what() << std::endl; + } + catch (...) { std::cerr << "[TCP] 处理命令发生未知异常" << std::endl; } } + // 一直没出现 '>':异常客户端灌数据,防止 rbuf 无限膨胀 + if (rbuf.size() > 65536) rbuf.clear(); } // 客户端断开:若它是某自动扫描的 owner,扫描仍会到时自动停止; // 未设置 scanTime 的扫描则持续到下一次 start 强制重启或 stop。 - closesocket(client); } } // namespace -void run_tcp_server(pavo2::pavo2_driver& driver) +void run_tcp_server() { - g_driver = &driver; std::cout << "\n========== TCP服务端模式 ==========" << std::endl; std::cout << "[TCP] 监听端口: " << cfg.server.port << " | 消息以 '>' 结尾" << std::endl; + std::cout << "[TCP] 已绑定雷达" << radar_mgr.nodes().size() << "台:"; + for (const auto& id : cfg.ids) std::cout << " " << id; + std::cout << " (请求用 \"radar\" 指定,缺省用第一个)" << std::endl; SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, 0); if (listen_sock == INVALID_SOCKET) @@ -493,16 +655,43 @@ void run_tcp_server(pavo2::pavo2_driver& driver) std::cout << "[TCP] 初始化完成,等待命令 " "{\"function\":\"count\",\"scan\":\"start\",...} ..." << std::endl; - while (true) + // 主循环必须吞掉一切异常:线程创建失败等异常若逃出 main 会直接终止进程 + try { - SOCKET client = accept(listen_sock, nullptr, nullptr); - if (client == INVALID_SOCKET) + while (true) { - std::cout << "[TCP] accept失败,重试..." << std::endl; - continue; + SOCKET client = accept(listen_sock, nullptr, nullptr); + if (client == INVALID_SOCKET) + { + std::cout << "[TCP] accept失败,重试..." << std::endl; + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + continue; + } + set_sock_timeouts(client, TCP_RECV_TIMEOUT_MS, SEND_TIMEOUT_MS); + if (g_conn_count.load() >= MAX_CONNECTIONS) + { + std::cerr << "[TCP] 并发连接数已达上限 " << MAX_CONNECTIONS + << ",拒绝新连接(可能有客户端连上后不发数据)" << std::endl; + closesocket(client); + continue; + } + ++g_conn_count; + try { std::thread(handle_client, client).detach(); } + catch (...) + { + --g_conn_count; + std::cerr << "[TCP] 创建客户端处理线程失败,关闭该连接" << std::endl; + closesocket(client); + } } - std::thread(handle_client, client).detach(); } + catch (const std::exception& e) + { + std::cerr << "[TCP] 服务端主循环异常退出: " << e.what() << std::endl; + } + catch (...) { std::cerr << "[TCP] 服务端主循环未知异常退出" << std::endl; } + closesocket(listen_sock); + stop_server_workers(); // 通知并等待 detach 的工作线程退出,再交回 main 收尾 } // ==================== HTTP 服务端模式 ==================== @@ -514,7 +703,11 @@ void run_tcp_server(pavo2::pavo2_driver& driver) // start(不带scanTime) → 采集完成 → stop 拿结果。 namespace { - static bool http_read_head(SOCKET c, std::string& head, std::string& rest) + // 读 HTTP 请求头(依赖 socket 上的 SO_RCVTIMEO,超时 recv 返回 <0)。 + // 返回: 0 = 收到完整头(head=头, rest=已收到的 body 片段) + // 1 = 超时/对端半关且未收到 "\r\n\r\n"(head=已收到的全部数据, 用于TCP协议兜底) + // -1 = 出错或没有任何数据,应关闭连接 + static int http_read_head(SOCKET c, std::string& head, std::string& rest) { char buf[8192]; head.clear(); @@ -522,17 +715,18 @@ namespace while (head.size() < 65536) // 头上限 64KB,防呆 { int n = recv(c, buf, (int)sizeof(buf), 0); - if (n <= 0) return false; + if (n < 0) return head.empty() ? -1 : 1; // 超时/出错 + if (n == 0) return head.empty() ? -1 : 1; // 对端关闭(可能已发部分数据) head.append(buf, n); size_t p = head.find("\r\n\r\n"); if (p != std::string::npos) { rest = head.substr(p + 4); // 头部之后可能已带部分 body head = head.substr(0, p); - return true; + return 0; } } - return false; + return -1; } static long http_find_content_length(const std::string& head) @@ -573,10 +767,36 @@ namespace // 每个连接处理一个请求后即关闭(Connection: close) static void handle_http_client(SOCKET c) { + WorkerGuard wg; // 计入活跃工作线程,退出时被等待 + ConnGuard guard(c); // 任何路径退出都关闭 socket 并归还配额 + set_sock_timeouts(c, HTTP_RECV_TIMEOUT_MS, SEND_TIMEOUT_MS); + std::string head, rest; - if (!http_read_head(c, head, rest)) + int hr = http_read_head(c, head, rest); + if (hr < 0) return; + + // 兼容兜底:旧 TCP 客户端(直接发 "{...}" 无 HTTP 头)连到 HTTP 端口时, + // 永远等不到 "\r\n\r\n",原实现会让线程永久卡死且不打印任何日志。 + // 这里改为按 TCP 协议解析并直接回包,避免线程泄漏。 + if (hr != 0) { - closesocket(c); + std::string raw = head; + std::cout << "[HTTP] 收到非HTTP报文(" << raw.size() << "B),按TCP协议处理" << std::endl; + size_t pos; + bool replied = false; + while ((pos = raw.find('>')) != std::string::npos) + { + std::string one = raw.substr(0, pos); + raw.erase(0, pos + 1); + try { if (dispatch(one, c, nullptr)) replied = true; } + catch (const std::exception& e) + { + std::cerr << "[HTTP] 处理命令异常: " << e.what() << std::endl; + } + catch (...) { std::cerr << "[HTTP] 处理命令发生未知异常" << std::endl; } + } + if (!replied) + send_text(c, "{\"code\":400,\"message\":\"unknown command, expect HTTP POST or TCP json ending with '>'\"}"); return; } @@ -589,7 +809,6 @@ namespace { http_send_response(c, 405, "Method Not Allowed", "{\"code\":405,\"message\":\"use POST, body same as TCP protocol json\"}"); - closesocket(c); return; } @@ -598,7 +817,6 @@ namespace { http_send_response(c, 411, "Length Required", "{\"code\":411,\"message\":\"missing/invalid Content-Length\"}"); - closesocket(c); return; } @@ -619,13 +837,25 @@ namespace << brief << std::endl; std::string out; - bool handled = dispatch(msg, INVALID_SOCKET, &out); + bool handled = false; + try { handled = dispatch(msg, INVALID_SOCKET, &out); } + catch (const std::exception& e) + { + std::cerr << "[HTTP] 处理命令异常: " << e.what() << std::endl; + out = "{\"code\":500,\"message\":\"internal error\"}"; + handled = true; + } + catch (...) + { + std::cerr << "[HTTP] 处理命令发生未知异常" << std::endl; + out = "{\"code\":500,\"message\":\"internal error\"}"; + handled = true; + } if (handled) http_send_response(c, 200, "OK", out); else http_send_response(c, 400, "Bad Request", "{\"code\":400,\"message\":\"unknown command, see TCP protocol\"}"); - closesocket(c); } static void run_http_server_internal() @@ -658,24 +888,53 @@ namespace std::cout << "[HTTP] 初始化完成,等待 POST 命令 " "{\"function\":\"count\",\"scan\":\"start\",...} ..." << std::endl; - while (true) + // 主循环必须吞掉一切异常:线程创建失败等异常逃出 main 会直接终止进程 + try { - SOCKET client = accept(listen_sock, nullptr, nullptr); - if (client == INVALID_SOCKET) + while (true) { - std::cout << "[HTTP] accept失败,重试..." << std::endl; - continue; + SOCKET client = accept(listen_sock, nullptr, nullptr); + if (client == INVALID_SOCKET) + { + std::cout << "[HTTP] accept失败,重试..." << std::endl; + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + continue; + } + set_sock_timeouts(client, HTTP_RECV_TIMEOUT_MS, SEND_TIMEOUT_MS); + if (g_conn_count.load() >= MAX_CONNECTIONS) + { + std::cerr << "[HTTP] 并发连接数已达上限 " << MAX_CONNECTIONS + << ",拒绝新连接(可能有客户端连上后不发数据)" << std::endl; + closesocket(client); + continue; + } + ++g_conn_count; + try { std::thread(handle_http_client, client).detach(); } + catch (...) + { + --g_conn_count; + std::cerr << "[HTTP] 创建客户端处理线程失败,关闭该连接" << std::endl; + closesocket(client); + } } - std::thread(handle_http_client, client).detach(); } + catch (const std::exception& e) + { + std::cerr << "[HTTP] 服务端主循环异常退出: " << e.what() << std::endl; + } + catch (...) { std::cerr << "[HTTP] 服务端主循环未知异常退出" << std::endl; } + closesocket(listen_sock); } } -void run_http_server(pavo2::pavo2_driver& driver) +void run_http_server() { - g_driver = &driver; std::cout << "\n========== HTTP服务端模式 ==========" << std::endl; std::cout << "[HTTP] 监听端口: " << cfg.server.port << " | POST json body(与TCP协议相同, 不带结尾'>')" << std::endl; + std::cout << "[HTTP] 已绑定雷达" << radar_mgr.nodes().size() << "台:"; + for (const auto& id : cfg.ids) std::cout << " " << id; + std::cout << " (请求用 \"radar\" 指定,缺省用第一个)" << std::endl; run_http_server_internal(); + stop_server_workers(); // 通知并等待 detach 的工作线程退出,再交回 main 收尾 } diff --git a/tcp_server.h b/tcp_server.h index 12d1344..8cc8731 100644 --- a/tcp_server.h +++ b/tcp_server.h @@ -5,12 +5,13 @@ #include "PAVO2_SDK/include/pavo2_driver.h" // 启动 TCP 服务端(阻塞运行直到进程退出/失败)。 -// 需在 WSAStartup、load_config、connect_lidar 之后调用。 -void run_tcp_server(pavo2::pavo2_driver& driver); +// 需在 WSAStartup、load_config、radar_mgr.init() 之后调用(雷达由 radar_mgr 管理, +// 请求用 "radar" 字段指定雷达,缺省用配置里第一个雷达)。 +void run_tcp_server(); // 启动 HTTP 服务端(阻塞运行直到进程退出/失败)。 // POST json:body 与 TCP 协议消息相同(不带结尾 '>'),响应同 TCP(不带 '>')。 -// 需在 WSAStartup、load_config、connect_lidar 之后调用。 -void run_http_server(pavo2::pavo2_driver& driver); +// 需在 WSAStartup、load_config、radar_mgr.init() 之后调用。 +void run_http_server(); #endif // TCP_SERVER_H diff --git a/tcp通信协议文档.md b/tcp通信协议文档.md index 630e5ca..0eb0696 100644 --- a/tcp通信协议文档.md +++ b/tcp通信协议文档.md @@ -1,25 +1,34 @@ -# PAVO2 雷达 TCP 通信协议文档(v2 统一扫描协议) +# PAVO2 雷达 TCP 通信协议文档(v3 多雷达统一扫描协议) -> 本文档对应模块化重构后的新版协议:`count` / `check` **共用同一次采集**, -> 通过 `function` + `scan` + `scanTime` 三个字段控制扫描的开始、自动/手动停止, -> 消息统一以 `>` 结尾(不再是旧版的按行 `\n` 协议)。 +> 本文档对应**多雷达**版本协议: +> - 一台程序可绑定**多台雷达**(配置文件里每个雷达标识一份完整参数),后台异步连接、断线自动重连; +> - 所有命令用顶层 `"radar":"<雷达标识>"` 指定操作哪台雷达,**不带时默认操作第一个雷达**(兼容旧客户端); +> - `count` / `check` **共用同一台雷达的同一次采集**,通过 `function` + `scan` + `scanTime` 三个字段控制开始、自动/手动停止; +> - 消息统一以 `>` 结尾(不再是旧版的按行 `\n` 协议)。 ## 1. 概述 -程序以 **TCP 服务端模式** 运行(配置文件 `"Run_Mode": "tcp"`),监听固定端口,供上位机(Java/Python 等)下发检测(check)与计数(count)命令。 +程序以 **TCP 服务端模式** 运行(配置文件 `server.mode = "tcp"`),监听固定端口,供上位机(Java/Python 等)下发检测(check)与计数(count)命令。 - 协议类型:TCP(SOCK_STREAM,IPv4) - 监听地址:`0.0.0.0`(本机所有网卡) -- 默认端口:`9601`(配置文件 `Tcp_Port` 可改) +- 默认端口:`9601`(配置文件 `server.port` 可改) - 数据格式:**每条消息是一个 JSON 对象,以字符 `>` 结尾** - 编码:ASCII / UTF-8(不含中文字符的 JSON) - 连接方式:长连接,每个客户端独立线程处理,断开后自动清理 -**统一单次扫描模型:** +**多雷达模型:** -- `count` 与 `check` 共用同一个后台采集会话(`ScanSession`),同一时刻只有一个采集线程访问雷达,二者**互斥、不并发**。 +- 配置文件里除 `server` / `log` 外的每个一级键就是一个**雷达标识**(如 `radar1`、`radar2`),各自一份完整参数(IP/滤波/多边形/地板高/容差/层数算法等可不同)。 +- 启动时连接**全部**雷达;**连不上不阻塞主程序**,后台线程按 3 秒间隔一直重试,直到连上;已连接的雷达掉线后同样自动重连。 +- 每台雷达有**独立**的采集会话(`ScanSession`)、独立参数、独立自动到时停止,**不同雷达可并行扫描**;同一台雷达的 count / check 仍**互斥、不并发**(新 start 会强制重启该雷达的旧扫描)。 +- 雷达未连接时下发 start / ConfigureSpeed,返回 `503`(不会阻塞其它雷达)。 + +**统一单次扫描模型(单台雷达内):** + +- `count` 与 `check` 共用该雷达的同一个后台采集会话。 - 扫描的启停方式对 count / check 完全一致: - - `"scan":"start"`:启动一次采集(若已有扫描在运行,则**强制重启**,旧结果丢弃); + - `"scan":"start"`:启动一次采集(若**该雷达**已有扫描在运行,则**强制重启**,旧结果丢弃); - `"scanTime":N`(N>0):采集 N 秒后**自动停止**并回发结果,无需再发 stop; - `"scan":"stop"`:手动立即停止并回发结果(用于未带 scanTime 或需提前结束的情况)。 - 停止时刻才会执行结果计算与文件保存(z 空隙插值仅在停止后执行一次)。 @@ -46,6 +55,24 @@ - 参数缺失时按默认值处理(数值缺省为 0,字符串缺省为空)。 - `box.maxPerLayer` 可简写为 `max`(二者任一即可)。 +### 2.4 雷达标识 `radar`(多雷达必读) + +所有命令(除 `config`)都可用顶层字段 `radar` 指定操作哪台雷达: + +```json +{"function":"count","radar":"radar1","scan":"start",...} +``` + +| 情况 | 行为 | +|---|---| +| 带 `"radar":"radar1"` | 操作标识为 `radar1` 的雷达(标识 = 配置文件里的一级键名,区分大小写) | +| **不带 `radar`** | 默认操作配置里**第一个**雷达(兼容旧客户端/单雷达部署) | +| `radar` 值在配置里不存在 | `{"code":400,"message":"unknown radar: xxx"}` | +| 该雷达当前**未连接** | `{"code":503,"message":"radar not connected: radar1"}`(后台仍在自动重连,稍后重试即可) | + +- 响应中会**回带** `"radar":"radar1"`,便于上位机核对是哪台雷达的结果。 +- `config` 命令是全局的(重载配置文件 + 重新同步雷达节点),**不带** `radar` 也生效。 + ## 3. 命令说明 ### 3.1 速度剖面标定 `{"function":"ConfigureSpeed"}` @@ -54,26 +81,29 @@ **请求:** ```json -{"function":"ConfigureSpeed","scanTime":6} +{"function":"ConfigureSpeed","radar":"radar1","scanTime":6} ``` | 参数 | 类型 | 必填 | 说明 | |---|---|---|---| -| `scanTime` | number | 否 | 标定采集时长(秒)。缺省或 ≤0 时用配置 `Collect_Duration_S` | +| `radar` | string | 否 | 雷达标识,缺省用第一个雷达 | +| `scanTime` | number | 否 | 标定采集时长(秒)。缺省或 ≤0 时用该雷达配置的 `scan.collect_duration_s` | **行为:** -- 标定采集与扫描会话互斥:若当前有扫描在运行,会被强制中断后开始标定(其它命令在标定期间排队)。 -- 结束后自动重载新剖面:若当前 `Speed_Z_Type` 已是 `configuration`,下一次扫描立即按新表变速换算;否则需在配置中把 `Speed_Z_Type` 改为 `configuration` 并发送 `config` 命令生效。 +- 标定采集与该雷达的扫描会话互斥:若**该雷达**当前有扫描在运行,会被强制中断后开始标定(其它雷达的扫描不受影响)。 +- 结束后自动重载新剖面:若该雷达 `scan.z_speed_type` 已是 `configuration`,下一次扫描立即按新表变速换算;否则需在配置中把 `scan.z_speed_type` 改为 `configuration` 并发送 `config` 命令生效。 +- 剖面文件 `config/speed.txt` 为**全局共用**(不区分雷达)。 **响应:** ```json -{"code":200,"message":"configure speed done","rows":302,"duration":6,"file":"config/speed.txt"} +{"code":200,"message":"configure speed done","rows":302,"duration":6,"radar":"radar1","file":"config/speed.txt"} ``` **失败响应:** ```json {"code":400,"message":"configure speed failed"} +{"code":503,"message":"radar not connected: radar1"} ``` **`config/speed.txt` 格式(每行两列,时间须递增):** @@ -87,12 +117,14 @@ ``` - 以 `#`(或 `;`)开头的行与空行被忽略; - 时间单位 **ms**,距离单位 **mm**; -- 无此文件或行数不足 2 时,程序自动回退使用 `Speed_Z` 匀速; +- 无此文件或行数不足 2 时,程序自动回退使用 `scan.z_speed` 匀速; - 识别时间**超出表末**时,按表中**最后两行的平均速度**继续向外延伸。 ### 3.2 配置重载 `{"function":"config"}` -重新读取 `config\config` 配置文件(雷达 IP/端口、采集时长、多边形过滤区域、分层容差、插值模式、日志保留天数等全部重新加载),无需重启程序。若 `Speed_Z_Type` 为 `configuration`,同时**重新读取 `config/speed.txt`**(配合 3.1 标定的新表直接生效)。 +重新读取 `config\config` 配置文件(各雷达的 IP/端口/滤波、采集时长、多边形过滤区域、分层容差、插值模式、日志保留天数等全部重新加载),无需重启程序。若某雷达 `scan.z_speed_type` 为 `configuration`,同时**重新读取 `config/speed.txt`**(配合 3.1 标定的新表直接生效)。 + +> 该命令是**全局**的(不需要 `radar`)。重载后会同步雷达节点:配置里**新出现**的雷达自动建节点并后台连接;配置里**被删除**的雷达节点停用(正在进行的扫描会被停止)。 **请求:** ```json @@ -107,13 +139,14 @@ ### 3.3 扫描命令总体结构 `function=count / check` ```json -{"function":"count|check","scan":"start|stop","scanTime":6,"box":{...},"task_id":"t1"} +{"function":"count|check","radar":"radar1","scan":"start|stop","scanTime":6,"box":{...},"task_id":"t1"} ``` | 参数 | 类型 | 必填 | 说明 | |---|---|---|---| | `function` | string | 是 | 取 `"count"` / `"check"` / `"config"` 之一 | -| `scan` | string | start/stop 时是 | `"start"` 开始扫描;`"stop"` 立即停止并返回结果 | +| `radar` | string | 否 | 雷达标识(配置一级键名)。缺省用**第一个**雷达;不存在返回 400,未连接返回 503 | +| `scan` | string | start/stop 时是 | `"start"` 开始**该雷达**的扫描;`"stop"` 立即停止**该雷达**的扫描并返回结果 | | `scanTime` | number | 否 | 扫描时长(秒)。**>0 时到时自动停止并回发结果**,无需再发 stop;缺省或 0 时持续采集,直到收到 `scan:"stop"` | | `box.l` | number | count 必填 | 物品长 mm(单件面积 = l × w) | | `box.w` | number | count 必填 | 物品宽 mm | @@ -127,52 +160,53 @@ **请求(count 示例):** ```json -{"function":"count","scan":"start","scanTime":6,"box":{"l":154,"w":153,"h":310,"maxPerLayer":30},"task_id":"t1"} +{"function":"count","radar":"radar1","scan":"start","scanTime":6,"box":{"l":154,"w":153,"h":310,"maxPerLayer":30},"task_id":"t1"} ``` -**请求(check 示例,不带 scanTime,需手动 stop):** +**请求(check 示例,不带 scanTime,需手动 stop;不带 radar 用默认雷达):** ```json {"function":"check","scan":"start","task_id":"t1"} ``` **行为:** -1. 若当前已有扫描在运行,收到新的 start 会**强制重启**:立即停止旧扫描、丢弃其点集,用本次参数重新开始(旧任务不会返回结果)。 +1. 若**该雷达**已有扫描在运行,收到新的 start 会**强制重启**该雷达的扫描:立即停止旧扫描、丢弃其点集,用本次参数重新开始(旧任务不会返回结果)。**其它雷达的扫描不受影响**。 2. 启动成功,返回受理确认(ack): ```json -{"code":200,"message":"scan start","function":"count","task_id":"t1","scanTime":6} +{"code":200,"message":"scan start","function":"count","task_id":"t1","scanTime":6,"radar":"radar1"} ``` -(`task_id` 缺失时不带;`scanTime` >0 时回显 `scanTime`。) -3. 带 `scanTime` 时,程序内部在到点后自动停止扫描,并把结果回发给**发起该次扫描的连接**。 -4. 不带 `scanTime` 时,程序持续采集,直到收到 `scan:"stop"`(建议在同一连接上收发)。 +(`task_id` 缺失时不带;`scanTime` >0 时回显 `scanTime`;`radar` 恒回显实际操作的雷达。) +3. 带 `scanTime` 时,程序内部在到点后自动停止**该雷达**的扫描,并把结果回发给**发起该次扫描的连接**。 +4. 不带 `scanTime` 时,程序持续采集,直到收到带同一 `radar` 的 `scan:"stop"`(建议在同一连接上收发)。 -**启动失败(雷达采集线程未能启动)响应:** +**启动失败响应:** ```json {"code":400,"message":"scan start failed"} +{"code":503,"message":"radar not connected: radar1"} ``` ### 3.5 停止扫描 `{"function":"count|check","scan":"stop",...}` -手动立即停止当前扫描并返回结果。`function` 应与发起 start 时一致。 +手动立即停止**指定雷达**的当前扫描并返回结果。`function` 应与发起 start 时一致,`radar` 必须与 start 时一致(否则停的是另一台雷达,或该雷达并未在扫描)。 **请求:** ```json -{"function":"count","scan":"stop","task_id":"t1"} +{"function":"count","radar":"radar1","scan":"stop","task_id":"t1"} ``` **响应(count 结果):** ```json -{"code":200,"message":"count done","count":12,"task_id":"t1"} +{"code":200,"message":"count done","count":12,"task_id":"t1","radar":"radar1"} ``` **响应(check 结果):** ```json -{"code":200,"message":"detect done","start":{"fill_pct":85.32,"y_max":410.0,"y_median":320.5},"end":{"fill_pct":76.10,"y_max":398.0,"y_median":301.2},"task_id":"t1"} +{"code":200,"message":"detect done","start":{"fill_pct":85.32,"y_max":410.0,"y_median":320.5},"end":{"fill_pct":76.10,"y_max":398.0,"y_median":301.2},"task_id":"t1","radar":"radar1"} ``` -**当前未在扫描(从未 start / 已被停止 / 已到时自动完成)时:** +**该雷达当前未在扫描(从未 start / 已被停止 / 已到时自动完成 / radar 填错)时:** ```json -{"code":200,"message":"scan not running","count":0} +{"code":200,"message":"scan not running","count":0,"radar":"radar1"} ``` (`function=count` 时附带 `"count":0`;`check` 时只回 `scan not running`,不会误用历史数据。) @@ -185,6 +219,7 @@ | `start.y_max` | double | 起点截面 Y 最大值(mm) | | `start.y_median` | double | 起点截面 Y 中位数(mm) | | `end.*` | double | 同上,终点截面 | +| `radar` | string | 本次操作的雷达标识(恒回显) | **count 结果字段说明:** @@ -192,14 +227,15 @@ |---|---|---| | `count` | int | 物品总个数(0 表示无有效计数) | | `task_id` | string | 仅当请求中携带时才回传 | +| `radar` | string | 本次操作的雷达标识(恒回显) | ### 3.6 check 检测原理与数据产物 截面检测在 **stop 时**对本次采集的全部点执行一次: 1. Y 整体偏移(`SHIFT_Y_POSITIVE`,使最小 Y ≥ 0); -2. 起点截面 = 行进方向起始端 `Z_Range_MM` 区间内的点;终点截面 = 结束端前 `Z_Range_MM` 区间内的点; -3. 每个截面内取 Y 在 `[Y最小, Y最小 + Y_Range_MM]` 的点,生成 X-Z 二值图像,统计白色占比,并求截面点集的 Y 最大值 / 中位数; +2. 起点截面 = 行进方向起始端 `detect.z_range_mm` 区间内的点;终点截面 = 结束端前 `detect.z_range_mm` 区间内的点; +3. 每个截面内取 Y 在 `[Y最小, Y最小 + detect.y_range_mm]` 的点,生成 X-Z 二值图像,统计白色占比,并求截面点集的 Y 最大值 / 中位数; 4. 结果回发(见 3.5),文件保存到任务目录(不阻塞响应)。 **任务目录:** `data/YYYYMMDD/HHMMSS_taskId/`(`task_id` 为空时名为 `HHMMSS_`;`task_id` 建议避免含 `/ \ : * ? " < > |` 等路径非法字符)。 @@ -208,7 +244,7 @@ | 文件 | 说明 | |---|---| -| `log.txt` | 检测日志(`Log_Flag` 开启时写入,UTF-8) | +| `log.txt` | 检测日志(`log.enable` 开启时写入,UTF-8) | | `lidar_data.pcd` | 本次采集的原始点云(x_mm, y_mm, z_val) | | `lidar_data_shifted.pcd` | Y 偏移后的点云 | | `lidar_slice_start.pcd` / `.jpg` | 起点截面点云 / X-Z 二值图 | @@ -221,45 +257,60 @@ **计数原理(供上位机理解返回值):** -1. 高度 = `Floor_Height - Y轴`;层级 `level = round(高度 / box.h)`,最多统计 5 层; -2. 分层容差:仅把 `|高度 - level×box.h| ≤ 容差` 的点归入该层;容差 = `Count_Base_Tolerance + level × Count_Additional_Tolerance_Per_Level`(第 1 层即初始冗余,每高一层递增); -3. 每层记录点数与理论高度范围(`level×h ± 容差`); +> 分层方式由**该雷达**配置的 `count.layer_algorithm` 决定;三种算法共用同一容差: +> `容差 tol(level) = count.base_tolerance + level × count.per_level_tolerance` + +1. 高度 = `count.floor_height - Y轴`;最多统计 5 层; +2. 分层方式(三选一): + - `LevelTolerance`(默认):`level = round(高度 / box.h)`,仅把 `|高度 - level×box.h| ≤ tol` 的点归入该层(低于 `0.5×h` 的点不归层); + - `HalfMax`:两步判定 —— ①粗筛区间 `[(level-0.5)×box.h, (level+0.5)×box.h]`(本层中间 ~ 下一层中间);②取①内最高点 `top`,有效区间 `[top - tol, top]`; + - `KdePeak`:两步判定 —— ①粗筛同 `HalfMax`;②对①内高度做 KDE(高斯核密度估计)得密度曲线,取最密处高度 `peak`,有效区间 `[peak - tol, peak + tol]`(`count.kde_bandwidth` = 0 时带宽按 Silverman 自动;`count.kde_step` 为密度曲线扫描步长 mm); +3. 每层记录点数、有效高度区间与该层实际最大高度; 4. 从最高层(层 5)向下找**有效层**(点数 > 1000 且投影面积 > 0),该层件数 `i = min(round(面积 / (box.l × box.w)), maxPerLayer)`;`i > 0` 时: `总个数 = maxPerLayer × (level - 1) + i`; 5. 无有效层或未真正 start 过时返回 `count:0`。 -**z 空隙插值(count 模式):** 雷达断流 / 瞬间丢包会在 z 方向产生空白带。停止采集后按 `Z_Interpolate_Mode` 修复: +**日志 `log.txt` 分层行格式:** +`层N: 点数=…, 高度范围(lo,hi), 最大高度=…, 面积=…mm^2, 个数…个` +其中 `最大高度` 是该层有效点的实际最大 `floor_height - Y`(`HalfMax` 下即 `top`)。 + +**z 空隙插值(count 模式):** 雷达断流 / 瞬间丢包会在 z 方向产生空白带。停止采集后按 `count.z_interpolate_mode` 修复: - `none`:不插值; - `CopyTranslation` / `AngleBucketInterpolation`:对超过 2×正常圈间隔且满足同层约束(相邻圈 Y 均值差 ≤ 30 mm、X 落在相邻圈范围内)的空隙补行,最多补 10 行。 -- 正常圈间隔:`configuration`(变速)模式用 `speed.txt` 剖面平均速度(mm/s)÷`Lidar_Hz`(mm/圈)近似;否则 `Speed_Z>0` 时为 `Speed_Z×10/Lidar_Hz`(mm),`Speed_Z=0`(时间模式)时为 `1000/Lidar_Hz`(ms)。 +- 正常圈间隔:`configuration`(变速)模式用 `speed.txt` 剖面平均速度(mm/s)÷`radar.hz`(mm/圈)近似;否则 `scan.z_speed>0` 时为 `scan.z_speed×10/radar.hz`(mm),`scan.z_speed=0`(时间模式)时为 `1000/radar.hz`(ms)。 **输出文件(count):** | 文件 | 说明 | |---|---| -| `count_data.pcd` | 采集点云(Y 已转为高度 = `Floor_Height - Y`) | +| `count_data.pcd` | 采集点云(Y 已转为高度 = `count.floor_height - Y`) | | `count_points.jpg` | 全部点 X-Z 平面投影图(尺寸按实际跨度生成) | | `count_layer.jpg` | 被计数层(最高有效层)图像(有有效层时) | -| `log.txt` | 日志:task_id、box 参数、分层情况(各层点数/高度范围/面积)、计数结果(`Log_Flag` 开启时) | +| `log.txt` | 日志:task_id、雷达标识、box 参数、分层情况(各层点数/高度范围/最大高度/面积)、计数结果(`log.enable` 开启时) | ### 3.8 未知 / 非法命令 | 情况 | 行为 | |---|---| -| 无 `function` 字段 / `function` 不是 `count` / `check` / `config` | 无响应(忽略) | +| 无 `function` 字段 / `function` 不是 `count` / `check` / `config` / `ConfigureSpeed` | 无响应(忽略) | | 有 `function` 但无 `scan` 或 `scan` 不是 `start` / `stop` | 无响应(忽略) | +| `radar` 指定的标识在配置里不存在 | `{"code":400,"message":"unknown radar: xxx"}` | | `scan:"start"` 但采集线程启动失败 | `{"code":400,"message":"scan start failed"}` | +| `scan:"start"` / `ConfigureSpeed` 但该雷达当前未连接 | `{"code":503,"message":"radar not connected: radar1"}` | ## 4. 错误码汇总 | code | message | 场景 | |---|---|---| | 200 | `config reloaded` | 配置重载成功(function=config) | -| 200 | `scan start` | 扫描受理成功(ack,回显 function / task_id / scanTime) | -| 200 | `count done` | count 扫描停止,返回计数结果 | -| 200 | `detect done` | check 扫描停止,返回截面检测结果 | -| 200 | `scan not running` | 收到 stop 但当前无扫描在运行 | +| 200 | `scan start` | 扫描受理成功(ack,回显 function / task_id / scanTime / radar) | +| 200 | `count done` | count 扫描停止,返回计数结果(含 radar) | +| 200 | `detect done` | check 扫描停止,返回截面检测结果(含 radar) | +| 200 | `scan not running` | 收到 stop 但该雷达无扫描在运行(含 radar) | +| 400 | `unknown radar: xxx` | `radar` 标识在配置里不存在 | | 400 | `scan start failed` | 扫描采集线程启动失败 | +| 400 | `configure speed failed` | 速度剖面标定失败(未收到有效雷达帧) | +| 503 | `radar not connected: xxx` | 该雷达当前未连接(后台持续重连中,稍后重试) | ## 5. 典型调用流程 @@ -273,80 +324,138 @@ ### 5.2 检测(check,固定时长自动停止) ``` -客户端 → {"function":"check","scan":"start","scanTime":6,"task_id":"c1"}> -服务端 → {"code":200,"message":"scan start","function":"check","task_id":"c1","scanTime":6}> +客户端 → {"function":"check","radar":"radar1","scan":"start","scanTime":6,"task_id":"c1"}> +服务端 → {"code":200,"message":"scan start","function":"check","task_id":"c1","scanTime":6,"radar":"radar1"}> (采集 6 秒后自动停止……) -服务端 → {"code":200,"message":"detect done","start":{...},"end":{...},"task_id":"c1"}> +服务端 → {"code":200,"message":"detect done","start":{...},"end":{...},"task_id":"c1","radar":"radar1"}> (文件保存到 data/20260902/143000_c1/) ``` ### 5.3 物品计数(count,手动 stop) ``` -客户端 → {"function":"count","scan":"start","box":{"l":154,"w":153,"h":310,"maxPerLayer":30},"task_id":"t1"}> -服务端 → {"code":200,"message":"scan start","function":"count","task_id":"t1"}> - (等待采集,期间可再发 start 强制重启 / 换参数……) -客户端 → {"function":"count","scan":"stop","task_id":"t1"}> -服务端 → {"code":200,"message":"count done","count":12,"task_id":"t1"}> +客户端 → {"function":"count","radar":"radar1","scan":"start","box":{"l":154,"w":153,"h":310,"maxPerLayer":30},"task_id":"t1"}> +服务端 → {"code":200,"message":"scan start","function":"count","task_id":"t1","radar":"radar1"}> + (等待采集,期间可再发同一 radar 的 start 强制重启 / 换参数……) +客户端 → {"function":"count","radar":"radar1","scan":"stop","task_id":"t1"}> +服务端 → {"code":200,"message":"count done","count":12,"task_id":"t1","radar":"radar1"}> (后台保存 count_data.pcd / count_points.jpg / count_layer.jpg / log.txt 到 data/20260902/143030_t1/) ``` ### 5.4 计数(固定时长自动停止) ``` -客户端 → {"function":"count","scan":"start","scanTime":10,"box":{...},"task_id":"t2"}> -服务端 → {"code":200,"message":"scan start","function":"count","task_id":"t2","scanTime":10}> +客户端 → {"function":"count","radar":"radar1","scan":"start","scanTime":10,"box":{...},"task_id":"t2"}> +服务端 → {"code":200,"message":"scan start","function":"count","task_id":"t2","scanTime":10,"radar":"radar1"}> (采集 10 秒后自动停止并回发结果,无需 stop) -服务端 → {"code":200,"message":"count done","count":27,"task_id":"t2"}> +服务端 → {"code":200,"message":"count done","count":27,"task_id":"t2","radar":"radar1"}> ``` -## 6. 配置文件主要字段(config\config,JSON 格式) +### 5.5 两台雷达并行扫描 -行首(忽略空白)以 `//` 开头的行是注释,加载时剔除。常用字段: +``` +客户端 → {"function":"count","radar":"radar1","scan":"start","scanTime":10,"box":{...},"task_id":"t1"}> +服务端 → {"code":200,"message":"scan start",...,"radar":"radar1"}> +客户端 → {"function":"check","radar":"radar2","scan":"start","scanTime":10,"task_id":"c1"}> +服务端 → {"code":200,"message":"scan start",...,"radar":"radar2"}> + (两台各自独立采集、各自到时停止,互不干扰) +服务端 → {"code":200,"message":"count done","count":27,"task_id":"t1","radar":"radar1"}> +服务端 → {"code":200,"message":"detect done","start":{...},"end":{...},"task_id":"c1","radar":"radar2"}> +``` + +### 5.6 雷达未连接 + +``` +客户端 → {"function":"count","radar":"radar2","scan":"start","scanTime":6,"box":{...}}> +服务端 → {"code":503,"message":"radar not connected: radar2"}> + (程序后台每 3 秒重试连接 radar2,连上后再发同样命令即可) +``` + +## 6. 配置文件结构(config\config,JSON 格式) + +行首(忽略空白)以 `//` 开头的行是注释,加载时剔除。 + +### 6.1 根级 map 结构 + +```json +{ + "server": { "mode": "http", "port": 9601 }, + "log": { "enable": true, "retain_days": 1, "clean_time": "06:01:30" }, + "radar1": { + "radar": {"ip":"192.168.1.170","port":2368,"hz":50,...}, + "scan": {...}, "detect": {...}, "count": {...}, "filter": {...} + }, + "radar2": { "radar": {...}, "scan": {...}, "detect": {...}, "count": {...}, "filter": {...} } +} +``` + +- `server` / `log` 是**全局段**(整进程唯一,不能每个雷达各配一份端口/日志策略); +- 其余**每个一级键就是一个雷达标识**(如 `radar1`、`radar2`),其下是该雷达的**一整套完整参数**(radar / scan / detect / count / filter),各雷达可完全不同; +- 雷达标识就是通信时 `radar` 字段的取值,**区分大小写**;**第一个**标识为默认雷达(请求不带 `radar` 时用它); +- 段内缺项用代码默认值;改配置后发 `{"function":"config"}` 热重载(新增/删除雷达也会同步)。 + +### 6.2 全局段字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `server.mode` | string | `"tcp"` = TCP 服务端;`"http"` = HTTP 服务端(POST json);其他 = exe 模式自动检测一次(用默认雷达) | +| `server.port` | number | TCP / HTTP 监听端口(默认 9601) | +| `log.enable` | bool | 是否写任务目录日志 `log.txt` | +| `log.retain_days` | number | 任务目录保留天数;启动时与每日定时自动清理更早的目录 | +| `log.clean_time` | string | 每日日志定时清理时刻,格式 `"HH:MM:SS"`。运行期发 `config` 命令重载后下一轮生效 | + +### 6.3 每个雷达的参数(`<标识>` 段内) | 字段 | 类型 | 说明 | |---|---|---| -| `Leida_IP` / `Leida_Port` | string / number | 雷达 IP 与数据端口 | -| `Collect_Duration_S` | number | 默认扫描时长(秒)。**exe 模式**下自动采集该时长后做一次 check;TCP 模式下仅作参考(不带 `scanTime` 的 start 仍等手动 stop) | -| `Speed_Z_Type` | string | z 轴速度来源模式。`uniformSpeed` = 匀速(用 `Speed_Z`);`configuration` = 变速(用 `config/speed.txt`,缺表时回退 `Speed_Z`) | -| `Speed_Z` | number | Z 轴速度(cm/s)。0 = z 轴为时间(ms);>0 = z 轴为移动距离(mm)。仅 `uniformSpeed` 模式直接生效 | -| `Lidar_Hz` | number | 雷达帧率(Hz),用于正常圈间隔与截面矩形长度计算 | -| `Z_Range_MM` / `Y_Range_MM` | number | 截面检测区间长度(mm) | -| `Rect_Width_PX` / `Rect_Length_Ratio` | number | 截面矩形宽度(px)/ 长度倍率 | -| `Run_Mode` | string | `"tcp"` = TCP 服务端;`"http"` = HTTP 服务端(POST json);其他 = exe 模式自动检测一次 | -| `Tcp_Port` | number | TCP / HTTP 监听端口 | -| `Filter_Polygon` | array | 过滤多边形顶点(mm,至少 3 个顶点) | -| `Floor_Height` | number | 地板高度(mm),物品高度 = `Floor_Height - Y` | -| `Count_Base_Tolerance` | number | count 分层初始冗余(第 1 层容差,mm) | -| `Count_Additional_Tolerance_Per_Level` | number | count 每层递增冗余(mm) | -| `Point_Dilate` | number | 画 layer 图每个点膨胀的像素(Point_Dilate × Point_Dilate) | -| `Z_Interpolate_Mode` | string | `none` / `CopyTranslation` / `AngleBucketInterpolation` | -| `Log_Retain_Days` | number | 任务目录保留天数;程序启动时与每日定时(`Log_Clean_Hour`/`Log_Clean_Minute`)自动清理更早的目录 | -| `Log_Clean_Hour` / `Log_Clean_Minute` | number | 每日日志定时清理时刻(时 0-23 / 分 0-59,默认 1:00)。运行期发 `config` 命令重载后下一轮生效 | -| `Tuo_Wei` / `Qian_Chong` / `Yuan_Duan` / `Jin_Duan` | number / bool | 雷达滤波参数(拖尾 / 前冲 / 远端高反 / 近端高反) | +| `radar.ip` / `radar.port` | string / number | 该雷达 IP 与数据端口 | +| `radar.hz` | number | 雷达帧率(Hz),用于正常圈间隔与截面矩形长度计算 | +| `radar.tail_filter` | number | 拖尾滤波 0=关 1=弱 2=中 3=强 | +| `radar.front_filter` | number | 前冲滤波 0=关 1=弱 2=中 3=强 | +| `radar.far_hr_filter` | number | 远端高反滤波 0=关 1=弱 2=强 | +| `radar.near_hr_filter` | bool | 近端高反识别 | +| `scan.collect_duration_s` | number | 默认扫描时长(秒)。exe 模式下自动采集该时长做一次 check;TCP/HTTP 模式下不带 `scanTime` 的 start 仍等手动 stop | +| `scan.z_speed_type` | string | `uniformSpeed` = 匀速(用 `z_speed`);`configuration` = 变速(用 `config/speed.txt`,缺表回退 `z_speed`) | +| `scan.z_speed` | number | Z 轴速度(cm/s)。0 = z 轴为时间(ms);>0 = z 轴为移动距离(mm) | +| `detect.z_range_mm` / `detect.y_range_mm` | number | 截面检测区间长度(mm) | +| `detect.rect_width_px` / `detect.rect_length_ratio` | number | 截面矩形宽度(px)/ 长度倍率 | +| `count.floor_height` | number | 地板高度(mm),物品高度 = `floor_height - Y` | +| `count.base_tolerance` | number | 分层初始冗余(第 1 层容差,mm) | +| `count.per_level_tolerance` | number | 每层递增冗余(mm);容差 = `base_tolerance + 层号 × per_level_tolerance` | +| `count.point_dilate` | number | 画 layer 图每个点膨胀的像素(point_dilate × point_dilate) | +| `count.z_interpolate_mode` | string | `none` / `CopyTranslation` / `AngleBucketInterpolation` | +| `count.layer_algorithm` | string | 层数算法:`LevelTolerance` / `HalfMax` / `KdePeak`(见 3.7) | +| `count.kde_bandwidth` / `count.kde_step` | number | KDE 带宽(mm,0=自动)/ 密度曲线扫描步长(mm),仅 `KdePeak` 用 | +| `filter.polygon` | array | 过滤多边形顶点(mm,至少 3 个) | +| `filter.sparse_cut.*` | object | 点云级稀疏拖影过滤(推荐,默认开):`enable` / `x_radius` / `circle_min` / `run_min` | +| `filter.tail_cut.*` | object | 图片级连续段尾部减除(旧方案,默认关):`enable` / `max_tail_length` / `y_delta` / `min_run_length` / `core_min_len` / `support_rows` | ## 7. 常见问题 -- **Q:一条 TCP 连接能连续做多次扫描吗?** 可以,`start → stop → start → stop` 循环使用;每次 start 前旧的必须已停止(或会被强制重启)。 -- **Q:不带 scanTime 的 start 之后忘了发 stop 会怎样?** 扫描会一直持续,直到该连接发 `scan:"stop"` 或其它连接再发 `scan:"start"`(强制重启旧扫描)。建议每次都带 `scanTime` 或确保成对发送。 -- **Q:扫描进行中再发一次 start?** 会强制重启:旧扫描被丢弃,按新参数重新开始,旧任务不返回结果。 -- **Q:check 和 count 能并发吗?** 不能。二者共用同一个采集会话,同一时刻只能有一个扫描;新的 start 会强制重启当前扫描。 +- **Q:一条 TCP 连接能连续做多次扫描吗?** 可以,`start → stop → start → stop` 循环使用;每次 start 前该雷达旧的必须已停止(或会被强制重启)。 +- **Q:不带 scanTime 的 start 之后忘了发 stop 会怎样?** 该雷达的扫描会一直持续,直到收到带同一 `radar` 的 `scan:"stop"`,或其它连接再发**该雷达**的 `scan:"start"`(强制重启)。建议每次都带 `scanTime` 或确保成对发送。 +- **Q:扫描进行中再发一次 start?** 同一雷达会强制重启:旧扫描被丢弃,按新参数重新开始,旧任务不返回结果。不同雷达之间互不影响。 +- **Q:check 和 count 能并发吗?** 同一台雷达不能(共用该雷达的采集会话);**不同雷达可以并行**(各自独立会话与自动停止)。 +- **Q:怎么操作第 2 台雷达?** 请求加 `"radar":"radar2"`(取值 = 配置文件里的一级键名)。不带 `radar` 时操作配置里的第一个雷达。 +- **Q:雷达没连上 / 中途掉线会怎样?** 程序启动时不阻塞,后台每 3 秒重连一次直到连上;掉线后同样自动重连(日志打印 `[连接][radar1] 连接已断开,准备重连`)。未连接时下发 start / ConfigureSpeed 会返回 `503`,连上后重试即可。 +- **Q:怎么加一台雷达?** 在 `config\config` 里复制一个雷达段(改标识名与 IP/参数),重启程序或发 `{"function":"config"}` 热重载即可;新雷达会自动后台连接。 +- **Q:两台雷达的参数能不一样吗?** 能。每个雷达段是完整一套参数(IP、滤波、多边形、地板高、容差、层数算法、截面参数等),互不影响。 ## 8. HTTP 运行模式(可选,替代 TCP 客户端调试) -`Run_Mode` 设为 `"http"` 后,程序改用 HTTP 协议监听 `Tcp_Port`,方便浏览器 / Postman / 网页上位机直连,无需自定义 TCP 客户端。 +`server.mode` 设为 `"http"` 后,程序改用 HTTP 协议监听 `server.port`,方便浏览器 / Postman / 网页上位机直连,无需自定义 TCP 客户端。多雷达规则与 TCP 完全一致(同样用顶层 `radar` 指定雷达)。 ### 8.1 请求 - 方法:`POST`(其他方法返回 `405`) -- URL:`http://<服务器IP>:/`(路径任意,服务端不校验) +- URL:`http://<服务器IP>:/`(路径任意,服务端不校验) - 请求头:`Content-Type: application/json` - Body:与 TCP 协议完全相同的 JSON 字符串,**不带结尾的 `>`**(TCP 的 `>` 只是消息边界符,HTTP 用 Content-Length 定界) 示例: ```json -{"function":"count","scan":"start","scanTime":6,"box":{"l":154,"w":153,"h":310,"maxPerLayer":30},"task_id":"t1"} +{"function":"count","radar":"radar1","scan":"start","scanTime":6,"box":{"l":154,"w":153,"h":310,"maxPerLayer":30},"task_id":"t1"} ``` ### 8.2 响应 @@ -363,14 +472,20 @@ | `scanTime` 到时自动停止并推送结果 | 支持(推送给该连接) | 不支持推送:结果打印到服务端日志,任务文件照常保存 | | 推荐流程 | `start(scanTime)` 即可 | `start`(不带 scanTime)→ 采集完成后发 `stop` 拿结果 | +> 多雷达:每台雷达各自到时停止、各自保存任务文件;HTTP 无推送,结果都只打印在服务端日志。 + ### 8.4 Postman 调试 -`config/PAVO2_HTTP_API.postman_collection.json` 为现成 Postman Collection(v2.1),导入后把集合变量 `host` 改为服务程序所在电脑 IP 即可逐条调试。 +`config/PAVO2_HTTP_API.postman_collection.json` 为现成 Postman Collection(v2.1),导入后把集合变量 `host` 改为服务程序所在电脑 IP 即可逐条调试;多雷达时在 body 里加 `"radar":"<标识>"`(或另存为不同雷达的请求)。 - **Q:收到 stop 但之前没 start 过?** 返回 `{"code":200,"message":"scan not running","count":0}`(count),不会误用历史数据。 - **Q:带 scanTime 的扫描,其连接中途断开会怎样?** 扫描仍会到时自动停止,但结果回发给已断开的 socket 会失败;不带 scanTime 的扫描则继续,直到被 start 强制重启或 stop。 -- **Q:同一秒内连续两次停止,会不会写到同一目录?** `task_id` 相同且在同一秒内完成时会写入同一目录(文件覆盖);建议任务间隔 >1 秒或使用不同 `task_id`。 -- **Q:输出目录在哪?** `data/YYYYMMDD/HHMMSS_taskId/`;超过 `Log_Retain_Days` 天的旧目录会在启动与每日 01:00 被自动清理。 -- **Q:端口是多少?** 配置文件 `Tcp_Port`,默认 `9601`。 -- **Q:如何实现变速(非匀速)运动下的测量?** 三步:① 把 `Speed_Z_Type` 设为 `configuration`;② 生成 `config/speed.txt`(用 `ConfigureSpeed` 命令让被测物跑一遍自动标定,或手工按“时间ms 距离mm”填写);③ 发送 `config` 命令重载(此后 z 按表变速插值)。无 `speed.txt` 时自动回退 `Speed_Z` 匀速。 +- **Q:同一秒内连续两次停止,会不会写到同一目录?** `task_id` 相同且在同一秒内完成时会写入同一目录(文件覆盖);多雷达并行时两台雷达若 `task_id` 相同也可能撞目录,建议不同雷达用可区分的 `task_id`(如 `r1_t1` / `r2_t1`)。 +- **Q:输出目录在哪?** `data/YYYYMMDD/HHMMSS_taskId/`;超过 `log.retain_days` 天的旧目录会在启动与每日 `log.clean_time` 时刻被自动清理。 +- **Q:端口是多少?** 配置文件 `server.port`,默认 `9601`。 +- **Q:如何实现变速(非匀速)运动下的测量?** 三步:① 把该雷达的 `scan.z_speed_type` 设为 `configuration`;② 生成 `config/speed.txt`(用 `ConfigureSpeed` 命令让被测物跑一遍自动标定,或手工按“时间ms 距离mm”填写);③ 发送 `config` 命令重载(此后 z 按表变速插值)。无 `speed.txt` 时自动回退 `scan.z_speed` 匀速。 - **Q:`speed.txt` 时间/距离的单位?** 时间毫秒(ms)、距离毫米(mm),时间列须递增;识别时间超过表末时按表内最后两行平均速度延伸。 -- **Q:`ConfigureSpeed` 和普通扫描冲突吗?** 不并发:标定开始前会强制中断进行中的扫描,标定期间其它命令排队等待。 +- **Q:`ConfigureSpeed` 和普通扫描冲突吗?** 不并发:标定开始前会强制中断**该雷达**进行中的扫描(其它雷达不受影响),标定期间其它命令排队等待。 + +--- + +> 版本: v3.0(多雷达:配置根级 map、通信带 `radar` 标识、每雷达独立会话与后台自动重连) | 更新: 2026-10-08 diff --git a/test_cfg.cpp b/test_cfg.cpp index 1c295af..0d524b5 100644 --- a/test_cfg.cpp +++ b/test_cfg.cpp @@ -4,29 +4,29 @@ int main() { load_config("./config/config"); - std::cout << "radar: " << cfg.radar.ip << ":" << cfg.radar.port - << " hz=" << cfg.radar.hz - << " tail=" << cfg.radar.tail_filter << std::endl; + std::cout << "radar: " << radar_cfg().radar.ip << ":" << radar_cfg().radar.port + << " hz=" << radar_cfg().radar.hz + << " tail=" << radar_cfg().radar.tail_filter << std::endl; std::cout << "clean_time => " << cfg.log.clean_hour << ":" << cfg.log.clean_minute << ":" << cfg.log.clean_sec << std::endl; std::cout << "log.enable=" << cfg.log.enable << " retain=" << cfg.log.retain_days << std::endl; - std::cout << "poly_n=" << cfg.filter.poly_n << std::endl; - std::cout << "tail_cut.enable=" << cfg.filter.tail_cut.enable - << " max_tail=" << cfg.filter.tail_cut.max_tail_length << std::endl; - std::cout << "sparse_cut.enable=" << cfg.filter.sparse_cut.enable - << " x_radius=" << cfg.filter.sparse_cut.x_radius - << " circle=" << cfg.filter.sparse_cut.circle_min - << " run=" << cfg.filter.sparse_cut.run_min << std::endl; + std::cout << "poly_n=" << radar_cfg().filter.poly_n << std::endl; + std::cout << "tail_cut.enable=" << radar_cfg().filter.tail_cut.enable + << " max_tail=" << radar_cfg().filter.tail_cut.max_tail_length << std::endl; + std::cout << "sparse_cut.enable=" << radar_cfg().filter.sparse_cut.enable + << " x_radius=" << radar_cfg().filter.sparse_cut.x_radius + << " circle=" << radar_cfg().filter.sparse_cut.circle_min + << " run=" << radar_cfg().filter.sparse_cut.run_min << std::endl; std::cout << "server: " << cfg.server.mode << ":" << cfg.server.port << std::endl; - std::cout << "detect: z=" << cfg.detect.z_range_mm << " y=" << cfg.detect.y_range_mm - << " w=" << cfg.detect.rect_width_px << " ratio=" << cfg.detect.rect_length_ratio << std::endl; - std::cout << "count: floor=" << cfg.count.floor_height - << " base_tol=" << cfg.count.base_tolerance - << " per_tol=" << cfg.count.per_level_tolerance - << " dilate=" << cfg.count.point_dilate - << " interp=" << cfg.count.z_interpolate_mode << std::endl; - std::cout << "scan: dur=" << cfg.scan.collect_duration_s - << " type=" << cfg.scan.z_speed_type - << " speed=" << cfg.scan.z_speed << std::endl; + std::cout << "detect: z=" << radar_cfg().detect.z_range_mm << " y=" << radar_cfg().detect.y_range_mm + << " w=" << radar_cfg().detect.rect_width_px << " ratio=" << radar_cfg().detect.rect_length_ratio << std::endl; + std::cout << "count: floor=" << radar_cfg().count.floor_height + << " base_tol=" << radar_cfg().count.base_tolerance + << " per_tol=" << radar_cfg().count.per_level_tolerance + << " dilate=" << radar_cfg().count.point_dilate + << " interp=" << radar_cfg().count.z_interpolate_mode << std::endl; + std::cout << "scan: dur=" << radar_cfg().scan.collect_duration_s + << " type=" << radar_cfg().scan.z_speed_type + << " speed=" << radar_cfg().scan.z_speed << std::endl; return 0; }