news 2026/7/15 9:29:18

基于夹角判断递归算法实现河流骨架线自动提取

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于夹角判断递归算法实现河流骨架线自动提取

1. 项目概述:从离散点云到河流骨架

在地理信息系统、水文分析乃至游戏地图生成中,我们常常会面对一个经典问题:如何从一堆看似杂乱无章的、代表河流的离散点或短线段数据中,抽取出那条清晰、连贯的主干线?这听起来像是一个简单的“连线”游戏,但实际处理起来,却充满了陷阱。数据可能带有分支、可能蜿蜒曲折、可能在局部区域点密度不均,甚至存在噪声点。手动描绘?对于大规模数据来说无异于天方夜谭。我们需要一种智能的、自动化的算法来完成这项任务。

今天要分享的,就是我在处理一个水文网络分析项目时,设计并实现的一种基于夹角判断的递归算法。它的核心思想非常直观:模拟一个“探路者”,从河流的源头(或某个端点)出发,在每一步都选择最可能延续主干方向的那个“下一跳”,通过递归的方式,一步步“走”完全程,从而将主干线提取出来。整个算法用 C++ 实现,代码清晰,逻辑严密,实测在多种复杂河网数据上都表现稳定。

这个算法特别适合以下场景:当你拥有河流的矢量线段数据(但线段是断裂的、未连接的),或者高精度的河流边缘点云数据,需要从中重建出连续的、单线的河流中心线(骨架线)。它不依赖于复杂的图像处理,纯粹从几何和拓扑关系出发,因此效率很高,也易于理解和定制。

2. 算法核心思想与设计思路拆解

2.1 问题本质与挑战分析

我们首先要把问题定义清楚。输入是什么?通常是一系列的点(Point)或者线段(Segment)。为了通用性,我们假设输入是大量的、离散的短线段集合,这些线段共同勾勒出了河流的形态,但它们彼此之间可能没有连接,或者连接关系是隐含的(例如,端点距离很近就意味着应该连接)。

核心挑战在于:

  1. 分支干扰:河流常有支流汇入。算法必须能判断哪个方向是主流(主干),哪个方向是支流,并选择主流继续前进。
  2. 方向连续性:河流主干线在局部应该是平滑的,方向不会发生剧烈突变。算法需要利用这一先验知识。
  3. 噪声与歧义:数据中可能存在偏离主干的离群点,或者在某一点处有多个距离相近的候选点,造成路径选择的歧义。
  4. 递归的终止:算法必须知道何时该停止,即当走到河流的终点(河口或源头)或陷入死胡同时。

2.2 夹角判断法:如何选择“下一跳”

这是本算法的灵魂。我们如何量化“最可能延续主干方向”?

假设我们的“探路者”已经走过了若干点,形成了当前的前进轨迹。我们取最近走过的两个点(记为P_prevP_current),其连线向量V_dir = P_current - P_prev代表了当前的前进方向

现在,从P_current点出发,周围有多个候选的下一跳点{C1, C2, C3, ...}。对于每一个候选点C_i,我们计算向量V_candidate = C_i - P_current

关键步骤来了:计算V_dirV_candidate之间的夹角(通常取绝对值在0到180度之间)。这个夹角越小,说明候选方向与当前前进方向越一致,该候选点延续主干的可能性就越大。

为什么是夹角?

  • 它直接度量了方向变化的剧烈程度。河流转弯通常是渐进的,锐角转弯常见,而接近180度的掉头几乎不可能发生在主干线上。
  • 计算简单高效,只需向量点积公式:cosθ = (V_dir · V_candidate) / (|V_dir| * |V_candidate|)。我们甚至不需要算出角度本身,直接比较余弦值即可(余弦值越大,夹角越小)。

设计考量与参数化:

  • 搜索半径:我们不会考虑P_current点周围无限远的所有点,那样效率太低且容易误连。需要设定一个合理的搜索半径R_search。这个半径应略大于数据中平均的线段长度或点间距,以确保能“捕捉”到下一个正确的点,同时又不会大到囊括无关的支流或噪声。
  • 夹角阈值:我们可以设定一个最大允许夹角θ_max(例如150度)。任何候选点,如果其方向与当前方向的夹角大于θ_max,则直接排除。这可以有效过滤掉那些明显“回头”的错误方向。
  • 距离权重:单纯看夹角可能还不够。有时两个候选点夹角相似,但一个距离很近,一个很远。通常,在夹角相近的情况下,优先选择距离更近的点,这符合“路径最短”或“局部连接”的直觉。因此,最终的“得分”函数可以是夹角余弦值和距离倒数的一个加权组合。

2.3 递归流程设计

有了选择“下一跳”的策略,递归的流程就清晰了:

  1. 初始化:确定起点。这通常需要外部输入,例如已知的河流源头坐标,或者通过算法自动寻找(比如寻找数据中最边缘的点或只有一个邻居的点作为端点)。
  2. 递归函数:设计一个函数extractMainBranch(Point current, Point previous, visitedSet)
    • 参数current是当前所在点,previous是上一个点(用于计算方向),visitedSet是已访问点的集合,防止走回头路或陷入循环。
    • 基线情况(递归终止条件): a. 在current点的搜索半径内,找不到任何未访问过夹角合格的候选点。说明走到了终点或死胡同。 b. 路径长度达到预设上限(防止意外无限递归)。 c.current点本身是预设的终点(如河口)。
    • 递归步骤: a. 将current点加入结果主干线列表和已访问集合。 b. 以current为中心,R_search为半径,找到所有未访问的候选点。 c. 利用previouscurrent计算当前方向V_dir。 d. 对每个候选点,计算其与V_dir的夹角(或得分)。 e. 选择得分最高(夹角最小,或综合得分最优)的候选点作为nextPoint。 f. 以nextPoint为新的当前点,current为新的上一个点,递归调用extractMainBranch(nextPoint, current, visitedSet)
  3. 结果收集:递归函数在每次调用时将当前点加入一个全局或引用传递的列表中。递归结束后,这个列表就是提取出的主干线点序列。

注意:这是一个单向递归,从起点走到终点。对于一条完整的河流,你可能需要分别从源头到河口、从河口到源头各执行一次,或者选择更长的那条作为最终主干。另一种更鲁棒的做法是,先找到两个端点(通过计算每个点的邻居数,邻居数为1的通常是端点),然后分别以它们为起点执行提取,最后合并或选择最优路径。

3. 关键数据结构与C++实现细节

3.1 基础数据结构定义

一个清晰的开始是定义好基础的数据结构。我们将使用 C++ STL 并结合简单的自定义结构。

#include <vector> #include <cmath> #include <unordered_set> // 表示一个二维点(可根据需要扩展到三维) struct Point { double x; double y; Point(double x_ = 0, double y_ = 0) : x(x_), y(y_) {} // 计算两点间欧氏距离的平方(避免开方,节省计算) double distSquared(const Point& other) const { double dx = x - other.x; double dy = y - other.y; return dx * dx + dy * dy; } // 向量减法 Point operator-(const Point& other) const { return Point(x - other.x, y - other.y); } // 向量点积 double dot(const Point& other) const { return x * other.x + y * other.y; } // 向量模长的平方 double normSquared() const { return x * x + y * y; } }; // 表示一条线段(输入数据可能以此形式给出) struct Segment { Point start; Point end; }; // 用于快速查找邻近点的空间索引结构(简单示例,实际可用KD-Tree、四叉树等) // 此处为简化,我们假设使用所有点的向量,并通过遍历进行邻近搜索。 // 在实际项目中,强烈建议使用空间索引来加速。 using PointCloud = std::vector<Point>;

3.2 递归核心函数实现

这是算法的核心引擎。我们假设输入是所有离散点的集合PointCloud all_points,并已经通过某种方式确定了起点startPoint

// 用于存储最终的主干线点序列 std::vector<Point> mainBranch; // 用于记录已访问的点,防止重复。使用点的内存地址或唯一ID更高效,这里用点的坐标哈希(需为Point定义哈希函数)。 // 简单起见,我们使用一个自定义的哈希函数(注意:浮点数直接哈希可能存在精度问题,生产环境需处理)。 struct PointHash { std::size_t operator()(const Point& p) const { // 一个简单的哈希组合,实际应用中可能需要更鲁棒的方法 return std::hash<double>()(p.x) ^ (std::hash<double>()(p.y) << 1); } }; struct PointEqual { bool operator()(const Point& a, const Point& b) const { // 浮点数比较使用容差 const double eps = 1e-9; return std::fabs(a.x - b.x) < eps && std::fabs(a.y - b.y) < eps; } }; using VisitedSet = std::unordered_set<Point, PointHash, PointEqual>; // 递归提取函数 void extractBranchRecursive(const Point& currentPoint, const Point& prevPoint, const PointCloud& allPoints, VisitedSet& visited, std::vector<Point>& branch, double searchRadius, double maxAngleCosine) { // 这里传入cos值,更高效 // 基线条件1:当前点已访问?理论上不应发生,但作为保护 if (visited.find(currentPoint) != visited.end()) { return; } // 将当前点加入结果和已访问集合 branch.push_back(currentPoint); visited.insert(currentPoint); // 计算当前前进方向向量 Point directionVec = currentPoint - prevPoint; double dirNormSq = directionVec.normSquared(); // 如果这是起点(prevPoint是无效点),方向向量为零,需要特殊处理 bool isStartPoint = (dirNormSq < 1e-12); // 判断是否为起点 // 在当前点周围搜索候选点 double radiusSq = searchRadius * searchRadius; Point bestCandidate; double bestScore = -std::numeric_limits<double>::infinity(); // 初始化为负无穷 bool foundCandidate = false; for (const Point& candidate : allPoints) { // 跳过已访问点 if (visited.find(candidate) != visited.end()) { continue; } // 跳过距离过远的点 double distSq = currentPoint.distSquared(candidate); if (distSq > radiusSq || distSq < 1e-12) { // 也跳过距离为0的点(即自身) continue; } // 计算候选向量 Point candidateVec = candidate - currentPoint; // 计算方向一致性得分 double score = 0.0; if (isStartPoint) { // 对于起点,没有历史方向,我们可以使用其他启发式方法。 // 例如:选择距离最近的点,或者所有候选点中,使得后续路径“最直”的点。 // 这里简化处理:起点的得分仅基于距离(距离越近得分越高) score = 1.0 / (1.0 + std::sqrt(distSq)); // 距离越近,得分越高 } else { // 对于非起点,计算夹角余弦值作为方向得分 double cosAngle = directionVec.dot(candidateVec) / (std::sqrt(dirNormSq * candidateVec.normSquared())); // 如果余弦值小于阈值,说明夹角太大,排除 if (cosAngle < maxAngleCosine) { // maxAngleCosine 是 cos(θ_max),例如 cos(150°) continue; } // 综合得分:方向一致性为主,距离为辅 // 权重系数需要根据数据调整。这里方向权重0.7,距离权重0.3 double directionScore = cosAngle; // 余弦值在[-1,1],合格时在[cosθ_max, 1] double distanceScore = 1.0 / (1.0 + std::sqrt(distSq)); // 归一化距离得分 score = 0.7 * directionScore + 0.3 * distanceScore; } // 更新最佳候选 if (score > bestScore) { bestScore = score; bestCandidate = candidate; foundCandidate = true; } } // 如果找到了合格的下一跳,则继续递归 if (foundCandidate) { extractBranchRecursive(bestCandidate, currentPoint, allPoints, visited, branch, searchRadius, maxAngleCosine); } // 如果没有找到,递归自然终止,回溯到上一层 } // 对外暴露的驱动函数 std::vector<Point> extractMainBranch(const PointCloud& points, const Point& startPoint, double searchRadius, double maxAngleDeg) { std::vector<Point> branch; VisitedSet visited; // 需要一个虚拟的上一个点。对于起点,可以将其自身稍微偏移,或者用一个非常远的方向。 // 这里采用一个简单策略:将起点的“上一个点”设置为 (startPoint.x - 1, startPoint.y),人为创造一个初始水平向右的方向。 // 这对于大多数河流数据是合理的,因为起点通常是源头,方向性不强。你也可以尝试其他启发式方法。 Point virtualPrevPoint(startPoint.x - 1.0, startPoint.y); double maxAngleCosine = std::cos(maxAngleDeg * M_PI / 180.0); // 角度转余弦值 extractBranchRecursive(startPoint, virtualPrevPoint, points, visited, branch, searchRadius, maxAngleCosine); return branch; }

3.3 参数选择与初始化技巧

算法的表现很大程度上依赖于几个关键参数和初始条件:

  1. 搜索半径searchRadius

    • 如何确定:分析你的数据。计算所有点与其最近邻点距离的平均值或中位数。searchRadius可以设为这个平均值的 2 到 3 倍。太大会导致误连支流,太小则可能无法连接断裂处。
    • 实操心得:可以写一个辅助函数来统计点云的平均最近邻距离。如果数据中缺口较大,可能需要更大的半径,但最好配合其他逻辑(如缺口 bridging)处理,而不是一味增大半径。
  2. 最大夹角maxAngleDeg

    • 典型值:通常设置在 120 度到 160 度之间。150度是一个比较宽松且常用的值,允许河流有较大的转弯。
    • 调整策略:如果发现算法在急转弯处切到了支流上,可以适当减小这个角度(如140度),迫使算法选择更平滑的转弯。但这可能会在非常弯曲的河道处提前终止。需要在测试数据上微调。
  3. 起点选择

    • 自动检测:一个健壮的系统应该能自动找到端点。可以计算每个点在searchRadius内的邻居数量。邻居数等于1的点,极有可能是端点(源头或河口)。你可以选择两个相距最远的端点分别作为起点和终点进行提取,然后选择更长的路径作为主干。
    • 手动指定:在交互式应用中,允许用户点击指定起点和终点也是常见做法。
  4. 得分函数权重

    • 代码中使用了0.7 * directionScore + 0.3 * distanceScore。这个权重比(7:3)强调方向连续性。如果你的数据点非常密集均匀,可以增大方向权重(如0.9)。如果数据稀疏、缺口多,可以增大距离权重(如0.5),让算法更倾向于连接最近的点,避免跳过缺口。

重要提示:递归深度可能很大(河流很长)。虽然现代编译器对尾递归优化支持不一,但为了安全起见,对于极长的河流,可以考虑用显式的栈(stack)来模拟递归过程,将递归函数改为迭代函数,以避免栈溢出风险。这在 C++ 中是一个良好的实践。

4. 完整工作流与周边处理

一个完整的河流主干线提取系统,不仅仅只有递归算法本身。这里给出一个从原始数据到最终结果的典型工作流。

4.1 数据预处理

原始数据可能不适合直接喂给算法。

  1. 数据加载:从 Shapefile、GeoJSON、CSV 等格式读取点或线段数据。
  2. 统一为点集:如果输入是线段(Segment),需要将线段离散化为点集。简单的方法是取线段的两个端点。但对于长线段,可能需要在中间插入点,以保证点密度均匀。插入点的密度应与你的searchRadius匹配。
  3. 去噪:使用统计方法(如 Radius Outlier Removal)移除明显偏离主群体的孤立噪声点。这些点会被误认为是候选点,干扰方向判断。
  4. 简化/抽稀:如果数据点过多,可以使用道格拉斯-普克算法等方法进行简化,在保持形状的同时减少点数,大幅提升后续处理速度。

4.2 主干线提取与后处理

  1. 端点探测:如前所述,通过邻域分析找到候选端点。
  2. 双向提取与路径选择
    PointCloud allPoints = // ... 加载并预处理后的点云 double radius = // ... 计算出的搜索半径 double maxAngle = 150.0; // 最大夹角 // 找到两个候选端点 endpointA 和 endpointB std::vector<Point> endpoints = findEndpoints(allPoints, radius); if (endpoints.size() >= 2) { Point startA = endpoints[0]; Point startB = endpoints[1]; std::vector<Point> branchA = extractMainBranch(allPoints, startA, radius, maxAngle); std::vector<Point> branchB = extractMainBranch(allPoints, startB, radius, maxAngle); // 选择点数量更多的作为主干(假设更长的路径是主干) std::vector<Point>& mainBranch = (branchA.size() >= branchB.size()) ? branchA : branchB; // 注意:branchA和branchB方向可能相反。你可能需要反转其中一个,使其从源头到河口。 }
  3. 平滑处理:递归提取出的点序列可能因为数据本身的离散性而有些“毛刺”。可以使用移动平均滤波、或样条插值,生成一条更光滑的曲线。
  4. 重采样:将平滑后的曲线按固定长度间隔进行重采样,得到最终均匀、简洁的主干线点序列。

4.3 可视化与调试

可视化是调试算法不可或缺的一环。

  • 使用开源库:在 C++ 中,可以使用像OpenCV(绘制到图像)或SFML(图形窗口)这样的库进行简单可视化。
  • 绘制元素
    • 用灰色小点绘制所有输入点。
    • 用红色圆点标出自动检测到的端点。
    • 用蓝色粗线连接递归提取出的主干线点。
    • 在每一步递归时,可以短暂暂停并绘制出当前点、搜索半径圆、候选点及其得分,这对于理解算法行为和调试参数极其有用。
  • 输出中间结果:将每一步选择的点及其得分输出到日志文件,便于分析算法在复杂分叉处是如何决策的。

5. 常见问题、优化策略与避坑指南

在实际实现和应用中,你肯定会遇到各种各样的问题。下面是我踩过的一些坑以及解决方案。

5.1 算法陷入局部循环或提前终止

  • 现象:提取出的主干线非常短,或者在某个区域来回震荡。
  • 原因与排查
    1. visitedSet失效:检查你的Point哈希和相等性判断函数。浮点数的精度问题可能导致“同一个”点被判定为不同点,从而无法被visitedSet拦截,造成循环。确保你的PointEqual使用了适当的容差(epsilon)。
    2. 搜索半径过小:半径小于点间距,导致从当前点找不到任何“未访问”的候选点(因为最近的点也被标记为已访问,但实际是同一个连接点),算法提前终止。解决方法:在判断候选点时,除了检查是否在visitedSet中,还应检查该候选点是否与当前点过于接近(例如距离小于一个极小阈值),如果是,则也应跳过,避免自循环。同时,确保搜索半径设置正确。
    3. 夹角阈值过小:在河流急转弯处,所有候选点的夹角都超过了maxAngleDeg,导致找不到合格点而终止。解决方法:适当增大maxAngleDeg,或者在急转弯处引入更复杂的判断逻辑,例如考虑“历史轨迹的平滑度”。

5.2 在分叉点选错了路(误入支流)

  • 现象:主干线走着走着拐进了一条支流。
  • 原因与对策
    1. 得分函数过于简单:只考虑当前点与上一个点的夹角,在分叉点可能不足以区分主干和支流。支流和主干在分叉点处的初始夹角可能都很小。
    2. 优化策略
      • 前瞻性(Look-ahead):不要只比较下一个点,而是考虑未来2-3个点的整体走向。例如,计算如果选择候选点A,再预测其下一个可能点,形成的折线整体方向与历史方向的偏差。这需要递归地浅度搜索,计算量会增大,但更准确。
      • 全局线索:如果数据带有流量、河流等级等信息,优先选择流量大或等级高的方向。
      • “惯性”权重:给得分函数加入一个“惯性”项,即强烈倾向于选择与历史平均方向一致的路径。可以计算最近N个点的移动平均方向。
      • 事后纠正:先让算法自由走完一条路径。如果发现路径在中途有明显的方向突变(可能是误入支流后又绕回),可以在后处理阶段进行检测和剪枝。

5.3 处理数据缺口(断裂处)

  • 现象:河流数据不连续,有较大的缺口,算法在缺口处停止。
  • 解决方案
    1. 增大搜索半径:临时增大searchRadius以跨越缺口。但需谨慎,容易误连。
    2. 缺口桥接:当算法在一点找不到候选点时,不立即终止,而是启动一个“缺口桥接”子程序。该子程序以更大半径搜索,如果找到候选点,则在当前点和找到的点之间进行线性插值,补上几个虚拟点,然后继续递归。这些虚拟点只用于路径记录,不参与后续的邻近搜索。
    3. 分段提取与连接:先使用一个较小的半径提取出各个连续的段落,然后使用更高级的算法(如最小生成树、曲线拟合延伸)将这些段落连接起来。

5.4 性能优化

当点数据量达到数十万时,每次递归都遍历全部点集是不可接受的。

  1. 空间索引:这是必须的优化。使用KD-Tree(如nanoflannFLANN库)或Octree来管理点云。在递归函数中,使用radiusSearch来快速获取搜索半径内的邻域点,时间复杂度从 O(N) 降为 O(log N)。
  2. 提前终止递归:设置一个最大递归深度或最大路径长度,作为安全阀。
  3. 并行化考虑:主干线提取本身是串行的。但可以并行处理多条独立的河流(如果数据中包含多条)。另外,在预处理阶段(如构建KD-Tree、去噪)可以并行。

5.5 C++ 实现中的工程细节

  1. 浮点数精度:始终使用double而非float进行几何计算。在比较距离、判断相等时,使用一个很小的容差值(epsilon,如1e-9)。
  2. 内存管理visitedSet会存储大量点。如果Point结构体很大,可以考虑存储点的索引(整数)而非点本身。确保点云数据allPoints以引用或常引用的形式传递,避免拷贝。
  3. 代码可配置性:将搜索半径、夹角阈值、得分权重等参数设计为可配置的,最好通过一个配置结构体传入,方便调参和实验。
  4. 单元测试:为递归函数编写单元测试,使用简单的模拟数据(如一条折线点集)验证其基本功能是否正确。特别是测试端点、分叉点、缺口等 corner case。

最后,我想分享一个最深的体会:没有一种算法能完美处理所有情况。基于夹角判断的递归算法强大而直观,是解决这类“路径追踪”问题的利器。但它本质上是一种贪婪算法,每一步都做局部最优选择。因此,它的效果非常依赖于数据质量和参数设置。在实际项目中,我通常会将此算法作为核心引擎,但会包裹一层“预处理”和“后处理”的流水线,并准备一些启发式规则来处理特定场景。例如,对于特别复杂的河网交汇区,我会先用这个算法提取出几条主要候选路径,再结合河流宽度、上下游关系等辅助信息做最终裁决。算法的价值在于它提供了一个自动化、可解释的基础框架,让工程师能够在此基础上构建更鲁棒、更专业的解决方案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/15 9:28:45

Privasis-Cleaner-4B模型训练原理:从37K指令数据到智能清理

Privasis-Cleaner-4B模型训练原理&#xff1a;从37K指令数据到智能清理 【免费下载链接】Privasis-Cleaner-4B 项目地址: https://ai.gitcode.com/hf_mirrors/nvidia/Privasis-Cleaner-4B Privasis-Cleaner-4B是一款轻量级文本清理模型&#xff0c;能够根据用户提供的清…

作者头像 李华
网站建设 2026/7/15 9:28:37

深入解析DS90UB960-Q1:FPD-Link III与CSI-2接口的电气特性与时序设计

1. 项目概述与核心价值 在汽车高级驾驶辅助系统&#xff08;ADAS&#xff09;、工业机器视觉以及多摄像头监控系统中&#xff0c;一个核心的工程挑战是如何将分布在不同位置的多个高分辨率图像传感器产生的海量数据&#xff0c;可靠、实时且低延迟地传输到中央处理单元。传统并…

作者头像 李华
网站建设 2026/7/15 9:26:25

Dependabot 版本更新引入默认冷却期,降低供应链攻击风险!

跳过内容 跳至侧边栏[ ](https://github.com) / [ 博客](https://github.blog/) * [更新日志](https://github.blog/changelog/) * [文档](https://docs.github.com/) * [客户案例](https://github.com/customer-stories)[ 试用 GitHub Copilot CLI](https://github.com/featu…

作者头像 李华
网站建设 2026/7/15 9:25:51

datalib API参考:完整掌握数据工具库的所有功能

datalib API参考&#xff1a;完整掌握数据工具库的所有功能 【免费下载链接】datalib JavaScript data utility library. 项目地址: https://gitcode.com/gh_mirrors/da/datalib datalib是一个功能强大的JavaScript数据工具库&#xff0c;提供了丰富的数据处理、分析和转…

作者头像 李华
网站建设 2026/7/15 9:23:58

企业数字化GEO技术应用风险解析

企业数字化GEO技术应用风险解析随着生成式AI搜索引擎的普及&#xff0c;GEO&#xff08;生成式引擎优化&#xff09;已成为企业数字化营销的新标配。然而&#xff0c;在技术快速迭代的过程中&#xff0c;风险与机遇并存。据QuestMobile最新行业调研显示&#xff0c;2024年Q2已有…

作者头像 李华